[
  {
    "path": ".dockerignore",
    "content": ".git\n.gitignore\nnode_modules\nDockerfile\n.dockerignore\ndist\n.cache\nsrc-tauri\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/问题反馈-功能请求.md",
    "content": "---\nname: 问题反馈/功能请求\nabout: 提交问题反馈或新功能请求\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n## 问题类型\n请在适当的选项前打 [x]\n- [ ] 🐛 Bug报告\n- [ ] ✨ 功能请求\n- [ ] 📝 文档改进\n- [ ] 🎨 UI/UX改进\n- [ ] 🧰 技术支持\n\n## 功能重要程度\n请在适当的选项前打 [x]\n- [ ] 🔴 核心功能（影响主要使用流程）\n- [ ] 🟠 主要功能（影响重要使用场景）\n- [ ] 🟡 次要功能（改善用户体验）\n- [ ] 🟢 增强功能（锦上添花）\n\n## 问题/请求详情\n请尽可能详细描述您遇到的问题或需要的功能\n\n## 复现步骤（针对Bug）\n1. \n2. \n3. \n\n## 当前行为（针对Bug）\n描述目前看到的结果\n\n## 期望行为\n描述您期望看到的结果\n\n## 运行环境\n- 操作系统: Windows 10/11, macOS, Linux等\n- 浏览器: Chrome, Firefox, Safari等（附上版本号）\n- 应用版本: \n  - 可执行文件（附上版本号）\n  - 源码（附上你使用的提交hash）\n- 设备类型: 桌面端/移动端\n\n## 截图/视频\n贴上相关截图或视频链接，帮助我们更好地理解问题\n\n## 控制台日志\n如果有浏览器控制台错误，请附上相关日志\n\n## 额外信息\n其他可能有助于解决问题的信息\n"
  },
  {
    "path": ".github/workflows/docker-image.yml",
    "content": "name: Docker Image\n\non:\n  push:\n    branches:\n      - master\n    tags:\n      - \"v*\"\n  workflow_dispatch:\n\npermissions:\n  contents: read\n  packages: write\n\njobs:\n  build-and-push:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Login to GHCR\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Docker metadata\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ghcr.io/${{ github.repository_owner }}/bili-history-frontend\n          flavor: |\n            latest=false\n          tags: |\n            type=ref,event=tag\n            type=semver,pattern={{version}}\n            type=raw,value=latest,enable={{is_default_branch}}\n\n      - name: Build and push\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          file: Dockerfile\n          platforms: linux/amd64,linux/arm64\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n\n"
  },
  {
    "path": ".github/workflows/tauri-release.yml",
    "content": "name: Release (Tauri)\n\non:\n  push:\n    tags:\n      - \"v*\"\n  workflow_dispatch:\n    inputs:\n      tag:\n        description: \"Git tag to build (e.g. v1.0.0)\"\n        required: true\n        type: string\n\npermissions:\n  contents: write\n\njobs:\n  build-tauri:\n    name: Build (${{ matrix.label }})\n    runs-on: ${{ matrix.platform }}\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - label: macos-universal\n            platform: macos-latest\n            args: --target universal-apple-darwin\n          - label: linux-x64\n            platform: ubuntu-24.04\n            args: \"\"\n          - label: windows-x64\n            platform: windows-latest\n            args: \"\"\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n        with:\n          ref: ${{ github.event.inputs.tag || github.ref }}\n\n      - name: Setup Node\n        uses: actions/setup-node@v4\n        with:\n          node-version: 20\n          cache: npm\n\n      - name: Setup Rust\n        uses: dtolnay/rust-toolchain@stable\n        with:\n          targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}\n\n      - name: Rust cache\n        uses: swatinem/rust-cache@v2\n        with:\n          workspaces: \"./src-tauri -> target\"\n\n      - name: Install Linux dependencies\n        if: matrix.platform == 'ubuntu-24.04'\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y \\\n            libwebkit2gtk-4.1-dev \\\n            libgtk-3-dev \\\n            libayatana-appindicator3-dev \\\n            librsvg2-dev \\\n            patchelf \\\n            libssl-dev \\\n            pkg-config \\\n            fakeroot \\\n            libfuse2\n\n      - name: Install Windows dependencies\n        if: matrix.platform == 'windows-latest'\n        run: choco install nsis -y\n\n      - name: Install frontend dependencies\n        run: npm install --no-package-lock --include=optional --no-fund --no-audit\n\n      - name: Build and upload release assets\n        uses: tauri-apps/tauri-action@v0.6\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        with:\n          tagName: ${{ github.event.inputs.tag || github.ref_name }}\n          releaseName: \"BiliHistoryFrontend ${{ github.event.inputs.tag || github.ref_name }}\"\n          releaseBody: \"See the assets to download and install this version.\"\n          releaseDraft: false\n          prerelease: ${{ contains(github.event.inputs.tag || github.ref_name, '-') }}\n          tauriScript: \"npx tauri\"\n          args: ${{ matrix.args }}\n          generateReleaseNotes: true\n"
  },
  {
    "path": ".gitignore",
    "content": "# generated by: https://gitignore.itranswarp.com/\n\n#################### Node.gitignore ####################\n\n# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\nlerna-debug.log*\n.pnpm-debug.log*\n\n# Diagnostic reports (https://nodejs.org/api/report.html)\nreport.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n\n# Directory for instrumented libs generated by jscoverage/JSCover\nlib-cov\n\n# Coverage directory used by tools like istanbul\ncoverage\n*.lcov\n\n# nyc test coverage\n.nyc_output\n\n# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)\n.grunt\n\n# Bower dependency directory (https://bower.io/)\nbower_components\n\n# node-waf configuration\n.lock-wscript\n\n# Compiled binary addons (https://nodejs.org/api/addons.html)\nbuild/Release\n\n# Dependency directories\nnode_modules/\njspm_packages/\n\n# Snowpack dependency directory (https://snowpack.dev/)\nweb_modules/\n\n# TypeScript cache\n*.tsbuildinfo\n\n# Optional npm cache directory\n.npm\n\n# Optional eslint cache\n.eslintcache\n\n# Optional stylelint cache\n.stylelintcache\n\n# Microbundle cache\n.rpt2_cache/\n.rts2_cache_cjs/\n.rts2_cache_es/\n.rts2_cache_umd/\n\n# Optional REPL history\n.node_repl_history\n\n# Output of 'npm pack'\n*.tgz\n\n# Yarn Integrity file\n.yarn-integrity\n\n# dotenv environment variable files\n.env\n.env.development.local\n.env.test.local\n.env.production.local\n.env.local\n\n# parcel-bundler cache (https://parceljs.org/)\n.cache\n.parcel-cache\n\n# Next.js build output\n.next\nout\n\n# Nuxt.js build / generate output\n.nuxt\ndist\n\n# Gatsby files\n.cache/\n# Comment in the public line in if your project uses Gatsby and not Next.js\n# https://nextjs.org/blog/next-9-1#public-directory-support\n# public\n\n# vuepress build output\n.vuepress/dist\n\n# vuepress v2.x temp and cache directory\n.temp\n.cache\n\n# Docusaurus cache and generated files\n.docusaurus\n\n# Serverless directories\n.serverless/\n\n# FuseBox cache\n.fusebox/\n\n# DynamoDB Local files\n.dynamodb/\n\n# TernJS port file\n.tern-port\n\n# Stores VSCode versions used for testing VSCode extensions\n.vscode-test\n\n# yarn v2\n.yarn/cache\n.yarn/unplugged\n.yarn/build-state.yml\n.yarn/install-state.gz\n.pnp.*\n\n#################### Archives.gitignore ####################\n\n# It's better to unpack these files and commit the raw source because\n# git has its own built in compression methods.\n*.7z\n*.jar\n*.rar\n*.zip\n*.gz\n*.gzip\n*.tgz\n*.bzip\n*.bzip2\n*.bz2\n*.xz\n*.lzma\n*.cab\n*.xar\n\n# Packing-only formats\n*.iso\n*.tar\n\n# Package management formats\n*.dmg\n*.xpi\n*.gem\n*.egg\n*.deb\n*.rpm\n*.msi\n*.msm\n*.msp\n*.txz\n\n#################### Backup.gitignore ####################\n\n*.bak\n*.gho\n*.ori\n*.orig\n*.tmp\n\n#################### Emacs.gitignore ####################\n\n# -*- mode: gitignore; -*-\n*~\n\\#*\\#\n/.emacs.desktop\n/.emacs.desktop.lock\n*.elc\nauto-save-list\ntramp\n.\\#*\n\n# Org-mode\n.org-id-locations\n*_archive\n\n# flymake-mode\n*_flymake.*\n\n# eshell files\n/eshell/history\n/eshell/lastdir\n\n# elpa packages\n/elpa/\n\n# reftex files\n*.rel\n\n# AUCTeX auto folder\n/auto/\n\n# cask packages\n.cask/\ndist/\n\n# Flycheck\nflycheck_*.el\n\n# server auth directory\n/server/\n\n# projectiles files\n.projectile\n\n# directory configuration\n.dir-locals.el\n\n# network security\n/network-security.data\n\n\n#################### Linux.gitignore ####################\n\n*~\n\n# temporary files which can be created if a process still has a handle open of a deleted file\n.fuse_hidden*\n\n# KDE directory preferences\n.directory\n\n# Linux trash folder which might appear on any partition or disk\n.Trash-*\n\n# .nfs files are created when an open file is removed but is still being accessed\n.nfs*\n\n#################### NotepadPP.gitignore ####################\n\n# Notepad++ backups #\n*.bak\n\n#################### PuTTY.gitignore ####################\n\n# Private key\n*.ppk\n\n#################### SublimeText.gitignore ####################\n\n# Cache files for Sublime Text\n*.tmlanguage.cache\n*.tmPreferences.cache\n*.stTheme.cache\n\n# Workspace files are user-specific\n*.sublime-workspace\n\n# Project files should be checked into the repository, unless a significant\n# proportion of contributors will probably not be using Sublime Text\n# *.sublime-project\n\n# SFTP configuration file\nsftp-config.json\nsftp-config-alt*.json\n\n# Package control specific files\nPackage Control.last-run\nPackage Control.ca-list\nPackage Control.ca-bundle\nPackage Control.system-ca-bundle\nPackage Control.cache/\nPackage Control.ca-certs/\nPackage Control.merged-ca-bundle\nPackage Control.user-ca-bundle\noscrypto-ca-bundle.crt\nbh_unicode_properties.cache\n\n# Sublime-github package stores a github token in this file\n# https://packagecontrol.io/packages/sublime-github\nGitHub.sublime-settings\n\n#################### Vim.gitignore ####################\n\n# Swap\n[._]*.s[a-v][a-z]\n!*.svg  # comment out if you don't need vector files\n[._]*.sw[a-p]\n[._]s[a-rt-v][a-z]\n[._]ss[a-gi-z]\n[._]sw[a-p]\n\n# Session\nSession.vim\nSessionx.vim\n\n# Temporary\n.netrwhist\n*~\n# Auto-generated tag files\ntags\n# Persistent undo\n[._]*.un~\n\n#################### VisualStudioCode.gitignore ####################\n\n.vscode/*\n!.vscode/settings.json\n!.vscode/tasks.json\n!.vscode/launch.json\n!.vscode/extensions.json\n!.vscode/*.code-snippets\n\n# Local History for Visual Studio Code\n.history/\n\n# Built Visual Studio Code Extensions\n*.vsix\n\n#################### Windows.gitignore ####################\n\n# Windows thumbnail cache files\nThumbs.db\nThumbs.db:encryptable\nehthumbs.db\nehthumbs_vista.db\n\n# Dump file\n*.stackdump\n\n# Folder config file\n[Dd]esktop.ini\n\n# Recycle Bin used on file shares\n$RECYCLE.BIN/\n\n# Windows Installer files\n*.cab\n*.msi\n*.msix\n*.msm\n*.msp\n\n# Windows shortcuts\n*.lnk\n\n#################### macOS.gitignore ####################\n\n# General\n.DS_Store\n.AppleDouble\n.LSOverride\n\n# Icon must end with two \\r\nIcon\n\n\n# Thumbnails\n._*\n\n# Files that might appear in the root of a volume\n.DocumentRevisions-V100\n.fseventsd\n.Spotlight-V100\n.TemporaryItems\n.Trashes\n.VolumeIcon.icns\n.com.apple.timemachine.donotpresent\n\n# Directories potentially created on remote AFP share\n.AppleDB\n.AppleDesktop\nNetwork Trash Folder\nTemporary Items\n.apdisk\n\n#################### Custom.gitignore ####################\n\n# add your custom gitignore here:\n!.gitignore\n!.gitsubmodules\n\n/src-tauri/target/\n/src-tauri/gen/\n.idea\n# tauri\ntarget\nsrc-tauri/gen\nsrc-tauri/target\n/CLAUDE.md\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM oven/bun:1 AS base\nWORKDIR /app\n\nFROM base AS install\nCOPY package.json bun.lock .\nRUN bun install\n\nFROM base AS build\nARG BACKEND_URL_ARG=http://localhost:8899\nENV VITE_DEFAULT_BACKEND_URL=${BACKEND_URL_ARG}\nCOPY . .\nCOPY --from=install /app/node_modules node_modules\nRUN bun run build\n\nFROM caddy:2-alpine AS release\nCOPY --from=build /app/dist /app\nCOPY deploy/Caddyfile /etc/caddy\n\nEXPOSE 80\n"
  },
  {
    "path": "LICENSE.md",
    "content": "MIT License\n\nCopyright (c) 2024-present 2977094657\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n  <img src=\"./public/logo.png\" alt=\"Logo\">\n</div>\n\n这是一个基于 Vue 3 开发的 B 站历史记录分析工具的前端项目，为用户提供丰富的 B 站观看历史数据分析功能。\n\n## 该项目需要配合 [BilibiliHistoryFetcher](https://github.com/2977094657/BilibiliHistoryFetcher) 后端项目一起使用\n\n## 零基础快速运行（Windows 免安装版，推荐）\n\n1. 下载 exe：后端 https://github.com/2977094657/BilibiliHistoryFetcher/releases/latest\n2. 下载 exe：前端 https://github.com/2977094657/BiliHistoryFrontend/releases/latest\n3. 两个都双击运行即可\n\n## 快速开始\n\n### 使用 Docker 安装\n\n#### 使用预构建镜像（GitHub Container Registry）\n\n```bash\ndocker pull ghcr.io/2977094657/bili-history-frontend:latest\ndocker run --name bili-history-frontend-web -p 5173:80 -d ghcr.io/2977094657/bili-history-frontend:latest\n```\n\n1. 安装[Docker](https://docs.docker.com/get-started/get-docker/).\n2. 构建镜像：`docker build -t bili-history-frontend-web:dev .`\n3. 启动容器：`docker run --name bili-history-frontend-web -p 5173:80 -d bili-history-frontend-web:dev`\n4. 停止容器：`docker stop bili-history-frontend-web`\n\n### [通过 1Panel 部署](https://github.com/2977094657/BilibiliHistoryFetcher/discussions/65)\n由社区贡献者 [@QYG2297248353](https://github.com/QYG2297248353) 实现 ([#66](https://github.com/2977094657/BilibiliHistoryFetcher/pull/66))\n### 使用源码安装\n\n1. 克隆项目\n```bash\ngit clone https://github.com/2977094657/BiliHistoryFrontend.git\ncd BilibiliHistoryFrontend\n```\n\n2. 安装依赖\n```bash\nnpm install\n```\n\n3. 启动开发服务器\n```bash\n# 网页版开发\nnpm run dev\n```\n\n## 首次使用指南\n\n1. **登录账号**\n  - 点击侧边栏的设置，然后配置你的服务器地址\n  - 然后点击侧边栏中的\"未登录\"状态\n  - 使用 B 站手机 APP 扫描二维码进行登录\n  - 登录成功后会显示你的用户名\n\n2. **获取历史记录**\n  - 登录成功后，点击导航栏中的\"实时更新\"按钮\n  - 首次使用时会自动获取你的全部历史记录，这可能需要一些时间\n  - 获取完成后数据会自动导入到本地数据库\n  - 页面会自动刷新并显示你的观看历史\n\n3. **后续使用**\n  - 默认的计划任务会在每天 0 点自动获取历史记录\n  - 可去设置里配置邮箱进行通知，不配置不影响自动获取，只是无法收到通知\n  - 每次打开页面时，建议点击\"实时更新\"以获取最新记录\n  - 实时更新只会获取新增的记录，速度很快\n\n\n## 页面介绍\n\n**1. 年度总结页面**\n<img src=\"./public/QQ20250705-180733.png\" alt=\"\">\n<img src=\"./public/layout-collage-1751711304790.jpg\" alt=\"\">\n<img src=\"./public/layout-collage-1751711351462.jpg\" alt=\"\">\n<img src=\"./public/layout-collage-1751711376523.jpg\" alt=\"\">\n<img src=\"./public/layout-collage-1751711396674.jpg\" alt=\"\">\n<img src=\"./public/layout-collage-1751711408262.jpg\" alt=\"\">\n\n**2. 主页** 支持列表/网格切换与日期、分区筛选，一键实时更新，支持隐私模式。\n<img src=\"./public/home.png\" alt=\"\">\n\n**3. 评论** 登录后查看我的评论，支持关键词与类型筛选，并可跳转原文。\n<img src=\"./public/Comments.png\" alt=\"\">\n\n**4. 我的收藏** 支持查看我创建/收藏及本地收藏夹，可同步到本地并下载收藏内容。\n<img src=\"./public/favorites.png\" alt=\"\">\n\n**5. 媒体管理** 集中管理已下载视频与图片，查看/编辑备注与评论，并可批量补全视频详情。\n<img src=\"./public/images.png\" alt=\"\">\n\n**6. 计划任务** 统一管理定时与链式任务，支持新建/编辑/执行/启用或禁用，并查看历史与成功率。\n<img src=\"./public/scheduler.png\" alt=\"\">\n\n**7. 设置** 配置服务器、隐私与布局、数据导出。\n<img src=\"./public/setting.png\" alt=\"\">\n\n**8. 视频下载功能** 输入 BV/链接或 UP UID 下载单个/合集/投稿，过程实时反馈。\n<img src=\"./public/download.png\" alt=\"\">\n<img src=\"./public/SingleVideo.png\" alt=\"\">\n<img src=\"./public/MultipleVideos.png\" alt=\"\">\n\n**9. 视频观看总时长** 查询合集级观看总时长、平均时长与完播率，可按列查看统计\n<img src=\"./public/viewtime.png\" alt=\"\">\n\n**10. 动态下载** 输入用户MID下载B站动态内容，实时显示下载进度\n<img src=\"./public/dynamic.png\" alt=\"\">\n\n**11. 本地摘要功能** 基于本地语音转文字结合 DeepSeek 生成视频摘要，支持模型管理、环境检测与结果缓存。\n<img src=\"./public/LocalSummary.png\" alt=\"\">\n<img src=\"./public/DSSummary.png\" alt=\"\">\n\n## 使用 Tauri 构建桌面应用\n\n### GitHub Actions 自动构建（多平台包体）\n\n推送 tag（例如 `v1.0.0`）后，会自动在 GitHub Releases 里生成 Windows/macOS/Linux 的安装包与产物。\n\n**环境准备**\n\n1. 安装 Rust 开发环境\n  - 按照 [Rust 官方指南](https://www.rust-lang.org/tools/install) 安装 Rust\n  - Windows 用户还需安装 [Visual Studio C++ 构建工具](https://visualstudio.microsoft.com/visual-cpp-build-tools/)\n\n2. 安装 Node.js 依赖\n   ```bash\n   npm install\n   ```\n\n**开发与构建**\n\n1. 开发模式\n   ```bash\n   npm run tauri:dev\n   ```\n   这将启动一个开发服务器，并自动打开应用窗口，支持热重载。\n\n2. 构建可执行文件\n   ```bash\n   npm run tauri:build:exe\n   ```\n   构建完成后，将在项目根目录生成 `BiliBili-History-Frontend.exe` 可执行文件。\n\n3. 清理构建文件\n   ```bash\n   npm run tauri:clean\n   ```\n   清理 `src-tauri/target` 目录中的构建产物，释放磁盘空间。\n\n## 赞助与支持\n\n如果本项目对你有帮助，欢迎通过以下方式赞助。付款时请在备注中填写“希望公开展示的链接”（如个人主页、B 站空间、GitHub 仓库等），我们会在 README 的“赞助鸣谢”表格中展示。\n\n<div align=\"center\">\n  <table>\n    <tr>\n      <td align=\"center\">\n        <img src=\"./public/wechat.png\" alt=\"微信收款码\" width=\"220\"><br>\n        微信赞助\n      </td>\n      <td align=\"center\">\n        <img src=\"./public/zfb.jpg\" alt=\"支付宝收款码\" width=\"220\"><br>\n        支付宝赞助\n      </td>\n    </tr>\n  </table>\n</div>\n\n### 赞助鸣谢\n\n| 联系内容                                              | 付款金额 |\n| ----------------------------------------------------- | -------- |\n| [星语半夏的个人空间-哔哩哔哩](https://b23.tv/WPHOtCS) | ￥15      |\n| 匿名微信用户 | ￥5      |\n| [EMP-NOVA13721RCL的个人空间-哔哩哔哩](https://space.bilibili.com/503193026) | ￥50   |\n\n提示：已赞助但未收录，请在 Issues 提交凭证与备注链接；如需匿名可说明。\n\n## 贡献指南\n\n欢迎提交 Issue 和 Pull Request 来帮助改进这个项目。\n\n\n## 致谢\n\n- [bilibili-API-collect](https://github.com/SocialSisterYi/bilibili-API-collect) - 没有它就没有这个项目\n- [Yutto](https://yutto.nyakku.moe/) - 可爱的 B 站视频下载工具\n- [FasterWhisper](https://github.com/SYSTRAN/faster-whisper) - 音频转文字\n- [DeepSeek](https://github.com/deepseek-ai/DeepSeek-R1) - DeepSeek AI API\n- [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) - 强大且灵活的 HTML5 视频播放器\n- [aicu.cc](https://www.aicu.cc/) - 第三方 B 站用户评论 API\n- [小黑盒用户 shengyI](https://www.xiaoheihe.cn/app/bbs/link/153880174) - 视频观看总时长功能思路提供者\n- 所有贡献者，特别感谢:\n  - [@eli-yip](https://github.com/eli-yip) 对 Docker 部署的贡献\n  - [@QYG2297248353](https://github.com/QYG2297248353) 对 1Panel 部署的贡献\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=2977094657/BiliHistoryFrontend&type=Date)](https://star-history.com/#2977094657/BiliHistoryFrontend&Date)\n"
  },
  {
    "path": "deploy/Caddyfile",
    "content": "{\n\tauto_https off\n}\n\n:80\n\nrespond /health 200 {\n\tbody `{\"status\": \"ok\"}`\n}\n\nroot * /app\nfile_server\ntry_files {path} /index.html\n"
  },
  {
    "path": "index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <!-- 必须要设置此 `meta` 标签才能使多端自适应 -->\n    <meta charset=\"UTF-8\" name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <!-- 这里嵌入了 SVG 图标 -->\n    <link id=\"favicon\" rel=\"icon\" type=\"image/svg+xml\" href=\"/logo.svg\" />\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/logo.svg\" media=\"(prefers-color-scheme: light)\" />\n    <link rel=\"icon\" type=\"image/png\" href=\"/logoDark.png\" media=\"(prefers-color-scheme: dark)\" />\n    <!--      此meta标签用于隐藏referer，不隐藏会导致b站知道请求来源，导致图片访问403-->\n    <meta name=\"referrer\" content=\"no-referrer\" />\n    <title>b站历史记录</title>\n    <script>\n      (function() {\n        var link = document.getElementById('favicon');\n        if (!link) {\n          link = document.createElement('link');\n          link.id = 'favicon';\n          link.rel = 'icon';\n          document.head.appendChild(link);\n        }\n        var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)');\n        function updateFavicon() {\n          var isDark = document.documentElement.classList.contains('dark') || (prefersDark && prefersDark.matches);\n          link.href = isDark ? '/logoDark.png' : '/logo.svg';\n          link.type = isDark ? 'image/png' : 'image/svg+xml';\n        }\n        updateFavicon();\n        if (prefersDark && prefersDark.addEventListener) {\n          prefersDark.addEventListener('change', updateFavicon);\n        } else if (prefersDark && prefersDark.addListener) {\n          prefersDark.addListener(updateFavicon);\n        }\n        var mo = new MutationObserver(updateFavicon);\n        mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });\n      })();\n    </script>\n  </head>\n  <body>\n    <div id=\"app\"></div>\n    <script type=\"module\" src=\"/src/main.js\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "jsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"Node\",\n    \"target\": \"ESNext\",\n    \"jsx\": \"preserve\",\n    \"strictFunctionTypes\": false,\n    \"allowJs\": true,\n    \"checkJs\": false\n  },\n  \"exclude\": [\"node_modules\", \"dist\"]\n} "
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"bilibilihistoryfrontend\",\n  \"version\": \"1.0.0\",\n  \"description\": \"获取Bili历史记录应用\",\n  \"author\": \"46\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"preview\": \"vite preview\",\n    \"tauri\": \"tauri\",\n    \"tauri:dev\": \"tauri dev\",\n    \"tauri:build\": \"tauri build\",\n    \"tauri:build:windows\": \"tauri build --target x86_64-pc-windows-msvc\",\n    \"tauri:build:exe\": \"npm run tauri:build:windows && powershell -c \\\"Copy-Item ./src-tauri/target/x86_64-pc-windows-msvc/release/bilibili-history-frontend.exe ./BiliBili-History-Frontend.exe -Force\\\"\",\n    \"tauri:clean\": \"powershell -c \\\"if (Test-Path ./src-tauri/target) { Remove-Item -Recurse -Force ./src-tauri/target }\\\"\"\n  },\n  \"dependencies\": {\n    \"@headlessui/vue\": \"^1.7.23\",\n    \"@heroicons/vue\": \"^2.1.5\",\n    \"@tailwindcss/forms\": \"^0.5.9\",\n    \"@tauri-apps/plugin-shell\": \"~2.2.1\",\n    \"@vant/touch-emulator\": \"^1.4.0\",\n    \"@vueuse/components\": \"^12.3.0\",\n    \"@vueuse/core\": \"^12.3.0\",\n    \"@vueuse/motion\": \"^2.2.6\",\n    \"animate.css\": \"^4.1.1\",\n    \"aos\": \"^2.3.4\",\n    \"artplayer\": \"^5.2.2\",\n    \"artplayer-plugin-danmuku\": \"^5.1.5\",\n    \"axios\": \"^1.7.7\",\n    \"crypto-js\": \"^4.2.0\",\n    \"echarts\": \"^5.6.0\",\n    \"echarts-wordcloud\": \"^2.1.0\",\n    \"gsap\": \"^3.12.5\",\n    \"html2canvas\": \"^1.4.1\",\n    \"jsencrypt\": \"^3.3.2\",\n    \"lottie-web\": \"^5.12.2\",\n    \"typeit\": \"^8.8.7\",\n    \"vant\": \"^4.9.15\",\n    \"vue\": \"^3.5.11\",\n    \"vue-countup-v3\": \"^1.4.2\",\n    \"vue-echarts\": \"^7.0.3\",\n    \"vue-router\": \"^4.4.5\",\n    \"vue3-lottie\": \"^3.3.1\"\n  },\n  \"devDependencies\": {\n    \"@tauri-apps/api\": \"~2.4.0\",\n    \"@tauri-apps/cli\": \"~2.4.0\",\n    \"@types/node\": \"^20.17.6\",\n    \"@vitejs/plugin-vue\": \"^5.1.4\",\n    \"@vue/runtime-core\": \"^3.5.13\",\n    \"autoprefixer\": \"^10.4.20\",\n    \"cross-env\": \"^7.0.3\",\n    \"postcss\": \"^8.4.47\",\n    \"tailwindcss\": \"^3.4.13\",\n    \"vite\": \"^5.4.8\",\n    \"vite-plugin-vue-inspector\": \"^5.3.1\"\n  }\n}\n"
  },
  {
    "path": "postcss.config.js",
    "content": "export default {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n}\n"
  },
  {
    "path": "src/App.vue",
    "content": "<script setup>\nimport { onMounted, onUnmounted, ref } from 'vue'\nimport { ConfigProvider } from 'vant'\nimport 'vant/es/notify/style'\nimport 'vant/es/dialog/style'\nimport 'vant/es/config-provider/style'\nimport privacyManager from './utils/privacyManager'\nimport { useDarkMode } from './store/darkMode'\n\nconst { isDarkMode, initDarkMode } = useDarkMode()\n\n\n\n// 处理隐私模式变化\nconst handlePrivacyModeChange = (isEnabled) => {\n  console.log('隐私模式状态变化:', isEnabled)\n\n  if (isEnabled) {\n    // 隐私模式启用，无像素化相关处理\n  }\n}\n\n\nonMounted(() => {\n  // 初始化深色模式\n  initDarkMode()\n\n  // 清理localStorage中的API密钥（API密钥验证已移除）\n  if (localStorage.getItem('apiKey')) {\n    localStorage.removeItem('apiKey')\n    console.log('已清理localStorage中的API密钥')\n  }\n\n\n\n  // 添加隐私模式变化监听器\n  privacyManager.addListener(handlePrivacyModeChange)\n\n  // 首先检查隐私模式\n  const privacyModeEnabled = privacyManager.isEnabled()\n\n    // 同步隐私模式状态\n    if (privacyModeEnabled) {\n      handlePrivacyModeChange(true)\n    }\n\n\n})\n\n</script>\n\n<template>\n  <!-- 使用ConfigProvider根据深色模式动态切换主题 -->\n  <ConfigProvider :theme=\"isDarkMode ? 'dark' : 'light'\">\n    <div class=\"min-h-screen bg-white dark:bg-gray-900 transition-colors duration-300\">\n      <!-- 主应用内容 -->\n      <router-view></router-view>\n    </div>\n  </ConfigProvider>\n</template>\n\n<style scoped>\n/* 已移除服务器连接相关样式 */\n</style>\n"
  },
  {
    "path": "src/api/api.js",
    "content": "import axios from 'axios'\n// 导入通知组件\nimport 'vant/es/notify/style'\n\n// 你的服务器地址\nconst DEFAULT_FALLBACK_URL = 'http://localhost:8899';\nconst VITE_CONFIGURED_DEFAULT_URL = import.meta.env.VITE_DEFAULT_BACKEND_URL || DEFAULT_FALLBACK_URL;\nconst getBaseUrl = () => {\n  return localStorage.getItem('baseUrl') || VITE_CONFIGURED_DEFAULT_URL\n}\n\nconst BASE_URL = getBaseUrl()\n\n// 服务器地址列表\nconst SERVER_URLS = [\n  'http://127.0.0.1:8899',\n  'http://localhost:8899',\n  'http://0.0.0.0:8899'\n]\n\nif (!SERVER_URLS.includes(VITE_CONFIGURED_DEFAULT_URL)) {\n  SERVER_URLS.unshift(VITE_CONFIGURED_DEFAULT_URL)\n}\n\n// 设置服务器地址\nexport const setBaseUrl = (url) => {\n  localStorage.setItem('baseUrl', url)\n  // 更新 axios 实例的 baseURL\n  updateInstanceBaseUrl(url)\n  // 触发API BASE URL更新事件，供其他API模块使用\n  try {\n    const event = new CustomEvent('api-baseurl-updated', { detail: { url } })\n    window.dispatchEvent(event)\n    console.log('已触发API BaseURL更新事件:', url)\n  } catch (error) {\n    console.error('触发API BaseURL更新事件失败:', error)\n  }\n  window.location.reload() // 刷新页面以应用新的baseUrl\n}\n\n// 获取当前服务器地址\nexport const getCurrentBaseUrl = () => {\n  return getBaseUrl()\n}\n\n// 创建一个 axios 实例\nconst instance = axios.create({\n  baseURL: BASE_URL,\n})\n\n// 请求拦截器\ninstance.interceptors.request.use(\n  (config) => {\n    return config\n  },\n  (error) => {\n    return Promise.reject(error)\n  }\n)\n\n// 响应拦截器\ninstance.interceptors.response.use(\n  (response) => {\n    return response\n  },\n  (error) => {\n    return Promise.reject(error)\n  }\n)\n\n// 更新 axios 实例的 baseURL\nexport const updateInstanceBaseUrl = (newBaseUrl) => {\n  instance.defaults.baseURL = newBaseUrl\n}\n\n// 历史记录相关接口\nexport const getBiliHistory2024 = (page, size, sortOrder, tagName, mainCategory, dateRange, useLocalImages = false, business = '') => {\n  return instance.get(`/history/all`, {\n    params: {\n      page,\n      size,\n      sort_order: sortOrder,\n      tag_name: tagName,\n      main_category: mainCategory,\n      date_range: dateRange,\n      use_local_images: useLocalImages,\n      business: business,\n    },\n  })\n}\n\nexport const searchBiliHistory2024 = (search, searchType = 'all', page = 1, size = 30, useLocalImages = false, useSessdata = true) => {\n  return instance.get(`/history/search`, {\n    params: {\n      page,\n      size,\n      search,\n      search_type: searchType,\n      use_local_images: useLocalImages,\n      use_sessdata: useSessdata\n    },\n  })\n}\n\n// 获取可用年份列表\nexport const getAvailableYears = () => {\n  return instance.get(`/history/available-years`)\n}\n\n// 分类相关接口\nexport const getVideoCategories = () => {\n  return instance.get(`/categories/categories`) // 使用新的分类接口\n}\n\nexport const getMainCategories = () => {\n  return instance.get(`/categories/main-categories`)\n}\n\n// 标题分析相关接口已拆分为以下独立接口：\n// - getTitleKeywordAnalysis: 关键词分析\n// - getTitleLengthAnalysis: 长度分析\n// - getTitleSentimentAnalysis: 情感分析\n// - getTitleTrendAnalysis: 趋势分析\n// - getTitleInteractionAnalysis: 互动分析\n\n// 获取标题关键词分析\nexport const getTitleKeywordAnalysis = (year, useCache = true) => {\n  return instance.get(`/title/keyword-analysis`, {\n    params: {\n      year,\n      use_cache: useCache\n    }\n  })\n}\n\n// 获取标题长度分析\nexport const getTitleLengthAnalysis = (year, useCache = true) => {\n  return instance.get(`/title/length-analysis`, {\n    params: {\n      year,\n      use_cache: useCache\n    }\n  })\n}\n\n// 获取标题情感分析\nexport const getTitleSentimentAnalysis = (year, useCache = true) => {\n  return instance.get(`/title/sentiment-analysis`, {\n    params: {\n      year,\n      use_cache: useCache\n    }\n  })\n}\n\n// 获取标题趋势分析\nexport const getTitleTrendAnalysis = (year, useCache = true) => {\n  return instance.get(`/title/trend-analysis`, {\n    params: {\n      year,\n      use_cache: useCache\n    }\n  })\n}\n\n// 获取标题互动分析\nexport const getTitleInteractionAnalysis = (year, useCache = true) => {\n  return instance.get(`/title/interaction-analysis`, {\n    params: {\n      year,\n      use_cache: useCache\n    }\n  })\n}\n\n// 观看时间分析相关接口已拆分为以下独立接口：\n// - getViewingMonthlyStats: 月度统计分析\n// - getViewingWeeklyStats: 周度统计分析\n// - getViewingTimeSlots: 时段分析\n// - getViewingContinuity: 观看连续性分析\n// 更多维度接口将逐步添加...\n\n// 获取月度观看统计分析\nexport const getViewingMonthlyStats = (year, useCache = true) => {\n  return instance.get(`/viewing/monthly-stats`, {\n    params: {\n      year,\n      use_cache: useCache\n    }\n  })\n}\n\n// 获取周度观看统计分析\nexport const getViewingWeeklyStats = (year, useCache = true) => {\n  return instance.get(`/viewing/weekly-stats`, {\n    params: {\n      year,\n      use_cache: useCache\n    }\n  })\n}\n\n// 获取时段观看分析\nexport const getViewingTimeSlots = (year, useCache = true) => {\n  return instance.get(`/viewing/time-slots`, {\n    params: {\n      year,\n      use_cache: useCache\n    }\n  })\n}\n\n// 获取观看连续性分析\nexport const getViewingContinuity = (year, useCache = true) => {\n  return instance.get(`/viewing/continuity`, {\n    params: {\n      year,\n      use_cache: useCache\n    }\n  })\n}\n\n// 获取重复观看分析\nexport const getViewingWatchCounts = (year, useCache = true) => {\n  return instance.get(`/viewing/watch-counts`, {\n    params: {\n      year,\n      use_cache: useCache\n    }\n  })\n}\n\n// 获取视频完成率分析\nexport const getViewingCompletionRates = (year, useCache = true) => {\n  return instance.get(`/viewing/completion-rates`, {\n    params: {\n      year,\n      use_cache: useCache\n    }\n  })\n}\n\n// 获取UP主完成率分析\nexport const getViewingAuthorCompletion = (year, useCache = true) => {\n  return instance.get(`/viewing/author-completion`, {\n    params: {\n      year,\n      use_cache: useCache\n    }\n  })\n}\n\n// 获取标签分析\nexport const getViewingTagAnalysis = (year, useCache = true) => {\n  return instance.get(`/viewing/tag-analysis`, {\n    params: {\n      year,\n      use_cache: useCache\n    }\n  })\n}\n\n// 获取视频时长分析\nexport const getViewingDurationAnalysis = (year, useCache = true) => {\n  return instance.get(`/viewing/duration-analysis`, {\n    params: {\n      year,\n      use_cache: useCache\n    }\n  })\n}\n\n// 原始的观看时间分析接口已删除，现在使用拆分后的独立接口\n\n// 获取观看行为数据分析\nexport const getViewingBehavior = async (year, useCache = false) => {\n  return instance.get(`/viewing/viewing/`, {\n    params: {\n      year,\n      use_cache: useCache\n    }\n  })\n}\n\n// 获取每年每天的观看数合集\nexport const getYearlyAnalysis = async (year) => {\n  return instance.post(`/analysis/analyze`, null, {\n    params: {\n      year\n    }\n  })\n}\n\n// 获取热门视频命中率分析\nexport const getPopularHitRate = async (year, useCache = true) => {\n  return instance.get(`/popular/popular-hit-rate`, {\n    params: {\n      year,\n      use_cache: useCache\n    }\n  })\n}\n\n// 获取热门预测能力分析\nexport const getPopularPredictionAbility = async (year, useCache = true) => {\n  return instance.get(`/popular/popular-prediction-ability`, {\n    params: {\n      year,\n      use_cache: useCache\n    }\n  })\n}\n\n// 获取UP主热门关联分析\nexport const getAuthorPopularAssociation = async (year, useCache = true) => {\n  return instance.get(`/popular/author-popular-association`, {\n    params: {\n      year,\n      use_cache: useCache\n    }\n  })\n}\n\n// 获取热门视频分区分布分析\nexport const getCategoryPopularDistribution = async (year, useCache = true) => {\n  return instance.get(`/popular/category-popular-distribution`, {\n    params: {\n      year,\n      use_cache: useCache\n    }\n  })\n}\n\n// 获取热门视频时长分布分析\nexport const getDurationPopularDistribution = async (year, useCache = true) => {\n  return instance.get(`/popular/duration-popular-distribution`, {\n    params: {\n      year,\n      use_cache: useCache\n    }\n  })\n}\n\n\n\n// 实时更新历史记录\nexport const updateBiliHistoryRealtime = (syncDeleted = false) => {\n  return instance.get(`/fetch/bili-history-realtime`, {\n    params: {\n      sync_deleted: syncDeleted\n    }\n  }).then(response => {\n    // 检查响应格式\n    if (!response.data) {\n      throw new Error('响应数据格式错误')\n    }\n\n    // 如果返回未找到本地历史记录错误，则调用完整获取接口\n    if (response.data.status === 'error' && response.data.message === '未找到本地历史记录') {\n      return getBiliHistory()\n    }\n\n    return response\n  }).catch(error => {\n    console.error('API 请求错误:', error)\n    // 重新抛出错误，让调用者处理\n    throw error\n  })\n}\n\n// 获取完整历史记录\nexport const getBiliHistory = () => {\n  return instance.get('/fetch/bili-history').then(async response => {\n    // 检查响应格式\n    if (!response.data) {\n      throw new Error('响应数据格式错误')\n    }\n\n    // 如果获取历史记录成功，调用导入SQLite接口\n    if (response.data.status === 'success') {\n      try {\n        await importSqliteData()\n        // 1秒后刷新页面，让用户看到成功提示\n        setTimeout(() => {\n          window.location.reload()\n        }, 1000)\n      } catch (error) {\n        console.error('导入SQLite失败:', error)\n        // 即使导入失败也返回历史记录的响应\n      }\n    }\n\n    return response\n  }).catch(error => {\n    console.error('获取历史记录失败:', error)\n    throw error\n  })\n}\n\n// 获取每日视频统计\nexport const getDailyStats = async (date, year) => {\n  return instance.get(`/daily/daily-count`, {\n    params: {\n      date,\n      year\n    }\n  })\n}\n\n// 导入SQLite数据\nexport const importSqliteData = () => {\n  return instance.post(`/importSqlite/import_data_sqlite`)\n}\n\n// 导出相关接口\n// 导出历史记录到Excel\nexport const exportHistory = (options = {}) => {\n  // 只传递非空参数\n  const params = {}\n\n  // 年份参数\n  if (options.year !== undefined && options.year !== null) {\n    params.year = options.year\n  }\n\n  // 月份参数\n  if (options.month !== undefined && options.month !== null) {\n    params.month = options.month\n  }\n\n  // 开始日期参数\n  if (options.start_date) {\n    params.start_date = options.start_date\n  }\n\n  // 结束日期参数\n  if (options.end_date) {\n    params.end_date = options.end_date\n  }\n\n  console.log('导出参数:', params)\n\n  return instance.post('/export/export_history', null, {\n    params\n  })\n}\n\n// 下载Excel文件\nexport const downloadExcelFile = (filename) => {\n  return instance.get(`/export/download_excel/${encodeURIComponent(filename)}`, {\n    responseType: 'blob',\n    headers: {\n      'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'\n    }\n  }).then(response => {\n    // 创建blob链接并下载\n    const blob = new Blob([response.data], {\n      type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'\n    })\n    const url = window.URL.createObjectURL(blob)\n    const link = document.createElement('a')\n    link.href = url\n    link.setAttribute('download', filename)\n    document.body.appendChild(link)\n    link.click()\n    document.body.removeChild(link)\n    window.URL.revokeObjectURL(url)\n    return response\n  })\n}\n\n// 下载SQLite数据库\nexport const downloadDatabase = () => {\n  return instance.get('/export/download_db', {\n    responseType: 'blob',\n    headers: {\n      'Accept': 'application/x-sqlite3'\n    }\n  }).then(response => {\n    // 创建blob链接并下载\n    const blob = new Blob([response.data], {\n      type: 'application/x-sqlite3'\n    })\n    const url = window.URL.createObjectURL(blob)\n    const link = document.createElement('a')\n    link.href = url\n    link.setAttribute('download', 'bilibili_history.db')\n    document.body.appendChild(link)\n    link.click()\n    document.body.removeChild(link)\n    window.URL.revokeObjectURL(url)\n    return response\n  })\n}\n\n// 登录相关接口\n// 生成登录二维码\nexport const generateLoginQRCode = () => {\n  return instance.get('/login/qrcode/generate')\n}\n\n// 获取二维码图片URL\nexport const getQRCodeImageURL = () => {\n  return `${BASE_URL}/login/qrcode/image`\n}\n\n// 获取二维码图片（返回blob URL）\nexport const getQRCodeImageBlob = async () => {\n  try {\n    const response = await instance.get('/login/qrcode/image', {\n      responseType: 'blob'\n    })\n\n    // 创建blob URL\n    const blob = new Blob([response.data], {\n      type: response.headers['content-type'] || 'image/png'\n    })\n    return URL.createObjectURL(blob)\n  } catch (error) {\n    console.error('获取二维码图片失败:', error)\n    throw error\n  }\n}\n\n// 轮询二维码状态\nexport const pollQRCodeStatus = (qrcodeKey) => {\n  return instance.get('/login/qrcode/poll', {\n    params: {\n      qrcode_key: qrcodeKey\n    }\n  })\n}\n\n// 退出登录\nexport const logout = () => {\n  return instance.post('/login/logout')\n}\n\n// 获取登录状态\nexport const getLoginStatus = () => {\n  return instance.get('/login/check')\n}\n\n// 获取视频摘要\nexport const getVideoSummary = (bvid, cid, upMid, forceRefresh = false) => {\n  return instance.get('/summary/get_summary', {\n    params: {\n      bvid,\n      cid,\n      up_mid: upMid,\n      force_refresh: forceRefresh\n    }\n  })\n}\n\n// 获取摘要配置\nexport const getSummaryConfig = () => {\n  return instance.get('/summary/config')\n}\n\n// 更新摘要配置\nexport const updateSummaryConfig = (config) => {\n  return instance.post('/summary/config', config)\n}\n\n// 批量删除历史记录\nexport const batchDeleteHistory = (items) => {\n  return instance.delete('/delete/batch-delete', {\n    data: items  // 直接发送数组，不要包装在 items 对象中\n  })\n}\n\n// 删除B站历史记录\nexport const deleteBilibiliHistory = (kid, syncToBilibili = true) => {\n  return instance.delete(`/bilibili/history/single`, {\n    params: {\n      kid\n    },\n    data: {\n      sync_to_bilibili: syncToBilibili\n    }\n  })\n}\n\n// 批量删除B站历史记录\nexport const batchDeleteBilibiliHistory = (items) => {\n  return instance.delete('/bilibili/history/batch', {\n    data: {\n      items\n    }\n  })\n}\n\n// =============================\n// 热门视频数据清理（按年分库）\n// =============================\n\n/**\n * 获取热门视频数据库可用年份（降序）\n * GET /bilibili/popular/years\n * @returns {Promise<any>}\n */\nexport const getPopularCleanupYears = () => {\n  return instance.get('/bilibili/popular/years')\n}\n\n// 数据库管理相关接口\n// 重置数据库\nexport const resetDatabase = () => {\n  return instance.post('/history/reset-database')\n}\n\n// 备注相关接口\n// 更新视频备注\nexport const updateVideoRemark = (bvid, viewAt, remark) => {\n  return instance.post('/history/update-remark', {\n    bvid,\n    view_at: viewAt,\n    remark\n  })\n}\n\n// 批量获取视频备注\nexport const batchGetRemarks = (records) => {\n  return instance.post('/history/batch-remarks', {\n    items: records\n  })\n}\n\n// 获取所有备注记录\nexport const getAllRemarks = (page = 1, size = 10, sortOrder = 0) => {\n  return instance.get('/history/remarks', {\n    params: {\n      page,\n      size,\n      sort_order: sortOrder\n    }\n  })\n}\n\n// 获取SQLite版本\nexport const getSqliteVersion = () => {\n  return instance.get('/history/sqlite-version')\n}\n\n// 图片管理相关接口\n// 获取图片下载状态\nexport const getImagesStatus = () => {\n  return instance.get('/images/status')\n}\n\n// 开始下载图片\nexport const startImagesDownload = (year = null, useSessdata = true) => {\n  return instance.post('/images/start', null, {\n    params: {\n      year,\n      use_sessdata: useSessdata\n    }\n  })\n}\n\n// 停止下载图片\nexport const stopImagesDownload = () => {\n  return instance.post('/images/stop')\n}\n\n// 清空图片\nexport const clearImages = () => {\n  return instance.post('/images/clear')\n}\n\n// 下载视频\nexport const downloadVideo = async (bvid, sessdata = null, onMessage, downloadCover = true, onlyAudio = false, cid = 0, options = {}) => {\n  console.log('调用下载API, bvid:', bvid, '高级选项:', options)\n\n  const requestBody = {\n    url: bvid,\n    sessdata,\n    download_cover: downloadCover,\n    only_audio: onlyAudio,\n    cid,\n    // 添加高级选项\n    ...options\n  }\n\n  // 从本地存储获取API密钥\n  const apiKey = localStorage.getItem('apiKey')\n\n  // 准备请求头\n  const headers = {\n    'Content-Type': 'application/json',\n  }\n\n  // 如果存在API密钥，添加到请求头\n  if (apiKey) {\n    headers['X-API-Key'] = apiKey\n  }\n\n  const response = await fetch(`${BASE_URL}/download/download_video`, {\n    method: 'POST',\n    headers,\n    body: JSON.stringify(requestBody)\n  })\n\n  if (!response.ok) {\n    const errorData = await response.json()\n    throw new Error(errorData.detail || '下载请求失败')\n  }\n\n  const reader = response.body.getReader()\n  const decoder = new TextDecoder()\n  let buffer = ''\n\n  while (true) {\n    const { value, done } = await reader.read()\n    if (done) break\n\n    buffer += decoder.decode(value, { stream: true })\n\n    // 处理缓冲区中的完整行\n    const lines = buffer.split('\\n')\n    buffer = lines.pop() // 保留最后一个不完整的行\n\n    for (const line of lines) {\n      if (line.startsWith('data: ')) {\n        const content = line.substring(6).trim()\n        if (content && content !== 'close') {\n          onMessage(content)\n        }\n      }\n    }\n  }\n\n  // 处理最后可能剩余的数据\n  if (buffer) {\n    if (buffer.startsWith('data: ')) {\n      const content = buffer.substring(6).trim()\n      if (content && content !== 'close') {\n        onMessage(content)\n      }\n    }\n  }\n}\n\n// 检查 FFmpeg 安装状态\nexport const checkFFmpeg = () => {\n  return instance.get('/download/check_ffmpeg')\n}\n\n// 计划任务管理相关接口\nexport const getAllSchedulerTasks = (params = {}) => {\n  return instance.get('/scheduler/tasks', { params })\n    .then(response => {\n      return response\n    })\n    .catch(error => {\n      console.error('getAllSchedulerTasks API错误:', error)\n      throw error\n    })\n}\n\nexport const getSchedulerTaskDetail = (taskId, params = {}) => {\n  return instance.get(`/scheduler/tasks`, {\n    params: {\n      task_id: taskId,\n      include_subtasks: true,  // 默认包含子任务\n      ...params\n    }\n  }).then(response => {\n    return response;\n  }).catch(error => {\n    console.error('API - 获取任务详情出错:', error);\n    throw error;\n  });\n}\n\nexport const createSchedulerTask = (taskData) => {\n  return instance.post('/scheduler/tasks', taskData)\n}\n\nexport const updateSchedulerTask = (taskId, taskData) => {\n  return instance.put(`/scheduler/tasks/${taskId}`, taskData)\n}\n\nexport const deleteSchedulerTask = (taskId) => {\n  return instance.delete(`/scheduler/tasks/${taskId}`)\n}\n\nexport const executeSchedulerTask = (taskId, options = {}) => {\n  return instance.post(`/scheduler/tasks/${taskId}/execute`, options)\n}\n\n// 子任务管理接口\nexport const addSubTask = (taskId, subTaskData) => {\n  console.log('调用addSubTask API:', { taskId, subTaskData })\n  return instance.post(`/scheduler/tasks/${taskId}/subtasks`, subTaskData)\n    .then(response => {\n      console.log('addSubTask API响应:', response)\n      return response\n    })\n    .catch(error => {\n      console.error('addSubTask API错误:', error)\n      throw error\n    })\n}\n\n\nexport const deleteSubTask = (taskId, subTaskId) => {\n  return instance.delete(`/scheduler/tasks/${taskId}/subtasks/${subTaskId}`)\n}\n\n\n// 获取任务历史记录\nexport const getTaskHistory = ({\n  task_id = null,\n  include_subtasks = true,\n  status = null,\n  start_date = null,\n  end_date = null,\n  page = 1,\n  page_size = 20\n}) => {\n  return instance.get(`/scheduler/tasks/history`, {\n    params: {\n      task_id,\n      include_subtasks,\n      status,\n      start_date,\n      end_date,\n      page,\n      page_size\n    }\n  })\n}\n\n// 系统接口\nexport const getAvailableEndpoints = () => {\n  return instance.get('/scheduler/available-endpoints')\n}\n\n// 启用/禁用任务\nexport const setTaskEnabled = (taskId, enabled) => {\n  return instance.post(`/scheduler/tasks/${taskId}/enable`, {\n    enabled\n  })\n}\n\n// 邮件配置相关接口\n// 获取邮件配置\nexport const getEmailConfig = () => {\n  return instance.get('/config/email-config')\n    .then(response => {\n      console.log('邮件配置API响应成功:', response)\n      return response\n    })\n    .catch(error => {\n      console.error('邮件配置API错误:', error)\n      throw error\n    })\n}\n\n// 更新邮件配置\nexport const updateEmailConfig = (config) => {\n  return instance.post('/config/email-config', config)\n    .then(response => {\n      console.log('更新邮件配置API响应成功:', response)\n      return response\n    })\n    .catch(error => {\n      console.error('更新邮件配置API错误:', error)\n      throw error\n    })\n}\n\n// 测试邮件配置\nexport const testEmailConfig = (testData) => {\n  return instance.post('/config/test-email', testData)\n    .then(response => {\n      console.log('测试邮件API响应成功:', response)\n      return response\n    })\n    .catch(error => {\n      console.error('测试邮件API错误:', error)\n      throw error\n    })\n}\n\n// 音频转文字相关接口\nexport const checkAudioToTextEnvironment = () => {\n  return instance.get('/audio_to_text/check_environment')\n}\n\n// 检查系统资源\nexport const checkSystemResources = () => {\n  return instance.get('/audio_to_text/resource_check')\n}\n\n// 获取可用的 Whisper 模型列表\nexport const getWhisperModels = () => {\n  return instance.get('/audio_to_text/models')\n}\n\n// 查找音频文件路径\nexport const findAudioPath = (cid) => {\n  return instance.get('/audio_to_text/find_audio', {\n    params: { cid }\n  })\n}\n\n// 检查语音转文字文件是否存在\nexport const checkSttFile = (cid) => {\n  return instance.get('/audio_to_text/check_stt_file', {\n    params: { cid }\n  })\n}\n\n// 转录音频文件\nexport const transcribeAudio = (params) => {\n  return instance.post('/audio_to_text/transcribe', params)\n}\n\n// 根据CID生成视频摘要\nexport const summarizeByCid = (cid) => {\n  return instance.post('/summary/summarize_by_cid', {\n    cid\n  })\n}\n\n// 检查本地摘要文件\nexport const checkLocalSummary = (cid, includeContent = true) => {\n  return instance.get(`/summary/check_local_summary/${cid}`, {\n    params: {\n      include_content: includeContent\n    }\n  })\n}\n\n// 下载指定的Whisper模型\nexport const downloadWhisperModel = (modelSize) => {\n  return instance.post('/audio_to_text/download_model', null, {\n    params: {\n      model_size: modelSize\n    }\n  })\n}\n\n// 删除指定的Whisper模型\nexport const deleteWhisperModel = (modelSize) => {\n  return instance.delete('/audio_to_text/models', {\n    data: {\n      model_size: modelSize\n    }\n  })\n}\n\n// DeepSeek相关接口\nexport const checkDeepSeekApiKey = () => {\n  return instance.get('/deepseek/check_api_key')\n}\n\nexport const setDeepSeekApiKey = (apiKey) => {\n  return instance.post('/deepseek/set_api_key', {\n    api_key: apiKey\n  })\n}\n\nexport const getDeepSeekBalance = () => {\n  return instance.get('/deepseek/balance')\n}\n\n// API安全相关接口已移除\n\n// 检查视频是否已下载\nexport const checkVideoDownload = (cids) => {\n  return instance.get(`/download/check_video_download`, {\n    params: {\n      cids: Array.isArray(cids) ? cids.join(',') : cids\n    }\n  })\n}\n\n// 获取已下载视频列表\nexport const getDownloadedVideos = (searchTerm = '', page = 1, limit = 20) => {\n  return instance.get(`/download/list_downloaded_videos`, {\n    params: {\n      search_term: searchTerm,\n      page,\n      limit\n    }\n  })\n}\n\n/**\n * 删除已下载的视频\n * @param {number|null} cid 视频的CID，若为null则必须指定directory\n * @param {boolean} deleteDirectory 是否删除整个目录，默认为false（只删除视频文件）\n * @param {string|null} directory 可选，指定要删除文件的目录路径\n *                               若提供则在该目录中查找和删除文件\n *                               对于从收藏夹下载的视频，由于没有CID，\n *                               可以设置cid为null并通过directory参数指定目录路径\n * @returns {Promise<object>} 包含删除结果信息的响应\n */\nexport const deleteDownloadedVideo = (cid, deleteDirectory = false, directory = null) => {\n  return instance.delete(`/download/delete_downloaded_video`, {\n    params: {\n      cid,\n      delete_directory: deleteDirectory,\n      directory: directory\n    }\n  })\n}\n\n// 获取评论列表\nexport const getComments = (uid, page = 1, pageSize = 20, commentType = 'all', keyword = '', typeFilter = '') => {\n  // 确保 typeFilter 为有效整数或不传递\n  const params = {\n    page,\n    page_size: pageSize,\n    comment_type: commentType,\n    keyword\n  }\n\n  // 只有当 typeFilter 有值且不为 '0' 时才添加到参数中\n  if (typeFilter && typeFilter !== '0') {\n    params.type_filter = parseInt(typeFilter)\n  }\n\n  return instance.get(`/comment/query/${uid}`, { params })\n}\n\n// 服务器健康检查\nexport const checkServerHealth = () => {\n  return instance.get('/health')\n}\n\n/**\n * 获取视频流地址\n * @param {string} file_path 视频文件路径\n * @returns {string} 视频流URL\n */\nexport const getVideoStream = (file_path) => {\n  if (!file_path) return ''\n  const baseUrl = instance.defaults.baseURL\n\n  // 构建基本URL\n  return `${baseUrl}/download/stream_video?file_path=${encodeURIComponent(file_path)}&t=${Date.now()}`\n}\n\n/**\n * 获取弹幕文件内容\n * @param {string} cid 弹幕ID\n * @param {string} file_path 弹幕文件路径\n * @returns {Promise<Object>} 响应对象\n */\nexport const getDanmakuFile = async (cid = '', file_path = '') => {\n  try {\n    const params = {};\n    if (cid) params.cid = cid;\n    if (file_path) params.file_path = file_path;\n\n    return await instance.get(`/download/stream_danmaku`, {\n      params,\n      responseType: 'text' // 获取纯文本格式的弹幕文件\n    });\n  } catch (error) {\n    console.error('获取弹幕文件失败:', error);\n    throw error;\n  }\n}\n\n// 数据同步相关接口\n/**\n * 数据同步API\n * @param {string} db_path - 数据库文件路径\n * @param {string} json_path - JSON文件根目录\n * @param {boolean} async_mode - 是否异步执行\n * @returns {Promise} - API响应\n */\nexport const syncData = (db_path = 'output/bilibili_history.db', json_path = 'output/history_by_date', async_mode = false) => {\n  return instance.post('/data_sync/sync', {\n    db_path,\n    json_path,\n    async_mode\n  })\n}\n\n/**\n * 获取最新同步结果API\n * @returns {Promise} - API响应\n */\nexport const getSyncResult = () => {\n  return instance.get('/data_sync/sync/result')\n}\n\n/**\n * 检查数据完整性API\n * @param {string} db_path - 数据库文件路径\n * @param {string} json_path - JSON文件根目录\n * @param {boolean} async_mode - 是否异步执行\n * @param {boolean} force_check - 是否强制执行检查，忽略配置设置\n * @returns {Promise} - API响应\n */\nexport const checkDataIntegrity = (db_path = 'output/bilibili_history.db', json_path = 'output/history_by_date', async_mode = false, force_check = false) => {\n  return instance.post('/data_sync/check', {\n    db_path,\n    json_path,\n    async_mode,\n    force_check\n  })\n}\n\n/**\n * 获取数据完整性校验配置API\n * @returns {Promise} - API响应\n */\nexport const getIntegrityCheckConfig = () => {\n  return instance.get('/data_sync/config')\n}\n\n/**\n * 获取数据完整性报告API\n * @returns {Promise} - API响应\n */\nexport const getIntegrityReport = () => {\n  return instance.get('/data_sync/report')\n}\n\n/**\n * 更新数据完整性校验配置API\n * @param {boolean} checkOnStartup - 是否在启动时进行数据完整性校验\n * @returns {Promise} - API响应\n */\nexport const updateIntegrityCheckConfig = (checkOnStartup) => {\n  return instance.post('/data_sync/config', {\n    check_on_startup: checkOnStartup\n  })\n}\n\n/**\n * 获取指定用户创建的所有收藏夹信息\n * @param {Object} params 请求参数\n * @param {number} [params.up_mid] 目标用户mid，不提供则使用当前登录用户\n * @param {number} [params.type] 目标内容属性，0=全部(默认)，2=视频稿件\n * @param {number} [params.rid] 目标内容id，视频稿件为avid\n * @param {string} [params.sessdata] 用户的SESSDATA，不提供则从配置文件读取\n * @returns {Promise<Object>} 收藏夹列表\n */\nexport const getCreatedFavoriteFolders = (params = {}) => {\n  return instance.get('/favorite/folder/created/list-all', { params })\n}\n\n/**\n * 获取指定用户收藏的视频收藏夹列表\n * @param {Object} params 请求参数\n * @param {number} [params.up_mid] 目标用户mid，不提供则使用当前登录用户\n * @param {number} [params.pn] 页码，默认为1\n * @param {number} [params.ps] 每页项数，默认为20\n * @param {string} [params.keyword] 搜索关键词\n * @param {string} [params.sessdata] 用户的SESSDATA，不提供则从配置文件读取\n * @returns {Promise<Object>} 收藏夹列表\n */\nexport const getCollectedFavoriteFolders = (params = {}) => {\n  return instance.get('/favorite/folder/collected/list', { params })\n}\n\n/**\n * 获取收藏夹内容列表\n * @param {Object} params 请求参数\n * @param {number} params.media_id 目标收藏夹id（完整id）\n * @param {number} [params.pn] 页码，默认为1\n * @param {number} [params.ps] 每页项数，默认为20\n * @param {string} [params.keyword] 搜索关键词\n * @param {string} [params.order] 排序方式，mtime(收藏时间，默认)或view(播放量)\n * @param {number} [params.type] 筛选类型，0=全部(默认)，2=视频\n * @param {number} [params.tid] 分区ID，0=全部(默认)\n * @param {string} [params.platform] 平台标识，默认为web\n * @param {string} [params.sessdata] 用户的SESSDATA，不提供则从配置文件读取\n * @returns {Promise<Object>} 收藏夹内容列表\n */\nexport const getFavoriteContents = (params = {}) => {\n  return instance.get('/favorite/folder/resource/list', { params })\n}\n\n/**\n * 获取数据库中的收藏夹列表\n * @param {Object} params 请求参数\n * @param {number} [params.mid] 用户UID，不提供则返回所有收藏夹\n * @param {number} [params.page] 页码，默认为1\n * @param {number} [params.size] 每页数量，默认为20\n * @returns {Promise<Object>} 收藏夹列表\n */\nexport const getLocalFavoriteFolders = (params = {}) => {\n  return instance.get('/favorite/list', { params })\n}\n\n/**\n * 获取数据库中的收藏内容列表\n * @param {Object} params 请求参数\n * @param {number} params.media_id 收藏夹ID\n * @param {number} [params.page] 页码，默认为1\n * @param {number} [params.size] 每页数量，默认为20\n * @returns {Promise<Object>} 内容列表\n */\nexport const getLocalFavoriteContents = (params = {}) => {\n  return instance.get('/favorite/content/list', { params })\n}\n// #endregion\n\n/**\n * 收藏或取消收藏视频\n * @param {Object} params 请求参数\n * @param {number} params.rid 稿件avid (不含av前缀)\n * @param {string} [params.add_media_ids] 需要加入的收藏夹ID，多个用逗号分隔\n * @param {string} [params.del_media_ids] 需要取消的收藏夹ID，多个用逗号分隔\n * @param {string} [params.sessdata] 用户的SESSDATA，不提供则从配置文件读取\n * @returns {Promise<Object>} 操作结果\n */\nexport const favoriteResource = (params = {}) => {\n  return instance.post('/favorite/resource/deal', params)\n}\n\n/**\n * 批量收藏或取消收藏视频\n * @param {Object} params 请求参数\n * @param {string} params.rids 稿件avid列表 (不含av前缀)，多个用逗号分隔\n * @param {string} [params.add_media_ids] 需要加入的收藏夹ID，多个用逗号分隔\n * @param {string} [params.del_media_ids] 需要取消的收藏夹ID，多个用逗号分隔\n * @param {string} [params.sessdata] 用户的SESSDATA，不提供则从配置文件读取\n * @returns {Promise<Object>} 操作结果\n */\nexport const batchFavoriteResource = (params = {}) => {\n  return instance.post('/favorite/resource/batch-deal', params)\n}\n\n/**\n * 本地批量收藏或取消收藏视频\n * @param {Object} params 请求参数\n * @param {string} params.rids 稿件avid列表 (不含av前缀)，多个用逗号分隔\n * @param {string} [params.add_media_ids] 需要加入的收藏夹ID，多个用逗号分隔\n * @param {string} [params.del_media_ids] 需要取消的收藏夹ID，多个用逗号分隔\n * @param {string} [params.operation_type] 操作类型，`sync`=同步到远程，`local`=仅本地操作，默认为`sync`\n * @param {string} [params.sessdata] 用户的SESSDATA，不提供则从配置文件读取\n * @returns {Promise<Object>} 操作结果\n */\nexport const localBatchFavoriteResource = (params = {}) => {\n  return instance.post('/favorite/resource/local-batch-deal', params)\n}\n\n/**\n * 批量检查视频收藏状态\n * @param {Object} params 请求参数\n * @param {Array<number>|string} params.oids 视频av号列表，可以是数组或逗号分隔的字符串\n * @param {string} [params.sessdata] 用户的SESSDATA，不提供则从配置文件读取\n * @returns {Promise<Object>} 视频收藏状态\n */\nexport const batchCheckFavoriteStatus = (params = {}) => {\n  const requestParams = { ...params };\n\n  // 确保 oids 是数组格式\n  if (typeof requestParams.oids === 'string') {\n    requestParams.oids = requestParams.oids.split(',').map(id => parseInt(id.trim(), 10)).filter(id => !isNaN(id));\n  } else if (!Array.isArray(requestParams.oids)) {\n    console.error('批量检查收藏状态参数错误：oids必须是数组或逗号分隔的字符串');\n    requestParams.oids = [];\n  }\n\n  return instance.post('/favorite/check/batch', requestParams);\n}\n\n/**\n * 下载用户收藏夹视频\n * @param {Object} options 下载选项\n * @param {string} options.user_id 用户UID\n * @param {string} [options.fid] 收藏夹ID，不提供则下载全部收藏夹\n * @param {string} [options.sessdata] 用户的SESSDATA，不提供则从配置文件读取\n * @param {boolean} [options.download_cover] 是否下载封面，默认true\n * @param {boolean} [options.only_audio] 是否仅下载音频，默认false\n * @param {Function} onMessage 消息处理回调函数\n * @returns {Promise<void>}\n */\nexport const downloadFavorites = async (options, onMessage) => {\n  console.log('调用收藏夹下载API, 参数:', options)\n\n  // 提取基本选项和高级选项\n  const { user_id, fid, sessdata, download_cover, only_audio, ...advancedOptions } = options\n\n  const requestBody = {\n    user_id,\n    fid,\n    sessdata,\n    download_cover: download_cover ?? true,\n    only_audio: only_audio ?? false,\n    // 添加高级选项\n    ...advancedOptions\n  }\n\n  // 准备请求头\n  const headers = {\n    'Content-Type': 'application/json',\n  }\n\n  const response = await fetch(`${BASE_URL}/download/download_favorites`, {\n    method: 'POST',\n    headers,\n    body: JSON.stringify(requestBody)\n  })\n\n  if (!response.ok) {\n    const errorData = await response.json()\n    throw new Error(errorData.detail || '下载请求失败')\n  }\n\n  const reader = response.body.getReader()\n  const decoder = new TextDecoder()\n  let buffer = ''\n\n  while (true) {\n    const { value, done } = await reader.read()\n    if (done) break\n\n    buffer += decoder.decode(value, { stream: true })\n\n    // 处理缓冲区中的完整行\n    const lines = buffer.split('\\n')\n    buffer = lines.pop() // 保留最后一个不完整的行\n\n    for (const line of lines) {\n      if (line.startsWith('data: ')) {\n        const content = line.substring(6).trim()\n        if (content && content !== 'close') {\n          onMessage(content)\n        }\n      }\n    }\n  }\n\n  // 处理最后可能剩余的数据\n  if (buffer) {\n    if (buffer.startsWith('data: ')) {\n      const content = buffer.substring(6).trim()\n      if (content && content !== 'close') {\n        onMessage(content)\n      }\n    }\n  }\n}\n\n/**\n * 批量下载多个视频\n * @param {Object} options 下载选项\n * @param {Array} options.videos 要下载的视频列表，每个视频包含bvid、cid、title、author、cover\n * @param {string} [options.sessdata] 用户的SESSDATA，不提供则从配置文件读取\n * @param {boolean} [options.download_cover] 是否下载封面，默认true\n * @param {boolean} [options.only_audio] 是否仅下载音频，默认false\n * @param {Function} onMessage 消息处理回调函数\n * @returns {Promise<void>}\n */\nexport const batchDownloadVideos = async (options, onMessage) => {\n  console.log('调用批量下载API, 参数:', options)\n\n  // 提取基本选项和高级选项\n  const { videos, sessdata, download_cover, only_audio, ...advancedOptions } = options\n\n  const requestBody = {\n    videos,\n    sessdata,\n    download_cover: download_cover ?? true,\n    only_audio: only_audio ?? false,\n    // 添加高级选项\n    ...advancedOptions\n  }\n\n  // 准备请求头\n  const headers = {\n    'Content-Type': 'application/json',\n  }\n\n  const response = await fetch(`${BASE_URL}/download/batch_download`, {\n    method: 'POST',\n    headers,\n    body: JSON.stringify(requestBody)\n  })\n\n  if (!response.ok) {\n    const errorData = await response.json()\n    throw new Error(errorData.detail || '批量下载请求失败')\n  }\n\n  const reader = response.body.getReader()\n  const decoder = new TextDecoder()\n  let buffer = ''\n\n  while (true) {\n    const { value, done } = await reader.read()\n    if (done) break\n\n    buffer += decoder.decode(value, { stream: true })\n\n    // 处理缓冲区中的完整行\n    const lines = buffer.split('\\n')\n    buffer = lines.pop() // 保留最后一个不完整的行\n\n    for (const line of lines) {\n      if (line.startsWith('data: ')) {\n        const content = line.substring(6).trim()\n        if (content && content !== 'close') {\n          onMessage(content)\n        }\n      }\n    }\n  }\n}\n\n/**\n * 下载用户全部投稿视频\n * @param {Object} options 下载选项\n * @param {string} options.user_id 用户UID\n * @param {string} [options.sessdata] 用户的SESSDATA，不提供则从配置文件读取\n * @param {boolean} [options.download_cover] 是否下载封面，默认true\n * @param {boolean} [options.only_audio] 是否仅下载音频，默认false\n * @param {Function} onMessage 消息处理回调函数\n * @returns {Promise<void>}\n */\nexport const downloadUserVideos = async (options, onMessage) => {\n  console.log('调用用户视频下载API, 参数:', options)\n\n  // 提取基本选项和高级选项\n  const { user_id, sessdata, download_cover, only_audio, ...advancedOptions } = options\n\n  const requestBody = {\n    user_id,\n    sessdata,\n    download_cover: download_cover ?? true,\n    only_audio: only_audio ?? false,\n    // 添加高级选项\n    ...advancedOptions\n  }\n\n  // 准备请求头\n  const headers = {\n    'Content-Type': 'application/json',\n  }\n\n  const response = await fetch(`${BASE_URL}/download/download_user_videos`, {\n    method: 'POST',\n    headers,\n    body: JSON.stringify(requestBody)\n  })\n\n  if (!response.ok) {\n    const errorData = await response.json()\n    throw new Error(errorData.detail || '下载请求失败')\n  }\n\n  const reader = response.body.getReader()\n  const decoder = new TextDecoder()\n  let buffer = ''\n\n  while (true) {\n    const { value, done } = await reader.read()\n    if (done) break\n\n    buffer += decoder.decode(value, { stream: true })\n\n    // 处理缓冲区中的完整行\n    const lines = buffer.split('\\n')\n    buffer = lines.pop() // 保留最后一个不完整的行\n\n    for (const line of lines) {\n      if (line.startsWith('data: ')) {\n        const content = line.substring(6).trim()\n        if (content && content !== 'close') {\n          onMessage(content)\n        }\n      }\n    }\n  }\n\n  // 处理最后可能剩余的数据\n  if (buffer) {\n    if (buffer.startsWith('data: ')) {\n      const content = buffer.substring(6).trim()\n      if (content && content !== 'close') {\n        onMessage(content)\n      }\n    }\n  }\n}\n\n\n/**\n * 获取B站视频详细信息\n * @param {Object} params 参数对象\n * @param {string} [params.aid] 视频的avid\n * @param {string} [params.bvid] 视频的bvid\n * @returns {Promise<object>} 包含视频详细信息的响应\n */\nexport const getVideoInfo = (params) => {\n  return instance.get(`/download/video_info`, {\n    params\n  })\n}\n\n// 获取用户投稿视频列表\nexport const getUserVideos = (params) => {\n  return instance.get('/download/user_videos', {\n    params: {\n      mid: params.mid,\n      pn: params.pn || 1,\n      ps: params.ps || 30,\n      tid: params.tid || 0,\n      keyword: params.keyword || '',\n      order: params.order || 'pubdate',\n      sessdata: params.sessdata || ''\n    }\n  })\n}\n\n/**\n * 批量获取视频详情（使用新的超详细接口）\n * @param {object} params - 请求参数\n * @param {number} params.max_videos - 最多处理的视频数量，0表示全部\n * @param {string} params.specific_videos - 要获取的特定视频ID列表，用逗号分隔（可选）\n * @param {boolean} params.use_sessdata - 是否使用SESSDATA获取详情，某些视频需要登录才能查看（可选，默认为true）\n * @returns {Promise<object>} - 包含获取结果的响应\n */\nexport const fetchVideoDetails = (params) => {\n  let maxVideos = 100\n  let specificVideos = ''\n  let useSessdata = true\n\n  // 兼容旧版调用方式，同时支持对象参数和独立参数\n  if (typeof params === 'object') {\n    maxVideos = params.max_videos !== undefined ? params.max_videos : 100\n    specificVideos = params.specific_videos || ''\n    useSessdata = params.use_sessdata !== undefined ? params.use_sessdata : true\n  } else if (typeof params === 'number') {\n    // 旧版调用方式: fetchVideoDetails(maxVideos, specificVideos)\n    maxVideos = params\n    specificVideos = arguments[1] || ''\n  }\n\n  // 使用新的超详细视频详情接口\n  return instance.get('/video_details/batch_fetch_from_history', {\n    params: {\n      max_videos: maxVideos,\n      specific_videos: specificVideos,\n      use_sessdata: useSessdata\n    }\n  })\n}\n\n/**\n * 创建视频详情进度的SSE连接（使用新的超详细接口）\n * @param {object|number} params - 请求参数或更新间隔\n * @param {number} params.update_interval - 更新间隔，单位秒\n * @returns {EventSource} - SSE事件源对象\n */\nexport const createVideoDetailsProgressSSE = (params) => {\n  let updateInterval = 0.1\n\n  // 兼容旧版调用方式，同时支持对象参数和数字参数\n  if (typeof params === 'object') {\n    updateInterval = params.update_interval !== undefined ? params.update_interval : 0.1\n  } else if (typeof params === 'number') {\n    updateInterval = params\n  }\n\n  const baseUrl = instance.defaults.baseURL\n\n  // 构建基本URL，使用新的超详细接口\n  let url = `${baseUrl}/video_details/progress?update_interval=${updateInterval}`\n\n  return new EventSource(url)\n}\n\n/**\n * 获取视频详情统计数据（使用新的超详细接口）\n * @returns {Promise<object>} - 包含视频详情统计的响应\n */\nexport const getVideoDetailsStats = () => {\n  return instance.get('/video_details/stats')\n}\n\n/**\n * 获取失效视频明细\n * @param {Object} params 可选过滤参数\n * @returns {Promise<object>}\n */\nexport const getInvalidVideos = (params = {}) => {\n  return instance.get('/fetch/invalid-videos', { params })\n}\n\n/**\n * 停止视频详情获取任务\n * @returns {Promise<object>} - 包含停止结果的响应\n */\nexport const stopVideoDetailsFetch = () => {\n  return instance.post('/video_details/stop')\n}\n/**\n * 获取视频观看时长信息（合集视频）\n * @param {Object} params 参数对象\n * @param {string} params.bvid 视频的bvid\n * @param {boolean} [params.use_sessdata=true] 是否使用登录状态查询\n * @returns {Promise<object>} 包含视频合集观看时长信息的响应\n */\nexport const getVideoSeasonInfo = (params) => {\n  return instance.get('/download/video_season_info', {\n    params\n  })\n}\n\n/**\n * 检查视频是否为合集\n * @param {string} url 视频URL\n * @param {string} [sessdata] 可选的SESSDATA用于认证\n * @returns {Promise<object>} 包含合集信息的响应\n */\nexport const checkCollection = (url, sessdata = null) => {\n  const params = { url }\n  if (sessdata) {\n    params.sessdata = sessdata\n  }\n  return instance.get('/collection/check_collection', {\n    params\n  })\n}\n\n/**\n * 下载整个合集\n * @param {Object} params 下载参数\n * @param {string} params.url 合集URL\n * @param {number} params.cid 视频的CID\n * @param {Function} onMessage 消息回调函数\n * @returns {Promise<void>}\n */\nexport const downloadCollection = async (params, onMessage) => {\n  try {\n    const response = await fetch(`${BASE_URL}/collection/download_collection`, {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n      },\n      body: JSON.stringify(params)\n    })\n\n    if (!response.ok) {\n      throw new Error(`HTTP error! status: ${response.status}`)\n    }\n\n    const reader = response.body.getReader()\n    const decoder = new TextDecoder()\n\n    while (true) {\n      const { done, value } = await reader.read()\n      if (done) break\n\n      const chunk = decoder.decode(value)\n      const lines = chunk.split('\\n')\n\n      for (const line of lines) {\n        if (line.startsWith('data: ')) {\n          const message = line.slice(6)\n          if (message && onMessage) {\n            onMessage(message)\n          }\n        }\n      }\n    }\n  } catch (error) {\n    console.error('下载合集失败:', error)\n    throw error\n  }\n}\n\n// =============================\n// 动态相关接口（/dynamic）\n// =============================\n\n/**\n * 列出数据库中已有动态的 UP 列表（含名称与头像）\n * GET /dynamic/db/hosts\n * @param {number} [limit=50] - 每页数量（1-200）\n * @param {number} [offset=0] - 偏移量（>=0）\n */\nexport const getDynamicDbHosts = (limit = 50, offset = 0) => {\n  return instance.get('/dynamic/db/hosts', {\n    params: { limit, offset }\n  })\n}\n\n/**\n * 列出指定 UP 的动态（来自数据库）\n * GET /dynamic/db/space/{host_mid}\n * @param {string|number} hostMid - UP 的 mid\n * @param {number} [limit=20] - 每页数量（1-200）\n * @param {number} [offset=0] - 偏移量（>=0）\n */\nexport const getDynamicDbSpace = (hostMid, limit = 20, offset = 0) => {\n  return instance.get(`/dynamic/db/space/${hostMid}`, {\n    params: { limit, offset }\n  })\n}\n/**\n * 自动从上次位置继续抓取（页级延迟 3-5 秒，支持“页级停止”）\n * GET /dynamic/space/auto/{host_mid}\n * @param {string|number} hostMid - UP 的 mid\n * @param {Object} params - 查询参数\n * @param {boolean} [params.need_top=false]\n * @param {boolean} [params.save_to_db=true]\n * @param {boolean} [params.save_media=true]\n */\nexport const startDynamicAutoFetch = (hostMid, params = {}) => {\n  const {\n    need_top = false,\n    save_to_db = true,\n    save_media = true\n  } = params\n  return instance.get(`/dynamic/space/auto/${hostMid}`, {\n    params: { need_top, save_to_db, save_media }\n  })\n}\n\n/**\n * 创建动态抓取进度的 SSE 连接\n * GET /dynamic/space/auto/{host_mid}/progress\n * @param {string|number} hostMid - UP 的 mid\n * @returns {EventSource}\n */\nexport const createDynamicProgressSSE = (hostMid) => {\n  const baseUrl = instance.defaults.baseURL\n  const url = `${baseUrl}/dynamic/space/auto/${hostMid}/progress`\n  return new EventSource(url)\n}\n\n/**\n * 发送停止信号（当前页抓取完成后停止并记录 offset）\n * POST /dynamic/space/auto/{host_mid}/stop\n * @param {string|number} hostMid - UP 的 mid\n */\nexport const stopDynamicAutoFetch = (hostMid) => {\n  return instance.post(`/dynamic/space/auto/${hostMid}/stop`)\n}\n// =============================\n// 动态删除接口（/dynamic）\n// =============================\n\n/**\n * 清理指定 UP 的动态及媒体文件\n * DELETE /dynamic/space/{host_mid}\n * @param {string|number} hostMid - UP 的 mid\n * @returns {Promise<any>}\n */\nexport const deleteDynamicSpace = (hostMid) => {\n  return instance.delete(`/dynamic/space/${hostMid}`)\n}\n"
  },
  {
    "path": "src/components/PrivacyControl.vue",
    "content": "<template>\n  <div class=\"privacy-control\">\n    <div class=\"privacy-status\" :class=\"{ 'privacy-enabled': isPrivacyEnabled }\">\n      隐私模式: {{ isPrivacyEnabled ? '已开启' : '已关闭' }}\n    </div>\n    <button @click=\"togglePrivacy\" class=\"privacy-toggle\">\n      {{ isPrivacyEnabled ? '关闭隐私模式' : '开启隐私模式' }}\n    </button>\n    <div class=\"privacy-info\">\n      {{ isPrivacyEnabled ? '隐私模式开启时将隐藏敏感信息' : '隐私模式关闭时将显示完整信息' }}\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, onUnmounted } from 'vue'\nimport privacyManager from '../utils/privacyManager'\n\nconst isPrivacyEnabled = ref(false)\nlet checkInterval = null\n\n// 切换隐私模式\nconst togglePrivacy = () => {\n  const newState = privacyManager.toggle()\n  isPrivacyEnabled.value = newState\n}\n\n// 隐私模式变化处理\nconst handlePrivacyChange = (enabled) => {\n  isPrivacyEnabled.value = enabled\n}\n\nonMounted(() => {\n  // 初始化状态\n  isPrivacyEnabled.value = privacyManager.isEnabled()\n  \n  // 添加隐私模式变化监听\n  privacyManager.addListener(handlePrivacyChange)\n  \n  // 定时检查，确保UI状态与实际存储状态一致\n  checkInterval = setInterval(() => {\n    const currentState = privacyManager.isEnabled()\n    if (isPrivacyEnabled.value !== currentState) {\n      isPrivacyEnabled.value = currentState\n    }\n  }, 1000)\n})\n\nonUnmounted(() => {\n  if (checkInterval) {\n    clearInterval(checkInterval)\n  }\n})\n</script>\n\n<style scoped>\n.privacy-control {\n  margin: 10px;\n  padding: 10px;\n  border: 1px solid #ddd;\n  border-radius: 8px;\n  background-color: #f9f9f9;\n  text-align: center;\n}\n\n.privacy-status {\n  font-weight: bold;\n  margin-bottom: 10px;\n}\n\n.privacy-enabled {\n  color: #e74c3c;\n}\n\n.privacy-toggle {\n  background-color: #3498db;\n  color: white;\n  border: none;\n  padding: 8px 16px;\n  border-radius: 4px;\n  cursor: pointer;\n  transition: background-color 0.3s;\n}\n\n.privacy-toggle:hover {\n  background-color: #2980b9;\n}\n\n.privacy-info {\n  margin-top: 10px;\n  font-size: 0.9em;\n  color: #666;\n}\n</style> "
  },
  {
    "path": "src/components/tailwind/ArtPlayerWithDanmaku.vue",
    "content": "<template>\n  <div ref=\"artPlayerContainer\" class=\"art-player-container\"></div>\n</template>\n\n<script setup>\nimport { ref, onMounted, onUnmounted, watch } from 'vue'\nimport Artplayer from 'artplayer'\nimport artplayerPluginDanmuku from 'artplayer-plugin-danmuku'\nimport { getDanmakuFile } from '../../api/api'\n\n// 定义组件属性\nconst props = defineProps({\n  // 视频源URL\n  videoSrc: {\n    type: String,\n    required: true\n  },\n  // 弹幕文件路径\n  danmakuFilePath: {\n    type: String,\n    default: ''\n  },\n  // 视频CID（用于获取弹幕）\n  cid: {\n    type: String,\n    default: ''\n  },\n  // 视频封面\n  poster: {\n    type: String,\n    default: ''\n  },\n  // 视频标题\n  title: {\n    type: String,\n    default: '视频播放'\n  },\n  // 是否自动播放\n  autoplay: {\n    type: Boolean,\n    default: false\n  },\n  // 播放器宽度\n  width: {\n    type: String,\n    default: '100%'\n  },\n  // 播放器高度\n  height: {\n    type: String,\n    default: '100%'\n  }\n})\n\n// 定义事件\nconst emit = defineEmits(['ready', 'error'])\n\n// DOM引用\nconst artPlayerContainer = ref(null)\n// 播放器实例\nconst player = ref(null)\n// 弹幕数据\nconst danmakuData = ref([])\n\n// 加载弹幕文件内容\nconst loadDanmaku = async () => {\n  if (!props.danmakuFilePath && !props.cid) {\n    console.log('没有提供弹幕文件路径或CID，跳过弹幕加载')\n    return []\n  }\n\n  try {\n    // 优先使用文件路径\n    const path = props.danmakuFilePath || ''\n    const cid = props.cid || ''\n    \n    // 获取弹幕文件内容\n    const response = await getDanmakuFile(cid, path)\n    \n    if (!response || !response.data) {\n      console.warn('弹幕文件内容为空')\n      return []\n    }\n    \n    // 解析ASS格式弹幕\n    const danmakuItems = parseAssDanmaku(response.data)\n    danmakuData.value = danmakuItems\n    \n    // 如果播放器已存在，更新弹幕\n    if (player.value && player.value.plugins.artplayerPluginDanmuku) {\n      player.value.plugins.artplayerPluginDanmuku.config({\n        danmuku: danmakuItems\n      })\n    }\n    \n    return danmakuItems\n  } catch (error) {\n    console.error('加载弹幕文件失败:', error)\n    return []\n  }\n}\n\n// 解析ASS格式弹幕\nconst parseAssDanmaku = (assContent) => {\n  if (!assContent) return []\n  \n  const danmakuItems = []\n  const lines = assContent.split('\\n')\n  console.log(\"弹幕文件总行数:\", lines.length)\n  \n  // 查找Dialogue行\n  lines.forEach((line, index) => {\n    if (line.startsWith('Dialogue:')) {\n      try {\n        // ASS格式: Dialogue: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n        const parts = line.split(',')\n        if (parts.length >= 10) {\n          // 提取开始时间\n          const startTimeStr = parts[1].trim()\n          // 转换时间格式为秒 (h:mm:ss.ms)\n          const startTime = parseAssTime(startTimeStr)\n          \n          // 提取样式名称，可能用于确定弹幕位置\n          const styleName = parts[3].trim()\n          \n          // 提取弹幕文本 (最后一个部分)\n          const textParts = parts.slice(9)\n          let text = textParts.join(',')\n          \n          // 提取颜色信息\n          let color = '#ffffff' // 默认白色\n          \n          // B站弹幕可能使用大括号包裹样式，如 {\\\\c&HFFFFFF&}\n          // 或者直接使用反斜杠，如 \\\\c&HFFFFFF&\n          let colorRegex = /(\\\\c|{\\\\c)&H([0-9A-Fa-f]{2,6})&/\n          let colorMatch = text.match(colorRegex)\n          \n          if (colorMatch) {\n            let colorCode = colorMatch[2]\n            console.log(`找到颜色代码: ${colorCode}, 行: ${index}, 文本: ${text}`)\n            \n            // 标准化颜色代码为6位\n            while (colorCode.length < 6) {\n              colorCode = '0' + colorCode\n            }\n            \n            // B站弹幕的颜色格式通常是BBGGRR，需要转换为网页的RGB格式\n            if (colorCode.length === 6) {\n              const blue = colorCode.substring(0, 2)\n              const green = colorCode.substring(2, 4)\n              const red = colorCode.substring(4, 6)\n              color = `#${red}${green}${blue}`\n              console.log(`转换后颜色: ${color}`)\n            }\n          }\n          \n          // 确定弹幕模式\n          // mode: 0=滚动弹幕, 1=顶部弹幕, 2=底部弹幕\n          let mode = 0 // 默认为滚动弹幕\n          \n          // B站弹幕定位可能有多种形式：\n          // 1. 使用\\an指定位置，\\an8是顶部，\\an2是底部\n          // 2. 使用\\pos固定位置显示\n          // 3. 使用\\move实现滚动效果\n          \n          // 检查是否有位置标记\n          if (text.includes('\\\\an8') || text.includes('{\\\\an8}')) {\n            mode = 1 // 顶部弹幕\n            console.log(\"找到顶部弹幕: \", text)\n          } else if (text.includes('\\\\an2') || text.includes('{\\\\an2}')) {\n            mode = 2 // 底部弹幕\n            console.log(\"找到底部弹幕: \", text)\n          } else if (text.includes('\\\\pos') || text.includes('{\\\\pos')) {\n            // 根据Y坐标判断是顶部还是底部弹幕\n            const posMatch = text.match(/\\\\pos\\((\\d+),\\s*(\\d+)\\)/) || text.match(/{\\\\pos\\((\\d+),\\s*(\\d+)\\)}/)\n            if (posMatch) {\n              const yPos = parseInt(posMatch[2])\n              // 屏幕高度一般为1080，上半部分认为是顶部弹幕，下半部分认为是底部弹幕\n              if (yPos < 540) {\n                mode = 1 // 顶部弹幕\n                console.log(\"找到顶部定位弹幕: \", text)\n              } else {\n                mode = 2 // 底部弹幕\n                console.log(\"找到底部定位弹幕: \", text)\n              }\n            }\n          } else if (text.includes('\\\\move') || text.includes('{\\\\move')) {\n            // 移动弹幕默认为滚动弹幕(mode=0)\n            console.log(\"找到滚动弹幕: \", text)\n          } else if (styleName.toLowerCase().includes('top')) {\n            mode = 1 // 通过样式名称判断顶部弹幕\n          } else if (styleName.toLowerCase().includes('bottom')) {\n            mode = 2 // 通过样式名称判断底部弹幕\n          }\n          \n          // 移除ASS格式标签，保留纯文本\n          text = text.replace(/{[^}]*}/g, '') // 去除{}中的内容\n          text = text.replace(/\\\\[a-zA-Z0-9]+(&H[0-9A-Fa-f]+&)?/g, '') // 去除\\command&Hxxxx&类格式\n          text = text.replace(/\\\\[a-zA-Z0-9]+\\([^)]*\\)/g, '') // 去除\\command(params)类格式\n          text = text.trim()\n          \n          // 创建弹幕对象\n          danmakuItems.push({\n            text,\n            time: startTime,\n            color: color, // 设置提取的颜色\n            border: false,\n            mode: mode, // 设置弹幕模式\n          })\n        }\n      } catch (error) {\n        console.warn('解析弹幕行失败:', line, error)\n      }\n    }\n  })\n  \n  console.log(`成功解析${danmakuItems.length}条弹幕`)\n  return danmakuItems\n}\n\n// 将ASS时间格式转换为秒\nconst parseAssTime = (timeStr) => {\n  const parts = timeStr.split(':')\n  if (parts.length === 3) {\n    const hours = parseInt(parts[0])\n    const minutes = parseInt(parts[1])\n    const seconds = parseFloat(parts[2])\n    \n    return hours * 3600 + minutes * 60 + seconds\n  }\n  return 0\n}\n\n// 初始化播放器\nconst initPlayer = async () => {\n  if (!artPlayerContainer.value) return\n  \n  // 销毁现有播放器实例\n  if (player.value) {\n    player.value.destroy()\n    player.value = null\n  }\n  \n  // 加载弹幕数据\n  await loadDanmaku()\n  \n  // 创建播放器选项\n  const options = {\n    container: artPlayerContainer.value,\n    url: props.videoSrc,\n    title: props.title,\n    poster: props.poster,\n    volume: 0.7,\n    autoplay: props.autoplay,\n    autoSize: false,\n    autoMini: true,\n    loop: false,\n    flip: true,\n    playbackRate: true,\n    aspectRatio: true,\n    setting: true,\n    hotkey: true,\n    pip: true,\n    fullscreen: true,\n    fullscreenWeb: true,\n    subtitleOffset: true,\n    miniProgressBar: true,\n    playsInline: true,\n    lock: true,\n    fastForward: true,\n    autoPlayback: true,\n    theme: '#fb7299', // B站粉色主题\n    lang: 'zh-cn',\n    moreVideoAttr: {\n      crossOrigin: 'anonymous',\n    },\n    icons: {\n      loading: '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"50\" height=\"50\" viewBox=\"0 0 24 24\"><path fill=\"#fb7299\" d=\"M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z\" opacity=\".25\"/><path fill=\"#fb7299\" d=\"M12,4a8,8,0,0,1,7.89,6.7A1.53,1.53,0,0,0,21.38,12h0a1.5,1.5,0,0,0,1.48-1.75,11,11,0,0,0-21.72,0A1.5,1.5,0,0,0,2.62,12h0a1.53,1.53,0,0,0,1.49-1.3A8,8,0,0,1,12,4Z\"><animateTransform attributeName=\"transform\" dur=\"0.75s\" repeatCount=\"indefinite\" type=\"rotate\" values=\"0 12 12;360 12 12\"/></path></svg>',\n    },\n    customType: {},\n  }\n  \n  // 如果有弹幕数据，添加弹幕插件\n  if (danmakuData.value.length > 0) {\n    options.plugins = [\n      artplayerPluginDanmuku({\n        danmuku: danmakuData.value,\n        speed: 5, // 弹幕速度\n        opacity: 0.8, // 弹幕透明度\n        fontSize: 25, // 弹幕字体大小\n        color: '#ffffff', // 默认颜色，实际会被每条弹幕自己的颜色覆盖\n        mode: 0, // 默认弹幕模式\n        margin: [10, '25%'], // 弹幕上下边距\n        antiOverlap: true, // 防重叠\n        useWorker: true, // 使用Web Worker\n        synchronousPlayback: true, // 同步播放（随视频播放速度变化）\n        filter: () => true, // 弹幕过滤\n        lockTime: 0, // 锁定弹幕时间\n        maxLength: 100, // 最大长度\n        minWidth: 200, // 弹幕最小宽度\n        maxWidth: 400, // 弹幕最大宽度\n        theme: 'dark', // 弹幕主题\n        disableDanmuku: false, // 不禁用弹幕\n        defaultOff: false, // 默认开启弹幕\n        controls: [\n          {\n            name: 'danmuku',\n            position: 'right',\n            html: '弹幕',\n            tooltip: '显示/隐藏弹幕',\n            style: {\n              padding: '0 10px',\n              fontSize: '14px',\n              fontWeight: 'bold'\n            }\n          }\n        ]\n      }),\n    ]\n  }\n  \n  try {\n    // 初始化播放器\n    player.value = new Artplayer(options)\n    \n    // 注册事件监听\n    player.value.on('ready', () => {\n      emit('ready', player.value)\n    })\n    \n    player.value.on('error', (error) => {\n      console.error('播放器错误:', error)\n      emit('error', error)\n    })\n    \n  } catch (error) {\n    console.error('初始化播放器失败:', error)\n    emit('error', error)\n  }\n}\n\n// 监听属性变化\nwatch(() => props.videoSrc, () => {\n  if (player.value) {\n    player.value.switchUrl(props.videoSrc)\n    \n    // 如果有弹幕，重新加载\n    if (props.danmakuFilePath || props.cid) {\n      loadDanmaku()\n    }\n  } else {\n    initPlayer()\n  }\n}, { immediate: false })\n\n// 监听弹幕路径变化\nwatch([() => props.danmakuFilePath, () => props.cid], () => {\n  if (player.value && (props.danmakuFilePath || props.cid)) {\n    loadDanmaku()\n  }\n}, { immediate: false })\n\n// 组件挂载时初始化\nonMounted(() => {\n  if (props.videoSrc) {\n    initPlayer()\n  }\n})\n\n// 组件卸载时销毁播放器\nonUnmounted(() => {\n  if (player.value) {\n    player.value.destroy()\n    player.value = null\n  }\n})\n\n// 暴露播放器实例和方法\ndefineExpose({\n  player: player,\n  reload: initPlayer,\n  loadDanmaku\n})\n</script>\n\n<style scoped>\n.art-player-container {\n  width: v-bind(width);\n  height: v-bind(height);\n  background-color: #000;\n  position: relative;\n}\n</style> "
  },
  {
    "path": "src/components/tailwind/BusinessTypeSelector.vue",
    "content": "<template>\n  <van-popup\n    :show=\"show\"\n    @update:show=\"$emit('update:show', $event)\"\n    position=\"bottom\"\n    :style=\"{ height: '35%' }\"\n    round\n  >\n    <div class=\"pt-6 px-3\">\n      <div class=\"grid grid-cols-2 gap-2\">\n        <!-- 全部 -->\n        <div\n          class=\"flex items-center p-2 rounded-lg cursor-pointer border\"\n          :class=\"selectedType === '' ? 'border-[#fb7299] bg-[#fb7299]/5' : 'border-gray-200 hover:border-[#fb7299]/50'\"\n          @click=\"selectType('')\"\n        >\n          <div class=\"flex-1\">\n            <div class=\"font-medium\">全部</div>\n            <div class=\"text-xs text-gray-500\">显示所有类型</div>\n          </div>\n          <div v-if=\"selectedType === ''\" class=\"text-[#fb7299]\">\n            <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 13l4 4L19 7\" />\n            </svg>\n          </div>\n        </div>\n        \n        <!-- 普通视频 -->\n        <div\n          class=\"flex items-center p-2 rounded-lg cursor-pointer border\"\n          :class=\"selectedType === 'archive' ? 'border-[#fb7299] bg-[#fb7299]/5' : 'border-gray-200 hover:border-[#fb7299]/50'\"\n          @click=\"selectType('archive')\"\n        >\n          <div class=\"flex-1\">\n            <div class=\"font-medium\">普通视频</div>\n            <div class=\"text-xs text-gray-500\">B站普通投稿视频</div>\n          </div>\n          <div v-if=\"selectedType === 'archive'\" class=\"text-[#fb7299]\">\n            <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 13l4 4L19 7\" />\n            </svg>\n          </div>\n        </div>\n        \n        <!-- 番剧 -->\n        <div\n          class=\"flex items-center p-2 rounded-lg cursor-pointer border\"\n          :class=\"selectedType === 'pgc' ? 'border-[#fb7299] bg-[#fb7299]/5' : 'border-gray-200 hover:border-[#fb7299]/50'\"\n          @click=\"selectType('pgc')\"\n        >\n          <div class=\"flex-1\">\n            <div class=\"font-medium\">番剧</div>\n            <div class=\"text-xs text-gray-500\">番剧、电影、纪录片等</div>\n          </div>\n          <div v-if=\"selectedType === 'pgc'\" class=\"text-[#fb7299]\">\n            <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 13l4 4L19 7\" />\n            </svg>\n          </div>\n        </div>\n        \n        <!-- 直播 -->\n        <div\n          class=\"flex items-center p-2 rounded-lg cursor-pointer border\"\n          :class=\"selectedType === 'live' ? 'border-[#fb7299] bg-[#fb7299]/5' : 'border-gray-200 hover:border-[#fb7299]/50'\"\n          @click=\"selectType('live')\"\n        >\n          <div class=\"flex-1\">\n            <div class=\"font-medium\">直播</div>\n            <div class=\"text-xs text-gray-500\">B站直播间</div>\n          </div>\n          <div v-if=\"selectedType === 'live'\" class=\"text-[#fb7299]\">\n            <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 13l4 4L19 7\" />\n            </svg>\n          </div>\n        </div>\n        \n        <!-- 文章 -->\n        <div\n          class=\"flex items-center p-2 rounded-lg cursor-pointer border\"\n          :class=\"selectedType === 'article' ? 'border-[#fb7299] bg-[#fb7299]/5' : 'border-gray-200 hover:border-[#fb7299]/50'\"\n          @click=\"selectType('article')\"\n        >\n          <div class=\"flex-1\">\n            <div class=\"font-medium\">文章</div>\n            <div class=\"text-xs text-gray-500\">B站专栏文章</div>\n          </div>\n          <div v-if=\"selectedType === 'article'\" class=\"text-[#fb7299]\">\n            <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 13l4 4L19 7\" />\n            </svg>\n          </div>\n        </div>\n        \n        <!-- 文集 -->\n        <div\n          class=\"flex items-center p-2 rounded-lg cursor-pointer border\"\n          :class=\"selectedType === 'article-list' ? 'border-[#fb7299] bg-[#fb7299]/5' : 'border-gray-200 hover:border-[#fb7299]/50'\"\n          @click=\"selectType('article-list')\"\n        >\n          <div class=\"flex-1\">\n            <div class=\"font-medium\">文集</div>\n            <div class=\"text-xs text-gray-500\">B站专栏文集</div>\n          </div>\n          <div v-if=\"selectedType === 'article-list'\" class=\"text-[#fb7299]\">\n            <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 13l4 4L19 7\" />\n            </svg>\n          </div>\n        </div>\n      </div>\n    </div>\n  </van-popup>\n</template>\n\n<script setup>\nimport { ref, watch } from 'vue'\nimport 'vant/es/popup/style'\n\nconst props = defineProps({\n  show: {\n    type: Boolean,\n    default: false\n  },\n  selectedBusiness: {\n    type: String,\n    default: ''\n  }\n})\n\nconst emit = defineEmits(['update:show', 'select'])\n\n// 当前选中的类型\nconst selectedType = ref(props.selectedBusiness)\n\n// 监听props变化\nwatch(() => props.selectedBusiness, (newVal) => {\n  selectedType.value = newVal\n})\n\n// 监听show变化，当弹窗显示时重新同步selectedType\nwatch(() => props.show, (newVal) => {\n  if (newVal) {\n    selectedType.value = props.selectedBusiness\n  }\n})\n\n// 选择类型\nconst selectType = (type) => {\n  selectedType.value = type\n  emit('select', type)\n  emit('update:show', false)\n}\n\n// 业务类型映射表\nconst businessTypeMap = {\n  '': '全部',\n  'archive': '普通视频',\n  'pgc': '番剧',\n  'live': '直播',\n  'article': '文章',\n  'article-list': '文集'\n}\n\n// 导出映射表，方便外部使用\ndefineExpose({\n  businessTypeMap\n})\n</script> "
  },
  {
    "path": "src/components/tailwind/CustomDropdown.vue",
    "content": "<template>\n  <div class=\"relative\" :style=\"containerStyle\">\n    <button \n      ref=\"triggerBtn\"\n      @click.stop=\"toggleDropdown\" \n      type=\"button\"\n      :class=\"[\n        'custom-dropdown-trigger w-full py-1.5 px-2 border border-gray-300 dark:border-gray-600 rounded-md text-xs text-gray-800 dark:text-gray-200 focus:border-[#fb7299] focus:outline-none focus:ring focus:ring-[#fb7299]/20 flex items-center justify-between bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-200',\n        customClass,\n        'whitespace-nowrap overflow-hidden'\n      ]\"\n    >\n      <slot name=\"trigger-content\">\n        <span class=\"truncate mr-1\">{{ selectedText }}</span>\n      </slot>\n      <svg class=\"w-3 h-3 text-[#fb7299] flex-shrink-0\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 9l-7 7-7-7\" />\n      </svg>\n    </button>\n    \n    <!-- 下拉菜单 -->\n    <div \n      v-if=\"showDropdown\" \n      @click.stop\n      class=\"fixed z-50 mt-1 rounded-lg bg-white dark:bg-gray-800 shadow-lg ring-1 ring-black ring-opacity-5 dark:ring-white/10 border border-gray-200 dark:border-gray-700 focus:outline-none max-w-[90vw]\"\n      :style=\"dropdownStyle\"\n    >\n      <div class=\"py-1 max-h-60 overflow-auto\">\n        <slot>\n          <button\n            v-for=\"(option, index) in options\" \n            :key=\"index\"\n            @click.stop=\"selectOption(option.value)\"\n            class=\"w-full px-2 py-1 text-xs text-left hover:bg-[#fb7299]/5 hover:text-[#fb7299] transition-colors flex items-center whitespace-nowrap\"\n            :class=\"{'text-[#fb7299] bg-[#fb7299]/5 font-medium': modelValue === option.value}\"\n          >\n            {{ option.label }}\n          </button>\n        </slot>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, onUnmounted, watch, computed } from 'vue'\n\nconst props = defineProps({\n  modelValue: {\n    type: [String, Number],\n    default: ''\n  },\n  options: {\n    type: Array,\n    default: () => []\n  },\n  selectedText: {\n    type: String,\n    default: '请选择'\n  },\n  customClass: {\n    type: String,\n    default: ''\n  },\n  minWidth: {\n    type: Number,\n    default: 120\n  },\n  useFixedWidth: {\n    type: Boolean,\n    default: false\n  },\n  buttonWidth: {\n    type: [String, Number],\n    default: null\n  }\n})\n\nconst emit = defineEmits(['update:modelValue', 'change'])\n\n// 计算容器样式（包括宽度控制）\nconst containerStyle = computed(() => {\n  if (props.buttonWidth) {\n    return {\n      width: typeof props.buttonWidth === 'number' ? `${props.buttonWidth}px` : props.buttonWidth\n    }\n  }\n  return {}\n})\n\nconst triggerBtn = ref(null)\nconst showDropdown = ref(false)\nconst dropdownStyle = ref({})\n\n// 切换下拉菜单\nconst toggleDropdown = () => {\n  showDropdown.value = !showDropdown.value\n  \n  if (showDropdown.value) {\n    // 计算位置\n    calculateDropdownPosition()\n  }\n}\n\n// 选择选项\nconst selectOption = (value) => {\n  emit('update:modelValue', value)\n  emit('change', value)\n  showDropdown.value = false\n}\n\n// 计算下拉菜单位置\nconst calculateDropdownPosition = () => {\n  setTimeout(() => {\n    if (triggerBtn.value) {\n      const rect = triggerBtn.value.getBoundingClientRect()\n      // 使用按钮自身的宽度或指定的最小宽度，不自动放大\n      const width = props.useFixedWidth ? rect.width : Math.max(rect.width, props.minWidth || 80)\n      \n      // 获取视口宽度，确保菜单不会超出边界\n      const viewportWidth = window.innerWidth\n      let left = rect.left\n      \n      // 如果下拉菜单右边缘超出视口，调整位置\n      if (left + width > viewportWidth - 10) {\n        left = Math.max(10, viewportWidth - width - 10)\n      }\n      \n      dropdownStyle.value = {\n        top: `${rect.bottom + window.scrollY}px`,\n        left: `${left}px`,\n        width: `${width}px`,\n        maxWidth: viewportWidth - 20 + 'px' // 确保不会超过视口宽度\n      }\n    }\n  }, 0)\n}\n\n// 点击外部关闭下拉菜单\nconst closeDropdown = (event) => {\n  if (triggerBtn.value && !triggerBtn.value.contains(event.target)) {\n    showDropdown.value = false\n  }\n}\n\n// 处理窗口大小变化\nconst handleResize = () => {\n  if (showDropdown.value) {\n    calculateDropdownPosition()\n  }\n}\n\n// 处理滚动事件\nconst handleScroll = () => {\n  if (showDropdown.value) {\n    calculateDropdownPosition()\n  }\n}\n\nonMounted(() => {\n  document.addEventListener('click', closeDropdown)\n  window.addEventListener('resize', handleResize)\n  window.addEventListener('scroll', handleScroll)\n})\n\nonUnmounted(() => {\n  document.removeEventListener('click', closeDropdown)\n  window.removeEventListener('resize', handleResize)\n  window.removeEventListener('scroll', handleScroll)\n})\n\n// 监听modelValue变化\nwatch(() => props.modelValue, (newVal) => {\n  // 如果需要在值变化时做额外处理\n})\n</script>\n\n<style scoped>\n/* 任何自定义样式 */\n</style> "
  },
  {
    "path": "src/components/tailwind/DataSyncManager.vue",
    "content": "<template>\n  <div class=\"fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50\" v-if=\"showModal\">\n    <div class=\"bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 p-6 rounded-lg shadow-lg max-w-2xl w-full max-h-[80vh] overflow-y-auto border border-gray-200 dark:border-gray-700\">\n      <!-- 头部标题 -->\n      <div class=\"flex justify-between items-center mb-4\">\n        <h2 class=\"text-xl font-bold text-gray-800 dark:text-gray-100\">{{ currentTab === 'sync' ? '数据同步' : '数据完整性检查' }}</h2>\n        <button @click=\"closeModal\" class=\"text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300\">\n          <svg class=\"w-6 h-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\"></path>\n          </svg>\n        </button>\n      </div>\n\n      <!-- 导航标签 -->\n      <div class=\"flex border-b mb-4\">\n        <button\n          @click=\"currentTab = 'sync'\"\n          class=\"py-2 px-4 font-medium text-sm transition-colors duration-200\"\n          :class=\"currentTab === 'sync' ? 'text-pink-500 border-b-2 border-pink-500' : 'text-gray-600 hover:text-pink-400'\"\n        >\n          数据同步\n        </button>\n        <button\n          @click=\"currentTab = 'integrity'\"\n          class=\"py-2 px-4 font-medium text-sm transition-colors duration-200\"\n          :class=\"currentTab === 'integrity' ? 'text-pink-500 border-b-2 border-pink-500' : 'text-gray-600 hover:text-pink-400'\"\n        >\n          数据完整性检查\n        </button>\n      </div>\n\n      <!-- 数据同步面板 -->\n      <div v-if=\"currentTab === 'sync'\">\n        <div class=\"mb-4\">\n          <p class=\"text-sm text-gray-600 mb-2\">同步数据库和JSON文件之间的历史记录数据。</p>\n\n          <div class=\"flex flex-col sm:flex-row gap-4 mb-4\">\n            <div class=\"flex-1\">\n              <label class=\"block text-sm font-medium text-gray-700 mb-1\">数据库路径</label>\n              <input\n                v-model=\"dbPath\"\n                type=\"text\"\n                class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-pink-500 focus:border-pink-500 text-sm dark:bg-gray-700 dark:text-gray-100\"\n              />\n            </div>\n            <div class=\"flex-1\">\n              <label class=\"block text-sm font-medium text-gray-700 mb-1\">JSON文件路径</label>\n              <input\n                v-model=\"jsonPath\"\n                type=\"text\"\n                class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-pink-500 focus:border-pink-500 text-sm dark:bg-gray-700 dark:text-gray-100\"\n              />\n            </div>\n          </div>\n\n          <button\n            @click=\"startSync\"\n            class=\"w-full bg-pink-600 hover:bg-pink-700 text-white font-medium py-2 px-4 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500 disabled:opacity-50 disabled:cursor-not-allowed\"\n            :disabled=\"isSyncing\"\n          >\n            <span v-if=\"isSyncing\" class=\"flex items-center justify-center\">\n              <svg class=\"animate-spin -ml-1 mr-2 h-4 w-4 text-white\" fill=\"none\" viewBox=\"0 0 24 24\">\n                <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n                <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n              </svg>\n              数据同步中...\n            </span>\n            <span v-else>开始同步</span>\n          </button>\n        </div>\n\n        <!-- 同步结果显示 -->\n        <div v-if=\"syncResult\" class=\"mt-6 border-t pt-4\">\n          <h3 class=\"font-medium text-gray-900 mb-2\">最近同步结果</h3>\n          <div class=\"bg-gray-50 dark:bg-gray-900 p-3 rounded-md border border-gray-200 dark:border-gray-700\">\n            <div class=\"grid grid-cols-2 gap-2 mb-3\">\n              <div class=\"text-sm\">\n                <span class=\"text-gray-500\">同步时间：</span>\n                <span class=\"text-gray-900 dark:text-gray-100\">{{ formatDateTime(syncResult.timestamp) }}</span>\n              </div>\n              <div class=\"text-sm\">\n                <span class=\"text-gray-500\">总同步记录：</span>\n                <span class=\"text-gray-900 dark:text-gray-100 font-medium\">{{ syncResult.total_synced }}</span>\n              </div>\n              <div class=\"text-sm\">\n                <span class=\"text-gray-500\">JSON导入数据库：</span>\n                <span class=\"text-gray-900\">{{ syncResult.json_to_db_count }}</span>\n              </div>\n              <div class=\"text-sm\">\n                <span class=\"text-gray-500\">数据库导出JSON：</span>\n                <span class=\"text-gray-900 dark:text-gray-100\">{{ syncResult.db_to_json_count }}</span>\n              </div>\n            </div>\n\n            <div v-if=\"syncResult.synced_days && syncResult.synced_days.length > 0\">\n              <h4 class=\"text-sm font-medium text-gray-700 mb-2\">同步的日期</h4>\n              <div class=\"max-h-48 overflow-y-auto bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700\">\n                <div v-for=\"(day, index) in syncResult.synced_days\" :key=\"index\" class=\"p-2 text-sm border-b last:border-b-0\">\n                  <div class=\"flex justify-between items-center mb-1\">\n                    <div>\n                      <span class=\"font-medium\">{{ day.date }}</span>\n                      <span class=\"ml-2 text-gray-500\">({{ day.imported_count }}条记录)</span>\n                    </div>\n                    <span class=\"text-xs px-2 py-1 rounded\" :class=\"day.source === 'json_to_db' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'\">\n                      {{ day.source === 'json_to_db' ? 'JSON→数据库' : '数据库→JSON' }}\n                    </span>\n                  </div>\n                  <div v-if=\"day.titles && day.titles.length\" class=\"pl-2 border-l-2 border-gray-200\">\n                    <div v-for=\"(title, titleIndex) in day.titles.slice(0, 3)\" :key=\"titleIndex\" class=\"text-gray-600 truncate\">\n                      {{ title }}\n                    </div>\n                    <div v-if=\"day.titles.length > 3\" class=\"text-gray-400 text-xs\">\n                      还有{{ day.titles.length - 3 }}条记录...\n                    </div>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <!-- 数据完整性检查面板 -->\n      <div v-if=\"currentTab === 'integrity'\">\n        <!-- 直接显示完整性报告 -->\n        <div v-if=\"reportHtml\" class=\"mb-6 prose prose-sm max-w-none bg-white dark:bg-gray-800 p-4 rounded-md border border-gray-200 dark:border-gray-700 dark:text-gray-100\">\n          <div v-html=\"reportHtml\"></div>\n        </div>\n\n        <div class=\"mb-4\">\n          <p class=\"text-sm text-gray-600 mb-2\">检查数据库和JSON文件之间的数据完整性。</p>\n\n          <div class=\"flex flex-col sm:flex-row gap-4 mb-4\">\n            <div class=\"flex-1\">\n              <label class=\"block text-sm font-medium text-gray-700 mb-1\">数据库路径</label>\n              <input\n                v-model=\"dbPath\"\n                type=\"text\"\n                class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-pink-500 focus:border-pink-500 text-sm dark:bg-gray-700 dark:text-gray-100\"\n              />\n            </div>\n            <div class=\"flex-1\">\n              <label class=\"block text-sm font-medium text-gray-700 mb-1\">JSON文件路径</label>\n              <input\n                v-model=\"jsonPath\"\n                type=\"text\"\n                class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-pink-500 focus:border-pink-500 text-sm dark:bg-gray-700 dark:text-gray-100\"\n              />\n            </div>\n          </div>\n\n          <button\n            @click=\"startCheck\"\n            class=\"w-full bg-pink-600 hover:bg-pink-700 text-white font-medium py-2 px-4 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500 disabled:opacity-50 disabled:cursor-not-allowed\"\n            :disabled=\"isChecking\"\n          >\n            <span v-if=\"isChecking\" class=\"flex items-center justify-center\">\n              <svg class=\"animate-spin -ml-1 mr-2 h-4 w-4 text-white\" fill=\"none\" viewBox=\"0 0 24 24\">\n                <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n                <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n              </svg>\n              检查数据中...\n            </span>\n            <span v-else>开始检查</span>\n          </button>\n        </div>\n\n        <!-- 检查结果显示 -->\n        <div v-if=\"checkResult\" class=\"mt-6 border-t pt-4\">\n          <h3 class=\"font-medium text-gray-900 mb-2\">数据完整性检查结果</h3>\n          <div class=\"bg-gray-50 dark:bg-gray-900 p-3 rounded-md border border-gray-200 dark:border-gray-700\">\n            <div class=\"grid grid-cols-2 gap-3 mb-3\">\n              <div class=\"text-sm\">\n                <span class=\"text-gray-500\">检查时间：</span>\n                <span class=\"text-gray-900\">{{ formatDateTime(checkResult.timestamp) }}</span>\n              </div>\n              <div class=\"text-sm\">\n                <span class=\"text-gray-500\">JSON文件总数：</span>\n                <span class=\"text-gray-900\">{{ checkResult.total_json_files }}</span>\n              </div>\n              <div class=\"text-sm\">\n                <span class=\"text-gray-500\">JSON记录总数：</span>\n                <span class=\"text-gray-900\">{{ checkResult.total_json_records }}</span>\n              </div>\n              <div class=\"text-sm\">\n                <span class=\"text-gray-500\">数据库记录总数：</span>\n                <span class=\"text-gray-900\">{{ checkResult.total_db_records }}</span>\n              </div>\n            </div>\n\n            <div class=\"text-sm p-2 mb-3 rounded-md\" :class=\"[checkResult.difference === 0 ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' : 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300']\">\n              <span class=\"font-medium\">数据差异：</span>\n              <span v-if=\"checkResult.difference === 0\">数据一致</span>\n              <span v-else-if=\"checkResult.difference > 0\">数据库缺少 {{ checkResult.difference }} 条记录</span>\n              <span v-else>数据库多出 {{ -checkResult.difference }} 条记录</span>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, watch } from 'vue'\nimport { syncData, getSyncResult, checkDataIntegrity, getIntegrityReport } from '../../api/api'\nimport { showNotify } from 'vant'\nimport 'vant/es/notify/style'\n\n// 定义Props\nconst props = defineProps({\n  showModal: {\n    type: Boolean,\n    default: false\n  },\n  initialTab: {\n    type: String,\n    default: 'integrity'\n  }\n})\n\n// 定义事件\nconst emit = defineEmits(['update:showModal', 'sync-complete', 'check-complete'])\n\n// 状态变量\nconst currentTab = ref(props.initialTab)\nconst dbPath = ref('output/bilibili_history.db')\nconst jsonPath = ref('output/history_by_date')\nconst isSyncing = ref(false)\nconst isChecking = ref(false)\nconst syncResult = ref(null)\nconst checkResult = ref(null)\nconst reportHtml = ref('')\n\n// 格式化日期时间\nconst formatDateTime = (dateTimeString) => {\n  if (!dateTimeString) return ''\n  const date = new Date(dateTimeString)\n  return date.toLocaleString('zh-CN', {\n    year: 'numeric',\n    month: '2-digit',\n    day: '2-digit',\n    hour: '2-digit',\n    minute: '2-digit',\n    second: '2-digit'\n  })\n}\n\n// 关闭模态框\nconst closeModal = () => {\n  emit('update:showModal', false)\n}\n\n// 获取上次同步结果\nconst fetchSyncResult = async () => {\n  try {\n    const response = await getSyncResult()\n    if (response.data && response.data.success) {\n      syncResult.value = response.data\n    }\n  } catch (error) {\n    console.error('获取同步结果失败:', error)\n    showNotify({ type: 'danger', message: '获取同步结果失败' })\n  }\n}\n\n// 开始同步数据\nconst startSync = async () => {\n  isSyncing.value = true\n  try {\n    const response = await syncData(dbPath.value, jsonPath.value, false) // 禁用异步模式\n\n    if (response.data.success) {\n      syncResult.value = response.data\n      showNotify({ type: 'success', message: `同步完成，共同步${response.data.total_synced}条记录` })\n      emit('sync-complete', response.data)\n    } else {\n      showNotify({ type: 'danger', message: response.data.message || '同步失败' })\n    }\n  } catch (error) {\n    console.error('同步数据失败:', error)\n    showNotify({ type: 'danger', message: error.response?.data?.detail || '同步数据失败' })\n  } finally {\n    isSyncing.value = false\n  }\n}\n\n// 开始数据完整性检查\nconst startCheck = async () => {\n  isChecking.value = true\n  try {\n    // 强制执行检查，忽略配置设置\n    const response = await checkDataIntegrity(dbPath.value, jsonPath.value, false, true)\n\n    if (response.data.success) {\n      checkResult.value = response.data\n\n      // 检查是否有消息提示（可能是配置禁用了检查）\n      if (response.data.message && response.data.message.includes('数据完整性校验已在配置中禁用')) {\n        showNotify({\n          type: 'warning',\n          message: '数据完整性校验已在配置中禁用，但已强制执行检查'\n        })\n      } else {\n        showNotify({ type: 'success', message: '数据完整性检查完成' })\n      }\n\n      emit('check-complete', response.data)\n\n      // 检查完成后自动获取报告内容\n      await fetchIntegrityReport()\n    } else {\n      showNotify({ type: 'danger', message: response.data.message || '检查失败' })\n    }\n  } catch (error) {\n    console.error('数据完整性检查失败:', error)\n    showNotify({ type: 'danger', message: error.response?.data?.detail || '数据完整性检查失败' })\n  } finally {\n    isChecking.value = false\n  }\n}\n\n// 获取完整性报告内容\nconst fetchIntegrityReport = async () => {\n  try {\n    const response = await getIntegrityReport()\n\n    // 检查是否有报告内容\n    if (response.data && response.data.content) {\n      // 更完善的Markdown到HTML转换\n      let content = response.data.content\n        // 预处理 - 先移除单独的#行和不带空格的#开头\n        .replace(/^#\\s*$/gm, '') // 移除单独的#行\n        .replace(/^\\s*#\\s*$/gm, '') // 移除仅有空格和#的行\n        .replace(/^#(?!\\s)/gm, '') // 移除不带空格的#开头\n\n      // 整理标题格式\n      content = content.replace(/### ([^\\n]+)/g, '<h3 class=\"text-base font-medium my-2\">$1</h3>')\n        .replace(/## ([^\\n]+)/g, '<h2 class=\"text-lg font-semibold my-3\">$1</h2>')\n        .replace(/# ([^\\n]+)/g, '<h1 class=\"text-xl font-bold my-4\">$1</h1>')\n\n      // 处理列表项\n      content = content.replace(/^\\* (.*?)$/gm, '<li class=\"ml-4\">$1</li>')\n\n      // 将列表项包装在ul标签中\n      content = content.replace(/<li class=\"ml-4\">(.*?)<\\/li>(\\s*)<li/g, '<li class=\"ml-4\">$1</li></ul><ul><li')\n        .replace(/<li class=\"ml-4\">(.*?)<\\/li>(?!\\s*<li|\\s*<\\/ul>)/g, '<li class=\"ml-4\">$1</li></ul>')\n        .replace(/<li/g, '<ul><li')\n\n      // 修复可能的重复ul标签\n      content = content.replace(/<\\/ul>\\s*<ul>/g, '')\n\n      // 段落处理\n      content = content.replace(/\\n\\n/g, '</p><p class=\"my-2\">')\n\n      // 处理一些特殊格式\n      content = content.replace(/\\*\\*(.*?)\\*\\*/g, '<strong>$1</strong>')\n        .replace(/\\*(.*?)\\*/g, '<em>$1</em>')\n\n      // 确保所有内容都有封装标签\n      if (!content.startsWith('<')) {\n        content = '<p class=\"my-2\">' + content + '</p>'\n      }\n\n      reportHtml.value = content\n      return true\n    } else if (response.data && response.data.message && response.data.message.includes('数据完整性校验已在配置中禁用')) {\n      // 如果报告为空是因为配置禁用了校验\n      reportHtml.value = `<div class=\"p-4 bg-yellow-50 text-yellow-800 rounded-md\">\n        <h3 class=\"font-medium\">数据完整性校验已在配置中禁用</h3>\n        <p class=\"mt-2\">您已在设置中禁用启动时数据完整性校验。如需查看报告，请点击\"开始检查\"按钮强制执行检查。</p>\n      </div>`\n      return true\n    } else {\n      // 其他原因导致报告为空\n      showNotify({ type: 'warning', message: '报告内容为空' })\n      reportHtml.value = `<div class=\"p-4 bg-gray-50 text-gray-600 rounded-md\">\n        <p>暂无报告内容。请点击\"开始检查\"按钮执行数据完整性检查。</p>\n      </div>`\n      return false\n    }\n  } catch (error) {\n    console.error('获取报告失败:', error)\n    reportHtml.value = `<div class=\"p-4 bg-red-50 text-red-600 rounded-md\">\n      <h3 class=\"font-medium\">获取报告失败</h3>\n      <p class=\"mt-2\">错误信息: ${error.message || '未知错误'}</p>\n      <p class=\"mt-1\">请点击\"开始检查\"按钮重新执行数据完整性检查。</p>\n    </div>`\n    return false\n  }\n}\n\n// 监听模态框状态变化\nwatch(() => props.showModal, async (newVal) => {\n  if (newVal) {\n    // 模态框打开时，获取最新数据\n    await fetchIntegrityReport() // 先获取报告\n    await fetchSyncResult() // 再获取同步结果\n  }\n})\n\n// 监听initialTab变化\nwatch(() => props.initialTab, (newVal) => {\n  currentTab.value = newVal\n})\n\n// 组件挂载时\nonMounted(async () => {\n  if (props.showModal) {\n    await fetchIntegrityReport() // 默认加载报告\n    await fetchSyncResult()\n  }\n})\n</script>"
  },
  {
    "path": "src/components/tailwind/DownloadDialog.vue",
    "content": "<!-- 视频下载弹窗 -->\n<template>\n  <!-- 将通知容器放置在最外层，确保z-index最高 -->\n  <Teleport to=\"body\">\n    <div class=\"notification-container fixed top-0 left-0 right-0 z-[999999]\"></div>\n  </Teleport>\n\n  <Teleport to=\"body\">\n    <div v-if=\"show\" class=\"fixed inset-0 z-[9999] flex items-center justify-center\">\n      <!-- 遮罩层 -->\n      <div class=\"fixed inset-0 bg-black/60\" @click=\"handleClose\"></div>\n\n      <!-- 弹窗内容 -->\n      <div\n        class=\"relative bg-white dark:bg-gray-800 rounded-lg shadow-xl w-[96vw] max-w-5xl h-[95vh] z-10 overflow-hidden flex flex-col\">\n        <!-- 关闭按钮 -->\n        <button\n          @click=\"handleClose\"\n          class=\"absolute right-4 top-4 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 z-20 p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors\"\n        >\n          <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\" />\n          </svg>\n        </button>\n\n        <!-- 页眉区域：包含Yutto致谢和FFmpeg状态 -->\n        <div\n          class=\"px-6 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700\">\n          <div class=\"flex items-center space-x-3\">\n            <img src=\"https://yutto.nyakku.moe/logo-mini.svg\" alt=\"Yutto Logo\" class=\"w-6 h-6\">\n            <div class=\"flex flex-col\">\n              <p class=\"text-xs text-gray-700 dark:text-gray-300\">下载功能通过 <a href=\"https://yutto.nyakku.moe/\"\n                                                                                  target=\"_blank\"\n                                                                                  class=\"text-[#fb7299] hover:text-[#fb7299]/80 font-medium\">Yutto</a>\n                实现</p>\n              <p class=\"text-xs text-gray-500 dark:text-gray-400\">感谢 Yutto 开发团队的开源贡献</p>\n            </div>\n          </div>\n\n          <!-- FFmpeg 状态 -->\n          <div v-if=\"ffmpegStatus\" class=\"flex-shrink-0\">\n            <div v-if=\"ffmpegStatus.installed\"\n                 class=\"flex items-center space-x-1 p-1.5 bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-lg text-xs\">\n              <svg class=\"w-4 h-4 flex-shrink-0\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 13l4 4L19 7\" />\n              </svg>\n              <div>\n                <p class=\"font-medium\">FFmpeg 已安装</p>\n                <p class=\"text-xs\">{{ ffmpegStatus.version }}</p>\n              </div>\n            </div>\n            <div v-else class=\"group relative\">\n              <div class=\"flex flex-col space-y-1\">\n                <div\n                  class=\"flex items-center space-x-1 p-1.5 bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-lg text-xs\">\n                  <svg class=\"w-4 h-4 flex-shrink-0\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                          d=\"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z\" />\n                  </svg>\n                  <p class=\"font-medium\">FFmpeg 未安装</p>\n                </div>\n                <div class=\"text-xs text-gray-600 dark:text-gray-400\">\n                  <p>\n                    <a\n                      href=\"https://yutto.nyakku.moe/guide/quick-start#ffmpeg-%E4%B8%8B%E8%BD%BD%E4%B8%8E%E9%85%8D%E7%BD%AE\"\n                      target=\"_blank\"\n                      class=\"text-[#fb7299] hover:text-[#fb7299]/80\">\n                      点击查看Yutto说明 →\n                    </a>\n                  </p>\n                </div>\n              </div>\n              <div class=\"hidden group-hover:block hover:block absolute right-0 top-full h-2 w-full\"></div>\n              <div\n                class=\"hidden group-hover:block hover:block absolute right-0 top-[calc(100%+0.5rem)] w-[500px] p-3 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-30 text-xs\">\n                <p class=\"font-medium text-gray-900 dark:text-gray-100 mb-2\">安装指南：</p>\n                <div v-if=\"ffmpegStatus?.install_guide\" class=\"space-y-1 whitespace-pre-wrap\">\n                  <div v-for=\"(line, index) in installGuideLines\" :key=\"index\" class=\"flex items-start space-x-1\">\n                    <template v-if=\"isCommand(line)\">\n                      <div class=\"flex-1 bg-gray-50 dark:bg-gray-900 p-1.5 rounded break-all\">\n                        <code class=\"text-gray-700 dark:text-gray-300\">{{ getCommandContent(line) }}</code>\n                      </div>\n                      <button @click=\"copyToClipboard(getCommandContent(line))\"\n                              class=\"text-[#fb7299] hover:text-[#fb7299]/80 p-1 rounded-md hover:bg-[#fb7299]/10 flex-shrink-0\"\n                              title=\"点击复制命令\">\n                        <svg class=\"w-3 h-3\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                          <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                                d=\"M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3\" />\n                        </svg>\n                      </button>\n                    </template>\n                    <template v-else>\n                      <p class=\"text-gray-600 dark:text-gray-400 break-all\">{{ line }}</p>\n                    </template>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <!-- 主内容区域 -->\n        <div class=\"flex-1 overflow-y-auto\">\n          <div class=\"px-6 pt-3 pb-4\">\n            <!-- 标题 -->\n            <div class=\"mb-2\">\n              <h3 class=\"text-lg font-semibold text-gray-900 dark:text-gray-100\">\n                {{ getDownloadTitle() }}\n              </h3>\n              <p v-if=\"downloadStarted\" class=\"text-sm text-gray-500 dark:text-gray-400\">\n                {{ isDownloading ? '正在下载：' : (downloadError ? '下载出错：' : '下载完成：') }} {{ currentVideoTitle }}\n              </p>\n              <p v-else class=\"text-sm text-gray-500 dark:text-gray-400\">\n                {{ videoInfo.title }}\n              </p>\n              <!-- 收藏夹视频总数 -->\n              <p v-if=\"isFavoriteFolder\" class=\"text-sm text-gray-500 dark:text-gray-400 mt-1\">\n                共 {{ favoritePageInfo.totalCount || props.videoInfo.total_videos || favoriteVideos.length }}\n                个视频，当前进度：{{ currentVideoIndex + 1\n                }}/{{ favoritePageInfo.totalCount || props.videoInfo.total_videos || favoriteVideos.length }}\n              </p>\n              <!-- 批量下载视频总数 -->\n              <p v-if=\"props.isBatchDownload\" class=\"text-sm text-gray-500 dark:text-gray-400 mt-1\">\n                共 {{ props.batchVideos.length }} 个视频，当前进度：{{ props.currentVideoIndex + 1\n                }}/{{ props.batchVideos.length }}\n              </p>\n            </div>\n\n            <!-- 视频信息 -->\n            <div class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 mb-4\">\n              <div class=\"flex items-start space-x-4\">\n                <div class=\"w-32 h-20 flex-shrink-0 overflow-hidden rounded-lg\">\n                  <img :src=\"normalizeImageUrl(currentVideoCover)\" :alt=\"currentVideoTitle\"\n                       class=\"w-full h-full object-cover transition-transform hover:scale-105\">\n                </div>\n                <div class=\"flex-1 min-w-0\">\n                  <p v-if=\"!isFavoriteFolder\" class=\"text-xs text-gray-500 dark:text-gray-400 mt-0.5\">\n                    UP主：{{ props.isBatchDownload ? currentVideoAuthor : props.videoInfo.author || '未知' }}</p>\n                  <p v-if=\"!isFavoriteFolder\" class=\"text-xs text-gray-500 dark:text-gray-400 mt-0.5\">\n                    BV号：{{ props.isBatchDownload ? currentVideoBvid : props.videoInfo.bvid || '未知' }}</p>\n\n                  <!-- 基础下载选项 -->\n                  <div class=\"flex flex-wrap gap-4 items-center mt-3\">\n                    <label class=\"flex items-center space-x-2 cursor-pointer select-none\">\n                      <input\n                        type=\"checkbox\"\n                        v-model=\"downloadCover\"\n                        class=\"w-4 h-4 text-[#fb7299] border-gray-300 rounded focus:ring-[#fb7299]\"\n                      >\n                      <span class=\"text-sm text-gray-700 dark:text-gray-300\">下载并合成视频封面</span>\n                    </label>\n                    <label class=\"flex items-center space-x-2 cursor-pointer select-none\">\n                      <input\n                        type=\"checkbox\"\n                        v-model=\"onlyAudio\"\n                        class=\"w-4 h-4 text-[#fb7299] border-gray-300 rounded focus:ring-[#fb7299]\"\n                      >\n                      <span class=\"text-sm text-gray-700 dark:text-gray-300\">仅下载音频</span>\n                    </label>\n                  </div>\n                </div>\n              </div>\n            </div>\n\n            <!-- 高级选项切换按钮 -->\n            <div v-if=\"!showAdvancedOptions\" class=\"mb-2\">\n              <button\n                @click=\"showAdvancedOptions = true\"\n                class=\"w-full flex items-center justify-center py-2.5 px-4 text-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors group shadow-sm\"\n              >\n                <div class=\"flex items-center space-x-3\">\n                  <svg class=\"w-5 h-5 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                          d=\"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z\" />\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                          d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\" />\n                  </svg>\n                  <span class=\"font-medium text-gray-700 dark:text-gray-300\">高级下载选项</span>\n                  <div class=\"flex-grow\"></div>\n                  <div class=\"flex items-center space-x-1 text-xs text-[#fb7299]\">\n                    <span>展开</span>\n                    <svg class=\"w-4 h-4 transform transition-transform group-hover:translate-y-0.5 duration-300\"\n                         fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                      <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 9l-7 7-7-7\" />\n                    </svg>\n                  </div>\n                </div>\n              </button>\n            </div>\n\n            <!-- 高级下载选项区域 -->\n            <div\n              class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 mb-2 overflow-hidden shadow-sm transition-all duration-300\"\n              :class=\"{\n 'max-h-[1000px] opacity-100': showAdvancedOptions,\n 'max-h-0 opacity-0 border-0': !showAdvancedOptions\n }\"\n            >\n              <!-- 高级选项标题 -->\n              <div class=\"bg-gray-50 dark:bg-gray-700 p-3 flex justify-between items-center border-b border-gray-200 dark:border-gray-700\">\n                <div class=\"flex items-center space-x-2\">\n                  <svg class=\"w-4 h-4 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                          d=\"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z\" />\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                          d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\" />\n                  </svg>\n                  <h4 class=\"text-sm font-medium text-gray-800 dark:text-gray-200\">高级下载选项</h4>\n                </div>\n\n                <!-- 隐藏按钮 -->\n                <button\n                  @click=\"showAdvancedOptions = false\"\n                  class=\"flex items-center space-x-1 px-2.5 py-1.5 rounded-md text-xs text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 hover:text-[#fb7299] transition-all duration-200 group\"\n                >\n                  <span>收起选项</span>\n                  <svg class=\"w-4 h-4 transition-transform group-hover:-translate-y-0.5 duration-300\" fill=\"none\"\n                       viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\" />\n                  </svg>\n                </button>\n              </div>\n\n              <div class=\"p-3\">\n                <!-- 基础参数区块 -->\n                <div class=\"mb-3\">\n                  <h5 class=\"text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2 uppercase tracking-wide flex items-center\">\n                    <svg class=\"w-3 h-3 mr-1 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                      <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                            d=\"M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z\" />\n                    </svg>\n                    视频和音频质量\n                  </h5>\n                  <div class=\"grid grid-cols-1 md:grid-cols-3 gap-3\">\n                    <!-- 视频清晰度 -->\n                    <div>\n                      <label class=\"block text-xs text-gray-600 dark:text-gray-400 mb-1\">视频清晰度</label>\n                      <CustomDropdown\n                        v-model=\"advancedOptions.video_quality\"\n                        :options=\"videoQualityOptions\"\n                        :selected-text=\"getVideoQualityText(advancedOptions.video_quality)\"\n                        custom-class=\"w-full text-xs\"\n                      />\n                      <div class=\"text-xs text-gray-500 dark:text-gray-400 mt-1 text-[10px]\">\n                        yutto会尽可能满足清晰度要求，如不存在会自动调节\n                      </div>\n                    </div>\n\n                    <!-- 音频码率 -->\n                    <div>\n                      <label class=\"block text-xs text-gray-600 dark:text-gray-400 mb-1\">音频码率</label>\n                      <CustomDropdown\n                        v-model=\"advancedOptions.audio_quality\"\n                        :options=\"audioQualityOptions\"\n                        :selected-text=\"getAudioQualityText(advancedOptions.audio_quality)\"\n                        custom-class=\"w-full text-xs\"\n                      />\n                    </div>\n\n                    <!-- 输出格式 -->\n                    <div>\n                      <label class=\"block text-xs text-gray-600 dark:text-gray-400 mb-1\">输出格式</label>\n                      <CustomDropdown\n                        v-model=\"advancedOptions.output_format\"\n                        :options=\"outputFormatOptions\"\n                        :selected-text=\"getOutputFormatText(advancedOptions.output_format)\"\n                        custom-class=\"w-full text-xs\"\n                      />\n                    </div>\n                  </div>\n                </div>\n\n                <!-- 编码参数区块 -->\n                <div class=\"mb-3\">\n                  <h5 class=\"text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2 uppercase tracking-wide flex items-center\">\n                    <svg class=\"w-3 h-3 mr-1 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                      <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                            d=\"M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4\" />\n                    </svg>\n                    编码选项\n                  </h5>\n                  <div class=\"grid grid-cols-1 md:grid-cols-2 gap-3\">\n                    <!-- 视频编码 -->\n                    <div>\n                      <label class=\"block text-xs text-gray-600 dark:text-gray-400 mb-1\">视频编码</label>\n                      <CustomDropdown\n                        v-model=\"advancedOptions.vcodec\"\n                        :options=\"vcodecOptions\"\n                        :selected-text=\"getVcodecText(advancedOptions.vcodec)\"\n                        custom-class=\"w-full text-xs\"\n                      />\n                    </div>\n\n                    <!-- 音频编码 -->\n                    <div>\n                      <label class=\"block text-xs text-gray-600 dark:text-gray-400 mb-1\">音频编码</label>\n                      <CustomDropdown\n                        v-model=\"advancedOptions.acodec\"\n                        :options=\"acodecOptions\"\n                        :selected-text=\"getAcodecText(advancedOptions.acodec)\"\n                        custom-class=\"w-full text-xs\"\n                      />\n                    </div>\n                  </div>\n                </div>\n\n                <!-- 资源选项区块 -->\n                <div>\n                  <h5 class=\"text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2 uppercase tracking-wide flex items-center\">\n                    <svg class=\"w-3 h-3 mr-1 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                      <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                            d=\"M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10\" />\n                    </svg>\n                    资源选择\n                  </h5>\n                  <div class=\"grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3\">\n                    <!-- 第一行 -->\n                    <label class=\"flex items-center space-x-2 cursor-pointer select-none\">\n                      <input\n                        type=\"checkbox\"\n                        v-model=\"advancedOptions.video_only\"\n                        class=\"w-4 h-4 text-[#fb7299] border-gray-300 rounded focus:ring-[#fb7299]\"\n                        :disabled=\"onlyAudio\"\n                      >\n                      <span class=\"text-xs text-gray-700 dark:text-gray-300\">仅下载视频流</span>\n                    </label>\n\n                    <label class=\"flex items-center space-x-2 cursor-pointer select-none\">\n                      <input\n                        type=\"checkbox\"\n                        v-model=\"advancedOptions.no_danmaku\"\n                        class=\"w-4 h-4 text-[#fb7299] border-gray-300 rounded focus:ring-[#fb7299]\"\n                        :disabled=\"advancedOptions.danmaku_only\"\n                      >\n                      <span class=\"text-xs text-gray-700 dark:text-gray-300\">不生成弹幕</span>\n                    </label>\n\n                    <label class=\"flex items-center space-x-2 cursor-pointer select-none\">\n                      <input\n                        type=\"checkbox\"\n                        v-model=\"advancedOptions.danmaku_only\"\n                        class=\"w-4 h-4 text-[#fb7299] border-gray-300 rounded focus:ring-[#fb7299]\"\n                        :disabled=\"advancedOptions.no_danmaku\"\n                      >\n                      <span class=\"text-xs text-gray-700 dark:text-gray-300 \">仅生成弹幕</span>\n                    </label>\n\n                    <label class=\"flex items-center space-x-2 cursor-pointer select-none\">\n                      <input\n                        type=\"checkbox\"\n                        v-model=\"advancedOptions.no_subtitle\"\n                        class=\"w-4 h-4 text-[#fb7299] border-gray-300 rounded focus:ring-[#fb7299]\"\n                        :disabled=\"advancedOptions.subtitle_only\"\n                      >\n                      <span class=\"text-xs text-gray-700 dark:text-gray-300 \">不生成字幕</span>\n                    </label>\n\n                    <!-- 第二行 -->\n                    <label class=\"flex items-center space-x-2 cursor-pointer select-none\">\n                      <input\n                        type=\"checkbox\"\n                        v-model=\"advancedOptions.subtitle_only\"\n                        class=\"w-4 h-4 text-[#fb7299] border-gray-300 rounded focus:ring-[#fb7299]\"\n                        :disabled=\"advancedOptions.no_subtitle\"\n                      >\n                      <span class=\"text-xs text-gray-700 dark:text-gray-300 \">仅生成字幕</span>\n                    </label>\n\n                    <label class=\"flex items-center space-x-2 cursor-pointer select-none\">\n                      <input\n                        type=\"checkbox\"\n                        v-model=\"advancedOptions.with_metadata\"\n                        class=\"w-4 h-4 text-[#fb7299] border-gray-300 rounded focus:ring-[#fb7299]\"\n                        :disabled=\"advancedOptions.metadata_only\"\n                      >\n                      <span class=\"text-xs text-gray-700 dark:text-gray-300 \">生成元数据</span>\n                    </label>\n\n                    <label class=\"flex items-center space-x-2 cursor-pointer select-none\">\n                      <input\n                        type=\"checkbox\"\n                        v-model=\"advancedOptions.metadata_only\"\n                        class=\"w-4 h-4 text-[#fb7299] border-gray-300 rounded focus:ring-[#fb7299]\"\n                        :disabled=\"advancedOptions.with_metadata\"\n                      >\n                      <span class=\"text-xs text-gray-700 dark:text-gray-300 \">仅生成元数据</span>\n                    </label>\n\n                    <label class=\"flex items-center space-x-2 cursor-pointer select-none\">\n                      <input\n                        type=\"checkbox\"\n                        v-model=\"advancedOptions.no_cover\"\n                        class=\"w-4 h-4 text-[#fb7299] border-gray-300 rounded focus:ring-[#fb7299]\"\n                        :disabled=\"!downloadCover || advancedOptions.save_cover || advancedOptions.cover_only\"\n                      >\n                      <span class=\"text-xs text-gray-700 dark:text-gray-300 \">不生成封面</span>\n                    </label>\n\n                    <!-- 第三行 -->\n                    <label class=\"flex items-center space-x-2 cursor-pointer select-none\">\n                      <input\n                        type=\"checkbox\"\n                        v-model=\"advancedOptions.save_cover\"\n                        class=\"w-4 h-4 text-[#fb7299] border-gray-300 rounded focus:ring-[#fb7299]\"\n                        :disabled=\"!downloadCover || advancedOptions.cover_only || advancedOptions.no_cover\"\n                      >\n                      <span class=\"text-xs text-gray-700 dark:text-gray-300 \">单独保存封面</span>\n                    </label>\n\n                    <label class=\"flex items-center space-x-2 cursor-pointer select-none\">\n                      <input\n                        type=\"checkbox\"\n                        v-model=\"advancedOptions.cover_only\"\n                        class=\"w-4 h-4 text-[#fb7299] border-gray-300 rounded focus:ring-[#fb7299]\"\n                        :disabled=\"!downloadCover || advancedOptions.save_cover || advancedOptions.no_cover\"\n                      >\n                      <span class=\"text-xs text-gray-700 dark:text-gray-300 \">仅生成封面</span>\n                    </label>\n\n                    <label class=\"flex items-center space-x-2 cursor-pointer select-none\">\n                      <input\n                        type=\"checkbox\"\n                        v-model=\"advancedOptions.no_chapter_info\"\n                        class=\"w-4 h-4 text-[#fb7299] border-gray-300 rounded focus:ring-[#fb7299]\"\n                      >\n                      <span class=\"text-xs text-gray-700 dark:text-gray-300 \">不生成章节</span>\n                    </label>\n                  </div>\n                </div>\n              </div>\n            </div>\n\n            <!-- 下载日志 -->\n            <div\n              class=\"w-full bg-gray-50 dark:bg-gray-900 rounded-lg p-2 pb-0 font-mono text-[11px] overflow-y-auto border border-gray-200 dark:border-gray-700\"\n              :class=\"showAdvancedOptions ? 'h-[calc(100vh-588px)] min-h-[130px]' : 'h-[calc(100vh-418px)] min-h-[180px]'\"\n              ref=\"logContainer\">\n              <div v-if=\"!downloadStarted\" class=\"text-gray-500 dark:text-gray-400 flex items-center justify-center h-full\">\n                <div class=\"text-center\">\n                  <svg class=\"w-8 h-8 mx-auto mb-0 text-gray-400 dark:text-gray-500\" fill=\"none\" viewBox=\"0 0 24 24\"\n                       stroke=\"currentColor\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.5\"\n                          d=\"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z\" />\n                  </svg>\n                  <p>点击下方按钮开始下载...</p>\n                </div>\n              </div>\n              <div v-else>\n                <div v-for=\"(log, index) in downloadLogs\" :key=\"index\" class=\"whitespace-pre break-all leading-5 py-0.5 last:pb-0\"\n                     :class=\"{\n'text-gray-600 dark:text-gray-300': !log.includes('ERROR') && !log.includes('下载完成') && !log.includes('WARN'),\n'text-red-500 dark:text-red-400': log.includes('ERROR'),\n'text-green-500 dark:text-green-400': log.includes('下载完成'),\n'text-yellow-500 dark:text-yellow-400': log.includes('WARN'),\n}\">{{ log }}\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <!-- 页脚区域：状态和按钮 -->\n        <div class=\"bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 py-2 px-6 flex items-center justify-between\">\n          <div class=\"text-xs font-medium\" :class=\"{\n 'text-gray-500 dark:text-gray-400': !downloadStarted,\n 'text-red-500 dark:text-red-400': downloadError,\n 'text-green-500 dark:text-green-400': !isDownloading && downloadStarted && !downloadError,\n 'text-[#fb7299]': isDownloading\n }\">\n            {{ downloadStatus }}\n          </div>\n          <div class=\"flex space-x-3\">\n            <button\n              @click=\"handleClose\"\n              class=\"px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors\"\n              :disabled=\"isDownloading\"\n            >\n              {{ isDownloading ? '下载中...' : '关闭' }}\n            </button>\n            <button\n              v-if=\"!downloadStarted || downloadError\"\n              @click=\"startDownload\"\n              class=\"px-3 py-1.5 text-xs font-medium text-white bg-[#fb7299] rounded-md hover:bg-[#fb7299]/90 disabled:opacity-50 transition-colors\"\n              :disabled=\"isDownloading\"\n            >\n              {{ downloadError ? '重试' : '开始下载' }}\n            </button>\n          </div>\n        </div>\n      </div>\n    </div>\n  </Teleport>\n</template>\n\n<script setup>\nimport { ref, computed, watch, onUnmounted, nextTick } from 'vue'\nimport {\n  downloadVideo,\n  checkFFmpeg,\n  downloadFavorites,\n  getFavoriteContents,\n  downloadUserVideos,\n  batchDownloadVideos,\n  downloadCollection,\n} from '@/api/api.js'\nimport { showNotify } from 'vant'\nimport 'vant/es/notify/style'\nimport CustomDropdown from '@/components/tailwind/CustomDropdown.vue'\nimport { normalizeImageUrl } from '@/utils/imageUrl.js'\n\ndefineOptions({\n  name: 'DownloadDialog',\n})\n\nconst props = defineProps({\n  show: {\n    type: Boolean,\n    default: false,\n  },\n  videoInfo: {\n    type: Object,\n    required: true,\n    default: () => ({\n      title: '',\n      author: '',\n      bvid: '',\n      cover: '',\n      cid: 0,\n    }),\n  },\n  // 当为 true 时，弹窗打开时默认勾选“仅下载音频”\n  defaultOnlyAudio: {\n    type: Boolean,\n    default: false,\n  },\n  // 添加 UP 主视频列表参数\n  upUserVideos: {\n    type: Array,\n    default: () => [],\n  },\n  // 批量下载参数\n  isBatchDownload: {\n    type: Boolean,\n    default: false,\n  },\n  batchVideos: {\n    type: Array,\n    default: () => [],\n  },\n  // 当前下载的视频索引\n  currentVideoIndex: {\n    type: Number,\n    default: 0,\n  },\n})\n\nconst emit = defineEmits(['update:show', 'download-complete', 'update:currentVideoIndex'])\n\n// 下载相关状态\nconst downloadStarted = ref(false)\nconst isDownloading = ref(false)\nconst downloadError = ref(false)\nconst downloadLogs = ref([])\n// 控制高级选项的显示状态\nconst showAdvancedOptions = ref(false)\n\n// 下载状态文本\nconst downloadStatus = computed(() => {\n  if (!downloadStarted.value) return '准备就绪'\n  if (downloadError.value) return '下载出错'\n  if (isDownloading.value) return '下载中...'\n  return '下载完成'\n})\n\n// 获取下载标题\nconst getDownloadTitle = () => {\n  if (props.videoInfo.is_collection_download) {\n    return '下载合集'\n  } else if (props.isBatchDownload) {\n    return '批量下载视频'\n  } else if (isFavoriteFolder.value) {\n    return '下载收藏夹'\n  } else {\n    return '下载视频'\n  }\n}\n\n// 日志容器引用\nconst logContainer = ref(null)\n\n// FFmpeg 状态\nconst ffmpegStatus = ref(null)\n\n// 检查 FFmpeg 安装状态\nconst checkFFmpegStatus = async () => {\n  try {\n    const response = await checkFFmpeg()\n    if (response.data) {\n      ffmpegStatus.value = {\n        installed: response.data.status === 'success',\n        version: response.data.version,\n        path: response.data.path,\n        os_info: response.data.os_info,\n        install_guide: response.data.install_guide,\n      }\n    }\n  } catch (error) {\n    console.error('检查 FFmpeg 失败:', error)\n  }\n}\n\n// 下载封面选项\nconst downloadCover = ref(true)\n// 仅下载音频选项\nconst onlyAudio = ref(false)\n// 高级选项\nconst advancedOptions = ref({\n  // 基础参数\n  video_quality: null,\n  audio_quality: null,\n  vcodec: null,\n  acodec: null,\n  download_vcodec_priority: null,\n  output_format: null,\n  output_format_audio_only: null,\n\n  // 资源选择参数\n  video_only: false,\n  danmaku_only: false,\n  no_danmaku: false,\n  subtitle_only: false,\n  no_subtitle: false,\n  with_metadata: false,\n  metadata_only: false,\n  no_cover: false,\n  save_cover: false,\n  cover_only: false,\n  no_chapter_info: false,\n})\n\n// 当前正在下载的视频信息\nconst currentVideoTitle = ref('')\nconst currentVideoCover = ref('')\nconst currentVideoAuthor = ref('')\nconst currentVideoBvid = ref('')\n\nconst videoTitles = ref([]) // 存储所有检测到的视频标题\n\n// 存储收藏夹中所有视频信息\nconst favoriteVideos = ref([])\n// 当前正在下载的视频索引\nconst currentVideoIndex = ref(-1)\n// 收藏夹的页码信息\nconst favoritePageInfo = ref({\n  page: 1,\n  pageSize: 40,\n  totalCount: 0,\n  totalPage: 0,\n  hasMore: false,\n})\n// 加载收藏夹内容状态\nconst loadingFavorites = ref(false)\n\n// 是否是收藏夹\nconst isFavoriteFolder = computed(() => {\n  return !!props.videoInfo.is_favorite_folder\n})\n\n// 预加载收藏夹所有视频\nconst preloadFavoriteVideos = async () => {\n  if (!isFavoriteFolder.value || !props.videoInfo.fid) return\n\n  try {\n    loadingFavorites.value = true\n    downloadLogs.value.push('INFO 正在获取收藏夹内容，请稍候...')\n\n    // 先获取基本信息，确定总视频数\n    try {\n      const initialResponse = await getFavoriteContents({\n        media_id: props.videoInfo.fid,\n        pn: 1,\n        ps: 1, // 只获取一个视频，主要是为了拿到总数\n      })\n\n      if (initialResponse.data && initialResponse.data.status === 'success' && initialResponse.data.data) {\n        // 更新总数信息\n        favoritePageInfo.value.totalCount = initialResponse.data.data.total || props.videoInfo.total_videos || 0\n        favoritePageInfo.value.totalPage = Math.ceil(favoritePageInfo.value.totalCount / 40)\n\n        console.log('收藏夹总视频数:', favoritePageInfo.value.totalCount)\n        console.log('总页数:', favoritePageInfo.value.totalPage)\n\n        downloadLogs.value.push(`INFO 收藏夹共有 ${favoritePageInfo.value.totalCount} 个视频，开始获取视频信息`)\n      }\n    } catch (error) {\n      console.error('获取收藏夹基本信息失败:', error)\n    }\n\n    // 如果仍然没有总数信息，使用props中的值\n    if (!favoritePageInfo.value.totalCount) {\n      favoritePageInfo.value.totalCount = props.videoInfo.total_videos || 0\n      favoritePageInfo.value.totalPage = Math.ceil(favoritePageInfo.value.totalCount / 40)\n    }\n\n    let allVideos = []\n    let page = 1\n    let maxPages = Math.min(favoritePageInfo.value.totalPage || 5, 10) // 最多获取10页，避免过多请求\n    let hasMore = page <= maxPages\n\n    // 如果视频总数超过200个，提示用户\n    if (favoritePageInfo.value.totalCount > 200) {\n      downloadLogs.value.push(`WARN 收藏夹视频数量较多(${favoritePageInfo.value.totalCount}个)，将只预加载部分视频信息`)\n      downloadLogs.value.push('INFO 下载过程中会自动更新视频信息')\n    }\n\n    while (hasMore) {\n      try {\n        downloadLogs.value.push(`INFO 正在获取第${page}页视频信息...`)\n\n        const response = await getFavoriteContents({\n          media_id: props.videoInfo.fid,\n          pn: page,\n          ps: 40, // 每页40条\n        })\n\n        if (response.data && response.data.status === 'success' && response.data.data) {\n          const data = response.data.data\n\n          // 更新总视频数，避免后续请求\n          if (page === 1 && data.total) {\n            favoritePageInfo.value.totalCount = data.total\n            favoritePageInfo.value.totalPage = Math.ceil(data.total / (data.pagesize || 40))\n          }\n\n          if (data.medias && Array.isArray(data.medias)) {\n            // 合并结果\n            const newVideos = data.medias.map(item => ({\n              title: item.title || '',\n              cover: item.cover || '',\n              bvid: item.bvid || '',\n              cid: item.cid || 0,\n              author: item.upper?.name || '',\n              avid: item.id || 0,\n            }))\n\n            allVideos = allVideos.concat(newVideos)\n\n            // 更新日志，显示当前进度\n            downloadLogs.value.push(`INFO 已获取 ${allVideos.length}/${favoritePageInfo.value.totalCount} 个视频信息`)\n          }\n\n          // 判断是否还有更多页\n          page++\n          hasMore = page <= maxPages && page <= favoritePageInfo.value.totalPage\n\n          // 大型收藏夹时，避免请求过多页面\n          if (page > 5 && favoritePageInfo.value.totalCount > 200) {\n            downloadLogs.value.push(`INFO 已获取前${page - 1}页视频信息，剩余信息将在下载过程中更新`)\n            hasMore = false\n          }\n\n          // 休眠一段时间，避免触发API限制\n          if (hasMore) {\n            await new Promise(resolve => setTimeout(resolve, 500))\n          }\n        } else {\n          downloadLogs.value.push('ERROR 获取收藏夹内容失败，将使用实时日志更新视频信息')\n          hasMore = false\n        }\n      } catch (error) {\n        console.error(`获取收藏夹内容第${page}页失败:`, error)\n        downloadLogs.value.push(`ERROR 获取第${page}页内容失败，可能触发了API限制`)\n        hasMore = false\n      }\n    }\n\n    favoriteVideos.value = allVideos\n\n    if (allVideos.length < favoritePageInfo.value.totalCount) {\n      downloadLogs.value.push(`INFO 已预加载 ${allVideos.length}/${favoritePageInfo.value.totalCount} 个视频信息，剩余视频将在下载过程中更新`)\n    } else {\n      downloadLogs.value.push(`INFO 收藏夹内容获取完成，共 ${allVideos.length} 个视频`)\n    }\n\n  } catch (error) {\n    console.error('加载收藏夹内容失败:', error)\n    downloadLogs.value.push('ERROR 获取收藏夹内容失败，将使用实时日志更新视频信息')\n  } finally {\n    loadingFavorites.value = false\n  }\n}\n\n// 监听日志变化，提取视频顺序索引\nwatch(() => downloadLogs.value, async (logs) => {\n  if (!logs || logs.length === 0) return\n\n  // 获取最新的日志信息\n  const latestLog = logs[logs.length - 1]\n  console.log('处理新日志:', latestLog)\n\n  // 检查是否是下载完成信息\n  if (latestLog === '下载完成') {\n    console.log('收藏夹下载全部完成')\n    // 确保显示最后一个视频\n    if (isFavoriteFolder.value && favoriteVideos.value.length > 0) {\n      currentVideoIndex.value = favoriteVideos.value.length - 1\n      const lastVideo = favoriteVideos.value[currentVideoIndex.value]\n      currentVideoTitle.value = lastVideo.title\n      currentVideoCover.value = lastVideo.cover || ''\n    }\n    return\n  }\n\n  // 检查是否为视频序号信息 [n/total]\n  const indexMatch = latestLog.match(/\\[(\\d+)\\/(\\d+)\\]/)\n  if (indexMatch) {\n    const index = parseInt(indexMatch[1], 10) - 1 // 索引从0开始\n    const total = parseInt(indexMatch[2], 10)\n    console.log(`检测到视频索引: ${index + 1}/${total}`)\n\n    // 提取完整的视频标题\n    const titleMatch = latestLog.match(/\\[(\\d+)\\/(\\d+)\\]\\s+(.+)/)\n    if (titleMatch) {\n      const videoTitle = titleMatch[3].trim()\n      console.log('检测到视频标题:', videoTitle)\n      currentVideoTitle.value = videoTitle\n\n      // 更新索引和封面\n      if (isFavoriteFolder.value && favoriteVideos.value.length > 0) {\n        if (index >= 0 && index < favoriteVideos.value.length) {\n          currentVideoIndex.value = index\n          const videoInfo = favoriteVideos.value[index]\n          if (videoInfo && videoInfo.cover) {\n            currentVideoCover.value = videoInfo.cover\n          }\n        }\n      } else {\n        // 搜索封面\n        trySearchCover(videoTitle)\n      }\n    }\n    return\n  }\n\n  // 检查是否为\"开始处理视频\"\n  const processingMatch = latestLog.match(/INFO\\s+开始处理视频\\s+(.+)/)\n  if (processingMatch) {\n    const videoTitle = processingMatch[1].trim()\n    console.log('检测到开始处理视频:', videoTitle)\n\n    // 更新当前视频标题\n    currentVideoTitle.value = videoTitle\n\n    // 查找匹配的视频\n    if (isFavoriteFolder.value && favoriteVideos.value.length > 0) {\n      const videoIndex = favoriteVideos.value.findIndex(v => v.title === videoTitle)\n      if (videoIndex >= 0) {\n        currentVideoIndex.value = videoIndex\n        const videoInfo = favoriteVideos.value[videoIndex]\n        if (videoInfo && videoInfo.cover) {\n          currentVideoCover.value = videoInfo.cover\n        }\n      } else {\n        // 没找到匹配的视频，尝试搜索封面\n        trySearchCover(videoTitle)\n      }\n    } else {\n      // 没有预加载数据，搜索封面\n      trySearchCover(videoTitle)\n    }\n    return\n  }\n\n  // 检查是否为\"合并完成\"\n  if (latestLog.includes('INFO 合并完成！')) {\n    console.log('检测到视频合并完成')\n\n    // 预测下一个视频\n    const nextIndex = currentVideoIndex.value + 1\n    if (isFavoriteFolder.value && favoriteVideos.value.length > 0 && nextIndex < favoriteVideos.value.length) {\n      // 等待短暂时间，看是否会有新的视频标题出现\n      setTimeout(() => {\n        // 再次检查最新的几条日志\n        const recentLogs = downloadLogs.value.slice(-5).join('\\n')\n        // 如果没有新的视频标题信息，则主动切换到下一个视频\n        if (!recentLogs.includes('[') || !recentLogs.includes('/')) {\n          console.log(`准备切换到下一个视频, 索引: ${nextIndex + 1}/${favoriteVideos.value.length}`)\n          currentVideoIndex.value = nextIndex\n          const nextVideo = favoriteVideos.value[nextIndex]\n          if (nextVideo) {\n            currentVideoTitle.value = nextVideo.title\n            currentVideoCover.value = nextVideo.cover || props.videoInfo.cover\n          }\n        }\n      }, 300)\n    }\n    return\n  }\n\n  // 检查是否为\"下载完成！\"\n  if (latestLog.includes('INFO 下载完成！')) {\n    console.log('检测到视频下载完成')\n    // 这里不做处理，等待\"合并完成\"的消息\n    return\n  }\n}, { deep: true })\n\n// 辅助函数：尝试搜索视频封面\nconst trySearchCover = async (videoTitle) => {\n  if (!videoTitle) return\n\n  // 此函数不再实际执行搜索操作，只记录日志\n  console.log('UP主投稿模式-图片加载失败，使用原始封面:', currentVideoCover.value)\n}\n\n// 监听 show 变化\nwatch(() => props.show, async (isVisible) => {\n  if (isVisible) {\n    // 输出调试信息\n    console.log('DownloadDialog 弹窗打开，接收到的视频信息:', JSON.stringify(props.videoInfo, null, 2))\n\n    // 初始化\n    currentVideoTitle.value = props.videoInfo.title\n    currentVideoCover.value = props.videoInfo.pic || props.videoInfo.cover\n    currentVideoAuthor.value = props.videoInfo.author || ''\n    currentVideoBvid.value = props.videoInfo.bvid || ''\n    console.log('设置封面图片路径:', currentVideoCover.value)\n    videoTitles.value = []\n    currentVideoIndex.value = -1\n    favoriteVideos.value = []\n\n    // 如果是收藏夹，预加载收藏夹内容\n    if (isFavoriteFolder.value && props.videoInfo.fid) {\n      await preloadFavoriteVideos()\n    }\n\n    // 在弹窗打开时检查 FFmpeg\n    checkFFmpegStatus()\n\n    // 根据传入的默认开关设置仅下载音频\n    if (props.defaultOnlyAudio) {\n      onlyAudio.value = true\n    }\n  }\n})\n\n// 重置状态\nconst resetState = () => {\n  downloadStarted.value = false\n  isDownloading.value = false\n  downloadError.value = false\n  downloadLogs.value = []\n  currentVideoTitle.value = props.videoInfo.title\n  currentVideoCover.value = props.videoInfo.pic || props.videoInfo.cover\n  currentVideoAuthor.value = props.videoInfo.author || ''\n  currentVideoBvid.value = props.videoInfo.bvid || ''\n  videoTitles.value = []\n  currentVideoIndex.value = -1\n  favoriteVideos.value = []\n  // 重置高级选项的显示状态\n  showAdvancedOptions.value = true\n\n  // 重置高级选项\n  advancedOptions.value = {\n    // 基础参数\n    video_quality: null,\n    audio_quality: null,\n    vcodec: null,\n    acodec: null,\n    download_vcodec_priority: null,\n    output_format: null,\n    output_format_audio_only: null,\n\n    // 资源选择参数\n    video_only: false,\n    danmaku_only: false,\n    no_danmaku: false,\n    subtitle_only: false,\n    no_subtitle: false,\n    with_metadata: false,\n    metadata_only: false,\n    no_cover: false,\n    save_cover: false,\n    cover_only: false,\n    no_chapter_info: false,\n  }\n}\n\n// 显示下载完成通知\nconst showDownloadCompleteNotify = () => {\n  showNotify({\n    type: 'success',\n    message: '下载已完成',\n    position: 'top',\n    duration: 2000,\n    teleport: '.notification-container',\n  })\n}\n\n// 开始下载\nconst startDownload = async () => {\n  try {\n    // 如果 FFmpeg 未安装，显示错误提示\n    if (ffmpegStatus.value && !ffmpegStatus.value.installed) {\n      downloadLogs.value.push('ERROR: FFmpeg 未安装，请先安装 FFmpeg')\n      downloadError.value = true\n      return\n    }\n\n    // 重置状态\n    downloadStarted.value = true\n    isDownloading.value = true\n    downloadError.value = false\n    downloadLogs.value = []\n\n    // 隐藏高级选项，让日志显示在更靠上的位置\n    showAdvancedOptions.value = false\n\n    // 首次显示正在使用预加载的视频\n    if (isFavoriteFolder.value && favoriteVideos.value.length > 0) {\n      downloadLogs.value.push(`INFO 将使用预加载的 ${favoriteVideos.value.length} 个视频信息进行下载`)\n\n      // 立即设置第一个视频的信息\n      currentVideoIndex.value = 0\n      const firstVideo = favoriteVideos.value[0]\n      if (firstVideo) {\n        currentVideoTitle.value = firstVideo.title\n        currentVideoCover.value = firstVideo.cover || props.videoInfo.cover\n      }\n    } else {\n      // 设置当前视频信息\n      currentVideoTitle.value = props.videoInfo.title\n      currentVideoCover.value = props.videoInfo.pic || props.videoInfo.cover\n    }\n\n    // 检查是否是用户视频下载请求\n    if (props.videoInfo.is_user_videos) {\n      // 使用传入的UP主视频列表\n      if (props.upUserVideos && props.upUserVideos.length > 0) {\n        console.log('使用预加载的UP主视频列表:', props.upUserVideos.length)\n        favoriteVideos.value = props.upUserVideos.map(video => ({\n          title: video.title || '',\n          cover: video.pic || '',\n          bvid: video.bvid || '',\n          author: video.author || '',\n        }))\n      }\n\n      // 发起用户视频下载请求并处理实时消息\n      await downloadUserVideos({\n        user_id: props.videoInfo.user_id,\n        download_cover: downloadCover.value,\n        only_audio: onlyAudio.value,\n        // 添加高级选项\n        ...advancedOptions.value,\n      }, (content) => {\n        console.log('收到用户视频下载消息:', content)\n        downloadLogs.value.push(content)\n\n        // 检查是否为视频标题信息 [n/5] 视频标题\n        const upVideoTitleMatch = content.match(/\\[(\\d+)\\/(\\d+)\\]\\s+(.+)/)\n        if (upVideoTitleMatch) {\n          const index = parseInt(upVideoTitleMatch[1], 10) - 1 // 索引从0开始\n          const total = parseInt(upVideoTitleMatch[2], 10)\n          const videoTitle = upVideoTitleMatch[3].trim()\n          console.log('检测到UP主视频标题:', videoTitle, `${index + 1}/${total}`)\n          currentVideoTitle.value = videoTitle\n\n          // 尝试从预加载的视频列表中找到匹配的视频以获取封面\n          if (favoriteVideos.value.length > 0) {\n            // 尝试使用索引直接获取\n            if (index >= 0 && index < favoriteVideos.value.length) {\n              const matchedVideo = favoriteVideos.value[index]\n              if (matchedVideo && matchedVideo.cover) {\n                console.log('找到匹配视频:', matchedVideo.title)\n                console.log('更新视频封面:', matchedVideo.cover)\n                currentVideoCover.value = matchedVideo.cover\n                currentVideoIndex.value = index\n              }\n            } else {\n              // 如果索引无效，尝试通过标题匹配\n              const videoByTitle = favoriteVideos.value.find(v => v.title === videoTitle)\n              if (videoByTitle && videoByTitle.cover) {\n                console.log('通过标题找到匹配视频:', videoByTitle.title)\n                console.log('更新视频封面:', videoByTitle.cover)\n                currentVideoCover.value = videoByTitle.cover\n                currentVideoIndex.value = favoriteVideos.value.indexOf(videoByTitle)\n              }\n            }\n          }\n        }\n\n        // 检查是否为\"开始处理视频\"\n        const processingMatch = content.match(/INFO\\s+开始处理视频\\s+(.+)/)\n        if (processingMatch) {\n          const videoTitle = processingMatch[1].trim()\n          console.log('检测到开始处理UP主视频:', videoTitle)\n          currentVideoTitle.value = videoTitle\n\n          // 尝试从预加载的视频列表中找到匹配的视频以获取封面\n          if (favoriteVideos.value.length > 0) {\n            const videoByTitle = favoriteVideos.value.find(v => v.title === videoTitle)\n            if (videoByTitle && videoByTitle.cover) {\n              console.log('根据处理信息找到匹配视频:', videoByTitle.title)\n              console.log('更新视频封面:', videoByTitle.cover)\n              currentVideoCover.value = videoByTitle.cover\n              currentVideoIndex.value = favoriteVideos.value.indexOf(videoByTitle)\n            }\n          }\n        }\n\n        // 检查下载状态\n        if (content.includes('下载完成') && !content.includes('INFO')) {\n          isDownloading.value = false\n          // 显示下载完成通知\n          showDownloadCompleteNotify()\n          emit('download-complete')\n        } else if (content.includes('ERROR')) {\n          downloadError.value = true\n          isDownloading.value = false\n        }\n\n        // 自动滚动到底部\n        nextTick(() => {\n          scrollToBottom()\n        })\n      })\n    }\n    // 检查是否是收藏夹下载请求\n    else if (props.videoInfo.is_favorite_folder) {\n      // 发起收藏夹下载请求并处理实时消息\n      await downloadFavorites({\n        user_id: props.videoInfo.user_id,\n        fid: props.videoInfo.fid,\n        download_cover: downloadCover.value,\n        only_audio: onlyAudio.value,\n        // 添加高级选项\n        ...advancedOptions.value,\n      }, (content) => {\n        console.log('收到收藏夹下载消息:', content)\n        downloadLogs.value.push(content)\n\n        // 检查下载状态\n        if (content.includes('下载完成') && !content.includes('INFO')) {\n          isDownloading.value = false\n          // 显示下载完成通知\n          showDownloadCompleteNotify()\n          emit('download-complete')\n        } else if (content.includes('ERROR')) {\n          downloadError.value = true\n          isDownloading.value = false\n        }\n\n        // 自动滚动到底部\n        nextTick(() => {\n          scrollToBottom()\n        })\n      })\n    } else if (props.isBatchDownload && props.batchVideos.length > 0) {\n      // 批量下载多个视频\n      downloadLogs.value.push(`INFO 开始批量下载，共 ${props.batchVideos.length} 个视频`)\n\n      // 设置初始视频信息\n      if (props.batchVideos.length > 0 && props.currentVideoIndex < props.batchVideos.length) {\n        const currentVideo = props.batchVideos[props.currentVideoIndex]\n        currentVideoTitle.value = currentVideo.title || currentVideo.bvid\n        currentVideoCover.value = currentVideo.cover || props.videoInfo.cover\n        currentVideoAuthor.value = currentVideo.author || props.videoInfo.author || ''\n        currentVideoBvid.value = currentVideo.bvid || props.videoInfo.bvid || ''\n      }\n\n      // 发起批量下载请求\n      await batchDownloadVideos({\n        videos: props.batchVideos,\n        download_cover: downloadCover.value,\n        only_audio: onlyAudio.value,\n        // 添加高级选项\n        ...advancedOptions.value,\n      }, (content) => {\n        console.log('收到批量下载消息:', content)\n        downloadLogs.value.push(content)\n\n        // 检查是否包含当前下载的视频信息\n        const currentVideoMatch = content.match(/正在下载第\\s+(\\d+)\\/(\\d+)\\s+个视频:\\s+(.+)/)\n        if (currentVideoMatch) {\n          const index = parseInt(currentVideoMatch[1], 10) - 1\n          const total = parseInt(currentVideoMatch[2], 10)\n          const title = currentVideoMatch[3].trim()\n\n          console.log(`正在下载第 ${index + 1}/${total} 个视频: ${title}`)\n\n          // 更新当前视频信息\n          if (index >= 0 && index < props.batchVideos.length) {\n            currentVideoIndex.value = index\n            currentVideoTitle.value = title\n            const video = props.batchVideos[index]\n            if (video) {\n              if (video.cover) {\n                currentVideoCover.value = video.cover\n              }\n              if (video.author) {\n                currentVideoAuthor.value = video.author\n              }\n              if (video.bvid) {\n                currentVideoBvid.value = video.bvid\n              }\n            }\n            // 通知父组件当前视频索引已更新\n            emit('update:currentVideoIndex', index)\n          }\n        }\n\n        // 检查下载状态\n        if (content.includes('批量下载完成') || (content.includes('下载完成') && !content.includes('INFO'))) {\n          isDownloading.value = false\n          // 显示下载完成通知\n          showDownloadCompleteNotify()\n          emit('download-complete')\n        } else if (content.includes('ERROR')) {\n          downloadError.value = true\n          isDownloading.value = false\n        }\n\n        // 自动滚动到底部\n        nextTick(() => {\n          scrollToBottom()\n        })\n      })\n    } else if (props.videoInfo.is_collection_download) {\n      // 合集下载\n      console.log('开始合集下载:', props.videoInfo)\n\n      await downloadCollection({\n        url: props.videoInfo.original_url,\n        cid: props.videoInfo.cid,\n        download_cover: downloadCover.value,\n        only_audio: onlyAudio.value,\n        // 添加高级选项\n        ...advancedOptions.value,\n      }, (content) => {\n        console.log('收到合集下载消息:', content)\n        downloadLogs.value.push(content)\n\n        // 检查下载状态\n        if (content.includes('下载完成')) {\n          isDownloading.value = false\n          // 显示下载完成通知\n          showDownloadCompleteNotify()\n          emit('download-complete')\n        } else if (content.includes('ERROR')) {\n          downloadError.value = true\n          isDownloading.value = false\n        }\n\n        // 自动滚动到底部\n        nextTick(() => {\n          scrollToBottom()\n        })\n      })\n    } else {\n      // 发起单个视频下载请求并处理实时消息\n      await downloadVideo(\n        props.videoInfo.bvid,\n        null,\n        (content) => {\n          console.log('收到消息:', content)\n          downloadLogs.value.push(content)\n\n          // 检查下载状态\n          if (content.includes('下载完成')) {\n            isDownloading.value = false\n            // 显示下载完成通知\n            showDownloadCompleteNotify()\n            emit('download-complete')\n          } else if (content.includes('ERROR')) {\n            downloadError.value = true\n            isDownloading.value = false\n          }\n\n          // 自动滚动到底部\n          nextTick(() => {\n            scrollToBottom()\n          })\n        },\n        downloadCover.value,\n        onlyAudio.value,\n        props.videoInfo.cid,\n        // 添加高级选项\n        advancedOptions.value,\n      )\n    }\n  } catch (error) {\n    console.error('下载失败:', error)\n    downloadError.value = true\n    isDownloading.value = false\n    const errorLines = error.message.split('\\n')\n    for (const line of errorLines) {\n      downloadLogs.value.push(`ERROR: ${line}`)\n    }\n  }\n}\n\n// 滚动到底部的优化实现\nconst scrollToBottom = () => {\n  if (logContainer.value) {\n    logContainer.value.scrollTop = logContainer.value.scrollHeight\n  }\n}\n\n// 监听日志变化\nwatch(() => downloadLogs.value.length, () => {\n  nextTick(() => {\n    scrollToBottom()\n  })\n})\n\n// 处理关闭弹窗\nconst handleClose = () => {\n  if (isDownloading.value) {\n    if (!confirm('下载正在进行中，确定要关闭吗？')) {\n      return\n    }\n  }\n\n  // 如果下载已完成且没有错误，触发下载完成事件\n  if (downloadStarted.value && !isDownloading.value && !downloadError.value) {\n    emit('download-complete')\n  }\n\n  emit('update:show', false)\n  // 重置状态\n  resetState()\n}\n\n// 监听show变化\nwatch(() => props.show, (newVal) => {\n  if (!newVal) {\n    handleClose()\n  } else {\n    // 在弹窗打开时检查 FFmpeg\n    checkFFmpegStatus()\n    // 初始化当前视频标题和封面\n    currentVideoTitle.value = props.videoInfo.title\n    currentVideoCover.value = props.videoInfo.pic || props.videoInfo.cover\n  }\n})\n\n// 组件卸载时清理\nonUnmounted(() => {\n  // 重置状态\n  resetState()\n})\n\n// 复制到剪贴板函数\nconst copyToClipboard = async (text) => {\n  try {\n    await navigator.clipboard.writeText(text)\n    showNotify({\n      type: 'success',\n      message: '命令已复制到剪贴板',\n      position: 'top',\n      duration: 2000,\n      teleport: '.notification-container',\n    })\n  } catch (err) {\n    console.error('复制失败:', err)\n    showNotify({\n      type: 'danger',\n      message: '复制失败，请手动复制',\n      position: 'top',\n      duration: 2000,\n      teleport: '.notification-container',\n    })\n  }\n}\n\n// 处理安装指南的行\nconst installGuideLines = computed(() => {\n  if (!ffmpegStatus.value?.install_guide) return []\n  return ffmpegStatus.value.install_guide.split('\\n').filter(line => line.trim())\n})\n\n// 判断是否为命令行\nconst isCommand = (line) => {\n  return line.trim().startsWith('yum') ||\n    line.trim().startsWith('sudo') ||\n    line.trim().startsWith('apt') ||\n    line.trim().startsWith('brew')\n}\n\n// 获取命令内容\nconst getCommandContent = (line) => {\n  return line.trim()\n}\n\n// 下拉菜单选项定义\nconst videoQualityOptions = [\n  { label: '默认', value: null },\n  { label: '127: 8K 超高清', value: '127' },\n  { label: '126: 杜比视界', value: '126' },\n  { label: '125: HDR 真彩', value: '125' },\n  { label: '120: 4K 超清', value: '120' },\n  { label: '116: 1080P 60帧', value: '116' },\n  { label: '112: 1080P 高码率', value: '112' },\n  { label: '100: 智能修复', value: '100' },\n  { label: '80: 1080P 高清', value: '80' },\n  { label: '74: 720P 60帧', value: '74' },\n  { label: '64: 720P 高清', value: '64' },\n  { label: '32: 480P 清晰', value: '32' },\n  { label: '16: 360P 流畅', value: '16' },\n]\n\nconst audioQualityOptions = [\n  { label: '默认', value: null },\n  { label: '30251: Hi-Res', value: '30251' },\n  { label: '30255: 杜比音效', value: '30255' },\n  { label: '30250: 杜比全景声', value: '30250' },\n  { label: '30280: 320kbps', value: '30280' },\n  { label: '30232: 128kbps', value: '30232' },\n  { label: '30216: 64kbps', value: '30216' },\n]\n\nconst vcodecOptions = [\n  { label: '默认 (avc:copy)', value: null },\n  { label: '下载AVC(H.264):直接复制', value: 'avc:copy' },\n  { label: '下载HEVC(H.265):直接复制', value: 'hevc:copy' },\n  { label: '下载AV1:直接复制', value: 'av1:copy' },\n  { label: '下载AVC(H.264):转码为H.264', value: 'avc:h264' },\n  { label: '下载HEVC(H.265):转码为H.265', value: 'hevc:hevc' },\n  { label: '下载AV1:转码为AV1', value: 'av1:av1' },\n]\n\nconst acodecOptions = [\n  { label: '默认 (mp4a:copy)', value: null },\n  { label: '下载MP4A:直接复制', value: 'mp4a:copy' },\n  { label: '下载MP4A:转码为AAC', value: 'mp4a:aac' },\n  { label: '下载MP4A:转码为MP3', value: 'mp4a:mp3' },\n  { label: '下载MP4A:转码为FLAC', value: 'mp4a:flac' },\n]\n\nconst outputFormatOptions = [\n  { label: '默认', value: null },\n  { label: '自动推断', value: 'infer' },\n  { label: 'MP4', value: 'mp4' },\n  { label: 'MKV', value: 'mkv' },\n  { label: 'MOV', value: 'mov' },\n]\n\nconst audioOutputFormatOptions = [\n  { label: '默认', value: null },\n  { label: '自动推断', value: 'infer' },\n  { label: 'M4A', value: 'm4a' },\n  { label: 'AAC', value: 'aac' },\n  { label: 'MP3', value: 'mp3' },\n  { label: 'FLAC', value: 'flac' },\n]\n\n// 获取选项文本的方法\nconst getVideoQualityText = (value) => {\n  const option = videoQualityOptions.find(opt => opt.value === value)\n  return option ? option.label : '默认'\n}\n\nconst getAudioQualityText = (value) => {\n  const option = audioQualityOptions.find(opt => opt.value === value)\n  return option ? option.label : '默认'\n}\n\nconst getVcodecText = (value) => {\n  const option = vcodecOptions.find(opt => opt.value === value)\n  return option ? option.label : '默认'\n}\n\nconst getAcodecText = (value) => {\n  const option = acodecOptions.find(opt => opt.value === value)\n  return option ? option.label : '默认'\n}\n\nconst getOutputFormatText = (value) => {\n  const option = outputFormatOptions.find(opt => opt.value === value)\n  return option ? option.label : '默认'\n}\n\nconst getAudioOutputFormatText = (value) => {\n  const option = audioOutputFormatOptions.find(opt => opt.value === value)\n  return option ? option.label : '默认'\n}\n</script>\n\n<style scoped>\n/* 当弹窗显示时禁用页面滚动 */\n:global(body) {\n  overflow: hidden;\n}\n</style>\n"
  },
  {
    "path": "src/components/tailwind/EnvironmentCheck.vue",
    "content": "<template>\n  <!-- 系统资源状态显示 -->\n  <div :class=\"[inline ? 'flex flex-row items-center space-x-2' : 'flex flex-col space-y-2']\">\n    <!-- 加载状态 -->\n    <div v-if=\"loading\" class=\"flex items-center space-x-2 bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400 rounded-lg\" :class=\"[compact ? 'p-1 text-xs' : 'p-2 text-sm']\">\n      <div class=\"animate-spin h-4 w-4 border-2 border-gray-300 border-t-transparent rounded-full\"></div>\n      <span>检查系统环境...</span>\n    </div>\n\n    <!-- 错误状态 -->\n    <div v-else-if=\"error\" class=\"flex items-center space-x-2 bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-lg border border-red-200 dark:border-red-800/60\" :class=\"[compact ? 'p-1 text-xs' : 'p-2 text-sm']\">\n      <svg :class=\"[compact ? 'w-4 h-4 flex-shrink-0' : 'w-5 h-5 flex-shrink-0']\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z\" />\n      </svg>\n      <span>{{ error }}</span>\n    </div>\n\n    <!-- 系统资源检查结果 -->\n    <template v-else>\n      <!-- 系统资源状态 -->\n      <div class=\"group relative\">\n        <div v-if=\"systemResources?.can_run_speech_to_text\" \n             class=\"flex items-center space-x-2 bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-lg border border-green-200 dark:border-green-800/60 cursor-help\" :class=\"[compact ? 'p-1 text-xs' : 'p-2 text-sm']\">\n          <svg :class=\"[compact ? 'w-4 h-4 flex-shrink-0' : 'w-5 h-5 flex-shrink-0']\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 13l4 4L19 7\" />\n          </svg>\n          <span class=\"font-medium\">系统资源满足要求</span>\n          \n          <!-- 悬浮详情 -->\n          <div class=\"hidden group-hover:block absolute right-0 top-full mt-2 w-64 p-3 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-30 text-sm\">\n            <h4 class=\"font-medium text-gray-900 dark:text-gray-100 mb-2\">系统资源详情</h4>\n            <div class=\"space-y-2 text-gray-600 dark:text-gray-300\">\n              <p>内存: {{ systemResources.memory.available_gb.toFixed(1) }}GB / {{ systemResources.memory.total_gb.toFixed(1) }}GB</p>\n              <p>CPU: {{ systemResources.cpu.physical_cores }} 核心 ({{ systemResources.cpu.logical_cores }} 线程)</p>\n              <p>磁盘可用: {{ systemResources.disk.free_gb.toFixed(1) }}GB</p>\n            </div>\n          </div>\n        </div>\n        <div v-else class=\"flex items-center space-x-2 bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-lg border border-red-200 dark:border-red-800/60\" :class=\"[compact ? 'p-1 text-xs' : 'p-2 text-sm']\">\n          <svg :class=\"[compact ? 'w-4 h-4 flex-shrink-0' : 'w-5 h-5 flex-shrink-0']\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z\" />\n          </svg>\n          <div class=\"flex flex-col\">\n            <span class=\"font-medium\">无法使用本地摘要功能</span>\n            <span class=\"text-xs\">{{ systemResources?.limitation_reason }}</span>\n          </div>\n        </div>\n      </div>\n\n      <!-- CUDA状态 -->\n      <template v-if=\"systemResources?.can_run_speech_to_text\">\n        <div v-if=\"cudaLoading\" class=\"flex items-center space-x-2 bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400 rounded-lg\" :class=\"[compact ? 'p-1 text-xs' : 'p-2 text-sm']\">\n          <div class=\"animate-spin h-4 w-4 border-2 border-gray-300 border-t-transparent rounded-full\"></div>\n          <span>检查CUDA支持...</span>\n        </div>\n        \n        <div v-else class=\"group relative\">\n          <div v-if=\"envInfo?.system_info.cuda_available\"\n               class=\"flex items-center space-x-2 bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-lg border border-green-200 dark:border-green-800/60\" :class=\"[compact ? 'p-1 text-xs' : 'p-2 text-sm']\">\n            <svg :class=\"[compact ? 'w-4 h-4 flex-shrink-0' : 'w-5 h-5 flex-shrink-0']\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 13l4 4L19 7\" />\n            </svg>\n            <span class=\"font-medium\">CUDA 可用 ({{ envInfo.system_info.cuda_version }})</span>\n          </div>\n          <div v-else class=\"flex items-center space-x-2 bg-yellow-50 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 rounded-lg border border-yellow-200 dark:border-yellow-800/60\" :class=\"[compact ? 'p-1 text-xs' : 'p-2 text-sm']\">\n            <svg :class=\"[compact ? 'w-4 h-4 flex-shrink-0' : 'w-5 h-5 flex-shrink-0']\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n            </svg>\n            <div class=\"flex flex-col\">\n              <span class=\"font-medium\">CUDA 不可用</span>\n              <span class=\"text-xs\">将使用 CPU 进行处理（速度较慢）</span>\n            </div>\n          </div>\n        </div>\n      </template>\n    </template>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted } from 'vue'\nimport { checkAudioToTextEnvironment, checkSystemResources } from '../../api/api'\nimport { showNotify } from 'vant'\nimport 'vant/es/notify/style'\n\nconst { inline = false, compact = false } = defineProps({\n  inline: { type: Boolean, default: false },\n  compact: { type: Boolean, default: false }\n})\n\nconst loading = ref(true)\nconst cudaLoading = ref(false)\nconst error = ref(null)\nconst envInfo = ref(null)\nconst systemResources = ref(null)\n\nconst emit = defineEmits(['environment-checked'])\n\nconst checkEnvironment = async () => {\n  try {\n    loading.value = true\n    error.value = null\n\n    // 1. 首先检查系统资源\n    const resourceResponse = await checkSystemResources()\n    systemResources.value = resourceResponse.data\n\n    // 发送检查结果到父组件\n    emit('environment-checked', {\n      canRun: systemResources.value.can_run_speech_to_text,\n      limitationReason: systemResources.value.limitation_reason\n    })\n\n    // 2. 如果系统资源满足要求，再检查CUDA\n    if (systemResources.value.can_run_speech_to_text) {\n      cudaLoading.value = true\n      const cudaResponse = await checkAudioToTextEnvironment()\n      envInfo.value = cudaResponse.data\n    }\n  } catch (err) {\n    error.value = '获取环境信息失败：' + (err.message || '未知错误')\n    showNotify({\n      type: 'danger',\n      message: error.value\n    })\n    // 发送错误状态到父组件\n    emit('environment-checked', {\n      canRun: false,\n      limitationReason: error.value\n    })\n  } finally {\n    loading.value = false\n    cudaLoading.value = false\n  }\n}\n\nonMounted(() => {\n  checkEnvironment()\n})\n</script> "
  },
  {
    "path": "src/components/tailwind/FavoriteDialog.vue",
    "content": "<template>\n  <van-dialog\n    v-model:show=\"visible\"\n    :title=\"title\"\n    :width=\"350\"\n    class=\"favorite-dialog\"\n    show-cancel-button\n    :confirm-button-text=\"confirmText\"\n    :cancel-button-text=\"cancelText\"\n    @confirm=\"handleConfirm\"\n    @cancel=\"handleCancel\"\n  >\n    <div class=\"p-5 bg-transparent\">\n      <div v-if=\"loading\" class=\"flex justify-center py-4\">\n        <div class=\"inline-flex items-center\">\n          <svg class=\"animate-spin -ml-1 mr-3 h-5 w-5 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\">\n            <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n            <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n          </svg>\n          <span>加载中...</span>\n        </div>\n      </div>\n      \n      <div v-else-if=\"favorites.length === 0\" class=\"text-center py-4\">\n        <p class=\"text-gray-500 dark:text-gray-400\">暂无收藏夹</p>\n        <div class=\"mt-3\">\n          <button \n            class=\"px-3 py-1.5 text-sm bg-[#fb7299] text-white rounded-md hover:bg-[#fb7299]/90 transition-colors\"\n            @click=\"openLoginDialog\"\n          >\n            登录账号\n          </button>\n        </div>\n      </div>\n      \n      <div v-else>\n        <div v-if=\"videoInfo\" class=\"mb-3\">\n          <p class=\"text-sm text-gray-700 truncate\">\n            <span v-if=\"videoInfo.isBatch\">批量收藏：{{ videoInfo.selectedCount }}个视频</span>\n            <span v-else>收藏视频：{{ videoInfo.title }}</span>\n          </p>\n        </div>\n        \n        <div class=\"max-h-60 overflow-y-auto pr-2 space-y-2\">\n          <div\n            v-for=\"folder in favorites\"\n            :key=\"folder.id\"\n            class=\"flex items-center p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors\"\n          >\n            <input \n              type=\"checkbox\" \n              :id=\"`folder-${folder.id}`\" \n              :value=\"folder.id\" \n              v-model=\"selectedFolders\"\n              class=\"w-4 h-4 text-[#fb7299] border-gray-300 dark:border-gray-600 rounded focus:ring-[#fb7299]\"\n            />\n            <label :for=\"`folder-${folder.id}`\" class=\"ml-2 flex-1 cursor-pointer\">\n              <div class=\"flex items-center\">\n                <span class=\"text-sm font-medium text-gray-700 dark:text-gray-200\">{{ folder.title }}</span>\n                <span class=\"ml-1 text-xs text-gray-500 dark:text-gray-400\">({{ folder.media_count }})</span>\n              </div>\n            </label>\n          </div>\n        </div>\n      </div>\n    </div>\n  </van-dialog>\n  \n  <!-- 登录对话框 -->\n  <Teleport to=\"body\">\n    <LoginDialog\n      v-model:show=\"showLoginDialog\"\n      @login-success=\"handleLoginSuccess\"\n    />\n  </Teleport>\n</template>\n\n<script setup>\nimport { ref, defineProps, defineEmits, computed, watch, onMounted } from 'vue'\nimport { getCreatedFavoriteFolders, favoriteResource, batchFavoriteResource, localBatchFavoriteResource } from '../../api/api.js'\nimport { showNotify } from 'vant'\nimport LoginDialog from './LoginDialog.vue'\n\nconst props = defineProps({\n  modelValue: {\n    type: Boolean,\n    default: false\n  },\n  videoInfo: {\n    type: Object,\n    default: () => ({})\n  }\n})\n\nconst emit = defineEmits(['update:modelValue', 'favoriteDone'])\n\n// 对话框状态\nconst visible = computed({\n  get: () => props.modelValue,\n  set: (val) => emit('update:modelValue', val)\n})\n\n// 组件数据\nconst loading = ref(false)\nconst favorites = ref([])\nconst selectedFolders = ref([])\nconst showLoginDialog = ref(false)\nconst isLoggedIn = ref(false)\n\n// 对话框文本\nconst title = computed(() => '选择收藏夹')\nconst confirmText = computed(() => '确认收藏')\nconst cancelText = computed(() => '取消')\n\n// 加载收藏夹列表\nconst loadFavorites = async () => {\n  loading.value = true\n  try {\n    const response = await getCreatedFavoriteFolders()\n    if (response.data.status === 'success') {\n      favorites.value = response.data.data.list || []\n      isLoggedIn.value = true\n    } else {\n      // 没有权限或未登录\n      isLoggedIn.value = false\n      favorites.value = []\n    }\n  } catch (error) {\n    console.error('获取收藏夹列表失败:', error)\n    showNotify({ type: 'danger', message: '获取收藏夹列表失败' })\n  } finally {\n    loading.value = false\n  }\n}\n\n// 处理确认按钮\nconst handleConfirm = async () => {\n  if (selectedFolders.value.length === 0) {\n    showNotify({ type: 'warning', message: '请至少选择一个收藏夹' })\n    return\n  }\n  \n  loading.value = true\n  try {\n    let response;\n    \n    // 判断是批量收藏还是单个收藏\n    if (props.videoInfo.isBatch && props.videoInfo.batchIds) {\n      // 批量收藏\n      const requestParams = {\n        rids: props.videoInfo.batchIds,\n        add_media_ids: selectedFolders.value.join(',')\n      };\n      \n      // 先远程操作，然后本地同步\n      response = await batchFavoriteResource(requestParams);\n      \n      // 成功后进行本地同步（不展示给用户）\n      if (response.data.status === 'success') {\n        try {\n          await localBatchFavoriteResource({\n            rids: props.videoInfo.batchIds,\n            add_media_ids: selectedFolders.value.join(','),\n            operation_type: 'local' // 只在本地操作，不需要再同步远程\n          });\n        } catch (syncError) {\n          console.error('本地同步失败，但不影响用户体验:', syncError);\n        }\n        \n        showNotify({ type: 'success', message: `成功收藏${props.videoInfo.selectedCount}个视频` });\n        \n        emit('favoriteDone', { \n          success: true, \n          videoInfo: props.videoInfo, \n          folders: selectedFolders.value,\n          isBatch: true\n        });\n        \n        visible.value = false;\n      } else {\n        throw new Error(response.data.message || '批量收藏失败');\n      }\n    } else {\n      // 单个视频收藏\n      // 获取视频ID，适配不同的属性名（aid或avid）\n      const videoId = props.videoInfo?.aid || props.videoInfo?.avid || (props.videoInfo?.business === 'archive' ? props.videoInfo?.oid : null);\n      \n      if (!props.videoInfo || !videoId) {\n        showNotify({ type: 'warning', message: '视频信息不完整，无法收藏' });\n        return;\n      }\n      \n      // 先远程操作\n      response = await favoriteResource({\n        rid: videoId,\n        add_media_ids: selectedFolders.value.join(',')\n      });\n      \n      if (response.data.status === 'success') {\n        // 成功后进行本地同步（不展示给用户）\n        try {\n          await localBatchFavoriteResource({\n            rids: videoId.toString(),\n            add_media_ids: selectedFolders.value.join(','),\n            operation_type: 'local' // 只在本地操作，不需要再同步远程\n          });\n        } catch (syncError) {\n          console.error('本地同步失败，但不影响用户体验:', syncError);\n        }\n        \n        showNotify({ type: 'success', message: '收藏成功' });\n        \n        emit('favoriteDone', { \n          success: true, \n          videoInfo: props.videoInfo, \n          folders: selectedFolders.value\n        });\n        \n        visible.value = false;\n      } else {\n        throw new Error(response.data.message || '收藏失败');\n      }\n    }\n  } catch (error) {\n    console.error('收藏视频失败:', error);\n    showNotify({ type: 'danger', message: '收藏失败: ' + (error.message || '未知错误') });\n  } finally {\n    loading.value = false;\n  }\n}\n\n// 处理取消按钮\nconst handleCancel = () => {\n  visible.value = false\n}\n\n// 打开登录对话框\nconst openLoginDialog = () => {\n  showLoginDialog.value = true\n}\n\n// 登录成功回调\nconst handleLoginSuccess = () => {\n  showNotify({ type: 'success', message: '登录成功' })\n  loadFavorites()\n}\n\n// 监听对话框显示状态变化\nwatch(() => visible.value, (newVal) => {\n  if (newVal) {\n    selectedFolders.value = []\n    loadFavorites()\n  }\n})\n\nonMounted(() => {\n  if (visible.value) {\n    loadFavorites()\n  }\n})\n</script>\n\n<style scoped>\n.favorite-dialog {\n  border-radius: 8px;\n  overflow: hidden;\n}\n\n/* 对话框标题背景与分隔线（明暗主题） */\n.favorite-dialog :deep(.van-dialog__header) {\n  /* 与正文一致的标题背景（浅色） */\n  background-color: #ffffff; /* white */\n  border-bottom: 1px solid #e5e7eb; /* gray-200 */\n  color: #111827; /* gray-900 */\n}\n.dark .favorite-dialog :deep(.van-dialog__header) {\n  /* 与正文一致的标题背景（深色） */\n  background-color: #1f2937; /* gray-800 */\n  border-bottom-color: #374151; /* gray-700 */\n  color: #e5e7eb; /* gray-200 */\n}\n\n/* 对话框内容区域背景与正文一致（非标题部分） */\n.favorite-dialog :deep(.van-dialog__content) {\n  background-color: #ffffff; /* white */\n}\n.dark .favorite-dialog :deep(.van-dialog__content) {\n  background-color: #1f2937; /* gray-800 */\n}\n</style>"
  },
  {
    "path": "src/components/tailwind/FilterDropdown.vue",
    "content": "<template>\n  <div class=\"relative\">\n    <!-- 筛选头部 - 所有元素在同一行 -->\n    <div class=\"flex items-center justify-between flex-wrap py-2 px-3 rounded-md\">\n      <!-- 条目类型快速切换区域 -->\n      <div class=\"flex flex-1 flex-wrap gap-1 sm:gap-2\">\n        <button\n          v-for=\"(label, type) in businessTypeMap\"\n          :key=\"type\"\n          class=\"px-2 sm:px-3 py-1 sm:py-1.5 text-xs rounded-md border transition-colors duration-200\"\n          :class=\"business === type ? 'border-[#fb7299] bg-[#fb7299]/10 text-[#fb7299]' : 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:border-[#fb7299]/50'\"\n          @click=\"selectBusiness(type)\"\n        >\n          {{ label }}\n        </button>\n      </div>\n\n      <!-- 右侧操作区 -->\n      <div class=\"flex items-center space-x-2 sm:space-x-3 ml-1 sm:ml-2\">\n        <!-- 每页显示条数设置 -->\n        <div class=\"flex items-center text-xs text-gray-500 dark:text-gray-400\">\n          <span class=\"mr-1\">每页</span>\n          <input\n            type=\"number\"\n            :value=\"pageSize\"\n            @input=\"handlePageSizeChange\"\n            @blur=\"handlePageSizeBlur\"\n            min=\"10\"\n            max=\"100\"\n            class=\"w-12 h-6 rounded border border-gray-200 dark:border-gray-600 bg-transparent px-1 text-center text-gray-700 dark:text-gray-200 transition-colors [appearance:textfield] hover:border-[#fb7299] focus:border-[#fb7299] focus:outline-none focus:ring-1 focus:ring-[#fb7299]/30 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none\"\n          />\n          <span class=\"ml-1\">条</span>\n        </div>\n\n        <!-- 总视频数显示 -->\n        <div class=\"text-xs text-gray-500 dark:text-gray-400\">\n          总视频数: <span class=\"text-[#FF6699] font-medium\">{{ total }}</span>\n        </div>\n      </div>\n    </div>\n\n    <!-- 底部弹出式筛选栏 -->\n    <VanPopup\n      v-model:show=\"showFilterPopup\"\n      position=\"bottom\"\n      round\n      :z-index=\"2000\"\n      get-container=\"body\"\n      teleport=\"body\"\n      :style=\"{ height: '70%' }\"\n      class=\"overflow-hidden\"\n    >\n      <div class=\"p-3 sm:p-4 h-full flex flex-col bg-white dark:bg-gray-900\">\n\n        <div class=\"flex-1 overflow-y-auto\">\n          <!-- 条目类型筛选 -->\n          <div class=\"mb-4 sm:mb-6\">\n            <div class=\"flex items-center mb-2\">\n              <h4 class=\"text-sm font-medium text-gray-700 dark:text-gray-200\">条目类型</h4>\n              <div class=\"flex items-center ml-2 flex-1\">\n                <span\n                  class=\"text-xs sm:text-sm text-[#fb7299] font-medium truncate max-w-[80%]\">{{ businessLabel || '全部'\n                  }}</span>\n                <button\n                  v-if=\"business\"\n                  @click=\"clearBusiness\"\n                  class=\"ml-1 sm:ml-2 p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800\"\n                >\n                  <svg class=\"w-3 h-3 sm:w-4 sm:h-4 text-gray-500\" fill=\"none\" viewBox=\"0 0 24 24\"\n                       stroke=\"currentColor\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\" />\n                  </svg>\n                </button>\n              </div>\n            </div>\n\n            <div class=\"grid grid-cols-3 gap-1.5 sm:gap-2\">\n              <div\n                v-for=\"(label, type) in businessTypeMap\"\n                :key=\"type\"\n                class=\"flex items-center p-1.5 sm:p-2 rounded-lg cursor-pointer border transition-colors duration-200\"\n                :class=\"business === type ? 'border-[#fb7299] bg-[#fb7299]/5' : 'border-gray-200 dark:border-gray-700 hover:border-[#fb7299]/50'\"\n                @click=\"selectBusinessFromPopup(type)\"\n              >\n                <div class=\"flex-1\">\n                  <div class=\"text-xs font-medium truncate\">{{ label }}</div>\n                </div>\n                <div v-if=\"business === type\" class=\"text-[#fb7299]\">\n                  <svg class=\"w-3 h-3 sm:w-4 sm:h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 13l4 4L19 7\" />\n                  </svg>\n                </div>\n              </div>\n            </div>\n          </div>\n\n          <!-- 日期筛选 -->\n          <div class=\"mb-4 sm:mb-6\">\n            <div class=\"flex items-center mb-2\">\n              <h4 class=\"text-sm font-medium text-gray-700 dark:text-gray-200\">日期区间</h4>\n              <div class=\"flex items-center ml-2 flex-1\">\n                <span class=\"text-xs sm:text-sm text-[#fb7299] font-medium truncate max-w-[80%]\">{{ date || '全部'\n                  }}</span>\n                <button\n                  v-if=\"date\"\n                  @click=\"clearDate\"\n                  class=\"ml-1 sm:ml-2 p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800\"\n                >\n                  <svg class=\"w-3 h-3 sm:w-4 sm:h-4 text-gray-500\" fill=\"none\" viewBox=\"0 0 24 24\"\n                       stroke=\"currentColor\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\" />\n                  </svg>\n                </button>\n              </div>\n            </div>\n\n            <div class=\"flex items-center space-x-2\">\n              <div class=\"relative flex-1\">\n                <input\n                  type=\"date\"\n                  v-model=\"startDate\"\n                  @change=\"onDateChange\"\n                  class=\"w-full p-1.5 sm:p-2 text-xs sm:text-sm border border-gray-300 dark:border-gray-600 bg-transparent text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-1 focus:ring-[#fb7299] focus:border-[#fb7299] cursor-pointer\"\n                  :max=\"endDate || undefined\"\n                />\n                <label class=\"absolute -top-1.5 left-2 text-[10px] bg-transparent px-1 text-gray-500 dark:text-gray-400\">开始日期</label>\n              </div>\n              <span class=\"text-gray-400\">至</span>\n              <div class=\"relative flex-1\">\n                <input\n                  type=\"date\"\n                  v-model=\"endDate\"\n                  @change=\"onDateChange\"\n                  class=\"w-full p-1.5 sm:p-2 text-xs sm:text-sm border border-gray-300 dark:border-gray-600 bg-transparent text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-1 focus:ring-[#fb7299] focus:border-[#fb7299] cursor-pointer\"\n                  :min=\"startDate || undefined\"\n                />\n                <label class=\"absolute -top-1.5 left-2 text-[10px] bg-transparent px-1 text-gray-500 dark:text-gray-400\">结束日期</label>\n              </div>\n            </div>\n          </div>\n\n          <!-- 视频分区筛选 -->\n          <div>\n            <div class=\"flex items-center mb-2\">\n              <h4 class=\"text-sm font-medium text-gray-700 dark:text-gray-200\">视频分区</h4>\n              <div class=\"flex items-center ml-2 flex-1\">\n                <span class=\"text-xs sm:text-sm text-[#fb7299] font-medium truncate max-w-[80%]\">{{ category || '全部'\n                  }}</span>\n                <button\n                  v-if=\"category\"\n                  @click=\"clearCategory\"\n                  class=\"ml-1 sm:ml-2 p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800\"\n                >\n                  <svg class=\"w-3 h-3 sm:w-4 sm:h-4 text-gray-500\" fill=\"none\" viewBox=\"0 0 24 24\"\n                       stroke=\"currentColor\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\" />\n                  </svg>\n                </button>\n              </div>\n            </div>\n\n            <!-- 分区选择器 -->\n            <div class=\"border border-gray-300 dark:border-gray-600 rounded-md overflow-hidden\">\n              <!-- 主分区选择 -->\n              <div class=\"flex h-48 sm:h-56\">\n                <div class=\"w-1/3 border-r border-gray-300 dark:border-gray-600 overflow-y-auto bg-transparent\">\n                  <div\n                    v-for=\"(category, index) in videoCategories\"\n                    :key=\"category.text\"\n                    class=\"p-1.5 sm:p-2 text-xs sm:text-sm cursor-pointer transition-colors duration-200 truncate\"\n                    :class=\"activeMainCategory === index ? 'bg-[#fb7299]/10 text-[#fb7299] font-medium' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'\"\n                    @click=\"activeMainCategory = index\"\n                  >\n                    {{ category.text }}\n                  </div>\n                </div>\n\n                <!-- 子分区选择 -->\n                <div class=\"w-2/3 overflow-y-auto bg-transparent\">\n                  <div class=\"grid grid-cols-2 gap-1.5 sm:gap-2 p-1 sm:p-2\">\n                    <!-- 主分区选项 -->\n                    <div\n                      class=\"p-1 sm:p-2 text-xs sm:text-sm border rounded-md cursor-pointer transition-colors duration-200 truncate\"\n                      :class=\"category === videoCategories[activeMainCategory]?.text ? 'border-[#fb7299] bg-[#fb7299]/10 text-[#fb7299]' : 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'\"\n                      @click=\"selectVideoCategory({text: videoCategories[activeMainCategory]?.text, type: 'main'})\"\n                    >\n                      {{ videoCategories[activeMainCategory]?.text || '全部' }}\n                    </div>\n\n                    <!-- 子分区选项 -->\n                    <div\n                      v-for=\"subCategory in videoCategories[activeMainCategory]?.children\"\n                      :key=\"subCategory.id\"\n                      class=\"p-1 sm:p-2 text-xs sm:text-sm border rounded-md cursor-pointer transition-colors duration-200 truncate\"\n                      :class=\"category === subCategory.text ? 'border-[#fb7299] bg-[#fb7299]/10 text-[#fb7299]' : 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'\"\n                      @click=\"selectVideoCategory(subCategory)\"\n                    >\n                      {{ subCategory.text }}\n                    </div>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </VanPopup>\n  </div>\n</template>\n\n<script setup>\nimport { ref, watch, onMounted } from 'vue'\nimport { showNotify, Popup as VanPopup } from 'vant'\nimport 'vant/es/popup/style'\nimport 'vant/es/notify/style'\n\nconst props = defineProps({\n  business: {\n    type: String,\n    default: '',\n  },\n  businessLabel: {\n    type: String,\n    default: '',\n  },\n  date: {\n    type: String,\n    default: '',\n  },\n  category: {\n    type: String,\n    default: '',\n  },\n  total: {\n    type: Number,\n    default: 0,\n  },\n  pageSize: {\n    type: Number,\n    default: 30,\n  },\n})\n\nconst emit = defineEmits([\n  'update:business',\n  'update:businessLabel',\n  'update:date',\n  'update:category',\n  'update:pageSize',\n  'refresh-data',\n])\n\n// 底部弹出筛选栏的显示状态\nconst showFilterPopup = ref(false)\n\n// 供父组件控制的弹窗开关\nconst openFilterPopup = () => {\n  showFilterPopup.value = true\n}\n\nconst closeFilterPopup = () => {\n  showFilterPopup.value = false\n}\n\n// 日期选择相关\nconst startDate = ref('')\nconst endDate = ref('')\n\n// 视频分区选择相关\nconst videoCategories = ref([])\nconst activeMainCategory = ref(0)\n\n// 获取视频分类\nconst fetchVideoCategories = async () => {\n  try {\n    const { getVideoCategories } = await import('../../api/api.js')\n    const response = await getVideoCategories()\n    if (response.data.status === 'success') {\n      videoCategories.value = response.data.data.map((category) => ({\n        text: category.name,\n        type: 'main',\n        children: category.sub_categories.map((sub) => ({\n          text: sub.name,\n          id: sub.tid,\n          type: 'sub',\n        })),\n      }))\n    }\n  } catch (error) {\n    console.error('获取视频分类失败:', error)\n  }\n}\n\n// 选择视频分区\nconst selectVideoCategory = (item) => {\n  const isMainName = videoCategories.value.some(cat =>\n    cat.text === item.text && item.type === 'main',\n  )\n\n  let categoryText = ''\n  if (item.type === 'main' || (item.type === 'sub' && isMainName)) {\n    categoryText = item.text\n  } else if (item.type === 'sub') {\n    categoryText = item.text\n  }\n\n  // 打印日志，帮助调试\n  console.log('选择分区:', {\n    item,\n    categoryText,\n    isMainName,\n  })\n\n  // 先更新分类，然后重置页码\n  emit('update:category', categoryText)\n\n  // 重置页码到第一页，而不是触发实时更新\n  emit('update:page', 1)\n\n  showNotify({\n    type: 'success',\n    message: `已筛选分区: ${categoryText || '全部'}`,\n    duration: 1000,\n  })\n}\n\n// 监听日期属性变化，解析为开始和结束日期\nwatch(() => props.date, (newDate) => {\n  if (newDate) {\n    const dates = newDate.split(' 至 ')\n    if (dates.length === 2) {\n      startDate.value = formatDateForInput(dates[0])\n      endDate.value = formatDateForInput(dates[1])\n    }\n  } else {\n    startDate.value = ''\n    endDate.value = ''\n  }\n}, { immediate: true })\n\n// 格式化日期为输入框格式 (YYYY-MM-DD)\nconst formatDateForInput = (dateStr) => {\n  try {\n    const parts = dateStr.split('/')\n    if (parts.length === 3) {\n      return `${parts[0]}-${parts[1].padStart(2, '0')}-${parts[2].padStart(2, '0')}`\n    }\n    return ''\n  } catch (e) {\n    return ''\n  }\n}\n\n// 格式化日期为显示格式 (YYYY/MM/DD)\nconst formatDateForDisplay = (dateStr) => {\n  try {\n    const date = new Date(dateStr)\n    if (isNaN(date.getTime())) return ''\n    return `${date.getFullYear()}/${(date.getMonth() + 1).toString().padStart(2, '0')}/${date.getDate().toString().padStart(2, '0')}`\n  } catch (e) {\n    console.error('日期格式化错误:', e)\n    return ''\n  }\n}\n\n// 业务类型映射表\nconst businessTypeMap = {\n  '': '全部',\n  'archive': '普通视频',\n  'pgc': '番剧',\n  'live': '直播',\n  'article': '文章',\n  'article-list': '文集',\n}\n\n// 选择业务类型（快速切换区域）\nconst selectBusiness = (type) => {\n  emit('update:business', type)\n  emit('update:businessLabel', businessTypeMap[type])\n  // 移除实时更新触发，改为只更新当前数据\n  emit('update:page', 1) // 重置页码到第一页\n\n  showNotify({\n    type: 'success',\n    message: `已切换到${businessTypeMap[type]}`,\n    duration: 1000,\n  })\n}\n\n// 从弹出窗口选择业务类型\nconst selectBusinessFromPopup = (type) => {\n  emit('update:business', type)\n  emit('update:businessLabel', businessTypeMap[type])\n  // 移除实时更新触发，改为只更新当前数据\n  emit('update:page', 1) // 重置页码到第一页\n\n  showNotify({\n    type: 'success',\n    message: `已切换到${businessTypeMap[type]}`,\n    duration: 1000,\n  })\n}\n\n// 应用日期筛选\nconst applyDateFilter = () => {\n  if (startDate.value && endDate.value) {\n    const formattedStartDate = formatDateForDisplay(startDate.value)\n    const formattedEndDate = formatDateForDisplay(endDate.value)\n\n    if (formattedStartDate && formattedEndDate) {\n      const dateRange = `${formattedStartDate} 至 ${formattedEndDate}`\n      console.log('设置日期区间:', dateRange)\n      emit('update:date', dateRange)\n      emit('update:page', 1) // 重置页码到第一页，而不是触发实时更新\n\n      showNotify({\n        type: 'success',\n        message: `已筛选日期: ${dateRange}`,\n        duration: 1000,\n      })\n    } else {\n      showNotify({\n        type: 'warning',\n        message: '日期格式无效',\n        duration: 2000,\n      })\n\n    }\n  } else if (!startDate.value && !endDate.value) {\n    // 如果两个日期都为空，清除筛选\n    emit('update:date', '')\n    emit('update:page', 1) // 重置页码到第一页，而不是触发实时更新\n  } else {\n    // 如果只有一个日期，显示提示\n    showNotify({\n      type: 'warning',\n      message: '请同时设置开始和结束日期',\n      duration: 2000,\n    })\n\n  }\n}\n\n// 处理日期变化\nconst onDateChange = () => {\n  applyDateFilter()\n}\n\n// 清除分区\nconst clearCategory = () => {\n  console.log('清除分区筛选')\n  // 先更新分类，然后重置页码\n  emit('update:category', '')\n  emit('update:page', 1) // 重置页码到第一页，而不是触发实时更新\n\n  showNotify({\n    type: 'success',\n    message: '已清除分区筛选',\n    duration: 1000,\n  })\n}\n\n// 清除日期\nconst clearDate = () => {\n  console.log('清除日期筛选')\n  // 先更新日期，然后重置页码\n  emit('update:date', '')\n  emit('update:page', 1) // 重置页码到第一页，而不是触发实时更新\n\n  showNotify({\n    type: 'success',\n    message: '已清除日期筛选',\n    duration: 1000,\n  })\n}\n\n// 清除业务类型\nconst clearBusiness = () => {\n  console.log('清除业务类型筛选')\n  // 先更新业务类型，然后重置页码\n  emit('update:business', '')\n  emit('update:businessLabel', '')\n  emit('update:page', 1) // 重置页码到第一页，而不是触发实时更新\n\n  showNotify({\n    type: 'success',\n    message: '已清除业务类型筛选',\n    duration: 1000,\n  })\n}\n\n// 处理每页条数变化\nconst handlePageSizeChange = (event) => {\n  const value = parseInt(event.target.value)\n  if (!isNaN(value) && value >= 10 && value <= 100) {\n    emit('update:pageSize', value)\n  }\n}\n\n// 处理输入框失焦\nconst handlePageSizeBlur = (event) => {\n  let value = parseInt(event.target.value)\n  if (isNaN(value) || value < 10) {\n    value = 10\n  } else if (value > 100) {\n    value = 100\n  }\n  emit('update:pageSize', value)\n  // 不调用 refresh-data，因为 pageSize 的 watch 会自动触发 fetchHistoryByDateRange\n}\n\n// 组件挂载时获取视频分类\nonMounted(() => {\n  fetchVideoCategories()\n})\n\n// 暴露控制方法，便于导航栏触发筛选面板\ndefineExpose({\n  openFilterPopup,\n  closeFilterPopup,\n})\n</script>\n\n<style scoped>\n/* 可以添加自定义样式 */\n\n/* 确保日期输入框在移动设备上正常工作 */\ninput[type=\"date\"] {\n  -webkit-appearance: none;\n  appearance: none;\n  position: relative;\n}\n\ninput[type=\"date\"]::-webkit-calendar-picker-indicator {\n  position: absolute;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  width: 100%;\n  height: 100%;\n  opacity: 0;\n  cursor: pointer;\n}\n</style>\n"
  },
  {
    "path": "src/components/tailwind/HistoryContent.vue",
    "content": "<template>\n  <div class=\"transition-all duration-300 ease-in-out\">\n    <!-- 年度总结横幅 -->\n    <div class=\"mt-1 mb-3 sm:hidden\">\n      <router-link\n        to=\"/analytics\"\n        class=\"flex h-10 items-center justify-between px-2 bg-gradient-to-r from-[#fb7299] to-[#FF9966] text-white\"\n      >\n        <div class=\"flex items-center space-x-2\">\n          <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                  d=\"M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z\" />\n          </svg>\n          <span class=\"text-sm\">点击查看年度总结</span>\n        </div>\n        <svg class=\"w-4 h-4 animate-bounce-x\" 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      </router-link>\n    </div>\n\n    <!-- 加载状态 -->\n    <div v-if=\"isLoading\" class=\"flex flex-col items-center justify-center py-16 bg-white dark:bg-gray-800 rounded-lg\">\n      <div class=\"w-16 h-16 border-4 border-[#fb7299] border-t-transparent rounded-full animate-spin mb-4\"></div>\n      <h3 class=\"text-xl font-medium text-gray-600 mb-2\">加载中</h3>\n      <p class=\"text-gray-500\">正在获取历史记录数据...</p>\n    </div>\n\n    <!-- 登录状态空状态 -->\n    <div v-else-if=\"!isLoggedIn\" class=\"flex flex-col items-center justify-center py-16 bg-white dark:bg-gray-800 rounded-lg shadow-sm\">\n      <svg class=\"w-24 h-24 text-gray-300 mb-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.5\"\n              d=\"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z\" />\n      </svg>\n      <h3 class=\"text-xl font-medium text-gray-600 dark:text-gray-300 mb-2\">请先登录</h3>\n      <p class=\"text-gray-500 dark:text-gray-400 mb-6\">登录B站账号后才能查看您的历史记录</p>\n      <button\n        class=\"px-4 py-2 bg-[#fb7299] text-white rounded-md hover:bg-[#fb7299]/90 transition-colors duration-200 flex items-center space-x-2\"\n        @click=\"openLoginDialog\">\n        <svg class=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n          <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                d=\"M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1\" />\n        </svg>\n        <span>点击登录</span>\n      </button>\n    </div>\n\n    <!-- 数据为空状态 -->\n    <div v-else-if=\"isLoggedIn && records.length === 0\"\n         class=\"flex flex-col items-center justify-center py-16 bg-white dark:bg-gray-800 rounded-lg\">\n      <svg class=\"w-24 h-24 text-gray-300 mb-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.5\"\n              d=\"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z\" />\n      </svg>\n      <h3 class=\"text-xl font-medium text-gray-600 dark:text-gray-300 mb-2\">暂无历史记录</h3>\n      <p class=\"text-gray-500 dark:text-gray-400 mb-6\">点击下方按钮从B站获取您的历史记录</p>\n      <button\n        class=\"px-4 py-2 bg-[#fb7299] text-white rounded-md hover:bg-[#fb7299]/90 transition-colors duration-200 flex items-center space-x-2\"\n        @click=\"refreshData\">\n        <svg class=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n          <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                d=\"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15\" />\n        </svg>\n        <span>获取历史记录</span>\n      </button>\n    </div>\n\n    <!-- 视频记录列表 -->\n    <div v-else class=\"overflow-hidden\">\n      <transition name=\"float\" mode=\"out-in\">\n        <!-- 网格布局（仅PC端） -->\n        <div v-if=\"layout === 'grid'\"\n             class=\"hidden sm:grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 px-4 mx-auto transition-all duration-300 ease-in-out transform-gpu\"\n             style=\"max-width: 1152px;\" key=\"grid-layout\">\n          <template v-for=\"(record, index) in records\" :key=\"`grid-${record.id}-${record.view_at}`\">\n            <!-- 日期分割线和视频数量 -->\n            <div v-if=\"shouldShowDivider(index)\" class=\"col-span-full relative py-1\">\n              <div>\n                <div class=\"relative\">\n                  <div class=\"absolute inset-0 flex items-center\" aria-hidden=\"true\">\n                    <div class=\"w-full border-t border-gray-300 dark:border-gray-600\" />\n                  </div>\n                  <div class=\"relative flex items-center justify-between\">\n                    <div class=\"bg-white dark:bg-gray-900 pr-3 flex items-center space-x-2\">\n                      <!-- 添加当天记录的勾选框 -->\n                      <div v-if=\"isBatchMode\"\n                           class=\"flex items-center justify-center cursor-pointer\"\n                           @click.stop=\"toggleDaySelection(record.view_at)\">\n                        <div class=\"w-5 h-5 rounded border-2 flex items-center justify-center\"\n                             :class=\"isDaySelected(record.view_at) ? 'bg-[#fb7299] border-[#fb7299]' : 'border-gray-300 bg-white'\">\n                          <svg v-if=\"isDaySelected(record.view_at)\" class=\"w-3 h-3 text-white\" fill=\"none\"\n                               viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"3\" d=\"M5 13l4 4L19 7\" />\n                          </svg>\n                        </div>\n                      </div>\n                      <span class=\"lm:text-xs\">\n {{ formatDividerDate(record.view_at) }}\n </span>\n                    </div>\n                    <div class=\"bg-white dark:bg-gray-900 pl-3\">\n <span class=\"lm:text-xs text-[#FF6699]\">\n {{ getDailyStatsForDate(record.view_at) }}条数据 · 总时长 {{ formatDailyWatchTime(record.view_at) }}\n </span>\n                    </div>\n                  </div>\n                </div>\n              </div>\n            </div>\n\n            <!-- 网格布局的视频卡片 -->\n            <div\n              class=\"bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm rounded-lg overflow-hidden border border-gray-200/50 dark:border-gray-700/50 hover:border-[#FF6699] hover:shadow-md transition-all duration-200 relative group\"\n              :class=\"{ 'ring-2 ring-[#fb7299]': selectedRecords.has(`${record.bvid}_${record.view_at}`), 'cursor-pointer': isBatchMode }\"\n              @click=\"isBatchMode ? toggleRecordSelection(record) : null\">\n              <!-- 多选框 -->\n              <div v-if=\"isBatchMode\"\n                   class=\"absolute top-2 left-2 z-10\">\n                <div class=\"w-5 h-5 rounded border-2 flex items-center justify-center\"\n                     :class=\"selectedRecords.has(`${record.bvid}_${record.view_at}`) ? 'bg-[#fb7299] border-[#fb7299]' : 'border-white bg-black/20'\">\n                  <svg v-if=\"selectedRecords.has(`${record.bvid}_${record.view_at}`)\" class=\"w-3 h-3 text-white\"\n                       fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"3\" d=\"M5 13l4 4L19 7\" />\n                  </svg>\n                </div>\n              </div>\n\n              <!-- 封面图片 -->\n              <div class=\"relative aspect-video\" :class=\"{ 'cursor-pointer': !isBatchMode }\"\n                   @click=\"!isBatchMode ? handleVideoClick(record) : null\">\n                <!-- 下载状态标识 -->\n                <div v-if=\"isVideoDownloaded(record.cid) && record.business === 'archive'\"\n                     class=\"absolute left-0 top-0 z-20\">\n                  <div\n                    class=\"bg-gradient-to-r from-green-500 to-green-400 text-white font-semibold px-2 py-0.5 text-xs flex items-center space-x-1.5 rounded-br-md shadow-md\">\n                    <svg class=\"w-3 h-3\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                      <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                            d=\"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4\" />\n                    </svg>\n                    <span>已下载</span>\n                  </div>\n                </div>\n\n                <!-- 收藏状态标识 - 不对直播类型显示 -->\n                <div\n                  v-if=\"isVideoFavorited(parseInt(record.aid || record.avid || (record.business === 'archive' ? record.oid : 0), 10)) && record.business !== 'live'\"\n                  class=\"absolute right-0 top-0 z-20\">\n                  <div\n                    class=\"bg-gradient-to-r from-amber-500 to-yellow-400 text-white font-semibold px-2 py-0.5 text-xs flex items-center space-x-1.5 rounded-bl-md shadow-md\">\n                    <svg class=\"w-3 h-3\" fill=\"currentColor\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                      <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                            d=\"M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z\" />\n                    </svg>\n                    <span>已收藏</span>\n                  </div>\n                </div>\n\n                <!-- 按钮组 -->\n                <div v-if=\"!isBatchMode\"\n                     class=\"absolute right-2 top-2 z-20 hidden group-hover:flex flex-row items-center space-x-2\">\n                  <!-- 收藏按钮 - 不对直播类型显示 -->\n                  <div v-if=\"record.business !== 'live'\"\n                       class=\"flex items-center justify-center w-7 h-7 bg-[#7d7c75]/60 backdrop-blur-sm hover:bg-[#7d7c75]/80 rounded-md cursor-pointer transition-all duration-200\"\n                       @click.stop.prevent=\"handleFavoriteGrid(record)\">\n                    <svg class=\"w-4 h-4\"\n                         :class=\"isVideoFavorited(parseInt(record.aid || record.avid || (record.business === 'archive' ? record.oid : 0), 10)) ? 'text-yellow-400' : 'text-white'\"\n                         :fill=\"isVideoFavorited(parseInt(record.aid || record.avid || (record.business === 'archive' ? record.oid : 0), 10)) ? 'currentColor' : 'none'\"\n                         viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                      <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                            d=\"M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z\" />\n                    </svg>\n                  </div>\n                  <!-- 下载按钮 - 只对视频类型显示 -->\n                  <div v-if=\"record.business === 'archive'\"\n                       class=\"flex items-center justify-center w-7 h-7 bg-[#7d7c75]/60 backdrop-blur-sm hover:bg-[#7d7c75]/80 rounded-md cursor-pointer transition-all duration-200\"\n                       @click.stop.prevent=\"handleDownloadGrid(record)\">\n                    <svg class=\"w-4 h-4 text-white\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                      <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                            d=\"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4\" />\n                    </svg>\n                  </div>\n                  <!-- 详情按钮 - 只对普通视频类型显示 -->\n                  <div v-if=\"record.business === 'archive'\"\n                       class=\"flex items-center justify-center w-7 h-7 bg-[#7d7c75]/60 backdrop-blur-sm hover:bg-[#7d7c75]/80 rounded-md cursor-pointer transition-all duration-200\"\n                       @click.stop=\"openVideoDetail(record)\">\n                    <svg class=\"w-4 h-4 text-white\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                      <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                            d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n                    </svg>\n                  </div>\n                  <!-- 删除按钮 -->\n                  <div\n                    class=\"flex items-center justify-center w-7 h-7 bg-[#7d7c75]/60 backdrop-blur-sm hover:bg-[#7d7c75]/80 rounded-md cursor-pointer transition-all duration-200\"\n                    @click.stop=\"handleDelete(record)\">\n                    <svg class=\"w-4 h-4 text-white\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                      <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                            d=\"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16\" />\n                    </svg>\n                  </div>\n                </div>\n                <img\n                  :src=\"normalizeImageUrl(record.cover || record.covers?.[0])\"\n                  class=\"w-full h-full object-cover transition-all duration-300\"\n                  :class=\"{ 'blur-md': isPrivacyMode }\"\n                  alt=\"\"\n                />\n                <!-- 视频进度条 -->\n                <div\n                  v-if=\"record.business !== 'article-list' && record.business !== 'article' && record.business !== 'live'\"\n                  class=\"absolute bottom-0 left-0 w-full\">\n                  <div\n                    class=\"absolute bottom-1 right-1 rounded bg-black/50 px-1 py-1 text-[10px] font-semibold text-white\">\n                    <span>{{ formatDuration(record.progress) }}</span>\n                    <span>/</span>\n                    <span>{{ formatDuration(record.duration) }}</span>\n                  </div>\n                  <div class=\"absolute bottom-0 left-0 h-0.5 w-full bg-black\">\n                    <div class=\"h-full bg-[#FF6699]\"\n                         :style=\"{ width: getProgressWidth(record.progress, record.duration) }\">\n                    </div>\n                  </div>\n                </div>\n                <!-- 右上角的类型角标 -->\n                <div\n                  v-if=\"record.badge\"\n                  class=\"absolute right-1 top-1 rounded bg-[#FF6699] px-1 py-0.5 text-[10px] text-white\"\n                >\n                  {{ record.badge }}\n                </div>\n              </div>\n              <!-- 视频信息 -->\n              <div class=\"p-3 flex flex-col space-y-1\">\n                <!-- 标题 - 单行显示 -->\n                <div class=\"line-clamp-1 text-sm text-gray-900 dark:text-gray-100\"\n                     v-html=\"isPrivacyMode ? '******' : highlightText(record.title)\"\n                     :class=\"{ 'blur-sm': isPrivacyMode, 'cursor-pointer': !isBatchMode }\"\n                     @click=\"!isBatchMode ? handleVideoClick(record) : null\">\n                </div>\n                <!-- 分区标签 - 单行显示 -->\n                <div class=\"text-xs text-gray-500 dark:text-gray-400 truncate flex items-center space-x-1\">\n <span class=\"inline-flex items-center rounded-md bg-[#f1f2f3] dark:bg-gray-700 px-2 py-1 text-xs text-[#71767d] dark:text-gray-300\">\n {{ record.business === 'archive' ? record.tag_name : getBusinessType(record.business) }}\n </span>\n                  <span v-if=\"record.business === 'archive'\" class=\"text-gray-400\">·</span>\n                  <span v-if=\"record.business === 'archive' && record.name\" class=\"text-[#71767d]\">{{ record.name\n                    }}</span>\n                </div>\n                <!-- UP主和时间信息 - 单行显示 -->\n                <div class=\"flex items-center justify-between text-xs text-gray-600 dark:text-gray-400\">\n                  <div class=\"flex items-center space-x-2 min-w-0 flex-1\">\n                    <img v-if=\"record.business !== 'cheese' && record.business !== 'pgc'\"\n                         :src=\"normalizeImageUrl(record.author_face)\"\n                         :class=\"{ 'blur-md': isPrivacyMode, 'cursor-pointer': !isBatchMode }\"\n                         class=\"w-4 h-4 rounded-full flex-shrink-0\"\n                         @click=\"!isBatchMode ? handleAuthorClick(record) : null\"\n                         :title=\"isPrivacyMode ? '隐私模式已开启' : `访问 ${record.author_name} 的个人空间`\"\n                    />\n                    <span v-html=\"isPrivacyMode ? '******' : highlightText(record.author_name)\"\n                          :class=\"{ 'blur-sm': isPrivacyMode, 'cursor-pointer': !isBatchMode }\"\n                          class=\"hover:text-[#fb7299] transition-colors duration-200 truncate\"\n                          @click=\"!isBatchMode ? handleAuthorClick(record) : null\">\n </span>\n                  </div>\n                  <div class=\"flex items-center space-x-2 flex-shrink-0\">\n                    <img v-if=\"record.dt === 1 || record.dt === 3 || record.dt === 5 || record.dt === 7\"\n                         src=\"/Mobile.svg\"\n                         alt=\"Mobile\"\n                         class=\"h-4 w-4\"\n                    />\n                    <img v-else-if=\"record.dt === 2 || record.dt === 33\"\n                         src=\"/PC.svg\"\n                         alt=\"PC\"\n                         class=\"h-4 w-4\"\n                    />\n                    <img v-else-if=\"record.dt === 4 || record.dt === 6\"\n                         src=\"/Pad.svg\"\n                         alt=\"Pad\"\n                         class=\"h-4 w-4\"\n                    />\n                    <span>{{ formatTimestamp(record.view_at) }}</span>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </template>\n        </div>\n\n        <!-- 列表布局（移动端始终显示，PC端在list模式下显示） -->\n        <div v-else :class=\"{'sm:hidden': layout === 'grid'}\"\n             class=\"transition-all duration-300 ease-in-out transform-gpu\" key=\"list-layout\">\n          <template v-for=\"(record, index) in records\" :key=\"`list-${record.id}-${record.view_at}`\">\n            <!-- 日期分割线和视频数量 -->\n            <div v-if=\"shouldShowDivider(index)\" class=\"relative py-1 max-w-4xl mx-auto\">\n              <div class=\"px-2\">\n                <div class=\"relative\">\n                  <div class=\"absolute inset-0 flex items-center\" aria-hidden=\"true\">\n                    <div class=\"w-full border-t border-gray-300 dark:border-gray-600\" />\n                  </div>\n                  <div class=\"relative flex items-center justify-between\">\n                    <div class=\"bg-white dark:bg-gray-900 pr-3 flex items-center space-x-2\">\n                      <!-- 添加当天记录的勾选框 -->\n                      <div v-if=\"isBatchMode\"\n                           class=\"flex items-center justify-center cursor-pointer\"\n                           @click.stop=\"toggleDaySelection(record.view_at)\">\n                        <div class=\"w-5 h-5 rounded border-2 flex items-center justify-center\"\n                             :class=\"isDaySelected(record.view_at) ? 'bg-[#fb7299] border-[#fb7299]' : 'border-gray-300 bg-white'\">\n                          <svg v-if=\"isDaySelected(record.view_at)\" class=\"w-3 h-3 text-white\" fill=\"none\"\n                               viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"3\" d=\"M5 13l4 4L19 7\" />\n                          </svg>\n                        </div>\n                      </div>\n                      <span class=\"lm:text-xs\">\n {{ formatDividerDate(record.view_at) }}\n </span>\n                    </div>\n                    <div class=\"bg-white dark:bg-gray-900 pl-3\">\n <span class=\"lm:text-xs text-[#FF6699]\">\n {{ getDailyStatsForDate(record.view_at) }}条数据 · 总时长 {{ formatDailyWatchTime(record.view_at) }}\n </span>\n                    </div>\n                  </div>\n                </div>\n              </div>\n            </div>\n            <VideoRecord\n              :record=\"record\"\n              :is-batch-mode=\"isBatchMode\"\n              :is-selected=\"selectedRecords.has(`${record.bvid}_${record.view_at}`)\"\n              :remark-data=\"remarkData\"\n              :is-downloaded=\"isVideoDownloaded(record.cid)\"\n              :is-video-favorited=\"isVideoFavorited(parseInt(record.aid || record.avid || (record.business === 'archive' ? record.oid : 0), 10))\"\n              @toggle-selection=\"toggleRecordSelection\"\n              @refresh-data=\"fetchHistoryByDateRange\"\n              @remark-updated=\"handleRemarkUpdate\"\n              @favorite=\"handleFavorite\"\n            />\n          </template>\n        </div>\n      </transition>\n    </div>\n\n    <!-- 日期选择日历 -->\n    <van-calendar\n      :show-confirm=\"false\"\n      title=\"选择日期区间\"\n      switch-mode=\"year-month\"\n      :show=\"show\"\n      :style=\"{ height: '65%' }\"\n      type=\"range\"\n      @confirm=\"onConfirm\"\n      @update:show=\"(val) => emit('update:show', val)\"\n    />\n\n    <!-- 批量操作按钮区域 -->\n    <div v-if=\"isBatchMode && selectedRecords.size > 0\"\n         class=\"fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50 w-[90vw] max-w-[1200px]\">\n      <div class=\"flex flex-col space-y-2 w-full\">\n        <!-- 删除模式切换按钮 -->\n        <div class=\"flex justify-center mb-1\">\n          <!-- 已移除本地/远程收藏模式切换 -->\n        </div>\n\n        <div class=\"flex flex-wrap gap-3 justify-center w-full mt-2\">\n          <!-- 批量删除按钮 -->\n          <button\n            @click=\"handleBatchDelete\"\n            class=\"w-28 py-2 bg-red-500 text-white rounded-lg shadow-md hover:bg-red-600 transition-all duration-200 flex items-center justify-center space-x-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-opacity-50\"\n          >\n            <svg class=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                    d=\"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16\" />\n            </svg>\n            <span>删除({{ selectedRecords.size }})</span>\n          </button>\n\n          <!-- 批量下载按钮 - 始终显示 -->\n          <button\n            @click=\"handleBatchDownload\"\n            class=\"w-28 py-2 bg-green-500 text-white rounded-lg shadow-md hover:bg-green-600 transition-all duration-200 flex items-center justify-center space-x-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-green-400 focus:ring-opacity-50\"\n          >\n            <svg class=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                    d=\"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4\" />\n            </svg>\n            <span>下载({{ selectedRecords.size }})</span>\n          </button>\n\n          <!-- 批量收藏按钮 - 仅在有未收藏的视频时显示 -->\n          <button\n            v-if=\"!isAllFavorited && unfavoritedCount > 0\"\n            @click=\"handleBatchFavorite\"\n            class=\"w-28 py-2 bg-[#fb7299] text-white rounded-lg shadow-md hover:bg-[#fb7299]/90 transition-all duration-200 flex items-center justify-center space-x-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-pink-400 focus:ring-opacity-50\"\n          >\n            <svg class=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                    d=\"M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z\" />\n            </svg>\n            <span>收藏({{ unfavoritedCount }})</span>\n          </button>\n\n          <!-- 批量取消收藏按钮 - 仅在有已收藏的视频时显示 -->\n          <button\n            v-if=\"hasFavoritedVideos\"\n            @click=\"handleBatchUnfavorite\"\n            class=\"w-28 py-2 bg-orange-500 text-white rounded-lg shadow-md hover:bg-orange-600 transition-all duration-200 flex items-center justify-center space-x-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-orange-400 focus:ring-opacity-50\"\n          >\n            <svg class=\"w-4 h-4\" fill=\"currentColor\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\" />\n            </svg>\n            <span>取消收藏({{ favoritedCount }})</span>\n          </button>\n\n          <!-- 复制链接按钮 - 始终显示 -->\n          <button\n            @click=\"handleCopyLinks\"\n            class=\"w-28 py-2 bg-blue-500 text-white rounded-lg shadow-md hover:bg-blue-600 transition-all duration-200 flex items-center justify-center space-x-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-opacity-50\"\n          >\n            <svg class=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                    d=\"M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3\" />\n            </svg>\n            <span>复制链接({{ selectedRecords.size }})</span>\n          </button>\n        </div>\n      </div>\n    </div>\n  </div>\n\n  <!-- 视频详情对话框 -->\n  <Teleport to=\"body\">\n    <VideoDetailDialog\n      :modelValue=\"showDetailDialog\"\n      @update:modelValue=\"showDetailDialog = $event\"\n      :video=\"selectedRecord\"\n      :remarkData=\"remarkData\"\n      @remark-updated=\"handleRemarkUpdate\"\n    />\n  </Teleport>\n\n  <!-- 下载弹窗 -->\n  <Teleport to=\"body\">\n    <DownloadDialog\n      v-model:show=\"showDownloadDialog\"\n      :video-info=\"{\n title: selectedRecord?.title || '',\n author: selectedRecord?.author_name || '',\n bvid: selectedRecord?.bvid || '',\n cover: selectedRecord?.cover || selectedRecord?.covers?.[0] || '',\n cid: selectedRecord?.cid || ''\n }\"\n      :is-batch-download=\"isBatchDownload\"\n      :batch-videos=\"batchVideos\"\n      v-model:currentVideoIndex=\"currentVideoIndex\"\n      @download-complete=\"handleDownloadComplete\"\n    />\n  </Teleport>\n\n  <!-- 收藏夹选择对话框 -->\n  <Teleport to=\"body\">\n    <FavoriteDialog\n      v-model=\"showFavoriteDialog\"\n      :video-info=\"favoriteVideoInfo\"\n      @favorite-done=\"handleFavoriteDone\"\n    />\n  </Teleport>\n\n  <!-- 登录对话框 -->\n  <Teleport to=\"body\">\n    <LoginDialog\n      v-model:show=\"showLoginDialog\"\n      @login-success=\"handleLoginSuccess\"\n    />\n  </Teleport>\n</template>\n\n<style scoped>\n@keyframes bounce-x {\n  0%, 100% {\n    transform: translateX(0);\n  }\n  50% {\n    transform: translateX(25%);\n  }\n}\n\n.animate-bounce-x {\n  animation: bounce-x 1s infinite;\n}\n\n.float-enter-active,\n.float-leave-active {\n  transition: all 0.3s ease;\n}\n\n.float-enter-from {\n  opacity: 0;\n  transform: translateY(20px);\n}\n\n.float-leave-to {\n  opacity: 0;\n  transform: translateY(-20px);\n}\n</style>\n\n<script setup>\nimport { ref, computed, onMounted, watch } from 'vue'\nimport {\n  getBiliHistory2024,\n  getMainCategories,\n  getDailyStats,\n  batchDeleteHistory,\n  batchGetRemarks,\n  getLoginStatus,\n  updateBiliHistoryRealtime,\n  checkVideoDownload,\n  batchCheckFavoriteStatus,\n  favoriteResource,\n  localBatchFavoriteResource,\n  batchDeleteBilibiliHistory,\n  deleteBilibiliHistory,\n} from '@/api/api.js'\nimport { showNotify, showDialog } from 'vant'\nimport 'vant/es/dialog/style'\nimport VideoRecord from './VideoRecord.vue'\nimport { usePrivacyStore } from '@/store/privacy.js'\nimport VideoDetailDialog from './VideoDetailDialog.vue'\nimport LoginDialog from './LoginDialog.vue'\nimport DownloadDialog from './DownloadDialog.vue'\nimport FavoriteDialog from './FavoriteDialog.vue'\nimport { openInBrowser } from '@/utils/openUrl.js'\nimport { normalizeImageUrl } from '@/utils/imageUrl.js'\n\nconst { isPrivacyMode } = usePrivacyStore()\n\nconst props = defineProps({\n  selectedYear: {\n    type: Number,\n    default: new Date().getFullYear(),\n  },\n  page: {\n    type: Number,\n    default: 1,\n  },\n  show: {\n    type: Boolean,\n    default: false,\n  },\n  showBottom: {\n    type: Boolean,\n    default: false,\n  },\n  layout: {\n    type: String,\n    default: 'list',\n  },\n  searchKeyword: {\n    type: String,\n    default: '',\n  },\n  date: {\n    type: String,\n    default: '',\n  },\n  category: {\n    type: String,\n    default: '',\n  },\n  business: {\n    type: String,\n    default: '',\n  },\n  isBatchMode: {\n    type: Boolean,\n    default: false,\n  },\n  pageSize: {\n    type: Number,\n    default: 30,\n  },\n})\n\nconst emit = defineEmits([\n  'update:total-pages',\n  'update:total',\n  'update:date',\n  'update:category',\n  'update:show',\n  'update:showBottom',\n  'update:pageSize',\n])\n\n// 状态变量\nconst records = ref([])\nconst total = ref(0)\nconst sortOrder = ref(0)\nconst size = ref(props.pageSize)\nconst remarkData = ref({}) // 存储备注数据\nconst downloadedVideos = ref(new Set()) // 存储已下载视频的CID集合\nconst favoriteStatus = ref({}) // 存储视频收藏状态信息\n\nconst date = ref('')\nconst dateRange = ref('')\n\nconst tagName = ref('')\nconst mainCategory = ref('')\nconst mainCategories = ref([])\n\n// 每日统计数据\nconst dailyStats = ref({})\n\n// 批量删除相关\nconst selectedRecords = ref(new Set())\n\n// 在data区域添加\nconst selectedRecord = ref(null)\nconst showDetailDialog = ref(false)\nconst showDownloadDialog = ref(false)\nconst showFavoriteDialog = ref(false)\nconst favoriteVideoInfo = ref(null) // 用于存储收藏相关的视频信息\n\n// 登录相关\nconst isLoggedIn = ref(false)\nconst isLoading = ref(false)\nconst showLoginDialog = ref(false)\n\n// 选择/取消选择记录\nconst toggleRecordSelection = (record) => {\n  const key = `${record.bvid}_${record.view_at}`\n  if (selectedRecords.value.has(key)) {\n    selectedRecords.value.delete(key)\n  } else {\n    selectedRecords.value.add(key)\n  }\n}\n\n// 批量删除选中的记录\nconst handleBatchDelete = async () => {\n  if (selectedRecords.value.size === 0) {\n    showNotify({\n      type: 'warning',\n      message: '请先选择要删除的记录',\n    })\n    return\n  }\n\n  try {\n    // 检查是否需要同步删除B站历史记录\n    const syncDeleteToBilibili = localStorage.getItem('syncDeleteToBilibili') === 'true'\n\n    // 根据是否同步删除B站历史记录，显示不同的确认信息\n    await showDialog({\n      title: '确认删除',\n      message: syncDeleteToBilibili\n        ? `确定要删除选中的 ${selectedRecords.value.size} 条记录吗？此操作将同时删除B站服务器上的历史记录，不可恢复。`\n        : `确定要删除选中的 ${selectedRecords.value.size} 条记录吗？此操作不可恢复。`,\n      showCancelButton: true,\n      confirmButtonText: '确认删除',\n      cancelButtonText: '取消',\n      confirmButtonColor: '#fb7299',\n    })\n\n    // 从记录中找到对应的完整信息\n    const items = Array.from(selectedRecords.value).map(key => {\n      const [bvid, view_at] = key.split('_')\n      return {\n        bvid,\n        view_at: parseInt(view_at),\n      }\n    })\n\n    if (syncDeleteToBilibili) {\n      // 构建B站历史记录删除请求的items\n      const biliItems = items.map(item => {\n        // 查找对应的完整记录以获取业务类型\n        const record = records.value.find(r => r.bvid === item.bvid && r.view_at === item.view_at)\n        if (!record) return null\n\n        // 根据业务类型构建kid\n        const business = record.business || 'archive'\n        let kid\n\n        switch (business) {\n          case 'archive':\n            // 使用oid而不是bvid\n            kid = `${business}_${record.oid}`\n            break\n          case 'live':\n            kid = `${business}_${record.oid}`\n            break\n          case 'article':\n            kid = `${business}_${record.oid}`\n            break\n          case 'pgc':\n            kid = `${business}_${record.oid || record.ssid}`\n            break\n          case 'article-list':\n            kid = `${business}_${record.oid || record.rlid}`\n            break\n          default:\n            kid = `${business}_${record.oid || record.bvid}`\n            break\n        }\n\n        if (!kid) {\n          return null\n        }\n\n        return {\n          kid,\n          sync_to_bilibili: true,\n        }\n      }).filter(item => item !== null)\n\n      if (biliItems.length > 0) {\n        try {\n          // 调用B站历史记录删除API\n          const biliResponse = await batchDeleteBilibiliHistory(biliItems)\n          if (biliResponse.data.status === 'success' || biliResponse.data.status === 'partial_success') {\n            console.log('B站历史记录删除成功或部分成功:', biliResponse.data)\n          } else {\n            console.error('B站历史记录删除失败:', biliResponse.data)\n            throw new Error(biliResponse.data.message || '删除B站历史记录失败')\n          }\n        } catch (error) {\n          console.error('B站历史记录删除失败:', error)\n          // 即使B站删除失败，也继续删除本地记录\n        }\n      }\n    }\n\n    // 删除本地记录\n    const response = await batchDeleteHistory(items)\n    if (response.data.status === 'success') {\n      showNotify({\n        type: 'success',\n        message: response.data.message + (syncDeleteToBilibili ? '，并已同步删除B站历史记录' : ''),\n      })\n      selectedRecords.value.clear()\n      await fetchHistoryByDateRange()\n    } else {\n      throw new Error(response.data.message || '删除失败')\n    }\n  } catch (error) {\n    if (error.toString().includes('cancel')) return\n\n    showNotify({\n      type: 'danger',\n      message: error.response?.data?.detail || error.message || '删除失败',\n    })\n  }\n}\n\n// 监听批量模式变化\nwatch(() => props.isBatchMode, (newVal) => {\n  if (!newVal) {\n    selectedRecords.value.clear()\n  }\n})\n\n// 计算属性用于显示当前选中的分类\ncomputed(() => {\n  return mainCategory.value || tagName.value || '全部分区'\n})\n// 获取主分区列表\nconst fetchMainCategories = async () => {\n  try {\n    const response = await getMainCategories()\n    if (response.data.status === 'success') {\n      mainCategories.value = response.data.data.map((cat) => cat.name)\n    }\n  } catch (error) {\n    console.error('Error fetching main categories:', error)\n  }\n}\n\n// 辅助函数：格式化日期\nconst formatDate = (date) => `${date.getMonth() + 1}/${date.getDate()}`\nconst formatDateWithYear = (date) => `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`\nconst formatDateForAPI = (date) => {\n  const year = date.getFullYear()\n  const month = String(date.getMonth() + 1).padStart(2, '0')\n  const day = String(date.getDate()).padStart(2, '0')\n  return `${year}${month}${day}`\n}\n\n// 处理日期区间确认\nconst onConfirm = (values) => {\n  const [start, end] = values\n  emit('update:show', false)\n\n  const startYear = start.getFullYear()\n  const endYear = end.getFullYear()\n\n  let dateText\n  if (startYear === props.selectedYear && endYear === props.selectedYear) {\n    dateText = `${formatDate(start)} - ${formatDate(end)}`\n  } else {\n    dateText = `${formatDateWithYear(start)} - ${formatDateWithYear(end)}`\n  }\n\n  emit('update:date', dateText)\n  dateRange.value = `${formatDateForAPI(start)}-${formatDateForAPI(end)}`\n  fetchHistoryByDateRange()\n}\n\n// 批量检查视频下载状态\nconst batchCheckDownloadStatus = async () => {\n  try {\n    if (records.value.length === 0) return\n\n    // 筛选出视频类型的记录\n    const videoRecords = records.value.filter(record => record.business === 'archive')\n    if (videoRecords.length === 0) return\n\n    // 获取所有视频的CID\n    const cids = videoRecords.map(record => record.cid).filter(cid => cid)\n    if (cids.length === 0) return\n\n    const response = await checkVideoDownload(cids)\n\n    if (response.data && response.data.status === 'success') {\n      // 清空已有集合\n      downloadedVideos.value.clear()\n\n      // 处理返回结果，将已下载视频的CID添加到集合中\n      const results = response.data.results || {}\n\n      // 遍历results对象的每个键值对\n      Object.entries(results).forEach(([cid, info]) => {\n        if (info.downloaded) {\n          downloadedVideos.value.add(cid.toString())\n        }\n      })\n    }\n  } catch (error) {\n    console.error('批量检查下载状态失败:', error)\n  }\n}\n\n// 检查视频是否已下载\nconst isVideoDownloaded = (cid) => {\n  return cid && downloadedVideos.value.has(cid.toString())\n}\n\n// 检查视频是否已收藏\nconst isVideoFavorited = (oid) => {\n  if (!oid) return false\n\n  // 确保oid是字符串类型，方便比较\n  const oidStr = String(oid)\n\n  // 检查是否在收藏状态中\n  return Object.keys(favoriteStatus.value).some(key => {\n    return String(key) === oidStr && favoriteStatus.value[key].is_favorited\n  })\n}\n\n// 获取视频被收藏到的收藏夹\nconst getVideoFavoriteFolders = (oid) => {\n  if (!oid) return []\n\n  // 确保oid是字符串类型，方便比较\n  const oidStr = String(oid)\n\n  // 查找匹配的收藏状态\n  for (const key in favoriteStatus.value) {\n    if (String(key) === oidStr) {\n      return favoriteStatus.value[key].favorite_folders || []\n    }\n  }\n\n  return []\n}\n\n// 批量检查视频收藏状态\nconst batchCheckFavorites = async () => {\n  try {\n    if (records.value.length === 0) return\n\n    // 筛选出视频类型的记录\n    const videoRecords = records.value.filter(record => record.business === 'archive')\n    if (videoRecords.length === 0) return\n\n    // 获取所有视频的avid\n    const oids = videoRecords.map(record => {\n      // 使用 aid 或 avid 或 (oid 如果 business 是 archive)\n      const id = record.aid || record.avid || (record.business === 'archive' ? record.oid : null)\n      // 确保ID是数字类型\n      return id ? parseInt(id, 10) : null\n    }).filter(oid => oid !== null && !isNaN(oid))\n\n    if (oids.length === 0) return\n\n    console.log('批量检查视频收藏状态:', oids)\n    const response = await batchCheckFavoriteStatus({ oids }) // 直接传递数组\n\n    if (response.data && response.data.status === 'success') {\n      // 清空已有状态\n      favoriteStatus.value = {}\n\n      // 处理返回结果\n      const results = response.data.data.results || []\n      results.forEach(item => {\n        favoriteStatus.value[item.oid] = {\n          is_favorited: item.is_favorited,\n          favorite_folders: item.favorite_folders || [],\n        }\n      })\n\n      console.log('收藏状态数据:', favoriteStatus.value)\n    }\n  } catch (error) {\n    console.error('批量检查收藏状态失败:', error)\n  }\n}\n\n// 数据获取函数\nconst fetchHistoryByDateRange = async () => {\n\n  try {\n    isLoading.value = true\n    records.value = []\n    const response = await getBiliHistory2024(\n      props.page,\n      size.value,\n      sortOrder.value,\n      tagName.value,\n      mainCategory.value,\n      dateRange.value || '',\n      localStorage.getItem('useLocalImages') === 'true',\n      props.business,\n    )\n\n    if (response.data && response.data.data) {\n      total.value = response.data.data.total\n      records.value = response.data.data.records\n      emit('update:total-pages', Math.ceil(response.data.data.total / size.value))\n      emit('update:total', response.data.data.total)\n\n      // 批量获取备注\n      if (records.value.length > 0) {\n        const batchRecords = records.value.map(record => ({\n          bvid: record.bvid,\n          view_at: record.view_at,\n        }))\n        const remarksResponse = await batchGetRemarks(batchRecords)\n        if (remarksResponse.data.status === 'success') {\n          remarkData.value = remarksResponse.data.data\n        }\n\n        // 批量检查下载状态\n        await batchCheckDownloadStatus()\n\n        // 批量检查收藏状态\n        await batchCheckFavorites()\n      }\n    }\n  } catch (error) {\n    console.error('数据获取失败:', error)\n    records.value = []\n    total.value = 0\n    emit('update:total-pages', 0)\n    emit('update:total', 0)\n  } finally {\n    isLoading.value = false\n  }\n}\n\n// 监听年份和页码变化\nwatch(\n  () => [props.selectedYear, props.page],\n  () => {\n    fetchHistoryByDateRange()\n  },\n)\n\n// 监听 pageSize 变化\nwatch(\n  () => props.pageSize,\n  (newSize) => {\n    size.value = newSize\n    // 保存到 localStorage\n    localStorage.setItem('pageSize', newSize.toString())\n    fetchHistoryByDateRange()\n  },\n)\n\n// 监听父组件的 date 变化\nwatch(\n  () => props.date,\n  (newDate) => {\n    if (!newDate) {\n      dateRange.value = ''\n      fetchHistoryByDateRange()\n    } else {\n      // 解析日期区间格式 \"YYYY/MM/DD 至 YYYY/MM/DD\"\n      const dates = newDate.split(' 至 ')\n      if (dates.length === 2) {\n        const startParts = dates[0].split('/')\n        const endParts = dates[1].split('/')\n\n        if (startParts.length === 3 && endParts.length === 3) {\n          const startDate = `${startParts[0]}${startParts[1].padStart(2, '0')}${startParts[2].padStart(2, '0')}`\n          const endDate = `${endParts[0]}${endParts[1].padStart(2, '0')}${endParts[2].padStart(2, '0')}`\n          dateRange.value = `${startDate}-${endDate}`\n          fetchHistoryByDateRange()\n        }\n      }\n    }\n  },\n)\n\n// 监听父组件的 category 变化\nwatch(\n  () => props.category,\n  (newCategory) => {\n    if (!newCategory) {\n      tagName.value = ''\n      mainCategory.value = ''\n    } else {\n      // 根据分区名称判断是主分区还是子分区\n      // 由于我们无法确定是主分区还是子分区，所以统一赋值给mainCategory\n      mainCategory.value = newCategory\n      tagName.value = ''\n    }\n    // 这里会触发数据获取\n    fetchHistoryByDateRange()\n  },\n)\n\n// 监听父组件的 business 变化\nwatch(\n  () => props.business,\n  () => {\n    fetchHistoryByDateRange()\n  },\n)\n\n// 获取每日统计数据\nconst fetchDailyStats = async (timestamp) => {\n  if (!timestamp) return\n\n  try {\n    const date = new Date(timestamp * 1000)\n    const year = date.getFullYear()\n    const month = String(date.getMonth() + 1).padStart(2, '0')\n    const day = String(date.getDate()).padStart(2, '0')\n    const dateStr = `${month}${day}`\n\n    // 如果已经有这个日期的数据，就不重复获取\n    const dateKey = `${year}-${month}-${day}`\n    if (dailyStats.value[dateKey]) return\n\n    const response = await getDailyStats(dateStr, year)\n    if (response.data.status === 'success') {\n      dailyStats.value[dateKey] = response.data.data\n    }\n  } catch (error) {\n    console.error('获取每日统计数据失败:', error)\n  }\n}\n\n// 监听记录变化，获取所有不同日期的统计数据\nwatch(() => records.value, (newRecords) => {\n  if (newRecords && newRecords.length > 0) {\n    // 获取所有不同日期的时间戳\n    const uniqueDates = new Set()\n    newRecords.forEach(record => {\n      const date = new Date(record.view_at * 1000)\n      const dateKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`\n      if (!uniqueDates.has(dateKey)) {\n        uniqueDates.add(dateKey)\n        fetchDailyStats(record.view_at)\n      }\n    })\n  } else {\n    dailyStats.value = {}\n  }\n}, { deep: true })\n\n// 获取指定日期的统计数据\nconst getDailyStatsForDate = (timestamp) => {\n  const date = new Date(timestamp * 1000)\n  const month = String(date.getMonth() + 1).padStart(2, '0')\n  const day = String(date.getDate()).padStart(2, '0')\n  const dateKey = `${date.getFullYear()}-${month}-${day}`\n  return dailyStats.value[dateKey]?.total_count || 0\n}\n\n// 获取指定日期的总观看时长（秒）\nconst getDailyWatchSecondsForDate = (timestamp) => {\n  const date = new Date(timestamp * 1000)\n  const month = String(date.getMonth() + 1).padStart(2, '0')\n  const day = String(date.getDate()).padStart(2, '0')\n  const dateKey = `${date.getFullYear()}-${month}-${day}`\n  return dailyStats.value[dateKey]?.total_watch_seconds || 0\n}\n\n// 将秒格式化为中文时长（如 1小时23分45秒）\nconst formatHMS = (seconds) => {\n  const total = Math.max(0, Math.floor(seconds || 0))\n  const h = Math.floor(total / 3600)\n  const m = Math.floor((total % 3600) / 60)\n  const s = total % 60\n  if (h > 0) {\n    return `${h}小时${String(m).padStart(2, '0')}分${String(s).padStart(2, '0')}秒`\n  }\n  if (m > 0) {\n    return `${m}分${String(s).padStart(2, '0')}秒`\n  }\n  return `${s}秒`\n}\n\n// 获取指定日期的总时长（格式化）\nconst formatDailyWatchTime = (timestamp) => {\n  return formatHMS(getDailyWatchSecondsForDate(timestamp))\n}\n\n// 打开登录对话框\nconst openLoginDialog = () => {\n  showLoginDialog.value = true\n}\n\n// 处理登录成功\nconst handleLoginSuccess = async (userData) => {\n  try {\n    // 如果登录对话框传递了用户数据，直接使用\n    if (userData && userData.isLogin) {\n      isLoggedIn.value = userData.isLogin\n\n      // 触发全局事件，通知侧边栏更新登录状态，并传递用户信息\n      window.dispatchEvent(new CustomEvent('login-status-changed', {\n        detail: {\n          isLoggedIn: true,\n          userInfo: userData,\n        },\n      }))\n    } else {\n      // 如果没有传递用户数据，则调用API获取\n      const response = await getLoginStatus()\n      if (response.data && response.data.code === 0) {\n        isLoggedIn.value = response.data.data.isLogin\n\n        // 触发全局事件，通知侧边栏更新登录状态，并传递用户信息\n        window.dispatchEvent(new CustomEvent('login-status-changed', {\n          detail: {\n            isLoggedIn: true,\n            userInfo: response.data.data,\n          },\n        }))\n      }\n    }\n\n    // 刷新历史记录数据\n    if (isLoggedIn.value) {\n      fetchHistoryByDateRange()\n    }\n  } catch (error) {\n    console.error('登录成功后获取状态失败:', error)\n  }\n}\n\n// 检查登录状态\nconst checkLoginStatus = async () => {\n  try {\n    const response = await getLoginStatus()\n    isLoggedIn.value = response.data && response.data.code === 0 && response.data.data.isLogin\n  } catch (error) {\n    console.error('获取登录状态失败:', error)\n    isLoggedIn.value = false\n  }\n}\n\n// 刷新数据\nconst refreshData = async () => {\n  try {\n    isLoading.value = true\n    showNotify({ type: 'success', message: '正在从B站获取历史记录...' })\n\n    const syncDeleted = localStorage.getItem('syncDeleted') === 'true'\n    const response = await updateBiliHistoryRealtime(syncDeleted)\n    if (response.data.status === 'success') {\n      showNotify({ type: 'success', message: response.data.message || '数据获取成功' })\n      fetchHistoryByDateRange()\n    } else {\n      throw new Error(response.data.message || '获取失败')\n    }\n  } catch (error) {\n    console.error('刷新数据失败:', error)\n    showNotify({\n      type: 'danger',\n      message: error.response?.data?.message || error.message || '获取历史记录失败',\n    })\n  } finally {\n    isLoading.value = false\n  }\n}\n\nonMounted(async () => {\n  isLoading.value = true\n  try {\n    await checkLoginStatus()\n    await fetchMainCategories()\n    if (isLoggedIn.value) {\n      await fetchHistoryByDateRange()\n    } else {\n      isLoading.value = false\n    }\n  } catch (error) {\n    console.error('初始化失败:', error)\n    isLoading.value = false\n  }\n})\n\n// 暴露方法给父组件\ndefineExpose({\n  fetchHistoryByDateRange,\n  refreshData,\n  checkLoginStatus,\n})\n\n// 格式化分割线日期\nconst formatDividerDate = (timestamp) => {\n  const date = new Date(timestamp * 1000)\n  const currentYear = new Date().getFullYear()\n  const year = date.getFullYear()\n  const month = date.getMonth() + 1\n  const day = date.getDate()\n\n  if (year === currentYear) {\n    return `${month}月${day}日`\n  } else {\n    return `${year}年${month}月${day}日`\n  }\n}\n\n// 判断是否需要显示分割线\nconst shouldShowDivider = (index) => {\n  if (index === 0) return true\n\n  const currentDate = new Date(records.value[index].view_at * 1000)\n  const prevDate = new Date(records.value[index - 1].view_at * 1000)\n\n  return currentDate.getDate() !== prevDate.getDate() ||\n    currentDate.getMonth() !== prevDate.getMonth() ||\n    currentDate.getFullYear() !== prevDate.getFullYear()\n}\n\n// 处理视频点击\nconst handleVideoClick = async (record) => {\n  let url = ''\n\n  switch (record.business) {\n    case 'archive':\n      url = `https://www.bilibili.com/video/${record.bvid}`\n      break\n    case 'article':\n      url = `https://www.bilibili.com/read/cv${record.oid}`\n      break\n    case 'article-list':\n      url = `https://www.bilibili.com/read/readlist/rl${record.oid}`\n      break\n    case 'live':\n      url = `https://live.bilibili.com/${record.oid}`\n      break\n    case 'pgc':\n      url = record.uri || `https://www.bilibili.com/bangumi/play/ep${record.epid}`\n      break\n    case 'cheese':\n      url = record.uri || `https://www.bilibili.com/cheese/play/ep${record.epid}`\n      break\n    default:\n      console.warn('未知的业务类型:', record.business)\n      return\n  }\n\n  if (url) {\n    await openInBrowser(url)\n  }\n}\n\n// 处理UP主点击\nconst handleAuthorClick = async (record) => {\n  const url = `https://space.bilibili.com/${record.author_mid}`\n  await openInBrowser(url)\n}\n\n// 格式化时长\nconst formatDuration = (seconds) => {\n  if (seconds === -1) return '已看完'\n  const minutes = String(Math.floor(seconds / 60)).padStart(2, '0')\n  const secs = String(seconds % 60).padStart(2, '0')\n  return `${minutes}:${secs}`\n}\n\n// 获取业务类型\nconst getBusinessType = (business) => {\n  const businessTypes = {\n    archive: '稿件',\n    cheese: '课堂',\n    pgc: '电影',\n    live: '直播',\n    'article-list': '专栏',\n    article: '专栏',\n  }\n  return businessTypes[business] || '其他类型'\n}\n\n// 获取进度条宽度\nconst getProgressWidth = (progress, duration) => {\n  if (progress === -1) return '100%'\n  if (duration === 0) return '0%'\n  return `${(progress / duration) * 100}%`\n}\n\n// 格式化时间戳\nconst formatTimestamp = (timestamp) => {\n  if (!timestamp) {\n    console.warn('Invalid timestamp:', timestamp)\n    return '时间未知'\n  }\n\n  try {\n    const date = new Date(timestamp * 1000)\n    const now = new Date()\n\n    if (isNaN(date.getTime())) {\n      console.warn('Invalid date from timestamp:', timestamp)\n      return '时间未知'\n    }\n\n    const isToday = now.toDateString() === date.toDateString()\n    const isYesterday =\n      new Date(now.setDate(now.getDate() - 1)).toDateString() === date.toDateString()\n    const timeString = date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })\n\n    if (isToday) {\n      return timeString\n    } else if (isYesterday) {\n      return `昨天 ${timeString}`\n    } else if (now.getFullYear() === date.getFullYear()) {\n      return `${date.getMonth() + 1}-${date.getDate()} ${timeString}`\n    } else {\n      return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()} ${timeString}`\n    }\n  } catch (error) {\n    console.error('Error formatting timestamp:', error)\n    return '时间未知'\n  }\n}\n\n// 高亮显示匹配的文本\nconst highlightText = (text) => {\n  if (!props.searchKeyword || !text) return text\n\n  const regex = new RegExp(props.searchKeyword, 'gi')\n  return text.replace(regex, match => `<span class=\"text-[#FF6699]\">${match}</span>`)\n}\n\n// 处理删除记录\nconst handleDelete = async (record) => {\n  try {\n    // 检查是否需要同步删除B站历史记录\n    const syncDeleteToBilibili = localStorage.getItem('syncDeleteToBilibili') === 'true'\n\n    await showDialog({\n      title: '确认删除',\n      message: syncDeleteToBilibili\n        ? '确定要删除这条记录吗？此操作将同时删除B站服务器上的历史记录，不可恢复。'\n        : '确定要删除这条记录吗？此操作不可恢复。',\n      showCancelButton: true,\n      confirmButtonText: '确认删除',\n      cancelButtonText: '取消',\n      confirmButtonColor: '#fb7299',\n    })\n\n    // 如果需要同步删除B站历史记录\n    if (syncDeleteToBilibili) {\n      // 构建B站历史记录删除请求\n      const business = record.business || 'archive'\n      let kid = ''\n\n      switch (business) {\n        case 'archive':\n          // 使用oid而不是bvid\n          kid = `${business}_${record.oid}`\n          break\n        case 'live':\n          kid = `${business}_${record.oid}`\n          break\n        case 'article':\n          kid = `${business}_${record.oid}`\n          break\n        case 'pgc':\n          kid = `${business}_${record.oid || record.ssid}`\n          break\n        case 'article-list':\n          kid = `${business}_${record.oid || record.rlid}`\n          break\n        default:\n          kid = `${business}_${record.oid || record.bvid}`\n          break\n      }\n\n      if (kid) {\n        try {\n          // 调用B站历史记录删除API\n          const biliResponse = await deleteBilibiliHistory(kid, true)\n          if (biliResponse.data.status === 'success') {\n            console.log('B站历史记录删除成功:', biliResponse.data)\n          } else {\n            console.error('B站历史记录删除失败:', biliResponse.data)\n            // 即使B站删除失败，也继续删除本地记录\n          }\n        } catch (error) {\n          console.error('B站历史记录删除失败:', error)\n          // 即使B站删除失败，也继续删除本地记录\n        }\n      }\n    }\n\n    const response = await batchDeleteHistory([{\n      bvid: record.bvid,\n      view_at: record.view_at,\n    }])\n    if (response.data.status === 'success') {\n      showNotify({\n        type: 'success',\n        message: response.data.message + (syncDeleteToBilibili ? '，并已同步删除B站历史记录' : ''),\n      })\n      fetchHistoryByDateRange()\n    } else {\n      throw new Error(response.data.message || '删除失败')\n    }\n  } catch (error) {\n    if (error.toString().includes('cancel')) return\n\n    showNotify({\n      type: 'danger',\n      message: error.response?.data?.detail || error.message || '删除失败',\n    })\n  }\n}\n\n// 判断某一天是否被全部选中\nconst isDaySelected = (timestamp) => {\n  const date = new Date(timestamp * 1000)\n  const dayStart = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000\n  const dayEnd = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1).getTime() / 1000 - 1\n\n  const dayRecords = records.value.filter(record =>\n    record.view_at >= dayStart && record.view_at <= dayEnd,\n  )\n\n  return dayRecords.every(record =>\n    selectedRecords.value.has(`${record.bvid}_${record.view_at}`),\n  )\n}\n\n// 切换某一天的所有记录的选中状态\nconst toggleDaySelection = (timestamp) => {\n  const date = new Date(timestamp * 1000)\n  const dayStart = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000\n  const dayEnd = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1).getTime() / 1000 - 1\n\n  const dayRecords = records.value.filter(record =>\n    record.view_at >= dayStart && record.view_at <= dayEnd,\n  )\n\n  const allSelected = isDaySelected(timestamp)\n\n  dayRecords.forEach(record => {\n    const key = `${record.bvid}_${record.view_at}`\n    if (allSelected) {\n      selectedRecords.value.delete(key)\n    } else {\n      selectedRecords.value.add(key)\n    }\n  })\n}\n\n// 处理备注更新\nconst handleRemarkUpdate = (data) => {\n  const key = `${data.bvid}_${data.view_at}`\n  remarkData.value[key] = {\n    bvid: data.bvid,\n    view_at: data.view_at,\n    remark: data.remark,\n    remark_time: data.remark_time,\n  }\n}\n\n// 添加打开详情对话框的方法\nconst openVideoDetail = (record) => {\n  selectedRecord.value = record\n  showDetailDialog.value = true\n}\n\n// 处理网格视图下载按钮点击\nconst handleDownloadGrid = (record) => {\n  console.log('handleDownloadGrid - 处理网格视图下载按钮点击')\n  selectedRecord.value = record\n  // 确保设置为单个视频下载模式，而非批量下载\n  isBatchDownload.value = false\n  showDownloadDialog.value = true\n}\n\n// 批量下载视频列表\nconst batchVideos = ref([])\nconst isBatchDownload = ref(false)\nconst currentVideoIndex = ref(0)\n\n// 批量下载处理函数\nconst handleBatchDownload = async () => {\n  if (selectedRecords.value.size === 0) {\n    showNotify({\n      type: 'warning',\n      message: '请先选择要下载的记录',\n    })\n    return\n  }\n\n  try {\n    // 从选中的记录中提取视频信息\n    const videoRecords = [...selectedRecords.value].map(key => {\n      const [bvid, timestamp] = key.split('_')\n      return records.value.find(r => r.bvid === bvid && String(r.view_at) === timestamp)\n    }).filter(record => record && record.business === 'archive') // 过滤掉未找到的记录和非视频记录\n\n    if (videoRecords.length === 0) {\n      showNotify({\n        type: 'warning',\n        message: '选中的记录中没有有效的视频',\n      })\n      return\n    }\n\n    // 准备批量下载的视频列表\n    batchVideos.value = videoRecords.map(record => ({\n      bvid: record.bvid,\n      cid: record.cid,\n      title: record.title,\n      author: record.author_name,\n      cover: record.cover,\n    }))\n\n    // 设置批量下载模式\n    isBatchDownload.value = true\n    currentVideoIndex.value = 0\n\n    // 显示下载对话框\n    if (batchVideos.value.length > 0) {\n      // 设置第一个视频作为当前下载视频\n      const firstVideo = batchVideos.value[0]\n      selectedRecord.value = {\n        title: firstVideo.title,\n        author_name: firstVideo.author,\n        bvid: firstVideo.bvid,\n        cover: firstVideo.cover,\n        cid: firstVideo.cid,\n      }\n      showDownloadDialog.value = true\n    }\n  } catch (error) {\n    console.error('批量下载准备失败:', error)\n    showNotify({\n      type: 'danger',\n      message: '批量下载准备失败: ' + (error.message || '未知错误'),\n    })\n  }\n}\n\n// 处理下载完成\nconst handleDownloadComplete = async () => {\n  // 下载完成后重新检查下载状态\n  await batchCheckDownloadStatus()\n}\n\n// 调试函数，在控制台显示所有视频的CID和下载状态\n// 处理收藏按钮点击（网格布局）\nconst handleFavoriteGrid = (record) => {\n  // 获取视频ID，适配不同的属性名（aid或avid）\n  let videoId = record.aid || record.avid || (record.business === 'archive' ? record.oid : null)\n  if (videoId) {\n    videoId = parseInt(videoId, 10)\n  }\n  if (!videoId || isNaN(videoId)) {\n    showNotify({ type: 'warning', message: '无法识别视频ID' })\n    return\n  }\n\n  // 检查是否已收藏\n  if (isVideoFavorited(videoId)) {\n    // 如果已收藏，提示是否取消收藏\n    showDialog({\n      title: '取消收藏',\n      message: '确定要取消收藏该视频吗？',\n      showCancelButton: true,\n    }).then(async () => {\n      // 获取视频的收藏夹列表\n      const folders = getVideoFavoriteFolders(videoId)\n      if (folders.length > 0) {\n        // 获取收藏夹ID\n        const folderIds = folders.map(folder => folder.media_id)\n        try {\n          // 发送取消收藏请求\n          const response = await favoriteResource({\n            rid: videoId,\n            del_media_ids: folderIds.join(','),\n          })\n\n          // 如果远程操作成功，同步本地数据库（不提示用户）\n          if (response.data.status === 'success') {\n            try {\n              await localBatchFavoriteResource({\n                rids: videoId.toString(),\n                del_media_ids: folderIds.join(','),\n                operation_type: 'local', // 只在本地操作\n              })\n            } catch (syncError) {\n              console.error('本地同步取消收藏失败，但不影响用户体验:', syncError)\n            }\n\n            // 更新收藏状态\n            favoriteStatus.value[videoId] = {\n              is_favorited: false,\n              favorite_folders: [],\n            }\n\n            showNotify({ type: 'success', message: '已取消收藏' })\n\n            // 刷新收藏状态\n            await batchCheckFavorites()\n          } else {\n            throw new Error(response.data.message || '取消收藏失败')\n          }\n        } catch (error) {\n          console.error('取消收藏失败:', error)\n          showNotify({ type: 'danger', message: '取消收藏失败: ' + (error.message || '未知错误') })\n        }\n      }\n    }).catch(() => {\n      // 取消操作，不做任何处理\n    })\n  } else {\n    // 如果未收藏，打开收藏夹选择对话框\n    showFavoriteDialog.value = true\n    favoriteVideoInfo.value = record\n  }\n}\n\n// 处理收藏夹选择对话框\nconst handleFavoriteDone = async (result) => {\n  console.log('handleFavoriteDone - 处理收藏完成', result)\n  if (result && result.success) {\n    if (result.isBatch) {\n      // 批量收藏完成\n      showNotify({ type: 'success', message: `批量收藏完成，已添加${result.videoInfo.selectedCount}个视频到收藏夹` })\n\n      // 更新收藏状态\n      if (result.videoInfo.batchIds) {\n        const videoIds = result.videoInfo.batchIds.split(',').map(id => parseInt(id.trim(), 10)).filter(id => !isNaN(id))\n\n        // 为每个视频ID更新收藏状态\n        videoIds.forEach(videoId => {\n          favoriteStatus.value[videoId] = {\n            is_favorited: true,\n            favorite_folders: result.folders.map(folderId => ({\n              media_id: folderId,\n              title: '收藏夹',\n            })),\n          }\n        })\n\n        // 取消批量模式并清空选择\n        selectedRecords.value.clear()\n\n        // 刷新收藏状态\n        await batchCheckFavorites()\n      }\n    } else {\n      // 单个视频收藏完成\n      showNotify({ type: 'success', message: '收藏成功' })\n\n      // 更新收藏状态\n      let videoId = result.videoInfo.aid || result.videoInfo.avid ||\n        (result.videoInfo.business === 'archive' ? result.videoInfo.oid : null)\n\n      if (videoId) {\n        // 确保ID是整数\n        videoId = parseInt(videoId, 10)\n\n        if (!isNaN(videoId)) {\n          // 设置为已收藏状态\n          favoriteStatus.value[videoId] = {\n            is_favorited: true,\n            favorite_folders: result.folders.map(folderId => ({\n              media_id: folderId,\n              title: '收藏夹', // 由于API返回的是ID列表，我们不知道具体名称，所以用通用名称\n            })),\n          }\n\n          // 重新获取精确的收藏夹信息\n          await batchCheckFavorites()\n        }\n      }\n    }\n  }\n}\n\n// 批量收藏选中的记录\nconst handleBatchFavorite = async () => {\n  if (selectedRecords.value.size === 0) {\n    showNotify({\n      type: 'warning',\n      message: '请先选择要收藏的记录',\n    })\n    return\n  }\n\n  // 从选中的记录中提取视频ID\n  const videoRecords = [...selectedRecords.value].map(key => {\n    const [bvid, timestamp] = key.split('_')\n    return records.value.find(r => r.bvid === bvid && String(r.view_at) === timestamp)\n  }).filter(record => record) // 过滤掉未找到的记录\n\n  // 提取视频ID\n  const oids = videoRecords.map(record => {\n    const id = record.aid || record.avid || (record.business === 'archive' ? record.oid : null)\n    return id ? parseInt(id, 10) : null\n  }).filter(oid => oid !== null && !isNaN(oid))\n\n  if (oids.length === 0) {\n    showNotify({\n      type: 'warning',\n      message: '选中的记录中没有有效的视频ID',\n    })\n    return\n  }\n\n  // 打开收藏夹选择对话框\n  showFavoriteDialog.value = true\n  favoriteVideoInfo.value = {\n    isBatch: true,\n    batchIds: oids.join(','),\n    selectedCount: oids.length,\n  }\n}\n\n// 计算选中记录中已收藏的数量\nconst hasFavoritedVideos = computed(() => {\n  return favoritedCount.value > 0\n})\n\nconst favoritedCount = computed(() => {\n  if (selectedRecords.value.size === 0) return 0\n\n  let count = 0\n  selectedRecords.value.forEach(key => {\n    const [bvid, timestamp] = key.split('_')\n    const record = records.value.find(r => r.bvid === bvid && String(r.view_at) === timestamp)\n    if (record) {\n      const videoId = record.aid || record.avid || (record.business === 'archive' ? record.oid : null)\n      if (videoId && isVideoFavorited(parseInt(videoId, 10))) {\n        count++\n      }\n    }\n  })\n\n  return count\n})\n\n// 计算选中记录中未收藏的数量\nconst unfavoritedCount = computed(() => {\n  if (selectedRecords.value.size === 0) return 0\n  return selectedRecords.value.size - favoritedCount.value\n})\n\n// 检查是否所有选中的记录都已收藏\nconst isAllFavorited = computed(() => {\n  return selectedRecords.value.size > 0 && favoritedCount.value === selectedRecords.value.size\n})\n\n// 检查是否所有选中的记录都未收藏\ncomputed(() => {\n  return selectedRecords.value.size > 0 && unfavoritedCount.value === selectedRecords.value.size\n})\n// 复制选中视频的链接到剪贴板\nconst handleCopyLinks = async () => {\n  if (selectedRecords.value.size === 0) {\n    showNotify({\n      type: 'warning',\n      message: '请先选择要复制链接的记录',\n    })\n    return\n  }\n\n  try {\n    // 从选中的记录中提取视频信息\n    const videoRecords = [...selectedRecords.value].map(key => {\n      const [bvid, timestamp] = key.split('_')\n      return records.value.find(r => r.bvid === bvid && String(r.view_at) === timestamp)\n    }).filter(record => record) // 过滤掉未找到的记录\n\n    // 生成链接列表\n    const links = videoRecords.map(record => {\n      let url = ''\n      switch (record.business) {\n        case 'archive':\n          url = `https://www.bilibili.com/video/${record.bvid}`\n          break\n        case 'article':\n          url = `https://www.bilibili.com/read/cv${record.oid}`\n          break\n        case 'article-list':\n          url = `https://www.bilibili.com/read/readlist/rl${record.oid}`\n          break\n        case 'live':\n          url = `https://live.bilibili.com/${record.oid}`\n          break\n        case 'pgc':\n          url = record.uri || `https://www.bilibili.com/bangumi/play/ep${record.epid}`\n          break\n        case 'cheese':\n          url = record.uri || `https://www.bilibili.com/cheese/play/ep${record.epid}`\n          break\n        default:\n          console.warn('未知的业务类型:', record.business)\n          return null\n      }\n      return url\n    }).filter(url => url) // 过滤掉无效的URL\n\n    // 复制到剪贴板\n    if (links.length > 0) {\n      await copyToClipboard(links.join('\\n'))\n      showNotify({\n        type: 'success',\n        message: `已复制 ${links.length} 个链接到剪贴板`,\n      })\n    } else {\n      showNotify({\n        type: 'warning',\n        message: '没有找到有效的链接',\n      })\n    }\n  } catch (error) {\n    console.error('复制链接失败:', error)\n    showNotify({\n      type: 'danger',\n      message: '复制链接失败: ' + (error.message || '未知错误'),\n    })\n  }\n}\n\n// 复制到剪贴板函数\nconst copyToClipboard = async (text) => {\n  try {\n    await navigator.clipboard.writeText(text)\n    return true\n  } catch (err) {\n    console.error('复制失败:', err)\n    throw new Error('复制到剪贴板失败，请检查浏览器权限')\n  }\n}\n\n// 批量取消收藏选中的记录\nconst handleBatchUnfavorite = async () => {\n  if (selectedRecords.value.size === 0) {\n    showNotify({\n      type: 'warning',\n      message: '请先选择要取消收藏的记录',\n    })\n    return\n  }\n\n  try {\n    // 确认取消收藏\n    await showDialog({\n      title: '确认取消收藏',\n      message: `确定要取消${favoritedCount.value}个视频的收藏吗？`,\n      showCancelButton: true,\n    })\n\n    // 从选中的记录中提取视频ID\n    const videoRecords = [...selectedRecords.value].map(key => {\n      const [bvid, timestamp] = key.split('_')\n      return records.value.find(r => r.bvid === bvid && String(r.view_at) === timestamp)\n    }).filter(record => record) // 过滤掉未找到的记录\n\n    // 过滤出已收藏的视频\n    const favoritedRecords = videoRecords.filter(record => {\n      const videoId = record.aid || record.avid || (record.business === 'archive' ? record.oid : null)\n      return videoId && isVideoFavorited(parseInt(videoId, 10))\n    })\n\n    if (favoritedRecords.length === 0) {\n      showNotify({\n        type: 'warning',\n        message: '选中的记录中不包含已收藏的视频',\n      })\n      return\n    }\n\n    // 提取视频ID\n    const videoIds = favoritedRecords.map(record => {\n      const id = record.aid || record.avid || (record.business === 'archive' ? record.oid : null)\n      return id ? parseInt(id, 10) : null\n    }).filter(id => id !== null && !isNaN(id))\n\n    if (videoIds.length === 0) {\n      showNotify({\n        type: 'warning',\n        message: '无法获取有效的视频ID',\n      })\n      return\n    }\n\n    // 获取每个视频的收藏夹列表和执行取消收藏操作\n    let results\n\n    // 获取每个视频的收藏夹列表\n    const unfavoritePromises = videoIds.map(async videoId => {\n      const folders = getVideoFavoriteFolders(videoId)\n      if (folders.length > 0) {\n        // 获取收藏夹ID\n        const folderIds = folders.map(folder => folder.media_id)\n\n        // 发送取消收藏请求\n        const response = await favoriteResource({\n          rid: videoId,\n          del_media_ids: folderIds.join(','),\n        })\n\n        // 如果远程操作成功，同步本地数据库（不提示用户）\n        if (response.data.status === 'success') {\n          try {\n            await localBatchFavoriteResource({\n              rids: videoId.toString(),\n              del_media_ids: folderIds.join(','),\n              operation_type: 'local', // 只在本地操作\n            })\n          } catch (syncError) {\n            console.error('本地同步取消收藏失败，但不影响用户体验:', syncError)\n          }\n        }\n\n        return { videoId, success: response.data.status === 'success' }\n      }\n      return { videoId, success: false, reason: '没有找到收藏夹' }\n    })\n\n    results = await Promise.all(unfavoritePromises)\n    const successCount = results.filter(r => r.success).length\n\n    if (successCount > 0) {\n      showNotify({\n        type: 'success',\n        message: `成功取消${successCount}个视频的收藏`,\n      })\n\n      // 更新收藏状态\n      results.forEach(result => {\n        if (result.success) {\n          favoriteStatus.value[result.videoId] = {\n            is_favorited: false,\n            favorite_folders: [],\n          }\n        }\n      })\n\n      // 刷新收藏状态\n      await batchCheckFavorites()\n\n      // 取消选择\n      selectedRecords.value.clear()\n    } else {\n      showNotify({\n        type: 'danger',\n        message: '取消收藏失败',\n      })\n    }\n  } catch (error) {\n    if (error.toString().includes('cancel')) return\n\n    console.error('批量取消收藏失败:', error)\n    showNotify({\n      type: 'danger',\n      message: '批量取消收藏失败: ' + (error.message || '未知错误'),\n    })\n  }\n}\n\n// 处理收藏按钮点击（列表布局）\nconst handleFavorite = (record) => {\n  // 获取视频ID，适配不同的属性名（aid或avid）\n  const videoId = record.aid || record.avid || (record.business === 'archive' ? record.oid : null)\n\n  if (!videoId) {\n    showNotify({ type: 'warning', message: '无法识别视频ID' })\n    return\n  }\n\n  // 检查是否已收藏\n  const parsedVideoId = parseInt(videoId, 10)\n  if (isVideoFavorited(parsedVideoId)) {\n    // 如果已收藏，提示是否取消收藏\n    showDialog({\n      title: '取消收藏',\n      message: '确定要取消收藏该视频吗？',\n      showCancelButton: true,\n    }).then(async () => {\n      // 获取视频的收藏夹列表\n      const folders = getVideoFavoriteFolders(parsedVideoId)\n      if (folders.length > 0) {\n        // 获取收藏夹ID\n        const folderIds = folders.map(folder => folder.media_id)\n        try {\n          // 发送取消收藏请求\n          const response = await favoriteResource({\n            rid: parsedVideoId,\n            del_media_ids: folderIds.join(','),\n          })\n\n          // 如果远程操作成功，同步本地数据库（不提示用户）\n          if (response.data.status === 'success') {\n            try {\n              await localBatchFavoriteResource({\n                rids: parsedVideoId.toString(),\n                del_media_ids: folderIds.join(','),\n                operation_type: 'local', // 只在本地操作\n              })\n            } catch (syncError) {\n              console.error('本地同步取消收藏失败，但不影响用户体验:', syncError)\n            }\n\n            // 更新收藏状态\n            favoriteStatus.value[parsedVideoId] = {\n              is_favorited: false,\n              favorite_folders: [],\n            }\n\n            showNotify({ type: 'success', message: '已取消收藏' })\n\n            // 刷新收藏状态\n            await batchCheckFavorites()\n          } else {\n            throw new Error(response.data.message || '取消收藏失败')\n          }\n        } catch (error) {\n          console.error('取消收藏失败:', error)\n          showNotify({ type: 'danger', message: '取消收藏失败: ' + (error.message || '未知错误') })\n        }\n      }\n    }).catch(() => {\n      // 取消操作，不做任何处理\n    })\n  } else {\n    // 如果未收藏，打开收藏夹选择对话框\n    showFavoriteDialog.value = true\n    favoriteVideoInfo.value = record\n  }\n}\n</script>\n"
  },
  {
    "path": "src/components/tailwind/LoginDialog.vue",
    "content": "<!-- 登录弹窗 -->\n<template>\n  <div v-if=\"show\" class=\"fixed inset-0 z-50 flex items-center justify-center\">\n    <!-- 遮罩层 -->\n    <div class=\"absolute inset-0 bg-black/50\" @click=\"handleClose\"></div>\n\n    <!-- 弹窗内容 -->\n    <div class=\"relative bg-white rounded-lg shadow-xl w-[360px] max-h-[90vh] overflow-y-auto\">\n      <!-- 关闭按钮 -->\n      <button\n        @click=\"handleClose\"\n        class=\"absolute right-4 top-4 text-gray-400 hover:text-gray-500\"\n      >\n        <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n          <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\" />\n        </svg>\n      </button>\n\n      <!-- 标题 -->\n      <div class=\"p-6 pb-0\">\n        <h3 class=\"text-lg font-medium text-gray-900\">账号登录</h3>\n        <p class=\"mt-2 text-sm text-gray-500\">扫描二维码登录B站账号</p>\n      </div>\n\n      <!-- 登录内容 -->\n      <div class=\"p-6 flex flex-col items-center space-y-4\">\n        <!-- 二维码区域 -->\n        <div v-if=\"qrcodeKey\" class=\"relative\">\n          <img :src=\"qrcodeImageUrl\" alt=\"登录二维码\" class=\"w-48 h-48 rounded-lg shadow-sm\">\n          <!-- 二维码失效遮罩 -->\n          <div v-if=\"qrcodeExpired\" class=\"absolute inset-0 bg-black/50 rounded-lg flex items-center justify-center\">\n            <button\n              @click=\"refreshQRCode\"\n              class=\"px-4 py-2 bg-white text-gray-900 rounded-md text-sm hover:bg-gray-50\"\n            >\n              点击刷新\n            </button>\n          </div>\n        </div>\n\n        <!-- 状态提示 -->\n        <div class=\"text-sm\" :class=\"statusClass\">\n          {{ loginStatusText }}\n        </div>\n\n        <!-- 刷新按钮 -->\n        <button\n          v-if=\"!qrcodeKey || qrcodeExpired\"\n          @click=\"refreshQRCode\"\n          class=\"px-4 py-2 text-sm text-[#fb7299] hover:bg-[#fb7299]/5 rounded-md transition-colors duration-200\"\n        >\n          {{ qrcodeKey ? '重新获取二维码' : '获取登录二维码' }}\n        </button>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted, onUnmounted, watch } from 'vue'\nimport { showNotify } from 'vant'\nimport {\n  generateLoginQRCode,\n  getQRCodeImageURL,\n  getQRCodeImageBlob,\n  pollQRCodeStatus,\n  getLoginStatus\n} from '../../api/api'\n\nconst props = defineProps({\n  show: {\n    type: Boolean,\n    default: false\n  }\n})\n\nconst emit = defineEmits(['update:show', 'login-success'])\n\n// 登录相关状态\nconst qrcodeKey = ref('')\nconst qrcodeImageUrl = ref('')\nconst qrcodeExpired = ref(false)\nconst loginStatus = ref(86101) // 初始状态：未扫码\nconst pollingInterval = ref(null)\nconst pollingErrors = ref(0)\n\n// 登录状态文本\nconst loginStatusText = computed(() => {\n  switch (loginStatus.value) {\n    case 0:\n      return '登录成功'\n    case 86038:\n      return '二维码已失效'\n    case 86090:\n      return '已扫码，请在手机上确认'\n    case 86101:\n      return '请使用B站APP扫描二维码登录'\n    default:\n      return '获取二维码中...'\n  }\n})\n\n// 状态样式\nconst statusClass = computed(() => {\n  switch (loginStatus.value) {\n    case 0:\n      return 'text-green-500'\n    case 86038:\n      return 'text-red-500'\n    case 86090:\n      return 'text-[#fb7299]'\n    default:\n      return 'text-gray-500'\n  }\n})\n\n// 开始轮询\nconst startPolling = () => {\n  if (pollingInterval.value) {\n    clearInterval(pollingInterval.value)\n  }\n\n  let attempts = 0\n  const maxAttempts = 90 // 180秒内尝试90次\n  pollingErrors.value = 0 // 重置错误计数\n\n  pollingInterval.value = setInterval(async () => {\n    try {\n      if (!qrcodeKey.value) {\n        console.error('缺少qrcode_key参数')\n        clearInterval(pollingInterval.value)\n        showNotify({\n          type: 'danger',\n          message: '登录参数错误,请刷新页面重试'\n        })\n        return\n      }\n\n      const response = await pollQRCodeStatus(qrcodeKey.value)\n\n      if (!response?.data) {\n        throw new Error('服务器响应格式错误')\n      }\n\n      if (response.data.status === 'success' && response.data.data) {\n        const code = response.data.data.code\n        if (typeof code === 'undefined') {\n          throw new Error('响应中缺少状态码')\n        }\n\n        loginStatus.value = code\n        pollingErrors.value = 0\n\n        if (loginStatus.value === 0) {\n          // 登录成功\n          clearInterval(pollingInterval.value)\n          showNotify({\n            type: 'success',\n            message: '登录成功'\n          })\n\n          // 获取用户信息并发送登录成功事件\n          try {\n            const userResponse = await getLoginStatus()\n            if (userResponse.data && userResponse.data.code === 0) {\n              console.log('登录对话框获取到用户信息:', userResponse.data)\n              // 发送登录成功事件，并传递用户信息\n              emit('login-success', userResponse.data.data)\n            } else {\n              // 如果获取用户信息失败，仍然发送登录成功事件\n              emit('login-success')\n            }\n          } catch (error) {\n            console.error('获取用户信息失败:', error)\n            // 如果出错，仍然发送登录成功事件\n            emit('login-success')\n          }\n\n          // 关闭弹窗\n          setTimeout(() => {\n            handleClose()\n          }, 1000)\n        } else if (loginStatus.value === 86038) {\n          // 二维码失效\n          clearInterval(pollingInterval.value)\n          qrcodeExpired.value = true\n          showNotify({\n            type: 'warning',\n            message: '二维码已失效,请点击刷新'\n          })\n        } else if (loginStatus.value === 86090) {\n          // 已扫码待确认\n          showNotify({\n            type: 'primary',\n            message: '已扫码,请在手机上确认'\n          })\n        }\n      } else {\n        const errorMsg = response.data.detail || response.data.message || '获取状态失败'\n        throw new Error(errorMsg)\n      }\n\n      attempts++\n      if (attempts >= maxAttempts) {\n        clearInterval(pollingInterval.value)\n        qrcodeExpired.value = true\n        loginStatus.value = 86038\n        showNotify({\n          type: 'warning',\n          message: '登录超时,请重新获取二维码'\n        })\n      }\n    } catch (error) {\n      console.error('轮询出错:', error)\n      pollingErrors.value++\n\n      if (error.response?.status === 500) {\n        clearInterval(pollingInterval.value)\n        qrcodeExpired.value = true\n        showNotify({\n          type: 'danger',\n          message: '服务器错误,请稍后重试'\n        })\n        return\n      }\n\n      if (pollingErrors.value >= 3) {\n        clearInterval(pollingInterval.value)\n        qrcodeExpired.value = true\n        showNotify({\n          type: 'danger',\n          message: '网络异常,请检查网络后重试'\n        })\n      }\n    }\n  }, 2000)\n}\n\n// 获取二维码\nconst getQRCode = async () => {\n  try {\n    const response = await generateLoginQRCode()\n\n    if (!response?.data) {\n      throw new Error('服务器响应格式错误')\n    }\n\n    if (response.data.status === 'success' && response.data.data?.qrcode_key) {\n      qrcodeKey.value = response.data.data.qrcode_key\n\n      try {\n        // 使用axios获取二维码图片（带API密钥验证）\n        qrcodeImageUrl.value = await getQRCodeImageBlob()\n      } catch (imgError) {\n        console.error('获取二维码图片失败:', imgError)\n        // 如果获取图片失败，回退到直接使用URL\n        qrcodeImageUrl.value = getQRCodeImageURL()\n      }\n\n      qrcodeExpired.value = false\n      loginStatus.value = 86101\n      pollingErrors.value = 0\n      startPolling()\n    } else {\n      const errorMsg = response.data.detail || response.data.message || '获取二维码失败'\n      throw new Error(errorMsg)\n    }\n  } catch (error) {\n    console.error('获取二维码失败:', error)\n    showNotify({\n      type: 'danger',\n      message: error.response?.status === 500 ?\n        '服务器错误,请稍后重试' :\n        `获取二维码失败: ${error.message}`\n    })\n    qrcodeExpired.value = true\n  }\n}\n\n// 刷新二维码\nconst refreshQRCode = () => {\n  if (pollingInterval.value) {\n    clearInterval(pollingInterval.value)\n  }\n  getQRCode()\n}\n\n// 关闭弹窗\nconst handleClose = () => {\n  emit('update:show', false)\n  // 清理轮询\n  if (pollingInterval.value) {\n    clearInterval(pollingInterval.value)\n  }\n\n  // 如果是blob URL，需要释放\n  if (qrcodeImageUrl.value && qrcodeImageUrl.value.startsWith('blob:')) {\n    URL.revokeObjectURL(qrcodeImageUrl.value)\n  }\n\n  // 重置状态\n  qrcodeKey.value = ''\n  qrcodeImageUrl.value = ''\n  qrcodeExpired.value = false\n  loginStatus.value = 86101\n  pollingErrors.value = 0\n}\n\n// 监听show变化\nwatch(() => props.show, (newVal) => {\n  if (newVal) {\n    getQRCode()\n  } else {\n    handleClose()\n  }\n})\n\n// 组件卸载时清除轮询和释放资源\nonUnmounted(() => {\n  if (pollingInterval.value) {\n    clearInterval(pollingInterval.value)\n  }\n\n  // 释放blob URL\n  if (qrcodeImageUrl.value && qrcodeImageUrl.value.startsWith('blob:')) {\n    URL.revokeObjectURL(qrcodeImageUrl.value)\n  }\n})\n\n// 定义组件选项\ndefineOptions({\n  name: 'LoginDialog'\n})\n</script>"
  },
  {
    "path": "src/components/tailwind/Navbar.vue",
    "content": "<template>\n  <div class=\"sticky top-0 z-50\">\n    <nav class=\"bg-white dark:bg-gray-900 lg:pt-4 transition-colors duration-300\">\n      <div class=\"mx-auto transition-all duration-300 ease-in-out\" :class=\"{'max-w-4xl': layout === 'list', 'max-w-6xl': layout === 'grid'}\">\n        <!-- 导航栏主要内容 -->\n        <div class=\"flex items-center justify-between px-2 py-2\">\n          <!-- 左侧图标 -->\n          <div class=\"flex items-center space-x-6\">\n            <!-- 实时更新按钮 -->\n            <button\n              @click=\"handleUpdate\"\n              :disabled=\"isUpdating\"\n              class=\"flex sm:flex-col items-center text-gray-700 dark:text-gray-300 hover:text-[#fb7299] transition-colors duration-200\"\n              :class=\"{\n                'text-gray-500': isUpdating\n              }\"\n              :title=\"syncDeleted ? '当前模式：同步已删除记录' : '当前模式：不同步已删除记录'\"\n            >\n              <!-- 实时更新图标 - 加载中 -->\n              <svg\n                v-if=\"isUpdating\"\n                class=\"animate-spin w-5 h-5 sm:w-6 sm:h-6\"\n                xmlns=\"http://www.w3.org/2000/svg\"\n                fill=\"none\"\n                viewBox=\"0 0 24 24\"\n              >\n                <circle\n                  class=\"opacity-25\"\n                  cx=\"12\"\n                  cy=\"12\"\n                  r=\"10\"\n                  stroke=\"currentColor\"\n                  stroke-width=\"4\"\n                ></circle>\n                <path\n                  class=\"opacity-75\"\n                  fill=\"currentColor\"\n                  d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"\n                ></path>\n              </svg>\n\n              <!-- 实时更新图标 - 正常模式 -->\n              <svg\n                v-if=\"!isUpdating && !syncDeleted\"\n                class=\"w-5 h-5 sm:w-6 sm:h-6\"\n                xmlns=\"http://www.w3.org/2000/svg\"\n                fill=\"none\"\n                viewBox=\"0 0 24 24\"\n              >\n                <path\n                  stroke=\"currentColor\"\n                  stroke-linecap=\"round\"\n                  stroke-linejoin=\"round\"\n                  stroke-width=\"2\"\n                  d=\"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15\"\n                ></path>\n              </svg>\n\n              <!-- 实时更新图标 - 同步已删除记录模式 (带垃圾桶图标) -->\n              <svg\n                v-if=\"!isUpdating && syncDeleted\"\n                class=\"w-5 h-5 sm:w-6 sm:h-6\"\n                xmlns=\"http://www.w3.org/2000/svg\"\n                fill=\"none\"\n                viewBox=\"0 0 24 24\"\n              >\n                <!-- 刷新图标 -->\n                <path\n                  stroke=\"currentColor\"\n                  stroke-linecap=\"round\"\n                  stroke-linejoin=\"round\"\n                  stroke-width=\"2\"\n                  d=\"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15\"\n                ></path>\n\n                <!-- 垃圾桶图标 -->\n                <g transform=\"translate(12, 12) scale(0.012) translate(-512, -512)\">\n                  <path\n                    fill=\"currentColor\"\n                    d=\"M630.784 831.488c12.288 0 20.48-8.192 20.48-16.384l28.672-450.56c0-12.288-8.192-20.48-16.384-20.48-12.288 0-20.48 8.192-20.48 16.384l-28.672 450.56C614.4 823.296 622.592 831.488 630.784 831.488z\"\n                  ></path>\n                  <path\n                    fill=\"currentColor\"\n                    d=\"M409.6 831.488c12.288 0 20.48-8.192 16.384-20.48l-28.672-450.56c0-12.288-8.192-20.48-20.48-16.384C368.64 344.064 360.448 352.256 360.448 360.448l28.672 450.56C389.12 823.296 397.312 831.488 409.6 831.488z\"\n                  ></path>\n                  <path\n                    fill=\"currentColor\"\n                    d=\"M520.192 831.488c12.288 0 20.48-8.192 20.48-20.48l0-450.56c0-12.288-8.192-20.48-20.48-20.48-12.288 0-20.48 8.192-20.48 20.48l0 450.56C499.712 823.296 507.904 831.488 520.192 831.488z\"\n                  ></path>\n                  <path\n                    fill=\"currentColor\"\n                    d=\"M839.68 229.376l-188.416 0L651.264 151.552c0-20.48-16.384-36.864-36.864-36.864l-188.416 0c-20.48 0-36.864 16.384-36.864 36.864l0 73.728L200.704 225.28C188.416 229.376 180.224 237.568 180.224 245.76c0 12.288 8.192 20.48 20.48 20.48l36.864 0 36.864 602.112c4.096 40.96 32.768 73.728 73.728 73.728l339.968 0c40.96 0 69.632-32.768 73.728-73.728l36.864-602.112 36.864 0C851.968 266.24 860.16 258.048 860.16 245.76 860.16 237.568 851.968 229.376 839.68 229.376zM425.984 151.552 614.4 151.552l0 73.728-188.416 0L425.984 151.552zM729.088 868.352c-4.096 20.48-16.384 36.864-36.864 36.864L352.256 905.216c-20.48 0-32.768-16.384-36.864-36.864L274.432 266.24l491.52 0L729.088 868.352z\"\n                  ></path>\n                </g>\n              </svg>\n              <span class=\"sm:mt-1 text-xs hidden sm:block\">{{ isUpdating ? '更新中' : '实时更新' }}</span>\n            </button>\n\n            <!-- 深色模式按钮 -->\n            <button\n              @click=\"toggleDarkMode\"\n              class=\"flex sm:flex-col items-center text-gray-700 dark:text-gray-300 hover:text-[#fb7299] transition-colors duration-200\"\n            >\n              <!-- 浅色模式图标（深色模式下显示） -->\n              <svg\n                v-if=\"isDarkMode\"\n                class=\"w-5 h-5 sm:w-6 sm:h-6\"\n                fill=\"none\"\n                viewBox=\"0 0 24 24\"\n                stroke=\"currentColor\"\n              >\n                <path\n                  stroke-linecap=\"round\"\n                  stroke-linejoin=\"round\"\n                  stroke-width=\"2\"\n                  d=\"M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z\"\n                />\n              </svg>\n              <!-- 深色模式图标（浅色模式下显示） -->\n              <svg\n                v-else\n                class=\"w-5 h-5 sm:w-6 sm:h-6\"\n                fill=\"none\"\n                viewBox=\"0 0 24 24\"\n                stroke=\"currentColor\"\n              >\n                <path\n                  stroke-linecap=\"round\"\n                  stroke-linejoin=\"round\"\n                  stroke-width=\"2\"\n                  d=\"M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z\"\n                />\n              </svg>\n              <span class=\"sm:mt-1 text-xs hidden sm:block\">{{ isDarkMode ? '浅色模式' : '深色模式' }}</span>\n            </button>\n\n            <!-- 隐私模式按钮 -->\n            <button\n              @click=\"togglePrivacyMode\"\n              class=\"flex sm:flex-col items-center text-gray-700 dark:text-gray-300 hover:text-[#fb7299] transition-colors duration-200\"\n            >\n              <svg\n                class=\"w-5 h-5 sm:w-6 sm:h-6\"\n                fill=\"none\"\n                viewBox=\"0 0 256 256\"\n                :stroke=\"isPrivacyMode ? '#fb7299' : 'currentColor'\"\n              >\n                <path\n                  d=\"M128,56C48,56,8,128,8,128s40,72,120,72s120-72,120-72S208,56,128,56Z\"\n                  :class=\"isPrivacyMode ? 'hidden' : 'block'\"\n                  fill=\"none\"\n                  stroke-linecap=\"round\"\n                  stroke-linejoin=\"round\"\n                  stroke-width=\"16\"\n                />\n                <circle\n                  cx=\"128\"\n                  cy=\"128\"\n                  r=\"32\"\n                  :class=\"isPrivacyMode ? 'hidden' : 'block'\"\n                  fill=\"none\"\n                  stroke-linecap=\"round\"\n                  stroke-linejoin=\"round\"\n                  stroke-width=\"16\"\n                />\n                <path\n                  d=\"M48,40L208,216\"\n                  :class=\"isPrivacyMode ? 'block' : 'hidden'\"\n                  fill=\"none\"\n                  stroke-linecap=\"round\"\n                  stroke-linejoin=\"round\"\n                  stroke-width=\"16\"\n                />\n                <path\n                  d=\"M154.9,157.6A32,32,0,0,1,97.6,100.3\"\n                  :class=\"isPrivacyMode ? 'block' : 'hidden'\"\n                  fill=\"none\"\n                  stroke-linecap=\"round\"\n                  stroke-linejoin=\"round\"\n                  stroke-width=\"16\"\n                />\n                <path\n                  d=\"M183.9,186.1C165.9,197.5,147.2,204,128,204,48,204,8,132,8,132s15.3-27.4,41.9-48.5\"\n                  :class=\"isPrivacyMode ? 'block' : 'hidden'\"\n                  fill=\"none\"\n                  stroke-linecap=\"round\"\n                  stroke-linejoin=\"round\"\n                  stroke-width=\"16\"\n                />\n              </svg>\n              <span class=\"sm:mt-1 text-xs hidden sm:block\">{{ isPrivacyMode ? '关闭隐私' : '隐私模式' }}</span>\n            </button>\n          </div>\n\n          <!-- 中间搜索框 -->\n          <div class=\"flex-1 w-full mx-4 sm:mx-10\">\n            <SearchBar />\n          </div>\n\n          <!-- 右侧图标 -->\n          <div class=\"flex items-center space-x-3 sm:space-x-6\">\n            <!-- 布局切换按钮 -->\n            <button\n              @click=\"$emit('change-layout', layout === 'list' ? 'grid' : 'list')\"\n              class=\"hidden sm:flex sm:flex-col items-center text-gray-700 dark:text-gray-300 hover:text-[#fb7299] transition-colors duration-200\"\n              :class=\"{ 'text-[#fb7299]': layout === 'grid' }\"\n            >\n              <svg class=\"w-5 h-5 sm:w-6 sm:h-6\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                <path\n                  v-if=\"layout === 'list'\"\n                  stroke-linecap=\"round\"\n                  stroke-linejoin=\"round\"\n                  stroke-width=\"2\"\n                  d=\"M4 6h16M4 12h16M4 18h16\"\n                />\n                <path\n                  v-else\n                  stroke-linecap=\"round\"\n                  stroke-linejoin=\"round\"\n                  stroke-width=\"2\"\n                  d=\"M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z\"\n                />\n              </svg>\n              <span class=\"sm:mt-1 text-xs hidden sm:block\">{{ layout === 'list' ? '网格视图' : '列表视图' }}</span>\n            </button>\n\n            <!-- 筛选按钮 -->\n            <button\n              @click=\"openFilterPanel\"\n              class=\"flex sm:flex-col items-center text-gray-700 dark:text-gray-300 hover:text-[#fb7299] transition-colors duration-200\"\n              :class=\"{ 'text-[#fb7299]': isFilterActive }\"\n            >\n              <svg class=\"w-5 h-5 sm:w-6 sm:h-6\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                <path\n                  stroke-linecap=\"round\"\n                  stroke-linejoin=\"round\"\n                  stroke-width=\"2\"\n                  d=\"M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z\"\n                />\n              </svg>\n              <span class=\"sm:mt-1 text-xs hidden sm:block\">{{ isFilterActive ? '筛选中' : '筛选' }}</span>\n            </button>\n\n            <!-- 批量操作按钮 -->\n            <button\n              @click=\"$emit('toggle-batch-mode')\"\n              class=\"flex sm:flex-col items-center text-gray-700 dark:text-gray-300 hover:text-[#fb7299] transition-colors duration-200\"\n              :class=\"{ 'text-[#fb7299]': isBatchMode }\"\n            >\n              <svg class=\"w-5 h-5 sm:w-6 sm:h-6\" fill=\"none\" viewBox=\"0 0 24 24\" :stroke=\"isBatchMode ? '#fb7299' : 'currentColor'\">\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z\" />\n              </svg>\n              <span class=\"sm:mt-1 text-xs hidden sm:block\">{{ isBatchMode ? '点击取消' : '批量操作' }}</span>\n            </button>\n\n            <!-- 设置按钮 - 只在手机端显示 -->\n            <button\n              @click=\"$router.push('/settings')\"\n              class=\"flex sm:hidden flex-col items-center text-gray-700 dark:text-gray-300 hover:text-[#fb7299] transition-colors duration-200\"\n            >\n              <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z\" />\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\" />\n              </svg>\n            </button>\n          </div>\n        </div>\n\n        <!-- 筛选区域 -->\n        <div class=\"mx-auto transition-all duration-300 ease-in-out\" :class=\"{'max-w-4xl': layout === 'list', 'max-w-6xl': layout === 'grid'}\">\n          <FilterDropdown\n            ref=\"filterDropdownRef\"\n            :business=\"business\"\n            :businessLabel=\"businessLabel\"\n            :date=\"date\"\n            :category=\"category\"\n            :total=\"total\"\n            :pageSize=\"pageSize\"\n            @update:business=\"$emit('update:business', $event)\"\n            @update:businessLabel=\"$emit('update:businessLabel', $event)\"\n            @update:date=\"$emit('update:date', $event)\"\n            @update:category=\"$emit('update:category', $event)\"\n            @update:pageSize=\"$emit('update:pageSize', $event)\"\n            @click-date=\"$emit('click-date')\"\n            @click-category=\"$emit('click-category')\"\n            @refresh-data=\"$emit('refresh-data')\"\n          />\n        </div>\n      </div>\n    </nav>\n  </div>\n</template>\n\n<script setup>\nimport SearchBar from './SearchBar.vue'\nimport FilterDropdown from './FilterDropdown.vue'\nimport { ref, watch, computed } from 'vue'\nimport { showNotify } from 'vant'\nimport { usePrivacyStore } from '@/store/privacy.js'\nimport { useDarkMode } from '@/store/darkMode.js'\nimport 'vant/es/notify/style'\n\nconst { isPrivacyMode, togglePrivacyMode } = usePrivacyStore()\nconst { isDarkMode, toggleDarkMode } = useDarkMode()\n\nconst props = defineProps({\n  date: {\n    type: String,\n    default: ''\n  },\n  total: {\n    type: Number,\n    default: 0\n  },\n  category: {\n    type: String,\n    default: ''\n  },\n  layout: {\n    type: String,\n    default: 'list'\n  },\n  isBatchMode: {\n    type: Boolean,\n    default: false\n  },\n  businessLabel: {\n    type: String,\n    default: ''\n  },\n  business: {\n    type: String,\n    default: ''\n  },\n  pageSize: {\n    type: Number,\n    default: 30\n  }\n})\n\nconst emit = defineEmits([\n  'click-date',\n  'click-category',\n  'click-business',\n  'change-layout',\n  'update:date',\n  'update:category',\n  'update:business',\n  'update:businessLabel',\n  'update:pageSize',\n  'refresh-data',\n  'toggle-batch-mode'\n])\n\nconst isUpdating = ref(false)\nconst syncDeleted = ref(localStorage.getItem('syncDeleted') === 'true')\nconst filterDropdownRef = ref(null)\n\nconst isFilterActive = computed(() => Boolean(props.date || props.category || props.business))\n\n// 监听 syncDeleted 的变化\nwatch(() => localStorage.getItem('syncDeleted'), (newVal) => {\n  syncDeleted.value = newVal === 'true'\n})\n\nconst openFilterPanel = () => {\n  if (filterDropdownRef.value?.openFilterPopup) {\n    filterDropdownRef.value.openFilterPopup()\n  } else {\n    console.warn('Filter panel is not ready yet')\n  }\n}\n\n// 处理更新\nconst handleUpdate = async () => {\n  if (isUpdating.value) return\n\n  isUpdating.value = true\n  try {\n    console.log('触发 refresh-data 事件')\n    emit('refresh-data')\n  } catch (error) {\n    console.error('更新失败详细信息:', error)\n\n    let errorMessage = '更新失败，请稍后重试'\n    if (error.response?.data?.message) {\n      errorMessage = error.response.data.message\n    } else if (error.message) {\n      errorMessage = error.message\n    }\n\n    showNotify({\n      type: 'danger',\n      message: errorMessage,\n      duration: 3500,\n    })\n  } finally {\n    console.log('更新流程结束')\n    setTimeout(() => {\n      isUpdating.value = false\n    }, 2000) // 添加一个短暂延迟，提供更好的视觉反馈\n  }\n}\n</script>\n\n<style>\n/* 全局滚动条样式 - 仅在PC端生效 */\n@media (min-width: 1024px) {\n  ::-webkit-scrollbar {\n    width: 12px;\n    height: 12px;\n  }\n\n  ::-webkit-scrollbar-track {\n    background: #f6f7f8;\n    border-radius: 6px;\n  }\n\n  .dark ::-webkit-scrollbar-track {\n    background: #1f2937;\n  }\n\n  ::-webkit-scrollbar-thumb {\n    background: #e3e5e7;\n    border-radius: 6px;\n    border: 3px solid #f6f7f8;\n    transition: all 0.2s ease-in-out;\n  }\n\n  .dark ::-webkit-scrollbar-thumb {\n    background: #4b5563;\n    border: 3px solid #1f2937;\n  }\n\n  ::-webkit-scrollbar-thumb:hover {\n    background: #fb7299;\n    border: 3px solid #f6f7f8;\n  }\n\n  .dark ::-webkit-scrollbar-thumb:hover {\n    background: #fb7299;\n    border: 3px solid #1f2937;\n  }\n\n  ::-webkit-scrollbar-corner {\n    background: #f6f7f8;\n  }\n\n  .dark ::-webkit-scrollbar-corner {\n    background: #1f2937;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/tailwind/Pagination.vue",
    "content": "<template>\n  <div class=\"mx-auto mb-5 mt-8 max-w-4xl lm:text-xs\">\n    <div class=\"flex justify-between items-center space-x-4 lm:mx-5\">\n      <button\n        @click=\"handlePageChange(currentPage - 1)\"\n        :disabled=\"currentPage === 1\"\n        class=\"flex items-center text-gray-500 dark:text-gray-400 hover:text-[#fb7299] disabled:opacity-40 disabled:cursor-not-allowed transition-colors px-3 py-2\"\n      >\n        <svg class=\"w-5 h-5 mr-1\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n          <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 19l-7-7 7-7\" />\n        </svg>\n        <span class=\"hidden sm:inline\">上一页</span>\n      </button>\n\n      <div class=\"flex items-center text-gray-700 dark:text-gray-300 lm:text-xs\">\n        <div class=\"relative mx-1 inline-block\">\n          <input\n            ref=\"pageInput\"\n            type=\"number\"\n            v-model=\"currentPageInput\"\n            @keyup.enter=\"handleJumpPage\"\n            @blur=\"handleJumpPage\"\n            @focus=\"handleFocus\"\n            min=\"1\"\n            :max=\"totalPages\"\n            class=\"h-8 w-12 rounded border border-gray-200 dark:border-gray-600 px-2 text-center text-gray-700 dark:text-gray-300 dark:bg-gray-800 transition-colors [appearance:textfield] hover:border-[#fb7299] focus:border-[#fb7299] focus:outline-none focus:ring-1 focus:ring-[#fb7299]/30 lm:h-6 lm:w-10 lm:text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none\"\n          />\n        </div>\n        <span class=\"text-gray-500 dark:text-gray-400 mx-1\">/ {{ totalPages }}</span>\n      </div>\n\n      <button\n        @click=\"handlePageChange(currentPage + 1)\"\n        :disabled=\"currentPage === totalPages\"\n        class=\"flex items-center text-gray-500 dark:text-gray-400 hover:text-[#fb7299] disabled:opacity-40 disabled:cursor-not-allowed transition-colors px-3 py-2\"\n      >\n        <span class=\"hidden sm:inline\">下一页</span>\n        <svg class=\"w-5 h-5 ml-1\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n          <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 5l7 7-7 7\" />\n        </svg>\n      </button>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, watch } from 'vue'\nimport { useRouter, useRoute } from 'vue-router'\n\nconst props = defineProps({\n  currentPage: {\n    type: Number,\n    required: true,\n  },\n  totalPages: {\n    type: Number,\n    required: true,\n  },\n  // 是否使用路由导航（首页使用，搜索页不使用）\n  useRouting: {\n    type: Boolean,\n    default: false,\n  },\n})\n\nconst emit = defineEmits(['page-change'])\nconst router = useRouter()\nconst route = useRoute()\n\nconst currentPageInput = ref(props.currentPage.toString())\n\n// 监听 props 变化更新输入框\nwatch(\n  () => props.currentPage,\n  (newPage) => {\n    currentPageInput.value = newPage.toString()\n  }\n)\n\n// 处理页码变化\nconst handlePageChange = (newPage) => {\n  if (newPage >= 1 && newPage <= props.totalPages) {\n    if (props.useRouting) {\n      // 检查当前是否在搜索页面\n      if (route.name && (route.name === 'Search' || route.name === 'SearchPage')) {\n        // 搜索页面的路由导航\n        if (newPage === 1) {\n          router.push(`/search/${route.params.keyword}`)\n        } else {\n          router.push(`/search/${route.params.keyword}/page/${newPage}`)\n        }\n      } else {\n        // 首页的路由导航\n        if (newPage === 1) {\n          router.push('/')\n        } else {\n          router.push(`/page/${newPage}`)\n        }\n      }\n    } else {\n      emit('page-change', newPage)\n    }\n  }\n}\n\n// 处理输入框获得焦点\nconst handleFocus = (event) => {\n  event.target.select()\n}\n\n// 处理跳转\nconst handleJumpPage = () => {\n  const targetPage = parseInt(currentPageInput.value)\n  if (!isNaN(targetPage) && targetPage >= 1 && targetPage <= props.totalPages) {\n    if (targetPage !== props.currentPage) {\n      handlePageChange(targetPage)\n    }\n  } else {\n    currentPageInput.value = props.currentPage.toString()\n  }\n}\n</script>\n\n<style scoped>\nbutton {\n  transition: color 0.3s ease;\n}\n</style>\n"
  },
  {
    "path": "src/components/tailwind/SearchBar.vue",
    "content": "<template>\n  <div class=\"relative mx-auto max-w-4xl\">\n    <!-- 搜索区域容器 -->\n    <div class=\"relative\">\n      <!-- 搜索框容器 -->\n      <div class=\"flex w-full h-8 sm:h-10 items-center rounded-md border border-gray-300 dark:border-gray-600 bg-transparent focus-within:border-[#fb7299] transition-colors duration-200\">\n        <!-- 搜索图标 -->\n        <div class=\"pl-2 sm:pl-3 text-gray-400 dark:text-gray-500\">\n          <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-3 w-3 sm:h-4 sm:w-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z\" />\n          </svg>\n        </div>\n        \n        <!-- 搜索类型选择器 - 替换为CustomDropdown组件 -->\n        <div class=\"h-full pl-1 sm:pl-2 flex items-center\">\n          <CustomDropdown\n            v-model=\"searchType\"\n            :options=\"searchTypeOptions\"\n            :selected-text=\"searchType\"\n            @change=\"onSearchTypeChange\"\n            custom-class=\"h-full border-none !shadow-none !p-0 !m-0 !rounded-none !pr-1\"\n            :min-width=\"180\"\n            :use-fixed-width=\"false\"\n          >\n            <template #trigger-content>\n              <span class=\"text-[#fb7299] text-xs sm:text-sm flex items-center whitespace-nowrap\">{{ getTypeLabel(searchType) }}</span>\n            </template>\n          </CustomDropdown>\n        </div>\n\n        <!-- 分隔线 -->\n        <div class=\"h-4 sm:h-5 w-px bg-gray-200 dark:bg-gray-600 mx-1\"></div>\n\n        <!-- 输入框 -->\n        <input\n          v-model=\"searchQuery\"\n          @keyup.enter=\"handleSearch\"\n          type=\"search\"\n          :placeholder=\"getPlaceholder\"\n          class=\"h-full w-full border-none bg-transparent px-1 sm:px-2 pr-2 sm:pr-3 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-0 text-xs sm:text-sm leading-none\"\n        />\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, watch, computed } from 'vue'\nimport { useRouter, useRoute } from 'vue-router'\nimport { getAvailableYears } from '../../api/api.js'\nimport CustomDropdown from './CustomDropdown.vue'\n\nconst props = defineProps({\n  initialYear: {\n    type: Number,\n    default: () => new Date().getFullYear()\n  },\n  initialKeyword: {\n    type: String,\n    default: ''\n  },\n  initialSearchType: {\n    type: String,\n    default: 'all'\n  }\n})\n\nconst emit = defineEmits(['year-change', 'search'])\nconst router = useRouter()\nconst route = useRoute()\n\n// 搜索相关\nconst searchQuery = ref(props.initialKeyword)\nconst searchType = ref(props.initialSearchType)\n\n// 搜索类型选项\nconst searchTypeOptions = [\n  { value: 'all', label: '全部' },\n  { value: 'title', label: '标题' },\n  { value: 'author', label: 'UP主' },\n  { value: 'tag', label: '分区' },\n  { value: 'remark', label: '备注' }\n]\n\n// 根据选项值获取显示文本\nconst getTypeLabel = (value) => {\n  const option = searchTypeOptions.find(opt => opt.value === value)\n  return option ? option.label : '全部'\n}\n\n// 处理搜索类型变更\nconst onSearchTypeChange = (value) => {\n  searchType.value = value\n  // 如果在搜索页面且有搜索关键词，触发搜索\n  if (route.name === 'Search' && searchQuery.value.trim()) {\n    handleSearch()\n  }\n}\n\n// 年份相关\nconst selectedYear = ref(props.initialYear)\nconst availableYears = ref([props.initialYear])\n\n// 根据搜索类型获取占位符文本\nconst getPlaceholder = computed(() => {\n  switch (searchType.value) {\n    case 'title':\n      return '视频标题/oid'\n    case 'author':\n      return 'UP主名称'\n    case 'tag':\n      return '分区名称'\n    case 'remark':\n      return '备注内容'\n    default:\n      return '输入关键词搜索'\n  }\n})\n\n// 获取可用年份列表\nconst fetchAvailableYears = async () => {\n  try {\n    const response = await getAvailableYears()\n    if (response.data.status === 'success') {\n      availableYears.value = response.data.data\n      // 如果当前选中的年份不在可用年份列表中，设置为最新的年份\n      if (!availableYears.value.includes(selectedYear.value)) {\n        selectedYear.value = availableYears.value[0]\n        emit('year-change', selectedYear.value)\n      }\n    }\n  } catch (error) {\n    console.error('获取可用年份失败:', error)\n  }\n}\n\n// 处理年份变化\nconst handleYearChange = () => {\n  emit('year-change', selectedYear.value)\n}\n\n// 处理搜索\nconst handleSearch = () => {\n  if (searchQuery.value.trim()) {\n    console.log('SearchBar - 执行搜索:', {\n      keyword: searchQuery.value.trim(),\n      type: searchType.value\n    })\n    if (route.name === 'Search') {\n      // 如果在搜索页面，发出搜索事件\n      emit('search', {\n        keyword: searchQuery.value.trim(),\n        type: searchType.value\n      })\n    } else {\n      // 修改为在当前窗口打开搜索结果页面\n      router.push({\n        name: 'Search',\n        params: { keyword: searchQuery.value.trim() },\n        query: { \n          type: searchType.value\n        }\n      })\n      searchQuery.value = ''\n    }\n  } else {\n    alert('请输入有效的搜索关键词')\n  }\n}\n\n// 监听props变化\nwatch(() => props.initialKeyword, (newKeyword) => {\n  searchQuery.value = newKeyword\n})\n\nwatch(() => props.initialYear, (newYear) => {\n  if (newYear !== selectedYear.value) {\n    selectedYear.value = newYear\n  }\n})\n\nwatch(() => props.initialSearchType, (newType) => {\n  if (newType !== searchType.value) {\n    searchType.value = newType\n  }\n})\n\nonMounted(async () => {\n  await fetchAvailableYears()\n})\n</script>\n\n<style scoped>\n/* 移除搜索框的默认样式 */\ninput[type=\"search\"]::-webkit-search-decoration,\ninput[type=\"search\"]::-webkit-search-cancel-button,\ninput[type=\"search\"]::-webkit-search-results-button,\ninput[type=\"search\"]::-webkit-search-results-decoration {\n  display: none;\n}\n\n/* 移除输入框的默认focus样式 */\ninput:focus {\n  box-shadow: none !important;\n  outline: none !important;\n}\n\n:deep(.custom-dropdown-trigger) {\n  border: none !important;\n  box-shadow: none !important;\n  background: transparent !important;\n}\n\n/* 确保下拉菜单按钮文本不会折行 */\n:deep(.custom-dropdown-trigger span) {\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n</style>\n"
  },
  {
    "path": "src/components/tailwind/Settings.vue",
    "content": "<template>\n  <div class=\"min-h-screen bg-gray-50/30 dark:bg-gray-900\">\n    <div class=\"py-4\">\n      <div class=\"max-w-4xl mx-auto px-4\">\n        <!-- 设置导航 -->\n        <div class=\"mb-6\">\n          <div class=\"border-b border-gray-200 dark:border-gray-700\">\n            <nav class=\"-mb-px flex space-x-6 overflow-x-auto\" aria-label=\"设置选项卡\">\n              <button\n                v-for=\"(tab, index) in settingTabs\"\n                :key=\"index\"\n                @click=\"activeTab = tab.key\"\n                class=\"py-3 px-1 border-b-2 font-medium text-sm flex items-center space-x-2\"\n                :class=\"activeTab === tab.key\n                  ? 'border-[#fb7299] text-[#fb7299]'\n                  : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'\"\n              >\n                <div class=\"w-5 h-5\" v-html=\"tab.icon\"></div>\n                <span>{{ tab.label }}</span>\n              </button>\n            </nav>\n          </div>\n        </div>\n\n        <!-- 设置内容 -->\n        <div class=\"space-y-4\">\n          <!-- 基础设置 -->\n          <section v-if=\"activeTab === 'basic'\">\n            <div class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700\">\n              <!-- 服务器配置 -->\n              <div class=\"p-4 transition-colors duration-200 hover:bg-gray-50 dark:hover:bg-gray-700\">\n                <div class=\"flex items-center justify-between mb-3\">\n                  <div>\n                    <h3 class=\"text-base font-medium text-gray-900 dark:text-gray-100\">服务器配置</h3>\n                    <p class=\"text-sm text-gray-500 dark:text-gray-400\">配置API服务器地址，修改后将自动刷新页面</p>\n                  </div>\n                </div>\n                <div class=\"flex space-x-2\">\n                  <input\n                    v-model=\"serverUrl\"\n                    type=\"text\"\n                    class=\"flex-1 block rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 shadow-sm focus:border-[#fb7299] focus:ring-[#fb7299] sm:text-sm\"\n                    placeholder=\"例如：http://localhost:8899\"\n                  />\n                  <button\n                    @click=\"resetServerUrl\"\n                    class=\"inline-flex items-center px-3 py-2 text-sm font-medium text-[#fb7299] bg-[#fb7299]/5 dark:bg-[#fb7299]/10 rounded-lg hover:bg-[#fb7299]/10 dark:hover:bg-[#fb7299]/20\"\n                  >\n                    <svg class=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                      <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15\" />\n                    </svg>\n                  </button>\n                  <button\n                    @click=\"saveServerUrl\"\n                    class=\"inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-[#fb7299] rounded-lg hover:bg-[#fb7299]/90\"\n                  >\n                    保存\n                  </button>\n                </div>\n              </div>\n\n              <!-- 图片源设置 -->\n              <div class=\"p-4 transition-colors duration-200 hover:bg-gray-50 dark:hover:bg-gray-700\">\n                <div class=\"flex items-center justify-between\">\n                  <div>\n                    <h3 class=\"text-base font-medium text-gray-900 dark:text-gray-100\">使用本地图片源</h3>\n                    <p class=\"text-sm text-gray-500 dark:text-gray-400\">选择使用本地图片源或在线图片源，本地图片源适合离线访问</p>\n                  </div>\n                  <label class=\"relative inline-flex items-center cursor-pointer\">\n                    <input type=\"checkbox\" v-model=\"useLocalImages\" class=\"sr-only peer\" @change=\"handleImageSourceChange\">\n                    <div class=\"w-11 h-6 bg-gray-200 peer-focus:ring-4 peer-focus:ring-[#fb7299]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#fb7299]\"></div>\n                  </label>\n                </div>\n              </div>\n\n              <!-- 隐私模式 -->\n              <div class=\"p-4 transition-colors duration-200 hover:bg-gray-50 dark:hover:bg-gray-700\">\n                <div class=\"flex items-center justify-between\">\n                  <div>\n                    <h3 class=\"text-base font-medium text-gray-900 dark:text-gray-100\">隐私模式</h3>\n                    <p class=\"text-sm text-gray-500 dark:text-gray-400\">开启后将模糊显示标题、封面、UP主名称等敏感信息</p>\n                  </div>\n                  <label class=\"relative inline-flex items-center cursor-pointer\">\n                    <input type=\"checkbox\" v-model=\"privacyMode\" class=\"sr-only peer\">\n                    <div class=\"w-11 h-6 bg-gray-200 peer-focus:ring-4 peer-focus:ring-[#fb7299]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#fb7299]\"></div>\n                  </label>\n                </div>\n              </div>\n\n              <!-- 侧边栏设置 -->\n              <div class=\"p-4 transition-colors duration-200 hover:bg-gray-50 dark:hover:bg-gray-700\">\n                <div class=\"flex items-center justify-between\">\n                  <div>\n                    <h3 class=\"text-base font-medium text-gray-900 dark:text-gray-100\">侧边栏显示</h3>\n                    <p class=\"text-sm text-gray-500 dark:text-gray-400\">设置是否默认显示侧边栏，关闭后侧边栏将自动收起</p>\n                  </div>\n                  <label class=\"relative inline-flex items-center cursor-pointer\">\n                    <input type=\"checkbox\" v-model=\"showSidebar\" class=\"sr-only peer\" @change=\"handleSidebarChange\">\n                    <div class=\"w-11 h-6 bg-gray-200 peer-focus:ring-4 peer-focus:ring-[#fb7299]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#fb7299]\"></div>\n                  </label>\n                </div>\n              </div>\n\n              <!-- 首页默认布局设置 -->\n              <div class=\"p-4 transition-colors duration-200 hover:bg-gray-50 dark:hover:bg-gray-700\">\n                <div class=\"flex items-center justify-between\">\n                  <div>\n                    <h3 class=\"text-base font-medium text-gray-900 dark:text-gray-100\">首页默认布局</h3>\n                    <p class=\"text-sm text-gray-500 dark:text-gray-400\">设置历史记录页面的默认展示方式，开启为网格视图，关闭为列表视图</p>\n                  </div>\n                  <label class=\"relative inline-flex items-center cursor-pointer\">\n                    <input type=\"checkbox\" v-model=\"isGridLayout\" class=\"sr-only peer\" @change=\"handleLayoutChange\">\n                    <div class=\"w-11 h-6 bg-gray-200 peer-focus:ring-4 peer-focus:ring-[#fb7299]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#fb7299]\"></div>\n                  </label>\n                </div>\n              </div>\n\n\n\n              <!-- 同步已删除记录 -->\n              <div class=\"p-4 transition-colors duration-200 hover:bg-gray-50 dark:hover:bg-gray-700\">\n                <div class=\"flex items-center justify-between\">\n                  <div>\n                    <h3 class=\"text-base font-medium text-gray-900 dark:text-gray-100\">同步已删除记录</h3>\n                    <p class=\"text-sm text-gray-500 dark:text-gray-400\">开启后将同步已删除的历史记录，建议仅在需要恢复记录时开启</p>\n                  </div>\n                  <label class=\"relative inline-flex items-center cursor-pointer\">\n                    <input type=\"checkbox\" v-model=\"syncDeleted\" class=\"sr-only peer\">\n                    <div class=\"w-11 h-6 bg-gray-200 peer-focus:ring-4 peer-focus:ring-[#fb7299]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#fb7299]\"></div>\n                  </label>\n                </div>\n              </div>\n\n              <!-- 同步删除B站历史记录 -->\n              <div class=\"p-4 transition-colors duration-200 hover:bg-gray-50 dark:hover:bg-gray-700\">\n                <div class=\"flex items-center justify-between\">\n                  <div>\n                    <h3 class=\"text-base font-medium text-gray-900 dark:text-gray-100\">同步删除B站历史记录</h3>\n                    <p class=\"text-sm text-gray-500 dark:text-gray-400\">开启后删除本地历史记录时，同时删除B站服务器上的对应记录</p>\n                  </div>\n                  <label class=\"relative inline-flex items-center cursor-pointer\">\n                    <input type=\"checkbox\" v-model=\"syncDeleteToBilibili\" class=\"sr-only peer\" @change=\"handleSyncDeleteToBilibiliChange\">\n                    <div class=\"w-11 h-6 bg-gray-200 peer-focus:ring-4 peer-focus:ring-[#fb7299]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#fb7299]\"></div>\n                  </label>\n                </div>\n              </div>\n\n              <!-- 启动时数据完整性校验 -->\n              <div class=\"p-4 transition-colors duration-200 hover:bg-gray-50 dark:hover:bg-gray-700\">\n                <div class=\"flex items-center justify-between\">\n                  <div>\n                    <h3 class=\"text-base font-medium text-gray-900 dark:text-gray-100\">启动时数据完整性校验</h3>\n                    <p class=\"text-sm text-gray-500 dark:text-gray-400\">开启后每次启动应用时都会进行数据完整性校验，关闭可加快启动速度</p>\n                  </div>\n                  <label class=\"relative inline-flex items-center cursor-pointer\">\n                    <input type=\"checkbox\" v-model=\"checkIntegrityOnStartup\" class=\"sr-only peer\" @change=\"handleIntegrityCheckChange\">\n                    <div class=\"w-11 h-6 bg-gray-200 peer-focus:ring-4 peer-focus:ring-[#fb7299]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#fb7299]\"></div>\n                  </label>\n                </div>\n              </div>\n\n              <!-- 邮件配置 -->\n              <div class=\"p-4 transition-colors duration-200 hover:bg-gray-50 dark:hover:bg-gray-700\">\n                <div class=\"flex items-center justify-between mb-2\">\n                  <h3 class=\"text-base font-medium text-gray-900 dark:text-gray-100\">邮件配置</h3>\n                  <div class=\"flex space-x-2\">\n                    <button\n                      @click=\"resetEmailConfig\"\n                      class=\"inline-flex items-center px-3 py-1.5 text-sm font-medium text-[#fb7299] bg-[#fb7299]/5 dark:bg-[#fb7299]/10 rounded-lg hover:bg-[#fb7299]/10 dark:hover:bg-[#fb7299]/20\"\n                    >\n                      <svg class=\"w-4 h-4 mr-1\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15\" />\n                      </svg>\n                      重置\n                    </button>\n                    <button\n                      @click=\"saveEmailConfig\"\n                      class=\"inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-[#fb7299] rounded-lg hover:bg-[#fb7299]/90\"\n                    >\n                      保存\n                    </button>\n                    <button\n                      @click=\"testEmailConfig\"\n                      class=\"inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-[#fb7299] rounded-lg hover:bg-[#fb7299]/90\"\n                      :disabled=\"!isEmailConfigComplete\"\n                    >\n                      <svg class=\"w-4 h-4 mr-1\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M3 19v-8.93a2 2 0 01.89-1.664l7-4.666a2 2 0 012.22 0l7 4.666A2 2 0 0121 10.07V19M3 19a2 2 0 002 2h14a2 2 0 002-2M3 19l6.75-4.5M21 19l-6.75-4.5M3 10l6.75 4.5M21 10l-6.75 4.5m0 0l-1.14.76a2 2 0 01-2.22 0l-1.14-.76\" />\n                      </svg>\n                      测试\n                    </button>\n                  </div>\n                </div>\n\n                <div class=\"grid grid-cols-2 gap-3 mt-2\">\n                  <div class=\"space-y-3\">\n                    <div>\n                      <label class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1\">SMTP服务器</label>\n                      <input\n                        v-model=\"emailConfig.smtp_server\"\n                        type=\"text\"\n                        class=\"block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 shadow-sm focus:border-[#fb7299] focus:ring-[#fb7299] sm:text-sm\"\n                        placeholder=\"smtp.qq.com\"\n                      />\n                    </div>\n                    <div>\n                      <label class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1\">发件人邮箱</label>\n                      <input\n                        v-model=\"emailConfig.sender\"\n                        type=\"email\"\n                        class=\"block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 shadow-sm focus:border-[#fb7299] focus:ring-[#fb7299] sm:text-sm\"\n                        placeholder=\"example@qq.com\"\n                      />\n                    </div>\n                    <div>\n                      <label class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1\">收件人邮箱</label>\n                      <input\n                        v-model=\"emailConfig.receiver\"\n                        type=\"email\"\n                        class=\"block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 shadow-sm focus:border-[#fb7299] focus:ring-[#fb7299] sm:text-sm\"\n                        placeholder=\"receiver@qq.com\"\n                      />\n                    </div>\n                  </div>\n                  <div class=\"space-y-3\">\n                    <div>\n                      <label class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1\">SMTP端口</label>\n                      <input\n                        v-model.number=\"emailConfig.smtp_port\"\n                        type=\"number\"\n                        class=\"block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 shadow-sm focus:border-[#fb7299] focus:ring-[#fb7299] sm:text-sm\"\n                        placeholder=\"587\"\n                      />\n                    </div>\n                    <div>\n                      <label class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1\">邮箱授权码</label>\n                      <input\n                        v-model=\"emailConfig.password\"\n                        type=\"password\"\n                        class=\"block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 shadow-sm focus:border-[#fb7299] focus:ring-[#fb7299] sm:text-sm\"\n                        placeholder=\"授权码\"\n                      />\n                    </div>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </section>\n\n          <!-- DeepSeek配置 -->\n          <section v-if=\"activeTab === 'ai'\">\n            <div class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700\">\n              <!-- DeepSeek API密钥 -->\n              <div class=\"p-4 transition-colors duration-200 hover:bg-gray-50 dark:hover:bg-gray-700\">\n                <div class=\"flex items-center justify-between mb-3\">\n                  <div>\n                    <h3 class=\"text-base font-medium text-gray-900 dark:text-gray-100\">DeepSeek API密钥</h3>\n                    <p class=\"text-sm text-gray-500 dark:text-gray-400\">配置DeepSeek API密钥，用于AI摘要生成</p>\n                  </div>\n                  <!-- API密钥状态显示 -->\n                  <div v-if=\"deepseekApiKeyStatus.is_set\" class=\"flex items-center space-x-1 px-2 py-1 rounded-full text-xs\"\n                       :class=\"deepseekApiKeyStatus.is_valid ? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300 border border-green-200 dark:border-green-800/60' : 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-300 border border-red-200 dark:border-red-800/60'\">\n                    <svg v-if=\"deepseekApiKeyStatus.is_valid\" class=\"w-3.5 h-3.5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                      <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 13l4 4L19 7\" />\n                    </svg>\n                    <svg v-else class=\"w-3.5 h-3.5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                      <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z\" />\n                    </svg>\n                    <span>{{ deepseekApiKeyStatus.message }}</span>\n                  </div>\n                </div>\n                <div class=\"flex space-x-2\">\n                  <input\n                    v-model=\"deepseekApiKey\"\n                    type=\"password\"\n                    class=\"flex-1 block rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 shadow-sm focus:border-[#4D6BFE] focus:ring-[#4D6BFE] sm:text-sm\"\n                    :placeholder=\"deepseekApiKeyStatus.is_valid ? '已配置有效的API密钥，如需更换请输入新的密钥' : '请输入DeepSeek API密钥'\"\n                  />\n                  <button\n                    @click=\"saveDeepSeekApiKey\"\n                    class=\"inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-[#4D6BFE] rounded-lg hover:bg-[#4D6BFE]/90\"\n                  >\n                    {{ deepseekApiKeyStatus.is_valid ? '更新' : '保存' }}\n                  </button>\n                </div>\n              </div>\n\n              <!-- DeepSeek余额信息 -->\n              <div class=\"p-4 transition-colors duration-200 hover:bg-gray-50 dark:hover:bg-gray-700\">\n                <div class=\"flex items-center justify-between\">\n                  <div>\n                    <h3 class=\"text-base font-medium text-gray-900 dark:text-gray-100\">DeepSeek余额</h3>\n                    <p class=\"text-sm text-gray-500 dark:text-gray-400\">查询DeepSeek账户余额信息</p>\n                  </div>\n                  <div class=\"flex items-center space-x-2\">\n                    <button\n                      @click=\"refreshDeepSeekBalance\"\n                      class=\"inline-flex items-center px-3 py-1.5 text-sm font-medium text-[#4D6BFE] bg-[#4D6BFE]/5 dark:bg-[#4D6BFE]/10 rounded-lg hover:bg-[#4D6BFE]/10 dark:hover:bg-[#4D6BFE]/20\"\n                    >\n                      <svg class=\"w-4 h-4 mr-1\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15\" />\n                      </svg>\n                      刷新\n                    </button>\n                  </div>\n                </div>\n\n                <!-- 余额信息显示 -->\n                <div v-if=\"deepseekBalance.is_available\" class=\"mt-3 p-3 bg-[#4D6BFE]/5 rounded-lg\">\n                  <div v-for=\"(balance, index) in deepseekBalance.balance_infos\" :key=\"index\" class=\"flex items-center justify-between\">\n                    <div class=\"text-sm text-gray-700 dark:text-gray-300\">\n                      <span class=\"font-medium\">{{ balance.currency }}</span> 余额:\n                    </div>\n                    <div class=\"text-sm font-medium text-[#4D6BFE]\">\n                      {{ balance.total_balance }}\n                    </div>\n                  </div>\n                  <div class=\"mt-2 text-xs text-gray-500\">\n                    <div v-for=\"(balance, index) in deepseekBalance.balance_infos\" :key=\"'detail-'+index\">\n                      <div class=\"flex justify-between\">\n                        <span>赠送余额:</span>\n                        <span>{{ balance.granted_balance }}</span>\n                      </div>\n                      <div class=\"flex justify-between\">\n                        <span>充值余额:</span>\n                        <span>{{ balance.topped_up_balance }}</span>\n                      </div>\n                    </div>\n                  </div>\n                </div>\n\n                <!-- 未查询或查询失败状态 -->\n                <div v-else class=\"mt-3 p-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm text-gray-500 dark:text-gray-400 text-center\">\n                  {{ deepseekBalanceMessage || '点击刷新按钮查询余额' }}\n                </div>\n              </div>\n            </div>\n\n            <!-- AI摘要配置 -->\n            <div class=\"mt-4\">\n              <div class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700\">\n                <div class=\"p-4\">\n                  <h3 class=\"text-base font-medium text-gray-900 dark:text-gray-100 mb-2\">AI摘要配置</h3>\n                  <SummaryConfig />\n                </div>\n              </div>\n            </div>\n          </section>\n\n          <!-- 数据管理 -->\n          <section v-if=\"activeTab === 'data'\">\n            <div class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700\">\n              <!-- 数据导出 -->\n              <div class=\"p-4 transition-colors duration-200 hover:bg-gray-50 dark:hover:bg-gray-700\">\n                <div>\n                  <div class=\"flex items-center justify-between mb-4\">\n                    <div>\n                      <h3 class=\"text-base font-medium text-gray-900 dark:text-gray-100\">数据导出</h3>\n                      <p class=\"text-sm text-gray-500 dark:text-gray-400\">导出历史记录数据到Excel文件，支持按年份、月份或日期范围导出</p>\n                    </div>\n                  </div>\n\n                  <!-- 导出选项 -->\n                  <div class=\"bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 rounded-lg\">\n                    <div class=\"flex flex-wrap items-end gap-4\">\n                      <!-- 年份选择 (始终显示) -->\n                      <div class=\"w-32\">\n                        <label class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1\">年份</label>\n                        <select\n                          v-model=\"exportOptions.year\"\n                          class=\"block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 shadow-sm focus:border-[#fb7299] focus:ring-[#fb7299] sm:text-sm\"\n                        >\n                          <option v-for=\"year in availableYears\" :key=\"year\" :value=\"year\">\n                            {{ year }}年\n                          </option>\n                        </select>\n                      </div>\n\n                      <!-- 导出类型选择 -->\n                      <div class=\"w-40\">\n                        <label class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1\">导出类型</label>\n                        <select\n                          v-model=\"exportOptions.exportType\"\n                          class=\"block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 shadow-sm focus:border-[#fb7299] focus:ring-[#fb7299] sm:text-sm\"\n                        >\n                          <option value=\"year\">全年数据</option>\n                          <option value=\"month\">按月份</option>\n                          <option value=\"dateRange\">按日期范围</option>\n                        </select>\n                      </div>\n\n                      <!-- 按月选择框 -->\n                      <div v-if=\"exportOptions.exportType === 'month'\" class=\"w-24\">\n                        <label class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1\">月份</label>\n                        <select\n                          v-model=\"exportOptions.month\"\n                          class=\"block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 shadow-sm focus:border-[#fb7299] focus:ring-[#fb7299] sm:text-sm\"\n                        >\n                          <option v-for=\"month in 12\" :key=\"month\" :value=\"month\">\n                            {{ month }}月\n                          </option>\n                        </select>\n                      </div>\n\n                      <!-- 日期范围选择框 -->\n                      <template v-if=\"exportOptions.exportType === 'dateRange'\">\n                        <div class=\"w-40\">\n                          <label class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1\">开始日期</label>\n                          <input\n                            type=\"date\"\n                            v-model=\"exportOptions.startDate\"\n                            class=\"block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 shadow-sm focus:border-[#fb7299] focus:ring-[#fb7299] sm:text-sm\"\n                          />\n                        </div>\n\n                        <div class=\"w-40\">\n                          <label class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1\">结束日期</label>\n                          <input\n                            type=\"date\"\n                            v-model=\"exportOptions.endDate\"\n                            class=\"block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 shadow-sm focus:border-[#fb7299] focus:ring-[#fb7299] sm:text-sm\"\n                          />\n                        </div>\n                      </template>\n\n                      <!-- 导出按钮 -->\n                      <button\n                        @click=\"exportAndDownloadExcel\"\n                        :disabled=\"isExporting\"\n                        class=\"inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-[#fb7299] rounded-lg hover:bg-[#fb7299]/90 disabled:opacity-50 h-10\"\n                      >\n                        <svg v-if=\"isExporting\" class=\"animate-spin -ml-1 mr-2 h-4 w-4\" fill=\"none\" viewBox=\"0 0 24 24\">\n                          <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n                          <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n                        </svg>\n                        {{ isExporting ? '导出中...' : '导出Excel' }}\n                      </button>\n                    </div>\n                  </div>\n                </div>\n              </div>\n\n              <!-- 数据库下载 -->\n              <div class=\"p-4 transition-colors duration-200 hover:bg-gray-50 dark:hover:bg-gray-700\">\n                <div class=\"flex items-center justify-between\">\n                  <div>\n                    <h3 class=\"text-base font-medium text-gray-900 dark:text-gray-100\">数据库下载</h3>\n                    <p class=\"text-sm text-gray-500 dark:text-gray-400\">下载完整的SQLite数据库文件，包含所有历史记录数据</p>\n                  </div>\n                  <button\n                    @click=\"downloadSqlite\"\n                    class=\"inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-[#fb7299] rounded-lg hover:bg-[#fb7299]/90\"\n                  >\n                    <svg class=\"mr-2 h-4 w-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                      <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4\" />\n                    </svg>\n                    下载SQLite数据库\n                  </button>\n                </div>\n              </div>\n            </div>\n\n            <!-- 危险操作 -->\n            <div class=\"mt-4\">\n              <div class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700\">\n                <div class=\"p-4 transition-colors duration-200 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg\">\n                  <h3 class=\"text-base font-medium text-gray-900 dark:text-gray-100 mb-2\">危险操作</h3>\n                  <div class=\"flex items-center justify-between p-2 border border-red-100 dark:border-red-900/40 rounded-lg bg-red-50 dark:bg-red-900/10\">\n                    <div>\n                      <h4 class=\"text-sm font-medium text-red-700 dark:text-red-300\">数据库重置</h4>\n                      <p class=\"text-xs text-red-600 dark:text-red-300\">删除现有数据库并重新导入数据（此操作不可逆）</p>\n                    </div>\n                    <button\n                      @click=\"handleResetDatabase\"\n                      class=\"inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-red-500 rounded-lg hover:bg-red-600\"\n                    >\n                      <svg class=\"mr-1.5 h-4 w-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16\" />\n                      </svg>\n                      重置数据库\n                    </button>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </section>\n\n\n\n          <!-- 关于页面 -->\n          <section v-if=\"activeTab === 'about'\">\n            <div class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6\">\n              <!-- 页面标题 -->\n              <div class=\"mb-6\">\n                <h1 class=\"text-2xl font-bold text-gray-800 dark:text-gray-100 flex items-center\">\n                  <span class=\"bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent\">关于本项目</span>\n                </h1>\n                <p class=\"text-gray-500 dark:text-gray-400 mt-2\">哔哩哔哩历史记录管理与分析工具</p>\n              </div>\n\n              <!-- 项目介绍卡片 -->\n              <div class=\"mb-6\">\n                <h2 class=\"text-xl font-medium text-gray-800 dark:text-gray-100 mb-4 flex items-center\">\n                  <svg class=\"w-5 h-5 mr-2 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n                  </svg>\n                  项目简介\n                </h2>\n                <div class=\"text-gray-600 dark:text-gray-300 space-y-3\">\n                  <p>\n                    此项目是一个哔哩哔哩历史记录管理与分析工具，帮助用户更好地管理和分析自己的B站观看历史。基于Vue 3构建，通过现代的界面设计提供强大的功能，包括历史记录查询、视频下载、数据分析等多项功能。\n                  </p>\n\n                  <div class=\"mt-4 space-y-3\">\n                    <div class=\"flex items-center\">\n                      <svg class=\"w-5 h-5 mr-2 text-[#fb7299]\" fill=\"currentColor\" viewBox=\"0 0 24 24\">\n                        <path fill-rule=\"evenodd\" d=\"M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z\" clip-rule=\"evenodd\" />\n                      </svg>\n                      <span class=\"text-gray-500 w-24 flex-shrink-0\">前端项目</span>\n                      <a href=\"https://github.com/2977094657/BiliHistoryFrontend\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-[#fb7299] hover:underline break-all\">https://github.com/2977094657/BiliHistoryFrontend</a>\n                    </div>\n                    <div class=\"flex items-center\">\n                      <svg class=\"w-5 h-5 mr-2 text-[#fb7299]\" fill=\"currentColor\" viewBox=\"0 0 24 24\">\n                        <path fill-rule=\"evenodd\" d=\"M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z\" clip-rule=\"evenodd\" />\n                      </svg>\n                      <span class=\"text-gray-500 w-24 flex-shrink-0\">后端项目</span>\n                      <a href=\"https://github.com/2977094657/BilibiliHistoryFetcher\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-[#fb7299] hover:underline break-all\">https://github.com/2977094657/BilibiliHistoryFetcher</a>\n                    </div>\n                  </div>\n                </div>\n              </div>\n\n              <!-- 技术致谢卡片 -->\n              <div>\n                <h2 class=\"text-xl font-medium text-gray-800 dark:text-gray-100 mb-4 flex items-center\">\n                  <svg class=\"w-5 h-5 mr-2 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10\" />\n                  </svg>\n                  技术致谢\n                </h2>\n\n                <div class=\"text-gray-600 dark:text-gray-300 space-y-4 mt-4\">\n                  <ul class=\"list-disc pl-5 space-y-2\">\n                    <li><a href=\"https://github.com/SocialSisterYi/bilibili-API-collect\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-[#fb7299] hover:underline\">bilibili-API-collect</a> - 没有它就没有这个项目</li>\n                    <li><a href=\"https://yutto.nyakku.moe/\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-[#fb7299] hover:underline\">Yutto</a> - 可爱的B站视频下载工具</li>\n                    <li><a href=\"https://github.com/SYSTRAN/faster-whisper\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-[#fb7299] hover:underline\">FasterWhisper</a> - 音频转文字</li>\n                    <li><a href=\"https://github.com/deepseek-ai/DeepSeek-R1\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-[#fb7299] hover:underline\">DeepSeek</a> - DeepSeek AI API</li>\n                    <li><a href=\"https://github.com/zhw2590582/ArtPlayer\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-[#fb7299] hover:underline\">ArtPlayer</a> - 强大且灵活的HTML5视频播放器</li>\n                    <li><a href=\"https://www.aicu.cc/\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-[#fb7299] hover:underline\">aicu.cc</a> - 第三方B站用户评论API</li>\n                    <li>\n                      <div class=\"flex items-center\">\n                        <a href=\"https://www.xiaoheihe.cn/app/bbs/link/153880174\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"flex items-center hover:opacity-80 transition-opacity mr-1.5\">\n                          <span class=\"text-[#fb7299] hover:underline\">shengyI</span>\n                        </a>\n                        - 视频观看总时长功能思路提供者\n                      </div>\n                    </li>\n                    <li>所有贡献者</li>\n                  </ul>\n                </div>\n              </div>\n            </div>\n          </section>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted, watch, onUnmounted } from 'vue'\nimport { showNotify } from 'vant'\nimport 'vant/es/notify/style'\nimport 'vant/es/toast/style'\nimport 'vant/es/dialog/style'\nimport {\n  exportHistory,\n  downloadExcelFile,\n  downloadDatabase,\n  resetDatabase,\n  getAvailableYears,\n  importSqliteData,\n  getEmailConfig,\n  updateEmailConfig,\n  testEmailConfig as testEmailApi,\n  checkDeepSeekApiKey,\n  setDeepSeekApiKey,\n  getDeepSeekBalance,\n  getIntegrityCheckConfig,\n  updateIntegrityCheckConfig\n} from '../../api/api'\nimport { setBaseUrl, getCurrentBaseUrl } from '../../api/api'\nimport { usePrivacyStore } from '../../store/privacy'\nimport { showDialog } from 'vant'\nimport SummaryConfig from './SummaryConfig.vue'\nimport { useRoute } from 'vue-router'\nimport privacyManager from '../../utils/privacyManager'\n\n// 设置选项卡\nconst settingTabs = [\n  {\n    key: 'basic',\n    label: '基础设置',\n    icon: '<svg class=\"text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2m-2-4h.01M17 16h.01\" /></svg>'\n  },\n  {\n    key: 'ai',\n    label: 'AI与摘要',\n    icon: '<svg class=\"text-[#4D6BFE]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z\" /></svg>'\n  },\n  {\n    key: 'data',\n    label: '数据管理',\n    icon: '<svg class=\"text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4\" /></svg>'\n  },\n  {\n    key: 'about',\n    label: '关于',\n    icon: '<svg class=\"text-[#4D6BFE]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" /></svg>'\n  }\n]\n\nconst route = useRoute()\nconst activeTab = ref('basic')\n\n// 监听路由参数变化，切换标签页\nwatch(() => route.query.tab, (newTab) => {\n  if (newTab && settingTabs.some(tab => tab.key === newTab)) {\n    activeTab.value = newTab\n  }\n}, { immediate: true })\n\nconst availableYears = ref([])\nconst isExporting = ref(false)\n\n// 导出选项\nconst exportOptions = ref({\n  year: new Date().getFullYear(),\n  month: null,\n  startDate: '',\n  endDate: '',\n  exportType: 'year' // 默认导出全年数据\n})\nconst serverUrl = ref('')\nconst useLocalImages = ref(localStorage.getItem('useLocalImages') === 'true')\nconst DEFAULT_EMAIL_CONFIG = {\n  smtp_server: 'smtp.qq.com',\n  smtp_port: 587,\n  sender: '',\n  password: '',\n  receiver: ''\n}\nconst emailConfig = ref({ ...DEFAULT_EMAIL_CONFIG })\n\n// DeepSeek相关状态\nconst deepseekApiKey = ref('')\nconst deepseekApiKeyStatus = ref({\n  is_set: false,\n  is_valid: false,\n  message: ''\n})\nconst deepseekBalance = ref({\n  is_available: false,\n  balance_infos: []\n})\nconst deepseekBalanceMessage = ref('')\n\n// 隐私模式\nconst { isPrivacyMode, setPrivacyMode } = usePrivacyStore()\nconst privacyMode = ref(isPrivacyMode.value)\n\n// 同步已删除记录\nconst syncDeleted = ref(localStorage.getItem('syncDeleted') === 'true')\n\n// 监听同步已删除记录变化\nwatch(syncDeleted, (newVal) => {\n  localStorage.setItem('syncDeleted', newVal.toString())\n  showNotify({\n    type: 'success',\n    message: newVal ? '已开启同步已删除记录' : '已关闭同步已删除记录'\n  })\n})\n\n// 同步删除B站历史记录\nconst syncDeleteToBilibili = ref(localStorage.getItem('syncDeleteToBilibili') === 'true')\n\n// 处理同步删除B站历史记录变更\nconst handleSyncDeleteToBilibiliChange = () => {\n  localStorage.setItem('syncDeleteToBilibili', syncDeleteToBilibili.value.toString())\n  showNotify({\n    type: 'success',\n    message: syncDeleteToBilibili.value ? '已开启同步删除B站历史记录' : '已关闭同步删除B站历史记录'\n  })\n}\n\n// 启动时数据完整性校验\nconst checkIntegrityOnStartup = ref(true)\n\n// 处理数据完整性校验设置变更\nconst handleIntegrityCheckChange = async () => {\n  try {\n    const response = await updateIntegrityCheckConfig(checkIntegrityOnStartup.value)\n    if (response.data && response.data.success) {\n      showNotify({\n        type: 'success',\n        message: checkIntegrityOnStartup.value ? '已开启启动时数据完整性校验' : '已关闭启动时数据完整性校验'\n      })\n    } else {\n      throw new Error(response.data?.message || '更新配置失败')\n    }\n  } catch (error) {\n    console.error('更新数据完整性校验配置失败:', error)\n    showNotify({\n      type: 'danger',\n      message: `更新配置失败: ${error.message}`\n    })\n    // 恢复原值\n    checkIntegrityOnStartup.value = !checkIntegrityOnStartup.value\n  }\n}\n\n// 首页默认布局设置 - 网格布局或列表布局\nconst isGridLayout = ref(localStorage.getItem('defaultLayout') === 'list' ? false : true) // 默认为网格视图\n\n\n\n\n\n// 处理布局变更\nconst handleLayoutChange = () => {\n  // 更新localStorage，保存用户选择的布局模式\n  const newLayout = isGridLayout.value ? 'grid' : 'list'\n  localStorage.setItem('defaultLayout', newLayout)\n\n  // 触发全局事件，通知其他组件更新布局\n  try {\n    const event = new CustomEvent('layout-setting-changed', {\n      detail: { layout: newLayout }\n    })\n    window.dispatchEvent(event)\n    console.log('已触发布局设置更新事件:', newLayout)\n  } catch (error) {\n    console.error('触发布局设置更新事件失败:', error)\n  }\n\n  showNotify({\n    type: 'success',\n    message: `已切换到${isGridLayout.value ? '网格' : '列表'}视图`\n  })\n}\n\n// 监听隐私模式变化\nwatch(privacyMode, (newVal) => {\n  // 更新store中的隐私模式状态\n  setPrivacyMode(newVal)\n\n  // 更新localStorage中的隐私模式状态并触发自定义事件\n  if (newVal) {\n    privacyManager.enable()\n\n  } else {\n    privacyManager.disable()\n  }\n})\n\n// 侧边栏显示设置\nconst showSidebar = ref(localStorage.getItem('showSidebar') !== 'false') // 默认为true\n\n// 处理侧边栏设置变更\nconst handleSidebarChange = () => {\n  localStorage.setItem('showSidebar', showSidebar.value.toString())\n\n  // 触发全局事件，通知侧边栏组件更新设置\n  try {\n    const event = new CustomEvent('sidebar-setting-changed', {\n      detail: { showSidebar: showSidebar.value }\n    })\n    window.dispatchEvent(event)\n    console.log('已触发侧边栏设置更新事件:', showSidebar.value)\n  } catch (error) {\n    console.error('触发侧边栏设置更新事件失败:', error)\n  }\n\n  showNotify({\n    type: 'success',\n    message: `已${showSidebar.value ? '启用' : '禁用'}侧边栏显示`\n  })\n}\n\n// 初始化服务器地址\nonMounted(async () => {\n  console.log('Settings组件开始挂载')\n\n  // 添加隐私模式监听器\n  privacyManager.addListener((isEnabled) => {\n    console.log('Settings组件接收到隐私模式变化:', isEnabled)\n\n    // 更新组件内的隐私模式状态\n    if (privacyMode.value !== isEnabled) {\n      privacyMode.value = isEnabled\n    }\n\n  })\n\n  // 同步当前隐私模式状态\n  const currentPrivacyMode = privacyManager.isEnabled()\n  if (privacyMode.value !== currentPrivacyMode) {\n    privacyMode.value = currentPrivacyMode\n  }\n\n\n  try {\n    serverUrl.value = getCurrentBaseUrl()\n    console.log('当前服务器地址:', serverUrl.value)\n\n\n    // 监听侧边栏切换事件\n    window.addEventListener('sidebar-toggle-changed', handleSidebarToggleEvent)\n\n    // 监听布局切换事件\n    window.addEventListener('layout-changed', handleLayoutChangedEvent)\n\n    // 获取可用年份数据\n    await getAvailableYears().then(response => {\n      if (response.data.status === 'success') {\n        availableYears.value = response.data.data\n        if (availableYears.value.length > 0) {\n          exportOptions.value.year = availableYears.value[0]\n        }\n      }\n    }).catch(error => {\n      console.error('获取可用年份失败:', error)\n    })\n\n    await Promise.all([\n      (async () => {\n        console.log('开始初始化邮件配置')\n        await initEmailConfig()\n        console.log('邮件配置初始化完成')\n      })(),\n      (async () => {\n        console.log('开始获取可用年份')\n        try {\n          const response = await getAvailableYears()\n          console.log('获取年份响应:', response.data)\n          if (response.data.status === 'success') {\n            availableYears.value = response.data.data.sort((a, b) => b - a)\n            if (availableYears.value.length > 0) {\n              // 设置导出选项的年份\n              exportOptions.value.year = availableYears.value[0]\n            }\n            console.log('获取可用年份成功:', availableYears.value)\n          } else {\n            throw new Error(response.data.message || '获取年份列表失败')\n          }\n        } catch (error) {\n          console.error('获取可用年份失败:', error)\n          showNotify({\n            type: 'danger',\n            message: '获取年份列表失败'\n          })\n          // 设置当前年份作为默认值\n          const currentYear = new Date().getFullYear()\n          availableYears.value = [currentYear]\n\n          // 重置导出选项\n          exportOptions.value = {\n            year: currentYear,\n            month: null,\n            startDate: '',\n            endDate: '',\n            exportType: 'year'\n          }\n        }\n      })(),\n      (async () => {\n        console.log('开始获取DeepSeek配置')\n        await checkDeepSeekApiKeyStatus()\n        await refreshDeepSeekBalance()\n        console.log('DeepSeek配置获取完成')\n      })(),\n      (async () => {\n        console.log('开始获取数据完整性校验配置')\n        try {\n          const response = await getIntegrityCheckConfig()\n          if (response.data && response.data.success) {\n            checkIntegrityOnStartup.value = response.data.check_on_startup\n            console.log('数据完整性校验配置获取成功:', checkIntegrityOnStartup.value)\n          } else {\n            throw new Error(response.data?.message || '获取配置失败')\n          }\n        } catch (error) {\n          console.error('获取数据完整性校验配置失败:', error)\n          // 使用默认值\n          checkIntegrityOnStartup.value = true\n        }\n        console.log('数据完整性校验配置获取完成')\n      })()\n    ])\n    console.log('Settings组件初始化完成')\n  } catch (error) {\n    console.error('Settings组件初始化失败:', error)\n  }\n})\n\n// 导出并下载Excel\nconst exportAndDownloadExcel = async () => {\n  if (isExporting.value) return\n\n  try {\n    // 准备导出参数\n    let exportParams = {}\n\n    // 根据导出类型设置参数\n    switch (exportOptions.value.exportType) {\n      case 'month':\n        // 检查月份是否选择\n        if (!exportOptions.value.month) {\n          showNotify({\n            type: 'danger',\n            message: '请选择要导出的月份'\n          })\n          return\n        }\n        exportParams = {\n          year: exportOptions.value.year,\n          month: exportOptions.value.month\n        }\n        break\n\n      case 'dateRange':\n        // 验证日期范围\n        if (!exportOptions.value.startDate || !exportOptions.value.endDate) {\n          showNotify({\n            type: 'danger',\n            message: '请选择完整的日期范围'\n          })\n          return\n        }\n\n        const startDate = new Date(exportOptions.value.startDate)\n        const endDate = new Date(exportOptions.value.endDate)\n        if (startDate > endDate) {\n          showNotify({\n            type: 'danger',\n            message: '开始日期不能晚于结束日期'\n          })\n          return\n        }\n\n        // 只传递日期范围参数，不传递年份参数\n        exportParams = {\n          start_date: exportOptions.value.startDate,\n          end_date: exportOptions.value.endDate\n        }\n        break\n\n      case 'year':\n      default:\n        // 全年数据，只需要year参数\n        exportParams = {\n          year: exportOptions.value.year\n        }\n        break\n    }\n\n    isExporting.value = true\n    showNotify({\n      type: 'primary',\n      message: '正在导出数据...'\n    })\n\n    console.log('导出选项:', exportParams)\n    const response = await exportHistory(exportParams)\n    console.log('导出响应:', response.data)\n\n    if (response.data.status === 'success') {\n      showNotify({\n        type: 'success',\n        message: '导出成功，准备下载...'\n      })\n\n      // 使用响应中的文件名\n      const filename = response.data.filename\n      console.log('准备下载文件:', filename)\n      await downloadExcelFile(filename)\n      showNotify({\n        type: 'success',\n        message: '下载完成'\n      })\n    } else {\n      throw new Error(response.data.message)\n    }\n  } catch (error) {\n    console.error('导出错误:', error)\n    let errorMessage = error.message\n\n    // 尝试获取服务器返回的错误信息\n    if (error.response && error.response.data) {\n      if (error.response.data.detail) {\n        errorMessage = error.response.data.detail\n      } else if (typeof error.response.data === 'string') {\n        errorMessage = error.response.data\n      }\n    }\n\n    showNotify({\n      type: 'danger',\n      message: `操作失败：${errorMessage}`\n    })\n  } finally {\n    isExporting.value = false\n  }\n}\n\n// 下载SQLite数据库\nconst downloadSqlite = async () => {\n  try {\n    await downloadDatabase()\n  } catch (error) {\n    showNotify({\n      type: 'danger',\n      message: `下载失败：${error.message}`\n    })\n  }\n}\n\n// 保存服务器地址\nconst saveServerUrl = () => {\n  try {\n    // 简单的URL格式验证\n    const url = new URL(serverUrl.value)\n    setBaseUrl(serverUrl.value)\n    showNotify({\n      type: 'success',\n      message: '服务器地址已更新，页面即将刷新'\n    })\n  } catch (error) {\n    showNotify({\n      type: 'danger',\n      message: '请输入有效的URL地址'\n    })\n  }\n}\n\n// 在script setup部分添加重置功能\nconst FALLBACK_DEFAULT_SERVER_URL = 'http://localhost:8899';\nconst DEFAULT_SERVER_URL = import.meta.env.VITE_DEFAULT_BACKEND_URL || FALLBACK_DEFAULT_SERVER_URL;\n\n// 重置服务器地址\nconst resetServerUrl = () => {\n  showDialog({\n    title: '重置服务器地址',\n    message: '确定要将服务器地址重置为默认值吗？',\n    showCancelButton: true,\n    confirmButtonText: '确定',\n    cancelButtonText: '取消',\n    confirmButtonColor: '#fb7299'\n  }).then((result) => {\n    if (result === 'confirm') {\n      serverUrl.value = DEFAULT_SERVER_URL\n      setBaseUrl(DEFAULT_SERVER_URL)\n      showNotify({\n        type: 'success',\n        message: '服务器地址已重置，页面即将刷新'\n      })\n    }\n  })\n}\n\n// 处理数据库重置\nconst handleResetDatabase = () => {\n  showDialog({\n    title: '危险操作确认',\n    message: '此操作将删除现有数据库并重新导入数据。此操作不可逆，确定要继续吗？',\n    showCancelButton: true,\n    confirmButtonText: '确定重置',\n    cancelButtonText: '取消',\n    confirmButtonColor: '#dc2626'\n  }).then(async (result) => {\n    if (result === 'confirm') {\n      try {\n        showNotify({\n          type: 'warning',\n          message: '正在重置数据库...'\n        })\n\n        // 重置数据库\n        const resetResponse = await resetDatabase()\n        if (resetResponse.data.status === 'success') {\n          showNotify({\n            type: 'success',\n            message: '数据库已重置，正在重新导入数据...'\n          })\n\n          // 重新导入数据\n          try {\n            const importResponse = await importSqliteData()\n            if (importResponse.data.status === 'success') {\n              showNotify({\n                type: 'success',\n                message: '数据导入完成，页面即将刷新'\n              })\n              // 等待1秒后刷新页面，确保用户看到成功提示\n              setTimeout(() => {\n                window.location.reload()\n              }, 1000)\n            } else {\n              throw new Error(importResponse.data.message || '数据导入失败')\n            }\n          } catch (importError) {\n            showNotify({\n              type: 'danger',\n              message: `数据导入失败：${importError.message}`\n            })\n          }\n        }\n      } catch (error) {\n        showNotify({\n          type: 'danger',\n          message: `重置失败：${error.message}`\n        })\n      }\n    }\n  })\n}\n\n// 处理图片源变更\nconst handleImageSourceChange = () => {\n  localStorage.setItem('useLocalImages', useLocalImages.value.toString())\n  showNotify({\n    type: 'success',\n    message: `已${useLocalImages.value ? '启用' : '禁用'}本地图片源`\n  })\n  // 刷新页面以应用新设置\n  window.location.reload()\n}\n\n// 初始化邮件配置\nconst initEmailConfig = async () => {\n  try {\n    const response = await getEmailConfig()\n    if (response.data) {  // 直接检查 response.data\n      // 使用解构赋值来更新配置，保留默认值\n      emailConfig.value = {\n        ...DEFAULT_EMAIL_CONFIG,\n        ...response.data  // 直接使用 response.data\n      }\n    } else {\n      emailConfig.value = { ...DEFAULT_EMAIL_CONFIG }\n    }\n  } catch (error) {\n    console.error('获取邮件配置失败:', error)\n    showNotify({\n      type: 'warning',\n      message: '获取邮件配置失败，使用默认配置'\n    })\n    emailConfig.value = { ...DEFAULT_EMAIL_CONFIG }\n  }\n}\n\n// 保存邮件配置\nconst saveEmailConfig = async () => {\n  try {\n    // 验证邮箱格式\n    const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n    if (!emailRegex.test(emailConfig.value.sender)) {\n      throw new Error('发件人邮箱格式不正确')\n    }\n    if (!emailRegex.test(emailConfig.value.receiver)) {\n      throw new Error('收件人邮箱格式不正确')\n    }\n\n    // 验证端口号\n    if (emailConfig.value.smtp_port < 0 || emailConfig.value.smtp_port > 65535) {\n      throw new Error('端口号必须在0-65535之间')\n    }\n\n    const response = await updateEmailConfig(emailConfig.value)\n    if (response.data.status === 'success') {\n      showNotify({\n        type: 'success',\n        message: '邮件配置已保存'\n      })\n    } else {\n      throw new Error(response.data.message || '保存失败')\n    }\n  } catch (error) {\n    showNotify({\n      type: 'danger',\n      message: `保存失败：${error.message}`\n    })\n  }\n}\n\n// 重置邮件配置\nconst resetEmailConfig = () => {\n  showDialog({\n    title: '重置邮件配置',\n    message: '确定要重置邮件配置吗？这将清空所有配置并恢复默认的SMTP服务器和端口设置。',\n    showCancelButton: true,\n    confirmButtonText: '确定',\n    cancelButtonText: '取消',\n    confirmButtonColor: '#fb7299'\n  }).then(async (result) => {\n    if (result === 'confirm') {\n      try {\n        // 完全重置为默认配置\n        const resetConfig = { ...DEFAULT_EMAIL_CONFIG }\n        emailConfig.value = resetConfig\n\n        // 调用后端API保存重置后的配置\n        const response = await updateEmailConfig(resetConfig)\n        if (response.data.status === 'success') {\n          showNotify({\n            type: 'success',\n            message: '邮件配置已重置'\n          })\n        } else {\n          throw new Error(response.data.message || '重置失败')\n        }\n      } catch (error) {\n        showNotify({\n          type: 'danger',\n          message: `重置失败：${error.message}`\n        })\n        // 如果保存失败，重新获取配置\n        await initEmailConfig()\n      }\n    }\n  })\n}\n\n// 检查DeepSeek API密钥状态\nconst checkDeepSeekApiKeyStatus = async () => {\n  try {\n    const response = await checkDeepSeekApiKey()\n    deepseekApiKeyStatus.value = response.data\n  } catch (error) {\n    console.error('检查DeepSeek API密钥状态失败:', error)\n    deepseekApiKeyStatus.value = {\n      is_set: false,\n      is_valid: false,\n      message: '检查API密钥状态失败'\n    }\n  }\n}\n\n// 保存DeepSeek API密钥\nconst saveDeepSeekApiKey = async () => {\n  if (!deepseekApiKey.value) {\n    showNotify({\n      type: 'warning',\n      message: 'API密钥不能为空'\n    })\n    return\n  }\n\n  try {\n    const response = await setDeepSeekApiKey(deepseekApiKey.value)\n    if (response.data.success) {\n      showNotify({\n        type: 'success',\n        message: response.data.message || 'API密钥已保存'\n      })\n      // 清空输入框\n      deepseekApiKey.value = ''\n      // 更新API密钥状态和余额信息\n      await checkDeepSeekApiKeyStatus()\n      await refreshDeepSeekBalance()\n    } else {\n      throw new Error(response.data.message || 'API密钥保存失败')\n    }\n  } catch (error) {\n    showNotify({\n      type: 'danger',\n      message: `保存失败：${error.message || '未知错误'}`\n    })\n  }\n}\n\n// 刷新DeepSeek余额\nconst refreshDeepSeekBalance = async () => {\n  try {\n    deepseekBalanceMessage.value = '正在查询余额...'\n    const response = await getDeepSeekBalance()\n    deepseekBalance.value = response.data\n    deepseekBalanceMessage.value = ''\n  } catch (error) {\n    console.error('获取DeepSeek余额失败:', error)\n    deepseekBalance.value = { is_available: false }\n    deepseekBalanceMessage.value = error.response?.data?.message || '获取余额失败，请检查API密钥是否正确'\n  }\n}\n\n// 检查邮件配置是否完整\nconst isEmailConfigComplete = computed(() => {\n  return emailConfig.value.smtp_server &&\n         emailConfig.value.smtp_port &&\n         emailConfig.value.sender &&\n         emailConfig.value.password &&\n         emailConfig.value.receiver\n})\n\n// 测试邮件配置\nconst testEmailConfig = async () => {\n  try {\n    if (!isEmailConfigComplete.value) {\n      showNotify({\n        type: 'warning',\n        message: '请先完善邮件配置'\n      })\n      return\n    }\n\n    // 先保存邮件配置\n    try {\n      await saveEmailConfig()\n    } catch (error) {\n      // 如果保存配置失败，则终止测试\n      return\n    }\n\n    showNotify({\n      type: 'primary',\n      message: '正在发送测试邮件...'\n    })\n\n    const testData = {\n      to_email: emailConfig.value.receiver,\n      subject: '测试邮件',\n      content: '这是一封测试邮件，用于验证邮箱配置是否有效。'\n    }\n\n    const response = await testEmailApi(testData)\n    if (response.data.status === 'success') {\n      showNotify({\n        type: 'success',\n        message: '测试邮件发送成功'\n      })\n    } else {\n      throw new Error(response.data.message || '发送失败')\n    }\n  } catch (error) {\n    showNotify({\n      type: 'danger',\n      message: `发送失败：${error.message || '未知错误'}`\n    })\n  }\n}\n\n// 处理侧边栏切换事件\nconst handleSidebarToggleEvent = (event) => {\n  if (event.detail && typeof event.detail.showSidebar === 'boolean') {\n    showSidebar.value = event.detail.showSidebar\n  }\n}\n\n// 在script setup部分添加卸载功能\nonUnmounted(() => {\n  // 移除事件监听\n  window.removeEventListener('sidebar-toggle-changed', handleSidebarToggleEvent)\n  window.removeEventListener('layout-changed', handleLayoutChangedEvent)\n})\n\n// 处理布局变更事件 - 从首页接收的布局变化\nconst handleLayoutChangedEvent = (event) => {\n  if (event.detail && typeof event.detail.layout === 'string') {\n    isGridLayout.value = event.detail.layout === 'grid'\n  }\n}\n</script>\n"
  },
  {
    "path": "src/components/tailwind/Sidebar.vue",
    "content": "<!-- 侧边栏组件 -->\n<template>\n  <div class=\"flex h-screen\">\n    <!-- 左侧导航栏 -->\n    <div :class=\"[\n      'transition-all duration-300 ease-in-out bg-white/10 dark:bg-gray-800/10 backdrop-blur-lg border-r border-gray-200/50 dark:border-gray-700/50 hidden sm:block',\n      isCollapsed ? 'w-10' : 'w-40'\n    ]\">\n      <!-- 侧边栏内容 -->\n      <div class=\"h-full flex flex-col\">\n        <!-- 顶部 Logo -->\n        <div class=\"p-1 border-b border-gray-200/50 dark:border-gray-700/50\">\n          <router-link to=\"/\" class=\"w-full flex justify-center items-center\">\n            <img v-if=\"isCollapsed\" src=\"/logo.svg\" class=\"w-full object-contain\" alt=\"Logo\" />\n            <img v-else src=\"/logo.png\" class=\"w-full object-contain\" alt=\"Logo\" />\n          </router-link>\n        </div>\n\n        <!-- 导航菜单 -->\n        <nav class=\"flex-1 overflow-y-auto py-4 space-y-2\" :class=\"{ 'px-4': !isCollapsed }\">\n          <!-- 历史记录 -->\n          <button\n            @click=\"changeContent('history')\"\n            :title=\"isCollapsed ? '历史记录' : ''\"\n            class=\"w-full flex items-center py-1.5 text-gray-700 dark:text-gray-300 transition-all duration-300 ease-in-out text-sm\"\n            :class=\"[\n              { 'bg-[#fb7299]/10 text-[#fb7299] dark:bg-[#fb7299]/20': currentContent === 'history' && !showRemarks },\n              { 'justify-center': isCollapsed },\n              isCollapsed ? 'px-2' : 'px-3 rounded-lg'\n            ]\"\n          >\n            <svg class=\"w-5 h-5 flex-shrink-0\" :class=\"{ 'mr-3': !isCollapsed }\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z\" />\n            </svg>\n            <span v-show=\"!isCollapsed\" class=\"truncate\">历史记录</span>\n          </button>\n\n          <!-- 我的收藏 -->\n          <router-link\n            to=\"/favorites\"\n            :title=\"isCollapsed ? '我的收藏' : ''\"\n            class=\"flex items-center py-1.5 text-gray-700 dark:text-gray-300 transition-all duration-300 ease-in-out text-sm\"\n            :class=\"[\n              { 'bg-[#fb7299]/10 text-[#fb7299] dark:bg-[#fb7299]/20': currentContent === 'favorites' },\n              { 'justify-center': isCollapsed },\n              isCollapsed ? 'px-2' : 'px-3 rounded-lg'\n            ]\"\n          >\n            <svg class=\"w-5 h-5 flex-shrink-0\" :class=\"{ 'mr-3': !isCollapsed }\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z\" />\n            </svg>\n            <span v-show=\"!isCollapsed\" class=\"truncate\">我的收藏</span>\n          </router-link>\n\n          <!-- 年度总结 -->\n          <router-link\n            to=\"/analytics\"\n            :title=\"isCollapsed ? '年度总结' : ''\"\n            class=\"flex items-center py-1.5 text-gray-700 dark:text-gray-300 transition-all duration-300 ease-in-out text-sm\"\n            :class=\"[\n              { 'bg-[#fb7299]/10 text-[#fb7299] dark:bg-[#fb7299]/20': currentContent === 'analytics' },\n              { 'justify-center': isCollapsed },\n              isCollapsed ? 'px-2' : 'px-3 rounded-lg'\n            ]\"\n          >\n            <svg class=\"w-5 h-5 flex-shrink-0\" :class=\"{ 'mr-3': !isCollapsed }\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z\" />\n            </svg>\n            <span v-show=\"!isCollapsed\" class=\"truncate\">年度总结</span>\n          </router-link>\n\n          <!-- 媒体管理（整合图片管理和视频下载） -->\n          <router-link\n            to=\"/media\"\n            :title=\"isCollapsed ? '媒体管理' : ''\"\n            class=\"flex items-center py-1.5 text-gray-700 dark:text-gray-300 transition-all duration-300 ease-in-out text-sm\"\n            :class=\"[\n              { 'bg-[#fb7299]/10 text-[#fb7299] dark:bg-[#fb7299]/20': currentContent === 'media' },\n              { 'justify-center': isCollapsed },\n              isCollapsed ? 'px-2' : 'px-3 rounded-lg'\n            ]\"\n          >\n            <svg class=\"w-5 h-5 flex-shrink-0\" :class=\"{ 'mr-3': !isCollapsed }\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2\" />\n            </svg>\n            <span v-show=\"!isCollapsed\" class=\"truncate\">媒体管理</span>\n          </router-link>\n\n          <!-- B站助手 -->\n          <router-link\n            to=\"/bili-tools\"\n            :title=\"isCollapsed ? 'B站助手' : ''\"\n            class=\"flex items-center py-1.5 text-gray-700 dark:text-gray-300 transition-all duration-300 ease-in-out text-sm\"\n            :class=\"[\n              { 'bg-[#fb7299]/10 text-[#fb7299] dark:bg-[#fb7299]/20': currentContent === 'bili-tools' },\n              { 'justify-center': isCollapsed },\n              isCollapsed ? 'px-2' : 'px-3 rounded-lg'\n            ]\"\n          >\n            <svg class=\"w-5 h-5 flex-shrink-0\" :class=\"{ 'mr-3': !isCollapsed }\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M13 10V3L4 14h7v7l9-11h-7z\" />\n            </svg>\n            <span v-show=\"!isCollapsed\" class=\"truncate\">B站助手</span>\n          </router-link>\n\n          <!-- 计划任务 -->\n          <router-link\n            to=\"/scheduler\"\n            :title=\"isCollapsed ? '计划任务' : ''\"\n            class=\"flex items-center py-1.5 text-gray-700 dark:text-gray-300 transition-all duration-300 ease-in-out text-sm\"\n            :class=\"[\n              { 'bg-[#fb7299]/10 text-[#fb7299] dark:bg-[#fb7299]/20': currentContent === 'scheduler' },\n              { 'justify-center': isCollapsed },\n              isCollapsed ? 'px-2' : 'px-3 rounded-lg'\n            ]\"\n          >\n            <svg class=\"w-5 h-5 flex-shrink-0\" :class=\"{ 'mr-3': !isCollapsed }\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z\" />\n            </svg>\n            <span v-show=\"!isCollapsed\" class=\"truncate\">计划任务</span>\n          </router-link>\n\n          <!-- 设置 -->\n          <button\n            @click=\"changeContent('settings')\"\n            :title=\"isCollapsed ? '设置' : ''\"\n            class=\"w-full flex items-center py-1.5 text-gray-700 dark:text-gray-300 transition-all duration-300 ease-in-out text-sm\"\n            :class=\"[\n              { 'bg-[#fb7299]/10 text-[#fb7299] dark:bg-[#fb7299]/20': currentContent === 'settings' },\n              { 'justify-center': isCollapsed },\n              isCollapsed ? 'px-2' : 'px-3 rounded-lg'\n            ]\"\n          >\n            <svg class=\"w-5 h-5 flex-shrink-0\" :class=\"{ 'mr-3': !isCollapsed }\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z\" />\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\" />\n            </svg>\n            <span v-show=\"!isCollapsed\" class=\"truncate\">设置</span>\n          </button>\n\n          <!-- 登录状态显示 -->\n          <div\n            @click=\"handleLoginClick\"\n            class=\"w-full flex items-center py-1.5 text-gray-700 dark:text-gray-300 transition-all duration-300 ease-in-out cursor-pointer hover:text-[#fb7299] text-sm\"\n            :class=\"[\n              { 'justify-center': isCollapsed },\n              isCollapsed ? 'px-2' : 'px-3 rounded-lg'\n            ]\"\n          >\n            <!-- 未登录时显示默认图标 -->\n            <svg\n              v-if=\"!isLoggedIn\"\n              class=\"w-5 h-5 flex-shrink-0\"\n              :class=\"{ 'mr-3': !isCollapsed }\"\n              fill=\"none\"\n              viewBox=\"0 0 24 24\"\n              stroke=\"currentColor\"\n            >\n              <path\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                stroke-width=\"2\"\n                d=\"M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z\"\n              />\n            </svg>\n\n            <!-- 已登录时显示用户头像 -->\n            <div\n              v-else\n              class=\"w-5 h-5 flex-shrink-0 rounded-full overflow-hidden\"\n              :class=\"{ 'mr-3': !isCollapsed }\"\n            >\n              <img\n                :src=\"userInfo?.face\"\n                alt=\"用户头像\"\n                class=\"w-full h-full object-cover\"\n                :class=\"{ 'blur-md': isPrivacyMode }\"\n                onerror=\"this.src='/defaultAvatar.png'\"\n              />\n            </div>\n\n            <span\n              v-show=\"!isCollapsed\"\n              class=\"truncate relative\"\n              :class=\"{ 'text-green-500': isLoggedIn }\"\n            >\n              <template v-if=\"isLoggedIn\">\n                {{ isPrivacyMode ? '已登录' : (userInfo?.uname || '已登录') }}\n              </template>\n              <template v-else>\n                未登录\n              </template>\n            </span>\n          </div>\n        </nav>\n\n        <!-- 底部设置区域 -->\n        <div class=\"p-3 border-t border-gray-200/50 dark:border-gray-700/50\">\n          <!-- 服务器状态和数据完整性放在一个容器中，与SQLite版本信息保持一致的边距 -->\n          <div v-if=\"!isCollapsed\" class=\"mt-1 text-[11px] space-y-1 px-2\">\n            <!-- 服务器状态显示 -->\n            <div class=\"flex items-center text-gray-500 dark:text-gray-400\">\n              <div class=\"mr-1\">服务器状态:</div>\n              <div class=\"flex items-center\">\n                <span\n                  class=\"w-1.5 h-1.5 rounded-full mr-1\"\n                  :class=\"serverStatus.isRunning ? 'bg-green-500' : 'bg-red-500'\"\n                ></span>\n                <span :class=\"serverStatus.isRunning ? 'text-green-600' : 'text-red-600'\">\n                  {{ serverStatus.isRunning ? '运行中' : '未连接' }}\n                </span>\n              </div>\n            </div>\n\n            <!-- 数据完整性状态 -->\n            <div class=\"flex items-center text-gray-500 dark:text-gray-400\">\n              <div class=\"mr-1\">数据完整性:</div>\n              <div class=\"flex items-center\">\n                <span\n                  class=\"w-1.5 h-1.5 rounded-full mr-1\"\n                  :class=\"integrityStatus.status === 'consistent' ? 'bg-green-500' :\n                        integrityStatus.status === 'inconsistent' ? 'bg-yellow-500' :\n                        integrityStatus.status === 'disabled' ? 'bg-gray-500' : 'bg-gray-400'\"\n                ></span>\n                <span\n                  class=\"cursor-pointer hover:underline\"\n                  :class=\"integrityStatus.status === 'consistent' ? 'text-green-600' :\n                        integrityStatus.status === 'inconsistent' ? 'text-yellow-600' :\n                        integrityStatus.status === 'disabled' ? 'text-gray-500' : 'text-gray-400'\"\n                  @click=\"openDataSyncManager('integrity')\"\n                >\n                  {{ integrityStatus.status === 'consistent' ? '一致' :\n                    integrityStatus.status === 'inconsistent' ? '不一致' :\n                    integrityStatus.status === 'disabled' ? '未开启' : '未检查' }}\n                </span>\n              </div>\n            </div>\n          </div>\n\n          <!-- SQLite版本显示 -->\n          <div v-if=\"!isCollapsed\" class=\"mt-3 text-xs space-y-1 px-2 text-[11px]\">\n            <div class=\"text-gray-500 dark:text-gray-400\">\n              SQLite版本: {{ sqliteVersion?.sqlite_version || '加载中...' }}\n            </div>\n            <div class=\"text-gray-500 dark:text-gray-400\">\n              数据库大小: {{ sqliteVersion?.database_file?.size_mb?.toFixed(2) || '0' }} MB\n            </div>\n          </div>\n\n          <!-- 收缩按钮 -->\n          <button\n            @click=\"toggleCollapse\"\n            :title=\"isCollapsed ? '展开侧边栏' : '收起侧边栏'\"\n            class=\"mt-3 w-full flex items-center justify-center px-2 py-1.5 text-gray-700 dark:text-gray-300 hover:text-[#fb7299] transition-colors duration-200 text-sm\"\n          >\n            <svg\n              class=\"w-5 h-5 flex-shrink-0 transform transition-transform duration-300\"\n              :class=\"{ 'rotate-180': isCollapsed }\"\n              fill=\"none\"\n              viewBox=\"0 0 24 24\"\n              stroke=\"currentColor\"\n            >\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M11 19l-7-7 7-7m8 14l-7-7 7-7\" />\n            </svg>\n            <span v-show=\"!isCollapsed\" class=\"ml-2 truncate\">收起侧边栏</span>\n          </button>\n        </div>\n      </div>\n    </div>\n\n    <!-- 右侧内容区域 -->\n    <div class=\"flex-1 border-l border-gray-200/50 dark:border-gray-700/50 transition-all duration-300\">\n      <slot></slot>\n    </div>\n  </div>\n\n  <!-- 登录弹窗组件 -->\n  <LoginDialog\n    v-model:show=\"showLoginDialog\"\n    @login-success=\"checkLoginStatus\"\n  />\n</template>\n\n<script setup>\nimport { ref, computed, watch, onMounted, onUnmounted } from 'vue'\nimport { useRoute, useRouter } from 'vue-router'\nimport { usePrivacyStore } from '@/store/privacy.js'\nimport { useDarkMode } from '@/store/darkMode.js'\nimport { getLoginStatus, logout, getSqliteVersion, checkServerHealth, checkDataIntegrity, getIntegrityCheckConfig } from '../../api/api'\nimport { showNotify } from 'vant'\nimport { showDialog } from 'vant'\nimport 'vant/es/notify/style'\nimport 'vant/es/dialog/style'\nimport LoginDialog from './LoginDialog.vue'\n\nconst { isDarkMode } = useDarkMode()\n\nconst route = useRoute()\nconst router = useRouter()\nconst currentRoute = computed(() => route.path)\n\n// 根据当前路由路径计算当前内容\nconst currentContent = computed(() => {\n  const path = route.path\n  if (path.startsWith('/search')) return 'search'\n  if (path.startsWith('/analytics')) return 'analytics'\n  if (path.startsWith('/settings')) return 'settings'\n  if (path.startsWith('/images')) return 'images'\n  if (path.startsWith('/scheduler')) return 'scheduler'\n  if (path.startsWith('/downloads')) return 'downloads'\n  if (path.startsWith('/media')) return 'media'\n  if (path.startsWith('/favorites')) return 'favorites'\n  if (path.startsWith('/bili-tools')) return 'bili-tools'\n  return 'history' // 默认返回历史记录\n})\nconst props = defineProps({\n  showRemarks: {\n    type: Boolean,\n    default: false\n  }\n})\nconst emit = defineEmits(['change-content', 'update:showRemarks'])\n\n// 监听路由变化\nwatch(\n  () => route.path,\n  (path) => {\n    if (path === '/settings') {\n      currentContent.value = 'settings'\n    } else if (path === '/media') {\n      currentContent.value = 'media'\n    } else if (path === '/analytics') {\n      currentContent.value = 'analytics'\n    } else if (path === '/scheduler') {\n      currentContent.value = 'scheduler'\n    } else if (path === '/favorites' || path.startsWith('/favorites/')) {\n      currentContent.value = 'favorites'\n    } else if (path === '/bili-tools') {\n      currentContent.value = 'bili-tools'\n    } else if (path === '/' || path.startsWith('/page/')) {\n      currentContent.value = 'history'\n    }\n  }\n)\n\n// 切换内容\nconst changeContent = (content) => {\n  if (content === 'history') {\n    emit('change-content', content)\n    emit('update:showRemarks', false)\n\n    // 更新路由\n      if (route.path !== '/' && !route.path.startsWith('/page/')) {\n        router.push('/')\n      }\n    } else if (content === 'settings') {\n    emit('change-content', content)\n    emit('update:showRemarks', false)\n      router.push('/settings')\n  }\n}\n\n// 判断是否在历史记录页面（包括分页）\nconst isHistoryPage = computed(() => {\n  return currentRoute.value === '/' || currentRoute.value.startsWith('/page/')\n})\n// 隐私模式状态\nconst { isPrivacyMode } = usePrivacyStore()\n\n// 侧边栏收缩状态\nconst isCollapsed = ref(false)\nconst toggleCollapse = () => {\n  isCollapsed.value = !isCollapsed.value\n  // 保存当前侧边栏状态\n  localStorage.setItem('sidebarCollapsed', isCollapsed.value.toString())\n\n  // 如果用户手动收起侧边栏，将showSidebar设置为false\n  if (isCollapsed.value) {\n    localStorage.setItem('showSidebar', 'false')\n    // 触发全局事件，通知设置组件更新\n    try {\n      const event = new CustomEvent('sidebar-toggle-changed', {\n        detail: { showSidebar: false }\n      })\n      window.dispatchEvent(event)\n    } catch (error) {\n      console.error('触发侧边栏切换事件失败:', error)\n    }\n  } else {\n    // 如果用户展开侧边栏，将showSidebar设置为true\n    localStorage.setItem('showSidebar', 'true')\n    // 触发全局事件，通知设置组件更新\n    try {\n      const event = new CustomEvent('sidebar-toggle-changed', {\n        detail: { showSidebar: true }\n      })\n      window.dispatchEvent(event)\n    } catch (error) {\n      console.error('触发侧边栏切换事件失败:', error)\n    }\n  }\n}\n\n// SQLite版本信息\nconst sqliteVersion = ref({\n  sqlite_version: '',\n  user_version: 0,\n  database_settings: {\n    journal_mode: '',\n    synchronous: 0,\n    legacy_format: null\n  },\n  database_file: {\n    exists: false,\n    size_bytes: 0,\n    size_mb: 0,\n    path: ''\n  }\n})\n\n// 登录相关状态\nconst isLoggedIn = ref(false)\nconst userInfo = ref(null)\nconst showLoginDialog = ref(false)\n\n// 检查登录状态\nconst checkLoginStatus = async () => {\n  try {\n    const response = await getLoginStatus()\n    // 新的API响应格式是 {code: 0, message: \"0\", ttl: 1, data: {...}}\n    // code为0表示请求成功\n    if (response.data && response.data.code === 0) {\n      isLoggedIn.value = response.data.data.isLogin\n      if (isLoggedIn.value) {\n        userInfo.value = response.data.data\n      }\n    }\n  } catch (error) {\n    console.error('获取登录状态失败:', error)\n    isLoggedIn.value = false\n    userInfo.value = null\n  }\n}\n\n// 点击登录状态处理\nconst handleLoginClick = () => {\n  if (!isLoggedIn.value) {\n    showLoginDialog.value = true\n  } else {\n    showDialog({\n      title: '确认退出',\n      message: '确定要退出登录吗？',\n      showCancelButton: true,\n      confirmButtonText: '确认',\n      cancelButtonText: '取消',\n      confirmButtonColor: '#fb7299'\n    }).then(() => {\n      // 点击确认按钮\n      handleLogout()\n    }).catch(() => {\n      // 点击取消按钮，不做任何操作\n    })\n  }\n}\n\n// 处理退出登录\nconst handleLogout = async () => {\n  try {\n    const response = await logout()\n    if (response.data.status === 'success') {\n      showNotify({\n        type: 'success',\n        message: '已成功退出登录'\n      })\n      isLoggedIn.value = false\n      userInfo.value = null\n      // 1秒后刷新页面\n      setTimeout(() => {\n        window.location.reload()\n      }, 1000)\n    } else {\n      throw new Error(response.data.message || '退出登录失败')\n    }\n  } catch (error) {\n    console.error('退出登录失败:', error)\n    showNotify({\n      type: 'danger',\n      message: error.response?.status === 500 ?\n        '服务器错误,请稍后重试' :\n        `退出登录失败: ${error.message}`\n    })\n  }\n}\n\n// 获取SQLite版本\nconst fetchSqliteVersion = async () => {\n  try {\n    const response = await getSqliteVersion()\n    if (response.data.status === 'success') {\n      sqliteVersion.value = response.data.data\n    }\n  } catch (error) {\n    console.error('获取SQLite版本失败:', error)\n    sqliteVersion.value = null\n  }\n}\n\n// 服务器状态相关\nconst serverStatus = ref({\n  isRunning: false,\n  timestamp: '',\n  schedulerStatus: ''\n})\n\n// 数据完整性状态\nconst integrityStatus = ref({\n  status: 'unknown', // 'consistent', 'inconsistent', 'unknown'\n  difference: 0,\n  lastCheck: null\n})\n\n// 开始服务器健康检查\nconst checkServerHealthStatus = async () => {\n  try {\n    const response = await checkServerHealth()\n    if (response.data && response.data.status === 'running') {\n      serverStatus.value = {\n        isRunning: true,\n        timestamp: response.data.timestamp,\n        schedulerStatus: response.data.scheduler_status\n      }\n      return true\n    } else {\n      serverStatus.value.isRunning = false\n      console.error('服务器健康检查失败: 服务器未运行')\n      return false\n    }\n  } catch (error) {\n    console.error('服务器健康检查失败:', error)\n    serverStatus.value.isRunning = false\n    return false\n  }\n}\n\n// 获取数据完整性状态\nconst fetchIntegrityStatus = async () => {\n  try {\n    // 首先获取数据完整性校验配置\n    const configResponse = await getIntegrityCheckConfig()\n\n    // 检查是否启用了数据完整性校验\n    if (configResponse.data && configResponse.data.success) {\n      const checkEnabled = configResponse.data.check_on_startup\n\n      if (!checkEnabled) {\n        // 如果未启用数据完整性校验，显示\"未开启\"状态\n        integrityStatus.value = {\n          status: 'disabled',\n          difference: 0,\n          lastCheck: new Date().toISOString()\n        }\n        return\n      }\n    }\n\n    // 如果启用了数据完整性校验，获取校验结果\n    const response = await checkDataIntegrity('output/bilibili_history.db', 'output/history_by_date', false)\n\n    if (response.data && response.data.success) {\n      // 检查是否有消息提示（可能是配置禁用了校验）\n      if (response.data.message && response.data.message.includes('数据完整性校验已在配置中禁用')) {\n        integrityStatus.value = {\n          status: 'disabled',\n          difference: 0,\n          lastCheck: response.data.timestamp\n        }\n      } else {\n        integrityStatus.value = {\n          status: response.data.difference === 0 ? 'consistent' : 'inconsistent',\n          difference: response.data.difference || 0,\n          lastCheck: response.data.timestamp\n        }\n      }\n    }\n  } catch (error) {\n    console.error('获取完整性状态失败:', error)\n    // 出错时保持当前状态不变\n  }\n}\n\n// 打开数据同步管理器\nconst openDataSyncManager = (tab = null) => {\n  // 使用自定义事件触发全局弹窗\n  const event = new CustomEvent('open-data-sync-manager', {\n    detail: { tab: tab || 'integrity' }\n  })\n  window.dispatchEvent(event)\n}\n\n// 定时器引用\nconst healthCheckTimer = ref(null)\n\n// 设置定期健康检查\nconst setupPeriodicHealthCheck = () => {\n  // 先清除可能存在的定时器\n  if (healthCheckTimer.value) {\n    clearInterval(healthCheckTimer.value)\n  }\n\n  // 每30秒检查一次服务器状态\n  healthCheckTimer.value = setInterval(async () => {\n    await checkServerHealthStatus()\n  }, 30000) // 30秒\n}\n\nonMounted(async () => {\n  // 读取侧边栏设置，默认显示\n  const showSidebar = localStorage.getItem('showSidebar') !== 'false'\n  // 如果设置为不显示侧边栏，则自动收起\n  if (!showSidebar) {\n    isCollapsed.value = true\n  } else {\n    // 否则使用上次保存的状态\n    isCollapsed.value = localStorage.getItem('sidebarCollapsed') === 'true'\n  }\n\n  // 初始时检查登录状态\n  checkLoginStatus()\n  await fetchSqliteVersion()\n\n  // 设置定期健康检查\n  setupPeriodicHealthCheck()\n  checkServerHealthStatus()\n\n  // 添加获取数据完整性状态\n  await fetchIntegrityStatus()\n\n  // 添加全局事件监听器，当登录状态变化时更新侧边栏的登录状态\n  window.addEventListener('login-status-changed', handleLoginStatusChange)\n\n  // 添加侧边栏设置变更事件监听\n  window.addEventListener('sidebar-setting-changed', handleSidebarSettingChange)\n})\n\n// 处理登录状态变化事件\nconst handleLoginStatusChange = (event) => {\n  console.log('侧边栏收到登录状态变化事件，正在更新登录状态...', event.detail)\n\n  // 如果事件中包含用户信息，直接使用\n  if (event.detail && event.detail.isLoggedIn) {\n    isLoggedIn.value = true\n    if (event.detail.userInfo) {\n      userInfo.value = event.detail.userInfo\n      console.log('从事件中获取到用户信息:', userInfo.value)\n    } else {\n      // 如果没有用户信息，则调用API获取\n      checkLoginStatus()\n    }\n  } else {\n    // 如果事件中没有登录状态信息，则调用API获取\n    checkLoginStatus()\n  }\n}\n\n// 处理侧边栏设置变更事件\nconst handleSidebarSettingChange = (event) => {\n  console.log('侧边栏收到设置变更事件', event.detail)\n  if (event.detail && typeof event.detail.showSidebar === 'boolean') {\n    // 如果设置为不显示侧边栏，则自动收起\n    if (!event.detail.showSidebar) {\n      isCollapsed.value = true\n    }\n  }\n}\n\n// 组件卸载时移除事件监听器和清除定时器\nonUnmounted(() => {\n  window.removeEventListener('login-status-changed', handleLoginStatusChange)\n  window.removeEventListener('sidebar-setting-changed', handleSidebarSettingChange)\n\n  // 清除定时器\n  if (healthCheckTimer.value) {\n    clearInterval(healthCheckTimer.value)\n    healthCheckTimer.value = null\n  }\n})\n</script>\n"
  },
  {
    "path": "src/components/tailwind/SimpleSearchBar.vue",
    "content": "<template>\n  <div class=\"relative\">\n    <div class=\"flex h-9 items-center rounded-md border border-gray-300 dark:border-gray-600 bg-transparent focus-within:border-[#fb7299] transition-colors duration-200\">\n      <!-- 搜索图标 -->\n      <div class=\"pl-3 text-gray-400 dark:text-gray-500\">\n        <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-3.5 w-3.5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n          <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z\" />\n        </svg>\n      </div>\n      \n      <!-- 输入框 -->\n      <input\n        :value=\"modelValue\"\n        @input=\"$emit('update:modelValue', $event.target.value)\"\n        type=\"search\"\n        :placeholder=\"placeholder\"\n        class=\"h-full w-full border-none bg-transparent px-2 pr-3 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-0 text-xs leading-none\"\n        @keyup.enter=\"$emit('search')\"\n        @search=\"$emit('search')\"\n      />\n    </div>\n  </div>\n</template>\n\n<script setup>\ndefineProps({\n  modelValue: {\n    type: String,\n    default: ''\n  },\n  placeholder: {\n    type: String,\n    default: '输入关键词搜索...'\n  }\n})\n\ndefineEmits(['update:modelValue', 'search'])\n</script>\n\n<style scoped>\n/* 移除搜索框的默认样式 */\ninput[type=\"search\"]::-webkit-search-decoration,\ninput[type=\"search\"]::-webkit-search-cancel-button,\ninput[type=\"search\"]::-webkit-search-results-button,\ninput[type=\"search\"]::-webkit-search-results-decoration {\n  display: none;\n}\n\n/* 移除输入框的默认focus样式 */\ninput:focus {\n  box-shadow: none !important;\n  outline: none !important;\n}\n</style> "
  },
  {
    "path": "src/components/tailwind/SummaryConfig.vue",
    "content": "<template>\n  <div class=\"p-4\">\n    <div class=\"flex items-center space-x-2 text-gray-900 dark:text-gray-100 mb-2\">\n      <div class=\"p-1.5 bg-[#fb7299]/5 rounded-lg\">\n        <svg class=\"w-5 h-5 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n          <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                d=\"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z\" />\n        </svg>\n      </div>\n      <h2 class=\"text-lg font-medium\">摘要配置</h2>\n    </div>\n    <p class=\"text-sm text-gray-500 dark:text-gray-400 mb-3\">配置AI摘要的缓存策略，优化请求效率和存储空间</p>\n\n    <div v-if=\"loading\" class=\"flex justify-center py-4\">\n      <div class=\"animate-spin h-5 w-5 border-2 border-[#fb7299] border-t-transparent rounded-full\"></div>\n    </div>\n\n    <div v-else-if=\"error\" class=\"text-red-500 text-sm py-2\">\n      {{ error }}\n      <button\n        @click=\"fetchConfig\"\n        class=\"ml-2 text-xs bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded px-2 py-1 border border-gray-200 dark:border-gray-700\"\n      >\n        重试\n      </button>\n    </div>\n\n    <div v-else>\n      <div class=\"flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700\">\n        <span class=\"text-sm font-medium text-gray-700 dark:text-gray-300\">缓存空摘要</span>\n        <label class=\"relative inline-flex items-center cursor-pointer\">\n          <input\n            type=\"checkbox\"\n            v-model=\"config.cache_empty_summary\"\n            class=\"sr-only peer\"\n            @change=\"updateConfig\"\n          >\n          <div\n            class=\"w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#fb7299]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#fb7299]\"></div>\n        </label>\n      </div>\n\n      <div class=\"text-xs text-gray-500 dark:text-gray-400 mt-3\">\n        <p class=\"mb-1\"><strong>提示：</strong></p>\n        <ul class=\"list-disc pl-5 space-y-1\">\n          <li>开启缓存空摘要可减少API请求次数，但会增加数据库存储空间</li>\n          <li>关闭缓存空摘要可减少数据库存储空间，但会增加API请求次数</li>\n        </ul>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, reactive, onMounted } from 'vue'\nimport { getSummaryConfig, updateSummaryConfig } from '../../api/api'\nimport { showNotify } from 'vant'\nimport 'vant/es/notify/style'\n\nconst loading = ref(false)\nconst error = ref(null)\nconst config = reactive({\n  cache_empty_summary: true,\n})\n\n// 获取当前配置\nconst fetchConfig = async () => {\n  loading.value = true\n  error.value = null\n\n  try {\n    const response = await getSummaryConfig()\n    if (response.data) {\n      config.cache_empty_summary = response.data.cache_empty_summary\n    }\n  } catch (err) {\n    error.value = err.response?.data?.detail || err.message || '获取配置失败'\n    console.error('获取摘要配置失败:', err)\n  } finally {\n    loading.value = false\n  }\n}\n\n// 更新配置\nconst updateConfig = async () => {\n  loading.value = true\n\n  try {\n    const response = await updateSummaryConfig({\n      cache_empty_summary: config.cache_empty_summary,\n    })\n\n    if (response.data) {\n      showNotify({\n        type: 'success',\n        message: '配置已更新',\n      })\n    }\n  } catch (err) {\n    error.value = err.response?.data?.detail || err.message || '更新配置失败'\n    console.error('更新摘要配置失败:', err)\n\n    // 恢复原值\n    fetchConfig()\n  } finally {\n    loading.value = false\n  }\n}\n\n// 组件挂载时获取配置\nonMounted(() => {\n  fetchConfig()\n})\n</script>\n"
  },
  {
    "path": "src/components/tailwind/TaskTreeItem.vue",
    "content": "<template>\n  <div class=\"task-tree-node\">\n    <div class=\"flex items-start\">\n      <div class=\"flex items-center\">\n        <div class=\"w-6 h-6 flex items-center justify-center\">\n          <button \n            v-if=\"childTasks.length > 0\" \n            @click=\"toggleExpanded\" \n            class=\"w-5 h-5 rounded hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center justify-center focus:outline-none\"\n          >\n            <svg \n              class=\"w-4 h-4 text-gray-500 dark:text-gray-400 transform transition-transform duration-200\"\n              :class=\"{ 'rotate-90': expanded }\" \n              fill=\"none\" \n              viewBox=\"0 0 24 24\" \n              stroke=\"currentColor\"\n            >\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 5l7 7-7 7\" />\n            </svg>\n          </button>\n        </div>\n        <div class=\"flex-1 min-w-0\">\n          <div class=\"bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm p-3 hover:border-[#fb7299] transition-colors duration-200\">\n            <div class=\"flex items-center justify-between\">\n              <div class=\"flex items-center space-x-2\">\n                <span class=\"font-medium text-sm text-gray-900 dark:text-gray-100\">{{ task.config?.name || task.task_id }}</span>\n                <div class=\"flex items-center space-x-1\">\n                  <span \n                    v-if=\"task.config?.schedule_type\"\n                    :class=\"scheduleTypeClass\" \n                    class=\"px-1.5 py-0.5 rounded-full text-xs\"\n                  >\n                    {{ scheduleTypeLabel }}\n                  </span>\n                  <span \n                    v-if=\"task.execution?.status\" \n                    :class=\"statusClass\" \n                    class=\"px-1.5 py-0.5 rounded-full text-xs\"\n                  >\n                    {{ task.execution.status }}\n                  </span>\n                </div>\n              </div>\n              <div class=\"flex items-center space-x-1\">\n                <button \n                  @click=\"viewDetail\" \n                  class=\"text-blue-600 hover:text-blue-900 text-xs px-1.5 py-0.5\"\n                >\n                  详情\n                </button>\n                <button \n                  @click=\"editTask\" \n                  class=\"text-green-600 hover:text-green-900 text-xs px-1.5 py-0.5\"\n                >\n                  编辑\n                </button>\n                <button \n                  @click=\"executeTask\" \n                  class=\"text-purple-600 hover:text-purple-900 text-xs px-1.5 py-0.5\"\n                >\n                  执行\n                </button>\n                <button \n                  v-if=\"task.config?.enabled !== undefined\"\n                  @click=\"toggleEnabled\" \n                  :class=\"task.config.enabled ? 'text-orange-600 hover:text-orange-900' : 'text-teal-600 hover:text-teal-900'\"\n                  class=\"text-xs px-1.5 py-0.5\"\n                >\n                  {{ task.config.enabled ? '禁用' : '启用' }}\n                </button>\n                <button \n                  @click=\"deleteTask\" \n                  class=\"text-red-600 hover:text-red-900 text-xs px-1.5 py-0.5\"\n                >\n                  删除\n                </button>\n              </div>\n            </div>\n            <div class=\"mt-2 text-xs text-gray-500 dark:text-gray-400\">\n              <div v-if=\"task.execution?.last_run\">上次运行: {{ task.execution.last_run }}</div>\n              <div v-if=\"task.config?.endpoint\" class=\"mt-0.5\">端点: {{ task.config.endpoint }}</div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n    <div v-show=\"expanded && childTasks.length > 0\" class=\"pl-6 mt-2 space-y-2 relative\">\n      <!-- 添加垂直连接线 -->\n      <div class=\"absolute left-3 top-0 bottom-0 w-px bg-gray-200 dark:bg-gray-700\"></div>\n      <div v-for=\"childTask in childTasks\" :key=\"getTaskId(childTask)\" class=\"relative\">\n        <!-- 添加水平连接线 -->\n        <div class=\"absolute left-0 top-1/2 w-3 h-px bg-gray-200 dark:bg-gray-700\"></div>\n        <task-tree-item \n          :task=\"childTask\" \n          :tasks=\"tasks\" \n          @view-detail=\"$emit('view-detail', $event)\"\n          @edit-task=\"$emit('edit-task', $event)\"\n          @execute-task=\"$emit('execute-task', $event)\"\n          @delete-task=\"$emit('delete-task', $event)\"\n          @toggle-enabled=\"$emit('toggle-enabled', $event)\"\n        />\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { ref, computed, defineComponent } from 'vue'\n\ntype TaskConfig = {\n  name?: string;\n  schedule_type?: string;\n  schedule_time?: string;\n  enabled?: boolean;\n  endpoint?: string;\n  method?: string;\n}\n\ntype TaskExecution = {\n  status?: string;\n  last_run?: string;\n}\n\ntype Task = {\n  task_id: string;\n  config?: TaskConfig;\n  execution?: TaskExecution;\n  sub_tasks?: Task[];\n  success_rate?: number;\n  depends_on?: string[];\n  requires?: string[];\n  sequence_number?: number;\n}\n\nexport default defineComponent({\n  name: 'TaskTreeItem',\n  props: {\n    task: {\n      type: Object as () => Task,\n      required: true\n    },\n    tasks: {\n      type: Array as () => Task[],\n      default: () => []\n    }\n  },\n  emits: ['view-detail', 'edit-task', 'execute-task', 'delete-task', 'toggle-enabled'],\n  setup(props, { emit }) {\n    const expanded = ref(true)\n\n    // 获取子任务，按 sequence_number 排序\n    const childTasks = computed(() => {\n      const subTasks = props.task.sub_tasks || []\n      return subTasks.sort((a, b) => (a.sequence_number || 0) - (b.sequence_number || 0))\n    })\n\n    // 计算调度类型标签\n    const scheduleTypeLabel = computed(() => {\n      const type = props.task.config?.schedule_type\n      return type === 'daily' ? '每日' : \n             type === 'chain' ? '链式' : \n             type === 'once' ? '一次性' : \n             type === 'interval' ? '间隔' : type\n    })\n\n    // 计算调度类型样式\n    const scheduleTypeClass = computed(() => {\n      const type = props.task.config?.schedule_type\n      return {\n        'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-300': type === 'daily',\n        'bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-300': type === 'chain',\n        'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300': type === 'once',\n        'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300': type === 'interval'\n      }\n    })\n\n    // 计算状态样式\n    const statusClass = computed(() => {\n      const status = props.task.execution?.status\n      return {\n        'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300': status === 'success',\n        'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300': status === 'running',\n        'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-300': status === 'error' || status === 'failed'\n      }\n    })\n\n    // 切换展开状态\n    const toggleExpanded = () => {\n      expanded.value = !expanded.value\n    }\n\n    // 查看详情\n    const viewDetail = () => {\n      emit('view-detail', props.task.task_id)\n    }\n\n    // 编辑任务\n    const editTask = () => {\n      emit('edit-task', props.task.task_id)\n    }\n\n    // 执行任务\n    const executeTask = () => {\n      emit('execute-task', props.task.task_id)\n    }\n\n    // 删除任务\n    const deleteTask = () => {\n      emit('delete-task', props.task.task_id)\n    }\n\n    // 切换启用状态\n    const toggleEnabled = () => {\n      emit('toggle-enabled', props.task.task_id, !props.task.config?.enabled)\n    }\n\n    // 安全地获取任务ID\n    const getTaskId = (task: Task | null) => {\n      return task?.task_id || Math.random().toString()\n    }\n\n    return {\n      expanded,\n      childTasks,\n      scheduleTypeLabel,\n      scheduleTypeClass,\n      statusClass,\n      toggleExpanded,\n      viewDetail,\n      editTask,\n      executeTask,\n      deleteTask,\n      toggleEnabled,\n      getTaskId\n    }\n  }\n})\n</script>\n\n<style scoped>\n.task-tree-node {\n  transition: all 0.2s ease;\n}\n</style> "
  },
  {
    "path": "src/components/tailwind/UserVideos.vue",
    "content": "<template>\n  <div class=\"transition-all duration-300 ease-in-out\">\n    <!-- 加载状态 -->\n    <div v-if=\"isLoading\" class=\"flex flex-col items-center justify-center py-16 bg-white dark:bg-gray-900 rounded-lg\">\n      <div class=\"w-16 h-16 border-4 border-[#fb7299] border-t-transparent rounded-full animate-spin mb-4\"></div>\n      <h3 class=\"text-xl font-medium text-gray-600 dark:text-gray-300 mb-2\">加载中</h3>\n      <p class=\"text-gray-500 dark:text-gray-400\">正在获取视频列表...</p>\n    </div>\n\n    <!-- 视频列表为空状态 -->\n    <div v-else-if=\"videos.length === 0\" class=\"flex flex-col items-center justify-center py-16 bg-white dark:bg-gray-900 rounded-lg\">\n      <svg class=\"w-24 h-24 text-gray-300 dark:text-gray-600 mb-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.5\"\n              d=\"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z\" />\n      </svg>\n      <h3 class=\"text-xl font-medium text-gray-600 dark:text-gray-300 mb-2\">暂无视频</h3>\n      <p class=\"text-gray-500 dark:text-gray-400\">该用户还没有发布任何视频</p>\n    </div>\n\n    <!-- 视频列表 -->\n    <div v-else class=\"overflow-hidden\">\n      <div\n        class=\"grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 px-4 mx-auto transition-all duration-300 ease-in-out transform-gpu\"\n        style=\"max-width: 1152px;\">\n        <div v-for=\"video in videos\" :key=\"video.aid\"\n             class=\"bg-white/50 dark:bg-gray-800/60 backdrop-blur-sm rounded-lg overflow-hidden border border-gray-200/50 dark:border-gray-700 hover:border-[#FF6699] hover:shadow-md transition-all duration-200 relative group\">\n          <!-- 封面图片 -->\n          <div class=\"relative aspect-video cursor-pointer\" @click=\"handleVideoClick(video)\">\n            <img\n              :src=\"video.pic\"\n              class=\"w-full h-full object-cover transition-all duration-300\"\n              alt=\"\"\n            />\n            <!-- 视频时长 -->\n            <div class=\"absolute bottom-1 right-1 rounded bg-black/50 px-1 py-1 text-[10px] font-semibold text-white\">\n              {{ video.length }}\n            </div>\n          </div>\n          <!-- 视频信息 -->\n          <div class=\"p-3 flex flex-col space-y-1\">\n            <!-- 标题 -->\n            <div class=\"line-clamp-1 text-sm text-gray-900 dark:text-gray-100 cursor-pointer\" @click=\"handleVideoClick(video)\">\n              {{ video.title }}\n            </div>\n            <!-- 播放量和评论数 -->\n            <div class=\"text-xs text-gray-500 dark:text-gray-400 flex items-center space-x-2\">\n <span class=\"flex items-center\">\n <svg class=\"w-3 h-3 mr-1\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\" />\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n       d=\"M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z\" />\n </svg>\n {{ formatNumber(video.play) }}\n </span>\n              <span class=\"flex items-center\">\n <svg class=\"w-3 h-3 mr-1\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n       d=\"M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z\" />\n </svg>\n {{ formatNumber(video.comment) }}\n </span>\n            </div>\n            <!-- 发布时间 -->\n            <div class=\"text-xs text-gray-500 dark:text-gray-400\">\n              {{ formatTimestamp(video.created) }}\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <!-- 分页 -->\n      <div class=\"mt-4 flex justify-center\">\n        <Pagination\n          :current-page=\"currentPage\"\n          :total-pages=\"totalPages\"\n          @page-change=\"handlePageChange\"\n        />\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nimport { ref, onMounted, watch } from 'vue'\nimport { getUserVideos } from '@/api/api'\nimport Pagination from './Pagination.vue'\nimport { openInBrowser } from '@/utils/openUrl.js'\n\nexport default {\n  name: 'UserVideos',\n  components: {\n    Pagination,\n  },\n  props: {\n    mid: {\n      type: [String, Number],\n      required: true,\n    },\n  },\n  setup(props) {\n    const videos = ref([])\n    const isLoading = ref(false)\n    const currentPage = ref(1)\n    const totalPages = ref(1)\n    const pageSize = 30\n\n    const fetchVideos = async () => {\n      isLoading.value = true\n      try {\n        const response = await getUserVideos({\n          mid: props.mid,\n          pn: currentPage.value,\n          ps: pageSize,\n        })\n        if (response.data.status === 'success') {\n          videos.value = response.data.data.list.vlist\n          totalPages.value = Math.ceil(response.data.data.page.count / pageSize)\n        }\n      } catch (error) {\n        console.error('获取用户视频列表失败:', error)\n      } finally {\n        isLoading.value = false\n      }\n    }\n\n    const handlePageChange = (page) => {\n      currentPage.value = page\n      fetchVideos()\n    }\n\n    const handleVideoClick = async (video) => {\n      await openInBrowser(`https://www.bilibili.com/video/${video.bvid}`)\n    }\n\n    const formatNumber = (num) => {\n      if (num >= 10000) {\n        return (num / 10000).toFixed(1) + '万'\n      }\n      return num.toString()\n    }\n\n    const formatTimestamp = (timestamp) => {\n      const date = new Date(timestamp * 1000)\n      return date.toLocaleDateString('zh-CN', {\n        year: 'numeric',\n        month: '2-digit',\n        day: '2-digit',\n      })\n    }\n\n    watch(() => props.mid, () => {\n      currentPage.value = 1\n      fetchVideos()\n    })\n\n    onMounted(() => {\n      fetchVideos()\n    })\n\n    return {\n      videos,\n      isLoading,\n      currentPage,\n      totalPages,\n      handlePageChange,\n      handleVideoClick,\n      formatNumber,\n      formatTimestamp,\n    }\n  },\n}\n</script>\n"
  },
  {
    "path": "src/components/tailwind/VideoCategories.vue",
    "content": "<template>\n  <!-- 圆角弹窗（底部） -->\n  <van-popup v-model:show=\"localShowBottom\" round position=\"bottom\" :style=\"{ height: '80%' }\">\n    <van-tree-select\n      class=\"m-2\"\n      height=\"95%\"\n      v-model:active-id=\"activeId\"\n      v-model:main-active-index=\"activeIndex\"\n      :items=\"items\"\n      @click-item=\"onSelectItem\"\n    />\n  </van-popup>\n</template>\n\n<script setup>\nimport { computed, onMounted, ref } from 'vue'\nimport { getVideoCategories } from '../../api/api.js'\n\nconst emit = defineEmits(['update:category', 'update:showBottom', 'selectSubCategory'])\n\nconst props = defineProps({\n  category: {\n    type: String,\n    default: ''\n  },\n  showBottom: {\n    type: Boolean,\n    default: false,\n  },\n})\n\n// 定义状态\nconst activeId = ref(null)\nconst activeIndex = ref(0)\nconst items = ref([]) // 用于存放转换后的items结构\n\n// 计算属性，用于实现双向绑定\nconst localShowBottom = computed({\n  get() {\n    return props.showBottom\n  },\n  set(value) {\n    emit('update:showBottom', value)\n  },\n})\n\n// 获取视频分类并转换数据结构\nconst fetchCategories = async () => {\n  try {\n    const response = await getVideoCategories()\n    if (response.data.status === 'success') {\n      // 转换数据结构以适应组件\n      items.value = response.data.data.map((category) => ({\n        text: category.name,\n        type: 'main',\n        children: category.sub_categories.map((sub) => ({\n          text: sub.name,\n          id: sub.tid,\n          type: 'sub',\n        })),\n      }))\n    }\n  } catch (error) {\n    console.error('Error fetching categories:', error)\n  }\n}\n\n// 当选中分类时，发出事件并传递 name 和 type\nconst onSelectItem = (item) => {\n  emit('selectSubCategory', { name: item.text, type: item.type })\n}\n\n// 页面加载时获取数据\nonMounted(() => {\n  fetchCategories()\n})\n</script>\n\n<style scoped></style>\n"
  },
  {
    "path": "src/components/tailwind/VideoDetailDialog.vue",
    "content": "<template>\n  <van-dialog\n    :show=\"dialogVisible\"\n    @update:show=\"updateVisible\"\n    :title=\"video?.title || '视频详情'\"\n    class=\"video-detail-dialog\"\n    close-on-click-overlay\n    :show-confirm-button=\"false\"\n    :style=\"{ width: '1000px', maxWidth: '90%', position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', borderRadius: '0.5rem', boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)' }\"\n  >\n    <div v-if=\"video\" class=\"p-4 overflow-y-auto bg-transparent\" style=\"height: 600px\">\n      <!-- 视频基础信息 -->\n      <div class=\"flex flex-col md:flex-row gap-3\">\n        <!-- 左侧：视频封面 -->\n        <div class=\"md:w-[30%] flex-shrink-0\">\n          <div class=\"relative w-full h-28 md:h-32 rounded-lg overflow-hidden\">\n            <img\n              :src=\"normalizeImageUrl(video.cover || video.covers?.[0])\"\n              class=\"w-full h-full object-cover\"\n              :class=\"{ 'blur-md': isPrivacyMode }\"\n              alt=\"视频封面\"\n            />\n            <!-- 视频时长 -->\n            <div class=\"absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-1 rounded\">\n              {{ formatDuration(video.duration) }}\n            </div>\n\n            <!-- 进度条 -->\n            <div v-if=\"video.business !== 'article-list' && video.business !== 'article' && video.business !== 'live'\"\n                 class=\"absolute bottom-0 left-0 w-full h-1 bg-gray-700/50\">\n              <div\n                class=\"h-full bg-[#FF6699]\"\n                :style=\"{ width: getProgressWidth(video.progress, video.duration) }\">\n              </div>\n            </div>\n          </div>\n\n          \n\n          <!-- 视频下载信息 -->\n          <div v-if=\"isVideoDownloaded && downloadedFiles.length > 0\" class=\"mt-3\">\n            <div class=\"text-xs text-gray-500 dark:text-gray-400 p-2 bg-pink-50 dark:bg-pink-900/20 rounded-lg\">\n              <div class=\"flex items-center mb-1\">\n                <svg class=\"w-3 h-3 text-[#fb7299] mr-1\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 13l4 4L19 7\" />\n                </svg>\n                <span class=\"font-medium text-[#fb7299]\">视频已下载</span>\n              </div>\n              <div v-for=\"(file, index) in downloadedFiles\" :key=\"index\" class=\"ml-4 truncate\" :title=\"file.file_path\">\n                {{ file.file_name }} ({{ file.size_mb.toFixed(1) }} MB)\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <!-- 右侧：视频详情 -->\n        <div class=\"md:w-[70%]\">\n\n          <!-- 视频信息 -->\n          <div class=\"space-y-2 text-xs flex flex-col h-28 md:h-32 min-h-0\">\n            <!-- UP主信息 + 检测信息并排显示 -->\n            <div v-if=\"video.business !== 'cheese' && video.business !== 'pgc'\"\n                 class=\"flex items-center space-x-2\"\n                 @click.stop>\n              <div class=\"flex-shrink-0\">\n                <img\n                  :src=\"normalizeImageUrl(video.author_face)\"\n                  alt=\"author\"\n                  class=\"h-7 w-7 cursor-pointer rounded-full transition-all duration-300 hover:scale-110\"\n                  :class=\"{ 'blur-md': isPrivacyMode }\"\n                  @click=\"openAuthorPage\"\n                  :title=\"isPrivacyMode ? '隐私模式已开启' : `访问 ${video.author_name} 的个人空间`\"\n                />\n              </div>\n              <div class=\"flex-1 min-w-0\">\n                <p\n                  class=\"cursor-pointer text-gray-800 dark:text-gray-200 transition-colors hover:text-[#FF6699]\"\n                  @click=\"openAuthorPage\"\n                  :title=\"isPrivacyMode ? '隐私模式已开启' : `访问 ${video.author_name} 的个人空间`\"\n                  v-html=\"isPrivacyMode ? '******' : video.author_name\"\n                ></p>\n                <p class=\"text-xs text-gray-500 dark:text-gray-400\">UP主</p>\n              </div>\n              <div class=\"flex items-center space-x-2 ml-auto\">\n                <EnvironmentCheck inline compact />\n                <div class=\"hidden sm:block h-4 w-px bg-gray-200 dark:bg-gray-700\"></div>\n                <!-- DeepSeek 余额（紧凑显示） -->\n                <div class=\"flex items-center space-x-1 text-[11px] text-gray-600\">\n                  <svg class=\"w-3.5 h-3.5 text-[#4D6BFE]\" viewBox=\"0 0 30 30\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                    <path d=\"M27.501 8.46875C27.249 8.3457 27.1406 8.58008 26.9932 8.69922C26.9434 8.73828 26.9004 8.78906 26.8584 8.83398C26.4902 9.22852 26.0605 9.48633 25.5 9.45508C24.6787 9.41016 23.9785 9.66797 23.3594 10.2969C23.2275 9.52148 22.79 9.05859 22.125 8.76172C21.7764 8.60742 21.4238 8.45312 21.1807 8.11719C21.0098 7.87891 20.9639 7.61328 20.8779 7.35156C20.8242 7.19336 20.7695 7.03125 20.5879 7.00391C20.3906 6.97266 20.3135 7.13867 20.2363 7.27734C19.9258 7.84375 19.8066 8.46875 19.8174 9.10156C19.8447 10.5234 20.4453 11.6562 21.6367 12.4629C21.7725 12.5547 21.8076 12.6484 21.7646 12.7832C21.6836 13.0605 21.5869 13.3301 21.501 13.6074C21.4473 13.7852 21.3662 13.8242 21.1768 13.7461C20.5225 13.4727 19.957 13.0684 19.458 12.5781C18.6104 11.7578 17.8438 10.8516 16.8877 10.1426C16.6631 9.97656 16.4395 9.82227 16.207 9.67578C15.2314 8.72656 16.335 7.94727 16.5898 7.85547C16.8574 7.75977 16.6826 7.42773 15.8193 7.43164C14.957 7.43555 14.167 7.72461 13.1611 8.10938C13.0137 8.16797 12.8594 8.21094 12.7002 8.24414C11.7871 8.07227 10.8389 8.0332 9.84766 8.14453C7.98242 8.35352 6.49219 9.23633 5.39648 10.7441C4.08105 12.5547 3.77148 14.6133 4.15039 16.7617C4.54883 19.0234 5.70215 20.8984 7.47559 22.3633C9.31348 23.8809 11.4307 24.625 13.8457 24.4824C15.3125 24.3984 16.9463 24.2012 18.7881 22.6406C19.2529 22.8711 19.7402 22.9629 20.5498 23.0332C21.1729 23.0918 21.7725 23.002 22.2373 22.9062C22.9648 22.752 22.9141 22.0781 22.6514 21.9531C20.5186 20.959 20.9863 21.3633 20.5605 21.0371C21.6445 19.752 23.2783 18.418 23.917 14.0977C23.9668 13.7539 23.9238 13.5391 23.917 13.2598C23.9131 13.0918 23.9512 13.0254 24.1445 13.0059C24.6787 12.9453 25.1973 12.7988 25.6738 12.5352C27.0557 11.7793 27.6123 10.5391 27.7441 9.05078C27.7637 8.82422 27.7402 8.58789 27.501 8.46875ZM15.46 21.8613C13.3926 20.2344 12.3906 19.6992 11.9766 19.7227C11.5898 19.7441 11.6592 20.1875 11.7441 20.4766C11.833 20.7617 11.9492 20.959 12.1123 21.209C12.2246 21.375 12.3018 21.623 12 21.8066C11.334 22.2207 10.1768 21.668 10.1221 21.6406C8.77539 20.8477 7.64941 19.7988 6.85547 18.3652C6.08984 16.9844 5.64453 15.5039 5.57129 13.9238C5.55176 13.541 5.66406 13.4062 6.04297 13.3379C6.54199 13.2461 7.05762 13.2266 7.55664 13.2988C9.66602 13.6074 11.4619 14.5527 12.9668 16.0469C13.8262 16.9004 14.4766 17.918 15.1465 18.9121C15.8584 19.9688 16.625 20.9746 17.6006 21.7988C17.9443 22.0879 18.2197 22.3086 18.4824 22.4707C17.6895 22.5586 16.3652 22.5781 15.46 21.8613ZM16.4502 15.4805C16.4502 15.3105 16.5859 15.1758 16.7568 15.1758C16.7949 15.1758 16.8301 15.1836 16.8613 15.1953C16.9033 15.2109 16.9424 15.2344 16.9727 15.2695C17.0273 15.3223 17.0586 15.4004 17.0586 15.4805C17.0586 15.6504 16.9229 15.7852 16.7529 15.7852C16.582 15.7852 16.4502 15.6504 16.4502 15.4805ZM19.5273 17.0625C19.3301 17.1426 19.1328 17.2129 18.9434 17.2207C18.6494 17.2344 18.3281 17.1152 18.1533 16.9688C17.8828 16.7422 17.6895 16.6152 17.6074 16.2168C17.5732 16.0469 17.5928 15.7852 17.623 15.6348C17.6934 15.3105 17.6152 15.1035 17.3877 14.9141C17.2012 14.7598 16.9658 14.7188 16.7061 14.7188C16.6094 14.7188 16.5205 14.6758 16.4541 14.6406C16.3457 14.5859 16.2568 14.4512 16.3418 14.2852C16.3691 14.2324 16.501 14.1016 16.5322 14.0781C16.8838 13.877 17.29 13.9434 17.666 14.0938C18.0146 14.2363 18.2773 14.498 18.6562 14.8672C19.0439 15.3145 19.1133 15.4395 19.334 15.7734C19.5078 16.0371 19.667 16.3066 19.7754 16.6152C19.8408 16.8066 19.7559 16.9648 19.5273 17.0625Z\" fill=\"#4D6BFE\" />\n                  </svg>\n                  <div v-if=\"deepseekBalance.is_available\" class=\"text-[11px]\">\n                    <span class=\"text-gray-500\">余额:</span>\n                    <span class=\"font-medium text-[#4D6BFE]\">{{ deepseekBalance.balance_infos[0]?.total_balance || '0.00' }}</span>\n                    <span class=\"text-gray-500\">{{ deepseekBalance.balance_infos[0]?.currency }}</span>\n                  </div>\n                  <button @click=\"refreshDeepSeekBalance\" class=\"p-0.5 text-[#4D6BFE] hover:bg-[#4D6BFE]/10 rounded-full transition-colors duration-200\" :title=\"deepseekBalance.is_available ? '刷新余额' : '点击查询余额'\">\n                    <svg class=\"w-3.5 h-3.5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                      <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15\" />\n                    </svg>\n                  </button>\n                </div>\n              </div>\n              \n            </div>\n\n            <!-- 视频分区/观看时间/设备 行已按需求去除 -->\n\n            <!-- 备注 -->\n            <div class=\"mt-2 flex-1 flex flex-col min-h-0\">\n              <textarea\n                v-model=\"remarkContent\"\n                @blur=\"handleRemarkBlur\"\n                :disabled=\"isPrivacyMode\"\n                placeholder=\"添加备注...\"\n                rows=\"2\"\n                class=\"w-full flex-1 resize-none px-2 py-1.5 text-xs text-gray-800 dark:text-gray-200 bg-gray-50 dark:bg-gray-700 rounded border border-gray-200 dark:border-gray-600 focus:border-[#fb7299] focus:ring-[#fb7299] transition-colors duration-200\"\n                :class=\"{ 'blur-sm': isPrivacyMode }\"\n              ></textarea>\n              <div v-if=\"remarkTime\" class=\"text-xs text-gray-400 dark:text-gray-500 mt-1\">\n                上次编辑: {{ formatRemarkTime(remarkTime) }}\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <!-- 视频摘要 -->\n      <div v-if=\"video.business === 'archive'\" class=\"mt-6\">\n        <!-- 标签页 -->\n        <div class=\"border-b border-gray-200 dark:border-gray-700\">\n          <nav class=\"flex -mb-px\">\n            <button\n              v-for=\"tab in tabs\"\n              :key=\"tab.id\"\n              @click=\"currentTab = tab.id\"\n              class=\"px-4 py-2 text-sm font-medium border-b-2 transition-colors duration-200\"\n              :class=\"[\n                currentTab === tab.id\n                  ? 'border-[#fb7299] text-[#fb7299]'\n                  : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'\n              ]\"\n            >\n              {{ tab.name }}\n            </button>\n          </nav>\n        </div>\n\n        <!-- 标签页内容 -->\n        <div class=\"mt-4\">\n          <!-- B站AI摘要 -->\n          <div v-show=\"currentTab === 'bilibili'\" class=\"space-y-4\">\n            <VideoSummary\n              :key=\"videoSummaryKey\"\n              :bvid=\"video.bvid\"\n              :cid=\"String(video.cid)\"\n              :upMid=\"String(video.author_mid)\"\n            />\n          </div>\n\n          <!-- 本地摘要部分 -->\n          <div v-show=\"currentTab === 'local'\" class=\"space-y-6\">\n            <!-- 检查环境中的加载状态 -->\n            <div v-if=\"isCheckingEnvironment\" class=\"flex items-center justify-center p-6\">\n              <div class=\"flex flex-col items-center\">\n                <div\n                  class=\"animate-spin h-10 w-10 border-4 border-[#fb7299] border-t-transparent rounded-full mb-3\"></div>\n                <span class=\"text-gray-600\">正在检查系统环境...</span>\n              </div>\n            </div>\n\n            <!-- 系统资源不足提示 -->\n            <div v-else-if=\"!canRunSpeechToText\"\n                 class=\"flex items-center justify-center p-6 bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-lg border border-red-200 dark:border-red-800/60\">\n              <div class=\"flex flex-col items-center text-center\">\n                <svg class=\"w-12 h-12 mb-3\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                        d=\"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z\" />\n                </svg>\n                <span class=\"font-medium text-lg mb-2\">无法使用本地摘要功能</span>\n                <span class=\"text-sm\">{{ systemLimitationReason || '系统资源不足，无法运行语音转文字功能' }}</span>\n              </div>\n            </div>\n\n            <!-- CUDA不可用提示 -->\n            <div v-if=\"!cudaAvailable && cudaSetupGuide && showCudaGuide\"\n                 class=\"flex flex-col p-6 bg-yellow-50 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300 rounded-lg border border-yellow-100 dark:border-yellow-800/60\">\n              <div class=\"flex items-center mb-4\">\n                <svg class=\"w-8 h-8 mr-3\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                        d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n                </svg>\n                <div>\n                  <h3 class=\"font-medium text-lg\">CUDA 不可用</h3>\n                  <p class=\"text-sm\">本地摘要功能可以使用，但速度会较慢。安装CUDA可以显著提升处理速度。</p>\n                </div>\n              </div>\n\n              <div class=\"mt-2\">\n                <h4 class=\"font-medium mb-2\">CUDA 安装指南</h4>\n                <pre\n                  class=\"text-xs bg-gray-100 dark:bg-gray-900 dark:text-gray-300 border border-gray-200 dark:border-gray-700 p-3 rounded-md overflow-auto max-h-60 whitespace-pre-wrap\">{{ cudaSetupGuide\n                  }}</pre>\n              </div>\n\n              <div class=\"mt-4 flex justify-end\">\n                <button\n                  @click=\"showCudaGuide = false\"\n                  class=\"px-4 py-2 bg-yellow-600 text-white rounded-md text-sm hover:bg-yellow-700 transition-colors\"\n                >\n                  我已了解，继续使用\n                </button>\n              </div>\n            </div>\n\n            <!-- 只有在系统资源足够且CUDA可用或用户已确认时才显示以下内容 -->\n            <template v-else-if=\"canRunSpeechToText && (!cudaAvailable ? !showCudaGuide : true)\">\n              <!-- 本地摘要显示部分 -->\n              <div v-if=\"hasLocalSummary\" class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4\">\n                <div class=\"flex items-center justify-between mb-4\">\n                  <h3 class=\"text-base font-medium text-gray-900 dark:text-gray-100\">本地摘要</h3>\n                  <!-- DeepSeek余额显示 -->\n                  <div class=\"flex items-center space-x-2\">\n                    <svg class=\"w-4 h-4 text-[#4D6BFE]\" viewBox=\"0 0 30 30\" fill=\"none\"\n                         xmlns=\"http://www.w3.org/2000/svg\">\n                      <path\n                        d=\"M27.501 8.46875C27.249 8.3457 27.1406 8.58008 26.9932 8.69922C26.9434 8.73828 26.9004 8.78906 26.8584 8.83398C26.4902 9.22852 26.0605 9.48633 25.5 9.45508C24.6787 9.41016 23.9785 9.66797 23.3594 10.2969C23.2275 9.52148 22.79 9.05859 22.125 8.76172C21.7764 8.60742 21.4238 8.45312 21.1807 8.11719C21.0098 7.87891 20.9639 7.61328 20.8779 7.35156C20.8242 7.19336 20.7695 7.03125 20.5879 7.00391C20.3906 6.97266 20.3135 7.13867 20.2363 7.27734C19.9258 7.84375 19.8066 8.46875 19.8174 9.10156C19.8447 10.5234 20.4453 11.6562 21.6367 12.4629C21.7725 12.5547 21.8076 12.6484 21.7646 12.7832C21.6836 13.0605 21.5869 13.3301 21.501 13.6074C21.4473 13.7852 21.3662 13.8242 21.1768 13.7461C20.5225 13.4727 19.957 13.0684 19.458 12.5781C18.6104 11.7578 17.8438 10.8516 16.8877 10.1426C16.6631 9.97656 16.4395 9.82227 16.207 9.67578C15.2314 8.72656 16.335 7.94727 16.5898 7.85547C16.8574 7.75977 16.6826 7.42773 15.8193 7.43164C14.957 7.43555 14.167 7.72461 13.1611 8.10938C13.0137 8.16797 12.8594 8.21094 12.7002 8.24414C11.7871 8.07227 10.8389 8.0332 9.84766 8.14453C7.98242 8.35352 6.49219 9.23633 5.39648 10.7441C4.08105 12.5547 3.77148 14.6133 4.15039 16.7617C4.54883 19.0234 5.70215 20.8984 7.47559 22.3633C9.31348 23.8809 11.4307 24.625 13.8457 24.4824C15.3125 24.3984 16.9463 24.2012 18.7881 22.6406C19.2529 22.8711 19.7402 22.9629 20.5498 23.0332C21.1729 23.0918 21.7725 23.002 22.2373 22.9062C22.9648 22.752 22.9141 22.0781 22.6514 21.9531C20.5186 20.959 20.9863 21.3633 20.5605 21.0371C21.6445 19.752 23.2783 18.418 23.917 14.0977C23.9668 13.7539 23.9238 13.5391 23.917 13.2598C23.9131 13.0918 23.9512 13.0254 24.1445 13.0059C24.6787 12.9453 25.1973 12.7988 25.6738 12.5352C27.0557 11.7793 27.6123 10.5391 27.7441 9.05078C27.7637 8.82422 27.7402 8.58789 27.501 8.46875ZM15.46 21.8613C13.3926 20.2344 12.3906 19.6992 11.9766 19.7227C11.5898 19.7441 11.6592 20.1875 11.7441 20.4766C11.833 20.7617 11.9492 20.959 12.1123 21.209C12.2246 21.375 12.3018 21.623 12 21.8066C11.334 22.2207 10.1768 21.668 10.1221 21.6406C8.77539 20.8477 7.64941 19.7988 6.85547 18.3652C6.08984 16.9844 5.64453 15.5039 5.57129 13.9238C5.55176 13.541 5.66406 13.4062 6.04297 13.3379C6.54199 13.2461 7.05762 13.2266 7.55664 13.2988C9.66602 13.6074 11.4619 14.5527 12.9668 16.0469C13.8262 16.9004 14.4766 17.918 15.1465 18.9121C15.8584 19.9688 16.625 20.9746 17.6006 21.7988C17.9443 22.0879 18.2197 22.3086 18.4824 22.4707C17.6895 22.5586 16.3652 22.5781 15.46 21.8613ZM16.4502 15.4805C16.4502 15.3105 16.5859 15.1758 16.7568 15.1758C16.7949 15.1758 16.8301 15.1836 16.8613 15.1953C16.9033 15.2109 16.9424 15.2344 16.9727 15.2695C17.0273 15.3223 17.0586 15.4004 17.0586 15.4805C17.0586 15.6504 16.9229 15.7852 16.7529 15.7852C16.582 15.7852 16.4502 15.6504 16.4502 15.4805ZM19.5273 17.0625C19.3301 17.1426 19.1328 17.2129 18.9434 17.2207C18.6494 17.2344 18.3281 17.1152 18.1533 16.9688C17.8828 16.7422 17.6895 16.6152 17.6074 16.2168C17.5732 16.0469 17.5928 15.7852 17.623 15.6348C17.6934 15.3105 17.6152 15.1035 17.3877 14.9141C17.2012 14.7598 16.9658 14.7188 16.7061 14.7188C16.6094 14.7188 16.5205 14.6758 16.4541 14.6406C16.3457 14.5859 16.2568 14.4512 16.3418 14.2852C16.3691 14.2324 16.501 14.1016 16.5322 14.0781C16.8838 13.877 17.29 13.9434 17.666 14.0938C18.0146 14.2363 18.2773 14.498 18.6562 14.8672C19.0439 15.3145 19.1133 15.4395 19.334 15.7734C19.5078 16.0371 19.667 16.3066 19.7754 16.6152C19.8408 16.8066 19.7559 16.9648 19.5273 17.0625Z\"\n                        fill=\"#4D6BFE\" />\n                    </svg>\n                    <div v-if=\"deepseekBalance.is_available\" class=\"text-xs\">\n                      <span class=\"text-gray-500\">余额:</span>\n                      <span\n                        class=\"font-medium text-[#4D6BFE]\">{{ deepseekBalance.balance_infos[0]?.total_balance || '0.00'\n                        }}</span>\n                      <span class=\"text-gray-500\">{{ deepseekBalance.balance_infos[0]?.currency }}</span>\n                    </div>\n                    <button\n                      @click=\"refreshDeepSeekBalance\"\n                      class=\"p-1 text-[#4D6BFE] hover:bg-[#4D6BFE]/10 rounded-full transition-colors duration-200\"\n                      :title=\"deepseekBalance.is_available ? '刷新余额' : '点击查询余额'\"\n                    >\n                      <svg class=\"w-3.5 h-3.5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                              d=\"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15\" />\n                      </svg>\n                    </button>\n                  </div>\n                </div>\n\n                <!-- 处理信息和Token信息 -->\n                <div class=\"mb-4 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg\">\n                  <div class=\"text-xs text-gray-500 dark:text-gray-400 space-y-2\">\n                    <p>处理用时：{{ Math.round(localSummaryData?.processing_time || 0) }}秒</p>\n                    <p>使用模型：{{ localSummaryData?.from_deepseek ? 'DeepSeek' : 'GPT' }}</p>\n                    <div class=\"border-t border-gray-200 my-2 pt-2\">\n                      <p class=\"font-medium mb-1\">Token 使用统计：</p>\n                      <p>• 提示词：{{ localSummaryData?.tokens_used?.prompt_tokens || 0 }} tokens</p>\n                      <p>• 生成内容：{{ localSummaryData?.tokens_used?.completion_tokens || 0 }} tokens</p>\n                      <p>• 总计：{{ localSummaryData?.tokens_used?.total_tokens || 0 }} tokens</p>\n                      <p v-if=\"localSummaryData?.tokens_used?.prompt_tokens_details?.cached_tokens\">\n                        • 命中缓存：{{ localSummaryData?.tokens_used?.prompt_tokens_details?.cached_tokens }} tokens\n                      </p>\n                    </div>\n                  </div>\n                </div>\n\n                <!-- 摘要内容 -->\n                <div class=\"text-sm text-gray-700 dark:text-gray-300 whitespace-pre-line\">\n                  <div v-if=\"localSummaryData?.summary\" class=\"space-y-4\">\n                    <template v-for=\"(section, index) in localSummaryData.summary.split('\\n')\" :key=\"index\">\n                      <div v-if=\"hasTimeStamp(section)\"\n                           class=\"cursor-pointer hover:bg-[#fb7299]/10 hover:text-[#fb7299] transition-colors duration-200 px-2 py-1 rounded\"\n                           @click=\"handleTimeClick(section)\">\n                        <span class=\"text-[#fb7299]\">{{ extractTimeStamp(section) }}</span>\n                        <span>{{ section.replace(extractTimeStamp(section), '') }}</span>\n                      </div>\n                      <div v-else>{{ section }}</div>\n                    </template>\n                  </div>\n                </div>\n              </div>\n\n              <!-- 音频文件状态检查 -->\n              <div class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4\">\n                <div class=\"flex items-start justify-between\">\n                  <div class=\"flex items-start space-x-2\">\n                    <div v-if=\"isCheckingAudio\" class=\"flex items-center space-x-2 text-gray-500 dark:text-gray-400\">\n                      <div\n                        class=\"animate-spin h-4 w-4 border-2 border-gray-300 border-t-transparent rounded-full\"></div>\n                      <span>正在检查音频文件...</span>\n                    </div>\n                    <template v-else>\n                      <div v-if=\"audioPath\" class=\"flex-1\">\n                        <div class=\"flex items-center space-x-2\">\n                          <svg class=\"w-5 h-5 text-green-600\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 13l4 4L19 7\" />\n                          </svg>\n                          <span class=\"text-sm font-medium text-gray-900 dark:text-gray-100\">已找到音频文件</span>\n                        </div>\n                        <p class=\"mt-1 text-xs text-gray-500 dark:text-gray-400 break-all\">{{ audioPath }}</p>\n                      </div>\n                      <div v-else class=\"flex-1\">\n                        <div class=\"flex items-center space-x-2\">\n                          <svg class=\"w-5 h-5 text-yellow-500\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                                  d=\"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z\"></path>\n                          </svg>\n                          <span class=\"text-sm font-medium text-gray-900 dark:text-gray-100\">未找到音频文件</span>\n                        </div>\n                        <p class=\"mt-1 text-xs text-gray-500 dark:text-gray-400\">请下载音频文件</p>\n                      </div>\n                    </template>\n                  </div>\n                  <!-- 下载按钮 -->\n                  <button\n                    v-if=\"!audioPath && !isCheckingAudio\"\n                    @click=\"handleShowDownload\"\n                    class=\"px-4 py-2 text-sm font-medium text-white bg-[#fb7299] rounded-md hover:bg-[#fb7299]/90\"\n                  >\n                    下载音频\n                  </button>\n                </div>\n\n                <!-- 已存在的转录文件信息 -->\n                <div v-if=\"hasExistingStt\" class=\"mt-4 border-t pt-4\">\n                  <div class=\"flex items-center space-x-2\">\n                    <svg class=\"w-5 h-5 text-green-600\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                      <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 13l4 4L19 7\" />\n                    </svg>\n                    <span class=\"text-sm font-medium text-gray-900\">已找到转录文件</span>\n                  </div>\n                  <p v-if=\"sttFilePath\" class=\"mt-1 text-xs text-gray-500 break-all\">{{ sttFilePath }}</p>\n                </div>\n              </div>\n\n              <!-- 转录状态和结果横幅 -->\n              <div\n                v-if=\"transcriptionStatus && (isTranscribing || transcriptionResult || transcriptionStatus !== '音频文件已找到，可以开始转录')\"\n                class=\"rounded-lg p-4\" :class=\"{\n                'bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-900/30': isTranscribing,\n                'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-900/30': transcriptionResult && !isTranscribing,\n                'bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700': !transcriptionResult && !isTranscribing\n              }\">\n                <div class=\"flex items-center space-x-3\">\n                  <div v-if=\"isTranscribing\" class=\"flex-shrink-0\">\n                    <svg class=\"animate-spin h-5 w-5 text-blue-500\" fill=\"none\" viewBox=\"0 0 24 24\">\n                      <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n                      <path class=\"opacity-75\" fill=\"currentColor\"\n                            d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n                    </svg>\n                  </div>\n                  <div v-else-if=\"transcriptionResult\" class=\"flex-shrink-0\">\n                    <svg class=\"h-5 w-5 text-green-500\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                      <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 13l4 4L19 7\" />\n                    </svg>\n                  </div>\n                  <div class=\"flex-1\">\n                    <p class=\"text-sm font-medium\" :class=\"{\n                      'text-blue-700 dark:text-blue-300': isTranscribing,\n                      'text-green-700 dark:text-green-300': transcriptionResult && !isTranscribing,\n                      'text-gray-700 dark:text-gray-300': !transcriptionResult && !isTranscribing\n                    }\">{{ transcriptionStatus }}</p>\n\n                    <div v-if=\"transcriptionResult && !isTranscribing\" class=\"mt-2 grid grid-cols-3 gap-4\">\n                      <div class=\"text-xs text-gray-600\">\n                        <span class=\"font-medium\">视频时长：</span>\n                        {{ formatDuration(transcriptionResult.duration) }}\n                      </div>\n                      <div class=\"text-xs text-gray-600\">\n                        <span class=\"font-medium\">处理用时：</span>\n                        {{ Math.round(transcriptionResult.processingTime) }}秒\n                      </div>\n                      <div class=\"text-xs text-gray-600\">\n                        <span class=\"font-medium\">检测语言：</span>\n                        {{ transcriptionResult.languageDetected === 'zh' ? '中文' : '英文' }}\n                      </div>\n                    </div>\n                  </div>\n                </div>\n              </div>\n\n              <!-- 生成摘要按钮和状态 -->\n              <div v-if=\"!isTranscribing && (hasExistingStt || transcriptionResult)\">\n                <button\n                  @click=\"startGeneratingSummary\"\n                  :disabled=\"isSummarizing\"\n                  class=\"w-full flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-[#fb7299] rounded-md hover:bg-[#fb7299]/90 disabled:opacity-50\"\n                >\n                  <svg v-if=\"isSummarizing\" class=\"animate-spin -ml-1 mr-2 h-4 w-4 text-white\" fill=\"none\"\n                       viewBox=\"0 0 24 24\">\n                    <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n                    <path class=\"opacity-75\" fill=\"currentColor\"\n                          d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n                  </svg>\n                  {{ isSummarizing ? '正在生成摘要...' : '生成摘要' }}\n                </button>\n                <p v-if=\"isSummarizing\" class=\"mt-2 text-sm text-gray-500 text-center\">\n                  正在使用AI分析视频内容，请稍候...\n                </p>\n              </div>\n\n              <!-- 模型选择部分 -->\n              <div class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4\">\n                <div class=\"flex items-center justify-between mb-2\">\n                  <h4 class=\"text-base font-medium text-gray-900 dark:text-gray-100\">选择语音识别模型</h4>\n                  <button\n                    @click=\"startTranscription\"\n                    :disabled=\"!selectedModel || !selectedModel.is_downloaded || isTranscribing\"\n                    class=\"px-3 py-1.5 text-xs md:text-sm font-medium text-white rounded-md hover:bg-[#fb7299]/90 disabled:opacity-50 disabled:cursor-not-allowed\"\n                    :class=\"isTranscribing ? 'bg-blue-500' : 'bg-[#fb7299]'\"\n                  >\n                    <span v-if=\"isTranscribing\" class=\"flex items-center\">\n                      <svg class=\"animate-spin -ml-1 mr-2 h-4 w-4 text-white\" fill=\"none\" viewBox=\"0 0 24 24\">\n                        <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n                        <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n                      </svg>\n                      正在转换中...\n                    </span>\n                    <span v-else>开始音频转文字</span>\n                  </button>\n                </div>\n\n                \n\n                <!-- 长视频警告 -->\n                <div v-if=\"video && video.duration > 1800\"\n                     class=\"mb-4 p-3 bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-100 dark:border-yellow-800/60 rounded-lg\">\n                  <div class=\"flex items-start\">\n                    <svg class=\"w-5 h-5 text-yellow-600 mt-0.5 mr-2 flex-shrink-0\" fill=\"none\" viewBox=\"0 0 24 24\"\n                         stroke=\"currentColor\">\n                      <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                            d=\"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z\" />\n                    </svg>\n                    <div>\n                      <p class=\"text-sm font-medium text-yellow-800 dark:text-yellow-300\">长视频警告</p>\n                      <p class=\"text-sm text-yellow-700 dark:text-yellow-300 mt-1\">\n                        该视频时长超过30分钟，音频转文字后可能产生大量文本。这可能导致上下文过长，使AI无法接受请求。请谨慎操作\n                      </p>\n                    </div>\n                  </div>\n                </div>\n\n                <!-- 语言选择 -->\n                <div class=\"mb-4\">\n                  <label class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">选择语言</label>\n                  <div class=\"grid grid-cols-2 gap-2\">\n                    <button\n                      @click=\"selectedLanguage = 'zh'\"\n                      class=\"px-4 py-2 text-sm font-medium rounded-lg transition-colors duration-200\"\n                      :class=\"[\n                        selectedLanguage === 'zh'\n                          ? 'bg-[#fb7299] text-white'\n                          : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'\n                      ]\"\n                    >\n                      中文\n                    </button>\n                    <button\n                      @click=\"selectedLanguage = 'en'\"\n                      class=\"px-4 py-2 text-sm font-medium rounded-lg transition-colors duration-200\"\n                      :class=\"[\n                        selectedLanguage === 'en'\n                          ? 'bg-[#fb7299] text-white'\n                          : 'bg-gray-100 text-gray-700 hover:bg-gray-200'\n                      ]\"\n                    >\n                      英文\n                    </button>\n                  </div>\n                </div>\n\n                <!-- 模型列表 -->\n                <div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n                  <div v-for=\"model in whisperModels\"\n                       :key=\"model.name\"\n                       @click=\"selectModel(model)\"\n                       @mouseenter=\"hoveredModel = model\"\n                       @mouseleave=\"hoveredModel = null\"\n                       class=\"relative border rounded-lg p-4 cursor-pointer transition-all duration-200 hover:border-[#fb7299]\"\n                       :class=\"[\n                         selectedModel?.name === model.name ? 'border-[#fb7299] bg-pink-50 dark:bg-pink-900/20' : 'border-gray-200 dark:border-gray-700',\n                         !model.is_downloaded ? 'opacity-50' : ''\n                       ]\"\n                  >\n                    <div class=\"flex items-start justify-between\">\n                      <div>\n                        <h5 class=\"text-sm font-medium text-gray-900 dark:text-gray-100\">\n                          {{ model.description }}\n                          <span\n                            v-if=\"model.name === 'tiny' || (model.description && model.description.includes('极小型'))\"\n                            class=\"ml-2 inline-flex items-center px-1.5 py-0.5 rounded bg-[#fb7299]/10 text-[#fb7299] text-[10px]\"\n                          >推荐</span>\n                        </h5>\n                        <p class=\"text-xs text-gray-500 dark:text-gray-400 mt-1\">{{ model.params_size }}</p>\n                        <p v-if=\"model.is_downloaded\" class=\"text-xs text-gray-400 dark:text-gray-500 mt-1 truncate\" :title=\"model.path\">\n                          {{ model.path }}\n                        </p>\n                      </div>\n                      <div v-if=\"model.is_downloaded\"\n                           class=\"flex-shrink-0 text-green-600\">\n                        <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                          <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 13l4 4L19 7\" />\n                        </svg>\n                      </div>\n                    </div>\n\n                    <p class=\"mt-2 text-xs text-gray-600 dark:text-gray-400\">{{ model.recommended_use }}</p>\n\n                    <!-- 已下载模型的删除按钮 -->\n                    <div\n                      v-if=\"model.is_downloaded && hoveredModel && hoveredModel.name === model.name && !isDeletingModel\"\n                      class=\"absolute top-2 right-2 z-10\">\n                      <button @click.stop=\"showModelDeleteConfirm(model)\"\n                              class=\"p-1 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors duration-200\">\n                        <svg class=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                          <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                                d=\"M6 18L18 6M6 6l12 12\" />\n                        </svg>\n                      </button>\n                    </div>\n\n                    <!-- 删除模型中状态 -->\n                    <div v-if=\"isDeletingModel && modelToDelete && modelToDelete.name === model.name\"\n                         class=\"absolute inset-0 bg-white/80 dark:bg-gray-900/70 backdrop-blur-sm flex items-center justify-center rounded-lg\">\n                      <div class=\"flex flex-col items-center space-y-2\">\n                        <div\n                          class=\"animate-spin h-6 w-6 border-2 border-red-500 border-t-transparent rounded-full\"></div>\n                        <span class=\"text-sm text-red-500\">删除中...</span>\n                      </div>\n                    </div>\n\n                    <!-- 未下载提示 -->\n                    <div v-if=\"!model.is_downloaded\"\n                         class=\"absolute inset-0 bg-white/80 dark:bg-gray-900/70 backdrop-blur-sm flex items-center justify-center rounded-lg\">\n                      <!-- 下载按钮 -->\n                      <div v-if=\"hoveredModel && hoveredModel.name === model.name && !isDownloadingModel\"\n                           class=\"flex flex-col items-center space-y-2\"\n                           @click.stop=\"showModelDownloadConfirm(model)\">\n                        <button\n                          class=\"px-3 py-1.5 bg-[#fb7299] text-white rounded-md text-sm hover:bg-[#fb7299]/90 transition-colors duration-200\">\n                          下载模型\n                        </button>\n                        <span class=\"text-xs text-gray-500\">{{ model.params_size }}</span>\n                      </div>\n                      <!-- 下载中状态 -->\n                      <div v-else-if=\"isDownloadingModel && downloadingModel && downloadingModel.name === model.name\"\n                           class=\"flex flex-col items-center space-y-2\">\n                        <div\n                          class=\"animate-spin h-6 w-6 border-2 border-[#fb7299] border-t-transparent rounded-full\"></div>\n                        <span class=\"text-sm text-[#fb7299]\">下载中...</span>\n                      </div>\n                      <!-- 默认状态 -->\n                      <span v-else class=\"text-sm text-gray-500\">需要下载</span>\n                    </div>\n                  </div>\n                </div>\n\n                \n              </div>\n            </template>\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <!-- 加载中 -->\n    <div v-else class=\"p-6 flex justify-center\">\n      <div class=\"animate-spin h-8 w-8 border-4 border-[#fb7299] border-t-transparent rounded-full\"></div>\n    </div>\n\n    <!-- 下载对话框组件 -->\n    <DownloadDialog\n      v-model:show=\"showDownloadDialog\"\n      :video-info=\"{\n        title: video?.title || '',\n        author: video?.author_name || '',\n        bvid: video?.bvid || '',\n        cover: video?.cover || video?.covers?.[0] || '',\n        cid: video?.cid || 0\n      }\"\n      :default-only-audio=\"currentTab === 'local'\"\n      @download-complete=\"handleDownloadComplete\"\n    />\n\n    <!-- 下载模型确认对话框 -->\n    <van-dialog\n      v-model:show=\"showDownloadConfirm\"\n      title=\"下载模型确认\"\n      show-cancel-button\n      @confirm=\"startDownloadModel\"\n    >\n      <div class=\"p-4\">\n        <p class=\"text-gray-700 dark:text-gray-300 mb-3\">您确定要下载{{ modelToDownload?.description }}吗？</p>\n        <div v-if=\"modelToDownload\" class=\"bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 p-3 rounded-lg\">\n          <p class=\"font-medium text-gray-900 dark:text-gray-100\">{{ modelToDownload.description }}</p>\n          <p class=\"text-sm text-gray-500 dark:text-gray-400 mt-1\">{{ modelToDownload.params_size }}</p>\n          <p class=\"text-sm text-gray-500 dark:text-gray-400 mt-1\">{{ modelToDownload.recommended_use }}</p>\n        </div>\n        <p class=\"mt-3 text-sm text-gray-500 dark:text-gray-400\">下载模型将占用一定的磁盘空间</p>\n      </div>\n    </van-dialog>\n\n    <!-- 模型删除确认对话框 -->\n    <van-dialog\n      v-model:show=\"showDeleteConfirm\"\n      title=\"删除模型确认\"\n      show-cancel-button\n      @confirm=\"startDeleteModel\"\n    >\n      <div class=\"p-4\">\n        <p class=\"text-gray-700 dark:text-gray-300 mb-3\">您确定要删除{{ modelToDelete?.description }}吗？</p>\n        <div v-if=\"modelToDelete\" class=\"bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 p-3 rounded-lg\">\n          <p class=\"font-medium text-gray-900 dark:text-gray-100\">{{ modelToDelete.description }}</p>\n          <p class=\"text-sm text-gray-500 dark:text-gray-400 mt-1\">{{ modelToDelete.params_size }}</p>\n          <p class=\"text-sm text-gray-500 dark:text-gray-400 mt-1\">{{ modelToDelete.recommended_use }}</p>\n        </div>\n        <p class=\"mt-3 text-sm text-gray-500 dark:text-gray-400\">删除模型将释放磁盘空间</p>\n      </div>\n    </van-dialog>\n  </van-dialog>\n\n  <!-- 视频下载对话框 -->\n  <DownloadDialog\n    v-model:show=\"showDownloadDialog\"\n    :video-info=\"{\n      title: video?.title || '',\n      author: video?.author || '',\n      bvid: video?.bvid || '',\n      cover: video?.cover || video?.covers?.[0] || '',\n      cid: video?.cid || 0\n    }\"\n    :default-only-audio=\"currentTab === 'local'\"\n    @download-complete=\"handleDownloadComplete\"\n  />\n</template>\n\n<script setup>\nimport { ref, computed, watch } from 'vue'\nimport { showNotify, showDialog } from 'vant'\nimport { usePrivacyStore } from '../../store/privacy'\nimport 'vant/es/notify/style'\nimport 'vant/es/dialog/style'\nimport {\n  updateVideoRemark,\n  getWhisperModels,\n  findAudioPath,\n  transcribeAudio,\n  checkSttFile,\n  summarizeByCid,\n  checkLocalSummary,\n  downloadWhisperModel,\n  deleteWhisperModel,\n  checkAudioToTextEnvironment,\n  getDeepSeekBalance,\n  checkSystemResources,\n  checkVideoDownload,\n} from '../../api/api'\nimport VideoSummary from './VideoSummary.vue'\nimport EnvironmentCheck from './EnvironmentCheck.vue'\nimport DownloadDialog from './DownloadDialog.vue'\nimport 'vant/es/dialog/style'\nimport { openInBrowser } from '@/utils/openUrl.js'\nimport { normalizeImageUrl } from '@/utils/imageUrl.js'\n\nconst props = defineProps({\n  modelValue: {\n    type: Boolean,\n    default: false,\n  },\n  video: {\n    type: Object,\n    default: null,\n  },\n  remarkData: {\n    type: Object,\n    default: () => ({}),\n  },\n})\n\nconst emit = defineEmits(['update:modelValue', 'remark-updated'])\n\n// 在脚本顶部导入部分之后加入这个ref\nconst videoSummaryKey = ref(0)\n\n// 使用计算属性处理dialog可见性\nconst dialogVisible = computed(() => props.modelValue)\nconst updateVisible = (value) => {\n  emit('update:modelValue', value)\n\n  // 当对话框关闭时重置状态\n  if (!value) {\n    // 重置标签页到默认的B站摘要tab\n    currentTab.value = 'bilibili'\n\n    // 重置摘要相关状态\n    isCheckingEnvironment.value = false\n    canRunSpeechToText.value = false\n    cudaAvailable.value = false\n    cudaSetupGuide.value = ''\n    showCudaGuide.value = true\n    audioPath.value = null\n    isCheckingAudio.value = false\n    isTranscribing.value = false\n    transcriptionResult.value = null\n    isSummarizing.value = false\n    summaryStatus.value = ''\n    summaryResult.value = null\n    hasExistingStt.value = false\n    sttFilePath.value = null\n    hasLocalSummary.value = false\n    localSummaryData.value = null\n\n    // 通过改变key值强制重新创建摘要组件\n    videoSummaryKey.value += 1\n  } else {\n    // 弹窗打开时默认刷新一次 DeepSeek 余额，防止不显示\n    refreshDeepSeekBalance()\n  }\n}\n\n// 监听弹窗显隐，进入弹窗即刷新一次余额（不依赖标签页）\nwatch(() => props.modelValue, (visible) => {\n  if (visible) {\n    refreshDeepSeekBalance()\n  }\n})\n\nconst { isPrivacyMode } = usePrivacyStore()\n\n// 备注相关\nconst remarkContent = ref('')\nconst originalRemark = ref('')\nconst remarkTime = ref(null)\n\n// 格式化时间戳\nconst formatTimestamp = (timestamp) => {\n  if (!timestamp) return '时间未知'\n\n  try {\n    const date = new Date(timestamp * 1000)\n    return date.toLocaleString('zh-CN', {\n      year: 'numeric',\n      month: '2-digit',\n      day: '2-digit',\n      hour: '2-digit',\n      minute: '2-digit',\n    })\n  } catch (error) {\n    console.error('格式化时间戳失败:', error)\n    return '时间未知'\n  }\n}\n\n// 格式化备注时间\nconst formatRemarkTime = (timestamp) => {\n  if (!timestamp) return ''\n  const date = new Date(timestamp * 1000)\n  return date.toLocaleString('zh-CN', {\n    month: '2-digit',\n    day: '2-digit',\n    hour: '2-digit',\n    minute: '2-digit',\n  })\n}\n\n// 格式化时长\nconst formatDuration = (seconds) => {\n  if (seconds === -1) return '已看完'\n  const minutes = String(Math.floor(seconds / 60)).padStart(2, '0')\n  const secs = String(seconds % 60).padStart(2, '0')\n  return `${minutes}:${secs}`\n}\n\n// 计算进度条宽度百分比\nconst getProgressWidth = (progress, duration) => {\n  if (!duration || duration <= 0 || !progress || progress < 0) return '0%'\n  const percentage = Math.min(100, (progress / duration) * 100)\n  return `${percentage}%`\n}\n\n// 获取设备类型\nconst getDeviceType = (dt) => {\n  if (dt === 1 || dt === 3 || dt === 5 || dt === 7) return '手机'\n  if (dt === 2 || dt === 33) return '电脑'\n  if (dt === 4 || dt === 6) return '平板'\n  return '未知设备'\n}\n\n// 获取业务类型\nconst getBusinessType = (business) => {\n  const businessTypes = {\n    archive: '稿件',\n    cheese: '课堂',\n    pgc: '电影',\n    live: '直播',\n    'article-list': '专栏',\n    article: '专栏',\n  }\n  return businessTypes[business] || '其他类型'\n}\n\n// 初始化备注内容\nconst initRemark = () => {\n  if (!props.video) return\n\n  const key = `${props.video.bvid}_${props.video.view_at}`\n  const data = props.remarkData[key]\n\n  if (data) {\n    remarkContent.value = data.remark || ''\n    remarkTime.value = data.remark_time || null\n    originalRemark.value = remarkContent.value // 保存原始值\n  } else {\n    remarkContent.value = ''\n    remarkTime.value = null\n    originalRemark.value = ''\n  }\n}\n\n// 处理备注失去焦点\nconst handleRemarkBlur = async () => {\n  // 如果内容没有变化，不发送请求\n  if (remarkContent.value === originalRemark.value || !props.video) {\n    return\n  }\n\n  try {\n    const response = await updateVideoRemark(\n      props.video.bvid,\n      props.video.view_at,\n      remarkContent.value,\n    )\n\n    if (response.data.success || response.data.status === 'success') {\n      if (remarkContent.value) { // 只在有内容时显示提示\n        showNotify({\n          type: 'success',\n          message: '备注已保存',\n        })\n      }\n\n      originalRemark.value = remarkContent.value // 更新原始值\n      remarkTime.value = response.data.data.remark_time // 更新备注时间\n\n      // 通知父组件备注已更新\n      emit('remark-updated', {\n        bvid: props.video.bvid,\n        view_at: props.video.view_at,\n        remark: remarkContent.value,\n        remark_time: response.data.data.remark_time,\n      })\n    }\n  } catch (error) {\n    showNotify({\n      type: 'danger',\n      message: `保存备注失败：${error.message}`,\n    })\n    remarkContent.value = originalRemark.value // 恢复原始值\n  }\n}\n\n// 在B站打开视频\nconst openInBilibili = async () => {\n  if (!props.video) return\n\n  let url = ''\n\n  switch (props.video.business) {\n    case 'archive':\n      url = `https://www.bilibili.com/video/${props.video.bvid}`\n      break\n    case 'article':\n      url = `https://www.bilibili.com/read/cv${props.video.oid}`\n      break\n    case 'article-list':\n      url = `https://www.bilibili.com/read/readlist/rl${props.video.oid}`\n      break\n    case 'live':\n      url = `https://live.bilibili.com/${props.video.oid}`\n      break\n    case 'pgc':\n      url = props.video.uri || `https://www.bilibili.com/bangumi/play/ep${props.video.epid}`\n      break\n    case 'cheese':\n      url = props.video.uri || `https://www.bilibili.com/cheese/play/ep${props.video.epid}`\n      break\n    default:\n      console.warn('未知的业务类型:', props.video.business)\n      return\n  }\n\n  if (url) {\n    await openInBrowser(url)\n  }\n}\n\n// 打开UP主页面\nconst openAuthorPage = async () => {\n  if (!props.video || !props.video.author_mid) return\n  const url = `https://space.bilibili.com/${props.video.author_mid}`\n  await openInBrowser(url)\n}\n\n// 监听video变化，初始化备注\nwatch(() => props.video, () => {\n  if (props.video) {\n    initRemark()\n  }\n}, { deep: true, immediate: true })\n\nconst tabs = [\n  { id: 'bilibili', name: 'B站AI摘要' },\n  { id: 'local', name: '使用本地摘要' },\n]\nconst currentTab = ref('bilibili')\n\n// Whisper模型相关\nconst whisperModels = ref([])\nconst selectedModel = ref(null)\nconst transcriptionStatus = ref('')\nconst audioPath = ref(null)\nconst isCheckingAudio = ref(false)\nconst showDownloadDialog = ref(false)\nconst selectedLanguage = ref('zh')\nconst isTranscribing = ref(false)\nconst transcriptionResult = ref(null)\nconst isSummarizing = ref(false)\nconst summaryStatus = ref('')\nconst summaryResult = ref(null)\n\n// 模型下载相关状态\nconst downloadingModel = ref(null) // 当前正在下载的模型\nconst isDownloadingModel = ref(false) // 是否正在下载模型\nconst showDownloadConfirm = ref(false) // 是否显示下载确认对话框\nconst modelToDownload = ref(null) // 要下载的模型\nconst hoveredModel = ref(null) // 当前悬停的模型\n\n// 模型删除相关状态\nconst isDeletingModel = ref(false) // 是否正在删除模型\nconst showDeleteConfirm = ref(false) // 是否显示删除确认对话框\nconst modelToDelete = ref(null) // 要删除的模型\n\n// 显示下载对话框\nconst handleShowDownload = () => {\n  if (!props.video) return\n  showDownloadDialog.value = true\n}\n\n// 获取Whisper模型列表\nconst fetchWhisperModels = async () => {\n  try {\n    const response = await getWhisperModels()\n    // 对模型进行排序：多语言模型在前，英语模型在后\n    whisperModels.value = response.data.sort((a, b) => {\n      // 如果一个是英语模型（包含.en），一个不是，将非英语模型排在前面\n      const aIsEnglish = a.name.includes('.en')\n      const bIsEnglish = b.name.includes('.en')\n      if (aIsEnglish !== bIsEnglish) {\n        return aIsEnglish ? 1 : -1\n      }\n      // 如果都是同类型（都是英语或都是多语言），按照大小排序（tiny -> base -> small -> medium -> large）\n      const sizeOrder = ['tiny', 'base', 'small', 'medium', 'large-v1', 'large-v2', 'large-v3']\n      const aBaseName = a.name.replace('.en', '')\n      const bBaseName = b.name.replace('.en', '')\n      return sizeOrder.indexOf(aBaseName) - sizeOrder.indexOf(bBaseName)\n    })\n\n    // 选择推荐的模型\n    if (whisperModels.value.length > 0) {\n      // 选择tiny模型\n      const tinyModel = whisperModels.value.find(model =>\n        model.name === 'tiny' && model.is_downloaded,\n      )\n\n      // 如果找到tiny模型，选择它\n      if (tinyModel) {\n        selectModel(tinyModel)\n      } else {\n        // 如果没有找到tiny模型，选择第一个下载好的模型\n        const firstDownloadedModel = whisperModels.value.find(model => model.is_downloaded)\n        if (firstDownloadedModel) {\n          selectModel(firstDownloadedModel)\n        }\n      }\n    }\n  } catch (error) {\n    console.error('获取Whisper模型列表失败:', error)\n    showNotify({\n      type: 'danger',\n      message: '获取模型列表失败',\n    })\n  }\n}\n\n// 选择模型\nconst selectModel = (model) => {\n  if (model.is_downloaded) {\n    selectedModel.value = model\n  } else {\n    showNotify({\n      type: 'warning',\n      message: '该模型尚未下载，请选择已下载的模型',\n    })\n  }\n}\n\n// 获取音频文件路径\nconst checkAudioFile = async () => {\n  try {\n    isCheckingAudio.value = true\n    transcriptionStatus.value = '检查音频文件...'\n    const response = await findAudioPath(props.video.cid)\n    audioPath.value = response.data.audio_path\n    if (audioPath.value) {\n      transcriptionStatus.value = '音频文件已找到，可以开始转录'\n    }\n    return true\n  } catch (error) {\n    console.error('查找音频文件失败:', error)\n    transcriptionStatus.value = ''\n    return false\n  } finally {\n    isCheckingAudio.value = false\n  }\n}\n\n// 在 script setup 部分添加\nconst hasExistingStt = ref(false)\nconst sttFilePath = ref(null)\n\n// 检查是否存在转换后的文件\nconst checkExistingStt = async () => {\n  try {\n    if (!props.video?.cid) return\n\n    const response = await checkSttFile(props.video.cid)\n    if (response.data.success) {\n      hasExistingStt.value = response.data.exists\n      sttFilePath.value = response.data.file_path\n\n      if (response.data.exists) {\n        transcriptionStatus.value = '已存在转换后的文件'\n      }\n    }\n  } catch (error) {\n    console.error('检查转换文件失败:', error)\n  }\n}\n\n// 在 script setup 部分添加新的响应式变量\nconst localSummaryData = ref(null)\nconst hasLocalSummary = ref(false)\n\n// 添加检查本地摘要的函数\nconst checkLocalSummaryFile = async () => {\n  try {\n    if (!props.video?.cid) return\n\n    const response = await checkLocalSummary(props.video.cid)\n    if (response.data.exists) {\n      hasLocalSummary.value = true\n      localSummaryData.value = response.data.full_response\n      transcriptionStatus.value = '已找到本地摘要'\n    }\n  } catch (error) {\n    console.error('检查本地摘要失败:', error)\n  }\n}\n\n// 修改 watch currentTab\nwatch(currentTab, async (newTab) => {\n  if (newTab === 'local') {\n    // 首先检查环境\n    isCheckingEnvironment.value = true\n    canRunSpeechToText.value = false\n    cudaSetupGuide.value = ''\n\n    try {\n      // 1. 检查系统资源\n      const resourceResponse = await checkSystemResources()\n      canRunSpeechToText.value = resourceResponse.data.can_run_speech_to_text\n      systemLimitationReason.value = resourceResponse.data.limitation_reason\n\n      // 2. 如果系统资源足够，再检查CUDA\n      if (canRunSpeechToText.value) {\n        const cudaResponse = await checkAudioToTextEnvironment()\n        cudaAvailable.value = cudaResponse.data.system_info.cuda_available\n        cudaSetupGuide.value = cudaResponse.data.system_info.cuda_setup_guide || ''\n\n        // 只有在系统资源足够时才加载其他内容\n        fetchWhisperModels()\n        await checkAudioFile()\n        await checkExistingStt()\n        await checkLocalSummaryFile()\n        await refreshDeepSeekBalance()\n      }\n    } catch (error) {\n      console.error('检查系统资源失败:', error)\n      canRunSpeechToText.value = false\n      systemLimitationReason.value = '检查系统资源失败: ' + (error.message || '未知错误')\n    } finally {\n      isCheckingEnvironment.value = false\n    }\n  }\n})\n\n// 修改 startTranscription 函数\nconst startTranscription = async () => {\n  if (!selectedModel.value) {\n    showNotify({\n      type: 'warning',\n      message: '请先选择一个模型',\n    })\n    return\n  }\n\n  if (!audioPath.value) {\n    await checkAudioFile()\n    if (!audioPath.value) {\n      return\n    }\n  }\n\n  // 检查视频时长，如果超过30分钟，显示额外确认\n  if (props.video && props.video.duration > 1800) {\n    const result = await showDialog({\n      title: '长视频警告',\n      message: '该视频时长超过30分钟，转录后可能产生大量文本，导致AI无法处理。是否继续？',\n      showCancelButton: true,\n    })\n\n    if (!result) {\n      return\n    }\n  }\n\n  if (hasExistingStt.value) {\n    const result = await showDialog({\n      title: '提示',\n      message: '已存在转换后的文件，是否重新转换？',\n      showCancelButton: true,\n    })\n\n    if (!result) {\n      return\n    }\n  }\n\n  try {\n    isTranscribing.value = true\n    transcriptionStatus.value = '准备开始转换...'\n    const response = await transcribeAudio({\n      audio_path: audioPath.value,\n      model_size: selectedModel.value.name,\n      language: selectedLanguage.value,\n      cid: props.video.cid,\n    })\n\n    if (response.data.success || response.data.status === 'success') {\n      transcriptionStatus.value = '转录任务已开始，正在处理中...'\n      showNotify({\n        type: 'success',\n        message: '转录任务已开始',\n      })\n      handleTranscriptionComplete(response.data)\n    } else {\n      isTranscribing.value = false\n      showNotify({\n        type: 'danger',\n        message: response.data.message || '开始转录失败',\n      })\n    }\n  } catch (error) {\n    console.error('转录失败:', error)\n    transcriptionStatus.value = `转录失败: ${error.message || '未知错误'}`\n    isTranscribing.value = false\n  }\n}\n\n// 检查转录状态\nconst handleTranscriptionComplete = async (response) => {\n  isTranscribing.value = false\n  transcriptionStatus.value = '转录完成'\n  transcriptionResult.value = {\n    duration: response.duration,\n    processingTime: response.processing_time,\n    languageDetected: response.language_detected,\n  }\n\n  showNotify({\n    type: 'success',\n    message: '转录完成',\n  })\n\n  // 重置摘要相关状态\n  summaryStatus.value = ''\n  summaryResult.value = null\n  isSummarizing.value = false\n\n  // 转录完成后，刷新标签内容\n  await checkExistingStt()\n  await checkLocalSummaryFile()\n}\n\n// 添加生成摘要的函数\nconst startGeneratingSummary = async () => {\n  try {\n    // 检查视频时长，如果超过30分钟，显示额外确认\n    if (props.video && props.video.duration > 1800) {\n      const result = await showDialog({\n        title: '长视频警告',\n        message: '该视频时长超过30分钟，转录文本可能过长，导致AI无法处理摘要请求。是否继续？',\n        showCancelButton: true,\n      })\n\n      if (!result) {\n        return\n      }\n    }\n\n    isSummarizing.value = true\n    summaryStatus.value = '正在生成摘要...'\n\n    const response = await summarizeByCid(props.video.cid)\n    if (response.data.success || response.data.status === 'success') {\n      summaryStatus.value = '摘要生成完成'\n      summaryResult.value = response.data.summary\n      showNotify({\n        type: 'success',\n        message: '摘要生成完成',\n      })\n\n      // 摘要生成完成后，刷新标签内容\n      await checkLocalSummaryFile()\n    } else {\n      summaryStatus.value = '摘要生成失败：' + (response.data.message || '未知错误')\n      showNotify({\n        type: 'warning',\n        message: '摘要生成失败',\n      })\n    }\n  } catch (error) {\n    console.error('生成摘要失败:', error)\n    summaryStatus.value = '摘要生成失败：' + (error.message || '未知错误')\n    showNotify({\n      type: 'danger',\n      message: '生成摘要失败：' + (error.message || '未知错误'),\n    })\n  } finally {\n    isSummarizing.value = false\n  }\n}\n\n// 在 script setup 部分添加\nconst environmentCheck = ref(null)\nconst isCheckingEnvironment = ref(true)\nconst canRunSpeechToText = ref(false)\nconst systemLimitationReason = ref('')\nconst cudaAvailable = ref(false)\nconst cudaSetupGuide = ref('')\nconst showCudaGuide = ref(true)  // 默认显示CUDA安装指南\n\n// 处理环境检查结果\nconst handleEnvironmentCheck = (result) => {\n  isCheckingEnvironment.value = false\n  canRunSpeechToText.value = result.canRun\n  systemLimitationReason.value = result.limitationReason\n}\n\n// 修改 handleTimeClick 函数和添加 hasTimeStamp 函数\nconst hasTimeStamp = (text) => {\n  return /\\d{2}:\\d{2}[-–]\\d{2}:\\d{2}/.test(text)\n}\n\nconst handleTimeClick = async (section) => {\n  const timeMatch = section.match(/(\\d{2}):(\\d{2})[-–](\\d{2}):(\\d{2})/)\n  if (timeMatch) {\n    const startMinutes = parseInt(timeMatch[1])\n    const startSeconds = parseInt(timeMatch[2])\n    const startTime = startMinutes * 60 + startSeconds\n\n    // 构建B站视频URL并跳转\n    const url = `https://www.bilibili.com/video/${props.video.bvid}?t=${startTime}`\n    await openInBrowser(url)\n  }\n}\n\n// 提取时间戳的函数\nconst extractTimeStamp = (text) => {\n  const match = text.match(/(\\d{2}:\\d{2}[-–]\\d{2}:\\d{2})/)\n  return match ? match[1] : ''\n}\n\n// 处理下载完成事件\nconst handleDownloadComplete = async () => {\n  // 下载完成后，刷新标签内容\n  checkAudioFile()\n  checkExistingStt()\n  checkLocalSummaryFile()\n\n  // 重新检查视频下载状态\n  await checkIsVideoDownloaded()\n\n  showNotify({\n    type: 'success',\n    message: '下载完成',\n    duration: 2000,\n  })\n}\n\n// 下载模型确认对话框相关\nconst showModelDownloadConfirm = (model) => {\n  modelToDownload.value = model\n  showDownloadConfirm.value = true\n}\n\nconst startDownloadModel = async () => {\n  if (!modelToDownload.value) return\n\n  try {\n    isDownloadingModel.value = true\n    downloadingModel.value = modelToDownload.value\n    const response = await downloadWhisperModel(modelToDownload.value.name)\n    if (response.data.success || response.data.status === 'success') {\n      showNotify({\n        type: 'success',\n        message: response.data.message || '模型下载成功',\n      })\n      // 下载完成后，刷新模型列表\n      fetchWhisperModels()\n    } else {\n      showNotify({\n        type: 'danger',\n        message: response.data.message || '模型下载失败',\n      })\n    }\n  } catch (error) {\n    console.error('下载模型失败:', error)\n    showNotify({\n      type: 'danger',\n      message: '下载模型失败',\n    })\n  } finally {\n    isDownloadingModel.value = false\n    downloadingModel.value = null\n  }\n}\n\n// 模型删除确认对话框相关\nconst showModelDeleteConfirm = (model) => {\n  modelToDelete.value = model\n  showDeleteConfirm.value = true\n}\n\nconst startDeleteModel = async () => {\n  if (!modelToDelete.value) return\n\n  try {\n    isDeletingModel.value = true\n    const response = await deleteWhisperModel(modelToDelete.value.name)\n    if (response.data.success || response.data.status === 'success') {\n      showNotify({\n        type: 'success',\n        message: response.data.message || '模型删除成功',\n      })\n      // 删除完成后，刷新模型列表\n      fetchWhisperModels()\n    } else {\n      showNotify({\n        type: 'danger',\n        message: response.data.message || '模型删除失败',\n      })\n    }\n  } catch (error) {\n    console.error('删除模型失败:', error)\n    showNotify({\n      type: 'danger',\n      message: '删除模型失败',\n    })\n  } finally {\n    isDeletingModel.value = false\n    modelToDelete.value = null\n  }\n}\n\n// DeepSeek相关状态\nconst deepseekBalance = ref({\n  is_available: false,\n  balance_infos: [],\n})\n\n// 添加刷新DeepSeek余额的方法\nconst refreshDeepSeekBalance = async () => {\n  try {\n    const response = await getDeepSeekBalance()\n    deepseekBalance.value = response.data\n  } catch (error) {\n    console.error('获取DeepSeek余额失败:', error)\n    deepseekBalance.value = { is_available: false }\n  }\n}\n\n// 下载相关\nconst isVideoDownloaded = ref(false)\nconst downloadedFiles = ref([])\n\n// 检查视频是否已下载\nconst checkIsVideoDownloaded = async () => {\n  try {\n    // 如果没有CID，则无法检查\n    if (!props.video?.cid) return\n\n    const response = await checkVideoDownload(props.video.cid)\n    if (response.data && response.data.status === 'success') {\n      isVideoDownloaded.value = response.data.downloaded\n\n      if (isVideoDownloaded.value && response.data.files) {\n        downloadedFiles.value = response.data.files\n      } else {\n        downloadedFiles.value = []\n      }\n    }\n  } catch (error) {\n    console.error('检查视频下载状态出错:', error)\n    isVideoDownloaded.value = false\n    downloadedFiles.value = []\n  }\n}\n\n// 监听视频变化，检查下载状态\nwatch(() => props.video?.cid, (newCid) => {\n  if (newCid) {\n    checkIsVideoDownloaded()\n  } else {\n    isVideoDownloaded.value = false\n    downloadedFiles.value = []\n  }\n})\n</script>\n\n<style>\n.video-detail-dialog :deep(.van-dialog) {\n  border-radius: 0.5rem;\n  overflow: hidden;\n}\n\n/* 对话框正文背景与页面正文一致 */\n.video-detail-dialog :deep(.van-dialog__content) {\n  background-color: #ffffff; /* light */\n}\n.dark .video-detail-dialog :deep(.van-dialog__content) {\n  background-color: #1f2937; /* gray-800 */\n}\n\n.video-detail-dialog :deep(.van-dialog__header) {\n  padding: 12px 16px;\n  background-color: #ffffff; /* light: white 与正文一致 */\n  border-bottom: 1px solid #e5e7eb; /* gray-200 */\n  color: #111827; /* gray-900 */\n}\n.dark .video-detail-dialog :deep(.van-dialog__header) {\n  background-color: #1f2937; /* dark: gray-800 与正文一致 */\n  border-bottom: 1px solid #374151; /* gray-700 */\n  color: #e5e7eb; /* gray-200 */\n}\n</style>\n\n"
  },
  {
    "path": "src/components/tailwind/VideoPlayerDialog.vue",
    "content": "<template>\n  <Teleport to=\"body\">\n    <div v-if=\"show\" class=\"fixed inset-0 z-[9999] flex items-center justify-center\">\n      <!-- 遮罩层 -->\n      <div class=\"fixed inset-0 bg-black/80 backdrop-blur-sm\" @click=\"handleClose\"></div>\n\n      <!-- 视频播放器 -->\n      <div class=\"relative bg-black rounded-lg shadow-xl w-[90%] max-w-4xl max-h-[90vh] z-10 overflow-hidden\">\n        <!-- 关闭按钮 -->\n        <button\n          @click=\"handleClose\"\n          class=\"absolute right-4 top-4 text-white/70 hover:text-white z-20 bg-black/40 p-2 rounded-full\"\n        >\n          <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\" />\n          </svg>\n        </button>\n\n        <!-- 视频标题 -->\n        <div class=\"absolute top-0 left-0 right-0 p-4 bg-gradient-to-b from-black/80 to-transparent z-10\">\n          <h3 class=\"text-white text-lg font-medium truncate\">\n            {{ getFileName(videoPath) }}\n          </h3>\n          <div v-if=\"danmakuFile\" class=\"text-green-400 text-xs mt-1 flex items-center\">\n            <svg class=\"w-3.5 h-3.5 mr-1\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 13l4 4L19 7\" />\n            </svg>\n            <span>已加载弹幕</span>\n          </div>\n        </div>\n\n        <!-- 视频播放器 -->\n        <div class=\"w-full h-full aspect-video max-h-[80vh] relative\">\n          <!-- ArtPlayer播放器 -->\n          <div v-show=\"activeVideo\" class=\"w-full h-full\">\n            <ArtPlayerWithDanmaku\n              v-if=\"activeVideo\"\n              ref=\"artPlayerRef\"\n              :videoSrc=\"videoSrc\"\n              :cid=\"currentCid\"\n              :danmakuFilePath=\"danmakuFile\"\n              :title=\"getFileName(videoPath)\"\n              :autoplay=\"true\"\n              :width=\"'100%'\"\n              :height=\"'100%'\"\n            />\n          </div>\n        </div>\n\n        <!-- 错误提示 -->\n        <div v-if=\"error\" class=\"absolute inset-0 flex items-center justify-center bg-black/90\">\n          <div class=\"text-center p-6\">\n            <svg class=\"w-16 h-16 text-red-500 mx-auto mb-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z\" />\n            </svg>\n            <h3 class=\"text-xl font-medium text-white mb-2\">视频播放失败</h3>\n            <p class=\"text-white/70 mb-4\">{{ errorMessage }}</p>\n            <button \n              @click=\"handleClose\"\n              class=\"px-4 py-2 bg-[#fb7299] text-white rounded-md hover:bg-[#fb7299]/90 transition-colors\"\n            >\n              关闭\n            </button>\n          </div>\n        </div>\n      </div>\n    </div>\n  </Teleport>\n</template>\n\n<script setup>\nimport { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'\nimport { getVideoStream, getDanmakuFile } from '../../api/api'\nimport ArtPlayerWithDanmaku from './ArtPlayerWithDanmaku.vue'\n\ndefineOptions({\n  name: 'VideoPlayerDialog'\n})\n\nconst props = defineProps({\n  show: {\n    type: Boolean,\n    default: false\n  },\n  videoPath: {\n    type: String,\n    default: ''\n  }\n})\n\nconst emit = defineEmits(['update:show'])\n\n// 播放器引用\nconst artPlayerRef = ref(null)\nconst isLoading = ref(false)\nconst activeVideo = ref(false)\nconst videoSrc = ref('')\nconst danmakuFile = ref('')\nconst currentCid = ref('')\n\n// 错误状态\nconst error = ref(false)\nconst errorMessage = ref('')\n\n// 获取文件名\nconst getFileName = (path) => {\n  if (!path) return '未知文件'\n  return path.split('\\\\').pop().split('/').pop() || '未知文件'\n}\n\n// 从视频路径中提取CID\nconst extractCid = (path) => {\n  if (!path) return ''\n  \n  // 尝试从文件名中提取CID\n  const fileName = getFileName(path)\n  const cidMatch = fileName.match(/_(\\d+)\\.mp4$/) || fileName.match(/_(\\d+)\\.flv$/) || fileName.match(/_(\\d+)\\.m4a$/)\n  \n  if (cidMatch && cidMatch[1]) {\n    return cidMatch[1]\n  }\n  \n  // 如果文件名中没有CID，尝试从目录路径中提取\n  const dirMatch = path.match(/(\\d{8,})/)\n  return dirMatch ? dirMatch[1] : ''\n}\n\n// 获取弹幕文件路径\nconst findDanmakuFile = (videoPath) => {\n  if (!videoPath) return ''\n  \n  // 根据视频文件路径推断弹幕文件路径\n  const fileNameWithoutExt = videoPath.replace(/\\.(mp4|flv|m4a)$/i, '')\n  return `${fileNameWithoutExt}.ass`\n}\n\n// 处理播放错误\nconst handlePlayError = (error) => {\n  console.error('视频播放错误:', error)\n  isLoading.value = false\n  error.value = true\n  errorMessage.value = `无法播放视频，错误信息: ${error?.message || '未知错误'}`\n}\n\n// 销毁播放器\nconst destroyPlayer = () => {\n  if (artPlayerRef.value && artPlayerRef.value.player) {\n    artPlayerRef.value.player.destroy()\n  }\n  \n  // 重置变量\n  videoSrc.value = ''\n  danmakuFile.value = ''\n  currentCid.value = ''\n  activeVideo.value = false\n}\n\n// 关闭对话框\nconst handleClose = () => {\n  // 销毁播放器\n  destroyPlayer()\n  \n  // 重置错误状态\n  error.value = false\n  errorMessage.value = ''\n  isLoading.value = false\n  \n  // 通知父组件关闭对话框\n  emit('update:show', false)\n}\n\n// 加载视频\nconst loadVideo = () => {\n  if (!props.videoPath) return\n  \n  // 确保之前的播放器已被销毁\n  destroyPlayer()\n  \n  // 提取CID\n  currentCid.value = extractCid(props.videoPath)\n  \n  // 查找弹幕文件\n  danmakuFile.value = findDanmakuFile(props.videoPath)\n  \n  // 使用api生成视频流URL\n  videoSrc.value = getVideoStream(props.videoPath)\n  \n  // 激活视频元素\n  nextTick(() => {\n    activeVideo.value = true\n  })\n}\n\n// 监听show变化\nwatch(() => props.show, (newVal) => {\n  if (newVal) {\n    // 对话框打开时加载视频\n    loadVideo()\n  } else {\n    // 对话框关闭时销毁播放器\n    destroyPlayer()\n  }\n})\n\n// 监听videoPath变化\nwatch(() => props.videoPath, (newVal) => {\n  if (props.show && newVal) {\n    // 如果对话框已打开并且视频路径变化，重新加载视频\n    loadVideo()\n  }\n})\n\n// 组件挂载时初始化\nonMounted(() => {\n  if (props.show && props.videoPath) {\n    loadVideo()\n  }\n})\n\n// 组件卸载时清理资源\nonUnmounted(() => {\n  destroyPlayer()\n})\n</script>\n\n<style scoped>\n/* 当对话框显示时禁用页面滚动 */\n:deep(body) {\n  overflow: hidden;\n}\n\n.animate-spin {\n  animation: spin 1s linear infinite;\n}\n\n@keyframes spin {\n  from {\n    transform: rotate(0deg);\n  }\n  to {\n    transform: rotate(360deg);\n  }\n}\n</style> "
  },
  {
    "path": "src/components/tailwind/VideoRecord.vue",
    "content": "<template>\n  <!-- 每条记录的容器 -->\n  <div\n    class=\"mx-auto max-w-2xl cursor-pointer border-b border-gray-200 dark:border-gray-700 transition-all duration-200 ease-in-out sm:px-4 lg:max-w-4xl lg:px-0 relative\"\n    :class=\"{\n      'group': !showDownloadDialog,\n      'border-[#fb7299] bg-[#fff9fb] dark:bg-[#fb7299]/10 ring-1 ring-[#fb7299]/20': isBatchMode && isSelected,\n      'hover:border-[#fb7299] hover:bg-[#f5f5f5] dark:hover:bg-gray-800 hover:shadow-md': !isBatchMode || !isSelected\n    }\"\n    @click=\"handleClick\"\n  >\n    <!-- 内层容器 -->\n    <div class=\"p-2\">\n      <!-- 当类型为文章或文集时，图片铺满整行，标题在上方 -->\n      <div v-if=\"record.business === 'article-list' || record.business === 'article'\">\n        <!-- 标题在封面图片上方 -->\n        <div class=\"mb-2\">\n          <div\n            class=\"line-clamp-2 text-gray-900 dark:text-gray-100 lm:text-sm lg:font-semibold\"\n            v-html=\"isPrivacyMode ? '******' : highlightedTitle\"\n            :class=\"{ 'blur-sm': isPrivacyMode }\"\n          ></div>\n        </div>\n        <!-- 封面图片，铺满整行 -->\n        <div class=\"relative h-28 w-full overflow-hidden rounded-lg\">\n          <!-- 删除按钮 -->\n          <div v-if=\"!isBatchMode\"\n               class=\"absolute right-0 top-0 z-20 hidden group-hover:flex flex-row items-center justify-end pt-1 pr-1\">\n            <div class=\"flex items-center justify-end space-x-2\">\n              <!-- 下载按钮 - 只对视频类型显示 -->\n              <div v-if=\"record.business === 'archive'\"\n                   class=\"flex items-center justify-center w-7 h-7 bg-[#7d7c75]/60 backdrop-blur-sm hover:bg-[#7d7c75]/80 rounded-md cursor-pointer transition-all duration-200\"\n                   @click.stop.prevent=\"handleDownload\"\n                   title=\"下载视频\">\n                <svg class=\"w-4 h-4 text-white\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4\" />\n                </svg>\n              </div>\n              <!-- 收藏按钮 - 不对直播类型显示 -->\n              <div v-if=\"record.business !== 'live'\"\n                   class=\"flex items-center justify-center w-7 h-7 bg-[#7d7c75]/60 backdrop-blur-sm hover:bg-[#7d7c75]/80 rounded-md cursor-pointer transition-all duration-200\"\n                   @click.stop.prevent=\"handleFavorite\"\n                   title=\"收藏视频\">\n                <svg class=\"w-4 h-4\" :class=\"isVideoFavorited ? 'text-yellow-400' : 'text-white'\" :fill=\"isVideoFavorited ? 'currentColor' : 'none'\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z\" />\n                </svg>\n              </div>\n              <!-- 删除按钮 -->\n              <div class=\"flex items-center justify-center w-7 h-7 bg-[#7d7c75]/60 backdrop-blur-sm hover:bg-[#7d7c75]/80 rounded-md cursor-pointer transition-all duration-200\"\n                   @click.stop=\"handleDelete\"\n                   title=\"删除记录\">\n                <svg class=\"w-4 h-4 text-white\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16\" />\n                </svg>\n              </div>\n              <!-- 详情按钮 - 只对普通视频类型显示 -->\n              <div v-if=\"record.business === 'archive'\"\n                   class=\"flex items-center justify-center w-7 h-7 bg-[#7d7c75]/60 backdrop-blur-sm hover:bg-[#7d7c75]/80 rounded-md cursor-pointer transition-all duration-200\"\n                   @click.stop=\"showDetailDialog = true\"\n                   title=\"查看详情\">\n                <svg class=\"w-4 h-4 text-white\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n                </svg>\n              </div>\n            </div>\n          </div>\n          <!-- 多选框 -->\n          <div v-if=\"isBatchMode\"\n               class=\"absolute left-2 top-2 z-10\"\n               @click.stop=\"$emit('toggle-selection', record)\">\n            <div class=\"w-5 h-5 rounded border-2 flex items-center justify-center\"\n                 :class=\"isSelected ? 'bg-[#fb7299] border-[#fb7299]' : 'border-white bg-black/20'\">\n              <svg v-if=\"isSelected\" class=\"w-3 h-3 text-white\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"3\" d=\"M5 13l4 4L19 7\" />\n              </svg>\n            </div>\n          </div>\n          <!-- 批量模式下的收藏状态图标 - 不对直播类型显示 -->\n          <div v-if=\"isBatchMode && record.business !== 'live'\"\n               class=\"absolute right-2 top-2 z-10\">\n            <div class=\"flex items-center justify-center w-6 h-6 rounded-full bg-black/40 backdrop-blur-sm\">\n              <svg class=\"w-4 h-4\" :class=\"isVideoFavorited ? 'text-yellow-400' : 'text-white'\" :fill=\"isVideoFavorited ? 'currentColor' : 'none'\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z\" />\n              </svg>\n            </div>\n          </div>\n          <!-- 已下载标识 -->\n          <div v-if=\"isDownloaded && record.business === 'archive'\"\n               class=\"absolute left-0 top-0 z-10\">\n            <div class=\"bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] text-white font-semibold px-2 py-0.5 text-xs flex items-center space-x-1.5 rounded-br-md shadow-md\">\n              <svg class=\"w-3 h-3\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4\" />\n              </svg>\n              <span>已下载</span>\n            </div>\n          </div>\n\n          <!-- 收藏状态标识 - 不对直播类型显示 -->\n          <div v-if=\"isVideoFavorited && record.business !== 'live'\"\n               class=\"absolute right-0 top-0 z-10\">\n            <div class=\"bg-gradient-to-r from-amber-500 to-yellow-400 text-white font-semibold px-2 py-0.5 text-xs flex items-center space-x-1.5 rounded-bl-md shadow-md\">\n              <svg class=\"w-3 h-3\" fill=\"currentColor\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z\" />\n              </svg>\n              <span>已收藏</span>\n            </div>\n          </div>\n\n          <img\n            :src=\"normalizeImageUrl(record.cover || record.covers[0])\"\n            class=\"h-full w-full object-cover transition-all duration-300\"\n            :class=\"{ 'blur-md': isPrivacyMode }\"\n            alt=\"\"\n          />\n          <!-- 右上角的类型角标 -->\n          <div\n            v-if=\"record.badge\"\n            class=\"absolute right-1 top-1 rounded bg-[#FF6699] px-1 py-0.5 text-[10px] font-semibold text-white\"\n          >\n            {{ record.badge }}\n          </div>\n        </div>\n        <!-- 文章类型：作者信息、观看设备、时间放在封面图片下方 -->\n        <div class=\"mt-2 flex items-center justify-between text-sm text-[#99a2aa] lm:text-xs\">\n          <!-- 左侧：仅当类型不是剧集或课程时，显示作者头像和名字 -->\n          <div\n            v-if=\"record.business !== 'cheese' && record.business !== 'pgc'\"\n            class=\"flex items-center space-x-2\"\n            @click.stop\n          >\n            <img\n              :src=\"normalizeImageUrl(record.author_face)\"\n              alt=\"author\"\n              class=\"h-4 w-4 cursor-pointer rounded-full transition-all duration-300 hover:scale-110 lg:h-8 lg:w-8\"\n              :class=\"{ 'blur-md': isPrivacyMode }\"\n              @click=\"handleAuthorClick\"\n              :title=\"isPrivacyMode ? '隐私模式已开启' : `访问 ${record.author_name} 的个人空间`\"\n            />\n            <p\n              class=\"cursor-pointer transition-colors hover:text-[#FF6699]\"\n              @click=\"handleAuthorClick\"\n              :title=\"isPrivacyMode ? '隐私模式已开启' : `访问 ${record.author_name} 的个人空间`\"\n              v-html=\"isPrivacyMode ? '******' : highlightedAuthorName\"\n            ></p>\n          </div>\n          <!-- 右侧：设备和时间信息 -->\n          <div class=\"flex items-center space-x-2\">\n            <img\n              v-if=\"record.dt === 1 || record.dt === 3 || record.dt === 5 || record.dt === 7\"\n              src=\"/Mobile.svg\"\n              alt=\"Mobile\"\n              class=\"h-4 w-4 lg:h-8 lg:w-8\"\n            />\n            <img\n              v-else-if=\"record.dt === 2 || record.dt === 33\"\n              src=\"/PC.svg\"\n              alt=\"PC\"\n              class=\"h-4 w-4 lg:h-8 lg:w-8\"\n            />\n            <img\n              v-else-if=\"record.dt === 4 || record.dt === 6\"\n              src=\"/Pad.svg\"\n              alt=\"Pad\"\n              class=\"h-4 w-4 lg:h-8 lg:w-8\"\n            />\n            <p v-else>未知设备</p>\n            <!-- 显示时间 -->\n            <span>{{ formatTimestamp(record.view_at) }}</span>\n          </div>\n        </div>\n      </div>\n\n      <!-- 其他类型的展示方式 -->\n      <div v-else class=\"flex space-x-2\">\n        <!-- 封面图片区域 -->\n        <div class=\"relative h-20 w-32 overflow-hidden rounded-lg sm:h-28 sm:w-40\">\n          <!-- 多选框 -->\n          <div v-if=\"isBatchMode\"\n               class=\"absolute left-2 top-2 z-10\"\n               @click.stop=\"$emit('toggle-selection', record)\">\n            <div class=\"w-5 h-5 rounded border-2 flex items-center justify-center\"\n                 :class=\"isSelected ? 'bg-[#fb7299] border-[#fb7299]' : 'border-white bg-black/20'\">\n              <svg v-if=\"isSelected\" class=\"w-3 h-3 text-white\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"3\" d=\"M5 13l4 4L19 7\" />\n              </svg>\n            </div>\n          </div>\n          <!-- 批量模式下的收藏状态图标 - 不对直播类型显示 -->\n          <div v-if=\"isBatchMode && record.business !== 'live'\"\n               class=\"absolute right-2 top-2 z-10\">\n            <div class=\"flex items-center justify-center w-6 h-6 rounded-full bg-black/40 backdrop-blur-sm\">\n              <svg class=\"w-4 h-4\" :class=\"isVideoFavorited ? 'text-yellow-400' : 'text-white'\" :fill=\"isVideoFavorited ? 'currentColor' : 'none'\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z\" />\n              </svg>\n            </div>\n          </div>\n          <!-- 已下载标识 -->\n          <div v-if=\"isDownloaded && record.business === 'archive'\"\n               class=\"absolute left-0 top-0 z-10\">\n            <div class=\"bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] text-white font-semibold px-2 py-0.5 text-xs flex items-center space-x-1.5 rounded-br-md shadow-md\">\n              <svg class=\"w-3 h-3\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4\" />\n              </svg>\n              <span>已下载</span>\n            </div>\n          </div>\n\n          <!-- 收藏状态标识 - 不对直播类型显示 -->\n          <div v-if=\"isVideoFavorited && record.business !== 'live'\"\n               class=\"absolute right-0 top-0 z-10\">\n            <div class=\"bg-gradient-to-r from-amber-500 to-yellow-400 text-white font-semibold px-2 py-0.5 text-xs flex items-center space-x-1.5 rounded-bl-md shadow-md\">\n              <svg class=\"w-3 h-3\" fill=\"currentColor\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z\" />\n              </svg>\n              <span>已收藏</span>\n            </div>\n          </div>\n\n          <img\n            v-if=\"record.cover\"\n            :src=\"normalizeImageUrl(record.cover)\"\n            class=\"h-full w-full object-cover transition-all duration-300\"\n            :class=\"{ 'blur-md': isPrivacyMode }\"\n            alt=\"\"\n          />\n          <div v-else>\n            <div v-for=\"(cover, index) in record.covers\" :key=\"index\" class=\"mb-1\">\n              <img\n                :src=\"normalizeImageUrl(cover)\"\n                class=\"h-full w-full object-cover transition-all duration-300\"\n                :class=\"{ 'blur-md': isPrivacyMode }\"\n                alt=\"\"\n              />\n            </div>\n          </div>\n          <!-- 右上角的类型角标 -->\n          <div\n            v-if=\"record.badge\"\n            class=\"absolute right-1 top-1 rounded bg-[#FF6699] px-1 py-0.5 text-[10px] font-semibold text-white\"\n          >\n            {{ record.badge }}\n          </div>\n          <!-- 右下角的时间进度角标和进度条，仅当不是文章时显示 -->\n          <div\n            v-if=\"\n              record.business !== 'article-list' &&\n              record.business !== 'article' &&\n              record.business !== 'live'\n            \"\n          >\n            <div\n              class=\"absolute bottom-1 right-1 rounded bg-black/50 px-1 py-1 text-[10px] font-semibold text-white\"\n            >\n              <span>{{ formatDuration(record.progress) }}</span>\n              <span>/</span>\n              <span>{{ formatDuration(record.duration) }}</span>\n            </div>\n            <div class=\"absolute bottom-0 left-0 h-0.5 w-full bg-black\">\n              <div\n                class=\"h-full bg-[#FF6699]\"\n                :style=\"{ width: getProgressWidth(record.progress, record.duration) }\"\n              ></div>\n            </div>\n          </div>\n        </div>\n\n        <!-- 右侧内容区域 -->\n        <div class=\"ml-2 flex flex-1 flex-col justify-between lm:text-sm lg:font-semibold relative\">\n          <!-- 删除按钮 -->\n          <div v-if=\"!isBatchMode\"\n               class=\"absolute right-0 top-0 z-20 hidden group-hover:flex flex-row items-center justify-end pt-1 pr-1\">\n            <div class=\"flex items-center justify-end space-x-2\">\n              <!-- 下载按钮 - 只对视频类型显示 -->\n              <div v-if=\"record.business === 'archive'\"\n                   class=\"flex items-center justify-center w-7 h-7 bg-[#7d7c75]/60 backdrop-blur-sm hover:bg-[#7d7c75]/80 rounded-md cursor-pointer transition-all duration-200\"\n                   @click.stop.prevent=\"handleDownload\"\n                   title=\"下载视频\">\n                <svg class=\"w-4 h-4 text-white\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4\" />\n                </svg>\n              </div>\n              <!-- 收藏按钮 - 不对直播类型显示 -->\n              <div v-if=\"record.business !== 'live'\"\n                   class=\"flex items-center justify-center w-7 h-7 bg-[#7d7c75]/60 backdrop-blur-sm hover:bg-[#7d7c75]/80 rounded-md cursor-pointer transition-all duration-200\"\n                   @click.stop.prevent=\"handleFavorite\"\n                   title=\"收藏视频\">\n                <svg class=\"w-4 h-4\" :class=\"isVideoFavorited ? 'text-yellow-400' : 'text-white'\" :fill=\"isVideoFavorited ? 'currentColor' : 'none'\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z\" />\n                </svg>\n              </div>\n              <!-- 删除按钮 -->\n              <div class=\"flex items-center justify-center w-7 h-7 bg-[#7d7c75]/60 backdrop-blur-sm hover:bg-[#7d7c75]/80 rounded-md cursor-pointer transition-all duration-200\"\n                   @click.stop=\"handleDelete\"\n                   title=\"删除记录\">\n                <svg class=\"w-4 h-4 text-white\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16\" />\n                </svg>\n              </div>\n              <!-- 详情按钮 - 只对普通视频类型显示 -->\n              <div v-if=\"record.business === 'archive'\"\n                   class=\"flex items-center justify-center w-7 h-7 bg-[#7d7c75]/60 backdrop-blur-sm hover:bg-[#7d7c75]/80 rounded-md cursor-pointer transition-all duration-200\"\n                   @click.stop=\"showDetailDialog = true\"\n                   title=\"查看详情\">\n                <svg class=\"w-4 h-4 text-white\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n                </svg>\n              </div>\n            </div>\n          </div>\n          <div class=\"items-center justify-between lg:flex\">\n            <div\n              class=\"line-clamp-2 text-gray-900 dark:text-gray-100 lm:text-sm lg:font-semibold\"\n              v-html=\"isPrivacyMode ? '******' : highlightedTitle\"\n              :class=\"{ 'blur-sm': isPrivacyMode }\"\n            ></div>\n          </div>\n          <div class=\"flex items-center space-x-2\">\n            <span\n              v-if=\"record.business !== 'pgc'\"\n              class=\"inline-flex items-center rounded-md bg-[#f1f2f3] dark:bg-gray-700 px-2 py-1 text-xs text-[#71767d] dark:text-gray-300\"\n            >\n              {{ record.tag_name }}\n            </span>\n\n            <!-- 备注输入框 -->\n            <div class=\"flex-1 relative group\" @click.stop>\n              <div class=\"flex items-center space-x-1\">\n                <span class=\"text-xs text-[#fb7299]\">备注:</span>\n                <input\n                  type=\"text\"\n                  v-model=\"remarkContent\"\n                  @focus=\"handleRemarkFocus\"\n                  @blur=\"handleRemarkBlur\"\n                  placeholder=\"添加备注...\"\n                  :disabled=\"isPrivacyMode\"\n                  class=\"flex-1 px-2 py-1 text-xs text-[#fb7299] bg-[#f8f8f8] dark:bg-gray-800 dark:text-[#fb7299] rounded border-0 border-b border-transparent hover:border-gray-200 dark:hover:border-gray-600 focus:border-[#fb7299] focus:ring-0 transition-colors duration-200 placeholder-[#fb7299]/50\"\n                  :class=\"{ 'blur-sm': isPrivacyMode }\"\n                />\n                <span v-if=\"remarkTime\" class=\"text-xs text-gray-400\">{{ formatRemarkTime(remarkTime) }}</span>\n              </div>\n              <div v-if=\"!remarkContent && !isPrivacyMode\" class=\"absolute right-2 top-1/2 -translate-y-1/2 hidden group-hover:block\">\n                <svg class=\"w-3 h-3 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z\" />\n                </svg>\n              </div>\n            </div>\n          </div>\n\n          <div class=\"flex items-end justify-between text-sm text-[#99a2aa] lm:text-xs\">\n            <!-- PGC内容显示long_title -->\n            <div v-if=\"record.business === 'pgc'\" class=\"flex items-center space-x-2\">\n              <p class=\"text-[#99a2aa] dark:text-gray-400\">{{ record.long_title }}</p>\n            </div>\n            <!-- 非PGC内容显示UP主信息 -->\n            <div v-else class=\"flex items-center space-x-2\" @click.stop>\n              <img\n                :src=\"normalizeImageUrl(record.author_face)\"\n                alt=\"author\"\n                class=\"h-5 w-5 cursor-pointer rounded-full transition-all duration-300 hover:scale-110 lg:h-8 lg:w-8\"\n                :class=\"{ 'blur-md': isPrivacyMode }\"\n                @click=\"handleAuthorClick\"\n                :title=\"isPrivacyMode ? '隐私模式已开启' : `访问 ${record.author_name} 的个人空间`\"\n              />\n              <p\n                class=\"cursor-pointer transition-colors hover:text-[#FF6699]\"\n                @click=\"handleAuthorClick\"\n                :title=\"isPrivacyMode ? '隐私模式已开启' : `访问 ${record.author_name} 的个人空间`\"\n                v-html=\"isPrivacyMode ? '******' : highlightedAuthorName\"\n              ></p>\n            </div>\n\n            <div class=\"flex items-center space-x-2\">\n              <img\n                v-if=\"record.dt === 1 || record.dt === 3 || record.dt === 5 || record.dt === 7\"\n                src=\"/Mobile.svg\"\n                alt=\"Mobile\"\n                class=\"h-4 w-4 lg:h-8 lg:w-8\"\n              />\n              <img\n                v-else-if=\"record.dt === 2 || record.dt === 33\"\n                src=\"/PC.svg\"\n                alt=\"PC\"\n                class=\"h-4 w-4 lg:h-8 lg:w-8\"\n              />\n              <img\n                v-else-if=\"record.dt === 4 || record.dt === 6\"\n                src=\"/Pad.svg\"\n                alt=\"Pad\"\n                class=\"h-4 w-4 lg:h-8 lg:w-8\"\n              />\n              <p v-else>未知设备</p>\n\n              <!-- 显示时间 -->\n              <span>{{ formatTimestamp(record.view_at) }}</span>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <!-- 下载弹窗 -->\n    <Teleport to=\"body\">\n      <DownloadDialog\n        v-model:show=\"showDownloadDialog\"\n        :video-info=\"{\n          title: record.title,\n          author: record.author_name,\n          bvid: record.bvid,\n          cover: record.cover || record.covers?.[0],\n          cid: record.cid\n        }\"\n        :is-batch-download=\"false\"\n      />\n    </Teleport>\n\n    <!-- 视频详情对话框 -->\n    <Teleport to=\"body\">\n      <VideoDetailDialog\n        :modelValue=\"showDetailDialog\"\n        @update:modelValue=\"showDetailDialog = $event\"\n        :video=\"record\"\n        :remarkData=\"remarkData\"\n        @remark-updated=\"$emit('remark-updated', $event)\"\n      />\n    </Teleport>\n  </div>\n</template>\n\n<script setup>\nimport { computed, ref, onMounted, watch } from 'vue'\nimport { usePrivacyStore } from '../../store/privacy'\nimport { showDialog, showNotify } from 'vant'\nimport { batchDeleteHistory, updateVideoRemark, deleteBilibiliHistory } from '../../api/api'\nimport 'vant/es/dialog/style'\nimport 'vant/es/popup/style'\nimport 'vant/es/field/style'\nimport DownloadDialog from './DownloadDialog.vue'\nimport VideoDetailDialog from './VideoDetailDialog.vue'\nimport { openInBrowser } from '@/utils/openUrl.js'\nimport { normalizeImageUrl } from '@/utils/imageUrl.js'\n\nconst { isPrivacyMode } = usePrivacyStore()\n\nconst props = defineProps({\n  record: {\n    type: Object,\n    required: true\n  },\n  searchKeyword: {\n    type: String,\n    default: ''\n  },\n  searchType: {\n    type: String,\n    default: 'title'\n  },\n  isBatchMode: {\n    type: Boolean,\n    default: false\n  },\n  isSelected: {\n    type: Boolean,\n    default: false\n  },\n  remarkData: {\n    type: Object,\n    default: () => ({})\n  },\n  isDownloaded: {\n    type: Boolean,\n    default: false\n  },\n  isVideoFavorited: {\n    type: Boolean,\n    default: false\n  }\n})\n\nconst emit = defineEmits([\n  'toggle-selection',\n  'refresh-data',\n  'remark-updated',\n  'favorite'\n])\n\nconst remarkContent = ref('')\nconst originalRemark = ref('') // 用于存储原始备注内容\nconst remarkTime = ref(null)\nconst showDetailDialog = ref(false)\n\n// 高亮显示匹配的文本\nconst highlightText = (text) => {\n  if (!props.searchKeyword || !text) return text\n\n  // 将搜索关键词按空格分割成数组\n  const keywords = props.searchKeyword.split(/\\s+/).filter(k => k)\n  let highlightedText = text\n\n  // 对每个关键词进行高亮处理\n  keywords.forEach(keyword => {\n    const regex = new RegExp(keyword.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&'), 'gi')\n    highlightedText = highlightedText.replace(regex, match => `<span class=\"text-[#FF6699]\">${match}</span>`)\n  })\n\n  return highlightedText\n}\n\n// 获取高亮后的标题\nconst highlightedTitle = computed(() => {\n  if (!props.searchKeyword) return props.record.title\n  if (props.searchType === 'all' || props.searchType === 'title') {\n    return highlightText(props.record.title)\n  }\n  return props.record.title\n})\n\n// 获取高亮后的作者名称\nconst highlightedAuthorName = computed(() => {\n  if (!props.searchKeyword) return props.record.author_name\n  if (props.searchType === 'all' || props.searchType === 'author') {\n    return highlightText(props.record.author_name)\n  }\n  return props.record.author_name\n})\n\n// 处理点击事件\nconst handleClick = () => {\n  if (props.isBatchMode) {\n    emit('toggle-selection', props.record)\n  } else {\n    handleContentClick()\n  }\n}\n\n// 处理内容点击事件\nconst handleContentClick = async () => {\n  let url = ''\n\n  switch (props.record.business) {\n    case 'archive':\n      url = `https://www.bilibili.com/video/${props.record.bvid}`\n      break\n    case 'article':\n      url = `https://www.bilibili.com/read/cv${props.record.oid}`\n      break\n    case 'article-list':\n      url = `https://www.bilibili.com/read/readlist/rl${props.record.oid}`\n      break\n    case 'live':\n      url = `https://live.bilibili.com/${props.record.oid}`\n      break\n    case 'pgc':\n      url = props.record.uri || `https://www.bilibili.com/bangumi/play/ep${props.record.epid}`\n      break\n    case 'cheese':\n      url = props.record.uri || `https://www.bilibili.com/cheese/play/ep${props.record.epid}`\n      break\n    default:\n      console.warn('未知的业务类型:', props.record.business)\n      return\n  }\n\n  if (url) {\n    await openInBrowser(url)\n  }\n}\n\n// 处理UP主点击事件\nconst handleAuthorClick = async () => {\n  const url = `https://space.bilibili.com/${props.record.author_mid}`\n  await openInBrowser(url)\n}\n\n// 修改时间戳显示相关的代码\nconst formatTimestamp = (timestamp) => {\n  // 检查 timestamp 是否有效\n  if (!timestamp) {\n    console.warn('Invalid timestamp:', timestamp)\n    return '时间未知'\n  }\n\n  try {\n    // 将秒级时间戳转换为毫秒级\n    const date = new Date(timestamp * 1000)\n    const now = new Date()\n\n    // 检查日期是否有效\n    if (isNaN(date.getTime())) {\n      console.warn('Invalid date from timestamp:', timestamp)\n      return '时间未知'\n    }\n\n    const isToday = now.toDateString() === date.toDateString()\n    const isYesterday =\n      new Date(now.setDate(now.getDate() - 1)).toDateString() === date.toDateString()\n    const timeString = date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })\n\n    if (isToday) {\n      return timeString\n    } else if (isYesterday) {\n      return `昨天 ${timeString}`\n    } else if (now.getFullYear() === date.getFullYear()) {\n      return `${date.getMonth() + 1}-${date.getDate()} ${timeString}`\n    } else {\n      return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()} ${timeString}`\n    }\n  } catch (error) {\n    console.error('Error formatting timestamp:', error)\n    return '时间未知'\n  }\n}\n\nconst formatDuration = (seconds) => {\n  if (seconds === -1) return '已看完'\n  const minutes = String(Math.floor(seconds / 60)).padStart(2, '0')\n  const secs = String(seconds % 60).padStart(2, '0')\n  return `${minutes}:${secs}`\n}\n\nconst getProgressWidth = (progress, duration) => {\n  if (progress === -1) return '100%'\n  if (duration === 0) return '0%'\n  return `${(progress / duration) * 100}%`\n}\n\n// 处理删除事件\nconst handleDelete = async () => {\n  try {\n    // 检查是否需要同步删除B站历史记录\n    const syncDeleteToBilibili = localStorage.getItem('syncDeleteToBilibili') === 'true'\n\n    // 根据是否同步删除B站历史记录，显示不同的确认信息\n    await showDialog({\n      title: '确认删除',\n      message: syncDeleteToBilibili\n        ? '确定要删除这条记录吗？此操作将同时删除B站服务器上的历史记录，不可恢复。'\n        : '确定要删除这条记录吗？此操作不可恢复。',\n      showCancelButton: true,\n      confirmButtonText: '确认删除',\n      cancelButtonText: '取消',\n      confirmButtonColor: '#fb7299'\n    })\n\n    if (syncDeleteToBilibili) {\n      try {\n        // 构建kid\n        let kid = ''\n        const business = props.record.business || 'archive'\n\n        // 根据业务类型构建kid\n        switch (business) {\n          case 'archive':\n            // 使用oid而不是bvid\n            kid = `${business}_${props.record.oid}`\n            break\n          case 'live':\n            kid = `${business}_${props.record.oid}`\n            break\n          case 'article':\n            kid = `${business}_${props.record.oid}`\n            break\n          case 'pgc':\n            kid = `${business}_${props.record.oid || props.record.ssid}`\n            break\n          case 'article-list':\n            kid = `${business}_${props.record.oid || props.record.rlid}`\n            break\n          default:\n            kid = `${business}_${props.record.oid || props.record.bvid}`\n            break\n        }\n\n        if (kid) {\n          // 调用B站历史记录删除API\n          const biliResponse = await deleteBilibiliHistory(kid, true)\n          if (biliResponse.data.status === 'success') {\n            console.log('B站历史记录删除成功:', biliResponse.data)\n          } else {\n            console.error('B站历史记录删除失败:', biliResponse.data)\n            throw new Error(biliResponse.data.message || '删除B站历史记录失败')\n          }\n        }\n      } catch (error) {\n        console.error('B站历史记录删除失败:', error)\n        // 即使B站删除失败，也继续删除本地记录\n      }\n    }\n\n    // 删除本地记录\n    const response = await batchDeleteHistory([{\n      bvid: props.record.bvid,\n      view_at: props.record.view_at\n    }])\n    if (response.data.status === 'success') {\n      showNotify({\n        type: 'success',\n        message: response.data.message + (syncDeleteToBilibili ? '，并已同步删除B站历史记录' : '')\n      })\n      emit('refresh-data')\n    } else {\n      throw new Error(response.data.message || '删除失败')\n    }\n  } catch (error) {\n    if (error.toString().includes('cancel')) return\n\n    showNotify({\n      type: 'danger',\n      message: error.response?.data?.detail || error.message || '删除失败'\n    })\n  }\n}\n\n// 格式化备注时间\nconst formatRemarkTime = (timestamp) => {\n  if (!timestamp) return ''\n  const date = new Date(timestamp * 1000)\n  return date.toLocaleString('zh-CN', {\n    month: '2-digit',\n    day: '2-digit',\n    hour: '2-digit',\n    minute: '2-digit'\n  })\n}\n\n// 修改初始化备注内容的方法\nconst initRemark = () => {\n  const key = `${props.record.bvid}_${props.record.view_at}`\n  const data = props.remarkData[key]\n  if (data) {\n    remarkContent.value = data.remark || ''\n    remarkTime.value = data.remark_time || null\n    originalRemark.value = remarkContent.value // 保存原始值\n  } else {\n    remarkContent.value = ''\n    remarkTime.value = null\n    originalRemark.value = ''\n  }\n}\n\n// 修改备注更新方法\nconst handleRemarkBlur = async () => {\n  // 如果内容没有变化，不发送请求\n  if (remarkContent.value === originalRemark.value) {\n    return\n  }\n\n  try {\n    const response = await updateVideoRemark(\n      props.record.bvid,\n      props.record.view_at,\n      remarkContent.value\n    )\n    if (response.data.status === 'success') {\n      if (remarkContent.value) { // 只在有内容时显示提示\n        showNotify({\n          type: 'success',\n          message: '备注已保存'\n        })\n      }\n      originalRemark.value = remarkContent.value // 更新原始值\n      remarkTime.value = response.data.data.remark_time // 更新备注时间\n      // 通知父组件备注已更新\n      emit('remark-updated', {\n        bvid: props.record.bvid,\n        view_at: props.record.view_at,\n        remark: remarkContent.value,\n        remark_time: response.data.data.remark_time\n      })\n    }\n  } catch (error) {\n    showNotify({\n      type: 'danger',\n      message: `保存备注失败：${error.message}`\n    })\n    remarkContent.value = originalRemark.value // 恢复原始值\n  }\n}\n\n// 组件挂载时初始化备注\nonMounted(() => {\n  initRemark()\n})\n\n// 监听 remarkData 的变化\nwatch(() => props.remarkData, () => {\n  initRemark()\n}, { deep: true })\n\n// 下载弹窗状态\nconst showDownloadDialog = ref(false)\n\n// 处理下载按钮点击\nconst handleDownload = () => {\n  showDownloadDialog.value = true\n}\n\n// 处理收藏按钮点击\nconst handleFavorite = () => {\n  // 触发父组件的favorite事件\n  emit('favorite', props.record)\n}\n</script>\n\n<style scoped>\n.line-clamp-2 {\n  display: -webkit-box;\n  -webkit-box-orient: vertical;\n  -webkit-line-clamp: 1;\n  line-clamp: 1;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n/* 可以添加一些额外的悬停效果样式 */\n.hover\\:scale-110:hover {\n  transform: scale(1.1);\n}\n\n.hover\\:text-\\[\\#FF6699\\]:hover {\n  color: #ff6699;\n}\n\n/* 添加 group-hover 相关样式 */\n.group:hover .group-hover\\:flex {\n  display: flex;\n}\n</style>\n"
  },
  {
    "path": "src/components/tailwind/VideoSummary.vue",
    "content": "<template>\n  <div class=\"w-full bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4\">\n    <!-- 摘要内容显示区域 -->\n    <div v-if=\"summary && !loading && !error\" class=\"space-y-4\">\n      <div class=\"flex items-center justify-between\">\n        <h3 class=\"text-sm font-medium text-gray-900 dark:text-gray-100 flex items-center\">\n          <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-4 w-4 mr-1 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\"\n               stroke=\"currentColor\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                  d=\"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z\" />\n          </svg>\n          <span>AI视频摘要</span>\n        </h3>\n\n        <button\n          @click=\"refreshSummary\"\n          class=\"text-xs text-gray-500 dark:text-gray-400 hover:text-[#fb7299] flex items-center space-x-1\"\n          :disabled=\"loading\"\n        >\n          <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-3.5 w-3.5\" fill=\"none\" viewBox=\"0 0 24 24\"\n               stroke=\"currentColor\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                  d=\"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15\" />\n          </svg>\n          <span>刷新</span>\n        </button>\n      </div>\n\n      <!-- 总体摘要 -->\n      <div class=\"bg-gray-50 dark:bg-gray-700 rounded-lg p-3\">\n        <p class=\"text-xs text-gray-700 dark:text-gray-300 whitespace-pre-line leading-relaxed\">{{ summary }}</p>\n      </div>\n\n      <!-- 视频大纲 -->\n      <div v-if=\"outline && outline.length > 0\" class=\"mt-4\">\n        <h4 class=\"text-xs font-medium text-gray-800 dark:text-gray-200 mb-2 flex items-center\">\n          <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-3.5 w-3.5 mr-1 text-[#fb7299]\" fill=\"none\"\n               viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                  d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2\" />\n          </svg>\n          <span>视频大纲</span>\n        </h4>\n\n        <div class=\"space-y-3\">\n          <div v-for=\"(section, index) in outline\" :key=\"index\" class=\"border-l-2 border-[#fb7299]/30 pl-3 py-1\">\n            <!-- 章节标题 -->\n            <div class=\"flex items-start\">\n              <a\n                :href=\"`https://www.bilibili.com/video/${props.bvid}?t=${section.timestamp}`\"\n                target=\"_blank\"\n                class=\"inline-flex items-center text-xs font-medium text-[#fb7299] hover:underline\"\n              >\n                <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-3 w-3 mr-1\" fill=\"none\" viewBox=\"0 0 24 24\"\n                     stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                        d=\"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z\" />\n                </svg>\n                {{ formatTime(section.timestamp) }}\n              </a>\n              <h5 class=\"text-xs font-medium text-gray-800 dark:text-gray-200 ml-2\">{{ section.title }}</h5>\n            </div>\n\n            <!-- 章节要点 -->\n            <div v-if=\"section.part_outline && section.part_outline.length > 0\" class=\"mt-1 ml-4 space-y-1\">\n              <div v-for=\"(point, pIndex) in section.part_outline\" :key=\"`${index}-${pIndex}`\" class=\"flex items-start\">\n                <a\n                  :href=\"`https://www.bilibili.com/video/${props.bvid}?t=${point.timestamp}`\"\n                  target=\"_blank\"\n                  class=\"inline-flex items-center text-[10px] text-gray-500 hover:text-[#fb7299] hover:underline mt-0.5\"\n                >\n                  {{ formatTime(point.timestamp) }}\n                </a>\n                <p class=\"text-[11px] text-gray-600 dark:text-gray-400 ml-2\">{{ point.content }}</p>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <div v-if=\"fromCache\" class=\"text-xs text-gray-400 dark:text-gray-500 flex items-center mt-2\">\n        <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-3 w-3 mr-1\" fill=\"none\" viewBox=\"0 0 24 24\"\n             stroke=\"currentColor\">\n          <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n        </svg>\n        <span>摘要来自缓存</span>\n      </div>\n    </div>\n\n    <!-- 加载状态 -->\n    <div v-if=\"loading\" class=\"py-6 space-y-3\">\n      <div class=\"flex items-center\">\n        <svg class=\"animate-spin h-4 w-4 text-[#fb7299] mr-2\" fill=\"none\" viewBox=\"0 0 24 24\">\n          <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n          <path class=\"opacity-75\" fill=\"currentColor\"\n                d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n        </svg>\n        <h3 class=\"text-sm font-medium text-gray-900 dark:text-gray-100\">AI正在生成视频摘要...</h3>\n      </div>\n      <div class=\"animate-pulse flex space-x-4\">\n        <div class=\"flex-1 space-y-3\">\n          <div class=\"h-2 bg-gray-200 rounded\"></div>\n          <div class=\"h-2 bg-gray-200 rounded\"></div>\n          <div class=\"h-2 bg-gray-200 rounded\"></div>\n          <div class=\"h-2 bg-gray-200 rounded w-5/6\"></div>\n        </div>\n      </div>\n      <p class=\"text-xs text-gray-400 dark:text-gray-500\">首次生成可能需要一些时间，请耐心等待</p>\n    </div>\n\n    <!-- 错误状态 -->\n    <div v-if=\"error && !loading\" class=\"py-8 flex flex-col items-center justify-center\">\n      <h3 class=\"text-sm font-medium text-gray-900 dark:text-gray-100 flex items-center\">\n        <svg v-if=\"errorIsWarning\" xmlns=\"http://www.w3.org/2000/svg\" class=\"h-4 w-4 text-amber-500 mr-1\" fill=\"none\"\n             viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n          <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                d=\"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z\" />\n        </svg>\n        <svg v-else-if=\"isGenerating\" xmlns=\"http://www.w3.org/2000/svg\" class=\"h-4 w-4 text-blue-500 mr-1\" fill=\"none\"\n             viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n          <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M13 10V3L4 14h7v7l9-11h-7z\" />\n        </svg>\n        <svg v-else xmlns=\"http://www.w3.org/2000/svg\" class=\"h-4 w-4 text-red-500 mr-1\" fill=\"none\" viewBox=\"0 0 24 24\"\n             stroke=\"currentColor\">\n          <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                d=\"M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n        </svg>\n        <span>{{ errorTitle }}</span>\n      </h3>\n      <p class=\"text-xs text-gray-500 dark:text-gray-400 mt-2 text-center\">{{ error }}</p>\n      <div class=\"mt-4 flex items-center space-x-3\">\n        <button\n          v-if=\"shouldShowRetryButton\"\n          @click=\"() => fetchSummary(false)\"\n          class=\"text-xs bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded px-3 py-1.5\"\n        >\n          重试\n        </button>\n        <button\n          v-if=\"isGenerating\"\n          @click=\"() => fetchSummary(false)\"\n          class=\"text-xs bg-[#fb7299] hover:bg-[#fc8bad] text-white rounded px-3 py-1.5\"\n        >\n          查看进度\n        </button>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, computed, watch } from 'vue'\nimport { getVideoSummary } from '../../api/api'\nimport { showNotify } from 'vant'\nimport 'vant/es/notify/style'\n\nconst props = defineProps({\n  bvid: {\n    type: String,\n    required: true,\n  },\n  cid: {\n    type: [String, Number],\n    required: true,\n  },\n  upMid: {\n    type: [String, Number],\n    required: true,\n  },\n})\n\nconst summary = ref('')\nconst outline = ref(null)\nconst loading = ref(false)\nconst error = ref(null)\nconst fromCache = ref(false)\nconst stid = ref('')\n\n// 当props变化时，清除旧数据并重新获取摘要\nwatch(\n  () => [props.bvid, props.cid, props.upMid],\n  (newValues, oldValues) => {\n    // 如果是组件首次加载，不需要处理\n    if (!oldValues[0]) return\n\n    // 如果有任一值变化，说明是新的视频，清除旧数据并重新获取\n    if (\n      newValues[0] !== oldValues[0] ||\n      newValues[1] !== oldValues[1] ||\n      newValues[2] !== oldValues[2]\n    ) {\n      // 清除旧数据\n      clearSummaryData()\n      // 获取新数据\n      fetchSummary()\n    }\n  },\n)\n\n// 清除摘要数据\nconst clearSummaryData = () => {\n  summary.value = ''\n  outline.value = null\n  error.value = null\n  fromCache.value = false\n  stid.value = ''\n}\n\n// 将秒数格式化为时分秒\nconst formatTime = (seconds) => {\n  if (!seconds && seconds !== 0) return '00:00'\n\n  const hours = Math.floor(seconds / 3600)\n  const minutes = Math.floor((seconds % 3600) / 60)\n  const secs = Math.floor(seconds % 60)\n\n  if (hours > 0) {\n    return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`\n  } else {\n    return `${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`\n  }\n}\n\n// 获取视频摘要\nconst fetchSummary = async (forceRefresh = false) => {\n  if (!props.bvid || !props.cid || !props.upMid) {\n    error.value = '缺少必要参数，无法获取视频摘要'\n    return\n  }\n\n  loading.value = true\n  error.value = null\n\n  try {\n    const response = await getVideoSummary(\n      props.bvid,\n      props.cid,\n      props.upMid,\n      forceRefresh,\n    )\n\n    // 处理API返回的数据\n    const data = response.data\n\n    // 保存stid\n    stid.value = data.stid || ''\n\n    // 根据has_summary和result_type判断状态\n    if (data.has_summary) {\n      // 有摘要内容\n      summary.value = data.summary || ''\n      outline.value = data.outline || null\n      fromCache.value = data.from_cache !== undefined ? data.from_cache : false\n    } else {\n      // 根据result_type判断状态\n      switch (data.result_type) {\n        case -1:\n          error.value = '该视频不支持AI摘要（可能包含敏感内容）'\n          break\n        case 0:\n          error.value = data.status_message || '该视频没有摘要'\n          break\n        case 1:\n          // 仅存在摘要总结\n          summary.value = data.summary || ''\n          outline.value = null\n          fromCache.value = data.from_cache !== undefined ? data.from_cache : false\n          break\n        case 2:\n          // 存在摘要以及提纲\n          summary.value = data.summary || ''\n          outline.value = data.outline || null\n          fromCache.value = data.from_cache !== undefined ? data.from_cache : false\n          break\n        default:\n          error.value = data.status_message || '获取摘要失败'\n      }\n    }\n  } catch (err) {\n    error.value = err.response?.data?.detail || err.message || '网络错误，请稍后重试'\n    console.error('获取视频摘要失败:', err)\n  } finally {\n    loading.value = false\n  }\n}\n\n// 强制刷新摘要\nconst refreshSummary = async (event) => {\n  // 忽略事件对象，传递true作为force_refresh参数\n  await fetchSummary(true)\n  showNotify({\n    type: 'success',\n    message: '摘要已更新',\n  })\n}\n\n// 组件挂载时获取摘要\nonMounted(() => {\n  if (props.bvid && props.cid && props.upMid) {\n    // 先清除数据以显示加载状态\n    clearSummaryData()\n    loading.value = true\n    // 延迟一下再获取，确保加载状态能够显示\n    setTimeout(() => {\n      fetchSummary()\n    }, 100)\n  }\n})\n\n// 计算是否显示重试按钮\nconst shouldShowRetryButton = computed(() => {\n  // 网络错误或处理错误时显示重试按钮\n  if (!error.value) return false\n\n  // 这些情况下不显示重试按钮，因为重试没有意义\n  const noRetryMessages = [\n    '未识别到视频语音，无法生成摘要',\n    '该视频不支持AI摘要',\n    '缺少必要参数',\n  ]\n\n  return !noRetryMessages.some(msg => error.value.includes(msg))\n})\n\n// 判断错误是否为警告类型（黄色图标）\nconst errorIsWarning = computed(() => {\n  if (!error.value) return false\n\n  const warningMessages = [\n    '未识别到视频语音',\n    '此视频暂无摘要',\n    '该视频不支持AI摘要',\n  ]\n\n  return warningMessages.some(msg => error.value.includes(msg))\n})\n\n// 根据错误类型返回适当的标题\nconst errorTitle = computed(() => {\n  if (!error.value) return '获取摘要失败'\n\n  if (isGenerating.value) {\n    return '摘要生成中'\n  } else if (errorIsWarning.value) {\n    return '无法生成摘要'\n  } else {\n    return '获取摘要失败'\n  }\n})\n\n// 判断是否为\"生成中\"状态\nconst isGenerating = computed(() => {\n  if (!error.value) return false\n  return error.value.includes('正在生成')\n})\n</script>\n\n<style scoped>\n/* 平滑滚动 */\nhtml {\n  scroll-behavior: smooth;\n}\n</style>\n"
  },
  {
    "path": "src/components/tailwind/analytics/layout/AnalyticsLayout.vue",
    "content": "<!-- 年度总结通用布局组件 -->\n<template>\n  <div class=\"absolute inset-0 bg-gradient-to-b from-[#fef6f9] to-[#fff9fa] dark:from-[#2c2c2c] dark:to-[#1f1f1f] overflow-hidden\">\n    <!-- 背景装饰 -->\n    <div class=\"absolute inset-0 overflow-hidden pointer-events-none\">\n      <div class=\"absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-[#fb7299]/10 to-[#fc9b7a]/10 rounded-full blur-3xl animate-float\"></div>\n      <div class=\"absolute -bottom-20 -left-20 w-80 h-80 bg-gradient-to-tr from-[#fc9b7a]/10 to-[#fb7299]/10 rounded-full blur-3xl animate-float-delay\"></div>\n    </div>\n\n    <div class=\"relative min-h-screen flex items-center justify-center\">\n      <div class=\"max-w-7xl w-full mx-auto px-8\">\n        <slot></slot>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\n// 无需额外的逻辑\n</script>\n\n<style>\n@keyframes float {\n  0%, 100% { transform: translate(0, 0); }\n  50% { transform: translate(-20px, 20px); }\n}\n\n@keyframes float-delay {\n  0%, 100% { transform: translate(0, 0); }\n  50% { transform: translate(20px, -20px); }\n}\n\n.animate-float {\n  animation: float 8s ease-in-out infinite;\n}\n\n.animate-float-delay {\n  animation: float 8s ease-in-out infinite;\n  animation-delay: -4s;\n}\n</style> "
  },
  {
    "path": "src/components/tailwind/analytics/pages/AuthorCompletionPage.vue",
    "content": "<!-- UP主完成率分析页组件 -->\n<template>\n  <div class=\"absolute inset-0\">\n    <div class=\"h-full flex items-center justify-center\">\n      <div class=\"max-w-7xl w-full mx-auto px-2 py-4 overflow-y-auto\">\n        <div class=\"space-y-4\">\n          <h3 class=\"text-3xl font-bold text-center bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent\">\n            UP主完成率分析\n          </h3>\n\n          <div class=\"text-sm text-center text-gray-800 dark:text-gray-200 mb-2 space-y-1 px-4\">\n            <div v-if=\"viewingData?.insights?.most_valuable_author\" v-html=\"formatInsightText(viewingData.insights.most_valuable_author)\">\n            </div>\n            <div v-if=\"viewingData?.insights?.highest_completion_author\" v-html=\"formatInsightText(viewingData.insights.highest_completion_author)\">\n            </div>\n            <div v-if=\"viewingData?.insights?.potential_discovery\" v-html=\"formatInsightText(viewingData.insights.potential_discovery)\">\n            </div>\n            <div v-if=\"viewingData?.insights?.viewing_behavior_summary\" v-html=\"formatInsightText(viewingData.insights.viewing_behavior_summary)\">\n            </div>\n          </div>\n\n          <!-- 图表容器 -->\n          <div class=\"grid grid-cols-1 lg:grid-cols-2 gap-3\">\n            <div class=\"bg-white/50 dark:bg-white/5 backdrop-blur-sm rounded-xl p-3 border border-gray-300/50 dark:border-gray-500/50 h-[420px]\">\n              <h4 class=\"text-base font-bold text-center bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent mb-3\">\n                最喜欢的UP主\n              </h4>\n              <v-chart ref=\"favoriteChartRef\" class=\"h-[360px] w-full\" :option=\"favoriteOption\" autoresize @click=\"handleFavoriteClick\" />\n            </div>\n\n            <div class=\"bg-white/50 dark:bg-white/5 backdrop-blur-sm rounded-xl p-3 border border-gray-300/50 dark:border-gray-500/50 h-[420px]\">\n              <h4 class=\"text-base font-bold text-center bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent mb-3\">\n                观看最多的UP主\n              </h4>\n              <v-chart ref=\"mostWatchedChartRef\" class=\"h-[360px] w-full\" :option=\"mostWatchedOption\" autoresize @click=\"handleMostWatchedClick\" />\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted } from 'vue'\nimport VChart from 'vue-echarts'\nimport * as echarts from 'echarts/core'\nimport { CanvasRenderer } from 'echarts/renderers'\nimport { BarChart, LineChart, RadarChart } from 'echarts/charts'\nimport {\n  GridComponent,\n  TooltipComponent,\n  LegendComponent,\n  TitleComponent,\n  DataZoomComponent,\n  RadarComponent\n} from 'echarts/components'\nimport { use } from 'echarts/core'\nimport { useDarkMode } from '@/store/darkMode.js'\n\n// 注册必要的组件\nuse([\n  CanvasRenderer,\n  BarChart,\n  LineChart,\n  RadarChart,\n  GridComponent,\n  TooltipComponent,\n  LegendComponent,\n  TitleComponent,\n  DataZoomComponent,\n  RadarComponent\n])\n\nconst props = defineProps({\n  viewingData: {\n    type: Object,\n    required: true\n  }\n})\n\nconst favoriteChartRef = ref(null)\nconst mostWatchedChartRef = ref(null)\nconst { isDarkMode } = useDarkMode()\n\n// 获取最喜欢的UP主数据（使用most_valuable_authors）\nconst sortedFavoriteAuthors = computed(() => {\n  if (!props.viewingData?.completion_rates?.most_valuable_authors) return []\n  \n  return Object.entries(props.viewingData.completion_rates.most_valuable_authors)\n    .sort((a, b) => b[1].comprehensive_score - a[1].comprehensive_score)\n    .slice(0, 10) // 显示10个UP主\n})\n\n// 获取观看最多的UP主数据\nconst sortedMostWatchedAuthors = computed(() => {\n  if (!props.viewingData?.completion_rates?.most_watched_authors) return []\n  \n  return Object.entries(props.viewingData.completion_rates.most_watched_authors)\n    .sort((a, b) => b[1].video_count - a[1].video_count)\n    .slice(0, 10)\n})\n\n// 最喜欢的UP主雷达图配置\nconst favoriteOption = computed(() => {\n  const data = sortedFavoriteAuthors.value.map(([author, stats]) => ({\n    name: author,\n    value: [\n      stats.comprehensive_score,\n      stats.loyalty_score,\n      stats.quality_score,\n      stats.average_completion_rate,\n      stats.fully_watched_rate,\n      stats.video_count / 10 // 缩放视频数量以适应雷达图\n    ],\n    authorMid: stats.author_mid\n  }))\n\n  const isDark = !!(isDarkMode && isDarkMode.value)\n  const legendTextColor = isDark ? '#bbbbbb' : '#666'\n  const axisLabelColor = isDark ? '#bbbbbb' : '#999'\n  const axisLineColor = isDark ? '#444' : '#ddd'\n  const tooltipBg = isDark ? 'rgba(28, 28, 28, 0.9)' : 'rgba(255, 255, 255, 0.95)'\n  const tooltipText = isDark ? '#ffffff' : '#111111'\n\n  return {\n    tooltip: {\n      trigger: 'item',\n      backgroundColor: tooltipBg,\n      borderColor: '#fb7299',\n      textStyle: { color: tooltipText },\n      formatter: (params) => {\n        const stats = sortedFavoriteAuthors.value.find(([author]) => author === params.name)?.[1]\n        if (!stats) return ''\n        return `${params.name}<br/>\n                综合评分：${stats.comprehensive_score.toFixed(1)}<br/>\n                喜爱度评分：${stats.loyalty_score.toFixed(1)}<br/>\n                质量评分：${stats.quality_score.toFixed(1)}<br/>\n                平均完成率：${stats.average_completion_rate.toFixed(1)}%<br/>\n                完整观看率：${stats.fully_watched_rate.toFixed(1)}%<br/>\n                视频数量：${stats.video_count}个`\n      }\n    },\n    legend: {\n      data: data.map(item => item.name),\n      textStyle: { color: legendTextColor, fontSize: 10 },\n      left: 'left',\n      orient: 'vertical',\n      top: 'middle',\n      type: 'scroll',\n      width: 70\n    },\n    radar: {\n      center: ['65%', '50%'],\n      radius: '65%',\n      indicator: [\n        { name: '综合评分', max: 100 },\n        { name: '喜爱度', max: 100 },\n        { name: '质量评分', max: 100 },\n        { name: '完成率', max: 100 },\n        { name: '完整观看率', max: 100 },\n        { name: '视频数量', max: 20 }\n      ],\n      name: {\n        textStyle: {\n          color: axisLabelColor,\n          fontSize: 12,\n          fontWeight: 'bold'\n        }\n      },\n      axisLabel: {\n        color: axisLabelColor\n      },\n      axisLine: {\n        lineStyle: { color: axisLineColor }\n      },\n      splitLine: {\n        lineStyle: { color: axisLineColor }\n      }\n    },\n    series: [{\n      type: 'radar',\n      data: data.map((item, index) => {\n        const colors = [\n          '#fb7299', // 粉色\n          '#40a9ff', // 蓝色\n          '#66d980', // 绿色\n          '#fc9b7a', // 橙色\n          '#9254de', // 紫色\n          '#ffc53d', // 黄色\n          '#ff6b6b', // 红色\n          '#4ecdc4', // 青色\n          '#45b7d1', // 天蓝色\n          '#f39c12'  // 深橙色\n        ]\n        const color = colors[index % colors.length]\n        return {\n          ...item,\n          itemStyle: {\n            color: color\n          },\n          lineStyle: {\n            color: color,\n            width: 2\n          },\n          // 默认不显示背景区域\n          emphasis: {\n            itemStyle: {\n              color: color\n            },\n            lineStyle: {\n              color: color,\n              width: 4\n            },\n            areaStyle: {\n              color: color,\n              opacity: 0.4\n            }\n          }\n        }\n      })\n    }]\n  }\n})\n\n// 观看最多的UP主散点图配置\nconst mostWatchedOption = computed(() => {\n  const data = sortedMostWatchedAuthors.value.map(([author, stats]) => ([\n    stats.video_count,\n    stats.average_completion_rate,\n    stats.fully_watched,\n    author,\n    stats.author_mid\n  ]))\n\n  const isDarkMW = !!(isDarkMode && isDarkMode.value)\n  const axisLabelColor = isDarkMW ? '#bbbbbb' : '#999'\n  const axisLineColor = isDarkMW ? '#888888' : '#666'\n  const splitLineColor = isDarkMW ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.1)'\n  const tooltipBg = isDarkMW ? 'rgba(28, 28, 28, 0.9)' : 'rgba(255, 255, 255, 0.95)'\n  const tooltipText = isDarkMW ? '#ffffff' : '#111111'\n  return {\n    tooltip: {\n      trigger: 'item',\n      backgroundColor: tooltipBg,\n      borderColor: '#fb7299',\n      textStyle: { color: tooltipText },\n      formatter: (params) => {\n        return `${params.data[3]}<br/>\n                观看视频数：${params.data[0]}个<br/>\n                平均完成率：${params.data[1].toFixed(1)}%<br/>\n                完整观看数：${params.data[2]}个`\n      }\n    },\n    grid: {\n      top: '10%',\n      left: '12%',\n      right: '8%',\n      bottom: '15%'\n    },\n    xAxis: {\n      type: 'value',\n      name: '观看视频数',\n      nameLocation: 'middle',\n      nameGap: 25,\n      axisLine: { lineStyle: { color: axisLineColor } },\n      axisLabel: { color: axisLabelColor },\n      splitLine: { lineStyle: { color: splitLineColor } }\n    },\n    yAxis: {\n      type: 'value',\n      name: '平均完成率(%)',\n      nameLocation: 'middle',\n      nameGap: 35,\n      axisLine: { lineStyle: { color: axisLineColor } },\n      axisLabel: { color: axisLabelColor },\n      splitLine: { lineStyle: { color: splitLineColor } }\n    },\n    series: [{\n      type: 'scatter',\n      symbolSize: (data) => Math.sqrt(data[2]) * 3, // 根据完整观看数调整点的大小\n      data: data,\n      itemStyle: {\n        color: (params) => {\n          const colors = ['#fb7299', '#40a9ff', '#66d980', '#fc9b7a', '#9254de', '#ffc53d']\n          return colors[params.dataIndex % colors.length]\n        },\n        opacity: 0.8\n      },\n      emphasis: {\n        focus: 'item',\n        itemStyle: {\n          opacity: 1,\n          borderColor: '#333',\n          borderWidth: 2\n        }\n      }\n    }]\n  }\n})\n\n// 点击事件处理函数\nconst handleFavoriteClick = (params) => {\n  if (params.componentType === 'series') {\n    const authorData = sortedFavoriteAuthors.value.find(([author]) => author === params.name)\n    if (authorData?.[1]?.author_mid) {\n      handleAuthorClick(authorData[1].author_mid)\n    }\n  }\n}\n\nconst handleMostWatchedClick = (params) => {\n  if (params.componentType === 'series') {\n    const authorData = sortedMostWatchedAuthors.value.find(([author]) => author === params.data[3])\n    if (authorData?.[1]?.author_mid) {\n      handleAuthorClick(authorData[1].author_mid)\n    }\n  }\n}\n\nconst handleAuthorClick = (mid) => {\n  window.open(`https://space.bilibili.com/${mid}`, '_blank')\n}\n\n// 添加事件监听\nonMounted(() => {\n  const favoriteChart = favoriteChartRef.value\n  const mostWatchedChart = mostWatchedChartRef.value\n\n  // 雷达图点击事件（通过ECharts内置事件处理）\n  if (favoriteChart) {\n    favoriteChart.chart.on('click', (params) => {\n      if (params.componentType === 'series') {\n        const authorData = sortedFavoriteAuthors.value.find(([author]) => author === params.name)\n        if (authorData?.[1]?.author_mid) {\n          handleAuthorClick(authorData[1].author_mid)\n        }\n      }\n    })\n  }\n\n  // 散点图点击事件（通过ECharts内置事件处理）\n  if (mostWatchedChart) {\n    mostWatchedChart.chart.on('click', (params) => {\n      if (params.componentType === 'series') {\n        const authorData = sortedMostWatchedAuthors.value.find(([author]) => author === params.data[3])\n        if (authorData?.[1]?.author_mid) {\n          handleAuthorClick(authorData[1].author_mid)\n        }\n      }\n    })\n  }\n})\n\n// 格式化洞察文本，为数字添加颜色\nconst formatInsightText = (text) => {\n  if (!text) return '';\n  return text.replace(/(\\d+(\\.\\d+)?)/g, '<span class=\"text-[#fb7299]\">$1</span>')\n}\n</script>\n\n<style scoped>\n.echarts :deep(.yAxis) {\n  cursor: pointer;\n}\n</style>"
  },
  {
    "path": "src/components/tailwind/analytics/pages/AuthorPopularAssociationPage.vue",
    "content": "<template>\n  <div class=\"space-y-4\">\n    <h3 class=\"text-xl font-bold text-center bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent\">\n      UP主热门关联分析\n    </h3>\n\n    <!-- 加载状态 -->\n    <div v-if=\"loading\" class=\"flex justify-center items-center py-12\">\n      <div class=\"animate-spin rounded-full h-12 w-12 border-b-2 border-[#fb7299]\"></div>\n    </div>\n\n    <!-- 错误状态 -->\n    <div v-else-if=\"error\" class=\"text-center py-12\">\n      <div class=\"text-red-500 text-lg\">{{ error }}</div>\n    </div>\n\n    <!-- 主要内容 -->\n    <div v-else-if=\"associationData\" class=\"space-y-4\">\n\n      <!-- 洞察文本 -->\n      <div class=\"text-center text-gray-600 dark:text-gray-300\">\n        <div class=\"text-sm leading-relaxed\" v-html=\"formatInsightText(insights.join('，'))\"></div>\n      </div>\n\n      <!-- 可视化图表 -->\n      <div class=\"grid grid-cols-1 lg:grid-cols-3 gap-4\">\n        <!-- UP主热门能力分布图 -->\n        <div class=\"bg-white/50 dark:bg-white/5 backdrop-blur-sm rounded-xl p-4 border border-gray-300/50 dark:border-gray-500/50\">\n          <h4 class=\"text-base font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent mb-2 text-center\">\n            热门制造机分布\n          </h4>\n          <div ref=\"chartRef\" class=\"h-[460px]\"></div>\n        </div>\n\n        <!-- 热门UP主列表 -->\n        <div v-if=\"popularAuthors && popularAuthors.length > 0\"\n             class=\"lg:col-span-2 backdrop-blur-sm rounded-xl p-4 border border-gray-300/50 dark:border-gray-500/50\">\n          <h4 class=\"text-base font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent mb-2\">\n            热门制造机UP主 (前10个，按热门视频数排序)\n          </h4>\n          <div class=\"grid grid-cols-1 md:grid-cols-2 gap-2 h-[460px] overflow-y-auto\">\n            <div v-for=\"(author, index) in popularAuthors\" :key=\"index\"\n                 class=\"flex items-center p-2 rounded-lg transition-colors\">\n              <div class=\"flex-1 min-w-0\">\n                <div class=\"font-medium text-gray-900 dark:text-gray-100 truncate text-xs\">{{ author.author_name }}</div>\n                <div class=\"text-xs text-gray-500 dark:text-gray-400 mt-1\">\n                  <span class=\"text-red-500\">{{ author.popular_videos_watched }}个热门</span>\n                  <span class=\"ml-2\">观看{{ author.total_videos_watched }}个</span>\n                </div>\n                <div class=\"text-xs text-gray-400 mt-1\">\n                  <span>热门率: {{ author.popular_rate }}%</span>\n                  <span class=\"ml-2\">总热门: {{ author.total_popular_videos }}个</span>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, watch, computed } from 'vue'\nimport * as echarts from 'echarts'\nimport { useDarkMode } from '@/store/darkMode.js'\nimport { getAuthorPopularAssociation } from '../../../../api/api.js'\n\nconst props = defineProps({\n  selectedYear: {\n    type: Number,\n    default: () => new Date().getFullYear()\n  },\n  data: {\n    type: Object,\n    default: null\n  }\n})\n\nconst loading = ref(true)\nconst error = ref(null)\nconst associationData = ref(null)\nconst chartRef = ref(null)\nlet chart = null\nconst { isDarkMode } = useDarkMode()\n\n// 格式化洞察文本，为数字添加颜色\nconst formatInsightText = (text) => {\n  if (!text) return '';\n  return text.replace(/(\\d+(\\.\\d+)?)/g, '<span class=\"text-[#fb7299] font-semibold\">$1</span>')\n}\n\n// 计算属性\nconst insights = computed(() => {\n  if (!associationData.value) return []\n  return associationData.value.insights || []\n})\n\nconst popularAuthors = computed(() => {\n  if (!associationData.value) return []\n  return (associationData.value.popular_authors || []).slice(0, 10)\n})\n\n// 获取UP主热门关联数据\nconst fetchAssociationData = async (year) => {\n  if (!year) return\n\n  // 如果父组件已经传递了数据，直接使用\n  if (props.data) {\n    associationData.value = props.data\n    loading.value = false\n    await initCharts()\n    return\n  }\n\n  loading.value = true\n  error.value = null\n\n  try {\n    const response = await getAuthorPopularAssociation(year)\n    if (response.data.status === 'success') {\n      associationData.value = response.data.data.association_analysis\n      await initCharts()\n    } else {\n      error.value = response.data.message || '获取数据失败'\n    }\n  } catch (err) {\n    console.error('获取UP主热门关联数据出错:', err)\n    error.value = '获取数据时发生错误'\n  } finally {\n    loading.value = false\n  }\n}\n\n// 初始化图表\nconst initCharts = async () => {\n  await new Promise(resolve => setTimeout(resolve, 100)) // 等待DOM更新\n  initChart()\n}\n\n// 初始化图表\nconst initChart = () => {\n  if (!chartRef.value || !associationData.value) return\n\n  if (chart) {\n    chart.dispose()\n  }\n\n  chart = echarts.init(chartRef.value)\n\n  const isDark = !!(isDarkMode && isDarkMode.value)\n  const legendTextColor = isDark ? '#bbbbbb' : '#666'\n  const labelTextColor = isDark ? '#e5e7eb' : '#333'\n  const tooltipBg = isDark ? 'rgba(28, 28, 28, 0.9)' : 'rgba(255, 255, 255, 0.95)'\n  const tooltipText = isDark ? '#ffffff' : '#111111'\n\n  const totalAuthors = associationData.value.total_authors\n  const popularAuthorCount = associationData.value.popular_author_count\n  const normalAuthorCount = totalAuthors - popularAuthorCount\n\n  const option = {\n    tooltip: {\n      trigger: 'item',\n      backgroundColor: tooltipBg,\n      borderColor: '#fb7299',\n      textStyle: { color: tooltipText },\n      formatter: function(params) {\n        const percentage = params.percent\n        return `${params.name}: ${params.value} 个 (${percentage}%)`\n      }\n    },\n    legend: {\n      bottom: '8%',\n      left: 'center',\n      textStyle: {\n        color: legendTextColor,\n        fontSize: 12\n      }\n    },\n    series: [\n      {\n        name: 'UP主分布',\n        type: 'pie',\n        radius: '70%',\n        center: ['50%', '45%'],\n        label: {\n          show: true,\n          position: 'outside',\n          formatter: function(params) {\n            return `${params.name}\\n${params.value}个\\n${params.percent}%`\n          },\n          fontSize: 11,\n          color: labelTextColor\n        },\n        emphasis: {\n          label: {\n            show: true,\n            fontSize: '13',\n            fontWeight: 'bold'\n          },\n          itemStyle: {\n            shadowBlur: 10,\n            shadowOffsetX: 0,\n            shadowColor: 'rgba(0, 0, 0, 0.5)'\n          }\n        },\n        labelLine: {\n          show: true,\n          length: 15,\n          length2: 10\n        },\n        data: [\n          {\n            value: popularAuthorCount,\n            name: '热门制造机',\n            itemStyle: {\n              color: {\n                type: 'linear',\n                x: 0, y: 0, x2: 1, y2: 1,\n                colorStops: [\n                  { offset: 0, color: '#fb7299' },\n                  { offset: 1, color: '#fc9b7a' }\n                ]\n              }\n            }\n          },\n          {\n            value: normalAuthorCount,\n            name: '普通UP主',\n            itemStyle: {\n              color: {\n                type: 'linear',\n                x: 0, y: 0, x2: 1, y2: 1,\n                colorStops: [\n                  { offset: 0, color: '#e0e0e0' },\n                  { offset: 1, color: '#c0c0c0' }\n                ]\n              }\n            }\n          }\n        ]\n      }\n    ]\n  }\n\n  chart.setOption(option)\n}\n\n// 监听年份变化\nwatch(() => props.selectedYear, (newYear) => {\n  fetchAssociationData(newYear)\n}, { immediate: true })\n\n// 监听父组件传递的数据变化\nwatch(() => props.data, (newData) => {\n  if (newData) {\n    associationData.value = newData\n    loading.value = false\n    initCharts()\n  }\n}, { immediate: true })\n\n// 组件挂载时获取数据\nonMounted(() => {\n  fetchAssociationData(props.selectedYear)\n})\n\n// 深色模式切换时重绘图表\nwatch(() => isDarkMode.value, () => {\n  if (associationData.value) {\n    initChart()\n  }\n})\n</script>\n\n<style scoped>\n.line-clamp-2 {\n  display: -webkit-box;\n  -webkit-line-clamp: 2;\n  -webkit-box-orient: vertical;\n  overflow: hidden;\n}\n</style>\n"
  },
  {
    "path": "src/components/tailwind/analytics/pages/CategoryPopularDistributionPage.vue",
    "content": "<!-- 热门视频分区分布分析页组件 -->\n<template>\n  <div class=\"space-y-4\">\n    <h3 class=\"text-xl font-bold text-center bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent\">\n      热门视频分区分布分析\n    </h3>\n\n    <!-- 加载状态 -->\n    <div v-if=\"loading\" class=\"flex justify-center items-center py-12\">\n      <div class=\"animate-spin rounded-full h-12 w-12 border-b-2 border-[#fb7299]\"></div>\n    </div>\n\n    <!-- 错误状态 -->\n    <div v-else-if=\"error\" class=\"text-center py-12\">\n      <div class=\"text-red-500 text-lg\">{{ error }}</div>\n    </div>\n\n    <!-- 主要内容 -->\n    <div v-else-if=\"distributionData\" class=\"space-y-4\">\n\n      <!-- 洞察文本 -->\n      <div class=\"text-center text-gray-600 dark:text-gray-300\">\n        <div class=\"text-sm leading-relaxed\" v-html=\"formatInsightText(distributionData.insights.join('，'))\"></div>\n      </div>\n\n      <!-- 可视化图表 -->\n      <div class=\"grid grid-cols-1 lg:grid-cols-3 gap-2 -mx-4\">\n        <!-- 分区分布统计卡片 -->\n        <div class=\"bg-white/50 dark:bg-white/5 backdrop-blur-sm rounded-xl p-3 border border-gray-300/50 dark:border-gray-500/50\">\n          <h4 class=\"text-base font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent mb-2 text-center\">\n            分区分布统计\n          </h4>\n          <div ref=\"chartRef\" class=\"h-[320px] px-4\"></div>\n        </div>\n\n        <!-- 热门分区列表 -->\n        <div v-if=\"distributionData.popular_categories && distributionData.popular_categories.length > 0\"\n             class=\"lg:col-span-2 backdrop-blur-sm rounded-xl p-3 border border-gray-300/50 dark:border-gray-500/50\">\n          <h4 class=\"text-base font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent mb-2\">\n            热门分区排行 (前10个)\n          </h4>\n          <div class=\"grid grid-cols-1 md:grid-cols-3 gap-2 h-[450px] overflow-y-auto\">\n            <div v-for=\"(category, index) in distributionData.popular_categories\" :key=\"index\"\n                 class=\"flex items-center p-2 rounded-lg transition-colors\">\n              <div class=\"flex-1 min-w-0\">\n                <div class=\"font-medium text-gray-900 dark:text-gray-100 truncate text-sm flex items-center justify-between\">\n                  <span>{{ category.category_name }}</span>\n                  <span class=\"text-xs text-gray-500 dark:text-gray-400 ml-2\">\n                    <span class=\"text-[#fb7299] font-semibold\">{{ category.total_popular }}</span> 个热门视频\n                  </span>\n                </div>\n                <!-- 显示该分区的热门视频 -->\n                <div v-if=\"category.videos && category.videos.length > 0\" class=\"mt-2 space-y-1\">\n                  <div v-for=\"(video, vIndex) in category.videos.slice(0, 2)\" :key=\"vIndex\"\n                       @click=\"openVideo(video.bvid)\"\n                       class=\"text-xs text-gray-600 dark:text-gray-400 hover:text-[#fb7299] cursor-pointer truncate\">\n                    • {{ video.title }}\n                  </div>\n                  <div v-if=\"category.videos.length > 2\" class=\"text-xs text-gray-400\">\n                    还有 {{ category.videos.length - 2 }} 个视频...\n                  </div>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, watch } from 'vue'\nimport * as echarts from 'echarts'\nimport { useDarkMode } from '@/store/darkMode.js'\nimport { getCategoryPopularDistribution } from '../../../../api/api.js'\n\nconst props = defineProps({\n  selectedYear: {\n    type: Number,\n    default: () => new Date().getFullYear()\n  },\n  distributionData: {\n    type: Object,\n    default: null\n  }\n})\n\nconst loading = ref(true)\nconst error = ref(null)\nconst distributionData = ref(null)\nconst chartRef = ref(null)\nlet chart = null\nconst { isDarkMode } = useDarkMode()\n\n// 格式化洞察文本，为数字添加颜色\nconst formatInsightText = (text) => {\n  if (!text) return '';\n  return text.replace(/(\\d+(\\.\\d+)?)/g, '<span class=\"text-[#fb7299] font-semibold\">$1</span>')\n}\n\n// 打开视频\nconst openVideo = (bvid) => {\n  if (bvid) {\n    window.open(`https://www.bilibili.com/video/${bvid}`, '_blank')\n  }\n}\n\n// 获取分区分布数据\nconst fetchDistributionData = async (year) => {\n  if (!year) return\n\n  // 如果父组件已经传递了数据，直接使用\n  if (props.distributionData) {\n    distributionData.value = props.distributionData.distribution_analysis\n    loading.value = false\n    await initCharts()\n    return\n  }\n\n  loading.value = true\n  error.value = null\n\n  try {\n    const response = await getCategoryPopularDistribution(year)\n    if (response.data.status === 'success') {\n      distributionData.value = response.data.data.distribution_analysis\n      await initCharts()\n    } else {\n      error.value = response.data.message || '获取数据失败'\n    }\n  } catch (err) {\n    console.error('获取热门视频分区分布数据出错:', err)\n    error.value = '获取数据时发生错误'\n  } finally {\n    loading.value = false\n  }\n}\n\n// 初始化图表\nconst initCharts = async () => {\n  await new Promise(resolve => setTimeout(resolve, 100)) // 等待DOM更新\n  initChart()\n}\n\n// 初始化图表\nconst initChart = () => {\n  if (!chartRef.value || !distributionData.value) return\n\n  if (chart) {\n    chart.dispose()\n  }\n\n  chart = echarts.init(chartRef.value)\n\n  const isDark = !!(isDarkMode && isDarkMode.value)\n  const legendTextColor = isDark ? '#bbbbbb' : '#666'\n  const labelTextColor = isDark ? '#e5e7eb' : '#333'\n  const tooltipBg = isDark ? 'rgba(28, 28, 28, 0.9)' : 'rgba(255, 255, 255, 0.95)'\n  const tooltipText = isDark ? '#ffffff' : '#111111'\n\n  const categories = distributionData.value.popular_categories || []\n  \n  // 准备饼图数据\n  const data = categories.map(category => ({\n    name: category.category_name,\n    value: category.total_popular\n  }))\n\n  // 生成颜色数组\n  const colors = [\n    '#fb7299', '#fc9b7a', '#ff6b9d', '#f093fb', '#f5576c',\n    '#4facfe', '#00f2fe', '#43e97b', '#38f9d7', '#ffecd2'\n  ]\n\n  const option = {\n    tooltip: {\n      trigger: 'item',\n      backgroundColor: tooltipBg,\n      borderColor: '#fb7299',\n      textStyle: { color: tooltipText },\n      formatter: function(params) {\n        const percentage = params.percent\n        return `${params.name}: ${params.value} 个热门视频 (${percentage}%)`\n      }\n    },\n    legend: {\n      bottom: '1%',\n      left: 'center',\n      textStyle: {\n        color: legendTextColor,\n        fontSize: 9\n      },\n      type: 'scroll',\n      pageIconColor: '#fb7299',\n      pageIconInactiveColor: '#ccc',\n      pageTextStyle: {\n        color: legendTextColor\n      },\n      itemWidth: 12,\n      itemHeight: 8\n    },\n    series: [\n      {\n        name: '分区分布',\n        type: 'pie',\n        radius: '45%',\n        center: ['50%', '48%'],\n        label: {\n          show: true,\n          position: 'outside',\n          formatter: function(params) {\n            if (params.percent < 3) return '' // 小于3%不显示标签，避免过于拥挤\n            return `${params.name}\\n${params.value}个`\n          },\n          fontSize: 11,\n          color: labelTextColor,\n          fontWeight: '500',\n          lineHeight: 14\n        },\n        emphasis: {\n          label: {\n            show: true,\n            fontSize: '13',\n            fontWeight: 'bold'\n          },\n          itemStyle: {\n            shadowBlur: 10,\n            shadowOffsetX: 0,\n            shadowColor: 'rgba(0, 0, 0, 0.5)'\n          }\n        },\n        labelLine: {\n          show: true,\n          length: 20,\n          length2: 15,\n          smooth: true\n        },\n        data: data.map((item, index) => ({\n          ...item,\n          itemStyle: {\n            color: colors[index % colors.length]\n          }\n        }))\n      }\n    ]\n  }\n\n  chart.setOption(option)\n}\n\n// 监听年份变化\nwatch(() => props.selectedYear, (newYear) => {\n  fetchDistributionData(newYear)\n}, { immediate: true })\n\n// 监听父组件传递的数据变化\nwatch(() => props.distributionData, (newData) => {\n  if (newData) {\n    distributionData.value = newData.distribution_analysis\n    loading.value = false\n    initCharts()\n  }\n}, { immediate: true })\n\n// 组件挂载时获取数据\nonMounted(() => {\n  fetchDistributionData(props.selectedYear)\n})\n\n// 深色模式切换时重绘图表\nwatch(() => isDarkMode.value, () => {\n  if (distributionData.value) {\n    initChart()\n  }\n})\n</script>\n"
  },
  {
    "path": "src/components/tailwind/analytics/pages/DurationAnalysisPage.vue",
    "content": "<!-- 视频时长分析页组件 -->\n<template>\n  <div class=\"space-y-6\">\n    <h3 class=\"text-4xl font-bold text-center bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent\">\n      视频时长分析\n    </h3>\n\n    <div v-if=\"viewingData?.insights?.duration_preference\"\n      class=\"text-lg text-center text-gray-600 dark:text-gray-300 mb-8\"\n      v-html=\"formatInsightText(viewingData.insights.duration_preference)\"\n    >\n    </div>\n\n    <!-- 时长分布图表 -->\n    <div class=\"bg-white/50 dark:bg-white/5 backdrop-blur-sm rounded-xl p-6 border border-gray-300/50 dark:border-gray-500/50\">\n      <h4 class=\"text-xl font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent mb-4\">时长分布</h4>\n      <div class=\"h-[300px]\">\n        <v-chart ref=\"chartRef\" class=\"h-full w-full\" :option=\"durationDistributionOption\" autoresize />\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { computed, onMounted, ref } from 'vue'\nimport gsap from 'gsap'\nimport VChart from 'vue-echarts'\nimport * as echarts from 'echarts/core'\nimport { useDarkMode } from '@/store/darkMode.js'\n\nconst props = defineProps({\n  viewingData: {\n    type: Object,\n    required: true\n  }\n})\n\nconst chartRef = ref(null)\nconst { isDarkMode } = useDarkMode()\n\nconst durationDistributionOption = computed(() => {\n  if (!props.viewingData?.duration_correlation) return {}\n\n  const periods = ['凌晨', '上午', '下午', '晚上']\n  const types = ['短视频', '中等视频', '长视频']\n  const data = periods.map(period => {\n    const periodData = props.viewingData.duration_correlation[period]\n    return types.map(type => ({\n      period,\n      type,\n      value: periodData[type].video_count,\n      avg_duration: Math.round(periodData[type].avg_duration / 60)\n    }))\n  }).flat()\n\n  const typeColors = {\n    '短视频': {\n      from: 'rgba(251, 114, 153, 0.9)',\n      to: 'rgba(252, 155, 122, 0.9)'\n    },\n    '中等视频': {\n      from: 'rgba(64, 169, 255, 0.9)',\n      to: 'rgba(128, 208, 255, 0.9)'\n    },\n    '长视频': {\n      from: 'rgba(82, 196, 26, 0.9)',\n      to: 'rgba(144, 217, 79, 0.9)'\n    }\n  }\n\n  const isDark = !!(isDarkMode && isDarkMode.value)\n  const legendTextColor = isDark ? '#bbbbbb' : '#666'\n  const axisLabelColor = isDark ? '#bbbbbb' : '#999'\n  const axisLineColor = isDark ? '#888888' : '#666'\n  const splitLineColor = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.1)'\n  const tooltipBg = isDark ? 'rgba(28, 28, 28, 0.9)' : 'rgba(255, 255, 255, 0.95)'\n  const tooltipText = isDark ? '#ffffff' : '#111111'\n\n  return {\n    tooltip: {\n      trigger: 'axis',\n      axisPointer: { type: 'shadow' },\n      backgroundColor: tooltipBg,\n      borderColor: '#fb7299',\n      textStyle: { color: tooltipText },\n      formatter: (params) => {\n        const period = params[0].axisValue\n        let result = `${period}<br/>`\n        params.forEach(param => {\n          const data = param.data\n          result += `${param.seriesName}：${data.value}个 (平均${data.avg_duration}分钟)<br/>`\n        })\n        return result\n      }\n    },\n    legend: {\n      data: types,\n      top: 0,\n      textStyle: { color: legendTextColor },\n      itemStyle: {\n        borderWidth: 0\n      }\n    },\n    grid: {\n      top: '15%',\n      left: '3%',\n      right: '4%',\n      bottom: '3%',\n      containLabel: true\n    },\n    xAxis: {\n      type: 'category',\n      data: periods,\n      axisLine: { lineStyle: { color: axisLineColor } },\n      axisLabel: { color: axisLabelColor }\n    },\n    yAxis: {\n      type: 'value',\n      name: '视频数量',\n      axisLine: { lineStyle: { color: axisLineColor } },\n      axisLabel: { color: axisLabelColor },\n      splitLine: { lineStyle: { color: splitLineColor } }\n    },\n    series: types.map(type => ({\n      name: type,\n      type: 'bar',\n      stack: 'total',\n      emphasis: {\n        focus: 'series'\n      },\n      itemStyle: {\n        color: new echarts.graphic.LinearGradient(0, 1, 0, 0, [\n          { offset: 0, color: typeColors[type].from },\n          { offset: 1, color: typeColors[type].to }\n        ])\n      },\n      data: periods.map(period => {\n        return data.find(d => d.period === period && d.type === type)\n      })\n    }))\n  }\n})\n\nonMounted(() => {\n  if (chartRef.value) {\n    gsap.from(chartRef.value.$el, {\n      opacity: 0,\n      y: 20,\n      duration: 0.5,\n      ease: 'power2.out',\n      delay: 0.2\n    })\n  }\n})\n\n// 格式化洞察文本，为数字添加颜色\nconst formatInsightText = (text) => {\n  if (!text) return '';\n  return text.replace(/(\\d+(\\.\\d+)?)/g, '<span class=\"text-[#fb7299]\">$1</span>')\n}\n</script>\n"
  },
  {
    "path": "src/components/tailwind/analytics/pages/DurationPopularDistributionPage.vue",
    "content": "<!-- 热门视频时长分布分析页组件 -->\n<template>\n  <div class=\"space-y-4\">\n    <h3 class=\"text-xl font-bold text-center bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent\">\n      热门视频时长分布分析\n    </h3>\n\n    <!-- 加载状态 -->\n    <div v-if=\"loading\" class=\"flex justify-center items-center py-12\">\n      <div class=\"animate-spin rounded-full h-12 w-12 border-b-2 border-[#fb7299]\"></div>\n    </div>\n\n    <!-- 错误状态 -->\n    <div v-else-if=\"error\" class=\"text-center py-12\">\n      <div class=\"text-red-500 text-lg\">{{ error }}</div>\n    </div>\n\n    <!-- 主要内容 -->\n    <div v-else-if=\"durationData\" class=\"space-y-4\">\n\n      <!-- 洞察文本 -->\n      <div class=\"text-center text-gray-600 dark:text-gray-300\">\n        <div class=\"text-sm leading-relaxed\" v-html=\"formatInsightText(durationData.insights.join('，'))\"></div>\n      </div>\n\n      <!-- 可视化图表 -->\n      <div class=\"grid grid-cols-1 lg:grid-cols-3 gap-2 -mx-4\">\n        <!-- 时长分布统计卡片 -->\n        <div class=\"bg-white/50 dark:bg-white/5 backdrop-blur-sm rounded-xl p-3 border border-gray-300/50 dark:border-gray-500/50\">\n          <h4 class=\"text-base font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent mb-2 text-center\">\n            时长分布统计\n          </h4>\n          <div ref=\"chartRef\" class=\"h-[320px] px-4\"></div>\n        </div>\n\n        <!-- 热门时长类型列表 -->\n        <div v-if=\"durationData.popular_duration_videos && durationData.popular_duration_videos.length > 0\"\n             class=\"lg:col-span-2 backdrop-blur-sm rounded-xl p-3 border border-gray-300/50 dark:border-gray-500/50\">\n          <h4 class=\"text-base font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent mb-2\">\n            热门时长类型排行 (前4个)\n          </h4>\n          <div class=\"grid grid-cols-1 md:grid-cols-2 gap-2 h-[350px] overflow-y-auto\">\n            <div v-for=\"(durationType, index) in durationData.popular_duration_videos\" :key=\"index\"\n                 class=\"flex items-center p-2 rounded-lg transition-colors\">\n              <div class=\"flex-1 min-w-0\">\n                <div class=\"font-medium text-gray-900 dark:text-gray-100 truncate text-sm flex items-center justify-between\">\n                  <span>{{ durationType.duration_type }}</span>\n                  <span class=\"text-xs text-gray-500 dark:text-gray-400 ml-2\">\n                    <span class=\"text-[#fb7299] font-semibold\">{{ durationType.count }}</span> 个热门视频\n                  </span>\n                </div>\n                <!-- 显示该时长类型的热门视频 -->\n                <div v-if=\"durationType.videos && durationType.videos.length > 0\" class=\"mt-2 space-y-1\">\n                  <div v-for=\"(video, vIndex) in durationType.videos.slice(0, 2)\" :key=\"vIndex\"\n                       @click=\"openVideo(video.bvid)\"\n                       class=\"text-xs text-gray-600 dark:text-gray-400 hover:text-[#fb7299] cursor-pointer truncate\">\n                    • {{ video.title }} ({{ video.formatted_duration }})\n                  </div>\n                  <div v-if=\"durationType.videos.length > 2\" class=\"text-xs text-gray-400\">\n                    还有 {{ durationType.videos.length - 2 }} 个视频...\n                  </div>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, watch } from 'vue'\nimport * as echarts from 'echarts'\nimport { useDarkMode } from '@/store/darkMode.js'\nimport { getDurationPopularDistribution } from '../../../../api/api.js'\n\nconst props = defineProps({\n  selectedYear: {\n    type: Number,\n    default: () => new Date().getFullYear()\n  },\n  durationData: {\n    type: Object,\n    default: null\n  }\n})\n\nconst loading = ref(true)\nconst error = ref(null)\nconst durationData = ref(null)\nconst chartRef = ref(null)\nlet chart = null\nconst { isDarkMode } = useDarkMode()\n\n// 格式化洞察文本，为数字添加颜色\nconst formatInsightText = (text) => {\n  if (!text) return '';\n  return text.replace(/(\\d+(\\.\\d+)?)/g, '<span class=\"text-[#fb7299] font-semibold\">$1</span>')\n}\n\n// 打开视频\nconst openVideo = (bvid) => {\n  if (bvid) {\n    window.open(`https://www.bilibili.com/video/${bvid}`, '_blank')\n  }\n}\n\n// 获取时长分布数据\nconst fetchDurationData = async (year) => {\n  if (!year) return\n\n  // 如果父组件已经传递了数据，直接使用\n  if (props.durationData) {\n    durationData.value = props.durationData.duration_analysis\n    loading.value = false\n    await initCharts()\n    return\n  }\n\n  loading.value = true\n  error.value = null\n\n  try {\n    const response = await getDurationPopularDistribution(year)\n    if (response.data.status === 'success') {\n      durationData.value = response.data.data.duration_analysis\n      await initCharts()\n    } else {\n      error.value = response.data.message || '获取数据失败'\n    }\n  } catch (err) {\n    console.error('获取热门视频时长分布数据出错:', err)\n    error.value = '获取数据时发生错误'\n  } finally {\n    loading.value = false\n  }\n}\n\n// 初始化图表\nconst initCharts = async () => {\n  await new Promise(resolve => setTimeout(resolve, 100)) // 等待DOM更新\n  initChart()\n}\n\n// 初始化图表\nconst initChart = () => {\n  if (!chartRef.value || !durationData.value) return\n\n  if (chart) {\n    chart.dispose()\n  }\n\n  chart = echarts.init(chartRef.value)\n\n  const isDark = !!(isDarkMode && isDarkMode.value)\n  const axisLabelColor = isDark ? '#e5e7eb' : '#666'\n  const axisLineColor = isDark ? '#444' : '#ddd'\n  const splitLineColor = isDark ? '#2a2a2a' : '#f0f0f0'\n  const labelTextColor = isDark ? '#e5e7eb' : '#333'\n  const tooltipBg = isDark ? 'rgba(28, 28, 28, 0.9)' : 'rgba(255, 255, 255, 0.95)'\n  const tooltipText = isDark ? '#ffffff' : '#111111'\n\n  const durationStats = durationData.value.duration_stats || []\n  \n  // 准备柱状图数据\n  const data = durationStats.map(stat => ({\n    name: stat.duration_type,\n    value: stat.count,\n    percentage: stat.percentage\n  }))\n\n  // 生成颜色数组\n  const colors = ['#fb7299', '#fc9b7a', '#ff6b9d', '#f093fb']\n\n  const option = {\n    tooltip: {\n      trigger: 'axis',\n      axisPointer: {\n        type: 'shadow'\n      },\n      backgroundColor: tooltipBg,\n      borderColor: '#fb7299',\n      textStyle: { color: tooltipText },\n      formatter: function(params) {\n        const param = params[0]\n        return `${param.name}: ${param.value} 个热门视频 (${param.data.percentage}%)`\n      }\n    },\n    grid: {\n      left: '3%',\n      right: '4%',\n      bottom: '8%',\n      top: '10%',\n      containLabel: true\n    },\n    xAxis: {\n      type: 'category',\n      data: data.map(item => item.name),\n      axisLabel: {\n        color: axisLabelColor,\n        fontSize: 11,\n        interval: 0,\n        rotate: 0\n      },\n      axisLine: {\n        lineStyle: {\n          color: axisLineColor\n        }\n      }\n    },\n    yAxis: {\n      type: 'value',\n      axisLabel: {\n        color: axisLabelColor,\n        fontSize: 10\n      },\n      axisLine: {\n        lineStyle: {\n          color: axisLineColor\n        }\n      },\n      splitLine: {\n        lineStyle: {\n          color: splitLineColor\n        }\n      }\n    },\n    series: [\n      {\n        name: '热门视频数量',\n        type: 'bar',\n        data: data.map((item, index) => ({\n          ...item,\n          itemStyle: {\n            color: colors[index % colors.length]\n          }\n        })),\n        label: {\n          show: true,\n          position: 'top',\n          formatter: '{c}个',\n          fontSize: 10,\n          color: labelTextColor\n        },\n        barWidth: '60%'\n      }\n    ]\n  }\n\n  chart.setOption(option)\n}\n\n// 监听年份变化\nwatch(() => props.selectedYear, (newYear) => {\n  fetchDurationData(newYear)\n}, { immediate: true })\n\n// 监听父组件传递的数据变化\nwatch(() => props.durationData, (newData) => {\n  if (newData) {\n    durationData.value = newData.duration_analysis\n    loading.value = false\n    initCharts()\n  }\n}, { immediate: true })\n\n// 组件挂载时获取数据\nonMounted(() => {\n  fetchDurationData(props.selectedYear)\n})\n\n// 深色模式切换时重绘图表\nwatch(() => isDarkMode.value, () => {\n  if (durationData.value) {\n    initChart()\n  }\n})\n</script>\n"
  },
  {
    "path": "src/components/tailwind/analytics/pages/HeroPage.vue",
    "content": "<!-- 开场页组件 -->\n<template>\n  <div class=\"absolute inset-0\">\n    <div class=\"h-full flex items-center justify-center relative\">\n      <div class=\"text-center space-y-8\" ref=\"heroContent\">\n        <div class=\"relative inline-block\">\n          <h2 class=\"text-6xl font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent\">你的B站时光机</h2>\n          <div class=\"absolute -inset-1 bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] opacity-10 blur-xl\"></div>\n        </div>\n        <p class=\"text-2xl text-gray-600 dark:text-gray-300\">让我们开启{{ year }}年的回忆之旅</p>\n        <div class=\"text-gray-500 dark:text-gray-400 animate-bounce mt-12\">\n          <span class=\"block\">向下滑动开始探索</span>\n          <svg class=\"w-6 h-6 mx-auto mt-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 14l-7 7m0 0l-7-7m7 7V3\" />\n          </svg>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted } from 'vue'\nimport gsap from 'gsap'\n\nconst props = defineProps({\n  year: {\n    type: Number,\n    required: true\n  }\n})\n\nconst heroContent = ref(null)\n\nonMounted(() => {\n  // 开场动画\n  gsap.from(heroContent.value.children, {\n    opacity: 0,\n    y: 30,\n    duration: 1,\n    stagger: 0.2,\n    ease: 'power3.out'\n  })\n})\n</script> "
  },
  {
    "path": "src/components/tailwind/analytics/pages/MonthlyPage.vue",
    "content": "<!-- 月度趋势页组件 -->\n<template>\n  <div class=\"space-y-6\">\n    <h3 class=\"text-4xl font-bold text-center bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent\">\n      月度观看趋势\n    </h3>\n\n    <div v-if=\"viewingData?.insights?.monthly_pattern\" \n      class=\"text-lg text-center text-gray-600 dark:text-gray-300 mb-8\"\n      v-html=\"formatInsightText(viewingData.insights.monthly_pattern)\"\n    >\n    </div>\n\n    <!-- 月度趋势图表 -->\n    <div class=\"bg-white/50 dark:bg-white/5 backdrop-blur-sm rounded-xl p-6 border border-gray-300/50 dark:border-gray-500/50\">\n      <h4 class=\"text-xl font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent mb-4\">月度趋势</h4>\n      <div class=\"h-[220px]\">\n        <v-chart ref=\"chartRef\" class=\"h-full w-full\" :option=\"monthlyOption\" autoresize />\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted } from 'vue'\nimport gsap from 'gsap'\nimport VChart from 'vue-echarts'\nimport * as echarts from 'echarts/core'\nimport { useDarkMode } from '@/store/darkMode.js'\n\nconst props = defineProps({\n  viewingData: {\n    type: Object,\n    required: true\n  }\n})\n\nconst chartRef = ref(null)\n\nconst monthlyOption = computed(() => {\n  const { isDarkMode } = useDarkMode()\n  const isDark = !!(isDarkMode && isDarkMode.value)\n  const axisLabelColor = isDark ? '#bbbbbb' : '#999999'\n  const axisLineColor = isDark ? '#888888' : '#666666'\n  const splitLineColor = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.1)'\n  const tooltipBg = isDark ? 'rgba(28, 28, 28, 0.9)' : 'rgba(255, 255, 255, 0.95)'\n  const tooltipText = isDark ? '#ffffff' : '#111111'\n\n  if (!props.viewingData?.monthly_stats) return {}\n  \n  const data = Object.entries(props.viewingData.monthly_stats)\n    .sort(([a], [b]) => a.localeCompare(b))\n  \n  return {\n    tooltip: {\n      trigger: 'axis',\n      backgroundColor: tooltipBg,\n      borderColor: '#fb7299',\n      textStyle: { color: tooltipText }\n    },\n    grid: {\n      top: '15%',\n      left: '8%',\n      right: '4%',\n      bottom: '3%',\n      containLabel: true\n    },\n    xAxis: {\n      type: 'category',\n      data: data.map(([month]) => month),\n      axisLine: { lineStyle: { color: axisLineColor } },\n      axisLabel: { color: axisLabelColor }\n    },\n    yAxis: {\n      type: 'value',\n      name: '观看次数',\n      axisLine: { lineStyle: { color: axisLineColor } },\n      axisLabel: { color: axisLabelColor },\n      splitLine: { lineStyle: { color: splitLineColor } }\n    },\n    series: [{\n      data: data.map(([, count]) => count),\n      type: 'line',\n      smooth: true,\n      lineStyle: {\n        width: 2.5,\n        color: new echarts.graphic.LinearGradient(0, 1, 0, 0, [\n          { offset: 0, color: 'rgba(251, 114, 153, 0.9)' },\n          { offset: 1, color: 'rgba(252, 155, 122, 0.9)' }\n        ])\n      },\n      areaStyle: {\n        opacity: 0.15,\n        color: new echarts.graphic.LinearGradient(0, 1, 0, 0, [\n          { offset: 0, color: 'rgba(251, 114, 153, 0.4)' },\n          { offset: 1, color: 'rgba(252, 155, 122, 0)' }\n        ])\n      },\n      symbolSize: 6,\n      symbol: 'circle',\n      itemStyle: {\n        color: new echarts.graphic.LinearGradient(0, 1, 0, 0, [\n          { offset: 0, color: 'rgba(251, 114, 153, 0.9)' },\n          { offset: 1, color: 'rgba(252, 155, 122, 0.9)' }\n        ])\n      }\n    }]\n  }\n})\n\nonMounted(() => {\n  if (chartRef.value) {\n    gsap.from(chartRef.value.$el, {\n      opacity: 0,\n      y: 20,\n      duration: 0.5,\n      ease: 'power2.out',\n      delay: 0.2\n    })\n  }\n})\n\n// 格式化洞察文本，为数字添加颜色\nconst formatInsightText = (text) => {\n  if (!text) return '';\n  return text.replace(/(\\d+(\\.\\d+)?)/g, '<span class=\"text-[#fb7299]\">$1</span>')\n}\n</script> "
  },
  {
    "path": "src/components/tailwind/analytics/pages/OverallCompletionPage.vue",
    "content": "<!-- 视频整体完成率分析页组件 -->\n<template>\n  <div class=\"space-y-4\">\n    <h3 class=\"text-3xl font-bold text-center bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent\">\n      视频完成率分析\n    </h3>\n\n    <div v-if=\"viewingData?.insights?.overall_completion\" \n      class=\"text-base text-center text-gray-600 dark:text-gray-300 mb-4\"\n    >\n      <template v-for=\"(part, index) in formattedInsights\" :key=\"index\">\n        <span v-if=\"part.type === 'text'\" v-html=\"part.content\"></span>\n        <span v-else :class=\"[\n          'font-bold',\n          part.metric === 'average' ? 'text-[#fb7299]' : \n          part.metric === 'fully' ? 'text-[#fc9b7a]' : \n          'text-[#40a9ff]'\n        ]\">{{ part.content }}</span>\n      </template>\n    </div>\n\n    <div class=\"grid grid-cols-7 gap-4\">\n      <!-- 完成率分布图表 -->\n      <div class=\"col-span-3 bg-white/50 dark:bg-white/5 backdrop-blur-sm rounded-xl p-4 border border-gray-300/50 dark:border-gray-500/50\">\n        <h4 class=\"text-lg font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent mb-3\">完成率分布</h4>\n        <v-chart ref=\"completionDistributionRef\" class=\"h-[280px] w-full\" :option=\"completionDistributionOption\" autoresize />\n      </div>\n\n      <!-- 时长分布完成率 -->\n      <div class=\"col-span-4 bg-white/50 dark:bg-white/5 backdrop-blur-sm rounded-xl p-4 border border-gray-300/50 dark:border-gray-500/50\">\n        <h4 class=\"text-lg font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent mb-3\">时长分布完成率</h4>\n        <v-chart ref=\"durationCompletionRef\" class=\"h-[280px] w-full\" :option=\"durationCompletionOption\" autoresize />\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted } from 'vue'\nimport gsap from 'gsap'\nimport VChart from 'vue-echarts'\nimport * as echarts from 'echarts/core'\nimport { useDarkMode } from '@/store/darkMode.js'\n\nconst props = defineProps({\n  viewingData: {\n    type: Object,\n    required: true\n  }\n})\n\nconst completionDistributionRef = ref(null)\nconst durationCompletionRef = ref(null)\n\n// 格式化总结文字，为数字添加颜色\nconst formattedInsights = computed(() => {\n  if (!props.viewingData?.insights?.overall_completion) return []\n  \n  const text = props.viewingData.insights.overall_completion\n  const parts = []\n  \n  // 使用正则表达式匹配数字\n  const regex = /(\\d+(?:\\.\\d+)?%)/g\n  let lastIndex = 0\n  let match\n  let metricIndex = 0\n  const metrics = ['average', 'fully', 'not_started']\n  \n  while ((match = regex.exec(text)) !== null) {\n    // 添加数字前的文本\n    if (match.index > lastIndex) {\n      parts.push({\n        type: 'text',\n        content: text.substring(lastIndex, match.index)\n      })\n    }\n    \n    // 添加数字（带颜色）\n    parts.push({\n      type: 'number',\n      content: match[0],\n      metric: metrics[metricIndex]\n    })\n    \n    lastIndex = match.index + match[0].length\n    metricIndex++\n  }\n  \n  // 添加剩余的文本\n  if (lastIndex < text.length) {\n    parts.push({\n      type: 'text',\n      content: text.substring(lastIndex)\n    })\n  }\n  \n  return parts\n})\n\n// 完成率分布图表配置\nconst completionDistributionOption = computed(() => {\n  if (!props.viewingData?.completion_rates?.completion_distribution) return {}\n  \n  const data = Object.entries(props.viewingData.completion_rates.completion_distribution)\n    .map(([range, count]) => ({\n      name: range,\n      value: count\n    }))\n  \n  const { isDarkMode } = useDarkMode()\n  const isDark = !!(isDarkMode && isDarkMode.value)\n  const legendTextColor = isDark ? '#bbbbbb' : '#666'\n  const labelTextColor = isDark ? '#e5e7eb' : '#333'\n  const tooltipBg = isDark ? 'rgba(28, 28, 28, 0.9)' : 'rgba(255, 255, 255, 0.95)'\n  const tooltipText = isDark ? '#ffffff' : '#111111'\n\n  return {\n    tooltip: {\n      trigger: 'item',\n      backgroundColor: tooltipBg,\n      borderColor: '#fb7299',\n      textStyle: { color: tooltipText },\n      formatter: '{b}: {c} ({d}%)'\n    },\n    legend: {\n      orient: 'vertical',\n      right: '5%',\n      top: 'middle',\n      textStyle: { color: legendTextColor, fontSize: '12px' }\n    },\n    series: [{\n      type: 'pie',\n      radius: ['35%', '65%'],\n      center: ['40%', '50%'],\n      avoidLabelOverlap: true,\n      itemStyle: {\n        borderRadius: 4\n      },\n      label: {\n        show: false,\n        position: 'center'\n      },\n      emphasis: {\n        label: {\n          show: true,\n          fontSize: '14',\n          fontWeight: 'bold',\n          color: labelTextColor\n        }\n      },\n      labelLine: {\n        show: false\n      },\n      data: data.map((item, index) => ({\n        ...item,\n        itemStyle: {\n          color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [\n            { offset: 0, color: `rgba(251, 114, 153, ${Math.max(0.4, 0.9 - index * 0.1)})` },\n            { offset: 1, color: `rgba(252, 155, 122, ${Math.max(0.4, 0.9 - index * 0.1)})` }\n          ])\n        }\n      }))\n    }]\n  }\n})\n\n// 时长分布完成率图表配置\nconst durationCompletionOption = computed(() => {\n  if (!props.viewingData?.completion_rates?.duration_based_stats) return {}\n  \n  const data = Object.entries(props.viewingData.completion_rates.duration_based_stats)\n    .map(([duration, stats]) => ({\n      duration: duration.replace(/\\([^)]*\\)/, ''),\n      completion: stats.average_completion_rate,\n      count: stats.video_count,\n      fully_watched_rate: stats.fully_watched_rate\n    }))\n    .sort((a, b) => {\n      const order = ['短视频', '中等视频', '长视频']\n      return order.indexOf(a.duration) - order.indexOf(b.duration)\n    })\n  \n  const { isDarkMode } = useDarkMode()\n  const isDark = !!(isDarkMode && isDarkMode.value)\n  const axisLabelColor = isDark ? '#bbbbbb' : '#999'\n  const axisLineColor = isDark ? '#888888' : '#666'\n  const splitLineColor = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.1)'\n  const legendTextColor = isDark ? '#bbbbbb' : '#666'\n  const labelTextColor = isDark ? '#e5e7eb' : '#333'\n  const tooltipBg = isDark ? 'rgba(28, 28, 28, 0.9)' : 'rgba(255, 255, 255, 0.95)'\n  const tooltipText = isDark ? '#ffffff' : '#111111'\n\n  return {\n    tooltip: {\n      trigger: 'axis',\n      axisPointer: { type: 'shadow' },\n      backgroundColor: tooltipBg,\n      borderColor: '#fb7299',\n      textStyle: { color: tooltipText },\n      formatter: (params) => {\n        const completion = params.find(p => p.seriesName === '完成率')\n        const count = params.find(p => p.seriesName === '视频数量')\n        const fully = params.find(p => p.seriesName === '完整观看率')\n        return `${params[0].name}<br/>\n                完成率：${completion.value}%<br/>\n                完整观看率：${fully.value}%<br/>\n                视频数量：${count.value}个`\n      }\n    },\n    legend: {\n      data: ['完成率', '完整观看率', '视频数量'],\n      textStyle: { color: legendTextColor, fontSize: '12px' },\n      padding: [0, 0, 0, 0]\n    },\n    grid: {\n      top: '40px',\n      left: '3%',\n      right: '4%',\n      bottom: '3%',\n      containLabel: true\n    },\n    xAxis: {\n      type: 'category',\n      data: data.map(item => item.duration),\n      axisLine: { lineStyle: { color: axisLineColor } },\n      axisLabel: { color: axisLabelColor, fontSize: '12px' }\n    },\n    yAxis: [{\n      type: 'value',\n      name: '百分比',\n      min: 0,\n      max: 100,\n      axisLine: { lineStyle: { color: axisLineColor } },\n      axisLabel: {\n        color: axisLabelColor,\n        formatter: '{value}%',\n        fontSize: '12px'\n      },\n      splitLine: { lineStyle: { color: splitLineColor } }\n    }, {\n      type: 'value',\n      name: '视频数量',\n      axisLine: { lineStyle: { color: axisLineColor } },\n      axisLabel: { color: axisLabelColor, fontSize: '12px' },\n      splitLine: { show: false }\n    }],\n    series: [{\n      name: '完成率',\n      type: 'bar',\n      barWidth: '20%',\n      data: data.map((item, index) => ({\n        value: item.completion,\n        itemStyle: {\n          color: new echarts.graphic.LinearGradient(0, 1, 0, 0, [\n            { offset: 0, color: `rgba(251, 114, 153, ${Math.max(0.4, 0.9 - index * 0.2)})` },\n            { offset: 1, color: `rgba(252, 155, 122, ${Math.max(0.4, 0.9 - index * 0.2)})` }\n          ])\n        }\n      })),\n      label: {\n        show: true,\n        position: 'top',\n        formatter: '{c}%',\n        fontSize: '12px',\n        color: labelTextColor\n      }\n    }, {\n      name: '完整观看率',\n      type: 'bar',\n      barWidth: '20%',\n      data: data.map((item, index) => ({\n        value: item.fully_watched_rate,\n        itemStyle: {\n          color: new echarts.graphic.LinearGradient(0, 1, 0, 0, [\n            { offset: 0, color: `rgba(64, 169, 255, ${Math.max(0.4, 0.9 - index * 0.2)})` },\n            { offset: 1, color: `rgba(128, 208, 255, ${Math.max(0.4, 0.9 - index * 0.2)})` }\n          ])\n        }\n      })),\n      label: {\n        show: true,\n        position: 'top',\n        formatter: '{c}%',\n        fontSize: '12px',\n        color: labelTextColor,\n        textBorderWidth: 0,\n        textBorderColor: 'transparent',\n        textShadowBlur: 0,\n        textShadowColor: 'transparent'\n      }\n    }, {\n      name: '视频数量',\n      type: 'line',\n      yAxisIndex: 1,\n      smooth: true,\n      symbolSize: 6,\n      lineStyle: { width: 2 },\n      itemStyle: { color: '#fc9b7a' },\n      data: data.map(item => item.count)\n    }]\n  }\n})\n\nonMounted(() => {\n  const charts = [\n    completionDistributionRef.value,\n    durationCompletionRef.value\n  ]\n\n  if (charts.every(chart => chart)) {\n    gsap.from(charts.map(chart => chart.$el), {\n      opacity: 0,\n      y: 20,\n      duration: 0.5,\n      stagger: 0.1,\n      ease: 'power2.out',\n      delay: 0.2\n    })\n  }\n})\n</script> "
  },
  {
    "path": "src/components/tailwind/analytics/pages/OverviewPage.vue",
    "content": "<!-- 数据概览页组件 -->\n<template>\n  <div class=\"space-y-12\">\n    <h3 class=\"text-3xl font-bold text-center bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent\">\n      年度观看数据\n    </h3>\n\n    <div class=\"text-base text-center text-gray-600 dark:text-gray-300 space-y-3\">\n      <!-- 总体活动总结（放在最前面） -->\n      <div v-if=\"viewingData?.insights?.overall_activity\"\n        v-html=\"formatInsightText(viewingData.insights.overall_activity)\"\n      >\n      </div>\n\n      <!-- 按指定顺序合并所有总结，用逗号分隔 -->\n      <div>\n        <span v-if=\"viewingBehaviorData?.report?.total_summary\" v-html=\"formatInsightText(viewingBehaviorData.report.total_summary)\"></span>\n        <span v-if=\"viewingBehaviorData?.report?.total_summary && viewingBehaviorData?.report?.category_summary\">, </span>\n        <span v-if=\"viewingBehaviorData?.report?.category_summary\" v-html=\"formatInsightText(viewingBehaviorData.report.category_summary)\"></span>\n        <span v-if=\"(viewingBehaviorData?.report?.total_summary || viewingBehaviorData?.report?.category_summary) && viewingBehaviorData?.report?.device_summary\">, </span>\n        <span v-if=\"viewingBehaviorData?.report?.device_summary\" v-html=\"formatInsightText(viewingBehaviorData.report.device_summary)\"></span>\n        <span v-if=\"(viewingBehaviorData?.report?.total_summary || viewingBehaviorData?.report?.category_summary || viewingBehaviorData?.report?.device_summary) && viewingBehaviorData?.report?.up_summary\">, </span>\n        <span v-if=\"viewingBehaviorData?.report?.up_summary\" v-html=\"formatInsightText(viewingBehaviorData.report.up_summary)\"></span>\n        <span v-if=\"(viewingBehaviorData?.report?.total_summary || viewingBehaviorData?.report?.category_summary || viewingBehaviorData?.report?.device_summary || viewingBehaviorData?.report?.up_summary) && viewingBehaviorData?.report?.time_slot_summary\">, </span>\n        <span v-if=\"viewingBehaviorData?.report?.time_slot_summary\" v-html=\"formatInsightText(viewingBehaviorData.report.time_slot_summary)\"></span>\n        <span v-if=\"(viewingBehaviorData?.report?.total_summary || viewingBehaviorData?.report?.category_summary || viewingBehaviorData?.report?.device_summary || viewingBehaviorData?.report?.up_summary || viewingBehaviorData?.report?.time_slot_summary) && viewingBehaviorData?.report?.late_night_summary\">, </span>\n        <span v-if=\"viewingBehaviorData?.report?.late_night_summary\" v-html=\"formatInsightText(viewingBehaviorData.report.late_night_summary)\"></span>\n      </div>\n    </div>\n\n    <!-- 年度观看热力图 -->\n    <div class=\"space-y-8\">\n     <div class=\"flex justify-center items-center text-sm text-gray-500 dark:text-gray-400 space-x-6\">\n       <div class=\"flex items-center\">\n         <span class=\"inline-block w-3 h-3 rounded-sm bg-[#FFECF1] dark:bg-[#4B1F2C] mr-1\"></span>\n         <span>1-10</span>\n       </div>\n       <div class=\"flex items-center\">\n         <span class=\"inline-block w-3 h-3 rounded-sm bg-[#FFB3CA] dark:bg-[#7A2D47] mr-1\"></span>\n         <span>11-50</span>\n       </div>\n       <div class=\"flex items-center\">\n         <span class=\"inline-block w-3 h-3 rounded-sm bg-[#FF8CB0] dark:bg-[#B3476A] mr-1\"></span>\n         <span>51-100</span>\n       </div>\n       <div class=\"flex items-center\">\n         <span class=\"inline-block w-3 h-3 rounded-sm bg-[#FF6699] dark:bg-[#E35C8B] mr-1\"></span>\n         <span>101-200</span>\n       </div>\n       <div class=\"flex items-center\">\n         <span class=\"inline-block w-3 h-3 rounded-sm bg-[#E84B85] dark:bg-[#FF7FA8] mr-1\"></span>\n         <span>201+</span>\n       </div>\n     </div>\n      <div ref=\"heatmapChartRef\" class=\"w-full h-[320px]\"></div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, watch } from 'vue'\nimport * as echarts from 'echarts'\nimport { getYearlyAnalysis, getViewingBehavior } from '@/api/api.js'\nimport { useDarkMode } from '@/store/darkMode.js'\n\nconst props = defineProps({\n  viewingData: {\n    type: Object,\n    required: true\n  }\n})\n\nconst heatmapChartRef = ref(null)\nlet heatmapChart = null\nconst yearlyData = ref(null)\nconst viewingBehaviorData = ref(null)\nconst { isDarkMode } = useDarkMode()\n\n// 格式化洞察文本，为数字添加颜色\nconst formatInsightText = (text) => {\n  if (!text) return '';\n  return text.replace(/(\\d+(\\.\\d+)?)/g, '<span class=\"text-[#fb7299]\">$1</span>')\n}\n\n// 获取日期范围内的所有日期\nconst getDateRange = (year) => {\n  const start = new Date(Date.UTC(year, 0, 1))\n  const end = new Date(Date.UTC(year, 11, 31))\n  const dates = []\n  let current = start\n\n  while (current <= end) {\n    const date = current.toISOString().split('T')[0]\n    dates.push(date)\n    current = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth(), current.getUTCDate() + 1))\n  }\n\n  return dates\n}\n\n// 获取年度分析数据\nconst fetchYearlyData = async (year) => {\n  if (!year) return\n\n  try {\n    const response = await getYearlyAnalysis(year)\n    if (response.data.status === 'success') {\n      yearlyData.value = response.data\n      initHeatmapChart()\n    } else {\n      console.error('获取年度数据失败:', response.data.message)\n    }\n  } catch (error) {\n    console.error('获取年度数据出错:', error)\n  }\n}\n\n// 获取观看行为数据\nconst fetchViewingBehavior = async (year) => {\n  if (!year) return\n\n  try {\n    const response = await getViewingBehavior(year, true)\n    if (response.data && response.data.status === 'success') {\n      viewingBehaviorData.value = response.data.data\n    }\n  } catch (error) {\n    console.error('获取观看行为数据失败:', error)\n  }\n}\n\nconst initHeatmapChart = () => {\n  if (!heatmapChartRef.value || !yearlyData.value?.data?.daily_count) return\n\n  const year = props.viewingData?.year\n  if (!year) return\n\n  const dailyData = yearlyData.value.data.daily_count\n  const allDates = getDateRange(year)\n\n  // 将数据转换为热力图所需的格式\n  const data = allDates.map(date => {\n    const count = dailyData[date] || 0\n    return [date, count]\n  })\n\n  // 如果已经存在图表实例，先销毁它\n  if (heatmapChart) {\n    heatmapChart.dispose()\n  }\n\n  // 深色模式颜色方案\n  const isDark = !!(isDarkMode && isDarkMode.value)\n  const calendarItemColor = isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(255, 255, 255, 0.5)'\n  const calendarBorderColor = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(238, 238, 238, 0.8)'\n  const axisLabelColor = isDark ? '#bbbbbb' : '#666666'\n  const tooltipBg = isDark ? 'rgba(28, 28, 28, 0.9)' : 'rgba(255, 255, 255, 0.95)'\n  const tooltipText = isDark ? '#ffffff' : '#111111'\n\n  heatmapChart = echarts.init(heatmapChartRef.value)\n  const option = {\n    animation: true,\n    tooltip: {\n      show: true,\n      trigger: 'item',\n      backgroundColor: tooltipBg,\n      borderColor: '#fb7299',\n      textStyle: {\n        color: tooltipText,\n        fontSize: 14\n      },\n      formatter: function(params) {\n        const date = params.value[0]\n        const count = params.value[1]\n        const secondsMap = (yearlyData.value && yearlyData.value.data && yearlyData.value.data.daily_watch_seconds) || {}\n        const seconds = secondsMap[date] || 0\n        const h = Math.floor(seconds / 3600)\n        const m = Math.floor((seconds % 3600) / 60)\n        const s = seconds % 60\n        let timeStr\n        if (h > 0) {\n          timeStr = `${h}小时${String(m).padStart(2, '0')}分${String(s).padStart(2, '0')}秒`\n        } else if (m > 0) {\n          timeStr = `${m}分${String(s).padStart(2, '0')}秒`\n        } else {\n          timeStr = `${s}秒`\n        }\n        return `${date} : ${count}个视频 · 观看时长 ${timeStr}`\n      }\n    },\n    visualMap: {\n      show: false,\n      type: 'piecewise',\n      pieces: isDark ? [\n        { min: 1, max: 10, color: '#4B1F2C' },\n        { min: 11, max: 50, color: '#7A2D47' },\n        { min: 51, max: 100, color: '#B3476A' },\n        { min: 101, max: 200, color: '#E35C8B' },\n        { min: 201, max: 9999, color: '#FF7FA8' }\n      ] : [\n        { min: 1, max: 10, color: '#FFECF1' },\n        { min: 11, max: 50, color: '#FFB3CA' },\n        { min: 51, max: 100, color: '#FF8CB0' },\n        { min: 101, max: 200, color: '#FF6699' },\n        { min: 201, max: 9999, color: '#E84B85' }\n      ]\n    },\n    calendar: {\n      top: 30,\n      left: 60,\n      right: 60,\n      cellSize: [16, 16],\n      range: [`${year}-01-01`, `${year}-12-31`],\n      itemStyle: {\n        color: calendarItemColor,\n        borderColor: calendarBorderColor,\n        borderWidth: 1\n      },\n      yearLabel: { show: false },\n      dayLabel: {\n        firstDay: 0,\n        nameMap: ['日', '一', '二', '三', '四', '五', '六'],\n        color: axisLabelColor,\n        fontSize: 12,\n        margin: 12\n      },\n      monthLabel: {\n        nameMap: ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'],\n        color: axisLabelColor,\n        fontSize: 12,\n        align: 'center',\n        margin: 15\n      },\n      splitLine: {\n        show: false\n      }\n    },\n    series: [{\n      type: 'heatmap',\n      coordinateSystem: 'calendar',\n      data: data,\n      label: {\n        show: false\n      },\n      emphasis: {\n        itemStyle: {\n          borderColor: '#fb7299',\n          borderWidth: 2\n        }\n      }\n    }]\n  }\n\n  heatmapChart.setOption(option)\n}\n\nonMounted(() => {\n  if (props.viewingData?.year) {\n    fetchYearlyData(props.viewingData.year)\n    fetchViewingBehavior(props.viewingData.year)\n  }\n\n  // 监听窗口大小变化\n  window.addEventListener('resize', () => {\n    heatmapChart?.resize()\n  })\n})\n\n// 监听数据变化\nwatch(() => yearlyData.value, () => {\n  if (props.viewingData?.year) {\n    initHeatmapChart()\n  }\n}, { deep: true })\n\n// 监听深色模式变化，重绘图表\nwatch(() => isDarkMode.value, () => {\n  initHeatmapChart()\n})\n\n// 监听年份变化\nwatch(() => props.viewingData?.year, (newYear) => {\n  if (newYear) {\n    fetchYearlyData(newYear)\n    fetchViewingBehavior(newYear)\n  }\n}, { immediate: true })\n</script>\n\n<style>\n.dark .echarts {\n  filter: brightness(0.9);\n}\n</style>\n"
  },
  {
    "path": "src/components/tailwind/analytics/pages/PopularHitRatePage.vue",
    "content": "<!-- 热门视频命中率分析页组件 -->\n<template>\n  <div class=\"space-y-4\">\n    <h3 class=\"text-xl font-bold text-center bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent\">\n      热门视频命中率分析\n    </h3>\n\n    <!-- 加载状态 -->\n    <div v-if=\"loading\" class=\"flex justify-center items-center py-12\">\n      <div class=\"animate-spin rounded-full h-12 w-12 border-b-2 border-[#fb7299]\"></div>\n    </div>\n\n    <!-- 错误状态 -->\n    <div v-else-if=\"error\" class=\"text-center py-12\">\n      <div class=\"text-red-500 text-lg\">{{ error }}</div>\n    </div>\n\n    <!-- 主要内容 -->\n    <div v-else-if=\"hitRateData\" class=\"space-y-4\">\n\n      <!-- 洞察文本 -->\n      <div class=\"text-center text-gray-600 dark:text-gray-300\">\n        <div class=\"text-sm leading-relaxed\" v-html=\"formatInsightText(hitRateData.insights.join('，'))\"></div>\n      </div>\n\n      <!-- 可视化图表 -->\n      <div class=\"grid grid-cols-1 lg:grid-cols-3 gap-4\">\n        <!-- 命中率统计卡片 -->\n        <div class=\"bg-white/50 dark:bg-white/5 backdrop-blur-sm rounded-xl p-4 border border-gray-300/50 dark:border-gray-500/50\">\n          <h4 class=\"text-base font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent mb-2 text-center\">\n            观看时机分析\n          </h4>\n          <div ref=\"chartRef\" class=\"h-[460px]\"></div>\n        </div>\n\n        <!-- 热门视频列表 -->\n        <div v-if=\"hitRateData.popular_videos && hitRateData.popular_videos.length > 0\"\n             class=\"lg:col-span-2 backdrop-blur-sm rounded-xl p-4 border border-gray-300/50 dark:border-gray-500/50\">\n          <h4 class=\"text-base font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent mb-2\">\n            你观看过的热门视频 (前10个)\n          </h4>\n          <div class=\"grid grid-cols-1 md:grid-cols-2 gap-2 h-[460px] overflow-y-auto\">\n            <div v-for=\"(video, index) in hitRateData.popular_videos\" :key=\"index\"\n                 @click=\"openVideo(video.bvid)\"\n                 class=\"flex items-center p-2 rounded-lg cursor-pointer transition-colors\">\n              <div class=\"flex-1 min-w-0\">\n                <div class=\"font-medium text-gray-900 dark:text-gray-100 truncate text-xs\">{{ video.title }}</div>\n                <div class=\"text-xs text-gray-500 dark:text-gray-400 mt-1\">\n                  <span>{{ video.author }}</span>\n                  <span class=\"ml-2\">{{ formatDate(video.view_at) }}</span>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, watch } from 'vue'\nimport * as echarts from 'echarts'\nimport { useDarkMode } from '@/store/darkMode.js'\nimport { getPopularHitRate } from '../../../../api/api.js'\n\nconst props = defineProps({\n  selectedYear: {\n    type: Number,\n    default: () => new Date().getFullYear()\n  },\n  hitRateData: {\n    type: Object,\n    default: null\n  }\n})\n\nconst loading = ref(true)\nconst error = ref(null)\nconst hitRateData = ref(null)\nconst chartRef = ref(null)\nlet chart = null\nconst { isDarkMode } = useDarkMode()\n\n// 格式化洞察文本，为数字添加颜色\nconst formatInsightText = (text) => {\n  if (!text) return '';\n  return text.replace(/(\\d+(\\.\\d+)?)/g, '<span class=\"text-[#fb7299] font-semibold\">$1</span>')\n}\n\n// 格式化日期\nconst formatDate = (timestamp) => {\n  return new Date(timestamp * 1000).toLocaleDateString('zh-CN')\n}\n\n// 打开视频\nconst openVideo = (bvid) => {\n  if (bvid) {\n    window.open(`https://www.bilibili.com/video/${bvid}`, '_blank')\n  }\n}\n\n// 获取热门命中率数据\nconst fetchHitRateData = async (year) => {\n  if (!year) return\n\n  // 如果父组件已经传递了数据，直接使用\n  if (props.hitRateData) {\n    hitRateData.value = props.hitRateData.hit_rate_analysis\n    loading.value = false\n    await initCharts()\n    return\n  }\n\n  loading.value = true\n  error.value = null\n\n  try {\n    const response = await getPopularHitRate(year)\n    if (response.data.status === 'success') {\n      hitRateData.value = response.data.data.hit_rate_analysis\n      await initCharts()\n    } else {\n      error.value = response.data.message || '获取数据失败'\n    }\n  } catch (err) {\n    console.error('获取热门命中率数据出错:', err)\n    error.value = '获取数据时发生错误'\n  } finally {\n    loading.value = false\n  }\n}\n\n// 初始化图表\nconst initCharts = async () => {\n  await new Promise(resolve => setTimeout(resolve, 100)) // 等待DOM更新\n  initChart()\n}\n\n// 初始化图表\nconst initChart = () => {\n  if (!chartRef.value || !hitRateData.value) return\n\n  if (chart) {\n    chart.dispose()\n  }\n\n  chart = echarts.init(chartRef.value)\n\n  const timingData = hitRateData.value.time_pattern_analysis || {}\n\n  const data = [\n    { name: '立即观看', value: timingData.immediate_watch || 0 },\n    { name: '热门期观看', value: timingData.trending_watch || 0 }\n  ]\n\n  const isDark = !!(isDarkMode && isDarkMode.value)\n  const legendTextColor = isDark ? '#bbbbbb' : '#666'\n  const labelTextColor = isDark ? '#e5e7eb' : '#333'\n  const tooltipBg = isDark ? 'rgba(28, 28, 28, 0.9)' : 'rgba(255, 255, 255, 0.95)'\n  const tooltipText = isDark ? '#ffffff' : '#111111'\n\n  const option = {\n    tooltip: {\n      trigger: 'item',\n      backgroundColor: tooltipBg,\n      borderColor: '#fb7299',\n      textStyle: { color: tooltipText },\n      formatter: function(params) {\n        const percentage = params.percent\n        return `${params.name}: ${params.value} 个 (${percentage}%)`\n      }\n    },\n    legend: {\n      bottom: '8%',\n      left: 'center',\n      textStyle: {\n        color: legendTextColor,\n        fontSize: 12\n      }\n    },\n    series: [\n      {\n        name: '观看时机',\n        type: 'pie',\n        radius: '70%',\n        center: ['50%', '45%'],\n        label: {\n          show: true,\n          position: 'outside',\n          formatter: function(params) {\n            return `${params.name}\\n${params.value}个\\n${params.percent}%`\n          },\n          fontSize: 11,\n          color: labelTextColor\n        },\n        emphasis: {\n          label: {\n            show: true,\n            fontSize: '13',\n            fontWeight: 'bold'\n          },\n          itemStyle: {\n            shadowBlur: 10,\n            shadowOffsetX: 0,\n            shadowColor: 'rgba(0, 0, 0, 0.5)'\n          }\n        },\n        labelLine: {\n          show: true,\n          length: 15,\n          length2: 10\n        },\n        data: data.map((item, index) => ({\n          ...item,\n          itemStyle: {\n            color: ['#fb7299', '#fc9b7a'][index]\n          }\n        }))\n      }\n    ]\n  }\n\n  chart.setOption(option)\n}\n\n// 监听年份变化\nwatch(() => props.selectedYear, (newYear) => {\n  fetchHitRateData(newYear)\n}, { immediate: true })\n\n// 监听父组件传递的数据变化\nwatch(() => props.hitRateData, (newData) => {\n  if (newData) {\n    hitRateData.value = newData.hit_rate_analysis\n    loading.value = false\n    initCharts()\n  }\n}, { immediate: true })\n\n// 组件挂载时获取数据\nonMounted(() => {\n  fetchHitRateData(props.selectedYear)\n})\n\n// 深色模式切换时重绘图表\nwatch(() => isDarkMode.value, () => {\n  if (hitRateData.value) {\n    initChart()\n  }\n})\n</script>\n"
  },
  {
    "path": "src/components/tailwind/analytics/pages/PopularPredictionPage.vue",
    "content": "<template>\n  <div class=\"space-y-4\">\n    <h3 class=\"text-xl font-bold text-center bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent\">\n      热门预测能力分析\n    </h3>\n\n    <!-- 加载状态 -->\n    <div v-if=\"loading\" class=\"flex justify-center items-center py-12\">\n      <div class=\"animate-spin rounded-full h-12 w-12 border-b-2 border-[#fb7299]\"></div>\n    </div>\n\n    <!-- 错误状态 -->\n    <div v-else-if=\"error\" class=\"text-center py-12\">\n      <div class=\"text-red-500 text-lg\">{{ error }}</div>\n    </div>\n\n    <!-- 主要内容 -->\n    <div v-else-if=\"predictionData\" class=\"space-y-4\">\n\n      <!-- 洞察文本 -->\n      <div class=\"text-center text-gray-600 dark:text-gray-300\">\n        <div class=\"text-sm leading-relaxed\" v-html=\"formatInsightText(insights.join('，'))\"></div>\n      </div>\n\n      <!-- 可视化图表 -->\n      <div class=\"grid grid-cols-1 lg:grid-cols-3 gap-4\">\n        <!-- 预测能力统计卡片 -->\n        <div class=\"bg-white/50 dark:bg-white/5 backdrop-blur-sm rounded-xl p-4 border border-gray-300/50 dark:border-gray-500/50\">\n          <h4 class=\"text-base font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent mb-2 text-center\">\n            预测能力分布\n          </h4>\n          <div ref=\"chartRef\" class=\"h-[460px]\"></div>\n        </div>\n\n        <!-- 预测成功的视频列表 -->\n        <div v-if=\"predictedVideos && predictedVideos.length > 0\"\n             class=\"lg:col-span-2 backdrop-blur-sm rounded-xl p-4 border border-gray-300/50 dark:border-gray-500/50\">\n          <h4 class=\"text-base font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent mb-2\">\n            预测成功的视频 (前10个，按提前天数排序)\n          </h4>\n          <div class=\"grid grid-cols-1 md:grid-cols-2 gap-2 h-[460px] overflow-y-auto\">\n            <div v-for=\"(video, index) in predictedVideos\" :key=\"index\"\n                 @click=\"openVideo(video.bvid)\"\n                 class=\"flex items-center p-2 rounded-lg cursor-pointer transition-colors\">\n              <div class=\"flex-1 min-w-0\">\n                <div class=\"font-medium text-gray-900 dark:text-gray-100 truncate text-xs\">{{ video.title }}</div>\n                <div class=\"text-xs text-gray-500 dark:text-gray-400 mt-1\">\n                  <span>{{ video.author }}</span>\n                  <span class=\"ml-2 text-blue-600\">提前{{ video.advance_days }}天</span>\n                </div>\n                <div class=\"text-xs text-gray-400 mt-1\">\n                  <span>最高排名: {{ video.highest_rank || '未知' }}</span>\n                  <span class=\"ml-2\">{{ formatDate(video.view_at) }}</span>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, watch, computed } from 'vue'\nimport * as echarts from 'echarts'\nimport { useDarkMode } from '@/store/darkMode.js'\nimport { getPopularPredictionAbility } from '../../../../api/api.js'\n\nconst props = defineProps({\n  selectedYear: {\n    type: Number,\n    default: () => new Date().getFullYear()\n  },\n  data: {\n    type: Object,\n    default: null\n  }\n})\n\nconst loading = ref(true)\nconst error = ref(null)\nconst predictionData = ref(null)\nconst chartRef = ref(null)\nlet chart = null\nconst { isDarkMode } = useDarkMode()\n\n// 格式化洞察文本，为数字添加颜色\nconst formatInsightText = (text) => {\n  if (!text) return '';\n  return text.replace(/(\\d+(\\.\\d+)?)/g, '<span class=\"text-[#fb7299] font-semibold\">$1</span>')\n}\n\n// 格式化日期\nconst formatDate = (timestamp) => {\n  return new Date(timestamp * 1000).toLocaleDateString('zh-CN')\n}\n\n// 打开视频\nconst openVideo = (bvid) => {\n  if (bvid) {\n    window.open(`https://www.bilibili.com/video/${bvid}`, '_blank')\n  }\n}\n\n// 计算属性\nconst insights = computed(() => {\n  if (!predictionData.value) return []\n  return predictionData.value.insights || []\n})\n\nconst predictedVideos = computed(() => {\n  if (!predictionData.value) return []\n  return predictionData.value.predicted_videos || []\n})\n\n// 获取热门预测能力数据\nconst fetchPredictionData = async (year) => {\n  if (!year) return\n\n  // 如果父组件已经传递了数据，直接使用\n  if (props.data) {\n    predictionData.value = props.data\n    loading.value = false\n    await initCharts()\n    return\n  }\n\n  loading.value = true\n  error.value = null\n\n  try {\n    const response = await getPopularPredictionAbility(year)\n    if (response.data.status === 'success') {\n      predictionData.value = response.data.data.prediction_analysis\n      await initCharts()\n    } else {\n      error.value = response.data.message || '获取数据失败'\n    }\n  } catch (err) {\n    console.error('获取热门预测能力数据出错:', err)\n    error.value = '获取数据时发生错误'\n  } finally {\n    loading.value = false\n  }\n}\n\n// 初始化图表\nconst initCharts = async () => {\n  await new Promise(resolve => setTimeout(resolve, 100)) // 等待DOM更新\n  initChart()\n}\n\n// 初始化图表\nconst initChart = () => {\n  if (!chartRef.value || !predictionData.value) return\n\n  if (chart) {\n    chart.dispose()\n  }\n\n  chart = echarts.init(chartRef.value)\n\n  const predictedCount = predictionData.value.predicted_count\n  const nonPredictedCount = predictionData.value.total_watched - predictedCount\n  const predictionRate = predictionData.value.prediction_rate\n\n  const isDark = !!(isDarkMode && isDarkMode.value)\n  const legendTextColor = isDark ? '#bbbbbb' : '#666'\n  const labelTextColor = isDark ? '#e5e7eb' : '#333'\n  const tooltipBg = isDark ? 'rgba(28, 28, 28, 0.9)' : 'rgba(255, 255, 255, 0.95)'\n  const tooltipText = isDark ? '#ffffff' : '#111111'\n\n  const option = {\n    tooltip: {\n      trigger: 'item',\n      backgroundColor: tooltipBg,\n      borderColor: '#fb7299',\n      textStyle: { color: tooltipText },\n      formatter: function(params) {\n        const percentage = params.percent\n        return `${params.name}: ${params.value} 个 (${percentage}%)`\n      }\n    },\n    legend: {\n      bottom: '8%',\n      left: 'center',\n      textStyle: {\n        color: legendTextColor,\n        fontSize: 12\n      }\n    },\n    series: [\n      {\n        name: '预测能力分布',\n        type: 'pie',\n        radius: '70%',\n        center: ['50%', '45%'],\n        label: {\n          show: true,\n          position: 'outside',\n          formatter: function(params) {\n            return `${params.name}\\n${params.value}个\\n${params.percent}%`\n          },\n          fontSize: 11,\n          color: labelTextColor\n        },\n        emphasis: {\n          label: {\n            show: true,\n            fontSize: '13',\n            fontWeight: 'bold'\n          },\n          itemStyle: {\n            shadowBlur: 10,\n            shadowOffsetX: 0,\n            shadowColor: 'rgba(0, 0, 0, 0.5)'\n          }\n        },\n        labelLine: {\n          show: true,\n          length: 15,\n          length2: 10\n        },\n        data: [\n          {\n            value: predictedCount,\n            name: '预测成功',\n            itemStyle: {\n              color: {\n                type: 'linear',\n                x: 0, y: 0, x2: 1, y2: 1,\n                colorStops: [\n                  { offset: 0, color: '#10B981' },\n                  { offset: 1, color: '#059669' }\n                ]\n              }\n            }\n          },\n          {\n            value: nonPredictedCount,\n            name: '普通视频',\n            itemStyle: {\n              color: {\n                type: 'linear',\n                x: 0, y: 0, x2: 1, y2: 1,\n                colorStops: [\n                  { offset: 0, color: '#e0e0e0' },\n                  { offset: 1, color: '#c0c0c0' }\n                ]\n              }\n            }\n          }\n        ]\n      }\n    ]\n  }\n\n  chart.setOption(option)\n}\n\n// 监听年份变化\nwatch(() => props.selectedYear, (newYear) => {\n  fetchPredictionData(newYear)\n}, { immediate: true })\n\n// 监听父组件传递的数据变化\nwatch(() => props.data, (newData) => {\n  if (newData) {\n    predictionData.value = newData\n    loading.value = false\n    initCharts()\n  }\n}, { immediate: true })\n\n// 组件挂载时获取数据\nonMounted(() => {\n  fetchPredictionData(props.selectedYear)\n})\n\n// 深色模式切换时重绘图表\nwatch(() => isDarkMode.value, () => {\n  if (predictionData.value) {\n    initChart()\n  }\n})\n</script>\n\n<style scoped>\n.line-clamp-2 {\n  display: -webkit-box;\n  -webkit-line-clamp: 2;\n  -webkit-box-orient: vertical;\n  overflow: hidden;\n}\n</style>\n"
  },
  {
    "path": "src/components/tailwind/analytics/pages/RewatchPage.vue",
    "content": "<!-- 最爱重温页组件 -->\n<template>\n  <div class=\"absolute inset-0\">\n    <div class=\"h-full flex items-center justify-center\">\n      <div class=\"max-w-7xl w-full mx-auto px-2 py-6 overflow-y-auto\">\n        <div class=\"space-y-4\">\n          <h3 class=\"text-3xl font-bold text-center bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent\">\n            最爱重温的视频\n          </h3>\n\n          <div v-if=\"viewingData?.insights?.most_watched_videos\"\n            class=\"text-sm text-center text-gray-600 dark:text-gray-300 mb-2 px-4\"\n            v-html=\"formatInsightText(viewingData.insights.most_watched_videos)\"\n          >\n          </div>\n\n          <!-- 没有重复观看数据的提示 -->\n          <div v-if=\"!viewingData?.watch_counts?.most_watched_videos?.length\"\n               class=\"mt-8 text-center py-10 bg-white/50 dark:bg-white/5 backdrop-blur-sm rounded-lg border border-gray-300/50 dark:border-gray-500/50\">\n            <svg class=\"w-16 h-16 mx-auto text-gray-400\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.5\" d=\"M15 10.5a3 3 0 11-6 0 3 3 0 016 0z\" />\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.5\" d=\"M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z\" />\n            </svg>\n            <p class=\"mt-4 text-lg font-medium text-gray-700 dark:text-gray-300\">暂无重复观看记录</p>\n            <p class=\"mt-2 text-sm text-gray-600 dark:text-gray-400\">你在这一年中很少重复观看相同的视频</p>\n            <p class=\"mt-1 text-sm text-gray-500\">每次都在探索新内容的你，真是充满好奇心呢！</p>\n          </div>\n\n          <!-- 第一名 -->\n          <div v-else-if=\"viewingData.watch_counts.most_watched_videos[0]\"\n            class=\"bg-gradient-to-br from-white/50 via-[#fb7299]/10 to-[#fc9b7a]/20 dark:from-white/5 dark:via-[#fb7299]/20 dark:to-[#fc9b7a]/30 backdrop-blur-sm rounded-lg p-3 transform hover:scale-[1.01] transition-transform cursor-pointer video-item relative overflow-hidden border border-gray-300/50 dark:border-gray-500/50\"\n            @click=\"handleVideoClick(viewingData.watch_counts.most_watched_videos[0].bvid)\"\n          >\n            <div class=\"flex items-start space-x-4\">\n              <div class=\"flex-1 min-w-0\">\n                <div class=\"text-sm font-medium text-gray-800 dark:text-white hover:text-[#fb7299] transition-colors line-clamp-2\">\n                  {{ viewingData.watch_counts.most_watched_videos[0].title }}\n                </div>\n                <div class=\"mt-1.5 text-xs text-gray-600 dark:text-gray-400 flex items-center space-x-4\">\n                  <span>UP主：{{ viewingData.watch_counts.most_watched_videos[0].author_name }}</span>\n                  <span>观看 {{ viewingData.watch_counts.most_watched_videos[0].watch_count }} 次</span>\n                  <span>{{ viewingData.watch_counts.most_watched_videos[0].tag_name }}</span>\n                </div>\n              </div>\n              <div class=\"flex flex-col items-center justify-center bg-white/30 dark:bg-white/10 backdrop-blur-sm rounded-lg px-2 py-1.5 border border-gray-300/50 dark:border-gray-500/50\">\n                <div class=\"text-lg font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent rewatch-interval whitespace-nowrap\">\n                  {{ Math.round(viewingData.watch_counts.most_watched_videos[0].avg_interval / 3600 / 24) }}\n                </div>\n                <div class=\"text-[10px] text-gray-500 dark:text-gray-400 whitespace-nowrap\">天/次</div>\n              </div>\n            </div>\n          </div>\n\n          <!-- 其余视频 -->\n          <div v-if=\"viewingData.watch_counts.most_watched_videos?.length > 1\" class=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3\">\n            <div v-for=\"(video, index) in viewingData.watch_counts.most_watched_videos.slice(1)\"\n              :key=\"video.bvid\"\n              class=\"backdrop-blur-sm rounded-lg p-2.5 transform hover:scale-[1.02] transition-transform cursor-pointer video-item relative overflow-hidden border border-gray-300/50 dark:border-gray-500/50\"\n              :class=\"{\n                'bg-gradient-to-br from-white/50 via-[#fc9b7a]/10 to-[#fcd07a]/20 dark:from-white/5 dark:via-[#fc9b7a]/20 dark:to-[#fcd07a]/30': index === 0,\n                'bg-gradient-to-br from-white/50 via-[#fcd07a]/10 to-[#fce07a]/20 dark:from-white/5 dark:via-[#fcd07a]/20 dark:to-[#fce07a]/30': index === 1,\n                'bg-white/50 dark:bg-white/5': index > 1\n              }\"\n              @click=\"handleVideoClick(video.bvid)\"\n            >\n              <div class=\"flex items-start space-x-3\">\n                <div class=\"flex-1 min-w-0\">\n                  <div class=\"text-xs font-medium text-gray-800 dark:text-white hover:text-[#fb7299] transition-colors line-clamp-2\">\n                    {{ video.title }}\n                  </div>\n                  <div class=\"mt-1 text-[10px] text-gray-600 dark:text-gray-400 flex items-center justify-between\">\n                    <span>UP主：{{ video.author_name }}</span>\n                    <span>{{ video.watch_count }}次</span>\n                  </div>\n                  <div class=\"text-[10px] text-gray-500 dark:text-gray-400\">{{ video.tag_name }}</div>\n                </div>\n                <div class=\"flex flex-col items-center justify-center bg-white/30 dark:bg-white/10 backdrop-blur-sm rounded-lg px-2 py-1 ml-1 border border-gray-300/50 dark:border-gray-500/50\">\n                  <div class=\"text-sm font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent rewatch-interval whitespace-nowrap\">\n                    {{ Math.round(video.avg_interval / 3600 / 24) }}\n                  </div>\n                  <div class=\"text-[10px] text-gray-500 dark:text-gray-400 whitespace-nowrap\">天/次</div>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { onMounted, nextTick, watch, ref } from 'vue'\nimport gsap from 'gsap'\nimport { openInBrowser } from '@/utils/openUrl.js'\n\nconst props = defineProps({\n  viewingData: {\n    type: Object,\n    required: true\n  }\n})\n\n// 用于存储当前视频列表的引用\nconst videoList = ref([])\n\nconst handleVideoClick = async (bvid) => {\n  await openInBrowser(`https://www.bilibili.com/video/${bvid}`)\n}\n\n// 初始化动画\nconst initAnimation = () => {\n  nextTick(() => {\n    if (props.viewingData?.watch_counts?.most_watched_videos?.length > 0) {\n      const videoItems = document.querySelectorAll('.video-item')\n      if (videoItems.length > 0) {\n        gsap.from(videoItems, {\n          opacity: 0,\n          y: 20,\n          duration: 0.5,\n          stagger: 0.1,\n          ease: 'power2.out',\n          delay: 0.2\n        })\n      }\n    }\n  })\n}\n\n// 监听数据变化\nwatch(() => props.viewingData?.watch_counts?.most_watched_videos, (newVal) => {\n  // 先清空现有数据\n  videoList.value = []\n\n  // 在下一个 tick 中更新数据\n  nextTick(() => {\n    if (newVal) {\n      videoList.value = newVal\n      initAnimation()\n    }\n  })\n}, { deep: true })\n\nonMounted(() => {\n  videoList.value = props.viewingData?.watch_counts?.most_watched_videos || []\n  initAnimation()\n})\n\n// 格式化洞察文本，为数字添加颜色\nconst formatInsightText = (text) => {\n  if (!text) return '';\n  return text.replace(/(\\d+(\\.\\d+)?)/g, '<span class=\"text-[#fb7299]\">$1</span>')\n}\n</script>\n\n<style scoped>\n.line-clamp-2 {\n  display: -webkit-box;\n  -webkit-line-clamp: 2;\n  -webkit-box-orient: vertical;\n  overflow: hidden;\n}\n</style>\n"
  },
  {
    "path": "src/components/tailwind/analytics/pages/StreakPage.vue",
    "content": "<!-- 连续观看记录页组件 -->\n<template>\n  <div class=\"space-y-6\">\n    <h3 class=\"text-4xl font-bold text-center bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent\">\n      连续观看记录\n    </h3>\n\n    <div v-if=\"viewingData?.insights?.continuity\"\n      class=\"text-lg text-center text-gray-600 dark:text-gray-300 mb-8\"\n      v-html=\"formatInsightText(viewingData.insights.continuity)\"\n    >\n    </div>\n\n    <div v-if=\"viewingData?.viewing_continuity\" class=\"grid grid-cols-2 gap-8\">\n      <div class=\"bg-white/50 dark:bg-white/5 backdrop-blur-sm rounded-xl p-6 border border-gray-300/50 dark:border-gray-500/50\">\n        <div class=\"text-4xl font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent streak-number\">{{ viewingData.viewing_continuity.max_streak || 0 }}天</div>\n        <div class=\"text-lg text-gray-600 dark:text-gray-400 mt-2\">最长连续观看</div>\n        <div v-if=\"viewingData.viewing_continuity.longest_streak_period\" class=\"mt-4 text-sm text-gray-500\">\n          {{ viewingData.viewing_continuity.longest_streak_period.start }} 至\n          {{ viewingData.viewing_continuity.longest_streak_period.end }}\n        </div>\n      </div>\n      <div class=\"bg-white/50 dark:bg-white/5 backdrop-blur-sm rounded-xl p-6 border border-gray-300/50 dark:border-gray-500/50\">\n        <div class=\"text-4xl font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent streak-number\">{{ viewingData.viewing_continuity.current_streak || 0 }}天</div>\n        <div class=\"text-lg text-gray-600 dark:text-gray-400 mt-2\">当前连续观看</div>\n        <div v-if=\"viewingData.viewing_continuity.current_streak_start\" class=\"mt-4 text-sm text-gray-500\">\n          开始于 {{ viewingData.viewing_continuity.current_streak_start }}\n        </div>\n      </div>\n    </div>\n\n    <!-- 数据加载中或无数据时的提示 -->\n    <div v-else class=\"text-center text-gray-500 dark:text-gray-400\">\n      正在加载连续观看数据...\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { onMounted } from 'vue'\nimport gsap from 'gsap'\n\nconst props = defineProps({\n  viewingData: {\n    type: Object,\n    required: true\n  }\n})\n\nonMounted(() => {\n  // 数字动画效果\n  const streakNumbers = document.querySelectorAll('.streak-number')\n  streakNumbers.forEach(el => {\n    const finalValue = parseInt(el.textContent)\n    gsap.fromTo(el,\n      { textContent: 0 },\n      {\n        duration: 2,\n        textContent: finalValue,\n        snap: { textContent: 1 },\n        ease: 'power1.out'\n      }\n    )\n  })\n})\n\n// 格式化洞察文本，为数字添加颜色\nconst formatInsightText = (text) => {\n  if (!text) return '';\n  return text.replace(/(\\d+(\\.\\d+)?)/g, '<span class=\"text-[#fb7299]\">$1</span>')\n}\n</script>\n"
  },
  {
    "path": "src/components/tailwind/analytics/pages/TagsPage.vue",
    "content": "<!-- 标签分析页组件 -->\n<template>\n  <div class=\"space-y-6\">\n    <h3 class=\"text-4xl font-bold text-center bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent\">\n      标签分析\n    </h3>\n\n    <div class=\"text-lg text-center text-gray-600 dark:text-gray-300 mb-8 space-y-2\">\n      <div v-if=\"viewingData?.insights?.tag_preference\" v-html=\"formatInsightText(viewingData.insights.tag_preference)\">\n      </div>\n      <div v-if=\"viewingData?.insights?.tag_completion\" v-html=\"formatInsightText(viewingData.insights.tag_completion)\">\n      </div>\n    </div>\n\n    <div class=\"grid grid-cols-1 lg:grid-cols-2 gap-6\">\n      <!-- 标签分布图表 -->\n      <div class=\"bg-white/50 dark:bg-white/5 backdrop-blur-sm rounded-xl p-6 border border-gray-300/50 dark:border-gray-500/50\">\n        <h4 class=\"text-xl font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent mb-4\">观看分布</h4>\n        <div class=\"h-[280px]\">\n          <v-chart ref=\"distributionChartRef\" class=\"h-full w-full\" :option=\"tagDistributionOption\" autoresize />\n        </div>\n      </div>\n\n      <!-- 标签完成率图表 -->\n      <div class=\"bg-white/50 dark:bg-white/5 backdrop-blur-sm rounded-xl p-6 border border-gray-300/50 dark:border-gray-500/50\">\n        <h4 class=\"text-xl font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent mb-4\">完成率排行</h4>\n        <div class=\"h-[280px]\">\n          <v-chart ref=\"completionChartRef\" class=\"h-full w-full\" :option=\"tagCompletionOption\" autoresize />\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted } from 'vue'\nimport gsap from 'gsap'\nimport VChart from 'vue-echarts'\nimport * as echarts from 'echarts/core'\nimport { useDarkMode } from '@/store/darkMode.js'\n\nconst props = defineProps({\n  viewingData: {\n    type: Object,\n    required: true\n  }\n})\n\nconst distributionChartRef = ref(null)\nconst completionChartRef = ref(null)\n\nconst tagDistributionOption = computed(() => {\n  const { isDarkMode } = useDarkMode()\n  const isDark = !!(isDarkMode && isDarkMode.value)\n  const axisLabelColor = isDark ? '#bbbbbb' : '#999999'\n  const axisLineColor = isDark ? '#888888' : '#666666'\n  const splitLineColor = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.1)'\n  const tooltipBg = isDark ? 'rgba(28, 28, 28, 0.9)' : 'rgba(255, 255, 255, 0.95)'\n  const tooltipText = isDark ? '#ffffff' : '#111111'\n\n  if (!props.viewingData?.watch_counts?.tag_distribution) return {}\n  \n  const data = Object.entries(props.viewingData.watch_counts.tag_distribution)\n    .sort((a, b) => b[1] - a[1])\n    .map(([tag, count]) => ({\n      name: tag,\n      value: count\n    }))\n  \n  return {\n    tooltip: {\n      trigger: 'axis',\n      axisPointer: {\n        type: 'shadow'\n      },\n      backgroundColor: tooltipBg,\n      borderColor: '#fb7299',\n      textStyle: { color: tooltipText }\n    },\n    grid: {\n      top: '3%',\n      left: '3%',\n      right: '15%',\n      bottom: '3%',\n      containLabel: true\n    },\n    xAxis: {\n      type: 'value',\n      axisLine: { lineStyle: { color: axisLineColor } },\n      axisLabel: { color: axisLabelColor },\n      splitLine: { lineStyle: { color: splitLineColor } }\n    },\n    yAxis: {\n      type: 'category',\n      data: data.map(item => item.name),\n      axisLine: { lineStyle: { color: axisLineColor } },\n      axisLabel: { color: axisLabelColor },\n      inverse: true\n    },\n    series: [{\n      name: '视频数量',\n      type: 'bar',\n      data: data.map((item, index) => ({\n        value: item.value,\n        itemStyle: {\n          color: new echarts.graphic.LinearGradient(1, 0, 0, 0, [\n            { offset: 0, color: `rgba(251, 114, 153, ${Math.max(0.4, 0.9 - index * 0.05)})` },\n            { offset: 1, color: `rgba(252, 155, 122, ${Math.max(0.4, 0.9 - index * 0.05)})` }\n          ])\n        }\n      })),\n      label: {\n        show: true,\n        position: 'right',\n        color: axisLabelColor,\n        formatter: '{c} 个'\n      }\n    }]\n  }\n})\n\nconst tagCompletionOption = computed(() => {\n  const { isDarkMode } = useDarkMode()\n  const isDark = !!(isDarkMode && isDarkMode.value)\n  const axisLabelColor = isDark ? '#bbbbbb' : '#999999'\n  const axisLineColor = isDark ? '#888888' : '#666666'\n  const splitLineColor = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.1)'\n  const tooltipBg = isDark ? 'rgba(28, 28, 28, 0.9)' : 'rgba(255, 255, 255, 0.95)'\n  const tooltipText = isDark ? '#ffffff' : '#111111'\n\n  if (!props.viewingData?.completion_rates?.tag_completion_rates) return {}\n  \n  const data = Object.entries(props.viewingData.completion_rates.tag_completion_rates)\n    .map(([tag, stats]) => ({\n      tag,\n      completion: stats.average_completion_rate,\n      count: stats.video_count\n    }))\n    .sort((a, b) => b.completion - a.completion)\n    .slice(0, 10)\n  \n  return {\n    tooltip: {\n      trigger: 'axis',\n      axisPointer: { type: 'shadow' },\n      backgroundColor: tooltipBg,\n      borderColor: '#fb7299',\n      textStyle: { color: tooltipText },\n      formatter: (params) => {\n        const data = params[0].data\n        return `${params[0].name}<br/>完成率：${data.value}%<br/>视频数：${data.count}个`\n      }\n    },\n    grid: {\n      top: '3%',\n      left: '3%',\n      right: '15%',\n      bottom: '3%',\n      containLabel: true\n    },\n    xAxis: {\n      type: 'value',\n      name: '完成率',\n      axisLine: { lineStyle: { color: axisLineColor } },\n      axisLabel: {\n        color: axisLabelColor,\n        formatter: '{value}%'\n      },\n      splitLine: { lineStyle: { color: splitLineColor } }\n    },\n    yAxis: {\n      type: 'category',\n      data: data.map(item => `${item.tag}(${item.count}个)`),\n      axisLine: { lineStyle: { color: axisLineColor } },\n      axisLabel: { color: axisLabelColor },\n      inverse: true\n    },\n    series: [{\n      type: 'bar',\n      data: data.map((item, index) => ({\n        value: item.completion,\n        count: item.count,\n        itemStyle: {\n          color: new echarts.graphic.LinearGradient(1, 0, 0, 0, [\n            { offset: 0, color: `rgba(64, 169, 255, ${Math.max(0.4, 0.9 - index * 0.05)})` },\n            { offset: 1, color: `rgba(128, 208, 255, ${Math.max(0.4, 0.9 - index * 0.05)})` }\n          ])\n        }\n      })),\n      label: {\n        show: true,\n        position: 'right',\n        color: axisLabelColor,\n        formatter: '{c}%'\n      }\n    }]\n  }\n})\n\nonMounted(() => {\n  const charts = [distributionChartRef.value, completionChartRef.value]\n  if (charts.every(chart => chart)) {\n    gsap.from(charts.map(chart => chart.$el), {\n      opacity: 0,\n      y: 20,\n      duration: 0.5,\n      stagger: 0.1,\n      ease: 'power2.out',\n      delay: 0.2\n    })\n  }\n})\n\n// 格式化洞察文本，为数字添加颜色\nconst formatInsightText = (text) => {\n  if (!text) return '';\n  return text.replace(/(\\d+(\\.\\d+)?)/g, '<span class=\"text-[#fb7299]\">$1</span>')\n}\n</script> "
  },
  {
    "path": "src/components/tailwind/analytics/pages/TimeAnalysisPage.vue",
    "content": "<!-- 时间分析页组件 -->\n<template>\n  <div class=\"space-y-4 h-screen overflow-hidden\">\n    <h3 class=\"text-2xl font-bold text-center bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent\">\n      观看时间分析\n    </h3>\n\n    <!-- 时间洞察卡片 - 压缩高度 -->\n    <div class=\"text-center text-sm max-w-4xl mx-auto\">\n      <div v-if=\"viewingData?.insights?.daily_record\" class=\"text-gray-600 dark:text-gray-300\" v-html=\"formatInsightText(viewingData.insights.daily_record)\">\n      </div>\n    </div>\n\n    <!-- 时间统计卡片矩阵 - 压缩高度 -->\n    <div class=\"grid grid-cols-4 gap-3\">\n      <div class=\"bg-gradient-to-br from-[#fb7299]/10 to-[#fc9b7a]/10 backdrop-blur-sm rounded-lg p-3 border border-gray-300/30 dark:border-gray-500/30\">\n        <div class=\"flex items-center justify-between\">\n          <div>\n            <p class=\"text-xs text-gray-600 dark:text-gray-400\">单日最长</p>\n            <p class=\"text-lg font-bold text-[#fb7299]\">{{ formatDurationShort(viewingData?.time_investment?.max_duration_day?.total_duration || 0) }} <span class=\"text-xs text-gray-500 font-normal\">{{ formatDate(viewingData?.time_investment?.max_duration_day?.date || '') }}</span></p>\n          </div>\n          <div class=\"w-8 h-8 bg-[#fb7299]/20 rounded-full flex items-center justify-center\">\n            <svg class=\"w-4 h-4 text-[#fb7299]\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n              <path fill-rule=\"evenodd\" d=\"M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z\" clip-rule=\"evenodd\"/>\n            </svg>\n          </div>\n        </div>\n      </div>\n\n      <div class=\"bg-gradient-to-br from-[#fc9b7a]/10 to-[#fb7299]/10 backdrop-blur-sm rounded-lg p-3 border border-gray-300/30 dark:border-gray-500/30\">\n        <div class=\"flex items-center justify-between\">\n          <div>\n            <p class=\"text-xs text-gray-600 dark:text-gray-400\">日均时长</p>\n            <p class=\"text-lg font-bold text-[#fc9b7a]\">{{ formatDurationShort(viewingData?.time_investment?.avg_daily_duration || 0) }}</p>\n          </div>\n          <div class=\"w-8 h-8 bg-[#fc9b7a]/20 rounded-full flex items-center justify-center\">\n            <svg class=\"w-4 h-4 text-[#fc9b7a]\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n              <path fill-rule=\"evenodd\" d=\"M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z\" clip-rule=\"evenodd\"/>\n            </svg>\n          </div>\n        </div>\n      </div>\n\n      <div class=\"bg-gradient-to-br from-[#fb7299]/10 to-[#fc9b7a]/10 backdrop-blur-sm rounded-lg p-3 border border-gray-300/30 dark:border-gray-500/30\">\n        <div class=\"flex items-center justify-between\">\n          <div>\n            <p class=\"text-xs text-gray-600 dark:text-gray-400\">最活跃时段</p>\n            <p class=\"text-lg font-bold text-[#fb7299]\">{{ getPeakTimeSlot() }}</p>\n          </div>\n          <div class=\"w-8 h-8 bg-[#fb7299]/20 rounded-full flex items-center justify-center\">\n            <svg class=\"w-4 h-4 text-[#fb7299]\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n              <path fill-rule=\"evenodd\" d=\"M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z\" clip-rule=\"evenodd\"/>\n            </svg>\n          </div>\n        </div>\n      </div>\n\n      <div class=\"bg-gradient-to-br from-[#fc9b7a]/10 to-[#fb7299]/10 backdrop-blur-sm rounded-lg p-3 border border-gray-300/30 dark:border-gray-500/30\">\n        <div class=\"flex items-center justify-between\">\n          <div>\n            <p class=\"text-xs text-gray-600 dark:text-gray-400\">深夜观看</p>\n            <p class=\"text-lg font-bold text-[#fc9b7a]\">{{ getNightWatchCount() }}次</p>\n          </div>\n          <div class=\"w-8 h-8 bg-[#fc9b7a]/20 rounded-full flex items-center justify-center\">\n            <svg class=\"w-4 h-4 text-[#fc9b7a]\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n              <path d=\"M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z\"/>\n            </svg>\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <!-- 主要图表区域 - 单个柱状图 -->\n    <div class=\"flex-1\">\n      <!-- 24小时观看分布柱状图 -->\n      <div class=\"bg-white/50 dark:bg-white/5 backdrop-blur-sm rounded-xl p-6 border border-gray-300/50 dark:border-gray-500/50\">\n        <h4 class=\"text-xl font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent mb-4 text-center\">24小时观看分布</h4>\n        <div ref=\"barChartRef\" class=\"h-[280px]\"></div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, watch, computed } from 'vue'\nimport * as echarts from 'echarts'\nimport gsap from 'gsap'\nimport { useDarkMode } from '@/store/darkMode.js'\n\nconst props = defineProps({\n  viewingData: {\n    type: Object,\n    required: true\n  },\n  selectedYear: {\n    type: Number,\n    default: () => new Date().getFullYear()\n  }\n})\n\n// 图表引用\nconst barChartRef = ref(null)\n\n// 图表实例\nlet barChart = null\nconst { isDarkMode } = useDarkMode()\n\n// 格式化洞察文本，为数字添加颜色\nconst formatInsightText = (text) => {\n  if (!text) return '';\n  return text.replace(/(\\d+(\\.\\d+)?)/g, '<span class=\"text-[#fb7299]\">$1</span>')\n}\n\nconst formatDuration = (seconds) => {\n  const hours = Math.floor(seconds / 3600)\n  const minutes = Math.floor((seconds % 3600) / 60)\n  return `${hours}小时${minutes}分钟`\n}\n\n// 短格式时长显示\nconst formatDurationShort = (seconds) => {\n  const hours = Math.floor(seconds / 3600)\n  const minutes = Math.floor((seconds % 3600) / 60)\n  if (hours > 0) {\n    return `${hours}h${minutes}m`\n  }\n  return `${minutes}m`\n}\n\nconst formatDate = (dateStr) => {\n  const date = new Date(dateStr)\n  return `${date.getMonth() + 1}月${date.getDate()}日`\n}\n\n// 获取最活跃时段\nconst getPeakTimeSlot = () => {\n  if (!props.viewingData?.daily_time_slots) return '未知'\n  const timeData = props.viewingData.daily_time_slots\n  const maxHour = Object.keys(timeData).reduce((a, b) => timeData[a] > timeData[b] ? a : b)\n  const hour = parseInt(maxHour.replace('时', ''))\n\n  if (hour >= 6 && hour < 12) return '上午'\n  if (hour >= 12 && hour < 18) return '下午'\n  if (hour >= 18 && hour < 24) return '晚上'\n  return '深夜'\n}\n\n// 获取深夜观看次数\nconst getNightWatchCount = () => {\n  if (!props.viewingData?.daily_time_slots) return 0\n  const timeData = props.viewingData.daily_time_slots\n  let nightCount = 0\n\n  Object.keys(timeData).forEach(hour => {\n    const h = parseInt(hour.replace('时', ''))\n    if (h >= 0 && h < 6) {\n      nightCount += timeData[hour]\n    }\n  })\n\n  return nightCount\n}\n\n// 获取时段对应的颜色\nconst getTimeSlotColor = (hour) => {\n  const h = parseInt(hour.replace('时', ''))\n  if (h >= 6 && h < 12) return '#7afc8c' // 上午-绿色\n  if (h >= 12 && h < 18) return '#fc9b7a' // 下午-橙色\n  if (h >= 18 && h < 24) return '#fb7299' // 晚上-粉色\n  return '#7a9efc' // 深夜-蓝色\n}\n\n// 初始化24小时柱状图\nconst initBarChart = () => {\n  if (!barChartRef.value || !props.viewingData?.daily_time_slots) return\n\n  if (barChart) {\n    barChart.dispose()\n  }\n  const isDark = !!(isDarkMode && isDarkMode.value)\n  const axisLineColor = isDark ? '#888888' : '#ddd'\n  const axisLabelColor = isDark ? '#bbbbbb' : '#666'\n  const splitLineColor = isDark ? 'rgba(255, 255, 255, 0.08)' : '#f0f0f0'\n  const tooltipBg = isDark ? 'rgba(28, 28, 28, 0.9)' : 'rgba(255, 255, 255, 0.95)'\n  const tooltipText = isDark ? '#ffffff' : '#111111'\n\n  barChart = echarts.init(barChartRef.value)\n  const timeData = props.viewingData.daily_time_slots\n\n  // 准备24小时数据\n  const hours = []\n  const counts = []\n\n  for (let i = 0; i < 24; i++) {\n    const hour = `${i}时`\n    hours.push(`${i}:00`)\n    counts.push(timeData[hour] || 0)\n  }\n\n  const barOption = {\n    tooltip: {\n      trigger: 'axis',\n      axisPointer: { type: 'shadow' },\n      backgroundColor: tooltipBg,\n      borderColor: '#fb7299',\n      textStyle: { color: tooltipText },\n      formatter: function(params) {\n        const hour = parseInt(params[0].name.split(':')[0])\n        let period = '深夜'\n        if (hour >= 6 && hour < 12) period = '上午'\n        else if (hour >= 12 && hour < 18) period = '下午'\n        else if (hour >= 18 && hour < 24) period = '晚上'\n\n        return `${params[0].name} (${period})<br/>观看次数: ${params[0].value}`\n      }\n    },\n    grid: {\n      left: '3%',\n      right: '4%',\n      bottom: '3%',\n      containLabel: true\n    },\n    xAxis: {\n      type: 'category',\n      data: hours,\n      axisLabel: {\n        color: axisLabelColor,\n        fontSize: 12,\n        interval: 2 // 每隔2小时显示一个标签\n      },\n      axisLine: {\n        lineStyle: {\n          color: axisLineColor\n        }\n      }\n    },\n    yAxis: {\n      type: 'value',\n      axisLabel: {\n        color: axisLabelColor,\n        fontSize: 12\n      },\n      axisLine: {\n        lineStyle: {\n          color: axisLineColor\n        }\n      },\n      splitLine: {\n        lineStyle: {\n          color: splitLineColor\n        }\n      }\n    },\n    series: [{\n      name: '观看次数',\n      type: 'bar',\n      data: counts.map((count, index) => ({\n        value: count,\n        itemStyle: {\n          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [\n            { offset: 0, color: getTimeSlotColor(`${index}时`) },\n            { offset: 1, color: getTimeSlotColor(`${index}时`) + '80' }\n          ])\n        }\n      })),\n      barWidth: '60%',\n      emphasis: {\n        itemStyle: {\n          shadowBlur: 10,\n          shadowOffsetX: 0,\n          shadowOffsetY: 0,\n          shadowColor: 'rgba(0, 0, 0, 0.5)'\n        }\n      }\n    }]\n  }\n\n  barChart.setOption(barOption)\n}\n\n\n\nonMounted(() => {\n  initBarChart()\n\n  // 监听窗口大小变化\n  window.addEventListener('resize', () => {\n    barChart?.resize()\n  })\n})\n\n// 监听数据变化\nwatch(() => props.viewingData, () => {\n  if (props.viewingData) {\n    initBarChart()\n  }\n}, { deep: true })\n\n// 深色模式切换时重绘\nwatch(() => isDarkMode.value, () => {\n  initBarChart()\n})\n</script>\n\n<style>\n/* 流光动画效果 */\n@keyframes flow-light {\n  0% {\n    background-position: 200% 0;\n  }\n  100% {\n    background-position: -200% 0;\n  }\n}\n\n.animate-flow-light {\n  position: relative;\n}\n\n.animate-flow-light::before,\n.animate-flow-light-delay-1::before,\n.animate-flow-light-delay-2::before {\n  content: '';\n  position: absolute;\n  top: 0;\n  left: -100%;\n  width: 200%;\n  height: 100%;\n  background: linear-gradient(\n    90deg,\n    transparent,\n    rgba(255, 255, 255, 0.1),\n    transparent\n  );\n  animation: flow-light 5s infinite linear;\n}\n\n.animate-flow-light-delay-1::before {\n  animation-delay: 1s;\n}\n\n.animate-flow-light-delay-2::before {\n  animation-delay: 2s;\n}\n</style>\n"
  },
  {
    "path": "src/components/tailwind/analytics/pages/TimeDistributionPage.vue",
    "content": "<!-- 时间分布页组件 -->\n<template>\n  <div class=\"space-y-6\">\n    <h3 class=\"text-4xl font-bold text-center bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent\">\n      时间分布分析\n    </h3>\n\n    <div class=\"text-lg text-center text-gray-600 dark:text-gray-300 mb-8 space-y-2\">\n      <div v-if=\"viewingData?.insights?.weekly_pattern\" v-html=\"formatInsightText(viewingData.insights.weekly_pattern)\"></div>\n      <div v-if=\"viewingData?.insights?.seasonal_pattern\" v-html=\"formatInsightText(viewingData.insights.seasonal_pattern)\"></div>\n    </div>\n\n    <!-- 图表容器 -->\n    <div class=\"grid grid-cols-1 lg:grid-cols-2 gap-6\">\n      <!-- 周度分布图表 -->\n      <div class=\"bg-white/50 dark:bg-white/5 backdrop-blur-sm rounded-xl p-6 border border-gray-300/50 dark:border-gray-500/50\">\n        <h4 class=\"text-xl font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent mb-4\">周度分布</h4>\n        <div class=\"h-[220px]\">\n          <v-chart ref=\"weeklyChartRef\" class=\"h-full w-full\" :option=\"weeklyOption\" autoresize />\n        </div>\n      </div>\n\n      <!-- 季节分布图表 -->\n      <div class=\"bg-white/50 dark:bg-white/5 backdrop-blur-sm rounded-xl p-6 border border-gray-300/50 dark:border-gray-500/50\">\n        <h4 class=\"text-xl font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent mb-4\">季节分布</h4>\n        <div class=\"h-[220px]\">\n          <v-chart ref=\"seasonalChartRef\" class=\"h-full w-full\" :option=\"seasonalOption\" autoresize />\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted } from 'vue'\nimport gsap from 'gsap'\nimport VChart from 'vue-echarts'\nimport * as echarts from 'echarts/core'\nimport { useDarkMode } from '@/store/darkMode.js'\n\nconst props = defineProps({\n  viewingData: {\n    type: Object,\n    required: true\n  }\n})\n\nconst weeklyChartRef = ref(null)\nconst seasonalChartRef = ref(null)\nconst { isDarkMode } = useDarkMode()\n\n// 周度分布配置\nconst weeklyOption = computed(() => {\n  if (!props.viewingData?.weekly_stats) return {}\n  \n  const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']\n  const data = weekdays.map(day => props.viewingData.weekly_stats[day] || 0)\n  \n  return {\n    tooltip: {\n      trigger: 'axis',\n      backgroundColor: (isDarkMode && isDarkMode.value) ? 'rgba(28, 28, 28, 0.9)' : 'rgba(255, 255, 255, 0.95)',\n      borderColor: '#fb7299',\n      textStyle: { color: (isDarkMode && isDarkMode.value) ? '#ffffff' : '#111111' }\n    },\n    grid: {\n      top: '15%',\n      left: '3%',\n      right: '4%',\n      bottom: '3%',\n      containLabel: true\n    },\n    xAxis: {\n      type: 'category',\n      data: weekdays,\n      axisLine: { lineStyle: { color: (isDarkMode && isDarkMode.value) ? '#888888' : '#666' } },\n      axisLabel: { color: (isDarkMode && isDarkMode.value) ? '#bbbbbb' : '#999' }\n    },\n    yAxis: {\n      type: 'value',\n      name: '观看次数',\n      axisLine: { lineStyle: { color: (isDarkMode && isDarkMode.value) ? '#888888' : '#666' } },\n      axisLabel: { color: (isDarkMode && isDarkMode.value) ? '#bbbbbb' : '#999' },\n      splitLine: { lineStyle: { color: (isDarkMode && isDarkMode.value) ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.1)' } }\n    },\n    series: [{\n      data: data,\n      type: 'bar',\n      barWidth: '60%',\n      itemStyle: {\n        color: new echarts.graphic.LinearGradient(0, 1, 0, 0, [\n          { offset: 0, color: 'rgba(251, 114, 153, 0.9)' },\n          { offset: 1, color: 'rgba(252, 155, 122, 0.9)' }\n        ]),\n        borderRadius: [8, 8, 0, 0]\n      },\n      label: {\n        show: true,\n        position: 'top',\n        color: (isDarkMode && isDarkMode.value) ? '#cccccc' : '#666',\n        formatter: '{c}次'\n      }\n    }]\n  }\n})\n\n// 季节分布配置\nconst seasonalOption = computed(() => {\n  if (!props.viewingData?.seasonal_patterns) return {}\n  \n  const data = Object.entries(props.viewingData.seasonal_patterns)\n    .map(([season, stats]) => ({\n      name: season,\n      value: stats.view_count,\n      avg_duration: Math.round(stats.avg_duration)\n    }))\n  \n  return {\n    tooltip: {\n      trigger: 'axis',\n      axisPointer: { type: 'shadow' },\n      backgroundColor: (isDarkMode && isDarkMode.value) ? 'rgba(28, 28, 28, 0.9)' : 'rgba(255, 255, 255, 0.95)',\n      borderColor: '#fb7299',\n      textStyle: { color: (isDarkMode && isDarkMode.value) ? '#ffffff' : '#111111' },\n      formatter: (params) => {\n        const data = params[0]\n        return `${data.name}<br/>\n                观看次数：${data.value}<br/>\n                平均时长：${data.data.avg_duration}秒`\n      }\n    },\n    grid: {\n      top: '15%',\n      left: '3%',\n      right: '4%',\n      bottom: '3%',\n      containLabel: true\n    },\n    xAxis: {\n      type: 'category',\n      data: data.map(item => item.name),\n      axisLine: { lineStyle: { color: (isDarkMode && isDarkMode.value) ? '#888888' : '#666' } },\n      axisLabel: { color: (isDarkMode && isDarkMode.value) ? '#bbbbbb' : '#999' }\n    },\n    yAxis: {\n      type: 'value',\n      name: '观看次数',\n      axisLine: { lineStyle: { color: (isDarkMode && isDarkMode.value) ? '#888888' : '#666' } },\n      axisLabel: { color: (isDarkMode && isDarkMode.value) ? '#bbbbbb' : '#999' },\n      splitLine: { lineStyle: { color: (isDarkMode && isDarkMode.value) ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.1)' } }\n    },\n    series: [{\n      data: data.map((item, index) => ({\n        ...item,\n        itemStyle: {\n          color: new echarts.graphic.LinearGradient(0, 1, 0, 0, [\n            { offset: 0, color: `rgba(251, 114, 153, ${Math.max(0.4, 0.9 - index * 0.05)})` },\n            { offset: 1, color: `rgba(252, 155, 122, ${Math.max(0.4, 0.9 - index * 0.05)})` }\n          ])\n        }\n      })),\n      type: 'bar',\n      barWidth: '60%',\n      label: {\n        show: true,\n        position: 'top',\n        color: (isDarkMode && isDarkMode.value) ? '#cccccc' : '#666',\n        formatter: '{c}次'\n      }\n    }]\n  }\n})\n\nonMounted(() => {\n  if (weeklyChartRef.value && seasonalChartRef.value) {\n    gsap.from([\n      weeklyChartRef.value.$el,\n      seasonalChartRef.value.$el\n    ], {\n      opacity: 0,\n      y: 20,\n      duration: 0.5,\n      stagger: 0.1,\n      ease: 'power2.out',\n      delay: 0.2\n    })\n  }\n})\n\n// 格式化洞察文本，为数字添加颜色\nconst formatInsightText = (text) => {\n  if (!text) return '';\n  return text.replace(/(\\d+(\\.\\d+)?)/g, '<span class=\"text-[#fb7299]\">$1</span>')\n}\n</script> "
  },
  {
    "path": "src/components/tailwind/analytics/pages/TitleAnalysisPage.vue",
    "content": "<template>\n  <div class=\"min-h-screen py-8 px-4 sm:px-6 lg:px-8\">\n    <div class=\"max-w-7xl mx-auto\">\n      <!-- 标题和总结部分 -->\n      <div class=\"text-center mb-8\">\n        <h2 class=\"text-3xl font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent\">\n          标题关键词分析\n        </h2>\n        <div class=\"mt-4 text-gray-600 dark:text-gray-300 max-w-3xl mx-auto\" v-if=\"titleAnalytics && titleAnalytics.insights\">\n          <p class=\"mb-2\" v-if=\"titleAnalytics.insights[0]\" v-html=\"formatInsightText(titleAnalytics.insights[0])\"></p>\n          <p class=\"mb-2\" v-if=\"titleAnalytics.insights[1]\" v-html=\"formatInsightText(titleAnalytics.insights[1])\"></p>\n          <p v-if=\"titleAnalytics.insights[2]\" v-html=\"formatInsightText(titleAnalytics.insights[2])\"></p>\n        </div>\n      </div>\n\n      <!-- 主要内容区域 - 使用网格布局 -->\n      <div class=\"grid grid-cols-1 lg:grid-cols-5 gap-6\">\n        <!-- 左侧：词云图 -->\n        <div class=\"lg:col-span-2 bg-white/5 backdrop-blur-sm rounded-xl p-6\">\n          <h3 class=\"text-xl font-semibold text-gray-600 dark:text-gray-300 mb-4\">热门关键词</h3>\n          <div ref=\"wordCloudRef\" class=\"w-full h-[300px]\"></div>\n        </div>\n\n        <!-- 右侧：完成率对比图表 - 占更多空间 -->\n        <div class=\"lg:col-span-3 bg-white/5 backdrop-blur-sm rounded-xl p-6\">\n          <h3 class=\"text-xl font-semibold text-gray-600 dark:text-gray-300 mb-4\">关键词完成率对比</h3>\n          <div ref=\"completionChartRef\" class=\"w-full h-[300px]\"></div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, watch, computed, onUnmounted } from 'vue'\nimport * as echarts from 'echarts/core'\nimport { useDarkMode } from '@/store/darkMode.js'\n\nconst props = defineProps({\n  titleAnalytics: {\n    type: Object,\n    required: true\n  }\n})\n\nconst wordCloudRef = ref(null)\nconst completionChartRef = ref(null)\nlet wordCloudChart = null\nlet completionChart = null\nconst { isDarkMode } = useDarkMode()\n\n// 处理完成率数据\nconst highCompletionRates = computed(() => {\n  if (!props.titleAnalytics?.keyword_analysis?.completion_rates) return {}\n  const rates = Object.entries(props.titleAnalytics.keyword_analysis.completion_rates)\n    .sort(([, a], [, b]) => b.average_completion_rate - a.average_completion_rate)\n    .slice(0, 5)\n  return Object.fromEntries(rates)\n})\n\nconst lowCompletionRates = computed(() => {\n  if (!props.titleAnalytics?.keyword_analysis?.completion_rates) return {}\n  const rates = Object.entries(props.titleAnalytics.keyword_analysis.completion_rates)\n    .sort(([, a], [, b]) => a.average_completion_rate - b.average_completion_rate)\n    .slice(0, 5)\n  return Object.fromEntries(rates)\n})\n// 初始化词云图\nconst initWordCloud = () => {\n  if (!wordCloudRef.value || !props.titleAnalytics?.keyword_analysis?.top_keywords) return\n\n  wordCloudChart = echarts.init(wordCloudRef.value)\n  const wordCloudData = props.titleAnalytics.keyword_analysis.top_keywords.map(item => ({\n    name: item.word,\n    value: item.count,\n    textStyle: {\n      color: `rgb(${Math.random() * 70 + 185}, ${Math.random() * 70 + 185}, ${Math.random() * 70 + 185})`\n    }\n  }))\n\n  const option = {\n    tooltip: {\n      show: true,\n      formatter: function(params) {\n        return `${params.name}: ${params.value}次`\n      },\n      backgroundColor: 'rgba(0, 0, 0, 0.7)',\n      borderColor: 'rgba(251, 114, 153, 0.7)',\n      borderWidth: 1,\n      padding: [5, 10],\n      textStyle: {\n        color: '#e1e1e1',\n        fontSize: 12\n      }\n    },\n    series: [{\n      type: 'wordCloud',\n      shape: 'circle',\n      left: 'center',\n      top: 'center',\n      width: '90%',\n      height: '90%',\n      right: null,\n      bottom: null,\n      sizeRange: [12, 60],\n      rotationRange: [-45, 45],\n      rotationStep: 45,\n      gridSize: 8,\n      drawOutOfBound: false,\n      layoutAnimation: true,\n      textStyle: {\n        fontFamily: 'sans-serif',\n        fontWeight: 'bold'\n      },\n      emphasis: {\n        focus: 'self',\n        textStyle: {\n          shadowBlur: 10,\n          shadowColor: '#333'\n        }\n      },\n      data: wordCloudData\n    }]\n  }\n\n  wordCloudChart.setOption(option)\n}\n\n// 初始化完成率对比图表\nconst initCompletionChart = () => {\n  if (!completionChartRef.value || !props.titleAnalytics?.keyword_analysis?.completion_rates) return\n\n  completionChart = echarts.init(completionChartRef.value)\n\n  const isDark = !!(isDarkMode && isDarkMode.value)\n  const axisLabelColor = isDark ? '#e5e7eb' : '#4B5563'\n  const splitLineColor = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.1)'\n  const tooltipBg = isDark ? 'rgba(28, 28, 28, 0.9)' : 'rgba(255, 255, 255, 0.95)'\n  const tooltipText = isDark ? '#ffffff' : '#111111'\n\n  // 合并所有数据并按完成率排序\n  const allData = Object.entries(props.titleAnalytics.keyword_analysis.completion_rates)\n    .map(([word, data]) => ({\n      word,\n      rate: (data.average_completion_rate * 100).toFixed(1)\n    }))\n    .sort((a, b) => Number(b.rate) - Number(a.rate))\n    .slice(0, 10)\n\n  const option = {\n    tooltip: {\n      trigger: 'axis',\n      axisPointer: {\n        type: 'shadow'\n      },\n      backgroundColor: tooltipBg,\n      borderColor: '#fb7299',\n      textStyle: { color: tooltipText },\n      formatter: function(params) {\n        return `${params[0].name}: ${params[0].value}%`\n      }\n    },\n    grid: {\n      top: '10%',\n      left: '10%',\n      right: '8%',\n      bottom: '15%'\n    },\n    xAxis: {\n      type: 'value',\n      max: function(value) {\n        // 动态设置最大值，确保有足够空间显示完整数值\n        return Math.ceil(value.max * 1.1)\n      },\n      axisLabel: {\n        color: axisLabelColor,\n        formatter: '{value}%'\n      },\n      splitLine: {\n        lineStyle: {\n          color: splitLineColor\n        }\n      }\n    },\n    yAxis: {\n      type: 'category',\n      data: allData.map(item => item.word).reverse(),\n      axisLabel: {\n        color: axisLabelColor\n      }\n    },\n    series: [\n      {\n        name: '完成率',\n        type: 'bar',\n        data: allData.map(item => item.rate).reverse(),\n        itemStyle: {\n          color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [\n            { offset: 0, color: '#fb7299' },\n            { offset: 1, color: '#fc9b7a' }\n          ])\n        },\n        label: {\n          show: true,\n          position: 'right',\n          color: axisLabelColor,\n          formatter: '{c}%'\n        }\n      }\n    ]\n  }\n\n  completionChart.setOption(option)\n}\n\n// 监听窗口大小变化\nconst handleResize = () => {\n  if (wordCloudChart) {\n    wordCloudChart.resize()\n  }\n  if (completionChart) {\n    completionChart.resize()\n  }\n}\n\nonMounted(() => {\n  initWordCloud()\n  initCompletionChart()\n  window.addEventListener('resize', handleResize)\n})\n\nonUnmounted(() => {\n  window.removeEventListener('resize', handleResize)\n  if (wordCloudChart) {\n    wordCloudChart.dispose()\n  }\n  if (completionChart) {\n    completionChart.dispose()\n  }\n})\n\n// 监听数据变化\nwatch(() => props.titleAnalytics, () => {\n  if (wordCloudChart) {\n    initWordCloud()\n  }\n  if (completionChart) {\n    initCompletionChart()\n  }\n}, { deep: true })\n\n// 深色模式切换时重绘图表\nwatch(() => isDarkMode.value, () => {\n  initWordCloud()\n  initCompletionChart()\n})\n\n// 格式化洞察文字，为数字添加颜色\nconst formatInsightText = (text) => {\n  if (!text) return '';\n  return text.replace(/(\\d+(\\.\\d+)?)/g, '<span class=\"text-[#fb7299]\">$1</span>')\n}\n</script>\n"
  },
  {
    "path": "src/components/tailwind/analytics/pages/TitleInteractionAnalysisPage.vue",
    "content": "<template>\n  <div class=\"min-h-screen py-8 px-4 sm:px-6 lg:px-8\">\n    <div class=\"max-w-7xl mx-auto\">\n      <!-- 标题和总结部分 -->\n      <div class=\"text-center mb-8\">\n        <h2 class=\"text-3xl font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent\">\n          标题互动分析\n        </h2>\n        <div class=\"mt-4 text-gray-600 dark:text-gray-300 max-w-3xl mx-auto\" v-if=\"titleAnalytics && titleAnalytics.interaction_analysis\">\n          <p class=\"mb-2\" v-if=\"titleAnalytics.interaction_analysis.insights && titleAnalytics.interaction_analysis.insights[0]\" v-html=\"formatInsightText(titleAnalytics.interaction_analysis.insights[0])\"></p>\n          <p v-if=\"titleAnalytics.interaction_analysis.insights && titleAnalytics.interaction_analysis.insights[1]\" v-html=\"formatInsightText(titleAnalytics.interaction_analysis.insights[1])\"></p>\n        </div>\n      </div>\n\n      <!-- 互动数据分析 -->\n      <div class=\"grid grid-cols-1 lg:grid-cols-2 gap-6\">\n        <!-- 左侧：关键词互动率排名 -->\n        <div class=\"bg-white/5 backdrop-blur-sm rounded-xl p-6\">\n          <h3 class=\"text-xl font-semibold text-gray-600 dark:text-gray-300 mb-4\">关键词互动率排名</h3>\n          <div ref=\"keywordInteractionChartRef\" class=\"w-full h-[360px]\"></div>\n        </div>\n\n        <!-- 右侧：互动类型分布 -->\n        <div class=\"bg-white/5 backdrop-blur-sm rounded-xl p-6\">\n          <h3 class=\"text-xl font-semibold text-gray-600 dark:text-gray-300 mb-4\">互动类型分布</h3>\n          <div ref=\"interactionTypeChartRef\" class=\"w-full h-[360px]\"></div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, watch } from 'vue'\nimport * as echarts from 'echarts'\nimport { useDarkMode } from '@/store/darkMode.js'\n\nconst props = defineProps({\n  titleAnalytics: {\n    type: Object,\n    required: true\n  }\n})\n\nconst keywordInteractionChartRef = ref(null)\nconst interactionTypeChartRef = ref(null)\nlet keywordInteractionChart = null\nlet interactionTypeChart = null\nconst { isDarkMode } = useDarkMode()\n\n// 初始化关键词互动率图表\nconst initKeywordInteractionChart = () => {\n  if (!keywordInteractionChartRef.value || !props.titleAnalytics?.interaction_analysis?.interaction_stats) return\n\n  if (keywordInteractionChart) {\n    keywordInteractionChart.dispose()\n  }\n  keywordInteractionChart = echarts.init(keywordInteractionChartRef.value)\n  const stats = props.titleAnalytics.interaction_analysis.interaction_stats\n  const data = Object.entries(stats)\n    .filter(([key]) => key !== '其他') // 过滤掉\"其他\"类别\n    .map(([type, data]) => ({\n      name: type,\n      value: data.avg_completion_rate,\n      keywords: data.keywords || []\n    }))\n    .sort((a, b) => b.value - a.value)\n\n  const isDark = !!(isDarkMode && isDarkMode.value)\n  const axisLabelColor = isDark ? '#e5e7eb' : '#4B5563'\n  const splitLineColor = isDark ? 'rgba(255, 255, 255, 0.08)' : '#E5E7EB'\n  const tooltipBg = isDark ? 'rgba(28, 28, 28, 0.9)' : 'rgba(255, 255, 255, 0.95)'\n  const tooltipText = isDark ? '#ffffff' : '#111111'\n\n  const option = {\n    tooltip: {\n      trigger: 'axis',\n      axisPointer: { type: 'shadow' },\n      backgroundColor: tooltipBg,\n      borderColor: '#fb7299',\n      textStyle: { color: tooltipText },\n      formatter: (params) => {\n        const [param] = params\n        const keywords = data.find(item => item.name === param.name)?.keywords || []\n        return `${param.name}<br/>\n                互动率: ${(param.value * 100).toFixed(1)}%<br/>\n                关键词: ${keywords.join('、') || '暂无关键词'}`\n      },\n      confine: true\n    },\n    grid: {\n      left: '15%',\n      right: '8%',\n      bottom: '8%',\n      top: '8%',\n      containLabel: true\n    },\n    xAxis: {\n      type: 'value',\n      axisLabel: {\n        formatter: (value) => `${(value * 100).toFixed(1)}%`,\n        color: axisLabelColor\n      },\n      splitLine: {\n        lineStyle: {\n          color: splitLineColor\n        }\n      }\n    },\n    yAxis: {\n      type: 'category',\n      data: data.map(item => item.name),\n      axisLabel: {\n        color: axisLabelColor,\n        width: 80,\n        overflow: 'truncate'\n      }\n    },\n    series: [\n      {\n        name: '互动率',\n        type: 'bar',\n        data: data.map(item => item.value),\n        itemStyle: {\n          color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [\n            { offset: 0, color: '#fb7299' },\n            { offset: 1, color: '#fc9b7a' }\n          ])\n        }\n      }\n    ]\n  }\n\n  keywordInteractionChart.setOption(option)\n}\n\n// 初始化互动类型分布图表\nconst initInteractionTypeChart = () => {\n  if (!interactionTypeChartRef.value || !props.titleAnalytics?.interaction_analysis?.interaction_stats) return\n\n  if (interactionTypeChart) {\n    interactionTypeChart.dispose()\n  }\n  interactionTypeChart = echarts.init(interactionTypeChartRef.value)\n  const stats = props.titleAnalytics.interaction_analysis.interaction_stats\n  const data = Object.entries(stats)\n    .map(([type, data]) => ({\n      name: type,\n      value: data.count,\n      rate: data.avg_completion_rate\n    }))\n    .sort((a, b) => b.value - a.value)\n\n  const isDark = !!(isDarkMode && isDarkMode.value)\n  const axisLabelColor = isDark ? '#e5e7eb' : '#4B5563'\n  const tooltipBg = isDark ? 'rgba(28, 28, 28, 0.9)' : 'rgba(255, 255, 255, 0.95)'\n  const tooltipText = isDark ? '#ffffff' : '#111111'\n\n  const option = {\n    tooltip: {\n      trigger: 'item',\n      backgroundColor: tooltipBg,\n      borderColor: '#fb7299',\n      textStyle: { color: tooltipText },\n      formatter: (params) => {\n        return `${params.name}<br/>\n                数量: ${params.value}个视频<br/>\n                平均完成率: ${(data.find(item => item.name === params.name)?.rate * 100).toFixed(1)}%`\n      },\n      confine: true\n    },\n    legend: {\n      orient: 'vertical',\n      right: '5%',\n      top: 'center',\n      textStyle: {\n        color: axisLabelColor\n      }\n    },\n    grid: {\n      left: '10%',\n      containLabel: true\n    },\n    series: [\n      {\n        type: 'pie',\n        radius: ['30%', '60%'],\n        center: ['45%', '50%'],\n        avoidLabelOverlap: false,\n        itemStyle: {\n          borderRadius: 10\n        },\n        label: {\n          show: false\n        },\n        emphasis: {\n          label: {\n            show: true,\n            formatter: '{b}\\n{d}%',\n            fontSize: '14',\n            fontWeight: 'bold'\n          }\n        },\n        data: data\n      }\n    ]\n  }\n\n  interactionTypeChart.setOption(option)\n}\n\n// 监听窗口大小变化\nconst handleResize = () => {\n  keywordInteractionChart?.resize()\n  interactionTypeChart?.resize()\n}\n\nonMounted(() => {\n  initKeywordInteractionChart()\n  initInteractionTypeChart()\n  window.addEventListener('resize', handleResize)\n})\n\n// 监听数据变化\nwatch(\n  () => props.titleAnalytics,\n  () => {\n    initKeywordInteractionChart()\n    initInteractionTypeChart()\n  },\n  { deep: true }\n)\n\n// 深色模式切换时重绘图表\nwatch(() => isDarkMode.value, () => {\n  initKeywordInteractionChart()\n  initInteractionTypeChart()\n})\n\n// 格式化洞察文本，为数字添加颜色\nconst formatInsightText = (text) => {\n  if (!text) return '';\n  return text.replace(/(\\d+(\\.\\d+)?)/g, '<span class=\"text-[#fb7299]\">$1</span>')\n}\n</script>"
  },
  {
    "path": "src/components/tailwind/analytics/pages/TitleLengthAnalysisPage.vue",
    "content": "<template>\n  <div class=\"min-h-screen py-8 px-4 sm:px-6 lg:px-8\">\n    <div class=\"max-w-7xl mx-auto\">\n      <!-- 标题和总结部分 -->\n      <div class=\"text-center mb-8\">\n        <h2 class=\"text-3xl font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent\">\n          标题长度分析\n        </h2>\n        <div class=\"mt-4 text-gray-600 dark:text-gray-300 max-w-3xl mx-auto\" v-if=\"titleAnalytics && titleAnalytics.length_analysis\">\n          <p class=\"mb-2\" v-if=\"titleAnalytics.length_analysis.insights && titleAnalytics.length_analysis.insights[0]\" v-html=\"formatInsightText(titleAnalytics.length_analysis.insights[0])\"></p>\n          <p v-if=\"titleAnalytics.length_analysis.insights && titleAnalytics.length_analysis.insights[1]\" v-html=\"formatInsightText(titleAnalytics.length_analysis.insights[1])\"></p>\n        </div>\n      </div>\n\n      <!-- 主要内容区域 - 使用网格布局 -->\n      <div class=\"grid grid-cols-1 lg:grid-cols-2 gap-6\">\n        <!-- 左侧：标题长度分布图 -->\n        <div class=\"bg-white/5 backdrop-blur-sm rounded-xl p-6\">\n          <h3 class=\"text-xl font-semibold text-gray-600 dark:text-gray-300 mb-4\">标题长度分布</h3>\n          <div ref=\"distributionChartRef\" class=\"w-full h-[300px]\"></div>\n        </div>\n\n        <!-- 右侧：完成率分析图 -->\n        <div class=\"bg-white/5 backdrop-blur-sm rounded-xl p-6\">\n          <h3 class=\"text-xl font-semibold text-gray-600 dark:text-gray-300 mb-4\">标题长度与完成率关系</h3>\n          <div ref=\"completionChartRef\" class=\"w-full h-[300px]\"></div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, watch, onUnmounted } from 'vue'\nimport * as echarts from 'echarts/core'\nimport { useDarkMode } from '@/store/darkMode.js'\n\nconst props = defineProps({\n  titleAnalytics: {\n    type: Object,\n    required: true\n  }\n})\n\nconst distributionChartRef = ref(null)\nconst completionChartRef = ref(null)\nlet distributionChart = null\nlet completionChart = null\nconst { isDarkMode } = useDarkMode()\n\n// 初始化标题长度分布图\nconst initDistributionChart = () => {\n  if (!distributionChartRef.value || !props.titleAnalytics?.length_analysis?.length_stats) return\n\n  if (distributionChart) {\n    distributionChart.dispose()\n  }\n  distributionChart = echarts.init(distributionChartRef.value)\n\n  const lengthStats = props.titleAnalytics.length_analysis.length_stats\n  const data = Object.entries(lengthStats)\n    .sort(([a], [b]) => {\n      const numA = parseInt(a.split('-')[0])\n      const numB = parseInt(b.split('-')[0])\n      return numA - numB\n    })\n    .map(([range, stats]) => ({\n      name: range,\n      value: stats.count\n    }))\n\n  const isDark = !!(isDarkMode && isDarkMode.value)\n  const axisLabelColor = isDark ? '#e5e7eb' : '#4B5563'\n  const splitLineColor = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.1)'\n  const tooltipBg = isDark ? 'rgba(28, 28, 28, 0.9)' : 'rgba(255, 255, 255, 0.95)'\n  const tooltipTextColor = isDark ? '#ffffff' : '#111111'\n\n  const option = {\n    tooltip: {\n      trigger: 'axis',\n      backgroundColor: tooltipBg,\n      borderColor: '#fb7299',\n      textStyle: { color: tooltipTextColor },\n      formatter: '{b}: {c}个视频'\n    },\n    xAxis: {\n      type: 'category',\n      data: data.map(item => item.name),\n      axisLabel: {\n        color: axisLabelColor,\n        interval: 1,\n        rotate: 45\n      }\n    },\n    yAxis: {\n      type: 'value',\n      name: '视频数量',\n      axisLabel: {\n        color: axisLabelColor\n      },\n      splitLine: { lineStyle: { color: splitLineColor } }\n    },\n    series: [\n      {\n        data: data.map(item => item.value),\n        type: 'bar',\n        itemStyle: {\n          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [\n            { offset: 0, color: '#fb7299' },\n            { offset: 1, color: '#fc9b7a' }\n          ])\n        }\n      }\n    ]\n  }\n\n  distributionChart.setOption(option)\n}\n\n// 初始化完成率分析图\nconst initCompletionChart = () => {\n  if (!completionChartRef.value || !props.titleAnalytics?.length_analysis?.length_stats) return\n\n  if (completionChart) {\n    completionChart.dispose()\n  }\n  completionChart = echarts.init(completionChartRef.value)\n\n  const lengthStats = props.titleAnalytics.length_analysis.length_stats\n  const data = Object.entries(lengthStats)\n    .sort(([a], [b]) => {\n      const numA = parseInt(a.split('-')[0])\n      const numB = parseInt(b.split('-')[0])\n      return numA - numB\n    })\n    .map(([range, stats]) => ({\n      name: range,\n      value: (stats.avg_completion_rate * 100).toFixed(1)\n    }))\n\n  const isDark = !!(isDarkMode && isDarkMode.value)\n  const axisLabelColor = isDark ? '#e5e7eb' : '#4B5563'\n  const splitLineColor = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.1)'\n  const tooltipBg = isDark ? 'rgba(28, 28, 28, 0.9)' : 'rgba(255, 255, 255, 0.95)'\n  const tooltipTextColor = isDark ? '#ffffff' : '#111111'\n\n  const option = {\n    tooltip: {\n      trigger: 'axis',\n      backgroundColor: tooltipBg,\n      borderColor: '#fb7299',\n      textStyle: { color: tooltipTextColor },\n      formatter: '{b}: {c}%'\n    },\n    xAxis: {\n      type: 'category',\n      data: data.map(item => item.name),\n      axisLabel: {\n        color: axisLabelColor,\n        interval: 1,\n        rotate: 45\n      }\n    },\n    yAxis: {\n      type: 'value',\n      name: '平均完成率',\n      axisLabel: {\n        color: axisLabelColor,\n        formatter: '{value}%'\n      },\n      splitLine: { lineStyle: { color: splitLineColor } }\n    },\n    series: [\n      {\n        data: data.map(item => item.value),\n        type: 'line',\n        smooth: true,\n        symbolSize: 8,\n        lineStyle: {\n          width: 3,\n          color: '#fb7299'\n        },\n        itemStyle: {\n          color: '#fb7299'\n        },\n        areaStyle: {\n          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [\n            { offset: 0, color: 'rgba(251, 114, 153, 0.3)' },\n            { offset: 1, color: 'rgba(252, 155, 122, 0.1)' }\n          ])\n        }\n      }\n    ]\n  }\n\n  completionChart.setOption(option)\n}\n\n// 监听窗口大小变化\nconst handleResize = () => {\n  if (distributionChart) {\n    distributionChart.resize()\n  }\n  if (completionChart) {\n    completionChart.resize()\n  }\n}\n\nonMounted(() => {\n  initDistributionChart()\n  initCompletionChart()\n  window.addEventListener('resize', handleResize)\n})\n\nonUnmounted(() => {\n  window.removeEventListener('resize', handleResize)\n  if (distributionChart) {\n    distributionChart.dispose()\n  }\n  if (completionChart) {\n    completionChart.dispose()\n  }\n})\n\n// 监听数据变化\nwatch(() => props.titleAnalytics, () => {\n  if (distributionChart) {\n    initDistributionChart()\n  }\n  if (completionChart) {\n    initCompletionChart()\n  }\n}, { deep: true })\n\nwatch(() => isDarkMode.value, () => {\n  initDistributionChart()\n  initCompletionChart()\n})\n\n// 格式化洞察文本，为数字添加颜色\nconst formatInsightText = (text) => {\n  if (!text) return '';\n  return text.replace(/(\\d+(\\.\\d+)?)/g, '<span class=\"text-[#fb7299]\">$1</span>')\n}\n</script>"
  },
  {
    "path": "src/components/tailwind/analytics/pages/TitleSentimentAnalysisPage.vue",
    "content": "<template>\n  <div class=\"min-h-screen py-8 px-4 sm:px-6 lg:px-8\">\n    <div class=\"max-w-7xl mx-auto\">\n      <!-- 标题和总结部分 -->\n      <div class=\"text-center mb-8\">\n        <h2 class=\"text-3xl font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent\">\n          标题情感分析\n        </h2>\n        <div class=\"mt-4 text-gray-600 dark:text-gray-300 max-w-3xl mx-auto\" v-if=\"titleAnalytics && titleAnalytics.sentiment_analysis\">\n          <p class=\"mb-2\" v-if=\"titleAnalytics.sentiment_analysis.insights && titleAnalytics.sentiment_analysis.insights[0]\" v-html=\"formatInsightText(titleAnalytics.sentiment_analysis.insights[0])\"></p>\n          <p v-if=\"titleAnalytics.sentiment_analysis.insights && titleAnalytics.sentiment_analysis.insights[1]\" v-html=\"formatInsightText(titleAnalytics.sentiment_analysis.insights[1])\"></p>\n        </div>\n      </div>\n\n      <!-- 主要内容区域 - 使用网格布局 -->\n      <div class=\"grid grid-cols-1 lg:grid-cols-2 gap-6\">\n        <!-- 左侧：情感分布饼图 -->\n        <div class=\"bg-white/5 backdrop-blur-sm rounded-xl p-6\">\n          <h3 class=\"text-xl font-semibold text-gray-600 dark:text-gray-300 mb-4\">情感分布</h3>\n          <div ref=\"distributionChartRef\" class=\"w-full h-[300px]\"></div>\n        </div>\n\n        <!-- 右侧：完成率对比图 -->\n        <div class=\"bg-white/5 backdrop-blur-sm rounded-xl p-6\">\n          <h3 class=\"text-xl font-semibold text-gray-600 dark:text-gray-300 mb-4\">情感与完成率关系</h3>\n          <div ref=\"completionChartRef\" class=\"w-full h-[300px]\"></div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, watch, onUnmounted } from 'vue'\nimport * as echarts from 'echarts/core'\nimport { useDarkMode } from '@/store/darkMode.js'\n\nconst props = defineProps({\n  titleAnalytics: {\n    type: Object,\n    required: true\n  }\n})\n\nconst distributionChartRef = ref(null)\nconst completionChartRef = ref(null)\nlet distributionChart = null\nlet completionChart = null\nconst { isDarkMode } = useDarkMode()\n\n// 初始化情感分布饼图\nconst initDistributionChart = () => {\n  if (!distributionChartRef.value || !props.titleAnalytics?.sentiment_analysis?.sentiment_stats) return\n\n  if (distributionChart) {\n    distributionChart.dispose()\n  }\n  distributionChart = echarts.init(distributionChartRef.value)\n\n  const sentimentStats = props.titleAnalytics.sentiment_analysis.sentiment_stats\n  const data = Object.entries(sentimentStats).map(([sentiment, stats]) => ({\n    name: sentiment,\n    value: stats.count\n  }))\n\n  const isDark = !!(isDarkMode && isDarkMode.value)\n  const axisLabelColor = isDark ? '#e5e7eb' : '#4B5563'\n  const legendTextColor = axisLabelColor\n  const tooltipBg = isDark ? 'rgba(28, 28, 28, 0.9)' : 'rgba(255, 255, 255, 0.95)'\n  const tooltipText = isDark ? '#ffffff' : '#111111'\n\n  const option = {\n    tooltip: {\n      trigger: 'item',\n      formatter: '{b}: {c}个视频 ({d}%)',\n      backgroundColor: tooltipBg,\n      borderColor: '#fb7299',\n      textStyle: { color: tooltipText }\n    },\n    legend: {\n      orient: 'vertical',\n      right: 10,\n      top: 'center',\n      textStyle: {\n        color: legendTextColor\n      }\n    },\n    series: [\n      {\n        type: 'pie',\n        radius: ['40%', '70%'],\n        avoidLabelOverlap: false,\n        itemStyle: {\n          borderRadius: 10\n        },\n        label: {\n          show: false,\n          position: 'center'\n        },\n        emphasis: {\n          label: {\n            show: true,\n            fontSize: 20,\n            fontWeight: 'bold',\n            color: axisLabelColor\n          }\n        },\n        labelLine: {\n          show: false\n        },\n        data: data.map(item => ({\n          ...item,\n          itemStyle: {\n            color: item.name === '积极' ? '#fb7299' : \n                  item.name === '消极' ? '#fc9b7a' : \n                  '#9ca3af'\n          }\n        }))\n      }\n    ]\n  }\n\n  distributionChart.setOption(option)\n}\n\n// 初始化完成率对比图\nconst initCompletionChart = () => {\n  if (!completionChartRef.value || !props.titleAnalytics?.sentiment_analysis?.sentiment_stats) return\n\n  if (completionChart) {\n    completionChart.dispose()\n  }\n  completionChart = echarts.init(completionChartRef.value)\n\n  const sentimentStats = props.titleAnalytics.sentiment_analysis.sentiment_stats\n  const data = Object.entries(sentimentStats)\n    .sort(([, a], [, b]) => b.avg_completion_rate - a.avg_completion_rate)\n    .map(([sentiment, stats]) => ({\n      name: sentiment,\n      value: (stats.avg_completion_rate * 100).toFixed(1)\n    }))\n\n  const isDark = !!(isDarkMode && isDarkMode.value)\n  const axisLabelColor = isDark ? '#e5e7eb' : '#4B5563'\n  const tooltipBg = isDark ? 'rgba(28, 28, 28, 0.9)' : 'rgba(255, 255, 255, 0.95)'\n  const tooltipText = isDark ? '#ffffff' : '#111111'\n\n  const option = {\n    tooltip: {\n      trigger: 'axis',\n      backgroundColor: tooltipBg,\n      borderColor: '#fb7299',\n      textStyle: { color: tooltipText },\n      formatter: '{b}: {c}%'\n    },\n    grid: {\n      top: '10%',\n      left: '15%',\n      right: '5%',\n      bottom: '15%'\n    },\n    xAxis: {\n      type: 'category',\n      data: data.map(item => item.name),\n      axisLabel: {\n        color: axisLabelColor\n      }\n    },\n    yAxis: {\n      type: 'value',\n      name: '平均完成率',\n      axisLabel: {\n        color: axisLabelColor,\n        formatter: '{value}%'\n      }\n    },\n    series: [\n      {\n        data: data.map(item => ({\n          value: item.value,\n          itemStyle: {\n            color: item.name === '积极' ? '#fb7299' : \n                  item.name === '消极' ? '#fc9b7a' : \n                  '#9ca3af'\n          }\n        })),\n        type: 'bar',\n        barWidth: '40%',\n        label: {\n          show: true,\n          position: 'top',\n          color: axisLabelColor,\n          formatter: '{c}%'\n        }\n      }\n    ]\n  }\n\n  completionChart.setOption(option)\n}\n\n// 监听窗口大小变化\nconst handleResize = () => {\n  if (distributionChart) {\n    distributionChart.resize()\n  }\n  if (completionChart) {\n    completionChart.resize()\n  }\n}\n\nonMounted(() => {\n  initDistributionChart()\n  initCompletionChart()\n  window.addEventListener('resize', handleResize)\n})\n\nonUnmounted(() => {\n  window.removeEventListener('resize', handleResize)\n  if (distributionChart) {\n    distributionChart.dispose()\n  }\n  if (completionChart) {\n    completionChart.dispose()\n  }\n})\n\n// 监听数据变化\nwatch(() => props.titleAnalytics, () => {\n  if (distributionChart) {\n    initDistributionChart()\n  }\n  if (completionChart) {\n    initCompletionChart()\n  }\n}, { deep: true })\n\n// 深色模式切换时重绘图表\nwatch(() => isDarkMode.value, () => {\n  initDistributionChart()\n  initCompletionChart()\n})\n\n// 格式化洞察文本，为数字添加颜色\nconst formatInsightText = (text) => {\n  if (!text) return '';\n  return text.replace(/(\\d+(\\.\\d+)?)/g, '<span class=\"text-[#fb7299]\">$1</span>')\n}\n</script>"
  },
  {
    "path": "src/components/tailwind/analytics/pages/TitleTrendAnalysisPage.vue",
    "content": "<template>\n  <div class=\"min-h-screen py-4 px-1 sm:px-2 lg:px-3\">\n    <div class=\"max-w-full mx-auto\">\n      <!-- 标题和总结部分 -->\n      <div class=\"text-center mb-4\">\n        <h2 class=\"text-3xl font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent\">\n          标题趋势分析\n        </h2>\n        <div class=\"mt-3 text-gray-600 dark:text-gray-300 max-w-3xl mx-auto\" v-if=\"titleAnalytics && titleAnalytics.trend_analysis\">\n          <p class=\"mb-2\" v-if=\"titleAnalytics.trend_analysis.insights && titleAnalytics.trend_analysis.insights[0]\" v-html=\"formatInsightText(titleAnalytics.trend_analysis.insights[0])\"></p>\n          <p v-if=\"titleAnalytics.trend_analysis.insights && titleAnalytics.trend_analysis.insights[1]\" v-html=\"formatInsightText(titleAnalytics.trend_analysis.insights[1])\"></p>\n        </div>\n      </div>\n\n      <!-- 月度热门关键词 -->\n      <div class=\"rounded-xl p-2\">\n        <div class=\"grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-2\">\n          <div v-for=\"(trends, month) in getSortedMonthlyTrends()\" \n               :key=\"month\" \n               class=\"rounded-xl p-3\">\n            <h4 class=\"font-medium text-gray-600 dark:text-gray-300 mb-2\">{{ formatMonth(month) }}</h4>\n            <div class=\"space-y-1.5\">\n              <div v-for=\"([keyword, count], index) in trends.top_keywords.slice(0, 5)\" \n                   :key=\"keyword\"\n                   class=\"flex items-center justify-between text-sm\">\n                <div class=\"flex items-center space-x-2\">\n                  <div class=\"w-1.5 h-1.5 rounded-full\" :style=\"{ backgroundColor: colors[index % colors.length] }\"></div>\n                  <span class=\"text-gray-600 dark:text-gray-300\">{{ keyword }}</span>\n                </div>\n                <span class=\"text-gray-600 dark:text-gray-400 text-xs\">{{ count }}次</span>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { computed } from 'vue'\n\nconst props = defineProps({\n  titleAnalytics: {\n    type: Object,\n    required: true\n  }\n})\n\n// 颜色数组\nconst colors = [\n  '#fb7299', // 粉色\n  '#fc9b7a', // 橙色\n  '#7a9efc', // 蓝色\n  '#7afc8c', // 绿色\n  '#fc7ab3'  // 粉紫色\n]\n\n// 获取排序后的月度趋势数据\nconst getSortedMonthlyTrends = () => {\n  if (!props.titleAnalytics?.trend_analysis?.monthly_trends) return {}\n  const trends = props.titleAnalytics.trend_analysis.monthly_trends\n  return Object.fromEntries(\n    Object.entries(trends)\n      .sort(([a], [b]) => a.localeCompare(b))\n  )\n}\n\n// 格式化月份显示\nconst formatMonth = (month) => {\n  return month.replace('-', '年') + '月'\n}\n\n// 格式化洞察文本，为数字添加颜色\nconst formatInsightText = (text) => {\n  if (!text) return '';\n  return text.replace(/(\\d+(\\.\\d+)?)/g, '<span class=\"text-[#fb7299]\">$1</span>')\n}\n</script> "
  },
  {
    "path": "src/components/tailwind/dynamic/DynamicCardNormal.vue",
    "content": "<template>\n  <div class=\"border rounded-lg bg-white overflow-hidden\">\n    <!-- 头部：头像 + 名称 + 时间 + 动态链接 -->\n    <div class=\"flex items-center px-3 py-2\">\n      <img v-if=\"faceUrl\" :src=\"faceUrl\" class=\"w-6 h-6 rounded-full object-cover border\" alt=\"face\" />\n      <div class=\"ml-2 min-w-0\">\n        <div class=\"text-sm font-medium truncate\">{{ item.author_name || `UID ${item.host_mid || ''}` }}</div>\n        <div class=\"text-[11px] text-gray-500 truncate\">{{ formattedTime }}</div>\n      </div>\n      <button\n        v-if=\"item.id_str\"\n        type=\"button\"\n        class=\"ml-auto text-[11px] text-[#fb7299] hover:underline\"\n        @click=\"openLink(opusUrl)\"\n      >查看动态</button>\n    </div>\n\n    <!-- 主体：配文/标题内容 + 图片/实况九宫格（如有） -->\n    <div class=\"px-3 pb-3\">\n      <!-- DYNAMIC_TYPE_DRAW: 展示 opus 标题与摘要 -->\n      <template v-if=\"isDraw\">\n        <div\n          class=\"text-sm font-semibold text-gray-900 leading-6\"\n          v-if=\"drawTitle\"\n        >{{ drawTitle }}</div>\n        <div\n          class=\"mt-1 text-sm text-gray-700 leading-6\"\n          v-if=\"drawSummary\"\n        >\n          <span class=\"whitespace-pre-wrap\">\n            <template v-for=\"(seg, i) in parsedSummary\" :key=\"i\">\n              <span v-if=\"seg.type==='text'\">{{ seg.text }}</span>\n              <img\n                v-else\n                :src=\"seg.url\"\n                :alt=\"seg.name\"\n                class=\"emoji emoji-lg inline-block align-text-bottom cursor-zoom-in hover:opacity-90 transition\"\n                role=\"button\"\n                tabindex=\"0\"\n                title=\"Click to preview\"\n                @click.stop=\"openPreview('image', seg.url)\"\n                @keydown.enter.stop=\"openPreview('image', seg.url)\"\n              />\n            </template>\n          </span>\n        </div>\n      </template>\n      <!-- 其他类型：展示 txt（解析表情） -->\n      <div\n        v-else-if=\"item.txt\"\n        role=\"link\"\n        tabindex=\"0\"\n        @click=\"openLink(opusUrl)\"\n        @keydown.enter=\"openLink(opusUrl)\"\n        class=\"text-sm text-gray-800 leading-6 hover:underline cursor-pointer\"\n      >\n        <span class=\"whitespace-pre-wrap\">\n          <template v-for=\"(seg, i) in parsedTxt\" :key=\"'t'+i\">\n            <span v-if=\"seg.type==='text'\">{{ seg.text }}</span>\n            <img\n              v-else\n              :src=\"seg.url\"\n              :alt=\"seg.name\"\n              class=\"emoji emoji-lg inline-block align-text-bottom cursor-zoom-in hover:opacity-90 transition\"\n              role=\"button\"\n              tabindex=\"0\"\n              title=\"Click to preview\"\n              @click.stop=\"openPreview('image', seg.url)\"\n              @keydown.enter.stop=\"openPreview('image', seg.url)\"\n            />\n          </template>\n        </span>\n      </div>\n\n      <div v-if=\"displayMedias.length\" class=\"mt-2 grid gap-1 md:gap-2 grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6\">\n        <template v-for=\"(m, idx) in displayMedias\" :key=\"idx\">\n          <!-- 普通图片 -->\n          <div v-if=\"m.kind==='image'\" class=\"relative rounded-md overflow-hidden hover:opacity-90 cursor-pointer h-28 sm:h-32 md:h-36\" role=\"button\" tabindex=\"0\" @click=\"openPreview('image', m.url)\" @keydown.enter=\"openPreview('image', m.url)\">\n            <img :src=\"m.url\" class=\"block w-full h-full object-cover\" />\n          </div>\n          <!-- 实况照片（悬停播放） -->\n          <div v-else class=\"relative rounded-md overflow-hidden hover:opacity-90 cursor-pointer h-28 sm:h-32 md:h-36\"\n               role=\"button\" tabindex=\"0\"\n               @mouseenter=\"handleLiveEnter(idx)\" @mouseleave=\"handleLiveLeave(idx)\"\n               @click=\"openPreview('video', m.videoUrl, m.coverUrl)\" @keydown.enter=\"openPreview('video', m.videoUrl, m.coverUrl)\">\n            <video :poster=\"m.coverUrl\" :src=\"m.videoUrl\" muted playsinline loop\n                   class=\"absolute inset-0 w-full h-full object-cover\"\n                   :ref=\"el => setLiveRef(idx, el)\"\n            ></video>\n            <!-- 右下角 实况 徽标 -->\n            <div class=\"absolute bottom-1 right-1 px-1.5 py-0.5 bg-black/60 text-white text-[10px] flex items-center rounded\">\n              <img src=\"/live.svg\" class=\"w-3 h-3 mr-1 filter invert\" alt=\"live\" />\n              <span>实况</span>\n            </div>\n          </div>\n        </template>\n      </div>\n    </div>\n\n    <!-- 预览弹层 -->\n    <Teleport to=\"body\">\n      <div v-if=\"showPreview\" class=\"fixed inset-0 z-50 bg-black/80 flex items-center justify-center\" @click=\"closePreview\">\n        <div class=\"max-w-[95vw] max-h-[90vh] relative\" @click.stop>\n          <img v-if=\"previewType==='image'\" :src=\"previewSrc\" class=\"max-w-[95vw] max-h-[90vh] object-contain rounded-md\" />\n          <video v-else :src=\"previewSrc\" :poster=\"previewPoster\" controls autoplay loop muted class=\"max-w-[95vw] max-h-[90vh] rounded-md\"></video>\n          <button class=\"absolute -top-3 -right-3 w-8 h-8 rounded-full bg-black/70 text-white flex items-center justify-center\" @click=\"closePreview\">×</button>\n        </div>\n      </div>\n    </Teleport>\n  </div>\n</template>\n\n<script setup>\nimport { computed, ref } from 'vue'\nimport { openInBrowser } from '@/utils/openUrl'\nimport { toStaticUrl } from '@/utils/imageUrl'\n\nconst props = defineProps({\n  item: { type: Object, required: true },\n  faceUrl: { type: String, default: '' }\n})\n\n// 是否图文动态\nconst isDraw = computed(() => String(props.item?.type || '') === 'DYNAMIC_TYPE_DRAW')\nconst drawTitle = computed(() => props.item?.opus_title || '')\nconst drawSummary = computed(() => props.item?.opus_summary_text || '')\n\n// 归一化扩展名检查\nconst isVideoPath = (p) => /\\.mp4$/i.test(String(p || '').replace(/\\\\/g, '/'))\nconst isImagePath = (p) => /\\.(png|jpe?g|gif|webp)$/i.test(String(p || '').replace(/\\\\/g, '/'))\nconst getNameFromPath = (p) => {\n  const filename = String(p || '').split(/[/\\\\]/).pop() || ''\n  const decoded = decodeURIComponent(filename)\n  return decoded.replace(/\\.[^.]+$/, '')\n}\n\n// 从摘要中提取 [xxx] 表情名集合\nconst extractEmojiNames = (text) => {\n  const set = new Set()\n  if (!text) return set\n  const re = /\\[([^\\[\\]]+?)\\]/g\n  let m\n  while ((m = re.exec(text)) !== null) {\n    set.add(m[1])\n  }\n  return set\n}\n\nconst emojiNamesFromSummary = computed(() => extractEmojiNames(drawSummary.value))\nconst emojiNamesFromTxt = computed(() => extractEmojiNames(props.item?.txt || ''))\nconst allEmojiNames = computed(() => new Set([...emojiNamesFromSummary.value, ...emojiNamesFromTxt.value]))\n\n// 构建 emoji 名称到图片URL的映射（仅使用摘要中出现过的表情名）\nconst emojiMap = computed(() => {\n  const map = {}\n  const ml = Array.isArray(props.item?.media_locals) ? props.item.media_locals : []\n  for (const p of ml) {\n    if (!isImagePath(p)) continue\n    const name = getNameFromPath(p)\n    if (/^live_/i.test(name)) continue\n    if (allEmojiNames.value.has(name)) {\n      map[name] = toStaticUrl(p)\n    }\n  }\n  return map\n})\n\n// 将摘要解析为文本/emoji 片段\nconst parseWithEmoji = (text) => {\n  const map = emojiMap.value || {}\n  if (!text) return []\n  const result = []\n  const re = /\\[([^\\[\\]]+?)\\]/g\n  let last = 0\n  let m\n  while ((m = re.exec(text)) !== null) {\n    const idx = m.index\n    const raw = m[0]\n    const name = m[1]\n    if (idx > last) {\n      result.push({ type: 'text', text: text.slice(last, idx) })\n    }\n    if (map[name]) {\n      result.push({ type: 'emoji', name, url: map[name] })\n    } else {\n      result.push({ type: 'text', text: raw })\n    }\n    last = idx + raw.length\n  }\n  if (last < text.length) {\n    result.push({ type: 'text', text: text.slice(last) })\n  }\n  return result\n}\n\nconst parsedSummary = computed(() => {\n  const text = drawSummary.value || ''\n  return parseWithEmoji(text)\n})\n\nconst parsedTxt = computed(() => parseWithEmoji(props.item?.txt || ''))\n\n// 构造展示媒体：普通图片 + 实况照片（由 live_media_locals 配对 png+mp4）\nconst displayMedias = computed(() => {\n  const medias = []\n  const ml = Array.isArray(props.item?.media_locals) ? props.item.media_locals : []\n  for (const p of ml) {\n    if (isImagePath(p) && !/live_/i.test(p)) {\n      const name = getNameFromPath(p)\n      // 过滤掉作为emoji使用的图片\n      if (!allEmojiNames.value.has(name)) {\n        medias.push({ kind: 'image', url: toStaticUrl(p) })\n      }\n    }\n  }\n\n  const live = Array.isArray(props.item?.live_media_locals) ? props.item.live_media_locals : []\n  if (live.length) {\n    const covers = live.filter(isImagePath)\n    const videos = live.filter(isVideoPath)\n    const n = Math.min(covers.length, videos.length, 9)\n    for (let i = 0; i < n; i++) {\n      medias.push({ kind: 'live', coverUrl: toStaticUrl(covers[i]), videoUrl: toStaticUrl(videos[i]) })\n    }\n  }\n\n  return medias.slice(0, 12) // 限制单条最多展示若干项\n})\n\n// 网格固定为每行最多6列（随断点 3/4/5/6 列）\n\nconst formattedTime = computed(() => {\n  const ts = props.item?.publish_ts\n  if (!ts) return '-'\n  try {\n    const d = new Date(ts * 1000)\n    const pad = (n) => String(n).padStart(2, '0')\n    return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`\n  } catch {\n    return String(ts)\n  }\n})\n\nconst opusUrl = computed(() => props.item?.id_str ? `https://www.bilibili.com/opus/${props.item.id_str}` : '#')\n\nconst openLink = (url) => {\n  try { openInBrowser(url) } catch { window.open(url, '_blank') }\n}\n\n// 实况播放控制\nconst liveRefs = ref({})\nconst setLiveRef = (idx, el) => {\n  if (!liveRefs.value) liveRefs.value = {}\n  if (el) liveRefs.value[idx] = el\n  else delete liveRefs.value[idx]\n}\nconst handleLiveEnter = (idx) => {\n  const v = liveRefs.value?.[idx]\n  if (v) {\n    try { v.play() } catch (e) {}\n  }\n}\nconst handleLiveLeave = (idx) => {\n  const v = liveRefs.value?.[idx]\n  if (v) {\n    try { v.pause(); v.currentTime = 0 } catch (e) {}\n  }\n}\n\n// 预览逻辑\nconst showPreview = ref(false)\nconst previewType = ref('image')\nconst previewSrc = ref('')\nconst previewPoster = ref('')\nconst openPreview = (type, src, poster = '') => {\n  previewType.value = type\n  previewSrc.value = src\n  previewPoster.value = poster\n  showPreview.value = true\n}\nconst closePreview = () => { showPreview.value = false }\n</script>\n\n<style scoped>\n/* 使emoji图片与文字同高 */\n.emoji {\n  height: 1em;\n  width: 1em;\n  margin: 0 2px;\n}\n.emoji-lg {\n  height: 52px;\n  width: 52px;\n}\n</style>\n\n\n"
  },
  {
    "path": "src/components/tailwind/dynamic/DynamicCardVideo.vue",
    "content": "<template>\n  <div class=\"border rounded-lg bg-white overflow-hidden\">\n    <!-- 头部：头像 + 名称 + 时间 + 动态链接 -->\n    <div class=\"flex items-center px-3 py-2\">\n      <img v-if=\"faceUrl\" :src=\"faceUrl\" class=\"w-6 h-6 rounded-full object-cover border\" alt=\"face\" />\n      <div class=\"ml-2 min-w-0\">\n        <div class=\"text-sm font-medium truncate\">{{ item.author_name || `UID ${item.host_mid || ''}` }}</div>\n        <div class=\"text-[11px] text-gray-500 truncate\">{{ formattedTime }}</div>\n      </div>\n      <button\n        v-if=\"item.id_str\"\n        type=\"button\"\n        class=\"ml-auto text-[11px] text-[#fb7299] hover:underline\"\n        @click=\"openLink(opusUrl)\"\n      >查看动态</button>\n    </div>\n\n    <!-- 主体：封面（更小） + 文本，封面/标题可跳转视频 -->\n    <div class=\"px-3 pb-3\">\n      <div class=\"flex items-start\">\n        <div\n          class=\"relative w-40 sm:w-48 md:w-56 flex-shrink-0 rounded-md overflow-hidden hover:opacity-90 cursor-pointer\"\n          role=\"link\"\n          tabindex=\"0\"\n          @click=\"openLink(videoUrl)\"\n          @keydown.enter=\"openLink(videoUrl)\"\n        >\n          <div class=\"w-full\" style=\"aspect-ratio: 16 / 9\">\n            <img v-if=\"coverUrl\" :src=\"coverUrl\" class=\"block w-full h-full object-cover\" alt=\"cover\" />\n            <div v-else class=\"w-full h-full flex items-center justify-center text-gray-400 text-xs\">no cover</div>\n          </div>\n          <div v-if=\"item.bvid\" class=\"absolute bottom-1 left-1 text-[10px] bg-black/60 text-white px-1.5 py-0.5 rounded\">{{ item.bvid }}</div>\n        </div>\n        <div class=\"ml-3 flex-1 min-w-0\">\n          <div\n            role=\"link\"\n            tabindex=\"0\"\n            @click=\"openLink(videoUrl)\"\n            @keydown.enter=\"openLink(videoUrl)\"\n            class=\"text-sm font-semibold leading-5 line-clamp-2 hover:underline cursor-pointer\"\n            :title=\"item.title || item.txt || ''\"\n          >{{ item.title || item.txt || '视频动态' }}</div>\n          <div v-if=\"item.desc || item.txt\" class=\"text-xs text-gray-600 mt-1 line-clamp-2\">{{ item.desc || item.txt }}</div>\n        </div>\n      </div>\n    </div>\n  </div>\n  \n</template>\n\n<script setup>\nimport { computed } from 'vue'\nimport { openInBrowser } from '@/utils/openUrl'\nimport { toStaticUrl } from '@/utils/imageUrl'\n\nconst props = defineProps({\n  item: { type: Object, required: true },\n  faceUrl: { type: String, default: '' }\n})\n\nconst coverUrl = computed(() => {\n  const it = props.item || {}\n  if (it.cover) return toStaticUrl(it.cover)\n  const list = Array.isArray(it.media_locals) ? it.media_locals : []\n  if (list.length) return toStaticUrl(list[0])\n  return ''\n})\n\nconst formattedTime = computed(() => {\n  const ts = props.item?.publish_ts\n  if (!ts) return '-'\n  try {\n    const d = new Date(ts * 1000)\n    const pad = (n) => String(n).padStart(2, '0')\n    return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`\n  } catch {\n    return String(ts)\n  }\n})\n\nconst opusUrl = computed(() => props.item?.id_str ? `https://www.bilibili.com/opus/${props.item.id_str}` : '#')\nconst videoUrl = computed(() => props.item?.bvid ? `https://www.bilibili.com/video/${props.item.bvid}` : opusUrl.value)\n\nconst openLink = (url) => {\n  try { openInBrowser(url) } catch { window.open(url, '_blank') }\n}\n</script>\n\n<style scoped>\n.line-clamp-2 {\n  display: -webkit-box;\n  -webkit-line-clamp: 2;\n  -webkit-box-orient: vertical;\n  overflow: hidden;\n}\n</style>\n\n\n"
  },
  {
    "path": "src/components/tailwind/layout/MainLayout.vue",
    "content": "<template>\n  <Sidebar @change-content=\"currentContent = $event\" v-model:showRemarks=\"showRemarks\">\n    <!-- 主要内容区域 -->\n    <div class=\"h-screen overflow-y-auto\">\n      <router-view></router-view>\n    </div>\n  </Sidebar>\n\n  <!-- 数据同步管理模态框 - 全局层级 -->\n  <DataSyncManager \n    v-model:showModal=\"showDataSyncModal\"\n    :initialTab=\"currentSyncTab\"\n    @sync-complete=\"handleSyncComplete\"\n    @check-complete=\"handleCheckComplete\"\n  />\n</template>\n\n<script setup>\nimport { ref, watch, onMounted, onUnmounted } from 'vue'\nimport { useRoute } from 'vue-router'\nimport Sidebar from '../Sidebar.vue'\nimport DataSyncManager from '../DataSyncManager.vue'\n\nconst route = useRoute()\n\n// 当前显示的内容\nconst currentContent = ref('history')\nconst showRemarks = ref(false)\n\n// 数据同步弹窗状态\nconst showDataSyncModal = ref(false)\nconst currentSyncTab = ref('integrity')\n\n// 监听路由变化\nwatch(\n  () => route.path,\n  (path) => {\n    if (path === '/settings') {\n      currentContent.value = 'settings'\n      showRemarks.value = false\n    } else if (path === '/media') {\n      currentContent.value = 'media'\n      \n      // 如果是通过备注路由重定向过来的，需要显示备注\n      if (route.query.tab === 'remarks') {\n        showRemarks.value = true\n      } else {\n        showRemarks.value = false\n      }\n    } else if (path === '/' || path.startsWith('/page/')) {\n      currentContent.value = 'history'\n      showRemarks.value = false\n    } else if (path === '/analytics') {\n      currentContent.value = 'analytics'\n      showRemarks.value = false\n    } else if (path === '/images') {\n      currentContent.value = 'images'\n      showRemarks.value = false\n    } else if (path === '/scheduler') {\n      currentContent.value = 'scheduler'\n      showRemarks.value = false\n    } else if (path === '/video-downloader') {\n      currentContent.value = 'video-downloader'\n      showRemarks.value = false\n    }\n  },\n  { immediate: true }\n)\n\n// 处理同步完成事件\nconst handleSyncComplete = (result) => {\n  console.log('同步完成:', result)\n}\n\n// 处理检查完成事件\nconst handleCheckComplete = (result) => {\n  console.log('完整性检查完成:', result)\n}\n\n// 监听自定义全局事件\nonMounted(() => {\n  window.addEventListener('open-data-sync-manager', handleOpenDataSyncManager)\n})\n\nonUnmounted(() => {\n  window.removeEventListener('open-data-sync-manager', handleOpenDataSyncManager)\n})\n\n// 处理打开数据同步管理器事件\nconst handleOpenDataSyncManager = (event) => {\n  if (event.detail && event.detail.tab) {\n    currentSyncTab.value = event.detail.tab\n  }\n  showDataSyncModal.value = true\n}\n</script> "
  },
  {
    "path": "src/components/tailwind/page/AnimatedAnalytics.vue",
    "content": "<template>\n  <div class=\"h-screen\">\n    <analytics-layout>\n      <!-- 固定在顶部的导航 -->\n      <div class=\"fixed top-0 left-0 right-0 z-50\">\n        <div class=\"bg-white/5 backdrop-blur-md border-b border-white/10 dark:bg-black/5 dark:border-gray-800/50\">\n          <div class=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8\">\n            <div class=\"flex justify-between items-center h-16\">\n              <!-- 添加返回按钮 -->\n              <div class=\"flex items-center\">\n                <button\n                  @click=\"goToHome\"\n                  class=\"mr-3 p-1 rounded-full hover:bg-white/20 dark:hover:bg-black/20 transition-colors\"\n                  title=\"返回首页\"\n                >\n                  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-6 w-6 text-[#fb7299] dark:text-[#fc9b7a]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6\" />\n                  </svg>\n                </button>\n                <h1 class=\"text-2xl font-bold bg-gradient-to-r from-[#fb7299] to-[#fc9b7a] bg-clip-text text-transparent\">\n                  {{ selectedYear }}年度回顾\n                </h1>\n              </div>\n              <div class=\"flex items-center space-x-4\">\n                <select\n                  v-model=\"selectedYear\"\n                  class=\"w-24 bg-white/10 backdrop-blur text-gray-800 dark:text-white border border-white/20 dark:border-gray-700 rounded-lg px-3 py-1 focus:ring-2 focus:ring-[#fb7299] focus:border-transparent transition-colors duration-200\"\n                  :disabled=\"loading\"\n                >\n                  <option v-for=\"year in availableYears\" :key=\"year\" :value=\"year\">\n                    {{ year }}年\n                  </option>\n                </select>\n\n                <!-- 强制刷新按钮 -->\n                <button\n                  @click=\"handleForceRefresh\"\n                  :disabled=\"loading\"\n                  class=\"inline-flex items-center text-gray-600 dark:text-gray-300 hover:text-[#fb7299] dark:hover:text-[#fb7299] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200\"\n                >\n                  <svg\n                    class=\"w-5 h-5\"\n                    :class=\"{'animate-spin': loading}\"\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                    fill=\"none\"\n                    viewBox=\"0 0 24 24\"\n                    stroke=\"currentColor\"\n                  >\n                    <circle\n                      v-if=\"loading\"\n                      class=\"opacity-25\"\n                      cx=\"12\"\n                      cy=\"12\"\n                      r=\"10\"\n                      stroke=\"currentColor\"\n                      stroke-width=\"4\"\n                    ></circle>\n                    <path\n                      v-if=\"loading\"\n                      class=\"opacity-75\"\n                      fill=\"currentColor\"\n                      d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"\n                    ></path>\n                    <path\n                      v-if=\"!loading\"\n                      stroke=\"currentColor\"\n                      stroke-linecap=\"round\"\n                      stroke-linejoin=\"round\"\n                      stroke-width=\"2\"\n                      d=\"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15\"\n                    ></path>\n                  </svg>\n                  <span class=\"ml-2\">{{ loading ? '加载中' : '强制刷新' }}</span>\n                </button>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <!-- 主要内容区域 -->\n      <div class=\"relative h-full pt-16\">\n        <!-- 页面容器 -->\n        <div class=\"h-full\">\n          <!-- 加载状态 -->\n          <div v-if=\"loading\"\n            class=\"fixed inset-0 flex items-center justify-center z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm\"\n            style=\"min-height: 100vh\"\n          >\n            <div class=\"text-center\">\n              <svg\n                class=\"w-12 h-12 mx-auto mb-4 animate-spin text-[#fb7299]\"\n                xmlns=\"http://www.w3.org/2000/svg\"\n                fill=\"none\"\n                viewBox=\"0 0 24 24\"\n              >\n                <circle\n                  class=\"opacity-25\"\n                  cx=\"12\"\n                  cy=\"12\"\n                  r=\"10\"\n                  stroke=\"currentColor\"\n                  stroke-width=\"4\"\n                ></circle>\n                <path\n                  class=\"opacity-75\"\n                  fill=\"currentColor\"\n                  d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"\n                ></path>\n              </svg>\n              <div class=\"space-y-2\">\n                <p class=\"text-lg font-medium text-gray-800 dark:text-gray-200\">正在分析{{ selectedYear }}年的观看数据</p>\n                <p class=\"text-sm text-gray-600 dark:text-gray-400\">\n                  {{ loading && viewingData === null ? '首次加载数据可能需要30秒到1分钟，具体加载时间取决于数据量' : '正在从缓存加载数据，预计3-5秒' }}\n                </p>\n              </div>\n            </div>\n          </div>\n\n          <!-- 内容页面 -->\n          <Transition mode=\"out-in\" name=\"fade\">\n            <!-- 开场页 -->\n            <HeroPage v-if=\"currentPage === 0\" key=\"hero\" :year=\"selectedYear\" />\n\n            <!-- 数据概览页 -->\n            <OverviewPage v-else-if=\"currentPage === 1\" key=\"overview\" :viewing-data=\"monthlyStatsData\" />\n\n            <!-- 时间分析页 -->\n            <TimeAnalysisPage v-else-if=\"currentPage === 2\" key=\"time-analysis\" :viewing-data=\"timeSlotsData\" :selected-year=\"selectedYear\" />\n\n            <!-- 时间分布分析页 -->\n            <TimeDistributionPage v-else-if=\"currentPage === 3\" key=\"time-distribution\" :viewing-data=\"weeklyStatsData\" />\n\n            <!-- 月度趋势页 -->\n            <MonthlyPage v-else-if=\"currentPage === 4\" key=\"monthly\" :viewing-data=\"monthlyStatsData\" />\n\n            <!-- 连续观看页 -->\n            <StreakPage v-else-if=\"currentPage === 5\" key=\"streak\" :viewing-data=\"continuityData\" :selected-year=\"selectedYear\" />\n\n            <!-- 最爱重温页 -->\n            <RewatchPage v-else-if=\"currentPage === 6\" key=\"rewatch\" :viewing-data=\"watchCountsData\" />\n\n            <!-- 视频完成率分析页 -->\n            <OverallCompletionPage v-else-if=\"currentPage === 7\" key=\"overall-completion\" :viewing-data=\"completionRatesData\" />\n\n            <!-- UP主完成率分析页 -->\n            <AuthorCompletionPage v-else-if=\"currentPage === 8\" key=\"author-completion\" :viewing-data=\"authorCompletionData\" />\n\n            <!-- 标签分析页 -->\n            <TagsPage v-else-if=\"currentPage === 9\" key=\"tags\" :viewing-data=\"tagAnalysisData\" />\n\n            <!-- 视频时长分析页 -->\n            <DurationAnalysisPage v-else-if=\"currentPage === 10\" key=\"duration-analysis\" :viewing-data=\"durationAnalysisData\" />\n\n            <!-- 标题分析页 -->\n            <TitleAnalysisPage v-else-if=\"currentPage === 11\" key=\"title-analysis\" :title-analytics=\"keywordAnalyticsData\" />\n\n            <!-- 标题趋势分析页 -->\n            <TitleTrendAnalysisPage v-else-if=\"currentPage === 12\" key=\"title-trend-analysis\" :title-analytics=\"trendAnalyticsData\" />\n\n            <!-- 标题长度分析页 -->\n            <TitleLengthAnalysisPage v-else-if=\"currentPage === 13\" key=\"title-length-analysis\" :title-analytics=\"lengthAnalyticsData\" />\n\n            <!-- 标题情感分析页 -->\n            <TitleSentimentAnalysisPage v-else-if=\"currentPage === 14\" key=\"title-sentiment-analysis\" :title-analytics=\"sentimentAnalyticsData\" />\n\n            <!-- 标题互动分析页 -->\n            <TitleInteractionAnalysisPage v-else-if=\"currentPage === 15\" key=\"title-interaction-analysis\" :title-analytics=\"interactionAnalyticsData\" />\n\n            <!-- 热门命中率分析页 -->\n            <PopularHitRatePage v-else-if=\"currentPage === 16\" key=\"popular-hit-rate\" :selected-year=\"selectedYear\" :hit-rate-data=\"popularHitRateData\" />\n\n            <!-- 热门预测能力分析页 -->\n            <PopularPredictionPage v-else-if=\"currentPage === 17\" key=\"popular-prediction\" :data=\"popularPredictionData?.prediction_analysis\" />\n\n            <!-- UP主热门关联分析页 -->\n            <AuthorPopularAssociationPage v-else-if=\"currentPage === 18\" key=\"author-popular-association\" :data=\"authorPopularAssociationData?.association_analysis\" />\n\n            <!-- 热门视频分区分布分析页 -->\n            <CategoryPopularDistributionPage v-else-if=\"currentPage === 19\" key=\"category-popular-distribution\" :selected-year=\"selectedYear\" :distribution-data=\"categoryPopularDistributionData\" />\n\n            <!-- 热门视频时长分布分析页 -->\n            <DurationPopularDistributionPage v-else-if=\"currentPage === 20\" key=\"duration-popular-distribution\" :selected-year=\"selectedYear\" :duration-data=\"durationPopularDistributionData\" />\n          </Transition>\n        </div>\n      </div>\n    </analytics-layout>\n  </div>\n</template>\n\n<script setup>\nimport { ref, watch, onMounted, onUnmounted } from 'vue'\nimport { getTitleKeywordAnalysis, getTitleLengthAnalysis, getTitleSentimentAnalysis, getTitleTrendAnalysis, getTitleInteractionAnalysis, getViewingMonthlyStats, getViewingWeeklyStats, getViewingTimeSlots, getViewingContinuity, getViewingWatchCounts, getViewingCompletionRates, getViewingAuthorCompletion, getViewingTagAnalysis, getViewingDurationAnalysis, getPopularHitRate, getPopularPredictionAbility, getAuthorPopularAssociation, getCategoryPopularDistribution, getDurationPopularDistribution } from '../../../api/api.js'\nimport HeroPage from '../analytics/pages/HeroPage.vue'\nimport OverviewPage from '../analytics/pages/OverviewPage.vue'\nimport StreakPage from '../analytics/pages/StreakPage.vue'\nimport TimeAnalysisPage from '../analytics/pages/TimeAnalysisPage.vue'\nimport RewatchPage from '../analytics/pages/RewatchPage.vue'\nimport OverallCompletionPage from '../analytics/pages/OverallCompletionPage.vue'\nimport AuthorCompletionPage from '../analytics/pages/AuthorCompletionPage.vue'\nimport TagsPage from '../analytics/pages/TagsPage.vue'\nimport TimeDistributionPage from '../analytics/pages/TimeDistributionPage.vue'\nimport MonthlyPage from '../analytics/pages/MonthlyPage.vue'\nimport DurationAnalysisPage from '../analytics/pages/DurationAnalysisPage.vue'\nimport TitleAnalysisPage from '../analytics/pages/TitleAnalysisPage.vue'\nimport TitleLengthAnalysisPage from '../analytics/pages/TitleLengthAnalysisPage.vue'\nimport TitleSentimentAnalysisPage from '../analytics/pages/TitleSentimentAnalysisPage.vue'\nimport TitleTrendAnalysisPage from '../analytics/pages/TitleTrendAnalysisPage.vue'\nimport TitleInteractionAnalysisPage from '../analytics/pages/TitleInteractionAnalysisPage.vue'\nimport PopularHitRatePage from '../analytics/pages/PopularHitRatePage.vue'\nimport PopularPredictionPage from '../analytics/pages/PopularPredictionPage.vue'\nimport AuthorPopularAssociationPage from '../analytics/pages/AuthorPopularAssociationPage.vue'\nimport CategoryPopularDistributionPage from '../analytics/pages/CategoryPopularDistributionPage.vue'\nimport DurationPopularDistributionPage from '../analytics/pages/DurationPopularDistributionPage.vue'\nimport AnalyticsLayout from '../analytics/layout/AnalyticsLayout.vue'\nimport { CanvasRenderer } from 'echarts/renderers'\nimport { LineChart, BarChart, PieChart } from 'echarts/charts'\nimport {\n  GridComponent,\n  TooltipComponent,\n  LegendComponent,\n  TitleComponent\n} from 'echarts/components'\nimport { use } from 'echarts/core'\nimport 'echarts-wordcloud'\nimport { useRouter, useRoute } from 'vue-router'\n\n// 注册必要的组件\nuse([\n  CanvasRenderer,\n  LineChart,\n  BarChart,\n  PieChart,\n  GridComponent,\n  TooltipComponent,\n  LegendComponent,\n  TitleComponent\n])\n\n// 状态\nconst router = useRouter()\nconst route = useRoute()\nconst selectedYear = ref(new Date().getFullYear())\nconst availableYears = ref([])\nconst loading = ref(false)\n// 已删除原有的 analyticsData，现在使用拆分后的独立数据\nconst keywordAnalyticsData = ref(null)\nconst lengthAnalyticsData = ref(null)\nconst sentimentAnalyticsData = ref(null)\nconst trendAnalyticsData = ref(null)\nconst interactionAnalyticsData = ref(null)\n// 观看时间分析数据（逐步拆分中）\nconst monthlyStatsData = ref(null)\nconst weeklyStatsData = ref(null)\nconst timeSlotsData = ref(null)\nconst continuityData = ref(null)\nconst watchCountsData = ref(null)\nconst completionRatesData = ref(null)\nconst authorCompletionData = ref(null)\nconst tagAnalysisData = ref(null)\nconst durationAnalysisData = ref(null)\nconst popularHitRateData = ref(null)\nconst popularPredictionData = ref(null)\nconst authorPopularAssociationData = ref(null)\nconst categoryPopularDistributionData = ref(null)\nconst durationPopularDistributionData = ref(null)\nconst viewingData = ref(null)\nconst currentPage = ref(0)\nconst isTransitioning = ref(false)\n\n// 滚动和触摸相关状态\nlet touchStartX = 0\nlet touchStartY = 0\nlet lastWheelTime = 0\nconst wheelThreshold = 30 // 降低滚动阈值\nconst wheelCooldown = 800 // 增加冷却时间以适应新的动画持续时间\n\n// 页面配置\nconst pages = [\n  { name: '开场', color: '#fb7299' },\n  { name: '数据概览', color: '#fc9b7a' },\n  // 时间相关分析\n  { name: '时间分析', color: '#fb7299' },\n  { name: '时间分布', color: '#fc9b7a' },\n  { name: '月度趋势', color: '#fb7299' },\n  // 观看行为分析\n  { name: '连续观看', color: '#fc9b7a' },\n  { name: '最爱重温', color: '#fb7299' },\n  { name: '视频完成率', color: '#fc9b7a' },\n  // 内容分析\n  { name: 'UP主完成率', color: '#fb7299' },\n  { name: '标签分析', color: '#fc9b7a' },\n  { name: '视频时长分析', color: '#fb7299' },\n  // 标题分析\n  { name: '标题分析', color: '#fc9b7a' },\n  { name: '标题趋势分析', color: '#fb7299' },\n  { name: '标题长度分析', color: '#fc9b7a' },\n  { name: '标题情感分析', color: '#fb7299' },\n  { name: '标题互动分析', color: '#fc9b7a' },\n  // 热门视频分析\n  { name: '热门命中率', color: '#fb7299' },\n  { name: '预测能力', color: '#fc9b7a' },\n  { name: 'UP主热门关联', color: '#fb7299' },\n  { name: '分区分布', color: '#fc9b7a' },\n  { name: '时长分布', color: '#fb7299' }\n]\n\n// 监听页面切换\nwatch(currentPage, (newPage) => {\n  // 减少过渡动画时间\n  isTransitioning.value = true\n  setTimeout(() => {\n    isTransitioning.value = false\n  }, 300)\n\n  // 更新 URL\n  router.push({\n    query: {\n      ...route.query,\n      page: newPage\n    }\n  })\n})\n\n// 修改页面切换处理\nconst handlePageTransition = async (newPage) => {\n  if (isTransitioning.value) return\n  currentPage.value = newPage\n\n  // 切换页面时加载对应的数据\n  await fetchPageData(newPage)\n}\n\n// 修改滚轮事件处理\nconst handleWheel = (e) => {\n  // 如果正在加载，阻止滚动\n  if (loading.value) {\n    e.preventDefault()\n    return\n  }\n\n  const now = Date.now()\n  if (isTransitioning.value || now - lastWheelTime < wheelCooldown) return\n\n  const deltaY = Math.abs(e.deltaY)\n  if (deltaY < wheelThreshold) return\n\n  lastWheelTime = now\n\n  if (e.deltaY > 0 && currentPage.value < pages.length - 1) {\n    handlePageTransition(currentPage.value + 1)\n  } else if (e.deltaY < 0 && currentPage.value > 0) {\n    handlePageTransition(currentPage.value - 1)\n  }\n}\n\n// 检测是否为真正的触摸设备\nconst isTouchDevice = () => {\n  return 'ontouchstart' in window && navigator.maxTouchPoints > 0\n}\n\n// 修改触摸事件处理 - 只在真正的触摸设备上启用\nconst handleTouchStart = (e) => {\n  // 只在真正的触摸设备上处理触摸事件\n  if (!isTouchDevice()) return\n  // 如果正在加载，阻止触摸事件\n  if (loading.value || isTransitioning.value) return\n  touchStartX = e.touches[0].clientX\n  touchStartY = e.touches[0].clientY\n}\n\nconst handleTouchMove = (e) => {\n  // 只在真正的触摸设备上处理触摸事件\n  if (!isTouchDevice()) return\n  // 如果正在加载，阻止触摸移动\n  if (loading.value) {\n    e.preventDefault()\n    return\n  }\n\n  if (isTransitioning.value) return\n  e.preventDefault() // 防止页面滚动\n}\n\nconst handleTouchEnd = (e) => {\n  // 只在真正的触摸设备上处理触摸事件\n  if (!isTouchDevice()) return\n  // 如果正在加载，阻止触摸结束事件\n  if (loading.value || isTransitioning.value) return\n\n  const touchEndX = e.changedTouches[0].clientX\n  const touchEndY = e.changedTouches[0].clientY\n  const deltaX = touchEndX - touchStartX\n  const deltaY = touchEndY - touchStartY\n\n  if (Math.abs(deltaY) > Math.abs(deltaX)) {\n    if (Math.abs(deltaY) < 50) return\n\n    if (deltaY < 0 && currentPage.value < pages.length - 1) {\n      handlePageTransition(currentPage.value + 1)\n    } else if (deltaY > 0 && currentPage.value > 0) {\n      handlePageTransition(currentPage.value - 1)\n    }\n  }\n}\n\n// 按需加载数据的函数\nconst fetchPageData = async (pageNumber, forceRefresh = false) => {\n  if (loading.value) return\n\n  loading.value = true\n\n  try {\n    console.log(`开始获取第${pageNumber}页数据...`)\n\n    switch (pageNumber) {\n      case 0: // 开场页 - 不需要加载数据，但需要获取可用年份\n        if (!availableYears.value.length || forceRefresh) {\n          const response = await getViewingMonthlyStats(selectedYear.value, !forceRefresh)\n          if (response.data.status === 'success' && response.data.data.available_years) {\n            availableYears.value = response.data.data.available_years\n            if (!availableYears.value.includes(selectedYear.value)) {\n              selectedYear.value = availableYears.value[0]\n            }\n          }\n        }\n        break\n\n      case 1: // 月度统计页\n        if (!monthlyStatsData.value || forceRefresh) {\n          const response = await getViewingMonthlyStats(selectedYear.value, !forceRefresh)\n          if (response.data.status === 'success') {\n            monthlyStatsData.value = response.data.data\n            // 从第一个接口获取可用年份\n            if (response.data.data.available_years) {\n              availableYears.value = response.data.data.available_years\n              if (!availableYears.value.includes(selectedYear.value)) {\n                selectedYear.value = availableYears.value[0]\n              }\n            }\n          }\n        }\n        break\n\n      case 2: // 时间分析页\n        if (!timeSlotsData.value || forceRefresh) {\n          const response = await getViewingTimeSlots(selectedYear.value, !forceRefresh)\n          if (response.data.status === 'success') {\n            timeSlotsData.value = response.data.data\n          }\n        }\n        break\n\n      case 3: // 时间分布分析页\n        if (!weeklyStatsData.value || forceRefresh) {\n          const response = await getViewingWeeklyStats(selectedYear.value, !forceRefresh)\n          if (response.data.status === 'success') {\n            weeklyStatsData.value = response.data.data\n          }\n        }\n        break\n\n      case 4: // 月度趋势页\n        if (!monthlyStatsData.value || forceRefresh) {\n          const response = await getViewingMonthlyStats(selectedYear.value, !forceRefresh)\n          if (response.data.status === 'success') {\n            monthlyStatsData.value = response.data.data\n          }\n        }\n        break\n\n      case 5: // 连续观看记录页\n        if (!continuityData.value || forceRefresh) {\n          const response = await getViewingContinuity(selectedYear.value, !forceRefresh)\n          if (response.data.status === 'success') {\n            continuityData.value = response.data.data\n          }\n        }\n        break\n\n      case 6: // 最爱重温页\n        if (!watchCountsData.value || forceRefresh) {\n          const response = await getViewingWatchCounts(selectedYear.value, !forceRefresh)\n          if (response.data.status === 'success') {\n            watchCountsData.value = response.data.data\n          }\n        }\n        break\n\n      case 7: // 视频完成率分析页\n        if (!completionRatesData.value || forceRefresh) {\n          const response = await getViewingCompletionRates(selectedYear.value, !forceRefresh)\n          if (response.data.status === 'success') {\n            completionRatesData.value = response.data.data\n          }\n        }\n        break\n\n      case 8: // UP主完成率分析页\n        if (!authorCompletionData.value || forceRefresh) {\n          const response = await getViewingAuthorCompletion(selectedYear.value, !forceRefresh)\n          if (response.data.status === 'success') {\n            authorCompletionData.value = response.data.data\n          }\n        }\n        break\n\n      case 9: // 标签分析页\n        if (!tagAnalysisData.value || forceRefresh) {\n          const response = await getViewingTagAnalysis(selectedYear.value, !forceRefresh)\n          if (response.data.status === 'success') {\n            tagAnalysisData.value = response.data.data\n          }\n        }\n        break\n\n      case 10: // 视频时长分析页\n        if (!durationAnalysisData.value || forceRefresh) {\n          const response = await getViewingDurationAnalysis(selectedYear.value, !forceRefresh)\n          if (response.data.status === 'success') {\n            durationAnalysisData.value = response.data.data\n          }\n        }\n        break\n\n      case 11: // 标题分析页\n        if (!keywordAnalyticsData.value || forceRefresh) {\n          const response = await getTitleKeywordAnalysis(selectedYear.value, !forceRefresh)\n          if (response.data.status === 'success') {\n            keywordAnalyticsData.value = response.data.data\n          }\n        }\n        break\n\n      case 12: // 标题趋势分析页\n        if (!trendAnalyticsData.value || forceRefresh) {\n          const response = await getTitleTrendAnalysis(selectedYear.value, !forceRefresh)\n          if (response.data.status === 'success') {\n            trendAnalyticsData.value = response.data.data\n          }\n        }\n        break\n\n      case 13: // 标题长度分析页\n        if (!lengthAnalyticsData.value || forceRefresh) {\n          const response = await getTitleLengthAnalysis(selectedYear.value, !forceRefresh)\n          if (response.data.status === 'success') {\n            lengthAnalyticsData.value = response.data.data\n          }\n        }\n        break\n\n      case 14: // 标题情感分析页\n        if (!sentimentAnalyticsData.value || forceRefresh) {\n          const response = await getTitleSentimentAnalysis(selectedYear.value, !forceRefresh)\n          if (response.data.status === 'success') {\n            sentimentAnalyticsData.value = response.data.data\n          }\n        }\n        break\n\n      case 15: // 标题互动分析页\n        if (!interactionAnalyticsData.value || forceRefresh) {\n          const response = await getTitleInteractionAnalysis(selectedYear.value, !forceRefresh)\n          if (response.data.status === 'success') {\n            interactionAnalyticsData.value = response.data.data\n          }\n        }\n        break\n\n      case 16: // 热门命中率分析页\n        if (!popularHitRateData.value || forceRefresh) {\n          const response = await getPopularHitRate(selectedYear.value, !forceRefresh)\n          if (response.data.status === 'success') {\n            popularHitRateData.value = response.data.data\n          }\n        }\n        break\n\n      case 17: // 热门预测能力分析页\n        if (!popularPredictionData.value || forceRefresh) {\n          const response = await getPopularPredictionAbility(selectedYear.value, !forceRefresh)\n          if (response.data.status === 'success') {\n            popularPredictionData.value = response.data.data\n          }\n        }\n        break\n\n      case 18: // UP主热门关联分析页\n        if (!authorPopularAssociationData.value || forceRefresh) {\n          const response = await getAuthorPopularAssociation(selectedYear.value, !forceRefresh)\n          if (response.data.status === 'success') {\n            authorPopularAssociationData.value = response.data.data\n          }\n        }\n        break\n\n      case 19: // 热门视频分区分布分析页\n        if (!categoryPopularDistributionData.value || forceRefresh) {\n          const response = await getCategoryPopularDistribution(selectedYear.value, !forceRefresh)\n          if (response.data.status === 'success') {\n            categoryPopularDistributionData.value = response.data.data\n          }\n        }\n        break\n\n      case 20: // 热门视频时长分布分析页\n        if (!durationPopularDistributionData.value || forceRefresh) {\n          const response = await getDurationPopularDistribution(selectedYear.value, !forceRefresh)\n          if (response.data.status === 'success') {\n            durationPopularDistributionData.value = response.data.data\n          }\n        }\n        break\n\n      default:\n        console.log(`第${pageNumber}页暂未配置数据加载`)\n        break\n    }\n\n    console.log(`第${pageNumber}页数据加载完成`)\n  } catch (error) {\n    console.error(`获取第${pageNumber}页数据失败:`, error)\n  } finally {\n    loading.value = false\n  }\n}\n\n// 兼容旧的函数名，用于强制刷新所有数据\nconst fetchAnalyticsData = async (forceRefresh = false) => {\n  if (forceRefresh) {\n    // 清空现有数据\n    keywordAnalyticsData.value = null\n    lengthAnalyticsData.value = null\n    sentimentAnalyticsData.value = null\n    trendAnalyticsData.value = null\n    interactionAnalyticsData.value = null\n    monthlyStatsData.value = null\n    weeklyStatsData.value = null\n    timeSlotsData.value = null\n    continuityData.value = null\n    watchCountsData.value = null\n    completionRatesData.value = null\n    authorCompletionData.value = null\n    tagAnalysisData.value = null\n    durationAnalysisData.value = null\n    popularHitRateData.value = null\n    popularPredictionData.value = null\n    authorPopularAssociationData.value = null\n    categoryPopularDistributionData.value = null\n    durationPopularDistributionData.value = null\n    viewingData.value = null\n  }\n\n  // 只加载当前页面的数据\n  await fetchPageData(currentPage.value, forceRefresh)\n}\n\n// 移除单独的年份获取方法\nconst refreshData = async (forceRefresh = false) => {\n  // 直接调用 fetchPageData，避免重复设置 loading 状态\n  await fetchPageData(currentPage.value, forceRefresh)\n}\n\n// 强制刷新方法\nconst handleForceRefresh = async () => {\n  if (loading.value) return\n  await refreshData(true)\n}\n\n// 监听年份变化\nwatch(selectedYear, async (newYear) => {\n  if (newYear) {\n    // 年份变化时，重新加载当前页面的数据\n    await fetchPageData(currentPage.value, true) // 强制刷新\n  }\n})\n\n// 生命周期钩子\nonMounted(async () => {\n  // 从 URL 读取页码\n  const pageFromUrl = parseInt(Array.isArray(route.query.page) ? route.query.page[0] : route.query.page || '0')\n  if (!isNaN(pageFromUrl) && pageFromUrl >= 0 && pageFromUrl < pages.length) {\n    currentPage.value = pageFromUrl\n  }\n\n  // 确保获取可用年份（如果还没有获取）\n  if (!availableYears.value.length) {\n    try {\n      const response = await getViewingMonthlyStats(selectedYear.value, true)\n      if (response.data.status === 'success' && response.data.data.available_years) {\n        availableYears.value = response.data.data.available_years\n        if (!availableYears.value.includes(selectedYear.value)) {\n          selectedYear.value = availableYears.value[0]\n        }\n      }\n    } catch (error) {\n      console.error('获取可用年份失败:', error)\n    }\n  }\n\n  // 只加载当前页面的数据\n  await fetchPageData(currentPage.value)\n  window.addEventListener('wheel', handleWheel, { passive: false })\n  \n  // 只在真正的触摸设备上添加触摸事件监听器\n  if (isTouchDevice()) {\n    window.addEventListener('touchstart', handleTouchStart)\n    window.addEventListener('touchmove', handleTouchMove, { passive: false })\n    window.addEventListener('touchend', handleTouchEnd)\n  }\n})\n\nonUnmounted(() => {\n  window.removeEventListener('wheel', handleWheel)\n  \n  // 只在真正的触摸设备上移除触摸事件监听器\n  if (isTouchDevice()) {\n    window.removeEventListener('touchstart', handleTouchStart)\n    window.removeEventListener('touchmove', handleTouchMove)\n    window.removeEventListener('touchend', handleTouchEnd)\n  }\n})\n\n// 添加返回首页的方法\nconst goToHome = () => {\n  router.push('/')\n}\n</script>\n\n<style>\n.fade-enter-active,\n.fade-leave-active {\n  transition: opacity 0.3s ease;\n}\n\n.fade-enter-from,\n.fade-leave-to {\n  opacity: 0;\n}\n\n/* 添加过渡效果 */\nselect {\n  appearance: none;\n  background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23fb7299' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e\");\n  background-position: right 0.5rem center;\n  background-repeat: no-repeat;\n  background-size: 1.5em 1.5em;\n  padding-right: 2.5rem;\n}\n\nselect:focus {\n  outline: none;\n  box-shadow: 0 0 0 2px rgba(251, 114, 153, 0.2);\n}\n\n/* 移除深色模式媒体查询，始终使用浅色模式 */\n</style>\n"
  },
  {
    "path": "src/components/tailwind/page/BiliTools.vue",
    "content": "<template>\n  <div class=\"min-h-screen bg-gray-50/30 dark:bg-gray-900\">\n    <div class=\"py-6\">\n      <div class=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8\">\n        <!-- 主内容卡片 -->\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700\">\n          <!-- 标签导航 -->\n          <div class=\"border-b border-gray-200 dark:border-gray-700\">\n            <nav class=\"-mb-px flex px-6 overflow-x-auto\" aria-label=\"B站工具选项卡\">\n              <button\n                @click=\"activeTab = 'video-stats'\"\n                class=\"py-4 px-3 border-b-2 font-medium text-sm flex items-center space-x-2\"\n                :class=\"activeTab === 'video-stats'\n                  ? 'border-[#fb7299] text-[#fb7299]'\n                  : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'\"\n              >\n                <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z\" />\n                </svg>\n                <span>视频观看总时长</span>\n              </button>\n\n              <button\n                @click=\"activeTab = 'video-download'\"\n                class=\"ml-8 py-4 px-3 border-b-2 font-medium text-sm flex items-center space-x-2\"\n                :class=\"activeTab === 'video-download'\n                  ? 'border-[#fb7299] text-[#fb7299]'\n                  : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'\"\n              >\n                <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4\" />\n                </svg>\n                <span>视频下载</span>\n              </button>\n\n              <button\n                @click=\"activeTab = 'comment-query'\"\n                class=\"ml-8 py-4 px-3 border-b-2 font-medium text-sm flex items-center space-x-2\"\n                :class=\"activeTab === 'comment-query'\n                  ? 'border-[#fb7299] text-[#fb7299]'\n                  : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'\"\n              >\n                <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z\" />\n                </svg>\n                <span>评论查询</span>\n              </button>\n            </nav>\n          </div>\n\n          <!-- 内容区域 -->\n          <div class=\"transition-all duration-300\">\n            <!-- 视频观看时长信息 -->\n            <div v-if=\"activeTab === 'video-stats'\" class=\"animate-fadeIn\">\n              <div class=\"bg-white dark:bg-gray-800\">\n                <!-- 致谢信息 -->\n                <div class=\"mb-3 flex items-center justify-center h-7 px-3 py-0 bg-[#fb7299]/5 rounded-md border border-[#fb7299]/20 mx-6 mt-4\">\n                  <a href=\"https://www.xiaoheihe.cn/app/user/profile/55542982\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"flex items-center hover:opacity-80 transition-opacity mr-1.5\">\n                    <img src=\"https://imgheybox.max-c.com/avatar/2025/02/16/20a399e3b78c0db29b5ec14361b3e348.png?imageMogr2/thumbnail/400x400%3E\" alt=\"shengyI头像\" class=\"h-5 w-5 rounded-full mr-1.5\" />\n                  </a>\n                  <span class=\"text-xs text-gray-700 dark:text-gray-300\">感谢小黑盒用户\n                    <a\n                      href=\"https://www.xiaoheihe.cn/app/bbs/link/153880174\"\n                      target=\"_blank\"\n                      rel=\"noopener noreferrer\"\n                      class=\"text-[#fb7299] font-medium hover:underline\"\n                    >\n                      shengyI\n                    </a>\n                    提供思路\n                  </span>\n                </div>\n\n                <!-- 输入表单 -->\n                <div class=\"px-4 py-4 border-b border-gray-100 dark:border-gray-700\">\n                  <h2 class=\"text-base font-medium text-gray-900 dark:text-gray-100 mb-2\">视频时长信息查询</h2>\n                  <p class=\"text-xs text-gray-600 dark:text-gray-400 mb-3\">\n                    输入B站视频BV号，查询该视频的观看时长信息。<span class=\"text-[#fb7299] font-medium\">注意：只有被UP主添加到合集(season_id)的视频才能查询到观看时长数据。</span>\n                  </p>\n\n                  <div class=\"relative\">\n                    <input\n                      v-model=\"bvid\"\n                      type=\"text\"\n                      placeholder=\"输入视频BV号，例如：BV1hu411h7ot\"\n                      class=\"w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-transparent text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-[#fb7299] focus:border-transparent pr-10\"\n                      @input=\"debouncedFetchVideoStats\"\n                    />\n                    <div class=\"absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none\">\n                      <svg v-if=\"loading\" class=\"animate-spin h-5 w-5 text-[#fb7299]\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\">\n                        <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n                        <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n                      </svg>\n                    </div>\n                  </div>\n\n                  <div class=\"mt-3 flex items-center\">\n                    <input\n                      type=\"checkbox\"\n                      id=\"use-sessdata\"\n                      v-model=\"useSessdata\"\n                      class=\"h-4 w-4 text-[#fb7299] focus:ring-[#fb7299] border-gray-300 rounded\"\n                      @change=\"bvid && debouncedFetchVideoStats()\"\n                    />\n                    <label for=\"use-sessdata\" class=\"ml-2 block text-sm text-gray-600\">\n                      使用登录状态查询（用于需要登录才能查看的视频）\n                    </label>\n                  </div>\n                </div>\n\n                <!-- 结果展示 -->\n                <div v-if=\"videoStats\" class=\"px-6 py-5\">\n                  <!-- 如果是合集视频 -->\n                  <div v-if=\"videoStats.status === 'success'\">\n                    <!-- 查询的视频信息 -->\n                    <div v-if=\"queriedVideo\" class=\"mb-4 bg-white dark:bg-gray-800 border border-[#fb7299]/20 rounded-lg overflow-hidden\">\n                      <div class=\"bg-[#fb7299]/5 px-3 py-2 border-b border-[#fb7299]/20\">\n                        <h3 class=\"text-sm font-medium text-gray-900 flex items-center\">\n                          <svg class=\"w-4 h-4 mr-1.5 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z\" />\n                          </svg>\n                          查询视频详情\n                        </h3>\n                      </div>\n                      <div class=\"p-3\">\n                        <div class=\"flex flex-col md:flex-row\">\n                          <div class=\"md:w-52 flex-shrink-0 mb-3 md:mb-0\">\n                            <img\n                              :src=\"queriedVideo.cover\"\n                              class=\"w-full h-28 md:h-32 object-cover rounded shadow-sm\"\n                              alt=\"视频封面\"\n                            />\n                          </div>\n                          <div class=\"md:ml-4 flex-1\">\n                            <h4 class=\"text-sm font-medium text-gray-900 hover:text-[#fb7299]\">\n                              <a :href=\"`https://www.bilibili.com/video/${queriedVideo.bvid}`\" target=\"_blank\">{{ queriedVideo.title }}</a>\n                            </h4>\n                            <p class=\"text-xs text-gray-500 mt-1\">BV: {{ queriedVideo.bvid }}</p>\n\n                            <div class=\"mt-3 grid grid-cols-2 md:grid-cols-5 gap-2\">\n                              <div class=\"bg-gray-50 dark:bg-gray-700 p-2 rounded-lg\">\n                                <p class=\"text-xs text-gray-500\">视频时长</p>\n                                <p class=\"text-sm font-semibold text-gray-900\">{{ formatDuration(queriedVideo.duration) }}</p>\n                              </div>\n                              <div class=\"bg-gray-50 dark:bg-gray-700 p-2 rounded-lg\">\n                                <p class=\"text-xs text-gray-500\">观看次数</p>\n                                <p class=\"text-sm font-semibold text-gray-900\">{{ formatNumber(queriedVideo.vv) }}</p>\n                              </div>\n                              <div class=\"bg-gray-50 dark:bg-gray-700 p-2 rounded-lg\">\n                                <p class=\"text-xs text-gray-500\">总观看时长</p>\n                                <p class=\"text-sm font-semibold text-[#fb7299]\">\n                                  {{ formatDurationHours(queriedVideo.vt) }}\n                                </p>\n                                <p class=\"text-xs text-gray-500\">{{ formatDurationDays(queriedVideo.vt) }}</p>\n                              </div>\n                              <div class=\"bg-gray-50 dark:bg-gray-700 p-2 rounded-lg\">\n                                <p class=\"text-xs text-gray-500\">平均观看时长</p>\n                                <p class=\"text-sm font-semibold text-gray-900\">\n                                  {{ formatDuration(Math.round((queriedVideo.vt * 60) / queriedVideo.vv)) }}\n                                </p>\n                              </div>\n                              <div class=\"bg-gray-50 p-2 rounded-lg\">\n                                <p class=\"text-xs text-gray-500\">完播率</p>\n                                <p class=\"text-sm font-semibold text-gray-900\">\n                                  {{ secondsToPercent(Math.round((queriedVideo.vt * 60) / queriedVideo.vv), queriedVideo.duration) }}%\n                                </p>\n                                <div class=\"w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5 mt-1\">\n                                  <div\n                                    class=\"bg-[#fb7299] h-1.5 rounded-full\"\n                                    :style=\"`width: ${Math.min(100, Math.round(((queriedVideo.vt * 60) / queriedVideo.vv) / queriedVideo.duration * 100))}%`\"\n                                  ></div>\n                                </div>\n                              </div>\n                            </div>\n                          </div>\n                        </div>\n                      </div>\n                    </div>\n\n                    <!-- 视频列表 -->\n                    <div class=\"mt-4\">\n                      <h3 class=\"text-base font-medium text-gray-900 mb-3 flex items-center\">\n                        <svg class=\"w-4 h-4 mr-2 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                          <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 6h16M4 10h16M4 14h16M4 18h16\" />\n                        </svg>\n                        视频列表\n                      </h3>\n                      <div class=\"overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700\">\n                        <table class=\"min-w-full divide-y divide-gray-200\">\n                          <thead class=\"bg-gray-50 dark:bg-gray-800\">\n                            <tr>\n                              <th scope=\"col\" class=\"px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider\">视频信息</th>\n                              <th\n                                scope=\"col\"\n                                class=\"px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer\"\n                                @click=\"sortBy('duration')\"\n                              >\n                                <div class=\"flex items-center\">\n                                  时长\n                                  <svg\n                                    v-if=\"sortKey === 'duration'\"\n                                    class=\"w-3 h-3 ml-1\"\n                                    fill=\"none\"\n                                    viewBox=\"0 0 24 24\"\n                                    stroke=\"currentColor\"\n                                  >\n                                    <path\n                                      stroke-linecap=\"round\"\n                                      stroke-linejoin=\"round\"\n                                      stroke-width=\"2\"\n                                      :d=\"sortDirection === 'asc'\n                                        ? 'M5 15l7-7 7 7'\n                                        : 'M19 9l-7 7-7-7'\"\n                                    />\n                                  </svg>\n                                </div>\n                              </th>\n                              <th\n                                scope=\"col\"\n                                class=\"px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer\"\n                                @click=\"sortBy('vv')\"\n                              >\n                                <div class=\"flex items-center\">\n                                  观看次数\n                                  <svg\n                                    v-if=\"sortKey === 'vv'\"\n                                    class=\"w-3 h-3 ml-1\"\n                                    fill=\"none\"\n                                    viewBox=\"0 0 24 24\"\n                                    stroke=\"currentColor\"\n                                  >\n                                    <path\n                                      stroke-linecap=\"round\"\n                                      stroke-linejoin=\"round\"\n                                      stroke-width=\"2\"\n                                      :d=\"sortDirection === 'asc'\n                                        ? 'M5 15l7-7 7 7'\n                                        : 'M19 9l-7 7-7-7'\"\n                                    />\n                                  </svg>\n                                </div>\n                              </th>\n                              <th\n                                scope=\"col\"\n                                class=\"px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer\"\n                                @click=\"sortBy('vt')\"\n                              >\n                                <div class=\"flex items-center\">\n                                  总观看时长\n                                  <svg\n                                    v-if=\"sortKey === 'vt'\"\n                                    class=\"w-3 h-3 ml-1\"\n                                    fill=\"none\"\n                                    viewBox=\"0 0 24 24\"\n                                    stroke=\"currentColor\"\n                                  >\n                                    <path\n                                      stroke-linecap=\"round\"\n                                      stroke-linejoin=\"round\"\n                                      stroke-width=\"2\"\n                                      :d=\"sortDirection === 'asc'\n                                        ? 'M5 15l7-7 7 7'\n                                        : 'M19 9l-7 7-7-7'\"\n                                    />\n                                  </svg>\n                                </div>\n                              </th>\n                              <th\n                                scope=\"col\"\n                                class=\"px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer\"\n                                @click=\"sortBy('avgWatchTime')\"\n                              >\n                                <div class=\"flex items-center\">\n                                  平均观看时长\n                                  <svg\n                                    v-if=\"sortKey === 'avgWatchTime'\"\n                                    class=\"w-3 h-3 ml-1\"\n                                    fill=\"none\"\n                                    viewBox=\"0 0 24 24\"\n                                    stroke=\"currentColor\"\n                                  >\n                                    <path\n                                      stroke-linecap=\"round\"\n                                      stroke-linejoin=\"round\"\n                                      stroke-width=\"2\"\n                                      :d=\"sortDirection === 'asc'\n                                        ? 'M5 15l7-7 7 7'\n                                        : 'M19 9l-7 7-7-7'\"\n                                    />\n                                  </svg>\n                                </div>\n                              </th>\n                              <th\n                                scope=\"col\"\n                                class=\"px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer\"\n                                @click=\"sortBy('finishRate')\"\n                              >\n                                <div class=\"flex items-center\">\n                                  完播率\n                                  <svg\n                                    v-if=\"sortKey === 'finishRate'\"\n                                    class=\"w-3 h-3 ml-1\"\n                                    fill=\"none\"\n                                    viewBox=\"0 0 24 24\"\n                                    stroke=\"currentColor\"\n                                  >\n                                    <path\n                                      stroke-linecap=\"round\"\n                                      stroke-linejoin=\"round\"\n                                      stroke-width=\"2\"\n                                      :d=\"sortDirection === 'asc'\n                                        ? 'M5 15l7-7 7 7'\n                                        : 'M19 9l-7 7-7-7'\"\n                                    />\n                                  </svg>\n                                </div>\n                              </th>\n\n                            </tr>\n                          </thead>\n                          <tbody class=\"bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700\">\n                            <tr v-for=\"video in sortedVideos\" :key=\"video.bvid\" class=\"hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors\"\n                              :class=\"{'bg-[#fb7299]/5 border-l-4 border-[#fb7299]': video.bvid === bvid}\"\n                            >\n                              <td class=\"px-3 py-2 whitespace-nowrap\">\n                                <div class=\"flex items-center\">\n                                  <div class=\"flex-shrink-0 h-10 w-16\">\n                                    <img class=\"h-10 w-16 object-cover rounded shadow-sm\" :src=\"normalizeImageUrl(video.cover)\" alt=\"\" />\n                                  </div>\n                                  <div class=\"ml-3 max-w-xs\">\n                                    <div class=\"text-xs font-medium text-gray-900 dark:text-gray-100 truncate hover:text-[#fb7299]\" :title=\"video.title\">\n                                      <a :href=\"`https://www.bilibili.com/video/${video.bvid}`\" target=\"_blank\">{{ video.title }}</a>\n                                    </div>\n                                    <div class=\"text-xs text-gray-500\">BV: {{ video.bvid }}</div>\n                                  </div>\n                                </div>\n                              </td>\n                              <td class=\"px-3 py-2 whitespace-nowrap\">\n                                <div class=\"text-xs text-gray-900 dark:text-gray-100\">{{ formatDuration(video.duration) }}</div>\n                              </td>\n                              <td class=\"px-3 py-2 whitespace-nowrap\">\n                                <div class=\"text-xs text-gray-900 dark:text-gray-100 font-medium\">{{ formatNumber(video.vv) }}</div>\n                              </td>\n                              <td class=\"px-3 py-2 whitespace-nowrap\">\n                                <div class=\"text-xs font-medium text-[#fb7299]\">{{ formatDurationHours(video.vt) }}</div>\n                                <div class=\"text-xs text-gray-500\">{{ formatDurationDays(video.vt) }}</div>\n                              </td>\n                              <td class=\"px-3 py-2 whitespace-nowrap\">\n                                <div class=\"text-xs text-gray-900 dark:text-gray-100\">{{ formatDuration(Math.round((video.vt * 60) / video.vv)) }}</div>\n                              </td>\n                              <td class=\"px-3 py-2 whitespace-nowrap\">\n                                <div class=\"text-xs text-gray-900 dark:text-gray-100\">{{ secondsToPercent(Math.round((video.vt * 60) / video.vv), video.duration) }}%</div>\n                                <div class=\"w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1 mt-1\">\n                                  <div class=\"bg-[#fb7299] h-1 rounded-full\" :style=\"`width: ${Math.min(100, Math.round(((video.vt * 60) / video.vv) / video.duration * 100))}%`\"></div>\n                                </div>\n                              </td>\n\n                            </tr>\n                          </tbody>\n                        </table>\n\n                      </div>\n                    </div>\n                  </div>\n\n                  <!-- 如果不是合集视频 -->\n                  <div v-else-if=\"videoStats.status === 'info'\" class=\"p-5\">\n                    <div class=\"bg-blue-50 p-4 rounded-lg flex\">\n                      <svg class=\"w-5 h-5 text-blue-400 mr-3 flex-shrink-0\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n                      </svg>\n                      <p class=\"text-sm text-blue-700\">{{ videoStats.message }}</p>\n                    </div>\n                  </div>\n\n                  <!-- 如果发生错误 -->\n                  <div v-else-if=\"videoStats.status === 'error'\" class=\"p-5\">\n                    <div class=\"bg-red-50 p-4 rounded-lg flex\">\n                      <svg class=\"w-5 h-5 text-red-400 mr-3 flex-shrink-0\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n                      </svg>\n                      <p class=\"text-sm text-red-700\">{{ videoStats.message }}</p>\n                    </div>\n                  </div>\n                </div>\n\n                <!-- 加载中状态 -->\n                <div v-if=\"loading && !videoStats\" class=\"flex justify-center items-center py-16\">\n                  <div class=\"animate-spin h-10 w-10 border-4 border-[#fb7299] border-t-transparent rounded-full\"></div>\n                  <p class=\"ml-4 text-gray-600\">正在获取视频数据...</p>\n                </div>\n\n                <!-- 空状态 -->\n                <div v-if=\"!loading && !videoStats\" class=\"flex flex-col items-center justify-center py-16 px-6 text-center\">\n                  <svg class=\"w-16 h-16 text-gray-300\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1\" d=\"M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z\" />\n                  </svg>\n                  <p class=\"mt-4 text-gray-500\">输入视频BV号后查询观看时长信息</p>\n                </div>\n              </div>\n            </div>\n\n            <!-- 视频下载 -->\n            <div v-if=\"activeTab === 'video-download'\" class=\"animate-fadeIn\">\n              <VideoDownloader />\n            </div>\n\n            <!-- 评论查询 -->\n            <div v-if=\"activeTab === 'comment-query'\" class=\"animate-fadeIn\">\n              <div class=\"bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6\">\n                <!-- 用户ID输入区域 -->\n                <div class=\"mb-6 bg-transparent\">\n                  <h2 class=\"text-lg font-medium text-gray-900 dark:text-gray-100 mb-3\">B站评论查询</h2>\n                  <p class=\"text-sm text-gray-600 dark:text-gray-400 mb-4\">\n                    输入B站用户UID，查询该用户的全部评论记录。\n                  </p>\n\n                  <div class=\"flex space-x-3\">\n                    <div class=\"flex-1\">\n                      <div class=\"relative\">\n                        <input\n                          v-model=\"queryUserId\"\n                          type=\"text\"\n                          placeholder=\"输入用户UID，例如：12345678\"\n                          class=\"w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-transparent text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-[#fb7299] focus:border-transparent pr-10\"\n                          @keyup.enter=\"fetchUserComments()\"\n                        />\n                        <div class=\"absolute inset-y-0 right-0 flex items-center pr-3\">\n                          <svg v-if=\"commentLoading\" class=\"animate-spin h-5 w-5 text-[#fb7299]\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\">\n                            <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n                            <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n                          </svg>\n                          <button\n                            v-else\n                            @click=\"fetchUserComments()\"\n                            class=\"text-[#fb7299] hover:text-[#fb7299]/80 transition-colors\"\n                          >\n                            <svg class=\"h-5 w-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z\" />\n                            </svg>\n                          </button>\n                        </div>\n                      </div>\n                    </div>\n                  </div>\n                </div>\n\n                <!-- 筛选区域 -->\n                <div v-if=\"comments.length > 0 || commentLoading\" class=\"mb-6 bg-transparent\">\n                  <div class=\"mb-4\">\n                    <!-- 总评论数显示 -->\n                    <div class=\"mb-3 flex items-center text-sm text-gray-600\">\n                      <span>共</span>\n                      <span class=\"mx-1 text-[#fb7299] font-medium\">{{ commentTotal }}</span>\n                      <span>条评论</span>\n                    </div>\n\n                    <div class=\"flex flex-nowrap items-center space-x-2\">\n                      <!-- 关键词搜索 -->\n                      <div class=\"flex-1 min-w-0\">\n                        <div class=\"relative\">\n                          <div class=\"flex h-9 items-center rounded-md border border-gray-300 dark:border-gray-600 bg-transparent focus-within:border-[#fb7299] transition-colors duration-200\">\n                            <!-- 搜索图标 -->\n                            <div class=\"pl-3 text-gray-400\">\n                              <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-3.5 w-3.5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z\" />\n                              </svg>\n                            </div>\n\n                            <!-- 输入框 -->\n                            <input\n                              v-model=\"commentKeyword\"\n                              type=\"search\"\n                              placeholder=\"搜索评论内容...\"\n                              class=\"h-full w-full border-none bg-transparent px-2 pr-3 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-0 text-xs leading-none\"\n                              @keyup.enter=\"handleCommentSearch\"\n                            />\n                          </div>\n                        </div>\n                      </div>\n\n                      <!-- 评论类型筛选 -->\n                      <div class=\"w-24 flex-shrink-0\">\n                        <div class=\"relative\">\n                          <button\n                            @click=\"toggleCommentTypeDropdown\"\n                            type=\"button\"\n                            class=\"w-full py-1.5 px-2 border border-gray-300 dark:border-gray-600 rounded-md text-xs text-gray-800 dark:text-gray-200 bg-transparent focus:border-[#fb7299] focus:outline-none focus:ring focus:ring-[#fb7299]/20 flex items-center justify-between transition-colors duration-200 h-9 whitespace-nowrap overflow-hidden\"\n                          >\n                            <span class=\"truncate mr-1\">{{ getCommentTypeText(commentType) }}</span>\n                            <svg class=\"w-3 h-3 text-[#fb7299] flex-shrink-0\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 9l-7 7-7-7\" />\n                            </svg>\n                          </button>\n\n                          <!-- 评论类型下拉菜单 -->\n                          <div\n                            v-if=\"showCommentTypeDropdown\"\n                            class=\"absolute z-10 mt-1 w-full rounded-md bg-white dark:bg-gray-800 shadow-lg ring-1 ring-black ring-opacity-5 dark:ring-white/10 focus:outline-none\"\n                          >\n                            <div class=\"py-1\">\n                              <button\n                                v-for=\"option in commentTypeOptions\"\n                                :key=\"option.value\"\n                                @click=\"selectCommentType(option.value)\"\n                                class=\"w-full px-2 py-1 text-xs text-left hover:bg-[#fb7299]/5 hover:text-[#fb7299] transition-colors flex items-center whitespace-nowrap\"\n                                :class=\"{'text-[#fb7299] bg-[#fb7299]/5 font-medium': commentType === option.value}\"\n                              >\n                                {{ option.label }}\n                              </button>\n                            </div>\n                          </div>\n                        </div>\n                      </div>\n\n                      <!-- 内容类型筛选 -->\n                      <div class=\"w-24 flex-shrink-0\">\n                        <div class=\"relative\">\n                          <button\n                            @click=\"toggleContentTypeDropdown\"\n                            type=\"button\"\n                            class=\"w-full py-1.5 px-2 border border-gray-300 dark:border-gray-600 rounded-md text-xs text-gray-800 dark:text-gray-200 bg-transparent focus:border-[#fb7299] focus:outline-none focus:ring focus:ring-[#fb7299]/20 flex items-center justify-between transition-colors duration-200 h-9 whitespace-nowrap overflow-hidden\"\n                          >\n                            <span class=\"truncate mr-1\">{{ getContentTypeText(contentTypeFilter) }}</span>\n                            <svg class=\"w-3 h-3 text-[#fb7299] flex-shrink-0\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 9l-7 7-7-7\" />\n                            </svg>\n                          </button>\n\n                          <!-- 内容类型下拉菜单 -->\n                          <div\n                            v-if=\"showContentTypeDropdown\"\n                            class=\"absolute z-10 mt-1 w-full rounded-md bg-white dark:bg-gray-800 shadow-lg ring-1 ring-black ring-opacity-5 dark:ring-white/10 focus:outline-none\"\n                          >\n                            <div class=\"py-1\">\n                              <button\n                                v-for=\"option in contentTypeOptions\"\n                                :key=\"option.value\"\n                                @click=\"selectContentType(option.value)\"\n                                class=\"w-full px-2 py-1 text-xs text-left hover:bg-[#fb7299]/5 hover:text-[#fb7299] transition-colors flex items-center whitespace-nowrap\"\n                                :class=\"{'text-[#fb7299] bg-[#fb7299]/5 font-medium': contentTypeFilter === option.value}\"\n                              >\n                                {{ option.label }}\n                              </button>\n                            </div>\n                          </div>\n                        </div>\n                      </div>\n                    </div>\n                  </div>\n                </div>\n\n                <!-- 评论列表 -->\n                <div class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden\">\n                  <!-- 评论项 -->\n                  <div v-if=\"!commentLoading && comments.length > 0\" class=\"divide-y divide-gray-100\">\n                    <div v-for=\"comment in comments\" :key=\"comment.rpid\" class=\"p-4 md:p-6\">\n                      <div class=\"space-y-2\">\n                        <!-- 评论内容 -->\n                        <p class=\"text-gray-800 text-sm md:text-base whitespace-pre-wrap leading-relaxed\">{{ comment.message }}</p>\n\n                        <!-- 评论元数据 -->\n                        <div class=\"flex items-center justify-between text-xs text-gray-500\">\n                          <div class=\"flex items-center space-x-3\">\n                            <span :class=\"comment.type === 1 ? 'text-[#fb7299]' : 'text-[#fb7299]'\">\n                              {{ getCommentTypeDisplay(comment.type) }}\n                            </span>\n                            <span>{{ comment.time_str }}</span>\n                          </div>\n\n                          <a\n                            :href=\"getCommentLink(comment)\"\n                            target=\"_blank\"\n                            class=\"text-[#fb7299] hover:text-[#fb7299]/80 transition-colors\"\n                          >\n                            查看原文 →\n                          </a>\n                        </div>\n                      </div>\n                    </div>\n                  </div>\n\n                  <!-- 加载状态 -->\n                  <div v-if=\"commentLoading\" class=\"flex justify-center items-center py-16\">\n                    <div class=\"flex flex-col items-center\">\n                      <div class=\"animate-spin h-8 w-8 border-3 border-[#fb7299] border-t-transparent rounded-full\"></div>\n                      <p class=\"text-gray-500 text-sm mt-4\">加载评论中...</p>\n                    </div>\n                  </div>\n\n                  <!-- 空状态 -->\n                  <div v-if=\"!commentLoading && comments.length === 0\" class=\"flex justify-center items-center py-16\">\n                    <div class=\"flex flex-col items-center\">\n                      <div class=\"bg-[#fb7299]/5 rounded-full p-3 mb-3\">\n                        <svg class=\"w-8 h-8 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                          <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.5\" d=\"M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z\" />\n                        </svg>\n                      </div>\n                      <p class=\"text-gray-600 dark:text-gray-300 font-medium\">暂无评论数据</p>\n                      <p v-if=\"hasActiveCommentFilters\" class=\"text-gray-500 dark:text-gray-400 text-sm mt-1 text-center max-w-sm\">\n                        尝试调整搜索条件\n                      </p>\n                      <button\n                        v-if=\"hasActiveCommentFilters\"\n                        @click=\"clearCommentFilters\"\n                        class=\"mt-4 px-4 py-2 text-white bg-[#fb7299] hover:bg-[#fb7299]/90 rounded-md text-sm transition-colors\"\n                      >\n                        清除筛选\n                      </button>\n                    </div>\n                  </div>\n                </div>\n\n                <!-- 分页控件 -->\n                <div v-if=\"commentTotalPages > 0\" class=\"mt-6 flex justify-center\">\n                  <div class=\"mx-auto mb-5 mt-8 max-w-4xl lm:text-xs\">\n                    <div class=\"flex justify-between items-center space-x-4 lm:mx-5\">\n                      <button\n                        @click=\"handleCommentPageChange(commentCurrentPage - 1)\"\n                        :disabled=\"commentCurrentPage === 1\"\n                        class=\"flex items-center text-gray-500 hover:text-[#fb7299] disabled:opacity-40 disabled:cursor-not-allowed transition-colors px-3 py-2\"\n                      >\n                        <svg class=\"w-5 h-5 mr-1\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                          <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 19l-7-7 7-7\" />\n                        </svg>\n                        <span class=\"hidden sm:inline\">上一页</span>\n                      </button>\n\n                      <div class=\"flex items-center text-gray-700 lm:text-xs\">\n                        <div class=\"relative mx-1 inline-block\">\n                          <input\n                            type=\"number\"\n                            v-model=\"commentPageInput\"\n                            @keyup.enter=\"handleCommentJumpPage\"\n                            @blur=\"handleCommentJumpPage\"\n                            @focus=\"$event.target.select()\"\n                            min=\"1\"\n                            :max=\"commentTotalPages\"\n                            class=\"h-8 w-12 rounded border border-gray-200 dark:border-gray-600 px-2 text-center text-gray-700 dark:text-gray-200 bg-transparent transition-colors [appearance:textfield] hover:border-[#fb7299] focus:border-[#fb7299] focus:outline-none focus:ring-1 focus:ring-[#fb7299]/30 lm:h-6 lm:w-10 lm:text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none\"\n                          />\n                        </div>\n                        <span class=\"text-gray-500 mx-1\">/ {{ commentTotalPages }}</span>\n                      </div>\n\n                      <button\n                        @click=\"handleCommentPageChange(commentCurrentPage + 1)\"\n                        :disabled=\"commentCurrentPage === commentTotalPages\"\n                        class=\"flex items-center text-gray-500 hover:text-[#fb7299] disabled:opacity-40 disabled:cursor-not-allowed transition-colors px-3 py-2\"\n                      >\n                        <span class=\"hidden sm:inline\">下一页</span>\n                        <svg class=\"w-5 h-5 ml-1\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                          <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 5l7 7-7 7\" />\n                        </svg>\n                      </button>\n                    </div>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, watch, computed } from 'vue'\nimport { useRoute } from 'vue-router'\nimport VideoDownloader from './VideoDownloader.vue'\nimport { showNotify } from 'vant'\nimport 'vant/es/notify/style'\nimport { getVideoSeasonInfo, getComments } from '../../../api/api'\nimport { normalizeImageUrl } from '@/utils/imageUrl.js'\n\nconst route = useRoute()\n\n// 当前激活的标签\nconst activeTab = ref('video-stats')\n\n// 监听路由变化以更新激活的标签\nwatch(\n  () => route.query.tab,\n  (tab) => {\n    if (tab && ['video-stats', 'video-download', 'comment-query'].includes(tab)) {\n      activeTab.value = tab\n    }\n  },\n  { immediate: true }\n)\n\n// 组件挂载时根据URL初始化标签\nonMounted(() => {\n  const { tab } = route.query\n  if (tab && ['video-stats', 'video-download', 'comment-query'].includes(tab)) {\n    activeTab.value = tab\n  }\n})\n\n// 视频观看时长数据\nconst bvid = ref('')\nconst videoStats = ref(null)\nconst loading = ref(false)\nconst useSessdata = ref(true)\n\n// 排序状态\nconst sortKey = ref('vt')  // 默认按总观看时长排序\nconst sortDirection = ref('desc')  // 默认降序\n\n// 查询的视频信息\nconst queriedVideo = computed(() => {\n  if (!videoStats.value || !videoStats.value.videos || !bvid.value) return null\n  return videoStats.value.videos.find(v => v.bvid.toLowerCase() === bvid.value.toLowerCase())\n})\n\n// 计算合集总观看时长\nconst totalWatchTime = computed(() => {\n  if (!videoStats.value || !videoStats.value.videos) return 0\n  return videoStats.value.videos.reduce((total, video) => total + video.vt, 0)\n})\n\n// 排序后的视频列表\nconst sortedVideos = computed(() => {\n  if (!videoStats.value || !videoStats.value.videos) return []\n\n  const videos = [...videoStats.value.videos]\n\n  return videos.sort((a, b) => {\n    let aValue, bValue\n\n    if (sortKey.value === 'avgWatchTime') {\n      aValue = a.vt / a.vv\n      bValue = b.vt / b.vv\n    } else if (sortKey.value === 'finishRate') {\n      // 计算完播率：平均观看时长(秒) / 视频时长(秒) * 100\n      aValue = Math.min(100, Math.round(((a.vt * 60) / a.vv) / a.duration * 100))\n      bValue = Math.min(100, Math.round(((b.vt * 60) / b.vv) / b.duration * 100))\n    } else {\n      aValue = a[sortKey.value]\n      bValue = b[sortKey.value]\n    }\n\n    if (sortDirection.value === 'asc') {\n      return aValue - bValue\n    } else {\n      return bValue - aValue\n    }\n  })\n})\n\n// 切换排序\nconst sortBy = (key) => {\n  if (sortKey.value === key) {\n    // 如果已经是按这个键排序，则切换方向\n    sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'\n  } else {\n    // 否则切换排序字段，默认降序\n    sortKey.value = key\n    sortDirection.value = 'desc'\n  }\n}\n\n// 防抖函数\nlet timeout = null\nconst debouncedFetchVideoStats = () => {\n  clearTimeout(timeout)\n  timeout = setTimeout(() => {\n    if (bvid.value.trim().length >= 10) {  // BV号至少10个字符\n      fetchVideoStats()\n    }\n  }, 800)  // 800ms延迟\n}\n\n// 获取视频观看时长信息\nconst fetchVideoStats = async () => {\n  if (!bvid.value || bvid.value.trim().length < 10) {\n    return\n  }\n\n  loading.value = true\n  videoStats.value = null\n\n  try {\n    // 使用api.js中定义的方法\n    const response = await getVideoSeasonInfo({\n      bvid: bvid.value.trim(),\n      use_sessdata: useSessdata.value\n    })\n    videoStats.value = response.data\n\n    // 初始设置合适的排序\n    if (videoStats.value && videoStats.value.status === 'success') {\n      sortKey.value = 'vt'\n      sortDirection.value = 'desc'\n    }\n  } catch (error) {\n    console.error('获取视频观看时长信息失败:', error)\n    videoStats.value = {\n      status: 'error',\n      message: error.response?.data?.message || '获取视频信息失败，请检查网络连接或稍后重试'\n    }\n  } finally {\n    loading.value = false\n  }\n}\n\n// 格式化数字\nconst formatNumber = (num) => {\n  return new Intl.NumberFormat('zh-CN').format(num)\n}\n\n// 格式化时长 (分钟 -> mm:ss)\nconst formatDuration = (minutes) => {\n  // 视频时长仍然是秒级的，所以保持不变\n  const mins = Math.floor(minutes / 60)\n  const remainingSeconds = minutes % 60\n  return `${mins}:${remainingSeconds.toString().padStart(2, '0')}`\n}\n\n// 格式化时长 (分钟 -> 小时)\nconst formatDurationHours = (minutes) => {\n  // 后端返回的是分钟级别的数据\n  const hours = Math.floor(minutes / 60)\n  const remainingMinutes = minutes % 60\n  return `${hours} 小时 ${remainingMinutes} 分钟`\n}\n\n// 格式化时长 (分钟 -> 天/月/年)\nconst formatDurationDays = (minutes) => {\n  const days = Math.floor(minutes / (60 * 24)) // 一天有 24*60 分钟\n\n  if (days >= 365) {\n    // 超过365天显示为年\n    const years = Math.floor(days / 365)\n    const remainingDays = days % 365\n    if (remainingDays > 30) {\n      const months = Math.floor(remainingDays / 30)\n      return `约 ${years} 年 ${months} 个月`\n    } else {\n      return `约 ${years} 年 ${remainingDays} 天`\n    }\n  } else if (days >= 30) {\n    // 超过30天显示为月\n    const months = Math.floor(days / 30)\n    const remainingDays = days % 30\n    return `约 ${months} 个月 ${remainingDays} 天`\n  } else if (days > 0) {\n    // 显示为天和小时\n    const hours = Math.floor((minutes % (60 * 24)) / 60)\n    return `约 ${days} 天 ${hours} 小时`\n  }\n\n  return '' // 如果不足1天，不显示\n}\n\n// 计算完播率百分比\nconst secondsToPercent = (watchedSeconds, totalSeconds) => {\n  if (!totalSeconds) return 0\n  // watchedSeconds 是从分钟转换为秒的平均观看时长\n  // totalSeconds 是视频总时长（秒）\n  return Math.min(100, Math.round((watchedSeconds / totalSeconds) * 100))\n}\n\n// ===== 评论查询功能 =====\n// 评论查询数据\nconst queryUserId = ref('')\nconst comments = ref([])\nconst commentLoading = ref(false)\nconst commentCurrentPage = ref(1)\nconst commentPageSize = ref(20)\nconst commentTotal = ref(0)\nconst commentTotalPages = ref(0)\nconst commentKeyword = ref('')\nconst commentType = ref('all')\nconst contentTypeFilter = ref('0')\nconst commentPageInput = ref('1')\n\n// 下拉菜单状态\nconst showCommentTypeDropdown = ref(false)\nconst showContentTypeDropdown = ref(false)\n\n// 下拉菜单选项数据\nconst commentTypeOptions = [\n  { value: 'all', label: '全部' },\n  { value: 'root', label: '一级' },\n  { value: 'reply', label: '二级' }\n]\n\nconst contentTypeOptions = [\n  { value: '0', label: '全部' },\n  { value: '1', label: '视频' },\n  { value: '17', label: '动态' },\n  { value: '11', label: '旧动态' }\n]\n\n// 是否有活跃的评论筛选条件\nconst hasActiveCommentFilters = computed(() => {\n  return commentKeyword.value !== '' || commentType.value !== 'all' || contentTypeFilter.value !== '0'\n})\n\n// 获取评论类型显示文本\nconst getCommentTypeText = (type) => {\n  const option = commentTypeOptions.find(opt => opt.value === type)\n  return option ? option.label : '全部'\n}\n\n// 获取内容类型显示文本\nconst getContentTypeText = (type) => {\n  const option = contentTypeOptions.find(opt => opt.value === type)\n  return option ? option.label : '全部'\n}\n\n// 获取评论类型显示文本（单个评论）\nconst getCommentTypeDisplay = (type) => {\n  switch (type) {\n    case 1:\n      return '视频评论'\n    case 11:\n    case 17:\n      return '动态评论'\n    default:\n      return '其他评论'\n  }\n}\n\n// 获取评论链接\nconst getCommentLink = (comment) => {\n  const { type, oid, rpid } = comment\n\n  switch (type) {\n    case 1: // 视频评论\n      return `https://www.bilibili.com/video/av${oid}#reply${rpid}`\n    case 11: // 动态评论类型11\n      return `https://t.bilibili.com/${oid}?type=2#reply${rpid}`\n    case 17: // 动态评论类型17\n      return `https://t.bilibili.com/${oid}#reply${rpid}`\n    default:\n      return '#'\n  }\n}\n\n// 切换评论类型下拉菜单\nconst toggleCommentTypeDropdown = () => {\n  showCommentTypeDropdown.value = !showCommentTypeDropdown.value\n  showContentTypeDropdown.value = false\n}\n\n// 切换内容类型下拉菜单\nconst toggleContentTypeDropdown = () => {\n  showContentTypeDropdown.value = !showContentTypeDropdown.value\n  showCommentTypeDropdown.value = false\n}\n\n// 选择评论类型\nconst selectCommentType = (value) => {\n  commentType.value = value\n  showCommentTypeDropdown.value = false\n  commentCurrentPage.value = 1\n  fetchUserComments()\n}\n\n// 选择内容类型\nconst selectContentType = (value) => {\n  contentTypeFilter.value = value\n  showContentTypeDropdown.value = false\n  commentCurrentPage.value = 1\n  fetchUserComments()\n}\n\n// 获取用户评论列表\nconst fetchUserComments = async () => {\n  if (!queryUserId.value) {\n    showNotify({\n      type: 'warning',\n      message: '请输入用户UID'\n    })\n    return\n  }\n\n  commentLoading.value = true\n\n  try {\n    const response = await getComments(\n      queryUserId.value,\n      commentCurrentPage.value,\n      commentPageSize.value,\n      commentType.value,\n      commentKeyword.value,\n      contentTypeFilter.value\n    )\n\n    if (response.data) {\n      comments.value = response.data.comments || []\n      commentTotal.value = response.data.total || 0\n      commentTotalPages.value = response.data.total_pages || 0\n      commentPageInput.value = commentCurrentPage.value.toString()\n    }\n  } catch (error) {\n    console.error('获取评论列表失败:', error)\n    showNotify({\n      type: 'danger',\n      message: error.response?.data?.message || '获取评论列表失败'\n    })\n    comments.value = []\n    commentTotal.value = 0\n    commentTotalPages.value = 0\n  } finally {\n    commentLoading.value = false\n  }\n}\n\n// 处理评论搜索\nconst handleCommentSearch = () => {\n  commentCurrentPage.value = 1\n  fetchUserComments()\n}\n\n// 处理评论页码变化\nconst handleCommentPageChange = (newPage) => {\n  if (newPage >= 1 && newPage <= commentTotalPages.value) {\n    commentCurrentPage.value = newPage\n    fetchUserComments()\n  }\n}\n\n// 处理评论跳转页\nconst handleCommentJumpPage = () => {\n  const targetPage = parseInt(commentPageInput.value)\n  if (!isNaN(targetPage) && targetPage >= 1 && targetPage <= commentTotalPages.value) {\n    if (targetPage !== commentCurrentPage.value) {\n      commentCurrentPage.value = targetPage\n      fetchUserComments()\n    }\n  } else {\n    commentPageInput.value = commentCurrentPage.value.toString()\n  }\n}\n\n// 清除评论筛选条件\nconst clearCommentFilters = () => {\n  commentKeyword.value = ''\n  commentType.value = 'all'\n  contentTypeFilter.value = '0'\n  commentCurrentPage.value = 1\n  fetchUserComments()\n}\n\n\n</script>\n\n<style scoped>\n.animate-fadeIn {\n  animation: fadeIn 0.3s ease-in-out;\n}\n\n@keyframes fadeIn {\n  from { opacity: 0; }\n  to { opacity: 1; }\n}\n</style>"
  },
  {
    "path": "src/components/tailwind/page/Comments.vue",
    "content": "<template>\n  <div>\n        <!-- 致谢 -->\n        <div class=\"mb-4\">\n          <div class=\"bg-[#fb7299]/5 dark:bg-pink-900/20 border border-[#fb7299]/20 dark:border-[#fb7299]/30 px-3 py-0 rounded-md w-full flex items-center justify-center h-9\">\n            <img src=\"https://www.aicu.cc/favicon.ico\" alt=\"AICU\" class=\"w-3 h-3 mr-1.5\" />\n            <div class=\"flex items-center\">\n              <span class=\"text-xs text-gray-700 dark:text-gray-300\">\n                感谢 <a href=\"https://www.aicu.cc/\" target=\"_blank\" class=\"text-[#fb7299] hover:text-[#fb7299]/80 font-medium\">aicu.cc</a> 开放API\n              </span>\n              <span class=\"mx-1.5 text-gray-300 dark:text-gray-600\">|</span>\n              <div class=\"flex items-center text-xs text-gray-500 dark:text-gray-400\">\n                <svg class=\"w-3 h-3 text-[#fb7299] mr-1\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z\" />\n                </svg>\n                <span>非官方API，数据不是实时更新</span>\n                <a href=\"https://www.aicu.cc/help.html?id=11\" target=\"_blank\" class=\"text-[#fb7299] hover:text-[#fb7299]/80 ml-1 font-medium\">\n                  了解更多\n                </a>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <!-- 搜索和筛选 -->\n        <div class=\"mb-6 bg-transparent\">\n      <div class=\"mb-4\">\n            <!-- 总评论数显示 -->\n            <div class=\"mb-3 flex items-center text-sm text-gray-600\">\n              <span>共</span>\n              <span class=\"mx-1 text-[#fb7299] font-medium\">{{ total }}</span>\n              <span>条评论</span>\n            </div>\n\n            <div class=\"flex flex-nowrap items-center space-x-2\">\n              <!-- 关键词搜索 - 改为与首页一致的样式 -->\n              <div class=\"flex-1 min-w-0\">\n                <SimpleSearchBar\n                  v-model=\"keyword\"\n                  placeholder=\"搜索评论内容...\"\n                  @search=\"handleSearch\"\n                  class=\"w-full\"\n                />\n              </div>\n\n              <!-- 评论类型筛选 -->\n              <div class=\"w-24 flex-shrink-0\">\n                <CustomDropdown\n                  v-model=\"commentType\"\n                  :options=\"commentTypeOptions\"\n                  :selected-text=\"getCommentTypeText(commentType)\"\n                  @change=\"selectCommentType\"\n                  :min-width=\"80\"\n                  :use-fixed-width=\"true\"\n                  custom-class=\"h-9\"\n                />\n              </div>\n\n              <!-- 内容类型筛选 -->\n              <div class=\"w-24 flex-shrink-0\">\n                <CustomDropdown\n                  v-model=\"typeFilter\"\n                  :options=\"contentTypeOptions\"\n                  :selected-text=\"getContentTypeText(typeFilter)\"\n                  @change=\"selectContentType\"\n                  :min-width=\"80\"\n                  :use-fixed-width=\"true\"\n                  custom-class=\"h-9\"\n                />\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <!-- 评论列表 -->\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden\">\n          <!-- 评论项 -->\n          <div v-if=\"!loading && comments.length > 0\" class=\"divide-y divide-gray-100 dark:divide-gray-700\">\n            <div v-for=\"comment in comments\" :key=\"comment.rpid\" class=\"p-4 md:p-6\">\n              <div class=\"space-y-2\">\n                <!-- 评论内容 -->\n                <p class=\"text-gray-800 dark:text-gray-200 text-sm md:text-base whitespace-pre-wrap leading-relaxed\">{{ comment.message }}</p>\n\n                <!-- 评论元数据 -->\n                <div class=\"flex items-center justify-between text-xs text-gray-500 dark:text-gray-400\">\n                  <div class=\"flex items-center space-x-3\">\n                    <span :class=\"comment.type === 1 ? 'text-[#fb7299]' : 'text-[#fb7299]'\">\n                      {{ getCommentType(comment.type) }}\n                    </span>\n                    <span>{{ comment.time_str }}</span>\n                  </div>\n\n                  <a\n                    :href=\"getCommentLink(comment)\"\n                    target=\"_blank\"\n                    class=\"text-[#fb7299] hover:text-[#fb7299]/80 transition-colors\"\n                  >\n                    查看原文 →\n                  </a>\n                </div>\n              </div>\n            </div>\n          </div>\n\n          <!-- 加载状态 -->\n          <div v-if=\"loading\" class=\"flex justify-center items-center py-16\">\n            <div class=\"flex flex-col items-center\">\n              <div class=\"animate-spin h-8 w-8 border-3 border-[#fb7299] border-t-transparent rounded-full\"></div>\n              <p class=\"text-gray-500 text-sm mt-4\">加载评论中...</p>\n            </div>\n          </div>\n\n          <!-- 空状态 -->\n          <div v-if=\"!loading && comments.length === 0\" class=\"flex justify-center items-center py-16\">\n            <div class=\"flex flex-col items-center\">\n              <div class=\"bg-[#fb7299]/5 rounded-full p-3 mb-3\">\n                <svg class=\"w-8 h-8 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.5\" d=\"M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z\" />\n                </svg>\n              </div>\n              <p class=\"text-gray-600 dark:text-gray-300 font-medium\">暂无评论数据</p>\n              <p v-if=\"hasActiveFilters\" class=\"text-gray-500 dark:text-gray-400 text-sm mt-1 text-center max-w-sm\">\n                尝试调整搜索条件\n              </p>\n              <button\n                v-if=\"hasActiveFilters\"\n                @click=\"clearFilters\"\n                class=\"mt-4 px-4 py-2 text-white bg-[#fb7299] hover:bg-[#fb7299]/90 rounded-md text-sm transition-colors\"\n              >\n                清除筛选\n              </button>\n            </div>\n          </div>\n        </div>\n\n        <!-- 分页控件 -->\n        <div v-if=\"totalPages > 0\" class=\"mt-6 flex justify-center\">\n          <Pagination\n            :current-page=\"currentPage\"\n            :total-pages=\"totalPages\"\n            @page-change=\"handlePageChange\"\n          />\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, watch, computed, onUnmounted } from 'vue'\nimport { getComments, getLoginStatus } from '../../../api/api'\nimport { showNotify } from 'vant'\nimport { useRouter } from 'vue-router'\nimport Pagination from '../Pagination.vue'\nimport 'vant/es/notify/style'\nimport SimpleSearchBar from '../SimpleSearchBar.vue'\nimport CustomDropdown from '../CustomDropdown.vue'\n\nconst router = useRouter()\nconst comments = ref([])\nconst loading = ref(false)\nconst currentPage = ref(1)\nconst pageSize = ref(20)\nconst total = ref(0)\nconst totalPages = ref(0)\nconst userInfo = ref(null)\nconst keyword = ref('')\nconst commentType = ref('all')\nconst typeFilter = ref('0')\n\n// 获取用户信息\nconst getUserInfo = async () => {\n  try {\n    const response = await getLoginStatus()\n    // 新的API响应格式是 {code: 0, message: \"0\", ttl: 1, data: {...}}\n    // code为0表示请求成功\n    if (response.data && response.data.code === 0) {\n      const userData = response.data.data\n      if (userData.isLogin) {\n        userInfo.value = userData\n        return true\n      }\n    }\n    \n    // 如果未登录，显示提示\n    showNotify({\n      type: 'warning',\n      message: '请先登录后查看评论'\n    })\n    router.push('/')\n    return false\n  } catch (error) {\n    console.error('获取用户信息失败:', error)\n    showNotify({\n      type: 'danger',\n      message: '获取用户信息失败'\n    })\n    return false\n  }\n}\n\n// 下拉菜单选项数据\nconst commentTypeOptions = [\n  { value: 'all', label: '全部' },\n  { value: 'root', label: '一级' },\n  { value: 'reply', label: '二级' }\n]\n\nconst contentTypeOptions = [\n  { value: '0', label: '全部' },\n  { value: '1', label: '视频' },\n  { value: '17', label: '动态' },\n  { value: '11', label: '旧动态' }\n]\n\n// 计算下拉菜单按钮宽度 - 响应式设计\nconst windowWidth = ref(window.innerWidth)\ncomputed(() => {\n  // 检测是否为移动设备\n  const isMobile = windowWidth.value < 768\n  return isMobile ? '100%' : 100\n})\n// 处理窗口大小变化\nconst handleWindowResize = () => {\n  windowWidth.value = window.innerWidth\n}\n\n// 处理页码变化\nconst handlePageChange = (newPage) => {\n  currentPage.value = newPage\n}\n\n// 获取评论列表\nconst fetchComments = async () => {\n  if (!userInfo.value?.mid) {\n    const hasUser = await getUserInfo()\n    if (!hasUser) return\n  }\n\n  loading.value = true\n  try {\n    const response = await getComments(\n      userInfo.value.mid,\n      currentPage.value,\n      pageSize.value,\n      commentType.value,\n      keyword.value,\n      typeFilter.value\n    )\n\n    if (response.data) {\n      comments.value = response.data.comments\n      total.value = response.data.total\n      totalPages.value = response.data.total_pages\n    }\n  } catch (error) {\n    console.error('获取评论列表失败:', error)\n    showNotify({\n      type: 'danger',\n      message: '获取评论列表失败'\n    })\n  } finally {\n    loading.value = false\n  }\n}\n\n// 获取评论类型显示文本\nconst getCommentType = (type) => {\n  switch (type) {\n    case 1:\n      return '视频评论'\n    case 11:\n    case 17:\n      return '动态评论'\n    default:\n      return '其他评论'\n  }\n}\n\n// 获取评论链接\nconst getCommentLink = (comment) => {\n  const { type, oid, rpid } = comment\n\n  switch (type) {\n    case 1: // 视频评论\n      return `https://www.bilibili.com/video/av${oid}#reply${rpid}`\n    case 11: // 动态评论类型11\n      return `https://t.bilibili.com/${oid}?type=2#reply${rpid}`\n    case 17: // 动态评论类型17\n      return `https://t.bilibili.com/${oid}#reply${rpid}`\n    default:\n      return '#'\n  }\n}\n\n// 监听分页变化\nwatch(currentPage, () => {\n  fetchComments()\n})\n\n// 监听筛选条件变化\nwatch([commentType, typeFilter], () => {\n  currentPage.value = 1\n  fetchComments()\n})\n\n// 在组件挂载时添加事件监听器和获取数据\nonMounted(async () => {\n  window.addEventListener('resize', handleWindowResize)\n  const hasUser = await getUserInfo()\n  if (hasUser) {\n    fetchComments()\n  }\n})\n\n// 在组件卸载时移除事件监听器\nonUnmounted(() => {\n  window.removeEventListener('resize', handleWindowResize)\n})\n\n// 处理搜索\nconst handleSearch = () => {\n  if (keyword.value.trim() || hasActiveFilters.value) {\n    currentPage.value = 1\n    fetchComments()\n  }\n}\n\n// 获取评论类型文本\nconst getCommentTypeText = (type) => {\n  switch (type) {\n    case 'root':\n      return '一级'\n    case 'reply':\n      return '二级'\n    default:\n      return '全部'\n  }\n}\n\n// 获取内容类型文本\nconst getContentTypeText = (type) => {\n  switch (type) {\n    case '1':\n      return '视频'\n    case '17':\n      return '动态'\n    case '11':\n      return '旧动态'\n    case '0':\n    default:\n      return '全部'\n  }\n}\n\n// 清除筛选条件\nconst clearFilters = () => {\n  keyword.value = ''\n  commentType.value = 'all'\n  typeFilter.value = '0'\n  currentPage.value = 1\n  fetchComments()\n}\n\n// 判断是否有活动筛选条件\nconst hasActiveFilters = computed(() => {\n  return keyword.value || commentType.value !== 'all' || typeFilter.value !== '0'\n})\n\n// 选择评论类型\nconst selectCommentType = (value) => {\n  commentType.value = value\n  handleSearch()\n}\n\n// 选择内容类型\nconst selectContentType = (value) => {\n  typeFilter.value = value\n  handleSearch()\n}\n</script>\n\n<style>\n/* 这里可以添加任何需要的自定义样式 */\n</style>\n"
  },
  {
    "path": "src/components/tailwind/page/Downloads.vue",
    "content": "<template>\n  <div class=\"overflow-y-auto\">\n    <!-- 搜索框 -->\n    <div class=\"mb-6\">\n      <SimpleSearchBar\n        v-model=\"searchTerm\"\n        placeholder=\"搜索已下载的视频或目录路径...\"\n        @search=\"loadDownloadedVideos\"\n        class=\"w-full\"\n      />\n    </div>\n\n    <!-- 主要内容 -->\n    <div>\n      <!-- 下载数据加载中的占位 -->\n      <div v-if=\"isLoading\" class=\"flex flex-col items-center justify-center py-12\">\n        <svg class=\"animate-spin h-8 w-8 text-[#fb7299] mb-4\" fill=\"none\" viewBox=\"0 0 24 24\">\n          <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n          <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n        </svg>\n        <p class=\"text-gray-500 dark:text-gray-400\">加载中，请稍候...</p>\n      </div>\n\n      <!-- 没有数据时的显示 -->\n      <div v-else-if=\"!downloads.videos || downloads.videos.length === 0\" class=\"flex flex-col items-center justify-center py-12\">\n        <svg class=\"w-16 h-16 text-gray-400 mb-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n          <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.5\" d=\"M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10\" />\n        </svg>\n        <p class=\"text-xl font-medium text-gray-600 dark:text-gray-300 mb-2\">暂无下载记录</p>\n        <p class=\"text-gray-500 dark:text-gray-400 mb-4 text-center\">\n          你还没有下载任何视频，在浏览历史记录时可以点击\"下载\"按钮下载视频。\n        </p>\n      </div>\n\n      <!-- 下载视频列表 -->\n      <div v-else>\n        <p class=\"text-sm text-gray-500 dark:text-gray-400 mb-4\">\n          共找到 {{ downloads.total }} 个下载记录，当前第 {{ downloads.page }} 页，共 {{ downloads.pages }} 页\n        </p>\n\n        <div class=\"grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3\">\n          <div v-for=\"(video, index) in downloads.videos\" :key=\"index\" class=\"bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm rounded-md overflow-hidden border border-gray-200/50 dark:border-gray-700/50 hover:border-[#fb7299] hover:shadow-sm transition-all duration-200 relative group\">\n            <!-- 视频封面 -->\n            <div class=\"relative pb-[56.25%] overflow-hidden cursor-pointer group\" @click=\"handleVideoClick(video)\">\n              <img\n                :src=\"normalizeImageUrl(video.cover) || 'https://i0.hdslb.com/bfs/archive/c9e72655b7c9c9c68a30d3275313c501e68427d1.jpg'\"\n                :alt=\"video.title\"\n                class=\"absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-300\"\n                loading=\"lazy\"\n                onerror=\"this.src='https://i0.hdslb.com/bfs/archive/c9e72655b7c9c9c68a30d3275313c501e68427d1.jpg'\"\n              />\n\n              <!-- 播放按钮覆盖 -->\n              <div class=\"absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity\">\n                <button\n                  v-if=\"video.files && video.files.length > 0 && !video.files[0].is_audio_only\"\n                  @click.stop=\"playVideo(video.files[0].file_path)\"\n                  class=\"w-8 h-8 rounded-full bg-[#fb7299]/80 text-white flex items-center justify-center backdrop-blur-sm\"\n                >\n                  <svg class=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z\" />\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n                  </svg>\n                </button>\n              </div>\n\n              <!-- 文件信息标签 -->\n              <div class=\"absolute bottom-1 right-1 bg-black/60 backdrop-blur-sm px-1 py-0.5 rounded text-white text-[10px]\">\n                <div v-if=\"video.files && video.files.length > 0\">\n                  {{ video.files[0].size_mb.toFixed(1) }} MB\n                </div>\n              </div>\n\n              <!-- 删除按钮 -->\n              <div class=\"absolute right-1.5 top-1.5 z-20 hidden group-hover:flex items-center justify-center w-6 h-6 bg-[#7d7c75]/60 backdrop-blur-sm hover:bg-[#7d7c75]/80 rounded-md cursor-pointer transition-all duration-200\"\n                   @click.stop=\"handleDeleteVideo(video)\">\n                <svg class=\"w-3.5 h-3.5 text-white\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16\" />\n                </svg>\n              </div>\n\n              <!-- 多文件角标 -->\n              <div v-if=\"video.files && video.files.length > 1\"\n                   class=\"absolute left-1 top-1 rounded bg-[#fb7299] px-1 py-0.5 text-[10px] text-white\">\n                {{ video.files.length }}\n              </div>\n            </div>\n\n            <!-- 视频信息 -->\n            <div class=\"p-2 flex flex-col space-y-1\">\n              <!-- 标题 -->\n              <div class=\"line-clamp-1 text-xs text-gray-900 dark:text-gray-100 font-medium cursor-pointer\" @click=\"handleVideoClick(video)\">\n                {{ video.title }}\n              </div>\n\n              <!-- 作者信息 -->\n              <div class=\"flex items-center space-x-1\">\n                <img\n                  :src=\"normalizeImageUrl(video.author_face) || 'https://i1.hdslb.com/bfs/face/1b6f746be0d0c8324e01e618c5e85e113a8b38be.jpg'\"\n                  :alt=\"video.author_name\"\n                  class=\"w-3.5 h-3.5 rounded-full object-cover cursor-pointer\"\n                  loading=\"lazy\"\n                  onerror=\"this.src='https://i1.hdslb.com/bfs/face/1b6f746be0d0c8324e01e618c5e85e113a8b38be.jpg'\"\n                  @click.stop=\"handleAuthorClick(video)\"\n                />\n                <span class=\"text-[10px] text-gray-600 dark:text-gray-400 truncate hover:text-[#fb7299] cursor-pointer\" @click.stop=\"handleAuthorClick(video)\">\n                  {{ video.author_name || '未知UP主' }}\n                </span>\n              </div>\n\n              <!-- 下载时间 -->\n              <div class=\"flex justify-between items-center text-[10px] text-gray-500 dark:text-gray-400\">\n                <div class=\"flex items-center space-x-1\">\n                  <svg class=\"w-2.5 h-2.5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z\" />\n                  </svg>\n                  <span>{{ video.download_date }}</span>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <!-- 分页 -->\n        <div class=\"mt-8 flex justify-center\">\n          <Pagination\n            :current-page=\"currentPage\"\n            :total-pages=\"downloads.pages\"\n            @page-change=\"handlePageChange\"\n          />\n        </div>\n      </div>\n    </div>\n\n    <!-- 视频播放对话框 -->\n    <VideoPlayerDialog\n      v-model:show=\"showVideoPlayer\"\n      :video-path=\"currentVideoPath\"\n    />\n\n    <!-- 删除确认对话框 -->\n    <Teleport to=\"body\">\n      <div v-if=\"showDeleteConfirm\" class=\"fixed inset-0 z-50 flex items-center justify-center\">\n        <!-- 遮罩层 -->\n        <div class=\"fixed inset-0 bg-black/50\" @click=\"showDeleteConfirm = false\"></div>\n\n        <!-- 弹窗内容 -->\n        <div class=\"relative bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 w-[500px] z-10 p-6\">\n          <h3 class=\"text-lg font-medium text-gray-900 dark:text-gray-100 mb-4\">确认删除视频</h3>\n\n          <p class=\"text-gray-600 dark:text-gray-400 mb-4\">\n            确定要删除以下视频吗？此操作不可恢复。\n          </p>\n\n          <p class=\"font-medium text-gray-800 dark:text-gray-100 mb-2\">{{ currentVideo?.title || '未知视频' }}</p>\n\n          <!-- 显示CID和目录信息 -->\n          <div class=\"mb-3 text-sm text-gray-500\">\n            <p v-if=\"currentVideo?.cid\">CID: {{ currentVideo?.cid }}</p>\n\n            <!-- 目录信息显示 -->\n            <div class=\"mt-2\">\n              <p class=\"text-gray-600 dark:text-gray-400 mb-1\">目录路径:</p>\n              <div class=\"px-3 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md text-gray-700 dark:text-gray-300 text-sm break-all\">\n                {{ getVideoDirectory(currentVideo) || '无法获取目录路径' }}\n              </div>\n            </div>\n          </div>\n\n          <!-- 删除选项 -->\n          <div class=\"mb-4\">\n            <label class=\"flex items-center space-x-2 cursor-pointer select-none\">\n              <input\n                type=\"checkbox\"\n                v-model=\"deleteDirectory\"\n                class=\"w-4 h-4 text-[#fb7299] border-gray-300 dark:border-gray-600 rounded focus:ring-[#fb7299]\"\n              >\n              <span>同时删除整个目录（包含所有相关文件）</span>\n            </label>\n          </div>\n\n          <!-- 视频来源提示 (针对收藏夹视频) -->\n          <div v-if=\"!currentVideo?.cid && getVideoDirectory(currentVideo)\" class=\"mb-4 p-2 bg-amber-50 dark:bg-amber-900/20 rounded-md border border-amber-200 dark:border-amber-800\">\n            <p class=\"text-sm text-amber-700\">\n              <span class=\"font-medium\">提示：</span>\n              该视频可能来自收藏夹批量下载，将使用目录路径进行删除。\n            </p>\n          </div>\n\n          <!-- 按钮 -->\n          <div class=\"flex justify-end space-x-3 mt-6\">\n            <button\n              @click=\"showDeleteConfirm = false\"\n              class=\"px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700\"\n            >\n              取消\n            </button>\n            <button\n              @click=\"confirmDeleteVideo\"\n              class=\"px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700\"\n              :disabled=\"isDeleting || (!currentVideo?.cid && !getVideoDirectory(currentVideo))\"\n            >\n              {{ isDeleting ? '删除中...' : '确认删除' }}\n            </button>\n          </div>\n        </div>\n      </div>\n    </Teleport>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, computed } from 'vue'\nimport Pagination from '../Pagination.vue'\nimport VideoPlayerDialog from '../VideoPlayerDialog.vue'\nimport { getDownloadedVideos, deleteDownloadedVideo } from '../../../api/api'\nimport axios from 'axios'\nimport { showNotify } from 'vant'\nimport 'vant/es/notify/style'\nimport SimpleSearchBar from '../SimpleSearchBar.vue'\nimport { openInBrowser } from '@/utils/openUrl.js'\nimport { normalizeImageUrl } from '@/utils/imageUrl.js'\n\n// 定义组件选项\ndefineOptions({\n  name: 'Downloads'\n})\n\n// 定义API基础URL，与api.js中保持一致\nconst BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'\n\n// 状态变量\nconst isLoading = ref(true)\nconst downloads = ref({\n  videos: [],\n  total: 0,\n  page: 1,\n  limit: 20,\n  pages: 1\n})\nconst searchTerm = ref('')\nconst currentPage = ref(1)\n\n// 视频播放相关\nconst showVideoPlayer = ref(false)\nconst currentVideoPath = ref('')\n\n// 删除相关\nconst showDeleteConfirm = ref(false)\nconst currentVideo = ref(null)\nconst deleteDirectory = ref(true)\nconst isDeleting = ref(false)\n\n// 加载已下载的视频\nconst loadDownloadedVideos = async () => {\n  try {\n    isLoading.value = true\n    const response = await getDownloadedVideos(searchTerm.value, currentPage.value, 20)\n\n    if (response.data && response.data.status === 'success') {\n      downloads.value = {\n        videos: response.data.videos,\n        total: response.data.total,\n        page: response.data.page,\n        limit: response.data.limit,\n        pages: response.data.pages\n      }\n    } else {\n      console.error('获取下载视频失败:', response.data?.message || '未知错误')\n    }\n  } catch (error) {\n    console.error('请求获取下载视频列表出错:', error)\n  } finally {\n    isLoading.value = false\n  }\n}\n\n// 处理页码变化\nconst handlePageChange = (page) => {\n  currentPage.value = page\n  loadDownloadedVideos()\n}\n\n// 播放视频\nconst playVideo = (filePath) => {\n  // 先关闭播放器并重置路径\n  showVideoPlayer.value = false\n  currentVideoPath.value = ''\n\n  // 在短暂延迟后重新设置路径并打开播放器\n  setTimeout(() => {\n    currentVideoPath.value = filePath\n    showVideoPlayer.value = true\n  }, 50)\n}\n\n// 处理删除视频\nconst handleDeleteVideo = (video) => {\n  currentVideo.value = video\n  showDeleteConfirm.value = true\n  deleteDirectory.value = true\n}\n\n// 获取视频目录\nconst getVideoDirectory = (video) => {\n  // 如果视频对象已经有目录属性，直接使用\n  if (video.directory) return video.directory;\n\n  // 否则，尝试从第一个文件路径中提取目录\n  if (video.files && video.files.length > 0) {\n    const filePath = video.files[0].file_path;\n    if (filePath) {\n      // 提取目录路径 (去掉文件名)\n      const lastSlashIndex = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\\\'));\n      if (lastSlashIndex !== -1) {\n        return filePath.substring(0, lastSlashIndex);\n      }\n    }\n  }\n  return null;\n}\n\n// 提取短目录名\nconst getShortDirectory = (directory) => {\n  if (!directory) return '';\n  // 获取最后一级目录名\n  const parts = directory.split(/[\\/\\\\]/);\n  const lastPart = parts[parts.length - 1];\n\n  // 如果路径太长，截断显示\n  if (directory.length > 40) {\n    return '...' + directory.substring(directory.length - 40);\n  }\n  return directory;\n}\n\n// 确认删除视频\nconst confirmDeleteVideo = async () => {\n  try {\n    isDeleting.value = true\n\n    // 确定目录路径\n    const directory = getVideoDirectory(currentVideo.value)\n\n    // 自动判断是否使用CID\n    // 如果有CID则使用CID，否则使用目录路径\n    const cid = currentVideo.value?.cid || null\n\n    // 如果既没有CID也没有目录路径，则无法删除\n    if (!cid && !directory) {\n      showNotify({\n        type: 'warning',\n        message: '无法获取视频信息，删除失败'\n      })\n      isDeleting.value = false\n      return\n    }\n\n    const response = await deleteDownloadedVideo(cid, deleteDirectory.value, directory)\n\n    if (response.data && response.data.status === 'success') {\n      showNotify({\n        type: 'success',\n        message: response.data.message\n      })\n\n      // 关闭对话框\n      showDeleteConfirm.value = false\n\n      // 重新加载列表\n      await loadDownloadedVideos()\n    } else {\n      throw new Error(response.data?.message || '删除视频失败')\n    }\n  } catch (error) {\n    showNotify({\n      type: 'danger',\n      message: error.response?.data?.message || error.message || '删除视频失败'\n    })\n  } finally {\n    isDeleting.value = false\n  }\n}\n\n// 点击视频跳转到B站\nconst handleVideoClick = async (video) => {\n  // 构建视频链接，使用bvid而不是cid\n  const url = `https://www.bilibili.com/video/${video.bvid}`\n  // 在系统默认浏览器中打开\n  await openInBrowser(url)\n}\n\n// 点击作者头像或名称跳转到作者页面\nconst handleAuthorClick = async (video) => {\n  if (video.author_mid) {\n    const url = `https://space.bilibili.com/${video.author_mid}`\n    await openInBrowser(url)\n  }\n}\n\n// 组件挂载时加载数据\nonMounted(() => {\n  loadDownloadedVideos()\n})\n</script>\n\n<style scoped>\n.line-clamp-2 {\n  display: -webkit-box;\n  -webkit-box-orient: vertical;\n  -webkit-line-clamp: 2;\n  overflow: hidden;\n}\n\n.group:hover .group-hover\\:block {\n  display: block;\n}\n</style>"
  },
  {
    "path": "src/components/tailwind/page/DynamicDownloader.vue",
    "content": "<template>\n  <div class=\"space-y-4\">\n    <!-- 输入与操作 -->\n    <div class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4\">\n      <div class=\"flex items-center space-x-3\">\n        <div class=\"flex-1\">\n          <SimpleSearchBar\n            v-model=\"inputMid\"\n            placeholder=\"输入用户 MID\"\n            @search=\"triggerQueryNow\"\n            class=\"w-full\"\n          />\n        </div>\n        <button\n          class=\"px-4 py-2 bg-green-600 text-white rounded-md hover:opacity-90 disabled:opacity-50\"\n          :disabled=\"!canStartDownload\"\n          @click=\"handleStartDownload\"\n        >下载</button>\n        <button\n          class=\"px-4 py-2 bg-gray-700 text-white rounded-md hover:opacity-90 disabled:opacity-50\"\n          :disabled=\"!downloading\"\n          @click=\"handleStop\"\n        >停止</button>\n      </div>\n    </div>\n\n    <!-- 用户信息卡片 -->\n    <div v-if=\"hostInfo\" class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 flex items-center space-x-4\">\n      <img :src=\"hostFaceUrl\" alt=\"face\" class=\"w-14 h-14 rounded-full object-cover border\" />\n      <div class=\"flex-1 min-w-0\">\n        <div class=\"text-base font-medium truncate\">{{ hostInfo.up_name || `UID ${hostInfo.host_mid}` }}</div>\n        <div class=\"text-xs text-gray-500 dark:text-gray-400\">动态数：{{ hostInfo.item_count }} · 核心数：{{ hostInfo.core_count }}</div>\n        <div class=\"text-xs text-gray-400 dark:text-gray-500\">最近发布时间：{{ formatTs(hostInfo.last_publish_ts) }} · 最近抓取：{{ formatTs(hostInfo.last_fetch_time) }}</div>\n      </div>\n      <button\n        class=\"px-3 py-1.5 bg-green-600 text-white rounded-md hover:opacity-90 disabled:opacity-50\"\n        :disabled=\"!canStartDownload\"\n        @click=\"handleStartDownload\"\n      >下载</button>\n      <button\n        class=\"ml-2 px-3 py-1.5 bg-red-600 text-white rounded-md hover:opacity-90 disabled:opacity-50\"\n        :disabled=\"!hostMid || downloading || deleting\"\n        @click=\"handleDeleteHost\"\n      >删除</button>\n    </div>\n\n    <!-- 已抓取的UP列表 -->\n    <div class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4\">\n      <div class=\"flex items-center justify-between mb-3\">\n        <div class=\"text-sm font-medium\">已抓取的UP</div>\n        <button class=\"text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300\" @click=\"loadHosts\">刷新</button>\n      </div>\n      <div v-if=\"hostsLoading\" class=\"text-xs text-gray-400\">加载中...</div>\n      <div v-else>\n        <div v-if=\"hosts.length\" class=\"grid gap-4 grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3\">\n          <div v-for=\"h in hosts\" :key=\"h.host_mid\" class=\"group border rounded-md p-2 flex items-center space-x-2 hover:border-[#fb7299] cursor-pointer dark:border-gray-700\"\n               @click=\"selectHost(h.host_mid)\">\n            <img :src=\"h.face_path ? toStaticUrl(h.face_path) : ''\" class=\"w-9 h-9 rounded-full object-cover border\" alt=\"face\" />\n            <div class=\"min-w-0 flex-1\">\n              <div class=\"text-xs font-medium truncate\">{{ h.up_name || h.host_mid }}</div>\n              <div class=\"text-[11px] text-gray-500 truncate\">动态：{{ h.item_count }} · 抓取：{{ formatTs(h.last_fetch_time) }}</div>\n            </div>\n          </div>\n        </div>\n        <div v-else class=\"text-xs text-gray-400\">暂无数据</div>\n      </div>\n    </div>\n\n    <!-- SSE 实时日志：下载中或有历史日志时显示 -->\n    <div v-if=\"downloading || logs.length\" class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4\">\n      <div class=\"flex items-center justify-between\">\n        <div class=\"text-sm font-medium\">实时日志</div>\n        <button class=\"text-xs text-gray-500 hover:text-gray-700\" @click=\"logs=[]\">清空</button>\n      </div>\n      <div ref=\"logsContainer\" class=\"mt-2 h-40 overflow-auto bg-gray-50 dark:bg-gray-900 border dark:border-gray-700 rounded p-2 text-xs whitespace-pre-wrap\">\n        <template v-if=\"logs.length\">\n          <div v-for=\"(line, idx) in logs\" :key=\"idx\" class=\"text-gray-700 dark:text-gray-300\">{{ line }}</div>\n        </template>\n        <div v-else class=\"text-gray-400 dark:text-gray-500\">暂无日志</div>\n      </div>\n    </div>\n\n    <!-- 已下载动态（数据库读取） -->\n    <div v-if=\"hostMid && items.length\" class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4\">\n      <div class=\"text-sm font-medium mb-3\">已下载动态</div>\n      <div class=\"space-y-3\">\n        <div v-for=\"it in items\" :key=\"it.id_str\">\n          <component\n            :is=\"isVideoDynamic(it) ? DynamicCardVideo : DynamicCardNormal\"\n            :item=\"it\"\n            :face-url=\"hostFaceUrl\"\n          />\n        </div>\n      </div>\n      <div class=\"mt-3 flex justify-center\">\n        <button class=\"px-3 py-1.5 text-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 disabled:opacity-50\" :disabled=\"loadingMore || noMore\" @click=\"loadMore\">\n          {{ noMore ? '没有更多了' : (loadingMore ? '加载中...' : '加载更多') }}\n        </button>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, onUnmounted, watch, nextTick } from 'vue'\nimport { showDialog } from 'vant'\nimport 'vant/es/dialog/style'\nimport { toStaticUrl } from '@/utils/imageUrl'\nimport DynamicCardVideo from '@/components/tailwind/dynamic/DynamicCardVideo.vue'\nimport DynamicCardNormal from '@/components/tailwind/dynamic/DynamicCardNormal.vue'\nimport SimpleSearchBar from '@/components/tailwind/SimpleSearchBar.vue'\nimport {\n  getDynamicDbHosts,\n  getDynamicDbSpace,\n  startDynamicAutoFetch,\n  createDynamicProgressSSE,\n  stopDynamicAutoFetch,\n  deleteDynamicSpace\n} from '@/api/api'\n\n// 输入 mid\nconst inputMid = ref('')\nconst hostMid = ref('')\n\n// 主机信息与头像\nconst hostInfo = ref(null)\nconst hostFaceUrl = computed(() => hostInfo.value?.face_path ? toStaticUrl(hostInfo.value.face_path) : '')\n\n// UP 列表\nconst hosts = ref([])\nconst hostsLoading = ref(false)\n\n// 列表 & 分页\nconst items = ref([])\nconst limit = ref(20)\nconst offset = ref(0)\nconst total = ref(0)\nconst loadingMore = ref(false)\nconst noMore = ref(false)\n\n// 下载状态与 SSE\nconst downloading = ref(false)\nconst deleting = ref(false)\nlet sse = null\nconst logs = ref([])\nlet queryTimer = null\nconst logsContainer = ref(null)\n\nconst canStartDownload = computed(() => !!hostMid.value && !downloading.value)\n\nconst formatTs = (ts) => {\n  if (!ts) return '-'\n  try {\n    const d = new Date(ts * 1000)\n    return [d.getFullYear(), d.getMonth() + 1, d.getDate()].join('-') + ' ' + [d.getHours(), d.getMinutes(), d.getSeconds()].map(n => String(n).padStart(2, '0')).join(':')\n  } catch {\n    return String(ts)\n  }\n}\n\n// 从 hosts 里尝试读取基本信息（face/up_name）\nconst fetchHostInfo = async (mid) => {\n  const res = await getDynamicDbHosts(200, 0)\n  const list = res?.data?.data || []\n  const found = list.find(x => String(x.host_mid) === String(mid))\n  hostInfo.value = found || { host_mid: String(mid) }\n}\n\nconst loadHosts = async () => {\n  try {\n    hostsLoading.value = true\n    const res = await getDynamicDbHosts(50, 0)\n    hosts.value = res?.data?.data || []\n  } catch (e) {\n    // 忽略错误\n  } finally {\n    hostsLoading.value = false\n  }\n}\n\nconst selectHost = async (mid) => {\n  inputMid.value = String(mid)\n  await triggerQueryNow()\n}\n\nconst refreshList = async (reset = true) => {\n  if (!hostMid.value) return\n  if (reset) {\n    items.value = []\n    offset.value = 0\n    noMore.value = false\n  }\n  const res = await getDynamicDbSpace(hostMid.value, limit.value, offset.value)\n  total.value = res?.data?.total || 0\n  const rows = res?.data?.items || []\n  items.value = reset ? rows : items.value.concat(rows)\n  offset.value += rows.length\n  if (!rows.length || items.value.length >= total.value) noMore.value = true\n}\n\n// 手动查询入口已移除，改为输入 1 秒后自动触发\n\n// 去重追加日志：仅当与上一条不同才加入\nconst addLog = (line) => {\n  const msg = String(line || '').trim()\n  if (!msg) return\n  const last = logs.value[logs.value.length - 1]\n  if (last !== msg) {\n    logs.value.push(msg)\n    // 追加后自动滚动到底部\n    nextTick(() => {\n      if (logsContainer.value) {\n        logsContainer.value.scrollTop = logsContainer.value.scrollHeight\n      }\n    })\n  }\n}\n\nconst openSSE = (mid) => {\n  closeSSE()\n  try {\n    sse = createDynamicProgressSSE(mid)\n    sse.onopen = () => {\n      addLog(`[SSE] connected for ${mid}`)\n    }\n    // 仅识别最终完成格式：\"抓取完成！共获取 xx 条动态，总计 xx 页\"\n    const FINAL_DONE_RE = /\\[全部抓取完毕\\]\\s*抓取完成！共获取\\s+\\d+\\s*条动态，\\s*总计\\s+\\d+\\s*页/\n    // 显式监听 progress 事件名（后端事件名: progress）\n    sse.addEventListener('progress', (evt) => {\n      try {\n        const data = JSON.parse(evt.data)\n        const msg = data?.message || evt.data\n        addLog(String(msg))\n        // 仅在最终完成消息出现时自动停止\n        if (FINAL_DONE_RE.test(String(msg))) {\n          if (downloading.value) handleStop()\n        }\n      } catch {\n        addLog(String(evt.data))\n      }\n    })\n    // 兜底接收未命名事件\n    sse.onmessage = (evt) => {\n      try {\n        const data = JSON.parse(evt.data)\n        const msg = data?.message || evt.data\n        addLog(String(msg))\n        if (FINAL_DONE_RE.test(String(msg))) {\n          if (downloading.value) handleStop()\n        }\n      } catch {\n        addLog(String(evt.data))\n      }\n    }\n    sse.onerror = (evt) => {\n      addLog('[SSE] error, will close')\n      closeSSE()\n    }\n  } catch (e) {\n    addLog(`[SSE] create failed: ${e?.message || e}`)\n  }\n}\n\nconst closeSSE = () => {\n  if (sse) {\n    try { sse.close() } catch {}\n    sse = null\n  }\n}\n\nconst handleStartDownload = async () => {\n  if (!hostMid.value || downloading.value) return\n  downloading.value = true\n  logs.value.push(`Start auto fetch for MID ${hostMid.value}`)\n  openSSE(hostMid.value)\n  try {\n    await startDynamicAutoFetch(hostMid.value, {\n      need_top: false,\n      save_to_db: true,\n      save_media: true\n    })\n  } catch (e) {\n    logs.value.push(`start error: ${e?.message || e}`)\n  }\n}\n\nconst handleStop = async () => {\n  if (!hostMid.value) return\n  try {\n    await stopDynamicAutoFetch(hostMid.value)\n    addLog('Stop requested')\n  } catch (e) {\n    addLog(`stop error: ${e?.message || e}`)\n  } finally {\n    downloading.value = false\n    closeSSE()\n    // 停止后刷新一次列表\n    await refreshList(true)\n    // 停止后刷新头像（可能在抓取期间生成了face）\n    await fetchHostInfo(hostMid.value)\n  }\n}\n\nconst confirmDelete = (mid, name) => {\n  return new Promise((resolve) => {\n    showDialog({\n      title: '⚠️ 危险操作',\n      message: `即将删除 ${name} (UID: ${mid}) 的所有动态媒体与数据库记录。\\n\\n此操作不可恢复，请谨慎确认！`,\n      showCancelButton: true,\n      confirmButtonText: '确认删除',\n      cancelButtonText: '取消'\n    }).then(() => {\n      // 第二次确认\n      showDialog({\n        title: '🚨 最终确认',\n        message: `请再次确认删除 ${name} (UID: ${mid}) 的所有数据。\\n\\n点击确认后将立即执行删除操作！`,\n        showCancelButton: true,\n        confirmButtonText: '立即删除',\n        cancelButtonText: '取消'\n      }).then(() => resolve(true)).catch(() => resolve(false))\n    }).catch(() => resolve(false))\n  })\n}\n\nconst handleDeleteHost = async () => {\n  if (!hostMid.value || downloading.value || deleting.value) return\n  const mid = String(hostMid.value)\n  const name = hostInfo.value?.up_name || `UID ${mid}`\n  const confirmed = await confirmDelete(mid, name)\n  if (!confirmed) return\n  await executeDelete(mid, name, true)\n}\n\nconst executeDelete = async (mid, name, clearSelection) => {\n  try {\n    deleting.value = true\n    addLog(`Delete requested for ${name} (MID: ${mid})`)\n    await deleteDynamicSpace(mid)\n    addLog(`Delete success for ${name} (MID: ${mid})`)\n    \n    if (clearSelection) {\n      // 清空本地列表、选择状态并刷新\n      items.value = []\n      offset.value = 0\n      total.value = 0\n      noMore.value = true\n      hostInfo.value = null\n      hostMid.value = ''\n      inputMid.value = ''\n    }\n    \n    await loadHosts()\n  } catch (e) {\n    const errorMsg = e?.message || e\n    addLog(`delete error: ${errorMsg}`)\n    showDialog({\n      title: '删除失败',\n      message: `删除 ${name} 失败：\\n${errorMsg}`,\n      confirmButtonText: '确定'\n    })\n  } finally {\n    deleting.value = false\n  }\n}\n\nconst isVideoDynamic = (it) => {\n  // 有 bvid 即视为视频动态；或 type 包含 VIDEO\n  if (it?.bvid) return true\n  const t = String(it?.type || '')\n  return t.includes('VIDEO') || t.includes('AV')\n}\n\nconst loadMore = async () => {\n  if (loadingMore.value || noMore.value) return\n  loadingMore.value = true\n  try {\n    await refreshList(false)\n  } finally {\n    loadingMore.value = false\n  }\n}\n\nonUnmounted(() => {\n  closeSSE()\n  if (queryTimer) clearTimeout(queryTimer)\n})\n\n// 监听输入，1秒后自动查询\nwatch(() => inputMid.value, (val) => {\n  const mid = String(val || '').trim()\n  if (!mid) return\n  if (queryTimer) clearTimeout(queryTimer)\n  queryTimer = setTimeout(async () => {\n    if (hostMid.value !== mid) {\n      hostMid.value = mid\n      await fetchHostInfo(mid)\n      await refreshList(true)\n    }\n  }, 1000)\n})\n\n// 立即触发查询（供按回车使用）\nconst triggerQueryNow = async () => {\n  const mid = String(inputMid.value || '').trim()\n  if (!mid) return\n  if (queryTimer) clearTimeout(queryTimer)\n  if (hostMid.value !== mid) {\n    hostMid.value = mid\n    await fetchHostInfo(mid)\n    await refreshList(true)\n  }\n}\n\n// 初始化加载UP列表\nloadHosts()\n</script>\n\n<style scoped>\n</style>\n\n\n"
  },
  {
    "path": "src/components/tailwind/page/Favorites.vue",
    "content": "<!-- 收藏夹页面 -->\n<template>\n  <div class=\"min-h-screen bg-gray-50/30 dark:bg-gray-900\">\n    <div class=\"py-6\">\n      <div class=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8\">\n        <!-- 主内容卡片 -->\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700\">\n          <!-- 标签导航 -->\n          <div class=\"border-b border-gray-200 dark:border-gray-700\" v-if=\"!showFolderContents\">\n            <nav class=\"-mb-px flex space-x-6 px-4 overflow-x-auto\" aria-label=\"收藏夹选项卡\">\n              <button\n                @click=\"activeTab = 'created'\"\n                class=\"py-3 px-1 border-b-2 font-medium text-sm flex items-center space-x-2\"\n                :class=\"activeTab === 'created'\n                  ? 'border-[#fb7299] text-[#fb7299]'\n                  : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'\"\n              >\n                <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z\" />\n                </svg>\n                <span>我创建的收藏夹</span>\n              </button>\n\n              <button\n                @click=\"activeTab = 'collected'\"\n                class=\"py-3 px-1 border-b-2 font-medium text-sm flex items-center space-x-2\"\n                :class=\"activeTab === 'collected'\n                  ? 'border-[#fb7299] text-[#fb7299]'\n                  : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'\"\n              >\n                <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z\" />\n                </svg>\n                <span>我收藏的收藏夹</span>\n              </button>\n\n              <button\n                @click=\"activeTab = 'local'\"\n                class=\"py-3 px-1 border-b-2 font-medium text-sm flex items-center space-x-2\"\n                :class=\"activeTab === 'local'\n                  ? 'border-[#fb7299] text-[#fb7299]'\n                  : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'\"\n              >\n                <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4\" />\n                </svg>\n                <span>本地收藏夹</span>\n              </button>\n            </nav>\n          </div>\n\n          <!-- 文件夹内容标题栏 -->\n          <div class=\"border-b border-gray-200 dark:border-gray-700\" v-if=\"showFolderContents\">\n            <div class=\"flex items-center justify-between px-4 py-3\">\n              <div class=\"flex items-center space-x-4\">\n                <button\n                  @click=\"backToFolderList\"\n                  class=\"p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors\"\n                >\n                  <svg class=\"w-5 h-5 text-gray-600 dark:text-gray-300\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M10 19l-7-7m0 0l7-7m-7 7h18\" />\n                  </svg>\n                </button>\n                <h2 class=\"text-lg font-medium truncate\">{{ currentFolder?.title || '收藏夹内容' }}</h2>\n              </div>\n              <div class=\"flex items-center space-x-2\">\n                <button\n                  v-if=\"activeTab !== 'local'\"\n                  @click=\"fetchAllContents\"\n                  class=\"flex items-center px-3 py-1.5 text-xs text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors\"\n                  :disabled=\"fetchingAll\"\n                >\n                  <svg class=\"w-3.5 h-3.5 mr-1\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4\" />\n                  </svg>\n                  <span>同步到本地</span>\n                </button>\n              </div>\n            </div>\n          </div>\n\n          <!-- 内容区域 -->\n          <div class=\"transition-all duration-300 p-5\">\n            <!-- 全局提示信息 -->\n            <div class=\"mb-4 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-md text-amber-700 dark:text-amber-300 text-sm\">\n              <div class=\"flex items-start\">\n                <svg class=\"w-5 h-5 text-amber-500 mt-0.5 mr-2 flex-shrink-0\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z\" />\n                </svg>\n                <div>\n                  <p class=\"mt-1\">用户收藏夹往往非常庞大，解析时很容易触发反爬机制。如遇该问题请稍等片刻后重试。（emmm，如果视频太多的话还是建议逐个收藏夹下载……）</p>\n                </div>\n              </div>\n            </div>\n\n            <!-- 收藏夹列表 -->\n            <div class=\"animate-fadeIn\" v-if=\"!showFolderContents\">\n              <!-- 收藏夹列表显示区域 -->\n              <div v-if=\"loading\" class=\"flex justify-center py-20\">\n                <div class=\"inline-flex items-center px-4 py-2 bg-white dark:bg-gray-800 rounded-md shadow text-gray-900 dark:text-gray-100\">\n                  <svg class=\"animate-spin -ml-1 mr-3 h-5 w-5 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\">\n                    <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n                    <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n                  </svg>\n                  <span>加载中...</span>\n                </div>\n              </div>\n\n              <div v-else-if=\"favorites.length === 0\" class=\"text-center py-20\">\n                <svg class=\"w-16 h-16 mx-auto text-gray-300\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z\" />\n                </svg>\n                <p class=\"mt-4 text-gray-500 dark:text-gray-400\">暂无收藏夹</p>\n                <!-- 在线收藏夹（需要登录） -->\n                <template v-if=\"(activeTab === 'created' || activeTab === 'collected') && !isLoggedIn\">\n                  <p class=\"mt-2 text-sm text-gray-400\">您需要登录B站账号才能查看收藏夹</p>\n                  <button\n                    @click=\"openLoginDialog\"\n                    class=\"mt-4 px-4 py-2 bg-[#fb7299] text-white rounded-md hover:bg-[#fb7299]/90 transition-colors\"\n                  >\n                    登录账号\n                  </button>\n                </template>\n                <!-- 已登录但没有收藏夹 -->\n                <template v-else-if=\"(activeTab === 'created' || activeTab === 'collected') && isLoggedIn\">\n                  <p class=\"mt-2 text-sm text-gray-400\">\n                    {{ activeTab === 'created' ? '您还没有创建过收藏夹' : '您还没有收藏任何收藏夹' }}\n                  </p>\n                </template>\n                <!-- 本地收藏夹为空 -->\n                <template v-else-if=\"activeTab === 'local'\">\n                  <p class=\"mt-2 text-sm text-gray-400\">您的本地数据库中没有保存的收藏夹</p>\n                </template>\n              </div>\n\n              <div v-else class=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4\">\n                <!-- 收藏夹卡片 -->\n                <div\n                  v-for=\"folder in favorites\"\n                  :key=\"folder.id || folder.media_id\"\n                  class=\"bg-white dark:bg-gray-800 rounded-lg overflow-hidden shadow-sm hover:shadow-md transition-shadow border border-gray-200 dark:border-gray-700 flex flex-col\"\n                >\n                  <!-- 封面图 -->\n                  <div class=\"relative aspect-video bg-gray-100 dark:bg-gray-700 overflow-hidden\">\n                     <img\n                      :src=\"normalizeImageUrl(folder.cover)\"\n                      :alt=\"folder.title\"\n                      class=\"w-full h-full object-cover transition-transform duration-300 hover:scale-105\"\n                      @click=\"viewFolderContents(folder)\"\n                    />\n                    <div class=\"absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2\">\n                      <p class=\"text-white text-sm font-medium truncate\">{{ folder.title }}</p>\n                      <div class=\"flex items-center mt-1\">\n                        <span class=\"text-white/80 text-xs\">{{ folder.media_count }}个内容</span>\n                      </div>\n                    </div>\n                  </div>\n\n                  <!-- 收藏夹信息 -->\n                  <div class=\"p-3 flex-1 flex flex-col\">\n                    <div class=\"flex items-start justify-between\">\n                      <div class=\"flex-1\">\n                        <h3\n                          class=\"font-medium text-gray-900 dark:text-gray-100 hover:text-[#fb7299] transition-colors cursor-pointer\"\n                          @click=\"viewFolderContents(folder)\"\n                        >\n                          {{ folder.title }}\n                        </h3>\n                        <p class=\"mt-1 text-xs text-gray-500 dark:text-gray-400 line-clamp-2\">{{ folder.intro || '无简介' }}</p>\n                      </div>\n                    </div>\n\n                    <div class=\"mt-3 pt-2 border-t border-gray-100 dark:border-gray-700 flex items-center justify-between\">\n                      <div class=\"flex items-center\">\n                        <img\n                          :src=\"normalizeImageUrl(folder.upper?.face || folder.creator_face)\"\n                          :alt=\"folder.upper?.name || folder.creator_name\"\n                          class=\"w-5 h-5 rounded-full\"\n                        />\n                        <span class=\"ml-1.5 text-xs text-gray-600 dark:text-gray-400\">{{ folder.upper?.name || folder.creator_name }}</span>\n                      </div>\n                      <div class=\"flex items-center space-x-2\">\n                        <button\n                          v-if=\"activeTab !== 'local'\"\n                          @click=\"startDownloadFolder(folder)\"\n                          class=\"text-xs text-blue-500 hover:bg-blue-50 px-2 py-1 rounded transition-colors\"\n                          title=\"下载收藏夹中的视频\"\n                        >\n                          <svg class=\"w-3.5 h-3.5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4\" />\n                          </svg>\n                        </button>\n                        <button\n                          @click=\"viewFolderContents(folder)\"\n                          class=\"text-xs text-[#fb7299] hover:bg-[#fb7299]/10 px-2 py-1 rounded transition-colors\"\n                        >\n                          查看详情\n                        </button>\n                      </div>\n                    </div>\n                  </div>\n                </div>\n              </div>\n\n              <!-- 分页控件 -->\n              <div v-if=\"favorites.length > 0 && totalPages > 1\" class=\"mt-6 flex justify-center\">\n                <Pagination\n                  :current-page=\"currentPage\"\n                  :total-pages=\"totalPages\"\n                  @page-change=\"handlePageChange\"\n                />\n              </div>\n            </div>\n\n            <!-- 收藏夹内容 -->\n            <div v-if=\"showFolderContents\" class=\"animate-fadeIn\">\n              <div v-if=\"loadingContents\" class=\"flex justify-center py-20\">\n                <div class=\"inline-flex items-center px-4 py-2 bg-white dark:bg-gray-800 rounded-md shadow text-gray-900 dark:text-gray-100\">\n                  <svg class=\"animate-spin -ml-1 mr-3 h-5 w-5 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\">\n                    <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n                    <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n                  </svg>\n                  <span>加载中...</span>\n                </div>\n              </div>\n\n              <div v-else-if=\"folderContents.length === 0\" class=\"py-10 text-center\">\n                <p class=\"text-gray-500 dark:text-gray-400\">该收藏夹暂无内容</p>\n              </div>\n\n              <div v-else>\n                <!-- 收藏夹操作栏 -->\n                <div class=\"mb-4 flex flex-wrap justify-between items-center bg-white/70 dark:bg-gray-800/70 p-3 rounded-lg shadow-sm\">\n                  <div class=\"flex items-center space-x-3\">\n                    <div class=\"text-sm text-gray-700 dark:text-gray-300\">共 {{ contentsTotalItems }} 个内容</div>\n                    <div v-if=\"invalidVideosCount > 0\" class=\"text-sm text-red-500\">\n                      ({{ invalidVideosCount }} 个失效)\n                    </div>\n                  </div>\n\n                  <div class=\"flex items-center space-x-3 mt-2 sm:mt-0\">\n                    <button\n                      v-if=\"activeTab !== 'local'\"\n                      @click=\"startDownloadFolder(currentFolder)\"\n                      class=\"flex items-center px-3 py-1.5 text-xs text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-md transition-colors\"\n                    >\n                      <svg class=\"w-3.5 h-3.5 mr-1\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4\" />\n                      </svg>\n                      <span>下载收藏夹</span>\n                    </button>\n\n\n                  </div>\n                </div>\n\n                <!-- 内容列表 - 网格布局 -->\n                <div class=\"grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3\">\n                  <div\n                    v-for=\"item in folderContents\"\n                    :key=\"item.id || item.bvid\"\n                    class=\"bg-white/50 dark:bg-gray-800/50 rounded-md overflow-hidden border border-gray-200/50 dark:border-gray-700/50 hover:border-[#fb7299] hover:shadow-sm transition-all duration-200 relative group\"\n                  >\n                    <!-- 视频封面 -->\n                    <div class=\"relative pb-[56.25%] overflow-hidden cursor-pointer group\" @click=\"openVideo(item)\">\n\n\n                       <img\n                         :src=\"normalizeImageUrl(getVideoImage(item))\"\n                        :alt=\"getVideoTitle(item)\"\n                        class=\"absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-300\"\n                        loading=\"lazy\"\n                        onerror=\"this.src='https://i0.hdslb.com/bfs/archive/c9e72655b7c9c9c68a30d3275313c501e68427d1.jpg'\"\n                      />\n\n                      <!-- 视频时长标签 -->\n                      <div class=\"absolute bottom-1 right-1 bg-black/60 px-1 py-0.5 rounded text-white text-[10px]\">\n                        {{ formatDuration(item.duration) }}\n                      </div>\n\n\n                    </div>\n\n                    <!-- 视频信息 -->\n                    <div class=\"p-2 flex flex-col space-y-1\">\n                      <!-- 标题 -->\n                      <div class=\"line-clamp-2 text-xs text-gray-900 dark:text-gray-100 font-medium cursor-pointer\" @click=\"openVideo(item)\">\n                        {{ getVideoTitle(item) }}\n                      </div>\n\n                      <!-- 作者信息 -->\n                      <div class=\"flex items-center space-x-1\">\n                        <img\n                          :src=\"normalizeImageUrl(getAuthorFace(item))\"\n                          :alt=\"getAuthorName(item)\"\n                          class=\"w-3.5 h-3.5 rounded-full object-cover cursor-pointer\"\n                          loading=\"lazy\"\n                          onerror=\"this.src='https://i1.hdslb.com/bfs/face/1b6f746be0d0c8324e01e618c5e85e113a8b38be.jpg'\"\n                          @click.stop=\"openAuthorPage(item)\"\n                        />\n                        <span class=\"text-[10px] text-gray-600 dark:text-gray-400 truncate hover:text-[#fb7299] cursor-pointer\" @click.stop=\"openAuthorPage(item)\">\n                          {{ getAuthorName(item) }}\n                        </span>\n                      </div>\n\n                      <!-- 收藏时间 -->\n                      <div class=\"flex justify-between items-center text-[10px] text-gray-500\">\n                        <div class=\"flex items-center space-x-1\">\n                          <svg class=\"w-2.5 h-2.5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z\" />\n                          </svg>\n                          <span>收藏于: {{ formatTime(item.fav_time) }}</span>\n                        </div>\n                      </div>\n                    </div>\n                  </div>\n                </div>\n\n                <!-- 内容分页 -->\n                <div v-if=\"contentsTotalPages > 1\" class=\"flex justify-center mt-6\">\n                  <Pagination\n                    :current-page=\"contentsPage\"\n                    :total-pages=\"contentsTotalPages\"\n                    @page-change=\"handleContentsPageChange\"\n                  />\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <!-- 登录弹窗 -->\n    <LoginDialog\n      v-model:show=\"showLoginDialog\"\n      @login-success=\"onLoginSuccess\"\n    />\n\n    <!-- 全屏加载遮罩 -->\n    <div v-if=\"fetchingAll\" class=\"fixed inset-0 bg-black/40 flex items-center justify-center z-50\">\n      <div class=\"bg-white dark:bg-gray-800 p-6 rounded-lg max-w-xs w-full shadow-xl text-center\">\n        <svg class=\"animate-spin h-10 w-10 text-[#fb7299] mx-auto mb-4\" fill=\"none\" viewBox=\"0 0 24 24\">\n          <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n          <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n        </svg>\n        <h3 class=\"text-lg font-medium text-gray-900 dark:text-gray-100 mb-2\">正在获取全部收藏内容</h3>\n        <p class=\"text-gray-600 dark:text-gray-400 mb-3\">请耐心等待，这可能需要一些时间</p>\n        <div class=\"w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5 mb-3\">\n          <div class=\"bg-[#fb7299] h-2.5 rounded-full\" :style=\"{ width: `${fetchProgress}%` }\"></div>\n        </div>\n        <p class=\"text-sm text-gray-700 dark:text-gray-300\">已获取 {{ currentFetchPage }} / {{ totalFetchPages }} 页</p>\n      </div>\n    </div>\n\n    <!-- 使用DownloadDialog组件 -->\n    <DownloadDialog\n      v-model:show=\"showDownloadDialog\"\n      :video-info=\"favoriteDownloadInfo\"\n      @download-complete=\"handleDownloadComplete\"\n    />\n\n\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, computed, watch, onUnmounted } from 'vue'\nimport { useRouter } from 'vue-router'\nimport { showNotify } from 'vant'\nimport 'vant/es/notify/style'\nimport 'vant/es/dialog/style'\nimport Pagination from '../Pagination.vue'\nimport LoginDialog from '../LoginDialog.vue'\nimport DownloadDialog from '../DownloadDialog.vue'\nimport {\n  getCreatedFavoriteFolders,\n  getCollectedFavoriteFolders,\n  getLocalFavoriteFolders,\n  getFavoriteContents,\n  getLocalFavoriteContents,\n  getLoginStatus\n} from '@/api/api.js'\nimport { openInBrowser } from '@/utils/openUrl.js'\nimport { normalizeImageUrl } from '@/utils/imageUrl.js'\n\nconst router = useRouter()\n\n// 状态变量\nconst loading = ref(false)\nconst favorites = ref([])\nconst activeTab = ref('created')\nconst currentPage = ref(1)\nconst pageSize = ref(40)\nconst totalItems = ref(0)\nconst searchKeyword = ref('')\n\n// 收藏夹内容状态\nconst showFolderContents = ref(false)\nconst currentFolder = ref(null)\nconst folderContents = ref([])\nconst loadingContents = ref(false)\nconst contentsPage = ref(1)\nconst contentsPageSize = ref(40)\nconst contentsTotalItems = ref(0)\n\n// 登录弹窗状态\nconst showLoginDialog = ref(false)\n\n// 已移除修复功能相关弹窗\n\n// 登录状态\nconst isLoggedIn = ref(false)\nconst checkingLoginStatus = ref(false)\n\n// 获取全部收藏夹内容相关状态\nconst fetchingAll = ref(false)\nconst currentFetchPage = ref(0)\nconst totalFetchPages = ref(0)\nconst fetchProgress = ref(0)\nconst allFetchedContents = ref([])\n\n// 已移除修复状态与结果\n\n// 计算总页数\nconst totalPages = computed(() => {\n  return Math.ceil(totalItems.value / pageSize.value)\n})\n\n// 计算内容总页数\nconst contentsTotalPages = computed(() => {\n  return Math.ceil(contentsTotalItems.value / contentsPageSize.value)\n})\n\n// 监听活动标签变化\nwatch(activeTab, () => {\n  currentPage.value = 1\n  fetchFavorites()\n})\n\n// 组件挂载时加载数据\nonMounted(() => {\n  checkLoginStatus()\n  fetchFavorites()\n\n  // 添加全局登录状态变化的监听\n  window.addEventListener('login-status-changed', handleLoginStatusChange)\n})\n\n// 组件卸载时移除事件监听\nonUnmounted(() => {\n  window.removeEventListener('login-status-changed', handleLoginStatusChange)\n})\n\n// 处理登录状态变化事件\nfunction handleLoginStatusChange(event) {\n  console.log('收藏页面收到登录状态变化事件:', event.detail)\n  if (event.detail && typeof event.detail.isLoggedIn !== 'undefined') {\n    isLoggedIn.value = event.detail.isLoggedIn\n    if (isLoggedIn.value) {\n      // 如果登录状态变为已登录，重新获取收藏夹\n      fetchFavorites()\n    }\n  } else {\n    // 如果事件没有包含登录状态信息，则重新检查\n    checkLoginStatus()\n  }\n}\n\n// 检查登录状态\nasync function checkLoginStatus() {\n  checkingLoginStatus.value = true\n  try {\n    const response = await getLoginStatus()\n    console.log('获取登录状态响应:', response.data)\n    if (response.data && response.data.code === 0) {\n      isLoggedIn.value = response.data.data.isLogin\n      console.log('登录状态:', isLoggedIn.value)\n    } else {\n      console.warn('登录状态响应异常:', response.data)\n      isLoggedIn.value = false\n    }\n  } catch (error) {\n    console.error('获取登录状态失败:', error)\n    isLoggedIn.value = false\n  } finally {\n    checkingLoginStatus.value = false\n  }\n}\n\n// 获取收藏夹列表\nasync function fetchFavorites() {\n  loading.value = true\n  favorites.value = []\n\n  try {\n    let response\n\n    if (activeTab.value === 'created') {\n      response = await getCreatedFavoriteFolders({\n        keyword: searchKeyword.value || undefined\n      })\n    } else if (activeTab.value === 'collected') {\n      response = await getCollectedFavoriteFolders({\n        pn: currentPage.value,\n        ps: pageSize.value,\n        keyword: searchKeyword.value || undefined\n      })\n    } else if (activeTab.value === 'local') {\n      response = await getLocalFavoriteFolders({\n        page: currentPage.value,\n        size: pageSize.value\n      })\n    }\n\n    if (response.data.status === 'success') {\n      if (activeTab.value === 'created') {\n        favorites.value = response.data.data.list || []\n        totalItems.value = response.data.data.count || 0\n      } else if (activeTab.value === 'collected') {\n        favorites.value = response.data.data.list || []\n        totalItems.value = response.data.data.count || 0\n      } else if (activeTab.value === 'local') {\n        favorites.value = response.data.data.list || []\n        totalItems.value = response.data.data.total || 0\n      }\n\n      // 如果收藏夹没有封面，使用第一个视频的封面\n      for (const folder of favorites.value) {\n        if (!folder.cover || folder.cover.includes('nocover')) {\n          // 预加载第一个视频的封面\n          preloadFirstVideoCover(folder)\n        }\n      }\n    } else {\n      showNotify({ type: 'danger', message: response.data.message || '获取收藏夹失败' })\n    }\n  } catch (error) {\n    console.error('获取收藏夹出错:', error)\n    showNotify({ type: 'danger', message: '获取收藏夹出错: ' + (error.message || '未知错误') })\n  } finally {\n    loading.value = false\n  }\n}\n\n// 预加载收藏夹的第一个视频封面\nasync function preloadFirstVideoCover(folder) {\n  try {\n    const folderId = folder.id || folder.media_id\n    if (!folderId) return\n\n    let response\n    if (activeTab.value === 'local') {\n      response = await getLocalFavoriteContents({\n        media_id: folderId,\n        page: 1,\n        size: 1\n      })\n    } else {\n      // 对于线上收藏夹，直接获取收藏夹详细信息\n      response = await getFavoriteContents({\n        media_id: folderId,\n        pn: 1,\n        ps: 1\n      })\n\n      // 从响应中获取收藏夹详细信息\n      if (response.data.status === 'success' && response.data.data && response.data.data.info) {\n        // 更新收藏夹信息\n        const info = response.data.data.info\n        folder.title = info.title || folder.title\n        folder.cover = info.cover || folder.cover\n        folder.intro = info.intro || folder.intro\n        folder.media_count = info.media_count || folder.media_count\n\n        // 更新UP主信息\n        if (info.upper) {\n          folder.upper = info.upper\n        }\n\n        // 获取到了详细信息，无需继续处理\n        return\n      }\n    }\n\n    // 如果没有获取到详细信息或是本地收藏夹，则使用第一个视频的封面\n    if (response.data.status === 'success') {\n      let contents = []\n      if (activeTab.value === 'local') {\n        contents = response.data.data.list || []\n      } else if (response.data.data && response.data.data.medias) {\n        contents = response.data.data.medias || []\n      } else if (response.data.data && Array.isArray(response.data.data)) {\n        contents = response.data.data\n      }\n\n      if (contents.length > 0 && contents[0].cover) {\n        folder.cover = contents[0].cover\n      }\n    }\n  } catch (error) {\n    console.error('获取封面出错:', error)\n  }\n}\n\n// 查看收藏夹内容\nasync function viewFolderContents(folder) {\n  currentFolder.value = folder\n  showFolderContents.value = true\n  contentsPage.value = 1\n  folderContents.value = []\n\n  // 先加载内容\n  const contents = await loadContents()\n  console.log(`收藏夹[${folder.media_id || folder.id}]加载完成，共${contents.length}个视频`)\n}\n\n// 返回到收藏夹列表\nfunction backToFolderList() {\n  showFolderContents.value = false\n  currentFolder.value = null\n  folderContents.value = []\n}\n\n// 加载收藏夹内容\nasync function loadContents() {\n  if (!currentFolder.value) return\n\n  loadingContents.value = true\n  folderContents.value = []\n\n  try {\n    let response\n    // 确保使用正确的收藏夹ID\n    const folderId = currentFolder.value.media_id || currentFolder.value.id\n\n    console.log(`开始加载收藏夹[${folderId}]第${contentsPage.value}页内容`)\n\n    if (activeTab.value === 'local') {\n      response = await getLocalFavoriteContents({\n        media_id: folderId,\n        page: contentsPage.value,\n        size: contentsPageSize.value\n      })\n\n      if (response.data.status === 'success') {\n        folderContents.value = response.data.data.list || []\n        contentsTotalItems.value = response.data.data.total || 0\n        console.log(`加载到本地收藏夹内容 ${folderContents.value.length} 条`)\n      } else {\n        console.error('本地收藏夹请求失败:', response.data)\n        showNotify({ type: 'danger', message: response.data.message || '获取本地收藏夹内容失败' })\n      }\n    } else {\n      console.log('发送在线收藏夹请求:', {\n        media_id: folderId,\n        pn: contentsPage.value,\n        ps: contentsPageSize.value\n      })\n\n      response = await getFavoriteContents({\n        media_id: folderId,\n        pn: contentsPage.value,\n        ps: contentsPageSize.value,\n        keyword: searchKeyword.value || undefined\n      })\n\n      console.log('收到在线收藏夹响应:', response.data)\n\n      if (response.data.status === 'success') {\n        // 更新收藏夹信息\n        if (response.data.data && response.data.data.info) {\n          const info = response.data.data.info\n          // 更新当前展示的收藏夹信息\n          currentFolder.value.title = info.title || currentFolder.value.title\n          currentFolder.value.cover = info.cover || currentFolder.value.cover\n          currentFolder.value.intro = info.intro || currentFolder.value.intro\n          currentFolder.value.media_count = info.media_count || currentFolder.value.media_count\n\n          // 更新UP主信息\n          if (info.upper) {\n            currentFolder.value.upper = info.upper\n          }\n        }\n\n        // 确保我们能够正确处理不同的数据结构\n        if (response.data.data && response.data.data.medias) {\n          console.log('找到媒体数据，数量:', response.data.data.medias.length)\n          folderContents.value = response.data.data.medias\n          contentsTotalItems.value = currentFolder.value.media_count || 0\n        } else if (response.data.data && Array.isArray(response.data.data)) {\n          console.log('找到数组数据，数量:', response.data.data.length)\n          folderContents.value = response.data.data\n          contentsTotalItems.value = response.data.total || currentFolder.value.media_count || 0\n        } else {\n          console.warn('收藏夹内容数据结构异常:', response.data)\n          folderContents.value = []\n          showNotify({ type: 'warning', message: '收藏夹数据结构异常，无法显示内容' })\n        }\n\n        // 确保至少更新了folderContents\n        if (folderContents.value.length === 0) {\n          console.warn('无法从响应中提取内容数据')\n          showNotify({ type: 'warning', message: '无法从响应中提取内容数据' })\n        }\n      } else {\n        console.error('在线收藏夹请求失败:', response.data)\n        showNotify({ type: 'danger', message: response.data.message || '获取收藏夹内容失败' })\n      }\n    }\n\n\n  } catch (error) {\n    console.error('获取收藏夹内容出错:', error)\n    showNotify({ type: 'danger', message: '获取收藏夹内容出错: ' + (error.message || '未知错误') })\n  } finally {\n    loadingContents.value = false\n  }\n\n  // 返回加载的内容，便于调用者使用\n  return folderContents.value\n}\n\n// 打开视频\nasync function openVideo(video) {\n  // 使用BV号或视频ID打开视频，跳转到B站\n  const videoId = video.bvid || video.id\n  if (videoId) {\n    // 在系统默认浏览器中打开B站视频链接\n    await openInBrowser(`https://www.bilibili.com/video/${videoId}`)\n  }\n}\n\n// 处理搜索\n// 处理分页变化\nfunction handlePageChange(page) {\n  currentPage.value = page\n  fetchFavorites()\n}\n\n// 处理内容分页变化\nasync function handleContentsPageChange(page) {\n  console.log(`切换到第${page}页内容`)\n  contentsPage.value = page\n\n  try {\n    // 等待内容加载完成\n    await loadContents()\n\n  } catch (error) {\n    console.error('分页处理出错:', error)\n    showNotify({\n      type: 'danger',\n      message: '分页处理出错: ' + (error.message || '未知错误')\n    })\n  }\n}\n\n// 打开登录对话框\nfunction openLoginDialog() {\n  showLoginDialog.value = true\n}\n\n// 登录成功回调\nfunction onLoginSuccess() {\n  isLoggedIn.value = true\n  showNotify({ type: 'success', message: '登录成功，正在获取收藏夹数据' })\n  fetchFavorites()\n}\n\n// 格式化时间戳为可读格式\nfunction formatTime(timestamp) {\n  if (!timestamp) return '未知'\n\n  const date = new Date(timestamp * 1000)\n  return date.toLocaleDateString('zh-CN', {\n    year: 'numeric',\n    month: '2-digit',\n    day: '2-digit'\n  })\n}\n\n// 格式化视频时长\nfunction formatDuration(seconds) {\n  if (!seconds) return '00:00'\n\n  const minutes = Math.floor(seconds / 60)\n  const remainingSeconds = seconds % 60\n\n  return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`\n}\n\n// 获取作者头像\nfunction getAuthorFace(item) {\n  // 首先检查upper对象\n  if (item.upper && item.upper.face) {\n    return item.upper.face\n  }\n  // 然后检查creator_face属性（本地数据结构）\n  else if (item.creator_face) {\n    return item.creator_face\n  }\n  // 再检查upper_mid关联的信息（本地数据可能有的另一种形式）\n  else if (item.upper_mid && typeof item.upper_mid === 'number') {\n    // 如果只有mid而没有头像，返回默认头像\n    return 'https://i1.hdslb.com/bfs/face/1b6f746be0d0c8324e01e618c5e85e113a8b38be.jpg'\n  }\n  // 最后返回默认头像\n  else {\n    return 'https://i1.hdslb.com/bfs/face/1b6f746be0d0c8324e01e618c5e85e113a8b38be.jpg'\n  }\n}\n\n// 获取作者名称\nfunction getAuthorName(item) {\n  // 首先检查upper对象\n  if (item.upper && item.upper.name) {\n    return item.upper.name\n  }\n  // 然后检查creator_name属性（本地数据结构）\n  else if (item.creator_name) {\n    return item.creator_name\n  }\n  // 最后返回默认名称\n  else {\n    return '未知UP主'\n  }\n}\n\n// 获取视频是否有修复信息\n// 已移除修复信息获取逻辑\n\n// 获取全部收藏夹内容的函数，每页请求间隔1秒\nasync function fetchAllContents() {\n  if (!currentFolder.value || fetchingAll.value) return\n\n  fetchingAll.value = true\n  allFetchedContents.value = []\n\n  try {\n    // 获取收藏夹信息以确定总页数\n    const folderId = currentFolder.value.media_id || currentFolder.value.id\n    let firstPageResponse\n    let fetchApi\n    let processResponse\n\n    console.log('开始获取全部收藏内容，收藏夹ID:', folderId, '每页大小:', contentsPageSize.value)\n\n    if (activeTab.value === 'local') {\n      fetchApi = (page) => getLocalFavoriteContents({\n        media_id: folderId,\n        page: page,\n        size: contentsPageSize.value\n      })\n\n      processResponse = (response, page) => {\n        console.log(`处理本地收藏夹第${page}页响应:`, response.data)\n        if (response.data.status === 'success') {\n          return {\n            contents: response.data.data.list || [],\n            total: response.data.data.total || 0\n          }\n        }\n        return { contents: [], total: 0 }\n      }\n    } else {\n      fetchApi = (page) => {\n        console.log(`请求在线收藏夹第${page}页, 参数:`, {\n          media_id: folderId,\n          pn: page,\n          ps: contentsPageSize.value\n        })\n        return getFavoriteContents({\n          media_id: folderId,\n          pn: page,\n          ps: contentsPageSize.value\n        })\n      }\n\n      processResponse = (response, page) => {\n        console.log(`处理在线收藏夹第${page}页响应:`, response.data)\n        if (response.data.status === 'success') {\n          // 更新收藏夹信息\n          if (response.data.data && response.data.data.info && page === 1) {\n            const info = response.data.data.info\n            currentFolder.value.title = info.title || currentFolder.value.title\n            currentFolder.value.cover = info.cover || currentFolder.value.cover\n            currentFolder.value.intro = info.intro || currentFolder.value.intro\n            currentFolder.value.media_count = info.media_count || currentFolder.value.media_count\n\n            if (info.upper) {\n              currentFolder.value.upper = info.upper\n            }\n          }\n\n          // 处理多种可能的数据结构\n          if (response.data.data && response.data.data.medias) {\n            console.log(`第${page}页: 找到媒体数据，数量:`, response.data.data.medias.length)\n            return {\n              contents: response.data.data.medias,\n              total: currentFolder.value.media_count || 0\n            }\n          } else if (response.data.data && Array.isArray(response.data.data)) {\n            console.log(`第${page}页: 找到数组数据，数量:`, response.data.data.length)\n            return {\n              contents: response.data.data,\n              total: response.data.total || currentFolder.value.media_count || 0\n            }\n          } else {\n            console.warn(`第${page}页: 数据结构异常:`, response.data)\n            return { contents: [], total: currentFolder.value.media_count || 0 }\n          }\n        }\n        console.error(`第${page}页: 请求失败:`, response.data)\n        return { contents: [], total: 0 }\n      }\n    }\n\n    // 第一页请求，获取总数量信息\n    console.log('获取第1页数据...')\n    firstPageResponse = await fetchApi(1)\n    const result = processResponse(firstPageResponse, 1)\n    const total = result.total\n\n    console.log('首页数据获取完成，总数据条目:', total)\n\n    // 计算总页数\n    totalFetchPages.value = Math.ceil(total / contentsPageSize.value)\n    console.log('计算出总页数:', totalFetchPages.value)\n\n    currentFetchPage.value = 1\n    fetchProgress.value = (1 / totalFetchPages.value) * 100\n\n    // 添加第一页数据\n    allFetchedContents.value = [...allFetchedContents.value, ...result.contents]\n\n    // 如果只有一页，则完成\n    if (totalFetchPages.value <= 1) {\n      showNotify({ type: 'success', message: '收藏夹内容获取完成！' })\n      fetchingAll.value = false\n      folderContents.value = allFetchedContents.value\n      return\n    }\n\n    // 依次请求后续页面\n    for (let page = 2; page <= totalFetchPages.value; page++) {\n      console.log(`等待1秒后获取第${page}页数据...`)\n      // 等待1秒\n      await new Promise(resolve => setTimeout(resolve, 1000))\n\n      try {\n        console.log(`开始获取第${page}页数据`)\n        const response = await fetchApi(page)\n        const pageResult = processResponse(response, page)\n\n        // 添加本页数据\n        allFetchedContents.value = [...allFetchedContents.value, ...pageResult.contents]\n\n        // 更新进度\n        currentFetchPage.value = page\n        fetchProgress.value = (page / totalFetchPages.value) * 100\n        console.log(`第${page}页数据获取完成，当前进度: ${fetchProgress.value.toFixed(2)}%`)\n      } catch (error) {\n        console.error(`获取第${page}页出错:`, error)\n        showNotify({ type: 'warning', message: `获取第${page}页出错，将继续获取下一页: ${error.message}` })\n      }\n    }\n\n    // 完成后更新数据并通知\n    folderContents.value = allFetchedContents.value\n    console.log('所有页面获取完成，总共获取到', allFetchedContents.value.length, '条内容')\n    showNotify({\n      type: 'success',\n      message: `已获取全部${allFetchedContents.value.length}个收藏内容！`\n    })\n  } catch (error) {\n    console.error('获取全部收藏夹内容出错:', error)\n    showNotify({ type: 'danger', message: '获取收藏夹内容出错: ' + (error.message || '未知错误') })\n  } finally {\n    fetchingAll.value = false\n  }\n}\n\n// 获取视频封面，优先使用修复后的封面\nfunction getVideoImage(video) {\n  // 检查video对象是否存在\n  if (!video) return ''\n\n  console.log('获取视频封面:', video.bvid || video.avid)\n\n\n\n  // 返回原始封面\n  return video.cover || ''\n}\n\n// 获取视频标题，优先使用修复后的标题\nfunction getVideoTitle(video) {\n  // 检查video对象是否存在\n  if (!video) return '未知标题'\n\n  console.log('获取视频标题:', video.bvid || video.avid)\n\n\n\n  // 返回原始标题\n  return video.title || '未知标题'\n}\n\n// 判断视频是否正在修复\n// 已移除修复中状态判断\n\n// 打开UP主页面\nfunction openAuthorPage(video) {\n  let upId = null;\n\n// 已移除从修复结果提取UP主ID逻辑\n\n  // 然后检查视频本身的数据\n  if (!upId && video.upper_mid) {\n    upId = video.upper_mid\n  } else if (!upId && video.upper && video.upper.mid) {\n    upId = video.upper.mid\n  }\n\n  // 如果找到UP主ID，跳转到B站UP主页面\n  if (upId) {\n    window.open(`https://space.bilibili.com/${upId}`, '_blank')\n  } else {\n    showNotify({ type: 'warning', message: '无法获取UP主信息' })\n  }\n}\n\n// 下载相关状态\nconst showDownloadDialog = ref(false)\nconst favoriteDownloadInfo = ref({\n  title: '',\n  author: '',\n  bvid: '',\n  cover: '',\n  cid: 0\n})\n\n// 计算无效视频数量\nconst invalidVideosCount = computed(() => 0)\n\n// 开始下载收藏夹\nasync function startDownloadFolder(folder) {\n  if (!folder) return;\n\n  // 检查登录状态\n  if (!isLoggedIn.value) {\n    showNotify({ type: 'warning', message: '请先登录B站账号' });\n    showLoginDialog.value = true;\n    return;\n  }\n\n  // 获取完整的收藏夹视频总数\n  try {\n    // 发起一次API请求获取视频总数，仅获取第一页第一条\n    const response = await getFavoriteContents({\n      media_id: folder.id || folder.media_id,\n      pn: 1,\n      ps: 1\n    });\n\n    if (response.data && response.data.status === 'success' && response.data.data) {\n      // 更新收藏夹信息\n      if (response.data.data.info) {\n        folder.media_count = response.data.data.info.media_count || response.data.data.total || folder.media_count;\n      } else if (response.data.data.total) {\n        folder.media_count = response.data.data.total;\n      }\n\n      console.log(`获取到收藏夹[${folder.title}]视频总数: ${folder.media_count}`);\n    }\n  } catch (error) {\n    console.error('获取收藏夹信息失败:', error);\n  }\n\n  // 设置要下载的收藏夹信息\n  favoriteDownloadInfo.value = {\n    title: `收藏夹: ${folder.title || '未命名收藏夹'}`,\n    author: folder.upper?.name || folder.creator_name || '未知创建者',\n    bvid: `fid_${(folder.id || folder.media_id || '').toString()}`,  // 使用特殊格式标识这是收藏夹ID\n    cover: folder.cover || '',\n    cid: 0,\n    // 添加额外信息供下载组件使用\n    is_favorite_folder: true,\n    user_id: (folder.mid || folder.creator_mid || '').toString(),\n    fid: (folder.id || folder.media_id || '').toString(),\n    // 添加视频总数信息，帮助下载对话框显示正确的总数\n    total_videos: folder.media_count || 0\n  };\n\n  // 打开下载对话框\n  showDownloadDialog.value = true;\n}\n\n// 处理下载完成\nfunction handleDownloadComplete() {\n  showNotify({ type: 'success', message: '收藏夹下载完成' });\n}\n</script>\n\n<style scoped>\n.animate-fadeIn {\n  animation: fadeIn 0.3s ease-in-out;\n}\n\n@keyframes fadeIn {\n  from { opacity: 0; }\n  to { opacity: 1; }\n}\n</style>\n"
  },
  {
    "path": "src/components/tailwind/page/History.vue",
    "content": "<template>\n  <!-- 主要内容区域 -->\n  <div>\n    <!-- 导航栏 -->\n    <Navbar\n      v-if=\"currentContent === 'history' && !showRemarks\"\n      @refresh-data=\"refreshData\"\n      v-model:date=\"date\"\n      v-model:category=\"category\"\n      v-model:business=\"business\"\n      v-model:businessLabel=\"businessLabel\"\n      v-model:pageSize=\"pageSize\"\n      :total=\"total\"\n      @click-date=\"show = true\"\n      :layout=\"layout\"\n      @change-layout=\"layout = $event\"\n      :is-batch-mode=\"isBatchMode\"\n      :show-remarks=\"showRemarks\"\n      @toggle-batch-mode=\"isBatchMode = !isBatchMode\"\n      @toggle-remarks=\"showRemarks = !showRemarks\"\n    />\n\n    <!-- 内容区域 -->\n    <div>\n      <div class=\"mx-auto max-w-7xl sm:px-2 lg:px-8\">\n        <div class=\"\">\n          <!-- 历史记录内容 -->\n          <HistoryContent\n            v-if=\"currentContent === 'history' && !showRemarks\"\n            ref=\"historyContentRef\"\n            :selected-year=\"selectedYear\"\n            :page=\"page\"\n            :pageSize=\"pageSize\"\n            @update:total-pages=\"totalPages = $event\"\n            @update:total=\"total = $event\"\n            @update:date=\"date = $event\"\n            @update:category=\"category = $event\"\n            v-model:show=\"show\"\n            v-model:showBottom=\"showBottom\"\n            :layout=\"layout\"\n            :date=\"date\"\n            :category=\"category\"\n            :business=\"business\"\n            :is-batch-mode=\"isBatchMode\"\n          />\n\n          <!-- 备注列表内容 -->\n          <Remarks v-else-if=\"showRemarks\" />\n\n          <!-- 设置内容 -->\n          <Settings v-else-if=\"currentContent === 'settings'\" />\n        </div>\n\n        <!-- 分页组件 -->\n        <div v-if=\"currentContent === 'history' && !showRemarks && total > 0\" class=\"mx-auto mb-5 mt-8 max-w-4xl\">\n          <Pagination :current-page=\"page\" :total-pages=\"totalPages\" :use-routing=\"true\" />\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, watch, onUnmounted } from 'vue'\nimport { useRouter, useRoute } from 'vue-router'\nimport Navbar from '../Navbar.vue'\nimport HistoryContent from '../HistoryContent.vue'\nimport Pagination from '../Pagination.vue'\nimport Settings from '../Settings.vue'\nimport Remarks from './Remarks.vue'\n\n// 定义 props\nconst props = defineProps({\n  defaultShowRemarks: {\n    type: Boolean,\n    default: false\n  }\n})\n\n// 当前显示的内容\nconst currentContent = ref('history')\n\n// 路由对象\nconst router = useRouter()\nconst route = useRoute()\n\n// 状态\nconst page = ref(parseInt(route.params.pageNumber) || 1)\nconst totalPages = ref(1)\nconst selectedYear = ref(new Date().getFullYear())\nconst show = ref(false)\nconst showBottom = ref(false)\nconst date = ref('')\nconst total = ref(0)\nconst category = ref('')\nconst layout = ref(localStorage.getItem('defaultLayout') || 'grid')\nconst isBatchMode = ref(false)\nconst showRemarks = ref(props.defaultShowRemarks)\nconst business = ref('')\nconst businessLabel = ref('')\nconst pageSize = ref(parseInt(localStorage.getItem('pageSize')) || 30)\n\n// 组件引用\nconst historyContentRef = ref(null)\n\n// 刷新数据方法\nconst refreshData = async () => {\n  console.log('History - refreshData 被调用')\n  console.log('当前日期:', date.value)\n  console.log('当前分区:', category.value)\n  console.log('当前业务类型:', business.value)\n  try {\n    if (historyContentRef.value && typeof historyContentRef.value.refreshData === 'function') {\n      await historyContentRef.value.refreshData()\n    } else if (historyContentRef.value && typeof historyContentRef.value.fetchHistoryByDateRange === 'function') {\n      await historyContentRef.value.fetchHistoryByDateRange()\n    } else {\n      console.error('刷新数据失败: HistoryContent 组件的 refreshData 方法不可用')\n    }\n  } catch (error) {\n    console.error('刷新数据失败:', error)\n  }\n}\n\n// 监听 date 和 category 的变化\nwatch([date, category], ([newDate, newCategory], [oldDate, oldCategory]) => {\n  console.log('History - date/category 变化:', {\n    date: { old: oldDate, new: newDate },\n    category: { old: oldCategory, new: newCategory }\n  })\n})\n\n// 监听路由变化\nwatch(\n  () => route.path,\n  (path) => {\n    if (path === '/settings') {\n      currentContent.value = 'settings'\n      showRemarks.value = false\n    } else if (path === '/remarks') {\n      currentContent.value = 'history'\n      showRemarks.value = true\n    } else if (path === '/' || path.startsWith('/page/')) {\n      currentContent.value = 'history'\n      showRemarks.value = false\n    }\n  },\n  { immediate: true }\n)\n\n// 组件挂载时设置初始状态\nonMounted(() => {\n  // 设置初始的备注显示状态\n  if (props.defaultShowRemarks || route.path === '/remarks') {\n    showRemarks.value = true\n  }\n\n  // 确保路由参数是单个字符串并进行类型转换\n  const pageParam = Array.isArray(route.params.pageNumber)\n    ? route.params.pageNumber[0]\n    : route.params.pageNumber\n  page.value = parseInt(pageParam) || 1\n\n  if (page.value !== 1 && !route.path.startsWith('/remarks')) {\n    router.push(`/page/${page.value}`)\n  }\n  \n  // 监听布局设置变更事件\n  window.addEventListener('layout-setting-changed', handleLayoutSettingChanged)\n})\n\n// 组件卸载时清理事件监听器\nonUnmounted(() => {\n  window.removeEventListener('layout-setting-changed', handleLayoutSettingChanged)\n})\n\n// 处理布局设置变更事件 - 从设置页面接收\nconst handleLayoutSettingChanged = (event) => {\n  if (event.detail && typeof event.detail.layout === 'string') {\n    layout.value = event.detail.layout\n  }\n}\n\n// 监听布局变化，同步到localStorage并触发全局事件\nwatch(layout, (newLayout) => {\n  // 保存到localStorage\n  localStorage.setItem('defaultLayout', newLayout)\n  \n  // 触发全局事件通知设置页面\n  try {\n    const event = new CustomEvent('layout-changed', { \n      detail: { layout: newLayout } \n    })\n    window.dispatchEvent(event)\n  } catch (error) {\n    console.error('触发布局变更事件失败:', error)\n  }\n})\n\n// 修改路由参数监听部分\nwatch(\n  [() => route.params.pageNumber, () => route.path],\n  ([newPage, path], [oldPage, oldPath]) => {\n    if (newPage === oldPage && path === oldPath) return\n\n    if (path === '/') {\n      if (page.value !== 1) {\n        page.value = 1\n      }\n    } else if (newPage) {\n      // 确保 newPage 是单个字符串\n      const pageStr = Array.isArray(newPage) ? newPage[0] : newPage\n      const pageNum = parseInt(pageStr)\n      if (page.value !== pageNum) {\n        page.value = pageNum\n      }\n    }\n  },\n  { immediate: true }\n)\n</script>\n\n<style scoped>\n@keyframes bounce-x {\n  0%, 100% {\n    transform: translateX(0);\n  }\n  50% {\n    transform: translateX(4px);\n  }\n}\n\n.animate-bounce-x {\n  animation: bounce-x 1.5s infinite;\n}\n</style>\n"
  },
  {
    "path": "src/components/tailwind/page/Images.vue",
    "content": "<!-- 图片管理页面 -->\n<template>\n  <div class=\"container mx-auto max-w-full\">\n    <!-- 操作按钮 -->\n    <div class=\"mb-6 flex flex-col space-y-4 sm:flex-row sm:space-y-0 sm:space-x-4\">\n      <div class=\"flex space-x-4\">\n        <button\n          @click=\"handleDownloadClick\"\n          :disabled=\"isLoading || isStoppingDownload\"\n          class=\"px-4 py-2 bg-[#fb7299] text-white rounded-md hover:bg-[#fb7299]/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 border border-[#fb7299]/20\"\n        >\n          <div class=\"flex items-center space-x-2\">\n            <svg v-if=\"isDownloading\" class=\"animate-spin h-5 w-5\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\">\n              <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n              <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n            </svg>\n            <svg v-else class=\"h-5 w-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4\" />\n            </svg>\n            <span>{{ isDownloading ? (isStoppingDownload ? '正在停止...' : '停止下载') : '下载图片' }}</span>\n          </div>\n        </button>\n\n        <button\n          @click=\"handleClear\"\n          :disabled=\"isDownloading || isLoading || isStoppingDownload\"\n          class=\"px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 border border-red-400/20\"\n        >\n          <div class=\"flex items-center space-x-2\">\n            <svg class=\"h-5 w-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16\" />\n            </svg>\n            <span>清空图片</span>\n          </div>\n        </button>\n      </div>\n\n      <!-- 下载选项 -->\n      <div class=\"flex items-center space-x-2 bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm rounded-md px-4 py-2 border border-gray-200 dark:border-gray-700\">\n        <input\n          type=\"checkbox\"\n          id=\"useSessdata\"\n          v-model=\"useSessdata\"\n          class=\"w-4 h-4 text-[#fb7299] bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-[#fb7299]\"\n        >\n        <label for=\"useSessdata\" class=\"text-sm text-gray-700 dark:text-gray-300\">\n          使用SESSDATA下载图片（对于公开内容如视频封面和头像，可以不使用SESSDATA）\n        </label>\n      </div>\n    </div>\n\n    <!-- 加载状态 -->\n    <div v-if=\"isLoading\" class=\"space-y-8\">\n      <div v-for=\"i in 2\" :key=\"i\" class=\"bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm rounded-lg p-6 border border-gray-200 dark:border-gray-700 animate-pulse\">\n        <div class=\"h-8 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-4\"></div>\n        <div class=\"grid grid-cols-2 sm:grid-cols-4 gap-4 mb-4\">\n          <div v-for=\"j in 4\" :key=\"j\" class=\"bg-white/50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700\">\n            <div class=\"h-8 bg-gray-200 dark:bg-gray-700 rounded w-16 mb-2\"></div>\n            <div class=\"h-4 bg-gray-200 dark:bg-gray-700 rounded w-20\"></div>\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <!-- 下载状态 -->\n    <div v-else class=\"space-y-8\">\n      <!-- 封面图片状态 -->\n      <div class=\"bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm rounded-lg p-6 border border-gray-200 dark:border-gray-700\">\n        <div class=\"flex items-center justify-between mb-4\">\n          <h2 class=\"text-xl font-semibold text-[#fb7299]\">封面图片</h2>\n          <span class=\"text-sm text-gray-500 dark:text-gray-400\">最后更新: {{ formatTime(status?.last_update) }}</span>\n        </div>\n\n        <!-- 进度条 -->\n        <div v-if=\"isDownloading\" class=\"mb-6\">\n          <div class=\"flex justify-between mb-1\">\n            <span class=\"text-sm text-gray-600 dark:text-gray-400\">下载进度</span>\n            <span class=\"text-sm text-gray-600 dark:text-gray-400\">{{ getProgressPercentage(status?.covers, 'covers') }}%</span>\n          </div>\n          <div class=\"w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5\">\n            <div class=\"bg-[#fb7299] h-2.5 rounded-full transition-all duration-500 animate-pulse\"\n                 :style=\"{ width: getProgressPercentage(status?.covers, 'covers') + '%' }\"></div>\n          </div>\n        </div>\n\n        <!-- 状态卡片网格 -->\n        <div class=\"grid grid-cols-2 sm:grid-cols-4 gap-4\">\n          <div class=\"bg-white/50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700\">\n            <div class=\"text-2xl font-bold text-[#fb7299]\">{{ status?.covers?.total || 0 }}</div>\n            <div class=\"text-sm text-gray-500 dark:text-gray-400\">计划下载</div>\n          </div>\n          <div class=\"bg-white/50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700\">\n            <div class=\"text-2xl font-bold text-[#fb7299]\">{{ status?.covers?.downloaded || 0 }}</div>\n            <div class=\"text-sm text-gray-500 dark:text-gray-400\">已下载</div>\n          </div>\n          <div class=\"bg-white/50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700\">\n            <div class=\"text-2xl font-bold text-red-500\">{{ status?.covers?.failed || 0 }}</div>\n            <div class=\"text-sm text-gray-500 dark:text-gray-400\">失败数</div>\n          </div>\n          <div class=\"bg-white/50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700\">\n            <div class=\"text-2xl font-bold text-blue-500\">{{ status?.covers?.total_planned || 0 }}</div>\n            <div class=\"text-sm text-gray-500 dark:text-gray-400\">待下载</div>\n          </div>\n        </div>\n\n        <!-- 失败列表 -->\n        <div v-if=\"status?.covers?.failed_urls?.length\" class=\"mt-4\">\n          <div class=\"flex items-center space-x-2 mb-2\">\n            <svg class=\"w-5 h-5 text-red-500\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n            </svg>\n            <h3 class=\"text-lg font-medium text-red-500\">失败列表</h3>\n          </div>\n          <div class=\"max-h-40 overflow-y-auto space-y-2\">\n            <div v-for=\"(item, index) in status.covers.failed_urls\" :key=\"index\"\n                 class=\"text-sm text-gray-600 dark:text-gray-300 p-2 bg-red-50 dark:bg-red-900/20 rounded border border-red-100 dark:border-red-900/30\">\n              <div class=\"flex justify-between\">\n                <span class=\"font-medium\">错误: {{ item.error }}</span>\n                <span class=\"text-gray-500 dark:text-gray-400\">{{ formatTime(item.timestamp) }}</span>\n              </div>\n              <div class=\"mt-1 text-gray-500 dark:text-gray-400 break-all\">URL: {{ item.url }}</div>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <!-- 头像图片状态 -->\n      <div class=\"bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm rounded-lg p-6 border border-gray-200 dark:border-gray-700\">\n        <div class=\"flex items-center justify-between mb-4\">\n          <h2 class=\"text-xl font-semibold text-[#fb7299]\">头像图片</h2>\n        </div>\n\n        <!-- 进度条 -->\n        <div v-if=\"isDownloading\" class=\"mb-6\">\n          <div class=\"flex justify-between mb-1\">\n            <span class=\"text-sm text-gray-600 dark:text-gray-400\">下载进度</span>\n            <span class=\"text-sm text-gray-600 dark:text-gray-400\">{{ getProgressPercentage(status?.avatars, 'avatars') }}%</span>\n          </div>\n          <div class=\"w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5\">\n            <div class=\"bg-[#fb7299] h-2.5 rounded-full transition-all duration-500 animate-pulse\"\n                 :style=\"{ width: getProgressPercentage(status?.avatars, 'avatars') + '%' }\"></div>\n          </div>\n        </div>\n\n        <!-- 状态卡片网格 -->\n        <div class=\"grid grid-cols-2 sm:grid-cols-4 gap-4\">\n          <div class=\"bg-white/50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700\">\n            <div class=\"text-2xl font-bold text-[#fb7299]\">{{ status?.avatars?.total || 0 }}</div>\n            <div class=\"text-sm text-gray-500 dark:text-gray-400\">计划下载</div>\n          </div>\n          <div class=\"bg-white/50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700\">\n            <div class=\"text-2xl font-bold text-[#fb7299]\">{{ status?.avatars?.downloaded || 0 }}</div>\n            <div class=\"text-sm text-gray-500 dark:text-gray-400\">已下载</div>\n          </div>\n          <div class=\"bg-white/50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700\">\n            <div class=\"text-2xl font-bold text-red-500\">{{ status?.avatars?.failed || 0 }}</div>\n            <div class=\"text-sm text-gray-500 dark:text-gray-400\">失败数</div>\n          </div>\n          <div class=\"bg-white/50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700\">\n            <div class=\"text-2xl font-bold text-blue-500\">{{ status?.avatars?.total_planned || 0 }}</div>\n            <div class=\"text-sm text-gray-500 dark:text-gray-400\">待下载</div>\n          </div>\n        </div>\n\n        <!-- 失败列表 -->\n        <div v-if=\"status?.avatars?.failed_urls?.length\" class=\"mt-4\">\n          <div class=\"flex items-center space-x-2 mb-2\">\n            <svg class=\"w-5 h-5 text-red-500\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n            </svg>\n            <h3 class=\"text-lg font-medium text-red-500\">失败列表</h3>\n          </div>\n          <div class=\"max-h-40 overflow-y-auto space-y-2\">\n            <div v-for=\"(item, index) in status.avatars.failed_urls\" :key=\"index\"\n                 class=\"text-sm text-gray-600 p-2 bg-red-50 rounded border border-red-100\">\n              <div class=\"flex justify-between\">\n                <span class=\"font-medium\">错误: {{ item.error }}</span>\n                <span class=\"text-gray-500\">{{ formatTime(item.timestamp) }}</span>\n              </div>\n              <div class=\"mt-1 text-gray-500 break-all\">URL: {{ item.url }}</div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, onUnmounted } from 'vue'\nimport { getImagesStatus, startImagesDownload, stopImagesDownload, clearImages } from '../../../api/api'\nimport { showNotify, showDialog } from 'vant'\nimport 'vant/es/notify/style'\nimport 'vant/es/dialog/style'\n\nconst status = ref(null)\nconst isDownloading = ref(false)\nconst isLoading = ref(true)\nconst isStoppingDownload = ref(false)  // 新增：是否正在停止下载\nconst useSessdata = ref(true)  // 新增：是否使用SESSDATA\nlet statusInterval = null\n\n// 格式化时间戳\nconst formatTime = (timestamp) => {\n  if (!timestamp) return '无'\n  const date = new Date(timestamp * 1000)\n  return date.toLocaleString('zh-CN', {\n    year: 'numeric',\n    month: '2-digit',\n    day: '2-digit',\n    hour: '2-digit',\n    minute: '2-digit',\n    second: '2-digit'\n  })\n}\n\n// 计算进度百分比\nconst getProgressPercentage = (data, type) => {\n  if (!data) return 0\n  return Math.round((data.downloaded / (data.total || 1)) * 100)\n}\n\n// 获取状态\nconst fetchStatus = async () => {\n  try {\n    const response = await getImagesStatus()\n    if (response.data.status === 'success') {\n      status.value = response.data.data\n      isDownloading.value = status.value.is_downloading\n      isLoading.value = false  // 数据加载完成，关闭加载状态\n\n      // 如果下载完成，停止轮询\n      if (!status.value.is_downloading && statusInterval) {\n        clearInterval(statusInterval)\n        statusInterval = null\n        showNotify({\n          type: 'success',\n          message: '下载已完成'\n        })\n        // 1秒后刷新页面\n        setTimeout(() => {\n          window.location.reload()\n        }, 1000)\n      }\n    }\n  } catch (error) {\n    console.error('获取状态失败:', error)\n    isLoading.value = false  // 发生错误时也关闭加载状态\n    if (statusInterval) {\n      clearInterval(statusInterval)\n      statusInterval = null\n    }\n  }\n}\n\n// 处理下载按钮点击\nconst handleDownloadClick = async () => {\n  if (isDownloading.value) {\n    // 如果已经在停止过程中，直接返回\n    if (isStoppingDownload.value) return\n\n    // 显示确认对话框\n    try {\n      await showDialog({\n        title: '确认停止',\n        message: '确定要停止当前下载任务吗？',\n        showCancelButton: true,\n        confirmButtonText: '确认停止',\n        cancelButtonText: '取消',\n        confirmButtonColor: '#ef4444'\n      })\n\n      // 设置停止状态，防止重复点击\n      isStoppingDownload.value = true\n\n      // 停止下载\n      const response = await stopImagesDownload()\n      if (response.data.status === 'success') {\n        showNotify({\n          type: 'success',\n          message: '已停止下载'\n        })\n        // 重置状态\n        isDownloading.value = false\n        isStoppingDownload.value = false\n\n        // 清除定时器\n        if (statusInterval) {\n          clearInterval(statusInterval)\n          statusInterval = null\n        }\n\n        // 刷新状态\n        await fetchStatus()\n      }\n    } catch (error) {\n      // 如果是用户取消，直接返回\n      if (error.toString().includes('cancel')) return\n\n      showNotify({\n        type: 'danger',\n        message: `停止下载失败: ${error.message}`\n      })\n    } finally {\n      // 如果停止失败，也需要重置停止状态\n      isStoppingDownload.value = false\n    }\n  } else {\n    // 如果未在下载，则开始下载\n    try {\n      const response = await startImagesDownload(null, useSessdata.value)\n      if (response.data.status === 'success') {\n        showNotify({\n          type: 'success',\n          message: response.data.message\n        })\n        isDownloading.value = true\n\n        // 立即获取一次状态\n        await fetchStatus()\n\n        // 开始定时获取状态\n        if (!statusInterval) {\n          statusInterval = setInterval(fetchStatus, 1000)\n        }\n      }\n    } catch (error) {\n      showNotify({\n        type: 'danger',\n        message: `开始下载失败: ${error.message}`\n      })\n    }\n  }\n}\n\n// 处理清空图片\nconst handleClear = async () => {\n  try {\n    await showDialog({\n      title: '确认清空',\n      message: '确定要清空所有图片和下载状态吗？此操作不可恢复。',\n      showCancelButton: true,\n      confirmButtonText: '确认清空',\n      cancelButtonText: '取消',\n      confirmButtonColor: '#ef4444'\n    })\n\n    const response = await clearImages()\n    if (response.data.status === 'success') {\n      showNotify({\n        type: 'success',\n        message: response.data.message\n      })\n      // 立即刷新状态\n      await fetchStatus()\n    } else {\n      throw new Error(response.data.message || '清空失败')\n    }\n  } catch (error) {\n    if (error.toString().includes('cancel')) return\n\n    showNotify({\n      type: 'danger',\n      message: error.response?.status === 500 ?\n        '服务器错误,请稍后重试' :\n        `清空失败: ${error.message}`\n    })\n  }\n}\n\n// 组件挂载时获取一次状态\nonMounted(() => {\n  fetchStatus()\n})\n\n// 组件卸载时清除定时器\nonUnmounted(() => {\n  if (statusInterval) {\n    clearInterval(statusInterval)\n  }\n})\n</script>\n\n<style scoped>\n.animate-pulse {\n  animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;\n}\n\n@keyframes pulse {\n  0%, 100% {\n    opacity: 1;\n  }\n  50% {\n    opacity: .7;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/tailwind/page/MediaManager.vue",
    "content": "<template>\n  <div class=\"min-h-screen bg-gray-50/30 dark:bg-gray-900\">\n    <div class=\"py-6\">\n      <div class=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8\">\n        <!-- 主内容卡片 -->\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg overflow-hidden\">\n          <!-- 标签导航 - 修改为设置页风格 -->\n          <div class=\"border-b border-gray-200 dark:border-gray-700\">\n            <nav class=\"-mb-px flex space-x-6 px-4 overflow-x-auto\" aria-label=\"媒体管理选项卡\">\n              <button\n                @click=\"activeTab = 'videos'\"\n                class=\"py-3 px-1 border-b-2 font-medium text-sm flex items-center space-x-2\"\n                :class=\"activeTab === 'videos'\n                  ? 'border-[#fb7299] text-[#fb7299]'\n                  : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'\"\n              >\n                <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z\" />\n                </svg>\n                <span>视频管理</span>\n              </button>\n\n              <button\n                @click=\"activeTab = 'dynamic'\"\n                class=\"py-3 px-1 border-b-2 font-medium text-sm flex items-center space-x-2\"\n                :class=\"activeTab === 'dynamic'\n                  ? 'border-[#fb7299] text-[#fb7299]'\n                  : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'\"\n              >\n                <span class=\"w-4 h-4 inline-flex items-center justify-center\" aria-hidden=\"true\">\n                  <svg width=\"20\" height=\"21\" viewBox=\"0 0 20 21\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\" class=\"w-4 h-4\">\n                    <g clip-path=\"url(#clip0)\">\n                      <path d=\"M10 10.743C7.69883 10.743 5.83333 8.87747 5.83333 6.5763C5.83333 4.27512 7.69883 2.40964 10 2.40964V10.743Z\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linejoin=\"round\"></path>\n                      <path d=\"M10 10.743C10 13.0441 8.1345 14.9096 5.83333 14.9096C3.53217 14.9096 1.66667 13.0441 1.66667 10.743H10Z\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linejoin=\"round\"></path>\n                      <path d=\"M10 10.743C10 8.44182 11.8655 6.57632 14.1667 6.57632C16.4679 6.57632 18.3333 8.44182 18.3333 10.743H10Z\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linejoin=\"round\"></path>\n                      <path d=\"M9.99999 10.743C12.3012 10.743 14.1667 12.6085 14.1667 14.9096C14.1667 17.2108 12.3012 19.0763 9.99999 19.0763V10.743Z\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linejoin=\"round\"></path>\n                    </g>\n                    <defs><clipPath id=\"clip0\"><rect width=\"20\" height=\"20\" fill=\"currentColor\" transform=\"matrix(-1 0 0 1 20 0.742981)\"></rect></clipPath></defs>\n                  </svg>\n                </span>\n                <span>动态下载</span>\n              </button>\n\n              <button\n                @click=\"activeTab = 'images'\"\n                class=\"py-3 px-1 border-b-2 font-medium text-sm flex items-center space-x-2\"\n                :class=\"activeTab === 'images'\n                  ? 'border-[#fb7299] text-[#fb7299]'\n                  : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'\"\n              >\n                <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z\" />\n                </svg>\n                <span>图片管理</span>\n              </button>\n\n              <button\n                @click=\"activeTab = 'remarks'\"\n                class=\"py-3 px-1 border-b-2 font-medium text-sm flex items-center space-x-2\"\n                :class=\"activeTab === 'remarks'\n                  ? 'border-[#fb7299] text-[#fb7299]'\n                  : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'\"\n              >\n                <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z\" />\n                </svg>\n                <span>我的备注</span>\n              </button>\n\n              <button\n                @click=\"activeTab = 'comments'\"\n                class=\"py-3 px-1 border-b-2 font-medium text-sm flex items-center space-x-2\"\n                :class=\"activeTab === 'comments'\n                  ? 'border-[#fb7299] text-[#fb7299]'\n                  : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'\"\n              >\n                <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z\" />\n                </svg>\n                <span>我的评论</span>\n              </button>\n\n              <button\n                @click=\"activeTab = 'details'\"\n                class=\"py-3 px-1 border-b-2 font-medium text-sm flex items-center space-x-2\"\n                :class=\"activeTab === 'details'\n                  ? 'border-[#fb7299] text-[#fb7299]'\n                  : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'\"\n              >\n                <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n                </svg>\n                <span>视频详情</span>\n              </button>\n            </nav>\n          </div>\n\n          <!-- 内容区域 -->\n          <div class=\"transition-all duration-300 p-5\">\n            <!-- 动态下载 -->\n            <div v-if=\"activeTab === 'dynamic'\" class=\"animate-fadeIn\">\n              <DynamicDownloader />\n            </div>\n\n            <!-- 图片管理 -->\n            <div v-if=\"activeTab === 'images'\" class=\"animate-fadeIn\">\n              <Images />\n            </div>\n\n            <!-- 视频管理 -->\n            <div v-if=\"activeTab === 'videos'\" class=\"animate-fadeIn\">\n              <!-- ArtPlayer致谢 - 只在视频标签显示 -->\n              <div class=\"mb-4 flex items-center justify-center h-9 px-3 py-0 bg-[#fb7299]/5 dark:bg-pink-900/20 rounded-md border border-[#fb7299]/20 dark:border-[#fb7299]/30\">\n                <a href=\"https://github.com/zhw2590582/ArtPlayer\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"flex items-center hover:opacity-80 transition-opacity mr-1.5\">\n                  <img src=\"https://artplayer.org/document/logo.png\" alt=\"ArtPlayer Logo\" class=\"h-3.5 mr-1.5\" />\n                </a>\n                <span class=\"text-xs text-gray-700 dark:text-gray-300\">视频播放通过\n                  <a\n                    href=\"https://github.com/zhw2590582/ArtPlayer\"\n                    target=\"_blank\"\n                    rel=\"noopener noreferrer\"\n                    class=\"text-[#fb7299] font-medium hover:underline\"\n                  >\n                    ArtPlayer\n                  </a>\n                  项目实现，感谢ArtPlayer团队的开源贡献\n                </span>\n              </div>\n              <Downloads />\n            </div>\n\n            <!-- 我的备注 -->\n            <div v-if=\"activeTab === 'remarks'\" class=\"animate-fadeIn\">\n              <History :defaultShowRemarks=\"true\" />\n            </div>\n\n            <!-- 我的评论 -->\n            <div v-if=\"activeTab === 'comments'\" class=\"animate-fadeIn\">\n              <Comments />\n            </div>\n\n            <!-- 视频详情管理 -->\n            <div v-if=\"activeTab === 'details'\" class=\"animate-fadeIn\">\n              <VideoDetailsManager />\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, watch } from 'vue'\nimport { useRoute } from 'vue-router'\nimport Images from './Images.vue'\nimport Downloads from './Downloads.vue'\nimport VideoDetailsManager from './VideoDetailsManager.vue'\nimport History from './History.vue'\nimport Comments from './Comments.vue'\nimport DynamicDownloader from './DynamicDownloader.vue'\n\nconst route = useRoute()\n\n// 当前激活的标签\nconst activeTab = ref('videos')\n\n// 监听路由变化以更新激活的标签\nwatch(\n  () => route.query.tab,\n  (tab) => {\n    if (tab && ['images', 'videos', 'remarks', 'comments', 'details', 'dynamic'].includes(tab)) {\n      activeTab.value = tab\n    }\n  },\n  { immediate: true }\n)\n\n// 组件挂载时根据URL初始化标签\nonMounted(() => {\n  const { tab } = route.query\n  if (tab && ['images', 'videos', 'remarks', 'comments', 'details', 'dynamic'].includes(tab)) {\n    activeTab.value = tab\n  }\n})\n</script>\n\n<style scoped>\n.animate-fadeIn {\n  animation: fadeIn 0.3s ease-in-out;\n}\n\n@keyframes fadeIn {\n  from { opacity: 0; }\n  to { opacity: 1; }\n}\n</style>"
  },
  {
    "path": "src/components/tailwind/page/Remarks.vue",
    "content": "<template>\n  <div>\n    <!-- 页面标题 -->\n    <div class=\"flex items-center justify-between mb-8\">\n      <div class=\"flex items-center space-x-3 text-gray-900 dark:text-gray-100\">\n        <svg class=\"w-7 h-7 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n          <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z\" />\n        </svg>\n        <h1 class=\"text-2xl font-medium\">我的备注</h1>\n      </div>\n      <div class=\"text-sm text-gray-500 dark:text-gray-400\">\n        共 {{ total }} 条备注\n      </div>\n    </div>\n\n    <!-- 备注列表 -->\n    <div v-if=\"remarkRecords.length > 0\" class=\"grid grid-cols-1 gap-6\">\n      <div v-for=\"record in remarkRecords\"\n            :key=\"record.bvid + record.view_at\"\n            class=\"bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-all duration-300 border border-gray-200 dark:border-gray-700\">\n        <div class=\"flex p-4 space-x-6\">\n          <!-- 左侧：视频信息 -->\n          <div class=\"w-64 flex-shrink-0\">\n            <!-- 视频封面 -->\n            <div class=\"relative w-full aspect-video overflow-hidden rounded-lg mb-3\">\n              <img\n                :src=\"normalizeImageUrl(record.cover)\"\n                :class=\"{ 'blur-md': isPrivacyMode }\"\n                class=\"w-full h-full object-cover\"\n                alt=\"\"\n              />\n              <!-- 视频时长 -->\n              <div class=\"absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-1 rounded\">\n                {{ formatDuration(record.duration) }}\n              </div>\n              <!-- 观看进度条 -->\n              <div class=\"absolute bottom-0 left-0 w-full h-1 bg-gray-200 dark:bg-gray-700\">\n                <div\n                  class=\"h-full bg-[#fb7299]\"\n                  :style=\"{ width: getProgressWidth(record.progress, record.duration) }\"\n                ></div>\n              </div>\n            </div>\n\n            <!-- 视频标题 -->\n            <h3 class=\"text-sm font-medium text-gray-900 dark:text-gray-100 mb-2 line-clamp-2 hover:line-clamp-none\"\n                :class=\"{ 'blur-sm': isPrivacyMode }\"\n                v-html=\"isPrivacyMode ? '******' : record.title\">\n            </h3>\n\n            <!-- UP主信息和时间 -->\n            <div class=\"flex items-center space-x-2 mb-2\">\n              <img\n                :src=\"normalizeImageUrl(record.author_face)\"\n                :class=\"{ 'blur-md': isPrivacyMode }\"\n                class=\"w-4 h-4 rounded-full\"\n                alt=\"\"\n              />\n              <span class=\"text-xs text-gray-600 dark:text-gray-300\"\n                    :class=\"{ 'blur-sm': isPrivacyMode }\"\n                    v-text=\"isPrivacyMode ? '******' : record.author_name\">\n              </span>\n            </div>\n            <div class=\"flex items-center justify-between text-xs text-gray-400 dark:text-gray-400\">\n              <span>{{ formatTimestamp(record.view_at) }}</span>\n              <button\n                @click=\"openVideo(record)\"\n                class=\"inline-flex items-center space-x-1 text-[#fb7299] hover:text-[#fb7299]/80 transition-colors duration-200\"\n              >\n                <svg class=\"w-3 h-3\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z\" />\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n                </svg>\n                <span>查看视频</span>\n              </button>\n            </div>\n          </div>\n\n          <!-- 右侧：备注内容 -->\n          <div class=\"flex-1 min-w-0 relative\">\n            <van-field\n              v-model=\"record.remark\"\n              :disabled=\"isPrivacyMode\"\n              @blur=\"handleRemarkUpdate(record)\"\n              type=\"textarea\"\n              rows=\"8\"\n              :autosize=\"{ minHeight: 160 }\"\n              :placeholder=\"'添加备注...'\"\n              class=\"remarks-field !bg-transparent\"\n            />\n            <div v-if=\"record.remark_time\" class=\"absolute bottom-1 right-2 text-xs text-gray-400 dark:text-gray-500\">\n              最后更新: {{ formatRemarkTime(record.remark_time) }}\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <!-- 空状态显示 -->\n    <div v-else class=\"flex flex-col items-center justify-center py-16 bg-white dark:bg-gray-900 rounded-lg\">\n      <svg class=\"w-20 h-20 text-gray-300 dark:text-gray-600 mb-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.5\" d=\"M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z\" />\n      </svg>\n      <h3 class=\"text-xl font-medium text-gray-600 dark:text-gray-300 mb-2\">暂无备注</h3>\n      <p class=\"text-gray-500 dark:text-gray-400 mb-6\">当你添加备注后，将在这里显示</p>\n      <button \n        class=\"px-4 py-2 bg-[#fb7299] text-white rounded-md hover:bg-[#fb7299]/90 transition-colors duration-200 flex items-center space-x-2\"\n        @click=\"$router.push('/')\">\n        <svg class=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n          <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 19l-7-7 7-7\" />\n        </svg>\n        <span>返回视频列表</span>\n      </button>\n    </div>\n\n    <!-- 分页组件 -->\n    <div v-if=\"remarkRecords.length > 0\" class=\"mt-8\">\n      <Pagination\n        :current-page=\"page\"\n        :total-pages=\"totalPages\"\n        :use-routing=\"false\"\n        @page-change=\"handlePageChange\"\n      />\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted } from 'vue'\nimport { usePrivacyStore } from '../../../store/privacy'\nimport { getAllRemarks, updateVideoRemark, batchGetRemarks } from '../../../api/api'\nimport { showNotify } from 'vant'\nimport Pagination from '../Pagination.vue'\nimport { normalizeImageUrl } from '@/utils/imageUrl.js'\n\nconst { isPrivacyMode } = usePrivacyStore()\n\n// 状态管理\nconst page = ref(1)\nconst totalPages = ref(1)\nconst total = ref(0)\nconst remarkRecords = ref([])\n\n// 获取备注列表\nconst fetchRemarks = async () => {\n  try {\n    const response = await getAllRemarks(page.value, 12) // 每页显示12条\n    if (response.data.status === 'success') {\n      const records = response.data.data.records\n      // 构建批量查询请求\n      const batchRecords = records.map(record => ({\n        bvid: record.bvid,\n        view_at: record.view_at\n      }))\n      \n      // 批量获取备注信息\n      const remarksResponse = await batchGetRemarks(batchRecords)\n      if (remarksResponse.data.status === 'success') {\n        remarkRecords.value = records.map(record => {\n          const key = `${record.bvid}_${record.view_at}`\n          const remarkData = remarksResponse.data.data[key] || {}\n          return {\n            ...record,\n            remark: remarkData.remark || '',\n            remark_time: remarkData.remark_time,\n            originalRemark: remarkData.remark || '' // 保存原始备注内容\n          }\n        })\n      }\n      \n      totalPages.value = Math.ceil(response.data.data.total / response.data.data.size)\n      total.value = response.data.data.total\n    } else {\n      throw new Error(response.data.message || '获取备注列表失败')\n    }\n  } catch (error) {\n    showNotify({\n      type: 'danger',\n      message: error.message\n    })\n  }\n}\n\n// 处理分页变化\nconst handlePageChange = (newPage) => {\n  page.value = newPage\n  fetchRemarks()\n}\n\n// 打开视频\nconst openVideo = (record) => {\n  const url = `https://www.bilibili.com/video/${record.bvid}`\n  window.open(url, '_blank')\n}\n\n// 格式化时间戳\nconst formatTimestamp = (timestamp) => {\n  const date = new Date(timestamp * 1000)\n  return date.toLocaleString('zh-CN', {\n    year: 'numeric',\n    month: '2-digit',\n    day: '2-digit',\n    hour: '2-digit',\n    minute: '2-digit'\n  })\n}\n\n// 格式化时长\nconst formatDuration = (seconds) => {\n  if (seconds === -1) return '已看完'\n  const minutes = String(Math.floor(seconds / 60)).padStart(2, '0')\n  const secs = String(seconds % 60).padStart(2, '0')\n  return `${minutes}:${secs}`\n}\n\n// 获取进度条宽度\nconst getProgressWidth = (progress, duration) => {\n  if (progress === -1) return '100%'\n  if (duration === 0) return '0%'\n  return `${(progress / duration) * 100}%`\n}\n\n// 格式化备注时间\nconst formatRemarkTime = (timestamp) => {\n  if (!timestamp) return ''\n  const date = new Date(timestamp * 1000)\n  return date.toLocaleString('zh-CN', {\n    year: 'numeric',\n    month: '2-digit',\n    day: '2-digit',\n    hour: '2-digit',\n    minute: '2-digit'\n  })\n}\n\n// 处理备注更新\nconst handleRemarkUpdate = async (record) => {\n  const newValue = record.remark\n  if (newValue === record.originalRemark) return\n\n  try {\n    const response = await updateVideoRemark(\n      record.bvid,\n      record.view_at,\n      newValue\n    )\n    if (response.data.status === 'success') {\n      record.originalRemark = newValue\n      record.remark_time = response.data.data.remark_time // 更新备注时间\n      showNotify({\n        type: 'success',\n        message: '备注已更新'\n      })\n    } else {\n      throw new Error(response.data.message)\n    }\n  } catch (error) {\n    showNotify({\n      type: 'danger',\n      message: error.message\n    })\n    // 恢复原值\n    record.remark = record.originalRemark\n  }\n}\n\n// 组件挂载时获取数据\nonMounted(() => {\n  fetchRemarks()\n})\n\n// 默认导出\ndefineOptions({\n  name: 'Remarks'\n})\n</script>\n\n<style scoped>\n.line-clamp-2 {\n  display: -webkit-box;\n  -webkit-box-orient: vertical;\n  -webkit-line-clamp: 2;\n  overflow: hidden;\n}\n\n:deep(.remarks-field) {\n  height: 100%;\n}\n\n:deep(.remarks-field .van-field__control) {\n  padding: 12px 16px;\n  background-color: rgba(251, 114, 153, 0.03);\n  border-radius: 8px;\n  font-size: 14px;\n  line-height: 1.6;\n  color: #4b5563;\n  transition: all 0.2s ease;\n  resize: none;\n  min-height: 160px !important;\n  border: 1px solid transparent;\n}\n\n:deep(.remarks-field .van-field__control:hover) {\n  background-color: rgba(251, 114, 153, 0.05);\n}\n\n:deep(.remarks-field .van-field__control:focus) {\n  background-color: rgba(251, 114, 153, 0.05);\n  border-color: #fb7299;\n  box-shadow: 0 0 0 2px rgba(251, 114, 153, 0.1);\n  outline: none;\n}\n\n:deep(.remarks-field .van-field__control::placeholder) {\n  color: #9ca3af;\n}\n\n:deep(.remarks-field.van-field) {\n  padding: 0;\n  border: none;\n}\n\n:deep(.van-field__error-message) {\n  display: none;\n}\n\n/* Dark mode overrides */\n:deep(.dark .remarks-field .van-field__control) {\n  background-color: rgba(251, 114, 153, 0.06);\n  color: #e5e7eb;\n  border-color: rgba(255, 255, 255, 0.08);\n}\n\n:deep(.dark .remarks-field .van-field__control:hover) {\n  background-color: rgba(251, 114, 153, 0.08);\n}\n\n:deep(.dark .remarks-field .van-field__control:focus) {\n  background-color: rgba(251, 114, 153, 0.08);\n  border-color: #fb7299;\n  box-shadow: 0 0 0 2px rgba(251, 114, 153, 0.15);\n  outline: none;\n}\n\n:deep(.dark .remarks-field .van-field__control::placeholder) {\n  color: #9ca3af;\n}\n</style>\n"
  },
  {
    "path": "src/components/tailwind/page/SchedulerTasks.vue",
    "content": "<template>\n  <div class=\"min-h-screen bg-gray-50/30 dark:bg-gray-900\">\n    <div class=\"py-4\">\n      <div class=\"max-w-7xl mx-auto px-3\">\n        <!-- 任务列表包装层 - 添加相对定位以便放置新建按钮 -->\n        <div class=\"relative mb-6\">\n          <!-- 任务列表 -->\n          <div v-if=\"loading\" class=\"flex justify-center items-center py-20\">\n            <div class=\"animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-[#fb7299]\"></div>\n          </div>\n\n          <div v-else-if=\"tasks.length === 0\" class=\"bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 text-center\">\n            <!-- 在空状态页面添加标题和新建按钮 -->\n            <div class=\"flex justify-between items-center mb-6\">\n              <h3 class=\"text-base font-medium text-gray-900 dark:text-gray-100\">计划任务</h3>\n              <button \n                @click=\"openCreateTaskModal\" \n                class=\"text-[#fb7299] hover:text-[#fb7299]/80 transition-colors text-sm font-medium\"\n              >\n                新建任务\n              </button>\n            </div>\n            \n            <svg class=\"mx-auto h-12 w-12 text-gray-400\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2\" />\n            </svg>\n            <h3 class=\"mt-2 text-sm font-medium text-gray-900 dark:text-gray-100\">暂无计划任务</h3>\n            <p class=\"mt-1 text-sm text-gray-500 dark:text-gray-400\">点击\"新建任务\"按钮创建您的第一个计划任务</p>\n          </div>\n\n          <div v-else class=\"bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden\">\n            <!-- 添加表格标题 -->\n            <div class=\"px-4 py-3 flex justify-between items-center border-b border-gray-200 dark:border-gray-700\">\n              <h3 class=\"text-base font-medium text-gray-900 dark:text-gray-100\">计划任务</h3>\n              <button \n                @click=\"openCreateTaskModal\" \n                class=\"text-[#fb7299] hover:text-[#fb7299]/80 transition-colors text-sm font-medium\"\n              >\n                新建任务\n              </button>\n            </div>\n            \n            <div class=\"overflow-x-auto\">\n              <table class=\"min-w-full divide-y divide-gray-200 dark:divide-gray-700\">\n                <thead class=\"bg-gray-50 dark:bg-gray-800\">\n                  <tr>\n                    <th scope=\"col\" class=\"px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider\">任务ID</th>\n                    <th scope=\"col\" class=\"px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider\">名称</th>\n                    <th scope=\"col\" class=\"px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider\">调度类型</th>\n                    <th scope=\"col\" class=\"px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider\">调度时间</th>\n                    <th scope=\"col\" class=\"px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider\">成功率</th>\n                    <th scope=\"col\" class=\"px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider\">最后执行</th>\n                    <th scope=\"col\" class=\"px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider\">状态</th>\n                    <th scope=\"col\" class=\"px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider\">\n                      <!-- 移除这里的新建任务按钮，因为已经添加到标题中 -->\n                      操作\n                    </th>\n                  </tr>\n                </thead>\n                <tbody class=\"bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700\">\n                  <template v-for=\"task in tasks\" :key=\"task.task_id\">\n                    <!-- 主任务行 -->\n                    <tr class=\"hover:bg-gray-50 dark:hover:bg-gray-700 border-t-2 border-gray-100 dark:border-gray-700\">\n                      <td class=\"px-4 py-3 whitespace-nowrap text-xs font-medium text-gray-900 dark:text-gray-100\">\n                        <div class=\"flex items-center space-x-1\">\n                          <button \n                            v-if=\"task.sub_tasks && task.sub_tasks.length > 0\"\n                            @click=\"task.isExpanded = !task.isExpanded\"\n                            class=\"p-0.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors\"\n                          >\n                            <svg \n                              class=\"w-3.5 h-3.5 text-gray-500 transform transition-transform\"\n                              :class=\"{'rotate-90': task.isExpanded}\"\n                              fill=\"none\" \n                              viewBox=\"0 0 24 24\" \n                              stroke=\"currentColor\"\n                            >\n                              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 5l7 7-7 7\" />\n                            </svg>\n                          </button>\n                          {{ task.task_id }}\n                        </div>\n                      </td>\n                      <td class=\"px-4 py-3 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400\">\n                        <div class=\"flex items-center\">\n                          {{ task.config?.name || task.task_id }}\n                          <span v-if=\"task.sub_tasks && task.sub_tasks.length > 0\" \n                                class=\"ml-2 px-1.5 py-0.5 text-xs rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300\">\n                            {{ task.sub_tasks.length }}个子任务\n                          </span>\n                        </div>\n                      </td>\n                      <td class=\"px-4 py-3 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400\">\n                        <span \n                          :class=\"{\n                            'bg-blue-100 text-blue-800': task.config?.schedule_type === 'daily',\n                            'bg-purple-100 text-purple-800': task.config?.schedule_type === 'chain',\n                            'bg-green-100 text-green-800': task.config?.schedule_type === 'once',\n                            'bg-yellow-100 text-yellow-800': task.config?.schedule_type === 'interval'\n                          }\" \n                          class=\"px-1.5 inline-flex text-xs leading-5 font-semibold rounded-full\"\n                        >\n                          {{ \n                            task.config?.schedule_type === 'daily' ? '每日' : \n                            task.config?.schedule_type === 'chain' ? '链式' : \n                            task.config?.schedule_type === 'once' ? '一次性' : \n                            task.config?.schedule_type === 'interval' ? '间隔' : \n                            task.config?.schedule_type \n                          }}\n                        </span>\n                      </td>\n                      <td class=\"px-4 py-3 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400\">\n                        {{ \n                          task.config?.schedule_type === 'chain' ? '依赖主任务' : \n                          task.config?.schedule_type === 'interval' ? \n                            (task.config?.interval_value || '-') + ' ' + \n                            (task.config?.interval_unit === 'minutes' ? '分钟' : \n                             task.config?.interval_unit === 'hours' ? '小时' : \n                             task.config?.interval_unit === 'days' ? '天' : \n                             task.config?.interval_unit === 'months' ? '月' : \n                             task.config?.interval_unit === 'years' ? '年' : \n                             task.config?.interval_unit || '') : \n                          task.config?.schedule_time || '-' \n                        }}\n                      </td>\n                      <td class=\"px-4 py-3 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400\">\n                        <span v-if=\"task.execution?.success_rate !== undefined\" class=\"inline-flex items-center\">\n                          {{ Math.round(task.execution.success_rate) }}%\n                          <div class=\"ml-1.5 h-1 w-12 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden\">\n                            <div \n                              class=\"h-full rounded-full\" \n                              :class=\"{\n                                'bg-green-500': task.execution.success_rate >= 90,\n                                'bg-yellow-500': task.execution.success_rate >= 60 && task.execution.success_rate < 90,\n                                'bg-red-500': task.execution.success_rate < 60\n                              }\"\n                              :style=\"{width: `${task.execution.success_rate}%`}\"\n                            ></div>\n                          </div>\n                        </span>\n                        <span v-else>-</span>\n                      </td>\n                      <td class=\"px-4 py-3 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400\">\n                        {{ task.execution?.last_run || '未记录' }}\n                      </td>\n                      <td class=\"px-4 py-3 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400\">\n                        <span \n                          :class=\"{\n                            'bg-green-100 text-green-800': task.config?.enabled === true,\n                            'bg-red-100 text-red-800': task.config?.enabled === false\n                          }\" \n                          class=\"px-1.5 inline-flex text-xs leading-5 font-semibold rounded-full\"\n                        >\n                          {{ task.config?.enabled ? '已启用' : '已禁用' }}\n                        </span>\n                      </td>\n                      <td class=\"px-4 py-3 whitespace-nowrap text-right text-xs font-medium\">\n                        <div class=\"flex justify-end space-x-1.5\">\n                          <button \n                            @click=\"openTaskDetailModal(task.task_id)\" \n                            class=\"text-indigo-600 hover:text-indigo-900\"\n                          >\n                            详情\n                          </button>\n                          <button \n                            @click=\"openEditTaskModal(task.task_id)\" \n                            class=\"text-blue-600 hover:text-blue-900\"\n                          >\n                            编辑\n                          </button>\n                          <button \n                            @click=\"openCreateSubTaskModal(task.task_id)\" \n                            class=\"text-purple-600 hover:text-purple-900\"\n                          >\n                            添加子任务\n                          </button>\n                          <button \n                            @click=\"executeTask(task.task_id)\" \n                            class=\"text-green-600 hover:text-green-900\"\n                          >\n                            执行\n                          </button>\n                          <button \n                            v-if=\"task.config?.enabled !== undefined\"\n                            @click=\"toggleTaskEnabled(task.task_id, !task.config.enabled)\" \n                            :class=\"task.config.enabled ? 'text-orange-600 hover:text-orange-900' : 'text-teal-600 hover:text-teal-900'\"\n                          >\n                            {{ task.config.enabled ? '禁用' : '启用' }}\n                          </button>\n                          <button \n                            @click=\"confirmDeleteTask(task.task_id)\" \n                            class=\"text-red-600 hover:text-red-900\"\n                          >\n                            删除\n                          </button>\n                        </div>\n                      </td>\n                    </tr>\n                    <!-- 子任务行 -->\n                    <template v-if=\"task.sub_tasks && task.sub_tasks.length > 0 && task.isExpanded\">\n                      <tr v-for=\"subTask in task.sub_tasks\" \n                          :key=\"subTask.task_id\" \n                          class=\"bg-[#fff8fa] dark:bg-pink-900/10 hover:bg-[#fff2f6] dark:hover:bg-pink-900/20 border-l-4 border-[#fb7299]/30\">\n                        <td class=\"pl-12 pr-4 py-2.5 whitespace-nowrap text-xs font-medium text-gray-900 dark:text-gray-100\">\n                          <div class=\"flex items-center\">\n                            <svg class=\"w-3.5 h-3.5 text-gray-400 mr-1.5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z\" />\n                            </svg>\n                            {{ subTask.task_id }}\n                          </div>\n                        </td>\n                        <td class=\"px-4 py-3 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400\">\n                          {{ subTask.config?.name || subTask.task_id }}\n                        </td>\n                        <td class=\"px-4 py-3 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400\">\n                          <span class=\"px-1.5 inline-flex text-xs leading-5 font-semibold rounded-full bg-purple-100 text-purple-800\">\n                            链式\n                          </span>\n                        </td>\n                        <td class=\"px-4 py-3 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400\">\n                          依赖主任务\n                        </td>\n                        <td class=\"px-4 py-3 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400\">\n                          <span v-if=\"subTask.execution?.success_rate !== undefined\" class=\"inline-flex items-center\">\n                            {{ Math.round(subTask.execution.success_rate) }}%\n                            <div class=\"ml-1.5 h-1 w-12 bg-gray-200 rounded-full overflow-hidden\">\n                              <div \n                                class=\"h-full rounded-full\" \n                                :class=\"{\n                                  'bg-green-500': subTask.execution.success_rate >= 90,\n                                  'bg-yellow-500': subTask.execution.success_rate >= 60 && subTask.execution.success_rate < 90,\n                                  'bg-red-500': subTask.execution.success_rate < 60\n                                }\"\n                                :style=\"{width: `${subTask.execution.success_rate}%`}\"\n                              ></div>\n                            </div>\n                          </span>\n                          <span v-else>-</span>\n                        </td>\n                        <td class=\"px-4 py-3 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400\">\n                          {{ subTask.execution?.last_run || '未记录' }}\n                        </td>\n                        <td class=\"px-4 py-3 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400\">\n                          <span \n                            :class=\"{\n                              'bg-green-100 text-green-800': subTask.config?.enabled === true,\n                              'bg-red-100 text-red-800': subTask.config?.enabled === false\n                            }\" \n                            class=\"px-1.5 inline-flex text-xs leading-5 font-semibold rounded-full\"\n                          >\n                            {{ subTask.config?.enabled ? '已启用' : '已禁用' }}\n                          </span>\n                        </td>\n                        <td class=\"px-4 py-3 whitespace-nowrap text-right text-xs font-medium\">\n                          <div class=\"flex justify-end space-x-1.5\">\n                            <button \n                              @click=\"openTaskDetailModal(subTask.task_id)\" \n                              class=\"text-indigo-600 hover:text-indigo-900\"\n                            >\n                              详情\n                            </button>\n                            <button \n                              @click=\"openEditTaskModal(subTask.task_id)\" \n                              class=\"text-blue-600 hover:text-blue-900\"\n                            >\n                              编辑\n                            </button>\n                            <button \n                              v-if=\"subTask.config?.enabled !== undefined\"\n                              @click=\"toggleTaskEnabled(subTask.task_id, !subTask.config.enabled)\" \n                              :class=\"subTask.config.enabled ? 'text-orange-600 hover:text-orange-900' : 'text-teal-600 hover:text-teal-900'\"\n                            >\n                              {{ subTask.config.enabled ? '禁用' : '启用' }}\n                            </button>\n                            <button \n                              @click=\"confirmDeleteTask(subTask.task_id, task.task_id)\" \n                              class=\"text-red-600 hover:text-red-900\"\n                            >\n                              删除\n                            </button>\n                          </div>\n                        </td>\n                      </tr>\n                    </template>\n                  </template>\n                </tbody>\n              </table>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n\n  <!-- 任务详情弹窗 -->\n  <TaskDetail\n    v-model:show=\"showTaskDetailModal\"\n    :task=\"currentTask\"\n    @view-history=\"fetchTaskHistory\"\n    @edit-task=\"openEditTaskModal\"\n    @execute-task=\"executeTask\"\n    @toggle-enabled=\"toggleTaskEnabled\"\n    @delete-task=\"confirmDeleteTask\"\n    @refresh=\"fetchTasks\"\n  />\n\n  <!-- 任务历史弹窗 -->\n  <TaskHistory\n    v-model:show=\"showTaskHistoryModal\"\n    :task-id=\"currentTask?.task_id\"\n    :task-name=\"currentTask?.config?.name || currentTask?.task_id\"\n  />\n\n  <!-- 创建/编辑任务弹窗 -->\n  <TaskForm\n    v-model:show=\"showTaskFormModal\"\n    :is-editing=\"isEditing\"\n    :task-id=\"currentTask?.task_id\"\n    :parent-task-id=\"parentTaskId\"\n    :tasks=\"tasks\"\n    @task-saved=\"fetchTasks\"\n  />\n</template>\n\n<script setup>\nimport { ref, reactive, onMounted, computed, watch } from 'vue'\nimport { showNotify, showDialog } from 'vant'\nimport 'vant/es/dialog/style'\nimport 'vant/es/notify/style'\nimport 'vant/es/loading/style'\nimport { \n  getAllSchedulerTasks, \n  getSchedulerTaskDetail, \n  createSchedulerTask, \n  updateSchedulerTask, \n  deleteSchedulerTask, \n  executeSchedulerTask,\n  getTaskHistory,\n  setTaskEnabled,\n  deleteSubTask\n} from '../../../api/api'\nimport TaskForm from '../scheduler/TaskForm.vue'\nimport TaskDetail from '../scheduler/TaskDetail.vue'\nimport TaskHistory from '../scheduler/TaskHistory.vue'\n\n// 防抖函数\nconst debounce = (fn, delay) => {\n  let timer = null\n  return function (...args) {\n    if (timer) clearTimeout(timer)\n    timer = setTimeout(() => {\n      fn.apply(this, args)\n    }, delay)\n  }\n}\n\n// 加载状态\nconst loading = ref(false)\n\n// 任务列表\nconst tasks = ref([])\n\n// 当前任务\nconst currentTask = ref(null)\n\n// 弹窗控制\nconst showTaskFormModal = ref(false)\nconst showTaskDetailModal = ref(false)\nconst showTaskHistoryModal = ref(false)\nconst selectedTaskHistory = ref([])\n\n// 是否为编辑模式\nconst isEditing = ref(false)\n\n// 搜索关键词\nconst searchKeyword = ref('')\n\n// 标签输入\nconst newTagInput = ref('')\n\n// 父任务ID\nconst parentTaskId = ref(null)\n\n// 执行任务\nconst executeTask = async (taskId) => {\n  try {\n    const response = await executeSchedulerTask(taskId, {\n      wait_for_completion: false\n    })\n    \n    if (response.data && response.data.status === 'success') {\n      showNotify({ type: 'success', message: '任务执行已启动' })\n      // 刷新任务列表\n      fetchTasks()\n    } else {\n      const errorMessage = '执行任务失败: ' + (response.data?.message || '未知错误')\n      showNotify({ type: 'danger', message: errorMessage })\n    }\n  } catch (error) {\n    console.error('执行任务出错:', error)\n    showNotify({ type: 'danger', message: '执行任务出错: ' + (error.message || '未知错误') })\n  }\n}\n\n// 获取所有计划任务\nconst fetchTasks = debounce(async () => {\n  if (loading.value) return // 如果正在加载，则不重复获取\n  \n  loading.value = true\n  try {\n    const response = await getAllSchedulerTasks({\n      include_subtasks: true,\n      detail_level: 'full'\n    })\n    if (response.data && response.data.status === 'success') {\n      // 为每个任务添加展开/收起状态\n      tasks.value = (response.data.tasks || []).map(task => {\n        return {\n          ...task,\n          isExpanded: true // 默认展开\n        }\n      })\n    } else {\n      showNotify({ type: 'danger', message: '获取任务列表失败: ' + (response.data?.message || '未知错误') })\n    }\n  } catch (error) {\n    console.error('获取任务列表出错:', error)\n    showNotify({ type: 'danger', message: '获取任务列表出错: ' + (error.message || '未知错误') })\n  } finally {\n    loading.value = false\n  }\n}, 300) // 300ms 的防抖延迟\n\n// 刷新任务列表\nconst refreshTasks = () => {\n  fetchTasks()\n}\n\n// 打开任务详情弹窗\nconst openTaskDetailModal = async (taskId) => {\n  if (!taskId) {\n    return;\n  }\n\n  const response = await getSchedulerTaskDetail(taskId);\n  \n  if (response.data?.status === 'success' && Array.isArray(response.data.tasks) && response.data.tasks.length > 0) {\n    const task = response.data.tasks[0];\n    \n    if (!task.execution) {\n      task.execution = {\n        status: 'pending',\n        success_rate: 0,\n        avg_duration: 0,\n        total_runs: 0,\n        success_runs: 0,\n        fail_runs: 0\n      };\n    }\n    \n    currentTask.value = task;\n    showTaskDetailModal.value = true;\n  } else {\n    showNotify({ type: 'danger', message: '获取任务详情失败：' + (response.data?.message || '未知错误') });\n  }\n}\n\n// 获取任务历史记录\nconst fetchTaskHistory = async (taskId) => {\n  try {\n    const response = await getTaskHistory({\n      task_id: taskId,\n      include_subtasks: true,\n      page: 1,\n      page_size: 20\n    })\n    if (response.data && response.data.status === 'success') {\n      showTaskHistoryModal.value = true\n      selectedTaskHistory.value = response.data.history || []\n    } else {\n      showNotify({ type: 'danger', message: '获取任务历史失败: ' + (response.data?.message || '未知错误') })\n    }\n  } catch (error) {\n    console.error('获取任务历史出错:', error)\n    showNotify({ type: 'danger', message: '获取任务历史出错: ' + (error.message || '未知错误') })\n  }\n}\n\n// 计算历史记录的成功率\nconst getSuccessRate = (historyList) => {\n  if (!historyList || historyList.length === 0) return 0\n  \n  const successCount = historyList.filter(h => h.status === 'success').length\n  return Math.round((successCount / historyList.length) * 100)\n}\n\n// 启用/禁用任务\nconst toggleTaskEnabled = async (taskId, enabled) => {\n  try {\n    const response = await setTaskEnabled(taskId, enabled)\n    if (response.data && response.data.status === 'success') {\n      showNotify({ type: 'success', message: enabled ? '任务已启用' : '任务已禁用' })\n      // 刷新任务列表\n      fetchTasks()\n    } else {\n      showNotify({ type: 'danger', message: (enabled ? '启用' : '禁用') + '任务失败: ' + (response.data?.message || '未知错误') })\n    }\n  } catch (error) {\n    showNotify({ type: 'danger', message: '切换任务状态出错: ' + (error.message || '未知错误') })\n  }\n}\n\n// 打开编辑任务弹窗\nconst openEditTaskModal = async (taskId) => {\n  try {\n    // 重置状态\n    isEditing.value = true\n    parentTaskId.value = null  // 确保编辑时parentTaskId为null\n    currentTask.value = null\n    \n    const response = await getSchedulerTaskDetail(taskId)\n    if (response.data?.status === 'success' && Array.isArray(response.data.tasks) && response.data.tasks.length > 0) {\n      currentTask.value = response.data.tasks[0]\n      showTaskFormModal.value = true\n    } else {\n      showNotify({ type: 'danger', message: '获取任务详情失败：' + (response.data?.message || '未知错误') })\n    }\n  } catch (error) {\n    console.error('获取任务详情出错:', error)\n    showNotify({ type: 'danger', message: '获取任务详情出错: ' + (error.message || '未知错误') })\n  }\n}\n\n// 打开创建子任务弹窗\nconst openCreateSubTaskModal = async (taskId) => {\n  try {\n    // 重置状态\n    isEditing.value = false\n    currentTask.value = null\n    \n    // 先获取主任务详情\n    const response = await getSchedulerTaskDetail(taskId)\n    if (response.data?.status === 'success' && Array.isArray(response.data.tasks) && response.data.tasks.length > 0) {\n      const mainTask = response.data.tasks[0]\n      currentTask.value = mainTask\n      parentTaskId.value = taskId\n\n      // 检查主任务是否有子任务\n      if (mainTask.sub_tasks && mainTask.sub_tasks.length > 0) {\n        // 如果有子任务，新子任务应该依赖于最后一个子任务\n        const lastSubTask = mainTask.sub_tasks[mainTask.sub_tasks.length - 1]\n        currentTask.value = {\n          ...mainTask,\n          depends_on: {\n            task_id: lastSubTask.task_id,\n            name: lastSubTask.config?.name || lastSubTask.task_id\n          }\n        }\n      } else {\n      }\n      \n      showTaskFormModal.value = true\n    } else {\n      showNotify({ type: 'danger', message: '获取主任务详情失败：' + (response.data?.message || '未知错误') })\n    }\n  } catch (error) {\n    console.error('获取主任务详情出错:', error)\n    showNotify({ type: 'danger', message: '获取主任务详情出错: ' + (error.message || '未知错误') })\n  }\n}\n\n// 打开创建任务弹窗\nconst openCreateTaskModal = () => {\n  // 重置所有状态\n  isEditing.value = false\n  parentTaskId.value = null\n  currentTask.value = null\n  showTaskFormModal.value = true\n}\n\n// 确认删除任务\nconst confirmDeleteTask = (taskId, parentTaskId = null) => {\n  const isSubTask = !!parentTaskId;\n  showDialog({\n    title: '确认删除',\n    message: isSubTask ? '确定要删除此子任务吗？此操作不可撤销。' : '确定要删除此任务吗？此操作不可撤销。',\n    showCancelButton: true,\n    confirmButtonText: '确定',\n    cancelButtonText: '取消',\n    confirmButtonColor: '#fb7299',\n  }).then(() => {\n    deleteTask(taskId, parentTaskId)\n  }).catch(() => {\n    // 取消删除\n  })\n}\n\n// 删除任务\nconst deleteTask = async (taskId, parentTaskId = null) => {\n  try {\n    console.log('开始删除任务:', taskId, parentTaskId ? `(父任务: ${parentTaskId})` : '');\n    let response;\n    \n    if (parentTaskId) {\n      // 删除子任务\n      response = await deleteSubTask(parentTaskId, taskId)\n    } else {\n      // 删除主任务\n      response = await deleteSchedulerTask(taskId)\n    }\n    \n    if (response.data && response.data.status === 'success') {\n      showNotify({ type: 'success', message: parentTaskId ? '子任务删除成功' : '任务删除成功' })\n      // 关闭所有相关的弹窗\n      showTaskDetailModal.value = false\n      showTaskHistoryModal.value = false\n      // 重新获取任务列表\n      fetchTasks()\n    } else {\n      showNotify({ type: 'danger', message: (parentTaskId ? '删除子任务失败: ' : '删除任务失败: ') + (response.data?.message || '未知错误') })\n    }\n  } catch (error) {\n    console.error('删除任务出错:', error)\n    showNotify({ type: 'danger', message: (parentTaskId ? '删除子任务出错: ' : '删除任务出错: ') + (error.message || '未知错误') })\n  }\n}\n\n// 初始化\nonMounted(() => {\n  fetchTasks()\n})\n</script>\n\n<style scoped>\n.task-detail-dialog :deep(.van-dialog__content) {\n  max-height: 70vh;\n  overflow-y: auto;\n}\n\n.task-form-dialog :deep(.van-dialog__content) {\n  max-height: 70vh;\n  overflow-y: auto;\n}\n\n.task-form-dialog :deep(.van-dialog) {\n  max-height: 85vh;\n  overflow: hidden;\n  display: flex;\n  flex-direction: column;\n}\n\n.task-form-dialog :deep(.van-dialog__header) {\n  flex-shrink: 0;\n  padding: 10px 14px;\n  font-size: 13px;\n}\n\n.task-history-dialog :deep(.van-dialog__content) {\n  max-height: 70vh;\n  overflow-y: auto;\n}\n\n/* 优化滚动条样式 */\n:deep(.van-dialog__content)::-webkit-scrollbar,\npre::-webkit-scrollbar,\n.max-h-32::-webkit-scrollbar,\n.max-h-28::-webkit-scrollbar {\n  width: 4px;\n  height: 4px;\n}\n\n:deep(.van-dialog__content)::-webkit-scrollbar-thumb,\npre::-webkit-scrollbar-thumb,\n.max-h-32::-webkit-scrollbar-thumb,\n.max-h-28::-webkit-scrollbar-thumb {\n  background: #ddd;\n  border-radius: 2px;\n}\n\n:deep(.van-dialog__content)::-webkit-scrollbar-thumb:hover,\npre::-webkit-scrollbar-thumb:hover,\n.max-h-32::-webkit-scrollbar-thumb:hover,\n.max-h-28::-webkit-scrollbar-thumb:hover {\n  background: #fb7299;\n}\n\n/* 弹窗标题样式 */\n:deep(.van-dialog__header) {\n  padding: 12px 16px;\n  font-weight: 600;\n  color: #333;\n  border-bottom: 1px solid #f0f0f0;\n  font-size: 14px;\n}\n\n/* 表单元素焦点样式 */\ninput:focus, select:focus, textarea:focus {\n  border-color: #fb7299;\n  box-shadow: 0 0 0 2px rgba(251, 114, 153, 0.2);\n}\n\n/* 按钮悬停效果 */\nbutton {\n  transition: all 0.2s ease;\n}\n\n/* 表格行悬停效果 */\ntr.hover\\:bg-gray-50:hover {\n  background-color: rgba(251, 114, 153, 0.05);\n}\n\n/* 卡片阴影效果 */\n.shadow-sm {\n  box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);\n  transition: box-shadow 0.2s ease;\n}\n\n.shadow-sm:hover {\n  box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.1);\n}\n\n/* 标签样式 */\n.rounded-md {\n  transition: background-color 0.2s ease;\n}\n</style> "
  },
  {
    "path": "src/components/tailwind/page/Search.vue",
    "content": "<template>\n  <div>\n    <!-- 搜索框和总数显示容器 -->\n    <div class=\"sticky top-0 bg-white dark:bg-gray-900 lg:pt-4 z-50\">\n      <div class=\"bg-white dark:bg-gray-900\">\n        <div class=\"mx-auto max-w-4xl\">\n          <!-- 使用SearchBar组件 -->\n          <SearchBar\n            :initial-keyword=\"keyword\"\n            :initial-search-type=\"searchType\"\n            @search=\"handleSearch\"\n          />\n\n          <!-- 显示总条数，和输入框左端对齐 -->\n          <p class=\"p-1.5 text-lg text-gray-700 dark:text-gray-300 lm:text-sm\">\n            共 <span class=\"text-[#fb7299]\">{{ totalResults }}</span> 条数据和\n            <span class=\"text-[#fb7299]\">{{ keyword }}</span> 相关\n          </p>\n        </div>\n      </div>\n    </div>\n\n    <!-- 主要内容区域 -->\n    <div class=\"mx-auto max-w-7xl sm:px-2 lg:px-8\">\n      <!-- 使用 key 来强制组件重新渲染 -->\n      <div :key=\"page\">\n        <!-- 视频记录列表 -->\n        <VideoRecord\n          v-for=\"record in records\"\n          :key=\"record.id\"\n          :record=\"record\"\n          :search-keyword=\"keyword\"\n          :search-type=\"searchType\"\n          :remark-data=\"remarkData\"\n          @remark-updated=\"handleRemarkUpdate\"\n        />\n      </div>\n\n      <!-- 分页功能 -->\n      <div class=\"mb-5 mt-8\">\n        <Pagination\n          v-model:current-page=\"page\"\n          :total-pages=\"totalPages\"\n          :use-routing=\"true\"\n          @update:current-page=\"handlePageChange\"\n        />\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { onMounted, ref, watch } from 'vue'\nimport { useRoute, useRouter } from 'vue-router'\nimport { searchBiliHistory2024, batchGetRemarks } from '../../../api/api.js'\nimport SearchBar from '../SearchBar.vue'\nimport VideoRecord from '../VideoRecord.vue'\nimport Pagination from '../Pagination.vue'\nimport { normalizeImageUrl } from '@/utils/imageUrl.js'\n\n// 获取路由参数\nconst route = useRoute()\nconst router = useRouter()\n\n// 状态变量\nconst records = ref([])\nconst page = ref(1)\nconst size = ref(30)\nconst totalPages = ref(0)\nconst totalResults = ref(0)\nconst remarkData = ref({}) // 存储备注数据\n\n// 搜索相关变量\nconst keyword = ref('')  // 初始化为空字符串\nconst searchType = ref('all')  // 默认为全部搜索\n\n// 处理搜索\nconst handleSearch = ({ keyword: searchKeyword, type }) => {\n  console.log('Search - 收到搜索事件:', { searchKeyword, type })\n  if (searchKeyword.trim()) {\n    keyword.value = searchKeyword.trim()\n    searchType.value = type\n    page.value = 1\n    router.push({\n      name: 'Search',\n      params: { keyword: searchKeyword.trim() },\n      query: {\n        type: type\n      }\n    })\n    fetchSearchResults()\n  }\n}\n\n// 处理页码变化\nconst handlePageChange = async (newPage) => {\n  if (newPage !== page.value) {\n    page.value = newPage\n    // 清空当前记录，避免显示旧数据\n    records.value = []\n    // 更新路由\n    if (newPage === 1) {\n      await router.push({\n        name: 'Search',\n        params: { keyword: keyword.value },\n        query: {\n          type: searchType.value\n        }\n      })\n    } else {\n      await router.push({\n        name: 'Search',\n        params: { keyword: keyword.value, pageNumber: newPage },\n        query: {\n          type: searchType.value\n        }\n      })\n    }\n    await fetchSearchResults()\n  }\n}\n\n// 获取搜索结果\nconst fetchSearchResults = async () => {\n  try {\n    // 从localStorage获取是否使用本地图片源的设置\n    const useLocalImages = localStorage.getItem('useLocalImages') === 'true'\n\n    const response = await searchBiliHistory2024(\n      keyword.value,           // search\n      searchType.value,        // searchType\n      page.value,             // page\n      size.value,             // size\n      useLocalImages          // 使用本地图片源\n    )\n\n    if (response.data.status === 'success') {\n      records.value = response.data.data.records\n      totalPages.value = Math.ceil(response.data.data.total / size.value)\n      totalResults.value = response.data.data.total\n\n      // 批量获取备注\n      if (records.value.length > 0) {\n        const batchRecords = records.value.map(record => ({\n          bvid: record.bvid,\n          view_at: record.view_at\n        }))\n        const remarksResponse = await batchGetRemarks(batchRecords)\n        if (remarksResponse.data.status === 'success') {\n          remarkData.value = remarksResponse.data.data\n        }\n      }\n    }\n  } catch (error) {\n    console.error('搜索失败:', error)\n  }\n}\n\n// 处理备注更新\nconst handleRemarkUpdate = (data) => {\n  const key = `${data.bvid}_${data.view_at}`\n  remarkData.value[key] = {\n    bvid: data.bvid,\n    view_at: data.view_at,\n    remark: data.remark,\n    remark_time: data.remark_time\n  }\n}\n\n// 监听 keyword 变化\nwatch(\n  () => route.params.keyword,\n  (newKeyword) => {\n    if (newKeyword !== keyword.value) {\n      // 确保 keyword 是字符串类型\n      keyword.value = Array.isArray(newKeyword) ? newKeyword[0] : String(newKeyword)\n      page.value = 1\n      records.value = [] // 清空当前记录\n      fetchSearchResults()\n    }\n  }\n)\n\n// 监听页码变化\nwatch(\n  () => route.params.pageNumber,\n  async (newPage) => {\n    const pageNum = Number(newPage) || 1\n    if (pageNum !== page.value) {\n      page.value = pageNum\n      records.value = [] // 清空当前记录\n      await fetchSearchResults()\n    }\n  }\n)\n\n// 监听搜索类型变化\nwatch(\n  searchType,\n  (newType) => {\n    console.log('Search - 搜索类型变化:', newType)\n    if (keyword.value) {  // 只有在有搜索关键词时才重新搜索\n      records.value = [] // 清空当前记录\n      router.push({\n        name: 'Search',\n        params: { keyword: keyword.value },\n        query: {\n          type: newType\n        }\n      })\n      fetchSearchResults()\n    }\n  }\n)\n\n// 组件挂载时获取数据\nonMounted(async () => {\n  const typeFromQuery = String(route.query.type || '')\n  if (typeFromQuery) {\n    searchType.value = typeFromQuery\n  }\n\n  // 设置初始关键词\n  const initialKeyword = Array.isArray(route.params.keyword)\n    ? route.params.keyword[0]\n    : String(route.params.keyword || '')\n  keyword.value = initialKeyword\n\n  // 从路由参数获取页码\n  page.value = Number(route.params.pageNumber || 1)\n\n  await fetchSearchResults()\n})\n</script>\n\n<style scoped>\n/* 移除之前的sticky样式 */\n</style>\n"
  },
  {
    "path": "src/components/tailwind/page/VideoDetailsManager.vue",
    "content": "<!-- 视频详情管理组件 -->\n<template>\n  <div class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6\">\n    <!-- 操作按钮 -->\n    <div class=\"mb-6 flex flex-col space-y-4 sm:flex-row sm:space-y-0 sm:space-x-4\">\n      <div class=\"flex space-x-4\">\n        <button\n          @click=\"startFetchingDetails\"\n          :disabled=\"stats.pendingVideosCount === 0 || isFetching\"\n          class=\"px-4 py-2 bg-[#fb7299] text-white rounded-md hover:bg-[#fb7299]/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 border border-[#fb7299]/20\"\n        >\n          <div class=\"flex items-center space-x-2\">\n            <svg v-if=\"isFetching\" class=\"animate-spin h-5 w-5\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\">\n              <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n              <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n            </svg>\n            <svg v-else class=\"h-5 w-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4\" />\n            </svg>\n            <span>{{ isFetching ? '获取中...' : (stats.pendingVideosCount === 0 ? '无需获取' : '获取视频详情') }}</span>\n          </div>\n        </button>\n\n        <!-- 停止按钮 -->\n        <button\n          v-if=\"isFetching && progress.isProcessing && !progress.isComplete\"\n          @click=\"stopFetching\"\n          class=\"px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-all duration-200 border border-red-600/20\"\n        >\n          <div class=\"flex items-center space-x-2\">\n            <svg class=\"h-5 w-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 10h6v4H9z\" />\n            </svg>\n            <span>停止获取</span>\n          </div>\n        </button>\n      </div>\n\n      <!-- 下载选项 -->\n      <div class=\"flex items-center space-x-2 bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm rounded-md px-4 py-2 border border-gray-200 dark:border-gray-700\">\n        <input\n          type=\"checkbox\"\n          id=\"useSessdata\"\n          v-model=\"useSessdata\"\n          class=\"w-4 h-4 text-[#fb7299] bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-[#fb7299]\"\n          :disabled=\"stats.pendingVideosCount === 0 || isFetching\"\n        >\n        <label for=\"useSessdata\" class=\"text-sm text-gray-700 dark:text-gray-300\">\n          使用SESSDATA获取详情（对于公开视频可以不使用SESSDATA）\n        </label>\n      </div>\n    </div>\n\n    <!-- 加载中状态 -->\n    <div v-if=\"isLoading\" class=\"flex justify-center items-center py-12\">\n      <div class=\"animate-spin rounded-full h-16 w-16 border-b-2 border-[#fb7299]\"></div>\n    </div>\n\n    <!-- 统计数据卡片 -->\n    <div v-if=\"!isLoading\" class=\"grid grid-cols-1 md:grid-cols-4 gap-4 mb-6\">\n      <div class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 flex flex-col\">\n        <span class=\"text-sm text-gray-500 dark:text-gray-400\">历史记录视频总数</span>\n        <span class=\"text-2xl font-bold text-gray-800 dark:text-gray-100\">{{ stats.totalHistoryVideos }}</span>\n      </div>\n\n      <div class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 flex flex-col\">\n        <span class=\"text-sm text-gray-500\">已获取详情</span>\n        <span class=\"text-2xl font-bold text-green-600\">{{ stats.existingVideosCount }}</span>\n      </div>\n\n      <div class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 flex flex-col\">\n        <span class=\"text-sm text-gray-500\">已知失效视频</span>\n        <span class=\"text-2xl font-bold text-orange-500\">{{ stats.invalidVideosCount }}</span>\n      </div>\n\n      <div class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 flex flex-col\">\n        <span class=\"text-sm text-gray-500\">待获取视频</span>\n        <span class=\"text-2xl font-bold\" :class=\"stats.pendingVideosCount > 0 ? 'text-blue-600' : 'text-gray-400'\">\n          {{ stats.pendingVideosCount }}\n        </span>\n      </div>\n    </div>\n\n    <!-- 进度状态卡片 - 仅在获取时显示 -->\n    <div v-if=\"isFetching && !isLoading\" class=\"space-y-6\">\n      <!-- 总体进度 -->\n      <div class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4\">\n        <div class=\"mb-4\">\n          <div class=\"flex justify-between items-center mb-1 text-sm\">\n            <span class=\"font-medium\">处理进度: {{ progress.processedVideos || 0 }}/{{ progress.totalVideos || 0 }}</span>\n            <span>{{ progress.progressPercentage ? `${progress.progressPercentage.toFixed(1)}%` : '0%' }}</span>\n          </div>\n          <div class=\"w-full h-3 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden\">\n            <div\n              class=\"h-full bg-[#fb7299] transition-all duration-300\"\n              :style=\"{width: `${progress.progressPercentage || 0}%`}\"\n            ></div>\n          </div>\n        </div>\n      </div>\n\n      <!-- 状态卡片组 -->\n      <div class=\"grid grid-cols-2 md:grid-cols-4 gap-4\">\n        <!-- 成功卡片 -->\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4\">\n          <div>\n            <p class=\"text-sm font-medium text-gray-500 dark:text-gray-400\">成功获取</p>\n            <p class=\"text-2xl font-bold text-green-600\">{{ progress.successCount || 0 }}</p>\n          </div>\n        </div>\n\n        <!-- 失败卡片 -->\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4\">\n          <div>\n            <p class=\"text-sm font-medium text-gray-500 dark:text-gray-400\">获取失败</p>\n            <p class=\"text-2xl font-bold text-red-600\">{{ progress.failedCount || 0 }}</p>\n          </div>\n        </div>\n\n        <!-- 跳过卡片 -->\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4\">\n          <div>\n            <p class=\"text-sm font-medium text-gray-500 dark:text-gray-400\">跳过失效</p>\n            <p class=\"text-2xl font-bold text-yellow-600\">{{ progress.skippedInvalidCount || 0 }}</p>\n          </div>\n        </div>\n\n        <!-- 时间卡片 -->\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4\">\n          <div>\n            <p class=\"text-sm font-medium text-gray-500 dark:text-gray-400\">已用时间</p>\n            <p class=\"text-2xl font-bold text-blue-600\">{{ progress.elapsedTime || '0秒' }}</p>\n          </div>\n        </div>\n      </div>\n\n      <!-- 失败视频列表 -->\n      <div v-if=\"progress.errorVideos && progress.errorVideos.length > 0\" class=\"bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4\">\n        <div class=\"font-medium text-sm mb-2 text-red-600 flex items-center\">\n          <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-5 w-5 mr-1\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z\" />\n          </svg>\n          获取失败的视频:\n        </div>\n        <div class=\"max-h-[120px] overflow-y-auto text-xs bg-gray-50 dark:bg-gray-900 p-2 rounded border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300\">\n          <div v-for=\"(bvid, index) in progress.errorVideos\" :key=\"index\" class=\"mb-1\">\n            {{ bvid }}\n          </div>\n        </div>\n      </div>\n\n      <!-- 完成按钮 -->\n      <div v-if=\"progress.isComplete\" class=\"flex justify-end\">\n        <button\n          @click=\"completeFetching\"\n          class=\"inline-flex items-center px-4 py-2 rounded-md text-white bg-green-600 hover:bg-green-700 font-medium\"\n        >\n          <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-5 w-5 mr-2\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 13l4 4L19 7\" />\n          </svg>\n          完成\n        </button>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, onUnmounted } from 'vue'\nimport { getVideoDetailsStats, fetchVideoDetails, createVideoDetailsProgressSSE, stopVideoDetailsFetch } from '../../../api/api'\nimport { showNotify } from 'vant'\nimport 'vant/es/notify/style'\n\n// 状态变量\nconst isLoading = ref(true)\nconst isFetching = ref(false)\nconst useSessdata = ref(true) // 默认使用SESSDATA\nconst stats = ref({\n  totalHistoryVideos: 0,\n  existingVideosCount: 0,\n  invalidVideosCount: 0,\n  pendingVideosCount: 0,\n  completionPercentage: 0,\n  errorTypeStats: {},\n  pendingVideos: []\n})\n\n// 进度相关\nconst progressSource = ref(null)\nconst progress = ref({\n  isProcessing: false,\n  totalVideos: 0,\n  processedVideos: 0,\n  successCount: 0,\n  failedCount: 0,\n  errorVideos: [],\n  skippedInvalidCount: 0,\n  progressPercentage: 0,\n  elapsedTime: '',\n  isComplete: false\n})\n\n// 获取统计数据\nconst fetchStats = async () => {\n  isLoading.value = true\n  try {\n    // 调用API获取视频详情统计数据\n    const response = await getVideoDetailsStats()\n    if (response.data.status === 'success') {\n      // 确保数据结构完整\n      const data = response.data.data || {}\n      stats.value = {\n        totalHistoryVideos: data.total_videos || 0,\n        existingVideosCount: data.videos_with_details || 0,\n        invalidVideosCount: (data.invalid_videos_count ?? 0),\n        pendingVideosCount: (data.pending_videos_count ?? data.videos_without_details ?? 0),\n        completionPercentage: data.completion_percentage ?? 0,\n        errorTypeStats: (data.invalid_error_type_stats || {}),\n        pendingVideos: data.pending_videos || []\n      }\n    } else {\n      throw new Error(response.data.message || '获取统计数据失败')\n    }\n  } catch (error) {\n    console.error('获取视频详情统计失败:', error)\n    showNotify({\n      type: 'danger',\n      message: error.message || '获取统计数据失败'\n    })\n  } finally {\n    isLoading.value = false\n  }\n}\n\n// 格式化错误类型名称\nconst formatErrorType = (type) => {\n  const errorTypes = {\n    'parse_error': '解析错误',\n    'not_found': '视频不存在',\n    'restricted': '访问受限',\n    'api_error': 'API错误',\n    'timeout': '超时',\n    'network_error': '网络错误',\n    '404_not_found': '视频不存在',\n    '62002_invisible': '视频已设为私有'\n  }\n  return errorTypes[type] || type\n}\n\n// 开始获取视频详情\nconst startFetchingDetails = async () => {\n  try {\n    // 调用API启动获取流程，设置maxVideos=0表示获取全部\n    const response = await fetchVideoDetails({\n      max_videos: 0,  // 获取全部视频\n      specific_videos: '',\n      use_sessdata: useSessdata.value // 传递是否使用SESSDATA的选项\n    })\n\n    if (response.data.status === 'success') {\n      isFetching.value = true\n\n      // 初始化进度对象\n      const data = response.data.data || {}\n      progress.value = {\n        isProcessing: data.is_processing || true,\n        totalVideos: data.total_videos || 0,\n        processedVideos: data.processed_videos || 0,\n        successCount: data.success_count || 0,\n        failedCount: data.failed_count || 0,\n        errorVideos: data.error_videos || [],\n        skippedInvalidCount: data.skipped_invalid_count || 0,\n        progressPercentage: data.progress_percentage || 0,\n        elapsedTime: typeof data.elapsed_time === 'number' ? `${data.elapsed_time.toFixed(2)}秒` : '0.00秒',\n        isComplete: false\n      }\n\n      // 启动进度监听\n      startProgressStream()\n\n      showNotify({\n        type: 'success',\n        message: '已开始获取视频详情'\n      })\n    } else {\n      throw new Error(response.data.message || '启动获取失败')\n    }\n  } catch (error) {\n    console.error('启动视频详情获取失败:', error)\n    showNotify({\n      type: 'danger',\n      message: error.message || '启动获取失败'\n    })\n  }\n}\n\n// 停止获取\nconst stopFetching = async () => {\n  try {\n    const response = await stopVideoDetailsFetch()\n    if (response.data.status === 'success') {\n      // 立即重置前端状态\n      closeProgressStream()\n      isFetching.value = false\n\n      // 重置进度状态到初始状态\n      progress.value = {\n        isProcessing: false,\n        totalVideos: 0,\n        processedVideos: 0,\n        successCount: 0,\n        failedCount: 0,\n        errorVideos: [],\n        skippedInvalidCount: 0,\n        progressPercentage: 0,\n        elapsedTime: '',\n        isComplete: false\n      }\n\n      showNotify({\n        type: 'success',\n        message: '任务已停止'\n      })\n\n      // 刷新统计数据\n      fetchStats()\n    } else {\n      throw new Error(response.data.message || '停止失败')\n    }\n  } catch (error) {\n    console.error('停止视频详情获取失败:', error)\n    showNotify({\n      type: 'danger',\n      message: error.message || '停止失败'\n    })\n  }\n}\n\n// 完成获取\nconst completeFetching = () => {\n  closeProgressStream()\n  isFetching.value = false\n\n  showNotify({\n    type: 'success',\n    message: `视频详情获取完成! 成功: ${progress.value.successCount}, 失败: ${progress.value.failedCount}`\n  })\n\n  // 刷新统计数据\n  fetchStats()\n}\n\n// 开始流式获取进度\nconst startProgressStream = () => {\n  // 关闭可能存在的连接\n  closeProgressStream()\n\n  try {\n    console.log('开始获取视频详情进度流')\n    // 使用接口文档中的更新间隔参数\n    progressSource.value = createVideoDetailsProgressSSE({ update_interval: 0.2 })\n\n    // 连接建立\n    progressSource.value.onopen = (event) => {\n      console.log('视频详情进度流连接已建立')\n    }\n\n    // 接收消息\n    progressSource.value.onmessage = (event) => {\n      try {\n        const data = JSON.parse(event.data)\n        console.log('收到进度更新:', data)\n\n        // 更新进度，映射API文档中的字段\n        progress.value = {\n          isProcessing: data.is_processing,\n          totalVideos: data.total_videos,\n          processedVideos: data.processed_videos,\n          successCount: data.success_count,\n          failedCount: data.failed_count,\n          errorVideos: data.error_videos || [],\n          skippedInvalidCount: data.skipped_invalid_count || 0,\n          progressPercentage: data.progress_percentage,\n          elapsedTime: data.elapsed_time,\n          isComplete: data.is_complete\n        }\n\n        // 处理完成\n        if (data.is_complete) {\n          console.log('视频详情获取完成')\n          showNotify({\n            type: 'success',\n            message: '视频详情获取任务已完成'\n          })\n        }\n      } catch (error) {\n        console.error('解析进度数据失败:', error)\n      }\n    }\n\n    // 错误处理\n    progressSource.value.onerror = (event) => {\n      console.error('视频详情进度流错误:', event)\n      closeProgressStream()\n\n      // 如果正在获取中，显示错误消息\n      if (isFetching.value) {\n        showNotify({\n          type: 'warning',\n          message: '进度更新连接已断开'\n        })\n      }\n    }\n  } catch (error) {\n    console.error('创建进度流失败:', error)\n    showNotify({\n      type: 'danger',\n      message: '无法获取实时进度'\n    })\n  }\n}\n\n// 关闭SSE连接\nconst closeProgressStream = () => {\n  if (progressSource.value) {\n    console.log('关闭视频详情进度流')\n    progressSource.value.close()\n    progressSource.value = null\n  }\n}\n\n// 组件挂载时获取统计数据\nonMounted(() => {\n  fetchStats()\n})\n\n// 组件卸载时清理资源\nonUnmounted(() => {\n  closeProgressStream()\n})\n</script>\n\n<style scoped>\n.animate-pulse {\n  animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;\n}\n\n@keyframes pulse {\n  0%, 100% {\n    opacity: 1;\n  }\n  50% {\n    opacity: .7;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/tailwind/page/VideoDownloader.vue",
    "content": "<!-- 视频下载界面 -->\n<template>\n  <div class=\"mt-10\">\n    <!-- 搜索区域 - 使用与首页一致的搜索栏，去掉背景、边框和顶部内容 -->\n    <div class=\"mx-auto max-w-6xl\">\n      <div>\n        <!-- 搜索框和类型选择 -->\n        <div class=\"flex flex-col md:flex-row gap-4\">\n          <!-- 搜索框容器 -->\n          <div class=\"w-full flex\">\n            <div class=\"flex items-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 focus-within:border-[#fb7299] transition-colors duration-200 flex-grow\">\n              <!-- 下载类型选择 -->\n              <div class=\"h-10 pl-2 flex items-center\">\n                <CustomDropdown\n                  v-model=\"downloadType\"\n                  :options=\"downloadTypeOptions\"\n                  :selected-text=\"getDownloadTypeLabel(downloadType)\"\n                  @change=\"onDownloadTypeChange\"\n                  custom-class=\"h-full border-none !shadow-none !p-0 !m-0 !rounded-none !pr-1\"\n                  :min-width=\"120\"\n                  :use-fixed-width=\"false\"\n                >\n                  <template #trigger-content>\n                    <span class=\"text-[#fb7299] text-sm flex items-center whitespace-nowrap\">{{ getDownloadTypeLabel(downloadType) }}</span>\n                  </template>\n                </CustomDropdown>\n              </div>\n\n              <!-- 分隔线 -->\n              <div class=\"h-5 w-px bg-gray-200 dark:bg-gray-600 mx-1\"></div>\n\n              <!-- 输入框 -->\n              <input\n                v-model=\"inputValue\"\n                @keyup.enter=\"handleDownload\"\n                type=\"search\"\n                :placeholder=\"downloadType === 'video' ? '输入BV号或完整视频链接' : '输入UP主UID'\"\n                class=\"h-10 w-full border-none bg-transparent px-2 pr-3 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-0 text-sm leading-none\"\n              />\n            </div>\n\n            <!-- 搜索按钮 - 增加左边距 -->\n            <button\n              @click=\"handleDownload\"\n              :disabled=\"!inputValue\"\n              class=\"flex-shrink-0 bg-[#fb7299] hover:bg-[#fb7299]/90 text-white px-4 py-2 rounded-md font-medium transition-colors duration-200 flex items-center justify-center ml-4\"\n            >\n              <span class=\"flex items-center\">\n                <svg class=\"mr-1 h-4 w-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4\"></path>\n                </svg>\n                下载视频\n              </span>\n            </button>\n          </div>\n        </div>\n\n        <p class=\"mt-2 text-xs text-gray-500 dark:text-gray-400\">\n          {{ downloadType === 'video' ? '支持完整链接或 BV 号，例如：BV1xx411c7mD' : '在 UP 主空间页面 URL 中查看 UID' }}\n        </p>\n      </div>\n    </div>\n\n    <!-- 空状态提示 -->\n    <div v-if=\"!inputValue\" class=\"mx-auto max-w-6xl mt-24 flex flex-col items-center justify-center text-center\">\n      <div class=\"w-24 h-24 text-gray-300 dark:text-gray-600 mb-4\">\n        <svg fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n          <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.5\" d=\"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4\" />\n        </svg>\n      </div>\n      <h3 class=\"text-lg font-medium text-gray-900 dark:text-gray-100 mb-2\">开始下载视频</h3>\n      <p class=\"text-sm text-gray-500 dark:text-gray-400 max-w-md\">\n        {{ downloadType === 'video'\n          ? '请输入视频的 BV 号或完整链接，支持单个视频下载'\n          : '请输入 UP 主的 UID，支持批量下载 UP 主的所有投稿视频' }}\n      </p>\n    </div>\n\n    <!-- 视频信息卡片（仅当有视频信息且输入框有内容时显示） -->\n    <div v-if=\"hasVideoInfo && inputValue\" class=\"mx-auto max-w-6xl\">\n      <div class=\"mb-6 overflow-hidden\">\n        <!-- 视频信息卡片内容 -->\n        <div class=\"p-4 flex flex-col\">\n          <!-- 视频数据统计和提示信息合并在同一行 -->\n          <div class=\"flex flex-wrap items-center gap-4 mb-4\">\n            <!-- 播放量 -->\n            <div class=\"flex items-center\">\n              <svg class=\"view-icon w-4 h-4 text-gray-600 mr-1\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\">\n                <path d=\"M10 4.040041666666666C7.897383333333334 4.040041666666666 6.061606666666667 4.147 4.765636666666667 4.252088333333334C3.806826666666667 4.32984 3.061106666666667 5.0637316666666665 2.9755000000000003 6.015921666666667C2.8803183333333333 7.074671666666667 2.791666666666667 8.471183333333332 2.791666666666667 9.998333333333333C2.791666666666667 11.525566666666668 2.8803183333333333 12.922083333333333 2.9755000000000003 13.9808C3.061106666666667 14.932983333333334 3.806826666666667 15.666916666666667 4.765636666666667 15.744683333333336C6.061611666666668 15.849716666666666 7.897383333333334 15.956666666666667 10 15.956666666666667C12.10285 15.956666666666667 13.93871666666667 15.849716666666666 15.234766666666667 15.74461666666667C16.193416666666668 15.66685 16.939000000000004 14.933216666666667 17.024583333333336 13.981216666666668C17.11975 12.922916666666667 17.208333333333332 11.526666666666666 17.208333333333332 9.998333333333333C17.208333333333332 8.470083333333333 17.11975 7.073818333333334 17.024583333333336 6.015513333333334C16.939000000000004 5.063538333333333 16.193416666666668 4.329865000000001 15.234766666666667 4.252118333333334C13.93871666666667 4.147016666666667 12.10285 4.040041666666666 10 4.040041666666666zM4.684808333333334 3.255365C6.001155 3.14862 7.864583333333334 3.0400416666666668 10 3.0400416666666668C12.13565 3.0400416666666668 13.999199999999998 3.148636666666667 15.315566666666667 3.2553900000000002C16.753416666666666 3.3720016666666672 17.890833333333333 4.483195 18.020583333333335 5.925965000000001C18.11766666666667 7.005906666666667 18.208333333333336 8.433 18.208333333333336 9.998333333333333C18.208333333333336 11.56375 18.11766666666667 12.990833333333335 18.020583333333335 14.0708C17.890833333333333 15.513533333333331 16.753416666666666 16.624733333333335 15.315566666666667 16.74138333333333C13.999199999999998 16.848116666666666 12.13565 16.95666666666667 10 16.95666666666667C7.864583333333334 16.95666666666667 6.001155 16.848116666666666 4.684808333333334 16.7414C3.2467266666666665 16.624750000000002 2.1092383333333338 15.513266666666667 1.9795200000000002 14.070383333333334C1.8823900000000002 12.990000000000002 1.7916666666666667 11.562683333333334 1.7916666666666667 9.998333333333333C1.7916666666666667 8.434066666666666 1.8823900000000002 7.00672 1.9795200000000002 5.926381666666667C2.1092383333333338 4.483463333333334 3.2467266666666665 3.371976666666667 4.684808333333334 3.255365z\" fill=\"currentColor\"></path>\n                <path d=\"M12.23275 9.1962C12.851516666666667 9.553483333333332 12.851516666666667 10.44665 12.232683333333332 10.803866666666666L9.57975 12.335600000000001C8.960983333333335 12.692816666666667 8.1875 12.246250000000002 8.187503333333334 11.531733333333333L8.187503333333334 8.4684C8.187503333333334 7.753871666666667 8.960983333333335 7.307296666666667 9.57975 7.66456L12.23275 9.1962z\" fill=\"currentColor\"></path>\n              </svg>\n              <span class=\"text-sm font-medium text-gray-600\">{{ formatCount(videoInfo.stat?.view) }}</span>\n            </div>\n\n            <!-- 弹幕数 -->\n            <div class=\"flex items-center\">\n              <svg class=\"dm-icon w-4 h-4 text-gray-600 mr-1\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\">\n                <path d=\"M10 4.040041666666666C7.897383333333334 4.040041666666666 6.061606666666667 4.147 4.765636666666667 4.252088333333334C3.806826666666667 4.32984 3.061106666666667 5.0637316666666665 2.9755000000000003 6.015921666666667C2.8803183333333333 7.074671666666667 2.791666666666667 8.471183333333332 2.791666666666667 9.998333333333333C2.791666666666667 11.525566666666668 2.8803183333333333 12.922083333333333 2.9755000000000003 13.9808C3.061106666666667 14.932983333333334 3.806826666666667 15.666916666666667 4.765636666666667 15.744683333333336C6.061611666666668 15.849716666666666 7.897383333333334 15.956666666666667 10 15.956666666666667C12.10285 15.956666666666667 13.93871666666667 15.849716666666666 15.234766666666667 15.74461666666667C16.193416666666668 15.66685 16.939000000000004 14.933216666666667 17.024583333333336 13.981216666666668C17.11975 12.922916666666667 17.208333333333332 11.526666666666666 17.208333333333332 9.998333333333333C17.208333333333332 8.470083333333333 17.11975 7.073818333333334 17.024583333333336 6.015513333333334C16.939000000000004 5.063538333333333 16.193416666666668 4.329865000000001 15.234766666666667 4.252118333333334C13.93871666666667 4.147016666666667 12.10285 4.040041666666666 10 4.040041666666666zM4.684808333333334 3.255365C6.001155 3.14862 7.864583333333334 3.0400416666666668 10 3.0400416666666668C12.13565 3.0400416666666668 13.999199999999998 3.148636666666667 15.315566666666667 3.2553900000000002C16.753416666666666 3.3720016666666672 17.890833333333333 4.483195 18.020583333333335 5.925965000000001C18.11766666666667 7.005906666666667 18.208333333333336 8.433 18.208333333333336 9.998333333333333C18.208333333333336 11.56375 18.11766666666667 12.990833333333335 18.020583333333335 14.0708C17.890833333333333 15.513533333333331 16.753416666666666 16.624733333333335 15.315566666666667 16.74138333333333C13.999199999999998 16.848116666666666 12.13565 16.95666666666667 10 16.95666666666667C7.864583333333334 16.95666666666667 6.001155 16.848116666666666 4.684808333333334 16.7414C3.2467266666666665 16.624750000000002 2.1092383333333338 15.513266666666667 1.9795200000000002 14.070383333333334C1.8823900000000002 12.990000000000002 1.7916666666666667 11.562683333333334 1.7916666666666667 9.998333333333333C1.7916666666666667 8.434066666666666 1.8823900000000002 7.00672 1.9795200000000002 5.926381666666667C2.1092383333333338 4.483463333333334 3.2467266666666665 3.371976666666667 4.684808333333334 3.255365z\" fill=\"currentColor\"></path>\n                <path d=\"M13.291666666666666 8.833333333333334L8.166666666666668 8.833333333333334C7.890526666666666 8.833333333333334 7.666666666666666 8.609449999999999 7.666666666666666 8.333333333333334C7.666666666666666 8.057193333333334 7.890526666666666 7.833333333333334 8.166666666666668 7.833333333333334L13.291666666666666 7.833333333333334C13.567783333333335 7.833333333333334 13.791666666666668 8.057193333333334 13.791666666666668 8.333333333333334C13.791666666666668 8.609449999999999 13.567783333333335 8.833333333333334 13.291666666666666 8.833333333333334z\" fill=\"currentColor\"></path>\n                <path d=\"M14.541666666666666 12.166666666666666L9.416666666666668 12.166666666666666C9.140550000000001 12.166666666666666 8.916666666666666 11.942783333333333 8.916666666666666 11.666666666666668C8.916666666666666 11.390550000000001 9.140550000000001 11.166666666666668 9.416666666666668 11.166666666666668L14.541666666666666 11.166666666666668C14.817783333333335 11.166666666666668 15.041666666666668 11.390550000000001 15.041666666666668 11.666666666666668C15.041666666666668 11.942783333333333 14.817783333333335 12.166666666666666 14.541666666666666 12.166666666666666z\" fill=\"currentColor\"></path>\n                <path d=\"M6.5 8.333333333333334C6.5 8.609449999999999 6.27614 8.833333333333334 6 8.833333333333334L5.458333333333333 8.833333333333334C5.182193333333334 8.833333333333334 4.958333333333334 8.609449999999999 4.958333333333334 8.333333333333334C4.958333333333334 8.057193333333334 5.182193333333334 7.833333333333334 5.458333333333333 7.833333333333334L6 7.833333333333334C6.27614 7.833333333333334 6.5 8.057193333333334 6.5 8.333333333333334z\" fill=\"currentColor\"></path>\n                <path d=\"M7.750000000000001 11.666666666666668C7.750000000000001 11.942783333333333 7.526140000000001 12.166666666666666 7.25 12.166666666666666L6.708333333333334 12.166666666666666C6.432193333333334 12.166666666666666 6.208333333333334 11.942783333333333 6.208333333333334 11.666666666666668C6.208333333333334 11.390550000000001 6.432193333333334 11.166666666666668 6.708333333333334 11.166666666666668L7.25 11.166666666666668C7.526140000000001 11.166666666666668 7.750000000000001 11.390550000000001 7.750000000000001 11.666666666666668z\" fill=\"currentColor\"></path>\n              </svg>\n              <span class=\"text-sm font-medium text-gray-600\">{{ formatCount(videoInfo.stat?.danmaku) }}</span>\n            </div>\n\n            <!-- 发布时间 -->\n            <div v-if=\"videoInfo.pubdate\" class=\"flex items-center\">\n              <svg class=\"w-4 h-4 text-gray-600 mr-1\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z\" />\n              </svg>\n              <span class=\"text-sm font-medium text-gray-600\">{{ formatDetailedTimestamp(videoInfo.pubdate) }}</span>\n            </div>\n\n            <!-- 警告/提示信息 -->\n            <div v-if=\"videoInfo.argue_info?.argue_msg\"\n                 class=\"inline-flex items-center bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800/60 px-2 py-1 rounded text-xs text-yellow-700 dark:text-yellow-300\">\n              <span class=\"mr-1\">提示：</span>{{ videoInfo.argue_info.argue_msg }}\n            </div>\n\n            <!-- 荣誉信息 - 修改样式 -->\n            <div v-if=\"videoInfo.honor_reply?.honor && videoInfo.honor_reply.honor.length > 0\"\n                 class=\"inline-flex flex-wrap gap-4\">\n              <div v-for=\"(honor, index) in videoInfo.honor_reply.honor\"\n                   :key=\"index\"\n                   class=\"text-xs px-2 py-1 bg-[#fff7e9] text-[#ffb027] rounded-md flex items-center\">\n                <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 13\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\" class=\"honor-icon mr-1\" data-v-b37c19ce=\"\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M0.69043 3.9539C0.69043 2.62399 1.76853 1.5459 3.09843 1.5459L3.09843 2.23389C3.09843 2.89885 3.63748 3.4379 4.30243 3.4379C4.96739 3.4379 5.50643 2.89885 5.50643 2.2339V1.5459H10.4944V2.23389C10.4944 2.89885 11.0335 3.4379 11.6984 3.4379C12.3634 3.4379 12.9024 2.89885 12.9024 2.2339L12.9024 1.5459C14.2323 1.5459 15.3104 2.62399 15.3104 3.9539V10.5759C15.3104 11.9058 14.2323 12.9839 12.9024 12.9839H3.09843C1.76853 12.9839 0.69043 11.9058 0.69043 10.5759V3.9539ZM8.59407 5.73981C8.26159 5.73981 7.99207 6.00934 7.99207 6.34181C7.99207 6.67429 8.26159 6.9438 8.59407 6.9438H13.4101C13.7425 6.9438 14.0121 6.67429 14.0121 6.34181C14.0121 6.00934 13.7425 5.73981 13.4101 5.73981H8.59407ZM7.99207 9.35182C7.99207 9.01935 8.26159 8.74982 8.59407 8.74982H13.4101C13.7425 8.74982 14.0121 9.01935 14.0121 9.35182C14.0121 9.6843 13.7425 9.95381 13.4101 9.95381H8.59407C8.26159 9.95381 7.99207 9.6843 7.99207 9.35182ZM2.23794 6.80617L3.04621 6.68384L3.4095 5.92297C3.61412 5.4928 4.20532 5.4928 4.40994 5.92297L4.77212 6.68384L5.5815 6.80617C6.00636 6.87022 6.19423 7.37784 5.95382 7.71812L5.89065 7.79341L5.30428 8.38548L5.44316 9.22243C5.5161 9.66373 5.10432 10.0072 4.71637 9.86957L4.63378 9.83257L3.90972 9.43657L3.18566 9.83257C2.8037 10.0409 2.36159 9.74072 2.36788 9.31535L2.37628 9.22243L2.51404 8.38548L1.92879 7.79341C1.62136 7.48247 1.75692 6.95721 2.14419 6.82854L2.23794 6.80617Z\" fill=\"currentColor\"></path> <path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M4.31965 1.02979V1.88979V1.02979Z\" fill=\"currentColor\"></path> <path d=\"M4.31965 1.02979V1.88979\" stroke-width=\"1.72\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke=\"currentColor\"></path> <path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M11.7415 1.02979V1.88979V1.02979Z\" fill=\"currentColor\"></path> <path d=\"M11.7415 1.02979V1.88979\" stroke-width=\"1.72\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke=\"currentColor\"></path></svg>\n                {{ honor.desc }}\n              </div>\n            </div>\n          </div>\n\n          <div class=\"flex flex-col md:flex-row space-x-0 md:space-x-4\">\n            <!-- 左侧封面 -->\n            <div class=\"w-full md:w-1/3 mb-4 md:mb-0 flex flex-col\">\n              <div class=\"relative aspect-video bg-gray-200 dark:bg-gray-700 rounded-lg overflow-hidden\">\n                  <img\n                    v-if=\"videoInfo.pic\"\n                    :src=\"normalizeImageUrl(videoInfo.pic)\"\n                  :alt=\"videoInfo.title\"\n                  class=\"w-full h-full object-cover\"\n                />\n                <div v-else class=\"w-full h-full flex items-center justify-center text-gray-400\">\n                  <svg class=\"w-16 h-16\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z\" />\n                  </svg>\n                </div>\n\n                <!-- 视频时长标记 -->\n                <div v-if=\"videoInfo.duration\" class=\"absolute bottom-1 right-1 rounded bg-black/50 px-1 py-0.5 text-xs font-semibold text-white\">\n                  {{ formatDuration(videoInfo.duration) }}\n                </div>\n\n                <!-- 视频数据统计 - 播放、弹幕、评论放在封面上与时长同一行 -->\n                <div class=\"absolute bottom-1 left-1 flex space-x-2\">\n                </div>\n              </div>\n\n              <!-- 视频互动数据统计 - 点赞、投币、收藏、分享 -->\n              <div class=\"grid grid-cols-4 gap-2 mt-6\">\n                <div class=\"flex items-center justify-center text-gray-700\">\n                  <svg class=\"w-5 h-5 mr-1\" width=\"36\" height=\"36\" viewBox=\"0 0 36 36\" xmlns=\"http://www.w3.org/2000/svg\">\n                      <path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M9.77234 30.8573V11.7471H7.54573C5.50932 11.7471 3.85742 13.3931 3.85742 15.425V27.1794C3.85742 29.2112 5.50932 30.8573 7.54573 30.8573H9.77234ZM11.9902 30.8573V11.7054C14.9897 10.627 16.6942 7.8853 17.1055 3.33591C17.2666 1.55463 18.9633 0.814421 20.5803 1.59505C22.1847 2.36964 23.243 4.32583 23.243 6.93947C23.243 8.50265 23.0478 10.1054 22.6582 11.7471H29.7324C31.7739 11.7471 33.4289 13.402 33.4289 15.4435C33.4289 15.7416 33.3928 16.0386 33.3215 16.328L30.9883 25.7957C30.2558 28.7683 27.5894 30.8573 24.528 30.8573H11.9911H11.9902Z\" fill=\"currentColor\"/>\n                    </svg>\n                  <span class=\"text-sm font-medium\">{{ formatCount(videoInfo.stat?.like) }}</span>\n                </div>\n                <div class=\"flex items-center justify-center text-gray-700\">\n                  <svg class=\"w-5 h-5 mr-1\" width=\"28\" height=\"28\" viewBox=\"0 0 28 28\" xmlns=\"http://www.w3.org/2000/svg\">\n                      <path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M14.045 25.5454C7.69377 25.5454 2.54504 20.3967 2.54504 14.0454C2.54504 7.69413 7.69377 2.54541 14.045 2.54541C20.3963 2.54541 25.545 7.69413 25.545 14.0454C25.545 17.0954 24.3334 20.0205 22.1768 22.1771C20.0201 24.3338 17.095 25.5454 14.045 25.5454ZM9.66202 6.81624H18.2761C18.825 6.81624 19.27 7.22183 19.27 7.72216C19.27 8.22248 18.825 8.62807 18.2761 8.62807H14.95V10.2903C17.989 10.4444 20.3766 12.9487 20.3855 15.9916V17.1995C20.3854 17.6997 19.9799 18.1052 19.4796 18.1052C18.9793 18.1052 18.5738 17.6997 18.5737 17.1995V15.9916C18.5667 13.9478 16.9882 12.2535 14.95 12.1022V20.5574C14.95 21.0577 14.5444 21.4633 14.0441 21.4633C13.5437 21.4633 13.1382 21.0577 13.1382 20.5574V12.1022C11.1 12.2535 9.52148 13.9478 9.51448 15.9916V17.1995C9.5144 17.6997 9.10883 18.1052 8.60856 18.1052C8.1083 18.1052 7.70273 17.6997 7.70265 17.1995V15.9916C7.71158 12.9487 10.0992 10.4444 13.1382 10.2903V8.62807H9.66202C9.11309 8.62807 8.66809 8.22248 8.66809 7.72216C8.66809 7.22183 9.11309 6.81624 9.66202 6.81624Z\" fill=\"currentColor\"/>\n                    </svg>\n                  <span class=\"text-sm font-medium\">{{ formatCount(videoInfo.stat?.coin) }}</span>\n                </div>\n                <div class=\"flex items-center justify-center text-gray-700\">\n                  <svg class=\"w-5 h-5 mr-1\" width=\"28\" height=\"28\" viewBox=\"0 0 28 28\" xmlns=\"http://www.w3.org/2000/svg\">\n                      <path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M19.8071 9.26152C18.7438 9.09915 17.7624 8.36846 17.3534 7.39421L15.4723 3.4972C14.8998 2.1982 13.1004 2.1982 12.4461 3.4972L10.6468 7.39421C10.1561 8.36846 9.25639 9.09915 8.19315 9.26152L3.94016 9.91102C2.63155 10.0734 2.05904 11.6972 3.04049 12.6714L6.23023 15.9189C6.96632 16.6496 7.29348 17.705 7.1299 18.7605L6.39381 23.307C6.14844 24.6872 7.62063 25.6614 8.84745 25.0119L12.4461 23.0634C13.4276 22.4951 14.6544 22.4951 15.6359 23.0634L19.2345 25.0119C20.4614 25.6614 21.8518 24.6872 21.6882 23.307L20.8703 18.7605C20.7051 17.705 21.0339 16.6496 21.77 15.9189L24.9597 12.6714C25.9412 11.6972 25.3687 10.0734 24.06 9.91102L19.8071 9.26152Z\" fill=\"currentColor\"/>\n                    </svg>\n                  <span class=\"text-sm font-medium\">{{ formatCount(videoInfo.stat?.favorite) }}</span>\n                </div>\n                <div class=\"flex items-center justify-center text-gray-700\">\n                  <svg class=\"w-5 h-5 mr-1\" width=\"28\" height=\"28\" viewBox=\"0 0 28 28\" xmlns=\"http://www.w3.org/2000/svg\">\n                    <path d=\"M12.6058 10.3326V5.44359C12.6058 4.64632 13.2718 4 14.0934 4C14.4423 4 14.78 4.11895 15.0476 4.33606L25.3847 12.7221C26.112 13.3121 26.2087 14.3626 25.6007 15.0684C25.5352 15.1443 25.463 15.2144 25.3847 15.2779L15.0476 23.6639C14.4173 24.1753 13.4791 24.094 12.9521 23.4823C12.7283 23.2226 12.6058 22.8949 12.6058 22.5564V18.053C7.59502 18.053 5.37116 19.9116 2.57197 23.5251C2.47607 23.6489 2.00031 23.7769 2.00031 23.2122C2.00031 16.2165 3.90102 10.3326 12.6058 10.3326Z\" fill=\"currentColor\"/>\n                  </svg>\n                  <span class=\"text-sm font-medium\">{{ formatCount(videoInfo.stat?.share) }}</span>\n                </div>\n              </div>\n            </div>\n\n            <!-- 右侧信息 -->\n            <div class=\"w-full md:w-2/3 flex flex-col\">\n              <!-- 视频标题可点击 -->\n              <a\n                :href=\"`https://www.bilibili.com/video/${videoInfo.bvid}`\"\n                target=\"_blank\"\n                class=\"text-xl font-bold text-gray-900 dark:text-gray-100 mb-6 line-clamp-2 hover:text-[#fb7299] transition-colors duration-200\"\n              >\n                {{ videoInfo.title }}\n              </a>\n\n              <!-- 作者信息区域 - UP 主可点击 -->\n              <div v-if=\"videoInfo.owner\" class=\"flex items-center mb-6 space-x-2\">\n                <a\n                  :href=\"`https://space.bilibili.com/${videoInfo.owner.mid}`\"\n                  target=\"_blank\"\n                  class=\"flex items-center space-x-2 hover:text-[#fb7299] transition-colors duration-200\"\n                >\n                  <img\n                    v-if=\"videoInfo.owner.face\"\n                    :src=\"videoInfo.owner.face\"\n                    :alt=\"videoInfo.owner.name\"\n                    class=\"w-10 h-10 rounded-full object-cover border border-gray-200 dark:border-gray-700\"\n                  />\n                  <div class=\"text-sm font-medium text-gray-900 dark:text-gray-100\">{{ videoInfo.owner.name || '未知作者' }}</div>\n                </a>\n              </div>\n\n              <!-- 视频动态信息 - 在作者信息下方，添加提示文字 -->\n              <div v-if=\"videoInfo.dynamic\" class=\"mb-6 text-xs bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-900/40 px-2 py-1 rounded text-blue-700 dark:text-blue-300\">\n                <span class=\"font-medium mr-1\">视频同步发布的动态：</span>{{ videoInfo.dynamic }}\n              </div>\n\n              <!-- 视频基本信息 -->\n              <div class=\"mb-6 grid grid-cols-4 gap-2\">\n                <!-- 分区 显示 tname-tname_v2 -->\n                <div v-if=\"videoInfo.tname\" class=\"flex items-center text-gray-700 dark:text-gray-300 text-sm\">\n                  <svg class=\"w-4 h-4 mr-2\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z\" />\n                  </svg>\n                  <span>{{ videoInfo.tname + (videoInfo.tname_v2 ? `-${videoInfo.tname_v2}` : '') }}</span>\n                </div>\n\n                <!-- 视频类型 -->\n                <div v-if=\"videoInfo.copyright !== undefined\" class=\"flex items-center text-gray-700 dark:text-gray-300 text-sm\">\n                  <svg class=\"w-4 h-4 mr-2\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z\" />\n                  </svg>\n                  <span>{{ videoInfo.copyright === 1 ? '原创' : '转载' }}</span>\n                </div>\n\n                <!-- 清晰度 -->\n                <div v-if=\"videoInfo.dimension\" class=\"flex items-center text-gray-700 dark:text-gray-300 text-sm\">\n                  <svg class=\"w-4 h-4 mr-2\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z\" />\n                  </svg>\n                  <span>{{ videoInfo.dimension.width }}x{{ videoInfo.dimension.height }}</span>\n                </div>\n\n                <!-- 评论数 -->\n                <div v-if=\"videoInfo.stat?.reply\" class=\"flex items-center text-gray-700 dark:text-gray-300 text-sm\">\n                  <svg class=\"w-4 h-4 mr-2\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                    <path d=\"M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n                  </svg>\n                  <span>{{ formatCount(videoInfo.stat?.reply) }} 条评论</span>\n                </div>\n              </div>\n\n              <!-- 合集信息调试 -->\n              <div v-if=\"collectionInfo.is_collection\" class=\"mb-5 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-900/30 rounded-md\">\n                <h4 class=\"text-sm font-semibold text-blue-900 dark:text-blue-300 mb-2\">🎬 检测到合集</h4>\n                <p class=\"text-sm text-blue-800 dark:text-blue-300\">\n                  <strong>{{ collectionInfo.collection_title }}</strong>\n                </p>\n                <p class=\"text-xs text-blue-600 dark:text-blue-300 mt-1\">\n                  共 {{ collectionInfo.total_videos }} 个视频，当前是第 {{ collectionInfo.current_video_index }} 个\n                </p>\n              </div>\n\n              <!-- 多 P 信息 -->\n              <div v-if=\"videoInfo.pages && videoInfo.pages.length > 1\" class=\"mb-5\">\n                <h4 class=\"text-sm font-semibold text-gray-900 dark:text-gray-100 mb-5\">分 P 列表</h4>\n                <div class=\"max-h-40 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md\">\n                  <div\n                    v-for=\"(page, index) in videoInfo.pages\"\n                    :key=\"index\"\n                    class=\"flex items-center p-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 border-b border-gray-100 dark:border-gray-700 last:border-b-0\"\n                  >\n                    <span class=\"mr-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 w-6 h-6 flex items-center justify-center rounded-full text-xs\">{{ page.page }}</span>\n                    <span class=\"mr-auto truncate\">{{ page.part }}</span>\n                    <span class=\"text-gray-500 dark:text-gray-400 text-xs\">{{ formatDuration(page.duration) }}</span>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </div>\n\n          <!-- 视频简介 - 移动到外层，占据整行 -->\n          <div v-if=\"videoInfo.desc\" class=\"mt-3\">\n            <h4 class=\"text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2\">视频简介</h4>\n            <p class=\"text-sm text-gray-600 dark:text-gray-300 bg-gray-50 dark:bg-gray-900 p-2 rounded-md max-h-32 overflow-y-auto whitespace-pre-wrap\">{{ videoInfo.desc }}</p>\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <!-- 用户视频列表 -->\n    <div v-if=\"downloadType === 'user' && inputValue\" class=\"mt-6\">\n      <UserVideos :mid=\"inputValue\" />\n    </div>\n\n    <!-- 下载对话框 -->\n    <DownloadDialog\n      v-model:show=\"showDownloadDialog\"\n      :video-info=\"downloadVideoInfo\"\n      :up-user-videos=\"upUserVideosList\"\n      @download-complete=\"handleDownloadComplete\"\n    />\n\n    <!-- 合集选择对话框 -->\n    <Teleport to=\"body\">\n      <div v-if=\"showCollectionChoice\" class=\"fixed inset-0 z-50 flex items-center justify-center\">\n        <!-- 背景遮罩 -->\n        <div class=\"absolute inset-0 bg-black/50\" @click=\"showCollectionChoice = false\"></div>\n\n        <!-- 对话框内容 -->\n        <div class=\"relative bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 w-[500px] z-10 p-6\">\n          <h3 class=\"text-lg font-medium text-gray-900 dark:text-gray-100 mb-4\">检测到合集视频</h3>\n\n          <div class=\"mb-4\">\n            <p class=\"text-gray-600 dark:text-gray-400 mb-2\">\n              此视频属于合集：<span class=\"font-medium text-gray-800\">{{ collectionInfo.collection_title }}</span>\n            </p>\n            <p class=\"text-sm text-gray-500 dark:text-gray-400 mb-4\">\n              合集共包含 {{ collectionInfo.total_videos }} 个视频，当前是第 {{ collectionInfo.current_video_index }} 个\n            </p>\n\n            <p class=\"text-gray-600 dark:text-gray-400 mb-4\">\n              请选择下载方式：\n            </p>\n          </div>\n\n          <!-- 选择按钮 -->\n          <div class=\"flex flex-col gap-3 mb-6\">\n            <button\n              @click=\"handleCollectionChoice('single')\"\n              class=\"w-full px-4 py-3 text-left border border-gray-200 dark:border-gray-700 rounded-md hover:border-[#fb7299] hover:bg-[#fb7299]/5 transition-colors\"\n            >\n              <div class=\"font-medium text-gray-900 dark:text-gray-100\">只下载当前视频</div>\n              <div class=\"text-sm text-gray-500 dark:text-gray-400\">仅下载当前播放的这个视频</div>\n            </button>\n\n            <button\n              @click=\"handleCollectionChoice('collection')\"\n              class=\"w-full px-4 py-3 text-left border border-gray-200 dark:border-gray-700 rounded-md hover:border-[#fb7299] hover:bg-[#fb7299]/5 transition-colors\"\n            >\n              <div class=\"font-medium text-gray-900 dark:text-gray-100\">下载整个合集</div>\n              <div class=\"text-sm text-gray-500 dark:text-gray-400\">下载合集中的所有 {{ collectionInfo.total_videos }} 个视频</div>\n            </button>\n          </div>\n\n          <!-- 取消按钮 -->\n          <div class=\"flex justify-end\">\n            <button\n              @click=\"showCollectionChoice = false\"\n              class=\"px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors\"\n            >\n              取消\n            </button>\n          </div>\n        </div>\n      </div>\n    </Teleport>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, watch } from 'vue'\nimport { showNotify } from 'vant'\nimport 'vant/es/notify/style'\nimport { getLoginStatus, downloadVideo, downloadUserVideos, getVideoInfo, checkCollection, downloadCollection } from '../../../api/api'\nimport DownloadDialog from '../DownloadDialog.vue'\nimport SimpleSearchBar from '../SimpleSearchBar.vue'\nimport CustomDropdown from '../CustomDropdown.vue'\nimport UserVideos from '../UserVideos.vue'\nimport { normalizeImageUrl } from '@/utils/imageUrl.js'\n\n// 下载类型\nconst downloadType = ref('video')\nconst inputValue = ref('')\n\n// 下载类型选项\nconst downloadTypeOptions = [\n  { value: 'video', label: '单个视频' },\n  { value: 'user', label: 'UP 主投稿' }\n]\n\n// 获取显示文本\nconst getDownloadTypeLabel = (value) => {\n  const option = downloadTypeOptions.find(opt => opt.value === value)\n  return option ? option.label : '单个视频'\n}\n\n// 处理下载类型变更\nconst onDownloadTypeChange = (value) => {\n  downloadType.value = value\n  inputValue.value = '' // 清空输入框\n  videoInfo.value = {} // 重置视频信息\n}\n\n// 下载选项\nconst downloadCover = ref(true)\nconst onlyAudio = ref(false)\n\n// 下载状态\nconst isDownloading = ref(false)\nconst showDownloadDialog = ref(false)\n\n// 视频信息\nconst videoInfo = ref({})\nconst hasVideoInfo = computed(() => !!videoInfo.value?.bvid)\n\n// 合集信息\nconst collectionInfo = ref({})\nconst isCollection = computed(() => collectionInfo.value?.is_collection || false)\nconst showCollectionChoice = ref(false)\n\n// 下载对话框的视频信息\nconst downloadVideoInfo = ref({})\n\n// 添加用于存储UP主视频列表的ref\nconst upUserVideosList = ref([])\n\n// 从 BV 链接提取 BV 号\nconst extractBvid = (input) => {\n  // 如果已经是 BV 开头的格式，直接返回\n  if (/^BV[a-zA-Z0-9]{10}$/.test(input)) {\n    return input\n  }\n\n  // 尝试从 URL 中提取 BV 号\n  const match = input.match(/\\/video\\/(BV[a-zA-Z0-9]{10})/)\n  if (match && match[1]) {\n    return match[1]\n  }\n\n  return input // 返回原始输入，让后端处理错误\n}\n\n// 监听输入框内容变化，自动提取并获取视频信息\nwatch(inputValue, async (newValue) => {\n  if (downloadType.value === 'video') {\n    if (newValue) {\n      const extractedBvid = extractBvid(newValue)\n      if (/^BV[a-zA-Z0-9]{10}$/.test(extractedBvid)) {\n        // 自动获取视频信息\n        await handleVideoInfo(extractedBvid)\n      }\n    } else {\n      // 当输入框被清空时，重置视频信息\n      videoInfo.value = {}\n    }\n  }\n})\n\n// 获取视频信息单独拆分成函数\nconst handleVideoInfo = async (bvid) => {\n  isDownloading.value = true\n  try {\n    // 获取视频详细信息\n    const response = await getVideoInfo({ bvid })\n    if (response.data.status === 'success') {\n      videoInfo.value = response.data.data\n      console.log('获取到视频信息：', videoInfo.value)\n\n      // 检查是否为合集\n      await checkVideoCollection(inputValue.value)\n    } else {\n      throw new Error(response.data.message || '获取视频信息失败')\n    }\n  } catch (error) {\n    console.error('处理失败：', error)\n    showNotify({ type: 'danger', message: error.message || '获取信息失败' })\n  } finally {\n    isDownloading.value = false\n  }\n}\n\n// 检查视频是否为合集\nconst checkVideoCollection = async (url) => {\n  try {\n    console.log('开始检查合集，URL:', url)\n    const response = await checkCollection(url)\n    console.log('合集检测API响应：', response)\n    if (response.data.status === 'success') {\n      collectionInfo.value = response.data.data\n      console.log('合集检测结果：', collectionInfo.value)\n      console.log('是否为合集：', collectionInfo.value.is_collection)\n    } else {\n      console.log('合集检测API返回失败状态：', response.data)\n      collectionInfo.value = { is_collection: false }\n    }\n  } catch (error) {\n    console.error('检查合集失败：', error)\n    // 不显示错误，因为这不是关键功能\n    collectionInfo.value = { is_collection: false }\n  }\n}\n\n// 处理下载\nconst handleDownload = async () => {\n  try {\n    if (!inputValue.value) return\n\n    isDownloading.value = true\n\n    if (downloadType.value === 'video') {\n      // 如果已有视频信息，检查是否为合集\n      if (hasVideoInfo.value) {\n        console.log('已有视频信息，检查是否为合集')\n        console.log('isCollection.value:', isCollection.value)\n        console.log('collectionInfo.value:', collectionInfo.value)\n        if (isCollection.value) {\n          // 是合集，显示选择对话框\n          console.log('检测到合集，显示选择对话框')\n          isDownloading.value = false\n          showCollectionChoice.value = true\n          return\n        } else {\n          // 不是合集，直接下载\n          console.log('不是合集，直接下载')\n          startDownload()\n        }\n      } else {\n        // 没有视频信息，先获取信息再开始下载\n        console.log('没有视频信息，先获取信息')\n        const extractedBvid = extractBvid(inputValue.value)\n        await handleVideoInfo(extractedBvid)\n        if (hasVideoInfo.value) {\n          console.log('获取视频信息后，检查是否为合集')\n          console.log('isCollection.value:', isCollection.value)\n          console.log('collectionInfo.value:', collectionInfo.value)\n          if (isCollection.value) {\n            // 是合集，显示选择对话框\n            console.log('检测到合集，显示选择对话框')\n            isDownloading.value = false\n            showCollectionChoice.value = true\n            return\n          } else {\n            // 不是合集，直接下载\n            console.log('不是合集，直接下载')\n            startDownload()\n          }\n        }\n      }\n    } else {\n      // 用户投稿下载 - 先获取视频列表信息\n      try {\n        console.log('获取UP主视频信息，UID:', inputValue.value)\n        const { getUserVideos } = await import('../../../api/api')\n\n        // 先获取第一个视频以获取封面\n        const response = await getUserVideos({\n          mid: inputValue.value,\n          pn: 1,\n          ps: 1 // 只获取第一个视频，用于显示封面\n        })\n\n        console.log('获取UP主视频响应:', response.data)\n\n        // 检查响应\n        if (response.data.status === 'success' &&\n            response.data.data.list.vlist &&\n            response.data.data.list.vlist.length > 0) {\n\n          const firstVideo = response.data.data.list.vlist[0]\n          console.log('第一个视频信息:', firstVideo)\n          console.log('视频封面URL:', firstVideo.pic)\n\n          // 使用第一个视频的信息\n          downloadVideoInfo.value = {\n            title: `UP 主 ${firstVideo.author || inputValue.value} 的全部投稿视频`,\n            author: firstVideo.author || '',\n            bvid: firstVideo.bvid || '',\n            pic: firstVideo.pic || '',\n            cover: firstVideo.pic || '',\n            cid: 0,\n            // 特殊字段标识这是用户视频下载\n            is_user_videos: true,\n            user_id: inputValue.value\n          }\n\n          console.log('准备显示下载弹窗，传递的视频信息:', downloadVideoInfo.value)\n          console.log('检查封面URL是否正确:', downloadVideoInfo.value.pic)\n\n          // 开始预加载更多视频的信息，获取页数和投稿总数\n          await fetchUpUserVideosList(inputValue.value)\n        } else {\n          console.warn('未获取到UP主视频信息，使用默认值')\n          // 使用默认信息\n          downloadVideoInfo.value = {\n            title: `UP 主 ${inputValue.value} 的全部投稿视频`,\n            author: '',\n            bvid: '',\n            cover: '',\n            pic: '',\n            cid: 0,\n            // 特殊字段标识这是用户视频下载\n            is_user_videos: true,\n            user_id: inputValue.value\n          }\n        }\n      } catch (error) {\n        console.error('获取UP主视频信息失败:', error)\n        // 使用默认信息\n        downloadVideoInfo.value = {\n          title: `UP 主 ${inputValue.value} 的全部投稿视频`,\n          author: '',\n          bvid: '',\n          cover: '',\n          pic: '',\n          cid: 0,\n          // 特殊字段标识这是用户视频下载\n          is_user_videos: true,\n          user_id: inputValue.value\n        }\n      }\n\n      // 显示下载对话框\n      showDownloadDialog.value = true\n    }\n  } catch (error) {\n    console.error('处理失败：', error)\n    showNotify({ type: 'danger', message: error.message || '处理失败' })\n    isDownloading.value = false\n  }\n}\n\n// 获取UP主的视频列表，预加载视频数据\nconst fetchUpUserVideosList = async (userId) => {\n  try {\n    const { getUserVideos } = await import('../../../api/api')\n\n    // 先获取第一页，确定总视频数\n    const response = await getUserVideos({\n      mid: userId,\n      pn: 1,\n      ps: 30\n    })\n\n    if (!response.data || response.data.status !== 'success') {\n      console.warn('获取UP主视频列表失败')\n      return\n    }\n\n    // 保存第一页的视频\n    const videos = response.data.data.list.vlist || []\n    upUserVideosList.value = videos\n\n    // 尝试获取总页数\n    const count = response.data.data.page?.count || 0\n    if (count > 30) {\n      // 如果总数超过30个，只预加载前2页\n      const totalPages = Math.min(3, Math.ceil(count / 30))\n\n      for (let page = 2; page <= totalPages; page++) {\n        try {\n          const pageResponse = await getUserVideos({\n            mid: userId,\n            pn: page,\n            ps: 30\n          })\n\n          if (pageResponse.data && pageResponse.data.status === 'success') {\n            const pageVideos = pageResponse.data.data.list.vlist || []\n            upUserVideosList.value = [...upUserVideosList.value, ...pageVideos]\n          }\n\n          // 防止频繁请求\n          await new Promise(resolve => setTimeout(resolve, 300))\n        } catch (err) {\n          console.error(`获取第${page}页UP主视频列表失败:`, err)\n        }\n      }\n    }\n\n    console.log(`已预加载 ${upUserVideosList.value.length} 个视频信息`)\n  } catch (error) {\n    console.error('预加载UP主视频列表失败:', error)\n  }\n}\n\n// 格式化时长\nconst formatDuration = (seconds) => {\n  if (!seconds) return '未知时长'\n\n  const hours = Math.floor(seconds / 3600)\n  const minutes = Math.floor((seconds % 3600) / 60)\n  const remainingSeconds = seconds % 60\n\n  if (hours > 0) {\n    return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`\n  } else {\n    return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`\n  }\n}\n\n// 格式化时间戳为秒级时间显示\nconst formatDetailedTimestamp = (timestamp) => {\n  if (!timestamp) return '未知时间'\n\n  const date = new Date(timestamp * 1000)\n  const year = date.getFullYear()\n  const month = (date.getMonth() + 1).toString().padStart(2, '0')\n  const day = date.getDate().toString().padStart(2, '0')\n  const hours = date.getHours().toString().padStart(2, '0')\n  const minutes = date.getMinutes().toString().padStart(2, '0')\n  const seconds = date.getSeconds().toString().padStart(2, '0')\n\n  return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`\n}\n\n// 格式化数字（播放、点赞等）\nconst formatCount = (count) => {\n  if (!count && count !== 0) return '0'\n\n  if (count >= 10000) {\n    return (count / 10000).toFixed(1) + '万'\n  }\n\n  return count.toString()\n}\n\n// 开始下载\nconst startDownload = () => {\n  try {\n    console.log('开始下载，类型:', downloadType.value)\n\n    if (downloadType.value === 'video') {\n      // 设置视频信息\n      downloadVideoInfo.value = {\n        title: videoInfo.value.title,\n        author: videoInfo.value.owner?.name || '',\n        bvid: videoInfo.value.bvid,\n        cover: videoInfo.value.pic,\n        pic: videoInfo.value.pic,\n        cid: videoInfo.value.cid\n      }\n      console.log('设置单个视频下载信息:', downloadVideoInfo.value)\n    } else {\n      // 此时应该已经在 handleDownload 中设置了用户投稿下载信息\n      // 确保必要的字段存在\n      if (!downloadVideoInfo.value.is_user_videos) {\n        console.warn('用户视频下载信息不完整，重新设置')\n        downloadVideoInfo.value = {\n          ...downloadVideoInfo.value,\n          is_user_videos: true,\n          user_id: inputValue.value\n        }\n      }\n      console.log('用户视频下载信息:', downloadVideoInfo.value)\n    }\n\n    // 显示下载对话框\n    showDownloadDialog.value = true\n  } catch (error) {\n    console.error('开始下载失败：', error)\n    showNotify({ type: 'danger', message: error.message || '开始下载失败' })\n  }\n}\n\n// 处理合集选择\nconst handleCollectionChoice = (choice) => {\n  showCollectionChoice.value = false\n\n  if (choice === 'single') {\n    // 下载单个视频\n    startDownload()\n  } else if (choice === 'collection') {\n    // 下载整个合集\n    startCollectionDownload()\n  }\n}\n\n// 开始合集下载\nconst startCollectionDownload = () => {\n  // 设置合集下载信息\n  downloadVideoInfo.value = {\n    title: collectionInfo.value.collection_title || videoInfo.value.title,\n    author: videoInfo.value.owner?.name || '',\n    bvid: videoInfo.value.bvid,\n    cover: videoInfo.value.pic || '',\n    cid: videoInfo.value.cid || 0,\n    // 特殊字段标识这是合集下载\n    is_collection_download: true,\n    collection_info: collectionInfo.value,\n    original_url: inputValue.value\n  }\n\n  // 显示下载对话框\n  showDownloadDialog.value = true\n}\n\n// 处理下载完成\nconst handleDownloadComplete = () => {\n  showNotify({ type: 'success', message: '下载任务已完成' })\n  // 不自动重置输入，让用户可以再次点击下载\n}\n</script>\n\n<style scoped>\n/* 移除搜索框的默认样式 */\ninput[type=\"search\"]::-webkit-search-decoration,\ninput[type=\"search\"]::-webkit-search-cancel-button,\ninput[type=\"search\"]::-webkit-search-results-button,\ninput[type=\"search\"]::-webkit-search-results-decoration {\n  display: none;\n}\n\n/* 移除输入框的默认 focus 样式 */\ninput:focus {\n  box-shadow: none !important;\n  outline: none !important;\n}\n\n:deep(.custom-dropdown-trigger) {\n  border: none !important;\n  box-shadow: none !important;\n  background: transparent !important;\n}\n\n/* 确保下拉菜单按钮文本不会折行 */\n:deep(.custom-dropdown-trigger span) {\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n</style>"
  },
  {
    "path": "src/components/tailwind/scheduler/SelectDialog.vue",
    "content": "<template>\n  <van-dialog\n    :show=\"show\"\n    @update:show=\"$emit('update:show', $event)\"\n    :title=\"title\"\n    width=\"90%\"\n    :show-confirm-button=\"false\"\n    class=\"select-dialog\"\n  >\n    <template #title>\n      <div class=\"flex items-center justify-between px-4\">\n        <span>{{ title }}</span>\n        <button\n          @click=\"$emit('update:show', false)\"\n          class=\"p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors\"\n        >\n          <svg class=\"w-4 h-4 text-gray-500 dark:text-gray-400\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\" />\n          </svg>\n        </button>\n      </div>\n    </template>\n\n    <div class=\"p-4\">\n      <!-- 搜索框 -->\n      <div class=\"mb-4 flex items-center space-x-2\">\n        <div class=\"flex-1 relative\">\n          <input\n            v-model=\"searchQuery\"\n            type=\"text\"\n            :placeholder=\"searchPlaceholder\"\n            class=\"w-full pl-8 pr-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-[#fb7299] focus:border-[#fb7299]\"\n          />\n          <svg class=\"w-4 h-4 text-gray-400 absolute left-2.5 top-2\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z\" />\n          </svg>\n          <button\n            v-if=\"searchQuery\"\n            @click=\"resetSearch\"\n            class=\"absolute right-2 top-2 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300\"\n          >\n            <svg class=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\" />\n            </svg>\n          </button>\n        </div>\n        \n        <!-- 方法过滤器 -->\n        <select\n          v-if=\"showMethodFilter\"\n          v-model=\"methodFilter\"\n          class=\"text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-[#fb7299] focus:border-[#fb7299] py-1.5 pl-2 pr-8\"\n        >\n          <option value=\"ALL\">全部</option>\n          <option value=\"GET\">GET</option>\n          <option value=\"POST\">POST</option>\n          <option value=\"PUT\">PUT</option>\n          <option value=\"DELETE\">DELETE</option>\n        </select>\n      </div>\n\n      <!-- 列表内容 -->\n      <div class=\"custom-scrollbar max-h-[45vh] overflow-y-auto pr-2\">\n        <div v-for=\"(items, group) in filteredGroupedItems\" :key=\"group\" class=\"mb-4\">\n          <div \n            class=\"flex items-center justify-between cursor-pointer mb-2 bg-gray-100 dark:bg-gray-800 p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700\"\n            @click=\"toggleGroup(group)\"\n          >\n            <div class=\"flex items-center space-x-2\">\n              <span class=\"text-sm font-medium text-gray-800 dark:text-gray-100\">{{ group || '未分类' }}</span>\n              <span class=\"text-xs text-gray-600 dark:text-gray-400\">({{ items.length }}个)</span>\n            </div>\n            <svg \n              class=\"w-4 h-4 text-gray-600 transform transition-transform duration-200\"\n              :class=\"{ 'rotate-90': expandedGroups[group] }\"\n              fill=\"none\" \n              viewBox=\"0 0 24 24\" \n              stroke=\"currentColor\"\n            >\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 5l7 7-7 7\" />\n            </svg>\n          </div>\n          \n          <div v-show=\"expandedGroups[group]\" class=\"space-y-1 pl-2 border-l-2 border-gray-100 dark:border-gray-700\">\n            <div \n              v-for=\"item in items\" \n              :key=\"item.id\"\n              @click=\"selectItem(item)\"\n              class=\"p-2 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer rounded-md transition-colors\"\n              :class=\"{'bg-[#fb7299]/10 hover:bg-[#fb7299]/20': isSelected(item)}\"\n            >\n              <div class=\"flex items-start justify-between\">\n                <div class=\"flex-1\">\n                  <div class=\"text-sm font-medium text-gray-900 dark:text-gray-100\">{{ item.name || '' }}</div>\n                  <div class=\"text-xs text-gray-500 dark:text-gray-400 mt-0.5\">{{ item.description || '' }}</div>\n                </div>\n                <div v-if=\"item.method\" class=\"ml-3\">\n                  <span class=\"px-2 py-0.5 text-xs font-medium rounded-full\" \n                        :class=\"{\n                          'bg-blue-100 text-blue-800': item.method === 'GET',\n                          'bg-green-100 text-green-800': item.method === 'POST',\n                          'bg-yellow-100 text-yellow-800': item.method === 'PUT',\n                          'bg-red-100 text-red-800': item.method === 'DELETE'\n                        }\">\n                    {{ item.method }}\n                  </span>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </van-dialog>\n</template>\n\n<script setup>\nimport { ref, computed, watch } from 'vue'\nimport 'vant/es/dialog/style'\n\nconst props = defineProps({\n  show: {\n    type: Boolean,\n    default: false\n  },\n  title: {\n    type: String,\n    required: true\n  },\n  items: {\n    type: Array,\n    default: () => []\n  },\n  groupBy: {\n    type: String,\n    default: 'tags'\n  },\n  selected: {\n    type: [String, Array],\n    default: null\n  },\n  multiple: {\n    type: Boolean,\n    default: false\n  },\n  showMethodFilter: {\n    type: Boolean,\n    default: false\n  },\n  searchPlaceholder: {\n    type: String,\n    default: '搜索...'\n  }\n})\n\nconst emit = defineEmits(['update:show', 'select', 'update:selected'])\n\nconst searchQuery = ref('')\nconst methodFilter = ref('ALL')\nconst expandedGroups = ref({})\nconst selectedItems = ref(props.multiple ? [] : null)\n\n// 初始化选中状态\nwatch(() => props.selected, (newVal) => {\n  selectedItems.value = props.multiple ? (Array.isArray(newVal) ? newVal : []) : newVal\n}, { immediate: true })\n\n// 更新格式化任务项的函数以适应新的数据结构\nconst formatTaskItem = (task) => {\n  const formattedTask = {\n    id: task.id || task.operationId || task.path,\n    name: task.name || task.summary || task.path,\n    description: task.description || task.path,\n    method: task.method || 'GET',\n    tags: Array.isArray(task.tags) ? task.tags : [],\n    enabled: task.enabled,\n    execution: task.execution || {},\n    config: task.config || {},\n    parent_id: task.parent_id,\n    sub_tasks: task.sub_tasks || [],\n    sequence_number: task.sequence_number\n  }\n  return formattedTask\n}\n\n// 分组和过滤数据\nconst filteredGroupedItems = computed(() => {\n  let filtered = props.items.map(formatTaskItem)\n\n  // 搜索过滤\n  if (searchQuery.value) {\n    const query = searchQuery.value.toLowerCase()\n    filtered = filtered.filter(item => \n      (item.name && item.name.toLowerCase().includes(query)) ||\n      (item.description && item.description.toLowerCase().includes(query))\n    )\n  }\n\n  // 方法过滤\n  if (props.showMethodFilter && methodFilter.value !== 'ALL') {\n    filtered = filtered.filter(item => item.method === methodFilter.value)\n  }\n\n  // 分组\n  const grouped = {}\n  filtered.forEach(item => {\n    if (props.groupBy === 'tags' && Array.isArray(item.tags) && item.tags.length > 0) {\n      item.tags.forEach(tag => {\n        if (!grouped[tag]) {\n          grouped[tag] = []\n        }\n        grouped[tag].push(item)\n      })\n    } else {\n      const group = item[props.groupBy] || '未分类'\n      if (!grouped[group]) {\n        grouped[group] = []\n      }\n      grouped[group].push(item)\n    }\n  })\n\n  return grouped\n})\n\n// 切换分组展开状态\nconst toggleGroup = (group) => {\n  expandedGroups.value[group] = !expandedGroups.value[group]\n}\n\n// 重置搜索\nconst resetSearch = () => {\n  searchQuery.value = ''\n  methodFilter.value = 'ALL'\n  // 重置所有分组为展开状态\n  Object.keys(filteredGroupedItems.value).forEach(group => {\n    expandedGroups.value[group] = true\n  })\n}\n\n// 选择项目\nconst selectItem = (item) => {\n  if (props.multiple) {\n    const index = selectedItems.value.indexOf(item.id)\n    if (index === -1) {\n      selectedItems.value.push(item.id)\n    } else {\n      selectedItems.value.splice(index, 1)\n    }\n  } else {\n    selectedItems.value = item.id\n    emit('update:show', false)\n  }\n  emit('select', item)\n  emit('update:selected', selectedItems.value)\n}\n\n// 判断是否选中\nconst isSelected = (item) => {\n  if (props.multiple) {\n    return selectedItems.value.includes(item.id)\n  }\n  return selectedItems.value === item.id\n}\n\n// 监听显示状态，重置搜索和展开所有分组\nwatch(() => props.show, (newVal) => {\n  if (newVal) {\n    resetSearch()\n  }\n})\n</script>\n\n<style scoped>\n.select-dialog :deep(.van-dialog) {\n  max-height: 80vh;\n  overflow: hidden;\n  display: flex;\n  flex-direction: column;\n}\n\n.select-dialog :deep(.van-dialog__header) {\n  flex-shrink: 0;\n  padding: 12px 16px;\n  font-size: 14px;\n}\n\n/* 自定义滚动条样式 */\n.custom-scrollbar {\n  scrollbar-width: thin;\n  scrollbar-color: #fb7299 #f3f4f6;\n}\n\n.custom-scrollbar::-webkit-scrollbar {\n  width: 4px;\n}\n\n.custom-scrollbar::-webkit-scrollbar-track {\n  background: #f3f4f6;\n  border-radius: 2px;\n}\n\n.custom-scrollbar::-webkit-scrollbar-thumb {\n  background-color: #fb7299;\n  border-radius: 2px;\n}\n</style> "
  },
  {
    "path": "src/components/tailwind/scheduler/TaskDetail.vue",
    "content": "<template>\n  <van-dialog\n    :show=\"show\"\n    @update:show=\"$emit('update:show', $event)\"\n    title=\"任务详情\"\n    width=\"60%\"\n    :show-confirm-button=\"false\"\n    class=\"task-detail-dialog\"\n  >\n    <template #title>\n      <div class=\"flex items-center justify-between px-3\">\n        <span>任务详情</span>\n        <div class=\"flex items-center space-x-2\">\n          <button \n            @click=\"$emit('view-history', task.task_id)\" \n            class=\"inline-flex items-center px-2 py-1 text-xs font-medium text-white bg-[#fb7299] hover:bg-[#fb7299]/90 focus:outline-none rounded-md\"\n          >\n            <svg class=\"w-3.5 h-3.5 mr-1\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z\" />\n            </svg>\n            查看历史\n          </button>\n          <button\n            @click=\"$emit('update:show', false)\"\n            class=\"p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors\"\n          >\n            <svg class=\"w-4 h-4 text-gray-500 dark:text-gray-400\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\" />\n            </svg>\n          </button>\n        </div>\n      </div>\n    </template>\n    <div v-if=\"task\" class=\"p-3\">\n      <!-- 任务标题和状态栏 -->\n      <div class=\"flex items-center justify-between mb-3 pb-2 border-b border-gray-100 dark:border-gray-700\">\n        <div class=\"flex items-center\">\n          <div class=\"p-1.5 bg-[#fb7299]/10 rounded-lg mr-2\">\n            <svg class=\"w-4 h-4 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z\" />\n            </svg>\n          </div>\n          <div>\n            <div class=\"flex items-center space-x-1\">\n              <h3 class=\"text-base font-medium text-gray-800 dark:text-gray-100 truncate\" :title=\"task.config?.name\">{{ task.config?.name }}</h3>\n            </div>\n            <p class=\"text-xs text-gray-500 dark:text-gray-400\">ID: {{ task.task_id }}</p>\n          </div>\n        </div>\n        <div class=\"flex flex-col items-end\">\n          <div class=\"flex items-center space-x-1 mb-1\">\n            <span\n              v-if=\"task.execution?.status\"\n              :class=\"{\n                'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 border-green-200 dark:border-green-900/30': task.execution.status === 'success',\n                'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-300 border-yellow-200 dark:border-yellow-900/30': task.execution.status === 'running',\n                'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 border-red-200 dark:border-red-900/30': task.execution.status === 'error'\n              }\"\n              class=\"px-1.5 py-0.5 text-xs font-medium rounded-md border\"\n            >\n              {{ statusLabel }}\n            </span>\n            <span \n              v-if=\"task.config?.enabled !== undefined\"\n              :class=\"{'bg-green-50 text-green-700 border-green-200': task.config.enabled, 'bg-red-50 text-red-700 border-red-200': !task.config.enabled}\" \n              class=\"px-1.5 py-0.5 text-xs font-medium rounded-md border\"\n            >\n              {{ task.config.enabled ? '已启用' : '已禁用' }}\n            </span>\n          </div>\n        </div>\n      </div>\n\n      <!-- 任务详情内容 -->\n      <div class=\"space-y-3\">\n        <!-- 基本信息 -->\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg p-2 border border-gray-200 dark:border-gray-700\">\n          <h4 class=\"text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider mb-1.5 flex items-center\">\n            <svg class=\"w-3 h-3 mr-1 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n            </svg>\n            基本信息\n          </h4>\n          <div class=\"grid grid-cols-2 gap-2\">\n            <div>\n              <p class=\"text-xs text-gray-500 dark:text-gray-400\">API端点</p>\n              <p class=\"text-sm text-gray-800 dark:text-gray-100 font-mono\">{{ task.config?.endpoint }}</p>\n            </div>\n            <div>\n              <p class=\"text-xs text-gray-500 dark:text-gray-400\">请求方法</p>\n              <p class=\"text-sm text-gray-800 dark:text-gray-100\">{{ task.config?.method }}</p>\n            </div>\n            <div>\n              <p class=\"text-xs text-gray-500 dark:text-gray-400\">优先级</p>\n              <p class=\"text-sm text-gray-800 dark:text-gray-100\">{{ task.config?.priority || 0 }}</p>\n            </div>\n            <div>\n              <p class=\"text-xs text-gray-500 dark:text-gray-400\">最后修改</p>\n              <p class=\"text-sm text-gray-800 dark:text-gray-100\">{{ task.last_modified?.replace('T', ' ') }}</p>\n            </div>\n          </div>\n        </div>\n\n        <!-- 调度信息 -->\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg p-2 border border-gray-200 dark:border-gray-700\">\n          <h4 class=\"text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider mb-1.5 flex items-center\">\n            <svg class=\"w-3 h-3 mr-1 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z\" />\n            </svg>\n            调度信息\n          </h4>\n          <div class=\"grid grid-cols-2 gap-2\">\n            <div>\n              <p class=\"text-xs text-gray-500 dark:text-gray-400\">调度类型</p>\n              <p class=\"text-sm text-gray-800 dark:text-gray-100\">{{ scheduleTypeLabel }}</p>\n            </div>\n            <div>\n              <p class=\"text-xs text-gray-500 dark:text-gray-400\">执行时间</p>\n              <p class=\"text-sm text-gray-800 dark:text-gray-100\">\n                <template v-if=\"task.task_type === 'main'\">\n                  <template v-if=\"task.config?.schedule_type === 'interval'\">\n                    {{ task.config?.interval_value || '-' }} \n                    {{ \n                      task.config?.interval_unit === 'minutes' ? '分钟' : \n                      task.config?.interval_unit === 'hours' ? '小时' : \n                      task.config?.interval_unit === 'days' ? '天' : \n                      task.config?.interval_unit === 'months' ? '月' : \n                      task.config?.interval_unit === 'years' ? '年' : \n                      task.config?.interval_unit || ''\n                    }}\n                  </template>\n                  <template v-else>\n                    {{ task.config?.schedule_time || '未设置' }}\n                  </template>\n                </template>\n                <template v-else>\n                  依赖于主任务\n                </template>\n              </p>\n            </div>\n            <div>\n              <p class=\"text-xs text-gray-500 dark:text-gray-400\">上次执行</p>\n              <p class=\"text-sm text-gray-800 dark:text-gray-100\">{{ task.execution?.last_run?.replace('T', ' ') || '从未执行' }}</p>\n            </div>\n            <div>\n              <p class=\"text-xs text-gray-500 dark:text-gray-400\">下次执行</p>\n              <p class=\"text-sm text-gray-800 dark:text-gray-100\">\n                <template v-if=\"task.task_type === 'main'\">\n                  {{ task.execution?.next_run || '未排定' }}\n                </template>\n                <template v-else>\n                  依赖于主任务\n                </template>\n              </p>\n            </div>\n          </div>\n        </div>\n\n        <!-- 执行统计 -->\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg p-2 border border-gray-200 dark:border-gray-700\">\n          <h4 class=\"text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider mb-1.5 flex items-center\">\n            <svg class=\"w-3 h-3 mr-1 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z\" />\n            </svg>\n            执行统计\n          </h4>\n          <div class=\"grid grid-cols-2 gap-2\">\n            <div>\n              <div class=\"flex justify-between items-center mb-1\">\n                <span class=\"text-xs text-gray-500 dark:text-gray-400\">成功率</span>\n                <span class=\"text-xs text-gray-800 dark:text-gray-100\">\n                  {{ Math.round(executionInfo.successRate) }}%\n                </span>\n              </div>\n              <div class=\"h-1.5 w-full bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden\">\n                <div class=\"h-full rounded-full\" \n                  :class=\"{\n                    'bg-green-500': executionInfo.successRate >= 90,\n                    'bg-yellow-500': executionInfo.successRate >= 60 && executionInfo.successRate < 90,\n                    'bg-red-500': executionInfo.successRate < 60\n                  }\" \n                  :style=\"{width: `${executionInfo.successRate}%`}\">\n                </div>\n              </div>\n            </div>\n            <div>\n              <p class=\"text-xs text-gray-500 dark:text-gray-400\">平均耗时</p>\n              <p class=\"text-sm text-gray-800 dark:text-gray-100\">\n                {{ executionInfo.avgDuration.toFixed(2) }}秒\n              </p>\n            </div>\n            <div>\n              <p class=\"text-xs text-gray-500 dark:text-gray-400\">总执行次数</p>\n              <p class=\"text-sm text-gray-800 dark:text-gray-100\">\n                {{ executionInfo.totalRuns }}\n              </p>\n            </div>\n            <div>\n              <p class=\"text-xs text-gray-500 dark:text-gray-400\">成功/失败</p>\n              <p class=\"text-sm\">\n                <span class=\"text-green-600\">{{ executionInfo.successRuns }}</span>\n                <span class=\"text-gray-400 mx-1\">/</span>\n                <span class=\"text-red-600\">{{ executionInfo.failRuns }}</span>\n              </p>\n            </div>\n          </div>\n        </div>\n\n        <!-- 依赖任务 -->\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg p-2 border border-gray-200 dark:border-gray-700\">\n          <h4 class=\"text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider mb-1.5 flex items-center\">\n            <svg class=\"w-3 h-3 mr-1 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4\" />\n            </svg>\n            依赖任务\n          </h4>\n          <div v-if=\"task.depends_on\" class=\"flex flex-wrap gap-0.5\">\n            <span\n              class=\"inline-flex items-center px-1.5 py-0.5 rounded-md text-xs font-medium bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-900/30\"\n              :title=\"task.depends_on.name\"\n            >\n              {{ task.depends_on.name }} ({{ task.depends_on.task_id }})\n            </span>\n          </div>\n          <p v-else class=\"text-xs text-gray-500 dark:text-gray-400\">无依赖</p>\n        </div>\n\n        <!-- 最近错误 -->\n        <div v-if=\"task.execution?.last_error\" class=\"bg-white dark:bg-gray-800 rounded-lg p-2 border border-red-100 dark:border-red-900/30 shadow-sm\">\n          <h4 class=\"text-xs font-semibold text-red-600 uppercase tracking-wider mb-1.5 flex items-center\">\n            <svg class=\"w-3 h-3 mr-1 text-red-600\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n            </svg>\n            最近错误\n          </h4>\n          <p class=\"text-xs text-red-600 whitespace-pre-wrap\">{{ task.execution.last_error }}</p>\n        </div>\n      </div>\n    </div>\n  </van-dialog>\n</template>\n\n<script setup>\nimport { computed, watch } from 'vue'\nimport { showNotify, showDialog } from 'vant'\nimport 'vant/es/dialog/style'\nimport 'vant/es/notify/style'\n\nconst props = defineProps({\n  show: {\n    type: Boolean,\n    default: false\n  },\n  task: {\n    type: Object,\n    default: null\n  }\n})\n\nconst emit = defineEmits([\n  'update:show',\n  'view-history',\n  'edit-task',\n  'execute-task',\n  'toggle-enabled',\n  'delete-task'\n])\n\n// 添加 watch 来监控 task 属性的变化\nwatch(() => props.task, (newTask) => {\n  if (!newTask) {\n    return\n  }\n}, { deep: true, immediate: true })\n\n// 添加计算属性来处理执行信息\nconst executionInfo = computed(() => {\n  const execution = props.task?.execution || {}\n  \n  // 确保所有数值类型的字段都有默认值\n  const info = {\n    lastRun: execution.last_run ?? null,\n    nextRun: execution.next_run ?? null,\n    status: execution.status ?? 'pending',\n    successRate: typeof execution.success_rate === 'number' ? execution.success_rate : 0,\n    avgDuration: typeof execution.avg_duration === 'number' ? execution.avg_duration : 0,\n    totalRuns: typeof execution.total_runs === 'number' ? execution.total_runs : 0,\n    successRuns: typeof execution.success_runs === 'number' ? execution.success_runs : 0,\n    failRuns: typeof execution.fail_runs === 'number' ? execution.fail_runs : 0\n  }\n  \n  return info\n})\n\n// 计算调度类型标签\nconst scheduleTypeLabel = computed(() => {\n  const type = props.task?.config?.schedule_type\n  return type === 'daily' ? '每日' : \n         type === 'chain' ? '链式任务' : \n         type === 'once' ? '一次性' : \n         type === 'interval' ? '间隔执行' : type\n})\n\n// 计算状态标签\nconst statusLabel = computed(() => {\n  const status = props.task?.execution?.status\n  return status === 'success' ? '成功' :\n         status === 'running' ? '执行中' :\n         status === 'error' ? '失败' :\n         status === 'pending' ? '等待中' : status\n})\n\n// 确认删除\nconst confirmDelete = () => {\n  showDialog({\n    title: '确认删除',\n    message: `确定要删除任务 \"${props.task.config?.name}\" 吗？此操作不可撤销。`,\n    showCancelButton: true,\n    confirmButtonText: '删除',\n    confirmButtonColor: '#ee0a24',\n  }).then(() => {\n    emit('delete-task', props.task.task_id)\n  }).catch(() => {\n    // 用户取消删除\n  })\n}\n</script>\n\n<script>\nexport default {\n  name: 'TaskDetail'\n}\n</script>\n\n<style>\n/* 确保弹窗在X轴和Y轴都居中 */\n.task-detail-dialog .van-dialog {\n  position: fixed !important;\n  top: 50% !important;\n  left: 50% !important;\n  transform: translate(-50%, -50%) !important;\n  margin: 0 !important;\n  max-height: 80vh !important;\n  overflow-y: auto !important;\n}\n\n.task-detail-dialog :deep(.van-dialog__content) {\n  max-height: 70vh;\n  overflow-y: auto;\n}\n\n.task-detail-dialog :deep(.van-dialog) {\n  max-height: 85vh;\n  overflow: hidden;\n  display: flex;\n  flex-direction: column;\n}\n\n.task-detail-dialog :deep(.van-dialog__header) {\n  flex-shrink: 0;\n  padding: 12px 16px;\n  font-size: 14px;\n}\n</style>"
  },
  {
    "path": "src/components/tailwind/scheduler/TaskForm.vue",
    "content": "<template>\n  <van-dialog\n    :show=\"show\"\n    @update:show=\"$emit('update:show', $event)\"\n    :title=\"getDialogTitle\"\n    width=\"60%\"\n    :show-confirm-button=\"false\"\n    class=\"task-form-dialog\"\n  >\n    <div class=\"p-2\">\n      <form @submit.prevent=\"submitForm\" class=\"space-y-2\">\n        <!-- 表单标题和说明 -->\n        <div class=\"flex items-center mb-2 pb-1 border-b border-gray-100 dark:border-gray-700\">\n          <div class=\"p-1 bg-[#fb7299]/10 rounded-lg mr-2\">\n            <svg class=\"w-3.5 h-3.5 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z\" />\n            </svg>\n          </div>\n          <div>\n            <h3 class=\"text-sm font-medium text-gray-800 dark:text-gray-200\">{{ getFormTitle }}</h3>\n            <p class=\"text-xs text-gray-500 dark:text-gray-400\">{{ getFormDescription }}</p>\n          </div>\n        </div>\n\n        <!-- 基本信息 -->\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg p-1.5 border border-gray-200 dark:border-gray-700\">\n          <h4 class=\"text-xs font-semibold text-gray-600 uppercase tracking-wider mb-1 flex items-center\">\n            <svg class=\"w-3 h-3 mr-1 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n            </svg>\n            基本信息\n          </h4>\n          <div class=\"grid grid-cols-1 md:grid-cols-2 gap-2\">\n            <div>\n              <label for=\"taskId\" class=\"block text-xs font-medium text-gray-700 dark:text-gray-300 mb-0.5\">任务ID</label>\n              <input\n                id=\"taskId\"\n                v-model=\"form.task_id\"\n                type=\"text\"\n                :disabled=\"props.isEditing\"\n                class=\"block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-[#fb7299] focus:ring-[#fb7299] text-xs py-1 dark:bg-gray-700 dark:text-gray-100\"\n                placeholder=\"请输入任务ID（选择API端点后会自动填充，可修改）\"\n                required\n              />\n            </div>\n\n            <div>\n              <label for=\"name\" class=\"block text-xs font-medium text-gray-700 dark:text-gray-300 mb-0.5\">任务名称 *</label>\n              <input\n                id=\"name\"\n                v-model=\"form.name\"\n                type=\"text\"\n                class=\"block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-[#fb7299] focus:ring-[#fb7299] text-xs py-1 dark:bg-gray-700 dark:text-gray-100\"\n                required\n                placeholder=\"例如：获取B站历史记录\"\n              />\n            </div>\n          </div>\n        </div>\n\n        <!-- API设置 -->\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg p-1.5 border border-gray-200 dark:border-gray-700\">\n          <h4 class=\"text-xs font-semibold text-gray-600 uppercase tracking-wider mb-1 flex items-center\">\n            <svg class=\"w-3 h-3 mr-1 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4\" />\n            </svg>\n            API设置\n          </h4>\n          <div class=\"grid grid-cols-1 md:grid-cols-2 gap-2\">\n            <div>\n              <label for=\"endpoint\" class=\"block text-xs font-medium text-gray-700 dark:text-gray-300 mb-0.5\">API端点 *</label>\n              <div class=\"relative\">\n                <button\n                  type=\"button\"\n                  @click=\"showApiSelector = true\"\n                  class=\"block w-full text-left rounded-md border border-gray-300 dark:border-gray-600 shadow-sm focus:border-[#fb7299] focus:ring-[#fb7299] text-xs py-1.5 px-2 bg-white dark:bg-gray-700 dark:text-gray-100\"\n                >\n                  {{ form.endpoint || '请选择API端点' }}\n                </button>\n              </div>\n            </div>\n\n            <div>\n              <label for=\"method\" class=\"block text-xs font-medium text-gray-700 dark:text-gray-300 mb-0.5\">请求方法</label>\n              <input\n                id=\"method\"\n                v-model=\"form.method\"\n                type=\"text\"\n                disabled\n                class=\"block w-full rounded-md border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 shadow-sm focus:border-[#fb7299] focus:ring-[#fb7299] text-xs py-1 dark:text-gray-100\"\n              />\n            </div>\n          </div>\n        </div>\n\n        <!-- 热门视频清理设置（仅清理接口显示） -->\n        <div v-if=\"isPopularCleanupEndpoint\" class=\"bg-white dark:bg-gray-800 rounded-lg p-1.5 border border-gray-200 dark:border-gray-700\">\n          <h4 class=\"text-xs font-semibold text-gray-600 uppercase tracking-wider mb-1 flex items-center\">\n            <svg class=\"w-3 h-3 mr-1 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm0 10c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6z\" />\n            </svg>\n            热门视频清理\n          </h4>\n          <div class=\"grid grid-cols-1 md:grid-cols-2 gap-2\">\n            <div class=\"md:col-span-2\">\n              <label for=\"popularCleanupYear\" class=\"block text-xs font-medium text-gray-700 dark:text-gray-300 mb-0.5\">清理年份（可选）</label>\n              <select\n                id=\"popularCleanupYear\"\n                v-model=\"popularCleanupYear\"\n                class=\"block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-[#fb7299] focus:ring-[#fb7299] text-xs py-1 disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed dark:bg-gray-700 dark:text-gray-100 disabled:dark:bg-gray-800 disabled:dark:text-gray-500\"\n                :disabled=\"popularCleanupYearsLoading\"\n              >\n                <option :value=\"null\">全部年份（不传 year）</option>\n                <option\n                  v-for=\"year in popularCleanupYearOptions\"\n                  :key=\"year\"\n                  :value=\"year\"\n                >\n                  {{ year }}\n                </option>\n              </select>\n              <div v-if=\"popularCleanupYearsLoading\" class=\"text-xs text-gray-500 dark:text-gray-400 mt-0.5\">\n                正在获取可用年份...\n              </div>\n              <div v-else-if=\"popularCleanupYearsMessage\" class=\"text-xs text-gray-500 dark:text-gray-400 mt-0.5\">\n                {{ popularCleanupYearsMessage }}\n              </div>\n              <div v-else-if=\"popularCleanupDefaultYear\" class=\"text-xs text-gray-500 dark:text-gray-400 mt-0.5\">\n                建议默认年份：{{ popularCleanupDefaultYear }}\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <!-- 调度设置 -->\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg p-1.5 border border-gray-200 dark:border-gray-700\">\n          <h4 class=\"text-xs font-semibold text-gray-600 uppercase tracking-wider mb-1 flex items-center\">\n            <svg class=\"w-3 h-3 mr-1 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z\" />\n            </svg>\n            调度设置\n          </h4>\n          <div class=\"grid grid-cols-1 md:grid-cols-2 gap-2\">\n            <div>\n              <label for=\"scheduleType\" class=\"block text-xs font-medium text-gray-700 dark:text-gray-300 mb-0.5\">调度类型 *</label>\n              <select\n                id=\"scheduleType\"\n                v-model=\"form.schedule_type\"\n                class=\"block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-[#fb7299] focus:ring-[#fb7299] text-xs py-1 disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed dark:bg-gray-700 dark:text-gray-100 disabled:dark:bg-gray-800 disabled:dark:text-gray-500\"\n                required\n                :disabled=\"props.parentTaskId || props.isEditing\"\n              >\n                <option v-if=\"!props.parentTaskId\" value=\"daily\">每日</option>\n                <option v-if=\"!props.parentTaskId\" value=\"interval\">间隔执行</option>\n                <option value=\"chain\">链式</option>\n              </select>\n            </div>\n\n            <div v-if=\"form.schedule_type === 'daily' && !props.parentTaskId\">\n              <label for=\"scheduleTime\" class=\"block text-xs font-medium text-gray-700 dark:text-gray-300 mb-0.5\">执行时间 *</label>\n              <input\n                id=\"scheduleTime\"\n                v-model=\"form.schedule_time\"\n                type=\"time\"\n                class=\"block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-[#fb7299] focus:ring-[#fb7299] text-xs py-1 dark:bg-gray-700 dark:text-gray-100\"\n                required\n              />\n            </div>\n\n            <div v-if=\"form.schedule_type === 'interval' && !props.parentTaskId\" class=\"col-span-2 grid grid-cols-2 gap-2\">\n              <div>\n                <label for=\"intervalValue\" class=\"block text-xs font-medium text-gray-700 dark:text-gray-300 mb-0.5\">间隔时间 *</label>\n                <input\n                  id=\"intervalValue\"\n                  v-model.number=\"form.interval\"\n                  type=\"number\"\n                  min=\"1\"\n                  class=\"block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-[#fb7299] focus:ring-[#fb7299] text-xs py-1 disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed dark:bg-gray-700 dark:text-gray-100 disabled:dark:bg-gray-800 disabled:dark:text-gray-500\"\n                  required\n                  :disabled=\"props.isEditing\"\n                />\n                <span v-if=\"props.isEditing\" class=\"text-xs text-gray-500 mt-0.5 block\">间隔任务的时间不可修改</span>\n              </div>\n              <div>\n                <label for=\"intervalUnit\" class=\"block text-xs font-medium text-gray-700 dark:text-gray-300 mb-0.5\">时间单位 *</label>\n                <select\n                  id=\"intervalUnit\"\n                  v-model=\"form.unit\"\n                  class=\"block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-[#fb7299] focus:ring-[#fb7299] text-xs py-1 disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed dark:bg-gray-700 dark:text-gray-100 disabled:dark:bg-gray-800 disabled:dark:text-gray-500\"\n                  required\n                  :disabled=\"props.isEditing\"\n                >\n                  <option value=\"minutes\">分钟</option>\n                  <option value=\"hours\">小时</option>\n                  <option value=\"days\">天</option>\n                  <option value=\"months\">月</option>\n                  <option value=\"years\">年</option>\n                </select>\n                <span v-if=\"props.isEditing\" class=\"text-xs text-gray-500 mt-0.5 block\">间隔任务的单位不可修改</span>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <!-- 依赖任务 -->\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg p-1.5 border border-gray-200 dark:border-gray-700\">\n          <h4 class=\"text-xs font-semibold text-gray-600 uppercase tracking-wider mb-1 flex items-center\">\n            <svg class=\"w-3 h-3 mr-1 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4\" />\n            </svg>\n            依赖任务\n          </h4>\n          <div>\n            <label for=\"requires\" class=\"block text-xs font-medium text-gray-700 dark:text-gray-300 mb-0.5\">依赖任务 (可选)</label>\n            <div class=\"relative\">\n              <button\n                type=\"button\"\n                @click=\"showDependencySelector = true\"\n                :disabled=\"props.parentTaskId || props.isEditing\"\n                class=\"block w-full text-left rounded-md border border-gray-300 dark:border-gray-600 shadow-sm focus:border-[#fb7299] focus:ring-[#fb7299] text-xs py-1.5 px-2 min-h-[2rem] disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed bg-white dark:bg-gray-700 dark:text-gray-100 disabled:dark:bg-gray-800 disabled:dark:text-gray-500\"\n              >\n                <div v-if=\"form.depends_on.length === 0\" class=\"text-gray-500 dark:text-gray-400\">\n                  {{ props.parentTaskId ? '子任务依赖关系由系统自动设置' : (props.isEditing ? '编辑时不可修改依赖任务' : '选择依赖任务') }}\n                </div>\n                <div v-else class=\"flex flex-wrap gap-1\">\n                  <div\n                    v-for=\"taskId in form.depends_on\"\n                    :key=\"taskId\"\n                    class=\"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-[#fb7299]/10 text-[#fb7299]\"\n                  >\n                    <span>{{ getTaskName(taskId) }}</span>\n                    <button\n                      type=\"button\"\n                      @click.stop=\"removeTask(taskId)\"\n                      class=\"ml-1 hover:text-[#fb7299]/70\"\n                      v-if=\"!props.parentTaskId && !props.isEditing\"\n                    >\n                      <svg class=\"w-3 h-3\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\" />\n                      </svg>\n                    </button>\n                  </div>\n                </div>\n              </button>\n            </div>\n          </div>\n        </div>\n\n        <!-- 底部按钮 -->\n        <div class=\"flex justify-end space-x-2 pt-1.5 border-t border-gray-100 dark:border-gray-700\">\n          <button\n            type=\"button\"\n            @click=\"cancel\"\n            class=\"inline-flex items-center px-2 py-1 border border-gray-300 dark:border-gray-600 rounded-md text-xs font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-1 focus:ring-offset-1 focus:ring-[#fb7299]\"\n          >\n            取消\n          </button>\n          <button\n            type=\"submit\"\n            class=\"inline-flex items-center px-2 py-1 border border-transparent rounded-md text-xs font-medium text-white bg-[#fb7299] hover:bg-[#fb7299]/90 focus:outline-none focus:ring-1 focus:ring-offset-1 focus:ring-[#fb7299]\"\n          >\n            {{ getSubmitButtonText }}\n          </button>\n        </div>\n      </form>\n    </div>\n\n    <!-- API选择弹窗 -->\n    <select-dialog\n      v-model:show=\"showApiSelector\"\n      title=\"选择API端点\"\n      :items=\"availableEndpoints\"\n      v-model:selected=\"selectedEndpoint\"\n      :show-method-filter=\"true\"\n      search-placeholder=\"搜索API端点...\"\n      group-by=\"tags\"\n      id-field=\"id\"\n      name-field=\"name\"\n      description-field=\"description\"\n      @select=\"handleApiSelect\"\n    />\n\n    <!-- 依赖任务选择弹窗 -->\n    <select-dialog\n      v-model:show=\"showDependencySelector\"\n      title=\"选择依赖任务\"\n      :items=\"availableEndpoints\"\n      v-model:selected=\"form.depends_on\"\n      :multiple=\"false\"\n      :show-method-filter=\"true\"\n      search-placeholder=\"搜索任务...\"\n      group-by=\"tags\"\n      id-field=\"operationId\"\n      name-field=\"summary\"\n      description-field=\"path\"\n    />\n  </van-dialog>\n</template>\n\n<script setup>\nimport { ref, computed, reactive, watch, nextTick } from 'vue'\nimport { showNotify } from 'vant'\nimport 'vant/es/notify/style'\nimport SelectDialog from './SelectDialog.vue'\nimport {\n  createSchedulerTask,\n  updateSchedulerTask,\n  getSchedulerTaskDetail,\n  getAvailableEndpoints,\n  addSubTask,\n  getPopularCleanupYears\n} from '../../../api/api'\n\nconst props = defineProps({\n  show: {\n    type: Boolean,\n    default: false\n  },\n  isEditing: {\n    type: Boolean,\n    default: false\n  },\n  taskId: {\n    type: String,\n    default: ''\n  },\n  parentTaskId: {\n    type: String,\n    default: ''\n  },\n  tasks: {\n    type: Array,\n    default: () => []\n  },\n  currentTask: {\n    type: Object,\n    default: () => null\n  }\n})\n\nconst emit = defineEmits(['update:show', 'task-saved'])\n\n// 表单数据\nconst form = reactive({\n  task_id: '',\n  name: '',\n  endpoint: '',\n  method: '',\n  params: {},\n  schedule_type: 'daily',\n  schedule_time: '00:00',\n  interval: 1,\n  unit: 'hours',\n  depends_on: [],\n  enabled: true,\n  sub_tasks: []\n})\n\n// 参数JSON文本和错误信息\nconst paramsJson = ref('{}')\nconst paramsError = ref('')\n\nconst POPULAR_CLEANUP_ENDPOINT_PATH = '/bilibili/popular/cleanup'\n\nconst parseEndpointUrlParts = (endpoint) => {\n  const raw = (endpoint || '').trim()\n  if (!raw) return { path: '', searchParams: new URLSearchParams() }\n\n  try {\n    const url = new URL(raw, 'http://placeholder')\n    return { path: url.pathname || '', searchParams: url.searchParams || new URLSearchParams() }\n  } catch {\n    const [pathPart, queryPart] = raw.split('?')\n    return { path: (pathPart || raw).trim(), searchParams: new URLSearchParams(queryPart || '') }\n  }\n}\n\nconst normalizeEndpointPath = (path) => {\n  let normalized = (path || '').trim()\n  if (!normalized) return ''\n  if (!normalized.startsWith('/')) normalized = `/${normalized}`\n  normalized = normalized.replace(/\\/+$/, '')\n  return normalized || '/'\n}\n\nconst isPopularCleanupEndpointPath = (path) => {\n  const normalized = normalizeEndpointPath(path)\n  return normalized === POPULAR_CLEANUP_ENDPOINT_PATH || normalized.endsWith(POPULAR_CLEANUP_ENDPOINT_PATH)\n}\n\n// 可用的API端点\nconst availableEndpoints = ref([])\n\n// 在 script setup 部分添加\nconst showApiSelector = ref(false)\nconst showDependencySelector = ref(false)\nconst apiSearchQuery = ref('')\nconst expandedTags = reactive({})\nconst methodFilter = ref('ALL')\nconst selectedEndpoint = ref('')\n\nconst popularCleanupYears = ref([])\nconst popularCleanupDefaultYear = ref(null)\nconst popularCleanupYearsMessage = ref('')\nconst popularCleanupYearsLoading = ref(false)\nconst popularCleanupYearTouched = ref(false)\nconst cachedPopularCleanupYear = ref(undefined)\n\nconst popularCleanupEndpointParts = computed(() => parseEndpointUrlParts(form.endpoint))\n\nconst isPopularCleanupEndpoint = computed(() => {\n  return isPopularCleanupEndpointPath(popularCleanupEndpointParts.value.path)\n})\n\nconst popularCleanupYearFromEndpointQuery = computed(() => {\n  const year = popularCleanupEndpointParts.value.searchParams.get('year')\n  if (!year) return null\n  const normalized = Number.parseInt(year, 10)\n  return Number.isFinite(normalized) ? normalized : null\n})\n\nconst popularCleanupYear = computed({\n  get: () => (Object.prototype.hasOwnProperty.call(form.params, 'year') ? form.params.year : null),\n  set: (year) => {\n    popularCleanupYearTouched.value = true\n    if (year === null || year === undefined || year === '') {\n      if (Object.prototype.hasOwnProperty.call(form.params, 'year')) {\n        delete form.params.year\n      }\n      return\n    }\n    form.params.year = year\n  }\n})\n\nconst popularCleanupYearOptions = computed(() => {\n  const years = Array.isArray(popularCleanupYears.value) ? [...popularCleanupYears.value] : []\n  const currentYear = form.params?.year\n  const normalizedCurrentYear = typeof currentYear === 'string' ? Number.parseInt(currentYear, 10) : currentYear\n\n  if (Number.isFinite(normalizedCurrentYear) && !years.includes(normalizedCurrentYear)) {\n    years.push(normalizedCurrentYear)\n  }\n\n  return years\n    .filter((y) => Number.isFinite(y))\n    .sort((a, b) => b - a)\n})\n\nconst applyPopularCleanupYearFromEndpointQuery = () => {\n  if (!isPopularCleanupEndpoint.value) return\n  const yearFromQuery = popularCleanupYearFromEndpointQuery.value\n  if (yearFromQuery === null || yearFromQuery === undefined) return\n  if (!Object.prototype.hasOwnProperty.call(form.params, 'year')) {\n    form.params.year = yearFromQuery\n  }\n}\n\nconst normalizePopularCleanupYearParam = () => {\n  if (!form.params || typeof form.params !== 'object') return\n  if (!Object.prototype.hasOwnProperty.call(form.params, 'year')) return\n\n  const year = form.params.year\n  if (year === null || year === undefined || year === '') {\n    delete form.params.year\n    return\n  }\n\n  if (typeof year === 'string') {\n    const normalized = Number.parseInt(year, 10)\n    if (Number.isFinite(normalized)) {\n      form.params.year = normalized\n    } else {\n      delete form.params.year\n    }\n  }\n}\n\nconst fetchPopularCleanupYears = async () => {\n  if (popularCleanupYearsLoading.value) return\n\n  popularCleanupYearsLoading.value = true\n  popularCleanupYearsMessage.value = ''\n  popularCleanupDefaultYear.value = null\n\n  try {\n    const response = await getPopularCleanupYears()\n    if (response.data && response.data.status === 'success') {\n      popularCleanupYears.value = Array.isArray(response.data.data) ? response.data.data : []\n      popularCleanupDefaultYear.value = response.data.default_year ?? null\n      popularCleanupYearsMessage.value = response.data.message || ''\n\n      // 新建任务：当用户尚未手动选择且未设置 year 时，自动使用后端建议默认年份\n      if (\n        isPopularCleanupEndpoint.value &&\n        !props.isEditing &&\n        !popularCleanupYearTouched.value &&\n        !Object.prototype.hasOwnProperty.call(form.params, 'year') &&\n        popularCleanupDefaultYear.value !== null &&\n        popularCleanupDefaultYear.value !== undefined\n      ) {\n        form.params.year = popularCleanupDefaultYear.value\n      }\n    } else {\n      popularCleanupYears.value = []\n      popularCleanupYearsMessage.value = response.data?.message || '获取年份列表失败'\n      popularCleanupDefaultYear.value = null\n    }\n  } catch (error) {\n    popularCleanupYears.value = []\n    popularCleanupYearsMessage.value = '获取年份列表出错: ' + (error.message || '未知错误')\n    popularCleanupDefaultYear.value = null\n  } finally {\n    popularCleanupYearsLoading.value = false\n  }\n}\n\n// 按tag对API进行分组\nconst groupedEndpoints = computed(() => {\n  const groups = {}\n  availableEndpoints.value.forEach(endpoint => {\n    const tags = endpoint.tags && endpoint.tags.length > 0 ? endpoint.tags : ['未分类']\n    tags.forEach(tag => {\n      if (!groups[tag]) {\n        groups[tag] = []\n      }\n      groups[tag].push(endpoint)\n    })\n  })\n  return groups\n})\n\n// 过滤后的分组结果\nconst filteredGroupedEndpoints = computed(() => {\n  const query = apiSearchQuery.value.toLowerCase()\n  const filtered = {}\n\n  Object.entries(groupedEndpoints.value).forEach(([tag, apis]) => {\n    const filteredApis = apis.filter(endpoint => {\n      const matchesSearch = !query ||\n        endpoint.path.toLowerCase().includes(query) ||\n        endpoint.summary?.toLowerCase().includes(query)\n\n      const matchesMethod = methodFilter.value === 'ALL' ||\n        endpoint.method === methodFilter.value\n\n      return matchesSearch && matchesMethod\n    })\n\n    if (filteredApis.length > 0) {\n      filtered[tag] = filteredApis\n    }\n  })\n\n  return filtered\n})\n\n// 切换tag展开状态\nconst toggleTagExpand = (tag) => {\n  expandedTags[tag] = !expandedTags[tag]\n}\n\n// 重置搜索和展开状态\nconst resetSearch = () => {\n  apiSearchQuery.value = ''\n  methodFilter.value = 'ALL'\n  Object.keys(groupedEndpoints.value).forEach(tag => {\n    expandedTags[tag] = true\n  })\n}\n\n// 监听搜索框变化\nwatch(apiSearchQuery, (newVal) => {\n  if (!newVal) {\n    // 当搜索框清空时，重置所有展开状态\n    resetSearch()\n  }\n})\n\n// 初始化所有tag为展开状态\nwatch(() => showApiSelector.value, (newVal) => {\n  if (newVal) {\n    resetSearch()\n  }\n})\n\n// 监听选中的API端点变化\nwatch(selectedEndpoint, (newVal) => {\n  if (newVal) {\n    const endpoint = availableEndpoints.value.find(e => e.id === newVal || e.operationId === newVal || e.path === newVal)\n    if (endpoint) {\n      form.endpoint = endpoint.description || endpoint.path\n      form.method = endpoint.method || 'GET'\n      if (!form.task_id) {\n        form.task_id = endpoint.id || endpoint.operationId || endpoint.path\n      }\n      if (!form.name && (endpoint.name || endpoint.summary)) {\n        form.name = endpoint.name || endpoint.summary\n      }\n    }\n  }\n})\n\n// 获取任务名称\nconst getTaskName = (taskId) => {\n  const endpoint = availableEndpoints.value.find(e => e.operationId === taskId || e.path === taskId)\n  return endpoint ? `${endpoint.summary || endpoint.path} (${taskId})` : taskId\n}\n\n// 移除依赖任务\nconst removeTask = (taskId) => {\n  form.depends_on = form.depends_on.filter(id => id !== taskId)\n}\n\n// 计算属性：检查表单是否有效\nconst isFormValid = computed(() => {\n  // 检查基本字段\n  if (!form.task_id.trim() || !form.name.trim() || !form.endpoint || !form.method) {\n    return false\n  }\n\n  // 检查调度相关字段\n  if (form.schedule_type === 'daily' && !form.schedule_time) {\n    return false\n  }\n\n  if (form.schedule_type === 'interval' && (!form.interval || form.interval < 1 || !form.unit)) {\n    return false\n  }\n\n  // 检查参数JSON格式\n  return paramsError.value === ''\n})\n\n// 重置表单\nconst resetForm = () => {\n  // 根据当前的props状态设置初始值\n  const initialState = {\n    task_id: '',\n    name: '',\n    endpoint: '',\n    method: '',\n    params: {},\n    schedule_type: props.parentTaskId ? 'chain' : 'daily',\n    schedule_time: '00:00',\n    interval: 1,\n    unit: 'hours',\n    depends_on: props.currentTask?.depends_on ? [props.currentTask.depends_on.task_id] : [],\n    enabled: true,\n    sub_tasks: []\n  }\n\n  // 使用Object.keys确保所有属性都被重置\n  Object.keys(form).forEach(key => {\n    if (key === 'depends_on' && props.parentTaskId && props.currentTask) {\n      return\n    }\n    form[key] = initialState[key]\n  })\n\n  // 重置其他相关状态\n  selectedEndpoint.value = ''\n  paramsJson.value = '{}'\n  paramsError.value = ''\n  popularCleanupYearTouched.value = false\n  cachedPopularCleanupYear.value = undefined\n  showApiSelector.value = false\n  showDependencySelector.value = false\n  apiSearchQuery.value = ''\n  methodFilter.value = 'ALL'\n  Object.keys(expandedTags).forEach(key => {\n    expandedTags[key] = false\n  })\n}\n\n// 监听show变化\nwatch(() => props.show, async (newVal) => {\n  if (newVal) {\n    await fetchAvailableEndpoints()\n\n    if (props.isEditing && props.taskId) {\n      await loadTaskDetail(props.taskId)\n    } else if (!props.isEditing && props.parentTaskId) {\n      resetForm()\n      form.schedule_type = 'chain'\n\n      // 查找父任务及其子任务\n      const parentTask = props.tasks.find(task => task.task_id === props.parentTaskId)\n\n      if (parentTask?.sub_tasks?.length > 0) {\n        // 如果父任务有子任务，依赖最后一个子任务\n        const lastSubTask = parentTask.sub_tasks[parentTask.sub_tasks.length - 1]\n        form.depends_on = [lastSubTask.task_id]\n\n        // 确保依赖任务在availableEndpoints中\n        if (!availableEndpoints.value.find(e => e.operationId === lastSubTask.task_id)) {\n          availableEndpoints.value.push({\n            operationId: lastSubTask.task_id,\n            summary: lastSubTask.config?.name || lastSubTask.task_id,\n            path: lastSubTask.task_id\n          })\n        }\n      } else {\n        // 如果父任务没有子任务，依赖父任务\n        form.depends_on = [props.parentTaskId]\n\n        // 确保父任务在availableEndpoints中\n        if (!availableEndpoints.value.find(e => e.operationId === props.parentTaskId)) {\n          availableEndpoints.value.push({\n            operationId: props.parentTaskId,\n            summary: parentTask?.config?.name || props.parentTaskId,\n            path: props.parentTaskId\n          })\n        }\n      }\n    } else {\n      resetForm()\n    }\n  } else {\n    resetForm()\n    emit('task-saved')\n  }\n}, { immediate: true })\n\n// 监听currentTask变化\nwatch(() => props.currentTask, async (newVal) => {\n  if (props.show && !props.isEditing && props.parentTaskId && newVal) {\n    await nextTick()\n    setDependencyFromCurrentTask(newVal)\n  }\n})\n\n// 设置依赖关系的辅助函数\nconst setDependencyFromCurrentTask = (task) => {\n  if (!task) {\n    form.depends_on = [props.parentTaskId]\n    return\n  }\n\n  if (task.sub_tasks?.length > 0) {\n    // 如果主任务有子任务，依赖最后一个子任务\n    const lastSubTask = task.sub_tasks[task.sub_tasks.length - 1]\n    form.depends_on = [lastSubTask.task_id]\n\n    // 确保依赖任务在availableEndpoints中\n    if (!availableEndpoints.value.find(e => e.operationId === lastSubTask.task_id)) {\n      availableEndpoints.value.push({\n        operationId: lastSubTask.task_id,\n        summary: lastSubTask.config?.name || lastSubTask.task_id,\n        path: lastSubTask.task_id\n      })\n    }\n  } else {\n    // 如果主任务没有子任务，依赖主任务\n    form.depends_on = [props.parentTaskId]\n\n    // 确保父任务在availableEndpoints中\n    if (!availableEndpoints.value.find(e => e.operationId === props.parentTaskId)) {\n      availableEndpoints.value.push({\n        operationId: props.parentTaskId,\n        summary: props.parentTaskId,\n        path: props.parentTaskId\n      })\n    }\n  }\n}\n\n// 加载任务详情\nconst loadTaskDetail = async (taskId) => {\n  try {\n    const response = await getSchedulerTaskDetail(taskId, { include_subtasks: true })\n    if (response.data && response.data.status === 'success') {\n      const taskInfo = response.data.tasks[0]\n      if (taskInfo) {\n        // 基本信息\n        form.task_id = taskInfo.task_id\n        form.name = taskInfo.config.name\n        form.endpoint = taskInfo.config.endpoint\n        form.method = taskInfo.config.method\n        form.params = taskInfo.config.params || {}\n        if (isPopularCleanupEndpointPath(taskInfo.config.endpoint)) {\n          popularCleanupYearTouched.value = true\n          applyPopularCleanupYearFromEndpointQuery()\n          normalizePopularCleanupYearParam()\n        }\n        form.schedule_type = taskInfo.config.schedule_type\n        form.schedule_time = taskInfo.config.schedule_time\n        form.enabled = taskInfo.config.enabled\n\n        // 间隔执行配置\n        if (taskInfo.config.schedule_type === 'interval') {\n          form.interval = taskInfo.config.interval || 1\n          form.unit = taskInfo.config.unit || 'hours'\n        }\n\n        // 设置依赖任务\n        if (taskInfo.depends_on) {\n          form.depends_on = [taskInfo.depends_on.task_id]\n          // 确保依赖任务在availableEndpoints中\n          if (!availableEndpoints.value.find(e => e.operationId === taskInfo.depends_on.task_id)) {\n            availableEndpoints.value.push({\n              operationId: taskInfo.depends_on.task_id,\n              summary: taskInfo.depends_on.name,\n              path: taskInfo.depends_on.task_id\n            })\n          }\n        } else {\n          form.depends_on = []\n        }\n\n        // 加载子任务\n        if (taskInfo.sub_tasks) {\n          form.sub_tasks = taskInfo.sub_tasks\n        }\n      }\n    } else {\n      showNotify({ type: 'danger', message: '获取任务详情失败: ' + (response.data?.message || '未知错误') })\n    }\n  } catch (error) {\n    showNotify({ type: 'danger', message: '获取任务详情出错: ' + (error.message || '未知错误') })\n  }\n}\n\nwatch(\n  () => [props.show, isPopularCleanupEndpoint.value],\n  async ([show, isCleanup]) => {\n    if (!show || !isCleanup) return\n    applyPopularCleanupYearFromEndpointQuery()\n    normalizePopularCleanupYearParam()\n    await fetchPopularCleanupYears()\n  }\n)\n\nwatch(isPopularCleanupEndpoint, (isCleanup, wasCleanup) => {\n  if (wasCleanup && !isCleanup) {\n    cachedPopularCleanupYear.value = popularCleanupYear.value\n    if (Object.prototype.hasOwnProperty.call(form.params, 'year')) {\n      delete form.params.year\n    }\n    return\n  }\n\n  if (!wasCleanup && isCleanup) {\n    normalizePopularCleanupYearParam()\n    if (\n      cachedPopularCleanupYear.value !== undefined &&\n      !Object.prototype.hasOwnProperty.call(form.params, 'year') &&\n      cachedPopularCleanupYear.value !== null\n    ) {\n      form.params.year = cachedPopularCleanupYear.value\n    }\n  }\n})\n\n// 获取可用的API端点\nconst fetchAvailableEndpoints = async () => {\n  try {\n    const response = await getAvailableEndpoints()\n    if (response.data && response.data.status === 'success') {\n      availableEndpoints.value = response.data.endpoints || []\n    } else {\n      showNotify({ type: 'danger', message: '获取API端点失败: ' + (response.data?.message || '未知错误') })\n    }\n  } catch (error) {\n    showNotify({ type: 'danger', message: '获取API端点出错: ' + (error.message || '未知错误') })\n  }\n}\n\n// 监听paramsJson变化，验证JSON格式\nwatch(paramsJson, (newVal) => {\n  if (!newVal.trim()) {\n    paramsJson.value = '{}'\n    paramsError.value = ''\n    return\n  }\n\n  try {\n    JSON.parse(newVal)\n    paramsError.value = ''\n  } catch (error) {\n    paramsError.value = 'JSON格式错误: ' + error.message\n  }\n})\n\n// 取消按钮\nconst cancel = () => {\n  resetForm()\n  emit('update:show', false)\n  emit('task-saved')\n}\n\n// 提交表单\nconst submitForm = async () => {\n  try {\n    if (props.parentTaskId) {\n      form.schedule_type = 'chain'\n    }\n\n    // 确保任务ID不为空\n    if (!form.task_id.trim()) {\n      showNotify({ type: 'danger', message: '任务ID不能为空' })\n      return\n    }\n\n    console.log('开始提交任务表单:', props.isEditing ? '编辑模式' : (props.parentTaskId ? '子任务模式' : '主任务模式'))\n\n    const taskData = {\n      task_id: form.task_id.trim(),\n      name: form.name,\n      endpoint: form.endpoint,\n      method: form.method,\n      params: form.params,\n      enabled: form.enabled\n    }\n\n    // 只有在创建新任务时才设置调度类型和依赖任务\n    if (!props.isEditing) {\n      taskData.schedule_type = form.schedule_type\n\n      if (form.schedule_type === 'daily') {\n        taskData.schedule_time = form.schedule_time\n      } else if (form.schedule_type === 'interval') {\n        taskData.interval = form.interval\n        taskData.unit = form.unit\n      }\n\n      if (form.depends_on.length > 0) {\n        taskData.depends_on = {\n          task_id: form.depends_on[0],\n          name: getTaskName(form.depends_on[0])\n        }\n      }\n    } else {\n      // 编辑模式下，保留原有的调度类型\n      taskData.schedule_type = form.schedule_type\n\n      // 如果是每日任务，可以修改执行时间\n      if (form.schedule_type === 'daily') {\n        taskData.schedule_time = form.schedule_time\n      }\n      // 注意：如果是间隔任务，不允许修改间隔时间和单位，所以这里不设置这些字段\n    }\n\n    console.log('准备提交的任务数据:', taskData)\n\n    let response\n    if (props.isEditing) {\n      console.log('开始更新任务:', props.taskId)\n      response = await updateSchedulerTask(props.taskId, taskData)\n    } else if (props.parentTaskId) {\n      console.log('开始添加子任务:', props.parentTaskId)\n      response = await addSubTask(props.parentTaskId, {\n        ...taskData,\n        schedule_type: 'chain'\n      })\n    } else {\n      console.log('开始创建主任务')\n      response = await createSchedulerTask({\n        task_id: form.task_id.trim(),\n        task_type: 'main',\n        config: {\n          name: form.name,\n          endpoint: form.endpoint,\n          method: form.method,\n          params: form.params,\n          schedule_type: form.schedule_type,\n          schedule_time: form.schedule_type === 'daily' ? form.schedule_time : undefined,\n          interval: form.schedule_type === 'interval' ? form.interval : undefined,\n          unit: form.schedule_type === 'interval' ? form.unit : undefined,\n          enabled: form.enabled\n        },\n        depends_on: taskData.depends_on\n      })\n    }\n\n    console.log('任务操作响应:', response.data)\n\n    if (response.data && response.data.status === 'success') {\n      const successMessage = props.isEditing ? '更新成功' :\n                             props.parentTaskId ? '子任务创建成功' : '创建成功'\n      console.log('任务操作成功:', successMessage)\n      showNotify({\n        type: 'success',\n        message: successMessage\n      })\n      emit('task-saved')\n      emit('update:show', false)\n      resetForm()\n    } else {\n      const errorMessage = (props.isEditing ? '更新失败: ' :\n                           props.parentTaskId ? '创建子任务失败: ' : '创建失败: ') +\n                           (response.data?.message || '未知错误')\n      console.error('任务操作失败:', errorMessage)\n      showNotify({\n        type: 'danger',\n        message: errorMessage\n      })\n    }\n  } catch (error) {\n    const errorMessage = (props.isEditing ? '更新出错: ' :\n                         props.parentTaskId ? '创建子任务出错: ' : '创建出错: ') +\n                         (error.message || '未知错误')\n    console.error('任务操作异常:', errorMessage, error)\n    showNotify({\n      type: 'danger',\n      message: errorMessage\n    })\n  }\n}\n\n// 添加handleApiSelect函数\nconst handleApiSelect = (endpoint) => {\n  selectedEndpoint.value = endpoint.id\n}\n\n// 在 script setup 中添加计算属性\nconst getDialogTitle = computed(() => {\n  if (props.isEditing) return '编辑任务'\n  if (props.parentTaskId) return '创建子任务'\n  return '创建主任务'\n})\n\nconst getFormTitle = computed(() => {\n  if (props.isEditing) return '编辑任务信息'\n  if (props.parentTaskId) return '创建新子任务'\n  return '创建新主任务'\n})\n\nconst getFormDescription = computed(() => {\n  if (props.isEditing) return '修改任务配置信息'\n  if (props.parentTaskId) return '填写以下信息创建新的子任务'\n  return '填写以下信息创建新的主任务'\n})\n\nconst getSubmitButtonText = computed(() => {\n  if (props.isEditing) return '保存'\n  if (props.parentTaskId) return '创建子任务'\n  return '创建主任务'\n})\n\n// 监听props变化，确保状态同步\nwatch(() => props.parentTaskId, (newVal) => {\n  if (props.show) {\n    // 如果弹窗是打开的，根据parentTaskId的变化更新schedule_type\n    form.schedule_type = newVal ? 'chain' : 'daily'\n  }\n})\n\nwatch(() => props.isEditing, (newVal) => {\n  if (props.show && newVal && props.taskId) {\n    // 如果弹窗是打开的且切换到编辑模式，加载任务详情\n    loadTaskDetail(props.taskId)\n  }\n})\n</script>\n\n<script>\nexport default {\n  name: 'TaskForm'\n}\n</script>\n\n<style scoped>\n.task-form-dialog :deep(.van-dialog) {\n  max-height: 85vh;\n  overflow: hidden;\n  display: flex;\n  flex-direction: column;\n}\n\n.task-form-dialog :deep(.van-dialog__header) {\n  flex-shrink: 0;\n  padding: 12px 16px;\n  font-size: 14px;\n  background-color: #f9fafb; /* gray-50 */\n  border-bottom: 1px solid #f3f4f6; /* gray-100 */\n  color: #111827; /* gray-900 */\n}\n.dark .task-form-dialog :deep(.van-dialog__header) {\n  background-color: #1f2937; /* gray-800 */\n  border-bottom-color: #374151; /* gray-700 */\n  color: #e5e7eb; /* gray-200 */\n}\n\n/* 自定义滚动条样式 */\n.custom-scrollbar {\n  scrollbar-width: thin;\n  scrollbar-color: #fb7299 #f3f4f6;\n}\n\n.custom-scrollbar::-webkit-scrollbar {\n  width: 4px;\n}\n\n.custom-scrollbar::-webkit-scrollbar-track {\n  background: #f3f4f6;\n  border-radius: 2px;\n}\n\n.custom-scrollbar::-webkit-scrollbar-thumb {\n  background-color: #fb7299;\n  border-radius: 2px;\n}\n</style>\n"
  },
  {
    "path": "src/components/tailwind/scheduler/TaskHistory.vue",
    "content": "<template>\n  <van-dialog\n    :show=\"show\"\n    @update:show=\"$emit('update:show', $event)\"\n    title=\"任务执行历史\"\n    width=\"80%\"\n    :show-confirm-button=\"false\"\n    class=\"task-history-dialog\"\n  >\n    <template #title>\n      <div class=\"flex items-center justify-between px-3\">\n        <span>任务执行历史</span>\n        <button\n          @click=\"$emit('update:show', false)\"\n          class=\"p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors\"\n        >\n          <svg class=\"w-4 h-4 text-gray-500 dark:text-gray-400\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\" />\n          </svg>\n        </button>\n      </div>\n    </template>\n    <div class=\"p-3\">\n      <div class=\"flex items-center justify-between mb-3\">\n        <div class=\"flex items-center\">\n          <div class=\"p-1.5 bg-[#fb7299]/10 dark:bg-[#fb7299]/20 rounded-lg mr-2\">\n            <svg class=\"w-4 h-4 text-[#fb7299]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z\" />\n            </svg>\n          </div>\n          <div>\n            <h3 class=\"text-base font-medium text-gray-800 dark:text-gray-100\">{{ taskName }}</h3>\n            <p class=\"text-xs text-gray-500 dark:text-gray-400\">ID: {{ taskId }}</p>\n          </div>\n        </div>\n        <div>\n          <button \n            @click=\"refreshHistory\" \n            class=\"inline-flex items-center px-2 py-1 border border-transparent rounded-md text-xs font-medium text-white bg-[#fb7299] hover:bg-[#fb7299]/90 focus:outline-none\"\n          >\n            <svg class=\"w-3.5 h-3.5 mr-1\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15\" />\n            </svg>\n            刷新\n          </button>\n        </div>\n      </div>\n      \n      <div v-if=\"loading\" class=\"flex justify-center items-center py-8\">\n        <van-loading type=\"spinner\" color=\"#fb7299\" />\n      </div>\n      <div v-else-if=\"!history.length\" class=\"text-center py-8 text-gray-500 dark:text-gray-400\">\n        暂无执行历史\n      </div>\n      <div v-else class=\"space-y-2\">\n        <div v-for=\"record in history\" :key=\"record.execution_id\" class=\"bg-white dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-700\">\n          <div class=\"flex items-center justify-between\">\n            <div class=\"flex items-center space-x-2\">\n              <span \n                :class=\"{\n                  'bg-green-50 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-300 dark:border-green-800/60': record.status === 'success',\n                  'bg-yellow-50 text-yellow-700 border-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-300 dark:border-yellow-800/60': record.status === 'running',\n                  'bg-red-50 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-300 dark:border-red-800/60': record.status === 'error'\n                }\"\n                class=\"px-1.5 py-0.5 text-xs font-medium rounded-md border\"\n              >\n                {{ statusLabel(record.status) }}\n              </span>\n              <span class=\"text-sm text-gray-600 dark:text-gray-300\">{{ record.start_time?.replace('T', ' ') }}</span>\n            </div>\n            <div class=\"text-sm text-gray-500 dark:text-gray-400\">\n              耗时: {{ record.duration?.toFixed(2) || 0 }}秒\n            </div>\n          </div>\n          <div v-if=\"record.error\" class=\"mt-2\">\n            <button \n              @click=\"viewError(record)\"\n              class=\"text-xs text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300\"\n            >\n              查看错误详情\n            </button>\n          </div>\n        </div>\n      </div>\n\n      <!-- 分页器 -->\n      <div v-if=\"total > pageSize\" class=\"mt-4 flex justify-center\">\n        <van-pagination\n          v-model=\"currentPage\"\n          :total-items=\"total\"\n          :items-per-page=\"pageSize\"\n          :show-page-size=\"3\"\n          force-ellipses\n          @change=\"handlePageChange\"\n        />\n      </div>\n    </div>\n\n    <!-- 错误详情弹窗 -->\n    <van-dialog\n      v-model:show=\"showErrorDialog\"\n      title=\"错误详情\"\n      width=\"80%\"\n      :show-confirm-button=\"false\"\n      class=\"task-history-dialog\"\n    >\n      <template #title>\n        <div class=\"flex items-center justify-between px-3\">\n          <span>错误详情</span>\n          <button\n            @click=\"showErrorDialog = false\"\n            class=\"p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors\"\n          >\n            <svg class=\"w-4 h-4 text-gray-500 dark:text-gray-400\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\" />\n            </svg>\n          </button>\n        </div>\n      </template>\n      <div v-if=\"selectedRecord\" class=\"p-3\">\n        <div class=\"bg-red-50 dark:bg-red-900/30 p-2 rounded-md\">\n          <pre class=\"text-xs font-mono text-red-800 dark:text-red-300 whitespace-pre-wrap overflow-x-auto\">{{ selectedRecord.error }}</pre>\n        </div>\n      </div>\n    </van-dialog>\n  </van-dialog>\n</template>\n\n<script setup>\nimport { ref, onMounted, watch } from 'vue'\nimport { showNotify } from 'vant'\nimport 'vant/es/dialog/style'\nimport 'vant/es/notify/style'\nimport 'vant/es/loading/style'\nimport 'vant/es/pagination/style'\nimport 'vant/es/date-picker/style'\nimport { getTaskHistory } from '../../../api/api'\n\nconst props = defineProps({\n  show: {\n    type: Boolean,\n    default: false\n  },\n  taskId: {\n    type: String,\n    default: ''\n  },\n  taskName: {\n    type: String,\n    default: ''\n  }\n})\n\nconst emit = defineEmits(['update:show'])\n\nconst loading = ref(false)\nconst history = ref([])\nconst showErrorDialog = ref(false)\nconst selectedRecord = ref(null)\n\n// 分页相关\nconst currentPage = ref(1)\nconst pageSize = ref(20)\nconst total = ref(0)\n\n// 获取任务历史记录\nconst fetchHistory = async () => {\n  if (!props.taskId) return\n  \n  loading.value = true\n  try {\n    const response = await getTaskHistory({\n      task_id: props.taskId,\n      include_subtasks: false,\n      page: currentPage.value,\n      page_size: pageSize.value\n    })\n    if (response.data && response.data.status === 'success') {\n      history.value = response.data.history || []\n      total.value = response.data.total || 0\n    } else {\n      showNotify({ type: 'danger', message: '获取历史记录失败: ' + (response.data?.message || '未知错误') })\n    }\n  } catch (error) {\n    showNotify({ type: 'danger', message: '获取历史记录出错: ' + (error.message || '未知错误') })\n  } finally {\n    loading.value = false\n  }\n}\n\n// 处理页码变化\nconst handlePageChange = (page) => {\n  currentPage.value = page\n  fetchHistory()\n}\n\n// 刷新历史记录\nconst refreshHistory = () => {\n  fetchHistory()\n}\n\n// 查看错误详情\nconst viewError = (record) => {\n  selectedRecord.value = record\n  showErrorDialog.value = true\n}\n\n// 状态标签\nconst statusLabel = (status) => {\n  switch (status) {\n    case 'success':\n      return '成功'\n    case 'error':\n      return '失败'\n    case 'running':\n      return '执行中'\n    case 'pending':\n      return '等待中'\n    default:\n      return status\n  }\n}\n\n// 监听任务ID变化，自动获取历史记录\nonMounted(() => {\n  if (props.show && props.taskId) {\n    fetchHistory()\n  }\n})\n\n// 监听show变化，自动获取历史记录\nwatch(() => props.show, (newVal) => {\n  if (newVal && props.taskId) {\n    fetchHistory()\n  }\n})\n</script>\n\n<script>\nexport default {\n  name: 'TaskHistory'\n}\n</script>\n\n<style scoped>\n.task-history-dialog :deep(.van-dialog) {\n  max-height: 85vh;\n  overflow: hidden;\n  display: flex;\n  flex-direction: column;\n  margin-top: -10vh !important;\n}\n\n.task-history-dialog :deep(.van-dialog__header) {\n  flex-shrink: 0;\n  padding: 12px 16px;\n  font-size: 14px;\n}\n\n.result-dialog :deep(.van-dialog) {\n  max-height: 75vh;\n  overflow: hidden;\n  display: flex;\n  flex-direction: column;\n  margin-top: -5vh !important;\n}\n\n.result-dialog :deep(.van-dialog__header) {\n  flex-shrink: 0;\n  padding: 10px 14px;\n  font-size: 13px;\n}\n</style> "
  },
  {
    "path": "src/main.js",
    "content": "import { createApp } from 'vue'\nimport './style.css'\n// Vant的库，会在桌面端自动将 mouse 事件转换成对应的 touch 事件，使得组件能够在桌面端使用\nimport '@vant/touch-emulator'\nimport App from './App.vue'\nimport { createMyRouter } from './router/router' // 使用命名导入\nimport Vant from 'vant'\n// 引入Vant组件样式\nimport 'vant/lib/index.css'\n\n// 初始化custom event用于API模块间通信\nwindow.addEventListener('api-baseurl-updated', (event) => {\n  console.log('API BaseURL 已更新:', event.detail?.url)\n})\n\nasync function initTauri() {\n  try {\n    // 尝试导入Tauri core API\n    const { invoke } = await import('@tauri-apps/api/core')\n    if (typeof invoke === 'function') {\n      // 设置全局标识\n      window.__TAURI_INVOKE__ = invoke\n      window.__TAURI__ = true\n      console.log('Tauri API 初始化成功')\n      return true\n    }\n  } catch (error) {\n    return false\n  }\n}\n\n;(async function bootstrap () {\n  // 初始化Tauri API\n  let isTauri = await initTauri()\n\n  // 根据环境创建适当的路由实例\n  const router = createMyRouter(isTauri ? 'hash' : 'history')\n\n  const app = createApp(App)\n  app.use(router)\n  app.use(Vant)\n  app.mount('#app')\n\n  // 在 Tauri 环境中设置所有链接在当前窗口打开\n  if (isTauri) {\n    // 重写默认的链接打开行为\n    document.addEventListener('click', function(e) {\n      // 查找点击事件中是否包含链接元素\n      let target = e.target;\n      while (target && target !== document) {\n        if (target.tagName === 'A' && target.getAttribute('href')) {\n          // 获取链接地址\n          const href = target.getAttribute('href');\n\n          // 如果是外部链接或绝对路径，则在当前窗口打开\n          if (href.startsWith('http') || href.startsWith('//') || href.startsWith('/')) {\n            e.preventDefault();\n            window.location.href = href;\n          }\n          break;\n        }\n        target = target.parentNode;\n      }\n    }, true);\n  }\n})()\n"
  },
  {
    "path": "src/router/router.js",
    "content": "import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'\nimport History from '../components/tailwind/page/History.vue'\nimport Search from '../components/tailwind/page/Search.vue'\nimport AnimatedAnalytics from '../components/tailwind/page/AnimatedAnalytics.vue'\nimport Settings from '../components/tailwind/Settings.vue'\nimport MainLayout from '../components/tailwind/layout/MainLayout.vue'\nimport Images from '../components/tailwind/page/Images.vue'\nimport SchedulerTasks from '../components/tailwind/page/SchedulerTasks.vue'\nimport Downloads from '../components/tailwind/page/Downloads.vue'\nimport MediaManager from '../components/tailwind/page/MediaManager.vue'\nimport Favorites from '../components/tailwind/page/Favorites.vue'\nimport BiliTools from '../components/tailwind/page/BiliTools.vue'\n\nconst routes = [\n  {\n    path: '/',\n    component: MainLayout,\n    children: [\n      {\n        path: '',\n        component: History,\n      },\n      {\n        path: 'page/:pageNumber',\n        component: History,\n      },\n      {\n        path: 'search/:keyword',\n        name: 'Search',\n        component: Search,\n      },\n      {\n        path: 'search/:keyword/page/:pageNumber',\n        name: 'SearchPage',\n        component: Search,\n      },\n      {\n        path: 'analytics',\n        name: 'AnimatedAnalytics',\n        component: AnimatedAnalytics,\n      },\n      {\n        path: 'settings',\n        name: 'Settings',\n        component: Settings,\n      },\n      {\n        path: 'remarks',\n        name: 'Remarks',\n        redirect: '/media?tab=remarks'\n      },\n      {\n        path: 'images',\n        name: 'Images',\n        component: Images\n      },\n      {\n        path: 'scheduler',\n        name: 'SchedulerTasks',\n        component: SchedulerTasks\n      },\n      {\n        path: 'downloads',\n        name: 'Downloads',\n        component: Downloads\n      },\n      {\n        path: 'comments',\n        name: 'Comments',\n        redirect: '/media?tab=comments'\n      },\n      {\n        path: 'media',\n        name: 'MediaManager',\n        component: MediaManager\n      },\n      {\n        path: 'about',\n        name: 'About',\n        redirect: '/settings?tab=about'\n      },\n      {\n        path: 'favorites',\n        name: 'Favorites',\n        component: Favorites\n      },\n      {\n        path: 'video-downloader',\n        name: 'VideoDownloader',\n        redirect: '/bili-tools?tab=video-download'\n      },\n      {\n        path: 'bili-tools',\n        name: 'BiliTools',\n        component: BiliTools\n      }\n    ]\n  }\n]\n\n// 创建路由实例的工厂函数\nexport const createMyRouter = (mode = 'hash') => {\n\n  const history = mode === 'hash' ? createWebHashHistory() : createWebHistory()\n\n  return createRouter({\n    history,\n    routes,\n  })\n}\n"
  },
  {
    "path": "src/store/darkMode.js",
    "content": "import { ref, watch } from 'vue'\n\n// 深色模式状态管理\nconst isDarkMode = ref(false)\n\n// 从localStorage读取深色模式设置\nconst initDarkMode = () => {\n  const savedMode = localStorage.getItem('darkMode')\n  if (savedMode !== null) {\n    isDarkMode.value = savedMode === 'true'\n  } else {\n    // 默认检测系统偏好\n    isDarkMode.value = window.matchMedia('(prefers-color-scheme: dark)').matches\n  }\n  applyDarkMode()\n}\n\n// 应用深色模式\nconst applyDarkMode = () => {\n  if (isDarkMode.value) {\n    document.documentElement.classList.add('dark')\n  } else {\n    document.documentElement.classList.remove('dark')\n  }\n}\n\n// 切换深色模式\nconst toggleDarkMode = () => {\n  isDarkMode.value = !isDarkMode.value\n  localStorage.setItem('darkMode', isDarkMode.value.toString())\n  applyDarkMode()\n}\n\n// 监听深色模式变化\nwatch(isDarkMode, () => {\n  applyDarkMode()\n})\n\nexport const useDarkMode = () => {\n  return {\n    isDarkMode,\n    toggleDarkMode,\n    initDarkMode\n  }\n}"
  },
  {
    "path": "src/store/privacy.js",
    "content": "import { ref } from 'vue'\n\n// 从 localStorage 读取初始状态\nconst isPrivacyMode = ref(localStorage.getItem('privacyMode') === 'true')\n\nexport const usePrivacyStore = () => {\n  const togglePrivacyMode = () => {\n    isPrivacyMode.value = !isPrivacyMode.value\n    // 保存到 localStorage\n    localStorage.setItem('privacyMode', isPrivacyMode.value.toString())\n  }\n  \n  const setPrivacyMode = (value) => {\n    isPrivacyMode.value = value\n    // 保存到 localStorage\n    localStorage.setItem('privacyMode', value.toString())\n  }\n\n  return {\n    isPrivacyMode,\n    togglePrivacyMode,\n    setPrivacyMode\n  }\n} "
  },
  {
    "path": "src/style.css",
    "content": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n/* 深色模式全局样式 */\n@layer base {\n  /* 确保html和body支持深色模式 */\n  html {\n    transition: background-color 0.3s, color 0.3s;\n  }\n  \n  body {\n    @apply bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100;\n    transition: background-color 0.3s, color 0.3s;\n  }\n}\n:root:root {\n  --van-primary-color: #ff6699; /* 修改Vant主颜色 */\n}\n\n\n\n\n\n\n\n/* 计算弹窗管理样式 */\n.van-dialog {\n  position: fixed !important;\n  top: 50% !important;\n  left: 50% !important;\n  transform: translate(-50%, -50%) !important;\n  margin: 0 !important;\n  max-height: 95vh !important;\n  overflow-y: auto !important;\n}\n\n/* 弹窗内容滚动样式 */\n.van-dialog__content {\n  max-height: 85vh;\n  overflow-y: auto;\n}\n"
  },
  {
    "path": "src/utils/imageProxy.js",
    "content": "/**\n * 图片代理服务\n * 使用公共代理服务获取跨域图片\n */\n\n/**\n * 从原始URL获取代理图片URL\n * @param {string} originalUrl 原始图片URL\n * @returns {string} 处理后的URL\n */\nexport const getImageProxyUrl = (originalUrl) => {\n  // 检查是否需要代理\n  if (!originalUrl) return originalUrl;\n  \n  // 数据URL和blob URL不需要代理\n  if (originalUrl.startsWith('data:') || originalUrl.startsWith('blob:')) {\n    return originalUrl;\n  }\n  \n  // 相对URL不需要代理\n  if (!/^https?:\\/\\/|^\\/\\//.test(originalUrl)) {\n    return originalUrl;\n  }\n  \n  // 使用images.weserv.nl (较稳定的公共代理)\n  return `https://images.weserv.nl/?url=${encodeURIComponent(originalUrl)}`;\n};\n\nexport default {\n  getImageProxyUrl\n}; "
  },
  {
    "path": "src/utils/imageUrl.js",
    "content": "// 规范化图片 URL：\n// - data:/blob: 保持不变\n// - // 开头的协议相对地址 -> https:\n// - http/https 绝对地址 -> 保持不变\n// - 以 / 开头或相对路径 -> 拼接当前 API BaseURL\nimport { getCurrentBaseUrl } from '@/api/api'\n\nexport const normalizeImageUrl = (url) => {\n\tif (!url) return ''\n\tif (typeof url !== 'string') return url\n\n\t// 数据或 blob URL\n\tif (url.startsWith('data:') || url.startsWith('blob:')) return url\n\n\t// 协议相对 URL\n\tif (url.startsWith('//')) return `https:${url}`\n\n\t// 已是绝对 URL\n\tif (/^https?:\\/\\//i.test(url)) return url\n\n\t// 其余按相对路径处理，拼接当前 API BaseURL\n\tconst base = getCurrentBaseUrl && getCurrentBaseUrl()\n\tif (!base) return url\n\tconst baseNormalized = String(base).replace(/\\/$/, '')\n\tconst pathNormalized = url.startsWith('/') ? url : `/${url}`\n\treturn `${baseNormalized}${pathNormalized}`\n}\n\n/**\n * 将相对 output 路径转换为可访问的静态 URL（自动拼接 /static/ 前缀，并处理反斜杠）\n * 例如：\"dynamic\\\\341376543\\\\face.jpg\" -> \"<BASE>/static/dynamic/341376543/face.jpg\"\n * 若已是 http(s)/data/blob，则保持不变\n * 若已以 /static/ 开头，则直接规范化为完整 URL\n */\nexport const toStaticUrl = (relativePath) => {\n  if (!relativePath) return ''\n  if (typeof relativePath !== 'string') return relativePath\n\n  // 已是绝对/数据/Blob URL\n  if (relativePath.startsWith('data:') || relativePath.startsWith('blob:') || /^https?:\\/\\//i.test(relativePath)) {\n    return relativePath\n  }\n\n  // 统一分隔符\n  const normalized = relativePath.replace(/\\\\/g, '/').replace(/^\\/+/, '')\n\n  // 已包含 /static/ 前缀\n  const staticPath = normalized.startsWith('static/') || normalized.startsWith('/static/')\n    ? (normalized.startsWith('/') ? normalized : `/${normalized}`)\n    : `/static/${normalized}`\n\n  // 拼接域名\n  return normalizeImageUrl(staticPath)\n}\n\nexport default {\n\tnormalizeImageUrl,\n\ttoStaticUrl,\n}\n\n\n"
  },
  {
    "path": "src/utils/openUrl.js",
    "content": "/**\n * 在系统默认浏览器中打开URL\n * 在Tauri环境中使用shell API，在浏览器环境中使用window.open\n * @param {string} url - 要打开的URL\n */\nexport async function openInBrowser(url) {\n  // 检测Tauri环境\n  const hasTauriInvoke = window && typeof window.__TAURI_INVOKE__ === 'function'\n  const hasTauriGlobal = window && window.__TAURI__\n  const userAgent = navigator.userAgent\n  const isTauriUserAgent = userAgent.includes('Tauri')\n  const isLocalhost = window.location.hostname === 'localhost'\n  const isTauriPort = window.location.port === '1420' // Tauri默认端口\n  const hasFileProtocol = window.location.protocol === 'tauri:'\n\n  // 检测是否应该尝试Tauri API\n  const shouldTryTauri = hasTauriInvoke || hasTauriGlobal || isTauriUserAgent || isTauriPort || hasFileProtocol\n\n  if (shouldTryTauri) {\n    // 方法1: 直接使用__TAURI_INVOKE__\n    if (hasTauriInvoke) {\n      try {\n        await window.__TAURI_INVOKE__('plugin:shell|open', { path: url })\n        return\n      } catch (error) {\n        console.warn('Tauri __TAURI_INVOKE__ 失败:', error.message)\n      }\n    }\n\n    // 方法2: 尝试导入@tauri-apps/api/core\n    try {\n      const { invoke } = await import('@tauri-apps/api/core')\n      if (typeof invoke === 'function') {\n        await invoke('plugin:shell|open', { path: url })\n        return\n      }\n    } catch (error) {\n      console.warn('Tauri core API 失败:', error.message)\n    }\n\n    // 方法3: 尝试导入@tauri-apps/plugin-shell\n    try {\n      const { open } = await import('@tauri-apps/plugin-shell')\n      if (typeof open === 'function') {\n        await open(url)\n        return\n      }\n    } catch (error) {\n      console.warn('Tauri shell plugin 失败:', error.message)\n    }\n  }\n\n  // 回退到window.open\n  window.open(url, '_blank')\n}\n"
  },
  {
    "path": "src/utils/privacyManager.js",
    "content": "/**\n * 隐私模式管理工具\n * 集中管理隐私模式状态，提供全局API\n */\n\n// 隐私模式事件名称\nconst PRIVACY_MODE_EVENT = 'privacy-mode-changed'\n\n/**\n * 检查隐私模式是否启用\n * @returns {boolean} 隐私模式是否启用\n */\nexport const isPrivacyModeEnabled = () => {\n  return localStorage.getItem('privacyMode') === 'true'\n}\n\n/**\n * 启用隐私模式\n */\nexport const enablePrivacyMode = () => {\n  localStorage.setItem('privacyMode', 'true')\n  \n  // 触发隐私模式变更事件\n  dispatchPrivacyModeChanged(true)\n  \n  console.log('隐私模式已启用')\n}\n\n/**\n * 禁用隐私模式\n */\nexport const disablePrivacyMode = () => {\n  localStorage.setItem('privacyMode', 'false')\n  \n  // 触发隐私模式变更事件\n  dispatchPrivacyModeChanged(false)\n  \n  console.log('隐私模式已禁用')\n}\n\n/**\n * 切换隐私模式\n * @returns {boolean} 切换后的状态\n */\nexport const togglePrivacyMode = () => {\n  const currentState = isPrivacyModeEnabled()\n  const newState = !currentState\n  \n  if (newState) {\n    enablePrivacyMode()\n  } else {\n    disablePrivacyMode()\n  }\n  \n  return newState\n}\n\n/**\n * 分发隐私模式变更事件\n * @param {boolean} enabled 隐私模式是否启用\n */\nexport const dispatchPrivacyModeChanged = (enabled) => {\n  window.dispatchEvent(new CustomEvent(PRIVACY_MODE_EVENT, {\n    detail: { enabled }\n  }))\n}\n\n/**\n * 添加隐私模式变更监听器\n * @param {Function} callback 回调函数\n */\nexport const addPrivacyModeListener = (callback) => {\n  window.addEventListener(PRIVACY_MODE_EVENT, (event) => {\n    if (event.detail && typeof event.detail.enabled === 'boolean') {\n      callback(event.detail.enabled)\n    }\n  })\n}\n\nexport default {\n  isEnabled: isPrivacyModeEnabled,\n  enable: enablePrivacyMode,\n  disable: disablePrivacyMode,\n  toggle: togglePrivacyMode,\n  addListener: addPrivacyModeListener\n} "
  },
  {
    "path": "src-tauri/Cargo.toml",
    "content": "[package]\nname = \"bilibili-history-frontend\"\nversion = \"1.0.0\"\ndescription = \"获取Bili历史记录应用\"\nauthors = [\"46\"]\nlicense = \"\"\nrepository = \"\"\nedition = \"2021\"\nrust-version = \"1.77.2\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[lib]\nname = \"app_lib\"\ncrate-type = [\"staticlib\", \"cdylib\", \"rlib\"]\n\n[build-dependencies]\ntauri-build = { version = \"~2.1.0\", features = [] }\n\n[dependencies]\nserde_json = \"1.0\"\nserde = { version = \"1.0\", features = [\"derive\"] }\nlog = \"0.4\"\ntauri = { version = \"~2.4.0\", features = [] }\ntauri-plugin-log = \"2.0.0\"\ntauri-plugin-shell = \"~2.2.1\"\n"
  },
  {
    "path": "src-tauri/build.rs",
    "content": "fn main() {\n  tauri_build::build()\n}\n"
  },
  {
    "path": "src-tauri/capabilities/default.json",
    "content": "{\n  \"$schema\": \"../gen/schemas/desktop-schema.json\",\n  \"identifier\": \"default\",\n  \"description\": \"enables the default permissions\",\n  \"windows\": [\n    \"main\"\n  ],\n  \"permissions\": [\n    \"core:default\",\n    \"shell:allow-open\"\n  ]\n}\n"
  },
  {
    "path": "src-tauri/src/lib.rs",
    "content": "#[cfg_attr(mobile, tauri::mobile_entry_point)]\npub fn run() {\n  tauri::Builder::default()\n    .plugin(tauri_plugin_shell::init())\n    .setup(|app| {\n      if cfg!(debug_assertions) {\n        app.handle().plugin(\n          tauri_plugin_log::Builder::default()\n            .level(log::LevelFilter::Info)\n            .build(),\n        )?;\n      }\n      Ok(())\n    })\n    .run(tauri::generate_context!())\n    .expect(\"error while running tauri application\");\n}\n"
  },
  {
    "path": "src-tauri/src/main.rs",
    "content": "// Prevents additional console window on Windows in release, DO NOT REMOVE!!\n#![cfg_attr(not(debug_assertions), windows_subsystem = \"windows\")]\n\nfn main() {\n  app_lib::run();\n}\n"
  },
  {
    "path": "src-tauri/tauri.conf.json",
    "content": "{\n  \"$schema\": \"../node_modules/@tauri-apps/cli/config.schema.json\",\n  \"productName\": \"BiliBili History Frontend\",\n  \"version\": \"1.0.0\",\n  \"identifier\": \"com.ab46.history\",\n  \"build\": {\n    \"frontendDist\": \"../dist\",\n    \"devUrl\": \"http://localhost:5173\",\n    \"beforeDevCommand\": \"npm run dev\",\n    \"beforeBuildCommand\": \"npm run build\"\n  },\n  \"app\": {\n    \"windows\": [\n      {\n        \"title\": \"BiliBili History Frontend\",\n        \"width\": 1024,\n        \"height\": 768,\n        \"resizable\": true,\n        \"fullscreen\": false,\n        \"center\": true,\n        \"devtools\": true\n      }\n    ],\n    \"security\": {\n      \"csp\": null\n    }\n  },\n  \"plugins\": {\n    \"shell\": {\n      \"open\": true\n    }\n  },\n  \"bundle\": {\n    \"active\": true,\n    \"targets\": [],\n    \"icon\": [\n      \"icons/32x32.png\",\n      \"icons/128x128.png\",\n      \"icons/128x128@2x.png\",\n      \"icons/icon.icns\",\n      \"icons/icon.ico\"\n    ]\n  }\n}\n"
  },
  {
    "path": "src-tauri/tauri.linux.conf.json",
    "content": "{\n  \"bundle\": {\n    \"targets\": [\"deb\", \"appimage\"]\n  }\n}\n\n"
  },
  {
    "path": "src-tauri/tauri.macos.conf.json",
    "content": "{\n  \"bundle\": {\n    \"targets\": [\"dmg\"]\n  }\n}\n\n"
  },
  {
    "path": "src-tauri/tauri.windows.conf.json",
    "content": "{\n  \"bundle\": {\n    \"targets\": [\"nsis\"]\n  }\n}\n\n"
  },
  {
    "path": "tailwind.config.js",
    "content": "import forms from '@tailwindcss/forms'\n\n/** @type {import('tailwindcss').Config} */\nexport default {\n  darkMode: 'class', // 启用 class 模式的深色模式\n  content: [\n    './src/components/tailwind/*.vue',\n    './src/components/tailwind/**/*.vue',\n    './src/App.vue',\n    // 其他文件...\n  ],\n  theme: {\n    extend: {\n      colors: {\n        bg: '#F1F2F3',\n      },\n    },\n    screens: {\n      ssm: '0px',\n      lm: { max: '640px' },\n      ld: { max: '768px' },\n      llg: { max: '1023px' },\n      sm: '640px',\n      smd: '641px',\n      md: '768px',\n      lg: '1024px',\n      xl: '1280px',\n      '2xl': '1536px',\n    },\n  },\n  plugins: [forms],\n}\n"
  },
  {
    "path": "vite.config.js",
    "content": "import { defineConfig } from 'vite'\nimport vue from '@vitejs/plugin-vue'\nimport Inspector from 'vite-plugin-vue-inspector'\nimport path from 'path'\n\n// 获取环境变量\nconst isElectron = process.env.ELECTRON === 'true'\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n  plugins: [\n    Inspector({\n      toggleComboKey: 'alt-x',\n    }),\n    vue(),\n  ],\n  base: isElectron ? './' : '/', // Electron 使用相对路径，Web 使用绝对路径\n  build: {\n    emptyOutDir: true,\n  },\n  server: {\n    host: '0.0.0.0',\n  },\n  resolve: {\n    alias: {\n      '@': path.resolve(__dirname, 'src'),\n    },\n  },\n})\n"
  }
]