[
  {
    "path": ".editorconfig",
    "content": "# EditorConfig is awesome: https://EditorConfig.org\n\n# top-most EditorConfig file\nroot = true\n\n# Unix-style newlines with a newline ending every file\n[*]\nend_of_line = lf\ninsert_final_newline = true\n\n# Matches multiple files with brace expansion notation\n# Set default charset\n[*.{js,py}]\ncharset = utf-8\n\n# 4 space indentation\n[*.py]\nindent_style = space\nindent_size = 4\n\n# 2 space indentation\n[*.{vue,scss,ts}]\nindent_style = space\nindent_size = 2\n\n# Tab indentation (no size specified)\n[Makefile]\nindent_style = tab\n\n# Indentation override for all JS under lib directory\n[lib/**.js]\nindent_style = space\nindent_size = 2\n\n# Matches the exact files either package.json or .travis.yml\n[{package.json,.travis.yml}]\nindent_style = space\nindent_size = 2\n"
  },
  {
    "path": ".eslintrc.js",
    "content": "module.exports = {\n  env: {\n    browser: true,\n    es2021: true,\n  },\n  extends: ['@antfu/eslint-config-vue', 'plugin:sonarjs/recommended'],\n  ignorePatterns: ['src/@iconify/*.js', 'node_modules', 'dist', '*.d.ts'],\n  plugins: ['regex'],\n  rules: {\n    'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',\n    'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',\n\n    'sonarjs/no-duplicate-string': 'warn',\n\n    'vue/valid-v-slot': ['error', {\n      allowModifiers: true,\n    }],\n\n    // https://github.com/gmullerb/eslint-plugin-regex\n    'regex/invalid': [\n      'error',\n      [\n        {\n          regex: '@/assets/images',\n          replacement: '@images',\n          message: 'Use \\'@images\\' path alias for image imports',\n        },\n        {\n          regex: '@/styles',\n          replacement: '@styles',\n          message: 'Use \\'@styles\\' path alias for importing styles from \\'src/styles\\'',\n        },\n\n        // {\n        //   id: 'Disallow icon of icon library',\n        //   regex: 'tabler-\\\\w',\n        //   message: 'Only \\'mdi\\' icons are allowed',\n        // },\n\n        {\n          regex: '@core/\\\\w',\n          message: 'You can\\'t use @core when you are in @layouts module',\n          files: {\n            inspect: '@layouts/.*',\n          },\n        },\n        {\n          regex: 'useLayouts\\\\(',\n          message:\n            '`useLayouts` composable is only allowed in @layouts & @core directory. Please use `useThemeConfig` composable instead.',\n          files: {\n            inspect: '^(?!.*(@core|@layouts)).*',\n          },\n        },\n      ],\n\n      // Ignore files\n      '.eslintrc.js',\n    ],\n  },\n  settings: {\n    'import/resolver': {\n      node: {\n        extensions: ['.ts', '.js', '.tsx', '.jsx', '.mjs', '.png', '.jpg'],\n      },\n      typescript: {},\n    },\n  },\n}\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: 问题反馈\ndescription: File a bug report\ntitle: \"[错误报告]：请在此处简单描述你的问题\"\nlabels: [\"bug\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        请确认以下信息：\n        1. 请按此模板提交issues，不按模板提交的问题将直接关闭。\n        2. 如果你的问题可以直接在以往 issue 或者 Telegram频道 中找到，那么你的 issue 将会被直接关闭。\n        3. 提交问题务必描述清楚、附上日志，描述不清导致无法理解和分析的问题会被直接关闭。\n        4. 此仓库为前端仓库，如果是后端问题请在[后端仓库](https://github.com/jxxghp/MoviePilot)提 issue。\n  - type: checkboxes\n    id: ensure\n    attributes:\n      label: 确认\n      description: 在提交 issue 之前，请确认你已经阅读并确认以下内容\n      options:\n        - label: 我的版本是最新版本，我的版本号与 [version](https://github.com/jxxghp/MoviePilot-Frontend/releases/latest) 相同。\n          required: true\n        - label: 我已经 [issue](https://github.com/jxxghp/MoviePilot-Frontend/issues) 中搜索过，确认我的问题没有被提出过。\n          required: true\n        - label: 我已经 [Telegram频道](https://t.me/moviepilot_channel) 中搜索过，确认我的问题没有被提出过。\n          required: true\n        - label: 我已经修改标题，将标题中的 描述 替换为我遇到的问题。\n          required: true\n  - type: input\n    id: version\n    attributes:\n      label: 当前程序版本\n      description: 遇到问题时程序所在的版本号\n    validations:\n      required: true\n  - type: textarea\n    id: what-happened\n    attributes:\n      label: 问题描述\n      description: 请详细描述你碰到的问题\n      placeholder: \"问题描述\"\n    validations:\n      required: true\n  - type: textarea\n    id: logs\n    attributes:\n      label: 发生问题时系统日志和配置文件\n      description: 问题出现时，程序运行日志请复制到这里。\n      render: bash\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n   - name: Telegram 频道\n     url: https://t.me/moviepilot_channel\n     about: 更新日志\n   - name: Telegram 交流群\n     url: https://t.me/moviepilot_official\n     about: 交流互助\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/discussion.yml",
    "content": "name: 项目讨论\ndescription: discussion\ntitle: \"[Discussion]: \"\nlabels: [\"discussion\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        [BUG](https://github.com/jxxghp/MoviePilot-Frontend/issues/new?assignees=&labels=bug&template=bug_report.yml&title=%5BBUG%5D%3A) 与 [Feature Request](https://github.com/jxxghp/MoviePilot-Frontend/issues/new?assignees=&labels=feature+request&template=feature_request.yml&title=%5BFeature+Request%5D%3A+) 请转到对应位置提交。\n  - type: textarea\n    id: discussion\n    attributes:\n      label: 项目讨论\n      description: 请详细描述需要讨论的内容。\n      placeholder: \"项目讨论\"\n    validations:\n      required: true\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "content": "name: 功能改进\ndescription: Feature Request\ntitle: \"[Feature Request]: \"\nlabels: [\"feature request\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        请说明你希望添加的功能。\n  - type: input\n    id: version\n    attributes:\n      label: 当前程序版本\n      description: 目前使用的程序版本\n    validations:\n      required: true\n  - type: textarea\n    id: feature-request\n    attributes:\n      label: 功能改进\n      description: 请详细描述需要改进或者添加的功能。\n      placeholder: \"功能改进\"\n    validations:\n      required: true\n  - type: textarea\n    id: references\n    attributes:\n      label: 参考资料\n      description: 可以列举一些参考资料，但是不要引用同类但商业化软件的任何内容。\n      placeholder: \"参考资料\"\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/rfc.yml",
    "content": "name: 功能提案\ndescription: Request for Comments\ntitle: '[RFC]'\nlabels: ['RFC']\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        一份提案(RFC)定位为 **「在某功能/重构的具体开发前，用于开发者间 review 技术设计/方案的文档」**，\n        目的是让协作的开发者间清晰的知道「要做什么」和「具体会怎么做」，以及所有的开发者都能公开透明的参与讨论；\n        以便评估和讨论产生的影响 (遗漏的考虑、向后兼容性、与现有功能的冲突)，\n        因此提案侧重在对解决问题的 **方案、设计、步骤** 的描述上。\n          \n        如果仅希望讨论是否添加或改进某功能本身，请使用 -> [Issue: 功能改进](https://github.com/jxxghp/MoviePilot/issues/new?assignees=&labels=feature+request&projects=&template=feature_request.yml&title=%5BFeature+Request%5D%3A+)\n  - type: textarea\n    id: background\n    attributes:\n      label: 背景 or 问题\n      description: 简单描述遇到的什么问题或需要改动什么。可以引用其他 issue、讨论、文档等。\n    validations:\n      required: true\n  - type: textarea\n    id: goal\n    attributes:\n      label: '目标 & 方案简述'\n      description: 简单描述提案此提案实现后，**预期的目标效果**，以及简单大致描述会采取的方案/步骤，可能会/不会产生什么影响。\n    validations:\n      required: true\n  - type: textarea\n    id: design\n    attributes:\n      label: '方案设计 & 实现步骤'\n      description: |\n        详细描述你设计的具体方案，可以考虑拆分列表或要点，一步步描述具体打算如何实现的步骤和相关细节。\n        这部份不需要一次性写完整，即使在创建完此提案 issue 后，依旧可以再次编辑修改。\n    validations:\n      required: false\n  - type: textarea\n    id: alternative\n    attributes:\n      label: '替代方案 & 对比'\n      description: |\n        [可选] 为来实现目标效果，还考虑过什么其他方案，有什么对比？\n    validations:\n      required: false\n"
  },
  {
    "path": ".github/workflows/build.yml",
    "content": "name: Build Moviepilot-Frontend v2\n\non:\n  workflow_dispatch:\n  push:\n    branches:\n      - v2\n    paths:\n      - 'package.json'\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v3\n\n      - name: Release version\n        id: release_version\n        run: |\n          frontend_version=$(jq -r '.version' package.json)\n          echo \"frontend_version=v$frontend_version\" >> $GITHUB_ENV\n\n      - name: Setup node\n        uses: actions/setup-node@v3\n        with:\n          node-version: '20'\n          cache: 'yarn'\n\n      - name: Download Icons\n        run: |\n          pwd\n          curl -sL \"https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip\" | busybox unzip -d /tmp -\n          mv /tmp/MoviePilot-Plugins-main/icons public/plugin_icon\n          rm -rf /tmp/MoviePilot-Plugins-main\n\n      - name: Build frontend\n        id: build_frontend\n        run: |\n          yarn\n          yarn build\n          echo \"$frontend_version\" > dist/version.txt\n          zip -r dist.zip dist\n\n      - name: Delete Release\n        uses: dev-drprasad/delete-tag-and-release@v1.1\n        continue-on-error: true\n        with:\n          tag_name: ${{ env.frontend_version }}\n          delete_release: true\n          github_token: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Generate Release\n        uses: softprops/action-gh-release@v2\n        with:\n          tag_name: ${{ env.frontend_version }}\n          name: ${{ env.frontend_version }}\n          draft: false\n          prerelease: false\n          make_latest: true\n          files: |\n            dist.zip\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\n.DS_Store\ndist\ndist-ssr\ndev-dist\n*.local\npackage-lock.json\n\n/cypress/videos/\n/cypress/screenshots/\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n!.vscode/settings.json\n!.vscode/*.code-snippets\n!.vscode/tours\n.idea\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n.yarn\n\n# iconify dist files\nsrc/@iconify/*.js\npublic/plugin_icon/**\n"
  },
  {
    "path": ".prettierignore",
    "content": "dist\nnode_modules"
  },
  {
    "path": ".prettierrc.json",
    "content": "{\n  \"arrowParens\": \"avoid\",\n  \"bracketSpacing\": true,\n  \"htmlWhitespaceSensitivity\": \"css\",\n  \"insertPragma\": false,\n  \"jsxBracketSameLine\": false,\n  \"jsxSingleQuote\": true,\n  \"printWidth\": 120,\n  \"proseWrap\": \"preserve\",\n  \"quoteProps\": \"preserve\",\n  \"requirePragma\": false,\n  \"semi\": false,\n  \"singleQuote\": true,\n  \"tabWidth\": 2,\n  \"trailingComma\": \"all\",\n  \"useTabs\": false,\n  \"vueIndentScriptAndStyle\": false,\n  \"endOfLine\": \"lf\",\n  \"singleAttributePerLine\": false\n}\n"
  },
  {
    "path": ".stylelintrc.json",
    "content": "{\n    \"extends\": [\n        \"stylelint-config-standard-scss\",\n        \"stylelint-config-idiomatic-order\"\n    ],\n    \"plugins\": [\n        \"stylelint-use-logical-spec\"\n    ],\n    \"overrides\": [\n        {\n            \"files\": [\n                \"**/*.scss\"\n            ],\n            \"customSyntax\": \"postcss-scss\"\n        },\n        {\n            \"files\": [\n                \"**/*.vue\"\n            ],\n            \"customSyntax\": \"postcss-html\"\n        }\n    ],\n    \"rules\": {\n        \"liberty/use-logical-spec\": true,\n        \"selector-class-pattern\": null,\n        \"color-function-notation\": null\n    },\n    \"fix\": true\n}"
  },
  {
    "path": ".vscode/anchor-comments.code-snippets",
    "content": "{\n    \"Add hand emoji\": {\n        \"prefix\": \"cm-hand-emoji\",\n        \"body\": [\n            \"👉\"\n        ],\n        \"description\": \"Add hand emoji\"\n    },\n    \"Add info emoji\": {\n        \"prefix\": \"cm-info-emoji\",\n        \"body\": [\n            \"ℹ️\"\n        ],\n        \"description\": \"Add info emoji\"\n    },\n    \"Add warning emoji\": {\n        \"prefix\": \"cm-warning-emoji\",\n        \"body\": [\n            \"❗\"\n        ],\n        \"description\": \"Add warning emoji\"\n    }\n}\n"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\n    \"dbaeumer.vscode-eslint\",\n    \"esbenp.prettier-vscode\",\n    \"mgmcdermott.vscode-language-babel\",\n    \"editorconfig.editorconfig\",\n    \"xabikos.javascriptsnippets\",\n    \"stylelint.vscode-stylelint\",\n    \"fabiospampinato.vscode-highlight\",\n    \"github.vscode-pull-request-github\",\n    \"vue.volar\",\n    \"antfu.iconify\",\n    \"cipchk.cssrem\",\n    \"matijao.vue-nuxt-snippets\"\n  ]\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"editor.formatOnSave\": true,\n  \"javascript.updateImportsOnFileMove.enabled\": \"always\",\n  \"editor.defaultFormatter\": \"esbenp.prettier-vscode\",\n  \"files.eol\": \"\\n\",\n  \"[javascript]\": {\n    \"editor.formatOnSave\": false\n  },\n  \"[markdown]\": {\n    \"editor.defaultFormatter\": \"DavidAnson.vscode-markdownlint\"\n  },\n  // SCSS\n  \"[scss]\": {\n    \"editor.defaultFormatter\": \"stylelint.vscode-stylelint\",\n    \"editor.formatOnSave\": false\n  },\n  // JSON\n  \"[json]\": {\n    \"editor.defaultFormatter\": \"vscode.json-language-features\"\n  },\n  \"[jsonc]\": {\n    \"editor.defaultFormatter\": \"vscode.json-language-features\"\n  },\n  // Vue\n  \"[vue]\": {\n    \"editor.formatOnSave\": true\n  },\n  // Extension: Volar\n  \"volar.preview.port\": 3000,\n  \"volar.completion.preferredTagNameCase\": \"pascal\",\n  \"editor.codeActionsOnSave\": {\n    \"source.fixAll.eslint\": \"explicit\",\n    \"source.fixAll.stylelint\": \"explicit\"\n  },\n  \"[typescript]\": {\n    \"editor.defaultFormatter\": \"esbenp.prettier-vscode\",\n    \"editor.formatOnSave\": true\n  },\n  \"eslint.alwaysShowStatus\": true,\n  \"eslint.format.enable\": true,\n  // Extension: Stylelint\n  \"stylelint.packageManager\": \"yarn\",\n  \"stylelint.validate\": [\n    \"css\",\n    \"scss\",\n    \"vue\"\n  ],\n  // Extension: Spell Checker\n  \"cSpell.words\": [\n    \"Composables\",\n    \"Customizer\",\n    \"flagpack\",\n    \"Iconify\",\n    \"psudo\",\n    \"stylelint\",\n    \"touchless\",\n    \"triggerer\",\n    \"unref\",\n    \"vuetify\"\n  ],\n  // Extension: Comment Anchors\n  \"commentAnchors.tags.list\": [\n    {\n      \"tag\": \"ℹ️\",\n      \"scope\": \"hidden\",\n      // This color is taken from \"Better Comments\" Extension (?)\n      \"highlightColor\": \"#3498DB\",\n      \"styleComment\": true,\n      \"isItalic\": false\n    },\n    {\n      \"tag\": \"👉\",\n      \"scope\": \"file\",\n      // This color is taken from \"Better Comments\" Extension (*)\n      \"highlightColor\": \"#98C379\",\n      \"styleComment\": true,\n      \"isItalic\": false\n    },\n    {\n      \"tag\": \"❗\",\n      \"scope\": \"hidden\",\n      // This color is taken from \"Better Comments\" Extension (*)\n      \"highlightColor\": \"#FF2D00\",\n      \"styleComment\": true,\n      \"isItalic\": false\n    }\n  ],\n  // Extension: fabiospampinato.vscode-highlight\n  \"highlight.regexFlags\": \"gi\",\n  \"highlight.regexes\": {\n    // We flaged this for enforcing logical CSS properties\n    \"(100vh|translate|margin:|padding:|margin-left|margin-right|rotate|text-align|border-top|border-right|border-bottom|border-left|float|background-position|transform|width|height|top|left|bottom|right|float|clear|(p|m)(l|r)-|border-(start|end)-(start|end)-radius)\": [\n      {\n        // \"rangeBehavior\": 1,\n        \"borderWidth\": \"1px\",\n        \"borderColor\": \"tomato\",\n        \"borderStyle\": \"solid\"\n      }\n    ],\n    \"(overflow-x:|overflow-y:)\": [\n      {\n        // \"rangeBehavior\": 1,\n        \"borderWidth\": \"1px\",\n        \"borderColor\": \"green\",\n        \"borderStyle\": \"solid\"\n      }\n    ]\n  },\n  \"vue3snippets.enable-compile-vue-file-on-did-save-code\": false,\n  \"i18n-ally.localesPaths\": [\n    \"src/locales\"\n  ]\n}\n"
  },
  {
    "path": ".vscode/vue-ts.code-snippets",
    "content": "{\n    \"Vue TS - DefineProps\": {\n        \"prefix\": \"dprops\",\n        \"body\": [\n            \"defineProps<${1:Props}>()\"\n        ],\n        \"description\": \"DefineProps in script setup\"\n    },\n    \"Vue TS - Props interface\": {\n        \"prefix\": \"iprops\",\n        \"body\": [\n            \"interface Props {\",\n            \"  ${1}\",\n            \"}\"\n        ],\n        \"description\": \"Create props interface in script setup\"\n    }\n}\n"
  },
  {
    "path": ".vscode/vue.code-snippets",
    "content": "{\n    \"script\": {\n        \"prefix\": \"vue-sfc-ts\",\n        \"body\": [\n            \"<script lang=\\\"ts\\\" setup>\",\n            \"\",\n            \"</script>\",\n            \"\",\n            \"<template>\",\n            \"  \",\n            \"</template>\",\n            \"\",\n            \"<style lang=\\\"scss\\\">\",\n            \"\",\n            \"</style>\",\n            \"\"\n        ],\n        \"description\": \"Vue SFC Typescript\"\n    },\n    \"template\": {\n        \"scope\": \"vue\",\n        \"prefix\": \"template\",\n        \"body\": [\n            \"<template>\",\n            \"  $1\",\n            \"</template>\"\n        ],\n        \"description\": \"Create <template> block\"\n    },\n    \"Script setup + TS\": {\n        \"prefix\": \"script-setup-ts\",\n        \"body\": [\n            \"<script setup lang=\\\"ts\\\">\",\n            \"${1}\",\n            \"</script>\"\n        ],\n        \"description\": \"Script setup + TS\"\n    },\n    \"style\": {\n        \"scope\": \"vue\",\n        \"prefix\": \"style\",\n        \"body\": [\n            \"<style lang=\\\"scss\\\">\",\n            \"$1\",\n            \"</style>\"\n        ],\n        \"description\": \"Create <style> block\"\n    },\n    \"use composable\": {\n        \"prefix\": \"use-composable\",\n        \"body\": [\n            \"const { $2 } = ${1:useComposable}()\"\n        ],\n        \"description\": \"We frequently uses composable in our components and writing const {} = useModule() is tedious. This snippet helps you to write it quickly.\"\n    },\n    \"template interpolation\": {\n        \"prefix\": \"cc\",\n        \"body\": [\n            \"{{ ${1} }}\"\n        ],\n        \"description\": \"We are just making writing template interpolation easier.\"\n    }\n}"
  },
  {
    "path": ".vscode/vuetify.code-snippets",
    "content": "{\n  \"Vuetify Menu -- Parent Activator\": {\n    \"prefix\": \"v-menu\",\n    \"body\": [\n      \"<v-btn color=\\\"primary\\\">\",\n      \"  Activator\",\n      \"  <v-menu activator=\\\"parent\\\">\",\n      \"    <v-list>\",\n      \"      <v-list-item\",\n      \"        v-for=\\\"(item, index) in ['apple', 'banana', 'cherry']\\\"\",\n      \"        :key=\\\"index\\\"\",\n      \"        :value=\\\"index\\\"\",\n      \"      >\",\n      \"        <v-list-item-title>{{ item }}</v-list-item-title>\",\n      \"      </v-list-item>\",\n      \"    </v-list>\",\n      \"  </v-menu>\",\n      \"</v-btn>\"\n    ],\n    \"description\": \"We use menu component with parent activator mostly because it is compact and easy to understand.\"\n  },\n  \"Vuetify CSS variable\": {\n    \"prefix\": \"v-css-var\",\n    \"body\": [\n      \"rgb(var(--v-${1:theme}))\"\n    ],\n    \"description\": \"Vuetify CSS variable\"\n  },\n  \"Icon only button\": {\n    \"prefix\": \"IconBtn\",\n    \"body\": [\n      \"<IconBtn>\",\n      \"  <VIcon icon=\\\"mdi-${1}\\\" />\",\n      \"</IconBtn>\"\n    ],\n    \"description\": \"Icon only button\"\n  },\n  \"Radio Group\": {\n    \"prefix\": \"v-radio-grp\",\n    \"body\": [\n      \"<v-radio-group v-model=\\\"${1:modelValue}\\\">\",\n      \"  <v-radio\",\n      \"    v-for=\\\"item in ['apple', 'banana', 'cherry']\\\"\",\n      \"    :key=\\\"item\\\"\",\n      \"    :label=\\\"item\\\"\",\n      \"    :value=\\\"item\\\"\",\n      \"  />\",\n      \"</v-radio-group>\"\n    ],\n    \"description\": \"Radio Group\"\n  }\n}"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2023 jxxghp\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": "# MoviePilot-Frontend\n\n*中文 | [English](README_EN.md)*\n\n[MoviePilot](https://github.com/jxxghp/MoviePilot) 的前端项目，NodeJS版本：>= `v20.12.1`。\n\n## 特性\n\n- 基于 Vue 3 和 Vuetify 3 构建的现代化界面\n- 使用 Vite 作为构建工具，提供快速的开发体验\n- 支持多语言（中文/英文）\n- 完整的插件系统支持，包括远程组件动态加载\n\n## 模块联邦功能\n\nMoviePilot 现已支持模块联邦（Module Federation）功能，允许插件开发者创建可动态加载的远程组件，实现更丰富的插件用户界面。\n\n### 相关文档\n\n- [模块联邦开发指南](docs/module-federation-guide.md) - 如何开发远程组件插件\n- [模块联邦问题排查指南](docs/federation-troubleshooting.md) - 常见问题和解决方案\n- [插件远程组件示例](examples/plugin-component/) - 开发插件组件的完整示例项目 \n\n## 开发部署\n\n### 推荐的IDE设置\n\n[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) (并禁用 Vetur).\n\n### 配置Vite\n\n请参阅 [Vite 配置参考](https://vitejs.dev/config/).\n\n### 依赖安装\n\n```sh\nyarn\n```\n\n### 开发运行\n\n```sh\nyarn dev\n```\n\n### 编译打包\n\n```sh\nyarn build\n```\n\n### 静态运行\n\n1. 使用 `nginx` 等Web服务器托管 `dist` 静态文件，nginx配置参考 `public/nginx.conf`。\n\n2. 使用 `node` 命令直接运行`service.js`，默认监听 `3000` 端口，设置环境变量 `NGINX_PORT` 来调整运行端口。\n\n```shell\nnode dist/service.js\n```\n"
  },
  {
    "path": "README_EN.md",
    "content": "# MoviePilot-Frontend\n\n*[中文](README.md) | English*\n\nFrontend project for [MoviePilot](https://github.com/jxxghp/MoviePilot), NodeJS version required: >= `v20.12.1`.\n\n## Features\n\n- Modern interface built with Vue 3 and Vuetify 3\n- Fast development experience with Vite build tool\n- Multi-language support (Chinese/English)\n- Complete plugin system with dynamic remote component loading\n\n## Module Federation\n\nMoviePilot now supports Module Federation, allowing plugin developers to create dynamically loadable remote components for richer plugin user interfaces.\n\n### Documentation\n\n- [Module Federation Troubleshooting Guide](docs/federation-troubleshooting.md) - Common issues and solutions\n- [Plugin Remote Component Example](examples/plugin-component/) - Complete example project for developing plugin components\n\n## Development\n\n### Recommended IDE Setup\n\n[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) (disable Vetur).\n\n### Configure Vite \n\nSee [Vite Configuration Reference](https://vitejs.dev/config/).\n\n### Install Dependencies\n\n```sh\nyarn\n```\n\n### Development Server\n\n```sh\nyarn dev\n```\n\n### Build for Production\n\n```sh\nyarn build\n```\n\n### Static Deployment\n\n1. Host the `dist` static files using a web server like `nginx`. Refer to `public/nginx.conf` for nginx configuration.\n\n2. Alternatively, run the `service.js` directly with the `node` command. It listens on port `3000` by default. Set the `NGINX_PORT` environment variable to adjust the port.\n\n```shell\nnode dist/service.js\n``` \n"
  },
  {
    "path": "auto-imports.d.ts",
    "content": "/* eslint-disable */\n/* prettier-ignore */\n// @ts-nocheck\n// noinspection JSUnusedGlobalSymbols\n// Generated by unplugin-auto-import\n// biome-ignore lint: disable\nexport {}\ndeclare global {\n  const EffectScope: typeof import('vue')['EffectScope']\n  const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']\n  const asyncComputed: typeof import('@vueuse/core')['asyncComputed']\n  const autoResetRef: typeof import('@vueuse/core')['autoResetRef']\n  const computed: typeof import('vue')['computed']\n  const computedAsync: typeof import('@vueuse/core')['computedAsync']\n  const computedEager: typeof import('@vueuse/core')['computedEager']\n  const computedInject: typeof import('@vueuse/core')['computedInject']\n  const computedWithControl: typeof import('@vueuse/core')['computedWithControl']\n  const controlledComputed: typeof import('@vueuse/core')['controlledComputed']\n  const controlledRef: typeof import('@vueuse/core')['controlledRef']\n  const createApp: typeof import('vue')['createApp']\n  const createEventHook: typeof import('@vueuse/core')['createEventHook']\n  const createGenericProjection: typeof import('@vueuse/math')['createGenericProjection']\n  const createGlobalState: typeof import('@vueuse/core')['createGlobalState']\n  const createInjectionState: typeof import('@vueuse/core')['createInjectionState']\n  const createPinia: typeof import('pinia')['createPinia']\n  const createProjection: typeof import('@vueuse/math')['createProjection']\n  const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']\n  const createRef: typeof import('@vueuse/core')['createRef']\n  const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']\n  const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']\n  const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']\n  const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']\n  const customRef: typeof import('vue')['customRef']\n  const debouncedRef: typeof import('@vueuse/core')['debouncedRef']\n  const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']\n  const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']\n  const defineComponent: typeof import('vue')['defineComponent']\n  const defineStore: typeof import('pinia')['defineStore']\n  const eagerComputed: typeof import('@vueuse/core')['eagerComputed']\n  const effectScope: typeof import('vue')['effectScope']\n  const extendRef: typeof import('@vueuse/core')['extendRef']\n  const getActivePinia: typeof import('pinia')['getActivePinia']\n  const getCurrentInstance: typeof import('vue')['getCurrentInstance']\n  const getCurrentScope: typeof import('vue')['getCurrentScope']\n  const h: typeof import('vue')['h']\n  const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']\n  const inject: typeof import('vue')['inject']\n  const injectLocal: typeof import('@vueuse/core')['injectLocal']\n  const isDefined: typeof import('@vueuse/core')['isDefined']\n  const isProxy: typeof import('vue')['isProxy']\n  const isReactive: typeof import('vue')['isReactive']\n  const isReadonly: typeof import('vue')['isReadonly']\n  const isRef: typeof import('vue')['isRef']\n  const logicAnd: typeof import('@vueuse/math')['logicAnd']\n  const logicNot: typeof import('@vueuse/math')['logicNot']\n  const logicOr: typeof import('@vueuse/math')['logicOr']\n  const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']\n  const mapActions: typeof import('pinia')['mapActions']\n  const mapGetters: typeof import('pinia')['mapGetters']\n  const mapState: typeof import('pinia')['mapState']\n  const mapStores: typeof import('pinia')['mapStores']\n  const mapWritableState: typeof import('pinia')['mapWritableState']\n  const markRaw: typeof import('vue')['markRaw']\n  const nextTick: typeof import('vue')['nextTick']\n  const onActivated: typeof import('vue')['onActivated']\n  const onBeforeMount: typeof import('vue')['onBeforeMount']\n  const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']\n  const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']\n  const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']\n  const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']\n  const onClickOutside: typeof import('@vueuse/core')['onClickOutside']\n  const onDeactivated: typeof import('vue')['onDeactivated']\n  const onElementRemoval: typeof import('@vueuse/core')['onElementRemoval']\n  const onErrorCaptured: typeof import('vue')['onErrorCaptured']\n  const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']\n  const onLongPress: typeof import('@vueuse/core')['onLongPress']\n  const onMounted: typeof import('vue')['onMounted']\n  const onRenderTracked: typeof import('vue')['onRenderTracked']\n  const onRenderTriggered: typeof import('vue')['onRenderTriggered']\n  const onScopeDispose: typeof import('vue')['onScopeDispose']\n  const onServerPrefetch: typeof import('vue')['onServerPrefetch']\n  const onStartTyping: typeof import('@vueuse/core')['onStartTyping']\n  const onUnmounted: typeof import('vue')['onUnmounted']\n  const onUpdated: typeof import('vue')['onUpdated']\n  const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']\n  const pausableWatch: typeof import('@vueuse/core')['pausableWatch']\n  const provide: typeof import('vue')['provide']\n  const provideLocal: typeof import('@vueuse/core')['provideLocal']\n  const reactify: typeof import('@vueuse/core')['reactify']\n  const reactifyObject: typeof import('@vueuse/core')['reactifyObject']\n  const reactive: typeof import('vue')['reactive']\n  const reactiveComputed: typeof import('@vueuse/core')['reactiveComputed']\n  const reactiveOmit: typeof import('@vueuse/core')['reactiveOmit']\n  const reactivePick: typeof import('@vueuse/core')['reactivePick']\n  const readonly: typeof import('vue')['readonly']\n  const ref: typeof import('vue')['ref']\n  const refAutoReset: typeof import('@vueuse/core')['refAutoReset']\n  const refDebounced: typeof import('@vueuse/core')['refDebounced']\n  const refDefault: typeof import('@vueuse/core')['refDefault']\n  const refThrottled: typeof import('@vueuse/core')['refThrottled']\n  const refWithControl: typeof import('@vueuse/core')['refWithControl']\n  const resolveComponent: typeof import('vue')['resolveComponent']\n  const resolveRef: typeof import('@vueuse/core')['resolveRef']\n  const resolveUnref: typeof import('@vueuse/core')['resolveUnref']\n  const setActivePinia: typeof import('pinia')['setActivePinia']\n  const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']\n  const shallowReactive: typeof import('vue')['shallowReactive']\n  const shallowReadonly: typeof import('vue')['shallowReadonly']\n  const shallowRef: typeof import('vue')['shallowRef']\n  const storeToRefs: typeof import('pinia')['storeToRefs']\n  const syncRef: typeof import('@vueuse/core')['syncRef']\n  const syncRefs: typeof import('@vueuse/core')['syncRefs']\n  const templateRef: typeof import('@vueuse/core')['templateRef']\n  const throttledRef: typeof import('@vueuse/core')['throttledRef']\n  const throttledWatch: typeof import('@vueuse/core')['throttledWatch']\n  const toRaw: typeof import('vue')['toRaw']\n  const toReactive: typeof import('@vueuse/core')['toReactive']\n  const toRef: typeof import('vue')['toRef']\n  const toRefs: typeof import('vue')['toRefs']\n  const toValue: typeof import('vue')['toValue']\n  const triggerRef: typeof import('vue')['triggerRef']\n  const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount']\n  const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount']\n  const tryOnMounted: typeof import('@vueuse/core')['tryOnMounted']\n  const tryOnScopeDispose: typeof import('@vueuse/core')['tryOnScopeDispose']\n  const tryOnUnmounted: typeof import('@vueuse/core')['tryOnUnmounted']\n  const unref: typeof import('vue')['unref']\n  const unrefElement: typeof import('@vueuse/core')['unrefElement']\n  const until: typeof import('@vueuse/core')['until']\n  const useAbs: typeof import('@vueuse/math')['useAbs']\n  const useActiveElement: typeof import('@vueuse/core')['useActiveElement']\n  const useAnimate: typeof import('@vueuse/core')['useAnimate']\n  const useArrayDifference: typeof import('@vueuse/core')['useArrayDifference']\n  const useArrayEvery: typeof import('@vueuse/core')['useArrayEvery']\n  const useArrayFilter: typeof import('@vueuse/core')['useArrayFilter']\n  const useArrayFind: typeof import('@vueuse/core')['useArrayFind']\n  const useArrayFindIndex: typeof import('@vueuse/core')['useArrayFindIndex']\n  const useArrayFindLast: typeof import('@vueuse/core')['useArrayFindLast']\n  const useArrayIncludes: typeof import('@vueuse/core')['useArrayIncludes']\n  const useArrayJoin: typeof import('@vueuse/core')['useArrayJoin']\n  const useArrayMap: typeof import('@vueuse/core')['useArrayMap']\n  const useArrayReduce: typeof import('@vueuse/core')['useArrayReduce']\n  const useArraySome: typeof import('@vueuse/core')['useArraySome']\n  const useArrayUnique: typeof import('@vueuse/core')['useArrayUnique']\n  const useAsyncQueue: typeof import('@vueuse/core')['useAsyncQueue']\n  const useAsyncState: typeof import('@vueuse/core')['useAsyncState']\n  const useAttrs: typeof import('vue')['useAttrs']\n  const useAverage: typeof import('@vueuse/math')['useAverage']\n  const useBase64: typeof import('@vueuse/core')['useBase64']\n  const useBattery: typeof import('@vueuse/core')['useBattery']\n  const useBluetooth: typeof import('@vueuse/core')['useBluetooth']\n  const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints']\n  const useBroadcastChannel: typeof import('@vueuse/core')['useBroadcastChannel']\n  const useBrowserLocation: typeof import('@vueuse/core')['useBrowserLocation']\n  const useCached: typeof import('@vueuse/core')['useCached']\n  const useCeil: typeof import('@vueuse/math')['useCeil']\n  const useClamp: typeof import('@vueuse/math')['useClamp']\n  const useClipboard: typeof import('@vueuse/core')['useClipboard']\n  const useClipboardItems: typeof import('@vueuse/core')['useClipboardItems']\n  const useCloned: typeof import('@vueuse/core')['useCloned']\n  const useColorMode: typeof import('@vueuse/core')['useColorMode']\n  const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']\n  const useCountdown: typeof import('@vueuse/core')['useCountdown']\n  const useCounter: typeof import('@vueuse/core')['useCounter']\n  const useCssModule: typeof import('vue')['useCssModule']\n  const useCssVar: typeof import('@vueuse/core')['useCssVar']\n  const useCssVars: typeof import('vue')['useCssVars']\n  const useCurrentElement: typeof import('@vueuse/core')['useCurrentElement']\n  const useCycleList: typeof import('@vueuse/core')['useCycleList']\n  const useDark: typeof import('@vueuse/core')['useDark']\n  const useDateFormat: typeof import('@vueuse/core')['useDateFormat']\n  const useDebounce: typeof import('@vueuse/core')['useDebounce']\n  const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn']\n  const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory']\n  const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion']\n  const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation']\n  const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio']\n  const useDevicesList: typeof import('@vueuse/core')['useDevicesList']\n  const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia']\n  const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility']\n  const useDraggable: typeof import('@vueuse/core')['useDraggable']\n  const useDropZone: typeof import('@vueuse/core')['useDropZone']\n  const useElementBounding: typeof import('@vueuse/core')['useElementBounding']\n  const useElementByPoint: typeof import('@vueuse/core')['useElementByPoint']\n  const useElementHover: typeof import('@vueuse/core')['useElementHover']\n  const useElementSize: typeof import('@vueuse/core')['useElementSize']\n  const useElementVisibility: typeof import('@vueuse/core')['useElementVisibility']\n  const useEventBus: typeof import('@vueuse/core')['useEventBus']\n  const useEventListener: typeof import('@vueuse/core')['useEventListener']\n  const useEventSource: typeof import('@vueuse/core')['useEventSource']\n  const useEyeDropper: typeof import('@vueuse/core')['useEyeDropper']\n  const useFavicon: typeof import('@vueuse/core')['useFavicon']\n  const useFetch: typeof import('@vueuse/core')['useFetch']\n  const useFileDialog: typeof import('@vueuse/core')['useFileDialog']\n  const useFileSystemAccess: typeof import('@vueuse/core')['useFileSystemAccess']\n  const useFloor: typeof import('@vueuse/math')['useFloor']\n  const useFocus: typeof import('@vueuse/core')['useFocus']\n  const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin']\n  const useFps: typeof import('@vueuse/core')['useFps']\n  const useFullscreen: typeof import('@vueuse/core')['useFullscreen']\n  const useGamepad: typeof import('@vueuse/core')['useGamepad']\n  const useGeolocation: typeof import('@vueuse/core')['useGeolocation']\n  const useI18n: typeof import('vue-i18n')['useI18n']\n  const useId: typeof import('vue')['useId']\n  const useIdle: typeof import('@vueuse/core')['useIdle']\n  const useImage: typeof import('@vueuse/core')['useImage']\n  const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll']\n  const useIntersectionObserver: typeof import('@vueuse/core')['useIntersectionObserver']\n  const useInterval: typeof import('@vueuse/core')['useInterval']\n  const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn']\n  const useKeyModifier: typeof import('@vueuse/core')['useKeyModifier']\n  const useLastChanged: typeof import('@vueuse/core')['useLastChanged']\n  const useLink: typeof import('vue-router')['useLink']\n  const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']\n  const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys']\n  const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory']\n  const useMath: typeof import('@vueuse/math')['useMath']\n  const useMax: typeof import('@vueuse/math')['useMax']\n  const useMediaControls: typeof import('@vueuse/core')['useMediaControls']\n  const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery']\n  const useMemoize: typeof import('@vueuse/core')['useMemoize']\n  const useMemory: typeof import('@vueuse/core')['useMemory']\n  const useMin: typeof import('@vueuse/math')['useMin']\n  const useModel: typeof import('vue')['useModel']\n  const useMounted: typeof import('@vueuse/core')['useMounted']\n  const useMouse: typeof import('@vueuse/core')['useMouse']\n  const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']\n  const useMousePressed: typeof import('@vueuse/core')['useMousePressed']\n  const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver']\n  const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage']\n  const useNetwork: typeof import('@vueuse/core')['useNetwork']\n  const useNow: typeof import('@vueuse/core')['useNow']\n  const useObjectUrl: typeof import('@vueuse/core')['useObjectUrl']\n  const useOffsetPagination: typeof import('@vueuse/core')['useOffsetPagination']\n  const useOnline: typeof import('@vueuse/core')['useOnline']\n  const usePageLeave: typeof import('@vueuse/core')['usePageLeave']\n  const useParallax: typeof import('@vueuse/core')['useParallax']\n  const useParentElement: typeof import('@vueuse/core')['useParentElement']\n  const usePerformanceObserver: typeof import('@vueuse/core')['usePerformanceObserver']\n  const usePermission: typeof import('@vueuse/core')['usePermission']\n  const usePointer: typeof import('@vueuse/core')['usePointer']\n  const usePointerLock: typeof import('@vueuse/core')['usePointerLock']\n  const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe']\n  const usePrecision: typeof import('@vueuse/math')['usePrecision']\n  const usePreferredColorScheme: typeof import('@vueuse/core')['usePreferredColorScheme']\n  const usePreferredContrast: typeof import('@vueuse/core')['usePreferredContrast']\n  const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']\n  const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']\n  const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']\n  const usePreferredReducedTransparency: typeof import('@vueuse/core')['usePreferredReducedTransparency']\n  const usePrevious: typeof import('@vueuse/core')['usePrevious']\n  const useProjection: typeof import('@vueuse/math')['useProjection']\n  const useRafFn: typeof import('@vueuse/core')['useRafFn']\n  const useRefHistory: typeof import('@vueuse/core')['useRefHistory']\n  const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']\n  const useRound: typeof import('@vueuse/math')['useRound']\n  const useRoute: typeof import('vue-router')['useRoute']\n  const useRouter: typeof import('vue-router')['useRouter']\n  const useSSRWidth: typeof import('@vueuse/core')['useSSRWidth']\n  const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']\n  const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']\n  const useScriptTag: typeof import('@vueuse/core')['useScriptTag']\n  const useScroll: typeof import('@vueuse/core')['useScroll']\n  const useScrollLock: typeof import('@vueuse/core')['useScrollLock']\n  const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage']\n  const useShare: typeof import('@vueuse/core')['useShare']\n  const useSlots: typeof import('vue')['useSlots']\n  const useSorted: typeof import('@vueuse/core')['useSorted']\n  const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition']\n  const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis']\n  const useStepper: typeof import('@vueuse/core')['useStepper']\n  const useStorage: typeof import('@vueuse/core')['useStorage']\n  const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']\n  const useStyleTag: typeof import('@vueuse/core')['useStyleTag']\n  const useSum: typeof import('@vueuse/math')['useSum']\n  const useSupported: typeof import('@vueuse/core')['useSupported']\n  const useSwipe: typeof import('@vueuse/core')['useSwipe']\n  const useTemplateRef: typeof import('vue')['useTemplateRef']\n  const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList']\n  const useTextDirection: typeof import('@vueuse/core')['useTextDirection']\n  const useTextSelection: typeof import('@vueuse/core')['useTextSelection']\n  const useTextareaAutosize: typeof import('@vueuse/core')['useTextareaAutosize']\n  const useThrottle: typeof import('@vueuse/core')['useThrottle']\n  const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn']\n  const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory']\n  const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo']\n  const useTimeout: typeof import('@vueuse/core')['useTimeout']\n  const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn']\n  const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll']\n  const useTimestamp: typeof import('@vueuse/core')['useTimestamp']\n  const useTitle: typeof import('@vueuse/core')['useTitle']\n  const useToNumber: typeof import('@vueuse/core')['useToNumber']\n  const useToString: typeof import('@vueuse/core')['useToString']\n  const useToggle: typeof import('@vueuse/core')['useToggle']\n  const useTransition: typeof import('@vueuse/core')['useTransition']\n  const useTrunc: typeof import('@vueuse/math')['useTrunc']\n  const useUrlSearchParams: typeof import('@vueuse/core')['useUrlSearchParams']\n  const useUserMedia: typeof import('@vueuse/core')['useUserMedia']\n  const useVModel: typeof import('@vueuse/core')['useVModel']\n  const useVModels: typeof import('@vueuse/core')['useVModels']\n  const useVibrate: typeof import('@vueuse/core')['useVibrate']\n  const useVirtualList: typeof import('@vueuse/core')['useVirtualList']\n  const useWakeLock: typeof import('@vueuse/core')['useWakeLock']\n  const useWebNotification: typeof import('@vueuse/core')['useWebNotification']\n  const useWebSocket: typeof import('@vueuse/core')['useWebSocket']\n  const useWebWorker: typeof import('@vueuse/core')['useWebWorker']\n  const useWebWorkerFn: typeof import('@vueuse/core')['useWebWorkerFn']\n  const useWindowFocus: typeof import('@vueuse/core')['useWindowFocus']\n  const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll']\n  const useWindowSize: typeof import('@vueuse/core')['useWindowSize']\n  const watch: typeof import('vue')['watch']\n  const watchArray: typeof import('@vueuse/core')['watchArray']\n  const watchAtMost: typeof import('@vueuse/core')['watchAtMost']\n  const watchDebounced: typeof import('@vueuse/core')['watchDebounced']\n  const watchDeep: typeof import('@vueuse/core')['watchDeep']\n  const watchEffect: typeof import('vue')['watchEffect']\n  const watchIgnorable: typeof import('@vueuse/core')['watchIgnorable']\n  const watchImmediate: typeof import('@vueuse/core')['watchImmediate']\n  const watchOnce: typeof import('@vueuse/core')['watchOnce']\n  const watchPausable: typeof import('@vueuse/core')['watchPausable']\n  const watchPostEffect: typeof import('vue')['watchPostEffect']\n  const watchSyncEffect: typeof import('vue')['watchSyncEffect']\n  const watchThrottled: typeof import('@vueuse/core')['watchThrottled']\n  const watchTriggerable: typeof import('@vueuse/core')['watchTriggerable']\n  const watchWithFilter: typeof import('@vueuse/core')['watchWithFilter']\n  const whenever: typeof import('@vueuse/core')['whenever']\n}\n// for type re-export\ndeclare global {\n  // @ts-ignore\n  export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'\n  import('vue')\n}\n\n// for vue template auto import\nimport { UnwrapRef } from 'vue'\ndeclare module 'vue' {\n  interface GlobalComponents {}\n  interface ComponentCustomProperties {\n    readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>\n    readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>\n    readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>\n    readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>\n    readonly computed: UnwrapRef<typeof import('vue')['computed']>\n    readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>\n    readonly computedEager: UnwrapRef<typeof import('@vueuse/core')['computedEager']>\n    readonly computedInject: UnwrapRef<typeof import('@vueuse/core')['computedInject']>\n    readonly computedWithControl: UnwrapRef<typeof import('@vueuse/core')['computedWithControl']>\n    readonly controlledComputed: UnwrapRef<typeof import('@vueuse/core')['controlledComputed']>\n    readonly controlledRef: UnwrapRef<typeof import('@vueuse/core')['controlledRef']>\n    readonly createApp: UnwrapRef<typeof import('vue')['createApp']>\n    readonly createEventHook: UnwrapRef<typeof import('@vueuse/core')['createEventHook']>\n    readonly createGenericProjection: UnwrapRef<typeof import('@vueuse/math')['createGenericProjection']>\n    readonly createGlobalState: UnwrapRef<typeof import('@vueuse/core')['createGlobalState']>\n    readonly createInjectionState: UnwrapRef<typeof import('@vueuse/core')['createInjectionState']>\n    readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>\n    readonly createProjection: UnwrapRef<typeof import('@vueuse/math')['createProjection']>\n    readonly createReactiveFn: UnwrapRef<typeof import('@vueuse/core')['createReactiveFn']>\n    readonly createRef: UnwrapRef<typeof import('@vueuse/core')['createRef']>\n    readonly createReusableTemplate: UnwrapRef<typeof import('@vueuse/core')['createReusableTemplate']>\n    readonly createSharedComposable: UnwrapRef<typeof import('@vueuse/core')['createSharedComposable']>\n    readonly createTemplatePromise: UnwrapRef<typeof import('@vueuse/core')['createTemplatePromise']>\n    readonly createUnrefFn: UnwrapRef<typeof import('@vueuse/core')['createUnrefFn']>\n    readonly customRef: UnwrapRef<typeof import('vue')['customRef']>\n    readonly debouncedRef: UnwrapRef<typeof import('@vueuse/core')['debouncedRef']>\n    readonly debouncedWatch: UnwrapRef<typeof import('@vueuse/core')['debouncedWatch']>\n    readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>\n    readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>\n    readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>\n    readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']>\n    readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>\n    readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>\n    readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>\n    readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>\n    readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>\n    readonly h: UnwrapRef<typeof import('vue')['h']>\n    readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']>\n    readonly inject: UnwrapRef<typeof import('vue')['inject']>\n    readonly injectLocal: UnwrapRef<typeof import('@vueuse/core')['injectLocal']>\n    readonly isDefined: UnwrapRef<typeof import('@vueuse/core')['isDefined']>\n    readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>\n    readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>\n    readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>\n    readonly isRef: UnwrapRef<typeof import('vue')['isRef']>\n    readonly logicAnd: UnwrapRef<typeof import('@vueuse/math')['logicAnd']>\n    readonly logicNot: UnwrapRef<typeof import('@vueuse/math')['logicNot']>\n    readonly logicOr: UnwrapRef<typeof import('@vueuse/math')['logicOr']>\n    readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>\n    readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>\n    readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>\n    readonly mapState: UnwrapRef<typeof import('pinia')['mapState']>\n    readonly mapStores: UnwrapRef<typeof import('pinia')['mapStores']>\n    readonly mapWritableState: UnwrapRef<typeof import('pinia')['mapWritableState']>\n    readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>\n    readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>\n    readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>\n    readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>\n    readonly onBeforeRouteLeave: UnwrapRef<typeof import('vue-router')['onBeforeRouteLeave']>\n    readonly onBeforeRouteUpdate: UnwrapRef<typeof import('vue-router')['onBeforeRouteUpdate']>\n    readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>\n    readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>\n    readonly onClickOutside: UnwrapRef<typeof import('@vueuse/core')['onClickOutside']>\n    readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>\n    readonly onElementRemoval: UnwrapRef<typeof import('@vueuse/core')['onElementRemoval']>\n    readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>\n    readonly onKeyStroke: UnwrapRef<typeof import('@vueuse/core')['onKeyStroke']>\n    readonly onLongPress: UnwrapRef<typeof import('@vueuse/core')['onLongPress']>\n    readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>\n    readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>\n    readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>\n    readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>\n    readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>\n    readonly onStartTyping: UnwrapRef<typeof import('@vueuse/core')['onStartTyping']>\n    readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>\n    readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>\n    readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>\n    readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>\n    readonly provide: UnwrapRef<typeof import('vue')['provide']>\n    readonly provideLocal: UnwrapRef<typeof import('@vueuse/core')['provideLocal']>\n    readonly reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']>\n    readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']>\n    readonly reactive: UnwrapRef<typeof import('vue')['reactive']>\n    readonly reactiveComputed: UnwrapRef<typeof import('@vueuse/core')['reactiveComputed']>\n    readonly reactiveOmit: UnwrapRef<typeof import('@vueuse/core')['reactiveOmit']>\n    readonly reactivePick: UnwrapRef<typeof import('@vueuse/core')['reactivePick']>\n    readonly readonly: UnwrapRef<typeof import('vue')['readonly']>\n    readonly ref: UnwrapRef<typeof import('vue')['ref']>\n    readonly refAutoReset: UnwrapRef<typeof import('@vueuse/core')['refAutoReset']>\n    readonly refDebounced: UnwrapRef<typeof import('@vueuse/core')['refDebounced']>\n    readonly refDefault: UnwrapRef<typeof import('@vueuse/core')['refDefault']>\n    readonly refThrottled: UnwrapRef<typeof import('@vueuse/core')['refThrottled']>\n    readonly refWithControl: UnwrapRef<typeof import('@vueuse/core')['refWithControl']>\n    readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>\n    readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']>\n    readonly resolveUnref: UnwrapRef<typeof import('@vueuse/core')['resolveUnref']>\n    readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>\n    readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']>\n    readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>\n    readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>\n    readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>\n    readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']>\n    readonly syncRef: UnwrapRef<typeof import('@vueuse/core')['syncRef']>\n    readonly syncRefs: UnwrapRef<typeof import('@vueuse/core')['syncRefs']>\n    readonly templateRef: UnwrapRef<typeof import('@vueuse/core')['templateRef']>\n    readonly throttledRef: UnwrapRef<typeof import('@vueuse/core')['throttledRef']>\n    readonly throttledWatch: UnwrapRef<typeof import('@vueuse/core')['throttledWatch']>\n    readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>\n    readonly toReactive: UnwrapRef<typeof import('@vueuse/core')['toReactive']>\n    readonly toRef: UnwrapRef<typeof import('vue')['toRef']>\n    readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>\n    readonly toValue: UnwrapRef<typeof import('vue')['toValue']>\n    readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>\n    readonly tryOnBeforeMount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeMount']>\n    readonly tryOnBeforeUnmount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeUnmount']>\n    readonly tryOnMounted: UnwrapRef<typeof import('@vueuse/core')['tryOnMounted']>\n    readonly tryOnScopeDispose: UnwrapRef<typeof import('@vueuse/core')['tryOnScopeDispose']>\n    readonly tryOnUnmounted: UnwrapRef<typeof import('@vueuse/core')['tryOnUnmounted']>\n    readonly unref: UnwrapRef<typeof import('vue')['unref']>\n    readonly unrefElement: UnwrapRef<typeof import('@vueuse/core')['unrefElement']>\n    readonly until: UnwrapRef<typeof import('@vueuse/core')['until']>\n    readonly useAbs: UnwrapRef<typeof import('@vueuse/math')['useAbs']>\n    readonly useActiveElement: UnwrapRef<typeof import('@vueuse/core')['useActiveElement']>\n    readonly useAnimate: UnwrapRef<typeof import('@vueuse/core')['useAnimate']>\n    readonly useArrayDifference: UnwrapRef<typeof import('@vueuse/core')['useArrayDifference']>\n    readonly useArrayEvery: UnwrapRef<typeof import('@vueuse/core')['useArrayEvery']>\n    readonly useArrayFilter: UnwrapRef<typeof import('@vueuse/core')['useArrayFilter']>\n    readonly useArrayFind: UnwrapRef<typeof import('@vueuse/core')['useArrayFind']>\n    readonly useArrayFindIndex: UnwrapRef<typeof import('@vueuse/core')['useArrayFindIndex']>\n    readonly useArrayFindLast: UnwrapRef<typeof import('@vueuse/core')['useArrayFindLast']>\n    readonly useArrayIncludes: UnwrapRef<typeof import('@vueuse/core')['useArrayIncludes']>\n    readonly useArrayJoin: UnwrapRef<typeof import('@vueuse/core')['useArrayJoin']>\n    readonly useArrayMap: UnwrapRef<typeof import('@vueuse/core')['useArrayMap']>\n    readonly useArrayReduce: UnwrapRef<typeof import('@vueuse/core')['useArrayReduce']>\n    readonly useArraySome: UnwrapRef<typeof import('@vueuse/core')['useArraySome']>\n    readonly useArrayUnique: UnwrapRef<typeof import('@vueuse/core')['useArrayUnique']>\n    readonly useAsyncQueue: UnwrapRef<typeof import('@vueuse/core')['useAsyncQueue']>\n    readonly useAsyncState: UnwrapRef<typeof import('@vueuse/core')['useAsyncState']>\n    readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>\n    readonly useAverage: UnwrapRef<typeof import('@vueuse/math')['useAverage']>\n    readonly useBase64: UnwrapRef<typeof import('@vueuse/core')['useBase64']>\n    readonly useBattery: UnwrapRef<typeof import('@vueuse/core')['useBattery']>\n    readonly useBluetooth: UnwrapRef<typeof import('@vueuse/core')['useBluetooth']>\n    readonly useBreakpoints: UnwrapRef<typeof import('@vueuse/core')['useBreakpoints']>\n    readonly useBroadcastChannel: UnwrapRef<typeof import('@vueuse/core')['useBroadcastChannel']>\n    readonly useBrowserLocation: UnwrapRef<typeof import('@vueuse/core')['useBrowserLocation']>\n    readonly useCached: UnwrapRef<typeof import('@vueuse/core')['useCached']>\n    readonly useCeil: UnwrapRef<typeof import('@vueuse/math')['useCeil']>\n    readonly useClamp: UnwrapRef<typeof import('@vueuse/math')['useClamp']>\n    readonly useClipboard: UnwrapRef<typeof import('@vueuse/core')['useClipboard']>\n    readonly useClipboardItems: UnwrapRef<typeof import('@vueuse/core')['useClipboardItems']>\n    readonly useCloned: UnwrapRef<typeof import('@vueuse/core')['useCloned']>\n    readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>\n    readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>\n    readonly useCountdown: UnwrapRef<typeof import('@vueuse/core')['useCountdown']>\n    readonly useCounter: UnwrapRef<typeof import('@vueuse/core')['useCounter']>\n    readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>\n    readonly useCssVar: UnwrapRef<typeof import('@vueuse/core')['useCssVar']>\n    readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>\n    readonly useCurrentElement: UnwrapRef<typeof import('@vueuse/core')['useCurrentElement']>\n    readonly useCycleList: UnwrapRef<typeof import('@vueuse/core')['useCycleList']>\n    readonly useDark: UnwrapRef<typeof import('@vueuse/core')['useDark']>\n    readonly useDateFormat: UnwrapRef<typeof import('@vueuse/core')['useDateFormat']>\n    readonly useDebounce: UnwrapRef<typeof import('@vueuse/core')['useDebounce']>\n    readonly useDebounceFn: UnwrapRef<typeof import('@vueuse/core')['useDebounceFn']>\n    readonly useDebouncedRefHistory: UnwrapRef<typeof import('@vueuse/core')['useDebouncedRefHistory']>\n    readonly useDeviceMotion: UnwrapRef<typeof import('@vueuse/core')['useDeviceMotion']>\n    readonly useDeviceOrientation: UnwrapRef<typeof import('@vueuse/core')['useDeviceOrientation']>\n    readonly useDevicePixelRatio: UnwrapRef<typeof import('@vueuse/core')['useDevicePixelRatio']>\n    readonly useDevicesList: UnwrapRef<typeof import('@vueuse/core')['useDevicesList']>\n    readonly useDisplayMedia: UnwrapRef<typeof import('@vueuse/core')['useDisplayMedia']>\n    readonly useDocumentVisibility: UnwrapRef<typeof import('@vueuse/core')['useDocumentVisibility']>\n    readonly useDraggable: UnwrapRef<typeof import('@vueuse/core')['useDraggable']>\n    readonly useDropZone: UnwrapRef<typeof import('@vueuse/core')['useDropZone']>\n    readonly useElementBounding: UnwrapRef<typeof import('@vueuse/core')['useElementBounding']>\n    readonly useElementByPoint: UnwrapRef<typeof import('@vueuse/core')['useElementByPoint']>\n    readonly useElementHover: UnwrapRef<typeof import('@vueuse/core')['useElementHover']>\n    readonly useElementSize: UnwrapRef<typeof import('@vueuse/core')['useElementSize']>\n    readonly useElementVisibility: UnwrapRef<typeof import('@vueuse/core')['useElementVisibility']>\n    readonly useEventBus: UnwrapRef<typeof import('@vueuse/core')['useEventBus']>\n    readonly useEventListener: UnwrapRef<typeof import('@vueuse/core')['useEventListener']>\n    readonly useEventSource: UnwrapRef<typeof import('@vueuse/core')['useEventSource']>\n    readonly useEyeDropper: UnwrapRef<typeof import('@vueuse/core')['useEyeDropper']>\n    readonly useFavicon: UnwrapRef<typeof import('@vueuse/core')['useFavicon']>\n    readonly useFetch: UnwrapRef<typeof import('@vueuse/core')['useFetch']>\n    readonly useFileDialog: UnwrapRef<typeof import('@vueuse/core')['useFileDialog']>\n    readonly useFileSystemAccess: UnwrapRef<typeof import('@vueuse/core')['useFileSystemAccess']>\n    readonly useFloor: UnwrapRef<typeof import('@vueuse/math')['useFloor']>\n    readonly useFocus: UnwrapRef<typeof import('@vueuse/core')['useFocus']>\n    readonly useFocusWithin: UnwrapRef<typeof import('@vueuse/core')['useFocusWithin']>\n    readonly useFps: UnwrapRef<typeof import('@vueuse/core')['useFps']>\n    readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>\n    readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>\n    readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>\n    readonly useI18n: UnwrapRef<typeof import('vue-i18n')['useI18n']>\n    readonly useId: UnwrapRef<typeof import('vue')['useId']>\n    readonly useIdle: UnwrapRef<typeof import('@vueuse/core')['useIdle']>\n    readonly useImage: UnwrapRef<typeof import('@vueuse/core')['useImage']>\n    readonly useInfiniteScroll: UnwrapRef<typeof import('@vueuse/core')['useInfiniteScroll']>\n    readonly useIntersectionObserver: UnwrapRef<typeof import('@vueuse/core')['useIntersectionObserver']>\n    readonly useInterval: UnwrapRef<typeof import('@vueuse/core')['useInterval']>\n    readonly useIntervalFn: UnwrapRef<typeof import('@vueuse/core')['useIntervalFn']>\n    readonly useKeyModifier: UnwrapRef<typeof import('@vueuse/core')['useKeyModifier']>\n    readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>\n    readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']>\n    readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>\n    readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>\n    readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']>\n    readonly useMath: UnwrapRef<typeof import('@vueuse/math')['useMath']>\n    readonly useMax: UnwrapRef<typeof import('@vueuse/math')['useMax']>\n    readonly useMediaControls: UnwrapRef<typeof import('@vueuse/core')['useMediaControls']>\n    readonly useMediaQuery: UnwrapRef<typeof import('@vueuse/core')['useMediaQuery']>\n    readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']>\n    readonly useMemory: UnwrapRef<typeof import('@vueuse/core')['useMemory']>\n    readonly useMin: UnwrapRef<typeof import('@vueuse/math')['useMin']>\n    readonly useModel: UnwrapRef<typeof import('vue')['useModel']>\n    readonly useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']>\n    readonly useMouse: UnwrapRef<typeof import('@vueuse/core')['useMouse']>\n    readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']>\n    readonly useMousePressed: UnwrapRef<typeof import('@vueuse/core')['useMousePressed']>\n    readonly useMutationObserver: UnwrapRef<typeof import('@vueuse/core')['useMutationObserver']>\n    readonly useNavigatorLanguage: UnwrapRef<typeof import('@vueuse/core')['useNavigatorLanguage']>\n    readonly useNetwork: UnwrapRef<typeof import('@vueuse/core')['useNetwork']>\n    readonly useNow: UnwrapRef<typeof import('@vueuse/core')['useNow']>\n    readonly useObjectUrl: UnwrapRef<typeof import('@vueuse/core')['useObjectUrl']>\n    readonly useOffsetPagination: UnwrapRef<typeof import('@vueuse/core')['useOffsetPagination']>\n    readonly useOnline: UnwrapRef<typeof import('@vueuse/core')['useOnline']>\n    readonly usePageLeave: UnwrapRef<typeof import('@vueuse/core')['usePageLeave']>\n    readonly useParallax: UnwrapRef<typeof import('@vueuse/core')['useParallax']>\n    readonly useParentElement: UnwrapRef<typeof import('@vueuse/core')['useParentElement']>\n    readonly usePerformanceObserver: UnwrapRef<typeof import('@vueuse/core')['usePerformanceObserver']>\n    readonly usePermission: UnwrapRef<typeof import('@vueuse/core')['usePermission']>\n    readonly usePointer: UnwrapRef<typeof import('@vueuse/core')['usePointer']>\n    readonly usePointerLock: UnwrapRef<typeof import('@vueuse/core')['usePointerLock']>\n    readonly usePointerSwipe: UnwrapRef<typeof import('@vueuse/core')['usePointerSwipe']>\n    readonly usePrecision: UnwrapRef<typeof import('@vueuse/math')['usePrecision']>\n    readonly usePreferredColorScheme: UnwrapRef<typeof import('@vueuse/core')['usePreferredColorScheme']>\n    readonly usePreferredContrast: UnwrapRef<typeof import('@vueuse/core')['usePreferredContrast']>\n    readonly usePreferredDark: UnwrapRef<typeof import('@vueuse/core')['usePreferredDark']>\n    readonly usePreferredLanguages: UnwrapRef<typeof import('@vueuse/core')['usePreferredLanguages']>\n    readonly usePreferredReducedMotion: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedMotion']>\n    readonly usePreferredReducedTransparency: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedTransparency']>\n    readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>\n    readonly useProjection: UnwrapRef<typeof import('@vueuse/math')['useProjection']>\n    readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>\n    readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']>\n    readonly useResizeObserver: UnwrapRef<typeof import('@vueuse/core')['useResizeObserver']>\n    readonly useRound: UnwrapRef<typeof import('@vueuse/math')['useRound']>\n    readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>\n    readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>\n    readonly useSSRWidth: UnwrapRef<typeof import('@vueuse/core')['useSSRWidth']>\n    readonly useScreenOrientation: UnwrapRef<typeof import('@vueuse/core')['useScreenOrientation']>\n    readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']>\n    readonly useScriptTag: UnwrapRef<typeof import('@vueuse/core')['useScriptTag']>\n    readonly useScroll: UnwrapRef<typeof import('@vueuse/core')['useScroll']>\n    readonly useScrollLock: UnwrapRef<typeof import('@vueuse/core')['useScrollLock']>\n    readonly useSessionStorage: UnwrapRef<typeof import('@vueuse/core')['useSessionStorage']>\n    readonly useShare: UnwrapRef<typeof import('@vueuse/core')['useShare']>\n    readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>\n    readonly useSorted: UnwrapRef<typeof import('@vueuse/core')['useSorted']>\n    readonly useSpeechRecognition: UnwrapRef<typeof import('@vueuse/core')['useSpeechRecognition']>\n    readonly useSpeechSynthesis: UnwrapRef<typeof import('@vueuse/core')['useSpeechSynthesis']>\n    readonly useStepper: UnwrapRef<typeof import('@vueuse/core')['useStepper']>\n    readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']>\n    readonly useStorageAsync: UnwrapRef<typeof import('@vueuse/core')['useStorageAsync']>\n    readonly useStyleTag: UnwrapRef<typeof import('@vueuse/core')['useStyleTag']>\n    readonly useSum: UnwrapRef<typeof import('@vueuse/math')['useSum']>\n    readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']>\n    readonly useSwipe: UnwrapRef<typeof import('@vueuse/core')['useSwipe']>\n    readonly useTemplateRef: UnwrapRef<typeof import('vue')['useTemplateRef']>\n    readonly useTemplateRefsList: UnwrapRef<typeof import('@vueuse/core')['useTemplateRefsList']>\n    readonly useTextDirection: UnwrapRef<typeof import('@vueuse/core')['useTextDirection']>\n    readonly useTextSelection: UnwrapRef<typeof import('@vueuse/core')['useTextSelection']>\n    readonly useTextareaAutosize: UnwrapRef<typeof import('@vueuse/core')['useTextareaAutosize']>\n    readonly useThrottle: UnwrapRef<typeof import('@vueuse/core')['useThrottle']>\n    readonly useThrottleFn: UnwrapRef<typeof import('@vueuse/core')['useThrottleFn']>\n    readonly useThrottledRefHistory: UnwrapRef<typeof import('@vueuse/core')['useThrottledRefHistory']>\n    readonly useTimeAgo: UnwrapRef<typeof import('@vueuse/core')['useTimeAgo']>\n    readonly useTimeout: UnwrapRef<typeof import('@vueuse/core')['useTimeout']>\n    readonly useTimeoutFn: UnwrapRef<typeof import('@vueuse/core')['useTimeoutFn']>\n    readonly useTimeoutPoll: UnwrapRef<typeof import('@vueuse/core')['useTimeoutPoll']>\n    readonly useTimestamp: UnwrapRef<typeof import('@vueuse/core')['useTimestamp']>\n    readonly useTitle: UnwrapRef<typeof import('@vueuse/core')['useTitle']>\n    readonly useToNumber: UnwrapRef<typeof import('@vueuse/core')['useToNumber']>\n    readonly useToString: UnwrapRef<typeof import('@vueuse/core')['useToString']>\n    readonly useToggle: UnwrapRef<typeof import('@vueuse/core')['useToggle']>\n    readonly useTransition: UnwrapRef<typeof import('@vueuse/core')['useTransition']>\n    readonly useTrunc: UnwrapRef<typeof import('@vueuse/math')['useTrunc']>\n    readonly useUrlSearchParams: UnwrapRef<typeof import('@vueuse/core')['useUrlSearchParams']>\n    readonly useUserMedia: UnwrapRef<typeof import('@vueuse/core')['useUserMedia']>\n    readonly useVModel: UnwrapRef<typeof import('@vueuse/core')['useVModel']>\n    readonly useVModels: UnwrapRef<typeof import('@vueuse/core')['useVModels']>\n    readonly useVibrate: UnwrapRef<typeof import('@vueuse/core')['useVibrate']>\n    readonly useVirtualList: UnwrapRef<typeof import('@vueuse/core')['useVirtualList']>\n    readonly useWakeLock: UnwrapRef<typeof import('@vueuse/core')['useWakeLock']>\n    readonly useWebNotification: UnwrapRef<typeof import('@vueuse/core')['useWebNotification']>\n    readonly useWebSocket: UnwrapRef<typeof import('@vueuse/core')['useWebSocket']>\n    readonly useWebWorker: UnwrapRef<typeof import('@vueuse/core')['useWebWorker']>\n    readonly useWebWorkerFn: UnwrapRef<typeof import('@vueuse/core')['useWebWorkerFn']>\n    readonly useWindowFocus: UnwrapRef<typeof import('@vueuse/core')['useWindowFocus']>\n    readonly useWindowScroll: UnwrapRef<typeof import('@vueuse/core')['useWindowScroll']>\n    readonly useWindowSize: UnwrapRef<typeof import('@vueuse/core')['useWindowSize']>\n    readonly watch: UnwrapRef<typeof import('vue')['watch']>\n    readonly watchArray: UnwrapRef<typeof import('@vueuse/core')['watchArray']>\n    readonly watchAtMost: UnwrapRef<typeof import('@vueuse/core')['watchAtMost']>\n    readonly watchDebounced: UnwrapRef<typeof import('@vueuse/core')['watchDebounced']>\n    readonly watchDeep: UnwrapRef<typeof import('@vueuse/core')['watchDeep']>\n    readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>\n    readonly watchIgnorable: UnwrapRef<typeof import('@vueuse/core')['watchIgnorable']>\n    readonly watchImmediate: UnwrapRef<typeof import('@vueuse/core')['watchImmediate']>\n    readonly watchOnce: UnwrapRef<typeof import('@vueuse/core')['watchOnce']>\n    readonly watchPausable: UnwrapRef<typeof import('@vueuse/core')['watchPausable']>\n    readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>\n    readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>\n    readonly watchThrottled: UnwrapRef<typeof import('@vueuse/core')['watchThrottled']>\n    readonly watchTriggerable: UnwrapRef<typeof import('@vueuse/core')['watchTriggerable']>\n    readonly watchWithFilter: UnwrapRef<typeof import('@vueuse/core')['watchWithFilter']>\n    readonly whenever: UnwrapRef<typeof import('@vueuse/core')['whenever']>\n  }\n}"
  },
  {
    "path": "components.d.ts",
    "content": "/* eslint-disable */\n// @ts-nocheck\n// Generated by unplugin-vue-components\n// Read more: https://github.com/vuejs/core/pull/3399\n// biome-ignore lint: disable\nexport {}\n\n/* prettier-ignore */\ndeclare module 'vue' {\n  export interface GlobalComponents {\n    ConfirmDialog: typeof import('./src/@core/components/ConfirmDialog.vue')['default']\n    DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default']\n    ErrorHeader: typeof import('./src/@core/components/ErrorHeader.vue')['default']\n    ExistIcon: typeof import('./src/@core/components/ExistIcon.vue')['default']\n    LoadingBanner: typeof import('./src/@core/components/LoadingBanner.vue')['default']\n    MoreBtn: typeof import('./src/@core/components/MoreBtn.vue')['default']\n    PageContentTitle: typeof import('./src/@core/components/PageContentTitle.vue')['default']\n    RouterLink: typeof import('vue-router')['RouterLink']\n    RouterView: typeof import('vue-router')['RouterView']\n    ScrollToTopBtn: typeof import('./src/@core/components/ScrollToTopBtn.vue')['default']\n    StatIcon: typeof import('./src/@core/components/StatIcon.vue')['default']\n  }\n}\n"
  },
  {
    "path": "docs/federation-troubleshooting.md",
    "content": "# MoviePilot 模块联邦问题排查指南\n\n本文档提供了针对 MoviePilot 项目中使用模块联邦时可能遇到的常见问题及解决方案。\n\n## 远程组件注册机制\n\nMoviePilot 使用自动注册机制来加载远程组件：\n\n1. 对于使用 Vue 渲染模式的插件，自动注册其远程组件\n2. 每个远程组件根据插件 ID 唯一标识，确保不会冲突\n3. 在需要加载组件时，会优先检查已注册的组件信息\n\n这种设计使得插件开发者只需专注于组件开发，而不需要担心加载机制的复杂性。\n\n## 常见错误\n\n### 1. \"Module name 'vue' does not resolve to a valid URL\"\n\n**原因**：远程组件无法正确解析共享依赖的 URL，通常是因为共享依赖配置不正确。\n\n**解决方案**：\n\n1. 在 **插件组件项目** 的 `vite.config.js` 中正确配置共享依赖：\n\n```js\nfederation({\n  // ...\n  shared: {\n    vue: {\n      singleton: true,\n      requiredVersion: false // 关闭版本检查\n    }\n  }\n})\n```\n\n2. 在 **主应用** 的 `vite.config.ts` 中确保共享依赖配置正确：\n\n```ts\nfederation({\n  name: 'host',\n  remotes: {},\n  shared: ['vue', 'vuetify']\n})\n```\n\n### 2. \"Top-level await is not available in the configured target environment\"\n\n**原因**：模块联邦使用了顶层 await，但目标构建环境不支持此功能。\n\n**解决方案**：\n\n在 **主应用** 和 **插件组件项目** 的构建配置中添加 `target: 'esnext'`：\n\n```js\nbuild: {\n  target: 'esnext', // 支持顶层await\n  // 其他配置...\n}\n```\n\n### 3. \"TypeError: Failed to fetch dynamically imported module\"\n\n**原因**：远程组件 JS 文件无法被正确加载，可能是路径错误或网络问题。\n\n**解决方案**：\n\n1. 检查网络请求是否成功（状态码200）\n2. 确认组件 URL 是否正确\n3. 确保服务器允许访问该 JS 文件（CORS 配置）\n4. 检查插件后端是否正确提供了静态文件服务\n\n### 4. 组件加载后渲染为空白或出现错误\n\n**原因**：组件内部代码错误或与主应用不兼容。\n\n**解决方案**：\n\n1. 检查浏览器控制台错误信息\n2. 确保组件代码没有语法错误\n3. 避免在组件中使用主应用未提供的依赖\n4. 确保所有路径（如图片、API请求URL等）都是正确的\n\n## 调试技巧\n\n### 1. 启用详细日志\n\n在浏览器控制台中设置：\n\n```js\nlocalStorage.setItem('debug', 'vite:*')\n```\n\n### 2. 分析网络请求\n\n1. 打开浏览器开发者工具\n2. 转到 Network 标签页\n3. 确认远程组件 JS 文件请求是否成功\n4. 分析响应内容是否为有效的 JavaScript\n\n### 3. 隔离测试远程组件\n\n创建一个独立的简单页面来测试插件组件，排除主应用的干扰因素。\n\n## 其他资源\n\n- [MoviePilot 插件组件示例](../examples/plugin-component/) \n- [Vite 模块联邦插件文档](https://github.com/originjs/vite-plugin-federation)\n- [Vite 官方文档](https://vitejs.dev/guide/build.html)\n- [Origin.js 模块联邦示例](https://github.com/originjs/vite-plugin-federation/tree/main/packages/examples)\n"
  },
  {
    "path": "docs/module-federation-guide.md",
    "content": "# MoviePilot前端远程模块开发指南\n\n## 1. 概述\n\nMoviePilot前端采用模块联邦(Module Federation)技术实现插件的动态加载和集成。本文档详细说明如何开发符合要求的远程模块，以便在MoviePilot中作为插件使用。\n\n关联阅读后端插件开发文档：[第三方插件开发说明](https://github.com/jxxghp/MoviePilot-Plugins/blob/main/README.md)\n\n\n## 2. 技术要求\n\n- Node.js 20+\n- Vue 3\n- Vite 4+\n- TypeScript 5+\n\n## 3. 核心概念\n\n每个 Vue 联邦插件需要提供下列标准组件（`AppPage` 为可选，用于主界面侧栏全页入口）：\n\n| 组件名称 | 暴露名 | 文件名 | 用途 |\n|---------|--------|--------|------|\n| Page | `./Page` | Page.vue | 插件管理中的详情弹窗 |\n| Config | `./Config` | Config.vue | 插件配置页面 |\n| Dashboard | `./Dashboard` | Dashboard.vue | 仪表盘小组件 |\n| AppPage | `./AppPage` | AppPage.vue | 主界面侧栏独立全页（主内容区由插件完全绘制） |\n| （可选） | `./AppPage{Xxx}` | 如 AppPageSettings.vue | 多 `nav_key` 时按名优先加载，见下文「多界面」 |\n\n主应用在侧栏全页路由中按 `nav_key` 解析暴露名（如 `AppPageSettings`），再回退 `AppPage` → `Page`；`nav_key` 为 `main` 时仅尝试 `AppPage` → `Page`。\n\n## 4. 快速开始\n\n### 创建项目\n\n```bash\n# 创建项目\nnpm create vite@latest my-plugin -- --template vue-ts\n\n# 进入项目目录\ncd my-plugin\n\n# 安装依赖\nyarn\n```\n\n### 配置vite.config.ts\n\n```typescript\nimport { defineConfig } from 'vite'\nimport vue from '@vitejs/plugin-vue'\nimport federation from '@originjs/vite-plugin-federation'\n\nexport default defineConfig({\n  plugins: [\n    vue(),\n    federation({\n      name: 'MyPlugin',\n      filename: 'remoteEntry.js',\n      exposes: {\n        './Page': './src/components/Page.vue',\n        './Config': './src/components/Config.vue',\n        './Dashboard': './src/components/Dashboard.vue',\n        './AppPage': './src/components/AppPage.vue',\n        './AppPageSettings': './src/components/AppPageSettings.vue',\n      },\n      shared: {\n        vue: {\n          requiredVersion: false,\n          generate: false,\n        },\n        vuetify: {\n          requiredVersion: false,\n          generate: false,\n          singleton: true,\n        },\n        'vuetify/styles': {\n          requiredVersion: false,\n          generate: false,\n          singleton: true,\n        },\n      },\n      format: 'esm'\n    })\n  ],\n  build: {\n    target: 'esnext',   // 必须设置为esnext以支持顶层await\n    minify: false,      // 开发阶段建议关闭混淆\n    cssCodeSplit: true, // 改为true以便能分离样式文件\n  },\n  css: {\n    preprocessorOptions: {\n      scss: {\n        additionalData: '/* 覆盖vuetify样式 */',\n      }\n    },\n    postcss: {\n      plugins: [\n        {\n          postcssPlugin: 'internal:charset-removal',\n          AtRule: {\n            charset: (atRule) => {\n              if (atRule.name === 'charset') {\n                atRule.remove();\n              }\n            }\n          }\n        },\n        {\n          postcssPlugin: 'vuetify-filter',\n          Root(root) {\n            // 过滤掉所有vuetify相关的CSS\n            root.walkRules(rule => {\n              if (rule.selector && (\n                  rule.selector.includes('.v-') || \n                  rule.selector.includes('.mdi-'))) {\n                rule.remove();\n              }\n            });\n          }\n        }\n      ]\n    }\n  },\n  server: {\n    port: 5001,   // 使用不同于主应用的端口\n    cors: true,   // 启用CORS\n    origin: 'http://localhost:5001'\n  },\n}) \n\n```\n\n## 5. 组件开发规范\n\n### 5.1 Page组件（详情页面）\n\n```vue\n<script setup lang=\"ts\">\n// 自定义事件，用于通知主应用刷新数据\nconst emit = defineEmits(['action', 'switch', 'close'])\n\n// 接收API对象\nconst props = defineProps({\n  api: {\n    type: Object,\n    default: () => {}\n  }\n})\n\n// 页面逻辑代码...\n\n// 通知主应用刷新数据\nfunction notifyRefresh() {\n  emit('action')\n}\n\n// 通知主应用切换到配置页面\nfunction notifySwitch() {\n  emit('switch')\n}\n\n// 通知主应用关闭当前页面\nfunction notifyClose() {\n  emit('close')\n}\n</script>\n\n<template>\n  <div class=\"plugin-page\">\n    <!-- 插件详情页面操作按钮示例 -->\n    <v-btn @click=\"notifyRefresh\">刷新数据</v-btn>\n    <v-btn @click=\"notifySwitch\">配置插件</v-btn>\n    <v-btn @click=\"notifyClose\">关闭页面</v-btn>\n  </div>\n</template>\n```\n\n### 5.2 Config组件（配置页面）\n\n```vue\n<script setup lang=\"ts\">\n// 接收初始配置和API对象\nconst props = defineProps({\n  initialConfig: {\n    type: Object,\n    default: () => ({})\n  },\n  api: {\n    type: Object,\n    default: () => {}\n  }\n})\n\n// 配置数据\nconst config = ref({...props.initialConfig})\n\n// 自定义事件，用于保存配置\nconst emit = defineEmits(['save', 'close', 'switch'])\n\n// 保存配置\nfunction saveConfig() {\n  emit('save', config.value)\n}\n\n// 通知主应用切换到详情页面\nfunction notifySwitch() {\n  emit('switch')\n}\n\n// 通知主应用关闭当前页面\nfunction notifyClose() {\n  emit('close')\n}\n</script>\n\n<template>\n  <div class=\"plugin-config\">\n    <!-- 配置表单示例 -->\n    <v-text-field v-model=\"config.someField\" label=\"配置项\"></v-text-field>\n    \n    <!-- 保存按钮示例 -->\n    <v-btn color=\"primary\" @click=\"saveConfig\">保存配置</v-btn>\n\n    <!-- 关闭按钮示例 -->\n    <v-btn color=\"primary\" @click=\"notifyClose\">关闭页面</v-btn>\n\n    <!-- 切换按钮示例 -->\n    <v-btn color=\"primary\" @click=\"notifySwitch\">切换到详情页面</v-btn>\n  </div>\n</template>\n```\n\n### 5.3 Dashboard组件（仪表板）\n\n```vue\n<script setup lang=\"ts\">\n// 接收配置和刷新控制\nconst props = defineProps({\n  config: {\n    type: Object,\n    default: () => ({})\n  },\n  allowRefresh: {\n    type: Boolean,\n    default: true\n  }\n})\n\n// 仪表板逻辑...\n</script>\n\n<template>\n  <div class=\"dashboard-widget\">\n    <v-hover>\n      <!-- 仪表板内容 -->\n      <template #default=\"{ isHovering, props: hoverProps }\">\n        <v-card v-bind=\"hoverProps\">\n          <v-card-title>{{ config.title || '仪表板组件' }}</v-card-title>\n          <v-card-text>\n            <!-- 组件内容 -->\n          </v-card-text>\n          <!-- 只在悬停时显示拖拽图标 -->\n          <div v-show=\"isHovering\" class=\"absolute right-5 top-5\">\n            <v-icon class=\"cursor-move\">mdi-drag</v-icon>\n          </div>\n        </v-card>\n      </template>\n    </v-hover>\n  </div>\n</template>\n```\n\n### 5.4 AppPage 组件（侧栏全页）\n\n用于主应用左侧导航中的独立页面（路由 `#/plugin-app/:pluginId/:navKey?`），占据默认布局下的主内容区；与 `Page` 不同，不嵌在插件管理弹窗中。\n\n主应用传入的 props：\n\n| 属性 | 说明 |\n|------|------|\n| `api` | 与 `Page` 相同，用于 `bear` 认证的插件 HTTP 调用 |\n| `navKey` | 与侧栏声明的 `nav_key` 一致，同一插件多入口时用于区分 |\n| `pluginId` | 当前插件 ID |\n\n```vue\n<script setup lang=\"ts\">\nconst props = defineProps({\n  api: { type: Object, default: () => ({}) },\n  navKey: { type: String, default: 'main' },\n  pluginId: { type: String, default: '' },\n})\nconst emit = defineEmits(['action'])\n</script>\n\n<template>\n  <div class=\"pa-4\">\n    <div class=\"text-h6 mb-2\">侧栏全页示例（{{ pluginId }} / {{ navKey }}）</div>\n    <v-btn size=\"small\" @click=\"emit('action')\">通知主应用</v-btn>\n  </div>\n</template>\n```\n\n#### 后端：注册侧栏入口\n\n插件需为 **Vue** 渲染模式（`get_render_mode` 返回 `vue`），并实现 `get_sidebar_nav`，返回列表项字段与主应用 `GET /api/v1/plugin/sidebar_nav` 一致：\n\n| 字段 | 说明 |\n|------|------|\n| `nav_key` | URL 路径段，唯一标识本入口（同一插件可多入口） |\n| `title` | 侧栏显示标题 |\n| `icon` | MDI 图标名，如 `mdi-rss` |\n| `section` | 分组：`start` / `discovery` / `subscribe` / `organize` / `system` |\n| `permission` | 可选：`subscribe` / `discovery` / `search` / `manage` / `admin`，与主应用菜单权限一致 |\n| `order` | 可选：同组内排序，数值越小越靠前 |\n\n```python\ndef get_sidebar_nav(self) -> List[Dict[str, Any]]:\n    return [\n        {\n            \"nav_key\": \"main\",\n            \"title\": \"示例订阅页\",\n            \"icon\": \"mdi-rss\",\n            \"section\": \"subscribe\",\n            \"permission\": \"subscribe\",\n            \"order\": 10,\n        }\n    ]\n```\n\n#### 同一插件多个全页界面（多 `nav_key`）\n\n在 `get_sidebar_nav` 中**返回多条**记录，每条使用不同的 `nav_key` / `title` / `section` 等，侧栏与「更多」中会出现多个入口，路由形如 `#/plugin-app/<插件ID>/<nav_key>`。\n\n前端加载远程组件的顺序为：\n\n| `nav_key` | 依次尝试的联邦暴露名 |\n|-----------|----------------------|\n| `main` 或省略 | `./AppPage` → `./Page` |\n| 其它（如 `settings`、`my_tool`） | `./AppPage{PascalCase}` → `./AppPage` → `./Page` |\n\n`PascalCase` 规则：按 `-`、`_`、空格分段后首字母大写并拼接。例如 `nav_key=settings` → 先试 `./AppPageSettings`；`my_tool` → `./AppPageMyTool`。\n\n**两种实现方式（二选一或混用）：**\n\n1. **单文件分支**：只暴露 `./AppPage`，在组件内根据 `navKey` prop 用 `v-if` / `<component>` 切换子界面。  \n2. **多文件**：为某个入口单独暴露 `./AppPageSettings.vue` 等，主应用会优先加载对应模块，失败再回退到 `AppPage`。\n\n`vite.config` 多暴露示例：\n\n```typescript\nexposes: {\n  './AppPage': './src/components/AppPage.vue',\n  './AppPageSettings': './src/components/AppPageSettings.vue',\n  // ...\n}\n```\n\n## 6. 构建和部署\n\n### 构建项目\n\n```bash\nyarn build\n```\n\n- 将生成的dist文件夹上传到插件后端目录下（默认为`dist/assets`）\n\n **注意： `__federation_shared_vuetify` 目录以及 `index-`、`date-`、`runtime-` 开头的文件不需要上传**，只需要上传以下命名格式文件：`__federation_*`、`_plugin-vue_export-helper-*`、`remoteEntry.js`\n\n\n- 在插件的后端python代码中，实现以下方法来集成远程组件：\n\n```python\ndef get_render_mode() -> Tuple[str, str]:\n    \"\"\"\n    获取插件渲染模式\n    :return: 1、渲染模式，支持：vue/vuetify，默认vuetify\n    :return: 2、组件路径，默认 dist/assets\n    \"\"\"\n    return \"vue\", \"dist/assets\"\n```\n\n-  需要在插件前端页面调用后端接口时，通过传入的api模块发起调用，后端api接口声明认证类型为：`bear`\n```typescript\n// 演示使用api模块调用插件接口\nrecentItems.value = await props.api.get(`plugin/MyPlugin/history`)\n```\n\n```python\ndef get_api(self) -> List[Dict[str, Any]]:\n    \"\"\"\n    注册插件API\n    \"\"\"\n    return [\n        {\n            \"path\": \"/history\",\n            \"endpoint\": self.get_history,\n            \"methods\": [\"GET\"],\n            \"auth\": \"bear\",  # 认证类型设为bear\n            \"summary\": \"查询历史记录\"\n        }\n    ]\n```\n\n\n## 7. 调试与排错\n\n### 常见问题\n\n1. **模块无法加载**\n   - 检查网络请求是否成功（状态码200）\n   - 确认文件路径是否正确\n   - 检查CORS跨域设置\n\n2. **模块加载但组件不显示**\n   - 检查控制台错误信息\n   - 确认组件是否正确导出\n   - 验证共享依赖配置\n\n3. **\"Module name 'vue' does not resolve to a valid URL\"**\n   - 检查`shared`配置是否正确\n   - 设置`requiredVersion: false`尝试解决\n\n4. **\"Top-level await is not available\"**\n   - 确保`build.target`设置为`esnext`\n\n## 8. 高级配置\n\n### 8.1 CSS隔离\n\n为防止样式冲突，建议使用CSS Modules或scoped样式：\n\n```vue\n<style scoped>\n/* 组件样式 */\n</style>\n```\n\n### 8.2 共享更多依赖\n\n如果您的插件需要共享更多依赖，可以扩展shared配置：\n\n```js\nshared: {\n  vue: { requiredVersion: false },\n  vuetify: { requiredVersion: false },\n  '@vueuse/core': { requiredVersion: false },\n  pinia: { requiredVersion: false }\n}\n```\n\n### 8.3 开发环境测试\n\n开发期间可以使用以下配置在本地测试：\n\n```typescript\n// vite.config.ts\nexport default defineConfig({\n  server: {\n    port: 5001,   // 使用不同于主应用的端口\n    cors: true,   // 启用CORS\n    origin: 'http://localhost:5001'\n  }\n})\n```\n\n## 9. 示例代码\n\n- [插件远程组件示例](../examples/plugin-component/) - 开发插件组件的完整示例项目 \n- [模块联邦问题排查指南](./federation-troubleshooting.md) - 常见问题排查\n\n## 10. 参考资料\n\n- [Vite Plugin Federation](https://github.com/originjs/vite-plugin-federation)\n- [Vue 3官方文档](https://vuejs.org/)\n\n---\n\n如有问题，请提交Issue。 \n"
  },
  {
    "path": "env.d.ts",
    "content": "import 'vue-router'\n\ndeclare module 'vue-router' {\n  interface RouteMeta {\n    action?: string\n    subject?: string\n    layoutWrapperClasses?: string\n    navActiveLink?: RouteLocationRaw\n  }\n}\n\n// 支持动态导入远程模块\ndeclare module '*' {\n  import { DefineComponent } from 'vue'\n  const component: DefineComponent<{}, {}, any>\n  export default component\n}\n"
  },
  {
    "path": "examples/plugin-component/README.md",
    "content": "# MoviePilot 插件远程组件示例\n\n这是 MoviePilot 插件远程组件的示例项目，展示了如何正确配置和开发与主应用兼容的远程组件。本示例包含 Page、Config、Dashboard、AppPage，以及可选的 `AppPageSettings`（`nav_key=settings` 时由主应用优先加载，用于演示「一插件多全页界面」）。\n\n## 1. 开发环境准备\n\n### 安装依赖\n\n```bash\nnpm install\n# 或\nyarn\n```\n\n### 开发模式运行\n\n```bash\nnpm run dev\n# 或\nyarn dev\n```\n\n## 2. 项目结构\n\n```\nplugin-component/\n├── src/\n│   ├── components/\n│   │   ├── Page.vue       # 插件详情页面组件\n│   │   ├── Config.vue     # 插件配置页面组件\n│   │   ├── Dashboard.vue  # 插件仪表板组件\n│   │   ├── AppPage.vue    # 侧栏全页（主内容区，nav_key=main）\n│   │   └── AppPageSettings.vue  # 可选第二全页（nav_key=settings）\n│   ├── App.vue            # 本地开发入口组件\n│   └── main.js            # 本地开发入口文件\n├── vite.config.js         # Vite和模块联邦配置\n├── index.html             # 本地开发HTML入口\n└── package.json           # 依赖配置\n```\n\n## 3. 开发指引\n\n- [模块联邦开发指南](../../docs/module-federation-guide.md)\n- [模块联邦问题排查指南](../../docs/federation-troubleshooting.md)。\n"
  },
  {
    "path": "examples/plugin-component/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n\n<head>\n  <meta charset=\"UTF-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n  <title>MoviePilot插件组件示例</title>\n  <link href=\"https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900\" rel=\"stylesheet\" />\n  <link href=\"https://cdn.jsdelivr.net/npm/@mdi/font@6.x/css/materialdesignicons.min.css\" rel=\"stylesheet\" />\n  <style>\n    body {\n      margin: 0;\n      padding: 0;\n      font-family: 'Roboto', sans-serif;\n    }\n  </style>\n</head>\n\n<body>\n  <div id=\"app\"></div>\n  <script type=\"module\" src=\"/src/main.js\"></script>\n</body>\n\n</html>\n"
  },
  {
    "path": "examples/plugin-component/package.json",
    "content": "{\n  \"name\": \"moviepilot-plugin-component\",\n  \"private\": true,\n  \"version\": \"1.0.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"preview\": \"vite preview\"\n  },\n  \"dependencies\": {\n    \"vue\": \"^3.5.13\",\n    \"vuetify\": \"3.7.3\",\n    \"echarts\": \"^5.4.3\",\n    \"vue-echarts\": \"^6.6.1\",\n    \"@vueuse/core\": \"^12.4.0\"\n  },\n  \"devDependencies\": {\n    \"@originjs/vite-plugin-federation\": \"^1.4.1\",\n    \"@vitejs/plugin-vue\": \"^4.4.0\",\n    \"vite\": \"^5.4.11\"\n  }\n}"
  },
  {
    "path": "examples/plugin-component/src/App.vue",
    "content": "<template>\n  <div class=\"app-container\">\n    <v-app>\n      <v-app-bar color=\"primary\" app>\n        <v-app-bar-title>MoviePilot插件组件示例</v-app-bar-title>\n      </v-app-bar>\n\n      <v-main>\n        <v-container>\n          <v-tabs v-model=\"activeTab\" bg-color=\"primary\">\n            <v-tab value=\"page\">详情页面</v-tab>\n            <v-tab value=\"config\">配置页面</v-tab>\n            <v-tab value=\"dashboard\">仪表板</v-tab>\n          </v-tabs>\n\n          <v-window v-model=\"activeTab\" class=\"mt-4\">\n            <v-window-item value=\"page\">\n              <h2 class=\"text-h5 mb-4\">Page组件</h2>\n              <div class=\"component-preview\">\n                <page-component @action=\"handleAction\"></page-component>\n              </div>\n            </v-window-item>\n\n            <v-window-item value=\"config\">\n              <h2 class=\"text-h5 mb-4\">Config组件</h2>\n              <div class=\"component-preview\">\n                <config-component :initial-config=\"initialConfig\" @save=\"handleConfigSave\"></config-component>\n              </div>\n            </v-window-item>\n\n            <v-window-item value=\"dashboard\">\n              <h2 class=\"text-h5 mb-4\">Dashboard组件</h2>\n              <v-switch v-model=\"dashboardConfig.attrs.border\" label=\"显示边框\" color=\"primary\" class=\"mb-4\"></v-switch>\n              <div class=\"component-preview\">\n                <dashboard-component :config=\"dashboardConfig\" :allow-refresh=\"true\"></dashboard-component>\n              </div>\n            </v-window-item>\n          </v-window>\n        </v-container>\n      </v-main>\n\n      <v-footer app color=\"primary\" class=\"text-center d-flex justify-center\">\n        <span class=\"text-white\">MoviePilot 模块联邦示例 ©{{ new Date().getFullYear() }}</span>\n      </v-footer>\n    </v-app>\n\n    <!-- 通知弹窗 -->\n    <v-snackbar v-model=\"snackbar.show\" :color=\"snackbar.color\" :timeout=\"snackbar.timeout\">\n      {{ snackbar.text }}\n      <template v-slot:actions>\n        <v-btn variant=\"text\" @click=\"snackbar.show = false\"> 关闭 </v-btn>\n      </template>\n    </v-snackbar>\n  </div>\n</template>\n\n<script setup>\nimport { ref, reactive } from 'vue'\nimport PageComponent from './components/Page.vue'\nimport ConfigComponent from './components/Config.vue'\nimport DashboardComponent from './components/Dashboard.vue'\n\n// 活动标签页\nconst activeTab = ref('page')\n\n// 配置初始值\nconst initialConfig = {\n  name: '测试插件',\n  description: '这是一个测试配置',\n  enable_notifications: true,\n  update_interval: 30,\n  api_url: 'https://api.example.com',\n  api_key: 'test_api_key_123',\n  concurrent_tasks: 2,\n  tags: ['电影', '测试'],\n}\n\n// 仪表板配置\nconst dashboardConfig = reactive({\n  id: 'test_plugin',\n  name: '测试插件',\n  attrs: {\n    title: '仪表板示例',\n    subtitle: '插件数据展示',\n    border: true,\n  },\n})\n\n// 通知状态\nconst snackbar = reactive({\n  show: false,\n  text: '',\n  color: 'success',\n  timeout: 3000,\n})\n\n// 显示通知\nfunction showNotification(text, color = 'success') {\n  snackbar.text = text\n  snackbar.color = color\n  snackbar.show = true\n}\n\n// 处理详情页面操作\nfunction handleAction() {\n  showNotification('Page组件触发了action事件')\n}\n\n// 处理配置保存\nfunction handleConfigSave(config) {\n  console.log('配置已保存:', config)\n  showNotification('配置已保存')\n}\n</script>\n\n<style scoped>\n/* 为了使测试应用更美观 */\n.app-container {\n  block-size: 100vh;\n  inline-size: 100vw;\n}\n\n.component-preview {\n  overflow: hidden;\n  border: 1px solid #e0e0e0;\n  border-radius: 8px;\n}\n</style>\n"
  },
  {
    "path": "examples/plugin-component/src/components/AppPage.vue",
    "content": "<script setup lang=\"ts\">\n/**\n * 侧栏全页：在主应用 #/plugin-app/:pluginId/:navKey 中渲染，占据主内容区。\n * 需在插件后端实现 get_sidebar_nav 才会出现在侧栏。\n */\nconst props = defineProps({\n  api: {\n    type: Object,\n    default: () => ({}),\n  },\n  navKey: {\n    type: String,\n    default: 'main',\n  },\n  pluginId: {\n    type: String,\n    default: '',\n  },\n})\n\nconst emit = defineEmits(['action'])\n</script>\n\n<template>\n  <div class=\"plugin-app-page pa-4\">\n    <div class=\"text-h6 mb-2\">AppPage（侧栏全页）</div>\n    <div class=\"text-body-2 text-medium-emphasis mb-4\">\n      pluginId: {{ pluginId }} · navKey: {{ navKey }}\n    </div>\n    <v-btn size=\"small\" variant=\"tonal\" @click=\"emit('action')\">action</v-btn>\n  </div>\n</template>\n"
  },
  {
    "path": "examples/plugin-component/src/components/AppPageSettings.vue",
    "content": "<script setup lang=\"ts\">\n/**\n * 示例：nav_key=settings 时主应用会优先加载 AppPageSettings，再回退 AppPage。\n */\nconst props = defineProps({\n  api: { type: Object, default: () => ({}) },\n  navKey: { type: String, default: 'settings' },\n  pluginId: { type: String, default: '' },\n})\n</script>\n\n<template>\n  <div class=\"pa-4\">\n    <div class=\"text-subtitle-1\">Settings 子界面（AppPageSettings）</div>\n    <div class=\"text-caption text-medium-emphasis\">navKey={{ navKey }} · pluginId={{ pluginId }}</div>\n  </div>\n</template>\n"
  },
  {
    "path": "examples/plugin-component/src/components/Config.vue",
    "content": "<template>\n  <div class=\"plugin-config\">\n    <v-card>\n      <v-card-item>\n        <v-card-title>插件配置</v-card-title>\n        <template #append>\n          <v-btn icon color=\"primary\" variant=\"text\" @click=\"notifyClose\">\n            <v-icon left>mdi-close</v-icon>\n          </v-btn>\n        </template>\n      </v-card-item>\n      <v-card-text class=\"overflow-y-auto\">\n        <v-alert v-if=\"error\" type=\"error\" class=\"mb-4\">{{ error }}</v-alert>\n\n        <v-form ref=\"form\" v-model=\"isFormValid\" @submit.prevent=\"saveConfig\">\n          <!-- 基本设置区域 -->\n          <div class=\"text-subtitle-1 font-weight-bold mt-4 mb-2\">基本设置</div>\n          <v-row>\n            <v-col cols=\"12\">\n              <v-switch\n                v-model=\"config.enable\"\n                label=\"启用插件\"\n                color=\"primary\"\n                inset\n                hint=\"启用插件后，插件将开始工作\"\n                persistent-hint\n              ></v-switch>\n            </v-col>\n            <v-col cols=\"12\">\n              <v-text-field\n                v-model=\"config.name\"\n                label=\"插件名称\"\n                variant=\"outlined\"\n                :rules=\"[v => !!v || '名称不能为空']\"\n                hint=\"显示在插件列表中的名称\"\n              ></v-text-field>\n            </v-col>\n            <v-col cols=\"12\">\n              <v-textarea\n                v-model=\"config.description\"\n                label=\"插件描述\"\n                variant=\"outlined\"\n                rows=\"3\"\n                hint=\"简要说明插件的功能和用途\"\n              ></v-textarea>\n            </v-col>\n          </v-row>\n          <!-- 功能配置区域 -->\n          <div class=\"text-subtitle-1 font-weight-bold mt-4 mb-2\">功能配置</div>\n          <v-row>\n            <v-col cols=\"12\">\n              <v-select\n                v-model=\"config.update_interval\"\n                label=\"更新频率\"\n                :items=\"updateIntervalOptions\"\n                variant=\"outlined\"\n                item-title=\"text\"\n                item-value=\"value\"\n              ></v-select>\n            </v-col>\n          </v-row>\n          <!-- API配置区域 -->\n          <div class=\"text-subtitle-1 font-weight-bold mt-4 mb-2\">API设置</div>\n          <v-row>\n            <v-col cols=\"12\" md=\"6\">\n              <v-text-field\n                v-model=\"config.api_url\"\n                label=\"API地址\"\n                variant=\"outlined\"\n                hint=\"外部服务API地址\"\n                :rules=\"[v => !v || v.startsWith('http') || '请输入有效的URL']\"\n              ></v-text-field>\n            </v-col>\n            <v-col cols=\"12\" md=\"6\">\n              <v-text-field\n                v-model=\"config.api_key\"\n                label=\"API密钥\"\n                variant=\"outlined\"\n                :append-inner-icon=\"showApiKey ? 'mdi-eye-off' : 'mdi-eye'\"\n                :type=\"showApiKey ? 'text' : 'password'\"\n                @click:append-inner=\"showApiKey = !showApiKey\"\n              ></v-text-field>\n            </v-col>\n          </v-row>\n          <!-- 高级选项区域 -->\n          <v-expansion-panels variant=\"accordion\">\n            <v-expansion-panel>\n              <v-expansion-panel-title>高级选项</v-expansion-panel-title>\n              <v-expansion-panel-text>\n                <v-slider\n                  v-model=\"config.concurrent_tasks\"\n                  label=\"并发任务数\"\n                  min=\"1\"\n                  max=\"10\"\n                  step=\"1\"\n                  thumb-label\n                ></v-slider>\n\n                <v-combobox\n                  v-model=\"config.tags\"\n                  label=\"标签\"\n                  variant=\"outlined\"\n                  chips\n                  multiple\n                  closable-chips\n                ></v-combobox>\n              </v-expansion-panel-text>\n            </v-expansion-panel>\n          </v-expansion-panels>\n        </v-form>\n      </v-card-text>\n      <v-card-actions>\n        <v-btn color=\"secondary\" @click=\"resetForm\">重置</v-btn>\n        <v-spacer></v-spacer>\n        <v-btn color=\"primary\" :disabled=\"!isFormValid\" @click=\"saveConfig\" :loading=\"saving\">保存配置</v-btn>\n      </v-card-actions>\n    </v-card>\n  </div>\n</template>\n\n<script setup>\nimport { ref, reactive, onMounted } from 'vue'\n\n// 接收初始配置\nconst props = defineProps({\n  initialConfig: {\n    type: Object,\n    default: () => ({}),\n  },\n  api: {\n    type: Object,\n    default: () => {},\n  },\n})\n\n// 表单状态\nconst form = ref(null)\nconst isFormValid = ref(true)\nconst error = ref(null)\nconst saving = ref(false)\nconst showApiKey = ref(false)\n\n// 更新频率选项\nconst updateIntervalOptions = [\n  { text: '5分钟', value: 5 },\n  { text: '15分钟', value: 15 },\n  { text: '30分钟', value: 30 },\n  { text: '1小时', value: 60 },\n  { text: '2小时', value: 120 },\n  { text: '6小时', value: 360 },\n  { text: '12小时', value: 720 },\n  { text: '1天', value: 1440 },\n]\n\n// 配置数据，使用默认值和初始配置合并\nconst defaultConfig = {\n  name: '我的插件',\n  description: '',\n  enable: true,\n  update_interval: 60,\n  api_url: '',\n  api_key: '',\n  concurrent_tasks: 3,\n  tags: [],\n}\n\n// 合并默认配置和初始配置\nconst config = reactive({ ...defaultConfig })\n\n// 初始化配置\nonMounted(() => {\n  // 加载初始配置\n  if (props.initialConfig) {\n    Object.keys(props.initialConfig).forEach(key => {\n      if (key in config) {\n        config[key] = props.initialConfig[key]\n      }\n    })\n  }\n})\n\n// 自定义事件，用于保存配置\nconst emit = defineEmits(['save', 'close', 'switch'])\n\n// 保存配置\nasync function saveConfig() {\n  if (!isFormValid.value) {\n    error.value = '请修正表单错误'\n    return\n  }\n\n  saving.value = true\n  error.value = null\n\n  try {\n    // 模拟API调用等待\n    await new Promise(resolve => setTimeout(resolve, 1000))\n\n    // 发送保存事件\n    emit('save', { ...config })\n  } catch (err) {\n    console.error('保存配置失败:', err)\n    error.value = err.message || '保存配置失败'\n  } finally {\n    saving.value = false\n  }\n}\n\n// 重置表单\nfunction resetForm() {\n  Object.keys(defaultConfig).forEach(key => {\n    config[key] = defaultConfig[key]\n  })\n\n  if (form.value) {\n    form.value.resetValidation()\n  }\n}\n\n// 通知主应用关闭组件\nfunction notifyClose() {\n  emit('close')\n}\n</script>\n"
  },
  {
    "path": "examples/plugin-component/src/components/Dashboard.vue",
    "content": "<template>\n  <div class=\"dashboard-widget\">\n    <v-card v-if=\"!config?.attrs?.border\" flat>\n      <v-card-text class=\"pa-0\">\n        <div class=\"dashboard-content\">\n          <!-- 加载中状态 -->\n          <div v-if=\"loading\" class=\"d-flex justify-center align-center py-4\">\n            <v-progress-circular indeterminate color=\"primary\"></v-progress-circular>\n          </div>\n\n          <!-- 数据内容 -->\n          <div v-else>\n            <!-- 数据图表 -->\n            <div v-if=\"chartData\" class=\"chart-container\">\n              <v-chart class=\"chart\" :option=\"chartOptions\" autoresize />\n            </div>\n\n            <!-- 数据列表 -->\n            <v-list v-if=\"items.length\" density=\"compact\" class=\"py-0\">\n              <v-list-item v-for=\"(item, index) in items\" :key=\"index\" :title=\"item.title\" :subtitle=\"item.subtitle\">\n                <template v-slot:prepend>\n                  <v-avatar :color=\"getStatusColor(item.status)\" size=\"small\">\n                    <v-icon size=\"small\" color=\"white\">{{ getStatusIcon(item.status) }}</v-icon>\n                  </v-avatar>\n                </template>\n                <template v-slot:append v-if=\"item.value\">\n                  <span class=\"text-caption\">{{ item.value }}</span>\n                </template>\n              </v-list-item>\n            </v-list>\n          </div>\n        </div>\n      </v-card-text>\n    </v-card>\n\n    <!-- 带边框的卡片 -->\n    <v-card v-else>\n      <v-card-item>\n        <v-card-title>{{ config?.attrs?.title || '仪表板组件' }}</v-card-title>\n        <v-card-subtitle v-if=\"config?.attrs?.subtitle\">{{ config.attrs.subtitle }}</v-card-subtitle>\n      </v-card-item>\n\n      <v-card-text>\n        <!-- 加载中状态 -->\n        <div v-if=\"loading\" class=\"d-flex justify-center align-center py-4\">\n          <v-progress-circular indeterminate color=\"primary\"></v-progress-circular>\n        </div>\n\n        <!-- 数据内容 -->\n        <div v-else>\n          <!-- 数据图表 -->\n          <div v-if=\"chartData\" class=\"chart-container\">\n            <v-chart class=\"chart\" :option=\"chartOptions\" autoresize />\n          </div>\n\n          <!-- 数据列表 -->\n          <v-list v-if=\"items.length\" density=\"compact\" class=\"rounded pa-0\">\n            <v-list-item v-for=\"(item, index) in items\" :key=\"index\" :title=\"item.title\" :subtitle=\"item.subtitle\">\n              <template v-slot:prepend>\n                <v-avatar :color=\"getStatusColor(item.status)\" size=\"small\">\n                  <v-icon size=\"small\" color=\"white\">{{ getStatusIcon(item.status) }}</v-icon>\n                </v-avatar>\n              </template>\n              <template v-slot:append v-if=\"item.value\">\n                <span class=\"text-caption\">{{ item.value }}</span>\n              </template>\n            </v-list-item>\n          </v-list>\n        </div>\n      </v-card-text>\n    </v-card>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted, onUnmounted } from 'vue'\nimport VChart from 'vue-echarts'\nimport { use } from 'echarts/core'\nimport { CanvasRenderer } from 'echarts/renderers'\nimport { LineChart, PieChart } from 'echarts/charts'\nimport { GridComponent, TooltipComponent, LegendComponent, TitleComponent } from 'echarts/components'\n\n// 注册ECharts组件\ntry {\n  use([CanvasRenderer, LineChart, PieChart, GridComponent, TooltipComponent, LegendComponent, TitleComponent])\n} catch (e) {\n  console.warn('ECharts components registration failed', e)\n}\n\n// 接收仪表板配置\nconst props = defineProps({\n  config: {\n    type: Object,\n    default: () => ({}),\n  },\n  allowRefresh: {\n    type: Boolean,\n    default: true,\n  },\n})\n\n// 组件状态\nconst loading = ref(true)\nconst items = ref([])\nconst chartData = ref(null)\nlet refreshTimer = null\n\n// 获取状态图标\nfunction getStatusIcon(status) {\n  const icons = {\n    'success': 'mdi-check-circle',\n    'warning': 'mdi-alert',\n    'error': 'mdi-alert-circle',\n    'info': 'mdi-information',\n    'running': 'mdi-play-circle',\n    'pending': 'mdi-clock-outline',\n    'completed': 'mdi-check-circle-outline',\n  }\n  return icons[status] || 'mdi-help-circle'\n}\n\n// 获取状态颜色\nfunction getStatusColor(status) {\n  const colors = {\n    'success': 'success',\n    'warning': 'warning',\n    'error': 'error',\n    'info': 'info',\n    'running': 'primary',\n    'pending': 'secondary',\n    'completed': 'success',\n  }\n  return colors[status] || 'grey'\n}\n\n// 图表选项\nconst chartOptions = computed(() => {\n  if (!chartData.value) return {}\n\n  const { type, data } = chartData.value\n\n  if (type === 'line') {\n    return {\n      tooltip: {\n        trigger: 'axis',\n      },\n      xAxis: {\n        type: 'category',\n        data: data.xAxis,\n        axisLabel: {\n          color: '#888',\n        },\n      },\n      yAxis: {\n        type: 'value',\n        axisLabel: {\n          color: '#888',\n        },\n      },\n      series: data.series.map(series => ({\n        name: series.name,\n        type: 'line',\n        smooth: true,\n        data: series.data,\n        areaStyle: { opacity: 0.1 },\n      })),\n    }\n  }\n\n  if (type === 'pie') {\n    return {\n      tooltip: {\n        trigger: 'item',\n        formatter: '{a} <br/>{b}: {c} ({d}%)',\n      },\n      series: [\n        {\n          name: data.name,\n          type: 'pie',\n          radius: ['40%', '70%'],\n          avoidLabelOverlap: false,\n          itemStyle: {\n            borderRadius: 10,\n            borderColor: '#fff',\n            borderWidth: 2,\n          },\n          label: {\n            show: false,\n            position: 'center',\n          },\n          emphasis: {\n            label: {\n              show: true,\n              fontSize: '12',\n              fontWeight: 'bold',\n            },\n          },\n          labelLine: {\n            show: false,\n          },\n          data: data.items,\n        },\n      ],\n    }\n  }\n\n  return {}\n})\n\n// 获取仪表板数据\nasync function fetchDashboardData() {\n  if (!props.allowRefresh) return\n\n  loading.value = true\n\n  try {\n    // 模拟API调用\n    await new Promise(resolve => setTimeout(resolve, 1000))\n\n    // 随机决定显示饼图或折线图\n    const showPie = Math.random() > 0.5\n\n    if (showPie) {\n      // 饼图数据\n      chartData.value = {\n        type: 'pie',\n        data: {\n          name: '文件分布',\n          items: [\n            { value: Math.floor(Math.random() * 50) + 30, name: '电影' },\n            { value: Math.floor(Math.random() * 40) + 20, name: '电视剧' },\n            { value: Math.floor(Math.random() * 30) + 10, name: '动漫' },\n            { value: Math.floor(Math.random() * 20) + 5, name: '纪录片' },\n          ],\n        },\n      }\n    } else {\n      // 折线图数据\n      const days = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']\n      chartData.value = {\n        type: 'line',\n        data: {\n          xAxis: days,\n          series: [\n            {\n              name: '下载量',\n              data: days.map(() => Math.floor(Math.random() * 10) + 1),\n            },\n            {\n              name: '完成量',\n              data: days.map(() => Math.floor(Math.random() * 8) + 1),\n            },\n          ],\n        },\n      }\n    }\n\n    // 生成列表数据\n    const statuses = ['success', 'warning', 'error', 'info', 'running', 'pending', 'completed']\n    items.value = Array.from({ length: 5 }, (_, i) => {\n      const status = statuses[Math.floor(Math.random() * statuses.length)]\n      return {\n        title: `项目 ${i + 1}`,\n        subtitle: `上次更新: ${new Date().toLocaleTimeString()}`,\n        status,\n        value: Math.floor(Math.random() * 100) + '%',\n      }\n    })\n  } catch (error) {\n    console.error('获取仪表板数据失败:', error)\n  } finally {\n    loading.value = false\n  }\n}\n\n// 设置定时刷新\nfunction setupRefreshTimer() {\n  if (props.allowRefresh) {\n    // 每30秒刷新一次\n    refreshTimer = setInterval(() => {\n      fetchDashboardData()\n    }, 30000)\n  }\n}\n\n// 初始化\nonMounted(() => {\n  fetchDashboardData()\n  setupRefreshTimer()\n})\n\n// 清理\nonUnmounted(() => {\n  if (refreshTimer) {\n    clearInterval(refreshTimer)\n  }\n})\n</script>"
  },
  {
    "path": "examples/plugin-component/src/components/Page.vue",
    "content": "<template>\n  <div class=\"plugin-page\">\n    <v-card>\n      <v-card-item>\n        <v-card-title>{{ title }}</v-card-title>\n        <template #append>\n          <v-btn icon color=\"primary\" variant=\"text\" @click=\"notifyClose\">\n            <v-icon left>mdi-close</v-icon>\n          </v-btn>\n        </template>\n      </v-card-item>\n      <v-card-text>\n        <v-alert v-if=\"error\" type=\"error\" class=\"mb-4\">{{ error }}</v-alert>\n        <v-skeleton-loader v-if=\"loading\" type=\"card\"></v-skeleton-loader>\n        <div v-else>\n          <!-- 数据统计展示 -->\n          <v-row v-if=\"stats\">\n            <v-col v-for=\"(value, key) in stats\" :key=\"key\" cols=\"12\" sm=\"6\" md=\"4\">\n              <v-card variant=\"outlined\" class=\"text-center\">\n                <v-card-text>\n                  <div class=\"text-h4 font-weight-bold\">{{ value }}</div>\n                  <div class=\"text-subtitle-1\">{{ key }}</div>\n                </v-card-text>\n              </v-card>\n            </v-col>\n          </v-row>\n\n          <!-- 最近记录展示 -->\n          <div v-if=\"recentItems && recentItems.length\" class=\"mt-4\">\n            <div class=\"text-h6 mb-2\">最近记录</div>\n            <v-timeline density=\"compact\">\n              <v-timeline-item\n                v-for=\"(item, index) in recentItems\"\n                :key=\"index\"\n                :dot-color=\"getItemColor(item.type)\"\n                size=\"small\"\n              >\n                <div class=\"d-flex align-center\">\n                  <v-icon :color=\"getItemColor(item.type)\" size=\"small\" class=\"mr-2\">\n                    {{ getItemIcon(item.type) }}\n                  </v-icon>\n                  <span class=\"font-weight-medium\">{{ item.title }}</span>\n                </div>\n                <div class=\"text-caption text-secondary\">{{ item.time }}</div>\n              </v-timeline-item>\n            </v-timeline>\n          </div>\n\n          <!-- 当前状态 -->\n          <div class=\"mt-4 text-subtitle-2\">\n            <div>\n              <strong>状态:</strong>\n              <v-chip size=\"small\" :color=\"status === 'running' ? 'success' : 'warning'\">{{ status }}</v-chip>\n            </div>\n            <div><strong>最后更新:</strong> {{ lastUpdated }}</div>\n          </div>\n        </div>\n      </v-card-text>\n      <v-card-actions>\n        <v-btn color=\"primary\" @click=\"refreshData\" :loading=\"loading\">\n          <v-icon left>mdi-refresh</v-icon>\n          刷新数据\n        </v-btn>\n        <v-spacer></v-spacer>\n        <v-btn color=\"primary\" @click=\"notifySwitch\">\n          <v-icon left>mdi-cog</v-icon>\n          配置\n        </v-btn>\n      </v-card-actions>\n    </v-card>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted } from 'vue'\n\n// 接收初始配置\nconst props = defineProps({\n  model: {\n    type: Object,\n    default: () => {},\n  },\n  api: {\n    type: Object,\n    default: () => {},\n  },\n})\n\n// 组件状态\nconst title = ref('插件详情页面')\nconst loading = ref(true)\nconst error = ref(null)\nconst stats = ref(null)\nconst recentItems = ref([])\nconst status = ref('running')\nconst lastUpdated = ref('')\n\n// 自定义事件，用于通知主应用刷新数据\nconst emit = defineEmits(['action', 'switch', 'close'])\n\n// 获取状态图标\nfunction getItemIcon(type) {\n  const icons = {\n    'movie': 'mdi-movie',\n    'tv': 'mdi-television-classic',\n    'download': 'mdi-download',\n    'error': 'mdi-alert-circle',\n    'success': 'mdi-check-circle',\n  }\n  return icons[type] || 'mdi-information'\n}\n\n// 获取状态颜色\nfunction getItemColor(type) {\n  const colors = {\n    'movie': 'blue',\n    'tv': 'green',\n    'download': 'purple',\n    'error': 'red',\n    'success': 'success',\n  }\n  return colors[type] || 'grey'\n}\n\n// 获取和刷新数据\nasync function refreshData() {\n  loading.value = true\n  error.value = null\n\n  try {\n    // 模拟数据\n    stats.value = {\n      '电影': Math.floor(Math.random() * 100) + 50,\n      '电视剧': Math.floor(Math.random() * 100) + 30,\n      '动漫': Math.floor(Math.random() * 100) + 20,\n      '纪录片': Math.floor(Math.random() * 100) + 10,\n      '综艺': Math.floor(Math.random() * 100) + 5,\n    }\n\n    // 演示使用api模块调用插件接口\n    recentItems.value = await props.api.get(`plugin/MyPlugin/history`)\n\n    status.value = Math.random() > 0.2 ? 'running' : 'paused'\n    lastUpdated.value = new Date().toLocaleString()\n  } catch (err) {\n    console.error('获取数据失败:', err)\n    error.value = err.message || '获取数据失败'\n  } finally {\n    loading.value = false\n    // 通知主应用组件已更新\n    emit('action')\n  }\n}\n\n// 通知主应用切换到配置页面\nfunction notifySwitch() {\n  emit('switch')\n}\n\n// 通知主应用关闭组件\nfunction notifyClose() {\n  emit('close')\n}\n\n// 组件挂载时加载数据\nonMounted(() => {\n  refreshData()\n})\n</script>\n"
  },
  {
    "path": "examples/plugin-component/src/main.js",
    "content": "import { createApp } from 'vue'\nimport App from './App.vue'\nimport { createVuetify } from 'vuetify'\nimport * as components from 'vuetify/components'\nimport * as directives from 'vuetify/directives'\nimport defaults from './vuetify/defaults'\nimport theme from './vuetify/theme'\nimport 'vuetify/styles'\n\n// 创建Vuetify实例\nconst vuetify = createVuetify({\n  components,\n  directives,\n  theme,\n  defaults\n})\n\n// 创建应用\nconst app = createApp(App)\n\n// 使用插件\napp.use(vuetify)\n\n// 挂载应用\napp.mount('#app') \n"
  },
  {
    "path": "examples/plugin-component/src/vuetify/defaults.ts",
    "content": "export default {\n  IconBtn: {\n    icon: true,\n    color: 'default',\n    variant: 'text',\n    VIcon: {\n      size: 24,\n    },\n  },\n  VAlert: {\n    VBtn: {\n      color: undefined,\n    },\n  },\n  VAvatar: {\n    // ℹ️ Remove after next release\n    variant: 'flat',\n    VIcon: {\n      size: 24,\n    },\n  },\n  VBadge: {\n    // set v-badge default color to primary\n    color: 'primary',\n  },\n  VBtn: {\n    // set v-btn default color to primary\n    color: 'primary',\n    elevation: 0,\n  },\n  VCard: {\n    elevation: 0,\n    rounded: 'lg',\n  },\n  VMenu: {\n    elevation: 0,\n  },\n  VChip: {\n    elevation: 0,\n  },\n  VBottomSheet: {\n    elevation: 0,\n  },\n  VDialog: {\n    elevation: 0,\n    rounded: 'lg',\n  },\n  VExpansionPanels: {\n    elevation: 0,\n  },\n  VList: {\n    color: 'primary',\n    elevation: 0,\n  },\n  VListItem: {\n    rounded: 'md',\n  },\n  VPagination: {\n    activeColor: 'primary',\n  },\n  VTabs: {\n    // set v-tabs default color to primary\n    color: 'primary',\n    VSlideGroup: {\n      showArrows: true,\n    },\n  },\n  VTooltip: {\n    // set v-tooltip default location to top\n    location: 'top',\n  },\n  VCheckboxBtn: {\n    color: 'primary',\n    hideDetails: 'auto',\n  },\n  VCheckbox: {\n    // set v-checkbox default color to primary\n    color: 'primary',\n    hideDetails: 'auto',\n  },\n  VRadioGroup: {\n    color: 'primary',\n    hideDetails: 'auto',\n  },\n  VRadio: {\n    color: 'primary',\n    hideDetails: 'auto',\n  },\n  VSelect: {\n    variant: 'outlined',\n    color: 'primary',\n    hideDetails: 'auto',\n    menuProps: { elevation: 0 },\n  },\n  VRangeSlider: {\n    // set v-range-slider default color to primary\n    color: 'primary',\n    density: 'comfortable',\n    thumbLabel: true,\n    hideDetails: 'auto',\n  },\n  VRating: {\n    // set v-rating default color to primary\n    color: 'rgba(var(--v-theme-on-background),0.23)',\n    activeColor: 'warning',\n    halfIncrements: true,\n  },\n  VProgressCircular: {\n    // set v-progress-circular default color to primary\n    color: 'primary',\n  },\n  VSlider: {\n    // set v-slider default color to primary\n    color: 'primary',\n    hideDetails: 'auto',\n  },\n  VTextField: {\n    variant: 'outlined',\n    color: 'primary',\n    hideDetails: 'auto',\n  },\n  VAutocomplete: {\n    variant: 'outlined',\n    color: 'primary',\n    hideDetails: 'auto',\n  },\n  VCombobox: {\n    variant: 'outlined',\n    color: 'primary',\n    hideDetails: 'auto',\n    menuProps: { elevation: 0 },\n  },\n  VFileInput: {\n    variant: 'outlined',\n    color: 'primary',\n    hideDetails: 'auto',\n  },\n  VTextarea: {\n    variant: 'outlined',\n    color: 'primary',\n    hideDetails: 'auto',\n  },\n  VSwitch: {\n    // set v-switch default color to primary\n    color: 'primary',\n    hideDetails: 'auto',\n  },\n}\n"
  },
  {
    "path": "examples/plugin-component/src/vuetify/theme.ts",
    "content": "import type { VuetifyOptions } from 'vuetify'\n\nconst theme: VuetifyOptions['theme'] = {\n  defaultTheme: 'light',\n  themes: {\n    light: {\n      dark: false,\n      colors: {\n        'primary': '#9155FD',\n        'secondary': '#8A8D93',\n        'on-secondary': '#FFFFFF',\n        'success': '#56CA00',\n        'info': '#16B1FF',\n        'warning': '#FFB400',\n        'error': '#FF4C51',\n        'on-primary': '#FFFFFF',\n        'on-success': '#FFFFFF',\n        'on-warning': '#FFFFFF',\n        'background': '#F4F5FA',\n        'on-background': '#3A3541',\n        'on-surface': '#3A3541',\n        'grey-50': '#FAFAFA',\n        'grey-100': '#F0F2F8',\n        'grey-200': '#EEEEEE',\n        'grey-300': '#E0E0E0',\n        'grey-400': '#BDBDBD',\n        'grey-500': '#9E9E9E',\n        'grey-600': '#757575',\n        'grey-700': '#616161',\n        'grey-800': '#424242',\n        'grey-900': '#212121',\n        'perfect-scrollbar-thumb': '#DBDADE',\n        'skin-bordered-background': '#FFFFFF',\n        'skin-bordered-surface': '#FFFFFF',\n      },\n\n      variables: {\n        'code-color': '#D400FF',\n        'overlay-scrim-background': '#3A3541',\n        'overlay-scrim-opacity': 0.5,\n        'hover-opacity': 0.04,\n        'focus-opacity': 0.1,\n        'selected-opacity': 0.12,\n        'activated-opacity': 0.1,\n        'pressed-opacity': 0.14,\n        'dragged-opacity': 0.1,\n        'border-color': '#3A3541',\n        'table-header-background': '#F9FAFC',\n        'custom-background': '#F9F8F9',\n\n        // Shadows\n        'shadow-key-umbra-opacity': 'rgba(var(--v-theme-on-surface), 0.08)',\n        'shadow-key-penumbra-opacity': 'rgba(var(--v-theme-on-surface), 0.12)',\n        'shadow-key-ambient-opacity': 'rgba(var(--v-theme-on-surface), 0.04)',\n      },\n    },\n    dark: {\n      dark: true,\n      colors: {\n        'primary': '#6E66ED',\n        'secondary': '#8A8D93',\n        'on-secondary': '#FFFFFF',\n        'success': '#56CA00',\n        'info': '#16B1FF',\n        'warning': '#FFB400',\n        'error': '#FF4C51',\n        'on-primary': '#FFFFFF',\n        'on-success': '#FFFFFF',\n        'on-warning': '#FFFFFF',\n        'background': '#0E1116',\n        'on-background': '#E7E3FC',\n        'surface': '#14161F',\n        'on-surface': '#E7E3FC',\n        'grey-50': '#2A2E42',\n        'grey-100': '#474360',\n        'grey-200': '#4A5072',\n        'grey-300': '#5E6692',\n        'grey-400': '#7983BB',\n        'grey-500': '#8692D0',\n        'grey-600': '#AAB3DE',\n        'grey-700': '#B6BEE3',\n        'grey-800': '#CFD3EC',\n        'grey-900': '#E7E9F6',\n        'perfect-scrollbar-thumb': '#4A5072',\n        'skin-bordered-background': '#312d4b',\n        'skin-bordered-surface': '#312d4b',\n      },\n      variables: {\n        'code-color': '#d400ff',\n        'overlay-scrim-background': '#191D21',\n        'overlay-scrim-opacity': 0.6,\n        'hover-opacity': 0.04,\n        'focus-opacity': 0.1,\n        'selected-opacity': 0.12,\n        'activated-opacity': 0.1,\n        'pressed-opacity': 0.14,\n        'dragged-opacity': 0.1,\n        'border-color': '#E7E3FC',\n        'table-header-background': '#14161F',\n        'custom-background': '#373452',\n        // Shadows\n        'shadow-key-umbra-opacity': 'rgba(20, 18, 33, 0.08)',\n        'shadow-key-penumbra-opacity': 'rgba(20, 18, 33, 0.12)',\n        'shadow-key-ambient-opacity': 'rgba(20, 18, 33, 0.04)',\n      },\n    },\n    purple: {\n      dark: true,\n      colors: {\n        'primary': '#9155FD',\n        'secondary': '#8A8D93',\n        'on-secondary': '#FFFFFF',\n        'success': '#56CA00',\n        'info': '#16B1FF',\n        'warning': '#FFB400',\n        'error': '#FF4C51',\n        'on-primary': '#FFFFFF',\n        'on-success': '#FFFFFF',\n        'on-warning': '#FFFFFF',\n        'background': '#28243D',\n        'on-background': '#E7E3FC',\n        'surface': '#312D4B',\n        'on-surface': '#E7E3FC',\n        'grey-50': '#2A2E42',\n        'grey-100': '#474360',\n        'grey-200': '#4A5072',\n        'grey-300': '#5E6692',\n        'grey-400': '#7983BB',\n        'grey-500': '#8692D0',\n        'grey-600': '#AAB3DE',\n        'grey-700': '#B6BEE3',\n        'grey-800': '#CFD3EC',\n        'grey-900': '#E7E9F6',\n        'perfect-scrollbar-thumb': '#4A5072',\n        'skin-bordered-background': '#312d4b',\n        'skin-bordered-surface': '#312d4b',\n      },\n      variables: {\n        'code-color': '#d400ff',\n        'overlay-scrim-background': '#2C2942',\n        'overlay-scrim-opacity': 0.6,\n        'hover-opacity': 0.04,\n        'focus-opacity': 0.1,\n        'selected-opacity': 0.12,\n        'activated-opacity': 0.1,\n        'pressed-opacity': 0.14,\n        'dragged-opacity': 0.1,\n        'border-color': '#E7E3FC',\n        'table-header-background': '#3D3759',\n        'custom-background': '#373452',\n\n        // Shadows\n        'shadow-key-umbra-opacity': 'rgba(20, 18, 33, 0.08)',\n        'shadow-key-penumbra-opacity': 'rgba(20, 18, 33, 0.12)',\n        'shadow-key-ambient-opacity': 'rgba(20, 18, 33, 0.04)',\n      },\n    },\n    transparent: {\n      dark: true,\n      colors: {\n        'primary': '#A370F7',\n        'secondary': '#8A8D93',\n        'on-secondary': '#FFFFFF',\n        'success': '#66BB6A',\n        'info': '#42A5F5',\n        'warning': '#FFA726',\n        'error': '#EF5350',\n        'on-primary': '#FFFFFF',\n        'on-success': '#FFFFFF',\n        'on-warning': '#FFFFFF',\n        'background': '#000000',\n        'on-background': '#E7E3FC',\n        'surface': 'rgba(30, 30, 30, 0.3)',\n        'on-surface': '#E7E3FC',\n        'surface-variant': 'rgba(30, 30, 30, 0.2)',\n        'on-surface-variant': 'rgba(255, 255, 255, 0.65)',\n        'grey-50': 'rgba(42, 46, 66, 0.15)',\n        'grey-100': 'rgba(71, 67, 96, 0.15)',\n        'grey-200': 'rgba(74, 80, 114, 0.15)',\n        'grey-300': 'rgba(94, 102, 146, 0.15)',\n        'grey-400': 'rgba(121, 131, 187, 0.15)',\n        'grey-500': 'rgba(134, 146, 208, 0.15)',\n        'grey-600': 'rgba(170, 179, 222, 0.15)',\n        'grey-700': 'rgba(182, 190, 227, 0.15)',\n        'grey-800': 'rgba(207, 211, 236, 0.15)',\n        'grey-900': 'rgba(231, 233, 246, 0.15)',\n        'perfect-scrollbar-thumb': 'rgba(158, 158, 190, 0.4)',\n        'skin-bordered-background': 'rgba(30, 30, 30, 0.3)',\n        'skin-bordered-surface': 'rgba(30, 30, 30, 0.3)',\n        'card-background': 'rgba(30, 30, 30, 0.3)',\n      },\n      variables: {\n        'code-color': '#6D9EEB',\n        'overlay-scrim-background': '0, 0, 0',\n        'overlay-scrim-opacity': 0.7,\n        'hover-opacity': 0.1,\n        'focus-opacity': 0.15,\n        'selected-opacity': 0.2,\n        'activated-opacity': 0.15,\n        'pressed-opacity': 0.2,\n        'dragged-opacity': 0.15,\n        'border-color': '#E7E3FC',\n        'table-header-background': 'rgba(30, 30, 30, 0.3)',\n        'custom-background': 'rgba(30, 30, 30, 0.3)',\n        'card-background': 'rgba(30, 30, 30, 0.3)',\n\n        // Shadows\n        'shadow-key-umbra-opacity': 'rgba(0, 0, 0, 0.07)',\n        'shadow-key-penumbra-opacity': 'rgba(0, 0, 0, 0.1)',\n        'shadow-key-ambient-opacity': 'rgba(0, 0, 0, 0.05)',\n      },\n    },\n  },\n}\n\nexport default theme\n"
  },
  {
    "path": "examples/plugin-component/vite.config.js",
    "content": "import { defineConfig } from 'vite'\nimport vue from '@vitejs/plugin-vue'\nimport federation from '@originjs/vite-plugin-federation'\n\nexport default defineConfig({\n  plugins: [\n    vue(),\n    federation({\n      name: 'MyPlugin',\n      filename: 'remoteEntry.js',\n      exposes: {\n        './Page': './src/components/Page.vue',\n        './Config': './src/components/Config.vue',\n        './Dashboard': './src/components/Dashboard.vue',\n        './AppPage': './src/components/AppPage.vue',\n        './AppPageSettings': './src/components/AppPageSettings.vue',\n      },\n      shared: {\n        vue: {\n          requiredVersion: false,\n          generate: false,\n        },\n        vuetify: {\n          requiredVersion: false,\n          generate: false,\n          singleton: true,\n        },\n        'vuetify/styles': {\n          requiredVersion: false,\n          generate: false,\n          singleton: true,\n        },\n      },\n      format: 'esm'\n    })\n  ],\n  build: {\n    target: 'esnext',   // 必须设置为esnext以支持顶层await\n    minify: false,      // 开发阶段建议关闭混淆\n    cssCodeSplit: true, // 改为true以便能分离样式文件\n  },\n  css: {\n    preprocessorOptions: {\n      scss: {\n        additionalData: '/* 覆盖vuetify样式 */',\n      }\n    },\n    postcss: {\n      plugins: [\n        {\n          postcssPlugin: 'internal:charset-removal',\n          AtRule: {\n            charset: (atRule) => {\n              if (atRule.name === 'charset') {\n                atRule.remove();\n              }\n            }\n          }\n        },\n        {\n          postcssPlugin: 'vuetify-filter',\n          Root(root) {\n            // 过滤掉所有vuetify相关的CSS\n            root.walkRules(rule => {\n              if (rule.selector && (\n                  rule.selector.includes('.v-') || \n                  rule.selector.includes('.mdi-'))) {\n                rule.remove();\n              }\n            });\n          }\n        }\n      ]\n    }\n  },\n  server: {\n    port: 5001,   // 使用不同于主应用的端口\n    cors: true,   // 启用CORS\n    origin: 'http://localhost:5001'\n  },\n}) \n"
  },
  {
    "path": "index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\" style=\"\n    overflow: hidden auto;\n    min-block-size: 100vh;\n    min-block-size: 100dvh;\n    --safe-area-inset-bottom: env(safe-area-inset-bottom);\n    --safe-area-inset-top: env(safe-area-inset-top);\n    background: var(--initial-loader-bg, #fff);\n  \">\n\n<head>\n  <title>MoviePilot</title>\n  <meta charset=\"UTF-8\" />\n  <!-- 核心viewport设置 - 针对PWA优化 -->\n  <meta name=\"viewport\"\n    content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no, interactive-widget=resizes-content\" />\n\n  <!-- 防止缩放和选择，提供原生应用体验 -->\n  <meta name=\"format-detection\" content=\"telephone=no, date=no, email=no, address=no\" />\n\n  <!-- 基础信息 -->\n  <meta name=\"description\" content=\"MoviePilot - 智能影视媒体库管理工具\" />\n  <meta name=\"author\" content=\"MoviePilot\" />\n  <meta name=\"keywords\" content=\"MoviePilot,影视,媒体库,管理\" />\n\n  <!-- 安全和隐私 -->\n  <meta name=\"Robots\" content=\"noindex,nofollow,noarchive\" />\n  <meta name=\"referrer\" content=\"no-referrer\" />\n\n  <!-- PWA - 基础图标 -->\n  <link rel=\"icon\" type=\"image/png\" href=\"/favicon.ico\" />\n  <link rel=\"icon\" type=\"image/png\" href=\"/logo.png\" sizes=\"any\" />\n  <link rel=\"icon\" type=\"image/svg+xml\" href=\"/logo.svg\" />\n\n  <!-- iOS Safari PWA 优化 -->\n  <link rel=\"apple-touch-icon\" href=\"/apple-touch-icon.png\" />\n  <link rel=\"apple-touch-icon-precomposed\" href=\"/apple-touch-icon.png\" />\n  <link rel=\"apple-touch-startup-image\" href=\"/splash/apple-splash.png\" />\n\n  <!-- iOS Safari 全屏模式 -->\n  <meta name=\"apple-mobile-web-app-capable\" content=\"yes\" />\n  <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"black-translucent\" />\n  <meta name=\"apple-mobile-web-app-title\" content=\"MoviePilot\" />\n\n  <!-- iOS Safari 防止自动识别 -->\n  <meta name=\"apple-mobile-web-app-orientations\" content=\"portrait\" />\n\n  <!-- Android Chrome PWA 优化 -->\n  <meta name=\"mobile-web-app-capable\" content=\"yes\" />\n  <meta name=\"mobile-web-app-status-bar-style\" content=\"black-translucent\" />\n  <meta name=\"mobile-web-app-title\" content=\"MoviePilot\" />\n\n  <!-- Microsoft Windows PWA -->\n  <meta name=\"msapplication-TileColor\" content=\"#0E1116\" />\n  <meta name=\"msapplication-TileImage\" content=\"/android-chrome-192x192.png\" />\n  <meta name=\"msapplication-config\" content=\"none\" />\n  <meta name=\"msapplication-tap-highlight\" content=\"no\" />\n  <meta name=\"msapplication-navbutton-color\" content=\"#0E1116\" />\n\n  <!-- 主题色彩 - 适配深色和浅色模式 -->\n  <meta name=\"theme-color\" content=\"#0E1116\" media=\"(prefers-color-scheme: dark)\" />\n  <meta name=\"theme-color\" content=\"#F4F5FA\" media=\"(prefers-color-scheme: light)\" />\n  <meta name=\"color-scheme\" content=\"dark light\" />\n\n  <!-- 屏幕方向锁定 -->\n  <meta name=\"screen-orientation\" content=\"portrait\" />\n  <meta name=\"x5-orientation\" content=\"portrait\" />\n  <meta name=\"x5-fullscreen\" content=\"true\" />\n  <meta name=\"x5-page-mode\" content=\"app\" />\n\n  <!-- UC浏览器优化 -->\n  <meta name=\"browsermode\" content=\"application\" />\n  <meta name=\"wap-font-scale\" content=\"no\" />\n\n  <!-- 360浏览器优化 -->\n  <meta name=\"renderer\" content=\"webkit\" />\n\n  <!-- 触摸优化 -->\n  <meta name=\"HandheldFriendly\" content=\"True\" />\n  <meta name=\"MobileOptimized\" content=\"320\" />\n\n  <!-- 缓存控制 -->\n  <meta http-equiv=\"Cache-Control\" content=\"no-cache, no-store, must-revalidate\" />\n  <meta http-equiv=\"Pragma\" content=\"no-cache\" />\n  <meta http-equiv=\"Expires\" content=\"0\" />\n\n  <!-- DNS预解析和预连接 -->\n  <link rel=\"dns-prefetch\" href=\"//fonts.googleapis.com\" />\n  <link rel=\"dns-prefetch\" href=\"//cdn.jsdelivr.net\" />\n  <link rel=\"dns-prefetch\" href=\"//image.tmdb.org\" />\n  <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" crossorigin />\n  <link rel=\"preconnect\" href=\"https://cdn.jsdelivr.net\" crossorigin />\n\n  <style>\n    #app {\n      min-block-size: 100%;\n      -webkit-overflow-scrolling: touch;\n      overscroll-behavior: contain;\n    }\n\n    #loading-bg {\n      position: fixed;\n      z-index: 99999;\n      display: block;\n      background: var(--initial-loader-bg, #fff);\n      block-size: 100vh;\n      inline-size: 100vw;\n      transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;\n    }\n\n    .loading-logo {\n      position: absolute;\n      inset-block-start: 35%;\n      inset-inline-start: calc(50% - 5rem);\n      transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;\n    }\n\n    .loading-complete .loading-logo {\n      filter: blur(10px);\n      opacity: 0;\n      transform: scale(1.5);\n    }\n\n    .loading-complete {\n      filter: blur(15px);\n      opacity: 0;\n      transform: scale(1.2);\n    }\n\n    .loading {\n      position: absolute;\n      box-sizing: border-box;\n      border: 3px solid transparent;\n      border-radius: 50%;\n      block-size: 55px;\n      inline-size: 55px;\n      inset-block-start: 80%;\n      inset-inline-start: calc(50% - 27.5px);\n      transition: opacity 0.6s ease;\n    }\n\n    .loading-complete .loading {\n      opacity: 0;\n    }\n\n    .loading .effect-1,\n    .loading .effect-2,\n    .loading .effect-3 {\n      position: absolute;\n      box-sizing: border-box;\n      border: 3px solid transparent;\n      border-radius: 50%;\n      block-size: 100%;\n      border-inline-start: 3px solid var(--initial-loader-color, #eee);\n      inline-size: 100%;\n    }\n\n    .loading .effect-1 {\n      animation: rotate 1s ease infinite;\n    }\n\n    .loading .effect-2 {\n      animation: rotate-opacity 1s ease infinite 0.1s;\n    }\n\n    .loading .effect-3 {\n      animation: rotate-opacity 1s ease infinite 0.2s;\n    }\n\n    .loading .effects {\n      transition: all 0.3s ease;\n    }\n\n    @keyframes rotate {\n      0% {\n        transform: rotate(0deg);\n      }\n\n      100% {\n        transform: rotate(1turn);\n      }\n    }\n\n    @keyframes rotate-opacity {\n      0% {\n        opacity: 0.1;\n        transform: rotate(0deg);\n      }\n\n      100% {\n        opacity: 1;\n        transform: rotate(1turn);\n      }\n    }\n\n    /* 超时通知样式 */\n    #loading-timeout {\n      position: absolute;\n      z-index: 2500;\n      display: none;\n      inset-block-end: 20px;\n      inset-inline-start: 50%;\n      transform: translateX(-50%);\n      background: rgba(0, 0, 0, 0.8);\n      color: #fff;\n      padding: 12px 24px;\n      border-radius: 12px;\n      font-size: 14px;\n      font-family: sans-serif;\n      text-align: center;\n      box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);\n      white-space: nowrap;\n      backdrop-filter: blur(4px);\n      border: 1px solid rgba(255, 255, 255, 0.1);\n    }\n\n    #timeout-btn {\n      color: var(--initial-loader-color, #9155FD);\n      text-decoration: none;\n      font-weight: bold;\n      margin-inline-start: 8px;\n      border-bottom: 1px solid var(--initial-loader-color, #9155FD);\n    }\n  </style>\n\n  <script>\n    // 检测系统主题是否为深色模式\n    function checkPrefersColorSchemeIsDark() {\n      try {\n        return window.matchMedia('(prefers-color-scheme: dark)').matches\n      } catch (e) {\n        return false\n      }\n    }\n\n    // 主题色彩初始化\n    let loaderColor = localStorage.getItem('materio-initial-loader-bg')\n    let primaryColor = localStorage.getItem('materio-initial-loader-color')\n\n    // 检查主题设置\n    const savedTheme = localStorage.getItem('theme') || 'auto'\n    const isAutoTheme = savedTheme === 'auto'\n\n    // 如果是自动主题或者没有保存的背景色，根据系统主题设置背景色\n    if (isAutoTheme || !loaderColor) {\n      loaderColor = checkPrefersColorSchemeIsDark() ? '#0E1116' : '#FFFFFF'\n    }\n    if (!primaryColor) {\n      primaryColor = '#9155FD'\n    }\n\n    // 应用主题色彩\n    document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)\n    document.documentElement.style.setProperty('--initial-loader-color', primaryColor)\n\n    // 状态栏适配\n    if (window.navigator.standalone) {\n      document.documentElement.style.setProperty('--status-bar-height', '20px')\n    }\n\n    // 安全区域适配\n    function updateSafeArea() {\n      const safeAreaTop = getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-top)')\n      const safeAreaBottom = getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-bottom)',\n      )\n\n      if (safeAreaTop) document.documentElement.style.setProperty('--safe-area-top', safeAreaTop)\n      if (safeAreaBottom) document.documentElement.style.setProperty('--safe-area-bottom', safeAreaBottom)\n    }\n\n    updateSafeArea()\n    window.addEventListener('resize', updateSafeArea)\n    window.addEventListener('orientationchange', updateSafeArea)\n\n    // 清除缓存处理逻辑\n    window.clearAndReload = async function() {\n      try {\n        // 1. 清除所有缓存\n        if ('caches' in window) {\n          const cacheNames = await caches.keys()\n          await Promise.all(cacheNames.map(name => caches.delete(name)))\n          console.log('[VersionChecker] 已清除所有缓存')\n        }\n        // 2. 注销 Service Worker\n        if ('serviceWorker' in navigator) {\n          const registrations = await navigator.serviceWorker.getRegistrations()\n          await Promise.all(registrations.map(registration => registration.unregister()))\n          console.log('[VersionChecker] 已注销所有 Service Worker')\n        }\n      } catch (e) {\n        console.error('[VersionChecker] 清除缓存时出错:', e)\n      } finally {\n        // 3. 重载页面\n        const url = new URL(window.location.href)\n        url.searchParams.set('_t', Date.now().toString())\n        window.location.replace(url.pathname + url.search + url.hash)\n      }\n    };\n\n    setTimeout(function() {\n      const timeoutEl = document.getElementById('loading-timeout');\n      if (timeoutEl) {\n        // 适配多语言\n        const lang = navigator.language || 'zh-CN';\n        const messages = {\n          'zh-CN': {\n            text: '页面加载似乎遇到了阻碍，请尝试',\n            btn: '清除缓存'\n          },\n          'zh-TW': {\n            text: '頁面載入似乎遇到了阻礙，請嘗試',\n            btn: '清除快取'\n          },\n          'en-US': {\n            text: 'Page loading seems to be blocked, please try',\n            btn: 'Clear Cache'\n          }\n        };\n        \n        // 默认匹配前缀，如 en-GB 匹配 en-US 的逻辑\n        let msg = messages['zh-CN'];\n        if (lang.startsWith('zh-TW') || lang.startsWith('zh-HK')) {\n          msg = messages['zh-TW'];\n        } else if (lang.startsWith('en')) {\n          msg = messages['en-US'];\n        }\n\n        const textNode = document.createTextNode(msg.text + ' ');\n        const btnLink = document.createElement('a');\n        btnLink.href = 'javascript:void(0)';\n        btnLink.id = 'timeout-btn';\n        btnLink.onclick = window.clearAndReload;\n        btnLink.textContent = msg.btn;\n        \n        timeoutEl.innerHTML = '';\n        timeoutEl.appendChild(textNode);\n        timeoutEl.appendChild(btnLink);\n        timeoutEl.style.display = 'block';\n      }\n    }, 15000); // 15秒后显示超时提示\n  </script>\n</head>\n\n<body style=\"margin: 0; overflow: hidden; overscroll-behavior: none; -webkit-overflow-scrolling: touch\">\n  <div id=\"loading-bg\">\n    <div class=\"loading-logo\">\n      <!-- Logo -->\n      <img src=\"/logo.svg\" alt=\"MoviePilot\" width=\"160px\" height=\"160px\" />\n    </div>\n    <div class=\"loading\">\n      <div class=\"effect-1 effects\"></div>\n      <div class=\"effect-2 effects\"></div>\n      <div class=\"effect-3 effects\"></div>\n    </div>\n    <!-- 超时提示 - 默认隐藏 -->\n    <div id=\"loading-timeout\"></div>\n  </div>\n  <div id=\"app\"></div>\n  <script type=\"module\" src=\"/src/main.ts\"></script>\n</body>\n\n</html>"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"moviepilot\",\n  \"version\": \"2.10.5\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"bin\": \"dist/service.js\",\n  \"scripts\": {\n    \"dev\": \"vite --host\",\n    \"prebuild\": \"npm run build:icons\",\n    \"build\": \"vite build\",\n    \"preview\": \"vite preview --port 5050\",\n    \"typecheck\": \"vue-tsc --noEmit\",\n    \"lint\": \"eslint . -c .eslintrc.js --fix --ext .ts,.js,.vue,.tsx,.jsx\",\n    \"build:icons\": \"tsc -b src/@iconify && node src/@iconify/build-icons.js\",\n    \"postinstall\": \"npm run build:icons\",\n    \"pkg\": \"pkg . -t node18-win-x64 -o MoviePilot-Frontend.exe\"\n  },\n  \"pkg\": {\n    \"assets\": [\n      \"dist/**/*\"\n    ]\n  },\n  \"dependencies\": {\n    \"@fullcalendar/core\": \"^6.1.15\",\n    \"@fullcalendar/daygrid\": \"^6.1.15\",\n    \"@fullcalendar/interaction\": \"^6.1.15\",\n    \"@fullcalendar/list\": \"^6.1.15\",\n    \"@fullcalendar/timegrid\": \"^6.1.15\",\n    \"@fullcalendar/vue3\": \"^6.1.15\",\n    \"@iconify/utils\": \"^2.2.1\",\n    \"@types/crypto-js\": \"^4.2.2\",\n    \"@types/js-cookie\": \"^3.0.6\",\n    \"@vue-flow/background\": \"^1.3.2\",\n    \"@vue-flow/controls\": \"^1.1.2\",\n    \"@vue-flow/core\": \"^1.42.1\",\n    \"@vue-flow/minimap\": \"^1.5.2\",\n    \"@vue-flow/node-resizer\": \"^1.4.0\",\n    \"@vue-flow/node-toolbar\": \"^1.1.0\",\n    \"@vue-js-cron/vuetify\": \"^5.0.9\",\n    \"@vueuse/core\": \"^12.4.0\",\n    \"@vueuse/math\": \"^12.4.0\",\n    \"ace-builds\": \"^1.37.4\",\n    \"apexcharts\": \"^4.0.0\",\n    \"axios\": \"^1.7.9\",\n    \"body-scroll-lock\": \"^3.1.5\",\n    \"colorthief\": \"^2.6.0\",\n    \"copy-to-clipboard\": \"^3.3.3\",\n    \"crypto-js\": \"^4.2.0\",\n    \"dayjs\": \"^1.11.13\",\n    \"express\": \"^4.21.2\",\n    \"express-http-proxy\": \"^2.1.1\",\n    \"http-proxy-middleware\": \"^3.0.0\",\n    \"js-cookie\": \"^3.0.5\",\n    \"lodash-es\": \"^4.17.21\",\n    \"markdown-it\": \"^14.1.0\",\n    \"markdown-it-link-attributes\": \"^4.0.1\",\n    \"mousetrap\": \"^1.6.5\",\n    \"nprogress\": \"^0.2.0\",\n    \"pinia\": \"^3.0.1\",\n    \"pinia-plugin-persistedstate\": \"^4.2.0\",\n    \"qrcode\": \"^1.5.4\",\n    \"sass\": \"^1.83.4\",\n    \"tailwindcss\": \"^ 3.4.17\",\n    \"vue\": \"^3.5.13\",\n    \"vue-router\": \"^4.5.0\",\n    \"vue-toastification\": \"^2.0.0-rc.5\",\n    \"vue3-ace-editor\": \"^2.2.4\",\n    \"vue3-apexcharts\": \"^1.8.0\",\n    \"vue3-perfect-scrollbar\": \"^2.0.0\",\n    \"vuedraggable\": \"^4.1.0\",\n    \"vuetify\": \"3.7.3\",\n    \"webfontloader\": \"^1.6.28\"\n  },\n  \"devDependencies\": {\n    \"@iconify-json/line-md\": \"^1.2.13\",\n    \"@iconify-json/lucide\": \"^1.2.85\",\n    \"@iconify-json/material-symbols\": \"^1.2.51\",\n    \"@iconify-json/mdi\": \"^1.1.52\",\n    \"@iconify/tools\": \"^4.0.4\",\n    \"@iconify/vue\": \"^4.3.0\",\n    \"@intlify/unplugin-vue-i18n\": \"^6.0.3\",\n    \"@originjs/vite-plugin-federation\": \"^1.4.1\",\n    \"@tailwindcss/aspect-ratio\": \"^0.4.2\",\n    \"@types/body-scroll-lock\": \"^3.1.2\",\n    \"@types/lodash-es\": \"^4.17.12\",\n    \"@types/markdown-it\": \"^14.1.2\",\n    \"@types/markdown-it-link-attributes\": \"^3.0.5\",\n    \"@types/mousetrap\": \"^1.6.15\",\n    \"@types/node\": \"^20.1.4\",\n    \"@types/nprogress\": \"^0.2.3\",\n    \"@types/qrcode\": \"^1.5.6\",\n    \"@types/webfontloader\": \"^1.6.34\",\n    \"@typescript-eslint/eslint-plugin\": \"^8.20.0\",\n    \"@typescript-eslint/parser\": \"^8.20.0\",\n    \"@vitejs/plugin-vue\": \"^5.0.4\",\n    \"@vitejs/plugin-vue-jsx\": \"^4.1.1\",\n    \"autoprefixer\": \"^10.4.14\",\n    \"eslint\": \"^9.18.0\",\n    \"eslint-import-resolver-typescript\": \"^3.5.1\",\n    \"eslint-plugin-import\": \"^2.26.0\",\n    \"eslint-plugin-promise\": \"^7.2.1\",\n    \"eslint-plugin-regex\": \"^1.10.0\",\n    \"eslint-plugin-sonarjs\": \"^3.0.1\",\n    \"eslint-plugin-unicorn\": \"^56.0.1\",\n    \"eslint-plugin-vue\": \"^9.12.0\",\n    \"postcss\": \"^8.5.1\",\n    \"postcss-html\": \"^1.5.0\",\n    \"stylelint\": \"^16.13.2\",\n    \"stylelint-config-idiomatic-order\": \"^10.0.0\",\n    \"stylelint-config-standard-scss\": \"^14.0.0\",\n    \"stylelint-use-logical-spec\": \"5.0.1\",\n    \"terser\": \"^5.36.0\",\n    \"type-fest\": \"^4.15.0\",\n    \"typescript\": \"^5.0.4\",\n    \"unplugin-auto-import\": \"^19.0.0\",\n    \"unplugin-vue-components\": \"^28.0.0\",\n    \"unplugin-vue-define-options\": \"^1.5.3\",\n    \"vite\": \"^5.4.11\",\n    \"vite-plugin-pages\": \"^0.32.1\",\n    \"vite-plugin-pwa\": \"^0.21.1\",\n    \"vite-plugin-top-level-await\": \"^1.5.0\",\n    \"vite-plugin-vue-layouts\": \"^0.11.0\",\n    \"vite-plugin-vuetify\": \"2.0.4\",\n    \"vue-shepherd\": \"^4.1.0\",\n    \"vue-tsc\": \"^2.0.10\",\n    \"workbox-build\": \"^7.3.0\",\n    \"workbox-window\": \"^7.3.0\"\n  },\n  \"packageManager\": \"yarn@1.22.18\"\n}\n"
  },
  {
    "path": "postcss.config.js",
    "content": "export default {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n}\n"
  },
  {
    "path": "public/nginx.conf",
    "content": "worker_processes auto;\n\nevents {\n    worker_connections 1024;\n}\n\n\nhttp {\n\n    sendfile on;\n\n    keepalive_timeout 3600;\n\n    gzip on;\n    gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;\n    gzip_proxied any;\n    gzip_min_length 256;\n    gzip_vary on;\n    gzip_comp_level 6;\n\n    server {\n\n        include mime.types;\n        default_type application/octet-stream;\n\n        listen 3000;\n        listen [::]:3000;\n        server_name moviepilot;\n\n        location / {\n            # 主目录\n            expires off;\n            add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n            root html;\n            try_files $uri $uri/ /index.html;\n        }\n\n        location ~* \\.(js|css|png|jpg|jpeg|gif|ico|svg)$ {\n            # 静态资源\n            expires 1y;\n            add_header Cache-Control \"public, immutable\";\n            root html;\n        }\n\n        location /assets {\n            # 静态资源\n            expires 1y;\n            add_header Cache-Control \"public\";\n            root html;\n        }\n\n        location ~ ^/api/v1/system/(message|progress/) {\n            # SSE MIME类型设置\n            default_type text/event-stream;\n\n            # 禁用缓存\n            add_header Cache-Control no-cache;\n            add_header X-Accel-Buffering no;\n            proxy_buffering off;\n            proxy_cache off;\n\n            # 代理设置\n            proxy_pass http://backend_api;\n            proxy_http_version 1.1;\n            proxy_set_header Connection \"\";\n            proxy_set_header Host $host;\n            proxy_set_header X-Real-IP $remote_addr;\n            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\n            # 超时设置\n            proxy_read_timeout 3600s;\n        }\n\n        location /api {\n            # 后端API\n            proxy_pass http://backend_api;\n            rewrite ^.+mock-server/?(.*)$ /$1 break;\n            proxy_http_version 1.1;\n            proxy_buffering off;\n            proxy_cache off;\n            proxy_redirect off;\n            proxy_set_header Connection \"\";\n            proxy_set_header Upgrade $http_upgrade;\n            proxy_set_header X-Real-IP $remote_addr;\n            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n            proxy_set_header Host $http_host;\n            proxy_set_header X-Nginx-Proxy true;\n\n            # 超时设置\n            proxy_read_timeout 600s;\n        }\n\n        location /cookiecloud {\n            # 后端cookiecloud地址\n            proxy_pass http://backend_api;\n            rewrite ^.+mock-server/?(.*)$ /$1 break;\n            proxy_http_version 1.1;\n            proxy_buffering off;\n            proxy_cache off;\n            proxy_redirect off;\n            proxy_set_header Connection \"\";\n            proxy_set_header Upgrade $http_upgrade;\n            proxy_set_header X-Real-IP $remote_addr;\n            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n            proxy_set_header Host $http_host;\n            proxy_set_header X-Nginx-Proxy true;\n\n            # 超时设置\n            proxy_read_timeout 600s;\n        }\n\n    }\n\n    upstream backend_api {\n        # 后端API的地址和端口\n        server 127.0.0.1:3001;\n        # 可以添加更多后端服务器作为负载均衡\n    }\n\n}\n"
  },
  {
    "path": "public/offline.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, viewport-fit=cover\">\n  <title>MoviePilot - 离线</title>\n  <link rel=\"icon\" href=\"/favicon.ico\">\n  <style>\n    :root {\n      --primary-color: #9155FD;\n      --surface-color: #FFFFFF;\n      --text-color: #333333;\n      --border-color: rgba(0, 0, 0, 0.12);\n    }\n    \n    @media (prefers-color-scheme: dark) {\n      :root {\n        --surface-color: #0E1116;\n        --text-color: #FFFFFF;\n        --border-color: rgba(255, 255, 255, 0.12);\n      }\n    }\n    \n    * {\n      margin: 0;\n      padding: 0;\n      box-sizing: border-box;\n    }\n    \n    body {\n      font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif;\n      background-color: var(--surface-color);\n      color: var(--text-color);\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      min-height: 100vh;\n      padding: 20px;\n    }\n    \n    .offline-container {\n      text-align: center;\n      max-width: 400px;\n      width: 100%;\n      padding: 40px;\n      background: var(--surface-color);\n      border-radius: 24px;\n      box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1), 0 0 0 1px var(--border-color);\n    }\n    \n    .icon-wrapper {\n      width: 120px;\n      height: 120px;\n      margin: 0 auto 32px;\n      background: rgba(145, 85, 253, 0.1);\n      border-radius: 50%;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n    }\n    \n    .icon {\n      width: 64px;\n      height: 64px;\n      fill: var(--primary-color);\n    }\n    \n    h1 {\n      font-size: 2rem;\n      margin-bottom: 16px;\n      font-weight: 600;\n    }\n    \n    p {\n      font-size: 1.1rem;\n      line-height: 1.6;\n      opacity: 0.7;\n      margin-bottom: 32px;\n    }\n    \n    .retry-button {\n      background: var(--primary-color);\n      color: white;\n      border: none;\n      padding: 12px 32px;\n      font-size: 1rem;\n      border-radius: 8px;\n      cursor: pointer;\n      font-weight: 500;\n      transition: opacity 0.2s;\n    }\n    \n    .retry-button:hover {\n      opacity: 0.9;\n    }\n    \n    .status-badge {\n      display: inline-flex;\n      align-items: center;\n      gap: 8px;\n      margin-top: 24px;\n      padding: 8px 16px;\n      background: rgba(145, 85, 253, 0.1);\n      border-radius: 20px;\n      font-size: 0.875rem;\n    }\n    \n    .status-dot {\n      width: 8px;\n      height: 8px;\n      background: #EF5350;\n      border-radius: 50%;\n      animation: pulse 2s infinite;\n    }\n    \n    @keyframes pulse {\n      0% { opacity: 1; }\n      50% { opacity: 0.5; }\n      100% { opacity: 1; }\n    }\n  </style>\n</head>\n<body>\n  <div class=\"offline-container\">\n    <div class=\"icon-wrapper\">\n      <svg class=\"icon\" viewBox=\"0 0 24 24\">\n        <path d=\"M12,2.03C17.73,2.5 22,7.08 22,12.75C22,13.84 21.79,14.89 21.4,15.86L19.53,14C19.5,13.83 19.5,13.67 19.5,13.5A2.5,2.5 0 0,0 17,11A2.5,2.5 0 0,0 14.5,13.5A2.5,2.5 0 0,0 17,16A2.5,2.5 0 0,0 19.5,13.5C19.5,13.67 19.5,13.83 19.53,14L21.4,15.86C20.04,19.09 16.9,21.47 13.19,21.97L11.75,20.53C11.83,20.5 11.92,20.5 12,20.5A2.5,2.5 0 0,0 14.5,18A2.5,2.5 0 0,0 12,15.5A2.5,2.5 0 0,0 9.5,18C9.5,18.08 9.5,18.17 9.53,18.25L7.66,16.38C7.25,15.96 6.86,15.5 6.5,15H8.17C8.06,14.7 8,14.35 8,14A3,3 0 0,1 11,11A3,3 0 0,1 14,14C14,14.35 13.94,14.7 13.83,15H15.5C15.14,15.5 14.75,15.96 14.34,16.38L12.47,14.5C12.5,14.42 12.5,14.33 12.47,14.25L10.6,12.38C10.18,11.97 9.72,11.59 9.23,11.25L7.36,9.38C6.94,8.96 6.5,8.61 6,8.31V6.64L4.14,4.78C3.6,5.55 3.17,6.4 2.86,7.31L1,5.45V4.46L2.05,3.41C2.5,2.86 3.05,2.41 3.66,2.06L20,18.4L18.73,19.67L12.47,13.41L11.75,20.53C11.83,20.5 11.92,20.5 12,20.5A2.5,2.5 0 0,0 14.5,18A2.5,2.5 0 0,0 12,15.5A2.5,2.5 0 0,0 9.5,18C9.5,18.08 9.5,18.17 9.53,18.25L7.66,16.38C7.25,15.96 6.86,15.5 6.5,15H8.17C8.06,14.7 8,14.35 8,14A3,3 0 0,1 11,11A3,3 0 0,1 14,14C14,14.35 13.94,14.7 13.83,15H15.5C15.14,15.5 14.75,15.96 14.34,16.38L2.46,4.5C3.5,3.17 4.9,2.15 6.5,1.58V3.25C5.43,3.7 4.47,4.33 3.66,5.11L2.61,6.16V8.03C3.16,7.33 3.82,6.73 4.57,6.25V8.31C3.57,9.14 2.75,10.19 2.21,11.39L1,10.18V8.65C1.5,6.16 3.03,4.03 5.11,2.71L6.39,4C8.97,2.73 12.03,2.24 14.97,3.03L16.84,4.9C18.17,5.86 19.25,7.16 19.94,8.68L18.07,6.81C17.07,5.5 15.66,4.5 14,4.04V5.71C15.93,6.17 17.5,7.53 18.33,9.3L16.46,7.43C15.46,6.61 14.2,6.08 12.82,6V7.67C13.69,7.79 14.47,8.11 15.14,8.58L13.27,6.71C12.94,6.66 12.6,6.63 12.25,6.63L10.38,4.76C10.87,4.66 11.37,4.59 11.88,4.56L10,2.68C10.66,2.56 11.33,2.5 12,2.5V2.03Z\" />\n      </svg>\n    </div>\n    \n    <h1>您当前处于离线状态</h1>\n    <p>无法连接到 MoviePilot 服务器。请检查您的网络连接后重试。</p>\n    \n    <button class=\"retry-button\" onclick=\"window.location.reload()\">\n      重新加载\n    </button>\n    \n    <div class=\"status-badge\">\n      <span class=\"status-dot\"></span>\n      <span>离线状态</span>\n    </div>\n  </div>\n  \n  <script>\n    // 监听网络状态变化\n    window.addEventListener('online', function() {\n      window.location.reload();\n    });\n    \n    // Service Worker 消息处理\n    if ('serviceWorker' in navigator) {\n      navigator.serviceWorker.addEventListener('message', function(event) {\n        if (event.data && event.data.type === 'OFFLINE_STATUS' && !event.data.offline) {\n          window.location.reload();\n        }\n      });\n    }\n  </script>\n</body>\n</html>\n"
  },
  {
    "path": "public/robots.txt",
    "content": "User-agent: *\nDisallow: /\n"
  },
  {
    "path": "public/service.js",
    "content": "const path = require('node:path')\nconst express = require('express')\nconst proxy = require('express-http-proxy')\n\nconst app = express()\nconst port = process.env.NGINX_PORT || 3000\n\n// 后端 API 地址\nconst proxyConfig = {\n  URL: '127.0.0.1',\n  PORT: process.env.PORT || 3001\n}\n\n// 静态文件服务目录\napp.use(express.static(__dirname))\n\n// 配置代理中间件将请求转发给后端API\napp.use(\n  '/api',\n  proxy(`${proxyConfig.URL}:${proxyConfig.PORT}`, {\n    // 路径加上 /api 前缀\n    proxyReqPathResolver: (req) => {\n      return `/api${req.url}`\n    }\n  })\n);\n\n// 配置代理中间件将CookieCloud请求转发给后端API\napp.use(\n  '/cookiecloud',\n  proxy(`${proxyConfig.URL}:${proxyConfig.PORT}`, {\n    // 路径加上 /cookiecloud 前缀\n    proxyReqPathResolver: (req) => {\n      return `/cookiecloud${req.url}`\n    }\n  })\n);\n\n// 处理根路径的请求\napp.get('/', (req, res) => {\n  res.sendFile(path.join(__dirname, 'index.html'))\n})\n\n// 处理所有其他请求，重定向到前端入口文件\napp.get('*', (req, res) => {\n  res.sendFile(path.join(__dirname, 'index.html'))\n})\n\napp.listen(port, () => {\n  console.log(`Server is running on port ${port}`)\n})\n"
  },
  {
    "path": "shims.d.ts",
    "content": "declare module '*.vue' {\n  import type { DefineComponent } from 'vue'\n  \n  const component: DefineComponent<{}, {}, any>\n  export default component\n}\n\n\ndeclare module 'vue-prism-component' {\n  import { ComponentOptions } from 'vue'\n  const component: ComponentOptions\n  export default component\n}\ndeclare module 'vue-shepherd';\ndeclare module 'colorthief';\n"
  },
  {
    "path": "src/@core/components/ConfirmDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\n\ninterface Props {\n  modelValue: boolean\n  type?: 'info' | 'warn' | 'error'\n  title?: string\n  content?: string\n  confirmText?: string\n  cancelText?: string\n  width?: string | number\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  type: 'info',\n  title: '',\n  content: '',\n  confirmText: '',\n  cancelText: '',\n  width: '28rem',\n})\n\nconst emit = defineEmits<{\n  (e: 'update:modelValue', value: boolean): void\n  (e: 'confirm'): void\n  (e: 'cancel'): void\n}>()\n\n// 对话框类型对应的图标和颜色\nconst typeConfig = {\n  info: {\n    icon: 'mdi-information',\n    color: 'info',\n  },\n  warn: {\n    icon: 'mdi-alert',\n    color: 'warning',\n  },\n  error: {\n    icon: 'mdi-alert-circle',\n    color: 'error',\n  },\n}\n\n// 获取当前类型的配置\nconst currentType = computed(() => typeConfig[props.type])\n\n// 确认按钮点击\nfunction handleConfirm() {\n  emit('confirm')\n  emit('update:modelValue', false)\n}\n\n// 取消按钮点击\nfunction handleCancel() {\n  emit('cancel')\n  emit('update:modelValue', false)\n}\n</script>\n\n<template>\n  <VDialog :model-value=\"modelValue\" @update:model-value=\"emit('update:modelValue', $event)\" :max-width=\"width\">\n    <VCard>\n      <VCardItem>\n        <div class=\"d-flex align-center justify-start mt-3\">\n          <VAvatar :color=\"currentType.color\" variant=\"text\" size=\"x-large\">\n            <VIcon size=\"x-large\" :icon=\"currentType.icon\" />\n          </VAvatar>\n          <div class=\"mx-3\">\n            <p class=\"font-weight-bold text-xl text-high-emphasis\">{{ title }}</p>\n            <p>{{ content }}</p>\n          </div>\n        </div>\n      </VCardItem>\n      <VCardActions class=\"mx-auto\">\n        <VBtn variant=\"tonal\" color=\"secondary\" class=\"px-5\" @click=\"handleCancel\">\n          {{ cancelText }}\n        </VBtn>\n        <VBtn variant=\"elevated\" :color=\"currentType.color\" @click=\"handleConfirm\" class=\"px-5\">\n          {{ confirmText }}\n        </VBtn>\n      </VCardActions>\n      <VDialogCloseBtn @click=\"handleCancel\" />\n    </VCard>\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/@core/components/DialogCloseBtn.vue",
    "content": "<script lang=\"ts\" setup>\n// 定义输入参数\nconst props = defineProps({\n  // 是否显示\n  innerClass: String,\n})\n// 定义触发的自定义事件\nconst emit = defineEmits(['click', 'update:modelValue'])\n// 按钮点击\nfunction onClick() {\n  emit('update:modelValue', false)\n  emit('click')\n}\n</script>\n\n<template>\n  <IconBtn\n    :class=\"props.innerClass ? props.innerClass : 'absolute right-3 top-3 z-10'\"\n    @click.stop=\"onClick\"\n  >\n    <VIcon icon=\"mdi-close\" />\n  </IconBtn>\n</template>\n"
  },
  {
    "path": "src/@core/components/ErrorHeader.vue",
    "content": "<script setup lang=\"ts\">\ninterface Props {\n  errorCode?: string\n  errorTitle?: string\n  errorDescription?: string\n}\n\nconst props = defineProps<Props>()\n</script>\n\n<template>\n  <div class=\"text-center mb-4\">\n    <!-- 👉 Title and subtitle -->\n    <h1\n      v-if=\"props.errorCode\"\n      class=\"text-h1 font-weight-medium\"\n    >\n      {{ props.errorCode }}\n    </h1>\n    <h5\n      v-if=\"props.errorTitle\"\n      class=\"text-h5 font-weight-medium mb-3\"\n    >\n      {{ props.errorTitle }}\n    </h5>\n    <p v-if=\"props.errorDescription\">\n      {{ props.errorDescription }}\n    </p>\n  </div>\n</template>\n"
  },
  {
    "path": "src/@core/components/ExistIcon.vue",
    "content": "<template>\n  <div class=\"absolute top-0 right-0 flex items-center justify-between p-2\">\n    <div class=\"pointer-events-none z-40 flex items-center\">\n      <div\n        class=\"relative inline-flex whitespace-nowrap rounded-full border-gray-700 font-semibold leading-5 ring-gray-700\"\n      >\n        <div\n          class=\"rounded-full bg-opacity-80 w-5 border p-0 bg-green-500 border-green-400 ring-green-400 text-green-100\"\n        >\n          <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\" aria-hidden=\"true\">\n            <path\n              fill-rule=\"evenodd\"\n              d=\"M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z\"\n              clip-rule=\"evenodd\"\n            />\n          </svg>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "src/@core/components/LoadingBanner.vue",
    "content": "<script lang=\"ts\" setup>\n// 定义输入参数\nconst props = defineProps({\n  text: String,\n})\n</script>\n\n<template>\n  <div class=\"w-full text-center text-gray-500 text-sm flex flex-col items-center my-5\">\n    <div class=\"initial-loading-container\">\n      <div class=\"initial-loading-content\">\n        <div class=\"wave-loader\">\n          <div class=\"wave-dot\"></div>\n          <div class=\"wave-dot\"></div>\n          <div class=\"wave-dot\"></div>\n          <div class=\"wave-dot\"></div>\n        </div>\n        <div class=\"initial-loading-text\" v-if=\"props.text\">{{ props.text }}</div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<style scoped>\n/* 初始的加载状态 */\n.initial-loading-container {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  min-block-size: 20vh;\n}\n\n.initial-loading-content {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  gap: 20px;\n}\n\n.wave-loader {\n  display: flex;\n  align-items: center;\n  block-size: 40px;\n  gap: 6px;\n}\n\n.wave-dot {\n  border-radius: 50%;\n  animation: wave 1.5s ease-in-out infinite;\n  background-color: rgb(var(--v-theme-primary));\n  block-size: 8px;\n  inline-size: 8px;\n}\n\n.wave-dot:nth-child(1) {\n  animation-delay: 0s;\n}\n\n.wave-dot:nth-child(2) {\n  animation-delay: 0.2s;\n}\n\n.wave-dot:nth-child(3) {\n  animation-delay: 0.4s;\n}\n\n.wave-dot:nth-child(4) {\n  animation-delay: 0.6s;\n}\n\n@keyframes wave {\n  0%,\n  100% {\n    transform: translateY(0);\n  }\n\n  50% {\n    transform: translateY(-15px);\n  }\n}\n\n.initial-loading-text {\n  color: rgb(var(--v-theme-primary));\n  font-size: 0.9rem;\n  font-weight: 500;\n  letter-spacing: 1px;\n}\n</style>\n"
  },
  {
    "path": "src/@core/components/MoreBtn.vue",
    "content": "<script lang=\"ts\" setup>\ninterface Props {\n  menuList?: unknown[]\n  itemProps?: boolean\n}\n\nconst props = defineProps<Props>()\n</script>\n\n<template>\n  <IconBtn>\n    <VIcon icon=\"mdi-dots-vertical\" />\n\n    <VMenu\n      v-if=\"props.menuList\"\n      activator=\"parent\"\n      close-on-content-click\n    >\n      <VList\n        :items=\"props.menuList\"\n        :item-props=\"props.itemProps\"\n      />\n    </VMenu>\n  </IconBtn>\n</template>\n"
  },
  {
    "path": "src/@core/components/PageContentTitle.vue",
    "content": "<script setup lang=\"ts\">\ndefineProps({\n  // 标题\n  title: String,\n})\n</script>\n<template>\n  <div v-if=\"title\" class=\"my-3 mx-3 md:flex md:items-center md:justify-between\">\n    <div class=\"min-w-0 flex-1 mx-0\">\n      <h2\n        class=\"ms-1 truncate text-2xl font-bold leading-7 text-gray-100 sm:overflow-visible sm:text-3xl sm:leading-9 md:mb-0\"\n        data-testid=\"page-header\"\n      >\n        <span class=\"text-moviepilot\">{{ title }}</span>\n      </h2>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "src/@core/components/ScrollToTopBtn.vue",
    "content": "<script lang=\"ts\" setup>\n// 控制回到顶部按钮的可见性\nconst showScrollToTop = ref(false)\nconst scrollThreshold = 200 // 滚动多少像素后显示按钮\n\n// 滚动事件处理函数\nconst handleScroll = () => {\n  showScrollToTop.value = window.scrollY > scrollThreshold\n}\n\nconst scrollToTop = () => {\n  window.scrollTo({ top: 0, behavior: 'smooth' })\n}\n\nonMounted(async () => {\n  // Add scroll event listener\n  window.addEventListener('scroll', handleScroll)\n  // Initial check for scroll-to-top\n  handleScroll()\n})\n\nonUnmounted(() => {\n  // Remove scroll event listener\n  window.removeEventListener('scroll', handleScroll)\n})\n</script>\n\n<template>\n  <div class=\"global-action-buttons d-none d-sm-block\">\n    <Transition name=\"scroll-fade\">\n      <button v-show=\"showScrollToTop\" class=\"global-action-button\" @click=\"scrollToTop\">\n        <svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n          <path\n            d=\"M7 14L12 9L17 14\"\n            stroke=\"currentColor\"\n            stroke-width=\"2\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n          />\n        </svg>\n      </button>\n    </Transition>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n/* Global Action Button Styles (FAB) */\n.global-action-buttons {\n  position: fixed;\n  z-index: 100;\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n  inset-block-end: 2rem;\n  inset-inline-end: 2rem;\n}\n\n.global-action-button {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border: 1px solid rgba(var(--v-theme-on-surface), 0.05);\n  border-radius: 50%;\n  backdrop-filter: blur(10px);\n  background-color: rgba(var(--v-theme-background), 0.8);\n  block-size: 44px;\n  box-shadow: 0 5px 15px rgba(0, 0, 0, 8%);\n  color: rgb(var(--v-theme-on-surface));\n  cursor: pointer;\n  inline-size: 44px;\n  transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);\n\n  &:hover {\n    background-color: rgba(var(--v-theme-background), 0.95);\n    color: rgb(var(--v-theme-primary));\n    transform: translateY(-4px);\n  }\n\n  svg {\n    block-size: 20px;\n    inline-size: 20px;\n    transition: all 0.3s ease;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/@core/components/StatIcon.vue",
    "content": "<script lang=\"ts\" setup>\ninterface Props {\n  color?: string\n  message?: string\n}\n\nconst props = defineProps<Props>()\n</script>\n\n<template>\n  <div class=\"absolute top-2 right-2 flex items-center justify-between p-2\">\n    <VBadge :color=\"props.color\" bordered>\n      <template #badge>\n        <VIcon icon=\"mdi-pulse\"></VIcon>\n      </template>\n    </VBadge>\n  </div>\n</template>\n"
  },
  {
    "path": "src/@core/libs/apex-chart/apexCharConfig.ts",
    "content": "import type { ThemeInstance } from 'vuetify'\nimport { hexToRgb } from '@layouts/utils'\n\n// 👉 Colors variables\nfunction colorVariables(themeColors: ThemeInstance['themes']['value']['colors']) {\n  const themeSecondaryTextColor = `rgba(${hexToRgb(themeColors.colors['on-surface'])},${themeColors.variables['medium-emphasis-opacity']})`\n  const themeDisabledTextColor = `rgba(${hexToRgb(themeColors.colors['on-surface'])},${themeColors.variables['disabled-opacity']})`\n  const themeBorderColor = `rgba(${hexToRgb(String(themeColors.variables['border-color']))},${themeColors.variables['border-opacity']})`\n  const themePrimaryTextColor = `rgba(${hexToRgb(themeColors.colors['on-surface'])},${themeColors.variables['high-emphasis-opacity']})`\n\n  return { themeSecondaryTextColor, themeDisabledTextColor, themeBorderColor, themePrimaryTextColor }\n}\n\nexport function getScatterChartConfig(themeColors: ThemeInstance['themes']['value']['colors']) {\n  const scatterColors = {\n    series1: '#ff9f43',\n    series2: '#7367f0',\n    series3: '#28c76f',\n  }\n\n  const { themeSecondaryTextColor, themeBorderColor, themeDisabledTextColor } = colorVariables(themeColors)\n\n  return {\n    chart: {\n      parentHeightOffset: 0,\n      toolbar: { show: false },\n      zoom: {\n        type: 'xy',\n        enabled: true,\n      },\n    },\n    legend: {\n      position: 'top',\n      horizontalAlign: 'left',\n      markers: { offsetX: -3 },\n\n      labels: { colors: themeSecondaryTextColor },\n      itemMargin: {\n        vertical: 3,\n        horizontal: 10,\n      },\n    },\n    colors: [scatterColors.series1, scatterColors.series2, scatterColors.series3],\n    grid: {\n      borderColor: themeBorderColor,\n      xaxis: {\n        lines: { show: true },\n      },\n    },\n    yaxis: {\n      labels: {\n        style: { colors: themeDisabledTextColor },\n      },\n    },\n    xaxis: {\n      tickAmount: 10,\n      axisBorder: { show: false },\n\n      axisTicks: { color: themeBorderColor },\n      crosshairs: {\n        stroke: { color: themeBorderColor },\n      },\n      labels: {\n        style: { colors: themeDisabledTextColor },\n        formatter: (val: string) => parseFloat(val).toFixed(1),\n      },\n    },\n  }\n}\nexport function getLineChartSimpleConfig(themeColors: ThemeInstance['themes']['value']['colors']) {\n  const { themeBorderColor, themeDisabledTextColor } = colorVariables(themeColors)\n\n  return {\n    chart: {\n      parentHeightOffset: 0,\n      zoom: { enabled: false },\n      toolbar: { show: false },\n    },\n    colors: ['#ff9f43'],\n    stroke: { curve: 'straight' },\n    dataLabels: { enabled: false },\n    markers: {\n      strokeWidth: 7,\n      strokeOpacity: 1,\n      colors: ['#ff9f43'],\n      strokeColors: ['#fff'],\n    },\n    grid: {\n      padding: { top: -10 },\n\n      borderColor: themeBorderColor,\n      xaxis: {\n        lines: { show: true },\n      },\n    },\n    tooltip: {\n\n      custom(data: any) {\n        return `<div class='bar-chart pa-2'>\n          <span>${data.series[data.seriesIndex][data.dataPointIndex]}%</span>\n        </div>`\n      },\n    },\n    yaxis: {\n      labels: {\n        style: { colors: themeDisabledTextColor },\n      },\n    },\n    xaxis: {\n      axisBorder: { show: false },\n\n      axisTicks: { color: themeBorderColor },\n      crosshairs: {\n        stroke: { color: themeBorderColor },\n      },\n      labels: {\n        style: { colors: themeDisabledTextColor },\n      },\n      categories: [\n        '7/12',\n        '8/12',\n        '9/12',\n        '10/12',\n        '11/12',\n        '12/12',\n        '13/12',\n        '14/12',\n        '15/12',\n        '16/12',\n        '17/12',\n        '18/12',\n        '19/12',\n        '20/12',\n        '21/12',\n      ],\n    },\n  }\n}\n\nexport function getBarChartConfig(themeColors: ThemeInstance['themes']['value']['colors']) {\n  const { themeBorderColor, themeDisabledTextColor } = colorVariables(themeColors)\n\n  return {\n    chart: {\n      parentHeightOffset: 0,\n      toolbar: { show: false },\n    },\n    colors: ['#00cfe8'],\n    dataLabels: { enabled: false },\n    plotOptions: {\n      bar: {\n        borderRadius: 8,\n        barHeight: '30%',\n        horizontal: true,\n        startingShape: 'rounded',\n      },\n    },\n    grid: {\n      borderColor: themeBorderColor,\n      xaxis: {\n        lines: { show: false },\n      },\n      padding: {\n        top: -10,\n      },\n    },\n    yaxis: {\n      labels: {\n        style: { colors: themeDisabledTextColor },\n      },\n    },\n    xaxis: {\n      axisBorder: { show: false },\n      axisTicks: { color: themeBorderColor },\n      categories: ['MON, 11', 'THU, 14', 'FRI, 15', 'MON, 18', 'WED, 20', 'FRI, 21', 'MON, 23'],\n      labels: {\n        style: { colors: themeDisabledTextColor },\n      },\n    },\n  }\n}\n\nexport function getCandlestickChartConfig(themeColors: ThemeInstance['themes']['value']['colors']) {\n  const candlestickColors = {\n    series1: '#28c76f',\n    series2: '#ea5455',\n  }\n\n  const { themeBorderColor, themeDisabledTextColor } = colorVariables(themeColors)\n\n  return {\n    chart: {\n      parentHeightOffset: 0,\n      toolbar: { show: false },\n    },\n    plotOptions: {\n      bar: { columnWidth: '40%' },\n      candlestick: {\n        colors: {\n          upward: candlestickColors.series1,\n          downward: candlestickColors.series2,\n        },\n      },\n    },\n    grid: {\n      padding: { top: -10 },\n      borderColor: themeBorderColor,\n      xaxis: {\n        lines: { show: true },\n      },\n    },\n    yaxis: {\n      tooltip: { enabled: true },\n      crosshairs: {\n        stroke: { color: themeBorderColor },\n      },\n      labels: {\n        style: { colors: themeDisabledTextColor },\n      },\n    },\n    xaxis: {\n      type: 'datetime',\n      axisBorder: { show: false },\n      axisTicks: { color: themeBorderColor },\n      crosshairs: {\n        stroke: { color: themeBorderColor },\n      },\n      labels: {\n        style: { colors: themeDisabledTextColor },\n      },\n    },\n  }\n}\nexport function getRadialBarChartConfig(themeColors: ThemeInstance['themes']['value']['colors']) {\n  const radialBarColors = {\n    series1: '#fdd835',\n    series2: '#32baff',\n    series3: '#00d4bd',\n    series4: '#7367f0',\n    series5: '#FFA1A1',\n  }\n\n  const { themeSecondaryTextColor, themePrimaryTextColor } = colorVariables(themeColors)\n\n  return {\n    stroke: { lineCap: 'round' },\n    labels: ['Comments', 'Replies', 'Shares'],\n    legend: {\n      show: true,\n      position: 'bottom',\n      labels: {\n        colors: themeSecondaryTextColor,\n      },\n      markers: {\n        offsetX: -3,\n      },\n      itemMargin: {\n        vertical: 3,\n        horizontal: 10,\n      },\n    },\n    colors: [radialBarColors.series1, radialBarColors.series2, radialBarColors.series4],\n    plotOptions: {\n      radialBar: {\n        hollow: { size: '30%' },\n        track: {\n          margin: 15,\n          background: themeColors.colors['grey-100'],\n        },\n        dataLabels: {\n          name: {\n            fontSize: '2rem',\n          },\n          value: {\n            fontSize: '1rem',\n            color: themeSecondaryTextColor,\n          },\n          total: {\n            show: true,\n            fontWeight: 400,\n            label: 'Comments',\n            fontSize: '1.125rem',\n\n            color: themePrimaryTextColor,\n\n            formatter(w: { globals: { seriesTotals: any[]; series: string | any[] } }) {\n              const totalValue\n                = w.globals.seriesTotals.reduce((a: number, b: number) => {\n                  return a + b\n                }, 0) / w.globals.series.length\n\n              if (totalValue % 1 === 0)\n                return `${totalValue}%`\n              else\n                return `${totalValue.toFixed(2)}%`\n            },\n          },\n        },\n      },\n    },\n    grid: {\n      padding: {\n        top: -30,\n        bottom: -25,\n      },\n    },\n  }\n}\n\nexport function getDonutChartConfig(themeColors: ThemeInstance['themes']['value']['colors']) {\n  const donutColors = {\n    series1: '#fdd835',\n    series2: '#00d4bd',\n    series3: '#826bf8',\n    series4: '#32baff',\n    series5: '#ffa1a1',\n  }\n\n  const { themeSecondaryTextColor, themePrimaryTextColor } = colorVariables(themeColors)\n\n  return {\n    stroke: { width: 0 },\n    labels: ['Operational', 'Networking', 'Hiring', 'R&D'],\n    colors: [donutColors.series1, donutColors.series5, donutColors.series3, donutColors.series2],\n    dataLabels: {\n      enabled: true,\n      formatter: (val: string) => `${parseInt(val, 10)}%`,\n    },\n    legend: {\n      position: 'bottom',\n      markers: { offsetX: -3 },\n      labels: { colors: themeSecondaryTextColor },\n      itemMargin: {\n        vertical: 3,\n        horizontal: 10,\n      },\n    },\n    plotOptions: {\n      pie: {\n        donut: {\n          labels: {\n            show: true,\n            name: {\n              fontSize: '1.5rem',\n            },\n            value: {\n              fontSize: '1.5rem',\n              color: themeSecondaryTextColor,\n              formatter: (val: string) => `${parseInt(val, 10)}`,\n            },\n            total: {\n              show: true,\n              fontSize: '1.5rem',\n              label: 'Operational',\n              formatter: () => '31%',\n              color: themePrimaryTextColor,\n            },\n          },\n        },\n      },\n    },\n    responsive: [\n      {\n        breakpoint: 992,\n        options: {\n          chart: {\n            height: 380,\n          },\n          legend: {\n            position: 'bottom',\n          },\n        },\n      },\n      {\n        breakpoint: 576,\n        options: {\n          chart: {\n            height: 320,\n          },\n          plotOptions: {\n            pie: {\n              donut: {\n                labels: {\n                  show: true,\n                  name: {\n                    fontSize: '1rem',\n                  },\n                  value: {\n                    fontSize: '1rem',\n                  },\n                  total: {\n                    fontSize: '1rem',\n                  },\n                },\n              },\n            },\n          },\n        },\n      },\n    ],\n  }\n}\n\nexport function getAreaChartSplineConfig(themeColors: ThemeInstance['themes']['value']['colors']) {\n  const areaColors = {\n    series3: '#e0cffe',\n    series2: '#b992fe',\n    series1: '#ab7efd',\n  }\n\n  const { themeSecondaryTextColor, themeBorderColor, themeDisabledTextColor } = colorVariables(themeColors)\n\n  return {\n    chart: {\n      parentHeightOffset: 0,\n      toolbar: { show: false },\n    },\n    tooltip: { shared: false },\n    dataLabels: { enabled: false },\n    stroke: {\n      show: false,\n      curve: 'straight',\n    },\n    legend: {\n      position: 'top',\n      horizontalAlign: 'left',\n\n      labels: { colors: themeSecondaryTextColor },\n      markers: {\n        offsetY: 1,\n        offsetX: -3,\n      },\n      itemMargin: {\n        vertical: 3,\n        horizontal: 10,\n      },\n    },\n\n    colors: [areaColors.series3, areaColors.series2, areaColors.series1],\n    fill: {\n      opacity: 1,\n      type: 'solid',\n    },\n    grid: {\n      show: true,\n      borderColor: themeBorderColor,\n      xaxis: {\n        lines: { show: true },\n      },\n    },\n    yaxis: {\n      labels: {\n        style: { colors: themeDisabledTextColor },\n      },\n    },\n    xaxis: {\n      axisBorder: { show: false },\n\n      axisTicks: { color: themeBorderColor },\n      crosshairs: {\n        stroke: { color: themeBorderColor },\n      },\n      labels: {\n        style: { colors: themeDisabledTextColor },\n      },\n      categories: [\n        '7/12',\n        '8/12',\n        '9/12',\n        '10/12',\n        '11/12',\n        '12/12',\n        '13/12',\n        '14/12',\n        '15/12',\n        '16/12',\n        '17/12',\n        '18/12',\n        '19/12',\n      ],\n    },\n  }\n}\n\nexport function getColumnChartConfig(themeColors: ThemeInstance['themes']['value']['colors']) {\n  const columnColors = {\n    series1: '#826af9',\n    series2: '#d2b0ff',\n    bg: '#f8d3ff',\n  }\n\n  const { themeSecondaryTextColor, themeBorderColor, themeDisabledTextColor } = colorVariables(themeColors)\n\n  return {\n    chart: {\n      offsetX: -10,\n      stacked: true,\n      parentHeightOffset: 0,\n      toolbar: { show: false },\n    },\n    fill: { opacity: 1 },\n    dataLabels: { enabled: false },\n\n    colors: [columnColors.series1, columnColors.series2],\n    legend: {\n      position: 'top',\n      horizontalAlign: 'left',\n\n      labels: { colors: themeSecondaryTextColor },\n      markers: {\n        offsetY: 1,\n        offsetX: -3,\n      },\n      itemMargin: {\n        vertical: 3,\n        horizontal: 10,\n      },\n    },\n    stroke: {\n      show: true,\n      colors: ['transparent'],\n    },\n    plotOptions: {\n      bar: {\n        columnWidth: '15%',\n        colors: {\n          backgroundBarRadius: 10,\n\n          backgroundBarColors: [columnColors.bg, columnColors.bg, columnColors.bg, columnColors.bg, columnColors.bg],\n        },\n      },\n    },\n    grid: {\n      borderColor: themeBorderColor,\n      xaxis: {\n        lines: { show: true },\n      },\n    },\n    yaxis: {\n      labels: {\n        style: { colors: themeDisabledTextColor },\n      },\n    },\n    xaxis: {\n      axisBorder: { show: false },\n\n      axisTicks: { color: themeBorderColor },\n      categories: ['7/12', '8/12', '9/12', '10/12', '11/12', '12/12', '13/12', '14/12', '15/12'],\n      crosshairs: {\n        stroke: { color: themeBorderColor },\n      },\n      labels: {\n        style: { colors: themeDisabledTextColor },\n      },\n    },\n    responsive: [\n      {\n        breakpoint: 600,\n        options: {\n          plotOptions: {\n            bar: {\n              columnWidth: '35%',\n            },\n          },\n        },\n      },\n    ],\n  }\n}\n\nexport function getHeatMapChartConfig(themeColors: ThemeInstance['themes']['value']['colors']) {\n  const { themeSecondaryTextColor, themeDisabledTextColor } = colorVariables(themeColors)\n\n  return {\n    chart: {\n      parentHeightOffset: 0,\n      toolbar: { show: false },\n    },\n    dataLabels: { enabled: false },\n    stroke: {\n      colors: [themeColors.colors.surface],\n    },\n    legend: {\n      position: 'bottom',\n      labels: {\n        colors: themeSecondaryTextColor,\n      },\n      markers: {\n        offsetY: 0,\n        offsetX: -3,\n      },\n      itemMargin: {\n        vertical: 3,\n        horizontal: 10,\n      },\n    },\n    plotOptions: {\n      heatmap: {\n        enableShades: false,\n        colorScale: {\n          ranges: [\n            { to: 10, from: 0, name: '0-10', color: '#b9b3f8' },\n            { to: 20, from: 11, name: '10-20', color: '#aba4f6' },\n            { to: 30, from: 21, name: '20-30', color: '#9d95f5' },\n            { to: 40, from: 31, name: '30-40', color: '#8f85f3' },\n            { to: 50, from: 41, name: '40-50', color: '#8176f2' },\n            { to: 60, from: 51, name: '50-60', color: '#7367f0' },\n          ],\n        },\n      },\n    },\n    grid: {\n      padding: { top: -20 },\n    },\n    yaxis: {\n      labels: {\n        style: {\n          colors: themeDisabledTextColor,\n        },\n      },\n    },\n    xaxis: {\n      labels: { show: false },\n      axisTicks: { show: false },\n      axisBorder: { show: false },\n    },\n  }\n}\n\nexport function getRadarChartConfig(themeColors: ThemeInstance['themes']['value']['colors']) {\n  const radarColors = {\n    series1: '#9b88fa',\n    series2: '#ffa1a1',\n  }\n\n  const { themeSecondaryTextColor, themeBorderColor, themeDisabledTextColor } = colorVariables(themeColors)\n\n  return {\n    chart: {\n      parentHeightOffset: 0,\n      toolbar: { show: false },\n      dropShadow: {\n        top: 1,\n        blur: 8,\n        left: 1,\n        opacity: 0.2,\n        enabled: false,\n      },\n    },\n    markers: { size: 0 },\n    fill: { opacity: [1, 0.8] },\n    colors: [radarColors.series1, radarColors.series2],\n    stroke: {\n      width: 0,\n      show: false,\n    },\n    legend: {\n      labels: {\n        colors: themeSecondaryTextColor,\n      },\n      markers: {\n        offsetX: -3,\n      },\n      itemMargin: {\n        vertical: 3,\n        horizontal: 10,\n      },\n    },\n    plotOptions: {\n      radar: {\n        polygons: {\n          strokeColors: themeBorderColor,\n          connectorColors: themeBorderColor,\n        },\n      },\n    },\n    grid: {\n      show: false,\n      padding: {\n        top: -20,\n        bottom: -20,\n      },\n    },\n    yaxis: { show: false },\n    xaxis: {\n      categories: ['Battery', 'Brand', 'Camera', 'Memory', 'Storage', 'Display', 'OS', 'Price'],\n      labels: {\n        style: {\n          colors: [\n            themeDisabledTextColor,\n            themeDisabledTextColor,\n            themeDisabledTextColor,\n            themeDisabledTextColor,\n            themeDisabledTextColor,\n            themeDisabledTextColor,\n            themeDisabledTextColor,\n            themeDisabledTextColor,\n          ],\n        },\n      },\n    },\n  }\n}\n"
  },
  {
    "path": "src/@core/scss/README.md",
    "content": "# SCSS结构说明\n\n## 目录整合\n\n本项目SCSS文件已完成整合：\n- 主入口文件：`src/@core/scss/index.scss`\n- 实际功能文件位于：`src/@core/scss/template/index.scss`\n\n## 整合内容\n\n- 整合了原`src/@core/scss/base`和`src/@core/scss/template`目录的功能\n- 统一使用`template`目录作为SCSS样式的主要引用点\n- 保留原有引用结构以保证向后兼容性\n\n## 整合进度\n\n已完成：\n- ✅ 主入口文件引用更新\n- ✅ mixins文件合并\n- ✅ placeholders目录下文件转移\n- ✅ perfect-scrollbar文件整合\n- ✅ vuetify相关文件整合\n- ✅ default-layout-w-vertical-nav文件整合\n- ✅ 移除了template/index.scss中对base目录组件的依赖\n- ✅ 修复了components.scss中对base/mixins的引用\n- ✅ 修复了variables.scss中对base/variables的引用\n- ✅ 修复了apex-chart.scss和full-calendar.scss的linter错误\n- ✅ 整合并移除了对vuetify/variables的依赖\n- ✅ 修复了SCSS变量名冲突问题\n- ✅ 修复了SASS模块重复加载配置问题\n- ✅ 修复了导入路径问题（misc、utils等模块的引用路径）\n\n待完成：\n- ⬜ 最终测试确保无样式问题\n- ⬜ 清理冗余文件\n\n## 使用方式\n\n在项目中引用SCSS时，应使用：\n```scss\n@use \"@core/scss\";\n```\n\n这将自动加载所有必要的样式文件。\n\n## 注意事项\n\n此次整合已将所有功能文件整合到template目录，不再依赖base目录的代码。现在可以安全地从外部引用template目录下的文件，但需要进行最终测试以确保样式正常工作。\n\n测试无误后，可以考虑完全删除base目录，以简化项目结构。\n\n## 最近修复\n\n在最近的更新中，我们修复了以下问题：\n1. 解决了变量名冲突问题，通过使用命名空间（如`layouts-vars`）来引用外部模块变量\n2. 修复了SASS模块重复配置问题，将多处的`@forward...with`配置合并到了template/_variables.scss文件中\n3. 统一使用命名空间引用模块，避免后续出现冲突\n4. 修复了`_default-layout-w-vertical-nav.scss`中导入路径错误，将`@use \"misc\"`修改为`@use \"../misc\"`\n"
  },
  {
    "path": "src/@core/scss/_components.scss",
    "content": "@use \"mixins\";\n@use \"vuetify/lib/styles/tools/_elevation\" as mixins_elevation;\n@use \"@layouts/styles/_placeholders\";\n@use \"@configured-variables\" as variables;\n\n// 👉 Alert\n.v-alert {\n  .v-alert__close {\n    .v-icon {\n      block-size: 20px !important;\n      font-size: 20px !important;\n      inline-size: 20px !important;\n    }\n  }\n\n  &:not(.v-alert--prominent) .v-alert__prepend {\n    .v-icon {\n      block-size: 1.375rem !important;\n      font-size: 1.375rem !important;\n      inline-size: 1.375rem !important;\n    }\n  }\n\n  .v-alert-title {\n    line-height: 1.5rem;\n    margin-block-end: 0.25rem;\n  }\n}\n\n// 👉 Avatar font-size\n.v-avatar {\n  @include mixins.avatar-font-sizes($map: variables.$avatar-font-sizes);\n}\n\n// 👉 Avatar group\n.v-avatar-group {\n  display: flex;\n  align-items: center;\n\n  > * {\n    &:not(:first-child) {\n      margin-inline-start: -0.8rem;\n    }\n\n    transition: transform 0.25s ease, box-shadow 0.15s ease;\n\n    &:hover {\n      z-index: 2;\n      transform: translateY(-5px) scale(1.05);\n\n      @include mixins_elevation.elevation(3);\n    }\n  }\n\n  > .v-avatar {\n    border: 2px solid rgb(var(--v-theme-surface));\n  }\n}\n\n// 👉 Button\n.v-btn {\n  /* stylelint-disable-next-line no-descending-specificity */\n  &:not(.v-btn--icon) .v-icon {\n    --v-icon-size-multiplier: 0.9525 !important;\n  }\n}\n\n// 👉 Chip\n.v-chip.v-chip--size-default .v-avatar {\n  --v-avatar-height: 24px;\n}\n\n.v-chip.v-chip--density-comfortable {\n  line-height: 1;\n}\n\n// Dialog responsive width\n.v-dialog {\n  .v-card {\n    @extend %style-scroll-bar;\n  }\n}\n\n@media (width >= 576px) {\n  .v-dialog {\n    &.v-dialog-sm,\n    &.v-dialog-lg,\n    &.v-dialog-xl {\n      inline-size: 565px !important;\n    }\n  }\n}\n\n@media (width >= 992px) {\n  .v-dialog {\n    &.v-dialog-lg,\n    &.v-dialog-xl {\n      inline-size: 865px !important;\n    }\n  }\n}\n\n@media (width >= 1200px) {\n  .v-dialog.v-dialog-xl,\n  .v-dialog.v-dialog-xl .v-overlay__content > .v-card {\n    inline-size: 1165px !important;\n  }\n}\n\n// 👉 Expansion Panel\n.v-expansion-panel {\n  .v-expansion-panel-text {\n    font-size: 1rem;\n  }\n}\n\n// 👉 Tooltip\n.v-tooltip > .v-overlay__content {\n  font-weight: 500;\n  line-height: 0.875rem;\n}\n\n// 👉 List\n\n//  👉 Tab with pill support\n.v-tabs.v-tabs-pill {\n  .v-tab.v-btn {\n    border-radius: 6px !important;\n    min-inline-size: 8.125rem;\n    transition: none;\n\n    .v-tab__slider {\n      visibility: hidden;\n    }\n  }\n\n  .v-slide-group__content {\n    transition: none;\n  }\n}\n\n// loop for all colors bg\n@each $color-name in variables.$theme-colors-name {\n  .v-tabs.v-tabs-pill {\n    .v-slide-group-item--active.v-tab--selected.text-#{$color-name} {\n      background-color: rgb(var(--v-theme-#{$color-name}));\n      color: rgb(var(--v-theme-on-#{$color-name})) !important;\n    }\n  }\n}\n\n// 👉 Timeline added box shadow\n.v-timeline-item {\n  .v-timeline-divider__dot {\n    .v-timeline-divider__inner-dot {\n      box-shadow: 0 0 0 0.1875rem rgb(var(--v-theme-on-surface-variant));\n\n      @each $color-name in variables.$theme-colors-name {\n\n        &.bg-#{$color-name} {\n          box-shadow: 0 0 0 0.1875rem rgba(var(--v-theme-#{$color-name}), 0.12);\n        }\n      }\n    }\n  }\n}\n\n// 👉 Timeline Outlined style\n.v-timeline-variant-outlined.v-timeline {\n  .v-timeline-divider__dot {\n    .v-timeline-divider__inner-dot {\n      box-shadow: inset 0 0 0 0.125rem rgb(var(--v-theme-on-surface-variant));\n\n      @each $color-name in variables.$theme-colors-name {\n        background-color: rgb(var(--v-theme-surface)) !important;\n\n        &.bg-#{$color-name} {\n          box-shadow: inset 0 0 0 0.125rem rgb(var(--v-theme-#{$color-name}));\n        }\n      }\n    }\n  }\n}\n\n// ℹ️ We are make even width of all v-timeline body\n.v-timeline--vertical.v-timeline {\n  .v-timeline-item {\n    .v-timeline-item__body {\n      justify-self: stretch !important;\n    }\n  }\n}\n\n// 👉 Expansion panels\n.v-expansion-panel-title,\n.v-expansion-panel-title--active,\n.v-expansion-panel-title:hover,\n.v-expansion-panel-title:focus,\n.v-expansion-panel-title:focus-visible,\n.v-expansion-panel-title--active:focus,\n.v-expansion-panel-title--active:hover {\n  .v-expansion-panel-title__overlay {\n    opacity: 0 !important;\n  }\n}\n\n// 👉 Set Elevation when panel open\n\n.v-expansion-panels:not(.v-expansion-panels--variant-accordion) {\n  .v-expansion-panel.v-expansion-panel--active {\n    .v-expansion-panel__shadow {\n      @include mixins_elevation.elevation(3);\n    }\n  }\n}\n\n// 👉 Slider\n.v-slider.v-input--horizontal .v-slider-track__fill {\n  block-size: var(--v-slider-track-size);\n}\n\n.v-slider.v-input--vertical .v-slider-track__fill {\n  inline-size: var(--v-slider-track-size);\n}\n\n.v-slider-thumb {\n  .v-slider-thumb__label {\n    background: rgb(117, 117, 117);\n    color: rgb(var(--v-theme-on-primary));\n\n    &::before {\n      color: rgb(117, 117, 117);\n    }\n  }\n}\n\n// 👉 Switch\n.v-switch {\n  .v-selection-control:not(.v-selection-control--dirty) .v-switch__thumb {\n    color: #fff;\n  }\n}\n\n// 👉 Table\n.v-table--density-default > .v-table__wrapper > table > tbody > tr > td,\n.v-table--density-default > .v-table__wrapper > table > thead > tr > td,\n.v-table--density-default > .v-table__wrapper > table > tfoot > tr > td {\n  block-size: 50px !important;\n}\n\n.v-table {\n  --v-table-header-height: 54px !important;\n\n  th {\n    color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important;\n    font-size: 0.75rem;\n\n    .v-data-table-header__content {\n      display: flex;\n      justify-content: space-between;\n    }\n  }\n\n  .v-selection-control {\n    color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) !important;\n    font-size: 1rem;\n  }\n}\n\n.v-data-table {\n  th {\n    background: rgb(var(--v-table-header-background)) !important;\n  }\n}\n\n// 👉 Pagination\n.v-pagination {\n  .v-btn {\n    border-radius: 4px;\n    color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));\n    font-size: 14px;\n    font-weight: 400;\n  }\n}\n\n// 👉 SnackBar\n.v-snackbar--variant-elevated {\n  @include mixins.elevation(6);\n}\n"
  },
  {
    "path": "src/@core/scss/_dark.scss",
    "content": "@use \"@configured-variables\" as variables;\n\n// ————————————————————————————————————\n// Perfect Scrollbar\n// ————————————————————————————————————\n\n.v-application.v-theme--dark {\n  .ps__rail-y,\n  .ps__rail-x {\n    background-color: transparent !important;\n  }\n\n  .ps__thumb-y {\n    background-color: variables.$plugin-ps-thumb-y-dark;\n  }\n}\n"
  },
  {
    "path": "src/@core/scss/_default-layout-w-vertical-nav.scss",
    "content": "@use \"@configured-variables\" as variables;\n@use \"placeholders\" as *;\n@use \"vuetify/lib/styles/tools/_elevation\" as mixins_elevation;\n@use \"misc\";\n@use \"mixins\";\n\n$header: \".layout-navbar\";\n\n@if variables.$layout-vertical-nav-navbar-is-contained {\n  $header: \".layout-navbar .navbar-content-container\";\n}\n\n.layout-wrapper.layout-nav-type-vertical {\n  // SECTION  Layout Navbar\n  // 👉 Elevated navbar\n  @if variables.$vertical-nav-navbar-style == \"elevated\" {\n    // Add transition\n    #{$header} {\n      transition: padding 0.2s ease;\n    }\n\n    // If navbar is contained => Add border radius to header\n    @if variables.$layout-vertical-nav-navbar-is-contained {\n      // #{$header} {\n        // border-radius: 0 0 variables.$default-layout-with-vertical-nav-navbar-footer-roundness variables.$default-layout-with-vertical-nav-navbar-footer-roundness;\n      // }\n    }\n\n    // Scrolled styles for sticky navbar\n    @at-root {\n      /* ℹ️ Only apply scrolled styles when window is actually scrolled,\n        not when dialog is opened without scroll\n    */\n      &.window-scrolled.layout-navbar-fixed {\n\n        #{$header} {\n          padding-inline: 1rem;\n\n          @extend %default-layout-vertical-nav-scrolled-sticky-elevated-nav;\n          @extend %default-layout-vertical-nav-floating-navbar-and-sticky-elevated-navbar-scrolled;\n        }\n\n        .navbar-blur#{$header} {\n          @extend %blurry-bg;\n        }\n      }\n\n      /* ℹ️ Ensure header styles are preserved when dialog is opened,\n        but only if window was scrolled before dialog opened\n    */\n      html.v-overlay-scroll-blocked &.window-scrolled.layout-navbar-fixed {\n\n        #{$header} {\n          padding-inline: 1rem;\n\n          @extend %default-layout-vertical-nav-scrolled-sticky-elevated-nav;\n          @extend %default-layout-vertical-nav-floating-navbar-and-sticky-elevated-navbar-scrolled;\n        }\n\n        .navbar-blur#{$header} {\n          @extend %blurry-bg;\n        }\n      }\n    }\n  }\n\n  // 👉 Floating navbar\n  @else if  variables.$vertical-nav-navbar-style == \"floating\" {\n    // ℹ️ Regardless of navbar is contained or not => Apply overlay to .layout-navbar\n    .layout-navbar {\n      &.navbar-blur {\n        @extend %default-layout-vertical-nav-floating-navbar-overlay;\n      }\n    }\n\n    &:not(.layout-navbar-fixed) {\n      #{$header} {\n        margin-block-start: variables.$vertical-nav-floating-navbar-top;\n      }\n    }\n\n    #{$header} {\n      @if variables.$layout-vertical-nav-navbar-is-contained {\n        border-radius: variables.$default-layout-with-vertical-nav-navbar-footer-roundness;\n      }\n\n      background-color: rgb(var(--v-theme-surface));\n\n      @extend %default-layout-vertical-nav-floating-navbar-and-sticky-elevated-navbar-scrolled;\n    }\n\n    .navbar-blur#{$header} {\n      @extend %blurry-bg;\n    }\n  }\n\n  // !SECTION\n\n  // 👉 Layout footer\n  .layout-footer {\n    $ele-layout-footer: &;\n\n    .footer-content-container {\n      border-radius: variables.$default-layout-with-vertical-nav-navbar-footer-roundness variables.$default-layout-with-vertical-nav-navbar-footer-roundness 0 0;\n\n      // Sticky footer\n      @at-root {\n        // ℹ️ .layout-footer-sticky#{$ele-layout-footer} => .layout-footer-sticky.layout-wrapper.layout-nav-type-vertical .layout-footer\n        .layout-footer-sticky#{$ele-layout-footer} {\n          .footer-content-container {\n            background-color: rgb(var(--v-theme-surface));\n            padding-block: 0;\n            padding-inline: 1.2rem;\n\n            @include mixins.elevation(3);\n          }\n        }\n      }\n    }\n  }\n} \n"
  },
  {
    "path": "src/@core/scss/_default-layout.scss",
    "content": "@use \"placeholders\";\n@use \"variables\" as core-vars;\n\n.layout-navbar {\n  @if core-vars.$navbar-high-emphasis-text {\n    @extend %layout-navbar;\n  }\n}\n"
  },
  {
    "path": "src/@core/scss/_misc.scss",
    "content": "// ℹ️ scrollable-content allows creating fixed header and scrollable content for VNavigationDrawer (Used when perfect scrollbar is used)\n.scrollable-content {\n  &.v-navigation-drawer {\n    .v-navigation-drawer__content {\n      display: flex;\n      overflow: hidden;\n      flex-direction: column;\n    }\n  }\n}\n\n// ℹ️ adding styling for code tag\ncode {\n  border-radius: 3px;\n  background: rgba(var(--v-code-background-color), var(--v-focus-opacity));\n  color: currentcolor;\n  font-size: 85%;\n  font-weight: 400;\n  padding-block: 0.2em;\n  padding-inline: 0.4em;\n}\n\n%blurry-bg {\n  position: relative;\n  box-shadow: 0 1px 3px rgba(0, 0, 0, 4%), 0 1px 2px rgba(0, 0, 0, 2%);\n\n  @media (width >= 1280px) and (hover: hover) {\n    background: rgba(var(--v-theme-background), 1);\n\n    .v-theme--transparent & {\n      backdrop-filter: blur(var(--transparent-blur-light, 5px));\n      background: rgba(var(--v-theme-background), var(--transparent-opacity-light, 0.1)) !important;\n    }\n  }\n\n  @media (width < 1280px), (hover: none) {\n    background: transparent;\n\n    &::before {\n      position: absolute;\n      z-index: -1;\n      backdrop-filter: blur(24px);\n      block-size: calc(env(safe-area-inset-top, 0px) + var(--navbar-tab-height) + 4rem);\n      content: \"\";\n      inset-block-start: 0;\n      inset-inline: 0;\n      pointer-events: none;\n      transition: padding 0.3s ease-in-out;\n      \n      .v-theme--light & {\n        background: rgba(var(--v-theme-surface), 0.6);\n      }\n      \n      .v-theme--dark & {\n        background: rgba(var(--v-theme-background), 0.5);\n      }\n\n      .v-theme--purple & {\n        background: rgba(var(--v-theme-background), 0.5);\n      }\n\n      .v-theme--transparent & {\n        backdrop-filter: blur(var(--transparent-blur-heavy, 16px));\n        background: rgba(var(--v-theme-background), var(--transparent-opacity-heavy, 0.5));\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/@core/scss/_mixins.scss",
    "content": "@use \"sass:map\";\n@use \"vuetify/lib/styles/settings/_index.sass\" as vuetify_settings;\n@use \"@styles/variables/_vuetify.scss\" as vuetify;\n\n@mixin themed($property, $light-value, $dark-value) {\n  @at-root {\n    .v-theme {\n      &--light {\n        #{$property}: $light-value;\n      }\n\n      &--dark {\n        #{$property}: $dark-value;\n      }\n    }\n  }\n}\n\n// ℹ️ This mixin is inspired from vuetify for adding hover styles via before pseudo element\n@mixin before-pseudo() {\n  position: relative;\n\n  &::before {\n    position: absolute;\n    border-radius: inherit;\n    background: currentcolor;\n    block-size: 100%;\n    content: \"\";\n    inline-size: 100%;\n    inset: 0;\n    opacity: 0;\n    pointer-events: none;\n  }\n}\n\n// ——— Light background generator ——————— //\n// ℹ️ With this you have to give text color to the component you want light bg\n// e.g. class=\"avatar-initial text-primary\" for primary light bg\n@mixin light-bg-provider($component, $inner-selector: \"\", $opacity: 0.12) {\n  .#{$component}.#{$component}-light-bg #{$inner-selector} {\n    background-color: transparent !important;\n\n    &.bg-static-white {\n      background-color: white !important;\n    }\n\n    &::before {\n      position: absolute;\n      border-radius: inherit;\n      background-color: currentcolor;\n      content: \"\";\n      inset: 0;\n      opacity: $opacity;\n      pointer-events: none;\n    }\n  }\n}\n\n@mixin avatar-font-sizes($map: $avatar-sizes) {\n  @each $sizeName, $multiplier in vuetify_settings.$size-scales {\n    /* stylelint-disable-next-line scss/no-global-function-names */\n    $size: map.get($map, $sizeName);\n\n    &.v-avatar--size-#{$sizeName} {\n      font-size: #{$size}px;\n    }\n  }\n}\n\n@mixin elevation($z, $important: false) {\n  box-shadow: map.get(vuetify.$shadow-key-umbra, $z), map.get(vuetify.$shadow-key-penumbra, $z), map.get(vuetify.$shadow-key-ambient, $z) if($important, !important, null);\n}\n\n@mixin bordered-skin($component, $border-property: \"border\", $important: false) {\n  #{$component} {\n    box-shadow: none !important;\n    #{$border-property}: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)) if($important, !important, null);\n  }\n}\n\n@mixin selected-states($selector) {\n  #{$selector} {\n    opacity: calc(var(--v-selected-opacity) * var(--v-theme-overlay-multiplier));\n  }\n\n  &:hover\n  #{$selector} {\n    opacity: calc(var(--v-selected-opacity) + var(--v-hover-opacity) * var(--v-theme-overlay-multiplier));\n  }\n\n  &:focus-visible\n  #{$selector} {\n    opacity: calc(var(--v-selected-opacity) + var(--v-focus-opacity) * var(--v-theme-overlay-multiplier));\n  }\n\n  @supports not selector(:focus-visible) {\n    &:focus {\n      #{$selector} {\n        opacity: calc(var(--v-selected-opacity) + var(--v-focus-opacity) * var(--v-theme-overlay-multiplier));\n      }\n    }\n  }\n}\n\n@mixin push-anchors() {\n  :target {\n    scroll-margin-block-start: 90px;\n  }\n}\n\n@mixin xs {\n  @media (width >= 0) and (width <= 599.98px) {\n    @content;\n  }\n}\n\n@mixin sm {\n  @media (width >= 600px) and (width <= 959.98px) {\n    @content;\n  }\n}\n\n@mixin md {\n  @media (width >= 960px) and (width <= 1279.98px) {\n    @content;\n  }\n}\n\n@mixin lg {\n  @media (width >= 1280px) and (width <= 1919.98px) {\n    @content;\n  }\n}\n\n@mixin xl {\n  @media (width >= 1920px) {\n    @content;\n  }\n}\n"
  },
  {
    "path": "src/@core/scss/_utilities.scss",
    "content": ".bg-var-theme-background {\n  background-color: rgba(var(--v-theme-on-surface), var(--v-hover-opacity)) !important;\n}\n\n// 👉 Pagination small-select dropdown for table\n// TODO: remove this class after vuetify datatable implememtation\n\n.per-page-select {\n  margin-block: auto;\n\n  .v-field__input {\n    align-items: center;\n    padding: 2px;\n    font-size: 14px;\n  }\n\n  .v-field__append-inner {\n    align-items: center;\n    padding: 0;\n\n    .v-icon {\n      margin-inline-start: 0 !important;\n    }\n  }\n}\n"
  },
  {
    "path": "src/@core/scss/_utils.scss",
    "content": "@use \"sass:map\";\n@use \"sass:list\";\n@use \"sass:string\";\n\n// Thanks: https://css-tricks.com/snippets/sass/deep-getset-maps/\n@function map-deep-get($map, $keys...) {\n  @each $key in $keys {\n    $map: map.get($map, $key);\n  }\n\n  @return $map;\n}\n\n@function map-deep-set($map, $keys, $value) {\n  $maps: ($map,);\n  $result: null;\n\n  // If the last key is a map already\n  // Warn the user we will be overriding it with $value\n  @if type-of(nth($keys, -1)) == \"map\" {\n    @warn \"The last key you specified is a map; it will be overrided with `#{$value}`.\";\n  }\n\n  // If $keys is a single key\n  // Just merge and return\n  @if length($keys) == 1 {\n    @return map-merge($map, ($keys: $value));\n  }\n\n  // Loop from the first to the second to last key from $keys\n  // Store the associated map to this key in the $maps list\n  // If the key doesn't exist, throw an error\n  @for $i from 1 through length($keys) - 1 {\n    $current-key: list.nth($keys, $i);\n    $current-map: list.nth($maps, -1);\n    $current-get: map.get($current-map, $current-key);\n\n    @if not $current-get {\n      @error \"Key `#{$key}` doesn't exist at current level in map.\";\n    }\n\n    $maps: list.append($maps, $current-get);\n  }\n\n  // Loop from the last map to the first one\n  // Merge it with the previous one\n  @for $i from length($maps) through 1 {\n    $current-map: list.nth($maps, $i);\n    $current-key: list.nth($keys, $i);\n    $current-val: if($i == list.length($maps), $value, $result);\n    $result: map.map-merge($current-map, ($current-key: $current-val));\n  }\n\n  // Return result\n  @return $result;\n}\n\n// font size utility classes\n// font size\n$font-sizes: (\n  \"xs\": 0.75rem,\n  \"sm\": 0.875rem,\n  \"base\": 1rem,\n  \"lg\": 1.125rem,\n  \"xl\": 1.25rem,\n  \"2xl\": 1.5rem,\n  \"3xl\": 1.875rem,\n  \"4xl\": 2.25rem,\n  \"5xl\": 3rem,\n  \"6xl\": 3.75rem,\n  \"7xl\": 4.5rem,\n  \"8xl\": 6rem,\n  \"9xl\": 8rem\n);\n\n// font line-height\n$font-line-height: (\n  \"xs\": 1rem,\n  \"sm\": 1.25rem,\n  \"base\": 1.5rem,\n  \"lg\": 1.75rem,\n  \"xl\": 1.75rem,\n  \"2xl\": 2rem,\n  \"3xl\": 2.25rem,\n  \"4xl\": 2.5rem,\n  \"5xl\": 1,\n  \"6xl\": 1,\n  \"7xl\": 1,\n  \"8xl\": 1,\n  \"9xl\": 1\n);\n\n@each $name, $size in $font-sizes {\n  .text-#{$name} {\n    font-size: $size;\n    line-height: map.get($font-line-height, $name);\n  }\n}\n\n// truncate utility class\n.truncate {\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n// gap utility class\n$gap: (\n  \"0\": 0,\n  \"1\": 0.25rem,\n  \"2\": 0.5rem,\n  \"3\": 0.75rem,\n  \"4\": 1rem,\n  \"5\": 1.25rem,\n  \"6\":1.5rem,\n  \"7\": 1.75rem,\n  \"8\": 2rem,\n  \"9\": 2.25rem,\n  \"10\": 2.5rem,\n  \"11\": 2.75rem,\n  \"12\": 3rem,\n  \"14\": 3.5rem,\n  \"16\": 4rem,\n  \"20\": 5rem,\n  \"24\": 6rem,\n  \"28\": 7rem,\n  \"32\": 8rem,\n  \"36\": 9rem,\n  \"40\": 10rem,\n  \"44\": 11rem,\n  \"48\": 12rem,\n  \"52\": 13rem,\n  \"56\": 14rem,\n  \"60\": 15rem,\n  \"64\": 16rem,\n  \"72\": 18rem,\n  \"80\": 20rem,\n  \"96\": 24rem\n);\n\n@each $name, $size in $gap {\n  .gap-#{$name} {\n    gap: $size;\n  }\n\n  .gap-x-#{$name} {\n    column-gap: $size;\n  }\n\n  .gap-y-#{$name} {\n    row-gap: $size;\n  }\n}\n\n/*\n  ℹ️ This function is helpful when we have multi dimensional value\n\n  Assume we have padding variable `$nav-padding-horizontal: 10px;`\n  With above variable let's say we use it in some style:\n  ```scss\n  .selector {\n    margin-left: $nav-padding-horizontal;\n  }\n  ```\n\n  Now, problem is we can also have value as `$nav-padding-horizontal: 10px 15px;`\n  In this case above style will be invalid.\n\n  This function will extract the left most value from the variable value.\n\n  $nav-padding-horizontal: 10px; => 10px;\n  $nav-padding-horizontal: 10px 15px; => 10px;\n\n  This is safe:\n  ```scss\n  .selector {\n    margin-left: get-first-value($nav-padding-horizontal);\n  }\n  ```\n*/\n@function get-first-value($var) {\n  $start-at: string.index(#{$var}, \" \");\n\n  @if $start-at {\n    @return string.slice(\n      #{$var},\n      0,\n      $start-at\n    );\n  } @else {\n    @return $var;\n  }\n}\n"
  },
  {
    "path": "src/@core/scss/_variables.scss",
    "content": "/*\n  TODO: Add docs on when to use placeholder vs when to use SASS variable\n\n  Placeholder\n    - When we want to keep customization to our self between templates use it\n\n  Variables\n    - When we want to allow customization from both user and our side\n    - You can also use variable for consistency (e.g. mx 1 rem should be applied to both vertical nav items and vertical nav header)\n*/\n@use \"sass:map\";\n@use \"utils\";\n@use \"vuetify/lib/styles/tools/functions\" as *;\n\n// 合并两个文件中的@forward配置\n@forward \"@layouts/styles/variables\" with (\n  // 来自_variables.scss的配置\n  $layout-vertical-nav-collapsed-width: 68px !default,\n  \n  // 来自template/_variables.scss的配置\n  $layout-vertical-nav-z-index: 1004,\n  $layout-overlay-z-index: 1003\n);\n\n// 使用命名空间来避免变量冲突\n@use \"@layouts/styles/variables\" as layouts-vars;\n\n$vertical-nav-horizontal-padding-custom: 1.375rem 1rem;\n\n// ℹ️ We created this SCSS var to extract the start padding\n// Docs: https://sass-lang.com/documentation/modules/string\n// $vertical-nav-horizontal-padding => 0 8px;\n// string.index(#{$vertical-nav-horizontal-padding}, \" \") + 1 => 2\n//   string.index(#{$vertical-nav-horizontal-padding}, \" \") => 1\n// string.slice(0 8px, 2, -1) => 8px => $card-actions-padding-x\n\n$vertical-nav-horizontal-padding-start: utils.get-first-value($vertical-nav-horizontal-padding-custom) !default;\n$vertical-nav-items-icon-margin-inline-end: 0.625rem !default;\n\n// Vertical Nav Configuration\n$vertical-nav-collapsed-width: 68px !default;\n\n// ℹ️ This is used to keep consistency between nav items and nav header left & right margin\n// This is used by nav items & nav header\n$vertical-nav-horizontal-spacing: 0 1.125rem !default;\n$vertical-nav-horizontal-padding: $vertical-nav-horizontal-padding-custom !default;\n\n// Vertical nav header padding\n$vertical-nav-header-padding: 1rem 0.25rem 1rem $vertical-nav-horizontal-padding-start !default;\n\n// 👉 Custom Variables\n$avatar-font-sizes: (\n  \"x-small\":12,\n  \"small\":14,\n  \"default\":18,\n  \"large\":20,\n  \"x-large\":24\n) !default;\n\n$theme-colors-name: (\n  \"primary\",\n  \"secondary\",\n  \"error\",\n  \"info\",\n  \"success\",\n  \"warning\"\n) !default;\n\n// 👉 Default layout with vertical nav\n\n$default-layout-with-vertical-nav-navbar-footer-roundness: 10px !default;\n\n// 👉 Vertical nav\n$vertical-nav-background-color-rgb: var(--v-theme-background) !default;\n$vertical-nav-background-color: rgb(#{$vertical-nav-background-color-rgb}) !default;\n\n// Vertical nav header height. Mostly we will align it with navbar height;\n$vertical-nav-header-height: layouts-vars.$layout-vertical-nav-navbar-height !default;\n$vertical-nav-navbar-elevation: 3 !default;\n$vertical-nav-navbar-style: \"elevated\" !default; // options: elevated, floating\n$vertical-nav-floating-navbar-top: 1rem !default;\n\n$vertical-nav-header-inline-spacing: $vertical-nav-horizontal-spacing !default;\n\n// Move logo when vertical nav is mini (collapsed but not hovered)\n$vertical-nav-header-logo-translate-x-when-vertical-nav-mini: -4px !default;\n\n// Space between logo and title\n$vertical-nav-header-logo-title-spacing: 0.9rem !default;\n\n// Section title margin top (when its not first child)\n$vertical-nav-section-title-mt: 1.5rem !default;\n\n// Section title margin bottom\n$vertical-nav-section-title-mb: 0.5rem !default;\n\n// Vertical nav icons\n$vertical-nav-items-icon-size: 1.5rem !default;\n$vertical-nav-items-nested-icon-size: 0.9rem !default;\n\n// Transition duration for nav group arrow\n$vertical-nav-nav-group-arrow-transition-duration: 0.15s !default;\n\n// Timing function for nav group arrow\n$vertical-nav-nav-group-arrow-transition-timing-function: ease-in-out !default;\n\n// 👉 Horizontal nav\n\n/*\n    ❗ Heads up\n    ==================\n    Here we assume we will always use shorthand property which will apply same padding on four side\n    This is because this have been used as value of top property by `.popper-content`\n*/\n$horizontal-nav-padding: 0.6875rem !default;\n\n// Gap between top level horizontal nav items\n$horizontal-nav-top-level-items-gap: 4px !default;\n\n// Horizontal nav icons\n$horizontal-nav-items-icon-size: 1.5rem !default;\n$horizontal-nav-third-level-icon-size: 0.9rem !default;\n$horizontal-nav-items-icon-margin-inline-end: 0.625rem !default;\n\n// ℹ️ We used SCSS variable because we want to allow users to update max height of popper content\n// 120px is combined height of navbar & horizontal nav\n$horizontal-nav-popper-content-max-height: calc((var(--vh, 1vh) * 100) - 120px - 4rem) !default;\n\n// ℹ️ This variable is used for horizontal nav popper content's `margin-top` and \"The bridge\"'s height. We need to sync both values.\n$horizontal-nav-popper-content-top: calc($horizontal-nav-padding + 0.375rem) !default;\n\n// 👉 Plugins\n\n$plugin-ps-thumb-y-dark: rgba(var(--v-theme-surface-variant), 0.35) !default;\n\n// 👉 Vuetify\n\n// Used in src/@core/scss/base/libs/vuetify/_overrides.scss\n$vuetify-reduce-default-compact-button-icon-size: true !default;\n\n// 👉 Custom variables\n// for utility classes\n$font-sizes: () !default;\n$font-sizes: map-deep-merge(\n  (\n    \"xs\": 0.75rem,\n    \"sm\": 0.875rem,\n    \"base\": 1rem,\n    \"lg\": 1.125rem,\n    \"xl\": 1.25rem,\n    \"2xl\": 1.5rem,\n    \"3xl\": 1.875rem,\n    \"4xl\": 2.25rem,\n    \"5xl\": 3rem,\n    \"6xl\": 3.75rem,\n    \"7xl\": 4.5rem,\n    \"8xl\": 6rem,\n    \"9xl\": 8rem\n  ),\n  $font-sizes\n);\n\n// line height\n$font-line-height: () !default;\n$font-line-height: map-deep-merge(\n  (\n    \"xs\": 1rem,\n    \"sm\": 1.25rem,\n    \"base\": 1.5rem,\n    \"lg\": 1.75rem,\n    \"xl\": 1.75rem,\n    \"2xl\": 2rem,\n    \"3xl\": 2.25rem,\n    \"4xl\": 2.5rem,\n    \"5xl\": 1,\n    \"6xl\": 1,\n    \"7xl\": 1,\n    \"8xl\": 1,\n    \"9xl\": 1\n  ),\n  $font-line-height\n);\n\n// gap utility class\n$gap: () !default;\n$gap: map-deep-merge(\n  (\n    \"0\": 0,\n    \"1\": 0.25rem,\n    \"2\": 0.5rem,\n    \"3\": 0.75rem,\n    \"4\": 1rem,\n    \"5\": 1.25rem,\n    \"6\":1.5rem,\n    \"7\": 1.75rem,\n    \"8\": 2rem,\n    \"9\": 2.25rem,\n    \"10\": 2.5rem,\n    \"11\": 2.75rem,\n    \"12\": 3rem,\n    \"14\": 3.5rem,\n    \"16\": 4rem,\n    \"20\": 5rem,\n    \"24\": 6rem,\n    \"28\": 7rem,\n    \"32\": 8rem,\n    \"36\": 9rem,\n    \"40\": 10rem,\n    \"44\": 11rem,\n    \"48\": 12rem,\n    \"52\": 13rem,\n    \"56\": 14rem,\n    \"60\": 15rem,\n    \"64\": 16rem,\n    \"72\": 18rem,\n    \"80\": 20rem,\n    \"96\": 24rem\n  ),\n  $gap\n);\n\n// 👉 Default layout\n\n$navbar-high-emphasis-text: true !default;\n"
  },
  {
    "path": "src/@core/scss/_vertical-nav.scss",
    "content": "@use \"./placeholders\";\n@use \"@configured-variables\" as variables;\n@use \"./mixins\" as mixins;\n@use \"vuetify/lib/styles/tools/states\" as vuetifyStates;\n@use \"vuetify/lib/styles/tools/elevation\" as elevation;\n\n.layout-nav-type-vertical {\n  // 👉 Layout Vertical nav\n  .layout-vertical-nav {\n    $sl-layout-nav-type-vertical: &;\n\n    @extend %nav;\n\n    background-color: variables.$vertical-nav-background-color;\n\n    // 👉 Nav header\n    .nav-header {\n      overflow: hidden;\n      padding: variables.$vertical-nav-header-padding;\n      margin-inline: variables.$vertical-nav-header-inline-spacing;\n      min-block-size: variables.$vertical-nav-header-height;\n\n      // TEMPLATE: Check if we need to move this to master\n      .app-logo {\n        flex-shrink: 0;\n        transition: transform 0.25s ease-in-out;\n\n        @at-root {\n          // Move logo a bit to align center with the icons in vertical nav mini variant\n          .layout-vertical-nav-collapsed#{$sl-layout-nav-type-vertical}:not(.hovered) .nav-header .app-logo {\n            transform: translateX(variables.$vertical-nav-header-logo-translate-x-when-vertical-nav-mini);\n          }\n        }\n      }\n\n      .app-title {\n        margin-inline-start: variables.$vertical-nav-header-logo-title-spacing;\n      }\n    }\n\n    // 👉 Nav items shadow\n    .vertical-nav-items-shadow {\n      position: absolute;\n      z-index: 1;\n      background:\n        linear-gradient(\n          rgb(#{variables.$vertical-nav-background-color-rgb}) 5%,\n          rgba(#{variables.$vertical-nav-background-color-rgb}, 85%) 30%,\n          rgba(#{variables.$vertical-nav-background-color-rgb}, 50%) 65%,\n          rgba(#{variables.$vertical-nav-background-color-rgb}, 30%) 75%,\n          transparent\n        );\n      block-size: calc(env(safe-area-inset-top) + 4rem);\n      inline-size: 100%;\n      inset-block-start: calc(#{variables.$vertical-nav-header-height} - 2px);\n      opacity: 0;\n      pointer-events: none;\n      transform: translateX(-8px);\n      transition: opacity 0.15s ease-in-out;\n      will-change: opacity;\n    }\n\n    &.scrolled {\n      .vertical-nav-items-shadow {\n        opacity: 1;\n      }\n    }\n\n    // 👉 Nav section title\n    .nav-section-title {\n      @extend %vertical-nav-item;\n      @extend %vertical-nav-section-title;\n\n      // ℹ️ Update the margin-inline-end when vertical nav is in mini state. We done same for link & group.\n      @at-root {\n          .layout-nav-type-vertical.layout-vertical-nav-collapsed .layout-vertical-nav:not(.hovered) .nav-section-title {\n            margin-inline: 4px 0;\n          }\n        }\n\n      margin-block-end: variables.$vertical-nav-section-title-mb;\n\n      &:not(:first-child) {\n        margin-block-start: variables.$vertical-nav-section-title-mt;\n      }\n\n      .placeholder-icon {\n        margin-inline: auto;\n      }\n    }\n\n    // Nav item badge\n    .nav-item-badge {\n      @extend %vertical-nav-item-badge;\n    }\n\n    // 👉 Nav Link\n    .nav-link {\n      overflow: hidden;\n\n      > :first-child {\n        @extend %vertical-nav-item;\n        @extend %vertical-nav-item-interactive;\n        \n        // ℹ️ Update the margin-inline-end when vertical nav is in mini state. We done same for section title.\n        @at-root {\n          .layout-nav-type-vertical.layout-vertical-nav-collapsed .layout-vertical-nav:not(.hovered) .nav-link > :first-child, .layout-nav-type-vertical .layout-vertical-nav .nav-group > :first-child {\n            margin-inline: 0 5px;\n          }\n        }\n      }\n\n      .nav-item-icon {\n        @extend %vertical-nav-items-icon;\n      }\n\n      &.disabled {\n        opacity: var(--v-disabled-opacity);\n        pointer-events: none;\n      }\n\n      > .router-link-exact-active {\n        @extend %nav-link-active;\n      }\n\n      > a {\n        // Adds before psudo element to style hover state\n        @include mixins.before-pseudo;\n\n        // Adds vuetify states\n        @include vuetifyStates.states($active: false);\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/@core/scss/index.scss",
    "content": "@use \"sass:map\";\n\n// 基础变量和配置\n@use \"variables\";\n@use \"mixins\";\n@use \"utils\";\n\n// 布局相关\n@use \"default-layout\";\n@use \"vertical-nav\";\n@use \"default-layout-w-vertical-nav\";\n\n// 组件样式\n@use \"components\";\n\n// 工具类\n@use \"utilities\";\n\n// 其他样式\n@use \"misc\";\n@use \"dark\";\n\n// 第三方库样式\n@use \"libs/perfect-scrollbar\";\n@use \"libs/apex-chart\";\n@use \"libs/full-calendar\";\n@use \"libs/vuetify\";\n\n// 全局样式\na {\n  color: rgb(var(--v-theme-primary));\n  text-decoration: none;\n}\n\n// Vuetify 3 don't provide margin bottom style like vuetify 2\np {\n  margin-block-end: 1rem;\n}\n\n// Iconify icon size\nsvg.iconify {\n  block-size: 1em;\n  inline-size: 1em;\n}\n"
  },
  {
    "path": "src/@core/scss/libs/apex-chart.scss",
    "content": "@use \"@configured-variables\" as variables;\n@use \"../mixins\";\n\n// 👉 Apex chart\n.apexcharts-canvas {\n  // For RTL alignment\n  .apexcharts-yaxis-texts-g {\n    text-align: start;\n  }\n\n  // Tooltip\n  .apexcharts-tooltip {\n    line-height: 1.5;\n\n    .apexcharts-tooltip-title {\n      border-color: rgba(var(--v-border-color), var(--v-border-opacity));\n      background: rgb(var(--v-theme-surface));\n      font-weight: 500;\n      margin-block-end: 0.25rem;\n      padding-inline: 1rem;\n    }\n\n    .apexcharts-tooltip-text {\n      display: flex;\n      align-items: center;\n      color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));\n      font-size: inherit;\n      gap: 0.5rem;\n      line-height: inherit;\n    }\n\n    .apexcharts-tooltip-text-label,\n    .apexcharts-tooltip-text-value {\n      font-weight: 600;\n      line-height: 1.5;\n    }\n    \n    .apexcharts-tooltip-series-group {\n      padding-block: 0 0.5rem;\n      padding-inline: 1rem;\n\n      &:last-child {\n        padding-block-end: 1rem;\n      }\n\n      &.active {\n        padding-block-start: 0;\n      }\n    }\n\n    &.apexcharts-theme-light {\n      border-color: rgb(var(--v-border-color));\n      background: rgb(var(--v-theme-surface));\n      box-shadow: none;\n      \n      .apexcharts-tooltip-text-label,\n      .apexcharts-tooltip-text-value {\n        color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));\n      }\n    }\n  }\n\n  .apexcharts-marker {\n    transition: none;\n  }\n\n  // 👉 stroke-dasharray\n  .apexcharts-radialbar,\n  .apexcharts-radialbar-slice-current {\n    stroke-linecap: round;\n  }\n\n  .apexcharts-xaxistooltip,\n  .apexcharts-yaxistooltip {\n    border-color: rgb(var(--v-border-color));\n    background: rgb(var(--v-theme-surface));\n    color: rgba(var(--v-theme-on-background), var(--v-medium-emphasis-opacity));\n\n    &::after,\n    &::before {\n      border-block-end-color: rgb(var(--v-border-color));\n    }\n  }\n\n  // 👉 Text color\n  .apexcharts-text,\n  .apexcharts-tooltip-text,\n  .apexcharts-datalabel-label,\n  .apexcharts-datalabel,\n  .apexcharts-xaxistooltip-text,\n  .apexcharts-yaxistooltip-text,\n  .apexcharts-legend-text {\n    color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity)) !important;\n    font-family: inherit !important;\n  }\n\n  // 👉 Annotation Label\n  .apexcharts-annotation-rect {\n    &.apexcharts-xaxis-annotation-rect,\n    &.apexcharts-yaxis-annotation-rect {\n      fill-opacity: 0.05;\n      stroke-opacity: 0;\n    }\n  }\n}\n"
  },
  {
    "path": "src/@core/scss/libs/full-calendar.scss",
    "content": "@use \"../mixins\";\n@use \"@configured-variables\" as variables;\n\n.v-application .fc {\n  --fc-today-bg-color: rgba(var(--v-theme-on-surface), 0.04);\n  --fc-border-color: rgba(var(--v-border-color), var(--v-border-opacity));\n  --fc-neutral-bg-color: rgb(var(--v-theme-background));\n  --fc-list-event-hover-bg-color: rgba(var(--v-theme-on-surface), 0.02);\n  --fc-page-bg-color: rgb(var(--v-theme-surface));\n  --fc-event-border-color: currentcolor;\n\n  a {\n    color: inherit;\n  }\n\n  .fc-timegrid-divider {\n    padding: 0;\n  }\n\n  .fc-toolbar-title {\n    display: inline-block;\n    color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));\n    font-size: 1.25rem;\n    font-weight: 500;\n    margin-inline-start: 0.25rem;\n  }\n\n  .fc-col-header-cell-cushion {\n    color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));\n    font-size: 0.875rem;\n    font-weight: 600;\n  }\n\n  .fc-event-time {\n    font-size: 0.75rem;\n  }\n\n  .fc-timegrid-event {\n    .fc-event-title {\n      font-size: 0.875rem;\n    }\n  }\n\n  .fc-prev-button {\n    padding-inline-start: 0;\n  }\n\n  .fc-prev-button,\n  .fc-next-button {\n    padding: 0.25rem;\n  }\n\n  .fc-col-header .fc-col-header-cell .fc-col-header-cell-cushion {\n    padding: 0.5rem;\n    text-decoration: none !important;\n  }\n\n  .fc-timegrid .fc-timegrid-slots .fc-timegrid-slot {\n    block-size: 3rem;\n  }\n\n  // Removed double border on left in list view\n  .fc-list {\n    border-inline-start: none;\n    font-size: 0.875rem;\n\n    .fc-list-day-cushion.fc-cell-shaded {\n      background-color: rgba(var(--v-theme-on-surface), var(--v-hover-opacity));\n      color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));\n      font-weight: 600;\n    }\n\n    .fc-list-event-time,\n    .fc-list-event-title {\n      color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));\n    }\n\n    .fc-list-day .fc-list-day-text,\n    .fc-list-day .fc-list-day-side-text {\n      text-decoration: none;\n    }\n  }\n\n  .fc-timegrid-axis {\n    color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));\n    font-size: 0.75rem;\n    text-transform: capitalize;\n  }\n\n  .fc-timegrid-slot-label-frame {\n    color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));\n    font-size: 0.75rem;\n    text-align: center;\n    text-transform: uppercase;\n  }\n\n  .fc-header-toolbar {\n    flex-wrap: wrap;\n    margin: 1.25rem;\n    gap: 1rem 0.5rem;\n  }\n\n  // 👉 Toolbar Chunk and Button Group\n  .fc-toolbar-chunk {\n    display: flex;\n    align-items: center;\n\n    .fc-button-group {\n      .fc-button-primary {\n        &,\n        &:focus,\n        &:hover,\n        &:not(.disabled):active {\n          border-color: transparent;\n          background-color: transparent;\n          box-shadow: none !important;\n          color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));\n        }\n      }\n    }\n\n    // 👉 sidebar toggler\n    .fc-drawerToggler-button {\n      display: none;\n      background-image: url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='rgba(94,86,105,0.68)' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round' class='css-i6dzq1'%3E%3Cpath d='M3 12h18M3 6h18M3 18h18'/%3E%3C/svg%3E\");\n      background-position: 50%;\n      background-repeat: no-repeat;\n      block-size: 1.5625rem;\n      font-size: 0;\n      inline-size: 1.5625rem;\n      margin-inline-end: 0.25rem;\n\n      @media (width <= 1264px) {\n        display: block !important;\n      }\n\n      .v-theme--dark & {\n        background-image: url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='rgba(232,232,241,0.68)' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round' class='css-i6dzq1'%3E%3Cpath d='M3 12h18M3 6h18M3 18h18'/%3E%3C/svg%3E\");\n      }\n    }\n\n    // Special styling for the last toolbar chunk\n    &:last-child {\n      .fc-button-group {\n        border: 0.0625rem solid rgba(var(--v-border-color), var(--v-border-opacity));\n        border-radius: 0.375rem;\n\n        .fc-button {\n          font-size: 0.9rem;\n          letter-spacing: 0.0187rem;\n          padding-inline: 1rem;\n          text-transform: uppercase;\n\n          &:not(:last-child) {\n            border-inline-end: 0.0625rem solid rgba(var(--v-border-color), var(--v-border-opacity));\n          }\n\n          &.fc-button-active {\n            background-color: rgba(var(--v-theme-primary), var(--v-activated-opacity));\n            color: rgb(var(--v-theme-primary));\n          }\n        }\n      }\n    }\n  }\n\n  .fc-scrollgrid-section {\n    th {\n      border-inline: 0;\n    }\n  }\n\n  // Calendar content container\n  .fc-view-harness {\n    min-block-size: 40.625rem;\n  }\n\n  .fc-event {\n    border-color: transparent;\n    margin-block-end: 0.3rem;\n    padding-block: 0.1875rem;\n    padding-inline: 0.3125rem;\n  }\n\n  .fc-event-main {\n    color: inherit;\n    font-size: 0.75rem;\n    font-weight: 500;\n    padding-inline: 0.25rem;\n  }\n\n  tbody[role=\"rowgroup\"] {\n    > tr > td[role=\"presentation\"] {\n      border: none;\n    }\n  }\n\n  .fc-scrollgrid {\n    border-inline-start: none;\n  }\n\n  .fc-daygrid-day {\n    padding: 0.3125rem;\n  }\n\n  .fc-daygrid-day-number {\n    padding-block: 0.5rem;\n    padding-inline: 0.75rem;\n  }\n\n  .fc-list-event-dot {\n    color: inherit;\n\n    --fc-event-border-color: currentcolor;\n  }\n\n  .fc-list-event {\n    background-color: transparent !important;\n  }\n\n  .fc-popover {\n    @include mixins.elevation(3);\n\n    border-radius: 6px;\n\n    .fc-popover-header,\n    .fc-popover-body {\n      padding: 0.5rem;\n    }\n\n    .fc-popover-title {\n      margin: 0;\n      font-size: 1rem;\n      font-weight: 500;\n    }\n  }\n\n  // ℹ️ Workaround of https://github.com/fullcalendar/fullcalendar/issues/6407\n  .fc-col-header,\n  .fc-daygrid-body,\n  .fc-scrollgrid-sync-table,\n  .fc-timegrid-body,\n  .fc-timegrid-body table {\n    inline-size: 100% !important;\n  }\n}\n"
  },
  {
    "path": "src/@core/scss/libs/perfect-scrollbar.scss",
    "content": "$ps-size: 0.25rem;\n$ps-hover-size: 0.375rem;\n$ps-track-size: 0.5rem;\n\n.ps__thumb-x,\n.ps__thumb-y {\n  background-color: rgb(var(--v-theme-perfect-scrollbar-thumb)) !important;\n}\n\n.ps__thumb-y {\n  inline-size: $ps-size;\n  inset-inline-end: 0.0625rem;\n}\n\n.ps__thumb-x {\n  block-size: $ps-size !important;\n}\n\n.ps__rail-x {\n  background: transparent !important;\n  block-size: $ps-track-size;\n}\n\n.ps__rail-y {\n  background: transparent !important;\n  inline-size: $ps-track-size !important;\n  inset-inline-end: 0.125rem !important;\n  inset-inline-start: unset !important;\n}\n\n.ps__rail-y.ps--clicking .ps__thumb-y,\n.ps__rail-y:focus > .ps__thumb-y,\n.ps__rail-y:hover > .ps__thumb-y {\n  inline-size: $ps-hover-size;\n}\n\n// fix bug\n@media(hover: none) {\n  .ps > .ps__rail-x,\n  .ps > .ps__rail-y {\n    opacity: 0.6;\n  }\n} \n"
  },
  {
    "path": "src/@core/scss/libs/vuetify/_overrides.scss",
    "content": "@use \"@configured-variables\" as variables;\n@use \"../../utils\";\n\n// 👉 Application\n// ℹ️ We need accurate vh in mobile devices as well\n.v-application__wrap {\n  /* stylelint-disable-next-line liberty/use-logical-spec */\n  min-height: calc(var(--vh, 1vh) * 100);\n}\n\n// 👉 Typography\nh1,\nh2,\nh3,\nh4,\nh5,\nh6,\n.text-h1,\n.text-h2,\n.text-h3,\n.text-h4,\n.text-h5,\n.text-h6,\n.text-button,\n.text-overline,\n.v-card-title {\n  color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));\n}\n\n.text-body-1,\n.text-body-2,\n.text-subtitle-1,\n.text-subtitle-2 {\n  color: rgba(var(--v-theme-on-background), var(--v-medium-emphasis-opacity));\n}\n\n// 👉 Grid\n// Remove margin-bottom of v-input_details inside grid (validation error message)\n.v-row {\n  .v-col,\n  [class^=\"v-col-*\"] {\n    .v-input__details {\n      margin-block-end: 0;\n    }\n  }\n}\n\n// 👉 Button\n@if variables.$vuetify-reduce-default-compact-button-icon-size {\n  .v-btn--density-compact.v-btn--size-default {\n    .v-btn__content > svg {\n      block-size: 22px;\n      font-size: 22px;\n      inline-size: 22px;\n    }\n  }\n}\n\n// 👉 Card\n// Removes padding-top for immediately placed v-card-text after itself\n.v-card-text {\n  & + & {\n    padding-block-start: 0 !important;\n  }\n}\n\n/*\n  👉 Checkbox & Radio Ripple\n\n  TODO Checkbox and switch component. Remove it when vuetify resolve the extra spacing: https://github.com/vuetifyjs/vuetify/issues/15519\n  We need this because form elements likes checkbox and switches are by default set to height of textfield height which is way big than we want\n  Tested with checkbox & switches\n*/\n.v-checkbox.v-input,\n.v-switch.v-input {\n  --v-input-control-height: auto;\n\n  flex: unset;\n}\n\n.v-selection-control--density-comfortable {\n  &.v-checkbox-btn,\n  &.v-radio,\n  &.v-radio-btn {\n    .v-selection-control__wrapper {\n      margin-inline-start: -0.5625rem;\n    }\n  }\n}\n\n.v-selection-control--density-compact {\n  &.v-radio,\n  &.v-radio-btn,\n  &.v-checkbox-btn {\n    .v-selection-control__wrapper {\n      margin-inline-start: -0.3125rem;\n    }\n  }\n}\n\n.v-selection-control--density-default {\n  &.v-checkbox-btn,\n  &.v-radio,\n  &.v-radio-btn {\n    .v-selection-control__wrapper {\n      margin-inline-start: -0.6875rem;\n    }\n  }\n}\n\n.v-radio-group {\n  .v-selection-control-group {\n    .v-radio:not(:last-child) {\n      margin-inline-end: 0.9rem;\n    }\n  }\n}\n\n/*\n  👉 Tabs\n  Disable tab transition\n\n  This is for tabs where we don't have card wrapper to tabs and have multiple cards as tab content.\n\n  This class will disable transition and adds `overflow: unset` on `VWindow` to allow spreading shadow\n*/\n.disable-tab-transition {\n  overflow: unset !important;\n\n  .v-window__container {\n    block-size: auto !important;\n  }\n\n  .v-window-item:not(.v-window-item--active) {\n    display: none !important;\n  }\n\n  .v-window__container .v-window-item {\n    transform: none !important;\n  }\n}\n\n// 👉 List\n.v-list {\n  // Set icons opacity to .87\n  .v-list-item__prepend > .v-icon,\n  .v-list-item__append > .v-icon {\n    opacity: var(--v-high-emphasis-opacity);\n  }\n}\n\n// 👉 Card list\n\n/*\n  ℹ️ Custom class\n\n  Remove list spacing inside card\n\n  This is because card title gets padding of 20px and list item have padding of 16px. Moreover, list container have padding-bottom as well.\n*/\n.card-list {\n  --v-card-list-gap: 20px;\n\n  &.v-list {\n    padding-block: 0;\n  }\n\n  .v-list-item {\n    min-block-size: unset;\n    min-block-size: auto !important;\n    padding-block: 0 !important;\n    padding-inline: 0 !important;\n\n    > .v-ripple__container {\n      opacity: 0;\n    }\n\n    &:not(:last-child) {\n      padding-block-end: var(--v-card-list-gap) !important;\n    }\n  }\n\n  .v-list-item:hover,\n  .v-list-item:focus,\n  .v-list-item:active,\n  .v-list-item.active {\n    > .v-list-item__overlay {\n      opacity: 0 !important;\n    }\n  }\n}\n\n// 👉 Divider\n.v-divider {\n  color: rgb(var(--v-border-color));\n}\n\n// 👉 DataTable\n.v-data-table {\n  /* stylelint-disable-next-line no-descending-specificity */\n  .v-checkbox-btn .v-selection-control__wrapper {\n    margin-inline-start: 0 !important;\n  }\n\n  .v-selection-control {\n    display: flex !important;\n  }\n\n  .v-pagination {\n    color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));\n  }\n}\n\n// 👉 v-field\n.v-field:hover .v-field__outline {\n  --v-field-border-opacity: var(--v-medium-emphasis-opacity);\n}\n\n// 👉 VLabel\n.v-label {\n  opacity: 1 !important;\n\n  &:not(.v-field-label--floating) {\n    color: rgba(var(--v-theme-on-background), var(--v-medium-emphasis-opacity));\n  }\n}\n\n// 👉 Overlay\n.v-overlay__scrim,\n.v-navigation-drawer__scrim {\n  background: rgba(var(--v-overlay-scrim-background), var(--v-overlay-scrim-opacity));\n  opacity: 1;\n}\n\n// 透明主题下全屏弹窗的overlay背景透明度调整\nhtml[data-theme=\"transparent\"] .v-dialog--fullscreen .v-overlay__scrim {\n  background: rgba(var(--v-overlay-scrim-background), 0.3);\n}\n\n// 👉 VMessages\n.v-messages {\n  color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));\n  opacity: 1;\n}\n\n// 👉 Alert close btn\n.v-alert__close {\n  .v-btn--icon .v-icon {\n    --v-icon-size-multiplier: 1.5;\n  }\n}\n\n// 👉 Badge icon alignment\n.v-badge__badge {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n// 👉 Dialog\n.v-dialog--fullscreen {\n  background-color: rgb(var(--v-theme-surface));\n}\n\n// 透明主题下全屏弹窗背景透明\nhtml[data-theme=\"transparent\"] .v-dialog--fullscreen {\n  background-color: transparent !important;\n}\n\n// For dialog card title\n.v-card-item + .v-card-text {\n  padding-block-start: 0 !important;\n}\n\n// 👉 v-slide-group (List of chips)\n.v-slide-group {\n  .v-slide-group__container {\n    display: flex;\n    flex-wrap: wrap;\n\n    // Spacing between buttons in v-slide-group\n    .v-slide-group-item:not(:last-child) {\n      margin-inline-end: 0.5rem;\n    }\n  }\n}\n\n// 👉 Expansion Panel\n.v-expansion-panels {\n  .v-expansion-panel-title {\n    min-block-size: unset !important;\n    padding-block: 1rem !important;\n  }\n}\n\n// 👉 v-textarea\n.v-textarea {\n  textarea {\n    color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));\n\n    &:hover,\n    &:focus {\n      color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));\n    }\n  }\n}\n\n// 👉 Cursor\n.cursor-pointer {\n  cursor: pointer;\n} \n"
  },
  {
    "path": "src/@core/scss/libs/vuetify/_variables.scss",
    "content": "$shadow-key-umbra-opacity-custom: var(--v-shadow-key-umbra-opacity);\n$shadow-key-penumbra-opacity-custom: var(--v-shadow-key-penumbra-opacity);\n$shadow-key-ambient-opacity-custom: var(--v-shadow-key-ambient-opacity);\n/* stylelint-disable-next-line max-line-length */\n$font-family-custom: 'Inter', 'Noto Sans SC', sans-serif, -apple-system, blinkmacsystemfont, \"Segoe UI\", roboto, \"Helvetica Neue\", arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\";\n\n// 👉 Card transition properties\n$card-transition-property-custom: box-shadow, opacity;\n\n@forward \"vuetify/settings\" with (\n  // 👉 General settings\n  $color-pack: false !default,\n\n  // 👉 Shadow opacity\n  $shadow-key-umbra-opacity: $shadow-key-umbra-opacity-custom !default,\n  $shadow-key-penumbra-opacity: $shadow-key-penumbra-opacity-custom !default,\n  $shadow-key-ambient-opacity: $shadow-key-ambient-opacity-custom !default,\n\n  $body-font-family: $font-family-custom !default,\n  $border-radius-root: 6px !default,\n\n  $shadow-key-umbra: (\n    0: (0 0 0 0 var(--v-shadow-key-umbra-opacity)),\n    1: (0 2px 1px -1px var(--v-shadow-key-umbra-opacity)),\n    2: (0 3px 1px -2px var(--v-shadow-key-umbra-opacity)),\n\n    // ℹ️ Modified\n    3: (0 4px 14px -4px var(--v-shadow-key-umbra-opacity)),\n\n    4: (0 2px 4px -1px var(--v-shadow-key-umbra-opacity)),\n    5: (0 3px 5px -1px var(--v-shadow-key-umbra-opacity)),\n\n    // ℹ️ Modified\n    6: (0 4px 5px -2px var(--v-shadow-key-umbra-opacity)),\n\n    7: (0 4px 5px -2px var(--v-shadow-key-umbra-opacity)),\n    8: (0 5px 5px -3px var(--v-shadow-key-umbra-opacity)),\n    9: (0 5px 6px -3px var(--v-shadow-key-umbra-opacity)),\n    10: (0 6px 6px -3px var(--v-shadow-key-umbra-opacity)),\n    11: (0 6px 7px -4px var(--v-shadow-key-umbra-opacity)),\n    12: (0 7px 8px -4px var(--v-shadow-key-umbra-opacity)),\n    13: (0 7px 8px -4px var(--v-shadow-key-umbra-opacity)),\n    14: (0 7px 9px -4px var(--v-shadow-key-umbra-opacity)),\n    15: (0 8px 9px -5px var(--v-shadow-key-umbra-opacity)),\n    16: (0 8px 10px -5px var(--v-shadow-key-umbra-opacity)),\n    17: (0 8px 11px -5px var(--v-shadow-key-umbra-opacity)),\n    18: (0 9px 11px -5px var(--v-shadow-key-umbra-opacity)),\n    19: (0 9px 12px -6px var(--v-shadow-key-umbra-opacity)),\n    20: (0 10px 13px -6px var(--v-shadow-key-umbra-opacity)),\n    21: (0 10px 13px -6px var(--v-shadow-key-umbra-opacity)),\n    22: (0 10px 14px -6px var(--v-shadow-key-umbra-opacity)),\n    23: (0 11px 14px -7px var(--v-shadow-key-umbra-opacity)),\n    24: (0 11px 15px -7px var(--v-shadow-key-umbra-opacity))\n  ) !default,\n\n  $shadow-key-penumbra: (\n    0: (0 0 0 0 $shadow-key-penumbra-opacity-custom),\n    1: (0 1px 1px 0 $shadow-key-penumbra-opacity-custom),\n    2: (0 2px 2px 0 $shadow-key-penumbra-opacity-custom),\n\n    // ℹ️ Modified\n    3: (0 4px 8px -4px $shadow-key-penumbra-opacity-custom),\n\n    4: (0 4px 5px 0 $shadow-key-penumbra-opacity-custom),\n    5: (0 5px 8px 0 $shadow-key-penumbra-opacity-custom),\n\n    // ℹ️ Modified\n    6: (0 2px 10px 1px $shadow-key-penumbra-opacity-custom),\n\n    7: (0 7px 10px 1px $shadow-key-penumbra-opacity-custom),\n    8: (0 8px 10px 1px $shadow-key-penumbra-opacity-custom),\n    9: (0 9px 12px 1px $shadow-key-penumbra-opacity-custom),\n    10: (0 10px 14px 1px $shadow-key-penumbra-opacity-custom),\n    11: (0 11px 15px 1px $shadow-key-penumbra-opacity-custom),\n    12: (0 12px 17px 2px $shadow-key-penumbra-opacity-custom),\n    13: (0 13px 19px 2px $shadow-key-penumbra-opacity-custom),\n    14: (0 14px 21px 2px $shadow-key-penumbra-opacity-custom),\n    15: (0 15px 22px 2px $shadow-key-penumbra-opacity-custom),\n    16: (0 16px 24px 2px $shadow-key-penumbra-opacity-custom),\n    17: (0 17px 26px 2px $shadow-key-penumbra-opacity-custom),\n    18: (0 18px 28px 2px $shadow-key-penumbra-opacity-custom),\n    19: (0 19px 29px 2px $shadow-key-penumbra-opacity-custom),\n    20: (0 20px 31px 3px $shadow-key-penumbra-opacity-custom),\n    21: (0 21px 33px 3px $shadow-key-penumbra-opacity-custom),\n    22: (0 22px 35px 3px $shadow-key-penumbra-opacity-custom),\n    23: (0 23px 36px 3px $shadow-key-penumbra-opacity-custom),\n    24: (0 24px 38px 3px $shadow-key-penumbra-opacity-custom)\n  ) !default,\n\n  $shadow-key-ambient: (\n    0: (0 0 0 0 $shadow-key-ambient-opacity-custom),\n    1: (0 1px 3px 0 $shadow-key-ambient-opacity-custom),\n    2: (0 1px 5px 0 $shadow-key-ambient-opacity-custom),\n\n    // ℹ️ Modified\n    3: (0 4px 8px -4px $shadow-key-ambient-opacity-custom),\n\n    4: (0 1px 10px 0 $shadow-key-ambient-opacity-custom),\n    5: (0 1px 14px 0 $shadow-key-ambient-opacity-custom),\n\n    // ℹ️ Modified\n    6: (0 2px 16px 1px $shadow-key-ambient-opacity-custom),\n\n    7: (0 2px 16px 1px $shadow-key-ambient-opacity-custom),\n    8: (0 3px 14px 2px $shadow-key-ambient-opacity-custom),\n    9: (0 3px 16px 2px $shadow-key-ambient-opacity-custom),\n    10: (0 4px 18px 3px $shadow-key-ambient-opacity-custom),\n    11: (0 4px 20px 3px $shadow-key-ambient-opacity-custom),\n    12: (0 5px 22px 4px $shadow-key-ambient-opacity-custom),\n    13: (0 5px 24px 4px $shadow-key-ambient-opacity-custom),\n    14: (0 5px 26px 4px $shadow-key-ambient-opacity-custom),\n    15: (0 6px 28px 5px $shadow-key-ambient-opacity-custom),\n    16: (0 6px 30px 5px $shadow-key-ambient-opacity-custom),\n    17: (0 6px 32px 5px $shadow-key-ambient-opacity-custom),\n    18: (0 7px 34px 6px $shadow-key-ambient-opacity-custom),\n    19: (0 7px 36px 6px $shadow-key-ambient-opacity-custom),\n    20: (0 8px 38px 7px $shadow-key-ambient-opacity-custom),\n    21: (0 8px 40px 7px $shadow-key-ambient-opacity-custom),\n    22: (0 8px 42px 7px $shadow-key-ambient-opacity-custom),\n    23: (0 9px 44px 8px $shadow-key-ambient-opacity-custom),\n    24: (0 9px 46px 8px $shadow-key-ambient-opacity-custom)\n  ) !default,\n\n  // 👉 Card\n  $card-color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) !default,\n  $card-elevation: 6 !default,\n  $card-title-line-height: 2rem !default,\n  $card-actions-min-height: unset !default,\n  $card-text-padding: 1.25rem !default,\n  $card-item-padding: 1.25rem !default,\n  $card-actions-padding: 0 12px 12px !default,\n  $card-transition-property: $card-transition-property-custom !default,\n  $card-subtitle-opacity: 1 !default,\n  $card-title-letter-spacing: 0.0094rem !default,\n\n  // 👉 Typography\n  $typography: (\n    \"h1\": (\n      \"weight\": 500,\n      \"line-height\": 7rem,\n      \"letter-spacing\": -0.0938rem\n    ),\n    \"h2\": (\n      \"weight\": 500,\n      \"line-height\": 4.5rem,\n      \"letter-spacing\": -0.0313rem\n    ),\n    \"h3\": (\n      \"weight\": 500,\n      \"line-height\": 3.5rem\n    ),\n    \"h4\": (\n      \"weight\": 500,\n      \"line-height\": 2.625rem,\n      \"letter-spacing\": 0.0156rem\n    ),\n    \"h5\": (\n      \"weight\": 500,\n      \"line-height\": 2rem\n    ),\n    \"h6\": (\n      \"letter-spacing\": 0.0094rem\n    ),\n    \"subtitle-1\": (\n      \"letter-spacing\": 0.0094rem\n    ),\n    \"subtitle-2\": (\n      \"line-height\": 1.375rem,\n      \"letter-spacing\": 0.0063rem,\n    ),\n    \"body-1\": (\n      \"letter-spacing\": 0.0094rem,\n    ),\n    \"body-2\": (\n      \"letter-spacing\": 0.0094rem,\n    ),\n    \"caption\": (\n      \"letter-spacing\": 0.025rem,\n    ),\n    \"overline\": (\n      \"weight\": 400,\n      \"line-height\": 1.125rem,\n      \"letter-spacing\": 0.0625rem,\n    )\n  ) !default,\n\n  // 👉 List\n  $list-item-icon-margin-end: 16px !default,\n  $list-item-icon-margin-start: 16px !default,\n  $list-item-subtitle-opacity: 1 !default,\n  $list-subheader-text-opacity: 1 !default,\n\n  // 👉 Tooltip\n  $tooltip-background-color: #212121 !default,\n  $tooltip-text-color: rgb(var(--v-theme-on-primary)) !default,\n  $tooltip-font-size: 0.75rem !default,\n  $tooltip-border-radius: 4px !default,\n  $tooltip-padding: 4px 8px !default,\n\n  // 👉 Alert\n  $alert-title-font-size: 1rem !default,\n  $alert-border-radius: 5px !default,\n  $alert-title-letter-spacing: 0.15px !default,\n\n  // 👉 Badge\n  $badge-border-color:rgb(var(--v-theme-surface)) !default,\n  $badge-dot-height: 0.5rem !default,\n  $badge-dot-width: 0.5rem !default,\n\n  // 👉 Button\n  $button-height: 38px !default,\n  $button-elevation: (\"default\": 3, \"hover\": 4, \"active\": 8) !default,\n  $button-border-radius: 5px !default,\n  $button-padding-ratio: 1.7 !default,\n  $button-text-letter-spacing: 0.025rem !default,\n  $button-icon-density: (\"default\": 0.5, \"comfortable\": -2, \"compact\": -3) !default,\n\n  // 👉 Dialog\n  $dialog-card-header-padding: 20px !default,\n  $dialog-card-header-text-padding-top: 0 !default,\n  $dialog-card-text-padding: 20px !default,\n\n  // 👉 Chip\n  $chip-label-border-radius: 4px !default,\n  $chip-close-size: 20px !default,\n\n  // 👉 Expansion panel\n  $expansion-panel-title-padding: 16px 20px !default,\n  $expansion-panel-title-font-size: 1rem !default,\n  $expansion-panel-disabled-overlay: 0 !default,\n  $expansion-panel-active-title-min-height: 51px !default,\n  $expansion-panel-title-min-height: 51px !default,\n  $expansion-panel-text-padding: 0 20px 20px !default,\n\n  // 👉 Menu\n  $menu-content-border-radius: 5px !default,\n\n  // 👉 Snackbar\n  $snackbar-background:#212121 !default,\n  $snackbar-border-radius: 4px !default,\n  $snackbar-color: rgb(var(--v-theme-on-primary)) !default,\n\n  // 👉 Tabs\n  $tabs-height: 40px !default,\n\n  // 👉 Slider\n  $slider-track-active-size: 4px !default,\n  $slider-thumb-label-padding: 4px 12px !default,\n  $slider-thumb-label-font-size: 0.875rem !default,\n\n  // 👉 Timeline\n  $timeline-dot-size: 34px !default,\n  $timeline-dot-divider-background: transparent !default,\n\n  // 👉 Overlay\n  $overlay-opacity: 0.5 !default,\n\n  // 👉 Navigation Drawer\n  $navigation-drawer-scrim-opacity:0.5 !default,\n\n  // 👉 Table\n  $table-color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)),\n);\n"
  },
  {
    "path": "src/@core/scss/libs/vuetify/index.scss",
    "content": "@use \"variables\";\n@use \"overrides\";\n"
  },
  {
    "path": "src/@core/scss/pages/misc.scss",
    "content": ".misc-wrapper {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  padding: 1.25rem;\n  overflow: hidden;\n}\n\n.misc-footer-img {\n  position: absolute;\n  inline-size: 100%;\n  inset-block-end: 0;\n}\n\n.misc-avatar {\n  z-index: 1;\n}\n\n.misc-footer-tree {\n  position: absolute;\n  z-index: 1;\n}\n"
  },
  {
    "path": "src/@core/scss/pages/page-auth.scss",
    "content": ".auth-wrapper {\n  min-block-size: 100%;\n  min-block-size: 100vh;\n  min-block-size: 100dvh;\n}\n\n.auth-footer-mask {\n  position: absolute;\n  inset-block-end: 0;\n  min-inline-size: 100%;\n}\n\n.auth-card {\n  z-index: 1 !important;\n}\n\n.auth-footer-start-tree,\n.auth-footer-end-tree {\n  position: absolute;\n  z-index: 1;\n}\n\n.auth-footer-start-tree {\n  inset-block-end: 0;\n  inset-inline-start: 0;\n}\n\n.auth-footer-end-tree {\n  inset-block-end: 0;\n  inset-inline-end: 0;\n}\n\n.auth-illustration {\n  z-index: 1;\n}\n\n.auth-logo {\n  position: absolute;\n  z-index: 1;\n  inset-block-start: 2rem;\n  inset-inline-start: 2.3rem;\n}\n\n.auth-bg {\n  background-color: rgb(var(--v-theme-surface));\n}\n"
  },
  {
    "path": "src/@core/scss/placeholders/_default-layout.scss",
    "content": "%layout-navbar {\n  color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));\n}\n\n// Vertical nav scrolled sticky elevated nav\n%default-layout-vertical-nav-scrolled-sticky-elevated-nav {\n  background-color: rgb(var(--v-theme-surface));\n  box-shadow: 0 4px 8px -4px rgb(94 86 105 / 42%);\n}\n\n// Floating navbar and sticky elevated navbar scrolled\n%default-layout-vertical-nav-floating-navbar-and-sticky-elevated-navbar-scrolled {\n  background-color: rgb(var(--v-theme-surface));\n  box-shadow: 0 4px 8px -4px rgb(94 86 105 / 42%);\n}\n\n// Floating navbar overlay\n%default-layout-vertical-nav-floating-navbar-overlay {\n  backdrop-filter: blur(8px);\n  background-color: rgba(var(--v-theme-surface), 0.9);\n}\n"
  },
  {
    "path": "src/@core/scss/placeholders/_index.scss",
    "content": "@forward \"vertical-nav\";\n@forward \"nav\";\n@forward \"default-layout\";\n"
  },
  {
    "path": "src/@core/scss/placeholders/_nav.scss",
    "content": "@use \"vuetify/lib/styles/tools/_elevation\" as mixins_elevation;\n\n// ℹ️ This is common style that needs to be applied to both navs\n%nav {\n  color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));\n\n  .nav-item-title {\n    letter-spacing: 0.15px;\n  }\n\n  .nav-section-title {\n    letter-spacing: 0.4px;\n  }\n}\n\n/*\n    Active nav link styles for horizontal & vertical nav\n\n    For horizontal nav it will be only applied to top level nav items\n    For vertical nav it will be only applied to nav links (not nav groups)\n*/\n%nav-link-active {\n  background: linear-gradient(270deg, rgb(var(--v-theme-primary)) 0%, white 300%);\n  color: rgb(var(--v-theme-on-primary));\n\n  @include mixins_elevation.elevation(3);\n}\n\n%nav-link {\n  a {\n    color: inherit;\n  }\n}\n"
  },
  {
    "path": "src/@core/scss/placeholders/_vertical-nav.scss",
    "content": "@use \"../mixins\";\n@use \"@configured-variables\" as variables;\n@use \"vuetify/lib/styles/tools/states\" as vuetifyStates;\n@use \"../utils\";\n\n// Nav items styles (including section title)\n%vertical-nav-item {\n  margin-block: 0;\n  margin-inline: variables.$vertical-nav-horizontal-spacing;\n  padding-block: 0;\n  padding-inline: variables.$vertical-nav-horizontal-padding;\n  white-space: nowrap;\n}\n\n// This is same as `%vertical-nav-item` except section title is excluded\n%vertical-nav-item-interactive {\n  block-size: 2.625rem;\n  border-end-end-radius: 3.125rem;\n  border-start-end-radius: 3.125rem;\n\n  /*\n    ℹ️ We will use `margin-block-end` instead of `margin-block` to give more space for shadow to appear.\n    With `margin-block`, due to small space (space gets divided between top & bottom) shadow cuts\n  */\n  margin-block-end: 0.375rem;\n}\n\n// Common styles for nav item icon styles\n// ℹ️ Nav group's children icon styles are not here (Adjusts height, width & margin)\n%vertical-nav-items-icon {\n  flex-shrink: 0;\n  font-size: variables.$vertical-nav-items-icon-size;\n  margin-inline-end: variables.$vertical-nav-items-icon-margin-inline-end;\n}\n\n// Section title\n%vertical-nav-section-title {\n  // ℹ️ Setting height will prevent jerking when text & icon is toggled\n  block-size: 1.5rem;\n  color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));\n  font-size: 0.75rem;\n  padding-inline: variables.$vertical-nav-horizontal-padding;\n  text-transform: uppercase;\n\n  /*\n    ℹ️ We will use this to add gap between divider and text.\n    Moreover, we will use this to adjust the `flex-basis` property of left divider\n  */\n  $divider-gap: 0.625rem;\n\n  // Thanks: https://stackoverflow.com/a/62359101/10796681\n  .title-text {\n    display: flex;\n    flex-wrap: nowrap;\n    align-items: center;\n    justify-content: flex-start;\n    column-gap: $divider-gap;\n\n    &::before,\n    &::after {\n      border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));\n      content: \"\";\n    }\n\n    &::after {\n      flex: 1 1 auto;\n    }\n\n    &::before {\n      flex: 0 1 calc(variables.$vertical-nav-horizontal-padding-start - $divider-gap);\n      margin-inline-start: -#{variables.$vertical-nav-horizontal-padding-start};\n    }\n  }\n}\n\n// Vertical nav item badge styles\n%vertical-nav-item-badge {\n  display: inline-block;\n  border-radius: 1.5rem;\n  font-size: 0.8em;\n  font-weight: 500;\n  line-height: 1;\n  padding-block: 0.25em;\n  padding-inline: 0.55em;\n  text-align: center;\n  vertical-align: baseline;\n  white-space: nowrap;\n}\n"
  },
  {
    "path": "src/@core/utils/compatibility.ts",
    "content": "/**\n * 浏览器兼容性处理\n */\n\n/**\n * 修复低版本Safari等浏览器数组不支持at函数的问题\n */\n;(function fixArrayAt() {\n  if (!Array.prototype.at) {\n    Array.prototype.at = function (index: number) {\n      if (index >= 0) {\n        return this[index]\n      } else {\n        return this[this.length + index]\n      }\n    }\n  }\n})()\n"
  },
  {
    "path": "src/@core/utils/dom.ts",
    "content": "export function removeEl(selector: string) {\n  if (selector) {\n    const el = document.querySelector(selector)\n    el?.parentNode?.removeChild(el)\n  }\n}\n\nexport function useDefer(maxFrameCount = 1) {\n  const frameCount = ref(0)\n  const refreshFrameCount = () => {\n    requestAnimationFrame(() => {\n      frameCount.value++\n      if (frameCount.value < maxFrameCount) refreshFrameCount()\n    })\n  }\n  refreshFrameCount()\n  return function (showInFrameCount: number) {\n    return frameCount.value >= showInFrameCount\n  }\n}\n\nexport function ensureRenderComplete(callback: () => void) {\n  requestAnimationFrame(() => {\n    requestAnimationFrame(callback)\n  })\n}\n"
  },
  {
    "path": "src/@core/utils/formatters.ts",
    "content": "import dayjs from 'dayjs'\nimport relativeTime from 'dayjs/plugin/relativeTime'\nimport ZH_CN from 'dayjs/locale/zh-cn'\n\nimport { isToday } from './index'\n\ndayjs.extend(relativeTime)\ndayjs.locale(ZH_CN)\n\nexport function avatarText(value: string) {\n  if (!value) return ''\n  const nameArray = value.split(' ')\n\n  return nameArray.map(word => word.charAt(0).toUpperCase()).join('')\n}\n\n// TODO: Try to implement this: https://twitter.com/fireship_dev/status/1565424801216311297\nexport function kFormatter(num: number) {\n  const regex = /\\B(?=(\\d{3})+(?!\\d))/g\n\n  return Math.abs(num) > 9999\n    ? `${Math.sign(num) * +(Math.abs(num) / 1000).toFixed(1)}k`\n    : Math.abs(num).toFixed(0).replace(regex, ',')\n}\n\n// 格式化下载量显示，超过1000显示为x.xk格式\nexport function formatDownloadCount(num: number): string {\n  if (!num || num < 1000) return num?.toLocaleString() || '0'\n\n  return `${(num / 1000).toFixed(1)}k`\n}\n\n/**\n * Format and return date in Humanize format\n * Intl docs: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/format\n * Intl Constructor: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat\n * @param {string} value date to format\n * @param {Intl.DateTimeFormatOptions} formatting Intl object to format with\n */\nexport function formatDate(\n  value: string,\n  formatting: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' },\n) {\n  if (!value) return value\n\n  return new Intl.DateTimeFormat('en-US', formatting).format(new Date(value))\n}\n\n/**\n * Return short human friendly month representation of date\n * Can also convert date to only time if date is of today (Better UX)\n * @param {string} value date to format\n * @param {boolean} toTimeForCurrentDay Shall convert to time if day is today/current\n */\nexport function formatDateToMonthShort(value: string, toTimeForCurrentDay = true) {\n  const date = new Date(value)\n  let formatting: Record<string, string> = { month: 'short', day: 'numeric' }\n\n  if (toTimeForCurrentDay && isToday(date)) formatting = { hour: 'numeric', minute: 'numeric' }\n\n  return new Intl.DateTimeFormat('en-US', formatting).format(new Date(value))\n}\n\nexport const prefixWithPlus = (value: number) => (value > 0 ? `+${value}` : value)\n\n// 格式化为Sxx\nexport const formatSeason = (value: string) => (value ? `S${value.padStart(2, '0')}` : '')\n\n// 格式化为xx[TGMK]B\nexport function formatFileSize(bytes: number, decimals = 2, prefix = false) {\n  // 负数标记\n  let negative = false\n  let size = bytes\n  if (bytes < 0) {\n    negative = true\n    size = Math.abs(bytes)\n  }\n\n  const units = ['B', 'KB', 'MB', 'GB', 'TB']\n  let unitIndex = 0\n\n  while (size >= 1024 && unitIndex < units.length - 1) {\n    size /= 1024\n    unitIndex++\n  }\n  if (negative) return `-${size.toFixed(decimals)} ${units[unitIndex]}`\n  else\n    return prefix ? `+${size.toFixed(decimals)} ${units[unitIndex]}` : `${size.toFixed(decimals)} ${units[unitIndex]}`\n}\n\n// 将时间秒格式化为时分秒\nexport function formatSeconds(seconds: number) {\n  const hours = Math.floor(seconds / 3600)\n  const minutes = Math.floor((seconds % 3600) / 60)\n  const remainingSeconds = seconds % 60\n\n  let formattedTime = ''\n\n  if (hours > 0) formattedTime += `${hours}小时`\n\n  if (minutes > 0) formattedTime += `${minutes}分`\n\n  if ((remainingSeconds > 0 || formattedTime === '') && hours <= 0) formattedTime += `${remainingSeconds}秒`\n\n  return formattedTime\n}\n\n// YYYY-MM-DD 转化为Date\nexport function parseDate(dateString: string): Date | null {\n  if (!dateString) return null\n  const [year, month, day] = dateString.split('-').map(Number)\n\n  return new Date(year, month - 1, day)\n}\n\n// 文件大小格式化\nexport function formatBytes(bytes: number, decimals = 2) {\n  if (bytes === 0) return '0 bytes'\n\n  const k = 1024\n  const dm = decimals < 0 ? 0 : decimals\n  const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']\n\n  const i = Math.floor(Math.log(bytes) / Math.log(k))\n\n  return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`\n}\n\n// 格式化剧集列表\nexport function formatEp(nums: number[]): string {\n  if (!nums.length) return ''\n\n  if (nums.length === 1) return nums[0].toString()\n\n  // 将数组升序排序\n  nums.sort((a, b) => a - b)\n  const formattedRanges: string[] = []\n  let start = nums[0]\n  let end = nums[0]\n\n  for (let i = 1; i < nums.length; i++) {\n    if (nums[i] === end + 1) {\n      end = nums[i]\n    } else {\n      if (start === end) formattedRanges.push(start.toString())\n      else formattedRanges.push(`${start.toString()}-${end.toString()}`)\n\n      start = end = nums[i]\n    }\n  }\n\n  if (start === end) formattedRanges.push(start.toString())\n  else formattedRanges.push(`${start.toString()}-${end.toString()}`)\n\n  return formattedRanges.join('、')\n}\n\n// 将yyyy-mm-dd hh:mm:ss转换为时间差，如：1小时前，1天前\nexport function formatDateDifference(dateString: string): string {\n  if (!dateString) return ''\n  return dayjs(dateString).fromNow()\n}\n\n// 格式化评份，如为10及以下的数按原值显示，否则格式化为xxM、xxK显示\nexport function formatRating(rating: number): string {\n  if (!rating) return ''\n  if (rating <= 10) return rating.toString()\n  if (rating < 1000) return rating.toLocaleString()\n  if (rating < 1000 * 1000) return `${(rating / 1000).toFixed(1)}K`\n  return `${(rating / 1000 / 1000).toFixed(1)}M`\n}\n"
  },
  {
    "path": "src/@core/utils/image.ts",
    "content": "import ColorThief from 'colorthief'\n\n// 将 RGB 转换为十六进制\nfunction rgbStringToHex(rgbArray: number[]): string {\n  if (rgbArray.length !== 3 || rgbArray.some(isNaN)) throw new Error('Invalid RGB string format')\n\n  const [r, g, b] = rgbArray\n\n  const toHex = (c: number): string => {\n    const hex = c.toString(16)\n    return hex.length === 1 ? `0${hex}` : hex\n  }\n\n  return `#${toHex(r)}${toHex(g)}${toHex(b)}`\n}\n\n// 提取主要颜色\nexport async function getDominantColor(image: HTMLImageElement): Promise<string> {\n  const colorThief = new ColorThief()\n  const dominantColor = colorThief.getColor(image)\n  return rgbStringToHex(dominantColor)\n}\n\n// 预加载图片\nexport async function preloadImage(url: string): Promise<boolean> {\n  return new Promise(resolve => {\n    const img = new Image()\n\n    img.onload = () => resolve(true)\n    img.onerror = () => resolve(false)\n\n    // 设置超时，防止图片长时间加载\n    const timeout = setTimeout(() => {\n      img.src = ''\n      resolve(false)\n    }, 5000) // 5秒超时\n\n    img.src = url\n\n    // 如果图片已经缓存，onload可能不会触发\n    if (img.complete) {\n      clearTimeout(timeout)\n      resolve(true)\n    }\n  })\n}\n"
  },
  {
    "path": "src/@core/utils/index.ts",
    "content": "// 👉 IsEmpty\nexport function isEmpty(value: unknown): boolean {\n  if (value === null || value === undefined || value === '') return true\n\n  return !!(Array.isArray(value) && value.length === 0)\n}\n\n// 👉 IsNullOrUndefined\nexport function isNullOrUndefined(value: unknown): value is undefined | null {\n  return value === null || value === undefined\n}\n\n// 👉 IsEmptyArray\nexport function isEmptyArray(arr: unknown): boolean {\n  return Array.isArray(arr) && arr.length === 0\n}\n\n// 👉 IsObject\nexport function isObject(obj: unknown): obj is Record<string, unknown> {\n  return obj !== null && !!obj && typeof obj === 'object' && !Array.isArray(obj)\n}\n\nexport function isToday(date: Date) {\n  const today = new Date()\n\n  return (\n    /* eslint-disable operator-linebreak */\n    date.getDate() === today.getDate() &&\n    date.getMonth() === today.getMonth() &&\n    date.getFullYear() === today.getFullYear()\n    /* eslint-enable */\n  )\n}\n\n// 判断一个数组subArray是不是在另一个数组mainArray中\nexport function isContained(subArray: any[], mainArray: any[]): boolean {\n  return subArray.every(element => mainArray.includes(element))\n}\n\n// 判断两个数组是否存在交集\nexport function isIntersected(array1: any[], array2: any[]): boolean {\n  return array1.some(element => array2.includes(element))\n}\n\nexport function isNullOrEmptyObject(obj: any): boolean {\n  // 首先判断是否为 null 或 undefined\n  if (obj === null || obj === undefined) return true\n\n  // 然后判断是否为空对象\n  return !!(typeof obj === 'object' && Object.keys(obj).length === 0)\n}\n\n// 判断系统配置色是否是黑暗的\nexport function checkPrefersColorSchemeIsDark(): boolean {\n  try {\n    return window.matchMedia('(prefers-color-scheme: dark)').matches\n  } catch (e) {\n    return false\n  }\n}\n\n// 从URL中获取参数值\nexport function getQueryValue(key: string, url = window.location.href): string {\n  const reg = new RegExp(`[?&]${key}=([^&#]*)`, 'i')\n  const res = reg.exec(url)\n  return res ? res[1] : ''\n}\n\n// 导出 navigator 相关函数\nexport { isMobileDevice, isIOSDevice, isAndroidDevice } from './navigator'\n"
  },
  {
    "path": "src/@core/utils/navigator.ts",
    "content": "import copy from 'copy-to-clipboard'\n\n// 请求和获取剪贴板内容\nexport async function getClipboardContent() {\n  if (navigator.clipboard && window.isSecureContext) {\n    return await navigator.clipboard.readText()\n  } else {\n    const input = document.createElement('textarea')\n    document.body.appendChild(input)\n    input.select()\n    document.execCommand('paste')\n    const content = input.value\n    document.body.removeChild(input)\n    return content\n  }\n}\n\n// 将内容复制到剪贴板\nexport async function copyToClipboard(content: string) {\n  const success = copy(content)\n  return success\n}\n\n// VAPID公钥转Uint8Array\nexport function urlBase64ToUint8Array(base64String: string) {\n  const padding = '='.repeat((4 - (base64String.length % 4)) % 4)\n  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')\n\n  const rawData = window.atob(base64)\n  const outputArray = new Uint8Array(rawData.length)\n\n  for (let i = 0; i < rawData.length; ++i) {\n    outputArray[i] = rawData.charCodeAt(i)\n  }\n  return outputArray\n}\n\n// Uint8Array 转 Base64URL\nexport function bufferToBase64Url(buffer: ArrayBuffer): string {\n  return btoa(String.fromCharCode(...new Uint8Array(buffer)))\n    .replace(/\\+/g, '-')\n    .replace(/\\//g, '_')\n    .replace(/=/g, '')\n}\n\n// Base64URL 转 Uint8Array\nexport function base64UrlToUint8Array(base64Url: string): Uint8Array {\n  return Uint8Array.from(atob(base64Url.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))\n}\n\n// 判断是否为PWA\nexport const isPWA = async (): Promise<boolean> => {\n  if ('serviceWorker' in navigator) {\n    const registrations = await navigator.serviceWorker.getRegistrations()\n    return registrations.length > 0\n  }\n  return (window.navigator as any).standalone === true\n}\n\n// 同步检测PWA显示模式\nexport const isPWADisplayMode = (): boolean => {\n  return (\n    window.matchMedia('(display-mode: standalone)').matches ||\n    (window.navigator as any).standalone ||\n    document.referrer.includes('android-app://')\n  )\n}\n\n// 全面的PWA检测（推荐使用）\nexport const checkPWAStatus = async () => {\n  const hasServiceWorker = await isPWA()\n  const isStandaloneMode = isPWADisplayMode()\n\n  return {\n    // 是否有PWA功能（Service Worker）\n    hasPWAFeatures: hasServiceWorker,\n    // 是否在独立显示模式下运行\n    isStandaloneMode,\n    // 综合判断：更宽松的检测，在移动设备上默认启用PWA功能\n    isPWAEnvironment: hasServiceWorker || isStandaloneMode || isMobileDevice(),\n    // 完整的PWA体验：既有功能又在独立模式下运行\n    isFullPWA: hasServiceWorker && isStandaloneMode,\n  }\n}\n\n// 检测是否为移动设备\nexport const isMobileDevice = (): boolean => {\n  // 检查用户代理字符串\n  const userAgent = navigator.userAgent || ''\n  const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i\n\n  // 检查触摸屏支持\n  const hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0\n\n  // 检查屏幕尺寸（小于768px认为是移动设备）\n  const isMobileSize = window.innerWidth < 768\n\n  return mobileRegex.test(userAgent) || hasTouchScreen || isMobileSize\n}\n\n// 检测是否为iOS设备\nexport const isIOSDevice = (): boolean => {\n  const userAgent = navigator.userAgent.toLowerCase()\n  return /iphone|ipad|ipod/.test(userAgent) && !(window as any).MSStream\n}\n\n// 检测是否为Android设备\nexport const isAndroidDevice = (): boolean => {\n  const userAgent = navigator.userAgent.toLowerCase()\n  return /android/.test(userAgent)\n}\n"
  },
  {
    "path": "src/@core/utils/theme.ts",
    "content": "export function saveLocalTheme(name: string, theme: any) {\n  // 存储主题到本地\n  localStorage.setItem('theme', name)\n  localStorage.setItem('materio-initial-loader-bg', theme.current.value.colors.background)\n  localStorage.setItem('materio-initial-loader-color', theme.current.value.colors.primary)\n}\n"
  },
  {
    "path": "src/@core/utils/workflow.ts",
    "content": "import { useVueFlow } from '@vue-flow/core'\nimport { ref, watch } from 'vue'\nimport { cloneDeep } from 'lodash-es'\n\n/**\n * @returns {string} - A unique id.\n */\nfunction getId() {\n  // 生成以act_开头的唯一id\n  return 'act_' + Math.random().toString(36).substr(2, 9)\n}\n\n/**\n * In a real world scenario you'd want to avoid creating refs in a global scope like this as they might not be cleaned up properly.\n * @type {{draggedData: Ref<any>, isDragOver: Ref<boolean>, isDragging: Ref<boolean>}}\n */\nconst state = {\n  /**\n   * The type of the node being dragged.\n   */\n  draggedData: ref<any | null>({}),\n  isDragOver: ref(false),\n  isDragging: ref(false),\n}\n\nexport default function useDragAndDrop() {\n  const { draggedData, isDragOver, isDragging } = state\n\n  const { addNodes, screenToFlowCoordinate, onNodesInitialized, updateNode } = useVueFlow()\n\n  watch(isDragging, dragging => {\n    document.body.style.userSelect = dragging ? 'none' : ''\n  })\n\n  function onDragStart(event: any, data: any) {\n    if (event.dataTransfer) {\n      event.dataTransfer.setData('application/vueflow', data)\n      event.dataTransfer.effectAllowed = 'move'\n    }\n\n    draggedData.value = data\n    isDragging.value = true\n\n    document.addEventListener('drop', onDragEnd)\n  }\n\n  /**\n   * Handles the drag over event.\n   *\n   * @param {DragEvent} event\n   */\n  function onDragOver(event: any) {\n    event.preventDefault()\n\n    if (draggedData.value) {\n      isDragOver.value = true\n\n      if (event.dataTransfer) {\n        event.dataTransfer.dropEffect = 'move'\n      }\n    }\n  }\n\n  function onDragLeave() {\n    isDragOver.value = false\n  }\n\n  function onDragEnd() {\n    isDragging.value = false\n    isDragOver.value = false\n    draggedData.value = null\n    document.removeEventListener('drop', onDragEnd)\n  }\n\n  /**\n   * Handles the drop event.\n   *\n   * @param {DragEvent} event\n   */\n  function onDrop(event: any) {\n    const position = screenToFlowCoordinate({\n      x: event.clientX,\n      y: event.clientY,\n    })\n\n    const nodeId = getId()\n\n    const newNode = {\n      id: nodeId,\n      type: draggedData.value?.type,\n      name: draggedData.value?.name,\n      description: draggedData.value?.description,\n      position,\n      data: draggedData.value?.data ? cloneDeep(draggedData.value.data) : {},\n    }\n\n    /**\n     * Align node position after drop, so it's centered to the mouse\n     *\n     * We can hook into events even in a callback, and we can remove the event listener after it's been called.\n     */\n    const { off } = onNodesInitialized(() => {\n      updateNode(nodeId, node => ({\n        position: { x: node.position.x - node.dimensions.width / 2, y: node.position.y - node.dimensions.height / 2 },\n      }))\n\n      off()\n    })\n\n    addNodes(newNode)\n  }\n\n  return {\n    draggedData,\n    isDragOver,\n    isDragging,\n    onDragStart,\n    onDragLeave,\n    onDragOver,\n    onDrop,\n  }\n}\n"
  },
  {
    "path": "src/@iconify/build-icons.ts",
    "content": "/**\n * This is an advanced example for creating icon bundles for Iconify SVG Framework.\n *\n * It creates a bundle from:\n * - All SVG files in a directory.\n * - Custom JSON files.\n * - Iconify icon sets.\n * - SVG framework.\n *\n * This example uses Iconify Tools to import and clean up icons.\n * For Iconify Tools documentation visit https://docs.iconify.design/tools/tools2/\n */\nimport { promises as fs } from 'node:fs'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { createRequire } from 'node:module'\n\n// Get current directory\nconst __dirname = dirname(fileURLToPath(import.meta.url))\n\n// Create require function for importing JSON files in ESM\nconst require = createRequire(import.meta.url)\n\n// Installation: npm install --save-dev @iconify/tools @iconify/utils @iconify/json @iconify/iconify\nimport {\n  cleanupSVG,\n  importDirectory,\n  isEmptyColor,\n  parseColors,\n  runSVGO,\n} from '@iconify/tools'\nimport type { IconifyJSON, IconifyMetaData } from '@iconify/types'\nimport { getIcons, minifyIconSet, stringToIcon } from '@iconify/utils'\n\n/**\n * Script configuration\n */\ninterface BundleScriptCustomSVGConfig {\n\n  // Path to SVG files\n  dir: string\n\n  // True if icons should be treated as monotone: colors replaced with currentColor\n  monotone: boolean\n\n  // Icon set prefix\n  prefix: string\n}\n\ninterface BundleScriptCustomJSONConfig {\n\n  // Path to JSON file\n  filename: string\n\n  // List of icons to import. If missing, all icons will be imported\n  icons?: string[]\n}\n\ninterface BundleScriptConfig {\n\n  // Custom SVG to import and bundle\n  svg?: BundleScriptCustomSVGConfig[]\n\n  // Icons to bundled from @iconify/json packages\n  icons?: string[]\n\n  // List of JSON files to bundled\n  // Entry can be a string, pointing to filename or a BundleScriptCustomJSONConfig object (see type above)\n  // If entry is a string or object without 'icons' property, an entire JSON file will be bundled\n  json?: (string | BundleScriptCustomJSONConfig)[]\n}\n\nconst sources: BundleScriptConfig = {\n  svg: [\n    // {\n    //   dir: 'src/assets/images/iconify-svg',\n    //   monotone: true,\n    //   prefix: 'custom',\n    // },\n\n    // {\n    //   dir: 'emojis',\n    //   monotone: false,\n    //   prefix: 'emoji',\n    // },\n  ],\n\n  icons: [\n    // 'mdi:home',\n    // 'mdi:account',\n    // 'mdi:login',\n    // 'mdi:logout',\n    // 'octicon:book-24',\n    // 'octicon:code-square-24',\n    'lucide:sparkles',\n    'material-symbols:passkey',\n    'line-md:loading-twotone-loop',\n  ],\n\n  json: [\n    // Custom JSON file\n    // 'json/gg.json',\n\n    // Iconify JSON file (@iconify/json is a package name, /json/ is directory where files are, then filename)\n    require.resolve('@iconify-json/mdi/icons.json'),\n\n    // Custom file with only few icons\n    // {\n    //   filename: require.resolve('@iconify-json/line-md/icons.json'),\n    //   icons: [\n    //     'home-twotone-alt',\n    //     'github',\n    //     'document-list',\n    //     'document-code',\n    //     'image-twotone',\n    //   ],\n    // },\n  ],\n}\n\n// Iconify component (this changes import statement in generated file)\n// Available options: '@iconify/react' for React, '@iconify/vue' for Vue 3, '@iconify/vue2' for Vue 2, '@iconify/svelte' for Svelte\nconst component = '@iconify/vue'\n\n// Set to true to use require() instead of import\nconst commonJS = false\n\n// File to save bundle to\nconst target = join(__dirname, 'icons-bundle.js');\n\n/**\n * Do stuff!\n */\n// eslint-disable-next-line sonarjs/cognitive-complexity\n(async function () {\n  let bundle = commonJS\n    ? `const { addCollection } = require('${component}');\\n\\n`\n    : `import { addCollection } from '${component}';\\n\\n`\n\n  // Create directory for output if missing\n  const dir = dirname(target)\n  try {\n    await fs.mkdir(dir, {\n      recursive: true,\n    })\n  }\n  catch (err) {\n    //\n  }\n\n  /**\n   * Convert sources.icons to sources.json\n   */\n  if (sources.icons) {\n    const sourcesJSON = sources.json ? sources.json : (sources.json = [])\n\n    // Sort icons by prefix\n    const organizedList = organizeIconsList(sources.icons)\n    for (const prefix in organizedList) {\n      let filename\n      try {\n        filename = require.resolve(`@iconify-json/${prefix}/icons.json`)\n      }\n      catch (err) {\n        filename = require.resolve(`@iconify/json/json/${prefix}.json`)\n      }\n\n      sourcesJSON.push({\n        filename,\n        icons: organizedList[prefix],\n      })\n    }\n  }\n\n  /**\n   * Bundle JSON files\n   */\n  if (sources.json) {\n    for (let i = 0; i < sources.json.length; i++) {\n      const item = sources.json[i]\n\n      // Load icon set\n      const filename = typeof item === 'string' ? item : item.filename\n      let content = JSON.parse(\n        await fs.readFile(filename, 'utf8'),\n      ) as IconifyJSON\n\n      // Filter icons\n      if (typeof item !== 'string' && item.icons?.length) {\n        const filteredContent = getIcons(content, item.icons)\n        if (!filteredContent)\n          throw new Error(`Cannot find required icons in ${filename}`)\n\n        content = filteredContent\n      }\n\n      // Remove metadata and add to bundle\n      removeMetaData(content)\n      minifyIconSet(content)\n      bundle += `addCollection(${JSON.stringify(content)});\\n`\n      console.log(`Bundled icons from ${filename}`)\n    }\n  }\n\n  /**\n   * Custom SVG\n   */\n  if (sources.svg) {\n    for (let i = 0; i < sources.svg.length; i++) {\n      const source = sources.svg[i]\n\n      // Import icons\n      const iconSet = await importDirectory(source.dir, {\n        prefix: source.prefix,\n      })\n\n      // Validate, clean up, fix palette and optimise\n      await iconSet.forEach(async (name, type) => {\n        if (type !== 'icon')\n          return\n\n        // Get SVG instance for parsing\n        const svg = iconSet.toSVG(name)\n        if (!svg) {\n          // Invalid icon\n          iconSet.remove(name)\n\n          return\n        }\n\n        // Clean up and optimise icons\n        try {\n          // Clean up icon code\n          await cleanupSVG(svg)\n\n          if (source.monotone) {\n            // Replace color with currentColor, add if missing\n            // If icon is not monotone, remove this code\n            await parseColors(svg, {\n              defaultColor: 'currentColor',\n              callback: (attr, colorStr, color) => {\n                return (!color || isEmptyColor(color))\n                  ? colorStr\n                  : 'currentColor'\n              },\n            })\n          }\n\n          // Optimise\n          await runSVGO(svg)\n        }\n        catch (err) {\n          // Invalid icon\n          console.error(\n            `Error parsing ${name} from ${source.dir}:`,\n            err,\n          )\n          iconSet.remove(name)\n\n          return\n        }\n\n        // Update icon from SVG instance\n        iconSet.fromSVG(name, svg)\n      })\n      console.log(`Bundled ${iconSet.count()} icons from ${source.dir}`)\n\n      // Export to JSON\n      const content = iconSet.export()\n\n      bundle += `addCollection(${JSON.stringify(content)});\\n`\n    }\n  }\n\n  // Save to file\n  await fs.writeFile(target, bundle, 'utf8')\n\n  console.log(`Saved ${target} (${bundle.length} bytes)`)\n})().catch((err) => {\n  console.error(err)\n})\n\n/**\n * Remove metadata from icon set\n */\nfunction removeMetaData(iconSet: IconifyJSON) {\n  const props: (keyof IconifyMetaData)[] = [\n    'info',\n    'chars',\n    'categories',\n    'themes',\n    'prefixes',\n    'suffixes',\n  ]\n\n  props.forEach((prop) => {\n    delete iconSet[prop]\n  })\n}\n\n/**\n * Sort icon names by prefix\n */\nfunction organizeIconsList(icons: string[]): Record<string, string[]> {\n  const sorted: Record<string, string[]> = Object.create(null)\n\n  icons.forEach((icon) => {\n    const item = stringToIcon(icon)\n    if (!item)\n      return\n\n    const prefix = item.prefix\n\n    const prefixList = sorted[prefix]\n      ? sorted[prefix]\n      : (sorted[prefix] = [])\n\n    const name = item.name\n    if (!prefixList.includes(name))\n      prefixList.push(name)\n  })\n\n  return sorted\n}\n"
  },
  {
    "path": "src/@iconify/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ESNext\",\n    \"module\": \"Node16\",\n    \"declaration\": false,\n    \"declarationMap\": false,\n    \"sourceMap\": false,\n    \"composite\": false,\n    \"strict\": true,\n    \"moduleResolution\": \"node16\",\n    \"esModuleInterop\": true,\n    \"forceConsistentCasingInFileNames\": true,\n  },\n  \"exclude\": [\n    \"./*.js\"\n  ]\n}\n"
  },
  {
    "path": "src/@iconify/tsconfig.tsbuildinfo",
    "content": "{\"root\":[\"./build-icons.ts\"],\"version\":\"5.8.3\"}"
  },
  {
    "path": "src/@layouts/components/VerticalNav.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { Component } from 'vue'\nimport { useDisplay } from 'vuetify'\nimport logo from '@images/logo.svg?raw'\n\ninterface Props {\n  tag?: string | Component\n  isOverlayNavActive: boolean\n  toggleIsOverlayNavActive: (value: boolean) => void\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  tag: 'aside',\n})\n\nconst { mdAndDown } = useDisplay()\nconst refNav = ref()\nconst route = useRoute()\n\nwatch(\n  () => route.path,\n  () => {\n    props.toggleIsOverlayNavActive(false)\n  },\n)\n\n// 是否滚动\nconst isVerticalNavScrolled = ref(false)\nconst updateIsVerticalNavScrolled = (val: boolean) => (isVerticalNavScrolled.value = val)\n\n// 滚动响应\nfunction handleNavScroll(evt: Event) {\n  isVerticalNavScrolled.value = (evt.target as HTMLElement).scrollTop > 0\n}\n</script>\n\n<template>\n  <Component\n    :is=\"props.tag\"\n    ref=\"refNav\"\n    class=\"layout-vertical-nav touch-none\"\n    :class=\"[\n      {\n        'visible': isOverlayNavActive,\n        'scrolled': isVerticalNavScrolled,\n        'overlay-nav': mdAndDown,\n      },\n    ]\"\n  >\n    <!-- 👉 Header -->\n    <div class=\"nav-header\">\n      <slot name=\"nav-header\">\n        <RouterLink to=\"/\" class=\"app-logo d-flex align-center app-title-wrapper\">\n          <div class=\"d-flex\" v-html=\"logo\" />\n\n          <h1 class=\"font-weight-bold leading-normal text-xl\">\n            MOVIEPILOT <span class=\"text-sm text-gray-500\">v2</span>\n          </h1>\n        </RouterLink>\n      </slot>\n    </div>\n    <slot name=\"nav-items\" :update-is-vertical-nav-scrolled=\"updateIsVerticalNavScrolled\">\n      <PerfectScrollbar\n        tag=\"ul\"\n        class=\"nav-items\"\n        :options=\"{ wheelPropagation: false }\"\n        @ps-scroll-y=\"handleNavScroll\"\n      >\n        <slot />\n      </PerfectScrollbar>\n    </slot>\n\n    <slot name=\"after-nav-items\" />\n  </Component>\n</template>\n\n<style lang=\"scss\">\n@use '@configured-variables' as variables;\n@use '@layouts/styles/mixins';\n\n.visible {\n  visibility: visible !important;\n}\n\n// 👉 Vertical Nav\n.layout-vertical-nav {\n  position: fixed;\n  z-index: variables.$layout-vertical-nav-z-index;\n  display: flex;\n  flex-direction: column;\n  block-size: 100%;\n  inline-size: variables.$layout-vertical-nav-width;\n  inset-block-start: 0;\n  inset-inline-start: 0;\n  transition: transform 0.25s ease-in-out, inline-size 0.25s ease-in-out, box-shadow 0.25s ease-in-out;\n  visibility: hidden;\n  will-change: transform, inline-size;\n\n  &:not(.overlay-nav) {\n    visibility: visible;\n  }\n\n  .nav-header {\n    display: flex;\n    align-items: center;\n\n    .header-action {\n      cursor: pointer;\n    }\n  }\n\n  .app-title-wrapper {\n    margin-inline-end: auto;\n  }\n\n  .nav-items {\n    block-size: 100%;\n\n    // ℹ️ We no loner needs this overflow styles as perfect scrollbar applies it\n    // overflow-x: hidden;\n\n    // // ℹ️ We used `overflow-y` instead of `overflow` to mitigate overflow x. Revert back if any issue found.\n    // overflow-y: auto;\n  }\n\n  .nav-item-title {\n    overflow: hidden;\n    margin-inline-end: auto;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n\n  // 👉 Collapsed\n  .layout-vertical-nav-collapsed & {\n    &:not(.hovered) {\n      inline-size: variables.$layout-vertical-nav-collapsed-width;\n    }\n  }\n\n  // 👉 Overlay nav\n  &.overlay-nav {\n    &:not(.visible) {\n      transform: translateX(-#{variables.$layout-vertical-nav-width});\n\n      @include mixins.rtl {\n        transform: translateX(variables.$layout-vertical-nav-width);\n      }\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/@layouts/components/VerticalNavLayout.vue",
    "content": "<script lang=\"ts\">\nimport { Transition } from 'vue'\nimport { useDisplay } from 'vuetify'\nimport VerticalNav from '@layouts/components/VerticalNav.vue'\n\nexport default defineComponent({\n  setup(props, { slots }) {\n    const isOverlayNavActive = ref(false)\n    const isLayoutOverlayVisible = ref(false)\n    const toggleIsOverlayNavActive = useToggle(isOverlayNavActive)\n\n    const route = useRoute()\n    const { mdAndDown } = useDisplay()\n\n    // ℹ️ This is alternative to below two commented watcher\n    // We want to show overlay if overlay nav is visible and want to hide overlay if overlay is hidden and vice versa.\n    syncRef(isOverlayNavActive, isLayoutOverlayVisible)\n\n    const scrollDistance = ref(window.scrollY)\n    const isDialogOpen = ref(false)\n    const wasScrolledBeforeDialog = ref(false)\n\n    // 监听弹窗状态变化\n    const checkDialogState = () => {\n      const wasDialogOpen = isDialogOpen.value\n      isDialogOpen.value = document.documentElement.classList.contains('v-overlay-scroll-blocked')\n\n      // 当弹窗刚打开时，记录当前的滚动状态\n      if (!wasDialogOpen && isDialogOpen.value) {\n        wasScrolledBeforeDialog.value = scrollDistance.value > 0\n      }\n    }\n\n    onMounted(() => {\n      window.addEventListener('scroll', () => {\n        scrollDistance.value = window.scrollY\n      })\n\n      // 初始检查弹窗状态\n      checkDialogState()\n\n      // 监听 DOM 变化以检测弹窗状态\n      const observer = new MutationObserver(checkDialogState)\n      observer.observe(document.documentElement, {\n        attributes: true,\n        attributeFilter: ['class'],\n      })\n    })\n\n    return () => {\n      // 👉 Vertical nav\n      const verticalNav = h(\n        VerticalNav,\n        { isOverlayNavActive: isOverlayNavActive.value, toggleIsOverlayNavActive },\n        {\n          'nav-header': () => slots['vertical-nav-header']?.(),\n          'before-nav-items': () => slots['before-vertical-nav-items']?.(),\n          'default': () => slots['vertical-nav-content']?.(),\n          'after-nav-items': () => slots['after-vertical-nav-items']?.(),\n        },\n      )\n\n      // 👉 Navbar\n      const navbar = h(\n        'header',\n        { class: ['layout-navbar navbar-blur'] },\n        [\n          h(\n            'div',\n            { class: 'navbar-content-container' },\n            [\n              slots.navbar?.({\n                toggleVerticalOverlayNavActive: toggleIsOverlayNavActive,\n              }),\n              // 👉 Dynamic Header Tab in NavBar\n              slots['dynamic-header-tab']?.()\n                ? h('div', { class: 'layout-dynamic-header-tab' }, slots['dynamic-header-tab']?.())\n                : null,\n            ].filter(Boolean),\n          ),\n        ].filter(Boolean),\n      )\n\n      const main = h(\n        'main',\n        { class: 'layout-page-content' },\n        h(Transition, { name: 'fade-slide', mode: 'out-in', appear: true }, () =>\n          h('section', { class: 'page-content-container' }, slots.default?.()),\n        ),\n      )\n\n      // 👉 根据路由 meta 决定 footer 高度\n      const shouldShowFooter = !route.meta.hideFooter\n\n      // 👉 Footer\n      const footer = h('footer', { class: 'layout-footer' }, [\n        h(\n          'div',\n          {\n            class: ['footer-content-container', !shouldShowFooter && 'footer-content-container-noheight'],\n          },\n          slots.footer?.(),\n        ),\n      ])\n\n      // 👉 Overlay\n      const layoutOverlay = h('div', {\n        class: ['layout-overlay', 'touch-none', { visible: isLayoutOverlayVisible.value }],\n        onClick: () => {\n          isLayoutOverlayVisible.value = !isLayoutOverlayVisible.value\n        },\n      })\n\n      return h(\n        'div',\n        {\n          class: [\n            'layout-wrapper layout-nav-type-vertical layout-navbar-static layout-footer-static layout-content-width-fluid',\n            'layout-navbar-fixed',\n            mdAndDown.value && 'layout-overlay-nav',\n            route.meta.layoutWrapperClasses,\n            (scrollDistance.value > 5 || (isDialogOpen.value && wasScrolledBeforeDialog.value)) && 'window-scrolled',\n          ],\n        },\n        [verticalNav, h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), layoutOverlay],\n      )\n    }\n  },\n})\n</script>\n\n<style lang=\"scss\">\n@use '@configured-variables' as variables;\n@use '@layouts/styles/placeholders';\n@use '@layouts/styles/mixins';\n\n.layout-page-content {\n  position: relative;\n  z-index: 1;\n  margin-block-start: 0;\n}\n\n.layout-wrapper.layout-nav-type-vertical {\n  // TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav\n  min-block-size: 100%;\n\n  .layout-content-wrapper {\n    display: flex;\n    flex-direction: column;\n    flex-grow: 1;\n    min-block-size: calc(var(--vh, 1vh) * 100);\n    transition: padding-inline-start 0.2s ease-in-out;\n    will-change: padding-inline-start;\n  }\n\n  .layout-navbar {\n    position: fixed;\n    z-index: variables.$layout-vertical-nav-layout-navbar-z-index;\n    inline-size: calc(100vw - variables.$layout-vertical-nav-width - 0.5rem);\n    inset-block-start: 0;\n\n    .navbar-content-container {\n      block-size: calc(\n        env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height + var(--navbar-tab-height)\n      );\n    }\n\n    @at-root {\n      .layout-wrapper.layout-nav-type-vertical {\n        .layout-navbar {\n          @if variables.$layout-vertical-nav-navbar-is-contained {\n            @include mixins.boxed-content;\n          }\n        }\n      }\n    }\n  }\n\n  &.layout-navbar-fixed .layout-navbar {\n    @extend %layout-navbar-fixed;\n  }\n\n  &.layout-navbar-hidden .layout-navbar {\n    @extend %layout-navbar-hidden;\n  }\n\n  // 👉 Footer\n  .layout-footer {\n    @include mixins.boxed-content;\n  }\n\n  // 👉 Layout overlay\n  .layout-overlay {\n    position: fixed;\n    z-index: variables.$layout-overlay-z-index;\n    background-color: rgb(0 0 0 / 60%);\n    cursor: pointer;\n    inset: 0;\n    opacity: 0;\n    pointer-events: none;\n    transition: opacity 0.25s ease-in-out;\n    will-change: transform;\n\n    &.visible {\n      opacity: 1;\n      pointer-events: auto;\n    }\n  }\n\n  &:not(.layout-overlay-nav) .layout-content-wrapper {\n    padding-inline-start: calc(variables.$layout-vertical-nav-width);\n  }\n\n  // Adjust right column pl when vertical nav is collapsed\n  &.layout-vertical-nav-collapsed .layout-content-wrapper {\n    padding-inline-start: variables.$layout-vertical-nav-collapsed-width;\n  }\n\n  // 👉 Content height fixed\n  &.layout-content-height-fixed {\n    .layout-content-wrapper {\n      max-block-size: calc(var(--vh) * 100);\n    }\n\n    .layout-page-content {\n      // display: flex;\n      // 使用 clip 替代 hidden，避免 Chrome 144+ 滚动锁定问题\n      overflow-x: clip;\n      overflow-y: auto;\n\n      .page-content-container {\n        inline-size: 100%;\n\n        > :first-child {\n          max-block-size: 100%;\n          overflow-y: auto;\n        }\n      }\n    }\n  }\n}\n\n.layout-wrapper.layout-nav-type-vertical.layout-overlay-nav {\n  .layout-navbar {\n    inline-size: 100%;\n    padding-inline: 0;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/@layouts/components/VerticalNavLink.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { NavLink } from '@layouts/types'\n\ndefineProps<{\n  item: NavLink\n}>()\n</script>\n\n<template>\n  <li class=\"nav-link\" :class=\"{ disabled: item.disable }\">\n    <Component :is=\"item.to ? 'RouterLink' : 'a'\" :to=\"item.to\" :href=\"item.href\">\n      <VIcon :icon=\"item.icon as string\" class=\"nav-item-icon\" />\n      <!-- 👉 Title -->\n      <span class=\"nav-item-title\">\n        {{ item.title }}\n      </span>\n    </Component>\n  </li>\n</template>\n\n<style lang=\"scss\">\n.layout-vertical-nav {\n  .nav-link a {\n    display: flex;\n    align-items: center;\n    border-radius: 0 3.125rem 3.125rem 0 !important;\n    cursor: pointer;\n    margin-inline-end: 1.125em;\n    padding-inline: 1.375rem 1rem;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/@layouts/components/VerticalNavSectionTitle.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { NavSectionTitle } from '@layouts/types'\n\ndefineProps<{\n  item: NavSectionTitle\n}>()\n</script>\n\n<template>\n  <li class=\"nav-section-title\">\n    <div class=\"title-wrapper\">\n      <!-- eslint-disable vue/no-v-text-v-html-on-component -->\n      <span class=\"title-text\" v-text=\"item.heading\" />\n      <!-- eslint-enable vue/no-v-text-v-html-on-component -->\n    </div>\n  </li>\n</template>\n\n<style lang=\"scss\">\n.layout-vertical-nav {\n  .nav-section-title {\n    padding-left: 1.375rem;\n    padding-right: 1rem;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/@layouts/components.ts",
    "content": "export { default as VerticalNavLayout } from './components/VerticalNavLayout.vue'\nexport { default as VerticalNavLink } from './components/VerticalNavLink.vue'\nexport { default as VerticalNavSectionTitle } from './components/VerticalNavSectionTitle.vue'\n"
  },
  {
    "path": "src/@layouts/index.ts",
    "content": "export * from './components'\n"
  },
  {
    "path": "src/@layouts/styles/_classes.scss",
    "content": ".cursor-pointer {\n  cursor: pointer;\n}\n"
  },
  {
    "path": "src/@layouts/styles/_default-layout.scss",
    "content": "// These are styles which are both common in layout w/ vertical nav & horizontal nav\n@use \"@layouts/styles/rtl\";\n@use \"@layouts/styles/placeholders\";\n@use \"@layouts/styles/mixins\";\n@use \"@configured-variables\" as variables;\n\nhtml {\n  background: rgb(var(--v-theme-background));\n  min-block-size: 100vh;\n  min-block-size: 100dvh;\n}\n\nbody {\n  background: rgb(var(--v-theme-background));\n  overscroll-behavior-y: contain;\n  // Chrome 144+ 兼容性：覆盖 Vuetify 的内联 overflow: hidden 样式\n  overflow: visible !important;\n\n  --webkit-overflow-scrolling: touch;\n}\n\nbody,\n#app,\n.v-application {\n  min-block-size: 100%;\n}\n\n.layout-vertical-nav {\n  padding-block: env(safe-area-inset-top) env(safe-area-inset-bottom);\n}\n\n.navbar-content-container {\n  padding-block-start: env(safe-area-inset-top);\n  padding-inline: 0.5rem;\n}\n\n.layout-page-content {\n  @include mixins.boxed-content(true);\n\n  // Chrome 144+ 兼容性：使用 clip 替代 hidden，避免滚动锁定问题\n  // overflow: hidden 在新版 Chrome 中可能意外阻止垂直滚动\n  overflow: clip;\n  flex-grow: 1;\n\n  // TODO: Use grid gutter variable here;\n  padding-block: 1.5rem;\n  padding-block-start: calc(env(safe-area-inset-top) + 4.5rem);\n  padding-inline: 0.5rem;\n\n  // display: flex;display\n\n\n  .page-content-container {\n    // flex: 1;flex\n    display: flex;\n\n    & > div:first-child {\n      position: relative;\n      flex: auto;\n      inline-size: calc(100vw - variables.$layout-vertical-nav-width - 1rem);\n    }\n  }\n}\n\n@media screen and (width <= 1280px){\n  .page-content-container > div:first-child  {\n    inline-size: calc(100vw - 1rem) !important;\n  }\n}\n\n.layout-footer {\n  .footer-content-container {\n    block-size: variables.$layout-vertical-nav-footer-height;\n  }\n\n  .footer-content-container-noheight {\n    block-size: 0 !important;\n  }\n\n  .layout-footer-sticky & {\n    position: sticky;\n    inset-block-end: 0;\n    will-change: transform;\n  }\n\n  .layout-footer-hidden & {\n    display: none;\n  }\n}\n"
  },
  {
    "path": "src/@layouts/styles/_global.scss",
    "content": "*,\n::before,\n::after {\n  box-sizing: inherit;\n  background-repeat: no-repeat;\n}\n\nhtml {\n  box-sizing: border-box;\n  min-block-size: 100%;\n  min-block-size: 100vh;\n  min-block-size: 100dvh;\n}\n"
  },
  {
    "path": "src/@layouts/styles/_mixins.scss",
    "content": "@use \"placeholders\";\n@use \"@configured-variables\" as variables;\n\n@mixin rtl {\n  @if variables.$enable-rtl-styles {\n    [dir=\"rtl\"] & {\n      @content;\n    }\n  }\n}\n\n@mixin boxed-content($nest-selector: false) {\n  & {\n    @extend %boxed-content-spacing;\n\n    @at-root {\n      @if $nest-selector == false {\n        .layout-content-width-boxed#{&} {\n          @extend %boxed-content;\n        }\n      } @else {\n        .layout-content-width-boxed & {\n          @extend %boxed-content;\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/@layouts/styles/_placeholders.scss",
    "content": "// placeholders\n@use \"@configured-variables\" as variables;\n\n%boxed-content {\n  @at-root #{&}-spacing {\n    // TODO: Use grid gutter variable here\n    // padding-inline: 0.5rem;\n  }\n\n  inline-size: 100%;\n  margin-inline: auto;\n  max-inline-size: variables.$layout-boxed-content-width;\n}\n\n%layout-navbar-hidden {\n  display: none;\n}\n\n// ℹ️ We created this placeholder even it is being used in just layout w/ vertical nav because in future we might apply style to both navbar & horizontal nav separately\n%layout-navbar-fixed {\n  position: fixed;\n  top: 0;\n  inset-block-start: 0;\n\n  // will-change: transform;\n  // inline-size: 100%;\n}\n\n%style-scroll-bar {\n  /* width */\n\n  &::-webkit-scrollbar {\n    background: rgb(var(--v-theme-surface));\n    block-size: 8px;\n    border-end-end-radius: 14px;\n    border-start-end-radius: 14px;\n    inline-size: 4px;\n  }\n\n  /* Track */\n  &::-webkit-scrollbar-track {\n    background: transparent;\n  }\n\n  /* Handle */\n  &::-webkit-scrollbar-thumb {\n    border-radius: 0.5rem;\n    background: rgb(var(--v-theme-perfect-scrollbar-thumb));\n  }\n\n  &::-webkit-scrollbar-corner {\n    display: none;\n  }\n}\n"
  },
  {
    "path": "src/@layouts/styles/_rtl.scss",
    "content": "@use \"./mixins\";\n\n.layout-vertical-nav .nav-group-arrow {\n  @include mixins.rtl {\n    transform: rotate(180deg);\n  }\n}\n"
  },
  {
    "path": "src/@layouts/styles/_variables.scss",
    "content": "// @use \"@styles/style.scss\";\n\n// 👉 Vertical nav\n$layout-vertical-nav-z-index: 12 !default;\n$layout-vertical-nav-width: 16.25rem !default;\n$layout-vertical-nav-collapsed-width: 5rem !default;\n\n// 👉 Horizontal nav\n$layout-horizontal-nav-z-index: 11 !default;\n$layout-horizontal-nav-navbar-height: 4rem !default;\n\n// 👉 Navbar\n$layout-vertical-nav-navbar-height: 4rem !default;\n$layout-vertical-nav-navbar-is-contained: true !default;\n$layout-vertical-nav-layout-navbar-z-index: 11 !default;\n$layout-horizontal-nav-layout-navbar-z-index: 11 !default;\n\n// 👉 Main content\n$layout-boxed-content-width: 90rem !default;\n\n//  👉Footer\n$layout-vertical-nav-footer-height: 8rem !default;\n\n// 👉 Layout overlay\n$layout-overlay-z-index: 11 !default;\n\n// 👉 RTL\n$enable-rtl-styles: true !default;\n"
  },
  {
    "path": "src/@layouts/styles/index.scss",
    "content": "@use \"_global\";\n@use \"_classes\";\n"
  },
  {
    "path": "src/@layouts/types.d.ts",
    "content": "import type { Component, Ref, VNode } from 'vue'\nimport type { RouteLocationRaw } from 'vue-router'\nimport { ContentWidth, FooterType, NavbarType } from './enums'\n\nexport interface UserConfig {\n  app: {\n    title: Lowercase<string>\n    logo: VNode\n    contentWidth: (typeof ContentWidth)[keyof typeof ContentWidth]\n    contentLayoutNav: (typeof AppContentLayoutNav)[keyof typeof AppContentLayoutNav]\n    overlayNavFromBreakpoint: number\n    enableI18n: boolean\n    isRtl: boolean\n    iconRenderer?: Component\n  }\n  navbar: {\n    type: (typeof NavbarType)[keyof typeof NavbarType]\n    navbarBlur: boolean\n  }\n  footer: {\n    type: (typeof FooterType)[keyof typeof FooterType]\n  }\n  verticalNav: {\n    isVerticalNavCollapsed: boolean\n    defaultNavItemIconProps: unknown\n  }\n  horizontalNav: {\n    type: 'sticky' | 'static' | 'hidden'\n    transition?: string | Component\n  }\n  icons: {\n    chevronDown: any\n    chevronRight: any\n    close: any\n    verticalNavPinned: any\n    verticalNavUnPinned: any\n    sectionTitlePlaceholder: any\n  }\n}\n\n/*\n  TODO: use MergeDeep for DRY\n   Waiting for https://github.com/sindresorhus/type-fest/issues/150\n*/\nexport interface Config {\n  app: {\n    title: UserConfig['app']['title']\n    logo: UserConfig['app']['logo']\n    contentWidth: Ref<UserConfig['app']['contentWidth']>\n    contentLayoutNav: Ref<UserConfig['app']['contentLayoutNav']>\n    overlayNavFromBreakpoint: UserConfig['app']['overlayNavFromBreakpoint']\n    enableI18n: UserConfig['app']['enableI18n']\n    isRtl: Ref<UserConfig['app']['isRtl']>\n    iconRenderer?: UserConfig['app']['iconRenderer']\n  }\n  navbar: {\n    type: Ref<UserConfig['navbar']['type']>\n    navbarBlur: Ref<UserConfig['navbar']['navbarBlur']>\n  }\n  footer: {\n    type: Ref<UserConfig['footer']['type']>\n  }\n  verticalNav: {\n    isVerticalNavCollapsed: Ref<UserConfig['verticalNav']['isVerticalNavCollapsed']>\n    defaultNavItemIconProps: UserConfig['verticalNav']['defaultNavItemIconProps']\n  }\n  horizontalNav: {\n    type: Ref<UserConfig['horizontalNav']['type']>\n    transition?: UserConfig['horizontalNav']['transition']\n  }\n  icons: {\n    chevronDown: UserConfig['icons']['chevronDown']\n    chevronRight: UserConfig['icons']['chevronRight']\n    close: UserConfig['icons']['close']\n    verticalNavPinned: UserConfig['icons']['verticalNavPinned']\n    verticalNavUnPinned: UserConfig['icons']['verticalNavUnPinned']\n    sectionTitlePlaceholder: UserConfig['icons']['sectionTitlePlaceholder']\n  }\n}\n\ninterface AclProperties {\n  action: string\n  subject: string\n}\n\n// 👉 Vertical nav section title\nexport interface NavSectionTitle extends Partial<AclProperties> {\n  heading: string\n}\n\n// 👉 Vertical nav link\ndeclare type ATagTargetAttrValues = '_blank' | '_self' | '_parent' | '_top' | 'framename'\ndeclare type ATagRelAttrValues =\n  | 'alternate'\n  | 'author'\n  | 'bookmark'\n  | 'external'\n  | 'help'\n  | 'license'\n  | 'next'\n  | 'nofollow'\n  | 'noopener'\n  | 'noreferrer'\n  | 'prev'\n  | 'search'\n  | 'tag'\n\nexport interface NavLinkProps {\n  to?: RouteLocationRaw | string | null\n  href?: string\n  target?: ATagTargetAttrValues\n  rel?: ATagRelAttrValues\n}\n\nexport interface NavLink extends NavLinkProps, Partial<AclProperties> {\n  title: string\n  full_title?: string\n  icon?: unknown\n  badgeContent?: string\n  badgeClass?: string\n  disable?: boolean\n}\n\nexport interface NavMenu extends NavLink {\n  header: string\n  description?: string\n  admin?: boolean\n  footer?: boolean\n}\n\n// 👉 Vertical nav group\nexport interface NavGroup extends Partial<AclProperties> {\n  title: string\n  icon?: unknown\n  badgeContent?: string\n  badgeClass?: string\n  children: (NavLink | NavGroup)[]\n  disable?: boolean\n}\n\nexport declare type VerticalNavItems = (NavLink | NavGroup | NavSectionTitle)[]\nexport declare type HorizontalNavItems = (NavLink | NavGroup)[]\n\n// 👉 Components ========================\n\ninterface I18nLanguage {\n  label: string\n  i18nLang: string\n}\n\n// avatar | text | icon\n// Thanks: https://stackoverflow.com/a/60617060/10796681\ntype Notification = {\n  id: number\n  title: string\n  subtitle: string\n  time: string\n  color?: string\n  isSeen: boolean\n} & (\n  | { img: string; text?: never; icon?: never }\n  | { img?: never; text: string; icon?: never }\n  | { img?: never; text?: never; icon: string }\n)\n\ninterface ThemeSwitcherTheme {\n  name: string\n  title: string\n  icon: string\n}\n"
  },
  {
    "path": "src/@layouts/utils.ts",
    "content": "import type { NavLink, NavLinkProps } from '@layouts/types'\n\n/**\n * Return nav link props to use\n * @param {Object, String} item navigation routeName or route Object provided in navigation data\n */\nexport const getComputedNavLinkToProp = computed(() => (link: NavLink) => {\n  const props: NavLinkProps = {\n    target: link.target,\n    rel: link.rel,\n  }\n\n  // If route is string => it assumes string is route name => Create route object from route name\n  // If route is not string => It assumes it's route object => returns passed route object\n  if (link.to)\n    props.to = typeof link.to === 'string' ? { name: link.to } : link.to\n  else props.href = link.href\n\n  return props\n})\n\n/**\n * Convert Hex color to rgb\n * @param hex\n */\n\nexport function hexToRgb(hex: string) {\n// Expand shorthand form (e.g. \"03F\") to full form (e.g. \"0033FF\")\n  const shorthandRegex = /^#?([a-f\\d])([a-f\\d])([a-f\\d])$/i\n\n  hex = hex.replace(shorthandRegex, (m: string, r: string, g: string, b: string) => {\n    return r + r + g + g + b + b\n  })\n\n  const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex)\n\n  return result ? `${parseInt(result[1], 16)},${parseInt(result[2], 16)},${parseInt(result[3], 16)}` : null\n}\n"
  },
  {
    "path": "src/@validators/index.ts",
    "content": "type ValidationRule = (value: any) => string | boolean\n\n// 必输校验\nexport const requiredValidator: ValidationRule = (value: any) => {\n  return !!value\n}\n\n// 数字校验\nexport const numberValidator: ValidationRule = (value: any) => {\n  return !isNaN(value)\n}\n"
  },
  {
    "path": "src/App.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useTheme } from 'vuetify'\nimport { checkPrefersColorSchemeIsDark } from '@/@core/utils'\nimport { ensureRenderComplete, removeEl } from './@core/utils/dom'\nimport api from '@/api'\nimport { useAuthStore, useGlobalSettingsStore } from '@/stores'\nimport { getBrowserLocale, setI18nLanguage } from './plugins/i18n'\nimport { SupportedLocale } from '@/types/i18n'\nimport { checkAndEmitUnreadMessages } from '@/utils/badge'\nimport { preloadImage } from './@core/utils/image'\nimport { globalLoadingStateManager } from '@/utils/loadingStateManager'\nimport { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'\nimport PWAInstallPrompt from '@/components/PWAInstallPrompt.vue'\nimport { themeManager } from '@/utils/themeManager'\n\n// 生效主题\nconst { global: globalTheme } = useTheme()\nlet themeValue = localStorage.getItem('theme') || 'auto'\nconst autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'\nglobalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue\n\n// 生效语言\nconst localeValue = getBrowserLocale()\nsetI18nLanguage(localeValue as SupportedLocale)\n\n// 检查是否登录\nconst authStore = useAuthStore()\nconst isLogin = computed(() => authStore.token)\n\n// 全局设置store\nconst globalSettingsStore = useGlobalSettingsStore()\n\n// 生成背景图片key\nconst loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'))\n\n// 背景图片\nconst backgroundImages = ref<string[]>([])\nconst activeImageIndex = ref(0)\nconst isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')\n\n// 心跳检测\nlet heartbeatInterval: number | null = null\n\n// ApexCharts 全局配置\ndeclare global {\n  interface Window {\n    Apex: any\n  }\n}\n\n// 启动心跳\nconst startHeartbeat = () => {\n  // 如果已经有心跳，则先停止\n  if (heartbeatInterval) {\n    stopHeartbeat()\n  }\n\n  // 开始心跳任务\n  heartbeatInterval = window.setInterval(async () => {\n    try {\n      if (isLogin.value) {\n        await api.get('dashboard/cpu')\n      }\n    } catch (error) {\n      console.warn('Heartbeat request failed:', error)\n    }\n  }, 5 * 60 * 1000)\n}\n\n// 停止心跳\nconst stopHeartbeat = () => {\n  if (heartbeatInterval) {\n    window.clearInterval(heartbeatInterval)\n    heartbeatInterval = null\n  }\n}\n\n// 配置 ApexCharts 全局选项\nfunction configureApexCharts() {\n  if (typeof window !== 'undefined' && window.Apex) {\n    try {\n      // 获取当前主题\n      const currentTheme = globalTheme.name.value\n      const isDark = currentTheme === 'dark' || currentTheme === 'transparent'\n\n      // 数据标签\n      window.Apex.dataLabels = {\n        formatter: function (_: number, { seriesIndex, w }: { seriesIndex: number; w: any }) {\n          // 如果有小数点，保留两位小数，否则保留整数\n          const data = w.config.series[seriesIndex]\n          return data.toFixed(data % 1 === 0 ? 0 : 1)\n        },\n      }\n      // 图例\n      window.Apex.legend = {\n        labels: {\n          useSeriesColors: true,\n        },\n      }\n      // 标题\n      window.Apex.title = {\n        style: {\n          color: 'rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))',\n        },\n      }\n      // 鼠标悬浮提示\n      window.Apex.tooltip = {\n        theme: isDark ? 'dark' : 'light',\n      }\n    } catch (error) {\n      console.warn('ApexCharts 全局配置失败:', error)\n    }\n  }\n}\n\n// 更新data-theme属性以便CSS选择器能正确匹配\nfunction updateHtmlThemeAttribute(themeName: string) {\n  document.documentElement.setAttribute('data-theme', themeName)\n  document.body.setAttribute('data-theme', themeName)\n}\n\n// 获取背景图片\nasync function fetchBackgroundImages() {\n  try {\n    const controller = new AbortController()\n    backgroundImages.value = await api.get(`/login/wallpapers`, {\n      signal: controller.signal,\n    })\n    activeImageIndex.value = 0\n  } catch (e) {\n    throw e\n  }\n}\n\n// 背景图片轮换函数\nfunction rotateBackgroundImage() {\n  if (backgroundImages.value.length > 1) {\n    // 计算下一个图片索引\n    const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length\n    // 预加载下一张图片\n    preloadImage(backgroundImages.value[nextIndex]).then(success => {\n      // 只有图片成功加载才切换\n      if (success) {\n        activeImageIndex.value = nextIndex\n      }\n    })\n  }\n}\n\n// 开始背景图片轮换\nfunction startBackgroundRotation() {\n  // 清除现有定时器\n  removeBackgroundTimer('background-rotation')\n\n  if (backgroundImages.value.length > 1) {\n    // 使用优化的定时器管理器，后台时自动暂停\n    addBackgroundTimer(\n      'background-rotation',\n      rotateBackgroundImage,\n      10000, // 每10秒切换一次\n      {\n        runInBackground: false, // 后台时不运行\n        skipInitialRun: true, // 不需要立即执行\n      },\n    )\n  }\n}\n\n// 添加logo动画效果并延迟移除加载界面\nfunction animateAndRemoveLoader() {\n  const loadingBg = document.querySelector('#loading-bg') as HTMLElement\n  if (loadingBg) {\n    removeEl('#loading-bg')\n    document.documentElement.style.removeProperty('background')\n  }\n}\n\n// 检查PWA状态并移除加载界面\nasync function removeLoadingWithStateCheck() {\n  try {\n    // 设置各个组件的加载状态\n    globalLoadingStateManager.setLoadingState('pwa-state', true)\n    globalLoadingStateManager.setLoadingState('global-settings', true)\n    globalLoadingStateManager.setLoadingState('background-images', true)\n\n    // 静默检查PWA状态恢复\n    const pwaController = (window as any).pwaStateController\n    if (pwaController) {\n      await pwaController.waitForStateRestore()\n    }\n    globalLoadingStateManager.setLoadingState('pwa-state', false)\n\n    // 并行加载关键资源\n    await Promise.all([\n      globalSettingsStore.initialize().then(async () => {\n        // 如果已登录，加载用户相关设置\n        if (isLogin.value) {\n          await globalSettingsStore.loadUserSettings()\n        }\n        globalLoadingStateManager.setLoadingState('global-settings', false)\n      }),\n      new Promise(resolve => {\n        setTimeout(() => {\n          globalLoadingStateManager.setLoadingState('background-images', false)\n          resolve(void 0)\n        }, 50)\n      }),\n    ])\n\n    // 等待所有加载完成\n    await globalLoadingStateManager.waitForAllComplete()\n\n    // 移除加载界面\n    animateAndRemoveLoader()\n\n    // 检查未读消息\n    checkAndEmitUnreadMessages()\n  } catch (error) {\n    // 即使出错也要移除加载界面\n    globalLoadingStateManager.reset()\n    animateAndRemoveLoader()\n  }\n}\n\n// 加载背景图片\nasync function loadBackgroundImages(retryCount = 0) {\n  const maxRetries = 3\n  try {\n    await fetchBackgroundImages()\n    startBackgroundRotation()\n  } catch (error: any) {\n    const isAbortError = error.name === 'AbortError' || error.code === 'ERR_CANCELED'\n    if (retryCount < maxRetries) {\n      const baseDelay = isAbortError ? 1000 : 3000\n      const retryDelay = Math.min(baseDelay * Math.pow(2, retryCount), 10000)\n      setTimeout(() => {\n        loadBackgroundImages(retryCount + 1)\n      }, retryDelay)\n    }\n  }\n}\n\nonMounted(async () => {\n  // 移除URL中的时间戳参数\n  const url = new URL(window.location.href)\n  if (url.searchParams.has('_t')) {\n    url.searchParams.delete('_t')\n    const newUrl = url.pathname + url.search + url.hash\n    window.history.replaceState(null, '', newUrl)\n  }\n\n  // 配置 ApexCharts\n  configureApexCharts()\n\n  // 初始化data-theme属性\n  updateHtmlThemeAttribute(globalTheme.name.value)\n\n  // 初始化主题管理器 - 统一处理主题初始化\n  await themeManager.setTheme(themeValue)\n\n  // 监听主题变化\n  watch(\n    () => globalTheme.name.value,\n    newTheme => {\n      // 更新HTML主题属性\n      updateHtmlThemeAttribute(newTheme)\n      // 重新配置ApexCharts以适应新主题\n      configureApexCharts()\n    },\n  )\n\n  // 加载背景图片\n  loadBackgroundImages()\n\n  // 使用优化后的加载界面移除逻辑\n  ensureRenderComplete(() => {\n    nextTick(removeLoadingWithStateCheck)\n  })\n  // 启动心跳\n  startHeartbeat()\n})\n\nonUnmounted(() => {\n  // 清除背景轮换定时器\n  removeBackgroundTimer('background-rotation')\n  // 停止心跳\n  stopHeartbeat()\n})\n</script>\n\n<template>\n  <div class=\"app-wrapper\">\n    <!-- 透明主题背景 -->\n    <div v-if=\"backgroundImages.length > 0 && (isTransparentTheme || !isLogin)\" class=\"background-container\">\n      <div\n        v-for=\"(imageUrl, index) in backgroundImages\"\n        :key=\"`bg-${index}-${loginStateKey}`\"\n        class=\"background-image\"\n        :class=\"{ 'active': index === activeImageIndex }\"\n        :style=\"{ 'backgroundImage': `url(${imageUrl})` }\"\n      />\n      <!-- 全局磨砂层 -->\n      <div v-if=\"isLogin && isTransparentTheme\" class=\"global-blur-layer\"></div>\n    </div>\n    <!-- 页面内容 -->\n    <VApp>\n      <RouterView />\n      <!-- PWA安装提示 -->\n      <PWAInstallPrompt />\n    </VApp>\n  </div>\n</template>\n\n<style lang=\"scss\">\n/* 全局样式 */\n.app-wrapper {\n  position: relative;\n  inline-size: 100%;\n  min-block-size: 100vh;\n}\n\n.background-container {\n  position: fixed;\n  z-index: 0;\n  overflow: hidden;\n  block-size: 100%;\n  inline-size: 100%;\n  inset-block-start: 0;\n  inset-inline-start: 0;\n}\n\n.background-image {\n  position: absolute;\n  background-position: center;\n  background-repeat: no-repeat;\n  background-size: cover;\n  block-size: 100%;\n  inline-size: 100%;\n  inset-block-start: 0;\n  inset-inline-start: 0;\n  opacity: 0;\n  transition: opacity 1.5s ease;\n\n  &::after {\n    position: absolute;\n    background: linear-gradient(rgba(0, 0, 0, 30%) 0%, rgba(0, 0, 0, 60%) 100%);\n    block-size: 100%;\n    content: '';\n    inline-size: 100%;\n    inset-block-start: 0;\n    inset-inline-start: 0;\n  }\n\n  &.active {\n    opacity: 1;\n  }\n}\n\n/* 全局磨砂层 */\n.global-blur-layer {\n  position: absolute;\n  z-index: 1;\n  backdrop-filter: blur(16px);\n  background-color: rgba(128, 128, 128, 30%);\n  block-size: 100%;\n  inline-size: 100%;\n  inset-block-start: 0;\n  inset-inline-start: 0;\n}\n</style>\n"
  },
  {
    "path": "src/ace-config.ts",
    "content": "import ace from 'ace-builds'\n\nimport modeJsonUrl from 'ace-builds/src-noconflict/mode-json?url'\n\nimport modeJavascriptUrl from 'ace-builds/src-noconflict/mode-javascript?url'\n\nimport modeHtmlUrl from 'ace-builds/src-noconflict/mode-html?url'\n\nimport modeYamlUrl from 'ace-builds/src-noconflict/mode-yaml?url'\n\nimport modeCssUrl from 'ace-builds/src-noconflict/mode-css?url'\n\nimport modeIniUrl from 'ace-builds/src-noconflict/mode-ini?url'\n\nimport themeGithubUrl from 'ace-builds/src-noconflict/theme-github?url'\n\nimport themeChromeUrl from 'ace-builds/src-noconflict/theme-chrome?url'\n\nimport themeMonokaiUrl from 'ace-builds/src-noconflict/theme-monokai?url'\n\nimport workerBaseUrl from 'ace-builds/src-noconflict/worker-base?url'\n\nimport workerJsonUrl from 'ace-builds/src-noconflict/worker-json?url'\n\nimport workerJavascriptUrl from 'ace-builds/src-noconflict/worker-javascript?url'\n\nimport workerHtmlUrl from 'ace-builds/src-noconflict/worker-html?url'\n\nimport workerYamlUrl from 'ace-builds/src-noconflict/worker-yaml?url'\n\nimport workerCssUrl from 'ace-builds/src-noconflict/worker-css?url'\n\nimport snippetsHtmlUrl from 'ace-builds/src-noconflict/snippets/html?url'\n\nimport snippetsJsUrl from 'ace-builds/src-noconflict/snippets/javascript?url'\n\nimport snippetsYamlUrl from 'ace-builds/src-noconflict/snippets/yaml?url'\n\nimport snippetsJsonUrl from 'ace-builds/src-noconflict/snippets/json?url'\n\nimport snippertsCssUrl from 'ace-builds/src-noconflict/snippets/css?url'\n\nimport snippertsIniUrl from 'ace-builds/src-noconflict/snippets/ini?url'\n\nimport 'ace-builds/src-noconflict/ext-language_tools'\n\nconst aceModule = ace as typeof ace & {\n  define?: (moduleName: string, deps: string[], payload: (...args: any[]) => void) => void\n}\n\nfunction registerJinja2Mode() {\n  aceModule.define?.(\n    'ace/mode/jinja2_highlight_rules',\n    ['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],\n    (require: any, exports: any) => {\n      const oop = require('../lib/oop')\n      const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules\n\n      const Jinja2HighlightRules = function (this: any) {\n        const tags =\n          'autoescape|block|call|do|elif|else|endautoescape|endblock|endcall|endfilter|endfor|endif|endmacro|endraw|endset|endtrans|endwith|extends|filter|for|from|if|import|include|macro|raw|set|trans|with'\n        const filters =\n          'abs|attr|batch|capitalize|center|count|d|default|dictsort|e|escape|filesizeformat|first|float|forceescape|format|groupby|indent|int|items|join|last|length|list|lower|map|max|min|pprint|random|reject|rejectattr|replace|reverse|round|safe|select|selectattr|slice|sort|string|striptags|sum|title|tojson|trim|truncate|unique|upper|urlencode|urlize|wordcount|wordwrap|xmlattr'\n        const functions = 'cycler|dict|joiner|lipsum|namespace|range'\n        const tests =\n          'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper'\n        const operators = 'and|in|is|not|or'\n        const contextVariables =\n          'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'\n\n        const keywordMapper = this.createKeywordMapper(\n          {\n            'keyword.control.jinja2': tags,\n            'keyword.operator.jinja2': operators,\n            'support.function.jinja2': [filters, functions, tests].join('|'),\n            'constant.language.jinja2': 'false|False|none|None|null|true|True',\n          },\n          'identifier',\n        )\n\n        const jinjaExpressionRules = [\n          {\n            token: 'string',\n            regex: \"'\",\n            push: 'jinja2-qstring',\n          },\n          {\n            token: 'string',\n            regex: '\"',\n            push: 'jinja2-qqstring',\n          },\n          {\n            token: 'constant.numeric',\n            regex: /[+-]?(?:0[xX][0-9a-fA-F]+|\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][+-]?\\d+)?\\b/,\n          },\n          {\n            token: ['keyword.operator.other.jinja2', 'text', 'support.function.jinja2'],\n            regex: `(\\\\|)(\\\\s*)(${filters})\\\\b`,\n          },\n          {\n            token: ['keyword.operator.jinja2', 'text', 'support.function.jinja2'],\n            regex: `(\\\\bis\\\\b)(\\\\s*)(${tests})\\\\b`,\n          },\n          {\n            token: ['support.function.jinja2', 'text', 'paren.lparen'],\n            regex: `\\\\b(${functions})(\\\\s*)(\\\\()`,\n          },\n          {\n            token: 'variable.language.jinja2',\n            regex: `\\\\b(?:${contextVariables})\\\\b`,\n          },\n          {\n            token: keywordMapper,\n            regex: /[a-zA-Z_$][a-zA-Z0-9_$]*\\b/,\n          },\n          {\n            token: 'keyword.operator.assignment.jinja2',\n            regex: /=|~/,\n          },\n          {\n            token: 'keyword.operator.comparison.jinja2',\n            regex: /==|!=|<=|>=|<|>/,\n          },\n          {\n            token: 'keyword.operator.arithmetic.jinja2',\n            regex: /\\+|-|\\/\\/|\\/|%|\\*\\*|\\*/,\n          },\n          {\n            token: 'keyword.operator.other.jinja2',\n            regex: /\\.{2}|\\||:/,\n          },\n          {\n            token: 'punctuation.operator.jinja2',\n            regex: /[.,;?]/,\n          },\n          {\n            token: 'paren.lparen',\n            regex: /[\\[({]/,\n          },\n          {\n            token: 'paren.rparen',\n            regex: /[\\])}]/,\n          },\n          {\n            token: 'text',\n            regex: /\\s+/,\n          },\n        ]\n\n        this.$rules = {\n          start: [\n            {\n              token: 'comment.block.jinja2',\n              regex: /\\{#-?/,\n              push: 'jinja2-comment',\n            },\n            {\n              token: 'constant.language.jinja2',\n              regex: /\\{\\{-?/,\n              push: 'jinja2-expression',\n            },\n            {\n              token: 'keyword.control.jinja2',\n              regex: /\\{%-?/,\n              push: 'jinja2-statement',\n            },\n          ],\n          'jinja2-comment': [\n            {\n              token: 'comment.block.jinja2',\n              regex: /-?#\\}/,\n              next: 'pop',\n            },\n            {\n              defaultToken: 'comment.block.jinja2',\n            },\n          ],\n          'jinja2-expression': [\n            {\n              token: 'constant.language.jinja2',\n              regex: /-?\\}\\}/,\n              next: 'pop',\n            },\n            ...jinjaExpressionRules,\n          ],\n          'jinja2-statement': [\n            {\n              token: 'keyword.control.jinja2',\n              regex: /-?%\\}/,\n              next: 'pop',\n            },\n            ...jinjaExpressionRules,\n          ],\n          'jinja2-qqstring': [\n            {\n              token: 'constant.language.escape',\n              regex: /\\\\[\\\\\"ntr]/,\n            },\n            {\n              token: 'string',\n              regex: '\"',\n              next: 'pop',\n            },\n            {\n              defaultToken: 'string',\n            },\n          ],\n          'jinja2-qstring': [\n            {\n              token: 'constant.language.escape',\n              regex: /\\\\[\\\\'ntr]/,\n            },\n            {\n              token: 'string',\n              regex: \"'\",\n              next: 'pop',\n            },\n            {\n              defaultToken: 'string',\n            },\n          ],\n        }\n\n        this.normalizeRules()\n      }\n\n      oop.inherits(Jinja2HighlightRules, TextHighlightRules)\n      exports.Jinja2HighlightRules = Jinja2HighlightRules\n    },\n  )\n\n  aceModule.define?.(\n    'ace/mode/jinja2',\n    ['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/jinja2_highlight_rules'],\n    (require: any, exports: any) => {\n      const oop = require('../lib/oop')\n      const TextMode = require('./text').Mode\n      const Jinja2HighlightRules = require('./jinja2_highlight_rules').Jinja2HighlightRules\n\n      const Mode = function (this: any) {\n        TextMode.call(this)\n        this.HighlightRules = Jinja2HighlightRules\n      }\n\n      oop.inherits(Mode, TextMode)\n\n      ;(function (this: any) {\n        this.$id = 'ace/mode/jinja2'\n        this.blockComment = { start: '{#', end: '#}' }\n      }).call(Mode.prototype)\n\n      exports.Mode = Mode\n    },\n  )\n\n  aceModule.define?.('ace/snippets/jinja2', ['require', 'exports', 'module'], (_require: any, exports: any) => {\n    exports.snippetText =\n      'snippet if\\n\\t{% if ${1:condition} %}\\n\\t\\t${0}\\n\\t{% endif %}\\n' +\n      'snippet for\\n\\t{% for ${1:item} in ${2:items} %}\\n\\t\\t${0}\\n\\t{% endfor %}\\n' +\n      'snippet var\\n\\t{{ ${1:name} }}\\n'\n    exports.scope = 'jinja2'\n  })\n\n  aceModule.define?.(\n    'ace/mode/jinja2_json_highlight_rules',\n    ['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],\n    (require: any, exports: any) => {\n      const oop = require('../lib/oop')\n      const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules\n\n      const Jinja2JsonHighlightRules = function (this: any) {\n        const tags =\n          'autoescape|block|call|do|elif|else|endautoescape|endblock|endcall|endfilter|endfor|endif|endmacro|endraw|endset|endtrans|endwith|extends|filter|for|from|if|import|include|macro|raw|set|trans|with'\n        const filters =\n          'abs|attr|batch|capitalize|center|count|d|default|dictsort|e|escape|filesizeformat|first|float|forceescape|format|groupby|indent|int|items|join|last|length|list|lower|map|max|min|pprint|random|reject|rejectattr|replace|reverse|round|safe|select|selectattr|slice|sort|string|striptags|sum|title|tojson|trim|truncate|unique|upper|urlencode|urlize|wordcount|wordwrap|xmlattr'\n        const functions = 'cycler|dict|joiner|lipsum|namespace|range'\n        const tests =\n          'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper'\n        const operators = 'and|in|is|not|or'\n        const contextVariables =\n          'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'\n\n        const keywordMapper = this.createKeywordMapper(\n          {\n            'keyword.control.jinja2': tags,\n            'keyword.operator.jinja2': operators,\n            'support.function.jinja2': [filters, functions, tests].join('|'),\n            'constant.language.jinja2': 'false|False|none|None|null|true|True',\n          },\n          'identifier',\n        )\n\n        const jinjaRules = [\n          {\n            token: 'string',\n            regex: \"'\",\n            push: 'jinja2-json-qstring',\n          },\n          {\n            token: 'constant.language.escape',\n            regex: /\\\\(?:x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|[\"\\\\\\/bfnrt])/,\n          },\n          {\n            token: 'constant.numeric',\n            regex: /[+-]?(?:0[xX][0-9a-fA-F]+|\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][+-]?\\d+)?\\b/,\n          },\n          {\n            token: ['keyword.operator.other.jinja2', 'text', 'support.function.jinja2'],\n            regex: `(\\\\|)(\\\\s*)(${filters})\\\\b`,\n          },\n          {\n            token: ['keyword.operator.jinja2', 'text', 'support.function.jinja2'],\n            regex: `(\\\\bis\\\\b)(\\\\s*)(${tests})\\\\b`,\n          },\n          {\n            token: ['support.function.jinja2', 'text', 'paren.lparen'],\n            regex: `\\\\b(${functions})(\\\\s*)(\\\\()`,\n          },\n          {\n            token: 'variable.language.jinja2',\n            regex: `\\\\b(?:${contextVariables})\\\\b`,\n          },\n          {\n            token: keywordMapper,\n            regex: /[a-zA-Z_$][a-zA-Z0-9_$]*\\b/,\n          },\n          {\n            token: 'keyword.operator.assignment.jinja2',\n            regex: /=|~/,\n          },\n          {\n            token: 'keyword.operator.comparison.jinja2',\n            regex: /==|!=|<=|>=|<|>/,\n          },\n          {\n            token: 'keyword.operator.arithmetic.jinja2',\n            regex: /\\+|-|\\/\\/|\\/|%|\\*\\*|\\*/,\n          },\n          {\n            token: 'keyword.operator.other.jinja2',\n            regex: /\\.{2}|\\||:/,\n          },\n          {\n            token: 'punctuation.operator.jinja2',\n            regex: /[.,;?]/,\n          },\n          {\n            token: 'paren.lparen',\n            regex: /[\\[({]/,\n          },\n          {\n            token: 'paren.rparen',\n            regex: /[\\])}]/,\n          },\n          {\n            token: 'text',\n            regex: /\\s+/,\n          },\n        ]\n\n        this.$rules = {\n          start: [\n            {\n              token: 'variable',\n              regex: /\"(?:(?:\\\\.)|(?:[^\"\\\\]))*?\"\\s*(?=:)/,\n            },\n            {\n              token: 'string',\n              regex: '\"',\n              push: 'json-string',\n            },\n            {\n              token: 'constant.numeric',\n              regex: /0[xX][0-9a-fA-F]+\\b/,\n            },\n            {\n              token: 'constant.numeric',\n              regex: /[+-]?\\d+(?:(?:\\.\\d*)?(?:[eE][+-]?\\d+)?)?\\b/,\n            },\n            {\n              token: 'constant.language.boolean',\n              regex: /(?:true|false|null)\\b/,\n            },\n            {\n              token: 'text',\n              regex: /['](?:(?:\\\\.)|(?:[^'\\\\]))*?[']/,\n            },\n            {\n              token: 'comment',\n              regex: /\\/\\/.*$/,\n            },\n            {\n              token: 'comment.start',\n              regex: /\\/\\*/,\n              push: 'comment',\n            },\n            {\n              token: 'paren.lparen',\n              regex: /[[({]/,\n            },\n            {\n              token: 'paren.rparen',\n              regex: /[\\])}]/,\n            },\n            {\n              token: 'punctuation.operator',\n              regex: /[:,]/,\n            },\n            {\n              token: 'text',\n              regex: /\\s+/,\n            },\n          ],\n          'json-string': [\n            {\n              token: 'constant.language.escape',\n              regex: /\\\\(?:x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|[\"\\\\\\/bfnrt])/,\n            },\n            {\n              token: 'comment.block.jinja2',\n              regex: /\\{#-?/,\n              push: 'jinja2-json-comment',\n            },\n            {\n              token: 'constant.language.jinja2',\n              regex: /\\{\\{-?/,\n              push: 'jinja2-json-expression',\n            },\n            {\n              token: 'keyword.control.jinja2',\n              regex: /\\{%-?/,\n              push: 'jinja2-json-statement',\n            },\n            {\n              token: 'string',\n              regex: /\"|$/,\n              next: 'pop',\n            },\n            {\n              defaultToken: 'string',\n            },\n          ],\n          comment: [\n            {\n              token: 'comment.end',\n              regex: /\\*\\//,\n              next: 'pop',\n            },\n            {\n              defaultToken: 'comment',\n            },\n          ],\n          'jinja2-json-comment': [\n            {\n              token: 'comment.block.jinja2',\n              regex: /-?#\\}/,\n              next: 'pop',\n            },\n            {\n              defaultToken: 'comment.block.jinja2',\n            },\n          ],\n          'jinja2-json-expression': [\n            {\n              token: 'constant.language.jinja2',\n              regex: /-?\\}\\}/,\n              next: 'pop',\n            },\n            ...jinjaRules,\n          ],\n          'jinja2-json-statement': [\n            {\n              token: 'keyword.control.jinja2',\n              regex: /-?%\\}/,\n              next: 'pop',\n            },\n            ...jinjaRules,\n          ],\n          'jinja2-json-qstring': [\n            {\n              token: 'constant.language.escape',\n              regex: /\\\\[\\\\'ntr]/,\n            },\n            {\n              token: 'string',\n              regex: \"'\",\n              next: 'pop',\n            },\n            {\n              defaultToken: 'string',\n            },\n          ],\n        }\n\n        this.normalizeRules()\n      }\n\n      oop.inherits(Jinja2JsonHighlightRules, TextHighlightRules)\n      exports.Jinja2JsonHighlightRules = Jinja2JsonHighlightRules\n    },\n  )\n\n  aceModule.define?.(\n    'ace/mode/jinja2_json',\n    ['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/jinja2_json_highlight_rules'],\n    (require: any, exports: any) => {\n      const oop = require('../lib/oop')\n      const TextMode = require('./text').Mode\n      const Jinja2JsonHighlightRules = require('./jinja2_json_highlight_rules').Jinja2JsonHighlightRules\n\n      const Mode = function (this: any) {\n        TextMode.call(this)\n        this.HighlightRules = Jinja2JsonHighlightRules\n      }\n\n      oop.inherits(Mode, TextMode)\n\n      ;(function (this: any) {\n        this.lineCommentStart = '//'\n        this.blockComment = { start: '/*', end: '*/' }\n        this.$id = 'ace/mode/jinja2_json'\n      }).call(Mode.prototype)\n\n      exports.Mode = Mode\n    },\n  )\n}\n\nace.config.setModuleUrl('ace/mode/json', modeJsonUrl)\nace.config.setModuleUrl('ace/mode/javascript', modeJavascriptUrl)\nace.config.setModuleUrl('ace/mode/html', modeHtmlUrl)\nace.config.setModuleUrl('ace/mode/yaml', modeYamlUrl)\nace.config.setModuleUrl('ace/mode/css', modeCssUrl)\nace.config.setModuleUrl('ace/mode/ini', modeIniUrl)\nace.config.setModuleUrl('ace/theme/github', themeGithubUrl)\nace.config.setModuleUrl('ace/theme/chrome', themeChromeUrl)\nace.config.setModuleUrl('ace/theme/monokai', themeMonokaiUrl)\nace.config.setModuleUrl('ace/mode/base', workerBaseUrl)\nace.config.setModuleUrl('ace/mode/json_worker', workerJsonUrl)\nace.config.setModuleUrl('ace/mode/javascript_worker', workerJavascriptUrl)\nace.config.setModuleUrl('ace/mode/html_worker', workerHtmlUrl)\nace.config.setModuleUrl('ace/mode/yaml_worker', workerYamlUrl)\nace.config.setModuleUrl('ace/mode/css_worker', workerCssUrl)\nace.config.setModuleUrl('ace/snippets/html', snippetsHtmlUrl)\nace.config.setModuleUrl('ace/snippets/javascript', snippetsJsUrl)\nace.config.setModuleUrl('ace/snippets/yaml', snippetsYamlUrl)\nace.config.setModuleUrl('ace/snippets/json', snippetsJsonUrl)\nace.config.setModuleUrl('ace/snippets/css', snippertsCssUrl)\nace.config.setModuleUrl('ace/snippets/ini', snippertsIniUrl)\n\nregisterJinja2Mode()\nace.require('ace/ext/language_tools')\n"
  },
  {
    "path": "src/api/constants.ts",
    "content": "import i18n from '@/plugins/i18n'\n\nexport const storageAttributes = [\n  {\n    type: 'local',\n    icon: 'mdi-folder-multiple-outline',\n    remote: false,\n  },\n  {\n    type: 'alipan',\n    icon: 'mdi-cloud-outline',\n    remote: true,\n  },\n  {\n    type: 'u115',\n    icon: 'mdi-cloud-outline',\n    remote: true,\n  },\n  {\n    type: 'rclone',\n    icon: 'mdi-server-network-outline',\n    remote: true,\n  },\n  {\n    type: 'alist',\n    icon: 'mdi-server-network-outline',\n    remote: true,\n  },\n  {\n    type: 'smb',\n    icon: 'mdi-folder-network-outline',\n    remote: true,\n  },\n]\n\nexport const storageIconDict = storageAttributes.reduce((dict, item) => {\n  dict[item.type] = item.icon\n  return dict\n}, {} as Record<string, string>)\n\nexport const storageRemoteDict = storageAttributes.reduce((dict, item) => {\n  dict[item.type] = item.remote\n  return dict\n}, {} as Record<string, boolean>)\n\nexport const downloaderOptions = [\n  {\n    value: 'qbittorrent',\n    title: i18n.global.t('setting.system.qbittorrent'),\n  },\n  {\n    value: 'transmission',\n    title: i18n.global.t('setting.system.transmission'),\n  },\n  {\n    value: 'rtorrent',\n    title: i18n.global.t('setting.system.rtorrent'),\n  },\n]\n\nexport const downloaderDict = downloaderOptions.reduce((dict, item) => {\n  dict[item.value] = item.title\n  return dict\n}, {} as Record<string, string>)\n\nexport const mediaServerOptions = [\n  {\n    value: 'emby',\n    title: i18n.global.t('setting.system.emby'),\n  },\n  {\n    value: 'jellyfin',\n    title: i18n.global.t('setting.system.jellyfin'),\n  },\n  {\n    value: 'plex',\n    title: i18n.global.t('setting.system.plex'),\n  },\n  {\n    value: 'trimemedia',\n    title: i18n.global.t('setting.system.trimeMedia'),\n  },\n  {\n    value: 'ugreen',\n    title: i18n.global.t('setting.system.ugreen'),\n  },\n]\n\nexport const mediaServerDict = mediaServerOptions.reduce((dict, item) => {\n  dict[item.value] = item.title\n  return dict\n}, {} as Record<string, string>)\n\nexport const innerFilterRules = [\n  { title: i18n.global.t('filterRules.specSub'), value: ' SPECSUB ' },\n  { title: i18n.global.t('filterRules.cnSub'), value: ' CNSUB ' },\n  { title: i18n.global.t('filterRules.cnVoi'), value: ' CNVOI ' },\n  { title: i18n.global.t('filterRules.gz'), value: ' GZ ' },\n  { title: i18n.global.t('filterRules.notCnVoi'), value: ' !CNVOI ' },\n  { title: i18n.global.t('filterRules.hkVoi'), value: ' HKVOI ' },\n  { title: i18n.global.t('filterRules.notHkVoi'), value: ' !HKVOI ' },\n  { title: i18n.global.t('filterRules.free'), value: ' FREE ' },\n  { title: i18n.global.t('filterRules.resolution4k'), value: ' 4K ' },\n  { title: i18n.global.t('filterRules.resolution1080p'), value: ' 1080P ' },\n  { title: i18n.global.t('filterRules.resolution720p'), value: ' 720P ' },\n  { title: i18n.global.t('filterRules.not720p'), value: ' !720P ' },\n  { title: i18n.global.t('filterRules.qualityBlu'), value: ' BLU ' },\n  { title: i18n.global.t('filterRules.notBlu'), value: ' !BLU ' },\n  { title: i18n.global.t('filterRules.qualityBluray'), value: ' BLURAY ' },\n  { title: i18n.global.t('filterRules.notBluray'), value: ' !BLURAY ' },\n  { title: i18n.global.t('filterRules.qualityUhd'), value: ' UHD ' },\n  { title: i18n.global.t('filterRules.notUhd'), value: ' !UHD ' },\n  { title: i18n.global.t('filterRules.qualityRemux'), value: ' REMUX ' },\n  { title: i18n.global.t('filterRules.notRemux'), value: ' !REMUX ' },\n  { title: i18n.global.t('filterRules.qualityWebdl'), value: ' WEBDL ' },\n  { title: i18n.global.t('filterRules.notWebdl'), value: ' !WEBDL ' },\n  { title: i18n.global.t('filterRules.quality60fps'), value: ' 60FPS ' },\n  { title: i18n.global.t('filterRules.not60fps'), value: ' !60FPS ' },\n  { title: i18n.global.t('filterRules.codecH265'), value: ' H265 ' },\n  { title: i18n.global.t('filterRules.notH265'), value: ' !H265 ' },\n  { title: i18n.global.t('filterRules.codecH264'), value: ' H264 ' },\n  { title: i18n.global.t('filterRules.notH264'), value: ' !H264 ' },\n  { title: i18n.global.t('filterRules.effectDolby'), value: ' DOLBY ' },\n  { title: i18n.global.t('filterRules.notDolby'), value: ' !DOLBY ' },\n  { title: i18n.global.t('filterRules.effectAtmos'), value: ' ATMOS ' },\n  { title: i18n.global.t('filterRules.notAtmos'), value: ' !ATMOS ' },\n  { title: i18n.global.t('filterRules.effectHdr'), value: ' HDR ' },\n  { title: i18n.global.t('filterRules.notHdr'), value: ' !HDR ' },\n  { title: i18n.global.t('filterRules.effectSdr'), value: ' SDR ' },\n  { title: i18n.global.t('filterRules.notSdr'), value: ' !SDR ' },\n  { title: i18n.global.t('filterRules.effect3d'), value: ' 3D ' },\n  { title: i18n.global.t('filterRules.not3d'), value: ' !3D ' },\n]\n\nexport const transferTypeOptions = [\n  { title: i18n.global.t('transferType.copy'), value: 'copy' },\n  { title: i18n.global.t('transferType.move'), value: 'move' },\n  { title: i18n.global.t('transferType.link'), value: 'link' },\n  { title: i18n.global.t('transferType.softlink'), value: 'softlink' },\n]\n\nexport const qualityOptions = ref([\n  {\n    title: i18n.global.t('qualityOptions.all'),\n    value: '',\n  },\n  {\n    title: i18n.global.t('qualityOptions.blurayOriginal'),\n    value: 'Blu-?Ray.+VC-?1|Blu-?Ray.+AVC|UHD.+blu-?ray.+HEVC|MiniBD',\n  },\n  {\n    title: i18n.global.t('qualityOptions.remux'),\n    value: 'Remux',\n  },\n  {\n    title: i18n.global.t('qualityOptions.bluray'),\n    value: 'Blu-?Ray',\n  },\n  {\n    title: i18n.global.t('qualityOptions.uhd'),\n    value: 'UHD|UltraHD',\n  },\n  {\n    title: i18n.global.t('qualityOptions.webdl'),\n    value: 'WEB-?DL|WEB-?RIP',\n  },\n  {\n    title: i18n.global.t('qualityOptions.hdtv'),\n    value: 'HDTV',\n  },\n  {\n    title: i18n.global.t('qualityOptions.h265'),\n    value: '[Hx].?265|HEVC',\n  },\n  {\n    title: i18n.global.t('qualityOptions.h264'),\n    value: '[Hx].?264|AVC',\n  },\n])\n\n// 分辨率选择框数据\nexport const resolutionOptions = ref([\n  {\n    title: i18n.global.t('resolutionOptions.all'),\n    value: '',\n  },\n  {\n    title: i18n.global.t('resolutionOptions.4k'),\n    value: '4K|2160p|x2160',\n  },\n  {\n    title: i18n.global.t('resolutionOptions.1080p'),\n    value: '1080[pi]|x1080',\n  },\n  {\n    title: i18n.global.t('resolutionOptions.720p'),\n    value: '720[pi]|x720',\n  },\n])\n\n// 特效选择框数据\nexport const effectOptions = ref([\n  {\n    title: i18n.global.t('effectOptions.all'),\n    value: '',\n  },\n  {\n    title: i18n.global.t('effectOptions.dolbyVision'),\n    value: 'Dolby[\\\\s.]+Vision|DOVI|[\\\\s.]+DV[\\\\s.]+',\n  },\n  {\n    title: i18n.global.t('effectOptions.dolbyAtmos'),\n    value: 'Dolby[\\\\s.]*\\\\+?Atmos|Atmos',\n  },\n  {\n    title: i18n.global.t('effectOptions.hdr'),\n    value: '[\\\\s.]+HDR[\\\\s.]+|HDR10|HDR10\\\\+',\n  },\n  {\n    title: i18n.global.t('effectOptions.sdr'),\n    value: '[\\\\s.]+SDR[\\\\s.]+',\n  },\n])\n\n// 媒体类型选项\nexport const mediaTypeOptions = [\n  {\n    title: i18n.global.t('mediaType.movie'),\n    value: '电影',\n  },\n  {\n    title: i18n.global.t('mediaType.tv'),\n    value: '电视剧',\n  },\n  {\n    title: i18n.global.t('mediaType.anime'),\n    value: '动漫',\n  },\n  {\n    title: i18n.global.t('mediaType.collection'),\n    value: '合集',\n  },\n  {\n    title: i18n.global.t('mediaType.unknown'),\n    value: '未知',\n  },\n]\n\n// 媒体类型字典\nexport const mediaTypeDict = mediaTypeOptions.reduce((dict, item) => {\n  dict[item.value] = item.title\n  return dict\n}, {} as Record<string, string>)\n\n// 通知开关选项\nexport const notificationSwitchOptions = [\n  {\n    title: i18n.global.t('notificationSwitch.resourceDownload'),\n    value: '资源下载',\n  },\n  {\n    title: i18n.global.t('notificationSwitch.organize'),\n    value: '整理入库',\n  },\n  {\n    title: i18n.global.t('notificationSwitch.subscribe'),\n    value: '订阅',\n  },\n  {\n    title: i18n.global.t('notificationSwitch.site'),\n    value: '站点',\n  },\n  {\n    title: i18n.global.t('notificationSwitch.mediaServer'),\n    value: '媒体服务器',\n  },\n  {\n    title: i18n.global.t('notificationSwitch.manual'),\n    value: '手动处理',\n  },\n  {\n    title: i18n.global.t('notificationSwitch.plugin'),\n    value: '插件',\n  },\n  {\n    title: i18n.global.t('notificationSwitch.agent'),\n    value: '智能体',\n  },\n  {\n    title: i18n.global.t('notificationSwitch.other'),\n    value: '其它',\n  },\n]\n\n// 通知开关字典\nexport const notificationSwitchDict = notificationSwitchOptions.reduce((dict, item) => {\n  dict[item.value] = item.title\n  return dict\n}, {} as Record<string, string>)\n\n// 操作步骤选项\nexport const actionStepOptions = [\n  {\n    title: i18n.global.t('actionStep.addDownload'),\n    value: '添加下载',\n  },\n  {\n    title: i18n.global.t('actionStep.addSubscribe'),\n    value: '添加订阅',\n  },\n  {\n    title: i18n.global.t('actionStep.fetchDownloads'),\n    value: '获取下载任务',\n  },\n  {\n    title: i18n.global.t('actionStep.fetchMedias'),\n    value: '获取媒体数据',\n  },\n  {\n    title: i18n.global.t('actionStep.fetchRss'),\n    value: '获取RSS资源',\n  },\n  {\n    title: i18n.global.t('actionStep.fetchTorrents'),\n    value: '搜索站点资源',\n  },\n  {\n    title: i18n.global.t('actionStep.filterMedias'),\n    value: '过滤媒体数据',\n  },\n  {\n    title: i18n.global.t('actionStep.filterTorrents'),\n    value: '过滤资源',\n  },\n  {\n    title: i18n.global.t('actionStep.scanFile'),\n    value: '扫描目录',\n  },\n  {\n    title: i18n.global.t('actionStep.scrapeFile'),\n    value: '刮削文件',\n  },\n  {\n    title: i18n.global.t('actionStep.sendEvent'),\n    value: '发送事件',\n  },\n  {\n    title: i18n.global.t('actionStep.sendMessage'),\n    value: '发送消息',\n  },\n  {\n    title: i18n.global.t('actionStep.transferFile'),\n    value: '整理文件',\n  },\n  {\n    title: i18n.global.t('actionStep.invokePlugin'),\n    value: '调用插件',\n  },\n  {\n    title: i18n.global.t('actionStep.note'),\n    value: '备注',\n  },\n]\n\n// 操作步骤字典\nexport const actionStepDict = actionStepOptions.reduce((dict, item) => {\n  dict[item.value] = item.title\n  return dict\n}, {} as Record<string, string>)\n"
  },
  {
    "path": "src/api/index.ts",
    "content": "import axios from 'axios'\nimport router from '@/router'\nimport { useAuthStore } from '@/stores'\nimport { initializeRequestOptimizer } from '@/utils/requestOptimizer'\nimport { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'\n\n// 创建axios实例\nconst api = axios.create({\n  baseURL: import.meta.env.VITE_API_BASE_URL,\n})\n\n// 声明全局变量类型\ndeclare global {\n  interface Window {\n    MoviePilotAPI: typeof api\n  }\n}\n\n// 将 API 实例暴露到全局，供插件使用\nwindow.MoviePilotAPI = api\n\n// 初始化请求优化器（必须在其他拦截器之前）\ninitializeRequestOptimizer(api)\n\n// 添加请求拦截器\napi.interceptors.request.use(config => {\n  // 认证 Store\n  const authStore = useAuthStore()\n  // 在请求头中添加token\n  if (authStore.token) {\n    config.headers.Authorization = `Bearer ${authStore.token}`\n  }\n  return config\n})\n\n// 离线状态管理\nconst globalOfflineStatus = useGlobalOfflineStatus()\n\n// 添加响应拦截器\napi.interceptors.response.use(\n  response => {\n    // 成功响应时，清除应用离线状态并重置连续错误计数\n    globalOfflineStatus.setAppOffline(false)\n    globalOfflineStatus.resetConsecutiveErrors()\n    return response.data\n  },\n  error => {\n    if (!error.response) {\n      // 网络错误或请求超时 - 通知离线状态管理系统\n      const isNetworkError =\n        error.code === 'NETWORK_ERROR' ||\n        error.code === 'ERR_NETWORK' ||\n        error.code === 'ECONNABORTED' ||\n        error.name === 'NetworkError'\n\n      if (isNetworkError) {\n        let reason = 'Network connection failed'\n        if (error.code === 'ECONNABORTED') {\n          reason = 'Request timeout'\n        }\n        // 记录网络错误，只有连续三次才会设置为离线模式\n        globalOfflineStatus.recordNetworkError(reason)\n      }\n\n      if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK') {\n        // 网络连接问题\n        return Promise.reject(new Error('Network connection failed, please check your network status'))\n      } else if (error.code === 'ECONNABORTED') {\n        // 请求超时\n        return Promise.reject(new Error('Request timeout, please try again later'))\n      } else if (error.name === 'AbortError') {\n        // 请求被中止（路由切换等）\n        return Promise.reject(new Error('Request cancelled'))\n      }\n      // 其他网络错误\n      return Promise.reject(new Error(error.message || 'Network error'))\n    } else if (error.response.status === 403) {\n      // 认证 Store\n      const authStore = useAuthStore()\n      // 清除登录状态信息\n      authStore.logout()\n      // token验证失败，跳转到登录页面\n      router.push('/login')\n    }\n\n    return Promise.reject(error)\n  },\n)\n\nexport default api\n"
  },
  {
    "path": "src/api/nprogress.ts",
    "content": "import NProgress from 'nprogress'\nimport 'nprogress/nprogress.css'\n\nexport function configureNProgress() {\n  NProgress.configure({\n    showSpinner: false,\n  })\n}\n\nexport function startNProgress() {\n  NProgress.start()\n}\n\nexport function doneNProgress() {\n  NProgress.done()\n}\n"
  },
  {
    "path": "src/api/types.ts",
    "content": "// 订阅\nexport interface Subscribe {\n  // 订阅ID\n  id: number\n  // 订阅名称\n  name: string\n  // 订阅年份\n  year: string\n  // 订阅类型 电影/电视剧\n  type: string\n  // 搜索关键字\n  keyword?: string\n  // TMDB ID\n  tmdbid: number\n  // 豆瓣ID\n  doubanid?: string\n  // Bangumi ID\n  bangumiid?: string\n  // 其它媒体ID\n  mediaid?: string\n  // 季号\n  season?: number\n  // 海报\n  poster?: string\n  // 背景图\n  backdrop?: string\n  // 评分\n  vote?: number\n  // 描述\n  description?: string\n  // 过滤规则\n  filter?: string\n  // 包含\n  include?: string\n  // 排除\n  exclude?: string\n  // 质量\n  quality?: string\n  // 分辨率\n  resolution?: string\n  // 特效\n  effect?: string\n  // 总集数\n  total_episode?: number\n  // 开始集数\n  start_episode?: number\n  // 缺失集数\n  lack_episode?: number\n  // 附加信息\n  note?: string\n  // 状态：N-新建 R-订阅中 P-待定 S-暂停\n  state: string\n  // 最后更新时间\n  last_update: string\n  // 订阅用户\n  username: string\n  // 订阅站点\n  sites: number[]\n  // 是否洗版，数字或者boolean\n  best_version: any\n  // 使用 imdbid 搜索\n  search_imdbid?: any\n  // 当前优先级\n  current_priority: number\n  // 保存目录\n  save_path?: string\n  // 时间\n  date: string\n  // 编辑框设置项\n  show_edit_dialog: boolean\n  // 编辑框打开状态\n  page_open?: boolean\n  // 自定义识别词\n  custom_words?: string\n  // 自定义媒体类别\n  media_category?: string\n  // 过滤规则组\n  filter_groups?: string[]\n  // 下载器\n  downloader?: string\n  // 自定义剧集组\n  episode_group?: string\n}\n\n// 订阅分享\nexport interface SubscribeShare {\n  // 分享ID\n  id?: number\n  // 订阅ID\n  subscribe_id?: number\n  // 分享标题\n  share_title?: string\n  // 分享说明\n  share_comment?: string\n  // 分享人\n  share_user?: string\n  // 分享人唯一ID\n  share_uid?: string\n  // 订阅名称\n  name?: string\n  // 订阅年份\n  year?: string\n  // 订阅类型 电影/电视剧\n  type?: string\n  // 搜索关键字\n  keyword?: string\n  // TMDB ID\n  tmdbid?: number\n  // 豆瓣ID\n  doubanid?: string\n  // 季号\n  season?: number\n  // 海报\n  poster?: string\n  // 背景图\n  backdrop?: string\n  // 评分\n  vote?: number\n  // 描述\n  description?: string\n  // 过滤规则\n  filter?: string\n  // 包含\n  include?: string\n  // 排除\n  exclude?: string\n  // 质量\n  quality?: string\n  // 分辨率\n  resolution?: string\n  // 特效\n  effect?: string\n  // 总集数\n  total_episode?: number\n  // 时间\n  date?: string\n  // 自定义识别词\n  custom_words?: string\n  // 自定义媒体类别\n  media_category?: string\n  // 复用次数\n  count?: number\n  // 自定义剧集组\n  episode_group?: string\n}\n\n// 工作流分享\nexport interface WorkflowShare {\n  // 分享ID\n  id?: string\n  // 工作流ID\n  workflow_id?: string\n  // 分享标题\n  share_title?: string\n  // 分享说明\n  share_comment?: string\n  // 分享人\n  share_user?: string\n  // 分享人唯一ID\n  share_uid?: string\n  // 工作流名称\n  name?: string\n  // 工作流描述\n  description?: string\n  // 定时器\n  timer?: string\n  // 触发类型：timer-定时触发 event-事件触发 manual-手动触发\n  trigger_type?: string\n  // 事件类型（当trigger_type为event时使用）\n  event_type?: string\n  // 动作列表\n  actions?: any[]\n  // 动作流\n  flows?: any[]\n  // 上下文\n  context?: string\n  // 时间\n  date?: string\n  // 复用次数\n  count?: number\n}\n\n// 历史记录\nexport interface TransferHistory {\n  // ID\n  id: number\n  // 源存储\n  src_storage?: string\n  // 目标存储\n  dest_storage?: string\n  // 源目录\n  src?: string\n  // 目的目录\n  dest?: string\n  // 转移模式link/copy/move/softlink/rclone_copy/rclone_move\n  mode?: string\n  // 类型：电影、电视剧\n  type?: string\n  // 二级分类\n  category?: string\n  // 标题\n  title?: string\n  // 年份\n  year?: string\n  // TMDBID\n  tmdbid?: number\n  // IMDBID\n  imdbid?: string\n  // TVDBID\n  tvdbid?: number\n  // 豆瓣ID\n  doubanid?: string\n  // 季Sxx\n  seasons?: string\n  // 集Exx\n  episodes?: string\n  // 海报\n  image?: string\n  // 下载器Hash\n  download_hash?: string\n  // 状态 1-成功，0-失败\n  status: boolean\n  // 失败原因\n  errmsg?: string\n  // 日期\n  date?: string\n  // 源文件项\n  src_fileitem?: FileItem\n}\n\n// 媒体信息\nexport interface MediaInfo {\n  // 来源：themoviedb、douban、bangumi\n  source?: string\n  // 类型 电影、电视剧、合集\n  type?: string\n  // 媒体标题\n  title?: string\n  // 年份\n  year?: string\n  // 标题（年）\n  title_year?: string\n  // 季号\n  season?: number\n  // TMDB ID\n  tmdb_id?: number\n  // IMDB ID\n  imdb_id?: string\n  // TVDB ID\n  tvdb_id?: string\n  // 豆瓣ID\n  douban_id?: string\n  // Bangumi ID\n  bangumi_id?: string\n  // 合集ID\n  collection_id?: number\n  // 其它媒体ID前缀\n  mediaid_prefix?: string\n  // 其它媒体ID值\n  media_id?: string\n  // 媒体原语种\n  original_language?: string\n  // 媒体原发行标题\n  original_title?: string\n  // 媒体发行日期\n  release_date?: string\n  // 背景图片\n  backdrop_path?: string\n  // 海报图片\n  poster_path?: string\n  // 评分\n  vote_average?: number\n  // 描述\n  overview?: string\n  // 二级分类\n  category?: string\n  // 详情页面\n  detail_link?: string\n  // 季详情\n  season_info?: TmdbSeason[]\n  // 导演\n  directors?: any[]\n  // 演员\n  actors?: any[]\n  // 成人内容\n  adult?: boolean\n  // 创建人\n  created_by?: string[]\n  // 集时长\n  episode_run_time: string[]\n  // 风格\n  genres?: string[]\n  // 首映日期\n  first_air_date?: string\n  // 主页\n  homepage?: string\n  // 语言\n  languages?: string[]\n  // 最后更新日期\n  last_air_date?: string\n  // 流媒体\n  networks?: string[]\n  // 总集数\n  number_of_episodes?: number\n  // 总季数\n  number_of_seasons?: number\n  // 原产国\n  origin_country: string[]\n  // 原名\n  original_name?: string\n  // 出品公司\n  production_companies?: any[]\n  // 出品国\n  production_countries?: any[]\n  // 语种\n  spoken_languages?: string[]\n  // 数字/实体发行日期\n  release_dates?: MediaRelease[]\n  // 状态\n  status?: string\n  // 标签\n  tagline?: string\n  // 评分人数\n  vote_count?: number\n  // 流行度\n  popularity?: number\n  // 时长\n  runtime?: number\n  // 下一集\n  next_episode_to_air?: object\n  // 别名\n  names?: string[]\n  // 剧集组\n  episode_group?: string\n}\n\n// 季信息\nexport interface MediaSeason {\n  // 上映日期\n  air_date?: string\n  // 总集数\n  episode_count?: number\n  // 季名称\n  name?: string\n  // 描述\n  overview?: string\n  // 海报\n  poster_path?: string\n  // 季号\n  season_number?: number\n  // 评分\n  vote_average?: number\n}\n\n// TMDB季信息\nexport interface TmdbSeason {\n  // 上映日期\n  air_date?: string\n  // 总集数\n  episode_count?: number\n  // 季名称\n  name?: string\n  // 描述\n  overview?: string\n  // 海报\n  poster_path?: string\n  // 季号\n  season_number?: number\n  // 评分\n  vote_average?: number\n}\n\n// 发行信息\nexport interface MediaRelease {\n  // 发行日期\n  date: string\n  // 发行地区\n  iso_code: string\n  // 备注\n  note?: string\n  // 发行类型\n  type: number\n}\n\n// TMDB集信息\nexport interface TmdbEpisode {\n  // 上映日期\n  air_date?: string\n  // 集号\n  episode_number?: number\n  // 剧集名称\n  name?: string\n  // 描述\n  overview?: string\n  // 时长\n  runtime?: number\n  // 季号\n  season_number?: number\n  // 海报\n  still_path?: string\n  // 评分\n  vote_average?: number\n  // 演职人员\n  crew: Object[]\n  // 嘉宾\n  guest_stars: Object[]\n}\n\n// TMDB人物信息\nexport interface Person {\n  // 来源：themoviedb、douban、bangumi\n  source?: string\n  // ID\n  id?: number\n  // 名称\n  name?: string\n  // 角色\n  character?: string\n  // 性别\n  gender?: number | string\n  // 生日\n  birthday?: string\n  // 详情\n  biography?: string\n  // images large/normal douban、bangumi\n  images?: { [key: string]: string }\n  // themoviedb\n  // themoviedb图片\n  profile_path?: string\n  // 原名\n  original_name?: string\n  // 演员ID\n  credit_id?: string\n  // 别名\n  also_known_as?: string[]\n  // 卒日\n  deathday?: string\n  // IMDB ID\n  imdb_id?: string\n  // 部门\n  known_for_department?: string\n  // 出生地\n  place_of_birth?: string\n  // 热度\n  popularity?: number\n  // douban\n  // 角色\n  roles?: string[]\n  // 简介\n  title?: string\n  // 详情页面\n  url?: string\n  // 图片\n  avatar?: any\n  // 别名\n  latin_name?: string\n  // bangumi\n  // 类型\n  type?: number | string\n  // 角色\n  career?: string[]\n  // 关系\n  relation?: string\n}\n\n// 站点\nexport interface Site {\n  // ID\n  id: number\n  // 站点名称\n  name: string\n  // 站点主域名Key\n  domain: string\n  // 站点地址\n  url: string\n  // 站点优先级\n  pri?: number\n  // RSS地址\n  rss?: string\n  // 下载器\n  downloader: string\n  // Cookie\n  cookie?: string\n  // ApiKey\n  apikey?: string\n  // Token\n  token?: string\n  // User-Agent\n  ua?: string\n  // 是否使用代理\n  proxy?: any\n  // 过滤规则\n  filter?: string\n  // 是否演染\n  render?: any\n  // 是否公开站点\n  public?: number\n  // 备注\n  note?: string\n  // 超时时间\n  timeout?: number\n  // 流控单位周期\n  limit_interval?: number\n  // 流控次数\n  limit_count?: number\n  // 流控间隔\n  limit_seconds?: number\n  // 是否启用\n  is_active: boolean\n}\n\n// 站点使用统计\nexport interface SiteStatistic {\n  // 站点主域名Key\n  domain?: string\n  // 成功次数\n  success?: number\n  // 失败次数\n  fail?: number\n  // 平均耗时\n  seconds?: number\n  // 最后一次访问状态 0-成功 1-失败\n  lst_state?: number\n  // 最后访问时间\n  lst_mod_date?: string\n  // 耗时记录 JSON\n  note?: string\n}\n\n// 站点用户数据\nexport interface SiteUserData {\n  // 站点域名\n  domain?: string\n  // 用户名\n  username?: string\n  // 用户ID\n  userid?: string\n  // 用户等级\n  user_level?: string\n  // 加入时间\n  join_at?: string\n  // 积分\n  bonus?: number // 默认为 0.0\n  // 上传量\n  upload?: number // 默认为 0\n  // 下载量\n  download?: number // 默认为 0\n  // 分享率\n  ratio?: number // 默认为 0\n  // 做种数\n  seeding?: number // 默认为 0\n  // 下载数\n  leeching?: number // 默认为 0\n  // 做种体积\n  seeding_size?: number // 默认为 0\n  // 下载体积\n  leeching_size?: number // 默认为 0\n  // 做种人数, 种子大小\n  seeding_info?: any[] // 默认为空数组\n  // 未读消息\n  message_unread?: number // 默认为 0\n  // 未读消息内容\n  message_unread_contents?: any[] // 默认为空数组\n  // 错误信息\n  err_msg?: string | null // 默认为 null\n  // 更新日期\n  updated_day?: string\n  // 更新时间\n  updated_time?: string\n}\n\n// 正在下载\nexport interface DownloadingInfo {\n  // HASH\n  hash?: string\n  // 种子名称\n  title?: string\n  // 识别后的名称\n  name?: string\n  // 年份\n  year?: string\n  // SXXEXX\n  season_episode?: string\n  // 大小\n  size?: number\n  // 下载进 度\n  progress?: number\n  // 状态\n  state?: string\n  // 下载速度\n  dlspeed?: string\n  // 上传速度\n  upspeed?: string\n  // 媒体信息\n  media: { [key: string]: any }\n  // 下载用户ID\n  userid?: string\n  // 下载用户名称\n  username?: string\n  // 剩余时间\n  left_time?: string\n}\n\n// 缺失剧集信息\nexport interface NotExistMediaInfo {\n  // 季\n  season: number\n  // 剧集列表\n  episodes: number[]\n  // 总集数\n  total_episode: number\n  // 开始集\n  start_episode: number\n}\n\n// 插件\nexport interface Plugin {\n  id: string\n  // 插件名称\n  plugin_name: string\n  // 插件描述\n  plugin_desc?: string\n  // 插件图标\n  plugin_icon?: string\n  // 插件标签，多个以,分隔\n  plugin_label?: string\n  // 插件版本\n  plugin_version?: string\n  // 插件作者\n  plugin_author?: string\n  // 作者主页\n  author_url?: string\n  // 插件配置项ID前缀\n  plugin_config_prefix?: string\n  // 加载顺序\n  plugin_order?: number\n  // 可使用的用户级别\n  auth_level?: number\n  // 是否已安装\n  installed?: boolean\n  // 运行状态\n  state?: boolean\n  // 是否有详情页面\n  has_page?: boolean\n  // 是否有新版本\n  has_update?: boolean\n  // 是否本地插件\n  is_local?: boolean\n  // 插件仓库地址\n  repo_url?: string\n  // 变更历史\n  history?: { [key: string]: string }\n  // 添加时间\n  add_time?: number\n  // 页面打开状态\n  page_open?: boolean\n}\n\n// 插件侧栏全页导航项（与后端 PluginSidebarNavItem 对齐）\nexport interface PluginSidebarNavItem {\n  plugin_id: string\n  nav_key: string\n  title: string\n  icon: string\n  section: 'start' | 'discovery' | 'subscribe' | 'organize' | 'system'\n  permission?: 'subscribe' | 'discovery' | 'search' | 'manage' | 'admin' | null\n  order: number\n}\n\n// 渲染结构\nexport interface RenderProps {\n  component: string\n  text?: string\n  html?: string\n  content?: any\n  slots?: any\n  props?: any\n  events?: any\n}\n\n// 仪表板组件\nexport interface DashboardItem {\n  // ID\n  id: string\n  // 名称\n  name: string\n  // 插件的仪表板key\n  key: string\n  // 全局配置\n  attrs: { [key: string]: any }\n  // col列数\n  cols: { [key: string]: number }\n  // 页面元素\n  elements: RenderProps[]\n  // 渲染方式\n  render_mode?: string\n}\n\n// 种子信息\nexport interface TorrentInfo {\n  // 站点ID\n  site?: number\n  // 站点名称\n  site_name?: string\n  // 站点Cookie\n  site_cookie?: string\n  // 站点UA\n  site_ua?: string\n  // 站点是否使用代理\n  site_proxy: boolean\n  // 站点优先级\n  site_order: number\n  // 站点下载器\n  site_downloader?: string\n  // 种子名称\n  title?: string\n  // 种子副标题\n  description?: string\n  // IMDB ID\n  imdbid: string\n  // 种子链接\n  enclosure?: string\n  // 详情页面\n  page_url?: string\n  // 种子大小\n  size: number\n  // 做种者\n  seeders: number\n  // 下载者\n  peers: number\n  // 完成者\n  grabs: number\n  // 发布时间\n  pubdate?: string\n  // 已过时间\n  date_elapsed?: string\n  // 上传因子\n  uploadvolumefactor: number\n  // 下载因子\n  downloadvolumefactor: number\n  // HR\n  hit_and_run: boolean\n  // 种子标签\n  labels: string[]\n  // 种子优先级\n  pri_order: number\n  // 促销描述\n  volume_factor: string\n  // 免费时间\n  freedate: string\n  // 剩余免费时间\n  freedate_diff: string\n  // 种子类型\n  category: string\n}\n\n// 识别元数据\nexport interface MetaInfo {\n  // 是否处理的文件\n  isfile: boolean\n  // 原字符串\n  org_string?: string\n  // 原标题（未经识别词转换）\n  title?: string\n  // 副标题\n  subtitle?: string\n  // 类型 电影、电视剧\n  type: string\n  // 识别的中文名\n  cn_name?: string\n  // 识别的英文名\n  en_name?: string\n  // 年份\n  year?: string\n  // 总季数\n  total_season: number\n  // 识别的开始季 数字\n  begin_season?: number\n  // 识别的结束季 数字\n  end_season?: number\n  // 总集数\n  total_episode: number\n  // 识别的开始集\n  begin_episode?: number\n  // 识别的结束集\n  end_episode?: number\n  // Partx Cd Dvd Disk Disc\n  part?: string\n  // 识别的资源类型\n  resource_type?: string\n  // 识别的效果\n  resource_effect?: string\n  // 识别的分辨率\n  resource_pix?: string\n  // 识别的制作组/字幕组\n  resource_team?: string\n  // 视频编码\n  video_encode?: string\n  // 音频编码\n  audio_encode?: string\n  // 名称（自动中英文）\n  name: string\n  // SXX-SXX\n  season: string\n  // SXX-SXX 有季号才返回\n  sea: string\n  // begin_season 的数字，电视剧没有季的返回1\n  season_seq: string\n  // 季的数组\n  season_list: number[]\n  // Exx-Exx\n  episode: string\n  // 集的数组\n  episode_list: number[]\n  // ExxExx\n  episodes: string\n  // xx-xx\n  episode_seqs: string\n  // begin_episode 的数字\n  episode_seq: string\n  // SxxExx\n  season_episode: string\n  // 资源类型字符串，含分辨率\n  resource_term: string\n  // 发布组/字幕组字符串\n  release_group: string\n  // 视频编码\n  video_term: string\n  // 音频编码\n  audio_term: string\n  // 资源类型+特效\n  edition: string\n  // 流媒体平台\n  web_source: string\n  // 应用的自定义识别词\n  apply_words: string[]\n}\n\n// 上下文信息\nexport interface Context {\n  // 元信息\n  meta_info: MetaInfo\n  // 媒体信息\n  media_info: MediaInfo\n  // 种子信息\n  torrent_info: TorrentInfo\n}\n\n// 用户信息\nexport interface User {\n  // 用户ID\n  id: number\n  // 用户名称\n  name: string\n  // 用户密码\n  password: string\n  // 用户邮箱\n  email: string\n  // 是否激活\n  is_active: boolean\n  // 是否管理员\n  is_superuser: boolean\n  // 头像\n  avatar: string\n  // 是否开启双重验证\n  is_otp: boolean\n  // 用户权限 json\n  permissions: { [key: string]: any }\n  // 用户个性化设置 json\n  settings: { [key: string]: string | null }\n  // 昵称\n  nickname?: string\n}\n\n// 通行密钥\nexport interface PassKey {\n  id: number\n  name: string\n  created_at: string\n  last_used_at?: string\n  aaguid?: string\n  transports?: string\n}\n\n// 存储空间\nexport interface Storage {\n  // 总空间\n  total_storage: number\n  // 已使用空间\n  used_storage: number\n}\n\n// 媒体统计\nexport interface MediaStatistic {\n  // 电影总数\n  movie_count: number\n  // 电视剧总数\n  tv_count: number\n  // 电视剧总集数，未获取时为 null\n  episode_count: number | null\n  // 用户数量\n  user_count: number\n}\n\n// 后台进程\nexport interface Process {\n  // 进程ID\n  pid: number\n  // 进程名称\n  name: string\n  // 进程状态\n  status: string\n  // 进程启动时间\n  create_time: number\n  // 进程运行时间\n  run_time: number\n  // 进程CPU占用率\n  cpu: number\n  // 进程内存占用\n  memory: number\n}\n\n// 下载器信息\nexport interface DownloaderInfo {\n  // 下载速度\n  download_speed: number\n  // 上传速度\n  upload_speed: number\n  // 下载量\n  download_size: number\n  // 上传量\n  upload_size: number\n  // 剩余空间\n  free_space: number\n}\n\n// 定时服务信息\nexport interface ScheduleInfo {\n  // ID\n  id: string\n  // 名称\n  name: string\n  // 提供者\n  provider: string\n  // 状态\n  status: string\n  // 下次运行时间\n  next_run: string\n}\n\n// 消息通知\nexport interface NotificationSwitch {\n  // 消息类型\n  mtype: string\n  // 开关\n  wechat: boolean\n  telegram: boolean\n  slack: boolean\n  synologychat: boolean\n  vocechat: boolean\n  webpush: boolean\n}\n\n// 文件浏览接口\nexport interface EndPoints {\n  // 文件列表\n  list: any\n  // 创建目录\n  mkdir: any\n  // 删除文件\n  delete: any\n  // 下载文件\n  download: any\n  // 图片预览\n  image: any\n  // 重命名\n  rename: any\n}\n\n// 文件浏览项目\nexport interface FileItem {\n  // 存储\n  storage: string\n  // 类型 dir/file\n  type: string\n  // 文件名\n  name: string\n  // 文件名不含扩展名\n  basename?: string\n  // 文件路径\n  path: string\n  // 文件扩展名\n  extension?: string\n  // 文件大小\n  size?: number\n  // 文件子元素\n  children?: FileItem[]\n  // 文件创建时间\n  modify_time?: number\n  // 文件ID\n  fileid?: string\n  // 上级文件ID\n  parent_fileid?: string\n  // 缩略图\n  thumbnail?: string\n  // pickcode\n  pickcode?: string\n  // drive_id\n  drive_id?: string\n}\n\n// 媒体服务器播放条目\nexport interface MediaServerPlayItem {\n  // ID\n  id?: string | number\n  // 标题\n  title: string\n  // 副标题\n  subtitle?: string\n  // 类型\n  type?: string\n  // 海报\n  image?: string\n  // 链接\n  link?: string\n  // 播放百分比\n  percent?: number\n  // 媒体服务器类型\n  server_type?: string\n  // 图片是否需要Cookies\n  use_cookies?: boolean\n}\n\n// 媒体服务器媒体库\nexport interface MediaServerLibrary {\n  // 服务器名称\n  server: string\n  // ID\n  id?: string | number\n  // 名称\n  name: string\n  // 路径\n  path?: string\n  // 类型\n  type?: string\n  // 图片\n  image?: string\n  // 图片列表\n  image_list?: string[]\n  // 链接\n  link?: string\n  // 媒体服务器类型\n  server_type?: string\n  // 图片是否需要Cookies\n  use_cookies?: boolean\n}\n\n// 消息通知\nexport interface Message {\n  // 消息类型\n  mtype?: string\n  // 消息标题\n  title?: string\n  // 消息内容\n  text?: string\n  // 消息链接\n  link?: string\n  // 消息图片\n  image?: string\n  // 消息时间\n  date?: string\n  // 登记时间\n  reg_time?: string\n  // 用户ID\n  userid?: string\n  // 消息方向：0-接收，1-发送\n  action?: number\n  // JSON\n  note?: string\n}\n\n// 系统通知\nexport interface SystemNotification {\n  // 通知类型 user/system/plugin\n  type: string\n  // 通知标题\n  title: string\n  // 通知内容\n  text: string\n  // 通知时间\n  date: string\n  // 是否已读\n  read?: boolean\n}\n\n// 下载器配置\nexport interface DownloaderConf {\n  // 名称\n  name: string\n  // 类型 qbittorrent/transmission\n  type: string\n  // 是否默认\n  default: boolean\n  // 配置\n  config: { [key: string]: any }\n  // 是否启用\n  enabled: boolean\n  // 路径映射\n  path_mapping?: Array<[storagePath: string, downloadPath: string]>\n}\n\n// 通知配置\nexport interface NotificationConf {\n  // 名称\n  name: string\n  // 类型 telegram/wechat/vocechat/synologychat\n  type: string\n  // 配置\n  config: { [key: string]: any }\n  // 场景开关\n  switchs?: string[]\n  // 是否启用\n  enabled: boolean\n}\n\n// 通知场景开关配置\nexport interface NotificationSwitchConf {\n  // 场景名称\n  type: string\n  // 通知范围 all/user/admin\n  action: string\n}\n\n// 存储配置\nexport interface StorageConf {\n  // 名称\n  name: string\n  // 类型 local/alipan/u115/rclone\n  type: string\n  // 配置\n  config?: { [key: string]: any }\n}\n\n// 媒体服务器配置\nexport interface MediaServerConf {\n  // 名称\n  name: string\n  // 类型 emby/jellyfin/plex/trimemedia/ugreen\n  type: string\n  // 配置\n  config: { [key: string]: any }\n  // 是否启用\n  enabled: boolean\n  // 同步媒体体库列表\n  sync_libraries?: string[]\n}\n\n// 文件整理目录配置\nexport interface TransferDirectoryConf {\n  // 名称\n  name: string\n  // 优先级\n  priority: number\n  // 存储\n  storage: string\n  // 下载目录\n  download_path?: string\n  // 适用媒体类型\n  media_type?: string\n  // 适用媒体类别\n  media_category?: string\n  // 下载类型子目录\n  download_type_folder?: boolean\n  // 下载类别子目录\n  download_category_folder?: boolean\n  // 监控方式 downloader/monitor，None为不监控\n  monitor_type?: string\n  // 监控模式 fast/compatibility\n  monitor_mode?: string\n  // 整理方式 move/copy/link/softlink\n  transfer_type: string\n  // 文件覆盖模式 always/size/never/latest\n  overwrite_mode?: string\n  // 整理到媒体库目录\n  library_path?: string\n  // 媒体库目录存储\n  library_storage?: string\n  // 智能重命名\n  renaming?: boolean\n  // 刮削\n  scraping?: boolean\n  // 媒体库类型子目录\n  library_type_folder?: boolean\n  // 媒体库类别子目录\n  library_category_folder?: boolean\n  // 是否发送通知\n  notify?: boolean\n}\n\n// 自定义规则项\nexport interface CustomRule {\n  // 规则ID\n  id: string\n  // 名称\n  name: string\n  // 包含\n  include?: string\n  // 排除\n  exclude?: string\n  // 大小范围\n  size_range?: string\n  // 最少做种人数\n  seeders?: string\n  // 发布时间\n  publish_time?: string\n}\n\n// 过滤规则组\nexport interface FilterRuleGroup {\n  // 名称\n  name: string\n  // 规则串\n  rule_string?: string\n  // 适用类媒体类型 None-全部 电影/电视剧\n  media_type?: string\n  // # 适用媒体类别 None-全部 对应二级分类\n  category?: string\n}\n\n// 订阅下载文件详情\nexport interface SubscribeDownloadFileInfo {\n  // 种子名称\n  torrent_title?: string\n  // 站点名称\n  site_name?: string\n  // 下载器\n  downloader?: string\n  // hash\n  hash?: string\n  // 文件路径\n  file_path?: string\n}\n\n// 订阅媒体库文件详情\nexport interface SubscribeLibraryFileInfo {\n  // 存储\n  storage?: string\n  // 文件路径\n  file_path?: string\n}\n\n// 订阅集详情\nexport interface SubscribeEpisodeInfo {\n  // 标题\n  title?: string\n  // 描述\n  description?: string\n  // 背景图\n  backdrop?: string\n  // 下载文件信息\n  download?: SubscribeDownloadFileInfo[]\n  // 媒体库文件信息\n  library?: SubscribeLibraryFileInfo[]\n}\n\n// 订阅详情\nexport interface SubscrbieInfo {\n  // 订阅信息\n  subscribe: Subscribe\n  // 集信息 {集号: {download: 文件路径，library: 文件路径, backdrop: url, title: 标题, description: 描述}}\n  episodes: Record<number, SubscribeEpisodeInfo>\n}\n\n// 整理表单\nexport interface TransferForm {\n  // 文件项\n  fileitem: FileItem\n  // 历史ID\n  logid: number\n  // 目标存储\n  target_storage: string\n  // 目标路径\n  target_path: string\n  // TMDB ID\n  tmdbid?: number\n  // 豆瓣 ID\n  doubanid?: string\n  // 季号\n  season?: number\n  // 类型\n  type_name?: string\n  // 整理方式\n  transfer_type: string\n  // 自定义格式\n  episode_format?: string\n  // 指定集数\n  episode_detail?: string\n  // 指定PART\n  episode_part?: string\n  // 集数偏移\n  episode_offset?: string\n  // 最小文件大小\n  min_filesize: number\n  // 刮削\n  scrape: boolean\n  // 复用历史识别信息\n  from_history: boolean\n  // 媒体库类型子目录\n  library_type_folder?: boolean\n  // 媒体库类别子目录\n  library_category_folder?: boolean\n  // 剧集组编号\n  episode_group?: string\n}\n\n// 整理队列\nexport interface TransferQueue {\n  // 媒体信息\n  media: MediaInfo\n  // 季\n  season?: number\n  // 任务列表\n  tasks: {\n    // 文件项\n    fileitem: FileItem\n    // 元数据\n    meta: MetaInfo\n    // 状态\n    state: string\n  }[]\n}\n\n// 探索的数据源\nexport interface DiscoverSource {\n  // 数据源名称\n  name: string\n  // 媒体ID的前缀，不含:\n  mediaid_prefix: string\n  // 媒体数据源API地址\n  api_path: string\n  // 过滤参数\n  filter_params: { [key: string]: any }\n  // 过滤参数UI配置\n  filter_ui: RenderProps[]\n  // UI依赖关系字典\n  depends?: { [key: string]: string[] }\n}\n\n// 推荐的数据源\nexport interface RecommendSource {\n  // 数据源名称\n  name: string\n  // 媒体数据源API地址\n  api_path: string\n  // 类型\n  type: string\n}\n\n// 站点资源分类\nexport interface SiteCategory {\n  id: number\n  cat: string\n  desc: string\n}\n\n// 工作流\nexport interface Workflow {\n  // 工作流ID\n  id?: string\n  // 工作流名称\n  name?: string\n  // 工作流描述\n  description?: string\n  // 定时器\n  timer?: string\n  // 触发类型：timer-定时触发 event-事件触发 manual-手动触发\n  trigger_type?: string\n  // 事件类型（当trigger_type为event时使用）\n  event_type?: string\n  // 状态\n  state?: string\n  // 当前执行动作\n  current_action?: string\n  // 任务执行结果\n  result?: string\n  // 已执行次数\n  run_count?: number\n  // 动作列表\n  actions?: any[]\n  // 动作流\n  flows?: any[]\n  // 创建时间\n  add_time?: string\n  // 最后执行时间\n  last_time?: string\n}\n\n// 种子缓存项\nexport interface TorrentCacheItem {\n  // 种子hash（用于操作标识）\n  hash: string\n  // 站点域名\n  domain: string\n  // 种子标题\n  title: string\n  // 种子描述\n  description?: string\n  // 种子大小\n  size: number\n  // 发布时间\n  pubdate?: string\n  // 站点名称\n  site_name?: string\n  // 识别的媒体名称\n  media_name?: string\n  // 识别的媒体年份\n  media_year?: string\n  // 识别的媒体类型\n  media_type?: string\n  // 季集信息\n  season_episode?: string\n  // 资源信息\n  resource_term?: string\n  // 种子链接\n  enclosure?: string\n  // 详情页面\n  page_url?: string\n  // 海报图片\n  poster_path?: string\n  // 背景图片\n  backdrop_path?: string\n}\n\n// 种子缓存数据\nexport interface TorrentCacheData {\n  // 缓存数量\n  count: number\n  // 站点数量\n  sites: number\n  // 缓存数据\n  data: TorrentCacheItem[]\n}\n\n// 订阅分享统计\nexport interface SubscribeShareStatistics {\n  // 分享人\n  share_user?: string\n  // 分享数量\n  share_count?: number\n  // 总复用人次\n  total_reuse_count?: number\n}\n\n// 通用API响应\nexport interface ApiResponse<T = any> {\n  success: boolean\n  message?: string\n  data: T\n}\n\n// 分类规则\nexport interface CategoryRule {\n  genre_ids?: string\n  original_language?: string\n  production_countries?: string\n  origin_country?: string\n  release_year?: string\n}\n\n// 分类配置\nexport interface CategoryConfig {\n  movie?: { [key: string]: CategoryRule }\n  tv?: { [key: string]: CategoryRule }\n}\n"
  },
  {
    "path": "src/components/FileBrowser.vue",
    "content": "<script lang=\"ts\" setup>\nimport FileList from './filebrowser/FileList.vue'\nimport FileToolbar from './filebrowser/FileToolbar.vue'\nimport FileNavigator from './filebrowser/FileNavigator.vue'\nimport type { EndPoints, FileItem, StorageConf } from '@/api/types'\nimport { storageIconDict } from '@/api/constants'\nimport type { AxiosInstance } from 'axios'\nimport { useDynamicButton } from '@/composables/useDynamicButton'\nimport { usePWA } from '@/composables/usePWA'\n\n// LocalStorage keys\nconst SORT_KEY = 'fileBrowser.sort'\nconst SHOW_TREE_KEY = 'fileBrowser.showDirTree'\nconst NAV_WIDTH_KEY = 'fileBrowser.navigatorWidth'\n\n// 输入参数\nconst props = defineProps({\n  storages: Array as PropType<StorageConf[]>,\n  tree: Boolean,\n  endpoints: Object as PropType<EndPoints>,\n  axios: {\n    type: Object as PropType<AxiosInstance>,\n    required: true,\n  },\n  axiosconfig: Object,\n  item: {\n    type: Object as PropType<FileItem>,\n    required: true,\n  },\n  itemstack: {\n    type: Array as PropType<FileItem[]>,\n    default: () => [],\n  },\n})\n\n// 对外事件\nconst emit = defineEmits(['pathchanged'])\nconst route = useRoute()\nconst { appMode } = usePWA()\nconst toolbarRef = ref<InstanceType<typeof FileToolbar> | null>(null)\n\nconst fileIcons = {\n  // 压缩包\n  zip: 'mdi-folder-zip-outline',\n  rar: 'mdi-folder-zip-outline',\n  bak: 'mdi-folder-zip-outline',\n  tar: 'mdi-folder-zip-outline',\n  gz: 'mdi-folder-zip-outline',\n  bz2: 'mdi-folder-zip-outline',\n  // 开发\n  htm: 'mdi-language-html5',\n  html: 'mdi-language-html5',\n  vue: 'mdi-vuejs',\n  js: 'mdi-nodejs',\n  ts: 'mdi-language-typescript',\n  json: 'mdi-file-document-outline',\n  css: 'mdi-language-css3',\n  scss: 'mdi-language-css3',\n  less: 'mdi-language-css3',\n  php: 'mdi-language-php',\n  py: 'mdi-language-python',\n  java: 'mdi-language-java',\n  go: 'mdi-language-go',\n  c: 'mdi-language-c',\n  cpp: 'mdi-language-cpp',\n  h: 'mdi-language-c',\n  cs: 'mdi-language-csharp',\n  sql: 'mdi-database',\n  sh: 'mdi-language-bash',\n  bat: 'mdi-language-bash',\n  ps1: 'mdi-language-powershell',\n  // markdown\n  md: 'mdi-language-markdown-outline',\n  markdown: 'mdi-language-markdown-outline',\n  // 图片\n  png: 'mdi-file-png-box',\n  jpg: 'mdi-file-jpg-box',\n  jpeg: 'mdi-file-jpg-box',\n  gif: 'mdi-file-gif-box',\n  bmp: 'mdi-file-image-box',\n  webp: 'mdi-file-image-box',\n  ico: 'mdi-file-image-box',\n  svg: 'mdi-file-image-box',\n  // 视频\n  mp4: 'mdi-filmstrip',\n  mkv: 'mdi-filmstrip',\n  avi: 'mdi-filmstrip',\n  wmv: 'mdi-filmstrip',\n  mov: 'mdi-filmstrip',\n  flv: 'mdi-filmstrip',\n  rmvb: 'mdi-filmstrip',\n  // 文档\n  txt: 'mdi-file-document-outline',\n  env: 'mdi-file-cog-outline',\n  yml: 'mdi-file-cog-outline',\n  yaml: 'mdi-file-cog-outline',\n  conf: 'mdi-file-cog-outline',\n  log: 'mdi-file-document-outline',\n  csv: 'mdi-file-delimited',\n  // office\n  xls: 'mdi-file-excel',\n  xlsx: 'mdi-file-excel',\n  doc: 'mdi-file-word',\n  docx: 'mdi-file-word',\n  ppt: 'mdi-file-powerpoint',\n  pptx: 'mdi-file-powerpoint',\n  pdf: 'mdi-file-pdf',\n  // 音频\n  mp2: 'mdi-music',\n  mp3: 'mdi-music',\n  m4a: 'mdi-music',\n  wma: 'mdi-music',\n  aac: 'mdi-music',\n  ogg: 'mdi-music',\n  flac: 'mdi-music',\n  wav: 'mdi-music',\n  // 字体\n  ttf: 'mdi-format-font',\n  otf: 'mdi-format-font',\n  woff: 'mdi-format-font',\n  woff2: 'mdi-format-font',\n  eot: 'mdi-format-font',\n  // 字幕\n  srt: 'mdi-subtitles-outline',\n  ass: 'mdi-subtitles-outline',\n  sub: 'mdi-subtitles-outline',\n  // 其他\n  other: 'mdi-file-outline',\n}\n\nfunction openNewFolderDialog() {\n  toolbarRef.value?.openNewFolderDialog()\n}\n\nconst showFloatingNewFolderAction = computed(() => route.path === '/filemanager')\n\nuseDynamicButton({\n  icon: 'mdi-folder-plus-outline',\n  onClick: openNewFolderDialog,\n  show: computed(() => appMode.value && showFloatingNewFolderAction.value),\n})\n\n// 加载次数\nconst loading = ref(0)\n\n// 刷新\nconst refreshPending = ref(false)\n// 排序 - 从localStorage恢复\nconst sort = ref(localStorage.getItem(SORT_KEY) || 'name')\n\n// 是否显示目录树 - 从localStorage恢复\nconst showDirTree = ref(localStorage.getItem(SHOW_TREE_KEY) === 'true')\n\n// 拖动分隔条相关 - 从localStorage恢复宽度\nconst navigatorWidth = ref(parseInt(localStorage.getItem(NAV_WIDTH_KEY) || '280'))\nconst isDragging = ref(false)\nconst dragStartX = ref(0)\nconst dragStartWidth = ref(0)\n\nwatch(sort, (val) => {\n  localStorage.setItem(SORT_KEY, val)\n})\n\nwatch(showDirTree, (val) => {\n  localStorage.setItem(SHOW_TREE_KEY, String(val))\n})\n\nwatch(navigatorWidth, (val) => {\n  localStorage.setItem(NAV_WIDTH_KEY, String(val))\n})\n\n// 计算属性\nconst storagesArray = computed(() => {\n  return props.storages?.map(item => ({\n    title: item.name,\n    value: item.type,\n    icon: storageIconDict[item.type] ?? 'mdi-server-network-outline',\n  }))\n})\n\n\n// 方法\nfunction loadingChanged(isLoading: number) {\n  if (isLoading) loading.value++\n  else if (loading.value > 0) loading.value--\n}\n\n// 存储切换\nasync function storageChanged(storage: string) {\n  emit('pathchanged', { storage: storage, path: '/', fileid: 'root' })\n}\n\n// 路径变化\nfunction pathChanged(item: FileItem) {\n  emit('pathchanged', item)\n}\n\n// 排序变化\nfunction sortChanged(s: string) {\n  sort.value = s\n  refreshPending.value = true\n}\n\n// 切换目录树\nfunction switchDirTree(state: boolean) {\n  showDirTree.value = state\n}\n\n// 文件列表\nconst fileListItems = ref<FileItem[]>([])\n\n// 文件列表数据更新\nfunction fileListUpdated(items: FileItem[]) {\n  fileListItems.value = items\n}\n\n// 阻止选择事件\nfunction preventSelect(event: Event) {\n  event.preventDefault()\n  return false\n}\n\n// 拖动分隔条相关方法\nfunction startDrag(event: MouseEvent) {\n  event.preventDefault() // 阻止默认行为\n  event.stopPropagation() // 阻止事件冒泡\n\n  isDragging.value = true\n  dragStartX.value = event.clientX\n  dragStartWidth.value = navigatorWidth.value\n\n  document.addEventListener('mousemove', handleDrag, { passive: false })\n  document.addEventListener('mouseup', stopDrag, { passive: false })\n  document.addEventListener('selectstart', preventSelect) // 阻止选择开始\n\n  document.body.style.cursor = 'col-resize'\n  document.body.style.userSelect = 'none'\n  ;(document.body.style as any).webkitUserSelect = 'none' // Safari兼容\n  ;(document.body.style as any).mozUserSelect = 'none' // Firefox兼容\n}\n\nfunction handleDrag(event: MouseEvent) {\n  if (!isDragging.value) return\n\n  event.preventDefault() // 阻止默认行为\n\n  const deltaX = event.clientX - dragStartX.value\n  const newWidth = dragStartWidth.value + deltaX\n\n  // 设置最小和最大宽度限制\n  const minWidth = 200\n  const maxWidth = window.innerWidth * 0.6\n\n  navigatorWidth.value = Math.max(minWidth, Math.min(maxWidth, newWidth))\n}\n\nfunction stopDrag() {\n  isDragging.value = false\n  document.removeEventListener('mousemove', handleDrag)\n  document.removeEventListener('mouseup', stopDrag)\n  document.removeEventListener('selectstart', preventSelect)\n\n  document.body.style.cursor = ''\n  document.body.style.userSelect = ''\n  ;(document.body.style as any).webkitUserSelect = ''\n  ;(document.body.style as any).mozUserSelect = ''\n}\n</script>\n\n<template>\n  <div class=\"mx-auto\" :loading=\"loading > 0\">\n    <div v-if=\"item\">\n      <FileToolbar\n        ref=\"toolbarRef\"\n        :sort=\"sort\"\n        :item=\"item\"\n        :itemstack=\"itemstack\"\n        :storages=\"storagesArray\"\n        :endpoints=\"endpoints\"\n        :axios=\"axios\"\n        :show-new-folder-button=\"!showFloatingNewFolderAction\"\n        @storagechanged=\"storageChanged\"\n        @pathchanged=\"pathChanged\"\n        @foldercreated=\"refreshPending = true\"\n        @sortchanged=\"sortChanged\"\n      />\n      <div class=\"flex\">\n        <FileNavigator\n          v-if=\"showDirTree\"\n          :storage=\"item.storage\"\n          :currentPath=\"item.path\"\n          :items=\"fileListItems\"\n          :endpoints=\"endpoints\"\n          :axios=\"axios\"\n          :style=\"{ width: `${navigatorWidth}px`, minWidth: `${navigatorWidth}px` }\"\n          @navigate=\"pathChanged\"\n        />\n        <!-- 拖动分隔条 -->\n        <div v-if=\"showDirTree\" class=\"divider\" :class=\"{ 'divider-dragging': isDragging }\" @mousedown=\"startDrag\">\n          <div class=\"divider-line\"></div>\n          <VIcon class=\"divider-icon\" size=\"small\">mdi-drag-vertical</VIcon>\n        </div>\n        <FileList\n          :item=\"item\"\n          :icons=\"fileIcons\"\n          :endpoints=\"endpoints\"\n          :axios=\"axios\"\n          :refreshpending=\"refreshPending\"\n          :sort=\"sort\"\n          :showTree=\"showDirTree\"\n          :style=\"{ flex: 1 }\"\n          @pathchanged=\"pathChanged\"\n          @loading=\"loadingChanged\"\n          @refreshed=\"refreshPending = false\"\n          @filedeleted=\"refreshPending = true\"\n          @renamed=\"refreshPending = true\"\n          @items-updated=\"fileListUpdated\"\n          @switch-tree=\"switchDirTree\"\n        />\n      </div>\n    </div>\n  </div>\n\n  <Teleport to=\"body\" v-if=\"!appMode && showFloatingNewFolderAction\">\n    <div class=\"compact-fab-stack\">\n      <VFab\n        icon=\"mdi-folder-plus-outline\"\n        color=\"primary\"\n        appear\n        class=\"compact-fab compact-fab--primary\"\n        @click=\"openNewFolderDialog\"\n      />\n    </div>\n  </Teleport>\n</template>\n\n<style scoped>\n.divider {\n  position: relative;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background-color: transparent;\n  cursor: col-resize;\n  inline-size: 4px;\n  transition: background-color 0.2s ease;\n  user-select: none;\n}\n\n.divider:hover {\n  background-color: rgba(var(--v-theme-on-surface), 0.08);\n}\n\n.divider-dragging {\n  background-color: rgba(var(--v-theme-primary), 0.12) !important;\n}\n\n.divider-line {\n  background-color: rgba(var(--v-theme-outline), 0.3);\n  block-size: 100%;\n  inline-size: 1px;\n  transition: background-color 0.2s ease;\n  user-select: none;\n}\n\n.divider-dragging .divider-line {\n  background-color: rgb(var(--v-theme-primary)) !important;\n}\n\n.divider:hover .divider-line {\n  background-color: rgba(var(--v-theme-primary), 0.8);\n}\n\n.divider-icon {\n  position: absolute;\n  z-index: 1;\n  padding: 2px;\n  border-radius: 2px;\n  background-color: rgba(var(--v-theme-surface), 0.9);\n  color: rgba(var(--v-theme-on-surface-variant), 0.6);\n  opacity: 0;\n  pointer-events: none;\n  transition: all 0.2s ease;\n}\n\n.divider-dragging .divider-icon {\n  background-color: rgba(var(--v-theme-surface), 0.95);\n  color: rgb(var(--v-theme-primary));\n  opacity: 1;\n}\n\n.divider:hover .divider-icon {\n  color: rgba(var(--v-theme-primary), 0.9);\n  opacity: 1;\n}\n</style>\n"
  },
  {
    "path": "src/components/NoDataFound.vue",
    "content": "<script setup lang=\"ts\">\nimport { useI18n } from 'vue-i18n'\nimport page404 from '@images/pages/404.svg'\n\n// 国际化\nconst { t } = useI18n()\n\nconst props = defineProps<Props>()\n\ninterface Props {\n  errorCode?: string\n  errorTitle?: string\n  errorDescription?: string\n  icon?: string\n  iconColor?: string\n}\n</script>\n\n<template>\n  <div class=\"no-data-container\">\n    <!-- 图标容器 -->\n    <div class=\"icon-wrapper\">\n      <img :src=\"page404\" alt=\"404\" />\n    </div>\n\n    <!-- 标题 -->\n    <div class=\"error-title\">\n      {{ props.errorTitle || t('common.noData') }}\n    </div>\n\n    <!-- 描述 -->\n    <div class=\"error-description\">\n      {{ props.errorDescription || t('common.noContent') }}\n    </div>\n\n    <!-- 按钮插槽 -->\n    <div class=\"actions-container\">\n      <slot name=\"button\" />\n    </div>\n  </div>\n</template>\n\n<style scoped>\n.no-data-container {\n  position: relative;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  inline-size: 100%;\n  min-block-size: 300px;\n  padding-block-start: 3rem;\n  text-align: center;\n}\n\n/* 图标样式 */\n.icon-wrapper {\n  position: relative;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  inline-size: 15rem;\n  margin-block: 0 1rem;\n  margin-inline: auto;\n}\n\n/* 文字样式 */\n.error-title {\n  position: relative;\n  color: rgba(var(--v-theme-on-surface), 0.95);\n  font-size: 1.5rem;\n  font-weight: 500;\n  margin-block-end: 0.75rem;\n  text-shadow: 0 1px 2px rgba(0, 0, 0, 5%);\n}\n\n.error-title::after {\n  display: block;\n  border-radius: 3px;\n  background: linear-gradient(90deg, rgba(var(--v-theme-primary), 0.8), rgba(var(--v-theme-primary), 0.2));\n  block-size: 3px;\n  content: '';\n  inline-size: 60px;\n  margin-inline: auto;\n}\n\n.error-description {\n  color: rgba(var(--v-theme-on-surface), 0.75);\n  font-size: 1rem;\n  margin-block-end: 1rem;\n  margin-inline: auto;\n  max-inline-size: 80%;\n}\n</style>\n"
  },
  {
    "path": "src/components/PWAInstallPrompt.vue",
    "content": "<script setup lang=\"ts\">\nimport { usePWAInstall } from '@/composables/usePWAInstall'\nimport { useAuthStore } from '@/stores'\nimport { useI18n } from 'vue-i18n'\nimport { useToast } from 'vue-toastification'\n\nconst { t, locale, messages } = useI18n()\nconst { isInstalled, showInstallPrompt, getInstallInstructions } = usePWAInstall()\n\nconst showBanner = ref(false)\nconst showInstructions = ref(false)\nconst dismissed = ref(false)\n\n// 检查是否登录\nconst authStore = useAuthStore()\nconst isLogin = computed(() => authStore.token)\n\n// 检查当前是不是https环境\nconst isHttps = computed(() => {\n  return window.location.protocol === 'https:'\n})\n\n// 检查是否应该显示横幅\nconst shouldShowBanner = computed(() => {\n  return !isInstalled.value && !dismissed.value && !showInstructions.value && isLogin.value && isHttps.value\n})\n\n// 显示延迟（避免立即显示）\nonMounted(() => {\n  setTimeout(() => {\n    // 检查本地存储，看用户是否已经关闭过提示\n    const dismissedTime = localStorage.getItem('pwa-install-dismissed')\n    if (dismissedTime) {\n      const dismissedDate = new Date(dismissedTime)\n      const now = new Date()\n      const daysDiff = (now.getTime() - dismissedDate.getTime()) / (1000 * 60 * 60 * 24)\n\n      // 如果距离上次关闭不到30天，不显示\n      if (daysDiff < 30) {\n        dismissed.value = true\n        return\n      }\n    }\n\n    showBanner.value = true\n  }, 5000) // 5秒后显示\n})\n\n// 处理安装\nconst handleInstall = async () => {\n  const installed = await showInstallPrompt()\n  if (installed) {\n    showBanner.value = false\n    // 显示成功消息\n    useToast().success(t('pwa.installSuccess'))\n  } else {\n    // 如果用户拒绝，显示手动安装说明\n    showInstructions.value = true\n  }\n}\n\n// 关闭横幅\nconst dismissBanner = () => {\n  showBanner.value = false\n  dismissed.value = true\n  // 记录关闭时间\n  localStorage.setItem('pwa-install-dismissed', new Date().toISOString())\n}\n\n// 获取平台特定的安装说明\nconst instructions = computed(() => {\n  const rawInstructions = getInstallInstructions()\n  const platformKey = rawInstructions.platformKey\n\n  // 获取平台显示名称\n  const platformName = t(`pwa.platforms.${platformKey}`)\n\n  // 直接使用t函数获取安装步骤，避免编译对象的问题\n  const steps = []\n  const maxSteps = 10 // 最大步骤数，防止无限循环\n\n  for (let i = 0; i < maxSteps; i++) {\n    try {\n      const stepKey = `pwa.installSteps.${platformKey}.${i}`\n      const stepText = t(stepKey)\n\n      // 如果返回的是键名本身，说明没有找到对应的翻译\n      if (stepText === stepKey) {\n        break\n      }\n\n      steps.push(stepText)\n    } catch (error) {\n      // 如果出现错误，说明没有更多步骤\n      break\n    }\n  }\n\n  return {\n    platform: platformName,\n    steps,\n  }\n})\n</script>\n\n<template>\n  <!-- 安装横幅 -->\n  <Teleport to=\"body\">\n    <Transition\n      enter-active-class=\"transition-all duration-300\"\n      enter-from-class=\"translate-y-full opacity-0\"\n      enter-to-class=\"translate-y-0 opacity-100\"\n      leave-active-class=\"transition-all duration-300\"\n      leave-from-class=\"translate-y-0 opacity-100\"\n      leave-to-class=\"translate-y-full opacity-0\"\n    >\n      <VCard v-if=\"shouldShowBanner && showBanner\" class=\"pwa-install-banner\">\n        <div class=\"banner-content\">\n          <VIcon icon=\"mdi-cellphone-link\" size=\"24\" class=\"me-3\" />\n          <div class=\"flex-grow-1\">\n            <div class=\"font-weight-medium\">{{ t('pwa.installApp') }}</div>\n            <div class=\"text-sm opacity-70\">{{ t('pwa.installDescription') }}</div>\n          </div>\n          <VBtn color=\"primary\" size=\"small\" variant=\"flat\" @click=\"handleInstall\">\n            {{ t('pwa.install') }}\n          </VBtn>\n          <VBtn icon size=\"small\" variant=\"text\" @click=\"dismissBanner\">\n            <VIcon icon=\"mdi-close\" />\n          </VBtn>\n        </div>\n      </VCard>\n    </Transition>\n  </Teleport>\n\n  <!-- 手动安装说明对话框 -->\n  <VDialog v-model=\"showInstructions\" max-width=\"500\">\n    <VCard>\n      <VCardItem>\n        <VCardTitle class=\"d-flex align-center\">\n          <VIcon icon=\"mdi-information-outline\" class=\"me-2\" />\n          {{ t('pwa.installGuide') }}\n        </VCardTitle>\n      </VCardItem>\n\n      <VCardText>\n        <div class=\"mb-4\">\n          <div class=\"text-subtitle-1 mb-2\">\n            {{ t('pwa.installInstructions', { platform: instructions.platform }) }}\n          </div>\n          <VList density=\"compact\">\n            <VListItem\n              v-for=\"(step, index) in instructions.steps\"\n              :key=\"index\"\n              :prepend-icon=\"`mdi-numeric-${index + 1}-circle`\"\n            >\n              <VListItemTitle>{{ step }}</VListItemTitle>\n            </VListItem>\n          </VList>\n        </div>\n\n        <VAlert type=\"info\" variant=\"tonal\" density=\"compact\">\n          {{ t('pwa.installNote') }}\n        </VAlert>\n      </VCardText>\n\n      <VCardActions>\n        <VSpacer />\n        <VBtn color=\"primary\" variant=\"text\" @click=\"showInstructions = false\">\n          {{ t('pwa.gotIt') }}\n        </VBtn>\n      </VCardActions>\n    </VCard>\n  </VDialog>\n</template>\n\n<style scoped>\n.pwa-install-banner {\n  position: fixed;\n  z-index: 1000;\n  border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));\n  border-radius: 12px;\n  background: rgb(var(--v-theme-surface));\n  box-shadow: 0 4px 20px rgba(0, 0, 0, 10%);\n  inset-block-end: 5rem;\n  inset-inline: 20px;\n}\n\n.banner-content {\n  display: flex;\n  align-items: center;\n  padding: 16px;\n  gap: 8px;\n}\n\n@media (width >= 600px) {\n  .pwa-install-banner {\n    inset-inline: auto 20px;\n    max-inline-size: 400px;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/cards/BackdropCard.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { MediaServerPlayItem } from '@/api/types'\nimport noImage from '@images/no-image.jpeg'\nimport { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'\n// 输入参数\nconst props = defineProps({\n  media: Object as PropType<MediaServerPlayItem>,\n  width: String,\n  height: String,\n})\n\n// 图片是否加载完成\nconst imageLoaded = ref(false)\nconst imageLoadError = ref(false)\n\n// 图片加载完成响应\nfunction imageLoadHandler() {\n  imageLoaded.value = true\n}\n\n// 图片加载失败响应\nfunction imageErrorHandler() {\n  imageLoadError.value = true\n}\n\n// 跳转播放\nasync function goPlay() {\n  if (props.media?.link) {\n    await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)\n  }\n}\n\n// 计算图片地址\nconst getImgUrl = computed(() => {\n  const image = props.media?.image || ''\n  if (!image || imageLoadError.value) return noImage\n  let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`\n  const use_cookies = props.media?.use_cookies\n  if (use_cookies) {\n   url += `&use_cookies=${encodeURIComponent(use_cookies)}`\n  }\n  return url\n})\n</script>\n\n<template>\n  <VHover>\n    <template #default=\"hover\">\n      <VCard\n        v-bind=\"hover.props\"\n        :height=\"props.height\"\n        :width=\"props.width\"\n        class=\"ring-gray-500\"\n        :class=\"{\n          'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,\n          'ring-1': imageLoaded,\n        }\"\n        @click=\"goPlay\"\n      >\n        <template #image>\n          <VImg :src=\"getImgUrl\" aspect-ratio=\"2/3\" cover @load=\"imageLoadHandler\" @error=\"imageErrorHandler\">\n            <template #placeholder>\n              <div class=\"w-full h-full\">\n                <VSkeletonLoader class=\"object-cover aspect-w-3 aspect-h-2\" />\n              </div>\n            </template>\n            <template #default>\n              <VCardText\n                class=\"w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2\"\n              >\n                <h1\n                  class=\"mb-1 text-white text-shadow font-bold text-lg line-clamp-2 overflow-hidden text-ellipsis ...\"\n                >\n                  {{ props.media?.title }}\n                </h1>\n                <span class=\"text-shadow text-sm\">{{ props.media?.subtitle }}</span>\n              </VCardText>\n            </template>\n          </VImg>\n        </template>\n        <div class=\"w-full absolute bottom-0\">\n          <VProgressLinear\n            v-if=\"props.media?.percent\"\n            :model-value=\"props.media?.percent\"\n            bg-color=\"success\"\n            color=\"success\"\n          />\n        </div>\n      </VCard>\n    </template>\n  </VHover>\n</template>\n"
  },
  {
    "path": "src/components/cards/CustomRuleCard.vue",
    "content": "<script lang=\"ts\" setup>\nimport { CustomRule } from '@/api/types'\nimport { useToast } from 'vue-toastification'\nimport filter_svg from '@images/svg/filter.svg'\nimport { cloneDeep } from 'lodash-es'\nimport { innerFilterRules } from '@/api/constants'\nimport { useI18n } from 'vue-i18n'\nimport { useDisplay } from 'vuetify'\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 输入参数\nconst props = defineProps({\n  // 单条规则\n  rule: {\n    type: Object as PropType<CustomRule>,\n    required: true,\n  },\n  // 所有规则\n  rules: {\n    type: Array as PropType<CustomRule[]>,\n    required: true,\n  },\n})\n\n// 提示框\nconst $toast = useToast()\nconst { t } = useI18n()\n\n// 定义触发的自定义事件\nconst emit = defineEmits(['close', 'change', 'done'])\n\n// 规则详情弹窗\nconst ruleInfoDialog = ref(false)\n\n// 规则详情\nconst ruleInfo = ref<CustomRule>({\n  id: '',\n  name: '',\n  include: '',\n  exclude: '',\n  size_range: '',\n  seeders: '',\n  publish_time: '',\n})\n\n// 打开详情弹窗\nfunction openRuleInfoDialog() {\n  // 深复制\n  ruleInfo.value = cloneDeep(props.rule)\n  ruleInfoDialog.value = true\n}\n\n// 保存详情数据\nfunction saveRuleInfo() {\n  // 有空值\n  if (!ruleInfo.value.id || !ruleInfo.value.name) {\n    if (!ruleInfo.value.id && !ruleInfo.value.name) {\n      $toast.error(t('customRule.error.emptyIdName'))\n    }\n    return\n  }\n  // 检查ID是否在内置的规则中\n  if (innerFilterRules.find(option => option.value === ruleInfo.value.id)) {\n    $toast.error(t('customRule.error.idOccupied'))\n    return\n  }\n  // 检查规则名称是否在内置的规则中\n  if (innerFilterRules.find(option => option.title === ruleInfo.value.name)) {\n    $toast.error(t('customRule.error.nameOccupied'))\n    return\n  }\n  // ID已存在\n  if (ruleInfo.value.id !== props.rule.id && props.rules.find(rule => rule.id === ruleInfo.value.id)) {\n    $toast.error(t('customRule.error.idExists', { id: ruleInfo.value.id }))\n    return\n  }\n  // 规则名称已存在\n  if (ruleInfo.value.name !== props.rule.name && props.rules.find(rule => rule.name === ruleInfo.value.name)) {\n    $toast.error(t('customRule.error.nameExists', { name: ruleInfo.value.name }))\n    return\n  }\n  // 保存数据\n  ruleInfoDialog.value = false\n  emit('change', ruleInfo.value, props.rule.id)\n  emit('done')\n}\n\n// 验证规则ID输入\nfunction validateRuleId() {\n  // 只允许英文和数字，不允许空格\n  ruleInfo.value.id = ruleInfo.value.id.replace(/[^a-zA-Z0-9]/g, '')\n}\n\n// 按钮点击\nfunction onClose() {\n  emit('close')\n}\n</script>\n\n<template>\n  <div>\n    <VCard variant=\"tonal\" class=\"app-card-shell\" @click=\"openRuleInfoDialog\">\n      <span class=\"app-card-top-action absolute top-3 right-12\">\n        <IconBtn @click.stop>\n          <VIcon class=\"cursor-move\" icon=\"mdi-drag\" />\n        </IconBtn>\n      </span>\n      <VDialogCloseBtn @click=\"onClose\" />\n      <VCardText class=\"app-card-summary app-card-summary--double-action app-card-summary--title-subtitle\">\n        <div class=\"app-card-summary__content\">\n          <h5 class=\"app-card-summary__title text-h6\">{{ props.rule.name }}</h5>\n          <div class=\"app-card-summary__subtitle text-body-1\">{{ props.rule.id }}</div>\n        </div>\n        <div class=\"app-card-summary__media\" aria-hidden=\"true\">\n          <VImg :src=\"filter_svg\" contain class=\"app-card-summary__image\" />\n        </div>\n      </VCardText>\n    </VCard>\n    <VDialog\n      v-if=\"ruleInfoDialog\"\n      v-model=\"ruleInfoDialog\"\n      scrollable\n      max-width=\"40rem\"\n      :fullscreen=\"!display.mdAndUp.value\"\n    >\n      <VCard>\n        <VCardItem>\n          <template #prepend>\n            <VIcon icon=\"mdi-filter-outline\" class=\"me-2\" />\n          </template>\n          <VCardTitle>{{ t('customRule.title', { id: props.rule.id }) }}</VCardTitle>\n        </VCardItem>\n        <VDialogCloseBtn v-model=\"ruleInfoDialog\" />\n        <VDivider />\n        <VCardText>\n          <VForm>\n            <VRow>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"ruleInfo.id\"\n                  :label=\"t('customRule.field.ruleId')\"\n                  :placeholder=\"t('customRule.placeholder.ruleId')\"\n                  :hint=\"t('customRule.hint.ruleId')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-identifier\"\n                  @input=\"validateRuleId\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"ruleInfo.name\"\n                  :label=\"t('customRule.field.ruleName')\"\n                  :placeholder=\"t('customRule.placeholder.ruleName')\"\n                  :hint=\"t('customRule.hint.ruleName')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-label\"\n                />\n              </VCol>\n              <VCol cols=\"12\">\n                <VTextField\n                  v-model=\"ruleInfo.include\"\n                  :label=\"t('customRule.field.include')\"\n                  :placeholder=\"t('customRule.placeholder.include')\"\n                  :hint=\"t('customRule.hint.include')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-plus-circle\"\n                />\n              </VCol>\n              <VCol cols=\"12\">\n                <VTextField\n                  v-model=\"ruleInfo.exclude\"\n                  :label=\"t('customRule.field.exclude')\"\n                  :placeholder=\"t('customRule.placeholder.exclude')\"\n                  :hint=\"t('customRule.hint.exclude')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-minus-circle\"\n                />\n              </VCol>\n              <VCol cols=\"6\">\n                <VTextField\n                  v-model=\"ruleInfo.size_range\"\n                  :label=\"t('customRule.field.sizeRange')\"\n                  :placeholder=\"t('customRule.placeholder.sizeRange')\"\n                  :hint=\"t('customRule.hint.sizeRange')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-harddisk\"\n                />\n              </VCol>\n              <VCol cols=\"6\">\n                <VTextField\n                  v-model=\"ruleInfo.seeders\"\n                  :label=\"t('customRule.field.seeders')\"\n                  :placeholder=\"t('customRule.placeholder.seeders')\"\n                  :hint=\"t('customRule.hint.seeders')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-account-group\"\n                />\n              </VCol>\n              <VCol cols=\"6\">\n                <VTextField\n                  v-model=\"ruleInfo.publish_time\"\n                  :label=\"t('customRule.field.publishTime')\"\n                  :placeholder=\"t('customRule.placeholder.publishTime')\"\n                  :hint=\"t('customRule.hint.publishTime')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-calendar-clock\"\n                />\n              </VCol>\n            </VRow>\n          </VForm>\n        </VCardText>\n        <VCardActions class=\"pt-3\">\n          <VBtn @click=\"saveRuleInfo\" prepend-icon=\"mdi-content-save\" class=\"px-5\">{{\n            t('customRule.action.confirm')\n          }}</VBtn>\n        </VCardActions>\n      </VCard>\n    </VDialog>\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/cards/DirectoryCard.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { StorageConf, TransferDirectoryConf } from '@/api/types'\nimport api from '@/api'\nimport { nextTick } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport { storageRemoteDict } from '@/api/constants'\n\n// 国际化\nconst { t } = useI18n()\n\n// 输入参数\nconst props = defineProps({\n  type: String, // download/library\n  directory: {\n    type: Object as PropType<TransferDirectoryConf>,\n    required: true, // 必填参数\n  },\n  categories: {\n    type: Object as PropType<{ [key: string]: any }>,\n    required: true,\n  },\n  storages: {\n    type: Array as PropType<StorageConf[]>,\n    required: true,\n  },\n  width: String,\n  height: String,\n})\n\n// 卡版是否折叠状态\nconst isCollapsed = ref(true)\n\n// 类型下拉字典\nconst typeItems = computed(() => [\n  { title: t('common.all'), value: '' },\n  { title: t('mediaType.movie'), value: '电影' },\n  { title: t('mediaType.tv'), value: '电视剧' },\n])\n\n// 计算资源存储字典（整理方式为下载器时不能为远程存储）\nconst resourceStorageOptions = computed(() => {\n  return props.storages\n    .filter(item => !storageRemoteDict[item.type] || props.directory.monitor_type !== 'downloader')\n    .map(item => ({\n      title: item.name,\n      value: item.type,\n    }))\n})\n\n// 存储字典\nconst libraryStorageOptions = computed(() => {\n  return props.storages.map(item => ({\n    title: item.name,\n    value: item.type,\n  }))\n})\n\n// 自动整理方式下拉字典\nconst transferSourceItems = computed(() => [\n  { title: t('directory.noTransfer'), value: '' },\n  { title: t('directory.downloaderMonitor'), value: 'downloader' },\n  { title: t('directory.directoryMonitor'), value: 'monitor' },\n  { title: t('directory.manualTransfer'), value: 'manual' },\n])\n\n// 监控模式下拉字典\nconst MonitorModeItems = computed(() => [\n  { title: t('directory.performanceMode'), value: 'fast' },\n  { title: t('directory.compatibilityMode'), value: 'compatibility' },\n])\n\n// 整理方式下拉字典\nconst transferTypeItems = ref<{ title: string; value: string }[]>([])\n\n// 调用API查询支持的整理方式\nasync function loadTransferTypeItems() {\n  // 参数不全时不查询\n  if (!props.directory.library_storage || !props.directory.storage) return\n  try {\n    // 下载器储存整理方法\n    const storage_res = await api.get(`storage/transtype/${props.directory.storage}`)\n    const storage_transtype = (storage_res as any).transtype\n    // 媒体库储存整理方法\n    const library_storage_res = await api.get(`storage/transtype/${props.directory.library_storage}`)\n    const library_storage_transtype = (library_storage_res as any).transtype\n    // 为空终止\n    if (!library_storage_transtype || !storage_transtype) return\n    // 取并集\n    const transtype: { [key: string]: string } = {}\n    Object.keys(storage_transtype).forEach(key => {\n      if (key in library_storage_transtype) {\n        transtype[key] = storage_transtype[key]\n      }\n    })\n    // 非空时设置整理方式下拉字典\n    if (transtype && Object.keys(transtype).length > 0) {\n      transferTypeItems.value = Object.keys(transtype).map(key => ({\n        title: transtype[key],\n        value: key,\n      }))\n      // 如果整理方式下拉字典不为空，且当前值不在新的transferTypeItems里，则设置整理方式为第一个\n      if (\n        transferTypeItems.value.length > 0 &&\n        !transferTypeItems.value.find(item => item.value === props.directory.transfer_type)\n      ) {\n        nextTick(() => {\n          props.directory.transfer_type = transferTypeItems.value[0].value\n        })\n      }\n      // 如果整理方式下拉字典为空，清空整理方式\n      if (transferTypeItems.value.length === 0) {\n        props.directory.transfer_type = ''\n      }\n    } else {\n      // 无可用整理方式，清除已选值\n      transferTypeItems.value = []\n      props.directory.transfer_type = ''\n    }\n  } catch (e) {\n    console.log(e)\n  }\n}\n\n// 整理方式无数据提示\nconst computedNoDataText = computed(() => {\n  if (!props.directory.library_storage && !props.directory.storage) {\n    return t('directory.pleaseSelectStorage')\n  } else if (!props.directory.library_storage) {\n    return t('directory.pleaseSelectLibraryStorage')\n  } else if (!props.directory.storage) {\n    return t('directory.pleaseSelectDownloadStorage')\n  } else {\n    return t('directory.noSupportedTransferType')\n  }\n})\n\n// 覆盖模式下拉字典\nconst overwriteModeItems = computed(() => [\n  { title: t('directory.never'), value: 'never' },\n  { title: t('directory.always'), value: 'always' },\n  { title: t('directory.byFileSize'), value: 'size' },\n  { title: t('directory.keepLatestOnly'), value: 'latest' },\n])\n\n// 定义触发的自定义事件\nconst emit = defineEmits(['close', 'changed', 'update:modelValue'])\n\n// 按钮点击\nfunction onClose() {\n  emit('close')\n}\n\n// 根据选中的媒体类型，获取对应的媒体类别\nconst getCategories = computed(() => {\n  const default_value = [{ title: t('common.all'), value: '' }]\n  if (!props.categories || !props.categories[props.directory?.media_type ?? '']) return default_value\n  return default_value.concat(props.categories[props.directory.media_type ?? ''])\n})\n\n// 监听 资源存储与媒体库储存 变化，重新加载整理方式下拉字典\nwatch(\n  [() => props.directory.library_storage, () => props.directory.storage],\n  ([newLibraryStorage, newStorage], [oldLibraryStorage, oldStorage]) => {\n    if (newLibraryStorage !== oldLibraryStorage || newStorage !== oldStorage) {\n      loadTransferTypeItems()\n    }\n  },\n  { immediate: true },\n)\n\n// 媒体类别和类型变更非空时，将按类型分类和按类别分类置为false\nwatch(\n  [() => props.directory.media_type, () => props.directory.media_category],\n  ([newMediaType, newMediaCategory], [oldMediaType, oldMediaCategory]) => {\n    if (newMediaType && newMediaType !== oldMediaType) {\n      props.directory.download_type_folder = false\n      props.directory.library_type_folder = false\n    }\n    if (newMediaCategory && newMediaCategory !== oldMediaCategory) {\n      props.directory.download_category_folder = false\n      props.directory.library_category_folder = false\n    }\n  },\n)\n\n// 监听monitor_type变化，如果为downloader则设置为本地\nwatch(\n  () => props.directory.monitor_type,\n  newMonitorType => {\n    if (newMonitorType === 'downloader') {\n      props.directory.storage = 'local'\n    }\n  },\n)\n</script>\n\n<template>\n  <VCard variant=\"tonal\" class=\"app-card-shell\" :width=\"props.width\" :height=\"props.height\">\n    <VDialogCloseBtn @click=\"onClose\" />\n    <VCardItem>\n      <VTextField\n        v-model=\"props.directory.name\"\n        variant=\"underlined\"\n        :label=\"t('directory.alias')\"\n        class=\"me-20 text-high-emphasis font-weight-bold\"\n      />\n      <span class=\"app-card-top-action absolute top-3 right-12\">\n        <IconBtn @click.stop>\n          <VIcon class=\"cursor-move\" icon=\"mdi-drag\" />\n        </IconBtn>\n      </span>\n    </VCardItem>\n    <VCardText v-if=\"!isCollapsed\">\n      <VForm>\n        <VRow>\n          <VCol cols=\"6\">\n            <VAutocomplete\n              v-model=\"props.directory.media_type\"\n              variant=\"underlined\"\n              :items=\"typeItems\"\n              :label=\"t('directory.mediaType')\"\n              @update:modelValue=\"props.directory.media_category = ''\"\n            />\n          </VCol>\n          <VCol cols=\"6\">\n            <VAutocomplete\n              v-model=\"props.directory.media_category\"\n              variant=\"underlined\"\n              :items=\"getCategories\"\n              :label=\"t('directory.mediaCategory')\"\n            />\n          </VCol>\n          <VCol cols=\"4\">\n            <VAutocomplete\n              v-model=\"props.directory.storage\"\n              variant=\"underlined\"\n              :items=\"resourceStorageOptions\"\n              :label=\"t('directory.resourceStorage')\"\n            />\n          </VCol>\n          <VCol cols=\"8\">\n            <VPathField\n              v-model=\"props.directory.download_path\"\n              :storage=\"props.directory.storage\"\n              variant=\"underlined\"\n              :label=\"t('directory.resourceDirectory')\"\n            />\n          </VCol>\n          <VCol cols=\"6\" v-if=\"!props.directory.media_type || props.directory.media_type === ''\">\n            <VSwitch v-model=\"props.directory.download_type_folder\" :label=\"t('directory.sortByType')\"></VSwitch>\n          </VCol>\n          <VCol cols=\"6\" v-if=\"!props.directory.media_category || props.directory.media_category === ''\">\n            <VSwitch\n              v-model=\"props.directory.download_category_folder\"\n              :label=\"t('directory.sortByCategory')\"\n            ></VSwitch>\n          </VCol>\n        </VRow>\n        <VDivider v-if=\"$props.directory.monitor_type\" class=\"my-3 bg-primary\" />\n        <VRow>\n          <VCol>\n            <VSelect\n              v-model=\"props.directory.monitor_type\"\n              variant=\"underlined\"\n              :items=\"transferSourceItems\"\n              :label=\"t('directory.autoTransfer')\"\n            />\n          </VCol>\n        </VRow>\n        <VRow v-if=\"$props.directory.monitor_type\">\n          <VCol cols=\"12\" v-if=\"$props.directory.monitor_type == 'monitor'\">\n            <VSelect\n              v-model=\"props.directory.monitor_mode\"\n              variant=\"underlined\"\n              :items=\"MonitorModeItems\"\n              :label=\"t('directory.monitorMode')\"\n            />\n          </VCol>\n          <VCol cols=\"4\">\n            <VAutocomplete\n              v-model=\"props.directory.library_storage\"\n              variant=\"underlined\"\n              :items=\"libraryStorageOptions\"\n              :label=\"t('directory.libraryStorage')\"\n            />\n          </VCol>\n          <VCol cols=\"8\">\n            <VPathField\n              v-model=\"props.directory.library_path\"\n              :storage=\"props.directory.library_storage\"\n              variant=\"underlined\"\n              :label=\"t('directory.libraryDirectory')\"\n            />\n          </VCol>\n          <VCol cols=\"4\">\n            <VSelect\n              v-model=\"props.directory.transfer_type\"\n              variant=\"underlined\"\n              :items=\"transferTypeItems\"\n              :label=\"t('directory.transferType')\"\n              :no-data-text=\"computedNoDataText\"\n            />\n          </VCol>\n          <VCol cols=\"8\">\n            <VSelect\n              v-model=\"props.directory.overwrite_mode\"\n              variant=\"underlined\"\n              :items=\"overwriteModeItems\"\n              :label=\"t('directory.overwriteMode')\"\n            />\n          </VCol>\n          <VCol cols=\"6\" v-if=\"!props.directory.media_type || props.directory.media_type === ''\">\n            <VSwitch v-model=\"props.directory.library_type_folder\" :label=\"t('directory.sortByType')\"></VSwitch>\n          </VCol>\n          <VCol cols=\"6\" v-if=\"!props.directory.media_category || props.directory.media_category === ''\">\n            <VSwitch v-model=\"props.directory.library_category_folder\" :label=\"t('directory.sortByCategory')\"></VSwitch>\n          </VCol>\n          <VCol cols=\"6\">\n            <VSwitch v-model=\"props.directory.renaming\" :label=\"t('directory.smartRename')\"></VSwitch>\n          </VCol>\n          <VCol cols=\"6\">\n            <VSwitch v-model=\"props.directory.scraping\" :label=\"t('directory.scrapingMetadata')\"></VSwitch>\n          </VCol>\n          <VCol cols=\"6\">\n            <VSwitch v-model=\"props.directory.notify\" :label=\"t('directory.sendNotification')\"></VSwitch>\n          </VCol>\n        </VRow>\n      </VForm>\n    </VCardText>\n    <VCardActions class=\"text-center py-0\">\n      <VSpacer />\n      <VBtn :icon=\"isCollapsed ? 'mdi-chevron-down' : 'mdi-chevron-up'\" @click.stop=\"isCollapsed = !isCollapsed\" />\n      <VSpacer />\n    </VCardActions>\n  </VCard>\n</template>\n"
  },
  {
    "path": "src/components/cards/DownloaderCard.vue",
    "content": "<script setup lang=\"ts\">\nimport api from '@/api'\nimport { formatFileSize } from '@/@core/utils/formatters'\nimport { DownloaderConf } from '@/api/types'\nimport { useToast } from 'vue-toastification'\nimport type { DownloaderInfo } from '@/api/types'\nimport { getLogoUrl } from '@/utils/imageUtils'\nimport { cloneDeep } from 'lodash-es'\nimport { useI18n } from 'vue-i18n'\nimport { downloaderDict, storageAttributes } from '@/api/constants'\nimport { useDisplay } from 'vuetify'\nimport { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 获取i18n实例\nconst { t } = useI18n()\nconst { useConditionalDataRefresh } = useBackgroundOptimization()\n\n// 定义输入\nconst props = defineProps({\n  // 单个下载器\n  downloader: {\n    type: Object as PropType<DownloaderConf>,\n    required: true,\n  },\n  // 是否允许刷新数据\n  allowRefresh: {\n    type: Boolean,\n    default: true,\n  },\n  // 所有下载器\n  downloaders: {\n    type: Array as PropType<DownloaderConf[]>,\n    required: true,\n  },\n})\n\n// 定义触发的自定义事件\nconst emit = defineEmits(['close', 'done', 'change'])\n\n// 提示框\nconst $toast = useToast()\n\n// 上传速率\nconst upload_rate = ref(0)\n\n// 下载速度\nconst download_rate = ref(0)\n\n// 下载器详情弹窗\nconst downloaderInfoDialog = ref(false)\n\n// 表单\nconst downloaderForm = ref()\n\n// 路径前缀选项\nconst prefixOptions = computed(() => {\n  return storageAttributes.map(item => ({\n    title: t(`storage.${item.type}`),\n    value: item.type,\n  }))\n})\n\nfunction getStorageType(path: string) {\n  if (!path) return 'local'\n  // 查找匹配的存储类型\n  const storage = storageAttributes.find(s => s.type !== 'local' && path.startsWith(`${s.type}:`))\n  return storage?.type || 'local'\n}\n\nfunction storage2Prefix(storage: string) {\n  return storage === 'local' ? '' : storage + ':'\n}\n\n// 获取存储路径前后缀\nfunction parseStoragePath(path: string): [prefix: string, suffix: string] {\n  if (!path) return ['', '']\n  const storage = getStorageType(path)\n  const prefix = storage2Prefix(storage)\n  return [prefix, path.slice(prefix.length)]\n}\n\n// 更新存储路径前缀\nfunction updateStoragePrefix(row: PathMappingRow, storage: string) {\n  const [, currentSuffix] = parseStoragePath(row.storage)\n  const prefix = storage2Prefix(storage)\n  row.storage = prefix + currentSuffix\n}\n\n// 更新存储路径后缀\nfunction updateStorageSuffix(row: PathMappingRow, suffix: string) {\n  const [currentPrefix] = parseStoragePath(row.storage)\n  row.storage = currentPrefix + suffix\n}\n\nconst pathValidationRules = [\n  (v: string) => !!v || t('downloader.pathMappingRequired'),\n  (v: string) => v.startsWith('/') || t('downloader.pathMappingError'),\n]\n\n// 下载器详情\nconst downloaderInfo = ref<DownloaderConf>({\n  name: '',\n  type: '',\n  default: false,\n  enabled: false,\n  config: {},\n  path_mapping: [],\n})\n\n// 路径映射行定义\ninterface PathMappingRow {\n  id: string\n  storage: string\n  download: string\n}\n\n// 路径映射行数据\nconst pathMappingRows = ref<PathMappingRow[]>([])\n\n// 生成随机ID\nfunction generateId() {\n  return Math.random().toString(36).substring(2, 9)\n}\n\n// 下载器是否应该刷新数据的计算属性\nconst shouldRefresh = computed(() => props.allowRefresh && props.downloader.enabled)\n\n// 调用API查询下载器数据\nasync function loadDownloaderInfo() {\n  if (!shouldRefresh.value) {\n    // 当下载器被禁用时，重置速率数据\n    upload_rate.value = 0\n    download_rate.value = 0\n    return\n  }\n  try {\n    const res: DownloaderInfo = await api.get('dashboard/downloader', {\n      params: {\n        name: props.downloader.name,\n      },\n    })\n\n    if (res) {\n      upload_rate.value = res.upload_speed\n      download_rate.value = res.download_speed\n    }\n  } catch (e) {\n    console.log(e)\n  }\n}\n\n// 打开详情弹窗\nfunction openDownloaderInfoDialog() {\n  // 深复制\n  downloaderInfo.value = cloneDeep(props.downloader)\n  // 初始化路径映射行数据\n  pathMappingRows.value = (downloaderInfo.value.path_mapping || []).map(item => ({\n    id: generateId(),\n    storage: item[0],\n    download: item[1],\n  }))\n  downloaderInfoDialog.value = true\n}\n\n// 保存详情数据\nasync function saveDownloaderInfo() {\n  // 表单校验\n  const { valid } = await downloaderForm.value?.validate()\n  if (!valid) return\n\n  // 同步路径映射数据\n  downloaderInfo.value.path_mapping = pathMappingRows.value.map(row => [row.storage, row.download])\n\n  // 为空不保存，跳出警告框\n  if (!downloaderInfo.value.name) {\n    $toast.error(t('downloader.nameRequired'))\n    return\n  }\n  // 重名判断\n  if (props.downloaders.some(item => item.name === downloaderInfo.value.name && item !== props.downloader)) {\n    $toast.error(t('downloader.nameDuplicate'))\n    return\n  }\n  // 默认下载器去重\n  if (downloaderInfo.value.default) {\n    props.downloaders.forEach(item => {\n      if (item.default && item !== props.downloader) {\n        item.default = false\n        $toast.info(t('downloader.defaultChanged'))\n      }\n    })\n  }\n  // 执行保存\n  downloaderInfoDialog.value = false\n  emit('change', downloaderInfo.value, props.downloader.name)\n  emit('done')\n}\n\n// 根据存储类型选择图标\nconst getIcon = computed(() => {\n  switch (props.downloader.type) {\n    case 'qbittorrent':\n      return getLogoUrl('qbittorrent')\n    case 'transmission':\n      return getLogoUrl('transmission')\n    case 'rtorrent':\n      return getLogoUrl('rtorrent')\n    default:\n      return getLogoUrl('downloader')\n  }\n})\n\n// 添加路径映射\nfunction addPathMapping() {\n  pathMappingRows.value.push({\n    id: generateId(),\n    storage: '',\n    download: '',\n  })\n}\n\n// 移除路径映射\nfunction removePathMapping(index: number) {\n  pathMappingRows.value.splice(index, 1)\n}\n\n// 按钮点击\nfunction onClose() {\n  emit('close')\n}\n\n// 使用条件性数据刷新定时器（只在下载器启用时运行）\nconst { stop: stopRefresh } = useConditionalDataRefresh(\n  `downloader-${props.downloader.name}`,\n  loadDownloaderInfo,\n  shouldRefresh, // 响应式条件：只有当allowRefresh为true且downloader启用时才运行\n  3000, // 3秒间隔\n  true, // 立即执行一次\n)\n\nonUnmounted(() => {\n  stopRefresh()\n})\n</script>\n\n<template>\n  <div>\n    <VHover v-slot=\"hover\">\n      <VCard\n        v-bind=\"hover.props\"\n        variant=\"tonal\"\n        class=\"app-card-shell\"\n        @click=\"openDownloaderInfoDialog\"\n        :class=\"{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }\"\n      >\n        <VDialogCloseBtn @click=\"onClose\" />\n        <span class=\"app-card-top-action absolute top-3 right-12\">\n          <IconBtn @click.stop>\n            <VIcon class=\"cursor-move\" icon=\"mdi-drag\" />\n          </IconBtn>\n        </span>\n        <VCardText class=\"app-card-summary app-card-summary--double-action\">\n          <div class=\"app-card-summary__content\">\n            <div class=\"app-card-summary__title-row\">\n              <VBadge\n                v-if=\"props.downloader.default && props.downloader.enabled\"\n                dot\n                inline\n                color=\"success\"\n                class=\"me-1\"\n              />\n              <span class=\"app-card-summary__title text-h6\">{{ downloader.name }}</span>\n            </div>\n            <div\n              v-if=\"downloaderDict[downloader.type] && props.downloader.enabled\"\n              class=\"app-card-summary__meta text-sm\"\n            >\n              <span class=\"app-card-summary__meta-item\">{{ `↑ ${formatFileSize(upload_rate, 1)}/s` }}</span>\n              <span class=\"app-card-summary__meta-item\">{{ `↓ ${formatFileSize(download_rate, 1)}/s` }}</span>\n            </div>\n            <div v-else-if=\"!downloaderDict[downloader.type]\" class=\"app-card-summary__subtitle text-sm\">\n              自定义下载器\n            </div>\n          </div>\n          <div class=\"app-card-summary__media\" aria-hidden=\"true\">\n            <VImg :src=\"getIcon\" contain class=\"app-card-summary__image\" />\n          </div>\n        </VCardText>\n      </VCard>\n    </VHover>\n\n    <VDialog\n      v-if=\"downloaderInfoDialog\"\n      v-model=\"downloaderInfoDialog\"\n      scrollable\n      max-width=\"40rem\"\n      :fullscreen=\"!display.mdAndUp.value\"\n    >\n      <VCard>\n        <VCardItem class=\"py-2\">\n          <template #prepend>\n            <VIcon icon=\"mdi-download\" class=\"me-2\" />\n          </template>\n          <VCardTitle>{{ t('common.config') }}</VCardTitle>\n          <VCardSubtitle>{{ props.downloader.name }}</VCardSubtitle>\n        </VCardItem>\n        <VDialogCloseBtn v-model=\"downloaderInfoDialog\" />\n        <VDivider />\n        <VCardText>\n          <VForm ref=\"downloaderForm\">\n            <VRow>\n              <VCol cols=\"12\" md=\"6\">\n                <VSwitch v-model=\"downloaderInfo.enabled\" :label=\"t('downloader.enabled')\" />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VSwitch\n                  v-model=\"downloaderInfo.default\"\n                  :label=\"t('downloader.default')\"\n                  :disabled=\"!downloaderInfo.enabled\"\n                />\n              </VCol>\n            </VRow>\n            <VRow v-if=\"downloaderInfo.type == 'qbittorrent'\">\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"downloaderInfo.name\"\n                  :label=\"t('downloader.name')\"\n                  :placeholder=\"t('downloader.nameRequired')\"\n                  :hint=\"t('downloader.name')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-label\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"downloaderInfo.config.host\"\n                  :label=\"t('downloader.host')\"\n                  placeholder=\"http(s)://ip:port\"\n                  :hint=\"t('downloader.host')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-server\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"downloaderInfo.config.username\"\n                  :label=\"t('downloader.username')\"\n                  :hint=\"t('downloader.username')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-account\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"downloaderInfo.config.password\"\n                  type=\"password\"\n                  :label=\"t('downloader.password')\"\n                  :hint=\"t('downloader.password')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-lock\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VSwitch\n                  v-model=\"downloaderInfo.config.category\"\n                  :label=\"t('downloader.category')\"\n                  :hint=\"t('downloader.category')\"\n                  persistent-hint\n                  active\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VSwitch\n                  v-model=\"downloaderInfo.config.sequentail\"\n                  :label=\"t('downloader.sequentail')\"\n                  :hint=\"t('downloader.sequentail')\"\n                  persistent-hint\n                  active\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VSwitch\n                  v-model=\"downloaderInfo.config.force_resume\"\n                  :label=\"t('downloader.force_resume')\"\n                  :hint=\"t('downloader.force_resume')\"\n                  persistent-hint\n                  active\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VSwitch\n                  v-model=\"downloaderInfo.config.first_last_piece\"\n                  :label=\"t('downloader.first_last_piece')\"\n                  :hint=\"t('downloader.first_last_piece')\"\n                  persistent-hint\n                  active\n                />\n              </VCol>\n            </VRow>\n            <VRow v-else-if=\"downloaderInfo.type == 'transmission'\">\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"downloaderInfo.name\"\n                  :label=\"t('downloader.name')\"\n                  :placeholder=\"t('downloader.nameRequired')\"\n                  :hint=\"t('downloader.name')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-label\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"downloaderInfo.config.host\"\n                  :label=\"t('downloader.host')\"\n                  placeholder=\"http(s)://ip:port\"\n                  :hint=\"t('downloader.host')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-server\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"downloaderInfo.config.username\"\n                  :label=\"t('downloader.username')\"\n                  :hint=\"t('downloader.username')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-account\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"downloaderInfo.config.password\"\n                  type=\"password\"\n                  :label=\"t('downloader.password')\"\n                  :hint=\"t('downloader.password')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-lock\"\n                />\n              </VCol>\n            </VRow>\n            <VRow v-else-if=\"downloaderInfo.type == 'rtorrent'\">\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"downloaderInfo.name\"\n                  :label=\"t('downloader.name')\"\n                  :placeholder=\"t('downloader.nameRequired')\"\n                  :hint=\"t('downloader.name')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-label\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"downloaderInfo.config.host\"\n                  :label=\"t('downloader.host')\"\n                  placeholder=\"http(s)://ip:port/RPC2\"\n                  :hint=\"t('downloader.rtorrentHostHint')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-server\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"downloaderInfo.config.username\"\n                  :label=\"t('downloader.username')\"\n                  :hint=\"t('downloader.username')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-account\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"downloaderInfo.config.password\"\n                  type=\"password\"\n                  :label=\"t('downloader.password')\"\n                  :hint=\"t('downloader.password')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-lock\"\n                />\n              </VCol>\n            </VRow>\n            <VRow v-else>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"downloaderInfo.type\"\n                  :label=\"t('downloader.type')\"\n                  :hint=\"t('downloader.customTypeHint')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-cog\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"downloaderInfo.name\"\n                  :label=\"t('downloader.name')\"\n                  :hint=\"t('downloader.nameRequired')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-label\"\n                />\n              </VCol>\n            </VRow>\n            <VRow>\n              <VCol cols=\"12\">\n                <VDivider class=\"my-2\">\n                  <span class=\"text-body-1 font-weight-medium\">{{ t('downloader.pathMapping') }}</span>\n                </VDivider>\n\n                <div v-if=\"pathMappingRows.length === 0\" class=\"text-center py-2\">\n                  <VIcon icon=\"mdi-folder-network\" size=\"48\" class=\"text-disabled mb-1\" />\n                  <div class=\"text-body-2 text-disabled\">{{ t('common.noData') }}</div>\n                </div>\n\n                <VCard v-for=\"(row, index) in pathMappingRows\" :key=\"row.id\" variant=\"outlined\" class=\"my-2\">\n                  <VCardText class=\"pa-3\">\n                    <VRow align=\"center\" no-gutters>\n                      <VCol cols=\"12\" class=\"mb-2\">\n                        <div class=\"d-flex align-center mb-1\">\n                          <VIcon icon=\"mdi-folder-outline\" size=\"18\" class=\"me-1 text-primary\" />\n                          <span class=\"text-caption text-medium-emphasis\">{{ t('downloader.storagePath') }}</span>\n                        </div>\n                        <VRow no-gutters>\n                          <VCol cols=\"12\" sm=\"4\" class=\"pe-2\">\n                            <VSelect\n                              :model-value=\"getStorageType(row.storage)\"\n                              :items=\"prefixOptions\"\n                              density=\"compact\"\n                              variant=\"outlined\"\n                              hide-details\n                              @update:model-value=\"v => updateStoragePrefix(row, v)\"\n                            />\n                          </VCol>\n                          <VCol cols=\"12\" sm=\"8\">\n                            <VTextField\n                              :model-value=\"parseStoragePath(row.storage)[1]\"\n                              :placeholder=\"'/path/to/storage'\"\n                              density=\"compact\"\n                              variant=\"outlined\"\n                              hide-details=\"auto\"\n                              :rules=\"pathValidationRules\"\n                              @update:model-value=\"v => updateStorageSuffix(row, v)\"\n                            />\n                          </VCol>\n                        </VRow>\n                      </VCol>\n\n                      <VCol cols=\"12\" class=\"mb-1\">\n                        <div class=\"d-flex align-center justify-center my-1\">\n                          <VIcon icon=\"mdi-arrow-down\" size=\"18\" class=\"text-medium-emphasis\" />\n                        </div>\n                        <div class=\"d-flex align-center mb-1\">\n                          <VIcon icon=\"mdi-download-outline\" size=\"18\" class=\"me-1 text-success\" />\n                          <span class=\"text-caption text-medium-emphasis\">{{ t('downloader.downloadPath') }}</span>\n                        </div>\n                        <VTextField\n                          v-model=\"row.download\"\n                          :placeholder=\"'/path/to/download'\"\n                          density=\"compact\"\n                          variant=\"outlined\"\n                          hide-details=\"auto\"\n                          :rules=\"pathValidationRules\"\n                        />\n                      </VCol>\n\n                      <VCol cols=\"12\" class=\"d-flex justify-end pt-1\">\n                        <IconBtn variant=\"text\" color=\"error\" size=\"small\" @click=\"removePathMapping(index)\">\n                          <VIcon icon=\"mdi-delete-outline\" />\n                        </IconBtn>\n                      </VCol>\n                    </VRow>\n                  </VCardText>\n                </VCard>\n\n                <VBtn\n                  variant=\"tonal\"\n                  color=\"primary\"\n                  prepend-icon=\"mdi-plus-circle-outline\"\n                  @click=\"addPathMapping\"\n                  class=\"mt-1\"\n                  size=\"small\"\n                >\n                  {{ t('common.add') }} {{ t('downloader.pathMapping') }}\n                </VBtn>\n              </VCol>\n            </VRow>\n          </VForm>\n        </VCardText>\n        <VCardActions class=\"pt-3\">\n          <VBtn @click=\"saveDownloaderInfo\" prepend-icon=\"mdi-content-save\" class=\"px-5\">\n            {{ t('common.save') }}\n          </VBtn>\n        </VCardActions>\n      </VCard>\n    </VDialog>\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/cards/DownloadingCard.vue",
    "content": "<script lang=\"ts\" setup>\nimport api from '@/api'\nimport type { DownloadingInfo } from '@/api/types'\nimport { formatFileSize } from '@/@core/utils/formatters'\n\n// 输入参数\nconst props = defineProps({\n  info: Object as PropType<DownloadingInfo>,\n  downloaderName: String,\n})\n\n// 是否显示卡片\nconst cardState = ref(true)\n\n// 进度条\nfunction getPercentage() {\n  return props.info?.progress ?? 0\n}\n\n// 速度\nfunction getSpeedText() {\n  return `${formatFileSize(props.info?.size || 0)} ↑ ${props.info?.upspeed}/s ↓ ${props.info?.dlspeed}/s ${\n    props.info?.left_time\n  }`\n}\n\n// 下载状态\nconst isDownloading = ref(props.info?.state === 'downloading')\n\n// 监听props.info?.state的变化\nwatch(\n  () => props.info?.state,\n  newValue => {\n    isDownloading.value = newValue === 'downloading'\n  },\n)\n\n// 图片是否加载完成\nconst imageLoaded = ref(false)\n\n// 图片加载完成响应\nfunction imageLoadHandler() {\n  imageLoaded.value = true\n}\n\n// 下载状态控制\nasync function toggleDownload() {\n  const operation = isDownloading.value ? 'stop' : 'start'\n  try {\n    const result: { [key: string]: any } = await api.get(`download/${operation}/${props.info?.hash}`, {\n      params: {\n        name: props.downloaderName,\n      },\n    })\n\n    if (result.success) isDownloading.value = !isDownloading.value\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 删除下截\nasync function deleteDownload() {\n  try {\n    await api.delete(`download/${props.info?.hash}`, { params: { name: props.downloaderName } })\n    cardState.value = false\n  } catch (error) {\n    console.error(error)\n  }\n}\n</script>\n\n<template>\n  <VCard v-if=\"cardState\" :key=\"props.info?.hash\" class=\"flex flex-col h-full\" min-height=\"150\">\n    <template #image>\n      <VImg :src=\"props.info?.media.image\" aspect-ratio=\"2/3\" cover @load=\"imageLoadHandler\" position=\"top\">\n        <template #placeholder>\n          <div class=\"w-full h-full\">\n            <VSkeletonLoader class=\"object-cover aspect-w-2 aspect-h-3\" />\n          </div>\n        </template>\n        <template #default>\n          <div class=\"absolute inset-0 outline-none downloading-card-background\"></div>\n        </template>\n      </VImg>\n    </template>\n\n    <div>\n      <VCardTitle class=\"break-words whitespace-normal text-white\">\n        {{ props.info?.media.title || props.info?.name }}\n        {{\n          props.info?.media.episode\n            ? `${props.info?.media.season} ${props.info?.media.episode}`\n            : props.info?.season_episode\n        }}\n      </VCardTitle>\n\n      <VCardSubtitle class=\"break-words whitespace-normal text-white\">\n        {{ props.info?.title }}\n      </VCardSubtitle>\n\n      <VCardText class=\"text-subtitle-1 pt-3 pb-1 text-white\">\n        {{ getSpeedText() }}\n      </VCardText>\n\n      <VCardText v-if=\"getPercentage() > 0\" class=\"text-white\">\n        <VProgressLinear :model-value=\"getPercentage()\" bg-color=\"success\" color=\"success\" />\n      </VCardText>\n\n      <VCardActions class=\"justify-space-between\">\n        <VBtn :icon=\"`${isDownloading ? 'mdi-pause' : 'mdi-play'}`\" @click=\"toggleDownload\" />\n        <VBtn color=\"error\" icon=\"mdi-trash-can-outline\" @click=\"deleteDownload\" />\n      </VCardActions>\n    </div>\n  </VCard>\n</template>\n\n<style lang=\"scss\" scoped>\n.downloading-card-background {\n  background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);\n}\n</style>\n"
  },
  {
    "path": "src/components/cards/FilterRuleCard.vue",
    "content": "<script lang=\"ts\" setup>\nimport { innerFilterRules } from '@/api/constants'\nimport { CustomRule } from '@/api/types'\nimport { cloneDeep } from 'lodash-es'\nimport { useI18n } from 'vue-i18n'\n\n// 获取i18n实例\nconst { t } = useI18n()\n\n// 输入参数\nconst props = defineProps({\n  pri: String,\n  rules: Array as PropType<string[]>,\n  custom_rules: Array as PropType<CustomRule[]>,\n})\n\n// 定义触发的自定义事件\nconst emit = defineEmits(['close', 'changed'])\n\n// 按钮点击\nfunction onClose() {\n  emit('close')\n}\n\n// 选项变化\nfunction filtersChanged(value: string[]) {\n  emit('changed', props.pri, value)\n}\n\n// 过滤规则下拉框\nconst selectFilterOptions = ref<{ [key: string]: string }[]>([])\n\nonMounted(() => {\n  selectFilterOptions.value = cloneDeep(innerFilterRules)\n  if (props.custom_rules) {\n    console.log(props.custom_rules)\n    props.custom_rules.map(rule => {\n      selectFilterOptions.value.push({\n        title: rule.name,\n        value: rule.id,\n      })\n    })\n  }\n})\n</script>\n\n<template>\n  <VCard variant=\"tonal\" class=\"app-card-shell\">\n    <span class=\"app-card-top-action absolute top-3 right-12\">\n      <IconBtn @click.stop>\n        <VIcon class=\"cursor-move\" icon=\"mdi-drag\" />\n      </IconBtn>\n    </span>\n    <VDialogCloseBtn @click=\"onClose\" />\n    <VCardItem>\n      <VCardTitle class=\"pr-8\">{{ t('filterRule.priority') }} {{ props.pri }}</VCardTitle>\n      <VRow>\n        <VCol>\n          <VAutocomplete\n            v-model=\"props.rules\"\n            variant=\"underlined\"\n            :items=\"selectFilterOptions\"\n            chips\n            :label=\"t('filterRule.rules')\"\n            multiple\n            clearable\n            @update:modelValue=\"filtersChanged\"\n          />\n        </VCol>\n      </VRow>\n    </VCardItem>\n  </VCard>\n</template>\n"
  },
  {
    "path": "src/components/cards/FilterRuleGroupCard.vue",
    "content": "<script lang=\"ts\" setup>\nimport draggable from 'vuedraggable'\nimport { copyToClipboard } from '@/@core/utils/navigator'\nimport { CustomRule, FilterRuleGroup } from '@/api/types'\nimport FilterRuleCard from '@/components/cards/FilterRuleCard.vue'\nimport { useToast } from 'vue-toastification'\nimport ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'\nimport filter_group_svg from '@images/svg/filter-group.svg'\nimport { cloneDeep } from 'lodash-es'\nimport { useI18n } from 'vue-i18n'\nimport { useDisplay } from 'vuetify'\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 获取i18n实例\nconst { t } = useI18n()\n\n// 输入参数\nconst props = defineProps({\n  // 单个规则组\n  group: {\n    type: Object as PropType<FilterRuleGroup>,\n    required: true,\n  },\n  // 所有规则组\n  groups: {\n    type: Array as PropType<FilterRuleGroup[]>,\n    required: true,\n  },\n  // 媒体类型字典\n  categories: {\n    type: Object as PropType<{ [key: string]: any }>,\n    required: true,\n  },\n  // 自定义规则列表\n  custom_rules: Array as PropType<CustomRule[]>,\n})\n\n// 规则卡片类型\ninterface FilterCard {\n  // 优先级\n  pri: string\n  // 已选规则\n  rules: string[]\n}\n\n// 提示框\nconst $toast = useToast()\n\n// 定义触发的自定义事件\nconst emit = defineEmits(['close', 'change', 'done'])\n\n// 规则详情弹窗\nconst groupInfoDialog = ref(false)\n\n// 规则详情\nconst groupInfo = ref<FilterRuleGroup>({\n  name: props.group?.name ?? '',\n  rule_string: props.group?.rule_string ?? '',\n  media_type: props.group?.media_type ?? '',\n  category: props.group?.category ?? '',\n})\n\n// 媒体类型字典\nconst mediaTypeItems = [\n  { title: t('common.all'), value: '' },\n  { title: t('mediaType.movie'), value: '电影' },\n  { title: t('mediaType.tv'), value: '电视剧' },\n]\n\n// 根据选中的媒体类型，获取对应的媒体类别\nconst getCategories = computed(() => {\n  const default_value = [{ title: t('common.all'), value: '' }]\n  if (!props.categories || !groupInfo.value.media_type || !props.categories[groupInfo.value.media_type]) {\n    return default_value\n  }\n  return default_value.concat(props.categories[groupInfo.value.media_type] || [])\n})\n\n// 规则组规则卡片列表\nconst filterRuleCards = ref<FilterCard[]>([])\n\n// 导入代码弹窗\nconst importCodeDialog = ref(false)\n\n// 导入代码类型\nconst importCodeType = ref('')\n\n// 更新规则卡片的值\nfunction updateFilterCardValue(pri: string, rules: string[]) {\n  const card = filterRuleCards.value.find(card => card.pri === pri)\n  if (card && Array.isArray(rules)) card.rules = rules\n}\n\n// 移除卡片\nfunction filterCardClose(pri: string) {\n  filterRuleCards.value = filterRuleCards.value\n    .filter(card => card.pri !== pri)\n    .map((card, index) => {\n      card.pri = (index + 1).toString()\n      return card\n    })\n}\n\n// 分享规则\nasync function shareRules() {\n  if (filterRuleCards.value.length === 0) return\n\n  const value = filterRuleCards.value\n    .filter(card => Array.isArray(card.rules) && card.rules.length > 0)\n    .map(card => card.rules.join('&'))\n    .join('>')\n\n  try {\n    let success\n    success = copyToClipboard(value)\n    if (await success) $toast.success(t('filterRule.shareSuccess'))\n    else $toast.error(t('filterRule.shareFailed'))\n  } catch (error) {\n    $toast.error(t('filterRule.shareFailed'))\n    console.error(error)\n  }\n}\n\n// 导入规则\nasync function importRules(ruleType: string) {\n  importCodeType.value = ruleType\n  importCodeDialog.value = true\n}\n\n// 保存导入的代码，直接覆盖原有值\nfunction saveCodeString(type: string, code: any) {\n  try {\n    code = code.value\n    if (type === 'priority') {\n      // 解析值\n      if (!code) return\n      // 首尾增加空格\n      if (!code.startsWith(' ')) code = ` ${code}`\n      if (!code.endsWith(' ')) code = `${code} `\n      const groups = code.split('>')\n      filterRuleCards.value = groups.map((group: string, index: number) => ({\n        pri: (index + 1).toString(),\n        rules: group.split('&').filter(rule => rule),\n      }))\n    }\n  } catch (error) {\n    $toast.error(t('filterRule.importFailed'))\n    console.error(error)\n  }\n}\n\n// 增加卡片\nfunction addFilterCard() {\n  const pri = (filterRuleCards.value.length + 1).toString()\n  const newCard: FilterCard = { pri, rules: [] }\n  filterRuleCards.value.push(newCard)\n}\n\n// 根据列表的拖动顺序更新优先级\nfunction dragOrderEnd() {\n  filterRuleCards.value.forEach((card, index) => {\n    card.pri = (index + 1).toString()\n  })\n}\n\n// 打开详情弹窗\nfunction opengroupInfoDialog() {\n  groupInfo.value = cloneDeep(props.group)\n  if (props.group.rule_string) {\n    filterRuleCards.value = props.group.rule_string.split('>').map((group: string, index: number) => ({\n      pri: (index + 1).toString(),\n      rules: group.split('&').filter(rule => rule),\n    }))\n  }\n  groupInfoDialog.value = true\n}\n\n// 保存详情数据\nfunction saveGroupInfo() {\n  if (!groupInfo.value.name.trim()) {\n    $toast.error(t('filterRule.nameRequired'))\n    return\n  }\n  if (props.groups.some(item => item.name === groupInfo.value.name && item !== props.group)) {\n    $toast.error(t('filterRule.nameDuplicate'))\n    return\n  }\n\n  groupInfoDialog.value = false\n  groupInfo.value.rule_string = filterRuleCards.value\n    .filter(card => Array.isArray(card.rules) && card.rules.length > 0)\n    .map(card => card.rules.join('&'))\n    .join('>')\n  emit('change', groupInfo.value, props.group.name)\n  emit('done')\n}\n\n// 按钮点击\nfunction onClose() {\n  emit('close')\n}\n</script>\n\n<template>\n  <div>\n    <VCard variant=\"tonal\" class=\"app-card-shell\" @click=\"opengroupInfoDialog\">\n      <span class=\"app-card-top-action absolute top-3 right-12\">\n        <IconBtn @click.stop>\n          <VIcon class=\"cursor-move\" icon=\"mdi-drag\" />\n        </IconBtn>\n      </span>\n      <VDialogCloseBtn @click=\"onClose\" />\n      <VCardText class=\"app-card-summary app-card-summary--double-action app-card-summary--title-subtitle\">\n        <div class=\"app-card-summary__content\">\n          <h5 class=\"app-card-summary__title text-h6\">{{ props.group.name }}</h5>\n          <div class=\"app-card-summary__subtitle text-body-1\">\n            <span v-if=\"!props.group.category\">{{ props.group.media_type || t('common.all') }}</span>\n            <span v-else>{{ props.group.category }}</span>\n          </div>\n        </div>\n        <div class=\"app-card-summary__media\" aria-hidden=\"true\">\n          <VImg :src=\"filter_group_svg\" contain class=\"app-card-summary__image\" />\n        </div>\n      </VCardText>\n    </VCard>\n    <VDialog\n      v-if=\"groupInfoDialog\"\n      v-model=\"groupInfoDialog\"\n      scrollable\n      max-width=\"80rem\"\n      :fullscreen=\"!display.mdAndUp.value\"\n    >\n      <VCard :title=\"`${props.group.name} - ${t('filterRule.title')}`\">\n        <VDialogCloseBtn v-model=\"groupInfoDialog\" />\n        <VDivider />\n        <VCardItem class=\"pt-1\">\n          <VRow class=\"mt-1\">\n            <VCol cols=\"12\" md=\"6\">\n              <VTextField\n                v-model=\"groupInfo.name\"\n                :label=\"t('filterRule.groupName')\"\n                :placeholder=\"t('filterRule.nameRequired')\"\n                :hint=\"t('filterRule.groupName')\"\n                persistent-hint\n                active\n                prepend-inner-icon=\"mdi-label\"\n              />\n            </VCol>\n            <VCol cols=\"6\" md=\"3\">\n              <VAutocomplete\n                v-model=\"groupInfo.media_type\"\n                :label=\"t('filterRule.mediaType')\"\n                :items=\"mediaTypeItems\"\n                :hint=\"t('filterRule.mediaType')\"\n                persistent-hint\n                active\n                prepend-inner-icon=\"mdi-movie-open\"\n              />\n            </VCol>\n            <VCol cols=\"6\" md=\"3\">\n              <VAutocomplete\n                v-model=\"groupInfo.category\"\n                :items=\"getCategories\"\n                :label=\"t('filterRule.category')\"\n                :hint=\"t('filterRule.category')\"\n                persistent-hint\n                active\n                prepend-inner-icon=\"mdi-folder-open\"\n              />\n            </VCol>\n          </VRow>\n        </VCardItem>\n        <VCardText>\n          <draggable\n            v-model=\"filterRuleCards\"\n            handle=\".cursor-move\"\n            item-key=\"pri\"\n            tag=\"div\"\n            @end=\"dragOrderEnd\"\n            :component-data=\"{ 'class': 'grid gap-3 grid-filterrule-card' }\"\n          >\n            <template #item=\"{ element }\">\n              <FilterRuleCard\n                :pri=\"element.pri\"\n                :maxpri=\"filterRuleCards.length.toString()\"\n                :rules=\"element.rules\"\n                :custom_rules=\"props.custom_rules\"\n                @changed=\"updateFilterCardValue\"\n                @close=\"filterCardClose(element.pri)\"\n              />\n            </template>\n          </draggable>\n          <div class=\"text-center\" v-if=\"filterRuleCards.length == 0\">{{ t('filterRule.add') }}</div>\n        </VCardText>\n        <VCardActions class=\"pt-3\">\n          <VBtn color=\"primary\" @click=\"addFilterCard\">\n            <VIcon icon=\"mdi-plus\" />\n          </VBtn>\n          <VBtn color=\"success\" @click=\"importRules('priority')\">\n            <VIcon icon=\"mdi-import\" />\n          </VBtn>\n          <VBtn color=\"info\" @click=\"shareRules\">\n            <VIcon icon=\"mdi-share\" />\n          </VBtn>\n          <VSpacer />\n          <VBtn @click=\"saveGroupInfo\" prepend-icon=\"mdi-content-save\" class=\"px-5\">\n            {{ t('common.save') }}\n          </VBtn>\n        </VCardActions>\n      </VCard>\n    </VDialog>\n    <ImportCodeDialog\n      v-if=\"importCodeDialog\"\n      v-model=\"importCodeDialog\"\n      :title=\"t('filterRule.import')\"\n      :dataType=\"importCodeType\"\n      @close=\"importCodeDialog = false\"\n      @save=\"saveCodeString\"\n    />\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/cards/LibraryCard.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { MediaServerLibrary } from '@/api/types'\nimport plex from '@images/misc/plex.png'\nimport emby from '@images/misc/emby.png'\nimport jellyfin from '@images/misc/jellyfin.png'\nimport { getLogoUrl } from '@/utils/imageUtils'\nimport { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'\n\n// 输入参数\nconst props = defineProps({\n  media: Object as PropType<MediaServerLibrary>,\n  width: String,\n  height: String,\n})\n\n// canvas\nconst canvasRef = ref<HTMLCanvasElement>()\n\n// 图片地址\nconst imgUrl = ref('')\n\n// 图片是否加载完成\nconst imageLoaded = ref(false)\n\n// 图片是否加载错误\nconst imageError = ref(false)\n\n// 图片加载完成响应\nfunction imageLoadHandler() {\n  imageLoaded.value = true\n}\n\n// 图片加载错误\nfunction imageErrorHandler() {\n  imageError.value = true\n  imgUrl.value = getDefaultImage()\n}\n\n// 默认图片\nfunction getDefaultImage() {\n  if (props.media?.server_type === 'plex') return plex\n  else if (props.media?.server_type === 'emby') return emby\n  else if (props.media?.server_type === 'jellyfin') return jellyfin\n  else if (props.media?.server_type === 'trimemedia') return getLogoUrl('trimemedia')\n  else if (props.media?.server_type === 'ugreen') return getLogoUrl('ugreen')\n  else return plex\n}\n\n// 跳转播放\nasync function goPlay() {\n  if (props.media?.link) {\n    await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)\n  }\n}\n\n// 生成图片代理路径\nfunction getImgUrl(url: string, use_cookies?: boolean) {\n  if (!url || imageError.value) return getDefaultImage()\n  let imgurl = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`\n  if (use_cookies) {\n    imgurl += `&use_cookies=${encodeURIComponent(use_cookies)}`\n  }\n  return imgurl\n}\n\n// 根据多张图片生成媒体库封面\nasync function drawImages(imageList: string[], use_cookies?: boolean) {\n  // 图片\n  const IMAGES = [...imageList]\n  if (IMAGES.length === 0) return getDefaultImage()\n\n  // 为所有图片添加system/img前缀\n  for (let i = 0; i < IMAGES.length; i++) {\n    IMAGES[i] = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(IMAGES[i])}`\n    if (use_cookies) {\n      IMAGES[i] += `&use_cookies=${encodeURIComponent(use_cookies)}`\n    }\n  }\n\n  // canvas\n  const canvas = canvasRef.value\n  if (!canvas) return getDefaultImage()\n\n  // 画布参数\n  const POSTER_WIDTH = (canvas.width - 40) / 4 // 左右边框8px + 3个间隔24px = 40px\n  const POSTER_HEIGHT = 256 // 上方海报高256\n  const MARGIN_WIDTH = 8 // 左右间隔为8\n  const MARGIN_HEIGHT = 4 // 海报和倒影之间的间隔为4\n  const REFLECTION_HEIGHT = canvas.height - POSTER_HEIGHT - MARGIN_HEIGHT // 下方倒影使用剩余全部高度\n\n  // 获取画布上下文\n  const ctx = canvas.getContext('2d')\n  if (!ctx) return getDefaultImage()\n\n  // 设置背景色为透明\n  ctx.clearRect(0, 0, canvas.width, canvas.height)\n\n  // 绘制图片\n  async function drawImageWithReflection(imgSrc: string, index: number) {\n    if (!canvas) return\n\n    if (!ctx) return\n\n    const img = new Image()\n    img.setAttribute('crossorigin', 'anonymous')\n    img.src = imgSrc\n    try {\n      await new Promise<void>((resolve, reject) => {\n        img.onload = () => resolve()\n        img.onerror = () => reject(new Error(`Failed to load image: ${imgSrc}`))\n      })\n    } catch (error) {\n      console.error(error)\n      ctx.fillStyle = '#e5e7eb'\n      ctx.fillRect(MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1), 0, POSTER_WIDTH, POSTER_HEIGHT)\n      return\n    }\n\n    const x = MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1)\n    const y = 0 // 海报紧贴顶部\n\n    ctx.drawImage(img, x, y, POSTER_WIDTH, POSTER_HEIGHT)\n\n    ctx.save()\n    ctx.translate(0, canvas.height)\n    ctx.scale(1, -1)\n    ctx.drawImage(img, 0, 0, img.width, img.height, x, 0, POSTER_WIDTH, REFLECTION_HEIGHT)\n\n    const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height - (POSTER_HEIGHT + MARGIN_HEIGHT))\n\n    gradient.addColorStop(0, 'rgba(0, 0, 0, 1)')\n    gradient.addColorStop(1, 'rgba(0, 0, 0, 0.7)')\n    ctx.globalCompositeOperation = 'destination-out'\n    ctx.fillStyle = gradient\n    ctx.fillRect(x, 0, POSTER_WIDTH, REFLECTION_HEIGHT)\n\n    ctx.restore()\n  }\n\n  // 绘制多张图片\n  const loopCount = Math.min(4, IMAGES.length)\n  for (let i = 0; i < loopCount; i++) await drawImageWithReflection(IMAGES[i], i + 1)\n\n  // 转换为图片地址\n  return canvas.toDataURL('image/png')\n}\n\nonMounted(async () => {\n  if (props.media?.image_list && props.media?.image_list.length > 0)\n    imgUrl.value = await drawImages(props.media?.image_list || [], props.media?.use_cookies)\n  else imgUrl.value = getImgUrl(props.media?.image || '', props.media?.use_cookies)\n})\n</script>\n\n<template>\n  <VHover>\n    <template #default=\"hover\">\n      <VCard\n        v-bind=\"hover.props\"\n        :height=\"props.height\"\n        :width=\"props.width\"\n        :class=\"{\n          'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,\n        }\"\n        @click=\"goPlay\"\n      >\n        <template #image>\n          <canvas ref=\"canvasRef\" width=\"640\" height=\"360\" class=\"w-full h-full hidden\" />\n          <VImg :src=\"imgUrl\" aspect-ratio=\"2/3\" cover @load=\"imageLoadHandler\" @error=\"imageErrorHandler\">\n            <template #placeholder>\n              <div class=\"w-full h-full\">\n                <VSkeletonLoader class=\"object-cover aspect-w-3 aspect-h-2\" />\n              </div>\n            </template>\n            <template #default>\n              <VCardText\n                class=\"w-full flex flex-col flex-wrap justify-end align-center text-white absolute bottom-0 cursor-pointer pa-2\"\n              >\n                <h1 class=\"mb-1 text-white text-shadow font-bold line-clamp-2 overflow-hidden text-ellipsis ...\">\n                  {{ props.media?.name }}\n                </h1>\n              </VCardText>\n            </template>\n          </VImg>\n        </template>\n      </VCard>\n    </template>\n  </VHover>\n</template>\n"
  },
  {
    "path": "src/components/cards/MediaCard.vue",
    "content": "<script lang=\"ts\" setup>\nimport noImage from '@images/no-image.jpeg'\nimport { getLogoUrl } from '@/utils/imageUtils'\nimport api from '@/api'\nimport { useToast } from 'vue-toastification'\nimport { formatSeason, formatRating } from '@/@core/utils/formatters'\nimport { doneNProgress, startNProgress } from '@/api/nprogress'\nimport type { MediaInfo, Subscribe, MediaSeason, Site } from '@/api/types'\nimport router from '@/router'\nimport { useUserStore, useGlobalSettingsStore } from '@/stores'\nimport SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'\nimport SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'\nimport SubscribeSeasonDialog from '../dialog/SubscribeSeasonDialog.vue'\nimport { useI18n } from 'vue-i18n'\nimport { mediaTypeDict } from '@/api/constants'\nimport { hasPermission } from '@/utils/permission'\n\n// 国际化\nconst { t } = useI18n()\n\ninterface MediaCardMedia extends MediaInfo {\n  total_episode?: number\n  episode_count?: number\n}\n\n// 输入参数\nconst props = defineProps({\n  media: Object as PropType<MediaCardMedia>,\n  width: String,\n  height: String,\n})\n\n// 从 provide 中获取全局设置\n// 全局设置\nconst globalSettingsStore = useGlobalSettingsStore()\nconst globalSettings = globalSettingsStore.globalSettings\n\n// 用户 Store\nconst userStore = useUserStore()\n\n// 提示框\nconst $toast = useToast()\n\n// 图片加载状态\nconst isImageLoaded = ref(false)\n\n// 图片加载失败\nconst imageLoadError = ref(false)\n\n// 当前订阅状态\nconst isSubscribed = ref(false)\n\n// 本地存在状态\nconst isExists = ref(false)\n\n// 订阅季弹窗\nconst subscribeSeasonDialog = ref(false)\n\n// 订阅编辑弹窗\nconst subscribeEditDialog = ref(false)\n\n// 订阅ID\nconst subscribeId = ref<number>()\n\n// 选中的订阅季\nconst seasonsSelected = ref<MediaSeason[]>([])\n\n// 来源角标字典\nconst sourceIconDict: { [key: string]: any } = {\n  themoviedb: getLogoUrl('tmdb'),\n  douban: getLogoUrl('douban-black'),\n  bangumi: getLogoUrl('bangumi'),\n}\n\n// 绑定MediaCard元素\nconst mediaCardRef = ref<HTMLElement | null>(null)\n\n// 创建Intersection Observer实例\nconst observer = ref<IntersectionObserver | null>(null)\n\n// 所有站点\nconst allSites = ref<Site[]>([])\n\n// 选中的站点\nconst selectedSites = ref<number[]>([])\n\n// 搜索菜单显示状态\nconst searchMenuShow = ref(false)\n\n// 选择站点对话框\nconst chooseSiteDialog = ref(false)\n\n// 选择的剧集组\nconst episodeGroup = ref('')\n\n// 查询所有站点\nasync function querySites() {\n  try {\n    const data: Site[] = await api.get('site/')\n\n    // 过滤站点，只有启用的站点才显示\n    allSites.value = data.filter(item => item.is_active)\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 查询用户选中的站点\nasync function querySelectedSites() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/IndexerSites')\n    selectedSites.value = result.data?.value ?? []\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 获得mediaid\nfunction getMediaId() {\n  if (props.media?.tmdb_id) return `tmdb:${props.media?.tmdb_id}`\n  else if (props.media?.douban_id) return `douban:${props.media?.douban_id}`\n  else if (props.media?.bangumi_id) return `bangumi:${props.media?.bangumi_id}`\n  else return `${props.media?.mediaid_prefix}:${props.media?.media_id}`\n}\n\n// 角标颜色\nfunction getChipColor(type: string) {\n  if (type === '电影') return 'border-blue-500 bg-blue-600'\n  else if (type === '电视剧') return ' bg-indigo-500 border-indigo-600'\n  else return 'border-purple-600 bg-purple-600'\n}\n\n// 添加订阅处理\nasync function handleAddSubscribe() {\n  if (props.media?.type === '电视剧') {\n    // 弹出季选择列表，支持多选\n    seasonsSelected.value = []\n    subscribeSeasonDialog.value = true\n  } else {\n    // 电影\n    addSubscribe()\n  }\n}\n\n// 调用API添加订阅，电视剧的话需要指定季\nasync function addSubscribe(season: number | null = null, best_version: number = 0) {\n  // 开始处理\n  startNProgress()\n  try {\n    // 是否洗版\n    if (!best_version && props.media?.type == '电影') best_version = isExists.value ? 1 : 0\n    // 请求API\n    const result: { [key: string]: any } = await api.post('subscribe/', {\n      name: props.media?.title,\n      type: props.media?.type,\n      year: props.media?.year,\n      tmdbid: props.media?.tmdb_id,\n      doubanid: props.media?.douban_id,\n      bangumiid: props.media?.bangumi_id,\n      mediaid: props.media?.media_id ? `${props.media?.mediaid_prefix}:${props.media?.media_id}` : '',\n      season: props.media?.type === '电影' ? null : season,\n      best_version,\n      episode_group: episodeGroup.value,\n    })\n\n    // 订阅状态\n    if (result.success) {\n      // 订阅成功\n      isSubscribed.value = true\n    }\n\n    // 提示\n    showSubscribeAddToast(result.success, props.media?.title ?? '', season, result.message, best_version)\n\n    // 弹出订阅编辑弹窗\n    if (result.success && seasonsSelected.value.length <= 1) {\n      const show_edit_dialog = await queryDefaultSubscribeConfig()\n      if (show_edit_dialog) {\n        subscribeId.value = result.data.id\n        subscribeEditDialog.value = true\n      }\n    }\n  } catch (error) {\n    console.error(error)\n  } finally {\n    doneNProgress()\n  }\n}\n\n// 弹出添加订阅提示\nfunction showSubscribeAddToast(result: boolean, title: string, season: number | null, message: string, best_version: number) {\n  if (season !== null) title = `${title} ${formatSeason(season.toString())}`\n\n  let subname = t('subscribe.normalSub')\n  if (best_version > 0) subname = t('subscribe.versionSub')\n\n  if (result) $toast.success(`${title} ${t('subscribe.addSuccess', { name: subname })}`)\n  else if (!result) $toast.error(`${title} ${t('subscribe.addFailed', { name: subname, message: message })}`)\n}\n\n// 调用API取消订阅\nasync function removeSubscribe() {\n  // 开始处理\n  startNProgress()\n  try {\n    const mediaid = getMediaId()\n\n    const result: { [key: string]: any } = await api.delete(`subscribe/media/${mediaid}`, {\n      params: {\n        season: props.media?.season,\n      },\n    })\n\n    if (result.success) {\n      isSubscribed.value = false\n      $toast.success(`${props.media?.title} ${t('subscribe.cancelSuccess')}`)\n    } else {\n      $toast.error(`${props.media?.title} ${t('subscribe.cancelFailed', { message: result.message })}`)\n    }\n  } catch (error) {\n    console.error(error)\n  } finally {\n    doneNProgress()\n  }\n}\n\n// 查询当前媒体是否已订阅\nasync function handleCheckSubscribe() {\n  try {\n    const result = await checkSubscribe(props.media?.season ?? null)\n    if (result) isSubscribed.value = true\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 查询当前媒体是否已入库\nasync function handleCheckExists() {\n  try {\n    const result: { [key: string]: any } = await api.get('mediaserver/exists', {\n      params: {\n        tmdbid: props.media?.tmdb_id,\n        title: props.media?.title,\n        year: props.media?.year,\n        season: props.media?.season,\n        mtype: props.media?.type,\n      },\n    })\n\n    if (result.success) isExists.value = true\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 调用API检查是否已订阅，电视剧需要指定季\nasync function checkSubscribe(season: number | null) {\n  try {\n    // AbortController 现在由全局请求优化器自动管理\n    const mediaid = getMediaId()\n    const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {\n      params: {\n        season,\n        title: props.media?.title,\n      },\n    })\n\n    return result.id || null\n  } catch (error) {\n    console.error(error)\n  }\n\n  return null\n}\n\n// 查询订阅弹窗规则\nasync function queryDefaultSubscribeConfig() {\n  // 非管理员不显示\n  if (!userStore.superUser) return false\n  try {\n    let subscribe_config_url = ''\n    if (props.media?.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'\n    else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'\n    const result: { [key: string]: any } = await api.get(subscribe_config_url)\n    if (result.data?.value) return result.data.value.show_edit_dialog\n  } catch (error) {\n    console.log(error)\n  }\n  return false\n}\n\n// 爱心订阅按钮响应\nfunction handleSubscribe() {\n  if (isSubscribed.value) removeSubscribe()\n  else handleAddSubscribe()\n}\n\n// 订阅多季\nfunction subscribeSeasons(seasons: MediaSeason[], seasonNoExists: { [key: number]: number }, groudId: string) {\n  subscribeSeasonDialog.value = false\n  episodeGroup.value = groudId\n  seasonsSelected.value = seasons || []\n  seasonsSelected.value.forEach(season => {\n    let best_version = 0\n    if (season && props.media?.tmdb_id)\n      // 全部存在时洗版\n      best_version = !seasonNoExists[season.season_number || 0] ? 1 : 0\n    addSubscribe(season.season_number ?? null, best_version)\n  })\n}\n\n// 打开详情页\nfunction goMediaDetail(isHovering = false) {\n  if (isHovering) {\n    if (props.media?.collection_id) {\n      // 跳转到合集列表\n      router.push({\n        path: `/browse/tmdb/collection/${props.media?.collection_id}`,\n        query: {\n          title: props.media?.title,\n        },\n      })\n    } else {\n      // 跳转到媒体详情页\n      router.push({\n        path: '/media',\n        query: {\n          mediaid: getMediaId(),\n          title: props.media?.title,\n          year: props.media?.year,\n          type: props.media?.type,\n        },\n      })\n    }\n  }\n}\n\n// 点击搜索\nasync function clickSearch() {\n  if (allSites.value?.length == 0) {\n    await querySites()\n    await querySelectedSites()\n  }\n  if (allSites.value?.length > 0) {\n    chooseSiteDialog.value = true\n  } else {\n    handleSearch()\n  }\n}\n\n// 开始搜索\nfunction handleSearch() {\n  router.push({\n    path: '/resource',\n    query: {\n      keyword: getMediaId(),\n      type: props.media?.type,\n      area: 'title',\n      title: props.media?.title,\n      year: props.media?.year,\n      season: props.media?.season,\n      sites: selectedSites.value.join(','),\n    },\n  })\n}\n\n// 搜索多站点\nfunction searchSites(sites: number[]) {\n  chooseSiteDialog.value = false\n  selectedSites.value = sites\n  handleSearch()\n}\n\n// 懒加载检查\nfunction handleCheckLazy() {\n  if (props.media?.collection_id) {\n    return\n  }\n  handleCheckSubscribe()\n  handleCheckExists()\n}\n\n// 在元素进入视窗时触发懒加载函数\nfunction setupIntersectionObserver() {\n  if (mediaCardRef.value) {\n    observer.value = new IntersectionObserver(\n      entries => {\n        entries.forEach(entry => {\n          if (entry.isIntersecting) {\n            // 只要MediaCard进入视窗，就调用懒加载的操作\n            handleCheckLazy()\n            // 加载后销毁观察者实例\n            observer.value?.disconnect()\n            observer.value = null\n          }\n        })\n      },\n      { threshold: 0.1 },\n    )\n    observer.value.observe(mediaCardRef.value)\n  }\n}\n\n// 计算图片地址\nconst getImgUrl: Ref<string> = computed(() => {\n  if (imageLoadError.value) return noImage\n  const url = props.media?.poster_path?.replace('original', 'w500') ?? noImage\n  // 使用图片缓存\n  if (globalSettings.GLOBAL_IMAGE_CACHE)\n    return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`\n  // 如果地址中包含douban则使用中转代理\n  if (url.includes('doubanio.com'))\n    return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`\n  return url\n})\n\n// 移除订阅\nfunction onRemoveSubscribe() {\n  subscribeEditDialog.value = false\n}\n\n// 获取媒体类型文本\nfunction getMediaTypeText(type: string | undefined) {\n  if (!type) return ''\n  return mediaTypeDict[type]\n}\n\nonMounted(() => {\n  setupIntersectionObserver()\n})\n\nonBeforeUnmount(() => {\n  observer.value?.disconnect()\n  observer.value = null\n})\n</script>\n\n<template>\n  <VHover>\n    <template #default=\"hover\">\n      <div ref=\"mediaCardRef\">\n        <VCard\n          v-bind=\"hover.props\"\n          :height=\"props.height\"\n          :width=\"props.width\"\n          class=\"outline-none ring-gray-500 media-card\"\n          :class=\"{\n            'transition transform-cpu duration-300  -translate-y-1': hover.isHovering,\n            'ring-1': isImageLoaded,\n          }\"\n          @click.stop=\"goMediaDetail(hover.isHovering ?? false)\"\n        >\n          <VImg\n            aspect-ratio=\"2/3\"\n            :src=\"getImgUrl\"\n            class=\"object-cover aspect-w-2 aspect-h-3\"\n            cover\n            @load=\"isImageLoaded = true\"\n            @error=\"imageLoadError = true\"\n          >\n            <template #placeholder>\n              <div class=\"w-full h-full\">\n                <VSkeletonLoader class=\"object-cover aspect-w-2 aspect-h-3\" />\n              </div>\n            </template>\n          </VImg>\n\n          <!-- 详情 -->\n          <VCardText\n            v-show=\"hover.isHovering || imageLoadError || searchMenuShow\"\n            class=\"w-full h-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2\"\n            style=\"background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)\"\n          >\n            <span class=\"font-semibold text-sm\">{{ props.media?.year }}</span>\n            <h1 class=\"media-card-title font-bold mb-2 text-white line-clamp-2 overflow-hidden text-ellipsis ...\">\n              {{ props.media?.title }}\n            </h1>\n            <p class=\"media-card-overview line-clamp-3 overflow-hidden text-ellipsis ...\">\n              {{ props.media?.overview }}\n            </p>\n            <div v-if=\"props.media?.collection_id\" class=\"mb-3\" @click.stop=\"\"></div>\n            <div v-else class=\"flex align-center justify-between\">\n              <IconBtn\n                v-if=\"hasPermission({ is_superuser: userStore.superUser, ...userStore.permissions }, 'search')\"\n                icon=\"mdi-magnify\"\n                color=\"white\"\n                size=\"small\"\n                @click.stop=\"clickSearch\"\n              />\n              <VSpacer />\n              <IconBtn\n                icon=\"mdi-heart\"\n                :color=\"isSubscribed ? 'error' : 'white'\"\n                size=\"small\"\n                @click.stop=\"handleSubscribe\"\n              />\n            </div>\n          </VCardText>\n          <!-- 类型角标 -->\n          <VChip\n            v-show=\"isImageLoaded\"\n            variant=\"elevated\"\n            size=\"small\"\n            :class=\"getChipColor(props.media?.type || '')\"\n            class=\"absolute left-2 top-2 bg-opacity-80 text-white font-bold\"\n          >\n            {{ getMediaTypeText(props.media?.type) }}\n          </VChip>\n          <!-- 本地存在标识 -->\n          <ExistIcon v-if=\"isExists && !hover.isHovering\" />\n          <!-- 评分角标 -->\n          <VChip\n            v-if=\"isImageLoaded && props.media?.vote_average && !(isExists && !hover.isHovering)\"\n            variant=\"elevated\"\n            size=\"small\"\n            :class=\"getChipColor('rating')\"\n            class=\"absolute right-2 top-2 bg-opacity-80 text-white font-bold\"\n          >\n            {{ formatRating(props.media?.vote_average) }}\n          </VChip>\n          <!--来源图标-->\n          <VAvatar\n            size=\"24\"\n            density=\"compact\"\n            class=\"absolute bottom-1 right-1\"\n            tile\n            v-if=\"!hover.isHovering && isImageLoaded && props.media?.source && !imageLoadError\"\n          >\n            <VImg cover :src=\"sourceIconDict[props.media?.source]\" class=\"shadow-lg\" />\n          </VAvatar>\n        </VCard>\n      </div>\n    </template>\n  </VHover>\n  <!-- 订阅季弹窗 -->\n  <subscribeSeasonDialog\n    v-if=\"subscribeSeasonDialog\"\n    v-model=\"subscribeSeasonDialog\"\n    :media=\"media\"\n    @subscribe=\"subscribeSeasons\"\n    @close=\"subscribeSeasonDialog = false\"\n  />\n  <!-- 订阅编辑弹窗 -->\n  <SubscribeEditDialog\n    v-if=\"subscribeEditDialog\"\n    v-model=\"subscribeEditDialog\"\n    :subid=\"subscribeId\"\n    @close=\"subscribeEditDialog = false\"\n    @save=\"subscribeEditDialog = false\"\n    @remove=\"onRemoveSubscribe\"\n  />\n  <!-- 站点选择对话框 -->\n  <SearchSiteDialog\n    v-if=\"chooseSiteDialog\"\n    v-model=\"chooseSiteDialog\"\n    :sites=\"allSites\"\n    :selected=\"selectedSites\"\n    @search=\"searchSites\"\n    @close=\"chooseSiteDialog = false\"\n  />\n</template>\n<style scoped>\n.media-card-title {\n  font-size: 1.125rem;\n  line-height: 1.25rem;\n}\n\n.media-card-overview {\n  font-size: 0.875rem;\n  line-height: 1rem;\n}\n</style>\n"
  },
  {
    "path": "src/components/cards/MediaInfoCard.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { PropType } from 'vue'\nimport type { Context } from '@/api/types'\nimport { isNullOrEmptyObject } from '@/@core/utils'\n\n// 输入参数\ndefineProps({\n  context: Object as PropType<Context>,\n})\n\n// TMDB图片转换为w500大小\nfunction getW500Image(url = '') {\n  if (!url) return ''\n  return url.replace('original', 'w500')\n}\n\n// 打开TMDB详情页面\nfunction openTmdbPage(type: string, tmdbId: number) {\n  if (!type || !tmdbId) return\n\n  const url = `https://www.themoviedb.org/${type === '电影' ? 'movie' : 'tv'}/${tmdbId}`\n  window.open(url, '_blank')\n}\n</script>\n\n<template>\n  <div v-show=\"context\">\n    <VCol>\n      <div\n        v-if=\"context?.meta_info?.name\"\n        class=\"d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row\"\n      >\n        <div v-if=\"context?.media_info?.poster_path\" class=\"ma-auto\">\n          <VImg\n            width=\"10rem\"\n            aspect-ratio=\"2/3\"\n            class=\"object-cover aspect-w-2 aspect-h-3 rounded-lg ring-1 ring-gray-500\"\n            :src=\"getW500Image(context?.media_info?.poster_path)\"\n            cover\n          >\n            <template #placeholder>\n              <div class=\"w-full h-full\">\n                <VSkeletonLoader class=\"object-cover aspect-w-2 aspect-h-3\" />\n              </div>\n            </template>\n          </VImg>\n        </div>\n        <div class=\"flex-grow\">\n          <VCardItem class=\"pb-1\">\n            <div class=\"text-center text-md-left text-h6 font-weight-bold line-clamp-2 overflow-hidden text-ellipsis\">\n              {{ context?.media_info?.title || context?.meta_info?.name }}\n              <span v-if=\"context?.meta_info?.season_episode\" class=\"text-sm text-medium-emphasis align-top\">\n                {{ context?.meta_info?.season_episode }}\n              </span>\n            </div>\n            <VCardSubtitle class=\"text-center text-md-left\">\n              {{ context?.media_info?.year || context?.meta_info?.year }}\n            </VCardSubtitle>\n          </VCardItem>\n\n          <VCardText\n            v-if=\"context?.media_info?.overview\"\n            class=\"line-clamp-4 overflow-hidden text-ellipsis text-center text-md-left ...\"\n          >\n            {{ context?.media_info?.overview }}\n          </VCardText>\n\n          <VCardItem class=\"text-center text-md-left\">\n            <!-- 类型 -->\n            <VChip\n              v-if=\"context?.media_info?.type || context?.meta_info?.type\"\n              variant=\"elevated\"\n              class=\"me-1 mb-1 text-white bg-blue-500\"\n            >\n              {{ context?.media_info?.type || context?.meta_info?.type }}\n            </VChip>\n            <!-- 二级分类 -->\n            <VChip v-if=\"context?.media_info?.category\" variant=\"elevated\" class=\"me-1 mb-1 text-white bg-blue-500\">\n              {{ context?.media_info?.category }}\n            </VChip>\n            <!-- TMDBID -->\n            <VChip\n              v-if=\"context?.media_info?.tmdb_id\"\n              variant=\"elevated\"\n              color=\"success\"\n              class=\"me-1 mb-1\"\n              @click=\"openTmdbPage(context?.media_info?.type || '', context?.media_info?.tmdb_id)\"\n            >\n              {{ context?.media_info?.tmdb_id }}\n            </VChip>\n            <!-- meta_info -->\n            <VChip v-if=\"context?.meta_info?.web_source\" variant=\"elevated\" class=\"me-1 mb-1 text-white bg-purple-500\">\n              {{ context?.meta_info?.web_source }}\n            </VChip>\n            <VChip v-if=\"context?.meta_info?.edition\" variant=\"elevated\" class=\"me-1 mb-1 text-white bg-red-500\">\n              {{ context?.meta_info?.edition }}\n            </VChip>\n            <VChip v-if=\"context?.meta_info?.resource_pix\" variant=\"elevated\" class=\"me-1 mb-1 text-white bg-red-500\">\n              {{ context?.meta_info?.resource_pix }}\n            </VChip>\n            <VChip\n              v-if=\"context?.meta_info?.video_encode\"\n              variant=\"elevated\"\n              class=\"me-1 mb-1 text-white bg-orange-500\"\n            >\n              {{ context?.meta_info?.video_encode }}\n            </VChip>\n            <VChip\n              v-if=\"context?.meta_info?.audio_encode\"\n              variant=\"elevated\"\n              class=\"me-1 mb-1 text-white bg-orange-500\"\n            >\n              {{ context?.meta_info?.audio_encode }}\n            </VChip>\n            <VChip v-if=\"context?.meta_info?.resource_team\" variant=\"elevated\" class=\"me-1 mb-1 text-white bg-cyan-500\">\n              {{ context?.meta_info?.resource_team }}\n            </VChip>\n          </VCardItem>\n        </div>\n      </div>\n      <VAlert v-if=\"!context?.meta_info?.name\" icon=\"mdi-alert-circle-outline\"> 识别失败，无法识别到有效信息！ </VAlert>\n    </VCol>\n    <VExpansionPanels v-show=\"!isNullOrEmptyObject(context?.meta_info.apply_words)\">\n      <VExpansionPanel>\n        <VExpansionPanelTitle> 识别词应用详情 </VExpansionPanelTitle>\n        <VExpansionPanelText>\n          <VChip variant=\"elevated\" class=\"me-1 mb-1 break-all\" color=\"primary\">\n            {{ context?.meta_info.org_string }}\n          </VChip>\n          <VChip\n            v-for=\"(val, key) in context?.meta_info.apply_words\"\n            :key=\"key\"\n            :val=\"val\"\n            variant=\"outlined\"\n            color=\"info\"\n            class=\"me-1 mb-1 break-all\"\n          >\n            {{ val }}\n          </VChip>\n        </VExpansionPanelText>\n      </VExpansionPanel>\n    </VExpansionPanels>\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/cards/MediaServerCard.vue",
    "content": "<script setup lang=\"ts\">\nimport { MediaServerConf, MediaServerLibrary, MediaStatistic } from '@/api/types'\nimport { useToast } from 'vue-toastification'\nimport { getLogoUrl } from '@/utils/imageUtils'\nimport api from '@/api'\nimport { cloneDeep } from 'lodash-es'\nimport { useI18n } from 'vue-i18n'\nimport { mediaServerDict } from '@/api/constants'\nimport { useDisplay } from 'vuetify'\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 获取i18n实例\nconst { t } = useI18n()\n\n// 定义输入\nconst props = defineProps({\n  // 单个媒体服务器\n  mediaserver: {\n    type: Object as PropType<MediaServerConf>,\n    required: true,\n  },\n  // 所有媒体服务器\n  mediaservers: {\n    type: Array as PropType<MediaServerConf[]>,\n    required: true,\n  },\n})\n\n// 提示框\nconst $toast = useToast()\n\n// 定义触发的自定义事件\nconst emit = defineEmits(['close', 'done', 'change'])\n\n// 媒体统计数据\nconst infoItems = ref([\n  {\n    avatar: 'mdi-movie-roll',\n    title: t('mediaType.movie'),\n    amount: '0',\n  },\n  {\n    avatar: 'mdi-television-box',\n    title: t('mediaType.tv'),\n    amount: '0',\n  },\n  {\n    avatar: 'mdi-account',\n    title: t('common.user'),\n    amount: '0',\n  },\n])\n\n// 同步媒体库选项\nconst librariesOptions = ref<{ title: string; value: string | undefined }[]>([\n  {\n    title: t('common.all'),\n    value: 'all',\n  },\n])\n\nconst ugreenScanModeOptions = computed(() => [\n  { title: t('mediaserver.scanModeOptions.newAndModified'), value: 'new_and_modified' },\n  { title: t('mediaserver.scanModeOptions.supplementMissing'), value: 'supplement_missing' },\n  { title: t('mediaserver.scanModeOptions.fullOverride'), value: 'full_override' },\n])\n\n// 媒体服务器详情弹窗\nconst mediaServerInfoDialog = ref(false)\n\n// 媒体服务器详情\nconst mediaServerInfo = ref<MediaServerConf>({\n  name: '',\n  type: '',\n  enabled: false,\n  config: {},\n})\n\n// 打开详情弹窗\nfunction openMediaServerInfoDialog() {\n  loadLibrary(props.mediaserver.name)\n  // 深复制\n  mediaServerInfo.value = cloneDeep(props.mediaserver)\n  if (mediaServerInfo.value.type === 'ugreen') {\n    mediaServerInfo.value.config = mediaServerInfo.value.config || {}\n    if (!mediaServerInfo.value.config.scan_mode) {\n      mediaServerInfo.value.config.scan_mode = 'supplement_missing'\n    }\n    if (mediaServerInfo.value.config.verify_ssl === undefined) {\n      mediaServerInfo.value.config.verify_ssl = true\n    }\n  }\n  mediaServerInfoDialog.value = true\n  if (!props.mediaserver.sync_libraries) {\n    mediaServerInfo.value.sync_libraries = ['all']\n  }\n}\n\n// 保存详情数据\nfunction saveMediaServerInfo() {\n  // 为空不保存，跳出警告框\n  if (!mediaServerInfo.value.name) {\n    $toast.error(t('common.nameRequired'))\n    return\n  }\n  // 重名判断\n  if (props.mediaservers.some(item => item.name === mediaServerInfo.value.name && item !== props.mediaserver)) {\n    $toast.error(t('common.nameExists', { name: mediaServerInfo.value.name }))\n    return\n  }\n  // 执行保存\n  mediaServerInfoDialog.value = false\n  emit('change', mediaServerInfo.value, props.mediaserver.name)\n  emit('done')\n}\n\n// 根据存储类型选择图标\nconst getIcon = computed(() => {\n  switch (props.mediaserver.type) {\n    case 'emby':\n      return getLogoUrl('emby')\n    case 'jellyfin':\n      return getLogoUrl('jellyfin')\n    case 'trimemedia':\n      return getLogoUrl('trimemedia')\n    case 'ugreen':\n      return getLogoUrl('ugreen')\n    case 'plex':\n      return getLogoUrl('plex')\n    default:\n      return getLogoUrl('mediaserver')\n  }\n})\n\n// 按钮点击\nfunction onClose() {\n  emit('close')\n}\n\n// 调用API加载媒体统计数据\nasync function loadMediaStatistic() {\n  try {\n    const res: MediaStatistic = await api.get('dashboard/statistic', {\n      params: {\n        name: props.mediaserver.name,\n      },\n    })\n\n    if (res) {\n      infoItems.value = [\n        {\n          avatar: 'mdi-movie-roll',\n          title: t('mediaType.movie'),\n          amount: res.movie_count.toLocaleString(),\n        },\n        {\n          avatar: 'mdi-television-box',\n          title: t('mediaType.tv'),\n          amount: res.tv_count.toLocaleString(),\n        },\n        {\n          avatar: 'mdi-account',\n          title: t('common.user'),\n          amount: res.user_count.toLocaleString(),\n        },\n      ]\n    }\n  } catch (e) {\n    console.log(e)\n  }\n}\n\n// 调用API查询媒体库\nasync function loadLibrary(server: string) {\n  try {\n    const result: MediaServerLibrary[] = await api.get('mediaserver/library', { params: { server } })\n    if (result && result.length > 0) {\n      librariesOptions.value = result.map(item => ({\n        title: item.name,\n        value: item.id?.toString(),\n      }))\n    } else {\n      librariesOptions.value = []\n    }\n    librariesOptions.value.unshift({\n      title: t('common.all'),\n      value: 'all',\n    })\n  } catch (e) {\n    console.log(e)\n  }\n}\n\nonMounted(() => {\n  loadMediaStatistic()\n})\n</script>\n<template>\n  <div>\n    <VCard variant=\"tonal\" class=\"app-card-shell\" @click=\"openMediaServerInfoDialog\">\n      <VDialogCloseBtn @click=\"onClose\" />\n      <VCardText class=\"app-card-summary app-card-summary--single-action\">\n        <div class=\"app-card-summary__content\">\n          <div class=\"app-card-summary__title text-h6\">{{ mediaserver.name }}</div>\n          <div\n            v-if=\"mediaServerDict[mediaserver.type] && mediaserver.enabled\"\n            class=\"grid min-h-6 grid-cols-3 gap-2 text-sm text-medium-emphasis\"\n          >\n            <span v-for=\"item in infoItems\" :key=\"item.title\" class=\"flex min-w-0 items-center\">\n              <VIcon rounded :icon=\"item.avatar\" class=\"me-1 shrink-0\" />\n              <span class=\"truncate\">{{ item.amount }}</span>\n            </span>\n          </div>\n          <div v-else-if=\"!mediaServerDict[mediaserver.type]\" class=\"app-card-summary__subtitle text-sm\">\n            自定义媒体服务器\n          </div>\n        </div>\n        <div class=\"app-card-summary__media\" aria-hidden=\"true\">\n          <VImg :src=\"getIcon\" contain class=\"app-card-summary__image\" />\n        </div>\n      </VCardText>\n    </VCard>\n\n    <VDialog\n      v-if=\"mediaServerInfoDialog\"\n      v-model=\"mediaServerInfoDialog\"\n      scrollable\n      max-width=\"40rem\"\n      :fullscreen=\"!display.mdAndUp.value\"\n    >\n      <VCard>\n        <VCardItem class=\"py-2\">\n          <template #prepend>\n            <VIcon icon=\"mdi-cog\" class=\"me-2\" />\n          </template>\n          <VCardTitle>{{ t('common.config') }}</VCardTitle>\n          <VCardSubtitle>{{ props.mediaserver.name }}</VCardSubtitle>\n        </VCardItem>\n        <VDialogCloseBtn v-model=\"mediaServerInfoDialog\" />\n        <VDivider />\n        <VCardText>\n          <VForm>\n            <VRow>\n              <VCol cols=\"12\" md=\"6\">\n                <VSwitch v-model=\"mediaServerInfo.enabled\" :label=\"t('mediaserver.enableMediaServer')\" />\n              </VCol>\n            </VRow>\n            <VRow v-if=\"mediaServerInfo.type == 'emby'\">\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"mediaServerInfo.name\"\n                  :label=\"t('common.name')\"\n                  :placeholder=\"t('mediaserver.nameRequired')\"\n                  :hint=\"t('mediaserver.serverAlias')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-label\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"mediaServerInfo.config.host\"\n                  :label=\"t('mediaserver.host')\"\n                  :placeholder=\"t('mediaserver.hostPlaceholder')\"\n                  :hint=\"t('mediaserver.hostHint')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-server\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"mediaServerInfo.config.play_host\"\n                  :label=\"t('mediaserver.playHost')\"\n                  :placeholder=\"t('mediaserver.playHostPlaceholder')\"\n                  :hint=\"t('mediaserver.playHostHint')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-play-network\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"mediaServerInfo.config.username\"\n                  :label=\"t('mediaserver.username')\"\n                  :hint=\"t('mediaserver.usernameHint')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-account\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"mediaServerInfo.config.apikey\"\n                  :label=\"t('mediaserver.apiKey')\"\n                  :hint=\"t('mediaserver.embyApiKeyHint')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-key\"\n                />\n              </VCol>\n              <VCol cols=\"12\">\n                <VAutocomplete\n                  v-model=\"mediaServerInfo.sync_libraries\"\n                  :label=\"t('mediaserver.syncLibraries')\"\n                  :items=\"librariesOptions\"\n                  chips\n                  multiple\n                  clearable\n                  :hint=\"t('mediaserver.syncLibrariesHint')\"\n                  persistent-hint\n                  active\n                  append-inner-icon=\"mdi-refresh\"\n                  prepend-inner-icon=\"mdi-library\"\n                  @click:append-inner=\"loadLibrary(mediaServerInfo.name)\"\n                />\n              </VCol>\n            </VRow>\n            <VRow v-else-if=\"mediaServerInfo.type == 'jellyfin'\">\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"mediaServerInfo.name\"\n                  :label=\"t('common.name')\"\n                  :placeholder=\"t('mediaserver.nameRequired')\"\n                  :hint=\"t('mediaserver.serverAlias')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-label\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"mediaServerInfo.config.host\"\n                  :label=\"t('mediaserver.host')\"\n                  :placeholder=\"t('mediaserver.hostPlaceholder')\"\n                  :hint=\"t('mediaserver.hostHint')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-server\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"mediaServerInfo.config.play_host\"\n                  :label=\"t('mediaserver.playHost')\"\n                  :placeholder=\"t('mediaserver.playHostPlaceholder')\"\n                  :hint=\"t('mediaserver.playHostHint')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-play-network\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"mediaServerInfo.config.apikey\"\n                  :label=\"t('mediaserver.apiKey')\"\n                  :hint=\"t('mediaserver.jellyfinApiKeyHint')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-key\"\n                />\n              </VCol>\n              <VCol cols=\"12\">\n                <VAutocomplete\n                  v-model=\"mediaServerInfo.sync_libraries\"\n                  :label=\"t('mediaserver.syncLibraries')\"\n                  :items=\"librariesOptions\"\n                  chips\n                  multiple\n                  clearable\n                  :hint=\"t('mediaserver.syncLibrariesHint')\"\n                  persistent-hint\n                  active\n                  append-inner-icon=\"mdi-refresh\"\n                  prepend-inner-icon=\"mdi-library\"\n                  @click:append-inner=\"loadLibrary(mediaServerInfo.name)\"\n                />\n              </VCol>\n            </VRow>\n            <VRow v-else-if=\"mediaServerInfo.type == 'trimemedia'\">\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"mediaServerInfo.name\"\n                  :label=\"t('common.name')\"\n                  :placeholder=\"t('mediaserver.nameRequired')\"\n                  :hint=\"t('mediaserver.serverAlias')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-label\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"mediaServerInfo.config.host\"\n                  :label=\"t('mediaserver.host')\"\n                  :placeholder=\"t('mediaserver.hostPlaceholder')\"\n                  :hint=\"t('mediaserver.hostHint')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-server\"\n                />\n              </VCol>\n              <VCol cols=\"12\">\n                <VTextField\n                  v-model=\"mediaServerInfo.config.play_host\"\n                  :label=\"t('mediaserver.playHost')\"\n                  :placeholder=\"t('mediaserver.playHostPlaceholder')\"\n                  :hint=\"t('mediaserver.playHostHint')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-play-network\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"mediaServerInfo.config.username\"\n                  :label=\"t('mediaserver.username')\"\n                  active\n                  prepend-inner-icon=\"mdi-account\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  type=\"password\"\n                  v-model=\"mediaServerInfo.config.password\"\n                  :label=\"t('mediaserver.password')\"\n                  active\n                  prepend-inner-icon=\"mdi-lock\"\n                />\n              </VCol>\n              <VCol cols=\"12\">\n                <VAutocomplete\n                  v-model=\"mediaServerInfo.sync_libraries\"\n                  :label=\"t('mediaserver.syncLibraries')\"\n                  :items=\"librariesOptions\"\n                  chips\n                  multiple\n                  clearable\n                  :hint=\"t('mediaserver.syncLibrariesHint')\"\n                  persistent-hint\n                  active\n                  append-inner-icon=\"mdi-refresh\"\n                  prepend-inner-icon=\"mdi-library\"\n                  @click:append-inner=\"loadLibrary(mediaServerInfo.name)\"\n                />\n              </VCol>\n            </VRow>\n            <VRow v-else-if=\"mediaServerInfo.type == 'ugreen'\">\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"mediaServerInfo.name\"\n                  :label=\"t('common.name')\"\n                  :placeholder=\"t('mediaserver.nameRequired')\"\n                  :hint=\"t('mediaserver.serverAlias')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-label\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"mediaServerInfo.config.host\"\n                  :label=\"t('mediaserver.host')\"\n                  :placeholder=\"t('mediaserver.hostPlaceholder')\"\n                  :hint=\"t('mediaserver.hostHint')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-server\"\n                />\n              </VCol>\n              <VCol cols=\"12\">\n                <VTextField\n                  v-model=\"mediaServerInfo.config.play_host\"\n                  :label=\"t('mediaserver.playHost')\"\n                  :placeholder=\"t('mediaserver.playHostPlaceholder')\"\n                  :hint=\"t('mediaserver.playHostHint')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-play-network\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"mediaServerInfo.config.username\"\n                  :label=\"t('mediaserver.username')\"\n                  active\n                  prepend-inner-icon=\"mdi-account\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  type=\"password\"\n                  v-model=\"mediaServerInfo.config.password\"\n                  :label=\"t('mediaserver.password')\"\n                  active\n                  prepend-inner-icon=\"mdi-lock\"\n                />\n              </VCol>\n              <VCol cols=\"12\">\n                <VAutocomplete\n                  v-model=\"mediaServerInfo.sync_libraries\"\n                  :label=\"t('mediaserver.syncLibraries')\"\n                  :items=\"librariesOptions\"\n                  chips\n                  multiple\n                  clearable\n                  :hint=\"t('mediaserver.syncLibrariesHint')\"\n                  persistent-hint\n                  active\n                  append-inner-icon=\"mdi-refresh\"\n                  prepend-inner-icon=\"mdi-library\"\n                  @click:append-inner=\"loadLibrary(mediaServerInfo.name)\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VSelect\n                  v-model=\"mediaServerInfo.config.scan_mode\"\n                  :label=\"t('mediaserver.scanMode')\"\n                  :items=\"ugreenScanModeOptions\"\n                  :hint=\"t('mediaserver.scanModeHint')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-radar\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VSwitch\n                  v-model=\"mediaServerInfo.config.verify_ssl\"\n                  :label=\"t('mediaserver.verifySsl')\"\n                  :hint=\"t('mediaserver.verifySslHint')\"\n                  persistent-hint\n                  color=\"primary\"\n                  inset\n                />\n              </VCol>\n            </VRow>\n            <VRow v-else-if=\"mediaServerInfo.type == 'plex'\">\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"mediaServerInfo.name\"\n                  :label=\"t('common.name')\"\n                  :placeholder=\"t('mediaserver.nameRequired')\"\n                  :hint=\"t('mediaserver.serverAlias')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-label\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"mediaServerInfo.config.host\"\n                  :label=\"t('mediaserver.host')\"\n                  :placeholder=\"t('mediaserver.hostPlaceholder')\"\n                  :hint=\"t('mediaserver.hostHint')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-server\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"mediaServerInfo.config.play_host\"\n                  :label=\"t('mediaserver.playHost')\"\n                  :placeholder=\"t('mediaserver.playHostPlaceholder')\"\n                  :hint=\"t('mediaserver.playHostHint')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-play-network\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"mediaServerInfo.config.token\"\n                  :label=\"t('mediaserver.plexToken')\"\n                  :hint=\"t('mediaserver.plexTokenHint')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-key\"\n                />\n              </VCol>\n              <VCol cols=\"12\">\n                <VAutocomplete\n                  v-model=\"mediaServerInfo.sync_libraries\"\n                  :label=\"t('mediaserver.syncLibraries')\"\n                  :items=\"librariesOptions\"\n                  chips\n                  multiple\n                  clearable\n                  :hint=\"t('mediaserver.syncLibrariesHint')\"\n                  persistent-hint\n                  active\n                  append-inner-icon=\"mdi-refresh\"\n                  prepend-inner-icon=\"mdi-library\"\n                  @click:append-inner=\"loadLibrary(mediaServerInfo.name)\"\n                />\n              </VCol>\n            </VRow>\n            <VRow v-else>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"mediaServerInfo.type\"\n                  :label=\"t('mediaserver.type')\"\n                  :hint=\"t('mediaserver.customTypeHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-cog\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  :label=\"t('common.name')\"\n                  :hint=\"t('mediaserver.nameRequired')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-label\"\n                />\n              </VCol>\n            </VRow>\n          </VForm>\n        </VCardText>\n        <VCardActions class=\"pt-3\">\n          <VBtn @click=\"saveMediaServerInfo\" prepend-icon=\"mdi-content-save\" class=\"px-5\">\n            {{ t('common.confirm') }}\n          </VBtn>\n        </VCardActions>\n      </VCard>\n    </VDialog>\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/cards/MessageCard.vue",
    "content": "<script lang=\"ts\" setup>\nimport MarkdownIt from 'markdown-it'\nimport mdLinkAttributes from 'markdown-it-link-attributes'\nimport { isNullOrEmptyObject } from '@/@core/utils'\nimport type { Message } from '@/api/types'\nimport { formatDateDifference } from '@core/utils/formatters'\n\n// 输入参数\nconst props = defineProps({\n  message: Object as PropType<Message>,\n  width: String,\n  height: String,\n})\n\n// 定义事件\nconst emit = defineEmits(['imageload'])\n\n// 图片是否加载完成\nconst isImageLoaded = ref(false)\n\n// 图片是否加载失败\nconst imageLoadError = ref(false)\n\n// 初始化 markdown-it\nconst md = new MarkdownIt({\n  html: true,\n  breaks: true,\n  linkify: true,\n  typographer: true,\n})\n\n// 插件：链接在新窗口打开\nmd.use(mdLinkAttributes, {\n  attrs: {\n    target: '_blank',\n    rel: 'noopener noreferrer',\n  },\n})\n\n// 图片加载完成\nasync function imageLoaded() {\n  isImageLoaded.value = true\n  emit('imageload')\n}\n\n// 链接打开新窗口\nfunction openLink() {\n  if (props.message?.link) window.open(props.message.link, '_blank')\n}\n\n// 将note转换为json\nfunction noteToJson() {\n  if (props.message?.note) {\n    try {\n      return JSON.parse(props.message.note)\n    } catch (error) {\n      return props.message.note\n    }\n  }\n  return {}\n}\n\n// 渲染 Markdown\nfunction renderMarkdown(value: string) {\n  if (!value) return ''\n  return md.render(value)\n}\n</script>\n\n<template>\n  <VCard variant=\"tonal\" :width=\"props.width\" :height=\"props.height\" @click=\"openLink\" max-width=\"23rem\">\n    <div v-if=\"props.message?.image\" class=\"relative text-center card-cover-blurred\">\n      <VImg\n        :src=\"props.message?.image\"\n        aspect-ratio=\"3/2\"\n        cover\n        position=\"top\"\n        @load=\"imageLoaded\"\n        @error=\"imageLoadError = true\"\n        min-height=\"10rem\"\n      >\n        <template #placeholder>\n          <div class=\"w-full h-full\">\n            <VSkeletonLoader class=\"object-cover aspect-w-2 aspect-h-3\" />\n          </div>\n        </template>\n      </VImg>\n    </div>\n    <div\n      v-if=\"\n        props.message?.title &&\n        !props.message?.text &&\n        !props.message?.image &&\n        isNullOrEmptyObject(props.message?.note) &&\n        props.message?.action === 0\n      \"\n      class=\"rounded-md text-body-1 py-2 px-4 elevation-2 bg-primary text-white chat-right mb-1\"\n    >\n      <p class=\"mb-0\">{{ props.message?.title }}</p>\n    </div>\n    <VCardTitle v-else-if=\"props.message?.title\" class=\"break-words whitespace-break-spaces\">\n      {{ props.message?.title }}\n    </VCardTitle>\n    <div\n      v-if=\"props.message?.text && props.message?.action === 0\"\n      class=\"rounded-md text-body-1 py-1 px-4 elevation-2 bg-primary text-white chat-right\"\n    >\n      <div class=\"markdown-body\" v-html=\"renderMarkdown(props.message?.text)\" />\n    </div>\n    <VCardText\n      v-if=\"props.message?.text && props.message?.action === 1\"\n      class=\"markdown-body\"\n      v-html=\"renderMarkdown(props.message?.text)\"\n    />\n    <VCardText v-if=\"!isNullOrEmptyObject(props.message?.note)\">\n      <VList>\n        <VListItem v-for=\"(value, key) in noteToJson()\" :key=\"key\" two-line>\n          <VListItemTitle v-if=\"value.title_year\" class=\"font-bold break-words whitespace-break-spaces\">\n            {{ Number(key) + 1 }}. {{ value.title_year }}\n          </VListItemTitle>\n          <VListItemTitle v-if=\"value.enclosure\" class=\"font-bold break-words whitespace-break-spaces\">\n            {{ Number(key) + 1 }}. {{ value.title }} {{ value.volume_factor }} ↑{{ value.seeders }}\n          </VListItemTitle>\n          <VListItemSubtitle v-if=\"value.type\">\n            类型：{{ value.type }} 评分：{{ value.vote_average }}\n          </VListItemSubtitle>\n          <VListItemSubtitle v-if=\"value.enclosure\" class=\"whitespace-break-spaces\">\n            {{ value.description }}\n          </VListItemSubtitle>\n        </VListItem>\n      </VList>\n    </VCardText>\n  </VCard>\n  <div class=\"text-end\">\n    <span v-if=\"props.message?.action === 0\" class=\"text-sm italic me-2\">{{ props.message?.userid }}</span>\n    <span class=\"text-sm italic me-2\">{{\n      formatDateDifference(props.message?.reg_time || props.message?.date || '')\n    }}</span>\n  </div>\n</template>\n\n<style lang=\"scss\">\n.markdown-body {\n  word-break: break-all;\n\n  p {\n    margin-block-end: 0.5rem;\n  }\n\n  p:last-child {\n    margin-block-end: 0;\n  }\n\n  a {\n    color: inherit;\n    text-decoration: underline;\n  }\n\n  ul {\n    list-style-type: disc;\n    margin-block-end: 0.5rem;\n    padding-inline-start: 1.5rem;\n  }\n\n  ol {\n    list-style-type: decimal;\n    margin-block-end: 0.5rem;\n    padding-inline-start: 1.5rem;\n  }\n\n  li {\n    display: list-item;\n    margin-block-end: 0.25rem;\n  }\n\n  code {\n    border-radius: 4px;\n    background-color: rgba(var(--v-border-color), 0.1);\n    font-family: monospace;\n    padding-block: 0.2rem;\n    padding-inline: 0.4rem;\n  }\n\n  pre {\n    overflow: auto;\n    padding: 1rem;\n    border-radius: 8px;\n    background-color: rgba(var(--v-border-color), 0.1);\n    margin-block-end: 0.5rem;\n\n    code {\n      padding: 0;\n      background-color: transparent;\n    }\n  }\n\n  blockquote {\n    border-inline-start: 4px solid rgba(var(--v-border-color), 0.2);\n    font-style: italic;\n    margin-block-end: 0.5rem;\n    padding-inline-start: 1rem;\n  }\n\n  table {\n    border-collapse: collapse;\n    inline-size: 100%;\n    margin-block-end: 1rem;\n\n    th,\n    td {\n      padding: 0.5rem;\n      border: 1px solid rgba(var(--v-border-color), 0.1);\n      text-align: start;\n    }\n\n    th {\n      background-color: rgba(var(--v-border-color), 0.05);\n    }\n  }\n\n  img {\n    block-size: auto;\n    max-inline-size: 100%;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/cards/NotificationChannelCard.vue",
    "content": "<script setup lang=\"ts\">\nimport { NotificationConf } from '@/api/types'\nimport { getLogoUrl } from '@/utils/imageUtils'\nimport { useToast } from 'vue-toastification'\nimport { cloneDeep } from 'lodash-es'\nimport { useI18n } from 'vue-i18n'\nimport { useDisplay } from 'vuetify'\n\n// 显示器宽度\nconst display = useDisplay()\n\nconst { t } = useI18n()\n\n// 定义输入\nconst props = defineProps({\n  // 单个通知\n  notification: {\n    type: Object as PropType<NotificationConf>,\n    required: true,\n  },\n  // 所有通知\n  notifications: {\n    type: Array as PropType<NotificationConf[]>,\n    required: true,\n  },\n})\n\n// 定义触发的自定义事件\nconst emit = defineEmits(['close', 'change', 'done'])\n\n// 提示框\nconst $toast = useToast()\n\n// 通知详情弹窗\nconst notificationInfoDialog = ref(false)\n\n// 通知详情\nconst notificationInfo = ref<NotificationConf>({\n  name: '',\n  type: '',\n  enabled: false,\n  config: {},\n})\n\n// 各通知类型的名称字典\nconst notificationTypeNames: { [key: string]: string } = {\n  wechat: t('notification.wechat.name'),\n  telegram: t('notification.telegram.name'),\n  qqbot: t('notification.qqbot.name'),\n  vocechat: t('notification.vocechat.name'),\n  synologychat: t('notification.synologychat.name'),\n  slack: t('notification.slack.name'),\n  discord: t('notification.discord.name'),\n  webpush: t('notification.webpush.name'),\n  custom: t('setting.notification.custom'),\n}\n\n// 消息类型下拉字典\nconst notificationTypes = [\n  { value: '资源下载', title: t('notificationSwitch.resourceDownload') },\n  { value: '整理入库', title: t('notificationSwitch.organize') },\n  { value: '订阅', title: t('notificationSwitch.subscribe') },\n  { value: '站点', title: t('notificationSwitch.site') },\n  { value: '媒体服务器', title: t('notificationSwitch.mediaServer') },\n  { value: '手动处理', title: t('notificationSwitch.manual') },\n  { value: '插件', title: t('notificationSwitch.plugin') },\n  { value: '智能体', title: t('notificationSwitch.agent') },\n  { value: '其它', title: t('notificationSwitch.other') },\n]\n\nfunction ensureWechatConfigDefaults(notification: NotificationConf) {\n  if (notification.type !== 'wechat') {\n    return\n  }\n  if (!notification.config) {\n    notification.config = {}\n  }\n  if (!notification.config.WECHAT_MODE) {\n    notification.config.WECHAT_MODE = 'app'\n  }\n  if (!notification.config.WECHAT_BOT_WS_URL) {\n    notification.config.WECHAT_BOT_WS_URL = 'wss://openws.work.weixin.qq.com'\n  }\n}\n\nconst isWechatBotMode = computed({\n  get: () => notificationInfo.value.config?.WECHAT_MODE === 'bot',\n  set: value => {\n    if (!notificationInfo.value.config) {\n      notificationInfo.value.config = {}\n    }\n    notificationInfo.value.config.WECHAT_MODE = value ? 'bot' : 'app'\n    if (value && !notificationInfo.value.config.WECHAT_BOT_WS_URL) {\n      notificationInfo.value.config.WECHAT_BOT_WS_URL = 'wss://openws.work.weixin.qq.com'\n    }\n  },\n})\n\n// 打开详情弹窗\nfunction openNotificationInfoDialog() {\n  // 替换成深复制，避免修改时影响原数据\n  notificationInfo.value = cloneDeep(props.notification)\n  ensureWechatConfigDefaults(notificationInfo.value)\n  notificationInfoDialog.value = true\n}\n\n// 保存详情数据\nfunction saveNotificationInfo() {\n  // 为空不保存，跳出警告框\n  if (!notificationInfo.value.name) {\n    $toast.error(t('notification.name') + t('common.required'))\n    return\n  }\n  // 重名判断\n  if (props.notifications.some(item => item.name === notificationInfo.value.name && item !== props.notification)) {\n    $toast.error(t('notification.channel') + `【${notificationInfo.value.name}】` + t('common.exists'))\n    return\n  }\n  ensureWechatConfigDefaults(notificationInfo.value)\n  notificationInfoDialog.value = false\n  emit('change', notificationInfo.value, props.notification.name)\n  emit('done')\n}\n\n// 根据存储类型选择图标\nconst getIcon = computed(() => {\n  switch (props.notification.type) {\n    case 'wechat':\n      return getLogoUrl('wechat')\n    case 'telegram':\n      return getLogoUrl('telegram')\n    case 'qqbot':\n      return getLogoUrl('qq')\n    case 'vocechat':\n      return getLogoUrl('vocechat')\n    case 'synologychat':\n      return getLogoUrl('synologychat')\n    case 'slack':\n      return getLogoUrl('slack')\n    case 'discord':\n      return getLogoUrl('discord')\n    case 'webpush':\n      return getLogoUrl('chrome')\n    default:\n      return getLogoUrl('notification')\n  }\n})\n\n// 按钮点击\nfunction onClose() {\n  emit('close')\n}\n</script>\n<template>\n  <div>\n    <VCard variant=\"tonal\" class=\"app-card-shell\" @click=\"openNotificationInfoDialog\">\n      <span class=\"app-card-top-action absolute top-3 right-12\">\n        <IconBtn @click.stop>\n          <VIcon class=\"cursor-move\" icon=\"mdi-drag\" />\n        </IconBtn>\n      </span>\n      <VDialogCloseBtn @click=\"onClose\" />\n      <VCardText class=\"app-card-summary app-card-summary--double-action app-card-summary--title-subtitle\">\n        <div class=\"app-card-summary__content\">\n          <div class=\"app-card-summary__title-row\">\n            <VBadge v-if=\"props.notification.enabled\" dot inline color=\"success\" class=\"me-1\" />\n            <span class=\"app-card-summary__title text-h6\">{{ props.notification.name }}</span>\n          </div>\n          <div class=\"app-card-summary__subtitle text-body-1\">{{ notificationTypeNames[notification.type] }}</div>\n        </div>\n        <div class=\"app-card-summary__media\" aria-hidden=\"true\">\n          <VImg :src=\"getIcon\" contain class=\"app-card-summary__image\" />\n        </div>\n      </VCardText>\n    </VCard>\n\n    <VDialog\n      v-if=\"notificationInfoDialog\"\n      v-model=\"notificationInfoDialog\"\n      scrollable\n      max-width=\"40rem\"\n      :fullscreen=\"!display.mdAndUp.value\"\n    >\n      <VCard>\n        <VCardItem class=\"py-2\">\n          <template #prepend>\n            <VIcon icon=\"mdi-cog\" class=\"me-2\" />\n          </template>\n          <VCardTitle>{{ t('common.config') }}</VCardTitle>\n          <VCardSubtitle>{{ props.notification.name }}</VCardSubtitle>\n        </VCardItem>\n        <VDialogCloseBtn @click=\"notificationInfoDialog = false\" />\n        <VDivider />\n        <VCardText>\n          <VForm>\n            <VRow>\n              <VCol cols=\"12\" md=\"6\">\n                <VSwitch v-model=\"notificationInfo.enabled\" :label=\"t('notification.enabled')\" />\n              </VCol>\n              <VCol cols=\"12\">\n                <VAutocomplete\n                  v-model=\"notificationInfo.switchs\"\n                  :items=\"notificationTypes\"\n                  :label=\"t('notification.type')\"\n                  :hint=\"t('notification.typeHint')\"\n                  multiple\n                  clearable\n                  chips\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-bell-outline\"\n                />\n              </VCol>\n            </VRow>\n            <VRow v-if=\"notificationInfo.type == 'wechat'\">\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.name\"\n                  :label=\"t('notification.name')\"\n                  :placeholder=\"t('notification.name')\"\n                  :hint=\"t('notification.nameHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-label\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VSwitch\n                  v-model=\"isWechatBotMode\"\n                  :label=\"t('notification.wechat.useBotMode')\"\n                  :hint=\"t('notification.wechat.useBotModeHint')\"\n                  persistent-hint\n                  color=\"primary\"\n                />\n              </VCol>\n              <template v-if=\"isWechatBotMode\">\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"notificationInfo.config.WECHAT_BOT_ID\"\n                    :label=\"t('notification.wechat.botId')\"\n                    :hint=\"t('notification.wechat.botIdHint')\"\n                    persistent-hint\n                    prepend-inner-icon=\"mdi-robot\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"notificationInfo.config.WECHAT_BOT_SECRET\"\n                    :label=\"t('notification.wechat.botSecret')\"\n                    :hint=\"t('notification.wechat.botSecretHint')\"\n                    persistent-hint\n                    prepend-inner-icon=\"mdi-key\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"notificationInfo.config.WECHAT_BOT_CHAT_ID\"\n                    :label=\"t('notification.wechat.botChatId')\"\n                    :placeholder=\"t('notification.wechat.botChatIdPlaceholder')\"\n                    :hint=\"t('notification.wechat.botChatIdHint')\"\n                    persistent-hint\n                    prepend-inner-icon=\"mdi-chat-processing\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"notificationInfo.config.WECHAT_BOT_WS_URL\"\n                    :label=\"t('notification.wechat.botWsUrl')\"\n                    :hint=\"t('notification.wechat.botWsUrlHint')\"\n                    persistent-hint\n                    prepend-inner-icon=\"mdi-lan-connect\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"notificationInfo.config.WECHAT_ADMINS\"\n                    :label=\"t('notification.wechat.admins')\"\n                    :placeholder=\"t('notification.wechat.adminsPlaceholder')\"\n                    :hint=\"t('notification.wechat.adminsHint')\"\n                    persistent-hint\n                    prepend-inner-icon=\"mdi-account-supervisor\"\n                  />\n                </VCol>\n              </template>\n              <template v-else>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"notificationInfo.config.WECHAT_CORPID\"\n                    :label=\"t('notification.wechat.corpId')\"\n                    :hint=\"t('notification.wechat.corpIdHint')\"\n                    persistent-hint\n                    prepend-inner-icon=\"mdi-domain\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"notificationInfo.config.WECHAT_APP_ID\"\n                    :label=\"t('notification.wechat.appId')\"\n                    :hint=\"t('notification.wechat.appIdHint')\"\n                    persistent-hint\n                    prepend-inner-icon=\"mdi-application\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"notificationInfo.config.WECHAT_APP_SECRET\"\n                    :label=\"t('notification.wechat.appSecret')\"\n                    :hint=\"t('notification.wechat.appSecretHint')\"\n                    persistent-hint\n                    prepend-inner-icon=\"mdi-key\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"notificationInfo.config.WECHAT_PROXY\"\n                    :label=\"t('notification.wechat.proxy')\"\n                    :hint=\"t('notification.wechat.proxyHint')\"\n                    persistent-hint\n                    prepend-inner-icon=\"mdi-server-network\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"notificationInfo.config.WECHAT_TOKEN\"\n                    :label=\"t('notification.wechat.token')\"\n                    :hint=\"t('notification.wechat.tokenHint')\"\n                    persistent-hint\n                    prepend-inner-icon=\"mdi-key-variant\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"notificationInfo.config.WECHAT_ENCODING_AESKEY\"\n                    :label=\"t('notification.wechat.encodingAesKey')\"\n                    :hint=\"t('notification.wechat.encodingAesKeyHint')\"\n                    persistent-hint\n                    prepend-inner-icon=\"mdi-lock\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"notificationInfo.config.WECHAT_ADMINS\"\n                    :label=\"t('notification.wechat.admins')\"\n                    :placeholder=\"t('notification.wechat.adminsPlaceholder')\"\n                    :hint=\"t('notification.wechat.adminsHint')\"\n                    persistent-hint\n                    prepend-inner-icon=\"mdi-account-supervisor\"\n                  />\n                </VCol>\n              </template>\n            </VRow>\n            <VRow v-else-if=\"notificationInfo.type == 'telegram'\">\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.name\"\n                  :label=\"t('notification.name')\"\n                  :placeholder=\"t('notification.name')\"\n                  :hint=\"t('notification.nameHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-label\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.config.TELEGRAM_TOKEN\"\n                  :label=\"t('notification.telegram.token')\"\n                  :hint=\"t('notification.telegram.tokenHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-key\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.config.TELEGRAM_CHAT_ID\"\n                  :label=\"t('notification.telegram.chatId')\"\n                  :hint=\"t('notification.telegram.chatIdHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-chat\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.config.TELEGRAM_USERS\"\n                  :label=\"t('notification.telegram.users')\"\n                  :placeholder=\"t('notification.telegram.usersPlaceholder')\"\n                  :hint=\"t('notification.telegram.usersHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-account-group\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.config.TELEGRAM_ADMINS\"\n                  :label=\"t('notification.telegram.admins')\"\n                  :placeholder=\"t('notification.telegram.adminsPlaceholder')\"\n                  :hint=\"t('notification.telegram.adminsHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-account-supervisor\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.config.API_URL\"\n                  :label=\"t('notification.telegram.apiUrl')\"\n                  :placeholder=\"t('notification.telegram.apiUrlPlaceholder')\"\n                  :hint=\"t('notification.telegram.apiUrlHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-web\"\n                />\n              </VCol>\n            </VRow>\n            <VRow v-else-if=\"notificationInfo.type == 'slack'\">\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.name\"\n                  :label=\"t('notification.name')\"\n                  :placeholder=\"t('notification.name')\"\n                  :hint=\"t('notification.nameHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-label\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.config.SLACK_OAUTH_TOKEN\"\n                  :label=\"t('notification.slack.oauthToken')\"\n                  :placeholder=\"t('notification.slack.oauthTokenPlaceholder')\"\n                  :hint=\"t('notification.slack.oauthTokenHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-key\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.config.SLACK_APP_TOKEN\"\n                  :label=\"t('notification.slack.appToken')\"\n                  :placeholder=\"t('notification.slack.appTokenPlaceholder')\"\n                  :hint=\"t('notification.slack.appTokenHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-application\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.config.SLACK_CHANNEL\"\n                  :label=\"t('notification.slack.channel')\"\n                  :placeholder=\"t('notification.slack.channelPlaceholder')\"\n                  :hint=\"t('notification.slack.channelHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-pound\"\n                />\n              </VCol>\n            </VRow>\n            <VRow v-else-if=\"notificationInfo.type == 'discord'\">\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.name\"\n                  :label=\"t('notification.name')\"\n                  :placeholder=\"t('notification.name')\"\n                  :hint=\"t('notification.nameHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-label\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.config.DISCORD_BOT_TOKEN\"\n                  :label=\"t('notification.discord.botToken')\"\n                  :hint=\"t('notification.discord.botTokenHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-key-variant\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.config.DISCORD_GUILD_ID\"\n                  :label=\"t('notification.discord.guildId')\"\n                  :placeholder=\"t('notification.discord.guildIdPlaceholder')\"\n                  :hint=\"t('notification.discord.guildIdHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-pound\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.config.DISCORD_CHANNEL_ID\"\n                  :label=\"t('notification.discord.channelId')\"\n                  :placeholder=\"t('notification.discord.channelIdPlaceholder')\"\n                  :hint=\"t('notification.discord.channelIdHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-pound-box\"\n                />\n              </VCol>\n            </VRow>\n            <VRow v-else-if=\"notificationInfo.type == 'synologychat'\">\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.name\"\n                  :label=\"t('notification.name')\"\n                  :placeholder=\"t('notification.name')\"\n                  :hint=\"t('notification.nameHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-label\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.config.SYNOLOGYCHAT_WEBHOOK\"\n                  :label=\"t('notification.synologychat.webhook')\"\n                  :hint=\"t('notification.synologychat.webhookHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-webhook\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.config.SYNOLOGYCHAT_TOKEN\"\n                  :label=\"t('notification.synologychat.token')\"\n                  :hint=\"t('notification.synologychat.tokenHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-key\"\n                />\n              </VCol>\n            </VRow>\n            <VRow v-else-if=\"notificationInfo.type == 'vocechat'\">\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.name\"\n                  :label=\"t('notification.name')\"\n                  :placeholder=\"t('notification.name')\"\n                  :hint=\"t('notification.nameHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-label\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.config.VOCECHAT_HOST\"\n                  :label=\"t('notification.vocechat.host')\"\n                  :hint=\"t('notification.vocechat.hostHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-server\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.config.VOCECHAT_API_KEY\"\n                  :label=\"t('notification.vocechat.apiKey')\"\n                  :hint=\"t('notification.vocechat.apiKeyHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-key\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.config.VOCECHAT_CHANNEL_ID\"\n                  :label=\"t('notification.vocechat.channelId')\"\n                  :placeholder=\"t('notification.vocechat.channelIdPlaceholder')\"\n                  :hint=\"t('notification.vocechat.channelIdHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-pound\"\n                />\n              </VCol>\n            </VRow>\n            <VRow v-else-if=\"notificationInfo.type == 'qqbot'\">\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.name\"\n                  :label=\"t('notification.name')\"\n                  :placeholder=\"t('notification.name')\"\n                  :hint=\"t('notification.nameHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-label\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.config.QQ_APP_ID\"\n                  :label=\"t('notification.qqbot.appId')\"\n                  :hint=\"t('notification.qqbot.appIdHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-application\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.config.QQ_APP_SECRET\"\n                  :label=\"t('notification.qqbot.appSecret')\"\n                  :hint=\"t('notification.qqbot.appSecretHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-key\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.config.QQ_OPENID\"\n                  :label=\"t('notification.qqbot.openId')\"\n                  :placeholder=\"t('notification.qqbot.openIdPlaceholder')\"\n                  :hint=\"t('notification.qqbot.openIdHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-account\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.config.QQ_GROUP_OPENID\"\n                  :label=\"t('notification.qqbot.groupOpenId')\"\n                  :placeholder=\"t('notification.qqbot.groupOpenIdPlaceholder')\"\n                  :hint=\"t('notification.qqbot.groupOpenIdHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-account-group\"\n                />\n              </VCol>\n            </VRow>\n            <VRow v-else-if=\"notificationInfo.type == 'webpush'\">\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.name\"\n                  :label=\"t('notification.name')\"\n                  :placeholder=\"t('notification.name')\"\n                  :hint=\"t('notification.nameHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-label\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.config.WEBPUSH_USERNAME\"\n                  :label=\"t('notification.webpush.username')\"\n                  :hint=\"t('notification.webpush.usernameHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-account\"\n                />\n              </VCol>\n            </VRow>\n            <VRow v-else>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.type\"\n                  :label=\"t('notification.type')\"\n                  :hint=\"t('notification.customTypeHint')\"\n                  persistent-hint\n                  active\n                  prepend-inner-icon=\"mdi-cog\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"notificationInfo.name\"\n                  :label=\"t('notification.name')\"\n                  :hint=\"t('notification.nameRequired')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-label\"\n                />\n              </VCol>\n            </VRow>\n          </VForm>\n        </VCardText>\n        <VCardActions class=\"pt-3\">\n          <VBtn @click=\"saveNotificationInfo\" prepend-icon=\"mdi-content-save\" class=\"px-5\">\n            {{ t('common.confirm') }}\n          </VBtn>\n        </VCardActions>\n      </VCard>\n    </VDialog>\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/cards/PersonCard.vue",
    "content": "<script lang=\"ts\" setup>\nimport personIcon from '@images/misc/person-icon.png'\nimport type { Person } from '@/api/types'\nimport router from '@/router'\nimport { useGlobalSettingsStore } from '@/stores'\n\nconst personProps = defineProps({\n  person: Object as PropType<Person>,\n  width: String,\n  height: String,\n})\n\n// 从 provide 中获取全局设置\n// 全局设置\nconst globalSettingsStore = useGlobalSettingsStore()\nconst globalSettings = globalSettingsStore.globalSettings\n\n// 当前人物\nconst personInfo = ref(personProps.person)\n\n// 人物图片是否加载\nconst isImageLoaded = ref(false)\n\n// 人物图片地址\nfunction getPersonImage() {\n  let url = ''\n  if (personProps.person?.source === 'themoviedb') {\n    if (!personInfo.value?.profile_path) return personIcon\n    url = `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w600_and_h900_bestv2${personInfo.value?.profile_path}`\n  } else if (personProps.person?.source === 'douban') {\n    if (!personInfo.value?.avatar) return personIcon\n    if (typeof personInfo.value?.avatar === 'object') {\n      url = personInfo.value?.avatar?.normal\n    } else {\n      url = personInfo.value?.avatar\n    }\n  } else if (personProps.person?.source === 'bangumi') {\n    if (!personInfo.value?.images) return personIcon\n    url = personInfo.value?.images?.medium\n  } else {\n    return personIcon\n  }\n  if (globalSettings.GLOBAL_IMAGE_CACHE && url)\n    return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`\n  return url\n}\n\n// 人物姓名\nfunction getPersonName() {\n  return personInfo.value?.name\n}\n\n// 人物角色\nfunction getPersonCharacter() {\n  if (personProps.person?.source === 'bangumi') {\n    if (!personInfo.value?.career) return ''\n    return personInfo.value?.career.join('、')\n  } else {\n    return personInfo.value?.character\n  }\n}\n\n// 人物详情\nfunction goPersonDetail() {\n  if (!personInfo.value?.id) return\n  router.push({\n    path: '/person',\n    query: {\n      personid: personInfo.value?.id,\n      source: personInfo.value?.source,\n    },\n  })\n}\n</script>\n\n<template>\n  <VHover>\n    <template #default=\"hover\">\n      <VCard\n        v-bind=\"hover.props\"\n        :height=\"personProps.height\"\n        :width=\"personProps.width\"\n        :class=\"{\n          'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,\n        }\"\n        @click.stop=\"goPersonDetail\"\n      >\n        <div class=\"person-card relative cursor-pointer ring-gray-700\">\n          <div style=\"padding-block-end: 150%\">\n            <div class=\"absolute inset-0 flex h-full w-full flex-col items-center p-2\">\n              <div class=\"relative mt-2 mb-4 flex h-1/2 w-full justify-center\">\n                <VAvatar\n                  size=\"100\"\n                  :class=\"{\n                    'ring-1 ring-gray-700': isImageLoaded,\n                  }\"\n                >\n                  <VImg :src=\"getPersonImage()\" cover @load=\"isImageLoaded = true\" />\n                </VAvatar>\n              </div>\n              <div class=\"w-full truncate text-center font-bold\">\n                {{ getPersonName() }}\n              </div>\n              <div class=\"overflow-hidden whitespace-normal text-center text-sm text-ellipsis line-clamp-2\">\n                {{ getPersonCharacter() }}\n              </div>\n              <div class=\"absolute bottom-0 left-0 right-0 h-12 rounded-b\" />\n            </div>\n          </div>\n        </div>\n      </VCard>\n    </template>\n  </VHover>\n</template>\n\n<style lang=\"scss\" scoped>\n.person-card {\n  background-image: linear-gradient(\n    45deg,\n    rgb工(var(--v-theme-background), 0.3),\n    rgba(var(--v-theme-surface), 0.3) 60%\n  );\n}\n\n.person-card:hover {\n  background-image: linear-gradient(\n    45deg,\n    rgba(var(--v-theme-background), 0.3),\n    rgba(var(--v-custom-background), 0.3) 60%\n  );\n}\n</style>\n"
  },
  {
    "path": "src/components/cards/PluginAppCard.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useToast } from 'vue-toastification'\nimport VersionHistory from '../misc/VersionHistory.vue'\nimport api from '@/api'\nimport type { Plugin } from '@/api/types'\nimport { getLogoUrl } from '@/utils/imageUtils'\nimport { getDominantColor } from '@/@core/utils/image'\nimport { isNullOrEmptyObject } from '@/@core/utils'\nimport { formatDownloadCount } from '@/@core/utils/formatters'\nimport ProgressDialog from '@/components/dialog/ProgressDialog.vue'\nimport { useI18n } from 'vue-i18n'\n\n// 输入参数\nconst props = defineProps({\n  plugin: Object as PropType<Plugin>,\n  width: String,\n  height: String,\n  count: Number,\n})\n\n// 定义触发的自定义事件\nconst emit = defineEmits(['install'])\n\n// 多语言\nconst { t } = useI18n()\n\n// 背景颜色\nconst backgroundColor = ref('#28A9E1')\n\n// 图片对象\nconst imageRef = ref<any>()\n\n// 提示框\nconst $toast = useToast()\n\n// 进度框\nconst progressDialog = ref(false)\n\n// 进度框文本\nconst progressText = ref('')\n\n// 获取当前插件的标签\nconst pluginLabels = computed(() => {\n  if (!props.plugin?.plugin_label) return []\n\n  return props.plugin.plugin_label\n    .split(',')\n    .map(tag => tag.trim())\n    .filter(tag => tag.length > 0)\n})\n\n// 图片是否加载完成\nconst isImageLoaded = ref(false)\n\n// 图片是否加载失败\nconst imageLoadError = ref(false)\n\n// 更新日志弹窗\nconst releaseDialog = ref(false)\n\n// 插件详情弹窗\nconst detailDialog = ref(false)\n\n// 图片加载完成\nasync function imageLoaded() {\n  isImageLoaded.value = true\n  const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement\n  // 从图片中提取背景色\n  backgroundColor.value = await getDominantColor(imageElement)\n}\n\n// 安装插件\nasync function installPlugin() {\n  try {\n    // 显示等待提示框\n    progressDialog.value = true\n    progressText.value = t('plugin.installing', {\n      name: props.plugin?.plugin_name,\n      version: props?.plugin?.plugin_version,\n    })\n\n    const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {\n      params: {\n        repo_url: props.plugin?.repo_url,\n        force: props.plugin?.has_update,\n      },\n    })\n\n    // 隐藏等待提示框\n    progressDialog.value = false\n\n    if (result.success) {\n      $toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))\n      detailDialog.value = false\n      // 通知父组件刷新\n      emit('install')\n    } else {\n      $toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))\n    }\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 计算图标路径\nconst iconPath: Ref<string> = computed(() => {\n  if (imageLoadError.value) return getLogoUrl('plugin')\n  // 如果是网络图片则使用代理后返回\n  if (props.plugin?.plugin_icon?.startsWith('http'))\n    return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(\n      props.plugin?.plugin_icon,\n    )}&cache=true`\n\n  return `./plugin_icon/${props.plugin?.plugin_icon}`\n})\n\n// 访问插件页面\nfunction visitPluginPage() {\n  // 将raw.githubusercontent.com转换为项目地址\n  let repoUrl = props.plugin?.repo_url\n  if (props.plugin?.is_local || repoUrl?.startsWith('local://')) {\n    repoUrl = props.plugin?.author_url\n  }\n  if (repoUrl) {\n    if (repoUrl.includes('raw.githubusercontent.com')) {\n      if (!repoUrl.endsWith('/')) repoUrl += '/'\n\n      if (repoUrl.split('/').length < 6) repoUrl = `${repoUrl}main/`\n\n      try {\n        const [user, repo] = repoUrl.split('/').slice(-4, -2)\n        repoUrl = `https://github.com/${user}/${repo}`\n      } catch (error) {\n        return\n      }\n    }\n  } else {\n    repoUrl = props.plugin?.author_url\n  }\n  window.open(repoUrl, '_blank')\n}\n\n// 显示更新日志\nfunction showUpdateHistory() {\n  releaseDialog.value = true\n}\n\n// 弹出菜单\nconst dropdownItems = ref([\n  {\n    title: t('plugin.projectHome'),\n    value: 1,\n    show: true,\n    props: {\n      prependIcon: 'mdi-github',\n      click: visitPluginPage,\n    },\n  },\n  {\n    title: t('plugin.updateHistory'),\n    value: 2,\n    show: !isNullOrEmptyObject(props.plugin?.history || {}),\n    props: {\n      prependIcon: 'mdi-update',\n      click: showUpdateHistory,\n    },\n  },\n])\n</script>\n\n<template>\n  <div>\n    <VHover>\n      <template #default=\"hover\">\n        <VCard\n          v-bind=\"hover.props\"\n          :width=\"props.width\"\n          :height=\"props.height\"\n          @click=\"detailDialog = true\"\n          class=\"flex flex-col h-full\"\n          :class=\"{\n            'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,\n          }\"\n        >\n          <div\n            class=\"flex-grow\"\n            :style=\"`background: linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(${backgroundColor} 0%, ${backgroundColor} 100%)`\"\n          >\n            <VCardText class=\"px-2 pt-2 pb-0\">\n              <VCardTitle\n                class=\"text-white px-2 pb-0 text-lg text-shadow whitespace-nowrap overflow-hidden text-ellipsis\"\n              >\n                {{ props.plugin?.plugin_name }}\n                <span class=\"text-sm mt-1 text-gray-200\"> v{{ props.plugin?.plugin_version }} </span>\n              </VCardTitle>\n            </VCardText>\n            <div class=\"relative flex flex-row items-start px-2 justify-between grow\">\n              <div class=\"relative flex-1 min-w-0\">\n                <div\n                  class=\"text-white text-sm px-2 py-1 text-shadow overflow-hidden ...\"\n                  :class=\"{ 'line-clamp-3': !props.plugin?.plugin_label, 'line-clamp-2': props.plugin?.plugin_label }\"\n                >\n                  {{ props.plugin?.plugin_desc }}\n                </div>\n                <!-- 插件标签 -->\n                <div v-if=\"pluginLabels.length > 0\" class=\"plugin-app-card__tags-section px-2\">\n                  <VChip\n                    v-for=\"tag in pluginLabels\"\n                    :key=\"tag\"\n                    size=\"x-small\"\n                    variant=\"tonal\"\n                    color=\"info\"\n                    class=\"me-1 mb-1\"\n                    tile\n                  >\n                    {{ tag }}\n                  </VChip>\n                </div>\n              </div>\n              <div class=\"relative flex-shrink-0 self-center pb-3\">\n                <VAvatar size=\"48\">\n                  <VImg\n                    ref=\"imageRef\"\n                    :src=\"iconPath\"\n                    aspect-ratio=\"4/3\"\n                    cover\n                    @load=\"imageLoaded\"\n                    @error=\"imageLoadError = true\"\n                  />\n                </VAvatar>\n              </div>\n            </div>\n          </div>\n          <VCardText\n            class=\"flex flex-col align-self-baseline justify-between px-2 py-2 w-full overflow-hidden max-h-10 min-h-10\"\n          >\n            <div class=\"flex flex-nowrap items-center w-full pe-10\">\n              <div class=\"flex flex-nowrap max-w-40 items-center align-middle\">\n                <VIcon icon=\"mdi-github\" class=\"me-1\" />\n                <a\n                  class=\"overflow-hidden text-ellipsis whitespace-nowrap\"\n                  :href=\"props.plugin?.author_url\"\n                  target=\"_blank\"\n                  @click.stop\n                >\n                  {{ props.plugin?.plugin_author }}\n                </a>\n              </div>\n              <div v-if=\"props.count\" class=\"ms-2 flex-shrink-0 download-count align-middle items-center\">\n                <VIcon size=\"small\" icon=\"mdi-download\" />\n                <span class=\"text-sm\">{{ formatDownloadCount(props.count) }}</span>\n              </div>\n            </div>\n            <div class=\"absolute bottom-0 right-0\">\n              <IconBtn>\n                <VIcon size=\"small\" icon=\"mdi-dots-vertical\" />\n                <VMenu activator=\"parent\" close-on-content-click>\n                  <VList>\n                    <VListItem v-for=\"(item, i) in dropdownItems\" v-show=\"item.show\" :key=\"i\" @click=\"item.props.click\">\n                      <template #prepend>\n                        <VIcon :icon=\"item.props.prependIcon\" />\n                      </template>\n                      <VListItemTitle v-text=\"item.title\" />\n                    </VListItem>\n                  </VList>\n                </VMenu>\n              </IconBtn>\n            </div>\n          </VCardText>\n        </VCard>\n      </template>\n    </VHover>\n    <!-- 安装插件进度框 -->\n    <ProgressDialog v-if=\"progressDialog\" v-model=\"progressDialog\" :text=\"progressText\" />\n    <!-- 更新日志 -->\n    <VDialog v-if=\"releaseDialog\" v-model=\"releaseDialog\" width=\"600\" scrollable>\n      <VCard :title=\"t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })\">\n        <VDialogCloseBtn @click=\"releaseDialog = false\" />\n        <VDivider />\n        <VersionHistory :history=\"props.plugin?.history\" />\n      </VCard>\n    </VDialog>\n    <!-- 插件详情-->\n    <VDialog v-if=\"detailDialog\" v-model=\"detailDialog\" max-width=\"30rem\">\n      <VCard>\n        <VDialogCloseBtn @click=\"detailDialog = false\" />\n        <VCardText>\n          <VCol>\n            <div class=\"d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row\">\n              <div class=\"mx-auto mt-5\">\n                <VAvatar size=\"64\">\n                  <VImg\n                    ref=\"imageRef\"\n                    :src=\"iconPath\"\n                    aspect-ratio=\"4/3\"\n                    cover\n                    @load=\"imageLoaded\"\n                    @error=\"imageLoadError = true\"\n                  />\n                </VAvatar>\n              </div>\n              <div class=\"flex-grow\">\n                <VCardItem>\n                  <VCardTitle class=\"text-center text-md-left\">\n                    {{ props.plugin?.plugin_name }}\n                  </VCardTitle>\n                  <VCardSubtitle\n                    class=\"text-center text-md-left break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis ...\"\n                  >\n                    {{ props.plugin?.plugin_desc }}\n                  </VCardSubtitle>\n                  <VList lines=\"one\">\n                    <VListItem class=\"ps-0\">\n                      <VListItemTitle class=\"text-center text-md-left\">\n                        <span class=\"font-weight-medium\">{{ t('common.version') }}：</span>\n                        <span class=\"text-body-1\"> v{{ props.plugin?.plugin_version }}</span>\n                      </VListItemTitle>\n                    </VListItem>\n                    <VListItem class=\"ps-0\">\n                      <VListItemTitle class=\"text-center text-md-left\">\n                        <span class=\"font-weight-medium\">{{ t('common.author') }}：</span>\n                        <span class=\"text-body-1 cursor-pointer\" @click=\"visitPluginPage\">\n                          {{ props.plugin?.plugin_author }}\n                        </span>\n                      </VListItemTitle>\n                    </VListItem>\n                  </VList>\n                  <div class=\"text-center text-md-left\">\n                    <VBtn color=\"primary\" @click=\"installPlugin\" prepend-icon=\"mdi-download\">{{\n                      t('plugin.installToLocal')\n                    }}</VBtn>\n                    <div class=\"text-xs mt-2\" v-if=\"props.count\">\n                      <VIcon icon=\"mdi-fire\" />{{\n                        t('plugin.totalDownloads', { count: formatDownloadCount(props.count) })\n                      }}\n                    </div>\n                  </div>\n                </VCardItem>\n              </div>\n            </div>\n          </VCol>\n        </VCardText>\n      </VCard>\n    </VDialog>\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/cards/PluginCard.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useToast } from 'vue-toastification'\nimport { useConfirm } from '@/composables/useConfirm'\nimport api from '@/api'\nimport type { Plugin } from '@/api/types'\nimport { isNullOrEmptyObject } from '@core/utils'\nimport { getLogoUrl } from '@/utils/imageUtils'\nimport { getDominantColor } from '@/@core/utils/image'\nimport { formatDownloadCount } from '@/@core/utils/formatters'\nimport VersionHistory from '@/components/misc/VersionHistory.vue'\nimport ProgressDialog from '../dialog/ProgressDialog.vue'\nimport PluginConfigDialog from '../dialog/PluginConfigDialog.vue'\nimport PluginDataDialog from '../dialog/PluginDataDialog.vue'\nimport LoggingView from '@/views/system/LoggingView.vue'\nimport { useI18n } from 'vue-i18n'\nimport { useDisplay } from 'vuetify'\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 输入参数\nconst props = defineProps({\n  plugin: Object as PropType<Plugin>,\n  count: Number, // 下载次数\n  action: Boolean, // 动作标识\n  width: String,\n  height: String,\n})\n\n// 定义触发的自定义事件\nconst emit = defineEmits(['remove', 'save', 'actionDone'])\n\n// 多语言\nconst { t } = useI18n()\n\n// 背景颜色\nconst backgroundColor = ref('#28A9E1')\n\n// 图片对象\nconst imageRef = ref<any>()\n\n// 提示框\nconst $toast = useToast()\n\n// 确认框\nconst createConfirm = useConfirm()\n\n// 本身是否可见\nconst isVisible = ref(true)\n\n// 插件配置页面\nconst pluginConfigDialog = ref(false)\n\n// 菜单显示状态\nconst menuVisible = ref(false)\n\n// 进度框\nconst progressDialog = ref(false)\n\n// 插件数据页面\nconst pluginInfoDialog = ref(false)\n\n// 实时日志弹窗\nconst loggingDialog = ref(false)\n\n// 进度框文本\nconst progressText = ref('正在更新插件...')\n\n// 用户头像是否加载完成\nconst isAvatarLoaded = ref(false)\n\n// 图片是否加载完成\nconst isImageLoaded = ref(false)\n\n// 图片是否加载失败\nconst imageLoadError = ref(false)\n\n// 更新日志弹窗\nconst releaseDialog = ref(false)\n\n// 插件分身对话框\nconst pluginCloneDialog = ref(false)\n\n// 插件分身表单\nconst cloneForm = ref({\n  suffix: '',\n  name: '',\n  description: '',\n  version: '',\n  icon: '',\n})\n\n// 监听动作标识，如为true则打开详情\nwatch(\n  () => props.action,\n  (newAction, oldAction) => {\n    if (newAction && !oldAction) {\n      openPluginDetail()\n      emit('actionDone')\n    }\n  },\n)\n\n// 图片加载完成\nasync function imageLoaded() {\n  isImageLoaded.value = true\n  const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement\n  // 从图片中提取背景色\n  backgroundColor.value = await getDominantColor(imageElement)\n}\n\n// 显示更新日志\nfunction showUpdateHistory() {\n  // 检查当前版本是否有更新日志\n  if (isNullOrEmptyObject(props.plugin?.history)) {\n    updatePlugin()\n  } else {\n    releaseDialog.value = true\n  }\n}\n\n// 调用API卸载插件\nasync function uninstallPlugin() {\n  const isConfirmed = await createConfirm({\n    title: t('common.confirm'),\n    content: t('plugin.confirmUninstall', { name: props.plugin?.plugin_name }),\n  })\n\n  if (!isConfirmed) return\n\n  try {\n    // 显示等待提示框\n    progressDialog.value = true\n    progressText.value = t('plugin.uninstalling', { name: props.plugin?.plugin_name })\n    const result: { [key: string]: any } = await api.delete(`plugin/${props.plugin?.id}`)\n    // 隐藏等待提示框\n    progressDialog.value = false\n    if (result.success) {\n      $toast.success(t('plugin.uninstallSuccess', { name: props.plugin?.plugin_name }))\n\n      // 通知父组件刷新\n      emit('remove')\n    } else {\n      $toast.error(\n        t('plugin.uninstallFailed', {\n          name: props.plugin?.plugin_name,\n          message: result.message,\n        }),\n      )\n    }\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 显示插件数据\nasync function showPluginInfo() {\n  pluginConfigDialog.value = false\n  pluginInfoDialog.value = true\n}\n\n// 显示插件配置\nasync function showPluginConfig() {\n  // 显示对话框\n  pluginInfoDialog.value = false\n  pluginConfigDialog.value = true\n}\n\n// 计算图标路径\nconst iconPath: Ref<string> = computed(() => {\n  if (imageLoadError.value) return getLogoUrl('plugin')\n  // 如果是网络图片则使用代理后返回\n  if (props.plugin?.plugin_icon?.startsWith('http'))\n    return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(\n      props.plugin?.plugin_icon,\n    )}&cache=true`\n\n  return `./plugin_icon/${props.plugin?.plugin_icon}`\n})\n\n// 插件作者头像路径\nconst authorPath: Ref<string> = computed(() => {\n  // 网络图片则使用代理后返回\n  return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(\n    props.plugin?.author_url + '.png',\n  )}&cache=true`\n})\n\n// 重置插件\nasync function resetPlugin() {\n  const isConfirmed = await createConfirm({\n    title: t('common.confirm'),\n    content: t('plugin.confirmReset', { name: props.plugin?.plugin_name }),\n  })\n\n  if (!isConfirmed) return\n\n  try {\n    const result: { [key: string]: any } = await api.get(`plugin/reset/${props.plugin?.id}`)\n    if (result.success) {\n      $toast.success(t('plugin.resetSuccess', { name: props.plugin?.plugin_name }))\n      // 通知父组件刷新\n      emit('save')\n    } else {\n      $toast.error(\n        t('plugin.resetFailed', {\n          name: props.plugin?.plugin_name,\n          message: result.message,\n        }),\n      )\n    }\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 更新插件\nasync function updatePlugin() {\n  try {\n    releaseDialog.value = false\n    // 显示等待提示框\n    progressDialog.value = true\n    progressText.value = t('plugin.updating', { name: props.plugin?.plugin_name })\n\n    const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {\n      params: {\n        repo_url: props.plugin?.repo_url,\n        force: true,\n      },\n    })\n\n    // 隐藏等待提示框\n    progressDialog.value = false\n\n    if (result.success) {\n      $toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))\n\n      // 通知父组件刷新\n      emit('save')\n    } else {\n      $toast.error(\n        t('plugin.updateFailed', {\n          name: props.plugin?.plugin_name,\n          message: result.message,\n        }),\n      )\n    }\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 访问作者主页\nfunction visitAuthorPage() {\n  window.open(props.plugin?.author_url, '_blank')\n}\n\n// 查看日志URL\nfunction openLoggerWindow() {\n  const url = `${\n    import.meta.env.VITE_API_BASE_URL\n  }system/logging?length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`\n  window.open(url, '_blank')\n}\n\n// 打开插件详情\nfunction openPluginDetail() {\n  if (props.plugin?.has_page) showPluginInfo()\n  else showPluginConfig()\n}\n\n// 配置完成\nfunction configDone() {\n  pluginConfigDialog.value = false\n  emit('save')\n}\n\n// 显示插件分身对话框\nfunction showPluginClone() {\n  cloneForm.value = {\n    suffix: '',\n    name: t('plugin.cloneDefaultName', { name: props.plugin?.plugin_name }),\n    description: t('plugin.cloneDefaultDescription', { description: props.plugin?.plugin_desc }),\n    version: props.plugin?.plugin_version || '1.0',\n    icon: props.plugin?.plugin_icon || '',\n  }\n  pluginCloneDialog.value = true\n}\n\n// 执行插件分身\nasync function executePluginClone() {\n  if (!cloneForm.value.suffix.trim()) {\n    $toast.error(t('plugin.suffixRequired'))\n    return\n  }\n\n  try {\n    progressDialog.value = true\n    progressText.value = t('plugin.cloning', { name: props.plugin?.plugin_name })\n\n    const result: { [key: string]: any } = await api.post(`plugin/clone/${props.plugin?.id}`, {\n      suffix: cloneForm.value.suffix.trim(),\n      name: cloneForm.value.name.trim(),\n      description: cloneForm.value.description.trim(),\n      version: cloneForm.value.version.trim(),\n      icon: cloneForm.value.icon.trim(),\n    })\n\n    progressDialog.value = false\n\n    if (result.success) {\n      $toast.success(t('plugin.cloneSuccess', { name: cloneForm.value.name }))\n      pluginCloneDialog.value = false\n      // 通知父组件刷新\n      emit('remove')\n    } else {\n      $toast.error(t('plugin.cloneFailed', { message: result.message }))\n    }\n  } catch (error) {\n    progressDialog.value = false\n    $toast.error(t('plugin.cloneFailedGeneral'))\n    console.error(error)\n  }\n}\n\n// 弹出菜单\nconst dropdownItems = ref([\n  {\n    title: t('plugin.viewData'),\n    value: 1,\n    show: props.plugin?.has_page,\n    props: {\n      prependIcon: 'mdi-information-outline',\n      click: showPluginInfo,\n    },\n  },\n  {\n    title: t('plugin.settings'),\n    value: 2,\n    show: true,\n    props: {\n      prependIcon: 'mdi-cog-outline',\n      click: showPluginConfig,\n    },\n  },\n  {\n    title: t('plugin.clone'),\n    value: 8,\n    show: true,\n    props: {\n      prependIcon: 'mdi-content-copy',\n      color: 'info',\n      click: showPluginClone,\n    },\n  },\n  {\n    title: t('plugin.update'),\n    value: 3,\n    show: props.plugin?.has_update,\n    props: {\n      prependIcon: 'mdi-arrow-up-circle-outline',\n      color: 'success',\n      click: showUpdateHistory,\n    },\n  },\n  {\n    title: t('plugin.reset'),\n    value: 4,\n    show: true,\n    props: {\n      prependIcon: 'mdi-cancel',\n      color: 'warning',\n      click: resetPlugin,\n    },\n  },\n  {\n    title: t('plugin.uninstall'),\n    value: 5,\n    show: true,\n    props: {\n      prependIcon: 'mdi-trash-can-outline',\n      color: 'error',\n      click: uninstallPlugin,\n    },\n  },\n  {\n    title: t('plugin.viewLogs'),\n    value: 6,\n    show: true,\n    props: {\n      prependIcon: 'mdi-file-document-outline',\n      click: () => {\n        loggingDialog.value = true\n      },\n    },\n  },\n  {\n    title: t('plugin.authorHome'),\n    value: 7,\n    show: true,\n    props: {\n      prependIcon: 'mdi-home-circle-outline',\n      click: visitAuthorPage,\n    },\n  },\n])\n\n// 监听插件状态变化\nwatch(\n  () => props.plugin?.has_update,\n  (newHasUpdate, _) => {\n    const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3)\n    if (updateItemIndex !== -1) dropdownItems.value[updateItemIndex].show = newHasUpdate\n  },\n)\n\n// 监听插件窗口状态变化\nwatch(\n  () => props.plugin?.page_open,\n  (newOpenState, _) => {\n    if (newOpenState) openPluginDetail()\n  },\n)\n</script>\n\n<template>\n  <div class=\"h-full\">\n    <!-- 插件卡片 -->\n    <VHover>\n      <template #default=\"hover\">\n        <VCard\n          v-if=\"isVisible\"\n          v-bind=\"hover.props\"\n          :width=\"props.width\"\n          :height=\"props.height\"\n          @click=\"openPluginDetail\"\n          class=\"flex flex-col h-full\"\n          :class=\"{\n            'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,\n          }\"\n        >\n          <div\n            class=\"flex-grow\"\n            :style=\"`background: linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(${backgroundColor} 0%, ${backgroundColor} 100%)`\"\n          >\n            <VCardText class=\"px-2 pt-2 pb-0\">\n              <VCardTitle\n                class=\"text-white px-2 pb-0 text-lg text-shadow whitespace-nowrap overflow-hidden text-ellipsis\"\n              >\n                <VBadge dot inline :color=\"props.plugin?.state ? 'success' : 'secondary'\" />\n                {{ props.plugin?.plugin_name }}\n                <span class=\"text-sm mt-1 text-gray-200\"> v{{ props.plugin?.plugin_version }} </span>\n              </VCardTitle>\n            </VCardText>\n            <div class=\"relative flex flex-row items-start px-2 justify-between grow\">\n              <div class=\"relative flex-1 min-w-0\">\n                <div class=\"px-2 py-1 text-white text-sm text-shadow overflow-hidden line-clamp-3 ...\">\n                  {{ props.plugin?.plugin_desc }}\n                </div>\n              </div>\n              <div class=\"relative flex-shrink-0 self-center pb-3\" :class=\"{ 'cursor-move': display.mdAndUp.value }\">\n                <VAvatar size=\"48\">\n                  <VImg\n                    ref=\"imageRef\"\n                    :src=\"iconPath\"\n                    aspect-ratio=\"4/3\"\n                    cover\n                    @load=\"imageLoaded\"\n                    @error=\"imageLoadError = true\"\n                  />\n                </VAvatar>\n              </div>\n            </div>\n          </div>\n          <VCardText\n            class=\"flex flex-col align-self-baseline justify-between px-2 py-2 w-full overflow-hidden max-h-10 min-h-10\"\n          >\n            <div class=\"flex flex-nowrap items-center w-full pe-10\">\n              <div class=\"flex flex-nowrap max-w-40 items-center align-middle\">\n                <VImg :src=\"authorPath\" class=\"author-avatar\" @load=\"isAvatarLoaded = true\">\n                  <template #default>\n                    <VIcon v-if=\"!isAvatarLoaded\" size=\"small\" icon=\"mdi-github\" class=\"me-1\" />\n                  </template>\n                </VImg>\n                <a\n                  :href=\"props.plugin?.author_url\"\n                  target=\"_blank\"\n                  @click.stop\n                  class=\"overflow-hidden text-ellipsis whitespace-nowrap\"\n                >\n                  {{ props.plugin?.plugin_author }}\n                </a>\n              </div>\n              <span v-if=\"props.count\" class=\"ms-2 flex-shrink-0 download-count items-center align-middle\">\n                <VIcon size=\"small\" icon=\"mdi-download\" />\n                <span class=\"text-sm\">{{ formatDownloadCount(props.count) }}</span>\n              </span>\n            </div>\n            <div class=\"absolute bottom-0 right-0\">\n              <IconBtn>\n                <VIcon icon=\"mdi-dots-vertical\" />\n                <VMenu v-model=\"menuVisible\" activator=\"parent\" close-on-content-click>\n                  <VList>\n                    <VListItem\n                      v-for=\"(item, i) in dropdownItems\"\n                      v-show=\"item.show\"\n                      :key=\"i\"\n                      :base-color=\"item.props.color\"\n                      @click=\"item.props.click\"\n                    >\n                      <template #prepend>\n                        <VIcon :icon=\"item.props.prependIcon\" />\n                      </template>\n                      <VListItemTitle v-text=\"item.title\" />\n                    </VListItem>\n                  </VList>\n                </VMenu>\n              </IconBtn>\n            </div>\n          </VCardText>\n          <div v-if=\"props.plugin?.has_update\" class=\"me-n3 absolute top-0 right-5\">\n            <VIcon icon=\"mdi-new-box\" class=\"text-white\" />\n          </div>\n        </VCard>\n      </template>\n    </VHover>\n\n    <!-- 插件配置页面 -->\n    <PluginConfigDialog\n      v-if=\"pluginConfigDialog\"\n      v-model=\"pluginConfigDialog\"\n      :plugin=\"props.plugin\"\n      @save=\"configDone\"\n      @close=\"pluginConfigDialog = false\"\n      @switch=\"showPluginInfo\"\n    />\n\n    <!-- 插件数据页面 -->\n    <PluginDataDialog\n      v-if=\"pluginInfoDialog\"\n      v-model=\"pluginInfoDialog\"\n      :plugin=\"props.plugin\"\n      @close=\"pluginInfoDialog = false\"\n      @switch=\"showPluginConfig\"\n    />\n\n    <!-- 进度框 -->\n    <ProgressDialog v-if=\"progressDialog\" v-model=\"progressDialog\" :text=\"progressText\" />\n\n    <!-- 更新日志 -->\n    <VDialog v-if=\"releaseDialog\" v-model=\"releaseDialog\" width=\"600\" scrollable :fullscreen=\"!display.mdAndUp.value\">\n      <VCard :title=\"t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })\">\n        <VDialogCloseBtn @click=\"releaseDialog = false\" />\n        <VDivider />\n        <VersionHistory :history=\"props.plugin?.history\" />\n        <VDivider />\n        <VCardItem>\n          <VBtn @click=\"updatePlugin\" block>\n            <template #prepend>\n              <VIcon icon=\"mdi-arrow-up-circle-outline\" />\n            </template>\n            {{ t('plugin.updateToLatest') }}\n          </VBtn>\n        </VCardItem>\n      </VCard>\n    </VDialog>\n\n    <!-- 实时日志弹窗 -->\n    <VDialog\n      v-if=\"loggingDialog\"\n      v-model=\"loggingDialog\"\n      scrollable\n      max-width=\"60rem\"\n      :fullscreen=\"!display.mdAndUp.value\"\n    >\n      <VCard>\n        <VDialogCloseBtn @click=\"loggingDialog = false\" />\n        <VCardItem>\n          <VCardTitle class=\"d-inline-flex\">\n            <VIcon icon=\"mdi-file-document\" class=\"me-2\" />\n            {{ t('plugin.logTitle') }}\n            <a class=\"mx-2 d-inline-flex align-center cursor-pointer\" @click=\"openLoggerWindow\">\n              <VChip color=\"grey-darken-1\" size=\"small\" class=\"ml-2\">\n                <VIcon icon=\"mdi-open-in-new\" size=\"small\" start />\n                {{ t('common.openInNewWindow') }}\n              </VChip>\n            </a>\n          </VCardTitle>\n        </VCardItem>\n        <VDivider />\n        <VCardText>\n          <LoggingView :logfile=\"`plugins/${props.plugin?.id?.toLowerCase()}.log`\" />\n        </VCardText>\n      </VCard>\n    </VDialog>\n\n    <!-- 插件分身对话框 -->\n    <VDialog\n      v-if=\"pluginCloneDialog\"\n      v-model=\"pluginCloneDialog\"\n      width=\"600\"\n      scrollable\n      :fullscreen=\"!display.mdAndUp.value\"\n    >\n      <VCard>\n        <VCardItem class=\"py-2\">\n          <template #prepend>\n            <VIcon icon=\"mdi-content-copy\" class=\"me-2\" />\n          </template>\n          <VCardTitle>{{ t('plugin.cloneTitle') }}</VCardTitle>\n          <VCardSubtitle>{{ t('plugin.cloneSubtitle', { name: props.plugin?.plugin_name }) }}</VCardSubtitle>\n        </VCardItem>\n        <VDialogCloseBtn @click=\"pluginCloneDialog = false\" />\n        <VDivider />\n        <VCardText>\n          <VForm>\n            <VRow>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"cloneForm.suffix\"\n                  :label=\"t('plugin.suffix') + ' *'\"\n                  :placeholder=\"t('plugin.suffixPlaceholder')\"\n                  :hint=\"t('plugin.suffixHint')\"\n                  persistent-hint\n                  :rules=\"[\n                    v => !!v || t('plugin.suffixRequired'),\n                    v => /^[a-zA-Z0-9]+$/.test(v) || t('plugin.suffixFormatError'),\n                    v => v.length <= 20 || t('plugin.suffixLengthError'),\n                  ]\"\n                  required\n                  prepend-inner-icon=\"mdi-tag\"\n                />\n              </VCol>\n\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"cloneForm.name\"\n                  :label=\"t('plugin.cloneName')\"\n                  :placeholder=\"t('plugin.cloneNamePlaceholder')\"\n                  :hint=\"t('plugin.cloneNameHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-rename-box\"\n                />\n              </VCol>\n\n              <VCol cols=\"12\">\n                <VTextField\n                  v-model=\"cloneForm.description\"\n                  :label=\"t('plugin.cloneDescriptionLabel')\"\n                  :placeholder=\"t('plugin.cloneDescriptionPlaceholder')\"\n                  :hint=\"t('plugin.cloneDescriptionHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-text\"\n                />\n              </VCol>\n\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"cloneForm.version\"\n                  :label=\"t('plugin.cloneVersion')\"\n                  :placeholder=\"t('plugin.cloneVersionPlaceholder')\"\n                  :hint=\"t('plugin.cloneVersionHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-numeric\"\n                />\n              </VCol>\n\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"cloneForm.icon\"\n                  :label=\"t('plugin.cloneIcon')\"\n                  :placeholder=\"t('plugin.cloneIconPlaceholder')\"\n                  :hint=\"t('plugin.cloneIconHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-image\"\n                />\n              </VCol>\n\n              <!-- 重要提醒 -->\n              <VCol cols=\"12\">\n                <VAlert type=\"warning\" variant=\"tonal\" density=\"compact\" class=\"mt-2\" icon=\"mdi-alert-circle-outline\">\n                  <div class=\"text-body-2\">\n                    <strong>{{ t('common.notice') }}</strong\n                    >：{{ t('plugin.cloneNotice') }}\n                  </div>\n                </VAlert>\n              </VCol>\n            </VRow>\n          </VForm>\n        </VCardText>\n        <VCardActions class=\"pt-3\">\n          <VSpacer />\n          <VBtn\n            color=\"primary\"\n            @click=\"executePluginClone\"\n            prepend-icon=\"mdi-content-copy\"\n            class=\"px-5\"\n            :disabled=\"!cloneForm.suffix.trim()\"\n          >\n            {{ t('plugin.createClone') }}\n          </VBtn>\n        </VCardActions>\n      </VCard>\n    </VDialog>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.card-cover-blurred::before {\n  position: absolute;\n  /* stylelint-disable-next-line property-no-vendor-prefix */\n  -webkit-backdrop-filter: blur(2px);\n  backdrop-filter: blur(2px);\n  background: rgba(29, 39, 59, 48%);\n  content: '';\n  inset: 0;\n}\n\n.author-avatar {\n  border-radius: 50%;\n  block-size: 24px;\n  inline-size: 24px;\n  margin-inline-end: 8px;\n  object-fit: cover;\n}\n</style>\n"
  },
  {
    "path": "src/components/cards/PluginFolderCard.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useToast } from 'vue-toastification'\nimport { useConfirm } from '@/composables/useConfirm'\nimport { useI18n } from 'vue-i18n'\nimport { useDisplay } from 'vuetify'\n\n// 文件夹配置接口\ninterface FolderConfig {\n  plugins?: string[]\n  order?: number\n  background?: string\n  icon?: string\n  color?: string\n  gradient?: string\n  showIcon?: boolean\n}\n\n// 输入参数\nconst props = defineProps({\n  folderName: String,\n  pluginCount: Number,\n  folderConfig: {\n    type: Object as PropType<FolderConfig>,\n    default: () => ({}),\n  },\n  width: String,\n  height: String,\n})\n\n// 定义触发的自定义事件\nconst emit = defineEmits(['open', 'delete', 'rename', 'update-config'])\n\n// 多语言\nconst { t } = useI18n()\n\n// 响应式显示\nconst display = useDisplay()\n\n// 提示框\nconst $toast = useToast()\n\n// 确认框\nconst createConfirm = useConfirm()\n\n// 菜单显示状态\nconst menuVisible = ref(false)\n\n// 重命名对话框\nconst renameDialog = ref(false)\n\n// 设置对话框\nconst settingDialog = ref(false)\n\n// 新名称\nconst newFolderName = ref('')\n\n// 默认颜色\nconst defaultColor = '#2196F3'\n// 默认图标\nconst defaultIcon = 'mdi-folder'\n// 默认渐变\nconst defaultGradient =\n  'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(135deg, rgba(33, 150, 243, 0.7) 0%, rgba(33, 150, 243, 0.8s) 100%)'\n\n// 文件夹设置\nconst folderSettings = ref<FolderConfig>({\n  background: '',\n  icon: defaultIcon,\n  color: defaultColor,\n  gradient: defaultGradient,\n  showIcon: true,\n})\n\n// 计算背景图片\nconst backgroundImage = computed(() => {\n  return props.folderConfig.background || folderSettings.value.background\n})\n\n// 预设图标选项\nconst iconOptions = [\n  'mdi-folder',\n  'mdi-folder-star',\n  'mdi-folder-heart',\n  'mdi-folder-cog',\n  'mdi-folder-music',\n  'mdi-folder-image',\n  'mdi-folder-video',\n  'mdi-folder-download',\n  'mdi-folder-network',\n  'mdi-folder-special',\n]\n\n// 预设颜色选项\nconst colorOptions = [\n  '#2196F3', // 蓝色\n  '#4CAF50', // 绿色\n  '#FF9800', // 橙色\n  '#9C27B0', // 紫色\n  '#F44336', // 红色\n  '#607D8B', // 蓝灰色\n  '#795548', // 棕色\n  '#E91E63', // 粉色\n]\n\n// 预设渐变选项\nconst gradientOptions = [\n  'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(33, 150, 243, 0.7) 0%, rgba(33, 150, 243, 0.8) 100%)',\n  'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(76, 175, 80, 0.7) 0%, rgba(76, 175, 80, 0.8) 100%)',\n  'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(255, 152, 0, 0.7) 0%, rgba(255, 152, 0, 0.8) 100%)',\n  'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(156, 39, 176, 0.7) 0%, rgba(156, 39, 176, 0.8) 100%)',\n  'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(244, 67, 54, 0.7) 0%, rgba(244, 67, 54, 0.8) 100%)',\n  'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(96, 125, 139, 0.7) 0%, rgba(96, 125, 139, 0.8) 100%)',\n  'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(233, 30, 99, 0.7) 0%, rgba(233, 30, 99, 0.8) 100%)',\n  'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(63, 81, 181, 0.7) 0%, rgba(156, 39, 176, 0.8) 100%)',\n]\n\n// 计算背景渐变\nconst backgroundGradient = computed(() => {\n  const config = props.folderConfig || {}\n  const settings = folderSettings.value\n\n  return config.gradient || settings.gradient || gradientOptions[0]\n})\n\n// 计算图标\nconst folderIcon = computed(() => {\n  const config = props.folderConfig || {}\n  const settings = folderSettings.value\n\n  return config.icon || settings.icon || defaultIcon\n})\n\n// 计算图标颜色\nconst iconColor = computed(() => {\n  const config = props.folderConfig || {}\n  const settings = folderSettings.value\n\n  return config.color || settings.color || defaultColor\n})\n\n// 计算是否显示图标\nconst shouldShowIcon = computed(() => {\n  const config = props.folderConfig || {}\n  const settings = folderSettings.value\n\n  return config.showIcon !== undefined ? config.showIcon : settings.showIcon !== undefined ? settings.showIcon : true\n})\n\n// 监听props变化，更新本地设置\nwatch(\n  () => props.folderConfig,\n  newConfig => {\n    if (newConfig) {\n      folderSettings.value = {\n        ...folderSettings.value,\n        ...newConfig,\n      }\n    }\n  },\n  { deep: true, immediate: true },\n)\n\n// 打开文件夹\nfunction openFolder() {\n  emit('open', props.folderName)\n}\n\n// 重命名文件夹\nfunction showRenameDialog() {\n  newFolderName.value = props.folderName || ''\n  renameDialog.value = true\n}\n\n// 确认重命名\nasync function confirmRename() {\n  if (!newFolderName.value.trim()) {\n    $toast.error(t('folder.folderNameCannotBeEmpty'))\n    return\n  }\n\n  if (newFolderName.value === props.folderName) {\n    renameDialog.value = false\n    return\n  }\n\n  try {\n    emit('rename', props.folderName, newFolderName.value)\n    renameDialog.value = false\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 删除文件夹\nasync function deleteFolder() {\n  const isConfirmed = await createConfirm({\n    title: t('common.confirm'),\n    content: t('folder.confirmDeleteFolder', { folderName: props.folderName }),\n  })\n\n  if (!isConfirmed) return\n\n  try {\n    emit('delete', props.folderName)\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 显示设置对话框\nfunction showSettingDialog() {\n  folderSettings.value = {\n    background: props.folderConfig?.background || '',\n    icon: props.folderConfig?.icon || defaultIcon,\n    color: props.folderConfig?.color || defaultColor,\n    gradient: props.folderConfig?.gradient || gradientOptions[0],\n    showIcon: props.folderConfig?.showIcon !== undefined ? props.folderConfig.showIcon : true,\n  }\n  settingDialog.value = true\n}\n\n// 保存设置\nfunction saveSettings() {\n  const config = {\n    ...props.folderConfig,\n    ...folderSettings.value,\n  }\n\n  emit('update-config', props.folderName, config)\n  settingDialog.value = false\n  $toast.success(t('folder.folderSettingsSaved'))\n}\n\n// 弹出菜单\nconst dropdownItems = ref([\n  {\n    title: t('folder.settingAppearance'),\n    value: 0,\n    show: true,\n    props: {\n      prependIcon: 'mdi-palette',\n      click: showSettingDialog,\n    },\n  },\n  {\n    title: t('folder.rename'),\n    value: 1,\n    show: true,\n    props: {\n      prependIcon: 'mdi-pencil',\n      click: showRenameDialog,\n    },\n  },\n  {\n    title: t('folder.deleteFolder'),\n    value: 2,\n    show: true,\n    props: {\n      prependIcon: 'mdi-delete',\n      color: 'error',\n      click: deleteFolder,\n    },\n  },\n])\n</script>\n\n<template>\n  <div class=\"h-full\">\n    <!-- 文件夹卡片 -->\n    <VHover>\n      <template #default=\"hover\">\n        <VCard\n          v-bind=\"hover.props\"\n          :ripple=\"false\"\n          :width=\"props.width\"\n          :height=\"props.height\"\n          min-height=\"8.5rem\"\n          @click=\"openFolder\"\n          class=\"plugin-folder-card h-full\"\n          :class=\"{\n            'plugin-folder-card--mobile': display.mobile,\n            'plugin-folder-card--hover': hover.isHovering,\n          }\"\n        >\n          <template v-if=\"backgroundImage\" #image>\n            <VImg :src=\"backgroundImage\" cover position=\"top\"> </VImg>\n          </template>\n\n          <!-- 背景遮罩（当有背景图片时） -->\n          <div v-if=\"backgroundImage\" class=\"plugin-folder-card__overlay\" />\n\n          <!-- 背景渐变层 -->\n          <div v-else class=\"plugin-folder-card__bg\" :style=\"{ background: backgroundGradient }\" />\n\n          <!-- 卡片内容 -->\n          <div class=\"plugin-folder-card__content\">\n            <!-- 主体内容 -->\n            <div class=\"plugin-folder-card__body\" :class=\"{ 'plugin-folder-card__body--no-icon': !shouldShowIcon }\">\n              <!-- 文件夹图标 -->\n              <div v-if=\"shouldShowIcon\" class=\"plugin-folder-card__icon-container\">\n                <VIcon\n                  :icon=\"folderIcon\"\n                  :size=\"display.mobile ? 56 : 72\"\n                  :color=\"iconColor\"\n                  :class=\"{ 'cursor-move': display.mdAndUp.value }\"\n                />\n              </div>\n\n              <!-- 文件夹信息 -->\n              <div\n                class=\"plugin-folder-card__info\"\n                :class=\"{ 'cursor-move': display.mdAndUp.value, 'plugin-folder-card__info--no-icon': !shouldShowIcon }\"\n              >\n                <!-- 文件夹名称 -->\n                <h3 class=\"plugin-folder-card__name\">\n                  {{ props.folderName }}\n                </h3>\n                <!-- 插件数量 -->\n                <p class=\"plugin-folder-card__count\">{{ t('folder.pluginCount', { count: props.pluginCount }) }}</p>\n              </div>\n            </div>\n\n            <!-- 更多菜单按钮 - 右下角 -->\n            <div class=\"absolute top-0 right-0\">\n              <VMenu v-model=\"menuVisible\" location=\"top end\" :close-on-content-click=\"true\">\n                <template #activator=\"{ props: menuProps }\">\n                  <IconBtn v-bind=\"menuProps\" @click.stop>\n                    <VIcon size=\"small\" icon=\"mdi-dots-vertical\" class=\"text-white\" />\n                  </IconBtn>\n                </template>\n                <VList>\n                  <VListItem\n                    v-for=\"(item, i) in dropdownItems\"\n                    v-show=\"item.show\"\n                    :key=\"i\"\n                    :base-color=\"item.props.color\"\n                    @click=\"item.props.click\"\n                  >\n                    <template #prepend>\n                      <VIcon :icon=\"item.props.prependIcon\" size=\"16\" />\n                    </template>\n                    <VListItemTitle class=\"text-body-2\">{{ item.title }}</VListItemTitle>\n                  </VListItem>\n                </VList>\n              </VMenu>\n            </div>\n          </div>\n        </VCard>\n      </template>\n    </VHover>\n\n    <!-- 重命名对话框 -->\n    <VDialog v-if=\"renameDialog\" v-model=\"renameDialog\" max-width=\"400\">\n      <VCard>\n        <VCardItem>\n          <template #prepend>\n            <VIcon icon=\"mdi-pencil\" class=\"me-2\" />\n          </template>\n          <VCardTitle>{{ t('folder.renameFolder') }}</VCardTitle>\n        </VCardItem>\n        <VDialogCloseBtn @click=\"renameDialog = false\" />\n        <VDivider />\n        <VCardText>\n          <VTextField\n            v-model=\"newFolderName\"\n            :label=\"t('folder.folderName')\"\n            variant=\"outlined\"\n            autofocus\n            @keyup.enter=\"confirmRename\"\n          />\n        </VCardText>\n        <VCardActions>\n          <VSpacer />\n          <VBtn color=\"primary\" prepend-icon=\"mdi-check\" class=\"px-5\" @click=\"confirmRename\">确认</VBtn>\n        </VCardActions>\n      </VCard>\n    </VDialog>\n\n    <!-- 设置对话框 -->\n    <VDialog\n      v-if=\"settingDialog\"\n      v-model=\"settingDialog\"\n      max-width=\"600\"\n      scrollable\n      :fullscreen=\"!display.mdAndUp.value\"\n    >\n      <VCard>\n        <VDialogCloseBtn @click=\"settingDialog = false\" />\n        <VCardItem>\n          <VCardTitle>\n            <VIcon icon=\"mdi-palette\" class=\"mr-2\" />\n            {{ t('folder.folderAppearanceSettings') }}\n          </VCardTitle>\n        </VCardItem>\n        <VDivider />\n        <VCardText>\n          <VRow>\n            <!-- 显示图标开关 -->\n            <VCol cols=\"12\">\n              <VSwitch\n                v-model=\"folderSettings.showIcon\"\n                :label=\"t('folder.showFolderIcon')\"\n                color=\"primary\"\n                hide-details\n              />\n            </VCol>\n\n            <!-- 图标选择 -->\n            <VCol v-if=\"folderSettings.showIcon\" cols=\"12\" md=\"6\">\n              <VCardSubtitle class=\"pa-0 mb-2\">{{ t('folder.icon') }}</VCardSubtitle>\n              <div class=\"icon-grid\">\n                <VBtn\n                  v-for=\"icon in iconOptions\"\n                  icon\n                  :key=\"icon\"\n                  :variant=\"folderSettings.icon === icon ? 'tonal' : 'text'\"\n                  :color=\"folderSettings.icon === icon ? 'primary' : 'default'\"\n                  size=\"large\"\n                  class=\"ma-1\"\n                  @click=\"folderSettings.icon = icon\"\n                >\n                  <VIcon :icon=\"icon\" size=\"24\" />\n                </VBtn>\n              </div>\n            </VCol>\n\n            <!-- 颜色选择 -->\n            <VCol v-if=\"folderSettings.showIcon\" cols=\"12\" md=\"6\">\n              <VCardSubtitle class=\"pa-0 mb-2\">{{ t('folder.iconColor') }}</VCardSubtitle>\n              <div class=\"color-grid\">\n                <VBtn\n                  v-for=\"color in colorOptions\"\n                  :key=\"color\"\n                  :variant=\"folderSettings.color === color ? 'tonal' : 'text'\"\n                  :color=\"color\"\n                  size=\"large\"\n                  class=\"ma-1 color-btn\"\n                  :style=\"{ backgroundColor: color }\"\n                  @click=\"folderSettings.color = color\"\n                >\n                  <VIcon v-if=\"folderSettings.color === color\" icon=\"mdi-check\" color=\"white\" />\n                </VBtn>\n              </div>\n            </VCol>\n\n            <!-- 渐变背景选择 -->\n            <VCol cols=\"12\">\n              <VCardSubtitle class=\"pa-0 mb-2\">{{ t('folder.backgroundGradient') }}</VCardSubtitle>\n              <div class=\"gradient-grid\">\n                <VBtn\n                  v-for=\"(gradient, index) in gradientOptions\"\n                  :key=\"index\"\n                  :variant=\"folderSettings.gradient === gradient ? 'tonal' : 'text'\"\n                  class=\"ma-1 gradient-btn\"\n                  :style=\"{ background: gradient }\"\n                  size=\"large\"\n                  @click=\"folderSettings.gradient = gradient\"\n                >\n                  <VIcon v-if=\"folderSettings.gradient === gradient\" icon=\"mdi-check\" color=\"white\" />\n                </VBtn>\n              </div>\n            </VCol>\n\n            <!-- 自定义背景图片 -->\n            <VCol cols=\"12\">\n              <VTextField\n                v-model=\"folderSettings.background\"\n                :label=\"t('folder.customBackgroundImageURL')\"\n                placeholder=\"https://example.com/image.jpg\"\n                variant=\"outlined\"\n                :hint=\"t('folder.customBackgroundImageHint')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-image\"\n              />\n            </VCol>\n          </VRow>\n        </VCardText>\n        <VCardActions>\n          <VSpacer />\n          <VBtn color=\"primary\" prepend-icon=\"mdi-content-save\" class=\"px-5\" @click=\"saveSettings\">保存</VBtn>\n        </VCardActions>\n      </VCard>\n    </VDialog>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.plugin-folder-card {\n  position: relative;\n  overflow: hidden;\n  cursor: pointer;\n  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n\n  &--hover {\n    transform: translateY(-4px);\n  }\n\n  &__bg {\n    position: absolute;\n    z-index: 0;\n    inset: 0;\n    outline: none;\n  }\n\n  &__overlay {\n    position: absolute;\n    z-index: 1;\n    background: rgba(0, 0, 0, 60%);\n    inset: 0;\n  }\n\n  &__content {\n    position: relative;\n    z-index: 2;\n    display: flex;\n    flex-direction: column;\n    padding: 16px;\n    block-size: 100%;\n    padding-block-end: 12px;\n\n    .plugin-folder-card--mobile & {\n      padding: 12px;\n      padding-block-end: 10px;\n    }\n  }\n\n  &__body {\n    display: flex;\n    flex: 1;\n    flex-direction: row;\n    align-items: center;\n    justify-content: flex-start;\n    gap: 16px;\n    padding-block: 0;\n    padding-inline: 8px;\n\n    .plugin-folder-card--mobile & {\n      gap: 12px;\n      padding-block: 0;\n      padding-inline: 4px;\n    }\n\n    &--no-icon {\n      align-items: flex-start;\n      justify-content: flex-start;\n      padding: 16px;\n      gap: 0;\n\n      .plugin-folder-card--mobile & {\n        padding: 12px;\n        gap: 0;\n      }\n    }\n  }\n\n  &__icon-container {\n    display: flex;\n    flex-shrink: 0;\n    align-items: center;\n    justify-content: center;\n  }\n\n  &__info {\n    flex: 1;\n    min-block-size: 0;\n    text-align: start;\n\n    &--no-icon {\n      flex: none;\n      text-align: start;\n    }\n  }\n\n  &__name {\n    display: -webkit-box;\n    overflow: hidden;\n    margin: 0;\n    -webkit-box-orient: vertical;\n    color: white;\n    font-size: 1.1rem;\n    font-weight: 600;\n    -webkit-line-clamp: 1;\n    line-clamp: 1;\n    line-height: 1.3;\n    max-inline-size: none;\n    text-overflow: ellipsis;\n    text-shadow: 0 2px 4px rgba(0, 0, 0, 50%);\n\n    .plugin-folder-card--mobile & {\n      font-size: 1rem;\n    }\n\n    .plugin-folder-card__info--no-icon & {\n      font-size: 1.3rem;\n      font-weight: 700;\n      -webkit-line-clamp: 2;\n      line-clamp: 2;\n      margin-block-end: 4px;\n\n      .plugin-folder-card--mobile & {\n        font-size: 1.2rem;\n      }\n    }\n  }\n\n  &__count {\n    color: white;\n    font-size: 0.85rem;\n    margin-block: 2px 0;\n    margin-inline: 0;\n    opacity: 0.9;\n    text-shadow: 0 1px 2px rgba(0, 0, 0, 50%);\n\n    .plugin-folder-card--mobile & {\n      font-size: 0.8rem;\n    }\n\n    .plugin-folder-card__info--no-icon & {\n      font-size: 0.9rem;\n      margin-block-start: 0;\n\n      .plugin-folder-card--mobile & {\n        font-size: 0.85rem;\n      }\n    }\n  }\n}\n\n// 设置对话框样式\n.icon-grid {\n  display: grid;\n  gap: 8px;\n  grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));\n  max-block-size: 200px;\n  overflow-y: auto;\n}\n\n.color-grid {\n  display: grid;\n  gap: 8px;\n  grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));\n}\n\n.gradient-grid {\n  display: grid;\n  gap: 8px;\n  grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));\n  max-block-size: 200px;\n  overflow-y: auto;\n}\n\n.color-btn {\n  border-radius: 8px !important;\n  block-size: 60px !important;\n  min-inline-size: 60px !important;\n}\n\n.gradient-btn {\n  border-radius: 8px !important;\n  block-size: 60px !important;\n  min-inline-size: 120px !important;\n}\n</style>\n"
  },
  {
    "path": "src/components/cards/PluginMixedSortCard.vue",
    "content": "<script lang=\"ts\" setup>\nimport PluginCard from './PluginCard.vue'\nimport PluginFolderCard from './PluginFolderCard.vue'\n\ninterface MixedSortItem {\n  type: 'folder' | 'plugin'\n  id: string\n  data: any\n  order: number\n}\n\ninterface Props {\n  item: MixedSortItem\n  pluginStatistics?: { [key: string]: number }\n  pluginActions?: { [key: string]: boolean }\n  showRemoveButton?: boolean\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  pluginStatistics: () => ({}),\n  pluginActions: () => ({}),\n  showRemoveButton: false,\n})\n\nconst emit = defineEmits<{\n  openFolder: [folderName: string]\n  deleteFolder: [folderName: string]\n  renameFolder: [oldName: string, newName: string]\n  updateFolderConfig: [folderName: string, config: any]\n  refreshData: []\n  actionDone: [pluginId: string]\n  removeFromFolder: [pluginId: string]\n  dropToFolder: [event: DragEvent, folderName: string]\n}>()\n\n// 拖拽事件处理\nfunction handleDragOver(event: DragEvent) {\n  // 只有当拖拽的是插件时才允许放入文件夹\n  if (props.item.type === 'folder') {\n    event.preventDefault()\n    event.stopPropagation()\n    event.dataTransfer!.dropEffect = 'move'\n    const target = event.currentTarget as HTMLElement\n    target.classList.add('drag-over')\n  }\n}\n\nfunction handleDragEnter(event: DragEvent) {\n  if (props.item.type === 'folder') {\n    event.preventDefault()\n    event.stopPropagation()\n  }\n}\n\nfunction handleDragLeave(event: DragEvent) {\n  if (props.item.type === 'folder') {\n    event.preventDefault()\n    event.stopPropagation()\n    const target = event.currentTarget as HTMLElement\n    target.classList.remove('drag-over')\n  }\n}\n\nfunction handleDropToFolder(event: DragEvent) {\n  if (props.item.type === 'folder') {\n    event.preventDefault()\n    event.stopPropagation()\n    const target = event.currentTarget as HTMLElement\n    target.classList.remove('drag-over')\n\n    emit('dropToFolder', event, props.item.id)\n  }\n}\n</script>\n\n<template>\n  <div class=\"mixed-sort-card-wrapper h-full\">\n    <!-- 文件夹卡片 -->\n    <div\n      v-if=\"item.type === 'folder'\"\n      class=\"drop-zone h-full\"\n      :data-plugin-id=\"item.id\"\n      @dragover=\"handleDragOver\"\n      @dragenter=\"handleDragEnter\"\n      @dragleave=\"handleDragLeave\"\n      @drop=\"handleDropToFolder\"\n    >\n      <PluginFolderCard\n        :folder-name=\"item.data.name\"\n        :plugin-count=\"item.data.pluginCount\"\n        :folder-config=\"item.data.config\"\n        @open=\"$emit('openFolder', item.id)\"\n        @delete=\"$emit('deleteFolder', item.id)\"\n        @rename=\"(oldName, newName) => $emit('renameFolder', oldName, newName)\"\n        @update-config=\"(folderName, config) => $emit('updateFolderConfig', folderName, config)\"\n      />\n    </div>\n\n    <!-- 插件卡片 -->\n    <div v-else-if=\"item.type === 'plugin'\" class=\"plugin-item-wrapper h-full\" :data-plugin-id=\"item.id\">\n      <PluginCard\n        :count=\"pluginStatistics[item.id] || 0\"\n        :plugin=\"item.data\"\n        :action=\"pluginActions[item.id] || false\"\n        @remove=\"$emit('refreshData')\"\n        @save=\"$emit('refreshData')\"\n        @action-done=\"$emit('actionDone', item.id)\"\n      />\n\n      <!-- 移出文件夹按钮（仅在文件夹内显示） -->\n      <VBtn\n        v-if=\"showRemoveButton\"\n        icon=\"mdi-folder-remove\"\n        variant=\"text\"\n        color=\"warning\"\n        size=\"small\"\n        class=\"remove-from-folder-btn\"\n        @click=\"$emit('removeFromFolder', item.id)\"\n      />\n    </div>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.mixed-sort-card-wrapper {\n  block-size: 100%;\n  inline-size: 100%;\n\n  // 确保拖拽时的边界清晰\n  &.sortable-chosen {\n    opacity: 0.5;\n  }\n\n  &.sortable-ghost {\n    border: 2px dashed #2196f3;\n    border-radius: 16px;\n    background: rgba(33, 150, 243, 10%);\n    opacity: 0.3;\n  }\n}\n\n// 拖拽相关样式\n.drop-zone {\n  position: relative;\n  isolation: isolate; // 创建新的层叠上下文\n  transition: all 0.3s ease;\n\n  &.drag-over {\n    border: 2px dashed #2196f3;\n    border-radius: 16px;\n    box-shadow: 0 0 20px rgba(33, 150, 243, 50%);\n    transform: scale(1.02);\n  }\n}\n\n.plugin-item-wrapper {\n  position: relative;\n  isolation: isolate; // 创建新的层叠上下文\n\n  .remove-from-folder-btn {\n    position: absolute;\n    z-index: 10;\n    border-radius: 50%;\n    backdrop-filter: blur(4px);\n    background: rgba(255, 255, 255, 10%);\n    inset-block-start: 4px;\n    inset-inline-end: 4px;\n    opacity: 0;\n    transition: opacity 0.3s ease;\n  }\n\n  &:hover .remove-from-folder-btn {\n    opacity: 1;\n  }\n}\n\n// 拖拽时的样式优化\n.mixed-sort-card-wrapper.sortable-drag {\n  .remove-from-folder-btn {\n    display: none !important;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/cards/PosterCard.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { PropType } from 'vue'\nimport type { MediaServerPlayItem } from '@/api/types'\nimport noImage from '@images/no-image.jpeg'\nimport { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'\n\n// 输入参数\nconst props = defineProps({\n  media: Object as PropType<MediaServerPlayItem>,\n  width: String,\n  height: String,\n})\n\n// 图片加载状态\nconst isImageLoaded = ref(false)\n\n// 图片加载失败\nconst imageLoadError = ref(false)\n\n// 角标颜色\nfunction getChipColor(type: string) {\n  if (type === '电影') return 'border-blue-500 bg-blue-600'\n  else if (type === '电视剧') return ' bg-indigo-500 border-indigo-600'\n  else return 'border-purple-600 bg-purple-600'\n}\n\n// 计算图片地址\nconst getImgUrl = computed(() => {\n  if (imageLoadError.value) return noImage\n  const image = props.media?.image || ''\n  let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`\n  const use_cookies = props.media?.use_cookies\n  if (use_cookies) {\n   url += `&use_cookies=${encodeURIComponent(use_cookies)}`\n  }\n  return url\n})\n\n// 跳转播放\nasync function goPlay(isHovering: boolean | null = false) {\n  if (props.media?.link && isHovering) {\n    await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)\n  }\n}\n</script>\n\n<template>\n  <VHover>\n    <template #default=\"hover\">\n      <VCard\n        v-bind=\"hover.props\"\n        :height=\"props.height\"\n        :width=\"props.width\"\n        class=\"outline-none ring-gray-500\"\n        :class=\"{\n          'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,\n          'ring-1': isImageLoaded,\n        }\"\n      >\n        <VImg\n          aspect-ratio=\"2/3\"\n          :src=\"getImgUrl\"\n          class=\"object-cover aspect-w-2 aspect-h-3\"\n          cover\n          @load=\"isImageLoaded = true\"\n          @error=\"imageLoadError = true\"\n        >\n          <template #placeholder>\n            <div class=\"w-full h-full\">\n              <VSkeletonLoader class=\"object-cover aspect-w-2 aspect-h-3\" />\n            </div>\n          </template>\n        </VImg>\n        <!-- 类型角标 -->\n        <VChip\n          v-show=\"isImageLoaded\"\n          variant=\"elevated\"\n          size=\"small\"\n          :class=\"getChipColor(props.media?.type || '')\"\n          class=\"absolute left-2 top-2 bg-opacity-80 text-white font-bold\"\n        >\n          {{ props.media?.type }}\n        </VChip>\n        <!-- 详情 -->\n        <VCardText\n          v-show=\"hover.isHovering || imageLoadError\"\n          class=\"w-full h-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2 pb-5\"\n          style=\"background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)\"\n          @click.stop=\"goPlay(hover.isHovering)\"\n        >\n          <span class=\"font-semibold text-sm\">{{ props.media?.subtitle }}</span>\n          <h1 class=\"mb-1 text-white font-bold text-lg line-clamp-2 overflow-hidden text-ellipsis ...\">\n            {{ props.media?.title }}\n          </h1>\n        </VCardText>\n      </VCard>\n    </template>\n  </VHover>\n</template>\n"
  },
  {
    "path": "src/components/cards/SiteCard.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { PropType } from 'vue'\nimport { getLogoUrl } from '@/utils/imageUtils'\nimport { useToast } from 'vue-toastification'\nimport { useI18n } from 'vue-i18n'\nimport SiteAddEditDialog from '../dialog/SiteAddEditDialog.vue'\nimport SiteUserDataDialog from '../dialog/SiteUserDataDialog.vue'\nimport SiteResourceDialog from '../dialog/SiteResourceDialog.vue'\nimport SiteCookieUpdateDialog from '../dialog/SiteCookieUpdateDialog.vue'\nimport api from '@/api'\nimport type { Site, SiteStatistic, SiteUserData } from '@/api/types'\nimport { isNullOrEmptyObject } from '@/@core/utils'\nimport { formatFileSize } from '@/@core/utils/formatters'\nimport { useConfirm } from '@/composables/useConfirm'\nimport { useDisplay } from 'vuetify'\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 国际化\nconst { t } = useI18n()\n\n// 输入参数\nconst cardProps = defineProps({\n  site: Object as PropType<Site>,\n  data: Object as PropType<SiteUserData>,\n  stats: Object as PropType<SiteStatistic>,\n})\n\n// 定义触发的自定义事件\nconst emit = defineEmits(['update', 'remove', 'refresh-stats'])\n\n// 确认框\nconst createConfirm = useConfirm()\n\n// 图标\nconst siteIcon = ref<string>('')\n\n// 提示框\nconst $toast = useToast()\n\n// 测试按钮文字\nconst testButtonText = ref(t('site.testConnectivity'))\n\n// 测试按钮可用性\nconst testButtonDisable = ref(false)\n\n// 更新站点Cookie UA弹窗\nconst siteCookieDialog = ref(false)\n\n// 站点编辑弹窗\nconst siteEditDialog = ref(false)\n\n// 资源浏览弹窗\nconst resourceDialog = ref(false)\n\n// 用户数据弹窗\nconst siteUserDataDialog = ref(false)\n\n// 查询站点图标\nasync function getSiteIcon() {\n  try {\n    siteIcon.value = (await api.get(`site/icon/${cardProps.site?.id}`)).data.icon\n    if (!siteIcon.value) {\n      siteIcon.value = getLogoUrl('site')\n    }\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 测试站点连通性\nasync function testSite() {\n  try {\n    testButtonText.value = t('site.testing')\n    testButtonDisable.value = true\n\n    const result: { [key: string]: any } = await api.get(`site/test/${cardProps.site?.id}`)\n    if (result.success) $toast.success(t('site.testSuccess', { name: cardProps.site?.name }))\n    else $toast.error(t('site.testFailed', { name: cardProps.site?.name, message: result.message }))\n\n    testButtonText.value = t('site.testConnectivity')\n    testButtonDisable.value = false\n\n    // 测试完成后刷新统计数据\n    emit('refresh-stats', cardProps.site?.domain)\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 打开更新站点Cookie UA弹窗\nasync function handleSiteUpdate() {\n  siteCookieDialog.value = true\n}\n\n// 打开资源浏览弹窗\nasync function handleResourceBrowse() {\n  resourceDialog.value = true\n}\n\n// 打开站点用户数据弹窗\nasync function handleSiteUserData() {\n  siteUserDataDialog.value = true\n}\n\n// 打开站点页面\nfunction openSitePage() {\n  window.open(cardProps.site?.url, '_blank')\n}\n\n// 调用API删除站点信息\nasync function deleteSiteInfo() {\n  const isConfirmed = await createConfirm({\n    title: t('common.confirm'),\n    content: t('site.deleteConfirm'),\n  })\n\n  if (!isConfirmed) return\n\n  try {\n    const result: { [key: string]: any } = await api.delete(`site/${cardProps.site?.id}`)\n    if (result.success) emit('remove')\n    else $toast.error(t('site.deleteFailed', { name: cardProps.site?.name, message: result.message }))\n  } catch (error) {\n    $toast.error(t('site.deleteFailed', { name: cardProps.site?.name, message: error }))\n    console.error(error)\n  }\n}\n\n// 根据站点状态显示不同的状态图标\nconst statColor = computed(() => {\n  if (!cardProps.stats || isNullOrEmptyObject(cardProps.stats)) {\n    return 'secondary'\n  }\n  if (cardProps.stats?.lst_state === 1) {\n    return 'error'\n  } else if (cardProps.stats?.lst_state === 0) {\n    if (!cardProps.stats?.seconds) return 'secondary'\n    if (cardProps.stats?.seconds >= 5) return 'warning'\n    return 'success'\n  }\n  return 'secondary'\n})\n\n// 数据百分比计算\nconst getMaxDataValue = computed(() => {\n  // 获取站点数据中的最大值作为基准\n  const upload = cardProps.data?.upload || 0\n  const download = cardProps.data?.download || 0\n\n  // 避免两者都为0的情况\n  if (upload === 0 && download === 0) return 1\n\n  return Math.max(upload, download)\n})\n\n// 上传百分比\nconst getUploadPercent = computed(() => {\n  const upload = cardProps.data?.upload || 0\n  return Math.min(100, Math.max(3, (upload / getMaxDataValue.value) * 100))\n})\n\n// 下载百分比\nconst getDownloadPercent = computed(() => {\n  const download = cardProps.data?.download || 0\n  return Math.min(100, Math.max(3, (download / getMaxDataValue.value) * 100))\n})\n\n// 保存站点\nfunction saveSite() {\n  siteEditDialog.value = false\n  emit('update')\n}\n\n// 更新站点Cookie UA后的回调\nfunction onSiteCookieUpdated() {\n  siteCookieDialog.value = false\n  // Cookie更新后刷新统计数据\n  emit('refresh-stats', cardProps.site?.domain)\n}\n\n// 资源浏览弹窗关闭后的回调\nfunction onSiteResourceDone() {\n  resourceDialog.value = false\n  // 资源操作完成后刷新统计数据\n  emit('refresh-stats', cardProps.site?.domain)\n}\n\n// 装载时查询站点图标\nonMounted(() => {\n  getSiteIcon()\n})\n</script>\n\n<template>\n  <div>\n    <VCard\n      class=\"site-card relative h-full flex flex-col overflow-hidden group transition-all duration-300 cursor-pointer hover:-translate-y-1\"\n      :class=\"[\n        cardProps.site?.is_active ? '' : 'opacity-70',\n        {\n          'border-error': statColor === 'error',\n          'border-warning': statColor === 'warning',\n          'border-success': statColor === 'success',\n        },\n      ]\"\n      :ripple=\"false\"\n      variant=\"flat\"\n      elevation=\"0\"\n      rounded=\"lg\"\n      hover\n      @click=\"handleResourceBrowse\"\n    >\n      <!-- 装饰性状态指示器 -->\n      <div v-if=\"cardProps.site?.is_active\" class=\"site-status-indicator\" :class=\"statColor\"></div>\n\n      <!-- 主体部分 -->\n      <div class=\"relative z-1 flex flex-1 flex-col p-3 pr-12\">\n        <!-- 顶部：图标和站点名称 -->\n        <div class=\"mb-1 flex min-w-0 items-center gap-2\">\n          <!-- 站点图标 -->\n          <VAvatar\n            tile\n            rounded=\"lg\"\n            size=\"32\"\n            class=\"shrink-0\"\n            :class=\"{ 'cursor-move': display.mdAndUp.value }\"\n          >\n            <VImg :src=\"siteIcon\" class=\"w-full h-full\" :alt=\"cardProps.site?.name\" cover>\n              <template #placeholder>\n                <div class=\"w-full h-full\">\n                  <VSkeletonLoader class=\"object-cover aspect-square\" />\n                </div>\n              </template>\n            </VImg>\n          </VAvatar>\n\n          <!-- 站点名称和特性图标 -->\n          <div class=\"flex min-w-0 flex-1 items-center gap-2\">\n            <h3 class=\"min-w-0 flex-1 truncate text-lg font-semibold leading-tight\">{{ cardProps.site?.name }}</h3>\n\n            <!-- 站点特性图标 -->\n            <div class=\"ml-auto flex shrink-0 items-center gap-2\">\n              <div v-if=\"cardProps.site?.limit_interval\" class=\"hover:bg-primary/8 transition-colors\">\n                <VIcon icon=\"mdi-speedometer\" size=\"16\" color=\"primary\" class=\"opacity-85 hover:opacity-100\" />\n              </div>\n              <div v-if=\"cardProps.site?.proxy\" class=\"hover:bg-primary/8 transition-colors\">\n                <VIcon icon=\"mdi-network-outline\" size=\"16\" color=\"primary\" class=\"opacity-85 hover:opacity-100\" />\n              </div>\n              <div v-if=\"cardProps.site?.render\" class=\"hover:bg-primary/8 transition-colors\">\n                <VIcon icon=\"mdi-apple-safari\" size=\"16\" color=\"primary\" class=\"opacity-85 hover:opacity-100\" />\n              </div>\n              <div v-if=\"cardProps.site?.filter\" class=\"hover:bg-primary/8 transition-colors\">\n                <VIcon icon=\"mdi-filter-cog-outline\" size=\"16\" color=\"primary\" class=\"opacity-85 hover:opacity-100\" />\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <!-- 中间部分：网址 -->\n        <div class=\"my-3\">\n          <div class=\"min-w-0 truncate text-sm text-medium-emphasis\" @click.stop=\"openSitePage\">\n            {{ cardProps.site?.url }}\n          </div>\n        </div>\n\n        <!-- 底部：数据统计 -->\n        <div class=\"flex-1 flex flex-col justify-end\">\n          <!-- 更直观的上传下载数据条 -->\n          <div class=\"border-t mt-1.5 pt-1.5\">\n            <!-- 上传数据 -->\n            <div class=\"flex items-center justify-between gap-3 mb-1.5\">\n              <div class=\"text-sm text-medium-emphasis min-w-[70px]\">\n                <VIcon icon=\"mdi-arrow-up\" size=\"14\" color=\"info\" class=\"mr-1\" />\n                <span>{{ formatFileSize(cardProps.data?.upload || 0) }}</span>\n              </div>\n              <div class=\"flex-grow h-1 rounded bg-on-surface/8 relative overflow-hidden\">\n                <VProgressLinear :model-value=\"getUploadPercent\" color=\"info\" height=\"4\" rounded=\"lg\" />\n              </div>\n            </div>\n\n            <!-- 下载数据 -->\n            <div class=\"flex items-center justify-between gap-3\">\n              <div class=\"flex items-center text-[0.8rem] text-medium-emphasis min-w-[70px]\">\n                <VIcon icon=\"mdi-arrow-down\" size=\"14\" color=\"success\" class=\"mr-1\" />\n                <span>{{ formatFileSize(cardProps.data?.download || 0) }}</span>\n              </div>\n              <div class=\"flex-grow h-1 rounded bg-on-surface/8 relative overflow-hidden\">\n                <VProgressLinear :model-value=\"getDownloadPercent\" color=\"warning\" height=\"4\" rounded=\"lg\" />\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <!-- 右侧操作按钮区 -->\n      <VSheet class=\"site-card-actions absolute inset-y-0 right-0 z-20 flex flex-col py-2 px-1\">\n        <!-- 测试按钮 -->\n        <VBtn\n          icon\n          variant=\"text\"\n          density=\"comfortable\"\n          class=\"mb-1 relative flex items-center justify-center rounded-full mx-auto\"\n          :disabled=\"testButtonDisable\"\n          @click.stop=\"testSite\"\n          size=\"36\"\n        >\n          <div class=\"relative flex items-center justify-center w-full h-full\">\n            <div\n              class=\"w-[20px] h-[20px] rounded-full shadow-[inset_0_0_0_2px_rgba(var(--v-theme-on-surface),0.1)] pulse-dot\"\n              :class=\"statColor\"\n            ></div>\n          </div>\n          <div\n            v-if=\"testButtonDisable\"\n            class=\"absolute inset-0 flex flex-col items-center justify-center bg-surface/95 rounded-full shadow-md animate-fade-in\"\n          >\n            <div class=\"relative w-6 h-6\">\n              <div class=\"spinner-circle\"></div>\n            </div>\n          </div>\n        </VBtn>\n\n        <!-- 用户数据按钮 -->\n        <VBtn icon variant=\"text\" @click.stop=\"handleSiteUserData\" size=\"36\">\n          <VIcon icon=\"mdi-chart-bell-curve\" size=\"20\" />\n        </VBtn>\n\n        <!-- 更新按钮 -->\n        <VBtn icon variant=\"text\" @click.stop=\"handleSiteUpdate\" size=\"36\">\n          <VIcon icon=\"mdi-refresh\" size=\"20\" />\n        </VBtn>\n\n        <!-- 更多选项按钮 -->\n        <VBtn icon variant=\"text\" class=\"mt-auto\" size=\"36\">\n          <VIcon icon=\"mdi-dots-vertical\" size=\"20\" />\n          <VMenu :activator=\"'parent'\" :close-on-content-click=\"true\" :location=\"'left'\">\n            <VList>\n              <VListItem @click=\"siteEditDialog = true\" base-color=\"info\">\n                <template #prepend>\n                  <VIcon icon=\"mdi-file-edit-outline\" size=\"20\" />\n                </template>\n                <VListItemTitle>{{ t('site.actions.edit') }}</VListItemTitle>\n              </VListItem>\n              <VListItem @click=\"deleteSiteInfo\">\n                <template #prepend>\n                  <VIcon icon=\"mdi-delete-outline\" size=\"20\" color=\"error\" />\n                </template>\n                <VListItemTitle class=\"text-error\">{{ t('site.deleteSite') }}</VListItemTitle>\n              </VListItem>\n            </VList>\n          </VMenu>\n        </VBtn>\n      </VSheet>\n    </VCard>\n\n    <!-- 对话框组件 -->\n    <SiteCookieUpdateDialog\n      v-if=\"siteCookieDialog\"\n      v-model=\"siteCookieDialog\"\n      :site=\"cardProps.site\"\n      @close=\"siteCookieDialog = false\"\n      @done=\"onSiteCookieUpdated\"\n    />\n    <SiteAddEditDialog\n      v-if=\"siteEditDialog\"\n      v-model=\"siteEditDialog\"\n      :siteid=\"cardProps.site?.id\"\n      @save=\"saveSite\"\n      @remove=\"emit('remove')\"\n      @close=\"siteEditDialog = false\"\n    />\n    <SiteUserDataDialog\n      v-if=\"siteUserDataDialog\"\n      v-model=\"siteUserDataDialog\"\n      :site=\"cardProps.site\"\n      @close=\"siteUserDataDialog = false\"\n    />\n    <SiteResourceDialog\n      v-if=\"resourceDialog\"\n      v-model=\"resourceDialog\"\n      :site=\"cardProps.site\"\n      @close=\"onSiteResourceDone\"\n    />\n  </div>\n</template>\n\n<style scoped>\n.site-status-indicator {\n  position: absolute;\n  z-index: 1;\n  block-size: 2px;\n  inset-block-start: 0;\n  inset-inline: 0;\n  opacity: 0.5;\n  transition: block-size 0.3s ease, opacity 0.3s ease;\n}\n\n.site-status-indicator.error {\n  background: linear-gradient(90deg, transparent, rgba(var(--v-theme-error), 0.7), transparent);\n  box-shadow: 0 0 8px rgba(var(--v-theme-error), 0.3);\n}\n\n.site-status-indicator.warning {\n  background: linear-gradient(90deg, transparent, rgba(var(--v-theme-warning), 0.7), transparent);\n  box-shadow: 0 0 8px rgba(var(--v-theme-warning), 0.3);\n}\n\n.site-status-indicator.success {\n  background: linear-gradient(90deg, transparent, rgba(var(--v-theme-success), 0.7), transparent);\n  box-shadow: 0 0 8px rgba(var(--v-theme-success), 0.3);\n}\n\n.site-status-indicator.secondary {\n  background: linear-gradient(90deg, transparent, rgba(var(--v-theme-secondary), 0.7), transparent);\n  box-shadow: 0 0 8px rgba(var(--v-theme-secondary), 0.3);\n}\n\n/* 站点卡片悬停时状态指示器变化 */\n.site-card:hover .site-status-indicator {\n  block-size: 2px;\n  opacity: 0.8;\n}\n\n/* 上传下载条样式 */\n.upload-bar {\n  animation: pulse-width 2s infinite;\n  background: linear-gradient(90deg, #4d79ff, #07f);\n  box-shadow: 0 0 4px rgba(0, 119, 255, 50%);\n}\n\n.download-bar {\n  animation: pulse-width 2s infinite;\n  background: linear-gradient(90deg, #42d392, #00b77e);\n  box-shadow: 0 0 4px rgba(0, 183, 126, 50%);\n}\n\n/* 测试状态点样式 */\n.pulse-dot::before {\n  position: absolute;\n  z-index: 1;\n  border-radius: 50%;\n  block-size: 70%;\n  content: '';\n  inline-size: 70%;\n  inset-block-start: 15%;\n  inset-inline-start: 15%;\n}\n\n.pulse-dot::after {\n  position: absolute;\n  z-index: 2;\n  border-radius: 50%;\n  block-size: 100%;\n  content: '';\n  inline-size: 100%;\n  inset-block-start: 0;\n  inset-inline-start: 0;\n}\n\n.pulse-dot.error::before {\n  background-color: rgba(var(--v-theme-error), 1);\n  box-shadow: 0 0 10px rgba(var(--v-theme-error), 0.8);\n}\n\n.pulse-dot.error::after {\n  animation: pulse-animation-error 2s infinite;\n  box-shadow: 0 0 0 2px rgba(var(--v-theme-error), 0.3);\n}\n\n.pulse-dot.warning::before {\n  background-color: rgba(var(--v-theme-warning), 1);\n  box-shadow: 0 0 10px rgba(var(--v-theme-warning), 0.8);\n}\n\n.pulse-dot.warning::after {\n  animation: pulse-animation-warning 2s infinite;\n  box-shadow: 0 0 0 2px rgba(var(--v-theme-warning), 0.3);\n}\n\n.pulse-dot.success::before {\n  background-color: rgba(var(--v-theme-success), 1);\n  box-shadow: 0 0 10px rgba(var(--v-theme-success), 0.8);\n}\n\n.pulse-dot.success::after {\n  animation: pulse-animation-success 2s infinite;\n  box-shadow: 0 0 0 2px rgba(var(--v-theme-success), 0.3);\n}\n\n.pulse-dot.secondary::before {\n  background-color: rgba(var(--v-theme-secondary), 1);\n  box-shadow: 0 0 10px rgba(var(--v-theme-secondary), 0.8);\n}\n\n.pulse-dot.secondary::after {\n  animation: pulse-animation-secondary 2s infinite;\n  box-shadow: 0 0 0 2px rgba(var(--v-theme-secondary), 0.3);\n}\n\n/* 加载动画 */\n.spinner-circle {\n  position: absolute;\n  border: 1px solid rgba(var(--v-theme-primary), 0.2);\n  border-radius: 50%;\n  animation: spin 0.8s linear infinite;\n  block-size: 100%;\n  border-block-start-color: rgba(var(--v-theme-primary), 1);\n  inline-size: 100%;\n}\n\n/* 动画关键帧 */\n@keyframes pulse-width {\n  0%,\n  100% {\n    opacity: 0.85;\n    transform: scaleX(0.95);\n  }\n\n  50% {\n    opacity: 1;\n    transform: scaleX(1.05);\n  }\n}\n\n@keyframes pulse-animation-error {\n  0% {\n    box-shadow: 0 0 0 0 rgba(var(--v-theme-error), 0.6);\n  }\n\n  70% {\n    box-shadow: 0 0 0 10px rgba(var(--v-theme-error), 0);\n  }\n\n  100% {\n    box-shadow: 0 0 0 0 rgba(var(--v-theme-error), 0);\n  }\n}\n\n@keyframes pulse-animation-warning {\n  0% {\n    box-shadow: 0 0 0 0 rgba(var(--v-theme-warning), 0.6);\n  }\n\n  70% {\n    box-shadow: 0 0 0 10px rgba(var(--v-theme-warning), 0);\n  }\n\n  100% {\n    box-shadow: 0 0 0 0 rgba(var(--v-theme-warning), 0);\n  }\n}\n\n@keyframes pulse-animation-success {\n  0% {\n    box-shadow: 0 0 0 0 rgba(var(--v-theme-success), 0.6);\n  }\n\n  70% {\n    box-shadow: 0 0 0 10px rgba(var(--v-theme-success), 0);\n  }\n\n  100% {\n    box-shadow: 0 0 0 0 rgba(var(--v-theme-success), 0);\n  }\n}\n\n@keyframes pulse-animation-secondary {\n  0% {\n    box-shadow: 0 0 0 0 rgba(var(--v-theme-secondary), 0.6);\n  }\n\n  70% {\n    box-shadow: 0 0 0 10px rgba(var(--v-theme-secondary), 0);\n  }\n\n  100% {\n    box-shadow: 0 0 0 0 rgba(var(--v-theme-secondary), 0);\n  }\n}\n\n@keyframes spin {\n  0% {\n    transform: rotate(0deg);\n  }\n\n  100% {\n    transform: rotate(360deg);\n  }\n}\n\n@keyframes fade-in {\n  from {\n    opacity: 0;\n  }\n\n  to {\n    opacity: 1;\n  }\n}\n\n.site-card-actions {\n  opacity: 0;\n  transform: translateX(100%);\n  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n  visibility: hidden;\n}\n\n.site-card:hover .site-card-actions {\n  opacity: 1;\n  transform: translateX(0);\n  visibility: visible;\n}\n</style>\n"
  },
  {
    "path": "src/components/cards/StorageCard.vue",
    "content": "<script setup lang=\"ts\">\nimport { StorageConf } from '@/api/types'\nimport { formatBytes } from '@core/utils/formatters'\nimport storage_png from '@images/misc/storage.png'\nimport alipan_png from '@images/misc/alipan.webp'\nimport u115_png from '@images/misc/u115.png'\nimport rclone_png from '@images/misc/rclone.png'\nimport alist_png from '@images/misc/openlist.svg'\nimport custom_png from '@images/misc/database.png'\nimport smb_png from '@images/misc/smb.png'\nimport api from '@/api'\nimport AliyunAuthDialog from '../dialog/AliyunAuthDialog.vue'\nimport U115AuthDialog from '../dialog/U115AuthDialog.vue'\nimport RcloneConfigDialog from '../dialog/RcloneConfigDialog.vue'\nimport AlistConfigDialog from '../dialog/AlistConfigDialog.vue'\nimport SmbConfigDialog from '../dialog/SmbConfigDialog.vue'\nimport { useToast } from 'vue-toastification'\nimport { isNullOrEmptyObject } from '@/@core/utils'\nimport { useI18n } from 'vue-i18n'\nimport { useDisplay } from 'vuetify'\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 国际化\nconst { t } = useI18n()\n\n// 定义输入\nconst props = defineProps({\n  storage: {\n    type: Object as PropType<StorageConf>,\n    required: true,\n  },\n})\n\n// 定义事件\nconst emit = defineEmits(['done', 'close'])\n\n// 提示信息\nconst $toast = useToast()\n\n// 存储总空间\nconst total = ref(0)\n\n// 存储可用空间\nconst available = ref(0)\n\n// 储存已用空间\nconst used = computed(() => {\n  return total.value - available.value\n})\n\n// 存储\nconst storage_ref = ref(props.storage)\n\n// 自定义存储名称\nconst customName = ref(props.storage.name)\n\n// 自定义存储类型\nconst storageType = ref(props.storage.type)\n\n// 阿里云盘认证对话框\nconst aliyunAuthDialog = ref(false)\n// 115网盘认证对话框\nconst u115AuthDialog = ref(false)\n// Rclone配置对话框\nconst rcloneConfigDialog = ref(false)\n// AList配置对话框\nconst aListConfigDialog = ref(false)\n// SMB配置对话框\nconst smbConfigDialog = ref(false)\n// 自定义存储配置对话框\nconst customConfigDialog = ref(false)\n\n// 打开存储对话框\nfunction openStorageDialog() {\n  switch (props.storage.type) {\n    case 'alipan':\n      aliyunAuthDialog.value = true\n      break\n    case 'u115':\n      u115AuthDialog.value = true\n      break\n    case 'rclone':\n      rcloneConfigDialog.value = true\n      break\n    case 'alist':\n      aListConfigDialog.value = true\n      break\n    case 'smb':\n      smbConfigDialog.value = true\n      break\n    case 'local':\n      $toast.info(t('storage.noConfigNeeded'))\n      break\n    default:\n      customConfigDialog.value = true\n      break\n  }\n}\n\n// 根据存储类型选择图标\nconst getIcon = computed(() => {\n  switch (props.storage.type) {\n    case 'local':\n      return storage_png\n    case 'alipan':\n      return alipan_png\n    case 'u115':\n      return u115_png\n    case 'rclone':\n      return rclone_png\n    case 'alist':\n      return alist_png\n    case 'smb':\n      return smb_png\n    default:\n      return custom_png\n  }\n})\n\n// 计算进度条颜色\nconst progressColor = computed(() => {\n  if (usage.value > 90) {\n    return 'error'\n  } else if (usage.value > 70) {\n    return 'warning'\n  } else {\n    return 'success'\n  }\n})\n\n// 计算存储使用率\nconst usage = computed(() => {\n  return Math.round((used.value / (total.value || 1)) * 1000) / 10\n})\n\n// 查询存储信息\nasync function queryStorage() {\n  try {\n    const data: { total: number; available: number } = await api.get(`storage/usage/${props.storage.type}`)\n    total.value = data.total\n    available.value = data.available\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 完成配置后的处理\nfunction handleDone() {\n  aliyunAuthDialog.value = false\n  u115AuthDialog.value = false\n  rcloneConfigDialog.value = false\n  aListConfigDialog.value = false\n  smbConfigDialog.value = false\n  customConfigDialog.value = false\n  // 更新存储\n  storage_ref.value.name = customName.value\n  storage_ref.value.type = storageType.value\n  emit('done', storage_ref.value)\n}\n\nonMounted(() => {\n  queryStorage()\n})\n\n// 关闭\nfunction onClose() {\n  emit('close')\n}\n</script>\n<template>\n  <div>\n    <VCard variant=\"tonal\" @click=\"openStorageDialog\">\n      <VDialogCloseBtn @click=\"onClose\" class=\"absolute top-1 right-1\" />\n      <VCardText class=\"flex justify-space-between align-center gap-3\">\n        <div class=\"align-self-start flex-1\">\n          <h5 class=\"text-h6 mb-1\">{{ storage.name }}</h5>\n          <div class=\"mb-3 text-sm\" v-if=\"total\">{{ formatBytes(used, 1) }} / {{ formatBytes(total, 1) }}</div>\n          <div v-else-if=\"isNullOrEmptyObject(storage.config)\">{{ t('storage.notConfigured') }}</div>\n        </div>\n        <VImg :src=\"getIcon\" cover class=\"mt-8\" max-width=\"3rem\" min-width=\"3rem\" />\n      </VCardText>\n      <div class=\"w-full absolute bottom-0\">\n        <VProgressLinear v-if=\"usage > 0\" :model-value=\"usage\" :bg-color=\"progressColor\" :color=\"progressColor\" />\n      </div>\n    </VCard>\n    <AliyunAuthDialog\n      v-if=\"aliyunAuthDialog\"\n      v-model=\"aliyunAuthDialog\"\n      :conf=\"props.storage.config || {}\"\n      @close=\"aliyunAuthDialog = false\"\n      @done=\"handleDone\"\n    />\n    <U115AuthDialog\n      v-if=\"u115AuthDialog\"\n      v-model=\"u115AuthDialog\"\n      :conf=\"props.storage.config || {}\"\n      @close=\"u115AuthDialog = false\"\n      @done=\"handleDone\"\n    />\n    <RcloneConfigDialog\n      v-if=\"rcloneConfigDialog\"\n      v-model=\"rcloneConfigDialog\"\n      :conf=\"props.storage.config || {}\"\n      @close=\"rcloneConfigDialog = false\"\n      @done=\"handleDone\"\n    />\n    <AlistConfigDialog\n      v-if=\"aListConfigDialog\"\n      v-model=\"aListConfigDialog\"\n      :conf=\"props.storage.config || {}\"\n      @close=\"aListConfigDialog = false\"\n      @done=\"handleDone\"\n    />\n    <SmbConfigDialog\n      v-if=\"smbConfigDialog\"\n      v-model=\"smbConfigDialog\"\n      :conf=\"props.storage.config || {}\"\n      @close=\"smbConfigDialog = false\"\n      @done=\"handleDone\"\n    />\n    <VDialog\n      v-if=\"customConfigDialog\"\n      v-model=\"customConfigDialog\"\n      scrollable\n      max-width=\"30rem\"\n      :fullscreen=\"!display.mdAndUp.value\"\n    >\n      <VCard>\n        <VCardItem>\n          <template #prepend>\n            <VIcon icon=\"mdi-cog\" />\n          </template>\n          <VCardTitle>{{ t('storage.custom') }}</VCardTitle>\n          <VDialogCloseBtn v-model=\"customConfigDialog\" />\n        </VCardItem>\n        <VDivider />\n        <VCardText>\n          <VRow>\n            <VCol cols=\"12\" md=\"6\">\n              <VTextField\n                v-model=\"storageType\"\n                :label=\"t('storage.type')\"\n                :hint=\"t('storage.customTypeHint')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-database\"\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"6\">\n              <VTextField\n                v-model=\"customName\"\n                :label=\"t('storage.name')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-label\"\n              />\n            </VCol>\n          </VRow>\n        </VCardText>\n        <VCardActions class=\"pt-3\">\n          <VBtn @click=\"handleDone\" prepend-icon=\"mdi-content-save\" class=\"px-5\">\n            {{ t('common.save') }}\n          </VBtn>\n        </VCardActions>\n      </VCard>\n    </VDialog>\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/cards/SubscribeCard.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useToast } from 'vue-toastification'\nimport { useConfirm } from '@/composables/useConfirm'\nimport SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'\nimport SubscribeFilesDialog from '../dialog/SubscribeFilesDialog.vue'\nimport SubscribeShareDialog from '../dialog/SubscribeShareDialog.vue'\nimport { formatDateDifference, formatSeason } from '@/@core/utils/formatters'\nimport api from '@/api'\nimport type { Subscribe } from '@/api/types'\nimport router from '@/router'\nimport { useI18n } from 'vue-i18n'\nimport { useDisplay } from 'vuetify'\nimport { useGlobalSettingsStore } from '@/stores'\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 国际化\nconst { t } = useI18n()\n\n// 输入参数\nconst props = defineProps({\n  media: Object as PropType<Subscribe>,\n  batchMode: {\n    type: Boolean,\n    default: false,\n  },\n  selected: {\n    type: Boolean,\n    default: false,\n  },\n})\n\n// 从 provide 中获取全局设置\n// 全局设置\nconst globalSettingsStore = useGlobalSettingsStore()\nconst globalSettings = globalSettingsStore.globalSettings\n\n// 定义触发的自定义事件\nconst emit = defineEmits(['remove', 'save', 'select'])\n\n// 确认框\nconst createConfirm = useConfirm()\n\n// 提示框\nconst $toast = useToast()\n\n// 图片是否加载完成\nconst imageLoaded = ref(false)\n\n// 订阅弹窗\nconst subscribeEditDialog = ref(false)\n\n// 订阅文件信息弹窗\nconst subscribeFilesDialog = ref(false)\n\n// 分享订阅弹窗\nconst subscribeShareDialog = ref(false)\n\n// 当前的订阅状态\nconst subscribeState = ref<string>(props.media?.state ?? 'P')\n\n// 上一次更新时间\nconst lastUpdateText = computed(() => (props.media?.last_update ? formatDateDifference(props.media.last_update) : ''))\n\n// 图片加载完成响应\nfunction imageLoadHandler() {\n  imageLoaded.value = true\n}\n\n// 计算百分比\nfunction getPercentage() {\n  if (props.media?.total_episode === 0) return 0\n\n  return Math.round(\n    (((props.media?.total_episode ?? 0) - (props.media?.lack_episode ?? 0)) / (props.media?.total_episode ?? 1)) * 100,\n  )\n}\n\n// 删除订阅\nasync function removeSubscribe() {\n  try {\n    const result: { [key: string]: any } = await api.delete(`subscribe/${props.media?.id}`)\n\n    if (result.success) {\n      // 通知父组件刷新\n      emit('remove')\n    }\n  } catch (e) {\n    console.log(e)\n  }\n}\n\n// 搜索订阅\nasync function searchSubscribe() {\n  try {\n    const result: { [key: string]: any } = await api.get(`subscribe/search/${props.media?.id}`)\n\n    // 提示\n    if (result.success) $toast.success(`${props.media?.name} 提交搜索请求成功！`)\n  } catch (e) {\n    console.log(e)\n  }\n}\n\n// 切换订阅状态\nasync function toggleSubscribeStatus(state: 'R' | 'S') {\n  try {\n    // 根据传入的 state 判断对应的操作文字\n    const action = state === 'S' ? t('common.pause') : t('common.enable')\n    // 弹出确认框\n    const isConfirmed = await createConfirm({\n      title: t('common.confirmAction', { action }),\n      content: t('subscribe.confirmToggle', { action, name: props.media?.name }),\n    })\n    if (!isConfirmed) return\n    // 调用 API 更新订阅状态\n    const result: { [key: string]: any } = await api.put(`subscribe/status/${props.media?.id}?state=${state}`)\n    // 提示\n    if (result.success) {\n      $toast.success(t('subscribe.toggleSuccess', { name: props.media?.name, action }))\n      subscribeState.value = state\n      emit('save')\n    } else {\n      $toast.error(t('subscribe.toggleFailed', { action, message: result.message }))\n    }\n  } catch (e) {\n    console.log(e)\n  }\n}\n\n// 重置订阅\nasync function resetSubscribe() {\n  // 确认\n  try {\n    const isConfirmed = await createConfirm({\n      title: t('common.confirm'),\n      content: t('subscribe.resetConfirm', { name: props.media?.name }),\n    })\n    if (!isConfirmed) return\n    // 重置\n    const result: { [key: string]: any } = await api.get(`subscribe/reset/${props.media?.id}`)\n    // 提示\n    if (result.success) {\n      $toast.success(t('subscribe.resetSuccess', { name: props.media?.name }))\n      subscribeState.value = 'R'\n      emit('save')\n    } else $toast.error(t('subscribe.resetFailed', { name: props.media?.name, message: result.message }))\n  } catch (e) {\n    console.log(e)\n  }\n}\n\n//  分享订阅\nasync function shareSubscribe() {\n  subscribeShareDialog.value = true\n}\n\n// 编辑订阅响应\nasync function editSubscribeDialog() {\n  subscribeEditDialog.value = true\n}\n\n// 获得mediaid\nfunction getMediaId() {\n  if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`\n  else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`\n  else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}`\n  else return props.media?.mediaid\n}\n\n// 查看媒体详情\nasync function viewMediaDetail() {\n  router.push({\n    path: '/media',\n    query: {\n      mediaid: getMediaId(),\n      title: props.media?.name,\n      year: props.media?.year,\n      type: props.media?.type,\n    },\n  })\n}\n\n// 查看文件详情\nasync function viewSubscribeFiles() {\n  subscribeFilesDialog.value = true\n}\n\n// 弹出菜单\nconst dropdownItems = computed(() => [\n  {\n    title: t('common.edit'),\n    value: 1,\n    props: {\n      prependIcon: 'mdi-file-edit-outline',\n      click: editSubscribeDialog,\n    },\n  },\n  {\n    title: t('common.search'),\n    value: 2,\n    props: {\n      prependIcon: 'mdi-magnify',\n      click: searchSubscribe,\n    },\n  },\n  {\n    title: t('common.details'),\n    value: 3,\n    props: {\n      prependIcon: 'mdi-information-outline',\n      click: viewMediaDetail,\n    },\n  },\n  {\n    title: t('common.files'),\n    value: 4,\n    props: {\n      prependIcon: 'mdi-file-document-outline',\n      click: viewSubscribeFiles,\n    },\n  },\n  {\n    title: subscribeState.value === 'S' ? t('common.enable') : t('common.pause'),\n    value: 5,\n    props: {\n      prependIcon: subscribeState.value === 'S' ? 'mdi-play' : 'mdi-pause',\n      click: () => toggleSubscribeStatus(subscribeState.value === 'S' ? 'R' : 'S'),\n      color: subscribeState.value === 'S' ? 'success' : 'info',\n    },\n  },\n  {\n    title: t('common.reset'),\n    value: 6,\n    props: {\n      prependIcon: 'mdi-restore-alert',\n      click: resetSubscribe,\n      color: 'warning',\n    },\n  },\n  {\n    title: t('common.share'),\n    value: 7,\n    props: {\n      prependIcon: 'mdi-share',\n      click: shareSubscribe,\n      color: 'success',\n    },\n    show: props.media?.type === '电视剧',\n  },\n  {\n    title: t('common.unsubscribe'),\n    value: 8,\n    props: {\n      prependIcon: 'mdi-trash-can-outline',\n      color: 'error',\n      click: removeSubscribe,\n    },\n  },\n])\n\n// 监听插件窗口状态变化\nwatch(\n  () => props.media?.page_open,\n  (newOpenState, _) => {\n    if (newOpenState) editSubscribeDialog()\n  },\n)\n\n// 监听订阅状态\nwatch(\n  () => props.media?.state,\n  newState => {\n    subscribeState.value = newState ?? 'P'\n  },\n)\n\n// 计算backdrop图片地址\nconst backdropUrl = computed(() => {\n  const url = props.media?.backdrop || props.media?.poster\n  // 使用图片缓存\n  if (globalSettings.GLOBAL_IMAGE_CACHE && url)\n    return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`\n  return url\n})\n\n// 计算海报图片地址\nconst posterUrl = computed(() => {\n  const url = props.media?.poster\n  // 使用图片缓存\n  if (globalSettings.GLOBAL_IMAGE_CACHE && url)\n    return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`\n  return url\n})\n\n// 订阅编辑保存\nfunction onSubscribeEditSave() {\n  subscribeEditDialog.value = false\n  emit('save')\n}\n\n// 订阅编辑取消\nfunction onSubscribeEditRemove() {\n  subscribeEditDialog.value = false\n  emit('remove')\n}\n\n// 处理卡片点击事件\nfunction handleCardClick() {\n  if (props.batchMode) {\n    // 批量模式下触发选择事件\n    emit('select')\n  } else {\n    // 非批量模式下打开编辑弹窗\n    editSubscribeDialog()\n  }\n}\n</script>\n\n<template>\n  <div>\n    <VHover>\n      <template #default=\"hover\">\n        <div\n          class=\"w-full h-full rounded-lg overflow-hidden\"\n          :class=\"{\n            'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,\n            'outline-dashed outline-1': props.media?.best_version && imageLoaded,\n            'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,\n          }\"\n        >\n          <VCard\n            v-bind=\"hover.props\"\n            :key=\"props.media?.id\"\n            class=\"flex flex-col h-full\"\n            :class=\"{\n              'opacity-70': subscribeState === 'S',\n            }\"\n            rounded=\"0\"\n            min-height=\"150\"\n            @click=\"handleCardClick\"\n            :ripple=\"!props.batchMode\"\n          >\n            <div class=\"me-n3 absolute top-1 right-4\">\n              <IconBtn>\n                <VIcon icon=\"mdi-dots-vertical\" color=\"white\" />\n                <VMenu activator=\"parent\" close-on-content-click>\n                  <VList>\n                    <template v-for=\"(item, i) in dropdownItems\" :key=\"i\">\n                      <VListItem v-if=\"item.show !== false\" :base-color=\"item.props.color\" @click=\"item.props.click\">\n                        <template #prepend>\n                          <VIcon :icon=\"item.props.prependIcon\" />\n                        </template>\n                        <VListItemTitle v-text=\"item.title\" />\n                      </VListItem>\n                    </template>\n                  </VList>\n                </VMenu>\n              </IconBtn>\n            </div>\n            <template #image>\n              <VImg :src=\"backdropUrl || posterUrl\" aspect-ratio=\"3/2\" cover @load=\"imageLoadHandler\" position=\"top\">\n                <template #placeholder>\n                  <div class=\"w-full h-full\">\n                    <VSkeletonLoader class=\"object-cover aspect-w-3 aspect-h-2\" />\n                  </div>\n                </template>\n                <template #default>\n                  <div class=\"absolute inset-0 outline-none subscribe-card-background\"></div>\n                </template>\n              </VImg>\n              <div\n                v-if=\"subscribeState === 'P'\"\n                class=\"absolute inset-0 bg-yellow-900 opacity-80 pointer-events-none\"\n              />\n            </template>\n            <div>\n              <VCardText class=\"flex items-center pt-3 pb-2\">\n                <div\n                  class=\"h-auto w-12 flex-shrink-0 overflow-hidden rounded-md\"\n                  v-if=\"imageLoaded\"\n                  :class=\"{ 'cursor-move': display.mdAndUp.value }\"\n                >\n                  <VImg :src=\"posterUrl\" aspect-ratio=\"2/3\" cover>\n                    <template #placeholder>\n                      <div class=\"w-full h-full\">\n                        <VSkeletonLoader class=\"object-cover aspect-w-2 aspect-h-3\" />\n                      </div>\n                    </template>\n                  </VImg>\n                </div>\n                <div class=\"flex flex-col justify-center overflow-hidden pl-2 xl:pl-4\">\n                  <div class=\"text-sm font-medium text-white sm:pt-1\">{{ props.media?.year }}</div>\n                  <div class=\"mr-2 min-w-0 text-lg font-bold text-white text-ellipsis overflow-hidden line-clamp-2 ...\">\n                    {{ props.media?.name }}\n                    {{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}\n                  </div>\n                </div>\n              </VCardText>\n              <VCardText class=\"flex justify-space-between align-center flex-wrap px-3\">\n                <div class=\"flex align-center\">\n                  <IconBtn\n                    v-if=\"props.media?.total_episode\"\n                    size=\"small\"\n                    v-bind=\"props\"\n                    icon=\"mdi-progress-download\"\n                    color=\"white\"\n                  />\n                  <div v-if=\"props.media?.season\" class=\"text-subtitle-2 me-2 text-white\">\n                    {{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /\n                    {{ props.media?.total_episode }}\n                  </div>\n                  <IconBtn v-if=\"props.media?.username\" icon=\"mdi-account\" size=\"small\" color=\"white\" />\n                  <span v-if=\"props.media?.username\" class=\"text-subtitle-2 text-white\">\n                    {{ props.media?.username }}\n                  </span>\n                </div>\n              </VCardText>\n              <VCardText\n                v-if=\"lastUpdateText\"\n                class=\"absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs\"\n              >\n                <VIcon icon=\"mdi-download\" class=\"me-1\" />\n                {{ lastUpdateText }}\n              </VCardText>\n              <div class=\"w-full absolute bottom-0\">\n                <VProgressLinear\n                  v-if=\"getPercentage() > 0\"\n                  :model-value=\"getPercentage()\"\n                  bg-color=\"success\"\n                  color=\"success\"\n                />\n              </div>\n            </div>\n          </VCard>\n        </div>\n      </template>\n    </VHover>\n    <!-- 订阅编辑弹窗 -->\n    <SubscribeEditDialog\n      v-if=\"subscribeEditDialog\"\n      v-model=\"subscribeEditDialog\"\n      :subid=\"props.media?.id\"\n      @remove=\"onSubscribeEditRemove\"\n      @save=\"onSubscribeEditSave\"\n      @close=\"subscribeEditDialog = false\"\n    />\n\n    <!-- 订阅文件信息弹窗 -->\n    <SubscribeFilesDialog\n      v-if=\"subscribeFilesDialog\"\n      v-model=\"subscribeFilesDialog\"\n      :subid=\"props.media?.id\"\n      @close=\"subscribeFilesDialog = false\"\n    />\n    <!-- 分享订阅弹窗 -->\n    <SubscribeShareDialog\n      v-if=\"subscribeShareDialog\"\n      v-model=\"subscribeShareDialog\"\n      :sub=\"props.media\"\n      @close=\"subscribeShareDialog = false\"\n    />\n  </div>\n</template>\n<style lang=\"scss\" scoped>\n.subscribe-card-background {\n  background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);\n}\n</style>\n"
  },
  {
    "path": "src/components/cards/SubscribeShareCard.vue",
    "content": "<script lang=\"ts\" setup>\nimport { formatDateDifference } from '@/@core/utils/formatters'\nimport type { SubscribeShare } from '@/api/types'\nimport router from '@/router'\nimport SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'\nimport ForkSubscribeDialog from '../dialog/ForkSubscribeDialog.vue'\nimport { useGlobalSettingsStore } from '@/stores'\n\n// 输入参数\nconst props = defineProps({\n  media: Object as PropType<SubscribeShare>,\n})\n\n// 定义删除事件\nconst emit = defineEmits(['delete'])\n\n// 从 provide 中获取全局设置\n// 全局设置\nconst globalSettingsStore = useGlobalSettingsStore()\nconst globalSettings = globalSettingsStore.globalSettings\n\n// 图片是否加载完成\nconst imageLoaded = ref(false)\n\n// 订阅编辑弹窗\nconst subscribeEditDialog = ref(false)\n\n// 复用订阅弹窗\nconst forkSubscribeDialog = ref(false)\n\n// 订阅ID\nconst subscribeId = ref<number>()\n\n// 图片加载完成响应\nfunction imageLoadHandler() {\n  imageLoaded.value = true\n}\n\n// 分享时间\nconst dateText = ref(props.media && props.media?.date ? formatDateDifference(props.media.date) : '')\n\n// 计算backdrop图片地址\nconst backdropUrl = computed(() => {\n  const url = props.media?.backdrop || props.media?.poster\n  // 使用图片缓存\n  if (globalSettings.GLOBAL_IMAGE_CACHE && url)\n    return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`\n  return url\n})\n\n// 计算海报图片地址\nconst posterUrl = computed(() => {\n  const url = props.media?.poster\n  // 使用图片缓存\n  if (globalSettings.GLOBAL_IMAGE_CACHE && url)\n    return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`\n  return url\n})\n\n// 获得mediaid\nfunction getMediaId() {\n  if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`\n  else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`\n}\n\n// 查看媒体详情\nasync function viewMediaDetail() {\n  router.push({\n    path: '/media',\n    query: {\n      mediaid: getMediaId(),\n      title: props.media?.name,\n      year: props.media?.year,\n      type: props.media?.type,\n    },\n  })\n}\n\n// 复用订阅\nfunction showForkSubscribe() {\n  forkSubscribeDialog.value = true\n}\n\n// 完成复用订阅\nfunction finishForkSubscribe(subid: number) {\n  subscribeId.value = subid\n  forkSubscribeDialog.value = false\n  subscribeEditDialog.value = true\n}\n\n// 删除订阅分享时处理\nfunction doDelete() {\n  forkSubscribeDialog.value = false\n  // 通知父组件刷新\n  emit('delete')\n}\n</script>\n\n<template>\n  <div class=\"h-full\">\n    <VHover>\n      <template #default=\"hover\">\n        <div\n          class=\"w-full h-full rounded-lg overflow-hidden\"\n          :class=\"{\n            'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,\n          }\"\n        >\n          <VCard\n            v-bind=\"hover.props\"\n            :key=\"props.media?.id\"\n            class=\"flex flex-col h-full\"\n            rounded=\"0\"\n            min-height=\"150\"\n            @click=\"showForkSubscribe\"\n          >\n            <template #image>\n              <VImg :src=\"backdropUrl || posterUrl\" aspect-ratio=\"3/2\" cover @load=\"imageLoadHandler\" position=\"top\">\n                <template #placeholder>\n                  <div class=\"w-full h-full\">\n                    <VSkeletonLoader class=\"object-cover aspect-w-3 aspect-h-2\" />\n                  </div>\n                </template>\n                <template #default>\n                  <div class=\"absolute inset-0 subscribe-card-background\"></div>\n                </template>\n              </VImg>\n            </template>\n            <div class=\"h-full flex flex-col\">\n              <VCardText class=\"flex items-center pa-3 pb-1 grow\">\n                <div class=\"h-auto w-16 flex-shrink-0 overflow-hidden rounded-md\" v-if=\"imageLoaded\">\n                  <VImg :src=\"posterUrl\" aspect-ratio=\"2/3\" cover @click.stop=\"viewMediaDetail\">\n                    <template #placeholder>\n                      <div class=\"w-full h-full\">\n                        <VSkeletonLoader class=\"object-cover aspect-w-2 aspect-h-3\" />\n                      </div>\n                    </template>\n                  </VImg>\n                </div>\n                <div class=\"flex flex-col justify-center pl-2 xl:pl-4\">\n                  <div class=\"mr-2 min-w-0 text-lg font-bold text-white line-clamp-2 overflow-hidden text-ellipsis ...\">\n                    {{ props.media?.share_title }}\n                  </div>\n                  <div class=\"text-sm font-medium text-gray-200 sm:pt-1 line-clamp-3 overflow-hidden text-ellipsis ...\">\n                    {{ props.media?.share_comment }}\n                  </div>\n                </div>\n              </VCardText>\n              <VCardText class=\"flex justify-space-between align-center flex-wrap py-2\">\n                <div class=\"flex align-center\">\n                  <IconBtn v-bind=\"props\" icon=\"mdi-account\" color=\"white\" class=\"me-1\" />\n                  <div class=\"text-subtitle-2 me-4 text-white\">\n                    {{ props.media?.share_user }}\n                  </div>\n                  <IconBtn v-if=\"props.media?.count\" icon=\"mdi-fire\" color=\"white\" class=\"me-1\" />\n                  <span v-if=\"props.media?.count\" class=\"text-subtitle-2 me-4 text-white\">\n                    {{ props.media?.count.toLocaleString() }}\n                  </span>\n                </div>\n              </VCardText>\n              <VCardText class=\"absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300\">\n                <VIcon icon=\"mdi-calcdar\" class=\"me-1\" />\n                {{ dateText }}\n              </VCardText>\n            </div>\n          </VCard>\n        </div>\n      </template>\n    </VHover>\n    <!-- 订阅编辑弹窗 -->\n    <SubscribeEditDialog\n      v-if=\"subscribeEditDialog\"\n      v-model=\"subscribeEditDialog\"\n      :subid=\"subscribeId\"\n      @close=\"subscribeEditDialog = false\"\n      @save=\"subscribeEditDialog = false\"\n      @remove=\"subscribeEditDialog = false\"\n    />\n    <!-- 复用订阅弹窗 -->\n    <ForkSubscribeDialog\n      v-if=\"forkSubscribeDialog\"\n      v-model=\"forkSubscribeDialog\"\n      :media=\"props.media\"\n      @close=\"forkSubscribeDialog = false\"\n      @fork=\"finishForkSubscribe\"\n      @delete=\"doDelete\"\n    />\n  </div>\n</template>\n<style lang=\"scss\" scoped>\n.subscribe-card-background {\n  background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);\n}\n</style>\n"
  },
  {
    "path": "src/components/cards/TorrentCard.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { PropType } from 'vue'\nimport { formatFileSize, formatDateDifference } from '@/@core/utils/formatters'\nimport api from '@/api'\nimport type { Context } from '@/api/types'\nimport AddDownloadDialog from '../dialog/AddDownloadDialog.vue'\nimport { isNullOrEmptyObject } from '@/@core/utils'\n\n// 输入参数\nconst props = defineProps({\n  torrent: Object as PropType<Context>,\n  more: Array as PropType<Context[]>,\n  width: String,\n  height: String,\n})\n\n// 更多来源界面\nconst showMoreTorrents = ref(false)\n\n// 种子信息\nconst torrent = ref(props.torrent?.torrent_info)\n\n// 媒体信息\nconst media = ref(props.torrent?.media_info)\n\n// 识别元数据\nconst meta = ref(props.torrent?.meta_info)\n\n// 当前下载项\nconst downloadItem = ref(props.torrent)\n\n// 站点图标\nconst siteIcons = ref<Record<number, string>>({})\n\n// 存储是否已经下载过的记录\nconst downloaded = ref<string[]>([])\n\n// 添加下载对话框\nconst addDownloadDialog = ref(false)\n\n// 添加下载成功\nfunction addDownloadSuccess(url: string) {\n  addDownloadDialog.value = false\n  // 添加下载成功\n  downloaded.value.push(url)\n}\n\n// 添加下载失败\nfunction addDownloadError(error: string) {\n  addDownloadDialog.value = false\n}\n\n// 查询站点图标\nasync function getSiteIcon(site: number | undefined) {\n  if (!site) return\n  try {\n    siteIcons.value[site] = (await api.get(`site/icon/${site}`)).data.icon\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 询问并添加下载\nasync function handleAddDownload(item: Context | null = null) {\n  if (item && !isNullOrEmptyObject(item)) {\n    downloadItem.value = item\n  }\n  // 打开下载对话框\n  addDownloadDialog.value = true\n}\n\n// 打开种子详情页面\nfunction openTorrentDetail(item: Context | null = null) {\n  if (item && !isNullOrEmptyObject(item) && !isNullOrEmptyObject(item.torrent_info)) {\n    window.open(item.torrent_info.page_url, '_blank')\n    return\n  }\n  window.open(torrent.value?.page_url, '_blank')\n}\n\n// 下载种子文件\nasync function downloadTorrentFile() {\n  window.open(torrent.value?.enclosure, '_blank')\n}\n\n// 获取优惠类型样式\nfunction getPromotionClass(downloadVolumeFactor: number | undefined, uploadVolumeFactor: number | undefined) {\n  if (!downloadVolumeFactor) return 'bg-success'\n  if (downloadVolumeFactor === 0) return 'bg-success'\n  else if (downloadVolumeFactor < 1) return 'bg-orange'\n  else if (uploadVolumeFactor !== undefined && uploadVolumeFactor > 1) return 'bg-purple'\n  else return ''\n}\n\n// 获取优惠标签类\nfunction getPromotionChipClass(downloadVolumeFactor: number | undefined, uploadVolumeFactor: number | undefined) {\n  if (!downloadVolumeFactor) return 'chip-free'\n  if (downloadVolumeFactor === 0) return 'chip-free'\n  else if (downloadVolumeFactor < 1) return 'chip-discount'\n  else if (uploadVolumeFactor !== undefined && uploadVolumeFactor > 1) return 'chip-bonus'\n  else return ''\n}\n\n// 打开更多来源对话框\nasync function openMoreTorrentsDialog() {\n  props.more?.forEach(t => {\n    return getSiteIcon(t.torrent_info?.site)\n  })\n  showMoreTorrents.value = true\n}\n\n// 装载时查询站点图标\nonMounted(() => {\n  getSiteIcon(props.torrent?.torrent_info?.site)\n})\n</script>\n\n<template>\n  <div class=\"h-full\">\n    <VCard\n      :width=\"props.width || '100%'\"\n      :variant=\"downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'\"\n      @click=\"handleAddDownload(props.torrent)\"\n      class=\"h-full cursor-pointer transition-transform hover:-translate-y-1 duration-300 d-flex flex-column overflow-hidden torrent-card\"\n      :class=\"{ 'border-success border-2 opacity-85': downloaded.includes(torrent?.enclosure || '') }\"\n      hover\n    >\n      <!-- 优惠标签 -->\n      <div\n        v-if=\"torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1\"\n        class=\"discount-banner text-white px-2 py-1 text-sm font-weight-bold rounded-bl-lg\"\n        :class=\"getPromotionClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)\"\n      >\n        {{ torrent?.volume_factor }}\n      </div>\n\n      <!-- 媒体标题 -->\n      <VCardItem class=\"pt-3 pb-0\">\n        <div class=\"d-flex flex-row flex-wrap justify-start align-center mb-2 pr-8\">\n          <span class=\"text-h6 font-weight-bold me-2\">\n            {{ media?.title ?? meta?.name }}\n          </span>\n          <VChip\n            v-if=\"meta?.season_episode\"\n            class=\"chip-season rounded-sm font-weight-bold\"\n            variant=\"elevated\"\n            size=\"small\"\n          >\n            {{ meta?.season_episode }}\n          </VChip>\n        </div>\n\n        <!-- 站点信息条 -->\n        <div class=\"d-flex justify-space-between align-center flex-wrap\">\n          <div class=\"d-flex align-center\">\n            <VImg\n              v-if=\"siteIcons[torrent?.site || 0]\"\n              :src=\"siteIcons[torrent?.site || 0]\"\n              :alt=\"torrent?.site_name\"\n              class=\"mr-2 rounded\"\n              width=\"20\"\n              height=\"20\"\n            />\n            <VAvatar v-else size=\"20\" class=\"mr-2 text-caption bg-surface-variant\" color=\"surface-variant\">\n              {{ torrent?.site_name?.substring(0, 1) }}\n            </VAvatar>\n            <span class=\"font-weight-bold text-body-2\">{{ torrent?.site_name }}</span>\n          </div>\n\n          <div class=\"d-flex align-center gap-3\">\n            <span v-if=\"torrent?.seeders\" class=\"d-flex align-center font-weight-bold\">\n              <VIcon size=\"small\" color=\"success\" icon=\"mdi-arrow-up\" class=\"mr-1\"></VIcon>\n              {{ torrent?.seeders }}\n            </span>\n            <span v-if=\"torrent?.peers\" class=\"d-flex align-center font-weight-bold\">\n              <VIcon size=\"small\" color=\"warning\" icon=\"mdi-arrow-down\" class=\"mr-1\"></VIcon>\n              {{ torrent?.peers }}\n            </span>\n          </div>\n        </div>\n      </VCardItem>\n\n      <!-- 种子内容 -->\n      <VCardText class=\"d-flex flex-column flex-grow-1 pa-3 overflow-hidden\">\n        <!-- 种子标题 -->\n        <div class=\"text-subtitle-2 text-high-emphasis font-weight-medium mb-1 break-all\" :title=\"torrent?.title\">\n          {{ torrent?.title }}\n        </div>\n\n        <!-- 种子描述 -->\n        <div\n          v-if=\"meta?.subtitle || torrent?.description\"\n          class=\"text-body-2 text-medium-emphasis mb-2 break-all\"\n          :title=\"meta?.subtitle || torrent?.description\"\n        >\n          {{ meta?.subtitle || torrent?.description }}\n        </div>\n\n        <!-- 发布时间 -->\n        <div v-if=\"torrent?.pubdate\" class=\"d-flex align-center justify-start mb-2\">\n          <VIcon size=\"small\" color=\"grey\" icon=\"mdi-clock-outline\" class=\"me-1\"></VIcon>\n          <span class=\"text-sm text-medium-emphasis\">{{ formatDateDifference(torrent.pubdate) }}</span>\n        </div>\n\n        <!-- 资源标签区 -->\n        <div class=\"d-flex flex-wrap gap-1 mb-2\">\n          <!-- 流媒体平台 -->\n          <VChip v-if=\"meta?.web_source\" class=\"chip-web-source rounded-sm\" size=\"x-small\" variant=\"elevated\">\n            {{ meta?.web_source }}\n          </VChip>\n\n          <!-- 版本标签 -->\n          <VChip v-if=\"meta?.edition\" class=\"chip-edition rounded-sm\" size=\"x-small\" variant=\"elevated\">\n            {{ meta?.edition }}\n          </VChip>\n\n          <!-- 分辨率标签 -->\n          <VChip v-if=\"meta?.resource_pix\" class=\"chip-resolution rounded-sm\" size=\"x-small\" variant=\"elevated\">\n            {{ meta?.resource_pix }}\n          </VChip>\n\n          <!-- 编码标签 -->\n          <VChip v-if=\"meta?.video_encode\" class=\"chip-codec rounded-sm\" size=\"x-small\" variant=\"elevated\">\n            {{ meta?.video_encode }}\n          </VChip>\n\n          <!-- 制作组标签 -->\n          <VChip v-if=\"meta?.resource_team\" class=\"chip-team rounded-sm\" size=\"x-small\" variant=\"elevated\">\n            {{ meta?.resource_team }}\n          </VChip>\n\n          <!-- 其他标签 -->\n          <VChip\n            v-for=\"(label, index) in torrent?.labels\"\n            :key=\"index\"\n            class=\"chip-label rounded-sm\"\n            size=\"x-small\"\n            variant=\"elevated\"\n          >\n            {{ label }}\n          </VChip>\n\n          <!-- 特殊标签 -->\n          <VChip v-if=\"torrent?.hit_and_run\" class=\"chip-hr rounded-sm\" size=\"x-small\" variant=\"elevated\">H&R</VChip>\n          <VChip v-if=\"torrent?.freedate_diff\" class=\"chip-expire rounded-sm\" size=\"x-small\" variant=\"elevated\">\n            {{ torrent?.freedate_diff }}\n          </VChip>\n        </div>\n      </VCardText>\n\n      <!-- 卡片底部信息 -->\n      <VCardActions class=\"border-t border-opacity-10 mt-auto pa-2\">\n        <div v-if=\"props.more && props.more.length > 0\">\n          <VBtn\n            variant=\"text\"\n            color=\"primary\"\n            size=\"small\"\n            class=\"pa-1 d-flex align-center\"\n            @click.stop=\"openMoreTorrentsDialog\"\n          >\n            <VIcon :icon=\"showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'\" size=\"small\" class=\"mr-1\"></VIcon>\n            更多来源 ({{ props.more.length }})\n          </VBtn>\n        </div>\n\n        <VSpacer />\n\n        <!-- 体积和详情按钮并排 -->\n        <div class=\"d-flex align-center\">\n          <VChip v-if=\"torrent?.size\" color=\"primary\" size=\"x-small\" variant=\"elevated\" class=\"rounded-sm mr-2\">\n            {{ formatFileSize(torrent.size) }}\n          </VChip>\n          <VBtn icon size=\"small\" variant=\"text\" color=\"primary\" @click.stop=\"openTorrentDetail()\">\n            <VIcon icon=\"mdi-information-outline\"></VIcon>\n          </VBtn>\n        </div>\n      </VCardActions>\n    </VCard>\n\n    <!-- 更多来源对话框 -->\n    <VDialog v-model=\"showMoreTorrents\" max-width=\"25rem\" location=\"center\">\n      <VCard>\n        <VCardTitle class=\"py-3 d-flex align-center\">\n          <span>其他来源</span>\n          <VSpacer />\n          <VBtn variant=\"text\" size=\"small\" icon=\"mdi-close\" @click.stop=\"showMoreTorrents = false\"></VBtn>\n        </VCardTitle>\n\n        <VDivider />\n\n        <VCardText class=\"more-sources-content pa-0\">\n          <VList lines=\"one\" density=\"compact\">\n            <VListItem\n              v-for=\"(item, index) in props.more\"\n              :key=\"index\"\n              @click.stop=\"handleAddDownload(item)\"\n              class=\"hover:bg-primary-lighten-5\"\n            >\n              <template v-slot:prepend>\n                <div class=\"d-flex align-center gap-1\">\n                  <VImg\n                    v-if=\"siteIcons[item.torrent_info?.site || 0]\"\n                    :src=\"siteIcons[item.torrent_info?.site || 0]\"\n                    :alt=\"item.torrent_info?.site_name\"\n                    width=\"16\"\n                    height=\"16\"\n                    class=\"rounded\"\n                  />\n                  <VAvatar v-else size=\"16\" class=\"text-caption bg-surface-variant\">\n                    {{ item.torrent_info?.site_name?.substring(0, 1) }}\n                  </VAvatar>\n                  <span class=\"text-body-2 font-weight-bold\">{{ item.torrent_info.site_name }}</span>\n\n                  <VChip\n                    v-if=\"item.meta_info?.season_episode\"\n                    class=\"chip-season rounded-sm ml-1\"\n                    size=\"x-small\"\n                    variant=\"elevated\"\n                  >\n                    {{ item.meta_info.season_episode }}\n                  </VChip>\n\n                  <VChip\n                    v-if=\"item.torrent_info?.downloadvolumefactor !== 1 || item.torrent_info?.uploadvolumefactor !== 1\"\n                    :class=\"\n                      getPromotionChipClass(\n                        item.torrent_info?.downloadvolumefactor,\n                        item.torrent_info?.uploadvolumefactor,\n                      )\n                    \"\n                    size=\"x-small\"\n                    variant=\"elevated\"\n                    class=\"rounded-sm ml-1\"\n                  >\n                    {{ item.torrent_info?.volume_factor }}\n                  </VChip>\n                </div>\n              </template>\n\n              <template v-slot:append>\n                <div class=\"d-flex align-center gap-2\">\n                  <span class=\"text-caption font-weight-bold text-primary\">\n                    {{ formatFileSize(item.torrent_info?.size) }}\n                  </span>\n                  <span class=\"d-flex align-center text-caption font-weight-bold\">\n                    <VIcon size=\"small\" color=\"success\" icon=\"mdi-arrow-up\" class=\"mr-1\"></VIcon>\n                    {{ item.torrent_info?.seeders }}\n                  </span>\n                  <span>\n                    <VIcon\n                      @click.stop=\"openTorrentDetail(item)\"\n                      size=\"small\"\n                      color=\"secondary\"\n                      icon=\"mdi-arrow-top-right\"\n                      class=\"mr-1\"\n                    ></VIcon>\n                  </span>\n                </div>\n              </template>\n            </VListItem>\n          </VList>\n        </VCardText>\n      </VCard>\n    </VDialog>\n\n    <AddDownloadDialog\n      v-if=\"addDownloadDialog\"\n      v-model=\"addDownloadDialog\"\n      :title=\"`${downloadItem?.media_info?.title_year || downloadItem?.meta_info?.name} ${\n        downloadItem?.meta_info?.season_episode\n      }`\"\n      :media=\"downloadItem?.media_info\"\n      :torrent=\"downloadItem?.torrent_info\"\n      @done=\"addDownloadSuccess\"\n      @error=\"addDownloadError\"\n      @close=\"addDownloadDialog = false\"\n    />\n  </div>\n</template>\n\n<style scoped>\n.discount-banner {\n  position: absolute;\n  inset-block-start: 0;\n  inset-inline-end: 0;\n}\n\n.more-sources-content {\n  max-block-size: 60vh;\n  overflow-y: auto;\n}\n\n/* 卡片悬停效果 */\n.torrent-card {\n  border: 1px solid transparent;\n}\n\n.torrent-card:hover {\n  border-color: rgba(var(--v-theme-primary), 0.3);\n}\n\n/* 优惠标签样式 */\n.bg-success {\n  background-color: #4caf50;\n}\n\n.bg-orange {\n  background-color: #ff5722;\n}\n\n.bg-purple {\n  background-color: #9c27b0;\n}\n\n.chip-season {\n  background-color: #3f51b5;\n  color: white;\n}\n\n.chip-web-source {\n  background-color: #8000ff;\n  color: white;\n}\n\n.chip-edition {\n  background-color: #f44336;\n  color: white;\n}\n\n.chip-resolution {\n  background-color: #7b1fa2;\n  color: white;\n}\n\n.chip-codec {\n  background-color: #ff9800;\n  color: white;\n}\n\n.chip-team {\n  background-color: #00897b;\n  color: white;\n}\n\n.chip-label {\n  background-color: #5c6bc0;\n  color: white;\n}\n\n.chip-hr {\n  background-color: #212121;\n  color: white;\n}\n\n.chip-expire {\n  background-color: #7e57c2;\n  color: white;\n}\n\n.chip-free {\n  background-color: #4caf50;\n  color: white;\n}\n\n.chip-discount {\n  background-color: #ff5722;\n  color: white;\n}\n\n.chip-bonus {\n  background-color: #9c27b0;\n  color: white;\n}\n</style>\n"
  },
  {
    "path": "src/components/cards/TorrentItem.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { PropType } from 'vue'\nimport { formatFileSize, formatDateDifference } from '@/@core/utils/formatters'\nimport api from '@/api'\nimport type { Context } from '@/api/types'\nimport AddDownloadDialog from '../dialog/AddDownloadDialog.vue'\n\n// 输入参数\nconst props = defineProps({\n  torrent: Object as PropType<Context>,\n})\n\n// 种子信息\nconst torrent = ref(props.torrent?.torrent_info)\n\n// 媒体信息\nconst media = ref(props.torrent?.media_info)\n\n// 识别元数据\nconst meta = ref(props.torrent?.meta_info)\n\n// 站点图标\nconst siteIcon = ref('')\n\n// 站点图标加载状态\nconst iconLoading = ref(false)\nconst iconError = ref(false)\n\n// 存储是否已经下载过的记录\nconst downloaded = ref<string[]>([])\n\n// 添加下载对话框\nconst addDownloadDialog = ref(false)\n\n// 查询站点图标\nasync function getSiteIcon() {\n  if (!torrent?.value?.site || iconLoading.value) {\n    return\n  }\n\n  iconLoading.value = true\n  iconError.value = false\n\n  try {\n    const response = await api.get(`site/icon/${torrent.value.site}`)\n    if (response && response.data && response.data.icon) {\n      siteIcon.value = response.data.icon\n    } else {\n      iconError.value = true\n    }\n  } catch (error) {\n    console.error('Failed to load site icon:', error)\n    iconError.value = true\n  } finally {\n    iconLoading.value = false\n  }\n}\n\n// 获取优惠类型样式\nfunction getPromotionClass(downloadVolumeFactor: number | undefined, uploadVolumeFactor: number | undefined) {\n  if (!downloadVolumeFactor) return 'bg-success'\n  if (downloadVolumeFactor === 0) return 'bg-success'\n  else if (downloadVolumeFactor < 1) return 'bg-orange'\n  else if (uploadVolumeFactor !== undefined && uploadVolumeFactor > 1) return 'bg-purple'\n  else return ''\n}\n\n// 获取优惠标签类\nfunction getPromotionChipClass(downloadVolumeFactor: number | undefined, uploadVolumeFactor: number | undefined) {\n  if (!downloadVolumeFactor) return 'chip-free'\n  if (downloadVolumeFactor === 0) return 'chip-free'\n  else if (downloadVolumeFactor < 1) return 'chip-discount'\n  else if (uploadVolumeFactor !== undefined && uploadVolumeFactor > 1) return 'chip-bonus'\n  else return ''\n}\n\n// 询问并添加下载\nasync function handleAddDownload() {\n  // 打开下载对话框\n  addDownloadDialog.value = true\n}\n\n// 添加下载成功\nfunction addDownloadSuccess(url: string) {\n  addDownloadDialog.value = false\n  // 添加下载成功\n  downloaded.value.push(url)\n}\n\n// 添加下载失败\nfunction addDownloadError(error: string) {\n  addDownloadDialog.value = false\n}\n\n// 打开种子详情页面\nfunction openTorrentDetail() {\n  window.open(torrent.value?.page_url, '_blank')\n}\n\n// 装载时查询站点图标\nonMounted(() => {\n  getSiteIcon()\n})\n</script>\n\n<template>\n  <div class=\"w-100\">\n    <VListItem\n      :value=\"props.torrent?.torrent_info?.enclosure\"\n      class=\"pa-3 mb-2 rounded torrent-item transition-all duration-300 hover:-translate-y-1 overflow-hidden\"\n      :class=\"{ 'border-start border-success border-3 opacity-85': downloaded.includes(torrent?.enclosure || '') }\"\n      @click=\"handleAddDownload\"\n    >\n      <!-- 优惠标签 -->\n      <div\n        v-if=\"torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1\"\n        class=\"discount-banner text-white px-2 py-1 text-sm font-weight-bold rounded-bl-lg\"\n        :class=\"getPromotionClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)\"\n      >\n        {{ torrent?.volume_factor }}\n      </div>\n\n      <template v-slot:prepend>\n        <div class=\"d-flex flex-column align-center pr-3\" :title=\"torrent?.site_name\">\n          <VImg\n            v-if=\"siteIcon\"\n            :src=\"siteIcon\"\n            :alt=\"torrent?.site_name\"\n            class=\"rounded mb-1 site-icon\"\n            width=\"32\"\n            height=\"32\"\n          />\n          <VAvatar\n            v-else\n            size=\"32\"\n            class=\"mb-1 text-caption bg-primary-lighten-4 text-primary font-weight-bold site-icon\"\n          >\n            {{ torrent?.site_name?.substring(0, 1) }}\n          </VAvatar>\n        </div>\n      </template>\n\n      <VListItemTitle class=\"whitespace-normal\">\n        <div class=\"d-flex flex-row flex-wrap align-center mb-2\">\n          <span class=\"text-h6 font-weight-bold me-2\">{{ media?.title ?? meta?.name }}</span>\n          <VChip\n            v-if=\"meta?.season_episode\"\n            class=\"chip-season rounded-sm font-weight-bold\"\n            variant=\"elevated\"\n            size=\"small\"\n          >\n            {{ meta?.season_episode }}\n          </VChip>\n        </div>\n\n        <div class=\"text-subtitle-2 font-weight-medium mb-2 break-all\" :title=\"torrent?.title\">\n          {{ torrent?.title }}\n        </div>\n\n        <div\n          class=\"text-body-2 text-medium-emphasis mb-2 break-all\"\n          :title=\"meta?.subtitle || torrent?.description || '暂无描述'\"\n        >\n          {{ meta?.subtitle || torrent?.description || '暂无描述' }}\n        </div>\n\n        <!-- 发布时间 -->\n        <div v-if=\"torrent?.pubdate\" class=\"d-flex align-center mb-2\">\n          <VIcon size=\"small\" color=\"grey\" icon=\"mdi-clock-outline\" class=\"me-1\"></VIcon>\n          <span class=\"text-sm text-medium-emphasis\">{{ formatDateDifference(torrent.pubdate) }}</span>\n        </div>\n\n        <div class=\"d-flex flex-wrap gap-1 mb-2\">\n          <!-- 流媒体平台 -->\n          <VChip v-if=\"meta?.web_source\" class=\"chip-web-source rounded-sm\" size=\"x-small\" variant=\"elevated\">\n            {{ meta?.web_source }}\n          </VChip>\n\n          <!-- 版本标签 -->\n          <VChip v-if=\"meta?.edition\" class=\"chip-edition rounded-sm\" size=\"x-small\" variant=\"elevated\">\n            {{ meta?.edition }}\n          </VChip>\n\n          <!-- 分辨率标签 -->\n          <VChip v-if=\"meta?.resource_pix\" class=\"chip-resolution rounded-sm\" size=\"x-small\" variant=\"elevated\">\n            {{ meta?.resource_pix }}\n          </VChip>\n\n          <!-- 编码标签 -->\n          <VChip v-if=\"meta?.video_encode\" class=\"chip-codec rounded-sm\" size=\"x-small\" variant=\"elevated\">\n            {{ meta?.video_encode }}\n          </VChip>\n\n          <!-- 制作组标签 -->\n          <VChip v-if=\"meta?.resource_team\" class=\"chip-team rounded-sm\" size=\"x-small\" variant=\"elevated\">\n            {{ meta?.resource_team }}\n          </VChip>\n\n          <!-- 其他标签 -->\n          <VChip\n            v-for=\"(label, index) in torrent?.labels\"\n            :key=\"index\"\n            class=\"chip-label rounded-sm\"\n            size=\"x-small\"\n            variant=\"elevated\"\n          >\n            {{ label }}\n          </VChip>\n\n          <!-- 特殊标签 -->\n          <VChip v-if=\"torrent?.hit_and_run\" class=\"chip-hr rounded-sm\" size=\"x-small\" variant=\"elevated\"> H&R </VChip>\n          <VChip v-if=\"torrent?.freedate_diff\" class=\"chip-expire rounded-sm\" size=\"x-small\" variant=\"elevated\">\n            {{ torrent?.freedate_diff }}\n          </VChip>\n        </div>\n      </VListItemTitle>\n\n      <template v-slot:append>\n        <div class=\"d-flex flex-column align-end gap-2\">\n          <div class=\"d-flex align-center gap-3\">\n            <span v-if=\"torrent?.seeders\" class=\"d-flex align-center font-weight-bold\">\n              <VIcon size=\"small\" color=\"success\" icon=\"mdi-arrow-up\" class=\"mr-1\"></VIcon>\n              {{ torrent?.seeders }}\n            </span>\n            <span v-if=\"torrent?.peers\" class=\"d-flex align-center font-weight-bold\">\n              <VIcon size=\"small\" color=\"warning\" icon=\"mdi-arrow-down\" class=\"mr-1\"></VIcon>\n              {{ torrent?.peers }}\n            </span>\n          </div>\n\n          <div class=\"d-flex align-center\">\n            <VChip v-if=\"torrent?.size\" color=\"primary\" size=\"x-small\" variant=\"elevated\" class=\"rounded-sm mr-2\">\n              {{ formatFileSize(torrent.size) }}\n            </VChip>\n\n            <VBtn icon size=\"small\" variant=\"text\" color=\"primary\" @click.stop=\"openTorrentDetail\">\n              <VIcon icon=\"mdi-information-outline\"></VIcon>\n            </VBtn>\n          </div>\n        </div>\n      </template>\n    </VListItem>\n\n    <AddDownloadDialog\n      v-if=\"addDownloadDialog\"\n      v-model=\"addDownloadDialog\"\n      :title=\"`${media?.title_year || meta?.name} ${meta?.season_episode || ''}`\"\n      :media=\"media\"\n      :torrent=\"torrent\"\n      @done=\"addDownloadSuccess\"\n      @error=\"addDownloadError\"\n      @close=\"addDownloadDialog = false\"\n    />\n  </div>\n</template>\n\n<style scoped>\n.discount-banner {\n  position: absolute;\n  z-index: 3;\n  inset-block-start: 0;\n  inset-inline-end: 0;\n}\n\n.torrent-item {\n  border: 1px solid transparent;\n}\n\n.torrent-item:hover {\n  border-color: rgba(var(--v-theme-primary), 0.3);\n}\n\n.chip-season {\n  background-color: #3f51b5;\n  color: white;\n}\n\n.chip-web-source {\n  background-color: #8000ff;\n  color: white;\n}\n\n.chip-edition {\n  background-color: #f44336;\n  color: white;\n}\n\n.chip-resolution {\n  background-color: #7b1fa2;\n  color: white;\n}\n\n.chip-codec {\n  background-color: #ff9800;\n  color: white;\n}\n\n.chip-team {\n  background-color: #00897b;\n  color: white;\n}\n\n.chip-label {\n  background-color: #5c6bc0;\n  color: white;\n}\n\n.chip-hr {\n  background-color: #212121;\n  color: white;\n}\n\n.chip-expire {\n  background-color: #7e57c2;\n  color: white;\n}\n\n/* 优惠标签样式 */\n.bg-success {\n  background-color: #4caf50;\n}\n\n.bg-orange {\n  background-color: #ff5722;\n}\n\n.bg-purple {\n  background-color: #9c27b0;\n}\n\n.chip-free {\n  background-color: #4caf50;\n  color: white;\n}\n\n.chip-discount {\n  background-color: #ff5722;\n  color: white;\n}\n\n.chip-bonus {\n  background-color: #9c27b0;\n  color: white;\n}\n\n.site-icon {\n  transition: transform 0.2s ease;\n}\n\n.site-icon:hover {\n  transform: scale(1.1);\n}\n</style>\n"
  },
  {
    "path": "src/components/cards/UserCard.vue",
    "content": "<script setup lang=\"ts\">\nimport api from '@/api'\nimport { Subscribe, User } from '@/api/types'\nimport { useUserStore } from '@/stores'\nimport avatar1 from '@images/avatars/avatar-1.png'\nimport { useToast } from 'vue-toastification'\nimport { useConfirm } from '@/composables/useConfirm'\nimport UserAddEditDialog from '@/components/dialog/UserAddEditDialog.vue'\nimport { useDisplay } from 'vuetify'\nimport { useI18n } from 'vue-i18n'\n\n// 国际化\nconst { t } = useI18n()\n\n// 扩展User类型以包含昵称字段\ninterface ExtendedUser extends User {\n  nickname?: string\n}\n\n// 定义输入变量\nconst props = defineProps({\n  // 用户信息\n  user: {\n    type: Object as PropType<ExtendedUser>,\n    required: true,\n  },\n  // 所有用户\n  users: {\n    type: Array as PropType<User[]>,\n    required: true,\n  },\n})\n\nconst display = useDisplay()\nconst isMobile = computed(() => display.mdAndDown.value)\n\n// 当前用户的ID\nconst currentLoginUserId = computed(() => useUserStore().userID)\n\n// 当前用户是否是管理员\nconst currentUserIsSuperuser = computed(() => useUserStore().superUser)\n\n// 定义触发的自定义事件\nconst emit = defineEmits(['remove', 'save'])\n\n// 确认框\nconst createConfirm = useConfirm()\n\n// 用户信息弹窗\nconst userEditDialog = ref(false)\n\n// 提示框\nconst $toast = useToast()\n\n// 用户电影订阅数量\nconst movieSubscriptions = ref(0)\n\n// 用户电视剧订阅数量\nconst tvShowSubscriptions = ref(0)\n\n// 显示名称 - 如果有昵称则优先显示昵称\nconst displayName = computed(() => {\n  const settingsNickname = props.user.settings?.nickname as string | undefined\n  const nickname = props.user.nickname || settingsNickname\n  return nickname || props.user.name\n})\n\n// 按用户查询订阅数量\nasync function fetchSubscriptions() {\n  try {\n    const result: Subscribe[] = await api.get(`subscribe/user/${props.user.name}`)\n    if (result) {\n      movieSubscriptions.value = result.filter(item => item.type === '电影').length\n      tvShowSubscriptions.value = result.filter(item => item.type === '电视剧').length\n    }\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 删除用户\nasync function removeUser() {\n  if (props.user.id === currentLoginUserId.value) {\n    $toast.error(t('user.cannotDeleteCurrentUser'))\n    return\n  }\n  try {\n    const isConfirmed = await createConfirm({\n      title: t('common.confirm'),\n      content: t('user.confirmDeleteUser', { username: props.user?.name }),\n    })\n    if (!isConfirmed) return\n    const result: { [key: string]: any } = await api.delete(`user/id/${props.user.id}`)\n    if (result.success) {\n      $toast.success(t('user.deleteSuccess'))\n      emit('remove')\n    } else {\n      $toast.error(t('user.deleteFailed'))\n    }\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 编辑用户\nfunction editUser() {\n  userEditDialog.value = true\n}\n\n// 用户更新完成时\nfunction onUserUpdate() {\n  userEditDialog.value = false\n  emit('save')\n}\n\nonMounted(() => {\n  fetchSubscriptions()\n})\n</script>\n<template>\n  <VCard\n    :class=\"[\n      'transition-transform duration-300 hover:-translate-y-1',\n      !props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',\n    ]\"\n    class=\"flex flex-column\"\n    @click=\"userEditDialog = true\"\n  >\n    <div class=\"flex-grow\">\n      <!-- 用户头像和基本信息 -->\n      <VCardItem :class=\"[user.is_superuser ? 'admin-header' : '']\">\n        <template v-slot:prepend>\n          <div class=\"position-relative mr-4\">\n            <VAvatar\n              size=\"72\"\n              rounded=\"lg\"\n              :class=\"[\n                user.is_superuser ? 'admin-avatar' : 'border-4 bg-surface',\n                !user.is_active ? 'grayscale-50 opacity-90' : '',\n              ]\"\n              :style=\"user.is_superuser ? 'border: 4px solid rgba(var(--v-theme-warning), 0.3);' : ''\"\n            >\n              <VImg :src=\"user.avatar || avatar1\" :alt=\"user.name\" />\n              <div\n                v-if=\"!user.is_active\"\n                class=\"position-absolute d-flex align-center justify-center rounded-lg bg-surface-variant opacity-20\"\n                style=\"inset: 0\"\n              >\n                <VIcon icon=\"mdi-account-lock\" color=\"white\" />\n              </div>\n            </VAvatar>\n            <div v-if=\"user.is_superuser\" class=\"admin-crown\">\n              <VIcon icon=\"mdi-crown\" color=\"warning\" />\n            </div>\n          </div>\n        </template>\n\n        <VCardTitle class=\"pa-0 d-flex flex-column\">\n          <div class=\"d-flex flex-column mb-1\">\n            <div class=\"d-flex align-center\">\n              <span\n                :class=\"[\n                  'text-h6 font-weight-bold truncate',\n                  user.is_superuser ? 'text-warning' : '',\n                  !user.is_active ? 'text-medium-emphasis' : '',\n                ]\"\n              >\n                {{ displayName }}\n                <VIcon\n                  v-if=\"user.nickname || user.settings?.nickname\"\n                  icon=\"mdi-format-quote-close\"\n                  size=\"x-small\"\n                  color=\"info\"\n                  class=\"animate-pulse\"\n                />\n              </span>\n            </div>\n            <div class=\"d-flex flex-wrap gap-1 overflow-auto\">\n              <VChip v-if=\"user.is_superuser\" size=\"x-small\" color=\"error\" variant=\"outlined\" label>{{\n                t('user.admin')\n              }}</VChip>\n              <VChip v-else size=\"x-small\" label>{{ t('user.normal') }}</VChip>\n              <VChip size=\"x-small\" :color=\"user.is_active ? 'success' : 'grey'\" variant=\"tonal\" label>\n                {{ user.is_active ? t('user.active') : t('user.inactive') }}\n              </VChip>\n              <VChip v-if=\"user.is_otp\" size=\"x-small\" color=\"info\" variant=\"tonal\" label>2FA</VChip>\n            </div>\n          </div>\n\n          <!-- 移动端订阅数据信息 -->\n          <div v-if=\"isMobile\" class=\"d-flex gap-5 mt-2\">\n            <div class=\"d-flex align-center\">\n              <VIcon size=\"x-small\" icon=\"mdi-movie-outline\" color=\"primary\" class=\"mr-1\" />\n              <span class=\"text-body-2\">{{ movieSubscriptions }}</span>\n            </div>\n            <div class=\"d-flex align-center\">\n              <VIcon size=\"x-small\" icon=\"mdi-television-classic\" color=\"primary\" class=\"mr-1\" />\n              <span class=\"text-body-2\">{{ tvShowSubscriptions }}</span>\n            </div>\n          </div>\n        </VCardTitle>\n\n        <!-- 头部操作按钮 -->\n        <template v-slot:append>\n          <div :class=\"['d-flex', isMobile ? 'position-absolute top-2 right-2' : '']\">\n            <VBtn\n              icon\n              size=\"small\"\n              :color=\"user.is_superuser ? 'warning' : 'primary'\"\n              variant=\"text\"\n              class=\"opacity-70 hover:opacity-100 transition-opacity\"\n              @click.stop=\"editUser\"\n            >\n              <VIcon icon=\"mdi-pencil\" />\n            </VBtn>\n\n            <VBtn\n              v-if=\"props.user.id != currentLoginUserId && currentUserIsSuperuser\"\n              icon\n              size=\"small\"\n              color=\"error\"\n              variant=\"text\"\n              class=\"opacity-70 hover:opacity-100 transition-opacity\"\n              @click.stop=\"removeUser\"\n            >\n              <VIcon icon=\"mdi-delete\" />\n            </VBtn>\n          </div>\n        </template>\n      </VCardItem>\n\n      <!-- 权限显示 -->\n      <div v-if=\"!user.is_superuser && user.permissions\" class=\"d-flex flex-wrap gap-1 px-7 pb-3\">\n        <VChip v-if=\"user.permissions.discovery\" size=\"x-small\" color=\"purple\" variant=\"outlined\" label>\n          {{ t('dialog.userAddEdit.permissions.discovery') }}\n        </VChip>\n        <VChip v-if=\"user.permissions.search\" size=\"x-small\" color=\"blue\" variant=\"outlined\" label>\n          {{ t('dialog.userAddEdit.permissions.search') }}\n        </VChip>\n        <VChip v-if=\"user.permissions.subscribe\" size=\"x-small\" color=\"green\" variant=\"outlined\" label>\n          {{ t('dialog.userAddEdit.permissions.subscribe') }}\n        </VChip>\n        <VChip v-if=\"user.permissions.manage\" size=\"x-small\" color=\"orange\" variant=\"outlined\" label>\n          {{ t('dialog.userAddEdit.permissions.manage') }}\n        </VChip>\n      </div>\n    </div>\n    <!-- 独立的邮箱显示 -->\n    <VDivider class=\"mx-4\" />\n    <div>\n      <VCardText class=\"d-flex align-center py-2 px-4 text-medium-emphasis\">\n        <VIcon icon=\"mdi-email-outline\" size=\"small\" color=\"primary\" class=\"mr-2 opacity-70\" />\n        <span class=\"text-body-2 truncate\">{{ user.email || t('user.noEmail') }}</span>\n      </VCardText>\n\n      <!-- PC端显示订阅统计信息 -->\n      <VCardText v-if=\"!isMobile\" class=\"px-4 pt-0 pb-4\">\n        <div rounded=\"lg\" class=\"d-flex justify-space-around\">\n          <div class=\"d-flex align-center gap-3\">\n            <VAvatar\n              tile\n              rounded=\"lg\"\n              size=\"large\"\n              class=\"mr-1\"\n              :class=\"user.is_superuser ? 'admin-stats-container' : 'user-stats-container'\"\n            >\n              <div :class=\"['d-flex align-center justify-center rounded-lg w-10 h-10']\">\n                <VIcon :color=\"user.is_superuser ? 'warning' : 'primary'\" icon=\"mdi-movie-outline\" size=\"20\" />\n              </div>\n            </VAvatar>\n            <div class=\"d-flex flex-column\">\n              <span class=\"text-lg text-medium-emphasis font-weight-bold\">{{ movieSubscriptions }}</span>\n              <span class=\"text-caption text-medium-emphasis\">{{ t('user.movieSubscriptions') }}</span>\n            </div>\n          </div>\n          <div class=\"d-flex align-center gap-3\">\n            <VAvatar\n              tile\n              rounded=\"lg\"\n              size=\"large\"\n              class=\"mr-1\"\n              :class=\"user.is_superuser ? 'admin-stats-container' : 'user-stats-container'\"\n            >\n              <div :class=\"['d-flex align-center justify-center rounded-lg w-10 h-10']\">\n                <VIcon :color=\"user.is_superuser ? 'warning' : 'primary'\" icon=\"mdi-television-classic\" />\n              </div>\n            </VAvatar>\n            <div class=\"d-flex flex-column\">\n              <span class=\"text-lg text-medium-emphasis\">{{ tvShowSubscriptions }}</span>\n              <span class=\"text-caption text-medium-emphasis\">{{ t('user.tvSubscriptions') }}</span>\n            </div>\n          </div>\n        </div>\n      </VCardText>\n    </div>\n  </VCard>\n\n  <!-- 用户编辑弹窗 -->\n  <UserAddEditDialog\n    v-if=\"userEditDialog\"\n    v-model=\"userEditDialog\"\n    :username=\"props.user?.name\"\n    :usernames=\"props.users.map(item => item.name)\"\n    oper=\"edit\"\n    @save=\"onUserUpdate\"\n    @close=\"userEditDialog = false\"\n  />\n</template>\n\n<style scoped>\n.admin-decoration {\n  position: absolute;\n  z-index: 1;\n  display: flex;\n  align-items: center;\n  inline-size: 100%;\n  inset-block-start: 0;\n  padding-block: 8px;\n  padding-inline: 12px;\n}\n\n.admin-header {\n  background: linear-gradient(to bottom, rgba(var(--v-theme-warning), 0.05), transparent);\n}\n\n.admin-avatar::after {\n  position: absolute;\n  border: 1px solid rgba(var(--v-theme-warning), 0.3);\n  border-radius: 12px;\n  animation: pulse 2.5s infinite;\n  content: '';\n  inset: -5px;\n  pointer-events: none;\n}\n\n.admin-stats-container {\n  background-color: rgba(var(--v-theme-warning), 0.1);\n}\n\n.user-stats-container {\n  background-color: rgba(var(--v-theme-primary), 0.1);\n}\n\n@keyframes pulse {\n  0% {\n    opacity: 0.6;\n    transform: scale(0.95);\n  }\n\n  70% {\n    opacity: 0.2;\n    transform: scale(1.05);\n  }\n\n  100% {\n    opacity: 0.6;\n    transform: scale(0.95);\n  }\n}\n\n.admin-crown {\n  position: absolute;\n  z-index: 5;\n  animation: float 3s ease-in-out infinite;\n  filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 40%));\n  inset-block-start: -10px;\n  inset-inline-start: -6px;\n  transform: rotate(-25deg);\n}\n\n@keyframes float {\n  0% {\n    transform: rotate(-25deg) translateY(0);\n  }\n\n  50% {\n    transform: rotate(-25deg) translateY(-3px);\n  }\n\n  100% {\n    transform: rotate(-25deg) translateY(0);\n  }\n}\n\n.animate-pulse {\n  animation: pulse-nickname 2s ease infinite;\n}\n\n@keyframes pulse-nickname {\n  0%,\n  100% {\n    opacity: 0.9;\n    transform: scale(1);\n  }\n\n  50% {\n    opacity: 1;\n    transform: scale(1.2);\n  }\n}\n\n.grayscale-50 {\n  filter: grayscale(50%);\n}\n</style>\n"
  },
  {
    "path": "src/components/cards/WorkflowShareCard.vue",
    "content": "<script lang=\"ts\" setup>\nimport { formatDateDifference } from '@/@core/utils/formatters'\nimport type { WorkflowShare } from '@/api/types'\nimport ForkWorkflowDialog from '../dialog/ForkWorkflowDialog.vue'\n\n// 输入参数\nconst props = defineProps({\n  workflow: Object as PropType<WorkflowShare>,\n  eventTypes: {\n    type: Array as PropType<Array<{ title: string; value: string }>>,\n    default: () => [],\n  },\n})\n\n// 定义删除事件\nconst emit = defineEmits(['delete', 'update'])\n\n// 复用工作流弹窗\nconst forkWorkflowDialog = ref(false)\n\n// 工作流ID\nconst workflowId = ref<string>()\n\n// 分享时间\nconst dateText = ref(props.workflow && props.workflow?.date ? formatDateDifference(props.workflow.date) : '')\n\n// 随机渐变背景\nconst gradientStyle = ref('')\n\n// 生成随机渐变背景\nfunction generateRandomGradient() {\n  const gradients = [\n    'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',\n    'linear-gradient(135deg, #553c9a 0%, #b794f4 100%)',\n    'linear-gradient(135deg, #2c5aa0 0%, #1a365d 100%)',\n    'linear-gradient(135deg, #2f855a 0%, #22543d 100%)',\n    'linear-gradient(135deg, #c53030 0%, #742a2a 100%)',\n    'linear-gradient(135deg, #d69e2e 0%, #975a16 100%)',\n    'linear-gradient(135deg, #805ad5 0%, #553c9a 100%)',\n    'linear-gradient(135deg, #3182ce 0%, #2c5282 100%)',\n    'linear-gradient(135deg, #38a169 0%, #276749 100%)',\n    'linear-gradient(135deg, #e53e3e 0%, #c53030 100%)',\n    'linear-gradient(135deg, #dd6b20 0%, #c05621 100%)',\n    'linear-gradient(135deg, #6b46c1 0%, #553c9a 100%)',\n    'linear-gradient(135deg, #2b6cb0 0%, #2c5282 100%)',\n    'linear-gradient(135deg, #38a169 0%, #2f855a 100%)',\n    'linear-gradient(135deg, #d53f8c 0%, #97266d 100%)',\n  ]\n\n  // 基于工作流ID生成固定的随机数，确保同一工作流总是显示相同的渐变\n  const seed = String(props.workflow?.id || Math.random())\n  const hash = seed.split('').reduce((a, b) => {\n    a = (a << 5) - a + b.charCodeAt(0)\n    return a & a\n  }, 0)\n\n  const index = Math.abs(hash) % gradients.length\n  return gradients[index]\n}\n\n// 初始化渐变背景\nonMounted(() => {\n  gradientStyle.value = generateRandomGradient()\n})\n\n// 复用工作流\nfunction showForkWorkflow() {\n  forkWorkflowDialog.value = true\n}\n\n// 完成复用工作流\nfunction finishForkWorkflow(wid: string) {\n  workflowId.value = wid\n  forkWorkflowDialog.value = false\n  emit('update')\n}\n\n// 删除工作流分享时处理\nfunction doDelete() {\n  forkWorkflowDialog.value = false\n  // 通知父组件刷新\n  emit('delete')\n}\n</script>\n\n<template>\n  <div class=\"h-full\">\n    <VHover>\n      <template #default=\"hover\">\n        <div\n          class=\"w-full h-full rounded-lg overflow-hidden\"\n          :class=\"{\n            'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,\n          }\"\n        >\n          <VCard\n            v-bind=\"hover.props\"\n            :key=\"props.workflow?.id\"\n            class=\"flex flex-col h-full\"\n            rounded=\"0\"\n            min-height=\"150\"\n            :style=\"{ background: gradientStyle }\"\n            @click=\"showForkWorkflow\"\n          >\n            <div class=\"h-full flex flex-col\">\n              <VCardText class=\"flex items-center pa-3 pb-1 grow\">\n                <div class=\"flex flex-col justify-center w-full\">\n                  <VCardTitle class=\"text-lg text-bold text-white line-clamp-2 break-words\">\n                    {{ props.workflow?.share_title }}\n                  </VCardTitle>\n                  <div class=\"px-4 text-white text-opacity-90 overflow-hidden line-clamp-3 break-all ...\">\n                    {{ props.workflow?.share_comment }}\n                  </div>\n                </div>\n              </VCardText>\n              <VCardText class=\"flex justify-space-between align-center flex-wrap py-2\">\n                <div class=\"flex align-center\">\n                  <IconBtn v-bind=\"props\" icon=\"mdi-account\" class=\"me-1 text-white\" />\n                  <div class=\"text-subtitle-2 me-4 text-white text-opacity-90\">\n                    {{ props.workflow?.share_user }}\n                  </div>\n                  <IconBtn v-if=\"props.workflow?.count\" icon=\"mdi-fire\" class=\"me-1 text-white\" />\n                  <span v-if=\"props.workflow?.count\" class=\"text-subtitle-2 me-4 text-white text-opacity-90\">\n                    {{ props.workflow?.count.toLocaleString() }}\n                  </span>\n                </div>\n              </VCardText>\n              <VCardText class=\"absolute right-0 bottom-0 d-flex align-center p-2 text-white text-sm text-opacity-75\">\n                <VIcon icon=\"mdi-calendar\" size=\"small\" class=\"me-1\" />\n                {{ dateText }}\n              </VCardText>\n            </div>\n          </VCard>\n        </div>\n      </template>\n    </VHover>\n    <!-- 复用工作流弹窗 -->\n    <ForkWorkflowDialog\n      v-if=\"forkWorkflowDialog\"\n      v-model=\"forkWorkflowDialog\"\n      :workflow=\"props.workflow\"\n      :event-types=\"props.eventTypes\"\n      @close=\"forkWorkflowDialog = false\"\n      @fork=\"finishForkWorkflow\"\n      @delete=\"doDelete\"\n    />\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/cards/WorkflowTaskCard.vue",
    "content": "<script lang=\"ts\" setup>\nimport { Workflow } from '@/api/types'\nimport { useToast } from 'vue-toastification'\nimport { useConfirm } from '@/composables/useConfirm'\nimport WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue'\nimport WorkflowActionsDialog from '@/components/dialog/WorkflowActionsDialog.vue'\nimport WorkflowShareDialog from '@/components/dialog/WorkflowShareDialog.vue'\nimport api from '@/api'\nimport { useI18n } from 'vue-i18n'\n\nconst { t } = useI18n()\n\n// 定义输入参数\nconst props = defineProps({\n  workflow: {\n    required: true,\n    type: Object as PropType<Workflow>,\n  },\n  eventTypes: {\n    type: Array as PropType<Array<{ title: string; value: string }>>,\n    default: () => [],\n  },\n})\n\n// 定义事件\nconst emit = defineEmits(['refresh'])\n\n// 提示框\nconst $toast = useToast()\n\n// 确认框\nconst createConfirm = useConfirm()\n\n// 编辑对话框\nconst editDialog = ref(false)\n\n// 流程对话框\nconst flowDialog = ref(false)\n\n// 分享对话框\nconst shareDialog = ref(false)\n\n// 加载中\nconst loading = ref(false)\n\n// 根据事件类型值获取显示文本\nconst getEventTypeText = (eventTypeValue: string) => {\n  const eventType = props.eventTypes.find(item => item.value === eventTypeValue)\n  return eventType ? eventType.title : eventTypeValue\n}\n\n// 编辑任务\nfunction handleEdit(item: Workflow) {\n  editDialog.value = true\n}\n\n// 编辑流程\nfunction handleFlow(item: Workflow) {\n  flowDialog.value = true\n}\n\n// 分享工作流\nfunction handleShare(item: Workflow) {\n  shareDialog.value = true\n}\n\n// 编辑完成\nfunction editDone() {\n  editDialog.value = false\n  flowDialog.value = false\n  shareDialog.value = false\n  emit('refresh')\n}\n\n// 删除任务\nasync function handleDelete(item: Workflow) {\n  const isConfirmed = await createConfirm({\n    title: t('common.confirm'),\n    content: t('workflow.task.confirmDelete', { name: item.name }),\n  })\n\n  if (!isConfirmed) return\n\n  try {\n    const result: { [key: string]: string } = await api.delete(`workflow/${item.id}`)\n    if (result.success) {\n      $toast.success(t('workflow.task.deleteSuccess'))\n      emit('refresh')\n    } else {\n      $toast.error(t('workflow.task.deleteFailed', { message: result.message }))\n    }\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 开始任务\nasync function handleEnable(item: Workflow) {\n  loading.value = true\n  try {\n    const result: { [key: string]: string } = await api.post(`workflow/${item.id}/start`)\n    if (result.success) {\n      $toast.success(t('workflow.task.enableSuccess'))\n      emit('refresh')\n    } else {\n      $toast.error(t('workflow.task.enableFailed', { message: result.message }))\n    }\n  } catch (error) {\n    console.error(error)\n  }\n  loading.value = false\n}\n\n// 停用任务\nasync function handlePause(item: Workflow) {\n  loading.value = true\n  try {\n    const result: { [key: string]: string } = await api.post(`workflow/${item.id}/pause`)\n    if (result.success) {\n      $toast.success(t('workflow.task.pauseSuccess'))\n      emit('refresh')\n    } else {\n      $toast.error(t('workflow.task.pauseFailed', { message: result.message }))\n    }\n  } catch (error) {\n    console.error(error)\n  }\n  loading.value = false\n}\n\n// 立即执行任务\nasync function handleRun(item: Workflow, from_begin: boolean) {\n  loading.value = true\n  try {\n    setTimeout(() => {\n      emit('refresh')\n    }, 500)\n    const result: { [key: string]: string } = await api.post(`workflow/${item.id}/run?from_begin=${from_begin}`, {\n      from_begin,\n    })\n    if (result.success) {\n      $toast.success(t('workflow.task.runSuccess'))\n      emit('refresh')\n    } else {\n      $toast.error(t('workflow.task.runFailed', { message: result.message }))\n      emit('refresh')\n    }\n  } catch (error) {\n    console.error(error)\n  }\n  loading.value = false\n}\n\n// 重置任务\nasync function handleReset(item: Workflow) {\n  const isConfirmed = await createConfirm({\n    title: t('common.confirm'),\n    content: t('workflow.task.confirmReset', { name: item.name }),\n  })\n\n  if (!isConfirmed) return\n\n  try {\n    const result: { [key: string]: string } = await api.post(`workflow/${item.id}/reset`)\n    if (result.success) {\n      $toast.success(t('workflow.task.resetSuccess'))\n      emit('refresh')\n    } else {\n      $toast.error(t('workflow.task.resetFailed', { message: result.message }))\n    }\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 计算状态颜色\nconst resolveStatusVariant = (status: string | undefined) => {\n  if (status === 'S')\n    return {\n      color: 'success',\n      bgColor: 'linear-gradient(to bottom right, rgba(76, 175, 80, 0.9), rgba(76, 175, 80, 0.7))',\n      text: t('workflow.task.status.success'),\n    }\n  else if (status === 'R')\n    return {\n      color: 'primary',\n      bgColor: 'linear-gradient(to bottom right, rgba(33, 150, 243, 0.9), rgba(33, 150, 243, 0.7))',\n      text: t('workflow.task.status.running'),\n    }\n  else if (status === 'F')\n    return {\n      color: 'error',\n      bgColor: 'linear-gradient(to bottom right, rgba(244, 67, 54, 0.9), rgba(244, 67, 54, 0.7))',\n      text: t('workflow.task.status.failed'),\n    }\n  else if (status === 'P')\n    return {\n      color: 'warning',\n      bgColor: 'linear-gradient(to bottom right, rgba(255, 152, 0, 0.9), rgba(255, 152, 0, 0.7))',\n      text: t('workflow.task.status.paused'),\n    }\n  else\n    return {\n      color: 'info',\n      bgColor: 'linear-gradient(to bottom right, rgba(33, 150, 243, 0.9), rgba(33, 150, 243, 0.7))',\n      text: t('workflow.task.status.waiting'),\n    }\n}\n\n// 计算当前动作占比\nconst resolveProgress = (item: Workflow) => {\n  const current_action_length = item.current_action?.split(',').length || 0\n  return item.actions?.length ? Math.round((current_action_length / (item.actions.length || 1)) * 100) : 0\n}\n</script>\n<template>\n  <div class=\"h-full\">\n    <VHover v-slot=\"hover\">\n      <VCard\n        v-bind=\"hover.props\"\n        class=\"mx-auto h-full\"\n        @click=\"handleFlow(workflow)\"\n        :ripple=\"false\"\n        :loading=\"loading\"\n        :class=\"{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }\"\n      >\n        <VCardItem\n          class=\"px-2 py-2\"\n          :style=\"{\n            background: resolveStatusVariant(workflow?.state).bgColor,\n          }\"\n        >\n          <template #prepend>\n            <VAvatar variant=\"text\" size=\"small\">\n              <VIcon\n                v-if=\"workflow?.state === 'P'\"\n                color=\"success\"\n                icon=\"mdi-play\"\n                @click.stop=\"handleEnable(workflow)\"\n              />\n              <VIcon v-else color=\"warning\" icon=\"mdi-pause\" @click.stop=\"handlePause(workflow)\" />\n            </VAvatar>\n          </template>\n          <VCardTitle class=\"text-white text-lg\">\n            <span :title=\"workflow?.description\">{{ workflow?.name }}</span>\n          </VCardTitle>\n          <template #append>\n            <IconBtn>\n              <VIcon icon=\"mdi-dots-vertical\" />\n              <VMenu activator=\"parent\" close-on-content-click>\n                <VList>\n                  <VListItem base-color=\"primary\" @click=\"handleEdit(workflow)\">\n                    <template #prepend>\n                      <VIcon icon=\"mdi-note-edit\" />\n                    </template>\n                    <VListItemTitle>{{ t('workflow.task.edit') }}</VListItemTitle>\n                  </VListItem>\n                  <VListItem base-color=\"success\" @click=\"handleFlow(workflow)\">\n                    <template #prepend>\n                      <VIcon icon=\"mdi-vector-polyline\" />\n                    </template>\n                    <VListItemTitle>{{ t('workflow.task.editFlow') }}</VListItemTitle>\n                  </VListItem>\n                  <VListItem v-if=\"workflow.current_action\" base-color=\"info\" @click=\"handleRun(workflow, false)\">\n                    <template #prepend>\n                      <VIcon icon=\"mdi-play-speed\" />\n                    </template>\n                    <VListItemTitle>{{ t('workflow.task.continue') }}</VListItemTitle>\n                  </VListItem>\n                  <VListItem v-if=\"workflow.current_action\" base-color=\"info\" @click=\"handleRun(workflow, true)\">\n                    <template #prepend>\n                      <VIcon icon=\"mdi-replay\" />\n                    </template>\n                    <VListItemTitle>{{ t('workflow.task.restart') }}</VListItemTitle>\n                  </VListItem>\n                  <VListItem v-else base-color=\"info\" @click=\"handleRun(workflow, true)\">\n                    <template #prepend>\n                      <VIcon icon=\"mdi-run\" />\n                    </template>\n                    <VListItemTitle>{{ t('workflow.task.run') }}</VListItemTitle>\n                  </VListItem>\n                  <VListItem base-color=\"warning\" @click=\"handleReset(workflow)\">\n                    <template #prepend>\n                      <VIcon icon=\"mdi-restore-alert\" />\n                    </template>\n                    <VListItemTitle>{{ t('workflow.task.reset') }}</VListItemTitle>\n                  </VListItem>\n                  <VListItem base-color=\"info\" @click=\"handleShare(workflow)\">\n                    <template #prepend>\n                      <VIcon icon=\"mdi-share\" />\n                    </template>\n                    <VListItemTitle>{{ t('workflow.task.share') }}</VListItemTitle>\n                  </VListItem>\n                  <VListItem base-color=\"error\" @click=\"handleDelete(workflow)\">\n                    <template #prepend>\n                      <VIcon icon=\"mdi-delete\" />\n                    </template>\n                    <VListItemTitle>{{ t('workflow.task.delete') }}</VListItemTitle>\n                  </VListItem>\n                </VList>\n              </VMenu>\n            </IconBtn>\n          </template>\n        </VCardItem>\n        <VDivider />\n        <VCardText class=\"pa-3\">\n          <div class=\"d-flex flex-column gap-y-3\">\n            <div class=\"d-flex flex-wrap gap-x-3\">\n              <div class=\"flex-1\">\n                <div class=\"mb-1\">{{ t('workflow.task.info.trigger') }}</div>\n                <h5>\n                  <span v-if=\"workflow?.trigger_type === 'timer' || !workflow?.trigger_type\">\n                    <VIcon icon=\"mdi-clock-outline\" size=\"small\" class=\"me-1\" />\n                    {{ workflow?.timer }}\n                  </span>\n                  <span v-else-if=\"workflow?.trigger_type === 'event'\">\n                    <VIcon icon=\"mdi-calendar-check\" size=\"small\" class=\"me-1\" />\n                    {{ getEventTypeText(workflow?.event_type || '') }}\n                  </span>\n                  <span v-else-if=\"workflow?.trigger_type === 'manual'\">\n                    <VIcon icon=\"mdi-hand-pointing-up\" size=\"small\" class=\"me-1\" />\n                    {{ t('workflow.task.info.manualTrigger') }}\n                  </span>\n                </h5>\n              </div>\n              <div class=\"flex-1\">\n                <div class=\"mb-1\">{{ t('workflow.task.info.status') }}</div>\n                <h5 :class=\"`text-${resolveStatusVariant(workflow?.state).color}`\">\n                  {{ resolveStatusVariant(workflow?.state).text }}\n                </h5>\n              </div>\n            </div>\n            <div class=\"d-flex flex-wrap gap-x-3\">\n              <div class=\"flex-1\">\n                <div class=\"mb-1\">{{ t('workflow.task.info.actionCount') }}</div>\n                <div>\n                  <VAvatar size=\"24\" color=\"primary\" variant=\"tonal\">\n                    <span class=\"text-xs\">{{ workflow?.actions?.length }}</span>\n                  </VAvatar>\n                </div>\n              </div>\n              <div class=\"flex-1\">\n                <div class=\"mb-1\">{{ t('workflow.task.info.runCount') }}</div>\n                <h5>{{ workflow?.run_count }}</h5>\n              </div>\n            </div>\n            <div class=\"d-flex flex-wrap gap-x-3\">\n              <div class=\"flex-1\">\n                <div class=\"mb-1\">{{ t('workflow.task.info.progress') }}</div>\n                <div class=\"d-flex align-center gap-5\">\n                  <div class=\"flex-grow-1\">\n                    <VProgressLinear color=\"info\" rounded :model-value=\"resolveProgress(workflow)\" />\n                  </div>\n                  <span> {{ resolveProgress(workflow) }}% </span>\n                </div>\n              </div>\n            </div>\n            <div class=\"d-flex flex-wrap gap-x-3\" v-if=\"workflow?.result\">\n              <div class=\"flex-1\">\n                <div class=\"mb-1\">{{ t('workflow.task.info.error') }}</div>\n                <div class=\"text-error\">{{ workflow?.result }}</div>\n              </div>\n            </div>\n          </div>\n        </VCardText>\n      </VCard>\n    </VHover>\n    <!-- 流程对话框 -->\n    <WorkflowActionsDialog\n      v-if=\"flowDialog\"\n      v-model=\"flowDialog\"\n      @close=\"flowDialog = false\"\n      @save=\"editDone\"\n      :workflow=\"workflow\"\n    />\n    <!-- 编辑对话框 -->\n    <WorkflowAddEditDialog\n      v-if=\"editDialog\"\n      v-model=\"editDialog\"\n      @close=\"editDialog = false\"\n      @save=\"editDone\"\n      :workflow=\"workflow\"\n    />\n    <!-- 分享对话框 -->\n    <WorkflowShareDialog v-if=\"shareDialog\" v-model=\"shareDialog\" :workflow=\"workflow\" @close=\"shareDialog = false\" />\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/dialog/AboutDialog.vue",
    "content": "<script lang=\"ts\" setup>\nimport { formatDateDifference } from '@/@core/utils/formatters'\nimport api from '@/api'\nimport { clearCachesAndServiceWorker, reloadWithTimestamp } from '@/composables/useVersionChecker'\nimport MarkdownIt from 'markdown-it'\nimport mdLinkAttributes from 'markdown-it-link-attributes'\nimport { useI18n } from 'vue-i18n'\nimport { useDisplay } from 'vuetify'\n\n// 国际化\nconst { t } = useI18n()\n\n// APP版本\nconst appVersion = __APP_VERSION__\n\n// 定义事件\nconst emit = defineEmits(['close'])\n\n// 显示器\nconst display = useDisplay()\n\n// 初始化 markdown-it\nconst md = new MarkdownIt({\n  html: true,\n  linkify: true,\n  typographer: true,\n})\n\n// 插件：链接在新窗口打开\nmd.use(mdLinkAttributes, {\n  attrs: {\n    target: '_blank',\n    rel: 'noopener noreferrer',\n  },\n})\n\n// 系统环境变量\nconst systemEnv = ref<any>({})\n\n// 所有Release\nconst allRelease = ref<any>([])\n\n// 支持站点\nconst supportingSites = ref<any>({})\n\n// 支持站点折叠状态\nconst sitesExpanded = ref(false)\n\n// 去重后的支持站点\nconst uniqueSupportingSites = computed(() => {\n  const sitesMap = new Map()\n\n  Object.entries(supportingSites.value).forEach(([domain, site]: [string, any]) => {\n    if (!sitesMap.has(site.name)) {\n      sitesMap.set(site.name, {\n        name: site.name,\n        urls: [{ domain, url: site.url }],\n      })\n    } else {\n      sitesMap.get(site.name).urls.push({ domain, url: site.url })\n    }\n  })\n\n  return Array.from(sitesMap.values())\n})\n\n// 显示的支持站点（折叠时只显示前5个）\nconst displayedSites = computed(() => {\n  if (sitesExpanded.value) {\n    return uniqueSupportingSites.value\n  }\n  return uniqueSupportingSites.value.slice(0, 5)\n})\n\n// 变更日志对话框\nconst releaseDialog = ref(false)\n\n// 最新版本\nconst latestRelease = ref('')\n\n// 变更日志对话框标题\nconst releaseDialogTitle = ref('')\n\n// 变更日志对话框内容\nconst releaseDialogBody = ref('')\n\n// 打开日志对话框\nfunction showReleaseDialog(title: string, body: string) {\n  releaseDialogTitle.value = title\n  releaseDialogBody.value = body ? md.render(body) : ''\n  releaseDialog.value = true\n}\n\n// 查询系统环境变量\nasync function querySystemEnv() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/env')\n\n    systemEnv.value = result.data\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 查询所有Release\nasync function queryAllRelease() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/versions')\n\n    allRelease.value = result.data ?? []\n\n    // 最新版本\n    if (allRelease.value.length > 0) latestRelease.value = allRelease.value[0].tag_name\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 查询支持站点\nasync function querySupportingSites() {\n  try {\n    supportingSites.value = await api.get('site/supporting')\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 切换站点列表展开状态\nfunction toggleSitesExpanded() {\n  sitesExpanded.value = !sitesExpanded.value\n}\n\n// 计算发布时间\nfunction releaseTime(releaseDate: string) {\n  // 上一次更新时间\n  return formatDateDifference(releaseDate)\n}\n\n// 强制清除缓存\nasync function clearCache() {\n  await clearCachesAndServiceWorker()\n  // 刷新页面，添加时间戳参数以强制更新\n  reloadWithTimestamp()\n}\n\nonMounted(() => {\n  querySystemEnv()\n  queryAllRelease()\n  querySupportingSites()\n})\n</script>\n\n<template>\n  <VDialog max-width=\"50rem\" scrollable :fullscreen=\"!display.mdAndUp.value\">\n    <VCard>\n      <VCardItem>\n        <VCardTitle>\n          <VIcon icon=\"mdi-information\" class=\"me-2\" />\n          {{ t('setting.about.title') }}\n        </VCardTitle>\n        <VDialogCloseBtn @click=\"emit('close')\" />\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <div class=\"px-3\">\n          <div class=\"section\">\n            <div class=\"section border-gray-800\">\n              <dl>\n                <div>\n                  <div class=\"max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4\">\n                    <dt class=\"block text-sm font-bold\">{{ t('setting.about.softwareVersion') }}</dt>\n                    <dd class=\"flex text-sm sm:col-span-2 sm:mt-0\">\n                      <span class=\"flex-grow flex flex-row items-center truncate\">\n                        <code class=\"truncate\">{{ systemEnv.VERSION }}</code>\n                        <a\n                          v-if=\"latestRelease === systemEnv.VERSION\"\n                          href=\"https://github.com/jxxghp/MoviePilot/releases\"\n                          target=\"_blank\"\n                          rel=\"noopener noreferrer\"\n                        >\n                          <span\n                            class=\"px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap bg-green-500 bg-opacity-80 border border-green-500 !text-green-100 ml-2 !cursor-pointer transition hover:bg-green-400\"\n                          >\n                            {{ t('setting.about.latest') }}\n                          </span>\n                        </a>\n                      </span>\n                    </dd>\n                  </div>\n                </div>\n                <div v-if=\"systemEnv.FRONTEND_VERSION\">\n                  <div class=\"max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4\">\n                    <dt class=\"block text-sm font-bold\">{{ t('setting.about.frontendVersion') }}</dt>\n                    <dd class=\"flex text-sm sm:col-span-2 sm:mt-0\">\n                      <span class=\"flex-grow flex flex-row items-center truncate\">\n                        <code class=\"truncate\">{{ systemEnv.FRONTEND_VERSION }}</code>\n                      </span>\n                    </dd>\n                  </div>\n                </div>\n                <div>\n                  <div class=\"max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4\">\n                    <dt class=\"block text-sm font-bold\">{{ t('setting.about.browserVersion') }}</dt>\n                    <dd class=\"flex text-sm sm:col-span-2 sm:mt-0\">\n                      <span class=\"flex-grow flex flex-row items-center truncate\">\n                        <code class=\"truncate\">{{ appVersion }}</code>\n                        <VBtn\n                          size=\"x-small\"\n                          variant=\"tonal\"\n                          class=\"ms-2\"\n                          @click=\"clearCache\"\n                        >\n                          <template #prepend>\n                            <VIcon icon=\"mdi-refresh\" size=\"14\" />\n                          </template>\n                          {{ t('setting.about.clearCache') }}\n                        </VBtn>\n                      </span>\n                    </dd>\n                  </div>\n                </div>\n                <div>\n                  <div class=\"max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4\">\n                    <dt class=\"block text-sm font-bold\">{{ t('setting.about.authVersion') }}</dt>\n                    <dd class=\"flex text-sm sm:col-span-2 sm:mt-0\">\n                      <span class=\"flex-grow flex flex-row items-center truncate\">\n                        <code class=\"truncate\">{{ systemEnv.AUTH_VERSION }}</code>\n                      </span>\n                    </dd>\n                  </div>\n                </div>\n                <div>\n                  <div class=\"max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4\">\n                    <dt class=\"block text-sm font-bold\">{{ t('setting.about.indexerVersion') }}</dt>\n                    <dd class=\"flex text-sm sm:col-span-2 sm:mt-0\">\n                      <span class=\"flex-grow flex flex-row items-center truncate\">\n                        <code class=\"truncate\">{{ systemEnv.INDEXER_VERSION }}</code>\n                      </span>\n                    </dd>\n                  </div>\n                </div>\n                <div>\n                  <div class=\"max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4\">\n                    <dt class=\"block text-sm font-bold\">{{ t('setting.about.configDir') }}</dt>\n                    <dd class=\"flex text-sm sm:col-span-2 sm:mt-0\">\n                      <span class=\"flex-grow break-all\">\n                        <code>{{ systemEnv.CONFIG_DIR }}</code>\n                      </span>\n                    </dd>\n                  </div>\n                  <div class=\"max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4\">\n                    <dt class=\"block text-sm font-bold\">{{ t('setting.about.dataDir') }}</dt>\n                    <dd class=\"flex text-sm sm:col-span-2 sm:mt-0\">\n                      <span class=\"flex-grow break-all\"\n                        ><code>{{ t('setting.about.dataDirectory') }}</code></span\n                      >\n                    </dd>\n                  </div>\n                </div>\n                <div>\n                  <div class=\"max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4\">\n                    <dt class=\"block text-sm font-bold\">{{ t('setting.about.timezone') }}</dt>\n                    <dd class=\"flex text-sm sm:col-span-2 sm:mt-0\">\n                      <span class=\"flex-grow break-all\">\n                        <code>{{ systemEnv.TZ }}</code>\n                      </span>\n                    </dd>\n                  </div>\n                </div>\n                <div>\n                  <div class=\"max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4\">\n                    <dt class=\"block text-sm font-bold\">{{ t('setting.about.supportingSites') }}</dt>\n                    <dd class=\"flex text-sm sm:col-span-2 sm:mt-0\">\n                      <div class=\"flex flex-col gap-2\">\n                        <div class=\"flex flex-wrap gap-2 mt-1 ms-1\">\n                          <VChip v-for=\"site in displayedSites\" :key=\"site.name\" variant=\"outlined\" size=\"small\">\n                            <span class=\"truncate max-w-32\">{{ site.name }}</span>\n                          </VChip>\n                          <VChip\n                            v-if=\"!sitesExpanded && uniqueSupportingSites.length > 5\"\n                            variant=\"tonal\"\n                            size=\"small\"\n                            @click=\"toggleSitesExpanded\"\n                          >\n                            <span> {{ uniqueSupportingSites.length }}+ ...</span>\n                          </VChip>\n                          <VChip\n                            v-if=\"sitesExpanded && uniqueSupportingSites.length > 5\"\n                            variant=\"tonal\"\n                            size=\"small\"\n                            @click=\"toggleSitesExpanded\"\n                          >\n                            <span>< {{ t('setting.about.collapse') }}</span>\n                          </VChip>\n                        </div>\n                      </div>\n                    </dd>\n                  </div>\n                </div>\n              </dl>\n            </div>\n          </div>\n          <div class=\"section\">\n            <div>\n              <h3 class=\"heading\">{{ t('setting.about.support') }}</h3>\n            </div>\n            <div class=\"section border-t border-gray-800\">\n              <dl>\n                <div>\n                  <div class=\"max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4\">\n                    <dt class=\"block text-sm font-bold\">{{ t('setting.about.documentation') }}</dt>\n                    <dd class=\"flex text-sm sm:col-span-2 sm:mt-0\">\n                      <span class=\"flex-grow break-all\">\n                        <a\n                          href=\"https://movie-pilot.org\"\n                          target=\"_blank\"\n                          rel=\"noreferrer\"\n                          class=\"text-indigo-500 transition duration-300 hover:underline\"\n                        >\n                          https://movie-pilot.org\n                        </a>\n                      </span>\n                    </dd>\n                  </div>\n                </div>\n                <div>\n                  <div class=\"max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4\">\n                    <dt class=\"block text-sm font-bold\">{{ t('setting.about.feedback') }}</dt>\n                    <dd class=\"flex text-sm sm:col-span-2 sm:mt-0\">\n                      <span class=\"flex-grow break-all\">\n                        <a\n                          href=\"https://github.com/jxxghp/MoviePilot/issues/new/choose\"\n                          target=\"_blank\"\n                          rel=\"noreferrer\"\n                          class=\"text-indigo-500 transition duration-300 hover:underline\"\n                        >\n                          https://github.com/jxxghp/MoviePilot/issues/new/choose\n                        </a>\n                      </span>\n                    </dd>\n                  </div>\n                </div>\n                <div>\n                  <div class=\"max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4\">\n                    <dt class=\"block text-sm font-bold\">{{ t('setting.about.channel') }}</dt>\n                    <dd class=\"flex text-sm sm:col-span-2 sm:mt-0\">\n                      <span class=\"flex-grow break-all\">\n                        <a\n                          href=\"https://t.me/moviepilot_channel\"\n                          target=\"_blank\"\n                          rel=\"noreferrer\"\n                          class=\"text-indigo-500 transition duration-300 hover:underline\"\n                        >\n                          https://t.me/moviepilot_channel\n                        </a>\n                      </span>\n                    </dd>\n                  </div>\n                </div>\n              </dl>\n            </div>\n          </div>\n          <div class=\"section\">\n            <div>\n              <h3 class=\"heading\">{{ t('setting.about.versions') }}</h3>\n              <div class=\"section space-y-3\">\n                <div>\n                  <div\n                    v-for=\"release in allRelease\"\n                    :key=\"release.tag_name\"\n                    class=\"mb-3 flex w-full flex-col space-y-3 rounded-md px-4 py-2 ring-1 ring-gray-400 sm:flex-row sm:space-y-0 sm:space-x-3\"\n                  >\n                    <div class=\"flex w-full flex-grow items-center justify-start space-x-2 truncate sm:justify-start\">\n                      <span class=\"truncate text-lg font-bold\">\n                        <span class=\"mr-2 whitespace-nowrap text-xs font-normal\">{{\n                          releaseTime(release.published_at)\n                        }}</span>\n                        {{ release.tag_name }}\n                      </span>\n                      <span\n                        v-if=\"release.tag_name === latestRelease\"\n                        class=\"px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap cursor-default bg-green-500 bg-opacity-80 border border-green-500 !text-green-100\"\n                      >\n                        {{ t('setting.about.latestVersion') }}\n                      </span>\n                      <span\n                        v-if=\"release.tag_name === systemEnv.VERSION\"\n                        class=\"px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap cursor-default bg-indigo-500 bg-opacity-80 border border-indigo-500 !text-indigo-100\"\n                      >\n                        {{ t('setting.about.currentVersion') }}\n                      </span>\n                    </div>\n                    <VBtn @click.stop=\"showReleaseDialog(release.tag_name, release.body)\">\n                      <template #prepend>\n                        <VIcon icon=\"mdi-text-box-outline\" />\n                      </template>\n                      {{ t('setting.about.viewChangelog') }}\n                    </VBtn>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </VCardText>\n    </VCard>\n    <VDialog v-if=\"releaseDialog\" v-model=\"releaseDialog\" width=\"600\" scrollable>\n      <VCard>\n        <VCardItem>\n          <VDialogCloseBtn @click=\"releaseDialog = false\" />\n          <VCardTitle>{{ releaseDialogTitle }} {{ t('setting.about.changelog') }}</VCardTitle>\n        </VCardItem>\n        <VCardText class=\"markdown-body\" v-html=\"releaseDialogBody\" />\n      </VCard>\n    </VDialog>\n  </VDialog>\n</template>\n\n<style type=\"scss\" scoped>\n.heading {\n  font-size: 1.5rem;\n  font-weight: 700;\n  line-height: 2rem;\n\n  --tw-text-opacity: 1;\n}\n\n.section {\n  margin-block: 0.5rem 2.5rem;\n}\n\n.markdown-body :deep(h1),\n.markdown-body :deep(h2),\n.markdown-body :deep(h3) {\n  margin-block: 0.5rem;\n  font-weight: 600;\n}\n\n.markdown-body :deep(h1) {\n  font-size: 1.5rem;\n}\n\n.markdown-body :deep(h2) {\n  font-size: 1.25rem;\n}\n\n.markdown-body :deep(h3) {\n  font-size: 1.1rem;\n}\n\n.markdown-body :deep(ul),\n.markdown-body :deep(ol) {\n  padding-inline-start: 1.5rem;\n  margin-block: 0.5rem;\n}\n\n.markdown-body :deep(li) {\n  margin-block: 0.25rem;\n}\n\n.markdown-body :deep(p) {\n  margin-block: 0.5rem;\n}\n\n.markdown-body :deep(a) {\n  color: rgb(99 102 241);\n  text-decoration: none;\n}\n\n.markdown-body :deep(a:hover) {\n  text-decoration: underline;\n}\n\n.markdown-body :deep(code) {\n  padding: 0.15rem 0.4rem;\n  border-radius: 0.25rem;\n  font-size: 0.875em;\n  background-color: rgba(127, 127, 127, 0.15);\n}\n\n.markdown-body :deep(pre) {\n  padding: 0.75rem 1rem;\n  margin-block: 0.5rem;\n  overflow-x: auto;\n  border-radius: 0.375rem;\n  background-color: rgba(127, 127, 127, 0.15);\n}\n\n.markdown-body :deep(pre code) {\n  padding: 0;\n  background-color: transparent;\n}\n\n.markdown-body :deep(blockquote) {\n  padding-inline-start: 1rem;\n  margin-block: 0.5rem;\n  border-inline-start: 3px solid rgba(127, 127, 127, 0.4);\n  color: rgba(127, 127, 127, 0.8);\n}\n\n.markdown-body :deep(hr) {\n  margin-block: 1rem;\n  border: none;\n  border-block-start: 1px solid rgba(127, 127, 127, 0.3);\n}\n\n.markdown-body :deep(table) {\n  width: 100%;\n  margin-block: 0.5rem;\n  border-collapse: collapse;\n}\n\n.markdown-body :deep(th),\n.markdown-body :deep(td) {\n  padding: 0.4rem 0.75rem;\n  border: 1px solid rgba(127, 127, 127, 0.3);\n}\n\n.markdown-body :deep(th) {\n  font-weight: 600;\n  background-color: rgba(127, 127, 127, 0.1);\n}\n\n.markdown-body :deep(img) {\n  max-width: 100%;\n  height: auto;\n}\n</style>\n"
  },
  {
    "path": "src/components/dialog/AddDownloadDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport { useToast } from 'vue-toastification'\nimport api from '@/api'\nimport { doneNProgress, startNProgress } from '@/api/nprogress'\nimport type { DownloaderConf, MediaInfo, TorrentInfo, TransferDirectoryConf } from '@/api/types'\nimport { formatFileSize } from '@/@core/utils/formatters'\nimport { VCardTitle, VChip } from 'vuetify/lib/components/index.mjs'\nimport { useI18n } from 'vue-i18n'\nimport MediaIdSelector from '../misc/MediaIdSelector.vue'\nimport { numberValidator } from '@/@validators'\nimport { useGlobalSettingsStore } from '@/stores'\n\n// 多语言支持\nconst { t } = useI18n()\n\n// 从 provide 中获取全局设置\nconst globalSettingsStore = useGlobalSettingsStore()\nconst globalSettings = globalSettingsStore.globalSettings\n\n// 当前识别类型\nconst mediaSource = ref(globalSettings.RECOGNIZE_SOURCE || 'themoviedb')\n\n// 输入参数\nconst props = defineProps({\n  title: String,\n  media: Object as PropType<MediaInfo>,\n  torrent: Object as PropType<TorrentInfo>,\n})\n\n// 定义成功和失败事件\nconst emit = defineEmits(['done', 'error', 'close'])\n\n// 提示框\nconst $toast = useToast()\n\n// 选择的下载器\nconst selectedDownloader = ref<string | null>(null)\n\n// 选择的保存目录\nconst selectedDirectory = ref<string | null>(null)\n\n// 下载器\nconst downloaders = ref<DownloaderConf[]>([])\n\n// 所有目录设置\nconst directories = ref<TransferDirectoryConf[]>([])\n\n// 是否正在加载\nconst loading = ref(false)\n\n// 是否显示高级选项\nconst showAdvancedOptions = ref(false)\n\n// TMDB ID\nconst tmdbid = ref<number | undefined>(undefined)\n\n// 豆瓣ID\nconst doubanId = ref<string | undefined>(undefined)\n\n// TMDB选择对话框\nconst mediaSelectorDialog = ref(false)\n\n// 计算按钮图标\nconst icon = computed(() => (loading.value ? 'mdi-progress-download' : 'mdi-download'))\n\n// 计算按钮文字\nconst buttonText = computed(() =>\n  loading.value ? t('dialog.addDownload.downloading') : t('dialog.addDownload.startDownload'),\n)\n\n// 加载目录设置\nasync function loadDirectories() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/Directories')\n    directories.value = result.data?.value ?? []\n  } catch (error) {\n    console.log(error)\n  }\n}\n\nfunction convertToUri(item: TransferDirectoryConf) {\n  if (!item.download_path) {\n    return undefined\n  }\n  if (item.storage === 'local') {\n    return item.download_path\n  }\n  return item.storage + ':' + item.download_path\n}\n\n// 获取保存目录\nconst targetDirectories = computed(() => {\n  const downloadDirectories = directories.value\n    .map(item => convertToUri(item))\n    .filter((item): item is string => item !== undefined)\n  return [...new Set(downloadDirectories)]\n})\n\n// 调用API查询下载器设置\nasync function loadDownloaderSetting() {\n  try {\n    downloaders.value = await api.get('download/clients')\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 下载器可选项\nconst downloaderOptions = computed(() => {\n  return downloaders.value.map(item => ({\n    title: item.name,\n    value: item.name,\n  }))\n})\n\n// 添加下载\nasync function addDownload() {\n  startNProgress()\n  loading.value = true\n  try {\n    let result: { [key: string]: any }\n\n    const payload: any = {\n      torrent_in: props.torrent,\n      downloader: selectedDownloader.value,\n      save_path: selectedDirectory.value,\n    }\n\n    if (props.media) {\n      payload.media_in = props.media\n    }\n\n    // 添加媒体ID辅助识别\n    if (tmdbid.value) {\n      payload.tmdbid = tmdbid.value\n    }\n    if (doubanId.value) {\n      payload.doubanid = doubanId.value\n    }\n\n    const endpoint = props.media ? 'download/' : 'download/add'\n\n    result = await api.post(endpoint, payload)\n\n    if (result && result.success) {\n      // 添加下载成功\n      $toast.success(\n        t('dialog.addDownload.downloadSuccess', { site: props.torrent?.site_name, title: props.torrent?.title }),\n      )\n      // 下载成功，返回链接\n      emit('done', props.torrent?.enclosure)\n    } else {\n      // 添加下载失败\n      $toast.error(\n        t('dialog.addDownload.downloadFailed', {\n          site: props.torrent?.site_name,\n          title: props.torrent?.title,\n          message: result?.message,\n        }),\n      )\n      // 下载失败，返回错误原因\n      emit('error', result?.message)\n    }\n  } catch (error) {\n    console.error(error)\n  }\n  loading.value = false\n  doneNProgress()\n}\n\nonMounted(() => {\n  loadDirectories()\n  loadDownloaderSetting()\n})\n</script>\n<template>\n  <VDialog max-width=\"35rem\" scrollable>\n    <VCard>\n      <VCardItem class=\"py-2\">\n        <template #prepend>\n          <VIcon icon=\"mdi-monitor-arrow-down-variant\" class=\"me-2\" />\n        </template>\n        <VCardTitle>{{ t('dialog.addDownload.confirmDownload') }}</VCardTitle>\n        <VCardSubtitle>{{ torrent?.site_name }} - {{ title }}</VCardSubtitle>\n      </VCardItem>\n      <VDialogCloseBtn @click=\"emit('close')\" />\n      <VDivider />\n      <VCardText>\n        <VList lines=\"one\">\n          <VListItem>\n            <template #prepend>\n              <VIcon icon=\"mdi-web\"></VIcon>\n            </template>\n            <VListItemTitle>\n              <span class=\"whitespace-break-spaces me-2\">{{ torrent?.title }}</span>\n              <span class=\"text-green-700 ms-2 text-sm\">↑{{ torrent?.seeders }}</span>\n              <span class=\"text-orange-700 ms-2 text-sm\">↓{{ torrent?.peers }}</span>\n            </VListItemTitle>\n          </VListItem>\n          <VListItem v-if=\"torrent?.description\">\n            <template #prepend>\n              <VIcon icon=\"mdi-subtitles-outline\"></VIcon>\n            </template>\n            <VListItemTitle>\n              <span class=\"text-body-2 whitespace-break-spaces\">{{ torrent?.description }}</span>\n            </VListItemTitle>\n          </VListItem>\n          <VListItem v-if=\"torrent?.size\">\n            <template #prepend>\n              <VIcon icon=\"mdi-database\"></VIcon>\n            </template>\n            <VListItemTitle>\n              <span class=\"text-body-2\">\n                <VChip variant=\"tonal\" label>\n                  {{ formatFileSize(torrent?.size || 0) }}\n                </VChip>\n              </span>\n            </VListItemTitle>\n          </VListItem>\n        </VList>\n        <VRow class=\"px-5\">\n          <VCol cols=\"12\" md=\"6\">\n            <VSelect\n              v-model=\"selectedDownloader\"\n              :items=\"downloaderOptions\"\n              :label=\"t('dialog.addDownload.downloader')\"\n              variant=\"underlined\"\n              :placeholder=\"t('dialog.addDownload.defaultPlaceholder')\"\n              density=\"comfortable\"\n              prepend-inner-icon=\"mdi-download\"\n            />\n          </VCol>\n          <VCol cols=\"12\" md=\"6\">\n            <VCombobox\n              v-model=\"selectedDirectory\"\n              :items=\"targetDirectories\"\n              :label=\"t('dialog.addDownload.saveDirectory')\"\n              :placeholder=\"t('dialog.addDownload.autoPlaceholder')\"\n              variant=\"underlined\"\n              density=\"comfortable\"\n              prepend-inner-icon=\"mdi-folder\"\n            />\n          </VCol>\n        </VRow>\n        <VRow class=\"px-5 mt-2\">\n          <VCol cols=\"12\">\n            <VBtn\n              variant=\"text\"\n              :prepend-icon=\"showAdvancedOptions ? 'mdi-chevron-up' : 'mdi-chevron-down'\"\n              @click=\"showAdvancedOptions = !showAdvancedOptions\"\n            >\n              {{\n                showAdvancedOptions\n                  ? t('dialog.addDownload.hideAdvancedOptions')\n                  : t('dialog.addDownload.showAdvancedOptions')\n              }}\n            </VBtn>\n          </VCol>\n        </VRow>\n        <VRow v-show=\"showAdvancedOptions\" class=\"px-5\">\n          <VCol cols=\"12\">\n            <VTextField\n              v-if=\"mediaSource === 'themoviedb'\"\n              v-model=\"tmdbid\"\n              :label=\"t('dialog.reorganize.tmdbId')\"\n              :placeholder=\"t('dialog.reorganize.mediaIdPlaceholder')\"\n              :rules=\"[numberValidator]\"\n              append-inner-icon=\"mdi-magnify\"\n              :hint=\"t('dialog.reorganize.mediaIdHint')\"\n              persistent-hint\n              prepend-inner-icon=\"mdi-identifier\"\n              variant=\"underlined\"\n              density=\"comfortable\"\n              @click:append-inner=\"mediaSelectorDialog = true\"\n            />\n            <VTextField\n              v-else\n              v-model=\"doubanId\"\n              :label=\"t('dialog.reorganize.doubanId')\"\n              :placeholder=\"t('dialog.reorganize.mediaIdPlaceholder')\"\n              :rules=\"[numberValidator]\"\n              append-inner-icon=\"mdi-magnify\"\n              :hint=\"t('dialog.reorganize.mediaIdHint')\"\n              persistent-hint\n              prepend-inner-icon=\"mdi-identifier\"\n              variant=\"underlined\"\n              density=\"comfortable\"\n              @click:append-inner=\"mediaSelectorDialog = true\"\n            />\n          </VCol>\n        </VRow>\n      </VCardText>\n      <VCardText class=\"text-center\">\n        <VBtn variant=\"elevated\" :disabled=\"loading\" @click=\"addDownload\" :prepend-icon=\"icon\" class=\"px-5\">\n          {{ buttonText }}\n        </VBtn>\n      </VCardText>\n    </VCard>\n    <!-- 媒体ID选择器 -->\n    <VDialog v-model=\"mediaSelectorDialog\" width=\"40rem\" scrollable max-height=\"85vh\">\n      <MediaIdSelector\n        v-if=\"mediaSource === 'themoviedb'\"\n        v-model=\"tmdbid\"\n        @close=\"mediaSelectorDialog = false\"\n        :type=\"mediaSource\"\n      />\n      <MediaIdSelector v-else v-model=\"doubanId\" @close=\"mediaSelectorDialog = false\" :type=\"mediaSource\" />\n    </VDialog>\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/components/dialog/AlistConfigDialog.vue",
    "content": "<script lang=\"ts\" setup>\nimport api from '@/api'\nimport { useI18n } from 'vue-i18n'\nimport { useDisplay } from 'vuetify'\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 多语言支持\nconst { t } = useI18n()\n\n// 定义输入\nconst props = defineProps({\n  conf: {\n    type: Object as PropType<{ [key: string]: any }>,\n    required: true,\n  },\n})\n\n// 定义事件\nconst emit = defineEmits(['done', 'close'])\n\n// 完成\nasync function handleDone() {\n  await savaAlistConfig()\n  emit('done')\n}\n\n// 重置配置\nasync function handleReset() {\n  try {\n    const result: { [key: string]: any } = await api.get('/storage/reset/alist')\n    if (result.success) {\n      // 重置成功\n      handleDone()\n    }\n  } catch (e) {\n    console.error(e)\n  }\n}\n\n// 登录类型\nlet loginType = ref('username')\nif (props.conf.token) {\n  loginType = ref('token')\n} else if (props.conf.username) {\n  loginType = ref('username')\n} else {\n  loginType = ref('guest')\n}\n\n// 数据源\nconst sourceItems = [\n  {\n    'title': t('dialog.alistConfig.loginTypeOptions.username'),\n    'value': 'username',\n  },\n  { 'title': t('dialog.alistConfig.loginTypeOptions.token'), 'value': 'token' },\n  { 'title': t('dialog.alistConfig.loginTypeOptions.guest'), 'value': 'guest' },\n]\n\n// 保存alist设置\nasync function savaAlistConfig() {\n  try {\n    await api.post(`storage/save/alist`, props.conf)\n  } catch (e) {\n    console.error(e)\n  }\n}\n</script>\n\n<template>\n  <VDialog width=\"50rem\" scrollable :fullscreen=\"!display.mdAndUp.value\">\n    <VCard>\n      <VDialogCloseBtn @click=\"emit('close')\" />\n      <VCardItem>\n        <template #prepend>\n          <VIcon icon=\"mdi-cog-outline\" class=\"me-2\" />\n        </template>\n        <VCardTitle>\n          {{ t('dialog.alistConfig.title') }}\n        </VCardTitle>\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <VRow>\n          <VCol cols=\"12\">\n            <VTextField\n              v-model=\"props.conf.url\"\n              :hint=\"t('dialog.alistConfig.serverUrl')\"\n              :label=\"t('dialog.alistConfig.serverUrl')\"\n              persistent-hint\n              prepend-inner-icon=\"mdi-server\"\n            />\n          </VCol>\n          <VCol cols=\"12\" md=\"4\">\n            <VSelect\n              v-model=\"loginType\"\n              :items=\"sourceItems\"\n              :label=\"t('dialog.alistConfig.loginType')\"\n              :hint=\"t('dialog.alistConfig.loginType')\"\n              persistent-hint\n              prepend-inner-icon=\"mdi-login\"\n            />\n          </VCol>\n          <VCol cols=\"12\" md=\"4\" v-if=\"loginType == 'username'\">\n            <VTextField\n              v-model=\"props.conf.username\"\n              :hint=\"t('dialog.alistConfig.username')\"\n              :label=\"t('dialog.alistConfig.username')\"\n              persistent-hint\n              prepend-inner-icon=\"mdi-account\"\n            />\n          </VCol>\n          <VCol cols=\"12\" md=\"4\" v-if=\"loginType == 'username'\">\n            <VTextField\n              type=\"password\"\n              v-model=\"props.conf.password\"\n              :hint=\"t('dialog.alistConfig.password')\"\n              :label=\"t('dialog.alistConfig.password')\"\n              persistent-hint\n              prepend-inner-icon=\"mdi-lock\"\n            />\n          </VCol>\n          <VCol cols=\"12\" md=\"8\" v-if=\"loginType == 'token'\">\n            <VTextField\n              v-model=\"props.conf.token\"\n              :hint=\"t('dialog.alistConfig.loginTypeOptions.token')\"\n              :label=\"t('dialog.alistConfig.loginTypeOptions.token')\"\n              persistent-hint\n              prepend-inner-icon=\"mdi-key\"\n            />\n          </VCol>\n        </VRow>\n      </VCardText>\n      <VCardActions>\n        <VBtn color=\"error\" @click=\"handleReset\" prepend-icon=\"mdi-restore\" class=\"px-5 me-3\">\n          {{ t('dialog.alistConfig.reset') }}\n        </VBtn>\n        <VSpacer />\n        <VBtn @click=\"handleDone\" prepend-icon=\"mdi-check\" class=\"px-5 me-3\">\n          {{ t('dialog.alistConfig.complete') }}\n        </VBtn>\n      </VCardActions>\n    </VCard>\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/components/dialog/AliyunAuthDialog.vue",
    "content": "<script lang=\"ts\" setup>\nimport api from '@/api'\nimport { useI18n } from 'vue-i18n'\nimport { useDisplay } from 'vuetify'\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 多语言支持\nconst { t } = useI18n()\n\n// 定义输入\ndefineProps({\n  conf: {\n    type: Object as PropType<{ [key: string]: any }>,\n    required: true,\n  },\n})\n\n// 定义事件\nconst emit = defineEmits(['done', 'close'])\n\n// 二维码内容\nconst qrCodeUrl = ref('')\n\n// 下方的提示信息\nconst text = ref(t('dialog.aliyunAuth.scanQrCode'))\n\n// 提醒类型\nconst alertType = ref<'success' | 'info' | 'error' | 'warning' | undefined>('info')\n\n// timeout定时器\nlet timeoutTimer: NodeJS.Timeout | undefined = undefined\n\n// 完成\nasync function handleDone() {\n  clearTimeout(timeoutTimer)\n  emit('done')\n}\n\n// 调用/aliyun/qrcode api生成二维码\nasync function getQrcode() {\n  try {\n    const result: { [key: string]: any } = await api.get('/storage/qrcode/alipan')\n    if (result.success && result.data) {\n      qrCodeUrl.value = result.data.codeUrl\n      timeoutTimer = setTimeout(checkQrcode, 3000)\n    } else {\n      text.value = result.message\n    }\n  } catch (e) {\n    console.error(e)\n  }\n}\n\n// 调用/aliyun/check api验证二维码\nasync function checkQrcode() {\n  try {\n    const result: { [key: string]: any } = await api.get('/storage/check/alipan')\n    if (result.success && result.data) {\n      const qrCodeStatus = result.data.status\n      text.value = result.data.tip\n      if (qrCodeStatus == 'LoginSuccess') {\n        // 登录成功\n        alertType.value = 'success'\n        handleDone()\n      } else if (qrCodeStatus == 'WaitLogin' || qrCodeStatus == 'ScanSuccess') {\n        // 等待登录扫码成功\n        alertType.value = 'info'\n        clearTimeout(timeoutTimer)\n        timeoutTimer = setTimeout(checkQrcode, 3000)\n      } else {\n        // 二维码过期\n        alertType.value = 'error'\n      }\n    } else {\n      alertType.value = 'error'\n      text.value = result.message\n    }\n  } catch (e) {\n    console.error(e)\n  }\n}\n\n// 重置配置\nasync function handleReset() {\n  try {\n    const result: { [key: string]: any } = await api.get('/storage/reset/alipan')\n    console.log(result.success)\n    if (result.success) {\n      // 重置成功\n      alertType.value = 'success'\n      handleDone()\n    } else {\n      alertType.value = 'error'\n      text.value = result.message\n    }\n  } catch (e) {\n    console.error(e)\n  }\n}\n\nonMounted(async () => {\n  await getQrcode()\n})\n\nonUnmounted(() => {\n  if (timeoutTimer) clearTimeout(timeoutTimer)\n})\n</script>\n\n<template>\n  <VDialog width=\"40rem\" scrollable :fullscreen=\"!display.mdAndUp.value\">\n    <VCard>\n      <VDialogCloseBtn @click=\"emit('close')\" />\n      <VCardItem>\n        <template #prepend>\n          <VIcon icon=\"mdi-qrcode\" class=\"me-2\" />\n        </template>\n        <VCardTitle>\n          {{ t('dialog.aliyunAuth.loginTitle') }}\n        </VCardTitle>\n      </VCardItem>\n      <VDivider />\n      <VCardText class=\"pt-2 flex flex-col items-center justify-center\">\n        <div class=\"mt-6 rounded text-center p-3 border\">\n          <VImg class=\"mx-auto\" :src=\"qrCodeUrl\" width=\"200\" height=\"200\">\n            <template #placeholder>\n              <div class=\"w-full h-full\">\n                <VSkeletonLoader class=\"object-cover aspect-w-1 aspect-h-1\" />\n              </div>\n            </template>\n          </VImg>\n        </div>\n        <div>\n          <VAlert variant=\"tonal\" :type=\"alertType\" class=\"my-4 text-center\" :text=\"text\">\n            <template #prepend />\n          </VAlert>\n        </div>\n      </VCardText>\n      <VCardActions>\n        <VBtn color=\"error\" @click=\"handleReset\" prepend-icon=\"mdi-restore\" class=\"px-5 me-3\">\n          {{ t('dialog.aliyunAuth.reset') }}\n        </VBtn>\n        <VSpacer />\n        <VBtn @click=\"handleDone\" prepend-icon=\"mdi-check\" class=\"px-5 me-3\">\n          {{ t('dialog.aliyunAuth.complete') }}\n        </VBtn>\n      </VCardActions>\n    </VCard>\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/components/dialog/CategoryEditDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport draggable from 'vuedraggable'\nimport api from '@/api'\nimport type { CategoryConfig } from '@/api/types'\nimport { useToast } from 'vue-toastification'\nimport { useI18n } from 'vue-i18n'\nimport { useDisplay } from 'vuetify'\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 定义输入参数\ndefineProps<{\n  modelValue?: boolean\n}>()\n\n// 定义事件\nconst emit = defineEmits(['close', 'save'])\n\nconst activeTab = ref('movie')\nconst loading = ref(false)\nconst saving = ref(false)\nconst toast = useToast()\nconst { t } = useI18n()\n\nconst generateId = () => {\n  return 'id-' + Math.random().toString(36).substr(2, 9) + '-' + Date.now()\n}\n\ninterface CategoryItem {\n  id: string\n  name: string\n  rule: any\n}\n\nconst movieList = ref<CategoryItem[]>([])\nconst tvList = ref<CategoryItem[]>([])\n\n// TMDB 类型映射\nconst genreOptions = [\n  { title: '动作 (Action)', value: '28' },\n  { title: '冒险 (Adventure)', value: '12' },\n  { title: '动画 (Animation)', value: '16' },\n  { title: '喜剧 (Comedy)', value: '35' },\n  { title: '犯罪 (Crime)', value: '80' },\n  { title: '纪录 (Documentary)', value: '99' },\n  { title: '剧情 (Drama)', value: '18' },\n  { title: '家庭 (Family)', value: '10751' },\n  { title: '奇幻 (Fantasy)', value: '14' },\n  { title: '历史 (History)', value: '36' },\n  { title: '恐怖 (Horror)', value: '27' },\n  { title: '音乐 (Music)', value: '10402' },\n  { title: '悬疑 (Mystery)', value: '9648' },\n  { title: '爱情 (Romance)', value: '10749' },\n  { title: '科幻 (SF)', value: '878' },\n  { title: '电视电影', value: '10770' },\n  { title: '惊悚 (Thriller)', value: '53' },\n  { title: '战争 (War)', value: '10752' },\n  { title: '西部 (Western)', value: '37' },\n  { title: '儿童 (Kids)', value: '10762' },\n  { title: '新闻 (News)', value: '10763' },\n  { title: '真人秀 (Reality)', value: '10764' },\n  { title: '科幻/奇幻 (Sci-Fi)', value: '10765' },\n  { title: '肥皂剧 (Soap)', value: '10766' },\n  { title: '访谈 (Talk)', value: '10767' },\n  { title: '战争/政治', value: '10768' },\n]\n\n// 语种选项 (original_language)\nconst languageOptions = [\n  { title: '中文', value: 'zh' },\n  { title: '中文', value: 'cn' },\n  { title: '英语 (English)', value: 'en' },\n  { title: '日语 (Japanese)', value: 'ja' },\n  { title: '韩语 (Korean)', value: 'ko' },\n  { title: '法语 (French)', value: 'fr' },\n  { title: '德语 (German)', value: 'de' },\n  { title: '西班牙语 (Spanish)', value: 'es' },\n  { title: '意大利语 (Italian)', value: 'it' },\n  { title: '葡萄牙语 (Portuguese)', value: 'pt' },\n  { title: '俄语 (Russian)', value: 'ru' },\n  { title: '阿拉伯语', value: 'ar' },\n  { title: '泰语 (Thai)', value: 'th' },\n  { title: '越南语 (Vietnamese)', value: 'vi' },\n  { title: '印地语 (Hindi)', value: 'hi' },\n  { title: '土耳其语 (Turkish)', value: 'tr' },\n  { title: '荷兰语 (Dutch)', value: 'nl' },\n  { title: '波兰语 (Polish)', value: 'pl' },\n  { title: '瑞典语 (Swedish)', value: 'sv' },\n  { title: '丹麦语 (Danish)', value: 'da' },\n  { title: '挪威语 (Norwegian)', value: 'nb' },\n  { title: '芬兰语 (Finnish)', value: 'fi' },\n  { title: '希腊语 (Greek)', value: 'el' },\n  { title: '捷克语 (Czech)', value: 'cs' },\n  { title: '匈牙利语 (Hungarian)', value: 'hu' },\n  { title: '罗马尼亚语 (Romanian)', value: 'ro' },\n  { title: '乌克兰语 (Ukrainian)', value: 'uk' },\n  { title: '印度尼西亚语 (Indonesian)', value: 'id' },\n  { title: '马来语 (Malay)', value: 'ms' },\n  { title: '希伯来语 (Hebrew)', value: 'he' },\n]\n\n// 国家/地区选项 (origin_country/production_countries)\nconst countryOptions = [\n  { title: '中国大陆 (CN)', value: 'CN' },\n  { title: '中国香港 (HK)', value: 'HK' },\n  { title: '中国台湾 (TW)', value: 'TW' },\n  { title: '美国 (US)', value: 'US' },\n  { title: '英国 (GB)', value: 'GB' },\n  { title: '日本 (JP)', value: 'JP' },\n  { title: '韩国 (KR)', value: 'KR' },\n  { title: '法国 (FR)', value: 'FR' },\n  { title: '德国 (DE)', value: 'DE' },\n  { title: '意大利 (IT)', value: 'IT' },\n  { title: '西班牙 (ES)', value: 'ES' },\n  { title: '加拿大 (CA)', value: 'CA' },\n  { title: '澳大利亚 (AU)', value: 'AU' },\n  { title: '俄罗斯 (RU)', value: 'RU' },\n  { title: '印度 (IN)', value: 'IN' },\n  { title: '泰国 (TH)', value: 'TH' },\n  { title: '新加坡 (SG)', value: 'SG' },\n  { title: '马来西亚 (MY)', value: 'MY' },\n  { title: '越南 (VN)', value: 'VN' },\n  { title: '菲律宾 (PH)', value: 'PH' },\n  { title: '巴西 (BR)', value: 'BR' },\n  { title: '墨西哥 (MX)', value: 'MX' },\n  { title: '阿根廷 (AR)', value: 'AR' },\n  { title: '荷兰 (NL)', value: 'NL' },\n  { title: '比利时 (BE)', value: 'BE' },\n  { title: '瑞士 (CH)', value: 'CH' },\n  { title: '瑞典 (SE)', value: 'SE' },\n  { title: '挪威 (NO)', value: 'NO' },\n  { title: '丹麦 (DK)', value: 'DK' },\n  { title: '波兰 (PL)', value: 'PL' },\n  { title: '捷克 (CZ)', value: 'CZ' },\n  { title: '土耳其 (TR)', value: 'TR' },\n  { title: '以色列 (IL)', value: 'IL' },\n  { title: '埃及 (EG)', value: 'EG' },\n  { title: '南非 (ZA)', value: 'ZA' },\n  { title: '新西兰 (NZ)', value: 'NZ' },\n]\n\nconst fetchConfig = async () => {\n  loading.value = true\n  try {\n    const res: any = await api.get('media/category/config')\n    if (res && res.data) {\n      parseConfig(res.data)\n    }\n  } catch (e) {\n    console.error(e)\n    toast.error(t('setting.category.loadFailed'))\n  } finally {\n    loading.value = false\n  }\n}\n\nconst parseConfig = (data: CategoryConfig) => {\n  // 将对象 { \"Name\": { ... } } 转换为数组 [ { id: uuid, name: \"Name\", rule: { ... } } ]\n  movieList.value = []\n  if (data.movie) {\n    for (const [key, value] of Object.entries(data.movie)) {\n      // 为了UI一致性处理 genre_ids 为数组或字符串，但 API 发送的是字符串\n      const rule = { ...value }\n      if (rule.genre_ids && typeof rule.genre_ids === 'string') {\n        // UI 多选预期为数组，检查输入。实际上 VAutocomplete 多选预期数组。我们需要将字符串分割为数组。\n        // @ts-ignore\n        rule.genre_ids = rule.genre_ids.split(',')\n      } else {\n        // @ts-ignore\n        rule.genre_ids = []\n      }\n\n      // 处理语种\n      if (rule.original_language && typeof rule.original_language === 'string') {\n        // @ts-ignore\n        rule.original_language = rule.original_language.split(',')\n      } else {\n        // @ts-ignore\n        rule.original_language = []\n      }\n\n      // 处理制片国家/地区\n      if (rule.production_countries && typeof rule.production_countries === 'string') {\n        // @ts-ignore\n        rule.production_countries = rule.production_countries.split(',')\n      } else {\n        // @ts-ignore\n        rule.production_countries = []\n      }\n\n      movieList.value.push({\n        id: generateId(),\n        name: key,\n        rule: rule as any,\n      })\n    }\n  }\n\n  tvList.value = []\n  if (data.tv) {\n    for (const [key, value] of Object.entries(data.tv)) {\n      const rule = { ...value }\n      if (rule.genre_ids && typeof rule.genre_ids === 'string') {\n        // @ts-ignore\n        rule.genre_ids = rule.genre_ids.split(',')\n      } else {\n        // @ts-ignore\n        rule.genre_ids = []\n      }\n\n      // 处理语种\n      if (rule.original_language && typeof rule.original_language === 'string') {\n        // @ts-ignore\n        rule.original_language = rule.original_language.split(',')\n      } else {\n        // @ts-ignore\n        rule.original_language = []\n      }\n\n      // 处理发行国家/地区\n      if (rule.origin_country && typeof rule.origin_country === 'string') {\n        // @ts-ignore\n        rule.origin_country = rule.origin_country.split(',')\n      } else {\n        // @ts-ignore\n        rule.origin_country = []\n      }\n\n      tvList.value.push({\n        id: generateId(),\n        name: key,\n        rule: rule as any,\n      })\n    }\n  }\n}\n\nconst addMovieItem = () => {\n  movieList.value.push({\n    id: generateId(),\n    name: '新分类',\n    rule: { genre_ids: [] as any },\n  })\n}\n\nconst removeMovieItem = (index: number) => {\n  movieList.value.splice(index, 1)\n}\n\nconst addTvItem = () => {\n  tvList.value.push({\n    id: generateId(),\n    name: '新分类',\n    rule: { genre_ids: [] as any },\n  })\n}\n\nconst removeTvItem = (index: number) => {\n  tvList.value.splice(index, 1)\n}\n\nconst saveConfig = async () => {\n  saving.value = true\n  try {\n    // 将数组转换回对象\n    const payload: CategoryConfig = {\n      movie: {},\n      tv: {},\n    }\n\n    movieList.value.forEach(item => {\n      if (item.name) {\n        const rule = { ...item.rule }\n        // 将 genre_ids 数组转换回字符串\n        if (Array.isArray(rule.genre_ids) && rule.genre_ids.length > 0) {\n          rule.genre_ids = rule.genre_ids.join(',')\n        } else {\n          // @ts-ignore\n          rule.genre_ids = null\n        }\n\n        // 将 original_language 数组转换回字符串\n        if (Array.isArray(rule.original_language) && rule.original_language.length > 0) {\n          rule.original_language = rule.original_language.join(',')\n        } else {\n          rule.original_language = undefined\n        }\n\n        // 将 production_countries 数组转换回字符串\n        if (Array.isArray(rule.production_countries) && rule.production_countries.length > 0) {\n          rule.production_countries = rule.production_countries.join(',')\n        } else {\n          rule.production_countries = undefined\n        }\n\n        // 清理空字符串\n        if (!rule.release_year) rule.release_year = undefined\n\n        // @ts-ignore\n        payload.movie[item.name] = rule\n      }\n    })\n\n    tvList.value.forEach(item => {\n      if (item.name) {\n        const rule = { ...item.rule }\n        if (Array.isArray(rule.genre_ids) && rule.genre_ids.length > 0) {\n          rule.genre_ids = rule.genre_ids.join(',')\n        } else {\n          // @ts-ignore\n          rule.genre_ids = null\n        }\n\n        // 将 original_language 数组转换回字符串\n        if (Array.isArray(rule.original_language) && rule.original_language.length > 0) {\n          rule.original_language = rule.original_language.join(',')\n        } else {\n          rule.original_language = undefined\n        }\n\n        // 将 origin_country 数组转换回字符串\n        if (Array.isArray(rule.origin_country) && rule.origin_country.length > 0) {\n          rule.origin_country = rule.origin_country.join(',')\n        } else {\n          rule.origin_country = undefined\n        }\n\n        // 清理空字符串\n        if (!rule.release_year) rule.release_year = undefined\n\n        // @ts-ignore\n        payload.tv[item.name] = rule\n      }\n    })\n\n    const res: any = await api.post('media/category/config', payload)\n    if (res && res.success) {\n      toast.success(t('setting.category.saveSuccess'))\n      emit('save')\n      emit('close')\n    } else {\n      toast.error(t('setting.category.saveFailed', { message: res.message || 'Error' }))\n    }\n  } catch (e) {\n    console.error(e)\n    toast.error(t('setting.category.saveFailed', { message: 'Network or Config Error' }))\n  } finally {\n    saving.value = false\n  }\n}\n\nonMounted(() => {\n  fetchConfig()\n})\n</script>\n\n<template>\n  <VDialog :model-value=\"modelValue\" max-width=\"1000\" scrollable :fullscreen=\"!display.mdAndUp.value\">\n    <VCard>\n      <VDialogCloseBtn @click=\"emit('close')\" />\n      <VCardItem class=\"py-3\">\n        <template #prepend>\n          <VIcon icon=\"mdi-shape-outline\" class=\"me-2\" />\n        </template>\n        <VCardTitle>\n          {{ t('setting.category.title') }}\n        </VCardTitle>\n        <VCardSubtitle>\n          {{ t('setting.category.subtitle') }}\n        </VCardSubtitle>\n      </VCardItem>\n\n      <VCardText>\n        <VTabs v-model=\"activeTab\" show-arrows class=\"mb-4\">\n          <VTab value=\"movie\">\n            <VIcon icon=\"mdi-movie-outline\" class=\"me-2\" />\n            {{ t('setting.category.movie') }}\n          </VTab>\n          <VTab value=\"tv\">\n            <VIcon icon=\"mdi-television\" class=\"me-2\" />\n            {{ t('setting.category.tv') }}\n          </VTab>\n        </VTabs>\n\n        <div v-if=\"loading\" class=\"d-flex justify-center align-center\" style=\"min-height: 300px\">\n          <VProgressCircular indeterminate color=\"primary\" size=\"64\" />\n        </div>\n\n        <VWindow v-else v-model=\"activeTab\" class=\"disable-tab-transition\" :touch=\"false\">\n          <VWindowItem value=\"movie\">\n            <draggable v-model=\"movieList\" handle=\".drag-handle\" item-key=\"id\" animation=\"200\">\n              <template #item=\"{ element, index }\">\n                <VCard variant=\"tonal\" class=\"mb-4 category-item\">\n                  <VCardText class=\"pa-4\">\n                    <div class=\"d-flex align-center mb-5\">\n                      <VTextField\n                        v-model=\"element.name\"\n                        :label=\"t('setting.category.name')\"\n                        density=\"comfortable\"\n                        hide-details\n                        variant=\"plain\"\n                        class=\"font-bold\"\n                        prepend-inner-icon=\"mdi-tag-outline\"\n                      />\n                      <VSpacer />\n                      <VBtn\n                        icon=\"mdi-drag-vertical\"\n                        variant=\"text\"\n                        size=\"small\"\n                        class=\"drag-handle me-2\"\n                        color=\"primary\"\n                      />\n                      <VBtn\n                        icon=\"mdi-delete-outline\"\n                        color=\"error\"\n                        variant=\"text\"\n                        size=\"small\"\n                        @click=\"removeMovieItem(index)\"\n                      />\n                    </div>\n\n                    <VRow>\n                      <VCol cols=\"12\" md=\"6\">\n                        <VAutocomplete\n                          v-model=\"element.rule.genre_ids\"\n                          :items=\"genreOptions\"\n                          :label=\"t('setting.category.genre')\"\n                          item-title=\"title\"\n                          item-value=\"value\"\n                          multiple\n                          chips\n                          closable-chips\n                          density=\"comfortable\"\n                          variant=\"outlined\"\n                          persistent-hint\n                          prepend-inner-icon=\"mdi-movie-filter-outline\"\n                        />\n                      </VCol>\n                      <VCol cols=\"12\" md=\"6\">\n                        <VAutocomplete\n                          v-model=\"element.rule.production_countries\"\n                          :items=\"countryOptions\"\n                          :label=\"t('setting.category.country')\"\n                          item-title=\"title\"\n                          item-value=\"value\"\n                          multiple\n                          chips\n                          closable-chips\n                          density=\"comfortable\"\n                          variant=\"outlined\"\n                          persistent-hint\n                          prepend-inner-icon=\"mdi-earth\"\n                        />\n                      </VCol>\n                      <VCol cols=\"12\" md=\"6\">\n                        <VAutocomplete\n                          v-model=\"element.rule.original_language\"\n                          :items=\"languageOptions\"\n                          :label=\"t('setting.category.language')\"\n                          item-title=\"title\"\n                          item-value=\"value\"\n                          multiple\n                          chips\n                          closable-chips\n                          density=\"comfortable\"\n                          variant=\"outlined\"\n                          persistent-hint\n                          prepend-inner-icon=\"mdi-translate\"\n                        />\n                      </VCol>\n                      <VCol cols=\"12\" md=\"6\">\n                        <VTextField\n                          v-model=\"element.rule.release_year\"\n                          :label=\"t('setting.category.year')\"\n                          :placeholder=\"t('setting.category.yearPlaceholder')\"\n                          density=\"comfortable\"\n                          variant=\"outlined\"\n                          persistent-hint\n                          prepend-inner-icon=\"mdi-calendar-range\"\n                        />\n                      </VCol>\n                    </VRow>\n                  </VCardText>\n                </VCard>\n              </template>\n            </draggable>\n\n            <VBtn\n              block\n              variant=\"outlined\"\n              size=\"large\"\n              prepend-icon=\"mdi-plus-circle-outline\"\n              class=\"mt-2 add-category-btn\"\n              @click=\"addMovieItem\"\n            >\n              {{ t('setting.category.addMovie') }}\n            </VBtn>\n          </VWindowItem>\n\n          <VWindowItem value=\"tv\">\n            <draggable v-model=\"tvList\" handle=\".drag-handle\" item-key=\"id\" animation=\"200\">\n              <template #item=\"{ element, index }\">\n                <VCard variant=\"tonal\" class=\"mb-4 category-item\">\n                  <VCardText class=\"pa-4\">\n                    <div class=\"d-flex align-center mb-5\">\n                      <VTextField\n                        v-model=\"element.name\"\n                        :label=\"t('setting.category.name')\"\n                        density=\"comfortable\"\n                        hide-details\n                        variant=\"plain\"\n                        class=\"font-bold\"\n                        prepend-inner-icon=\"mdi-tag-outline\"\n                      />\n                      <VSpacer />\n                      <VBtn\n                        icon=\"mdi-drag-vertical\"\n                        variant=\"text\"\n                        size=\"small\"\n                        class=\"drag-handle me-2\"\n                        color=\"primary\"\n                      />\n                      <VBtn\n                        icon=\"mdi-delete-outline\"\n                        color=\"error\"\n                        variant=\"text\"\n                        size=\"small\"\n                        @click=\"removeTvItem(index)\"\n                      />\n                    </div>\n\n                    <VRow>\n                      <VCol cols=\"12\" md=\"6\">\n                        <VAutocomplete\n                          v-model=\"element.rule.genre_ids\"\n                          :items=\"genreOptions\"\n                          :label=\"t('setting.category.genre')\"\n                          item-title=\"title\"\n                          item-value=\"value\"\n                          multiple\n                          chips\n                          closable-chips\n                          density=\"comfortable\"\n                          variant=\"outlined\"\n                          persistent-hint\n                          prepend-inner-icon=\"mdi-movie-filter-outline\"\n                        />\n                      </VCol>\n                      <VCol cols=\"12\" md=\"6\">\n                        <VAutocomplete\n                          v-model=\"element.rule.origin_country\"\n                          :items=\"countryOptions\"\n                          :label=\"t('setting.category.country')\"\n                          item-title=\"title\"\n                          item-value=\"value\"\n                          multiple\n                          chips\n                          closable-chips\n                          density=\"comfortable\"\n                          variant=\"outlined\"\n                          persistent-hint\n                          prepend-inner-icon=\"mdi-earth\"\n                        />\n                      </VCol>\n                      <VCol cols=\"12\" md=\"6\">\n                        <VAutocomplete\n                          v-model=\"element.rule.original_language\"\n                          :items=\"languageOptions\"\n                          :label=\"t('setting.category.language')\"\n                          item-title=\"title\"\n                          item-value=\"value\"\n                          multiple\n                          chips\n                          closable-chips\n                          density=\"comfortable\"\n                          variant=\"outlined\"\n                          persistent-hint\n                          prepend-inner-icon=\"mdi-translate\"\n                        />\n                      </VCol>\n                      <VCol cols=\"12\" md=\"6\">\n                        <VTextField\n                          v-model=\"element.rule.release_year\"\n                          :label=\"t('setting.category.year')\"\n                          :placeholder=\"t('setting.category.yearPlaceholder')\"\n                          density=\"comfortable\"\n                          variant=\"outlined\"\n                          persistent-hint\n                          prepend-inner-icon=\"mdi-calendar-range\"\n                        />\n                      </VCol>\n                    </VRow>\n                  </VCardText>\n                </VCard>\n              </template>\n            </draggable>\n\n            <VBtn\n              block\n              variant=\"outlined\"\n              size=\"large\"\n              prepend-icon=\"mdi-plus-circle-outline\"\n              class=\"mt-2 add-category-btn\"\n              @click=\"addTvItem\"\n            >\n              {{ t('setting.category.addTv') }}\n            </VBtn>\n          </VWindowItem>\n        </VWindow>\n      </VCardText>\n\n      <VCardActions class=\"pt-3\">\n        <VSpacer />\n        <VBtn variant=\"text\" @click=\"emit('close')\">\n          {{ t('common.cancel') }}\n        </VBtn>\n        <VBtn color=\"primary\" :loading=\"saving\" prepend-icon=\"mdi-content-save\" class=\"px-5\" @click=\"saveConfig\">\n          {{ t('common.save') }}\n        </VBtn>\n      </VCardActions>\n    </VCard>\n  </VDialog>\n</template>\n\n<style scoped>\n.drag-handle {\n  cursor: grab;\n  opacity: 0.6;\n  transition: opacity 0.2s ease;\n}\n\n.drag-handle:hover {\n  opacity: 1;\n}\n\n.drag-handle:active {\n  cursor: grabbing;\n}\n\n.category-item {\n  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n  border: 1px solid transparent;\n}\n\n.category-item:hover {\n  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);\n}\n\n.add-category-btn {\n  border-style: dashed !important;\n  transition: all 0.2s ease;\n}\n\n.add-category-btn:hover {\n  border-style: solid !important;\n  transform: translateY(-1px);\n}\n\n.disable-tab-transition > * {\n  transition: none !important;\n}\n</style>\n"
  },
  {
    "path": "src/components/dialog/ForkSubscribeDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport api from '@/api'\nimport { doneNProgress, startNProgress } from '@/api/nprogress'\nimport { SubscribeShare } from '@/api/types'\nimport router from '@/router'\nimport { useToast } from 'vue-toastification'\nimport { VBtn } from 'vuetify/lib/components/index.mjs'\nimport { useI18n } from 'vue-i18n'\nimport { useGlobalSettingsStore } from '@/stores'\n\n// 国际化\nconst { t } = useI18n()\n\n// 输入参数\nconst props = defineProps({\n  media: Object as PropType<SubscribeShare>,\n})\n\n// 定义事件\nconst emit = defineEmits(['fork', 'delete', 'close'])\n\n// 从 provide 中获取全局设置\n// 全局设置\nconst globalSettingsStore = useGlobalSettingsStore()\nconst globalSettings = globalSettingsStore.globalSettings\n\n// 提示框\nconst $toast = useToast()\n\n// 处理中\nconst processing = ref(false)\n\n// 删除中\nconst deleting = ref(false)\n\n// 是否折叠\nconst isExpanded = ref(false)\n\n// follow用户列表\nconst followUsers = ref<string[]>([])\n\n// 当前用户是否已follow\nconst isFollowed = computed(() => followUsers.value.includes(props.media?.share_uid || ''))\n\n// 折叠展开\nfunction toggleExpand() {\n  isExpanded.value = !isExpanded.value\n}\n\n// 加载follow用户列表\nasync function queryFollowUsers() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/FollowSubscribers')\n    followUsers.value = result.data?.value ?? []\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// follow用户\nasync function followUser() {\n  try {\n    const result: { [key: string]: any } = await api.post(`subscribe/follow?share_uid=${props.media?.share_uid}`)\n    if (result.success) {\n      queryFollowUsers()\n    }\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// unfollow用户\nasync function unfollowUser() {\n  try {\n    const result: { [key: string]: any } = await api.delete('subscribe/follow', {\n      params: {\n        share_uid: props.media?.share_uid,\n      },\n    })\n    if (result.success) {\n      queryFollowUsers()\n    }\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 计算海报图片地址\nconst posterUrl = computed(() => {\n  const url = props.media?.poster\n  // 使用图片缓存\n  if (globalSettings.GLOBAL_IMAGE_CACHE && url)\n    return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`\n  return url\n})\n\n// 获得mediaid\nfunction getMediaId() {\n  if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`\n  else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`\n}\n\n// 查看媒体详情\nasync function viewMediaDetail() {\n  router.push({\n    path: '/media',\n    query: {\n      mediaid: getMediaId(),\n      title: props.media?.name,\n      year: props.media?.year,\n      type: props.media?.type,\n    },\n  })\n}\n\n// 复用订阅\nasync function doFork() {\n  // 开始处理\n  startNProgress()\n  try {\n    processing.value = true\n    // 请求API\n    const result: { [key: string]: any } = await api.post('subscribe/fork', props.media)\n    // 订阅状态\n    if (result.success) {\n      $toast.success(t('subscribe.addSuccess', { name: props.media?.share_title }))\n      // 完成\n      emit('fork', result.data.id)\n    } else {\n      $toast.error(t('subscribe.addFailed', { name: props.media?.share_title, message: result.message }))\n    }\n  } catch (error) {\n    console.error(error)\n  } finally {\n    processing.value = false\n    doneNProgress()\n  }\n}\n\n// 删除订阅分享\nasync function doDelete() {\n  // 开始处理\n  startNProgress()\n  try {\n    deleting.value = true\n    // 请求API\n    const result: { [key: string]: any } = await api.delete(`subscribe/share/${props.media?.id}`, {\n      params: {\n        share_uid: globalSettings.USER_UNIQUE_ID,\n      },\n    })\n    // 订阅状态\n    if (result.success) {\n      $toast.success(t('subscribe.cancelSuccess'))\n      // 完成\n      emit('delete', result.data.id)\n    } else {\n      $toast.error(t('subscribe.cancelFailed', { message: result.message }))\n    }\n  } catch (error) {\n    console.error(error)\n  } finally {\n    deleting.value = false\n    doneNProgress()\n  }\n}\n\nonMounted(() => {\n  queryFollowUsers()\n})\n</script>\n<template>\n  <VDialog max-width=\"40rem\" scrollable>\n    <VCard>\n      <VCardText>\n        <VCol>\n          <div class=\"d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row\">\n            <div class=\"ma-auto\">\n              <VImg\n                width=\"10rem\"\n                aspect-ratio=\"2/3\"\n                class=\"object-cover aspect-w-2 aspect-h-3 rounded-lg ring-1 ring-gray-500\"\n                :src=\"posterUrl\"\n                @click=\"viewMediaDetail\"\n                cover\n              >\n                <template #placeholder>\n                  <div class=\"w-full h-full\">\n                    <VSkeletonLoader class=\"object-cover aspect-w-2 aspect-h-3\" />\n                  </div>\n                </template>\n              </VImg>\n            </div>\n            <div class=\"flex-grow\">\n              <VCardItem>\n                <VCardTitle\n                  class=\"text-center text-md-left break-words whitespace-break-spaces line-clamp-2 overflow-hidden text-ellipsis\"\n                >\n                  {{ props.media?.share_title }}\n                </VCardTitle>\n                <VCardSubtitle\n                  class=\"text-center text-md-left break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis\"\n                >\n                  {{ props.media?.share_comment }}\n                </VCardSubtitle>\n                <VList lines=\"one\">\n                  <VListItem class=\"ps-0\">\n                    <VListItemTitle class=\"text-center text-md-left\">\n                      <span class=\"font-weight-medium\">{{ t('subscribe.sharer') }}：</span>\n                      <span class=\"text-body-1\"> {{ media?.share_user }}</span>\n                    </VListItemTitle>\n                  </VListItem>\n                  <VListItem class=\"ps-0\" v-if=\"media?.keyword\">\n                    <VListItemTitle class=\"text-center text-md-left\">\n                      <span class=\"font-weight-medium\">{{ t('subscribe.keyword') }}：</span>\n                      <span class=\"text-body-1\"> {{ media?.keyword }}</span>\n                    </VListItemTitle>\n                  </VListItem>\n                  <VListItem class=\"ps-0\" v-if=\"media?.custom_words\" @click.stop=\"toggleExpand\">\n                    <VListItemTitle\n                      class=\"text-center text-md-left break-words whitespace-break-spaces\"\n                      :class=\"{\n                        'line-clamp-4 overflow-hidden text-ellipsis': !isExpanded,\n                      }\"\n                    >\n                      <span class=\"font-weight-medium\">{{ t('subscribe.recognitionWords') }}：</span>\n                      <span class=\"text-body-1\"> {{ media?.custom_words }}</span>\n                    </VListItemTitle>\n                  </VListItem>\n                </VList>\n                <div class=\"text-center text-md-left\">\n                  <div>\n                    <VBtn\n                      color=\"primary\"\n                      :disabled=\"processing\"\n                      @click=\"doFork\"\n                      prepend-icon=\"mdi-heart\"\n                      :loading=\"processing\"\n                      class=\"mb-2 me-2\"\n                    >\n                      {{ t('subscribe.normalSub') }}\n                    </VBtn>\n                    <VBtn\n                      v-if=\"isFollowed && props.media?.share_uid\"\n                      color=\"warning\"\n                      @click=\"unfollowUser\"\n                      prepend-icon=\"mdi-account-remove\"\n                      class=\"mb-2 me-2\"\n                    >\n                      {{ t('subscribe.unfollow') }}\n                    </VBtn>\n                    <VBtn\n                      v-else-if=\"props.media?.share_uid\"\n                      @click=\"followUser\"\n                      color=\"info\"\n                      prepend-icon=\"mdi-account-plus\"\n                      class=\"mb-2 me-2\"\n                    >\n                      {{ t('subscribe.follow') }}\n                    </VBtn>\n                    <VBtn\n                      v-if=\"\n                        (props.media?.share_uid && props.media?.share_uid === globalSettings.USER_UNIQUE_ID) ||\n                        globalSettings.SUBSCRIBE_SHARE_MANAGE\n                      \"\n                      color=\"error\"\n                      :disabled=\"deleting\"\n                      @click=\"doDelete\"\n                      prepend-icon=\"mdi-delete\"\n                      :loading=\"deleting\"\n                      class=\"mb-2 me-2\"\n                    >\n                      {{ t('subscribe.cancelShare') }}\n                    </VBtn>\n                  </div>\n                  <div class=\"text-xs mt-2\" v-if=\"props.media?.count\">\n                    <VIcon icon=\"mdi-fire\" />{{\n                      t('subscribe.usageCount', { count: props.media?.count?.toLocaleString() })\n                    }}\n                  </div>\n                </div>\n              </VCardItem>\n            </div>\n          </div>\n        </VCol>\n      </VCardText>\n      <VDialogCloseBtn @click=\"emit('close')\" />\n    </VCard>\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/components/dialog/ForkWorkflowDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport api from '@/api'\nimport { doneNProgress, startNProgress } from '@/api/nprogress'\nimport { WorkflowShare } from '@/api/types'\nimport { useToast } from 'vue-toastification'\nimport { useI18n } from 'vue-i18n'\nimport { useGlobalSettingsStore } from '@/stores'\nimport { VueFlow, useVueFlow } from '@vue-flow/core'\n\n// 国际化\nconst { t } = useI18n()\n\n// 输入参数\nconst props = defineProps({\n  workflow: Object as PropType<WorkflowShare>,\n  eventTypes: {\n    type: Array as PropType<Array<{ title: string; value: string }>>,\n    default: () => [],\n  },\n})\n\n// 定义事件\nconst emit = defineEmits(['fork', 'delete', 'close'])\n\n// 从 provide 中获取全局设置\n// 全局设置\nconst globalSettingsStore = useGlobalSettingsStore()\nconst globalSettings = globalSettingsStore.globalSettings\n\n// 提示框\nconst $toast = useToast()\n\n// 处理中\nconst processing = ref(false)\n\n// 删除中\nconst deleting = ref(false)\n\n// 根据事件类型值获取显示文本\nconst getEventTypeText = (eventTypeValue: string) => {\n  const eventType = props.eventTypes.find(item => item.value === eventTypeValue)\n  return eventType ? eventType.title : eventTypeValue\n}\n\n// 流程图相关\nconst { nodes, edges } = useVueFlow()\n\n// 自定义节点类型\nconst nodeTypes: Record<string, any> = ref({})\n\n// 自动扫描目录下所有的 .vue 文件\nconst components = import.meta.glob('../workflow/*Action.vue')\n\n// 动态加载某个组件\nconst loadComponent = async (componentName: string) => {\n  const component = components[`../workflow/${componentName}.vue`]\n  if (component) {\n    return ((await component()) as any).default\n  }\n  throw new Error(t('dialog.workflowActions.componentNotFound', { component: componentName }))\n}\n\n// 将所有components中的组件加载到nodeTypes中\nfor (const path in components) {\n  const componentName = path.match(/\\.\\/workflow\\/(.*).vue$/)?.[1]\n  if (!componentName) {\n    continue\n  }\n  loadComponent(componentName).then(component => {\n    nodeTypes.value[componentName] = markRaw(component)\n  })\n}\n\n// 解析工作流数据\nconst parsedWorkflow = computed(() => {\n  if (!props.workflow) return null\n\n  try {\n    const workflow = { ...props.workflow }\n\n    // 解析actions\n    if (typeof workflow.actions === 'string') {\n      workflow.actions = JSON.parse(workflow.actions)\n    }\n\n    // 解析flows\n    if (typeof workflow.flows === 'string') {\n      workflow.flows = JSON.parse(workflow.flows)\n    }\n\n    return workflow\n  } catch (error) {\n    console.error('解析工作流数据失败:', error)\n    return props.workflow\n  }\n})\n\n// 初始化流程图数据\nonMounted(() => {\n  if (parsedWorkflow.value) {\n    nodes.value = parsedWorkflow.value.actions ?? []\n    edges.value = parsedWorkflow.value.flows ?? []\n  }\n})\n\n// 复用工作流\nasync function doFork() {\n  // 开始处理\n  startNProgress()\n  try {\n    processing.value = true\n    // 请求API\n    const result: { [key: string]: any } = await api.post('workflow/fork', props.workflow)\n    // 工作流状态\n    if (result.success) {\n      $toast.success(t('workflow.addSuccess', { name: props.workflow?.share_title }))\n      // 完成\n      emit('fork', result.data.id)\n    } else {\n      $toast.error(t('workflow.addFailed', { name: props.workflow?.share_title, message: result.message }))\n    }\n  } catch (error) {\n    console.error(error)\n  } finally {\n    processing.value = false\n    doneNProgress()\n  }\n}\n\n// 删除工作流分享\nasync function doDelete() {\n  // 开始处理\n  startNProgress()\n  try {\n    deleting.value = true\n    // 请求API\n    const result: { [key: string]: any } = await api.delete(`workflow/share/${props.workflow?.id}`, {\n      params: {\n        share_uid: globalSettings.USER_UNIQUE_ID,\n      },\n    })\n    // 工作流状态\n    if (result.success) {\n      $toast.success(t('workflow.cancelSuccess'))\n      // 完成\n      emit('delete', result.data.id)\n    } else {\n      $toast.error(t('workflow.cancelFailed', { message: result.message }))\n    }\n  } catch (error) {\n    console.error(error)\n  } finally {\n    deleting.value = false\n    doneNProgress()\n  }\n}\n</script>\n<template>\n  <VDialog max-width=\"40rem\" scrollable>\n    <VCard>\n      <VCardText>\n        <VCol>\n          <div class=\"d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row\">\n            <div class=\"ma-auto mt-5\">\n              <div class=\"workflow-preview\">\n                <VueFlow\n                  :nodes=\"nodes\"\n                  :edges=\"edges\"\n                  :nodeTypes=\"nodeTypes\"\n                  :default-edge-options=\"{ type: 'animation', animated: true }\"\n                  :delete-key-code=\"null\"\n                  :select-nodes-on-drag=\"false\"\n                  :nodes-draggable=\"false\"\n                  :nodes-connectable=\"false\"\n                  :fit-view=\"true\"\n                  :fit-view-options=\"{ padding: 0.1, minZoom: 0.2, maxZoom: 1 }\"\n                  :default-viewport=\"{ x: 0, y: 0, zoom: 0.2 }\"\n                  class=\"workflow-preview-flow\"\n                />\n              </div>\n            </div>\n\n            <!-- 右侧内容 -->\n            <div class=\"flex-grow\">\n              <VCardItem>\n                <VCardTitle\n                  class=\"text-center text-md-left break-words whitespace-break-spaces line-clamp-2 overflow-hidden text-ellipsis\"\n                >\n                  {{ props.workflow?.share_title }}\n                </VCardTitle>\n                <VCardSubtitle\n                  class=\"text-center text-md-left break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis\"\n                >\n                  {{ props.workflow?.share_comment }}\n                </VCardSubtitle>\n                <VList lines=\"one\">\n                  <VListItem class=\"ps-0\">\n                    <VListItemTitle class=\"text-center text-md-left\">\n                      <span class=\"font-weight-medium\">{{ t('workflow.sharer') }}：</span>\n                      <span class=\"text-body-1\"> {{ props.workflow?.share_user }}</span>\n                    </VListItemTitle>\n                  </VListItem>\n                  <VListItem class=\"ps-0\" v-if=\"props.workflow?.trigger_type || props.workflow?.timer\">\n                    <VListItemTitle class=\"text-center text-md-left\">\n                      <span class=\"font-weight-medium\">{{ t('workflow.trigger') }}：</span>\n                      <span class=\"text-body-1\">\n                        <span v-if=\"props.workflow?.trigger_type === 'timer' || !props.workflow?.trigger_type\">\n                          <VIcon icon=\"mdi-clock-outline\" size=\"small\" class=\"me-1\" />\n                          {{ props.workflow?.timer }}\n                        </span>\n                        <span v-else-if=\"props.workflow?.trigger_type === 'event'\">\n                          <VIcon icon=\"mdi-calendar-check\" size=\"small\" class=\"me-1\" />\n                          {{ getEventTypeText(props.workflow?.event_type || '') }}\n                        </span>\n                        <span v-else-if=\"props.workflow?.trigger_type === 'manual'\">\n                          <VIcon icon=\"mdi-hand-pointing-up\" size=\"small\" class=\"me-1\" />\n                          {{ t('workflow.manualTrigger') }}\n                        </span>\n                      </span>\n                    </VListItemTitle>\n                  </VListItem>\n                  <VListItem class=\"ps-0\" v-if=\"parsedWorkflow?.actions\">\n                    <VListItemTitle class=\"text-center text-md-left\">\n                      <span class=\"font-weight-medium\">{{ t('workflow.actionCount') }}：</span>\n                      <span class=\"text-body-1\"> {{ parsedWorkflow?.actions?.length }}</span>\n                    </VListItemTitle>\n                  </VListItem>\n                </VList>\n                <div class=\"text-center text-md-left\">\n                  <div>\n                    <VBtn\n                      color=\"primary\"\n                      :disabled=\"processing\"\n                      @click=\"doFork\"\n                      prepend-icon=\"mdi-heart\"\n                      :loading=\"processing\"\n                      class=\"mb-2 me-2\"\n                    >\n                      {{ t('workflow.normalFork') }}\n                    </VBtn>\n                    <VBtn\n                      v-if=\"\n                        (props.workflow?.share_uid && props.workflow?.share_uid === globalSettings.USER_UNIQUE_ID) ||\n                        globalSettings.WORKFLOW_SHARE_MANAGE\n                      \"\n                      color=\"error\"\n                      :disabled=\"deleting\"\n                      @click=\"doDelete\"\n                      prepend-icon=\"mdi-delete\"\n                      :loading=\"deleting\"\n                      class=\"mb-2 me-2\"\n                    >\n                      {{ t('workflow.cancelShare') }}\n                    </VBtn>\n                  </div>\n                  <div class=\"text-xs mt-2\" v-if=\"props.workflow?.count\">\n                    <VIcon icon=\"mdi-fire\" />{{\n                      t('workflow.usageCount', { count: props.workflow?.count?.toLocaleString() })\n                    }}\n                  </div>\n                </div>\n              </VCardItem>\n            </div>\n          </div>\n        </VCol>\n      </VCardText>\n      <VDialogCloseBtn @click=\"emit('close')\" />\n    </VCard>\n  </VDialog>\n</template>\n\n<style lang=\"scss\">\n@import '@vue-flow/core/dist/style.css';\n@import '@vue-flow/core/dist/theme-default.css';\n@import '@vue-flow/minimap/dist/style.css';\n\n.workflow-preview {\n  position: relative;\n  overflow: hidden;\n  border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));\n  border-radius: 8px;\n  background-color: rgba(var(--v-theme-surface), 0.8);\n  block-size: 280px;\n  inline-size: 240px;\n}\n\n.workflow-preview-flow {\n  block-size: 100%;\n  inline-size: 100%;\n\n  .vue-flow__node {\n    border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));\n    border-radius: 8px;\n    font-size: 10px;\n\n    &:hover {\n      box-shadow: none;\n      transform: none;\n    }\n\n    &.selected {\n      box-shadow: none;\n    }\n  }\n\n  .vue-flow__edge-path,\n  .vue-flow__connection-path {\n    stroke-width: 2;\n  }\n\n  .vue-flow__handle {\n    border-radius: 2px;\n    block-size: 12px;\n    inline-size: 4px;\n  }\n\n  // 自定义动作连线样式\n  .vue-flow__edge.animation {\n    .vue-flow__edge-path {\n      stroke: rgb(var(--v-theme-primary));\n    }\n\n    &.selected {\n      .vue-flow__edge-path {\n        stroke: rgb(var(--v-theme-primary));\n        stroke-width: 3;\n      }\n    }\n  }\n}\n\n@media screen and (width <= 600px) {\n  .workflow-preview {\n    block-size: 240px;\n    inline-size: 240px;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/dialog/ImportCodeDialog.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useI18n } from 'vue-i18n'\n\n// 多语言支持\nconst { t } = useI18n()\n\n// 输入参数\nconst props = defineProps({\n  title: String,\n  dataType: String,\n})\n\n// 代码\nconst codeString = ref('')\n\n// 定义事件\nconst emit = defineEmits(['close', 'save'])\n\n// 导入\nfunction handleImport() {\n  emit('save', props.dataType, codeString)\n  emit('close')\n}\n</script>\n\n<template>\n  <VDialog width=\"40rem\" scrollable max-height=\"85vh\">\n    <VCard>\n      <VCardItem>\n        <template #prepend>\n          <VIcon icon=\"mdi-code-json\" class=\"me-2\" />\n        </template>\n        <VCardTitle>{{ props.title }}</VCardTitle>\n      </VCardItem>\n      <VDialogCloseBtn @click=\"emit('close')\" />\n      <VCardText class=\"pt-2\">\n        <VTextarea v-model=\"codeString\" prepend-inner-icon=\"mdi-code-json\" />\n      </VCardText>\n      <VCardActions>\n        <VSpacer />\n        <VBtn @click=\"handleImport\" prepend-icon=\"mdi-import\" class=\"px-5 me-3\">\n          {{ t('dialog.importCode.import') }}\n        </VBtn>\n      </VCardActions>\n    </VCard>\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/components/dialog/MediaInfoDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport { Context } from '@/api/types'\nimport MediaInfoCard from '../cards/MediaInfoCard.vue'\nimport { useI18n } from 'vue-i18n'\n\n// 多语言支持\nconst { t } = useI18n()\n\n// 输入参数\ndefineProps({\n  context: Object as PropType<Context>,\n})\n\n// 定义事件\nconst emit = defineEmits(['close'])\n</script>\n<template>\n  <VDialog max-width=\"50rem\">\n    <VCard>\n      <VDialogCloseBtn @click=\"emit('close')\" />\n      <VCardItem>\n        <MediaInfoCard :context=\"context\" />\n      </VCardItem>\n    </VCard>\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/components/dialog/OTPAuthDialog.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useToast } from 'vue-toastification'\nimport QRCode from 'qrcode'\nimport { useDisplay } from 'vuetify'\nimport { useI18n } from 'vue-i18n'\nimport api from '@/api'\nimport type { ApiResponse, PassKey } from '@/api/types'\nimport { useGlobalSettingsStore } from '@/stores'\n\ninterface Props {\n  modelValue: boolean\n  isOtp: boolean\n  passkeyList?: PassKey[]\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  passkeyList: () => [],\n})\n\nconst emit = defineEmits(['update:modelValue', 'update:isOtp', 'verifyPassword'])\n\nconst { t } = useI18n()\nconst display = useDisplay()\nconst $toast = useToast()\nconst globalSettingsStore = useGlobalSettingsStore()\n\n// 内部状态\nconst show = computed({\n  get: () => props.modelValue,\n  set: value => emit('update:modelValue', value),\n})\n\n// otp uri\nconst otpUri = ref('')\n\n// otp secret\nconst secret = ref('')\n\n// 确认双重验证密码\nconst otpPassword = ref('')\n\nconst allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)\n\n// 二维码图片 base64\nconst qrCodeImage = ref('')\n\n// 二维码信息\nconst qrCode = ref('')\n\n// 为当前用户获取Otp Uri\nasync function getOtpUri() {\n  // 如果已经启用OTP，只打开对话框，不生成新的二维码\n  if (props.isOtp) {\n    qrCode.value = '' // 清空二维码，这样对话框会显示清除界面\n    qrCodeImage.value = ''\n    return\n  }\n\n  // 未启用OTP，生成新的二维码\n  try {\n    const result = (await api.post('mfa/otp/generate')) as ApiResponse<{\n      uri: string\n      secret: string\n    }>\n    if (result.success) {\n      otpUri.value = result.data.uri\n      secret.value = result.data.secret\n      qrCode.value = result.data.uri\n      // 生成二维码图片\n      qrCodeImage.value = await QRCode.toDataURL(result.data.uri, {\n        width: 200,\n        margin: 1,\n      })\n    } else {\n      $toast.error(t('profile.otpGenerateFailed', { message: result.message }))\n    }\n  } catch (error) {\n    console.error(error)\n    $toast.error(t('profile.otpGenerateFailed', { message: error instanceof Error ? error.message : String(error) }))\n  }\n}\n\n// 启用Otp\nasync function judgeOtpPassword() {\n  if (!otpPassword.value) {\n    $toast.error(t('profile.otpCodeRequired'))\n    return\n  }\n  try {\n    const result = (await api.post('mfa/otp/verify', {\n      uri: otpUri.value,\n      otpPassword: otpPassword.value,\n    })) as ApiResponse\n\n    if (result.success) {\n      $toast.success(t('profile.otpEnableSuccess'))\n      show.value = false\n      emit('update:isOtp', true)\n    } else {\n      $toast.error(t('profile.otpEnableFailed', { message: result.message }))\n    }\n  } catch (error) {\n    console.error(error)\n    $toast.error(t('profile.otpEnableFailed', { message: error instanceof Error ? error.message : String(error) }))\n  }\n}\n\n// 关闭当前用户的双重验证\nfunction disableOtp() {\n  // 如果已绑定PassKey，不允许关闭OTP\n  if (props.passkeyList && props.passkeyList.length > 0 && !allowPasskeyWithoutOtp.value) {\n    $toast.error(t('profile.disableOtpWithPasskeyError'))\n    return\n  }\n\n  emit('verifyPassword', {\n    title: t('profile.disableTwoFactor'),\n    text: t('profile.confirmToDisableOtp'),\n    callback: async (password: string) => {\n      try {\n        const result = (await api.post('mfa/otp/disable', {\n          password,\n        })) as ApiResponse\n        if (result.success) {\n          emit('update:isOtp', false)\n          $toast.success(t('profile.otpDisableSuccess'))\n          show.value = false\n        } else {\n          $toast.error(t('profile.otpDisableFailed', { message: result.message }))\n        }\n      } catch (error) {\n        console.error(error)\n        $toast.error(t('profile.otpDisableFailed', { message: error instanceof Error ? error.message : String(error) }))\n      }\n    },\n  })\n}\n\n// 监听弹窗打开，自动获取 URI\nwatch(\n  () => props.modelValue,\n  val => {\n    if (val) {\n      getOtpUri()\n      otpPassword.value = ''\n    } else {\n      // 弹窗关闭时，清空数据\n      qrCodeImage.value = ''\n      qrCode.value = ''\n      otpUri.value = ''\n      secret.value = ''\n      otpPassword.value = ''\n    }\n  },\n)\n</script>\n\n<template>\n  <VDialog v-model=\"show\" max-width=\"45rem\" scrollable :fullscreen=\"!display.mdAndUp.value\">\n    <VCard>\n      <VCardItem>\n        <VCardTitle>\n          <VIcon icon=\"mdi-cellphone-key\" class=\"me-2\" />\n          {{ props.isOtp && !qrCode ? t('profile.authenticatorManagement') : t('profile.setupAuthenticator') }}\n        </VCardTitle>\n        <VDialogCloseBtn @click=\"show = false\" />\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <p class=\"mb-6\">\n          {{ t('profile.authenticatorAppDescription') }}\n        </p>\n        <!-- 如果已启用OTP，显示清除界面 -->\n        <template v-if=\"props.isOtp && !qrCode\">\n          <VAlert type=\"success\" variant=\"tonal\" class=\"mb-4\">\n            {{ t('profile.authenticatorEnabled') }}\n          </VAlert>\n          <p class=\"mb-6\">\n            {{ t('profile.clearAuthenticatorTip') }}\n          </p>\n          <div class=\"d-flex justify-end flex-wrap gap-4\">\n            <VBtn variant=\"outlined\" color=\"secondary\" @click=\"show = false\">\n              {{ t('common.cancel') }}\n            </VBtn>\n            <VBtn color=\"error\" @click=\"disableOtp\">\n              <template #prepend>\n                <VIcon icon=\"mdi-delete\" />\n              </template>\n              {{ t('profile.clearAuthenticator') }}\n            </VBtn>\n          </div>\n        </template>\n\n        <!-- 设置新的OTP -->\n        <template v-else>\n          <div class=\"my-6 rounded text-center p-3 border\" style=\"width: fit-content; margin: 0 auto\">\n            <VImg class=\"mx-auto\" :src=\"qrCodeImage\" width=\"200\" height=\"200\">\n              <template #placeholder>\n                <div class=\"w-full h-full\">\n                  <VSkeletonLoader class=\"object-cover aspect-w-1 aspect-h-1\" />\n                </div>\n              </template>\n            </VImg>\n          </div>\n          <VAlert :title=\"secret\" variant=\"tonal\" type=\"warning\" class=\"my-4\" :text=\"t('profile.secretKeyTip')\">\n            <template #prepend />\n          </VAlert>\n          <VForm @submit.prevent=\"judgeOtpPassword\">\n            <VTextField\n              v-model=\"otpPassword\"\n              type=\"text\"\n              inputmode=\"numeric\"\n              autocomplete=\"one-time-code\"\n              :label=\"t('profile.enterVerificationCode')\"\n              class=\"mb-8\"\n              variant=\"outlined\"\n              prepend-inner-icon=\"mdi-shield-key\"\n            />\n            <div class=\"d-flex justify-end flex-wrap gap-4\">\n              <VBtn variant=\"outlined\" color=\"secondary\" @click=\"show = false\">\n                {{ t('common.cancel') }}\n              </VBtn>\n              <VBtn type=\"submit\">\n                <template #prepend>\n                  <VIcon icon=\"mdi-check\" />\n                </template>\n                {{ t('common.confirm') }}\n              </VBtn>\n            </div>\n          </VForm>\n        </template>\n      </VCardText>\n    </VCard>\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/components/dialog/PasskeyDialog.vue",
    "content": "<script lang=\"ts\" setup>\nimport { bufferToBase64Url, base64UrlToUint8Array } from '@/@core/utils/navigator'\nimport { useToast } from 'vue-toastification'\nimport { useDisplay } from 'vuetify'\nimport { useI18n } from 'vue-i18n'\nimport { formatDateDifference } from '@core/utils/formatters'\nimport api from '@/api'\nimport type { ApiResponse, PassKey } from '@/api/types'\nimport { useGlobalSettingsStore } from '@/stores'\n\ninterface Props {\n  modelValue: boolean\n  isOtp: boolean\n}\n\n// WebAuthn 相关接口定义\ninterface PublicKeyCredentialDescriptorJSON {\n  id: string\n  type: 'public-key'\n  transports?: AuthenticatorTransport[]\n}\n\nconst props = defineProps<Props>()\n\nconst emit = defineEmits(['update:modelValue', 'update:passkeyList', 'verifyPassword'])\n\nconst { t, locale } = useI18n()\nconst display = useDisplay()\nconst $toast = useToast()\nconst globalSettingsStore = useGlobalSettingsStore()\n\n// 内部状态\nconst show = computed({\n  get: () => props.modelValue,\n  set: value => emit('update:modelValue', value),\n})\n\n// PassKey列表\nconst passkeyList = ref<PassKey[]>([])\n\n// PassKey注册loading\nconst passkeyRegistering = ref(false)\n\n// PassKey名称\nconst passkeyName = ref('')\n\n// PassKey challenge\nconst passkeyChallenge = ref('')\n\nconst allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)\nconst canRegisterPasskey = computed(() => props.isOtp || allowPasskeyWithoutOtp.value)\n\n// 格式化日期\nfunction formatDate(dateStr: string) {\n  return new Date(dateStr).toLocaleDateString(locale.value)\n}\n\n// 获取PassKey列表\nasync function fetchPassKeyList() {\n  try {\n    const result = (await api.get('mfa/passkey/list')) as ApiResponse<PassKey[]>\n    if (result.success) {\n      passkeyList.value = result.data || []\n      emit('update:passkeyList', passkeyList.value)\n    }\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 注册PassKey\nasync function registerPassKey() {\n  if (!passkeyName.value) {\n    $toast.error(t('profile.passkeyNameRequired'))\n    return\n  }\n\n  // 检查浏览器环境\n  if (!window.PublicKeyCredential) {\n    if (!window.isSecureContext) {\n      $toast.error(t('login.passkeySecureContextRequired'))\n    } else {\n      $toast.error(t('login.passkeyNotSupported'))\n    }\n    return\n  }\n\n  passkeyRegistering.value = true\n  try {\n    // 1. 开始注册\n    const startResult = (await api.post('mfa/passkey/register/start', {\n      name: passkeyName.value,\n    })) as ApiResponse<{ options: string; challenge: string }>\n\n    if (!startResult.success) {\n      $toast.error(startResult.message || t('profile.passkeyRegisterFailed'))\n      return\n    }\n\n    const { options, challenge } = startResult.data\n    const publicKeyOptions = JSON.parse(options)\n    passkeyChallenge.value = challenge\n\n    // 2. 调用WebAuthn API\n    const credential = (await navigator.credentials.create({\n      publicKey: {\n        ...publicKeyOptions,\n        challenge: base64UrlToUint8Array(publicKeyOptions.challenge),\n        user: {\n          ...publicKeyOptions.user,\n          id: base64UrlToUint8Array(publicKeyOptions.user.id),\n        },\n        excludeCredentials: publicKeyOptions.excludeCredentials?.map((cred: PublicKeyCredentialDescriptorJSON) => ({\n          ...cred,\n          id: base64UrlToUint8Array(cred.id),\n        })),\n      },\n    })) as PublicKeyCredential\n\n    if (!credential) {\n      $toast.error(t('profile.passkeyRegisterCancelled'))\n      return\n    }\n\n    // 3. 转换credential为可传输格式\n    const response = credential.response as AuthenticatorAttestationResponse\n    const credentialJSON = {\n      id: credential.id,\n      rawId: bufferToBase64Url(credential.rawId),\n      type: credential.type,\n      response: {\n        attestationObject: bufferToBase64Url(response.attestationObject),\n        clientDataJSON: bufferToBase64Url(response.clientDataJSON),\n        transports: typeof response.getTransports === 'function' ? response.getTransports() : [],\n      },\n    }\n\n    // 4. 完成注册\n    const finishResult = (await api.post('mfa/passkey/register/finish', {\n      credential: credentialJSON,\n      challenge: passkeyChallenge.value,\n      name: passkeyName.value,\n    })) as ApiResponse\n\n    if (finishResult.success) {\n      $toast.success(t('profile.passkeyRegisterSuccess'))\n      passkeyName.value = ''\n      await fetchPassKeyList()\n    } else {\n      $toast.error(finishResult.message || t('profile.passkeyRegisterFailed'))\n    }\n  } catch (error: any) {\n    console.error('PassKey注册失败:', error)\n    if (error.name === 'NotAllowedError') {\n      $toast.error(t('profile.passkeyRegisterCancelled'))\n    } else if (error.name === 'NotSupportedError') {\n      $toast.error(t('login.passkeyNotSupported'))\n    } else if (error.message?.includes('start failed')) {\n      $toast.error(t('login.passkeyLoginStartFailed'))\n    } else if (error.response) {\n      $toast.error(error.response.data?.detail || t('profile.passkeyRegisterFailed'))\n    } else {\n      $toast.error(error.message || t('profile.passkeyRegisterFailed'))\n    }\n  } finally {\n    passkeyRegistering.value = false\n  }\n}\n\n// 删除PassKey\nasync function deletePassKey(passkeyId: number) {\n  emit('verifyPassword', {\n    title: t('profile.deletePasskey'),\n    text: t('profile.confirmToDeletePasskey'),\n    callback: async (password: string) => {\n      try {\n        const result = (await api.post('mfa/passkey/delete', {\n          passkey_id: passkeyId,\n          password,\n        })) as ApiResponse\n        if (result.success) {\n          $toast.success(t('profile.passkeyDeleteSuccess'))\n          await fetchPassKeyList()\n        } else {\n          $toast.error(result.message || t('profile.passkeyDeleteFailed'))\n        }\n      } catch (error) {\n        console.error(error)\n        $toast.error(t('profile.passkeyDeleteFailed'))\n      }\n    },\n  })\n}\n\n// 监听弹窗打开，自动加载列表\nwatch(\n  () => props.modelValue,\n  val => {\n    if (val) {\n      fetchPassKeyList()\n      passkeyName.value = ''\n    } else {\n      // 弹窗关闭时，清空数据\n      passkeyName.value = ''\n      passkeyChallenge.value = ''\n      passkeyList.value = []\n    }\n  },\n)\n</script>\n\n<template>\n  <VDialog v-model=\"show\" max-width=\"45rem\" scrollable :fullscreen=\"!display.mdAndUp.value\">\n    <VCard>\n      <VCardItem>\n        <VCardTitle>\n          <VIcon icon=\"material-symbols:passkey\" class=\"me-2\" />\n          {{ t('profile.passkeyManagement') }}\n        </VCardTitle>\n        <VDialogCloseBtn @click=\"show = false\" />\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <p class=\"mb-6\">\n          {{ t('profile.passkeyAppDescription') }}\n        </p>\n\n        <!-- 安全警告 -->\n        <VAlert type=\"warning\" variant=\"tonal\" class=\"mb-6\" icon=\"mdi-alert\">\n          <i18n-t keypath=\"profile.passkeyDomainWarning\" tag=\"span\">\n            <template #domain>\n              <b>{{ t('profile.accessDomain') }}</b>\n            </template>\n          </i18n-t>\n        </VAlert>\n\n        <!-- 注册新通行密钥 -->\n        <VCard v-if=\"canRegisterPasskey\" variant=\"tonal\" class=\"mb-6\">\n          <VCardText>\n            <h5 class=\"text-h5 font-weight-medium mb-2\">{{ t('profile.registerNewPasskey') }}</h5>\n            <p class=\"mb-4\">{{ t('profile.passkeyDescription') }}</p>\n            <VForm @submit.prevent=\"registerPassKey\">\n              <VTextField\n                v-model=\"passkeyName\"\n                :label=\"t('profile.passkeyName')\"\n                :placeholder=\"t('profile.passkeyNamePlaceholder')\"\n                class=\"mb-4\"\n                variant=\"outlined\"\n                prepend-inner-icon=\"mdi-form-textbox\"\n              />\n              <VBtn color=\"primary\" type=\"submit\" :loading=\"passkeyRegistering\" prepend-icon=\"mdi-plus\">\n                {{ t('profile.registerPasskey') }}\n              </VBtn>\n            </VForm>\n          </VCardText>\n        </VCard>\n\n        <!-- 未启用 OTP 提示 -->\n        <VAlert v-else type=\"error\" variant=\"tonal\" class=\"mb-6\" icon=\"mdi-shield-lock\">\n          <i18n-t keypath=\"profile.otpRequiredForPasskey\" tag=\"span\">\n            <template #otp>\n              <b>{{ t('profile.otpAuthenticator') }}</b>\n            </template>\n          </i18n-t>\n        </VAlert>\n\n        <!-- 已注册的通行密钥列表 -->\n        <div v-if=\"passkeyList.length > 0\" class=\"mt-6 px-4\">\n          <div\n            v-for=\"passkey in passkeyList\"\n            :key=\"passkey.id\"\n            class=\"py-4 d-flex align-center justify-space-between border-b last:border-0\"\n          >\n            <div>\n              <div class=\"text-body-1 font-weight-bold mb-1\">{{ passkey.name }}</div>\n              <div class=\"text-caption text-disabled d-flex flex-wrap gap-x-3\">\n                <span>{{ t('profile.createdAt') }} {{ formatDate(passkey.created_at) }}</span>\n                <span v-if=\"passkey.last_used_at\">\n                  {{ t('profile.lastUsedAt') }} {{ formatDateDifference(passkey.last_used_at) }}\n                </span>\n              </div>\n            </div>\n            <div>\n              <VBtn\n                variant=\"flat\"\n                color=\"error\"\n                size=\"small\"\n                class=\"rounded delete-btn\"\n                @click=\"deletePassKey(passkey.id)\"\n              >\n                <VIcon icon=\"mdi-trash-can-outline\" size=\"20\" />\n              </VBtn>\n            </div>\n          </div>\n        </div>\n        <VAlert v-else type=\"info\" variant=\"tonal\" class=\"mt-6\">\n          {{ t('profile.noPasskeys') }}\n        </VAlert>\n      </VCardText>\n\n      <VCardActions class=\"justify-end px-6 pb-4\">\n        <VBtn variant=\"outlined\" @click=\"show = false\">{{ t('common.close') }}</VBtn>\n      </VCardActions>\n    </VCard>\n  </VDialog>\n</template>\n\n<style scoped>\n.v-btn.delete-btn {\n  min-width: 45px;\n  padding: 0;\n  background-color: rgba(var(--v-theme-error), 0.1);\n  color: rgb(var(--v-theme-error));\n  transition: all 0.2s ease;\n}\n\n.v-btn.delete-btn:hover {\n  background-color: rgba(var(--v-theme-error), 0.2);\n  color: rgb(var(--v-theme-error));\n}\n</style>\n"
  },
  {
    "path": "src/components/dialog/PluginConfigDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport { useDisplay } from 'vuetify'\nimport type { Plugin } from '@/api/types'\nimport { isNullOrEmptyObject } from '@/@core/utils'\nimport api from '@/api'\nimport { useToast } from 'vue-toastification'\nimport FormRender from '../render/FormRender.vue'\nimport ProgressDialog from '../dialog/ProgressDialog.vue'\nimport { useI18n } from 'vue-i18n'\nimport { loadRemoteComponent } from '@/utils/federationLoader'\n\n// 国际化\nconst { t } = useI18n()\n\n// 输入参数\nconst props = defineProps({\n  plugin: {\n    type: Object as PropType<Plugin>,\n  },\n})\n\n// 定义事件\nconst emit = defineEmits(['close', 'save', 'switch'])\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 插件配置表单数据\nconst pluginConfigForm = ref({})\n\n// 插件表单配置项\nlet pluginFormItems = reactive([])\n\n// 进度框\nconst progressDialog = ref(false)\n\n// 进度文字\nconst progressText = ref('')\n\n// 提示框\nconst $toast = useToast()\n\n// 是否刷新\nconst isRefreshed = ref(false)\n\n// 渲染模式: 'vuetify' 或 'vue'\nconst renderMode = ref('vuetify')\n\n// Vue 模式：动态加载的组件\nconst dynamicComponent = defineAsyncComponent({\n  // 工厂函数\n  loader: async () => {\n    try {\n      if (!props.plugin?.id) {\n        throw new Error('插件ID不存在')\n      }\n\n      // 动态加载远程组件\n      const module = await loadRemoteComponent(props.plugin.id, 'Config')\n\n      // 直接返回加载的组件，无需再获取default\n      return module\n    } catch (error) {\n      console.error('加载远程组件失败:', error)\n    }\n  },\n  // 加载中显示的组件\n  loadingComponent: {\n    template: '<VSkeletonLoader type=\"card\"></VSkeletonLoader>',\n  },\n  // 添加错误处理\n  errorComponent: {\n    template: `\n      <div class=\"pa-4\">\n        <VAlert type=\"error\" title=\"组件加载错误\">\n          无法加载组件，请稍后再试\n        </VAlert>\n      </div>\n    `,\n  },\n  // 添加超时设置\n  timeout: 20000,\n})\n\n//调用API读取UI和配置数据\nasync function loadPluginUIData() {\n  // 重置\n  isRefreshed.value = false\n  pluginFormItems = []\n  pluginConfigForm.value = {}\n  renderMode.value = 'vuetify'\n\n  try {\n    // 获取UI定义\n    const result: { [key: string]: any } = await api.get(`plugin/form/${props.plugin?.id}`)\n    if (!result) {\n      console.error(`插件 ${props.plugin?.plugin_name} UI数据加载失败：无效的响应`)\n      return\n    }\n    renderMode.value = result.render_mode\n    if (renderMode.value === 'vue') {\n      // Vue模式下，初始配置在同一个API返回\n      if (!isNullOrEmptyObject(result.model)) {\n        pluginConfigForm.value = result.model\n      }\n    } else {\n      // Vuetify模式\n      pluginFormItems = result.conf || []\n      if (result.model) {\n        pluginConfigForm.value = result.model\n      }\n    }\n  } catch (error: any) {\n    console.error(error)\n  } finally {\n    isRefreshed.value = true\n  }\n}\n\n// 处理 Vue 组件触发的保存事件\nfunction handleVueComponentSave(newConfig: Record<string, any>) {\n  pluginConfigForm.value = newConfig\n  savePluginConf()\n}\n\n// 调用API保存配置数据\nasync function savePluginConf() {\n  // 显示等待提示框\n  progressDialog.value = true\n  progressText.value = t('dialog.pluginConfig.saving', { name: props.plugin?.plugin_name })\n  try {\n    const result: { [key: string]: any } = await api.put(`plugin/${props.plugin?.id}`, pluginConfigForm.value)\n    if (result.success) {\n      $toast.success(t('dialog.pluginConfig.saveSuccess', { name: props.plugin?.plugin_name }))\n      // 通知父组件刷新\n      emit('save')\n    } else {\n      $toast.error(t('dialog.pluginConfig.saveFailed', { name: props.plugin?.plugin_name, message: result.message }))\n    }\n  } catch (error) {\n    console.error(error)\n  }\n  progressDialog.value = false\n}\n\nonBeforeMount(async () => {\n  await loadPluginUIData()\n})\n</script>\n<template>\n  <VDialog scrollable max-width=\"60rem\" :fullscreen=\"!display.mdAndUp.value\">\n    <!-- Vuetify 渲染模式 -->\n    <VCard v-if=\"renderMode === 'vuetify'\" :title=\"`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`\">\n      <VDialogCloseBtn @click=\"emit('close')\" />\n      <VDivider />\n      <LoadingBanner v-if=\"!isRefreshed\" class=\"mt-5\" />\n      <VCardText v-else=\"isRefreshed\">\n        <div>\n          <FormRender v-for=\"(item, index) in pluginFormItems\" :key=\"index\" :config=\"item\" :model=\"pluginConfigForm\" />\n          <div v-if=\"!pluginFormItems || pluginFormItems.length === 0\">此插件没有可配置项</div>\n        </div>\n      </VCardText>\n      <VCardActions class=\"pt-3\">\n        <VBtn v-if=\"props.plugin?.has_page\" @click=\"emit('switch')\" color=\"info\">\n          {{ t('dialog.pluginConfig.viewData') }}\n        </VBtn>\n        <VSpacer />\n        <!-- 只有Vuetify模式显示默认保存按钮，Vue模式由组件内部控制 -->\n        <VBtn v-if=\"renderMode === 'vuetify'\" @click=\"savePluginConf\" prepend-icon=\"mdi-content-save\" class=\"px-5\">\n          保存\n        </VBtn>\n      </VCardActions>\n    </VCard>\n    <!-- Vue 渲染模式 -->\n    <VCard v-else-if=\"renderMode === 'vue'\">\n      <VCardText class=\"pa-0\">\n        <component\n          :is=\"dynamicComponent\"\n          :initial-config=\"pluginConfigForm\"\n          :api=\"api\"\n          @save=\"handleVueComponentSave\"\n          @switch=\"emit('switch')\"\n          @close=\"emit('close')\"\n        />\n      </VCardText>\n    </VCard>\n\n    <!-- 进度框 -->\n    <ProgressDialog v-if=\"progressDialog\" v-model=\"progressDialog\" :text=\"progressText\" />\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/components/dialog/PluginDataDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport { useDisplay } from 'vuetify'\nimport type { Plugin } from '@/api/types'\nimport PageRender from '@/components/render/PageRender.vue'\nimport api from '@/api'\nimport { loadRemoteComponent } from '@/utils/federationLoader'\nimport { usePWA } from '@/composables/usePWA'\n\n// 输入参数\nconst props = defineProps({\n  plugin: {\n    type: Object as PropType<Plugin>,\n  },\n  show_switch: {\n    type: Boolean,\n    default: true,\n  },\n})\n\n// 定义事件\nconst emit = defineEmits(['close', 'save', 'switch'])\n\n// 显示器宽度\nconst display = useDisplay()\n// APP\n// PWA模式检测\nconst { appMode } = usePWA()\n\n// 是否刷新\nconst isRefreshed = ref(false)\n// 组件是否已加载成功\nconst componentLoaded = ref(false)\n// 是否正在加载数据\nconst isLoading = ref(false)\n\n// 渲染模式: 'vuetify' 或 'vue'\nconst renderMode = ref('vuetify')\n\n// 插件数据页面配置项\nlet pluginPageItems = ref([])\n\n// Vue 模式：动态加载的组件\nconst dynamicComponent = defineAsyncComponent({\n  // 工厂函数\n  loader: async () => {\n    try {\n      if (!props.plugin?.id) {\n        throw new Error('插件ID不存在')\n      }\n\n      // 动态加载远程组件\n      const module = await loadRemoteComponent(props.plugin.id, 'Page')\n      componentLoaded.value = true\n      return module\n    } catch (error) {\n      console.error('加载远程组件失败:', error)\n      componentLoaded.value = false\n    }\n  },\n  // 加载中显示的组件\n  loadingComponent: {\n    template: '<VSkeletonLoader type=\"card\"></VSkeletonLoader>',\n  },\n  // 添加错误处理\n  errorComponent: {\n    template: `\n      <div class=\"pa-4\">\n        <VAlert type=\"error\" title=\"组件加载错误\">\n          无法加载组件，请稍后再试\n        </VAlert>\n      </div>\n    `,\n  },\n  // 添加超时设置\n  timeout: 20000,\n})\n\n// 调用API读取数据页面UI\nasync function loadPluginUIData() {\n  // 如果正在加载，则不重复加载\n  if (isLoading.value) return\n\n  isLoading.value = true\n  isRefreshed.value = false\n  pluginPageItems.value = []\n\n  try {\n    // 如果已经是vue模式且组件已加载成功，不需要再请求模式\n    if (renderMode.value === 'vue' && componentLoaded.value) {\n      isRefreshed.value = true\n      isLoading.value = false\n      return\n    }\n\n    const result: { [key: string]: any } = await api.get(`plugin/page/${props.plugin?.id}`)\n    if (!result || !result.render_mode) {\n      console.error(`插件 ${props.plugin?.plugin_name} UI数据加载失败：无效的响应`)\n      return\n    }\n    renderMode.value = result.render_mode\n    if (renderMode.value === 'vuetify') {\n      // Vuetify模式\n      pluginPageItems.value = result.page || []\n    }\n  } catch (error: any) {\n    console.error(error)\n  } finally {\n    isRefreshed.value = true\n    isLoading.value = false\n  }\n}\n\n// 重新加载数据（可由 PageRender 或 Vue component 触发）\nfunction handleAction(event: any) {\n  // 避免在组件已加载的情况下重复调用loadPluginUIData\n  if (renderMode.value === 'vue' && componentLoaded.value) {\n    return\n  }\n  loadPluginUIData()\n}\n\nonMounted(() => {\n  loadPluginUIData()\n})\n</script>\n<template>\n  <VDialog scrollable max-width=\"80rem\" :fullscreen=\"!display.mdAndUp.value\">\n    <!-- Vuetify 渲染模式 -->\n    <VCard v-if=\"renderMode === 'vuetify'\" :title=\"`${props.plugin?.plugin_name}`\">\n      <VDialogCloseBtn @click=\"emit('close')\" />\n      <LoadingBanner v-if=\"!isRefreshed\" class=\"mt-5\" />\n      <VCardText v-else class=\"min-h-40\">\n        <div>\n          <PageRender @action=\"handleAction\" v-for=\"(item, index) in pluginPageItems\" :key=\"index\" :config=\"item\" />\n          <div v-if=\"!pluginPageItems || pluginPageItems.length === 0\">此插件没有详情页面</div>\n        </div>\n      </VCardText>\n      <VFab\n        v-if=\"show_switch\"\n        icon=\"mdi-cog\"\n        location=\"bottom\"\n        size=\"x-large\"\n        fixed\n        app\n        appear\n        @click=\"emit('switch')\"\n        :class=\"{ 'mb-10': appMode }\"\n      />\n    </VCard>\n    <!-- Vue 渲染模式 -->\n    <VCard v-else-if=\"renderMode === 'vue'\">\n      <VCardText class=\"pa-0\">\n        <component\n          :is=\"dynamicComponent\"\n          :api=\"api\"\n          :show_switch=\"show_switch\"\n          @action=\"handleAction\"\n          @switch=\"emit('switch')\"\n          @close=\"emit('close')\"\n        />\n      </VCardText>\n    </VCard>\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/components/dialog/PluginMarketSettingDialog.vue",
    "content": "<script lang=\"ts\" setup>\nimport api from '@/api'\nimport draggable from 'vuedraggable'\nimport { useToast } from 'vue-toastification'\nimport { useI18n } from 'vue-i18n'\nimport { useDisplay } from 'vuetify'\n\nconst display = useDisplay()\n\nconst { t } = useI18n()\nconst $toast = useToast()\n\nconst repoList = ref<string[]>([])\nconst newRepoUrl = ref('')\nconst editingIndex = ref<number | null>(null)\nconst editingUrl = ref('')\n\nconst emit = defineEmits(['save', 'close'])\n\nasync function queryMarketRepoSetting() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/PLUGIN_MARKET')\n    if (result && result.data && result.data.value) {\n      repoList.value = result.data.value.split(',').filter((repo: string) => repo.trim() !== '')\n    }\n  } catch (error) {\n    console.log(error)\n  }\n}\n\nasync function saveHandle() {\n  try {\n    const repoStringToSave = repoList.value.join(',')\n    const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET', repoStringToSave)\n\n    if (result.success) {\n      $toast.success(t('dialog.pluginMarketSetting.saveSuccess'))\n      emit('save')\n    } else $toast.error(t('dialog.pluginMarketSetting.saveFailed', { message: result?.message }))\n  } catch (error) {\n    console.log(error)\n  }\n}\n\nfunction addRepo() {\n  const url = newRepoUrl.value.trim()\n  if (!url) return\n\n  if (!url.startsWith('http://') && !url.startsWith('https://')) {\n    $toast.error(t('dialog.pluginMarketSetting.invalidUrl'))\n    return\n  }\n\n  if (repoList.value.includes(url)) {\n    $toast.error(t('dialog.pluginMarketSetting.duplicateUrl'))\n    return\n  }\n\n  repoList.value.push(url)\n  newRepoUrl.value = ''\n}\n\nfunction removeRepo(index: number) {\n  repoList.value.splice(index, 1)\n}\n\nfunction startEdit(index: number) {\n  editingIndex.value = index\n  editingUrl.value = repoList.value[index]\n}\n\nfunction saveEdit() {\n  if (editingIndex.value === null) return\n\n  const url = editingUrl.value.trim()\n  if (!url) return\n\n  if (!url.startsWith('http://') && !url.startsWith('https://')) {\n    $toast.error(t('dialog.pluginMarketSetting.invalidUrl'))\n    return\n  }\n\n  repoList.value[editingIndex.value] = url\n  editingIndex.value = null\n  editingUrl.value = ''\n}\n\nfunction cancelEdit() {\n  editingIndex.value = null\n  editingUrl.value = ''\n}\n\nfunction formatRepoDisplay(url: string) {\n  try {\n    const parsedUrl = new URL(url)\n    const pathSegments = parsedUrl.pathname.split('/').filter(Boolean)\n\n    if (\n      ['github.com', 'www.github.com', 'raw.githubusercontent.com'].includes(parsedUrl.hostname)\n      && pathSegments.length >= 2\n    ) {\n      return `${pathSegments[0]}/${pathSegments[1].replace(/\\.git$/, '')}`\n    }\n  } catch {\n    // Ignore malformed URLs and fall back to the original value.\n  }\n\n  return url\n}\n\nfunction repoItemKey(repo: string) {\n  return repo\n}\n\nonMounted(() => {\n  queryMarketRepoSetting()\n})\n</script>\n\n<template>\n  <VDialog width=\"50rem\" scrollable :fullscreen=\"!display.mdAndUp.value\">\n    <VCard class=\"plugin-market-dialog-card\">\n      <VCardItem>\n        <VCardTitle>\n          <VIcon icon=\"mdi-store-cog\" class=\"me-2\" />\n          {{ t('dialog.pluginMarketSetting.title') }}\n        </VCardTitle>\n        <VDialogCloseBtn @click=\"emit('close')\" />\n      </VCardItem>\n      <VDivider />\n      <VCardText class=\"plugin-market-dialog-body pt-4\">\n        <div class=\"plugin-market-input mb-4\">\n          <VTextField\n            v-model=\"newRepoUrl\"\n            density=\"compact\"\n            :placeholder=\"t('dialog.pluginMarketSetting.urlPlaceholder')\"\n            prepend-inner-icon=\"mdi-link-plus\"\n            clearable\n            @keyup.enter=\"addRepo\"\n          >\n            <template #append>\n              <VBtn icon=\"mdi-plus\" variant=\"text\" color=\"primary\" @click=\"addRepo\" />\n            </template>\n          </VTextField>\n        </div>\n\n        <div class=\"plugin-market-list-wrap\">\n          <VList v-if=\"repoList.length > 0\" class=\"px-0\">\n            <draggable\n              v-model=\"repoList\"\n              :item-key=\"repoItemKey\"\n              handle=\".drag-handle\"\n              animation=\"200\"\n              :disabled=\"editingIndex !== null\"\n            >\n              <template #item=\"{ element: repo, index }\">\n                <div>\n                  <VListItem class=\"py-2\">\n                    <template #prepend>\n                      <VBtn\n                        icon=\"mdi-drag-vertical\"\n                        size=\"small\"\n                        variant=\"text\"\n                        color=\"primary\"\n                        class=\"drag-handle me-2\"\n                        :disabled=\"editingIndex !== null\"\n                      />\n                    </template>\n\n                    <VListItemTitle v-if=\"editingIndex !== index\">\n                      <span class=\"text-truncate\" :title=\"repo\">{{ formatRepoDisplay(repo) }}</span>\n                    </VListItemTitle>\n\n                    <VTextField\n                      v-else\n                      v-model=\"editingUrl\"\n                      density=\"compact\"\n                      variant=\"outlined\"\n                      hide-details\n                      @keyup.enter=\"saveEdit\"\n                      @keyup.escape=\"cancelEdit\"\n                    />\n\n                    <template #append v-if=\"editingIndex !== index\">\n                      <div class=\"d-flex align-center\">\n                        <IconBtn icon=\"mdi-pencil\" size=\"small\" variant=\"text\" @click=\"startEdit(index)\" />\n                        <IconBtn\n                          icon=\"mdi-delete\"\n                          size=\"small\"\n                          variant=\"text\"\n                          color=\"error\"\n                          @click=\"removeRepo(index)\"\n                        />\n                      </div>\n                    </template>\n\n                    <template #append v-else>\n                      <div class=\"d-flex align-center\">\n                        <IconBtn icon=\"mdi-check\" size=\"small\" variant=\"text\" color=\"success\" @click=\"saveEdit\" />\n                        <IconBtn icon=\"mdi-close\" size=\"small\" variant=\"text\" @click=\"cancelEdit\" />\n                      </div>\n                    </template>\n                  </VListItem>\n                  <VDivider v-if=\"index < repoList.length - 1\" class=\"mx-4\" />\n                </div>\n              </template>\n            </draggable>\n          </VList>\n\n          <div v-else class=\"text-center text-medium-emphasis py-8\">\n            <VIcon icon=\"mdi-folder-open-outline\" size=\"48\" class=\"mb-2\" />\n            <div>{{ t('dialog.pluginMarketSetting.noRepos') }}</div>\n          </div>\n        </div>\n      </VCardText>\n      <VCardActions>\n        <VSpacer />\n        <VBtn\n          @click=\"saveHandle\"\n          prepend-icon=\"mdi-content-save-check\"\n          class=\"px-5 me-3\"\n          :disabled=\"repoList.length === 0\"\n        >\n          {{ t('dialog.pluginMarketSetting.save') }}\n        </VBtn>\n      </VCardActions>\n    </VCard>\n  </VDialog>\n</template>\n\n<style scoped lang=\"scss\">\n.plugin-market-dialog-card {\n  display: flex;\n  flex-direction: column;\n}\n\n.plugin-market-dialog-body {\n  display: flex;\n  overflow: hidden;\n  flex: 1;\n  flex-direction: column;\n  min-block-size: 0;\n}\n\n.plugin-market-input {\n  flex-shrink: 0;\n}\n\n.plugin-market-list-wrap {\n  flex: 1;\n  min-block-size: 0;\n  overflow-y: auto;\n}\n</style>\n"
  },
  {
    "path": "src/components/dialog/ProgressDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport { useI18n } from 'vue-i18n'\n\nconst { t } = useI18n()\n\nconst props = defineProps({\n  value: Number,\n  text: String,\n})\n</script>\n<template>\n  <!-- Progress Dialog -->\n  <VDialog :scrim=\"false\" width=\"25rem\">\n    <VCard elevation=\"3\" color=\"primary\">\n      <VCardText class=\"text-center\">\n        {{ props.text || t('dialog.progress.processing') }}\n        <VProgressLinear color=\"white\" class=\"mb-0 mt-1\" :model-value=\"props.value\" indeterminate />\n      </VCardText>\n    </VCard>\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/components/dialog/RcloneConfigDialog.vue",
    "content": "<script lang=\"ts\" setup>\nimport api from '@/api'\nimport { useI18n } from 'vue-i18n'\nimport { useDisplay } from 'vuetify'\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 多语言支持\nconst { t } = useI18n()\n\n// 定义输入\nconst props = defineProps({\n  conf: {\n    type: Object as PropType<{ [key: string]: any }>,\n    required: true,\n  },\n})\n\nif (!props.conf.filepath) {\n  props.conf.filepath = '/moviepilot/.config/rclone/rclone.conf'\n}\n\nif (!props.conf.content) {\n  props.conf.content = t('dialog.rcloneConfig.defaultContent')\n}\n\n// 定义事件\nconst emit = defineEmits(['done', 'close'])\n\n// 完成\nasync function handleDone() {\n  await savaRcloneConfig()\n  emit('done')\n}\n\n// 保存rclone设置\nasync function savaRcloneConfig() {\n  try {\n    await api.post(`storage/save/rclone`, props.conf)\n  } catch (e) {\n    console.error(e)\n  }\n}\n\n// 重置配置\nasync function handleReset() {\n  try {\n    const result: { [key: string]: any } = await api.get('/storage/reset/rclone')\n    if (result.success) {\n      handleDone()\n    }\n  } catch (e) {\n    console.error(e)\n  }\n}\n</script>\n\n<template>\n  <VDialog width=\"50rem\" scrollable :fullscreen=\"!display.mdAndUp.value\">\n    <VCard>\n      <VDialogCloseBtn @click=\"emit('close')\" />\n      <VCardItem>\n        <template #prepend>\n          <VIcon icon=\"mdi-cog-outline\" class=\"me-2\" />\n        </template>\n        <VCardTitle>\n          {{ t('dialog.rcloneConfig.title') }}\n        </VCardTitle>\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <VRow>\n          <VCol cols=\"12\">\n            <VTextField\n              v-model=\"props.conf.filepath\"\n              :label=\"t('dialog.rcloneConfig.filePath')\"\n              prepend-inner-icon=\"mdi-file-document\"\n            />\n          </VCol>\n          <VCol cols=\"12\">\n            <VAceEditor\n              v-model:value=\"props.conf.content\"\n              lang=\"ini\"\n              theme=\"monokai\"\n              class=\"rounded h-full min-h-[30rem]\"\n            >\n            </VAceEditor>\n          </VCol>\n        </VRow>\n      </VCardText>\n      <VCardActions>\n        <VBtn color=\"error\" @click=\"handleReset\" prepend-icon=\"mdi-restore\" class=\"px-5 me-3\">\n          {{ t('dialog.rcloneConfig.reset') }}\n        </VBtn>\n        <VSpacer />\n        <VBtn @click=\"handleDone\" prepend-icon=\"mdi-check\" class=\"px-5 me-3\">\n          {{ t('dialog.rcloneConfig.complete') }}\n        </VBtn>\n      </VCardActions>\n    </VCard>\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/components/dialog/ReorganizeDialog.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useToast } from 'vue-toastification'\nimport MediaIdSelector from '../misc/MediaIdSelector.vue'\nimport api from '@/api'\nimport { transferTypeOptions } from '@/api/constants'\nimport { numberValidator } from '@/@validators'\nimport { useDisplay } from 'vuetify'\nimport ProgressDialog from './ProgressDialog.vue'\nimport { FileItem, StorageConf, TransferDirectoryConf, TransferForm } from '@/api/types'\nimport { useI18n } from 'vue-i18n'\nimport { useGlobalSettingsStore } from '@/stores'\nimport { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'\nimport CryptoJS from 'crypto-js'\n\n// 国际化\nconst { t } = useI18n()\nconst { useProgressSSE } = useBackgroundOptimization()\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 输入参数\nconst props = defineProps({\n  logids: Array<number>,\n  items: Array<FileItem>,\n  target_storage: String,\n  target_path: String,\n})\n\n// 从 provide 中获取全局设置\n// 全局设置\nconst globalSettingsStore = useGlobalSettingsStore()\nconst globalSettings = globalSettingsStore.globalSettings\n\n// 当前识别类型\nconst mediaSource = ref(globalSettings.RECOGNIZE_SOURCE || 'themoviedb')\n\n// 定义事件\nconst emit = defineEmits(['done', 'close'])\n\n// 生成1到100季的下拉框选项\nconst seasonItems = ref(\n  Array.from({ length: 101 }, (_, i) => i).map(item => ({\n    title: `${t('dialog.subscribeEdit.seasonFormat', { number: item })}`,\n    value: item,\n  })),\n)\n\n// 提示框\nconst $toast = useToast()\n\n// TMDB选择对话框\nconst mediaSelectorDialog = ref(false)\n\n// 进度是否激活\nconst progressActive = ref(false)\n\n// 整理进度条\nconst progressDialog = ref(false)\n\n// 整理进度文本\nconst progressText = ref(t('dialog.reorganize.processing'))\n\n// 整理进度\nconst progressValue = ref(0)\n\n// 进度SSE连接\nconst progressSSE = ref<any>(null)\n\n// 所有存储\nconst storages = ref<StorageConf[]>([])\n\n// 查询存储\nasync function loadStorages() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/Storages')\n\n    storages.value = result.data?.value ?? []\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 存储字典\nconst storageOptions = computed(() => {\n  return storages.value.map(item => ({\n    title: item.name,\n    value: item.type,\n  }))\n})\n\n// 标题\nconst dialogTitle = computed(() => {\n  return t('dialog.reorganize.manualTitle')\n})\n\n// 副标题\nconst dialogSubtitle = computed(() => {\n  if (props.items) {\n    if (props.items.length > 1) return t('dialog.reorganize.multipleItemsTitle', { count: props.items.length })\n    return t('dialog.reorganize.singleItemTitle', { path: props.items[0].path })\n  } else if (props.logids) {\n    return t('dialog.reorganize.multipleItemsTitle', { count: props.logids.length })\n  }\n})\n// 禁用指定集数\nconst disableEpisodeDetail = computed(() => {\n  if (props.items) {\n    if (transferForm.episode_format) return false\n    return !(props.items.length === 1 && props.items[0].type !== 'dir')\n  }\n})\n\n// 表单\nconst transferForm = reactive<TransferForm>({\n  fileitem: {} as FileItem,\n  logid: 0,\n  target_storage: props.target_storage ?? 'local',\n  transfer_type: '',\n  target_path: '',\n  min_filesize: 0,\n  scrape: false,\n  from_history: false,\n})\n\n// 所有媒体库目录\nconst directories = ref<TransferDirectoryConf[]>([])\n\n// 查询目录\nasync function loadDirectories() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/Directories')\n    directories.value = result.data?.value ?? []\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 目的目录下拉框\nconst targetDirectories = computed(() => {\n  const libraryDirectories = directories.value.map(item => item.library_path)\n  return [...new Set(libraryDirectories)]\n})\n\n// 监听目的路径变化，配置默认值\nwatch(\n  () => transferForm.target_path,\n  async newPath => {\n    if (newPath) {\n      const directory = directories.value.find(item => item.library_path === newPath)\n      if (directory) {\n        transferForm.target_storage = directory.library_storage ?? 'local'\n        transferForm.transfer_type = transferForm.transfer_type || directory.transfer_type\n        transferForm.scrape = directory.scraping ?? false\n        transferForm.library_category_folder = directory.library_category_folder ?? false\n        transferForm.library_type_folder = directory.library_type_folder ?? false\n      } else {\n        transferForm.transfer_type = transferForm.transfer_type || 'copy'\n        transferForm.scrape = false\n        transferForm.library_category_folder = false\n        transferForm.library_type_folder = false\n      }\n    } else {\n      // 路径为空时, 恢复到`自动`条件\n      transferForm.transfer_type = ''\n      transferForm.library_type_folder = undefined\n      transferForm.library_category_folder = undefined\n    }\n  },\n)\n\n// 整理文件\nasync function handleTransfer(item: FileItem, background: boolean = false) {\n  transferForm.fileitem = item\n  transferForm.logid = 0\n  try {\n    const result: { [key: string]: any } = await api.post(`transfer/manual?background=${background}`, transferForm)\n    if (!result.success) $toast.error(result.message)\n    else if (background) $toast.success(t('dialog.reorganize.successMessage', { name: item.name }))\n  } catch (e) {\n    console.log(e)\n  }\n}\n\n// 整理日志\nasync function handleTransferLog(logid: number, background: boolean = false) {\n  transferForm.logid = logid\n  transferForm.fileitem = {} as FileItem\n  try {\n    const result: { [key: string]: any } = await api.post(`transfer/manual?background=${background}`, transferForm)\n    if (!result.success) $toast.error(result.message)\n    else if (background) $toast.success(`历史记录 ${logid} 已加入整理队列！`)\n  } catch (e) {\n    console.log(e)\n  }\n}\n\n// 进度SSE消息处理函数\nfunction handleProgressMessage(event: MessageEvent) {\n  const progress = JSON.parse(event.data)\n  if (progress) {\n    progressText.value = progress.text\n    progressValue.value = progress.value\n  }\n}\n\n// 使用SSE监听加载进度\nfunction startLoadingProgress(key: string) {\n  progressText.value = t('dialog.reorganize.processing')\n  progressActive.value = true\n\n  // 如果已经有连接，先停止\n  if (progressSSE.value) {\n    progressSSE.value.stop()\n  }\n\n  const url = `${import.meta.env.VITE_API_BASE_URL}system/progress/${key}`\n\n  // 创建新的SSE连接\n  progressSSE.value = useProgressSSE(url, handleProgressMessage, `reorganize-progress-${key}`, progressActive)\n\n  progressSSE.value.start()\n}\n\n// 停止监听加载进度\nfunction stopLoadingProgress() {\n  progressActive.value = false\n  if (progressSSE.value) {\n    progressSSE.value.stop()\n    progressSSE.value = null\n  }\n}\n\n// 整理文件\nasync function transfer(background: boolean = false) {\n  if (!props.logids && !props.items) return\n\n  // 显示进度条\n  progressDialog.value = true\n\n  // 文件整理\n  if (props.items) {\n    for (const item of props.items) {\n      if (!background) {\n        // 如果是文件，计算MD5\n        const key = item.type === 'dir' ? 'filetransfer' : CryptoJS.MD5(item.path).toString()\n\n        // 开始监听进度\n        startLoadingProgress(key)\n      }\n      await handleTransfer(item, background)\n    }\n  }\n\n  // 日志整理\n  if (props.logids) {\n    if (!background) {\n      // 为日志整理任务开启进度监听\n      startLoadingProgress('filetransfer')\n    }\n    for (const logid of props.logids) {\n      await handleTransferLog(logid, background)\n    }\n  }\n  if (!background) {\n    // 停止监听进度\n    stopLoadingProgress()\n  }\n\n  // 关闭进度条\n  progressDialog.value = false\n  // 重新加载\n  emit('done')\n}\n\nonMounted(() => {\n  loadDirectories()\n  loadStorages()\n})\n\nonUnmounted(() => {\n  stopLoadingProgress()\n})\n</script>\n\n<template>\n  <VDialog scrollable max-width=\"45rem\" :fullscreen=\"!display.mdAndUp.value\">\n    <VCard>\n      <VCardItem class=\"py-2\">\n        <template #prepend> <VIcon icon=\"mdi-folder-move\" class=\"me-2\" /> </template>\n        <VCardTitle>{{ dialogTitle }}</VCardTitle>\n        <VCardSubtitle>{{ dialogSubtitle }}</VCardSubtitle>\n      </VCardItem>\n      <VDialogCloseBtn @click=\"emit('close')\" />\n      <VDivider />\n      <VCardText>\n        <VForm @submit.prevent=\"() => {}\">\n          <VRow>\n            <VCol cols=\"12\" md=\"6\">\n              <VSelect\n                v-model=\"transferForm.target_storage\"\n                :items=\"storageOptions\"\n                :label=\"t('dialog.reorganize.targetStorage')\"\n                :placeholder=\"t('dialog.reorganize.targetPathPlaceholder')\"\n                :hint=\"t('dialog.reorganize.targetStorageHint')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-harddisk\"\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"6\">\n              <VSelect\n                v-model=\"transferForm.transfer_type\"\n                :label=\"t('dialog.reorganize.transferType')\"\n                :items=\"transferTypeOptions\"\n                :hint=\"t('dialog.reorganize.transferTypeHint')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-swap-horizontal\"\n              >\n                <template v-slot:selection=\"{ item }\">\n                  {{ transferForm.transfer_type === '' ? t('dialog.reorganize.auto') : item.title }}\n                </template>\n              </VSelect>\n            </VCol>\n            <VCol cols=\"12\">\n              <VCombobox\n                v-model=\"transferForm.target_path\"\n                :items=\"targetDirectories\"\n                :label=\"t('dialog.reorganize.targetPath')\"\n                :placeholder=\"t('dialog.reorganize.targetPathPlaceholder')\"\n                :hint=\"t('dialog.reorganize.targetPathHint')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-folder-outline\"\n              />\n            </VCol>\n          </VRow>\n          <VRow>\n            <VCol cols=\"12\" md=\"6\">\n              <VSelect\n                v-model=\"transferForm.type_name\"\n                :label=\"t('dialog.reorganize.mediaType')\"\n                :items=\"[\n                  { title: t('dialog.reorganize.auto'), value: '' },\n                  { title: t('dialog.reorganize.movie'), value: '电影' },\n                  { title: t('dialog.reorganize.tv'), value: '电视剧' },\n                ]\"\n                :hint=\"t('dialog.reorganize.mediaTypeHint')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-movie-open\"\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"6\">\n              <VTextField\n                v-if=\"mediaSource === 'themoviedb'\"\n                v-model=\"transferForm.tmdbid\"\n                :disabled=\"transferForm.type_name === ''\"\n                :label=\"t('dialog.reorganize.tmdbId')\"\n                :placeholder=\"t('dialog.reorganize.mediaIdPlaceholder')\"\n                :rules=\"[numberValidator]\"\n                append-inner-icon=\"mdi-magnify\"\n                :hint=\"t('dialog.reorganize.mediaIdHint')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-identifier\"\n                @click:append-inner=\"mediaSelectorDialog = true\"\n              />\n              <VTextField\n                v-else\n                v-model=\"transferForm.doubanid\"\n                :disabled=\"transferForm.type_name === ''\"\n                :label=\"t('dialog.reorganize.doubanId')\"\n                :placeholder=\"t('dialog.reorganize.mediaIdPlaceholder')\"\n                :rules=\"[numberValidator]\"\n                append-inner-icon=\"mdi-magnify\"\n                :hint=\"t('dialog.reorganize.mediaIdHint')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-identifier\"\n                @click:append-inner=\"mediaSelectorDialog = true\"\n              />\n            </VCol>\n          </VRow>\n          <VRow v-show=\"transferForm.type_name === '电视剧'\">\n            <VCol cols=\"12\" md=\"6\">\n              <VTextField\n                v-model=\"transferForm.episode_group\"\n                :label=\"t('dialog.reorganize.episodeGroup')\"\n                :placeholder=\"t('dialog.reorganize.episodeGroupPlaceholder')\"\n                :hint=\"t('dialog.reorganize.episodeGroupHint')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-view-list\"\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"3\">\n              <VSelect\n                v-model.number=\"transferForm.season\"\n                :label=\"t('dialog.reorganize.season')\"\n                :items=\"seasonItems\"\n                :hint=\"t('dialog.reorganize.seasonHint')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-calendar\"\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"3\">\n              <VTextField\n                v-model=\"transferForm.episode_detail\"\n                :disabled=\"disableEpisodeDetail\"\n                :label=\"t('dialog.reorganize.episodeDetail')\"\n                :placeholder=\"t('dialog.reorganize.episodeDetailPlaceholder')\"\n                :hint=\"t('dialog.reorganize.episodeDetailHint')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-playlist-play\"\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"6\">\n              <VTextField\n                v-model=\"transferForm.episode_format\"\n                :label=\"t('dialog.reorganize.episodeFormat')\"\n                :placeholder=\"t('dialog.reorganize.episodeFormatPlaceholder')\"\n                :hint=\"t('dialog.reorganize.episodeFormatHint')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-format-text\"\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"6\">\n              <VTextField\n                v-model=\"transferForm.episode_offset\"\n                :label=\"t('dialog.reorganize.episodeOffset')\"\n                :placeholder=\"t('dialog.reorganize.episodeOffsetPlaceholder')\"\n                :hint=\"t('dialog.reorganize.episodeOffsetHint')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-numeric\"\n              />\n            </VCol>\n          </VRow>\n          <VRow>\n            <VCol cols=\"12\" md=\"6\">\n              <VTextField\n                v-model=\"transferForm.episode_part\"\n                :label=\"t('dialog.reorganize.episodePart')\"\n                :placeholder=\"t('dialog.reorganize.episodePartPlaceholder')\"\n                :hint=\"t('dialog.reorganize.episodePartHint')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-file-multiple\"\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"6\">\n              <VTextField\n                v-model.number=\"transferForm.min_filesize\"\n                :label=\"t('dialog.reorganize.minFileSize')\"\n                :rules=\"[numberValidator]\"\n                placeholder=\"0\"\n                :hint=\"t('dialog.reorganize.minFileSizeHint')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-file-document-outline\"\n              />\n            </VCol>\n          </VRow>\n          <VRow>\n            <VCol cols=\"12\" md=\"6\" v-if=\"transferForm.target_path\">\n              <VSwitch\n                v-model=\"transferForm.library_type_folder\"\n                :label=\"t('dialog.reorganize.typeFolderOption')\"\n                :hint=\"t('dialog.reorganize.typeFolderHint')\"\n                persistent-hint\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"6\" v-if=\"transferForm.target_path\">\n              <VSwitch\n                v-model=\"transferForm.library_category_folder\"\n                :label=\"t('dialog.reorganize.categoryFolderOption')\"\n                :hint=\"t('dialog.reorganize.categoryFolderHint')\"\n                persistent-hint\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"6\">\n              <VSwitch\n                v-model=\"transferForm.scrape\"\n                :label=\"t('dialog.reorganize.scrapeOption')\"\n                :hint=\"t('dialog.reorganize.scrapeHint')\"\n                persistent-hint\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"6\" v-if=\"props.logids\">\n              <VSwitch\n                v-model=\"transferForm.from_history\"\n                :label=\"t('dialog.reorganize.fromHistoryOption')\"\n                :hint=\"t('dialog.reorganize.fromHistoryHint')\"\n                persistent-hint\n              />\n            </VCol>\n          </VRow>\n        </VForm>\n      </VCardText>\n      <VCardActions class=\"pt-3\">\n        <VSpacer />\n        <VBtn color=\"success\" @click=\"transfer(true)\" prepend-icon=\"mdi-plus\" class=\"px-5\">\n          {{ t('dialog.reorganize.addToQueue') }}\n        </VBtn>\n        <VBtn @click=\"transfer(false)\" prepend-icon=\"mdi-arrow-right-bold\" class=\"px-5\">\n          {{ t('dialog.reorganize.reorganizeNow') }}\n        </VBtn>\n      </VCardActions>\n    </VCard>\n    <!-- 手动整理进度框 -->\n    <ProgressDialog v-if=\"progressDialog\" v-model=\"progressDialog\" :text=\"progressText\" :value=\"progressValue\" />\n    <!-- TMDB ID搜索框 -->\n    <VDialog v-model=\"mediaSelectorDialog\" width=\"40rem\" scrollable max-height=\"85vh\">\n      <MediaIdSelector\n        v-if=\"mediaSource === 'themoviedb'\"\n        v-model=\"transferForm.tmdbid\"\n        @close=\"mediaSelectorDialog = false\"\n        :type=\"mediaSource\"\n      />\n      <MediaIdSelector\n        v-else\n        v-model=\"transferForm.doubanid\"\n        @close=\"mediaSelectorDialog = false\"\n        :type=\"mediaSource\"\n      />\n    </VDialog>\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/components/dialog/SearchBarDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport api from '@/api'\nimport type { Site, Plugin, Subscribe } from '@/api/types'\nimport { getNavMenus, getSettingTabs } from '@/router/i18n-menu'\nimport { NavMenu } from '@/@layouts/types'\nimport { useUserStore, useGlobalSettingsStore } from '@/stores'\nimport SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'\nimport { useI18n } from 'vue-i18n'\nimport { useDisplay } from 'vuetify'\nimport { hasPermission, filterMenusByPermission } from '@/utils/permission'\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 多语言支持\nconst { t } = useI18n()\n\n// 定义props，接收modelValue\nconst props = defineProps<{\n  modelValue: boolean\n}>()\n\n// 路由\nconst router = useRouter()\n\n// 用户 Store\nconst userStore = useUserStore()\n\n// 全局设置 Store\nconst globalSettingsStore = useGlobalSettingsStore()\nconst globalSettings = globalSettingsStore.globalSettings\n\n// 超级用户\nconst superUser = userStore.superUser\n\n// 当前用户名\nconst userName = userStore.userName\n\n// 权限检查\nconst hasSearchPermission = computed(() => {\n  return hasPermission(\n    {\n      is_superuser: userStore.superUser,\n      ...userStore.permissions,\n    },\n    'search',\n  )\n})\n\nconst hasSubscribePermission = computed(() => {\n  return hasPermission(\n    {\n      is_superuser: userStore.superUser,\n      ...userStore.permissions,\n    },\n    'subscribe',\n  )\n})\n\nconst hasManagePermission = computed(() => {\n  return hasPermission(\n    {\n      is_superuser: userStore.superUser,\n      ...userStore.permissions,\n    },\n    'manage',\n  )\n})\n\n// 是否显示合集搜索项（当SEARCH_SOURCE包含themoviedb时显示）\nconst showCollectionSearch = computed(() => {\n  return globalSettings.SEARCH_SOURCE?.includes('themoviedb') || false\n})\n\n// 所有订阅数据\nconst SubscribeItems = ref<Subscribe[]>([])\n\n// 站点选择对话框\nconst chooseSiteDialog = ref(false)\nconst selectedSites = ref<number[]>([])\nconst allSites = ref<Site[]>([])\n\n// 定义事件\nconst emit = defineEmits(['close', 'update:modelValue'])\n\n// 对话框状态的本地计算属性\nconst dialog = computed({\n  get: () => props.modelValue,\n  set: val => emit('update:modelValue', val),\n})\n\n// 搜索词\nconst searchWord = ref<string | null>(null)\n\n// ref\nconst searchWordInput = ref<HTMLElement | null>(null)\n\n// 近期搜索词条\nconst recentSearches = ref<string[]>([])\n\n// 检测操作系统是否是Mac\nfunction isMac() {\n  return navigator.platform.toUpperCase().indexOf('MAC') >= 0\n}\n\n// 计算属性：根据操作系统显示不同的按键提示\nconst metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))\n\n// 保存近期搜索到本地\nfunction saveRecentSearches(keyword: string) {\n  if (!keyword) return\n  if (recentSearches.value.includes(keyword)) return\n  recentSearches.value.unshift(keyword)\n  localStorage.setItem('MP_RecentSearches', JSON.stringify(recentSearches.value))\n}\n\n// 从本地加载近期搜索\nfunction loadRecentSearches() {\n  const recentSearchesStr = localStorage.getItem('MP_RecentSearches')\n  if (recentSearchesStr) {\n    recentSearches.value = JSON.parse(recentSearchesStr)\n    // 只保留最近的 5 条\n    if (recentSearches.value.length > 5) {\n      recentSearches.value = recentSearches.value.slice(0, 5)\n    }\n  }\n}\n\n// 所有菜单功能\nfunction getMenus(): NavMenu[] {\n  let menus: NavMenu[] = []\n  // 导航菜单\n  getNavMenus(t).forEach(\n    item =>\n      item &&\n      menus.push({\n        title: item.full_title ?? item.title,\n        icon: item.icon,\n        to: item.to,\n        header: item.header,\n        admin: item.admin,\n      }),\n  )\n  // 设置标签页\n  getSettingTabs(t).forEach(\n    item =>\n      item &&\n      menus.push({\n        title: t('navItems.setting') + ' -> ' + item.title,\n        icon: item.icon,\n        to: `/setting?tab=${item.tab}`,\n        header: '',\n        admin: true,\n        description: item.description,\n      }),\n  )\n\n  return menus\n}\n\n// 获取用户权限信息\nconst userPermissions = computed(() => ({\n  is_superuser: userStore.superUser,\n  ...userStore.permissions,\n}))\n\n// 匹配的菜单列表\nconst matchedMenuItems = computed(() => {\n  if (!searchWord.value) return []\n  const lowerWord = (searchWord.value as string).toLowerCase()\n  const menuItems = getMenus()\n  if (menuItems) {\n    // 先根据用户权限过滤菜单\n    const filteredMenus = filterMenusByPermission(menuItems, userPermissions.value)\n    // 再根据搜索词过滤\n    return filteredMenus.filter(\n      item =>\n        item.title.toLowerCase().includes(lowerWord) ||\n        (item.description && item.description.toLowerCase().includes(lowerWord)),\n    )\n  }\n  return []\n})\n\n// 所有插件（已安装）\nconst pluginItems = ref<Plugin[]>([])\n\n// 获取插件列表数据\nasync function fetchInstalledPlugins() {\n  try {\n    pluginItems.value = await api.get('plugin/', {\n      params: {\n        state: 'installed',\n      },\n    })\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 匹配的插件列表\nconst matchedPluginItems = computed(() => {\n  if (!searchWord.value) return []\n  if (!hasManagePermission.value) return []\n  const lowerWord = (searchWord.value as string).toLowerCase()\n  return pluginItems.value.filter((item: Plugin) => {\n    if (!item.plugin_name && !item.plugin_desc) return false\n    return item.plugin_name?.toLowerCase().includes(lowerWord) || item.plugin_desc?.toLowerCase().includes(lowerWord)\n  })\n})\n\n// 获取订阅列表数据\nasync function fetchSubscribes() {\n  try {\n    SubscribeItems.value = await api.get('subscribe/')\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 从接口加载用户站点偏好设置\nconst loadUserSitePreferences = async () => {\n  try {\n    const result = await api.get('system/setting/IndexerSites')\n    if (result && result.data && result.data.value) {\n      selectedSites.value = result.data.value\n      return\n    }\n  } catch (err) {\n    console.error(err)\n  }\n}\n\n// 查询所有站点\nasync function queryAllSites() {\n  try {\n    const data: Site[] = await api.get('site/')\n    // 过滤站点，只有启用的站点才显示\n    allSites.value = data.filter(item => item.is_active)\n    // 如果没有选择任何站点并且有可用站点，才默认选择全部\n    if (selectedSites.value.length === 0 && allSites.value.length > 0) {\n      selectedSites.value = allSites.value.map((site: Site) => site.id)\n    }\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 打开站点选择对话框\nconst openSiteDialog = () => {\n  chooseSiteDialog.value = true\n}\n\n// 匹配的订阅列表\nconst matchedSubscribeItems = computed(() => {\n  if (!searchWord.value) return []\n  if (!hasSubscribePermission.value) return []\n  const lowerWord = (searchWord.value as string).toLowerCase()\n  return SubscribeItems.value.filter((item: Subscribe) => {\n    return (item.name.toLowerCase().includes(lowerWord) && (superUser || userName === item.username)) || false\n  })\n})\n\n// 搜索多站点\nfunction searchSites(sites: number[]) {\n  chooseSiteDialog.value = false\n  selectedSites.value = sites\n  searchTorrent()\n}\n\n// 搜索资源\nfunction searchTorrent() {\n  if (!searchWord.value) return\n  // 记录搜索词\n  saveRecentSearches(searchWord.value)\n  // 跳转到搜索页面\n  router.push({\n    path: '/resource',\n    query: {\n      keyword: searchWord.value,\n      area: 'title',\n      sites: selectedSites.value.join(','),\n    },\n  })\n  // 关闭搜索对话框\n  dialog.value = false\n  emit('close')\n}\n\n// 跳转媒体搜索页面\nfunction searchMedia(searchType: string) {\n  // 搜索类型 media/person\n  if (!searchWord.value) return\n  saveRecentSearches(searchWord.value)\n  router.push({\n    path: '/browse/media/search',\n    query: {\n      title: searchWord.value,\n      type: searchType,\n    },\n  })\n  emit('close')\n}\n\n// 跳转到历史记录页面\nfunction searchHistory() {\n  if (!searchWord.value) return\n  saveRecentSearches(searchWord.value)\n  router.push({\n    path: '/history',\n    query: {\n      search: searchWord.value,\n    },\n  })\n  emit('close')\n}\n\n// 跳转到订阅分享页面\nfunction searchSubscribeShares() {\n  if (!searchWord.value) return\n  saveRecentSearches(searchWord.value)\n  router.push({\n    path: '/subscribe-share',\n    query: {\n      keyword: searchWord.value,\n    },\n  })\n  emit('close')\n}\n\n// 跳转插件页面\nfunction showPlugin(pluginId: string) {\n  router.push({\n    path: `/plugins/`,\n    query: {\n      tab: 'installed',\n      id: pluginId,\n    },\n  })\n  emit('close')\n}\n\n// 跳转菜单页面\nfunction goPage(to: string) {\n  router.push(to)\n  emit('close')\n}\n\n// 跳转订阅页面\nfunction goSubscribe(subscribe: Subscribe) {\n  if (subscribe.type === '电影') {\n    router.push({\n      path: '/subscribe/movie',\n      query: {\n        id: subscribe.id,\n      },\n    })\n  } else {\n    router.push({\n      path: '/subscribe/tv',\n      query: {\n        id: subscribe.id,\n      },\n    })\n  }\n  emit('close')\n}\n\nonMounted(() => {\n  setTimeout(() => {\n    searchWordInput.value?.focus()\n  }, 500)\n  // 根据权限加载不同的数据\n  if (hasManagePermission.value) {\n    fetchInstalledPlugins()\n  }\n  if (hasSubscribePermission.value) {\n    fetchSubscribes()\n  }\n  loadRecentSearches()\n  if (hasSearchPermission.value) {\n    loadUserSitePreferences()\n    if (hasManagePermission.value) {\n      queryAllSites()\n    }\n  }\n})\n</script>\n<template>\n  <VDialog v-model=\"dialog\" max-width=\"40rem\" scrollable :fullscreen=\"!display.mdAndUp.value\">\n    <VCard class=\"search-dialog\">\n      <!-- 搜索输入框区域 -->\n      <div class=\"search-header\">\n        <div class=\"search-input-wrapper\">\n          <VIcon icon=\"mdi-magnify\" size=\"22\" class=\"search-input-icon\" />\n          <input\n            ref=\"searchWordInput\"\n            v-model=\"searchWord\"\n            type=\"text\"\n            class=\"search-native-input\"\n            :placeholder=\"t('dialog.searchBar.searchPlaceholder')\"\n            @keydown.enter=\"searchMedia('media')\"\n            @keydown.escape=\"emit('close')\"\n          />\n          <VBtn icon size=\"small\" variant=\"text\" class=\"search-submit-btn\" @click=\"searchMedia('media')\">\n            <VIcon icon=\"mdi-magnify\" size=\"20\" />\n          </VBtn>\n        </div>\n      </div>\n\n      <!-- 主内容区域 -->\n      <div class=\"search-content\">\n        <!-- 有搜索词时显示搜索入口和匹配结果 -->\n        <VList lines=\"two\" v-if=\"searchWord\" class=\"search-list pa-0 py-2\">\n          <!-- 媒体搜索入口 -->\n          <VListSubheader class=\"font-weight-medium text-uppercase px-4\">\n            {{ t('common.media') }}\n          </VListSubheader>\n\n          <VListItem density=\"comfortable\" link @click=\"searchMedia('media')\" class=\"search-result-item mx-2 my-1\">\n            <template #prepend>\n              <div class=\"result-icon-wrapper\">\n                <VIcon icon=\"mdi-movie-search\" size=\"small\" color=\"medium-emphasis\" />\n              </div>\n            </template>\n            <VListItemTitle class=\"font-weight-medium text-body-2\">\n              {{ t('recommend.categoryMovie') }}、{{ t('recommend.categoryTV') }}\n            </VListItemTitle>\n            <VListItemSubtitle class=\"text-caption text-medium-emphasis\">\n              {{ t('common.search') }} <span class=\"primary-text font-weight-medium\">{{ searchWord }}</span>\n              {{ t('resource.title') }}\n            </VListItemSubtitle>\n          </VListItem>\n\n          <VListItem\n            v-if=\"showCollectionSearch\"\n            density=\"comfortable\"\n            link\n            @click=\"searchMedia('collection')\"\n            class=\"search-result-item mx-2 my-1\"\n          >\n            <template #prepend>\n              <div class=\"result-icon-wrapper\">\n                <VIcon icon=\"mdi-movie-filter\" size=\"small\" color=\"medium-emphasis\" />\n              </div>\n            </template>\n            <VListItemTitle class=\"font-weight-medium text-body-2\">{{\n              t('dialog.searchBar.collections')\n            }}</VListItemTitle>\n            <VListItemSubtitle class=\"text-caption text-medium-emphasis\">\n              {{ t('common.search') }} <span class=\"primary-text font-weight-medium\">{{ searchWord }}</span>\n              {{ t('dialog.searchBar.collectionSearch') }}\n            </VListItemSubtitle>\n          </VListItem>\n\n          <VListItem density=\"comfortable\" link @click=\"searchMedia('person')\" class=\"search-result-item mx-2 my-1\">\n            <template #prepend>\n              <div class=\"result-icon-wrapper\">\n                <VIcon icon=\"mdi-account-search\" size=\"small\" color=\"medium-emphasis\" />\n              </div>\n            </template>\n            <VListItemTitle class=\"font-weight-medium text-body-2\">{{ t('browse.actor') }}</VListItemTitle>\n            <VListItemSubtitle class=\"text-caption text-medium-emphasis\">\n              {{ t('common.search') }} <span class=\"primary-text font-weight-medium\">{{ searchWord }}</span>\n              {{ t('dialog.searchBar.actorSearch') }}\n            </VListItemSubtitle>\n          </VListItem>\n\n          <VListItem\n            v-if=\"hasSubscribePermission\"\n            density=\"comfortable\"\n            link\n            @click=\"searchSubscribeShares\"\n            class=\"search-result-item mx-2 my-1\"\n          >\n            <template #prepend>\n              <div class=\"result-icon-wrapper\">\n                <VIcon icon=\"mdi-share-variant\" size=\"small\" color=\"medium-emphasis\" />\n              </div>\n            </template>\n            <VListItemTitle class=\"font-weight-medium text-body-2\">{{ t('subscribe.searchShares') }}</VListItemTitle>\n            <VListItemSubtitle class=\"text-caption text-medium-emphasis\">\n              {{ t('common.search') }} <span class=\"primary-text font-weight-medium\">{{ searchWord }}</span>\n              {{ t('dialog.searchBar.subscribeShareSearch') }}\n            </VListItemSubtitle>\n          </VListItem>\n\n          <VListItem\n            v-if=\"hasManagePermission\"\n            density=\"comfortable\"\n            link\n            @click=\"searchHistory\"\n            class=\"search-result-item mx-2 my-1\"\n          >\n            <template #prepend>\n              <div class=\"result-icon-wrapper\">\n                <VIcon icon=\"mdi-history\" size=\"small\" color=\"medium-emphasis\" />\n              </div>\n            </template>\n            <VListItemTitle class=\"font-weight-medium text-body-2\">{{ t('navItems.history') }}</VListItemTitle>\n            <VListItemSubtitle class=\"text-caption text-medium-emphasis\">\n              {{ t('common.search') }} <span class=\"primary-text font-weight-medium\">{{ searchWord }}</span>\n              {{ t('dialog.searchBar.historySearch') }}\n            </VListItemSubtitle>\n          </VListItem>\n\n          <!-- 匹配的订阅 -->\n          <template v-if=\"matchedSubscribeItems.length > 0\">\n            <VDivider class=\"mx-4 my-2 search-divider\" />\n            <VListSubheader class=\"font-weight-medium text-uppercase px-4\">\n              {{ t('dialog.searchBar.subscriptions') }}\n            </VListSubheader>\n            <VListItem\n              v-for=\"subscribe in matchedSubscribeItems\"\n              :key=\"subscribe.id\"\n              density=\"comfortable\"\n              link\n              @click=\"goSubscribe(subscribe)\"\n              class=\"search-result-item mx-2 my-1\"\n            >\n              <template #prepend>\n                <div class=\"result-icon-wrapper\">\n                  <VIcon\n                    :icon=\"subscribe.type === '电影' ? 'mdi-movie-roll' : 'mdi-television-classic'\"\n                    size=\"small\"\n                    color=\"medium-emphasis\"\n                  />\n                </div>\n              </template>\n              <VListItemTitle class=\"font-weight-medium text-body-2\">\n                {{ subscribe.name }}\n                <span v-if=\"subscribe.season\" class=\"text-caption\">\n                  {{ t('resource.season') }} {{ subscribe.season }}</span\n                >\n              </VListItemTitle>\n              <VListItemSubtitle class=\"text-caption text-medium-emphasis\">\n                {{ subscribe.type }}\n              </VListItemSubtitle>\n            </VListItem>\n          </template>\n\n          <!-- 匹配的菜单/功能 -->\n          <template v-if=\"matchedMenuItems.length > 0\">\n            <VDivider class=\"mx-4 my-2 search-divider\" />\n            <VListSubheader class=\"font-weight-medium text-uppercase px-4\">\n              {{ t('dialog.searchBar.functions') }}\n            </VListSubheader>\n            <VListItem\n              v-for=\"menu in matchedMenuItems\"\n              :key=\"menu.title\"\n              density=\"comfortable\"\n              link\n              @click=\"goPage(menu.to as string)\"\n              class=\"search-result-item mx-2 my-1\"\n            >\n              <template #prepend>\n                <div class=\"result-icon-wrapper\">\n                  <VIcon :icon=\"menu.icon as string\" size=\"small\" color=\"medium-emphasis\" />\n                </div>\n              </template>\n              <VListItemTitle class=\"font-weight-medium text-body-2\">\n                {{ menu.title }}\n              </VListItemTitle>\n              <VListItemSubtitle v-if=\"menu.description\" class=\"text-caption text-medium-emphasis\">\n                {{ menu.description }}\n              </VListItemSubtitle>\n            </VListItem>\n          </template>\n\n          <!-- 匹配的插件 -->\n          <template v-if=\"matchedPluginItems.length > 0\">\n            <VDivider class=\"mx-4 my-2 search-divider\" />\n            <VListSubheader class=\"font-weight-medium text-uppercase px-4\">\n              {{ t('dialog.searchBar.plugins') }}\n            </VListSubheader>\n            <VListItem\n              v-for=\"plugin in matchedPluginItems\"\n              :key=\"plugin.id\"\n              density=\"comfortable\"\n              link\n              @click=\"showPlugin(plugin.id ?? '')\"\n              class=\"search-result-item mx-2 my-1\"\n            >\n              <template #prepend>\n                <div class=\"result-icon-wrapper\">\n                  <VIcon icon=\"mdi-puzzle\" size=\"small\" color=\"medium-emphasis\" />\n                </div>\n              </template>\n              <VListItemTitle class=\"font-weight-medium text-body-2\">\n                {{ plugin.plugin_name }}\n              </VListItemTitle>\n              <VListItemSubtitle class=\"text-caption text-medium-emphasis\">\n                {{ plugin.plugin_desc }}\n              </VListItemSubtitle>\n            </VListItem>\n          </template>\n\n          <!-- 站点资源搜索 -->\n          <template v-if=\"hasSearchPermission\">\n            <VDivider class=\"mx-4 my-2 search-divider\" />\n            <VListSubheader class=\"font-weight-medium text-uppercase px-4\">\n              {{ t('dialog.searchBar.siteResources') }}\n            </VListSubheader>\n\n            <VListItem density=\"comfortable\" link @click=\"searchTorrent\" class=\"search-result-item mx-2 my-1\">\n              <template #prepend>\n                <div class=\"result-icon-wrapper\">\n                  <VIcon icon=\"mdi-file-search\" size=\"small\" color=\"medium-emphasis\" />\n                </div>\n              </template>\n              <VListItemTitle class=\"font-weight-medium text-body-2\">{{\n                t('dialog.searchBar.searchInSites')\n              }}</VListItemTitle>\n              <VListItemSubtitle class=\"text-caption text-medium-emphasis\">\n                {{ t('common.search') }} <span class=\"primary-text font-weight-medium\">{{ searchWord }}</span>\n                {{ t('dialog.searchBar.relatedResources') }}\n              </VListItemSubtitle>\n              <template #append>\n                <VBtn\n                  v-if=\"hasManagePermission\"\n                  size=\"x-small\"\n                  variant=\"tonal\"\n                  color=\"primary\"\n                  rounded=\"pill\"\n                  @click.stop=\"openSiteDialog\"\n                >\n                  {{ t('dialog.searchBar.selectSites') }}\n                </VBtn>\n              </template>\n            </VListItem>\n          </template>\n        </VList>\n\n        <!-- 无搜索词时显示空状态 -->\n        <div v-else class=\"search-empty-state\">\n          <!-- 有最近搜索 -->\n          <div v-if=\"!searchWord && recentSearches.length > 0\" class=\"recent-searches-section\">\n            <div class=\"text-body-2 font-weight-medium text-medium-emphasis mb-3\">\n              {{ t('dialog.searchBar.recentSearches') }}\n            </div>\n            <div class=\"d-flex flex-wrap gap-2\">\n              <VChip\n                v-for=\"(word, index) in recentSearches\"\n                :key=\"index\"\n                variant=\"flat\"\n                color=\"primary\"\n                size=\"small\"\n                @click=\"searchWord = word\"\n              >\n                <VIcon start size=\"x-small\">mdi-history</VIcon>\n                {{ word }}\n              </VChip>\n            </div>\n          </div>\n\n          <!-- 空状态提示 -->\n          <div v-else class=\"empty-hint\">\n            <span class=\"text-body-1 text-medium-emphasis\">{{ t('dialog.searchBar.emptySearchHint') }}</span>\n          </div>\n        </div>\n      </div>\n\n      <!-- 底部区域 -->\n      <!-- 桌面端：快捷键提示 -->\n      <div v-if=\"display.mdAndUp.value\" class=\"search-footer\">\n        <div class=\"shortcut-group\">\n          <kbd>Esc</kbd>\n          <span class=\"shortcut-label\">{{ t('dialog.searchBar.escClose') }}</span>\n        </div>\n        <div class=\"shortcut-group\">\n          <kbd>{{ metaKey }}</kbd>\n          <span class=\"shortcut-label\">{{ t('dialog.searchBar.openSearch') }}</span>\n        </div>\n      </div>\n      <!-- 移动端：关闭图标 -->\n      <div v-else class=\"search-footer-mobile\">\n        <VBtn icon variant=\"tonal\" @click=\"emit('close')\">\n          <VIcon icon=\"mdi-close\" size=\"20\" />\n        </VBtn>\n      </div>\n    </VCard>\n  </VDialog>\n\n  <!-- 站点选择对话框 -->\n  <SearchSiteDialog\n    v-if=\"chooseSiteDialog\"\n    v-model=\"chooseSiteDialog\"\n    :sites=\"allSites\"\n    :selected=\"selectedSites\"\n    @search=\"searchSites\"\n    @close=\"chooseSiteDialog = false\"\n    @reload=\"queryAllSites\"\n  />\n</template>\n\n<style scoped>\n.search-dialog {\n  display: flex;\n  overflow: hidden;\n  flex-direction: column;\n  border-radius: 16px !important;\n  box-shadow: 0 12px 40px rgba(0, 0, 0, 12%) !important;\n}\n\n/* 搜索头部区域 */\n.search-header {\n  padding-block: 16px 12px;\n  padding-inline: 16px;\n}\n\n/* 搜索输入框容器 */\n.search-input-wrapper {\n  display: flex;\n  align-items: center;\n  border: 1.5px solid rgba(var(--v-theme-on-surface), 0.15);\n  border-radius: 28px;\n  background-color: rgba(var(--v-theme-surface-variant), 0.04);\n  block-size: 48px;\n  padding-inline: 14px 6px;\n  transition:\n    border-color 0.2s ease,\n    box-shadow 0.2s ease;\n}\n\n.search-input-wrapper:focus-within {\n  border-color: rgba(var(--v-theme-on-surface), 0.3);\n  box-shadow: 0 0 0 3px rgba(var(--v-theme-on-surface), 0.04);\n}\n\n.search-input-icon {\n  flex-shrink: 0;\n  color: rgba(var(--v-theme-on-surface), 0.45);\n  margin-inline-end: 10px;\n}\n\n.search-native-input {\n  flex: 1;\n  border: none;\n  background: transparent;\n  color: rgba(var(--v-theme-on-surface), 0.87);\n  font-size: 15px;\n  line-height: 1.5;\n  min-inline-size: 0;\n  outline: none;\n}\n\n.search-native-input::placeholder {\n  color: rgba(var(--v-theme-on-surface), 0.38);\n}\n\n.search-submit-btn {\n  flex-shrink: 0;\n  border-radius: 50% !important;\n  background-color: rgba(var(--v-theme-on-surface), 0.06) !important;\n  block-size: 36px !important;\n  color: rgba(var(--v-theme-on-surface), 0.6) !important;\n  inline-size: 36px !important;\n  transition: background-color 0.2s ease;\n}\n\n.search-submit-btn:hover {\n  background-color: rgba(var(--v-theme-on-surface), 0.12) !important;\n}\n\n/* 主内容区域 */\n.search-content {\n  max-block-size: 600px;\n  min-block-size: 150px;\n  overflow-y: auto;\n}\n\n.search-list {\n  background: transparent !important;\n}\n\n.search-result-item {\n  border-radius: 10px !important;\n  margin-block-end: 2px;\n  transition: background-color 0.15s ease;\n}\n\n.search-result-item:hover {\n  background-color: rgba(var(--v-theme-on-surface), 0.04);\n}\n\n.result-icon-wrapper {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border-radius: 8px;\n  background-color: rgba(var(--v-theme-surface-variant), 0.1);\n  block-size: 32px;\n  inline-size: 32px;\n  margin-inline-end: 12px;\n}\n\n.search-divider {\n  opacity: 0.08;\n}\n\n.primary-text {\n  color: rgb(var(--v-theme-primary));\n}\n\n/* 空状态 */\n.search-empty-state {\n  display: flex;\n  align-items: start;\n  justify-content: center;\n  min-block-size: 150px;\n  padding-block: 0;\n  padding-inline: 1.5rem;\n}\n\n.recent-searches-section {\n  inline-size: 100%;\n}\n\n.empty-hint {\n  text-align: center;\n}\n\n/* 底部快捷键提示 */\n.search-footer {\n  display: flex;\n  align-items: center;\n  border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);\n  gap: 16px;\n  padding-block: 10px;\n  padding-inline: 16px;\n}\n\n.shortcut-group {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n}\n\nkbd {\n  border: 1px solid rgba(var(--v-theme-on-surface), 0.15);\n  border-radius: 5px;\n  background-color: rgba(var(--v-theme-on-surface), 0.04);\n  color: rgba(var(--v-theme-on-surface), 0.6);\n  font-family: inherit;\n  font-size: 11px;\n  font-weight: 500;\n  line-height: 1;\n  padding-block: 3px;\n  padding-inline: 6px;\n}\n\n.shortcut-label {\n  color: rgba(var(--v-theme-on-surface), 0.45);\n  font-size: 12px;\n}\n\n/* 移动端底部关闭图标 */\n.search-footer-mobile {\n  display: flex;\n  justify-content: center;\n  margin-block-start: auto;\n  padding-block: 12px;\n  padding-block-end: calc(12px + env(safe-area-inset-bottom));\n}\n\n/* 响应式 */\n@media (width <= 600px) {\n  .search-header {\n    padding-block: 12px 10px;\n    padding-inline: 12px;\n  }\n\n  .search-input-wrapper {\n    block-size: 44px;\n    padding-inline: 12px 4px;\n  }\n\n  .search-native-input {\n    font-size: 14px;\n  }\n\n  .search-footer {\n    padding-block: 8px;\n    padding-inline: 12px;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/dialog/SearchSiteDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport type { Site } from '@/api/types'\nimport { useI18n } from 'vue-i18n'\n\n// 多语言支持\nconst { t } = useI18n()\n\nconst props = defineProps({\n  sites: {\n    type: Array as PropType<Site[]>,\n    required: true,\n  },\n  selected: Array as PropType<Number[]>,\n})\n\n// 定义事件\nconst emit = defineEmits(['close', 'search', 'reload'])\n\n// 过滤词\nconst siteFilter = ref('')\n\n// 已选择站点\nconst selectedSites = ref<any[]>(props.selected || [])\n\nwatch(\n  () => props.selected,\n  value => {\n    if (selectedSites.value.length == 0 && value) {\n      selectedSites.value = value\n    }\n  },\n)\n\n// 全选/全不选按钮文字\nconst checkAllText = computed(() => {\n  return selectedSites.value.length < props.sites?.length\n    ? t('dialog.searchSite.selectAll')\n    : t('dialog.searchSite.deselectAll')\n})\n\n// 全选/全不选\nconst checkAllSitesorNot = () => {\n  if (selectedSites.value.length < props.sites?.length) {\n    selectedSites.value = props.sites?.map((item: Site) => item.id)\n  } else {\n    selectedSites.value = []\n  }\n}\n\n// 根据筛选条件过滤站点\nconst filteredSites = computed(() => {\n  if (!siteFilter.value) return props.sites\n  const filter = siteFilter.value.toLowerCase()\n  return props.sites?.filter((site: Site) => site.name.toLowerCase().includes(filter))\n})\n</script>\n<template>\n  <!-- Site Selection Dialog -->\n  <VDialog max-width=\"40rem\" fullscreen-mobile>\n    <VCard class=\"site-dialog\">\n      <VCardItem>\n        <template #prepend>\n          <VIcon icon=\"mdi-web-check\" />\n        </template>\n        <VCardTitle>\n          {{ t('dialog.searchSite.selectSites') }}\n        </VCardTitle>\n      </VCardItem>\n      <VDialogCloseBtn @click=\"emit('close')\" />\n      <VDivider />\n      <VCardText style=\"max-block-size: 420px\" class=\"overflow-y-auto px-4 py-4\">\n        <!-- 站点列表 -->\n        <div v-if=\"filteredSites.length > 0\">\n          <!-- 选择操作 -->\n          <div class=\"d-flex align-center mb-4\">\n            <VBtn\n              size=\"small\"\n              :color=\"selectedSites.length < sites.length ? 'primary' : 'error'\"\n              @click=\"checkAllSitesorNot\"\n              class=\"me-2\"\n              rounded=\"pill\"\n              variant=\"flat\"\n            >\n              <VIcon start size=\"small\">\n                {{ selectedSites.length < sites.length ? 'mdi-check-all' : 'mdi-close-circle-outline' }}\n              </VIcon>\n              {{ checkAllText }}\n            </VBtn>\n            <div\n              class=\"text-body-2 font-weight-medium\"\n              :class=\"selectedSites.length > 0 ? 'text-primary' : 'text-medium-emphasis'\"\n            >\n              {{ t('dialog.searchSite.searchAllSites', { selected: selectedSites.length, total: sites.length }) }}\n            </div>\n          </div>\n\n          <!-- 站点选择器 -->\n          <VRow dense>\n            <VCol v-for=\"site in filteredSites\" :key=\"site.id\" cols=\"6\" sm=\"6\" md=\"4\">\n              <VHover v-slot=\"{ isHovering, props }\">\n                <div\n                  v-bind=\"props\"\n                  :class=\"[\n                    'site-checkbox-wrapper pa-2 pa-sm-3 rounded-lg d-flex align-center',\n                    {\n                      'site-selected': selectedSites.includes(site.id),\n                      'site-hover': isHovering && !selectedSites.includes(site.id),\n                    },\n                  ]\"\n                  @click=\"\n                    () => {\n                      const index = selectedSites.indexOf(site.id)\n                      if (index === -1) {\n                        selectedSites.push(site.id)\n                      } else {\n                        selectedSites.splice(index, 1)\n                      }\n                    }\n                  \"\n                >\n                  <VIcon\n                    :icon=\"selectedSites.includes(site.id) ? 'mdi-check-circle' : 'mdi-checkbox-blank-circle-outline'\"\n                    :color=\"selectedSites.includes(site.id) ? 'primary' : 'medium-emphasis'\"\n                    class=\"me-2\"\n                    size=\"small\"\n                  />\n                  <span :class=\"['text-body-2 site-name', { 'font-weight-medium': selectedSites.includes(site.id) }]\">\n                    {{ site.name }}\n                  </span>\n                </div>\n              </VHover>\n            </VCol>\n          </VRow>\n        </div>\n        <div v-else class=\"text-center py-8 empty-site-state\">\n          <div class=\"search-icon-wrapper mb-4 mx-auto warning\">\n            <VIcon icon=\"mdi-alert-circle-outline\" size=\"large\" color=\"warning\" />\n          </div>\n          <div class=\"text-h6 font-weight-medium mb-2\">{{ t('torrent.noMatchingResults') }}</div>\n          <div class=\"text-subtitle-1 text-medium-emphasis mb-4\">\n            {{ siteFilter ? t('site.noFilterData') : t('site.sitesWillBeShownHere') }}\n          </div>\n          <VBtn\n            v-if=\"siteFilter\"\n            color=\"primary\"\n            variant=\"flat\"\n            class=\"mt-3\"\n            prepend-icon=\"mdi-refresh\"\n            @click=\"siteFilter = ''\"\n          >\n            {{ t('torrent.clearFilters') }}\n          </VBtn>\n          <VBtn v-else color=\"primary\" variant=\"flat\" class=\"mt-3\" prepend-icon=\"mdi-refresh\" @click=\"emit('reload')\">\n            {{ t('common.loading') }}\n          </VBtn>\n        </div>\n      </VCardText>\n\n      <VCardActions class=\"pt-3\">\n        <VSpacer />\n        <VBtn\n          color=\"primary\"\n          :disabled=\"selectedSites.length === 0\"\n          @click=\"emit('search', selectedSites)\"\n          prepend-icon=\"mdi-magnify\"\n          class=\"d-flex align-center justify-center px-5\"\n        >\n          {{ t('common.search') }}\n        </VBtn>\n      </VCardActions>\n    </VCard>\n  </VDialog>\n</template>\n<style scoped>\n.site-checkbox-wrapper {\n  border: 1px solid rgba(var(--v-theme-on-surface), 0.08);\n  cursor: pointer;\n  transition: transform 0.2s ease, background-color 0.2s ease;\n}\n\n.site-checkbox-wrapper:hover {\n  transform: translateY(-2px);\n}\n\n.site-name {\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.site-selected {\n  border-color: rgba(var(--v-theme-primary), 0.2);\n  background-color: rgba(var(--v-theme-primary), 0.08);\n  color: rgb(var(--v-theme-primary));\n}\n\n.site-hover {\n  background-color: rgba(var(--v-theme-primary), 0.04);\n}\n</style>\n"
  },
  {
    "path": "src/components/dialog/SiteAddEditDialog.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useToast } from 'vue-toastification'\nimport type { DownloaderConf, Site } from '@/api/types'\nimport { doneNProgress, startNProgress } from '@/api/nprogress'\nimport { numberValidator, requiredValidator } from '@/@validators'\nimport api from '@/api'\nimport { useDisplay } from 'vuetify'\nimport { useI18n } from 'vue-i18n'\n\n// 国际化\nconst { t } = useI18n()\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 输入参数\nconst props = defineProps({\n  siteid: Number,\n  oper: String,\n})\n\n// 注册事件\nconst emit = defineEmits(['save', 'remove', 'close'])\n\n// 站点编辑表单数据\nconst siteForm = ref<Site>({\n  id: props.siteid ?? 0,\n  url: '',\n  rss: '',\n  cookie: '',\n  ua: '',\n  pri: 0,\n  is_active: true,\n  limit_interval: 0,\n  limit_seconds: 0,\n  name: '',\n  domain: '',\n  downloader: '',\n})\n\n// 提示框\nconst $toast = useToast()\n\n// 维护类型\nconst siteType = ref('cookie')\n\n// 是否限流\nconst isLimit = ref(false)\n\n// 状态下拉项\nconst statusItems = [\n  { title: t('site.status.enabled'), value: true },\n  { title: t('site.status.disabled'), value: false },\n]\n\n// 生成1到50的优先级下拉框选项\nconst priorityItems = ref(\n  Array.from({ length: 100 }, (_, i) => i + 1).map(item => ({\n    title: item,\n    value: item,\n  })),\n)\n\n// 下载器选项\nconst downloaderOptions = ref<{ title: string; value: string }[]>([])\n\nasync function loadDownloaderSetting() {\n  try {\n    const downloaders: DownloaderConf[] = await api.get('download/clients')\n    downloaderOptions.value = [\n      { title: t('common.default'), value: '' },\n      ...downloaders.map((item: { name: any }) => ({\n        title: item.name,\n        value: item.name,\n      })),\n    ]\n  } catch (error) {\n    console.error(t('site.errors.loadDownloader'), error)\n  }\n}\n\n// 查询站点信息\nasync function fetchSiteInfo() {\n  try {\n    siteForm.value = await api.get(`site/${props.siteid}`)\n    siteForm.value.proxy = siteForm.value.proxy === 1\n    siteForm.value.render = siteForm.value.render === 1\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 调用API 新增站点\nasync function addSite() {\n  if (!siteForm.value?.url) return\n  startNProgress()\n  try {\n    const result: { [key: string]: string } = await api.post('site/', siteForm.value)\n    if (result.success) {\n      $toast.success(t('site.messages.addSuccess'))\n      emit('save')\n    } else {\n      $toast.error(`${t('site.messages.addFailed')}：${result.message}`)\n    }\n  } catch (error) {\n    console.error(error)\n  }\n  doneNProgress()\n}\n\n// 调用API更新站点信息\nasync function updateSiteInfo() {\n  startNProgress()\n  try {\n    if (isLimit.value) {\n      siteForm.value.limit_interval = siteForm.value.limit_interval || 0\n      siteForm.value.limit_count = siteForm.value.limit_count || 0\n      siteForm.value.limit_seconds = siteForm.value.limit_seconds || 0\n    } else {\n      siteForm.value.limit_interval = 0\n      siteForm.value.limit_count = 0\n      siteForm.value.limit_seconds = 0\n    }\n    const result: { [key: string]: any } = await api.put('site/', siteForm.value)\n    if (result.success) {\n      $toast.success(`${siteForm.value?.name} ${t('site.messages.updateSuccess')}`)\n      emit('save')\n    } else {\n      $toast.error(`${siteForm.value?.name} ${t('site.messages.updateFailed')}：${result.message}`)\n    }\n  } catch (error) {\n    $toast.error(`${siteForm.value?.name} ${t('site.messages.updateFailed')}！`)\n    console.error(error)\n  }\n  doneNProgress()\n}\n\nonMounted(async () => {\n  if (props.oper !== 'add') {\n    await fetchSiteInfo()\n    if (siteForm.value.limit_interval || siteForm.value.limit_count || siteForm.value.limit_seconds)\n      isLimit.value = true\n    if (siteForm.value.apikey || siteForm.value.token) siteType.value = 'api'\n  }\n  await loadDownloaderSetting()\n})\n</script>\n\n<template>\n  <VDialog scrollable :close-on-back=\"false\" eager max-width=\"45rem\" :fullscreen=\"!display.mdAndUp.value\">\n    <VCard>\n      <VCardItem :class=\"props.oper === 'add' ? 'py-3' : 'py-2'\">\n        <template #prepend>\n          <VIcon :icon=\"oper == 'add' ? 'mdi-web-plus' : 'mdi-web'\" class=\"me-2\" />\n        </template>\n        <VCardTitle>{{ `${props.oper === 'add' ? t('site.actions.add') : t('site.actions.edit')}` }}</VCardTitle>\n        <VCardSubtitle>{{ siteForm.name }}</VCardSubtitle>\n      </VCardItem>\n      <VDialogCloseBtn @click=\"emit('close')\" />\n      <VDivider />\n      <VCardText>\n        <VForm @submit.prevent=\"() => {}\">\n          <VRow>\n            <VCol cols=\"12\" md=\"6\">\n              <VTextField\n                v-model=\"siteForm.url\"\n                :label=\"t('site.fields.url')\"\n                :rules=\"[requiredValidator]\"\n                :hint=\"t('site.hints.url')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-web\"\n              />\n            </VCol>\n            <VCol cols=\"6\" md=\"3\">\n              <VAutocomplete\n                v-model=\"siteForm.pri\"\n                :label=\"t('site.fields.priority')\"\n                :items=\"priorityItems\"\n                :rules=\"[requiredValidator]\"\n                :hint=\"t('site.hints.priority')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-priority-high\"\n              />\n            </VCol>\n            <VCol cols=\"6\" md=\"3\">\n              <VSelect\n                v-model=\"siteForm.is_active\"\n                :items=\"statusItems\"\n                :label=\"t('site.fields.status')\"\n                :hint=\"t('site.hints.status')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-toggle-switch\"\n              />\n            </VCol>\n          </VRow>\n          <VRow>\n            <VCol cols=\"12\" md=\"6\">\n              <VTextField\n                v-model=\"siteForm.rss\"\n                :label=\"t('site.fields.rss')\"\n                :hint=\"t('site.hints.rss')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-rss\"\n              />\n            </VCol>\n            <VCol cols=\"6\" md=\"3\">\n              <VTextField\n                v-model=\"siteForm.timeout\"\n                :label=\"t('site.fields.timeout')\"\n                :hint=\"t('site.hints.timeout')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-timer\"\n              />\n            </VCol>\n            <VCol cols=\"6\" md=\"3\">\n              <VAutocomplete\n                v-model=\"siteForm.downloader\"\n                :label=\"t('site.fields.downloader')\"\n                :items=\"downloaderOptions\"\n                :hint=\"t('site.hints.downloader')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-download\"\n              />\n            </VCol>\n          </VRow>\n          <VTabs v-model=\"siteType\" show-arrows class=\"v-tabs-pill mt-3\">\n            <VTab value=\"cookie\" selected-class=\"v-tab--selected\">\n              <div>\n                <VIcon size=\"20\" start icon=\"mdi-cookie\" />\n                Cookie\n              </div>\n            </VTab>\n            <VTab value=\"api\" selected-class=\"v-tab--selected\">\n              <div>\n                <VIcon size=\"20\" start icon=\"mdi-api\" />\n                API\n              </div>\n            </VTab>\n          </VTabs>\n          <VWindow v-model=\"siteType\" class=\"my-3 disable-tab-transition\" :touch=\"false\">\n            <VWindowItem value=\"cookie\">\n              <VRow>\n                <VCol cols=\"12\">\n                  <VTextarea\n                    v-model=\"siteForm.cookie\"\n                    :label=\"t('site.fields.cookie')\"\n                    :hint=\"t('site.hints.cookie')\"\n                    persistent-hint\n                    prepend-inner-icon=\"mdi-cookie\"\n                  />\n                </VCol>\n                <VCol cols=\"12\">\n                  <VTextField\n                    v-model=\"siteForm.ua\"\n                    :label=\"t('site.fields.userAgent')\"\n                    :hint=\"t('site.hints.userAgent')\"\n                    persistent-hint\n                    prepend-inner-icon=\"mdi-web-box\"\n                  />\n                </VCol>\n              </VRow>\n            </VWindowItem>\n            <VWindowItem value=\"api\">\n              <VRow>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"siteForm.token\"\n                    :label=\"t('site.fields.authorization')\"\n                    :hint=\"t('site.hints.authorization')\"\n                    persistent-hint\n                    prepend-inner-icon=\"mdi-key\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"siteForm.apikey\"\n                    :label=\"t('site.fields.apiKey')\"\n                    :hint=\"t('site.hints.apiKey')\"\n                    persistent-hint\n                    prepend-inner-icon=\"mdi-api\"\n                  />\n                </VCol>\n              </VRow>\n            </VWindowItem>\n          </VWindow>\n          <VRow>\n            <VCol cols=\"12\" md=\"4\">\n              <VSwitch v-model=\"isLimit\" :label=\"t('site.fields.limitAccess')\" />\n            </VCol>\n          </VRow>\n          <VRow v-if=\"isLimit\">\n            <VCol cols=\"12\" md=\"4\">\n              <VTextField\n                v-model=\"siteForm.limit_interval\"\n                :label=\"t('site.fields.limitInterval')\"\n                :rules=\"[numberValidator]\"\n                :hint=\"t('site.hints.limitInterval')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-clock-outline\"\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"4\">\n              <VTextField\n                v-model=\"siteForm.limit_count\"\n                :label=\"t('site.fields.limitCount')\"\n                :rules=\"[numberValidator]\"\n                :hint=\"t('site.hints.limitCount')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-counter\"\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"4\">\n              <VTextField\n                v-model=\"siteForm.limit_seconds\"\n                :label=\"t('site.fields.limitSeconds')\"\n                :rules=\"[numberValidator]\"\n                :hint=\"t('site.hints.limitSeconds')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-timer-sand\"\n              />\n            </VCol>\n          </VRow>\n          <VRow>\n            <VCol cols=\"12\" md=\"6\">\n              <VSwitch\n                v-model=\"siteForm.proxy\"\n                :label=\"t('site.fields.useProxy')\"\n                :hint=\"t('site.hints.useProxy')\"\n                persistent-hint\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"6\">\n              <VSwitch\n                v-model=\"siteForm.render\"\n                :label=\"t('site.fields.browserSimulation')\"\n                :hint=\"t('site.hints.browserSimulation')\"\n                persistent-hint\n              />\n            </VCol>\n          </VRow>\n        </VForm>\n      </VCardText>\n      <VCardActions class=\"pt-3\">\n        <VSpacer />\n        <VBtn v-if=\"props.oper === 'add'\" color=\"primary\" @click=\"addSite\" prepend-icon=\"mdi-plus\" class=\"px-5\">\n          {{ t('site.actions.add') }}\n        </VBtn>\n        <VBtn v-else color=\"primary\" @click=\"updateSiteInfo\" prepend-icon=\"mdi-content-save\" class=\"px-5\">\n          {{ t('common.save') }}\n        </VBtn>\n      </VCardActions>\n    </VCard>\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/components/dialog/SiteCookieUpdateDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport api from '@/api'\nimport { Site } from '@/api/types'\nimport { requiredValidator } from '@/@validators'\nimport { useToast } from 'vue-toastification'\nimport ProgressDialog from '../dialog/ProgressDialog.vue'\nimport { useI18n } from 'vue-i18n'\n\n// 国际化\nconst { t } = useI18n()\n\n// 输入参数\nconst cardProps = defineProps({\n  site: Object as PropType<Site>,\n})\n\n// 定义触发的自定义事件\nconst emit = defineEmits(['close', 'done'])\n\n// 提示框\nconst $toast = useToast()\n\n// 用户名密码表单\nconst userPwForm = ref({\n  username: '',\n  password: '',\n  code: '',\n})\n\n// 密码输入\nconst isPasswordVisible = ref(false)\n\n// 更新按钮可用性\nconst updateButtonDisable = ref(false)\n\n// 进度条\nconst progressDialog = ref(false)\n\n// 进度文本\nconst progressText = ref(t('dialog.siteCookieUpdate.processing'))\n\n// 调用API，更新站点Cookie UA\nasync function updateSiteCookie() {\n  try {\n    if (!userPwForm.value.username || !userPwForm.value.password) return\n\n    // 更新按钮状态\n    updateButtonDisable.value = true\n\n    progressDialog.value = true\n    progressText.value = t('dialog.siteCookieUpdate.updating', { site: cardProps.site?.name })\n\n    const result: { [key: string]: any } = await api.get(`site/cookie/${cardProps.site?.id}`, {\n      params: {\n        username: userPwForm.value.username,\n        password: userPwForm.value.password,\n        code: userPwForm.value.code,\n      },\n    })\n\n    if (result.success) {\n      $toast.success(t('dialog.siteCookieUpdate.success', { site: cardProps.site?.name }))\n      emit('done')\n    } else $toast.error(t('dialog.siteCookieUpdate.failed', { site: cardProps.site?.name, message: result.message }))\n\n    progressDialog.value = false\n    updateButtonDisable.value = false\n  } catch (error) {\n    console.error(error)\n  }\n}\n</script>\n<template>\n  <VDialog max-width=\"30rem\" scrollable>\n    <!-- Dialog Content -->\n    <VCard :title=\"t('dialog.siteCookieUpdate.title')\">\n      <VDialogCloseBtn @click=\"emit('close')\" />\n      <VDivider />\n      <VCardText>\n        <VForm @submit.prevent=\"() => {}\">\n          <VRow>\n            <VCol cols=\"12\">\n              <VTextField v-model=\"userPwForm.username\" :label=\"t('login.username')\" :rules=\"[requiredValidator]\" />\n            </VCol>\n            <VCol cols=\"12\">\n              <VTextField\n                v-model=\"userPwForm.password\"\n                :label=\"t('login.password')\"\n                :type=\"isPasswordVisible ? 'text' : 'password'\"\n                :append-inner-icon=\"isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'\"\n                :rules=\"[requiredValidator]\"\n                @click:append-inner=\"isPasswordVisible = !isPasswordVisible\"\n                @keydown.enter=\"updateSiteCookie\"\n              />\n            </VCol>\n            <VCol cols=\"12\">\n              <VTextField v-model=\"userPwForm.code\" :label=\"t('login.otpCode')\" />\n            </VCol>\n          </VRow>\n        </VForm>\n      </VCardText>\n      <VCardActions class=\"mx-auto\">\n        <VBtn\n          size=\"large\"\n          @click=\"updateSiteCookie\"\n          :disabled=\"updateButtonDisable\"\n          :loading=\"updateButtonDisable\"\n          prepend-icon=\"mdi-refresh\"\n          class=\"px-5\"\n        >\n          {{ t('dialog.siteCookieUpdate.updateButton') }}\n        </VBtn>\n      </VCardActions>\n    </VCard>\n    <!-- 进度框 -->\n    <ProgressDialog v-if=\"progressDialog\" v-model=\"progressDialog\" :text=\"progressText\" />\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/components/dialog/SiteImportDialog.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useToast } from 'vue-toastification'\nimport type { Site } from '@/api/types'\nimport { doneNProgress, startNProgress } from '@/api/nprogress'\nimport api from '@/api'\nimport { useDisplay } from 'vuetify'\nimport { useI18n } from 'vue-i18n'\n\n// 国际化\nconst { t } = useI18n()\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 提示框\nconst $toast = useToast()\n\n// 注册事件\nconst emit = defineEmits(['update:modelValue', 'import-success'])\n\n// 界面阶段枚举\nenum ImportStage {\n  SELECT_FILE = 'select_file', // 选择文件阶段\n  PREVIEW_FILE = 'preview_file', // 文件预览阶段\n  IMPORTING = 'importing', // 正在导入阶段\n  IMPORT_COMPLETE = 'import_complete', // 导入完成阶段\n}\n\n// 当前阶段\nconst currentStage = ref<ImportStage>(ImportStage.SELECT_FILE)\n\n// 是否拖拽中\nconst isDragging = ref(false)\n\n// 导入的文件数据\nconst importData = ref<Site[]>([])\n\n// 导入进度\nconst importProgress = ref(0)\n\n// 预览数据\nconst previewData = ref<Site[]>([])\n\n// 选中的文件\nconst selectedFile = ref<File | null>(null)\n\n// 导入错误信息\nconst importErrors = ref<Array<{ site: Site; error: string }>>([])\n\n// 导入成功的站点\nconst importSuccesses = ref<Site[]>([])\n\n// 是否显示错误详情\nconst showErrorDetails = ref(false)\n\n// 处理拖拽事件\nfunction handleDragOver(event: DragEvent) {\n  event.preventDefault()\n  isDragging.value = true\n}\n\nfunction handleDragLeave(event: DragEvent) {\n  event.preventDefault()\n  isDragging.value = false\n}\n\nasync function handleDrop(event: DragEvent) {\n  event.preventDefault()\n  isDragging.value = false\n\n  const files = event.dataTransfer?.files\n  if (files && files.length > 0) {\n    const file = files[0]\n    if (file.type === 'application/json' || file.name.endsWith('.json')) {\n      selectedFile.value = file\n      await processFile(file)\n    } else {\n      $toast.error(t('site.messages.invalidFileType'))\n    }\n  }\n}\n\n// 处理文件\nasync function processFile(file: File) {\n  try {\n    const text = await file.text()\n    const data = JSON.parse(text)\n\n    if (Array.isArray(data)) {\n      importData.value = data\n      previewData.value = data.slice(0, 5) // 只显示前5个站点作为预览\n      currentStage.value = ImportStage.PREVIEW_FILE\n    } else {\n      $toast.error(t('site.messages.invalidFileFormat'))\n    }\n  } catch (error) {\n    console.error('Parse file error:', error)\n    $toast.error(t('site.messages.parseFileError'))\n  }\n}\n\n// 验证站点数据\nfunction validateSiteData(site: any): boolean {\n  const requiredFields = ['name', 'domain', 'url']\n  return requiredFields.every(field => site[field])\n}\n\n// 批量导入站点\nasync function importSites() {\n  if (importData.value.length === 0) {\n    $toast.error(t('site.messages.noDataToImport'))\n    return\n  }\n\n  // 验证数据\n  const validSites = importData.value.filter(validateSiteData)\n  if (validSites.length === 0) {\n    $toast.error(t('site.messages.noValidData'))\n    return\n  }\n\n  if (validSites.length !== importData.value.length) {\n    $toast.warning(t('site.messages.someInvalidData', { valid: validSites.length, total: importData.value.length }))\n  }\n\n  // 进入导入阶段\n  currentStage.value = ImportStage.IMPORTING\n  startNProgress()\n  importProgress.value = 0\n\n  try {\n    let successCount = 0\n    let failCount = 0\n    importErrors.value = [] // 清空之前的错误信息\n    importSuccesses.value = [] // 清空之前的成功信息\n\n    for (let i = 0; i < validSites.length; i++) {\n      const site = validSites[i]\n      try {\n        // 移除id字段，避免冲突\n        const { id, ...siteData } = site\n        const result: { success: boolean; message?: string } = await api.post('site/', siteData)\n        if (result.success) {\n          // 记录成功的站点\n          successCount++\n          importSuccesses.value.push(site)\n        } else {\n          failCount++\n          // 记录失败信息\n          importErrors.value.push({\n            site,\n            error: result.message || t('site.messages.importFailed'),\n          })\n        }\n      } catch (error) {\n        console.error(`Import site ${site.name} failed:`, error)\n        failCount++\n        // 记录错误信息\n        importErrors.value.push({\n          site,\n          error: error instanceof Error ? error.message : t('site.messages.importFailed'),\n        })\n      }\n      // 更新进度\n      importProgress.value = Math.round(((i + 1) / validSites.length) * 100)\n    }\n\n    // 进入完成阶段\n    currentStage.value = ImportStage.IMPORT_COMPLETE\n\n    // 显示导入结果\n    if (failCount === 0 && successCount > 0) {\n      // 全部成功，直接关闭对话框\n      $toast.success(t('site.messages.importSuccess', { count: successCount }))\n      closeDialog(true)\n    } else if (successCount === 0 && failCount > 0) {\n      // 全部失败的情况\n      $toast.error(t('site.messages.importAllFailed', { count: failCount }))\n      showErrorDetails.value = true\n    } else {\n      // 部分成功部分失败的情况\n      $toast.error(t('site.messages.importPartialFailed', { success: successCount, failed: failCount }))\n      showErrorDetails.value = true\n    }\n  } catch (error) {\n    console.error('Import sites failed:', error)\n    $toast.error(t('site.messages.importFailed'))\n    // 出错时回到预览阶段\n    currentStage.value = ImportStage.PREVIEW_FILE\n  } finally {\n    doneNProgress()\n  }\n}\n\n// 重置到文件选择阶段\nfunction resetToFileSelection() {\n  currentStage.value = ImportStage.SELECT_FILE\n  importData.value = []\n  previewData.value = []\n  importProgress.value = 0\n  isDragging.value = false\n  selectedFile.value = null\n  importErrors.value = []\n  importSuccesses.value = []\n  showErrorDetails.value = false\n}\n\n// 关闭对话框\nfunction closeDialog(success: boolean = false) {\n  if (success) {\n    emit('import-success')\n  }\n  emit('update:modelValue', false)\n}\n\n// 监听文件选择\nwatch(selectedFile, async newFile => {\n  if (newFile) {\n    await processFile(newFile)\n  }\n})\n</script>\n\n<template>\n  <VDialog scrollable max-width=\"50rem\" :fullscreen=\"!display.mdAndUp.value\">\n    <VCard>\n      <VCardItem class=\"py-2\">\n        <template #prepend>\n          <VIcon icon=\"mdi-upload\" class=\"me-2\" />\n        </template>\n        <VCardTitle>{{ t('site.actions.import') }}</VCardTitle>\n        <VCardSubtitle>{{ t('site.hints.import') }}</VCardSubtitle>\n      </VCardItem>\n      <VDialogCloseBtn @click=\"closeDialog\" />\n      <VDivider />\n      <VCardText>\n        <!-- 阶段1：选择文件阶段 -->\n        <div v-if=\"currentStage === ImportStage.SELECT_FILE\" class=\"upload-area\">\n          <div\n            class=\"upload-zone\"\n            :class=\"{ 'dragging': isDragging }\"\n            @dragover=\"handleDragOver\"\n            @dragleave=\"handleDragLeave\"\n            @drop=\"handleDrop\"\n          >\n            <VFileInput\n              v-model=\"selectedFile\"\n              accept=\".json\"\n              :label=\"t('site.fields.selectFile')\"\n              :hint=\"t('site.hints.selectFile')\"\n              persistent-hint\n              prepend-icon=\"mdi-file-upload\"\n            />\n            <div class=\"text-center mt-4\">\n              <VIcon icon=\"mdi-cloud-upload\" size=\"48\" color=\"primary\" />\n              <p class=\"text-body-1 mt-2\">{{ t('site.hints.dragDropFile') }}</p>\n              <p class=\"text-caption text-medium-emphasis\">{{ t('site.hints.supportedFormat') }}</p>\n            </div>\n          </div>\n        </div>\n\n        <!-- 阶段2：文件预览阶段 -->\n        <div v-if=\"currentStage === ImportStage.PREVIEW_FILE\" class=\"preview-area\">\n          <VAlert\n            type=\"info\"\n            variant=\"tonal\"\n            class=\"mb-4\"\n            :text=\"t('site.messages.previewData', { count: importData.length })\"\n          />\n\n          <!-- 预览列表 -->\n          <VCard variant=\"outlined\" class=\"mb-4\">\n            <VCardTitle class=\"text-subtitle-1\">\n              {{ t('site.preview.title') }} ({{\n                t('site.preview.showing', { count: previewData.length, total: importData.length })\n              }})\n            </VCardTitle>\n            <VCardText>\n              <VList>\n                <VListItem\n                  v-for=\"(site, index) in previewData\"\n                  :key=\"index\"\n                  :class=\"{ 'border-error': !validateSiteData(site) }\"\n                >\n                  <template #prepend>\n                    <VIcon\n                      :icon=\"validateSiteData(site) ? 'mdi-check-circle' : 'mdi-alert-circle'\"\n                      :color=\"validateSiteData(site) ? 'success' : 'error'\"\n                    />\n                  </template>\n                  <VListItemTitle>{{ site.name || t('site.preview.unnamed') }}</VListItemTitle>\n                  <VListItemSubtitle>{{ site.url || t('site.preview.noUrl') }}</VListItemSubtitle>\n                  <template #append>\n                    <VChip v-if=\"!validateSiteData(site)\" size=\"small\" color=\"error\" variant=\"tonal\">\n                      {{ t('site.preview.invalid') }}\n                    </VChip>\n                  </template>\n                </VListItem>\n              </VList>\n            </VCardText>\n          </VCard>\n\n          <!-- 操作按钮 -->\n          <div class=\"d-flex justify-end gap-2\">\n            <VBtn variant=\"text\" @click=\"resetToFileSelection\">\n              {{ t('common.reset') }}\n            </VBtn>\n            <VBtn color=\"primary\" @click=\"importSites\" :disabled=\"importData.length === 0\">\n              {{ t('site.actions.startImport') }}\n            </VBtn>\n          </div>\n        </div>\n\n        <!-- 阶段3：正在导入阶段 -->\n        <div v-if=\"currentStage === ImportStage.IMPORTING\" class=\"importing-area\">\n          <VAlert\n            type=\"info\"\n            variant=\"tonal\"\n            class=\"mb-4\"\n            :text=\"t('site.messages.importing', { progress: importProgress })\"\n          />\n\n          <!-- 导入进度 -->\n          <VCard variant=\"outlined\" class=\"mb-4\">\n            <VCardTitle class=\"text-subtitle-1\">\n              {{ t('site.messages.importing', { progress: importProgress }) }}\n            </VCardTitle>\n            <VCardText>\n              <VProgressLinear v-model=\"importProgress\" color=\"primary\" height=\"8\" rounded class=\"mb-2\" />\n              <p class=\"text-caption text-center\">{{ importProgress }}%</p>\n            </VCardText>\n          </VCard>\n        </div>\n\n        <!-- 阶段4：导入完成阶段 -->\n        <div v-if=\"currentStage === ImportStage.IMPORT_COMPLETE\" class=\"result-area\">\n          <!-- 成功导入的站点 -->\n          <div v-if=\"importSuccesses.length > 0\" class=\"success-sites mb-4\">\n            <VAlert\n              type=\"success\"\n              variant=\"tonal\"\n              class=\"mb-4\"\n              :text=\"t('site.messages.importSuccess', { count: importSuccesses.length })\"\n            />\n          </div>\n\n          <!-- 错误详情 -->\n          <div v-if=\"showErrorDetails && importErrors.length > 0\" class=\"error-details\">\n            <VAlert\n              type=\"error\"\n              variant=\"tonal\"\n              class=\"mb-4\"\n              :text=\"t('site.messages.importErrors', { count: importErrors.length })\"\n            />\n\n            <VCard variant=\"outlined\" class=\"mb-4\">\n              <VCardTitle class=\"text-subtitle-1 d-flex align-center justify-space-between\">\n                {{ t('site.errors.title') }}\n              </VCardTitle>\n              <!-- 错误信息详情 -->\n              <VExpansionPanels class=\"mt-4\">\n                <VExpansionPanel v-for=\"(error, index) in importErrors\" :key=\"index\">\n                  <VExpansionPanelTitle>\n                    {{ error.site.name || t('site.preview.unnamed') }} - {{ t('site.errors.details') }}\n                  </VExpansionPanelTitle>\n                  <VExpansionPanelText>\n                    <VAlert type=\"error\" variant=\"text\" :text=\"error.error\" class=\"mb-0\" />\n                  </VExpansionPanelText>\n                </VExpansionPanel>\n              </VExpansionPanels>\n            </VCard>\n          </div>\n\n          <!-- 操作按钮 -->\n          <div class=\"d-flex justify-end gap-2\">\n            <VBtn variant=\"text\" @click=\"resetToFileSelection\">\n              {{ t('common.reset') }}\n            </VBtn>\n            <VBtn color=\"primary\" @click=\"closeDialog(false)\">\n              {{ t('common.close') }}\n            </VBtn>\n          </div>\n        </div>\n      </VCardText>\n    </VCard>\n  </VDialog>\n</template>\n\n<style scoped>\n.upload-area {\n  padding: 2rem;\n}\n\n.upload-zone {\n  padding: 2rem;\n  border: 2px dashed #ccc;\n  border-radius: 8px;\n  text-align: center;\n  transition: all 0.3s ease;\n}\n\n.upload-zone.dragging {\n  border-color: rgb(var(--v-theme-primary));\n  background-color: rgba(var(--v-theme-primary), 0.05);\n}\n\n.error-details {\n  margin-block: 1rem;\n  margin-inline: 0;\n}\n\n.error-details .v-expansion-panels {\n  background: transparent;\n}\n\n.border-success {\n  border-inline-start: 4px solid rgb(var(--v-theme-success));\n}\n\n.border-error {\n  border-inline-start: 4px solid rgb(var(--v-theme-error));\n}\n</style>\n"
  },
  {
    "path": "src/components/dialog/SiteResourceDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport api from '@/api'\nimport type { Site, TorrentInfo, SiteCategory } from '@/api/types'\nimport { formatFileSize } from '@core/utils/formatters'\nimport { useDisplay } from 'vuetify'\nimport AddDownloadDialog from '../dialog/AddDownloadDialog.vue'\nimport { useI18n } from 'vue-i18n'\n\n// 国际化\nconst { t, locale } = useI18n()\n\n// 响应式断点\nconst display = useDisplay()\n\n// 输入参数\nconst props = defineProps({\n  site: Object as PropType<Site>,\n})\n\n// 关键字\nconst keyword = ref<string>()\n\n// 选择分类\nconst selectCategory = ref<number[]>([])\n\n// 全部分类\nconst siteCategoryList = ref<SiteCategory[]>()\n\n// 注册事件\nconst emit = defineEmits(['close'])\n\n// 数据列表\nconst resourceDataList = ref<TorrentInfo[]>([])\n\n// 每页条数\nconst resourceItemsPerPage = ref(25)\n\n// 当前页\nconst resourcePage = ref(1)\n\n// 加载状态\nconst resourceLoading = ref(false)\n\n// 移动端搜索栏是否展开\nconst mobileSearchExpanded = ref(false)\n\n// 种子元数据\nconst torrent = ref<TorrentInfo>()\n\n// 添加下载对话框\nconst addDownloadDialog = ref(false)\n\n// 分类选项\nconst categoryOptions = computed(() => {\n  return siteCategoryList.value?.map(item => {\n    return { title: item.desc, value: item.id }\n  })\n})\n\n// 总条数\nconst resourceTotalItems = computed(() => resourceDataList.value.length)\n\n// 资源浏览表头\nconst resourceHeaders = computed(() => [\n  { title: t('dialog.siteResource.titleColumn'), key: 'title', sortable: false },\n  { title: t('dialog.siteResource.timeColumn'), key: 'pubdate', sortable: true },\n  { title: t('dialog.siteResource.sizeColumn'), key: 'size', sortable: true },\n  { title: t('dialog.siteResource.seedersColumn'), key: 'seeders', sortable: true },\n  { title: t('dialog.siteResource.peersColumn'), key: 'peers', sortable: true },\n  { title: '', key: 'actions', sortable: false },\n])\n\n// 输入框标签\nconst keywordFieldLabel = computed(() => {\n  return keyword.value ? '' : t('dialog.siteResource.searchKeyword')\n})\n\nconst categoryFieldLabel = computed(() => {\n  return selectCategory.value.length > 0 ? '' : t('dialog.siteResource.resourceCategory')\n})\n\n// 结果统计文案\nconst resultSummaryText = computed(() => {\n  if (locale.value.startsWith('zh')) {\n    return `共 ${resourceTotalItems.value} 条结果`\n  }\n\n  return `${resourceTotalItems.value} results`\n})\n\n// 是否小屏幕\nconst isMobileLayout = computed(() => display.smAndDown.value)\n\n// 移动端分页数据\nconst mobileResourceList = computed(() => resourceDataList.value)\n\n// 打开种子详情页面\nfunction openTorrentDetail(page_url: string) {\n  if (!page_url) return\n  window.open(page_url, '_blank')\n}\n\n// 下载种子文件\nasync function downloadTorrentFile(enclosure: string) {\n  if (!enclosure) return\n  window.open(enclosure, '_blank')\n}\n\n// 促销Chip类\nfunction getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {\n  if (downloadVolume === 0) return 'text-white bg-lime-500'\n  if (downloadVolume < 1) return 'text-white bg-green-500'\n  if (uploadVolume !== 1) return 'text-white bg-sky-500'\n\n  return 'text-white bg-gray-500'\n}\n\n// 添加下载\nasync function addDownload(_torrent: TorrentInfo) {\n  torrent.value = _torrent\n  addDownloadDialog.value = true\n}\n\n// 添加下载成功\nfunction addDownloadSuccess(_url: string) {\n  addDownloadDialog.value = false\n}\n\n// 添加下载失败\nfunction addDownloadError(_error: string) {\n  addDownloadDialog.value = false\n}\n\n// 调用API，查询站点资源\nasync function getResourceList() {\n  resourceLoading.value = true\n  resourcePage.value = 1\n\n  try {\n    resourceDataList.value = await api.get(`site/resource/${props.site?.id}`, {\n      params: {\n        keyword: keyword.value,\n        cat: selectCategory.value?.join(','),\n      },\n    })\n  } catch (error) {\n    console.error(error)\n  }\n\n  resourceLoading.value = false\n\n  if (isMobileLayout.value) {\n    mobileSearchExpanded.value = false\n  }\n}\n\n// 加载站点分类\nasync function getSiteCategoryList() {\n  try {\n    siteCategoryList.value = await api.get(`site/category/${props.site?.id}`)\n  } catch (error) {\n    console.error(error)\n  }\n}\n\nwatch([resourceItemsPerPage, resourceTotalItems, () => display.mdAndUp.value], () => {\n  if (display.mdAndUp.value) {\n    const maxPage = Math.max(1, Math.ceil(resourceTotalItems.value / resourceItemsPerPage.value))\n    if (resourcePage.value > maxPage) {\n      resourcePage.value = maxPage\n    }\n\n    return\n  }\n})\n\nwatch(\n  () => display.mdAndUp.value,\n  isDesktop => {\n    if (isDesktop) {\n      mobileSearchExpanded.value = false\n    }\n  },\n)\n\nfunction toggleMobileSearch() {\n  mobileSearchExpanded.value = !mobileSearchExpanded.value\n}\n\nfunction closeMobileSearch() {\n  mobileSearchExpanded.value = false\n}\n\n// 装载时查询站点分类和资源\nonMounted(() => {\n  getSiteCategoryList()\n  getResourceList()\n})\n</script>\n\n<template>\n  <VDialog scrollable :fullscreen=\"display.smAndDown.value\" max-width=\"92rem\" transition=\"dialog-bottom-transition\">\n    <VCard class=\"site-resource-dialog\">\n      <div>\n        <VToolbar color=\"primary\" density=\"comfortable\">\n          <VToolbarTitle>{{ t('dialog.siteResource.browseTitle', { name: props.site?.name }) }}</VToolbarTitle>\n          <VSpacer />\n          <VToolbarItems>\n            <VBtn icon @click=\"emit('close')\" class=\"me-3\">\n              <VIcon size=\"large\" color=\"white\" icon=\"ri-close-line\" />\n            </VBtn>\n          </VToolbarItems>\n        </VToolbar>\n      </div>\n\n      <div class=\"pa-3 pb-2\">\n        <template v-if=\"!isMobileLayout\">\n          <VSheet class=\"site-resource-filter-panel\" rounded=\"lg\" border>\n            <div class=\"site-resource-filter-panel__inner\">\n              <VRow class=\"site-resource-filter-row\">\n                <VCol cols=\"12\" md=\"4\">\n                  <VTextField\n                    v-model=\"keyword\"\n                    class=\"site-resource-filter-input\"\n                    size=\"small\"\n                    density=\"compact\"\n                    variant=\"solo-filled\"\n                    flat\n                    :label=\"keywordFieldLabel\"\n                    clearable\n                    prepend-inner-icon=\"mdi-magnify\"\n                    hide-details\n                    @keyup.enter=\"getResourceList\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"5\">\n                  <VSelect\n                    v-model=\"selectCategory\"\n                    :items=\"categoryOptions\"\n                    class=\"site-resource-filter-input\"\n                    size=\"small\"\n                    density=\"compact\"\n                    variant=\"solo-filled\"\n                    flat\n                    chips\n                    :label=\"categoryFieldLabel\"\n                    multiple\n                    clearable\n                    prepend-inner-icon=\"mdi-folder\"\n                    hide-details\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"3\" class=\"d-flex align-center\">\n                  <VBtn\n                    color=\"primary\"\n                    variant=\"flat\"\n                    block\n                    size=\"default\"\n                    rounded=\"lg\"\n                    prepend-icon=\"mdi-magnify\"\n                    class=\"site-resource-search-btn\"\n                    @click=\"getResourceList\"\n                  >\n                    {{ t('dialog.siteResource.search') }}\n                  </VBtn>\n                </VCol>\n              </VRow>\n\n              <div\n                v-if=\"resourceTotalItems > 0\"\n                class=\"d-flex justify-space-between align-center flex-wrap gap-2 mt-3\"\n              >\n                <div class=\"text-body-2 text-medium-emphasis\">\n                  {{ resultSummaryText }}\n                </div>\n                <VChip size=\"small\" color=\"primary\" variant=\"tonal\" class=\"site-resource-result-chip\">\n                  {{ resourceTotalItems }}\n                </VChip>\n              </div>\n            </div>\n          </VSheet>\n        </template>\n\n        <template v-else>\n          <div class=\"site-resource-mobile-search\">\n            <VBtn\n              icon\n              variant=\"text\"\n              color=\"primary\"\n              class=\"site-resource-mobile-search__toggle\"\n              @click=\"toggleMobileSearch\"\n            >\n              <VIcon icon=\"mdi-magnify\" />\n            </VBtn>\n            <div v-if=\"resourceTotalItems > 0\" class=\"text-body-2 text-medium-emphasis\">\n              {{ resultSummaryText }}\n            </div>\n          </div>\n\n          <VExpandTransition>\n            <div v-if=\"mobileSearchExpanded\" class=\"mt-2\">\n              <VSheet class=\"site-resource-filter-panel\" rounded=\"lg\" border>\n                <div class=\"site-resource-filter-panel__inner\">\n                  <VRow class=\"site-resource-filter-row\">\n                    <VCol cols=\"12\">\n                      <VTextField\n                        v-model=\"keyword\"\n                        class=\"site-resource-filter-input\"\n                        size=\"small\"\n                        density=\"compact\"\n                        variant=\"solo-filled\"\n                        flat\n                        :label=\"keywordFieldLabel\"\n                        clearable\n                        prepend-inner-icon=\"mdi-magnify\"\n                        hide-details\n                        autofocus\n                        @keyup.enter=\"getResourceList\"\n                      />\n                    </VCol>\n                    <VCol cols=\"12\">\n                      <VSelect\n                        v-model=\"selectCategory\"\n                        :items=\"categoryOptions\"\n                        class=\"site-resource-filter-input\"\n                        size=\"small\"\n                        density=\"compact\"\n                        variant=\"solo-filled\"\n                        flat\n                        chips\n                        :label=\"categoryFieldLabel\"\n                        multiple\n                        clearable\n                        prepend-inner-icon=\"mdi-folder\"\n                        hide-details\n                      />\n                    </VCol>\n                    <VCol cols=\"12\" class=\"d-flex gap-2\">\n                      <VBtn color=\"primary\" variant=\"flat\" block rounded=\"lg\" class=\"site-resource-search-btn\" @click=\"getResourceList\">\n                        {{ t('dialog.siteResource.search') }}\n                      </VBtn>\n                      <VBtn variant=\"text\" rounded=\"lg\" @click=\"closeMobileSearch\">\n                        {{ t('common.cancel') }}\n                      </VBtn>\n                    </VCol>\n                  </VRow>\n                </div>\n              </VSheet>\n            </div>\n          </VExpandTransition>\n        </template>\n      </div>\n\n      <VCardText class=\"site-resource-content px-0 py-0 my-0\">\n        <VDataTable\n          v-if=\"display.mdAndUp.value\"\n          v-model:page=\"resourcePage\"\n          v-model:items-per-page=\"resourceItemsPerPage\"\n          :headers=\"resourceHeaders\"\n          :items=\"resourceDataList\"\n          :items-length=\"resourceTotalItems\"\n          :loading=\"resourceLoading\"\n          density=\"compact\"\n          item-value=\"title\"\n          return-object\n          fixed-header\n          hover\n          :items-per-page-text=\"t('dialog.siteResource.itemsPerPage')\"\n          :loading-text=\"t('dialog.siteResource.loading')\"\n          :items-per-page-options=\"[10, 25, 50, 100]\"\n          height=\"100%\"\n          class=\"h-full site-resource-table\"\n        >\n          <template #item.title=\"{ item }\">\n            <button type=\"button\" class=\"site-resource-title-btn text-start\" @click.stop=\"addDownload(item)\">\n              <div class=\"text-high-emphasis pt-1 font-weight-medium\">\n                {{ item.title }}\n              </div>\n              <div v-if=\"item.description\" class=\"text-sm my-1 text-medium-emphasis\">\n                {{ item.description }}\n              </div>\n              <div class=\"mt-2\">\n                <VChip v-if=\"item.hit_and_run\" variant=\"elevated\" size=\"small\" class=\"me-1 mb-1 text-white bg-black\">\n                  H&amp;R\n                </VChip>\n                <VChip v-if=\"item.freedate_diff\" variant=\"elevated\" color=\"secondary\" size=\"small\" class=\"me-1 mb-1\">\n                  {{ item.freedate_diff }}\n                </VChip>\n                <VChip\n                  v-for=\"(label, index) in item.labels\"\n                  :key=\"index\"\n                  variant=\"elevated\"\n                  size=\"small\"\n                  color=\"primary\"\n                  class=\"me-1 mb-1\"\n                >\n                  {{ label }}\n                </VChip>\n                <VChip\n                  v-if=\"item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1\"\n                  :class=\"getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)\"\n                  variant=\"elevated\"\n                  size=\"small\"\n                  class=\"me-1 mb-1\"\n                >\n                  {{ item.volume_factor }}\n                </VChip>\n              </div>\n            </button>\n          </template>\n\n          <template #item.pubdate=\"{ item }\">\n            <div>{{ item.date_elapsed }}</div>\n            <div class=\"text-sm text-medium-emphasis\">\n              {{ item.pubdate }}\n            </div>\n          </template>\n\n          <template #item.size=\"{ item }\">\n            <div class=\"text-nowrap whitespace-nowrap\">\n              {{ formatFileSize(item.size) }}\n            </div>\n          </template>\n\n          <template #item.seeders=\"{ item }\">\n            <div>{{ item.seeders }}</div>\n          </template>\n\n          <template #item.peers=\"{ item }\">\n            <div>{{ item.peers }}</div>\n          </template>\n\n          <template #item.actions=\"{ item }\">\n            <div class=\"me-n3\">\n              <IconBtn>\n                <VIcon icon=\"mdi-dots-vertical\" />\n                <VMenu activator=\"parent\" close-on-content-click>\n                  <VList>\n                    <VListItem @click=\"openTorrentDetail(item.page_url || '')\">\n                      <template #prepend>\n                        <VIcon icon=\"mdi-information\" />\n                      </template>\n                      <VListItemTitle>{{ t('dialog.siteResource.viewDetails') }}</VListItemTitle>\n                    </VListItem>\n                    <VListItem v-if=\"item.enclosure?.startsWith('http')\" @click=\"downloadTorrentFile(item.enclosure)\">\n                      <template #prepend>\n                        <VIcon icon=\"mdi-download\" />\n                      </template>\n                      <VListItemTitle>{{ t('dialog.siteResource.downloadTorrent') }}</VListItemTitle>\n                    </VListItem>\n                  </VList>\n                </VMenu>\n              </IconBtn>\n            </div>\n          </template>\n\n          <template #no-data>{{ t('dialog.siteResource.noData') }}</template>\n        </VDataTable>\n\n        <div v-else class=\"site-resource-mobile\">\n          <div v-if=\"resourceLoading\" class=\"px-4 py-6\">\n            <VProgressLinear color=\"primary\" indeterminate rounded />\n            <div class=\"text-center text-body-2 text-medium-emphasis mt-3\">\n              {{ t('dialog.siteResource.loading') }}\n            </div>\n          </div>\n\n          <div v-else-if=\"mobileResourceList.length > 0\" class=\"px-3 pb-4\">\n            <VCard\n              v-for=\"(item, index) in mobileResourceList\"\n              :key=\"item.page_url || item.enclosure || `${item.title}-${index}`\"\n              class=\"mb-3\"\n            >\n              <VCardText class=\"pa-4\">\n                <button type=\"button\" class=\"site-resource-title-btn text-start\" @click=\"addDownload(item)\">\n                  <div class=\"text-body-1 font-weight-medium text-high-emphasis\">\n                    {{ item.title }}\n                  </div>\n                  <div\n                    v-if=\"item.description\"\n                    class=\"site-resource-card__description mt-2 text-body-2 text-medium-emphasis\"\n                  >\n                    {{ item.description }}\n                  </div>\n                </button>\n\n                <div class=\"mt-3\">\n                  <VChip v-if=\"item.hit_and_run\" variant=\"elevated\" size=\"small\" class=\"me-1 mb-1 text-white bg-black\">\n                    H&amp;R\n                  </VChip>\n                  <VChip v-if=\"item.freedate_diff\" variant=\"elevated\" color=\"secondary\" size=\"small\" class=\"me-1 mb-1\">\n                    {{ item.freedate_diff }}\n                  </VChip>\n                  <VChip\n                    v-for=\"(label, chipIndex) in item.labels\"\n                    :key=\"chipIndex\"\n                    variant=\"elevated\"\n                    size=\"small\"\n                    color=\"primary\"\n                    class=\"me-1 mb-1\"\n                  >\n                    {{ label }}\n                  </VChip>\n                  <VChip\n                    v-if=\"item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1\"\n                    :class=\"getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)\"\n                    variant=\"elevated\"\n                    size=\"small\"\n                    class=\"me-1 mb-1\"\n                  >\n                    {{ item.volume_factor }}\n                  </VChip>\n                </div>\n\n                <div class=\"site-resource-card__meta mt-4\">\n                  <div class=\"site-resource-card__meta-item\">\n                    <div class=\"text-caption text-medium-emphasis\">{{ t('dialog.siteResource.timeColumn') }}</div>\n                    <div class=\"text-body-2 font-weight-medium\">{{ item.date_elapsed || item.pubdate || '-' }}</div>\n                    <div v-if=\"item.pubdate\" class=\"text-caption text-medium-emphasis mt-1\">{{ item.pubdate }}</div>\n                  </div>\n                  <div class=\"site-resource-card__meta-item\">\n                    <div class=\"text-caption text-medium-emphasis\">{{ t('dialog.siteResource.sizeColumn') }}</div>\n                    <div class=\"text-body-2 font-weight-medium\">{{ formatFileSize(item.size) }}</div>\n                  </div>\n                  <div class=\"site-resource-card__meta-item\">\n                    <div class=\"text-caption text-medium-emphasis\">{{ t('dialog.siteResource.seedersColumn') }}</div>\n                    <div class=\"text-body-2 font-weight-medium\">{{ item.seeders }}</div>\n                  </div>\n                  <div class=\"site-resource-card__meta-item\">\n                    <div class=\"text-caption text-medium-emphasis\">{{ t('dialog.siteResource.peersColumn') }}</div>\n                    <div class=\"text-body-2 font-weight-medium\">{{ item.peers }}</div>\n                  </div>\n                </div>\n\n                <div class=\"site-resource-card__actions mt-4\">\n                  <VBtn color=\"primary\" variant=\"flat\" block prepend-icon=\"mdi-download\" @click=\"addDownload(item)\">\n                    {{ t('actionStep.addDownload') }}\n                  </VBtn>\n                  <div class=\"site-resource-card__secondary-actions mt-2\">\n                    <VBtn\n                      variant=\"tonal\"\n                      prepend-icon=\"mdi-open-in-new\"\n                      @click=\"openTorrentDetail(item.page_url || '')\"\n                    >\n                      {{ t('common.viewDetails') }}\n                    </VBtn>\n                    <VBtn\n                      v-if=\"item.enclosure?.startsWith('http')\"\n                      variant=\"tonal\"\n                      prepend-icon=\"mdi-tray-arrow-down\"\n                      @click=\"downloadTorrentFile(item.enclosure)\"\n                    >\n                      {{ t('dialog.siteResource.downloadTorrent') }}\n                    </VBtn>\n                  </div>\n                </div>\n              </VCardText>\n            </VCard>\n\n          </div>\n\n          <div v-else class=\"px-4 py-10 text-center text-medium-emphasis\">\n            {{ t('dialog.siteResource.noData') }}\n          </div>\n        </div>\n      </VCardText>\n    </VCard>\n\n    <AddDownloadDialog\n      v-if=\"addDownloadDialog\"\n      v-model=\"addDownloadDialog\"\n      :torrent=\"torrent\"\n      @done=\"addDownloadSuccess\"\n      @error=\"addDownloadError\"\n      @close=\"addDownloadDialog = false\"\n    />\n  </VDialog>\n</template>\n\n<style lang=\"scss\" scoped>\n.site-resource-dialog {\n  display: flex;\n  flex-direction: column;\n  overflow: hidden;\n}\n\n.site-resource-filter-row {\n  align-items: center;\n}\n\n.site-resource-filter-panel {\n  border-color: rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.9));\n  background:\n    radial-gradient(circle at top left, rgba(var(--v-theme-primary), 0.06), transparent 40%),\n    linear-gradient(180deg, rgba(var(--v-theme-surface), 0.98), rgba(var(--v-theme-surface), 0.93));\n  box-shadow: 0 10px 24px rgba(15, 23, 42, 4%);\n}\n\n.site-resource-filter-panel__inner {\n  padding: 0.75rem 0.85rem;\n}\n\n.site-resource-filter-input :deep(.v-field) {\n  border-radius: 0.75rem;\n  background: rgba(var(--v-theme-surface), 0.92);\n  box-shadow: inset 0 0 0 1px rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.8));\n}\n\n.site-resource-filter-input :deep(.v-field__prepend-inner) {\n  color: rgba(var(--v-theme-primary), 0.85);\n}\n\n.site-resource-search-btn {\n  box-shadow: 0 8px 18px rgba(var(--v-theme-primary), 0.18);\n  letter-spacing: 0.02em;\n  min-block-size: 40px;\n}\n\n.site-resource-result-chip {\n  font-weight: 600;\n}\n\n.site-resource-mobile-search {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 0.75rem;\n}\n\n.site-resource-mobile-search__toggle {\n  flex: 0 0 auto;\n}\n\n.site-resource-title-btn {\n  padding: 0;\n  border: 0;\n  background: transparent;\n  cursor: pointer;\n  inline-size: 100%;\n}\n\n.site-resource-content {\n  flex: 1 1 auto;\n  min-block-size: 0;\n  overflow: hidden;\n}\n\n.site-resource-table {\n  block-size: 100%;\n}\n\n.site-resource-table :deep(.v-data-table) {\n  display: flex;\n  flex-direction: column;\n  block-size: 100%;\n}\n\n.site-resource-table :deep(.v-data-table__wrapper) {\n  flex: 1 1 auto;\n  min-block-size: 0;\n}\n\n.site-resource-table :deep(.v-table__wrapper) {\n  flex: 1 1 auto;\n  min-block-size: 0;\n}\n\n.site-resource-table :deep(.v-data-table-footer) {\n  flex: 0 0 auto;\n}\n\n.v-table th {\n  white-space: nowrap;\n}\n\n.site-resource-card__description {\n  display: -webkit-box;\n  overflow: hidden;\n  -webkit-box-orient: vertical;\n  -webkit-line-clamp: 3;\n}\n\n.site-resource-card__meta {\n  display: grid;\n  gap: 0.55rem;\n  grid-template-columns: repeat(2, minmax(0, 1fr));\n}\n\n.site-resource-card__meta-item {\n  border: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.7));\n  border-radius: 0.6rem;\n  background: rgba(var(--v-theme-surface), 0.78);\n  min-block-size: 0;\n  padding-block: 0.55rem;\n  padding-inline: 0.65rem;\n}\n\n.site-resource-card__meta-item :deep(.text-caption) {\n  font-size: 0.72rem !important;\n  line-height: 1.2;\n}\n\n.site-resource-card__meta-item :deep(.text-body-2) {\n  font-size: 0.82rem !important;\n  line-height: 1.25;\n}\n\n.site-resource-card__secondary-actions {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 0.5rem;\n}\n\n.site-resource-card__secondary-actions :deep(.v-btn) {\n  flex: 1 1 12rem;\n}\n\n@media (width >= 960px) {\n  .site-resource-dialog {\n    block-size: min(88vh, 960px);\n  }\n}\n\n@media (width <= 959px) {\n  .site-resource-dialog {\n    border-radius: 0;\n  }\n\n  .site-resource-filter-panel__inner {\n    padding: 0.7rem 0.75rem;\n  }\n\n  .site-resource-mobile-search {\n    min-block-size: 2.5rem;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/dialog/SiteStatisticsDialog.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { PropType } from 'vue'\nimport api from '@/api'\nimport type { Site, SiteStatistic } from '@/api/types'\nimport { useI18n } from 'vue-i18n'\nimport { useDisplay } from 'vuetify'\n\n// 国际化\nconst { t } = useI18n()\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 输入参数\nconst props = defineProps({\n  sites: {\n    type: Array as PropType<Site[]>,\n    default: () => [],\n  },\n})\n\n// 定义触发的自定义事件\nconst emit = defineEmits(['update:modelValue'])\n\n// 站点统计数据\nconst siteStats = ref<SiteStatistic[]>([])\n\n// 是否加载中\nconst loading = ref(false)\n\n// 当前选中的站点\nconst selectedSite = ref<Site | null>(null)\n\n// 耗时记录详情弹窗\nconst detailDialog = ref(false)\n\n// 获取站点统计数据\nasync function fetchSiteStats() {\n  try {\n    loading.value = true\n    const response = await api.get('site/statistic')\n    siteStats.value = Array.isArray(response) ? response : response.data || []\n    loading.value = false\n  } catch (error) {\n    console.error('Failed to fetch site statistics:', error)\n    loading.value = false\n  }\n}\n\n// 根据站点域名获取统计数据\nfunction getSiteStats(domain: string): SiteStatistic | undefined {\n  return siteStats.value.find(stat => stat.domain === domain)\n}\n\n// 获取站点连接状态\nfunction getConnectionStatus(stats: SiteStatistic | undefined): string {\n  if (!stats || Object.keys(stats).length === 0) {\n    return 'unknown'\n  }\n  if (stats.lst_state === 1) {\n    return 'failed'\n  } else if (stats.lst_state === 0) {\n    if (!stats.seconds) return 'unknown'\n    if (stats.seconds >= 5) return 'slow'\n    return 'connected'\n  }\n  return 'unknown'\n}\n\n// 获取状态颜色\nfunction getStatusColor(status: string): string {\n  switch (status) {\n    case 'connected':\n      return 'success'\n    case 'slow':\n      return 'warning'\n    case 'failed':\n      return 'error'\n    default:\n      return 'secondary'\n  }\n}\n\n// 获取状态图标\nfunction getStatusIcon(status: string): string {\n  switch (status) {\n    case 'connected':\n      return 'mdi-wifi'\n    case 'slow':\n      return 'mdi-wifi-strength-2'\n    case 'failed':\n      return 'mdi-wifi-off'\n    default:\n      return 'mdi-help-circle'\n  }\n}\n\n// 获取状态文本\nfunction getStatusText(status: string): string {\n  switch (status) {\n    case 'connected':\n      return t('site.connectionNormal')\n    case 'slow':\n      return t('site.connectionSlow')\n    case 'failed':\n      return t('site.connectionFailed')\n    default:\n      return t('site.connectionUnknown')\n  }\n}\n\n// 获取耗时颜色\nfunction getTimeColor(seconds: number | undefined): string {\n  if (!seconds) return 'secondary'\n  if (seconds < 2) return 'success'\n  if (seconds < 5) return 'warning'\n  return 'error'\n}\n\n// 获取成功率（与列表/概览口径一致）\nfunction getSuccessRate(stats: SiteStatistic | undefined): string {\n  if (!stats) return '-'\n  const success = Number(stats.success ?? 0)\n  const fail = Number(stats.fail ?? 0)\n  const total = success + fail\n  if (total <= 0) return '-'\n  return String(Math.round((success / total) * 100))\n}\n\n// 解析耗时记录\nfunction parseTimeRecords(note: any): Array<{ time: string; duration: number }> {\n  if (!note) return []\n\n  try {\n    // note可能是字符串或对象，如果是字符串则解析\n    const records = typeof note === 'string' ? JSON.parse(note) : note\n\n    if (typeof records === 'object' && records !== null) {\n      const result = Object.entries(records)\n        .map(([time, duration]) => ({\n          time,\n          duration: Number(duration) || 0,\n        }))\n        .sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime())\n        .slice(0, 10) // 只显示最近10条记录\n\n      return result\n    }\n  } catch (error) {\n    console.error('Failed to parse time records:', error)\n  }\n\n  return []\n}\n\n// 查看详情\nfunction viewDetail(site: Site) {\n  selectedSite.value = site\n  detailDialog.value = true\n}\n\n// 关闭弹窗\nfunction closeDialog() {\n  emit('update:modelValue', false)\n}\n\n// 计算属性：按平均耗时排序的站点列表\nconst sortedSites = computed(() => {\n  return props.sites\n    .map(site => {\n      const stats = getSiteStats(site.domain)\n      return {\n        site,\n        stats,\n        status: getConnectionStatus(stats),\n        avgTime: stats?.seconds || 0,\n      }\n    })\n    .sort((a, b) => {\n      // 先按状态排序：connected > slow > failed > unknown\n      const statusOrder = { connected: 0, slow: 1, failed: 2, unknown: 3 }\n      const statusDiff =\n        statusOrder[a.status as keyof typeof statusOrder] - statusOrder[b.status as keyof typeof statusOrder]\n      if (statusDiff !== 0) return statusDiff\n\n      // 再按平均耗时排序\n      return a.avgTime - b.avgTime\n    })\n})\n\n// 统计总览（与列表口径一致）\nconst overviewCounts = computed(() => {\n  const items = sortedSites.value\n  const total = items.length\n  const connected = items.filter(i => i.status === 'connected').length\n  const slow = items.filter(i => i.status === 'slow').length\n  const failed = items.filter(i => i.status === 'failed').length\n  const unknown = total - connected - slow - failed\n  return { total, connected, slow, failed, unknown }\n})\n\nonMounted(() => {\n  fetchSiteStats()\n})\n</script>\n\n<template>\n  <VDialog max-width=\"50rem\" :fullscreen=\"display.smAndDown.value\" scrollable>\n    <VCard>\n      <!-- 标题栏 -->\n      <VCardItem>\n        <VDialogCloseBtn @click=\"closeDialog\" />\n        <template #prepend>\n          <VIcon icon=\"mdi-chart-line\" class=\"me-2\" />\n        </template>\n        <VCardTitle>\n          {{ t('site.statistics') }}\n        </VCardTitle>\n      </VCardItem>\n      <VDivider />\n      <!-- 内容区域 -->\n      <VCardText class=\"pa-0\">\n        <LoadingBanner v-if=\"loading\" class=\"my-8\" />\n\n        <div v-else class=\"site-statistics-content\">\n          <!-- 统计概览 -->\n          <div class=\"statistics-overview pa-4\">\n            <div class=\"d-flex flex-wrap gap-4\">\n              <div class=\"stat-card\">\n                <div class=\"stat-number\">{{ overviewCounts.total }}</div>\n                <div class=\"stat-label\">{{ t('site.totalSites') }}</div>\n              </div>\n              <div class=\"stat-card\">\n                <div class=\"stat-number success--text\">{{ overviewCounts.connected }}</div>\n                <div class=\"stat-label\">{{ t('site.normalSites') }}</div>\n              </div>\n              <div class=\"stat-card\">\n                <div class=\"stat-number warning--text\">{{ overviewCounts.slow }}</div>\n                <div class=\"stat-label\">{{ t('site.slowSites') }}</div>\n              </div>\n              <div class=\"stat-card\">\n                <div class=\"stat-number error--text\">{{ overviewCounts.failed }}</div>\n                <div class=\"stat-label\">{{ t('site.failedSites') }}</div>\n              </div>\n            </div>\n          </div>\n\n          <!-- 站点列表 -->\n          <div class=\"sites-list\">\n            <div\n              v-for=\"item in sortedSites\"\n              :key=\"item.site.id\"\n              class=\"site-item pa-4 border-b\"\n              :class=\"`border-${getStatusColor(item.status)}`\"\n            >\n              <div class=\"d-flex align-center justify-space-between\">\n                <!-- 左侧：站点信息 -->\n                <div class=\"d-flex align-center flex-1 min-w-0\">\n                  <!-- 状态指示器 -->\n                  <div class=\"status-indicator me-3\" :class=\"getStatusColor(item.status)\">\n                    <VIcon :icon=\"getStatusIcon(item.status)\" size=\"20\" />\n                  </div>\n\n                  <!-- 站点名称和状态 -->\n                  <div class=\"flex-1 min-w-0\">\n                    <div class=\"d-flex align-center\">\n                      <h4 class=\"text-h6 mb-1 truncate\">{{ item.site.name }}</h4>\n                      <VChip :color=\"getStatusColor(item.status)\" size=\"small\" class=\"ml-2\" variant=\"tonal\">\n                        {{ getStatusText(item.status) }}\n                      </VChip>\n                    </div>\n                    <div class=\"text-caption text-medium-emphasis\">{{ item.site.domain }}</div>\n                  </div>\n                </div>\n\n                <!-- 右侧：统计信息 -->\n                <div class=\"d-flex align-center gap-4\">\n                  <!-- 平均耗时 -->\n                  <div class=\"text-center\">\n                    <div class=\"text-h6 font-weight-bold\" :class=\"`text-${getTimeColor(item.stats?.seconds)}`\">\n                      {{ item.stats?.seconds || '-' }}s\n                    </div>\n                    <div class=\"text-caption text-medium-emphasis\">{{ t('site.averageTime') }}</div>\n                  </div>\n\n                  <!-- 成功率 -->\n                  <div class=\"text-center\">\n                    <div class=\"text-h6 font-weight-bold\">{{ getSuccessRate(item.stats) }}%</div>\n                    <div class=\"text-caption text-medium-emphasis\">{{ t('site.successRate') }}</div>\n                  </div>\n\n                  <!-- 详情按钮 -->\n                  <VBtn icon variant=\"text\" size=\"small\" @click=\"viewDetail(item.site)\">\n                    <VIcon icon=\"mdi-information-outline\" />\n                  </VBtn>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </VCardText>\n    </VCard>\n\n    <!-- 详情弹窗 -->\n    <VDialog v-model=\"detailDialog\" :max-width=\"display.mdAndUp.value ? 600 : '95%'\" scrollable>\n      <VCard v-if=\"selectedSite\">\n        <VCardItem class=\"py-3\">\n          <template #prepend>\n            <VIcon icon=\"mdi-information-outline\" class=\"me-2\" />\n          </template>\n          <VCardTitle> {{ selectedSite.name }} - {{ t('site.timeRecords') }} </VCardTitle>\n          <VDialogCloseBtn @click=\"detailDialog = false\" />\n        </VCardItem>\n        <VDivider />\n        <VCardText>\n          <div v-if=\"getSiteStats(selectedSite.domain)\">\n            <div class=\"mb-4\">\n              <h5 class=\"text-h6 mb-2\">{{ t('site.statistics') }}</h5>\n              <div class=\"d-flex flex-wrap gap-4\">\n                <div class=\"stat-item\">\n                  <span class=\"stat-label\">{{ t('site.successCount') }}:</span>\n                  <span class=\"stat-value success--text\">\n                    {{ getSiteStats(selectedSite.domain)?.success || 0 }}\n                  </span>\n                </div>\n                <div class=\"stat-item\">\n                  <span class=\"stat-label\">{{ t('site.failCount') }}:</span>\n                  <span class=\"stat-value error--text\">\n                    {{ getSiteStats(selectedSite.domain)?.fail || 0 }}\n                  </span>\n                </div>\n                <div class=\"stat-item\">\n                  <span class=\"stat-label\">{{ t('site.averageTime') }}:</span>\n                  <span class=\"stat-value\" :class=\"`text-${getTimeColor(getSiteStats(selectedSite.domain)?.seconds)}`\">\n                    {{ getSiteStats(selectedSite.domain)?.seconds || '-' }}s\n                  </span>\n                </div>\n                <div class=\"stat-item\">\n                  <span class=\"stat-label\">{{ t('site.lastAccess') }}:</span>\n                  <span class=\"stat-value\">\n                    {{ getSiteStats(selectedSite.domain)?.lst_mod_date || '-' }}\n                  </span>\n                </div>\n              </div>\n            </div>\n\n            <div>\n              <h5 class=\"text-h6 mb-2\">{{ t('site.recentTimeRecords') }}</h5>\n              <div class=\"time-records\">\n                <div\n                  v-for=\"(record, index) in parseTimeRecords(getSiteStats(selectedSite.domain)?.note)\"\n                  :key=\"index\"\n                  class=\"time-record-item pa-3 border rounded mb-2\"\n                  :class=\"`border-${getTimeColor(record.duration)}`\"\n                >\n                  <div class=\"d-flex justify-space-between align-center\">\n                    <div>\n                      <div class=\"text-body-2 font-weight-medium\">{{ record.time }}</div>\n                      <div class=\"text-caption text-medium-emphasis\">{{ t('site.accessTime') }}</div>\n                    </div>\n                    <div class=\"text-end\">\n                      <div class=\"text-h6 font-weight-bold\" :class=\"`text-${getTimeColor(record.duration)}`\">\n                        {{ record.duration }}s\n                      </div>\n                      <div class=\"text-caption text-medium-emphasis\">{{ t('site.responseTime') }}</div>\n                    </div>\n                  </div>\n                </div>\n\n                <div\n                  v-if=\"parseTimeRecords(getSiteStats(selectedSite.domain)?.note).length === 0\"\n                  class=\"text-center pa-4\"\n                >\n                  <VIcon icon=\"mdi-information-outline\" size=\"48\" color=\"secondary\" class=\"mb-2\" />\n                  <div class=\"text-body-1 text-medium-emphasis\">{{ t('site.noTimeRecords') }}</div>\n                </div>\n              </div>\n            </div>\n          </div>\n        </VCardText>\n      </VCard>\n    </VDialog>\n  </VDialog>\n</template>\n\n<style scoped>\n.statistics-overview {\n  background: linear-gradient(135deg, var(--v-theme-surface) 0%, var(--v-theme-surface-variant) 100%);\n  border-block-end: 1px solid var(--v-border-color);\n}\n\n.stat-card {\n  padding: 16px;\n  border: 1px solid var(--v-border-color);\n  border-radius: 8px;\n  background: var(--v-theme-surface);\n  min-inline-size: 100px;\n  text-align: center;\n}\n\n.stat-number {\n  font-size: 24px;\n  font-weight: bold;\n  line-height: 1;\n  margin-block-end: 4px;\n}\n\n.stat-label {\n  color: var(--v-theme-on-surface-variant);\n  font-size: 12px;\n}\n\n.sites-list {\n  background: var(--v-theme-surface);\n}\n\n.site-item {\n  transition: background-color 0.2s ease;\n}\n\n.site-item:hover {\n  background: var(--v-theme-surface-variant);\n}\n\n.status-indicator {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border-radius: 50%;\n  background: var(--v-theme-surface-variant);\n  block-size: 40px;\n  inline-size: 40px;\n}\n\n.status-indicator.success {\n  background: rgba(var(--v-theme-success), 0.1);\n  color: rgb(var(--v-theme-success));\n}\n\n.status-indicator.warning {\n  background: rgba(var(--v-theme-warning), 0.1);\n  color: rgb(var(--v-theme-warning));\n}\n\n.status-indicator.error {\n  background: rgba(var(--v-theme-error), 0.1);\n  color: rgb(var(--v-theme-error));\n}\n\n.status-indicator.secondary {\n  background: rgba(var(--v-theme-secondary), 0.1);\n  color: rgb(var(--v-theme-secondary));\n}\n\n.stat-item {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.stat-item .stat-label {\n  color: var(--v-theme-on-surface-variant);\n  font-weight: 500;\n}\n\n.stat-value {\n  font-weight: bold;\n}\n\n.time-records {\n  max-block-size: 300px;\n  overflow-y: auto;\n}\n\n.time-record-item {\n  transition: all 0.2s ease;\n}\n</style>\n"
  },
  {
    "path": "src/components/dialog/SiteUserDataDialog.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { Site, SiteUserData } from '@/api/types'\nimport api from '@/api'\nimport { useDisplay, useTheme } from 'vuetify'\nimport { formatFileSize } from '@/@core/utils/formatters'\nimport ProgressDialog from '@/components/dialog/ProgressDialog.vue'\nimport { useI18n } from 'vue-i18n'\n\n// 多语言支持\nconst { t } = useI18n()\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 输入参数\nconst props = defineProps({\n  site: Object as PropType<Site>,\n})\n\n// 注册事件\nconst emit = defineEmits(['close'])\n\n// 进度框\nconst progressDialog = ref(false)\n\nconst vuetifyTheme = useTheme()\n\nconst currentTheme = controlledComputed(\n  () => vuetifyTheme.name.value,\n  () => vuetifyTheme.current.value.colors,\n)\n\n// 站点数据列表\nconst siteDatas = ref<SiteUserData[]>([])\n\n// 最新一天的数据\nconst siteData = computed(() => siteDatas.value[siteDatas.value.length - 1])\n\n// 站点数据列表中的上传量、下载量数据生成图形使用的数据\nconst historySeries = computed(() => {\n  return [\n    {\n      name: t('dialog.siteUserData.uploadTitle'),\n      data: siteDatas.value.map(item => Math.round((item.upload ?? 0) / 1024 / 1024 / 1024)),\n    },\n    {\n      name: t('dialog.siteUserData.downloadTitle'),\n      data: siteDatas.value.map(item => Math.round((item.download ?? 0) / 1024 / 1024 / 1024)),\n    },\n  ]\n})\n\n// 图形选项\nconst historyChartOptions = computed(() => {\n  return {\n    chart: {\n      type: 'area',\n      parentHeightOffset: 0,\n      toolbar: { show: false },\n      animations: { enabled: true },\n      background: currentTheme.value.surface, // 新增背景色同步\n      foreColor: currentTheme.value.onSurface, // 新增文字颜色同步\n      dataLabels: {\n        enabled: true,\n      },\n      zoom: {\n        autoScaleYaxis: true,\n      },\n    },\n    theme: {\n      mode: vuetifyTheme.global.current.value.dark ? 'dark' : 'light', // 同步主题模式\n    },\n    tooltip: {\n      enabled: true,\n      tooltip: {\n        x: {\n          format: 'dd MMM yyyy',\n        },\n      },\n      style: {\n        background: currentTheme.value.background, // 提示框背景色同步\n        color: currentTheme.value.onBackground, // 文字颜色同步\n      },\n    },\n    grid: {\n      xaxis: {\n        lines: { show: false },\n      },\n      yaxis: {\n        title: {\n          text: 'GB',\n        },\n        lines: { show: true },\n      },\n    },\n    stroke: {\n      width: 3,\n      lineCap: 'butt',\n      curve: 'smooth',\n    },\n    colors: [currentTheme.value.success, currentTheme.value.warning],\n    markers: {\n      size: 0,\n      style: 'hollow',\n    },\n    xaxis: {\n      type: 'category',\n      categories: siteDatas.value.map(item => item.updated_day),\n      labels: {\n        show: true,\n        formatter: function (val: string) {\n          return new Date(val).toLocaleDateString('zh-CN')\n        },\n      },\n    },\n    yaxis: {\n      title: {\n        text: 'GB',\n      },\n      labels: {\n        formatter: function (val: number) {\n          return val.toLocaleString()\n        },\n      },\n    },\n    fill: {\n      type: 'gradient',\n      gradient: {\n        shadeIntensity: 1,\n        opacityFrom: 0.5,\n        opacityTo: 0.7,\n        stops: [0, 100],\n      },\n    },\n  }\n})\n\n// 做种分布列，seeding_info的格式为[[x, y], [x, y], ...]，x为做种数，y为做种体积，做种体积需要转换为GB\nconst seedingSeries = computed(() => {\n  return [\n    {\n      name: t('dialog.siteUserData.volumeTitle'),\n      data: siteData.value?.seeding_info?.map(item => [item[0] ?? 0, Math.round((item[1] ?? 0) / 1024 / 1024 / 1024)]),\n    },\n  ]\n})\n\n// 做种分布图形选项\nconst seedingChartOptions = computed(() => {\n  return {\n    chart: {\n      type: 'scatter',\n      parentHeightOffset: 0,\n      toolbar: { show: false },\n      animations: { enabled: true },\n      background: currentTheme.value.surface, // 新增背景色同步\n      foreColor: currentTheme.value.onSurface, // 新增文字颜色同步\n      zoom: {\n        autoScaleYaxis: true,\n      },\n    },\n    theme: {\n      mode: vuetifyTheme.global.current.value.dark ? 'dark' : 'light', // 同步主题模式\n    },\n    tooltip: {\n      enabled: true,\n      x: {\n        formatter: function (val: number) {\n          return t('dialog.siteUserData.countTitle') + val.toLocaleString()\n        },\n      },\n      style: {\n        background: currentTheme.value.background, // 提示框背景色同步\n        color: currentTheme.value.onBackground, // 文字颜色同步\n      },\n    },\n    grid: {\n      xaxis: {\n        lines: { show: true },\n      },\n      yaxis: {\n        lines: { show: true },\n      },\n    },\n    colors: [currentTheme.value.primary],\n    xaxis: {\n      type: 'numeric',\n      labels: {\n        show: true,\n        formatter: function (val: number) {\n          return Math.round(val).toLocaleString()\n        },\n      },\n      title: {\n        text: t('dialog.siteUserData.countTitle'),\n      },\n      tickAmount: 10,\n    },\n    yaxis: {\n      title: {\n        text: 'GB',\n      },\n      labels: {\n        formatter: function (val: number) {\n          return val.toLocaleString() + ' GB'\n        },\n      },\n    },\n  }\n})\n\n// 根据传入属性，计算列表数据中第一条与第二条的差值，如果没有第二条则差值为全部\nconst diffData: { [key: string]: any } = computed(() => {\n  if (siteDatas.value.length < 2) {\n    return siteData.value\n  }\n  const first = siteDatas.value[siteDatas.value.length - 1]\n  const second = siteDatas.value[siteDatas.value.length - 2]\n  return {\n    bonus: (first.bonus ?? 0) - (second.bonus ?? 0),\n    ratio: (first.ratio ?? 0) - (second.ratio ?? 0),\n    upload: (first.upload ?? 0) - (second.upload ?? 0),\n    download: (first.download ?? 0) - (second.download ?? 0),\n    seeding: (first.seeding ?? 0) - (second.seeding ?? 0),\n    seeding_size: (first.seeding_size ?? 0) - (second.seeding_size ?? 0),\n  }\n})\n\n// 格式化差值\nfunction getDiffString(diff: number | undefined, format: boolean = true) {\n  if (diff === undefined) {\n    return '0'\n  }\n  if (format) {\n    return diff >= 0 ? `+${diff.toLocaleString()}` : diff.toLocaleString()\n  }\n  return diff >= 0 ? `+${diff}` : diff\n}\n\n// 根据差值的正负，返回不同的样式\nfunction getDiffClass(diff: number | undefined) {\n  if (diff === undefined) {\n    return ''\n  }\n  if (diff == 0) {\n    return ''\n  }\n  return diff > 0 ? 'text-success' : 'text-error'\n}\n\n// 查询站点用户数据\nasync function fetchSiteUserData() {\n  try {\n    const result: { [key: string]: any } = await api.get(`site/userdata/${props.site?.id}`)\n    if (result.success) {\n      // 使用nextTick确保DOM更新完成后再更新图表数据\n      await nextTick()\n      siteDatas.value = result.data.sort((a: { updated_day: any }, b: { updated_day: any }) =>\n        (a.updated_day || '').localeCompare(b.updated_day || ''),\n      )\n    }\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 刷新站点数据\nasync function refreshSiteData() {\n  progressDialog.value = true\n  try {\n    const result: { [key: string]: any } = await api.post(`site/userdata/${props.site?.id}`)\n    if (result.success) {\n      await fetchSiteUserData()\n    }\n  } catch (error) {\n    console.log(error)\n  }\n  progressDialog.value = false\n}\n\nonBeforeMount(() => {\n  // 延迟加载，确保组件完全挂载\n  nextTick(() => {\n    fetchSiteUserData()\n  })\n})\n</script>\n\n<template>\n  <VDialog scrollable eager max-width=\"80rem\" :fullscreen=\"!display.mdAndUp.value\">\n    <VCard>\n      <VCardItem>\n        <VCardTitle>\n          {{ t('dialog.siteUserData.title') }} - {{ props.site?.name }}\n          <IconBtn @click.stop=\"refreshSiteData\" color=\"info\"><VIcon icon=\"mdi-refresh\" /></IconBtn>\n        </VCardTitle>\n        <VDialogCloseBtn @click=\"emit('close')\" />\n      </VCardItem>\n      <VDivider />\n      <VCardText class=\"pt-5\">\n        <VRow class=\"match-height\">\n          <!-- 用户信息 -->\n          <VCol cols=\"12\" md=\"3\">\n            <VCard>\n              <VCardText class=\"d-flex align-center\">\n                <div class=\"d-flex justify-space-between\" style=\"inline-size: 100%\">\n                  <div class=\"d-flex flex-column gap-y-1 overflow-hidden\">\n                    <span class=\"text-base\">{{ t('dialog.siteUserData.userLevel') }}</span>\n                    <h5 class=\"text-h5 d-flex align-center gap-2 text-wrap\">\n                      {{ siteData?.user_level || t('dialog.siteUserData.noData') }}\n                    </h5>\n                  </div>\n                  <VAvatar variant=\"tonal\" size=\"42\" rounded>\n                    <VIcon icon=\"mdi-account\"></VIcon>\n                  </VAvatar>\n                </div>\n              </VCardText>\n            </VCard>\n          </VCol>\n          <!-- 积分 -->\n          <VCol cols=\"12\" md=\"3\">\n            <VCard>\n              <VCardText class=\"d-flex align-center\">\n                <div class=\"d-flex justify-space-between\" style=\"inline-size: 100%\">\n                  <div class=\"d-flex flex-column gap-y-1 overflow-hidden\">\n                    <span class=\"text-base\">{{ t('dialog.siteUserData.bonus') }}</span>\n                    <h5 class=\"text-h5 d-flex align-center gap-2 text-wrap\">\n                      {{ siteData?.bonus?.toLocaleString() }}\n                      <span class=\"text-base font-weight-regular\" :class=\"getDiffClass(diffData?.bonus)\">\n                        ({{ getDiffString(diffData?.bonus) }})\n                      </span>\n                    </h5>\n                  </div>\n                  <VAvatar variant=\"tonal\" size=\"42\" rounded>\n                    <VIcon icon=\"mdi-scoreboard\"></VIcon>\n                  </VAvatar>\n                </div>\n              </VCardText>\n            </VCard>\n          </VCol>\n          <!-- 分享率 -->\n          <VCol cols=\"12\" md=\"3\">\n            <VCard>\n              <VCardText class=\"d-flex align-center\">\n                <div class=\"d-flex justify-space-between\" style=\"inline-size: 100%\">\n                  <div class=\"d-flex flex-column gap-y-1\">\n                    <span class=\"text-base\">{{ t('dialog.siteUserData.ratio') }}</span>\n                    <h5 class=\"text-h5 d-flex align-center gap-2 text-wrap\">\n                      {{ siteData?.ratio }}\n                      <span class=\"text-base font-weight-regular\" :class=\"getDiffClass(diffData?.ratio)\">\n                        ({{ getDiffString(diffData?.ratio) }})\n                      </span>\n                    </h5>\n                  </div>\n                  <VAvatar variant=\"tonal\" size=\"42\" rounded>\n                    <VIcon icon=\"mdi-percent\"></VIcon>\n                  </VAvatar>\n                </div>\n              </VCardText>\n            </VCard>\n          </VCol>\n          <!-- 总上传量 -->\n          <VCol cols=\"12\" md=\"3\">\n            <VCard>\n              <VCardText class=\"d-flex align-center\">\n                <div class=\"d-flex justify-space-between\" style=\"inline-size: 100%\">\n                  <div class=\"d-flex flex-column gap-y-1 overflow-hidden\">\n                    <span class=\"text-base\">{{ t('dialog.siteUserData.uploadTotal') }}</span>\n                    <h5 class=\"text-h5 d-flex align-center gap-2 text-wrap\">\n                      {{ formatFileSize(siteData?.upload || 0) }}\n                      <span class=\"text-base font-weight-regular\" :class=\"getDiffClass(diffData?.upload)\">\n                        ({{ formatFileSize(diffData?.upload || 0, 2, true) }})\n                      </span>\n                    </h5>\n                  </div>\n                  <VAvatar variant=\"tonal\" size=\"42\" rounded>\n                    <VIcon icon=\"mdi-upload\"></VIcon>\n                  </VAvatar>\n                </div>\n              </VCardText>\n            </VCard>\n          </VCol>\n          <!-- 总下载量 -->\n          <VCol cols=\"12\" md=\"3\">\n            <VCard>\n              <VCardText class=\"d-flex align-center\">\n                <div class=\"d-flex justify-space-between\" style=\"inline-size: 100%\">\n                  <div class=\"d-flex flex-column gap-y-1 overflow-hidden\">\n                    <span class=\"text-base\">{{ t('dialog.siteUserData.downloadTotal') }}</span>\n                    <h5 class=\"text-h5 d-flex align-center gap-2 text-wrap\">\n                      {{ formatFileSize(siteData?.download || 0) }}\n                      <span class=\"text-base font-weight-regular\" :class=\"getDiffClass(diffData?.download)\">\n                        ({{ formatFileSize(diffData?.download || 0, 2, true) }})\n                      </span>\n                    </h5>\n                  </div>\n                  <VAvatar variant=\"tonal\" size=\"42\" rounded>\n                    <VIcon icon=\"mdi-download\"></VIcon>\n                  </VAvatar>\n                </div>\n              </VCardText>\n            </VCard>\n          </VCol>\n          <!-- 总做种数 -->\n          <VCol cols=\"12\" md=\"3\">\n            <VCard>\n              <VCardText class=\"d-flex align-center\">\n                <div class=\"d-flex justify-space-between\" style=\"inline-size: 100%\">\n                  <div class=\"d-flex flex-column gap-y-1 overflow-hidden\">\n                    <span class=\"text-base\">{{ t('dialog.siteUserData.seedingCount') }}</span>\n                    <h5 class=\"text-h5 d-flex align-center gap-2 text-wrap\">\n                      {{ siteData?.seeding?.toLocaleString() }}\n                      <span class=\"text-base font-weight-regular\" :class=\"getDiffClass(diffData?.seeding)\">\n                        ({{ getDiffString(diffData?.seeding) }})\n                      </span>\n                    </h5>\n                  </div>\n                  <VAvatar variant=\"tonal\" size=\"42\" rounded>\n                    <VIcon icon=\"mdi-seed\"></VIcon>\n                  </VAvatar>\n                </div>\n              </VCardText>\n            </VCard>\n          </VCol>\n          <!-- 总做种体积 -->\n          <VCol cols=\"12\" md=\"3\">\n            <VCard>\n              <VCardText class=\"d-flex align-center\">\n                <div class=\"d-flex justify-space-between\" style=\"inline-size: 100%\">\n                  <div class=\"d-flex flex-column gap-y-1 overflow-hidden\">\n                    <span class=\"text-base\">{{ t('dialog.siteUserData.seedingSize') }}</span>\n                    <h5 class=\"text-h5 d-flex align-center gap-2 text-wrap\">\n                      {{ formatFileSize(siteData?.seeding_size || 0) }}\n                      <span class=\"text-base font-weight-regular\" :class=\"getDiffClass(diffData?.seeding_size)\">\n                        ({{ formatFileSize(diffData?.seeding_size || 0, 2, true) }})\n                      </span>\n                    </h5>\n                  </div>\n                  <VAvatar variant=\"tonal\" size=\"42\" rounded>\n                    <VIcon icon=\"mdi-database\"></VIcon>\n                  </VAvatar>\n                </div>\n              </VCardText>\n            </VCard>\n          </VCol>\n          <!-- 加入时间 -->\n          <VCol cols=\"12\" md=\"3\">\n            <VCard>\n              <VCardText class=\"d-flex align-center\">\n                <div class=\"d-flex justify-space-between\" style=\"inline-size: 100%\">\n                  <div class=\"d-flex flex-column gap-y-1 overflow-hidden\">\n                    <span class=\"text-base\">{{ t('dialog.siteUserData.joinTime') }}</span>\n                    <h5 class=\"text-h5 d-flex align-center gap-2 text-wrap\">\n                      {{ siteData?.join_at?.split(' ')[0] }}\n                    </h5>\n                  </div>\n                  <VAvatar variant=\"tonal\" size=\"42\" rounded>\n                    <VIcon icon=\"mdi-calendar\"></VIcon>\n                  </VAvatar>\n                </div>\n              </VCardText>\n            </VCard>\n          </VCol>\n        </VRow>\n        <VRow>\n          <VCol>\n            <VCard :title=\"t('dialog.siteUserData.trafficHistory')\">\n              <VCardText>\n                <VApexChart type=\"line\" :options=\"historyChartOptions\" :series=\"historySeries\" :height=\"300\" />\n              </VCardText>\n            </VCard>\n          </VCol>\n        </VRow>\n        <VRow>\n          <VCol>\n            <VCard :title=\"t('dialog.siteUserData.seedingDistribution')\">\n              <VCardText>\n                <VApexChart type=\"scatter\" :options=\"seedingChartOptions\" :series=\"seedingSeries\" :height=\"300\" />\n              </VCardText>\n            </VCard>\n          </VCol>\n        </VRow>\n      </VCardText>\n    </VCard>\n    <!-- 进度框 -->\n    <ProgressDialog v-if=\"progressDialog\" v-model=\"progressDialog\" :text=\"t('dialog.siteUserData.refreshing')\" />\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/components/dialog/SmbConfigDialog.vue",
    "content": "<script lang=\"ts\" setup>\nimport api from '@/api'\nimport { useI18n } from 'vue-i18n'\nimport { useDisplay } from 'vuetify'\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 多语言支持\nconst { t } = useI18n()\n\n// 定义输入\nconst props = defineProps({\n  conf: {\n    type: Object as PropType<{ [key: string]: any }>,\n    required: true,\n  },\n})\n\n// 定义事件\nconst emit = defineEmits(['done', 'close'])\n\n// 完成\nasync function handleDone() {\n  await saveSmbConfig()\n  emit('done')\n}\n\n// 重置配置\nasync function handleReset() {\n  try {\n    const result: { [key: string]: any } = await api.get('/storage/reset/smb')\n    if (result.success) {\n      // 重置成功\n      handleDone()\n    }\n  } catch (e) {\n    console.error(e)\n  }\n}\n\n// 保存 SMB 设置\nasync function saveSmbConfig() {\n  try {\n    await api.post(`storage/save/smb`, props.conf)\n  } catch (e) {\n    console.error(e)\n  }\n}\n</script>\n\n<template>\n  <VDialog width=\"50rem\" scrollable :fullscreen=\"!display.mdAndUp.value\">\n    <VCard>\n      <VDialogCloseBtn @click=\"emit('close')\" />\n      <VCardItem>\n        <template #prepend>\n          <VIcon icon=\"mdi-folder-network-outline\" class=\"me-2\" />\n        </template>\n        <VCardTitle>\n          {{ t('dialog.smbConfig.title') }}\n        </VCardTitle>\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <VRow>\n          <VCol cols=\"12\" md=\"6\">\n            <VTextField\n              v-model=\"props.conf.host\"\n              :hint=\"t('dialog.smbConfig.hostHint')\"\n              :label=\"t('dialog.smbConfig.host')\"\n              persistent-hint\n              prepend-inner-icon=\"mdi-server\"\n              placeholder=\"192.168.1.100\"\n            />\n          </VCol>\n          <VCol cols=\"12\" md=\"6\">\n            <VTextField\n              v-model=\"props.conf.share\"\n              :hint=\"t('dialog.smbConfig.shareHint')\"\n              :label=\"t('dialog.smbConfig.share')\"\n              persistent-hint\n              prepend-inner-icon=\"mdi-folder-network\"\n              placeholder=\"shared_folder\"\n            />\n          </VCol>\n          <VCol cols=\"12\" md=\"6\">\n            <VTextField\n              v-model=\"props.conf.username\"\n              :hint=\"t('dialog.smbConfig.usernameHint')\"\n              :label=\"t('dialog.smbConfig.username')\"\n              persistent-hint\n              prepend-inner-icon=\"mdi-account\"\n              placeholder=\"your_username\"\n            />\n          </VCol>\n          <VCol cols=\"12\" md=\"6\">\n            <VTextField\n              type=\"password\"\n              v-model=\"props.conf.password\"\n              :hint=\"t('dialog.smbConfig.passwordHint')\"\n              :label=\"t('dialog.smbConfig.password')\"\n              persistent-hint\n              prepend-inner-icon=\"mdi-lock\"\n              placeholder=\"your_password\"\n            />\n          </VCol>\n          <VCol cols=\"12\" md=\"6\">\n            <VTextField\n              v-model=\"props.conf.domain\"\n              :hint=\"t('dialog.smbConfig.domainHint')\"\n              :label=\"t('dialog.smbConfig.domain')\"\n              persistent-hint\n              prepend-inner-icon=\"mdi-domain\"\n              placeholder=\"WORKGROUP\"\n            />\n          </VCol>\n        </VRow>\n      </VCardText>\n      <VCardActions>\n        <VBtn color=\"error\" @click=\"handleReset\" prepend-icon=\"mdi-restore\" class=\"px-5 me-3\">\n          {{ t('dialog.smbConfig.reset') }}\n        </VBtn>\n        <VSpacer />\n        <VBtn @click=\"handleDone\" prepend-icon=\"mdi-check\" class=\"px-5 me-3\">\n          {{ t('dialog.smbConfig.complete') }}\n        </VBtn>\n      </VCardActions>\n    </VCard>\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/components/dialog/SubscribeEditDialog.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useToast } from 'vue-toastification'\nimport { numberValidator } from '@/@validators'\nimport api from '@/api'\nimport type { DownloaderConf, FilterRuleGroup, Site, Subscribe, TransferDirectoryConf } from '@/api/types'\nimport { useDisplay } from 'vuetify'\nimport { useConfirm } from '@/composables/useConfirm'\nimport { useI18n } from 'vue-i18n'\nimport { qualityOptions, resolutionOptions, effectOptions } from '@/api/constants'\n// i18n\nconst { t } = useI18n()\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 确认框\nconst createConfirm = useConfirm()\n\n// 输入参数\nconst props = defineProps({\n  subid: Number,\n  default: Boolean,\n  type: String,\n})\n\n// 定义触发的自定义事件\nconst emit = defineEmits(['remove', 'save', 'close'])\n\nconst activeTab = ref('basic')\n\n// 站点数据列表\nconst siteList = ref<Site[]>([])\n\n// 下载目录列表\nconst downloadDirectories = ref<TransferDirectoryConf[]>([])\n\n// 站点选择下载框\nconst selectSitesOptions = ref<{ [key: number]: string }[]>([])\n\n// 所有规则组列表\nconst filterRuleGroups = ref<FilterRuleGroup[]>([])\n\n// 订阅编辑表单\nconst subscribeForm = ref<Subscribe>({\n  id: props.subid ?? 0,\n  name: '',\n  year: '',\n  type: '',\n  tmdbid: 0,\n  state: '',\n  last_update: '',\n  username: '',\n  sites: [],\n  best_version: undefined,\n  current_priority: 0,\n  downloader: '',\n  date: '',\n  show_edit_dialog: false,\n  episode_group: '',\n})\n\n// 提示框\nconst $toast = useToast()\n\n// 下载器选项\nconst downloaderOptions = ref<{ title: string; value: string }[]>([])\n\n// 所有剧集组\nconst episodeGroups = ref<{ [key: string]: any }[]>([])\n\n// 剧集组选项\nconst episodeGroupOptions = computed(() => {\n  return (episodeGroups.value as { id: number; name: string; group_count: number; episode_count: number }[]).map(\n    item => {\n      return {\n        title: item.name,\n        subtitle: `${item.group_count} 季 • ${item.episode_count} 集`,\n        value: item.id,\n      }\n    },\n  )\n})\n\n// 生成1到100季的下拉框选项\nconst seasonItems = ref(\n  Array.from({ length: 101 }, (_, i) => i).map(item => ({\n    title: t('dialog.subscribeEdit.seasonFormat', { number: item }),\n    value: item,\n  })),\n)\n\n// 剧集组选项属性\nfunction episodeGroupItemProps(item: { title: string; subtitle: string }) {\n  return {\n    title: item.title,\n    subtitle: item.subtitle,\n  }\n}\n\n// 查询所有剧集组\nasync function getEpisodeGroups() {\n  if (!subscribeForm.value.tmdbid) {\n    console.warn('tmdbid is not set or is empty')\n    return\n  }\n  try {\n    episodeGroups.value = await api.get(`media/groups/${subscribeForm.value.tmdbid}`)\n  } catch (error) {\n    console.error(error)\n  }\n}\n\nasync function loadDownloaderSetting() {\n  try {\n    const downloaders: DownloaderConf[] = await api.get('download/clients')\n    downloaderOptions.value = [\n      { title: t('common.default'), value: '' },\n      ...downloaders.map((item: { name: any }) => ({\n        title: item.name,\n        value: item.name,\n      })),\n    ]\n  } catch (error) {\n    console.error('加载下载器设置失败:', error)\n  }\n}\n\n// 加载规则组\nasync function queryFilterRuleGroups() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')\n    filterRuleGroups.value = result.data?.value ?? []\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 过滤规则组选择项\nconst filterRuleGroupOptions = computed(() => {\n  return filterRuleGroups.value.map(item => ({\n    title: item.name,\n    value: item.name,\n  }))\n})\n\n// 调用API修改订阅\nasync function updateSubscribeInfo() {\n  try {\n    const result: { [key: string]: any } = await api.put('subscribe/', subscribeForm.value)\n    // 提示\n    if (result.success) {\n      $toast.success(`${subscribeForm.value.name} 更新成功！`)\n      // 通知父组件刷新\n      emit('save')\n    } else {\n      $toast.error(`${subscribeForm.value.name} 更新失败：${result.message}！`)\n    }\n  } catch (e) {\n    console.log(e)\n  }\n}\n\n// 设置用户设置的默认订阅规则\nasync function saveDefaultSubscribeConfig() {\n  try {\n    let subscribe_config_url = ''\n    if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'\n    else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'\n\n    const result: { [key: string]: any } = await api.post(subscribe_config_url, subscribeForm.value)\n    if (result.success) $toast.success(`${props.type}订阅默认规则保存成功`)\n    else $toast.error(`${props.type}订阅默认规则保存失败！`)\n\n    // 通知父组件刷新\n    emit('save')\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 查询用户设置的默认订阅规则\nasync function queryDefaultSubscribeConfig() {\n  try {\n    let subscribe_config_url = ''\n    if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'\n    else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'\n\n    const result: { [key: string]: any } = await api.get(subscribe_config_url)\n\n    if (result.data.value) subscribeForm.value = result.data?.value ?? ''\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 获取站点列表数据\nasync function loadSites() {\n  try {\n    const data: Site[] = await api.get('site/rss')\n\n    // 过滤站点，只有启用的站点才显示\n    siteList.value = data.filter(item => item.is_active)\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 获取站点列表选择框数据\nasync function getSiteList() {\n  // 加载订阅站点列表\n  if (!siteList.value.length) await loadSites()\n\n  const maps = siteList.value.map(item => {\n    return {\n      title: item.name,\n      value: item.id,\n    }\n  })\n\n  selectSitesOptions.value = maps.flat()\n}\n\n// 获取订阅信息\nasync function getSubscribeInfo() {\n  try {\n    const result: Subscribe = await api.get(`subscribe/${props.subid}`)\n    subscribeForm.value = result\n    subscribeForm.value.best_version = subscribeForm.value.best_version === 1\n    subscribeForm.value.search_imdbid = subscribeForm.value.search_imdbid === 1\n    // 加载剧集组\n    if (subscribeForm.value.type == '电视剧') getEpisodeGroups()\n  } catch (e) {\n    console.log(e)\n  }\n}\n\n// 删除订阅\nasync function removeSubscribe() {\n  const isConfirmed = await createConfirm({\n    title: t('common.confirm'),\n    content: t('dialog.subscribeEdit.cancelSubscribeConfirm'),\n  })\n\n  if (!isConfirmed) return\n  try {\n    const result: { [key: string]: any } = await api.delete(`subscribe/${props.subid}`)\n\n    if (result.success) {\n      $toast.success(`订阅 ${subscribeForm.value.name} 已取消！`)\n      // 通知父组件刷新\n      emit('remove')\n    }\n  } catch (e) {\n    console.log(e)\n  }\n}\n\n// 查询下载目录\nasync function loadDownloadDirectories() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/Directories')\n    if (result.success && result.data?.value) {\n      downloadDirectories.value = result.data.value\n    }\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 保存目录下拉框\nconst targetDirectories = computed(() => {\n  // 去重后的下载目录\n  return downloadDirectories.value.map(item => item.download_path)\n})\n\nonMounted(() => {\n  queryFilterRuleGroups()\n  loadDownloadDirectories()\n  getSiteList()\n  loadDownloaderSetting()\n  if (props.subid) getSubscribeInfo()\n  if (props.default) queryDefaultSubscribeConfig()\n})\n</script>\n\n<template>\n  <VDialog scrollable max-width=\"45rem\" :fullscreen=\"!display.mdAndUp.value\">\n    <VCard>\n      <VCardItem class=\"py-2\">\n        <VDialogCloseBtn @click=\"emit('close')\" />\n        <template #prepend>\n          <VIcon icon=\"mdi-clipboard-list-outline\" class=\"me-2\" />\n        </template>\n        <VCardTitle>\n          {{ props.default ? t('dialog.subscribeEdit.titleDefault') : t('dialog.subscribeEdit.titleEdit') }}\n        </VCardTitle>\n        <VCardSubtitle v-if=\"!props.default\">\n          {{ subscribeForm.name }}\n          <span v-if=\"subscribeForm.season\">\n            {{ t('dialog.subscribeEdit.seasonFormat', { number: subscribeForm.season }) }}\n          </span>\n        </VCardSubtitle>\n        <VCardSubtitle v-else>\n          {{ props.type }}\n        </VCardSubtitle>\n      </VCardItem>\n      <VCardText>\n        <VForm @submit.prevent=\"() => {}\">\n          <VTabs v-model=\"activeTab\" show-arrows>\n            <VTab value=\"basic\">\n              <div>{{ t('dialog.subscribeEdit.tabs.basic') }}</div>\n            </VTab>\n            <VTab value=\"advance\">\n              <div>{{ t('dialog.subscribeEdit.tabs.advance') }}</div>\n            </VTab>\n          </VTabs>\n          <VWindow v-model=\"activeTab\" class=\"mt-5 disable-tab-transition\" :touch=\"false\">\n            <VWindowItem value=\"basic\">\n              <div>\n                <VRow v-if=\"!props.default\">\n                  <VCol cols=\"12\" md=\"4\">\n                    <VTextField\n                      v-model=\"subscribeForm.keyword\"\n                      :label=\"t('dialog.subscribeEdit.searchKeyword')\"\n                      :hint=\"t('dialog.subscribeEdit.searchKeywordHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-magnify\"\n                    />\n                  </VCol>\n                  <VCol v-if=\"subscribeForm.type === '电视剧'\" cols=\"12\" md=\"4\">\n                    <VTextField\n                      v-model=\"subscribeForm.total_episode\"\n                      :label=\"t('dialog.subscribeEdit.totalEpisode')\"\n                      :rules=\"[numberValidator]\"\n                      :hint=\"t('dialog.subscribeEdit.totalEpisodeHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-playlist-play\"\n                    />\n                  </VCol>\n                  <VCol v-if=\"subscribeForm.type === '电视剧'\" cols=\"12\" md=\"4\">\n                    <VTextField\n                      v-model=\"subscribeForm.start_episode\"\n                      :label=\"t('dialog.subscribeEdit.startEpisode')\"\n                      :rules=\"[numberValidator]\"\n                      :hint=\"t('dialog.subscribeEdit.startEpisodeHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-play-circle-outline\"\n                    />\n                  </VCol>\n                </VRow>\n                <VRow>\n                  <VCol cols=\"12\" md=\"4\">\n                    <VAutocomplete\n                      v-model=\"subscribeForm.quality\"\n                      :label=\"t('dialog.subscribeEdit.quality')\"\n                      :items=\"qualityOptions\"\n                      :hint=\"t('dialog.subscribeEdit.qualityHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-quality-high\"\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"4\">\n                    <VAutocomplete\n                      v-model=\"subscribeForm.resolution\"\n                      :label=\"t('dialog.subscribeEdit.resolution')\"\n                      :items=\"resolutionOptions\"\n                      :hint=\"t('dialog.subscribeEdit.resolutionHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-monitor\"\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"4\">\n                    <VAutocomplete\n                      v-model=\"subscribeForm.effect\"\n                      :label=\"t('dialog.subscribeEdit.effect')\"\n                      :items=\"effectOptions\"\n                      :hint=\"t('dialog.subscribeEdit.effectHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-auto-fix\"\n                    />\n                  </VCol>\n                </VRow>\n                <VRow>\n                  <VCol cols=\"12\">\n                    <VAutocomplete\n                      v-model=\"subscribeForm.sites\"\n                      :items=\"selectSitesOptions\"\n                      chips\n                      :label=\"t('dialog.subscribeEdit.subscribeSites')\"\n                      multiple\n                      clearable\n                      :hint=\"t('dialog.subscribeEdit.subscribeSitesHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-web\"\n                    />\n                  </VCol>\n                </VRow>\n                <VRow>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VAutocomplete\n                      v-model=\"subscribeForm.downloader\"\n                      :items=\"downloaderOptions\"\n                      :label=\"t('dialog.subscribeEdit.downloader')\"\n                      :hint=\"t('dialog.subscribeEdit.downloaderHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-download\"\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VCombobox\n                      v-model=\"subscribeForm.save_path\"\n                      :items=\"targetDirectories\"\n                      :label=\"t('dialog.subscribeEdit.savePath')\"\n                      :hint=\"t('dialog.subscribeEdit.savePathHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-folder\"\n                    />\n                  </VCol>\n                </VRow>\n                <VRow>\n                  <VCol cols=\"12\" md=\"4\">\n                    <VSwitch\n                      v-model=\"subscribeForm.best_version\"\n                      :label=\"t('dialog.subscribeEdit.bestVersion')\"\n                      :hint=\"t('dialog.subscribeEdit.bestVersionHint')\"\n                      persistent-hint\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"4\">\n                    <VSwitch\n                      v-model=\"subscribeForm.search_imdbid\"\n                      :label=\"t('dialog.subscribeEdit.searchImdbid')\"\n                      :hint=\"t('dialog.subscribeEdit.searchImdbidHint')\"\n                      persistent-hint\n                    />\n                  </VCol>\n                  <VCol v-if=\"props.default\" cols=\"12\" md=\"4\">\n                    <VSwitch\n                      v-model=\"subscribeForm.show_edit_dialog\"\n                      :label=\"t('dialog.subscribeEdit.showEditDialog')\"\n                      :hint=\"t('dialog.subscribeEdit.showEditDialogHint')\"\n                      persistent-hint\n                    />\n                  </VCol>\n                </VRow>\n              </div>\n            </VWindowItem>\n            <VWindowItem value=\"advance\">\n              <div>\n                <VRow>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"subscribeForm.include\"\n                      :label=\"t('dialog.subscribeEdit.include')\"\n                      :hint=\"t('dialog.subscribeEdit.includeHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-plus-circle-outline\"\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"subscribeForm.exclude\"\n                      :label=\"t('dialog.subscribeEdit.exclude')\"\n                      :hint=\"t('dialog.subscribeEdit.excludeHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-minus-circle-outline\"\n                    />\n                  </VCol>\n                </VRow>\n                <VRow>\n                  <VCol cols=\"12\">\n                    <VAutocomplete\n                      v-model=\"subscribeForm.filter_groups\"\n                      :items=\"filterRuleGroupOptions\"\n                      chips\n                      multiple\n                      clearable\n                      :label=\"t('dialog.subscribeEdit.filterGroups')\"\n                      :hint=\"t('dialog.subscribeEdit.filterGroupsHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-filter\"\n                    />\n                  </VCol>\n                  <VCol v-if=\"!props.default && subscribeForm.type === '电视剧'\" cols=\"12\" md=\"6\">\n                    <VAutocomplete\n                      v-model=\"subscribeForm.episode_group\"\n                      :items=\"episodeGroupOptions\"\n                      :item-props=\"episodeGroupItemProps\"\n                      :label=\"t('dialog.subscribeEdit.episodeGroup')\"\n                      :hint=\"t('dialog.subscribeEdit.episodeGroupHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-view-list\"\n                    />\n                  </VCol>\n                  <VCol v-if=\"!props.default && subscribeForm.type === '电视剧'\" cols=\"12\" md=\"6\">\n                    <VAutocomplete\n                      v-model=\"subscribeForm.season\"\n                      :items=\"seasonItems\"\n                      :label=\"t('dialog.subscribeEdit.season')\"\n                      :hint=\"t('dialog.subscribeEdit.seasonHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-calendar\"\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" v-if=\"!props.default\">\n                    <VTextField\n                      v-model=\"subscribeForm.media_category\"\n                      :label=\"t('dialog.subscribeEdit.mediaCategory')\"\n                      :hint=\"t('dialog.subscribeEdit.mediaCategoryHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-tag\"\n                    />\n                  </VCol>\n                </VRow>\n                <VRow v-if=\"!props.default\">\n                  <VCol cols=\"12\">\n                    <VTextarea\n                      v-model=\"subscribeForm.custom_words\"\n                      :label=\"t('dialog.subscribeEdit.customWords')\"\n                      :hint=\"t('dialog.subscribeEdit.customWordsHint')\"\n                      persistent-hint\n                      :placeholder=\"t('dialog.subscribeEdit.customWordsPlaceholder')\"\n                      prepend-inner-icon=\"mdi-text\"\n                    />\n                  </VCol>\n                </VRow>\n              </div>\n            </VWindowItem>\n          </VWindow>\n        </VForm>\n      </VCardText>\n      <VCardActions class=\"pt-3\">\n        <VBtn v-if=\"!props.default\" color=\"error\" @click=\"removeSubscribe\" class=\"me-3\">\n          {{ t('dialog.subscribeEdit.cancelSubscribe') }}\n        </VBtn>\n        <VSpacer />\n        <VBtn\n          @click=\";`${props.default ? saveDefaultSubscribeConfig() : updateSubscribeInfo()}`\"\n          prepend-icon=\"mdi-content-save\"\n          class=\"px-5\"\n        >\n          {{ t('dialog.subscribeEdit.save') }}\n        </VBtn>\n      </VCardActions>\n    </VCard>\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/components/dialog/SubscribeFilesDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport api from '@/api'\nimport { SubscrbieInfo } from '@/api/types'\nimport { useDisplay } from 'vuetify'\nimport { useI18n } from 'vue-i18n'\n\n// i18n\nconst { t } = useI18n()\n\n// 显示器宽度\nconst display = useDisplay()\n\n//定义输入参数\nconst props = defineProps({\n  subid: Number,\n})\n\nconst activeTab = ref('download')\n\n// 定义触发的自定义事件\nconst emit = defineEmits(['close'])\n\n// 订阅文件信息\nconst subScribeInfo = ref<SubscrbieInfo>()\n\n// 是否加载中\nconst loading = ref(false)\n\n// 下载文件表头\nconst downloadHeaders = [\n  { title: t('dialog.subscribeFiles.episodeColumn'), key: 'episode_number', sortable: true },\n  { title: t('dialog.subscribeFiles.torrentColumn'), key: 'torrent_title', sortable: true },\n  { title: t('dialog.subscribeFiles.fileColumn'), key: 'file_path', sortable: true },\n]\n\n// 媒体库文件表头\nconst libraryHeaders = [\n  { title: t('dialog.subscribeFiles.episodeColumn'), key: 'episode_number', sortable: true },\n  { title: t('dialog.subscribeFiles.fileColumn'), key: 'file_path', sortable: true },\n]\n\n// 调用API查询订阅文件信息\nasync function loadSubscribeFilesInfo() {\n  try {\n    loading.value = true\n    subScribeInfo.value = await api.get(`subscribe/files/${props.subid}`)\n  } catch (e) {\n    console.log(e)\n  } finally {\n    loading.value = false\n  }\n}\n\n// 计算下载文件列表\nconst downloadInfos = computed(() => {\n  return Object.keys(subScribeInfo.value?.episodes ?? {}).map((key: any) => {\n    const item = subScribeInfo.value?.episodes[key]\n    return {\n      episode_number: key,\n      title: item?.title,\n      download: item?.download ?? [],\n    }\n  })\n})\n\n// 总集数\nconst totalCount = computed(() => {\n  return Object.keys(subScribeInfo.value?.episodes ?? {}).length\n})\n\n// 计算媒体库文件列表\nconst libraryInfos = computed(() => {\n  return Object.keys(subScribeInfo.value?.episodes ?? {}).map((key: any) => {\n    const item = subScribeInfo.value?.episodes[key]\n    return {\n      episode_number: key,\n      title: item?.title,\n      library: item?.library ?? [],\n    }\n  })\n})\n\nonBeforeMount(() => {\n  loadSubscribeFilesInfo()\n})\n</script>\n<template>\n  <VDialog scrollable max-width=\"80rem\" :fullscreen=\"!display.mdAndUp.value\">\n    <VCard>\n      <VCardItem class=\"my-2\">\n        <VDialogCloseBtn @click=\"emit('close')\" />\n      </VCardItem>\n      <LoadingBanner v-if=\"loading\" />\n      <VCardText v-else>\n        <div class=\"media-page\">\n          <div class=\"media-header\">\n            <div class=\"media-poster\">\n              <VImg\n                :src=\"subScribeInfo?.subscribe?.poster\"\n                cover\n                class=\"object-cover aspect-w-2 aspect-h-3 ring-1 ring-gray-500\"\n              >\n                <template #placeholder>\n                  <div class=\"w-full h-full\">\n                    <VSkeletonLoader class=\"object-cover aspect-w-2 aspect-h-3\" />\n                  </div>\n                </template>\n              </VImg>\n            </div>\n            <div class=\"media-title\">\n              <h1 class=\"d-flex flex-column flex-lg-row align-baseline justify-center justify-lg-start\">\n                <div class=\"align-self-center align-self-lg-end\">\n                  {{ subScribeInfo?.subscribe?.name }}\n                </div>\n                <div v-if=\"subScribeInfo?.subscribe?.season\" class=\"text-lg align-self-center align-self-lg-end ms-3\">\n                  {{ t('dialog.subscribeFiles.season', { number: subScribeInfo?.subscribe?.season }) }}\n                </div>\n              </h1>\n              <div>{{ subScribeInfo?.subscribe?.year }}</div>\n              <div class=\"media-overview\">\n                <div class=\"media-overview-left\">\n                  <p>{{ subScribeInfo?.subscribe?.description }}</p>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n        <div class=\"mt-7\">\n          <VTabs v-model=\"activeTab\" show-arrows class=\"v-tabs-pill\">\n            <VTab value=\"download\" selected-class=\"v-slide-group-item--active v-tab--selected\">\n              <div>\n                <VIcon size=\"20\" start icon=\"mdi-download\" />\n                {{ t('dialog.subscribeFiles.downloadTab') }}\n              </div>\n            </VTab>\n            <VTab value=\"library\" selected-class=\"v-slide-group-item--active v-tab--selected\">\n              <div>\n                <VIcon size=\"20\" start icon=\"mdi-filmstrip-box-multiple\" />\n                {{ t('dialog.subscribeFiles.libraryTab') }}\n              </div>\n            </VTab>\n          </VTabs>\n          <VWindow v-model=\"activeTab\" class=\"mt-5 disable-tab-transition\" :touch=\"false\">\n            <VWindowItem value=\"download\">\n              <transition name=\"fade-slide\" appear>\n                <div>\n                  <VDataTable\n                    items-per-page=\"50\"\n                    :headers=\"downloadHeaders\"\n                    :items=\"downloadInfos\"\n                    :items-length=\"totalCount\"\n                    density=\"compact\"\n                    item-value=\"title\"\n                    return-object\n                    fixed-header\n                    hover\n                    :items-per-page-text=\"t('dialog.subscribeFiles.itemsPerPage')\"\n                    :page-text=\"t('dialog.subscribeFiles.pageText')\"\n                    :loading-text=\"t('dialog.subscribeFiles.loadingText')\"\n                  >\n                    <template #item.episode_number=\"{ item }\">\n                      <div class=\"text-high-emphasis pt-1\">{{ item.episode_number }}. {{ item.title }}</div>\n                    </template>\n                    <template #item.torrent_title=\"{ item }\">\n                      <div class=\"text-xs\" v-for=\"file in item.download\">\n                        【{{ file.site_name }}】{{ file.torrent_title }}\n                      </div>\n                    </template>\n                    <template #item.file_path=\"{ item }\">\n                      <div class=\"text-xs\" v-for=\"file in item.download\">{{ file.file_path }}</div>\n                    </template>\n                    <template #no-data> {{ t('dialog.subscribeFiles.noData') }} </template>\n                  </VDataTable>\n                </div>\n              </transition>\n            </VWindowItem>\n            <VWindowItem value=\"library\">\n              <transition name=\"fade-slide\" appear>\n                <div>\n                  <VDataTable\n                    items-per-page=\"50\"\n                    :headers=\"libraryHeaders\"\n                    :items=\"libraryInfos\"\n                    :items-length=\"totalCount\"\n                    density=\"compact\"\n                    item-value=\"title\"\n                    return-object\n                    fixed-header\n                    hover\n                    :items-per-page-text=\"t('dialog.subscribeFiles.itemsPerPage')\"\n                    :page-text=\"t('dialog.subscribeFiles.pageText')\"\n                    :loading-text=\"t('dialog.subscribeFiles.loadingText')\"\n                  >\n                    <template #item.episode_number=\"{ item }\">\n                      <div class=\"text-high-emphasis pt-1\">{{ item.episode_number }}. {{ item.title }}</div>\n                    </template>\n                    <template #item.file_path=\"{ item }\">\n                      <div class=\"text-xs\" v-for=\"file in item.library\">{{ file.file_path }}</div>\n                    </template>\n                    <template #no-data> {{ t('dialog.subscribeFiles.noData') }} </template>\n                  </VDataTable>\n                </div>\n              </transition>\n            </VWindowItem>\n          </VWindow>\n        </div>\n      </VCardText>\n    </VCard>\n  </VDialog>\n</template>\n\n<style lang=\"scss\" scoped>\n.vue-media-back {\n  background-image: linear-gradient(\n      180deg,\n      rgba(var(--v-theme-background), 0) 50%,\n      rgba(var(--v-theme-background), 1) 100%\n    ),\n    linear-gradient(90deg, rgba(var(--v-theme-background), 0) 50%, rgba(var(--v-theme-background), 1) 100%),\n    linear-gradient(270deg, rgba(var(--v-theme-background), 0) 50%, rgba(var(--v-theme-background), 1) 100%);\n  box-shadow: 0 0 0 2px rgb(var(--v-theme-background));\n  margin-block-start: calc(-70px - env(safe-area-inset-top));\n}\n\n.media-page {\n  position: relative;\n  background-position: 50%;\n  background-size: cover;\n  margin-block-start: calc(-4rem - env(safe-area-inset-top));\n  padding-block-start: calc(4rem + env(safe-area-inset-top));\n  padding-inline: 1rem;\n}\n\n.media-header {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  padding-block-start: 1rem;\n}\n\n@media (width >= 1280px) {\n  .media-header {\n    flex-direction: row;\n    align-items: flex-end;\n  }\n}\n\n.media-overview {\n  display: flex;\n  flex-direction: column;\n  padding-block: 1rem;\n}\n\n@media (width >= 1024px) {\n  .media-overview {\n    flex-direction: row;\n  }\n}\n\n.media-poster {\n  overflow: hidden;\n  border-radius: 0.25rem;\n  box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);\n  inline-size: 8rem;\n\n  --tw-shadow: 0 1px 3px 0 rgba(0, 0, 0, 10%), 0 1px 2px -1px rgba(0, 0, 0, 10%);\n  --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);\n}\n\n@media (width >= 1280px) {\n  .media-poster {\n    inline-size: 13rem;\n    margin-inline-end: 1rem;\n  }\n}\n\n@media (width >= 768px) {\n  .media-poster {\n    border-radius: 0.5rem;\n    box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);\n    inline-size: 11rem;\n\n    --tw-shadow: 0 25px 50px -12px rgba(0, 0, 0, 25%);\n    --tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color);\n  }\n}\n\n.media-title {\n  display: flex;\n  flex: 1 1 0%;\n  flex-direction: column;\n  margin-block-start: 1rem;\n  text-align: center;\n}\n\n@media (width >= 1280px) {\n  .media-title {\n    margin-block-start: 0;\n    margin-inline-end: 1rem;\n    text-align: start;\n  }\n}\n\n.media-title > h1 {\n  font-size: 1.5rem;\n  font-weight: 700;\n  line-height: 2rem;\n}\n\n@media (width >= 1280px) {\n  .media-title > h1 {\n    font-size: 2.25rem;\n    line-height: 2.5rem;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/dialog/SubscribeHistoryDialog.vue",
    "content": "<script lang=\"ts\" setup>\nimport api from '@/api'\nimport { Subscribe } from '@/api/types'\nimport { formatDateDifference } from '@core/utils/formatters'\nimport { useDisplay } from 'vuetify'\nimport ProgressDialog from './ProgressDialog.vue'\nimport { useI18n } from 'vue-i18n'\nimport { mediaTypeDict } from '@/api/constants'\n\n// 国际化\nconst { t } = useI18n()\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 输入参数\nconst props = defineProps({\n  type: String,\n})\n\n// 定义触发的自定义事件\nconst emit = defineEmits(['close', 'save'])\n\n// 订阅历史列表\nconst historyList = ref<Subscribe[]>([])\n\n// 当前加载数据\nconst currData = ref<Subscribe[]>([])\n\n// 当前页\nconst currentPage = ref(1)\n\n// 每页数量\nconst pageSize = ref(30)\n\n// 是否加载中\nconst loading = ref(false)\n\n// 是否加载完成\nconst isRefreshed = ref(false)\n\n// 进度框\nconst progressDialog = ref(false)\n\n// 进度文字\nconst progressText = ref('')\n\n// 调用API查询列表\nasync function loadHistory({ done }: { done: any }) {\n  // 如果正在加载中，直接返回\n  if (loading.value) {\n    done('ok')\n    return\n  }\n\n  // 调用API查询列表\n  try {\n    // 设置加载中\n    loading.value = true\n    currData.value = await api.get(`subscribe/history/${props.type}`, {\n      params: {\n        page: currentPage.value,\n        count: pageSize.value,\n      },\n    })\n    // 标计为已请求完成\n    isRefreshed.value = true\n    if (currData.value.length === 0) {\n      // 如果没有数据，跳出\n      done('empty')\n    } else {\n      // 合并数据\n      historyList.value = [...historyList.value, ...currData.value]\n      // 页码+1\n      currentPage.value++\n      // 返回加载成功\n      done('ok')\n    }\n    // 取消加载中\n    loading.value = false\n  } catch (e) {\n    console.error(e)\n    // 返回加载失败\n    done('error')\n  }\n}\n\n// 重新订阅\nasync function reSubscribe(item: Subscribe) {\n  if (item.type === '电影') {\n    progressText.value = t('dialog.subscribeHistory.resubscribeMovie', { name: item.name })\n  } else {\n    progressText.value = t('dialog.subscribeHistory.resubscribeTv', { name: item.name, season: item.season })\n  }\n  progressDialog.value = true\n  try {\n    const result: { [key: string]: any } = await api.post('subscribe/', item)\n    if (result.success) {\n      emit('save')\n    }\n  } catch (e) {\n    console.error(e)\n  }\n  progressDialog.value = false\n}\n\n// 删除记录\nasync function deleteHistory(item: Subscribe) {\n  try {\n    const result: { [key: string]: any } = await api.delete(`subscribe/history/${item.id}`)\n    if (result.success) {\n      historyList.value = historyList.value.filter(i => i.id !== item.id)\n    }\n  } catch (e) {\n    console.error(e)\n  }\n}\n\n// 弹出菜单\nconst dropdownItems = ref([\n  {\n    title: t('dialog.subscribeHistory.resubscribe'),\n    value: 1,\n    color: '',\n    props: {\n      prependIcon: 'mdi-redo',\n      click: reSubscribe,\n    },\n  },\n  {\n    title: t('common.delete'),\n    value: 2,\n    color: 'error',\n    props: {\n      prependIcon: 'mdi-delete',\n      click: deleteHistory,\n    },\n  },\n])\n\n// 获取媒体类型文本\nfunction getMediaTypeText(type: string | undefined) {\n  if (!type) return ''\n  return mediaTypeDict[type]\n}\n</script>\n\n<template>\n  <VDialog scrollable max-width=\"50rem\" :fullscreen=\"!display.mdAndUp.value\">\n    <VCard class=\"mx-auto\" width=\"100%\">\n      <VCardItem>\n        <VCardTitle>{{ t('dialog.subscribeHistory.title', { type: getMediaTypeText(props.type) }) }}</VCardTitle>\n      </VCardItem>\n      <VDivider />\n      <VDialogCloseBtn @click=\"emit('close')\" />\n      <VList lines=\"two\">\n        <VInfiniteScroll mode=\"intersect\" side=\"end\" :items=\"historyList\" class=\"overflow-visible\" @load=\"loadHistory\">\n          <template #loading>\n            <LoadingBanner />\n          </template>\n          <template #empty />\n          <template v-if=\"historyList.length > 0\">\n            <template v-for=\"(item, i) in historyList\" :key=\"i\">\n              <VListItem>\n                <template #prepend>\n                  <VImg\n                    height=\"75\"\n                    width=\"50\"\n                    :src=\"item.poster\"\n                    aspect-ratio=\"2/3\"\n                    class=\"object-cover rounded ring-gray-500 me-3\"\n                    cover\n                  >\n                    <template #placeholder>\n                      <div class=\"w-full h-full\">\n                        <VSkeletonLoader class=\"object-cover aspect-w-2 aspect-h-3\" />\n                      </div>\n                    </template>\n                  </VImg>\n                </template>\n                <VListItemTitle v-if=\"item.type == '电视剧'\">\n                  {{ item.name }}\n                  <span class=\"text-sm\">{{ t('dialog.subscribeHistory.season', { season: item.season }) }}</span>\n                </VListItemTitle>\n                <VListItemTitle v-else>\n                  {{ item.name }}\n                </VListItemTitle>\n                <VListItemSubtitle class=\"mt-2\">{{ formatDateDifference(item.date) }}</VListItemSubtitle>\n                <VListItemSubtitle class=\"mt-2\">{{ item.description }}</VListItemSubtitle>\n                <template #append>\n                  <div class=\"me-n3\">\n                    <IconBtn>\n                      <VIcon icon=\"mdi-dots-vertical\" />\n                      <VMenu activator=\"parent\" close-on-content-click>\n                        <VList>\n                          <VListItem\n                            v-for=\"(menu, i) in dropdownItems\"\n                            :key=\"i\"\n                            :base-color=\"menu.color\"\n                            @click=\"menu.props.click(item)\"\n                          >\n                            <template #prepend>\n                              <VIcon :icon=\"menu.props.prependIcon\" />\n                            </template>\n                            <VListItemTitle v-text=\"menu.title\" />\n                          </VListItem>\n                        </VList>\n                      </VMenu>\n                    </IconBtn>\n                  </div>\n                </template>\n              </VListItem>\n            </template>\n          </template>\n        </VInfiniteScroll>\n      </VList>\n      <VCardText v-if=\"historyList.length === 0 && isRefreshed\" class=\"text-center\">{{\n        t('dialog.subscribeHistory.noData')\n      }}</VCardText>\n    </VCard>\n    <!-- 进度框 -->\n    <ProgressDialog v-if=\"progressDialog\" v-model=\"progressDialog\" :text=\"progressText\" />\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/components/dialog/SubscribeSeasonDialog.vue",
    "content": "<script lang=\"ts\" setup>\nimport api from '@/api'\nimport { MediaInfo, MediaSeason, NotExistMediaInfo } from '@/api/types'\nimport { PropType } from 'vue'\nimport NoDataFound from '@/components/NoDataFound.vue'\nimport { useI18n } from 'vue-i18n'\nimport { useGlobalSettingsStore } from '@/stores'\n\n// 国际化\nconst { t } = useI18n()\n\n// 定义事件\nconst emit = defineEmits(['subscribe', 'close'])\n\n// 定义输入\nconst props = defineProps({\n  media: Object as PropType<MediaInfo>,\n})\n\n// 从 provide 中获取全局设置\n// 全局设置\nconst globalSettingsStore = useGlobalSettingsStore()\nconst globalSettings = globalSettingsStore.globalSettings\n\n// 季详情\nconst seasonInfos = ref<MediaSeason[]>([])\n\n// 选中的订阅季\nconst seasonsSelected = ref<MediaSeason[]>([])\n\n// 各季缺失状态：0-已入库 1-部分缺失 2-全部缺失，没有数据也是已入库\nconst seasonsNotExisted = ref<{ [key: number]: number }>({})\n\n// 是否刷新过\nconst isRefreshed = ref(false)\n\n// 所有剧集组\nconst episodeGroups = ref<{ [key: string]: any }[]>([])\n\n// 当前选择剧集组\nconst episodeGroup = ref('')\n\n// 剧集组选项属性\nfunction episodeGroupItemProps(item: { title: string; subtitle: string }) {\n  return {\n    title: item.title,\n    subtitle: item.subtitle,\n  }\n}\n\n// 剧集组选项\nconst episodeGroupOptions = computed(() => {\n  let options = (episodeGroups.value as { id: string; name: string; group_count: number; episode_count: number }[]).map(\n    item => {\n      return {\n        title: item.name,\n        subtitle: `${t('dialog.subscribeSeason.seasonCount', { count: item.group_count })} • ${t(\n          'dialog.subscribeSeason.episodeCount',\n          { count: item.episode_count },\n        )}`,\n        value: item.id,\n      }\n    },\n  )\n  // 添加不使用选项\n  options.unshift({\n    title: t('dialog.subscribeSeason.defaultGroup'),\n    subtitle: t('dialog.subscribeSeason.seasonCount', { count: seasonInfos.value.length }),\n    value: '',\n  })\n  return options\n})\n\n// 获得mediaid\nfunction getMediaId() {\n  if (props.media?.tmdb_id) return `tmdb:${props.media?.tmdb_id}`\n  else if (props.media?.douban_id) return `douban:${props.media?.douban_id}`\n  else if (props.media?.bangumi_id) return `bangumi:${props.media?.bangumi_id}`\n  else return `${props.media?.mediaid_prefix}:${props.media?.media_id}`\n}\n\n// 查询所有剧集组\nasync function getEpisodeGroups() {\n  if (!props.media?.tmdb_id) {\n    console.warn('tmdbid is not set or is empty')\n    return\n  }\n  try {\n    episodeGroups.value = await api.get(`media/groups/${props.media?.tmdb_id}`)\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 查询TMDB的所有季信息\nasync function getMediaSeasons() {\n  try {\n    seasonInfos.value = await api.get('media/seasons', {\n      params: {\n        mediaid: getMediaId(),\n        title: props.media?.title,\n        year: props.media?.year,\n        season: props.media?.season,\n      },\n    })\n    isRefreshed.value = true\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 查询剧集组的剧集\nasync function getGroupSeasons() {\n  if (!episodeGroup.value) return\n  isRefreshed.value = false\n  try {\n    seasonInfos.value = await api.get(`media/group/seasons/${episodeGroup.value}`)\n  } catch (error) {\n    console.error(error)\n  }\n  isRefreshed.value = true\n}\n\n// 检查所有季的缺失状态（数据库）\nasync function checkSeasonsNotExists() {\n  // 开始处理\n  try {\n    let tmpMedia = props.media ?? { episode_group: '' }\n    if (episodeGroup.value) tmpMedia.episode_group = episodeGroup.value\n    else tmpMedia.episode_group = ''\n    const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', tmpMedia)\n    if (result) {\n      result.forEach(item => {\n        // 0-已入库 1-部分缺失 2-全部缺失\n        let state = 0\n        if (item.episodes.length === 0) state = 2\n        else if (item.episodes.length < item.total_episode) state = 1\n        seasonsNotExisted.value[item.season] = state\n      })\n    }\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 计算存在状态的颜色\nfunction getExistColor(season: number) {\n  const state = seasonsNotExisted.value[season]\n  if (!state) return 'success'\n\n  if (state === 1) return 'warning'\n  else if (state === 2) return 'error'\n  else return 'success'\n}\n\n// 计算存在状态的文本\nfunction getExistText(season: number) {\n  const state = seasonsNotExisted.value[season]\n  if (!state) return t('dialog.subscribeSeason.status.exists')\n\n  if (state === 1) return t('dialog.subscribeSeason.status.partial')\n  else if (state === 2) return t('dialog.subscribeSeason.status.missing')\n  else return t('dialog.subscribeSeason.status.exists')\n}\n\n// 拼装季图片地址\nfunction getSeasonPoster(posterPath: string) {\n  if (!posterPath) return props.media?.poster_path\n  return `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w500${posterPath}`\n}\n\n// 将yyyy-mm-dd转换为yyyy年mm月dd日\nfunction formatAirDate(airDate: string) {\n  if (!airDate) return ''\n  const date = new Date(airDate.replaceAll(/-/g, '/'))\n  return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`\n}\n\n// 从yyyy-mm-dd中提取年份\nfunction getYear(airDate: string) {\n  if (!airDate) return ''\n  const date = new Date(airDate.replaceAll(/-/g, '/'))\n  return date.getFullYear()\n}\n\nfunction subscribeSeasons() {\n  emit('subscribe', seasonsSelected.value, seasonsNotExisted.value, episodeGroup.value)\n}\n\nwatchEffect(() => {\n  if (episodeGroup.value) getGroupSeasons()\n  else getMediaSeasons()\n  checkSeasonsNotExists()\n})\n\nonMounted(async () => {\n  getMediaSeasons()\n  getEpisodeGroups()\n  checkSeasonsNotExists()\n})\n</script>\n\n<template>\n  <VBottomSheet inset scrollable>\n    <VCard>\n      <VDialogCloseBtn @click=\"emit('close')\" />\n      <VCardItem>\n        <VCardTitle class=\"pe-10\"> {{ t('dialog.subscribeSeason.title', { title: props.media?.title }) }} </VCardTitle>\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <VSelect\n          v-model=\"episodeGroup\"\n          :items=\"episodeGroupOptions\"\n          :item-props=\"episodeGroupItemProps\"\n          :label=\"t('dialog.subscribeSeason.selectGroup')\"\n          persistent-hint\n        />\n        <LoadingBanner v-if=\"!isRefreshed\" class=\"mt-5\" />\n        <div v-else-if=\"seasonInfos.length > 0\">\n          <VList v-model:selected=\"seasonsSelected\" lines=\"three\" select-strategy=\"classic\">\n            <VListItem v-for=\"(item, i) in seasonInfos\" :key=\"i\" :value=\"item\">\n              <template #prepend>\n                <VImg\n                  height=\"90\"\n                  width=\"60\"\n                  :src=\"getSeasonPoster(item.poster_path || '')\"\n                  aspect-ratio=\"2/3\"\n                  class=\"object-cover rounded ring-gray-500 me-3\"\n                  cover\n                >\n                  <template #placeholder>\n                    <div class=\"w-full h-full\">\n                      <VSkeletonLoader class=\"object-cover aspect-w-2 aspect-h-3\" />\n                    </div>\n                  </template>\n                </VImg>\n              </template>\n              <VListItemTitle>\n                {{ t('dialog.subscribeSeason.seasonNumber', { number: item.season_number }) }}\n              </VListItemTitle>\n              <VListItemSubtitle class=\"mt-1 me-2\">\n                <VChip v-if=\"item.vote_average\" color=\"primary\" size=\"small\" class=\"mb-1\">\n                  <VIcon icon=\"mdi-star\" /> {{ t('dialog.subscribeSeason.voteAverage', { score: item.vote_average }) }}\n                </VChip>\n                {{ getYear(item.air_date || '') }} •\n                {{ t('dialog.subscribeSeason.episodeCount', { count: item.episode_count }) }}\n              </VListItemSubtitle>\n              <VListItemSubtitle>\n                {{ t('dialog.subscribeSeason.airDate', { date: formatAirDate(item.air_date || '') }) }}\n              </VListItemSubtitle>\n              <VListItemSubtitle>\n                <VChip\n                  v-if=\"seasonsNotExisted\"\n                  class=\"mt-2\"\n                  size=\"small\"\n                  :color=\"getExistColor(item.season_number || 0)\"\n                >\n                  {{ getExistText(item.season_number || 0) }}\n                </VChip>\n              </VListItemSubtitle>\n              <template #append=\"{ isSelected }\">\n                <VListItemAction start>\n                  <VSwitch :model-value=\"isSelected\" />\n                </VListItemAction>\n              </template>\n            </VListItem>\n          </VList>\n        </div>\n        <NoDataFound v-else errorTitle=\"出错啦！\" :errorDescription=\"`${props.media?.title} 未查询到季集信息`\" />\n      </VCardText>\n      <div class=\"my-2 text-center\">\n        <VBtn :disabled=\"seasonsSelected.length === 0\" width=\"30%\" @click=\"subscribeSeasons\">\n          {{\n            seasonsSelected.length === 0\n              ? t('dialog.subscribeSeason.selectSeasons')\n              : t('dialog.subscribeSeason.submit')\n          }}\n        </VBtn>\n      </div>\n    </VCard>\n  </VBottomSheet>\n</template>\n"
  },
  {
    "path": "src/components/dialog/SubscribeShareDialog.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useToast } from 'vue-toastification'\nimport { requiredValidator } from '@/@validators'\nimport api from '@/api'\nimport type { Subscribe, SubscribeShare } from '@/api/types'\nimport { useDisplay } from 'vuetify'\nimport { formatSeason } from '@/@core/utils/formatters'\nimport { useI18n } from 'vue-i18n'\n\n// 多语言支持\nconst { t } = useI18n()\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 输入参数\nconst props = defineProps({\n  sub: Object as PropType<Subscribe>,\n})\n\n// 定义触发的自定义事件\nconst emit = defineEmits(['close'])\n\n// 分享处理状态\nconst shareDoing = ref(false)\n\n// 订阅编辑表单\nconst shareForm = ref<SubscribeShare>({\n  subscribe_id: props.sub?.id ?? 0,\n  share_title: `${props.sub?.name} ${formatSeason(props.sub?.season ? props.sub?.season.toString() : '')}`,\n})\n\n// 分享订阅\nasync function doShare() {\n  if (!shareForm.value.share_title || !shareForm.value.share_comment || !shareForm.value.share_user) return\n  try {\n    shareDoing.value = true\n    const result: { [key: string]: any } = await api.post('subscribe/share', shareForm.value)\n    shareDoing.value = false\n    // 提示\n    if (result.success) {\n      $toast.success(t('dialog.subscribeShare.shareSuccess', { name: props.sub?.name }))\n      // 通知父组件刷新\n      emit('close')\n    } else {\n      $toast.error(t('dialog.subscribeShare.shareFailed', { name: props.sub?.name, message: result.message }))\n    }\n  } catch (e) {\n    console.log(e)\n  }\n}\n\n// 提示框\nconst $toast = useToast()\n</script>\n\n<template>\n  <VDialog scrollable max-width=\"30rem\" :fullscreen=\"!display.mdAndUp.value\">\n    <VCard>\n      <VCardItem class=\"py-2\">\n        <template #prepend>\n          <VIcon icon=\"mdi-share-outline\" class=\"me-2\" />\n        </template>\n        <VCardTitle>{{ t('dialog.subscribeShare.shareSubscription') }}</VCardTitle>\n        <VCardSubtitle>\n          {{ props.sub?.name }}\n          {{ props.sub?.season ? t('dialog.subscribeShare.season', { number: props.sub?.season }) : '' }}\n        </VCardSubtitle>\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <VDialogCloseBtn @click=\"emit('close')\" />\n        <VForm @submit.prevent=\"() => {}\" class=\"pt-2\">\n          <VRow>\n            <VCol cols=\"12\">\n              <VTextField\n                v-model=\"shareForm.share_title\"\n                readonly\n                :label=\"t('dialog.subscribeShare.title')\"\n                :rules=\"[requiredValidator]\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-format-title\"\n              />\n            </VCol>\n            <VCol cols=\"12\">\n              <VTextarea\n                v-model=\"shareForm.share_comment\"\n                :label=\"t('dialog.subscribeShare.description')\"\n                :rules=\"[requiredValidator]\"\n                :hint=\"t('dialog.subscribeShare.descriptionHint')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-comment-text-outline\"\n              />\n            </VCol>\n            <VCol cols=\"12\">\n              <VTextField\n                v-model=\"shareForm.share_user\"\n                :label=\"t('dialog.subscribeShare.shareUser')\"\n                :rules=\"[requiredValidator]\"\n                :hint=\"t('dialog.subscribeShare.shareUserHint')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-account-outline\"\n              />\n            </VCol>\n          </VRow>\n        </VForm>\n      </VCardText>\n      <VCardActions class=\"pt-3\">\n        <VSpacer />\n        <VBtn :disabled=\"shareDoing\" @click=\"doShare\" prepend-icon=\"mdi-share\" class=\"px-5\" :loading=\"shareDoing\">\n          {{ t('dialog.subscribeShare.confirmShare') }}\n        </VBtn>\n      </VCardActions>\n    </VCard>\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/components/dialog/SubscribeShareStatisticsDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, computed, onMounted } from 'vue'\nimport api from '@/api'\nimport type { SubscribeShareStatistics } from '@/api/types'\nimport { useI18n } from 'vue-i18n'\nimport { useDisplay, useTheme } from 'vuetify'\n\n// 国际化\nconst { t } = useI18n()\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 主题\nconst theme = useTheme()\n\n// 定义事件\nconst emit = defineEmits(['close'])\n\n// 统计数据\nconst statistics = ref<SubscribeShareStatistics[]>([])\n\n// 是否加载中\nconst loading = ref(false)\n\n// 获取统计数据\nasync function fetchStatistics() {\n  try {\n    loading.value = true\n    const data: SubscribeShareStatistics[] = await api.get('subscribe/share/statistics')\n    statistics.value = data\n  } catch (error) {\n    console.error('获取分享统计数据失败:', error)\n  } finally {\n    loading.value = false\n  }\n}\n\n// 计算排名\nconst rankedStatistics = computed(() => {\n  return statistics.value\n    .sort((a, b) => (b.total_reuse_count || 0) - (a.total_reuse_count || 0))\n    .map((item, index) => ({\n      ...item,\n      rank: index + 1,\n    }))\n})\n\n// 获取排名样式\nfunction getRankStyle(rank: number) {\n  if (rank === 1) {\n    return {\n      background: 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)',\n      color: '#fff',\n      fontWeight: 'bold',\n    }\n  } else if (rank === 2) {\n    return {\n      background: 'linear-gradient(135deg, #CD7F32 0%, #B8860B 100%)',\n      color: '#fff',\n      fontWeight: 'bold',\n    }\n  } else if (rank === 3) {\n    return {\n      background: 'linear-gradient(135deg, #C0C0C0 0%, #A0A0A0 100%)',\n      color: '#fff',\n      fontWeight: 'bold',\n    }\n  }\n  return {}\n}\n\n// 获取前三名文字颜色\nfunction getPodiumTextColor() {\n  return theme.global.current.value.dark ? '#fff' : '#000'\n}\n\n// 获取前三名统计背景样式\nfunction getPodiumStatStyle() {\n  const isDark = theme.global.current.value.dark\n  return {\n    border: `1px solid ${isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'}`,\n    background: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',\n  }\n}\n\n// 获取前三名区域背景样式\nfunction getPodiumAreaBackgroundStyle() {\n  const isDark = theme.global.current.value.dark\n  return {\n    background: isDark\n      ? 'linear-gradient(135deg, rgba(255, 215, 0, 0.25) 0%, rgba(255, 69, 0, 0.2) 25%, rgba(255, 20, 147, 0.15) 50%, rgba(138, 43, 226, 0.1) 75%, rgba(0, 191, 255, 0.08) 100%), linear-gradient(to bottom, transparent 0%, transparent 70%, rgba(255, 215, 0, 0.1) 85%, transparent 100%)'\n      : 'linear-gradient(135deg, rgba(255, 215, 0, 0.2) 0%, rgba(255, 69, 0, 0.15) 25%, rgba(255, 20, 147, 0.12) 50%, rgba(138, 43, 226, 0.08) 75%, rgba(0, 191, 255, 0.05) 100%), linear-gradient(to bottom, transparent 0%, transparent 70%, rgba(255, 215, 0, 0.08) 85%, transparent 100%)',\n    border: 'none',\n    borderRadius: '0',\n    padding: '32px 24px 48px 24px',\n    margin: '0 -24px 0 -',\n    boxShadow: isDark\n      ? '0 16px 48px rgba(255, 215, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.1)'\n      : '0 16px 48px rgba(255, 215, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.3)',\n    position: 'relative' as const,\n    overflow: 'hidden',\n  }\n}\n\n// 获取排名图标\nfunction getRankIcon(rank: number) {\n  if (rank === 1) return 'mdi-trophy'\n  if (rank === 2) return 'mdi-medal-outline'\n  if (rank === 3) return 'mdi-medal'\n  return ''\n}\n\n// 组件挂载时获取数据\nonMounted(() => {\n  fetchStatistics()\n})\n</script>\n\n<template>\n  <VDialog scrollable max-width=\"40rem\" :fullscreen=\"!display.mdAndUp.value\">\n    <VCard>\n      <VCardItem>\n        <template #prepend>\n          <VIcon icon=\"mdi-chart-line\" class=\"me-2\" />\n        </template>\n        <VCardTitle>{{ t('subscribe.shareStatistics') }}</VCardTitle>\n      </VCardItem>\n      <VDialogCloseBtn @click=\"emit('close')\" />\n      <VDivider />\n      <VCardText class=\"pa-0\">\n        <LoadingBanner v-if=\"loading\" class=\"mt-4\" />\n        <div v-else-if=\"rankedStatistics.length === 0\" class=\"text-center py-8\">\n          <VIcon icon=\"mdi-chart-line\" size=\"64\" color=\"grey\" class=\"mb-4\" />\n          <div class=\"text-h6 text-grey\">{{ t('subscribe.noStatisticsData') }}</div>\n        </div>\n\n        <div v-else>\n          <!-- 前三名特殊展示 -->\n          <div class=\"podium-area\" :style=\"getPodiumAreaBackgroundStyle()\">\n            <!-- 装饰性背景元素 -->\n            <div class=\"podium-decoration\">\n              <div class=\"decoration-circle decoration-1\"></div>\n              <div class=\"decoration-circle decoration-2\"></div>\n              <div class=\"decoration-circle decoration-3\"></div>\n            </div>\n            <div class=\"text-h6 mb-4 text-center podium-title\">{{ t('subscribe.ranking') }}</div>\n            <!-- 大屏幕横向排列 -->\n            <div class=\"d-none d-md-flex justify-center align-center gap-4 flex-wrap\">\n              <!-- 第二名 -->\n              <div v-if=\"rankedStatistics[1]\" class=\"text-center\">\n                <div class=\"rank-circle mb-2\" :style=\"getRankStyle(2)\">\n                  <VIcon :icon=\"getRankIcon(2)\" size=\"24\" />\n                </div>\n                <div class=\"text-h6 font-weight-bold\" :style=\"{ color: getPodiumTextColor() }\">\n                  {{ rankedStatistics[1].share_user || '未知' }}\n                </div>\n                <div class=\"d-flex align-center justify-center gap-2 mt-1\">\n                  <div class=\"d-flex align-center podium-stat\" :style=\"getPodiumStatStyle()\">\n                    <VIcon icon=\"mdi-share-outline\" size=\"14\" :color=\"getPodiumTextColor()\" class=\"mr-1\" />\n                    <span class=\"font-weight-bold\" :style=\"{ color: getPodiumTextColor() }\">{{\n                      rankedStatistics[1].share_count || 0\n                    }}</span>\n                  </div>\n                  <div class=\"d-flex align-center podium-stat\" :style=\"getPodiumStatStyle()\">\n                    <VIcon icon=\"mdi-fire\" size=\"14\" :color=\"getPodiumTextColor()\" class=\"mr-1\" />\n                    <span class=\"font-weight-bold\" :style=\"{ color: getPodiumTextColor() }\">{{\n                      rankedStatistics[1].total_reuse_count || 0\n                    }}</span>\n                  </div>\n                </div>\n              </div>\n\n              <!-- 第一名 -->\n              <div v-if=\"rankedStatistics[0]\" class=\"text-center\">\n                <div class=\"rank-circle mb-2 first-place\" :style=\"getRankStyle(1)\">\n                  <VIcon :icon=\"getRankIcon(1)\" size=\"32\" />\n                </div>\n                <div class=\"text-h5 font-weight-bold\" :style=\"{ color: getPodiumTextColor() }\">\n                  {{ rankedStatistics[0].share_user || '未知' }}\n                </div>\n                <div class=\"d-flex align-center justify-center gap-3 mt-1\">\n                  <div class=\"d-flex align-center podium-stat\" :style=\"getPodiumStatStyle()\">\n                    <VIcon icon=\"mdi-share-outline\" size=\"14\" :color=\"getPodiumTextColor()\" class=\"mr-1\" />\n                    <span class=\"font-weight-bold\" :style=\"{ color: getPodiumTextColor() }\">{{\n                      rankedStatistics[0].share_count || 0\n                    }}</span>\n                  </div>\n                  <div class=\"d-flex align-center podium-stat\" :style=\"getPodiumStatStyle()\">\n                    <VIcon icon=\"mdi-fire\" size=\"14\" :color=\"getPodiumTextColor()\" class=\"mr-1\" />\n                    <span class=\"font-weight-bold\" :style=\"{ color: getPodiumTextColor() }\">{{\n                      rankedStatistics[0].total_reuse_count || 0\n                    }}</span>\n                  </div>\n                </div>\n              </div>\n\n              <!-- 第三名 -->\n              <div v-if=\"rankedStatistics[2]\" class=\"text-center\">\n                <div class=\"rank-circle mb-2\" :style=\"getRankStyle(3)\">\n                  <VIcon :icon=\"getRankIcon(3)\" size=\"24\" />\n                </div>\n                <div class=\"text-h6 font-weight-bold\" :style=\"{ color: getPodiumTextColor() }\">\n                  {{ rankedStatistics[2].share_user || '未知' }}\n                </div>\n                <div class=\"d-flex align-center justify-center gap-2 mt-1\">\n                  <div class=\"d-flex align-center podium-stat\" :style=\"getPodiumStatStyle()\">\n                    <VIcon icon=\"mdi-share-outline\" size=\"14\" :color=\"getPodiumTextColor()\" class=\"mr-1\" />\n                    <span class=\"font-weight-bold\" :style=\"{ color: getPodiumTextColor() }\">{{\n                      rankedStatistics[2].share_count || 0\n                    }}</span>\n                  </div>\n                  <div class=\"d-flex align-center podium-stat\" :style=\"getPodiumStatStyle()\">\n                    <VIcon icon=\"mdi-fire\" size=\"14\" :color=\"getPodiumTextColor()\" class=\"mr-1\" />\n                    <span class=\"font-weight-bold\" :style=\"{ color: getPodiumTextColor() }\">{{\n                      rankedStatistics[2].total_reuse_count || 0\n                    }}</span>\n                  </div>\n                </div>\n              </div>\n            </div>\n\n            <!-- 小屏幕垂直排列 -->\n            <div class=\"d-flex d-md-none flex-column align-center gap-4\">\n              <!-- 第一名 -->\n              <div v-if=\"rankedStatistics[0]\" class=\"text-center\">\n                <div class=\"rank-circle mb-2 first-place\" :style=\"getRankStyle(1)\">\n                  <VIcon :icon=\"getRankIcon(1)\" size=\"32\" />\n                </div>\n                <div class=\"text-h5 font-weight-bold\" :style=\"{ color: getPodiumTextColor() }\">\n                  {{ rankedStatistics[0].share_user || '未知' }}\n                </div>\n                <div class=\"d-flex align-center justify-center gap-3 mt-1\">\n                  <div class=\"d-flex align-center podium-stat\" :style=\"getPodiumStatStyle()\">\n                    <VIcon icon=\"mdi-share-outline\" size=\"14\" :color=\"getPodiumTextColor()\" class=\"mr-1\" />\n                    <span :style=\"{ color: getPodiumTextColor() }\">{{ rankedStatistics[0].share_count || 0 }}</span>\n                  </div>\n                  <div class=\"d-flex align-center podium-stat\" :style=\"getPodiumStatStyle()\">\n                    <VIcon icon=\"mdi-fire\" size=\"14\" :color=\"getPodiumTextColor()\" class=\"mr-1\" />\n                    <span :style=\"{ color: getPodiumTextColor() }\">{{\n                      rankedStatistics[0].total_reuse_count || 0\n                    }}</span>\n                  </div>\n                </div>\n              </div>\n\n              <!-- 第二名 -->\n              <div v-if=\"rankedStatistics[1]\" class=\"text-center\">\n                <div class=\"rank-circle mb-2\" :style=\"getRankStyle(2)\">\n                  <VIcon :icon=\"getRankIcon(2)\" size=\"24\" />\n                </div>\n                <div class=\"text-h6 font-weight-bold\" :style=\"{ color: getPodiumTextColor() }\">\n                  {{ rankedStatistics[1].share_user || '未知' }}\n                </div>\n                <div class=\"d-flex align-center justify-center gap-2 mt-1\">\n                  <div class=\"d-flex align-center podium-stat\" :style=\"getPodiumStatStyle()\">\n                    <VIcon icon=\"mdi-share-outline\" size=\"14\" :color=\"getPodiumTextColor()\" class=\"mr-1\" />\n                    <span :style=\"{ color: getPodiumTextColor() }\">{{ rankedStatistics[1].share_count || 0 }}</span>\n                  </div>\n                  <div class=\"d-flex align-center podium-stat\" :style=\"getPodiumStatStyle()\">\n                    <VIcon icon=\"mdi-fire\" size=\"14\" :color=\"getPodiumTextColor()\" class=\"mr-1\" />\n                    <span :style=\"{ color: getPodiumTextColor() }\">{{\n                      rankedStatistics[1].total_reuse_count || 0\n                    }}</span>\n                  </div>\n                </div>\n              </div>\n\n              <!-- 第三名 -->\n              <div v-if=\"rankedStatistics[2]\" class=\"text-center\">\n                <div class=\"rank-circle mb-2\" :style=\"getRankStyle(3)\">\n                  <VIcon :icon=\"getRankIcon(3)\" size=\"24\" />\n                </div>\n                <div class=\"text-h6 font-weight-bold\" :style=\"{ color: getPodiumTextColor() }\">\n                  {{ rankedStatistics[2].share_user || '未知' }}\n                </div>\n                <div class=\"d-flex align-center justify-center gap-2 mt-1\">\n                  <div class=\"d-flex align-center podium-stat\" :style=\"getPodiumStatStyle()\">\n                    <VIcon icon=\"mdi-share-outline\" size=\"14\" :color=\"getPodiumTextColor()\" class=\"mr-1\" />\n                    <span :style=\"{ color: getPodiumTextColor() }\">\n                      {{ rankedStatistics[2].share_count || 0 }}\n                    </span>\n                  </div>\n                  <div class=\"d-flex align-center podium-stat\" :style=\"getPodiumStatStyle()\">\n                    <VIcon icon=\"mdi-fire\" size=\"14\" :color=\"getPodiumTextColor()\" class=\"mr-1\" />\n                    <span :style=\"{ color: getPodiumTextColor() }\">\n                      {{ rankedStatistics[2].total_reuse_count || 0 }}\n                    </span>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </div>\n\n          <!-- 完整排行榜 -->\n          <VList class=\"bg-transparent px-3\">\n            <VListItem\n              v-for=\"item in rankedStatistics.filter(item => item.rank > 3)\"\n              :key=\"item.share_user\"\n              class=\"mb-2 rounded-lg\"\n            >\n              <VListItemTitle class=\"font-weight-bold text-h6 mb-1\">\n                {{ item.share_user || '未知' }}\n              </VListItemTitle>\n\n              <VListItemSubtitle class=\"d-flex align-center gap-3 mt-1\">\n                <div class=\"stat-badge share-badge\">\n                  <VIcon icon=\"mdi-share-outline\" size=\"14\" color=\"primary\" class=\"mr-1\" />\n                  <span class=\"text-primary font-weight-bold\">{{ item.share_count || 0 }}</span>\n                  <span class=\"text-grey text-caption ml-1\">{{ t('subscribe.shareCount') }}</span>\n                </div>\n                <div class=\"stat-badge reuse-badge\">\n                  <VIcon icon=\"mdi-fire\" size=\"14\" color=\"warning\" class=\"mr-1\" />\n                  <span class=\"text-warning font-weight-bold\">{{ item.total_reuse_count || 0 }}</span>\n                  <span class=\"text-grey text-caption ml-1\">{{ t('subscribe.totalReuseCount') }}</span>\n                </div>\n              </VListItemSubtitle>\n\n              <template #append>\n                <div class=\"text-right\">\n                  <div\n                    class=\"text-h6 font-weight-bold\"\n                    :style=\"{ color: item.rank <= 3 ? 'var(--v-primary-base)' : 'inherit' }\"\n                  >\n                    #{{ item.rank }}\n                  </div>\n                </div>\n              </template>\n            </VListItem>\n          </VList>\n        </div>\n      </VCardText>\n    </VCard>\n  </VDialog>\n</template>\n\n<style scoped>\n.rank-circle {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border-radius: 50%;\n  block-size: 60px;\n  inline-size: 60px;\n  margin-block: 0;\n  margin-inline: auto;\n}\n\n.first-place {\n  block-size: 80px;\n  box-shadow: 0 4px 12px rgba(255, 215, 0, 30%);\n  inline-size: 80px;\n}\n\n.rank-badge {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border-radius: 50%;\n  block-size: 32px;\n  inline-size: 32px;\n}\n\n.stat-badge {\n  display: flex;\n  align-items: center;\n  border: 1px solid rgba(var(--v-theme-outline), 0.2);\n  border-radius: 6px;\n  background: rgba(var(--v-theme-surface), 0.8);\n  padding-block: 4px;\n  padding-inline: 8px;\n  transition: all 0.2s ease;\n}\n\n.share-badge {\n  border-inline-start: 3px solid rgb(var(--v-theme-primary));\n}\n\n.reuse-badge {\n  border-inline-start: 3px solid rgb(var(--v-theme-warning));\n}\n\n.podium-stat {\n  border-radius: 6px;\n  backdrop-filter: blur(4px);\n  padding-block: 4px;\n  padding-inline: 8px;\n  transition: all 0.2s ease;\n}\n\n.podium-stat:hover {\n  transform: scale(1.05);\n}\n\n/* 前三名区域样式 */\n.podium-area {\n  position: relative;\n  z-index: 1;\n}\n\n.podium-title {\n  position: relative;\n  z-index: 2;\n  color: #fff !important;\n  font-weight: bold;\n  text-shadow: 0 2px 4px rgba(0, 0, 0, 30%);\n}\n\n/* 装饰性元素 */\n.podium-decoration {\n  position: absolute;\n  z-index: 0;\n  inset: 0;\n  pointer-events: none;\n}\n\n.decoration-circle {\n  position: absolute;\n  border-radius: 50%;\n  animation: float 6s ease-in-out infinite;\n  background: radial-gradient(circle, rgba(255, 255, 255, 10%) 0%, transparent 70%);\n}\n\n.decoration-1 {\n  animation-delay: 0s;\n  block-size: 80px;\n  inline-size: 80px;\n  inset-block-start: 10%;\n  inset-inline-start: 10%;\n}\n\n.decoration-2 {\n  animation-delay: 2s;\n  block-size: 60px;\n  inline-size: 60px;\n  inset-block-start: 20%;\n  inset-inline-end: 15%;\n}\n\n.decoration-3 {\n  animation-delay: 4s;\n  block-size: 40px;\n  inline-size: 40px;\n  inset-block-end: 20%;\n  inset-inline-start: 20%;\n}\n\n@keyframes float {\n  0%,\n  100% {\n    opacity: 0.6;\n    transform: translateY(0) rotate(0deg);\n  }\n\n  50% {\n    opacity: 1;\n    transform: translateY(-10px) rotate(180deg);\n  }\n}\n\n/* 增强前三名文字效果 */\n.podium-area .text-h6,\n.podium-area .text-h5 {\n  font-weight: bold;\n  text-shadow: 0 2px 4px rgba(0, 0, 0, 30%);\n}\n\n.podium-area .rank-circle {\n  border: 2px solid rgba(255, 255, 255, 20%);\n  box-shadow: 0 8px 24px rgba(0, 0, 0, 30%);\n}\n\n.podium-area .first-place {\n  border: 3px solid rgba(255, 215, 0, 50%);\n  box-shadow: 0 12px 32px rgba(255, 215, 0, 40%);\n}\n</style>\n"
  },
  {
    "path": "src/components/dialog/TransferQueueDialog.vue",
    "content": "<script lang=\"ts\" setup>\nimport { ref, computed, watch, onMounted, onUnmounted } from 'vue'\nimport { formatFileSize } from '@/@core/utils/formatters'\nimport api from '@/api'\nimport { FileItem, TransferQueue } from '@/api/types'\nimport { useDisplay } from 'vuetify'\nimport { useI18n } from 'vue-i18n'\nimport { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'\nimport CryptoJS from 'crypto-js'\n\n// 多语言支持\nconst { t } = useI18n()\nconst { useProgressSSE } = useBackgroundOptimization()\n\n// 显示器宽度\nconst display = useDisplay()\n// 定义触发的自定义事件\nconst emit = defineEmits(['close'])\n\n// 数据列表\nconst dataList = ref<TransferQueue[]>([])\n\n// 整体进度相关 - 根据完成的文件计算\nconst overallProgress = ref({\n  value: 0,\n  text: t('dialog.transferQueue.processing'),\n})\n\n// 文件进度映射\nconst fileProgressMap = ref<Map<string, { enable: boolean; value: number }>>(new Map())\n\n// 数据可刷新标志\nconst refreshFlag = ref(false)\n\n// 进度是否激活\nconst progressActive = ref(false)\n\n// 活动标签\nconst activeTab = ref('')\n\n// 定时器引用\nconst queueTimer = ref<NodeJS.Timeout | null>(null)\n\n// 状态标签\nconst stateDict: { [key: string]: string } = {\n  'waiting': t('dialog.transferQueue.waitingState'),\n  'running': t('dialog.transferQueue.runningState'),\n  'completed': t('dialog.transferQueue.finishedState'),\n  'failed': t('dialog.transferQueue.failedState'),\n  'cancelled': t('dialog.transferQueue.cancelledState'),\n}\n\n// 获取状态颜色\nfunction getStateColor(state: string) {\n  if (state === 'waiting') return 'gray'\n  else if (state === 'running') return 'primary'\n  else if (state === 'completed') return 'success'\n  else return 'error'\n}\n\n// 从dataList中提取所有的媒体信息，合并相同title_year的记录\nconst mediaList = computed(() => {\n  const mediaMap = new Map<string, any>()\n\n  dataList.value.forEach(item => {\n    const titleYear = item.media.title_year || ''\n    if (!mediaMap.has(titleYear)) {\n      mediaMap.set(titleYear, item.media)\n    }\n  })\n\n  return Array.from(mediaMap.values())\n})\n\n// 按media计算总数和完成数，返回 x/x\nfunction getMediaCount(title_year: string) {\n  // 按title_year查询出所有media列表\n  const medias = dataList.value.filter(item => item.media.title_year === title_year)\n  // 计算media下任务的总数\n  const total = medias.reduce((acc, cur) => acc + cur.tasks.length, 0)\n  // 计算media下任务的完成数\n  const completed = medias.reduce((acc, cur) => acc + cur.tasks.filter(task => task.state === 'completed').length, 0)\n  return `${completed} / ${total}`\n}\n\n// 根据媒体信息获取对应的整理任务，合并相同title_year的所有任务\nconst activeTasks = computed(() => {\n  const tasks = dataList.value.filter(item => item.media.title_year === activeTab.value).flatMap(item => item.tasks)\n  return tasks\n})\n\n// 根据媒体title_year获取对应的任务列表\nfunction getTasksByMedia(title_year: string) {\n  return dataList.value.filter(item => item.media.title_year === title_year).flatMap(item => item.tasks)\n}\n\n// 计算整体进度\nconst overallProgressComputed = computed(() => {\n  if (dataList.value.length === 0) return 0\n\n  const allTasks = dataList.value.flatMap(item => item.tasks)\n  const totalTasks = allTasks.length\n  const completedTasks = allTasks.filter(task => task.state === 'completed').length\n\n  return totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0\n})\n\n// 获取文件进度\nfunction getFileProgress(filePath: string) {\n  return fileProgressMap.value.get(filePath) || { enable: false, value: 0 }\n}\n\n// 调用API获取队列信息\nasync function get_transfer_queue() {\n  try {\n    dataList.value = await api.get('transfer/queue')\n    if (dataList.value.length > 0) {\n      if (!activeTab.value || activeTasks.value?.length == 0) activeTab.value = dataList.value[0].media.title_year || ''\n\n      // 如果有数据且SSE未启动，则启动SSE监听\n      if (!progressActive.value) {\n        startLoadingProgress()\n      }\n    } else {\n      // 如果没有数据，停止SSE监听\n      if (progressActive.value) {\n        stopLoadingProgress()\n      }\n    }\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 移除队列任务\nasync function remove_queue_task(fileitem: FileItem) {\n  try {\n    await api.delete(`transfer/queue`, { data: fileitem })\n    get_transfer_queue()\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 文件进度SSE消息处理函数\nfunction createFileProgressHandler(filePath: string) {\n  return function handleFileProgressMessage(event: MessageEvent) {\n    try {\n      const progress = JSON.parse(event.data)\n      if (progress) {\n        fileProgressMap.value.set(filePath, {\n          enable: progress.enable || false,\n          value: progress.value || 0,\n        })\n      }\n    } catch (error) {\n      console.error('解析文件进度消息失败:', error)\n    }\n  }\n}\n\n// 文件进度SSE连接映射\nconst fileProgressSSEMap = ref<Map<string, any>>(new Map())\n\n// 启动文件进度监听\nfunction startFileProgress(filePath: string) {\n  if (fileProgressSSEMap.value.has(filePath)) {\n    return // 已经存在连接\n  }\n\n  // filePath计算md5\n  const filePathMd5 = CryptoJS.MD5(filePath).toString()\n  // 使用包含文件路径的唯一监听器ID\n  const uniqueListenerId = `transfer-queue-file-progress-${filePathMd5}`\n  const fileProgressUrl = `${import.meta.env.VITE_API_BASE_URL}system/progress/${filePathMd5}`\n\n  const fileProgressSSE = useProgressSSE(\n    fileProgressUrl,\n    createFileProgressHandler(filePath),\n    uniqueListenerId,\n    progressActive,\n  )\n\n  fileProgressSSE.start()\n  fileProgressSSEMap.value.set(filePath, fileProgressSSE)\n}\n\n// 停止所有文件进度监听\nfunction stopAllFileProgress() {\n  fileProgressSSEMap.value.forEach((sse, filePath) => {\n    sse.stop()\n  })\n  fileProgressSSEMap.value.clear()\n  fileProgressMap.value.clear()\n}\n\n// 监听队列变化，自动管理文件进度SSE\nwatch(\n  dataList,\n  newDataList => {\n    // 获取当前正在运行的文件路径集合\n    const currentRunningFiles = new Set<string>()\n    newDataList.forEach(item => {\n      item.tasks.forEach(task => {\n        if (task.state === 'running') {\n          currentRunningFiles.add(task.fileitem.path)\n        }\n      })\n    })\n\n    // 获取当前已建立SSE连接的文件路径集合\n    const currentSSEFiles = new Set(fileProgressSSEMap.value.keys())\n\n    // 停止不再需要的SSE连接\n    currentSSEFiles.forEach(filePath => {\n      if (!currentRunningFiles.has(filePath)) {\n        const sse = fileProgressSSEMap.value.get(filePath)\n        if (sse) {\n          sse.stop()\n          fileProgressSSEMap.value.delete(filePath)\n        }\n        // 清除对应的进度数据\n        fileProgressMap.value.delete(filePath)\n      }\n    })\n\n    // 为新的运行中文件建立SSE连接\n    currentRunningFiles.forEach(filePath => {\n      if (!fileProgressSSEMap.value.has(filePath)) {\n        startFileProgress(filePath)\n      }\n    })\n  },\n  { deep: true },\n)\n\n// 使用SSE监听加载进度\nfunction startLoadingProgress() {\n  overallProgress.value.text = t('dialog.transferQueue.processing')\n  progressActive.value = true\n}\n\n// 停止监听加载进度\nfunction stopLoadingProgress() {\n  progressActive.value = false\n  // 只有在没有数据时才停止所有文件进度监听\n  if (dataList.value.length === 0) {\n    stopAllFileProgress()\n  }\n}\n\n// 启动定时获取队列\nfunction startQueueTimer() {\n  // 清除可能存在的定时器\n  if (queueTimer.value) {\n    clearInterval(queueTimer.value)\n  }\n\n  // 立即执行一次\n  get_transfer_queue()\n\n  // 设置3秒定时器\n  queueTimer.value = setInterval(() => {\n    get_transfer_queue()\n  }, 3000)\n}\n\n// 停止定时获取队列\nfunction stopQueueTimer() {\n  if (queueTimer.value) {\n    clearInterval(queueTimer.value)\n    queueTimer.value = null\n  }\n}\n\nonMounted(() => {\n  startQueueTimer()\n})\n\nonUnmounted(() => {\n  stopQueueTimer()\n  stopLoadingProgress()\n})\n</script>\n\n<template>\n  <VDialog scrollable max-width=\"60rem\" :fullscreen=\"!display.mdAndUp.value\">\n    <VCard class=\"mx-auto\" width=\"100%\">\n      <VCardItem>\n        <VCardTitle>{{ t('dialog.transferQueue.title') }}</VCardTitle>\n      </VCardItem>\n      <VDialogCloseBtn @click=\"emit('close')\" />\n\n      <!-- 整体进度显示 -->\n      <VProgressLinear v-if=\"dataList.length > 0\" :model-value=\"overallProgressComputed\" color=\"primary\" />\n      <VDivider v-else />\n\n      <VCardText v-if=\"dataList.length === 0\" class=\"text-center\">\n        {{ t('dialog.transferQueue.noTasks') }}\n      </VCardText>\n\n      <VCardText v-if=\"dataList.length > 0\">\n        <VTabs v-model=\"activeTab\" show-arrows class=\"v-tabs-pill\" stacked>\n          <VTab\n            v-for=\"media in mediaList\"\n            :value=\"media.title_year\"\n            selected-class=\"v-slide-group-item--active v-tab--selected\"\n          >\n            <div class=\"font-bold text-lg\">{{ media.title }}</div>\n            <div>({{ getMediaCount(media.title_year || '') }})</div>\n          </VTab>\n        </VTabs>\n        <VWindow v-model=\"activeTab\" class=\"mt-5 disable-tab-transition\" :touch=\"false\">\n          <VWindowItem v-for=\"media in mediaList\" :value=\"media.title_year\">\n            <VList>\n              <VListItem v-for=\"task in getTasksByMedia(media.title_year || '')\" :key=\"task.fileitem.path\">\n                <VListItemTitle>{{ task.fileitem.name }}</VListItemTitle>\n                <VListItemSubtitle class=\"py-1\">\n                  {{ t('dialog.transferQueue.sizeTitle') }}：{{ formatFileSize(task.fileitem.size || 0) }}\n                  <VChip size=\"small\" :color=\"getStateColor(task.state)\" class=\"mx-2\">\n                    {{ stateDict[task.state] }}\n                  </VChip>\n                </VListItemSubtitle>\n\n                <!-- 文件进度显示 -->\n                <div v-if=\"task.state === 'running' && getFileProgress(task.fileitem.path).enable\" class=\"mt-2\">\n                  <VProgressLinear\n                    :model-value=\"getFileProgress(task.fileitem.path).value\"\n                    color=\"success\"\n                    class=\"mb-1\"\n                    :height=\"3\"\n                  />\n                  <div class=\"text-xs text-medium-emphasis text-center\">\n                    {{ getFileProgress(task.fileitem.path).value.toFixed(1) }}%\n                  </div>\n                </div>\n                <template #append>\n                  <IconBtn\n                    size=\"small\"\n                    icon=\"mdi-cancel\"\n                    @click=\"remove_queue_task(task.fileitem)\"\n                    :disabled=\"task.state === 'completed'\"\n                  />\n                </template>\n              </VListItem>\n            </VList>\n          </VWindowItem>\n        </VWindow>\n      </VCardText>\n    </VCard>\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/components/dialog/U115AuthDialog.vue",
    "content": "<script lang=\"ts\" setup>\nimport api from '@/api'\nimport { useI18n } from 'vue-i18n'\nimport { useDisplay } from 'vuetify'\n\n// 常量定义\nconst AUTH_WINDOW_WIDTH = 600\nconst AUTH_WINDOW_HEIGHT = 700\nconst POLL_INTERVAL = 2000\nconst AUTH_STATUS_SUCCESS = 2\nconst AUTH_STATUS_FAILED = -1\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 多语言支持\nconst { t } = useI18n()\n\n// Props 定义\nconst props = defineProps({\n  conf: {\n    type: Object as PropType<{ [key: string]: any }>,\n    required: true,\n  },\n})\n\n// Events 定义\nconst emit = defineEmits(['done', 'close'])\n\n// 响应式状态\nconst authUrl = ref('')\nconst authState = ref('')\nconst text = ref('')\nconst alertType = ref<'success' | 'info' | 'error' | 'warning'>('info')\n\n// 授权窗口引用\nlet authWindow: Window | null = null\nlet pollTimer: NodeJS.Timeout | undefined\n\n// 清理资源\nfunction cleanup() {\n  if (pollTimer) {\n    clearTimeout(pollTimer)\n    pollTimer = undefined\n  }\n  if (authWindow && !authWindow.closed) {\n    authWindow.close()\n    authWindow = null\n  }\n}\n\n// 设置提示消息\nfunction setMessage(type: typeof alertType.value, message: string) {\n  alertType.value = type\n  text.value = message\n}\n\n// 完成授权\nfunction handleDone() {\n  cleanup()\n  emit('done')\n}\n\n// 重置配置\nasync function handleReset() {\n  try {\n    const result: { [key: string]: any } = await api.get('/storage/reset/u115')\n    if (result.success) {\n      setMessage('success', t('dialog.u115Auth.authSuccess'))\n      handleDone()\n    }\n    else {\n      setMessage('error', result.message || t('dialog.u115Auth.authFailed'))\n    }\n  }\n  catch (error) {\n    console.error('Reset failed:', error)\n    setMessage('error', t('dialog.u115Auth.authFailed'))\n  }\n}\n\n// 获取授权URL\nasync function fetchAuthUrl() {\n  try {\n    const result: { [key: string]: any } = await api.get('/storage/auth_url/u115')\n\n    if (result.success && result.data) {\n      authUrl.value = result.data.authUrl\n      authState.value = result.data.state\n    }\n    else {\n      setMessage('error', result.message || t('dialog.u115Auth.urlFetchFailed'))\n    }\n  }\n  catch (error) {\n    console.error('Fetch auth URL failed:', error)\n    setMessage('error', t('dialog.u115Auth.urlFetchFailed'))\n  }\n}\n\n// 打开授权窗口\nfunction openAuthWindow() {\n  if (!authUrl.value) {\n    setMessage('error', t('dialog.u115Auth.urlEmpty'))\n    return\n  }\n\n  const left = (window.screen.width - AUTH_WINDOW_WIDTH) / 2\n  const top = (window.screen.height - AUTH_WINDOW_HEIGHT) / 2\n  const features = [\n    `width=${AUTH_WINDOW_WIDTH}`,\n    `height=${AUTH_WINDOW_HEIGHT}`,\n    `left=${left}`,\n    `top=${top}`,\n    'toolbar=no',\n    'location=no',\n    'status=no',\n    'menubar=no',\n    'scrollbars=yes',\n    'resizable=yes',\n  ].join(',')\n\n  authWindow = window.open(authUrl.value, '115授权', features)\n\n  if (authWindow) {\n    setMessage('info', t('dialog.u115Auth.authorizing'))\n    pollTimer = setTimeout(checkAuthStatus, POLL_INTERVAL)\n  }\n  else {\n    setMessage('error', t('dialog.u115Auth.popupBlocked'))\n  }\n}\n\n// 检查授权状态\nasync function checkAuthStatus() {\n  try {\n    const result: { [key: string]: any } = await api.get('/storage/check/u115')\n\n    if (result.success && result.data) {\n      const { status, tip } = result.data\n\n      if (status === AUTH_STATUS_SUCCESS) {\n        // 授权成功\n        setMessage('success', t('dialog.u115Auth.authSuccess'))\n        handleDone()\n        return\n      }\n\n      if (status === AUTH_STATUS_FAILED) {\n        // 授权失败或过期\n        setMessage('error', tip || t('dialog.u115Auth.authFailed'))\n        cleanup()\n        return\n      }\n\n      // status === 0 或 1，继续等待\n    }\n  }\n  catch (error) {\n    console.error('Check auth status failed:', error)\n  }\n\n  // 检查窗口是否被用户关闭\n  if (authWindow?.closed) {\n    setMessage('warning', t('dialog.u115Auth.authCanceled'))\n    cleanup()\n    return\n  }\n\n  // 继续轮询\n  pollTimer = setTimeout(checkAuthStatus, POLL_INTERVAL)\n}\n\n// 生命周期钩子\nonMounted(() => {\n  fetchAuthUrl()\n})\n\nonUnmounted(() => {\n  cleanup()\n})\n</script>\n\n<template>\n  <VDialog width=\"40rem\" scrollable :fullscreen=\"!display.mdAndUp.value\">\n    <VCard>\n      <VDialogCloseBtn @click=\"emit('close')\" />\n\n      <VCardItem>\n        <template #prepend>\n          <VIcon icon=\"mdi-shield-key\" class=\"me-2\" />\n        </template>\n        <VCardTitle>\n          {{ t('dialog.u115Auth.loginTitle') }}\n        </VCardTitle>\n      </VCardItem>\n\n      <VDivider />\n\n      <VCardText class=\"pt-2 flex flex-col items-center justify-center\">\n        <!-- 授权按钮 -->\n        <div class=\"mt-6 mb-4 text-center\">\n          <VBtn\n            size=\"x-large\"\n            color=\"primary\"\n            prepend-icon=\"mdi-login\"\n            :disabled=\"!authUrl\"\n            class=\"px-8\"\n            @click=\"openAuthWindow\"\n          >\n            {{ t('dialog.u115Auth.openAuthWindow') }}\n          </VBtn>\n        </div>\n\n        <!-- 状态提示 -->\n        <div v-if=\"text\" class=\"w-full\">\n          <VAlert\n            variant=\"tonal\"\n            :type=\"alertType\"\n            :text=\"text\"\n            class=\"my-4 text-center\"\n          >\n            <template #prepend />\n          </VAlert>\n        </div>\n      </VCardText>\n\n      <VCardActions>\n        <VBtn\n          color=\"error\"\n          prepend-icon=\"mdi-restore\"\n          class=\"px-5 me-3\"\n          @click=\"handleReset\"\n        >\n          {{ t('dialog.u115Auth.reset') }}\n        </VBtn>\n\n        <VSpacer />\n\n        <VBtn\n          prepend-icon=\"mdi-check\"\n          class=\"px-5 me-3\"\n          @click=\"handleDone\"\n        >\n          {{ t('dialog.u115Auth.complete') }}\n        </VBtn>\n      </VCardActions>\n    </VCard>\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/components/dialog/UserAddEditDialog.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useToast } from 'vue-toastification'\nimport type { User } from '@/api/types'\nimport { doneNProgress, startNProgress } from '@/api/nprogress'\nimport api from '@/api'\nimport { useDisplay } from 'vuetify'\nimport avatar1 from '@images/avatars/avatar-1.png'\nimport { useUserStore } from '@/stores'\nimport { useI18n } from 'vue-i18n'\n\n// 多语言支持\nconst { t } = useI18n()\n\n// 显示器宽度\nconst display = useDisplay()\n\nconst refInputEl = ref<HTMLElement>()\nconst isNewPasswordVisible = ref(false)\nconst isConfirmPasswordVisible = ref(false)\nconst newPassword = ref('')\nconst confirmPassword = ref('')\n\n// 输入参数\nconst props = defineProps({\n  username: String,\n  usernames: Array,\n  oper: String,\n})\n\n// 用户 Store\nconst userStore = useUserStore()\n\n// 当前登录用户名称\nconst currentLoginUser = userStore.userName\n\n// 用户名\nconst userName = ref('')\n\n// 当前头像缓存\nconst currentAvatar = ref(avatar1)\n\n// 用户名缓存\nconst currentUserName = ref('')\n\n// 注册事件\nconst emit = defineEmits(['save', 'close'])\n\n// 创建新用户按钮运行状态\nconst isAdding = ref(false)\n\n// 更新用户消息按钮运行状态\nconst isUpdating = ref(false)\n\n// 提示框\nconst $toast = useToast()\n\n// 状态下拉项\nconst statusItems = [\n  { title: t('dialog.userAddEdit.active'), value: 1 },\n  { title: t('dialog.userAddEdit.inactive'), value: 0 },\n]\n\n// 扩展User类型以包含note字段\ninterface ExtendedUser extends User {\n  nickname?: string\n}\n\n// 权限类型定义\ninterface UserPermissions {\n  discovery: boolean // 发现权限\n  search: boolean // 搜索权限\n  subscribe: boolean // 订阅权限\n  manage: boolean // 管理权限\n}\n\n// 用户编辑表单数据\nconst userForm = ref<ExtendedUser>({\n  id: 0,\n  name: props.username ?? '',\n  password: '',\n  email: '',\n  is_active: true,\n  is_superuser: false,\n  avatar: avatar1,\n  is_otp: false,\n  permissions: {\n    discovery: true,\n    search: true,\n    subscribe: true,\n    manage: false,\n  },\n  settings: {\n    wechat_userid: null,\n    telegram_userid: null,\n    slack_userid: null,\n    discord_userid: null,\n    vocechat_userid: null,\n    synologychat_userid: null,\n  },\n  nickname: '', // 昵称字段\n})\n\n// 权限选项\nconst permissionOptions = [\n  {\n    key: 'discovery',\n    title: t('dialog.userAddEdit.permissions.discovery'),\n    description: t('dialog.userAddEdit.permissions.discoveryDesc'),\n    icon: 'mdi-star-outline',\n  },\n  {\n    key: 'search',\n    title: t('dialog.userAddEdit.permissions.search'),\n    description: t('dialog.userAddEdit.permissions.searchDesc'),\n    icon: 'mdi-magnify',\n  },\n  {\n    key: 'subscribe',\n    title: t('dialog.userAddEdit.permissions.subscribe'),\n    description: t('dialog.userAddEdit.permissions.subscribeDesc'),\n    icon: 'mdi-rss',\n  },\n  {\n    key: 'manage',\n    title: t('dialog.userAddEdit.permissions.manage'),\n    description: t('dialog.userAddEdit.permissions.manageDesc'),\n    icon: 'mdi-cog-outline',\n  },\n]\n\n// 权限状态计算属性\nconst userPermissions = computed({\n  get: () => {\n    const permissions = userForm.value.permissions as UserPermissions\n    return {\n      discovery: permissions?.discovery ?? true,\n      search: permissions?.search ?? true,\n      subscribe: permissions?.subscribe ?? true,\n      manage: permissions?.manage ?? false,\n    }\n  },\n  set: (value: UserPermissions) => {\n    userForm.value.permissions = value\n  },\n})\n\n// 切换权限状态\nfunction togglePermission(key: keyof UserPermissions) {\n  const currentPermissions = userPermissions.value\n  userPermissions.value = {\n    ...currentPermissions,\n    [key]: !currentPermissions[key],\n  }\n}\n\n// 更新头像\nfunction changeAvatar(file: Event) {\n  const fileReader = new FileReader()\n  const { files } = file.target as HTMLInputElement\n  if (files && files.length > 0) {\n    const selectedFile = files[0]\n    const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']\n    const maxSize = 800 * 1024\n    // 检查文件是否为图片\n    if (!allowedTypes.includes(selectedFile.type)) {\n      $toast.error(t('dialog.userAddEdit.invalidFile'))\n      return\n    }\n    // 检查文件大小\n    if (selectedFile.size > maxSize) {\n      $toast.error(t('dialog.userAddEdit.fileSizeLimit'))\n      return\n    }\n    fileReader.readAsDataURL(selectedFile)\n    fileReader.onload = () => {\n      if (typeof fileReader.result === 'string') {\n        currentAvatar.value = fileReader.result\n        $toast.success(t('dialog.userAddEdit.avatarUploadSuccess'))\n      }\n    }\n  }\n}\n\n// 重置默认头像\nfunction resetDefaultAvatar() {\n  currentAvatar.value = avatar1\n  $toast.success(t('dialog.userAddEdit.resetAvatarSuccess'))\n}\n\n// 还原当前头像\nfunction restoreCurrentAvatar() {\n  currentAvatar.value = userForm.value.avatar\n  $toast.success(t('dialog.userAddEdit.restoreAvatarSuccess'))\n}\n\n// 查询用户信息\nasync function fetchUserInfo() {\n  try {\n    userForm.value = await api.get(`user/${props.username}`)\n    if (userForm.value) {\n      userForm.value.avatar = userForm.value.avatar || avatar1\n      userForm.value.nickname = userForm.value.settings?.nickname ?? ''\n      currentAvatar.value = userForm.value.avatar\n      currentUserName.value = userForm.value.name\n      userName.value = userForm.value.name\n    }\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 调用API 新增用户\nasync function addUser() {\n  if (isAdding.value) {\n    $toast.error(t('dialog.userAddEdit.creatingUser', { name: userForm.value.name }))\n    return\n  }\n  if (!currentUserName.value) {\n    $toast.error(t('dialog.userAddEdit.usernameRequired'))\n    return\n  } else userForm.value.name = currentUserName.value\n  // 重名检查\n  if (props.usernames && props.usernames.includes(userForm.value.name)) {\n    $toast.error(t('dialog.userAddEdit.usernameExists'))\n    return\n  }\n  if (!userForm.value?.name || !newPassword.value) return\n  if (newPassword.value || confirmPassword.value) {\n    if (newPassword.value !== confirmPassword.value) {\n      $toast.error(t('dialog.userAddEdit.passwordMismatch'))\n      return\n    }\n    userForm.value.password = newPassword.value\n  }\n\n  // 设置权限数据\n  userForm.value.permissions = userPermissions.value\n\n  isAdding.value = true\n  startNProgress()\n  try {\n    const result: { [key: string]: string } = await api.post('user/', userForm.value)\n    if (result.success) {\n      $toast.success(t('dialog.userAddEdit.userCreated', { name: userForm.value.name }))\n      emit('save')\n    } else {\n      $toast.error(t('dialog.userAddEdit.userCreateFailed', { message: result.message }))\n      // 清除用户名\n      userForm.value.name = ''\n    }\n  } catch (error) {\n    console.error(error)\n  }\n  doneNProgress()\n  isAdding.value = false\n}\n\n// 调用API更新用户信息\nasync function updateUser() {\n  if (isUpdating.value) {\n    $toast.error(t('dialog.userAddEdit.updatingUser', { name: userForm.value.name }))\n    return\n  }\n  if (!currentUserName.value) {\n    $toast.error(t('dialog.userAddEdit.usernameRequired'))\n    return\n  }\n  if (newPassword.value || confirmPassword.value) {\n    if (newPassword.value !== confirmPassword.value) {\n      $toast.error(t('dialog.userAddEdit.passwordMismatch'))\n      return\n    }\n    userForm.value.password = newPassword.value\n  }\n\n  // 将nickname保存到settings中，后端可以直接处理JSON对象\n  if (!userForm.value.settings) {\n    userForm.value.settings = {}\n  }\n  userForm.value.settings.nickname = userForm.value.nickname ?? ''\n\n  const oldUserName = userForm.value.name\n  userForm.value.name = currentUserName.value\n  const oldAvatar = userForm.value.avatar\n  userForm.value.avatar = currentAvatar.value\n  isUpdating.value = true\n  startNProgress()\n  try {\n    // 确保昵称和权限保存，使用一个临时变量存储完整数据\n    const userData = { ...userForm.value }\n    // 确保权限数据正确传递\n    userData.permissions = userPermissions.value\n\n    const result: { [key: string]: any } = await api.put('user/', userData)\n\n    if (result.success) {\n      if (oldUserName !== currentUserName.value) {\n        $toast.success(t('dialog.userAddEdit.userUpdateSuccess', { name: `${oldUserName} → ${currentUserName.value}` }))\n        // 如果是当前登录用户，更新当前用户名称显示\n        if (isCurrentUser.value) {\n          userStore.setUserName(currentUserName.value)\n        }\n      } else {\n        $toast.success(t('dialog.userAddEdit.userUpdateSuccess', { name: userForm.value?.name }))\n      }\n      // 更新本地头像显示\n      if (oldAvatar !== currentAvatar.value && isCurrentUser.value) {\n        userStore.setAvatar(currentAvatar.value)\n      }\n      // 如果是当前登录用户，更新权限信息\n      if (isCurrentUser.value) {\n        userStore.setPermissions(userPermissions.value)\n      }\n      emit('save')\n    } else {\n      if (oldUserName !== currentUserName.value) {\n        $toast.error(t('dialog.userAddEdit.userUpdateFailed', { message: result.message }))\n        currentUserName.value = oldUserName\n      } else {\n        $toast.error(t('dialog.userAddEdit.userUpdateFailed', { message: result.message }))\n      }\n    }\n    //失败缓存值还原\n    currentUserName.value = userForm.value.name\n    userForm.value.name = oldUserName\n    currentAvatar.value = userForm.value.avatar\n    userForm.value.avatar = oldAvatar\n    userForm.value.password = ''\n  } catch (error) {\n    $toast.error(t('dialog.userAddEdit.userUpdateFailed', { message: '' }))\n    console.error('更新失败:', error)\n  }\n  doneNProgress()\n  isUpdating.value = false\n}\n\n// 用户状态转换，true/false转换为1/0\nconst userStatus = computed({\n  get: () => (userForm.value.is_active ? 1 : 0),\n  set: (value: number) => {\n    userForm.value.is_active = value === 1\n  },\n})\n\n// 计算是否有用户管理权限\nconst canControl = computed(() => {\n  // 新增用户时，有权限\n  if (props.oper === 'add') {\n    return true\n  } else {\n    // 调用isCurrentUser函数判断是否为当前用户\n    return !isCurrentUser.value\n  }\n})\n\n// 检查是否为当前用户\nconst isCurrentUser = computed(() => {\n  return props.username === currentLoginUser\n})\n\nonMounted(() => {\n  if (props.oper !== 'add') {\n    fetchUserInfo()\n  }\n})\n</script>\n\n<template>\n  <VDialog scrollable max-width=\"40rem\" :fullscreen=\"!display.mdAndUp.value\">\n    <VCard>\n      <VCardItem :class=\"props.oper === 'add' ? 'py-3' : 'py-2'\">\n        <template #prepend>\n          <VIcon icon=\"mdi-account\" class=\"me-2\" />\n        </template>\n        <VCardTitle>{{ props.oper === 'add' ? t('dialog.userAddEdit.add') : t('dialog.userAddEdit.edit') }}</VCardTitle>\n        <VCardSubtitle>{{ userName }}</VCardSubtitle>\n      </VCardItem>\n      <VDialogCloseBtn @click=\"emit('close')\" />\n      <VDivider />\n      <VCardItem>\n        <!-- 👉 Avatar -->\n        <div class=\"flex flex-row\">\n          <VAvatar rounded=\"lg\" size=\"100\" class=\"me-5\" :image=\"currentAvatar\" />\n          <!-- 👉 Upload Photo -->\n          <div class=\"flex flex-col justify-center gap-5\">\n            <div class=\"flex flex-wrap gap-2\">\n              <VBtn color=\"primary\" @click=\"refInputEl?.click()\">\n                <VIcon icon=\"mdi-cloud-upload-outline\" />\n                <span v-if=\"display.mdAndUp.value\" class=\"ms-2\">{{ t('dialog.userAddEdit.uploadAvatar') }}</span>\n              </VBtn>\n\n              <input\n                ref=\"refInputEl\"\n                type=\"file\"\n                name=\"file\"\n                accept=\".jpeg,.png,.jpg,GIF\"\n                hidden\n                @input=\"changeAvatar\"\n              />\n\n              <VBtn type=\"reset\" color=\"info\" variant=\"tonal\" @click=\"restoreCurrentAvatar\" v-if=\"props.oper !== 'add'\">\n                <VIcon icon=\"mdi-refresh\" />\n                <span v-if=\"display.mdAndUp.value\" class=\"ms-2\">{{ t('common.cancel') }}</span>\n              </VBtn>\n\n              <VBtn\n                type=\"reset\"\n                :color=\"props.oper === 'add' ? 'info' : 'error'\"\n                variant=\"tonal\"\n                @click=\"resetDefaultAvatar\"\n              >\n                <VIcon icon=\"mdi-image-sync-outline\" />\n                <span v-if=\"display.mdAndUp.value\" class=\"ms-2\">{{ t('dialog.userAddEdit.resetDefaultAvatar') }}</span>\n              </VBtn>\n            </div>\n            <p class=\"text-body-1 mb-0\">{{ t('dialog.userAddEdit.fileSizeLimit') }}</p>\n          </div>\n        </div>\n      </VCardItem>\n      <VCardText>\n        <VForm @submit.prevent=\"() => {}\">\n          <VDivider class=\"my-10\">\n            <span>{{ t('dialog.userAddEdit.saveUserInfo') }}</span>\n          </VDivider>\n          <VRow>\n            <VCol md=\"6\" cols=\"12\">\n              <VTextField\n                v-model=\"currentUserName\"\n                density=\"comfortable\"\n                :readonly=\"props.oper !== 'add'\"\n                :label=\"t('dialog.userAddEdit.username')\"\n                prepend-inner-icon=\"mdi-account\"\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"6\">\n              <VTextField\n                v-model=\"userForm.email\"\n                density=\"comfortable\"\n                clearable\n                :label=\"t('dialog.userAddEdit.email')\"\n                type=\"email\"\n                prepend-inner-icon=\"mdi-email\"\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"6\">\n              <VTextField\n                v-model=\"newPassword\"\n                density=\"comfortable\"\n                :type=\"isNewPasswordVisible ? 'text' : 'password'\"\n                :append-inner-icon=\"isNewPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'\"\n                clearable\n                :label=\"t('dialog.userAddEdit.password')\"\n                autocomplete=\"\"\n                prepend-inner-icon=\"mdi-lock\"\n                @click:append-inner=\"isNewPasswordVisible = !isNewPasswordVisible\"\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"6\">\n              <!-- 👉 confirm password -->\n              <VTextField\n                v-model=\"confirmPassword\"\n                density=\"comfortable\"\n                :type=\"isConfirmPasswordVisible ? 'text' : 'password'\"\n                :append-inner-icon=\"isConfirmPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'\"\n                clearable\n                :label=\"t('dialog.userAddEdit.confirmPassword')\"\n                prepend-inner-icon=\"mdi-lock-check\"\n                @click:append-inner=\"isConfirmPasswordVisible = !isConfirmPasswordVisible\"\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"6\">\n              <VTextField\n                v-model=\"userForm.nickname\"\n                density=\"comfortable\"\n                clearable\n                :label=\"t('dialog.userAddEdit.nickname')\"\n                placeholder=\"显示昵称，优先于用户名显示\"\n                prepend-inner-icon=\"mdi-card-account-details\"\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"6\" v-if=\"canControl\">\n              <VSelect\n                v-model=\"userStatus\"\n                :items=\"statusItems\"\n                item-text=\"title\"\n                item-value=\"value\"\n                :label=\"t('dialog.userAddEdit.status')\"\n                dense\n                prepend-inner-icon=\"mdi-toggle-switch\"\n              />\n            </VCol>\n          </VRow>\n          <VDivider class=\"my-10\">\n            <span>{{ t('dialog.userAddEdit.notifications') }}</span>\n          </VDivider>\n          <VRow>\n            <VCol cols=\"12\" md=\"6\">\n              <VTextField\n                v-model=\"userForm.settings.wechat_userid\"\n                density=\"comfortable\"\n                clearable\n                :label=\"t('dialog.userAddEdit.wechat')\"\n                prepend-inner-icon=\"mdi-wechat\"\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"6\">\n              <VTextField\n                v-model=\"userForm.settings.telegram_userid\"\n                density=\"comfortable\"\n                clearable\n                :label=\"t('dialog.userAddEdit.telegram')\"\n                prepend-inner-icon=\"mdi-send\"\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"6\">\n              <VTextField\n                v-model=\"userForm.settings.slack_userid\"\n                density=\"comfortable\"\n                clearable\n                :label=\"t('dialog.userAddEdit.slack')\"\n                prepend-inner-icon=\"mdi-slack\"\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"6\">\n              <VTextField\n                v-model=\"userForm.settings.discord_userid\"\n                density=\"comfortable\"\n                clearable\n                :label=\"t('dialog.userAddEdit.discord')\"\n                prepend-inner-icon=\"mdi-discord\"\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"6\">\n              <VTextField\n                v-model=\"userForm.settings.vocechat_userid\"\n                density=\"comfortable\"\n                clearable\n                :label=\"t('dialog.userAddEdit.vocechat')\"\n                prepend-inner-icon=\"mdi-chat\"\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"6\">\n              <VTextField\n                v-model=\"userForm.settings.synologychat_userid\"\n                density=\"comfortable\"\n                clearable\n                :label=\"t('dialog.userAddEdit.synologyChat')\"\n                prepend-inner-icon=\"mdi-message\"\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"6\">\n              <VTextField\n                v-model=\"userForm.settings.douban_userid\"\n                density=\"comfortable\"\n                clearable\n                label=\"豆瓣用户\"\n                prepend-inner-icon=\"mdi-movie\"\n              />\n            </VCol>\n          </VRow>\n          <VDivider class=\"my-10\" v-if=\"canControl\">\n            <span>{{ t('dialog.userAddEdit.permissions.title') }}</span>\n          </VDivider>\n          <!-- 权限设置 -->\n          <div v-if=\"canControl\">\n            <VRow>\n              <VCol v-for=\"option in permissionOptions\" :key=\"option.key\" cols=\"6\">\n                <VCard\n                  :color=\"userPermissions[option.key as keyof UserPermissions] ? 'primary' : 'surface'\"\n                  :variant=\"userPermissions[option.key as keyof UserPermissions] ? 'tonal' : 'outlined'\"\n                  class=\"cursor-pointer transition-all h-full\"\n                  @click=\"togglePermission(option.key as keyof UserPermissions)\"\n                  hover\n                >\n                  <VCardText class=\"d-flex align-center pa-4\">\n                    <VAvatar\n                      :color=\"userPermissions[option.key as keyof UserPermissions] ? 'primary' : 'surface-variant'\"\n                      size=\"40\"\n                      class=\"me-3\"\n                    >\n                      <VIcon :icon=\"option.icon\" />\n                    </VAvatar>\n                    <div class=\"flex-grow-1\">\n                      <div class=\"text-subtitle-1 font-weight-medium d-flex align-center\">\n                        {{ option.title }}\n                        <VIcon\n                          v-if=\"userPermissions[option.key as keyof UserPermissions]\"\n                          icon=\"mdi-check-circle\"\n                          color=\"primary\"\n                          size=\"small\"\n                          class=\"ms-2\"\n                        />\n                      </div>\n                      <div class=\"text-caption text-medium-emphasis\">\n                        {{ option.description }}\n                      </div>\n                    </div>\n                  </VCardText>\n                </VCard>\n              </VCol>\n            </VRow>\n          </div>\n        </VForm>\n      </VCardText>\n      <VCardActions class=\"pt-3\">\n        <VSpacer />\n        <VBtn\n          v-if=\"props.oper === 'add'\"\n          :disabled=\"isAdding\"\n          color=\"primary\"\n          @click=\"addUser\"\n          prepend-icon=\"mdi-plus\"\n          class=\"px-5\"\n        >\n          <span v-if=\"isAdding\">{{ t('common.loading') }}</span>\n          <span v-else>{{ t('common.add') }}</span>\n        </VBtn>\n        <VBtn\n          v-else\n          :disabled=\"isUpdating\"\n          color=\"primary\"\n          @click=\"updateUser\"\n          prepend-icon=\"mdi-content-save\"\n          class=\"px-5\"\n        >\n          <span v-if=\"isUpdating\">{{ t('common.loading') }}</span>\n          <span v-else>{{ t('common.save') }}</span>\n        </VBtn>\n      </VCardActions>\n    </VCard>\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/components/dialog/UserAuthDialog.vue",
    "content": "<script lang=\"ts\" setup>\nimport { isNullOrEmptyObject } from '@/@core/utils'\nimport api from '@/api'\nimport { useToast } from 'vue-toastification'\nimport { useI18n } from 'vue-i18n'\n\n// 多语言支持\nconst { t } = useI18n()\n\n// 定义事件\nconst emit = defineEmits(['done', 'close'])\n\n// 提示框\nconst $toast = useToast()\n\n// 是否加载中\nconst loading = ref(false)\n\n// 用户认证表单\nconst authForm = ref<any>({\n  site: null,\n  params: {},\n})\n\n// 所有认证站点\nconst authSites = ref<{\n  [key: string]: {\n    name: string\n    icon: string\n    params: { [key: string]: any }\n  }\n}>({})\n\n// 生成站点拉选项\nconst dropdownItems = computed(() => {\n  return Object.keys(authSites.value).map(key => {\n    return {\n      key,\n      name: authSites.value[key].name,\n      prependAvatar: authSites.value[key].icon,\n    }\n  })\n})\n\n// 读取authSites.params，生成表单配置列表\nconst formFields = computed(() => {\n  const site = authSites.value[authForm.value.site]\n  return Object.keys(site?.params || {})\n    .filter(item => {\n      return site.params[item].name && site.params[item].type\n    })\n    .map(key => {\n      return {\n        key,\n        site: authForm.value.site,\n        name: site.params[key].name,\n        type: site.params[key].type,\n        placeholder: site.params[key].placeholder,\n        tooltip: site.params[key].tooltip,\n      }\n    })\n})\n\n// 查询之前使用的认证参数\nasync function loadLastAuthParams() {\n  try {\n    const result: { [key: string]: any } = await api.get(`system/setting/UserSiteAuthParams`)\n    if (result.success) {\n      const ret = result.data?.value\n      if (ret && !isNullOrEmptyObject(ret.params)) {\n        authForm.value = ret\n      }\n    }\n  } catch (e) {\n    console.error(e)\n  }\n}\n\n// 加载认证站点配置\nasync function loadAuthSites() {\n  try {\n    authSites.value = (await api.get(`site/auth`)) || {}\n  } catch (e) {\n    console.error(e)\n  }\n}\n\n// 完成\nasync function handleDone() {\n  await checkUser()\n}\n\n// 认证处理\nasync function checkUser() {\n  if (!authForm.value.site) {\n    $toast.error(t('dialog.userAuth.selectSiteRequired'))\n    return\n  }\n  if (!authSites.value[authForm.value.site]) {\n    $toast.error(t('dialog.userAuth.siteConfigNotExist'))\n    return\n  }\n  if (formFields.value.length > 0) {\n    for (const field of formFields.value) {\n      if (!authForm.value.params[field.site.toUpperCase() + '_' + field.key.toUpperCase()]) {\n        $toast.error(t('dialog.userAuth.fieldRequired', { name: field.name }))\n        return\n      }\n    }\n  }\n  loading.value = true\n  try {\n    const result: { [key: string]: any } = await api.post(`site/auth`, authForm.value)\n    if (result.success) {\n      $toast.success(t('dialog.userAuth.authSuccess'))\n      // 1秒后刷新页面\n      setTimeout(() => {\n        emit('done')\n      }, 1000)\n    } else {\n      $toast.error(t('dialog.userAuth.authFailed', { message: result.message }))\n    }\n  } catch (e) {\n    console.error(e)\n  }\n  loading.value = false\n}\n\nonMounted(async () => {\n  await loadAuthSites()\n  loadLastAuthParams()\n})\n</script>\n\n<template>\n  <VDialog width=\"40rem\" scrollable>\n    <VCard>\n      <VCardItem>\n        <VCardTitle>\n          <VIcon icon=\"mdi-user-check\" class=\"me-2\" />\n          {{ t('dialog.userAuth.title') }}\n        </VCardTitle>\n        <VDialogCloseBtn @click=\"emit('close')\" />\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <VRow>\n          <VCol cols=\"12\">\n            <VSelect\n              v-model=\"authForm.site\"\n              :items=\"dropdownItems\"\n              item-value=\"key\"\n              item-title=\"name\"\n              :label=\"t('dialog.userAuth.selectSite')\"\n              item-props\n              prepend-inner-icon=\"mdi-web\"\n            >\n            </VSelect>\n          </VCol>\n        </VRow>\n        <VRow>\n          <VCol v-for=\"param in formFields\" :key=\"param.key\">\n            <VTextField\n              v-model=\"authForm.params[param.site.toUpperCase() + '_' + param.key.toUpperCase()]\"\n              :type=\"param.type\"\n              :label=\"param.name\"\n              :placeholder=\"param.placeholder\"\n              :hint=\"param.tooltip\"\n              clearable\n              persistent-hint\n            />\n          </VCol>\n        </VRow>\n      </VCardText>\n      <VCardText class=\"text-center\">\n        <VBtn @click=\"handleDone\" prepend-icon=\"mdi-check\" class=\"px-5\" size=\"large\" :disabled=\"loading\">\n          {{ t('dialog.userAuth.authBtn') }}\n        </VBtn>\n      </VCardText>\n    </VCard>\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/components/dialog/WorkflowActionsDialog.vue",
    "content": "<script lang=\"ts\" setup>\nimport { ref } from 'vue'\nimport { VueFlow, useVueFlow, type Connection, type GraphNode } from '@vue-flow/core'\nimport { MiniMap } from '@vue-flow/minimap'\nimport useDragAndDrop from '@core/utils/workflow'\nimport { Workflow } from '@/api/types'\nimport { useToast } from 'vue-toastification'\nimport api from '@/api'\nimport WorkflowSidebar from '@/layouts/components/WorkflowSidebar.vue'\nimport DropzoneBackground from '@/layouts/components/DropzoneBackground.vue'\nimport ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'\nimport { useI18n } from 'vue-i18n'\n\n// 多语言支持\nconst { t } = useI18n()\n\nconst { onConnect, addEdges, nodes, edges, addNodes, screenToFlowCoordinate } = useVueFlow()\n\nconst { onDragOver, onDrop, onDragLeave, isDragOver } = useDragAndDrop()\n\n// 连接事件\nonConnect((connection: Connection) => {\n  // 双重校验\n  if (!isValidConnection(connection)) {\n    $toast.warning(t('dialog.workflowActions.invalidConnection'))\n    return\n  }\n  addEdges(connection)\n})\n\n// 获取指定节点端口的类型（输入/输出）\nconst getPortType = (node: GraphNode, handleId: string) => {\n  // 检查是否是输入端口（对应 handleBounds.target）\n  const isInput = node.handleBounds?.target?.some(h => h.id === handleId)\n  if (isInput) return 'input'\n\n  // 检查是否是输出端口（对应 handleBounds.source）\n  const isOutput = node.handleBounds?.source?.some(h => h.id === handleId)\n  return isOutput ? 'output' : null\n}\n\n// 校验连接是否合法\nconst isValidConnection = (connection: Connection) => {\n  // 获取连接的源节点和目标节点\n  const sourceNode = nodes.value.find(n => n.id === connection.source)\n  const targetNode = nodes.value.find(n => n.id === connection.target)\n\n  if (!sourceNode || !targetNode) return false\n\n  // 获取端口类型\n  const sourcePortType = getPortType(sourceNode, connection.sourceHandle!)\n  const targetPortType = getPortType(targetNode, connection.targetHandle!)\n\n  /* 同时满足三个条件，才允许连接：\n   * 1. 源端口是输出类型（output）\n   * 2. 目标端口是输入类型（input）\n   * 3. 不是同一节点的连接\n   */\n  return sourcePortType === 'output' && targetPortType === 'input' && connection.source !== connection.target\n}\n\n// 自定义节点类型\nconst nodeTypes: Record<string, any> = ref({})\n\n// 自动扫描目录下所有的 .vue 文件\nconst components = import.meta.glob('../workflow/*Action.vue')\n\n// 动态加载某个组件\nconst loadComponent = async (componentName: string) => {\n  const component = components[`../workflow/${componentName}.vue`]\n  if (component) {\n    return ((await component()) as any).default\n  }\n  throw new Error(t('dialog.workflowActions.componentNotFound', { component: componentName }))\n}\n\n// 将所有components中的组件加载到nodeTypes中\nfor (const path in components) {\n  const componentName = path.match(/\\.\\/workflow\\/(.*).vue$/)?.[1]\n  if (!componentName) {\n    continue\n  }\n  loadComponent(componentName).then(component => {\n    nodeTypes.value[componentName] = markRaw(component)\n  })\n}\n\n// 定义输入参数\nconst props = defineProps({\n  workflow: Object as PropType<Workflow>,\n})\n\n// 定义事件\nconst emit = defineEmits(['close', 'save'])\n\n// 站点编辑表单数据\nconst workflowForm = ref<any>(props.workflow || {})\n\n// 提示框\nconst $toast = useToast()\n\n// 导入代码对话框\nconst importCodeDialog = ref(false)\n\n// 为移动端生成节点ID\nfunction getId() {\n  return 'act_' + Math.random().toString(36).substr(2, 9)\n}\n\n// 处理移动端组件点击事件\nfunction handleComponentClick(action: any) {\n  // 计算当前视图中心点\n  const centerX = window.innerWidth / 2\n  const centerY = window.innerHeight / 3\n\n  // 转换为画布坐标\n  const position = screenToFlowCoordinate({\n    x: centerX,\n    y: centerY,\n  })\n\n  // 生成一个新节点ID\n  const nodeId = getId()\n\n  // 创建新节点\n  const newNode = {\n    id: nodeId,\n    type: action.type,\n    name: action.name,\n    description: action.desc || '',\n    position,\n    data: {},\n  }\n\n  // 添加节点到画布\n  addNodes(newNode)\n\n  // 显示提示\n  $toast.success(t('dialog.workflowActions.componentAdded'))\n}\n\n// 调用API 编辑任务\nasync function updateWorkflow() {\n  // 更新节点和流程\n  workflowForm.value.actions = nodes\n  workflowForm.value.flows = edges\n\n  try {\n    const result: { [key: string]: string } = await api.put(`workflow/${workflowForm.value.id}`, workflowForm.value)\n    if (result.success) {\n      $toast.success(t('dialog.workflowActions.saveSuccess'))\n      emit('save')\n    } else {\n      $toast.error(t('dialog.workflowActions.saveFailed', { message: result.message }))\n    }\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 保存导入的代码，直接覆盖原有值\nfunction saveCodeString(type: string, code: any) {\n  try {\n    if (code) {\n      const codeObject = JSON.parse(code.value)\n      if (type === 'workflow') {\n        nodes.value = codeObject.actions || []\n        edges.value = codeObject.flows || []\n      }\n      importCodeDialog.value = false\n      $toast.success(t('dialog.workflowActions.importSuccess'))\n    }\n  } catch (error) {\n    $toast.error(t('dialog.workflowActions.importFailed'))\n    console.error(error)\n  }\n}\n\n// 分享工作流程\nfunction shareWorkflow() {\n  const codeString = JSON.stringify({ actions: nodes.value, flows: edges.value })\n  navigator.clipboard.writeText(codeString)\n  $toast.success(t('dialog.workflowActions.codeCopied'))\n}\n\nonMounted(() => {\n  if (props.workflow) {\n    nodes.value = props.workflow.actions ?? []\n    edges.value = props.workflow.flows ?? []\n  }\n})\n\n// 判断是不是MACOS\nconst isMacOS = computed(() => {\n  return /Macintosh|MacIntel|MacPPC|Mac68K/.test(navigator.userAgent)\n})\n</script>\n\n<template>\n  <VDialog scrollable fullscreen :scrim=\"false\" transition=\"dialog-bottom-transition\">\n    <VCard class=\"workflow-dialog\">\n      <!-- Toolbar -->\n      <VToolbar color=\"primary\" density=\"comfortable\">\n        <VToolbarItems>\n          <VBtn icon @click=\"emit('close')\" class=\"ms-3\">\n            <VIcon size=\"large\" color=\"white\" icon=\"mdi-close\" />\n          </VBtn>\n        </VToolbarItems>\n        <VToolbarTitle> {{ t('dialog.workflowActions.title') }} - {{ workflow?.name }} </VToolbarTitle>\n        <VToolbarItems>\n          <VBtn icon variant=\"text\" @click=\"importCodeDialog = true\" class=\"ms-2\">\n            <VIcon size=\"24\" color=\"white\" icon=\"mdi-import\" />\n          </VBtn>\n          <VBtn icon variant=\"text\" @click=\"shareWorkflow\" class=\"ms-2\">\n            <VIcon size=\"24\" color=\"white\" icon=\"mdi-share\" />\n          </VBtn>\n          <VBtn icon variant=\"text\" @click=\"updateWorkflow\" class=\"ms-2 me-3\">\n            <VIcon size=\"24\" color=\"white\" icon=\"mdi-content-save\" />\n          </VBtn>\n        </VToolbarItems>\n      </VToolbar>\n\n      <VCardText class=\"workflow-content pa-0\">\n        <div class=\"workflow-canvas\" @drop=\"onDrop\">\n          <VueFlow\n            :nodes=\"nodes\"\n            :edges=\"edges\"\n            :nodeTypes=\"nodeTypes\"\n            :is-valid-connection=\"isValidConnection\"\n            :default-edge-options=\"{ type: 'animation', animated: true }\"\n            :edge-updater-radius=\"10\"\n            @dragover=\"onDragOver\"\n            @dragleave=\"onDragLeave\"\n            :delete-key-code=\"isMacOS ? 'Backspace' : 'Delete'\"\n            auto-connect\n          >\n            <MiniMap />\n            <DropzoneBackground\n              :style=\"{\n                backgroundColor: isDragOver ? '#e7f3ff' : 'transparent',\n                transition: 'background-color 0.2s ease',\n              }\"\n            >\n            </DropzoneBackground>\n          </VueFlow>\n          <WorkflowSidebar @component-click=\"handleComponentClick\" />\n        </div>\n      </VCardText>\n    </VCard>\n\n    <ImportCodeDialog\n      v-if=\"importCodeDialog\"\n      v-model=\"importCodeDialog\"\n      :title=\"t('dialog.workflowActions.importTitle')\"\n      dataType=\"workflow\"\n      @close=\"importCodeDialog = false\"\n      @save=\"saveCodeString\"\n    />\n  </VDialog>\n</template>\n\n<style lang=\"scss\">\n@import '@vue-flow/core/dist/style.css';\n@import '@vue-flow/core/dist/theme-default.css';\n@import '@vue-flow/controls/dist/style.css';\n@import '@vue-flow/minimap/dist/style.css';\n@import '@vue-flow/node-resizer/dist/style.css';\n\n.workflow-dialog {\n  display: flex;\n  overflow: hidden;\n  flex-direction: column;\n  block-size: 100%;\n}\n\n.workflow-content {\n  position: relative;\n  overflow: hidden;\n  flex: 1;\n}\n\n.workflow-canvas {\n  position: relative;\n  block-size: 100%;\n  inline-size: 100%;\n}\n\n.vue-flow__minimap {\n  overflow: hidden;\n  border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));\n  border-radius: 8px;\n  background-color: rgba(var(--v-theme-surface), 0.8);\n  box-shadow: 0 4px 15px rgba(var(--v-shadow-key-umbra-color), 0.1);\n  inset-block-end: 20px;\n  inset-inline-end: 20px;\n  transform: scale(75%);\n  transform-origin: bottom right;\n}\n\n.vue-flow__handle {\n  border-radius: 4px;\n  block-size: 24px;\n  inline-size: 8px;\n}\n\n.vue-flow__edge-path,\n.vue-flow__connection-path {\n  stroke-width: 3;\n}\n\n.vue-flow__handle-left {\n  background-color: rgb(var(--v-theme-info));\n}\n\n.vue-flow__handle-right {\n  background-color: rgb(var(--v-theme-error));\n}\n\n// 自定义节点样式\n.vue-flow__node {\n  border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));\n  border-radius: 12px;\n\n  &:hover {\n    box-shadow: 0 8px 16px rgba(var(--v-shadow-key-umbra-color), 0.15) !important;\n    transform: translateY(-2px);\n  }\n\n  &.selected {\n    box-shadow: 0 0 0 1px rgb(var(--v-theme-primary)) !important;\n  }\n}\n\n// 自定义动作连线样式\n.vue-flow__edge.animation {\n  .vue-flow__edge-path {\n    stroke: rgb(var(--v-theme-primary));\n  }\n\n  &.selected {\n    .vue-flow__edge-path {\n      stroke: rgb(var(--v-theme-primary));\n      stroke-width: 4;\n    }\n  }\n}\n\n@media screen and (width <= 600px) {\n  .vue-flow__minimap {\n    display: none;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/dialog/WorkflowAddEditDialog.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useToast } from 'vue-toastification'\nimport type { Workflow } from '@/api/types'\nimport { doneNProgress, startNProgress } from '@/api/nprogress'\nimport { requiredValidator } from '@/@validators'\nimport api from '@/api'\nimport { useDisplay } from 'vuetify'\nimport { useI18n } from 'vue-i18n'\n\n// 多语言支持\nconst { t } = useI18n()\n\n// 输入参数\nconst props = defineProps({\n  // 任务信息\n  workflow: Object as PropType<Workflow>,\n})\n\n// 新增或修改字样\nconst title = computed(() =>\n  props.workflow ? t('dialog.workflowAddEdit.editTitle') : t('dialog.workflowAddEdit.addTitle'),\n)\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 注册事件\nconst emit = defineEmits(['save', 'remove', 'close'])\n\n// 站点编辑表单数据\nconst workflowForm = ref<Workflow>(\n  props.workflow || {\n    name: undefined,\n    timer: undefined,\n    description: undefined,\n    trigger_type: 'timer',\n    event_type: undefined,\n    state: 'P',\n    run_count: 0,\n  },\n)\n\n// 监听props变化，处理存量数据\nwatch(\n  () => props.workflow,\n  newWorkflow => {\n    if (newWorkflow) {\n      // 如果trigger_type为空，默认为timer\n      if (!newWorkflow.trigger_type) {\n        newWorkflow.trigger_type = 'timer'\n      }\n      workflowForm.value = { ...newWorkflow }\n    }\n  },\n  { immediate: true },\n)\n\n// 事件类型列表\nconst eventTypes = ref<Array<{ title: string; value: string }>>([])\n\n// 触发类型选项\nconst triggerTypeOptions = computed(() => [\n  {\n    title: t('dialog.workflowAddEdit.triggerTypeTimer'),\n    value: 'timer',\n    prependIcon: 'mdi-clock-outline',\n  },\n  {\n    title: t('dialog.workflowAddEdit.triggerTypeEvent'),\n    value: 'event',\n    prependIcon: 'mdi-calendar-check',\n  },\n  {\n    title: t('dialog.workflowAddEdit.triggerTypeManual'),\n    value: 'manual',\n    prependIcon: 'mdi-hand-pointing-up',\n  },\n])\n\n// 加载事件类型列表\nasync function loadEventTypes() {\n  try {\n    eventTypes.value = await api.get('workflow/event_types')\n  } catch (error) {\n    console.error('Failed to load event types:', error)\n  }\n}\n\n// 监听触发类型变化\nwatch(\n  () => workflowForm.value.trigger_type,\n  newType => {\n    if (newType !== 'event') {\n      workflowForm.value.event_type = undefined\n    }\n  },\n)\n\n// 提示框\nconst $toast = useToast()\n\n// 调用API 新增任务\nasync function addWorkflow() {\n  if (!workflowForm.value.name) {\n    $toast.error(t('dialog.workflowAddEdit.nameRequired'))\n    return\n  }\n\n  if (!workflowForm.value.trigger_type) {\n    $toast.error(t('dialog.workflowAddEdit.triggerRequired'))\n    return\n  }\n\n  // 根据触发类型验证必填字段\n  if (workflowForm.value.trigger_type === 'timer' && !workflowForm.value.timer) {\n    $toast.error(t('dialog.workflowAddEdit.timerRequired'))\n    return\n  }\n\n  if (workflowForm.value.trigger_type === 'event' && !workflowForm.value.event_type) {\n    $toast.error(t('dialog.workflowAddEdit.eventTypeRequired'))\n    return\n  }\n\n  startNProgress()\n  try {\n    const result: { [key: string]: string } = await api.post('workflow/', workflowForm.value)\n    if (result.success) {\n      $toast.success(t('dialog.workflowAddEdit.addSuccess'))\n      emit('save')\n    } else {\n      $toast.error(t('dialog.workflowAddEdit.addFailed', { message: result.message }))\n    }\n  } catch (error) {\n    console.error(error)\n  }\n  doneNProgress()\n}\n\n// 调用API 编辑任务\nasync function editWorkflow() {\n  if (!workflowForm.value.name) {\n    $toast.error(t('dialog.workflowAddEdit.nameRequired'))\n    return\n  }\n\n  if (!workflowForm.value.trigger_type) {\n    $toast.error(t('dialog.workflowAddEdit.triggerRequired'))\n    return\n  }\n\n  // 根据触发类型验证必填字段\n  if (workflowForm.value.trigger_type === 'timer' && !workflowForm.value.timer) {\n    $toast.error(t('dialog.workflowAddEdit.timerRequired'))\n    return\n  }\n\n  if (workflowForm.value.trigger_type === 'event' && !workflowForm.value.event_type) {\n    $toast.error(t('dialog.workflowAddEdit.eventTypeRequired'))\n    return\n  }\n\n  startNProgress()\n  try {\n    const result: { [key: string]: string } = await api.put(`workflow/${workflowForm.value.id}`, workflowForm.value)\n    if (result.success) {\n      $toast.success(t('dialog.workflowAddEdit.editSuccess'))\n      emit('save')\n    } else {\n      $toast.error(t('dialog.workflowAddEdit.editFailed', { message: result.message }))\n    }\n  } catch (error) {\n    console.error(error)\n  }\n  doneNProgress()\n}\n\n// 组件挂载时加载事件类型\nonMounted(() => {\n  loadEventTypes()\n})\n</script>\n\n<template>\n  <VDialog scrollable :close-on-back=\"false\" eager max-width=\"30rem\" :fullscreen=\"!display.mdAndUp.value\">\n    <VCard>\n      <VCardItem>\n        <template #prepend>\n          <VIcon icon=\"mdi-workflow-outline\" class=\"me-2\" />\n        </template>\n        <VCardTitle>{{ title }}</VCardTitle>\n      </VCardItem>\n      <VDialogCloseBtn @click=\"emit('close')\" />\n      <VDivider />\n      <VCardText>\n        <VForm @submit.prevent=\"() => {}\">\n          <VRow>\n            <VCol cols=\"12\">\n              <VTextField\n                v-model=\"workflowForm.name\"\n                :label=\"t('dialog.workflowAddEdit.name')\"\n                :rules=\"[requiredValidator]\"\n                persistent-hint\n                :hint=\"t('dialog.workflowAddEdit.namePlaceholder')\"\n                prepend-inner-icon=\"mdi-workflow\"\n              />\n            </VCol>\n            <VCol cols=\"12\">\n              <VSelect\n                v-model=\"workflowForm.trigger_type\"\n                :label=\"t('dialog.workflowAddEdit.triggerType')\"\n                :items=\"triggerTypeOptions\"\n                item-title=\"title\"\n                item-value=\"value\"\n                :rules=\"[requiredValidator]\"\n                prepend-inner-icon=\"mdi-run\"\n              >\n                <template #item=\"{ item, props: itemProps }\">\n                  <VListItem v-bind=\"itemProps\">\n                    <template #prepend>\n                      <VIcon :icon=\"item.raw.prependIcon\" />\n                    </template>\n                  </VListItem>\n                </template>\n              </VSelect>\n            </VCol>\n            <VCol v-if=\"workflowForm.trigger_type === 'timer'\" cols=\"12\">\n              <VCronField\n                v-model=\"workflowForm.timer\"\n                :label=\"t('dialog.workflowAddEdit.schedule')\"\n                :rules=\"[requiredValidator]\"\n                placeholder=\"5位cron表达式\"\n                persistent-hint\n                :hint=\"t('dialog.workflowAddEdit.cronExprDesc')\"\n                prepend-inner-icon=\"mdi-clock-outline\"\n              />\n            </VCol>\n            <VCol v-if=\"workflowForm.trigger_type === 'event'\" cols=\"12\">\n              <VSelect\n                v-model=\"workflowForm.event_type\"\n                :label=\"t('dialog.workflowAddEdit.eventType')\"\n                :items=\"eventTypes\"\n                item-title=\"title\"\n                item-value=\"value\"\n                :rules=\"[requiredValidator]\"\n                persistent-hint\n                :hint=\"t('dialog.workflowAddEdit.eventTypePlaceholder')\"\n                prepend-inner-icon=\"mdi-calendar-check\"\n              />\n            </VCol>\n            <VCol cols=\"12\">\n              <VTextarea\n                v-model=\"workflowForm.description\"\n                :label=\"t('dialog.workflowAddEdit.desc')\"\n                :placeholder=\"t('dialog.workflowAddEdit.descPlaceholder')\"\n                prepend-inner-icon=\"mdi-text-box-outline\"\n              />\n            </VCol>\n          </VRow>\n        </VForm>\n      </VCardText>\n      <VCardActions class=\"pt-3\">\n        <VSpacer />\n        <VBtn v-if=\"workflow\" color=\"primary\" @click=\"editWorkflow\" prepend-icon=\"mdi-content-save\" class=\"px-5\">\n          {{ t('dialog.workflowAddEdit.confirm') }}\n        </VBtn>\n        <VBtn v-else color=\"primary\" @click=\"addWorkflow\" prepend-icon=\"mdi-plus\" class=\"px-5\">\n          {{ t('dialog.workflowAddEdit.confirm') }}\n        </VBtn>\n      </VCardActions>\n    </VCard>\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/components/dialog/WorkflowShareDialog.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useToast } from 'vue-toastification'\nimport { requiredValidator } from '@/@validators'\nimport api from '@/api'\nimport type { Workflow, WorkflowShare } from '@/api/types'\nimport { useDisplay } from 'vuetify'\nimport { useI18n } from 'vue-i18n'\n\n// 多语言支持\nconst { t } = useI18n()\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 输入参数\nconst props = defineProps({\n  workflow: Object as PropType<Workflow>,\n})\n\n// 定义触发的自定义事件\nconst emit = defineEmits(['close'])\n\n// 分享处理状态\nconst shareDoing = ref(false)\n\n// 工作流分享表单\nconst shareForm = ref<WorkflowShare>({\n  id: props.workflow?.id ?? '',\n  share_title: props.workflow?.name ?? '',\n  share_comment: '',\n  share_user: '',\n})\n\n// 监听props变化\nwatch(\n  () => props.workflow,\n  newWorkflow => {\n    if (newWorkflow) {\n      shareForm.value.id = newWorkflow.id ?? ''\n      shareForm.value.share_title = newWorkflow.name ?? ''\n    }\n  },\n  { immediate: true },\n)\n\n// 分享工作流\nasync function doShare() {\n  if (!shareForm.value.share_title || !shareForm.value.share_comment || !shareForm.value.share_user) return\n  try {\n    shareDoing.value = true\n    const result: { [key: string]: any } = await api.post('workflow/share', shareForm.value)\n    shareDoing.value = false\n    // 提示\n    if (result.success) {\n      $toast.success(t('dialog.workflowShare.shareSuccess', { name: props.workflow?.name }))\n      // 通知父组件刷新\n      emit('close')\n    } else {\n      $toast.error(t('dialog.workflowShare.shareFailed', { name: props.workflow?.name, message: result.message }))\n    }\n  } catch (e) {\n    console.log(e)\n  }\n}\n\n// 提示框\nconst $toast = useToast()\n</script>\n\n<template>\n  <VDialog scrollable max-width=\"30rem\" :fullscreen=\"!display.mdAndUp.value\">\n    <VCard>\n      <VCardItem class=\"py-2\">\n        <template #prepend>\n          <VIcon icon=\"mdi-share-outline\" class=\"me-2\" />\n        </template>\n        <VCardTitle>{{ t('dialog.workflowShare.shareWorkflow') }}</VCardTitle>\n        <VCardSubtitle>\n          {{ props.workflow?.name }}\n        </VCardSubtitle>\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <VDialogCloseBtn @click=\"emit('close')\" />\n        <!-- 安全警告信息 -->\n        <VAlert\n          type=\"warning\"\n          variant=\"tonal\"\n          class=\"mb-4\"\n          :title=\"t('dialog.workflowShare.securityWarning')\"\n          :text=\"t('dialog.workflowShare.securityWarningMessage')\"\n          prepend-icon=\"mdi-alert-circle-outline\"\n        />\n        <VForm @submit.prevent=\"() => {}\" class=\"pt-2\">\n          <VRow>\n            <VCol cols=\"12\">\n              <VTextField\n                v-model=\"shareForm.share_title\"\n                :label=\"t('dialog.workflowShare.title')\"\n                :rules=\"[requiredValidator]\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-format-title\"\n              />\n            </VCol>\n            <VCol cols=\"12\">\n              <VTextarea\n                v-model=\"shareForm.share_comment\"\n                :label=\"t('dialog.workflowShare.description')\"\n                :rules=\"[requiredValidator]\"\n                :hint=\"t('dialog.workflowShare.descriptionHint')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-comment-text-outline\"\n              />\n            </VCol>\n            <VCol cols=\"12\">\n              <VTextField\n                v-model=\"shareForm.share_user\"\n                :label=\"t('dialog.workflowShare.shareUser')\"\n                :rules=\"[requiredValidator]\"\n                :hint=\"t('dialog.workflowShare.shareUserHint')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-account-outline\"\n              />\n            </VCol>\n          </VRow>\n        </VForm>\n      </VCardText>\n      <VCardActions class=\"pt-3\">\n        <VSpacer />\n        <VBtn :disabled=\"shareDoing\" @click=\"doShare\" prepend-icon=\"mdi-share\" class=\"px-5\" :loading=\"shareDoing\">\n          {{ t('dialog.workflowShare.confirmShare') }}\n        </VBtn>\n      </VCardActions>\n    </VCard>\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/components/field/CronField.vue",
    "content": "<script setup lang=\"ts\">\nimport CronInput from '@/components/input/CronInput.vue'\n\nconst attrs = useAttrs()\n\nconst props = defineProps({\n  modelValue: {\n    type: String,\n    default: '* * * * *',\n  },\n})\n\nconst emit = defineEmits(['update:modelValue'])\n\nconst innerValue = ref(props.modelValue)\n\nwatch(\n  () => props.modelValue,\n  value => {\n    innerValue.value = value\n  },\n)\n\nconst propsWithoutModelValue = computed(() => {\n  const { modelValue, ...rest } = props\n  return { ...rest, ...attrs }\n})\n\nfunction updateModelValue(value: string) {\n  innerValue.value = value\n  emit('update:modelValue', value)\n}\n</script>\n\n<template>\n  <CronInput v-model=\"innerValue\" @update:modelValue=\"updateModelValue\">\n    <template #activator=\"{ menuprops }\">\n      <VTextField\n        :modelValue=\"innerValue\"\n        @update:modelValue=\"updateModelValue\"\n        v-bind=\"{ ...menuprops, ...propsWithoutModelValue }\"\n        clearable\n      />\n    </template>\n  </CronInput>\n</template>\n"
  },
  {
    "path": "src/components/field/PathField.vue",
    "content": "<script setup lang=\"ts\">\nimport PathInput from '@/components/input/PathInput.vue'\n\nconst attrs = useAttrs()\n\nconst props = defineProps({\n  modelValue: {\n    type: String,\n    default: '/',\n  },\n  storage: {\n    type: String,\n    default: 'local',\n  },\n})\n\nconst emit = defineEmits(['update:modelValue'])\n\nconst innerValue = ref(props.modelValue)\n\nwatch(\n  () => props.modelValue,\n  value => {\n    innerValue.value = value\n  },\n)\n\nconst propsWithoutModelValue = computed(() => {\n  const { modelValue, ...rest } = props\n  return { ...rest, ...attrs }\n})\n\nfunction updateModelValue(value: string) {\n  innerValue.value = value\n  emit('update:modelValue', value)\n}\n</script>\n\n<template>\n  <PathInput v-model=\"innerValue\" :storage=\"props.storage\" @update:modelValue=\"updateModelValue\">\n    <template #activator=\"{ menuprops }\">\n      <VTextField\n        :modelValue=\"innerValue\"\n        @update:modelValue=\"updateModelValue\"\n        v-bind=\"{ ...menuprops, ...propsWithoutModelValue }\"\n      />\n    </template>\n  </PathInput>\n</template>\n"
  },
  {
    "path": "src/components/filebrowser/FileList.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { AxiosRequestConfig, AxiosInstance } from 'axios'\nimport type { PropType } from 'vue'\nimport { useConfirm } from '@/composables/useConfirm'\nimport { useToast } from 'vue-toastification'\nimport ReorganizeDialog from '../dialog/ReorganizeDialog.vue'\nimport { formatBytes } from '@core/utils/formatters'\nimport type { Context, EndPoints, FileItem } from '@/api/types'\nimport api from '@/api'\nimport ProgressDialog from '../dialog/ProgressDialog.vue'\nimport { useDisplay } from 'vuetify'\nimport MediaInfoDialog from '../dialog/MediaInfoDialog.vue'\nimport { useI18n } from 'vue-i18n'\nimport { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'\nimport { usePWA } from '@/composables/usePWA'\nimport { useAvailableHeight } from '@/composables/useAvailableHeight'\n\n// 国际化\nconst { t } = useI18n()\nconst { useProgressSSE } = useBackgroundOptimization()\n\n// 显示器宽度\nconst display = useDisplay()\n\nconst { appMode } = usePWA()\n\n// 计算列表可用高度\n// componentOffset = FileToolbar(48) + FileList操作栏(40) + VCard边距(4) = 92\nconst { availableHeight: listAvailableHeight } = useAvailableHeight(92, 300)\n\n// 输入参数\nconst inProps = defineProps({\n  icons: Object,\n  endpoints: Object as PropType<EndPoints>,\n  axios: {\n    type: Object as PropType<AxiosInstance>,\n    required: true,\n  },\n  refreshpending: Boolean,\n  item: {\n    type: Object as PropType<FileItem>,\n    required: true,\n  },\n  sort: String,\n  showTree: Boolean,\n})\n\n// 对外事件\nconst emit = defineEmits([\n  'loading',\n  'pathchanged',\n  'refreshed',\n  'filedeleted',\n  'renamed',\n  'items-updated',\n  'switch-tree',\n])\n\n// 确认框\nconst createConfirm = useConfirm()\n\n// 提示框\nconst $toast = useToast()\n\n// 是否选择模式\nconst selectMode = ref(false)\n\n// 是否正在加载\nconst loading = ref(true)\n\n// 重命名loading\nconst renameLoading = ref(false)\n\n// 识别进度条\nconst progressDialog = ref(false)\n\n// 识别进度文本\nconst progressText = ref(t('common.pleaseWait'))\n\n// 识别进度\nconst progressValue = ref(0)\n\n// 内容列表\nconst items = ref<FileItem[]>([])\n\n// 过滤条件\nconst filter = ref('')\n\n// 是否忽略大小写\nconst ignoreCase = ref(true)\n\n// 重命名弹窗\nconst renamePopper = ref(false)\n\n// 整理弹窗\nconst transferPopper = ref(false)\n\n// 新名称\nconst newName = ref('')\n\n// 处理目录内所有文件\nconst renameAll = ref(false)\n\n// 当前操作项\nconst currentItem = ref<FileItem>()\n\n// 选中的项目\nconst selected = ref<FileItem[]>([])\n\n// 识别结果\nconst nameTestResult = ref<Context>()\n\n// 识别结果对话框\nconst nameTestDialog = ref(false)\n\n// 弹出菜单\nconst dropdownItems = ref<{ [key: string]: any }[]>([])\n\n// 进度是否激活\nconst progressActive = ref(false)\n\n// 通用过滤\nconst getFilteredItems = (type: 'dir' | 'file') => {\n  const filterValue = filter.value\n  if (!filterValue) {\n    return items.value.filter(item => item.type === type)\n  }\n\n  if (ignoreCase.value) {\n    const lowerCaseFilter = filterValue.toLowerCase()\n    return items.value.filter(item => item.type === type && item.name.toLowerCase().includes(lowerCaseFilter))\n  } else {\n    return items.value.filter(item => item.type === type && item.name.includes(filterValue))\n  }\n}\n\n// 目录过滤\nconst dirs = computed(() => getFilteredItems('dir'))\n\n// 文件过滤\nconst files = computed(() => getFilteredItems('file'))\n// 是否文件\nconst isFile = computed(() => inProps.item.type == 'file')\n\n// 需要整理的文件项\nconst transferItems = ref<FileItem[]>([])\n\n// 当前图片地址\nconst currentImgLink = ref('')\n\n\n\n// 是否为图片文件\nconst isImage = computed(() => {\n  const ext = inProps.item.path?.split('.').pop()?.toLowerCase()\n  return ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'].includes(ext ?? '')\n})\n\n// 调整选择模式\nfunction changeSelectMode() {\n  selectMode.value = !selectMode.value\n  if (!selectMode.value) selected.value = []\n}\n\n// 调API加载文件夹内的内容\nasync function list_files() {\n  loading.value = true\n  const takeURISnapshot = () => [inProps.item.storage, inProps.item.path].join(':/');\n  const prevURI = takeURISnapshot();\n  emit('loading', true)\n\n  // 参数\n  const url = inProps.endpoints?.list.url.replace(/{sort}/g, inProps.sort || 'name')\n\n  const config: AxiosRequestConfig<FileItem> = {\n    url,\n    method: inProps.endpoints?.list.method || 'get',\n    data: inProps.item,\n  }\n\n  // 加载数据\n  const data = ((await inProps.axios.request<FileItem[], FileItem[]>(config))) ?? []\n  // 如果当前路径已经变化，则放弃此次加载结果\n  if (prevURI !== takeURISnapshot()) {\n    return;\n  }\n  items.value = data\n  emit('loading', false)\n  loading.value = false\n\n  // 通知父组件文件列表更新\n  emit('items-updated', items.value)\n}\n\n// 删除项目\nasync function deleteItem(item: FileItem, confirm: boolean = true) {\n  if (confirm) {\n    const confirmed = await createConfirm({\n      title: t('common.confirm'),\n      content: t('file.confirmFileDelete', {\n        type: item.type === 'dir' ? t('file.directory') : t('file.file'),\n        name: item.name,\n      }),\n    })\n    if (!confirmed) return\n  }\n\n  // 加载中\n  emit('loading', true)\n\n  // 请求API\n  const url = inProps.endpoints?.delete.url\n  const config: AxiosRequestConfig<FileItem> = {\n    url,\n    method: inProps.endpoints?.delete.method || 'post',\n    data: item,\n  }\n  await inProps.axios.request(config)\n\n  // 删除完成\n  emit('loading', false)\n  emit('filedeleted')\n\n  // 重新加载\n  list_files()\n}\n\n// 批量删除\nasync function batchDelete() {\n  const confirmed = await createConfirm({\n    title: t('common.confirm'),\n    content: t('file.confirmBatchDelete', { count: selected.value.length }),\n  })\n\n  if (!confirmed) return\n\n  // 显示进度条\n  progressDialog.value = true\n  progressValue.value = 0\n\n  // 删除选中的项目\n  selected.value.every(async item => {\n    progressText.value = t('file.deleting', { name: item.name })\n    await deleteItem(item, false)\n  })\n\n  // 关闭进度条\n  progressDialog.value = false\n\n  // 重新加载\n  list_files()\n}\n\n// 切换路径\nfunction changePath(item: FileItem) {\n  item.path = inProps.item.path + item.name + (item.type === 'dir' ? '/' : '')\n  emit('pathchanged', item)\n}\n\n// 点击列表项\nfunction listItemClick(item: FileItem) {\n  if (selectMode.value) {\n    if (selected.value.includes(item)) {\n      selected.value = selected.value.filter(i => i !== item)\n    } else {\n      selected.value.push(item)\n    }\n    // 去重\n    selected.value = Array.from(new Set(selected.value))\n    return false\n  }\n  changePath(item)\n}\n\n// 新窗口中下载文件\nasync function download(item: FileItem) {\n  const url = inProps.endpoints?.download.url\n  // 下载文件\n  const config: AxiosRequestConfig<FileItem> = {\n    url,\n    method: inProps.endpoints?.download.method || 'post',\n    data: item,\n    responseType: 'blob',\n  }\n  // 加载数据\n  const result: Blob = (await inProps.axios.request<Blob, Blob>(config))\n  if (result) {\n    const downloadUrl = URL.createObjectURL(result)\n    window.open(downloadUrl, '_blank')\n  }\n}\n\n// 获取图片地址\nasync function getImgLink(item: FileItem) {\n  let url = inProps.endpoints?.image.url\n  // 下载文件\n  const config: AxiosRequestConfig<FileItem> = {\n    url,\n    method: inProps.endpoints?.image.method || 'post',\n    data: item,\n    responseType: 'blob',\n  }\n  // 加载二进制数据\n  const result: Blob = (await inProps.axios.request<Blob, Blob>(config))\n  if (result) {\n    // 创建图片地址\n    currentImgLink.value = URL.createObjectURL(result)\n  }\n}\n\n// 如果当前是图片且是文件，则获取图片地址\nwatch(\n  () => inProps.item,\n  async () => {\n    if (isImage.value && isFile.value) {\n      await getImgLink(inProps.item)\n    }\n  },\n  { immediate: true },\n)\n\n// 显示重命名弹窗\nfunction showRenmae(item: FileItem) {\n  currentItem.value = item\n  newName.value = item.name\n  renameAll.value = false\n  renamePopper.value = true\n}\n\n// 调用API获取新名称\nasync function get_recommend_name() {\n  renameLoading.value = true\n  try {\n    const result: { [key: string]: any } = await api.get('transfer/name', {\n      params: {\n        path: `${inProps.item.path}${currentItem.value?.name}`,\n        filetype: currentItem.value?.type ?? 'file',\n      },\n    })\n    if (result.success && result.data) {\n      newName.value = result.data.name\n    } else {\n      $toast.error(result.message)\n    }\n  } catch (error) {\n    console.error(error)\n  }\n  renameLoading.value = false\n}\n\n// 重命名\nasync function rename() {\n  emit('loading', true)\n\n  // 关闭弹窗\n  renamePopper.value = false\n\n  // 显示进度条\n  progressDialog.value = true\n  progressValue.value = 0\n  if (renameAll.value) {\n    progressText.value = t('file.renamingAll', { path: currentItem.value?.path })\n  } else {\n    progressText.value = t('file.renaming', { name: currentItem.value?.name })\n  }\n  if (renameAll.value) {\n    startLoadingProgress()\n  }\n\n  // 调API\n  let url = inProps.endpoints?.rename.url.replace(/{newname}/g, encodeURIComponent(newName.value))\n  if (renameAll.value) {\n    url += '&recursive=true'\n  }\n\n  const config: AxiosRequestConfig<FileItem> = {\n    url,\n    method: inProps.endpoints?.rename.method || 'post',\n    data: currentItem.value,\n  }\n  const result: { [key: string]: any } = (await inProps.axios?.request<any, { [key: string]: any }>(config))\n  if (!result.success) {\n    $toast.error(result.message)\n  }\n\n  // 关闭进度条\n  if (renameAll.value) {\n    stopLoadingProgress()\n  }\n  progressDialog.value = false\n\n  // 通知重新加载\n  newName.value = ''\n  renameAll.value = false\n  emit('loading', false)\n  emit('renamed')\n}\n\n// 显示整理对话框\nfunction showTransfer(item: FileItem) {\n  transferItems.value = [item]\n  transferPopper.value = true\n}\n\n// 显示批量整理对话框\nfunction showBatchTransfer() {\n  transferItems.value = selected.value\n  transferPopper.value = true\n}\n\n// 整理完成\nfunction transferDone() {\n  transferPopper.value = false\n  list_files()\n}\n\n// 将文件修改时间（timestape）转换为本地时间\nfunction formatTime(timestape: number) {\n  return new Date(timestape * 1000).toLocaleString()\n}\n\n// 切换文件树显示\nfunction switchFileTree(state: boolean) {\n  emit('switch-tree', state)\n}\n\n// 监听refreshPending变化\nwatch(\n  () => inProps.refreshpending,\n  async () => {\n    if (inProps.refreshpending) {\n      await list_files()\n      emit('refreshed')\n    }\n  },\n)\n\n// 监听item变化\nwatch(\n  [() => inProps.item],\n  async () => {\n    // 清空列表\n    items.value = []\n    // 关闭弹窗\n    nameTestResult.value = undefined\n    nameTestDialog.value = false\n    // 重置菜单\n    dropdownItems.value = [\n      {\n        title: t('file.recognize'),\n        value: 1,\n        show: true,\n        props: {\n          prependIcon: 'mdi-text-recognition',\n          click: (_item: FileItem) => {\n            recognize(_item.path || '')\n          },\n        },\n      },\n      {\n        title: t('file.scrape'),\n        value: 2,\n        show: true,\n        props: {\n          prependIcon: 'mdi-auto-fix',\n          click: (_item: FileItem) => {\n            scrape(_item)\n          },\n        },\n      },\n      {\n        title: t('file.rename'),\n        value: 3,\n        show: true,\n        props: {\n          prependIcon: 'mdi-rename',\n          click: showRenmae,\n        },\n      },\n      {\n        title: t('file.reorganize'),\n        value: 4,\n        show: true,\n        props: {\n          prependIcon: 'mdi-folder-arrow-right',\n          click: showTransfer,\n        },\n      },\n      {\n        title: t('common.delete'),\n        value: 5,\n        show: true,\n        props: {\n          prependIcon: 'mdi-delete-outline',\n          color: 'error',\n          click: deleteItem,\n        },\n      },\n    ]\n    await list_files()\n  },\n  { immediate: true },\n)\n\n// 调用API识别\nasync function recognize(path: string) {\n  try {\n    // 显示进度条\n    progressDialog.value = true\n    progressText.value = t('file.recognizing', { path })\n    progressValue.value = 0\n    nameTestResult.value = await api.get('media/recognize_file', {\n      params: {\n        path,\n      },\n    })\n    // 关闭进度条\n    progressDialog.value = false\n    if (!nameTestResult.value) $toast.error(t('file.recognizeFailed', { path }))\n    nameTestDialog.value = !!nameTestResult.value?.meta_info?.name\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 调用API刮削\nasync function scrape(item: FileItem, confirm: boolean = true) {\n  try {\n    if (confirm) {\n      // 确认\n      const confirmed = await createConfirm({\n        title: t('common.confirm'),\n        content: t('file.confirmScrape', { path: item.path }),\n      })\n      if (!confirmed) return\n    }\n\n    // 显示进度条\n    progressDialog.value = true\n    progressText.value = t('file.scraping', { path: item.path })\n\n    const result: { [key: string]: any } = await api.post(`media/scrape/${inProps.item.storage}`, item)\n\n    // 关闭进度条\n    progressDialog.value = false\n    if (!result.success) $toast.error(result.message)\n    else $toast.success(t('file.scrapeCompleted', { path: item.path }))\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 批量刮削\nasync function batchScrape() {\n  // 确认\n  const confirmed = await createConfirm({\n    title: t('common.confirm'),\n    content: t('file.confirmBatchScrape', { count: selected.value.length }),\n  })\n  if (!confirmed) return\n\n  selected.value.map(item => {\n    scrape(item, false)\n  })\n}\n\n// 进度SSE消息处理函数\nfunction handleProgressMessage(event: MessageEvent) {\n  const progress = JSON.parse(event.data)\n  if (progress) {\n    progressText.value = progress.text\n    progressValue.value = progress.value\n  }\n}\n\n// 使用优化的进度SSE连接\nconst progressSSE = useProgressSSE(\n  `${import.meta.env.VITE_API_BASE_URL}system/progress/batchrename`,\n  handleProgressMessage,\n  'file-batch-rename-progress',\n  progressActive,\n)\n\n// 使用SSE监听加载进度\nfunction startLoadingProgress() {\n  progressText.value = t('common.pleaseWait')\n  progressActive.value = true\n  progressSSE.start()\n}\n\n// 停止监听加载进度\nfunction stopLoadingProgress() {\n  progressActive.value = false\n  progressSSE.stop()\n}\n\nonMounted(() => {\n  list_files()\n})\n</script>\n\n<style scoped>\n.file-list-container {\n  overflow: hidden auto;\n  block-size: 100%;\n  max-block-size: 100%;\n}\n</style>\n\n<template>\n  <div>\n    <VCard class=\"d-flex flex-column w-full h-full rounded-t-0\" :class=\"{ 'rounded-s-0': showTree }\">\n      <div v-if=\"!loading\" class=\"flex\">\n        <IconBtn v-if=\"display.mdAndUp.value\">\n          <VIcon v-if=\"showTree\" icon=\"mdi-file-tree\" @click=\"switchFileTree(false)\" />\n          <VIcon v-else icon=\"mdi-file-tree-outline\" @click=\"switchFileTree(true)\" />\n        </IconBtn>\n        <VTextField\n          v-if=\"!isFile\"\n          v-model=\"filter\"\n          hide-details\n          flat\n          density=\"compact\"\n          variant=\"plain\"\n          :placeholder=\"t('common.search')\"\n          prepend-inner-icon=\"mdi-filter-outline\"\n          class=\"mx-2\"\n          rounded\n        />\n        <VSpacer v-if=\"isFile\" />\n        <IconBtn v-if=\"!isFile\" @click=\"ignoreCase = !ignoreCase\">\n          <VIcon :color=\"ignoreCase ? 'primary' : 'error'\" icon=\"mdi-format-letter-case\" />\n        </IconBtn>\n        <IconBtn v-if=\"!isFile\" @click=\"changeSelectMode\">\n          <VIcon color=\"primary\" :icon=\"selectMode ? 'mdi-selection-remove' : 'mdi-select'\" />\n        </IconBtn>\n        <IconBtn v-if=\"isFile\" @click=\"recognize(inProps.item.path || '')\">\n          <VIcon color=\"primary\"> mdi-text-recognition </VIcon>\n        </IconBtn>\n        <IconBtn v-if=\"isFile && items.length > 0\" @click=\"download(items[0])\">\n          <VIcon color=\"primary\"> mdi-download </VIcon>\n        </IconBtn>\n        <IconBtn v-if=\"!isFile\" @click=\"list_files\">\n          <VIcon color=\"primary\"> mdi-refresh </VIcon>\n        </IconBtn>\n        <!-- 批量操作按钮 -->\n        <span v-if=\"selected.length > 0\">\n          <IconBtn @click.stop=\"batchScrape\">\n            <VIcon color=\"primary\" icon=\"mdi-auto-fix\" />\n          </IconBtn>\n          <IconBtn @click.stop=\"showBatchTransfer\">\n            <VIcon color=\"primary\" icon=\"mdi-folder-arrow-right\" />\n          </IconBtn>\n          <IconBtn @click.stop=\"batchDelete\">\n            <VIcon icon=\"mdi-delete-outline\" color=\"error\" />\n          </IconBtn>\n        </span>\n      </div>\n      <LoadingBanner v-if=\"loading\" />\n      <!-- 文件详情 -->\n      <VCardText v-else-if=\"isFile && !isImage && items.length > 0\" class=\"text-center break-all\">\n        <div v-if=\"items[0]?.thumbnail\" class=\"flex justify-center\">\n          <VImg max-width=\"15rem\" cover :src=\"items[0]?.thumbnail\" class=\"rounded border\">\n            <template #placeholder>\n              <VSkeletonLoader class=\"object-cover w-full h-full\" />\n            </template>\n          </VImg>\n        </div>\n        <div class=\"text-xl text-high-emphasis mt-3\">{{ items[0]?.name }}</div>\n        <p class=\"mt-2\" v-if=\"items[0]?.size && items[0].modify_time\">\n          {{ t('file.size') }}：{{ formatBytes(items[0]?.size || 0) }}<br />\n          {{ t('file.modifyTime') }}：{{ formatTime(items[0]?.modify_time || 0) }}\n        </p>\n      </VCardText>\n      <!-- 图片 -->\n      <VCardText v-else-if=\"isFile && isImage && items.length > 0\" class=\"grow d-flex justify-center align-center\">\n        <VImg :src=\"currentImgLink\" max-width=\"100%\" max-height=\"100%\" />\n      </VCardText>\n      <!-- 目录和文件列表 -->\n      <VCardText v-else-if=\"dirs.length || files.length\" class=\"p-0 flex-grow-1 overflow-hidden\">\n        <VList\n          class=\"text-high-emphasis file-list-container\"\n          :style=\"{ height: `${listAvailableHeight}px`, maxHeight: `${listAvailableHeight}px` }\"\n        >\n          <VVirtualScroll :items=\"[...dirs, ...files]\" style=\"block-size: 100%\">\n            <template #default=\"{ item }\">\n              <VHover>\n                <template #default=\"hover\">\n                  <VListItem v-bind=\"hover.props\" class=\"px-3 pe-1\" @click=\"listItemClick(item)\">\n                    <template #prepend>\n                      <VListItemAction v-if=\"selectMode\">\n                        <VCheckbox v-model=\"selected\" :value=\"item\" />\n                      </VListItemAction>\n                      <template v-else>\n                        <VIcon\n                          v-if=\"inProps.icons && item.extension\"\n                          :icon=\"inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other\"\n                        />\n                        <VIcon v-else-if=\"item.type == 'dir'\" icon=\"mdi-folder\" />\n                        <VIcon v-else icon=\"mdi-file-outline\" />\n                      </template>\n                    </template>\n                    <VListItemTitle v-text=\"item.name\" />\n                    <VListItemSubtitle v-if=\"item.size\">\n                      {{ formatBytes(item.size) }}\n                    </VListItemSubtitle>\n                    <template #append>\n                      <IconBtn v-if=\"display.smAndDown.value && !selectMode\">\n                        <VIcon icon=\"mdi-dots-vertical\" />\n                        <VMenu activator=\"parent\" close-on-content-click>\n                          <VList>\n                            <template v-for=\"(menu, i) in dropdownItems\" :key=\"i\">\n                              <VListItem\n                                v-if=\"menu.show\"\n                                :base-color=\"menu.props.color\"\n                                @click=\"menu.props.click(item)\"\n                              >\n                                <template #prepend>\n                                  <VIcon :icon=\"menu.props.prependIcon\" />\n                                </template>\n                                <VListItemTitle v-text=\"menu.title\" />\n                              </VListItem>\n                            </template>\n                          </VList>\n                        </VMenu>\n                      </IconBtn>\n                      <span v-if=\"hover.isHovering && display.mdAndUp.value && !selectMode\" class=\"flex\">\n                        <IconBtn @click.stop=\"recognize(item.path)\">\n                          <VIcon icon=\"mdi-text-recognition\" />\n                        </IconBtn>\n                        <IconBtn @click.stop=\"scrape(item)\">\n                          <VIcon icon=\"mdi-auto-fix\" />\n                        </IconBtn>\n                        <IconBtn @click.stop=\"showRenmae(item)\">\n                          <VIcon icon=\"mdi-rename\" />\n                        </IconBtn>\n                        <IconBtn @click.stop=\"showTransfer(item)\">\n                          <VIcon icon=\"mdi-folder-arrow-right\" />\n                        </IconBtn>\n                        <IconBtn @click.stop=\"deleteItem(item)\">\n                          <VIcon icon=\"mdi-delete-outline\" color=\"error\" />\n                        </IconBtn>\n                      </span>\n                    </template>\n                  </VListItem>\n                </template>\n              </VHover>\n            </template>\n          </VVirtualScroll>\n        </VList>\n      </VCardText>\n      <VCardText v-else-if=\"filter\" class=\"grow d-flex justify-center align-center grey--text py-5\">\n        {{ t('file.noFiles') }}\n      </VCardText>\n      <VCardText v-else-if=\"!loading\" class=\"grow d-flex justify-center align-center grey--text py-5\">\n        {{ t('file.emptyDirectory') }}\n      </VCardText>\n    </VCard>\n    <!-- 重命名弹窗 -->\n    <VDialog v-if=\"renamePopper\" v-model=\"renamePopper\" max-width=\"35rem\">\n      <VCard>\n        <VCardItem>\n          <template #prepend>\n            <VIcon icon=\"mdi-pencil\" class=\"me-2\" />\n          </template>\n          <VCardTitle>{{ t('file.rename') }}</VCardTitle>\n        </VCardItem>\n        <VDialogCloseBtn @click=\"renamePopper = false\" />\n        <VDivider />\n        <VCardText>\n          <VRow>\n            <VCol cols=\"12\">\n              <VTextField\n                v-model=\"newName\"\n                :label=\"t('file.newName')\"\n                :loading=\"renameLoading\"\n                prepend-inner-icon=\"mdi-format-text\"\n              />\n            </VCol>\n            <VCol cols=\"12\" v-if=\"currentItem && currentItem.type == 'dir'\">\n              <VSwitch v-model=\"renameAll\" :label=\"t('file.includeSubfolders')\" />\n            </VCol>\n          </VRow>\n        </VCardText>\n        <VCardActions>\n          <VBtn color=\"success\" @click=\"get_recommend_name\" prepend-icon=\"mdi-magic\" class=\"px-5 me-3\">\n            {{ t('file.autoRecognizeName') }}\n          </VBtn>\n          <VBtn :disabled=\"!newName\" @click=\"rename\" prepend-icon=\"mdi-check\" class=\"px-5 me-3\">\n            {{ t('common.confirm') }}\n          </VBtn>\n        </VCardActions>\n      </VCard>\n    </VDialog>\n    <!-- 文件整理弹窗 -->\n    <ReorganizeDialog\n      v-if=\"transferPopper\"\n      v-model=\"transferPopper\"\n      :items=\"transferItems\"\n      :target_storage=\"inProps.item.storage\"\n      @done=\"transferDone\"\n      @close=\"transferPopper = false\"\n    />\n    <!-- 进度框 -->\n    <ProgressDialog v-if=\"progressDialog\" v-model=\"progressDialog\" :text=\"progressText\" :value=\"progressValue\" />\n    <!-- 识别结果对话框 -->\n    <MediaInfoDialog\n      v-if=\"nameTestDialog\"\n      v-model=\"nameTestDialog\"\n      :context=\"nameTestResult\"\n      @close=\"nameTestDialog = false\"\n    />\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/filebrowser/FileNavigator.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { PropType } from 'vue'\nimport type { FileItem } from '@/api/types'\nimport { useDisplay } from 'vuetify'\nimport type { AxiosRequestConfig, AxiosInstance } from 'axios'\nimport { useI18n } from 'vue-i18n'\nimport { usePWA } from '@/composables/usePWA'\nimport { useAvailableHeight } from '@/composables/useAvailableHeight'\n\n// 国际化\nconst { t } = useI18n()\n\nconst display = useDisplay()\n\nconst { appMode } = usePWA()\n\n// 计算列表可用高度\n// componentOffset = FileToolbar(48) = 48\nconst { availableHeight } = useAvailableHeight(48, 300)\n\n// 输入参数\nconst props = defineProps({\n  storage: {\n    type: String,\n    required: true,\n  },\n  currentPath: {\n    type: String,\n    default: '/',\n  },\n  items: {\n    type: Array as PropType<FileItem[]>,\n    default: () => [],\n  },\n  endpoints: Object,\n  axios: {\n    type: Object as PropType<AxiosInstance>,\n    required: true,\n  },\n})\n\n// 对外事件\nconst emit = defineEmits(['navigate'])\n\n// 树形节点缓存\nconst treeCache = ref<{ [key: string]: FileItem[] }>({})\n\n// 展开的文件夹\nconst expandedFolders = ref<string[]>([])\n\n// 是否正在加载\nconst loading = ref<{ [key: string]: boolean }>({})\n\n// 点击目录\nfunction handleFolderClick(item: FileItem) {\n  emit('navigate', item)\n}\n\n// 切换文件夹展开状态\nasync function toggleFolder(path: string) {\n  const index = expandedFolders.value.indexOf(path)\n  if (index >= 0) {\n    // 折叠文件夹\n    expandedFolders.value.splice(index, 1)\n  } else {\n    // 展开文件夹\n    expandedFolders.value.push(path)\n    // 如果缓存中没有此目录内容，加载它\n    if (!treeCache.value[path]) {\n      await loadSubdirectories(path)\n    }\n  }\n}\n\n// 判断文件夹是否展开\nfunction isFolderExpanded(path: string) {\n  return expandedFolders.value.includes(path)\n}\n\n// 渲染文件夹图标\nfunction renderFolderIcon(isExpanded: boolean) {\n  if (isExpanded) {\n    return 'mdi-folder-open'\n  }\n  return 'mdi-folder'\n}\n\n// 加载子目录\nasync function loadSubdirectories(path: string) {\n  // 如果已经在加载中或已有缓存，跳过\n  if (loading.value[path] || treeCache.value[path]) return\n\n  // 标记为加载中\n  loading.value[path] = true\n\n  try {\n    // 构建假的文件项以加载目录内容\n    const fakeItem: FileItem = {\n      storage: props.storage,\n      type: 'dir',\n      name: path.split('/').pop() || '/',\n      path: path,\n    }\n\n    // 调用API加载目录内容\n    const url = props.endpoints?.list.url.replace(/{sort}/g, 'name')\n\n    const config: AxiosRequestConfig<FileItem> = {\n      url,\n      method: props.endpoints?.list.method || 'get',\n      data: fakeItem,\n    }\n\n    const result = (await props.axios?.request(config))\n    if (result && Array.isArray(result)) {\n      // 过滤出目录项\n      const dirs = result.filter(item => item.type === 'dir')\n\n      // 缓存目录内容\n      treeCache.value[path] = dirs\n    }\n  } catch (error) {\n    console.error('加载目录失败:', path, error)\n  } finally {\n    // 取消加载状态\n    loading.value[path] = false\n  }\n}\n\n// 初始加载根目录\nasync function loadRootDirectories() {\n  await loadSubdirectories('/')\n}\n\n// 检索所有目录节点\nfunction getAllDirectories() {\n  const allDirs: { dir: FileItem; level: number; parentPath: string }[] = []\n\n  // 添加根目录的子目录\n  if (treeCache.value['/']) {\n    treeCache.value['/'].forEach(dir => {\n      allDirs.push({ dir, level: 0, parentPath: '/' })\n      addSubdirectories(dir.path || '', 1, allDirs)\n    })\n  }\n\n  return allDirs\n}\n\n// 递归添加子目录\nfunction addSubdirectories(\n  parentPath: string,\n  level: number,\n  result: { dir: FileItem; level: number; parentPath: string }[],\n) {\n  if (treeCache.value[parentPath]) {\n    treeCache.value[parentPath].forEach(dir => {\n      result.push({ dir, level, parentPath })\n      if (isFolderExpanded(dir.path || '')) {\n        addSubdirectories(dir.path || '', level + 1, result)\n      }\n    })\n  }\n}\n\n// 监听当前路径变化，自动展开当前路径\nwatch(\n  () => props.currentPath,\n  async newPath => {\n    if (!newPath) return\n\n    // 如果当前路径不是根目录，自动展开父目录\n    if (newPath !== '/') {\n      const parts = newPath.split('/').filter(p => p)\n      let currentPath = ''\n\n      // 展开到当前路径的每一层\n      for (const part of parts) {\n        currentPath += '/' + part\n\n        // 如果该路径未展开，则展开它\n        if (!expandedFolders.value.includes(currentPath)) {\n          expandedFolders.value.push(currentPath)\n\n          // 确保子目录已加载\n          if (!treeCache.value[currentPath]) {\n            await loadSubdirectories(currentPath)\n          }\n        }\n\n        // 如果有上一级目录，确保它已加载\n        const parentPath = currentPath.substring(0, currentPath.lastIndexOf('/')) || '/'\n        if (!treeCache.value[parentPath]) {\n          await loadSubdirectories(parentPath)\n        }\n      }\n    }\n  },\n  { immediate: true },\n)\n\n// 监听目录变化，缓存当前目录的内容\nwatch(\n  () => props.items,\n  newItems => {\n    if (newItems) {\n      // 过滤出目录项\n      const dirs = newItems.filter(item => item.type === 'dir')\n\n      // 缓存当前目录内容\n      treeCache.value[props.currentPath || '/'] = dirs\n    }\n  },\n  { immediate: true },\n)\n\n// 是否为移动端\nconst isMobile = computed(() => {\n  return display.smAndDown.value\n})\n\n// 可用的根目录列表\nconst rootDirectories = computed(() => {\n  return treeCache.value['/'] || []\n})\n\n// 扁平化的目录树\nconst flattenedDirectories = computed(() => {\n  return getAllDirectories()\n})\n\n// 检查路径是否为指定目录的子目录或后代\nfunction isChildOrDescendant(path: string, ancestorPath: string) {\n  if (!path || !ancestorPath) return false\n  if (ancestorPath === '/') return true\n\n  // 确保路径以斜杠结尾，便于比较\n  const normalizedPath = path.endsWith('/') ? path : path + '/'\n  const normalizedAncestorPath = ancestorPath.endsWith('/') ? ancestorPath : ancestorPath + '/'\n\n  // 检查路径是否以祖先路径开头，但不是祖先路径本身\n  return normalizedPath.startsWith(normalizedAncestorPath) && normalizedPath !== normalizedAncestorPath\n}\n\n// 计算目录相对于其祖先的缩进级别\nfunction getIndentLevel(path: string, ancestorPath: string) {\n  if (!path || !ancestorPath) return 0\n\n  // 根目录特殊处理\n  if (ancestorPath === '/') {\n    return path.split('/').filter(p => p).length - 1\n  }\n\n  // 计算路径中斜杠的数量差异\n  const pathParts = path.split('/').filter(p => p).length\n  const ancestorParts = ancestorPath.split('/').filter(p => p).length\n\n  return pathParts - ancestorParts\n}\n\n// 组件挂载时初始加载\nonMounted(async () => {\n  await loadRootDirectories()\n})\n\n</script>\n\n<template>\n  <VCard class=\"file-navigator rounded-e-0 rounded-t-0\" v-if=\"!isMobile\" :height=\"`${availableHeight}px`\">\n    <div class=\"tree-container\">\n      <!-- 根目录项 -->\n      <div\n        class=\"tree-item root-item\"\n        :class=\"{ 'active': currentPath === '/' }\"\n        @click=\"\n          handleFolderClick({\n            storage: storage,\n            type: 'dir',\n            name: '/',\n            path: '/',\n          })\n        \"\n      >\n        <div class=\"folder-content\">\n          <VIcon icon=\"mdi-home\" class=\"me-2\" color=\"primary\" />\n          <span>{{ t('file.rootDirectory') }}</span>\n        </div>\n      </div>\n      <!-- 加载根目录 -->\n      <div v-if=\"loading['/']\" class=\"tree-loading\">\n        <VProgressCircular indeterminate size=\"24\" color=\"primary\" class=\"ma-2\" />\n        <span>{{ t('file.loadingDirectoryStructure') }}</span>\n      </div>\n\n      <!-- 目录树结构 -->\n      <template v-else>\n        <!-- 一级目录(根目录下的目录) -->\n        <div v-for=\"directory in rootDirectories\" :key=\"directory.path\" class=\"tree-item-container\">\n          <!-- 目录项 -->\n          <div class=\"tree-item\" :class=\"{ 'active': currentPath === directory.path }\">\n            <div class=\"folder-toggle\" @click.stop=\"toggleFolder(directory.path || '')\">\n              <VProgressCircular\n                v-if=\"loading[directory.path || '']\"\n                indeterminate\n                size=\"14\"\n                width=\"2\"\n                color=\"primary\"\n              />\n              <VIcon\n                v-else\n                size=\"small\"\n                :icon=\"isFolderExpanded(directory.path || '') ? 'mdi-chevron-down' : 'mdi-chevron-right'\"\n              />\n            </div>\n            <div class=\"folder-content\" @click.stop=\"handleFolderClick(directory)\">\n              <VIcon\n                size=\"small\"\n                :icon=\"renderFolderIcon(isFolderExpanded(directory.path || ''))\"\n                :color=\"currentPath === directory.path ? 'primary' : 'amber-darken-1'\"\n                class=\"me-1\"\n              />\n              <span class=\"folder-name\">\n                {{ directory.name }}\n              </span>\n            </div>\n          </div>\n\n          <!-- 子目录容器 - 如果该目录被展开，显示其所有子目录 -->\n          <div v-if=\"isFolderExpanded(directory.path || '')\">\n            <!-- 加载中状态 -->\n            <div v-if=\"loading[directory.path || '']\" class=\"tree-loading pl-8\">\n              <VProgressCircular indeterminate size=\"14\" color=\"primary\" class=\"ma-2\" />\n              <span class=\"text-caption\">{{ t('common.loading') }}</span>\n            </div>\n\n            <!-- 所有层级的子目录列表 -->\n            <div v-else>\n              <!-- 遍历所有扁平化的目录列表，查找对应层级的目录 -->\n              <div\n                v-for=\"item in flattenedDirectories\"\n                :key=\"item.dir.path\"\n                v-show=\"isChildOrDescendant(item.dir.path || '', directory.path || '')\"\n                class=\"tree-item\"\n                :class=\"{ 'active': currentPath === item.dir.path }\"\n                :style=\"{ paddingLeft: 16 + getIndentLevel(item.dir.path || '', directory.path || '') * 12 + 'px' }\"\n              >\n                <!-- 展开/折叠按钮 -->\n                <div class=\"folder-toggle\" @click.stop=\"toggleFolder(item.dir.path || '')\">\n                  <VProgressCircular\n                    v-if=\"loading[item.dir.path || '']\"\n                    indeterminate\n                    size=\"14\"\n                    width=\"2\"\n                    color=\"primary\"\n                  />\n                  <VIcon\n                    v-else\n                    size=\"small\"\n                    :icon=\"isFolderExpanded(item.dir.path || '') ? 'mdi-chevron-down' : 'mdi-chevron-right'\"\n                  />\n                </div>\n\n                <!-- 文件夹图标和名称 -->\n                <div class=\"folder-content\" @click.stop=\"handleFolderClick(item.dir)\">\n                  <VIcon\n                    size=\"small\"\n                    :icon=\"renderFolderIcon(isFolderExpanded(item.dir.path || ''))\"\n                    :color=\"currentPath === item.dir.path ? 'primary' : 'amber-darken-1'\"\n                    class=\"me-1\"\n                  />\n                  <span class=\"folder-name\">\n                    {{ item.dir.name }}\n                  </span>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </template>\n    </div>\n  </VCard>\n</template>\n\n<style lang=\"scss\" scoped>\n.file-navigator {\n  display: flex;\n  overflow: hidden;\n  flex-direction: column;\n  flex-shrink: 0;\n  background: rgb(var(--v-table-header-background));\n  block-size: 100%;\n  border-end-start-radius: 12px;\n  inline-size: 240px;\n}\n\n.navigator-header {\n  display: flex;\n  align-items: center;\n  border-block-end: 1px solid rgba(0, 0, 0, 8%);\n  padding-block: 12px;\n  padding-inline: 16px;\n}\n\n.tree-container {\n  overflow: hidden auto;\n  flex: 1;\n}\n\n.tree-item-container {\n  inline-size: 100%;\n}\n\n.tree-item {\n  display: flex;\n  box-sizing: border-box;\n  align-items: center;\n  cursor: pointer;\n  max-inline-size: 100%;\n  min-inline-size: 100%;\n  transition: background-color 0.2s ease;\n\n  &:hover {\n    background-color: rgba(var(--v-theme-primary), 0.05);\n  }\n\n  &.active {\n    background-color: rgba(var(--v-theme-primary), 0.08);\n  }\n}\n\n.folder-toggle {\n  display: flex;\n  flex-shrink: 0;\n  align-items: center;\n  justify-content: center;\n  block-size: 16px;\n  inline-size: 16px;\n  margin-inline-end: 4px;\n  padding-block: 6px;\n  padding-inline: 12px 0;\n}\n\n.folder-content {\n  display: flex;\n  overflow: hidden;\n  flex: 1;\n  align-items: center;\n  min-inline-size: 0;\n  padding-block: 6px;\n  padding-inline: 8px 16px;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.root-item {\n  font-weight: 500;\n}\n\n.folder-name {\n  display: inline-block;\n  overflow: hidden;\n  max-inline-size: 150px;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.subdirectory-container {\n  inline-size: 100%;\n}\n\n.tree-loading {\n  display: flex;\n  align-items: center;\n  color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));\n  padding-block: 4px;\n  padding-inline: 16px;\n}\n\n.pl-8 {\n  padding-inline-start: 20px !important;\n}\n</style>\n"
  },
  {
    "path": "src/components/filebrowser/FileToolbar.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { AxiosRequestConfig, AxiosInstance } from 'axios'\nimport type { EndPoints, FileItem } from '@/api/types'\nimport { useDisplay } from 'vuetify'\nimport { useI18n } from 'vue-i18n'\n\n// 国际化\nconst { t } = useI18n()\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 输入参数\nconst inProps = defineProps({\n  storages: Array as PropType<any[]>,\n  item: {\n    type: Object as PropType<FileItem>,\n    required: true,\n  },\n  itemstack: {\n    type: Array as PropType<FileItem[]>,\n    required: true,\n  },\n  endpoints: Object as PropType<EndPoints>,\n  axios: {\n    type: Object as PropType<AxiosInstance>,\n    required: true,\n  },\n  sort: {\n    type: String,\n    default: 'name',\n  },\n  showNewFolderButton: {\n    type: Boolean,\n    default: true,\n  },\n})\n\n// 对外事件\nconst emit = defineEmits(['storagechanged', 'pathchanged', 'loading', 'foldercreated', 'sortchanged'])\n\n// 新建文件夹名称\nconst newFolderPopper = ref(false)\n\n// 新建文件名称\nconst newFolderName = ref('')\n\n// 调整排序方式\nfunction changeSort() {\n  const newSort = inProps.sort === 'name' ? 'time' : 'name'\n  emit('sortchanged', newSort)\n}\n\n// 计算PATH面包屑\nconst pathSegments = computed(() => {\n  let path_str = ''\n  const isFolder = inProps.item.path?.endsWith('/')\n  const segments = inProps.item.path?.split('/').filter(item => item)\n  return (\n    segments?.map((item, index) => {\n      path_str += item + (index < segments.length - 1 || isFolder ? '/' : '')\n      return {\n        name: item,\n        path: path_str,\n      }\n    }) ?? []\n  )\n})\n\n// 当前存储\nconst storageObject = computed(() => {\n  return inProps.storages?.find(item => item.value === inProps.item.storage)\n})\n\n// 切换存储\nfunction changeStorage(code: string) {\n  if (inProps.item.storage!== code) {\n    emit('storagechanged', code)\n  }\n}\n\n// 路径变化\nfunction changePath(item: FileItem) {\n  emit('pathchanged', item)\n}\n\n// 返回上一级\nfunction goUp() {\n  const segments = pathSegments.value ?? []\n  const fileitem = inProps.itemstack[segments.length - 1]\n  changePath(fileitem)\n}\n\n// 创建目录\nasync function mkdir() {\n  emit('loading', true)\n  const url = inProps.endpoints?.mkdir.url.replace(/{name}/g, newFolderName.value)\n\n  const config: AxiosRequestConfig<FileItem> = {\n    url,\n    method: inProps.endpoints?.mkdir.method || 'post',\n    data: inProps.item,\n  }\n\n  // 调API\n  await inProps.axios.request(config)\n\n  newFolderPopper.value = false\n  newFolderName.value = ''\n  emit('loading', false)\n\n  // 通知重新加载\n  emit('foldercreated')\n}\n\nfunction openNewFolderDialog() {\n  newFolderName.value = ''\n  newFolderPopper.value = true\n}\n\n// 计算排序图标\nconst sortIcon = computed(() => {\n  if (inProps.sort === 'time') return 'mdi-sort-clock-ascending-outline'\n  else return 'mdi-sort-alphabetical-ascending'\n})\n\ndefineExpose({\n  openNewFolderDialog,\n})\n</script>\n\n<template>\n  <VToolbar flat dense class=\"rounded-t-lg border-b overflow-hidden\">\n    <VToolbarItems class=\"overflow-hidden\">\n      <VMenu v-if=\"storages?.length || 0 > 1\" offset-y>\n        <template #activator=\"{ props }\">\n          <VBtn v-bind=\"props\">\n            <VIcon icon=\"mdi-arrow-down-drop-circle-outline\" />\n          </VBtn>\n        </template>\n        <VList>\n          <VListItem\n            v-for=\"(item, index) in storages\"\n            :key=\"index\"\n            :disabled=\"item.value === storageObject?.value\"\n            @click=\"changeStorage(item.value)\"\n          >\n            <template #prepend>\n              <VIcon :icon=\"item.icon\" />\n            </template>\n            <VListItemTitle>{{ item.title }}</VListItemTitle>\n          </VListItem>\n        </VList>\n      </VMenu>\n      <VBtn variant=\"text\" :input-value=\"item.path === '/'\" class=\"px-1\" @click=\"changePath(inProps.itemstack[0])\">\n        <VIcon :icon=\"storageObject?.icon\" class=\"mr-2\" />\n        {{ storageObject?.title }}\n      </VBtn>\n      <template v-for=\"(segment, index) in pathSegments\" :key=\"index\">\n        <VBtn\n          v-if=\"display.mdAndUp.value\"\n          variant=\"text\"\n          :input-value=\"index === pathSegments.length - 1\"\n          class=\"px-1\"\n          @click=\"changePath(inProps.itemstack[index + 1])\"\n        >\n          <VIcon icon=\" mdi-chevron-right\" />\n          {{ segment.name }}\n        </VBtn>\n      </template>\n    </VToolbarItems>\n    <div class=\"flex-grow-1\" />\n    <IconBtn @click=\"changeSort\">\n      <VIcon :icon=\"sortIcon\" />\n    </IconBtn>\n    <IconBtn v-if=\"pathSegments.length > 0\" @click=\"goUp\">\n      <VIcon icon=\"mdi-arrow-up-bold-outline\" />\n    </IconBtn>\n    <!-- 新建文件夹 -->\n    <VDialog v-model=\"newFolderPopper\" max-width=\"35rem\">\n      <template v-if=\"showNewFolderButton\" #activator=\"{ props }\">\n        <IconBtn v-bind=\"props\">\n          <VIcon icon=\"mdi-folder-plus-outline\" />\n        </IconBtn>\n      </template>\n      <VCard>\n        <VCardItem>\n          <template #prepend>\n            <VIcon icon=\"mdi-folder-plus-outline\" class=\"me-2\" />\n          </template>\n          <VCardTitle>{{ t('file.newFolder') }}</VCardTitle>\n        </VCardItem>\n        <VDialogCloseBtn @click=\"newFolderPopper = false\" />\n        <VDivider />\n        <VCardText>\n          <VTextField v-model=\"newFolderName\" :label=\"t('common.name')\" prepend-inner-icon=\"mdi-format-text\" />\n        </VCardText>\n        <VCardActions>\n          <div class=\"flex-grow-1\" />\n          <VBtn :disabled=\"!newFolderName\" @click=\"mkdir\" prepend-icon=\"mdi-folder-plus\" class=\"px-5 me-3\">\n            {{ t('common.create') }}\n          </VBtn>\n        </VCardActions>\n      </VCard>\n    </VDialog>\n  </VToolbar>\n</template>\n"
  },
  {
    "path": "src/components/filter/TorrentFilterBar.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useI18n } from 'vue-i18n'\nimport { useDisplay } from 'vuetify'\nimport { useEventListener } from '@vueuse/core'\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 国际化\nconst { t } = useI18n()\n\n// 定义输入参数\nconst props = defineProps<{\n  // 筛选表单\n  filterForm: Record<string, string[]>\n  // 筛选选项\n  filterOptions: Record<string, string[]>\n  // 排序字段\n  sortField: string\n  // 排序方向\n  sortType: 'asc' | 'desc'\n  // 筛选后的总数量\n  totalFilteredCount: number\n  // 过滤项标题映射\n  filterTitles: Record<string, string>\n  // 排序标题映射\n  sortTitles: Record<string, string>\n  // 是否启用滚动动画\n  enableAnimation?: boolean\n}>()\n\n// 定义事件\nconst emit = defineEmits<{\n  'update:sortField': [value: string]\n  'update:sortType': [value: 'asc' | 'desc']\n  'update:filterForm': [key: string, values: string[]]\n  'selectAll': [key: string]\n  'clearFilter': [key: string]\n  'clearAllFilters': []\n  'removeFilter': [key: string, value: string]\n}>()\n\n// 过滤菜单相关\nconst filterMenuOpen = ref(false)\nconst currentFilter = ref('site')\nconst currentFilterTitle = computed(() => props.filterTitles[currentFilter.value])\nconst currentFilterOptions = computed(() => {\n  return props.filterOptions[currentFilter.value]\n})\n\n// 添加全部筛选菜单相关\nconst allFilterMenuOpen = ref(false)\n\n// 计算已选择的过滤条件数量\nconst getFilterCount = computed(() => {\n  let count = 0\n  for (const key in props.filterForm) {\n    count += props.filterForm[key].length\n  }\n  return count\n})\n\n// 计算已选择的过滤条件\nconst getSelectedFilters = computed(() => {\n  const filters: Record<string, string[]> = {}\n  for (const key in props.filterForm) {\n    if (props.filterForm[key].length > 0) {\n      filters[key] = [...props.filterForm[key]]\n    }\n  }\n  return filters\n})\n\n// 给定过滤类型返回不同图标\nfunction getFilterIcon(key: string) {\n  const icons: Record<string, string> = {\n    site: 'mdi-server-network',\n    season: 'mdi-television-classic',\n    freeState: 'mdi-gift-outline',\n    resolution: 'mdi-monitor-screenshot',\n    videoCode: 'mdi-video-vintage',\n    edition: 'mdi-quality-high',\n    releaseGroup: 'mdi-account-group-outline',\n  }\n  return icons[key] || 'mdi-filter-variant'\n}\n\n// 开关全部筛选菜单\nfunction toggleAllFilterMenu() {\n  allFilterMenuOpen.value = !allFilterMenuOpen.value\n}\n\n// 添加toggleFilterMenu函数\nfunction toggleFilterMenu(key: string) {\n  if (currentFilter.value === key && filterMenuOpen.value) {\n    filterMenuOpen.value = false\n  } else {\n    currentFilter.value = key\n    filterMenuOpen.value = true\n  }\n}\n\n// 处理筛选值变化\nfunction handleFilterChange(key: string, values: string[]) {\n  emit('update:filterForm', key, values)\n}\n\n// 全选某个过滤项\nfunction selectAll(key: string) {\n  emit('selectAll', key)\n}\n\n// 清除某个过滤项\nfunction clearFilter(key: string) {\n  emit('clearFilter', key)\n}\n\n// 清除所有过滤条件\nfunction clearAllFilters() {\n  emit('clearAllFilters')\n}\n\n// 移除单个过滤条件\nfunction removeFilter(key: string, value: string) {\n  emit('removeFilter', key, value)\n}\n\n// 滚动条引用\nconst filterBarRef = ref<HTMLElement>()\n\n/**\n * 自定义平滑滚动\n * @param element 元素\n * @param target 目标位置\n * @param duration 持续时间(ms)\n */\nfunction smoothScroll(element: HTMLElement, target: number, duration: number) {\n  const start = element.scrollLeft\n  const change = target - start\n  let startTime: number | null = null\n\n  function animate(currentTime: number) {\n    if (startTime === null) startTime = currentTime\n    const timeElapsed = currentTime - startTime\n    const progress = Math.min(timeElapsed / duration, 1)\n\n    // 使用 ease-in-out 缓动函数\n    const ease = progress < 0.5 ? 2 * progress * progress : -1 + (4 - 2 * progress) * progress\n    element.scrollLeft = start + change * ease\n\n    if (timeElapsed < duration) {\n      requestAnimationFrame(animate)\n    }\n  }\n\n  requestAnimationFrame(animate)\n}\n\n// 初始滚动动画\nonMounted(() => {\n  if (filterBarRef.value) {\n    useEventListener(filterBarRef, 'wheel', (e: WheelEvent) => {\n      if (e.deltaY !== 0) {\n        e.preventDefault()\n        filterBarRef.value!.scrollLeft += e.deltaY\n      }\n    })\n  }\n\n  if (props.enableAnimation === false) return\n\n  nextTick(() => {\n    setTimeout(() => {\n      const el = filterBarRef.value\n      if (el && el.clientWidth > 0 && el.scrollWidth > el.clientWidth) {\n        // 检查当前视口范围内的最后一个元素（即右侧边缘处的元素）\n        const containerRect = el.getBoundingClientRect()\n        const children = Array.from(el.children) as HTMLElement[]\n        const lastInViewport = children\n          .filter(c => {\n            const rect = c.getBoundingClientRect()\n            return rect.left < containerRect.right\n          })\n          .pop()\n\n        if (lastInViewport) {\n          const rect = lastInViewport.getBoundingClientRect()\n          const visibleWidth = Math.min(rect.right, containerRect.right) - rect.left\n          const visibleRatio = visibleWidth / rect.width\n\n          // 判断是否是列表最后一个元素\n          const isLastItem = lastInViewport === children[children.length - 1]\n\n          // 1. 如果是最后一个元素，且显示比例超过80%，说明基本已经展示完了，不需要动画\n          if (isLastItem && visibleRatio > 0.8) {\n            return\n          }\n\n          // 2. 如果视口内最后一个元素显示比例在30%到80%之间（明显的截断状态），用户能感知到后面还有内容，不需要滚动提示\n          // 比例过小(<0.3)可能看不清，非最后一个元素且比例过大(>0.8)可能误以为是结尾，这两种情况都需要提示\n          if (visibleRatio > 0.3 && visibleRatio < 0.8) {\n            return\n          }\n        }\n\n        // 滚动到底部 (1100ms)\n        smoothScroll(el, el.scrollWidth - el.clientWidth, 1100)\n        // 短暂停止后滚动回顶部 (1100ms)\n        setTimeout(() => {\n          smoothScroll(el, 0, 1100)\n        }, 1600)\n      }\n    }, 500)\n  })\n})\n</script>\n\n<template>\n  <!-- PC端头部和筛选栏 -->\n  <div class=\"search-header d-none d-sm-block\">\n    <VCard class=\"view-header mb-3\">\n      <div class=\"d-flex align-center pa-3\">\n        <!-- 固定位置：资源数量和排序 -->\n        <div class=\"d-flex align-center flex-shrink-0\">\n          <VChip\n            color=\"primary\"\n            variant=\"flat\"\n            size=\"small\"\n            class=\"search-count me-3 flex-shrink-0\"\n            prepend-icon=\"mdi-magnify\"\n          >\n            {{ totalFilteredCount }} {{ t('torrent.resources') }}\n          </VChip>\n\n          <VBtn variant=\"text\" size=\"small\" class=\"sort-btn\" :color=\"undefined\">\n            <template #prepend>\n              <VIcon :icon=\"sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending'\" class=\"me-1\" />\n            </template>\n            <span class=\"text-subtitle-2\">{{ sortTitles[sortField] }}</span>\n            <VIcon icon=\"mdi-chevron-down\" size=\"16\" class=\"ms-1\" />\n\n            <VMenu activator=\"parent\" transition=\"slide-y-transition\">\n              <VList density=\"compact\" min-width=\"120\" class=\"sort-menu-list\">\n                <!-- 升序/降序 选项 -->\n                <VListItem\n                  value=\"asc\"\n                  :active=\"sortType === 'asc'\"\n                  color=\"primary\"\n                  @click=\"emit('update:sortType', 'asc')\"\n                  class=\"px-3\"\n                >\n                  <template #prepend>\n                    <VIcon icon=\"mdi-sort-ascending\" size=\"small\" class=\"me-2\" />\n                  </template>\n                  <VListItemTitle>{{ t('common.ascending') }}</VListItemTitle>\n                </VListItem>\n                <VListItem\n                  value=\"desc\"\n                  :active=\"sortType === 'desc'\"\n                  color=\"primary\"\n                  @click=\"emit('update:sortType', 'desc')\"\n                  class=\"px-3\"\n                >\n                  <template #prepend>\n                    <VIcon icon=\"mdi-sort-descending\" size=\"small\" class=\"me-2\" />\n                  </template>\n                  <VListItemTitle>{{ t('common.descending') }}</VListItemTitle>\n                </VListItem>\n\n                <VDivider class=\"my-1\" />\n\n                <!-- 排序字段选项 -->\n                <VListItem\n                  v-for=\"(title, key) in sortTitles\"\n                  :key=\"key\"\n                  :value=\"key\"\n                  :active=\"sortField === key\"\n                  color=\"primary\"\n                  @click=\"emit('update:sortField', key as string)\"\n                  class=\"px-3\"\n                >\n                  <VListItemTitle>{{ title }}</VListItemTitle>\n                </VListItem>\n              </VList>\n            </VMenu>\n          </VBtn>\n\n          <div class=\"filter-divider\"></div>\n        </div>\n\n        <!-- 滚动区域：筛选条件 -->\n        <div class=\"filter-bar\" ref=\"filterBarRef\">\n          <!-- 筛选按钮 -->\n          <VBtn\n            v-for=\"(title, key) in filterTitles\"\n            v-show=\"filterOptions[key].length > 0\"\n            :key=\"key\"\n            variant=\"tonal\"\n            size=\"small\"\n            :color=\"filterForm[key].length > 0 ? 'primary' : undefined\"\n            :prepend-icon=\"getFilterIcon(key)\"\n            class=\"filter-btn\"\n            rounded=\"pill\"\n          >\n            {{ title }}\n            <VChip v-if=\"filterForm[key].length > 0\" size=\"small\" color=\"primary\" class=\"ms-1\" variant=\"elevated\">\n              {{ filterForm[key].length }}\n            </VChip>\n            <VMenu activator=\"parent\" :close-on-content-click=\"false\" scrim>\n              <VCard max-width=\"20rem\">\n                <VCardText class=\"filter-menu-content\">\n                  <div class=\"flex justify-between\">\n                    <VBtn variant=\"text\" size=\"small\" color=\"primary\" @click=\"selectAll(key)\">\n                      {{ t('torrent.selectAll') }}\n                    </VBtn>\n                    <VBtn\n                      v-if=\"filterForm[key].length > 0\"\n                      variant=\"text\"\n                      size=\"small\"\n                      color=\"error\"\n                      @click=\"clearFilter(key)\"\n                    >\n                      {{ t('torrent.clear') }}\n                    </VBtn>\n                  </div>\n                  <VChipGroup\n                    :model-value=\"filterForm[key]\"\n                    @update:model-value=\"(val: string[]) => handleFilterChange(key, val)\"\n                    column\n                    multiple\n                    class=\"filter-options\"\n                  >\n                    <VChip\n                      v-for=\"option in filterOptions[key]\"\n                      :key=\"option\"\n                      :value=\"option\"\n                      filter\n                      variant=\"elevated\"\n                      class=\"ma-1 filter-chip\"\n                      size=\"small\"\n                    >\n                      {{ option }}\n                    </VChip>\n                  </VChipGroup>\n                </VCardText>\n              </VCard>\n            </VMenu>\n          </VBtn>\n\n          <!-- 全部筛选按钮 -->\n          <VBtn\n            variant=\"tonal\"\n            size=\"small\"\n            color=\"primary\"\n            class=\"filter-btn me-2\"\n            prepend-icon=\"mdi-filter-variant\"\n            rounded=\"pill\"\n            @click=\"toggleAllFilterMenu\"\n          >\n            {{ t('torrent.allFilters') }}\n            <VChip v-if=\"getFilterCount > 0\" size=\"small\" color=\"primary\" class=\"ms-1\" variant=\"elevated\">\n              {{ getFilterCount }}\n            </VChip>\n          </VBtn>\n        </div>\n      </div>\n\n      <div v-if=\"getFilterCount > 0\" class=\"selected-filters\">\n        <div class=\"d-flex align-center\">\n          <div class=\"d-flex flex-wrap align-center flex-grow-1\">\n            <template v-for=\"(values, key) in getSelectedFilters\" :key=\"key\">\n              <VChip\n                v-for=\"(value, index) in values\"\n                :key=\"`${key}-${index}`\"\n                color=\"primary\"\n                size=\"small\"\n                closable\n                variant=\"elevated\"\n                class=\"me-1 mb-1 mt-1 filter-tag\"\n                @click:close=\"removeFilter(key as string, value)\"\n              >\n                <VIcon size=\"small\" :icon=\"getFilterIcon(key as string)\" class=\"me-1\"></VIcon>\n                <strong>{{ filterTitles[key as string] }}:</strong> {{ value }}\n              </VChip>\n            </template>\n          </div>\n\n          <VSpacer />\n\n          <!-- 清除全部筛选按钮 -->\n          <VBtn\n            v-if=\"getFilterCount > 0\"\n            variant=\"text\"\n            size=\"small\"\n            color=\"error\"\n            @click=\"clearAllFilters\"\n            class=\"ms-2 flex-shrink-0\"\n            prepend-icon=\"mdi-close-circle-outline\"\n          >\n            {{ t('torrent.clearFilters') }}\n          </VBtn>\n        </div>\n      </div>\n    </VCard>\n  </div>\n\n  <!-- 移动端头部和筛选区域 -->\n  <VCard class=\"d-block d-sm-none search-header-mobile mb-3\">\n    <div class=\"view-header\">\n      <div class=\"d-flex align-center flex-wrap pa-2\">\n        <div class=\"d-flex align-center w-100\">\n          <VChip\n            color=\"primary\"\n            variant=\"elevated\"\n            size=\"small\"\n            class=\"search-count me-auto\"\n            prepend-icon=\"mdi-magnify\"\n          >\n            {{ totalFilteredCount }} {{ t('torrent.resources') }}\n          </VChip>\n\n          <!-- 排序选择 -->\n          <VBtn variant=\"text\" size=\"small\" class=\"sort-btn mobile-sort-btn\" :color=\"undefined\">\n            <template #prepend>\n              <VIcon :icon=\"sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending'\" class=\"me-1\" />\n            </template>\n            <span class=\"text-subtitle-2\">{{ sortTitles[sortField] }}</span>\n            <VIcon icon=\"mdi-chevron-down\" size=\"16\" class=\"ms-1\" />\n\n            <VMenu activator=\"parent\" transition=\"slide-y-transition\">\n              <VList density=\"compact\" min-width=\"120\" class=\"sort-menu-list\">\n                <!-- 升序/降序 选项 -->\n                <VListItem\n                  value=\"asc\"\n                  :active=\"sortType === 'asc'\"\n                  color=\"primary\"\n                  @click=\"emit('update:sortType', 'asc')\"\n                  class=\"px-3\"\n                >\n                  <template #prepend>\n                    <VIcon icon=\"mdi-sort-ascending\" size=\"small\" class=\"me-2\" />\n                  </template>\n                  <VListItemTitle>{{ t('common.ascending') }}</VListItemTitle>\n                </VListItem>\n                <VListItem\n                  value=\"desc\"\n                  :active=\"sortType === 'desc'\"\n                  color=\"primary\"\n                  @click=\"emit('update:sortType', 'desc')\"\n                  class=\"px-3\"\n                >\n                  <template #prepend>\n                    <VIcon icon=\"mdi-sort-descending\" size=\"small\" class=\"me-2\" />\n                  </template>\n                  <VListItemTitle>{{ t('common.descending') }}</VListItemTitle>\n                </VListItem>\n\n                <VDivider class=\"my-1\" />\n\n                <!-- 排序字段选项 -->\n                <VListItem\n                  v-for=\"(title, key) in sortTitles\"\n                  :key=\"key\"\n                  :value=\"key\"\n                  :active=\"sortField === key\"\n                  color=\"primary\"\n                  @click=\"emit('update:sortField', key as string)\"\n                  class=\"px-3\"\n                >\n                  <VListItemTitle>{{ title }}</VListItemTitle>\n                </VListItem>\n              </VList>\n            </VMenu>\n          </VBtn>\n        </div>\n\n        <!-- 筛选图标按钮区域 -->\n        <div class=\"filter-buttons-grid w-100 mt-2\">\n          <VBtn\n            v-for=\"(title, key) in filterTitles\"\n            v-show=\"filterOptions[key].length > 0\"\n            :key=\"key\"\n            variant=\"text\"\n            color=\"primary\"\n            class=\"filter-btn-mobile\"\n            @click=\"toggleFilterMenu(key)\"\n          >\n            <VIcon :icon=\"getFilterIcon(key)\" class=\"filter-icon me-1\"></VIcon>\n            <span class=\"filter-label\">\n              {{ title }}\n            </span>\n            <VBadge\n              v-if=\"filterForm[key].length > 0\"\n              :content=\"filterForm[key].length\"\n              color=\"primary\"\n              location=\"top end\"\n              offset-x=\"-10\"\n              offset-y=\"-10\"\n            ></VBadge>\n          </VBtn>\n\n          <!-- 全部筛选按钮 -->\n          <VBtn variant=\"text\" color=\"primary\" class=\"filter-btn-mobile\" @click=\"toggleAllFilterMenu\">\n            <VIcon icon=\"mdi-filter-variant\" class=\"filter-icon me-1\"></VIcon>\n            <span class=\"filter-label\">\n              {{ t('torrent.allFilters') }}\n            </span>\n            <VBadge\n              v-if=\"getFilterCount > 0\"\n              :content=\"getFilterCount\"\n              color=\"primary\"\n              location=\"top end\"\n              offset-x=\"-10\"\n              offset-y=\"-10\"\n            ></VBadge>\n          </VBtn>\n        </div>\n      </div>\n    </div>\n  </VCard>\n\n  <!-- 全部筛选弹窗 -->\n  <VDialog\n    v-model=\"allFilterMenuOpen\"\n    max-width=\"50rem\"\n    location=\"center\"\n    scrollable\n    :fullscreen=\"!display.mdAndUp.value\"\n  >\n    <VCard>\n      <VDialogCloseBtn @click=\"allFilterMenuOpen = false\" />\n      <VCardTitle class=\"py-3 d-flex align-center\">\n        <VIcon icon=\"mdi-filter-variant\" class=\"me-2\"></VIcon>\n        <span>{{ t('torrent.allFilters') }}</span>\n        <VSpacer />\n        <VBtn\n          v-if=\"getFilterCount > 0\"\n          class=\"me-10\"\n          variant=\"text\"\n          size=\"small\"\n          color=\"error\"\n          @click=\"clearAllFilters\"\n        >\n          {{ t('torrent.clearAll') }}\n        </VBtn>\n      </VCardTitle>\n      <VDivider />\n      <VCardText>\n        <div class=\"all-filters-grid\">\n          <VCard\n            v-for=\"(title, key) in filterTitles\"\n            variant=\"tonal\"\n            :key=\"key\"\n            class=\"filter-section\"\n            v-show=\"filterOptions[key].length > 0\"\n          >\n            <VCardItem class=\"py-2\">\n              <template #prepend>\n                <VIcon :icon=\"getFilterIcon(key)\" class=\"me-2\"></VIcon>\n              </template>\n              <VCardTitle>{{ title }}</VCardTitle>\n              <template #append>\n                <VBtn variant=\"text\" size=\"small\" color=\"primary\" @click=\"selectAll(key)\">\n                  {{ t('torrent.selectAll') }}\n                </VBtn>\n                <VBtn\n                  v-if=\"filterForm[key].length > 0\"\n                  variant=\"text\"\n                  size=\"small\"\n                  color=\"error\"\n                  @click=\"clearFilter(key)\"\n                >\n                  {{ t('torrent.clear') }}\n                </VBtn>\n              </template>\n            </VCardItem>\n            <VCardText>\n              <VChipGroup\n                :model-value=\"filterForm[key]\"\n                @update:model-value=\"(val: string[]) => handleFilterChange(key, val)\"\n                column\n                multiple\n                class=\"filter-options\"\n              >\n                <VChip\n                  v-for=\"option in filterOptions[key]\"\n                  :key=\"option\"\n                  :value=\"option\"\n                  filter\n                  variant=\"elevated\"\n                  class=\"ma-1 filter-chip\"\n                  size=\"small\"\n                >\n                  {{ option }}\n                </VChip>\n              </VChipGroup>\n            </VCardText>\n          </VCard>\n        </div>\n      </VCardText>\n    </VCard>\n  </VDialog>\n\n  <!-- 筛选弹窗 -->\n  <VDialog v-model=\"filterMenuOpen\" max-width=\"25rem\" max-height=\"85vh\" location=\"center\" scrollable>\n    <VCard>\n      <VCardTitle class=\"py-3 d-flex align-center\">\n        <VIcon :icon=\"getFilterIcon(currentFilter)\" class=\"me-2\"></VIcon>\n        <span>{{ currentFilterTitle }}</span>\n        <VSpacer />\n        <VBtn\n          v-if=\"filterForm[currentFilter].length > 0\"\n          variant=\"text\"\n          size=\"small\"\n          color=\"error\"\n          @click=\"clearFilter(currentFilter)\"\n        >\n          {{ t('torrent.clear') }}\n        </VBtn>\n        <VBtn variant=\"text\" size=\"small\" color=\"primary\" @click=\"selectAll(currentFilter)\">\n          {{ t('torrent.selectAll') }}\n        </VBtn>\n      </VCardTitle>\n      <VDivider />\n      <VCardText>\n        <VChipGroup\n          :model-value=\"filterForm[currentFilter]\"\n          @update:model-value=\"(val: string[]) => handleFilterChange(currentFilter, val)\"\n          column\n          multiple\n          class=\"filter-options\"\n        >\n          <VChip\n            v-for=\"option in currentFilterOptions\"\n            :key=\"option\"\n            :value=\"option\"\n            filter\n            variant=\"elevated\"\n            class=\"ma-1 filter-chip\"\n            size=\"small\"\n          >\n            {{ option }}\n          </VChip>\n        </VChipGroup>\n      </VCardText>\n      <VCardActions>\n        <VSpacer />\n        <VBtn color=\"primary\" prepend-icon=\"mdi-check\" class=\"px-5\" @click=\"filterMenuOpen = false\">\n          {{ t('torrent.confirm') }}\n        </VBtn>\n      </VCardActions>\n    </VCard>\n  </VDialog>\n</template>\n\n<style scoped>\n.search-header,\n.search-header-mobile {\n  width: 100%;\n  max-width: 100%;\n}\n\n.view-header {\n  overflow: hidden;\n}\n\n.search-count {\n  font-weight: 500;\n}\n\n.sort-btn {\n  height: 32px !important;\n  font-weight: 500;\n  padding-inline: 12px 6px !important;\n}\n\n.sort-btn .v-icon {\n  color: rgba(var(--v-theme-on-surface), 0.6);\n}\n\n.sort-btn :deep(.v-btn__prepend) {\n  margin-inline-end: 2px !important;\n}\n\n.sort-menu-list {\n  border: 1px solid rgba(var(--v-theme-on-surface), 0.08);\n  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;\n}\n\n.sort-menu-list :deep(.v-list-item__prepend > .v-icon) {\n  margin-inline-end: 0px !important;\n}\n\n.filter-bar {\n  display: flex;\n  flex-wrap: nowrap;\n  align-items: center;\n  gap: 4px;\n  overflow-x: auto;\n  flex: 1;\n  width: 0;\n  min-width: 0;\n  scrollbar-width: none;\n  -ms-overflow-style: none;\n}\n\n.filter-bar::-webkit-scrollbar {\n  display: none;\n}\n\n.filter-bar > * {\n  flex-shrink: 0;\n}\n\n.filter-divider {\n  background-color: rgba(var(--v-theme-on-surface), 0.12);\n  block-size: 24px;\n  inline-size: 1px;\n  margin-block: 0;\n  margin-inline: 8px;\n}\n\n.filter-btn {\n  min-inline-size: 0;\n  transition: opacity 0.2s;\n}\n\n.filter-btn:hover {\n  opacity: 0.8;\n}\n\n.filter-menu-content {\n  max-block-size: 50vh;\n  overflow-y: auto;\n}\n\n.filter-options {\n  display: flex;\n  flex-wrap: wrap;\n}\n\n.filter-chip {\n  border: 1px solid rgba(var(--v-theme-primary), 0.2);\n  margin: 4px;\n  background-color: rgba(var(--v-theme-primary), 0.1) !important;\n  color: rgba(var(--v-theme-on-surface), 0.9) !important;\n  font-weight: 500;\n  transition: all 0.2s ease;\n}\n\n.filter-chip:hover {\n  background-color: rgba(var(--v-theme-primary), 0.15) !important;\n}\n\n.filter-chip.v-chip--selected {\n  background-color: rgba(var(--v-theme-primary), 0.85) !important;\n  box-shadow: 0 2px 4px rgba(var(--v-theme-primary), 0.3);\n  color: rgb(var(--v-theme-on-primary)) !important;\n  font-weight: 600;\n}\n\n.filter-tag {\n  font-weight: 500;\n  transition: all 0.2s;\n}\n\n.filter-tag:hover {\n  opacity: 0.8;\n}\n\n.selected-filters {\n  overflow: hidden;\n  background-color: rgba(var(--v-theme-surface-variant), 0.08);\n  padding-block: 8px;\n  padding-inline: 12px;\n}\n\n.filter-buttons-grid {\n  display: grid;\n  gap: 4px;\n  grid-template-columns: repeat(3, 1fr);\n}\n\n.filter-btn-mobile {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  border: 1px solid rgba(var(--v-theme-on-surface), 0.08);\n  border-radius: 8px;\n  background-color: rgba(var(--v-theme-surface), 0.5);\n  block-size: auto;\n  min-block-size: 48px;\n  padding-block: 4px;\n  padding-inline: 0;\n}\n\n.filter-icon {\n  font-size: 18px;\n  margin-block-end: 2px;\n}\n\n.filter-label {\n  font-size: 0.8rem;\n  text-align: center;\n}\n\n.all-filters-grid {\n  display: grid;\n  gap: 24px;\n  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));\n}\n\n.filter-section {\n  background-color: rgba(var(--v-theme-surface-variant), 0.08);\n}\n</style>\n"
  },
  {
    "path": "src/components/input/CronInput.vue",
    "content": "<script setup lang=\"ts\">\nconst props = defineProps({\n  modelValue: {\n    type: String,\n    default: '* * * * *',\n  },\n})\n\nconst emit = defineEmits(['update:modelValue'])\n\nconst menu = ref(false)\nconst currentCron = ref(props.modelValue)\nconst menuRoot = ref<HTMLElement>()\nconst instance = getCurrentInstance()\nconst menuContentClass = `cron-input-menu-${instance?.uid ?? 'default'}`\nconst menuContentSelector = `.${menuContentClass}`\n\nfunction isCronMenuTarget(target: EventTarget | null) {\n  if (!(target instanceof Element)) return false\n\n  if (menuRoot.value?.contains(target)) return true\n\n  const menuContent = document.querySelector(menuContentSelector)\n\n  if (menuContent?.contains(target)) return true\n\n  const overlayId = target.closest('.v-overlay')?.getAttribute('id')\n\n  if (!overlayId || !menuContent) return false\n\n  return Array.from(menuContent.querySelectorAll('[aria-owns]')).some(\n    activator => activator.getAttribute('aria-owns') === overlayId,\n  )\n}\n\nfunction closeOnOutsidePointerDown(event: PointerEvent) {\n  if (!menu.value || isCronMenuTarget(event.target)) return\n\n  menu.value = false\n}\n\nonMounted(() => {\n  document.addEventListener('pointerdown', closeOnOutsidePointerDown, true)\n})\n\nonBeforeUnmount(() => {\n  document.removeEventListener('pointerdown', closeOnOutsidePointerDown, true)\n})\n\nwatch(currentCron, newVal => {\n  emit('update:modelValue', newVal)\n})\n\nwatch(\n  () => props.modelValue,\n  value => {\n    currentCron.value = value\n  },\n)\n</script>\n\n<template>\n  <div ref=\"menuRoot\">\n    <VMenu\n      v-model=\"menu\"\n      :close-on-content-click=\"false\"\n      :content-class=\"['cursor-default', menuContentClass]\"\n      persistent\n    >\n      <template v-slot:activator=\"{ props }\">\n        <slot name=\"activator\" :menuprops=\"props\" />\n      </template>\n      <VList>\n        <VListItem>\n          <VCronVuetify v-model=\"currentCron\" locale=\"zh-CN\" :chip-props=\"{ color: 'success' }\" class=\"mt-1\" />\n        </VListItem>\n      </VList>\n    </VMenu>\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/input/PathInput.vue",
    "content": "<script setup lang=\"ts\">\nimport api from '@/api'\nimport { FileItem } from '@/api/types'\n\nconst props = defineProps({\n  modelValue: {\n    type: String,\n    default: '/',\n  },\n  root: {\n    type: String,\n    default: '/',\n  },\n  storage: {\n    type: String,\n    default: 'local',\n  },\n})\n\nconst emit = defineEmits(['update:modelValue'])\n\nconst menuVisible = ref(false)\n\nconst treeItems = ref<FileItem[]>([\n  {\n    name: '/',\n    path: props.root,\n    children: [],\n    type: 'dir',\n    basename: props.root,\n    storage: props.storage,\n  },\n])\n\nconst activedDirs = ref<FileItem[]>([])\n\nconst openedDirs = ref<FileItem[]>([])\n\n// 调用API查询子目录\nasync function fetchDirs(item: any) {\n  return api\n    .post('/storage/list', item)\n    .then((data: any) => {\n      data = data.filter((i: any) => i.type === 'dir')\n      item.children?.push(...data)\n    })\n    .catch(err => console.warn(err))\n}\n\n// 递归查询路径\nfunction findPath(item: FileItem, path: string): FileItem | null {\n  if (item.path === path) {\n    return item\n  }\n  if (item.children) {\n    for (const child of item.children) {\n      const res: FileItem | null = findPath(child, path)\n      if (res) {\n        return res\n      }\n    }\n  }\n  return null\n}\n\n// 根据路径展开所有子目录\nasync function expandDirs(path: string) {\n  // 分割路径\n  const paths = path.split('/').filter(i => i)\n  // 展开根目录\n  const root_item = treeItems.value[0]\n  await fetchDirs(root_item)\n  openedDirs.value.push(root_item)\n  // 逐级展开\n  let currentPath = '/'\n  for (const p of paths) {\n    currentPath += `${p}/`\n    // 查询当前目录\n    const item = findPath(root_item, currentPath)\n    if (!item) {\n      break\n    }\n    // 加载子目录\n    if (item.children?.length === 0) {\n      await fetchDirs(item)\n    }\n    // 打开当前目录\n    if (!openedDirs.value.includes(item) && path != currentPath) {\n      openedDirs.value.push(item)\n    }\n    // 选中当前目录\n    if (path == currentPath) {\n      activedDirs.value = [item]\n    }\n  }\n}\n\n// 当前选中项\nconst selectedPath = computed(() => {\n  if (activedDirs.value.length > 0) {\n    return activedDirs.value[0].path\n  }\n  return ''\n})\n\nfunction isFileItem(value: unknown): value is FileItem {\n  return typeof value === 'object' && value !== null && 'path' in value && 'type' in value\n}\n\nfunction activateDir({ id }: { id: unknown }) {\n  const item = isFileItem(id) ? id : typeof id === 'string' ? findPath(treeItems.value[0], id) : null\n\n  if (!item || item.type !== 'dir') return\n\n  activedDirs.value = [item]\n}\n\nwatch(activedDirs, newVal => {\n  if (!newVal.length) return\n\n  emit('update:modelValue', selectedPath.value)\n})\n\nwatch(\n  () => menuVisible.value,\n  async visible => {\n    if (visible) {\n      treeItems.value = [\n        {\n          name: '/',\n          path: props.root,\n          children: [],\n          type: 'dir',\n          basename: props.root,\n          storage: props.storage,\n        },\n      ]\n      openedDirs.value = []\n      activedDirs.value = []\n      await expandDirs(props.modelValue)\n    }\n  },\n)\n\nwatch(\n  () => props.storage,\n  async newVal => {\n    treeItems.value = [\n      {\n        name: '/',\n        path: props.root,\n        children: [],\n        type: 'dir',\n        basename: props.root,\n        storage: newVal,\n      },\n    ]\n    activedDirs.value = []\n    openedDirs.value = []\n  },\n)\n</script>\n\n<template>\n  <div>\n    <VMenu v-model=\"menuVisible\" :close-on-content-click=\"false\" content-class=\"cursor-default\">\n      <template v-slot:activator=\"{ props }\">\n        <slot name=\"activator\" :menuprops=\"props\" />\n      </template>\n      <VTreeview\n        v-model:activated=\"activedDirs\"\n        v-model:opened=\"openedDirs\"\n        :items=\"treeItems\"\n        :load-children=\"fetchDirs\"\n        item-key=\"path\"\n        item-title=\"name\"\n        item-value=\"path\"\n        activatable\n        return-object\n        max-height=\"20rem\"\n        open-on-click\n        expand-icon=\"mdi-folder\"\n        collapse-icon=\"mdi-folder-open\"\n        @click:open=\"activateDir\"\n      />\n    </VMenu>\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/misc/DashboardElement.vue",
    "content": "<script setup lang=\"ts\">\nimport api from '@/api'\nimport { DashboardItem } from '@/api/types'\nimport AnalyticsMediaStatistic from '@/views/dashboard/AnalyticsMediaStatistic.vue'\nimport AnalyticsScheduler from '@/views/dashboard/AnalyticsScheduler.vue'\nimport AnalyticsSpeed from '@/views/dashboard/AnalyticsSpeed.vue'\nimport AnalyticsStorage from '@/views/dashboard/AnalyticsStorage.vue'\nimport AnalyticsWeeklyOverview from '@/views/dashboard/AnalyticsWeeklyOverview.vue'\nimport AnalyticsCpu from '@/views/dashboard/AnalyticsCpu.vue'\nimport AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'\nimport AnalyticsNetwork from '@/views/dashboard/AnalyticsNetwork.vue'\nimport MediaServerLatest from '@/views/dashboard/MediaServerLatest.vue'\nimport MediaServerLibrary from '@/views/dashboard/MediaServerLibrary.vue'\nimport MediaServerPlaying from '@/views/dashboard/MediaServerPlaying.vue'\nimport DashboardRender from '@/components/render/DashboardRender.vue'\nimport { isNullOrEmptyObject } from '@/@core/utils'\nimport { loadRemoteComponent } from '@/utils/federationLoader'\n\n// 输入参数\nconst props = defineProps({\n  // 仪表板配置\n  config: Object as PropType<DashboardItem>,\n  // 刷新状态\n  refreshStatus: Boolean,\n  // 是否允许刷新数据\n  allowRefresh: {\n    type: Boolean,\n    default: true,\n  },\n})\n\nconst emit = defineEmits(['update:refreshStatus'])\n\n// 插件UI渲染模式 ('vuetify' 或 'vue')\nconst pluginRenderMode = computed(() => props.config?.render_mode || 'vuetify')\n\n// Vue 模式：动态加载的组件\nconst dynamicPluginComponent = defineAsyncComponent({\n  // 工厂函数\n  loader: async () => {\n    try {\n      if (!props.config?.id) {\n        throw new Error('插件ID不存在')\n      }\n\n      // 动态加载远程组件\n      const module = await loadRemoteComponent(props.config.id, 'Dashboard')\n\n      // 直接返回加载的组件，无需再获取default\n      return module\n    } catch (error) {\n      console.error('加载远程组件失败:', error)\n    }\n  },\n  // 加载中显示的组件\n  loadingComponent: {\n    template: '<VSkeletonLoader type=\"card\"></VSkeletonLoader>',\n  },\n  // 添加错误处理\n  errorComponent: {\n    template: `\n      <div class=\"pa-4\">\n        <VAlert type=\"error\" title=\"组件加载错误\">\n          无法加载组件，请稍后再试\n        </VAlert>\n      </div>\n    `,\n  },\n})\n\nonUnmounted(() => {\n  // 组件卸载时禁用刷新状态\n  emit('update:refreshStatus', false)\n})\n</script>\n<template>\n  <!-- 系统内置的仪表板 -->\n  <AnalyticsStorage v-if=\"config?.id === 'storage'\" />\n  <AnalyticsMediaStatistic v-else-if=\"config?.id === 'mediaStatistic'\" />\n  <AnalyticsWeeklyOverview v-else-if=\"config?.id === 'weeklyOverview'\" />\n  <AnalyticsSpeed v-else-if=\"config?.id === 'speed'\" :allowRefresh=\"props.allowRefresh\" />\n  <AnalyticsScheduler v-else-if=\"config?.id === 'scheduler'\" :allowRefresh=\"props.allowRefresh\" />\n  <AnalyticsCpu v-else-if=\"config?.id === 'cpu'\" :allowRefresh=\"props.allowRefresh\" />\n  <AnalyticsMemory v-else-if=\"config?.id === 'memory'\" :allowRefresh=\"props.allowRefresh\" />\n  <AnalyticsNetwork v-else-if=\"config?.id === 'network'\" :allowRefresh=\"props.allowRefresh\" />\n  <MediaServerLibrary v-else-if=\"config?.id === 'library'\" />\n  <MediaServerPlaying v-else-if=\"config?.id === 'playing'\" />\n  <MediaServerLatest v-else-if=\"config?.id === 'latest'\" />\n  <!-- 插件仪表板 -->\n  <template v-else-if=\"!isNullOrEmptyObject(props.config)\">\n    <!-- Vue 渲染模式 -->\n    <div v-if=\"pluginRenderMode === 'vue'\">\n      <component :is=\"dynamicPluginComponent\" :config=\"props.config\" :allow-refresh=\"props.allowRefresh\" :api=\"api\" />\n    </div>\n    <!-- Vuetify 渲染模式 -->\n    <VHover v-else-if=\"pluginRenderMode === 'vuetify'\">\n      <template #default=\"hover\">\n        <!-- 无边框 -->\n        <div v-if=\"props.config?.attrs.border === false\">\n          <VCard v-bind=\"hover.props\">\n            <VCardText class=\"p-0\">\n              <DashboardRender v-for=\"(item, index) in props.config?.elements\" :key=\"index\" :config=\"item\" />\n            </VCardText>\n            <div v-if=\"hover.isHovering\" class=\"absolute right-5 top-5\">\n              <VIcon class=\"cursor-move\">mdi-drag</VIcon>\n            </div>\n          </VCard>\n        </div>\n        <!-- 有边框 -->\n        <VCard v-else v-bind=\"hover.props\">\n          <VCardItem v-if=\"props.config?.attrs.border !== false\">\n            <template #append>\n              <VIcon class=\"cursor-move\" v-if=\"hover.isHovering\">mdi-drag</VIcon>\n            </template>\n            <VCardTitle>\n              {{ props.config?.attrs?.title ?? props.config?.name }}\n            </VCardTitle>\n            <VCardSubtitle v-if=\"props.config?.attrs?.subtitle\"> {{ props.config?.attrs?.subtitle }}</VCardSubtitle>\n          </VCardItem>\n          <VCardText>\n            <DashboardRender v-for=\"(item, index) in props.config?.elements\" :key=\"index\" :config=\"item\" />\n          </VCardText>\n        </VCard>\n      </template>\n    </VHover>\n    <!-- 未知模式或错误 -->\n    <VCard v-else>\n      <VCardText>无法渲染插件仪表盘部件: 未知渲染模式或配置错误</VCardText>\n    </VCard>\n  </template>\n</template>\n"
  },
  {
    "path": "src/components/misc/FilterOption.vue",
    "content": "<script lang=\"ts\" setup>\ndefineProps<{ title: string }>()\n</script>\n<template>\n  <VListSubheader>{{ title }}</VListSubheader>\n  <VListItem><slot /></VListItem>\n</template>\n"
  },
  {
    "path": "src/components/misc/MediaIdSelector.vue",
    "content": "<script lang=\"ts\" setup>\nimport api from '@/api'\nimport type { MediaInfo } from '@/api/types'\n\n// 定义输入变量\nconst props = defineProps({\n  type: String, // 来源 themoviedb | douban\n})\n\ninterface TmdbItem {\n  title: string\n  overview: string\n  tmdbid: number\n  doubanid: string\n  poster: string\n}\n\n// update:modelValue 事件\nconst emit = defineEmits(['update:modelValue', 'close'])\n\nconst items = ref<TmdbItem[]>([])\n\n// 搜索词\nconst keyword = ref('')\n\n// 加载中\nconst loading = ref(false)\n\n// ref\nconst inputKeyword = ref<HTMLElement | null>(null)\n\n// 选中条目\nfunction selectMedia(item: TmdbItem) {\n  emit('update:modelValue', item.tmdbid || item.doubanid)\n  emit('close')\n}\n\n// TMDB图片转换为w500大小\nfunction getW500Image(url = '') {\n  if (!url) return ''\n  return url.replace('original', 'w500')\n}\n\n// 搜索词条\nasync function searchMedias() {\n  if (!keyword) return\n\n  // 调用API搜索词条\n  try {\n    loading.value = true\n    const result: MediaInfo[] = await api.get('media/search', {\n      params: {\n        title: keyword.value,\n        page: 1,\n        count: 20,\n      },\n    })\n\n    // 清空\n    items.value = []\n\n    // 赋值\n    for (const item of result) {\n      if (props.type && props.type !== item.source) continue\n      items.value.push({\n        tmdbid: item.tmdb_id || 0,\n        doubanid: item.douban_id || '',\n        poster: getW500Image(item.poster_path),\n        title: `${item.title}（${item.year}）`,\n        overview: `<span class=\"text-primary\">${item.type}</span> ${item.overview}`,\n      })\n    }\n    loading.value = false\n  } catch (e) {\n    console.error(e)\n  }\n}\n\n// 加载时聚焦搜索框\nonMounted(() => {\n  // 500ms后聚焦\n  setTimeout(() => {\n    inputKeyword.value?.focus()\n  }, 500)\n})\n</script>\n\n<template>\n  <VCard class=\"mx-auto\" width=\"100%\">\n    <VToolbar flat class=\"p-0\">\n      <VTextField\n        ref=\"inputKeyword\"\n        v-model=\"keyword\"\n        label=\"输入名称搜索\"\n        single-line\n        placeholder=\"电影或电视剧名称\"\n        variant=\"solo\"\n        prepend-inner-icon=\"mdi-magnify\"\n        flat\n        class=\"mx-1\"\n        :loading=\"loading\"\n        @click:append-inner=\"searchMedias\"\n        @keydown.enter=\"searchMedias\"\n      />\n    </VToolbar>\n    <VDialogCloseBtn\n      @click=\"\n        () => {\n          emit('close')\n        }\n      \"\n    />\n    <VDivider />\n    <VList v-if=\"items.length > 0\" lines=\"three\">\n      <template v-for=\"(item, i) in items\" :key=\"i\">\n        <VListItem @click=\"selectMedia(item)\">\n          <template #prepend>\n            <VImg\n              height=\"75\"\n              width=\"50\"\n              :src=\"item.poster\"\n              aspect-ratio=\"2/3\"\n              class=\"object-cover rounded ring-gray-500 me-3\"\n              cover\n            >\n              <template #placeholder>\n                <div class=\"w-full h-full\">\n                  <VSkeletonLoader class=\"object-cover aspect-w-2 aspect-h-3\" />\n                </div>\n              </template>\n            </VImg>\n          </template>\n          <VListItemTitle>\n            {{ item.title }}\n          </VListItemTitle>\n          <VListItemSubtitle class=\"mt-2\" v-html=\"item.overview\" />\n        </VListItem>\n      </template>\n    </VList>\n  </VCard>\n</template>\n"
  },
  {
    "path": "src/components/misc/VersionHistory.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { PropType } from 'vue'\nimport MarkdownIt from 'markdown-it'\nimport mdLinkAttributes from 'markdown-it-link-attributes'\n\n// 初始化 markdown-it\nconst md = new MarkdownIt({\n  html: true,\n  linkify: true,\n  typographer: true,\n})\n\n// 插件：链接在新窗口打开\nmd.use(mdLinkAttributes, {\n  attrs: {\n    target: '_blank',\n    rel: 'noopener noreferrer',\n  },\n})\n\n// 渲染 Markdown\nfunction renderMarkdown(value: string) {\n  if (!value) return ''\n  return md.render(value)\n}\n\n// 输入参数\nconst props = defineProps({\n  history: Object as PropType<{ [key: string]: string }>,\n})\n</script>\n\n<template>\n  <VCardText>\n    <VList>\n      <VListItem v-for=\"(value, key) in props.history\" :key=\"key\">\n        <VListItemTitle class=\"font-bold text-lg\">\n          {{ key }}\n        </VListItemTitle>\n        <div class=\"markdown-body text-gray-500\" v-html=\"renderMarkdown(value)\" />\n      </VListItem>\n    </VList>\n  </VCardText>\n</template>\n\n<style scoped>\n.markdown-body :deep(h1),\n.markdown-body :deep(h2),\n.markdown-body :deep(h3) {\n  margin-block: 0.5rem;\n  font-weight: 600;\n}\n\n.markdown-body :deep(h1) {\n  font-size: 1.5rem;\n}\n\n.markdown-body :deep(h2) {\n  font-size: 1.25rem;\n}\n\n.markdown-body :deep(h3) {\n  font-size: 1.1rem;\n}\n\n.markdown-body :deep(ul),\n.markdown-body :deep(ol) {\n  padding-inline-start: 1.5rem;\n  margin-block: 0.5rem;\n}\n\n.markdown-body :deep(li) {\n  margin-block: 0.25rem;\n}\n\n.markdown-body :deep(p) {\n  margin-block: 0.5rem;\n}\n\n.markdown-body :deep(a) {\n  color: rgb(99 102 241);\n  text-decoration: none;\n}\n\n.markdown-body :deep(a:hover) {\n  text-decoration: underline;\n}\n\n.markdown-body :deep(code) {\n  padding: 0.15rem 0.4rem;\n  border-radius: 0.25rem;\n  font-size: 0.875em;\n  background-color: rgba(127, 127, 127, 0.15);\n}\n\n.markdown-body :deep(pre) {\n  padding: 0.75rem 1rem;\n  margin-block: 0.5rem;\n  overflow-x: auto;\n  border-radius: 0.375rem;\n  background-color: rgba(127, 127, 127, 0.15);\n}\n\n.markdown-body :deep(pre code) {\n  padding: 0;\n  background-color: transparent;\n}\n\n.markdown-body :deep(blockquote) {\n  padding-inline-start: 1rem;\n  margin-block: 0.5rem;\n  border-inline-start: 3px solid rgba(127, 127, 127, 0.4);\n  color: rgba(127, 127, 127, 0.8);\n}\n</style>\n"
  },
  {
    "path": "src/components/render/DashboardRender.vue",
    "content": "<script lang=\"ts\" setup>\nimport { RenderProps } from '@/api/types'\nimport { type PropType } from 'vue'\n\n// 输入参数\nconst elementProps = defineProps({\n  config: Object as PropType<RenderProps>,\n})\n// key\nconst componentKey = ref(0)\n\nonActivated(() => {\n  componentKey.value++\n})\n</script>\n\n<template>\n  <Component\n    :key=\"componentKey\"\n    :is=\"elementProps.config?.component\"\n    v-if=\"!elementProps.config?.html\"\n    v-bind=\"elementProps.config?.props\"\n  >\n    {{ elementProps.config?.text }}\n    <template v-for=\"(content, name) in elementProps.config?.slots || []\" :key=\"name\" v-slot:[name]=\"{ _props }\">\n      <slot :name=\"name\" v-bind=\"_props\">\n        <DashboardRender v-for=\"(slotItem, slotIndex) in content || []\" :key=\"slotIndex\" :config=\"slotItem\" />\n      </slot>\n    </template>\n    <DashboardRender\n      v-for=\"(innerItem, innerIndex) in elementProps.config?.content || []\"\n      :key=\"innerIndex\"\n      :config=\"innerItem\"\n    />\n  </Component>\n  <Component\n    :key=\"componentKey\"\n    :is=\"elementProps.config?.component\"\n    v-if=\"elementProps.config?.html\"\n    v-bind=\"elementProps.config?.props\"\n    v-html=\"elementProps.config?.html\"\n  />\n</template>\n"
  },
  {
    "path": "src/components/render/FormRender.vue",
    "content": "<script setup lang=\"ts\">\nimport { RenderProps } from '@/api/types'\n\n// 定义 props\ndefineProps<{\n  config: RenderProps // JSON 配置\n  model: Record<string, any> // 数据模型\n}>()\n\n/**\n * 解析属性，支持 v-model 和动态绑定\n * @param rawProps 原始属性\n * @param model 数据模型\n * @returns 解析后的属性\n */\nconst parseProps = (rawProps: Record<string, any>, model: Record<string, any>) => {\n  const parsedProps: Record<string, any> = {}\n\n  const isExpression = (value: string) => value.startsWith('{{') && value.endsWith('}}')\n  const extractExpression = (value: string) => value.slice(2, -2).trim()\n\n  for (const [key, value] of Object.entries(rawProps)) {\n    if (key === 'modelvalue') {\n      // 将 modelvalue 转换为 v-model:value 的形式\n      parsedProps['value'] = model[value]\n      parsedProps['onUpdate:value'] = (newValue: any) => {\n        model[value] = newValue\n      }\n    } else if (['model', 'v-model'].includes(key)) {\n      // 处理 v-model\n      parsedProps['modelValue'] = model[value]\n      parsedProps['onUpdate:modelValue'] = (newValue: any) => {\n        model[value] = newValue\n      }\n    } else if (['show', 'v-show'].includes(key)) {\n      // 处理 v-show，实现显示隐藏\n      const expression = isExpression(value) ? extractExpression(value) : value\n      const isVisible = new Function('model', `with(model) { return ${expression} }`)(model)\n      // 动态设置 style.display\n      if (!parsedProps.style) {\n        parsedProps.style = {}\n      }\n      parsedProps.style.display = isVisible ? '' : 'none'\n    } else if (key.startsWith('model:') || key.startsWith('v-model:')) {\n      // 处理 v-model:<prop>\n      const propName = key.split(':')[1]\n      parsedProps[propName] = model[value]\n      parsedProps[`onUpdate:${propName}`] = (newValue: any) => {\n        model[value] = newValue\n      }\n    } else if (key.startsWith('on')) {\n      // 处理事件监听，值是函数的代码 function xxx(e) { ... }\n      if (typeof value === 'string') {\n        // 创建动态函数并绑定model上下文\n        const handler = new Function(\n          'model',\n          'event',\n          `\n            try {\n              with(model) {\n                return (${value})(event);\n              }\n            } catch(e) {\n              console.error('事件处理函数执行错误:', e);\n            }\n          `,\n        )\n        // 包装事件处理器，保持vue事件参数传递特性\n        parsedProps[key] = (...args: any[]) => {\n          const [event] = args\n          return handler(model, event)\n        }\n      }\n    } else {\n      // 如果是表达式，需要绑定\n      if (typeof value === 'string' && isExpression(value)) {\n        const expression = extractExpression(value)\n        parsedProps[key] = new Function('model', `with(model) { return ${expression} }`)(model)\n      } else if (typeof value === 'string' && value in model) {\n        // 如果是数据模型的属性，直接绑定\n        parsedProps[key] = model[value]\n      } else {\n        // 其他情况直接赋值\n        parsedProps[key] = value\n      }\n    }\n  }\n\n  return parsedProps\n}\n\n/**\n * 渲染插槽内容\n * @param slotContent 插槽配置\n * @param model 数据模型\n * @param slotScope 插槽作用域\n */\nconst renderSlotContent = (slotContent: any, model: any, slotScope: any) => {\n  if (Array.isArray(slotContent)) {\n    // 如果插槽内容是数组，递归渲染\n    return slotContent.map(childConfig => renderComponent(childConfig, model, slotScope))\n  }\n  // 如果插槽内容是单个配置，递归渲染\n  return renderComponent(slotContent, model, slotScope)\n}\n\n/**\n * 渲染组件函数（递归支持嵌套）\n * @param config JSON 配置\n * @param model 数据模型\n * @param slotScope 插槽作用域\n * @returns 渲染的组件 VNode\n */\nconst renderComponent = (config: any, model: any, slotScope: any = {}) => {\n  const { component, props: componentProps = {}, content = [], slots = {}, html, text } = config\n\n  // 动态解析组件\n  const Component = resolveComponent(component)\n\n  // 解析属性\n  const parsedProps = parseProps(componentProps, model)\n\n  // 动态插槽解析\n  const slotNodes: Record<string, any> = {}\n  for (const [slotName, slotContent] of Object.entries(slots)) {\n    slotNodes[slotName] = (slotScopeData: any) =>\n      renderSlotContent(slotContent, model, { ...slotScope, ...slotScopeData })\n  }\n\n  // 渲染组件内容\n  const renderContent = () => {\n    // 如果配置了 `html`，直接渲染为 HTML 内容\n    if (html) {\n      return h(Component, { innerHTML: typeof html === 'string' ? html : model[html] })\n    }\n\n    // 如果配置了 `text`，直接渲染为文本内容\n    if (text) {\n      return typeof text === 'string' ? text : model[text]\n    }\n\n    // 如果配置了 `content`，递归渲染子组件\n    if (Array.isArray(content)) {\n      return content.map((childConfig: any) => renderComponent(childConfig, model, slotScope))\n    }\n\n    return null\n  }\n\n  // 渲染组件\n  return h(Component, parsedProps, {\n    ...slotNodes,\n    default: renderContent,\n  })\n}\n</script>\n\n<template>\n  <Component :is=\"renderComponent(config, model)\" />\n</template>\n"
  },
  {
    "path": "src/components/render/PageRender.vue",
    "content": "<script lang=\"ts\" setup>\nimport { isNullOrEmptyObject } from '@/@core/utils'\nimport api from '@/api'\nimport { type PropType } from 'vue'\nimport ProgressDialog from '../dialog/ProgressDialog.vue'\nimport { RenderProps } from '@/api/types'\n\n// 定议外部事件\nconst emit = defineEmits(['action'])\n\n// 输入参数\nconst props = defineProps({\n  config: Object as PropType<RenderProps>,\n})\n\n// 进度框\nconst progressDialog = ref(false)\n\n// 进度框文本\nconst progressText = ref('正在处理...')\n\n// 元素API事件响应\nasync function commonAction(api_path: string, method: string, params = {}) {\n  if (!api_path || !method) return\n  progressDialog.value = true\n  try {\n    if (method.toUpperCase() === 'GET') {\n      await api.get(api_path, {\n        params: params,\n      })\n    } else {\n      await api.post(api_path, params)\n    }\n    emit('action')\n  } catch (error) {\n    console.error(error)\n  }\n  progressDialog.value = false\n}\n\n// 组装事件\nlet componentEvents = reactive<{ [key: string]: any }>({})\nwatchEffect(() => {\n  if (!isNullOrEmptyObject(props.config?.events)) {\n    for (const key in props.config?.events) {\n      const attr = props.config?.events[key]\n      const func = async () => {\n        await commonAction(attr['api'], attr['method'], attr['params'])\n      }\n      componentEvents[key] = func\n    }\n  }\n})\n</script>\n\n<template>\n  <Component :is=\"config?.component\" v-if=\"!config?.html\" v-bind=\"config?.props\" v-on=\"componentEvents\">\n    {{ config?.text }}\n    <PageRender\n      v-for=\"(innerItem, innerIndex) in config?.content || []\"\n      :key=\"innerIndex\"\n      :config=\"innerItem\"\n      @action=\"emit('action')\"\n    />\n  </Component>\n  <Component\n    :is=\"config?.component\"\n    v-if=\"config?.html\"\n    v-bind=\"config?.props\"\n    v-html=\"config?.html\"\n    v-on=\"componentEvents\"\n  />\n  <!-- 进度框 -->\n  <ProgressDialog v-if=\"progressDialog\" v-model=\"progressDialog\" :text=\"progressText\" />\n</template>\n"
  },
  {
    "path": "src/components/slide/SlideView.vue",
    "content": "<script lang=\"ts\" setup>\nimport SlideViewTitle from '@/components/slide/SlideViewTitle.vue'\nimport { useDisplay } from 'vuetify'\n\n// 判断是否可以触摸\nconst display = useDisplay()\nconst isTouch = computed(() => display.mobile.value)\n\n// 元素\nconst slideview_content = ref<HTMLElement | null>(null)\nconst sliderContainer = ref<HTMLElement | null>(null)\n// 分页切换状态: 0-左边不可用 1-两边可用 2-右边不可用 3-两边都不可用\nconst disabled = ref(0)\n// 记录滚动值\nconst slideview_scrollLeft = ref(0)\n// 所有卡片数量\nlet slide_card_length: number\n// 卡片间距\nlet slide_gap_px: number\n// 卡片宽度\nlet card_width: number\n// 容器最多显示N张卡片\nlet card_max: number\n// 当前定位\nlet card_current: number\n// 获取传入的链接地址\nconst props: any = inject('rankingPropsKey', { linkurl: '', title: '' })\nconst isScrolling = ref(false)\nlet scrollTimeout: ReturnType<typeof setTimeout> | null = null\nconst scrollTimeoutDuration = 1500 // 滚动停止后延迟时间 (ms)\n\n// 分页切换\nfunction slideNext(next: boolean) {\n  let run_to_left_px\n  if (next) {\n    const card_index = card_current + card_max\n    run_to_left_px = card_index * card_width\n    if (run_to_left_px >= slideview_content.value!.scrollWidth - slideview_content.value!.clientWidth)\n      run_to_left_px = slideview_content.value!.scrollWidth - slideview_content.value!.clientWidth\n  } else {\n    const card_index = card_current - card_max\n    run_to_left_px = card_index * card_width\n    if (run_to_left_px <= 0) run_to_left_px = 0\n  }\n  slideview_content.value!.scrollTo({\n    top: 0,\n    left: run_to_left_px,\n    behavior: 'smooth',\n  })\n\n  // 点击后强制显示并重置计时器\n  isScrolling.value = true\n  if (scrollTimeout) {\n    clearTimeout(scrollTimeout)\n  }\n  scrollTimeout = setTimeout(() => {\n    isScrolling.value = false\n  }, scrollTimeoutDuration)\n}\n\n// 计算最大显示数量\nfunction countMaxNumber() {\n  if (!slideview_content.value || !slideview_content.value.firstElementChild) return\n  slide_card_length = slideview_content.value.children.length\n  card_width = slideview_content.value.firstElementChild.getBoundingClientRect().width\n  slide_gap_px = slideview_content.value.scrollWidth / slide_card_length - card_width\n  card_width += slide_gap_px\n  card_max = Math.trunc(slideview_content.value.clientWidth / card_width)\n  countDisabled()\n}\n\n// 修改分页切换按钮状态 & 处理滚动状态\nfunction handleContentScroll() {\n  if (!slideview_content.value) return\n  // 更新按钮禁用状态\n  countDisabled()\n\n  // 更新滚动状态并重置计时器\n  isScrolling.value = true\n  if (scrollTimeout) {\n    clearTimeout(scrollTimeout)\n  }\n  scrollTimeout = setTimeout(() => {\n    isScrolling.value = false\n  }, scrollTimeoutDuration) // 使用常量\n}\n\n// 原始的 countDisabled 逻辑，现在由 handleContentScroll 调用\nfunction countDisabled() {\n  if (!slideview_content.value) return\n  slideview_scrollLeft.value = slideview_content.value.scrollLeft\n  card_current =\n    slideview_content.value.scrollLeft === 0\n      ? 0\n      : Math.trunc((slideview_content.value.scrollLeft + card_width / 2) / card_width)\n  if (slide_card_length * card_width <= slideview_content.value.clientWidth) disabled.value = 3\n  else if (slideview_content.value.scrollLeft === 0) disabled.value = 0\n  else if (\n    slideview_content.value.scrollLeft >=\n    slideview_content.value.scrollWidth - slideview_content.value.clientWidth - 2\n  )\n    disabled.value = 2\n  else disabled.value = 1\n}\n\n// 组件加载完成\nonMounted(() => {\n  // 初次获取元素参数\n  countMaxNumber()\n  // 窗口大小发生改变时\n  window.addEventListener('resize', countMaxNumber)\n})\n\nonUnmounted(() => {\n  // 卸载事件\n  window.removeEventListener('resize', countMaxNumber)\n})\n\nonActivated(() => {\n  if (slideview_scrollLeft.value !== 0) {\n    slideview_content.value!.scrollLeft = slideview_scrollLeft.value\n  }\n})\n</script>\n\n<template>\n  <div ref=\"sliderContainer\" class=\"slider-container\" :class=\"{ 'is-scrolling': isScrolling }\">\n    <div class=\"slider-header\">\n      <slot name=\"title\">\n        <SlideViewTitle />\n      </slot>\n      <!-- 查看全部按钮 -->\n      <RouterLink v-if=\"props.linkurl\" :to=\"props.linkurl\" class=\"view-all-button\">\n        <span>更多</span>\n        <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" class=\"arrow-svg\">\n          <path d=\"M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z\" />\n        </svg>\n      </RouterLink>\n    </div>\n\n    <div class=\"slider-content-wrapper\">\n      <div class=\"slider-content-container\">\n        <div ref=\"slideview_content\" class=\"slider-content\" tabindex=\"0\" @scroll=\"handleContentScroll\">\n          <slot name=\"content\" />\n        </div>\n      </div>\n\n      <!-- 左侧导航按钮 -->\n      <VBtn\n        class=\"nav-button nav-button-left\"\n        @click.stop=\"slideNext(false)\"\n        v-show=\"disabled !== 0 && disabled !== 3 && !isTouch\"\n        variant=\"text\"\n        icon\n        color=\"secondary\"\n      >\n        <svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\">\n          <path d=\"M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z\" />\n        </svg>\n      </VBtn>\n\n      <!-- 右侧导航按钮 -->\n      <VBtn\n        class=\"nav-button nav-button-right\"\n        @click.stop=\"slideNext(true)\"\n        v-show=\"disabled !== 2 && disabled !== 3 && !isTouch\"\n        variant=\"text\"\n        icon\n        color=\"secondary\"\n      >\n        <svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\">\n          <path d=\"M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z\" />\n        </svg>\n      </VBtn>\n    </div>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.slider-container {\n  position: relative;\n  margin-block-end: 8px;\n}\n\n.slider-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 8px;\n  margin-block-end: 8px;\n  padding-block: 0;\n  padding-inline: 8px;\n\n  & > :first-child {\n    flex-grow: 1;\n    min-inline-size: 0;\n  }\n}\n\n.view-all-button {\n  .arrow-svg {\n    fill: currentcolor;\n    margin-inline-start: 2px;\n    transition: transform 0.3s ease;\n  }\n\n  display: inline-flex;\n  flex-shrink: 0;\n  align-items: center;\n  border-radius: 8px;\n  background-color: transparent;\n  color: rgb(var(--v-theme-primary));\n  font-size: 0.85rem;\n  font-weight: 500;\n  padding-block: 5px;\n  padding-inline: 12px;\n  text-decoration: none;\n  transition: all 0.25s ease;\n\n  &:hover {\n    border-color: rgba(var(--v-theme-primary), 0.5);\n    background-color: rgba(var(--v-theme-primary), 0.08);\n    transform: translateY(-1px);\n\n    .arrow-svg {\n      transform: translateX(3px);\n    }\n  }\n\n  span {\n    margin-inline-end: 4px;\n  }\n}\n\n.slider-content-wrapper {\n  position: relative;\n  inline-size: 100%;\n}\n\n.slider-content-container {\n  position: relative;\n  overflow: hidden;\n  inline-size: 100%;\n}\n\n.nav-button {\n  position: absolute;\n  z-index: 20;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  padding: 0;\n  border-radius: 50%;\n  backdrop-filter: blur(8px);\n  background-color: rgba(var(--v-theme-background), 0.3);\n  block-size: 36px;\n  box-shadow: 0 4px 12px rgba(0, 0, 0, 8%);\n  cursor: pointer;\n  inline-size: 36px;\n  inset-block-start: 50%;\n  opacity: 0;\n  pointer-events: none;\n  text-shadow: 0 1px 2px rgba(0, 0, 0, 10%);\n  transform: translateY(-50%);\n  transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), background-color 0.3s ease,\n    box-shadow 0.3s ease, border-color 0.3s ease;\n\n  svg {\n    block-size: 22px;\n    fill: currentcolor;\n    filter: none;\n    inline-size: 22px;\n    opacity: 0.7;\n    transition: all 0.3s ease;\n  }\n\n  &:hover {\n    color: rgb(var(--v-theme-primary));\n    transform: translateY(-50%) scale(1.05);\n\n    svg {\n      opacity: 1;\n    }\n  }\n}\n\n.nav-button-left {\n  inset-inline-start: 8px;\n}\n\n.nav-button-right {\n  inset-inline-end: 8px;\n}\n\n.slider-content {\n  display: grid;\n  overflow: scroll hidden !important;\n  justify-content: start;\n  gap: 16px;\n  grid-auto-flow: column;\n  grid-template-rows: 1fr;\n  -ms-overflow-style: none !important;\n  overscroll-behavior-x: contain !important;\n  padding-block: 8px;\n  padding-inline: 12px;\n  scroll-behavior: smooth;\n  scrollbar-width: none !important;\n\n  &::-webkit-scrollbar {\n    display: none;\n  }\n}\n\n// 触摸设备：滚动时显示 (通过 JS 添加的类控制)\n// 这个规则会在不支持 hover 的设备上生效\n.slider-container.is-scrolling .nav-button {\n  opacity: 1;\n  pointer-events: auto;\n}\n\n// 桌面设备：悬停时显示\n@media (hover: hover) {\n  .slider-container:hover .nav-button {\n    // 这个规则会覆盖 .is-scrolling 的效果 (如果同时存在)\n    // 或者在非 scrolling 状态下，hover 时也能显示\n    opacity: 1;\n    pointer-events: auto;\n  }\n\n  // 在 hover 设备上，即使在滚动，如果鼠标不悬停，按钮也应该隐藏\n  // 因此，基础 .nav-button 的 opacity: 0 规则在这里仍然是必要的\n  // (之前错误地以为 hover 会完全覆盖，但滚动时 class 和 hover 可能同时存在)\n  // .nav-button { opacity: 0; pointer-events: none; } // 这行其实不需要重复，默认就是这样\n}\n</style>\n"
  },
  {
    "path": "src/components/slide/SlideViewTitle.vue",
    "content": "<script lang=\"ts\" setup>\n// 输入参数\nconst props: any = inject('rankingPropsKey')\n</script>\n\n<template>\n  <div class=\"title-wrapper\">\n    <div class=\"title-section\">\n      <div class=\"title-badge\"></div>\n      <h3 class=\"title-text\">{{ props?.title }}</h3>\n    </div>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.title-wrapper {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  inline-size: 100%;\n}\n\n.title-section {\n  position: relative;\n  display: flex;\n  align-items: center;\n}\n\n.title-badge {\n  border-radius: 2px;\n  background-color: rgb(var(--v-theme-primary));\n  block-size: 16px;\n  inline-size: 3px;\n  margin-inline-end: 8px;\n}\n\n.title-text {\n  padding: 0;\n  margin: 0;\n  color: rgba(var(--v-theme-on-background), 0.95);\n  font-size: 1.2rem;\n  font-weight: 600;\n}\n</style>\n"
  },
  {
    "path": "src/components/toast/VersionUpdateToast.vue",
    "content": "<template>\n  <div class=\"version-update-toast\">\n    <span class=\"message\">{{ message }}</span>\n    <button v-if=\"refreshText\" class=\"refresh-button\" @click=\"handleRefresh\">\n      {{ refreshText }}\n    </button>\n    <div v-else class=\"spinner\"></div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\n// 接收 props\ninterface Props {\n  message: string\n  refreshText?: string\n  onRefresh?: () => void\n}\n\nconst props = defineProps<Props>()\n\nconst handleRefresh = () => {\n  if (props.onRefresh) {\n    props.onRefresh()\n  } else {\n    window.location.reload()\n  }\n}\n</script>\n\n<style scoped>\n.version-update-toast {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n}\n\n.message {\n  flex: 1;\n  word-break: break-all;\n  line-height: 1.4;\n}\n\n.refresh-button {\n  padding: 6px 16px;\n  background-color: #fff;\n  color: #333;\n  border: none;\n  border-radius: 4px;\n  cursor: pointer;\n  font-size: 14px;\n  font-weight: 500;\n  white-space: nowrap;\n  flex-shrink: 0;\n  transition: all 0.2s;\n  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);\n}\n\n.refresh-button:hover {\n  background-color: #f5f5f5;\n  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);\n}\n\n.refresh-button:active {\n  transform: scale(0.98);\n}\n\n.spinner {\n  width: 16px;\n  height: 16px;\n  border: 2px solid rgba(255, 255, 255, 0.3);\n  border-top-color: #fff;\n  border-radius: 50%;\n  animation: spin 0.8s linear infinite;\n  flex-shrink: 0;\n}\n\n@keyframes spin {\n  to {\n    transform: rotate(360deg);\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/workflow/AddDownloadAction.vue",
    "content": "<script setup lang=\"ts\">\nimport api from '@/api'\nimport { DownloaderConf } from '@/api/types'\nimport { Handle, Position } from '@vue-flow/core'\nimport { useI18n } from 'vue-i18n'\n\nconst { t } = useI18n()\n\ndefineProps({\n  id: {\n    type: String,\n    required: true,\n  },\n  data: {\n    type: Object,\n    required: true,\n  },\n})\n\n// 下载器选项\nconst downloaderOptions = ref<{ title: string; value: string }[]>([])\n\n// 加载所有下载器\nasync function loadDownloaderSetting() {\n  try {\n    const downloaders: DownloaderConf[] = await api.get('download/clients')\n    downloaderOptions.value = [\n      { title: t('common.default'), value: '' },\n      ...downloaders.map((item: { name: any }) => ({\n        title: item.name,\n        value: item.name,\n      })),\n    ]\n  } catch (error) {\n    console.error(error)\n  }\n}\n\nonMounted(() => {\n  loadDownloaderSetting()\n})\n</script>\n<template>\n  <div>\n    <VCard max-width=\"20rem\">\n      <Handle id=\"edge_in\" type=\"target\" :position=\"Position.Left\" />\n      <VCardItem>\n        <template v-slot:prepend>\n          <VAvatar>\n            <VIcon icon=\"mdi-download\" size=\"x-large\"></VIcon>\n          </VAvatar>\n        </template>\n        <VCardTitle>{{ t('workflow.addDownload.title') }}</VCardTitle>\n        <VCardSubtitle>{{ t('workflow.addDownload.subtitle') }}</VCardSubtitle>\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <VRow>\n          <VCol cols=\"12\">\n            <VSelect\n              v-model=\"data.downloader\"\n              :items=\"downloaderOptions\"\n              :label=\"t('workflow.addDownload.downloader')\"\n              outlined\n              dense\n            />\n          </VCol>\n          <VCol cols=\"12\">\n            <VTextField\n              v-model=\"data.labels\"\n              :label=\"t('workflow.addDownload.category')\"\n              :placeholder=\"t('workflow.addDownload.categoryPlaceholder')\"\n              outlined\n              dense\n            />\n          </VCol>\n          <VCol cols=\"12\">\n            <VPathField\n              v-model=\"data.save_path\"\n              storage=\"local\"\n              :label=\"t('workflow.addDownload.savePath')\"\n              clearable\n              :placeholder=\"t('workflow.addDownload.savePathPlaceholder')\"\n            />\n          </VCol>\n          <VCol cols=\"12\">\n            <VSwitch v-model=\"data.only_lack\" :label=\"t('workflow.addDownload.onlyLack')\" />\n          </VCol>\n        </VRow>\n      </VCardText>\n      <Handle id=\"edge_out\" type=\"source\" :position=\"Position.Right\" />\n    </VCard>\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/workflow/AddSubscribeAction.vue",
    "content": "<script setup lang=\"ts\">\nimport { Handle, Position } from '@vue-flow/core'\nimport { useI18n } from 'vue-i18n'\n\nconst { t } = useI18n()\n\ndefineProps({\n  id: {\n    type: String,\n    required: true,\n  },\n  data: {\n    type: Object,\n    required: true,\n  },\n})\n</script>\n<template>\n  <div>\n    <VCard>\n      <Handle id=\"edge_in\" type=\"target\" :position=\"Position.Left\" />\n      <VCardItem>\n        <template v-slot:prepend>\n          <VAvatar>\n            <VIcon icon=\"mdi-star-plus\" size=\"x-large\"></VIcon>\n          </VAvatar>\n        </template>\n        <VCardTitle>{{ t('workflow.addSubscribe.title') }}</VCardTitle>\n        <VCardSubtitle>{{ t('workflow.addSubscribe.subtitle') }}</VCardSubtitle>\n      </VCardItem>\n      <Handle id=\"edge_out\" type=\"source\" :position=\"Position.Right\" />\n    </VCard>\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/workflow/FetchDownloadsAction.vue",
    "content": "<script setup lang=\"ts\">\nimport { Handle, Position } from '@vue-flow/core'\nimport { useI18n } from 'vue-i18n'\n\nconst { t } = useI18n()\n\ndefineProps({\n  id: {\n    type: String,\n    required: true,\n  },\n  data: {\n    type: Object,\n    required: true,\n  },\n})\n</script>\n<template>\n  <div>\n    <VCard max-width=\"20rem\">\n      <Handle id=\"edge_in\" type=\"target\" :position=\"Position.Left\" />\n      <VCardItem>\n        <template v-slot:prepend>\n          <VAvatar>\n            <VIcon icon=\"mdi-progress-download\" size=\"x-large\"></VIcon>\n          </VAvatar>\n        </template>\n        <VCardTitle>{{ t('workflow.fetchDownloads.title') }}</VCardTitle>\n        <VCardSubtitle>{{ t('workflow.fetchDownloads.subtitle') }}</VCardSubtitle>\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <VRow>\n          <VCol cols=\"12\">\n            <VSwitch v-model=\"data.loop\" :label=\"t('workflow.fetchDownloads.loop')\" />\n          </VCol>\n          <VCol cols=\"12\">\n            <VTextField\n              v-model=\"data.loop_interval\"\n              :disabled=\"!data.loop\"\n              type=\"number\"\n              :label=\"t('workflow.fetchDownloads.loopInterval')\"\n              outlined\n              dense\n              clearable\n            />\n          </VCol>\n        </VRow>\n      </VCardText>\n      <Handle id=\"edge_out\" type=\"source\" :position=\"Position.Right\" />\n    </VCard>\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/workflow/FetchMediasAction.vue",
    "content": "<script setup lang=\"ts\">\nimport { Handle, Position } from '@vue-flow/core'\nimport api from '@/api'\nimport { RecommendSource } from '@/api/types'\nimport { useI18n } from 'vue-i18n'\n\nconst { t } = useI18n()\n\ndefineProps({\n  id: {\n    type: String,\n    required: true,\n  },\n  data: {\n    type: Object,\n    required: true,\n  },\n})\n\n// 内置榜单\nconst innerList = [\n  {\n    'api_path': 'recommend/tmdb_trending',\n    'name': t('workflow.fetchMedias.tmdbTrending'),\n  },\n  {\n    'api_path': 'recommend/douban_showing',\n    'name': t('workflow.fetchMedias.doubanShowing'),\n  },\n  {\n    'api_path': 'recommend/bangumi_calendar',\n    'name': t('workflow.fetchMedias.bangumiCalendar'),\n  },\n  {\n    'api_path': 'recommend/tmdb_movies',\n    'name': t('workflow.fetchMedias.tmdbMovies'),\n  },\n  {\n    'api_path': 'recommend/tmdb_tvs?with_original_language=zh|en|ja|ko',\n    'name': t('workflow.fetchMedias.tmdbTvs'),\n  },\n  {\n    'api_path': 'recommend/douban_movie_hot',\n    'name': t('workflow.fetchMedias.doubanMovieHot'),\n  },\n  {\n    'api_path': 'recommend/douban_tv_hot',\n    'name': t('workflow.fetchMedias.doubanTvHot'),\n  },\n  {\n    'api_path': 'recommend/douban_tv_animation',\n    'name': t('workflow.fetchMedias.doubanTvAnimation'),\n  },\n  {\n    'api_path': 'recommend/douban_movies',\n    'name': t('workflow.fetchMedias.doubanMovies'),\n  },\n  {\n    'api_path': 'recommend/douban_tvs',\n    'name': t('workflow.fetchMedias.doubanTvs'),\n  },\n  {\n    'api_path': 'recommend/douban_movie_top250',\n    'name': t('workflow.fetchMedias.doubanMovieTop250'),\n  },\n  {\n    'api_path': 'recommend/douban_tv_weekly_chinese',\n    'name': t('workflow.fetchMedias.doubanTvWeeklyChinese'),\n  },\n  {\n    'api_path': 'recommend/douban_tv_weekly_global',\n    'name': t('workflow.fetchMedias.doubanTvWeeklyGlobal'),\n  },\n]\n\n// 额外的数据源\nconst extraRecommendSources = ref<RecommendSource[]>([])\n\n// 加载额外的发现数据源\nasync function loadExtraRecommendSources() {\n  try {\n    extraRecommendSources.value = await api.get('recommend/source')\n    if (extraRecommendSources.value.length > 0) {\n      innerList.push(\n        ...extraRecommendSources.value.map(source => ({\n          api_path: source.api_path,\n          name: source.name,\n        })),\n      )\n    }\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 来源类型下拉框\nconst sourceTypeOptions = [\n  { value: 'ranking', title: t('workflow.fetchMedias.ranking') },\n  { value: 'api', title: t('workflow.fetchMedias.api') },\n]\n\n// 计算下拉框\nconst sourceOptions = computed(() => innerList.map(item => ({ value: item.api_path, title: item.name })))\n\nonMounted(() => {\n  loadExtraRecommendSources()\n})\n</script>\n<template>\n  <div>\n    <VCard max-width=\"20rem\">\n      <Handle id=\"edge_in\" type=\"target\" :position=\"Position.Left\" />\n      <VCardItem>\n        <template v-slot:prepend>\n          <VAvatar>\n            <VIcon icon=\"mdi-movie-search\" size=\"x-large\"></VIcon>\n          </VAvatar>\n        </template>\n        <VCardTitle>{{ t('workflow.fetchMedias.title') }}</VCardTitle>\n        <VCardSubtitle>{{ t('workflow.fetchMedias.subtitle') }}</VCardSubtitle>\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <VRow>\n          <VCol cols=\"12\">\n            <VSelect\n              v-model=\"data.source_type\"\n              :items=\"sourceTypeOptions\"\n              :label=\"t('workflow.fetchMedias.source')\"\n              outlined\n              dense\n            />\n          </VCol>\n        </VRow>\n        <VRow v-if=\"data.source_type === 'ranking'\">\n          <VCol cols=\"12\">\n            <VSelect\n              v-model=\"data.sources\"\n              :items=\"sourceOptions\"\n              :label=\"t('workflow.fetchMedias.selectRanking')\"\n              chips\n              multiple\n              outlined\n              dense\n              clearable\n            />\n          </VCol>\n        </VRow>\n        <VRow v-else>\n          <VCol cols=\"12\">\n            <VTextField\n              v-model=\"data.api_path\"\n              :label=\"t('workflow.fetchMedias.apiPath')\"\n              placeholder=\"/api/v1/plugin/xxx/xxxx\"\n              outlined\n              dense\n              clearable\n            />\n          </VCol>\n        </VRow>\n      </VCardText>\n      <Handle id=\"edge_out\" type=\"source\" :position=\"Position.Right\" />\n    </VCard>\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/workflow/FetchRssAction.vue",
    "content": "<script setup lang=\"ts\">\nimport { Handle, Position } from '@vue-flow/core'\nimport { useI18n } from 'vue-i18n'\n\nconst { t } = useI18n()\n\ndefineProps({\n  id: {\n    type: String,\n    required: true,\n  },\n  data: {\n    type: Object,\n    required: true,\n  },\n})\n</script>\n<template>\n  <div>\n    <VCard max-width=\"20rem\">\n      <Handle id=\"edge_in\" type=\"target\" :position=\"Position.Left\" />\n      <VCardItem>\n        <template v-slot:prepend>\n          <VAvatar>\n            <VIcon icon=\"mdi-rss\" size=\"x-large\"></VIcon>\n          </VAvatar>\n        </template>\n        <VCardTitle>{{ t('workflow.fetchRss.title') }}</VCardTitle>\n        <VCardSubtitle>{{ t('workflow.fetchRss.subtitle') }}</VCardSubtitle>\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <VRow>\n          <VCol cols=\"12\">\n            <VTextField v-model=\"data.url\" :label=\"t('workflow.fetchRss.url')\" outlined dense clearable />\n          </VCol>\n          <VCol cols=\"12\">\n            <VTextField v-model=\"data.ua\" :label=\"t('workflow.fetchRss.userAgent')\" outlined dense clearable />\n          </VCol>\n          <VCol cols=\"12\">\n            <VTextField\n              v-model=\"data.timeout\"\n              type=\"number\"\n              :label=\"t('workflow.fetchRss.timeout')\"\n              outlined\n              dense\n              clearable\n            />\n          </VCol>\n          <VCol cols=\"6\">\n            <VSwitch v-model=\"data.match_media\" :label=\"t('workflow.fetchRss.matchMedia')\" />\n          </VCol>\n          <VCol cols=\"6\">\n            <VSwitch v-model=\"data.proxy\" :label=\"t('workflow.fetchRss.useProxy')\" />\n          </VCol>\n        </VRow>\n      </VCardText>\n      <Handle id=\"edge_out\" type=\"source\" :position=\"Position.Right\" />\n    </VCard>\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/workflow/FetchTorrentsAction.vue",
    "content": "<script setup lang=\"ts\">\nimport api from '@/api'\nimport { Site } from '@/api/types'\nimport { Handle, Position } from '@vue-flow/core'\nimport { useI18n } from 'vue-i18n'\n\nconst { t } = useI18n()\n\ndefineProps({\n  id: {\n    type: String,\n    required: true,\n  },\n  data: {\n    type: Object,\n    required: true,\n  },\n})\n\n// 电影/电视剧下拉框\nconst typeOptions = ref([\n  {\n    title: t('mediaType.movie'),\n    value: '电影',\n  },\n  {\n    title: t('mediaType.tv'),\n    value: '电视剧',\n  },\n])\n\n// 搜索方式下拉框\nconst searchOptions = ref([\n  {\n    title: t('workflow.fetchTorrents.searchOptions.name'),\n    value: 'keyword',\n  },\n  {\n    title: t('workflow.fetchTorrents.searchOptions.mediaList'),\n    value: 'media',\n  },\n])\n\n// 站点数据列表\nconst siteList = ref<Site[]>([])\n\n// 获取站点列表数据\nasync function loadSites() {\n  try {\n    const data: Site[] = await api.get('site/rss')\n\n    // 过滤站点，只有启用的站点才显示\n    siteList.value = data.filter(item => item.is_active)\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 站点选项\nconst siteOptions = computed(() => {\n  return siteList.value.map(item => {\n    return {\n      title: item.name,\n      value: item.id,\n    }\n  })\n})\n\nonMounted(() => {\n  loadSites()\n})\n</script>\n<template>\n  <div>\n    <VCard max-width=\"20rem\">\n      <Handle id=\"edge_in\" type=\"target\" :position=\"Position.Left\" />\n      <VCardItem>\n        <template v-slot:prepend>\n          <VAvatar>\n            <VIcon icon=\"mdi-search-web\" size=\"x-large\"></VIcon>\n          </VAvatar>\n        </template>\n        <VCardTitle>{{ t('workflow.fetchTorrents.title') }}</VCardTitle>\n        <VCardSubtitle>{{ t('workflow.fetchTorrents.subtitle') }}</VCardSubtitle>\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <VRow>\n          <VCol cols=\"12\">\n            <VSelect\n              v-model=\"data.search_type\"\n              :label=\"t('workflow.fetchTorrents.searchType')\"\n              :items=\"searchOptions\"\n              outlined\n              dense\n            />\n          </VCol>\n        </VRow>\n        <VRow v-if=\"data.search_type === 'keyword'\">\n          <VCol cols=\"6\">\n            <VTextField v-model=\"data.name\" :label=\"t('workflow.fetchTorrents.name')\" outlined dense />\n          </VCol>\n          <VCol cols=\"6\">\n            <VTextField v-model=\"data.year\" :label=\"t('workflow.fetchTorrents.year')\" outlined dense />\n          </VCol>\n          <VCol cols=\"6\">\n            <VSelect\n              v-model=\"data.type\"\n              :label=\"t('workflow.fetchTorrents.type')\"\n              :items=\"typeOptions\"\n              outlined\n              dense\n            />\n          </VCol>\n          <VCol cols=\"6\">\n            <VTextField\n              v-model=\"data.season\"\n              type=\"number\"\n              :label=\"t('workflow.fetchTorrents.season')\"\n              outlined\n              dense\n            />\n          </VCol>\n        </VRow>\n        <VRow>\n          <VCol cols=\"12\">\n            <VSelect\n              v-model=\"data.sites\"\n              :label=\"t('workflow.fetchTorrents.sites')\"\n              :items=\"siteOptions\"\n              chips\n              multiple\n              outlined\n              dense\n              clearable\n            />\n          </VCol>\n        </VRow>\n        <VRow v-if=\"data.search_type === 'keyword'\">\n          <VCol cols=\"12\">\n            <VSwitch v-model=\"data.match_media\" :label=\"t('workflow.fetchTorrents.matchMedia')\" />\n          </VCol>\n        </VRow>\n      </VCardText>\n      <Handle id=\"edge_out\" type=\"source\" :position=\"Position.Right\" />\n    </VCard>\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/workflow/FilterMediasAction.vue",
    "content": "<script setup lang=\"ts\">\nimport api from '@/api'\nimport { Handle, Position } from '@vue-flow/core'\nimport { useI18n } from 'vue-i18n'\n\nconst { t } = useI18n()\n\nconst props = defineProps({\n  id: {\n    type: String,\n    required: true,\n  },\n  data: {\n    type: Object,\n    required: true,\n  },\n})\n\n// 电影/电视剧下拉框\nconst typeOptions = ref([\n  {\n    title: t('mediaType.movie'),\n    value: '电影',\n  },\n  {\n    title: t('mediaType.tv'),\n    value: '电视剧',\n  },\n])\n\n// 二级分类策略\nconst mediaCategories = ref<{ [key: string]: any }>({})\n\n// 调用API查询自动分类配置\nasync function loadMediaCategories() {\n  try {\n    mediaCategories.value = await api.get('media/category')\n  } catch (error) {\n    console.log(error)\n  }\n}\n\nonMounted(() => {\n  loadMediaCategories()\n})\n</script>\n<template>\n  <div>\n    <VCard max-width=\"20rem\">\n      <Handle id=\"edge_in\" type=\"target\" :position=\"Position.Left\" />\n      <VCardItem>\n        <template v-slot:prepend>\n          <VAvatar>\n            <VIcon icon=\"mdi-filter-check\" size=\"x-large\"></VIcon>\n          </VAvatar>\n        </template>\n        <VCardTitle>{{ t('workflow.filterMedias.title') }}</VCardTitle>\n        <VCardSubtitle>{{ t('workflow.filterMedias.subtitle') }}</VCardSubtitle>\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <VRow>\n          <VCol cols=\"12\">\n            <VSelect v-model=\"data.type\" :label=\"t('workflow.filterMedias.type')\" :items=\"typeOptions\" outlined dense />\n          </VCol>\n          <VCol cols=\"6\">\n            <VTextField v-model=\"data.year\" :label=\"t('workflow.filterMedias.year')\" outlined dense />\n          </VCol>\n          <VCol cols=\"6\">\n            <VTextField v-model=\"data.vote\" type=\"number\" :label=\"t('workflow.filterMedias.vote')\" outlined dense />\n          </VCol>\n        </VRow>\n      </VCardText>\n      <Handle id=\"edge_out\" type=\"source\" :position=\"Position.Right\" />\n    </VCard>\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/workflow/FilterTorrentsAction.vue",
    "content": "<script setup lang=\"ts\">\nimport api from '@/api'\nimport { FilterRuleGroup } from '@/api/types'\nimport { Handle, Position } from '@vue-flow/core'\nimport { useI18n } from 'vue-i18n'\nimport { qualityOptions, resolutionOptions, effectOptions } from '@/api/constants'\n\nconst { t } = useI18n()\n\ndefineProps({\n  id: {\n    type: String,\n    required: true,\n  },\n  data: {\n    type: Object,\n    required: true,\n  },\n})\n\n// 所有规则组列表\nconst filterRuleGroups = ref<FilterRuleGroup[]>([])\n\n// 加载规则组\nasync function queryFilterRuleGroups() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')\n    filterRuleGroups.value = result.data?.value ?? []\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 计算过滤规则组选择框数据\nconst ruleGroupsOptions = computed(() => {\n  return filterRuleGroups.value.map(group => ({\n    title: group.name,\n    value: group.name,\n  }))\n})\n\nonMounted(() => {\n  queryFilterRuleGroups()\n})\n</script>\n<template>\n  <div>\n    <VCard max-width=\"20rem\">\n      <Handle id=\"edge_in\" type=\"target\" :position=\"Position.Left\" />\n      <VCardItem>\n        <template v-slot:prepend>\n          <VAvatar>\n            <VIcon icon=\"mdi-filter-multiple\" size=\"x-large\"></VIcon>\n          </VAvatar>\n        </template>\n        <VCardTitle>{{ t('workflow.filterTorrents.title') }}</VCardTitle>\n        <VCardSubtitle>{{ t('workflow.filterTorrents.subtitle') }}</VCardSubtitle>\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <VRow>\n          <VCol cols=\"6\">\n            <VSelect\n              v-model=\"data.quality\"\n              :label=\"t('workflow.filterTorrents.quality')\"\n              :items=\"qualityOptions\"\n              outlined\n              dense\n            />\n          </VCol>\n          <VCol cols=\"6\">\n            <VSelect\n              v-model=\"data.resolution\"\n              :label=\"t('workflow.filterTorrents.resolution')\"\n              :items=\"resolutionOptions\"\n              outlined\n              dense\n            />\n          </VCol>\n          <VCol cols=\"6\">\n            <VSelect\n              v-model=\"data.effect\"\n              :label=\"t('workflow.filterTorrents.effect')\"\n              :items=\"effectOptions\"\n              outlined\n              dense\n            />\n          </VCol>\n          <VCol cols=\"6\">\n            <VTextField\n              v-model=\"data.size\"\n              :label=\"t('workflow.filterTorrents.size')\"\n              placeholder=\"MB\"\n              outlined\n              dense\n            />\n          </VCol>\n          <VCol cols=\"12\">\n            <VTextField v-model=\"data.include\" :label=\"t('workflow.filterTorrents.include')\" outlined dense />\n          </VCol>\n          <VCol cols=\"12\">\n            <VTextField v-model=\"data.exclude\" :label=\"t('workflow.filterTorrents.exclude')\" outlined dense />\n          </VCol>\n          <VCol cols=\"12\">\n            <VSelect\n              v-model=\"data.rule_groups\"\n              chips\n              multiple\n              :label=\"t('workflow.filterTorrents.ruleGroups')\"\n              :items=\"ruleGroupsOptions\"\n              outlined\n              dense\n            />\n          </VCol>\n        </VRow>\n      </VCardText>\n      <Handle id=\"edge_out\" type=\"source\" :position=\"Position.Right\" />\n    </VCard>\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/workflow/InvokePluginAction.vue",
    "content": "<script setup lang=\"ts\">\nimport api from '@/api'\nimport { Handle, Position } from '@vue-flow/core'\nimport { useI18n } from 'vue-i18n'\n\nconst { t } = useI18n()\n\nconst props = defineProps({\n  id: {\n    type: String,\n    required: true,\n  },\n  data: {\n    type: Object,\n    required: true,\n  },\n})\n\ninterface ActionItem {\n  id: string\n  name: string\n}\n\ninterface PluginAction {\n  plugin_id: string\n  plugin_name: string\n  actions: ActionItem[]\n}\n\n// 插件所有动作\nconst pluginActions = ref<PluginAction[]>([])\n\n// 插件选项\nconst pluginOptions = computed(() => {\n  return pluginActions.value.map((item: PluginAction) => ({\n    title: item.plugin_name,\n    value: item.plugin_id,\n  }))\n})\n\n// 动作选项\nconst actionOptions = computed(() => {\n  return pluginActions.value\n    .find((item: PluginAction) => item.plugin_id === props.data.plugin_id)\n    ?.actions.map((item: ActionItem) => ({\n      title: item.name,\n      value: item.id,\n    }))\n})\n\n// 用于在文本框显示和保存时转换action_params\nconst actionParamsText = computed({\n  get: () => {\n    try {\n      return typeof props.data.action_params === 'object'\n        ? JSON.stringify(props.data.action_params, null, 2)\n        : props.data.action_params || ''\n    } catch (error) {\n      console.error(error)\n      return ''\n    }\n  },\n  set: (value: string) => {\n    try {\n      props.data.action_params = value ? JSON.parse(value) : {}\n    } catch (error) {\n      // 如果JSON解析失败，保留原始文本\n      props.data.action_params = value\n      console.error(error)\n    }\n  },\n})\n\n// 加载动作选项\nasync function loadPluginActions() {\n  try {\n    pluginActions.value = await api.get('workflow/plugin/actions')\n  } catch (error) {\n    console.error(error)\n  }\n}\n\nonMounted(() => {\n  loadPluginActions()\n})\n</script>\n<template>\n  <div>\n    <VCard max-width=\"20rem\">\n      <Handle id=\"edge_in\" type=\"target\" :position=\"Position.Left\" />\n      <VCardItem>\n        <template v-slot:prepend>\n          <VAvatar>\n            <VIcon icon=\"mdi-run\" size=\"x-large\"></VIcon>\n          </VAvatar>\n        </template>\n        <VCardTitle>{{ t('workflow.invokePlugin.title') }}</VCardTitle>\n        <VCardSubtitle>{{ t('workflow.invokePlugin.subtitle') }}</VCardSubtitle>\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <VRow>\n          <VCol cols=\"12\">\n            <VSelect\n              v-model=\"data.plugin_id\"\n              :items=\"pluginOptions\"\n              :label=\"t('workflow.invokePlugin.plugin')\"\n              outlined\n              dense\n            />\n          </VCol>\n          <VCol cols=\"12\">\n            <VSelect\n              v-model=\"data.action_id\"\n              :items=\"actionOptions\"\n              :label=\"t('workflow.invokePlugin.actionid')\"\n              outlined\n              dense\n            />\n          </VCol>\n          <VCol cols=\"12\">\n            <VTextarea v-model=\"actionParamsText\" :label=\"t('workflow.invokePlugin.actionParams')\" outlined dense />\n          </VCol>\n        </VRow>\n      </VCardText>\n      <Handle id=\"edge_out\" type=\"source\" :position=\"Position.Right\" />\n    </VCard>\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/workflow/NoteAction.vue",
    "content": "<script setup lang=\"ts\">\nimport { useI18n } from 'vue-i18n'\n\nconst { t } = useI18n()\n\ndefineProps({\n  id: {\n    type: String,\n    required: true,\n  },\n  data: {\n    type: Object,\n    required: true,\n  },\n})\n</script>\n<template>\n  <div>\n    <VCard max-width=\"20rem\" class=\"note-card\">\n      <VCardItem class=\"py-2\">\n        <template v-slot:prepend>\n          <VAvatar color=\"warning\">\n            <VIcon icon=\"mdi-note-text\" size=\"x-large\"></VIcon>\n          </VAvatar>\n        </template>\n        <VCardTitle>{{ t('workflow.note.title') }}</VCardTitle>\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <VRow>\n          <VCol cols=\"12\">\n            <VTextarea\n              v-model=\"data.content\"\n              :label=\"t('workflow.note.content')\"\n              :placeholder=\"t('workflow.note.placeholder')\"\n              outlined\n              dense\n              auto-grow\n              rows=\"3\"\n              max-rows=\"6\"\n              clearable\n            />\n          </VCol>\n        </VRow>\n      </VCardText>\n    </VCard>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.note-card {\n  background: linear-gradient(135deg, rgba(var(--v-theme-warning), 0.1) 0%, rgba(var(--v-theme-warning), 0.05) 100%);\n\n  &:hover {\n    border-color: rgba(var(--v-theme-warning), 0.4);\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/workflow/ScanFileAction.vue",
    "content": "<script setup lang=\"ts\">\nimport api from '@/api'\nimport { StorageConf } from '@/api/types'\nimport { Handle, Position } from '@vue-flow/core'\nimport { useI18n } from 'vue-i18n'\n\nconst { t } = useI18n()\n\ndefineProps({\n  id: {\n    type: String,\n    required: true,\n  },\n  data: {\n    type: Object,\n    required: true,\n  },\n})\n\n// 所有存储\nconst storages = ref<StorageConf[]>([])\n\n// 查询存储\nasync function loadStorages() {\n  const result: { [key: string]: any } = await api.get('system/setting/Storages')\n  storages.value = result.data?.value ?? []\n}\n\n// 存储字典\nconst storageOptions = computed(() => {\n  return storages.value.map(item => ({\n    title: item.name,\n    value: item.type,\n  }))\n})\n\nonMounted(() => {\n  loadStorages()\n})\n</script>\n<template>\n  <div>\n    <VCard max-width=\"20rem\">\n      <Handle id=\"edge_in\" type=\"target\" :position=\"Position.Left\" />\n      <VCardItem>\n        <template v-slot:prepend>\n          <VAvatar>\n            <VIcon icon=\"mdi-folder-search\" size=\"x-large\"></VIcon>\n          </VAvatar>\n        </template>\n        <VCardTitle>{{ t('workflow.scanFile.title') }}</VCardTitle>\n        <VCardSubtitle>{{ t('workflow.scanFile.subtitle') }}</VCardSubtitle>\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <VRow>\n          <VCol cols=\"12\">\n            <VSelect\n              v-model=\"data.storage\"\n              :label=\"t('workflow.scanFile.storage')\"\n              :items=\"storageOptions\"\n              outlined\n              dense\n            />\n          </VCol>\n          <VCol cols=\"12\">\n            <VPathField\n              v-model=\"data.directory\"\n              :storage=\"data.storage\"\n              :label=\"t('workflow.scanFile.directory')\"\n              clearable\n            />\n          </VCol>\n        </VRow>\n      </VCardText>\n      <Handle id=\"edge_out\" type=\"source\" :position=\"Position.Right\" />\n    </VCard>\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/workflow/ScrapeFileAction.vue",
    "content": "<script setup lang=\"ts\">\nimport { Handle, Position } from '@vue-flow/core'\nimport { useI18n } from 'vue-i18n'\n\nconst { t } = useI18n()\n\ndefineProps({\n  id: {\n    type: String,\n    required: true,\n  },\n  data: {\n    type: Object,\n    required: true,\n  },\n})\n</script>\n<template>\n  <div>\n    <VCard max-width=\"20rem\">\n      <Handle id=\"edge_in\" type=\"target\" :position=\"Position.Left\" />\n      <VCardItem>\n        <template v-slot:prepend>\n          <VAvatar>\n            <VIcon icon=\"mdi-file-find\" size=\"x-large\"></VIcon>\n          </VAvatar>\n        </template>\n        <VCardTitle>{{ t('workflow.scrapeFile.title') }}</VCardTitle>\n        <VCardSubtitle>{{ t('workflow.scrapeFile.subtitle') }}</VCardSubtitle>\n      </VCardItem>\n      <Handle id=\"edge_out\" type=\"source\" :position=\"Position.Right\" />\n    </VCard>\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/workflow/SendEventAction.vue",
    "content": "<script setup lang=\"ts\">\nimport { Handle, Position } from '@vue-flow/core'\nimport { useI18n } from 'vue-i18n'\n\nconst { t } = useI18n()\n\ndefineProps({\n  id: {\n    type: String,\n    required: true,\n  },\n  data: {\n    type: Object,\n    required: true,\n  },\n})\n</script>\n<template>\n  <div>\n    <VCard max-width=\"20rem\">\n      <Handle id=\"edge_in\" type=\"target\" :position=\"Position.Left\" />\n      <VCardItem>\n        <template v-slot:prepend>\n          <VAvatar>\n            <VIcon icon=\"mdi-send-check\" size=\"x-large\"></VIcon>\n          </VAvatar>\n        </template>\n        <VCardTitle>{{ t('workflow.sendEvent.title') }}</VCardTitle>\n        <VCardSubtitle>{{ t('workflow.sendEvent.subtitle') }}</VCardSubtitle>\n      </VCardItem>\n      <Handle id=\"edge_out\" type=\"source\" :position=\"Position.Right\" />\n    </VCard>\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/workflow/SendMessageAction.vue",
    "content": "<script setup lang=\"ts\">\nimport api from '@/api'\nimport { NotificationConf } from '@/api/types'\nimport { Handle, Position } from '@vue-flow/core'\nimport { useI18n } from 'vue-i18n'\n\nconst { t } = useI18n()\n\ndefineProps({\n  id: {\n    type: String,\n    required: true,\n  },\n  data: {\n    type: Object,\n    required: true,\n  },\n})\n\n// 所有消息渠道\nconst notifications = ref<NotificationConf[]>([])\n\n// 调用API查询通知渠道设置\nasync function loadNotificationSetting() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/Notifications')\n    notifications.value = result.data?.value ?? []\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 计算消息渠道选项\nconst sourceOptions = computed(() => {\n  return notifications.value.map(item => {\n    return {\n      title: item.name,\n      value: item.name,\n    }\n  })\n})\n\nonMounted(() => {\n  loadNotificationSetting()\n})\n</script>\n<template>\n  <div>\n    <VCard max-width=\"20rem\">\n      <Handle id=\"edge_in\" type=\"target\" :position=\"Position.Left\" />\n      <VCardItem>\n        <template v-slot:prepend>\n          <VAvatar>\n            <VIcon icon=\"mdi-message-arrow-right\" size=\"x-large\"></VIcon>\n          </VAvatar>\n        </template>\n        <VCardTitle>{{ t('workflow.sendMessage.title') }}</VCardTitle>\n        <VCardSubtitle>{{ t('workflow.sendMessage.subtitle') }}</VCardSubtitle>\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <VRow>\n          <VCol cols=\"12\">\n            <VSelect\n              v-model=\"data.client\"\n              :items=\"sourceOptions\"\n              :label=\"t('workflow.sendMessage.channel')\"\n              chips\n              multiple\n              outlined\n              dense\n              clearable\n            />\n          </VCol>\n          <VCol cols=\"12\">\n            <VTextField\n              v-model=\"data.userid\"\n              :label=\"t('workflow.sendMessage.userId')\"\n              chips\n              multiple\n              outlined\n              dense\n              clearable\n            />\n          </VCol>\n        </VRow>\n      </VCardText>\n      <Handle id=\"edge_out\" type=\"source\" :position=\"Position.Right\" />\n    </VCard>\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/workflow/TransferFileAction.vue",
    "content": "<script setup lang=\"ts\">\nimport { Handle, Position } from '@vue-flow/core'\nimport { useI18n } from 'vue-i18n'\n\nconst { t } = useI18n()\n\ndefineProps({\n  id: {\n    type: String,\n    required: true,\n  },\n  data: {\n    type: Object,\n    required: true,\n  },\n})\n\n// 来源下拉框\nconst sourceOptions = ref([\n  {\n    title: t('workflow.transferFile.sourceOptions.fileList'),\n    value: 'files',\n  },\n  {\n    title: t('workflow.transferFile.sourceOptions.downloads'),\n    value: 'downloads',\n  },\n])\n</script>\n<template>\n  <div>\n    <VCard max-width=\"20rem\">\n      <Handle id=\"edge_in\" type=\"target\" :position=\"Position.Left\" />\n      <VCardItem>\n        <template v-slot:prepend>\n          <VAvatar>\n            <VIcon icon=\"mdi-file-move\" size=\"x-large\"></VIcon>\n          </VAvatar>\n        </template>\n        <VCardTitle>{{ t('workflow.transferFile.title') }}</VCardTitle>\n        <VCardSubtitle>{{ t('workflow.transferFile.subtitle') }}</VCardSubtitle>\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <VRow>\n          <VCol cols=\"12\">\n            <VSelect\n              v-model=\"data.source\"\n              :label=\"t('workflow.transferFile.source')\"\n              :items=\"sourceOptions\"\n              outlined\n              dense\n            />\n          </VCol>\n        </VRow>\n      </VCardText>\n      <Handle id=\"edge_out\" type=\"source\" :position=\"Position.Right\" />\n    </VCard>\n  </div>\n</template>\n"
  },
  {
    "path": "src/composables/useAvailableHeight.ts",
    "content": "import { computed, ref, watch, onMounted, onUnmounted, nextTick } from 'vue'\nimport { usePWA } from '@/composables/usePWA'\n\n/**\n * 计算页面内容的可用高度，自动适配 iOS 安全区域和底部 Dock 栏。\n *\n * 通过 DOM 测量获取布局的实际 padding（含 safe-area-inset-top/bottom），\n * 以及 Footer Dock 的实际高度，确保在任何设备上都不会被 Dock 遮挡。\n *\n * 计算公式: viewport - layoutPaddingTop - layoutPaddingBottom - footerDock - componentOffset\n *\n * @param componentOffset - 组件内部额外占用的空间（工具栏、分页栏等，默认 64）\n * @param minHeight - 最小高度（默认 300）\n */\nexport function useAvailableHeight(\n  componentOffset: number = 64,\n  minHeight: number = 300,\n) {\n  const { appMode } = usePWA()\n\n  // 响应式测量值\n  const viewportHeight = ref(window.innerHeight || document.documentElement.clientHeight)\n  const layoutPaddingTop = ref(72)\n  const layoutPaddingBottom = ref(24)\n  const footerDockMeasuredHeight = ref(0)\n\n  function updateMeasurements() {\n    viewportHeight.value = window.innerHeight || document.documentElement.clientHeight\n\n    // 测量 .layout-page-content 的实际 padding（含 env(safe-area-inset-top) 等）\n    const layoutEl = document.querySelector('.layout-page-content') as HTMLElement | null\n    if (layoutEl) {\n      const style = getComputedStyle(layoutEl)\n      layoutPaddingTop.value = parseFloat(style.paddingTop) || 72\n      layoutPaddingBottom.value = parseFloat(style.paddingBottom) || 24\n    }\n\n    // 直接查询 Footer Dock DOM，无论 appMode 状态\n    // Dock 通过 Teleport 挂载到 body，存在即测量，不存在即为 0\n    const footerEl = document.querySelector('.footer-nav-container') as HTMLElement | null\n    footerDockMeasuredHeight.value = footerEl ? footerEl.offsetHeight : 0\n  }\n\n  // appMode 异步变化时（PWA 检测完成、屏幕尺寸变化等），Dock 会出现/消失\n  // 需要等 DOM 更新后重新测量\n  watch(appMode, () => {\n    nextTick(updateMeasurements)\n  })\n\n  onMounted(() => {\n    nextTick(updateMeasurements)\n\n    window.addEventListener('resize', updateMeasurements)\n    if (window.visualViewport) {\n      window.visualViewport.addEventListener('resize', updateMeasurements)\n    }\n  })\n\n  onUnmounted(() => {\n    window.removeEventListener('resize', updateMeasurements)\n    if (window.visualViewport) {\n      window.visualViewport.removeEventListener('resize', updateMeasurements)\n    }\n  })\n\n  const availableHeight = computed(() => {\n    const vh = viewportHeight.value\n\n    // 布局顶部 padding（含 safe-area-inset-top + navbar 高度）\n    const topPadding = layoutPaddingTop.value\n\n    // 布局底部 padding\n    const bottomPadding = layoutPaddingBottom.value\n\n    // 底部 Dock 栏遮挡高度（通过 DOM 测量，含 safe-area-inset-bottom）\n    const footerDockHeight = footerDockMeasuredHeight.value\n\n    const available = vh - topPadding - bottomPadding - footerDockHeight - componentOffset\n\n    return Math.max(available, minHeight)\n  })\n\n  return {\n    availableHeight,\n    viewportHeight,\n  }\n}\n"
  },
  {
    "path": "src/composables/useBackgroundOptimization.ts",
    "content": "import { onMounted, onUnmounted, ref, type Ref } from 'vue'\nimport { sseManagerSingleton } from '@/utils/sseManager'\nimport { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'\n\n/**\n * 后台优化组合函数\n * 统一管理SSE连接和定时器，优化iOS后台性能\n */\nexport function useBackgroundOptimization() {\n  /**\n   * 使用优化的SSE连接\n   * @param url SSE连接地址\n   * @param messageHandler 消息处理函数\n   * @param listenerId 监听器ID（用于区分不同的监听器）\n   * @param options 选项\n   */\n  const useSSE = (\n    url: string,\n    messageHandler: (event: MessageEvent) => void,\n    listenerId: string,\n    options?: {\n      backgroundCloseDelay?: number\n      reconnectDelay?: number\n      maxReconnectAttempts?: number\n      connectDelay?: number // 新增：连接延迟\n    },\n  ) => {\n    // 使用独立的SSE管理器，确保每个监听器都有独立的连接\n    const manager = sseManagerSingleton.getIndependentManager(url, listenerId, options)\n    const isConnected = ref(false)\n\n    onMounted(() => {\n      // 延迟建立连接，确保组件完全挂载\n      const connectDelay = options?.connectDelay || 100\n      setTimeout(() => {\n        try {\n          manager.addMessageListener(listenerId, event => {\n            messageHandler(event)\n            isConnected.value = true\n          })\n        } catch (error) {\n          console.error('SSE连接建立失败:', error)\n        }\n      }, connectDelay)\n    })\n\n    onUnmounted(() => {\n      manager.removeMessageListener(listenerId)\n      isConnected.value = false\n    })\n\n    return {\n      manager,\n      readyState: () => manager.readyState,\n      close: () => manager.removeMessageListener(listenerId),\n      isConnected,\n      forceReconnect: () => manager.forceReconnect(),\n    }\n  }\n\n  /**\n   * 使用优化的定时器\n   * @param id 定时器ID\n   * @param callback 回调函数\n   * @param interval 间隔时间（毫秒）\n   * @param options 选项\n   */\n  const useTimer = (\n    id: string,\n    callback: () => void,\n    interval: number,\n    options?: {\n      runInBackground?: boolean\n      skipInitialRun?: boolean\n    },\n  ) => {\n    onMounted(() => {\n      addBackgroundTimer(id, callback, interval, options)\n    })\n\n    onUnmounted(() => {\n      removeBackgroundTimer(id)\n    })\n\n    return {\n      remove: () => removeBackgroundTimer(id),\n    }\n  }\n\n  /**\n   * 使用延迟SSE连接（类似原来的setTimeout延迟）\n   * @param url SSE连接地址\n   * @param messageHandler 消息处理函数\n   * @param listenerId 监听器ID\n   * @param delay 延迟时间（毫秒）\n   * @param options SSE选项\n   */\n  const useDelayedSSE = (\n    url: string,\n    messageHandler: (event: MessageEvent) => void,\n    listenerId: string,\n    delay: number = 3000,\n    options?: Parameters<typeof useSSE>[3],\n  ) => {\n    // 使用独立的SSE管理器，确保每个监听器都有独立的连接\n    const manager = sseManagerSingleton.getIndependentManager(url, listenerId, options)\n\n    onMounted(() => {\n      setTimeout(() => {\n        manager.addMessageListener(listenerId, messageHandler)\n      }, delay)\n    })\n\n    onUnmounted(() => {\n      manager.removeMessageListener(listenerId)\n    })\n\n    return {\n      manager,\n      readyState: () => manager.readyState,\n      close: () => manager.removeMessageListener(listenerId),\n    }\n  }\n\n  /**\n   * 使用进度SSE连接（用于进度监听）\n   * @param url SSE连接地址\n   * @param messageHandler 消息处理函数\n   * @param listenerId 监听器ID\n   * @param isActive 是否激活的响应式变量\n   */\n  const useProgressSSE = (\n    url: string,\n    messageHandler: (event: MessageEvent) => void,\n    listenerId: string,\n    isActive: Ref<boolean>,\n  ) => {\n    // 使用独立的SSE管理器，确保每个监听器都有独立的连接\n    const manager = sseManagerSingleton.getIndependentManager(url, listenerId, {\n      backgroundCloseDelay: 1000, // 进度SSE更快关闭\n      reconnectDelay: 1000,\n      maxReconnectAttempts: 5,\n    })\n\n    const startProgress = () => {\n      if (isActive.value) {\n        manager.addMessageListener(listenerId, messageHandler)\n      }\n    }\n\n    const stopProgress = () => {\n      manager.removeMessageListener(listenerId)\n    }\n\n    onUnmounted(() => {\n      stopProgress()\n    })\n\n    return {\n      start: startProgress,\n      stop: stopProgress,\n      manager,\n    }\n  }\n\n  /**\n   * 使用数据刷新定时器（用于仪表盘等数据刷新）\n   * @param id 定时器ID\n   * @param loadDataFunc 加载数据函数\n   * @param interval 刷新间隔（毫秒）\n   * @param immediate 是否立即执行\n   */\n  const useDataRefresh = (\n    id: string,\n    loadDataFunc: () => Promise<void> | void,\n    interval: number = 3000,\n    immediate: boolean = true,\n  ) => {\n    const loading = ref(false)\n\n    const wrappedLoadData = async () => {\n      if (loading.value) return\n\n      loading.value = true\n      try {\n        await loadDataFunc()\n      } catch (error) {\n        console.error(`数据刷新失败 [${id}]:`, error)\n      } finally {\n        loading.value = false\n      }\n    }\n\n    onMounted(async () => {\n      if (immediate) {\n        await wrappedLoadData()\n      }\n\n      addBackgroundTimer(id, wrappedLoadData, interval, {\n        runInBackground: false, // 后台不刷新数据\n        skipInitialRun: true, // 已经手动执行过了\n      })\n    })\n\n    onUnmounted(() => {\n      removeBackgroundTimer(id)\n    })\n\n    return {\n      loading,\n      refresh: wrappedLoadData,\n      stop: () => removeBackgroundTimer(id),\n    }\n  }\n\n  /**\n   * 使用条件性数据刷新定时器（用于需要动态启停的场景）\n   * @param id 定时器ID\n   * @param loadDataFunc 加载数据函数\n   * @param condition 条件响应式引用，为true时启动定时器\n   * @param interval 刷新间隔（毫秒）\n   * @param immediate 是否立即执行\n   */\n  const useConditionalDataRefresh = (\n    id: string,\n    loadDataFunc: () => Promise<void> | void,\n    condition: Ref<boolean>,\n    interval: number = 3000,\n    immediate: boolean = true,\n  ) => {\n    const loading = ref(false)\n    const isTimerActive = ref(false)\n\n    const wrappedLoadData = async () => {\n      if (loading.value || !condition.value) return\n\n      loading.value = true\n      try {\n        await loadDataFunc()\n      } catch (error) {\n        console.error(`条件数据刷新失败 [${id}]:`, error)\n      } finally {\n        loading.value = false\n      }\n    }\n\n    const startTimer = () => {\n      if (!isTimerActive.value && condition.value) {\n        addBackgroundTimer(id, wrappedLoadData, interval, {\n          runInBackground: false,\n          skipInitialRun: !immediate,\n        })\n        isTimerActive.value = true\n      }\n    }\n\n    const stopTimer = () => {\n      if (isTimerActive.value) {\n        removeBackgroundTimer(id)\n        isTimerActive.value = false\n      }\n    }\n\n    onMounted(() => {\n      if (condition.value) {\n        startTimer()\n      }\n\n      // 监听条件变化\n      watch(condition, (newValue: boolean) => {\n        if (newValue) {\n          startTimer()\n        } else {\n          stopTimer()\n        }\n      })\n    })\n\n    onUnmounted(() => {\n      stopTimer()\n    })\n\n    return {\n      loading,\n      refresh: wrappedLoadData,\n      stop: stopTimer,\n      start: startTimer,\n      isActive: isTimerActive,\n    }\n  }\n\n  return {\n    useSSE,\n    useTimer,\n    useDelayedSSE,\n    useProgressSSE,\n    useDataRefresh,\n    useConditionalDataRefresh,\n  }\n}\n"
  },
  {
    "path": "src/composables/useCacheManager.ts",
    "content": "interface CacheInfo {\n  cacheSizes: Record<string, number>\n  totalSize: number\n  totalSizeMB: string\n}\n\nexport function useCacheManager() {\n  const cacheInfo = ref<CacheInfo | null>(null)\n  const isLoading = ref(false)\n  const error = ref<string | null>(null)\n\n  // 发送消息到Service Worker\n  async function sendMessageToSW(message: any): Promise<any> {\n    if (!('serviceWorker' in navigator)) {\n      throw new Error('Service Worker not supported')\n    }\n\n    const registration = await navigator.serviceWorker.ready\n    const messageChannel = new MessageChannel()\n\n    return new Promise((resolve, reject) => {\n      messageChannel.port1.onmessage = (event) => {\n        if (event.data.success) {\n          resolve(event.data)\n        } else {\n          reject(new Error(event.data.error || 'Unknown error'))\n        }\n      }\n\n      registration.active?.postMessage(message, [messageChannel.port2])\n    })\n  }\n\n  // 获取缓存信息\n  async function getCacheInfo() {\n    isLoading.value = true\n    error.value = null\n    \n    try {\n      const response = await sendMessageToSW({ type: 'GET_CACHE_INFO' })\n      cacheInfo.value = response.cacheInfo\n    } catch (err) {\n      error.value = err instanceof Error ? err.message : 'Failed to get cache info'\n      console.error('Failed to get cache info:', err)\n    } finally {\n      isLoading.value = false\n    }\n  }\n\n  // 清理缓存\n  async function cleanupCaches() {\n    isLoading.value = true\n    error.value = null\n    \n    try {\n      const response = await sendMessageToSW({ type: 'CLEANUP_CACHES' })\n      cacheInfo.value = response.cacheInfo\n      return true\n    } catch (err) {\n      error.value = err instanceof Error ? err.message : 'Failed to cleanup caches'\n      console.error('Failed to cleanup caches:', err)\n      return false\n    } finally {\n      isLoading.value = false\n    }\n  }\n\n  // 格式化缓存大小\n  function formatSize(bytes: number): string {\n    if (bytes === 0) return '0 B'\n    \n    const k = 1024\n    const sizes = ['B', 'KB', 'MB', 'GB']\n    const i = Math.floor(Math.log(bytes) / Math.log(k))\n    \n    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]\n  }\n\n  // 获取缓存使用百分比（假设最大100MB）\n  function getCacheUsagePercentage(totalSize: number): number {\n    const maxSize = 100 * 1024 * 1024 // 100MB\n    return Math.min((totalSize / maxSize) * 100, 100)\n  }\n\n  // 监听Service Worker消息\n  function handleSWMessage(event: MessageEvent) {\n    if (event.data && event.data.type === 'CACHE_SIZE_UPDATE') {\n      cacheInfo.value = event.data.data\n    }\n  }\n\n  onMounted(() => {\n    // 获取初始缓存信息\n    getCacheInfo()\n    \n    // 监听Service Worker消息\n    if ('serviceWorker' in navigator) {\n      navigator.serviceWorker.addEventListener('message', handleSWMessage)\n    }\n  })\n\n  onUnmounted(() => {\n    // 移除事件监听\n    if ('serviceWorker' in navigator) {\n      navigator.serviceWorker.removeEventListener('message', handleSWMessage)\n    }\n  })\n\n  return {\n    cacheInfo,\n    isLoading,\n    error,\n    getCacheInfo,\n    cleanupCaches,\n    formatSize,\n    getCacheUsagePercentage,\n  }\n}"
  },
  {
    "path": "src/composables/useConfirm.ts",
    "content": "import { ref } from 'vue'\nimport { createApp } from 'vue'\nimport i18n from '@/plugins/i18n'\nimport vuetify from '@/plugins/vuetify'\nimport ConfirmDialog from '@/@core/components/ConfirmDialog.vue'\nimport DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'\n\ninterface ConfirmOptions {\n  type?: 'info' | 'warn' | 'error'\n  title?: string\n  content?: string\n  confirmText?: string\n  cancelText?: string\n  width?: string | number\n}\n\nlet resolvePromise: ((value: boolean) => void) | null = null\n\n// 创建确认对话框实例\nasync function createConfirmDialog(options: ConfirmOptions = {}) {\n  return new Promise<boolean>(resolve => {\n    resolvePromise = resolve\n\n    // 创建容器\n    const container = document.createElement('div')\n    document.body.appendChild(container)\n\n    // 处理国际化\n    const i18nOptions = {\n      ...options,\n      title: options.title || i18n.global.t('common.confirm'),\n      confirmText: options.confirmText || i18n.global.t('common.confirm'),\n      cancelText: options.cancelText || i18n.global.t('common.cancel'),\n    }\n\n    // 创建应用实例\n    const app = createApp(ConfirmDialog, {\n      modelValue: true,\n      ...i18nOptions,\n      'onUpdate:modelValue': (val: boolean) => {\n        if (!val) {\n          cleanup()\n        }\n      },\n      onConfirm: () => {\n        resolvePromise?.(true)\n        cleanup()\n      },\n      onCancel: () => {\n        resolvePromise?.(false)\n        cleanup()\n      },\n    })\n\n    // 注册必要的组件\n    app.component('VDialogCloseBtn', DialogCloseBtn)\n\n    // 使用插件\n    app.use(vuetify)\n    app.use(i18n)\n\n    // 挂载应用\n    app.mount(container)\n\n    // 清理函数\n    const cleanup = () => {\n      app.unmount()\n      document.body.removeChild(container)\n    }\n  })\n}\n\n// 创建一个函数对象，同时支持直接调用和解构\nconst confirmFunction = Object.assign(createConfirmDialog, {\n  createConfirm: createConfirmDialog,\n})\n\n// 导出 useConfirm 函数\nexport function useConfirm() {\n  return confirmFunction\n}\n\n// 插件\nexport default {\n  install: (app: any) => {\n    app.provide('confirm', { createConfirm: createConfirmDialog })\n  },\n}\n"
  },
  {
    "path": "src/composables/useDynamicButton.ts",
    "content": "import {\n  computed,\n  inject,\n  nextTick,\n  onActivated,\n  onDeactivated,\n  onMounted,\n  onUnmounted,\n  ref,\n  unref,\n  watch,\n  type ComputedRef,\n  type Ref,\n} from 'vue'\n\n// 声明全局变量类型\ndeclare global {\n  interface Window {\n    __VUE_INJECT_DYNAMIC_BUTTON__?: (button: any) => void\n    __VUE_UNINJECT_DYNAMIC_BUTTON__?: () => void\n  }\n}\n\ntype MaybeRefValue<T> = T | Ref<T> | ComputedRef<T>\n\nexport interface DynamicButtonMenuItem {\n  title?: string\n  titleKey?: string\n  titleParams?: Record<string, unknown>\n  icon?: string\n  color?: string\n  action: () => void\n}\n\nfunction resolveMaybeRef<T>(value: MaybeRefValue<T> | undefined): T | undefined\nfunction resolveMaybeRef<T>(value: MaybeRefValue<T> | undefined, fallback: T): T\nfunction resolveMaybeRef<T>(value: MaybeRefValue<T> | undefined, fallback?: T) {\n  return value !== undefined ? unref(value) : fallback\n}\n\n/**\n * 动态按钮钩子函数\n *\n * @param options 配置选项\n * @returns 控制函数和状态\n *\n * @example\n * // 在页面中使用\n * const { openDialog } = useDynamicButton({\n *   icon: 'mdi-cog',\n *   onClick: () => {\n *     dialog.value = true\n *   }\n * })\n */\nexport function useDynamicButton(options: {\n  icon: MaybeRefValue<string>\n  onClick?: () => void\n  menuItems?: MaybeRefValue<DynamicButtonMenuItem[] | undefined>\n  show?: MaybeRefValue<boolean>\n  autoRegister?: boolean // 是否自动注册，默认为true\n}) {\n  // 提取配置\n  const { icon, onClick, menuItems, show, autoRegister = true } = options\n\n  // 动态按钮相关\n  const registerDynamicButton = inject<((button: any) => void) | null>('registerDynamicButton', null)\n  const unregisterDynamicButton = inject<(() => void) | null>('unregisterDynamicButton', null)\n\n  // 按钮注册状态\n  const dynamicButtonRegistered = ref(false)\n  const componentActive = ref(false)\n\n  const resolvedIcon = computed(() => resolveMaybeRef(icon, 'mdi-plus'))\n  const resolvedShow = computed(() => resolveMaybeRef(show, true))\n  const resolvedMenuItems = computed(() => resolveMaybeRef(menuItems))\n\n  function buildDynamicButton() {\n    const buttonMenuItems = resolvedMenuItems.value\n\n    return {\n      icon: resolvedIcon.value,\n      action: onClick || (() => {}),\n      show: resolvedShow.value,\n      menuItems: buttonMenuItems && buttonMenuItems.length > 0 ? buttonMenuItems : undefined,\n    }\n  }\n\n  // 注册动态按钮\n  function setupDynamicButton() {\n    if (!componentActive.value) return\n\n    const button = buildDynamicButton()\n\n    if (!button.show) {\n      cleanupDynamicButton()\n      return\n    }\n\n    // 确保注册方法存在\n    if (!registerDynamicButton) {\n      // 尝试获取全局注册方法\n      const tryUseGlobalMethod = () => {\n        if (!componentActive.value) return false\n\n        if (typeof window !== 'undefined' && window.__VUE_INJECT_DYNAMIC_BUTTON__) {\n          window.__VUE_INJECT_DYNAMIC_BUTTON__(button)\n          dynamicButtonRegistered.value = true\n          return true\n        }\n        return false\n      }\n\n      // 立即尝试一次\n      if (!tryUseGlobalMethod()) {\n        // 如果失败，延迟再试一次\n        setTimeout(tryUseGlobalMethod, 1000)\n      }\n      return\n    }\n\n    // 如果注册方法存在，直接注册\n    nextTick(() => {\n      if (!componentActive.value) return\n\n      registerDynamicButton(button)\n      dynamicButtonRegistered.value = true\n    })\n  }\n\n  // 取消注册动态按钮\n  function cleanupDynamicButton() {\n    if (unregisterDynamicButton && dynamicButtonRegistered.value) {\n      unregisterDynamicButton()\n      dynamicButtonRegistered.value = false\n      return\n    }\n\n    if (typeof window !== 'undefined' && window.__VUE_UNINJECT_DYNAMIC_BUTTON__) {\n      window.__VUE_UNINJECT_DYNAMIC_BUTTON__()\n      dynamicButtonRegistered.value = false\n    }\n  }\n\n  // 暴露方法：手动打开对话框\n  function openDialog() {\n    onClick?.()\n  }\n\n  // 生命周期钩子\n  if (autoRegister) {\n    onMounted(() => {\n      componentActive.value = true\n      // 延迟执行，确保Footer组件已加载\n      setTimeout(() => {\n        setupDynamicButton()\n      }, 500)\n    })\n\n    onActivated(() => {\n      componentActive.value = true\n      // 重置注册状态，确保每次激活时都重新注册\n      dynamicButtonRegistered.value = false\n      setupDynamicButton()\n    })\n\n    onDeactivated(() => {\n      componentActive.value = false\n      cleanupDynamicButton()\n    })\n\n    onUnmounted(() => {\n      componentActive.value = false\n      cleanupDynamicButton()\n    })\n\n    watch([resolvedIcon, resolvedShow, resolvedMenuItems], () => {\n      if (!componentActive.value) return\n\n      setupDynamicButton()\n    }, { deep: true })\n  }\n\n  // 返回控制函数和状态\n  return {\n    setupDynamicButton, // 手动注册按钮\n    cleanupDynamicButton, // 手动取消注册\n    openDialog, // 手动触发点击事件\n    isRegistered: dynamicButtonRegistered, // 注册状态\n  }\n}\n"
  },
  {
    "path": "src/composables/useDynamicHeaderTab.ts",
    "content": "import type { ComputedRef, Ref } from 'vue'\nimport { useTabStateRestore } from '@/composables/useStateRestore'\n\n// 动态标签页相关类型\ninterface DynamicHeaderTabButton {\n  icon: string\n  color?: string | ComputedRef<string>\n  variant?: 'flat' | 'text' | 'elevated' | 'tonal' | 'outlined' | 'plain'\n  size?: string\n  class?: string\n  action?: () => void\n  show?: boolean | ComputedRef<boolean>\n  dataAttr?: string // 用于VMenu定位的data属性\n}\n\ninterface DynamicHeaderTabItem {\n  title: string\n  icon?: string\n  tab: string\n}\n\ninterface DynamicHeaderTabConfig {\n  items: DynamicHeaderTabItem[]\n  modelValue: string\n  appendButtons?: DynamicHeaderTabButton[]\n  routePath?: string\n  onUpdateModelValue?: (value: string) => void\n}\n\nexport function useDynamicHeaderTab() {\n  const route = useRoute()\n\n  // 尝试从inject获取\n  const registerDynamicHeaderTab = inject<(tab: DynamicHeaderTabConfig) => void>('registerDynamicHeaderTab')\n  const unregisterDynamicHeaderTab = inject<() => void>('unregisterDynamicHeaderTab')\n\n  // 注册动态标签页\n  const registerHeaderTab = (config: {\n    items: DynamicHeaderTabItem[] | ComputedRef<DynamicHeaderTabItem[]> | Ref<DynamicHeaderTabItem[]>\n    modelValue: Ref<string>\n    appendButtons?: DynamicHeaderTabButton[]\n    enableStateRestore?: boolean\n  }) => {\n    // 集成PWA状态恢复功能\n    const enablePWARestore = config.enableStateRestore !== false // 默认启用\n    const pwaTabState = enablePWARestore ? useTabStateRestore(config.modelValue.value) : null\n\n    // 标记是否已经初始化过，避免重复恢复状态\n    let isInitialized = false\n\n    // 如果启用了PWA状态恢复，先尝试恢复状态（仅在首次初始化时）\n    if (pwaTabState && pwaTabState.activeTab.value && !isInitialized) {\n      config.modelValue.value = pwaTabState.activeTab.value\n      isInitialized = true\n    }\n\n    const tabConfig: DynamicHeaderTabConfig = {\n      items: Array.isArray(config.items) ? config.items : config.items.value,\n      modelValue: config.modelValue.value,\n      appendButtons: config.appendButtons,\n      routePath: route.path,\n      onUpdateModelValue: (value: string) => {\n        config.modelValue.value = value\n        // 同步到PWA状态\n        if (pwaTabState && value) {\n          pwaTabState.activeTab.value = value\n        }\n      },\n    }\n\n    // 如果启用了PWA状态恢复，监听PWA状态变化并同步到modelValue\n    // 但只在非激活状态下响应，避免干扰页面激活时的状态\n    if (pwaTabState) {\n      watch(pwaTabState.activeTab, newTab => {\n        if (newTab && newTab !== config.modelValue.value) {\n          config.modelValue.value = newTab\n          // 更新tabConfig并重新注册\n          tabConfig.modelValue = newTab\n          if (registerDynamicHeaderTab) {\n            registerDynamicHeaderTab(tabConfig)\n          }\n        }\n      })\n    }\n\n    // 监听modelValue变化并更新配置\n    watch(config.modelValue, newValue => {\n      tabConfig.modelValue = newValue\n      // 同步到PWA状态\n      if (pwaTabState && newValue) {\n        pwaTabState.activeTab.value = newValue\n      }\n      // 重新注册以更新值\n      if (registerDynamicHeaderTab) {\n        registerDynamicHeaderTab(tabConfig)\n      } else if (typeof window !== 'undefined') {\n        // 使用全局方法作为备用\n        const globalRegister = (window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__\n        if (globalRegister) {\n          globalRegister(tabConfig)\n        }\n      }\n    })\n\n    // 如果items是computed或ref，也需要监听其变化\n    if (!Array.isArray(config.items)) {\n      watch(\n        config.items,\n        newItems => {\n          tabConfig.items = newItems\n          // 重新注册以更新items\n          if (registerDynamicHeaderTab) {\n            registerDynamicHeaderTab(tabConfig)\n          } else if (typeof window !== 'undefined') {\n            // 使用全局方法作为备用\n            const globalRegister = (window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__\n            if (globalRegister) {\n              globalRegister(tabConfig)\n            }\n          }\n        },\n        { deep: true },\n      )\n    }\n\n    // 注册函数\n    const doRegister = () => {\n      // 确保路由路径是最新的\n      tabConfig.routePath = route.path\n      // 确保items是最新的\n      tabConfig.items = Array.isArray(config.items) ? config.items : config.items.value\n      // 确保modelValue是最新的\n      tabConfig.modelValue = config.modelValue.value\n\n      if (registerDynamicHeaderTab) {\n        registerDynamicHeaderTab(tabConfig)\n      } else if (typeof window !== 'undefined') {\n        // 使用全局方法作为备用\n        const globalRegister = (window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__\n        if (globalRegister) {\n          globalRegister(tabConfig)\n        }\n      }\n    }\n\n    // 取消注册函数\n    const doUnregister = () => {\n      if (unregisterDynamicHeaderTab) {\n        unregisterDynamicHeaderTab()\n      }\n    }\n\n    // 初始注册（延迟到下个tick，确保路由已经完全切换）\n    nextTick(() => {\n      doRegister()\n    })\n\n    // 处理页面激活时重新注册（支持keep-alive缓存的页面）\n    onActivated(() => {\n      // 页面激活时，优先使用当前页面的实际状态，而不是恢复的PWA状态\n      // 这样可以避免从后台切换回来时显示错误的标签页\n      nextTick(() => {\n        // 确保使用当前页面的实际modelValue，不受PWA状态恢复影响\n        tabConfig.modelValue = config.modelValue.value\n        // 同步当前状态到PWA存储，确保状态一致性\n        if (pwaTabState && config.modelValue.value) {\n          pwaTabState.activeTab.value = config.modelValue.value\n        }\n        doRegister()\n      })\n    })\n\n    // 处理页面失活时取消注册（支持keep-alive缓存的页面）\n    onDeactivated(() => {\n      doUnregister()\n    })\n\n    // 在组件卸载时取消注册\n    onUnmounted(() => {\n      doUnregister()\n    })\n  }\n\n  // 取消注册\n  const unregisterHeaderTab = () => {\n    if (unregisterDynamicHeaderTab) {\n      unregisterDynamicHeaderTab()\n    }\n  }\n\n  return {\n    registerHeaderTab,\n    unregisterHeaderTab,\n  }\n}\n\n// 导出类型以供其他地方使用\nexport type { DynamicHeaderTabButton, DynamicHeaderTabItem, DynamicHeaderTabConfig }\n"
  },
  {
    "path": "src/composables/useInfiniteScroll.ts",
    "content": "import type { Ref } from 'vue'\n\ntype InfiniteScrollStatus = 'ok' | 'empty' | 'loading' | 'error'\n\n/**\n * 无限滚动 composable\n * 用于管理分页显示和无限滚动加载\n * @param sourceData - 源数据（响应式引用）\n * @param pageSize - 每页显示数量，默认20\n */\nexport function useInfiniteScroll<T>(\n  sourceData: Ref<T[]>,\n  pageSize: number = 20\n) {\n  // 显示用的数据列表\n  const displayDataList = ref<T[]>([])\n  \n  // 剩余数据列表（用于无限滚动）\n  const remainingDataList = ref<T[]>([]) as Ref<T[]>\n\n  // 初始化数据\n  function initData() {\n    if (sourceData.value?.length) {\n      // 显示前 pageSize 个\n      displayDataList.value = sourceData.value.slice(0, pageSize) as T[]\n      // 保存剩余数据\n      remainingDataList.value = sourceData.value.slice(pageSize) as T[]\n    } else {\n      displayDataList.value = []\n      remainingDataList.value = []\n    }\n  }\n\n  // 加载更多\n  function loadMore({ done }: { done: (status: InfiniteScrollStatus) => void }) {\n    // 从 remainingDataList 中获取最前面的 pageSize 个元素\n    const itemsToMove = remainingDataList.value.splice(0, pageSize) as T[]\n    ;(displayDataList.value as T[]).push(...itemsToMove)\n    done('ok')\n  }\n\n  // 重置数据\n  function reset() {\n    displayDataList.value = []\n    remainingDataList.value = []\n  }\n\n  // 监听源数据变化，重新初始化\n  watch(sourceData, () => {\n    initData()\n  }, { deep: true, immediate: true })\n\n  return {\n    displayDataList,\n    remainingDataList,\n    initData,\n    loadMore,\n    reset,\n  }\n}\n"
  },
  {
    "path": "src/composables/useOfflineStatus.ts",
    "content": "import { ref, computed } from 'vue'\nimport { useOnline } from '@vueuse/core'\n\n// 全局状态\nconst isAppOffline = ref(false)\nconst appOfflineReason = ref('')\nconst consecutiveNetworkErrors = ref(0)\nconst MAX_CONSECUTIVE_ERRORS = 3\n\n// 全局离线状态管理\nexport function useGlobalOfflineStatus() {\n  const isOnline = useOnline()\n\n  // 综合离线状态（网络离线 或 应用离线）\n  const isOffline = computed(() => !isOnline.value || isAppOffline.value)\n\n  // 是否可以执行网络操作\n  const canPerformNetworkAction = computed(() => isOnline.value && !isAppOffline.value)\n\n  // 设置应用离线状态\n  const setAppOffline = (offline: boolean, reason?: string) => {\n    isAppOffline.value = offline\n    appOfflineReason.value = reason || ''\n\n    // 如果设置为在线状态，重置连续错误计数\n    if (!offline) {\n      consecutiveNetworkErrors.value = 0\n    }\n  }\n\n  // 记录网络错误\n  const recordNetworkError = (reason?: string) => {\n    consecutiveNetworkErrors.value++\n\n    // 只有连续出现三次网络错误时才设置为离线模式\n    if (consecutiveNetworkErrors.value >= MAX_CONSECUTIVE_ERRORS) {\n      setAppOffline(true, reason || `连续${MAX_CONSECUTIVE_ERRORS}次网络错误`)\n    }\n  }\n\n  // 重置连续错误计数\n  const resetConsecutiveErrors = () => {\n    consecutiveNetworkErrors.value = 0\n  }\n\n  // 获取离线消息\n  const getOfflineMessage = () => {\n    if (!isOnline.value) {\n      return appOfflineReason.value\n    }\n    if (isAppOffline.value) {\n      return appOfflineReason.value\n    }\n    return ''\n  }\n\n  return {\n    isOnline,\n    isOffline,\n    canPerformNetworkAction,\n    setAppOffline,\n    recordNetworkError,\n    resetConsecutiveErrors,\n    getOfflineMessage,\n    consecutiveNetworkErrors: computed(() => consecutiveNetworkErrors.value),\n  }\n}\n\n// 单个组件的离线状态\nexport function useOfflineStatus(initialMessage?: string) {\n  const { isOnline, isOffline, canPerformNetworkAction, getOfflineMessage } = useGlobalOfflineStatus()\n\n  const message = computed(() => {\n    if (initialMessage) {\n      return initialMessage\n    }\n    return getOfflineMessage()\n  })\n\n  return {\n    isOnline,\n    isOffline,\n    canPerformNetworkAction,\n    message,\n  }\n}\n"
  },
  {
    "path": "src/composables/usePWA.ts",
    "content": "import { ref, computed, onMounted } from 'vue'\nimport { useDisplay } from 'vuetify'\nimport { checkPWAStatus, isPWADisplayMode } from '@/@core/utils/navigator'\n\n// 全局PWA状态，确保只初始化一次\nconst globalPwaStatus = ref<{\n  hasPWAFeatures: boolean\n  isStandaloneMode: boolean\n  isPWAEnvironment: boolean\n  isFullPWA: boolean\n} | null>(null)\nconst globalLoading = ref(false)\nlet initPromise: Promise<void> | null = null\n\n// UI模式设置\nexport type UIMode = 'auto' | 'desktop' | 'app'\nconst uiMode = ref<UIMode>((localStorage.getItem('ui-mode') as UIMode) || 'auto')\n\n// 设置UI模式\nfunction setUIMode(mode: UIMode) {\n  uiMode.value = mode\n  localStorage.setItem('ui-mode', mode)\n}\n\n// 全局初始化函数\nasync function initializePWAGlobally() {\n  if (initPromise) return initPromise\n\n  if (globalPwaStatus.value !== null || globalLoading.value) return Promise.resolve()\n\n  initPromise = new Promise(async resolve => {\n    globalLoading.value = true\n    try {\n      globalPwaStatus.value = await checkPWAStatus()\n    } catch (error) {\n      console.error('Failed to detect PWA status', error)\n      // 即使检测失败，也设置一个合理的默认值\n      globalPwaStatus.value = {\n        hasPWAFeatures: false,\n        isStandaloneMode: isPWADisplayMode(),\n        isPWAEnvironment: isPWADisplayMode(),\n        isFullPWA: false,\n      }\n    } finally {\n      globalLoading.value = false\n      // 无论成功还是失败，都解决Promise\n      resolve()\n    }\n  })\n\n  return initPromise\n}\n\nexport function usePWA() {\n  const display = useDisplay()\n\n  // 基于新的PWA状态结构\n  const pwaMode = computed(() => {\n    return globalPwaStatus.value?.isPWAEnvironment ?? false\n  })\n\n  const appMode = computed(() => {\n    if (uiMode.value === 'app') return true\n    if (uiMode.value === 'desktop') return false\n    return pwaMode.value && display.mdAndDown.value\n  })\n\n  // 详细的PWA状态信息\n  const pwaStatus = computed(() => globalPwaStatus.value)\n\n  // 自动初始化PWA检测\n  onMounted(() => {\n    initializePWAGlobally().catch(console.error)\n  })\n\n  // 如果是在服务端或首次调用，立即开始初始化\n  if (typeof window !== 'undefined' && globalPwaStatus.value === null && !globalLoading.value) {\n    initializePWAGlobally().catch(console.error)\n  }\n\n  return {\n    pwaMode,\n    appMode,\n    pwaStatus,\n    uiMode,\n    setUIMode,\n    loading: globalLoading,\n    initializePWA: initializePWAGlobally,\n  }\n}\n"
  },
  {
    "path": "src/composables/usePWAInstall.ts",
    "content": "interface BeforeInstallPromptEvent extends Event {\n  readonly platforms: string[]\n  readonly userChoice: Promise<{\n    outcome: 'accepted' | 'dismissed'\n    platform: string\n  }>\n  prompt(): Promise<void>\n}\n\ndeclare global {\n  interface WindowEventMap {\n    beforeinstallprompt: BeforeInstallPromptEvent\n  }\n}\n\nexport function usePWAInstall() {\n  const isInstallable = ref(false)\n  const isInstalled = ref(false)\n  const installPrompt = ref<BeforeInstallPromptEvent | null>(null)\n  const installOutcome = ref<'accepted' | 'dismissed' | null>(null)\n\n  // 检查是否已安装（通过检查display-mode）\n  const checkIfInstalled = () => {\n    const isStandalone = window.matchMedia('(display-mode: standalone)').matches\n    const isFullscreen = window.matchMedia('(display-mode: fullscreen)').matches\n    const isMinimalUI = window.matchMedia('(display-mode: minimal-ui)').matches\n    const isWindowControlsOverlay = window.matchMedia('(display-mode: window-controls-overlay)').matches\n\n    // iOS Safari特殊检查\n    const isIOSStandalone = (window.navigator as any).standalone === true\n\n    return isStandalone || isFullscreen || isMinimalUI || isWindowControlsOverlay || isIOSStandalone\n  }\n\n  // 显示安装提示\n  const showInstallPrompt = async () => {\n    if (!installPrompt.value) {\n      console.warn('No install prompt available')\n      return false\n    }\n\n    try {\n      // 显示浏览器的安装提示\n      await installPrompt.value.prompt()\n\n      // 等待用户响应\n      const { outcome } = await installPrompt.value.userChoice\n      installOutcome.value = outcome\n\n      // 如果用户接受安装，清除安装提示\n      if (outcome === 'accepted') {\n        isInstallable.value = false\n        installPrompt.value = null\n        isInstalled.value = true\n      }\n\n      return outcome === 'accepted'\n    } catch (error) {\n      console.error('Failed to show install prompt:', error)\n      return false\n    }\n  }\n\n  // 处理安装事件\n  const handleBeforeInstallPrompt = (e: BeforeInstallPromptEvent) => {\n    // 阻止默认行为\n    e.preventDefault()\n\n    // 保存安装提示\n    installPrompt.value = e\n    isInstallable.value = true\n  }\n\n  // 处理应用安装成功事件\n  const handleAppInstalled = () => {\n    isInstalled.value = true\n    isInstallable.value = false\n    installPrompt.value = null\n  }\n\n  // 检查是否支持 PWA 安装\n  // 使用 \"onbeforeinstallprompt\" 事件的存在性来判断，而不是检查\n  // BeforeInstallPromptEvent 构造函数（在运行时并不存在）。\n  // 对于不触发 beforeinstallprompt 的 iOS Safari，同样允许通过\n  // \"添加到主屏幕\" 的方式安装，因此这里也认为是支持的。\n  const isPWASupported = computed(() => {\n    const hasServiceWorker = 'serviceWorker' in navigator\n    const supportsInstallPromptEvent = 'onbeforeinstallprompt' in window\n    const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream\n\n    return hasServiceWorker && (supportsInstallPromptEvent || isIOS)\n  })\n\n  // 获取安装指南（针对不同平台）\n  const getInstallInstructions = () => {\n    const ua = navigator.userAgent\n    const isIOS = /iPad|iPhone|iPod/.test(ua) && !(window as any).MSStream\n    const isAndroid = /Android/.test(ua)\n    const isSafari = /Safari/.test(ua) && !/Chrome/.test(ua) && !/Edg/.test(ua)\n    const isChrome = /Chrome/.test(ua) && !/Edg/.test(ua)\n    const isEdge = /Edg/.test(ua)\n    const isFirefox = /Firefox/.test(ua)\n\n    if (isEdge) {\n      return {\n        platform: 'Microsoft Edge',\n        platformKey: 'edge',\n      }\n    } else if (isIOS && isSafari) {\n      return {\n        platform: 'iOS Safari',\n        platformKey: 'ios',\n      }\n    } else if (isAndroid && isChrome) {\n      return {\n        platform: 'Android Chrome',\n        platformKey: 'android',\n      }\n    } else if (isFirefox && isAndroid) {\n      return {\n        platform: 'Android Firefox',\n        platformKey: 'android',\n      }\n    } else if (isFirefox) {\n      return {\n        platform: 'Firefox',\n        platformKey: 'firefox',\n      }\n    } else if (isChrome) {\n      return {\n        platform: 'Chrome',\n        platformKey: 'chrome',\n      }\n    } else if (isSafari) {\n      return {\n        platform: 'Safari',\n        platformKey: 'safari',\n      }\n    } else if (isAndroid) {\n      return {\n        platform: 'Mobile Browser',\n        platformKey: 'mobile',\n      }\n    } else {\n      return {\n        platform: 'Desktop Browser',\n        platformKey: 'desktop',\n      }\n    }\n  }\n\n  onMounted(() => {\n    // 检查是否已安装\n    isInstalled.value = checkIfInstalled()\n\n    // 监听安装提示事件\n    window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)\n\n    // 监听安装成功事件\n    window.addEventListener('appinstalled', handleAppInstalled)\n\n    // 监听display-mode变化\n    const mediaQuery = window.matchMedia('(display-mode: standalone)')\n    mediaQuery.addEventListener('change', e => {\n      isInstalled.value = e.matches\n    })\n  })\n\n  onUnmounted(() => {\n    window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)\n    window.removeEventListener('appinstalled', handleAppInstalled)\n  })\n\n  return {\n    isInstallable,\n    isInstalled,\n    isPWASupported,\n    installOutcome,\n    showInstallPrompt,\n    getInstallInstructions,\n  }\n}\n"
  },
  {
    "path": "src/composables/usePullDownGesture.ts",
    "content": "import { ref, computed, onMounted, onBeforeUnmount, readonly, watch } from 'vue'\nimport { useDisplay } from 'vuetify'\nimport { usePWA } from './usePWA'\n\n// 下拉手势配置类型\nexport interface PullDownConfig {\n  START_THRESHOLD: number // 开始下拉的最小距离\n  SHOW_INDICATOR: number // 显示指示器的距离\n  TRIGGER_THRESHOLD: number // 触发回调的距离\n  MAX_PULL_DISTANCE: number // 最大下拉距离\n  PULL_RESISTANCE: number // 下拉阻力系数\n  CONTENT_FOLLOW_RATIO: number // 页面内容跟随比例\n  TOLERANCE: number // 手指抖动容忍度\n}\n\n// 下拉手势选项\nexport interface PullDownOptions {\n  config?: Partial<PullDownConfig>\n  // 检查是否可以使用下拉手势的函数\n  canUsePullGesture?: () => boolean\n  // 触发回调\n  onTrigger?: () => void\n  // 是否启用（默认true）\n  enabled?: boolean\n}\n\n// 默认配置\nconst DEFAULT_CONFIG: PullDownConfig = {\n  START_THRESHOLD: 20,\n  SHOW_INDICATOR: 60,\n  TRIGGER_THRESHOLD: 100,\n  MAX_PULL_DISTANCE: 200,\n  PULL_RESISTANCE: 0.75,\n  CONTENT_FOLLOW_RATIO: 0.4,\n  TOLERANCE: 80,\n}\n\nexport function usePullDownGesture(options: PullDownOptions = {}) {\n  const display = useDisplay()\n  const { appMode } = usePWA()\n\n  // 合并配置\n  const config = { ...DEFAULT_CONFIG, ...options.config }\n\n  // 状态管理\n  const isPulling = ref(false)\n  const startY = ref(0)\n  const pullDistance = ref(0)\n  const initialScrollTop = ref(0)\n  const hasDialogOpen = ref(false)\n  const lastDialogCheckTime = ref(0)\n  const DIALOG_CHECK_INTERVAL = 500\n\n  // 计算属性\n  const contentTransform = computed(() => {\n    if (!isPulling.value || pullDistance.value <= 0) return 'translateY(0)'\n    const moveDistance = pullDistance.value * config.CONTENT_FOLLOW_RATIO\n    return `translateY(${moveDistance}px)`\n  })\n\n  const contentTransition = computed(() => {\n    return isPulling.value ? 'none' : 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)'\n  })\n\n  const showPullIndicator = computed(() => {\n    return isPulling.value && pullDistance.value >= config.SHOW_INDICATOR\n  })\n\n  const indicatorRotation = computed(() => {\n    if (!isPulling.value) return 0\n    const progress = Math.min(\n      (pullDistance.value - config.SHOW_INDICATOR) / (config.TRIGGER_THRESHOLD - config.SHOW_INDICATOR),\n      1,\n    )\n    return progress * 180\n  })\n\n  const indicatorOpacity = computed(() => {\n    if (!isPulling.value) return 0\n    const progress = Math.min(\n      (pullDistance.value - config.SHOW_INDICATOR) / (config.TRIGGER_THRESHOLD - config.SHOW_INDICATOR),\n      1,\n    )\n    return 0.7 + progress * 0.3\n  })\n\n  const indicatorTransform = computed(() => {\n    return `translate(-50%, ${Math.min(60 + pullDistance.value - config.SHOW_INDICATOR, 70)}px)`\n  })\n\n  // 弹窗检测函数\n  const hasOpenDialog = (excludeSelector?: string) => {\n    try {\n      const dialogSelectors = [\n        '.v-overlay--active:not(.v-overlay--scroll-blocked)',\n        '.v-dialog--active',\n        '.v-menu--active',\n        '.v-bottom-sheet--active',\n        '.v-snackbar--active',\n        '[role=\"dialog\"]:not([style*=\"display: none\"])',\n        '.modal:not(.d-none):not([style*=\"display: none\"])',\n        '[aria-modal=\"true\"]:not([style*=\"display: none\"])',\n      ]\n\n      for (const selector of dialogSelectors) {\n        const elements = document.querySelectorAll(selector)\n        if (elements.length > 0) {\n          // 如果需要排除特定元素（如QuickAccess面板）\n          if (excludeSelector && elements.length === 1) {\n            const element = elements[0]\n            if (element.closest(excludeSelector)) {\n              continue\n            }\n          }\n          return true\n        }\n      }\n\n      return false\n    } catch (error) {\n      console.warn('检测弹窗状态时出错:', error)\n      return true\n    }\n  }\n\n  // 事件处理函数\n  const handleTouchStart = (event: TouchEvent) => {\n    if (!appMode.value || !display.mdAndDown.value || !options.enabled) return\n\n    // 检查是否可以使用下拉手势\n    if (options.canUsePullGesture && !options.canUsePullGesture()) return\n\n    // 检查是否有弹窗打开\n    hasDialogOpen.value = hasOpenDialog('.quick-access-panel')\n    lastDialogCheckTime.value = Date.now()\n\n    if (hasDialogOpen.value) return\n\n    const touch = event.touches[0]\n    startY.value = touch.clientY\n\n    // 重置下拉状态\n    isPulling.value = false\n    pullDistance.value = 0\n\n    // 记录开始时的滚动位置\n    initialScrollTop.value = window.scrollY || document.documentElement.scrollTop || 0\n  }\n\n  const handleTouchMove = (event: TouchEvent) => {\n    if (!appMode.value || !display.mdAndDown.value || !options.enabled) return\n\n    // 检查是否可以使用下拉手势\n    if (options.canUsePullGesture && !options.canUsePullGesture()) return\n\n    // 只在必要时重新检测弹窗\n    const currentTime = Date.now()\n    if (currentTime - lastDialogCheckTime.value > DIALOG_CHECK_INTERVAL) {\n      hasDialogOpen.value = hasOpenDialog('.quick-access-panel')\n      lastDialogCheckTime.value = currentTime\n    }\n\n    if (hasDialogOpen.value) {\n      isPulling.value = false\n      pullDistance.value = 0\n      return\n    }\n\n    const touch = event.touches[0]\n    const deltaY = touch.clientY - startY.value\n\n    if (isPulling.value) {\n      if (deltaY > -config.TOLERANCE) {\n        pullDistance.value = Math.max(0, Math.min(deltaY * config.PULL_RESISTANCE, config.MAX_PULL_DISTANCE))\n        event.preventDefault()\n      } else {\n        isPulling.value = false\n        pullDistance.value = 0\n      }\n    } else {\n      if (deltaY > config.START_THRESHOLD) {\n        const currentScrollTop = window.scrollY || document.documentElement.scrollTop || 0\n\n        if (currentScrollTop <= 100 && initialScrollTop.value <= 100) {\n          isPulling.value = true\n          pullDistance.value = Math.min(deltaY * config.PULL_RESISTANCE, config.MAX_PULL_DISTANCE)\n          event.preventDefault()\n        }\n      }\n    }\n  }\n\n  const handleTouchEnd = () => {\n    if (!appMode.value || !display.mdAndDown.value || !options.enabled) return\n\n    // 检查是否可以使用下拉手势\n    if (options.canUsePullGesture && !options.canUsePullGesture()) return\n\n    // 重置弹窗检测标志\n    hasDialogOpen.value = false\n    lastDialogCheckTime.value = 0\n\n    if (isPulling.value && pullDistance.value >= config.TRIGGER_THRESHOLD) {\n      // 达到触发阈值，执行回调\n      options.onTrigger?.()\n    }\n\n    // 停止拖拽状态\n    isPulling.value = false\n\n    // 延迟重置其他状态\n    setTimeout(() => {\n      pullDistance.value = 0\n      startY.value = 0\n    }, 300)\n  }\n\n  // 生命周期管理\n  let eventsAdded = false\n\n  const addEventListeners = () => {\n    if (!eventsAdded && appMode.value) {\n      document.addEventListener('touchstart', handleTouchStart, { passive: false })\n      document.addEventListener('touchmove', handleTouchMove, { passive: false })\n      document.addEventListener('touchend', handleTouchEnd, { passive: true })\n      eventsAdded = true\n    }\n  }\n\n  const removeEventListeners = () => {\n    if (eventsAdded) {\n      document.removeEventListener('touchstart', handleTouchStart)\n      document.removeEventListener('touchmove', handleTouchMove)\n      document.removeEventListener('touchend', handleTouchEnd)\n      eventsAdded = false\n    }\n  }\n\n  // 监听 appMode 变化动态添加/移除事件监听器\n  onMounted(() => {\n    watch(\n      appMode,\n      newValue => {\n        if (newValue) {\n          addEventListeners()\n        } else {\n          removeEventListeners()\n        }\n      },\n      { immediate: true },\n    )\n  })\n\n  onBeforeUnmount(() => {\n    removeEventListeners()\n  })\n\n  return {\n    // 状态\n    isPulling: readonly(isPulling),\n    pullDistance: readonly(pullDistance),\n\n    // 计算属性\n    contentTransform,\n    contentTransition,\n    showPullIndicator,\n    indicatorRotation,\n    indicatorOpacity,\n    indicatorTransform,\n\n    // 配置\n    config,\n\n    // 工具函数\n    hasOpenDialog,\n  }\n}\n"
  },
  {
    "path": "src/composables/useRecentPlugins.ts",
    "content": "import type { Plugin } from '@/api/types'\n\nconst RECENT_PLUGINS_KEY = 'moviepilot_recent_plugins'\nconst MAX_RECENT_PLUGINS = 3\n\ninterface RecentPlugin {\n  id: string\n  plugin_name: string\n  plugin_icon?: string\n  has_page: boolean\n  state: boolean\n  plugin_id: string\n  access_time: number\n}\n\n// 将Plugin转换为RecentPlugin\nfunction pluginToRecentPlugin(plugin: Plugin): RecentPlugin {\n  return {\n    id: plugin.id || '',\n    plugin_name: plugin.plugin_name || '',\n    plugin_icon: plugin.plugin_icon,\n    has_page: plugin.has_page || false,\n    state: plugin.state || false,\n    plugin_id: plugin.id || '',\n    access_time: Date.now(),\n  }\n}\n\n// 将RecentPlugin转换为Plugin\nfunction recentPluginToPlugin(recentPlugin: RecentPlugin): Plugin {\n  return {\n    id: recentPlugin.id,\n    plugin_name: recentPlugin.plugin_name,\n    plugin_icon: recentPlugin.plugin_icon,\n    has_page: recentPlugin.has_page,\n    state: recentPlugin.state,\n    plugin_id: recentPlugin.plugin_id,\n  } as Plugin\n}\n\nexport function useRecentPlugins() {\n  // 获取最近访问的插件\n  function getRecentPlugins(): Plugin[] {\n    try {\n      const stored = localStorage.getItem(RECENT_PLUGINS_KEY)\n      if (!stored) return []\n\n      const recentPlugins: RecentPlugin[] = JSON.parse(stored)\n\n      // 按访问时间倒序排列\n      return recentPlugins.sort((a, b) => b.access_time - a.access_time).map(recentPluginToPlugin)\n    } catch (error) {\n      console.error(error)\n      return []\n    }\n  }\n\n  // 添加插件到最近访问\n  function addRecentPlugin(plugin: Plugin) {\n    try {\n      if (!plugin.id || !plugin.has_page) return\n\n      const stored = localStorage.getItem(RECENT_PLUGINS_KEY)\n      let recentPlugins: RecentPlugin[] = stored ? JSON.parse(stored) : []\n\n      // 移除已存在的相同插件（如果有的话）\n      recentPlugins = recentPlugins.filter(p => p.id !== plugin.id)\n\n      // 添加新的插件到开头\n      recentPlugins.unshift(pluginToRecentPlugin(plugin))\n\n      // 限制最大数量\n      if (recentPlugins.length > MAX_RECENT_PLUGINS) {\n        recentPlugins = recentPlugins.slice(0, MAX_RECENT_PLUGINS)\n      }\n\n      localStorage.setItem(RECENT_PLUGINS_KEY, JSON.stringify(recentPlugins))\n    } catch (error) {\n      console.error(error)\n    }\n  }\n\n  // 清除所有最近访问记录\n  function clearRecentPlugins() {\n    try {\n      localStorage.removeItem(RECENT_PLUGINS_KEY)\n    } catch (error) {\n      console.error(error)\n    }\n  }\n\n  // 移除特定插件\n  function removeRecentPlugin(pluginId: string) {\n    try {\n      const stored = localStorage.getItem(RECENT_PLUGINS_KEY)\n      if (!stored) return\n\n      let recentPlugins: RecentPlugin[] = JSON.parse(stored)\n      recentPlugins = recentPlugins.filter(p => p.id !== pluginId)\n\n      localStorage.setItem(RECENT_PLUGINS_KEY, JSON.stringify(recentPlugins))\n    } catch (error) {\n      console.error(error)\n    }\n  }\n\n  return {\n    getRecentPlugins,\n    addRecentPlugin,\n    clearRecentPlugins,\n    removeRecentPlugin,\n  }\n}\n"
  },
  {
    "path": "src/composables/useSetupWizard.ts",
    "content": "import { ref, computed } from 'vue'\nimport { useToast } from 'vue-toastification'\nimport { useRouter } from 'vue-router'\nimport { useI18n } from 'vue-i18n'\nimport api from '@/api'\nimport { copyToClipboard } from '@/@core/utils/navigator'\nimport { User } from '@/api/types'\n\nexport interface WizardData {\n  basic: {\n    appDomain: string\n    apiToken: string\n    username: string\n    password: string\n    confirmPassword: string\n    recognizeSource: string\n    ocrHost: string\n    proxyHost: string\n    githubToken: string\n  }\n  siteAuth: {\n    auxiliaryAuthEnable: boolean\n    site: string\n    params: Record<string, string | number>\n  }\n  storage: {\n    downloadPath: string\n    libraryPath: string\n    transferType: string\n    overwriteMode: string\n  }\n  downloader: {\n    type: string\n    name: string\n    config: any\n  }\n  mediaServer: {\n    type: string\n    name: string\n    config: any\n    sync_libraries: any[]\n    switchs: any[]\n  }\n  notification: {\n    type: string\n    name: string\n    enabled: boolean\n    config: any\n    switchs: any[]\n  }\n  agent: {\n    enabled: boolean\n    global: boolean\n    verbose: boolean\n    provider: string\n    model: string\n    thinkingLevel: string\n    supportImageInput: boolean\n    apiKey: string\n    baseUrl: string\n    maxContextTokens: number\n    jobInterval: number\n    retryTransfer: boolean\n    recommendEnabled: boolean\n    recommendUserPreference: string\n    recommendMaxItems: number\n  }\n  preferences: {\n    quality: string\n    subtitle: string\n    resolution: string\n    personalizationOptions?: {\n      excludeDolbyVision: boolean\n      excludeBluray: boolean\n    }\n    ruleSequences?: Array<{\n      name: string\n      rule_string: string\n      media_type: string\n      category: string\n    }>\n  }\n}\n\nexport interface ConnectivityTestState {\n  isTesting: boolean\n  testMessage: string\n  testProgress: number\n  testResult: 'success' | 'error' | null\n  showResult: boolean\n}\n\nexport interface ValidationErrorState {\n  siteAuth: {\n    site: boolean\n    [key: string]: boolean\n  }\n  downloader: {\n    name: boolean\n    host: boolean\n    username: boolean\n    password: boolean\n  }\n  mediaServer: {\n    name: boolean\n    host: boolean\n    apikey: boolean\n    token: boolean\n    username: boolean\n    password: boolean\n  }\n  notification: {\n    name: boolean\n    [key: string]: boolean\n  }\n  agent: {\n    provider: boolean\n    apiKey: boolean\n    model: boolean\n    maxContextTokens: boolean\n    recommendMaxItems: boolean\n  }\n}\n\nfunction normalizeThinkingLevelValue(value?: unknown) {\n  const normalized = String(value ?? '').trim().toLowerCase()\n  if (!normalized) return ''\n\n  const aliasMap: Record<string, string> = {\n    none: 'off',\n    disabled: 'off',\n    disable: 'off',\n    enabled: 'auto',\n    enable: 'auto',\n    default: 'auto',\n    dynamic: 'auto',\n  }\n\n  return aliasMap[normalized] || normalized\n}\n\nfunction resolveThinkingLevelValue(data?: Record<string, any>) {\n  const explicit = normalizeThinkingLevelValue(data?.LLM_THINKING_LEVEL)\n  if (explicit) return explicit\n\n  const legacyEffort = normalizeThinkingLevelValue(data?.LLM_REASONING_EFFORT)\n  if (data?.LLM_DISABLE_THINKING === true) return 'off'\n  if (data?.LLM_DISABLE_THINKING === false) return legacyEffort || 'auto'\n  return legacyEffort || 'off'\n}\n\n// 全局状态，所有组件共享\nconst currentStep = ref(1)\nconst totalSteps = 8\n\n// 加载状态\nconst isLoading = ref(false)\n\n// 选中的预设规则\nconst selectedPreset = ref('')\n\n// 可认证站点列表\nconst authSites = ref<{\n  [key: string]: {\n    name: string\n    icon: string\n    params: {\n      [key: string]: {\n        name: string\n        type: string\n        placeholder?: string\n        tooltip?: string\n      }\n    }\n  }\n}>({})\n\n// 向导数据\nconst wizardData = ref<WizardData>({\n  basic: {\n    appDomain: '',\n    apiToken: '',\n    username: '',\n    password: '',\n    confirmPassword: '',\n    recognizeSource: 'themoviedb',\n    ocrHost: '',\n    proxyHost: '',\n    githubToken: '',\n  },\n  siteAuth: {\n    auxiliaryAuthEnable: false,\n    site: '',\n    params: {},\n  },\n  storage: {\n    downloadPath: '',\n    libraryPath: '',\n    transferType: 'link',\n    overwriteMode: 'never',\n  },\n  downloader: {\n    type: '',\n    name: '',\n    config: {},\n  },\n  mediaServer: {\n    type: '',\n    name: '',\n    config: {},\n    sync_libraries: [],\n    switchs: [],\n  },\n  notification: {\n    type: '',\n    name: '',\n    enabled: false,\n    config: {},\n    switchs: [],\n  },\n  agent: {\n    enabled: false,\n    global: false,\n    verbose: false,\n    provider: 'deepseek',\n    model: 'deepseek-chat',\n    thinkingLevel: 'off',\n    supportImageInput: true,\n    apiKey: '',\n    baseUrl: 'https://api.deepseek.com',\n    maxContextTokens: 64,\n    jobInterval: 0,\n    retryTransfer: false,\n    recommendEnabled: false,\n    recommendUserPreference: '',\n    recommendMaxItems: 50,\n  },\n  preferences: {\n    quality: '4K',\n    subtitle: 'chinese',\n    resolution: '2160p',\n  },\n})\n\n// 连通性测试状态\nconst connectivityTest = ref<ConnectivityTestState>({\n  isTesting: false,\n  testMessage: '',\n  testProgress: 0,\n  testResult: null,\n  showResult: false,\n})\n\n// 验证错误状态\nconst validationErrors = ref<ValidationErrorState>({\n  siteAuth: {\n    site: false,\n  },\n  downloader: {\n    name: false,\n    host: false,\n    username: false,\n    password: false,\n  },\n  mediaServer: {\n    name: false,\n    host: false,\n    apikey: false,\n    token: false,\n    username: false,\n    password: false,\n  },\n  notification: {\n    name: false,\n  },\n  agent: {\n    provider: false,\n    apiKey: false,\n    model: false,\n    maxContextTokens: false,\n    recommendMaxItems: false,\n  },\n})\n\nexport function useSetupWizard() {\n  const { t } = useI18n()\n  const router = useRouter()\n  const $toast = useToast()\n\n  // 类型到模块ID的映射关系\n  const typeToModuleMapping = {\n    // 下载器映射\n    downloader: {\n      'qbittorrent': 'QbittorrentModule',\n      'transmission': 'TransmissionModule',\n      'rtorrent': 'RtorrentModule',\n    },\n    // 媒体服务器映射\n    mediaServer: {\n      'emby': 'EmbyModule',\n      'jellyfin': 'JellyfinModule',\n      'plex': 'PlexModule',\n      'trimemedia': 'TrimeMediaModule',\n      'ugreen': 'UgreenModule',\n    },\n    // 通知映射\n    notification: {\n      'telegram': 'TelegramModule',\n      'wechat': 'WechatModule',\n      'slack': 'SlackModule',\n      'synologychat': 'SynologyChatModule',\n      'qqbot': 'QQBotModule',\n      'vocechat': 'VoceChatModule',\n      'webpush': 'WebPushModule',\n    },\n  }\n\n  // 步骤标题\n  const stepTitles = computed(() => [\n    t('setupWizard.basic.title'),\n    t('setupWizard.siteAuth.title'),\n    t('setupWizard.storage.title'),\n    t('setupWizard.downloader.title'),\n    t('setupWizard.mediaServer.title'),\n    t('setupWizard.notification.title'),\n    t('setupWizard.agent.title'),\n    t('setupWizard.preferences.title'),\n  ])\n\n  // 步骤描述\n  const stepDescriptions = computed(() => [\n    t('setupWizard.basic.description'),\n    t('setupWizard.siteAuth.description'),\n    t('setupWizard.storage.description'),\n    t('setupWizard.downloader.description'),\n    t('setupWizard.mediaServer.description'),\n    t('setupWizard.notification.description'),\n    t('setupWizard.agent.description'),\n    t('setupWizard.preferences.description'),\n  ])\n\n  // 创建随机API Token\n  function createRandomString() {\n    const charset = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'\n    const array = new Uint8Array(32)\n    window.crypto.getRandomValues(array)\n    wizardData.value.basic.apiToken = Array.from(array, byte => charset[byte % charset.length]).join('')\n  }\n\n  // 复制到剪贴板\n  async function copyValue(value: string) {\n    try {\n      const success = copyToClipboard(value)\n      if (await success) {\n        $toast.success(t('setting.system.copySuccess'))\n      } else {\n        $toast.error(t('setting.system.copyFailed'))\n      }\n    } catch (error) {\n      $toast.error(t('setting.system.copyError'))\n      console.error(error)\n    }\n  }\n\n  // 选择下载器\n  function selectDownloader(type: string) {\n    if (wizardData.value.downloader.type === type) {\n      // 重复点击已选中的类型，取消选择\n      wizardData.value.downloader.type = ''\n    } else {\n      wizardData.value.downloader.type = type\n      // 如果名称为空或为默认名称，则设置默认名称\n      if (!wizardData.value.downloader.name || wizardData.value.downloader.name.includes('下载器')) {\n        wizardData.value.downloader.name = `${type} 下载器`\n      }\n      // 不清空config，保留用户已输入的值\n    }\n  }\n\n  // 选择媒体服务器\n  function selectMediaServer(type: string) {\n    if (wizardData.value.mediaServer.type === type) {\n      // 重复点击已选中的类型，取消选择\n      wizardData.value.mediaServer.type = ''\n    } else {\n      wizardData.value.mediaServer.type = type\n      // 如果名称为空或为默认名称，则设置默认名称\n      if (!wizardData.value.mediaServer.name || wizardData.value.mediaServer.name.includes('服务器')) {\n        wizardData.value.mediaServer.name = `${type} 服务器`\n      }\n      // 不清空config和sync_libraries，保留用户已输入的值\n    }\n  }\n\n  // 选择通知\n  function selectNotification(type: string) {\n    if (wizardData.value.notification.type === type) {\n      // 重复点击已选中的类型，取消选择\n      wizardData.value.notification.type = ''\n    } else {\n      wizardData.value.notification.type = type\n      // 如果名称为空或为默认名称，则设置默认名称\n      if (!wizardData.value.notification.name || wizardData.value.notification.name.includes('通知')) {\n        wizardData.value.notification.name = `${type} 通知`\n      }\n      wizardData.value.notification.enabled = true\n      // 不清空config和switchs，保留用户已输入的值\n    }\n  }\n\n  // 选择预设规则\n  function selectPreset(preset: string) {\n    selectedPreset.value = preset\n\n    switch (preset) {\n      case '4k':\n        wizardData.value.preferences.quality = '4K'\n        wizardData.value.preferences.subtitle = 'bilingual'\n        wizardData.value.preferences.resolution = '2160p'\n        break\n      case 'balanced':\n        wizardData.value.preferences.quality = '1080P'\n        wizardData.value.preferences.subtitle = 'chinese'\n        wizardData.value.preferences.resolution = '1080p'\n        break\n      case 'chinese':\n        wizardData.value.preferences.quality = '1080P'\n        wizardData.value.preferences.subtitle = 'chinese'\n        wizardData.value.preferences.resolution = '1080p'\n        break\n    }\n  }\n\n  // 更新偏好设置\n  function updatePreferences(\n    personalizationOptions: { excludeDolbyVision: boolean; excludeBluray: boolean },\n    ruleSequences: Array<{ name: string; rule_string: string; media_type: string; category: string }>,\n  ) {\n    wizardData.value.preferences.personalizationOptions = personalizationOptions\n    wizardData.value.preferences.ruleSequences = ruleSequences\n  }\n\n  // 清除验证错误状态\n  function clearValidationErrors() {\n    validationErrors.value.siteAuth = {\n      site: false,\n    }\n    validationErrors.value.downloader = {\n      name: false,\n      host: false,\n      username: false,\n      password: false,\n    }\n    validationErrors.value.mediaServer = {\n      name: false,\n      host: false,\n      apikey: false,\n      token: false,\n      username: false,\n      password: false,\n    }\n    validationErrors.value.notification = {\n      name: false,\n    }\n    validationErrors.value.agent = {\n      provider: false,\n      apiKey: false,\n      model: false,\n      maxContextTokens: false,\n      recommendMaxItems: false,\n    }\n  }\n\n  // 验证用户站点认证字段\n  function validateSiteAuthFields(): { isValid: boolean; errors: string[] } {\n    const errors: string[] = []\n    clearValidationErrors()\n\n    if (!wizardData.value.siteAuth.site) {\n      return {\n        isValid: true,\n        errors,\n      }\n    }\n\n    const selectedSite = authSites.value[wizardData.value.siteAuth.site]\n    if (!selectedSite) {\n      errors.push(t('setupWizard.siteAuth.siteConfigNotExist'))\n      validationErrors.value.siteAuth.site = true\n      return {\n        isValid: false,\n        errors,\n      }\n    }\n\n    const fields = Object.keys(selectedSite.params || {}).filter(key => {\n      return selectedSite.params[key]?.name && selectedSite.params[key]?.type\n    })\n\n    fields.forEach(key => {\n      const fieldKey = `${wizardData.value.siteAuth.site.toUpperCase()}_${key.toUpperCase()}`\n      const value = wizardData.value.siteAuth.params[fieldKey]\n      if (value === undefined || value === null || value === '') {\n        errors.push(t('setupWizard.siteAuth.fieldRequired', { name: selectedSite.params[key].name }))\n        validationErrors.value.siteAuth[fieldKey] = true\n      }\n    })\n\n    return {\n      isValid: errors.length === 0,\n      errors,\n    }\n  }\n\n  // 验证下载器字段\n  function validateDownloaderFields(): { isValid: boolean; errors: string[] } {\n    const errors: string[] = []\n    clearValidationErrors()\n\n    // 名称必输\n    if (!wizardData.value.downloader.name?.trim()) {\n      errors.push(t('downloader.nameRequired'))\n      validationErrors.value.downloader.name = true\n    }\n\n    // 主机地址必输\n    if (!wizardData.value.downloader.config?.host?.trim()) {\n      errors.push(t('downloader.hostRequired'))\n      validationErrors.value.downloader.host = true\n    }\n\n    // 根据下载器类型验证其他必输项\n    if (\n      wizardData.value.downloader.type === 'qbittorrent'\n      || wizardData.value.downloader.type === 'transmission'\n      || wizardData.value.downloader.type === 'rtorrent'\n    ) {\n      if (!wizardData.value.downloader.config?.username?.trim()) {\n        errors.push(t('downloader.usernameRequired'))\n        validationErrors.value.downloader.username = true\n      }\n      if (!wizardData.value.downloader.config?.password?.trim()) {\n        errors.push(t('downloader.passwordRequired'))\n        validationErrors.value.downloader.password = true\n      }\n    }\n\n    return {\n      isValid: errors.length === 0,\n      errors,\n    }\n  }\n\n  // 验证媒体服务器字段\n  function validateMediaServerFields(): { isValid: boolean; errors: string[] } {\n    const errors: string[] = []\n    clearValidationErrors()\n\n    // 名称必输\n    if (!wizardData.value.mediaServer.name?.trim()) {\n      errors.push(t('mediaserver.nameRequired'))\n      validationErrors.value.mediaServer.name = true\n    }\n\n    // 主机地址必输\n    if (!wizardData.value.mediaServer.config?.host?.trim()) {\n      errors.push(t('mediaserver.hostRequired'))\n      validationErrors.value.mediaServer.host = true\n    }\n\n    // 根据媒体服务器类型验证API密钥或Token\n    if (wizardData.value.mediaServer.type === 'emby' || wizardData.value.mediaServer.type === 'jellyfin') {\n      if (!wizardData.value.mediaServer.config?.apikey?.trim()) {\n        errors.push(t('mediaserver.apiKeyRequired'))\n        validationErrors.value.mediaServer.apikey = true\n      }\n    } else if (wizardData.value.mediaServer.type === 'plex') {\n      if (!wizardData.value.mediaServer.config?.token?.trim()) {\n        errors.push(t('mediaserver.tokenRequired'))\n        validationErrors.value.mediaServer.token = true\n      }\n    } else if (wizardData.value.mediaServer.type === 'trimemedia' || wizardData.value.mediaServer.type === 'ugreen') {\n      if (!wizardData.value.mediaServer.config?.username?.trim()) {\n        errors.push(t('mediaserver.usernameRequired'))\n        validationErrors.value.mediaServer.username = true\n      }\n      if (!wizardData.value.mediaServer.config?.password?.trim()) {\n        errors.push(t('mediaserver.passwordRequired'))\n        validationErrors.value.mediaServer.password = true\n      }\n    }\n\n    return {\n      isValid: errors.length === 0,\n      errors,\n    }\n  }\n\n  // 验证通知字段\n  function validateNotificationFields(): { isValid: boolean; errors: string[] } {\n    const errors: string[] = []\n    clearValidationErrors()\n\n    // 名称必输\n    if (!wizardData.value.notification.name?.trim()) {\n      errors.push(t('notification.nameRequired'))\n      validationErrors.value.notification.name = true\n    }\n\n    // 根据通知类型验证必输项\n    const config = wizardData.value.notification.config || {}\n    switch (wizardData.value.notification.type) {\n      case 'wechat':\n        if (!config.WECHAT_CORPID?.trim()) {\n          errors.push(t('notification.wechat.corpIdRequired'))\n          validationErrors.value.notification.WECHAT_CORPID = true\n        }\n        if (!config.WECHAT_APP_ID?.trim()) {\n          errors.push(t('notification.wechat.appIdRequired'))\n          validationErrors.value.notification.WECHAT_APP_ID = true\n        }\n        if (!config.WECHAT_APP_SECRET?.trim()) {\n          errors.push(t('notification.wechat.appSecretRequired'))\n          validationErrors.value.notification.WECHAT_APP_SECRET = true\n        }\n        break\n      case 'telegram':\n        if (!config.TELEGRAM_TOKEN?.trim()) {\n          errors.push(t('notification.telegram.tokenRequired'))\n          validationErrors.value.notification.TELEGRAM_TOKEN = true\n        }\n        if (!config.TELEGRAM_CHAT_ID?.trim()) {\n          errors.push(t('notification.telegram.chatIdRequired'))\n          validationErrors.value.notification.TELEGRAM_CHAT_ID = true\n        }\n        break\n      case 'slack':\n        if (!config.SLACK_OAUTH_TOKEN?.trim()) {\n          errors.push(t('notification.slack.oauthTokenRequired'))\n          validationErrors.value.notification.SLACK_OAUTH_TOKEN = true\n        }\n        if (!config.SLACK_CHANNEL?.trim()) {\n          errors.push(t('notification.slack.channelRequired'))\n          validationErrors.value.notification.SLACK_CHANNEL = true\n        }\n        break\n      case 'synologychat':\n        if (!config.SYNOLOGYCHAT_WEBHOOK?.trim()) {\n          errors.push(t('notification.synologychat.webhookRequired'))\n          validationErrors.value.notification.SYNOLOGYCHAT_WEBHOOK = true\n        }\n        break\n      case 'vocechat':\n        if (!config.VOCECHAT_HOST?.trim()) {\n          errors.push(t('notification.vocechat.hostRequired'))\n          validationErrors.value.notification.VOCECHAT_HOST = true\n        }\n        if (!config.VOCECHAT_API_KEY?.trim()) {\n          errors.push(t('notification.vocechat.apiKeyRequired'))\n          validationErrors.value.notification.VOCECHAT_API_KEY = true\n        }\n        break\n      case 'webpush':\n        if (!config.WEBPUSH_USERNAME?.trim()) {\n          errors.push(t('notification.webpush.usernameRequired'))\n          validationErrors.value.notification.WEBPUSH_USERNAME = true\n        }\n        break\n      case 'qqbot':\n        if (!config.QQ_APP_ID?.trim()) {\n          errors.push(t('notification.qqbot.appIdRequired'))\n          validationErrors.value.notification.QQ_APP_ID = true\n        }\n        if (!config.QQ_APP_SECRET?.trim()) {\n          errors.push(t('notification.qqbot.appSecretRequired'))\n          validationErrors.value.notification.QQ_APP_SECRET = true\n        }\n        break\n    }\n\n    return {\n      isValid: errors.length === 0,\n      errors,\n    }\n  }\n\n  // 验证智能助手字段\n  function validateAgentFields(): { isValid: boolean; errors: string[] } {\n    const errors: string[] = []\n    clearValidationErrors()\n\n    if (!wizardData.value.agent.enabled) {\n      return {\n        isValid: true,\n        errors,\n      }\n    }\n\n    if (!wizardData.value.agent.provider?.trim()) {\n      errors.push(t('setupWizard.agent.providerRequired'))\n      validationErrors.value.agent.provider = true\n    }\n\n    if (!wizardData.value.agent.apiKey?.trim()) {\n      errors.push(t('setupWizard.agent.apiKeyRequired'))\n      validationErrors.value.agent.apiKey = true\n    }\n\n    if (!wizardData.value.agent.model?.trim()) {\n      errors.push(t('setupWizard.agent.modelRequired'))\n      validationErrors.value.agent.model = true\n    }\n\n    if (!wizardData.value.agent.maxContextTokens || wizardData.value.agent.maxContextTokens < 1) {\n      errors.push(t('setupWizard.agent.maxContextTokensRequired'))\n      validationErrors.value.agent.maxContextTokens = true\n    }\n\n    if (wizardData.value.agent.recommendEnabled && (!wizardData.value.agent.recommendMaxItems || wizardData.value.agent.recommendMaxItems < 1)) {\n      errors.push(t('setupWizard.agent.recommendMaxItemsRequired'))\n      validationErrors.value.agent.recommendMaxItems = true\n    }\n\n    return {\n      isValid: errors.length === 0,\n      errors,\n    }\n  }\n\n  // 验证当前步骤的必输项\n  function validateCurrentStep(): { isValid: boolean; errors: string[] } {\n    const errors: string[] = []\n\n    switch (currentStep.value) {\n      case 1: // 基础设置\n        if (!wizardData.value.basic.username) {\n          errors.push(t('dialog.userAddEdit.usernameRequired'))\n        }\n        // 密码是可选的，但如果输入了密码则需要验证\n        if (wizardData.value.basic.password) {\n          if (wizardData.value.basic.password.length < 6) {\n            errors.push(t('dialog.userAddEdit.passwordMinLength'))\n          }\n          if (!wizardData.value.basic.confirmPassword) {\n            errors.push(t('dialog.userAddEdit.confirmPasswordRequired'))\n          } else if (wizardData.value.basic.password !== wizardData.value.basic.confirmPassword) {\n            errors.push(t('dialog.userAddEdit.passwordMismatch'))\n          }\n        }\n        if (!wizardData.value.basic.apiToken) {\n          errors.push(t('setupWizard.basic.apiTokenRequired'))\n        }\n        break\n\n      case 2: // 存储设置\n        if (wizardData.value.siteAuth.site) {\n          const validation = validateSiteAuthFields()\n          errors.push(...validation.errors)\n        }\n        break\n\n      case 3: // 存储设置\n        if (!wizardData.value.storage.downloadPath) {\n          errors.push(t('setupWizard.storage.downloadPathRequired'))\n        }\n        if (!wizardData.value.storage.libraryPath) {\n          errors.push(t('setupWizard.storage.libraryPathRequired'))\n        }\n        break\n\n      case 4: // 下载器设置\n        if (wizardData.value.downloader.type) {\n          // 如果选择了下载器，则验证必输项\n          const validation = validateDownloaderFields()\n          errors.push(...validation.errors)\n        }\n        break\n\n      case 5: // 媒体服务器设置\n        if (wizardData.value.mediaServer.type) {\n          // 如果选择了媒体服务器，则验证必输项\n          const validation = validateMediaServerFields()\n          errors.push(...validation.errors)\n        }\n        break\n\n      case 6: // 通知设置\n        if (wizardData.value.notification.type) {\n          // 如果选择了通知，则验证必输项\n          const validation = validateNotificationFields()\n          errors.push(...validation.errors)\n        }\n        break\n\n      case 7: // 智能助手设置\n        if (wizardData.value.agent.enabled) {\n          const validation = validateAgentFields()\n          errors.push(...validation.errors)\n        }\n        break\n\n      case 8: // 偏好设置\n        // 偏好设置有默认值，不需要验证\n        break\n    }\n\n    return {\n      isValid: errors.length === 0,\n      errors,\n    }\n  }\n\n  // 检查是否需要进行测试\n  function shouldPerformTest(step: number): boolean {\n    switch (step) {\n      case 2: // 存储目录测试 - 总是需要测试\n        return false\n      case 3: // 存储目录测试 - 总是需要测试\n        return true\n      case 4: // 下载器测试 - 只有选择了下载器才测试\n        return !!wizardData.value.downloader.type\n      case 5: // 媒体服务器测试 - 只有选择了媒体服务器才测试\n        return !!wizardData.value.mediaServer.type\n      case 6: // 消息通知测试 - 只有选择了通知才测试\n        return !!wizardData.value.notification.type\n      default:\n        return false\n    }\n  }\n\n  // 连通性测试函数\n  async function testConnectivity(step: number) {\n    connectivityTest.value.isTesting = true\n    connectivityTest.value.testMessage = ''\n    connectivityTest.value.testProgress = 0\n    connectivityTest.value.testResult = null\n    connectivityTest.value.showResult = false\n\n    try {\n      let testResult: { success: boolean; message: string | null } = { success: false, message: null }\n\n      switch (step) {\n        case 2: // 存储目录测试\n          break\n        case 3: // 存储目录测试\n          testResult = await testStorageConnectivity()\n          break\n        case 4: // 下载器测试\n          testResult = await testDownloaderConnectivity()\n          break\n        case 5: // 媒体服务器测试\n          testResult = await testMediaServerConnectivity()\n          break\n        case 6: // 消息通知测试\n          testResult = await testNotificationConnectivity()\n          break\n      }\n\n      // 设置测试结果\n      connectivityTest.value.isTesting = false\n      connectivityTest.value.testResult = testResult.success ? 'success' : 'error'\n      connectivityTest.value.showResult = true\n\n      // 根据结果显示不同的消息\n      if (testResult.success) {\n        connectivityTest.value.testMessage = t('setupWizard.connectivityTestSuccess')\n      } else {\n        // 显示API返回的具体错误原因\n        connectivityTest.value.testMessage = testResult.message || t('setupWizard.connectivityTestFailed')\n      }\n\n      // 成功时2秒后隐藏结果，失败时保持显示直到用户操作\n      if (testResult.success) {\n        connectivityTest.value.showResult = false\n        connectivityTest.value.testResult = null\n      }\n\n      return testResult.success\n    } catch (error) {\n      console.error('Connectivity test failed:', error)\n      connectivityTest.value.isTesting = false\n      connectivityTest.value.testResult = 'error'\n      connectivityTest.value.showResult = true\n      connectivityTest.value.testMessage = (error as Error).message || t('setupWizard.connectivityTestFailed')\n      return false\n    }\n  }\n\n  // 存储目录连通性测试\n  async function testStorageConnectivity() {\n    try {\n      connectivityTest.value.testProgress = 30\n      connectivityTest.value.testMessage = t('setupWizard.testingStorage')\n\n      // 等待设置生效\n      await new Promise(resolve => setTimeout(resolve, 2000))\n\n      connectivityTest.value.testProgress = 60\n      connectivityTest.value.testMessage = t('setupWizard.checkingStorage')\n\n      // 调用存储测试API - 使用FileManagerModule\n      const result: { [key: string]: any } = await api.get('system/moduletest/FileManagerModule')\n      connectivityTest.value.testProgress = 100\n\n      if (result.success) {\n        return { success: true, message: null }\n      } else {\n        return { success: false, message: result.message || t('setupWizard.storageTestFailed') }\n      }\n    } catch (error) {\n      console.error('Storage test failed:', error)\n      return { success: false, message: (error as Error).message || t('setupWizard.storageTestFailed') }\n    }\n  }\n\n  // 下载器连通性测试\n  async function testDownloaderConnectivity() {\n    try {\n      connectivityTest.value.testProgress = 30\n      connectivityTest.value.testMessage = t('setupWizard.testingDownloader')\n\n      // 等待设置生效\n      await new Promise(resolve => setTimeout(resolve, 2000))\n\n      connectivityTest.value.testProgress = 60\n      connectivityTest.value.testMessage = t('setupWizard.checkingDownloader')\n\n      // 获取正确的模块ID\n      const downloaderType = wizardData.value.downloader.type\n      if (!downloaderType) {\n        return { success: false, message: t('setupWizard.downloaderNotSelected') }\n      }\n\n      const moduleid = typeToModuleMapping.downloader[downloaderType as keyof typeof typeToModuleMapping.downloader]\n      if (!moduleid) {\n        return { success: false, message: t('setupWizard.unsupportedDownloaderType', { type: downloaderType }) }\n      }\n\n      const result: { [key: string]: any } = await api.get(`system/moduletest/${moduleid}`)\n      connectivityTest.value.testProgress = 100\n\n      if (result.success) {\n        return { success: true, message: null }\n      } else {\n        return { success: false, message: result.message || t('setupWizard.downloaderTestFailed') }\n      }\n    } catch (error) {\n      console.error('Downloader test failed:', error)\n      return { success: false, message: (error as Error).message || t('setupWizard.downloaderTestFailed') }\n    }\n  }\n\n  // 媒体服务器连通性测试\n  async function testMediaServerConnectivity() {\n    try {\n      connectivityTest.value.testProgress = 30\n      connectivityTest.value.testMessage = t('setupWizard.testingMediaServer')\n\n      // 等待设置生效\n      await new Promise(resolve => setTimeout(resolve, 2000))\n\n      connectivityTest.value.testProgress = 60\n      connectivityTest.value.testMessage = t('setupWizard.checkingMediaServer')\n\n      // 获取正确的模块ID\n      const mediaServerType = wizardData.value.mediaServer.type\n      if (!mediaServerType) {\n        return { success: false, message: t('setupWizard.mediaServerNotSelected') }\n      }\n\n      const moduleid = typeToModuleMapping.mediaServer[mediaServerType as keyof typeof typeToModuleMapping.mediaServer]\n      if (!moduleid) {\n        return { success: false, message: t('setupWizard.unsupportedMediaServerType', { type: mediaServerType }) }\n      }\n\n      const result: { [key: string]: any } = await api.get(`system/moduletest/${moduleid}`)\n      connectivityTest.value.testProgress = 100\n\n      if (result.success) {\n        return { success: true, message: null }\n      } else {\n        return { success: false, message: result.message || t('setupWizard.mediaServerTestFailed') }\n      }\n    } catch (error) {\n      console.error('Media server test failed:', error)\n      return { success: false, message: (error as Error).message || t('setupWizard.mediaServerTestFailed') }\n    }\n  }\n\n  // 消息通知连通性测试\n  async function testNotificationConnectivity() {\n    try {\n      connectivityTest.value.testProgress = 30\n      connectivityTest.value.testMessage = t('setupWizard.testingNotification')\n\n      // 等待设置生效\n      await new Promise(resolve => setTimeout(resolve, 2000))\n\n      connectivityTest.value.testProgress = 60\n      connectivityTest.value.testMessage = t('setupWizard.checkingNotification')\n\n      // 获取正确的模块ID\n      const notificationType = wizardData.value.notification.type\n      if (!notificationType) {\n        return { success: false, message: t('setupWizard.notificationNotSelected') }\n      }\n\n      const moduleid =\n        typeToModuleMapping.notification[notificationType as keyof typeof typeToModuleMapping.notification]\n      if (!moduleid) {\n        return { success: false, message: t('setupWizard.unsupportedNotificationType', { type: notificationType }) }\n      }\n\n      const result: { [key: string]: any } = await api.get(`system/moduletest/${moduleid}`)\n      connectivityTest.value.testProgress = 100\n\n      if (result.success) {\n        return { success: true, message: null }\n      } else {\n        return { success: false, message: result.message || t('setupWizard.notificationTestFailed') }\n      }\n    } catch (error) {\n      console.error('Notification test failed:', error)\n      return { success: false, message: (error as Error).message || t('setupWizard.notificationTestFailed') }\n    }\n  }\n\n  // 下一步\n  async function nextStep() {\n    // 验证当前步骤的必输项\n    const validation = validateCurrentStep()\n    if (!validation.isValid) {\n      // 显示验证错误\n      validation.errors.forEach(error => {\n        $toast.error(error)\n      })\n      return false\n    }\n\n    // 保存当前步骤的设置\n    const saved = await saveCurrentStepSettings()\n    if (!saved) {\n      return false\n    }\n\n    // 检查是否需要进行测试\n    const needsTest = shouldPerformTest(currentStep.value)\n    if (needsTest) {\n      const testResult = await testConnectivity(currentStep.value)\n      if (!testResult) {\n        return false\n      }\n    }\n\n    // 如果不是最后一步，则前进到下一步\n    if (currentStep.value < totalSteps) {\n      currentStep.value++\n      connectivityTest.value.showResult = false\n    }\n\n    return true\n  }\n\n  // 上一步\n  function prevStep() {\n    if (currentStep.value > 1) {\n      currentStep.value--\n    }\n    connectivityTest.value.showResult = false\n  }\n\n  // 保存当前步骤的设置\n  async function saveCurrentStepSettings() {\n    try {\n      switch (currentStep.value) {\n        case 1:\n          return await saveBasicSettings()\n        case 2:\n          return await saveSiteAuthSettings()\n        case 3:\n          return await saveStorageSettings()\n        case 4:\n          return await saveDownloaderSettings()\n        case 5:\n          return await saveMediaServerSettings()\n        case 6:\n          return await saveNotificationSettings()\n        case 7:\n          return await saveAgentSettings()\n        case 8:\n          return await savePreferenceSettings()\n      }\n    } catch (error) {\n      console.error('Save current step settings failed:', error)\n      $toast.error(t('setupWizard.saveStepFailed'))\n      return false\n    }\n    return true\n  }\n\n  // 完成向导\n  async function completeWizard() {\n    try {\n      // 先处理下一步（保存当前步骤设置）\n      const saved = await nextStep()\n      if (!saved) {\n        return\n      }\n      // 保存设置向导完成状态\n      await saveSetupWizardState()\n\n      $toast.success(t('setupWizard.completed'))\n      router.push('/')\n    } catch (error) {\n      console.error('Setup wizard failed:', error)\n      $toast.error(t('setupWizard.failed'))\n    }\n  }\n\n  // 更新用户密码\n  async function updateUserPassword() {\n    if (wizardData.value.basic.username && wizardData.value.basic.password) {\n      try {\n        // 获取当前用户信息\n        const currentUser: User = await api.get('user/current')\n\n        if (currentUser) {\n          // 更新现有用户的密码\n          const userData = {\n            name: wizardData.value.basic.username,\n            password: wizardData.value.basic.password,\n            is_active: currentUser.is_active,\n            is_superuser: currentUser.is_superuser,\n          }\n\n          await api.put(`user/${currentUser.id}`, userData)\n        } else {\n          // 如果用户不存在，创建新用户（通常不会发生）\n          const userData = {\n            name: wizardData.value.basic.username,\n            password: wizardData.value.basic.password,\n            is_active: true,\n            is_superuser: true,\n          }\n\n          await api.post('user/', userData)\n        }\n      } catch (error) {\n        console.error('Update user password failed:', error)\n        throw error\n      }\n    }\n  }\n\n  // 保存基础设置\n  async function saveBasicSettings() {\n    try {\n      const basicSettings = {\n        APP_DOMAIN: wizardData.value.basic.appDomain,\n        API_TOKEN: wizardData.value.basic.apiToken,\n        RECOGNIZE_SOURCE: 'themoviedb',\n        OCR_HOST: wizardData.value.basic.ocrHost,\n        PROXY_HOST: wizardData.value.basic.proxyHost,\n        GITHUB_TOKEN: wizardData.value.basic.githubToken,\n      }\n\n      // 保存基础设置\n      const response: { [key: string]: any } = await api.post('system/env', basicSettings)\n      if (!response.success) {\n        return false\n      }\n\n      // 如果输入了密码，验证密码一致性\n      if (wizardData.value.basic.password) {\n        if (wizardData.value.basic.password !== wizardData.value.basic.confirmPassword) {\n          $toast.error(t('dialog.userAddEdit.passwordMismatch'))\n          return false\n        }\n        // 更新用户密码\n        await updateUserPassword()\n      }\n      return true\n    } catch (error) {\n      console.error('Save basic settings failed:', error)\n      $toast.error(t('setupWizard.saveBasicSettingsFailed'))\n      return false\n    }\n  }\n\n  // 保存存储配置\n  async function saveStorageSettings() {\n    try {\n      // 创建本地存储\n      const storage = {\n        name: '本地存储',\n        type: 'local',\n        config: {},\n      }\n\n      await api.post('system/setting/Storages', [storage])\n\n      // 创建目录配置\n      const directory = {\n        name: '默认目录',\n        storage: 'local',\n        library_storage: 'local',\n        download_path: wizardData.value.storage.downloadPath,\n        library_path: wizardData.value.storage.libraryPath,\n        priority: 0,\n        monitor_type: 'downloader',\n        media_type: '',\n        media_category: '',\n        download_type_folder: true,\n        download_category_folder: true,\n        transfer_type: wizardData.value.storage.transferType,\n        overwrite_mode: wizardData.value.storage.overwriteMode,\n        renaming: true,\n        scraping: true,\n        notify: true,\n        library_type_folder: true,\n        library_category_folder: true,\n      }\n\n      await api.post('system/setting/Directories', [directory])\n      return true\n    } catch (error) {\n      console.error('Save storage settings failed:', error)\n      $toast.error(t('setupWizard.saveStorageSettingsFailed'))\n      return false\n    }\n  }\n\n  // 保存用户站点认证设置\n  async function saveSiteAuthSettings() {\n    try {\n      const envResponse: { [key: string]: any } = await api.post('system/env', {\n        AUXILIARY_AUTH_ENABLE: wizardData.value.siteAuth.auxiliaryAuthEnable,\n      })\n\n      if (!envResponse.success) {\n        return false\n      }\n\n      if (!wizardData.value.siteAuth.site) {\n        return true\n      }\n\n      const response: { [key: string]: any } = await api.post('site/auth', {\n        site: wizardData.value.siteAuth.site,\n        params: wizardData.value.siteAuth.params,\n      })\n\n      if (!response.success) {\n        $toast.error(t('setupWizard.saveSiteAuthSettingsFailed', { message: response.message }))\n        return false\n      }\n\n      return true\n    } catch (error) {\n      console.error('Save site auth settings failed:', error)\n      $toast.error(t('setupWizard.saveSiteAuthSettingsFailed', { message: (error as Error).message || '' }))\n      return false\n    }\n  }\n\n  // 保存下载器配置\n  async function saveDownloaderSettings() {\n    if (wizardData.value.downloader.type) {\n      try {\n        // 只保存当前选中类型的配置\n        const config = { ...wizardData.value.downloader.config }\n\n        const downloader = {\n          name: wizardData.value.downloader.name,\n          type: wizardData.value.downloader.type,\n          default: true,\n          enabled: true,\n          config: config,\n        }\n\n        await api.post('system/setting/Downloaders', [downloader])\n        return true\n      } catch (error) {\n        console.error('Save downloader settings failed:', error)\n        $toast.error(t('setupWizard.saveDownloaderSettingsFailed'))\n        return false\n      }\n    } else {\n      // 没有选择下载器时，清空现有配置\n      console.log('No downloader selected, skipping save')\n      return true\n    }\n  }\n\n  // 保存媒体服务器配置\n  async function saveMediaServerSettings() {\n    if (wizardData.value.mediaServer.type) {\n      try {\n        // 只保存当前选中类型的配置\n        const config = { ...wizardData.value.mediaServer.config }\n        const sync_libraries = [...(wizardData.value.mediaServer.sync_libraries || [])]\n\n        const mediaServer = {\n          name: wizardData.value.mediaServer.name,\n          type: wizardData.value.mediaServer.type,\n          enabled: true,\n          config: config,\n          sync_libraries: sync_libraries,\n        }\n\n        await api.post('system/setting/MediaServers', [mediaServer])\n        return true\n      } catch (error) {\n        console.error('Save media server settings failed:', error)\n        $toast.error(t('setupWizard.saveMediaServerSettingsFailed'))\n        return false\n      }\n    } else {\n      // 没有选择媒体服务器时，清空现有配置\n      console.log('No media server selected, skipping save')\n      return true\n    }\n  }\n\n  // 保存通知配置\n  async function saveNotificationSettings() {\n    if (wizardData.value.notification.type) {\n      try {\n        // 只保存当前选中类型的配置\n        const config = { ...wizardData.value.notification.config }\n        const switchs = [...(wizardData.value.notification.switchs || [])]\n\n        const notification = {\n          name: wizardData.value.notification.name,\n          type: wizardData.value.notification.type,\n          enabled: wizardData.value.notification.enabled,\n          config: config,\n          switchs: switchs,\n        }\n\n        await api.post('system/setting/Notifications', [notification])\n        return true\n      } catch (error) {\n        console.error('Save notification settings failed:', error)\n        $toast.error(t('setupWizard.saveNotificationSettingsFailed'))\n        return false\n      }\n    } else {\n      // 没有选择通知时，清空现有配置\n      console.log('No notification selected, skipping save')\n      return true\n    }\n  }\n\n  // 保存智能助手设置\n  async function saveAgentSettings() {\n    try {\n      const agentSettings = {\n        AI_AGENT_ENABLE: wizardData.value.agent.enabled,\n        AI_AGENT_GLOBAL: wizardData.value.agent.enabled ? wizardData.value.agent.global : false,\n        AI_AGENT_VERBOSE: wizardData.value.agent.enabled ? wizardData.value.agent.verbose : false,\n        LLM_PROVIDER: wizardData.value.agent.provider,\n        LLM_MODEL: wizardData.value.agent.model,\n        LLM_THINKING_LEVEL: wizardData.value.agent.thinkingLevel,\n        LLM_SUPPORT_IMAGE_INPUT: wizardData.value.agent.supportImageInput,\n        LLM_API_KEY: wizardData.value.agent.apiKey,\n        LLM_BASE_URL: wizardData.value.agent.baseUrl || null,\n        LLM_MAX_CONTEXT_TOKENS: wizardData.value.agent.maxContextTokens,\n        AI_AGENT_JOB_INTERVAL: wizardData.value.agent.enabled ? wizardData.value.agent.jobInterval : 0,\n        AI_AGENT_RETRY_TRANSFER: wizardData.value.agent.enabled ? wizardData.value.agent.retryTransfer : false,\n        AI_RECOMMEND_ENABLED:\n          wizardData.value.agent.enabled && wizardData.value.agent.recommendEnabled,\n        AI_RECOMMEND_USER_PREFERENCE: wizardData.value.agent.recommendUserPreference,\n        AI_RECOMMEND_MAX_ITEMS: wizardData.value.agent.recommendMaxItems,\n      }\n\n      await api.post('system/env', agentSettings)\n      return true\n    } catch (error) {\n      console.error('Save agent settings failed:', error)\n      $toast.error(t('setupWizard.saveAgentSettingsFailed'))\n      return false\n    }\n  }\n\n  // 保存资源偏好设置\n  async function savePreferenceSettings() {\n    try {\n      // 如果有自定义规则序列，保存到用户过滤规则组\n      if (wizardData.value.preferences.ruleSequences && wizardData.value.preferences.ruleSequences.length > 0) {\n        try {\n          // 保存当前选中的规则组到 UserFilterRuleGroups\n          const filterResponse: { [key: string]: any } = await api.post(\n            'system/setting/UserFilterRuleGroups',\n            wizardData.value.preferences.ruleSequences,\n          )\n          if (filterResponse.success) {\n            // 保存规则组名称到其他设置\n            const ruleGroupNames = wizardData.value.preferences.ruleSequences.map(rule => [rule.name])\n\n            // 保存到 SubscribeFilterRuleGroups\n            await api.post('system/setting/SubscribeFilterRuleGroups', ruleGroupNames)\n\n            // 保存到 BestVersionFilterRuleGroups\n            await api.post('system/setting/BestVersionFilterRuleGroups', ruleGroupNames)\n          }\n        } catch (error) {\n          console.error('Save rule sequences failed:', error)\n        }\n      }\n      return true\n    } catch (error) {\n      console.error('Save preference settings failed:', error)\n      $toast.error(t('setupWizard.savePreferenceSettingsFailed'))\n      return false\n    }\n  }\n\n  // 保存设置向导完成状态\n  async function saveSetupWizardState() {\n    try {\n      const response: { [key: string]: any } = await api.post('system/setting/SetupWizardState', '1')\n      if (response.success) {\n        console.log('Setup wizard state saved successfully')\n      }\n    } catch (error) {\n      console.error('Save setup wizard state failed:', error)\n      // 这里不显示错误提示，因为向导状态保存失败不应该阻止用户完成向导\n    }\n  }\n\n  // 加载系统设置\n  async function loadSystemSettings() {\n    try {\n      const result: { [key: string]: any } = await api.get('system/env')\n      if (result.success) {\n        // 加载基础设置\n        if (result.data.APP_DOMAIN) {\n          wizardData.value.basic.appDomain = result.data.APP_DOMAIN\n        }\n        if (result.data.API_TOKEN) {\n          wizardData.value.basic.apiToken = result.data.API_TOKEN\n        }\n        if (result.data.PROXY_HOST) {\n          wizardData.value.basic.proxyHost = result.data.PROXY_HOST\n        }\n        if (result.data.OCR_HOST) {\n          wizardData.value.basic.ocrHost = result.data.OCR_HOST\n        }\n        if (result.data.GITHUB_TOKEN) {\n          wizardData.value.basic.githubToken = result.data.GITHUB_TOKEN\n        }\n        wizardData.value.siteAuth.auxiliaryAuthEnable = Boolean(result.data.AUXILIARY_AUTH_ENABLE)\n        if (result.data.SUPERUSER) {\n          wizardData.value.basic.username = result.data.SUPERUSER\n        }\n        wizardData.value.agent.enabled = Boolean(result.data.AI_AGENT_ENABLE)\n        wizardData.value.agent.global = Boolean(result.data.AI_AGENT_GLOBAL)\n        wizardData.value.agent.verbose = Boolean(result.data.AI_AGENT_VERBOSE)\n        wizardData.value.agent.provider = result.data.LLM_PROVIDER || 'deepseek'\n        wizardData.value.agent.model = result.data.LLM_MODEL || ''\n        wizardData.value.agent.thinkingLevel = resolveThinkingLevelValue(result.data)\n        wizardData.value.agent.supportImageInput = result.data.LLM_SUPPORT_IMAGE_INPUT ?? true\n        wizardData.value.agent.apiKey = result.data.LLM_API_KEY || ''\n        wizardData.value.agent.baseUrl = result.data.LLM_BASE_URL || ''\n        wizardData.value.agent.maxContextTokens = result.data.LLM_MAX_CONTEXT_TOKENS || 64\n        wizardData.value.agent.jobInterval = result.data.AI_AGENT_JOB_INTERVAL || 0\n        wizardData.value.agent.retryTransfer = Boolean(result.data.AI_AGENT_RETRY_TRANSFER)\n        wizardData.value.agent.recommendEnabled = Boolean(result.data.AI_RECOMMEND_ENABLED)\n        wizardData.value.agent.recommendUserPreference = result.data.AI_RECOMMEND_USER_PREFERENCE || ''\n        wizardData.value.agent.recommendMaxItems = result.data.AI_RECOMMEND_MAX_ITEMS || 50\n\n        // 如果没有API Token，则创建一个随机的\n        if (!wizardData.value.basic.apiToken) {\n          createRandomString()\n        }\n      }\n    } catch (error) {\n      console.log('Load system settings failed:', error)\n    }\n  }\n\n  // 加载用户站点认证列表\n  async function loadAuthSites() {\n    try {\n      authSites.value = (await api.get('site/auth')) || {}\n    } catch (error) {\n      console.log('Load auth sites failed:', error)\n    }\n  }\n\n  // 加载用户站点认证设置\n  async function loadSiteAuthSettings() {\n    try {\n      const result: { [key: string]: any } = await api.get('system/setting/UserSiteAuthParams')\n      if (result.success && result.data?.value) {\n        wizardData.value.siteAuth.site = result.data.value.site || ''\n        wizardData.value.siteAuth.params = result.data.value.params || {}\n      }\n    } catch (error) {\n      console.log('Load site auth settings failed:', error)\n    }\n  }\n\n  // 加载存储设置\n  async function loadStorageSettings() {\n    try {\n      const result: { [key: string]: any } = await api.get('system/setting/Directories')\n      if (result.success && result.data?.value && result.data.value.length > 0) {\n        const directory = result.data.value[0]\n        wizardData.value.storage.downloadPath = directory.download_path || ''\n        wizardData.value.storage.libraryPath = directory.library_path || ''\n        wizardData.value.storage.transferType = directory.transfer_type || 'link'\n        wizardData.value.storage.overwriteMode = directory.overwrite_mode || 'never'\n      }\n    } catch (error) {\n      console.log('Load storage settings failed:', error)\n    }\n  }\n\n  // 加载下载器设置\n  async function loadDownloaderSettings() {\n    try {\n      const result: { [key: string]: any } = await api.get('system/setting/Downloaders')\n      if (result.success && result.data?.value && result.data.value.length > 0) {\n        const downloader = result.data.value[0]\n        wizardData.value.downloader.type = downloader.type\n        wizardData.value.downloader.name = downloader.name\n        wizardData.value.downloader.config = downloader.config || {}\n      }\n    } catch (error) {\n      console.log('Load downloader settings failed:', error)\n    }\n  }\n\n  // 加载媒体服务器设置\n  async function loadMediaServerSettings() {\n    try {\n      const result: { [key: string]: any } = await api.get('system/setting/MediaServers')\n      if (result.success && result.data?.value && result.data.value.length > 0) {\n        const mediaServer = result.data.value[0]\n        wizardData.value.mediaServer.type = mediaServer.type\n        wizardData.value.mediaServer.name = mediaServer.name\n        wizardData.value.mediaServer.config = mediaServer.config || {}\n        wizardData.value.mediaServer.sync_libraries = mediaServer.sync_libraries || []\n      }\n    } catch (error) {\n      console.log('Load media server settings failed:', error)\n    }\n  }\n\n  // 加载通知设置\n  async function loadNotificationSettings() {\n    try {\n      const result: { [key: string]: any } = await api.get('system/setting/Notifications')\n      if (result.success && result.data?.value && result.data.value.length > 0) {\n        const notification = result.data.value[0]\n        wizardData.value.notification.type = notification.type\n        wizardData.value.notification.name = notification.name\n        wizardData.value.notification.enabled = notification.enabled\n        wizardData.value.notification.config = notification.config || {}\n        wizardData.value.notification.switchs = notification.switchs || []\n      }\n    } catch (error) {\n      console.log('Load notification settings failed:', error)\n    }\n  }\n\n  // 初始化\n  async function initialize() {\n    isLoading.value = true\n    try {\n      await loadSystemSettings()\n      await loadAuthSites()\n      await loadSiteAuthSettings()\n      await loadStorageSettings()\n      await loadDownloaderSettings()\n      await loadMediaServerSettings()\n      await loadNotificationSettings()\n    } finally {\n      isLoading.value = false\n    }\n  }\n\n  return {\n    // 状态\n    currentStep,\n    totalSteps,\n    stepTitles,\n    stepDescriptions,\n    wizardData,\n    authSites,\n    selectedPreset,\n    connectivityTest,\n    validationErrors,\n    isLoading,\n\n    // 方法\n    createRandomString,\n    copyValue,\n    selectDownloader,\n    selectMediaServer,\n    selectNotification,\n    selectPreset,\n    updatePreferences,\n    validateCurrentStep,\n    validateSiteAuthFields,\n    validateDownloaderFields,\n    validateMediaServerFields,\n    validateNotificationFields,\n    validateAgentFields,\n    clearValidationErrors,\n    testConnectivity,\n    nextStep,\n    prevStep,\n    completeWizard,\n    initialize,\n  }\n}\n"
  },
  {
    "path": "src/composables/useStateRestore.ts",
    "content": "/**\n * PWA状态恢复组合式API\n * 提供2个专门的hooks：路由、标签页\n */\n\nimport { ref, onMounted, onUnmounted, watch, inject } from 'vue'\nimport { useRoute } from 'vue-router'\nimport type { StateRestore } from '@/plugins/stateRestore'\n\n// =============================================================================\n// 1. 动态标签页状态恢复\n// =============================================================================\n\n/**\n * 动态标签页状态恢复Hook\n * 自动保存和恢复v-tabs的当前激活标签\n */\nexport function useTabStateRestore(defaultTab?: string) {\n  const route = useRoute()\n  const stateRestore = inject<StateRestore>('stateRestore')\n\n  const activeTab = ref<string>(defaultTab || '')\n\n  // 保存标签页状态\n  const saveTabState = (tab: string) => {\n    if (stateRestore && tab) {\n      stateRestore.tab.saveTabState(route.path, tab)\n    }\n  }\n\n  // 恢复标签页状态\n  const restoreTabState = () => {\n    if (stateRestore) {\n      const savedTab = stateRestore.tab.getTabState(route.path)\n      if (savedTab) {\n        activeTab.value = savedTab\n        console.log(`恢复标签页状态: ${route.path} -> ${savedTab}`)\n        return true\n      }\n    }\n    return false\n  }\n\n  // 监听activeTab变化，自动保存\n  watch(activeTab, newTab => {\n    if (newTab) {\n      saveTabState(newTab)\n    }\n  })\n\n  // 组件挂载时恢复状态（仅在首次加载时）\n  onMounted(() => {\n    // 先尝试恢复，如果没有保存的状态则使用默认值\n    if (!restoreTabState() && defaultTab) {\n      activeTab.value = defaultTab\n    }\n  })\n\n  return {\n    activeTab,\n    saveTabState,\n    restoreTabState,\n  }\n}\n\n// =============================================================================\n// 2. 路由状态恢复\n// =============================================================================\n\n/**\n * 路由状态恢复Hook\n * 获取路由恢复信息，主要用于调试和监控\n */\nexport function useRouteStateRestore() {\n  const stateRestore = inject<StateRestore>('stateRestore')\n\n  const lastRestoredRoute = ref<any>(null)\n\n  // 获取上次保存的路由\n  const getLastSavedRoute = () => {\n    if (stateRestore) {\n      return stateRestore.route.restoreRoute()\n    }\n    return null\n  }\n\n  // 手动保存当前路由\n  const saveCurrentRoute = () => {\n    if (stateRestore) {\n      stateRestore.route.saveCurrentRoute()\n    }\n  }\n\n  // 清除路由状态\n  const clearRouteState = () => {\n    if (stateRestore) {\n      stateRestore.route.clearRoute()\n    }\n  }\n\n  // 监听全局恢复事件\n  const handleRestore = (event: Event) => {\n    const customEvent = event as CustomEvent\n    if (customEvent.detail?.route) {\n      lastRestoredRoute.value = customEvent.detail.route\n    }\n  }\n\n  onMounted(() => {\n    window.addEventListener('pwa-state-restore', handleRestore)\n  })\n\n  onUnmounted(() => {\n    window.removeEventListener('pwa-state-restore', handleRestore)\n  })\n\n  return {\n    lastRestoredRoute,\n    getLastSavedRoute,\n    saveCurrentRoute,\n    clearRouteState,\n  }\n}\n\n// =============================================================================\n// 3. 全量状态恢复Hook\n// =============================================================================\n\n/**\n * 全量状态恢复Hook\n * 用于清理所有状态或获取统计信息\n */\nexport function useStateRestore() {\n  const stateRestore = inject<StateRestore>('stateRestore')\n\n  // 清除所有状态\n  const clearAllStates = () => {\n    if (stateRestore) {\n      stateRestore.clearAllStates()\n      console.log('已清除所有PWA状态')\n    }\n  }\n\n  // 获取状态统计\n  const getStateStats = () => {\n    if (!stateRestore) return null\n\n    return {\n      hasRoute: !!stateRestore.route.restoreRoute(),\n      // 可以扩展更多统计信息\n    }\n  }\n\n  return {\n    clearAllStates,\n    getStateStats,\n    stateRestore,\n  }\n}\n\n// =============================================================================\n// 4. 快捷Hook组合\n// =============================================================================\n\n/**\n * 页面级状态恢复Hook\n * 组合路由和标签页状态恢复功能，适用于有标签页的页面\n */\nexport function usePageStateRestore(defaultTab?: string) {\n  const tabs = defaultTab ? useTabStateRestore(defaultTab) : null\n  const route = useRouteStateRestore()\n  const global = useStateRestore()\n\n  return {\n    tabs,\n    route,\n    global,\n  }\n}\n"
  },
  {
    "path": "src/composables/useTorrentFilter.ts",
    "content": "import type { Context } from '@/api/types'\nimport { cloneDeepWith } from 'lodash-es'\nimport { useI18n } from 'vue-i18n'\n\n// 卡片视图的分组数据类型\ninterface SearchTorrent extends Context {\n  more?: Array<Context>\n}\n\ninterface GroupedItem {\n  data: SearchTorrent\n  originalIndex: number\n}\n\n// 筛选状态类型\nexport interface FilterState {\n  filterForm: Record<string, string[]>\n  filterOptions: Record<string, string[]>\n  sortField: string\n  sortType: 'asc' | 'desc'\n}\n\n// useTorrentFilter composable\nexport function useTorrentFilter() {\n  const { t } = useI18n()\n\n  // 过滤表单\n  const filterForm: Record<string, string[]> = reactive({\n    site: [] as string[],\n    season: [] as string[],\n    releaseGroup: [] as string[],\n    videoCode: [] as string[],\n    freeState: [] as string[],\n    edition: [] as string[],\n    resolution: [] as string[],\n  })\n\n  // 统一存储过滤选项\n  const filterOptions: Record<string, string[]> = reactive({\n    site: [] as string[],\n    season: [] as string[],\n    freeState: [] as string[],\n    edition: [] as string[],\n    resolution: [] as string[],\n    videoCode: [] as string[],\n    releaseGroup: [] as string[],\n  })\n\n  // 排序字段\n  const sortField = ref('default')\n  // 排序方向\n  const sortType = ref<'asc' | 'desc'>('desc')\n\n  // 过滤项映射\n  const filterTitles: Record<string, string> = {\n    site: t('torrent.filterSite'),\n    season: t('torrent.filterSeason'),\n    freeState: t('torrent.filterFreeState'),\n    videoCode: t('torrent.filterVideoCode'),\n    edition: t('torrent.filterEdition'),\n    resolution: t('torrent.filterResolution'),\n    releaseGroup: t('torrent.filterReleaseGroup'),\n  }\n\n  // 排序中文名\n  const sortTitles: Record<string, string> = {\n    default: t('torrent.sortDefault'),\n    site: t('torrent.sortSite'),\n    size: t('torrent.sortSize'),\n    seeder: t('torrent.sortSeeder'),\n    publishTime: t('torrent.sortPublishTime'),\n  }\n\n  // 筛选后数据的原始索引列表\n  const filteredIndices = ref<number[]>([])\n\n  // 筛选后的总数量\n  const totalFilteredCount = ref(0)\n\n  // 初始化过滤选项\n  function initOptions(data: Context) {\n    const { torrent_info, meta_info } = data\n    const optionValue = (options: Array<string>, value: string | undefined) => {\n      if (value && !options.includes(value)) {\n        options.push(value)\n        // 如果是season选项，立即触发重新计算\n        if (options === filterOptions.season) {\n          sortSeasonOptions()\n        }\n      }\n    }\n\n    optionValue(filterOptions.site, torrent_info?.site_name)\n    optionValue(filterOptions.season, meta_info?.season_episode)\n    optionValue(filterOptions.releaseGroup, meta_info?.resource_team)\n    optionValue(filterOptions.videoCode, meta_info?.video_encode)\n    optionValue(filterOptions.freeState, torrent_info?.volume_factor)\n    optionValue(filterOptions.edition, meta_info?.edition)\n    optionValue(filterOptions.resolution, meta_info?.resource_pix)\n  }\n\n  // 直接对季集选项进行排序的函数\n  function sortSeasonOptions() {\n    if (filterOptions.season.length <= 1) {\n      return\n    }\n\n    const parsedOptions = filterOptions.season.map((option, index) => {\n      const match = option.match(/^S(\\d+)(?:-S(\\d+))?\\s*(?:E(\\d+)(?:-E(\\d+))?)?$/)\n\n      if (!match) {\n        return {\n          original: option,\n          seasonNum: 0,\n          episodeNum: 0,\n          maxEpisodeNum: 0,\n          isWholeSeason: false,\n          index,\n        }\n      }\n\n      const seasonNum = parseInt(match[1], 10)\n      const episodeNum = match[3] ? parseInt(match[3], 10) : 0\n      const maxEpisodeNum = match[4] ? parseInt(match[4], 10) : episodeNum\n      const isWholeSeason = !match[3]\n\n      return {\n        original: option,\n        seasonNum,\n        episodeNum,\n        maxEpisodeNum,\n        isWholeSeason,\n        index,\n      }\n    })\n\n    const wholeSeasons = parsedOptions.filter(item => item.isWholeSeason)\n    const episodes = parsedOptions.filter(item => !item.isWholeSeason)\n\n    wholeSeasons.sort((a, b) => {\n      if (a.seasonNum !== b.seasonNum) {\n        return b.seasonNum - a.seasonNum\n      }\n      return a.index - b.index\n    })\n\n    episodes.sort((a, b) => {\n      if (a.seasonNum !== b.seasonNum) {\n        return b.seasonNum - a.seasonNum\n      }\n      const aMaxEp = a.maxEpisodeNum || a.episodeNum\n      const bMaxEp = b.maxEpisodeNum || b.episodeNum\n      if (aMaxEp !== bMaxEp) {\n        return bMaxEp - aMaxEp\n      }\n      if (a.episodeNum !== b.episodeNum) {\n        return b.episodeNum - a.episodeNum\n      }\n      return a.index - b.index\n    })\n\n    const sortedOptions = [...wholeSeasons, ...episodes].map(item => item.original)\n    filterOptions.season = sortedOptions\n  }\n\n  // 匹配过滤函数\n  const match = (filter: Array<string>, value: string | undefined) =>\n    filter.length === 0 || (value && filter.includes(value))\n\n  // 筛选列表视图数据（不分组）\n  function filterRowData(items: Context[] | undefined): Context[] {\n    // 重置状态\n    filteredIndices.value = []\n    \n    // 清空并重新初始化过滤选项\n    for (const key in filterOptions) {\n      filterOptions[key] = []\n    }\n\n    if (!items?.length) {\n      totalFilteredCount.value = 0\n      return []\n    }\n\n    // 首先收集所有过滤选项\n    items.forEach(data => {\n      initOptions(data)\n    })\n\n    // 筛选数据\n    let filteredData: Context[] = []\n\n    items.forEach((data, index) => {\n      const { meta_info, torrent_info } = data\n      if (\n        match(filterForm.site, torrent_info.site_name) &&\n        match(filterForm.freeState, torrent_info.volume_factor) &&\n        match(filterForm.season, meta_info.season_episode) &&\n        match(filterForm.releaseGroup, meta_info.resource_team) &&\n        match(filterForm.videoCode, meta_info.video_encode) &&\n        match(filterForm.resolution, meta_info.resource_pix) &&\n        match(filterForm.edition, meta_info.edition)\n      ) {\n        filteredData.push(data)\n        filteredIndices.value.push(index)\n      }\n    })\n\n    totalFilteredCount.value = filteredData.length\n\n    // 排序\n    filteredData = sortData(filteredData)\n\n    // 确保季集选项排序\n    if (filterOptions.season.length > 0) {\n      sortSeasonOptions()\n    }\n\n    return filteredData\n  }\n\n  // 筛选卡片视图数据（分组）\n  function filterCardData(items: Context[] | undefined): SearchTorrent[] {\n    // 重置状态\n    filteredIndices.value = []\n\n    // 清空并重新初始化过滤选项\n    for (const key in filterOptions) {\n      filterOptions[key] = []\n    }\n\n    if (!items?.length) {\n      totalFilteredCount.value = 0\n      return []\n    }\n\n    // 数据分组\n    const groupMap = new Map<string, GroupedItem[]>()\n\n    items.forEach((item, index) => {\n      const { torrent_info, meta_info } = item\n      // init options\n      initOptions(item)\n      // group data\n      const key = `${meta_info.name}_${meta_info.resource_pix}_${meta_info.edition}_${meta_info.resource_team}_${meta_info.season_episode}_${torrent_info.size}`\n      const groupedItem = { data: item, originalIndex: index }\n      if (groupMap.has(key)) {\n        const group = groupMap.get(key)\n        group?.push(groupedItem)\n      } else {\n        groupMap.set(key, [groupedItem])\n      }\n    })\n\n    // 筛选数据\n    const filteredData: SearchTorrent[] = []\n    let matchCount = 0\n    // 临时存储：每个分组的第一个原始索引\n    const groupIndexMap = new Map<SearchTorrent, number>()\n\n    groupMap.forEach(value => {\n      if (value.length > 0) {\n        const matchData = value.filter(item => {\n          const { meta_info, torrent_info } = item.data\n          return (\n            match(filterForm.site, torrent_info.site_name) &&\n            match(filterForm.freeState, torrent_info.volume_factor) &&\n            match(filterForm.season, meta_info.season_episode) &&\n            match(filterForm.releaseGroup, meta_info.resource_team) &&\n            match(filterForm.videoCode, meta_info.video_encode) &&\n            match(filterForm.resolution, meta_info.resource_pix) &&\n            match(filterForm.edition, meta_info.edition)\n          )\n        })\n        if (matchData.length > 0) {\n          matchCount += matchData.length\n          const firstItem = matchData[0]\n          const firstData = cloneDeepWith(firstItem.data) as SearchTorrent\n          if (matchData.length > 1) firstData.more = matchData.slice(1).map(x => x.data)\n          filteredData.push(firstData)\n          // 存储该分组的第一个原始索引\n          groupIndexMap.set(firstData, firstItem.originalIndex)\n        }\n      }\n    })\n\n    totalFilteredCount.value = matchCount\n\n    // 排序数据\n    const sortedData = sortCardData(filteredData)\n\n    // 在排序后重新构建 filteredIndices，保持与排序后顺序一致\n    filteredIndices.value = sortedData.map(item => groupIndexMap.get(item) || 0)\n\n    // 确保季集选项排序\n    if (filterOptions.season.length > 0) {\n      sortSeasonOptions()\n    }\n\n    return sortedData\n  }\n\n  // 排序列表数据\n  function sortData(data: Context[]): Context[] {\n    const sortOrder = sortType.value === 'asc' ? 1 : -1\n\n    return data.sort((a, b) => {\n      let result = 0\n      switch (sortField.value) {\n        case 'site':\n          result = (a.torrent_info.site_name || '').localeCompare(b.torrent_info.site_name || '')\n          break\n        case 'size':\n          result = a.torrent_info.size - b.torrent_info.size\n          break\n        case 'seeder':\n          result = a.torrent_info.seeders - b.torrent_info.seeders\n          break\n        case 'publishTime':\n          result = new Date(a.torrent_info.pubdate || 0).getTime() - new Date(b.torrent_info.pubdate || 0).getTime()\n          break\n        case 'default':\n        default:\n          result = a.torrent_info.pri_order - b.torrent_info.pri_order\n          break\n      }\n      return result * sortOrder\n    })\n  }\n\n  // 排序卡片数据\n  function sortCardData(data: SearchTorrent[]): SearchTorrent[] {\n    if (sortField.value === 'default') {\n      return data\n    }\n    const sortOrder = sortType.value === 'asc' ? 1 : -1\n    return data.sort((a, b) => {\n      let result = 0\n      switch (sortField.value) {\n        case 'site':\n          result = (a.torrent_info.site_name || '').localeCompare(b.torrent_info.site_name || '')\n          break\n        case 'size':\n          result = (Number(a.torrent_info.size) || 0) - (Number(b.torrent_info.size) || 0)\n          break\n        case 'seeder':\n          result = (Number(a.torrent_info.seeders) || 0) - (Number(b.torrent_info.seeders) || 0)\n          break\n        case 'publishTime':\n          result = new Date(a.torrent_info.pubdate || 0).getTime() - new Date(b.torrent_info.pubdate || 0).getTime()\n          break\n      }\n      return result * sortOrder\n    })\n  }\n\n  // 计算已选择的过滤条件数量\n  const getFilterCount = computed(() => {\n    let count = 0\n    for (const key in filterForm) {\n      count += filterForm[key].length\n    }\n    return count\n  })\n\n  // 计算已选择的过滤条件\n  const getSelectedFilters = computed(() => {\n    const filters: Record<string, string[]> = {}\n    for (const key in filterForm) {\n      if (filterForm[key].length > 0) {\n        filters[key] = [...filterForm[key]]\n      }\n    }\n    return filters\n  })\n\n  // 移除单个过滤条件\n  function removeFilter(key: string, value: string) {\n    const index = filterForm[key].indexOf(value)\n    if (index !== -1) {\n      filterForm[key].splice(index, 1)\n    }\n  }\n\n  // 清除所有过滤条件\n  function clearAllFilters() {\n    for (const key in filterForm) {\n      filterForm[key] = []\n    }\n  }\n\n  // 清除某个过滤项\n  function clearFilter(key: string) {\n    filterForm[key] = []\n  }\n\n  // 全选某个过滤项\n  function selectAll(key: string) {\n    filterForm[key] = [...filterOptions[key]]\n  }\n\n  // 给定过滤类型返回不同图标\n  function getFilterIcon(key: string) {\n    const icons: Record<string, string> = {\n      site: 'mdi-server-network',\n      season: 'mdi-television-classic',\n      freeState: 'mdi-gift-outline',\n      resolution: 'mdi-monitor-screenshot',\n      videoCode: 'mdi-video-vintage',\n      edition: 'mdi-quality-high',\n      releaseGroup: 'mdi-account-group-outline',\n    }\n    return icons[key] || 'mdi-filter-variant'\n  }\n\n  // 处理排序图标点击\n  const handleSortIconClick = () => {\n    sortType.value = sortType.value === 'asc' ? 'desc' : 'asc'\n  }\n\n  // 获取筛选后的原始索引列表\n  function getFilteredIndices() {\n    return filteredIndices.value\n  }\n\n  // 检查是否有活动的筛选条件\n  function hasActiveFilters() {\n    for (const key in filterForm) {\n      if (filterForm[key] && filterForm[key].length > 0) {\n        return true\n      }\n    }\n    return false\n  }\n\n  // 获取当前筛选条件\n  function getFilterForm() {\n    const filters: Record<string, string[]> = {}\n    for (const key in filterForm) {\n      filters[key] = [...filterForm[key]]\n    }\n    return filters\n  }\n\n  // 设置筛选条件\n  function setFilterForm(filters: Record<string, string[]>) {\n    for (const key in filterForm) {\n      filterForm[key] = filters[key] ? [...filters[key]] : []\n    }\n  }\n\n  // 获取完整的筛选状态\n  function getFilterState(): FilterState {\n    return {\n      filterForm: getFilterForm(),\n      filterOptions: { ...filterOptions },\n      sortField: sortField.value,\n      sortType: sortType.value,\n    }\n  }\n\n  // 设置完整的筛选状态\n  function setFilterState(state: FilterState) {\n    setFilterForm(state.filterForm)\n    sortField.value = state.sortField\n    sortType.value = state.sortType\n  }\n\n  return {\n    // 状态\n    filterForm,\n    filterOptions,\n    sortField,\n    sortType,\n    filteredIndices,\n    totalFilteredCount,\n    // 标题映射\n    filterTitles,\n    sortTitles,\n    // 计算属性\n    getFilterCount,\n    getSelectedFilters,\n    // 筛选方法\n    filterRowData,\n    filterCardData,\n    // 操作方法\n    removeFilter,\n    clearAllFilters,\n    clearFilter,\n    selectAll,\n    getFilterIcon,\n    handleSortIconClick,\n    // 状态管理方法\n    getFilteredIndices,\n    hasActiveFilters,\n    getFilterForm,\n    setFilterForm,\n    getFilterState,\n    setFilterState,\n    sortSeasonOptions,\n  }\n}\n"
  },
  {
    "path": "src/composables/useVersionChecker.ts",
    "content": "import { ref, h } from 'vue'\nimport { useToast } from 'vue-toastification'\nimport { Workbox } from 'workbox-window'\nimport i18n from '@/plugins/i18n'\nimport VersionUpdateToast from '@/components/toast/VersionUpdateToast.vue'\n\n// 全局状态\nconst currentVersion = ref(__APP_VERSION__)\nlet isUpdateToastShown = false\nlet wb: Workbox | null = null\n\n/**\n * 普通刷新页面\n */\nexport const reloadPage = (): void => {\n  window.location.reload()\n}\n\n/**\n * 刷新页面并添加时间戳\n */\nexport const reloadWithTimestamp = (): void => {\n  const url = new URL(window.location.href)\n  url.searchParams.set('_t', Date.now().toString())\n  window.location.replace(url.pathname + url.search + url.hash)\n}\n\n/**\n * 清除所有缓存和 Service Worker\n */\nexport const clearCachesAndServiceWorker = async (): Promise<void> => {\n  try {\n    // 1. 清除所有缓存\n    if ('caches' in window) {\n      const cacheNames = await caches.keys()\n      await Promise.all(cacheNames.map(name => caches.delete(name)))\n      console.log('[VersionChecker] 已清除所有缓存')\n    }\n\n    // 2. 注销 Service Worker\n    if ('serviceWorker' in navigator) {\n      const registrations = await navigator.serviceWorker.getRegistrations()\n      await Promise.all(registrations.map(registration => registration.unregister()))\n      console.log('[VersionChecker] 已注销所有 Service Worker')\n    }\n  } catch (error) {\n    console.error('[VersionChecker] 清除缓存失败:', error)\n  }\n}\n\n/**\n * 清除缓存并刷新\n */\nconst clearCacheAndReload = async (): Promise<void> => {\n  await clearCachesAndServiceWorker()\n  reloadWithTimestamp()\n}\n\n/**\n * 版本检查 Composable\n *\n * 功能：\n * - 使用 Workbox 监听 Service Worker 更新\n * - 检查浏览器版本与服务端版本是否一致\n * - 显示持久化更新通知\n */\nexport function useVersionChecker() {\n  const toast = useToast()\n\n  /**\n   * 显示版本更新通知\n   * @param message 通知消息文本\n   * @param refreshText 按钮文本,不传则不显示按钮\n   * @param onRefresh 按钮点击事件\n   */\n  const showUpdateNotification = (message: string, refreshText?: string, onRefresh?: () => void): void => {\n    if (isUpdateToastShown) return\n    isUpdateToastShown = true\n    const component = h(VersionUpdateToast, {\n      message,\n      refreshText,\n      onRefresh,\n    })\n\n    toast.info(component, {\n      timeout: false, // 不自动消失\n      closeButton: false,\n      closeOnClick: false,\n      draggable: false,\n    })\n  }\n\n  // 初始化 Workbox\n  if (!wb && 'serviceWorker' in navigator) {\n    wb = new Workbox('/service-worker.js')\n\n    // Service Worker 激活事件 (install -> activate)\n    wb.addEventListener('activated', event => {\n      // 只有在更新时才显示通知\n      if (event.isUpdate) {\n        console.log('[VersionChecker] Service Worker 更新已就绪，等待用户刷新')\n\n        showUpdateNotification(i18n.global.t('common.swUpdateReady'), i18n.global.t('common.refresh'), reloadPage)\n      }\n    })\n\n    // 注册 Service Worker\n    wb.register()\n  }\n\n  /**\n   * 检查版本并在需要时显示更新通知\n   * @param latestVersion 服务端返回的最新版本号\n   */\n  const checkVersion = async (latestVersion: string): Promise<void> => {\n    // 如果已经显示过通知,说明已经检查过了\n    if (isUpdateToastShown) return\n\n    // 版本一致，无需操作\n    if (latestVersion === currentVersion.value) {\n      console.log('[VersionChecker] 版本号一致，无需操作')\n      return\n    }\n\n    console.log(`[VersionChecker] 检测到版本不一致: ${currentVersion.value} -> ${latestVersion}`)\n\n    // 尝试触发 Service Worker 更新检查\n    if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {\n      try {\n        const registration = await navigator.serviceWorker.getRegistration()\n        if (registration) {\n          console.log('[VersionChecker] 触发 Service Worker 更新检查...')\n\n          // 标记是否发现更新\n          let updateFound = false\n          const onUpdateFound = () => {\n            updateFound = true\n          }\n\n          // 监听 updatefound 事件\n          registration.addEventListener('updatefound', onUpdateFound, { once: true })\n\n          // 等待检查完成\n          await registration.update()\n\n          // 检查是否有更新正在进行\n          // 如果发现更新，或者正在安装/等待中，则直接返回（交由 SW activated 事件处理）\n          if (updateFound || registration.installing || registration.waiting) {\n            console.log('[VersionChecker] Service Worker 更新中...')\n            return\n          }\n\n          console.log('[VersionChecker] SW 无更新，但版本号不一致，可能是缓存问题')\n        }\n      } catch (error) {\n        console.log('[VersionChecker] Service Worker 更新检查失败:', error)\n        // 失败继续向下执行，显示通知\n      }\n    } else {\n      console.log('[VersionChecker] 无 Service Worker, 直接显示通知')\n    }\n\n    // 最终兜底：显示版本不一致通知（清除缓存）\n    showUpdateNotification(\n      i18n.global.t('common.versionMismatch'),\n      i18n.global.t('common.clearCache'),\n      clearCacheAndReload,\n    )\n  }\n\n  return {\n    checkVersion,\n  }\n}\n"
  },
  {
    "path": "src/layouts/blank.vue",
    "content": "<template>\n  <div class=\"layout-wrapper layout-blank\">\n    <RouterView />\n  </div>\n</template>\n\n<style>\n.layout-wrapper.layout-blank {\n  flex-direction: column;\n}\n</style>\n"
  },
  {
    "path": "src/layouts/components/DefaultLayout.vue",
    "content": "<script lang=\"ts\" setup>\nimport VerticalNavSectionTitle from '@/@layouts/components/VerticalNavSectionTitle.vue'\nimport VerticalNavLayout from '@layouts/components/VerticalNavLayout.vue'\nimport VerticalNavLink from '@layouts/components/VerticalNavLink.vue'\nimport Footer from '@/layouts/components/Footer.vue'\nimport UserNofification from '@/layouts/components/UserNotification.vue'\nimport SearchBar from '@/layouts/components/SearchBar.vue'\nimport ShortcutBar from '@/layouts/components/ShortcutBar.vue'\nimport UserProfile from '@/layouts/components/UserProfile.vue'\nimport QuickAccess from '@/layouts/components/QuickAccess.vue'\nimport HeaderTab from '@/layouts/components/HeaderTab.vue'\nimport { usePluginSidebarNavStore, useUserStore } from '@/stores'\nimport { getNavMenus } from '@/router/i18n-menu'\nimport { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'\nimport { NavMenu } from '@/@layouts/types'\nimport { useDisplay } from 'vuetify'\nimport { useI18n } from 'vue-i18n'\nimport { useRoute } from 'vue-router'\nimport { filterMenusByPermission } from '@/utils/permission'\nimport { onUnreadMessage } from '@/utils/badge'\nimport { usePullDownGesture } from '@/composables/usePullDownGesture'\nimport { usePWA } from '@/composables/usePWA'\nimport OfflinePage from '@/layouts/components/OfflinePage.vue'\nimport { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'\n\nconst display = useDisplay()\n// PWA模式检测\nconst { appMode } = usePWA()\nconst { t } = useI18n()\nconst route = useRoute()\n\n// 用户 Store\nconst userStore = useUserStore()\nconst pluginSidebarNavStore = usePluginSidebarNavStore()\n\n// 响应式的超级用户状态\nconst superUser = computed(() => userStore.superUser)\n\n// ShortcutBar 引用\nconst shortcutBarRef = ref<InstanceType<typeof ShortcutBar> | null>(null)\n\n// 获取用户权限信息\nconst userPermissions = computed(() => ({\n  is_superuser: userStore.superUser,\n  ...userStore.permissions,\n}))\n\n// 开始菜单项\nconst startMenus = ref<NavMenu[]>([])\n\n// 发现菜单项\nconst discoveryMenus = ref<NavMenu[]>([])\n\n// 订阅菜单项\nconst subscribeMenus = ref<NavMenu[]>([])\n\n// 整理菜单项\nconst organizeMenus = ref<NavMenu[]>([])\n\n// 系统菜单项\nconst systemMenus = ref<NavMenu[]>([])\n\n// 插件快速访问相关状态\nconst showPluginQuickAccess = ref(false)\n\n// 离线状态管理\nconst { setAppOffline, isOffline } = useGlobalOfflineStatus()\n\n// 动态标签页相关\n// 定义动态标签页类型\ninterface DynamicHeaderTab {\n  items: Array<{ title: string; icon: string; tab: string }>\n  modelValue: string\n  appendButtons?: Array<{\n    icon: string\n    color?: string | ComputedRef<string>\n    variant?: 'flat' | 'text' | 'elevated' | 'tonal' | 'outlined' | 'plain'\n    size?: string\n    class?: string\n    action?: () => void\n    show?: boolean | ComputedRef<boolean>\n    dataAttr?: string\n  }>\n  routePath?: string // 用于标识哪个路由注册的\n  onUpdateModelValue?: (value: string) => void // 用于通知值更新\n}\n\n// 提供动态标签页注册和获取的方法\nconst dynamicHeaderTab = ref<DynamicHeaderTab | null>(null)\n\n// 提供一个方法让其他组件注册动态标签页\nconst registerDynamicHeaderTab = (tab: DynamicHeaderTab) => {\n  // 保存注册标签页的路由路径\n  tab.routePath = route.path\n  // 强制更新，确保响应式系统能检测到变化\n  dynamicHeaderTab.value = { ...tab }\n}\n\n// 提供一个方法让其他组件取消注册动态标签页\nconst unregisterDynamicHeaderTab = () => {\n  dynamicHeaderTab.value = null\n}\n\n// 标签页值更新处理\nconst handleTabChange = (newValue: string) => {\n  if (dynamicHeaderTab.value) {\n    dynamicHeaderTab.value.modelValue = newValue\n    // 通知注册的页面更新值\n    if (dynamicHeaderTab.value.onUpdateModelValue) {\n      dynamicHeaderTab.value.onUpdateModelValue(newValue)\n    }\n  }\n}\n\n// 添加全局注册方法，解决注入不可用的问题\nif (typeof window !== 'undefined') {\n  // 确保在浏览器环境中\n  ;(window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__ = registerDynamicHeaderTab\n}\n\n// 提供给其他组件使用\nprovide('registerDynamicHeaderTab', registerDynamicHeaderTab)\nprovide('unregisterDynamicHeaderTab', unregisterDynamicHeaderTab)\n\n// 监听路由变化来清除动态标签页\nwatch(\n  () => route.path,\n  () => {\n    // 使用nextTick确保新页面的组件已经挂载完成\n    nextTick(() => {\n      // 如果当前标签页不属于新路由，则清除\n      if (dynamicHeaderTab.value && dynamicHeaderTab.value.routePath !== route.path) {\n        dynamicHeaderTab.value = null\n      }\n    })\n  },\n  { immediate: false },\n)\n\n// 显示动态标签页\nconst showDynamicHeaderTab = computed(() => {\n  return (\n    dynamicHeaderTab.value && dynamicHeaderTab.value.items.length > 0 && dynamicHeaderTab.value.routePath === route.path\n  )\n})\n\n// 在组件销毁时清理\nonUnmounted(() => {\n  dynamicHeaderTab.value = null\n  // 清理全局方法\n  if (typeof window !== 'undefined') {\n    delete (window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__\n  }\n})\n\n// 监听Service Worker消息\nconst handleServiceWorkerMessage = (event: MessageEvent) => {\n  if (event.data && event.data.type === 'OFFLINE_STATUS') {\n    if (event.data.offline) {\n      setAppOffline(true, t('common.serverConnectionFailed'))\n    } else {\n      setAppOffline(false)\n    }\n  }\n}\n\n// 检查是否可以使用下拉手势\nconst canUsePullGesture = () => {\n  // 检查是否在dashboard页面\n  const isDashboard = route.path === '/dashboard' || route.path === '/'\n  // 检查是否是管理员\n  const isAdmin = superUser.value\n  // 检查插件快速访问面板是否已显示\n  const quickAccessOpen = showPluginQuickAccess.value\n  // 检查是否离线\n  const offline = isOffline.value\n\n  return isDashboard && isAdmin && !quickAccessOpen && !offline\n}\n\n// 使用下拉手势 composable\nconst {\n  pullDistance,\n  contentTransform,\n  contentTransition,\n  showPullIndicator,\n  indicatorRotation,\n  indicatorOpacity,\n  indicatorTransform,\n  config: PULL_CONFIG,\n} = usePullDownGesture({\n  enabled: true,\n  canUsePullGesture,\n  onTrigger: () => {\n    showPluginQuickAccess.value = true\n  },\n})\n\n// 根据分类获取菜单列表\nconst getMenuList = (header: string) => {\n  // 使用国际化菜单\n  const menus = getNavMenus(t)\n  const filteredMenus = filterMenusByPermission(menus, userPermissions.value)\n  return filteredMenus.filter((item: NavMenu) => item.header === header)\n}\n\n// 返回上一页\nfunction goBack() {\n  history.back()\n}\n\n// 处理未读消息事件\nfunction handleUnreadMessage(count: number) {\n  if (superUser.value && count > 0) {\n    // 延迟一点时间确保组件已渲染\n    setTimeout(() => {\n      if (shortcutBarRef.value && typeof shortcutBarRef.value.openMessageDialog === 'function') {\n        shortcutBarRef.value.openMessageDialog()\n      }\n    }, 500)\n  }\n}\n\n// 关闭插件快速访问\nfunction handleClosePluginQuickAccess() {\n  showPluginQuickAccess.value = false\n}\n\n// 点击插件后关闭\nfunction handlePluginClick() {\n  showPluginQuickAccess.value = false\n}\n\nfunction appendPluginSidebarMenus() {\n  for (const { navMenu, section } of filterPluginSidebarNavEntries(\n    pluginSidebarNavStore.items,\n    t,\n    userPermissions.value,\n  )) {\n    switch (section) {\n      case 'start':\n        startMenus.value.push(navMenu)\n        break\n      case 'discovery':\n        discoveryMenus.value.push(navMenu)\n        break\n      case 'subscribe':\n        subscribeMenus.value.push(navMenu)\n        break\n      case 'organize':\n        organizeMenus.value.push(navMenu)\n        break\n      case 'system':\n      default:\n        systemMenus.value.push(navMenu)\n        break\n    }\n  }\n}\n\nonMounted(async () => {\n  // 获取菜单列表\n  startMenus.value = getMenuList(t('menu.start'))\n  discoveryMenus.value = getMenuList(t('menu.discovery'))\n  subscribeMenus.value = getMenuList(t('menu.subscribe'))\n  organizeMenus.value = getMenuList(t('menu.organize'))\n  systemMenus.value = getMenuList(t('menu.system'))\n\n  await pluginSidebarNavStore.ensureSidebarNav()\n  appendPluginSidebarMenus()\n\n  // 监听全局未读消息事件\n  const unsubscribe = onUnreadMessage(handleUnreadMessage)\n\n  // 监听Service Worker消息\n  if ('serviceWorker' in navigator) {\n    navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)\n  }\n\n  // 组件卸载时清理监听\n  onBeforeUnmount(() => {\n    unsubscribe()\n    if ('serviceWorker' in navigator) {\n      navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)\n    }\n  })\n})\n</script>\n\n<template>\n  <!-- 👉 Offline Page -->\n  <OfflinePage />\n\n  <!-- 👉 Pull Down Indicator -->\n  <div\n    v-if=\"appMode && showPullIndicator\"\n    class=\"pull-indicator\"\n    :style=\"{\n      opacity: indicatorOpacity,\n      transform: indicatorTransform,\n    }\"\n  >\n    <div\n      class=\"indicator-icon\"\n      :style=\"{\n        transform: `scale(${\n          1 + Math.min((pullDistance - PULL_CONFIG.SHOW_INDICATOR) / PULL_CONFIG.MAX_PULL_DISTANCE, 0.5) * 0.3\n        }) rotate(${indicatorRotation}deg)`,\n      }\"\n    >\n      <VIcon\n        icon=\"mdi-gesture-swipe-down\"\n        size=\"24\"\n        :color=\"pullDistance >= PULL_CONFIG.TRIGGER_THRESHOLD ? 'success' : 'primary'\"\n      />\n    </div>\n  </div>\n  <VerticalNavLayout :style=\"{ '--navbar-tab-height': showDynamicHeaderTab ? '2.5rem' : '0px' }\">\n    <!-- 👉 Navbar -->\n    <template #navbar=\"{ toggleVerticalOverlayNavActive }\">\n      <div class=\"d-flex h-14 align-center mx-1\">\n        <!-- 👉 Vertical Nav Toggle -->\n        <IconBtn v-if=\"!appMode && display.mdAndDown.value\" class=\"ms-n2\" @click=\"toggleVerticalOverlayNavActive(true)\">\n          <VIcon icon=\"mdi-menu\" />\n        </IconBtn>\n        <!-- 👉 Back Button -->\n        <IconBtn v-if=\"appMode\" class=\"ms-n2\" @click=\"goBack\">\n          <VIcon icon=\"mdi-arrow-left\" size=\"32\" />\n        </IconBtn>\n        <!-- 👉 Search Bar -->\n        <SearchBar />\n        <!-- 👉 Spacer -->\n        <VSpacer />\n        <!-- 👉 Shortcuts -->\n        <ShortcutBar v-if=\"superUser\" ref=\"shortcutBarRef\" />\n        <!-- 👉 Notification -->\n        <UserNofification />\n        <!-- 👉 UserProfile -->\n        <UserProfile />\n      </div>\n    </template>\n\n    <template #vertical-nav-content>\n      <VerticalNavLink v-for=\"item in startMenus\" :item=\"item\" />\n      <!-- 👉 发现 -->\n      <VerticalNavSectionTitle\n        v-if=\"discoveryMenus.length > 0\"\n        :item=\"{\n          heading: t('menu.discovery'),\n        }\"\n      />\n      <VerticalNavLink v-for=\"item in discoveryMenus\" :item=\"item\" />\n      <!-- 👉 订阅 -->\n      <VerticalNavSectionTitle\n        v-if=\"subscribeMenus.length > 0\"\n        :item=\"{\n          heading: t('menu.subscribe'),\n        }\"\n      />\n      <VerticalNavLink v-for=\"item in subscribeMenus\" :item=\"item\" />\n      <!-- 👉 整理 -->\n      <VerticalNavSectionTitle\n        v-if=\"organizeMenus.length > 0\"\n        :item=\"{\n          heading: t('menu.organize'),\n        }\"\n      />\n      <VerticalNavLink v-for=\"item in organizeMenus\" :item=\"item\" />\n      <!-- 👉 系统 -->\n      <VerticalNavSectionTitle\n        v-if=\"systemMenus.length > 0\"\n        :item=\"{\n          heading: t('menu.system'),\n        }\"\n      />\n      <VerticalNavLink v-for=\"item in systemMenus\" :item=\"item\" />\n    </template>\n\n    <template #after-vertical-nav-items />\n\n    <!-- 👉 Dynamic Header Tab -->\n    <template #dynamic-header-tab>\n      <div v-if=\"showDynamicHeaderTab\">\n        <HeaderTab\n          :items=\"dynamicHeaderTab!.items\"\n          :model-value=\"dynamicHeaderTab!.modelValue\"\n          @update:model-value=\"handleTabChange\"\n        >\n          <template #append>\n            <template v-for=\"button in dynamicHeaderTab!.appendButtons\" :key=\"button.icon\">\n              <VBtn\n                v-if=\"typeof button.show === 'boolean' ? button.show !== false : (button.show as any)?.value !== false\"\n                :icon=\"button.icon\"\n                :variant=\"button.variant || 'text'\"\n                :color=\"typeof button.color === 'string' ? button.color : (button.color as any)?.value || 'gray'\"\n                :size=\"button.size || 'default'\"\n                :class=\"button.class || 'settings-icon-button'\"\n                :data-menu-activator=\"button.dataAttr\"\n                @click=\"button.action\"\n              />\n            </template>\n          </template>\n        </HeaderTab>\n      </div>\n    </template>\n\n    <!-- 👉 下拉跟随动画 -->\n    <div\n      class=\"main-content-wrapper\"\n      :style=\"{\n        transform: contentTransform,\n        transition: contentTransition,\n        paddingTop: showDynamicHeaderTab ? '3rem' : '0px',\n      }\"\n    >\n      <slot />\n    </div>\n\n    <!-- 👉 Footer -->\n    <template #footer>\n      <Footer :show-nav=\"!showPluginQuickAccess\" />\n    </template>\n  </VerticalNavLayout>\n\n  <!-- 👉 Plugin Quick Access -->\n  <QuickAccess\n    v-if=\"appMode\"\n    :visible=\"showPluginQuickAccess\"\n    :pull-distance=\"pullDistance\"\n    @close=\"handleClosePluginQuickAccess\"\n    @plugin-click=\"handlePluginClick\"\n  />\n</template>\n\n<style lang=\"scss\" scoped>\n.main-content-wrapper {\n  backface-visibility: hidden;\n  block-size: 100%;\n  inline-size: 100%;\n  transform: translateZ(0);\n  will-change: transform;\n}\n\n.pull-indicator {\n  position: fixed;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  padding: 6px;\n  border-radius: 50%;\n  backdrop-filter: blur(20px);\n  background: rgba(var(--v-theme-surface), 0.3);\n  box-shadow: 0 1px 2px rgba(0, 0, 0, 10%), 0 1px 3px rgba(0, 0, 0, 6%);\n  inset-block-start: 80px;\n  inset-inline-start: 50%;\n  pointer-events: none;\n  transform: translateX(-50%);\n  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n.indicator-icon {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border-radius: 50%;\n  background: rgba(var(--v-theme-primary), 0.08);\n  block-size: 40px;\n  inline-size: 40px;\n  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n/* 透明主题适配 */\nhtml[class*='transparent'] .pull-indicator,\nhtml[class*='mica'] .pull-indicator,\nhtml[class*='acrylic'] .pull-indicator {\n  border: 1px solid rgba(255, 255, 255, 20%);\n  background: rgba(255, 255, 255, 95%);\n  box-shadow: 0 8px 32px rgba(0, 0, 0, 12%), 0 4px 16px rgba(0, 0, 0, 8%);\n}\n\nhtml[class*='transparent'] .indicator-icon,\nhtml[class*='mica'] .indicator-icon,\nhtml[class*='acrylic'] .indicator-icon {\n  background: rgba(var(--v-theme-primary), 0.12);\n}\n\nhtml[data-theme='dark'][class*='transparent'] .pull-indicator,\nhtml[data-theme='dark'][class*='mica'] .pull-indicator,\nhtml[data-theme='dark'][class*='acrylic'] .pull-indicator {\n  border: 1px solid rgba(255, 255, 255, 10%);\n  background: rgba(18, 18, 18, 95%);\n  box-shadow: 0 8px 32px rgba(0, 0, 0, 30%), 0 4px 16px rgba(0, 0, 0, 20%);\n}\n\nhtml[data-theme='dark'][class*='transparent'] .indicator-icon,\nhtml[data-theme='dark'][class*='mica'] .indicator-icon,\nhtml[data-theme='dark'][class*='acrylic'] .indicator-icon {\n  background: rgba(var(--v-theme-primary), 0.15);\n}\n</style>\n"
  },
  {
    "path": "src/layouts/components/DropzoneBackground.vue",
    "content": "<script lang=\"ts\" setup>\nimport { Background } from '@vue-flow/background'\n</script>\n\n<template>\n  <div class=\"dropzone-background\">\n    <Background :size=\"2\" :gap=\"20\" pattern-color=\"#BDBDBD\" />\n    <div class=\"overlay\">\n      <slot />\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "src/layouts/components/Footer.vue",
    "content": "<script setup lang=\"ts\">\nimport { getNavMenus } from '@/router/i18n-menu'\nimport { useDisplay } from 'vuetify'\nimport { NavMenu } from '@/@layouts/types'\nimport { useI18n } from 'vue-i18n'\nimport { useUserStore } from '@/stores'\nimport { filterMenusByPermission } from '@/utils/permission'\nimport { usePWA } from '@/composables/usePWA'\nimport type { DynamicButtonMenuItem } from '@/composables/useDynamicButton'\n\n// 是否显示的输入参数\ndefineProps({\n  showNav: {\n    type: Boolean,\n    default: true,\n  },\n})\n\nconst display = useDisplay()\n// PWA模式检测\nconst { appMode } = usePWA()\nconst { t, locale } = useI18n()\n\n// 判断当前是否为英文环境\nconst isEnglish = computed(() => locale.value === 'en-US')\n\nconst route = useRoute()\n\n// 用户Store\nconst userStore = useUserStore()\n\n// 获取用户权限信息\nconst userPermissions = computed(() => {\n  // 确保用户已认证且信息已加载\n  if (!userStore || userStore.userID === -1) {\n    return {\n      is_superuser: false,\n      discovery: false,\n      search: false,\n      subscribe: false,\n      manage: false,\n    }\n  }\n\n  return {\n    is_superuser: userStore.superUser,\n    ...userStore.permissions,\n  }\n})\n\n// 获取导航菜单\nconst navMenus = computed(() => {\n  const allMenus = getNavMenus(t)\n  return filterMenusByPermission(allMenus, userPermissions.value)\n})\n\n// 根据当前路径获取匹配的菜单路径\nfunction getMenuPathFromRoute(path: string): string {\n  const matchedMenu = navMenus.value.find((menu: NavMenu) => menu.footer === true && path.startsWith(menu.to as string))\n  return matchedMenu ? (matchedMenu.to as string) : '/apps'\n}\n\n// 当前选中的菜单，初始值基于当前路由\nconst currentMenu = ref<string>(getMenuPathFromRoute(route.path))\n\n// 过滤出底部菜单项\nconst footerMenus = computed(() => {\n  // 获取所有有权限的菜单\n  const allAuthorizedMenus = navMenus.value\n\n  // 优先获取有 footer: true 属性的菜单\n  const footerMenusWithProperty = allAuthorizedMenus.filter((menu: NavMenu) => menu.footer === true)\n\n  // 设置期望的底部菜单数量（不包括\"更多\"按钮）\n  // 一般来说，底部导航栏显示 3-4 个主要功能比较合适\n  const expectedFooterMenuCount = 3\n\n  // 如果有 footer 属性的菜单已经足够，优先显示它们\n  if (footerMenusWithProperty.length >= expectedFooterMenuCount) {\n    return footerMenusWithProperty.slice(0, expectedFooterMenuCount)\n  }\n\n  // 如果不够，从没有 footer 属性或 footer 为 false 的菜单中补充\n  // 优先选择一些常用的功能菜单\n  const nonFooterMenus = allAuthorizedMenus.filter(\n    (menu: NavMenu) =>\n      menu.footer !== true &&\n      // 排除已经在 footerMenusWithProperty 中的菜单\n      !footerMenusWithProperty.some(footerMenu => footerMenu.to === menu.to),\n  )\n\n  // 计算还需要多少个菜单\n  const needCount = expectedFooterMenuCount - footerMenusWithProperty.length\n\n  // 合并菜单：优先显示有 footer 属性的，然后按菜单定义顺序添加其他菜单\n  let finalMenus = [...footerMenusWithProperty, ...nonFooterMenus.slice(0, needCount)]\n\n  // 确保至少有一个菜单显示，如果都没有权限，则显示第一个有权限的菜单\n  if (finalMenus.length === 0 && allAuthorizedMenus.length > 0) {\n    finalMenus = [allAuthorizedMenus[0]]\n  }\n\n  return finalMenus\n})\n\n// 监听路由变化来更新currentMenu\nwatch(\n  () => route.path,\n  newPath => {\n    currentMenu.value = getMenuPathFromRoute(newPath)\n    // 当路由变化时，清除动态按钮\n    dynamicButton.value = null\n  },\n  { immediate: false },\n)\n\n// 动态按钮相关\n// 定义动态按钮类型\ninterface DynamicButton {\n  icon: string\n  action: () => void\n  show: boolean\n  routePath?: string // 添加路径属性，用于标识哪个路由注册的\n  menuItems?: DynamicButtonMenuItem[]\n}\n\n// 提供动态按钮注册和获取的方法\nconst dynamicButton = ref<DynamicButton | null>(null)\n\n// 提供一个方法让其他组件注册动态按钮\nconst registerDynamicButton = (button: DynamicButton) => {\n  // 保存注册按钮的路由路径\n  button.routePath = route.path\n  dynamicButton.value = button\n}\n\n// 提供一个方法让其他组件取消注册动态按钮\nconst unregisterDynamicButton = () => {\n  dynamicButton.value = null\n}\n\n// 添加全局注册方法，解决注入不可用的问题\nif (typeof window !== 'undefined') {\n  // 确保在浏览器环境中\n  ;(window as any).__VUE_INJECT_DYNAMIC_BUTTON__ = registerDynamicButton\n  ;(window as any).__VUE_UNINJECT_DYNAMIC_BUTTON__ = unregisterDynamicButton\n}\n\n// 提供给其他组件使用\nprovide('registerDynamicButton', registerDynamicButton)\nprovide('unregisterDynamicButton', unregisterDynamicButton)\nprovide('dynamicButton', dynamicButton)\n\n// 在组件销毁时清理\nonUnmounted(() => {\n  dynamicButton.value = null\n  // 清理全局方法\n  if (typeof window !== 'undefined') {\n    delete (window as any).__VUE_INJECT_DYNAMIC_BUTTON__\n    delete (window as any).__VUE_UNINJECT_DYNAMIC_BUTTON__\n  }\n})\n\n// 显示动态按钮\nconst showDynamicButton = computed(() => {\n  return (\n    dynamicButton.value &&\n    dynamicButton.value.show &&\n    // 确保只在注册的路由路径下显示按钮\n    (!dynamicButton.value.routePath || dynamicButton.value.routePath === route.path)\n  )\n})\n\nconst hasDynamicButtonMenu = computed(() => Boolean(dynamicButton.value?.menuItems?.length))\n\nconst legacyDynamicMenuTitleKeyMap: Record<string, string> = {\n  'components.subscribeHistory.title': 'dialog.subscribeHistory.title',\n  'components.subscribeEdit.titleDefault': 'dialog.subscribeEdit.titleDefault',\n  'components.transferQueue.title': 'dialog.transferQueue.title',\n  'components.pluginMarketSetting.title': 'dialog.pluginMarketSetting.title',\n}\n\nfunction resolveDynamicMenuItemTitle(item: DynamicButtonMenuItem) {\n  if (item.titleKey) {\n    return t(item.titleKey, item.titleParams as any)\n  }\n\n  if (!item.title) {\n    return ''\n  }\n\n  const normalizedTitleKey = legacyDynamicMenuTitleKeyMap[item.title] || item.title\n  const looksLikeI18nKey = /^[a-z0-9_-]+(?:\\.[a-z0-9_-]+)+$/i.test(normalizedTitleKey)\n\n  return looksLikeI18nKey ? t(normalizedTitleKey, item.titleParams as any) : item.title\n}\n</script>\n\n<template>\n  <Teleport v-if=\"appMode && showNav\" to=\"body\">\n    <div class=\"footer-nav-container\">\n      <TransitionGroup name=\"footer-nav\" tag=\"div\" class=\"footer-nav-group\">\n        <VCard key=\"main-nav\" elevation=\"3\" class=\"footer-nav-card border\" rounded=\"pill\">\n          <VCardText class=\"footer-card-content\">\n            <!-- 添加指示器 -->\n            <div ref=\"indicator\" class=\"nav-indicator\"></div>\n            <VBtnToggle class=\"footer-btn-group\" :mandatory=\"true\" v-model=\"currentMenu\">\n              <!-- 遍历底部菜单项 -->\n              <VBtn\n                v-for=\"menu in footerMenus\"\n                :key=\"menu.to\"\n                :to=\"menu.to\"\n                :variant=\"currentMenu === menu.to ? 'text' : 'plain'\"\n                color=\"primary\"\n                :ripple=\"false\"\n                class=\"footer-nav-btn\"\n                rounded=\"pill\"\n                :class=\"{ 'footer-nav-btn-active': currentMenu === menu.to }\"\n                :value=\"menu.to\"\n              >\n                <div class=\"btn-content\">\n                  <VIcon :icon=\"menu.icon\" size=\"32\"></VIcon>\n                  <span v-if=\"!isEnglish\" class=\"text-xs\">{{ menu.title }}</span>\n                </div>\n              </VBtn>\n\n              <!-- 更多按钮 -->\n              <VBtn\n                :variant=\"currentMenu === '/apps' ? 'text' : 'plain'\"\n                color=\"primary\"\n                :ripple=\"false\"\n                to=\"/apps\"\n                rounded=\"pill\"\n                class=\"footer-nav-btn\"\n                :class=\"{ 'footer-nav-btn-active': currentMenu === '/apps' }\"\n                value=\"/apps\"\n              >\n                <div class=\"btn-content\">\n                  <VIcon icon=\"mdi-dots-horizontal\" size=\"32\"></VIcon>\n                  <span v-if=\"!isEnglish\" class=\"text-xs\">{{ t('nav.more') }}</span>\n                </div>\n              </VBtn>\n            </VBtnToggle>\n          </VCardText>\n        </VCard>\n        <VCard\n          v-if=\"showDynamicButton\"\n          key=\"dynamic-btn\"\n          elevation=\"3\"\n          class=\"footer-nav-card dynamic-btn-card border\"\n          rounded=\"pill\"\n        >\n          <VCardText class=\"footer-card-content\">\n            <!-- 各页面的动态按钮 -->\n            <div class=\"dynamic-btn-activator\">\n              <VBtn\n                icon\n                variant=\"text\"\n                :ripple=\"false\"\n                @click=\"!hasDynamicButtonMenu && dynamicButton?.action()\"\n                rounded=\"pill\"\n                class=\"footer-nav-btn\"\n              >\n                <VIcon\n                  color=\"secondary\"\n                  :icon=\"hasDynamicButtonMenu ? 'mdi-chevron-up' : dynamicButton?.icon || 'mdi-plus'\"\n                  size=\"28\"\n                ></VIcon>\n              </VBtn>\n              <VMenu v-if=\"hasDynamicButtonMenu\" activator=\"parent\" location=\"top end\" close-on-content-click>\n                <VList>\n                  <VListItem\n                    v-for=\"(item, index) in dynamicButton?.menuItems\"\n                    :key=\"item.titleKey || item.title || index\"\n                    :base-color=\"item.color\"\n                    @click=\"item.action()\"\n                  >\n                    <template #prepend>\n                      <VIcon v-if=\"item.icon\" :icon=\"item.icon\" />\n                    </template>\n                    <VListItemTitle>{{ resolveDynamicMenuItemTitle(item) }}</VListItemTitle>\n                  </VListItem>\n                </VList>\n              </VMenu>\n            </div>\n          </VCardText>\n        </VCard>\n      </TransitionGroup>\n    </div>\n  </Teleport>\n</template>\n\n<style lang=\"scss\">\n.footer-nav-container {\n  position: fixed;\n  z-index: 1999;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  inset-block-end: 0;\n  inset-inline: 0;\n  padding-block-end: calc(6px + env(safe-area-inset-bottom, 0px));\n  pointer-events: none;\n}\n\n.footer-nav-group {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n\n  // 按钮卡片之间的间距\n  > .v-card + .v-card {\n    margin-inline-start: 2px; // 减少间距\n  }\n}\n\n.footer-nav-card {\n  position: relative;\n  overflow: hidden;\n  backdrop-filter: blur(24px);\n  background-color: rgba(var(--v-theme-surface), 0.6);\n  pointer-events: auto;\n  transition: all 0.5s cubic-bezier(0.25, 1, 0.5, 1);\n  will-change: transform, max-inline-size, opacity;\n\n  // 透明主题下的特殊样式\n  .v-theme--transparent & {\n    backdrop-filter: blur(var(--transparent-blur-heavy, 16px));\n    background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy, 0.5));\n  }\n\n  .v-btn-toggle {\n    block-size: auto;\n    min-block-size: 56px;\n  }\n}\n\n.footer-card-content {\n  position: relative;\n  padding-block: 4px;\n  padding-inline: 6px;\n}\n\n.footer-btn-group {\n  position: relative;\n  display: flex;\n  justify-content: space-around;\n  border: none;\n  background-color: transparent;\n  inline-size: 100%;\n}\n\n.footer-nav-btn {\n  position: relative;\n  display: flex;\n  flex-direction: column;\n  flex-grow: 1;\n  align-items: center;\n  justify-content: center;\n  background-color: transparent;\n  block-size: 48px;\n\n  &.v-btn--active {\n    background-color: transparent;\n    box-shadow: none;\n  }\n\n  .btn-content {\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    justify-content: center;\n    inline-size: 100%;\n\n    span {\n      overflow: hidden;\n      text-overflow: ellipsis;\n      transform-origin: center;\n      white-space: nowrap;\n    }\n  }\n}\n\n// 动态按钮卡片样式\n.dynamic-btn-card {\n  block-size: auto;\n  inline-size: auto;\n  max-inline-size: 60px;\n  min-block-size: 0;\n\n  .footer-card-content {\n    padding: 3px;\n  }\n\n  .footer-nav-btn {\n    padding: 0;\n    block-size: 40px;\n    inline-size: 40px;\n    min-inline-size: 40px;\n\n    .btn-content {\n      margin: 0;\n    }\n\n    .v-icon {\n      margin-block-end: 0;\n    }\n  }\n}\n\n// 底部导航动画\n.footer-nav-enter-active,\n.footer-nav-leave-active {\n  overflow: hidden;\n  transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);\n}\n\n.footer-nav-enter-from,\n.footer-nav-leave-to {\n  padding: 0 !important;\n  border-width: 0 !important;\n  margin-inline-start: 0 !important;\n  max-inline-size: 0 !important;\n  opacity: 0;\n  transform: translateX(20px);\n}\n\n.footer-nav-move {\n  transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1);\n}\n\n@keyframes fade-in {\n  from {\n    opacity: 0;\n    transform: translateX(-50%) translateY(10px);\n  }\n\n  to {\n    opacity: 1;\n    transform: translateX(-50%) translateY(0);\n  }\n}\n</style>\n"
  },
  {
    "path": "src/layouts/components/HeaderTab.vue",
    "content": "<script setup lang=\"ts\">\nimport { useTabStateRestore } from '@/composables/useStateRestore'\nimport { isMobileDevice } from '@/@core/utils/navigator'\n\nconst props = defineProps({\n  modelValue: {\n    type: String,\n    default: '',\n  },\n  items: {\n    type: Array as PropType<{ title: string; icon: string; tab: string }[]>,\n    default: () => [],\n  },\n  // 新增：是否启用PWA状态恢复\n  enableStateRestore: {\n    type: Boolean,\n    default: true,\n  },\n})\n\nconst emit = defineEmits(['update:modelValue'])\n\n// 集成PWA状态恢复功能\nconst pwaTabState = props.enableStateRestore ? useTabStateRestore(props.modelValue) : null\n\n// 使用PWA状态恢复的activeTab或本地状态\nconst currentValue = ref(pwaTabState?.activeTab.value || props.modelValue)\n\n// 监听currentValue变化，同时更新PWA状态和父组件\nwatch(currentValue, newVal => {\n  emit('update:modelValue', newVal)\n  // 如果启用了PWA状态恢复，同步更新PWA状态\n  if (pwaTabState && newVal) {\n    pwaTabState.activeTab.value = newVal\n  }\n})\n\n// 监听父组件的modelValue变化\nwatch(\n  () => props.modelValue,\n  value => {\n    currentValue.value = value\n    // 同步到PWA状态\n    if (pwaTabState && value) {\n      pwaTabState.activeTab.value = value\n    }\n  },\n)\n\n// 如果启用了PWA状态恢复，监听PWA状态变化\nif (pwaTabState) {\n  watch(pwaTabState.activeTab, newTab => {\n    if (newTab && newTab !== currentValue.value) {\n      currentValue.value = newTab\n      emit('update:modelValue', newTab)\n    }\n  })\n}\n\n// Ref for the tabs container\nconst tabsContainerRef = ref<HTMLElement | null>(null)\n// State for showing the scroll indicator\nconst showTabsScrollIndicator = ref(false)\n// State for showing the scroll buttons\nconst showLeftButton = ref(false)\nconst showRightButton = ref(false)\n\n// Function to scroll the tabs\nconst scrollTabs = (direction: 'left' | 'right') => {\n  const el = tabsContainerRef.value\n  if (!el) return\n\n  // 可以根据需要调整滚动量\n  const scrollAmount = 200\n  const scrollPosition = direction === 'left' ? el.scrollLeft - scrollAmount : el.scrollLeft + scrollAmount\n\n  el.scrollTo({\n    left: scrollPosition,\n    behavior: 'smooth',\n  })\n\n  // 滚动完成后更新指示器状态\n  setTimeout(() => {\n    updateTabsIndicator()\n  }, 300) // 等待滚动动画完成\n}\n\n// Function to check and update the indicator state\nconst updateTabsIndicator = () => {\n  const el = tabsContainerRef.value\n  if (!el) return\n\n  // 在移动端不显示滚动指示器\n  const isMobile = isMobileDevice()\n\n  const tolerance = 1 // Allow 1px tolerance\n  const hasOverflow = el.scrollWidth > el.clientWidth + tolerance\n  const isScrolledToEnd = el.scrollLeft + el.clientWidth >= el.scrollWidth - tolerance\n  const isScrolledToStart = el.scrollLeft <= tolerance\n\n  showTabsScrollIndicator.value = hasOverflow && !isScrolledToEnd && !isMobile\n  showLeftButton.value = hasOverflow && !isScrolledToStart && !isMobile\n  showRightButton.value = hasOverflow && !isScrolledToEnd && !isMobile\n}\n\n// Debounce resize handler\nlet resizeTimeout: ReturnType<typeof setTimeout> | null = null\nconst handleResize = () => {\n  if (resizeTimeout) clearTimeout(resizeTimeout)\n  resizeTimeout = setTimeout(() => {\n    updateTabsIndicator()\n  }, 150)\n}\n\nonMounted(async () => {\n  // Add resize listener for tabs indicator\n  window.addEventListener('resize', handleResize)\n  // Add scroll listener for tabs container\n  tabsContainerRef.value?.addEventListener('scroll', updateTabsIndicator)\n  // Initial check for tabs indicator after DOM update\n  await nextTick() // Ensure element is rendered\n  updateTabsIndicator()\n})\n\nonUnmounted(() => {\n  // Remove resize listener\n  window.removeEventListener('resize', handleResize)\n  // Remove tabs scroll listener\n  tabsContainerRef.value?.removeEventListener('scroll', updateTabsIndicator)\n})\n</script>\n<template>\n  <div class=\"tab-header\">\n    <VBtn v-if=\"showLeftButton\" class=\"scroll-button left-button\" @click=\"scrollTabs('left')\" variant=\"text\" icon>\n      <VIcon icon=\"tabler-chevron-left\" size=\"small\" color=\"secondary\" />\n    </VBtn>\n\n    <div ref=\"tabsContainerRef\" class=\"header-tabs\" :class=\"{ 'show-indicator': showTabsScrollIndicator }\">\n      <div\n        v-for=\"(item, index) in items\"\n        :key=\"index\"\n        class=\"header-tab\"\n        :class=\"{ 'active': currentValue === item.tab }\"\n        @click=\"currentValue = item.tab\"\n      >\n        <VIcon v-if=\"item.icon\" :icon=\"item.icon\" size=\"small\" class=\"header-tab-icon\" />\n        <span>{{ item.title }}</span>\n      </div>\n    </div>\n\n    <VBtn v-if=\"showRightButton\" class=\"scroll-button right-button\" @click=\"scrollTabs('right')\" variant=\"text\" icon>\n      <VIcon icon=\"tabler-chevron-right\" size=\"small\" color=\"secondary\" />\n    </VBtn>\n\n    <slot name=\"append\" />\n  </div>\n</template>\n<style scoped lang=\"scss\">\n.tab-header {\n  position: relative;\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  transition: all 0.3s ease;\n}\n\n.scroll-button {\n  z-index: 2;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border: none;\n  border-radius: 50%;\n  block-size: 28px;\n  cursor: pointer;\n  inline-size: 28px;\n  outline: none;\n  transition: background-color 0.2s ease;\n\n  &.left-button {\n    margin-inline-end: 6px;\n  }\n\n  &.right-button {\n    margin-inline-start: 6px;\n  }\n\n  // 在移动端隐藏滚动按钮\n  @media (width <= 768px) {\n    display: none !important;\n  }\n}\n\n.header-tabs {\n  position: relative; // Needed for pseudo-element positioning\n  display: flex;\n  flex-grow: 1;\n  gap: 12px;\n\n  // Clip content that overflows, useful with padding\n  mask-image: linear-gradient(to right, black 95%, transparent 100%);\n  min-inline-size: 0;\n  overflow-x: auto;\n  padding-block: 4px;\n  padding-inline: 0;\n\n  // Add padding-right to make space for the indicator visually\n  padding-inline-end: 20px;\n  scrollbar-width: none;\n\n  &::-webkit-scrollbar {\n    display: none;\n  }\n\n  // Gradient indicator pseudo-element\n  &::after {\n    position: absolute;\n    z-index: 1; // Ensure it's above the tabs but below other header elements if needed\n    background: linear-gradient(to left, rgba(var(--v-theme-background), 10.3) 30%, transparent);\n    content: '';\n    inline-size: 40px; // Width of the fade effect\n    inset-block: 0;\n    inset-inline-end: 0;\n    opacity: 0; // Hidden by default\n    pointer-events: none; // Allow interaction with content behind it\n    transition: opacity 0.2s ease-in-out;\n  }\n\n  // Show indicator when class is applied\n  &.show-indicator::after {\n    opacity: 1;\n  }\n\n  // 在移动端隐藏渐变指示器\n  @media (width <= 768px) {\n    &::after {\n      display: none !important;\n    }\n  }\n}\n\n.header-tab-icon {\n  color: rgba(var(--v-theme-on-background), 0.6);\n  margin-inline-end: 6px;\n  text-shadow: 0 1px 2px rgba(0, 0, 0, 10%);\n  transition: color 0.2s ease;\n}\n\n.header-tab {\n  position: relative;\n  display: flex;\n  align-items: center;\n  border-radius: 20px;\n  background-color: transparent;\n  color: rgba(var(--v-theme-on-background), 0.7);\n  cursor: pointer;\n  font-size: 0.9rem;\n  font-weight: 600;\n  padding-block: 6px;\n  padding-inline: 14px;\n  text-shadow: 0 1px 3px rgba(0, 0, 0, 10%);\n  transition: all 0.2s ease;\n  white-space: nowrap;\n\n  &::after {\n    position: absolute;\n    border-radius: 3px;\n    background-color: rgb(var(--v-theme-primary));\n    block-size: 3px;\n    content: '';\n    inline-size: 70%;\n    inset-block-end: -4px;\n    inset-inline-start: 50%;\n    transform: translateX(-50%) scaleX(0);\n    transition: transform 0.2s ease;\n  }\n\n  &.active {\n    color: rgb(var(--v-theme-primary));\n    text-shadow: 0 1px 3px rgba(0, 0, 0, 15%);\n\n    &::after {\n      transform: translateX(-50%) scaleX(1);\n    }\n\n    .header-tab-icon {\n      color: rgb(var(--v-theme-primary));\n      text-shadow: 0 1px 3px rgba(0, 0, 0, 15%);\n    }\n  }\n\n  &:hover:not(.active) {\n    background-color: rgba(var(--v-theme-primary), 0.05);\n    color: rgba(var(--v-theme-on-background), 1);\n  }\n}\n</style>\n"
  },
  {
    "path": "src/layouts/components/OfflinePage.vue",
    "content": "<script setup lang=\"ts\">\nimport { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'\n\ninterface Props {\n  type?: 'offline' | 'online'\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  type: 'offline',\n})\n\nconst { t } = useI18n()\nconst { isOnline, canPerformNetworkAction, getOfflineMessage } = useGlobalOfflineStatus()\n\n// 重试连接\nconst retrying = ref(false)\nconst handleRetry = async () => {\n  if (retrying.value) return\n\n  retrying.value = true\n\n  try {\n    // 尝试发送一个简单的请求来检测网络\n    await fetch('/favicon.ico?' + new Date().getTime(), {\n      method: 'HEAD',\n      cache: 'no-cache',\n    })\n\n    // 如果成功，等待一下让状态更新\n    setTimeout(() => {\n      retrying.value = false\n    }, 1000)\n  } catch (error) {\n    retrying.value = false\n  }\n}\n\n// 当网络恢复时自动隐藏页面\nconst shouldShow = computed(() => {\n  return !canPerformNetworkAction.value\n})\n\n// 状态文本\nconst statusText = computed(() => {\n  if (props.type === 'online') {\n    return t('app.onlineMessage')\n  }\n  return getOfflineMessage()\n})\n\n// 图标\nconst statusIcon = computed(() => {\n  return props.type === 'online' ? 'mdi-wifi' : 'mdi-wifi-off'\n})\n\n// 颜色主题\nconst colorTheme = computed(() => {\n  return props.type === 'online' ? 'success' : 'error'\n})\n</script>\n\n<template>\n  <VDialog :model-value=\"shouldShow\" persistent max-width=\"420\" scrollable>\n    <VCard class=\"offline-dialog\">\n      <!-- 状态图标 -->\n      <div class=\"status-icon-wrapper\">\n        <div class=\"status-icon-bg\">\n          <VIcon :icon=\"statusIcon\" size=\"48\" :color=\"colorTheme\" />\n        </div>\n      </div>\n\n      <!-- 主要信息 -->\n      <VCardText class=\"text-center\">\n        <h2 class=\"offline-title mb-4\">\n          {{ props.type === 'online' ? t('app.online') : t('app.offline') }}\n        </h2>\n\n        <p class=\"offline-message mb-6\">\n          {{ statusText }}\n        </p>\n\n        <!-- 重试按钮 -->\n        <div class=\"action-section mb-6\">\n          <VBtn\n            v-if=\"props.type === 'offline'\"\n            :loading=\"retrying\"\n            :color=\"colorTheme\"\n            size=\"default\"\n            variant=\"flat\"\n            @click=\"handleRetry\"\n          >\n            <VIcon icon=\"mdi-refresh\" class=\"me-2\" />\n            {{ retrying ? t('common.checking') : t('common.retry') }}\n          </VBtn>\n        </div>\n\n        <!-- 状态指示器 -->\n        <div class=\"status-indicators\">\n          <VChip\n            :color=\"isOnline ? 'success' : 'error'\"\n            :prepend-icon=\"isOnline ? 'mdi-wifi' : 'mdi-wifi-off'\"\n            variant=\"tonal\"\n            size=\"small\"\n            class=\"me-2\"\n          >\n            {{ isOnline ? t('common.networkOnline') : t('common.networkOffline') }}\n          </VChip>\n\n          <VChip\n            :color=\"canPerformNetworkAction ? 'success' : 'warning'\"\n            :prepend-icon=\"canPerformNetworkAction ? 'mdi-check-circle' : 'mdi-alert-circle'\"\n            variant=\"tonal\"\n            size=\"small\"\n          >\n            {{ canPerformNetworkAction ? t('common.serviceAvailable') : t('common.serviceUnavailable') }}\n          </VChip>\n        </div>\n      </VCardText>\n    </VCard>\n  </VDialog>\n</template>\n\n<style scoped>\n.offline-dialog {\n  border-radius: 16px;\n}\n\n.status-icon-wrapper {\n  padding-block: 24px 0;\n  padding-inline: 24px;\n  text-align: center;\n}\n\n.status-icon-bg {\n  position: relative;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border-radius: 50%;\n  animation: icon-pulse 3s ease-in-out infinite;\n  background: rgba(var(--v-theme-surface-variant), 0.5);\n  block-size: 80px;\n  inline-size: 80px;\n  margin-block: 0;\n  margin-inline: auto;\n}\n\n.status-icon-bg::before {\n  position: absolute;\n  z-index: -1;\n  border-radius: 50%;\n  animation: icon-glow 2s ease-in-out infinite alternate;\n  background: linear-gradient(45deg, rgb(var(--v-theme-primary)), rgb(var(--v-theme-secondary)));\n  content: '';\n  inset: -3px;\n  opacity: 0.1;\n}\n\n@keyframes icon-pulse {\n  0%,\n  100% {\n    transform: scale(1);\n  }\n\n  50% {\n    transform: scale(1.05);\n  }\n}\n\n@keyframes icon-glow {\n  0% {\n    opacity: 0.1;\n    transform: scale(1);\n  }\n\n  100% {\n    opacity: 0.3;\n    transform: scale(1.1);\n  }\n}\n\n.offline-title {\n  color: rgb(var(--v-theme-on-surface));\n  font-size: 1.5rem;\n  font-weight: 600;\n}\n\n.offline-message {\n  color: rgb(var(--v-theme-on-surface));\n  font-size: 1rem;\n  line-height: 1.5;\n  opacity: 0.7;\n}\n\n.status-indicators {\n  display: flex;\n  flex-wrap: wrap;\n  justify-content: center;\n  gap: 8px;\n}\n\n/* 移动端优化 */\n@media (width <= 600px) {\n  .status-icon-bg {\n    block-size: 70px;\n    inline-size: 70px;\n  }\n\n  .offline-title {\n    font-size: 1.25rem;\n  }\n\n  .offline-message {\n    font-size: 0.9rem;\n  }\n\n  .status-indicators {\n    flex-direction: column;\n    align-items: center;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/layouts/components/QuickAccess.vue",
    "content": "<script setup lang=\"ts\">\nimport api from '@/api'\nimport type { Plugin } from '@/api/types'\nimport { getLogoUrl } from '@/utils/imageUtils'\nimport { useI18n } from 'vue-i18n'\nimport { useRecentPlugins } from '@/composables/useRecentPlugins'\nimport PluginDataDialog from '@/components/dialog/PluginDataDialog.vue'\nimport { VCard } from 'vuetify/components'\nimport { getDominantColor } from '@/@core/utils/image'\nimport { disableBodyScroll, enableBodyScroll } from 'body-scroll-lock'\n\n// 国际化\nconst { t } = useI18n()\n\n// 最近访问插件管理\nconst { getRecentPlugins, addRecentPlugin } = useRecentPlugins()\n\n// 输入参数\nconst props = defineProps({\n  visible: {\n    type: Boolean,\n    default: false,\n  },\n  pullDistance: {\n    type: Number,\n    default: 0,\n  },\n})\n\n// 事件\nconst emit = defineEmits<{\n  (e: 'close'): void\n  (e: 'plugin-click', plugin: Plugin): void\n}>()\n\n// 有详情页面的插件列表\nconst pluginsWithPage = ref<Plugin[]>([])\n\n// 最近访问的插件列表\nconst recentPlugins = ref<Plugin[]>([])\n\n// 是否加载中\nconst loading = ref(false)\n\n// 各插件的图标加载状态\nconst pluginIconLoadError = ref<Record<string, boolean>>({})\n\n// 各插件的背景颜色\nconst pluginBackgroundColors = ref<Record<string, string>>({})\n\n// 上滑关闭配置常量\nconst SWIPE_CONFIG = {\n  START_THRESHOLD: 10, // 开始检测上滑的最小距离\n  CLOSE_THRESHOLD: 100, // 触发关闭的距离\n  MAX_DRAG_DISTANCE: 1000, // 最大拖拽距离\n  VELOCITY_THRESHOLD: 0.8, // 快速滑动速度阈值 (px/ms)\n}\n\n// 上滑关闭相关状态\nconst isDraggingToClose = ref(false)\nconst dragOffset = ref(0)\nconst startY = ref(0)\nconst lastY = ref(0)\nconst lastTime = ref(0)\nconst velocity = ref(0)\nconst startedFromBottomArea = ref(false)\n\n// 插件弹窗相关状态\nconst showPluginDataDialog = ref(false)\nconst currentPlugin = ref<Plugin | null>(null)\n\n// 计算显示状态\nconst isVisible = computed(() => {\n  return props.visible\n})\n\n// 处理插件图标加载错误\nfunction handleIconError(plugin: Plugin) {\n  pluginIconLoadError.value[plugin.id] = true\n}\n\n// 处理插件图标加载完成\nasync function handleIconLoaded(src: string | undefined, plugin: Plugin) {\n  if (!src) return\n\n  try {\n    // 创建一个临时的img元素来获取图片数据\n    const img = new Image()\n    img.crossOrigin = 'anonymous'\n    img.onload = async () => {\n      try {\n        // 从图片中提取背景色\n        const backgroundColor = await getDominantColor(img)\n        pluginBackgroundColors.value[plugin.id] = backgroundColor\n      } catch (error) {\n        // 如果提取失败，使用默认颜色\n        pluginBackgroundColors.value[plugin.id] = '#28A9E1'\n      }\n    }\n    img.onerror = () => {\n      // 如果加载失败，使用默认颜色\n      pluginBackgroundColors.value[plugin.id] = '#28A9E1'\n    }\n    img.src = src\n  } catch (error) {\n    // 如果提取失败，使用默认颜色\n    pluginBackgroundColors.value[plugin.id] = '#28A9E1'\n  }\n}\n\n// 获取插件背景颜色\nfunction getPluginBackgroundColor(plugin: Plugin): string {\n  return pluginBackgroundColors.value[plugin.id] || '#28A9E1'\n}\n\n// 计算整个组件的transform（包含拖动偏移）\nconst componentTransform = computed(() => {\n  let baseTransform = ''\n  if (props.visible) {\n    baseTransform = 'translateY(0)'\n  } else {\n    baseTransform = 'translateY(-100%)'\n  }\n\n  // 如果正在拖动关闭，添加拖动偏移（向上拖拽为负值，让面板向上移动）\n  if (isDraggingToClose.value) {\n    return `${baseTransform} translateY(-${dragOffset.value}px)`\n  }\n\n  return baseTransform\n})\n\n// 计算组件透明度\nconst componentOpacity = computed(() => {\n  return props.visible ? 1 : 0\n})\n\n// 计算插件图标路径\nfunction getPluginIcon(plugin: Plugin): string {\n  if (!plugin.plugin_icon) return getLogoUrl('plugin')\n  if (pluginIconLoadError.value[plugin.id]) return getLogoUrl('plugin')\n\n  // 如果是网络图片则使用代理后返回\n  if (plugin?.plugin_icon?.startsWith('http'))\n    return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(\n      plugin?.plugin_icon,\n    )}&cache=true`\n\n  return `./plugin_icon/${plugin?.plugin_icon}`\n}\n\n// 获取有详情页面的插件\nasync function fetchPluginsWithPage() {\n  if (loading.value) return\n\n  try {\n    loading.value = true\n    const allPlugins: Plugin[] = await api.get('plugin/', {\n      params: {\n        state: 'installed',\n      },\n    })\n\n    // 只保留有详情页面且已启用的插件\n    pluginsWithPage.value = allPlugins\n      .filter(plugin => plugin.has_page)\n      .sort((a, b) => {\n        // 按插件名称排序\n        return (a.plugin_name || '').localeCompare(b.plugin_name || '')\n      })\n  } catch (error) {\n    console.error('获取插件列表失败:', error)\n  } finally {\n    loading.value = false\n  }\n}\n\n// 加载最近访问的插件\nfunction loadRecentPlugins() {\n  recentPlugins.value = getRecentPlugins()\n}\n\n// 点击插件\nfunction handlePluginClick(plugin: Plugin) {\n  // 添加到最近访问列表\n  addRecentPlugin(plugin)\n\n  // 更新最近访问列表显示\n  loadRecentPlugins()\n\n  emit('plugin-click', plugin)\n\n  // 设置当前插件并显示数据弹窗\n  currentPlugin.value = plugin\n  showPluginDataDialog.value = true\n}\n\n// 关闭面板\nfunction handleClose() {\n  emit('close')\n}\n\n// 关闭插件数据弹窗\nfunction handleClosePluginDataDialog() {\n  showPluginDataDialog.value = false\n  currentPlugin.value = null\n}\n\n// 管理滚动状态\nfunction manageScrollLock() {\n  if (isVisible.value) {\n    // 使用 nextTick 确保 DOM 已经更新\n    nextTick(() => {\n      // 先恢复之前的锁定状态，避免重复锁定\n      const scrollableElement = document.querySelector('.all-plugins-grid')\n      if (scrollableElement) {\n        // 确保元素存在且可见\n        if ((scrollableElement as HTMLElement).offsetHeight > 0) {\n          disableBodyScroll(scrollableElement as HTMLElement)\n        }\n      }\n    })\n  } else {\n    // 恢复背景滚动\n    const scrollableElement = document.querySelector('.all-plugins-grid')\n    if (scrollableElement) {\n      enableBodyScroll(scrollableElement as HTMLElement)\n    }\n  }\n}\n\n// 监听可见性变化，加载数据\nwatch(\n  () => isVisible.value,\n  visible => {\n    if (visible) {\n      fetchPluginsWithPage()\n      loadRecentPlugins()\n      manageScrollLock()\n    } else {\n      manageScrollLock()\n    }\n  },\n  { immediate: true },\n)\n\nonMounted(() => {\n  if (isVisible.value) {\n    fetchPluginsWithPage()\n    loadRecentPlugins()\n    manageScrollLock()\n  }\n})\n\n// 组件卸载时确保恢复背景滚动\nonUnmounted(() => {\n  const scrollableElement = document.querySelector('.all-plugins-grid')\n  if (scrollableElement) {\n    enableBodyScroll(scrollableElement as HTMLElement)\n  }\n})\n\n// 处理触摸开始\nfunction handleTouchStart(event: TouchEvent) {\n  if (!props.visible) return\n\n  const touch = event.touches[0]\n  if (!touch) return\n\n  // 检查是否从 bottom-drag-area 开始触摸\n  const target = event.target as HTMLElement\n  startedFromBottomArea.value = !!target.closest('.bottom-drag-area')\n\n  // 如果触摸发生在插件网格内，不处理拖拽关闭\n  if (target.closest('.plugin-grid')) {\n    startedFromBottomArea.value = false\n    return\n  }\n\n  startY.value = touch.clientY\n  lastY.value = touch.clientY\n  lastTime.value = Date.now()\n  velocity.value = 0\n\n  // 重置拖拽状态\n  isDraggingToClose.value = false\n  dragOffset.value = 0\n}\n\n// 处理触摸移动\nfunction handleTouchMove(event: TouchEvent) {\n  if (!props.visible) return\n\n  const touch = event.touches[0]\n  if (!touch) return\n\n  // 只有从 bottom-drag-area 开始的触摸才处理上滑关闭\n  if (!startedFromBottomArea.value) return\n\n  // 检查当前触摸是否在插件网格内，如果是则不处理拖拽关闭\n  const target = event.target as HTMLElement\n  if (target.closest('.plugin-grid')) {\n    return\n  }\n\n  const currentY = touch.clientY\n  const currentTime = Date.now()\n  const deltaY = startY.value - currentY // 向上为正值\n  const timeDelta = currentTime - lastTime.value\n\n  // 计算速度\n  if (timeDelta > 0) {\n    const moveDistance = lastY.value - currentY\n    velocity.value = moveDistance / timeDelta\n  }\n\n  // 如果已经开始拖拽，继续拖拽\n  if (isDraggingToClose.value) {\n    if (deltaY >= 0) {\n      // 向上拖拽，更新偏移量\n      dragOffset.value = Math.min(deltaY, SWIPE_CONFIG.MAX_DRAG_DISTANCE)\n      event.preventDefault()\n    } else {\n      // 向下拖拽，停止拖拽\n      isDraggingToClose.value = false\n      dragOffset.value = 0\n    }\n  } else {\n    // 还没开始拖拽，检查是否应该开始\n    if (deltaY > SWIPE_CONFIG.START_THRESHOLD) {\n      isDraggingToClose.value = true\n      dragOffset.value = Math.min(deltaY, SWIPE_CONFIG.MAX_DRAG_DISTANCE)\n      event.preventDefault()\n    }\n  }\n\n  lastY.value = currentY\n  lastTime.value = currentTime\n}\n\n// 处理触摸结束\nfunction handleTouchEnd() {\n  if (!props.visible) return\n\n  // 只有从 bottom-drag-area 开始的触摸才处理上滑关闭\n  if (!startedFromBottomArea.value) return\n\n  if (isDraggingToClose.value) {\n    // 判断是否应该关闭：距离超过阈值或者快速上滑\n    const shouldClose =\n      dragOffset.value >= SWIPE_CONFIG.CLOSE_THRESHOLD || velocity.value >= SWIPE_CONFIG.VELOCITY_THRESHOLD\n\n    if (shouldClose) {\n      emit('close')\n    }\n\n    // 重置拖拽状态\n    isDraggingToClose.value = false\n    dragOffset.value = 0\n  }\n\n  // 重置所有状态\n  startY.value = 0\n  lastY.value = 0\n  velocity.value = 0\n  startedFromBottomArea.value = false\n}\n\n// 点击底部空白区域关闭\nfunction handleBackdropClick(event: MouseEvent) {\n  const target = event.target as HTMLElement\n  // 点击根容器或底部提示区域时关闭\n  if (\n    target.classList.contains('plugin-quick-access') ||\n    target.classList.contains('footer-hint') ||\n    target.classList.contains('hint-text') ||\n    target.classList.contains('bottom-drag-area')\n  ) {\n    emit('close')\n  }\n}\n</script>\n\n<template>\n  <VCard\n    :ripple=\"false\"\n    class=\"plugin-quick-access\"\n    :class=\"{ 'visible': isVisible }\"\n    :style=\"{\n      opacity: componentOpacity,\n      transform: componentTransform,\n      transition: isDraggingToClose ? 'none' : 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',\n    }\"\n    @click=\"handleBackdropClick\"\n    @touchstart=\"handleTouchStart\"\n    @touchmove=\"handleTouchMove\"\n    @touchend=\"handleTouchEnd\"\n  >\n    <!-- 顶部指示器 -->\n    <div class=\"top-indicator\"></div>\n\n    <!-- 标题栏 -->\n    <div class=\"header\">\n      <div class=\"header-title\">{{ t('plugin.quickAccess') }}</div>\n      <VBtn icon variant=\"text\" @click=\"handleClose\" class=\"close-btn\">\n        <VIcon icon=\"mdi-close\" />\n      </VBtn>\n    </div>\n\n    <!-- 插件网格 -->\n    <div class=\"plugin-grid\">\n      <!-- 加载状态 -->\n      <LoadingBanner v-if=\"loading\" />\n\n      <!-- 最近访问 -->\n      <template v-else>\n        <div class=\"section-header\">\n          <div class=\"section-title\">{{ t('plugin.recentlyUsed') }}</div>\n        </div>\n\n        <div v-if=\"recentPlugins.length > 0\" class=\"recent-plugins-row\">\n          <div\n            v-for=\"plugin in recentPlugins\"\n            :key=\"`recent-${plugin.id}`\"\n            class=\"plugin-item\"\n            @click=\"handlePluginClick(plugin)\"\n          >\n            <VBadge dot :color=\"plugin.state ? 'success' : 'secondary'\" location=\"top end\">\n              <div\n                class=\"plugin-icon\"\n                :style=\"{\n                  background: `${getPluginBackgroundColor(plugin)}`,\n                }\"\n              >\n                <VImg\n                  :src=\"getPluginIcon(plugin)\"\n                  :alt=\"plugin.plugin_name\"\n                  cover\n                  @error=\"handleIconError(plugin)\"\n                  @load=\"src => handleIconLoaded(src, plugin)\"\n                  class=\"rounded-lg\"\n                />\n              </div>\n            </VBadge>\n            <div class=\"plugin-name\">{{ plugin.plugin_name }}</div>\n          </div>\n        </div>\n\n        <!-- 没有最近访问时显示\"无\" -->\n        <div v-else class=\"no-recent-plugins\">\n          <VIcon icon=\"mdi-puzzle-outline\" size=\"24\" color=\"grey\" />\n        </div>\n\n        <!-- 所有插件 -->\n        <div v-if=\"pluginsWithPage.length > 0\" class=\"section-header with-margin\">\n          <div class=\"section-title\">{{ t('plugin.allPlugins') }}</div>\n        </div>\n\n        <div v-if=\"pluginsWithPage.length > 0\" class=\"all-plugins-container\">\n          <div class=\"all-plugins-grid\">\n            <div\n              v-for=\"plugin in pluginsWithPage\"\n              :key=\"plugin.id\"\n              class=\"plugin-item\"\n              @click=\"handlePluginClick(plugin)\"\n            >\n              <VBadge\n                dot\n                :color=\"plugin.state ? 'success' : 'secondary'\"\n                location=\"top end\"\n                :offset-x=\"-1\"\n                :offset-y=\"-1\"\n              >\n                <div\n                  class=\"plugin-icon\"\n                  :style=\"{\n                    background: `${getPluginBackgroundColor(plugin)}`,\n                  }\"\n                >\n                  <VImg\n                    :src=\"getPluginIcon(plugin)\"\n                    :alt=\"plugin.plugin_name\"\n                    cover\n                    @load=\"src => handleIconLoaded(src, plugin)\"\n                    @error=\"handleIconError(plugin)\"\n                    class=\"rounded-lg\"\n                  />\n                </div>\n              </VBadge>\n              <div class=\"plugin-name\">{{ plugin.plugin_name }}</div>\n            </div>\n          </div>\n        </div>\n        <!-- 空状态（只有在没有插件时显示） -->\n        <div v-else-if=\"pluginsWithPage.length === 0\" class=\"empty-state\">\n          <VIcon icon=\"mdi-puzzle-outline\" size=\"48\" color=\"grey\" />\n          <div class=\"empty-text\">{{ t('plugin.noPluginsWithPage') }}</div>\n        </div>\n      </template>\n    </div>\n\n    <!-- 底部拖动区域 -->\n    <div class=\"bottom-drag-area\" @click=\"handleBackdropClick\">\n      <!-- 底部指示器 -->\n      <div class=\"bottom-indicator\">\n        <div\n          class=\"indicator-bar bottom\"\n          :class=\"{ 'dragging': isDraggingToClose }\"\n          :style=\"{\n            transform: isDraggingToClose\n              ? `scaleX(${Math.min(dragOffset / SWIPE_CONFIG.CLOSE_THRESHOLD, 1.5)})`\n              : 'scaleX(1)',\n            background: isDraggingToClose\n              ? dragOffset >= SWIPE_CONFIG.CLOSE_THRESHOLD\n                ? 'rgba(var(--v-theme-success), 0.8)'\n                : 'rgba(var(--v-theme-primary), 0.8)'\n              : 'rgba(var(--v-theme-on-surface), 0.12)',\n          }\"\n        ></div>\n      </div>\n    </div>\n  </VCard>\n\n  <!-- 插件数据弹窗 -->\n  <PluginDataDialog\n    v-if=\"showPluginDataDialog && currentPlugin\"\n    v-model=\"showPluginDataDialog\"\n    :plugin=\"currentPlugin\"\n    :show_switch=\"false\"\n    @close=\"handleClosePluginDataDialog\"\n  />\n</template>\n\n<style lang=\"scss\" scoped>\n.plugin-quick-access {\n  position: fixed;\n  z-index: 9999;\n  display: flex;\n  overflow: hidden;\n  flex-direction: column;\n  backdrop-filter: blur(32px);\n  background: rgba(var(--v-theme-surface), 0.95);\n  block-size: 100vh;\n  block-size: 100dvh;\n  inset-block-start: 0;\n  inset-inline: 0;\n  opacity: 0;\n  padding-block: env(safe-area-inset-top) env(safe-area-inset-bottom);\n  padding-inline: env(safe-area-inset-left) env(safe-area-inset-right);\n  pointer-events: none;\n  transform: translateY(-100%);\n  transition: all 1s cubic-bezier(0.4, 0, 0.2, 1);\n\n  &.visible {\n    opacity: 1;\n    pointer-events: auto;\n    transform: translateY(0);\n  }\n}\n\n.top-indicator {\n  display: flex;\n  justify-content: center;\n  padding-block: 12px 8px;\n  padding-inline: 0;\n}\n\n// 底部相关样式\n.bottom-indicator {\n  display: flex;\n  justify-content: center;\n  padding-block: 8px 12px;\n  padding-inline: 0;\n\n  .indicator-bar.bottom {\n    border-radius: 2px;\n    background: rgba(var(--v-theme-on-surface), 0.12);\n    block-size: 4px;\n    inline-size: 30vw;\n    transform-origin: center;\n    transition: all 0.2s ease;\n  }\n}\n\n.header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);\n  padding-block: 0 16px;\n  padding-inline: 20px;\n\n  .header-title {\n    color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));\n    font-size: 20px;\n    font-weight: 600;\n  }\n\n  .close-btn {\n    opacity: 0.6;\n\n    &:hover {\n      background: rgba(var(--v-theme-on-surface), 0.04);\n      opacity: 1;\n    }\n  }\n}\n\n.plugin-grid {\n  display: flex;\n  overflow: hidden auto;\n  flex: 1;\n  flex-direction: column;\n  gap: 16px;\n  max-block-size: calc(100vh - 200px); // 确保有最大高度限制\n  min-block-size: 0;\n  -webkit-overflow-scrolling: touch;\n  -ms-overflow-style: none; // IE/Edge\n  overscroll-behavior: contain;\n  padding-block: 24px;\n  padding-inline: 20px;\n\n  // 隐藏滚动条\n  scrollbar-width: none; // Firefox\n  touch-action: pan-y;\n  will-change: scroll-position;\n\n  &::-webkit-scrollbar {\n    display: none; // WebKit 浏览器\n  }\n}\n\n.section-header {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  margin-inline: 0;\n\n  .section-title {\n    color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));\n    font-size: 16px;\n    font-weight: 600;\n    white-space: nowrap;\n  }\n}\n\n.no-recent-plugins {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  padding-inline: 0;\n}\n\n.recent-plugins-row {\n  display: grid;\n  gap: 16px;\n  grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));\n  padding-block: 0;\n  padding-inline: 0;\n}\n\n.all-plugins-container {\n  display: flex;\n  overflow: hidden;\n  flex: 1;\n  flex-direction: column;\n  min-block-size: 0;\n}\n\n.all-plugins-grid {\n  display: grid;\n  gap: 4px;\n  grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));\n  max-block-size: 100%;\n  -webkit-overflow-scrolling: touch;\n  -ms-overflow-style: none; // IE/Edge\n  overflow-y: auto;\n  overscroll-behavior: contain;\n  padding-block: 8px;\n  padding-inline: 0;\n\n  // 隐藏滚动条\n  scrollbar-width: none; // Firefox\n  touch-action: pan-y;\n  will-change: scroll-position;\n\n  &::-webkit-scrollbar {\n    display: none; // WebKit 浏览器\n  }\n}\n\n.plugin-item {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  border-radius: 12px;\n  block-size: 120px;\n  cursor: pointer;\n  gap: 4px;\n  transition: all 0.2s ease;\n\n  &:hover {\n    background: rgba(var(--v-theme-on-surface), 0.04);\n    transform: translateY(-2px);\n  }\n\n  &:active {\n    background: rgba(var(--v-theme-on-surface), 0.08);\n    transform: translateY(0);\n  }\n}\n\n.plugin-icon {\n  position: relative;\n  display: flex;\n  overflow: hidden;\n  flex-shrink: 0;\n  align-items: center;\n  justify-content: center;\n  padding: 4px;\n  border-radius: 16px;\n  block-size: 64px;\n  inline-size: 64px;\n  transition: all 0.2s ease;\n\n  .plugin-item:hover & {\n    transform: scale(1.02);\n  }\n}\n\n.plugin-name {\n  display: -webkit-box;\n  overflow: hidden;\n  flex-shrink: 0;\n  -webkit-box-orient: vertical;\n  color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));\n  font-size: 12px;\n  font-weight: 500;\n  -webkit-line-clamp: 2;\n  line-clamp: 2;\n  line-height: 1.2;\n  max-block-size: 2.4em;\n  text-align: center;\n  word-break: break-all;\n}\n\n.empty-state {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  gap: 16px;\n  grid-column: 1 / -1;\n  padding-block: 40px;\n  padding-inline: 0;\n\n  .empty-text {\n    color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));\n    font-size: 14px;\n  }\n}\n\n.bottom-drag-area {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  cursor: pointer;\n  padding-block: 8px 0;\n  padding-inline: 20px;\n}\n\n@media (hover: none) and (pointer: coarse) {\n  .plugin-item:hover {\n    background: transparent;\n    transform: none;\n  }\n\n  .plugin-item:active {\n    background: rgba(var(--v-theme-on-surface), 0.08);\n  }\n}\n\n// 深色模式适配\nhtml[data-theme='dark'] .plugin-quick-access {\n  background: rgba(var(--v-theme-surface), 0.9);\n}\n</style>\n"
  },
  {
    "path": "src/layouts/components/SearchBar.vue",
    "content": "<script lang=\"ts\" setup>\nimport * as Mousetrap from 'mousetrap'\nimport SearchBarDialog from '@/components/dialog/SearchBarDialog.vue'\nimport { useDisplay } from 'vuetify'\nimport { useI18n } from 'vue-i18n'\n\nconst display = useDisplay()\nconst { t } = useI18n()\n\nconst searchDialog = ref(false)\n\n// 注册快捷键\nMousetrap.bind(['command+k', 'ctrl+k'], openSearchDialog)\n\n// 打开搜索弹窗\nfunction openSearchDialog() {\n  searchDialog.value = true\n  return false\n}\n\n// 检测操作系统是否是Mac\nfunction isMac() {\n  return navigator.platform.toUpperCase().indexOf('MAC') >= 0\n}\n// 计算属性：根据操作系统显示不同的按键提示\nconst metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))\n</script>\n\n<template>\n  <!-- 小屏：仅图标按钮 -->\n  <IconBtn v-if=\"!display.mdAndUp.value\" @click=\"openSearchDialog\">\n    <VIcon icon=\"mdi-magnify\" />\n  </IconBtn>\n\n  <!-- 中屏及以上：胶囊搜索触发栏 -->\n  <div v-else class=\"search-trigger\" @click=\"openSearchDialog\">\n    <VIcon icon=\"mdi-magnify\" size=\"18\" class=\"search-trigger-icon\" />\n    <span class=\"search-trigger-text\">{{ t('common.search') }}</span>\n    <kbd class=\"search-trigger-kbd\">{{ metaKey }}</kbd>\n  </div>\n\n  <!-- 搜索弹窗 -->\n  <SearchBarDialog v-model=\"searchDialog\" v-if=\"searchDialog\" @close=\"searchDialog = false\" />\n</template>\n\n<style scoped>\n.search-trigger {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  border: 1.5px solid rgba(var(--v-theme-on-surface), 0.12);\n  border-radius: 22px;\n  block-size: 36px;\n  cursor: pointer;\n  padding-inline: 12px;\n  transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;\n  user-select: none;\n}\n\n.search-trigger:hover {\n  border-color: rgba(var(--v-theme-on-surface), 0.22);\n  background-color: rgba(var(--v-theme-on-surface), 0.06);\n  box-shadow: 0 1px 4px rgba(0, 0, 0, 4%);\n}\n\n.search-trigger-icon {\n  color: rgba(var(--v-theme-on-surface), 0.4);\n  flex-shrink: 0;\n}\n\n.search-trigger-text {\n  color: rgba(var(--v-theme-on-surface), 0.4);\n  font-size: 13.5px;\n  line-height: 1;\n  white-space: nowrap;\n}\n\n.search-trigger-kbd {\n  border: 1px solid rgba(var(--v-theme-on-surface), 0.12);\n  border-radius: 5px;\n  background-color: rgba(var(--v-theme-on-surface), 0.04);\n  color: rgba(var(--v-theme-on-surface), 0.4);\n  font-family: inherit;\n  font-size: 11px;\n  font-weight: 500;\n  line-height: 1;\n  margin-inline-start: 4px;\n  padding-block: 3px;\n  padding-inline: 5px;\n}\n</style>\n"
  },
  {
    "path": "src/layouts/components/ShortcutBar.vue",
    "content": "<script lang=\"ts\" setup>\nimport NameTestView from '@/views/system/NameTestView.vue'\nimport NetTestView from '@/views/system/NetTestView.vue'\nimport LoggingView from '@/views/system/LoggingView.vue'\nimport RuleTestView from '@/views/system/RuleTestView.vue'\nimport ModuleTestView from '@/views/system/ModuleTestView.vue'\nimport MessageView from '@/views/system/MessageView.vue'\nimport WordsView from '@/views/system/WordsView.vue'\nimport CacheView from '@/views/system/CacheView.vue'\nimport AccountSettingService from '@/views/system/ServiceView.vue'\nimport api from '@/api'\nimport { useDisplay } from 'vuetify'\nimport { getQueryValue } from '@/@core/utils'\nimport { useI18n } from 'vue-i18n'\nimport { clearAppBadge } from '@/utils/badge'\n\n// 国际化\nconst { t } = useI18n()\n\n// 显示器宽度\nconst display = useDisplay()\n\n// App捷径\nconst appsMenu = ref(false)\n\n// 菜单最大宽度\nconst menuMaxWidth = ref(420)\n\n// 名称测试弹窗\nconst nameTestDialog = ref(false)\n\n// 网络测试弹窗\nconst netTestDialog = ref(false)\n\n// 实时日志弹窗\nconst loggingDialog = ref(false)\n\n// 过滤规则弹窗\nconst ruleTestDialog = ref(false)\n\n// 系统健康检查弹窗\nconst systemTestDialog = ref(false)\n\n// 消息中心弹窗\nconst messageDialog = ref(false)\n\n// 词表设置弹窗\nconst wordsDialog = ref(false)\n\n// 缓存管理弹窗\nconst cacheDialog = ref(false)\n\n// 定时服务弹窗\nconst schedulerDialog = ref(false)\n\n// 输入消息\nconst user_message = ref('')\n\n// 发送按钮是否可用\nconst sendButtonDisabled = ref(false)\n\n// 消息对话框引用\nconst messageDialogRef = ref<any>(null)\n\n// 消息视图引用\nconst messageViewRef = ref<any>(null)\n\n// 滚动容器引用\nconst messageContentRef = ref<any>()\n\n// 定义捷径列表\nconst shortcuts = [\n  {\n    title: t('shortcut.recognition.title'),\n    subtitle: t('shortcut.recognition.subtitle'),\n    icon: 'mdi-text-recognition',\n    dialog: 'nameTest',\n    dialogRef: nameTestDialog,\n  },\n  {\n    title: t('shortcut.rule.title'),\n    subtitle: t('shortcut.rule.subtitle'),\n    icon: 'mdi-filter-cog',\n    dialog: 'ruleTest',\n    dialogRef: ruleTestDialog,\n  },\n  {\n    title: t('shortcut.log.title'),\n    subtitle: t('shortcut.log.subtitle'),\n    icon: 'mdi-file-document',\n    dialog: 'logging',\n    dialogRef: loggingDialog,\n  },\n  {\n    title: t('shortcut.network.title'),\n    subtitle: t('shortcut.network.subtitle'),\n    icon: 'mdi-network',\n    dialog: 'netTest',\n    dialogRef: netTestDialog,\n  },\n  {\n    title: t('shortcut.words.title'),\n    subtitle: t('shortcut.words.subtitle'),\n    icon: 'mdi-file-word-box',\n    dialog: 'words',\n    dialogRef: wordsDialog,\n  },\n  {\n    title: t('shortcut.cache.title'),\n    subtitle: t('shortcut.cache.subtitle'),\n    icon: 'mdi-database',\n    dialog: 'cache',\n    dialogRef: cacheDialog,\n  },\n  {\n    title: t('shortcut.scheduler.title'),\n    subtitle: t('shortcut.scheduler.subtitle'),\n    icon: 'mdi-list-box',\n    dialog: 'scheduler',\n    dialogRef: schedulerDialog,\n  },\n  {\n    title: t('shortcut.system.title'),\n    subtitle: t('shortcut.system.subtitle'),\n    icon: 'mdi-cog',\n    dialog: 'systemTest',\n    dialogRef: systemTestDialog,\n  },\n  {\n    title: t('shortcut.message.title'),\n    subtitle: t('shortcut.message.subtitle'),\n    icon: 'mdi-message',\n    dialog: 'message',\n    dialogRef: messageDialog,\n  },\n]\n\n// 打开对话框\nfunction openDialog(dialogRef: any) {\n  dialogRef.value = true\n}\n\n// 打开消息弹窗并清除徽章\nasync function openMessageDialog() {\n  messageDialog.value = true\n  // 延迟清除徽章，确保对话框已经打开\n  setTimeout(async () => {\n    await clearAppBadge()\n  }, 500)\n  // 延迟滚动到底部，确保弹窗完全打开\n  setTimeout(() => {\n    forceScrollToEnd()\n  }, 600)\n  // 等待对话框打开后恢复SSE连接\n  nextTick(() => {\n    if (messageViewRef.value && typeof messageViewRef.value.resumeSSE === 'function') {\n      messageViewRef.value.resumeSSE()\n    }\n  })\n}\n\n// 智能滚动到底部（只有用户在底部附近时才滚动）\nfunction scrollMessageToEnd() {\n  // 使用更长的延迟确保DOM已更新\n  setTimeout(() => {\n    try {\n      // 查找消息弹窗的滚动容器\n      const cardText = document.querySelector('.v-dialog .v-card-text')\n      if (cardText) {\n        const { scrollTop, scrollHeight, clientHeight } = cardText\n        // 计算距离底部的距离\n        const distanceFromBottom = scrollHeight - scrollTop - clientHeight\n        // 如果用户距离底部小于1/3屏幕高度，认为用户在底部附近，执行自动滚动\n        if (distanceFromBottom <= clientHeight / 3) {\n          cardText.scrollTop = cardText.scrollHeight\n        }\n      }\n    } catch (error) {\n      console.error(error)\n    }\n  }, 500) // 增加延迟时间\n}\n\n// 强制滚动到底部（用于发送消息后）\nfunction forceScrollToEnd() {\n  setTimeout(() => {\n    try {\n      // 查找消息弹窗的滚动容器\n      const cardText = document.querySelector('.v-dialog .v-card-text')\n      if (cardText) {\n        cardText.scrollTop = cardText.scrollHeight\n      }\n    } catch (error) {\n      console.error(error)\n    }\n  }, 500)\n}\n\n// 拼接全部日志url\nfunction allLoggingUrl() {\n  return `${import.meta.env.VITE_API_BASE_URL}system/logging?length=-1`\n}\n\n// 发送消息\nasync function sendMessage() {\n  if (user_message.value) {\n    try {\n      sendButtonDisabled.value = true\n      await api.post(`message/web?text=${user_message.value}`)\n      user_message.value = ''\n      sendButtonDisabled.value = false\n      forceScrollToEnd() // 发送消息后强制滚动到底部\n    } catch (error) {\n      console.error(error)\n    }\n  }\n}\n\n// 供外部调用的打开消息弹窗方法\nfunction openMessageDialogFromExternal() {\n  openMessageDialog()\n}\n\n// 暴露方法给父组件\ndefineExpose({\n  openMessageDialog: openMessageDialogFromExternal,\n})\n\n// 监听消息对话框状态变化\nwatch(messageDialog, newValue => {\n  if (!newValue && messageViewRef.value && typeof messageViewRef.value.pauseSSE === 'function') {\n    // 对话框关闭时暂停SSE连接\n    messageViewRef.value.pauseSSE()\n  }\n})\n\nonMounted(() => {\n  const shortcut = getQueryValue('shortcut')\n  if (shortcut) {\n    const found = shortcuts.find(item => item.dialog === shortcut)\n    if (found) {\n      found.dialogRef.value = true\n    }\n  }\n})\n</script>\n\n<template>\n  <VMenu\n    v-model=\"appsMenu\"\n    :max-width=\"menuMaxWidth\"\n    width=\"100%\"\n    max-height=\"560\"\n    location=\"top end\"\n    origin=\"top end\"\n    close-on-content-click\n    close-on-back\n    scrim\n  >\n    <!-- Menu Activator -->\n    <template #activator=\"{ props }\">\n      <IconBtn class=\"ms-2\" v-bind=\"props\">\n        <VIcon icon=\"mdi-card-multiple-outline\" />\n      </IconBtn>\n    </template>\n    <!-- Menu Content -->\n    <VCard class=\"overflow-hidden\">\n      <VCardItem class=\"py-3\">\n        <VCardTitle>{{ t('shortcut.title') }}</VCardTitle>\n        <template #append>\n          <IconBtn @click=\"appsMenu = false\">\n            <VIcon icon=\"mdi-close\" />\n          </IconBtn>\n        </template>\n      </VCardItem>\n      <VDivider />\n      <div class=\"pa-3\">\n        <div class=\"grid grid-cols-2 gap-3\">\n          <!-- 循环渲染快捷方式 -->\n          <div v-for=\"(item, index) in shortcuts\" :key=\"index\">\n            <VCard\n              flat\n              class=\"pa-2 d-flex align-center cursor-pointer transition-transform duration-300 hover:-translate-y-1 border h-full\"\n              hover\n              @click=\"\n                item.dialog === 'message'\n                  ? openMessageDialog()\n                  : item.dialog === 'words'\n                    ? openDialog(item.dialogRef)\n                    : item.dialog === 'cache'\n                      ? openDialog(item.dialogRef)\n                      : openDialog(item.dialogRef)\n              \"\n            >\n              <VAvatar variant=\"text\" size=\"48\" rounded=\"lg\">\n                <VIcon color=\"primary\" :icon=\"item.icon\" size=\"24\" />\n              </VAvatar>\n              <div>\n                <div class=\"text-body-1 text-high-emphasis font-weight-medium\">{{ item.title }}</div>\n                <div class=\"text-caption text-medium-emphasis\">{{ item.subtitle }}</div>\n              </div>\n            </VCard>\n          </div>\n        </div>\n      </div>\n    </VCard>\n  </VMenu>\n  <!-- 名称测试弹窗 -->\n  <VDialog\n    v-if=\"nameTestDialog\"\n    v-model=\"nameTestDialog\"\n    max-width=\"45rem\"\n    scrollable\n    :fullscreen=\"!display.mdAndUp.value\"\n  >\n    <VCard>\n      <VCardItem>\n        <VCardTitle>\n          <VIcon icon=\"mdi-text-recognition\" class=\"me-2\" />\n          {{ t('shortcut.recognition.title') }}\n        </VCardTitle>\n        <VDialogCloseBtn @click=\"nameTestDialog = false\" />\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <NameTestView />\n      </VCardText>\n    </VCard>\n  </VDialog>\n  <!-- 网络测试弹窗 -->\n  <VDialog\n    v-if=\"netTestDialog\"\n    v-model=\"netTestDialog\"\n    max-width=\"35rem\"\n    scrollable\n    :fullscreen=\"!display.mdAndUp.value\"\n  >\n    <VCard>\n      <VCardItem>\n        <VCardTitle>\n          <VIcon icon=\"mdi-network\" class=\"me-2\" />\n          {{ t('shortcut.network.subtitle') }}\n        </VCardTitle>\n        <VDialogCloseBtn @click=\"netTestDialog = false\" />\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <NetTestView />\n      </VCardText>\n    </VCard>\n  </VDialog>\n  <!-- 实时日志弹窗 -->\n  <VDialog\n    v-if=\"loggingDialog\"\n    v-model=\"loggingDialog\"\n    scrollable\n    max-width=\"70rem\"\n    :fullscreen=\"!display.mdAndUp.value\"\n  >\n    <VCard>\n      <VDialogCloseBtn @click=\"loggingDialog = false\" />\n      <VCardItem>\n        <VCardTitle class=\"d-inline-flex\">\n          <VIcon icon=\"mdi-file-document\" class=\"me-2\" />\n          {{ t('shortcut.log.subtitle') }}\n          <a class=\"mx-2 d-inline-flex align-center\" :href=\"allLoggingUrl()\" target=\"_blank\">\n            <VChip color=\"grey-darken-1\" size=\"small\" class=\"ml-2\">\n              <VIcon icon=\"mdi-open-in-new\" size=\"small\" start />\n              {{ t('common.openInNewWindow') }}\n            </VChip>\n          </a>\n        </VCardTitle>\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <LoggingView logfile=\"moviepilot.log\" />\n      </VCardText>\n    </VCard>\n  </VDialog>\n  <!-- 过滤规则弹窗 -->\n  <VDialog\n    v-if=\"ruleTestDialog\"\n    v-model=\"ruleTestDialog\"\n    max-width=\"35rem\"\n    scrollable\n    :fullscreen=\"!display.mdAndUp.value\"\n  >\n    <VCard>\n      <VCardItem>\n        <VCardTitle>\n          <VIcon icon=\"mdi-filter-cog\" class=\"me-2\" />\n          {{ t('shortcut.rule.subtitle') }}\n        </VCardTitle>\n        <VDialogCloseBtn @click=\"ruleTestDialog = false\" />\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <RuleTestView />\n      </VCardText>\n    </VCard>\n  </VDialog>\n  <!-- 词表设置弹窗 -->\n  <VDialog v-if=\"wordsDialog\" v-model=\"wordsDialog\" max-width=\"60rem\" scrollable :fullscreen=\"!display.mdAndUp.value\">\n    <VCard>\n      <VCardItem>\n        <VCardTitle>\n          <VIcon icon=\"mdi-file-word-box\" class=\"me-2\" />\n          {{ t('shortcut.words.subtitle') }}\n        </VCardTitle>\n        <VDialogCloseBtn @click=\"wordsDialog = false\" />\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <WordsView />\n      </VCardText>\n    </VCard>\n  </VDialog>\n  <!-- 缓存管理弹窗 -->\n  <VDialog v-if=\"cacheDialog\" v-model=\"cacheDialog\" max-width=\"90rem\" scrollable :fullscreen=\"!display.mdAndUp.value\">\n    <VCard>\n      <VCardItem>\n        <VCardTitle>\n          <VIcon icon=\"mdi-database\" class=\"me-2\" />\n          {{ t('shortcut.cache.subtitle') }}\n        </VCardTitle>\n        <VDialogCloseBtn @click=\"cacheDialog = false\" />\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <CacheView />\n      </VCardText>\n    </VCard>\n  </VDialog>\n  <!-- 定时服务弹窗 -->\n  <VDialog\n    v-if=\"schedulerDialog\"\n    v-model=\"schedulerDialog\"\n    max-width=\"60rem\"\n    scrollable\n    :fullscreen=\"!display.mdAndUp.value\"\n  >\n    <VCard>\n      <VCardItem class=\"py-2\">\n        <VCardTitle>\n          <VIcon icon=\"mdi-list-box\" class=\"me-2\" />\n          {{ t('shortcut.scheduler.subtitle') }}\n        </VCardTitle>\n        <VCardSubtitle>{{ t('setting.scheduler.subtitle') }}</VCardSubtitle>\n        <VDialogCloseBtn @click=\"schedulerDialog = false\" />\n      </VCardItem>\n      <VDivider />\n      <VCardText class=\"pa-0\">\n        <AccountSettingService />\n      </VCardText>\n    </VCard>\n  </VDialog>\n  <!-- 系统健康检查弹窗 -->\n  <VDialog\n    v-if=\"systemTestDialog\"\n    v-model=\"systemTestDialog\"\n    max-width=\"35rem\"\n    scrollable\n    :fullscreen=\"!display.mdAndUp.value\"\n  >\n    <VCard>\n      <VCardItem>\n        <VCardTitle>\n          <VIcon icon=\"mdi-cog\" class=\"me-2\" />\n          {{ t('shortcut.system.subtitle') }}\n        </VCardTitle>\n        <VDialogCloseBtn @click=\"systemTestDialog = false\" />\n      </VCardItem>\n      <VDivider />\n      <VCardText class=\"pa-0\">\n        <ModuleTestView />\n      </VCardText>\n    </VCard>\n  </VDialog>\n  <!-- 消息中心弹窗 -->\n  <VDialog\n    v-if=\"messageDialog\"\n    v-model=\"messageDialog\"\n    max-width=\"50rem\"\n    scrollable\n    :fullscreen=\"!display.mdAndUp.value\"\n    ref=\"messageDialogRef\"\n  >\n    <VCard>\n      <VCardItem>\n        <VCardTitle>\n          <VIcon icon=\"mdi-message\" class=\"me-2\" />\n          {{ t('shortcut.message.subtitle') }}\n        </VCardTitle>\n        <VDialogCloseBtn @click=\"messageDialog = false\" />\n      </VCardItem>\n      <VDivider />\n      <VCardText ref=\"messageContentRef\">\n        <MessageView ref=\"messageViewRef\" @scroll=\"scrollMessageToEnd\" />\n      </VCardText>\n      <VDivider />\n      <VCardActions class=\"pa-4\">\n        <div class=\"d-flex w-100 gap-2\">\n          <VTextField\n            v-model=\"user_message\"\n            variant=\"outlined\"\n            hide-details\n            density=\"compact\"\n            :placeholder=\"t('common.inputMessage')\"\n            @keyup.enter=\"sendMessage\"\n          />\n          <VBtn\n            variant=\"elevated\"\n            :disabled=\"sendButtonDisabled\"\n            @click=\"sendMessage\"\n            :loading=\"sendButtonDisabled\"\n            color=\"primary\"\n            prepend-icon=\"mdi-send\"\n            >{{ t('common.send') }}\n          </VBtn>\n        </div>\n      </VCardActions>\n    </VCard>\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/layouts/components/UserNotification.vue",
    "content": "<script setup lang=\"ts\">\nimport { formatDateDifference } from '@core/utils/formatters'\nimport { SystemNotification } from '@/api/types'\nimport { useI18n } from 'vue-i18n'\nimport { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'\n\nconst { t } = useI18n()\nconst { useDelayedSSE } = useBackgroundOptimization()\n\n// 是否有新消息\nconst hasNewMessage = ref(false)\n\n// 通知列表\nconst notificationList = ref<SystemNotification[]>([])\n\n// 弹窗\nconst appsMenu = ref(false)\n\n// 标记所有消息为已读\nfunction markAllAsRead() {\n  hasNewMessage.value = false\n  // 标记所有消息为已读\n  notificationList.value.forEach(item => {\n    item.read = true\n  })\n  appsMenu.value = false\n}\n\n// 消息处理函数\nfunction handleMessage(event: MessageEvent) {\n  if (event.data) {\n    const noti: SystemNotification = JSON.parse(event.data)\n    notificationList.value.unshift(noti)\n    hasNewMessage.value = true\n  }\n}\n\n// 使用优化的SSE连接，延迟3秒启动，避免认证问题\nuseDelayedSSE(\n  `${import.meta.env.VITE_API_BASE_URL}system/message`,\n  handleMessage,\n  'user-notification',\n  3000,\n  {\n    backgroundCloseDelay: 5000,\n    reconnectDelay: 3000,\n    maxReconnectAttempts: 3\n  }\n)\n</script>\n\n<template>\n  <VMenu\n    v-model=\"appsMenu\"\n    width=\"400\"\n    transition=\"scale-transition\"\n    close-on-content-click\n    class=\"notification-menu\"\n    scrim\n  >\n    <!-- Menu Activator -->\n    <template #activator=\"{ props }\">\n      <VBadge v-if=\"hasNewMessage\" dot color=\"error\" :offset-x=\"5\" :offset-y=\"5\" v-bind=\"props\">\n        <IconBtn>\n          <VIcon icon=\"mdi-bell-outline\" />\n        </IconBtn>\n      </VBadge>\n      <IconBtn v-else v-bind=\"props\">\n        <VIcon icon=\"mdi-bell-outline\" />\n      </IconBtn>\n    </template>\n    <!-- Menu Content -->\n    <VCard>\n      <VCardItem class=\"py-3\">\n        <VCardTitle>{{ t('notification.center') }}</VCardTitle>\n        <template #append>\n          <VTooltip :text=\"t('notification.markRead')\">\n            <template #activator=\"{ props }\">\n              <IconBtn v-bind=\"props\" @click=\"markAllAsRead\">\n                <VIcon icon=\"mdi-email-check-outline\" size=\"20\" />\n              </IconBtn>\n            </template>\n          </VTooltip>\n        </template>\n      </VCardItem>\n      <VDivider />\n      <div class=\"notification-list-container\">\n        <div v-if=\"notificationList.length > 0\">\n          <VListItem v-for=\"(item, i) in notificationList\" :key=\"i\" lines=\"two\" class=\"mb-1\">\n            <template #prepend>\n              <VAvatar rounded>\n                <VIcon v-if=\"item.type === 'user'\" icon=\"mdi-account-alert\" size=\"large\"></VIcon>\n                <VIcon v-else-if=\"item.type === 'plugin'\" icon=\"mdi-robot\" size=\"large\"></VIcon>\n                <VIcon v-else icon=\"mdi-laptop\" size=\"large\"></VIcon>\n              </VAvatar>\n            </template>\n            <div>\n              <div class=\"text-body-1 text-high-emphasis break-words whitespace-break-spaces\">\n                {{ item.title }}\n              </div>\n              <div class=\"text-caption mt-1.5\">\n                {{ item.text }}\n              </div>\n              <div class=\"text-sm text-primary mt-1.5\">\n                {{ formatDateDifference(item.date) }}\n              </div>\n            </div>\n          </VListItem>\n        </div>\n        <div v-else class=\"py-8 text-center\">\n          <VIcon icon=\"mdi-bell-sleep-outline\" size=\"40\" class=\"mb-3\" />\n          <div>{{ t('notification.empty') }}</div>\n        </div>\n      </div>\n    </VCard>\n  </VMenu>\n</template>\n\n<style scoped>\n.notification-list-container {\n  max-block-size: 50vh;\n  overflow-y: auto;\n  scrollbar-width: thin;\n}\n</style>\n"
  },
  {
    "path": "src/layouts/components/UserProfile.vue",
    "content": "<script setup lang=\"ts\">\nimport { useToast } from 'vue-toastification'\nimport router from '@/router'\nimport avatar1 from '@images/avatars/avatar-1.png'\nimport api from '@/api'\nimport ProgressDialog from '@/components/dialog/ProgressDialog.vue'\nimport UserAuthDialog from '@/components/dialog/UserAuthDialog.vue'\nimport AboutDialog from '@/components/dialog/AboutDialog.vue'\nimport { useAuthStore, useUserStore, useGlobalSettingsStore } from '@/stores'\nimport { useI18n } from 'vue-i18n'\nimport { useDisplay, useTheme } from 'vuetify'\nimport { SUPPORTED_LOCALES, SupportedLocale } from '@/types/i18n'\nimport { checkPrefersColorSchemeIsDark } from '@/@core/utils'\nimport { getCurrentLocale, setI18nLanguage } from '@/plugins/i18n'\nimport { saveLocalTheme } from '@/@core/utils/theme'\nimport type { ThemeSwitcherTheme } from '@layouts/types'\nimport { useConfirm } from '@/composables/useConfirm'\nimport { themeManager } from '@/utils/themeManager'\nimport { usePWA, type UIMode } from '@/composables/usePWA'\n\n// 认证 Store\nconst authStore = useAuthStore()\n// 用户 Store\nconst userStore = useUserStore()\n// 全局设置 Store\nconst globalSettingsStore = useGlobalSettingsStore()\n// 国际化\nconst { t } = useI18n()\n// 显示器\nconst display = useDisplay()\n// PWA\nconst { uiMode, setUIMode } = usePWA()\n\n// 提示框\nconst $toast = useToast()\n\n// 进度框\nconst progressDialog = ref(false)\n\n// 站点认证对话框\nconst siteAuthDialog = ref(false)\n\n// 自定义CSS弹窗\nconst cssDialog = ref(false)\n\n// UI模式菜单是否显示\nconst showUIModeMenu = ref(false)\n\n// 主题菜单是否显示\nconst showThemeMenu = ref(false)\n\n// 语言菜单是否显示\nconst showLanguageMenu = ref(false)\n\n// 自定义CSS\nconst customCSS = ref('')\n\n// 透明度相关\nconst transparencyOpacity = ref(parseFloat(localStorage.getItem('transparency-opacity') || '0.3'))\nconst transparencyBlur = ref(parseFloat(localStorage.getItem('transparency-blur') || '10'))\nconst transparencyLevel = ref(localStorage.getItem('transparency-level') || 'medium')\nconst isTransparentTheme = computed(() => currentThemeName.value === 'transparent')\nconst showTransparencyDialog = ref(false)\n\n// 关于对话框\nconst aboutDialog = ref(false)\n\n// 预设值配置\nconst transparencyPresets = {\n  low: { opacity: 0.1, blur: 5 },\n  medium: { opacity: 0.3, blur: 10 },\n  high: { opacity: 0.6, blur: 15 },\n}\n\n// 判断当前值是否匹配预设值\nconst currentPresetLevel = computed(() => {\n  for (const [level, preset] of Object.entries(transparencyPresets)) {\n    if (\n      Math.abs(transparencyOpacity.value - preset.opacity) < 0.01 &&\n      Math.abs(transparencyBlur.value - preset.blur) < 0.1\n    ) {\n      return level\n    }\n  }\n  return null\n})\n\n// 重启轮询控制标识\nconst restartPollingId = ref<number | null>(null)\nconst isRestarting = ref(false)\n\n// 确认框\nconst { createConfirm } = useConfirm()\n\n// 执行注销操作\nfunction logout() {\n  // 清理重启相关状态\n  isRestarting.value = false\n  if (restartPollingId.value) {\n    clearTimeout(restartPollingId.value)\n    restartPollingId.value = null\n  }\n\n  // 清除登录状态信息\n  authStore.logout()\n  userStore.reset()\n  // 重定向到登录页面或其他适当的页面\n  router.push('/login')\n}\n\n// 检测服务状态\nasync function checkServiceStatus(): Promise<boolean> {\n  try {\n    const result: { [key: string]: any } = await api.get('system/env', { timeout: 3000 })\n    return result?.success === true\n  } catch (error) {\n    return false\n  }\n}\n\n// 轮询检测服务恢复状态\nasync function pollServiceStatus() {\n  // 如果已经有轮询在运行，先清除\n  if (restartPollingId.value) {\n    clearTimeout(restartPollingId.value)\n    restartPollingId.value = null\n  }\n\n  // 最大重试次数（约3分钟）\n  const maxRetries = 60\n  let retryCount = 0\n\n  const poll = async () => {\n    // 如果不在重启状态，停止轮询\n    if (!isRestarting.value) {\n      return\n    }\n\n    retryCount++\n    const isServiceUp = await checkServiceStatus()\n\n    if (isServiceUp) {\n      // 服务已恢复，清理状态并执行注销\n      isRestarting.value = false\n      progressDialog.value = false\n      restartPollingId.value = null\n\n      setTimeout(() => {\n        logout()\n      }, 1000)\n      return\n    }\n\n    if (retryCount >= maxRetries) {\n      // 超时未恢复，清理状态并提示用户\n      isRestarting.value = false\n      progressDialog.value = false\n      restartPollingId.value = null\n      $toast.error(t('app.restartTimeout'))\n      return\n    }\n\n    // 继续轮询，每3秒检测一次\n    restartPollingId.value = setTimeout(poll, 3000) as unknown as number\n  }\n\n  // 开始轮询\n  poll()\n}\n\n// 执行重启操作\nasync function restart() {\n  // 设置重启状态\n  isRestarting.value = true\n\n  // 调用API重启\n  try {\n    // 显示等待框\n    progressDialog.value = true\n    const result: { [key: string]: any } = await api.get('system/restart')\n    if (!result?.success) {\n      // 重启失败，清理状态\n      isRestarting.value = false\n      progressDialog.value = false\n      $toast.error(result.message)\n      return\n    }\n  } catch (error) {\n    // 重启失败，清理状态\n    isRestarting.value = false\n    progressDialog.value = false\n    console.error(error)\n    return\n  }\n\n  // 重启请求成功，开始轮询检测服务状态\n  setTimeout(() => {\n    pollServiceStatus()\n  }, 5000)\n}\n\n// 显示重启确认对话框\nasync function showRestartDialog() {\n  const isConfirmed = await createConfirm({\n    type: 'warn',\n    title: t('app.confirmRestart'),\n    content: t('app.restartTip'),\n  })\n\n  if (!isConfirmed) return\n\n  await restart()\n}\n\n// 显示站点认证对话框\nfunction showSiteAuthDialog() {\n  siteAuthDialog.value = true\n}\n\n// 显示关于对话框\nfunction showAboutDialog() {\n  aboutDialog.value = true\n}\n\n// 用户站点认证成功\nfunction siteAuthDone() {\n  siteAuthDialog.value = false\n  logout()\n}\n\n// 从用户 Store中获取信息\nconst superUser = computed(() => userStore.superUser)\nconst userName = computed(() => userStore.userName)\nconst avatar = computed(() => userStore.avatar || avatar1)\nconst userLevel = computed(() => userStore.level)\n\n// 检查是否为高级模式\nconst isAdvancedMode = computed(() => {\n  return globalSettingsStore.get('ADVANCED_MODE') !== false\n})\n\n// UI模式相关\nconst uiModes = computed(() => [\n  {\n    name: 'auto',\n    title: t('theme.autoUI'),\n    icon: 'mdi-devices',\n  },\n  {\n    name: 'desktop',\n    title: t('pwa.platforms.desktop'),\n    icon: 'mdi-monitor',\n  },\n  {\n    name: 'app',\n    title: t('pwa.platforms.mobile'),\n    icon: 'mdi-cellphone',\n  },\n])\n\n// 切换UI模式\nfunction changeUIMode(mode: UIMode) {\n  setUIMode(mode)\n  showUIModeMenu.value = false\n}\n\n// 获取当前UI模式图标\nconst getUIModeIcon = computed(() => {\n  const mode = uiModes.value.find(m => m.name === uiMode.value)\n  return mode?.icon || 'mdi-devices'\n})\n\n// 主题相关功能\nconst { name: themeName, global: globalTheme } = useTheme()\nconst savedTheme = ref(localStorage.getItem('theme') ?? 'auto')\nconst currentThemeName = ref(savedTheme.value)\n\nconst themes: ThemeSwitcherTheme[] = [\n  {\n    name: 'auto',\n    title: t('theme.auto'),\n    icon: 'mdi-laptop',\n  },\n  {\n    name: 'light',\n    title: t('theme.light'),\n    icon: 'mdi-weather-sunny',\n  },\n  {\n    name: 'dark',\n    title: t('theme.dark'),\n    icon: 'mdi-weather-night',\n  },\n  {\n    name: 'purple',\n    title: t('theme.purple'),\n    icon: 'mdi-brightness-4',\n  },\n  {\n    name: 'transparent',\n    title: t('theme.transparent'),\n    icon: 'mdi-gradient-horizontal',\n  },\n]\n\n// 编辑器主题\nconst editorTheme = computed(() => (currentThemeName.value === 'light' ? 'github' : 'monokai'))\n\n// 更新主题\nasync function updateTheme() {\n  const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'\n  const theme = currentThemeName.value === 'auto' ? autoTheme : currentThemeName.value\n\n  // 设置Vuetify主题\n  globalTheme.name.value = theme\n\n  // 统一处理主题切换 - 主题管理器会自动处理CSS加载和错误\n  await themeManager.setTheme(currentThemeName.value)\n\n  // 保存原始主题设置，而不是计算后的值\n  savedTheme.value = currentThemeName.value\n  // 保存主题到本地\n  saveLocalTheme(currentThemeName.value, globalTheme)\n}\n\n// 切换主题\nasync function changeTheme(theme: string) {\n  currentThemeName.value = theme\n  showThemeMenu.value = false\n\n  // 立即更新主题（不再刷新页面）\n  await updateTheme()\n\n  // 如果是透明主题，应用透明度设置\n  if (theme === 'transparent') {\n    applyTransparencySettings()\n  }\n\n  // 保存主题到服务端\n  try {\n    api.post('/user/config/Layout', {\n      theme,\n    })\n  } catch (e) {\n    console.error(e)\n  }\n}\n\n// 获取自定义 CSS\nasync function getCustomCSS() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/UserCustomCSS')\n    if (result && result.success && result.data?.value) {\n      customCSS.value = result.data?.value ?? ''\n      if (customCSS.value) {\n        const style = document.createElement('style')\n        style.innerHTML = result.data?.value ?? ''\n        document.head.appendChild(style)\n      }\n    }\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 保存自定义 CSS\nasync function saveCustomCSS() {\n  cssDialog.value = false\n  try {\n    const result: { [key: string]: any } = await api.post('system/setting/UserCustomCSS', customCSS.value, {\n      headers: {\n        'Content-Type': 'text/plain',\n      },\n    })\n\n    if (result.success) $toast.success(t('theme.customCssSaveSuccess'))\n  } catch (e) {\n    console.error(t('theme.customCssSaveFailed'))\n  }\n}\n\n// 应用透明度设置\nfunction applyTransparencySettings() {\n  const root = document.documentElement\n\n  // 设置CSS变量\n  root.style.setProperty('--transparent-opacity', transparencyOpacity.value.toString())\n  root.style.setProperty('--transparent-opacity-light', (transparencyOpacity.value * 0.67).toString())\n  root.style.setProperty('--transparent-opacity-heavy', (transparencyOpacity.value * 1.67).toString())\n  root.style.setProperty('--transparent-blur', `${transparencyBlur.value}px`)\n  root.style.setProperty('--transparent-blur-light', `${transparencyBlur.value * 0.6}px`)\n  root.style.setProperty('--transparent-blur-heavy', `${transparencyBlur.value * 1.6}px`)\n\n  // 保存到本地存储\n  localStorage.setItem('transparency-opacity', transparencyOpacity.value.toString())\n  localStorage.setItem('transparency-blur', transparencyBlur.value.toString())\n}\n\n// 调整透明度预设\nfunction adjustTransparency(level: string) {\n  transparencyLevel.value = level\n  localStorage.setItem('transparency-level', level)\n\n  // 设置预设值\n  switch (level) {\n    case 'low':\n      transparencyOpacity.value = 0.1\n      transparencyBlur.value = 5\n      break\n    case 'medium':\n      transparencyOpacity.value = 0.3\n      transparencyBlur.value = 10\n      break\n    case 'high':\n      transparencyOpacity.value = 0.6\n      transparencyBlur.value = 15\n      break\n  }\n\n  applyTransparencySettings()\n}\n\n// 透明度变化处理\nfunction onOpacityChange() {\n  applyTransparencySettings()\n  // 清除预设级别，因为用户手动调整了\n  transparencyLevel.value = ''\n}\n\n// 模糊度变化处理\nfunction onBlurChange() {\n  applyTransparencySettings()\n  // 清除预设级别，因为用户手动调整了\n  transparencyLevel.value = ''\n}\n\n// 重置透明度设置\nfunction resetTransparencySettings() {\n  transparencyOpacity.value = 0.3\n  transparencyBlur.value = 10\n  transparencyLevel.value = 'medium'\n  applyTransparencySettings()\n}\n\n// 监听主题变化\nwatch(\n  () => currentThemeName.value,\n  async () => {\n    await updateTheme()\n\n    // 如果切换到透明主题，应用透明度设置\n    if (currentThemeName.value === 'transparent') {\n      applyTransparencySettings()\n    }\n  },\n)\n\n// 监听系统主题变化\ntry {\n  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', async () => {\n    await updateTheme()\n  })\n} catch (e) {\n  console.error(t('theme.deviceNotSupport'))\n}\n\n// 语言相关功能\nconst currentLocale = ref<SupportedLocale>(getCurrentLocale())\n\n// 支持的语言列表\nconst locales = computed(() => {\n  return Object.entries(SUPPORTED_LOCALES).map(([key, locale]) => ({\n    value: key as SupportedLocale,\n    title: locale.title,\n    flag: locale.flag,\n    icon: `flag-${key.split('-')[0]}`,\n  }))\n})\n\n// 切换语言\nasync function changeLocale(locale: SupportedLocale) {\n  showLanguageMenu.value = false\n  try {\n    await setI18nLanguage(locale)\n    currentLocale.value = locale\n    // 刷新页面\n    window.location.reload()\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 获取当前语言图标\nconst getCurrentIcon = computed(() => {\n  const locale = locales.value.find(l => l.value === currentLocale.value)\n  return locale?.flag || '🌐'\n})\n\n// 获取当前主题图标\nconst getThemeIcon = computed(() => {\n  const theme = themes.find(t => t.name === currentThemeName.value)\n  return theme?.icon || 'mdi-laptop'\n})\n\nonMounted(() => {\n  getCustomCSS()\n\n  // 初始化透明度设置\n  if (isTransparentTheme.value) {\n    applyTransparencySettings()\n  }\n})\n\n// 组件卸载时清理轮询\nonUnmounted(() => {\n  // 清理重启轮询\n  if (restartPollingId.value) {\n    clearTimeout(restartPollingId.value)\n    restartPollingId.value = null\n  }\n  isRestarting.value = false\n})\n</script>\n\n<template>\n  <VAvatar class=\"cursor-pointer ms-3 border\" color=\"primary\" variant=\"tonal\">\n    <VImg :src=\"avatar\" />\n\n    <VMenu\n      activator=\"parent\"\n      width=\"15rem\"\n      location=\"bottom end\"\n      offset=\"14px\"\n      class=\"user-menu\"\n      :close-on-content-click=\"true\"\n      scrim\n    >\n      <VList class=\"pt-0\">\n        <!-- 👉 User Avatar & Name -->\n        <VListItem class=\"py-4\" bg-color=\"primary\" bg-opacity=\"0.05\">\n          <template #prepend>\n            <VAvatar size=\"60\" color=\"primary\" rounded=\"sm\" class=\"border-2 border-opacity-10\">\n              <VImg :src=\"avatar\" />\n            </VAvatar>\n          </template>\n          <div>\n            <span class=\"text-primary text-sm font-medium d-block\">\n              {{ superUser ? t('user.admin') : t('user.normal') }}\n            </span>\n            <span class=\"text-high-emphasis text-lg font-weight-bold\">\n              {{ userName }}\n            </span>\n          </div>\n        </VListItem>\n        <VDivider class=\"mb-2\" />\n        <div class=\"px-2\">\n          <!-- 👉 Profile -->\n          <VListItem link @click=\"router.push('/profile')\" class=\"mb-1 rounded-lg\" hover>\n            <template #prepend>\n              <VIcon icon=\"mdi-account-outline\" />\n            </template>\n            <VListItemTitle>{{ t('user.profile') }}</VListItemTitle>\n          </VListItem>\n\n          <VListItem\n            v-if=\"superUser\"\n            link\n            @click=\"isAdvancedMode ? router.push('/setting') : router.push('/setup-wizard')\"\n            class=\"mb-1 rounded-lg\"\n            hover\n          >\n            <template #prepend>\n              <VIcon :icon=\"isAdvancedMode ? 'mdi-cog-outline' : 'mdi-wizard-hat'\" />\n            </template>\n            <VListItemTitle>{{ isAdvancedMode ? t('user.systemSettings') : t('user.wizardSettings') }}</VListItemTitle>\n          </VListItem>\n\n          <!-- 👉 Site Auth -->\n          <VListItem v-if=\"userLevel < 2 && superUser\" link @click=\"showSiteAuthDialog\" class=\"mb-1 rounded-lg\" hover>\n            <template #prepend>\n              <VIcon icon=\"mdi-lock-check-outline\" />\n            </template>\n            <VListItemTitle>{{ t('user.siteAuth') }}</VListItemTitle>\n          </VListItem>\n\n          <!-- 👉 UI模式设置 - 使用嵌套菜单 -->\n          <VMenu location=\"end\" offset-x min-width=\"200\" v-model=\"showUIModeMenu\" :close-on-content-click=\"true\">\n            <template v-slot:activator=\"{ props: menuProps }\">\n              <VListItem v-bind=\"menuProps\" class=\"mb-1 rounded-lg\" hover>\n                <template #prepend>\n                  <VIcon :icon=\"getUIModeIcon\" />\n                </template>\n                <VListItemTitle>{{ t('common.uiMode') }}</VListItemTitle>\n                <VListItemSubtitle>\n                  {{ uiModes.find(m => m.name === uiMode)?.title || t('theme.autoUI') }}\n                </VListItemSubtitle>\n                <template #append>\n                  <VIcon icon=\"mdi-chevron-right\" size=\"small\" />\n                </template>\n              </VListItem>\n            </template>\n            <VList>\n              <VListItem\n                v-for=\"mode in uiModes\"\n                :key=\"mode.name\"\n                @click=\"changeUIMode(mode.name as UIMode)\"\n                :active=\"uiMode === mode.name\"\n                class=\"mb-1\"\n              >\n                <template #prepend>\n                  <VIcon :icon=\"mode.icon\" />\n                </template>\n                <VListItemTitle>{{ mode.title }}</VListItemTitle>\n                <template #append v-if=\"uiMode === mode.name\">\n                  <VIcon icon=\"mdi-check\" color=\"primary\" size=\"small\" />\n                </template>\n              </VListItem>\n            </VList>\n          </VMenu>\n\n          <!-- 👉 主题设置 - 使用嵌套菜单 -->\n          <VMenu location=\"end\" offset-x min-width=\"200\" v-model=\"showThemeMenu\" :close-on-content-click=\"true\">\n            <template v-slot:activator=\"{ props: menuProps }\">\n              <VListItem v-bind=\"menuProps\" class=\"mb-1 rounded-lg\" hover>\n                <template #prepend>\n                  <VIcon :icon=\"getThemeIcon\" />\n                </template>\n                <VListItemTitle>{{ t('common.theme') }}</VListItemTitle>\n                <VListItemSubtitle>\n                  {{ themes.find(t => t.name === currentThemeName)?.title || t('theme.auto') }}\n                </VListItemSubtitle>\n                <template #append>\n                  <VIcon icon=\"mdi-chevron-right\" size=\"small\" />\n                </template>\n              </VListItem>\n            </template>\n            <VList>\n              <VListItem\n                v-for=\"theme in themes\"\n                :key=\"theme.name\"\n                @click=\"changeTheme(theme.name)\"\n                :active=\"currentThemeName === theme.name\"\n                class=\"mb-1\"\n              >\n                <template #prepend>\n                  <VIcon :icon=\"theme.icon\" />\n                </template>\n                <VListItemTitle>{{ theme.title }}</VListItemTitle>\n                <template #append v-if=\"currentThemeName === theme.name\">\n                  <VIcon icon=\"mdi-check\" color=\"primary\" size=\"small\" />\n                </template>\n              </VListItem>\n              <VListItem @click=\"cssDialog = true\">\n                <template #prepend>\n                  <VIcon icon=\"mdi-palette\" />\n                </template>\n                <VListItemTitle>{{ t('theme.custom') }}</VListItemTitle>\n              </VListItem>\n\n              <!-- 透明度调整 - 仅在透明主题下显示 -->\n              <template v-if=\"isTransparentTheme\">\n                <VDivider class=\"my-2\" />\n                <VListItem @click=\"showTransparencyDialog = true\">\n                  <template #prepend>\n                    <VIcon icon=\"mdi-opacity\" />\n                  </template>\n                  <VListItemTitle>{{ t('theme.transparencyAdjust') }}</VListItemTitle>\n                  <template #append>\n                    <VIcon icon=\"mdi-chevron-right\" size=\"small\" />\n                  </template>\n                </VListItem>\n              </template>\n            </VList>\n          </VMenu>\n\n          <!-- 👉 语言设置 - 使用嵌套菜单 -->\n          <VMenu location=\"end\" offset-x min-width=\"200\" v-model=\"showLanguageMenu\" :close-on-content-click=\"true\">\n            <template v-slot:activator=\"{ props: menuProps }\">\n              <VListItem v-bind=\"menuProps\" class=\"mb-1 rounded-lg\" hover>\n                <template #prepend>\n                  <span class=\"me-4\">{{ getCurrentIcon }}</span>\n                </template>\n                <VListItemTitle>\n                  {{ locales.find(l => l.value === currentLocale)?.title || t('common.language') }}\n                </VListItemTitle>\n                <template #append>\n                  <VIcon icon=\"mdi-chevron-right\" size=\"small\" />\n                </template>\n              </VListItem>\n            </template>\n            <VList>\n              <VListItem\n                v-for=\"locale in locales\"\n                :key=\"locale.value\"\n                @click=\"changeLocale(locale.value)\"\n                :active=\"currentLocale === locale.value\"\n                class=\"mb-1\"\n              >\n                <template #prepend>\n                  <span class=\"text-xl me-2\">{{ locale.flag }}</span>\n                </template>\n                <VListItemTitle>{{ locale.title }}</VListItemTitle>\n                <template #append v-if=\"currentLocale === locale.value\">\n                  <VIcon icon=\"mdi-check\" color=\"primary\" size=\"small\" />\n                </template>\n              </VListItem>\n            </VList>\n          </VMenu>\n\n          <!-- 👉 FAQ -->\n          <VListItem href=\"https://movie-pilot.org\" target=\"_blank\" class=\"mb-1 rounded-lg\" hover>\n            <template #prepend>\n              <VIcon icon=\"mdi-help-circle-outline\" />\n            </template>\n            <VListItemTitle>{{ t('user.helpDocs') }}</VListItemTitle>\n          </VListItem>\n\n          <!-- 👉 About -->\n          <VListItem @click=\"showAboutDialog\" class=\"mb-1 rounded-lg\" hover>\n            <template #prepend>\n              <VIcon icon=\"mdi-information-outline\" />\n            </template>\n            <VListItemTitle>{{ t('setting.about.title') }}</VListItemTitle>\n          </VListItem>\n\n          <!-- Divider -->\n          <VDivider v-if=\"superUser\" class=\"my-3\" />\n\n          <!-- 👉 restart -->\n          <VListItem v-if=\"superUser\" @click=\"showRestartDialog\" class=\"mb-1 rounded-lg\" hover>\n            <template #prepend>\n              <VIcon icon=\"mdi-restart\" />\n            </template>\n            <VListItemTitle>{{ t('user.restart') }}</VListItemTitle>\n          </VListItem>\n        </div>\n        <!-- 👉 Logout -->\n        <div class=\"px-2 mt-3 mb-2\">\n          <VBtn color=\"error\" block class=\"py-3\" elevation=\"2\" @click=\"logout\">\n            <template #prepend>\n              <VIcon icon=\"mdi-logout\" />\n            </template>\n            {{ t('app.logout') }}\n          </VBtn>\n        </div>\n      </VList>\n    </VMenu>\n    <!-- !SECTION -->\n  </VAvatar>\n\n  <!-- 重启进度框 -->\n  <ProgressDialog v-if=\"progressDialog\" v-model=\"progressDialog\" :text=\"t('app.restarting')\" />\n  <!-- 用户认证对话框 -->\n  <UserAuthDialog v-if=\"siteAuthDialog\" v-model=\"siteAuthDialog\" @done=\"siteAuthDone\" @close=\"siteAuthDialog = false\" />\n  <!-- 自定义 CSS -->\n  <VDialog v-if=\"cssDialog\" v-model=\"cssDialog\" max-width=\"50rem\" scrollable :fullscreen=\"!display.mdAndUp.value\">\n    <VCard>\n      <VCardItem>\n        <VCardTitle>\n          <VIcon icon=\"mdi-palette\" class=\"me-2\" />\n          {{ t('theme.custom') }}\n        </VCardTitle>\n        <VDialogCloseBtn @click=\"cssDialog = false\" />\n      </VCardItem>\n      <VDivider />\n      <VAceEditor v-model:value=\"customCSS\" lang=\"css\" :theme=\"editorTheme\" class=\"w-full min-h-[30rem]\" />\n      <VDivider />\n      <VCardText class=\"text-center\">\n        <VBtn @click=\"saveCustomCSS\" class=\"w-1/2\">\n          <template #prepend>\n            <VIcon icon=\"mdi-content-save\" />\n          </template>\n          {{ t('common.save') }}\n        </VBtn>\n      </VCardText>\n    </VCard>\n  </VDialog>\n\n  <!-- 透明度调整对话框 -->\n  <VDialog v-if=\"showTransparencyDialog\" v-model=\"showTransparencyDialog\" max-width=\"30rem\">\n    <VCard>\n      <VCardItem>\n        <VCardTitle>\n          <VIcon icon=\"mdi-opacity\" class=\"me-2\" />\n          {{ t('theme.transparencyAdjust') }}\n        </VCardTitle>\n        <VDialogCloseBtn @click=\"showTransparencyDialog = false\" />\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <div class=\"space-y-6\">\n          <!-- 透明度滑动条 -->\n          <div>\n            <div class=\"d-flex align-center justify-space-between mb-2\">\n              <span class=\"text-body-2\">{{ t('theme.transparencyOpacity') }}</span>\n              <span class=\"text-caption\">{{ Math.round(transparencyOpacity * 100) }}%</span>\n            </div>\n            <VSlider\n              v-model=\"transparencyOpacity\"\n              :min=\"0\"\n              :max=\"1\"\n              :step=\"0.01\"\n              color=\"primary\"\n              @update:model-value=\"onOpacityChange\"\n            />\n          </div>\n\n          <!-- 模糊度滑动条 -->\n          <div>\n            <div class=\"d-flex align-center justify-space-between mb-2\">\n              <span class=\"text-body-2\">{{ t('theme.transparencyBlur') }}</span>\n              <span class=\"text-caption\">{{ transparencyBlur }}px</span>\n            </div>\n            <VSlider\n              v-model=\"transparencyBlur\"\n              :min=\"0\"\n              :max=\"30\"\n              :step=\"1\"\n              color=\"primary\"\n              @update:model-value=\"onBlurChange\"\n            />\n          </div>\n\n          <!-- 预设按钮 -->\n          <div>\n            <span class=\"text-body-2 d-block mb-2\">{{ t('common.preset') }}</span>\n            <VBtnGroup density=\"compact\" variant=\"outlined\" class=\"w-full\">\n              <VBtn\n                size=\"small\"\n                :color=\"currentPresetLevel === 'low' ? 'primary' : undefined\"\n                @click=\"adjustTransparency('low')\"\n                class=\"flex-1\"\n              >\n                {{ t('theme.transparencyLow') }}\n              </VBtn>\n              <VBtn\n                size=\"small\"\n                :color=\"currentPresetLevel === 'medium' ? 'primary' : undefined\"\n                @click=\"adjustTransparency('medium')\"\n                class=\"flex-1\"\n              >\n                {{ t('theme.transparencyMedium') }}\n              </VBtn>\n              <VBtn\n                size=\"small\"\n                :color=\"currentPresetLevel === 'high' ? 'primary' : undefined\"\n                @click=\"adjustTransparency('high')\"\n                class=\"flex-1\"\n              >\n                {{ t('theme.transparencyHigh') }}\n              </VBtn>\n            </VBtnGroup>\n          </div>\n        </div>\n      </VCardText>\n      <VDivider />\n      <VCardText class=\"text-center\">\n        <VBtn @click=\"resetTransparencySettings\" variant=\"outlined\" class=\"me-2\">\n          <template #prepend>\n            <VIcon icon=\"mdi-refresh\" />\n          </template>\n          {{ t('theme.transparencyReset') }}\n        </VBtn>\n        <VBtn @click=\"showTransparencyDialog = false\" color=\"primary\">\n          {{ t('common.confirm') }}\n        </VBtn>\n      </VCardText>\n    </VCard>\n  </VDialog>\n\n  <!-- 关于对话框 -->\n  <AboutDialog v-if=\"aboutDialog\" v-model=\"aboutDialog\" @close=\"aboutDialog = false\" />\n</template>\n\n<style lang=\"scss\" scoped>\n.v-list-item__prepend {\n  min-inline-size: 24px !important;\n}\n</style>\n"
  },
  {
    "path": "src/layouts/components/WorkflowSidebar.vue",
    "content": "<script lang=\"ts\" setup>\nimport api from '@/api'\nimport useDragAndDrop from '@core/utils/workflow'\nimport { useDisplay } from 'vuetify'\nimport { useI18n } from 'vue-i18n'\nimport { actionStepDict } from '@/api/constants'\nimport { usePWA } from '@/composables/usePWA'\n\ninterface ActionItem {\n  name: string\n  type: string\n  desc?: string\n}\n\nconst display = useDisplay()\n// APP\n// PWA模式检测\nconst { appMode } = usePWA()\nconst { t } = useI18n()\n\nconst { onDragStart } = useDragAndDrop()\n\n// 组件列表\nconst actions = ref<ActionItem[]>([])\n// 侧边栏是否收起 (仅在桌面端有效)\nconst isSidebarCollapsed = ref(false)\n// 侧边栏在移动端是否显示\nconst showMobileSidebar = ref(false)\n\n// 定义emit\nconst emit = defineEmits(['component-click'])\n\n// 加载组件列表\nasync function load_actions() {\n  try {\n    actions.value = await api.get('workflow/actions')\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 切换侧边栏收起状态\nfunction toggleSidebar() {\n  isSidebarCollapsed.value = !isSidebarCollapsed.value\n}\n\n// 切换移动端侧边栏显示状态\nfunction toggleMobileSidebar() {\n  showMobileSidebar.value = !showMobileSidebar.value\n}\n\n// 处理移动端点击组件事件\nfunction handleComponentClick(action: ActionItem) {\n  // 向父组件发送事件\n  emit('component-click', action)\n  // 关闭侧边栏\n  showMobileSidebar.value = false\n}\n\n// 根据动作类型获取图标\nfunction getActionIcon(type: string): string {\n  const iconMap: Record<string, string> = {\n    'AddSubscribeAction': 'mdi-star-plus',\n    'AddDownloadAction': 'mdi-download',\n    'FetchDownloadsAction': 'mdi-progress-download',\n    'FetchMediasAction': 'mdi-movie-search',\n    'FetchRssAction': 'mdi-rss',\n    'FetchTorrentsAction': 'mdi-search-web',\n    'FilterMediasAction': 'mdi-filter-check',\n    'FilterTorrentsAction': 'mdi-filter-multiple',\n    'ScanFileAction': 'mdi-folder-search',\n    'ScrapeFileAction': 'mdi-file-find',\n    'SendEventAction': 'mdi-send-check',\n    'SendMessageAction': 'mdi-message-arrow-right',\n    'TransferFileAction': 'mdi-file-move',\n    'InvokePluginAction': 'mdi-run',\n    'NoteAction': 'mdi-note-text',\n  }\n\n  return iconMap[type] || 'mdi-puzzle-outline'\n}\n\n// 计算侧边栏类名\nconst sidebarClasses = computed(() => {\n  return {\n    'sidebar-collapsed': isSidebarCollapsed.value && !display.smAndDown.value,\n    'sidebar-mobile': display.smAndDown.value,\n    'sidebar-mobile-open': showMobileSidebar.value && display.smAndDown.value,\n  }\n})\n\n// 监听屏幕尺寸变化，自动关闭移动端侧边栏\nwatch(\n  () => display.smAndDown.value,\n  isMobile => {\n    if (!isMobile) {\n      showMobileSidebar.value = false\n    }\n  },\n)\n\n// 获取动作步骤文本\nfunction getActionStepText(type: string | undefined) {\n  if (!type) return ''\n  return actionStepDict[type]\n}\n\nonMounted(() => {\n  load_actions()\n})\n</script>\n\n<template>\n  <!-- 移动端触发按钮 -->\n  <div\n    v-if=\"display.smAndDown.value\"\n    class=\"workflow-sidebar-trigger\"\n    :class=\"appMode ? 'right-4 bottom-28' : 'right-4 bottom-4'\"\n    @click=\"toggleMobileSidebar\"\n  >\n    <VBtn icon size=\"large\" class=\"workflow-sidebar-fab\">\n      <VIcon :icon=\"showMobileSidebar ? 'mdi-close' : 'mdi-plus'\" />\n    </VBtn>\n  </div>\n\n  <!-- 侧边栏 -->\n  <aside class=\"workflow-sidebar\" :class=\"sidebarClasses\">\n    <div class=\"sidebar-container\">\n      <!-- 侧边栏头部 -->\n      <div class=\"sidebar-header\">\n        <div class=\"header-content\">\n          <VAvatar size=\"36\" class=\"workflow-logo\">\n            <VIcon icon=\"mdi-apps\" />\n          </VAvatar>\n          <span v-if=\"!isSidebarCollapsed || display.smAndDown.value\" class=\"header-title\">{{\n            t('workflow.components')\n          }}</span>\n          <IconBtn v-if=\"!display.smAndDown.value\" @click=\"toggleSidebar\" class=\"collapse-btn\">\n            <VIcon :icon=\"isSidebarCollapsed ? 'mdi-chevron-right' : 'mdi-chevron-left'\" />\n          </IconBtn>\n        </div>\n      </div>\n\n      <!-- 组件列表 -->\n      <div class=\"components-container\">\n        <div\n          v-for=\"(action, index) in actions\"\n          :key=\"index\"\n          class=\"component-item\"\n          :draggable=\"!display.smAndDown.value\"\n          @dragstart=\"!display.smAndDown.value && onDragStart($event, action)\"\n          @click=\"display.smAndDown.value && handleComponentClick(action)\"\n        >\n          <VCard class=\"component-card\">\n            <VAvatar size=\"36\" class=\"component-avatar\">\n              <VIcon :icon=\"getActionIcon(action.type)\" size=\"18\" />\n            </VAvatar>\n            <div v-if=\"!isSidebarCollapsed || display.smAndDown.value\" class=\"component-info\">\n              <div class=\"component-name\">{{ getActionStepText(action.name) }}</div>\n              <div class=\"component-desc\">\n                {{ display.smAndDown.value ? t('workflow.clickToAdd') : t('workflow.dragToCanvas') }}\n              </div>\n            </div>\n          </VCard>\n        </div>\n      </div>\n\n      <!-- 底部提示 -->\n      <div class=\"sidebar-footer\">\n        <VBtn block class=\"drag-btn\">\n          <div class=\"btn-content\">\n            <VIcon v-if=\"isSidebarCollapsed && !display.smAndDown.value\" class=\"footer-icon\" icon=\"mdi-gesture-swipe\" />\n            <template v-else>\n              <VIcon :icon=\"display.smAndDown.value ? 'mdi-gesture-tap' : 'mdi-gesture-swipe'\" class=\"me-2\" />\n              <span>{{\n                display.smAndDown.value ? t('workflow.tapComponentHint') : t('workflow.dragComponentHint')\n              }}</span>\n            </template>\n          </div>\n        </VBtn>\n      </div>\n    </div>\n  </aside>\n</template>\n\n<style lang=\"scss\" scoped>\n@use 'sass:color';\n\n.workflow-sidebar {\n  position: absolute;\n  z-index: 100;\n  overflow: hidden;\n  background-color: rgb(var(--v-theme-background));\n  box-shadow: 0 0 15px rgba(0, 0, 0, 8%);\n  inline-size: 280px;\n  inset-block: 0;\n  inset-inline-start: 0;\n  transition: all 0.3s ease;\n\n  &.sidebar-collapsed {\n    inline-size: 70px;\n  }\n\n  &.sidebar-mobile {\n    inline-size: 240px;\n    transform: translateX(-100%);\n\n    &.sidebar-mobile-open {\n      transform: translateX(0);\n    }\n  }\n}\n\n.sidebar-container {\n  display: flex;\n  flex-direction: column;\n  block-size: 100%;\n}\n\n.sidebar-header {\n  flex-shrink: 0;\n  padding: 16px;\n  background-color: rgb(var(--v-theme-background));\n  border-block-end: 1px solid rgba(var(--v-theme-on-background), 0.06);\n\n  .header-content {\n    position: relative;\n    display: flex;\n    align-items: center;\n  }\n\n  .workflow-logo {\n    background-color: rgb(var(--v-theme-primary));\n    color: white;\n    margin-inline-end: 10px;\n  }\n\n  .header-title {\n    color: rgb(var(--v-theme-on-background));\n    font-size: 18px;\n    font-weight: 600;\n  }\n\n  .collapse-btn {\n    position: absolute;\n    color: rgb(var(--v-theme-primary));\n    inset-block-start: 0;\n    inset-inline-end: 0;\n  }\n}\n\n.components-container {\n  flex: 1;\n  padding: 12px;\n  overflow-y: auto;\n\n  &::-webkit-scrollbar {\n    inline-size: 5px;\n  }\n\n  &::-webkit-scrollbar-track {\n    background: transparent;\n  }\n\n  &::-webkit-scrollbar-thumb {\n    border-radius: 10px;\n    background-color: rgba(var(--v-theme-primary), 0.3);\n  }\n}\n\n.component-item {\n  cursor: grab;\n  margin-block-end: 10px;\n\n  &:active {\n    cursor: grabbing;\n  }\n}\n\n.component-card {\n  display: flex;\n  align-items: center;\n  padding: 10px;\n  border-radius: 12px;\n  background-color: rgb(var(--v-theme-surface-variant));\n  transition: all 0.2s ease;\n\n  &:hover {\n    background-color: rgb(var(--v-theme-surface-variant));\n    transform: translateY(-2px);\n  }\n}\n\n.component-avatar {\n  flex-shrink: 0;\n  background-color: rgb(var(--v-theme-primary));\n  color: white;\n  margin-inline-end: 12px;\n\n  .v-icon {\n    color: white !important;\n    opacity: 1 !important;\n  }\n}\n\n.component-info {\n  overflow: hidden;\n  max-inline-size: calc(100% - 48px);\n}\n\n.component-name {\n  overflow: hidden;\n  color: rgb(var(--v-theme-on-background));\n  font-size: 14px;\n  font-weight: 500;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.component-desc {\n  overflow: hidden;\n  color: #71717a;\n  font-size: 12px;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.sidebar-footer {\n  flex-shrink: 0;\n  padding: 12px;\n  background-color: rgb(var(--v-theme-background));\n  border-block-start: 1px solid rgba(0, 0, 0, 6%);\n\n  .drag-btn {\n    background-color: rgb(var(--v-theme-primary));\n    block-size: 44px;\n    color: white;\n    font-weight: 500;\n    letter-spacing: normal;\n    text-transform: none;\n\n    .btn-content {\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      inline-size: 100%;\n    }\n\n    .footer-icon {\n      font-size: 20px;\n    }\n  }\n}\n\n// 移动端悬浮按钮\n.workflow-sidebar-trigger {\n  position: fixed;\n  z-index: 100;\n}\n\n.workflow-sidebar-fab {\n  background-color: rgb(var(--v-theme-primary));\n  box-shadow: 0 4px 10px rgba(var(--v-theme-primary), 40%);\n  color: white;\n\n  &:hover {\n    background-color: color.adjust(#8c58f5, $lightness: -5%);\n  }\n}\n\n.sidebar-collapsed {\n  .component-card {\n    justify-content: center;\n    padding: 8px;\n  }\n\n  .component-avatar {\n    block-size: 40px !important;\n    inline-size: 40px !important;\n    margin-inline-end: 0;\n\n    .v-icon {\n      font-size: 20px !important;\n    }\n  }\n\n  .sidebar-footer {\n    padding-block: 10px;\n    padding-inline: 6px;\n\n    .drag-btn {\n      padding: 0;\n      border-radius: 10px;\n      block-size: 48px;\n      inline-size: 100%;\n      min-inline-size: 0;\n\n      .btn-content {\n        inline-size: 100%;\n      }\n    }\n  }\n}\n\n@media (width <= 600px) {\n  .component-card {\n    padding: 8px;\n  }\n\n  .component-item {\n    margin-block-end: 8px;\n  }\n\n  .components-container {\n    padding: 8px;\n  }\n\n  .sidebar-header {\n    padding: 12px;\n  }\n\n  .sidebar-footer {\n    padding: 8px;\n\n    .drag-btn {\n      block-size: 40px;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/layouts/default.vue",
    "content": "<script lang=\"ts\" setup>\nimport DefaultLayout from './components/DefaultLayout.vue'\n\nconst route = useRoute()\n</script>\n\n<template>\n  <DefaultLayout>\n    <router-view v-slot=\"{ Component }\">\n      <keep-alive>\n        <component :is=\"Component\" v-if=\"route.meta.keepAlive\" :key=\"route.fullPath\" />\n      </keep-alive>\n      <component :is=\"Component\" v-if=\"!route.meta.keepAlive\" :key=\"route.fullPath\" />\n    </router-view>\n  </DefaultLayout>\n</template>\n\n<style lang=\"scss\">\n// As we are using `layouts` plugin we need its styles to be imported\n@use '@layouts/styles/default-layout';\n</style>\n"
  },
  {
    "path": "src/locales/en-US.ts",
    "content": "export default {\n  common: {\n    confirm: 'Confirm',\n    cancel: 'Cancel',\n    save: 'Save',\n    close: 'Close',\n    version: 'Version',\n    author: 'Author',\n    delete: 'Delete',\n    edit: 'Edit',\n    add: 'Add',\n    search: 'Search',\n    loading: 'Loading',\n    success: 'Success',\n    error: 'Error',\n    openInNewWindow: 'Open in new window',\n    inputMessage: 'Enter message or command',\n    send: 'Send',\n    noData: 'No data',\n    noContent: 'No relevant content found',\n    all: 'All',\n    active: 'Active',\n    inactive: 'Inactive',\n    filter: 'Filter',\n    noMatchingData: 'No matching data',\n    tryChangingFilters: 'Try changing filters',\n    default: 'Default',\n    name: 'Name',\n    create: 'Create',\n    saving: 'Saving',\n    reset: 'Reset',\n    theme: 'Theme',\n    uiMode: 'UI Layout',\n    language: 'Language',\n    pleaseWait: 'Please wait...',\n    viewDetails: 'View Details',\n    user: 'User',\n    config: 'Configuration',\n    pause: 'Pause',\n    enable: 'Enable',\n    confirmAction: 'Confirm {action}',\n    details: 'Details',\n    files: 'Files',\n    share: 'Share',\n    subscribe: 'Subscribe',\n    unsubscribe: 'Unsubscribe',\n    media: 'Media',\n    unknown: 'Unknown',\n    notFetched: 'Not Fetched',\n    notice: 'Notice',\n    itemsPerPage: 'Items per page',\n    pageText: '{0}-{1} of {2}',\n    noDataText: 'No data',\n    next: 'Next',\n    previous: 'Previous',\n    skip: 'Skip',\n    loadingText: 'Loading...',\n    networkRequired: 'This feature requires network connection',\n    networkDisconnected: 'Network connection lost',\n    featuresLimited: 'Some features may be limited',\n    serverConnectionFailed: 'Server connection failed',\n    troubleshooting: 'Troubleshooting',\n    checking: 'Checking',\n    retry: 'Retry',\n    networkOnline: 'Network Online',\n    networkOffline: 'Network Offline',\n    serviceAvailable: 'Service Available',\n    serviceUnavailable: 'Service Unavailable',\n    status: 'Status',\n    preset: 'Preset',\n    refresh: 'Refresh',\n    swUpdateReady: 'New version is ready, please refresh the page to get the latest features',\n    ascending: 'Ascending',\n    descending: 'Descending',\n    versionMismatch: 'The browser cache version is inconsistent with the server version, please try to clear the cache',\n    clearCache: 'Clear Cache',\n  },\n  mediaType: {\n    movie: 'Movie',\n    tv: 'TV Show',\n    anime: 'Anime',\n    collection: 'Collection',\n    unknown: 'Unknown',\n  },\n  notificationSwitch: {\n    resourceDownload: 'Resource Download',\n    organize: 'Organize',\n    subscribe: 'Subscribe',\n    site: 'Site',\n    mediaServer: 'Media Server',\n    manual: 'Manual',\n    plugin: 'Plugin',\n    agent: 'Agent',\n    other: 'Other',\n  },\n  actionStep: {\n    addDownload: 'Add Download',\n    addSubscribe: 'Add Subscribe',\n    fetchDownloads: 'Fetch Downloads',\n    fetchMedias: 'Fetch Media',\n    fetchRss: 'Fetch RSS',\n    fetchTorrents: 'Fetch Torrents',\n    filterMedias: 'Filter Media',\n    filterTorrents: 'Filter Torrents',\n    scanFile: 'Scan Directory',\n    scrapeFile: 'Scrape File',\n    sendEvent: 'Send Event',\n    sendMessage: 'Send Message',\n    transferFile: 'Transfer File',\n    invokePlugin: 'Invoke Plugin',\n    note: 'Note',\n  },\n  qualityOptions: {\n    all: 'All',\n    blurayOriginal: 'Blu-ray Original',\n    remux: 'Remux',\n    bluray: 'BluRay',\n    uhd: 'UHD',\n    webdl: 'WEB-DL',\n    hdtv: 'HDTV',\n    h265: 'H265',\n    h264: 'H264',\n  },\n  resolutionOptions: {\n    all: 'All',\n    '4k': '4K',\n    '1080p': '1080p',\n    '720p': '720p',\n  },\n  effectOptions: {\n    all: 'All',\n    dolbyVision: 'Dolby Vision',\n    dolbyAtmos: 'Dolby Atmos',\n    hdr: 'HDR',\n    sdr: 'SDR',\n  },\n  theme: {\n    light: 'Light',\n    dark: 'Dark',\n    auto: 'Follow System',\n    autoUI: 'Auto',\n    transparent: 'Transparent',\n    purple: 'Purple',\n    custom: 'Custom Style',\n    transparency: 'Transparency',\n    transparencyAdjust: 'Transparency Adjustment',\n    transparencyOpacity: 'Opacity',\n    transparencyBlur: 'Blur',\n    transparencyReset: 'Reset',\n    transparencyLow: 'Low Transparency',\n    transparencyMedium: 'Medium Transparency',\n    transparencyHigh: 'High Transparency',\n    customCssSaveSuccess: 'Custom CSS saved successfully, please refresh the page to take effect!',\n    customCssSaveFailed: 'Failed to save custom CSS to server',\n    deviceNotSupport: 'Current device does not support monitoring system theme changes',\n  },\n  app: {\n    moviepilot: 'MoviePilot',\n    slogan: 'Intelligent Movie & TV Media Library Management Tool',\n    recommend: 'Recommend',\n    subscribeMovie: 'Movie Subscription',\n    subscribeTv: 'TV Subscription',\n    settings: 'Settings',\n    selectLanguage: 'Select Language',\n    logout: 'Logout',\n    restarting: 'Restarting...',\n    confirmRestart: 'Confirm restart system?',\n    restartTip: 'After restart, you will be logged out and need to log in again.',\n    restartTimeout: 'Restart timeout, the system may need more time to recover, please refresh the page manually later',\n    restartFailed: 'Restart failed, please check system status',\n    offline: 'Application Offline',\n    offlineMessage: 'Network connection lost, some features may be limited',\n    online: 'Application Online',\n    onlineMessage: 'Network connection restored',\n  },\n  pwa: {\n    installApp: 'Install MoviePilot App',\n    installDescription: 'Get better offline experience and performance',\n    install: 'Install',\n    installSuccess: 'App installed successfully!',\n    installGuide: 'Installation Guide',\n    installInstructions: 'Install MoviePilot on {platform}:',\n    installNote:\n      'After installation, you can quickly access MoviePilot from your home screen and enjoy offline features.',\n    gotIt: 'Got it',\n    // Platform specific descriptions\n    platforms: {\n      ios: 'iOS',\n      android: 'Android',\n      chrome: 'Chrome',\n      edge: 'Edge',\n      firefox: 'Firefox',\n      safari: 'Safari',\n      desktop: 'Desktop',\n      mobile: 'Mobile',\n      other: 'Other Browser',\n    },\n    // Installation steps\n    installSteps: {\n      ios: {\n        0: 'Tap the share button at the bottom of the browser',\n        1: 'Select \"Add to Home Screen\"',\n        2: 'Tap \"Add\" to confirm installation',\n      },\n      android: {\n        0: 'Tap the browser menu (three dots)',\n        1: 'Select \"Add to Home Screen\" or \"Install App\"',\n        2: 'Tap \"Install\" to confirm',\n      },\n      chrome: {\n        0: 'Click the install icon in the address bar',\n        1: 'Or click \"Install MoviePilot\" in the browser menu',\n        2: 'Click \"Install\" to confirm',\n      },\n      edge: {\n        0: 'Click the app icon in the address bar',\n        1: 'Select \"Install this site as an app\"',\n        2: 'Click \"Install\" to confirm',\n      },\n      firefox: {\n        0: 'Click the install icon in the address bar',\n        1: 'Select \"Install\"',\n        2: 'Confirm installation to desktop',\n      },\n      safari: {\n        0: 'Click the share button',\n        1: 'Select \"Add to Home Screen\"',\n        2: 'Tap \"Add\" to confirm',\n      },\n      desktop: {\n        0: 'Click the install icon in the address bar',\n        1: 'Select \"Install App\"',\n        2: 'Follow the prompts to complete installation',\n      },\n      mobile: {\n        0: 'Tap the browser menu',\n        1: 'Select \"Add to Home Screen\"',\n        2: 'Confirm installation',\n      },\n      other: {\n        0: 'Look for \"Install\" option in your browser',\n        1: 'Usually in the address bar or menu',\n        2: 'Follow the prompts to complete installation',\n      },\n    },\n  },\n  login: {\n    wallpapers: 'Wallpapers',\n    username: 'Username',\n    password: 'Password',\n    otpCode: 'Verification Code',\n    stayLoggedIn: 'Stay Logged In',\n    login: 'Login',\n    networkError: 'Login failed, please check your network connection!',\n    authFailure: 'Login failed, please check your username, password or secondary verification!',\n    permissionDenied: 'Login failed, you do not have permission to access!',\n    noPermission: 'Login failed, you have no functional permissions, please contact the administrator!',\n    serverError: 'Login failed, server error!',\n    loginFailed: 'Login Failed',\n    secondaryVerification: 'Secondary Verification',\n    orDivider: 'OR',\n    loginWithPasskey: 'Login with Passkey',\n    loginWithOtp: 'Login with OTP',\n    orUsePasskey: 'Or use Passkey for verification',\n    verifyWithPasskey: 'Verify with Passkey',\n    otpPlaceholder: 'Enter 6-digit code',\n    passkeyLoginStartFailed: 'Failed to start Passkey authentication',\n    passkeyNotSelected: 'No Passkey selected',\n    passkeyLoginFailed: 'Passkey login failed',\n    passkeyAuthCanceled: 'Passkey authentication canceled',\n    passkeyNotSupported: 'Current browser does not support Passkeys',\n    passkeySecureContextRequired: 'Passkey requires HTTPS secure connection',\n    passkeyVerifyFailed: 'Passkey verification failed',\n    passkeyVerifyFailedRetry: 'Passkey verification failed, please try again',\n    mfa: {\n      selectVerificationMethod: 'Please select a verification method',\n    },\n  },\n  menu: {\n    start: 'Start',\n    discovery: 'Discovery',\n    subscribe: 'Subscribe',\n    organize: 'Organize',\n    system: 'System',\n  },\n  navItems: {\n    dashboard: 'Dashboard',\n    mediaInfo: 'Media Library',\n    recommend: 'Recommend',\n    site: 'Sites',\n    search: 'Search',\n    searchResult: 'Search Results',\n    download: 'Download',\n    movieSubscribe: 'Movie Subscription',\n    tvSubscribe: 'TV Subscription',\n    history: 'History',\n    transfer: 'Organize',\n    rename: 'Rename',\n    statistic: 'Statistics',\n    setting: 'Settings',\n    plugin: 'Plugins',\n    user: 'Users',\n    about: 'About',\n    explore: 'Explore',\n    movie: 'Movies',\n    tv: 'TV Shows',\n    workflow: 'Workflow',\n    calendar: 'Calendar',\n    downloadManager: 'Download Manager',\n    mediaOrganize: 'Media Organize',\n    fileManager: 'File Manager',\n    pluginManager: 'Plugins',\n    siteManager: 'Site Management',\n    userManager: 'User Management',\n    settings: 'Settings',\n  },\n  settingTabs: {\n    system: {\n      title: 'System',\n      description:\n        'Basic settings, downloaders (Qbittorrent, Transmission), media servers (Emby, Jellyfin, Plex, TrimeMedia, Ugreen)',\n    },\n    directory: {\n      title: 'Storage & Directories',\n      description: 'Download directory, media library directory, organization, scraping',\n    },\n    site: {\n      title: 'Sites',\n      description: 'Site synchronization, site data refresh, site reset',\n    },\n    rule: {\n      title: 'Rules',\n      description: 'Custom rules, priority rule groups, download rules',\n    },\n    search: {\n      title: 'Search & Download',\n      description: 'Search data sources (TheMovieDb, Douban, Bangumi), download task tags, search sites',\n    },\n    subscribe: {\n      title: 'Subscription',\n      description: 'Subscription sites, subscription mode, subscription rules, version upgrade rules',\n    },\n    scheduler: {\n      title: 'Services',\n      description: 'Scheduled jobs',\n    },\n    cache: {\n      title: 'Cache',\n      description: 'Torrent cache, media recognition data cache, image file cache management',\n    },\n    notification: {\n      title: 'Notifications',\n      description: 'Notification channels (WeChat, Telegram, Slack, SynologyChat, VoceChat, WebPush), message scope',\n    },\n    about: {\n      title: 'About',\n      description: 'Software version',\n    },\n  },\n  subscribeTabs: {\n    movie: {\n      mysub: 'My Subscriptions',\n      popular: 'Popular Subscriptions',\n    },\n    tv: {\n      mysub: 'My Subscriptions',\n      popular: 'Popular Subscriptions',\n      share: 'Subscription Shares',\n    },\n  },\n  workflowTabs: {\n    list: 'My Workflows',\n    share: 'Workflow Share',\n  },\n  pluginTabs: {\n    installed: 'My Plugins',\n    market: 'Plugin Market',\n  },\n  discoverTabs: {\n    themoviedb: 'TheMovieDb',\n    douban: 'Douban',\n    bangumi: 'Bangumi',\n  },\n  user: {\n    admin: 'Admin',\n    normal: 'Normal User',\n    active: 'Active',\n    inactive: 'Inactive',\n    noEmail: 'No Email',\n    movieSubscriptions: 'Movie Subscriptions',\n    tvSubscriptions: 'TV Show Subscriptions',\n    cannotDeleteCurrentUser: 'Cannot delete the currently logged in user!',\n    confirmDeleteUser: 'Are you sure you want to delete all data of user {username}?',\n    deleteSuccess: 'User deleted successfully',\n    deleteFailed: 'Failed to delete user!',\n    profile: 'Profile',\n    systemSettings: 'System Settings',\n    wizardSettings: 'Setup Wizard',\n    siteAuth: 'User Authentication',\n    helpDocs: 'Help Documents',\n    about: 'About',\n    restart: 'Restart',\n    management: 'User Management',\n    noUsers: 'No Users',\n    clickToAddUser: 'Click Add User card to add users',\n    addUser: 'Add User',\n    editUser: 'Edit User',\n    username: 'Username',\n    usernameHint: 'Username for system login',\n    password: 'Password',\n    passwordHint: 'Please enter your login password',\n    confirmPassword: 'Confirm Password',\n    confirmPasswordHint: 'Please enter the password again to confirm',\n    role: 'Role',\n    email: 'Email',\n    enabled: 'Enabled',\n    disabled: 'Disabled',\n    status: 'Status',\n    operations: 'Operations',\n  },\n  nav: {\n    more: 'More',\n  },\n  notification: {\n    center: 'Notification Center',\n    markRead: 'Mark as Read',\n    empty: 'No Notifications',\n    channel: 'Notification Channel',\n    name: 'Name',\n    nameHint: 'Name of notification channel',\n    type: 'Type',\n    typeHint: 'Type of notification channel',\n    customTypeHint: 'Custom notification type, used for plugin implementation scenarios',\n    customTypePlaceholder: 'custom',\n    nameRequired: 'Please enter name',\n    enabled: 'Enabled',\n    config: 'Configuration',\n    wechat: {\n      name: 'WeChat Work',\n      useBotMode: 'Use AI Bot',\n      useBotModeHint: 'Enable WebSocket bot mode with fixed dmPolicy=open and groupPolicy=disabled',\n      corpId: 'Corp ID',\n      corpIdHint: 'Corp ID in WeChat Work backend enterprise information',\n      corpIdRequired: 'Corp ID cannot be empty',\n      appId: 'App AgentId',\n      appIdHint: 'AgentId of self-built app in WeChat Work',\n      appIdRequired: 'App AgentId cannot be empty',\n      appSecret: 'App Secret',\n      appSecretHint: 'Secret of self-built app in WeChat Work',\n      appSecretRequired: 'App Secret cannot be empty',\n      proxy: 'Proxy Address',\n      proxyHint:\n        'Proxy address for WeChat message forwarding, required for self-built apps created after June 20, 2022',\n      token: 'Token',\n      tokenHint: 'Token in WeChat Work self-built app -> API message receiving configuration',\n      encodingAesKey: 'EncodingAESKey',\n      encodingAesKeyHint: 'EncodingAESKey in WeChat Work self-built app -> API message receiving configuration',\n      botId: 'Bot ID',\n      botIdHint: 'Bot ID of the WeChat Work AI bot',\n      botSecret: 'Bot Secret',\n      botSecretHint: 'WebSocket secret of the WeChat Work AI bot',\n      botChatId: 'Default Target',\n      botChatIdHint: 'Use user userid; for proactive group messages use group:chatid. Leave empty to notify known interacted users',\n      botChatIdPlaceholder: 'userid or group:chatid',\n      botWsUrl: 'WebSocket URL',\n      botWsUrlHint: 'WebSocket endpoint for the WeChat Work AI bot, usually the default value',\n      admins: 'Admin Whitelist',\n      adminsHint: 'User IDs that can use admin menu and commands, separated by commas',\n      adminsPlaceholder: 'User IDs list, separated by commas',\n    },\n    telegram: {\n      name: 'Telegram',\n      token: 'Bot Token',\n      tokenHint: 'Telegram bot token, format: 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',\n      tokenRequired: 'Bot Token cannot be empty',\n      chatId: 'Chat ID',\n      chatIdHint: 'Chat ID of user, group or channel that receives notifications',\n      chatIdRequired: 'Chat ID cannot be empty',\n      users: 'User Whitelist',\n      usersHint: 'User IDs that can use Telegram bot, separated by commas. Leave empty to allow all users',\n      admins: 'Admin Whitelist',\n      adminsHint: 'User IDs that can use admin menu and commands, separated by commas',\n      adminsPlaceholder: 'User IDs list, separated by commas',\n      usersPlaceholder: 'User IDs list, separated by commas',\n      apiUrl: 'Proxy Api Url',\n      apiUrlHint: 'Custom proxy Api Url, format: https://api.telegram.org',\n      apiUrlPlaceholder: 'https://api.telegram.org',\n    },\n    slack: {\n      name: 'Slack',\n      oauthToken: 'Slack Bot User OAuth Token',\n      oauthTokenHint: 'Bot User OAuth Token in Slack app OAuth & Permissions page',\n      oauthTokenRequired: 'OAuth Token cannot be empty',\n      appToken: 'Slack App-Level Token',\n      appTokenHint: 'App-Level Token in Slack app OAuth & Permissions page',\n      channel: 'Channel Name',\n      channelHint: 'Channel to send messages, default is \"all\"',\n      channelRequired: 'Channel Name cannot be empty',\n    },\n    discord: {\n      name: 'Discord',\n      botToken: 'Bot Token',\n      botTokenHint: 'Discord Bot Token (enable Message Content Intent in Dev Portal)',\n      botTokenRequired: 'Bot Token is required',\n      guildId: 'Guild ID',\n      guildIdHint: 'Optional, restrict to a specific guild; leave blank to use any joined guild',\n      guildIdPlaceholder: '123456789012345678',\n      channelId: 'Channel ID',\n      channelIdHint: 'Optional, default broadcast channel; leave blank to auto-pick a writable channel',\n      channelIdPlaceholder: '123456789012345678',\n    },\n    synologychat: {\n      name: 'Synology Chat',\n      webhook: 'Webhook URL',\n      webhookHint: 'Synology Chat bot webhook URL',\n      webhookRequired: 'Webhook URL cannot be empty',\n      token: 'Token',\n      tokenHint: 'Synology Chat bot token',\n    },\n    vocechat: {\n      name: 'VoceChat',\n      host: 'Address',\n      hostHint: 'VoceChat server address, format: http(s)://ip:port',\n      hostRequired: 'Address cannot be empty',\n      apiKey: 'Bot API Key',\n      apiKeyHint: 'VoceChat bot API key',\n      apiKeyRequired: 'API Key cannot be empty',\n      channelId: 'Channel ID',\n      channelIdHint: 'VoceChat channel ID, without #',\n    },\n    webpush: {\n      name: 'WebPush',\n      username: 'Login Username',\n      usernameHint: 'Only push messages to the corresponding logged-in user',\n      usernameRequired: 'Username cannot be empty',\n    },\n    qqbot: {\n      name: 'QQ',\n      appId: 'App ID',\n      appIdHint: 'QQ Open Platform bot App ID',\n      appIdRequired: 'App ID cannot be empty',\n      appSecret: 'App Secret',\n      appSecretHint: 'QQ Open Platform bot App Secret',\n      appSecretRequired: 'App Secret cannot be empty',\n      openId: 'User OpenID',\n      openIdHint: 'Default recipient openid (C2C), user must have interacted with bot before',\n      openIdPlaceholder: '32-char hex',\n      groupOpenId: 'Group OpenID',\n      groupOpenIdHint: 'Default group openid (group chat), use either this or User OpenID',\n      groupOpenIdPlaceholder: 'Group openid',\n    },\n  },\n  shortcut: {\n    title: 'Shortcuts',\n    recognition: {\n      title: 'Recognition',\n      subtitle: 'Name Recognition Test',\n    },\n    rule: {\n      title: 'Rules',\n      subtitle: 'Rule Testing',\n    },\n    log: {\n      title: 'Logs',\n      subtitle: 'Real-time Logs',\n    },\n    network: {\n      title: 'Network',\n      subtitle: 'Network Speed and Connectivity Test',\n    },\n    system: {\n      title: 'System',\n      subtitle: 'Health Check',\n    },\n    message: {\n      title: 'Messages',\n      subtitle: 'Message Center',\n    },\n    words: {\n      title: 'Words',\n      subtitle: 'Word Settings',\n    },\n    cache: {\n      title: 'Cache',\n      subtitle: 'Manage Cache',\n    },\n    scheduler: {\n      title: 'Services',\n      subtitle: 'Scheduled Services',\n    },\n  },\n  workflow: {\n    components: 'Action Components',\n    clickToAdd: 'Click to Add',\n    dragToCanvas: 'Drag to Canvas',\n    tapComponentHint: 'Tap component to add to canvas',\n    dragComponentHint: 'Drag component to canvas',\n    task: {\n      edit: 'Edit Task',\n      editFlow: 'Edit Flow',\n      share: 'Share',\n      continue: 'Continue',\n      restart: 'Restart',\n      run: 'Run Now',\n      reset: 'Reset Task',\n      delete: 'Delete Task',\n      confirmDelete: 'Are you sure to delete task {name} ?',\n      confirmReset: 'Are you sure to reset task {name} ?',\n      deleteSuccess: 'Task deleted successfully!',\n      deleteFailed: 'Failed to delete task: {message}',\n      enableSuccess: 'Task enabled successfully!',\n      enableFailed: 'Failed to enable task: {message}',\n      pauseSuccess: 'Task paused successfully!',\n      pauseFailed: 'Failed to pause task: {message}',\n      runSuccess: 'Task execution completed!',\n      runFailed: 'Task execution failed: {message}',\n      resetSuccess: 'Task reset successfully!',\n      resetFailed: 'Failed to reset task: {message}',\n      status: {\n        success: 'Success',\n        running: 'Running',\n        failed: 'Failed',\n        paused: 'Paused',\n        waiting: 'Waiting',\n      },\n      info: {\n        trigger: 'Trigger',\n        timer: 'Timer',\n        status: 'Status',\n        actionCount: 'Action Count',\n        runCount: 'Run Count',\n        progress: 'Progress',\n        error: 'Error Message',\n        manualTrigger: 'Manual',\n      },\n    },\n    scanFile: {\n      title: 'Scan Directory',\n      subtitle: 'Scan directory files to queue',\n      storage: 'Storage',\n      directory: 'Directory',\n    },\n    addDownload: {\n      title: 'Add Download',\n      subtitle: 'Add resource to downloader',\n      downloader: 'Downloader',\n      category: 'Category',\n      savePath: 'Save Path',\n      sequential: 'Sequential',\n      forceResume: 'Force Resume',\n      firstLastPiece: 'First Last Piece',\n      onlyLack: 'Only Download Lack Resource',\n      categoryPlaceholder: 'Use comma to separate multiple',\n      savePathPlaceholder: 'Leave empty for auto',\n    },\n    addSubscribe: {\n      title: 'Add Subscribe',\n      subtitle: 'Add resource to subscription',\n      type: 'Type',\n      name: 'Name',\n      season: 'Season',\n      episode: 'Episode',\n    },\n    fetchMedias: {\n      title: 'Fetch Media Data',\n      subtitle: 'Fetch media data list from rankings',\n      source: 'Source',\n      searchType: 'Search Type',\n      type: 'Type',\n      name: 'Name',\n      year: 'Year',\n      ranking: 'Ranking',\n      api: 'Plugin API',\n      apiPath: 'API Path',\n      selectRanking: 'Select Ranking',\n      tmdbTrending: 'TMDB Trending',\n      doubanShowing: 'Now Showing',\n      bangumiCalendar: 'Bangumi Daily',\n      tmdbMovies: 'TMDB Popular Movies',\n      tmdbTvs: 'TMDB Popular TV Shows',\n      doubanMovieHot: 'Douban Hot Movies',\n      doubanTvHot: 'Douban Hot TV Shows',\n      doubanTvAnimation: 'Douban Hot Anime',\n      doubanMovies: 'Douban Latest Movies',\n      doubanTvs: 'Douban Latest TV Shows',\n      doubanMovieTop250: 'Douban Movie TOP250',\n      doubanTvWeeklyChinese: 'Douban Chinese TV Weekly',\n      doubanTvWeeklyGlobal: 'Douban Global TV Weekly',\n    },\n    filterMedias: {\n      title: 'Filter Media Data',\n      subtitle: 'Filter media data list',\n      type: 'Type',\n      name: 'Name',\n      year: 'Year',\n      vote: 'Vote',\n    },\n    scrapeFile: {\n      title: 'Scrape File',\n      subtitle: 'Scrape media info and images',\n    },\n    sendEvent: {\n      title: 'Send Event',\n      subtitle: 'Send task execution event',\n    },\n    fetchDownloads: {\n      title: 'Fetch Downloads',\n      subtitle: 'Fetch download queue task status',\n      loop: 'Loop Execution',\n      loopInterval: 'Loop Interval (seconds)',\n    },\n    fetchRss: {\n      title: 'Fetch RSS Resources',\n      subtitle: 'Subscribe RSS feed to get resources',\n      url: 'RSS URL',\n      userAgent: 'User-Agent',\n      timeout: 'Timeout',\n      matchMedia: 'Match Media Info',\n      useProxy: 'Use Proxy',\n    },\n    fetchTorrents: {\n      title: 'Search Site Resources',\n      subtitle: 'Search site torrent resource list',\n      searchType: 'Search Type',\n      searchOptions: {\n        name: 'Name',\n        mediaList: 'Media List',\n      },\n      name: 'Name',\n      year: 'Year',\n      type: 'Type',\n      season: 'Season',\n      sites: 'Sites',\n      matchMedia: 'Match Media Info',\n    },\n    sendMessage: {\n      title: 'Send Message',\n      subtitle: 'Send task execution message',\n      channel: 'Message Channel',\n      userId: 'User ID',\n    },\n    transferFile: {\n      title: 'Organize Files',\n      subtitle: 'Organize and rename files in queue',\n      source: 'Source',\n      sourceOptions: {\n        fileList: 'File List',\n        downloads: 'Downloads',\n      },\n    },\n    filterTorrents: {\n      title: 'Filter Resources',\n      subtitle: 'Filter resource list',\n      quality: 'Quality',\n      resolution: 'Resolution',\n      effect: 'Effect',\n      size: 'Size Range',\n      include: 'Include (Keywords, Regex)',\n      exclude: 'Exclude (Keywords, Regex)',\n      ruleGroups: 'Filter Rule Groups',\n    },\n    invokePlugin: {\n      title: 'Invoke Plugin',\n      subtitle: 'Call plugin to perform specific actions',\n      plugin: 'Plugin',\n      actionid: 'Action ID',\n      actionParams: 'Action Parameters',\n      loadPluginSettingFailed: 'Failed to load plugin settings',\n    },\n    note: {\n      title: 'Note',\n      subtitle: 'Add workflow description notes',\n      content: 'Note Content',\n      placeholder: 'Please enter note content...',\n    },\n    title: 'Workflow',\n    share: 'Workflow Share',\n    searchShares: 'Search Workflow Shares',\n    noShareData: 'No shared workflows',\n    sharer: 'Sharer',\n    trigger: 'Trigger',\n    timer: 'Timer',\n    manualTrigger: 'Manual Trigger',\n    actionCount: 'Action Count',\n    normalFork: 'Fork Workflow',\n    cancelShare: 'Cancel Share',\n    cancelSuccess: 'Share cancelled successfully',\n    cancelFailed: 'Failed to cancel share: {message}',\n    usageCount: 'Used {count} times',\n    addSuccess: 'Forked {name} successfully!',\n    addFailed: 'Failed to fork {name}: {message}',\n    noWorkflow: 'No Workflow',\n    noWorkflowDescription: 'Click the add button to create a workflow task.',\n  },\n  dashboard: {\n    storage: 'Storage',\n    mediaStatistic: 'Media Statistics',\n    weeklyOverview: 'Recent Imports',\n    realTimeSpeed: 'Real-time Speed',\n    scheduler: 'Background Tasks',\n    cpu: 'CPU',\n    memory: 'Memory',\n    network: 'Network Traffic',\n    upload: 'Upload',\n    download: 'Download',\n    library: 'My Media Library',\n    playing: 'Continue Watching',\n    latest: 'Recently Added',\n    settings: 'Dashboard Settings',\n    chooseContent: 'Choose content to display',\n    adaptiveHeight: 'Adaptive Component Height',\n    current: 'Current',\n    episodes: 'Episodes',\n    users: 'Users',\n    noSchedulers: 'No Background Services',\n    weeklyOverviewDescription: 'Added {count} media in the last week',\n    speed: {\n      totalUpload: 'Total Upload',\n      totalDownload: 'Total Download',\n      freeSpace: 'Free Disk Space',\n    },\n    processes: {\n      title: 'System Processes',\n      pid: 'Process ID',\n      name: 'Process Name',\n      runtime: 'Runtime',\n      memory: 'Memory Usage',\n    },\n    errors: {\n      loadMediaServer: 'Failed to load media server settings:',\n      loadLatest: 'Failed to load recently added from media server \"{server}\":',\n    },\n  },\n  media: {\n    status: {\n      inLibrary: 'In Library',\n      missing: 'Missing',\n      partiallyMissing: 'Partially Missing',\n      subscribed: 'Subscribed',\n    },\n    minutes: 'minutes',\n    overview: 'Overview',\n    seasons: 'Seasons',\n    seasonNumber: 'Season {number}',\n    episodeCount: '{count} Episodes',\n    actions: {\n      searchResource: 'Search Resource',\n      subscribe: 'Subscribe',\n      playOnline: 'Play Online',\n      playInApp: 'Play in App',\n      playInWeb: 'Play in Web',\n    },\n    search: {\n      byTitle: 'Title',\n      byImdb: 'IMDB Link',\n    },\n    info: {\n      originalTitle: 'Original Title',\n      status: 'Status',\n      releaseDate: 'Release Date',\n      digitalRelease: 'Digital Release',\n      physicalRelease: 'Physical Release',\n      originalLanguage: 'Original Language',\n      productionCountries: 'Production Countries',\n      productionCompanies: 'Production Companies',\n      doubanId: 'Douban ID',\n    },\n    subscribe: {\n      normal: 'Subscribe',\n      bestVersion: 'Best Version Subscribe',\n      addFailed: 'Failed to add subscription: {reason}!',\n      canceled: 'Subscription canceled!',\n      cancelFailed: 'Failed to cancel subscription: {reason}!',\n    },\n    castAndCrew: 'Cast & Crew',\n    recommendations: 'Recommendations',\n    similar: 'Similar',\n    error: {\n      title: 'Error!',\n      noMediaInfo: 'No media information recognized.',\n    },\n    server: {\n      plex: 'Plex',\n      jellyfin: 'Jellyfin',\n      emby: 'Emby',\n      appLaunchFailed: 'App launch failed, redirecting to web version',\n      appNotInstalled: 'App not detected, redirecting to web version',\n      downloadApp: 'Download App',\n    },\n  },\n  subscribe: {\n    normalSub: 'Subscribe',\n    versionSub: 'Version Upgrade Subscribe',\n    addSuccess: 'Added {name} successfully!',\n    addFailed: 'Failed to add {name}: {message}!',\n    cancelSuccess: 'Subscription cancelled!',\n    cancelFailed: 'Failed to cancel subscription: {message}!',\n    filterSubscriptions: 'Filter Subscriptions',\n    name: 'Name',\n    searchShares: 'Search Subscription Shares',\n    keyword: 'Keyword',\n    noShareData: 'No shared subscription data received, data sharing not enabled or server cannot connect.',\n    noPopularData: 'No popular subscription data received, data sharing not enabled or server cannot connect.',\n    noFilterData: 'No related content found with current filters, please change filter conditions.',\n    noSubscribeData: 'Please search to add movie or TV show subscriptions.',\n    sharer: 'Sharer',\n    follow: 'Follow',\n    unfollow: 'Unfollow',\n    recognitionWords: 'Recognition Words',\n    cancelShare: 'Cancel Share',\n    usageCount: '{count} Uses',\n    confirmToggle: 'Are you sure you want to {action} subscription {name}?',\n    toggleSuccess: '{name} has been {action}d!',\n    toggleFailed: '{action} failed: {message}',\n    resetConfirm:\n      'After reset, {name} will be restored to its initial state, downloaded records will be cleared, and unimported content will be downloaded again. Are you sure?',\n    resetSuccess: '{name} has been reset successfully!',\n    resetFailed: '{name} reset failed: {message}',\n    shareStatistics: 'Share Statistics',\n    shareCount: 'Shares',\n    totalReuseCount: 'Total Reuse Count',\n    ranking: 'Ranking',\n    noStatisticsData: 'No share statistics data available',\n    bestVersion: 'Version Upgrading',\n    completed: 'Completed',\n    subscribing: 'Subscribing',\n    notStarted: 'Not Started',\n    pending: 'Pending',\n    paused: 'Paused',\n    selectedCount: 'Selected {count}/{total} items',\n    noSelectedItems: 'Please select subscriptions to operate',\n    batchEnable: 'Batch Enable',\n    batchPause: 'Batch Pause',\n    batchDelete: 'Batch Delete',\n    batchEnableConfirm: 'Are you sure you want to enable {count} selected subscriptions?',\n    batchPauseConfirm: 'Are you sure you want to pause {count} selected subscriptions?',\n    batchDeleteConfirm: 'Are you sure you want to delete {count} selected subscriptions? This action cannot be undone!',\n    batchEnableSuccess: 'Successfully enabled {count} subscriptions',\n    batchPauseSuccess: 'Successfully paused {count} subscriptions',\n    batchDeleteSuccess: 'Successfully deleted {count} subscriptions',\n    batchEnableFailed: 'Failed to enable {count} subscriptions',\n    batchPauseFailed: 'Failed to pause {count} subscriptions',\n    batchDeleteFailed: 'Failed to delete {count} subscriptions',\n    batchEnableError: 'Batch enable operation failed',\n    batchPauseError: 'Batch pause operation failed',\n    batchDeleteError: 'Batch delete operation failed',\n    minSubscribers: 'Minimum Subscribers',\n  },\n  recommend: {\n    all: 'All',\n    categoryMovie: 'Movies',\n    categoryTV: 'TV Shows',\n    categoryAnime: 'Anime',\n    categoryRankings: 'Rankings',\n    trendingNow: 'Trending Now',\n    nowShowing: 'Now Showing',\n    bangumiDaily: 'Bangumi Daily Release',\n    tmdbHotMovies: 'TMDB Hot Movies',\n    tmdbHotTVShows: 'TMDB Hot TV Shows',\n    doubanHotMovies: 'Douban Hot Movies',\n    doubanHotTVShows: 'Douban Hot TV Shows',\n    doubanHotAnime: 'Douban Hot Anime',\n    doubanNewMovies: 'Douban New Movies',\n    doubanNewTVShows: 'Douban New TV Shows',\n    doubanTop250: 'Douban Top 250 Movies',\n    doubanChineseTVRankings: 'Douban Chinese TV Rankings',\n    doubanGlobalTVRankings: 'Douban Global TV Rankings',\n    noCategoryContent: 'No content to display in current category',\n    configureContent: 'Configure Display Content',\n    customizeContent: 'Customize Content',\n    selectContentToDisplay: 'Select content you want to display on the page',\n    selectAll: 'Select All',\n    selectNone: 'Select None',\n  },\n  discover: {\n    setTabOrder: 'Set Tab Order',\n    dragToReorder: 'Drag to reorder tabs',\n  },\n  downloading: {\n    noDownloader: 'No Downloader',\n    configureDownloader: 'Please configure and enable a downloader in settings first.',\n    title: 'Downloading',\n    noTask: 'No Task',\n    noTaskDescription: 'Downloading tasks will be displayed here.',\n  },\n  resource: {\n    searchResults: 'Resource Search Results',\n    keyword: 'Keyword',\n    title: 'Title',\n    year: 'Year',\n    season: 'Season',\n    switchingView: 'Switching View',\n    backToHome: 'Back to Home',\n    searching: 'Searching, please wait...',\n    noData: 'No Data',\n    noResourceFound: 'No resources found',\n    aiRecommend: 'AI Recommendation',\n    reRecommend: 'Regenerate Recommendation',\n    aiRecommendError: 'AI Recommendation Failed',\n  },\n  browse: {\n    actor: 'Actor',\n  },\n  appcenter: {\n    others: 'Others',\n  },\n  notFound: {\n    title: '⚠️ Page Not Found',\n    description: 'The page you tried to access does not exist. Please check if the address is correct.',\n    backButton: 'Go Back',\n  },\n  torrent: {\n    selectAll: 'Select All',\n    clear: 'Clear',\n    clearFilters: 'Clear Filters',\n    confirm: 'Confirm',\n    resources: 'resources',\n    noResults: 'No results found',\n    sortDefault: 'Default',\n    sortSite: 'Site',\n    sortSize: 'Size',\n    sortSeeder: 'Seeder',\n    sortPublishTime: 'Publish Time',\n    filterSite: 'Site',\n    filterSeason: 'Season',\n    filterFreeState: 'Free State',\n    filterVideoCode: 'Video Code',\n    filterEdition: 'Edition',\n    filterResolution: 'Resolution',\n    filterReleaseGroup: 'Release Group',\n    noMatchingResults: 'No matching data',\n    allFilters: 'All Filters',\n    clearAll: 'Clear All',\n  },\n  calendar: {\n    episode: 'Episode {number}',\n  },\n  storage: {\n    name: 'Name',\n    type: 'Type',\n    customTypeHint: 'Custom storage type, used for plugins and other scenarios',\n    usedPercent: '{percent}% Used',\n    noConfigNeeded: 'This storage type does not require configuration, please configure the directory directly!',\n    notConfigured: 'Not Configured',\n    local: 'Local',\n    alipan: 'Aliyun Drive',\n    u115: '115 Cloud',\n    rclone: 'RClone',\n    alist: 'OpenList',\n    smb: 'SMB Network Share',\n    custom: 'Custom',\n  },\n  filterRules: {\n    specSub: 'Special Subtitle',\n    cnSub: 'Chinese Subtitle',\n    cnVoi: 'Chinese Dubbing',\n    gz: 'Official Seed',\n    notCnVoi: 'Exclude: Chinese Dubbing',\n    hkVoi: 'Cantonese Dubbing',\n    notHkVoi: 'Exclude: Cantonese Dubbing',\n    free: 'Promotion: Free',\n    resolution4k: 'Resolution: 4K',\n    resolution1080p: 'Resolution: 1080P',\n    resolution720p: 'Resolution: 720P',\n    not720p: 'Exclude: 720P',\n    qualityBlu: 'Quality: Blu-ray',\n    notBlu: 'Exclude: Blu-ray',\n    qualityBluray: 'Quality: BLURAY',\n    notBluray: 'Exclude: BLURAY',\n    qualityUhd: 'Quality: UHD',\n    notUhd: 'Exclude: UHD',\n    qualityRemux: 'Quality: REMUX',\n    notRemux: 'Exclude: REMUX',\n    qualityWebdl: 'Quality: WEB-DL',\n    notWebdl: 'Exclude: WEB-DL',\n    quality60fps: 'Quality: 60fps',\n    not60fps: 'Exclude: 60fps',\n    codecH265: 'Codec: H265',\n    notH265: 'Exclude: H265',\n    codecH264: 'Codec: H264',\n    notH264: 'Exclude: H264',\n    effectDolby: 'Effect: Dolby Vision',\n    notDolby: 'Exclude: Dolby Vision',\n    effectAtmos: 'Effect: Dolby Atmos',\n    notAtmos: 'Exclude: Dolby Atmos',\n    effectHdr: 'Effect: HDR',\n    notHdr: 'Exclude: HDR',\n    effectSdr: 'Effect: SDR',\n    notSdr: 'Exclude: SDR',\n    effect3d: 'Effect: 3D',\n    not3d: 'Exclude: 3D',\n  },\n  transferType: {\n    copy: 'Copy',\n    move: 'Move',\n    link: 'Hard Link',\n    softlink: 'Soft Link',\n  },\n  site: {\n    noSites: 'No Sites',\n    noFilterData: 'No matching sites found',\n    sitesWillBeShownHere: 'Added and supported sites will be displayed here.',\n    title: 'Site',\n    status: {\n      enabled: 'Enabled',\n      disabled: 'Disabled',\n    },\n    fields: {\n      url: 'Site URL',\n      priority: 'Priority',\n      status: 'Status',\n      rss: 'RSS URL',\n      timeout: 'Timeout (seconds)',\n      downloader: 'Downloader',\n      cookie: 'Site Cookie',\n      userAgent: 'User-Agent',\n      authorization: 'Authorization Header',\n      apiKey: 'API Key',\n      limitAccess: 'Limit Site Access Frequency',\n      limitInterval: 'Interval Period (seconds)',\n      limitCount: 'Access Count per Period',\n      limitSeconds: 'Access Interval (seconds)',\n      useProxy: 'Use Proxy',\n      browserSimulation: 'Browser Simulation',\n      selectFile: 'Select File',\n    },\n    hints: {\n      url: 'Format: http://www.example.com/',\n      priority: 'Lower priority value means higher priority',\n      status: 'Enable/Disable site',\n      rss: 'Subscription link used when subscription mode is `Site RSS`, manual input if not auto-retrieved',\n      timeout: 'Site request timeout, no limit when set to 0',\n      downloader: 'Downloader used for this site',\n      cookie: 'Cookie information in site request header',\n      userAgent: 'User-Agent of the browser used to get Cookie',\n      authorization: 'Authorization information in site request header, required for special sites',\n      apiKey: 'Site API Key, required for special sites',\n      limitInterval: 'Duration of the rate limit control period',\n      limitCount: 'Allowed access count within the period',\n      limitSeconds: 'Minimum interval between each access',\n      useProxy: 'Use proxy server to access this site',\n      browserSimulation: 'Use browser simulation for authentic site access',\n      import: 'Batch import site data, supports JSON format files',\n      selectFile: 'Select JSON file',\n      dragDropFile: 'Drag and drop file here or click to select file',\n      supportedFormat: 'Supports JSON format site configuration files',\n    },\n    actions: {\n      add: 'Add Site',\n      edit: 'Edit Site',\n      import: 'Import',\n      export: 'Export',\n      startImport: 'Start Import',\n    },\n    messages: {\n      addSuccess: 'Site added successfully',\n      addFailed: 'Failed to add site',\n      updateSuccess: 'Updated successfully',\n      updateFailed: 'Update failed',\n      exportSuccess: 'Sites exported successfully',\n      exportFailed: 'Failed to export sites',\n      importSuccess: 'Successfully imported {count} sites',\n      importFailed: 'Failed to import sites',\n      importPartialFailed: 'Import completed, {success} successful, {failed} failed',\n      importAllFailed: 'Import failed, all {count} sites failed to import',\n      noDataToImport: 'No data to import',\n      noValidData: 'No valid data',\n      someInvalidData: 'Some data is invalid, valid data: {valid}/{total}',\n      invalidFileType: 'Unsupported file type, please select a JSON file',\n      invalidFileFormat: 'Invalid file format, please check file content',\n      parseFileError: 'Failed to parse file, please check file format',\n      previewData: 'Preview data ({count} sites)',\n      importing: 'Importing... ({progress}%)',\n      importErrors: 'Import encountered {count} errors',\n    },\n    errors: {\n      loadDownloader: 'Failed to load downloader settings',\n      title: 'Import Error Details',\n      failed: 'Import Failed',\n      details: 'Error Details',\n    },\n    results: {\n      successTitle: 'Successfully Imported Sites',\n      success: 'Import Success',\n    },\n    testConnectivity: 'Test Connectivity',\n    testing: 'Testing ...',\n    testSuccess: '{name} connectivity test successful, ready to use!',\n    testFailed: '{name} connectivity test failed: {message}',\n    connectionNormal: 'Connection Normal',\n    connectionSlow: 'Connection Slow',\n    connectionFailed: 'Connection Failed',\n    connectionUnknown: 'Connection Unknown',\n    deleteConfirm: 'Are you sure you want to delete this site?',\n    deleteSuccess: '{name} deleted successfully!',\n    deleteFailed: '{name} deletion failed: {message}',\n    browseResources: 'Browse Resources',\n    deleteSite: 'Delete Site',\n    updateCookie: 'Update Cookie',\n    viewUserData: 'View User Data',\n    statistics: 'Statistics',\n    totalSites: 'Total Sites',\n    normalSites: 'Normal Sites',\n    slowSites: 'Slow Sites',\n    failedSites: 'Failed Sites',\n    averageTime: 'Average Time',\n    successRate: 'Success Rate',\n    successCount: 'Success Count',\n    failCount: 'Fail Count',\n    lastAccess: 'Last Access',\n    timeRecords: 'Time Records',\n    recentTimeRecords: 'Recent Time Records',\n    accessTime: 'Access Time',\n    responseTime: 'Response Time',\n    noTimeRecords: 'No Time Records',\n    preview: {\n      title: 'Preview Sites',\n      showing: 'Showing {count}/{total}',\n      unnamed: 'Unnamed Site',\n      noUrl: 'No Site URL',\n      invalid: 'Invalid Data',\n    },\n  },\n  message: {\n    loadMore: 'Load More',\n    noMoreData: 'No more data',\n  },\n  logging: {\n    level: 'Level',\n    time: 'Time',\n    program: 'Program',\n    content: 'Content',\n    refreshing: 'Refreshing',\n    initializing: 'Initializing',\n  },\n  moduleTest: {\n    normal: 'Normal',\n    disabled: 'Disabled',\n    error: 'Error',\n    checking: 'Checking...',\n    complete: 'Check Complete',\n    preparing: 'Preparing...',\n    totalModules: 'Total Modules',\n    recheck: 'Recheck',\n  },\n  nameTest: {\n    recognize: 'Recognize',\n    recognizing: 'Recognizing...',\n    recognizeAgain: 'Recognize Again',\n    title: 'Title',\n    subtitle: 'Subtitle',\n  },\n  netTest: {\n    notTested: 'Not Tested',\n    testing: 'Testing...',\n    normal: 'Normal',\n  },\n  ruleTest: {\n    test: 'Test',\n    testing: 'Testing...',\n    testAgain: 'Test Again',\n    title: 'Title',\n    subtitle: 'Subtitle',\n    ruleGroup: 'Rule Group',\n    priority: 'Priority: {value}',\n    noPriorityRule: 'No priority rule matched!',\n  },\n  setting: {\n    about: {\n      title: 'About MoviePilot',\n      softwareVersion: 'Software Version',\n      frontendVersion: 'Frontend Version',\n      browserVersion: 'Browser Cached Version',\n      authVersion: 'Auth Resource Version',\n      indexerVersion: 'Indexer Resource Version',\n      configDir: 'Config Directory',\n      dataDir: 'Data Directory',\n      timezone: 'Timezone',\n      latest: 'Latest',\n      supportingSites: 'Supporting Sites',\n      support: 'Support',\n      documentation: 'Documentation',\n      feedback: 'Feedback',\n      channel: 'Release Channel',\n      versions: 'Software Versions',\n      latestVersion: 'Latest Version',\n      currentVersion: 'Current Version',\n      viewChangelog: 'View Changelog',\n      changelog: 'Changelog',\n      dataDirectory: '/moviepilot',\n      expand: 'Expand',\n      collapse: 'Collapse',\n      clearCache: 'Clear Cache',\n    },\n    system: {\n      custom: 'Custom',\n      basicSettings: 'Basic Settings',\n      basicSettingsDesc: 'Configure server global functions.',\n      appDomain: 'Access Domain',\n      appDomainHint: 'Used to add quick jump links when sending notifications',\n      wallpaper: 'Background Wallpaper',\n      wallpaperHint: 'Choose the source of the login page background',\n      recognizeSource: 'Recognition Data Source',\n      recognizeSourceHint: 'Set the default media info recognition data source',\n      mediaServerSyncInterval: 'Media Server Sync Interval',\n      mediaServerSyncIntervalHint: 'Time interval for syncing media server data to local',\n      hours: 'hours',\n      required: 'Required field, please fill in',\n      numbersOnly: 'Only numbers are supported, please do not enter other characters',\n      minInterval: 'Interval cannot be less than 1 hour',\n      apiToken: 'API Token',\n      apiTokenHint: 'Set the token value used when external requests access MoviePilot API',\n      apiTokenMinChars: 'Cannot be less than 16 characters',\n      apiTokenRequired: 'Required field; please enter API Token',\n      apiTokenLength: 'API Token must be at least 16 characters',\n      githubToken: 'Github Token',\n      githubTokenFormat: 'ghp_**** or github_pat_****',\n      githubTokenHint:\n        'Used to increase the rate limit threshold when plugins access Github API，it is recommended to configure, otherwise plugins may not work properly',\n      ocrHost: 'OCR Server',\n      ocrHostHint: 'Used for site check-in, updating site cookies and other captcha recognition',\n      aiAgent: 'Enable AI Assistant',\n      aiAgentEnable: 'Enable AI Assistant',\n      aiAgentEnableHint: 'Enable AI assistant functionality, requires LLM configuration',\n      aiAgentSectionTitle: 'AI Assistant Configuration',\n      aiAgentSectionDesc:\n        'After enabling it, you can use the Agent in message conversations and optionally turn on transfer-failure takeover and AI recommendations.',\n      llmProvider: 'LLM Provider',\n      llmProviderHint: 'Select the LLM service provider to use',\n      llmModel: 'LLM Model Name',\n      llmModelHint: 'Specify the LLM model to use, such as gpt-3.5-turbo, deepseek-chat, etc.',\n      llmThinking: 'Thinking Mode / Depth',\n      llmThinkingHint:\n        'Thinking depth: off/auto/minimal/low/medium/high/max/xhigh. Unsupported levels will be mapped to the nearest provider-supported value.',\n      llmThinkingLevelOff: 'Off (off)',\n      llmThinkingLevelAuto: 'Auto (auto)',\n      llmThinkingLevelMinimal: 'Minimal (minimal)',\n      llmThinkingLevelLow: 'Low (low)',\n      llmThinkingLevelMedium: 'Medium (medium)',\n      llmThinkingLevelHigh: 'High (high)',\n      llmThinkingLevelMax: 'Max (max)',\n      llmThinkingLevelXhigh: 'XHigh (xhigh)',\n      llmMaxContextTokens: 'LLM Max Context Tokens (K)',\n      llmMaxContextTokensHint:\n        'Set the maximum number of context tokens (in thousands) for the LLM. Exceeding this limit will trigger context trimming.',\n      llmApiKey: 'LLM API Key',\n      llmApiKeyHint: 'API key from the LLM service provider for authentication',\n      llmApiKeyPlaceholder: 'Please enter API key',\n      llmBaseUrl: 'LLM Base URL',\n      llmBaseUrlHint: 'Base URL for LLM API, used for custom API endpoints',\n      llmTestAction: 'Test Call',\n      llmTestSuccessToast: 'LLM test call succeeded',\n      llmTestFailedToast: 'LLM test call failed',\n      llmTestFailedToastWithMessage: 'LLM test call failed: {message}',\n      aiAgentGlobal: 'Global AI Assistant',\n      aiAgentGlobalHint:\n        'Enable global AI assistant functionality, all message conversations will be answered by the AI agent without using the /ai command',\n      aiAgentJobInterval: 'Scheduled Wake',\n      aiAgentJobIntervalHint:\n        'Set the check interval for scheduled wake. Select \"Disabled\" to disable scheduled tasks.',\n      aiAgentVerbose: 'Verbose Mode',\n      aiAgentVerboseHint: 'When enabled, tool call process will be displayed in AI agent responses',\n      aiAgentJobIntervalDisabled: 'Disabled',\n      aiAgentJobInterval1h: '1 Hour',\n      aiAgentJobInterval3h: '3 Hours',\n      aiAgentJobInterval6h: '6 Hours',\n      aiAgentJobInterval12h: '12 Hours',\n      aiAgentJobInterval24h: '24 Hours',\n      aiAgentJobInterval1w: '1 Week',\n      aiAgentJobInterval1M: '1 Month',\n      advancedSettings: 'Advanced Settings',\n      advancedSettingsDesc: 'System advanced settings, only need to be adjusted in special cases',\n      downloaders: 'Downloaders',\n      downloadersDesc: 'Only the default downloader will be used by default.',\n      aiAgentRetryTransfer: 'AI Takeover on Transfer Failure',\n      aiAgentRetryTransferHint:\n        'When enabled, the AI assistant will automatically take over and retry when file transfer/organization fails, using AI capabilities to resolve recognition and matching issues.',\n      aiRecommendEnabled: 'AI Search Recommendation',\n      aiRecommendEnabledHint:\n        'Enable AI search recommendation. When enabled, an AI recommendation button will be displayed on the search result page, recommending resources based on user preferences.',\n      aiRecommendUserPreference: 'User Preference',\n      aiRecommendUserPreferenceHint: 'Set user preferences for AI recommendation, e.g., 4K WEB-DL Dolby Vision',\n      aiRecommendMaxItems: 'AI Recommendation Analysis Limit',\n      aiRecommendMaxItemsHint:\n        'Limit the number of search results sent to the AI assistant for analysis. More items mean slower analysis and more token consumption. It is recommended to manually filter to a general range before using AI recommendation.',\n      mediaServers: 'Media Servers',\n      mediaServersDesc: 'All enabled media servers will be used.',\n      trimeMedia: 'TrimeMedia',\n      system: 'System',\n      media: 'Media',\n      network: 'Network',\n      log: 'Log',\n      lab: 'Lab',\n      downloaderSaveSuccess: 'Downloader settings saved successfully',\n      downloaderSaveFailed: 'Failed to save downloader settings!',\n      defaultDownloaderNotice: 'No default downloader set, [{name}] has been set as the default downloader',\n      mediaServerSaveSuccess: 'Media server settings saved successfully',\n      mediaServerSaveFailed: 'Failed to save media server settings!',\n      saveFailed: 'Failed to save settings: {message}!',\n      basicSaveSuccess: 'Basic settings saved successfully',\n      advancedSaveSuccess: 'Advanced settings saved successfully',\n      copySuccess: 'Copied to clipboard!',\n      copyFailed: 'Copy failed: browser may not support or user blocked!',\n      copyError: 'Copy failed!',\n      reloading: 'Applying configuration...',\n      qbittorrent: 'Qbittorrent',\n      transmission: 'Transmission',\n      rtorrent: 'rTorrent',\n      emby: 'Emby',\n      jellyfin: 'Jellyfin',\n      plex: 'Plex',\n      ugreen: 'Ugreen',\n      reloadSuccess: 'System configuration has taken effect',\n      reloadFailed: 'Failed to reload system!',\n      auxAuthEnable: 'User Auxiliary Authentication',\n      auxAuthEnableHint: 'Allow external services to authenticate login and automatically create users',\n      globalImageCache: 'Global Image Cache',\n      globalImageCacheHint: 'Cache media images locally to improve image loading speed',\n      subscribeStatisticShare: 'Share Subscription Data',\n      subscribeStatisticShareHint:\n        'Share subscription statistics to popular subscriptions for other MP users to reference',\n      pluginStatisticShare: 'Report Plugin Installation Data',\n      pluginStatisticShareHint: 'Report plugin installation data to the server for statistics and display purposes',\n      workflowStatisticShare: 'Share Workflow Data',\n      workflowStatisticShareHint: 'Share workflow statistics to popular workflows for other MP users to reference',\n      bigMemoryMode: 'Large Memory Mode',\n      bigMemoryModeHint: 'Use more memory to cache data and improve system performance',\n      dbWalEnable: 'Sqlite WAL Mode',\n      dbWalEnableHint:\n        'Can improve read/write concurrency performance, but may increase the risk of data loss in exceptional cases, requires restart to take effect',\n      tmdbApiDomain: 'TMDB API Service Address',\n      tmdbApiDomainPlaceholder: 'api.themoviedb.org',\n      tmdbApiDomainHint: 'Customize themoviedb API domain or proxy address',\n      tmdbApiDomainRequired: 'Please enter TMDB API domain',\n      tmdbImageDomain: 'TMDB Image Service Address',\n      tmdbImageDomainPlaceholder: 'image.tmdb.org',\n      tmdbImageDomainHint: 'Customize themoviedb image service domain or proxy address',\n      tmdbImageDomainRequired: 'Please enter image service domain',\n      tmdbLocale: 'TMDB Metadata Language',\n      tmdbLocalePlaceholder: 'en',\n      tmdbLocaleHint: 'Customize themoviedb metadata language',\n      metaCacheExpire: 'Media Metadata Cache Expiration Time',\n      metaCacheExpireHint: 'Recognition metadata local cache time, use built-in default value when set to 0',\n      metaCacheExpireRequired: 'Please enter metadata cache time',\n      metaCacheExpireMin: 'Metadata cache time must be greater than or equal to 0',\n      scrapFollowTmdb: 'Follow TMDB Recognition',\n      scrapFollowTmdbHint:\n        'When turned off, organization history will be used (if available) to avoid TMDB data changes during subscription',\n      scrapOriginalImage: 'Scrap TheMovieDb Original Language Image',\n      scrapOriginalImageHint: 'Scrap original language image from themoviedb, otherwise scrap metadata language image',\n      fanartEnable: 'Fanart Image Data Source',\n      fanartEnableHint: 'Use image data from fanart.tv',\n      fanartLang: 'Fanart Language',\n      fanartLangHint: 'Set language preference for Fanart images, ordered by priority when multiple selected',\n      recognizePluginFirst: \"Prioritize Plugin Recognition\",\n      recognizePluginFirstHint: \"Prioritize calling plugins for media recognition. If a plugin matches, native recognition will be skipped\",\n      githubProxy: 'Github Acceleration Proxy',\n      githubProxyPlaceholder: 'Leave empty for no proxy',\n      githubProxyHint: 'Use proxy to accelerate Github access speed',\n      pipProxy: 'PIP Acceleration Proxy',\n      pipProxyPlaceholder: 'Leave empty for no proxy',\n      pipProxyHint: 'Use proxy to accelerate pip library installation speed for plugins, etc.',\n      dohEnable: 'DNS Over HTTPS',\n      dohEnableHint: 'Use DOH to resolve specific domains to prevent DNS pollution',\n      dohResolvers: 'DOH Servers',\n      dohResolversPlaceholder: 'https://dns.google/dns-query,1.1.1.1',\n      dohResolversHint: 'DNS resolver server addresses, multiple addresses separated by commas',\n      dohDomains: 'DOH Domains',\n      dohDomainsPlaceholder: 'example.com,example2.com',\n      dohDomainsHint: 'Domains to be resolved using DOH, multiple domains separated by commas',\n      debug: 'Debug Mode',\n      debugHint: 'When debug mode is enabled, logs will be recorded at DEBUG level to help troubleshoot issues',\n      logLevel: 'Log Level',\n      logLevelHint: 'Set the level of log recording to control log output volume',\n      logMaxFileSize: 'Maximum Log File Size (MB)',\n      logMaxFileSizeHint: 'Limit the maximum size of a single log file, logs will be split automatically when exceeded',\n      logMaxFileSizeRequired: 'Maximum log file size',\n      logMaxFileSizeMin: 'Maximum log file size must be greater than or equal to 1',\n      logBackupCount: 'Maximum Number of Log File Backups',\n      logBackupCountHint:\n        'Set the maximum number of backups for each module log file, old logs will be overwritten when exceeded',\n      logBackupCountRequired: 'Please enter the maximum number of log file backups',\n      logBackupCountMin: 'Maximum number of log file backups must be greater than or equal to 1',\n      logFileFormat: 'Log File Format',\n      logFileFormatHint: 'Set the output format of log files to customize the displayed content of logs',\n      pluginAutoReload: 'Plugin Hot Reload',\n      pluginAutoReloadHint: 'Automatically reload after modifying plugin files, used when developing plugins',\n      pluginLocalRepoPaths: 'Local Plugin Repository Paths',\n      pluginLocalRepoPathsHint:\n        'Local plugin repository directories. Separate multiple directories with commas. Relative and absolute paths are supported.',\n      encodingDetectionPerformanceMode: 'Encoding Detection Performance Mode',\n      encodingDetectionPerformanceModeHint:\n        'Prioritize detection efficiency, but may reduce encoding detection accuracy',\n      transferThreads: 'File Transfer Threads',\n      transferThreadsHint: 'Multi-threaded file transfer can improve speed but may increase system resource usage',\n      tokenizedSearch: 'Tokenized Search',\n      tokenizedSearchHint:\n        'Improve organization history search precision, but may increase performance overhead and unexpected results',\n      tmdbLanguage: {\n        zhCN: 'Simplified Chinese',\n        zhTW: 'Traditional Chinese',\n        en: 'English',\n      },\n      fanartLanguage: {\n        zh: 'Chinese',\n        en: 'English',\n        ja: 'Japanese',\n        ko: 'Korean',\n        de: 'German',\n        fr: 'French',\n        es: 'Spanish',\n        it: 'Italian',\n        pt: 'Portuguese',\n        ru: 'Russian',\n      },\n      logLevelItems: {\n        debug: 'DEBUG',\n        info: 'INFO',\n        warning: 'WARNING',\n        error: 'ERROR',\n        critical: 'CRITICAL',\n      },\n      wallpaperItems: {\n        tmdb: 'TMDB Movie Posters',\n        bing: 'Bing Daily Wallpaper',\n        mediaserver: 'Media Server',\n        none: 'No Wallpaper',\n        customize: 'Customize',\n      },\n      mb: 'MB',\n      hour: 'hour',\n      customizeWallpaperApi: 'Customize Wallpaper Api',\n      customizeWallpaperApiHint:\n        'It will get the image file extension format images that are allowed in settings in the content returned by the API.',\n      customizeWallpaperApiRequired: 'Required field; please enter Wallpaper API',\n      securityImageDomains: 'Security Image Domains',\n      securityImageDomainsHint: 'Allowed image domains whitelist for caching, used to control trusted image sources',\n      noSecurityImageDomains: 'No security domains',\n      securityImageDomainAdd: 'Add domain, e.g.: image.tmdb.org',\n      proxyHost: 'Proxy Server',\n      proxyHostHint: 'Set proxy server address, support: http(s), socks5, socks5h, etc.',\n      moviePilotAutoUpdate: 'Auto Update MoviePilot',\n      moviePilotAutoUpdateHint: 'Automatically update MoviePilot to the latest release version when restarting',\n      autoUpdateResource: 'Auto Update Resource',\n      autoUpdateResourceHint: 'Automatically detect and update site resource package when restarting',\n      // Scraping Switch Settings\n      scrapingSwitchSettings: 'Scraping Switch Settings',\n      scrapingSwitchSettingsDesc: 'Control various media file scraping function switches',\n      movie: 'Movie',\n      tv: 'TV Show',\n      season: 'Season',\n      episode: 'Episode',\n      movieNfo: 'NFO',\n      seasonNfo: 'NFO',\n      moviePoster: 'Poster',\n      movieBackdrop: 'Backdrop',\n      movieLogo: 'Logo',\n      movieDisc: 'Disc',\n      movieBanner: 'Banner',\n      movieThumb: 'Thumb',\n      tvNfo: 'NFO',\n      tvPoster: 'Poster',\n      tvBackdrop: 'Backdrop',\n      tvBanner: 'Banner',\n      tvLogo: 'Logo',\n      tvThumb: 'Thumb',\n      seasonPoster: 'Poster',\n      seasonBanner: 'Banner',\n      seasonThumb: 'Thumb',\n      episodeNfo: 'NFO',\n      episodeThumb: 'Thumb',\n      scrapingSwitchSaveFailed: 'Scraping switch settings save failed: {message}',\n      scrapingSwitchSaveError: 'Scraping switch settings save failed',\n      policy: {\n        skipDesc: 'Skip scraping, this file will not be generated',\n        missingOnlyDesc: 'Scrape only if missing, existing file remains unchanged',\n        overwriteDesc: 'Always scrape, existing file will be overwritten',\n      }\n    },\n    site: {\n      siteSync: 'Site Synchronization',\n      siteSyncDesc: 'Quickly sync site data from CookieCloud',\n      enableLocalCookieCloud: 'Enable Local CookieCloud Server',\n      enableLocalCookieCloudHint:\n        'Use built-in CookieCloud service to sync site data, service address: http://localhost:3000/cookiecloud',\n      serviceAddress: 'Service Address',\n      serviceAddressPlaceholder: 'https://movie-pilot.org/cookiecloud',\n      serviceAddressHint: 'Remote CookieCloud service address, format: https://movie-pilot.org/cookiecloud',\n      userKey: 'User KEY',\n      userKeyHint: 'User KEY generated by CookieCloud browser plugin',\n      e2ePassword: 'End-to-End Encryption Password',\n      e2ePasswordHint: 'End-to-end encryption password generated by CookieCloud browser plugin',\n      autoSyncInterval: 'Auto Sync Interval',\n      autoSyncIntervalHint:\n        'Time interval for automatically syncing site cookies from CookieCloud server to MoviePilot',\n      syncBlacklist: 'Sync Domain Blacklist',\n      syncBlacklistPlaceholder: 'Multiple domains, separated by commas',\n      syncBlacklistHint: 'CookieCloud sync domain blacklist, multiple domains separated by commas',\n      userAgent: 'Browser User-Agent',\n      userAgentHint: 'User-Agent of the browser with CookieCloud plugin',\n      browserEmulation: 'Browser Emulation',\n      browserEmulationHint: 'Choose how to emulate browser when accessing sites (Playwright or FlareSolverr)',\n      flaresolverrUrl: 'FlareSolverr URL',\n      flaresolverrUrlHint: 'Required when using FlareSolverr, e.g. http://127.0.0.1:8191',\n      siteDataRefresh: 'Site Data Refresh',\n      siteOptions: 'Site Options',\n      siteDataRefreshInterval: 'Site Data Refresh Interval',\n      siteDataRefreshIntervalHint: 'Time interval for refreshing site user upload/download data',\n      readSiteMessage: 'Read Site Messages',\n      readSiteMessageHint: 'Read site messages and send notifications when refreshing data',\n      siteReset: 'Site Reset',\n      confirmReset: 'Confirm to delete all site data and resync.',\n      confirmResetHint:\n        'Delete all site data and resync from CookieCloud. Please clear all related site settings before this operation.',\n      resetSites: 'Reset Site Data',\n      resettingSites: 'Resetting...',\n      syncInterval: {\n        hourly: 'Hourly',\n        every6Hours: 'Every 6 Hours',\n        every12Hours: 'Every 12 Hours',\n        daily: 'Daily',\n        weekly: 'Weekly',\n        monthly: 'Monthly',\n        never: 'Never',\n      },\n      saveSuccess: 'Site settings saved successfully',\n      saveFailed: 'Failed to save site settings!',\n      resetSuccess: 'Sites reset successfully, please wait for CookieCloud sync to complete!',\n      resetFailed: 'Failed to reset sites!',\n    },\n    notification: {\n      channels: 'Notification Channels',\n      channelsDesc: 'Set message sending channel parameters',\n      organizeSuccess: 'Media Import',\n      downloadAdded: 'Download Added',\n      subscribeAdded: 'Subscribe Added',\n      subscribeComplete: 'Subscribe Complete',\n      templateConfigTitle: 'Message Template',\n      templateConfigDesc: 'Set message template, support Jinja2 syntax.',\n      templateSaveFailed: 'Failed to save template!',\n      templateSaveSuccess: 'Template saved successfully',\n      templateLoadFailed: 'Failed to load template!',\n      scope: 'Notification Scope',\n      scopeDesc: 'Corresponding message types will only be sent to specified users.',\n      messageType: 'Message Type',\n      scopeRange: 'Scope',\n      operationUserOnly: 'Operation User Only',\n      adminOnly: 'Admin Only',\n      userAndAdmin: 'Operation User and Admin',\n      allUsers: 'All Users',\n      sendTime: 'Notification Send Time',\n      sendTimeDesc: 'Set the time range for sending messages.',\n      startTime: 'Start Time',\n      endTime: 'End Time',\n      saveSuccess: 'Notification settings saved successfully',\n      saveFailed: 'Failed to save notification settings!',\n      switchSaveSuccess: 'Message type switches saved successfully',\n      switchSaveFailed: 'Failed to save message type switches!',\n      timeSaveSuccess: 'Notification send time saved successfully',\n      timeSaveFailed: 'Failed to save notification send time!',\n      channel: 'Notification',\n      wechat: 'WeChat',\n      resourceDownload: 'Resource Download',\n      mediaImport: 'Media Import',\n      subscription: 'Subscription',\n      site: 'Site',\n      mediaServer: 'Media Server',\n      manualProcess: 'Manual Process',\n      plugin: 'Plugin',\n      other: 'Other',\n      telegram: 'Telegram',\n      slack: 'Slack',\n      synologyChat: 'SynologyChat',\n      voceChat: 'VoceChat',\n      webPush: 'WebPush',\n      qq: 'QQ',\n      custom: 'Custom Notification',\n    },\n    words: {\n      customIdentifiers: 'Custom Identifiers',\n      identifiersDesc: 'Add rules to preprocess torrent names or file names to correct identification',\n      identifiersPlaceholder: 'Support regular expressions, special characters need \\\\ escape, one line for each rule',\n      identifiersHint: 'Support regular expressions, special characters need \\\\ escape, one line for each rule',\n      formatTitle: 'Supported configuration formats (mind the spaces):',\n      formatContent:\n        'Block words\\n' +\n        'Word to replace => Replacement\\n' +\n        'Front word <> Back word >> Episode offset (EP)\\n' +\n        'Word to replace => Replacement && Front word <> Back word >> Episode offset (EP)\\n' +\n        'Replacement format supports: &#123;[tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx]&#125; to directly specify TMDBID/Douban ID, where s and e are season and episode numbers (optional)',\n      identifierSaveSuccess: 'Custom identifiers saved successfully',\n      identifierSaveFailed: 'Failed to save custom identifiers!',\n\n      customReleaseGroups: 'Custom Release/Subtitle Groups',\n      releaseGroupsDesc: 'Add release/subtitle groups that cannot be identified.',\n      releaseGroupsPlaceholder:\n        'Support regular expressions, special characters need \\\\ escape, one line for each group',\n      releaseGroupsHint: 'Support regular expressions, special characters need \\\\ escape, one line for each group',\n      releaseGroupSaveSuccess: 'Custom release/subtitle groups saved successfully',\n      releaseGroupSaveFailed: 'Failed to save custom release/subtitle groups!',\n\n      customization: 'Custom Placeholders',\n      customizationDesc: 'Add custom placeholder regex patterns, use {customization} in rename format.',\n      customizationPlaceholder:\n        'Support regular expressions, special characters need \\\\ escape, separate multiple matches with new lines',\n      customizationHint:\n        'Support regular expressions, special characters need \\\\ escape, separate multiple matches with new lines',\n      customizationSaveSuccess: 'Custom placeholders saved successfully',\n      customizationSaveFailed: 'Failed to save custom placeholders!',\n\n      transferExcludeWords: 'File Organization Block Words',\n      excludeWordsDesc: 'Files or directories containing block words will not be organized.',\n      excludeWordsPlaceholder:\n        'Support regular expressions, special characters need \\\\ escape, one line for each block word',\n      excludeWordsHint: 'Support regular expressions, special characters need \\\\ escape, one line for each block word',\n      excludeWordsSaveSuccess: 'File organization block words saved successfully',\n      excludeWordsSaveFailed: 'Failed to save file organization block words!',\n    },\n    search: {\n      basicSettings: 'Basic Settings',\n      basicSettingsDesc: 'Set data sources, rule groups and other basic information',\n      recognizeSource: 'Recognition Data Source',\n      recognizeSourceDesc:\n        'Default is TMDB. Douban is usually more friendly for Chinese works, but some foreign works have incomplete information.',\n      themoviedb: 'TheMovieDb',\n      douban: 'Douban',\n      filterRuleGroup: 'Filter Rule Group',\n      filterRuleGroupDesc: 'Set filter rule groups used during download process.',\n      downloadLabel: 'Download Task Label',\n      downloadLabelDesc: 'Download labels in downloader, used for filtering queries.',\n      downloadLabelHint: 'Support multiple labels, separated by commas',\n      downloadSite: 'Search Sites',\n      downloadSiteDesc: 'Set site scope for specific category searches.',\n      movieSites: 'Movie Sites',\n      tvSites: 'TV Show Sites',\n      animeSites: 'Anime Sites',\n      saveSites: 'Save Sites',\n      saveSuccess: 'Search settings saved successfully',\n      saveFailed: 'Failed to save search settings!',\n      saveRuleFailed: 'Failed to save rules!',\n      movieCategory: 'Movies',\n      tvCategory: 'TV Shows',\n      animeCategory: 'Anime',\n      downloadUser: 'Remote Search Auto Download User List',\n      downloadUserHint:\n        'Whether to automatically download when searching with Telegram, WeChat, etc., comma separated, set to all to represent all users auto-download',\n      multipleNameSearch: 'Multiple Name Resource Search',\n      multipleNameSearchHint:\n        'Search site resources using multiple names (Chinese, English, etc.) and merge search results, will increase site access frequency',\n      downloadSubtitle: 'Download Site Subtitles',\n      downloadSubtitleHint: 'Check if site resources have separate subtitle files and download them automatically',\n      mediaSource: 'Media Search Data Source',\n      mediaSourceHint: 'Data sources and sorting used when searching for media information',\n      filterRuleGroupHint: 'Filter results by selected filter rule groups when searching for media information',\n      downloadUserPlaceholder: 'UserID1,UserID2',\n      downloadLabelPlaceholder: 'MOVIEPILOT',\n    },\n    directory: {\n      storage: 'Storage',\n      storageDesc: 'Set up local or cloud storage.',\n      directory: 'Directory',\n      mediaType: 'Media Type',\n      directoryDesc: 'Set up media file organization directory structure, matching in sequence.',\n      organizeAndScrap: 'Organization & Scraping',\n      organizeAndScrapDesc: 'Set rename format, scraping options, etc.',\n      scrapSource: 'Scraping Data Source',\n      scrapSourceHint: 'Metadata source for scraping',\n      movieRenameFormat: 'Movie Rename Format',\n      movieRenameFormatHint:\n        'Using Jinja2 syntax, format reference: https://jinja.palletsprojects.com/en/3.0.x/templates',\n      tvRenameFormat: 'TV Show Rename Format',\n      tvRenameFormatHint: 'Using Jinja2 syntax, format reference: https://jinja.palletsprojects.com/en/3.0.x/templates',\n      saveSuccess: 'Storage settings saved successfully',\n      saveFailed: 'Failed to save storage settings!',\n      directorySaveSuccess: 'Directory settings saved successfully',\n      directorySaveFailed: 'Failed to save directory settings!',\n      organizeSaveSuccess: 'Organization options saved successfully',\n      organizeSaveFailed: 'Failed to save organization options!',\n      duplicateDirectoryName: 'Duplicate directory names exist! Cannot save, please modify!',\n      defaultDirName: 'Directory',\n      storageSaveSuccess: 'Storage settings saved successfully',\n      storageSaveFailed: 'Failed to save storage settings!',\n    },\n    category: {\n      title: 'Category Policy',\n      subtitle: 'Configure media auto-categorization rules by type, language, region, etc.',\n      movie: 'Movies',\n      tv: 'TV Shows',\n      name: 'Category Name (Directory)',\n      genre: 'Genre',\n      language: 'Language',\n      languagePlaceholder: 'e.g., en,fr,zh (comma separated)',\n      country: 'Country/Region',\n      countryPlaceholder: 'e.g., US,CN,JP',\n      year: 'Year',\n      yearPlaceholder: 'e.g., 2023, 2020-2024',\n      addMovie: 'Add Movie Category',\n      addTv: 'Add TV Category',\n      saveSuccess: 'Category policy saved successfully',\n      loadFailed: 'Failed to load category configuration',\n      saveFailed: 'Save failed: {message}',\n    },\n    rule: {\n      customRules: 'Custom Rules',\n      customRulesDesc: 'Custom priority rule items',\n      priorityRuleGroups: 'Priority Rule Groups',\n      priorityRuleGroupsDesc: 'Preset priority rule groups for use in search and subscription.',\n      downloadRules: 'Download Rules',\n      downloadRulesDesc: 'Choose the best option when multiple resources are matched.',\n      resourcePriority: 'Resource Priority',\n      sitePriority: 'Site Priority',\n      siteUpload: 'Site Upload',\n      resourceSeeder: 'Resource Seeders',\n      emptyIdError: 'A rule has an empty ID, cannot save. Please modify!',\n      emptyNameError: 'A rule has an empty name, cannot save. Please modify!',\n      duplicateIdError: 'Duplicate rule IDs exist! Cannot save, please modify!',\n      duplicateNameError: 'Duplicate rule names exist! Cannot save, please modify!',\n      customRuleSaveSuccess: 'Custom rules saved successfully',\n      customRuleSaveFailed: 'Failed to save custom rules!',\n      emptyGroupNameError: 'A rule group has an empty name! Cannot save, please modify!',\n      duplicateGroupNameError: 'Duplicate rule group names exist! Cannot save, please modify!',\n      ruleGroupSaveSuccess: 'Priority rule groups saved successfully',\n      ruleGroupSaveFailed: 'Failed to save priority rule groups!',\n      customRuleCopySuccess: 'Custom rules copied to clipboard!',\n      customRuleCopyFailed: 'Failed to copy custom rules: browser may not support or user blocked!',\n      customRuleCopyError: 'Failed to copy custom rules!',\n      ruleGroupCopySuccess: 'Priority rule groups copied to clipboard!',\n      ruleGroupCopyFailed: 'Failed to copy priority rule groups: browser may not support or user blocked!',\n      ruleGroupCopyError: 'Failed to copy priority rule groups!',\n      currentPriorityRules: 'Current Download Priority Rules',\n      currentPriorityRulesHint: 'Higher priority for items at the front, unselected items are not included in sorting',\n      importCustomRules: 'Import Custom Rules',\n      importRuleGroups: 'Import Priority Rule Groups',\n      importFailed: 'Failed to import rules! Cannot parse input data!',\n      importUnknownType: 'Failed to import rules! Unknown data type!',\n      duplicateValue: 'Duplicate values exist',\n      importNoId: 'Import failed! Found rules without IDs, may belong to priority rule groups!',\n      importHasId: 'Import failed! Found rules with IDs, may belong to custom rules!',\n    },\n    scheduler: {\n      title: 'Scheduled Jobs',\n      subtitle: 'Includes built-in system services and plugin services',\n      provider: 'Provider',\n      taskName: 'Task Name',\n      taskStatus: 'Task Status',\n      nextRunTime: 'Next Run Time',\n      execute: 'Execute',\n      noService: 'No background services',\n      running: 'Running',\n      stopped: 'Stopped',\n      waiting: 'Waiting',\n      executeSuccess: 'Scheduled job execution request submitted successfully!',\n    },\n    subscribe: {\n      basicSettings: 'Basic Settings',\n      basicSettingsDesc: 'Set subscription mode, cycle and other basic settings',\n      subscribeSites: 'Subscribe Sites',\n      subscribeSitesDesc: 'Only selected sites will be used in subscriptions.',\n      mode: 'Subscription Mode',\n      modeHint: 'Auto: automatically crawl site homepage, Site RSS: subscribe via site RSS link',\n      rssInterval: 'Site RSS Interval',\n      rssIntervalHint: 'Set the site RSS running cycle, effective when subscription mode is `Site RSS`',\n      filterRuleGroup: 'Subscription Priority Rule Group',\n      filterRuleGroupHint: 'Filter subscriptions based on selected filter rule groups',\n      bestVersionRuleGroup: 'Version Upgrade Priority Rule Group',\n      bestVersionRuleGroupHint: 'Filter version upgrade subscriptions based on selected filter rule groups',\n      timedSearch: 'Subscription Scheduled Search',\n      timedSearchHint:\n        'Search all sites at specified intervals to supplement resources that may be missed by subscription',\n      searchInterval: 'Subscription Search Interval',\n      searchIntervalHint:\n        'Set the time interval for subscription search, only effective when subscription scheduled search is enabled',\n      checkLocalMedia: 'Check File System Resources',\n      checkLocalMediaHint:\n        'Scan the storage directory for existing resource files to avoid duplicate downloads; regardless of whether it is enabled, the media server will be checked',\n      modes: {\n        auto: 'Auto',\n        rss: 'Site RSS',\n      },\n      intervals: {\n        min5: '5 minutes',\n        min10: '10 minutes',\n        min20: '20 minutes',\n        min30: '30 minutes',\n        hour1: '1 hour',\n        hour12: '12 hours',\n        day1: '1 day',\n        day3: '3 days',\n        week1: '1 week',\n      },\n      saveSuccess: 'Subscription sites saved successfully',\n      saveFailed: 'Failed to save subscription sites!',\n      settingsSaveSuccess: 'Subscription basic settings saved successfully',\n      settingsSaveFailed: 'Failed to save subscription basic settings!',\n    },\n    cache: {\n      title: 'Cache Management',\n      subtitle: 'Manage cached site resources',\n      totalCount: 'Total Count',\n      siteCount: 'Site Count',\n      filterByTitle: 'Filter by Title',\n      filterBySite: 'Filter by Site',\n      selectSite: 'Select Site',\n      refresh: 'Refresh Cache',\n      deleteSelected: 'Delete Selected',\n      clearAll: 'Clear All Cache',\n      refreshSuccess: 'Cache refresh completed',\n      refreshFailed: 'Failed to refresh cache',\n      clearSuccess: 'Cache clear completed',\n      clearFailed: 'Failed to clear cache',\n      deleteSuccess: 'Cache item deleted successfully',\n      deleteFailed: 'Failed to delete cache item',\n      deleteSelectedSuccess: 'Successfully deleted {count} cache items',\n      deleteSelectedFailed: 'Failed to delete cache items',\n      loadFailed: 'Failed to load cache data',\n      selectDeleteWarning: 'Please select cache items to delete',\n      reidentify: 'Re-identify',\n      reidentifySuccess: 'Re-identification completed',\n      reidentifyFailed: 'Re-identification failed',\n      poster: 'Poster',\n      torrentTitle: 'Title',\n      site: 'Site',\n      size: 'Size',\n      publishTime: 'Publish Time',\n      recognitionResult: 'Recognition Result',\n      actions: 'Actions',\n      unrecognized: 'Unrecognized',\n      noData: 'No cache data',\n      noDataHint: 'Click \"Refresh Cache\" button to get the latest torrent cache',\n      reidentifyDialog: {\n        title: 'Re-identify',\n        torrentInfo: 'Torrent Info',\n        tmdbId: 'TMDB ID',\n        tmdbIdHint: 'Optional, manually specify TMDB ID for recognition',\n        doubanId: 'Douban ID',\n        doubanIdHint: 'Optional, manually specify Douban ID for recognition',\n        autoHint: 'If no ID is specified, the torrent will be automatically re-identified',\n        cancel: 'Cancel',\n        confirm: 'Re-identify',\n      },\n      mediaType: {\n        movie: 'Movie',\n        tv: 'TV Show',\n      },\n      clearConfirm: 'Are you sure you want to clear all cache?',\n    },\n  },\n  dialog: {\n    progress: {\n      processing: 'Processing',\n    },\n    subscribeSeason: {\n      title: 'Subscribe - {title}',\n      selectGroup: 'Select Episode Group',\n      defaultGroup: 'Default',\n      seasonCount: '{count} Seasons',\n      episodeCount: '{count} Episodes',\n      seasonNumber: 'Season {number}',\n      airDate: 'First aired on {date}',\n      voteAverage: '{score}',\n      status: {\n        exists: 'Exists',\n        partial: 'Partially Missing',\n        missing: 'Missing',\n      },\n      submit: 'Submit Subscription',\n      selectSeasons: 'Please select seasons to subscribe',\n    },\n    userAddEdit: {\n      add: 'Add User',\n      edit: 'Edit User',\n      username: 'Username',\n      usernameRequired: 'Username cannot be empty',\n      password: 'Password',\n      passwordMinLength: 'Password must be at least 6 characters',\n      confirmPassword: 'Confirm Password',\n      confirmPasswordRequired: 'Please confirm password',\n      passwordMismatch: 'Passwords do not match',\n      email: 'Email',\n      nickname: 'Nickname',\n      status: 'Status',\n      active: 'Active',\n      inactive: 'Inactive',\n      superUser: 'Super User',\n      otp: 'Enable Two-Factor Authentication',\n      avatar: 'Avatar',\n      uploadAvatar: 'Upload Avatar',\n      resetDefaultAvatar: 'Reset Default Avatar',\n      restoreCurrentAvatar: 'Restore Current Avatar',\n      notifications: 'Notifications',\n      wechat: 'WeChat UserID',\n      telegram: 'Telegram UserID',\n      slack: 'Slack UserID',\n      discord: 'Discord UserID',\n      vocechat: 'VoceChat UserID',\n      synologyChat: 'SynologyChat UserID',\n      webPush: 'WebPush',\n      creatingUser: 'Creating user [{name}], please wait',\n      updatingUser: 'Updating user [{name}], please wait',\n      usernameExists: 'Username already exists',\n      userCreated: 'User [{name}] created successfully',\n      userCreateFailed: 'Failed to create user: {message}',\n      userUpdateSuccess: 'User [{name}] updated successfully',\n      userUpdateFailed: 'Failed to update user: {message}',\n      userDeleteSuccess: 'User [{name}] deleted successfully',\n      userDeleteFailed: 'Failed to delete user: {message}',\n      invalidFile: 'The uploaded file does not meet the requirements, please choose a new avatar',\n      fileSizeLimit: 'File size must not exceed 800KB',\n      avatarUploadSuccess: 'New avatar uploaded successfully, will take effect after saving!',\n      resetAvatarSuccess: 'Reset to default avatar, will take effect after saving!',\n      restoreAvatarSuccess: 'Restored current avatar!',\n      deleteConfirm: 'Confirm delete user [{name}]?',\n      saveUserInfo: 'Save User Information',\n      cannotDeleteCurrentUser: 'Cannot delete current logged-in user',\n      deleteUser: 'Delete User',\n      permissions: {\n        title: 'Permission Settings',\n        presetNormal: 'Normal User',\n        presetAdmin: 'Administrator',\n        discovery: 'Discovery',\n        discoveryDesc: 'Access recommendation and exploration features',\n        search: 'Search',\n        searchDesc: 'Search site resources and add downloads',\n        subscribe: 'Subscribe',\n        subscribeDesc: 'Manage movie and TV show subscriptions',\n        manage: 'Manage',\n        manageDesc: 'Access download management and site management etc.',\n      },\n    },\n    searchBar: {\n      search: 'Search',\n      searchPlaceholder: 'Search movies, TV shows and more...',\n      recentSearches: 'Recent Searches',\n      noRecentSearches: 'No recent search history',\n      functions: 'Functions',\n      noFunctionsFound: 'No matching functions',\n      plugins: 'Plugins',\n      noPluginsFound: 'No matching plugins',\n      subscriptions: 'Subscriptions',\n      noSubscriptionsFound: 'No matching subscriptions',\n      searchSites: 'Search Sites',\n      selectSites: 'Select Sites',\n      collections: 'Collections',\n      collectionSearch: 'Related series works',\n      actorSearch: 'Related actors, directors, etc.',\n      historySearch: 'Related history records',\n      subscribeShareSearch: 'Related subscription shares',\n      siteResources: 'Site Resources',\n      searchInSites: 'Search for torrent resources in sites',\n      relatedResources: 'Related Resources',\n      searchTip: 'You can search for movies, TV shows, actors, resources, etc.',\n      emptySearchHint: 'Enter keywords to search',\n      escClose: 'Close',\n      openSearch: 'Open search',\n    },\n    searchSite: {\n      selectSites: 'Select Sites',\n      siteSearch: 'Site Search',\n      searchAllSites: 'Selected {selected}/{total} sites',\n      selectAll: 'Select All',\n      deselectAll: 'Deselect All',\n      confirm: 'Confirm',\n      cancel: 'Cancel',\n    },\n    importCode: {\n      import: 'Import',\n      title: 'Import Code',\n    },\n    addDownload: {\n      confirmDownload: 'Confirm Download',\n      downloader: 'Downloader (Default)',\n      saveDirectory: 'Save Directory (Auto)',\n      defaultPlaceholder: 'Leave empty for default',\n      autoPlaceholder: 'Leave empty for auto-match',\n      downloading: 'Downloading...',\n      startDownload: 'Start Download',\n      downloadSuccess: '{site} {title} downloaded successfully!',\n      downloadFailed: '{site} {title} download failed: {message}!',\n      showAdvancedOptions: 'Show Advanced Options',\n      hideAdvancedOptions: 'Hide Advanced Options',\n    },\n    subscribeShare: {\n      shareSubscription: 'Share Subscription',\n      season: 'Season {number}',\n      title: 'Title',\n      description: 'Description',\n      descriptionHint:\n        'Add a description about this subscription. Search terms, recognition words, etc. will be included in the share by default',\n      shareUser: 'Share User',\n      shareUserHint: \"Sharer's nickname\",\n      confirmShare: 'Confirm Share',\n      shareSuccess: '{name} shared successfully!',\n      shareFailed: '{name} share failed: {message}!',\n    },\n    workflowShare: {\n      shareWorkflow: 'Share Workflow',\n      title: 'Title',\n      description: 'Description',\n      descriptionHint:\n        'Add a description about this workflow. Actions and flows will be included in the share by default',\n      shareUser: 'Share User',\n      shareUserHint: \"Sharer's nickname\",\n      confirmShare: 'Confirm Share',\n      shareSuccess: '{name} shared successfully!',\n      shareFailed: '{name} share failed: {message}!',\n      securityWarning: 'Security Warning',\n      securityWarningMessage:\n        'Before sharing, please ensure the workflow does not contain sensitive information such as PassKey in RSS links to avoid information leakage.',\n    },\n    u115Auth: {\n      loginTitle: '115 Cloud Authorization',\n      openAuthWindow: 'Open Authorization Window',\n      authorizing: 'Please complete authorization in the new window...',\n      authSuccess: 'Authorization successful!',\n      authFailed: 'Authorization failed or expired',\n      authCanceled: 'Authorization canceled, please try again',\n      urlEmpty: 'Authorization URL is empty',\n      urlFetchFailed: 'Failed to fetch authorization URL',\n      popupBlocked: 'Unable to open authorization window, please check browser popup settings',\n      complete: 'Complete',\n      reset: 'Reset',\n    },\n    aliyunAuth: {\n      loginTitle: 'Aliyun Drive Login',\n      scanQrCode: 'Please scan with Aliyun Drive App',\n      scanned: 'Scanned',\n      complete: 'Complete',\n      reset: 'Reset',\n    },\n    rcloneConfig: {\n      title: 'RClone Configuration',\n      filePath: 'rclone config file path',\n      fileContent: 'rclone config file content',\n      defaultContent:\n        '# Please fill in your rclone config file content here \\n# Please refer to https://rclone.org/docs/ \\n# Storage node name must be: MP',\n      complete: 'Complete',\n      reset: 'Reset',\n    },\n    alistConfig: {\n      title: 'OpenList Configuration',\n      serverUrl: 'OpenList server address',\n      username: 'Username',\n      password: 'Password',\n      tokenUrl: 'Token acquisition address',\n      loginType: 'Login method',\n      loginTypeOptions: {\n        guest: 'Guest',\n        username: 'Username & Password',\n        token: 'Token',\n      },\n      complete: 'Complete',\n      reset: 'Reset',\n    },\n    smbConfig: {\n      title: 'SMB Network Share Configuration',\n      host: 'SMB Server Address',\n      hostHint: 'IP address or hostname of the SMB server',\n      share: 'Share Name',\n      shareHint: 'Name of the shared folder to connect to',\n      username: 'Username',\n      usernameHint: 'SMB login username',\n      password: 'Password',\n      passwordHint: 'SMB login password',\n      domain: 'Domain',\n      domainHint: 'SMB domain name, such as WORKGROUP or domain controller name',\n      complete: 'Complete',\n      reset: 'Reset',\n    },\n    workflowAddEdit: {\n      addTitle: 'Add Workflow',\n      editTitle: 'Edit Workflow',\n      name: 'Name',\n      namePlaceholder: 'Workflow name',\n      desc: 'Description',\n      descPlaceholder: 'Workflow description',\n      enabled: 'Enabled',\n      triggerType: 'Trigger Type',\n      triggerTypeTimer: 'Timer Trigger',\n      triggerTypeEvent: 'Event Trigger',\n      triggerTypeManual: 'Manual Trigger',\n      schedule: 'Schedule',\n      cronExpr: 'Cron Expression',\n      cronExprDesc: 'Cron expression for workflow scheduling',\n      eventType: 'Event Type',\n      eventTypePlaceholder: 'Please select event type',\n      nameRequired: 'Please fill in complete information!',\n      triggerRequired: 'Please select trigger type!',\n      timerRequired: 'Please fill in timer expression!',\n      eventTypeRequired: 'Please select event type!',\n      addSuccess: 'Task created successfully, please edit the workflow!',\n      addFailed: 'Failed to create task: {message}',\n      editSuccess: 'Task modified successfully!',\n      editFailed: 'Failed to modify task: {message}',\n      cancel: 'Cancel',\n      confirm: 'Confirm',\n    },\n    workflowActions: {\n      title: 'Edit Workflow',\n      noActionsMessage: 'Workflow has no actions, please add actions',\n      addAction: 'Add Action',\n      editAction: 'Edit Action',\n      deleteAction: 'Delete Action',\n      moveUp: 'Move Up',\n      moveDown: 'Move Down',\n      nameLabel: 'Action Name',\n      nameRequired: 'Action name cannot be empty',\n      typeLabel: 'Action Type',\n      typeRequired: 'Action type cannot be empty',\n      paramsLabel: 'Action Parameters',\n      outputLabel: 'Action Output',\n      saveAction: 'Save Action',\n      cancelAction: 'Cancel',\n      confirmDeleteTitle: 'Confirm Delete Action',\n      confirmDeleteMessage: 'Are you sure you want to delete this action? This operation cannot be undone.',\n      yesDelete: 'Yes, Delete',\n      noCancel: 'Cancel',\n      invalidConnection: 'Invalid connection: cannot connect to self or ports of the same type!',\n      componentNotFound: 'Component {component} not found',\n      componentAdded: 'Component added to canvas',\n      saveSuccess: 'Task workflow saved successfully!',\n      saveFailed: 'Failed to save task workflow: {message}',\n      importTitle: 'Import Task Workflow',\n      importSuccess: 'Import successful!',\n      importFailed: 'Import failed!',\n      codeCopied: 'Task workflow code copied to clipboard!',\n    },\n    siteCookieUpdate: {\n      title: 'Update Site Cookie & UA',\n      processing: 'Please wait...',\n      updating: 'Updating {site} Cookie & UA...',\n      success: '{site} Cookie & UA updated successfully!',\n      failed: '{site} update failed: {message}',\n      updateButton: 'Start Update',\n    },\n    siteAddEdit: {\n      addTitle: 'Add Site',\n      editTitle: 'Edit Site',\n      nameLabel: 'Site Name',\n      urlLabel: 'Site URL',\n      iconLabel: 'Site Icon',\n      uploadIcon: 'Upload Icon',\n      cookie: 'Cookie',\n      rssUrl: 'RSS Link',\n      enableLabel: 'Enable',\n      pubEnableLabel: 'Public Resources',\n      priorityLabel: 'Priority',\n      signInLabel: 'Sign In',\n      proxies: 'Proxies',\n      userInfo: 'User Info',\n      cancel: 'Cancel',\n      confirm: 'Save',\n    },\n    pluginConfig: {\n      title: 'Plugin Configuration',\n      save: 'Save',\n      close: 'Close',\n      viewData: 'View Data',\n      saving: 'Saving {name} configuration...',\n      saveSuccess: 'Plugin {name} configuration saved',\n      saveFailed: 'Failed to save plugin {name} configuration: {message}',\n    },\n    pluginData: {\n      title: 'Plugin Data',\n      save: 'Save',\n      close: 'Close',\n    },\n    pluginMarketSetting: {\n      title: 'Plugin Market Settings',\n      repoUrl: 'Plugin Repository URL',\n      repoPlaceholder: 'Format: https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/',\n      repoHint: 'Multiple URLs separated by lines, only Github repositories are supported',\n      urlPlaceholder: 'Enter plugin repository URL',\n      noRepos: 'No plugin repository URLs',\n      invalidUrl: 'Please enter a valid URL',\n      duplicateUrl: 'This URL already exists',\n      close: 'Close',\n      save: 'Save',\n      saveSuccess: 'Plugin repository saved successfully',\n      saveFailed: 'Failed to save plugin repository: {message}!',\n    },\n    userAuth: {\n      title: 'User Authentication',\n      codeLabel: 'Authentication Code',\n      codePlaceholder: 'Please enter authentication code',\n      authBtn: 'Start Authentication',\n      closeBtn: 'Close',\n      selectSite: 'Select Authentication Site',\n      selectSiteRequired: 'Please select authentication site!',\n      siteConfigNotExist: 'Site configuration does not exist!',\n      fieldRequired: 'Please enter {name}!',\n      authSuccess: 'User authentication successful, please log in again!',\n      authFailed: 'Authentication failed: {message}',\n    },\n    transferQueue: {\n      title: 'Organization Queue',\n      name: 'Name',\n      type: 'Type',\n      state: 'Status',\n      progress: 'Progress',\n      startTime: 'Start Time',\n      speedTitle: 'Speed',\n      pathTitle: 'Path',\n      sizeTitle: 'Size',\n      waitingState: 'Waiting',\n      runningState: 'Organizing',\n      finishedState: 'Completed',\n      failedState: 'Failed',\n      cancelledState: 'Cancelled',\n      noTasks: 'No tasks being organized',\n      processing: 'Please wait ...',\n      stopAll: 'Stop All',\n      startAll: 'Start All',\n      refresh: 'Refresh',\n      close: 'Close',\n      processingFile: 'Processing',\n      overallProgress: 'Overall Progress',\n      currentFileProgress: 'Current File Progress',\n      processingStatus: 'Processing',\n    },\n    reorganize: {\n      title: 'Organize',\n      sourceTitle: 'Source File',\n      targetTitle: 'Target File',\n      processingTitle: 'Processing',\n      confirmTitle: 'Confirm',\n      selectFile: 'Select File',\n      selectTarget: 'Select Target',\n      selectMediaType: 'Select Media Type',\n      movie: 'Movie',\n      tv: 'TV Show',\n      selectTmdbId: 'Select TMDB ID',\n      selectMediaInfo: 'Select Media Info',\n      selectTargetPath: 'Select Target Path',\n      selectTargetDir: 'Select Target Directory',\n      selectFileName: 'Select Filename',\n      confirmMoving: 'Please confirm move!',\n      sourceLabel: 'Source file:',\n      targetLabel: 'Target directory:',\n      filenameLabel: 'Filename:',\n      close: 'Close',\n      next: 'Next',\n      previous: 'Previous',\n      confirm: 'Confirm',\n      manualTitle: 'Manual Organization',\n      multipleItemsTitle: '{count} Items',\n      singleItemTitle: '{path}',\n      targetStorage: 'Target Storage',\n      targetStorageHint: 'Organization target storage',\n      transferType: 'Organization Method',\n      transferTypeHint: 'File operation organization method',\n      targetPath: 'Target Path',\n      targetPathHint: 'Organization target path, leave empty for auto-match',\n      targetPathPlaceholder: 'Leave empty for auto',\n      mediaType: 'Type',\n      mediaTypeHint: 'File media type',\n      tmdbId: 'TheMovieDb ID',\n      doubanId: 'Douban ID',\n      mediaIdHint: 'Query media ID by name, leave empty for auto recognition',\n      mediaIdPlaceholder: 'Leave empty for auto recognition',\n      episodeGroup: 'Episode Group ID',\n      episodeGroupHint: 'Specify episode group',\n      episodeGroupPlaceholder: 'Manually query episode group',\n      season: 'Season',\n      seasonHint: 'Which season',\n      episodeDetail: 'Episode',\n      episodeDetailHint: 'Episode number or range, e.g. 1 or 1,2',\n      episodeDetailPlaceholder: 'Start episode,End episode',\n      episodeFormat: 'Episode Positioning',\n      episodeFormatHint: 'Use {ep} to position episode number part in filename to assist recognition',\n      episodeFormatPlaceholder: 'Use {ep} to position episode',\n      episodeOffset: 'Episode Offset',\n      episodeOffsetHint: 'Episode offset calculation, e.g. -10 or EP*2',\n      episodeOffsetPlaceholder: 'e.g. -10',\n      episodePart: 'Specify Part',\n      episodePartHint: 'Specify part, e.g. part1',\n      episodePartPlaceholder: 'e.g. part1',\n      minFileSize: 'Min File Size (MB)',\n      minFileSizeHint: 'Only organize files larger than minimum file size',\n      typeFolderOption: 'Classify by Type',\n      typeFolderHint: 'Add subdirectory by media type in target path during organization',\n      categoryFolderOption: 'Classify by Category',\n      categoryFolderHint: 'Add subdirectory by media category in target path during organization',\n      scrapeOption: 'Scrape Metadata',\n      scrapeHint: 'Automatically scrape metadata after organization',\n      fromHistoryOption: 'Reuse Historical Recognition Info',\n      fromHistoryHint: 'Use media info already recognized in historical organization records',\n      addToQueue: 'Add to Organization Queue',\n      reorganizeNow: 'Organize Now',\n      auto: 'Auto',\n      processing: 'Processing ...',\n      successMessage: 'File {name} has been added to the organization queue!',\n    },\n    subscribeEdit: {\n      titleDefault: 'Default Subscription Rules',\n      titleEdit: 'Edit Subscription',\n      seasonFormat: 'Season {number}',\n      tabs: {\n        basic: 'Basic',\n        advance: 'Advanced',\n      },\n      searchKeyword: 'Search Keywords',\n      searchKeywordHint: 'Specify keywords used when searching sites',\n      totalEpisode: 'Total Episodes',\n      totalEpisodeHint: 'Total number of episodes',\n      startEpisode: 'Start Episode',\n      startEpisodeHint: 'Starting episode number to subscribe',\n      quality: 'Quality',\n      qualityHint: 'Subscription resource quality',\n      resolution: 'Resolution',\n      resolutionHint: 'Subscription resource resolution',\n      effect: 'Effects',\n      effectHint: 'Subscription resource effects',\n      subscribeSites: 'Subscription Sites',\n      subscribeSitesHint: 'Range of sites for subscription, use system settings if none selected',\n      downloader: 'Downloader',\n      downloaderHint: 'Specify downloader for this subscription',\n      savePath: 'Save Path',\n      savePathHint: 'Specify download save path for this subscription, leave empty to use default download directory',\n      bestVersion: 'Version Upgrade',\n      bestVersionHint: 'Perform version upgrade subscription based on upgrade priorities',\n      searchImdbid: 'Search Using ImdbID',\n      searchImdbidHint: 'Use ImdbID for precise resource searching',\n      showEditDialog: 'Edit More Rules When Subscribing',\n      showEditDialogHint: 'Show this edit subscription dialog when adding subscription',\n      include: 'Include (Keywords, Regex)',\n      includeHint: 'Include rules, supports regular expressions',\n      exclude: 'Exclude (Keywords, Regex)',\n      excludeHint: 'Exclude rules, supports regular expressions',\n      filterGroups: 'Priority Rule Groups',\n      filterGroupsHint: 'Filter subscriptions by selected filter rule groups',\n      episodeGroup: 'Specify Episode Group',\n      episodeGroupHint: 'Recognize and scrape by specific episode group',\n      season: 'Specify Season',\n      seasonHint: 'Specify any season for subscription',\n      mediaCategory: 'Custom Category',\n      mediaCategoryHint: 'Specify category name, leave empty for auto-recognition',\n      customWords: 'Custom Recognition Words',\n      customWordsHint: 'Recognition words only used for this subscription',\n      customWordsPlaceholder:\n        'Block word\\nReplaced word => Replacement word\\nPrefix <> Suffix >> Episode offset (EP)\\nReplaced word => Replacement word && Prefix <> Suffix >> Episode offset (EP)\\nReplacement word supports format: &#123; tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx &#125; to directly specify TMDBID/Douban ID recognition, where s, e are season and episode numbers (optional)',\n      cancelSubscribe: 'Cancel Subscription',\n      save: 'Save',\n      cancelSubscribeConfirm: 'Are you sure you want to cancel the subscription?',\n    },\n    subscribeFiles: {\n      title: 'Downloaded Files',\n      noFilesMessage: 'No files',\n      close: 'Close',\n      downloadTab: 'Download Files',\n      libraryTab: 'Media Library Files',\n      episodeColumn: 'Episode',\n      torrentColumn: 'Torrent',\n      fileColumn: 'File',\n      itemsPerPage: 'Items per page',\n      pageText: '{0}-{1} of {2} items',\n      loadingText: 'Loading...',\n      noData: 'No data',\n      season: 'Season {number}',\n    },\n    subscribeHistory: {\n      title: '{type} Subscription History',\n      resubscribe: 'Resubscribe',\n      resubscribeMovie: 'Resubscribing {name}...',\n      resubscribeTv: 'Resubscribing {name} Season {season}...',\n      season: 'Season {season}',\n      noData: 'No completed subscriptions',\n    },\n    siteUserData: {\n      title: 'Site User Data',\n      updateTime: 'Update Time',\n      username: 'Username',\n      uploadTitle: 'Upload',\n      uploadTotal: 'Total Upload',\n      downloadTitle: 'Download',\n      downloadTotal: 'Total Download',\n      seedingTitle: 'Seeding',\n      seedingCount: 'Total Seeding Count',\n      seedingSize: 'Total Seeding Size',\n      userLevel: 'User Level',\n      msgCount: 'Unread Messages',\n      inviteCount: 'Invites',\n      bonus: 'Bonus Points',\n      ratio: 'Ratio',\n      joinTime: 'Join Time',\n      trafficHistory: 'Traffic History',\n      seedingDistribution: 'Seeding Distribution',\n      volumeTitle: 'Volume',\n      countTitle: 'Count:',\n      noData: 'None',\n      refreshing: 'Refreshing site data...',\n      close: 'Close',\n    },\n    siteResource: {\n      browseTitle: 'Browse - {name}',\n      searchKeyword: 'Search Keyword',\n      resourceCategory: 'Resource Category',\n      search: 'Search',\n      itemsPerPage: 'Items Per Page',\n      noData: 'No Data',\n      loading: 'Loading...',\n      titleColumn: 'Title',\n      timeColumn: 'Time',\n      sizeColumn: 'Size',\n      seedersColumn: 'Seeders',\n      peersColumn: 'Peers',\n      viewDetails: 'View Details',\n      downloadTorrent: 'Download Torrent',\n      pageText: '{0}-{1} of {2} items',\n    },\n    forkSubscribe: {\n      title: 'Copy Subscription',\n      selectSubscriber: 'Select Copy Target',\n      overwriteExisting: 'Overwrite Existing Subscription',\n      overwriteExistingHint: 'Whether to overwrite when target user already has this subscription',\n      confirm: 'Confirm',\n      cancel: 'Cancel',\n    },\n  },\n  file: {\n    newFolder: 'New Folder',\n    autoRecognizeName: 'Auto Recognize Name',\n    createFolder: 'Create Folder',\n    fileName: 'File Name',\n    fileSize: 'File Size',\n    fileType: 'File Type',\n    lastModified: 'Last Modified',\n    actions: 'Actions',\n    rename: 'Rename',\n    delete: 'Delete',\n    confirmFileDelete: 'Confirm Delete',\n    upload: 'Upload',\n    download: 'Download',\n    preview: 'Preview',\n    selectAll: 'Select All',\n    deselectAll: 'Deselect All',\n    moveUp: 'Go Back',\n    sortByName: 'Sort by Name',\n    sortByTime: 'Sort by Time',\n    currentName: 'Current Name',\n    newName: 'New Name',\n    includeSubfolders: 'Auto rename all media files in directory',\n    emptyFolder: 'Empty Folder',\n    noFilesInFolder: 'No files in this folder',\n    autoRecognize: 'Auto Recognize Name',\n    directoryTree: 'Directory Tree',\n    rootDirectory: 'Root Directory',\n    noDirectories: 'No directories available',\n    directory: 'Directory',\n    file: 'File',\n    size: 'Size',\n    modifyTime: 'Modify Time',\n    noFiles: 'No directories or files',\n    emptyDirectory: 'Empty directory',\n    confirmDelete: 'Are you sure you want to delete {type} {name}?',\n    confirmBatchDelete: 'Are you sure you want to delete {count} selected items?',\n    deleting: 'Deleting {name}...',\n    recognize: 'Recognize',\n    recognizing: 'Recognizing {path}...',\n    recognizeFailed: '{path} recognition failed!',\n    scrape: 'Scrape',\n    scraping: 'Scraping {path}...',\n    scrapeCompleted: '{path} scraping completed!',\n    confirmScrape: 'Are you sure you want to scrape {path}?',\n    confirmBatchScrape: 'Are you sure you want to scrape {count} selected items?',\n    renaming: 'Renaming {name}...',\n    renamingAll: 'Renaming {path} and all files in the directory...',\n    close: 'Close',\n    loadingDirectoryStructure: 'Loading directory structure...',\n    reorganize: 'Reorganize',\n  },\n  person: {\n    alias: 'Also Known As:',\n    credits: 'Credits',\n    biography: 'Biography',\n    birthday: 'Birthday',\n    placeOfBirth: 'Place of Birth',\n  },\n  error: {\n    title: 'Error!',\n    networkError: 'Unable to get media information, please check your network connection.',\n    serverError: 'Server error, please try again later.',\n    notFound: 'Requested resource not found.',\n  },\n  plugin: {\n    sort: {\n      popular: 'Popular',\n      name: 'Plugin Name',\n      author: 'Author',\n      repository: 'Plugin Repository',\n      latest: 'Latest Release',\n    },\n    installingPlugin: 'Installing plugin...',\n    installing: 'Installing {name} v{version} ...',\n    installSuccess: 'Plugin {name} installed successfully!',\n    installFailed: 'Plugin {name} installation failed: {message}',\n    filterPlugins: 'Filter Plugins',\n    name: 'Name',\n    hasNewVersion: 'Has New Version',\n    running: 'Running',\n    author: 'Author',\n    label: 'Label',\n    repository: 'Repository',\n    sortTitle: 'Sort',\n    filter: 'Filter: {name}',\n    noMatchingContent: 'No matching content found',\n    pleaseInstallFromMarket: 'Please install plugins from the plugin market',\n    allPluginsInstalled: 'All plugins are installed',\n    searchPlugins: 'Search Plugins',\n    searchPlaceholder: 'Search by plugin name or description',\n    uninstalling: 'Uninstalling {name} ...',\n    uninstallSuccess: 'Plugin {name} uninstalled successfully!',\n    uninstallFailed: 'Plugin {name} uninstallation failed: {message}',\n    updating: 'Updating {name} ...',\n    updateSuccess: 'Plugin {name} updated successfully!',\n    updateFailed: 'Plugin {name} update failed: {message}',\n    noPlugins: 'No plugins installed',\n    installed: 'Installed',\n    notInstalled: 'Not Installed',\n    hasUpdate: 'Update Available',\n    configuring: 'Configuring',\n    enable: 'Enable',\n    disable: 'Disable',\n    settings: 'Settings',\n    projectHome: 'Project Home',\n    updateHistory: 'Update History',\n    local: 'Local',\n    installToLocal: 'Install to Local',\n    totalDownloads: 'Total {count} downloads',\n    viewData: 'View Data',\n    update: 'Update',\n    reset: 'Reset',\n    uninstall: 'Uninstall',\n    viewLogs: 'View Logs',\n    authorHome: 'Author Home',\n    confirmUninstall: 'Are you sure you want to uninstall plugin {name}?',\n    confirmReset:\n      'This operation will restore plugin {name} to default settings and clear all related data. Are you sure you want to continue?',\n    resetSuccess: 'Plugin {name} data has been reset',\n    resetFailed: 'Plugin {name} reset failed: {message}',\n    updateHistoryTitle: '{name} Update History',\n    updateToLatest: 'Update to Latest Version',\n    updatingTo: 'Updating {name} to v{version} ...',\n    folderNameEmpty: 'Folder name cannot be empty',\n    folderExists: 'Folder already exists',\n    folderCreateSuccess: 'Folder created successfully',\n    folderRenameSuccess: 'Folder renamed successfully',\n    folderRenameFailed: 'Failed to rename folder',\n    folderDeleteSuccess: 'Folder deleted successfully',\n    folderDeleteFailed: 'Failed to delete folder',\n    removeFromFolderSuccess: 'Plugin removed from folder',\n    operationFailed: 'Operation failed',\n    saveFolderConfigFailed: 'Failed to save folder config',\n    newFolder: 'New Folder',\n    folderName: 'Folder Name',\n    cancel: 'Cancel',\n    create: 'Create',\n    clone: 'Clone',\n    cloneTitle: 'Create Plugin Clone',\n    cloneSubtitle: 'Create an independent clone instance for {name}',\n    cloneFeature: 'Plugin Clone Feature',\n    cloneDescription:\n      'Create an independent copy of the plugin with separate configuration and data, suitable for multi-account, testing environments, etc.',\n    suffix: 'Clone Suffix',\n    suffixPlaceholder: 'e.g.: Test, Backup, Site1',\n    suffixHint: 'Unique identifier to distinguish clones, only letters and numbers allowed',\n    suffixRequired: 'Clone suffix cannot be empty',\n    suffixFormatError: 'Only letters and numbers allowed',\n    suffixLengthError: 'Length cannot exceed 20 characters',\n    cloneName: 'Clone Name',\n    cloneNamePlaceholder: 'e.g.: Auto Backup Test Version',\n    cloneNameHint: 'Display name for the clone plugin (optional)',\n    cloneDefaultName: '{name} Clone',\n    cloneDescriptionLabel: 'Clone Description',\n    cloneDescriptionPlaceholder: 'Describe the purpose and features of this clone...',\n    cloneDescriptionHint: 'Detailed description of the clone plugin purpose (optional)',\n    cloneDefaultDescription: '{description} (Clone Version)',\n    cloneVersion: 'Version',\n    cloneVersionPlaceholder: 'e.g.: 1.0, 2.1.0',\n    cloneVersionHint: 'Custom version number for the clone plugin (optional)',\n    cloneIcon: 'Icon URL',\n    cloneIconPlaceholder: 'https://example.com/icon.png',\n    cloneIconHint: 'Custom icon for the clone plugin (optional)',\n    cloneNotice:\n      'Clone plugins are disabled by default after creation and need to be manually configured and enabled. The clone suffix cannot be modified once set.',\n    createClone: 'Create Clone',\n    cloning: 'Creating clone for {name}...',\n    cloneSuccess: 'Plugin clone {name} created successfully!',\n    cloneFailed: 'Plugin clone creation failed: {message}',\n    cloneFailedGeneral: 'Plugin clone creation failed',\n    logTitle: 'Plugin Logging',\n    quickAccess: 'Quick Access',\n    noPluginsWithPage: 'No plugins with detail pages available',\n    tapToOpen: 'Tap to Return',\n    recentlyUsed: 'Recently Used',\n    allPlugins: 'All Plugins',\n    noRecentPlugins: 'None',\n  },\n  profile: {\n    disableOtpWithPasskeyError: 'Please delete all Passkeys before clearing the authenticator!',\n    personalInfo: 'Personal Information',\n    uploadNewAvatar: 'Upload New Avatar',\n    avatarFormatError: 'The uploaded file does not meet requirements, please select a new avatar',\n    avatarSizeError: 'File size must not exceed 800KB',\n    avatarUploadSuccess: 'New avatar uploaded successfully, will take effect after saving!',\n    resetAvatarSuccess: 'Reset to default avatar, will take effect after saving!',\n    restoreAvatarSuccess: 'Restored current avatar!',\n    savingInProgress: 'Saving in progress, please wait...',\n    usernameRequired: 'Username cannot be empty',\n    passwordMismatch: 'The two passwords do not match',\n    usernameChangeSuccess: '[{oldName}] renamed to [{newName}], user information saved successfully!',\n    saveSuccess: 'User information saved successfully!',\n    saveFailedWithNameChange: '[{oldName}] renamed to [{newName}], information save failed: {message}!',\n    saveFailed: 'User information save failed: {message}!',\n    nickname: 'Nickname',\n    nicknamePlaceholder: 'Display nickname, takes precedence over username',\n    accountBinding: 'Account Binding',\n    wechatUser: 'WeChat User',\n    telegramUser: 'Telegram User',\n    slackUser: 'Slack User',\n    discordUser: 'Discord User',\n    vocechatUser: 'VoceChat User',\n    synologychatUser: 'SynologyChat User',\n    doubanUser: 'Douban User',\n    setupAuthenticator: 'Setup Authenticator',\n    authenticatorManagement: 'Authenticator Management',\n    authenticatorEnabled: 'You have enabled authenticator two-factor authentication',\n    clearAuthenticatorTip: 'To set up a new authenticator, please clear the current configuration first.',\n    clearAuthenticator: 'Clear Authenticator',\n    enableTwoFactor: 'Enable Two-Factor Authentication',\n    disableTwoFactor: 'Disable Two-Factor Authentication',\n    setupMfa: 'Setup Two-Factor Authentication',\n    enableMfa: 'Enable Two-Factor Authentication',\n    useAuthenticator: 'Use Authenticator',\n    usePasskey: 'Use Passkey',\n    enabled: 'Enabled',\n    keysCount: '{count} keys',\n    passkeyManagement: 'Passkey Management',\n    registerNewPasskey: 'Register New Passkey',\n    passkeyDescription: 'Passkeys allow you to sign in quickly and securely without a password.',\n    passkeyAppDescription:\n      'Passkeys are a simpler, more secure way to sign in, serving as an alternative to passwords. You can authenticate using passkey-supported apps like iCloud Keychain, Bitwarden, or hardware keys.',\n    passkeyName: 'Passkey Name',\n    passkeyNamePlaceholder: 'e.g.: iPhone, Windows Hello',\n    registerPasskey: 'Register Passkey',\n    createdAt: 'Created',\n    lastUsedAt: 'Last used',\n    noPasskeys: 'You haven’t registered any passkeys yet',\n    passkeyNameRequired: 'Please enter a passkey name',\n    passkeyRegisterSuccess: 'Passkey registered successfully',\n    passkeyRegisterFailed: 'Registration failed',\n    passkeyRegisterCancelled: 'Registration cancelled',\n    passkeyDeleteSuccess: 'Passkey deleted',\n    passkeyDeleteFailed: 'Delete failed',\n    deletePasskey: 'Delete Passkey',\n    passkeyDomainWarning:\n      'The availability of PassKeys is closely related to the {domain}. In a public network environment, please make sure to configure the correct access domain name in \"Basic Settings\". Domain changes or configuration errors will cause the PassKey to be unusable.',\n    otpRequiredForPasskey:\n      'For security reasons, you must first enable {otp} before you can register a PassKey. This is to ensure that you can still log in to your account via OTP code if the PassKey becomes invalid due to domain configuration changes.',\n    accessDomain: 'access domain name',\n    otpAuthenticator: 'OTP Authenticator',\n    otpGenerateFailed: 'Failed to get OTP URI: {message}!',\n    otpDisableSuccess: 'Two-factor authentication disabled successfully!',\n    otpDisableFailed: 'Failed to disable OTP: {message}!',\n    otpCodeRequired: 'Please enter the 6-digit verification code',\n    otpEnableSuccess: 'Two-factor authentication enabled successfully!',\n    otpEnableFailed: 'Failed to enable OTP: {message}!',\n    otpDisableRestrictedByPasskey:\n      'You have registered Passkeys. Please delete all Passkeys before disabling OTP verification.',\n    confirmToDisableOtp:\n      'For security reasons, verifying your login password is required to disable two-factor authentication.',\n    confirmToDeletePasskey: 'For security reasons, verifying your login password is required to delete a Passkey.',\n    authenticatorAppDescription:\n      'Use an authenticator app like Google Authenticator, Microsoft Authenticator, Authy, or 1Password to scan the QR code and generate a 6-digit code.',\n    secretKeyTip:\n      \"If you're having trouble with the QR code, select manual entry in your app and enter the code above.\",\n    enterVerificationCode: 'Enter verification code to confirm enabling two-factor authentication',\n    avatarFormatTip: 'JPG, PNG, GIF, WEBP formats allowed, maximum size 800KB.',\n  },\n  transferHistory: {\n    title: 'Transfer History',\n    searchPlaceholder: 'Search transfer records',\n    titleColumn: 'Title',\n    pathColumn: 'Path',\n    modeColumn: 'Mode',\n    sizeColumn: 'Size',\n    dateColumn: 'Date',\n    statusColumn: 'Status',\n    actionsColumn: 'Actions',\n    seasonEpisode: 'Season/Episode',\n    transferQueue: 'Transfer Queue',\n    groupMode: 'Group Mode',\n    listMode: 'List Mode',\n    deleteConfirm: 'Confirm delete {title} {seasons}{episodes}?',\n    deleteConfirmBatch: 'Confirm delete {count} records?',\n    deleteRecordOnly: 'Delete Record Only',\n    deleteSourceOnly: 'Delete Record and Source File',\n    deleteDestOnly: 'Delete Record and Media Library File',\n    deleteAll: 'Delete All',\n    transferMode: {\n      copy: 'Copy',\n      move: 'Move',\n      link: 'Hard Link',\n      softlink: 'Soft Link',\n      rclone_copy: 'Rclone Copy',\n      rclone_move: 'Rclone Move',\n    },\n    status: {\n      success: 'Success',\n      failed: 'Failed',\n      unknown: 'Unknown',\n    },\n    noData: 'No Data',\n    loading: 'Loading...',\n    pageSize: 'Items Per Page',\n    pageInfo: '{begin} - {end} / {total}',\n    aiRedoDisabled: 'Please enable the AI assistant in system settings first',\n    aiRedoQueued: 'Assistant organize task submitted: {title}',\n    aiRedoFailed: 'Failed to submit assistant organize task',\n    actions: {\n      aiRedo: 'Assistant Organize',\n      aiRedoPending: 'Assistant Organizing...',\n      redo: 'Reorganize',\n      delete: 'Delete',\n      batchRedo: 'Batch Reorganize',\n      batchDelete: 'Batch Delete',\n    },\n    batchOperationTitle: 'Batch Operation',\n    progress: {\n      processing: 'Processing',\n      pleaseWait: 'Please wait...',\n    },\n    table: {\n      emptyTitle: 'Actions',\n    },\n  },\n  customRule: {\n    error: {\n      emptyIdName: 'Rule ID and rule name cannot be empty',\n      idOccupied: 'Current rule ID is occupied by built-in rule',\n      nameOccupied: 'Current rule name is occupied by built-in rule',\n      idExists: 'Rule ID [{id}] already exists',\n      nameExists: 'Rule name [{name}] already exists',\n    },\n    title: '{id} - Configuration',\n    field: {\n      ruleId: 'Rule ID',\n      ruleName: 'Rule Name',\n      include: 'Include',\n      exclude: 'Exclude',\n      sizeRange: 'Size (MB)',\n      seeders: 'Seeders',\n      publishTime: 'Publish Time (min)',\n    },\n    placeholder: {\n      ruleId: 'Required; no duplicate with other rule IDs',\n      ruleName: 'Required; no duplicate with other rule names',\n      include: 'Keywords/Regex',\n      exclude: 'Keywords/Regex',\n      sizeRange: '0/1-10',\n      seeders: '0/1-10',\n      publishTime: '0/1-10',\n    },\n    hint: {\n      ruleId: 'Combination of letters and numbers; no spaces',\n      ruleName: 'Use alias for easy identification',\n      include: 'Keywords or regex that must be included, separated by ｜',\n      exclude: 'Keywords or regex that must not be included, separated by ｜',\n      sizeRange: 'Minimum resource file size or range (MB)',\n      seeders: 'Minimum number of seeders or range',\n      publishTime: 'Minimum time since publication or range (min)',\n    },\n    action: {\n      confirm: 'Confirm',\n    },\n  },\n  downloader: {\n    title: 'Downloader',\n    name: 'Name',\n    type: 'Type',\n    enabled: 'Enabled',\n    customTypeHint: 'Custom downloader type, for plugin scenarios',\n    rtorrentHostHint: 'HTTP: http://ip:port/RPC2 or SCGI: scgi://ip:port',\n    default: 'Default',\n    host: 'Host',\n    username: 'Username',\n    password: 'Password',\n    category: 'Auto Category Management',\n    sequentail: 'Sequential Download',\n    force_resume: 'Force Resume',\n    first_last_piece: 'First/Last Piece Priority',\n    saveSuccess: 'Downloader settings saved successfully',\n    saveFailed: 'Failed to save downloader settings',\n    nameRequired: 'Name cannot be empty',\n    nameDuplicate: 'Name already exists',\n    defaultChanged: 'Default downloader exists, has been replaced',\n    hostRequired: 'Host cannot be empty',\n    usernameRequired: 'Username cannot be empty',\n    passwordRequired: 'Password cannot be empty',\n    pathMapping: 'Path Mapping',\n    pathMappingRequired: 'Path cannot be empty',\n    pathMappingError: 'Must start with /',\n    storagePath: 'Storage Path',\n    downloadPath: 'Download Path',\n  },\n  filterRule: {\n    title: 'Filter Rule',\n    groupName: 'Group Name',\n    priority: 'Priority',\n    rules: 'Rules',\n    add: 'Add Rule',\n    import: 'Import Rules',\n    share: 'Share Rules',\n    save: 'Save Rules',\n    nameRequired: 'Rule group name cannot be empty',\n    nameDuplicate: 'Rule group name already exists',\n    importSuccess: 'Rules imported successfully',\n    importFailed: 'Failed to import rules',\n    shareSuccess: 'Rules copied to clipboard',\n    shareFailed: 'Failed to copy rules',\n    mediaType: 'Media Type',\n    category: 'Media Category',\n    mediaTypeItems: {\n      movie: 'Movie',\n      tv: 'TV Show',\n      anime: 'Anime',\n      collection: 'Collection',\n      unknown: 'Unknown',\n    },\n  },\n  mediaserver: {\n    type: 'Type',\n    customTypeHint: 'Custom media server type, for plugin scenarios',\n    enableMediaServer: 'Enable Media Server',\n    nameRequired: 'Required; cannot be duplicated',\n    serverAlias: 'Media server alias',\n    host: 'Host',\n    hostPlaceholder: 'http(s)://ip:port',\n    hostHint: 'Server address, format: http(s)://ip:port',\n    playHost: 'External Playback URL',\n    playHostPlaceholder: 'http(s)://domain:port',\n    playHostHint: 'URL for playback page redirection, format: http(s)://domain:port',\n    apiKey: 'API Key',\n    embyApiKeyHint: 'API key generated in Emby Settings -> Advanced -> API Keys',\n    jellyfinApiKeyHint: 'API key generated in Jellyfin Settings -> Advanced -> API Keys',\n    plexToken: 'X-Plex-Token',\n    plexTokenHint: 'X-Plex-Token obtained from Plex request URL in browser F12 -> Network',\n    username: 'Username',\n    usernameHint: 'Login username',\n    password: 'Password',\n    syncLibraries: 'Sync Libraries',\n    syncLibrariesHint: 'Only selected libraries will be synchronized',\n    scanMode: 'Scan Mode',\n    scanModeHint: 'Applies to full-library and targeted refresh: New & Modified / Supplement Missing / Full Override',\n    verifySsl: 'Verify SSL Certificate',\n    verifySslHint: 'When enabled, HTTPS certificates are verified; disable for self-signed certificates',\n    scanModeOptions: {\n      newAndModified: 'New & Modified',\n      supplementMissing: 'Supplement Missing',\n      fullOverride: 'Full Override',\n    },\n    hostRequired: 'Host cannot be empty',\n    apiKeyRequired: 'API Key cannot be empty',\n    tokenRequired: 'Token cannot be empty',\n    usernameRequired: 'Username cannot be empty',\n    passwordRequired: 'Password cannot be empty',\n    nameExists: '【{name}】 already exists, please use a different name',\n  },\n  bangumi: {\n    category: 'Category',\n    sort: 'Sort',\n    year: 'Year',\n    cat: {\n      other: 'Other',\n      tv: 'TV',\n      ova: 'OVA',\n      movie: 'Movie',\n      web: 'WEB',\n    },\n    sortType: {\n      rank: 'Rank',\n      date: 'Date',\n    },\n  },\n  tmdb: {\n    type: 'Type',\n    sort: 'Sort',\n    genre: 'Genre',\n    language: 'Language',\n    rating: 'Rating',\n    sortType: {\n      popularityDesc: 'Popularity Descending',\n      popularityAsc: 'Popularity Ascending',\n      releaseDateDesc: 'Release Date Descending',\n      releaseDateAsc: 'Release Date Ascending',\n      firstAirDateDesc: 'First Air Date Descending',\n      firstAirDateAsc: 'First Air Date Ascending',\n      voteAverageDesc: 'Vote Average Descending',\n      voteAverageAsc: 'Vote Average Ascending',\n      time: 'Latest',\n      count: 'Popular',\n      rating: 'Rating',\n    },\n    genreType: {\n      action: 'Action',\n      adventure: 'Adventure',\n      animation: 'Animation',\n      comedy: 'Comedy',\n      crime: 'Crime',\n      documentary: 'Documentary',\n      drama: 'Drama',\n      family: 'Family',\n      fantasy: 'Fantasy',\n      history: 'History',\n      horror: 'Horror',\n      music: 'Music',\n      mystery: 'Mystery',\n      romance: 'Romance',\n      scienceFiction: 'Science Fiction',\n      tvMovie: 'TV Movie',\n      thriller: 'Thriller',\n      war: 'War',\n      western: 'Western',\n      actionAdventure: 'Action & Adventure',\n      kids: 'Kids',\n      news: 'News',\n      reality: 'Reality',\n      sciFiFantasy: 'Sci-Fi & Fantasy',\n      soap: 'Soap',\n      talk: 'Talk',\n      warPolitics: 'War & Politics',\n    },\n    languageType: {\n      zh: 'Chinese',\n      en: 'English',\n      ja: 'Japanese',\n      ko: 'Korean',\n      fr: 'French',\n      de: 'German',\n      es: 'Spanish',\n      it: 'Italian',\n      ru: 'Russian',\n      pt: 'Portuguese',\n      ar: 'Arabic',\n      hi: 'Hindi',\n      th: 'Thai',\n    },\n  },\n  douban: {\n    type: 'Type',\n    sort: 'Sort',\n    genre: 'Genre',\n    zone: 'Region',\n    year: 'Year',\n    sortType: {\n      comprehensive: 'Comprehensive',\n      releaseDate: 'Release Date',\n      recentHot: 'Recent Hot',\n      highScore: 'High Score',\n    },\n    genreType: {\n      comedy: 'Comedy',\n      romance: 'Romance',\n      action: 'Action',\n      scienceFiction: 'Science Fiction',\n      animation: 'Animation',\n      mystery: 'Mystery',\n      crime: 'Crime',\n      thriller: 'Thriller',\n      adventure: 'Adventure',\n      music: 'Music',\n      history: 'History',\n      fantasy: 'Fantasy',\n      horror: 'Horror',\n      war: 'War',\n      biography: 'Biography',\n      musical: 'Musical',\n      martialArts: 'Martial Arts',\n      erotic: 'Erotic',\n      disaster: 'Disaster',\n      western: 'Western',\n      documentary: 'Documentary',\n      shortFilm: 'Short Film',\n    },\n    zoneType: {\n      chinese: 'Chinese',\n      europeanAmerican: 'European & American',\n      korean: 'Korean',\n      japanese: 'Japanese',\n      mainlandChina: 'Mainland China',\n      usa: 'USA',\n      hongKong: 'Hong Kong',\n      taiwan: 'Taiwan',\n      uk: 'UK',\n      france: 'France',\n      germany: 'Germany',\n      italy: 'Italy',\n      spain: 'Spain',\n      india: 'India',\n      thailand: 'Thailand',\n      russia: 'Russia',\n      canada: 'Canada',\n      australia: 'Australia',\n      ireland: 'Ireland',\n      sweden: 'Sweden',\n      brazil: 'Brazil',\n      denmark: 'Denmark',\n    },\n    yearType: {\n      '2020s': '2020s',\n      '2010s': '2010s',\n      '2000s': '2000s',\n      '1990s': '1990s',\n      '1980s': '1980s',\n      '1970s': '1970s',\n      '1960s': '1960s',\n    },\n  },\n  directory: {\n    alias: 'Directory Alias',\n    mediaType: 'Media Type',\n    mediaCategory: 'Media Category',\n    resourceStorage: 'Resource Storage',\n    resourceDirectory: 'Resource Directory',\n    sortByType: 'Sort by Type',\n    sortByCategory: 'Sort by Category',\n    autoTransfer: 'Auto Transfer',\n    monitorMode: 'Monitor Mode',\n    libraryStorage: 'Library Storage',\n    libraryDirectory: 'Library Directory',\n    transferType: 'Transfer Type',\n    transferTypeHint: 'File operation organization method, hard link saves space, copy is safer',\n    overwriteMode: 'Overwrite Mode',\n    overwriteModeHint: 'How to handle when target file already exists',\n    smartRename: 'Smart Rename',\n    scrapingMetadata: 'Scrape Metadata',\n    sendNotification: 'Send Notification',\n    noTransfer: 'No Transfer',\n    downloaderMonitor: 'Downloader Monitor',\n    directoryMonitor: 'Directory Monitor',\n    manualTransfer: 'Manual Transfer',\n    performanceMode: 'Performance Mode',\n    compatibilityMode: 'Compatibility Mode',\n    pleaseSelectStorage: 'Please select storage',\n    pleaseSelectLibraryStorage: 'Please select library storage',\n    pleaseSelectDownloadStorage: 'Please select download storage',\n    noSupportedTransferType: 'No supported transfer type',\n    never: 'Never',\n    always: 'Always',\n    byFileSize: 'By File Size',\n    keepLatestOnly: 'Keep Latest Only',\n  },\n  validators: {\n    required: 'This field is required',\n    number: 'Please enter a number',\n  },\n  folder: {\n    settingAppearance: 'Appearance Settings',\n    rename: 'Rename',\n    deleteFolder: 'Delete Folder',\n    folderNameCannotBeEmpty: 'Folder name cannot be empty',\n    confirmDeleteFolder:\n      'Are you sure you want to delete folder \"{folderName}\"? Plugins in this folder will be moved back to the main list.',\n    folderSettingsSaved: 'Folder settings saved',\n    renameFolder: 'Rename Folder',\n    folderName: 'Folder Name',\n    folderAppearanceSettings: 'Folder Appearance Settings',\n    showFolderIcon: 'Show Folder Icon',\n    icon: 'Icon',\n    iconColor: 'Icon Color',\n    backgroundGradient: 'Background Gradient',\n    customBackgroundImageURL: 'Custom Background Image URL (Optional)',\n    customBackgroundImageHint: 'Supports web image URLs, leave blank for gradient background',\n    pluginCount: '{count} Plugins',\n  },\n  setupWizard: {\n    title: 'Welcome to MoviePilot!',\n    subtitle: 'Complete the configuration by the wizard, and start using it immediately.',\n    completed: 'Setup Wizard completed!',\n    failed: 'Setup Wizard failed, please try again',\n    complete: 'Complete Configuration',\n    loading: 'Loading configuration data...',\n    testing: 'Testing',\n    connectivityTestSuccess: 'Connectivity test passed',\n    connectivityTestFailed: 'Connectivity test failed',\n    testingStorage: 'Testing storage',\n    checkingStorage: 'Checking storage connectivity',\n    testingDownloader: 'Testing downloader',\n    checkingDownloader: 'Checking downloader connectivity',\n    testingMediaServer: 'Testing media server',\n    checkingMediaServer: 'Checking media server connectivity',\n    testingNotification: 'Testing notification',\n    checkingNotification: 'Checking notification connectivity',\n    testFailedHint: 'Please check if the configuration is correct, you can retest after modification',\n    unsupportedDownloaderType: 'Unsupported downloader type: {type}',\n    unsupportedMediaServerType: 'Unsupported media server type: {type}',\n    unsupportedNotificationType: 'Unsupported notification type: {type}',\n    storageTestFailed: 'Storage test failed',\n    downloaderTestFailed: 'Downloader test failed',\n    downloaderNotSelected: 'No downloader selected',\n    mediaServerTestFailed: 'Media server test failed',\n    mediaServerNotSelected: 'No media server selected',\n    notificationTestFailed: 'Notification test failed',\n    notificationNotSelected: 'No notification type selected',\n    saveStepFailed: 'Failed to save step settings',\n    basicSettingsSaved: 'Basic settings saved successfully',\n    saveBasicSettingsFailed: 'Failed to save basic settings',\n    storageSettingsSaved: 'Storage settings saved successfully',\n    saveStorageSettingsFailed: 'Failed to save storage settings',\n    downloaderSettingsSaved: 'Downloader settings saved successfully',\n    saveDownloaderSettingsFailed: 'Failed to save downloader settings',\n    mediaServerSettingsSaved: 'Media server settings saved successfully',\n    saveMediaServerSettingsFailed: 'Failed to save media server settings',\n    notificationSettingsSaved: 'Notification settings saved successfully',\n    saveNotificationSettingsFailed: 'Failed to save notification settings',\n    saveSiteAuthSettingsFailed: 'Failed to save user site authentication settings: {message}',\n    saveAgentSettingsFailed: 'Failed to save AI assistant settings',\n    preferenceSettingsSaved: 'Preference settings saved successfully',\n    savePreferenceSettingsFailed: 'Failed to save preference settings',\n    passwordUpdateSuccess: 'Password updated successfully',\n    userCreateSuccess: 'User created successfully',\n    passwordUpdateFailed: 'Failed to update password',\n    basic: {\n      title: 'Basic Settings',\n      description: 'Set access domain, username/password and network configuration',\n      appDomain: 'App Domain',\n      appDomainHint: 'Used to add quick jump links when sending notifications',\n      wallpaper: 'Background Wallpaper',\n      wallpaperHint: 'Choose the source of the login page background',\n      recognizeSource: 'Recognize Source',\n      recognizeSourceHint: 'Set the default media info recognition data source',\n      apiToken: 'API Token',\n      apiTokenHint: 'API Token required for accessing MoviePilot API, please record it for subsequent use',\n      currentUserHint: 'Current user, cannot be modified',\n      passwordOptionalHint: 'Leave blank to keep current password',\n      confirmPasswordHint: 'Confirm new password',\n      apiTokenRequired: 'API Token is required',\n    },\n    siteAuth: {\n      title: 'User Authentication',\n      description: 'Configure site authentication and auxiliary authentication',\n      info: 'User Site Authentication',\n      infoDesc:\n        'Completing site authentication unlocks site capabilities and some plugin permissions. This step is optional and can also be configured later from the user menu.',\n      selectSiteHint: 'Choose a supported auth site and fill in the required credentials for that site',\n      submitHint: 'When you click Next, the wizard will immediately validate against the selected auth site and save the current parameters on success.',\n      siteConfigNotExist: 'Authentication site configuration does not exist',\n      fieldRequired: 'Please enter {name}',\n    },\n    storage: {\n      title: 'Storage',\n      description: 'Configure download directory and media library directory',\n      info: 'Storage Configuration',\n      infoDesc: 'Configure local storage directories for download and media library management',\n      downloadPath: 'Download Directory',\n      downloadPathHint: 'Set the storage path for downloaded files',\n      libraryPath: 'Media Library Directory',\n      libraryPathHint: 'Set the storage path for media files',\n      downloadPathRequired: 'Download directory is required',\n      libraryPathRequired: 'Media library directory is required',\n    },\n    downloader: {\n      title: 'Downloader',\n      description: 'Configure downloader',\n      info: 'Downloader Configuration',\n      infoDesc: 'Configure downloader for resource download, can choose qBittorrent or Transmission',\n      type: 'Downloader Type',\n      typeHint: 'Select the type of downloader to use',\n      name: 'Downloader Name',\n      nameHint: 'Set a name for the downloader',\n      qbittorrentConfig: 'qBittorrent Configuration',\n      transmissionConfig: 'Transmission Configuration',\n      host: 'Server Address',\n      username: 'Username',\n      password: 'Password',\n      downloadPath: 'Download Path',\n    },\n    mediaServer: {\n      title: 'Media Server',\n      description: 'Configure media server',\n      info: 'Media Server Configuration',\n      infoDesc:\n        'Configure media server for media library management, can choose Emby, Jellyfin, Plex, TrimeMedia or Ugreen.',\n      type: 'Media Server Type',\n      typeHint: 'Select the type of media server to use',\n      name: 'Server Name',\n      nameHint: 'Set a name for the media server',\n      embyConfig: 'Emby Configuration',\n      jellyfinConfig: 'Jellyfin Configuration',\n      plexConfig: 'Plex Configuration',\n      host: 'Server Address',\n      apiKey: 'API Key',\n      token: 'Access Token',\n    },\n    notification: {\n      title: 'Notification',\n      description: 'Configure notification channels',\n      info: 'Notification Configuration',\n      infoDesc: 'Configure notification channels for receiving system messages (optional)',\n      type: 'Notification Type',\n      typeHint: 'Select the type of notification channel to use',\n      name: 'Notification Name',\n      nameHint: 'Set a name for the notification channel',\n      telegramConfig: 'Telegram Configuration',\n      emailConfig: 'Email Configuration',\n      botToken: 'Bot Token',\n      chatId: 'Chat ID',\n      smtpServer: 'SMTP Server',\n      smtpPort: 'SMTP Port',\n      senderEmail: 'Sender Email',\n      senderPassword: 'Sender Password',\n      receiverEmail: 'Receiver Email',\n    },\n    agent: {\n      title: 'AI Assistant',\n      description: 'Configure the Agent assistant and LLM parameters',\n      info: 'AI Assistant Configuration',\n      infoDesc:\n        'After enabling it, you can use the Agent in message conversations and optionally turn on transfer-failure takeover and AI recommendations.',\n      providerRequired: 'LLM provider is required',\n      apiKeyRequired: 'LLM API key is required',\n      modelRequired: 'LLM model name is required',\n      maxContextTokensRequired: 'LLM max context tokens must be greater than 0',\n      recommendMaxItemsRequired: 'AI recommendation analysis limit must be greater than 0',\n    },\n    preferences: {\n      title: 'Resource Preferences',\n      description: 'Set resource download preferences',\n      info: 'Resource Preferences',\n      infoDesc:\n        'Set resource download preferences, the system will automatically select the best resources based on these preferences',\n      quality: 'Quality Preference',\n      qualityHint: 'Select preferred video quality',\n      subtitle: 'Subtitle Preference',\n      subtitleHint: 'Select preferred subtitle type',\n      resolution: 'Resolution Preference',\n      resolutionHint: 'Select preferred video resolution',\n      presetRules: 'Preset Rules',\n      detailedConfig: 'Detailed Configuration',\n      quickPresets: 'Quick Presets',\n      quickPresetsDesc: 'Select preset configuration, system will automatically apply corresponding rules',\n      personalizationOptions: 'Personalization Options',\n      personalizationOptionsDesc: 'Adjust rules according to your needs',\n      excludeDolbyVision: 'Exclude Dolby Vision',\n      excludeDolbyVisionHint: 'Exclude Dolby Vision resources from rules when selected',\n      excludeBluray: 'Exclude Blu-ray',\n      excludeBlurayHint: 'Exclude Blu-ray resources from rules when selected',\n      presets: {\n        '4k-enthusiast': {\n          name: '4K Enthusiast',\n          description: 'Pursue the highest quality, prioritize 4K',\n        },\n        'balanced': {\n          name: 'Balanced Mode',\n          description: 'Balance between quality and storage space',\n        },\n        'space-saver': {\n          name: 'Space Saver',\n          description: 'Prioritize smaller files to save storage space',\n        },\n        'free-priority': {\n          name: 'Free Priority',\n          description: 'Prioritize free resources, no other requirements',\n        },\n      },\n    },\n  },\n}\n"
  },
  {
    "path": "src/locales/zh-CN.ts",
    "content": "export default {\n  common: {\n    confirm: '确认',\n    cancel: '取消',\n    save: '保存',\n    close: '关闭',\n    version: '版本',\n    author: '作者',\n    delete: '删除',\n    edit: '编辑',\n    add: '添加',\n    search: '搜索',\n    loading: '加载中',\n    success: '成功',\n    error: '错误',\n    openInNewWindow: '在新窗口中打开',\n    inputMessage: '输入消息或命令',\n    send: '发送',\n    noData: '暂无数据',\n    noContent: '没有找到相关内容',\n    all: '全部',\n    active: '激活',\n    inactive: '未激活',\n    filter: '筛选',\n    noMatchingData: '没有符合条件的数据',\n    tryChangingFilters: '请尝试更改筛选条件',\n    default: '默认',\n    name: '名称',\n    create: '新建',\n    saving: '保存中',\n    reset: '重置',\n    theme: '主题',\n    uiMode: '界面布局',\n    language: '语言',\n    pleaseWait: '请稍候...',\n    viewDetails: '查看详情',\n    user: '用户',\n    config: '配置',\n    pause: '暂停',\n    enable: '启用',\n    confirmAction: '确认{action}',\n    details: '详情',\n    files: '文件',\n    share: '分享',\n    subscribe: '订阅',\n    unsubscribe: '取消订阅',\n    media: '媒体',\n    unknown: '未知',\n    notFetched: '未获取',\n    notice: '注意',\n    itemsPerPage: '每页条数',\n    pageText: '{0}-{1} 共 {2} 条',\n    noDataText: '没有数据',\n    next: '下一步',\n    previous: '上一步',\n    skip: '跳过',\n    loadingText: '加载中...',\n    networkRequired: '此功能需要网络连接',\n    networkDisconnected: '网络连接已断开',\n    featuresLimited: '部分功能可能受限',\n    serverConnectionFailed: '服务器连接失败',\n    troubleshooting: '疑难解答',\n    checking: '检查中',\n    retry: '重试',\n    networkOnline: '网络在线',\n    networkOffline: '网络离线',\n    serviceAvailable: '服务可用',\n    serviceUnavailable: '服务不可用',\n    status: '状态',\n    preset: '预设',\n    refresh: '刷新',\n    swUpdateReady: '新版本已就绪，请刷新页面以获取最新功能',\n    ascending: '升序',\n    descending: '降序',\n    versionMismatch: '浏览器缓存版本与服务端版本不一致，请尝试清除缓存',\n    clearCache: '清除缓存',\n  },\n  mediaType: {\n    movie: '电影',\n    tv: '电视剧',\n    anime: '动漫',\n    collection: '合集',\n    unknown: '未知',\n  },\n  notificationSwitch: {\n    resourceDownload: '资源下载',\n    organize: '整理入库',\n    subscribe: '订阅',\n    site: '站点',\n    mediaServer: '媒体服务器',\n    manual: '手动处理',\n    plugin: '插件',\n    agent: '智能体',\n    other: '其它',\n  },\n  actionStep: {\n    addDownload: '添加下载',\n    addSubscribe: '添加订阅',\n    fetchDownloads: '获取下载任务',\n    fetchMedias: '获取媒体',\n    fetchRss: '获取RSS资源',\n    fetchTorrents: '获取站点资源',\n    filterMedias: '过滤媒体',\n    filterTorrents: '过滤资源',\n    scanFile: '扫描目录',\n    scrapeFile: '刮削文件',\n    sendEvent: '发送事件',\n    sendMessage: '发送消息',\n    transferFile: '整理文件',\n    invokePlugin: '调用插件',\n    note: '备注',\n  },\n  qualityOptions: {\n    all: '全部',\n    blurayOriginal: '蓝光原盘',\n    remux: 'Remux',\n    bluray: 'BluRay',\n    uhd: 'UHD',\n    webdl: 'WEB-DL',\n    hdtv: 'HDTV',\n    h265: 'H265',\n    h264: 'H264',\n  },\n  resolutionOptions: {\n    all: '全部',\n    '4k': '4k',\n    '1080p': '1080p',\n    '720p': '720p',\n  },\n  effectOptions: {\n    all: '全部',\n    dolbyVision: '杜比视界',\n    dolbyAtmos: '杜比全景声',\n    hdr: 'HDR',\n    sdr: 'SDR',\n  },\n  theme: {\n    light: '浅色',\n    dark: '深色',\n    auto: '跟随系统',\n    autoUI: '自动',\n    transparent: '透明',\n    purple: '幻紫',\n    custom: '附加样式',\n    transparency: '透明度',\n    transparencyAdjust: '透明度调整',\n    transparencyOpacity: '透明度',\n    transparencyBlur: '模糊度',\n    transparencyReset: '重置',\n    transparencyLow: '低透明度',\n    transparencyMedium: '中等透明度',\n    transparencyHigh: '高透明度',\n    customCssSaveSuccess: '自定义CSS保存成功，请刷新页面生效！',\n    customCssSaveFailed: '保存自定义CSS到服务端失败',\n    deviceNotSupport: '当前设备不支持监听系统主题变化',\n  },\n  app: {\n    moviepilot: 'MoviePilot',\n    slogan: '智能影视媒体库管理工具',\n    recommend: '推荐',\n    subscribeMovie: '电影订阅',\n    subscribeTv: '电视剧订阅',\n    settings: '设置',\n    selectLanguage: '选择语言',\n    logout: '退出登录',\n    restarting: '正在重启...',\n    confirmRestart: '确认重启系统吗？',\n    restartTip: '重启后，您将被注销并需要重新登录。',\n    restartTimeout: '重启超时，系统可能需要更长时间恢复，请稍后手动刷新页面',\n    restartFailed: '重启失败，请检查系统状态',\n    offline: '应用已离线',\n    offlineMessage: '网络连接已断开，部分功能可能受限',\n    online: '应用在线',\n    onlineMessage: '网络连接已恢复',\n  },\n  pwa: {\n    installApp: '安装 MoviePilot 应用',\n    installDescription: '获得更好的离线体验和性能',\n    install: '安装',\n    installSuccess: '应用安装成功！',\n    installGuide: '安装指南',\n    installInstructions: '在 {platform} 上安装 MoviePilot：',\n    installNote: '安装后，您可以从主屏幕快速访问 MoviePilot，并享受离线功能。',\n    gotIt: '知道了',\n    // 平台特定的说明\n    platforms: {\n      ios: 'iOS',\n      android: 'Android',\n      chrome: 'Chrome',\n      edge: 'Edge',\n      firefox: 'Firefox',\n      safari: 'Safari',\n      desktop: '桌面设备',\n      mobile: '移动设备',\n      other: '其他浏览器',\n    },\n    // 安装步骤\n    installSteps: {\n      ios: {\n        0: '点击浏览器底部的分享按钮',\n        1: '选择\"添加到主屏幕\"',\n        2: '点击\"添加\"确认安装',\n      },\n      android: {\n        0: '点击浏览器菜单（三个点）',\n        1: '选择\"添加到主屏幕\"或\"安装应用\"',\n        2: '点击\"安装\"确认',\n      },\n      chrome: {\n        0: '点击地址栏右侧的安装图标',\n        1: '或者点击浏览器菜单中的\"安装 MoviePilot\"',\n        2: '点击\"安装\"确认',\n      },\n      edge: {\n        0: '点击地址栏右侧的\"应用可用\"图标',\n        1: '在弹出的面板中点击\"安装\"按钮',\n        2: '在确认对话框中点击\"安装\"',\n      },\n      firefox: {\n        0: '点击地址栏右侧的安装图标',\n        1: '选择\"安装\"',\n        2: '确认安装到桌面',\n      },\n      safari: {\n        0: '点击分享按钮',\n        1: '选择\"添加到主屏幕\"',\n        2: '点击\"添加\"确认',\n      },\n      desktop: {\n        0: '点击地址栏右侧的安装图标',\n        1: '选择\"安装应用\"',\n        2: '按照提示完成安装',\n      },\n      mobile: {\n        0: '点击浏览器菜单',\n        1: '选择\"添加到主屏幕\"',\n        2: '确认安装',\n      },\n      other: {\n        0: '查找浏览器中的\"安装\"选项',\n        1: '通常在地址栏或菜单中',\n        2: '按照提示完成安装',\n      },\n    },\n  },\n  login: {\n    wallpapers: '壁纸',\n    username: '用户名',\n    password: '密码',\n    otpCode: '验证码',\n    stayLoggedIn: '保持登录',\n    login: '登录',\n    networkError: '登录失败，请检查网络连接！',\n    authFailure: '登录失败，请检查用户名、密码或二次验证是否正确！',\n    permissionDenied: '登录失败，您没有权限访问！',\n    noPermission: '登录失败，您没有任何功能权限，请联系管理员！',\n    serverError: '登录失败，服务器错误！',\n    loginFailed: '登录失败',\n    secondaryVerification: '二次验证',\n    orDivider: '或',\n    loginWithPasskey: '使用通行密钥登录',\n    loginWithOtp: '使用验证码登录',\n    orUsePasskey: '或使用通行密钥进行验证',\n    verifyWithPasskey: '使用通行密钥验证',\n    otpPlaceholder: '请输入6位验证码',\n    passkeyLoginStartFailed: '启动通行密钥认证失败',\n    passkeyNotSelected: '未选择通行密钥',\n    passkeyLoginFailed: '通行密钥登录失败',\n    passkeyAuthCanceled: '通行密钥认证被取消',\n    passkeyNotSupported: '当前浏览器不支持通行密钥',\n    passkeySecureContextRequired: '通行密钥需要 HTTPS 安全连接',\n    passkeyVerifyFailed: '通行密钥验证失败',\n    passkeyVerifyFailedRetry: '通行密钥验证失败，请重试',\n    mfa: {\n      selectVerificationMethod: '请选择验证方式',\n    },\n  },\n  menu: {\n    start: '开始',\n    discovery: '发现',\n    subscribe: '订阅',\n    organize: '整理',\n    system: '系统',\n  },\n  navItems: {\n    dashboard: '仪表盘',\n    mediaInfo: '媒体库',\n    recommend: '推荐',\n    site: '站点',\n    search: '搜索',\n    searchResult: '搜索结果',\n    download: '下载',\n    movieSubscribe: '电影订阅',\n    tvSubscribe: '电视剧订阅',\n    history: '历史记录',\n    transfer: '整理',\n    rename: '重命名',\n    statistic: '统计',\n    setting: '设置',\n    plugin: '插件',\n    user: '用户',\n    about: '关于',\n    explore: '探索',\n    movie: '电影',\n    tv: '电视剧',\n    workflow: '工作流',\n    calendar: '日历',\n    downloadManager: '下载管理',\n    mediaOrganize: '媒体整理',\n    fileManager: '文件管理',\n    pluginManager: '插件',\n    siteManager: '站点管理',\n    userManager: '用户管理',\n    settings: '设定',\n  },\n  settingTabs: {\n    system: {\n      title: '系统',\n      description: '基础设置、下载器（Qbittorrent、Transmission）、媒体服务器（Emby、Jellyfin、Plex、飞牛影视、绿联影视）',\n    },\n    directory: {\n      title: '存储 & 目录',\n      description: '下载目录、媒体库目录、整理、刮削',\n    },\n    site: {\n      title: '站点',\n      description: '站点同步、站点数据刷新、站点重置',\n    },\n    rule: {\n      title: '规则',\n      description: '自定义规则、优先级规则组、下载规则',\n    },\n    search: {\n      title: '搜索 & 下载',\n      description: '搜索数据源（TheMovieDb、豆瓣、Bangumi）、下载任务标签、搜索站点',\n    },\n    subscribe: {\n      title: '订阅',\n      description: '订阅站点、订阅模式、订阅规则、洗版规则',\n    },\n    scheduler: {\n      title: '服务',\n      description: '定时作业',\n    },\n    cache: {\n      title: '缓存',\n      description: '种子缓存、图片文件缓存管理',\n    },\n    notification: {\n      title: '通知',\n      description: '通知渠道（微信、Telegram、Slack、SynologyChat、VoceChat、WebPush）、消息发送范围',\n    },\n    about: {\n      title: '关于',\n      description: '软件版本',\n    },\n  },\n  subscribeTabs: {\n    movie: {\n      mysub: '我的订阅',\n      popular: '热门订阅',\n    },\n    tv: {\n      mysub: '我的订阅',\n      popular: '热门订阅',\n      share: '订阅分享',\n    },\n  },\n  workflowTabs: {\n    list: '我的工作流',\n    share: '工作流分享',\n  },\n  pluginTabs: {\n    installed: '我的插件',\n    market: '插件市场',\n  },\n  discoverTabs: {\n    themoviedb: 'TheMovieDb',\n    douban: '豆瓣',\n    bangumi: 'Bangumi',\n  },\n  user: {\n    admin: '管理员',\n    normal: '普通用户',\n    active: '激活',\n    inactive: '已停用',\n    noEmail: '未设置邮箱',\n    movieSubscriptions: '电影订阅',\n    tvSubscriptions: '剧集订阅',\n    cannotDeleteCurrentUser: '不能删除当前登录用户！',\n    confirmDeleteUser: '删除用户 {username} 的所有数据，是否确认？',\n    deleteSuccess: '用户删除成功',\n    deleteFailed: '用户删除失败！',\n    profile: '个人信息',\n    systemSettings: '系统设定',\n    wizardSettings: '设置向导',\n    siteAuth: '用户认证',\n    helpDocs: '帮助文档',\n    about: '关于',\n    restart: '重启',\n    management: '用户管理',\n    noUsers: '没有用户',\n    clickToAddUser: '点击添加用户卡片添加用户',\n    addUser: '添加用户',\n    editUser: '编辑用户',\n    username: '用户名',\n    usernameHint: '用于登录系统的用户名',\n    password: '密码',\n    passwordHint: '请输入登录密码',\n    confirmPassword: '确认密码',\n    confirmPasswordHint: '请再次输入密码以确认',\n    role: '角色',\n    email: '邮箱',\n    enabled: '启用',\n    disabled: '禁用',\n    status: '状态',\n    operations: '操作',\n  },\n  nav: {\n    more: '更多',\n  },\n  notification: {\n    center: '通知中心',\n    markRead: '设为已读',\n    empty: '暂无通知',\n    channel: '通知渠道',\n    name: '名称',\n    nameHint: '通知渠道名称',\n    type: '类型',\n    typeHint: '通知渠道类型',\n    customTypeHint: '自定义通知类型，用于插件实现场景',\n    customTypePlaceholder: 'custom',\n    nameRequired: '请输入名称',\n    enabled: '启用',\n    config: '配置',\n    wechat: {\n      name: '企业微信',\n      useBotMode: '使用智能机器人',\n      useBotModeHint: '开启后使用智能机器人长连接，固定 dmPolicy=open、groupPolicy=disabled',\n      corpId: '企业ID',\n      corpIdHint: '企业微信后台企业信息中的企业ID',\n      corpIdRequired: '企业ID不能为空',\n      appId: '应用 AgentId',\n      appIdHint: '企业微信自建应用的AgentId',\n      appIdRequired: '应用AgentId不能为空',\n      appSecret: '应用 Secret',\n      appSecretHint: '企业微信自建应用的Secret',\n      appSecretRequired: '应用Secret不能为空',\n      proxy: '代理地址',\n      proxyHint: '微信消息的转发代理地址，2022年6月20日后创建的自建应用才需要，不使用代理时需要保留默认值',\n      token: 'Token',\n      tokenHint: '微信企业自建应用->API接收消息配置中的Token',\n      encodingAesKey: 'EncodingAESKey',\n      encodingAesKeyHint: '微信企业自建应用->API接收消息配置中的EncodingAESKey',\n      botId: '机器人 BotID',\n      botIdHint: '企业微信智能机器人的 BotID',\n      botSecret: '机器人 Secret',\n      botSecretHint: '企业微信智能机器人长连接专用 Secret',\n      botChatId: '默认通知目标',\n      botChatIdHint: '可填写用户 userid；如需主动发群消息可填写 group:群聊chatid，不填则默认发给已互动用户',\n      botChatIdPlaceholder: 'userid 或 group:chatid',\n      botWsUrl: '长连接地址',\n      botWsUrlHint: '企业微信智能机器人 WebSocket 地址，通常使用默认值',\n      admins: '管理员白名单',\n      adminsHint: '可使用管理菜单及命令的用户ID列表，多个ID使用,分隔',\n      adminsPlaceholder: '用户ID列表，多个ID使用,分隔',\n    },\n    telegram: {\n      name: 'Telegram',\n      token: 'Bot Token',\n      tokenHint: 'Telegram机器人token，格式：123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',\n      tokenRequired: 'Bot Token不能为空',\n      chatId: 'Chat ID',\n      chatIdHint: '接受消息通知的用户、群组或频道Chat ID',\n      chatIdRequired: 'Chat ID不能为空',\n      users: '用户白名单',\n      usersHint: '可使用Telegram机器人的用户ID清单，多个用户用,分隔，不填写则所有用户都能使用',\n      admins: '管理员白名单',\n      adminsHint: '可使用管理菜单及命令的用户ID列表，多个ID使用,分隔',\n      adminsPlaceholder: '用户ID列表，多个ID使用,分隔',\n      usersPlaceholder: '用户ID列表，多个ID使用,分隔',\n      apiUrl: '代理API地址',\n      apiUrlHint: '自定义代理API地址，格式：https://api.telegram.org',\n      apiUrlPlaceholder: 'https://api.telegram.org',\n    },\n    slack: {\n      name: 'Slack',\n      oauthToken: 'Slack Bot User OAuth Token',\n      oauthTokenHint: 'Slack应用`OAuth & Permissions`页面中的`Bot User OAuth Token`',\n      oauthTokenRequired: 'OAuth Token不能为空',\n      appToken: 'Slack App-Level Token',\n      appTokenHint: 'Slack应用`OAuth & Permissions`页面中的`App-Level Token`',\n      channel: '频道名称',\n      channelHint: '消息发送频道，默认`全体`',\n      channelRequired: '频道名称不能为空',\n    },\n    discord: {\n      name: 'Discord',\n      botToken: 'Bot Token',\n      botTokenHint: 'Discord Bot Token（需在开发者后台开启 Message Content Intent）',\n      botTokenRequired: 'Bot Token不能为空',\n      guildId: '服务器 ID',\n      guildIdHint: '可选，限制使用的服务器；为空则使用已加入的任意服务器',\n      guildIdPlaceholder: '123456789012345678',\n      channelId: '频道 ID',\n      channelIdHint: '可选，默认广播频道；为空则自动选择可发送消息的频道',\n      channelIdPlaceholder: '123456789012345678',\n    },\n    synologychat: {\n      name: 'Synology Chat',\n      webhook: '机器人传入URL',\n      webhookHint: 'Synology Chat机器人传入URL',\n      webhookRequired: 'Webhook URL不能为空',\n      token: '令牌',\n      tokenHint: 'Synology Chat机器人令牌',\n    },\n    vocechat: {\n      name: 'VoceChat',\n      host: '地址',\n      hostHint: 'VoceChat服务端地址，格式：http(s)://ip:port',\n      hostRequired: '地址不能为空',\n      apiKey: '机器人密钥',\n      apiKeyHint: 'VoceChat机器人密钥',\n      apiKeyRequired: 'API密钥不能为空',\n      channelId: '频道ID',\n      channelIdHint: 'VoceChat的频道ID，不包含#号',\n    },\n    webpush: {\n      name: 'WebPush',\n      username: '登录用户名',\n      usernameHint: '只有对应的用户登录后才会推送消息',\n      usernameRequired: '用户名不能为空',\n    },\n    qqbot: {\n      name: 'QQ',\n      appId: 'AppID',\n      appIdHint: 'QQ 开放平台机器人 AppID',\n      appIdRequired: 'AppID 不能为空',\n      appSecret: 'AppSecret',\n      appSecretHint: 'QQ 开放平台机器人 AppSecret',\n      appSecretRequired: 'AppSecret 不能为空',\n      openId: '用户 OpenID',\n      openIdHint: '默认接收者 openid（单聊），用户需曾与机器人交互过',\n      openIdPlaceholder: '32位十六进制',\n      groupOpenId: '群组 OpenID',\n      groupOpenIdHint: '默认群组 openid（群聊），与用户 OpenID 二选一',\n      groupOpenIdPlaceholder: '群组 openid',\n    },\n  },\n  shortcut: {\n    title: '捷径',\n    recognition: {\n      title: '识别',\n      subtitle: '名称识别测试',\n    },\n    rule: {\n      title: '规则',\n      subtitle: '规则测试',\n    },\n    log: {\n      title: '日志',\n      subtitle: '实时日志',\n    },\n    network: {\n      title: '网络',\n      subtitle: '网速连通性测试',\n    },\n    system: {\n      title: '系统',\n      subtitle: '健康检查',\n    },\n    message: {\n      title: '消息',\n      subtitle: '消息中心',\n    },\n    words: {\n      title: '词表',\n      subtitle: '词表设置',\n    },\n    cache: {\n      title: '缓存',\n      subtitle: '管理缓存',\n    },\n    scheduler: {\n      title: '服务',\n      subtitle: '定时服务',\n    },\n  },\n  workflow: {\n    components: '动作组件',\n    clickToAdd: '点击添加',\n    dragToCanvas: '拖动到画布',\n    tapComponentHint: '点击组件添加到画布',\n    dragComponentHint: '拖动组件到画布',\n    task: {\n      edit: '编辑任务',\n      editFlow: '编辑流程',\n      share: '分享',\n      continue: '继续执行',\n      restart: '重新执行',\n      run: '立即执行',\n      reset: '重置任务',\n      delete: '删除任务',\n      confirmDelete: '是否确认删除任务 {name} ?',\n      confirmReset: '是否确认重置任务 {name} ?',\n      deleteSuccess: '删除任务成功！',\n      deleteFailed: '删除任务失败：{message}',\n      enableSuccess: '启用任务成功！',\n      enableFailed: '启用任务失败：{message}',\n      pauseSuccess: '停用任务成功！',\n      pauseFailed: '停用任务失败：{message}',\n      runSuccess: '任务执行完成！',\n      runFailed: '任务执行失败：{message}',\n      resetSuccess: '重置任务成功！',\n      resetFailed: '重置任务失败：{message}',\n      status: {\n        success: '成功',\n        running: '运行中',\n        failed: '失败',\n        paused: '暂停',\n        waiting: '等待',\n      },\n      info: {\n        trigger: '触发方式',\n        timer: '定时',\n        status: '状态',\n        actionCount: '动作数',\n        runCount: '已执行次数',\n        progress: '进度',\n        error: '错误信息',\n        manualTrigger: '手动',\n      },\n    },\n    scanFile: {\n      title: '扫描目录',\n      subtitle: '扫描目录文件到队列',\n      storage: '存储',\n      directory: '目录',\n    },\n    addDownload: {\n      title: '添加下载',\n      subtitle: '添加资源到下载器',\n      downloader: '下载器',\n      category: '分类',\n      savePath: '保存路径',\n      sequential: '顺序下载',\n      forceResume: '强制继续',\n      firstLastPiece: '优先首尾文件',\n      onlyLack: '仅下载缺失资源',\n      categoryPlaceholder: '多个使用,分隔',\n      savePathPlaceholder: '留空自动',\n    },\n    addSubscribe: {\n      title: '添加订阅',\n      subtitle: '添加资源到订阅',\n      type: '类型',\n      name: '名称',\n      season: '季',\n      episode: '集',\n    },\n    fetchMedias: {\n      title: '获取媒体',\n      subtitle: '从媒体服务器获取媒体信息',\n      source: '数据源',\n      searchType: '搜索类型',\n      type: '类型',\n      name: '名称',\n      year: '年份',\n      ranking: '榜单',\n      api: '插件API',\n      apiPath: 'API路径',\n      selectRanking: '选择榜单',\n      tmdbTrending: 'TMDB 流行趋势',\n      doubanShowing: '豆瓣正在热映',\n      bangumiCalendar: 'Bangumi 每日放送',\n      tmdbMovies: 'TMDB 热门电影',\n      tmdbTvs: 'TMDB 热门电视剧',\n      doubanMovieHot: '豆瓣热门电影',\n      doubanTvHot: '豆瓣热门电视剧',\n      doubanTvAnimation: '豆瓣热门动漫',\n      doubanMovies: '豆瓣最新电影',\n      doubanTvs: '豆瓣最新电视剧',\n      doubanMovieTop250: '豆瓣电影TOP250',\n      doubanTvWeeklyChinese: '豆瓣国产剧集榜',\n      doubanTvWeeklyGlobal: '豆瓣全球剧集榜',\n    },\n    filterMedias: {\n      title: '过滤媒体',\n      subtitle: '根据条件过滤媒体',\n      type: '类型',\n      name: '名称',\n      year: '年份',\n      vote: '评分',\n    },\n    scrapeFile: {\n      title: '刮削文件',\n      subtitle: '刮削文件元数据',\n    },\n    sendEvent: {\n      title: '发送事件',\n      subtitle: '发送系统事件',\n    },\n    fetchDownloads: {\n      title: '获取下载',\n      subtitle: '获取下载器任务',\n      loop: '循环执行',\n      loopInterval: '循环间隔',\n    },\n    fetchRss: {\n      title: '获取RSS',\n      subtitle: '获取RSS订阅',\n      url: 'URL',\n      userAgent: 'User-Agent',\n      timeout: '超时时间',\n      matchMedia: '匹配媒体数据',\n      useProxy: '使用代理',\n    },\n    fetchTorrents: {\n      title: '获取站点资源',\n      subtitle: '获取站点种子列表',\n      searchType: '搜索类型',\n      searchOptions: {\n        name: '名称',\n        mediaList: '媒体列表',\n      },\n      name: '名称',\n      year: '年份',\n      type: '类型',\n      season: '季',\n      sites: '站点',\n      matchMedia: '匹配媒体数据',\n    },\n    sendMessage: {\n      title: '发送消息',\n      subtitle: '发送系统消息',\n      channel: '渠道',\n      userId: '用户ID',\n    },\n    transferFile: {\n      title: '传输文件',\n      subtitle: '传输文件到目标目录',\n      source: '源目录',\n      sourceOptions: {\n        fileList: '文件列表',\n        downloads: '下载列表',\n      },\n    },\n    filterTorrents: {\n      title: '过滤资源',\n      subtitle: '对资源列表数据进行过滤',\n      quality: '质量',\n      resolution: '分辨率',\n      effect: '特效',\n      size: '大小范围',\n      include: '包含（关键字、正则式）',\n      exclude: '排除（关键字、正则式）',\n      ruleGroups: '过滤规则组',\n    },\n    invokePlugin: {\n      title: '调用插件',\n      subtitle: '调用插件执行特定操作',\n      plugin: '插件',\n      actionid: '动作ID',\n      actionParams: '动作参数',\n      loadPluginSettingFailed: '加载插件设置失败',\n    },\n    note: {\n      title: '备注',\n      subtitle: '添加流程说明注释',\n      content: '备注内容',\n      placeholder: '请输入备注内容...',\n    },\n    title: '工作流',\n    share: '工作流分享',\n    searchShares: '搜索工作流分享',\n    noShareData: '暂无分享的工作流',\n    sharer: '分享人',\n    trigger: '触发方式',\n    timer: '定时器',\n    manualTrigger: '手动触发',\n    actionCount: '动作数量',\n    normalFork: '复用工作流',\n    cancelShare: '取消分享',\n    cancelSuccess: '取消分享成功',\n    cancelFailed: '取消分享失败：{message}',\n    usageCount: '复用 {count} 次',\n    addSuccess: '复用 {name} 成功！',\n    addFailed: '复用 {name} 失败：{message}',\n    noWorkflow: '没有工作流',\n    noWorkflowDescription: '点击添加按钮创建工作流任务。',\n  },\n  dashboard: {\n    storage: '存储空间',\n    mediaStatistic: '媒体统计',\n    weeklyOverview: '最近入库',\n    realTimeSpeed: '实时速率',\n    scheduler: '后台任务',\n    cpu: 'CPU',\n    memory: '内存',\n    network: '网络流量',\n    upload: '上行',\n    download: '下行',\n    library: '我的媒体库',\n    playing: '继续观看',\n    latest: '最近添加',\n    settings: '设置仪表板',\n    chooseContent: '选择您想在页面显示的内容',\n    adaptiveHeight: '自适应组件高度',\n    current: '当前',\n    episodes: '剧集',\n    users: '用户',\n    noSchedulers: '没有后台服务',\n    weeklyOverviewDescription: '最近一周入库了 {count} 部影片',\n    speed: {\n      totalUpload: '总上传量',\n      totalDownload: '总下载量',\n      freeSpace: '磁盘剩余空间',\n    },\n    processes: {\n      title: '系统进程',\n      pid: '进程ID',\n      name: '进程名称',\n      runtime: '运行时间',\n      memory: '内存占用',\n    },\n    errors: {\n      loadMediaServer: '加载媒体服务器设置失败:',\n      loadLatest: '加载媒体服务器 \"{server}\" 的最近入库失败:',\n    },\n  },\n  media: {\n    status: {\n      inLibrary: '已入库',\n      missing: '缺失',\n      partiallyMissing: '部分缺失',\n      subscribed: '已订阅',\n    },\n    minutes: '分钟',\n    overview: '简介',\n    seasons: '季',\n    seasonNumber: '第 {number} 季',\n    episodeCount: '{count}集',\n    actions: {\n      searchResource: '搜索资源',\n      subscribe: '订阅',\n      playOnline: '在线播放',\n      playInApp: 'APP播放',\n      playInWeb: '网页播放',\n    },\n    search: {\n      byTitle: '标题',\n      byImdb: 'IMDB链接',\n    },\n    info: {\n      originalTitle: '原始标题',\n      status: '状态',\n      releaseDate: '上映日期',\n      digitalRelease: '数字发行',\n      physicalRelease: '实体发行',\n      originalLanguage: '原始语言',\n      productionCountries: '出品国家',\n      productionCompanies: '制作公司',\n      doubanId: '豆瓣ID',\n    },\n    subscribe: {\n      normal: '订阅',\n      bestVersion: '洗版订阅',\n      addFailed: '添加订阅失败：{reason}！',\n      canceled: '已取消订阅！',\n      cancelFailed: '取消订阅失败：{reason}！',\n    },\n    castAndCrew: '演员阵容',\n    recommendations: '推荐',\n    similar: '类似',\n    error: {\n      title: '出错啦！',\n      noMediaInfo: '未识别到媒体信息。',\n    },\n    server: {\n      plex: 'Plex',\n      jellyfin: 'Jellyfin',\n      emby: 'Emby',\n      appLaunchFailed: 'APP启动失败，正在跳转到网页版',\n      appNotInstalled: '未检测到APP，正在跳转到网页版',\n      downloadApp: '下载APP',\n    },\n  },\n  subscribe: {\n    normalSub: '订阅',\n    versionSub: '洗版订阅',\n    addSuccess: '添加{name}成功！',\n    addFailed: '添加{name}失败：{message}！',\n    cancelSuccess: '已取消订阅！',\n    cancelFailed: '取消订阅失败：{message}！',\n    filterSubscriptions: '筛选订阅',\n    name: '名称',\n    searchShares: '搜索订阅分享',\n    keyword: '关键词',\n    noShareData: '未获取到分享订阅数据，未开启数据分享或服务器无法连接。',\n    noPopularData: '未获取到热门订阅数据，未开启数据分享或服务器无法连接。',\n    noFilterData: '没有筛选到相关内容，请更换筛选条件。',\n    noSubscribeData: '请通过搜索添加电影、电视剧订阅。',\n    sharer: '分享人',\n    follow: '关注',\n    unfollow: '取消关注',\n    recognitionWords: '识别词',\n    cancelShare: '取消分享',\n    usageCount: '共 {count} 次复用',\n    confirmToggle: '是否{action}订阅 {name}？',\n    toggleSuccess: '{name} 已{action}！',\n    toggleFailed: '{action}失败：{message}',\n    resetConfirm: '重置后 {name} 将恢复初始状态，已下载记录将被清除，未入库的内容将会重新下载，是否确认？',\n    resetSuccess: '{name} 重置成功！',\n    resetFailed: '{name} 重置失败：{message}',\n    shareStatistics: '分享统计',\n    shareCount: '个分享',\n    totalReuseCount: '次复用',\n    ranking: '排名',\n    noStatisticsData: '暂无分享统计数据',\n    bestVersion: '洗版中',\n    completed: '订阅完成',\n    subscribing: '订阅中',\n    notStarted: '未开始',\n    pending: '待定',\n    paused: '暂停',\n    selectedCount: '已选择 {count}/{total} 项',\n    noSelectedItems: '请先选择要操作的订阅',\n    batchEnable: '批量启用',\n    batchPause: '批量暂停',\n    batchDelete: '批量删除',\n    batchEnableConfirm: '确定要启用选中的 {count} 个订阅吗？',\n    batchPauseConfirm: '确定要暂停选中的 {count} 个订阅吗？',\n    batchDeleteConfirm: '确定要删除选中的 {count} 个订阅吗？此操作不可恢复！',\n    batchEnableSuccess: '成功启用 {count} 个订阅',\n    batchPauseSuccess: '成功暂停 {count} 个订阅',\n    batchDeleteSuccess: '成功删除 {count} 个订阅',\n    batchEnableFailed: '启用失败 {count} 个订阅',\n    batchPauseFailed: '暂停失败 {count} 个订阅',\n    batchDeleteFailed: '删除失败 {count} 个订阅',\n    batchEnableError: '批量启用操作失败',\n    batchPauseError: '批量暂停操作失败',\n    batchDeleteError: '批量删除操作失败',\n    minSubscribers: '最小订阅人数',\n  },\n  recommend: {\n    all: '全部',\n    categoryMovie: '电影',\n    categoryTV: '电视剧',\n    categoryAnime: '动漫',\n    categoryRankings: '榜单',\n    trendingNow: '流行趋势',\n    nowShowing: '正在热映',\n    bangumiDaily: 'Bangumi每日放送',\n    tmdbHotMovies: 'TMDB热门电影',\n    tmdbHotTVShows: 'TMDB热门电视剧',\n    doubanHotMovies: '豆瓣热门电影',\n    doubanHotTVShows: '豆瓣热门电视剧',\n    doubanHotAnime: '豆瓣热门动漫',\n    doubanNewMovies: '豆瓣最新电影',\n    doubanNewTVShows: '豆瓣最新电视剧',\n    doubanTop250: '豆瓣电影TOP250',\n    doubanChineseTVRankings: '豆瓣国产剧集榜',\n    doubanGlobalTVRankings: '豆瓣全球剧集榜',\n    noCategoryContent: '当前分类下没有可显示的内容',\n    configureContent: '设置显示内容',\n    customizeContent: '自定义内容',\n    selectContentToDisplay: '选择您想在页面显示的内容',\n    selectAll: '全选',\n    selectNone: '全不选',\n  },\n  discover: {\n    setTabOrder: '设置标签顺序',\n    dragToReorder: '拖动对标签页进行排序',\n  },\n  downloading: {\n    noDownloader: '没有下载器',\n    configureDownloader: '请先在设置中正确配置并启用下载器。',\n    title: '下载',\n    noTask: '没有任务',\n    noTaskDescription: '正在下载的任务将会显示在这里。',\n  },\n  resource: {\n    searchResults: '资源搜索结果',\n    keyword: '关键词',\n    title: '标题',\n    year: '年份',\n    season: '季',\n    switchingView: '切换视图',\n    backToHome: '返回首页',\n    searching: '正在搜索，请稍候...',\n    noData: '没有数据',\n    noResourceFound: '未搜索到任何资源',\n    aiRecommend: '智能推荐',\n    reRecommend: '重新生成推荐',\n    aiRecommendError: '智能推荐失败',\n  },\n  browse: {\n    actor: '演员',\n  },\n  appcenter: {\n    others: '其他',\n  },\n  notFound: {\n    title: '⚠️ 页面不存在',\n    description: '您想要访问的页面不存在，请检查地址是否正确。',\n    backButton: '返回',\n  },\n  torrent: {\n    selectAll: '全选',\n    clear: '清除',\n    clearFilters: '清除筛选',\n    confirm: '确定',\n    resources: '个资源',\n    noResults: '没有找到匹配的资源',\n    sortDefault: '默认',\n    sortSite: '站点',\n    sortSize: '大小',\n    sortSeeder: '做种数',\n    sortPublishTime: '发布时间',\n    filterSite: '站点',\n    filterSeason: '季',\n    filterFreeState: '促销状态',\n    filterVideoCode: '视频编码',\n    filterEdition: '质量',\n    filterResolution: '分辨率',\n    filterReleaseGroup: '制作组',\n    noMatchingResults: '没有数据',\n    allFilters: '综合筛选',\n    clearAll: '清除全部',\n  },\n  calendar: {\n    episode: '第{number}集',\n  },\n  storage: {\n    name: '名称',\n    type: '类型',\n    customTypeHint: '自定义存储类型，用于插件等场景',\n    usedPercent: '已使用 {percent}%',\n    noConfigNeeded: '此存储类型无需配置参数，请直接配置目录！',\n    notConfigured: '未配置',\n    local: '本地',\n    alipan: '阿里云盘',\n    u115: '115网盘',\n    rclone: 'RClone',\n    alist: 'OpenList',\n    smb: 'SMB网络共享',\n    custom: '自定义',\n  },\n  filterRules: {\n    specSub: '特效字幕',\n    cnSub: '中文字幕',\n    cnVoi: '国语配音',\n    gz: '官种',\n    notCnVoi: '排除: 国语配音',\n    hkVoi: '粤语配音',\n    notHkVoi: '排除: 粤语配音',\n    free: '促销: 免费',\n    resolution4k: '分辨率: 4K',\n    resolution1080p: '分辨率: 1080P',\n    resolution720p: '分辨率: 720P',\n    not720p: '排除: 720P',\n    qualityBlu: '质量: 蓝光原盘',\n    notBlu: '排除: 蓝光原盘',\n    qualityBluray: '质量: BLURAY',\n    notBluray: '排除: BLURAY',\n    qualityUhd: '质量: UHD',\n    notUhd: '排除: UHD',\n    qualityRemux: '质量: REMUX',\n    notRemux: '排除: REMUX',\n    qualityWebdl: '质量: WEB-DL',\n    notWebdl: '排除: WEB-DL',\n    quality60fps: '质量: 60fps',\n    not60fps: '排除: 60fps',\n    codecH265: '编码: H265',\n    notH265: '排除: H265',\n    codecH264: '编码: H264',\n    notH264: '排除: H264',\n    effectDolby: '效果: 杜比视界',\n    notDolby: '排除: 杜比视界',\n    effectAtmos: '效果: 杜比全景声',\n    notAtmos: '排除: 杜比全景声',\n    effectHdr: '效果: HDR',\n    notHdr: '排除: HDR',\n    effectSdr: '效果: SDR',\n    notSdr: '排除: SDR',\n    effect3d: '效果: 3D',\n    not3d: '排除: 3D',\n  },\n  transferType: {\n    copy: '复制',\n    move: '移动',\n    link: '硬链接',\n    softlink: '软链接',\n  },\n  site: {\n    noSites: '没有站点',\n    sitesWillBeShownHere: '已添加并支持的站点将会在这里显示。',\n    noFilterData: '没有符合条件的站点',\n    title: '站点',\n    status: {\n      enabled: '启用',\n      disabled: '停用',\n    },\n    fields: {\n      url: '站点地址',\n      priority: '优先级',\n      status: '状态',\n      rss: 'RSS地址',\n      timeout: '超时时间（秒）',\n      downloader: '下载器',\n      cookie: '站点Cookie',\n      userAgent: '站点User-Agent',\n      authorization: '请求头（Authorization）',\n      apiKey: '令牌（API Key）',\n      limitAccess: '限制站点访问频率',\n      limitInterval: '单位周期（秒）',\n      limitCount: '周期内访问次数',\n      limitSeconds: '访问间隔（秒）',\n      useProxy: '使用代理访问',\n      browserSimulation: '浏览器仿真',\n      selectFile: '选择文件',\n    },\n    hints: {\n      url: '格式：http://www.example.com/',\n      priority: '优先级越小越优先',\n      status: '站点启用/停用',\n      rss: '订阅模式为`站点RSS`时使用的订阅链接，如未自动获取需手动补充',\n      timeout: '站点请求超时时间，为0时不限制',\n      downloader: '此站点使用的下载器',\n      cookie: '站点请求头中的Cookie信息',\n      userAgent: '获取Cookie的浏览器对应的User-Agent',\n      authorization: '站点请求头中的Authorization信息，特殊站点需要',\n      apiKey: '站点的访问API Key，特殊站点需要',\n      limitInterval: '限流控制的单位周期时长',\n      limitCount: '单位周期内允许的访问次数',\n      limitSeconds: '每次访问需要间隔的最小时间',\n      useProxy: '使用代理服务器访问该站点',\n      browserSimulation: '使用浏览器模拟真实访问该站点',\n      import: '批量导入站点数据，支持JSON格式文件',\n      selectFile: '选择JSON文件',\n      dragDropFile: '拖拽文件到此处或点击选择文件',\n      supportedFormat: '支持JSON格式的站点配置文件',\n    },\n    actions: {\n      add: '新增站点',\n      edit: '编辑站点',\n      import: '导入',\n      export: '导出',\n      startImport: '开始导入',\n    },\n    messages: {\n      addSuccess: '新增站点成功',\n      addFailed: '新增站点失败',\n      updateSuccess: '更新成功',\n      updateFailed: '更新失败',\n      exportSuccess: '站点导出成功',\n      exportFailed: '站点导出失败',\n      importSuccess: '成功导入 {count} 个站点',\n      importFailed: '站点导入失败',\n      importPartialFailed: '导入完成，成功 {success} 个，失败 {failed} 个',\n      importAllFailed: '导入失败，{count} 个站点全部导入失败',\n      noDataToImport: '没有数据可导入',\n      noValidData: '没有有效的数据',\n      someInvalidData: '部分数据无效，有效数据 {valid}/{total} 个',\n      invalidFileType: '不支持的文件类型，请选择JSON文件',\n      invalidFileFormat: '文件格式无效，请检查文件内容',\n      parseFileError: '文件解析失败，请检查文件格式',\n      previewData: '预览数据 ({count} 个站点)',\n      importing: '正在导入... ({progress}%)',\n      importErrors: '导入过程中出现 {count} 个错误',\n    },\n    errors: {\n      loadDownloader: '加载下载器设置失败',\n      title: '导入错误详情',\n      failed: '导入失败',\n      details: '错误详情',\n    },\n    results: {\n      successTitle: '成功导入的站点',\n      success: '导入成功',\n    },\n    testConnectivity: '测试连通性',\n    testing: '测试中 ...',\n    testSuccess: '{name} 连通性测试成功，可正常使用！',\n    testFailed: '{name} 连通性测试失败：{message}',\n    connectionNormal: '连接正常',\n    connectionSlow: '连接缓慢',\n    connectionFailed: '连接失败',\n    connectionUnknown: '连接未知',\n    deleteConfirm: '是否确认删除站点？',\n    deleteSuccess: '{name} 删除成功！',\n    deleteFailed: '{name} 删除失败：{message}',\n    browseResources: '浏览资源',\n    deleteSite: '删除站点',\n    updateCookie: '更新Cookie',\n    viewUserData: '查看用户数据',\n    statistics: '统计信息',\n    totalSites: '总站点数',\n    normalSites: '正常站点',\n    slowSites: '缓慢站点',\n    failedSites: '失败站点',\n    averageTime: '平均耗时',\n    successRate: '成功率',\n    successCount: '成功次数',\n    failCount: '失败次数',\n    lastAccess: '最后访问',\n    timeRecords: '耗时记录',\n    recentTimeRecords: '最近耗时记录',\n    accessTime: '访问时间',\n    responseTime: '响应时间',\n    noTimeRecords: '暂无耗时记录',\n    preview: {\n      title: '预览站点',\n      showing: '显示 {count}/{total}',\n      unnamed: '未命名站点',\n      noUrl: '无站点地址',\n      invalid: '数据无效',\n    },\n  },\n  message: {\n    loadMore: '加载更多',\n    noMoreData: '没有更多数据',\n  },\n  logging: {\n    level: '级别',\n    time: '时间',\n    program: '程序',\n    content: '内容',\n    refreshing: '正在刷新',\n    initializing: '正在初始化',\n  },\n  moduleTest: {\n    normal: '正常',\n    disabled: '未启用',\n    error: '错误',\n    checking: '正在检查...',\n    complete: '检查完成',\n    preparing: '准备检查...',\n    totalModules: '总模块数',\n    recheck: '重新检查',\n  },\n  nameTest: {\n    recognize: '识别',\n    recognizing: '识别中...',\n    recognizeAgain: '重新识别',\n    title: '标题',\n    subtitle: '副标题',\n  },\n  netTest: {\n    notTested: '未测试',\n    testing: '测试中...',\n    normal: '正常',\n  },\n  ruleTest: {\n    test: '测试',\n    testing: '正在测试...',\n    testAgain: '重新测试',\n    title: '标题',\n    subtitle: '副标题',\n    ruleGroup: '规则组',\n    priority: '优先级：{value}',\n    noPriorityRule: '未命中任何优先级规则！',\n  },\n  setting: {\n    about: {\n      title: '关于 MoviePilot',\n      softwareVersion: '软件版本',\n      frontendVersion: '前端版本',\n      browserVersion: '浏览器缓存版本',\n      authVersion: '认证资源版本',\n      indexerVersion: '站点资源版本',\n      configDir: '配置目录',\n      dataDir: '数据目录',\n      timezone: '时区',\n      latest: '最新',\n      supportingSites: '支持站点',\n      support: '支持',\n      documentation: '文档',\n      feedback: '问题反馈',\n      channel: '发布频道',\n      versions: '软件版本',\n      latestVersion: '最新软件版本',\n      currentVersion: '当前版本',\n      viewChangelog: '查看变更日志',\n      changelog: '变更日志',\n      dataDirectory: '/moviepilot',\n      expand: '展开',\n      collapse: '收起',\n      clearCache: '清除缓存',\n    },\n    system: {\n      custom: '自定义',\n      basicSettings: '基础设置',\n      basicSettingsDesc: '设置服务器的全局功能',\n      appDomain: '访问域名',\n      appDomainHint: '用于发送通知时，添加快捷跳转地址',\n      wallpaper: '背景壁纸',\n      wallpaperHint: '选择登陆页面背景来源',\n      recognizeSource: '识别数据源',\n      recognizeSourceHint: '设置默认媒体信息识别数据源',\n      mediaServerSyncInterval: '媒体服务器同步间隔',\n      mediaServerSyncIntervalHint: '定时同步媒体服务器数据到本地的时间间隔',\n      hours: '小时',\n      required: '必选项，请勿留空',\n      numbersOnly: '仅支持输入数字，请勿输入其他字符',\n      minInterval: '间隔不能小于1个小时',\n      apiToken: 'API令牌',\n      apiTokenHint: '设置外部请求MoviePilot API时使用的token值',\n      apiTokenMinChars: '不能小于16位字符',\n      apiTokenRequired: '必填项；请输入API Token',\n      apiTokenLength: 'API Token不得低于16位',\n      githubToken: 'Github Token',\n      githubTokenFormat: 'ghp_**** 或 github_pat_****',\n      githubTokenHint: '用于提高插件等访问Github API时的限流阈值，建议配置，否则插件可能无法正常使用',\n      ocrHost: '验证码识别服务器',\n      ocrHostHint: '用于站点签到、更新站点Cookie等识别验证码',\n      aiAgent: '启用智能助手',\n      aiAgentEnable: '启用智能助手',\n      aiAgentEnableHint: '启用后可使用智能助手功能，需要配置LLM相关参数',\n      aiAgentSectionTitle: '智能助手配置',\n      aiAgentSectionDesc: '启用后可在消息会话中使用 Agent 能力，也可开启失败整理接管和智能推荐。',\n      llmProvider: 'LLM提供商',\n      llmProviderHint: '选择使用的LLM服务提供商',\n      llmModel: 'LLM模型名称',\n      llmModelHint: '指定使用的LLM模型，如gpt-3.5-turbo、deepseek-chat等',\n      llmThinking: '思考模式 / 深度',\n      llmThinkingHint:\n        '思考深度：off/auto/minimal/low/medium/high/max/xhigh；不支持的级别会按 provider 能力自动映射到最近值',\n      llmThinkingLevelOff: '关闭 (off)',\n      llmThinkingLevelAuto: '自动 (auto)',\n      llmThinkingLevelMinimal: '最小 (minimal)',\n      llmThinkingLevelLow: '低 (low)',\n      llmThinkingLevelMedium: '中 (medium)',\n      llmThinkingLevelHigh: '高 (high)',\n      llmThinkingLevelMax: '极高 (max)',\n      llmThinkingLevelXhigh: '超高 (xhigh)',\n      llmSupportImageInput: '模型支持图片输入',\n      llmSupportImageInputHint:\n        '启用后，消息中的图片会按多模态图片发送给 LLM；关闭后图片会作为附件保存到本地，并将文件路径提供给智能助手处理',\n      llmMaxContextTokens: 'LLM 最大上下文 Token 数量 (K)',\n      llmMaxContextTokensHint:\n        '设定 LLM 记录会话历史的最大 Token 数量上限（千），超出后将自动修整历史记录以节省 Token 消耗及防止超出 LLM 限制',\n      llmApiKey: 'LLM API密钥',\n      llmApiKeyHint: 'LLM服务提供商的API密钥，用于身份验证',\n      llmApiKeyPlaceholder: '请输入API密钥',\n      llmBaseUrl: 'LLM基础URL',\n      llmBaseUrlHint: 'LLM API的基础URL地址，用于自定义API端点',\n      llmTestAction: '测试调用',\n      llmTestSuccessToast: 'LLM 调用测试成功',\n      llmTestFailedToast: 'LLM 调用测试失败',\n      llmTestFailedToastWithMessage: 'LLM 调用测试失败：{message}',\n      aiAgentGlobal: '全局智能助手',\n      aiAgentGlobalHint: '启用全局智能助手功能，所有消息对话均使用智能体回答而不用使用/ai命令',\n      aiAgentJobInterval: '定时唤醒',\n      aiAgentJobIntervalHint: '设置定时唤醒的检查间隔，选择\"不启用\"则不执行定时任务',\n      aiAgentVerbose: '啰嗦模式',\n      aiAgentVerboseHint: '开启后会在智能体回复时显示工具调用过程',\n      aiAgentJobIntervalDisabled: '不启用',\n      aiAgentJobInterval1h: '1小时',\n      aiAgentJobInterval3h: '3小时',\n      aiAgentJobInterval6h: '6小时',\n      aiAgentJobInterval12h: '12小时',\n      aiAgentJobInterval24h: '24小时',\n      aiAgentJobInterval1w: '1周',\n      aiAgentJobInterval1M: '1个月',\n      advancedSettings: '高级设置',\n      advancedSettingsDesc: '系统进阶设置，特殊情况下才需要调整',\n      downloaders: '下载器',\n      downloadersDesc: '只有默认下载器才会被默认使用。',\n      aiAgentRetryTransfer: '文件整理失败智能接管',\n      aiAgentRetryTransferHint:\n        '启用后，当文件整理失败时，智能助手将自动接管并尝试重新整理，利用AI能力解决识别和匹配问题',\n      aiRecommendEnabled: '搜索结果智能推荐',\n      aiRecommendEnabledHint:\n        '启用搜索结果智能推荐功能，开启后将在搜索结果页面显示智能推荐按钮，可根据用户偏好智能推荐资源',\n      aiRecommendUserPreference: '用户偏好',\n      aiRecommendUserPreferenceHint: '设置智能推荐时的用户偏好，例如：4K WEB-DL Dolby Vision',\n      aiRecommendMaxItems: '智能推荐分析条目上限',\n      aiRecommendMaxItemsHint:\n        '限制发送给智能助手进行分析的搜索结果数量，数量越多分析越慢且消耗 Token 越多，建议先手动筛选，筛选出大致范围后再进行智能推荐',\n      mediaServers: '媒体服务器',\n      mediaServersDesc: '所有启用的媒体服务器都会被使用。',\n      trimeMedia: '飞牛影视',\n      system: '系统',\n      media: '媒体',\n      network: '网络',\n      log: '日志',\n      lab: '实验室',\n      downloaderSaveSuccess: '下载器设置保存成功',\n      downloaderSaveFailed: '下载器设置保存失败！',\n      defaultDownloaderNotice: '未设置默认下载器，已将【{name}】作为默认下载器',\n      mediaServerSaveSuccess: '媒体服务器设置保存成功',\n      mediaServerSaveFailed: '媒体服务器设置保存失败！',\n      saveFailed: '设置保存失败：{message}！',\n      basicSaveSuccess: '基础设置保存成功',\n      advancedSaveSuccess: '高级设置保存成功',\n      copySuccess: '已复制到剪贴板！',\n      copyFailed: '复制失败：可能是浏览器不支持或被用户阻止！',\n      copyError: '复制失败！',\n      reloading: '正在应用配置...',\n      qbittorrent: 'Qbittorrent',\n      transmission: 'Transmission',\n      rtorrent: 'rTorrent',\n      emby: 'Emby',\n      jellyfin: 'Jellyfin',\n      plex: 'Plex',\n      ugreen: '绿联影视',\n      reloadSuccess: '系统配置已生效',\n      reloadFailed: '重载系统失败！',\n      auxAuthEnable: '用户辅助认证',\n      auxAuthEnableHint: '允许外部服务进行登录认证以及自动创建用户',\n      globalImageCache: '全局图片缓存',\n      globalImageCacheHint: '将媒体图片缓存到本地，提升图片加载速度',\n      subscribeStatisticShare: '分享订阅数据',\n      subscribeStatisticShareHint: '分享订阅统计数据到热门订阅，供其他MPer参考',\n      pluginStatisticShare: '上报插件安装数据',\n      pluginStatisticShareHint: '上报插件安装数据给服务器，用于统计展示插件安装情况',\n      workflowStatisticShare: '分享工作流数据',\n      workflowStatisticShareHint: '分享工作流统计数据到热门工作流，供其他MPer参考',\n      bigMemoryMode: '大内存模式',\n      bigMemoryModeHint: '使用更大的内存缓存数据，提升系统性能',\n      dbWalEnable: '数据库WAL模式',\n      dbWalEnableHint: '可提升读写并发性能，但可能在异常情况下增加数据丢失风险，更改后需重启生效',\n      tmdbApiDomain: 'TMDB API服务地址',\n      tmdbApiDomainPlaceholder: 'api.themoviedb.org',\n      tmdbApiDomainHint: '自定义themoviedb API域名或代理地址',\n      tmdbApiDomainRequired: '请输入TMDB API域名',\n      tmdbImageDomain: 'TMDB 图片服务地址',\n      tmdbImageDomainPlaceholder: 'image.tmdb.org',\n      tmdbImageDomainHint: '自定义themoviedb图片服务域名或代理地址',\n      tmdbImageDomainRequired: '请输入图片服务域名',\n      tmdbLocale: 'TMDB 元数据语言',\n      tmdbLocalePlaceholder: 'zh',\n      tmdbLocaleHint: '自定义themoviedb元数据语言',\n      metaCacheExpire: '媒体元数据缓存过期时间',\n      metaCacheExpireHint: '识别元数据本地缓存时间，为 0 时使用内置默认值',\n      metaCacheExpireRequired: '请输入元数据缓存时间',\n      metaCacheExpireMin: '元数据缓存时间必须大于等于0',\n      scrapFollowTmdb: '跟随TMDB识别整理',\n      scrapFollowTmdbHint: '关闭时以整理历史记录为准（如有），避免TMDB数据在订阅中途修改',\n      scrapOriginalImage: 'TMDB 刮削原语种图片',\n      scrapOriginalImageHint: '刮削原语种图片，否则刮削元数据语种图片',\n      fanartEnable: 'Fanart图片数据源',\n      fanartEnableHint: '使用 fanart.tv 的图片数据',\n      fanartLang: 'Fanart语言',\n      fanartLangHint: '设置Fanart图片的语言偏好，多选时按优先级顺序排列',\n      recognizePluginFirst: \"优先使用插件识别\",\n      recognizePluginFirstHint: \"优先调用插件识别媒体信息，若插件命中则不再调用原生识别\",\n      githubProxy: 'Github加速代理',\n      githubProxyPlaceholder: '留空表示不使用代理',\n      githubProxyHint: '使用代理加速Github访问速度',\n      pipProxy: 'PIP加速代理',\n      pipProxyPlaceholder: '留空表示不使用代理',\n      pipProxyHint: '使用代理加速插件等pip库安装速度',\n      dohEnable: 'DNS Over HTTPS',\n      dohEnableHint: '使用DOH对特定域名进行解析，以防止DNS污染',\n      dohResolvers: 'DOH 服务器',\n      dohResolversPlaceholder: 'https://dns.google/dns-query,1.1.1.1',\n      dohResolversHint: 'DNS解析服务器地址，多个地址使用逗号分隔',\n      dohDomains: 'DOH 域名',\n      dohDomainsPlaceholder: 'example.com,example2.com',\n      dohDomainsHint: '使用DOH解析的域名，多个域名使用逗号分隔',\n      debug: '调试模式',\n      debugHint: '启用调试模式后，日志将以DEBUG级别记录，以便排查问题',\n      logLevel: '日志等级',\n      logLevelHint: '设置日志记录的级别，用于控制日志输出量',\n      logMaxFileSize: '日志文件最大容量(MB)',\n      logMaxFileSizeHint: '限制单个日志文件的最大容量，超出后将自动分割日志',\n      logMaxFileSizeRequired: '日志文件最大大小',\n      logMaxFileSizeMin: '日志文件最大容量必须大于等于1',\n      logBackupCount: '日志文件最大备份数量',\n      logBackupCountHint: '设置每个模块日志文件的最大备份数量，超过后将覆盖旧日志',\n      logBackupCountRequired: '请输入日志文件最大备份数量',\n      logBackupCountMin: '日志文件最大备份数量必须大于等于1',\n      logFileFormat: '日志文件格式',\n      logFileFormatHint: '设置日志文件的输出格式，用于自定义日志的显示内容',\n      pluginAutoReload: '插件热加载',\n      pluginAutoReloadHint: '修改插件文件后自动重新加载，开发插件时使用',\n      pluginLocalRepoPaths: '本地插件仓库路径',\n      pluginLocalRepoPathsHint: '本地插件仓库目录，多个目录用英文逗号分隔，支持相对路径和绝对路径',\n      encodingDetectionPerformanceMode: '编码探测性能模式',\n      encodingDetectionPerformanceModeHint: '优先提升探测效率，但可能降低编码探测的准确性',\n      transferThreads: '文件整理线程数',\n      transferThreadsHint: '多线程整理文件可以提高速度，但可能增加系统资源占用',\n      tokenizedSearch: '分词搜索',\n      tokenizedSearchHint: '提升整理历史记录搜索精度，但可能增加性能开销和意外结果',\n      tmdbLanguage: {\n        zhCN: '简体中文',\n        zhTW: '繁体中文',\n        en: '英文',\n      },\n      fanartLanguage: {\n        zh: '中文',\n        en: '英文',\n        ja: '日文',\n        ko: '韩文',\n        de: '德文',\n        fr: '法文',\n        es: '西班牙文',\n        it: '意大利文',\n        pt: '葡萄牙文',\n        ru: '俄文',\n      },\n      logLevelItems: {\n        debug: 'DEBUG - 调试',\n        info: 'INFO - 信息',\n        warning: 'WARNING - 警告',\n        error: 'ERROR - 错误',\n        critical: 'CRITICAL - 严重',\n      },\n      wallpaperItems: {\n        tmdb: 'TMDB电影海报',\n        bing: 'Bing每日壁纸',\n        mediaserver: '媒体服务器',\n        none: '无壁纸',\n        customize: '自定义',\n      },\n      mb: 'MB',\n      hour: '小时',\n      customizeWallpaperApi: '自定义壁纸API地址',\n      customizeWallpaperApiHint: '会获取API返回内容中所有允许的安全域名地址的图片，需要同步设置安全域名地址',\n      customizeWallpaperApiRequired: '必填项；请输入自定义壁纸API',\n      securityImageDomains: '安全图片域名',\n      securityImageDomainsHint: '允许缓存的图片域名白名单，用于控制可信任的图片来源',\n      noSecurityImageDomains: '暂无安全域名',\n      securityImageDomainAdd: '添加域名，如：image.tmdb.org',\n      proxyHost: '代理服务器',\n      proxyHostHint: '设置代理服务器地址，支持：http(s)、socks5、socks5h 等协议',\n      moviePilotAutoUpdate: '自动更新MoviePilot',\n      moviePilotAutoUpdateHint: '重启时自动更新MoviePilot到最新发行版本',\n      autoUpdateResource: '自动更新站点资源',\n      autoUpdateResourceHint: '重启时自动检测和更新站点资源包',\n      // 刮削开关设置\n      scrapingSwitchSettings: '刮削开关设置',\n      scrapingSwitchSettingsDesc: '控制各类媒体文件的刮削功能开关',\n      movie: '电影',\n      tv: '电视剧',\n      season: '季',\n      episode: '集',\n      movieNfo: 'NFO',\n      moviePoster: '海报',\n      movieBackdrop: '背景图',\n      movieLogo: 'Logo',\n      movieDisc: '光盘图',\n      movieBanner: '横幅图',\n      movieThumb: '缩略图',\n      tvNfo: 'NFO',\n      seasonNfo: 'NFO',\n      tvPoster: '海报',\n      tvBackdrop: '背景图',\n      tvBanner: '横幅图',\n      tvLogo: 'Logo',\n      tvThumb: '缩略图',\n      seasonPoster: '海报',\n      seasonBanner: '横幅图',\n      seasonThumb: '缩略图',\n      episodeNfo: 'NFO',\n      episodeThumb: '缩略图',\n      scrapingSwitchSaveFailed: '刮削开关设置保存失败：{message}',\n      scrapingSwitchSaveError: '刮削开关设置保存失败',\n      policy: {\n        skipDesc: '跳过刮削，不生成该文件',\n        missingOnlyDesc: '仅在缺失时刮削，已存在则保持不变',\n        overwriteDesc: '始终刮削，已存在则覆盖',\n      }\n    },\n    site: {\n      siteSync: '站点同步',\n      siteSyncDesc: '从CookieCloud快速同步站点数据',\n      enableLocalCookieCloud: '启用本地CookieCloud服务器',\n      enableLocalCookieCloudHint: '使用内建CookieCloud服务同步站点数据，服务地址为：http://localhost:3000/cookiecloud',\n      serviceAddress: '服务地址',\n      serviceAddressPlaceholder: 'https://movie-pilot.org/cookiecloud',\n      serviceAddressHint: '远端CookieCloud服务地址，格式：https://movie-pilot.org/cookiecloud',\n      userKey: '用户KEY',\n      userKeyHint: 'CookieCloud浏览器插件生成的用户KEY',\n      e2ePassword: '端对端加密密码',\n      e2ePasswordHint: 'CookieCloud浏览器插件生成的端对端加密密码',\n      autoSyncInterval: '自动同步间隔',\n      autoSyncIntervalHint: '从CookieCloud服务器自动同步站点Cookie到MoviePilot的时间间隔',\n      syncBlacklist: '同步域名黑名单',\n      syncBlacklistPlaceholder: '多个域名,分割',\n      syncBlacklistHint: 'CookieCloud同步域名黑名单，多个域名,分割',\n      userAgent: '浏览器User-Agent',\n      userAgentHint: 'CookieCloud插件所在的浏览器的User-Agent',\n      siteDataRefresh: '站点数据刷新',\n      siteOptions: '站点选项',\n      browserEmulation: '浏览器仿真',\n      browserEmulationHint: '站点访问仿真方式，支持 Playwright 或 FlareSolverr',\n      flaresolverrUrl: 'FlareSolverr 服务地址',\n      flaresolverrUrlHint: '当仿真方式为 FlareSolverr 时生效，例如：http://127.0.0.1:8191',\n      siteDataRefreshInterval: '站点数据刷新间隔',\n      siteDataRefreshIntervalHint: '刷新站点用户上传下载等数据的时间间隔',\n      readSiteMessage: '阅读站点消息',\n      readSiteMessageHint: '刷新数据时读取站点消息并发送通知',\n      siteReset: '站点重置',\n      confirmReset: '确认删除所有站点数据并重新同步。',\n      confirmResetHint: '删除所有站点数据并重新从CookieCloud同步，操作请先清空涉及站点的相关设置。',\n      resetSites: '重置站点数据',\n      resettingSites: '正在重置...',\n      syncInterval: {\n        hourly: '每小时',\n        every6Hours: '每6小时',\n        every12Hours: '每12小时',\n        daily: '每天',\n        weekly: '每周',\n        monthly: '每月',\n        never: '永不',\n      },\n      saveSuccess: '保存站点设置成功',\n      saveFailed: '站点设置保存失败！',\n      resetSuccess: '站点重置成功，请等待CookieCloud同步完成！',\n      resetFailed: '站点重置失败！',\n    },\n    notification: {\n      channels: '通知渠道',\n      channelsDesc: '设置消息发送渠道参数',\n      organizeSuccess: '资源入库',\n      downloadAdded: '资源下载',\n      subscribeAdded: '添加订阅',\n      subscribeComplete: '订阅完成',\n      templateConfigTitle: '通知模板',\n      templateConfigDesc: '设置通知模板，支持Jinja2语法。',\n      templateSaveFailed: '模板保存失败！',\n      templateSaveSuccess: '模板保存成功',\n      templateLoadFailed: '模板加载失败！',\n      scope: '通知发送范围',\n      scopeDesc: '对应消息类型只会发送给设定的用户。',\n      messageType: '消息类型',\n      scopeRange: '范围',\n      operationUserOnly: '仅操作用户',\n      adminOnly: '仅管理员',\n      userAndAdmin: '操作用户和管理员',\n      allUsers: '所有用户',\n      sendTime: '通知发送时间',\n      sendTimeDesc: '设定消息发送的时间范围。',\n      startTime: '开始时间',\n      endTime: '结束时间',\n      saveSuccess: '通知设置保存成功',\n      saveFailed: '通知设置保存失败！',\n      switchSaveSuccess: '消息类型开关保存成功',\n      switchSaveFailed: '消息类型开关保存失败！',\n      timeSaveSuccess: '通知发送时间保存成功',\n      timeSaveFailed: '通知发送时间保存失败！',\n      channel: '通知',\n      wechat: '微信',\n      resourceDownload: '资源下载',\n      mediaImport: '整理入库',\n      subscription: '订阅',\n      site: '站点',\n      mediaServer: '媒体服务器',\n      manualProcess: '手动处理',\n      plugin: '插件',\n      other: '其它',\n      telegram: 'Telegram',\n      slack: 'Slack',\n      synologyChat: 'SynologyChat',\n      voceChat: 'VoceChat',\n      webPush: 'WebPush',\n      qq: 'QQ',\n      custom: '自定义通知',\n    },\n    words: {\n      customIdentifiers: '自定义识别词',\n      identifiersDesc: '添加规则对种子名或者文件名进行预处理以校正识别',\n      identifiersPlaceholder: '支持正则表达式，特殊字符需要\\\\转义，一行为一组',\n      identifiersHint: '支持正则表达式，特殊字符需要\\\\转义，一行为一组',\n      formatTitle: '支持的配置格式（注意空格）：',\n      formatContent:\n        '屏蔽词\\n' +\n        '被替换词 => 替换词\\n' +\n        '前定位词 <> 后定位词 >> 集偏移量（EP）\\n' +\n        '被替换词 => 替换词 && 前定位词 <> 后定位词 >> 集偏移量（EP）\\n' +\n        '其中替换词支持格式：&#123;[tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx]&#125; 直接指定TMDBID/豆瓣ID识别，其中s、e为季数和集数（可选）',\n      identifierSaveSuccess: '自定义识别词保存成功',\n      identifierSaveFailed: '自定义识别词保存失败！',\n\n      customReleaseGroups: '自定义制作组/字幕组',\n      releaseGroupsDesc: '添加无法识别的制作组/字幕组。',\n      releaseGroupsPlaceholder: '支持正则表达式，特殊字符需要\\\\转义，一行代表一个制作组/字幕组',\n      releaseGroupsHint: '支持正则表达式，特殊字符需要\\\\转义，一行代表一个制作组/字幕组',\n      releaseGroupSaveSuccess: '自定义制作组/字幕组保存成功',\n      releaseGroupSaveFailed: '自定义制作组/字幕组保存失败！',\n\n      customization: '自定义占位符',\n      customizationDesc: '添加自定义占位符识别正则，重命名格式中添加{customization}使用。',\n      customizationPlaceholder: '支持正则表达式，特殊字符需要\\\\转义，多个匹配对象请换行分隔',\n      customizationHint: '支持正则表达式，特殊字符需要\\\\转义，多个匹配对象请换行分隔',\n      customizationSaveSuccess: '自定义占位符保存成功',\n      customizationSaveFailed: '自定义占位符保存失败！',\n\n      transferExcludeWords: '文件整理屏蔽词',\n      excludeWordsDesc: '目录名或文件名中包含屏蔽词时不进行整理。',\n      excludeWordsPlaceholder: '支持正则表达式，特殊字符需要\\\\转义，一行代表一个屏蔽词',\n      excludeWordsHint: '支持正则表达式，特殊字符需要\\\\转义，一行代表一个屏蔽词',\n      excludeWordsSaveSuccess: '文件整理屏蔽词保存成功',\n      excludeWordsSaveFailed: '文件整理屏蔽词保存失败！',\n    },\n    search: {\n      basicSettings: '基础设置',\n      basicSettingsDesc: '设定数据源、规则组等基础信息',\n      recognizeSource: '识别数据源',\n      recognizeSourceDesc: '默认使用TMDB。豆瓣识别中文作品通常更友好，但有些国外作品信息不完整。',\n      themoviedb: 'TheMovieDb',\n      douban: '豆瓣',\n      filterRuleGroup: '过滤规则组',\n      filterRuleGroupDesc: '设置下载过程中使用的过滤规则组。',\n      downloadLabel: '下载任务标签',\n      downloadLabelDesc: '下载器中的下载标签，用于过滤查询。',\n      downloadLabelHint: '支持增加多个标签，英文逗号分隔',\n      downloadSite: '搜索站点',\n      downloadSiteDesc: '设置指定分类搜索的站点范围。',\n      movieSites: '电影站点',\n      tvSites: '电视剧站点',\n      animeSites: '动漫站点',\n      saveSites: '保存站点',\n      saveSuccess: '保存搜索设置成功',\n      saveFailed: '搜索设置保存失败！',\n      saveRuleFailed: '规则保存失败！',\n      movieCategory: '电影',\n      tvCategory: '电视剧',\n      animeCategory: '动漫',\n      downloadUser: '远程搜索自动下载用户',\n      downloadUserHint: '使用Telegram、微信等搜索时是否自动下载，使用逗号分割，设置为 all 代表所有用户自动择优下载',\n      multipleNameSearch: '多名称资源搜索',\n      multipleNameSearchHint: '使用多个名称（中文、英文等）搜索站点资源并合并搜索结果，会增加站点访问频率',\n      downloadSubtitle: '下载站点字幕',\n      downloadSubtitleHint: '检查站点资源是否有单独的字幕文件并自动下载',\n      mediaSource: '媒体搜索数据源',\n      mediaSourceHint: '搜索媒体信息时使用的数据源以及排序',\n      filterRuleGroupHint: '搜索媒体信息时按选定的过滤规则组对结果进行过滤',\n      downloadUserPlaceholder: '用户ID1,用户ID2',\n      downloadLabelPlaceholder: 'MOVIEPILOT',\n    },\n    directory: {\n      storage: '存储',\n      storageDesc: '设置本地或网盘存储',\n      directory: '目录',\n      mediaType: '媒体类型',\n      directoryDesc: '设置媒体文件整理目录结构，按先后顺序依次匹配。',\n      organizeAndScrap: '整理 & 刮削',\n      organizeAndScrapDesc: '设置重命名格式、刮削选项等。',\n      scrapSource: '刮削数据源',\n      scrapSourceHint: '刮削时的元数据来源',\n      movieRenameFormat: '电影重命名格式',\n      movieRenameFormatHint: '使用Jinja2语法，格式参考：https://jinja.palletsprojects.com/en/3.0.x/templates',\n      tvRenameFormat: '电视剧重命名格式',\n      tvRenameFormatHint: '使用Jinja2语法，格式参考：https://jinja.palletsprojects.com/en/3.0.x/templates',\n      saveSuccess: '存储设置保存成功',\n      saveFailed: '存储设置保存失败！',\n      directorySaveSuccess: '目录设置保存成功',\n      directorySaveFailed: '目录设置保存失败！',\n      organizeSaveSuccess: '整理选项设置保存成功',\n      organizeSaveFailed: '整理选项设置保存失败！',\n      duplicateDirectoryName: '存在重复目录名称！无法保存，请修改！',\n      defaultDirName: '目录',\n      storageSaveSuccess: '存储设置保存成功',\n      storageSaveFailed: '存储设置保存失败！',\n    },\n    category: {\n      title: '分类策略',\n      subtitle: '配置媒体自动分类规则,按类型、语言、地区等条件自动归类',\n      movie: '电影 (Movie)',\n      tv: '电视剧 (TV)',\n      name: '分类名称 (目录名)',\n      genre: '内容类型 (Genre)',\n      language: '语种 (Language)',\n      languagePlaceholder: '如: zh,cn,en (使用逗号分隔)',\n      country: '国家/地区 (Country)',\n      countryPlaceholder: '如: US,CN,JP',\n      year: '年份 (Year)',\n      yearPlaceholder: '如: 2023, 2020-2024',\n      addMovie: '添加电影分类',\n      addTv: '添加电视剧分类',\n      saveSuccess: '分类策略保存成功',\n      loadFailed: '加载分类配置失败',\n      saveFailed: '保存失败: {message}',\n    },\n    rule: {\n      customRules: '自定义规则',\n      customRulesDesc: '自定义优先级规则项',\n      priorityRuleGroups: '优先级规则组',\n      priorityRuleGroupsDesc: '预设优先级规则组，以便在搜索和订阅中使用。',\n      downloadRules: '下载规则',\n      downloadRulesDesc: '同时命中多个资源时择优下载。',\n      resourcePriority: '资源优先级',\n      sitePriority: '站点优先级',\n      siteUpload: '站点上传量',\n      resourceSeeder: '资源做种数',\n      emptyIdError: '存在空ID的规则，无法保存，请修改！',\n      emptyNameError: '存在空名字的规则，无法保存，请修改！',\n      duplicateIdError: '存在重复规则ID！无法保存，请修改！',\n      duplicateNameError: '存在重复规则名称！无法保存，请修改！',\n      customRuleSaveSuccess: '自定义规则保存成功',\n      customRuleSaveFailed: '自定义规则保存失败！',\n      emptyGroupNameError: '存在空名字的规则组！无法保存，请修改！',\n      duplicateGroupNameError: '存在重复规则组名称！无法保存，请修改！',\n      ruleGroupSaveSuccess: '优先级规则组保存成功',\n      ruleGroupSaveFailed: '优先级规则组保存失败！',\n      customRuleCopySuccess: '自定义规则已复制到剪贴板！',\n      customRuleCopyFailed: '自定义规则复制失败：可能是浏览器不支持或被用户阻止！',\n      customRuleCopyError: '自定义规则复制失败！',\n      ruleGroupCopySuccess: '优先级规则组已复制到剪贴板！',\n      ruleGroupCopyFailed: '优先级规则组复制失败：可能是浏览器不支持或被用户阻止！',\n      ruleGroupCopyError: '优先级规则组复制失败！',\n      currentPriorityRules: '当前使用下载优先规则',\n      currentPriorityRulesHint: '排在前面的优先级越高，未选择的项不纳入排序',\n      importCustomRules: '导入自定义规则',\n      importRuleGroups: '导入优先级规则组',\n      importFailed: '导入规则失败！无法解析输入的数据！',\n      importUnknownType: '导入规则失败！未知的数据类型！',\n      duplicateValue: '存在重名值',\n      importNoId: '导入失败！发现有规则不存在ID，可能属于优先级规则组！',\n      importHasId: '导入失败！发现有规则存在相同ID，可能属于自定义规则！',\n    },\n    scheduler: {\n      title: '定时作业',\n      subtitle: '包含系统内置服务以及插件提供的服务',\n      provider: '提供者',\n      taskName: '任务名称',\n      taskStatus: '任务状态',\n      nextRunTime: '下一次执行时间',\n      execute: '执行',\n      noService: '没有后台服务',\n      running: '正在运行',\n      stopped: '已停止',\n      waiting: '等待',\n      executeSuccess: '定时作业执行请求提交成功！',\n    },\n    subscribe: {\n      basicSettings: '基础设置',\n      basicSettingsDesc: '设定订阅模式、周期等基础设置',\n      subscribeSites: '订阅站点',\n      subscribeSitesDesc: '只有选中的站点才会在订阅中使用。',\n      mode: '订阅模式',\n      modeHint: '自动：自动爬取站点首页，站点RSS：通过站点RSS链接订阅',\n      rssInterval: '站点RSS周期',\n      rssIntervalHint: '设置站点RSS运行周期，在订阅模式为`站点RSS`时生效',\n      filterRuleGroup: '订阅优先级规则组',\n      filterRuleGroupHint: '按选定的过滤规则组对订阅进行过滤',\n      bestVersionRuleGroup: '洗版优先级规则组',\n      bestVersionRuleGroupHint: '按选定的过滤规则组对洗版订阅进行过滤',\n      timedSearch: '订阅定时搜索',\n      timedSearchHint: '每隔指定时间全站搜索，以补全订阅可能漏掉的资源',\n      searchInterval: '订阅搜索时间间隔',\n      searchIntervalHint: '设置订阅搜索的时间间隔，仅在开启订阅定时搜索时生效',\n      checkLocalMedia: '检查文件系统资源',\n      checkLocalMediaHint: '扫描存储目录中是否已存在相应资源文件，以避免重复下载；不管是否开启都会检查媒体服务器',\n      modes: {\n        auto: '自动',\n        rss: '站点RSS',\n      },\n      intervals: {\n        min5: '5分钟',\n        min10: '10分钟',\n        min20: '20分钟',\n        min30: '半小时',\n        hour1: '1小时',\n        hour12: '12小时',\n        day1: '1天',\n        day3: '3天',\n        week1: '一周',\n      },\n      saveSuccess: '订阅站点保存成功',\n      saveFailed: '订阅站点保存失败！',\n      settingsSaveSuccess: '订阅基础设置保存成功',\n      settingsSaveFailed: '订阅基础设置保存失败！',\n    },\n    cache: {\n      title: '缓存管理',\n      subtitle: '管理缓存的站点资源',\n      totalCount: '总条数',\n      siteCount: '站点数',\n      filterByTitle: '按标题筛选',\n      filterBySite: '按站点筛选',\n      selectSite: '选择站点',\n      refresh: '刷新缓存',\n      deleteSelected: '删除选中',\n      clearAll: '清空缓存',\n      refreshSuccess: '缓存刷新完成',\n      refreshFailed: '刷新缓存失败',\n      clearSuccess: '缓存清理完成',\n      clearFailed: '清理缓存失败',\n      deleteSuccess: '缓存项删除成功',\n      deleteFailed: '删除缓存项失败',\n      deleteSelectedSuccess: '成功删除 {count} 个缓存项',\n      deleteSelectedFailed: '删除缓存项失败',\n      loadFailed: '加载缓存数据失败',\n      selectDeleteWarning: '请选择要删除的缓存项',\n      reidentify: '重新识别',\n      reidentifySuccess: '重新识别完成',\n      reidentifyFailed: '重新识别失败',\n      poster: '海报',\n      torrentTitle: '标题',\n      site: '站点',\n      size: '大小',\n      publishTime: '发布时间',\n      recognitionResult: '识别结果',\n      actions: '操作',\n      unrecognized: '未识别',\n      noData: '暂无缓存数据',\n      noDataHint: '点击\"刷新缓存\"按钮获取最新的种子缓存',\n      reidentifyDialog: {\n        title: '重新识别',\n        torrentInfo: '种子信息',\n        tmdbId: 'TMDB ID',\n        tmdbIdHint: '可选，手动指定TMDB ID进行识别',\n        doubanId: '豆瓣 ID',\n        doubanIdHint: '可选，手动指定豆瓣ID进行识别',\n        autoHint: '如果不指定ID，将自动重新识别该种子',\n        cancel: '取消',\n        confirm: '重新识别',\n      },\n      mediaType: {\n        movie: '电影',\n        tv: '电视剧',\n      },\n      clearConfirm: '确认清空所有缓存吗？',\n    },\n  },\n  dialog: {\n    progress: {\n      processing: '处理中',\n    },\n    subscribeSeason: {\n      title: '订阅 - {title}',\n      selectGroup: '选择剧集组',\n      defaultGroup: '默认',\n      seasonCount: '{count} 季',\n      episodeCount: '{count} 集',\n      seasonNumber: '第 {number} 季',\n      airDate: '首播于 {date}',\n      voteAverage: '{score}',\n      status: {\n        exists: '已入库',\n        partial: '部分缺失',\n        missing: '缺失',\n      },\n      submit: '提交订阅',\n      selectSeasons: '请选择订阅季',\n    },\n    userAddEdit: {\n      add: '添加用户',\n      edit: '编辑用户',\n      username: '用户名',\n      usernameRequired: '用户名不能为空',\n      password: '密码',\n      passwordMinLength: '密码长度不能少于6位',\n      confirmPassword: '确认密码',\n      confirmPasswordRequired: '请确认密码',\n      passwordMismatch: '两次输入的密码不一致',\n      email: '邮箱',\n      nickname: '昵称',\n      status: '状态',\n      active: '激活',\n      inactive: '已停用',\n      superUser: '超级用户',\n      otp: '启用二次验证',\n      avatar: '头像',\n      uploadAvatar: '上传头像',\n      resetDefaultAvatar: '重置默认头像',\n      restoreCurrentAvatar: '还原当前头像',\n      notifications: '通知',\n      wechat: '微信ID',\n      telegram: 'Telegram ID',\n      slack: 'Slack ID',\n      discord: 'Discord ID',\n      vocechat: 'VoceChat ID',\n      synologyChat: 'SynologyChat ID',\n      webPush: 'WebPush',\n      creatingUser: '正在创建【{name}】用户，请稍后',\n      updatingUser: '正在更新【{name}】用户，请稍后',\n      usernameExists: '用户名已存在',\n      userCreated: '用户【{name}】创建成功',\n      userCreateFailed: '创建用户失败：{message}',\n      userUpdateSuccess: '用户【{name}】更新成功',\n      userUpdateFailed: '更新用户失败：{message}',\n      userDeleteSuccess: '用户【{name}】删除成功',\n      userDeleteFailed: '删除用户失败：{message}',\n      invalidFile: '上传的文件不符合要求，请重新选择头像',\n      fileSizeLimit: '文件大小不得大于800KB',\n      avatarUploadSuccess: '新头像上传成功，待保存后生效!',\n      resetAvatarSuccess: '已重置为默认头像，待保存后生效！',\n      restoreAvatarSuccess: '已还原当前使用头像！',\n      deleteConfirm: '确认删除用户【{name}】吗？',\n      saveUserInfo: '保存用户信息',\n      cannotDeleteCurrentUser: '不能删除当前登录用户',\n      deleteUser: '删除用户',\n      permissions: {\n        title: '权限设置',\n        presetNormal: '普通用户',\n        presetAdmin: '管理员',\n        discovery: '发现',\n        discoveryDesc: '访问推荐和探索功能',\n        search: '搜索',\n        searchDesc: '搜索站点资源和添加下载',\n        subscribe: '订阅',\n        subscribeDesc: '管理电影和电视剧订阅',\n        manage: '管理',\n        manageDesc: '访问下载管理和站点管理等功能',\n      },\n    },\n    searchBar: {\n      search: '搜索',\n      searchPlaceholder: '搜索电影、剧集以及更多...',\n      recentSearches: '最近搜索',\n      noRecentSearches: '没有最近搜索记录',\n      functions: '功能',\n      noFunctionsFound: '没有匹配的功能',\n      plugins: '插件',\n      noPluginsFound: '没有匹配的插件',\n      subscriptions: '订阅',\n      noSubscriptionsFound: '没有匹配的订阅',\n      searchSites: '搜索站点',\n      selectSites: '选择站点',\n      collections: '系列合集',\n      collectionSearch: '相关的系列作品',\n      actorSearch: '相关的演员、导演等',\n      historySearch: '相关的历史记录',\n      subscribeShareSearch: '相关的订阅分享',\n      siteResources: '站点资源',\n      searchInSites: '在站点中搜索种子资源',\n      relatedResources: '相关资源',\n      searchTip: '可搜索电影、电视剧、演员、资源等',\n      emptySearchHint: '输入关键字开始搜索',\n      escClose: '关闭',\n      openSearch: '打开搜索',\n    },\n    searchSite: {\n      selectSites: '选择站点',\n      siteSearch: '站点搜索',\n      searchAllSites: '已选择 {selected}/{total} 个站点',\n      selectAll: '选择全部',\n      deselectAll: '取消全选',\n      confirm: '确认',\n      cancel: '取消',\n    },\n    importCode: {\n      import: '导入',\n      title: '导入代码',\n    },\n    addDownload: {\n      confirmDownload: '确认下载',\n      downloader: '下载器（默认）',\n      saveDirectory: '保存目录（自动）',\n      defaultPlaceholder: '留空默认',\n      autoPlaceholder: '留空自动匹配',\n      downloading: '下载中...',\n      startDownload: '开始下载',\n      downloadSuccess: '{site} {title} 下载成功！',\n      downloadFailed: '{site} {title} 下载失败：{message}！',\n      showAdvancedOptions: '显示高级选项',\n      hideAdvancedOptions: '隐藏高级选项',\n    },\n    subscribeShare: {\n      shareSubscription: '分享订阅',\n      season: '第 {number} 季',\n      title: '标题',\n      description: '说明',\n      descriptionHint: '填写关于该订阅的说明，订阅中的搜索词、识别词等将会默认包含在分享中',\n      shareUser: '分享用户',\n      shareUserHint: '分享人的昵称',\n      confirmShare: '确认分享',\n      shareSuccess: '{name} 分享成功！',\n      shareFailed: '{name} 分享失败：{message}！',\n    },\n    workflowShare: {\n      shareWorkflow: '分享工作流',\n      title: '标题',\n      description: '说明',\n      descriptionHint: '填写关于该工作流的说明，工作流的动作和流程将会默认包含在分享中',\n      shareUser: '分享用户',\n      shareUserHint: '分享人的昵称',\n      confirmShare: '确认分享',\n      shareSuccess: '{name} 分享成功！',\n      shareFailed: '{name} 分享失败：{message}！',\n      securityWarning: '安全提醒',\n      securityWarningMessage: '分享前请确保工作流没有敏感信息，比如RSS链接中的PassKey等，避免产生信息泄露。',\n    },\n    u115Auth: {\n      loginTitle: '115网盘授权',\n      openAuthWindow: '打开授权窗口',\n      authorizing: '请在新窗口中完成授权...',\n      authSuccess: '授权成功！',\n      authFailed: '授权失败或已过期',\n      authCanceled: '授权已取消，请重试',\n      urlEmpty: '授权URL为空',\n      urlFetchFailed: '获取授权URL失败',\n      popupBlocked: '无法打开授权窗口，请检查浏览器弹窗设置',\n      complete: '完成',\n      reset: '重置',\n    },\n    aliyunAuth: {\n      loginTitle: '阿里云盘登录',\n      scanQrCode: '请用阿里云盘 App 扫码',\n      scanned: '已扫码',\n      complete: '完成',\n      reset: '重置',\n    },\n    rcloneConfig: {\n      title: 'RClone配置',\n      filePath: 'rclone配置文件路径',\n      fileContent: 'rclone配置文件内容',\n      defaultContent: '# 请在此处填写rclone配置文件内容 \\n# 请参考 https://rclone.org/docs/ \\n# 存储节点名必须为：MP',\n      complete: '完成',\n      reset: '重置',\n    },\n    alistConfig: {\n      title: 'OpenList配置',\n      serverUrl: 'OpenList服务地址',\n      username: '用户名',\n      password: '密码',\n      tokenUrl: '获取Token地址',\n      loginType: '登录方式',\n      loginTypeOptions: {\n        guest: '访客',\n        username: '用户名密码',\n        token: '令牌',\n      },\n      complete: '完成',\n      reset: '重置',\n    },\n    smbConfig: {\n      title: 'SMB网络共享配置',\n      host: 'SMB服务器地址',\n      hostHint: 'SMB服务器的IP地址或主机名',\n      share: '共享名称',\n      shareHint: '要连接的共享文件夹名称',\n      username: '用户名',\n      usernameHint: 'SMB登录用户名',\n      password: '密码',\n      passwordHint: 'SMB登录密码',\n      domain: '域名',\n      domainHint: 'SMB域名，如WORKGROUP或域控制器名称',\n      complete: '完成',\n      reset: '重置',\n    },\n    workflowAddEdit: {\n      addTitle: '添加工作流',\n      editTitle: '编辑工作流',\n      name: '名称',\n      namePlaceholder: '工作流名称',\n      desc: '描述',\n      descPlaceholder: '工作流描述',\n      enabled: '启用',\n      triggerType: '触发类型',\n      triggerTypeTimer: '定时触发',\n      triggerTypeEvent: '事件触发',\n      triggerTypeManual: '手动触发',\n      schedule: '定时执行',\n      cronExpr: 'Cron表达式',\n      cronExprDesc: '工作流定时执行的cron表达式',\n      eventType: '事件类型',\n      eventTypePlaceholder: '请选择事件类型',\n      nameRequired: '请填写完整信息！',\n      triggerRequired: '请选择触发类型！',\n      timerRequired: '请填写定时表达式！',\n      eventTypeRequired: '请选择事件类型！',\n      addSuccess: '创建任务成功，请编辑流程！',\n      addFailed: '创建任务失败：{message}',\n      editSuccess: '修改任务成功！',\n      editFailed: '修改任务失败：{message}',\n      cancel: '取消',\n      confirm: '确认',\n    },\n    workflowActions: {\n      title: '编辑流程',\n      noActionsMessage: '工作流没有动作，请添加动作',\n      addAction: '添加动作',\n      editAction: '编辑动作',\n      deleteAction: '删除动作',\n      moveUp: '上移',\n      moveDown: '下移',\n      nameLabel: '动作名称',\n      nameRequired: '动作名称不能为空',\n      typeLabel: '动作类型',\n      typeRequired: '动作类型不能为空',\n      paramsLabel: '动作参数',\n      outputLabel: '动作输出',\n      saveAction: '保存动作',\n      cancelAction: '取消',\n      confirmDeleteTitle: '确认删除动作',\n      confirmDeleteMessage: '确定要删除此动作吗？此操作无法撤销。',\n      yesDelete: '是的，删除',\n      noCancel: '取消',\n      invalidConnection: '非法连接：不能连接自身或同类型端口！',\n      componentNotFound: '组件 {component} 未找到',\n      componentAdded: '已添加组件到画布',\n      saveSuccess: '保存任务流程成功！',\n      saveFailed: '保存任务流程失败：{message}',\n      importTitle: '导入任务流程',\n      importSuccess: '导入成功！',\n      importFailed: '导入失败！',\n      codeCopied: '任务流程代码已复制到剪贴板！',\n    },\n    siteCookieUpdate: {\n      title: '更新站点Cookie & UA',\n      processing: '请稍候...',\n      updating: '正在更新 {site} Cookie & UA...',\n      success: '{site} 更新Cookie & UA成功！',\n      failed: '{site} 更新失败：{message}',\n      updateButton: '开始更新',\n    },\n    siteAddEdit: {\n      addTitle: '添加站点',\n      editTitle: '编辑站点',\n      nameLabel: '站点名称',\n      urlLabel: '站点URL',\n      iconLabel: '站点图标',\n      uploadIcon: '上传图标',\n      cookie: 'Cookie',\n      rssUrl: 'RSS链接',\n      enableLabel: '启用',\n      pubEnableLabel: '资源公开',\n      priorityLabel: '优先级',\n      signInLabel: '签到',\n      proxies: '代理',\n      userInfo: '用户信息',\n      cancel: '取消',\n      confirm: '保存',\n    },\n    pluginConfig: {\n      title: '插件配置',\n      save: '保存',\n      close: '关闭',\n      viewData: '查看数据',\n      saving: '正在保存 {name} 配置...',\n      saveSuccess: '插件 {name} 配置已保存',\n      saveFailed: '插件 {name} 配置保存失败：{message}',\n    },\n    pluginData: {\n      title: '插件数据',\n      save: '保存',\n      close: '关闭',\n    },\n    pluginMarketSetting: {\n      title: '插件市场设置',\n      repoUrl: '插件仓库地址',\n      repoPlaceholder: '格式：https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/',\n      repoHint: '多个地址使用换行分隔，仅支持Github仓库',\n      urlPlaceholder: '输入插件仓库地址',\n      noRepos: '暂无插件仓库地址',\n      invalidUrl: '请输入有效的URL地址',\n      duplicateUrl: '该地址已存在',\n      close: '关闭',\n      save: '保存',\n      saveSuccess: '插件仓库保存成功',\n      saveFailed: '插件仓库保存失败：{message}！',\n    },\n    userAuth: {\n      title: '用户认证',\n      codeLabel: '认证码',\n      codePlaceholder: '请输入认证码',\n      authBtn: '开始认证',\n      closeBtn: '关闭',\n      selectSite: '选择认证站点',\n      selectSiteRequired: '请选择认证站点！',\n      siteConfigNotExist: '站点配置不存在！',\n      fieldRequired: '请输入{name}！',\n      authSuccess: '用户认证成功，请重新登录！',\n      authFailed: '认证失败：{message}',\n    },\n    transferQueue: {\n      title: '整理队列',\n      name: '名称',\n      type: '类型',\n      state: '状态',\n      progress: '进度',\n      startTime: '开始时间',\n      speedTitle: '速度',\n      pathTitle: '路径',\n      sizeTitle: '大小',\n      waitingState: '等待中',\n      runningState: '正在整理',\n      finishedState: '完成',\n      failedState: '失败',\n      cancelledState: '已取消',\n      noTasks: '没有正在整理的任务',\n      processing: '请稍候 ...',\n      stopAll: '全部停止',\n      startAll: '全部开始',\n      refresh: '刷新',\n      close: '关闭',\n      processingFile: '正在整理',\n      overallProgress: '整体进度',\n      currentFileProgress: '当前文件进度',\n      processingStatus: '整理中',\n    },\n    reorganize: {\n      title: '整理',\n      sourceTitle: '源文件',\n      targetTitle: '目标文件',\n      processingTitle: '处理中',\n      confirmTitle: '确认',\n      selectFile: '选择文件',\n      selectTarget: '选择目标',\n      selectMediaType: '选择媒体类型',\n      movie: '电影',\n      tv: '电视剧',\n      selectTmdbId: '选择TMDB ID',\n      selectMediaInfo: '选择媒体信息',\n      selectTargetPath: '选择目标路径',\n      selectTargetDir: '选择目标目录',\n      selectFileName: '选择文件名',\n      confirmMoving: '请确认移动！',\n      sourceLabel: '源文件：',\n      targetLabel: '目标目录：',\n      filenameLabel: '文件名：',\n      close: '关闭',\n      next: '下一步',\n      previous: '上一步',\n      confirm: '确认',\n      manualTitle: '手动整理',\n      multipleItemsTitle: '共 {count} 项',\n      singleItemTitle: '{path}',\n      targetStorage: '目的存储',\n      targetStorageHint: '整理目的存储',\n      transferType: '整理方式',\n      transferTypeHint: '文件操作整理方式',\n      targetPath: '目的路径',\n      targetPathHint: '整理目的路径，留空将自动匹配',\n      targetPathPlaceholder: '留空自动',\n      mediaType: '类型',\n      mediaTypeHint: '文件的媒体类型',\n      tmdbId: 'TheMovieDb编号',\n      doubanId: '豆瓣编号',\n      mediaIdHint: '按名称查询媒体编号，留空自动识别',\n      mediaIdPlaceholder: '留空自动识别',\n      episodeGroup: '剧集组编号',\n      episodeGroupHint: '指定剧集组',\n      episodeGroupPlaceholder: '手动查询剧集组',\n      season: '季',\n      seasonHint: '第几季',\n      episodeDetail: '集',\n      episodeDetailHint: '集数或范围，如1或1,2',\n      episodeDetailPlaceholder: '起始集,终止集',\n      episodeFormat: '集数定位',\n      episodeFormatHint: '使用{ep}定位文件名中的集数部分以辅助识别',\n      episodeFormatPlaceholder: '使用{ep}定位集数',\n      episodeOffset: '集数偏移',\n      episodeOffsetHint: '集数偏移运算，如-10或EP*2',\n      episodeOffsetPlaceholder: '如-10',\n      episodePart: '指定Part',\n      episodePartHint: '指定Part，如part1',\n      episodePartPlaceholder: '如part1',\n      minFileSize: '最小文件大小（MB）',\n      minFileSizeHint: '只整理大于最小文件大小的文件',\n      typeFolderOption: '按类型分类',\n      typeFolderHint: '整理时目的路径下按媒体类型添加子目录',\n      categoryFolderOption: '按类别分类',\n      categoryFolderHint: '整理时在目的路径下按媒体类别添加子目录',\n      scrapeOption: '刮削元数据',\n      scrapeHint: '整理完成后自动刮削元数据',\n      fromHistoryOption: '复用历史识别信息',\n      fromHistoryHint: '使用历史整理记录中已识别的媒体信息',\n      addToQueue: '加入整理队列',\n      reorganizeNow: '立即整理',\n      auto: '自动',\n      processing: '正在处理 ...',\n      successMessage: '文件 {name} 已加入整理队列！',\n    },\n    subscribeEdit: {\n      titleDefault: '默认订阅规则',\n      titleEdit: '编辑订阅',\n      seasonFormat: '第 {number} 季',\n      tabs: {\n        basic: '基础',\n        advance: '进阶',\n      },\n      searchKeyword: '搜索关键词',\n      searchKeywordHint: '指定搜索站点时使用的关键词',\n      totalEpisode: '总集数',\n      totalEpisodeHint: '剧集总集数',\n      startEpisode: '开始集数',\n      startEpisodeHint: '开始订阅集数',\n      quality: '质量',\n      qualityHint: '订阅资源质量',\n      resolution: '分辨率',\n      resolutionHint: '订阅资源分辨率',\n      effect: '特效',\n      effectHint: '订阅资源特效',\n      subscribeSites: '订阅站点',\n      subscribeSitesHint: '订阅的站点范围，不选使用系统设置',\n      downloader: '下载器',\n      downloaderHint: '指定该订阅使用的下载器',\n      savePath: '保存路径',\n      savePathHint: '指定该订阅的下载保存路径，留空自动使用设定的下载目录',\n      bestVersion: '洗版',\n      bestVersionHint: '根据洗版优先级进行洗版订阅',\n      searchImdbid: '使用 ImdbID 搜索',\n      searchImdbidHint: '开使用 ImdbID 精确搜索资源',\n      showEditDialog: '订阅时编辑更多规则',\n      showEditDialogHint: '添加订阅时显示此编辑订阅对话框',\n      include: '包含（关键字、正则式）',\n      includeHint: '包含规则，支持正则表达式',\n      exclude: '排除（关键字、正则式）',\n      excludeHint: '排除规则，支持正则表达式',\n      filterGroups: '优先级规则组',\n      filterGroupsHint: '按选定的过滤规则组对订阅进行过滤',\n      episodeGroup: '指定剧集组',\n      episodeGroupHint: '按特定剧集组识别和刮削',\n      season: '指定季',\n      seasonHint: '指定任意季订阅',\n      mediaCategory: '自定义类别',\n      mediaCategoryHint: '指定类别名称，留空自动识别',\n      customWords: '自定义识别词',\n      customWordsHint: '只对该订阅使用的识别词',\n      customWordsPlaceholder:\n        '屏蔽词\\n被替换词 => 替换词\\n前定位词 <> 后定位词 >> 集偏移量（EP）\\n被替换词 => 替换词 && 前定位词 <> 后定位词 >> 集偏移量（EP）\\n其中替换词支持格式：&#123; tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx &#125; 直接指定TMDBID/豆瓣ID识别，其中s、e为季数和集数（可选）',\n      cancelSubscribe: '取消订阅',\n      save: '保存',\n      cancelSubscribeConfirm: '是否确认取消订阅？',\n    },\n    subscribeFiles: {\n      title: '已下载文件',\n      noFilesMessage: '暂无文件',\n      close: '关闭',\n      downloadTab: '下载文件',\n      libraryTab: '媒体库文件',\n      episodeColumn: '集',\n      torrentColumn: '种子',\n      fileColumn: '文件',\n      itemsPerPage: '每页条数',\n      pageText: '{0}-{1} 共 {2} 条',\n      loadingText: '加载中...',\n      noData: '没有数据',\n      season: '第 {number} 季',\n    },\n    subscribeHistory: {\n      title: '{type}订阅历史',\n      resubscribe: '重新订阅',\n      resubscribeMovie: '正在重新订阅 {name}...',\n      resubscribeTv: '正在重新订阅 {name} 第 {season} 季...',\n      season: '第 {season} 季',\n      noData: '没有已完成的订阅',\n    },\n    siteUserData: {\n      title: '站点用户数据',\n      updateTime: '更新时间',\n      username: '用户名',\n      uploadTitle: '上传量',\n      uploadTotal: '总上传量',\n      downloadTitle: '下载量',\n      downloadTotal: '总下载量',\n      seedingTitle: '做种数',\n      seedingCount: '总做种数',\n      seedingSize: '总做种体积',\n      userLevel: '用户等级',\n      msgCount: '未读消息',\n      inviteCount: '邀请数',\n      bonus: '积分',\n      ratio: '分享率',\n      joinTime: '加入时间',\n      trafficHistory: '历史流量',\n      seedingDistribution: '做种分布',\n      volumeTitle: '体积',\n      countTitle: '数量：',\n      noData: '无',\n      refreshing: '正在刷新站点数据...',\n      close: '关闭',\n    },\n    siteResource: {\n      browseTitle: '浏览 - {name}',\n      searchKeyword: '搜索关键字',\n      resourceCategory: '资源分类',\n      search: '搜索',\n      itemsPerPage: '每页条数',\n      noData: '没有数据',\n      loading: '加载中...',\n      titleColumn: '标题',\n      timeColumn: '时间',\n      sizeColumn: '大小',\n      seedersColumn: '做种',\n      peersColumn: '下载',\n      viewDetails: '查看详情',\n      downloadTorrent: '下载种子文件',\n      pageText: '{0}-{1} 共 {2} 条',\n    },\n    forkSubscribe: {\n      title: '复制订阅',\n      selectSubscriber: '选择复制目标',\n      overwriteExisting: '覆盖现有订阅',\n      overwriteExistingHint: '目标用户已存在该订阅时，是否覆盖',\n      confirm: '确认',\n      cancel: '取消',\n    },\n  },\n  file: {\n    newFolder: '新建文件夹',\n    autoRecognizeName: '自动识别名称',\n    createFolder: '创建文件夹',\n    fileName: '文件名',\n    fileSize: '文件大小',\n    fileType: '文件类型',\n    lastModified: '修改时间',\n    actions: '操作',\n    rename: '重命名',\n    delete: '删除',\n    confirmFileDelete: '确认删除',\n    upload: '上传',\n    download: '下载',\n    preview: '预览',\n    selectAll: '全选',\n    deselectAll: '取消全选',\n    moveUp: '返回上一级',\n    sortByName: '按名称排序',\n    sortByTime: '按时间排序',\n    currentName: '当前名称',\n    newName: '新名称',\n    includeSubfolders: '自动重命名目录内所有媒体文件',\n    emptyFolder: '空文件夹',\n    noFilesInFolder: '该文件夹内没有文件',\n    autoRecognize: '自动识别名称',\n    directoryTree: '目录树',\n    rootDirectory: '根目录',\n    noDirectories: '没有可用的目录',\n    directory: '目录',\n    file: '文件',\n    size: '大小',\n    modifyTime: '修改时间',\n    noFiles: '没有目录或文件',\n    emptyDirectory: '空目录',\n    confirmDelete: '是否确认删除{type} {name}？',\n    confirmBatchDelete: '是否确认删除选中的 {count} 个项目？',\n    deleting: '正在删除 {name}...',\n    recognize: '识别',\n    recognizing: '正在识别 {path}...',\n    recognizeFailed: '{path} 识别失败！',\n    scrape: '刮削',\n    scraping: '正在刮削 {path}...',\n    scrapeCompleted: '{path} 削刮完成！',\n    confirmScrape: '是否确认刮削 {path}？',\n    confirmBatchScrape: '是否确认刮削选中的 {count} 项？',\n    renaming: '正在重命名 {name}...',\n    renamingAll: '正在重命名 {path} 及目录内所有文件...',\n    close: '关闭',\n    loadingDirectoryStructure: '加载目录结构...',\n    reorganize: '整理',\n  },\n  person: {\n    alias: '别名：',\n    credits: '参演作品',\n    biography: '个人简介',\n    birthday: '出生日期',\n    placeOfBirth: '出生地',\n  },\n  error: {\n    title: '出错啦！',\n    networkError: '无法获取到媒体信息，请检查网络连接。',\n    serverError: '服务器错误，请稍后重试。',\n    notFound: '找不到请求的资源。',\n  },\n  plugin: {\n    sort: {\n      popular: '热门',\n      name: '插件名称',\n      author: '作者',\n      repository: '插件仓库',\n      latest: '最新发布',\n    },\n    installingPlugin: '正在安装插件...',\n    installing: '正在安装 {name} v{version} ...',\n    installSuccess: '插件 {name} 安装成功！',\n    installFailed: '插件 {name} 安装失败：{message}',\n    filterPlugins: '过滤插件',\n    name: '名称',\n    hasNewVersion: '有新版本',\n    running: '运行中',\n    author: '作者',\n    label: '标签',\n    repository: '仓库',\n    sortTitle: '排序',\n    filter: '过滤：{name}',\n    noMatchingContent: '没有找到匹配的内容',\n    pleaseInstallFromMarket: '请从插件市场安装插件',\n    allPluginsInstalled: '所有插件已安装',\n    searchPlugins: '搜索插件',\n    searchPlaceholder: '按插件名称或描述搜索',\n    uninstalling: '正在卸载 {name} ...',\n    uninstallSuccess: '插件 {name} 卸载成功！',\n    uninstallFailed: '插件 {name} 卸载失败：{message}',\n    updating: '正在更新 {name} ...',\n    updateSuccess: '插件 {name} 更新成功！',\n    updateFailed: '插件 {name} 更新失败：{message}',\n    noPlugins: '没有安装插件',\n    installed: '已安装',\n    notInstalled: '未安装',\n    hasUpdate: '有更新',\n    configuring: '配置',\n    enable: '启用',\n    disable: '禁用',\n    settings: '设置',\n    projectHome: '项目主页',\n    updateHistory: '更新说明',\n    local: '本地',\n    installToLocal: '安装到本地',\n    totalDownloads: '共 {count} 次下载',\n    viewData: '查看数据',\n    update: '更新',\n    reset: '重置',\n    uninstall: '卸载',\n    viewLogs: '查看日志',\n    authorHome: '作者主页',\n    confirmUninstall: '是否确认卸载插件 {name}？',\n    confirmReset: '此操作将恢复插件 {name} 的默认设置，并清除所有相关数据，确定要继续吗？',\n    resetSuccess: '插件 {name} 数据已重置',\n    resetFailed: '插件 {name} 重置失败：{message}',\n    updateHistoryTitle: '{name} 更新说明',\n    updateToLatest: '更新到最新版本',\n    updatingTo: '更新 {name} 到 {version} 版本...',\n    folderNameEmpty: '文件夹名称不能为空',\n    folderExists: '文件夹已存在',\n    folderCreateSuccess: '文件夹创建成功',\n    folderRenameSuccess: '文件夹重命名成功',\n    folderRenameFailed: '重命名文件夹失败',\n    folderDeleteSuccess: '文件夹删除成功',\n    folderDeleteFailed: '删除文件夹失败',\n    removeFromFolderSuccess: '插件已移出文件夹',\n    operationFailed: '操作失败',\n    saveFolderConfigFailed: '保存文件夹配置失败',\n    newFolder: '新建文件夹',\n    folderName: '文件夹名称',\n    cancel: '取消',\n    create: '创建',\n    clone: '分身',\n    cloneTitle: '创建插件分身',\n    cloneSubtitle: '为 {name} 创建独立的分身实例',\n    cloneFeature: '插件分身功能',\n    cloneDescription: '创建插件的独立副本，拥有独立的配置和数据，适用于多账号、测试环境等场景',\n    suffix: '分身后缀',\n    suffixPlaceholder: '例如：Test、Backup、Site1',\n    suffixHint: '用于区分分身的唯一标识，只能包含英文字母和数字',\n    suffixRequired: '分身后缀不能为空',\n    suffixFormatError: '只能包含英文字母和数字',\n    suffixLengthError: '长度不能超过20个字符',\n    cloneName: '分身名称',\n    cloneNamePlaceholder: '例如：自动备份 测试版',\n    cloneNameHint: '分身插件的显示名称（可选）',\n    cloneDefaultName: '{name} 分身',\n    cloneDescriptionLabel: '分身描述',\n    cloneDescriptionPlaceholder: '描述这个分身的用途和特点...',\n    cloneDescriptionHint: '详细描述分身插件的用途（可选）',\n    cloneDefaultDescription: '{description} (分身版本)',\n    cloneVersion: '版本号',\n    cloneVersionPlaceholder: '例如：1.0、2.1.0',\n    cloneVersionHint: '自定义分身插件的版本号（可选）',\n    cloneIcon: '图标URL',\n    cloneIconPlaceholder: 'https://example.com/icon.png',\n    cloneIconHint: '自定义分身插件的图标（可选）',\n    cloneNotice: '分身插件创建后默认为禁用状态，需要手动配置启用。分身后缀一旦确定无法修改。',\n    createClone: '创建分身',\n    cloning: '正在创建 {name} 的分身...',\n    cloneSuccess: '插件分身 {name} 创建成功！',\n    cloneFailed: '插件分身创建失败：{message}',\n    cloneFailedGeneral: '插件分身创建失败',\n    logTitle: '插件日志',\n    quickAccess: '快速访问',\n    tapToOpen: '点击返回主界面',\n    noPluginsWithPage: '暂无可用插件',\n    recentlyUsed: '最近使用',\n    allPlugins: '所有插件',\n    noRecentPlugins: '无',\n  },\n  profile: {\n    disableOtpWithPasskeyError: '请先删除所有通行密钥后再清除身份验证器！',\n    personalInfo: '个人信息',\n    uploadNewAvatar: '上传新头像',\n    avatarFormatError: '上传的文件不符合要求，请重新选择头像',\n    avatarSizeError: '文件大小不得大于800KB',\n    avatarUploadSuccess: '新头像上传成功，待保存后生效!',\n    resetAvatarSuccess: '已重置为默认头像，待保存后生效！',\n    restoreAvatarSuccess: '已还原当前使用头像！',\n    savingInProgress: '正在保存中，请稍后...',\n    usernameRequired: '用户名不能为空',\n    passwordMismatch: '两次输入的密码不一致',\n    usernameChangeSuccess: '【{oldName}】更名【{newName}】，用户信息保存成功！',\n    saveSuccess: '用户信息保存成功！',\n    saveFailedWithNameChange: '【{oldName}】更名【{newName}】，信息保存失败：{message}！',\n    saveFailed: '用户信息保存失败：{message}！',\n    nickname: '昵称',\n    nicknamePlaceholder: '显示昵称，优先于用户名显示',\n    accountBinding: '账号绑定',\n    wechatUser: '微信用户',\n    telegramUser: 'Telegram用户',\n    slackUser: 'Slack用户',\n    discordUser: 'Discord用户',\n    vocechatUser: 'VoceChat用户',\n    synologychatUser: 'SynologyChat用户',\n    doubanUser: '豆瓣用户',\n    setupAuthenticator: '设置身份验证器',\n    authenticatorManagement: '身份验证器管理',\n    authenticatorEnabled: '您已启用身份验证器双重验证',\n    clearAuthenticatorTip: '如需设置新的身份验证器，请先清除当前配置。',\n    clearAuthenticator: '清除身份验证器',\n    enableTwoFactor: '开启双重验证',\n    disableTwoFactor: '关闭双重验证',\n    setupMfa: '设置双重验证',\n    enableMfa: '开启双重验证',\n    useAuthenticator: '使用身份验证器',\n    usePasskey: '使用通行密钥',\n    enabled: '已启用',\n    keysCount: '{count} 个密钥',\n    passkeyManagement: '通行密钥管理',\n    registerNewPasskey: '注册新通行密钥',\n    passkeyDescription: '通行密钥可以让您无需密码即可快速安全地登录。',\n    passkeyAppDescription:\n      '通行密钥是一种更简单、更安全的登录方式，可以替代密码进行登录。您可以使用 iCloud 钥匙串、Bitwarden 等支持通行密钥的应用程序或硬件密钥完成验证。',\n    passkeyName: '通行密钥名称',\n    passkeyNamePlaceholder: '例如：iPhone、Windows Hello',\n    registerPasskey: '注册通行密钥',\n    createdAt: '创建于',\n    lastUsedAt: '最后使用时间',\n    noPasskeys: '您还没有注册任何通行密钥',\n    passkeyNameRequired: '请输入通行密钥名称',\n    passkeyRegisterSuccess: '通行密钥注册成功',\n    passkeyRegisterFailed: '注册失败',\n    passkeyRegisterCancelled: '注册被取消',\n    passkeyDeleteSuccess: '通行密钥已删除',\n    passkeyDeleteFailed: '删除失败',\n    deletePasskey: '删除通行密钥',\n    passkeyDomainWarning:\n      '通行密钥（PassKey）的可用性与 {domain} 紧密相关。在公网环境下，请务必在“基础设置”中配置正确的访问域名。域名变更或配置错误将导致通行密钥无法使用。',\n    otpRequiredForPasskey:\n      '为了安全起见，您必须先启用 {otp} 验证码，然后才能注册通行密钥。这是为了防止在域名配置变动导致 PassKey 失效时，您仍能通过 OTP 码登录账户。',\n    accessDomain: '访问域名',\n    otpAuthenticator: 'OTP 身份验证器',\n    otpGenerateFailed: '获取otp uri失败：{message}！',\n    otpDisableSuccess: '关闭登录双重验证成功！',\n    otpDisableFailed: '关闭otp失败：{message}！',\n    otpCodeRequired: '请填写6位验证码',\n    otpEnableSuccess: '开启登录双重验证成功！',\n    otpEnableFailed: '开启otp失败：{message}！',\n    otpDisableRestrictedByPasskey: '您已注册通行密钥，请先删除所有通行密钥再关闭 OTP 验证。',\n    confirmToDisableOtp: '为了安全起见，关闭双重验证需要验证您的登录密码。',\n    confirmToDeletePasskey: '为了安全起见，删除通行密钥需要验证您的登录密码。',\n    authenticatorAppDescription:\n      '使用 Google Authenticator、Microsoft Authenticator、Authy 或 1Password 等验证器应用扫描二维码，获取 6 位验证码。',\n    secretKeyTip: '如果您在使用二维码时遇到困难，请在您的应用程序中选择手动输入以上代码。',\n    enterVerificationCode: '输入验证码以确认开启双重验证',\n    avatarFormatTip: '允许 JPG、PNG、GIF、WEBP 格式， 最大尺寸 800KB。',\n  },\n  transferHistory: {\n    title: '转移历史',\n    searchPlaceholder: '搜索转移记录',\n    titleColumn: '标题',\n    pathColumn: '路径',\n    modeColumn: '转移方式',\n    sizeColumn: '大小',\n    dateColumn: '时间',\n    statusColumn: '状态',\n    actionsColumn: '操作',\n    seasonEpisode: '季集/类别',\n    transferQueue: '转移队列',\n    groupMode: '分组模式',\n    listMode: '列表模式',\n    deleteConfirm: '确认删除 {title} {seasons}{episodes} ?',\n    deleteConfirmBatch: '确认删除 {count} 条记录 ?',\n    deleteRecordOnly: '仅删除转移记录',\n    deleteSourceOnly: '删除转移记录和源文件',\n    deleteDestOnly: '删除转移记录和媒体库文件',\n    deleteAll: '删除转移记录、源文件和媒体库文件',\n    transferMode: {\n      copy: '复制',\n      move: '移动',\n      link: '硬链接',\n      softlink: '软链接',\n      rclone_copy: 'Rclone复制',\n      rclone_move: 'Rclone移动',\n    },\n    status: {\n      success: '成功',\n      failed: '失败',\n      unknown: '未知',\n    },\n    noData: '没有数据',\n    loading: '加载中...',\n    pageSize: '每页条数',\n    pageInfo: '{begin} - {end} / {total}',\n    aiRedoDisabled: '请先在系统设置中启用 AI 智能助手',\n    aiRedoQueued: '已提交智能助手整理任务：{title}',\n    aiRedoFailed: '提交智能助手整理任务失败',\n    actions: {\n      aiRedo: '智能助手整理',\n      aiRedoPending: '智能助手整理中...',\n      redo: '重新整理',\n      delete: '删除',\n      batchRedo: '批量重新整理',\n      batchDelete: '批量删除',\n    },\n    batchOperationTitle: '批量操作',\n    progress: {\n      processing: '处理中',\n      pleaseWait: '请稍候...',\n    },\n    table: {\n      emptyTitle: '操作',\n    },\n  },\n  customRule: {\n    error: {\n      emptyIdName: '规则ID和规则名称不能为空',\n      idOccupied: '当前规则ID已被内置规则占用',\n      nameOccupied: '当前规则名称已被内置规则占用',\n      idExists: '规则ID【{id}】已存在',\n      nameExists: '规则名称【{name}】已存在',\n    },\n    title: '{id} - 配置',\n    field: {\n      ruleId: '规则ID',\n      ruleName: '规则名称',\n      include: '包含',\n      exclude: '排除',\n      sizeRange: '资源体积（MB）',\n      seeders: '做种人数',\n      publishTime: '发布时间（分钟）',\n    },\n    placeholder: {\n      ruleId: '必填；不可与其他规则ID重名',\n      ruleName: '必填；不可与其他规则名称重名',\n      include: '关键字/正则表达式',\n      exclude: '关键字/正则表达式',\n      sizeRange: '0/1-10',\n      seeders: '0/1-10',\n      publishTime: '0/1-10',\n    },\n    hint: {\n      ruleId: '字符与数字组合，不能含空格',\n      ruleName: '使用别名便于区分规则',\n      include: '必须包含的关键字或正则表达式，多个值使用｜分隔',\n      exclude: '不能包含的关键字或正则表达式，多个值使用｜分隔',\n      sizeRange: '最小资源文件体积或体积范围（剧集计算单集平均大小）',\n      seeders: '最小做种人数或做种人数范围',\n      publishTime: '距离资源发布的最小时间间隔或时间区间',\n    },\n    action: {\n      confirm: '确定',\n    },\n  },\n  downloader: {\n    title: '下载器',\n    name: '名称',\n    type: '类型',\n    enabled: '启用',\n    customTypeHint: '自定义下载器类型，用于插件等场景',\n    rtorrentHostHint: 'HTTP: http://ip:port/RPC2 或 SCGI: scgi://ip:port',\n    default: '默认',\n    host: '地址',\n    username: '用户名',\n    password: '密码',\n    category: '自动分类管理',\n    sequentail: '顺序下载',\n    force_resume: '强制继续',\n    first_last_piece: '优先首尾文件',\n    saveSuccess: '下载器设置保存成功',\n    saveFailed: '下载器设置保存失败',\n    nameRequired: '不能为空，且不能重名',\n    nameDuplicate: '名称已存在',\n    defaultChanged: '存在默认下载器，已替换',\n    hostRequired: '地址不能为空',\n    usernameRequired: '用户名不能为空',\n    passwordRequired: '密码不能为空',\n    pathMapping: '路径映射',\n    pathMappingRequired: '路径不能为空',\n    pathMappingError: '必须以 / 开头',\n    storagePath: '存储路径',\n    downloadPath: '下载路径',\n  },\n  filterRule: {\n    title: '过滤规则',\n    groupName: '规则组名称',\n    priority: '优先级',\n    rules: '规则',\n    add: '添加规则',\n    import: '导入规则',\n    share: '分享规则',\n    save: '保存规则',\n    nameRequired: '规则组名称不能为空',\n    nameDuplicate: '规则组名称已存在',\n    importSuccess: '规则导入成功',\n    importFailed: '规则导入失败',\n    shareSuccess: '规则已复制到剪贴板',\n    shareFailed: '规则复制失败',\n    mediaType: '媒体类型',\n    category: '媒体类别',\n    mediaTypeItems: {\n      movie: '电影',\n      tv: '电视剧',\n      anime: '动漫',\n      collection: '合集',\n      unknown: '未知',\n    },\n  },\n  mediaserver: {\n    type: '类型',\n    customTypeHint: '自定义媒体服务器类型，用于插件等场景',\n    enableMediaServer: '启用媒体服务器',\n    nameRequired: '必填，不可重名',\n    serverAlias: '媒体服务器的别名',\n    host: '地址',\n    hostPlaceholder: 'http(s)://ip:port',\n    hostHint: '服务端地址，格式：http(s)://ip:port',\n    playHost: '外网播放地址',\n    playHostPlaceholder: 'http(s)://domain:port',\n    playHostHint: '跳转播放页面使用的地址，格式：http(s)://domain:port',\n    apiKey: 'API密钥',\n    embyApiKeyHint: 'Emby设置->高级->API密钥中生成的密钥',\n    jellyfinApiKeyHint: 'Jellyfin设置->高级->API密钥中生成的密钥',\n    plexToken: 'X-Plex-Token',\n    plexTokenHint: '浏览器F12->网络，从Plex请求URL中获取的X-Plex-Token',\n    username: '用户名',\n    usernameHint: '登录用户名',\n    password: '密码',\n    syncLibraries: '同步媒体库',\n    syncLibrariesHint: '只有选中的媒体库才会被同步',\n    scanMode: '扫描模式',\n    scanModeHint: '用于全库刷新和按库刷新：新添加和修改 / 补充缺失 / 覆盖扫描',\n    verifySsl: '校验 SSL 证书',\n    verifySslHint: '开启后会校验 HTTPS 证书；如使用自签名证书可关闭',\n    scanModeOptions: {\n      newAndModified: '新添加和修改',\n      supplementMissing: '补充缺失',\n      fullOverride: '覆盖扫描',\n    },\n    nameExists: '【{name}】已存在，请替换为其他名称',\n    hostRequired: '地址不能为空',\n    apiKeyRequired: 'API密钥不能为空',\n    tokenRequired: 'Token不能为空',\n    usernameRequired: '用户名不能为空',\n    passwordRequired: '密码不能为空',\n  },\n  bangumi: {\n    category: '类别',\n    sort: '排序',\n    year: '年份',\n    cat: {\n      other: '其他',\n      tv: 'TV',\n      ova: 'OVA',\n      movie: 'Movie',\n      web: 'WEB',\n    },\n    sortType: {\n      rank: '排名',\n      date: '日期',\n    },\n  },\n  tmdb: {\n    type: '类型',\n    sort: '排序',\n    genre: '风格',\n    language: '语言',\n    rating: '评分',\n    sortType: {\n      popularityDesc: '热度降序',\n      popularityAsc: '热度升序',\n      releaseDateDesc: '上映日期降序',\n      releaseDateAsc: '上映日期升序',\n      firstAirDateDesc: '首播日期降序',\n      firstAirDateAsc: '首播日期升序',\n      voteAverageDesc: '评分降序',\n      voteAverageAsc: '评分升序',\n      time: '最新',\n      count: '热门',\n      rating: '评分',\n    },\n    genreType: {\n      action: '动作',\n      adventure: '冒险',\n      animation: '动画',\n      comedy: '喜剧',\n      crime: '犯罪',\n      documentary: '纪录片',\n      drama: '剧情',\n      family: '家庭',\n      fantasy: '奇幻',\n      history: '历史',\n      horror: '恐怖',\n      music: '音乐',\n      mystery: '悬疑',\n      romance: '爱情',\n      scienceFiction: '科幻',\n      tvMovie: '电视电影',\n      thriller: '惊悚',\n      war: '战争',\n      western: '西部',\n      actionAdventure: '动作冒险',\n      kids: '儿童',\n      news: '新闻',\n      reality: '真人秀',\n      sciFiFantasy: '科幻奇幻',\n      soap: '肥皂剧',\n      talk: '戏剧',\n      warPolitics: '战争政治',\n    },\n    languageType: {\n      zh: '中文',\n      en: '英语',\n      ja: '日语',\n      ko: '韩语',\n      fr: '法语',\n      de: '德语',\n      es: '西班牙语',\n      it: '意大利语',\n      ru: '俄语',\n      pt: '葡萄牙语',\n      ar: '阿拉伯语',\n      hi: '印地语',\n      th: '泰语',\n    },\n  },\n  douban: {\n    type: '类型',\n    sort: '排序',\n    genre: '风格',\n    zone: '地区',\n    year: '年代',\n    sortType: {\n      comprehensive: '综合排序',\n      releaseDate: '首播时间',\n      recentHot: '近期热度',\n      highScore: '高分优先',\n    },\n    genreType: {\n      comedy: '喜剧',\n      romance: '爱情',\n      action: '动作',\n      scienceFiction: '科幻',\n      animation: '动画',\n      mystery: '悬疑',\n      crime: '犯罪',\n      thriller: '惊悚',\n      adventure: '冒险',\n      music: '音乐',\n      history: '历史',\n      fantasy: '奇幻',\n      horror: '恐怖',\n      war: '战争',\n      biography: '传记',\n      musical: '歌舞',\n      martialArts: '武侠',\n      erotic: '情色',\n      disaster: '灾难',\n      western: '西部',\n      documentary: '纪录片',\n      shortFilm: '短片',\n    },\n    zoneType: {\n      chinese: '华语',\n      europeanAmerican: '欧美',\n      korean: '韩国',\n      japanese: '日本',\n      mainlandChina: '中国大陆',\n      usa: '美国',\n      hongKong: '中国香港',\n      taiwan: '中国台湾',\n      uk: '英国',\n      france: '法国',\n      germany: '德国',\n      italy: '意大利',\n      spain: '西班牙',\n      india: '印度',\n      thailand: '泰国',\n      russia: '俄罗斯',\n      canada: '加拿大',\n      australia: '澳大利亚',\n      ireland: '爱尔兰',\n      sweden: '瑞典',\n      brazil: '巴西',\n      denmark: '丹麦',\n    },\n    yearType: {\n      '2020s': '2020年代',\n      '2010s': '2010年代',\n      '2000s': '2000年代',\n      '1990s': '90年代',\n      '1980s': '80年代',\n      '1970s': '70年代',\n      '1960s': '60年代',\n    },\n  },\n  directory: {\n    alias: '目录别名',\n    mediaType: '媒体类型',\n    mediaCategory: '媒体类别',\n    resourceStorage: '资源存储',\n    resourceDirectory: '资源目录',\n    sortByType: '按类型分类',\n    sortByCategory: '按类别分类',\n    autoTransfer: '自动整理',\n    monitorMode: '监控模式',\n    libraryStorage: '媒体库存储',\n    libraryDirectory: '媒体库目录',\n    transferType: '整理方式',\n    transferTypeHint: '文件操作整理方式，硬链接节省空间，复制更安全',\n    overwriteMode: '覆盖模式',\n    overwriteModeHint: '当目标文件已存在时的处理方式',\n    smartRename: '智能重命名',\n    scrapingMetadata: '刮削元数据',\n    sendNotification: '发送通知',\n    noTransfer: '不整理',\n    downloaderMonitor: '下载器监控',\n    directoryMonitor: '目录监控',\n    manualTransfer: '手动整理',\n    performanceMode: '性能模式',\n    compatibilityMode: '兼容模式',\n    pleaseSelectStorage: '请选择存储',\n    pleaseSelectLibraryStorage: '请选择媒体库存储',\n    pleaseSelectDownloadStorage: '请选择下载存储',\n    noSupportedTransferType: '没有支持的整理方式',\n    never: '从不',\n    always: '总是',\n    byFileSize: '按文件大小',\n    keepLatestOnly: '仅保留最新',\n  },\n  validators: {\n    required: '此项为必填项',\n    number: '请输入数字',\n  },\n  folder: {\n    settingAppearance: '设置外观',\n    rename: '重命名',\n    deleteFolder: '删除文件夹',\n    folderNameCannotBeEmpty: '文件夹名称不能为空',\n    confirmDeleteFolder: '确定要删除文件夹 \"{folderName}\" 吗？文件夹中的插件将移回主列表。',\n    folderSettingsSaved: '文件夹设置已保存',\n    renameFolder: '重命名文件夹',\n    folderName: '文件夹名称',\n    folderAppearanceSettings: '文件夹外观设置',\n    showFolderIcon: '显示文件夹图标',\n    icon: '图标',\n    iconColor: '图标颜色',\n    backgroundGradient: '背景渐变',\n    customBackgroundImageURL: '自定义背景图片URL（可选）',\n    customBackgroundImageHint: '支持网络图片URL，留空则使用渐变背景',\n    pluginCount: '{count} 个插件',\n  },\n  setupWizard: {\n    title: '欢迎使用 MoviePilot ！',\n    subtitle: '按向导完成配置，即刻开始使用。',\n    completed: '配置向导完成！',\n    failed: '配置向导失败，请重试',\n    complete: '完成配置',\n    loading: '正在加载配置数据...',\n    testing: '正在测试',\n    connectivityTestSuccess: '连通性测试通过',\n    connectivityTestFailed: '连通性测试失败',\n    testingStorage: '正在测试存储目录',\n    checkingStorage: '检查存储目录连通性',\n    storageTestFailed: '存储目录测试失败',\n    testingDownloader: '正在测试下载器',\n    checkingDownloader: '检查下载器连通性',\n    downloaderTestFailed: '下载器测试失败',\n    downloaderNotSelected: '未选择下载器',\n    unsupportedDownloaderType: '不支持的下载器类型: {type}',\n    testingMediaServer: '正在测试媒体服务器',\n    checkingMediaServer: '检查媒体服务器连通性',\n    mediaServerTestFailed: '媒体服务器测试失败',\n    mediaServerNotSelected: '未选择媒体服务器',\n    unsupportedMediaServerType: '不支持的媒体服务器类型: {type}',\n    testingNotification: '正在测试消息通知',\n    checkingNotification: '检查消息通知连通性',\n    notificationTestFailed: '消息通知测试失败',\n    notificationNotSelected: '未选择通知类型',\n    unsupportedNotificationType: '不支持的通知类型: {type}',\n    testFailedHint: '请检查配置是否正确，修改后可以重新测试',\n    saveStepFailed: '保存步骤设置失败',\n    basicSettingsSaved: '基础设置保存成功',\n    saveBasicSettingsFailed: '保存基础设置失败',\n    storageSettingsSaved: '存储设置保存成功',\n    saveStorageSettingsFailed: '保存存储设置失败',\n    downloaderSettingsSaved: '下载器设置保存成功',\n    saveDownloaderSettingsFailed: '保存下载器设置失败',\n    mediaServerSettingsSaved: '媒体服务器设置保存成功',\n    saveMediaServerSettingsFailed: '保存媒体服务器设置失败',\n    notificationSettingsSaved: '通知设置保存成功',\n    saveNotificationSettingsFailed: '保存通知设置失败',\n    saveSiteAuthSettingsFailed: '保存用户站点认证设置失败：{message}',\n    saveAgentSettingsFailed: '保存智能助手设置失败',\n    preferenceSettingsSaved: '偏好设置保存成功',\n    savePreferenceSettingsFailed: '保存偏好设置失败',\n    passwordUpdateSuccess: '密码更新成功',\n    passwordUpdateFailed: '密码更新失败',\n    userCreateSuccess: '用户创建成功',\n    basic: {\n      title: '基础设置',\n      description: '设置访问域名、用户名密码和网络配置',\n      appDomain: '访问域名',\n      appDomainHint: '用于发送通知时，添加快捷跳转地址',\n      wallpaper: '背景壁纸',\n      wallpaperHint: '选择登录页面背景来源',\n      recognizeSource: '识别数据源',\n      recognizeSourceHint: '设置默认媒体信息识别数据源',\n      apiToken: 'API 令牌',\n      apiTokenHint: '访问MoviePilot API 需要的访问令牌，请记录下来以便后续使用',\n      currentUserHint: '当前用户，不可修改',\n      passwordOptionalHint: '留空表示不修改密码',\n      confirmPasswordHint: '确认新密码',\n      apiTokenRequired: 'API Token不能为空',\n    },\n    siteAuth: {\n      title: '用户认证',\n      description: '配置用户站点认证与辅助认证',\n      info: '用户站点认证说明',\n      infoDesc: '完成站点认证后可解锁站点能力与部分插件权限。此步骤可选，后续也可在个人菜单中继续配置。',\n      selectSiteHint: '选择一个支持认证的站点，并填写该站点要求的认证参数',\n      submitHint: '点击下一步时将立即向认证站点发起校验，认证成功后会保存当前参数。',\n      siteConfigNotExist: '认证站点配置不存在',\n      fieldRequired: '请输入{name}',\n    },\n    storage: {\n      title: '存储',\n      description: '配置下载目录和媒体库目录',\n      info: '存储配置说明',\n      infoDesc: '配置本地存储目录，用于下载和媒体库管理',\n      downloadPath: '下载目录',\n      downloadPathHint: '设置下载文件的存储路径',\n      libraryPath: '媒体库目录',\n      libraryPathHint: '设置媒体文件的存储路径',\n      downloadPathRequired: '下载目录不能为空',\n      libraryPathRequired: '媒体库目录不能为空',\n    },\n    downloader: {\n      title: '下载器',\n      description: '配置下载器',\n      info: '下载器配置说明',\n      infoDesc: '配置下载器用于下载资源，可选择qBittorrent或Transmission',\n      type: '下载器类型',\n      typeHint: '选择要使用的下载器类型',\n      name: '下载器名称',\n      nameHint: '为下载器设置一个名称',\n      qbittorrentConfig: 'qBittorrent 配置',\n      transmissionConfig: 'Transmission 配置',\n      host: '服务器地址',\n      username: '用户名',\n      password: '密码',\n      downloadPath: '下载路径',\n    },\n    mediaServer: {\n      title: '媒体服务器',\n      description: '配置媒体服务器',\n      info: '媒体服务器配置说明',\n      infoDesc: '配置媒体服务器用于媒体库管理，可选择Emby、Jellyfin、Plex、飞牛影视或绿联影视',\n      type: '媒体服务器类型',\n      typeHint: '选择要使用的媒体服务器类型',\n      name: '服务器名称',\n      nameHint: '为媒体服务器设置一个名称',\n      embyConfig: 'Emby 配置',\n      jellyfinConfig: 'Jellyfin 配置',\n      plexConfig: 'Plex 配置',\n      host: '服务器地址',\n      apiKey: 'API 密钥',\n      token: '访问令牌',\n    },\n    notification: {\n      title: '通知',\n      description: '配置通知渠道',\n      info: '通知配置说明',\n      infoDesc: '配置通知渠道用于接收系统消息（可选）',\n      type: '通知类型',\n      typeHint: '选择要使用的通知渠道类型',\n      name: '通知名称',\n      nameHint: '为通知渠道设置一个名称',\n      telegramConfig: 'Telegram 配置',\n      emailConfig: '邮件配置',\n      botToken: '机器人令牌',\n      chatId: '聊天ID',\n      smtpServer: 'SMTP 服务器',\n      smtpPort: 'SMTP 端口',\n      senderEmail: '发送邮箱',\n      senderPassword: '发送密码',\n      receiverEmail: '接收邮箱',\n    },\n    agent: {\n      title: '智能助手',\n      description: '配置 Agent 助手与 LLM 参数',\n      info: '智能助手配置说明',\n      infoDesc: '启用后可在消息会话中使用 Agent 能力，也可开启失败整理接管和智能推荐。',\n      providerRequired: 'LLM 提供商不能为空',\n      apiKeyRequired: 'LLM API 密钥不能为空',\n      modelRequired: 'LLM 模型名称不能为空',\n      maxContextTokensRequired: 'LLM 最大上下文 Token 数量必须大于 0',\n      recommendMaxItemsRequired: '智能推荐分析条目上限必须大于 0',\n    },\n    preferences: {\n      title: '资源偏好',\n      description: '设置资源下载偏好',\n      info: '资源偏好说明',\n      infoDesc: '设置资源下载的偏好，系统将根据这些偏好自动选择最佳资源',\n      quality: '质量偏好',\n      qualityHint: '选择偏好的视频质量',\n      subtitle: '字幕偏好',\n      subtitleHint: '选择偏好的字幕类型',\n      resolution: '分辨率偏好',\n      resolutionHint: '选择偏好的视频分辨率',\n      presetRules: '预设规则',\n      detailedConfig: '详细配置',\n      quickPresets: '快速预设',\n      quickPresetsDesc: '选择预设配置，系统将自动应用对应的规则',\n      personalizationOptions: '个性化选项',\n      personalizationOptionsDesc: '根据您的需求调整规则',\n      excludeDolbyVision: '排除杜比视界',\n      excludeDolbyVisionHint: '选中后规则中将排除杜比视界资源',\n      excludeBluray: '排除蓝光原盘',\n      excludeBlurayHint: '选中后规则中将排除蓝光原盘资源',\n      presets: {\n        '4k-enthusiast': {\n          name: '4K发烧友',\n          description: '追求最高画质，优先4K',\n        },\n        'balanced': {\n          name: '平衡模式',\n          description: '画质与存储空间的平衡选择',\n        },\n        'space-saver': {\n          name: '节省空间',\n          description: '优先较小文件，节省存储空间',\n        },\n        'free-priority': {\n          name: '免费优先',\n          description: '优先免费资源，其它的没有要求',\n        },\n      },\n    },\n  },\n}\n"
  },
  {
    "path": "src/locales/zh-TW.ts",
    "content": "export default {\n  common: {\n    confirm: '確認',\n    cancel: '取消',\n    save: '儲存',\n    close: '關閉',\n    version: '版本',\n    author: '作者',\n    delete: '刪除',\n    edit: '編輯',\n    add: '添加',\n    search: '搜索',\n    loading: '加載中',\n    success: '成功',\n    error: '錯誤',\n    openInNewWindow: '在新窗口中打開',\n    inputMessage: '輸入消息或命令',\n    send: '發送',\n    noData: '暫無數據',\n    noContent: '沒有找到相關內容',\n    all: '全部',\n    active: '激活',\n    inactive: '未激活',\n    filter: '篩選',\n    noMatchingData: '沒有符合條件的數據',\n    tryChangingFilters: '請嘗試更改篩選條件',\n    default: '默認',\n    name: '名稱',\n    create: '新建',\n    saving: '保存中',\n    reset: '重置',\n    theme: '主題',\n    uiMode: '界面佈局',\n    language: '語言',\n    pleaseWait: '請稍候...',\n    viewDetails: '查看詳情',\n    user: '用戶',\n    config: '配置',\n    pause: '暫停',\n    enable: '啟用',\n    confirmAction: '確認{action}',\n    details: '詳情',\n    files: '文件',\n    share: '分享',\n    subscribe: '訂閱',\n    unsubscribe: '取消訂閱',\n    media: '媒體',\n    unknown: '未知',\n    notFetched: '未獲取',\n    notice: '注意',\n    itemsPerPage: '每頁條數',\n    pageText: '{0}-{1} 共 {2} 條',\n    noDataText: '沒有數據',\n    next: '下一步',\n    previous: '上一步',\n    skip: '跳過',\n    loadingText: '加載中...',\n    networkRequired: '此功能需要網絡連接',\n    networkDisconnected: '網絡連接已斷開',\n    featuresLimited: '部分功能可能受限',\n    serverConnectionFailed: '服務器連接失敗',\n    troubleshooting: '疑難排解',\n    checking: '檢查中',\n    retry: '重試',\n    networkOnline: '網絡在線',\n    networkOffline: '網絡離線',\n    serviceAvailable: '服務可用',\n    serviceUnavailable: '服務不可用',\n    status: '狀態',\n    preset: '預設',\n    refresh: '刷新',\n    swUpdateReady: '新版本已就緒，請刷新頁面以獲取最新功能',\n    ascending: '升序',\n    descending: '降序',\n    versionMismatch: '瀏覽器快取版本與服務端版本不一致，請嘗試清除快取',\n    clearCache: '清除快取',\n  },\n  mediaType: {\n    movie: '電影',\n    tv: '電視劇',\n    anime: '動漫',\n    collection: '合集',\n    unknown: '未知',\n  },\n  notificationSwitch: {\n    resourceDownload: '資源下載',\n    organize: '整理入庫',\n    subscribe: '訂閱',\n    site: '站點',\n    mediaServer: '媒體伺服器',\n    manual: '手動處理',\n    plugin: '插件',\n    agent: '智能體',\n    other: '其它',\n  },\n  actionStep: {\n    addDownload: '添加下載',\n    addSubscribe: '添加訂閱',\n    fetchDownloads: '獲取下載任務',\n    fetchMedias: '獲取媒體數據',\n    fetchRss: '獲取RSS資源',\n    fetchTorrents: '獲取站點資源',\n    filterMedias: '過濾媒體數據',\n    filterTorrents: '過濾資源',\n    scanFile: '掃描目錄',\n    scrapeFile: '刮削文件',\n    sendEvent: '發送事件',\n    sendMessage: '發送消息',\n    transferFile: '整理文件',\n    invokePlugin: '調用插件',\n    note: '備註',\n  },\n  qualityOptions: {\n    all: '全部',\n    blurayOriginal: '藍光原盤',\n    remux: 'Remux',\n    bluray: 'BluRay',\n    uhd: 'UHD',\n    webdl: 'WEB-DL',\n    hdtv: 'HDTV',\n    h265: 'H265',\n    h264: 'H264',\n  },\n  resolutionOptions: {\n    all: '全部',\n    '4k': '4k',\n    '1080p': '1080p',\n    '720p': '720p',\n  },\n  effectOptions: {\n    all: '全部',\n    dolbyVision: '杜比視界',\n    dolbyAtmos: '杜比全景聲',\n    hdr: 'HDR',\n    sdr: 'SDR',\n  },\n  theme: {\n    light: '淺色',\n    dark: '深色',\n    auto: '跟隨系統',\n    autoUI: '自動',\n    transparent: '透明',\n    purple: '幻紫',\n    custom: '附加樣式',\n    transparency: '透明度',\n    transparencyAdjust: '透明度調整',\n    transparencyOpacity: '透明度',\n    transparencyBlur: '模糊度',\n    transparencyReset: '重置',\n    transparencyLow: '低透明度',\n    transparencyMedium: '中等透明度',\n    transparencyHigh: '高透明度',\n    customCssSaveSuccess: '自定義CSS保存成功，請刷新頁面生效！',\n    customCssSaveFailed: '保存自定義CSS到服務端失敗',\n    deviceNotSupport: '當前設備不支持監聽系統主題變化',\n  },\n  app: {\n    moviepilot: 'MoviePilot',\n    slogan: '智能影視媒體庫管理工具',\n    recommend: '推薦',\n    subscribeMovie: '電影訂閱',\n    subscribeTv: '電視劇訂閱',\n    settings: '設置',\n    selectLanguage: '選擇語言',\n    logout: '退出登錄',\n    restarting: '正在重啟...',\n    confirmRestart: '確認重啟系統嗎？',\n    restartTip: '重啟後，您將被註銷並需要重新登錄。',\n    restartTimeout: '重啟超時，系統可能需要更長時間恢復，請稍後手動刷新頁面',\n    restartFailed: '重啟失敗，請檢查系統狀態',\n    offline: '應用已離線',\n    offlineMessage: '網絡連接已斷開，部分功能可能受限',\n    online: '應用在線',\n    onlineMessage: '網絡連接已恢復',\n  },\n  pwa: {\n    installApp: '安裝 MoviePilot 應用',\n    installDescription: '獲得更好的離線體驗和性能',\n    install: '安裝',\n    installSuccess: '應用安裝成功！',\n    installGuide: '安裝指南',\n    installInstructions: '在 {platform} 上安裝 MoviePilot：',\n    installNote: '安裝後，您可以從主屏幕快速訪問 MoviePilot，並享受離線功能。',\n    gotIt: '知道了',\n    // 平台特定的說明\n    platforms: {\n      ios: 'iOS',\n      android: 'Android',\n      chrome: 'Chrome',\n      edge: 'Edge',\n      firefox: 'Firefox',\n      safari: 'Safari',\n      desktop: '桌面設備',\n      mobile: '移動設備',\n      other: '其他瀏覽器',\n    },\n    // 安裝步驟\n    installSteps: {\n      ios: {\n        0: '點擊瀏覽器底部的分享按鈕',\n        1: '選擇\"添加到主屏幕\"',\n        2: '點擊\"添加\"確認安裝',\n      },\n      android: {\n        0: '點擊瀏覽器菜單（三個點）',\n        1: '選擇\"添加到主屏幕\"或\"安裝應用\"',\n        2: '點擊\"安裝\"確認',\n      },\n      chrome: {\n        0: '點擊地址欄右側的安裝圖標',\n        1: '或者點擊瀏覽器菜單中的\"安裝 MoviePilot\"',\n        2: '點擊\"安裝\"確認',\n      },\n      edge: {\n        0: '點擊地址欄右側的應用圖標',\n        1: '選擇\"安裝此站點為應用\"',\n        2: '點擊\"安裝\"確認',\n      },\n      firefox: {\n        0: '點擊地址欄右側的安裝圖標',\n        1: '選擇\"安裝\"',\n        2: '確認安裝到桌面',\n      },\n      safari: {\n        0: '點擊分享按鈕',\n        1: '選擇\"添加到主屏幕\"',\n        2: '點擊\"添加\"確認',\n      },\n      desktop: {\n        0: '點擊地址欄右側的安裝圖標',\n        1: '選擇\"安裝應用\"',\n        2: '按照提示完成安裝',\n      },\n      mobile: {\n        0: '點擊瀏覽器菜單',\n        1: '選擇\"添加到主屏幕\"',\n        2: '確認安裝',\n      },\n      other: {\n        0: '查找瀏覽器中的\"安裝\"選項',\n        1: '通常在地址欄或菜單中',\n        2: '按照提示完成安裝',\n      },\n    },\n  },\n  login: {\n    wallpapers: '壁紙',\n    username: '用戶名',\n    password: '密碼',\n    otpCode: '驗證碼',\n    stayLoggedIn: '保持登錄',\n    login: '登錄',\n    networkError: '登錄失敗，請檢查網絡連接！',\n    authFailure: '登錄失敗，請檢查用戶名、密碼或二次驗證是否正確！',\n    permissionDenied: '登錄失敗，您沒有權限訪問！',\n    serverError: '登錄失敗，服務器錯誤！',\n    noPermission: '登錄失敗，您沒有任何功能權限，請聯繫管理員！',\n    loginFailed: '登錄失敗',\n    secondaryVerification: '二次驗證',\n    orDivider: '或',\n    loginWithPasskey: '使用通行密鑰登錄',\n    loginWithOtp: '使用驗證碼登錄',\n    orUsePasskey: '或使用通行密鑰進行驗證',\n    verifyWithPasskey: '使用通行密鑰驗證',\n    otpPlaceholder: '請輸入6位驗證碼',\n    passkeyLoginStartFailed: '啟動通行密鑰驗證失敗',\n    passkeyNotSelected: '未選擇通行密鑰',\n    passkeyLoginFailed: '通行密鑰登錄失敗',\n    passkeyAuthCanceled: '通行密鑰驗證被取消',\n    passkeyNotSupported: '當前瀏覽器不支援通行密鑰',\n    passkeySecureContextRequired: '通行密鑰需要 HTTPS 安全連接',\n    passkeyVerifyFailed: '通行密鑰驗证失敗',\n    passkeyVerifyFailedRetry: '通行密鑰驗证失敗，請重試',\n    mfa: {\n      selectVerificationMethod: '請選擇驗证方式',\n    },\n  },\n  menu: {\n    start: '開始',\n    discovery: '發現',\n    subscribe: '訂閱',\n    organize: '整理',\n    system: '系統',\n  },\n  navItems: {\n    dashboard: '儀表盤',\n    mediaInfo: '媒體庫',\n    recommend: '推薦',\n    site: '站點',\n    search: '搜索',\n    searchResult: '搜索結果',\n    download: '下載',\n    movieSubscribe: '電影訂閱',\n    tvSubscribe: '電視劇訂閱',\n    history: '歷史記錄',\n    transfer: '整理',\n    rename: '重命名',\n    statistic: '統計',\n    setting: '設置',\n    plugin: '插件',\n    user: '用戶',\n    about: '關於',\n    explore: '探索',\n    movie: '電影',\n    tv: '電視劇',\n    workflow: '工作流',\n    calendar: '日曆',\n    downloadManager: '下載管理',\n    mediaOrganize: '媒體整理',\n    fileManager: '文件管理',\n    pluginManager: '插件',\n    siteManager: '站點管理',\n    userManager: '用戶管理',\n    settings: '設定',\n  },\n  settingTabs: {\n    system: {\n      title: '系統',\n      description:\n        '基礎設置、下載器（Qbittorrent、Transmission）、媒體服務器（Emby、Jellyfin、Plex、飛牛影視、綠聯影視）',\n    },\n    directory: {\n      title: '存儲 & 目錄',\n      description: '下載目錄、媒體庫目錄、整理、刮削',\n    },\n    site: {\n      title: '站點',\n      description: '站點同步、站點數據刷新、站點重置',\n    },\n    rule: {\n      title: '規則',\n      description: '自定義規則、優先級規則組、下載規則',\n    },\n    search: {\n      title: '搜索 & 下載',\n      description: '搜索數據源（TheMovieDb、豆瓣、Bangumi）、下載任務標籤、搜索站點',\n    },\n    subscribe: {\n      title: '訂閱',\n      description: '訂閱站點、訂閱模式、訂閱規則、洗版規則',\n    },\n    scheduler: {\n      title: '服務',\n      description: '定時作業',\n    },\n    cache: {\n      title: '緩存',\n      description: '種子緩存、識別媒體數據緩存、圖片文件緩存管理',\n    },\n    notification: {\n      title: '通知',\n      description: '通知渠道（微信、Telegram、Slack、SynologyChat、VoceChat、WebPush）、消息發送範圍',\n    },\n    about: {\n      title: '關於',\n      description: '軟件版本',\n    },\n  },\n  subscribeTabs: {\n    movie: {\n      mysub: '我的訂閱',\n      popular: '熱門訂閱',\n    },\n    tv: {\n      mysub: '我的訂閱',\n      popular: '熱門訂閱',\n      share: '訂閱分享',\n    },\n  },\n  workflowTabs: {\n    list: '我的工作流',\n    share: '工作流分享',\n  },\n  pluginTabs: {\n    installed: '我的插件',\n    market: '插件市場',\n  },\n  discoverTabs: {\n    themoviedb: 'TheMovieDb',\n    douban: '豆瓣',\n    bangumi: 'Bangumi',\n  },\n  user: {\n    admin: '管理員',\n    normal: '普通用戶',\n    active: '激活',\n    inactive: '已停用',\n    noEmail: '未設置郵箱',\n    movieSubscriptions: '電影訂閱',\n    tvSubscriptions: '劇集訂閱',\n    cannotDeleteCurrentUser: '不能刪除當前登入用戶！',\n    confirmDeleteUser: '刪除用戶 {username} 的所有數據，是否確認？',\n    deleteSuccess: '用戶刪除成功',\n    deleteFailed: '用戶刪除失敗！',\n    profile: '個人信息',\n    systemSettings: '系統設定',\n    wizardSettings: '設定向導',\n    siteAuth: '用戶認證',\n    helpDocs: '幫助文檔',\n    about: '關於',\n    restart: '重啟',\n    management: '用戶管理',\n    noUsers: '沒有用戶',\n    clickToAddUser: '點擊添加用戶卡片添加用戶',\n    addUser: '添加用戶',\n    editUser: '編輯用戶',\n    username: '用戶名',\n    usernameHint: '用於登入系統的用戶名',\n    password: '密碼',\n    passwordHint: '請輸入登入密碼',\n    confirmPassword: '確認密碼',\n    confirmPasswordHint: '請再次輸入密碼以確認',\n    role: '角色',\n    email: '郵箱',\n    enabled: '啟用',\n    disabled: '禁用',\n    status: '狀態',\n    operations: '操作',\n  },\n  nav: {\n    more: '更多',\n  },\n  notification: {\n    center: '通知中心',\n    markRead: '設為已讀',\n    empty: '暫無通知',\n    channel: '通知渠道',\n    name: '名稱',\n    nameHint: '通知渠道名稱',\n    type: '類型',\n    typeHint: '通知渠道類型',\n    customTypeHint: '自定義通知類型，用於插件實現場景',\n    customTypePlaceholder: 'custom',\n    nameRequired: '請輸入名稱',\n    enabled: '啟用',\n    config: '配置',\n    wechat: {\n      name: '企業微信',\n      useBotMode: '使用智能機器人',\n      useBotModeHint: '開啟後使用智能機器人長連線，固定 dmPolicy=open、groupPolicy=disabled',\n      corpId: '企業ID',\n      corpIdHint: '企業微信後台企業信息中的企業ID',\n      corpIdRequired: '企業ID不能為空',\n      appId: '應用 AgentId',\n      appIdHint: '企業微信自建應用的AgentId',\n      appIdRequired: '應用AgentId不能為空',\n      appSecret: '應用 Secret',\n      appSecretHint: '企業微信自建應用的Secret',\n      appSecretRequired: '應用Secret不能為空',\n      proxy: '代理地址',\n      proxyHint: '微信消息的轉發代理地址，2022年6月20日後創建的自建應用才需要，不使用代理時需要保留默認值',\n      token: 'Token',\n      tokenHint: '微信企業自建應用->API接收消息配置中的Token',\n      encodingAesKey: 'EncodingAESKey',\n      encodingAesKeyHint: '微信企業自建應用->API接收消息配置中的EncodingAESKey',\n      botId: '機器人 BotID',\n      botIdHint: '企業微信智能機器人的 BotID',\n      botSecret: '機器人 Secret',\n      botSecretHint: '企業微信智能機器人長連線專用 Secret',\n      botChatId: '預設通知目標',\n      botChatIdHint: '可填寫使用者 userid；如需主動發群消息可填寫 group:群聊chatid，不填則預設發給已互動使用者',\n      botChatIdPlaceholder: 'userid 或 group:chatid',\n      botWsUrl: '長連線地址',\n      botWsUrlHint: '企業微信智能機器人 WebSocket 位址，通常使用預設值',\n      admins: '管理員白名單',\n      adminsHint: '可使用管理菜單及命令的用戶ID列表，多個ID使用,分隔',\n      adminsPlaceholder: '用戶ID列表，多個ID使用,分隔',\n    },\n    telegram: {\n      name: 'Telegram',\n      token: 'Bot Token',\n      tokenHint: 'Telegram機器人token，格式：123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',\n      tokenRequired: 'Bot Token不能為空',\n      chatId: 'Chat ID',\n      chatIdHint: '接受消息通知的用戶、群組或頻道Chat ID',\n      chatIdRequired: 'Chat ID不能為空',\n      users: '用戶白名單',\n      usersHint: '可使用Telegram機器人的用戶ID清單，多個用戶用,分隔，不填寫則所有用戶都能使用',\n      admins: '管理員白名單',\n      adminsHint: '可使用管理菜單及命令的用戶ID列表，多個ID使用,分隔',\n      adminsPlaceholder: '用戶ID列表，多個ID使用,分隔',\n      usersPlaceholder: '用戶ID列表，多個ID使用,分隔',\n      apiUrl: '代理API地址',\n      apiUrlHint: '自定義代理API地址，格式：https://api.telegram.org',\n      apiUrlPlaceholder: 'https://api.telegram.org',\n    },\n    slack: {\n      name: 'Slack',\n      oauthToken: 'Slack Bot User OAuth Token',\n      oauthTokenHint: 'Slack應用`OAuth & Permissions`頁面中的`Bot User OAuth Token`',\n      oauthTokenRequired: 'OAuth Token不能為空',\n      appToken: 'Slack App-Level Token',\n      appTokenHint: 'Slack應用`OAuth & Permissions`頁面中的`App-Level Token`',\n      channel: '頻道名稱',\n      channelHint: '消息發送頻道，默認`全體`',\n      channelRequired: '頻道名稱不能為空',\n    },\n    discord: {\n      name: 'Discord',\n      botToken: 'Bot Token',\n      botTokenHint: 'Discord Bot Token（需在開發者後台開啟 Message Content Intent）',\n      botTokenRequired: 'Bot Token不能為空',\n      guildId: '伺服器 ID',\n      guildIdHint: '可選，限制使用的伺服器；空白則使用已加入的任意伺服器',\n      guildIdPlaceholder: '123456789012345678',\n      channelId: '頻道 ID',\n      channelIdHint: '可選，預設廣播頻道；空白則自動選擇可發送消息的頻道',\n      channelIdPlaceholder: '123456789012345678',\n    },\n    synologychat: {\n      name: 'Synology Chat',\n      webhook: '機器人傳入URL',\n      webhookHint: 'Synology Chat機器人傳入URL',\n      webhookRequired: 'Webhook URL不能為空',\n      token: '令牌',\n      tokenHint: 'Synology Chat機器人令牌',\n    },\n    vocechat: {\n      name: 'VoceChat',\n      host: '地址',\n      hostHint: 'VoceChat服務端地址，格式：http(s)://ip:port',\n      hostRequired: '地址不能為空',\n      apiKey: '機器人密鑰',\n      apiKeyHint: 'VoceChat機器人密鑰',\n      apiKeyRequired: 'API密鑰不能為空',\n      channelId: '頻道ID',\n      channelIdHint: 'VoceChat的頻道ID，不包含#號',\n    },\n    webpush: {\n      name: 'WebPush',\n      username: '登錄用戶名',\n      usernameHint: '只有對應的用戶登錄後才會推送消息',\n      usernameRequired: '用戶名不能為空',\n    },\n    qqbot: {\n      name: 'QQ',\n      appId: 'AppID',\n      appIdHint: 'QQ 開放平台機器人 AppID',\n      appIdRequired: 'AppID 不能為空',\n      appSecret: 'AppSecret',\n      appSecretHint: 'QQ 開放平台機器人 AppSecret',\n      appSecretRequired: 'AppSecret 不能為空',\n      openId: '用戶 OpenID',\n      openIdHint: '默認接收者 openid（單聊），用戶需曾與機器人交互過',\n      openIdPlaceholder: '32位十六進制',\n      groupOpenId: '群組 OpenID',\n      groupOpenIdHint: '默認群組 openid（群聊），與用戶 OpenID 二選一',\n      groupOpenIdPlaceholder: '群組 openid',\n    },\n  },\n  shortcut: {\n    title: '捷徑',\n    recognition: {\n      title: '識別',\n      subtitle: '名稱識別測試',\n    },\n    rule: {\n      title: '規則',\n      subtitle: '規則測試',\n    },\n    log: {\n      title: '日誌',\n      subtitle: '實時日誌',\n    },\n    network: {\n      title: '網絡',\n      subtitle: '網速連通性測試',\n    },\n    system: {\n      title: '系統',\n      subtitle: '健康檢查',\n    },\n    message: {\n      title: '消息',\n      subtitle: '消息中心',\n    },\n    words: {\n      title: '詞表',\n      subtitle: '詞表設置',\n    },\n    cache: {\n      title: '緩存',\n      subtitle: '管理緩存',\n    },\n    scheduler: {\n      title: '服務',\n      subtitle: '定時服務',\n    },\n  },\n  workflow: {\n    components: '動作組件',\n    clickToAdd: '點擊添加',\n    dragToCanvas: '拖曳至畫布',\n    tapComponentHint: '點擊組件添加到畫布',\n    dragComponentHint: '拖曳組件到畫布',\n    task: {\n      edit: '編輯任務',\n      editFlow: '編輯流程',\n      share: '分享',\n      continue: '繼續',\n      restart: '重新開始',\n      run: '立即執行',\n      reset: '重置任務',\n      delete: '刪除任務',\n      confirmDelete: '確定要刪除任務 {name} 嗎？',\n      confirmReset: '確定要重置任務 {name} 嗎？',\n      deleteSuccess: '任務刪除成功！',\n      deleteFailed: '刪除任務失敗：{message}',\n      enableSuccess: '任務啟用成功！',\n      enableFailed: '啟用任務失敗：{message}',\n      pauseSuccess: '任務暫停成功！',\n      pauseFailed: '暫停任務失敗：{message}',\n      runSuccess: '任務執行完成！',\n      runFailed: '任務執行失敗：{message}',\n      resetSuccess: '任務重置成功！',\n      resetFailed: '重置任務失敗：{message}',\n      status: {\n        success: '成功',\n        running: '執行中',\n        failed: '失敗',\n        paused: '已暫停',\n        waiting: '等待中',\n      },\n      info: {\n        trigger: '觸發方式',\n        timer: '定時器',\n        status: '狀態',\n        actionCount: '動作數量',\n        runCount: '執行次數',\n        progress: '進度',\n        error: '錯誤訊息',\n        manualTrigger: '手動',\n      },\n    },\n    scanFile: {\n      title: '掃描目錄',\n      subtitle: '掃描目錄文件到隊列',\n      storage: '存儲',\n      directory: '目錄',\n    },\n    addDownload: {\n      title: '添加下载',\n      subtitle: '添加资源到下载器',\n      downloader: '下载器',\n      category: '分类',\n      savePath: '保存路径',\n      sequential: '顺序下载',\n      forceResume: '强制继续',\n      firstLastPiece: '优先首尾文件',\n      onlyLack: '仅下载缺失资源',\n      categoryPlaceholder: '多個使用,分隔',\n      savePathPlaceholder: '留空自動',\n    },\n    addSubscribe: {\n      title: '添加订阅',\n      subtitle: '添加资源到订阅',\n      type: '类型',\n      name: '名称',\n      season: '季',\n      episode: '集',\n    },\n    fetchMedias: {\n      title: '獲取媒體數據',\n      subtitle: '獲取榜單等媒體數據列表',\n      source: '來源',\n      searchType: '搜索方式',\n      type: '類型',\n      name: '名稱',\n      year: '年份',\n      ranking: '榜单',\n      api: '插件API',\n      apiPath: 'API路径',\n      selectRanking: '选择榜单',\n      tmdbTrending: '流行趨勢',\n      doubanShowing: '正在熱映',\n      bangumiCalendar: 'Bangumi每日放送',\n      tmdbMovies: 'TMDB熱門電影',\n      tmdbTvs: 'TMDB熱門電視劇',\n      doubanMovieHot: '豆瓣熱門電影',\n      doubanTvHot: '豆瓣熱門電視劇',\n      doubanTvAnimation: '豆瓣熱門動漫',\n      doubanMovies: '豆瓣最新電影',\n      doubanTvs: '豆瓣最新電視劇',\n      doubanMovieTop250: '豆瓣電影TOP250',\n      doubanTvWeeklyChinese: '豆瓣國產劇集榜',\n      doubanTvWeeklyGlobal: '豆瓣全球劇集榜',\n    },\n    filterMedias: {\n      title: '過濾媒體數據',\n      subtitle: '對媒體數據列表進行過濾',\n      type: '類型',\n      name: '名稱',\n      year: '年份',\n      vote: '評分',\n    },\n    scrapeFile: {\n      title: '刮削文件',\n      subtitle: '刮削媒體信息和圖片',\n    },\n    sendEvent: {\n      title: '發送事件',\n      subtitle: '發送任務執行事件',\n    },\n    fetchDownloads: {\n      title: '獲取下載任務',\n      subtitle: '獲取下載隊列中的任務狀態',\n      loop: '循環執行',\n      loopInterval: '循環間隔 (秒)',\n    },\n    fetchRss: {\n      title: '獲取RSS資源',\n      subtitle: '訂閱RSS地址獲取資源',\n      url: 'RSS地址',\n      userAgent: 'User-Agent',\n      timeout: '超時時間',\n      matchMedia: '匹配媒體信息',\n      useProxy: '使用代理',\n    },\n    fetchTorrents: {\n      title: '搜索站點資源',\n      subtitle: '搜索站點種子資源列表',\n      searchType: '搜索方式',\n      searchOptions: {\n        name: '名稱',\n        mediaList: '媒體列表',\n      },\n      name: '名稱',\n      year: '年份',\n      type: '類型',\n      season: '季',\n      sites: '站點',\n      matchMedia: '匹配媒體信息',\n    },\n    sendMessage: {\n      title: '發送消息',\n      subtitle: '發送任務執行消息',\n      channel: '渠道',\n      userId: '用戶ID',\n    },\n    transferFile: {\n      title: '整理文件',\n      subtitle: '整理重命名隊列中的文件',\n      source: '來源',\n      sourceOptions: {\n        fileList: '文件列表',\n        downloads: '下載任務',\n      },\n    },\n    filterTorrents: {\n      title: '過濾資源',\n      subtitle: '對資源列表數據進行過濾',\n      quality: '質量',\n      resolution: '分辨率',\n      effect: '特效',\n      size: '大小範圍',\n      include: '包含（關鍵字、正則式）',\n      exclude: '排除（關鍵字、正則式）',\n      ruleGroups: '過濾規則組',\n    },\n    invokePlugin: {\n      title: '調用插件',\n      subtitle: '調用插件執行特定操作',\n      plugin: '插件',\n      actionid: '動作ID',\n      actionParams: '動作參數',\n      loadPluginSettingFailed: '加載插件設置失敗',\n    },\n    note: {\n      title: '備註',\n      subtitle: '添加流程說明註釋',\n      content: '備註內容',\n      placeholder: '請輸入備註內容...',\n    },\n    title: '工作流',\n    share: '工作流分享',\n    searchShares: '搜索工作流分享',\n    noShareData: '暫無分享的工作流',\n    sharer: '分享人',\n    trigger: '觸發方式',\n    timer: '定時器',\n    manualTrigger: '手動觸發',\n    actionCount: '動作數量',\n    normalFork: '復用工作流',\n    cancelShare: '取消分享',\n    cancelSuccess: '取消分享成功',\n    cancelFailed: '取消分享失敗：{message}',\n    usageCount: '復用 {count} 次',\n    addSuccess: '復用 {name} 成功！',\n    addFailed: '復用 {name} 失敗：{message}',\n    noWorkflow: '沒有工作流',\n    noWorkflowDescription: '點擊添加按鈕創建工作流任務。',\n  },\n  dashboard: {\n    storage: '存儲空間',\n    mediaStatistic: '媒體統計',\n    weeklyOverview: '最近入庫',\n    realTimeSpeed: '實時速率',\n    scheduler: '後台任務',\n    cpu: 'CPU',\n    memory: '內存',\n    network: '網絡流量',\n    upload: '上行',\n    download: '下行',\n    library: '我的媒體庫',\n    playing: '繼續觀看',\n    latest: '最近添加',\n    settings: '設置儀表板',\n    chooseContent: '選擇您想在頁面顯示的內容',\n    adaptiveHeight: '自適應組件高度',\n    current: '當前',\n    episodes: '劇集',\n    users: '用戶',\n    noSchedulers: '沒有後台服務',\n    weeklyOverviewDescription: '最近一週入庫了 {count} 部影片',\n    speed: {\n      totalUpload: '總上傳量',\n      totalDownload: '總下載量',\n      freeSpace: '磁盤剩餘空間',\n    },\n    processes: {\n      title: '系統進程',\n      pid: '進程ID',\n      name: '進程名稱',\n      runtime: '運行時間',\n      memory: '內存佔用',\n    },\n    errors: {\n      loadMediaServer: '加載媒體服務器設置失敗:',\n      loadLatest: '加載媒體服務器 \"{server}\" 的最近入庫失敗:',\n    },\n  },\n  media: {\n    status: {\n      inLibrary: '已入庫',\n      missing: '缺失',\n      partiallyMissing: '部分缺失',\n      subscribed: '已訂閱',\n    },\n    minutes: '分鐘',\n    overview: '簡介',\n    seasons: '季',\n    seasonNumber: '第 {number} 季',\n    episodeCount: '{count}集',\n    actions: {\n      searchResource: '搜索資源',\n      subscribe: '訂閱',\n      playOnline: '線上播放',\n      playInApp: 'APP播放',\n      playInWeb: '網頁播放',\n    },\n    search: {\n      byTitle: '標題',\n      byImdb: 'IMDB鏈接',\n    },\n    info: {\n      originalTitle: '原始標題',\n      status: '狀態',\n      releaseDate: '上映日期',\n      digitalRelease: '數位發行',\n      physicalRelease: '實體發行',\n      originalLanguage: '原始語言',\n      productionCountries: '出品國家',\n      productionCompanies: '製作公司',\n      doubanId: '豆瓣ID',\n    },\n    subscribe: {\n      normal: '訂閱',\n      bestVersion: '洗版訂閱',\n      addFailed: '添加訂閱失敗：{reason}！',\n      canceled: '已取消訂閱！',\n      cancelFailed: '取消訂閱失敗：{reason}！',\n    },\n    castAndCrew: '演員陣容',\n    recommendations: '推薦',\n    similar: '類似',\n    error: {\n      title: '出錯啦！',\n      noMediaInfo: '未識別到媒體信息。',\n    },\n    server: {\n      plex: 'Plex',\n      jellyfin: 'Jellyfin',\n      emby: 'Emby',\n      appLaunchFailed: 'APP啟動失敗，正在跳轉到網頁版',\n      appNotInstalled: '未檢測到APP，正在跳轉到網頁版',\n      downloadApp: '下載APP',\n    },\n  },\n  subscribe: {\n    normalSub: '訂閱',\n    versionSub: '洗版訂閱',\n    addSuccess: '添加{name}成功！',\n    addFailed: '添加{name}失敗：{message}！',\n    cancelSuccess: '已取消訂閱！',\n    cancelFailed: '取消訂閱失敗：{message}！',\n    filterSubscriptions: '篩選訂閱',\n    name: '名稱',\n    searchShares: '搜索訂閱分享',\n    keyword: '關鍵詞',\n    noShareData: '未獲取到分享訂閱數據，未開啟數據分享或服務器無法連接。',\n    noPopularData: '未獲取到熱門訂閱數據，未開啟數據分享或服務器無法連接。',\n    noFilterData: '沒有篩選到相關內容，請更換篩選條件。',\n    noSubscribeData: '請通過搜索添加電影、電視劇訂閱。',\n    sharer: '分享人',\n    follow: '關注',\n    unfollow: '取消關注',\n    recognitionWords: '識別詞',\n    cancelShare: '取消分享',\n    usageCount: '共 {count} 次複用',\n    confirmToggle: '是否{action}訂閱 {name}？',\n    toggleSuccess: '{name} 已{action}！',\n    toggleFailed: '{action}失敗：{message}',\n    resetConfirm: '重置後 {name} 將恢復初始狀態，已下載記錄將被清除，未入庫的內容將會重新下載，是否確認？',\n    resetSuccess: '{name} 重置成功！',\n    resetFailed: '{name} 重置失敗：{message}',\n    shareStatistics: '分享統計',\n    shareCount: '個分享',\n    totalReuseCount: '次複用',\n    ranking: '排名',\n    noStatisticsData: '暫無分享統計數據',\n    bestVersion: '洗版中',\n    completed: '訂閱完成',\n    subscribing: '訂閱中',\n    notStarted: '未開始',\n    pending: '待定',\n    paused: '暫停',\n    selectedCount: '已選擇 {count}/{total} 項',\n    noSelectedItems: '請先選擇要操作的訂閱',\n    batchEnable: '批量啟用',\n    batchPause: '批量暫停',\n    batchDelete: '批量刪除',\n    batchEnableConfirm: '確定要啟用選中的 {count} 個訂閱嗎？',\n    batchPauseConfirm: '確定要暫停選中的 {count} 個訂閱嗎？',\n    batchDeleteConfirm: '確定要刪除選中的 {count} 個訂閱嗎？此操作不可恢復！',\n    batchEnableSuccess: '成功啟用 {count} 個訂閱',\n    batchPauseSuccess: '成功暫停 {count} 個訂閱',\n    batchDeleteSuccess: '成功刪除 {count} 個訂閱',\n    batchEnableFailed: '啟用失敗 {count} 個訂閱',\n    batchPauseFailed: '暫停失敗 {count} 個訂閱',\n    batchDeleteFailed: '刪除失敗 {count} 個訂閱',\n    batchEnableError: '批量啟用操作失敗',\n    batchPauseError: '批量暫停操作失敗',\n    batchDeleteError: '批量刪除操作失敗',\n    minSubscribers: '最小訂閱人數',\n  },\n  recommend: {\n    all: '全部',\n    categoryMovie: '電影',\n    categoryTV: '電視劇',\n    categoryAnime: '動漫',\n    categoryRankings: '榜單',\n    trendingNow: '流行趨勢',\n    nowShowing: '正在熱映',\n    bangumiDaily: 'Bangumi每日放送',\n    tmdbHotMovies: 'TMDB熱門電影',\n    tmdbHotTVShows: 'TMDB熱門電視劇',\n    doubanHotMovies: '豆瓣熱門電影',\n    doubanHotTVShows: '豆瓣熱門電視劇',\n    doubanHotAnime: '豆瓣熱門動漫',\n    doubanNewMovies: '豆瓣最新電影',\n    doubanNewTVShows: '豆瓣最新電視劇',\n    doubanTop250: '豆瓣電影TOP250',\n    doubanChineseTVRankings: '豆瓣國產劇集榜',\n    doubanGlobalTVRankings: '豆瓣全球劇集榜',\n    noCategoryContent: '當前分類下沒有可顯示的內容',\n    configureContent: '設置顯示內容',\n    customizeContent: '自定義內容',\n    selectContentToDisplay: '選擇您想在頁面顯示的內容',\n    selectAll: '全選',\n    selectNone: '全不選',\n  },\n  discover: {\n    setTabOrder: '設置標籤順序',\n    dragToReorder: '拖動對標籤頁進行排序',\n  },\n  downloading: {\n    noDownloader: '沒有下載器',\n    configureDownloader: '請先在設置中正確配置並啟用下載器。',\n    title: '下載',\n    noTask: '沒有任務',\n    noTaskDescription: '正在下載的任務將會顯示在這裡。',\n  },\n  resource: {\n    searchResults: '資源搜索結果',\n    keyword: '關鍵詞',\n    title: '標題',\n    year: '年份',\n    season: '季',\n    switchingView: '切換視圖',\n    backToHome: '返回首頁',\n    searching: '正在搜索，請稍候...',\n    noData: '沒有數據',\n    noResourceFound: '未搜索到任何資源',\n    aiRecommend: '智能推薦',\n    reRecommend: '重新生成推薦',\n    aiRecommendError: '智能推薦失敗',\n  },\n  browse: {\n    actor: '演員',\n  },\n  appcenter: {\n    others: '其他',\n  },\n  notFound: {\n    title: '⚠️ 頁面不存在',\n    description: '您想要訪問的頁面不存在，請檢查地址是否正確。',\n    backButton: '返回',\n  },\n  torrent: {\n    selectAll: '全選',\n    clear: '清除',\n    clearFilters: '清除篩選',\n    confirm: '確定',\n    resources: '個資源',\n    noResults: '沒有找到匹配的資源',\n    sortDefault: '默認',\n    sortSite: '站點',\n    sortSize: '大小',\n    sortSeeder: '做種數',\n    sortPublishTime: '發布時間',\n    filterSite: '站點',\n    filterSeason: '季',\n    filterFreeState: '促銷狀態',\n    filterVideoCode: '視頻編碼',\n    filterEdition: '质量',\n    filterResolution: '分辨率',\n    filterReleaseGroup: '製作組',\n    noMatchingResults: '沒有數據',\n    allFilters: '綜合篩選',\n    clearAll: '清除全部',\n  },\n  calendar: {\n    episode: '第{number}集',\n  },\n  storage: {\n    name: '名稱',\n    type: '類型',\n    customTypeHint: '自定義存儲類型，用於插件等場景',\n    usedPercent: '已使用 {percent}%',\n    noConfigNeeded: '此存儲類型無需配置參數，請直接配置目錄！',\n    notConfigured: '未配置',\n    local: '本地',\n    alipan: '阿里雲盤',\n    u115: '115網盤',\n    rclone: 'RClone',\n    alist: 'OpenList',\n    smb: 'SMB網路共享',\n    custom: '自定義',\n  },\n\n  filterRules: {\n    specSub: '特效字幕',\n    cnSub: '中文字幕',\n    cnVoi: '國語配音',\n    gz: '官種',\n    notCnVoi: '排除: 國語配音',\n    hkVoi: '粵語配音',\n    notHkVoi: '排除: 粵語配音',\n    free: '促銷: 免費',\n    resolution4k: '解析度: 4K',\n    resolution1080p: '解析度: 1080P',\n    resolution720p: '解析度: 720P',\n    not720p: '排除: 720P',\n    qualityBlu: '品質: 藍光原盤',\n    notBlu: '排除: 藍光原盤',\n    qualityBluray: '品質: BLURAY',\n    notBluray: '排除: BLURAY',\n    qualityUhd: '品質: UHD',\n    notUhd: '排除: UHD',\n    qualityRemux: '品質: REMUX',\n    notRemux: '排除: REMUX',\n    qualityWebdl: '品質: WEB-DL',\n    notWebdl: '排除: WEB-DL',\n    quality60fps: '品質: 60fps',\n    not60fps: '排除: 60fps',\n    codecH265: '編碼: H265',\n    notH265: '排除: H265',\n    codecH264: '編碼: H264',\n    notH264: '排除: H264',\n    effectDolby: '效果: 杜比視界',\n    notDolby: '排除: 杜比視界',\n    effectAtmos: '效果: 杜比全景聲',\n    notAtmos: '排除: 杜比全景聲',\n    effectHdr: '效果: HDR',\n    notHdr: '排除: HDR',\n    effectSdr: '效果: SDR',\n    notSdr: '排除: SDR',\n    effect3d: '效果: 3D',\n    not3d: '排除: 3D',\n  },\n  transferType: {\n    copy: '複製',\n    move: '移動',\n    link: '硬連結',\n    softlink: '軟連結',\n  },\n  site: {\n    noSites: '沒有站點',\n    noFilterData: '沒有符合條件的站點',\n    sitesWillBeShownHere: '已添加並支持的站點將會在這裡顯示。',\n    title: '站點',\n    status: {\n      enabled: '啟用',\n      disabled: '停用',\n    },\n    fields: {\n      url: '站點地址',\n      priority: '優先級',\n      status: '狀態',\n      rss: 'RSS地址',\n      timeout: '超時時間（秒）',\n      downloader: '下載器',\n      cookie: '站點Cookie',\n      userAgent: '站點User-Agent',\n      authorization: '請求頭（Authorization）',\n      apiKey: '令牌（API Key）',\n      limitAccess: '限制站點訪問頻率',\n      limitInterval: '單位週期（秒）',\n      limitCount: '週期內訪問次數',\n      limitSeconds: '訪問間隔（秒）',\n      useProxy: '使用代理訪問',\n      browserSimulation: '瀏覽器仿真',\n      selectFile: '選擇文件',\n    },\n    hints: {\n      url: '格式：http://www.example.com/',\n      priority: '優先級越小越優先',\n      status: '站點啟用/停用',\n      rss: '訂閱模式為`站點RSS`時使用的訂閱鏈接，如未自動獲取需手動補充',\n      timeout: '站點請求超時時間，為0時不限制',\n      downloader: '此站點使用的下載器',\n      cookie: '站點請求頭中的Cookie信息',\n      userAgent: '獲取Cookie的瀏覽器對應的User-Agent',\n      authorization: '站點請求頭中的Authorization信息，特殊站點需要',\n      apiKey: '站點的訪問API Key，特殊站點需要',\n      limitInterval: '限流控制的單位週期時長',\n      limitCount: '單位週期內允許的訪問次數',\n      limitSeconds: '每次訪問需要間隔的最小時間',\n      useProxy: '使用代理服務器訪問該站點',\n      browserSimulation: '使用瀏覽器模擬真實訪問該站點',\n      import: '批量導入站點數據，支持JSON格式文件',\n      selectFile: '選擇JSON文件',\n      dragDropFile: '拖拽文件到此處或點擊選擇文件',\n      supportedFormat: '支持JSON格式的站點配置文件',\n    },\n    actions: {\n      add: '新增站點',\n      edit: '編輯站點',\n      import: '導入',\n      export: '導出',\n      startImport: '開始導入',\n    },\n    messages: {\n      addSuccess: '新增站點成功',\n      addFailed: '新增站點失敗',\n      updateSuccess: '更新成功',\n      updateFailed: '更新失敗',\n      exportSuccess: '站點導出成功',\n      exportFailed: '站點導出失敗',\n      importSuccess: '成功導入 {count} 個站點',\n      importFailed: '站點導入失敗',\n      importPartialFailed: '導入完成，成功 {success} 個，失敗 {failed} 個',\n      importAllFailed: '導入失敗，{count} 個站點全部導入失敗',\n      noDataToImport: '沒有數據可導入',\n      noValidData: '沒有有效的數據',\n      someInvalidData: '部分數據無效，有效數據 {valid}/{total} 個',\n      invalidFileType: '不支持的文件類型，請選擇JSON文件',\n      invalidFileFormat: '文件格式無效，請檢查文件內容',\n      parseFileError: '文件解析失敗，請檢查文件格式',\n      previewData: '預覽數據 ({count} 個站點)',\n      importing: '正在導入... ({progress}%)',\n      importErrors: '導入過程中出現 {count} 個錯誤',\n    },\n    errors: {\n      loadDownloader: '加載下載器設置失敗',\n      title: '導入錯誤詳情',\n      failed: '導入失敗',\n      details: '錯誤詳情',\n    },\n    results: {\n      successTitle: '成功導入的站點',\n      success: '導入成功',\n    },\n    testConnectivity: '測試連通性',\n    testing: '測試中 ...',\n    testSuccess: '{name} 連通性測試成功，可正常使用！',\n    testFailed: '{name} 連通性測試失敗：{message}',\n    connectionNormal: '連接正常',\n    connectionSlow: '連接緩慢',\n    connectionFailed: '連接失敗',\n    connectionUnknown: '連接未知',\n    deleteConfirm: '是否確認刪除站點？',\n    deleteSuccess: '{name} 刪除成功！',\n    deleteFailed: '{name} 刪除失敗：{message}',\n    browseResources: '瀏覽資源',\n    deleteSite: '刪除站點',\n    updateCookie: '更新Cookie',\n    viewUserData: '查看用戶數據',\n    statistics: '統計信息',\n    totalSites: '總站點數',\n    normalSites: '正常站點',\n    slowSites: '緩慢站點',\n    failedSites: '失敗站點',\n    averageTime: '平均耗時',\n    successRate: '成功率',\n    successCount: '成功次數',\n    failCount: '失敗次數',\n    lastAccess: '最後訪問',\n    timeRecords: '耗時記錄',\n    recentTimeRecords: '最近耗時記錄',\n    accessTime: '訪問時間',\n    responseTime: '響應時間',\n    noTimeRecords: '暫無耗時記錄',\n    preview: {\n      title: '預覽站點',\n      showing: '顯示 {count}/{total}',\n      unnamed: '未命名站點',\n      noUrl: '無站點地址',\n      invalid: '數據無效',\n    },\n  },\n  message: {\n    loadMore: '加載更多',\n    noMoreData: '沒有更多數據',\n  },\n  logging: {\n    level: '級別',\n    time: '時間',\n    program: '程序',\n    content: '內容',\n    refreshing: '正在刷新',\n    initializing: '正在初始化',\n  },\n  moduleTest: {\n    normal: '正常',\n    disabled: '未啟用',\n    error: '錯誤',\n    checking: '正在檢查...',\n    complete: '檢查完成',\n    preparing: '準備檢查...',\n    totalModules: '總模組數',\n    recheck: '重新檢查',\n  },\n  nameTest: {\n    recognize: '識別',\n    recognizing: '識別中...',\n    recognizeAgain: '重新識別',\n    title: '標題',\n    subtitle: '副標題',\n  },\n  netTest: {\n    notTested: '未測試',\n    testing: '測試中...',\n    normal: '正常',\n  },\n  ruleTest: {\n    test: '測試',\n    testing: '正在測試...',\n    testAgain: '重新測試',\n    title: '標題',\n    subtitle: '副標題',\n    ruleGroup: '規則組',\n    priority: '優先級：{value}',\n    noPriorityRule: '未命中任何優先級規則！',\n  },\n  setting: {\n    about: {\n      title: '關於 MoviePilot',\n      softwareVersion: '軟件版本',\n      frontendVersion: '前端版本',\n      browserVersion: '瀏覽器緩存版本',\n      authVersion: '認證資源版本',\n      indexerVersion: '站點資源版本',\n      configDir: '配置目錄',\n      dataDir: '數據目錄',\n      timezone: '時區',\n      latest: '最新',\n      support: '支援',\n      supportingSites: '支持站點',\n      documentation: '文檔',\n      feedback: '問題反饋',\n      channel: '發布頻道',\n      versions: '軟件版本',\n      latestVersion: '最新軟件版本',\n      currentVersion: '當前版本',\n      viewChangelog: '查看變更日誌',\n      changelog: '變更日誌',\n      dataDirectory: '/moviepilot',\n      expand: '展開',\n      collapse: '收起',\n      clearCache: '清除快取',\n    },\n    system: {\n      custom: '自定義',\n      basicSettings: '基礎設置',\n      basicSettingsDesc: '設置服務器的全局功能',\n      appDomain: '訪問域名',\n      appDomainHint: '用於發送通知時，添加快捷跳轉地址',\n      wallpaper: '背景壁紙',\n      wallpaperHint: '選擇登陸頁面背景來源',\n      recognizeSource: '識別數據源',\n      recognizeSourceHint: '設置默認媒體信息識別數據源',\n      mediaServerSyncInterval: '媒體服務器同步間隔',\n      mediaServerSyncIntervalHint: '定時同步媒體服務器數據到本地的時間間隔',\n      hours: '小時',\n      required: '必選項，請勿留空',\n      numbersOnly: '僅支持輸入數字，請勿輸入其他字符',\n      minInterval: '間隔不能小於1個小時',\n      apiToken: 'API令牌',\n      apiTokenHint: '設置外部請求MoviePilot API時使用的token值',\n      apiTokenMinChars: '不能小於16位字符',\n      apiTokenRequired: '必填項；請輸入API Token',\n      apiTokenLength: 'API Token不得低於16位',\n      githubToken: 'Github Token',\n      githubTokenFormat: 'ghp_**** 或 github_pat_****',\n      githubTokenHint: '用於提高插件等訪問Github API時的限流閾值，建議配置，否則插件可能無法正常使用',\n      ocrHost: '驗證碼識別服務器',\n      ocrHostHint: '用於站點簽到、更新站點Cookie等識別驗證碼',\n      aiAgent: '啟用智能助手',\n      aiAgentEnable: '啟用智能助手',\n      aiAgentEnableHint: '啟用後可使用智能助手功能，需要配置LLM相關參數',\n      aiAgentSectionTitle: '智能助手配置',\n      aiAgentSectionDesc: '啟用後可在消息對話中使用 Agent 能力，也可開啟失敗整理接管與智能推薦。',\n      llmProvider: 'LLM提供商',\n      llmProviderHint: '選擇使用的LLM服務提供商',\n      llmModel: 'LLM模型名稱',\n      llmModelHint: '指定使用的LLM模型，如gpt-3.5-turbo、deepseek-chat等',\n      llmThinking: '思考模式 / 深度',\n      llmThinkingHint:\n        '思考深度：off/auto/minimal/low/medium/high/max/xhigh；不支援的級別會按 provider 能力自動映射到最近值',\n      llmThinkingLevelOff: '關閉 (off)',\n      llmThinkingLevelAuto: '自動 (auto)',\n      llmThinkingLevelMinimal: '最小 (minimal)',\n      llmThinkingLevelLow: '低 (low)',\n      llmThinkingLevelMedium: '中 (medium)',\n      llmThinkingLevelHigh: '高 (high)',\n      llmThinkingLevelMax: '極高 (max)',\n      llmThinkingLevelXhigh: '超高 (xhigh)',\n      llmSupportImageInput: '模型支援圖片輸入',\n      llmSupportImageInputHint:\n        '啟用後，消息中的圖片會按多模態圖片發送給 LLM；關閉後圖片會作為附件保存到本地，並將檔案路徑提供給智能助手處理',\n      llmMaxContextTokens: 'LLM 最大上下文 Token 數量 (K)',\n      llmMaxContextTokensHint:\n        '設定 LLM 記錄會話歷史的最大 Token 數量上限（千），超出後將自動修整歷史記錄以節省 Token 消耗及防止超出 LLM 限制',\n      llmApiKey: 'LLM API密鑰',\n      llmApiKeyHint: 'LLM服務提供商的API密鑰，用於身份驗證',\n      llmApiKeyPlaceholder: '請輸入API密鑰',\n      llmBaseUrl: 'LLM基礎URL',\n      llmBaseUrlHint: 'LLM API的基礎URL地址，用於自定義API端點',\n      llmTestAction: '測試調用',\n      llmTestSuccessToast: 'LLM 調用測試成功',\n      llmTestFailedToast: 'LLM 調用測試失敗',\n      llmTestFailedToastWithMessage: 'LLM 調用測試失敗：{message}',\n      aiAgentGlobal: '全局智能助手',\n      aiAgentGlobalHint: '啟用全局智能助手功能，所有消息對話均使用智能體回答而不用使用/ai命令',\n      aiAgentJobInterval: '定時喚醒',\n      aiAgentJobIntervalHint: '設置定時喚醒的檢查間隔，選擇「不啟用」則不執行定時任務',\n      aiAgentVerbose: '囉嗦模式',\n      aiAgentVerboseHint: '開啟後會在智能體回覆時顯示工具調用過程',\n      aiAgentJobIntervalDisabled: '不啟用',\n      aiAgentJobInterval1h: '1小時',\n      aiAgentJobInterval3h: '3小時',\n      aiAgentJobInterval6h: '6小時',\n      aiAgentJobInterval12h: '12小時',\n      aiAgentJobInterval24h: '24小時',\n      aiAgentJobInterval1w: '1週',\n      aiAgentJobInterval1M: '1個月',\n      advancedSettings: '高級設置',\n      advancedSettingsDesc: '系統進階設置，特殊情況下才需要調整',\n      downloaders: '下載器',\n      downloadersDesc: '只有默認下載器才會被默認使用。',\n      aiAgentRetryTransfer: '檔案整理失敗智能接管',\n      aiAgentRetryTransferHint:\n        '啟用後，當檔案整理失敗時，智能助手將自動接管並嘗試重新整理，利用AI能力解決識別和匹配問題',\n      aiRecommendEnabled: '搜索結果智能推薦',\n      aiRecommendEnabledHint:\n        '啟用搜索結果智能推薦功能，開啟後將在搜索結果頁面顯示智能推薦按鈕，可根據用戶偏好智能推薦資源',\n      aiRecommendUserPreference: '用戶偏好',\n      aiRecommendUserPreferenceHint: '設置智能推薦時的用戶偏好，例如：4K WEB-DL Dolby Vision',\n      aiRecommendMaxItems: '智能推薦分析條目上限',\n      aiRecommendMaxItemsHint:\n        '限制發送給智能助手進行分析的搜索結果數量，數量越多分析越慢且消耗 Token 越多，建議先手動篩選，篩選出大致範圍後再進行智能推薦',\n      mediaServers: '媒體服務器',\n      mediaServersDesc: '所有啟用的媒體服務器都會被使用。',\n      trimeMedia: '飛牛影視',\n      system: '系統',\n      media: '媒體',\n      network: '網絡',\n      log: '日誌',\n      lab: '實驗室',\n      downloaderSaveSuccess: '下載器設置保存成功',\n      downloaderSaveFailed: '下載器設置保存失敗！',\n      defaultDownloaderNotice: '未設置默認下載器，已將【{name}】作為默認下載器',\n      mediaServerSaveSuccess: '媒體服務器設置保存成功',\n      mediaServerSaveFailed: '媒體服務器設置保存失敗！',\n      saveFailed: '設置保存失敗：{message}！',\n      basicSaveSuccess: '基礎設置保存成功',\n      advancedSaveSuccess: '高級設置保存成功',\n      copySuccess: '已複製到剪貼板！',\n      copyFailed: '複製失敗：可能是瀏覽器不支持或被用戶阻止！',\n      copyError: '複製失敗！',\n      reloading: '正在應用配置...',\n      qbittorrent: 'Qbittorrent',\n      transmission: 'Transmission',\n      rtorrent: 'rTorrent',\n      emby: 'Emby',\n      jellyfin: 'Jellyfin',\n      plex: 'Plex',\n      ugreen: '綠聯影視',\n      reloadSuccess: '系統配置已生效',\n      reloadFailed: '重載系統失敗！',\n      auxAuthEnable: '用戶輔助認證',\n      auxAuthEnableHint: '允許外部服務進行登錄認證以及自動創建用戶',\n      globalImageCache: '全局圖片緩存',\n      globalImageCacheHint: '將媒體圖片緩存到本地，提升圖片加載速度',\n      subscribeStatisticShare: '分享訂閱數據',\n      subscribeStatisticShareHint: '分享訂閱統計數據到熱門訂閱，供其他MPer參考',\n      pluginStatisticShare: '上報插件安裝數據',\n      pluginStatisticShareHint: '上報插件安裝數據給服務器，用於統計展示插件安裝情況',\n      workflowStatisticShare: '分享工作流數據',\n      workflowStatisticShareHint: '分享工作流統計數據到熱門工作流，供其他MPer參考',\n      bigMemoryMode: '大內存模式',\n      bigMemoryModeHint: '使用更大的內存緩存數據，提升系統性能',\n      dbWalEnable: '數據庫WAL模式',\n      dbWalEnableHint: '可提升讀寫併發性能，但可能在異常情況下增加數據丟失風險，更改後需重啟生效',\n      tmdbApiDomain: 'TMDB API服務地址',\n      tmdbApiDomainPlaceholder: 'api.themoviedb.org',\n      tmdbApiDomainHint: '自定義themoviedb API域名或代理地址',\n      tmdbApiDomainRequired: '請輸入TMDB API域名',\n      tmdbImageDomain: 'TMDB 圖片服務地址',\n      tmdbImageDomainPlaceholder: 'image.tmdb.org',\n      tmdbImageDomainHint: '自定義themoviedb圖片服務域名或代理地址',\n      tmdbImageDomainRequired: '請輸入圖片服務域名',\n      tmdbLocale: 'TMDB 元數據語言',\n      tmdbLocalePlaceholder: 'zh',\n      tmdbLocaleHint: '自定義themoviedb元數據語言',\n      metaCacheExpire: '媒體元數據緩存過期時間',\n      metaCacheExpireHint: '識別元數據本地緩存時間，為 0 時使用內置默認值',\n      metaCacheExpireRequired: '請輸入元數據緩存時間',\n      metaCacheExpireMin: '元數據緩存時間必須大於等於0',\n      scrapFollowTmdb: '跟隨TMDB識別整理',\n      scrapFollowTmdbHint: '關閉時以整理歷史記錄為準（如有），避免TMDB數據在訂閱中途修改',\n      scrapOriginalImage: 'TMDB 刮削原語种圖片',\n      scrapOriginalImageHint: '刮削原語种圖片，否则數據元数据語种圖片',\n      fanartEnable: 'Fanart圖片數據源',\n      fanartEnableHint: '使用 fanart.tv 的圖片數據',\n      fanartLang: 'Fanart語言',\n      fanartLangHint: '設定Fanart圖片的語言偏好，多選時按優先級順序排列',\n      recognizePluginFirst: '優先使用插件識別',\n      recognizePluginFirstHint: '優先調用插件識別媒體信息，若插件命中則不再調用原生識別',\n      githubProxy: 'Github加速代理',\n      githubProxyPlaceholder: '留空表示不使用代理',\n      githubProxyHint: '使用代理加速Github訪問速度',\n      pipProxy: 'PIP加速代理',\n      pipProxyPlaceholder: '留空表示不使用代理',\n      pipProxyHint: '使用代理加速插件等pip庫安裝速度',\n      dohEnable: 'DNS Over HTTPS',\n      dohEnableHint: '使用DOH對特定域名進行解析，以防止DNS污染',\n      dohResolvers: 'DOH 服務器',\n      dohResolversPlaceholder: 'https://dns.google/dns-query,1.1.1.1',\n      dohResolversHint: 'DNS解析服務器地址，多個地址使用逗號分隔',\n      dohDomains: 'DOH 域名',\n      dohDomainsPlaceholder: 'example.com,example2.com',\n      dohDomainsHint: '使用DOH解析的域名，多個域名使用逗號分隔',\n      debug: '調試模式',\n      debugHint: '啟用調試模式後，日誌將以DEBUG級別記錄，以便排查問題',\n      logLevel: '日誌等級',\n      logLevelHint: '設置日誌記錄的級別，用於控制日誌輸出量',\n      logMaxFileSize: '日誌文件最大容量(MB)',\n      logMaxFileSizeHint: '限制單個日誌文件的最大容量，超出後將自動分割日誌',\n      logMaxFileSizeRequired: '日誌文件最大大小',\n      logMaxFileSizeMin: '日誌文件最大容量必須大於等於1',\n      logBackupCount: '日誌文件最大備份數量',\n      logBackupCountHint: '設置每個模塊日誌文件的最大備份數量，超過後將覆蓋舊日誌',\n      logBackupCountRequired: '請輸入日誌文件最大備份數量',\n      logBackupCountMin: '日誌文件最大備份數量必須大於等於1',\n      logFileFormat: '日誌文件格式',\n      logFileFormatHint: '設置日誌文件的輸出格式，用於自定義日誌的顯示內容',\n      pluginAutoReload: '插件熱加載',\n      pluginAutoReloadHint: '修改插件文件後自動重新加載，開發插件時使用',\n      pluginLocalRepoPaths: '本地插件倉庫路徑',\n      pluginLocalRepoPathsHint: '本地插件倉庫目錄，多個目錄用英文逗號分隔，支持相對路徑和絕對路徑',\n      encodingDetectionPerformanceMode: '編碼探測性能模式',\n      encodingDetectionPerformanceModeHint: '優先提升探測效率，但可能降低編碼探測的準確性',\n      transferThreads: '文件整理線程數',\n      transferThreadsHint: '多線程整理文件可以提高速度，但可能增加系統資源佔用',\n      tokenizedSearch: '分詞搜索',\n      tokenizedSearchHint: '提升整理歷史記錄搜索精度，但可能增加性能開銷和意外結果',\n      tmdbLanguage: {\n        zhCN: '簡體中文',\n        zhTW: '繁體中文',\n        en: '英文',\n      },\n      fanartLanguage: {\n        zh: '中文',\n        en: '英文',\n        ja: '日文',\n        ko: '韓文',\n        de: '德文',\n        fr: '法文',\n        es: '西班牙文',\n        it: '意大利文',\n        pt: '葡萄牙文',\n        ru: '俄文',\n      },\n      logLevelItems: {\n        debug: 'DEBUG - 調試',\n        info: 'INFO - 信息',\n        warning: 'WARNING - 警告',\n        error: 'ERROR - 錯誤',\n        critical: 'CRITICAL - 嚴重',\n      },\n      wallpaperItems: {\n        tmdb: 'TMDB電影海報',\n        bing: 'Bing每日壁紙',\n        mediaserver: '媒體服務器',\n        none: '無壁紙',\n        customize: '自定義',\n      },\n      mb: 'MB',\n      hour: '小時',\n      customizeWallpaperApi: '自定義壁紙API',\n      customizeWallpaperApiHint: '會獲取 API 返回內容中所有安全設置中允許的圖片地址，需要設置安全域名白名單',\n      customizeWallpaperApiRequired: '必填項；請輸出自定義壁紙API',\n      securityImageDomains: '安全圖片域名',\n      securityImageDomainsHint: '允許緩存的圖片域名白名單，用於控制可信任的圖片來源',\n      noSecurityImageDomains: '暫無安全域名',\n      securityImageDomainAdd: '添加域名，如：image.tmdb.org',\n      proxyHost: '代理服務器',\n      proxyHostHint: '設置代理服務器地址，支持：http(s)、socks5、socks5h 等協議',\n      moviePilotAutoUpdate: '自動更新MoviePilot',\n      moviePilotAutoUpdateHint: '重啟時自動更新MoviePilot到最新發行版本',\n      autoUpdateResource: '自動更新站點資源',\n      autoUpdateResourceHint: '重啟時自動檢測和更新站點資源包',\n      // 刮削開關設定\n      scrapingSwitchSettings: '刮削開關設定',\n      scrapingSwitchSettingsDesc: '控制各類媒體檔案的刮削功能開關',\n      movie: '電影',\n      tv: '電視劇',\n      season: '季',\n      episode: '集',\n      movieNfo: 'NFO',\n      moviePoster: '海報',\n      movieBackdrop: '背景圖',\n      movieLogo: 'Logo',\n      movieDisc: '光碟圖',\n      movieBanner: '橫幅圖',\n      movieThumb: '縮略圖',\n      tvNfo: 'NFO',\n      seasonNfo: 'NFO',\n      tvPoster: '海報',\n      tvBackdrop: '背景圖',\n      tvBanner: '橫幅圖',\n      tvLogo: 'Logo',\n      tvThumb: '縮略圖',\n      seasonPoster: '海報',\n      seasonBanner: '橫幅圖',\n      seasonThumb: '縮略圖',\n      episodeNfo: 'NFO',\n      episodeThumb: '縮略圖',\n      scrapingSwitchSaveFailed: '刮削開關設定保存失敗：{message}',\n      scrapingSwitchSaveError: '刮削開關設定保存失敗',\n      policy: {\n        skipDesc: '跳過刮削，不生成該文件',\n        missingOnlyDesc: '僅在缺失時刮削，已存在則保持不變',\n        overwriteDesc: '始終刮削，已存在則覆蓋',\n      },\n    },\n    site: {\n      siteSync: '站點同步',\n      siteSyncDesc: '從CookieCloud快速同步站點數據',\n      enableLocalCookieCloud: '啟用本地CookieCloud服務器',\n      enableLocalCookieCloudHint: '使用內建CookieCloud服務同步站點數據，服務地址為：http://localhost:3000/cookiecloud',\n      serviceAddress: '服務地址',\n      serviceAddressPlaceholder: 'https://movie-pilot.org/cookiecloud',\n      serviceAddressHint: '遠端CookieCloud服務地址，格式：https://movie-pilot.org/cookiecloud',\n      userKey: '用戶KEY',\n      userKeyHint: 'CookieCloud瀏覽器插件生成的用戶KEY',\n      e2ePassword: '端對端加密密碼',\n      e2ePasswordHint: 'CookieCloud瀏覽器插件生成的端對端加密密碼',\n      autoSyncInterval: '自動同步間隔',\n      autoSyncIntervalHint: '從CookieCloud服務器自動同步站點Cookie到MoviePilot的時間間隔',\n      syncBlacklist: '同步域名黑名單',\n      syncBlacklistPlaceholder: '多個域名,分割',\n      syncBlacklistHint: 'CookieCloud同步域名黑名單，多個域名,分割',\n      userAgent: '瀏覽器User-Agent',\n      userAgentHint: 'CookieCloud插件所在的瀏覽器的User-Agent',\n      siteDataRefresh: '站點數據刷新',\n      siteOptions: '站點選項',\n      browserEmulation: '瀏覽器仿真',\n      browserEmulationHint: '站點訪問仿真方式，支援 Playwright 或 FlareSolverr',\n      flaresolverrUrl: 'FlareSolverr 服務地址',\n      flaresolverrUrlHint: '當仿真方式為 FlareSolverr 時生效，例如：http://127.0.0.1:8191',\n      siteDataRefreshInterval: '站點數據刷新間隔',\n      siteDataRefreshIntervalHint: '刷新站點用戶上傳下載等數據的時間間隔',\n      readSiteMessage: '閱讀站點消息',\n      readSiteMessageHint: '刷新數據時讀取站點消息並發送通知',\n      siteReset: '站點重置',\n      confirmReset: '確認刪除所有站點數據並重新同步。',\n      confirmResetHint: '刪除所有站點數據並重新從CookieCloud同步，操作請先清空涉及站點的相關設置。',\n      resetSites: '重置站點數據',\n      resettingSites: '正在重置...',\n      syncInterval: {\n        hourly: '每小時',\n        every6Hours: '每6小時',\n        every12Hours: '每12小時',\n        daily: '每天',\n        weekly: '每週',\n        monthly: '每月',\n        never: '永不',\n      },\n      saveSuccess: '保存站點設置成功',\n      saveFailed: '站點設置保存失敗！',\n      resetSuccess: '站點重置成功，請等待CookieCloud同步完成！',\n      resetFailed: '站點重置失敗！',\n    },\n    notification: {\n      channels: '通知渠道',\n      channelsDesc: '設置消息發送渠道參數',\n      organizeSuccess: '資源入庫',\n      downloadAdded: '資源下載',\n      subscribeAdded: '添加訂閱',\n      subscribeComplete: '訂閱完成',\n      templateConfigTitle: '通知模板',\n      templateConfigDesc: '設置通知模板，支持Jinja2語法。',\n      templateSaveFailed: '模板保存失敗！',\n      templateSaveSuccess: '模板保存成功',\n      templateLoadFailed: '模板載入失敗！',\n      scope: '通知發送範圍',\n      scopeDesc: '對應消息類型只會發送給設定的用戶。',\n      messageType: '消息類型',\n      scopeRange: '範圍',\n      operationUserOnly: '僅操作用戶',\n      adminOnly: '僅管理員',\n      userAndAdmin: '操作用戶和管理員',\n      allUsers: '所有用戶',\n      sendTime: '通知發送時間',\n      sendTimeDesc: '設定消息發送的時間範圍。',\n      startTime: '開始時間',\n      endTime: '結束時間',\n      saveSuccess: '通知設置保存成功',\n      saveFailed: '通知設置保存失敗！',\n      switchSaveSuccess: '消息類型開關保存成功',\n      switchSaveFailed: '消息類型開關保存失敗！',\n      timeSaveSuccess: '通知發送時間保存成功',\n      timeSaveFailed: '通知發送時間保存失敗！',\n      channel: '通知',\n      wechat: '微信',\n      resourceDownload: '資源下載',\n      mediaImport: '整理入庫',\n      subscription: '訂閱',\n      site: '站點',\n      mediaServer: '媒體服務器',\n      manualProcess: '手動處理',\n      plugin: '插件',\n      other: '其它',\n      telegram: 'Telegram',\n      slack: 'Slack',\n      synologyChat: 'SynologyChat',\n      voceChat: 'VoceChat',\n      webPush: 'WebPush',\n      qq: 'QQ',\n      custom: '自定義通知',\n    },\n    words: {\n      customIdentifiers: '自定義識別詞',\n      identifiersDesc: '添加規則對種子名或者文件名進行預處理以校正識別',\n      identifiersPlaceholder: '支持正則表達式，特殊字符需要\\\\轉義，一行為一組',\n      identifiersHint: '支持正則表達式，特殊字符需要\\\\轉義，一行為一組',\n      formatTitle: '支持的配置格式（注意空格）：',\n      formatContent:\n        '屏蔽詞\\n' +\n        '被替換詞 => 替換詞\\n' +\n        '前定位詞 <> 後定位詞 >> 集偏移量（EP）\\n' +\n        '被替換詞 => 替換詞 && 前定位詞 <> 後定位詞 >> 集偏移量（EP）\\n' +\n        '其中替換詞支持格式：&#123;[tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx]&#125; 直接指定TMDBID/豆瓣ID識別，其中s、e為季數和集數（可選）',\n      identifierSaveSuccess: '自定義識別詞保存成功',\n      identifierSaveFailed: '自定義識別詞保存失敗！',\n\n      customReleaseGroups: '自定義製作組/字幕組',\n      releaseGroupsDesc: '添加無法識別的製作組/字幕組。',\n      releaseGroupsPlaceholder: '支持正則表達式，特殊字符需要\\\\轉義，一行代表一個製作組/字幕組',\n      releaseGroupsHint: '支持正則表達式，特殊字符需要\\\\轉義，一行代表一個製作組/字幕組',\n      releaseGroupSaveSuccess: '自定義製作組/字幕組保存成功',\n      releaseGroupSaveFailed: '自定義製作組/字幕組保存失敗！',\n\n      customization: '自定義占位符',\n      customizationDesc: '添加自定義占位符識別正則，重命名格式中添加{customization}使用。',\n      customizationPlaceholder: '支持正則表達式，特殊字符需要\\\\轉義，多個匹配對象請換行分隔',\n      customizationHint: '支持正則表達式，特殊字符需要\\\\轉義，多個匹配對象請換行分隔',\n      customizationSaveSuccess: '自定義占位符保存成功',\n      customizationSaveFailed: '自定義占位符保存失敗！',\n\n      transferExcludeWords: '文件整理屏蔽詞',\n      excludeWordsDesc: '目錄名或文件名中包含屏蔽詞時不進行整理。',\n      excludeWordsPlaceholder: '支持正則表達式，特殊字符需要\\\\轉義，一行代表一個屏蔽詞',\n      excludeWordsHint: '支持正則表達式，特殊字符需要\\\\轉義，一行代表一個屏蔽詞',\n      excludeWordsSaveSuccess: '文件整理屏蔽詞保存成功',\n      excludeWordsSaveFailed: '文件整理屏蔽詞保存失敗！',\n    },\n    search: {\n      basicSettings: '基礎設置',\n      basicSettingsDesc: '設定數據源、規則組等基礎信息',\n      recognizeSource: '識別數據源',\n      recognizeSourceDesc: '默認使用TMDB。豆瓣識別中文作品通常更友好，但有些國外作品信息不完整。',\n      themoviedb: 'TheMovieDb',\n      douban: '豆瓣',\n      filterRuleGroup: '過濾規則組',\n      filterRuleGroupDesc: '設置下載過程中使用的過濾規則組。',\n      downloadLabel: '下載任務標籤',\n      downloadLabelDesc: '下載器中的下載標籤，用於過濾查詢。',\n      downloadLabelHint: '支持增加多個標籤，英文逗號分隔',\n      downloadSite: '搜索站點',\n      downloadSiteDesc: '設置指定分類搜索的站點範圍。',\n      movieSites: '電影站點',\n      tvSites: '電視劇站點',\n      animeSites: '動漫站點',\n      saveSites: '保存站點',\n      saveSuccess: '保存搜索設置成功',\n      saveFailed: '搜索設置保存失敗！',\n      saveRuleFailed: '規則保存失敗！',\n      movieCategory: '電影',\n      tvCategory: '電視劇',\n      animeCategory: '動漫',\n      downloadUser: '遠程搜索自動下載用戶',\n      multipleNameSearch: '多名稱資源搜索',\n      multipleNameSearchHint: '使用多個名稱（中文、英文等）搜索站點資源並合並搜索結果，會增加站點訪問頻率',\n      downloadSubtitle: '下載站點字幕',\n      downloadSubtitleHint: '檢查站點資源是否有單獨的字幕文件並自動下載',\n      mediaSource: '媒體搜索數據源',\n      mediaSourceHint: '搜索媒體信息時使用的數據源以及排序',\n      filterRuleGroupHint: '搜索媒體信息時按選定的過濾規則組對結果進行過濾',\n      downloadUserPlaceholder: '用戶ID1,用戶ID2',\n      downloadUserHint: '使用Telegram、微信等搜索時是否自動下載，使用逗號分割，設置為 all 代表所有用戶自動擇優下載',\n      downloadLabelPlaceholder: 'MOVIEPILOT',\n    },\n    directory: {\n      storage: '存儲',\n      storageDesc: '設置本地或網盤存儲',\n      directory: '目錄',\n      mediaType: '媒體類型',\n      directoryDesc: '設置媒體文件整理目錄結構，按先後順序依次匹配。',\n      organizeAndScrap: '整理 & 刮削',\n      organizeAndScrapDesc: '設置重命名格式、刮削選項等。',\n      scrapSource: '刮削數據源',\n      scrapSourceHint: '刮削時的元數據來源',\n      movieRenameFormat: '電影重命名格式',\n      movieRenameFormatHint: '使用Jinja2語法，格式參考：https://jinja.palletsprojects.com/en/3.0.x/templates',\n      tvRenameFormat: '電視劇重命名格式',\n      tvRenameFormatHint: '使用Jinja2語法，格式參考：https://jinja.palletsprojects.com/en/3.0.x/templates',\n      saveSuccess: '存儲設置保存成功',\n      saveFailed: '存儲設置保存失敗！',\n      directorySaveSuccess: '目錄設置保存成功',\n      directorySaveFailed: '目錄設置保存失敗！',\n      organizeSaveSuccess: '整理選項設置保存成功',\n      organizeSaveFailed: '整理選項設置保存失敗！',\n      duplicateDirectoryName: '存在重複目錄名稱！無法保存，請修改！',\n      defaultDirName: '目錄',\n      storageSaveSuccess: '存儲設置保存成功',\n      storageSaveFailed: '存儲設置保存失敗！',\n    },\n    category: {\n      title: '分類策略',\n      subtitle: '配置媒體自動分類規則,按類型、語言、地區等條件自動歸類',\n      movie: '電影 (Movie)',\n      tv: '電視劇 (TV)',\n      name: '分類名稱 (目錄名)',\n      genre: '內容類型 (Genre)',\n      language: '語種 (Language)',\n      languagePlaceholder: '如: zh,cn,en (使用逗號分隔)',\n      country: '國家/地區 (Country)',\n      countryPlaceholder: '如: US,CN,JP',\n      year: '年份 (Year)',\n      yearPlaceholder: '如: 2023, 2020-2024',\n      addMovie: '添加電影分類',\n      addTv: '添加電視劇分類',\n      saveSuccess: '分類策略保存成功',\n      loadFailed: '加載分類配置失敗',\n      saveFailed: '保存失敗: {message}',\n    },\n    rule: {\n      customRules: '自定義規則',\n      customRulesDesc: '自定義優先級規則項',\n      priorityRuleGroups: '優先級規則組',\n      priorityRuleGroupsDesc: '預設優先級規則組，以便在搜索和訂閱中使用。',\n      downloadRules: '下載規則',\n      downloadRulesDesc: '同時命中多個資源時擇優下載。',\n      resourcePriority: '資源優先級',\n      sitePriority: '站點優先級',\n      siteUpload: '站點上傳量',\n      resourceSeeder: '資源做種數',\n      emptyIdError: '存在空ID的規則，無法保存，請修改！',\n      emptyNameError: '存在空名字的規則，無法保存，請修改！',\n      duplicateIdError: '存在重複規則ID！無法保存，請修改！',\n      duplicateNameError: '存在重複規則名稱！無法保存，請修改！',\n      customRuleSaveSuccess: '自定義規則保存成功',\n      customRuleSaveFailed: '自定義規則保存失敗！',\n      emptyGroupNameError: '存在空名字的規則組！無法保存，請修改！',\n      duplicateGroupNameError: '存在重複規則組名稱！無法保存，請修改！',\n      ruleGroupSaveSuccess: '優先級規則組保存成功',\n      ruleGroupSaveFailed: '優先級規則組保存失敗！',\n      customRuleCopySuccess: '自定義規則已複製到剪貼板！',\n      customRuleCopyFailed: '自定義規則複製失敗：可能是瀏覽器不支持或被用戶阻止！',\n      customRuleCopyError: '自定義規則複製失敗！',\n      ruleGroupCopySuccess: '優先級規則組已複製到剪貼板！',\n      ruleGroupCopyFailed: '優先級規則組複製失敗：可能是瀏覽器不支持或被用戶阻止！',\n      ruleGroupCopyError: '優先級規則組複製失敗！',\n      currentPriorityRules: '當前使用下載優先規則',\n      currentPriorityRulesHint: '排在前面的優先級越高，未選擇的項不納入排序',\n      importCustomRules: '導入自定義規則',\n      importRuleGroups: '導入優先級規則組',\n      importFailed: '導入規則失敗！無法解析輸入的數據！',\n      importUnknownType: '導入規則失敗！未知的數據類型！',\n      duplicateValue: '存在重名值',\n      importNoId: '導入失敗！發現有規則不存在ID，可能屬於優先級規則組！',\n      importHasId: '導入失敗！發現有規則存在相同ID，可能屬於自定義規則！',\n    },\n    scheduler: {\n      title: '定時作業',\n      subtitle: '包含系統內置服務以及插件提供的服務',\n      provider: '提供者',\n      taskName: '任務名稱',\n      taskStatus: '任務狀態',\n      nextRunTime: '下一次執行時間',\n      execute: '執行',\n      noService: '沒有後台服務',\n      running: '正在運行',\n      stopped: '已停止',\n      waiting: '等待',\n      executeSuccess: '定時作業執行請求提交成功！',\n    },\n    subscribe: {\n      basicSettings: '基礎設置',\n      basicSettingsDesc: '設定訂閱模式、週期等基礎設置',\n      subscribeSites: '訂閱站點',\n      subscribeSitesDesc: '只有選中的站點才會在訂閱中使用。',\n      mode: '訂閱模式',\n      modeHint: '自動：自動爬取站點首頁，站點RSS：通過站點RSS鏈接訂閱',\n      rssInterval: '站點RSS週期',\n      rssIntervalHint: '設置站點RSS運行週期，在訂閱模式為`站點RSS`時生效',\n      filterRuleGroup: '訂閱優先級規則組',\n      filterRuleGroupHint: '按選定的過濾規則組對訂閱進行過濾',\n      bestVersionRuleGroup: '洗版優先級規則組',\n      bestVersionRuleGroupHint: '按選定的過濾規則組對洗版訂閱進行過濾',\n      timedSearch: '訂閱定時搜索',\n      timedSearchHint: '每隔指定時間全站搜索，以補全訂閱可能漏掉的資源',\n      searchInterval: '訂閱搜索時間間隔',\n      searchIntervalHint: '設置訂閱搜索的時間間隔，僅在開啟訂閱定時搜索時生效',\n      checkLocalMedia: '檢查文件系統資源',\n      checkLocalMediaHint: '掃描存儲目錄中是否已存在相應資源文件，以避免重複下載；不管是否開啟都會檢查媒體伺服器',\n      modes: {\n        auto: '自動',\n        rss: '站點RSS',\n      },\n      intervals: {\n        min5: '5分鐘',\n        min10: '10分鐘',\n        min20: '20分鐘',\n        min30: '半小時',\n        hour1: '1小時',\n        hour12: '12小時',\n        day1: '1天',\n        day3: '3天',\n        week1: '一週',\n      },\n      saveSuccess: '訂閱站點保存成功',\n      saveFailed: '訂閱站點保存失敗！',\n      settingsSaveSuccess: '訂閱基礎設置保存成功',\n      settingsSaveFailed: '訂閱基礎設置保存失敗！',\n    },\n    cache: {\n      title: '緩存管理',\n      subtitle: '管理緩存的站點資源',\n      totalCount: '總條數',\n      siteCount: '站點數',\n      filterByTitle: '按標題篩選',\n      filterBySite: '按站點篩選',\n      selectSite: '選擇站點',\n      refresh: '刷新緩存',\n      deleteSelected: '刪除選中',\n      clearAll: '清空緩存',\n      refreshSuccess: '緩存刷新完成',\n      refreshFailed: '刷新緩存失敗',\n      clearSuccess: '緩存清理完成',\n      clearFailed: '清理緩存失敗',\n      deleteSuccess: '緩存項刪除成功',\n      deleteFailed: '刪除緩存項失敗',\n      deleteSelectedSuccess: '成功刪除 {count} 個緩存項',\n      deleteSelectedFailed: '刪除緩存項失敗',\n      loadFailed: '加載緩存數據失敗',\n      selectDeleteWarning: '請選擇要刪除的緩存項',\n      reidentify: '重新識別',\n      reidentifySuccess: '重新識別完成',\n      reidentifyFailed: '重新識別失敗',\n      poster: '海報',\n      torrentTitle: '標題',\n      site: '站點',\n      size: '大小',\n      publishTime: '發布時間',\n      recognitionResult: '識別結果',\n      actions: '操作',\n      unrecognized: '未識別',\n      noData: '暫無緩存數據',\n      noDataHint: '點擊\"刷新緩存\"按鈕獲取最新的種子緩存',\n      reidentifyDialog: {\n        title: '重新識別',\n        torrentInfo: '種子信息',\n        tmdbId: 'TMDB ID',\n        tmdbIdHint: '可選，手動指定TMDB ID進行識別',\n        doubanId: '豆瓣 ID',\n        doubanIdHint: '可選，手動指定豆瓣ID進行識別',\n        autoHint: '如果不指定ID，將自動重新識別該種子',\n        cancel: '取消',\n        confirm: '重新識別',\n      },\n      mediaType: {\n        movie: '電影',\n        tv: '電視劇',\n      },\n      clearConfirm: '確認清空所有緩存嗎？',\n    },\n  },\n  dialog: {\n    progress: {\n      processing: '處理中',\n    },\n    subscribeSeason: {\n      title: '訂閱 - {title}',\n      selectGroup: '選擇劇集組',\n      defaultGroup: '默認',\n      seasonCount: '{count} 季',\n      episodeCount: '{count} 集',\n      seasonNumber: '第 {number} 季',\n      airDate: '首播於 {date}',\n      voteAverage: '{score}',\n      status: {\n        exists: '已入庫',\n        partial: '部分缺失',\n        missing: '缺失',\n      },\n      submit: '提交訂閱',\n      selectSeasons: '請選擇訂閱季',\n    },\n    userAddEdit: {\n      add: '添加用戶',\n      edit: '編輯用戶',\n      username: '用戶名',\n      usernameRequired: '用戶名不能為空',\n      password: '密碼',\n      passwordMinLength: '密碼長度不能少於6位',\n      confirmPassword: '確認密碼',\n      confirmPasswordRequired: '請確認密碼',\n      passwordMismatch: '兩次輸入的密碼不一致',\n      email: '郵箱',\n      nickname: '暱稱',\n      status: '狀態',\n      active: '激活',\n      inactive: '已停用',\n      superUser: '超級用戶',\n      otp: '啟用二次驗證',\n      avatar: '頭像',\n      uploadAvatar: '上傳頭像',\n      resetDefaultAvatar: '重置默認頭像',\n      restoreCurrentAvatar: '還原當前頭像',\n      notifications: '通知',\n      wechat: '微信UserID',\n      telegram: 'Telegram UserID',\n      slack: 'Slack UserID',\n      discord: 'Discord UserID',\n      vocechat: 'VoceChat UserID',\n      synologyChat: 'SynologyChat UserID',\n      webPush: 'WebPush',\n      creatingUser: '正在創建【{name}】用戶，請稍後',\n      updatingUser: '正在更新【{name}】用戶，請稍後',\n      usernameExists: '用戶名已存在',\n      userCreated: '用戶【{name}】創建成功',\n      userCreateFailed: '創建用戶失敗：{message}',\n      userUpdateSuccess: '用戶【{name}】更新成功',\n      userUpdateFailed: '更新用戶失敗：{message}',\n      userDeleteSuccess: '用戶【{name}】刪除成功',\n      userDeleteFailed: '刪除用戶失敗：{message}',\n      invalidFile: '上傳的文件不符合要求，請重新選擇頭像',\n      fileSizeLimit: '文件大小不得大於800KB',\n      avatarUploadSuccess: '新頭像上傳成功，待保存後生效!',\n      resetAvatarSuccess: '已重置為默認頭像，待保存後生效！',\n      restoreAvatarSuccess: '已還原當前使用頭像！',\n      deleteConfirm: '確認刪除用戶【{name}】嗎？',\n      saveUserInfo: '保存用戶信息',\n      cannotDeleteCurrentUser: '不能刪除當前登錄用戶',\n      deleteUser: '刪除用戶',\n      permissions: {\n        title: '權限設置',\n        presetNormal: '普通用戶',\n        presetAdmin: '管理員',\n        discovery: '發現',\n        discoveryDesc: '存取推薦和探索功能',\n        search: '搜索',\n        searchDesc: '搜索站點資源和添加下載',\n        subscribe: '訂閱',\n        subscribeDesc: '管理電影和電視劇訂閱',\n        manage: '管理',\n        manageDesc: '存取下載管理和站點管理等功能',\n      },\n    },\n    searchBar: {\n      search: '搜索',\n      searchPlaceholder: '搜索電影、劇集以及更多...',\n      recentSearches: '最近搜索',\n      noRecentSearches: '沒有最近搜索記錄',\n      functions: '功能',\n      noFunctionsFound: '沒有匹配的功能',\n      plugins: '插件',\n      noPluginsFound: '沒有匹配的插件',\n      subscriptions: '訂閱',\n      noSubscriptionsFound: '沒有匹配的訂閱',\n      searchSites: '搜索站點',\n      selectSites: '選擇站點',\n      collections: '系列合集',\n      collectionSearch: '相關的系列作品',\n      actorSearch: '相關的演員、導演等',\n      historySearch: '相關的歷史記錄',\n      subscribeShareSearch: '相關的訂閱分享',\n      siteResources: '站點資源',\n      searchInSites: '在站點中搜索種子資源',\n      relatedResources: '相關資源',\n      searchTip: '可搜索電影、電視劇、演員、資源等',\n      emptySearchHint: '輸入關鍵字開始搜索',\n      escClose: '關閉',\n      openSearch: '打開搜索',\n    },\n    searchSite: {\n      selectSites: '選擇站點',\n      siteSearch: '站點搜索',\n      searchAllSites: '已選擇 {selected}/{total} 個站點',\n      selectAll: '選擇全部',\n      deselectAll: '取消全選',\n      confirm: '確認',\n      cancel: '取消',\n    },\n    importCode: {\n      import: '導入',\n      title: '導入代碼',\n    },\n    addDownload: {\n      confirmDownload: '確認下載',\n      downloader: '下載器（默認）',\n      saveDirectory: '保存目錄（自動）',\n      defaultPlaceholder: '留空默認',\n      autoPlaceholder: '留空自動匹配',\n      downloading: '下載中...',\n      startDownload: '開始下載',\n      downloadSuccess: '{site} {title} 下載成功！',\n      downloadFailed: '{site} {title} 下載失敗：{message}！',\n      showAdvancedOptions: '顯示高級選項',\n      hideAdvancedOptions: '隱藏高級選項',\n    },\n    subscribeShare: {\n      shareSubscription: '分享訂閱',\n      season: '第 {number} 季',\n      title: '標題',\n      description: '說明',\n      descriptionHint: '填寫關於該訂閱的說明，訂閱中的搜索詞、識別詞等將會默認包含在分享中',\n      shareUser: '分享用戶',\n      shareUserHint: '分享人的暱稱',\n      confirmShare: '確認分享',\n      shareSuccess: '{name} 分享成功！',\n      shareFailed: '{name} 分享失敗：{message}！',\n    },\n    workflowShare: {\n      shareWorkflow: '分享工作流',\n      title: '標題',\n      description: '說明',\n      descriptionHint: '填寫關於該工作流的說明，工作流的動作和流程將會默認包含在分享中',\n      shareUser: '分享用戶',\n      shareUserHint: '分享人的暱稱',\n      confirmShare: '確認分享',\n      shareSuccess: '{name} 分享成功！',\n      shareFailed: '{name} 分享失敗：{message}！',\n      securityWarning: '安全提醒',\n      securityWarningMessage: '分享前請確保工作流沒有敏感資訊，比如RSS連結中的PassKey等，避免產生資訊洩露。',\n    },\n    u115Auth: {\n      loginTitle: '115網盤授權',\n      openAuthWindow: '打開授權窗口',\n      authorizing: '請在新窗口中完成授權...',\n      authSuccess: '授權成功！',\n      authFailed: '授權失敗或已過期',\n      authCanceled: '授權已取消，請重試',\n      urlEmpty: '授權URL為空',\n      urlFetchFailed: '獲取授權URL失敗',\n      popupBlocked: '無法打開授權窗口，請檢查瀏覽器彈窗設置',\n      complete: '完成',\n      reset: '重置',\n    },\n    aliyunAuth: {\n      loginTitle: '阿里雲盤登錄',\n      scanQrCode: '請用阿里雲盤 App 掃碼',\n      scanned: '已掃碼',\n      complete: '完成',\n      reset: '重置',\n    },\n    rcloneConfig: {\n      title: 'RClone配置',\n      filePath: 'rclone配置文件路徑',\n      fileContent: 'rclone配置文件內容',\n      defaultContent: '# 請在此處填寫rclone配置文件內容 \\n# 請參考 https://rclone.org/docs/ \\n# 存儲節點名必須為：MP',\n      complete: '完成',\n      reset: '重置',\n    },\n    alistConfig: {\n      title: 'OpenList配置',\n      serverUrl: 'OpenList服務地址',\n      username: '用戶名',\n      password: '密碼',\n      tokenUrl: '獲取Token地址',\n      loginType: '登錄方式',\n      loginTypeOptions: {\n        guest: '訪客',\n        username: '用戶名密碼',\n        token: 'Token',\n      },\n      complete: '完成',\n      reset: '重置',\n    },\n    smbConfig: {\n      title: 'SMB網路共享配置',\n      host: 'SMB伺服器地址',\n      hostHint: 'SMB伺服器的IP地址或主機名',\n      share: '共享名稱',\n      shareHint: '要連接的共享資料夾名稱',\n      username: '用戶名',\n      usernameHint: 'SMB登入用戶名',\n      password: '密碼',\n      passwordHint: 'SMB登入密碼',\n      domain: '域名',\n      domainHint: 'SMB域名，如WORKGROUP或域控制器名稱',\n      complete: '完成',\n      reset: '重置',\n    },\n    workflowAddEdit: {\n      addTitle: '新增工作流',\n      editTitle: '編輯工作流',\n      name: '名稱',\n      namePlaceholder: '工作流名稱',\n      desc: '描述',\n      descPlaceholder: '工作流描述',\n      enabled: '啟用',\n      triggerType: '觸發類型',\n      triggerTypeTimer: '定時觸發',\n      triggerTypeEvent: '事件觸發',\n      triggerTypeManual: '手動觸發',\n      schedule: '定時執行',\n      cronExpr: 'Cron表達式',\n      cronExprDesc: '工作流定時執行的cron表達式',\n      eventType: '事件類型',\n      eventTypePlaceholder: '請選擇事件類型',\n      nameRequired: '請填寫完整資訊！',\n      triggerRequired: '請選擇觸發類型！',\n      timerRequired: '請填寫定時表達式！',\n      eventTypeRequired: '請選擇事件類型！',\n      addSuccess: '建立任務成功，請編輯流程！',\n      addFailed: '建立任務失敗：{message}',\n      editSuccess: '修改任務成功！',\n      editFailed: '修改任務失敗：{message}',\n      cancel: '取消',\n      confirm: '確認',\n    },\n    workflowActions: {\n      title: '編輯流程',\n      noActionsMessage: '工作流沒有動作，請新增動作',\n      addAction: '新增動作',\n      editAction: '編輯動作',\n      deleteAction: '刪除動作',\n      moveUp: '上移',\n      moveDown: '下移',\n      nameLabel: '動作名稱',\n      nameRequired: '動作名稱不能為空',\n      typeLabel: '動作類型',\n      typeRequired: '動作類型不能為空',\n      paramsLabel: '動作參數',\n      outputLabel: '動作輸出',\n      saveAction: '儲存動作',\n      cancelAction: '取消',\n      confirmDeleteTitle: '確認刪除動作',\n      confirmDeleteMessage: '確定要刪除此動作嗎？此操作無法復原。',\n      yesDelete: '是的，刪除',\n      noCancel: '取消',\n      invalidConnection: '非法連接：不能連接自身或同類型端口！',\n      componentNotFound: '組件 {component} 未找到',\n      componentAdded: '已新增組件到畫布',\n      saveSuccess: '儲存任務流程成功！',\n      saveFailed: '儲存任務流程失敗：{message}',\n      importTitle: '匯入任務流程',\n      importSuccess: '匯入成功！',\n      importFailed: '匯入失敗！',\n      codeCopied: '任務流程代碼已複製到剪貼簿！',\n    },\n    siteCookieUpdate: {\n      title: '更新站點Cookie & UA',\n      processing: '請稍候...',\n      updating: '正在更新 {site} Cookie & UA...',\n      success: '{site} 更新Cookie & UA成功！',\n      failed: '{site} 更新失敗：{message}',\n      updateButton: '開始更新',\n    },\n    siteAddEdit: {\n      addTitle: '新增站點',\n      editTitle: '編輯站點',\n      nameLabel: '站點名稱',\n      urlLabel: '站點URL',\n      iconLabel: '站點圖標',\n      uploadIcon: '上傳圖標',\n      cookie: 'Cookie',\n      rssUrl: 'RSS連結',\n      enableLabel: '啟用',\n      pubEnableLabel: '資源公開',\n      priorityLabel: '優先級',\n      signInLabel: '簽到',\n      proxies: '代理',\n      userInfo: '用戶資訊',\n      cancel: '取消',\n      confirm: '儲存',\n    },\n    pluginConfig: {\n      title: '插件配置',\n      save: '儲存',\n      close: '關閉',\n      viewData: '查看數據',\n      saving: '正在儲存 {name} 配置...',\n      saveSuccess: '插件 {name} 配置已儲存',\n      saveFailed: '插件 {name} 配置儲存失敗：{message}',\n    },\n    pluginData: {\n      title: '插件數據',\n      save: '儲存',\n      close: '關閉',\n    },\n    pluginMarketSetting: {\n      title: '插件市場設置',\n      repoUrl: '插件倉庫地址',\n      repoPlaceholder: '格式：https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/',\n      repoHint: '多個地址使用换行分隔，僅支援Github倉庫',\n      urlPlaceholder: '輸入插件倉庫地址',\n      noRepos: '暫無插件倉庫地址',\n      invalidUrl: '請輸入有效的URL地址',\n      duplicateUrl: '該地址已存在',\n      close: '關閉',\n      save: '儲存',\n      saveSuccess: '插件倉庫儲存成功',\n      saveFailed: '插件倉庫儲存失敗：{message}！',\n    },\n    userAuth: {\n      title: '用戶認證',\n      codeLabel: '認證碼',\n      codePlaceholder: '請輸入認證碼',\n      authBtn: '開始認證',\n      closeBtn: '關閉',\n      selectSite: '選擇認證站點',\n      selectSiteRequired: '請選擇認證站點！',\n      siteConfigNotExist: '站點配置不存在！',\n      fieldRequired: '請輸入{name}！',\n      authSuccess: '用戶認證成功，請重新登入！',\n      authFailed: '認證失敗：{message}',\n    },\n    transferQueue: {\n      title: '整理隊列',\n      name: '名稱',\n      type: '類型',\n      state: '狀態',\n      progress: '進度',\n      startTime: '開始時間',\n      speedTitle: '速度',\n      pathTitle: '路徑',\n      sizeTitle: '大小',\n      waitingState: '等待中',\n      runningState: '正在整理',\n      finishedState: '完成',\n      failedState: '失敗',\n      cancelledState: '已取消',\n      noTasks: '沒有正在整理的任務',\n      processing: '請稍候 ...',\n      stopAll: '全部停止',\n      startAll: '全部開始',\n      refresh: '刷新',\n      close: '關閉',\n      processingFile: '正在整理',\n      overallProgress: '整體進度',\n      currentFileProgress: '當前文件進度',\n      processingStatus: '整理中',\n    },\n    reorganize: {\n      title: '整理',\n      sourceTitle: '源文件',\n      targetTitle: '目標文件',\n      processingTitle: '處理中',\n      confirmTitle: '確認',\n      selectFile: '選擇文件',\n      selectTarget: '選擇目標',\n      selectMediaType: '選擇媒體類型',\n      movie: '電影',\n      tv: '電視劇',\n      selectTmdbId: '選擇TMDB ID',\n      selectMediaInfo: '選擇媒體資訊',\n      selectTargetPath: '選擇目標路徑',\n      selectTargetDir: '選擇目標目錄',\n      selectFileName: '選擇文件名',\n      confirmMoving: '請確認移動！',\n      sourceLabel: '源文件：',\n      targetLabel: '目標目錄：',\n      filenameLabel: '文件名：',\n      close: '關閉',\n      next: '下一步',\n      previous: '上一步',\n      confirm: '確認',\n      manualTitle: '手動整理',\n      multipleItemsTitle: '共 {count} 項',\n      singleItemTitle: '{path}',\n      targetStorage: '目的存儲',\n      targetStorageHint: '整理目的存儲',\n      transferType: '整理方式',\n      transferTypeHint: '文件操作整理方式',\n      targetPath: '目的路徑',\n      targetPathHint: '整理目的路徑，留空將自動匹配',\n      targetPathPlaceholder: '留空自動',\n      mediaType: '類型',\n      mediaTypeHint: '文件的媒體類型',\n      tmdbId: 'TheMovieDb編號',\n      doubanId: '豆瓣編號',\n      mediaIdHint: '按名稱查詢媒體編號，留空自動識別',\n      mediaIdPlaceholder: '留空自動識別',\n      episodeGroup: '劇集組編號',\n      episodeGroupHint: '指定劇集組',\n      episodeGroupPlaceholder: '手動查詢劇集組',\n      season: '季',\n      seasonHint: '第幾季',\n      episodeDetail: '集',\n      episodeDetailHint: '集數或範圍，如1或1,2',\n      episodeDetailPlaceholder: '起始集,終止集',\n      episodeFormat: '集數定位',\n      episodeFormatHint: '使用{ep}定位文件名中的集數部分以輔助識別',\n      episodeFormatPlaceholder: '使用{ep}定位集數',\n      episodeOffset: '集數偏移',\n      episodeOffsetHint: '集數偏移運算，如-10或EP*2',\n      episodeOffsetPlaceholder: '如-10',\n      episodePart: '指定Part',\n      episodePartHint: '指定Part，如part1',\n      episodePartPlaceholder: '如part1',\n      minFileSize: '最小文件大小（MB）',\n      minFileSizeHint: '只整理大於最小文件大小的文件',\n      typeFolderOption: '按類型分類',\n      typeFolderHint: '整理時目的路徑下按媒體類型新增子目錄',\n      categoryFolderOption: '按類別分類',\n      categoryFolderHint: '整理時在目的路徑下按媒體類別新增子目錄',\n      scrapeOption: '刮削元數據',\n      scrapeHint: '整理完成後自動刮削元數據',\n      fromHistoryOption: '復用歷史識別資訊',\n      fromHistoryHint: '使用歷史整理記錄中已識別的媒體資訊',\n      addToQueue: '加入整理隊列',\n      reorganizeNow: '立即整理',\n      auto: '自動',\n      processing: '正在處理 ...',\n      successMessage: '文件 {name} 已加入整理隊列！',\n    },\n    subscribeEdit: {\n      titleDefault: '默認訂閱規則',\n      titleEdit: '編輯訂閱',\n      seasonFormat: '第 {number} 季',\n      tabs: {\n        basic: '基礎',\n        advance: '進階',\n      },\n      searchKeyword: '搜索關鍵詞',\n      searchKeywordHint: '指定搜索站點時使用的關鍵詞',\n      totalEpisode: '總集數',\n      totalEpisodeHint: '劇集總集數',\n      startEpisode: '開始集數',\n      startEpisodeHint: '開始訂閱集數',\n      quality: '質量',\n      qualityHint: '訂閱資源質量',\n      resolution: '分辨率',\n      resolutionHint: '訂閱資源分辨率',\n      effect: '特效',\n      effectHint: '訂閱資源特效',\n      subscribeSites: '訂閱站點',\n      subscribeSitesHint: '訂閱的站點範圍，不選使用系統設置',\n      downloader: '下載器',\n      downloaderHint: '指定該訂閱使用的下載器',\n      savePath: '儲存路徑',\n      savePathHint: '指定該訂閱的下載儲存路徑，留空自動使用設定的下載目錄',\n      bestVersion: '洗版',\n      bestVersionHint: '根據洗版優先級進行洗版訂閱',\n      searchImdbid: '使用 ImdbID 搜索',\n      searchImdbidHint: '開使用 ImdbID 精確搜索資源',\n      showEditDialog: '訂閱時編輯更多規則',\n      showEditDialogHint: '新增訂閱時顯示此編輯訂閱對話框',\n      include: '包含（關鍵字、正則式）',\n      includeHint: '包含規則，支援正則表達式',\n      exclude: '排除（關鍵字、正則式）',\n      excludeHint: '排除規則，支援正則表達式',\n      filterGroups: '優先級規則組',\n      filterGroupsHint: '按選定的過濾規則組對訂閱進行過濾',\n      episodeGroup: '指定劇集組',\n      episodeGroupHint: '按特定劇集組識別和刮削',\n      season: '指定季',\n      seasonHint: '指定任意季訂閱',\n      mediaCategory: '自定義類別',\n      mediaCategoryHint: '指定類別名稱，留空自動識別',\n      customWords: '自定義識別詞',\n      customWordsHint: '只對該訂閱使用的識別詞',\n      customWordsPlaceholder:\n        '屏蔽詞\\n被替換詞 => 替換詞\\n前定位詞 <> 後定位詞 >> 集偏移量（EP）\\n被替換詞 => 替換詞 && 前定位詞 <> 後定位詞 >> 集偏移量（EP）\\n其中替換詞支援格式：&#123; tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx &#125; 直接指定TMDBID/豆瓣ID識別，其中s、e為季數和集數（可選）',\n      cancelSubscribe: '取消訂閱',\n      save: '儲存',\n      cancelSubscribeConfirm: '是否確認取消訂閱？',\n    },\n    subscribeFiles: {\n      title: '已下載文件',\n      noFilesMessage: '暫無文件',\n      close: '關閉',\n      downloadTab: '下載文件',\n      libraryTab: '媒體庫文件',\n      episodeColumn: '集',\n      torrentColumn: '種子',\n      fileColumn: '文件',\n      itemsPerPage: '每頁條數',\n      pageText: '{0}-{1} 共 {2} 條',\n      loadingText: '載入中...',\n      noData: '沒有數據',\n      season: '第 {number} 季',\n    },\n    subscribeHistory: {\n      title: '{type}訂閱歷史',\n      resubscribe: '重新訂閱',\n      resubscribeMovie: '正在重新訂閱 {name}...',\n      resubscribeTv: '正在重新訂閱 {name} 第 {season} 季...',\n      season: '第 {season} 季',\n      noData: '沒有已完成的訂閱',\n    },\n    siteUserData: {\n      title: '站點用戶數據',\n      updateTime: '更新時間',\n      username: '用戶名',\n      uploadTitle: '上傳量',\n      uploadTotal: '總上傳量',\n      downloadTitle: '下載量',\n      downloadTotal: '總下載量',\n      seedingTitle: '做種數',\n      seedingCount: '總做種數',\n      seedingSize: '總做種體積',\n      userLevel: '用戶等級',\n      msgCount: '未讀消息',\n      inviteCount: '邀請數',\n      bonus: '積分',\n      ratio: '分享率',\n      joinTime: '加入時間',\n      trafficHistory: '歷史流量',\n      seedingDistribution: '做種分佈',\n      volumeTitle: '體積',\n      countTitle: '數量：',\n      noData: '無',\n      refreshing: '正在刷新站點數據...',\n      close: '關閉',\n    },\n    siteResource: {\n      browseTitle: '瀏覽 - {name}',\n      searchKeyword: '搜索關鍵字',\n      resourceCategory: '資源分類',\n      search: '搜索',\n      itemsPerPage: '每頁條數',\n      pageText: '{0}-{1} 共 {2} 條',\n      noData: '沒有數據',\n      loading: '加載中...',\n      titleColumn: '標題',\n      timeColumn: '時間',\n      sizeColumn: '大小',\n      seedersColumn: '做種',\n      peersColumn: '下載',\n      viewDetails: '查看詳情',\n      downloadTorrent: '下載種子文件',\n    },\n    forkSubscribe: {\n      title: '複製訂閱',\n      selectSubscriber: '選擇複製目標',\n      overwriteExisting: '覆蓋現有訂閱',\n      overwriteExistingHint: '目標用戶已存在該訂閱時，是否覆蓋',\n      confirm: '確認',\n      cancel: '取消',\n    },\n  },\n  file: {\n    newFolder: '新建文件夾',\n    autoRecognizeName: '自動識別名稱',\n    createFolder: '創建文件夾',\n    fileName: '文件名',\n    fileSize: '文件大小',\n    fileType: '文件類型',\n    lastModified: '修改時間',\n    actions: '操作',\n    rename: '重命名',\n    delete: '刪除',\n    confirmFileDelete: '確認刪除',\n    upload: '上傳',\n    download: '下載',\n    preview: '預覽',\n    selectAll: '全選',\n    deselectAll: '取消全選',\n    moveUp: '返回上一級',\n    sortByName: '按名稱排序',\n    sortByTime: '按時間排序',\n    currentName: '當前名稱',\n    newName: '新名稱',\n    includeSubfolders: '自動重命名目錄內所有媒體文件',\n    emptyFolder: '空文件夾',\n    noFilesInFolder: '該文件夾內沒有文件',\n    autoRecognize: '自動識別名稱',\n    directoryTree: '目錄樹',\n    rootDirectory: '根目錄',\n    noDirectories: '沒有可用的目錄',\n    directory: '目錄',\n    file: '文件',\n    size: '大小',\n    modifyTime: '修改時間',\n    noFiles: '沒有目錄或文件',\n    emptyDirectory: '空目錄',\n    confirmDelete: '是否確認刪除{type} {name}？',\n    confirmBatchDelete: '是否確認刪除選中的 {count} 個項目？',\n    deleting: '正在刪除 {name}...',\n    recognize: '識別',\n    recognizing: '正在識別 {path}...',\n    recognizeFailed: '{path} 識別失敗！',\n    scrape: '刮削',\n    scraping: '正在刮削 {path}...',\n    scrapeCompleted: '{path} 削刮完成！',\n    confirmScrape: '是否確認刮削 {path}？',\n    confirmBatchScrape: '是否確認刮削選中的 {count} 項？',\n    renaming: '正在重命名 {name}...',\n    renamingAll: '正在重命名 {path} 及目錄內所有文件...',\n    close: '關閉',\n    loadingDirectoryStructure: '加載目錄結構...',\n    reorganize: '整理',\n  },\n  person: {\n    alias: '別名：',\n    credits: '參演作品',\n    biography: '個人簡介',\n    birthday: '出生日期',\n    placeOfBirth: '出生地',\n  },\n  error: {\n    title: '出錯啦！',\n    networkError: '無法獲取到媒體信息，請檢查網絡連接。',\n    serverError: '服務器錯誤，請稍後重試。',\n    notFound: '找不到請求的資源。',\n  },\n  plugin: {\n    sort: {\n      popular: '熱門',\n      name: '插件名稱',\n      author: '作者',\n      repository: '插件倉庫',\n      latest: '最新發布',\n    },\n    installingPlugin: '正在安装插件...',\n    installing: '正在安装 {name} v{version} ...',\n    installSuccess: '插件 {name} 安装成功！',\n    installFailed: '插件 {name} 安装失败：{message}',\n    filterPlugins: '過濾插件',\n    name: '名稱',\n    hasNewVersion: '有新版本',\n    running: '運行中',\n    author: '作者',\n    label: '標籤',\n    repository: '倉庫',\n    sortTitle: '排序',\n    filter: '過濾：{name}',\n    noMatchingContent: '沒有找到匹配的內容',\n    pleaseInstallFromMarket: '請從插件市場安裝插件',\n    allPluginsInstalled: '所有插件已安裝',\n    searchPlugins: '搜索插件',\n    searchPlaceholder: '按插件名稱或描述搜索',\n    uninstalling: '正在卸載 {name} ...',\n    uninstallSuccess: '插件 {name} 卸载成功！',\n    uninstallFailed: '插件 {name} 卸载失败：{message}',\n    updating: '正在更新 {name} ...',\n    updateSuccess: '插件 {name} 更新成功！',\n    updateFailed: '插件 {name} 更新失敗：{message}',\n    noPlugins: '沒有安裝插件',\n    installed: '已安裝',\n    notInstalled: '未安裝',\n    hasUpdate: '可更新',\n    configuring: '配置中',\n    enable: '啟用',\n    disable: '禁用',\n    settings: '設置',\n    projectHome: '項目主頁',\n    updateHistory: '更新說明',\n    local: '本地',\n    installToLocal: '安裝到本地',\n    totalDownloads: '共 {count} 次下載',\n    viewData: '查看數據',\n    update: '更新',\n    reset: '重置',\n    uninstall: '卸載',\n    viewLogs: '查看日誌',\n    authorHome: '作者主頁',\n    confirmUninstall: '是否確認卸載插件 {name}？',\n    confirmReset: '此操作將恢復插件 {name} 的默認設置，並清除所有相關數據，確定要繼續嗎？',\n    resetSuccess: '插件 {name} 數據已重置',\n    resetFailed: '插件 {name} 重置失敗：{message}',\n    updateHistoryTitle: '{name} 更新說明',\n    updateToLatest: '更新到最新版本',\n    updatingTo: '正在更新 {name} 至 v{version} ...',\n    folderNameEmpty: '文件夾名稱不能為空',\n    folderExists: '文件夾已存在',\n    folderCreateSuccess: '文件夾創建成功',\n    folderRenameSuccess: '文件夾重命名成功',\n    folderRenameFailed: '重命名文件夾失敗',\n    folderDeleteSuccess: '文件夾刪除成功',\n    folderDeleteFailed: '刪除文件夾失敗',\n    removeFromFolderSuccess: '插件已移出文件夾',\n    operationFailed: '操作失敗',\n    saveFolderConfigFailed: '保存文件夾配置失敗',\n    newFolder: '新建文件夾',\n    folderName: '文件夾名稱',\n    cancel: '取消',\n    create: '創建',\n    clone: '分身',\n    cloneTitle: '創建插件分身',\n    cloneSubtitle: '為 {name} 創建獨立的分身實例',\n    cloneFeature: '插件分身功能',\n    cloneDescription: '創建插件的獨立副本，擁有獨立的配置和數據，適用於多賬號、測試環境等場景',\n    suffix: '分身後綴',\n    suffixPlaceholder: '例如：Test、Backup、Site1',\n    suffixHint: '用於區分分身的唯一標識，只能包含英文字母和數字',\n    suffixRequired: '分身後綴不能為空',\n    suffixFormatError: '只能包含英文字母和數字',\n    suffixLengthError: '長度不能超過20個字符',\n    cloneName: '分身名稱',\n    cloneNamePlaceholder: '例如：自動備份 測試版',\n    cloneNameHint: '分身插件的顯示名稱（可選）',\n    cloneDefaultName: '{name} 分身',\n    cloneDescriptionLabel: '分身描述',\n    cloneDescriptionPlaceholder: '描述這個分身的用途和特點...',\n    cloneDescriptionHint: '詳細描述分身插件的用途（可選）',\n    cloneDefaultDescription: '{description} (分身版本)',\n    cloneVersion: '版本號',\n    cloneVersionPlaceholder: '例如：1.0、2.1.0',\n    cloneVersionHint: '自定義分身插件的版本號（可選）',\n    cloneIcon: '圖標URL',\n    cloneIconPlaceholder: 'https://example.com/icon.png',\n    cloneIconHint: '自定義分身插件的圖標（可選）',\n    cloneNotice: '分身插件創建後默認為禁用狀態，需要手動配置啟用。分身後綴一旦確定無法修改。',\n    createClone: '創建分身',\n    cloning: '正在創建 {name} 的分身...',\n    cloneSuccess: '插件分身 {name} 創建成功！',\n    cloneFailed: '插件分身創建失敗：{message}',\n    cloneFailedGeneral: '插件分身創建失敗',\n    logTitle: '插件日誌',\n    quickAccess: '快速訪問',\n    noPluginsWithPage: '暫無可展示的插件',\n    tapToOpen: '點擊返回主界面',\n    recentlyUsed: '最近使用',\n    allPlugins: '所有插件',\n    noRecentPlugins: '無',\n  },\n  profile: {\n    disableOtpWithPasskeyError: '請先刪除所有通行密鑰後再清除身份驗證器！',\n    personalInfo: '個人信息',\n    uploadNewAvatar: '上傳新頭像',\n    avatarFormatError: '上傳的文件不符合要求，請重新選擇頭像',\n    avatarSizeError: '文件大小不得大於800KB',\n    avatarUploadSuccess: '新頭像上傳成功，待保存後生效!',\n    resetAvatarSuccess: '已重置為默認頭像，待保存後生效！',\n    restoreAvatarSuccess: '已還原當前使用頭像！',\n    savingInProgress: '正在保存中，請稍後...',\n    usernameRequired: '用戶名不能為空',\n    passwordMismatch: '兩次輸入的密碼不一致',\n    usernameChangeSuccess: '【{oldName}】更名【{newName}】，用戶信息保存成功！',\n    saveSuccess: '用戶信息保存成功！',\n    saveFailedWithNameChange: '【{oldName}】更名【{newName}】，信息保存失敗：{message}！',\n    saveFailed: '用戶信息保存失敗：{message}！',\n    nickname: '暱稱',\n    nicknamePlaceholder: '顯示暱稱，優先於用戶名顯示',\n    accountBinding: '賬號綁定',\n    wechatUser: '微信用戶',\n    telegramUser: 'Telegram用戶',\n    slackUser: 'Slack用戶',\n    discordUser: 'Discord用戶',\n    vocechatUser: 'VoceChat用戶',\n    synologychatUser: 'SynologyChat用戶',\n    doubanUser: '豆瓣用戶',\n    setupAuthenticator: '設置身份驗證器',\n    authenticatorManagement: '身份驗證器管理',\n    authenticatorEnabled: '您已啟用身份驗證器雙重驗證',\n    clearAuthenticatorTip: '如需設置新的身份驗證器，請先清除當前配置。',\n    clearAuthenticator: '清除身份驗證器',\n    enableTwoFactor: '開啟雙重驗證',\n    disableTwoFactor: '關閉雙重驗證',\n    setupMfa: '設置雙重驗證',\n    enableMfa: '開啟雙重驗證',\n    useAuthenticator: '使用身份驗證器',\n    usePasskey: '使用通行密鑰',\n    enabled: '已啟用',\n    keysCount: '{count} 個密鑰',\n    passkeyManagement: '通行密鑰管理',\n    registerNewPasskey: '註冊新通行密鑰',\n    passkeyDescription: '通行密鑰可以讓您無需密碼即可快速安全地登入。',\n    passkeyAppDescription:\n      '通行密鑰是一種更簡單、更安全的登入方式，可以替代密碼進行登入。您可以使用 iCloud 鑰匙圈、Bitwarden 等支援通行密鑰的應用程式或硬體金鑰完成驗證。',\n    passkeyName: '通行密鑰名稱',\n    passkeyNamePlaceholder: '例如：iPhone、Windows Hello',\n    registerPasskey: '註冊通行密鑰',\n    createdAt: '建立於',\n    lastUsedAt: '最後使用時間',\n    noPasskeys: '您還沒有註冊任何通行密鑰',\n    passkeyNameRequired: '請輸入通行密鑰名稱',\n    passkeyRegisterSuccess: '通行密鑰註冊成功',\n    passkeyRegisterFailed: '註冊失敗',\n    passkeyRegisterCancelled: '註冊被取消',\n    passkeyDeleteSuccess: '通行密鑰已刪除',\n    passkeyDeleteFailed: '刪除失敗',\n    deletePasskey: '刪除通行密鑰',\n    passkeyDomainWarning:\n      '通行密鑰（PassKey）的可用性與 {domain} 緊密相關。在公網環境下，請務必在「基本設定」中配置正確的訪問域名。域名變更或配置錯誤將導致通行密鑰無法使用。',\n    otpRequiredForPasskey:\n      '為了安全起見，您必須先啟用 {otp} 驗證碼，然後才能註冊通行密鑰。這是為了防止在網域配置變動導致 PassKey 失效時，您仍能通過 OTP 碼登入帳戶。',\n    accessDomain: '訪問域名',\n    otpAuthenticator: 'OTP 身份驗證器',\n    otpGenerateFailed: '獲取otp uri失敗：{message}！',\n    otpDisableSuccess: '關閉登錄雙重驗證成功！',\n    otpDisableFailed: '關閉otp失敗：{message}！',\n    otpCodeRequired: '請填寫6位驗證碼',\n    otpEnableSuccess: '開啟登錄雙重驗證成功！',\n    otpEnableFailed: '開啟otp失敗：{message}！',\n    otpDisableRestrictedByPasskey: '您已註冊通行密鑰，請先刪除所有通行密鑰再關閉 OTP 驗證。',\n    confirmToDisableOtp: '為了安全起見，關閉雙重驗證需要驗證您的登錄密碼。',\n    confirmToDeletePasskey: '為了安全起見，刪除通行密鑰需要驗證您的登錄密碼。',\n    authenticatorAppDescription:\n      '使用 Google Authenticator、Microsoft Authenticator、Authy 或 1Password 等驗證器應用程式掃描 QR Code，取得 6 位數驗證碼。',\n    secretKeyTip: '如果您在使用二維碼時遇到困難，請在您的應用程序中選擇手動輸入以上代碼。',\n    enterVerificationCode: '輸入驗證碼以確認開啟雙重驗證',\n    avatarFormatTip: '允許 JPG、PNG、GIF、WEBP 格式， 最大尺寸 800KB。',\n  },\n  transferHistory: {\n    title: '轉移歷史',\n    searchPlaceholder: '搜索轉移記錄',\n    titleColumn: '標題',\n    pathColumn: '路徑',\n    modeColumn: '轉移方式',\n    sizeColumn: '大小',\n    dateColumn: '時間',\n    statusColumn: '狀態',\n    actionsColumn: '操作',\n    seasonEpisode: '季集/類別',\n    transferQueue: '轉移隊列',\n    groupMode: '分組模式',\n    listMode: '列表模式',\n    deleteConfirm: '確認刪除 {title} {seasons}{episodes}?',\n    deleteConfirmBatch: '確認刪除 {count} 條記錄?',\n    deleteRecordOnly: '僅刪除轉移記錄',\n    deleteSourceOnly: '刪除轉移記錄和源文件',\n    deleteDestOnly: '刪除轉移記錄和媒體庫文件',\n    deleteAll: '刪除所有',\n    transferMode: {\n      copy: '複製',\n      move: '移動',\n      link: '硬鏈接',\n      softlink: '軟鏈接',\n      rclone_copy: 'Rclone複製',\n      rclone_move: 'Rclone移動',\n    },\n    status: {\n      success: '成功',\n      failed: '失敗',\n      unknown: '未知',\n    },\n    noData: '沒有數據',\n    loading: '加載中...',\n    pageSize: '每頁條數',\n    pageInfo: '{begin} - {end} / {total}',\n    aiRedoDisabled: '請先在系統設置中啟用 AI 智能助手',\n    aiRedoQueued: '已提交智能助手整理任務：{title}',\n    aiRedoFailed: '提交智能助手整理任務失敗',\n    actions: {\n      aiRedo: '智能助手整理',\n      aiRedoPending: '智能助手整理中...',\n      redo: '重新整理',\n      delete: '刪除',\n      batchRedo: '批量重新整理',\n      batchDelete: '批量刪除',\n    },\n    batchOperationTitle: '批量操作',\n    progress: {\n      processing: '處理中',\n      pleaseWait: '請稍候...',\n    },\n    table: {\n      emptyTitle: '操作',\n    },\n  },\n  customRule: {\n    error: {\n      emptyIdName: '規則ID和規則名稱不能為空',\n      idOccupied: '當前規則ID已被內置規則佔用',\n      nameOccupied: '當前規則名稱已被內置規則佔用',\n      idExists: '規則ID【{id}】已存在',\n      nameExists: '規則名稱【{name}】已存在',\n    },\n    title: '{id} - 配置',\n    field: {\n      ruleId: '規則ID',\n      ruleName: '規則名稱',\n      include: '包含',\n      exclude: '排除',\n      sizeRange: '資源體積（MB）',\n      seeders: '做種人數',\n      publishTime: '發佈時間（分鐘）',\n    },\n    placeholder: {\n      ruleId: '必填；不可與其他規則ID重名',\n      ruleName: '必填；不可與其他規則名稱重名',\n      include: '關鍵詞/正則表達式',\n      exclude: '關鍵詞/正則表達式',\n      sizeRange: '0/1-10',\n      seeders: '0/1-10',\n      publishTime: '0/1-10',\n    },\n    hint: {\n      ruleId: '字符與數字組合，不能含空格',\n      ruleName: '使用別名便於區分規則',\n      include: '必須包含的關鍵詞或正則表達式，多個值使用｜分隔',\n      exclude: '不能包含的關鍵詞或正則表達式，多個值使用｜分隔',\n      sizeRange: '最小資源文件體積或體積範圍（劇集計算單集平均大小）',\n      seeders: '最小做種人數或做種人數範圍',\n      publishTime: '距離資源發佈的最小時間間隔或時間區間',\n    },\n    action: {\n      confirm: '確定',\n    },\n  },\n  downloader: {\n    title: '下載器',\n    name: '名稱',\n    type: '類型',\n    customTypeHint: '自定義下載器類型，用於插件等場景',\n    rtorrentHostHint: 'HTTP: http://ip:port/RPC2 或 SCGI: scgi://ip:port',\n    enabled: '啟用',\n    default: '預設',\n    host: '地址',\n    username: '用戶名',\n    password: '密碼',\n    category: '自動分類管理',\n    sequentail: '順序下載',\n    force_resume: '強制繼續',\n    first_last_piece: '優先首尾文件',\n    saveSuccess: '下載器設置保存成功',\n    saveFailed: '下載器設置保存失敗',\n    nameRequired: '名稱不能為空',\n    nameDuplicate: '名稱已存在',\n    defaultChanged: '存在預設下載器，已替換',\n    hostRequired: '地址不能為空',\n    usernameRequired: '用戶名不能為空',\n    passwordRequired: '密碼不能為空',\n    pathMapping: '路徑映射',\n    pathMappingRequired: '路徑不能為空',\n    pathMappingError: '必須以 / 開頭',\n    storagePath: '存儲路徑',\n    downloadPath: '下載路徑',\n  },\n  filterRule: {\n    title: '過濾規則',\n    groupName: '規則組名稱',\n    priority: '優先級',\n    rules: '規則',\n    add: '添加規則',\n    import: '導入規則',\n    share: '分享規則',\n    save: '保存規則',\n    nameRequired: '規則組名稱不能為空',\n    nameDuplicate: '規則組名稱已存在',\n    importSuccess: '規則導入成功',\n    importFailed: '規則導入失敗',\n    shareSuccess: '規則已複製到剪貼板',\n    shareFailed: '規則複製失敗',\n    mediaType: '媒體類型',\n    category: '媒體類別',\n    mediaTypeItems: {\n      movie: '電影',\n      tv: '電視劇',\n      anime: '動漫',\n      collection: '合集',\n      unknown: '未知',\n    },\n  },\n  mediaserver: {\n    type: '類型',\n    customTypeHint: '自定義媒體伺服器類型，用於插件等場景',\n    enableMediaServer: '啟用媒體伺服器',\n    nameRequired: '必填；不可與其他名稱重名',\n    serverAlias: '媒體伺服器的別名',\n    host: '地址',\n    hostPlaceholder: 'http(s)://ip:port',\n    hostHint: '服務端地址，格式：http(s)://ip:port',\n    hostRequired: '地址不能為空',\n    playHost: '外網播放地址',\n    playHostPlaceholder: 'http(s)://domain:port',\n    playHostHint: '跳轉播放頁面使用的地址，格式：http(s)://domain:port',\n    apiKey: 'API密鑰',\n    apiKeyRequired: 'API密鑰不能為空',\n    embyApiKeyHint: 'Emby設置->高級->API密鑰中生成的密鑰',\n    jellyfinApiKeyHint: 'Jellyfin設置->高級->API密鑰中生成的密鑰',\n    plexToken: 'X-Plex-Token',\n    tokenRequired: 'Token不能為空',\n    usernameRequired: '用戶名不能為空',\n    passwordRequired: '密碼不能為空',\n    plexTokenHint: '瀏覽器F12->網絡，從Plex請求URL中獲取的X-Plex-Token',\n    username: '用戶名',\n    usernameHint: '登錄用戶名',\n    password: '密碼',\n    syncLibraries: '同步媒體庫',\n    syncLibrariesHint: '只有選中的媒體庫才會被同步',\n    scanMode: '掃描模式',\n    scanModeHint: '用於全庫刷新和按庫刷新：新添加和修改 / 補充缺失 / 覆蓋掃描',\n    verifySsl: '校驗 SSL 憑證',\n    verifySslHint: '開啟後會校驗 HTTPS 憑證；如使用自簽憑證可關閉',\n    scanModeOptions: {\n      newAndModified: '新添加和修改',\n      supplementMissing: '補充缺失',\n      fullOverride: '覆蓋掃描',\n    },\n    nameExists: '【{name}】已存在，請替換為其他名稱',\n  },\n  bangumi: {\n    category: '類別',\n    sort: '排序',\n    year: '年份',\n    cat: {\n      other: '其他',\n      tv: 'TV',\n      ova: 'OVA',\n      movie: 'Movie',\n      web: 'WEB',\n    },\n    sortType: {\n      rank: '排名',\n      date: '日期',\n    },\n  },\n  tmdb: {\n    type: '類型',\n    sort: '排序',\n    genre: '風格',\n    language: '語言',\n    rating: '評分',\n    sortType: {\n      popularityDesc: '熱度降序',\n      popularityAsc: '熱度升序',\n      releaseDateDesc: '上映日期降序',\n      releaseDateAsc: '上映日期升序',\n      firstAirDateDesc: '首播日期降序',\n      firstAirDateAsc: '首播日期升序',\n      voteAverageDesc: '評分降序',\n      voteAverageAsc: '評分升序',\n      time: '最新',\n      count: '熱門',\n      rating: '評分',\n    },\n    genreType: {\n      action: '動作',\n      adventure: '冒險',\n      animation: '動畫',\n      comedy: '喜劇',\n      crime: '犯罪',\n      documentary: '紀錄片',\n      drama: '劇情',\n      family: '家庭',\n      fantasy: '奇幻',\n      history: '歷史',\n      horror: '恐怖',\n      music: '音樂',\n      mystery: '懸疑',\n      romance: '愛情',\n      scienceFiction: '科幻',\n      tvMovie: '電視電影',\n      thriller: '驚悚',\n      war: '戰爭',\n      western: '西部',\n      actionAdventure: '動作冒險',\n      kids: '兒童',\n      news: '新聞',\n      reality: '真人秀',\n      sciFiFantasy: '科幻奇幻',\n      soap: '肥皂劇',\n      talk: '戲劇',\n      warPolitics: '戰爭政治',\n    },\n    languageType: {\n      zh: '中文',\n      en: '英語',\n      ja: '日語',\n      ko: '韓語',\n      fr: '法語',\n      de: '德語',\n      es: '西班牙語',\n      it: '意大利語',\n      ru: '俄語',\n      pt: '葡萄牙語',\n      ar: '阿拉伯語',\n      hi: '印地語',\n      th: '泰語',\n    },\n  },\n  douban: {\n    type: '類型',\n    sort: '排序',\n    genre: '風格',\n    zone: '地區',\n    year: '年代',\n    sortType: {\n      comprehensive: '綜合排序',\n      releaseDate: '首播時間',\n      recentHot: '近期熱度',\n      highScore: '高分優先',\n    },\n    genreType: {\n      comedy: '喜劇',\n      romance: '愛情',\n      action: '動作',\n      scienceFiction: '科幻',\n      animation: '動畫',\n      mystery: '懸疑',\n      crime: '犯罪',\n      thriller: '驚悚',\n      adventure: '冒險',\n      music: '音樂',\n      history: '歷史',\n      fantasy: '奇幻',\n      horror: '恐怖',\n      war: '戰爭',\n      biography: '傳記',\n      musical: '歌舞',\n      martialArts: '武俠',\n      erotic: '情色',\n      disaster: '災難',\n      western: '西部',\n      documentary: '紀錄片',\n      shortFilm: '短片',\n    },\n    zoneType: {\n      chinese: '華語',\n      europeanAmerican: '歐美',\n      korean: '韓國',\n      japanese: '日本',\n      mainlandChina: '中國大陸',\n      usa: '美國',\n      hongKong: '中國香港',\n      taiwan: '中國台灣',\n      uk: '英國',\n      france: '法國',\n      germany: '德國',\n      italy: '義大利',\n      spain: '西班牙',\n      india: '印度',\n      thailand: '泰國',\n      russia: '俄羅斯',\n      canada: '加拿大',\n      australia: '澳大利亞',\n      ireland: '愛爾蘭',\n      sweden: '瑞典',\n      brazil: '巴西',\n      denmark: '丹麥',\n    },\n    yearType: {\n      '2020s': '2020年代',\n      '2010s': '2010年代',\n      '2000s': '2000年代',\n      '1990s': '90年代',\n      '1980s': '80年代',\n      '1970s': '70年代',\n      '1960s': '60年代',\n    },\n  },\n  directory: {\n    alias: '目錄別名',\n    mediaType: '媒體類型',\n    mediaCategory: '媒體分類',\n    resourceStorage: '資源存儲',\n    resourceDirectory: '資源目錄',\n    sortByType: '按類型排序',\n    sortByCategory: '按分類排序',\n    autoTransfer: '自動轉移',\n    monitorMode: '監控模式',\n    libraryStorage: '媒體庫存儲',\n    libraryDirectory: '媒體庫目錄',\n    transferType: '轉移方式',\n    transferTypeHint: '文件操作整理方式，硬連結節省空間，複製更安全',\n    overwriteMode: '覆蓋模式',\n    overwriteModeHint: '當目標文件已存在時的處理方式',\n    smartRename: '智能重命名',\n    scrapingMetadata: '刮削元數據',\n    sendNotification: '發送通知',\n    noTransfer: '不轉移',\n    downloaderMonitor: '下載器監控',\n    directoryMonitor: '目錄監控',\n    manualTransfer: '手動轉移',\n    performanceMode: '性能模式',\n    compatibilityMode: '兼容模式',\n    pleaseSelectStorage: '請選擇存儲',\n    pleaseSelectLibraryStorage: '請選擇媒體庫存儲',\n    pleaseSelectDownloadStorage: '請選擇下載存儲',\n    noSupportedTransferType: '無支持的轉移方式',\n    never: '從不',\n    always: '總是',\n    byFileSize: '按文件大小',\n    keepLatestOnly: '僅保留最新',\n  },\n  validators: {\n    required: '此項為必填項',\n    number: '請輸入數字',\n  },\n  folder: {\n    settingAppearance: '設定外觀',\n    rename: '重新命名',\n    deleteFolder: '刪除資料夾',\n    folderNameCannotBeEmpty: '資料夾名稱不能為空',\n    confirmDeleteFolder: '確定要刪除資料夾 \"{folderName}\" 嗎？資料夾中的插件將移回主列表。',\n    folderSettingsSaved: '資料夾設定已儲存',\n    renameFolder: '重新命名資料夾',\n    folderName: '資料夾名稱',\n    folderAppearanceSettings: '資料夾外觀設定',\n    showFolderIcon: '顯示資料夾圖示',\n    icon: '圖示',\n    iconColor: '圖示顏色',\n    backgroundGradient: '背景漸變',\n    customBackgroundImageURL: '自定義背景圖片URL（可選）',\n    customBackgroundImageHint: '支援網路圖片URL，留空則使用漸變背景',\n    pluginCount: '{count} 個插件',\n  },\n  setupWizard: {\n    title: '歡迎使用 MoviePilot ！',\n    subtitle: '按向導完成配置，即刻開始使用。',\n    completed: '設定精靈完成！',\n    failed: '設定精靈失敗，請重試',\n    complete: '完成設定',\n    loading: '正在載入配置資料...',\n    testing: '正在測試',\n    connectivityTestSuccess: '連通性測試通過',\n    connectivityTestFailed: '連通性測試失敗',\n    testingStorage: '正在測試存儲目錄',\n    checkingStorage: '檢查存儲目錄連通性',\n    testingDownloader: '正在測試下載器',\n    checkingDownloader: '檢查下載器連通性',\n    testingMediaServer: '正在測試媒體服務器',\n    checkingMediaServer: '檢查媒體服務器連通性',\n    testingNotification: '正在測試消息通知',\n    checkingNotification: '檢查消息通知連通性',\n    testFailedHint: '請檢查配置是否正確，修改後可以重新測試',\n    unsupportedDownloaderType: '不支援的下載器類型: {type}',\n    unsupportedMediaServerType: '不支援的媒體服務器類型: {type}',\n    unsupportedNotificationType: '不支援的通知類型: {type}',\n    storageTestFailed: '存儲目錄測試失敗',\n    downloaderTestFailed: '下載器測試失敗',\n    downloaderNotSelected: '未選擇下載器',\n    mediaServerTestFailed: '媒體服務器測試失敗',\n    mediaServerNotSelected: '未選擇媒體服務器',\n    notificationTestFailed: '消息通知測試失敗',\n    notificationNotSelected: '未選擇通知類型',\n    saveStepFailed: '保存步驟設置失敗',\n    basicSettingsSaved: '基礎設置保存成功',\n    saveBasicSettingsFailed: '保存基礎設置失敗',\n    storageSettingsSaved: '存儲設置保存成功',\n    saveStorageSettingsFailed: '保存存儲設置失敗',\n    downloaderSettingsSaved: '下載器設置保存成功',\n    saveDownloaderSettingsFailed: '保存下載器設置失敗',\n    mediaServerSettingsSaved: '媒體服務器設置保存成功',\n    saveMediaServerSettingsFailed: '保存媒體服務器設置失敗',\n    notificationSettingsSaved: '通知設置保存成功',\n    saveNotificationSettingsFailed: '保存通知設置失敗',\n    saveSiteAuthSettingsFailed: '保存用戶站點認證設置失敗：{message}',\n    saveAgentSettingsFailed: '保存智能助手設置失敗',\n    preferenceSettingsSaved: '偏好設置保存成功',\n    savePreferenceSettingsFailed: '保存偏好設置失敗',\n    passwordUpdateSuccess: '密碼更新成功',\n    userCreateSuccess: '使用者建立成功',\n    passwordUpdateFailed: '密碼更新失敗',\n    basic: {\n      title: '基礎設定',\n      description: '設定存取網域、用戶名密碼和網路配置',\n      appDomain: '存取網域',\n      appDomainHint: '用於發送通知時，新增快速跳轉位址',\n      wallpaper: '背景桌布',\n      wallpaperHint: '選擇登入頁面背景來源',\n      recognizeSource: '識別資料來源',\n      recognizeSourceHint: '設定預設媒體資訊識別資料來源',\n      apiToken: 'API 權杖',\n      apiTokenHint: '訪問MoviePilot API 需要的訪問令牌，請記錄下來以便後續使用',\n      currentUserHint: '目前使用者，不可修改',\n      passwordOptionalHint: '留空表示不修改密碼',\n      confirmPasswordHint: '確認新密碼',\n      apiTokenRequired: 'API Token 不能為空',\n    },\n    siteAuth: {\n      title: '用戶認證',\n      description: '配置用戶站點認證與輔助認證',\n      info: '用戶站點認證說明',\n      infoDesc: '完成站點認證後可解鎖站點能力與部分插件權限。此步驟可選，後續也可在個人選單中繼續配置。',\n      selectSiteHint: '選擇一個支援認證的站點，並填寫該站點要求的認證參數',\n      submitHint: '點擊下一步時將立即向認證站點發起校驗，認證成功後會保存當前參數。',\n      siteConfigNotExist: '認證站點配置不存在',\n      fieldRequired: '請輸入{name}',\n    },\n    storage: {\n      title: '儲存',\n      description: '設定下載目錄和媒體庫目錄',\n      info: '儲存設定說明',\n      infoDesc: '設定本機儲存目錄，用於下載和媒體庫管理',\n      downloadPath: '下載目錄',\n      downloadPathHint: '設定下載檔案的儲存路徑',\n      libraryPath: '媒體庫目錄',\n      libraryPathHint: '設定媒體檔案的儲存路徑',\n      downloadPathRequired: '下載目錄不能為空',\n      libraryPathRequired: '媒體庫目錄不能為空',\n    },\n    downloader: {\n      title: '下載器',\n      description: '設定下載器',\n      info: '下載器設定說明',\n      infoDesc: '設定下載器用於下載資源，可選擇qBittorrent或Transmission',\n      type: '下載器類型',\n      typeHint: '選擇要使用的下載器類型',\n      name: '下載器名稱',\n      nameHint: '為下載器設定一個名稱',\n      qbittorrentConfig: 'qBittorrent 設定',\n      transmissionConfig: 'Transmission 設定',\n      host: '伺服器位址',\n      username: '使用者名稱',\n      password: '密碼',\n      downloadPath: '下載路徑',\n    },\n    mediaServer: {\n      title: '媒體伺服器',\n      description: '設定媒體伺服器',\n      info: '媒體伺服器設定說明',\n      infoDesc: '設定媒體伺服器用於媒體庫管理，可選擇Emby、Jellyfin、Plex、飛牛影視或綠聯影視',\n      type: '媒體伺服器類型',\n      typeHint: '選擇要使用的媒體伺服器類型',\n      name: '伺服器名稱',\n      nameHint: '為媒體伺服器設定一個名稱',\n      embyConfig: 'Emby 設定',\n      jellyfinConfig: 'Jellyfin 設定',\n      plexConfig: 'Plex 設定',\n      host: '伺服器位址',\n      apiKey: 'API 金鑰',\n      token: '存取權杖',\n    },\n    notification: {\n      title: '通知',\n      description: '設定通知管道',\n      info: '通知設定說明',\n      infoDesc: '設定通知管道用於接收系統訊息（可選）',\n      type: '通知類型',\n      typeHint: '選擇要使用的通知管道類型',\n      name: '通知名稱',\n      nameHint: '為通知管道設定一個名稱',\n      telegramConfig: 'Telegram 設定',\n      emailConfig: '郵件設定',\n      botToken: '機器人權杖',\n      chatId: '聊天ID',\n      smtpServer: 'SMTP 伺服器',\n      smtpPort: 'SMTP 連接埠',\n      senderEmail: '發送信箱',\n      senderPassword: '發送密碼',\n      receiverEmail: '接收信箱',\n    },\n    agent: {\n      title: '智能助手',\n      description: '配置 Agent 助手與 LLM 參數',\n      info: '智能助手配置說明',\n      infoDesc: '啟用後可在消息對話中使用 Agent 能力，也可開啟失敗整理接管與智能推薦。',\n      providerRequired: 'LLM 提供商不能為空',\n      apiKeyRequired: 'LLM API 密鑰不能為空',\n      modelRequired: 'LLM 模型名稱不能為空',\n      maxContextTokensRequired: 'LLM 最大上下文 Token 數量必須大於 0',\n      recommendMaxItemsRequired: '智能推薦分析條目上限必須大於 0',\n    },\n    preferences: {\n      title: '資源偏好',\n      description: '設定資源下載偏好',\n      info: '資源偏好說明',\n      infoDesc: '設定資源下載的偏好，系統將根據這些偏好自動選擇最佳資源',\n      quality: '品質偏好',\n      qualityHint: '選擇偏好的影片品質',\n      subtitle: '字幕偏好',\n      subtitleHint: '選擇偏好的字幕類型',\n      resolution: '解析度偏好',\n      resolutionHint: '選擇偏好的影片解析度',\n      presetRules: '預設規則',\n      detailedConfig: '詳細設定',\n      quickPresets: '快速預設',\n      quickPresetsDesc: '選擇預設配置，系統將自動應用對應的規則',\n      personalizationOptions: '個性化選項',\n      personalizationOptionsDesc: '根據您的需求調整規則',\n      excludeDolbyVision: '排除杜比視界',\n      excludeDolbyVisionHint: '選中後規則中將排除杜比視界資源',\n      excludeBluray: '排除藍光原盤',\n      excludeBlurayHint: '選中後規則中將排除藍光原盤資源',\n      presets: {\n        '4k-enthusiast': {\n          name: '4K發燒友',\n          description: '追求最高畫質，優先4K',\n        },\n        'balanced': {\n          name: '平衡模式',\n          description: '畫質與儲存空間的平衡選擇',\n        },\n        'space-saver': {\n          name: '節省空間',\n          description: '優先較小檔案，節省儲存空間',\n        },\n        'free-priority': {\n          name: '免費優先',\n          description: '優先免費資源，其它的沒有要求',\n        },\n      },\n    },\n  },\n}\n"
  },
  {
    "path": "src/main.ts",
    "content": "// 1. 配置与兼容性\nimport './ace-config'\nimport '@/@core/utils/compatibility'\nimport '@/@iconify/icons-bundle'\nimport '@/plugins/webfontloader'\n\n// 2. 核心插件和 UI 框架\nimport { createApp } from 'vue'\nimport vuetify from '@/plugins/vuetify'\nimport router from '@/router'\nimport pinia from '@/stores/index'\nimport i18n from '@/plugins/i18n'\n\n// 3. 全局组件\nimport App from '@/App.vue'\nimport { VAceEditor } from 'vue3-ace-editor'\nimport { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'\nimport { CronVuetify } from '@vue-js-cron/vuetify'\n\n// 4. 工具函数和其他辅助模块\nimport { loadRemoteComponents } from './utils/federationLoader'\n\n// 5. 其他插件和功能模块\nimport Toast from 'vue-toastification'\nimport ConfirmDialog from '@/composables/useConfirm'\nimport VueApexCharts from 'vue3-apexcharts'\n\n// 6. 注册自定义组件\nimport DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'\nimport ScrollToTopBtn from '@/@core/components/ScrollToTopBtn.vue'\nimport PageContentTitle from './@core/components/PageContentTitle.vue'\nimport MediaCard from './components/cards/MediaCard.vue'\nimport PosterCard from './components/cards/PosterCard.vue'\nimport BackdropCard from './components/cards/BackdropCard.vue'\nimport PersonCard from './components/cards/PersonCard.vue'\nimport MediaInfoCard from './components/cards/MediaInfoCard.vue'\nimport TorrentCard from './components/cards/TorrentCard.vue'\nimport MediaIdSelector from './components/misc/MediaIdSelector.vue'\nimport CronField from './components/field/CronField.vue'\nimport PathField from './components/field/PathField.vue'\nimport HeaderTab from './layouts/components/HeaderTab.vue'\n\n// 7. 样式文件 - 合并为单一导入\nimport '@/styles/main.scss'\n\n// 8. 状态恢复插件\nimport stateRestorePlugin from '@/plugins/stateRestore'\n\n// 9. 后台优化工具\nimport { backgroundManager } from '@/utils/backgroundManager'\nimport { sseManagerSingleton } from '@/utils/sseManager'\n\n// 创建Vue实例\nconst app = createApp(App)\n\n// 1. 注册pinia\napp.use(pinia)\n\n// 异步加载远程组件（不阻塞启动）\nloadRemoteComponents().catch(error => {\n  console.error('Failed to load remote components', error)\n})\n\n// 2. 注册 UI 框架\napp.use(vuetify)\n\n// 3. 注册路由\napp.use(router)\n\n// 4. 注册状态恢复插件\napp.use(stateRestorePlugin)\n\n// 5. 注册全局组件\napp\n  .component('VAceEditor', VAceEditor)\n  .component('VApexChart', VueApexCharts)\n  .component('VCronVuetify', CronVuetify)\n  .component('VDialogCloseBtn', DialogCloseBtn)\n  .component('VScrollToTopBtn', ScrollToTopBtn)\n  .component('VMediaCard', MediaCard)\n  .component('VPosterCard', PosterCard)\n  .component('VBackdropCard', BackdropCard)\n  .component('VPersonCard', PersonCard)\n  .component('VMediaInfoCard', MediaInfoCard)\n  .component('VTorrentCard', TorrentCard)\n  .component('VMediaIdSelector', MediaIdSelector)\n  .component('VCronField', CronField)\n  .component('VPathField', PathField)\n  .component('VHeaderTab', HeaderTab)\n  .component('VPageContentTitle', PageContentTitle)\n\n// 6. 注册其他插件\napp\n  .use(PerfectScrollbarPlugin)\n  .use(Toast, {\n    position: 'bottom-right',\n    hideProgressBar: true,\n  })\n  .use(ConfirmDialog)\n  .use(i18n)\n  .mount('#app')\n\n// 页面卸载时清理后台管理器\nwindow.addEventListener('beforeunload', () => {\n  backgroundManager.destroy()\n  sseManagerSingleton.closeAllManagers()\n})\n"
  },
  {
    "path": "src/pages/[...all].vue",
    "content": "<script setup lang=\"ts\">\nimport NoDataFound from '@/components/NoDataFound.vue'\nimport { useI18n } from 'vue-i18n'\n\n// 国际化\nconst { t } = useI18n()\n</script>\n\n<template>\n  <div class=\"pt-10\">\n    <NoDataFound error-code=\"404\" :error-title=\"t('notFound.title')\" :error-description=\"t('notFound.description')\">\n      <template #button>\n        <VBtn to=\"/\" class=\"mt-10\" prepend-icon=\"mdi-home\">\n          {{ t('notFound.backButton') }}\n        </VBtn>\n      </template>\n    </NoDataFound>\n  </div>\n</template>\n"
  },
  {
    "path": "src/pages/appcenter.vue",
    "content": "<script setup lang=\"ts\">\nimport { NavMenu } from '@/@layouts/types'\nimport { getNavMenus } from '@/router/i18n-menu'\nimport { usePluginSidebarNavStore, useUserStore } from '@/stores'\nimport { useI18n } from 'vue-i18n'\nimport { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'\nimport { filterMenusByPermission } from '@/utils/permission'\n\n// 国际化\nconst { t } = useI18n()\n\nconst userStore = useUserStore()\nconst pluginSidebarNavStore = usePluginSidebarNavStore()\n\n// 获取用户权限信息\nconst userPermissions = computed(() => ({\n  is_superuser: userStore.superUser,\n  ...userStore.permissions,\n}))\n\n// 应用分组（以header分组）\nconst appGroups = ref<Record<string, NavMenu[]>>({})\n\n// 根据header属性对应用进行分类（含插件侧栏项，与桌面端侧栏一致）\nasync function categorizeApps() {\n  const allMenus = getNavMenus(t)\n  const filteredMenus = filterMenusByPermission(allMenus, userPermissions.value)\n  let menus = filteredMenus.filter((item: NavMenu) => !item.footer)\n\n  await pluginSidebarNavStore.ensureSidebarNav()\n  if (pluginSidebarNavStore.items.length > 0) {\n    const pluginNavMenus = filterPluginSidebarNavEntries(\n      pluginSidebarNavStore.items,\n      t,\n      userPermissions.value,\n    ).map(e => e.navMenu)\n    menus = [...menus, ...pluginNavMenus]\n  }\n\n  const groupedMenus: Record<string, NavMenu[]> = {}\n\n  menus.forEach(menu => {\n    const header = menu.header || t('appcenter.others')\n    if (!groupedMenus[header]) {\n      groupedMenus[header] = []\n    }\n    groupedMenus[header].push(menu)\n  })\n\n  appGroups.value = groupedMenus\n}\n\nonMounted(() => {\n  categorizeApps()\n})\n</script>\n<template>\n  <div class=\"app-settings-container\">\n    <VContainer>\n      <!-- 遍历所有分组 -->\n      <div v-for=\"(apps, header) in appGroups\" :key=\"header\" class=\"mb-3\">\n        <VListSubheader class=\"ps-1\">\n          {{ header }}\n        </VListSubheader>\n        <!-- 分组内容 - 使用卡片包装 -->\n        <VCard variant=\"flat\" class=\"settings-section-card\">\n          <VList lines=\"one\" class=\"settings-list\">\n            <VListItem\n              v-for=\"(app, appIndex) in apps\"\n              :key=\"`${header}-${appIndex}-${String(app.to)}`\"\n              :to=\"app.to || ''\"\n              color=\"primary\"\n              class=\"settings-list-item\"\n              rounded=\"0\"\n            >\n              <template #prepend>\n                <VAvatar size=\"42\" color=\"primary\" variant=\"text\" class=\"me-3\">\n                  <VIcon :icon=\"app.icon as string\" size=\"24\"></VIcon>\n                </VAvatar>\n              </template>\n\n              <VListItemTitle class=\"font-weight-medium\">\n                {{ app.full_title || app.title }}\n              </VListItemTitle>\n\n              <VListItemSubtitle v-if=\"app.description\">\n                {{ app.description }}\n              </VListItemSubtitle>\n\n              <template #append>\n                <VIcon icon=\"mdi-chevron-right\"></VIcon>\n              </template>\n            </VListItem>\n          </VList>\n        </VCard>\n      </div>\n    </VContainer>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.app-settings-container {\n  margin-block: 0;\n  margin-inline: auto;\n  max-inline-size: 960px;\n}\n\n.settings-section-card {\n  overflow: hidden;\n  backdrop-filter: blur(10px);\n  background-color: rgb(var(--v-theme-surface));\n  box-shadow: 0 2px 8px rgba(0, 0, 0, 8%);\n}\n\n.settings-list {\n  padding: 0;\n}\n\n.settings-list-item {\n  padding-block: 8px;\n  padding-inline: 12px;\n  transition: background-color 0.2s;\n\n  &:not(:last-child) {\n    border-block-end: 1px solid rgba(var(--v-border-color), 0.12);\n  }\n\n  &:hover {\n    background-color: rgba(var(--v-theme-primary), 0.05);\n  }\n}\n</style>\n"
  },
  {
    "path": "src/pages/browse.vue",
    "content": "<script setup lang=\"ts\">\nimport MediaCardListView from '@/views/discover/MediaCardListView.vue'\nimport PersonCardListView from '@/views/discover/PersonCardListView.vue'\nimport { useI18n } from 'vue-i18n'\n\n// 国际化\nconst { t } = useI18n()\n\n// 输入参数\nconst props = defineProps({\n  // API路径\n  paths: Array as PropType<string[]> | PropType<string>,\n})\n\n// 路由参数\nconst route = useRoute()\n\n// 标题\nlet title = route.query?.title?.toString()\n\n// 类型\nconst type = route.query?.type?.toString()\nif (type === 'person') title = t('browse.actor') + ': ' + title\n\n// 计算API路径\nfunction getApiPath(paths: string[] | string) {\n  if (Array.isArray(paths)) return paths.join('/')\n  else return paths\n}\n</script>\n\n<template>\n  <div>\n    <VPageContentTitle :title=\"title\" />\n    <PersonCardListView v-if=\"type === 'person'\" :apipath=\"getApiPath(props.paths || '')\" :params=\"route.query\" />\n    <MediaCardListView v-else :apipath=\"getApiPath(props.paths || '')\" :params=\"route.query\" />\n    <Teleport to=\"body\" v-if=\"route.path === '/browse'\">\n      <VScrollToTopBtn />\n    </Teleport>\n  </div>\n</template>\n"
  },
  {
    "path": "src/pages/calendar.vue",
    "content": "<script setup lang=\"ts\">\nimport FullCalendarView from '@/views/subscribe/FullCalendarView.vue'\n</script>\n\n<template>\n  <div>\n    <FullCalendarView />\n  </div>\n</template>\n"
  },
  {
    "path": "src/pages/credits.vue",
    "content": "<script setup lang=\"ts\">\nimport PersonCardListView from '@/views/discover/PersonCardListView.vue'\n\n// 输入参数\nconst props = defineProps({\n  // API路径\n  paths: Array as PropType<string[]> | PropType<string>,\n})\n\n// 路由参数\nconst route = useRoute()\n\n// 标题\nlet title = route.query?.title?.toString()\n\n// 计算API路径\nfunction getApiPath(paths: string[] | string) {\n  if (Array.isArray(paths)) return paths.join('/')\n  else return paths\n}\n</script>\n\n<template>\n  <div>\n    <VPageContentTitle :title=\"title\" />\n    <PersonCardListView :apipath=\"getApiPath(props.paths || '')\" />\n  </div>\n</template>\n"
  },
  {
    "path": "src/pages/dashboard.vue",
    "content": "<script setup lang=\"ts\">\nimport draggable from 'vuedraggable'\nimport api from '@/api'\nimport { isNullOrEmptyObject } from '@/@core/utils'\nimport { DashboardItem } from '@/api/types'\nimport { useUserStore } from '@/stores'\nimport DashboardElement from '@/components/misc/DashboardElement.vue'\nimport { useDisplay } from 'vuetify'\nimport { useDynamicButton } from '@/composables/useDynamicButton'\nimport { useI18n } from 'vue-i18n'\nimport { VCardActions } from 'vuetify/components'\nimport { usePWA } from '@/composables/usePWA'\nimport { getItemColor, initializeItemColors } from '@/utils/colorUtils'\n\n// 国际化\nconst { t } = useI18n()\n\n// APP\nconst display = useDisplay()\n// PWA模式检测\nconst { appMode } = usePWA()\n\n// 路由\nconst route = useRoute()\n\n// 从用户 Store 中获取superuser信息\nconst superUser = useUserStore().superUser\n\n// 是否拉升高度\nconst isElevated = ref(true)\n\n// 是否发送请求的总开关\nconst isRequest = ref(true)\n\n// 计算属性，控制是否拉升高度\nconst elevatedConf = controlledComputed(\n  () => isElevated.value,\n  () => ({\n    class: { 'match-height': isElevated.value },\n  }),\n)\n\n// 所有组件刷新定时器的句柄\nconst refreshTimers = ref<{ [key: string]: NodeJS.Timeout }>({})\n\n// 仪表板启用配置\nconst enableConfig = ref<{ [key: string]: boolean }>({\n  mediaStatistic: true,\n  scheduler: false,\n  speed: false,\n  storage: true,\n  weeklyOverview: false,\n  cpu: false,\n  memory: false,\n  network: false,\n  library: true,\n  playing: true,\n  latest: true,\n})\n\n// 仪表板顺序配置\nconst orderConfig = ref<{ id: string; key: string }[]>([])\n\n// 仪表板配置\nconst dashboardConfigs = ref<DashboardItem[]>([\n  {\n    id: 'storage',\n    name: t('dashboard.storage'),\n    key: '',\n    attrs: {},\n    cols: { cols: 12, md: 4 },\n    elements: [],\n  },\n  {\n    id: 'mediaStatistic',\n    name: t('dashboard.mediaStatistic'),\n    key: '',\n    attrs: {},\n    cols: { cols: 12, md: 8 },\n    elements: [],\n  },\n  {\n    id: 'weeklyOverview',\n    name: t('dashboard.weeklyOverview'),\n    key: '',\n    attrs: {},\n    cols: { cols: 12, md: 4 },\n    elements: [],\n  },\n  {\n    id: 'speed',\n    name: t('dashboard.realTimeSpeed'),\n    key: '',\n    attrs: {},\n    cols: { cols: 12, md: 4 },\n    elements: [],\n  },\n  {\n    id: 'scheduler',\n    name: t('dashboard.scheduler'),\n    key: '',\n    attrs: {},\n    cols: { cols: 12, md: 4 },\n    elements: [],\n  },\n  {\n    id: 'cpu',\n    name: t('dashboard.cpu'),\n    key: '',\n    attrs: {},\n    cols: { cols: 12, md: 6 },\n    elements: [],\n  },\n  {\n    id: 'memory',\n    name: t('dashboard.memory'),\n    key: '',\n    attrs: {},\n    cols: { cols: 12, md: 6 },\n    elements: [],\n  },\n  {\n    id: 'network',\n    name: t('dashboard.network'),\n    key: '',\n    attrs: {},\n    cols: { cols: 12, md: 6 },\n    elements: [],\n  },\n  {\n    id: 'library',\n    name: t('dashboard.library'),\n    key: '',\n    attrs: {},\n    cols: { cols: 12 },\n    elements: [],\n  },\n  {\n    id: 'playing',\n    name: t('dashboard.playing'),\n    key: '',\n    attrs: {},\n    cols: { cols: 12 },\n    elements: [],\n  },\n  {\n    id: 'latest',\n    name: t('dashboard.latest'),\n    key: '',\n    attrs: {},\n    cols: { cols: 12 },\n    elements: [],\n  },\n])\n\n// 插件的仪表板元信息\nconst pluginDashboardMeta = ref<any[]>([])\n\n// 插件仪表板的刷新状态\nconst pluginDashboardRefreshStatus = ref<{ [key: string]: boolean }>({})\n\n// 弹窗\nconst dialog = ref(false)\n\n// 为每个项目生成随机颜色\nconst itemColors = ref<{ [key: string]: string }>({})\n\n// 初始化颜色\nfunction initializeColors() {\n  initializeItemColors(dashboardConfigs.value, item => buildPluginDashboardId(item.id, item.key))\n  dashboardConfigs.value.forEach(item => {\n    const itemId = buildPluginDashboardId(item.id, item.key)\n    itemColors.value[itemId] = getItemColor(itemId)\n  })\n}\n\n// 使用动态按钮钩子\nuseDynamicButton({\n  icon: 'mdi-view-dashboard-edit',\n  onClick: () => {\n    dialog.value = true\n  },\n})\n\n// 加载用户监控面板配置（本地无配置时才加载）\nasync function loadDashboardConfig() {\n  // 显示配置\n  const local_enable = localStorage.getItem('MP_DASHBOARD')\n  if (local_enable) {\n    enableConfig.value = JSON.parse(local_enable)\n  } else {\n    const response = await api.get('/user/config/Dashboard')\n    if (response && response.data && response.data.value) {\n      enableConfig.value = response.data.value\n      localStorage.setItem('MP_DASHBOARD', JSON.stringify(response.data.value))\n    }\n  }\n  // 顺序配置\n  const local_order = localStorage.getItem('MP_DASHBOARD_ORDER')\n  if (local_order) {\n    orderConfig.value = JSON.parse(local_order)\n  } else {\n    const response2 = await api.get('/user/config/DashboardOrder')\n    if (response2 && response2.data && response2.data.value) {\n      orderConfig.value = response2.data.value\n      localStorage.setItem('MP_DASHBOARD_ORDER', JSON.stringify(orderConfig.value))\n    }\n  }\n  // 是否拉升高度\n  const local_elevated = localStorage.getItem('MP_DASHBOARD_ELEVATED')\n  if (local_elevated) isElevated.value = local_elevated === 'true'\n  // 排序\n  if (orderConfig.value) {\n    sortDashboardConfigs()\n  }\n}\n\n// 按order的顺序对dashboardConfigs进行排序\nfunction sortDashboardConfigs() {\n  dashboardConfigs.value.sort((a, b) => {\n    const aIndex = orderConfig.value.findIndex(\n      (item: { id: string; key: string }) => item.id === a.id && item.key === a.key,\n    )\n    const bIndex = orderConfig.value.findIndex(\n      (item: { id: string; key: string }) => item.id === b.id && item.key === b.key,\n    )\n    return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex)\n  })\n}\n\n// 设置项目\nasync function saveDashboardConfig() {\n  // 启用配置\n  const enableString = JSON.stringify(enableConfig.value)\n  localStorage.setItem('MP_DASHBOARD', enableString)\n\n  // 顺序配置，从dashboardConfigs中提取\n  const orderObj = dashboardConfigs.value.map(item => ({ id: item.id, key: item.key }))\n  const orderString = JSON.stringify(orderObj)\n  localStorage.setItem('MP_DASHBOARD_ORDER', orderString)\n\n  // 是否拉升高度\n  localStorage.setItem('MP_DASHBOARD_ELEVATED', isElevated.value.toString())\n\n  // 保存到服务端\n  try {\n    await api.post('/user/config/Dashboard', enableConfig.value)\n    await api.post('/user/config/DashboardOrder', orderObj)\n  } catch (error) {\n    console.error(error)\n  }\n  // 保存后重新获取插件仪表板\n  getPluginDashboardMeta()\n  dialog.value = false\n}\n\n// 构造插件仪表板主ID\nfunction buildPluginDashboardId(plugin_id: string, key: string) {\n  if (!key) return plugin_id\n  return plugin_id + ':' + key\n}\n\n// 调用API获取所有插件的仪表板元信息\nasync function getPluginDashboardMeta() {\n  // 只有超级用户才能获取\n  if (!superUser) return\n  pluginDashboardMeta.value = await api.get('/plugin/dashboard/meta')\n  try {\n    if (!isNullOrEmptyObject(pluginDashboardMeta.value)) {\n      // 下载插件仪表板配置\n      pluginDashboardMeta.value.forEach(async (pluginDashboard: { id: string; key: string }) => {\n        const pluginDashboardId = buildPluginDashboardId(pluginDashboard.id, pluginDashboard.key)\n        // 初始化插件仪表板的刷新状态\n        pluginDashboardRefreshStatus.value[pluginDashboardId] = true\n        await getPluginDashboard(pluginDashboard.id, pluginDashboard.key)\n      })\n    }\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 获取一个插件的仪表板配置项\nasync function getPluginDashboard(id: string, key: string) {\n  try {\n    const url = key ? `/plugin/dashboard/${id}/${key}` : `/plugin/dashboard/${id}`\n    api.get(url).then((res: any) => {\n      if (res) {\n        // 名称替换为元信息的名称\n        const meta = pluginDashboardMeta.value.find(\n          (item: { id: string; key: string }) => item.id === id && item.key === key,\n        )\n        if (meta) res.name = meta.name\n        // 保存到仪表板配置中，如果已经存在则替换\n        const index = dashboardConfigs.value.findIndex(\n          (item: { id: string; key: string }) => item.id === id && item.key === key,\n        )\n        if (index !== -1) {\n          dashboardConfigs.value[index] = res\n        } else {\n          dashboardConfigs.value.push(res)\n          // 为新增的插件仪表板生成颜色\n          const pluginDashboardId = buildPluginDashboardId(id, key)\n          if (!itemColors.value[pluginDashboardId]) {\n            itemColors.value[pluginDashboardId] = getItemColor(pluginDashboardId)\n          }\n          // 排序\n          sortDashboardConfigs()\n        }\n        const pluginDashboardId = buildPluginDashboardId(id, key)\n        // 定时刷新\n        if (\n          res.attrs?.refresh &&\n          pluginDashboardRefreshStatus.value[pluginDashboardId] &&\n          enableConfig.value[pluginDashboardId] &&\n          isRequest.value\n        ) {\n          // 清除之前的定时器\n          if (refreshTimers.value[pluginDashboardId]) {\n            clearTimeout(refreshTimers.value[pluginDashboardId])\n          }\n          // 设置新的定时器\n          let timer = setTimeout(() => {\n            getPluginDashboard(id, key)\n          }, res.attrs.refresh * 1000)\n          refreshTimers.value[pluginDashboardId] = timer\n        }\n      }\n    })\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 拖动排序结束\nfunction dragOrderEnd() {\n  // 保存数据\n  saveDashboardConfig()\n}\n\nonBeforeMount(async () => {\n  await loadDashboardConfig()\n  initializeColors()\n  getPluginDashboardMeta()\n})\n\nonActivated(() => {\n  isRequest.value = true\n})\n\nonDeactivated(() => {\n  isRequest.value = false\n})\n</script>\n\n<template>\n  <!-- 仪表板 -->\n  <draggable\n    v-model=\"dashboardConfigs\"\n    @end=\"dragOrderEnd\"\n    handle=\".cursor-move\"\n    item-key=\"id\"\n    tag=\"VRow\"\n    :component-data=\"elevatedConf\"\n  >\n    <template #item=\"{ element }\">\n      <VCol v-if=\"enableConfig[buildPluginDashboardId(element.id, element.key)] && element.cols\" v-bind:=\"element.cols\">\n        <DashboardElement\n          :config=\"element\"\n          :allow-refresh=\"isRequest\"\n          v-model:refreshStatus=\"pluginDashboardRefreshStatus[buildPluginDashboardId(element.id, element.key)]\"\n        />\n      </VCol>\n    </template>\n  </draggable>\n\n  <!-- 底部操作按钮（只在非移动设备上显示） -->\n  <Teleport to=\"body\" v-if=\"route.path === '/dashboard'\">\n    <div v-if=\"!appMode\" class=\"compact-fab-stack\">\n      <VFab\n        icon=\"mdi-view-dashboard-edit\"\n        color=\"primary\"\n        appear\n        class=\"compact-fab compact-fab--primary\"\n        @click=\"dialog = true\"\n      />\n    </div>\n  </Teleport>\n\n  <!-- 弹窗，根据配置生成选项 -->\n  <VDialog v-if=\"dialog\" v-model=\"dialog\" max-width=\"35rem\" :fullscreen=\"!display.mdAndUp.value\" scrollable>\n    <VCard>\n      <VCardItem>\n        <VCardTitle>\n          <VIcon icon=\"mdi-tune\" size=\"small\" class=\"me-2\" />\n          {{ t('dashboard.settings') }}\n        </VCardTitle>\n        <VDialogCloseBtn @click=\"dialog = false\" />\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <p class=\"settings-hint\">{{ t('dashboard.chooseContent') }}</p>\n        <div class=\"settings-grid\">\n          <div\n            v-for=\"item in dashboardConfigs\"\n            :key=\"buildPluginDashboardId(item.id, item.key)\"\n            class=\"setting-item\"\n            :class=\"{\n              'enabled': enableConfig[buildPluginDashboardId(item.id, item.key)],\n            }\"\n            :style=\"{ '--item-color': itemColors[buildPluginDashboardId(item.id, item.key)] }\"\n            @click=\"\n              enableConfig[buildPluginDashboardId(item.id, item.key)] =\n                !enableConfig[buildPluginDashboardId(item.id, item.key)]\n            \"\n          >\n            <div class=\"setting-item-inner\">\n              <div class=\"setting-check\">\n                <VIcon\n                  :icon=\"\n                    enableConfig[buildPluginDashboardId(item.id, item.key)] ? 'mdi-check-circle' : 'mdi-circle-outline'\n                  \"\n                  :color=\"enableConfig[buildPluginDashboardId(item.id, item.key)] ? 'primary' : undefined\"\n                  size=\"small\"\n                />\n              </div>\n              <span class=\"setting-label\">{{ item.attrs?.title ?? item.name }}</span>\n            </div>\n          </div>\n        </div>\n        <p class=\"mt-3\">\n          <VSwitch v-model=\"isElevated\" :label=\"t('dashboard.adaptiveHeight')\" />\n        </p>\n      </VCardText>\n      <VCardActions class=\"pt-3\">\n        <VSpacer />\n        <VBtn @click=\"saveDashboardConfig\">\n          <template #prepend>\n            <VIcon icon=\"mdi-content-save\" />\n          </template>\n          {{ t('common.save') }}\n        </VBtn>\n      </VCardActions>\n    </VCard>\n  </VDialog>\n</template>\n<style lang=\"scss\" scoped>\n.settings-card-header {\n  padding-block: 16px;\n  padding-inline: 20px;\n}\n\n.settings-hint {\n  color: rgba(var(--v-theme-on-surface), 0.7);\n  font-size: 0.9rem;\n  margin-block-end: 16px;\n}\n\n.settings-grid {\n  display: grid;\n  gap: 12px;\n  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));\n}\n\n.setting-label {\n  flex: 1;\n  color: rgba(var(--v-theme-on-surface), 0.8);\n  font-size: 0.9rem;\n  font-weight: 500;\n  line-height: 1.2;\n  transition: color 0.2s ease;\n}\n\n.setting-item {\n  position: relative;\n  overflow: hidden;\n  border: 1px solid rgba(var(--v-theme-on-surface), 0.1);\n  border-radius: 8px;\n  background-color: rgba(var(--v-theme-surface-variant), 0.3);\n  cursor: pointer;\n  padding-block: 10px;\n  padding-inline: 12px;\n  transition: all 0.2s ease;\n\n  &::before {\n    position: absolute;\n    background-color: var(--item-color, #4caf50);\n    block-size: 100%;\n    content: '';\n    inline-size: 4px;\n    inset-block-start: 0;\n    inset-inline-start: 0;\n    transition: background-color 0.3s ease;\n  }\n\n  &:hover {\n    transform: translateY(-2px);\n  }\n\n  &.enabled {\n    border-color: rgba(var(--v-theme-primary), 0.3);\n    background-color: rgba(var(--v-theme-primary), 0.1);\n\n    .setting-label {\n      color: rgba(var(--v-theme-primary), 0.9);\n      font-weight: 500;\n    }\n  }\n}\n\n.setting-item-inner {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.setting-check {\n  flex-shrink: 0;\n}\n\n@media (width <= 600px) {\n  .settings-grid {\n    grid-template-columns: repeat(2, 1fr);\n  }\n}\n</style>\n"
  },
  {
    "path": "src/pages/discover.vue",
    "content": "<script setup lang=\"ts\">\nimport { getDiscoverTabs } from '@/router/i18n-menu'\nimport draggable from 'vuedraggable'\nimport TheMovieDbView from '@/views/discover/TheMovieDbView.vue'\nimport DoubanView from '@/views/discover/DoubanView.vue'\nimport BangumiView from '@/views/discover/BangumiView.vue'\nimport ExtraSourceView from '@/views/discover/ExtraSourceView.vue'\nimport { DiscoverSource } from '@/api/types'\nimport api from '@/api'\nimport { useI18n } from 'vue-i18n'\nimport { useDisplay } from 'vuetify'\nimport { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'\nimport { getItemColor, initializeItemColors } from '@/utils/colorUtils'\n\nconst display = useDisplay()\n\n// 国际化\nconst { t } = useI18n()\n\n// 路由\nconst route = useRoute()\n\nconst activeTab = ref('')\n\n// 本地存储键值\nconst localOrderKey = 'MP_DISCOVER_TAB_ORDER'\n\n// 顺序配置\nconst orderConfig = ref<{ name: string }[]>([])\n\n// 标签页\nconst discoverTabs = ref<DiscoverSource[]>([])\n\n// 标签页项\nconst discoverTabItems = computed(() => {\n  return discoverTabs.value.map(item => ({\n    title: item.name,\n    tab: item.mediaid_prefix,\n  }))\n})\n\n// 额外的数据源\nconst extraDiscoverSources = ref<DiscoverSource[]>([])\n\n// 排序对话框\nconst orderConfigDialog = ref(false)\n\n// 为每个项目生成随机颜色\nconst itemColors = ref<{ [key: string]: string }>({})\n\n// 初始化颜色\nfunction initializeColors() {\n  initializeItemColors(discoverTabs.value, item => item.mediaid_prefix)\n  discoverTabs.value.forEach(item => {\n    itemColors.value[item.mediaid_prefix] = getItemColor(item.mediaid_prefix)\n  })\n}\n\n// 初始化发现标签\nfunction initDiscoverTabs() {\n  const tabs = getDiscoverTabs(t)\n  for (const tab of tabs) {\n    discoverTabs.value.push({\n      name: tab.name,\n      mediaid_prefix: tab.tab,\n      api_path: '',\n      filter_params: {},\n      filter_ui: [],\n    })\n  }\n}\n\n// 加载额外的发现数据源\nasync function loadExtraDiscoverSources() {\n  try {\n    extraDiscoverSources.value = await api.get('discover/source')\n    if (extraDiscoverSources.value.length === 0) {\n      return\n    }\n    for (const source of extraDiscoverSources.value) {\n      if (discoverTabs.value.find(tab => tab.mediaid_prefix === source.mediaid_prefix)) {\n        continue\n      }\n      discoverTabs.value.push(source)\n      // 为新增的数据源生成颜色\n      if (!itemColors.value[source.mediaid_prefix]) {\n        itemColors.value[source.mediaid_prefix] = getItemColor(source.mediaid_prefix)\n      }\n    }\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 按order的顺序排序\nfunction sortSubscribeOrder() {\n  if (!orderConfig.value) {\n    return\n  }\n  if (discoverTabs.value.length === 0) {\n    return\n  }\n  discoverTabs.value.sort((a, b) => {\n    const aIndex = orderConfig.value.findIndex((item: { name: string }) => item.name === a.name)\n    const bIndex = orderConfig.value.findIndex((item: { name: string }) => item.name === b.name)\n    return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex)\n  })\n}\n\n// 加载顺序\nasync function loadOrderConfig() {\n  // 顺序配置\n  const local_order = localStorage.getItem(localOrderKey)\n  if (local_order) {\n    orderConfig.value = JSON.parse(local_order)\n  } else {\n    const response = await api.get(`/user/config/${localOrderKey}`)\n    if (response && response.data && response.data.value) {\n      orderConfig.value = response.data.value\n      localStorage.setItem(localOrderKey, JSON.stringify(orderConfig.value))\n    }\n  }\n}\n\n// 保存顺序设置\nasync function saveTabOrder() {\n  orderConfigDialog.value = false\n  // 顺序配置\n  const orderObj = discoverTabs.value.map(item => ({ name: item.name }))\n  orderConfig.value = orderObj\n  const orderString = JSON.stringify(orderObj)\n  localStorage.setItem(localOrderKey, orderString)\n\n  // 保存到服务端\n  try {\n    await api.post(`/user/config/${localOrderKey}`, orderObj)\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 使用动态标签页\nconst { registerHeaderTab } = useDynamicHeaderTab()\n\n// 注册动态标签页（在setup阶段，但使用computed保证响应性）\nregisterHeaderTab({\n  items: discoverTabItems, // 传递computed值，会自动响应变化\n  modelValue: activeTab,\n  appendButtons: [\n    {\n      icon: 'mdi-order-alphabetical-ascending',\n      variant: 'text',\n      color: 'grey',\n      class: 'settings-icon-button',\n      action: () => {\n        orderConfigDialog.value = true\n      },\n    },\n  ],\n})\n\nonBeforeMount(async () => {\n  initDiscoverTabs()\n  initializeColors()\n  await loadOrderConfig()\n  await loadExtraDiscoverSources()\n  sortSubscribeOrder()\n  // 选中第一个标签页\n  if (discoverTabs.value.length > 0) {\n    activeTab.value = discoverTabs.value[0].mediaid_prefix\n  }\n})\n\nonActivated(async () => {\n  await loadExtraDiscoverSources()\n  sortSubscribeOrder()\n  // 如果当前没有选中任何标签页，或者当前选中的标签页不存在，则选中第一个标签页\n  if (!activeTab.value || !discoverTabs.value.find(tab => tab.mediaid_prefix === activeTab.value)) {\n    if (discoverTabs.value.length > 0) {\n      activeTab.value = discoverTabs.value[0].mediaid_prefix\n    }\n  }\n})\n</script>\n\n<template>\n  <div>\n    <VWindow v-model=\"activeTab\" class=\"disable-tab-transition\" :touch=\"false\">\n      <VWindowItem value=\"themoviedb\">\n        <transition name=\"fade-slide\" appear>\n          <div>\n            <TheMovieDbView />\n          </div>\n        </transition>\n      </VWindowItem>\n      <VWindowItem value=\"douban\">\n        <transition name=\"fade-slide\" appear>\n          <div>\n            <DoubanView />\n          </div>\n        </transition>\n      </VWindowItem>\n      <VWindowItem value=\"bangumi\">\n        <transition name=\"fade-slide\" appear>\n          <div>\n            <BangumiView />\n          </div>\n        </transition>\n      </VWindowItem>\n      <VWindowItem v-for=\"item in extraDiscoverSources\" :key=\"item.mediaid_prefix\" :value=\"item.mediaid_prefix\">\n        <transition name=\"fade-slide\" appear>\n          <div>\n            <ExtraSourceView :source=\"item\" />\n          </div>\n        </transition>\n      </VWindowItem>\n    </VWindow>\n    <!-- 弹窗，根据配置生成选项 -->\n    <VDialog\n      v-if=\"orderConfigDialog\"\n      v-model=\"orderConfigDialog\"\n      max-width=\"35rem\"\n      scrollable\n      :fullscreen=\"!display.mdAndUp.value\"\n    >\n      <VCard>\n        <VCardItem>\n          <VCardTitle>\n            <VIcon icon=\"mdi-order-alphabetical-ascending\" size=\"small\" class=\"me-2\" />\n            {{ t('discover.setTabOrder') }}\n          </VCardTitle>\n          <VDialogCloseBtn @click=\"orderConfigDialog = false\" />\n        </VCardItem>\n        <VDivider />\n        <VCardText>\n          <p class=\"settings-hint\">{{ t('discover.dragToReorder') }}</p>\n          <draggable\n            v-model=\"discoverTabs\"\n            handle=\".cursor-move\"\n            item-key=\"mediaid_prefix\"\n            tag=\"div\"\n            :component-data=\"{ 'class': 'settings-grid' }\"\n          >\n            <template #item=\"{ element }\">\n              <VCard\n                variant=\"text\"\n                class=\"setting-item enabled\"\n                :style=\"{ '--item-color': itemColors[element.mediaid_prefix] }\"\n              >\n                <div class=\"setting-item-inner\">\n                  <span class=\"setting-label\">{{ element.name }}</span>\n                  <VIcon icon=\"mdi-drag\" class=\"drag-icon cursor-move\" />\n                </div>\n              </VCard>\n            </template>\n          </draggable>\n        </VCardText>\n        <VCardActions class=\"pt-3\">\n          <VSpacer />\n          <VBtn @click=\"saveTabOrder\">\n            <template #prepend>\n              <VIcon icon=\"mdi-content-save\" />\n            </template>\n            {{ t('common.save') }}\n          </VBtn>\n        </VCardActions>\n      </VCard>\n    </VDialog>\n    <!-- 快速滚动到顶部按钮 -->\n    <Teleport to=\"body\" v-if=\"route.path === '/discover'\">\n      <VScrollToTopBtn />\n    </Teleport>\n  </div>\n</template>\n<style lang=\"scss\" scoped>\n.settings-card-header {\n  padding-block: 16px;\n  padding-inline: 20px;\n}\n\n.settings-hint {\n  color: rgba(var(--v-theme-on-surface), 0.7);\n  font-size: 0.9rem;\n  margin-block-end: 16px;\n}\n\n.settings-grid {\n  display: grid;\n  gap: 12px;\n  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));\n}\n\n.setting-label {\n  flex: 1;\n  color: rgba(var(--v-theme-on-surface), 0.8);\n  font-size: 0.9rem;\n  font-weight: 500;\n  line-height: 1.2;\n  transition: color 0.2s ease;\n}\n\n.setting-item {\n  position: relative;\n  overflow: hidden;\n  border: 1px solid rgba(var(--v-theme-on-surface), 0.1);\n  border-radius: 8px;\n  background-color: rgba(var(--v-theme-surface-variant), 0.3);\n  cursor: pointer;\n  padding-block: 10px;\n  padding-inline: 12px;\n  transition: all 0.2s ease;\n\n  &::before {\n    position: absolute;\n    background-color: var(--item-color, #4caf50);\n    block-size: 100%;\n    content: '';\n    inline-size: 4px;\n    inset-block-start: 0;\n    inset-inline-start: 0;\n    transition: background-color 0.3s ease;\n  }\n\n  &:hover {\n    transform: translateY(-2px);\n  }\n\n  &.enabled {\n    border-color: rgba(var(--v-theme-primary), 0.3);\n    background-color: rgba(var(--v-theme-primary), 0.1);\n\n    .setting-label {\n      color: rgba(var(--v-theme-primary), 0.9);\n      font-weight: 500;\n    }\n  }\n}\n\n.setting-item-inner {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.setting-check {\n  flex-shrink: 0;\n}\n\n.drag-icon {\n  flex-shrink: 0;\n  color: rgba(var(--v-theme-on-surface), 0.5);\n  cursor: move;\n}\n\n@media (width <= 600px) {\n  .settings-grid {\n    grid-template-columns: repeat(2, 1fr);\n  }\n}\n</style>\n"
  },
  {
    "path": "src/pages/downloading.vue",
    "content": "<script setup lang=\"ts\">\nimport api from '@/api'\nimport { DownloaderConf } from '@/api/types'\nimport DownloadingListView from '@/views/reorganize/DownloadingListView.vue'\nimport NoDataFound from '@/components/NoDataFound.vue'\nimport { useI18n } from 'vue-i18n'\nimport { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'\n\n// 国际化\nconst { t } = useI18n()\n\nconst route = useRoute()\nconst activeTab = ref<string>((route.query.tab as string) || '')\n\n// 下载器\nconst downloaders = ref<DownloaderConf[]>([])\n\n// 下载器字典\nconst downloaderItems = computed(() => {\n  return downloaders.value.map(item => ({\n    title: item.name,\n    tab: item.name,\n  }))\n})\n\n// 使用动态标签页\nconst { registerHeaderTab } = useDynamicHeaderTab()\n\n// 调用API查询下载器设置\nasync function loadDownloaderSetting() {\n  try {\n    downloaders.value = await api.get('download/clients')\n    if (downloaders.value && downloaders.value.length > 0 && !activeTab.value)\n      activeTab.value = downloaders.value[0].name\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 注册动态标签页\nconst registerTabs = () => {\n  if (downloaderItems.value.length > 0) {\n    registerHeaderTab({\n      items: downloaderItems,\n      modelValue: activeTab,\n    })\n  }\n}\n\nonMounted(async () => {\n  await loadDownloaderSetting()\n  registerTabs()\n})\n\nonActivated(async () => {\n  await loadDownloaderSetting()\n  registerTabs()\n})\n</script>\n\n<template>\n  <div v-if=\"downloaders.length > 0\">\n    <VWindow v-model=\"activeTab\" class=\"disable-tab-transition\" :touch=\"false\">\n      <VWindowItem v-for=\"item in downloaders\" :value=\"item.name\">\n        <transition name=\"fade-slide\" appear>\n          <div>\n            <DownloadingListView :name=\"item.name\" />\n          </div>\n        </transition>\n      </VWindowItem>\n    </VWindow>\n  </div>\n  <NoDataFound\n    v-else\n    error-code=\"404\"\n    :error-title=\"t('downloading.noDownloader')\"\n    :error-description=\"t('downloading.configureDownloader')\"\n  />\n</template>\n"
  },
  {
    "path": "src/pages/filemanager.vue",
    "content": "<script setup lang=\"ts\">\nimport FileBrowserView from '@/views/reorganize/FileBrowserView.vue'\n</script>\n\n<template>\n  <FileBrowserView />\n</template>\n"
  },
  {
    "path": "src/pages/history.vue",
    "content": "<script setup lang=\"ts\">\nimport TransferHistoryView from '@/views/reorganize/TransferHistoryView.vue'\n</script>\n\n<template>\n  <div>\n    <TransferHistoryView />\n  </div>\n</template>\n"
  },
  {
    "path": "src/pages/login.vue",
    "content": "<script setup lang=\"ts\">\nimport { VForm } from 'vuetify/components/VForm'\nimport { useAuthStore, useUserStore, useGlobalSettingsStore } from '@/stores'\nimport { authState, userState } from '@/stores/types'\nimport { requiredValidator } from '@/@validators'\nimport api from '@/api'\nimport router from '@/router'\nimport logo from '@images/logo.png'\nimport { bufferToBase64Url, base64UrlToUint8Array, urlBase64ToUint8Array } from '@/@core/utils/navigator'\nimport { SUPPORTED_LOCALES, SupportedLocale } from '@/types/i18n'\nimport { getCurrentLocale, setI18nLanguage } from '@/plugins/i18n'\nimport { useTheme } from 'vuetify'\nimport { getNavMenus } from '@/router/i18n-menu'\nimport { filterMenusByPermission } from '@/utils/permission'\nimport type { ApiResponse } from '@/api/types'\n\n// 国际化\nconst { t } = useI18n()\n// 认证 Store\nconst authStore = useAuthStore()\n//用户 Store\nconst userStore = useUserStore()\n// 全局设置 Store\nconst globalSettingsStore = useGlobalSettingsStore()\n\n// 获取有权限的菜单\nconst navMenus = computed(() => getNavMenus(t))\n\n// 表单\nconst form = ref({\n  username: '',\n  password: '',\n  otp_password: '',\n  remember: true,\n})\n\nconst refForm = ref<InstanceType<typeof VForm> | null>(null)\n\n// 密码输入\nconst isPasswordVisible = ref(false)\n\n// 错误信息\nconst errorMessage = ref('')\n\n// 是否开启双重验证\nconst isOTP = ref(false)\n\n// 二次验证对话框\nconst mfaDialog = ref(false)\n\n// MFA PassKey loading\nconst mfaPasskeyLoading = ref(false)\n\n// 用户名称输入框\nconst usernameInput = ref()\n\n// 语言选择菜单\nconst langMenu = ref(false)\n\n// 当前语言\nconst currentLocale = ref(getCurrentLocale())\n\n// 当前主题\nconst vuetifyTheme = useTheme()\n\n// 判断是否为透明主题\nconst isTransparentTheme = computed(() => {\n  return vuetifyTheme.name.value === 'transparent'\n})\n\n// 可用的语言列表\nconst locales = Object.values(SUPPORTED_LOCALES)\n\n// 登录按钮 loading\nconst loading = ref(false)\n\n// PassKey 登录按钮 loading\nconst passkeyLoading = ref(false)\n\n// Conditional UI 的 AbortController\nlet conditionalAbortController: AbortController | null = null\n\n// 手动模式的 AbortController（用于防止重复点击）\nlet manualAbortController: AbortController | null = null\n\n// 标记当前是否有手动模式的 PassKey 请求正在进行\nlet isManualPassKeyActive = false\n\n// PassKey 认证核心函数 - 处理 WebAuthn 认证流程\ninterface PassKeyAuthOptions {\n  username?: string // 可选的用户名,用于 MFA 场景\n  isConditional?: boolean // 是否为 Conditional UI 模式\n  signal?: AbortSignal // AbortController 信号\n}\n\n// PassKey API 响应类型\ninterface PassKeyStartResponse {\n  options: string // JSON 字符串\n  challenge: string\n}\n\ninterface PassKeyFinishResponse {\n  access_token: string\n  super_user: boolean\n  user_id: number\n  user_name: string\n  avatar: string\n  level: number\n  permissions: Record<string, boolean>\n  wizard: boolean\n}\n\nasync function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promise<PassKeyFinishResponse> {\n  const { username, isConditional = false, signal } = options\n\n  // 1. 开始认证流程\n  const startResponse = (await api.post(\n    '/mfa/passkey/authenticate/start',\n    username ? { username } : {},\n  )) as ApiResponse<PassKeyStartResponse>\n\n  if (!startResponse.success) {\n    throw new Error(startResponse.message || 'PassKey start failed')\n  }\n\n  const { options: optionsStr, challenge } = startResponse.data\n  const publicKeyOptions = JSON.parse(optionsStr)\n\n  // 2. 调用WebAuthn API\n  const credentialRequestOptions: CredentialRequestOptions = {\n    publicKey: {\n      ...publicKeyOptions,\n      challenge: base64UrlToUint8Array(publicKeyOptions.challenge),\n      allowCredentials: publicKeyOptions.allowCredentials?.map((cred: any) => ({\n        ...cred,\n        id: base64UrlToUint8Array(cred.id),\n      })),\n    },\n  }\n\n  // 如果是 Conditional UI 模式，添加 mediation 和 signal\n  if (isConditional) {\n    credentialRequestOptions.mediation = 'conditional'\n    if (signal) {\n      credentialRequestOptions.signal = signal\n    }\n  }\n\n  const credential = await navigator.credentials.get(credentialRequestOptions)\n\n  // Conditional UI 模式下，用户选择通行密钥后才显示 loading\n  if (isConditional) {\n    passkeyLoading.value = true\n  }\n\n  if (!credential) {\n    throw new Error('No credential selected')\n  }\n\n  // 3. 转换credential为可传输格式\n  const publicKeyCredential = credential as PublicKeyCredential\n  const assertionResponse = publicKeyCredential.response as AuthenticatorAssertionResponse\n  const credentialJSON = {\n    id: publicKeyCredential.id,\n    rawId: bufferToBase64Url(publicKeyCredential.rawId),\n    type: publicKeyCredential.type,\n    response: {\n      authenticatorData: bufferToBase64Url(assertionResponse.authenticatorData),\n      clientDataJSON: bufferToBase64Url(assertionResponse.clientDataJSON),\n      signature: bufferToBase64Url(assertionResponse.signature),\n      userHandle: assertionResponse.userHandle ? bufferToBase64Url(assertionResponse.userHandle) : null,\n    },\n  }\n\n  // 4. 完成认证\n  const finishResponse = (await api.post('/mfa/passkey/authenticate/finish', {\n    credential: credentialJSON,\n    challenge: challenge,\n  })) as PassKeyFinishResponse\n\n  if (!finishResponse || !finishResponse.access_token) {\n    throw new Error('PassKey finish failed: No access token')\n  }\n\n  return finishResponse\n}\n\n// 统一处理 PassKey 认证流程\nasync function handlePassKeyAuth(\n  authOptions: PassKeyAuthOptions,\n  setLoading: (loading: boolean) => void,\n  onSuccess: (response: PassKeyFinishResponse) => Promise<void>,\n) {\n  const { isConditional = false } = authOptions\n  errorMessage.value = ''\n\n  // 检查浏览器环境\n  if (!window.PublicKeyCredential) {\n    if (!isConditional) {\n      if (!window.isSecureContext) {\n        errorMessage.value = t('login.passkeySecureContextRequired')\n      } else {\n        errorMessage.value = t('login.passkeyNotSupported')\n      }\n    }\n    return\n  }\n\n  // 如果是手动触发(非 Conditional UI)\n  if (!isConditional) {\n    // 取消之前的 Conditional UI 请求\n    if (conditionalAbortController) {\n      conditionalAbortController.abort()\n      conditionalAbortController = null\n    }\n\n    // 取消之前的手动请求（防止重复点击）\n    if (manualAbortController) {\n      manualAbortController.abort()\n    }\n\n    // 创建新的 AbortController\n    manualAbortController = new AbortController()\n\n    // 标记手动请求为活跃状态，并立即设置 loading\n    isManualPassKeyActive = true\n    setLoading(true)\n  }\n\n  try {\n    const finishResponse = await authenticateWithPassKey({\n      ...authOptions,\n      signal:\n        isConditional && conditionalAbortController\n          ? conditionalAbortController.signal\n          : !isConditional && manualAbortController\n            ? manualAbortController.signal\n            : undefined,\n    })\n\n    await onSuccess(finishResponse)\n  } catch (error: any) {\n    // Conditional UI 模式下：\n    // 1. 如果 loading 为 false，说明错误发生在用户选择密钥之前（如初始化失败、用户取消等），此时应静默\n    // 2. 如果是 AbortError，始终静默\n    if (isConditional && (!passkeyLoading.value || error.name === 'AbortError')) {\n      console.warn('[PassKey] Conditional UI silenced error:', error)\n      return\n    }\n\n    // 手动模式下的 AbortError 也应该静默（用户重复点击导致）\n    if (!isConditional && error.name === 'AbortError') {\n      console.warn('[PassKey] Manual request aborted (likely due to rapid clicking):', error)\n      return\n    }\n\n    // 设置错误信息\n    if (error.name === 'NotAllowedError') {\n      errorMessage.value = t('login.passkeyAuthCanceled')\n    } else if (error.name === 'NotSupportedError') {\n      errorMessage.value = t('login.passkeyNotSupported')\n    } else if (error.message?.includes('start failed')) {\n      errorMessage.value = t('login.passkeyLoginStartFailed')\n    } else {\n      errorMessage.value = t('login.authFailure')\n    }\n  } finally {\n    // 清除 loading 状态\n    if (!isConditional) {\n      // 手动模式：始终清除，并取消手动活跃标记\n      isManualPassKeyActive = false\n      setLoading(false)\n      manualAbortController = null\n    } else {\n      // Conditional UI 模式：只有在没有手动请求活跃时才清除\n      if (!isManualPassKeyActive && passkeyLoading.value) {\n        passkeyLoading.value = false\n      }\n    }\n  }\n}\n\n// 使用PassKey登录 (支持 Conditional UI)\nasync function loginWithPassKey(isConditional = false) {\n  await handlePassKeyAuth(\n    { isConditional },\n    val => (passkeyLoading.value = val),\n    async response => {\n      await handleLoginSuccess(response)\n    },\n  )\n}\n\n// 切换语言\nasync function switchLanguage(locale: SupportedLocale) {\n  await setI18nLanguage(locale)\n  currentLocale.value = locale\n  langMenu.value = false\n}\n\n// 订阅推送通知\nasync function subscribeForPushNotifications() {\n  if ('serviceWorker' in navigator && 'PushManager' in window) {\n    const registration = await navigator.serviceWorker.ready\n    // 获取订阅信息\n    const subscription = await registration.pushManager.getSubscription().then(function (subscription) {\n      if (subscription === null) {\n        const convertedVapidKey = urlBase64ToUint8Array(import.meta.env.VITE_PUBLIC_VAPID_KEY)\n        return registration.pushManager.subscribe({\n          userVisibleOnly: true,\n          applicationServerKey: convertedVapidKey,\n        })\n      } else {\n        return subscription\n      }\n    })\n    // 发送订阅请求\n    try {\n      await api.post('/message/webpush/subscribe', subscription)\n    } catch (e) {\n      console.error(e)\n    }\n  }\n}\n\n// 登录后处理\nasync function afterLogin(superuser: boolean, userPayload: userState, filteredMenus: any[]) {\n  // 如果需要显示设置向导，跳转到设置向导页面\n  if (userPayload.wizard) {\n    router.push('/setup-wizard')\n  } else {\n    // 如果有原始路径，优先跳转到原始路径\n    if (authStore.originalPath && authStore.originalPath !== '/') {\n      router.push(authStore.originalPath)\n    } else {\n      // 跳转到第一个有权限的菜单\n      router.push(filteredMenus[0].to)\n    }\n  }\n\n  // 订阅推送通知\n  if (superuser) await subscribeForPushNotifications()\n}\n\n// 处理登录成功\nasync function handleLoginSuccess(response: any) {\n  const userPayload: userState = {\n    superUser: response.super_user,\n    userID: response.user_id,\n    userName: response.user_name,\n    avatar: response.avatar,\n    level: response.level,\n    permissions: response.permissions,\n    wizard: response.wizard,\n  }\n\n  const userPermissions = {\n    is_superuser: userPayload.superUser,\n    ...userPayload.permissions,\n  }\n\n  const filteredMenus = filterMenusByPermission(navMenus.value, userPermissions)\n  if (filteredMenus.length === 0) {\n    errorMessage.value = t('login.noPermission')\n    return\n  }\n\n  const authPayLoad: authState = {\n    token: response.access_token,\n    remember: form.value.remember,\n  }\n\n  authStore.login(authPayLoad)\n  userStore.loginUser(userPayload)\n\n  // 登录后加载用户相关的全局设置\n  await globalSettingsStore.loadUserSettings()\n\n  await afterLogin(userPayload.superUser, userPayload, filteredMenus)\n}\n\n// 登录获取token事件\nasync function login() {\n  errorMessage.value = ''\n\n  // 进行表单校验\n  if (!form.value.username || !form.value.password) {\n    return\n  }\n\n  // 登录按钮 loading\n  loading.value = true\n\n  try {\n    // 用户名密码\n    const formData = new FormData()\n\n    formData.append('username', form.value.username)\n    formData.append('password', form.value.password)\n    formData.append('otp_password', form.value.otp_password)\n\n    // 请求token\n    const response: any = await api.post('/login/access-token', formData, {\n      headers: {\n        Accept: 'application/json', // 设置 Accept 类型\n      },\n    })\n\n    await handleLoginSuccess(response)\n  } catch (error: any) {\n    // 登录失败，显示错误提示\n    if (!error.response) {\n      errorMessage.value = t('login.networkError')\n      return\n    }\n\n    switch (error.response.status) {\n      case 401:\n        // 401错误可能是需要MFA或者认证失败\n        // 检查响应头是否有MFA要求标识\n        if (error.response.headers?.['x-mfa-required'] === 'true' && !form.value.otp_password) {\n          // 需要MFA验证，弹出对话框\n          isOTP.value = true\n          mfaDialog.value = true\n          return\n        }\n        // 不需要MFA或已填写OTP但认证失败\n        errorMessage.value = t('login.authFailure')\n        // 认证失败后清空OTP密码，防止下次点击不弹出对话框\n        form.value.otp_password = ''\n        break\n      case 403:\n        errorMessage.value = t('login.permissionDenied')\n        break\n      case 500:\n        errorMessage.value = t('login.serverError')\n        break\n      default:\n        errorMessage.value = `${t('login.authFailure')} (Status: ${error.response.status})`\n    }\n  } finally {\n    loading.value = false\n  }\n}\n\n// 使用OTP码继续登录\nfunction loginWithOTP() {\n  mfaDialog.value = false\n  login()\n}\n\n// 使用PassKey进行MFA验证\nasync function verifyWithPassKey() {\n  if (!form.value.username) return\n\n  await handlePassKeyAuth(\n    { username: form.value.username },\n    val => (mfaPasskeyLoading.value = val),\n    async response => {\n      // 关闭MFA对话框\n      mfaDialog.value = false\n      await handleLoginSuccess(response)\n    },\n  )\n}\n\n// 自动登录\nonMounted(async () => {\n  // 获取token和remember状态\n  const token = authStore.token\n  const remember = authStore.remember\n\n  // 如果token存在，且保持登录状态为true，则跳转到首页\n  if (token && remember) {\n    router.push('/')\n    return\n  }\n\n  // 初始化 Conditional UI 的 PassKey 自动填充\n  await initConditionalPasskey()\n})\n\n// 初始化 Conditional UI 的 PassKey 自动填充\nasync function initConditionalPasskey() {\n  // 检查浏览器是否支持 WebAuthn 和 Conditional UI\n  if (!window.PublicKeyCredential || !PublicKeyCredential.isConditionalMediationAvailable) {\n    return\n  }\n\n  try {\n    const available = await PublicKeyCredential.isConditionalMediationAvailable()\n    if (!available) {\n      return\n    }\n\n    // 安全防御：如果已存在 controller，先 abort 掉旧的，防止重复调用产生幽灵请求\n    if (conditionalAbortController) {\n      conditionalAbortController.abort()\n      conditionalAbortController = null\n    }\n\n    // 创建 AbortController 用于取消请求\n    conditionalAbortController = new AbortController()\n\n    // 启动 Conditional UI 模式的 PassKey 认证\n    await loginWithPassKey(true)\n  } catch (error) {\n    console.error('[PassKey] Failed to initialize Conditional UI:', error)\n  }\n}\n\n// 组件卸载时清理\nonUnmounted(() => {\n  if (conditionalAbortController) {\n    conditionalAbortController.abort()\n    conditionalAbortController = null\n  }\n  if (manualAbortController) {\n    manualAbortController.abort()\n    manualAbortController = null\n  }\n})\n</script>\n\n<template>\n  <!-- 登录页面容器 -->\n  <div class=\"relative flex min-h-screen flex-col items-center justify-center\">\n    <!-- 登录表单 -->\n    <div v-if=\"!mfaDialog\" class=\"auth-wrapper d-flex align-center justify-center\">\n      <VCard\n        class=\"auth-card px-7 pt-3 w-full h-full\"\n        :class=\"{ 'glass-effect': !isTransparentTheme }\"\n        max-width=\"24rem\"\n        border\n      >\n        <VCardItem class=\"justify-center\">\n          <template #prepend>\n            <div class=\"d-flex pe-0\">\n              <VImg :src=\"logo\" width=\"64\" height=\"64\" />\n            </div>\n          </template>\n          <VCardTitle class=\"font-weight-bold text-3xl text-uppercase\"> MoviePilot </VCardTitle>\n\n          <!-- 语言切换按钮 -->\n          <template #append>\n            <VMenu v-model=\"langMenu\" :close-on-content-click=\"false\">\n              <template #activator=\"{ props }\">\n                <VBtn variant=\"text\" size=\"small\" v-bind=\"props\" class=\"lang-switch-btn\">\n                  <span v-if=\"SUPPORTED_LOCALES[currentLocale].flag\">{{ SUPPORTED_LOCALES[currentLocale].flag }}</span>\n                  <VIcon v-else icon=\"mdi-translate\" />\n                  <span class=\"ms-1\">{{ SUPPORTED_LOCALES[currentLocale].title }}</span>\n                </VBtn>\n              </template>\n              <VCard min-width=\"180\">\n                <VList>\n                  <VListItem\n                    v-for=\"locale in locales\"\n                    :key=\"locale.name\"\n                    :value=\"locale.name\"\n                    @click=\"switchLanguage(locale.name as SupportedLocale)\"\n                  >\n                    <template #prepend>\n                      <span v-if=\"locale.flag\" class=\"mr-2\">{{ locale.flag }}</span>\n                      <VIcon v-else icon=\"mdi-translate\" size=\"small\" />\n                    </template>\n                    <VListItemTitle>{{ locale.title }}</VListItemTitle>\n                  </VListItem>\n                </VList>\n              </VCard>\n            </VMenu>\n          </template>\n        </VCardItem>\n        <VCardText>\n          <VForm ref=\"refForm\" autocomplete=\"on\" @submit.prevent=\"login\">\n            <VRow>\n              <!-- username -->\n              <VCol cols=\"12\">\n                <VTextField\n                  ref=\"usernameInput\"\n                  v-model=\"form.username\"\n                  :label=\"t('login.username')\"\n                  type=\"text\"\n                  name=\"username\"\n                  id=\"username\"\n                  autocomplete=\"username\"\n                  :rules=\"[requiredValidator]\"\n                  hide-details\n                />\n              </VCol>\n              <!-- password -->\n              <VCol cols=\"12\">\n                <VTextField\n                  v-model=\"form.password\"\n                  :label=\"t('login.password')\"\n                  :type=\"isPasswordVisible ? 'text' : 'password'\"\n                  name=\"password\"\n                  id=\"password\"\n                  autocomplete=\"current-password\"\n                  :append-inner-icon=\"isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'\"\n                  :rules=\"[requiredValidator]\"\n                  hide-details\n                  @click:append-inner=\"isPasswordVisible = !isPasswordVisible\"\n                />\n              </VCol>\n              <VCol cols=\"12\" class=\"py-0\">\n                <!-- remember me checkbox -->\n                <div class=\"d-flex align-center justify-space-between flex-wrap\">\n                  <VCheckbox v-model=\"form.remember\" :label=\"t('login.stayLoggedIn')\" required />\n                </div>\n              </VCol>\n              <VCol cols=\"12\">\n                <!-- login button -->\n                <VBtn block type=\"submit\" prepend-icon=\"mdi-login\" :loading=\"loading\" size=\"large\">\n                  {{ t('login.login') }}\n                </VBtn>\n\n                <!-- or divider -->\n                <div class=\"or-divider my-4\">\n                  <span class=\"or-divider-text\">{{ t('login.orDivider') }}</span>\n                </div>\n\n                <!-- passkey login button -->\n                <VBtn\n                  block\n                  variant=\"outlined\"\n                  color=\"success\"\n                  class=\"passkey-btn\"\n                  prepend-icon=\"material-symbols:passkey\"\n                  :loading=\"passkeyLoading\"\n                  @click=\"loginWithPassKey(false)\"\n                >\n                  {{ t('login.loginWithPasskey') }}\n                </VBtn>\n                <VAlert v-if=\"errorMessage\" type=\"error\" variant=\"tonal\" class=\"mt-3\">\n                  {{ errorMessage }}\n                </VAlert>\n              </VCol>\n            </VRow>\n          </VForm>\n        </VCardText>\n      </VCard>\n    </div>\n\n    <!-- MFA二次验证对话框 -->\n    <VDialog v-model=\"mfaDialog\" max-width=\"400\" persistent>\n      <VCard>\n        <VCardTitle class=\"text-h5 text-center mt-4 pb-2\">{{ t('login.secondaryVerification') }}</VCardTitle>\n        <VCardText class=\"pt-0\">\n          <p class=\"text-center mb-4\">{{ t('login.mfa.selectVerificationMethod') }}</p>\n\n          <!-- TOTP验证 -->\n          <VCard variant=\"tonal\" class=\"mb-3\">\n            <VCardText>\n              <VForm @submit.prevent=\"loginWithOTP\">\n                <VTextField\n                  v-model=\"form.otp_password\"\n                  :label=\"t('login.otpCode')\"\n                  :placeholder=\"t('login.otpPlaceholder')\"\n                  type=\"text\"\n                  name=\"otp\"\n                  id=\"otp\"\n                  autocomplete=\"one-time-code\"\n                  inputmode=\"numeric\"\n                  prepend-inner-icon=\"mdi-shield-key\"\n                  class=\"mb-2\"\n                />\n                <VBtn block type=\"submit\" color=\"primary\" :disabled=\"!form.otp_password\">\n                  {{ t('login.loginWithOtp') }}\n                </VBtn>\n              </VForm>\n            </VCardText>\n          </VCard>\n\n          <!-- PassKey验证 -->\n          <VCard variant=\"tonal\">\n            <VCardText>\n              <p class=\"text-body-2 mb-2\">{{ t('login.orUsePasskey') }}</p>\n              <VBtn\n                block\n                variant=\"tonal\"\n                color=\"success\"\n                class=\"passkey-btn\"\n                prepend-icon=\"material-symbols:passkey\"\n                :loading=\"mfaPasskeyLoading\"\n                @click=\"verifyWithPassKey\"\n              >\n                {{ t('login.verifyWithPasskey') }}\n              </VBtn>\n            </VCardText>\n          </VCard>\n\n          <!-- 错误提示 -->\n          <VAlert v-if=\"errorMessage\" type=\"error\" variant=\"tonal\" class=\"mt-3\">\n            {{ errorMessage }}\n          </VAlert>\n\n          <VBtn block variant=\"text\" class=\"mt-4\" @click=\"mfaDialog = false\">{{ t('common.cancel') }}</VBtn>\n        </VCardText>\n      </VCard>\n    </VDialog>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n@use '@core/scss/pages/page-auth';\n\n.v-card-item__prepend {\n  padding-inline-end: 0 !important;\n}\n\n.auth-wrapper {\n  overflow: hidden;\n  block-size: auto;\n}\n\n.lang-switch-btn {\n  position: absolute;\n  inset-block-start: 8px;\n  inset-inline-end: 8px;\n}\n\n.glass-effect {\n  backdrop-filter: blur(10px) !important;\n  background: rgba(var(--v-theme-surface), 0.7) !important;\n}\n\n.or-divider {\n  position: relative;\n  display: flex;\n  align-items: center;\n  text-align: center;\n\n  &::before,\n  &::after {\n    flex: 1;\n    border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));\n    content: '';\n  }\n\n  .or-divider-text {\n    color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));\n    font-size: 0.8125rem;\n    padding-inline: 12px;\n    white-space: nowrap;\n  }\n}\n\n.v-theme--light {\n  .passkey-btn.v-btn--variant-outlined {\n    color: rgb(86, 170, 0) !important;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/pages/media.vue",
    "content": "<script setup lang=\"ts\">\nimport MediaDetailView from '@/views/discover/MediaDetailView.vue'\nimport { useI18n } from 'vue-i18n'\n\n// 国际化\nconst { t } = useI18n()\n\n// 路由参数\nconst route = useRoute()\n\n// TMDB ID\nconst mediaid = route.query?.mediaid?.toString()\n\n// 类型：电影、电视剧\nconst type = route.query?.type?.toString()\n\n// 标题\nconst title = route.query?.title?.toString()\n\n// 年份\nconst year = route.query?.year?.toString()\n</script>\n\n<template>\n  <div>\n    <MediaDetailView :mediaid=\"mediaid\" :type=\"type\" :title=\"title\" :year=\"year\" />\n  </div>\n</template>\n"
  },
  {
    "path": "src/pages/person.vue",
    "content": "<script setup lang=\"ts\">\nimport PersonDetailView from '@/views/discover/PersonDetailView.vue'\n\n// 路由参数\nconst route = useRoute()\n\n// Person Id\nconst personid = route.query?.personid?.toString()\n\n// 来源\nconst source = route.query?.source?.toString()\n\n// 类型\nconst type = route.query?.type?.toString()\n</script>\n\n<template>\n  <div>\n    <PersonDetailView :personid=\"personid\" :type=\"type\" :source=\"source\" />\n  </div>\n</template>\n"
  },
  {
    "path": "src/pages/plugin-app.vue",
    "content": "<script setup lang=\"ts\">\nimport type { Component } from 'vue'\nimport api from '@/api'\nimport { loadRemoteAppPageComponent } from '@/utils/federationLoader'\n\nconst route = useRoute()\n\nconst pluginId = computed(() => route.params.pluginId as string)\nconst navKey = computed(() => (route.params.navKey as string) || 'main')\n\nconst RemoteView = shallowRef<Component | null>(null)\nconst loadError = ref(false)\n\nwatch(\n  [pluginId, navKey],\n  async ([pid, nk]) => {\n    loadError.value = false\n    if (!pid) {\n      RemoteView.value = null\n      return\n    }\n    try {\n      RemoteView.value = (await loadRemoteAppPageComponent(pid, nk)) as Component\n    } catch (e) {\n      console.error(e)\n      RemoteView.value = null\n      loadError.value = true\n    }\n  },\n  { immediate: true },\n)\n</script>\n\n<template>\n  <div class=\"plugin-app-page\">\n    <VAlert v-if=\"loadError\" type=\"error\" class=\"ma-4\" title=\"组件加载错误\">\n      无法加载插件全页组件。多入口时请暴露 AppPage 或 AppPage{Pascal}（见文档），并确认插件已启用。\n    </VAlert>\n    <VSkeletonLoader v-else-if=\"!RemoteView\" class=\"ma-4\" type=\"article, article, article\" />\n    <component\n      v-else\n      :is=\"RemoteView\"\n      :key=\"`${pluginId}-${navKey}`\"\n      :api=\"api\"\n      :nav-key=\"navKey\"\n      :plugin-id=\"pluginId\"\n      @action=\"() => {}\"\n    />\n  </div>\n</template>\n"
  },
  {
    "path": "src/pages/plugin.vue",
    "content": "<script setup lang=\"ts\">\nimport PluginCardListView from '@/views/plugin/PluginCardListView.vue'\n</script>\n\n<template>\n  <PluginCardListView />\n</template>\n"
  },
  {
    "path": "src/pages/profile.vue",
    "content": "<script setup lang=\"ts\">\nimport UserProfileView from '@/views/user/UserProfileView.vue'\n</script>\n\n<template>\n  <div>\n    <UserProfileView />\n  </div>\n</template>\n"
  },
  {
    "path": "src/pages/recommend.vue",
    "content": "<script setup lang=\"ts\">\nimport api from '@/api'\nimport { RecommendSource } from '@/api/types'\nimport MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'\nimport { useI18n } from 'vue-i18n'\nimport { useDisplay } from 'vuetify'\nimport { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'\nimport { getItemColor, initializeItemColors } from '@/utils/colorUtils'\n\nconst display = useDisplay()\n\n// 国际化\nconst { t } = useI18n()\n\n// 路由\nconst route = useRoute()\n\n// 当前选择的分类\nconst currentCategory = ref(t('recommend.all'))\n\n// 使用动态标签页\nconst { registerHeaderTab } = useDynamicHeaderTab()\n\nconst viewList = reactive<{ apipath: string; linkurl: string; title: string; type: string }[]>([\n  {\n    apipath: 'recommend/tmdb_trending',\n    linkurl: '/browse/recommend/tmdb_trending?title=' + t('recommend.trendingNow'),\n    title: t('recommend.trendingNow'),\n    type: t('recommend.categoryRankings'),\n  },\n  {\n    apipath: 'recommend/douban_showing',\n    linkurl: '/browse/recommend/douban_showing?title=' + t('recommend.nowShowing'),\n    title: t('recommend.nowShowing'),\n    type: t('recommend.categoryMovie'),\n  },\n  {\n    apipath: 'recommend/bangumi_calendar',\n    linkurl: '/browse/recommend/bangumi_calendar?title=' + t('recommend.bangumiDaily'),\n    title: t('recommend.bangumiDaily'),\n    type: t('recommend.categoryAnime'),\n  },\n  {\n    apipath: 'recommend/tmdb_movies',\n    linkurl: '/browse/recommend/tmdb_movies?title=' + t('recommend.tmdbHotMovies'),\n    title: t('recommend.tmdbHotMovies'),\n    type: t('recommend.categoryMovie'),\n  },\n  {\n    apipath: 'recommend/tmdb_tvs?with_original_language=zh|en|ja|ko',\n    linkurl: '/browse/recommend/tmdb_tvs??with_original_language=zh|en|ja|ko&title=' + t('recommend.tmdbHotTVShows'),\n    title: t('recommend.tmdbHotTVShows'),\n    type: t('recommend.categoryTV'),\n  },\n  {\n    apipath: 'recommend/douban_movie_hot',\n    linkurl: '/browse/recommend/douban_movie_hot?title=' + t('recommend.doubanHotMovies'),\n    title: t('recommend.doubanHotMovies'),\n    type: t('recommend.categoryMovie'),\n  },\n  {\n    apipath: 'recommend/douban_tv_hot',\n    linkurl: '/browse/recommend/douban_tv_hot?title=' + t('recommend.doubanHotTVShows'),\n    title: t('recommend.doubanHotTVShows'),\n    type: t('recommend.categoryTV'),\n  },\n  {\n    apipath: 'recommend/douban_tv_animation',\n    linkurl: '/browse/recommend/douban_tv_animation?title=' + t('recommend.doubanHotAnime'),\n    title: t('recommend.doubanHotAnime'),\n    type: t('recommend.categoryAnime'),\n  },\n  {\n    apipath: 'recommend/douban_movies',\n    linkurl: '/browse/recommend/douban_movies?title=' + t('recommend.doubanNewMovies'),\n    title: t('recommend.doubanNewMovies'),\n    type: t('recommend.categoryMovie'),\n  },\n  {\n    apipath: 'recommend/douban_tvs',\n    linkurl: '/browse/recommend/douban_tvs?title=' + t('recommend.doubanNewTVShows'),\n    title: t('recommend.doubanNewTVShows'),\n    type: t('recommend.categoryTV'),\n  },\n  {\n    apipath: 'recommend/douban_movie_top250',\n    linkurl: '/browse/recommend/douban_movie_top250?title=' + t('recommend.doubanTop250'),\n    title: t('recommend.doubanTop250'),\n    type: t('recommend.categoryRankings'),\n  },\n  {\n    apipath: 'recommend/douban_tv_weekly_chinese',\n    linkurl: '/browse/recommend/douban_tv_weekly_chinese?title=' + t('recommend.doubanChineseTVRankings'),\n    title: t('recommend.doubanChineseTVRankings'),\n    type: t('recommend.categoryRankings'),\n  },\n  {\n    apipath: 'recommend/douban_tv_weekly_global',\n    linkurl: '/browse/recommend/douban_tv_weekly_global?title=' + t('recommend.doubanGlobalTVRankings'),\n    title: t('recommend.doubanGlobalTVRankings'),\n    type: t('recommend.categoryRankings'),\n  },\n])\n\n// 计算当前分类下显示的视图\nconst filteredViews = computed(() => {\n  if (currentCategory.value === t('recommend.all')) {\n    return viewList.filter(item => enableConfig.value[item.title])\n  }\n  return viewList.filter(item => enableConfig.value[item.title] && item.type === currentCategory.value)\n})\n\n// 榜单启用配置， 以title为key\nconst enableConfig = ref<{ [key: string]: boolean }>({\n  ...Object.fromEntries(viewList.map(item => [item.title, true])),\n})\n\n// 为每个项目生成随机颜色\nconst itemColors = ref<{ [key: string]: string }>({})\n\n// 初始化颜色\nfunction initializeColors() {\n  initializeItemColors(viewList, item => item.title)\n  viewList.forEach(item => {\n    itemColors.value[item.title] = getItemColor(item.title)\n  })\n}\n\n// 弹窗\nconst dialog = ref(false)\n\n// 额外的数据源\nconst extraRecommendSources = ref<RecommendSource[]>([])\n\n// 加载额外的发现数据源\nasync function loadExtraRecommendSources() {\n  try {\n    extraRecommendSources.value = await api.get('recommend/source')\n    if (extraRecommendSources.value.length > 0) {\n      extraRecommendSources.value.map(source => {\n        if (!viewList.some(item => item.apipath === source.api_path)) {\n          const querySeparator = source.api_path.includes('?') ? '&' : '?'\n          const linkUrl = `/browse/${source.api_path}${querySeparator}title=${encodeURIComponent(source.name)}`\n          viewList.push({\n            apipath: source.api_path,\n            linkurl: linkUrl,\n            title: source.name,\n            type: source.type,\n          })\n        }\n      })\n    }\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 加载面板配置\nasync function loadConfig() {\n  // 显示配置\n  const local_enable = localStorage.getItem('MP_RECOMMEND')\n  if (local_enable) {\n    enableConfig.value = JSON.parse(local_enable)\n  } else {\n    const response = await api.get('/user/config/Recommend')\n    if (response && response.data && response.data.value) {\n      enableConfig.value = response.data.value\n      localStorage.setItem('MP_RECOMMEND', JSON.stringify(response.data.value))\n    }\n  }\n}\n\n// 设置项目\nasync function saveConfig() {\n  // 启用配置\n  const enableString = JSON.stringify(enableConfig.value)\n  localStorage.setItem('MP_RECOMMEND', enableString)\n\n  // 保存到服务端\n  try {\n    await api.post('/user/config/Recommend', enableConfig.value)\n  } catch (error) {\n    console.error(error)\n  }\n  dialog.value = false\n}\n\n// 标签图标映射\nconst categoryItems = computed(() => [\n  {\n    title: t('recommend.all'),\n    icon: 'mdi-filmstrip-box-multiple',\n    tab: t('recommend.all'),\n  },\n  {\n    title: t('recommend.categoryMovie'),\n    icon: 'mdi-movie',\n    tab: t('recommend.categoryMovie'),\n  },\n  {\n    title: t('recommend.categoryTV'),\n    icon: 'mdi-television-classic',\n    tab: t('recommend.categoryTV'),\n  },\n  {\n    title: t('recommend.categoryAnime'),\n    icon: 'mdi-animation',\n    tab: t('recommend.categoryAnime'),\n  },\n  {\n    title: t('recommend.categoryRankings'),\n    icon: 'mdi-trophy',\n    tab: t('recommend.categoryRankings'),\n  },\n])\n\n// 注册动态标签页\nregisterHeaderTab({\n  items: categoryItems,\n  modelValue: currentCategory,\n  appendButtons: [\n    {\n      icon: 'mdi-tune',\n      variant: 'text',\n      color: 'grey',\n      class: 'settings-icon-button',\n      action: () => {\n        dialog.value = true\n      },\n    },\n  ],\n})\n\n// 页面是否准备就绪\nconst isReady = ref(false)\n\n// 定时器\nlet timer: ReturnType<typeof setTimeout>\n\nonBeforeMount(async () => {\n  await loadConfig()\n  initializeColors()\n})\n\nonMounted(async () => {\n  // 延迟渲染内容，避免阻塞页面切换动画\n  timer = setTimeout(() => {\n    isReady.value = true\n  }, 400)\n\n  await loadExtraRecommendSources()\n  // 为新增的数据源也生成颜色\n  extraRecommendSources.value.forEach(source => {\n    if (!itemColors.value[source.name]) {\n      itemColors.value[source.name] = getItemColor(source.name)\n    }\n  })\n})\n\nonUnmounted(() => {\n  if (timer) clearTimeout(timer)\n})\n\nonActivated(async () => {\n  await loadExtraRecommendSources()\n})\n</script>\n\n<template>\n  <div class=\"mp-recommend\">\n    <!-- 滚动内容区域 -->\n    <div class=\"recommend-content\">\n      <TransitionGroup name=\"fade\">\n        <MediaCardSlideView\n          v-for=\"item in filteredViews\"\n          :key=\"item.title\"\n          v-bind=\"item\"\n          :ready=\"isReady\"\n          class=\"content-group\"\n        />\n      </TransitionGroup>\n\n      <div v-if=\"isReady && filteredViews.length === 0\" class=\"empty-category\">\n        <VIcon icon=\"mdi-alert-circle-outline\" size=\"large\" class=\"empty-icon\" />\n        <p class=\"empty-text\">{{ t('recommend.noCategoryContent') }}</p>\n        <VBtn color=\"primary\" variant=\"tonal\" size=\"small\" @click=\"dialog = true\">\n          {{ t('recommend.configureContent') }}\n        </VBtn>\n      </div>\n    </div>\n\n    <!-- 设置面板 -->\n    <VDialog v-model=\"dialog\" width=\"35rem\" class=\"settings-dialog\" scrollable :fullscreen=\"!display.mdAndUp.value\">\n      <VCard class=\"settings-card\">\n        <VCardItem class=\"settings-card-header\">\n          <VCardTitle>\n            <VIcon icon=\"mdi-tune\" size=\"small\" class=\"me-2\" />\n            {{ t('recommend.customizeContent') }}\n          </VCardTitle>\n          <VDialogCloseBtn @click=\"dialog = false\" />\n        </VCardItem>\n        <VDivider />\n        <VCardText>\n          <p class=\"settings-hint\">{{ t('recommend.selectContentToDisplay') }}</p>\n          <div class=\"settings-grid\">\n            <VCard\n              v-for=\"item in viewList\"\n              :key=\"item.title\"\n              class=\"setting-item\"\n              :class=\"{\n                'enabled': enableConfig[item.title],\n              }\"\n              :style=\"{ '--item-color': itemColors[item.title] }\"\n              @click=\"enableConfig[item.title] = !enableConfig[item.title]\"\n            >\n              <div class=\"setting-item-inner\">\n                <div class=\"setting-check\">\n                  <VIcon\n                    :icon=\"enableConfig[item.title] ? 'mdi-check-circle' : 'mdi-circle-outline'\"\n                    :color=\"enableConfig[item.title] ? 'primary' : undefined\"\n                    size=\"small\"\n                  />\n                </div>\n                <span class=\"setting-label\">{{ item.title }}</span>\n              </div>\n            </VCard>\n          </div>\n        </VCardText>\n        <VCardActions class=\"pt-3\">\n          <VBtn variant=\"text\" @click=\"Object.keys(enableConfig).forEach(key => (enableConfig[key] = true))\">\n            {{ t('recommend.selectAll') }}\n          </VBtn>\n          <VBtn variant=\"text\" @click=\"Object.keys(enableConfig).forEach(key => (enableConfig[key] = false))\">\n            {{ t('recommend.selectNone') }}\n          </VBtn>\n          <VSpacer />\n          <VBtn @click=\"saveConfig\" color=\"primary\" class=\"px-5\">\n            <template #prepend>\n              <VIcon icon=\"mdi-content-save\" />\n            </template>\n            {{ t('common.save') }}\n          </VBtn>\n        </VCardActions>\n      </VCard>\n    </VDialog>\n\n    <!-- 快速滚动到顶部按钮 -->\n    <Teleport to=\"body\" v-if=\"route.path === '/recommend'\">\n      <VScrollToTopBtn />\n    </Teleport>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.mp-recommend {\n  position: relative;\n  padding: 0;\n  max-inline-size: 100%;\n}\n\n.recommend-content {\n  padding-block: 0;\n}\n\n/* Fade transition for content groups */\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.content-group {\n  transition: opacity 0.3s ease;\n}\n\n.empty-category {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  padding: 40px;\n  color: rgba(var(--v-theme-on-surface), 0.6);\n  text-align: center;\n}\n\n.empty-icon {\n  margin-block-end: 16px;\n  opacity: 0.5;\n}\n\n.empty-text {\n  font-size: 1rem;\n  margin-block-end: 16px;\n}\n\n/* Settings Dialog Styles */\n.settings-card-header {\n  padding-block: 16px;\n  padding-inline: 20px;\n}\n\n.settings-hint {\n  color: rgba(var(--v-theme-on-surface), 0.7);\n  font-size: 0.9rem;\n  margin-block-end: 16px;\n}\n\n.settings-grid {\n  display: grid;\n  gap: 12px;\n  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));\n}\n\n.setting-item {\n  position: relative;\n  overflow: hidden;\n  border: 1px solid rgba(var(--v-theme-on-surface), 0.1);\n  border-radius: 8px;\n  background-color: rgba(var(--v-theme-surface-variant), 0.3);\n  cursor: pointer;\n  padding-block: 10px;\n  padding-inline: 12px;\n  transition: all 0.2s ease;\n\n  &::before {\n    position: absolute;\n    background-color: var(--item-color, #4caf50);\n    block-size: 100%;\n    content: '';\n    inline-size: 4px;\n    inset-block-start: 0;\n    inset-inline-start: 0;\n    transition: background-color 0.3s ease;\n  }\n\n  &.enabled {\n    border-color: rgba(var(--v-theme-primary), 0.3);\n    background-color: rgba(var(--v-theme-primary), 0.1);\n  }\n\n  &:hover {\n    box-shadow: 0 4px 12px rgba(var(--v-theme-on-surface), 0.1);\n    transform: translateY(-2px);\n  }\n}\n\n.setting-item-inner {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.setting-check {\n  flex-shrink: 0;\n}\n\n.setting-label {\n  flex: 1;\n  color: rgba(var(--v-theme-on-surface), 0.8);\n  font-size: 0.9rem;\n  font-weight: 500;\n  line-height: 1.2;\n  transition: color 0.2s ease;\n}\n\n.enabled .setting-label {\n  color: rgba(var(--v-theme-primary), 0.9);\n}\n\n@media (width <= 600px) {\n  .settings-grid {\n    grid-template-columns: repeat(2, 1fr);\n  }\n}\n</style>\n"
  },
  {
    "path": "src/pages/resource.vue",
    "content": "<script setup lang=\"ts\">\nimport { debounce } from 'lodash-es'\nimport NoDataFound from '@/components/NoDataFound.vue'\nimport api from '@/api'\nimport type { Context } from '@/api/types'\nimport TorrentCard from '@/components/cards/TorrentCard.vue'\nimport TorrentItem from '@/components/cards/TorrentItem.vue'\nimport TorrentFilterBar from '@/components/filter/TorrentFilterBar.vue'\nimport { useI18n } from 'vue-i18n'\nimport { useGlobalSettingsStore } from '@/stores/global'\nimport { useTorrentFilter, type FilterState } from '@/composables/useTorrentFilter'\nimport { useInfiniteScroll } from '@/composables/useInfiniteScroll'\nimport { useToast } from 'vue-toastification'\n\n// 国际化\nconst { t } = useI18n()\n\n// 提示框\nconst toast = useToast()\n\n// 全局设置 Store\nconst globalSettingsStore = useGlobalSettingsStore()\n\n// 使用筛选 composable\nconst torrentFilter = useTorrentFilter()\n\n// 路由参数\nconst route = useRoute()\n\n// 查询TMDBID或标题\nconst keyword = route.query?.keyword?.toString() ?? ''\n\n// 查询类型\nconst type = route.query?.type?.toString() ?? ''\n\n// 搜索字段\nconst area = route.query?.area?.toString() ?? ''\n\n// 搜索标题\nconst title = route.query?.title?.toString() ?? ''\n\n// 搜索年份\nconst year = route.query?.year\n\n// 搜索季\nconst season = route.query?.season?.toString() ?? ''\n\n// 搜索站点，以,分离多个\nconst sites = route.query?.sites?.toString() ?? ''\n\n// 视图类型，从localStorage中读取\nconst viewType = ref<string>(localStorage.getItem('MPTorrentsViewType') ?? 'card')\n\n// 智能推荐相关\n// 从全局设置中获取 AI_RECOMMEND_ENABLED 状态\nconst aiRecommendEnabled = computed(() => {\n  return globalSettingsStore.get('AI_RECOMMEND_ENABLED') === true\n})\nconst isRecommending = ref(false)\nconst isReRecommending = ref(false) // 是否正在重新推荐\nconst aiRecommended = ref(false) // 是否已执行过智能推荐\nconst showingAiResults = ref(false) // 是否正在显示智能推荐结果\nconst originalDataList = ref<Array<Context>>([]) // 原始搜索结果\nconst aiRecommendedList = ref<Array<Context>>([]) // 智能推荐结果\nconst savedFilterState = ref<FilterState | null>(null) // 保存的筛选状态\nconst aiStatusChecked = ref(false) // 是否已完成首次AI状态检查\nlet aiStatusCheckInterval: ReturnType<typeof setInterval> | null = null // AI状态检查定时器\n\n// 是否有搜索标签\nconst hasSearchTags = computed(() => {\n  return !!(keyword || title || year || season)\n})\n\n// 是否启用筛选栏动画\nconst enableFilterAnimation = ref(true)\n\n// 原始数据列表（未筛选）\nconst rawDataList = ref<Array<Context>>([])\n\n// 筛选后的数据列表（用于行视图）\nconst filteredRowDataList = ref<Array<Context>>([])\n\n// 筛选后的数据列表（用于卡片视图）\ninterface SearchTorrent extends Context {\n  more?: Array<Context>\n}\nconst filteredCardDataList = ref<Array<SearchTorrent>>([])\n\n// 使用无限滚动 composable（行视图）\nconst rowScroll = useInfiniteScroll(filteredRowDataList)\n\n// 使用无限滚动 composable（卡片视图）\nconst cardScroll = useInfiniteScroll(filteredCardDataList)\n\n// 是否刷新过\nconst isRefreshed = ref(false)\n\n// 加载进度文本\nconst progressText = ref(t('common.pleaseWait'))\n\n// 加载进度\nconst progressValue = ref(0)\n\n// 进度是否有效\nconst progressEnabled = ref(false)\n\n// 进度是否激活\nconst progressActive = ref(false)\n\n// 是否显示搜索进度\nconst isSearchProgressVisible = computed(\n  () => progressActive.value || (!isRefreshed.value && (progressEnabled.value || progressValue.value > 0)),\n)\n\n// 是否显示搜索中的页面态\nconst isSearchLoading = computed(\n  () => !isRefreshed.value && isSearchProgressVisible.value && rawDataList.value.length === 0,\n)\n\n// 归一化搜索进度，避免 SSE 异常值影响显示\nconst searchProgressPercent = computed(() => Math.min(100, Math.max(0, Math.ceil(Number(progressValue.value) || 0))))\n\n// 搜索进度文案\nconst searchProgressLabel = computed(() =>\n  progressEnabled.value || progressValue.value > 0 ? `${searchProgressPercent.value}%` : '...',\n)\n\n// 进度未返回前使用不确定态\nconst searchProgressIndeterminate = computed(() => !progressEnabled.value && searchProgressPercent.value <= 0)\n\n// 错误标题\nconst errorTitle = ref(t('resource.noData'))\n\n// 错误描述\nconst errorDescription = ref(t('resource.noResourceFound'))\n\nlet searchEventSource: EventSource | null = null\n\nconst streamPreviewLimit = 24\n\nconst streamTotalCount = ref(0)\n\nconst displayResourceCount = computed(() =>\n  progressActive.value ? streamTotalCount.value : torrentFilter.totalFilteredCount.value,\n)\n\n// 监听筛选条件变化，重新筛选数据\nwatch(\n  [() => torrentFilter.filterForm, () => torrentFilter.sortField.value, () => torrentFilter.sortType.value],\n  () => {\n    applyFilter()\n  },\n  { deep: true },\n)\n\n// 应用筛选\nfunction applyFilter() {\n  if (viewType.value === 'row') {\n    filteredRowDataList.value = torrentFilter.filterRowData(rawDataList.value)\n  } else {\n    filteredCardDataList.value = torrentFilter.filterCardData(rawDataList.value)\n  }\n}\n\n// 处理筛选表单更新\nfunction handleFilterFormUpdate(key: string, values: string[]) {\n  torrentFilter.filterForm[key] = values\n}\n\n// 处理全选\nfunction handleSelectAll(key: string) {\n  torrentFilter.selectAll(key)\n}\n\n// 处理清除筛选\nfunction handleClearFilter(key: string) {\n  torrentFilter.clearFilter(key)\n}\n\n// 处理清除所有筛选\nfunction handleClearAllFilters() {\n  torrentFilter.clearAllFilters()\n}\n\n// 处理移除单个筛选\nfunction handleRemoveFilter(key: string, value: string) {\n  torrentFilter.removeFilter(key, value)\n}\n\n// 添加安全超时，确保进度条不会永远卡住\nconst watchProgressValue = watch(\n  progressValue,\n  debounce(async () => {\n    if (progressActive.value && progressValue.value < 100) {\n      console.warn('卡进度超时 关闭进度条')\n      stopLoadingProgress()\n    }\n  }, 60_000),\n)\n\n// 使用SSE监听加载进度\nfunction startLoadingProgress() {\n  watchProgressValue.resume()\n  progressText.value = t('resource.searching')\n  progressValue.value = 0\n  progressEnabled.value = true\n  progressActive.value = true\n}\n\n// 停止监听加载进度\nfunction stopLoadingProgress() {\n  watchProgressValue.pause()\n  progressActive.value = false\n\n  // 确保进度显示100%，然后再渐进清零\n  progressValue.value = 100\n  setTimeout(() => {\n    progressValue.value = 0\n    progressEnabled.value = false\n  }, 1500)\n}\n\n// 关闭SSE连接\nfunction closeSearchEventSource() {\n  if (searchEventSource) {\n    searchEventSource.close()\n    searchEventSource = null\n  }\n}\n\n// 获取API URL\nfunction getApiUrl(path: string) {\n  const apiBaseUrl = import.meta.env.VITE_API_BASE_URL\n  const normalizedBaseUrl = apiBaseUrl.startsWith('http')\n    ? apiBaseUrl\n    : `${window.location.origin}${apiBaseUrl.startsWith('/') ? apiBaseUrl : `/${apiBaseUrl}`}`\n\n  return new URL(path, normalizedBaseUrl.endsWith('/') ? normalizedBaseUrl : `${normalizedBaseUrl}/`)\n}\n\n// 设置搜索参数\nfunction setSearchParam(params: URLSearchParams, key: string, value: unknown) {\n  if (value !== undefined && value !== null && value !== '') {\n    params.set(key, String(value))\n  }\n}\n\n// 构建搜索流URL\nfunction buildSearchStreamUrl() {\n  const isMediaSearch = /^[a-zA-Z]+:/.test(keyword)\n  const url = getApiUrl(isMediaSearch ? `search/media/${encodeURIComponent(keyword)}/stream` : 'search/title/stream')\n\n  if (isMediaSearch) {\n    setSearchParam(url.searchParams, 'mtype', type)\n    setSearchParam(url.searchParams, 'area', area)\n    setSearchParam(url.searchParams, 'title', title)\n    setSearchParam(url.searchParams, 'year', year)\n    setSearchParam(url.searchParams, 'season', season)\n    setSearchParam(url.searchParams, 'sites', sites)\n  } else {\n    setSearchParam(url.searchParams, 'keyword', keyword)\n    setSearchParam(url.searchParams, 'sites', sites)\n  }\n\n  return url.toString()\n}\n\n// 重置搜索结果\nfunction resetSearchResults() {\n  rawDataList.value = []\n  originalDataList.value = []\n  streamTotalCount.value = 0\n  aiRecommended.value = false\n  showingAiResults.value = false\n  aiRecommendedList.value = []\n  savedFilterState.value = null\n  aiStatusChecked.value = false\n  torrentFilter.clearAllFilters()\n  applyFilter()\n}\n\n// 更新搜索进度\nfunction updateSearchProgress(eventData: { [key: string]: any }) {\n  if (eventData.text) {\n    progressText.value = eventData.text\n  }\n  if (typeof eventData.value === 'number') {\n    progressValue.value = eventData.value\n  }\n  if (typeof eventData.total_items === 'number') {\n    streamTotalCount.value = eventData.total_items\n  }\n  progressEnabled.value = true\n}\n\n// 设置流式搜索结果\nfunction setStreamResults(items: Context[]) {\n  rawDataList.value = items\n  originalDataList.value = items\n  if (!progressActive.value) {\n    streamTotalCount.value = items.length\n  }\n  isRefreshed.value = true\n  applyFilter()\n}\n\n// 追加流式搜索结果\nfunction appendStreamResults(items: Context[]) {\n  if (!items.length) return\n\n  const nextItems = [...items, ...rawDataList.value]\n  setStreamResults(progressActive.value ? nextItems.slice(0, streamPreviewLimit) : nextItems)\n}\n\n// 获取磁力链接的key\nfunction getTorrentItemKey(item: Context, index: number) {\n  return (\n    item.torrent_info?.page_url ||\n    item.torrent_info?.enclosure ||\n    `${item.torrent_info?.site_name || ''}-${item.torrent_info?.title || ''}-${item.torrent_info?.description || ''}` ||\n    `torrent-${index}`\n  )\n}\n\n// 处理搜索流消息\nfunction handleSearchStreamMessage(eventData: { [key: string]: any }) {\n  updateSearchProgress(eventData)\n\n  if (eventData.type === 'error') {\n    errorDescription.value = eventData.message || t('resource.noResourceFound')\n    return\n  }\n\n  const items = Array.isArray(eventData.items) ? (eventData.items as Context[]) : []\n  if (eventData.type === 'append') {\n    appendStreamResults(items)\n  } else if (eventData.type === 'replace' || eventData.type === 'done') {\n    setStreamResults(items)\n  }\n}\n\n// 按请求搜索\nasync function searchByRequest() {\n  let result: { [key: string]: any }\n  // 如果keyword的格式是 xxxx:xxxxx 且:前面的xxxx为字符，则按照媒体ID格式搜索\n  if (/^[a-zA-Z]+:/.test(keyword)) {\n    result = await api.get(`search/media/${keyword}`, {\n      params: {\n        mtype: type,\n        area,\n        title,\n        year,\n        season,\n        sites,\n      },\n    })\n  } else {\n    // 按标题模糊查询\n    result = await api.get(`search/title`, {\n      params: {\n        keyword,\n        sites,\n      },\n    })\n  }\n\n  if (result && result.success) {\n    streamTotalCount.value = result.data?.length || 0\n    setStreamResults(result.data || [])\n  } else {\n    errorDescription.value = result?.message || t('resource.noResourceFound')\n    streamTotalCount.value = 0\n    setStreamResults([])\n  }\n}\n\n// 按流搜索\nfunction searchByStream() {\n  return new Promise<void>((resolve, reject) => {\n    closeSearchEventSource()\n\n    let settled = false\n    const source = new EventSource(buildSearchStreamUrl())\n    searchEventSource = source\n\n    source.onmessage = event => {\n      try {\n        const eventData = JSON.parse(event.data)\n        handleSearchStreamMessage(eventData)\n\n        if (eventData.type === 'error') {\n          settled = true\n          closeSearchEventSource()\n          resolve()\n          return\n        }\n\n        if (eventData.type === 'done') {\n          settled = true\n          closeSearchEventSource()\n          resolve()\n        }\n      } catch (error) {\n        settled = true\n        closeSearchEventSource()\n        reject(error)\n      }\n    }\n\n    source.onerror = () => {\n      if (settled) return\n\n      settled = true\n      closeSearchEventSource()\n      reject(new Error(t('resource.noResourceFound')))\n    }\n  })\n}\n\n// 设置视图类型\nfunction changeViewType(newType: string) {\n  if (viewType.value !== newType) {\n    // 立即更新视图类型\n    viewType.value = newType\n    localStorage.setItem('MPTorrentsViewType', newType)\n\n    // 切换视图时重新应用筛选\n    applyFilter()\n  }\n}\n\n// 获取搜索列表数据\nasync function fetchData() {\n  try {\n    enableFilterAnimation.value = true\n    if (!keyword) {\n      // 查询上次搜索结果\n      const results = await api.get('search/last')\n      rawDataList.value = (results as unknown as Context[]) || []\n      originalDataList.value = (results as unknown as Context[]) || []\n    } else {\n      resetSearchResults()\n      startLoadingProgress()\n      try {\n        await searchByStream()\n      } catch (error) {\n        console.warn('渐进式搜索连接失败，回退到普通搜索:', error)\n        await searchByRequest()\n      }\n      stopLoadingProgress()\n      // 从浏览器历史中删除当前搜索\n      window.history.replaceState(null, '', window.location.pathname)\n    }\n    // 应用筛选\n    applyFilter()\n    // 标记已刷新\n    isRefreshed.value = true\n  } catch (error) {\n    console.error(error)\n    closeSearchEventSource()\n    stopLoadingProgress()\n    isRefreshed.value = true\n    return Promise.reject(error)\n  }\n}\n\n// 切换到智能推荐结果（自动保存筛选条件）\nasync function switchToAiResults() {\n  if (showingAiResults.value) {\n    console.log('已经在显示AI结果')\n    return\n  }\n\n  // 保存当前筛选状态\n  savedFilterState.value = torrentFilter.getFilterState()\n\n  // 切换数据\n  rawDataList.value = [...aiRecommendedList.value]\n  showingAiResults.value = true\n  console.log('已切换到智能推荐结果')\n\n  // 清空智能推荐筛选条件\n  torrentFilter.clearAllFilters()\n\n  // 重新应用筛选\n  applyFilter()\n}\n\n// 切换回原始结果（自动还原筛选条件）\nasync function switchToOriginalResults() {\n  if (!showingAiResults.value) {\n    console.log('已经在显示原始结果')\n    return\n  }\n\n  // 切换数据\n  rawDataList.value = [...originalDataList.value]\n  showingAiResults.value = false\n  console.log('已切换到原始结果')\n\n  // 恢复原始筛选条件\n  if (savedFilterState.value) {\n    torrentFilter.setFilterState(savedFilterState.value)\n  }\n\n  // 重新应用筛选\n  applyFilter()\n}\n\n// 智能推荐/切换结果\nasync function toggleAiRecommend() {\n  // 如果当前显示AI结果，则切换回原始结果\n  if (showingAiResults.value) {\n    await switchToOriginalResults()\n    return\n  }\n\n  // 如果已经有智能推荐结果，直接切换\n  if (aiRecommended.value && aiRecommendedList.value.length > 0) {\n    await switchToAiResults()\n    return\n  }\n\n  // 否则启动智能推荐\n  // 保存当前筛选状态，以便切换回原始结果时恢复\n  savedFilterState.value = torrentFilter.getFilterState()\n  console.log('首次智能推荐，已保存筛选状态:', savedFilterState.value)\n\n  startAiRecommend()\n}\n\n// 启动智能推荐（开始轮询）\nasync function startAiRecommend(force: boolean = false) {\n  isRecommending.value = true\n  console.log('启动智能推荐', force ? '(强制)' : '')\n\n  // 首次或强制时，先发送一个启动任务的请求\n  await sendInitialRequest(force)\n\n  // 然后开始 check_only 轮询\n  startAiRecommendPolling()\n}\n\n// 发送初始请求以启动智能推荐任务\nasync function sendInitialRequest(force: boolean = false) {\n  try {\n    const requestBody: any = {}\n\n    // 检查是否有筛选条件\n    const hasFilters = torrentFilter.hasActiveFilters()\n    if (hasFilters) {\n      const indices = torrentFilter.getFilteredIndices()\n      if (indices && indices.length > 0) {\n        requestBody.filtered_indices = indices\n      }\n    }\n\n    // 如果是强制模式，添加 force 标志\n    if (force) {\n      requestBody.force = true\n    }\n\n    console.log('发送初始请求以启动任务', force ? '(force)' : '')\n    await api.post('search/recommend', requestBody)\n  } catch (error) {\n    console.error('发送初始请求失败:', error)\n    isRecommending.value = false\n  }\n}\n\n// 开始轮询智能推荐（使用 check_only 模式）\nfunction startAiRecommendPolling() {\n  // 停止可能存在的轮询\n  stopAiRecommendPolling()\n\n  // 立即发送一次 check_only 请求\n  pollAiRecommend()\n\n  // 然后每2秒轮询一次（check_only）\n  aiStatusCheckInterval = setInterval(() => {\n    pollAiRecommend()\n  }, 2000)\n}\n\n// 轮询智能推荐状态（始终使用 check_only 模式）\nasync function pollAiRecommend() {\n  try {\n    const result: { [key: string]: any } = await api.post('search/recommend', {\n      check_only: true,\n    })\n\n    const { success, data } = result\n    const status = data?.status\n\n    // 正在运行，继续轮询\n    if (success && status === 'running') {\n      console.log('AI推理中...')\n      return\n    }\n\n    // 其他所有状态均停止轮询\n    stopAiRecommendPolling()\n    isRecommending.value = false\n\n    if (success && status === 'completed') {\n      // 推荐完成\n      if (data.results?.length > 0) {\n        // 加载智能推荐结果\n        loadAiRecommendedResults(data.results)\n\n        // 自动切换到智能推荐结果（会自动保存筛选条件）\n        await switchToAiResults()\n      }\n    } else if (success && status === 'disabled') {\n      // 功能停用\n      console.error('AI功能未启用')\n    } else {\n      // 错误情况（status === 'error' 或 success 为 false）\n      const errMsg = result.message || data?.error || data?.message || 'Unknown error'\n      console.error('智能推荐错误:', errMsg)\n      toast.error(`${t('resource.aiRecommendError')}: ${errMsg}`)\n    }\n  } catch (error) {\n    console.error('智能推荐轮询失败:', error)\n    stopAiRecommendPolling()\n    isRecommending.value = false\n  }\n}\n\n// 停止轮询智能推荐\nfunction stopAiRecommendPolling() {\n  if (aiStatusCheckInterval) {\n    clearInterval(aiStatusCheckInterval)\n    aiStatusCheckInterval = null\n    console.log('停止智能推荐轮询')\n  }\n}\n\n// 加载智能推荐结果（从索引数组提取数据）\nfunction loadAiRecommendedResults(indices: number[]) {\n  if (!indices || indices.length === 0) {\n    return\n  }\n\n  // 从原始数据中根据索引提取结果\n  aiRecommendedList.value = indices.map((index: number) => originalDataList.value[index]).filter(Boolean)\n  aiRecommended.value = true\n  console.log(`加载智能推荐结果: ${aiRecommendedList.value.length} 条`)\n}\n\n// 重新推荐\nasync function reRecommend() {\n  try {\n    isReRecommending.value = true\n    console.log('重新推荐：重置状态')\n\n    // 重置状态\n    aiRecommended.value = false\n    aiRecommendedList.value = []\n\n    // 切换回原始结果（会自动还原筛选条件）\n    await switchToOriginalResults()\n\n    // 等待筛选数据还原完成（nextTick确保DOM更新完成）\n    await nextTick()\n\n    // 再等待一个微任务，确保筛选逻辑完全执行\n    await new Promise(resolve => setTimeout(resolve, 0))\n\n    // 重新启动智能推荐（带 force 标志）\n    startAiRecommend(true)\n  } catch (error) {\n    console.error('重新推荐失败:', error)\n  } finally {\n    isReRecommending.value = false\n  }\n}\n\n// 检查智能推荐状态（页面初始化时调用一次）\nasync function checkAiRecommendStatus() {\n  try {\n    // 首次检查时使用 check_only 模式\n    const result: { [key: string]: any } = await api.post('search/recommend', {\n      check_only: true,\n    })\n\n    const { success, data } = result\n    const status = data?.status\n\n    // 只要有数据且状态不是disabled，就标记已检查（允许重试）\n    if (data && status !== 'disabled') {\n      aiStatusChecked.value = true\n    }\n\n    if (success && data) {\n      const { results } = data\n\n      // 如果有完成的结果，加载它\n      if (status === 'completed' && results && results.length > 0) {\n        loadAiRecommendedResults(results)\n      }\n\n      // 如果正在运行，启动轮询\n      if (status === 'running') {\n        isRecommending.value = true\n        startAiRecommendPolling()\n      }\n    }\n  } catch (error) {\n    console.error('检查AI状态失败:', error)\n  }\n}\n\n// 计算当前显示的数据是否有数据\nconst hasData = computed(() => {\n  if (viewType.value === 'row') {\n    return filteredRowDataList.value.length > 0 || rawDataList.value.length > 0\n  } else {\n    return filteredCardDataList.value.length > 0 || rawDataList.value.length > 0\n  }\n})\n\n// 监听 AI_RECOMMEND_ENABLED 状态和数据加载状态\n// 使用 watchEffect 确保计算属性变化时立即响应\nwatchEffect(() => {\n  // 需要满足：AI 功能启用、数据已加载、尚未检查\n  if (\n    aiRecommendEnabled.value &&\n    originalDataList.value.length > 0 &&\n    !progressActive.value &&\n    !aiStatusChecked.value\n  ) {\n    checkAiRecommendStatus()\n  }\n})\n\n// 加载数据\nonMounted(async () => {\n  fetchData()\n})\n\n// 卸载时停止轮询\nonUnmounted(() => {\n  closeSearchEventSource()\n  stopLoadingProgress()\n  stopAiRecommendPolling()\n})\n</script>\n\n<template>\n  <div>\n    <!-- 搜索加载状态 -->\n    <VFadeTransition>\n      <div v-if=\"isSearchProgressVisible\" class=\"search-loading-state mb-3\" :class=\"{ 'is-empty-loading': isSearchLoading }\">\n        <VCard elevation=\"0\" class=\"search-progress-card\">\n          <div class=\"progress-header\">\n            <div class=\"progress-icon-wrap\">\n              <VProgressCircular\n                color=\"primary\"\n                :indeterminate=\"searchProgressIndeterminate\"\n                :model-value=\"searchProgressPercent\"\n                :size=\"56\"\n                :width=\"5\"\n              >\n                <VIcon icon=\"mdi-movie-search\" color=\"primary\" size=\"24\" />\n              </VProgressCircular>\n            </div>\n            <div class=\"progress-copy\">\n              <span class=\"progress-title\">{{ progressText }}</span>\n              <div v-if=\"hasSearchTags\" class=\"progress-tags d-flex flex-wrap\">\n                <VChip v-if=\"keyword\" class=\"search-tag progress-tag\" color=\"primary\" size=\"small\" variant=\"tonal\">\n                  {{ t('resource.keyword') }}: {{ keyword }}\n                </VChip>\n                <VChip v-if=\"title\" class=\"search-tag progress-tag\" color=\"primary\" size=\"small\" variant=\"tonal\">\n                  {{ t('resource.title') }}: {{ title }}\n                </VChip>\n                <VChip v-if=\"year\" class=\"search-tag progress-tag\" color=\"primary\" size=\"small\" variant=\"tonal\">\n                  {{ t('resource.year') }}: {{ year }}\n                </VChip>\n                <VChip v-if=\"season\" class=\"search-tag progress-tag\" color=\"primary\" size=\"small\" variant=\"tonal\">\n                  {{ t('resource.season') }}: {{ season }}\n                </VChip>\n              </div>\n            </div>\n            <div class=\"progress-percentage\">{{ searchProgressLabel }}</div>\n          </div>\n          <div class=\"progress-bar-container\">\n            <VProgressLinear\n              color=\"primary\"\n              rounded\n              :indeterminate=\"searchProgressIndeterminate\"\n              :model-value=\"searchProgressPercent\"\n            />\n          </div>\n        </VCard>\n\n        <div v-if=\"isSearchLoading && viewType === 'card'\" class=\"search-skeleton-grid\">\n          <VCard v-for=\"item in 6\" :key=\"`search-card-skeleton-${item}`\" class=\"search-skeleton-card\" elevation=\"0\">\n            <VSkeletonLoader type=\"image, article\" />\n          </VCard>\n        </div>\n\n        <VCard v-else-if=\"isSearchLoading\" class=\"search-skeleton-list\" elevation=\"0\">\n          <div v-for=\"item in 6\" :key=\"`search-row-skeleton-${item}`\" class=\"search-skeleton-row\">\n            <VSkeletonLoader type=\"list-item-avatar-two-line\" />\n          </div>\n        </VCard>\n      </div>\n    </VFadeTransition>\n\n    <!-- 精简标题栏 -->\n    <VCard v-if=\"isRefreshed && !progressActive\" class=\"search-header d-flex align-center mb-3\">\n      <div class=\"search-info-container\">\n        <div class=\"search-title text-moviepilot\">\n          <span class=\"d-none d-sm-inline\">{{ t('resource.searchResults') }}</span>\n          <span class=\"d-inline d-sm-none\">{{ t('navItems.searchResult') }}</span>\n        </div>\n        <div v-if=\"hasSearchTags\" class=\"search-tags d-flex flex-wrap mt-1\">\n          <VChip v-if=\"keyword\" class=\"search-tag\" color=\"primary\" size=\"small\" variant=\"flat\">\n            {{ t('resource.keyword') }}: {{ keyword }}\n          </VChip>\n          <VChip v-if=\"title\" class=\"search-tag\" color=\"primary\" size=\"small\" variant=\"flat\">\n            {{ t('resource.title') }}: {{ title }}\n          </VChip>\n          <VChip v-if=\"year\" class=\"search-tag\" color=\"primary\" size=\"small\" variant=\"flat\">\n            {{ t('resource.year') }}: {{ year }}\n          </VChip>\n          <VChip v-if=\"season\" class=\"search-tag\" color=\"primary\" size=\"small\" variant=\"flat\">\n            {{ t('resource.season') }}: {{ season }}\n          </VChip>\n        </div>\n      </div>\n\n      <VSpacer />\n\n      <!-- AI操作按钮组 -->\n      <div v-if=\"aiRecommendEnabled && originalDataList.length > 0\" class=\"ai-toggle-container me-2\">\n        <div class=\"ai-toggle-buttons\">\n          <VBtn\n            variant=\"text\"\n            size=\"small\"\n            rounded=\"0\"\n            @click=\"toggleAiRecommend\"\n            :disabled=\"isRecommending || !aiStatusChecked\"\n            height=\"44\"\n            class=\"ps-4 pe-3 ai-recommend-btn\"\n            :class=\"{ 'ai-active': showingAiResults }\"\n          >\n            <template #prepend>\n              <VIcon icon=\"lucide:sparkles\" size=\"18\" class=\"ai-icon\" :class=\"{ 'ai-icon-active': showingAiResults }\" />\n            </template>\n            <span class=\"ai-text\" :class=\"{ 'ai-text-active': showingAiResults }\">\n              {{ t('resource.aiRecommend') }}\n            </span>\n          </VBtn>\n\n          <VExpandXTransition>\n            <div v-if=\"aiRecommended || isRecommending\" class=\"d-flex align-center\">\n              <div class=\"ai-divider\" :style=\"{ opacity: showingAiResults ? 0 : 1 }\"></div>\n              <VBtn\n                variant=\"text\"\n                size=\"small\"\n                rounded=\"0\"\n                :disabled=\"isRecommending || !aiStatusChecked\"\n                @click=\"reRecommend\"\n                height=\"44\"\n                min-width=\"38\"\n                class=\"px-0\"\n              >\n                <VIcon\n                  :icon=\"isRecommending ? 'line-md:loading-twotone-loop' : 'mdi-refresh'\"\n                  size=\"18\"\n                  class=\"ai-refresh-icon\"\n                />\n                <VTooltip activator=\"parent\" location=\"top\">\n                  {{ t('resource.reRecommend') }}\n                </VTooltip>\n              </VBtn>\n            </div>\n          </VExpandXTransition>\n        </div>\n      </div>\n\n      <!-- 重新设计的视图切换按钮 -->\n      <div class=\"view-toggle-container\">\n        <div class=\"view-toggle-buttons\">\n          <div class=\"active-indicator\" :class=\"viewType\"></div>\n          <button class=\"view-toggle-btn\" :class=\"{ active: viewType === 'card' }\" @click=\"changeViewType('card')\">\n            <VIcon icon=\"mdi-view-grid-outline\" :color=\"viewType === 'card' ? 'primary' : undefined\" />\n          </button>\n          <button class=\"view-toggle-btn\" :class=\"{ active: viewType === 'row' }\" @click=\"changeViewType('row')\">\n            <VIcon icon=\"mdi-view-list-outline\" :color=\"viewType === 'row' ? 'primary' : undefined\" />\n          </button>\n        </div>\n      </div>\n    </VCard>\n\n    <!-- 搜索结果 -->\n    <div v-if=\"isRefreshed && hasData\" class=\"search-results-container\">\n      <!-- 筛选栏 -->\n      <TorrentFilterBar\n        v-if=\"!progressActive\"\n        :filter-form=\"torrentFilter.filterForm\"\n        :filter-options=\"torrentFilter.filterOptions\"\n        :sort-field=\"torrentFilter.sortField.value\"\n        :sort-type=\"torrentFilter.sortType.value\"\n        :total-filtered-count=\"displayResourceCount\"\n        :filter-titles=\"torrentFilter.filterTitles\"\n        :sort-titles=\"torrentFilter.sortTitles\"\n        :enable-animation=\"enableFilterAnimation\"\n        @update:sort-field=\"val => (torrentFilter.sortField.value = val)\"\n        @update:sort-type=\"val => (torrentFilter.sortType.value = val)\"\n        @update:filter-form=\"handleFilterFormUpdate\"\n        @select-all=\"handleSelectAll\"\n        @clear-filter=\"handleClearFilter\"\n        @clear-all-filters=\"handleClearAllFilters\"\n        @remove-filter=\"handleRemoveFilter\"\n      />\n\n      <!-- 视图切换区域 -->\n      <VFadeTransition mode=\"out-in\">\n        <!-- 卡片视图模式 -->\n        <div v-if=\"viewType === 'card'\" key=\"card\">\n          <!-- 资源列表 -->\n          <VInfiniteScroll\n            mode=\"intersect\"\n            side=\"end\"\n            :items=\"cardScroll.displayDataList.value\"\n            class=\"overflow-visible\"\n            @load=\"cardScroll.loadMore\"\n          >\n            <template #loading />\n            <template #empty />\n            <div class=\"grid gap-4 grid-torrent-card items-start\">\n              <TorrentCard\n                v-for=\"(item, index) in cardScroll.displayDataList.value\"\n                :key=\"getTorrentItemKey(item, index)\"\n                :torrent=\"item\"\n                :more=\"item.more\"\n                class=\"stream-result-item\"\n              />\n            </div>\n          </VInfiniteScroll>\n          <!-- 无结果时显示 -->\n          <div v-if=\"cardScroll.displayDataList.value.length === 0\" class=\"no-results\">\n            <VIcon icon=\"mdi-file-search-outline\" size=\"64\" color=\"grey-lighten-1\" />\n            <div class=\"text-h6 text-grey mt-4\">{{ t('torrent.noResults') }}</div>\n          </div>\n        </div>\n\n        <!-- 列表视图模式 -->\n        <div v-else-if=\"viewType === 'row'\" key=\"row\">\n          <VCard class=\"resource-list-container\">\n            <!-- 无结果时显示 -->\n            <div v-if=\"rowScroll.displayDataList.value.length === 0\" class=\"no-results\">\n              <VIcon icon=\"mdi-file-search-outline\" size=\"64\" color=\"grey-lighten-1\" />\n              <div class=\"text-h6 text-grey mt-4\">{{ t('torrent.noResults') }}</div>\n            </div>\n            <!-- 资源列表 -->\n            <VInfiniteScroll\n              v-else\n              mode=\"intersect\"\n              side=\"end\"\n              :items=\"rowScroll.displayDataList.value\"\n              class=\"resource-list overflow-visible\"\n              @load=\"rowScroll.loadMore\"\n            >\n              <template #loading />\n              <template #empty />\n              <div\n                v-for=\"(item, index) in rowScroll.displayDataList.value\"\n                :key=\"getTorrentItemKey(item, index)\"\n                class=\"stream-result-item\"\n              >\n                <TorrentItem :torrent=\"item\" />\n                <VDivider v-if=\"index < rowScroll.displayDataList.value.length - 1\" class=\"my-2\" />\n              </div>\n            </VInfiniteScroll>\n          </VCard>\n        </div>\n      </VFadeTransition>\n    </div>\n\n    <!-- 无数据显示 -->\n    <div v-else-if=\"isRefreshed\" class=\"d-flex flex-column align-center justify-center py-8\">\n      <NoDataFound :errorTitle=\"errorTitle\" :errorDescription=\"errorDescription\" />\n      <VBtn rounded=\"pill\" class=\"mt-4\" color=\"primary\" prepend-icon=\"mdi-home\" to=\"/\">\n        {{ t('resource.backToHome') }}\n      </VBtn>\n    </div>\n\n    <!-- 初始加载状态 -->\n    <LoadingBanner v-else-if=\"!isRefreshed && !isSearchLoading\" />\n    <!-- 滚动到顶部按钮 -->\n    <Teleport to=\"body\" v-if=\"route.path === '/resource'\">\n      <VScrollToTopBtn />\n    </Teleport>\n  </div>\n</template>\n\n<style scoped>\n.search-loading-state {\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n}\n\n.search-loading-state.is-empty-loading {\n  min-block-size: 50vh;\n}\n\n.search-progress-card {\n  padding: 16px;\n  border: 1px solid rgba(var(--v-theme-primary), 0.18);\n  border-radius: 8px;\n  backdrop-filter: blur(10px);\n  background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.08), transparent 42%), rgb(var(--v-theme-surface));\n  inline-size: 100%;\n}\n\n.progress-header {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n}\n\n.progress-icon-wrap {\n  display: flex;\n  flex: 0 0 auto;\n  align-items: center;\n  justify-content: center;\n}\n\n.progress-copy {\n  flex: 1 1 auto;\n  min-inline-size: 0;\n}\n\n.progress-title {\n  display: block;\n  overflow: hidden;\n  color: rgb(var(--v-theme-on-surface));\n  font-size: 1rem;\n  font-weight: 600;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.progress-tags {\n  gap: 6px;\n  margin-block-start: 8px;\n}\n\n.progress-tag {\n  max-inline-size: 100%;\n}\n\n.progress-bar-container {\n  display: flex;\n  align-items: center;\n  margin-block-start: 14px;\n}\n\n.progress-percentage {\n  flex: 0 0 auto;\n  color: rgb(var(--v-theme-primary));\n  font-size: 0.95rem;\n  font-weight: 700;\n  min-inline-size: 44px;\n  text-align: end;\n}\n\n.search-skeleton-grid {\n  display: grid;\n  gap: 16px;\n  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));\n}\n\n.search-skeleton-card,\n.search-skeleton-list {\n  overflow: hidden;\n  border: 1px solid rgba(var(--v-theme-on-surface), 0.08);\n  border-radius: 8px;\n  background: rgb(var(--v-theme-surface));\n}\n\n.search-skeleton-row + .search-skeleton-row {\n  border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);\n}\n\n.stream-result-item {\n  animation: stream-result-in 0.28s ease-out both;\n}\n\n@keyframes stream-result-in {\n  from {\n    opacity: 0;\n    transform: translateY(8px);\n  }\n\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n/* 精简标题栏样式 */\n.search-header {\n  border: 1px solid rgba(var(--v-theme-on-surface), 0.08);\n  padding-block: 8px;\n  padding-inline: 12px;\n}\n\n.search-info-container {\n  gap: 12px;\n}\n\n.search-title {\n  font-size: 1.2rem;\n  font-weight: 600;\n}\n\n.search-tags {\n  gap: 8px;\n}\n\n.search-tag {\n  font-size: 0.75rem;\n}\n\n/* 重新设计的视图切换按钮 */\n.view-toggle-container {\n  position: relative;\n}\n\n.view-toggle-buttons {\n  position: relative;\n  display: flex;\n  padding: 4px;\n  border-radius: 8px;\n  background-color: rgba(var(--v-theme-surface-variant), 0.1);\n  isolation: isolate; /* Create new stacking context */\n}\n\n.active-indicator {\n  position: absolute;\n  z-index: 1;\n  border-radius: 6px;\n  background-color: rgb(var(--v-theme-surface));\n  block-size: 36px;\n  box-shadow:\n    0 1px 3px rgba(0, 0, 0, 12%),\n    0 1px 2px rgba(0, 0, 0, 24%);\n  inline-size: 40px;\n  inset-block-start: 4px;\n  inset-inline-start: 4px;\n  transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n.active-indicator.row {\n  transform: translateX(40px);\n}\n\n.view-toggle-btn {\n  position: relative;\n  z-index: 2; /* Sit on top of indicator */\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border: none;\n  background: transparent;\n  block-size: 36px;\n  cursor: pointer;\n  inline-size: 40px;\n  transition: all 0.2s ease;\n}\n\n.view-toggle-btn:hover:not(.active) {\n  border-radius: 6px;\n  background-color: rgba(var(--v-theme-primary), 0.05);\n}\n\n/* AI按钮组样式 */\n.ai-toggle-container {\n  position: relative;\n}\n\n.ai-toggle-buttons {\n  display: flex;\n  overflow: hidden;\n  align-items: center;\n  padding: 0;\n  border-radius: 8px;\n  background-color: rgba(var(--v-theme-surface-variant), 0.1);\n  block-size: 44px; /* 36px(btn) + 4px*2(padding) to match right side exactly */\n}\n\n.ai-recommend-btn {\n  margin: 0;\n  block-size: 100% !important;\n  transition: all 0.3s ease;\n}\n\n/* 仅为激活的按钮添加背景 */\n.ai-recommend-btn.ai-active {\n  z-index: 1;\n  background-color: rgba(var(--v-theme-primary), 0.15);\n}\n\n/* 图标基础样式 */\n.ai-icon {\n  color: rgba(var(--v-theme-on-surface), 0.6);\n  transform: translateZ(0);\n  transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n/* 激活状态图标：变色 + 辉光 */\n.ai-icon-active {\n  color: rgb(var(--v-theme-primary));\n  filter: drop-shadow(0 0 4px rgba(var(--v-theme-primary), 0.5));\n}\n\n/* 文字基础样式 */\n.ai-text {\n  color: rgba(var(--v-theme-on-surface), 0.6);\n  font-size: 0.85rem;\n  font-weight: 600; /* 保持一致的字重防止位移 */\n  transform: translateZ(0);\n  transition: color 0.3s ease;\n}\n\n/* 激活状态文字 */\n.ai-text-active {\n  color: rgb(var(--v-theme-primary));\n}\n\n/* 刷新图标样式 */\n.ai-refresh-icon {\n  color: rgba(var(--v-theme-on-surface), 0.6);\n  transition: color 0.3s ease;\n}\n\n.ai-divider {\n  z-index: 0;\n  flex-shrink: 0;\n  block-size: 20px;\n  border-inline-start: 1px solid rgba(var(--v-theme-on-surface), 0.12); /* 使用边框显示线条 */\n  inline-size: 0; /* 宽度设为0，不占用空间 */\n  transition: opacity 0.3s ease;\n}\n\n.search-results-container {\n  position: relative;\n  min-block-size: 50vh;\n}\n\n/* 卡片网格布局 */\n.grid-torrent-card {\n  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));\n}\n\n/* 列表视图样式 */\n.resource-list-container {\n  padding: 8px;\n  border: 1px solid rgba(var(--v-theme-on-surface), 0.08);\n  border-radius: 12px;\n}\n\n.resource-list {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n}\n\n/* 无结果提示 */\n.no-results {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  min-block-size: 300px;\n}\n\n@media (width <= 600px) {\n  .search-header {\n    padding-block: 6px;\n    padding-inline: 12px;\n  }\n\n  .search-title {\n    font-size: 1.1rem;\n    white-space: nowrap;\n  }\n\n  .search-info-container {\n    gap: 8px;\n    min-inline-size: 0;\n  }\n\n  .search-tags {\n    flex-wrap: nowrap;\n    margin-inline-end: 4px;\n    overflow-x: auto;\n    scrollbar-width: none;\n  }\n\n  .search-tags::-webkit-scrollbar {\n    display: none;\n  }\n\n  .search-loading-state {\n    gap: 12px;\n  }\n\n  .search-progress-card {\n    padding: 12px;\n  }\n\n  .progress-header {\n    align-items: flex-start;\n  }\n\n  .progress-icon-wrap {\n    padding-block-start: 2px;\n  }\n\n  .progress-title {\n    white-space: normal;\n  }\n\n  .progress-percentage {\n    font-size: 0.85rem;\n    min-inline-size: 36px;\n  }\n\n  .progress-tags {\n    flex-wrap: nowrap;\n    overflow-x: auto;\n    scrollbar-width: none;\n  }\n\n  .progress-tags::-webkit-scrollbar {\n    display: none;\n  }\n\n  .search-skeleton-grid {\n    grid-template-columns: 1fr;\n  }\n\n  .view-toggle-container {\n    flex-shrink: 0;\n  }\n\n  .view-toggle-buttons {\n    padding: 2px;\n  }\n\n  .active-indicator {\n    block-size: 32px;\n    inline-size: 36px;\n    inset-block-start: 2px;\n    inset-inline-start: 2px;\n  }\n\n  .active-indicator.row {\n    transform: translateX(36px);\n  }\n\n  .view-toggle-btn {\n    block-size: 32px;\n    inline-size: 36px;\n  }\n\n  .ai-toggle-buttons {\n    block-size: 36px;\n  }\n\n  .ai-text {\n    font-size: 0.8rem;\n  }\n\n  .ai-recommend-btn,\n  .ai-toggle-buttons .v-btn {\n    block-size: 36px !important;\n    min-inline-size: unset !important;\n  }\n\n  .ai-recommend-btn {\n    padding-inline: 12px 8px !important;\n  }\n\n  .ai-toggle-buttons .v-btn:last-child {\n    min-inline-size: 32px !important;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/pages/setting.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useRoute } from 'vue-router'\nimport router from '@/router'\nimport AccountSettingNotification from '@/views/setting/AccountSettingNotification.vue'\nimport AccountSettingSite from '@/views/setting/AccountSettingSite.vue'\nimport AccountSettingSearch from '@/views/setting/AccountSettingSearch.vue'\nimport AccountSettingSubscribe from '@/views/setting/AccountSettingSubscribe.vue'\nimport AccountSettingSystem from '@/views/setting/AccountSettingSystem.vue'\nimport AccountSettingDirectory from '@/views/setting/AccountSettingDirectory.vue'\nimport AccountSettingRule from '@/views/setting/AccountSettingRule.vue'\nimport { getSettingTabs } from '@/router/i18n-menu'\nimport { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'\n\nconst { t } = useI18n()\nconst route = useRoute()\n\nconst activeTab = ref((route.query.tab as string) || '')\nconst settingTabs = computed(() => getSettingTabs(t))\n\n// 使用动态标签页\nconst { registerHeaderTab } = useDynamicHeaderTab()\n\n// 注册动态标签页\nregisterHeaderTab({\n  items: settingTabs.value,\n  modelValue: activeTab,\n})\n\n// 注册动态标签页\nonMounted(() => {\n  // 设置初始activeTab值\n  if (!activeTab.value && settingTabs.value.length > 0) {\n    activeTab.value = settingTabs.value[0].tab\n  }\n})\n</script>\n\n<template>\n  <div>\n    <VWindow v-model=\"activeTab\" class=\"disable-tab-transition\" :touch=\"false\">\n      <!-- 系统 -->\n      <VWindowItem value=\"system\">\n        <transition name=\"fade-slide\" appear>\n          <div>\n            <AccountSettingSystem />\n          </div>\n        </transition>\n      </VWindowItem>\n\n      <!-- 目录 -->\n      <VWindowItem value=\"directory\">\n        <transition name=\"fade-slide\" appear>\n          <div>\n            <AccountSettingDirectory />\n          </div>\n        </transition>\n      </VWindowItem>\n\n      <!-- 站点 -->\n      <VWindowItem value=\"site\">\n        <transition name=\"fade-slide\" appear>\n          <div>\n            <AccountSettingSite />\n          </div>\n        </transition>\n      </VWindowItem>\n\n      <!-- 规则 -->\n      <VWindowItem value=\"rule\">\n        <transition name=\"fade-slide\" appear>\n          <div>\n            <AccountSettingRule />\n          </div>\n        </transition>\n      </VWindowItem>\n\n      <!-- 搜索 -->\n      <VWindowItem value=\"search\">\n        <transition name=\"fade-slide\" appear>\n          <div>\n            <AccountSettingSearch />\n          </div>\n        </transition>\n      </VWindowItem>\n\n      <!-- 订阅 -->\n      <VWindowItem value=\"subscribe\">\n        <transition name=\"fade-slide\" appear>\n          <div>\n            <AccountSettingSubscribe />\n          </div>\n        </transition>\n      </VWindowItem>\n\n      <!-- 通知 -->\n      <VWindowItem value=\"notification\">\n        <transition name=\"fade-slide\" appear>\n          <div>\n            <AccountSettingNotification />\n          </div>\n        </transition>\n      </VWindowItem>\n    </VWindow>\n  </div>\n</template>\n"
  },
  {
    "path": "src/pages/setup.vue",
    "content": "<script lang=\"ts\" setup>\nimport { onMounted } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport { useRouter } from 'vue-router'\nimport { useSetupWizard } from '@/composables/useSetupWizard'\nimport BasicSettingsStep from '@/views/setup/BasicSettingsStep.vue'\nimport SiteAuthSettingsStep from '@/views/setup/SiteAuthSettingsStep.vue'\nimport StorageSettingsStep from '@/views/setup/StorageSettingsStep.vue'\nimport DownloaderSettingsStep from '@/views/setup/DownloaderSettingsStep.vue'\nimport MediaServerSettingsStep from '@/views/setup/MediaServerSettingsStep.vue'\nimport NotificationSettingsStep from '@/views/setup/NotificationSettingsStep.vue'\nimport AgentSettingsStep from '@/views/setup/AgentSettingsStep.vue'\nimport PreferencesSettingsStep from '@/views/setup/PreferencesSettingsStep.vue'\nimport ConnectivityTest from '@/views/setup/ConnectivityTest.vue'\nimport { useDisplay } from 'vuetify'\n\nconst { t } = useI18n()\nconst router = useRouter()\n\n// 显示器宽度\nconst display = useDisplay()\n\nconst {\n  currentStep,\n  totalSteps,\n  stepTitles,\n  connectivityTest,\n  nextStep,\n  prevStep,\n  completeWizard,\n  initialize,\n  isLoading,\n} = useSetupWizard()\n\n// 初始化\nonMounted(async () => {\n  await initialize()\n})\n</script>\n\n<template>\n  <div class=\"setup-wizard-fullscreen\">\n    <!-- 全屏头部 -->\n    <div class=\"setup-wizard-header\">\n      <div class=\"d-flex align-center justify-space-between\">\n        <!-- 左侧占位 -->\n        <div v-if=\"display.mdAndUp.value\" style=\"inline-size: 96px\"></div>\n\n        <!-- 中间标题 -->\n        <div class=\"d-flex align-center text-center\">\n          <div>\n            <h1 class=\"text-h3 font-weight-bold text-moviepilot mb-3\">{{ t('setupWizard.title') }}</h1>\n            <p class=\"text-body-1 text-medium-emphasis\">{{ t('setupWizard.subtitle') }}</p>\n          </div>\n        </div>\n\n        <!-- 右侧按钮组 -->\n        <div v-if=\"display.mdAndUp.value\" class=\"d-flex gap-2 px-3\">\n          <VBtn\n            variant=\"text\"\n            icon=\"mdi-cog\"\n            @click=\"router.push('/setting')\"\n            size=\"small\"\n            class=\"text-medium-emphasis\"\n          />\n          <VBtn variant=\"text\" icon=\"mdi-close\" @click=\"router.push('/')\" size=\"small\" />\n        </div>\n      </div>\n    </div>\n\n    <!-- 向导内容 -->\n    <VCard max-width=\"800px\" class=\"mx-auto my-5\">\n      <VCardText class=\"px-1\">\n        <!-- 加载状态 -->\n        <div v-if=\"isLoading\" class=\"d-flex flex-column align-center justify-center py-16\">\n          <VProgressCircular indeterminate color=\"primary\" size=\"64\" class=\"mb-4\" />\n          <p class=\"text-body-1 text-medium-emphasis\">{{ t('setupWizard.loading') }}</p>\n        </div>\n\n        <!-- 使用 VStepper 组件 -->\n        <VStepper v-else v-model=\"currentStep\" class=\"elevation-0\" flat alt-labels :mobile=\"display.smAndDown.value\">\n          <!-- 步骤标题 -->\n          <VStepperHeader class=\"elevation-0\">\n            <template v-for=\"(step, index) in stepTitles\" :key=\"index\">\n              <VStepperItem\n                :value=\"index + 1\"\n                :complete=\"currentStep > index + 1\"\n                :color=\"currentStep >= index + 1 ? 'primary' : 'default'\"\n                complete-icon=\"mdi-check-circle\"\n              >\n                <template #title>\n                  <span class=\"text-caption\">{{ step }}</span>\n                </template>\n              </VStepperItem>\n              <VDivider v-if=\"index < stepTitles.length - 1\" />\n            </template>\n          </VStepperHeader>\n\n          <!-- 步骤内容 -->\n          <VStepperWindow>\n            <!-- 步骤1：基础参数 -->\n            <VStepperWindowItem :value=\"1\">\n              <BasicSettingsStep />\n            </VStepperWindowItem>\n\n            <!-- 步骤2：用户认证 -->\n            <VStepperWindowItem :value=\"2\">\n              <SiteAuthSettingsStep />\n            </VStepperWindowItem>\n\n            <!-- 步骤3：存储目录 -->\n            <VStepperWindowItem :value=\"3\">\n              <StorageSettingsStep />\n            </VStepperWindowItem>\n\n            <!-- 步骤4：下载器 -->\n            <VStepperWindowItem :value=\"4\">\n              <DownloaderSettingsStep />\n            </VStepperWindowItem>\n\n            <!-- 步骤5：媒体服务器 -->\n            <VStepperWindowItem :value=\"5\">\n              <MediaServerSettingsStep />\n            </VStepperWindowItem>\n\n            <!-- 步骤6：通知 -->\n            <VStepperWindowItem :value=\"6\">\n              <NotificationSettingsStep />\n            </VStepperWindowItem>\n\n            <!-- 步骤7：智能助手 -->\n            <VStepperWindowItem :value=\"7\">\n              <AgentSettingsStep />\n            </VStepperWindowItem>\n\n            <!-- 步骤8：资源偏好 -->\n            <VStepperWindowItem :value=\"8\">\n              <PreferencesSettingsStep />\n            </VStepperWindowItem>\n          </VStepperWindow>\n\n          <!-- 连通性测试进度条 -->\n          <ConnectivityTest />\n\n          <!-- 操作按钮 -->\n          <VCardActions class=\"justify-space-between\">\n            <div class=\"d-flex gap-2\">\n              <VBtn\n                v-if=\"currentStep !== 1\"\n                prepend-icon=\"mdi-chevron-left\"\n                @click=\"prevStep\"\n                :disabled=\"connectivityTest.isTesting\"\n              >\n                {{ t('common.previous') }}\n              </VBtn>\n            </div>\n\n            <div class=\"d-flex gap-2\">\n              <VBtn\n                v-if=\"currentStep < totalSteps\"\n                color=\"primary\"\n                append-icon=\"mdi-chevron-right\"\n                @click=\"nextStep\"\n                :disabled=\"connectivityTest.isTesting\"\n              >\n                {{ connectivityTest.isTesting ? t('setupWizard.testing') : t('common.next') }}\n              </VBtn>\n              <VBtn\n                v-else\n                color=\"success\"\n                prepend-icon=\"mdi-check\"\n                @click=\"completeWizard\"\n                :disabled=\"connectivityTest.isTesting\"\n              >\n                {{ t('setupWizard.complete') }}\n              </VBtn>\n            </div>\n          </VCardActions>\n        </VStepper>\n      </VCardText>\n    </VCard>\n  </div>\n</template>\n\n<style scoped>\n.setup-wizard-fullscreen {\n  position: fixed;\n  background-color: rgb(var(--v-theme-background));\n  inset: 0;\n  overflow-y: auto;\n}\n\n.setup-wizard-header {\n  position: sticky;\n  z-index: 2000;\n  background-color: rgb(var(--v-theme-surface));\n  border-block-end: 1px solid rgb(var(--v-theme-outline-variant));\n  box-shadow: 0 0 5px rgba(0, 0, 0, 4%);\n  inset-block-start: 0;\n  padding-block: calc(16px + env(safe-area-inset-top)) 16px;\n}\n</style>\n"
  },
  {
    "path": "src/pages/site.vue",
    "content": "<script setup lang=\"ts\">\nimport SiteCardListView from '@/views/site/SiteCardListView.vue'\n</script>\n\n<template>\n  <div>\n    <SiteCardListView />\n  </div>\n</template>\n"
  },
  {
    "path": "src/pages/subscribe-share.vue",
    "content": "<script setup lang=\"ts\">\nimport SubscribeShareView from '@/views/subscribe/SubscribeShareView.vue'\n// 从路由参数中获取搜索关键字\nconst route = useRoute()\nconst keyword = route.query.keyword as string\n</script>\n\n<template>\n  <div>\n    <SubscribeShareView :keyword=\"keyword\" />\n    <!-- 滚动到顶部按钮 -->\n    <Teleport to=\"body\" v-if=\"route.path === '/subscribe-share'\">\n      <VScrollToTopBtn />\n    </Teleport>\n  </div>\n</template>\n"
  },
  {
    "path": "src/pages/subscribe.vue",
    "content": "<script setup lang=\"ts\">\nimport { debounce } from 'lodash-es'\nimport SubscribeListView from '@/views/subscribe/SubscribeListView.vue'\nimport SubscribePopularView from '@/views/subscribe/SubscribePopularView.vue'\nimport SubscribeShareView from '@/views/subscribe/SubscribeShareView.vue'\nimport SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue'\nimport SubscribeShareStatisticsDialog from '@/components/dialog/SubscribeShareStatisticsDialog.vue'\nimport { useI18n } from 'vue-i18n'\nimport { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'\nimport { useDynamicButton } from '@/composables/useDynamicButton'\nimport { usePWA } from '@/composables/usePWA'\nimport { useUserStore } from '@/stores'\n\nimport { getSubscribeMovieTabs, getSubscribeTvTabs } from '@/router/i18n-menu'\n\n// 国际化\nconst { t } = useI18n()\n\nconst route = useRoute()\nconst userStore = useUserStore()\nconst { appMode } = usePWA()\n\nconst subType = route.meta.subType?.toString()\nconst subId = ref(route.query.id as string)\nconst activeTab = ref((route.query.tab as string) || '')\nconst subscribeListViewRef = ref<InstanceType<typeof SubscribeListView> | null>(null)\n\n// 获取标签页\nconst subscribeTabs = computed(() => {\n  if (subType === '电影') {\n    return getSubscribeMovieTabs(t)\n  } else {\n    return getSubscribeTvTabs(t)\n  }\n})\n\n// 默认订阅设置弹窗\nconst subscribeEditDialog = ref(false)\n\n// 订阅过滤弹窗\nconst filterSubscribeDialog = ref(false)\n\n// 搜索订阅分享弹窗\nconst searchShareDialog = ref(false)\n\n// 订阅分享统计弹窗\nconst shareStatisticsDialog = ref(false)\n\n// 订阅过滤词\nconst subscribeFilter = ref('')\n\n// 订阅状态筛选\nconst subscribeStatusFilter = ref<string | null>(null)\n\n// 分享搜索词\nconst shareKeyword = ref('')\nconst shareKeywordInput = ref('')\n\n// 筛选选项\nconst filterOptions = computed(() => {\n  const baseOptions = [\n    { value: 'all', label: t('common.all'), icon: 'mdi-filter-multiple-outline' },\n    { value: 'best_version', label: t('subscribe.bestVersion'), icon: 'mdi-refresh', color: 'warning' },\n  ]\n\n  // 电影只显示基本选项和状态选项\n  if (subType === '电影') {\n    return [\n      ...baseOptions,\n      { value: 'pending', label: t('subscribe.pending'), icon: 'mdi-help-circle', color: 'secondary' },\n      { value: 'paused', label: t('subscribe.paused'), icon: 'mdi-pause-circle', color: 'error' },\n    ]\n  }\n\n  // 电视剧显示所有选项\n  return [\n    ...baseOptions,\n    { value: 'not_started', label: t('subscribe.notStarted'), icon: 'mdi-clock-outline', color: 'secondary' },\n    { value: 'subscribing', label: t('subscribe.subscribing'), icon: 'mdi-download', color: 'info' },\n    { value: 'pending', label: t('subscribe.pending'), icon: 'mdi-help-circle', color: 'secondary' },\n    { value: 'paused', label: t('subscribe.paused'), icon: 'mdi-pause-circle', color: 'error' },\n    { value: 'completed', label: t('subscribe.completed'), icon: 'mdi-check-circle', color: 'success' },\n  ]\n})\n\n// 当前选中的筛选选项\nconst currentFilter = computed(() => {\n  return filterOptions.value.find(option => option.value === (subscribeStatusFilter.value || 'all'))\n})\n\n// 计算筛选按钮颜色 - 有名称筛选或状态筛选时高亮\nconst filterButtonColor = computed(() => {\n  if (subscribeFilter.value || (subscribeStatusFilter.value && subscribeStatusFilter.value !== 'all')) {\n    return currentFilter.value?.color || 'primary'\n  }\n  return 'gray'\n})\n\n// 选择筛选选项\nfunction selectFilter(value: string) {\n  subscribeStatusFilter.value = value\n  filterSubscribeDialog.value = false\n}\n\n// VMenu activator选择器\nconst filterActivator = computed(() => '[data-menu-activator=\"filter-btn\"]')\nconst searchActivator = computed(() => '[data-menu-activator=\"share-filter-btn\"]')\n\nconst showDefaultRuleAction = computed(() => activeTab.value === 'mysub')\nconst showSubscribeHistoryAction = computed(() => showDefaultRuleAction.value && userStore.superUser)\nconst showShareStatisticsAction = computed(() => activeTab.value === 'share')\n\nfunction openDefaultRuleDialog() {\n  subscribeEditDialog.value = true\n}\n\nfunction openSubscribeHistoryDialog() {\n  subscribeListViewRef.value?.openHistoryDialog()\n}\n\nfunction openShareStatisticsDialog() {\n  shareStatisticsDialog.value = true\n}\n\nconst shareKeywordUpdater = debounce((keyword: string) => {\n  shareKeyword.value = keyword.trim()\n}, 300)\n\nwatch(shareKeywordInput, newKeyword => {\n  shareKeywordUpdater(newKeyword || '')\n})\n\nwatch(activeTab, newTab => {\n  if (newTab !== 'share') {\n    searchShareDialog.value = false\n  }\n})\n\nonUnmounted(() => {\n  shareKeywordUpdater.cancel()\n})\n\nconst subscribeDynamicMenuItems = computed(() => {\n  if (!appMode.value) return undefined\n\n  if (activeTab.value === 'mysub') {\n    const items: Array<{\n      titleKey: string\n      titleParams?: Record<string, unknown>\n      icon: string\n      action: () => void\n    }> = []\n\n    if (showSubscribeHistoryAction.value) {\n      items.push({\n        titleKey: 'dialog.subscribeHistory.title',\n        titleParams: { type: subType },\n        icon: 'mdi-history',\n        action: openSubscribeHistoryDialog,\n      })\n    }\n\n    items.push({\n      titleKey: 'dialog.subscribeEdit.titleDefault',\n      icon: 'mdi-clipboard-edit-outline',\n      action: openDefaultRuleDialog,\n    })\n\n    return items.length > 1 ? items : undefined\n  }\n\n  return undefined\n})\n\nconst subscribeDynamicIcon = computed(() => {\n  if (showShareStatisticsAction.value) return 'mdi-chart-line'\n  if (showSubscribeHistoryAction.value) return 'mdi-history'\n  return 'mdi-clipboard-edit-outline'\n})\n\nfunction handleSubscribeDynamicAction() {\n  if (showShareStatisticsAction.value) {\n    openShareStatisticsDialog()\n    return\n  }\n\n  if (showSubscribeHistoryAction.value) {\n    openSubscribeHistoryDialog()\n    return\n  }\n\n  if (showDefaultRuleAction.value) {\n    openDefaultRuleDialog()\n  }\n}\n\nuseDynamicButton({\n  icon: subscribeDynamicIcon,\n  onClick: handleSubscribeDynamicAction,\n  menuItems: subscribeDynamicMenuItems,\n  show: computed(() => appMode.value && (showDefaultRuleAction.value || showShareStatisticsAction.value)),\n})\n\n// 使用动态标签页\nconst { registerHeaderTab } = useDynamicHeaderTab()\n\n// 注册动态标签页\nregisterHeaderTab({\n  items: subscribeTabs.value,\n  modelValue: activeTab,\n  appendButtons: [\n    {\n      icon: 'mdi-filter-multiple-outline',\n      variant: 'text',\n      color: filterButtonColor,\n      class: 'settings-icon-button',\n      dataAttr: 'filter-btn',\n      action: () => {\n        filterSubscribeDialog.value = true\n      },\n      show: computed(() => activeTab.value === 'mysub'),\n    },\n    {\n      icon: 'mdi-checkbox-multiple-marked-outline',\n      variant: 'text',\n      color: 'gray',\n      class: 'settings-icon-button',\n      action: () => {\n        // 触发批量管理模式\n        const event = new CustomEvent('toggle-batch-mode')\n        window.dispatchEvent(event)\n      },\n      show: computed(() => activeTab.value === 'mysub'),\n    },\n    {\n      icon: 'mdi-filter-multiple-outline',\n      variant: 'text',\n      color: computed(() => (shareKeywordInput.value ? 'primary' : 'gray')),\n      class: 'settings-icon-button',\n      dataAttr: 'share-filter-btn',\n      action: () => {\n        searchShareDialog.value = true\n      },\n      show: computed(() => activeTab.value === 'share'),\n    },\n  ],\n})\n\n// 注册动态标签页\nonMounted(() => {\n  // 设置初始activeTab值\n  if (!activeTab.value && subscribeTabs.value.length > 0) {\n    activeTab.value = subscribeTabs.value[0].tab\n  }\n})\n</script>\n\n<template>\n  <div>\n    <VWindow v-model=\"activeTab\" class=\"disable-tab-transition content-window\" :touch=\"false\">\n      <VWindowItem value=\"mysub\">\n        <transition name=\"fade-slide\" appear>\n          <div>\n            <SubscribeListView\n              ref=\"subscribeListViewRef\"\n              :type=\"subType\"\n              :subid=\"subId\"\n              :keyword=\"subscribeFilter\"\n              :status-filter=\"subscribeStatusFilter ?? ''\"\n            />\n          </div>\n        </transition>\n      </VWindowItem>\n      <VWindowItem value=\"popular\">\n        <transition name=\"fade-slide\" appear>\n          <div>\n            <SubscribePopularView :type=\"subType\" />\n          </div>\n        </transition>\n      </VWindowItem>\n      <VWindowItem value=\"share\">\n        <transition name=\"fade-slide\" appear>\n          <div>\n            <SubscribeShareView :keyword=\"shareKeyword\" />\n          </div>\n        </transition>\n      </VWindowItem>\n    </VWindow>\n\n    <!-- 订阅过滤下拉菜单 -->\n    <Teleport to=\"body\" v-if=\"filterSubscribeDialog\">\n      <VMenu\n        v-model=\"filterSubscribeDialog\"\n        :close-on-content-click=\"false\"\n        :activator=\"filterActivator\"\n        location=\"bottom end\"\n      >\n        <VCard min-width=\"220\">\n          <!-- 名称搜索 -->\n          <div class=\"pa-3\">\n            <VTextField\n              v-model=\"subscribeFilter\"\n              :placeholder=\"t('subscribe.name')\"\n              prepend-inner-icon=\"mdi-magnify\"\n              density=\"compact\"\n              variant=\"outlined\"\n              hide-details\n              clearable\n            />\n          </div>\n          <VDivider class=\"mt-2\" />\n          <!-- 状态筛选列表 -->\n          <VList density=\"compact\" class=\"px-2 py-1\">\n            <VListSubheader>{{ t('common.status') }}</VListSubheader>\n            <VListItem\n              v-for=\"option in filterOptions\"\n              :key=\"option.value\"\n              :active=\"(subscribeStatusFilter || 'all') === option.value\"\n              @click=\"selectFilter(option.value)\"\n              density=\"compact\"\n            >\n              <template #prepend>\n                <VIcon :icon=\"option.icon\" :color=\"option.color\" size=\"small\" />\n              </template>\n              <VListItemTitle>{{ option.label }}</VListItemTitle>\n              <template #append>\n                <VIcon\n                  v-if=\"(subscribeStatusFilter || 'all') === option.value\"\n                  icon=\"mdi-check\"\n                  color=\"primary\"\n                  size=\"small\"\n                />\n              </template>\n            </VListItem>\n          </VList>\n        </VCard>\n      </VMenu>\n    </Teleport>\n\n    <!-- 搜索订阅分享弹窗 -->\n    <Teleport to=\"body\" v-if=\"searchShareDialog\">\n      <VMenu\n        v-model=\"searchShareDialog\"\n        :close-on-content-click=\"false\"\n        :activator=\"searchActivator\"\n        location=\"bottom end\"\n      >\n        <VCard min-width=\"260\" max-width=\"320\">\n          <div class=\"pa-3\">\n            <VTextField\n              v-model=\"shareKeywordInput\"\n              :placeholder=\"t('subscribe.keyword')\"\n              prepend-inner-icon=\"mdi-magnify\"\n              density=\"compact\"\n              variant=\"outlined\"\n              hide-details\n              clearable\n            />\n          </div>\n        </VCard>\n      </VMenu>\n    </Teleport>\n\n    <Teleport to=\"body\" v-if=\"!appMode && route.path.startsWith(`/subscribe/${subType === '电影' ? 'movie' : 'tv'}`)\">\n      <div class=\"compact-fab-stack\">\n        <VFab\n          v-if=\"showSubscribeHistoryAction\"\n          icon=\"mdi-history\"\n          color=\"info\"\n          variant=\"tonal\"\n          appear\n          class=\"compact-fab compact-fab--secondary\"\n          @click=\"openSubscribeHistoryDialog\"\n        />\n        <VFab\n          v-if=\"showDefaultRuleAction\"\n          icon=\"mdi-clipboard-edit-outline\"\n          color=\"primary\"\n          appear\n          class=\"compact-fab compact-fab--primary\"\n          @click=\"openDefaultRuleDialog\"\n        />\n        <VFab\n          v-if=\"showShareStatisticsAction\"\n          icon=\"mdi-chart-line\"\n          color=\"primary\"\n          appear\n          class=\"compact-fab compact-fab--primary\"\n          @click=\"openShareStatisticsDialog\"\n        />\n      </div>\n    </Teleport>\n\n    <!-- 订阅编辑弹窗 -->\n    <SubscribeEditDialog\n      v-if=\"subscribeEditDialog\"\n      v-model=\"subscribeEditDialog\"\n      :default=\"true\"\n      :type=\"subType\"\n      @save=\"subscribeEditDialog = false\"\n      @close=\"subscribeEditDialog = false\"\n    />\n\n    <!-- 订阅分享统计弹窗 -->\n    <SubscribeShareStatisticsDialog\n      v-if=\"shareStatisticsDialog\"\n      v-model=\"shareStatisticsDialog\"\n      @close=\"shareStatisticsDialog = false\"\n    />\n  </div>\n</template>\n\n<style scoped>\n.content-window {\n  margin-block-start: 0;\n}\n</style>\n"
  },
  {
    "path": "src/pages/user.vue",
    "content": "<script setup lang=\"ts\">\nimport UserListView from '@/views/user/UserListView.vue'\n</script>\n\n<template>\n  <div>\n    <UserListView />\n  </div>\n</template>\n"
  },
  {
    "path": "src/pages/workflow.vue",
    "content": "<script setup lang=\"ts\">\nimport { debounce } from 'lodash-es'\nimport WorkflowListView from '@/views/workflow/WorkflowListView.vue'\nimport WorkflowShareView from '@/views/workflow/WorkflowShareView.vue'\nimport { useI18n } from 'vue-i18n'\nimport { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'\nimport { useDynamicButton } from '@/composables/useDynamicButton'\nimport { usePWA } from '@/composables/usePWA'\nimport { getWorkflowTabs } from '@/router/i18n-menu'\n\n// 国际化\nconst { t } = useI18n()\n\nconst route = useRoute()\nconst { appMode } = usePWA()\n\nconst activeTab = ref((route.query.tab as string) || 'list')\nconst listViewKey = ref(0)\nconst workflowListViewRef = ref<InstanceType<typeof WorkflowListView> | null>(null)\n\n// 获取标签页\nconst workflowTabs = computed(() => {\n  return getWorkflowTabs(t)\n})\n\n// 分享搜索词\nconst shareKeyword = ref('')\nconst shareKeywordInput = ref('')\n\n// 搜索分享对话框\nconst searchShareDialog = ref(false)\n\n// 搜索分享激活器\nconst searchActivator = computed(() => '[data-menu-activator=\"share-filter-btn\"]')\n\nfunction openAddWorkflowDialog() {\n  workflowListViewRef.value?.openAddDialog()\n}\n\nconst shareKeywordUpdater = debounce((keyword: string) => {\n  shareKeyword.value = keyword.trim()\n}, 300)\n\nwatch(shareKeywordInput, newKeyword => {\n  shareKeywordUpdater(newKeyword || '')\n})\n\nwatch(activeTab, newTab => {\n  if (newTab !== 'share') {\n    searchShareDialog.value = false\n  }\n})\n\nonUnmounted(() => {\n  shareKeywordUpdater.cancel()\n})\n\nuseDynamicButton({\n  icon: 'mdi-plus',\n  onClick: openAddWorkflowDialog,\n  show: computed(() => appMode.value && activeTab.value === 'list'),\n})\n\n// 使用动态标签页\nconst { registerHeaderTab } = useDynamicHeaderTab()\n\n// 注册动态标签页\nregisterHeaderTab({\n  items: workflowTabs.value,\n  modelValue: activeTab,\n  appendButtons: [\n    {\n      icon: 'mdi-filter-multiple-outline',\n      variant: 'text',\n      color: computed(() => (shareKeywordInput.value ? 'primary' : 'gray')),\n      class: 'settings-icon-button',\n      dataAttr: 'share-filter-btn',\n      show: computed(() => activeTab.value === 'share'),\n      action: () => {\n        searchShareDialog.value = true\n      },\n    },\n  ],\n})\n\n// 注册动态标签页\nonMounted(() => {\n  // 设置初始activeTab值\n  if (!activeTab.value && workflowTabs.value.length > 0) {\n    activeTab.value = workflowTabs.value[0].tab\n  }\n})\n</script>\n\n<template>\n  <div>\n    <VWindow v-model=\"activeTab\" class=\"disable-tab-transition content-window\" :touch=\"false\">\n      <VWindowItem value=\"list\">\n        <transition name=\"fade-slide\" appear>\n          <div>\n            <WorkflowListView ref=\"workflowListViewRef\" :key=\"listViewKey\" />\n          </div>\n        </transition>\n      </VWindowItem>\n      <VWindowItem value=\"share\">\n        <transition name=\"fade-slide\" appear>\n          <div>\n            <WorkflowShareView :keyword=\"shareKeyword\" @update=\"listViewKey++\" />\n          </div>\n        </transition>\n      </VWindowItem>\n    </VWindow>\n\n    <!-- 搜索工作流分享弹窗 -->\n    <Teleport to=\"body\" v-if=\"searchShareDialog\">\n      <VMenu\n        v-model=\"searchShareDialog\"\n        :close-on-content-click=\"false\"\n        :activator=\"searchActivator\"\n        location=\"bottom end\"\n      >\n        <VCard min-width=\"260\" max-width=\"320\">\n          <div class=\"pa-3\">\n            <VTextField\n              v-model=\"shareKeywordInput\"\n              :placeholder=\"t('workflow.searchShares')\"\n              prepend-inner-icon=\"mdi-magnify\"\n              density=\"compact\"\n              variant=\"outlined\"\n              hide-details\n              clearable\n            />\n          </div>\n        </VCard>\n      </VMenu>\n    </Teleport>\n\n    <Teleport to=\"body\" v-if=\"!appMode && route.path === '/workflow' && activeTab === 'list'\">\n      <div class=\"compact-fab-stack\">\n        <VFab\n          icon=\"mdi-plus\"\n          color=\"primary\"\n          appear\n          class=\"compact-fab compact-fab--primary\"\n          @click=\"openAddWorkflowDialog\"\n        />\n      </div>\n    </Teleport>\n  </div>\n</template>\n\n<style scoped>\n.content-window {\n  margin-block-start: 0;\n}\n</style>\n"
  },
  {
    "path": "src/plugins/i18n.ts",
    "content": "import { createI18n } from 'vue-i18n'\nimport { nextTick } from 'vue'\nimport { SUPPORTED_LOCALES, SupportedLocale } from '@/types/i18n'\n\n// 导入语言文件\nimport zhCN from '@/locales/zh-CN'\nimport zhTW from '@/locales/zh-TW'\nimport enUS from '@/locales/en-US'\n\n// 创建 i18n 实例\nconst i18n = createI18n({\n  legacy: false, // 使用组合式API\n  locale: getBrowserLocale() || 'zh-CN', // 默认语言\n  fallbackLocale: 'zh-CN', // 回退语言\n  messages: {\n    'zh-CN': zhCN,\n    'zh-TW': zhTW,\n    'en-US': enUS,\n  },\n  silentTranslationWarn: true,\n  silentFallbackWarn: true,\n})\n\n/**\n * 获取浏览器语言设置\n */\nexport function getBrowserLocale(): SupportedLocale | null {\n  // 从本地存储获取\n  const storedLocale = localStorage.getItem('MP_LOCALE')\n  if (storedLocale && Object.keys(SUPPORTED_LOCALES).includes(storedLocale)) {\n    return storedLocale as SupportedLocale\n  }\n\n  // 从浏览器获取\n  const navigatorLocale = navigator.languages?.[0] || navigator.language || 'zh-CN'\n\n  // 检查是否为支持的语言\n  const locale = Object.keys(SUPPORTED_LOCALES).find(locale => {\n    return navigatorLocale.includes(locale.split('-')[0])\n  })\n\n  return (locale as SupportedLocale) || 'zh-CN'\n}\n\n/**\n * 设置i18n语言环境\n */\nexport async function setI18nLanguage(locale: SupportedLocale) {\n  // 更新 i18n 实例语言\n  i18n.global.locale.value = locale as any as any\n\n  // 保存到本地存储\n  localStorage.setItem('MP_LOCALE', locale)\n\n  // 更新 HTML 标签 lang 属性\n  document.querySelector('html')?.setAttribute('lang', locale)\n}\n\n/**\n * 获取当前语言\n */\nexport function getCurrentLocale(): SupportedLocale {\n  return i18n.global.locale.value as SupportedLocale\n}\n\nexport default i18n\n"
  },
  {
    "path": "src/plugins/stateRestore.ts",
    "content": "/**\n * PWA状态恢复插件 - 极简版\n * 只专注2个核心功能：路由、标签页\n */\n\nimport type { App } from 'vue'\n\n// =============================================================================\n// 1. 路由状态管理器\n// =============================================================================\n\nclass RouteStateManager {\n  private readonly STORAGE_KEY = 'pwa-current-route'\n\n  // 保存当前路由\n  saveCurrentRoute() {\n    const route = {\n      path: window.location.pathname,\n      search: window.location.search,\n      hash: window.location.hash,\n      timestamp: Date.now(),\n    }\n    sessionStorage.setItem(this.STORAGE_KEY, JSON.stringify(route))\n  }\n\n  // 恢复路由\n  restoreRoute() {\n    try {\n      const saved = sessionStorage.getItem(this.STORAGE_KEY)\n      if (!saved) return null\n\n      const route = JSON.parse(saved)\n      // 检查是否过期（1小时）\n      if (Date.now() - route.timestamp > 60 * 60 * 1000) {\n        this.clearRoute()\n        return null\n      }\n\n      return route\n    } catch {\n      return null\n    }\n  }\n\n  // 清除路由状态\n  clearRoute() {\n    sessionStorage.removeItem(this.STORAGE_KEY)\n  }\n\n  // 初始化路由恢复\n  init() {\n    // 监听路由变化，自动保存\n    window.addEventListener('popstate', () => this.saveCurrentRoute())\n\n    // 页面隐藏时保存\n    document.addEventListener('visibilitychange', () => {\n      if (document.hidden) {\n        this.saveCurrentRoute()\n      }\n    })\n\n    // 页面卸载时保存\n    window.addEventListener('beforeunload', () => {\n      this.saveCurrentRoute()\n    })\n  }\n}\n\n// =============================================================================\n// 2. 动态标签页状态管理器\n// =============================================================================\n\nclass TabStateManager {\n  private readonly STORAGE_KEY = 'pwa-active-tabs'\n\n  // 保存标签页状态\n  saveTabState(routePath: string, activeTab: string) {\n    try {\n      const allTabs = this.getAllTabStates()\n      allTabs[routePath] = {\n        activeTab,\n        timestamp: Date.now(),\n      }\n      sessionStorage.setItem(this.STORAGE_KEY, JSON.stringify(allTabs))\n    } catch (error) {\n      console.warn('保存标签页状态失败:', error)\n    }\n  }\n\n  // 获取标签页状态\n  getTabState(routePath: string): string | null {\n    try {\n      const allTabs = this.getAllTabStates()\n      const tabState = allTabs[routePath]\n\n      if (!tabState) return null\n\n      // 检查是否过期（1小时）\n      if (Date.now() - tabState.timestamp > 60 * 60 * 1000) {\n        delete allTabs[routePath]\n        sessionStorage.setItem(this.STORAGE_KEY, JSON.stringify(allTabs))\n        return null\n      }\n\n      return tabState.activeTab\n    } catch {\n      return null\n    }\n  }\n\n  // 获取所有标签页状态\n  private getAllTabStates(): Record<string, any> {\n    try {\n      const saved = sessionStorage.getItem(this.STORAGE_KEY)\n      return saved ? JSON.parse(saved) : {}\n    } catch {\n      return {}\n    }\n  }\n\n  // 清除标签页状态\n  clearTabState(routePath?: string) {\n    if (routePath) {\n      const allTabs = this.getAllTabStates()\n      delete allTabs[routePath]\n      sessionStorage.setItem(this.STORAGE_KEY, JSON.stringify(allTabs))\n    } else {\n      sessionStorage.removeItem(this.STORAGE_KEY)\n    }\n  }\n}\n\n// =============================================================================\n// 3. 主状态恢复管理器\n// =============================================================================\n\nclass StateRestore {\n  public route = new RouteStateManager()\n  public tab = new TabStateManager()\n\n  // 初始化\n  init() {\n    this.route.init()\n    this.setupAutoRestore()\n  }\n\n  // 设置自动恢复\n  private setupAutoRestore() {\n    // 页面显示时检查是否需要恢复状态\n    document.addEventListener('visibilitychange', () => {\n      if (!document.hidden) {\n        // 只恢复路由状态，不自动恢复标签页状态\n        // 标签页状态由组件自己控制，避免干扰当前页面状态\n        this.checkAndRestoreRoute()\n      }\n    })\n\n    // 页面加载完成后恢复状态\n    if (document.readyState === 'loading') {\n      document.addEventListener('DOMContentLoaded', () => {\n        setTimeout(() => this.checkAndRestoreRoute(), 100)\n      })\n    } else {\n      setTimeout(() => this.checkAndRestoreRoute(), 100)\n    }\n  }\n\n  // 检查并恢复路由状态（不恢复标签页状态）\n  private checkAndRestoreRoute() {\n    // 只恢复路由（如果当前路径与保存的不同）\n    const savedRoute = this.route.restoreRoute()\n    if (savedRoute && savedRoute.path !== window.location.pathname) {\n      const fullPath = savedRoute.path + savedRoute.search + savedRoute.hash\n      console.log('恢复路由:', fullPath)\n      window.history.replaceState(null, '', fullPath)\n    }\n\n    // 发送恢复事件，但标签页恢复由组件自己决定\n    window.dispatchEvent(\n      new CustomEvent('pwa-state-restore', {\n        detail: {\n          route: savedRoute,\n          tabs: this.tab,\n        },\n      }),\n    )\n  }\n\n  // 清除所有状态\n  clearAllStates() {\n    this.route.clearRoute()\n    this.tab.clearTabState()\n  }\n}\n\n// =============================================================================\n// 4. Vue插件安装\n// =============================================================================\n\nconst stateRestore = new StateRestore()\n\nexport default {\n  install(app: App) {\n    // 注册全局属性\n    app.config.globalProperties.$stateRestore = stateRestore\n\n    // 提供注入\n    app.provide('stateRestore', stateRestore)\n\n    // 初始化\n    stateRestore.init()\n\n    console.log('PWA状态恢复插件已安装（路由 + 标签页）')\n  },\n}\n\n// 导出管理器实例\nexport { stateRestore }\n\n// 导出类型\nexport type { RouteStateManager, TabStateManager, StateRestore }\n"
  },
  {
    "path": "src/plugins/vuetify/defaults.ts",
    "content": "export default {\n  IconBtn: {\n    icon: true,\n    color: 'default',\n    variant: 'text',\n    VIcon: {\n      size: 24,\n    },\n  },\n  VAlert: {\n    VBtn: {\n      color: undefined,\n    },\n  },\n  VAvatar: {\n    // ℹ️ Remove after next release\n    variant: 'flat',\n    VIcon: {\n      size: 24,\n    },\n  },\n  VBadge: {\n    // set v-badge default color to primary\n    color: 'primary',\n  },\n  VBtn: {\n    // set v-btn default color to primary\n    color: 'primary',\n    elevation: 0,\n  },\n  VCard: {\n    elevation: 0,\n    rounded: 'lg',\n  },\n  VMenu: {\n    elevation: 0,\n  },\n  VChip: {\n    elevation: 0,\n  },\n  VBottomSheet: {\n    elevation: 0,\n  },\n  VDialog: {\n    elevation: 0,\n    rounded: 'lg',\n  },\n  VExpansionPanels: {\n    elevation: 0,\n  },\n  VList: {\n    color: 'primary',\n    elevation: 0,\n  },\n  VListItem: {\n    rounded: 'md',\n  },\n  VPagination: {\n    activeColor: 'primary',\n  },\n  VTabs: {\n    // set v-tabs default color to primary\n    color: 'primary',\n    VSlideGroup: {\n      showArrows: true,\n    },\n  },\n  VTooltip: {\n    // set v-tooltip default location to top\n    location: 'top',\n  },\n  VCheckboxBtn: {\n    color: 'primary',\n    hideDetails: 'auto',\n  },\n  VCheckbox: {\n    // set v-checkbox default color to primary\n    color: 'primary',\n    hideDetails: 'auto',\n  },\n  VRadioGroup: {\n    color: 'primary',\n    hideDetails: 'auto',\n  },\n  VRadio: {\n    color: 'primary',\n    hideDetails: 'auto',\n  },\n  VSelect: {\n    variant: 'outlined',\n    color: 'primary',\n    hideDetails: 'auto',\n    menuProps: { elevation: 0 },\n  },\n  VRangeSlider: {\n    // set v-range-slider default color to primary\n    color: 'primary',\n    density: 'comfortable',\n    thumbLabel: true,\n    hideDetails: 'auto',\n  },\n  VRating: {\n    // set v-rating default color to primary\n    color: 'rgba(var(--v-theme-on-background),0.23)',\n    activeColor: 'warning',\n    halfIncrements: true,\n  },\n  VProgressCircular: {\n    // set v-progress-circular default color to primary\n    color: 'primary',\n  },\n  VSlider: {\n    // set v-slider default color to primary\n    color: 'primary',\n    hideDetails: 'auto',\n  },\n  VTextField: {\n    variant: 'outlined',\n    color: 'primary',\n    hideDetails: 'auto',\n  },\n  VAutocomplete: {\n    variant: 'outlined',\n    color: 'primary',\n    hideDetails: 'auto',\n  },\n  VCombobox: {\n    variant: 'outlined',\n    color: 'primary',\n    hideDetails: 'auto',\n    menuProps: { elevation: 0 },\n  },\n  VFileInput: {\n    variant: 'outlined',\n    color: 'primary',\n    hideDetails: 'auto',\n  },\n  VTextarea: {\n    variant: 'outlined',\n    color: 'primary',\n    hideDetails: 'auto',\n  },\n  VSwitch: {\n    // set v-switch default color to primary\n    color: 'primary',\n    hideDetails: 'auto',\n  },\n}\n"
  },
  {
    "path": "src/plugins/vuetify/icons.ts",
    "content": "import { Icon } from '@iconify/vue'\nimport { aliases } from 'vuetify/iconsets/mdi'\n\nconst alertTypeIcon = {\n  success: 'mdi-check-circle-outline',\n  info: 'mdi-information-outline',\n  warning: 'mdi-alert-outline',\n  error: 'mdi-alert-circle-outline',\n}\n\nconst modifiedAliases = Object.assign(aliases, alertTypeIcon)\n\nexport const iconify = {\n\n  component: (props: any) => h(Icon, props),\n}\n\nexport const icons = {\n  defaultSet: 'iconify',\n  mergedAliases: modifiedAliases,\n  sets: {\n    iconify,\n  },\n}\n"
  },
  {
    "path": "src/plugins/vuetify/index.ts",
    "content": "import { createVuetify } from 'vuetify'\nimport * as components from 'vuetify/components'\nimport { VBtn } from 'vuetify/components/VBtn'\nimport * as labsComponents from 'vuetify/labs/components'\nimport defaults from './defaults'\nimport { icons } from './icons'\nimport theme from './theme'\n\nexport default createVuetify({\n  aliases: {\n    IconBtn: VBtn,\n  },\n  defaults,\n  icons,\n  theme,\n  components: {\n    ...components,\n    ...labsComponents,\n  },\n})\n"
  },
  {
    "path": "src/plugins/vuetify/theme.ts",
    "content": "import type { VuetifyOptions } from 'vuetify'\n\nconst theme: VuetifyOptions['theme'] = {\n  defaultTheme: 'light',\n  themes: {\n    light: {\n      dark: false,\n      colors: {\n        'primary': '#9155FD',\n        'secondary': '#8A8D93',\n        'on-secondary': '#FFFFFF',\n        'success': '#56CA00',\n        'info': '#16B1FF',\n        'warning': '#FFB400',\n        'error': '#FF4C51',\n        'on-primary': '#FFFFFF',\n        'on-success': '#FFFFFF',\n        'on-warning': '#FFFFFF',\n        'background': '#F4F5FA',\n        'on-background': '#3A3541',\n        'on-surface': '#3A3541',\n        'grey-50': '#FAFAFA',\n        'grey-100': '#F0F2F8',\n        'grey-200': '#EEEEEE',\n        'grey-300': '#E0E0E0',\n        'grey-400': '#BDBDBD',\n        'grey-500': '#9E9E9E',\n        'grey-600': '#757575',\n        'grey-700': '#616161',\n        'grey-800': '#424242',\n        'grey-900': '#212121',\n        'perfect-scrollbar-thumb': '#DBDADE',\n        'skin-bordered-background': '#FFFFFF',\n        'skin-bordered-surface': '#FFFFFF',\n      },\n\n      variables: {\n        'code-color': '#D400FF',\n        'overlay-scrim-background': '#3A3541',\n        'overlay-scrim-opacity': 0.5,\n        'hover-opacity': 0.04,\n        'focus-opacity': 0.1,\n        'selected-opacity': 0.12,\n        'activated-opacity': 0.1,\n        'pressed-opacity': 0.14,\n        'dragged-opacity': 0.1,\n        'border-color': '#3A3541',\n        'table-header-background': '#F9FAFC',\n        'custom-background': '#F9F8F9',\n\n        // Shadows\n        'shadow-key-umbra-opacity': 'rgba(var(--v-theme-on-surface), 0.08)',\n        'shadow-key-penumbra-opacity': 'rgba(var(--v-theme-on-surface), 0.12)',\n        'shadow-key-ambient-opacity': 'rgba(var(--v-theme-on-surface), 0.04)',\n      },\n    },\n    dark: {\n      dark: true,\n      colors: {\n        'primary': '#6E66ED',\n        'secondary': '#8A8D93',\n        'on-secondary': '#FFFFFF',\n        'success': '#56CA00',\n        'info': '#16B1FF',\n        'warning': '#FFB400',\n        'error': '#FF4C51',\n        'on-primary': '#FFFFFF',\n        'on-success': '#FFFFFF',\n        'on-warning': '#FFFFFF',\n        'background': '#0E1116',\n        'on-background': '#E7E3FC',\n        'surface': '#14161F',\n        'on-surface': '#E7E3FC',\n        'grey-50': '#2A2E42',\n        'grey-100': '#474360',\n        'grey-200': '#4A5072',\n        'grey-300': '#5E6692',\n        'grey-400': '#7983BB',\n        'grey-500': '#8692D0',\n        'grey-600': '#AAB3DE',\n        'grey-700': '#B6BEE3',\n        'grey-800': '#CFD3EC',\n        'grey-900': '#E7E9F6',\n        'perfect-scrollbar-thumb': '#4A5072',\n        'skin-bordered-background': '#312d4b',\n        'skin-bordered-surface': '#312d4b',\n      },\n      variables: {\n        'code-color': '#d400ff',\n        'overlay-scrim-background': '#191D21',\n        'overlay-scrim-opacity': 0.6,\n        'hover-opacity': 0.04,\n        'focus-opacity': 0.1,\n        'selected-opacity': 0.12,\n        'activated-opacity': 0.1,\n        'pressed-opacity': 0.14,\n        'dragged-opacity': 0.1,\n        'border-color': '#E7E3FC',\n        'table-header-background': '#14161F',\n        'custom-background': '#373452',\n        // Shadows\n        'shadow-key-umbra-opacity': 'rgba(20, 18, 33, 0.08)',\n        'shadow-key-penumbra-opacity': 'rgba(20, 18, 33, 0.12)',\n        'shadow-key-ambient-opacity': 'rgba(20, 18, 33, 0.04)',\n      },\n    },\n    purple: {\n      dark: true,\n      colors: {\n        'primary': '#9155FD',\n        'secondary': '#8A8D93',\n        'on-secondary': '#FFFFFF',\n        'success': '#56CA00',\n        'info': '#16B1FF',\n        'warning': '#FFB400',\n        'error': '#FF4C51',\n        'on-primary': '#FFFFFF',\n        'on-success': '#FFFFFF',\n        'on-warning': '#FFFFFF',\n        'background': '#28243D',\n        'on-background': '#E7E3FC',\n        'surface': '#312D4B',\n        'on-surface': '#E7E3FC',\n        'grey-50': '#2A2E42',\n        'grey-100': '#474360',\n        'grey-200': '#4A5072',\n        'grey-300': '#5E6692',\n        'grey-400': '#7983BB',\n        'grey-500': '#8692D0',\n        'grey-600': '#AAB3DE',\n        'grey-700': '#B6BEE3',\n        'grey-800': '#CFD3EC',\n        'grey-900': '#E7E9F6',\n        'perfect-scrollbar-thumb': '#4A5072',\n        'skin-bordered-background': '#312d4b',\n        'skin-bordered-surface': '#312d4b',\n      },\n      variables: {\n        'code-color': '#d400ff',\n        'overlay-scrim-background': '#2C2942',\n        'overlay-scrim-opacity': 0.6,\n        'hover-opacity': 0.04,\n        'focus-opacity': 0.1,\n        'selected-opacity': 0.12,\n        'activated-opacity': 0.1,\n        'pressed-opacity': 0.14,\n        'dragged-opacity': 0.1,\n        'border-color': '#E7E3FC',\n        'table-header-background': '#3D3759',\n        'custom-background': '#373452',\n\n        // Shadows\n        'shadow-key-umbra-opacity': 'rgba(20, 18, 33, 0.08)',\n        'shadow-key-penumbra-opacity': 'rgba(20, 18, 33, 0.12)',\n        'shadow-key-ambient-opacity': 'rgba(20, 18, 33, 0.04)',\n      },\n    },\n    transparent: {\n      dark: true,\n      colors: {\n        'primary': '#A370F7',\n        'secondary': '#8A8D93',\n        'on-secondary': '#FFFFFF',\n        'success': '#66BB6A',\n        'info': '#42A5F5',\n        'warning': '#FFA726',\n        'error': '#EF5350',\n        'on-primary': '#FFFFFF',\n        'on-success': '#FFFFFF',\n        'on-warning': '#FFFFFF',\n        'background': '#1C1C1C',\n        'on-background': '#E7E3FC',\n        'surface': 'rgba(30, 30, 30, 0.3)',\n        'on-surface': '#E7E3FC',\n        'surface-variant': 'rgba(30, 30, 30, 0.2)',\n        'on-surface-variant': 'rgba(255, 255, 255, 0.65)',\n        'grey-50': 'rgba(42, 46, 66, 0.15)',\n        'grey-100': 'rgba(71, 67, 96, 0.15)',\n        'grey-200': 'rgba(74, 80, 114, 0.15)',\n        'grey-300': 'rgba(94, 102, 146, 0.15)',\n        'grey-400': 'rgba(121, 131, 187, 0.15)',\n        'grey-500': 'rgba(134, 146, 208, 0.15)',\n        'grey-600': 'rgba(170, 179, 222, 0.15)',\n        'grey-700': 'rgba(182, 190, 227, 0.15)',\n        'grey-800': 'rgba(207, 211, 236, 0.15)',\n        'grey-900': 'rgba(231, 233, 246, 0.15)',\n        'perfect-scrollbar-thumb': 'rgba(158, 158, 190, 0.4)',\n        'skin-bordered-background': 'rgba(30, 30, 30, 0.3)',\n        'skin-bordered-surface': 'rgba(30, 30, 30, 0.3)',\n        'card-background': 'rgba(30, 30, 30, 0.3)',\n      },\n      variables: {\n        'code-color': '#6D9EEB',\n        'overlay-scrim-background': '0, 0, 0',\n        'overlay-scrim-opacity': 0.7,\n        'hover-opacity': 0.1,\n        'focus-opacity': 0.15,\n        'selected-opacity': 0.2,\n        'activated-opacity': 0.15,\n        'pressed-opacity': 0.2,\n        'dragged-opacity': 0.15,\n        'border-color': '#E7E3FC',\n        'table-header-background': 'rgba(30, 30, 30, 0.3)',\n        'custom-background': 'rgba(30, 30, 30, 0.3)',\n        'card-background': 'rgba(30, 30, 30, 0.3)',\n\n        // Shadows\n        'shadow-key-umbra-opacity': 'rgba(0, 0, 0, 0.07)',\n        'shadow-key-penumbra-opacity': 'rgba(0, 0, 0, 0.1)',\n        'shadow-key-ambient-opacity': 'rgba(0, 0, 0, 0.05)',\n      },\n    },\n  },\n}\n\nexport default theme\n"
  },
  {
    "path": "src/plugins/webfontloader.ts",
    "content": "/**\n * plugins/webfontloader.js\n *\n * webfontloader documentation: https://github.com/typekit/webfontloader\n */\n\n;(async function loadFonts() {\n  const webFontLoader = await import(/* webpackChunkName: \"webfontloader\" */ 'webfontloader')\n\n  webFontLoader.load({\n    google: {\n      families: ['Inter:100,200,300,400,500,600,700&display=swap', 'Noto+Sans+SC:400,500,700&display=swap'],\n    },\n  })\n})()\n"
  },
  {
    "path": "src/router/i18n-menu.ts",
    "content": "import { useGlobalSettingsStore } from '@/stores'\nimport type { Composer } from 'vue-i18n'\n\n// 构建路由菜单，每次调用时使用当前的语言环境\nexport function getNavMenus(t: Composer['t']) {\n  const globalSettingsStore = useGlobalSettingsStore()\n\n  // 检查是否为高级模式\n  const isAdvancedMode = globalSettingsStore.get('ADVANCED_MODE') !== false\n\n  return [\n    {\n      title: t('navItems.dashboard'),\n      icon: 'mdi-home-outline',\n      to: '/dashboard',\n      header: t('menu.start'),\n      admin: false,\n      footer: true,\n      permission: 'manage',\n    },\n    {\n      title: t('navItems.searchResult'),\n      icon: 'mdi-magnify',\n      to: '/resource',\n      header: t('menu.start'),\n      admin: false,\n      permission: 'search',\n    },\n    {\n      title: t('navItems.recommend'),\n      icon: 'mdi-star-outline',\n      to: '/recommend',\n      header: t('menu.discovery'),\n      admin: false,\n      footer: true,\n      permission: 'discovery',\n    },\n    {\n      title: t('navItems.explore'),\n      icon: 'mdi-apple-safari',\n      to: '/discover',\n      header: t('menu.discovery'),\n      admin: false,\n      footer: true,\n      permission: 'discovery',\n    },\n    {\n      title: t('navItems.movie'),\n      full_title: t('navItems.movieSubscribe'),\n      icon: 'mdi-movie-open-outline',\n      to: '/subscribe/movie',\n      header: t('menu.subscribe'),\n      admin: false,\n      footer: false,\n      permission: 'subscribe',\n    },\n    {\n      title: t('navItems.tv'),\n      full_title: t('navItems.tvSubscribe'),\n      icon: 'mdi-television',\n      to: '/subscribe/tv',\n      header: t('menu.subscribe'),\n      admin: false,\n      footer: false,\n      permission: 'subscribe',\n    },\n    {\n      title: t('navItems.workflow'),\n      full_title: t('navItems.workflow'),\n      icon: 'mdi-state-machine',\n      to: '/workflow',\n      header: t('menu.subscribe'),\n      admin: true,\n      footer: false,\n      permission: 'manage',\n    },\n    {\n      title: t('navItems.calendar'),\n      full_title: t('navItems.calendar'),\n      icon: 'mdi-calendar',\n      to: '/calendar',\n      header: t('menu.subscribe'),\n      admin: false,\n      permission: 'subscribe',\n    },\n    {\n      title: t('navItems.downloadManager'),\n      icon: 'mdi-download-outline',\n      to: '/downloading',\n      header: t('menu.organize'),\n      admin: false,\n      permission: 'manage',\n    },\n    {\n      title: t('navItems.mediaOrganize'),\n      icon: 'mdi-folder-play-outline',\n      to: '/history',\n      header: t('menu.organize'),\n      admin: true,\n      permission: 'manage',\n    },\n    {\n      title: t('navItems.fileManager'),\n      icon: 'mdi-folder-multiple-outline',\n      to: '/filemanager',\n      header: t('menu.organize'),\n      admin: true,\n      permission: 'manage',\n    },\n    {\n      title: t('navItems.pluginManager'),\n      icon: 'mdi-apps',\n      to: '/plugins',\n      header: t('menu.system'),\n      admin: true,\n      permission: 'manage',\n    },\n    {\n      title: t('navItems.siteManager'),\n      icon: 'mdi-web',\n      to: '/site',\n      header: t('menu.system'),\n      admin: true,\n      permission: 'manage',\n    },\n    {\n      title: t('navItems.userManager'),\n      icon: 'mdi-account-group-outline',\n      to: '/user',\n      header: t('menu.system'),\n      admin: true,\n      permission: 'admin',\n    },\n    ...(isAdvancedMode\n      ? [\n          {\n            title: t('navItems.settings'),\n            icon: 'mdi-cog-outline',\n            to: '/setting',\n            header: t('menu.system'),\n            admin: true,\n            permission: 'admin',\n          },\n        ]\n      : []),\n  ]\n}\n\n// 获取设置标签页\nexport function getSettingTabs(t: Composer['t']) {\n  return [\n    {\n      title: t('settingTabs.system.title'),\n      icon: 'mdi-server-network',\n      tab: 'system',\n      description: t('settingTabs.system.description'),\n    },\n    {\n      title: t('settingTabs.directory.title'),\n      icon: 'mdi-folder',\n      tab: 'directory',\n      description: t('settingTabs.directory.description'),\n    },\n    {\n      title: t('settingTabs.site.title'),\n      icon: 'mdi-web',\n      tab: 'site',\n      description: t('settingTabs.site.description'),\n    },\n    {\n      title: t('settingTabs.rule.title'),\n      icon: 'mdi-filter',\n      tab: 'rule',\n      description: t('settingTabs.rule.description'),\n    },\n    {\n      title: t('settingTabs.search.title'),\n      icon: 'mdi-magnify',\n      tab: 'search',\n      description: t('settingTabs.search.description'),\n    },\n    {\n      title: t('settingTabs.subscribe.title'),\n      icon: 'mdi-rss',\n      tab: 'subscribe',\n      description: t('settingTabs.subscribe.description'),\n    },\n    {\n      title: t('settingTabs.notification.title'),\n      icon: 'mdi-bell',\n      tab: 'notification',\n      description: t('settingTabs.notification.description'),\n    },\n  ]\n}\n\n// 获取电影订阅标签页\nexport function getSubscribeMovieTabs(t: Composer['t']) {\n  return [\n    {\n      title: t('subscribeTabs.movie.mysub'),\n      tab: 'mysub',\n      icon: 'mdi-bell-check',\n    },\n    {\n      title: t('subscribeTabs.movie.popular'),\n      tab: 'popular',\n      icon: 'mdi-fire',\n    },\n  ]\n}\n\n// 获取电视剧订阅标签页\nexport function getSubscribeTvTabs(t: Composer['t']) {\n  return [\n    {\n      title: t('subscribeTabs.tv.mysub'),\n      tab: 'mysub',\n      icon: 'mdi-bell-check',\n    },\n    {\n      title: t('subscribeTabs.tv.popular'),\n      tab: 'popular',\n      icon: 'mdi-fire',\n    },\n    {\n      title: t('subscribeTabs.tv.share'),\n      tab: 'share',\n      icon: 'mdi-share-variant',\n    },\n  ]\n}\n\n// 获取插件标签页\nexport function getPluginTabs(t: Composer['t']) {\n  return [\n    {\n      title: t('pluginTabs.installed'),\n      tab: 'installed',\n      icon: 'mdi-apps',\n    },\n    {\n      title: t('pluginTabs.market'),\n      tab: 'market',\n      icon: 'mdi-shopping',\n    },\n  ]\n}\n\n// 获取发现标签页\nexport function getDiscoverTabs(t: Composer['t']) {\n  return [\n    {\n      name: t('discoverTabs.themoviedb'),\n      tab: 'themoviedb',\n      icon: 'themoviedb',\n    },\n    {\n      name: t('discoverTabs.douban'),\n      tab: 'douban',\n      icon: 'douban',\n    },\n    {\n      name: t('discoverTabs.bangumi'),\n      tab: 'bangumi',\n      icon: 'bangumi',\n    },\n  ]\n}\n\n// 获取工作流标签页\nexport function getWorkflowTabs(t: Composer['t']) {\n  return [\n    {\n      title: t('workflowTabs.list'),\n      tab: 'list',\n      icon: 'mdi-workflow-outline',\n    },\n    {\n      title: t('workflowTabs.share'),\n      tab: 'share',\n      icon: 'mdi-share-variant',\n    },\n  ]\n}\n\n/** 插件侧栏分组（与后端 get_sidebar_nav 的 section 一致） */\nexport type PluginSidebarSection = 'start' | 'discovery' | 'subscribe' | 'organize' | 'system'\n\n/**\n * 将插件声明的 section 映射为与 getNavMenus 一致的已翻译 header（用于 NavMenu.header）\n */\nexport function pluginSidebarSectionToHeaderKey(section: string, t: Composer['t']): string {\n  const map: Record<string, string> = {\n    start: 'menu.start',\n    discovery: 'menu.discovery',\n    subscribe: 'menu.subscribe',\n    organize: 'menu.organize',\n    system: 'menu.system',\n  }\n  return t(map[section] ?? 'menu.system')\n}\n"
  },
  {
    "path": "src/router/index.ts",
    "content": "import { createRouter, createWebHashHistory } from 'vue-router'\nimport { configureNProgress } from '@/api/nprogress'\nimport { useAuthStore } from '@/stores'\nimport { setNavigatingState as setRequestNavigatingState } from '@/utils/requestOptimizer'\n\n// Nprogress\nconfigureNProgress()\n\n// Router\nconst router = createRouter({\n  history: createWebHashHistory(import.meta.env.BASE_URL),\n  scrollBehavior(to: any, from: any, savedPosition: any) {\n    // 如果页面有缓存那么恢复其位置, 否则始终滚动到顶部\n    if (to.meta.keepAlive && savedPosition) return savedPosition\n    return { top: 0 }\n  },\n  routes: [\n    { path: '/', redirect: '/dashboard' },\n    {\n      path: '/',\n      component: () => import('../layouts/default.vue'),\n      children: [\n        {\n          path: '/dashboard',\n          component: () => import('../pages/dashboard.vue'),\n          meta: {\n            keepAlive: true,\n            requiresAuth: true,\n          },\n        },\n        {\n          path: '/recommend',\n          component: () => import('../pages/recommend.vue'),\n          meta: {\n            keepAlive: true,\n            requiresAuth: true,\n          },\n        },\n        {\n          path: '/discover',\n          component: () => import('../pages/discover.vue'),\n          meta: {\n            keepAlive: true,\n            requiresAuth: true,\n          },\n        },\n        {\n          path: '/resource',\n          component: () => import('../pages/resource.vue'),\n          meta: {\n            requiresAuth: true,\n          },\n        },\n        {\n          path: '/subscribe/movie',\n          component: () => import('../pages/subscribe.vue'),\n          meta: {\n            keepAlive: true,\n            requiresAuth: true,\n            subType: '电影',\n          },\n        },\n        {\n          path: '/subscribe/tv',\n          component: () => import('../pages/subscribe.vue'),\n          meta: {\n            keepAlive: true,\n            requiresAuth: true,\n            subType: '电视剧',\n          },\n        },\n        {\n          path: '/subscribe-share',\n          component: () => import('../pages/subscribe-share.vue'),\n          meta: {\n            keepAlive: true,\n            requiresAuth: true,\n          },\n        },\n        {\n          path: '/workflow',\n          component: () => import('../pages/workflow.vue'),\n          meta: {\n            keepAlive: true,\n            requiresAuth: true,\n          },\n        },\n        {\n          path: '/calendar',\n          component: () => import('../pages/calendar.vue'),\n          meta: {\n            keepAlive: true,\n            requiresAuth: true,\n          },\n        },\n        {\n          path: '/downloading',\n          component: () => import('../pages/downloading.vue'),\n          meta: {\n            requiresAuth: true,\n          },\n        },\n        {\n          path: '/history',\n          component: () => import('../pages/history.vue'),\n          meta: {\n            requiresAuth: true,\n            hideFooter: true,\n          },\n        },\n        {\n          path: '/site',\n          component: () => import('../pages/site.vue'),\n          meta: {\n            keepAlive: true,\n            requiresAuth: true,\n          },\n        },\n        {\n          path: '/user',\n          component: () => import('../pages/user.vue'),\n          meta: {\n            keepAlive: true,\n            requiresAuth: true,\n          },\n        },\n        {\n          path: '/profile',\n          component: () => import('../pages/profile.vue'),\n          meta: {\n            keepAlive: true,\n            requiresAuth: true,\n          },\n        },\n        {\n          path: '/plugins',\n          component: () => import('../pages/plugin.vue'),\n          meta: {\n            keepAlive: true,\n            requiresAuth: true,\n          },\n        },\n        {\n          path: '/plugin-app/:pluginId/:navKey?',\n          name: 'plugin-app',\n          component: () => import('../pages/plugin-app.vue'),\n          meta: {\n            keepAlive: true,\n            requiresAuth: true,\n          },\n        },\n        {\n          path: '/setting',\n          component: () => import('../pages/setting.vue'),\n          meta: {\n            requiresAuth: true,\n          },\n        },\n        {\n          path: '/browse/:paths+',\n          component: () => import('../pages/browse.vue'),\n          props: true,\n          meta: {\n            keepAlive: true,\n            requiresAuth: true,\n          },\n        },\n        {\n          path: '/credits/:paths+',\n          component: () => import('../pages/credits.vue'),\n          props: true,\n          meta: {\n            keepAlive: true,\n            requiresAuth: true,\n          },\n        },\n        {\n          path: '/person',\n          component: () => import('../pages/person.vue'),\n          props: true,\n          meta: {\n            keepAlive: true,\n            requiresAuth: true,\n          },\n        },\n        {\n          path: '/media',\n          component: () => import('../pages/media.vue'),\n          meta: {\n            keepAlive: true,\n            requiresAuth: true,\n          },\n        },\n        {\n          path: '/filemanager',\n          component: () => import('../pages/filemanager.vue'),\n          meta: {\n            keepAlive: true,\n            requiresAuth: true,\n            hideFooter: true,\n          },\n        },\n        {\n          path: '/apps',\n          component: () => import('../pages/appcenter.vue'),\n          meta: {\n            requiresAuth: true,\n          },\n        },\n      ],\n    },\n    {\n      path: '/',\n      component: () => import('../layouts/blank.vue'),\n      children: [\n        {\n          path: 'login',\n          component: () => import('../pages/login.vue'),\n        },\n        {\n          path: 'setup-wizard',\n          component: () => import('../pages/setup.vue'),\n          meta: {\n            requiresAuth: true,\n          },\n        },\n        {\n          path: '/:pathMatch(.*)*',\n          component: () => import('../pages/[...all].vue'),\n        },\n      ],\n    },\n  ],\n})\n\n// 路由导航守卫\nrouter.beforeEach(async (to: any, from: any, next: any) => {\n  // 设置导航状态 - 同时中断API请求\n  setRequestNavigatingState(true)\n\n  // 认证 Store\n  const authStore = useAuthStore()\n  // 总是记录非login路由\n  if (to.fullPath != '/login') authStore.originalPath = to.fullPath\n  const isAuthenticated = authStore.token !== null\n\n  if (to.meta.requiresAuth && !isAuthenticated) {\n    // 用户未登录，重定向到登录页\n    setRequestNavigatingState(false)\n    next('/login')\n  } else {\n    next()\n  }\n})\n\n// 路由导航完成后\nrouter.afterEach(() => {\n  setTimeout(() => {\n    setRequestNavigatingState(false)\n  }, 100)\n})\n\n// 导出默认对象\nexport default router\n"
  },
  {
    "path": "src/service-worker.ts",
    "content": "import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'\nimport { registerRoute, setCatchHandler } from 'workbox-routing'\nimport { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies'\nimport { ExpirationPlugin } from 'workbox-expiration'\nimport { CacheableResponsePlugin } from 'workbox-cacheable-response'\nimport * as navigationPreload from 'workbox-navigation-preload'\n\n// Service Worker 类型声明\ndeclare let self: ServiceWorkerGlobalScope & {\n  readonly __WB_MANIFEST: Array<{ url: string; revision?: string }>\n}\n\n// 缓存版本控制\nconst RESOURCE_VERSION = 'V2'\n// 开发态 dev-sw 可能拿不到 Vite define 注入；仅在开发环境做 dev 兜底\nconst hasAppVersion = typeof __APP_VERSION__ !== 'undefined'\nconst hasBuildTime = typeof __BUILD_TIME__ !== 'undefined'\nconst isDev = import.meta.env.DEV\n\nif (!isDev && (!hasAppVersion || !hasBuildTime)) {\n  throw new Error('[SW] Missing __APP_VERSION__ or __BUILD_TIME__ in production build')\n}\n\nconst appVersion = hasAppVersion ? __APP_VERSION__ : 'dev'\nconst buildTime = hasBuildTime ? __BUILD_TIME__ : 'dev'\nconst CACHE_VERSION = `${appVersion}-${buildTime}`\n\n// 启用导航预载\nnavigationPreload.enable()\n\n// 自动清理旧的预缓存\ncleanupOutdatedCaches()\n\n// 预缓存并路由\nprecacheAndRoute(self.__WB_MANIFEST)\n\n// 监听安装事件\nself.addEventListener('install', () => {\n  // 强制等待中的 Service Worker 立即激活\n  self.skipWaiting()\n})\n\n// 监听激活事件\nself.addEventListener('activate', event => {\n  // 让 Service Worker 立即接管页面\n  event.waitUntil(\n    (async () => {\n      await self.clients.claim()\n      // 清理旧版本的运行时缓存\n      await cleanupRuntimeCaches(true)\n    })(),\n  )\n})\n\n// 通知选项\nconst options = {\n  icon: '/logo.png',\n  vibrate: [100, 50, 100],\n  actions: [{ action: 'close', title: '关闭' }],\n}\n\n// 存储未读消息数量的键名\nconst UNREAD_COUNT_KEY = 'mp_unread_count'\n\n// --- 缓存策略配置 ---\n\n// 导航请求与 App Shell - 优先网络\nregisterRoute(\n  ({ request, url }) => request.mode === 'navigate' || url.pathname === '/' || url.pathname === '/index.html',\n  new NetworkFirst({\n    cacheName: `app-shell-${CACHE_VERSION}`,\n    plugins: [\n      new ExpirationPlugin({\n        maxEntries: 10,\n        maxAgeSeconds: 7 * 24 * 60 * 60, // 7天\n      }),\n    ],\n  }),\n)\n\n// 静态资源 (JS, CSS, HTML) - 优先缓存\nregisterRoute(\n  ({ request }) => ['style', 'script', 'worker'].includes(request.destination),\n  new StaleWhileRevalidate({\n    cacheName: `static-resources-${CACHE_VERSION}`,\n    plugins: [\n      new CacheableResponsePlugin({\n        statuses: [0, 200],\n      }),\n    ],\n  }),\n)\n\n// 图片资源 - 优先缓存\nregisterRoute(\n  ({ request }) => request.destination === 'image',\n  new CacheFirst({\n    cacheName: `image-cache-${RESOURCE_VERSION}`,\n    plugins: [\n      new CacheableResponsePlugin({\n        statuses: [0, 200],\n      }),\n      new ExpirationPlugin({\n        maxEntries: 200,\n        maxAgeSeconds: 30 * 24 * 60 * 60, // 30天\n      }),\n    ],\n  }),\n)\n\n// 字体资源 - 优先缓存\nregisterRoute(\n  ({ request }) => request.destination === 'font',\n  new CacheFirst({\n    cacheName: `font-cache-${RESOURCE_VERSION}`,\n    plugins: [\n      new CacheableResponsePlugin({\n        statuses: [0, 200],\n      }),\n      new ExpirationPlugin({\n        maxEntries: 50,\n        maxAgeSeconds: 365 * 24 * 60 * 60, // 1年\n      }),\n    ],\n  }),\n)\n\n// TMDB 图片 - 优先缓存\nregisterRoute(\n  ({ url }) => url.hostname === 'image.tmdb.org',\n  new CacheFirst({\n    cacheName: `tmdb-image-cache-${RESOURCE_VERSION}`,\n    plugins: [\n      new CacheableResponsePlugin({\n        statuses: [0, 200],\n      }),\n      new ExpirationPlugin({\n        maxEntries: 300,\n        maxAgeSeconds: 7 * 24 * 60 * 60, // 7天\n      }),\n    ],\n  }),\n)\n\n// API GET 请求 - 优先网络\nregisterRoute(\n  ({ url, request }) =>\n    url.pathname.includes('/api/v1/') &&\n    request.method === 'GET' &&\n    !url.pathname.includes('/api/v1/system/message') && // SSE实时消息流\n    !url.pathname.includes('/api/v1/system/progress/') && // SSE实时进度流\n    !url.pathname.includes('/api/v1/system/logging') && // SSE实时日志流\n    !url.pathname.includes('/api/v1/message/') && // 用户消息接口\n    !url.pathname.includes('/api/v1/system/global') && // 系统配置接口\n    !url.pathname.includes('/api/v1/mfa/') && // 多因素认证接口\n    !url.pathname.includes('/api/v1/dashboard/'), // Dashboard实时监控数据\n  new NetworkFirst({\n    cacheName: `api-cache-${CACHE_VERSION}`,\n    networkTimeoutSeconds: 5,\n    plugins: [\n      new CacheableResponsePlugin({\n        statuses: [0, 200],\n      }),\n      new ExpirationPlugin({\n        maxEntries: 500,\n        maxAgeSeconds: 24 * 60 * 60, // 24小时\n      }),\n    ],\n  }),\n)\n\n// 设置默认离线页面\nsetCatchHandler(async ({ request }) => {\n  if (request?.destination === 'document') {\n    return (await caches.match('/offline.html')) || Response.error()\n  }\n  return Response.error()\n})\n\n// --- 辅助函数 (通知与徽章) ---\n\n// 清理运行时缓存\nasync function cleanupRuntimeCaches(onlyOld: boolean = false) {\n  const cacheNames = await caches.keys()\n  const runtimeCachePrefixes = [\n    'app-shell',\n    'static-resources',\n    'image-cache',\n    'font-cache',\n    'api-cache',\n    'tmdb-image-cache',\n  ]\n\n  // 当前版本的缓存全名\n  const currentCacheNames = [\n    `app-shell-${CACHE_VERSION}`,\n    `static-resources-${CACHE_VERSION}`,\n    `image-cache-${RESOURCE_VERSION}`,\n    `font-cache-${RESOURCE_VERSION}`,\n    `tmdb-image-cache-${RESOURCE_VERSION}`,\n    `api-cache-${CACHE_VERSION}`,\n  ]\n\n  await Promise.all(\n    cacheNames.map(cacheName => {\n      const isRuntimeCache = runtimeCachePrefixes.some(prefix => cacheName.startsWith(prefix))\n      if (isRuntimeCache) {\n        if (!onlyOld || !currentCacheNames.includes(cacheName)) {\n          console.log('[SW] Deleting runtime cache:', cacheName)\n          return caches.delete(cacheName)\n        }\n      }\n      return Promise.resolve()\n    }),\n  )\n}\n\n// 简单的 IndexedDB 包装器 (用于未读计数)\nasync function openDB(): Promise<IDBDatabase> {\n  return new Promise((resolve, reject) => {\n    const request = indexedDB.open('mp_badge_db', 2)\n    request.onerror = () => reject(request.error)\n    request.onsuccess = () => resolve(request.result)\n    request.onupgradeneeded = event => {\n      const db = (event.target as IDBOpenDBRequest).result\n      if (!db.objectStoreNames.contains('badge')) {\n        db.createObjectStore('badge')\n      }\n    }\n  })\n}\n\nasync function get(key: string, storeName: string = 'badge'): Promise<any> {\n  try {\n    const db = await openDB()\n    return new Promise((resolve, reject) => {\n      if (!db.objectStoreNames.contains(storeName)) {\n        resolve(null)\n        return\n      }\n      const tx = db.transaction([storeName], 'readonly')\n      const store = tx.objectStore(storeName)\n      const request = store.get(key)\n      request.onerror = () => reject(request.error)\n      request.onsuccess = () => resolve(request.result)\n    })\n  } catch (e) {\n    return null\n  }\n}\n\nasync function set(key: string, value: any, storeName: string = 'badge'): Promise<void> {\n  try {\n    const db = await openDB()\n    return new Promise((resolve, reject) => {\n      if (!db.objectStoreNames.contains(storeName)) {\n        console.warn(`Store ${storeName} not found`)\n        resolve()\n        return\n      }\n      const tx = db.transaction([storeName], 'readwrite')\n      const store = tx.objectStore(storeName)\n      store.put(value, key)\n      tx.oncomplete = () => resolve()\n      tx.onerror = () => reject(tx.error)\n    })\n  } catch (e) {\n    console.error(`[SW] Failed to set IndexedDB key \"${key}\" in store \"${storeName}\":`, e)\n  }\n}\n\nasync function getStoredUnreadCount(): Promise<number> {\n  const count = await get(UNREAD_COUNT_KEY)\n  return typeof count === 'number' ? count : 0\n}\n\nasync function setStoredUnreadCount(count: number): Promise<void> {\n  await set(UNREAD_COUNT_KEY, count)\n}\n\nasync function updateBadge(count: number) {\n  if ('setAppBadge' in self.navigator) {\n    try {\n      if (count > 0) {\n        await self.navigator.setAppBadge(count)\n      } else {\n        await self.navigator.clearAppBadge()\n      }\n    } catch (error) {\n      console.error('Failed to update app badge:', error)\n    }\n  }\n}\n\nasync function clearBadge() {\n  if ('clearAppBadge' in self.navigator) {\n    try {\n      await self.navigator.clearAppBadge()\n      await setStoredUnreadCount(0)\n    } catch (error) {\n      console.error('Failed to clear app badge:', error)\n    }\n  }\n}\n\n// 监控缓存大小\nasync function monitorCacheSize() {\n  const cacheSizes: Record<string, number> = {}\n  let calculatedTotalSize = 0\n\n  try {\n    const cacheNames = await caches.keys()\n\n    // 并行处理所有缓存\n    await Promise.all(\n      cacheNames.map(async cacheName => {\n        const cache = await caches.open(cacheName)\n        const requests = await cache.keys()\n        let cacheSize = 0\n\n        // 遍历请求以获取响应头部，避免 matchAll 一次性加载大量响应对象到内存\n        for (const request of requests) {\n          const response = await cache.match(request)\n          if (response) {\n            const contentLength = response.headers.get('content-length')\n            if (contentLength) {\n              cacheSize += parseInt(contentLength, 10)\n            }\n          }\n        }\n        cacheSizes[cacheName] = cacheSize\n      }),\n    )\n\n    calculatedTotalSize = Object.values(cacheSizes).reduce((acc, size) => acc + size, 0)\n\n    // 获取系统级存储估算\n    let quota = 0\n    let usage = 0\n    if (self.navigator.storage && self.navigator.storage.estimate) {\n      const estimate = await self.navigator.storage.estimate()\n      quota = estimate.quota || 0\n      usage = estimate.usage || 0\n    }\n\n    // 构造结果：满足 useCacheManager.ts 的需求\n    const result = {\n      cacheSizes,\n      // 优先使用准确的 usage (真实磁盘占用)，如果不可用则退回到计算值\n      totalSize: usage || calculatedTotalSize,\n      totalSizeMB: ((usage || calculatedTotalSize) / 1024 / 1024).toFixed(2),\n      // 额外信息保留，供未来扩展\n      quota,\n      usage,\n      quotaMB: (quota / 1024 / 1024).toFixed(2),\n      usageMB: (usage / 1024 / 1024).toFixed(2),\n      calculatedTotalSize,\n    }\n\n    // 发送缓存统计信息给客户端\n    const clients = await self.clients.matchAll()\n    clients.forEach(client => {\n      client.postMessage({\n        type: 'CACHE_SIZE_UPDATE',\n        data: result,\n      })\n    })\n\n    return result\n  } catch (error) {\n    console.error('Failed to monitor cache size:', error)\n    return {\n      cacheSizes: {},\n      totalSize: 0,\n      totalSizeMB: '0.00',\n      quota: 0,\n      usage: 0,\n      quotaMB: '0.00',\n      usageMB: '0.00',\n    }\n  }\n}\n\n// --- 事件监听 ---\n\n// 监听 push 事件\nself.addEventListener('push', function (event) {\n  if (!event.data) {\n    return\n  }\n  let payload\n  try {\n    payload = event.data?.json()\n  } catch (err) {\n    payload = {\n      title: event.data?.text(),\n    }\n  }\n\n  try {\n    const content = {\n      body: payload.body || '',\n      icon: payload.icon || options.icon,\n      vibrate: [100, 50, 100],\n      data: { url: payload.url },\n      actions: options.actions,\n    }\n\n    event.waitUntil(\n      (async () => {\n        const currentCount = await getStoredUnreadCount()\n        const newCount = currentCount + 1\n        await setStoredUnreadCount(newCount)\n        await Promise.all([self.registration.showNotification(payload.title, content), updateBadge(newCount)])\n      })(),\n    )\n  } catch (e) {\n    // 忽略错误\n  }\n})\n\n// 监听通知点击\nself.addEventListener('notificationclick', function (event) {\n  const info = event.notification\n  if (event.action === 'close') {\n    info.close()\n  } else if (info.data?.url) {\n    event.waitUntil(self.clients.openWindow(info.data?.url))\n  }\n})\n\n// 监听消息\nself.addEventListener('message', function (event) {\n  if (event.data && event.data.type === 'CLEAR_BADGE') {\n    clearBadge()\n      .then(() => {\n        event.ports[0]?.postMessage({ success: true })\n      })\n      .catch(error => {\n        event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) })\n      })\n  } else if (event.data && event.data.type === 'UPDATE_BADGE') {\n    const count = event.data.count || 0\n    setStoredUnreadCount(count)\n      .then(() => updateBadge(count))\n      .then(() => {\n        event.ports[0]?.postMessage({ success: true })\n      })\n      .catch(error => {\n        event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) })\n      })\n  } else if (event.data && event.data.type === 'GET_UNREAD_COUNT') {\n    getStoredUnreadCount()\n      .then(count => {\n        event.ports[0]?.postMessage({ count })\n      })\n      .catch(() => {\n        event.ports[0]?.postMessage({ count: 0 })\n      })\n  } else if (event.data && event.data.type === 'CLEANUP_CACHES') {\n    // 手动清理: 清理所有运行时缓存\n    const performCleanup = async () => {\n      await cleanupRuntimeCaches(false)\n      return await monitorCacheSize()\n    }\n    performCleanup()\n      .then(cacheInfo => {\n        event.ports[0]?.postMessage({ success: true, cacheInfo })\n      })\n      .catch(error => {\n        event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) })\n      })\n  } else if (event.data && event.data.type === 'GET_CACHE_INFO') {\n    monitorCacheSize()\n      .then(cacheInfo => {\n        event.ports[0]?.postMessage({ success: true, cacheInfo })\n      })\n      .catch(error => {\n        event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) })\n      })\n  } else if (event.data && event.data.type === 'SKIP_WAITING') {\n    self.skipWaiting()\n  }\n})\n"
  },
  {
    "path": "src/stores/auth.ts",
    "content": "import { defineStore } from 'pinia'\nimport type { authState } from '@/stores/types'\nimport { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'\n\nexport const useAuthStore = defineStore('auth', {\n  state: (): authState => ({\n    token: null,\n    remember: false,\n    originalPath: null,\n  }),\n\n  // 全局持久化\n  persist: true,\n\n  actions: {\n    setToken(token: string | null) {\n      this.token = token\n    },\n    clearToken() {\n      this.token = null\n    },\n    setRemember(remember: boolean) {\n      this.remember = remember\n    },\n    setOriginalPath(originalPath: string | null) {\n      this.originalPath = originalPath\n    },\n    login(payload: authState) {\n      this.setToken(payload.token)\n      this.setRemember(payload.remember)\n    },\n    logout() {\n      this.clearToken()\n      this.setOriginalPath(null)\n      usePluginSidebarNavStore().reset()\n    },\n  },\n\n  getters: {\n    getToken: state => state.token,\n    getRemember: state => state.remember,\n    getOriginalPath: state => state.originalPath,\n  },\n})\n"
  },
  {
    "path": "src/stores/global.ts",
    "content": "import { defineStore } from 'pinia'\nimport type { globalSettingsState } from '@/stores/types'\nimport { fetchGlobalSettings } from '@/utils/globalSetting'\nimport { useVersionChecker } from '@/composables/useVersionChecker'\nimport api from '@/api'\n\nexport const useGlobalSettingsStore = defineStore('globalSettings', {\n  state: (): globalSettingsState => ({\n    data: {},\n    initialized: false,\n    loading: false,\n  }),\n\n  actions: {\n    async initialize() {\n      if (this.initialized || this.loading) return\n\n      this.loading = true\n      try {\n        const result = await fetchGlobalSettings()\n        this.data = result || {}\n        this.initialized = true\n\n        // 检查版本更新\n        if (result.FRONTEND_VERSION) {\n          const isBackendDev = Boolean(result.BACKEND_DEV)\n          const skipVersionCheck = import.meta.env.DEV || isBackendDev\n\n          if (skipVersionCheck) {\n            console.log('[VersionChecker] 开发环境下跳过版本一致性检查')\n            return\n          }\n\n          const { checkVersion } = useVersionChecker()\n          await checkVersion(result.FRONTEND_VERSION)\n        }\n      } catch (error) {\n        console.error('Failed to initialize global settings', error)\n      } finally {\n        this.loading = false\n      }\n    },\n\n    // 登录后加载用户相关设置\n    async loadUserSettings() {\n      try {\n        const result: { [key: string]: any } = await api.get('system/global/user')\n        if (result.success && result.data) {\n          // 合并用户设置到现有数据\n          this.data = { ...this.data, ...result.data }\n        }\n      } catch (error) {\n        console.error('Failed to load user settings', error)\n      }\n    },\n\n    setData(data: { [key: string]: any }) {\n      this.data = data\n      this.initialized = true\n    },\n\n    get(key: string) {\n      return this.data[key]\n    },\n\n    reset() {\n      this.data = {}\n      this.initialized = false\n      this.loading = false\n    },\n  },\n\n  getters: {\n    isInitialized: state => state.initialized,\n    isLoading: state => state.loading,\n    getData: state => state.data,\n    globalSettings: state => state.data,\n  },\n})\n"
  },
  {
    "path": "src/stores/index.ts",
    "content": "import { createPinia } from 'pinia'\nimport piniaPluginPersistedstate from 'pinia-plugin-persistedstate'\n\n// 创建 Pinia 实例\nconst pinia = createPinia()\n\n// 使用持久化插件\npinia.use(piniaPluginPersistedstate)\n\nexport default pinia\n\n// 所有的 store\nimport { useAuthStore } from './auth'\nimport { useUserStore } from './user'\nimport { useGlobalSettingsStore } from './global'\nimport { usePluginSidebarNavStore } from './pluginSidebarNav'\n\nexport { useAuthStore, useUserStore, useGlobalSettingsStore, usePluginSidebarNavStore }\n"
  },
  {
    "path": "src/stores/pluginSidebarNav.ts",
    "content": "import { defineStore } from 'pinia'\nimport api from '@/api'\nimport type { PluginSidebarNavItem } from '@/api/types'\n\n/**\n * 缓存 GET plugin/sidebar_nav 结果，供 DefaultLayout 与 appcenter 等共用，避免重复请求。\n */\nexport const usePluginSidebarNavStore = defineStore('pluginSidebarNav', {\n  state: () => ({\n    items: [] as PluginSidebarNavItem[],\n    /** 是否已成功拉取过一次（含空数组） */\n    loaded: false,\n    /** 并发去重：同一时刻只进行一次请求 */\n    inflight: null as Promise<void> | null,\n  }),\n\n  actions: {\n    /**\n     * 确保侧栏导航数据已加载；已缓存则直接返回，并发调用共享同一请求。\n     * @param force 为 true 时忽略缓存重新请求（如登出后再登录可配合 reset + ensure）\n     */\n    async ensureSidebarNav(force = false): Promise<void> {\n      if (!force && this.loaded) {\n        return\n      }\n      if (this.inflight) {\n        return this.inflight\n      }\n      this.inflight = (async () => {\n        try {\n          const res = await api.get('plugin/sidebar_nav')\n          this.items = Array.isArray(res) ? res : []\n        } catch {\n          this.items = []\n        } finally {\n          this.loaded = true\n          this.inflight = null\n        }\n      })()\n      return this.inflight\n    },\n\n    reset() {\n      this.items = []\n      this.loaded = false\n      this.inflight = null\n    },\n  },\n})\n"
  },
  {
    "path": "src/stores/types.ts",
    "content": "export interface authState {\n  // 用户令牌\n  token: string | null\n  // 记住我\n  remember: boolean\n  // 原始路径\n  originalPath?: string | null\n}\n\nexport interface userState {\n  // 是否属于超级管理员\n  superUser: boolean\n  // 用户ID\n  userID: number\n  // 用户名\n  userName: string\n  // 头像\n  avatar: string\n  // 用户认证等级 1-未认证 2-已认证\n  level: number\n  // 权限\n  permissions: { [key: string]: any }\n  // 是否需要显示设置向导\n  wizard: boolean\n}\n\nexport interface globalSettingsState {\n  // 全局设置数据\n  data: { [key: string]: any }\n  // 是否已初始化\n  initialized: boolean\n  // 是否正在加载\n  loading: boolean\n}\n"
  },
  {
    "path": "src/stores/user.ts",
    "content": "import { defineStore } from 'pinia'\nimport type { userState } from '@/stores/types'\nimport { DEFAULT_PERMISSIONS } from '@/utils/permission'\n\nexport const useUserStore = defineStore('user', {\n  state: (): userState => ({\n    superUser: false,\n    userID: -1,\n    userName: '',\n    avatar: '',\n    level: 1,\n    permissions: DEFAULT_PERMISSIONS,\n    wizard: false,\n  }),\n\n  // 全局持久化\n  persist: true,\n\n  actions: {\n    setSuperUser(superUser: boolean) {\n      this.superUser = superUser\n    },\n    setUserID(userID: number) {\n      this.userID = userID\n    },\n    setUserName(userName: string) {\n      this.userName = userName\n    },\n    setAvatar(avatar: string) {\n      this.avatar = avatar\n    },\n    setLevel(level: number) {\n      this.level = level\n    },\n    setPermissions(permissions: object) {\n      this.permissions = { ...DEFAULT_PERMISSIONS, ...permissions }\n    },\n    setWizard(wizard: boolean) {\n      this.wizard = wizard\n    },\n    loginUser(payload: userState) {\n      this.setSuperUser(payload.superUser)\n      this.setUserID(payload.userID)\n      this.setUserName(payload.userName)\n      this.setAvatar(payload.avatar)\n      this.setLevel(payload.level)\n      this.setPermissions(payload.permissions)\n      this.setWizard(payload.wizard)\n    },\n    reset() {\n      this.setSuperUser(false)\n      this.setUserID(-1)\n      this.setUserName('')\n      this.setAvatar('')\n      this.setLevel(1)\n      this.setPermissions(DEFAULT_PERMISSIONS)\n      this.setWizard(false)\n    },\n  },\n\n  getters: {\n    getSuperUser: state => state.superUser,\n    getUserID: state => state.userID,\n    getUserName: state => state.userName,\n    getAvatar: state => state.avatar,\n    getLevel: state => state.level,\n    getPermissions: state => state.permissions,\n    getWizard: state => state.wizard,\n  },\n})\n"
  },
  {
    "path": "src/styles/common.scss",
    "content": "// 公共样式 - 所有主题都需要\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\nhtml.v-overlay-scroll-blocked {\n  position: static;\n}\n\nhtml.v-overlay-scroll-blocked body {\n  position: fixed;\n  overflow: hidden;\n  inset: 0;\n  inset-block-start: var(--v-body-scroll-y);\n}\n\n@mixin hide-scrollbar {\n  -ms-overflow-style: none;\n  scrollbar-width: none;\n  \n  &::-webkit-scrollbar {\n    display: none;\n  }\n}\n\n@media (width <= 768px) {\n  html,body {\n    @include hide-scrollbar;\n  }\n}\n\n// 进度条样式\n#nprogress .bar {\n  background: rgb(var(--v-theme-primary)) !important;\n  inset-block-start: env(safe-area-inset-top) !important;\n}\n\n#nprogress .peg {\n  box-shadow: 0 0 10px rgb(var(--v-theme-primary)), 0 0 5px rgb(var(--v-theme-primary)) !important;\n  inline-size: 5px;\n  transform: rotate(0deg) translate(0, 0);\n}\n\n// 卡片高度匹配\n.match-height.v-row {\n  .v-card {\n    block-size: 100%;\n  }\n}\n\n// 应用类信息卡片：固定右侧媒体槽位，避免图片被左侧文字挤压变形\n.app-card-shell {\n  position: relative;\n  block-size: 100%;\n}\n\n// 保证卡片右上角的浮动操作区始终高于可点击的卡片内容层，避免误触发详情打开。\n.app-card-top-action {\n  z-index: 2;\n}\n\n.app-card-summary {\n  position: relative;\n  display: flex;\n  overflow: hidden;\n  align-items: stretch;\n  justify-content: flex-start;\n  block-size: 7.5rem;\n  min-block-size: 7.5rem;\n}\n\n.app-card-summary__content {\n  position: relative;\n  z-index: 1;\n  display: flex;\n  flex: 1 1 auto;\n  flex-direction: column;\n  justify-content: center;\n  min-inline-size: 0;\n  padding-block: 0.25rem 0.5rem;\n  row-gap: 0.25rem;\n}\n\n.app-card-summary__title-row {\n  display: flex;\n  align-items: flex-start;\n  column-gap: 0.25rem;\n  min-inline-size: 0;\n}\n\n.app-card-summary__title-row > .v-badge {\n  flex-shrink: 0;\n  align-self: center;\n}\n\n.app-card-summary__subtitle,\n.app-card-summary__meta-item {\n  overflow: hidden;\n  min-inline-size: 0;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.app-card-summary__title {\n  display: -webkit-box;\n  overflow: hidden;\n  flex: 0 0 auto;\n  -webkit-box-orient: vertical;\n  -webkit-line-clamp: 2;\n  line-clamp: 2;\n  line-height: 1.35;\n  max-block-size: calc(1.35em * 2);\n  min-inline-size: 0;\n  text-overflow: ellipsis;\n  white-space: normal;\n  word-break: break-word;\n}\n\n.app-card-summary__title-row .app-card-summary__title {\n  flex: 1 1 auto;\n}\n\n.app-card-summary__meta {\n  display: flex;\n  overflow: hidden;\n  align-items: center;\n  column-gap: 0.5rem;\n  min-block-size: 1.5rem;\n  min-inline-size: 0;\n}\n\n.app-card-summary--single-action .app-card-summary__content {\n  padding-inline-end: 3.75rem;\n}\n\n.app-card-summary--double-action .app-card-summary__content {\n  padding-inline-end: 5rem;\n}\n\n.app-card-summary--title-subtitle {\n  padding-block: 0.75rem !important;\n}\n\n.app-card-summary--title-subtitle .app-card-summary__content {\n  justify-content: space-between;\n  block-size: 100%;\n  padding-block: 0;\n}\n\n.app-card-summary--title-subtitle .app-card-summary__title {\n  flex: 0 1 auto;\n}\n\n.app-card-summary--title-subtitle .app-card-summary__subtitle {\n  flex-shrink: 0;\n}\n\n.app-card-summary__media {\n  position: absolute;\n  z-index: 0;\n  display: flex;\n  align-items: flex-end;\n  justify-content: flex-end;\n  filter: brightness(1.35) saturate(1.05);\n  inset-block-end: 0.75rem;\n  inset-inline-end: 1rem;\n  pointer-events: none;\n}\n\n.app-card-summary--single-action .app-card-summary__media,\n.app-card-summary--double-action .app-card-summary__media {\n  inset-inline-end: 1rem;\n}\n\n.app-card-summary__image {\n  flex-shrink: 0;\n  block-size: 3.5rem;\n  inline-size: 3.5rem;\n  max-block-size: 3.5rem;\n  max-inline-size: 3.5rem;\n  min-block-size: 3.5rem;\n  min-inline-size: 3.5rem;\n}\n\n.app-card-summary__image .v-img__img {\n  object-fit: contain;\n}\n\n// Toast通知样式\n.Vue-Toastification__container {\n  z-index: 2500;\n  margin-block: env(safe-area-inset-top) env(safe-area-inset-bottom);\n}\n\n@media only screen and (width <= 600px){\n  .Vue-Toastification__container {\n    inline-size: 100vw;\n    padding-block: 4.5rem;\n    padding-inline: 1rem;\n  }\n\n  .Vue-Toastification__toast {\n    border-radius: 8px;\n  }\n}\n\n// 对话框样式\n.v-dialog > .v-overlay__content > .v-card > .v-card-item {\n  padding: 16px;\n}\n\n// 路由过渡动画\n.fade-slide-leave-active,\n.fade-slide-enter-active {\n  transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);\n}\n\n.fade-slide-enter-from {\n  opacity: 0;\n  transform: translateX(20px);\n}\n\n.fade-slide-leave-to {\n  opacity: 0;\n  transform: translateX(20px);\n}\n\n// 网格布局样式\n.grid-info-card {\n  grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));\n  padding-block-end: 1rem;\n}\n\n.grid-site-card {\n  grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));\n  padding-block-end: 1rem;\n}\n\n.grid-media-card {\n  grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));\n}\n\n.grid-backdrop-card {\n  grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));\n}\n\n.grid-torrent-card {\n  grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));\n}\n\n.grid-plugin-card {\n  grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));\n}\n\n.grid-downloading-card {\n  grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));\n}\n\n.grid-directory-card {\n  grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));\n}\n\n.grid-filterrule-card {\n  grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));\n}\n\n.grid-customrule-card {\n  grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));\n}\n\n.grid-subscribe-card {\n  grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));\n}\n\n.grid-user-card {\n  grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));\n}\n\n.grid-app-card {\n  grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));\n}\n\n.grid-workflow-card {\n  grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));\n}\n\n.grid-workflow-share-card {\n  grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));\n}\n\n// 文本样式\n.text-moviepilot {\n  background-clip: text;\n  background-image: linear-gradient(to bottom right,var(--tw-gradient-stops));\n  color: transparent;\n\n  --tw-gradient-from: #818cf8;\n  --tw-gradient-stops: var(--tw-gradient-from),var(--tw-gradient-to);\n  --tw-gradient-to: #c084fc;\n}\n\n.text-shadow {\n  text-shadow: 1px 1px #777;\n}\n\n// 滑块标题样式\n.slider-header {\n  position: relative;\n  display: flex;\n}\n\n.slider-title {\n  display: inline-flex;\n  align-items: center;\n  font-size: 1.25rem;\n  font-weight: 700;\n  line-height: 1.75rem;\n}\n\n@media (width >= 640px){\n  .slider-title {\n    overflow: hidden;\n    font-size: 1.5rem;\n    line-height: 2.25rem;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n}\n\n// 滚动条样式\n::-webkit-scrollbar {\n  block-size: 4px;\n  inline-size: 4px;\n  opacity: 0;\n  transition: opacity 0.3s;\n}\n\n::-webkit-scrollbar-thumb {\n  border-radius: 2px;\n  background: rgb(var(--v-theme-perfect-scrollbar-thumb));\n  box-shadow: inset 0 0 10px rgba(0,0,0,20%);\n\n  @media(hover){\n    &:hover{\n      background: #a1a1a1;\n    }\n  }\n}\n\n*:hover::-webkit-scrollbar {\n  opacity: 1;\n}\n\n*:active::-webkit-scrollbar {\n  opacity: 1;\n}\n\n// 组件样式\n.v-alert--variant-elevated, .v-alert--variant-flat {\n  background: rgb(var(--v-table-header-background));\n  color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));\n}\n\n.backdrop-blur {\n  --tw-backdrop-blur: blur(8px)!important;\n\n  backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)!important;\n}\n\n.v-toolbar{\n  background: rgb(var(--v-table-header-background));\n}\n\n.v-divider {\n  border-color: rgba(var(--v-theme-on-background), var(--v-selected-opacity));\n  opacity:0.75;\n}\n\n// 紧凑型悬浮操作按钮\n.compact-fab-stack {\n  position: fixed;\n  z-index: 1100;\n  display: flex;\n  flex-direction: column;\n  align-items: flex-end;\n  gap: 0.75rem;\n  inset-block-end: max(1rem, calc(env(safe-area-inset-bottom) + 1rem));\n  inset-inline-end: max(1rem, calc(env(safe-area-inset-right) + 1rem));\n  pointer-events: none;\n}\n\n.compact-fab-stack > * {\n  pointer-events: auto;\n}\n\n.compact-fab-stack--history {\n  inset-block-end: max(4.5rem, calc(env(safe-area-inset-bottom) + 4.5rem));\n}\n\n.compact-fab.v-fab {\n  display: inline-flex;\n  overflow: visible;\n  flex: none;\n  min-inline-size: 0 !important;\n  pointer-events: auto;\n}\n\n.compact-fab .v-fab__container {\n  position: static;\n  display: inline-flex;\n  overflow: visible;\n  margin: 0 !important;\n}\n\n.compact-fab .v-btn {\n  border: 1px solid rgba(var(--v-theme-on-surface), 0.08);\n  backdrop-filter: blur(14px);\n  box-shadow:\n    0 16px 34px rgb(15 23 42 / 16%),\n    0 6px 16px rgb(15 23 42 / 10%);\n  opacity: 0.98;\n  transition:\n    transform 0.18s ease,\n    box-shadow 0.18s ease,\n    filter 0.18s ease,\n    opacity 0.18s ease;\n}\n\n.compact-fab--primary .v-btn {\n  block-size: 3rem !important;\n  box-shadow:\n    0 20px 40px rgb(15 23 42 / 20%),\n    0 8px 18px rgb(15 23 42 / 12%);\n  inline-size: 3rem !important;\n}\n\n.compact-fab--secondary .v-btn {\n  block-size: 3rem !important;\n  inline-size: 3rem !important;\n}\n\n.compact-fab--primary .v-icon {\n  font-size: 1.75rem !important;\n}\n\n.compact-fab--secondary .v-icon {\n  font-size: 1.75rem !important;\n}\n\n@media (hover: hover) {\n  .compact-fab .v-btn:hover {\n    box-shadow:\n      0 22px 42px rgb(15 23 42 / 22%),\n      0 8px 18px rgb(15 23 42 / 12%);\n    filter: saturate(1.03);\n    transform: translateY(-2px);\n  }\n\n  .compact-fab--primary .v-btn:hover {\n    box-shadow:\n      0 26px 46px rgb(15 23 42 / 24%),\n      0 10px 22px rgb(15 23 42 / 14%);\n  }\n}\n\n.compact-fab .v-btn:active {\n  box-shadow:\n    0 10px 22px rgb(15 23 42 / 16%),\n    0 3px 8px rgb(15 23 42 / 10%);\n  transform: translateY(0) scale(0.98);\n}\n\n@media (width <= 768px) {\n  .compact-fab-stack {\n    gap: 0.625rem;\n    inset-block-end: max(0.875rem, calc(env(safe-area-inset-bottom) + 0.875rem));\n    inset-inline-end: max(0.875rem, calc(env(safe-area-inset-right) + 0.875rem));\n  }\n\n  .compact-fab-stack--history {\n    inset-block-end: max(4rem, calc(env(safe-area-inset-bottom) + 4rem));\n  }\n\n  .compact-fab--primary .v-btn {\n    block-size: 3.5rem !important;\n    inline-size: 3.5rem !important;\n  }\n\n  .compact-fab--secondary .v-btn {\n    block-size: 3rem !important;\n    inline-size: 3rem !important;\n  }\n}\n\n.apexcharts-title-text {\n  color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important;\n}\n\n.v-tabs:not(.v-tabs-pill).v-tabs--horizontal {\n  border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));\n}\n\n.v-fab__container {\n  padding-block-end: env(safe-area-inset-bottom);\n}\n\n.card-cover-blurred::before {\n  position: absolute;\n  backdrop-filter: blur(2px);\n  background: rgba(29, 39, 59, 48%);\n  content: '';\n  inset: 0;\n}\n\n// 弹出层样式\n.v-overlay__content .v-list{\n  backdrop-filter: blur(6px);\n  background-color: rgb(var(--v-theme-surface), 0.9) !important;\n}\n\n.v-overlay__content .v-card:not(.bg-primary){\n  backdrop-filter: blur(8px);\n  background-color: rgb(var(--v-theme-surface), 0.95) !important;\n\n  .v-list, .v-table {\n    backdrop-filter: none;\n    background-color: transparent !important;\n  }\n}\n\n.v-menu {\n  .v-list-item:hover {\n    background-color: rgba(var(--v-theme-on-surface), 0.04) !important;\n  }\n}\n\n.v-btn.v-btn--icon {\n  transition: opacity 0.15s ease;\n}\n\n.v-btn.v-btn--icon:hover {\n  opacity: 0.85;\n}\n\n.v-overlay__content {\n  margin-block: env(safe-area-inset-top) env(safe-area-inset-bottom);\n  transition: opacity 0.2s ease !important;\n}\n\n.v-menu > .v-overlay__content {\n  overflow: hidden;\n}\n\n.v-dialog--fullscreen > .v-overlay__content > .v-card {\n  padding-block-end: calc(env(safe-area-inset-top) + env(safe-area-inset-bottom));\n}\n\n.v-dialog > .v-overlay__content {\n  margin-block: env(safe-area-inset-top) env(safe-area-inset-bottom);\n}\n\n.v-bottom-sheet > .v-bottom-sheet__content.v-overlay__content > .v-card {\n  padding-block-end: env(safe-area-inset-bottom);\n}\n\n.settings-icon-button {\n  flex-shrink: 0;\n  border-radius: 0.95rem;\n  block-size: 2.75rem;\n  inline-size: 2.75rem;\n  margin-inline-start: 0.25rem;\n  min-inline-size: 2.75rem;\n}\n\n.settings-icon-button .v-icon {\n  font-size: 1.35rem;\n}\n\n@media (width <= 768px) {\n  .settings-icon-button {\n    border-radius: 0.825rem;\n    block-size: 2.5rem;\n    inline-size: 2.5rem;\n    min-inline-size: 2.5rem;\n  }\n\n  .settings-icon-button .v-icon {\n    font-size: 1.25rem;\n  }\n}\n\n.v-infinite-scroll__side {\n  padding: 0;\n}\n\n.v-menu .v-overlay__content {\n  box-shadow: none !important;\n} \n"
  },
  {
    "path": "src/styles/main.scss",
    "content": "/* 主样式文件 - 合并所有CSS/SCSS引用 */\n\n/* Vuetify和模板核心样式 */\n@use '@core/scss/libs/vuetify/index' as vuetify-lib;\n@use '@core/scss/index' as template;\n@use '@layouts/styles/index' as layouts;\n@use 'vuetify/styles' as vuetify;\n@use '@styles/common' as common;\n\n/* 第三方库纯CSS样式 */\n@import 'vue-toastification/dist/index.css';\n@import 'vue3-perfect-scrollbar/style.css';\n@import '@vue-js-cron/vuetify/dist/vuetify.css'; \n"
  },
  {
    "path": "src/styles/themes/transparent.scss",
    "content": "// 透明主题专用样式\nhtml[data-theme=\"transparent\"] {\n  // 定义透明度变量\n  --transparent-opacity: 0.3;\n  --transparent-opacity-light: 0.2;\n  --transparent-opacity-heavy: 0.5;\n  --transparent-blur: 10px;\n  --transparent-blur-light: 6px;\n  --transparent-blur-heavy: 16px;\n\n  // 应用、布局、主内容区域\n  .v-application, .v-layout, .v-main, .layout-page-content {\n    background: transparent;\n  }\n  \n  // 侧边导航栏\n  .layout-vertical-nav {\n    backdrop-filter: blur(var(--transparent-blur-heavy));\n    background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-light));\n    border-inline-end: 1px solid rgba(var(--v-theme-on-surface), 0.05);\n  }\n\n  // 列表\n  .v-list {\n    backdrop-filter: blur(var(--transparent-blur));\n    background-color: rgba(var(--v-theme-surface), var(--transparent-opacity));\n  }\n  \n  // 卡片\n  .v-card:not(.no-blur) {\n    backdrop-filter: blur(var(--transparent-blur));\n    background-color: rgba(var(--v-theme-surface), var(--transparent-opacity));\n\n    .v-list {\n      backdrop-filter: none;\n      background-color: transparent;\n    }\n  }\n\n  // 工具栏\n  .v-toolbar {\n    backdrop-filter: blur(var(--transparent-blur));\n    background-color: rgba(var(--v-theme-surface), var(--transparent-opacity));\n  }\n  \n  // 表格\n  .v-table {\n    border-radius: 0;\n    background-color: rgba(var(--v-theme-surface), 0);\n\n    .v-table__wrapper > table > thead {\n      background-color: rgba(var(--v-theme-surface), var(--transparent-opacity));\n    }\n  }\n  \n  // 页脚\n  .v-footer {\n    backdrop-filter: blur(var(--transparent-blur));\n    background-color: rgba(var(--v-theme-surface), var(--transparent-opacity));\n  }\n\n  // Sheet\n  .v-sheet {\n    backdrop-filter: blur(var(--transparent-blur));\n    background-color: rgba(var(--v-theme-surface), var(--transparent-opacity));\n  }\n  \n  // 页面容器\n  .layout-content-wrapper {\n    background: transparent;\n  }\n  \n  // 无内容区域的背景设为透明\n  .page-content-container {\n    background: transparent;\n  }\n  \n  // 对话框和菜单蒙层样式\n  .v-overlay__scrim {\n    background: rgba(var(--v-overlay-scrim-background), var(--v-overlay-scrim-opacity));\n  }\n\n  // 折叠面板\n  .v-expansion-panel {\n    backdrop-filter: blur(var(--transparent-blur));\n    background-color: rgba(var(--v-theme-surface), var(--transparent-opacity));\n  }\n\n  // 加载占位\n  .v-skeleton-loader {\n    background-color: rgba(var(--v-theme-surface), var(--transparent-opacity));\n  }\n\n  // 输入框和搜索框\n  .v-field {\n    background-color: rgba(var(--v-theme-surface), 0);\n  }\n  \n  // 弹出层内容\n  .v-overlay__content {\n    border-radius: 12px !important;\n    backdrop-filter: blur(var(--transparent-blur)) !important;\n\n    .v-card:not(.bg-primary) {\n      backdrop-filter: blur(var(--transparent-blur));\n      background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy)) !important;\n    }\n\n    .v-list {\n      backdrop-filter: blur(var(--transparent-blur));\n      background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy)) !important;\n    }\n\n    .v-table__wrapper table thead {\n      background-color: rgba(var(--v-theme-surface), var(--transparent-opacity));\n    }\n  }\n} \n"
  },
  {
    "path": "src/styles/variables/_template.scss",
    "content": "@forward \"@core/scss/variables\";\n\n// 如果需要自定义变量，可以在这里添加\n// @forward \"@core/scss/variables\" with (\n//   $custom-variable: value\n// );\n"
  },
  {
    "path": "src/styles/variables/_vuetify.scss",
    "content": "@forward \"@core/scss/libs/vuetify/variables\";\n\n// 如果需要自定义Vuetify变量，可以在这里添加\n// @forward \"../../@core/scss/libs/vuetify/variables\" with (\n//   $custom-vuetify-variable: value\n// );\n"
  },
  {
    "path": "src/types/global.d.ts",
    "content": "// PWA Badge API 类型定义\ndeclare global {\n  const __APP_VERSION__: string\n  const __BUILD_TIME__: string\n\n  interface Navigator {\n    /**\n     * 设置应用徽章数量\n     * @param contents 要显示的数量，可选\n     */\n    setAppBadge(contents?: number): Promise<void>\n\n    /**\n     * 清除应用徽章\n     */\n    clearAppBadge(): Promise<void>\n  }\n}\n\nexport {}\n"
  },
  {
    "path": "src/types/i18n.ts",
    "content": "export interface LocaleInfo {\n  name: string\n  title: string\n  flag?: string\n}\n\nexport const SUPPORTED_LOCALES: Record<string, LocaleInfo> = {\n  'zh-CN': {\n    name: 'zh-CN',\n    title: '简体中文',\n    flag: '🇨🇳',\n  },\n  'zh-TW': {\n    name: 'zh-TW',\n    title: '繁體中文',\n    flag: '🇨🇳',\n  },\n  'en-US': {\n    name: 'en-US',\n    title: 'English',\n    flag: '🇺🇸',\n  },\n}\n\nexport type SupportedLocale = keyof typeof SUPPORTED_LOCALES\n"
  },
  {
    "path": "src/types/service-worker-sync.d.ts",
    "content": "export {}\n\ndeclare global {\n  /**\n   * Background SyncManager interface as per the Web Background Sync API.\n   */\n  interface SyncManager {\n    /**\n     * Registers a one-off sync event with the provided tag.\n     */\n    register(tag: string): Promise<void>\n  }\n\n  /**\n   * Extension of ServiceWorkerRegistration to include the SyncManager.\n   */\n  interface ServiceWorkerRegistration {\n    /**\n     * The SyncManager for background sync operations.\n     */\n    readonly sync: SyncManager\n  }\n\n  /**\n   * The event fired when a background sync is triggered.\n   */\n  interface SyncEvent extends ExtendableEvent {\n    readonly tag: string\n    readonly lastChance: boolean\n  }\n\n  /**\n   * Extend ServiceWorkerGlobalScope event map to include the sync event type.\n   */\n  interface ServiceWorkerGlobalScopeEventMap {\n    'sync': SyncEvent\n  }\n}"
  },
  {
    "path": "src/types/workbox-precaching.d.ts",
    "content": "// Type definitions for workbox-precaching runtime use in service worker\n// Simplified subset needed by this project\n\ndeclare module 'workbox-precaching' {\n  /**\n   * A manifest entry generated by Workbox build tools.\n   */\n  export interface ManifestEntry {\n    url: string\n    revision?: string\n  }\n\n  /**\n   * Removes outdated precaches created by older versions of Workbox.\n   */\n  export function cleanupOutdatedCaches(): void\n\n  /**\n   * Adds the supplied manifest entries to the precache list and sets up the\n   * appropriate route so that they are served from the cache.\n   */\n  export function precacheAndRoute(entries: ManifestEntry[]): void\n}"
  },
  {
    "path": "src/utils/appDeepLink.ts",
    "content": "/**\n * 通用APP深度链接工具类\n * 支持媒体服务器（Plex、Jellyfin、Emby）和豆瓣的APP跳转和网页跳转\n *\n * 深度链接格式参考：\n * - Plex: https://forums.plex.tv/t/plex-mobile-app-deep-linking/123456\n * - Emby: https://emby.media/support/articles/Deep-Linking.html\n * - Jellyfin: https://jellyfin.org/docs/general/administration/deep-linking\n * - 豆瓣: 官方搜索格式\n */\n\nimport { isMobileDevice, isIOSDevice, isAndroidDevice } from '@/@core/utils'\n\n// APP类型\nexport type AppType = 'plex' | 'jellyfin' | 'emby' | 'trimemedia' | 'douban'\n\n// 深度链接配置\ninterface DeepLinkConfig {\n  appScheme: string\n  webUrl: string\n  timeout: number\n}\n\n// 各APP的深度链接配置\nconst DEEP_LINK_CONFIGS: Record<AppType, DeepLinkConfig> = {\n  plex: {\n    appScheme: 'plex://',\n    webUrl: 'https://app.plex.tv',\n    timeout: 2000,\n  },\n  jellyfin: {\n    appScheme: 'jellyfin://',\n    webUrl: 'https://jellyfin.org',\n    timeout: 2000,\n  },\n  emby: {\n    appScheme: 'emby://',\n    webUrl: 'https://emby.media',\n    timeout: 2000,\n  },\n  trimemedia: {\n    appScheme: 'trimemedia://',\n    webUrl: 'https://trimemedia.com',\n    timeout: 2000,\n  },\n  douban: {\n    appScheme: 'douban://',\n    webUrl: 'https://movie.douban.com',\n    timeout: 2000,\n  },\n}\n\n// 豆瓣APP跳转参数\ninterface DoubanAppParams {\n  doubanId: string\n  mediaType?: string\n  title?: string\n  year?: string\n  fallbackUrl?: string\n}\n\n/**\n * 尝试跳转到APP，如果失败则跳转到网页\n * @param appType APP类型\n * @param params 跳转参数\n */\nexport async function openApp(appType: AppType, params: string | DoubanAppParams, fallbackUrl?: string): Promise<void> {\n  // 如果不是移动设备，直接使用网页链接\n  if (!isMobileDevice()) {\n    const webUrl = getWebUrl(appType, params, fallbackUrl)\n    window.open(webUrl, '_blank')\n    return\n  }\n\n  const config = DEEP_LINK_CONFIGS[appType]\n  if (!config) {\n    console.warn(`不支持的APP类型: ${appType}`)\n    const webUrl = getWebUrl(appType, params, fallbackUrl)\n    window.open(webUrl, '_blank')\n    return\n  }\n\n  // 构建APP深度链接\n  const appUrl = buildDeepLinkUrl(appType, params)\n\n  console.log(`构建${appType}深度链接:`, {\n    params,\n    deepLinkUrl: appUrl,\n  })\n\n  // 尝试跳转到APP\n  try {\n    await attemptAppLaunch(appUrl, config.timeout)\n  } catch (error) {\n    console.log(`${appType} APP跳转失败，使用网页链接: ${error}`)\n    // APP跳转失败，使用网页链接\n    const webUrl = getWebUrl(appType, params, fallbackUrl)\n    window.open(webUrl, '_blank')\n  }\n}\n\n/**\n * 获取网页链接\n * @param appType APP类型\n * @param params 参数\n * @param fallbackUrl 备用链接\n */\nfunction getWebUrl(appType: AppType, params: string | DoubanAppParams, fallbackUrl?: string): string {\n  if (fallbackUrl) return fallbackUrl\n\n  const config = DEEP_LINK_CONFIGS[appType]\n\n  switch (appType) {\n    case 'douban':\n      const doubanParams = params as DoubanAppParams\n      return `${config.webUrl}/subject/${doubanParams.doubanId}`\n    default:\n      return typeof params === 'string' ? params : config.webUrl\n  }\n}\n\n/**\n * 构建深度链接URL\n * @param appType APP类型\n * @param params 参数\n */\nfunction buildDeepLinkUrl(appType: AppType, params: string | DoubanAppParams): string {\n  switch (appType) {\n    case 'plex':\n      return buildPlexDeepLink(params as string)\n\n    case 'jellyfin':\n      return buildJellyfinDeepLink(params as string)\n\n    case 'emby':\n      return buildEmbyDeepLink(params as string)\n\n    case 'trimemedia':\n      return buildTrimemediaDeepLink(params as string)\n\n    case 'douban':\n      return buildDoubanDeepLink(params as DoubanAppParams)\n\n    default:\n      return typeof params === 'string' ? params : ''\n  }\n}\n\n/**\n * 构建Plex深度链接\n * 参考: https://forums.plex.tv/t/plex-mobile-app-deep-linking/123456\n *\n * 后台API返回格式：\n * - 媒体库: web/index.html#!/media/{machineIdentifier}/com.plexapp.plugins.library?source={library.key}&X-Plex-Token={token}\n * - 媒体项: web/index.html#!/server/{machineIdentifier}/details?key={item_id}&X-Plex-Token={token}\n *\n * Plex官方APP URL格式：\n * plex://play/?metadataKey=/library/metadata/$SOME_ID&server=$SERVER_ID\n * 例如: plex://play/?metadataKey=/library/metadata/123&server=456\n *\n * @param playUrl 播放链接\n */\nfunction buildPlexDeepLink(playUrl: string): string {\n  try {\n    const url = new URL(playUrl)\n\n    // 提取媒体ID、机器标识符、库ID等\n    let mediaId: string | null = null\n    let machineIdentifier: string | null = null\n    let libraryKey: string | null = null\n    let librarySectionId: string | null = null\n    let plexToken: string | null = null\n\n    // 提取X-Plex-Token\n    const tokenMatch = playUrl.match(/X-Plex-Token=([^&]+)/)\n    if (tokenMatch) {\n      plexToken = tokenMatch[1]\n      console.log('提取Plex Token:', { plexToken })\n    }\n\n    // 格式1: 后台API返回的媒体库格式\n    // web/index.html#!/media/{machineIdentifier}/com.plexapp.plugins.library?source={library.key}&X-Plex-Token={token}\n    const mediaLibraryMatch = playUrl.match(/\\/media\\/([^\\/]+)\\/com\\.plexapp\\.plugins\\.library\\?source=([^&]+)/)\n    if (mediaLibraryMatch) {\n      machineIdentifier = mediaLibraryMatch[1]\n      libraryKey = mediaLibraryMatch[2]\n      console.log('Plex后台API媒体库格式匹配:', { machineIdentifier, libraryKey })\n\n      // 从library.key中提取section ID\n      // library.key格式通常是: library://video-section/1 或类似格式\n      const sectionMatch = libraryKey.match(/section\\/(\\d+)/)\n      if (sectionMatch) {\n        librarySectionId = sectionMatch[1]\n        console.log('从library.key提取section ID:', { librarySectionId })\n      }\n    }\n\n    // 格式2: 后台API返回的媒体项格式\n    // web/index.html#!/server/{machineIdentifier}/details?key={item_id}&X-Plex-Token={token}\n    const serverDetailsMatch = playUrl.match(/\\/server\\/([^\\/]+)\\/details\\?key=([^&]+)/)\n    if (serverDetailsMatch) {\n      machineIdentifier = serverDetailsMatch[1]\n      const keyValue = serverDetailsMatch[2]\n      console.log('Plex后台API媒体项格式匹配:', { machineIdentifier, keyValue })\n\n      // 从key中提取媒体ID\n      // key格式可能是: /library/metadata/1668 或直接是 1668\n      const metadataMatch = keyValue.match(/\\/library\\/metadata\\/(\\d+)/)\n      if (metadataMatch) {\n        mediaId = metadataMatch[1]\n        console.log('从key提取媒体ID:', { mediaId })\n      } else if (/^\\d+$/.test(keyValue)) {\n        // 如果key本身就是数字，直接使用\n        mediaId = keyValue\n        console.log('key本身就是媒体ID:', { mediaId })\n      }\n    }\n\n    // 构建深度链接 - 使用新的官方格式\n    if (mediaId && machineIdentifier) {\n      // plex://play/?metadataKey=/library/metadata/$SOME_ID&server=$SERVER_ID\n      let deepLink = `plex://play/?metadataKey=/library/metadata/${mediaId}&server=${machineIdentifier}`\n      if (plexToken) {\n        deepLink += `&X-Plex-Token=${plexToken}`\n      }\n      console.log('Plex深度链接构建成功:', {\n        originalUrl: playUrl,\n        machineIdentifier,\n        libraryKey,\n        librarySectionId,\n        mediaId,\n        plexToken,\n        deepLink,\n      })\n      return deepLink\n    }\n\n    // 如果有媒体ID但没有机器标识符，尝试使用旧的格式作为降级\n    if (mediaId) {\n      let deepLink = `plex://library/metadata/${mediaId}`\n      if (plexToken) {\n        deepLink += `?X-Plex-Token=${plexToken}`\n      }\n      console.log('Plex深度链接构建成功(降级格式):', {\n        originalUrl: playUrl,\n        mediaId,\n        plexToken,\n        deepLink,\n      })\n      return deepLink\n    }\n\n    // 如果有库ID，尝试使用库ID\n    if (librarySectionId) {\n      // http://[PMS_IP_Address]:32400/library/sections/29/all?X-Plex-Token=YourTokenGoesHere\n      let libraryLink = `plex://library/sections/${librarySectionId}/all`\n      if (plexToken) {\n        libraryLink += `?X-Plex-Token=${plexToken}`\n      }\n      console.log('Plex库深度链接构建成功:', {\n        originalUrl: playUrl,\n        librarySectionId,\n        plexToken,\n        libraryLink,\n      })\n      return libraryLink\n    }\n\n    // 如果无法提取媒体ID，尝试使用机器标识符\n    if (machineIdentifier) {\n      // http://[PMS_IP_Address]:32400/library/sections?X-Plex-Token=YourTokenGoesHere\n      let fallbackLink = `plex://library/sections`\n      if (plexToken) {\n        fallbackLink += `?X-Plex-Token=${plexToken}`\n      }\n      console.log('Plex深度链接构建失败，使用机器标识符:', {\n        originalUrl: playUrl,\n        machineIdentifier,\n        plexToken,\n        fallbackLink,\n      })\n      return fallbackLink\n    }\n\n    // 最后的降级方案\n    console.log('Plex深度链接构建失败，使用原始URL:', {\n      originalUrl: playUrl,\n    })\n    return `plex://${playUrl}`\n  } catch (error) {\n    console.warn('构建Plex深度链接失败:', error)\n    return `plex://${playUrl}`\n  }\n}\n\n/**\n * 构建Jellyfin深度链接\n * 参考: https://jellyfin.org/docs/general/administration/deep-linking\n * @param playUrl 播放链接\n */\nfunction buildJellyfinDeepLink(playUrl: string): string {\n  try {\n    const url = new URL(playUrl)\n    const serverAddress = url.hostname + (url.port ? `:${url.port}` : '')\n\n    // 提取媒体ID、库ID、serverId\n    let mediaId: string | null = null\n    let libraryId: string | null = null\n    let serverId: string | null = null\n\n    // 格式1: /details?id={item_id}&serverId={serverid}\n    const detailsMatch = playUrl.match(/\\/details\\?id=([^&]+)&serverId=([^&]+)/)\n    if (detailsMatch) {\n      mediaId = detailsMatch[1]\n      serverId = detailsMatch[2]\n    }\n\n    // 格式2: /movies.html?topParentId={libraryId}\n    const moviesMatch = playUrl.match(/\\/movies\\.html\\?topParentId=([^&]+)/)\n    if (moviesMatch) {\n      libraryId = moviesMatch[1]\n    }\n    // 格式3: /tv.html?topParentId={libraryId}\n    const tvMatch = playUrl.match(/\\/tv\\.html\\?topParentId=([^&]+)/)\n    if (tvMatch) {\n      libraryId = tvMatch[1]\n    }\n    // 格式4: /library.html?topParentId={libraryId}\n    const libMatch = playUrl.match(/\\/library\\.html\\?topParentId=([^&]+)/)\n    if (libMatch) {\n      libraryId = libMatch[1]\n    }\n\n    // 兼容原有格式：?id=xxx\n    if (!mediaId) {\n      const idMatch = playUrl.match(/[?&]id=([^&]+)/)\n      if (idMatch) {\n        mediaId = idMatch[1]\n      }\n    }\n\n    // 兼容原有格式：/items/xxx\n    if (!mediaId) {\n      const itemsMatch = playUrl.match(/\\/items\\/([^\\/\\?]+)/)\n      if (itemsMatch) {\n        mediaId = itemsMatch[1]\n      }\n    }\n\n    // 构建深度链接\n    if (mediaId) {\n      let deepLink = `jellyfin://${serverAddress}/item/${mediaId}`\n      if (serverId) {\n        deepLink += `?serverId=${serverId}`\n      }\n      console.log('Jellyfin深度链接构建成功:', {\n        originalUrl: playUrl,\n        serverAddress,\n        mediaId,\n        serverId,\n        deepLink,\n      })\n      return deepLink\n    }\n    if (libraryId) {\n      const deepLink = `jellyfin://${serverAddress}/library/${libraryId}`\n      console.log('Jellyfin库深度链接构建成功:', {\n        originalUrl: playUrl,\n        serverAddress,\n        libraryId,\n        deepLink,\n      })\n      return deepLink\n    }\n\n    // 如果无法提取ID，尝试直接使用服务器地址\n    const fallbackLink = `jellyfin://${serverAddress}`\n    console.log('Jellyfin深度链接构建失败，使用服务器地址:', {\n      originalUrl: playUrl,\n      serverAddress,\n      fallbackLink,\n    })\n    return fallbackLink\n  } catch (error) {\n    console.warn('构建Jellyfin深度链接失败:', error)\n    return `jellyfin://${playUrl}`\n  }\n}\n\n/**\n * 构建Emby深度链接\n * 参考: https://emby.media/support/articles/Deep-Linking.html\n * iOS格式: emby://items?serverId={SERVER_ID}&itemId={ITEM_ID}\n * Android格式: emby://{服务器地址}/item/{媒体ID}\n * @param playUrl 播放链接\n */\nfunction buildEmbyDeepLink(playUrl: string): string {\n  try {\n    const url = new URL(playUrl)\n    const serverAddress = url.hostname + (url.port ? `:${url.port}` : '')\n\n    // 尝试多种格式提取媒体ID\n    let mediaId: string | null = null\n    let serverId: string | null = null\n\n    // 格式1: /web/index.html#!/item?id=xxx&context=home&serverId=xxx (后台返回的格式)\n    const itemHashMatch = playUrl.match(/\\/item\\?id=([^&]+)/)\n    if (itemHashMatch) {\n      mediaId = itemHashMatch[1]\n      // 提取serverId\n      const serverIdMatch = playUrl.match(/serverId=([^&]+)/)\n      if (serverIdMatch) {\n        serverId = serverIdMatch[1]\n      }\n    }\n\n    // 格式2: /web/index.html#!/videos?serverId=xxx&parentId=xxx (后台返回的格式)\n    const videosHashMatch = playUrl.match(/\\/videos\\?serverId=([^&]+)&parentId=([^&]+)/)\n    if (videosHashMatch) {\n      // 对于videos格式，我们使用parentId作为媒体ID\n      mediaId = videosHashMatch[2]\n      serverId = videosHashMatch[1]\n    }\n\n    // 格式3: ?id=xxx (通用格式)\n    if (!mediaId) {\n      const idMatch = playUrl.match(/[?&]id=([^&]+)/)\n      if (idMatch) {\n        mediaId = idMatch[1]\n      }\n    }\n\n    // 格式4: /itemdetails.html?id=xxx\n    if (!mediaId) {\n      const itemMatch = playUrl.match(/\\/itemdetails\\.html\\?id=([^&]+)/)\n      if (itemMatch) {\n        mediaId = itemMatch[1]\n      }\n    }\n\n    // 格式5: /items/xxx\n    if (!mediaId) {\n      const itemsMatch = playUrl.match(/\\/items\\/([^\\/\\?]+)/)\n      if (itemsMatch) {\n        mediaId = itemsMatch[1]\n      }\n    }\n\n    // 格式6: /item/xxx (路径格式)\n    if (!mediaId) {\n      const itemPathMatch = playUrl.match(/\\/item\\/([^\\/\\?]+)/)\n      if (itemPathMatch) {\n        mediaId = itemPathMatch[1]\n      }\n    }\n\n    if (mediaId) {\n      let deepLink: string\n\n      // 根据设备类型使用不同的深度链接格式\n      if (isIOSDevice()) {\n        // iOS格式: emby://items?serverId={SERVER_ID}&itemId={ITEM_ID}\n        if (serverId) {\n          deepLink = `emby://items?serverId=${serverId}&itemId=${mediaId}`\n        } else {\n          // 如果没有serverId，尝试使用服务器地址作为serverId\n          deepLink = `emby://items?serverId=${serverAddress}&itemId=${mediaId}`\n        }\n      } else {\n        // Android格式: emby://{服务器地址}/item/{媒体ID}\n        deepLink = `emby://${serverAddress}/item/${mediaId}`\n        if (serverId) {\n          deepLink += `?serverId=${serverId}`\n        }\n      }\n\n      console.log('Emby深度链接构建成功:', {\n        originalUrl: playUrl,\n        serverAddress,\n        mediaId,\n        serverId,\n        deviceType: isIOSDevice() ? 'iOS' : 'Android',\n        deepLink,\n      })\n      return deepLink\n    }\n\n    // 如果无法提取媒体ID，尝试直接使用服务器地址\n    // 这会打开Emby APP的主界面\n    const fallbackLink = `emby://${serverAddress}`\n    console.log('Emby深度链接构建失败，使用服务器地址:', {\n      originalUrl: playUrl,\n      serverAddress,\n      fallbackLink,\n    })\n    return fallbackLink\n  } catch (error) {\n    console.warn('构建Emby深度链接失败:', error)\n    return playUrl\n  }\n}\n\n/**\n * 构建Trimemedia深度链接\n * @param playUrl 播放链接\n */\nfunction buildTrimemediaDeepLink(playUrl: string): string {\n  try {\n    const url = new URL(playUrl)\n    const serverAddress = url.hostname + (url.port ? `:${url.port}` : '')\n\n    // 提取媒体ID\n    let mediaId: string | null = null\n\n    // 尝试从URL路径中提取媒体ID\n    const pathMatch = playUrl.match(/\\/item\\/([^\\/\\?]+)/)\n    if (pathMatch) {\n      mediaId = pathMatch[1]\n    }\n\n    // 尝试从查询参数中提取媒体ID\n    if (!mediaId) {\n      const idMatch = playUrl.match(/[?&]id=([^&]+)/)\n      if (idMatch) {\n        mediaId = idMatch[1]\n      }\n    }\n\n    // 构建深度链接\n    if (mediaId) {\n      const deepLink = `trimemedia://${serverAddress}/item/${mediaId}`\n      console.log('Trimemedia深度链接构建成功:', {\n        originalUrl: playUrl,\n        serverAddress,\n        mediaId,\n        deepLink,\n      })\n      return deepLink\n    }\n\n    // 如果无法提取媒体ID，尝试直接使用服务器地址\n    const fallbackLink = `trimemedia://${serverAddress}`\n    console.log('Trimemedia深度链接构建失败，使用服务器地址:', {\n      originalUrl: playUrl,\n      serverAddress,\n      fallbackLink,\n    })\n    return fallbackLink\n  } catch (error) {\n    console.warn('构建Trimemedia深度链接失败:', error)\n    return playUrl\n  }\n}\n\n/**\n * 构建豆瓣深度链接\n * 使用豆瓣App官方支持的搜索格式\n * @param params 豆瓣参数\n */\nfunction buildDoubanDeepLink(params: DoubanAppParams): string {\n  const { title, year } = params\n\n  // 使用豆瓣App官方支持的搜索格式\n  // 格式：douban:///search?q={query}\n  const searchQuery = `${title || ''} ${year || ''}`.trim()\n  const deepLink = `douban:///search?q=${encodeURIComponent(searchQuery)}`\n\n  console.log('豆瓣深度链接构建成功:', {\n    params,\n    searchQuery,\n    deepLink,\n  })\n\n  return deepLink\n}\n\n/**\n * 尝试启动APP\n * @param appUrl APP深度链接\n * @param timeout 超时时间\n */\nasync function attemptAppLaunch(appUrl: string, timeout: number): Promise<void> {\n  return new Promise((resolve, reject) => {\n    // 创建一个隐藏的iframe来尝试启动APP\n    const iframe = document.createElement('iframe')\n    iframe.style.display = 'none'\n    iframe.src = appUrl\n\n    // 设置超时\n    const timeoutId = setTimeout(() => {\n      document.body.removeChild(iframe)\n      reject(new Error('APP启动超时'))\n    }, timeout)\n\n    // 监听页面可见性变化，如果用户切换到APP，说明启动成功\n    const handleVisibilityChange = () => {\n      if (document.hidden) {\n        clearTimeout(timeoutId)\n        document.removeEventListener('visibilitychange', handleVisibilityChange)\n        document.body.removeChild(iframe)\n        resolve()\n      }\n    }\n\n    document.addEventListener('visibilitychange', handleVisibilityChange)\n\n    // 添加到页面并尝试启动\n    document.body.appendChild(iframe)\n\n    // 对于iOS，还需要尝试window.location\n    if (isIOSDevice()) {\n      try {\n        window.location.href = appUrl\n      } catch (error) {\n        console.log('iOS window.location跳转失败:', error)\n      }\n    }\n  })\n}\n\n/**\n * 根据播放链接自动检测媒体服务器类型并跳转\n * @param playUrl 播放链接\n * @param fallbackUrl 备用网页链接\n * @param serverType 媒体服务器类型（可选，优先使用此参数）\n */\nexport async function openMediaServerWithAutoDetect(\n  playUrl: string,\n  fallbackUrl?: string,\n  serverType?: string,\n): Promise<void> {\n  let detectedServerType: AppType | null = null\n\n  // 优先使用传入的 serverType 参数\n  if (serverType) {\n    const type = serverType.toLowerCase()\n    if (type === 'plex' || type === 'jellyfin' || type === 'emby' || type === 'trimemedia') {\n      detectedServerType = type as AppType\n    }\n  }\n\n  // 如果没有传入 serverType 或类型不支持，则从URL中检测\n  if (!detectedServerType) {\n    const url = playUrl.toLowerCase()\n\n    if (url.includes('plex') || url.includes('plex.tv')) {\n      detectedServerType = 'plex'\n    } else if (url.includes('jellyfin')) {\n      detectedServerType = 'jellyfin'\n    } else if (url.includes('emby')) {\n      detectedServerType = 'emby'\n    }\n  }\n\n  if (detectedServerType) {\n    await openApp(detectedServerType, playUrl, fallbackUrl)\n  } else {\n    // 无法检测到服务器类型，直接使用网页链接\n    window.open(fallbackUrl || playUrl, '_blank')\n  }\n}\n\n/**\n * 打开豆瓣APP\n * @param doubanId 豆瓣ID\n * @param mediaType 媒体类型（电影/电视剧）\n * @param title 媒体标题\n * @param year 媒体年份\n * @param fallbackUrl 备用网页链接\n */\nexport async function openDoubanApp(\n  doubanId: string,\n  mediaType?: string,\n  title?: string,\n  year?: string,\n  fallbackUrl?: string,\n): Promise<void> {\n  const params: DoubanAppParams = {\n    doubanId,\n    mediaType,\n    title,\n    year,\n    fallbackUrl,\n  }\n\n  await openApp('douban', params, fallbackUrl)\n}\n\n/**\n * 获取APP的下载链接\n * @param appType APP类型\n */\nexport function getAppDownloadUrl(appType: AppType): string {\n  switch (appType) {\n    case 'plex':\n      return 'https://www.plex.tv/apps/'\n    case 'jellyfin':\n      return 'https://jellyfin.org/downloads/'\n    case 'emby':\n      return 'https://emby.media/download.html'\n    case 'trimemedia':\n      return 'https://trimemedia.com/download'\n    case 'douban':\n      return 'https://www.douban.com/doubanapp/'\n    default:\n      return ''\n  }\n}\n\n/**\n * 检查是否安装了特定的APP\n * 注意：由于浏览器安全限制，无法直接检测APP是否安装\n * 这个方法主要用于提示用户\n */\nexport function checkAppInstalled(appType: AppType): boolean {\n  // 由于浏览器安全限制，无法直接检测APP是否安装\n  // 这里可以根据用户代理或其他信息进行推测\n  // 目前返回false，让系统总是尝试跳转\n  return false\n}\n"
  },
  {
    "path": "src/utils/backgroundManager.ts",
    "content": "/**\n * 后台管理器\n * 统一管理定时器和后台活动，减少iOS系统杀掉应用的概率\n */\nexport class BackgroundManager {\n  private timers: Map<string, {\n    callback: () => void\n    interval: number\n    timer: ReturnType<typeof setInterval> | null\n    pausedAt?: number\n    runInBackground?: boolean\n  }> = new Map()\n  \n  private isBackground = false\n  private isDestroyed = false\n  private lastActivityTime = Date.now()\n  private activityTimer: ReturnType<typeof setInterval> | null = null\n\n  constructor() {\n    this.setupVisibilityListener()\n    this.setupActivityTracking()\n  }\n\n  private setupVisibilityListener() {\n    document.addEventListener('visibilitychange', () => {\n      const wasBackground = this.isBackground\n      this.isBackground = document.hidden\n      \n      if (this.isBackground && !wasBackground) {\n        console.log('Background: 进入后台，暂停定时器')\n        this.pauseAllTimers()\n      } else if (!this.isBackground && wasBackground) {\n        console.log('Background: 回到前台，恢复定时器')\n        this.resumeAllTimers()\n      }\n    })\n\n    // 页面卸载时清理\n    window.addEventListener('beforeunload', () => {\n      this.destroy()\n    })\n  }\n\n  private setupActivityTracking() {\n    // 跟踪用户活动\n    const events = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart', 'click']\n    \n    const updateActivity = () => {\n      this.lastActivityTime = Date.now()\n    }\n\n    events.forEach(event => {\n      document.addEventListener(event, updateActivity, { passive: true })\n    })\n\n    // 定期更新活动状态\n    this.activityTimer = setInterval(() => {\n      // 如果超过5分钟没有活动，可以考虑减少后台活动\n      const inactiveTime = Date.now() - this.lastActivityTime\n      if (inactiveTime > 5 * 60 * 1000) {\n        console.log('Background: 用户长时间不活跃')\n      }\n    }, 60000) // 每分钟检查一次\n  }\n\n  /**\n   * 添加定时器\n   */\n  addTimer(\n    id: string, \n    callback: () => void, \n    interval: number, \n    options: {\n      runInBackground?: boolean\n      skipInitialRun?: boolean\n    } = {}\n  ) {\n    const { runInBackground = false, skipInitialRun = false } = options\n    \n    this.removeTimer(id)\n    \n    const timerConfig = {\n      callback,\n      interval,\n      timer: null as ReturnType<typeof setInterval> | null,\n      runInBackground\n    }\n\n    // 创建定时器\n    const wrappedCallback = () => {\n      if (this.isDestroyed) return\n      \n      // 只有在前台运行，或者明确允许后台运行时才执行\n      if (!this.isBackground || runInBackground) {\n        try {\n          callback()\n        } catch (error) {\n          console.error(`Background: 定时器 ${id} 执行错误:`, error)\n        }\n      }\n    }\n\n    timerConfig.timer = setInterval(wrappedCallback, interval)\n    this.timers.set(id, timerConfig)\n\n    // 如果不跳过初始运行，立即执行一次\n    if (!skipInitialRun) {\n      wrappedCallback()\n    }\n\n    console.log(`Background: 添加定时器 ${id}, 间隔 ${interval}ms`)\n  }\n\n  /**\n   * 移除定时器\n   */\n  removeTimer(id: string) {\n    const timerConfig = this.timers.get(id)\n    if (timerConfig) {\n      if (timerConfig.timer) {\n        clearInterval(timerConfig.timer)\n      }\n      this.timers.delete(id)\n      console.log(`Background: 移除定时器 ${id}`)\n    }\n  }\n\n  /**\n   * 暂停所有定时器\n   */\n  private pauseAllTimers() {\n    this.timers.forEach((timerConfig, id) => {\n      if (timerConfig.timer && !timerConfig.runInBackground) {\n        clearInterval(timerConfig.timer)\n        timerConfig.timer = null\n        timerConfig.pausedAt = Date.now()\n      }\n    })\n  }\n\n  /**\n   * 恢复所有定时器\n   */\n  private resumeAllTimers() {\n    this.timers.forEach((timerConfig, id) => {\n      if (!timerConfig.timer) {\n        const wrappedCallback = () => {\n          if (this.isDestroyed) return\n          \n          if (!this.isBackground || timerConfig.runInBackground) {\n            try {\n              timerConfig.callback()\n            } catch (error) {\n              console.error(`Background: 定时器 ${id} 执行错误:`, error)\n            }\n          }\n        }\n\n        timerConfig.timer = setInterval(wrappedCallback, timerConfig.interval)\n        delete timerConfig.pausedAt\n      }\n    })\n  }\n\n  /**\n   * 获取定时器状态\n   */\n  getTimerStatus(id: string): 'running' | 'paused' | 'not-found' {\n    const timerConfig = this.timers.get(id)\n    if (!timerConfig) return 'not-found'\n    return timerConfig.timer ? 'running' : 'paused'\n  }\n\n  /**\n   * 获取所有定时器信息\n   */\n  getTimersInfo(): Array<{\n    id: string\n    interval: number\n    status: 'running' | 'paused'\n    runInBackground: boolean\n    pausedAt?: number\n  }> {\n    return Array.from(this.timers.entries()).map(([id, config]) => ({\n      id,\n      interval: config.interval,\n      status: config.timer ? 'running' : 'paused',\n      runInBackground: config.runInBackground || false,\n      pausedAt: config.pausedAt\n    }))\n  }\n\n  /**\n   * 检查用户是否活跃\n   */\n  isUserActive(maxInactiveTime = 5 * 60 * 1000): boolean {\n    return Date.now() - this.lastActivityTime < maxInactiveTime\n  }\n\n  /**\n   * 获取最后活动时间\n   */\n  getLastActivityTime(): number {\n    return this.lastActivityTime\n  }\n\n  /**\n   * 获取当前状态\n   */\n  getStatus(): {\n    isBackground: boolean\n    isDestroyed: boolean\n    timerCount: number\n    lastActivityTime: number\n    isUserActive: boolean\n  } {\n    return {\n      isBackground: this.isBackground,\n      isDestroyed: this.isDestroyed,\n      timerCount: this.timers.size,\n      lastActivityTime: this.lastActivityTime,\n      isUserActive: this.isUserActive()\n    }\n  }\n\n  /**\n   * 销毁管理器\n   */\n  destroy() {\n    this.isDestroyed = true\n    \n    // 清理所有定时器\n    this.timers.forEach((timerConfig, id) => {\n      if (timerConfig.timer) {\n        clearInterval(timerConfig.timer)\n      }\n    })\n    this.timers.clear()\n\n    // 清理活动跟踪定时器\n    if (this.activityTimer) {\n      clearInterval(this.activityTimer)\n      this.activityTimer = null\n    }\n\n    console.log('Background: 管理器已销毁')\n  }\n}\n\n/**\n * 全局后台管理器实例\n */\nexport const backgroundManager = new BackgroundManager()\n\n/**\n * 便捷的定时器管理函数\n */\nexport function addBackgroundTimer(\n  id: string, \n  callback: () => void, \n  interval: number, \n  options?: {\n    runInBackground?: boolean\n    skipInitialRun?: boolean\n  }\n) {\n  backgroundManager.addTimer(id, callback, interval, options)\n}\n\nexport function removeBackgroundTimer(id: string) {\n  backgroundManager.removeTimer(id)\n}\n\nexport function getBackgroundTimerStatus(id: string) {\n  return backgroundManager.getTimerStatus(id)\n}"
  },
  {
    "path": "src/utils/badge.ts",
    "content": "/**\n * PWA 徽章管理工具\n */\n\n// 全局事件类型\ninterface UnreadMessageEvent extends CustomEvent {\n  detail: { count: number }\n}\n\n// 发送全局未读消息事件\nexport function emitUnreadMessageEvent(count: number) {\n  const event = new CustomEvent('unreadMessage', { detail: { count } }) as UnreadMessageEvent\n  window.dispatchEvent(event)\n}\n\n// 监听全局未读消息事件\nexport function onUnreadMessage(callback: (count: number) => void) {\n  const handler = (event: Event) => {\n    const unreadEvent = event as UnreadMessageEvent\n    callback(unreadEvent.detail.count)\n  }\n  window.addEventListener('unreadMessage', handler)\n  return () => window.removeEventListener('unreadMessage', handler)\n}\n\n// 等待Service Worker准备就绪\nexport async function waitForServiceWorker(): Promise<ServiceWorker | null> {\n  if (!('serviceWorker' in navigator)) {\n    return null\n  }\n\n  // 如果已经有激活的Service Worker，直接返回\n  if (navigator.serviceWorker.controller) {\n    return navigator.serviceWorker.controller\n  }\n\n  // 等待Service Worker注册和激活，最多等待10秒\n  return new Promise(resolve => {\n    let timeoutId: ReturnType<typeof setTimeout> | null = null\n    let resolved = false\n\n    const resolveOnce = (sw: ServiceWorker | null) => {\n      if (resolved) return\n      resolved = true\n      if (timeoutId) clearTimeout(timeoutId)\n      resolve(sw)\n    }\n\n    const checkServiceWorker = () => {\n      if (navigator.serviceWorker.controller) {\n        resolveOnce(navigator.serviceWorker.controller)\n      } else {\n        setTimeout(checkServiceWorker, 200)\n      }\n    }\n\n    // 监听Service Worker变化\n    navigator.serviceWorker.addEventListener('controllerchange', () => {\n      resolveOnce(navigator.serviceWorker.controller)\n    })\n\n    // 设置超时，10秒后返回null\n    timeoutId = setTimeout(() => {\n      resolveOnce(null)\n    }, 10000)\n\n    checkServiceWorker()\n  })\n}\n\n// 应用启动时检查未读消息数量\nexport async function checkUnreadOnStartup(): Promise<number> {\n  try {\n    // 检查Service Worker是否可用\n    if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) {\n      return 0\n    }\n\n    // 获取未读消息数量\n    const unreadCount = await getUnreadCount()\n    return unreadCount\n  } catch (error) {\n    return 0\n  }\n}\n\n// 应用启动检查并触发事件\nexport async function checkAndEmitUnreadMessages() {\n  try {\n    const count = await checkUnreadOnStartup()\n    if (count > 0) {\n      emitUnreadMessageEvent(count)\n    }\n  } catch (error) {\n    // 静默处理错误\n  }\n}\n\n// 清除桌面图标徽章\nexport async function clearAppBadge(): Promise<boolean> {\n  try {\n    // 如果浏览器支持原生Badge API，直接调用\n    if ('clearAppBadge' in navigator) {\n      await navigator.clearAppBadge()\n    }\n\n    // 向service worker发送清除徽章消息\n    if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {\n      const messageChannel = new MessageChannel()\n\n      return new Promise(resolve => {\n        messageChannel.port1.onmessage = event => {\n          resolve(event.data.success)\n        }\n\n        navigator.serviceWorker.controller?.postMessage({ type: 'CLEAR_BADGE' }, [messageChannel.port2])\n      })\n    }\n\n    return true\n  } catch (error) {\n    console.error('Failed to clear app badge:', error)\n    return false\n  }\n}\n\n// 更新桌面图标徽章数量\nexport async function updateAppBadge(count: number): Promise<boolean> {\n  try {\n    // 如果浏览器支持原生Badge API，直接调用\n    if ('setAppBadge' in navigator) {\n      if (count > 0) {\n        await navigator.setAppBadge(count)\n      } else {\n        await navigator.clearAppBadge()\n      }\n    }\n\n    // 向service worker发送更新徽章消息\n    if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {\n      const messageChannel = new MessageChannel()\n\n      return new Promise(resolve => {\n        messageChannel.port1.onmessage = event => {\n          resolve(event.data.success)\n        }\n\n        navigator.serviceWorker.controller?.postMessage({ type: 'UPDATE_BADGE', count }, [messageChannel.port2])\n      })\n    }\n\n    return true\n  } catch (error) {\n    console.error('Failed to update app badge:', error)\n    return false\n  }\n}\n\n// 获取Service Worker中的未读消息数量\nexport async function getUnreadCount(): Promise<number> {\n  try {\n    if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {\n      const messageChannel = new MessageChannel()\n\n      return new Promise(resolve => {\n        messageChannel.port1.onmessage = event => {\n          resolve(event.data.count || 0)\n        }\n\n        navigator.serviceWorker.controller?.postMessage({ type: 'GET_UNREAD_COUNT' }, [messageChannel.port2])\n      })\n    }\n\n    return 0\n  } catch (error) {\n    console.error('Failed to get unread count:', error)\n    return 0\n  }\n}\n\n// 检查浏览器是否支持Badge API\nexport function supportsBadgeAPI(): boolean {\n  return 'setAppBadge' in navigator && 'clearAppBadge' in navigator\n}\n"
  },
  {
    "path": "src/utils/colorUtils.ts",
    "content": "// 预定义的颜色数组，包含更多丰富的颜色选项\nconst COLORS = [\n  // 基础颜色\n  '#4caf50', // 绿色\n  '#2196f3', // 蓝色\n  '#ff9800', // 橙色\n  '#9c27b0', // 紫色\n  '#f44336', // 红色\n  '#00bcd4', // 青色\n  '#8bc34a', // 浅绿色\n  '#ff5722', // 深橙色\n  '#3f51b5', // 靛蓝色\n  '#009688', // 青绿色\n  '#e91e63', // 粉红色\n  '#673ab7', // 深紫色\n  '#ffc107', // 琥珀色\n  '#795548', // 棕色\n  '#607d8b', // 蓝灰色\n\n  // 扩展颜色\n  '#ff4081', // 深粉红色\n  '#00e676', // 浅绿色\n  '#ff6f00', // 深橙色\n  '#4fc3f7', // 浅蓝色\n  '#ba68c8', // 浅紫色\n  '#81c784', // 浅绿色\n  '#ffb74d', // 浅橙色\n  '#64b5f6', // 浅蓝色\n  '#f06292', // 浅粉红色\n  '#4db6ac', // 浅青绿色\n  '#aed581', // 浅绿色\n  '#ffd54f', // 浅黄色\n  '#7986cb', // 浅靛蓝色\n  '#4dd0e1', // 浅青色\n  '#ff8a65', // 浅红色\n  '#9575cd', // 浅紫色\n  '#4fc3f7', // 天蓝色\n  '#ffcc02', // 金黄色\n  '#7cb342', // 浅绿色\n  '#42a5f5', // 蓝色\n  '#ab47bc', // 紫色\n  '#26a69a', // 青绿色\n  '#66bb6a', // 绿色\n  '#ff7043', // 深橙色\n  '#29b6f6', // 浅蓝色\n  '#7e57c2', // 紫色\n  '#26c6da', // 青色\n  '#9ccc65', // 浅绿色\n  '#ffb300', // 琥珀色\n  '#8d6e63', // 棕色\n  '#78909c', // 蓝灰色\n  '#ef5350', // 红色\n  '#ec407a', // 粉红色\n  '#ab47bc', // 紫色\n  '#42a5f5', // 蓝色\n  '#7cb342', // 绿色\n  '#ffa726', // 橙色\n  '#26c6da', // 青色\n  '#d4e157', // 浅绿色\n  '#ffca28', // 黄色\n  '#9fa8da', // 浅靛蓝色\n  '#80cbc4', // 浅青绿色\n  '#c5e1a5', // 浅绿色\n  '#ffe082', // 浅黄色\n  '#b39ddb', // 浅紫色\n  '#90caf9', // 浅蓝色\n  '#a5d6a7', // 浅绿色\n  '#ffcc80', // 浅橙色\n  '#b2dfdb', // 浅青绿色\n  '#f8bbd9', // 浅粉红色\n  '#c8e6c9', // 浅绿色\n  '#fff9c4', // 浅黄色\n  '#d1c4e9', // 浅紫色\n  '#bbdefb', // 浅蓝色\n  '#c8e6c9', // 浅绿色\n  '#ffecb3', // 浅琥珀色\n  '#d7ccc8', // 浅棕色\n  '#cfd8dc', // 浅蓝灰色\n]\n\n// 颜色缓存，确保同一项目总是获得相同颜色\nconst colorCache = new Map<string, string>()\n\n/**\n * 生成随机颜色\n * @returns 随机颜色值\n */\nexport function generateRandomColor(): string {\n  return COLORS[Math.floor(Math.random() * COLORS.length)]\n}\n\n/**\n * 为指定项目获取或生成颜色\n * @param itemKey 项目的唯一标识\n * @returns 颜色值\n */\nexport function getItemColor(itemKey: string): string {\n  if (!colorCache.has(itemKey)) {\n    colorCache.set(itemKey, generateRandomColor())\n  }\n  return colorCache.get(itemKey)!\n}\n\n/**\n * 初始化项目颜色\n * @param items 项目数组\n * @param keyExtractor 从项目中提取唯一键的函数\n */\nexport function initializeItemColors<T>(items: T[], keyExtractor: (item: T) => string): void {\n  items.forEach(item => {\n    const key = keyExtractor(item)\n    getItemColor(key) // 这会自动缓存颜色\n  })\n}\n\n/**\n * 清除颜色缓存\n */\nexport function clearColorCache(): void {\n  colorCache.clear()\n}\n\n/**\n * 获取所有预定义颜色\n * @returns 颜色数组\n */\nexport function getAllColors(): string[] {\n  return [...COLORS]\n}\n\n/**\n * 获取颜色总数\n * @returns 颜色数量\n */\nexport function getColorCount(): number {\n  return COLORS.length\n}\n"
  },
  {
    "path": "src/utils/federationLoader.ts",
    "content": "import api from '@/api'\nimport {\n  __federation_method_setRemote,\n  __federation_method_getRemote,\n  __federation_method_unwrapDefault,\n  // @ts-ignore\n} from 'virtual:__federation__'\n\n// 创建一个专用的AbortController，用于federationLoader请求\nconst federationController = new AbortController()\n\n// 定义远程模块接口\ninterface RemoteModule {\n  id: string\n  url: string\n}\n\n/**\n * 获取单个远程模块信息\n * @param id 远程模块ID\n */\nasync function fetchSingleRemoteModule(id: string): Promise<RemoteModule | null> {\n  try {\n    const modules = await fetchRemoteModules()\n    return modules.find(module => module.id === id) || null\n  } catch (error) {\n    console.error(`获取远程模块信息失败: ${id}`, error)\n    return null\n  }\n}\n\n/**\n * 将 nav_key 转为联邦暴露名的 Pascal 片段（如 settings -> Settings，my-tool -> MyTool）\n */\nfunction navKeyToPascalSegment(navKey: string): string {\n  return navKey\n    .trim()\n    .split(/[-_\\s]+/)\n    .filter(Boolean)\n    .map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())\n    .join('')\n}\n\n/**\n * 加载插件全页组件（支持同一插件多界面）。\n *\n * 解析顺序（nav_key 为 main 或空时）：\n *   `AppPage` → `Page`\n *\n * 其它 nav_key（例如 settings、my_tool）：\n *   `AppPage{Pascal}` → `AppPage` → `Page`\n *   例：nav_key=settings → 尝试 `AppPageSettings`，再回退 `AppPage`、`Page`\n *\n * 也可在单个 `AppPage.vue` 内根据 `navKey` prop 分支渲染，无需多文件。\n */\nexport async function loadRemoteAppPageComponent(id: string, navKey: string = 'main') {\n  const raw = (navKey || 'main').trim()\n  const isMain = raw === '' || raw.toLowerCase() === 'main'\n\n  const candidateNames: string[] = []\n  if (isMain) {\n    candidateNames.push('AppPage', 'Page')\n  } else {\n    const pascal = navKeyToPascalSegment(raw)\n    if (pascal) {\n      candidateNames.push(`AppPage${pascal}`)\n    }\n    candidateNames.push('AppPage', 'Page')\n  }\n\n  let lastError: unknown\n  for (const name of candidateNames) {\n    try {\n      return await loadRemoteComponent(id, name)\n    } catch (error) {\n      lastError = error\n      console.debug(`[federation] 插件 ${id} 全页尝试 ./${name} 失败，回退下一候选`)\n    }\n  }\n  console.warn(`[federation] 插件 ${id} 全页均加载失败 (navKey=${raw})`, lastError)\n  throw lastError ?? new Error(`无法加载插件 ${id} 的全页组件`)\n}\n\n/**\n * 加载远程组件\n * @param id 远程模块ID\n * @param componentName 组件名称 (如 'Page')\n */\nexport async function loadRemoteComponent(id: string, componentName: string = 'Page') {\n  try {\n    const module = await __federation_method_getRemote(id, `./${componentName}`)\n    return __federation_method_unwrapDefault(module)\n  } catch (error) {\n    // 组件未注册，尝试重新注册\n    try {\n      const moduleInfo = await fetchSingleRemoteModule(id)\n      if (moduleInfo) {\n        console.log(`组件未注册，正在重新注册: ${id}`)\n        injectRemoteModule(moduleInfo)\n\n        // 重新尝试加载组件\n        const module = await __federation_method_getRemote(id, `./${componentName}`)\n        return __federation_method_unwrapDefault(module)\n      } else {\n        console.error(`无法找到远程模块信息: ${id}`)\n        throw new Error(`无法找到远程模块信息: ${id}`)\n      }\n    } catch (retryError) {\n      console.error(`重新注册并加载组件失败: ${id}/${componentName}`, retryError)\n      throw retryError\n    }\n  }\n}\n\n/**\n * 从API获取远程模块列表\n */\nasync function fetchRemoteModules(): Promise<RemoteModule[]> {\n  try {\n    const response = await api.get('plugin/remotes?token=moviepilot', {\n      signal: federationController.signal,\n    })\n    return (response as any) || []\n  } catch (error) {\n    console.error('获取远程模块列表失败:', error)\n    return []\n  }\n}\n\n/**\n * 动态注入Federation Remote模块\n * @param modules 远程模块列表\n */\nfunction injectRemoteModule(module: RemoteModule): void {\n  // 与 API 请求一致：使用 origin + pathname 作为前缀，子路径代理时 pathname 含 /mp 等\n  const baseUrl = new URL(window.location.href)\n  const pathBase = baseUrl.pathname.replace(/\\/$/, '') || ''\n  let apiBase = import.meta.env.VITE_API_BASE_URL\n  if (apiBase.startsWith('/')) {\n    apiBase = apiBase.slice(1)\n  }\n  if (apiBase.endsWith('/')) {\n    apiBase = apiBase.slice(0, -1)\n  }\n  const pathWithoutLeadingSlash = module.url.startsWith('/') ? module.url.slice(1) : module.url\n  const remoteEntryUrl = `${baseUrl.origin}${pathBase}/${apiBase}/${pathWithoutLeadingSlash}`\n  __federation_method_setRemote(module.id, {\n    url: () => Promise.resolve(remoteEntryUrl),\n    format: 'esm',\n    from: 'vite',\n  })\n  console.log('已注入远程模块:', module)\n}\n\n/**\n * 初始化并加载所有远程组件\n */\nexport async function loadRemoteComponents(): Promise<void> {\n  try {\n    // 获取远程模块列表\n    const modules = await fetchRemoteModules()\n\n    // 确保有模块才注入\n    if (modules && modules.length > 0) {\n      // 注入远程模块\n      modules.forEach(module => {\n        injectRemoteModule(module)\n      })\n    } else {\n      console.log('没有发现可用的远程模块')\n    }\n  } catch (error) {\n    console.error('加载远程组件失败:', error)\n  }\n}\n"
  },
  {
    "path": "src/utils/globalSetting.ts",
    "content": "import api from '@/api'\n\n// 创建一个专用的AbortController，用于globalSetting请求\nconst globalSettingController = new AbortController()\n\nexport async function fetchGlobalSettings() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/global', {\n      params: {\n        token: 'moviepilot',\n      },\n      // 手动设置signal，防止reqestOptimizer添加可中断的controller\n      signal: globalSettingController.signal,\n    })\n    return result.data || {}\n  } catch (error) {\n    console.error('Failed to fetch global settings', error)\n    throw error\n  }\n}\n"
  },
  {
    "path": "src/utils/imageUtils.ts",
    "content": "/**\n * 静态资源导入工具函数\n * 用于在生产环境中正确引用静态资源\n */\n\n// 导入所有 logo 图标\nimport qbittorrentLogo from '@/assets/images/logos/qbittorrent.png'\nimport transmissionLogo from '@/assets/images/logos/transmission.png'\nimport rtorrentLogo from '@/assets/images/logos/rtorrent.png'\nimport embyLogo from '@/assets/images/logos/emby.png'\nimport jellyfinLogo from '@/assets/images/logos/jellyfin.png'\nimport plexLogo from '@/assets/images/logos/plex.png'\nimport trimemediaLogo from '@/assets/images/logos/trimemedia.png'\nimport ugreenLogo from '@/assets/images/logos/ugreen.png'\nimport wechatLogo from '@/assets/images/logos/wechat.png'\nimport telegramLogo from '@/assets/images/logos/telegram.webp'\nimport slackLogo from '@/assets/images/logos/slack.webp'\nimport discordLogo from '@/assets/images/logos/discord.png'\nimport synologychatLogo from '@/assets/images/logos/synologychat.png'\nimport vocechatLogo from '@/assets/images/logos/vocechat.png'\nimport downloaderLogo from '@/assets/images/logos/downloader.png'\nimport mediaserverLogo from '@/assets/images/logos/mediaserver.png'\nimport notificationLogo from '@/assets/images/logos/notification.png'\nimport chromeLogo from '@/assets/images/logos/chrome.png'\nimport doubanLogo from '@/assets/images/logos/douban.png'\nimport githubLogo from '@/assets/images/logos/github.png'\nimport tmdbLogo from '@/assets/images/logos/tmdb.png'\nimport fanartLogo from '@/assets/images/logos/fanart.webp'\nimport pythonLogo from '@/assets/images/logos/python.png'\nimport pluginLogo from '@/assets/images/logos/plugin.png'\nimport siteLogo from '@/assets/images/logos/site.webp'\nimport bangumiLogo from '@/assets/images/logos/bangumi.png'\nimport doubanBlackLogo from '@/assets/images/logos/douban-black.png'\nimport qqLogo from '@/assets/images/logos/qq.png'\n\n// 图标映射表\nconst logoMap: Record<string, string> = {\n  qbittorrent: qbittorrentLogo,\n  transmission: transmissionLogo,\n  rtorrent: rtorrentLogo,\n  emby: embyLogo,\n  jellyfin: jellyfinLogo,\n  plex: plexLogo,\n  trimemedia: trimemediaLogo,\n  ugreen: ugreenLogo,\n  wechat: wechatLogo,\n  telegram: telegramLogo,\n  slack: slackLogo,\n  discord: discordLogo,\n  synologychat: synologychatLogo,\n  vocechat: vocechatLogo,\n  downloader: downloaderLogo,\n  mediaserver: mediaserverLogo,\n  notification: notificationLogo,\n  chrome: chromeLogo,\n  douban: doubanLogo,\n  github: githubLogo,\n  tmdb: tmdbLogo,\n  fanart: fanartLogo,\n  python: pythonLogo,\n  plugin: pluginLogo,\n  site: siteLogo,\n  bangumi: bangumiLogo,\n  'douban-black': doubanBlackLogo,\n  qq: qqLogo,\n}\n\n/**\n * 获取图标 URL\n * @param logoName 图标名称\n * @returns 图标的 URL\n */\nexport function getLogoUrl(logoName: string): string {\n  return logoMap[logoName] || ''\n}\n\n/**\n * 获取所有可用的图标名称\n * @returns 图标名称数组\n */\nexport function getAvailableLogos(): string[] {\n  return Object.keys(logoMap)\n}\n\n/**\n * 检查图标是否存在\n * @param logoName 图标名称\n * @returns 是否存在\n */\nexport function hasLogo(logoName: string): boolean {\n  return logoName in logoMap\n}\n"
  },
  {
    "path": "src/utils/loadingStateManager.ts",
    "content": "/**\n * PWA加载状态管理器\n * 用于协调不同组件的加载状态，确保所有关键资源加载完成后再显示界面\n */\nexport class PWALoadingStateManager {\n  private loadingStates: Map<string, boolean> = new Map()\n  private listeners: Set<(isLoading: boolean) => void> = new Set()\n\n  /**\n   * 设置加载状态\n   * @param key 状态键名\n   * @param loading 是否正在加载\n   */\n  setLoadingState(key: string, loading: boolean): void {\n    const wasLoading = this.isAnyLoading()\n    this.loadingStates.set(key, loading)\n    const isLoading = this.isAnyLoading()\n    \n    // 如果总体加载状态发生变化，通知监听器\n    if (wasLoading !== isLoading) {\n      this.notifyListeners(isLoading)\n    }\n  }\n\n  /**\n   * 检查是否有任何组件正在加载\n   */\n  isAnyLoading(): boolean {\n    return Array.from(this.loadingStates.values()).some(loading => loading)\n  }\n\n  /**\n   * 等待所有加载完成\n   */\n  waitForAllComplete(): Promise<void> {\n    return new Promise((resolve) => {\n      if (!this.isAnyLoading()) {\n        resolve()\n        return\n      }\n\n      const checkComplete = () => {\n        if (!this.isAnyLoading()) {\n          resolve()\n        } else {\n          // 检查间隔\n          setTimeout(checkComplete, 50)\n        }\n      }\n      checkComplete()\n    })\n  }\n\n  /**\n   * 添加状态变化监听器\n   * @param listener 监听器函数\n   */\n  addListener(listener: (isLoading: boolean) => void): void {\n    this.listeners.add(listener)\n  }\n\n  /**\n   * 移除状态变化监听器\n   * @param listener 监听器函数\n   */\n  removeListener(listener: (isLoading: boolean) => void): void {\n    this.listeners.delete(listener)\n  }\n\n  /**\n   * 通知所有监听器\n   * @param isLoading 是否正在加载\n   */\n  private notifyListeners(isLoading: boolean): void {\n    this.listeners.forEach(listener => {\n      try {\n        listener(isLoading)\n      } catch (error) {\n        // 静默处理错误\n      }\n    })\n  }\n\n  /**\n   * 获取当前加载状态详情\n   */\n  getLoadingStates(): Record<string, boolean> {\n    return Object.fromEntries(this.loadingStates)\n  }\n\n  /**\n   * 重置所有加载状态\n   */\n  reset(): void {\n    const wasLoading = this.isAnyLoading()\n    this.loadingStates.clear()\n    \n    if (wasLoading) {\n      this.notifyListeners(false)\n    }\n  }\n}\n\n// 全局实例\nexport const globalLoadingStateManager = new PWALoadingStateManager()"
  },
  {
    "path": "src/utils/permission.ts",
    "content": "// 权限类型定义\nexport interface UserPermissions {\n  discovery: boolean // 发现权限\n  search: boolean // 搜索权限\n  subscribe: boolean // 订阅权限\n  manage: boolean // 管理权限\n}\n\n// 默认权限配置\nexport const DEFAULT_PERMISSIONS: UserPermissions = {\n  discovery: true,\n  search: true,\n  subscribe: true,\n  manage: false,\n}\n\n// 管理员权限配置\nexport const ADMIN_PERMISSIONS: UserPermissions = {\n  discovery: true,\n  search: true,\n  subscribe: true,\n  manage: true,\n}\n\n// 权限检查函数\nexport function hasPermission(userPermissions: any, permission: keyof UserPermissions): boolean {\n  // 如果用户是超级用户，拥有所有权限\n  if (userPermissions?.is_superuser === true) {\n    return true\n  }\n\n  // 检查具体权限\n  const permissions = userPermissions || {}\n  return permissions[permission] === true\n}\n\n// 批量权限检查\nexport function hasAnyPermission(userPermissions: any, permissionList: (keyof UserPermissions)[]): boolean {\n  return permissionList.some(permission => hasPermission(userPermissions, permission))\n}\n\n// 检查是否有所有权限\nexport function hasAllPermissions(userPermissions: any, permissionList: (keyof UserPermissions)[]): boolean {\n  return permissionList.every(permission => hasPermission(userPermissions, permission))\n}\n\n// 根据权限过滤菜单项\nexport function filterMenusByPermission(menus: any[], userPermissions: any): any[] {\n  return menus.filter(menu => {\n    // 如果是超级用户，拥有所有权限\n    if (userPermissions?.is_superuser) {\n      return true\n    }\n\n    // 如果菜单没有权限要求，默认显示\n    if (!menu.permission) {\n      return true\n    }\n\n    // 检查用户是否拥有所需权限\n    return hasPermission(userPermissions, menu.permission)\n  })\n}\n"
  },
  {
    "path": "src/utils/pluginSidebarNav.ts",
    "content": "import type { Composer } from 'vue-i18n'\nimport type { NavMenu } from '@/@layouts/types'\nimport type { PluginSidebarNavItem } from '@/api/types'\nimport { pluginSidebarSectionToHeaderKey } from '@/router/i18n-menu'\nimport { filterMenusByPermission } from '@/utils/permission'\n\nexport type PluginNavMenuEntry = {\n  navMenu: NavMenu & { permission?: string }\n  section: string\n}\n\n/**\n * 将后端 sidebar_nav 单项转为侧栏 / 应用中心 共用的 NavMenu\n */\nexport function navMenuFromPluginSidebarItem(\n  item: PluginSidebarNavItem,\n  t: Composer['t'],\n): NavMenu & { permission?: string } {\n  const section = item.section || 'system'\n  const header = pluginSidebarSectionToHeaderKey(section, t)\n  return {\n    title: item.title,\n    icon: item.icon,\n    to: {\n      name: 'plugin-app',\n      params: {\n        pluginId: item.plugin_id,\n        navKey: item.nav_key,\n      },\n    },\n    header,\n    permission: item.permission ?? undefined,\n  } as NavMenu & { permission?: string }\n}\n\n/**\n * 过滤有权限的插件导航项，并保留 section 供 DefaultLayout 分栏插入\n */\nexport function filterPluginSidebarNavEntries(\n  items: PluginSidebarNavItem[],\n  t: Composer['t'],\n  userPermissions: Record<string, unknown>,\n): PluginNavMenuEntry[] {\n  const out: PluginNavMenuEntry[] = []\n  for (const item of items) {\n    const section = item.section || 'system'\n    const navMenu = navMenuFromPluginSidebarItem(item, t)\n    if (!filterMenusByPermission([navMenu], userPermissions).length) {\n      continue\n    }\n    out.push({ navMenu, section })\n  }\n  return out\n}\n"
  },
  {
    "path": "src/utils/requestOptimizer.ts",
    "content": "// 全局请求优化器\n// 自动管理所有API请求的中断，无需手动注册\n\nlet isNavigating = false\nconst activeRequests = new Set<AbortController>()\n\n// 监听路由状态\nexport function setNavigatingState(navigating: boolean) {\n  isNavigating = navigating\n\n  if (navigating) {\n    // 路由切换时，中断所有未完成的请求\n    console.log('Navigation started - aborting active requests')\n    abortAllActiveRequests()\n  }\n}\n\n// 中断所有活跃的请求\nfunction abortAllActiveRequests() {\n  for (const controller of activeRequests) {\n    if (!controller.signal.aborted) {\n      controller.abort()\n    }\n  }\n  activeRequests.clear()\n}\n\n// 清理已完成的请求控制器\nfunction cleanupController(controller: AbortController) {\n  activeRequests.delete(controller)\n}\n\n// 初始化请求优化器\nexport function initializeRequestOptimizer(axiosInstance: any) {\n  // 拦截请求，自动添加 AbortController\n  axiosInstance.interceptors.request.use(\n    (config: any) => {\n      // 如果请求已经有 signal，跳过（避免覆盖手动设置的）\n      if (config.signal) {\n        return config\n      }\n\n      // 创建新的 AbortController\n      const controller = new AbortController()\n      config.signal = controller.signal\n\n      // 将控制器添加到活跃列表\n      activeRequests.add(controller)\n\n      // 监听请求完成事件来清理控制器\n      const cleanup = () => cleanupController(controller)\n\n      // 监听中断事件\n      controller.signal.addEventListener('abort', cleanup, { once: true })\n\n      return config\n    },\n    (error: any) => {\n      return Promise.reject(error)\n    },\n  )\n\n  // 拦截响应，清理对应的控制器\n  axiosInstance.interceptors.response.use(\n    (response: any) => {\n      // 从配置中获取 signal 对应的控制器并清理\n      if (response.config?.signal) {\n        const controller = Array.from(activeRequests).find(ctrl => ctrl.signal === response.config.signal)\n        if (controller) {\n          cleanupController(controller)\n        }\n      }\n      return response\n    },\n    (error: any) => {\n      // 错误时也要清理控制器\n      if (error.config?.signal) {\n        const controller = Array.from(activeRequests).find(ctrl => ctrl.signal === error.config.signal)\n        if (controller) {\n          cleanupController(controller)\n        }\n      }\n      return Promise.reject(error)\n    },\n  )\n\n  console.log('Request optimizer initialized - all requests will be auto-managed')\n}\n\n// 获取当前活跃请求数量（调试用）\nexport function getActiveRequestsCount() {\n  return activeRequests.size\n}\n\n// 手动中断所有请求（备用方法）\nexport function abortAllRequests() {\n  abortAllActiveRequests()\n}\n"
  },
  {
    "path": "src/utils/sseManager.ts",
    "content": "/**\n * SSE连接管理器\n * 优化后台SSE连接，减少iOS系统杀掉应用的概率\n */\nexport class SSEManager {\n  private eventSource: EventSource | null = null\n  private url: string\n  private isBackground = false\n  private reconnectTimer: number | null = null\n  private backgroundCloseTimer: number | null = null\n  private listeners: Map<string, (event: MessageEvent) => void> = new Map()\n  private options: {\n    backgroundCloseDelay: number\n    reconnectDelay: number\n    maxReconnectAttempts: number\n  }\n  private reconnectAttempts = 0\n  private isConnecting = false\n\n  constructor(url: string, options: Partial<typeof SSEManager.prototype.options> = {}) {\n    this.url = url\n    this.options = {\n      backgroundCloseDelay: 5000, // 5秒后关闭后台连接\n      reconnectDelay: 3000, // 3秒后重连\n      maxReconnectAttempts: 3,\n      ...options,\n    }\n\n    this.setupVisibilityListener()\n  }\n\n  private setupVisibilityListener() {\n    document.addEventListener('visibilitychange', () => {\n      if (document.hidden) {\n        this.handleBackground()\n      } else {\n        this.handleForeground()\n      }\n    })\n\n    // 页面卸载时关闭连接\n    window.addEventListener('beforeunload', () => {\n      this.close()\n    })\n  }\n\n  private handleBackground() {\n    this.isBackground = true\n\n    // 延迟关闭SSE连接，避免频繁切换\n    if (this.backgroundCloseTimer) {\n      clearTimeout(this.backgroundCloseTimer)\n    }\n\n    this.backgroundCloseTimer = window.setTimeout(() => {\n      if (this.isBackground && this.eventSource) {\n        this.eventSource.close()\n        this.eventSource = null\n      }\n    }, this.options.backgroundCloseDelay)\n  }\n\n  private handleForeground() {\n    this.isBackground = false\n\n    // 清除后台关闭定时器\n    if (this.backgroundCloseTimer) {\n      clearTimeout(this.backgroundCloseTimer)\n      this.backgroundCloseTimer = null\n    }\n\n    // 只有在有活跃监听器时才重新建立连接\n    if (this.listeners.size > 0 && (!this.eventSource || this.eventSource.readyState === EventSource.CLOSED)) {\n      this.reconnectSSE()\n    }\n  }\n\n  private reconnectSSE(attemptCount = 0) {\n    if (attemptCount >= this.options.maxReconnectAttempts) {\n      return\n    }\n\n    if (this.isConnecting) {\n      return\n    }\n\n    // 如果没有活跃的监听器，不进行重连\n    if (this.listeners.size === 0) {\n      return\n    }\n\n    this.isConnecting = true\n    this.reconnectAttempts = attemptCount\n\n    try {\n      this.eventSource = new EventSource(this.url)\n\n      this.eventSource.onopen = () => {\n        this.isConnecting = false\n        this.reconnectAttempts = 0\n      }\n\n      this.eventSource.onerror = error => {\n        this.isConnecting = false\n\n        if (this.eventSource?.readyState === EventSource.CLOSED) {\n          // 连接已关闭，尝试重连\n          if (this.reconnectTimer) {\n            clearTimeout(this.reconnectTimer)\n          }\n\n          this.reconnectTimer = window.setTimeout(() => {\n            if (!this.isBackground && this.listeners.size > 0) {\n              this.reconnectSSE(this.reconnectAttempts + 1)\n            }\n          }, this.options.reconnectDelay)\n        }\n      }\n\n      this.eventSource.onmessage = event => {\n        // 分发消息给所有监听器\n        this.listeners.forEach((listener, listenerId) => {\n          try {\n            // 为每个监听器提供独立的错误处理\n            listener(event)\n          } catch (error) {\n            console.error(`SSE: 监听器错误 [${listenerId}]`, error)\n          }\n        })\n      }\n    } catch (error) {\n      this.isConnecting = false\n\n      // 连接创建失败，尝试重连\n      if (this.reconnectTimer) {\n        clearTimeout(this.reconnectTimer)\n      }\n\n      this.reconnectTimer = window.setTimeout(() => {\n        if (!this.isBackground && this.listeners.size > 0) {\n          this.reconnectSSE(this.reconnectAttempts + 1)\n        }\n      }, this.options.reconnectDelay)\n    }\n  }\n\n  /**\n   * 添加消息监听器\n   */\n  addMessageListener(id: string, listener: (event: MessageEvent) => void) {\n    this.listeners.set(id, listener)\n\n    // 如果还没有连接且不在后台，现在建立连接\n    if (!this.eventSource && !this.isBackground && !this.isConnecting) {\n      this.reconnectSSE()\n    }\n  }\n\n  /**\n   * 移除消息监听器\n   */\n  removeMessageListener(id: string) {\n    this.listeners.delete(id)\n\n    // 如果没有监听器了，关闭连接\n    if (this.listeners.size === 0) {\n      this.close()\n    }\n  }\n\n  /**\n   * 关闭连接\n   */\n  close() {\n    if (this.eventSource) {\n      this.eventSource.close()\n      this.eventSource = null\n    }\n\n    if (this.reconnectTimer) {\n      clearTimeout(this.reconnectTimer)\n      this.reconnectTimer = null\n    }\n\n    if (this.backgroundCloseTimer) {\n      clearTimeout(this.backgroundCloseTimer)\n      this.backgroundCloseTimer = null\n    }\n\n    this.listeners.clear()\n    this.isConnecting = false\n    this.reconnectAttempts = 0\n  }\n\n  /**\n   * 获取连接状态\n   */\n  get readyState(): number {\n    return this.eventSource?.readyState ?? EventSource.CLOSED\n  }\n\n  /**\n   * 获取连接URL\n   */\n  get connectionUrl(): string {\n    return this.url\n  }\n\n  /**\n   * 强制重新连接\n   */\n  forceReconnect() {\n    this.close()\n    if (!this.isBackground && this.listeners.size > 0) {\n      this.reconnectSSE()\n    }\n  }\n\n  /**\n   * 检查是否有活跃的监听器\n   */\n  get hasActiveListeners(): boolean {\n    return this.listeners.size > 0\n  }\n\n  /**\n   * 获取当前重连次数\n   */\n  get currentReconnectAttempts(): number {\n    return this.reconnectAttempts\n  }\n\n  /**\n   * 检查是否达到最大重连次数\n   */\n  get hasReachedMaxAttempts(): boolean {\n    return this.reconnectAttempts >= this.options.maxReconnectAttempts\n  }\n}\n\n/**\n * SSE管理器单例\n */\nclass SSEManagerSingleton {\n  private managers: Map<string, SSEManager> = new Map()\n\n  /**\n   * 获取或创建SSE管理器\n   * @param url SSE连接URL\n   * @param options SSE选项\n   * @returns SSE管理器实例\n   */\n  getManager(url: string, options?: ConstructorParameters<typeof SSEManager>[1]): SSEManager {\n    // 使用完整的URL作为key，确保不同路径的SSE连接不会复用\n    const managerKey = url\n    if (!this.managers.has(managerKey)) {\n      this.managers.set(managerKey, new SSEManager(url, options))\n    }\n    return this.managers.get(managerKey)!\n  }\n\n  /**\n   * 获取或创建独立的SSE管理器（为每个监听器创建独立连接）\n   * @param url SSE连接URL\n   * @param listenerId 监听器ID\n   * @param options SSE选项\n   * @returns SSE管理器实例\n   */\n  getIndependentManager(\n    url: string,\n    listenerId: string,\n    options?: ConstructorParameters<typeof SSEManager>[1],\n  ): SSEManager {\n    // 使用URL + 监听器ID作为key，确保每个监听器都有独立的连接\n    const managerKey = `${url}::${listenerId}`\n    if (!this.managers.has(managerKey)) {\n      this.managers.set(managerKey, new SSEManager(url, options))\n    }\n    return this.managers.get(managerKey)!\n  }\n\n  /**\n   * 关闭指定URL的管理器\n   */\n  closeManager(url: string) {\n    const manager = this.managers.get(url)\n    if (manager) {\n      manager.close()\n      this.managers.delete(url)\n    }\n  }\n\n  /**\n   * 关闭所有管理器\n   */\n  closeAllManagers() {\n    this.managers.forEach(manager => manager.close())\n    this.managers.clear()\n  }\n}\n\nexport const sseManagerSingleton = new SSEManagerSingleton()\n"
  },
  {
    "path": "src/utils/themeManager.ts",
    "content": "// 主题管理器 - 动态加载主题CSS\nexport interface ThemeConfig {\n  name: string\n  cssPath: string\n  isLoaded: boolean\n}\n\nclass ThemeManager {\n  private themes: Map<string, ThemeConfig> = new Map()\n  private currentTheme: string = 'default'\n  private loadedLinks: Map<string, HTMLLinkElement> = new Map()\n\n  constructor() {\n    // 注册所有可用主题\n    this.registerTheme('default', '')\n    this.registerTheme('light', '')\n    this.registerTheme('dark', '')\n    this.registerTheme('purple', '')\n    this.registerTheme('auto', '')\n    // 只有透明主题有特定的CSS文件\n    this.registerTheme('transparent', './src/styles/themes/transparent.css')\n  }\n\n  /**\n   * 注册主题\n   */\n  registerTheme(name: string, cssPath: string): void {\n    this.themes.set(name, {\n      name,\n      cssPath,\n      isLoaded: false,\n    })\n  }\n\n  /**\n   * 获取当前主题\n   */\n  getCurrentTheme(): string {\n    return this.currentTheme\n  }\n\n  /**\n   * 设置主题\n   */\n  async setTheme(themeName: string): Promise<void> {\n    if (!this.themes.has(themeName)) {\n      console.warn(`Theme \"${themeName}\" not found`)\n      return\n    }\n\n    const theme = this.themes.get(themeName)!\n\n    // 清理其他主题的CSS（除了当前要设置的主题）\n    this.unloadOtherThemes()\n\n    // 如果主题有CSS文件，则加载CSS\n    if (theme.cssPath) {\n      try {\n        await this.loadThemeCSS(themeName, theme.cssPath)\n      } catch (error) {\n        console.error(`Failed to load CSS for theme \"${themeName}\":`, error)\n        // 即使CSS加载失败，也继续应用主题（使用默认样式）\n      }\n    }\n\n    // 应用主题（无论是否有CSS文件）\n    this.applyTheme(themeName)\n  }\n\n  /**\n   * 加载主题CSS文件\n   */\n  private async loadThemeCSS(themeName: string, cssPath: string): Promise<void> {\n    // 如果已经加载过，直接返回\n    if (this.loadedLinks.has(themeName)) {\n      return\n    }\n\n    try {\n      // 动态导入CSS模块\n      if (themeName === 'transparent') {\n        await import('@/styles/themes/transparent.scss')\n        this.themes.get(themeName)!.isLoaded = true\n        return\n      }\n\n      // 对于其他主题，使用传统的link方式\n      const link = document.createElement('link')\n      link.rel = 'stylesheet'\n      link.type = 'text/css'\n      link.href = cssPath\n      link.id = `theme-${themeName}`\n\n      // 等待CSS加载完成\n      await new Promise<void>((resolve, reject) => {\n        link.onload = () => {\n          this.loadedLinks.set(themeName, link)\n          this.themes.get(themeName)!.isLoaded = true\n          resolve()\n        }\n        link.onerror = () => {\n          reject(new Error(`Failed to load theme CSS: ${cssPath}`))\n        }\n      })\n\n      // 添加到head\n      document.head.appendChild(link)\n    } catch (error) {\n      console.error(`Error loading theme \"${themeName}\":`, error)\n      throw error\n    }\n  }\n\n  /**\n   * 应用主题到DOM\n   */\n  private applyTheme(themeName: string): void {\n    // 移除之前的主题属性\n    document.documentElement.removeAttribute('data-theme')\n\n    // 设置新主题（除了default主题）\n    if (themeName !== 'default') {\n      document.documentElement.setAttribute('data-theme', themeName)\n    }\n\n    this.currentTheme = themeName\n\n    // 触发主题变更事件\n    this.dispatchThemeChangeEvent(themeName)\n  }\n\n  /**\n   * 卸载主题CSS\n   */\n  unloadTheme(themeName: string): void {\n    const theme = this.themes.get(themeName)\n    if (!theme) return\n\n    // 对于动态导入的CSS，我们无法直接卸载，但可以标记为未加载\n    if (themeName === 'transparent') {\n      theme.isLoaded = false\n      return\n    }\n\n    // 对于传统link方式加载的CSS\n    const link = this.loadedLinks.get(themeName)\n    if (link) {\n      link.remove()\n      this.loadedLinks.delete(themeName)\n      theme.isLoaded = false\n    }\n  }\n\n  /**\n   * 卸载所有主题CSS（除了当前主题）\n   */\n  unloadOtherThemes(): void {\n    for (const [themeName] of this.themes) {\n      if (themeName !== this.currentTheme && this.themes.get(themeName)?.isLoaded) {\n        this.unloadTheme(themeName)\n      }\n    }\n  }\n\n  /**\n   * 获取已注册的主题列表\n   */\n  getAvailableThemes(): string[] {\n    return Array.from(this.themes.keys())\n  }\n\n  /**\n   * 检查主题是否已加载\n   */\n  isThemeLoaded(themeName: string): boolean {\n    return this.themes.get(themeName)?.isLoaded || false\n  }\n\n  /**\n   * 触发主题变更事件\n   */\n  private dispatchThemeChangeEvent(themeName: string): void {\n    const event = new CustomEvent('themechange', {\n      detail: { theme: themeName },\n    })\n    document.dispatchEvent(event)\n  }\n\n  /**\n   * 监听主题变更事件\n   */\n  onThemeChange(callback: (theme: string) => void): void {\n    document.addEventListener('themechange', (event: any) => {\n      callback(event.detail.theme)\n    })\n  }\n\n  /**\n   * 移除主题变更监听器\n   */\n  offThemeChange(callback: (theme: string) => void): void {\n    document.removeEventListener('themechange', (event: any) => {\n      callback(event.detail.theme)\n    })\n  }\n}\n\n// 创建单例实例\nexport const themeManager = new ThemeManager()\n\n// 导出类型\nexport type { ThemeManager }\n"
  },
  {
    "path": "src/views/dashboard/AnalyticsCpu.vue",
    "content": "<script setup lang=\"ts\">\nimport { useTheme } from 'vuetify'\nimport { hexToRgb } from '@layouts/utils'\nimport api from '@/api'\nimport { useI18n } from 'vue-i18n'\nimport { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'\n\n// 国际化\nconst { t } = useI18n()\nconst { useDataRefresh } = useBackgroundOptimization()\n\n// 输入参数\nconst props = defineProps({\n  // 是否允许刷新数据\n  allowRefresh: {\n    type: Boolean,\n    default: true,\n  },\n})\n\nconst vuetifyTheme = useTheme()\n\nconst currentTheme = controlledComputed(\n  () => vuetifyTheme.name.value,\n  () => vuetifyTheme.current.value.colors,\n)\nconst variableTheme = controlledComputed(\n  () => vuetifyTheme.name.value,\n  () => vuetifyTheme.current.value.variables,\n)\n\nconst chartKey = ref(0)\n\n// 时间序列\nconst series = ref([\n  {\n    data: [0],\n  },\n])\n\n// 当前值\nconst current = ref(0)\n\nconst chartOptions = controlledComputed(\n  () => vuetifyTheme.name.value,\n  () => {\n    return {\n      chart: {\n        parentHeightOffset: 0,\n        toolbar: { show: false },\n        animations: { enabled: false },\n      },\n      tooltip: { enabled: false },\n      grid: {\n        borderColor: `rgba(${hexToRgb(String(variableTheme.value['border-color']))},${\n          variableTheme.value['border-opacity']\n        })`,\n        strokeDashArray: 6,\n        xaxis: {\n          lines: { show: false },\n        },\n        yaxis: {\n          lines: { show: true },\n        },\n        padding: {\n          top: -10,\n          left: -7,\n          right: 5,\n          bottom: 5,\n        },\n      },\n      stroke: {\n        width: 3,\n        lineCap: 'butt',\n        curve: 'smooth',\n      },\n      colors: [currentTheme.value.primary],\n      markers: {\n        size: 6,\n        offsetY: 4,\n        offsetX: -2,\n        strokeWidth: 3,\n        colors: ['transparent'],\n        strokeColors: 'transparent',\n        discrete: [\n          {\n            size: 5.5,\n            seriesIndex: 0,\n            strokeColor: currentTheme.value.primary,\n            fillColor: currentTheme.value.surface,\n          },\n        ],\n        hover: { size: 7 },\n      },\n      xaxis: {\n        labels: { show: false },\n        axisTicks: { show: false },\n        axisBorder: { show: false },\n      },\n      yaxis: {\n        labels: { show: false },\n        max: 100,\n      },\n    }\n  },\n)\n\n// 调用API接口获取最新CPU使用率\nasync function loadCpuData() {\n  if (!props.allowRefresh) return\n  try {\n    // 请求数据\n    current.value = (await api.get('dashboard/cpu')) ?? 0\n    // 使用nextTick确保DOM更新完成后再更新图表数据\n    await nextTick()\n    // 添加到序列\n    series.value[0].data.push(current.value)\n    // 序列超过30条记录时，清掉前面的\n    if (series.value[0].data.length > 30) series.value[0].data.shift()\n  } catch (e) {\n    console.log(e)\n  }\n}\n\n// 使用优化的数据刷新定时器\nconst { loading } = useDataRefresh(\n  'analytics-cpu',\n  loadCpuData,\n  2000, // 2秒间隔\n  true // 立即执行\n)\n\nonActivated(() => {\n  nextTick(() => {\n    chartKey.value += 1\n  })\n})\n</script>\n\n<template>\n  <VHover>\n    <template #default=\"hover\">\n      <VCard v-bind=\"hover.props\">\n        <VCardItem>\n          <template #append>\n            <VIcon class=\"cursor-move\" v-if=\"hover.isHovering\">mdi-drag</VIcon>\n          </template>\n          <VCardTitle>CPU</VCardTitle>\n        </VCardItem>\n        <VCardText>\n          <VApexChart :key=\"chartKey\" type=\"line\" :options=\"chartOptions\" :series=\"series\" :height=\"150\" />\n          <p class=\"text-center font-weight-medium mb-0\">{{ t('dashboard.current') }}：{{ current }}%</p>\n        </VCardText>\n      </VCard>\n    </template>\n  </VHover>\n</template>\n"
  },
  {
    "path": "src/views/dashboard/AnalyticsMediaStatistic.vue",
    "content": "<script setup lang=\"ts\">\nimport api from '@/api'\nimport type { MediaStatistic } from '@/api/types'\nimport { useI18n } from 'vue-i18n'\n\n// 国际化\nconst { t } = useI18n()\n\nconst statistics = ref<{ [key: string]: string }[]>([])\n\n// 调用API加载媒体统计数据\nasync function loadMediaStatistic() {\n  try {\n    const res: MediaStatistic = await api.get('dashboard/statistic')\n\n    statistics.value = [\n      {\n        title: t('mediaType.movie'),\n        stats: res.movie_count.toLocaleString(),\n        icon: 'mdi-movie-roll',\n        color: 'primary',\n      },\n      {\n        title: t('mediaType.tv'),\n        stats: res.tv_count.toLocaleString(),\n        icon: 'mdi-television-box',\n        color: 'success',\n      },\n      {\n        title: t('dashboard.episodes'),\n        stats: res.episode_count == null ? t('common.notFetched') : res.episode_count.toLocaleString(),\n        icon: 'mdi-television-classic',\n        color: 'warning',\n      },\n      {\n        title: t('dashboard.users'),\n        stats: res.user_count.toLocaleString(),\n        icon: 'mdi-account',\n        color: 'info',\n      },\n    ]\n  } catch (e) {\n    console.log(e)\n  }\n}\n\nonMounted(() => {\n  loadMediaStatistic()\n})\n\nonActivated(() => {\n  loadMediaStatistic()\n})\n</script>\n\n<template>\n  <VHover>\n    <template #default=\"hover\">\n      <VCard v-bind=\"hover.props\">\n        <VCardItem>\n          <template #append>\n            <VIcon class=\"cursor-move\" v-if=\"hover.isHovering\">mdi-drag</VIcon>\n          </template>\n          <VCardTitle>{{ t('dashboard.mediaStatistic') }}</VCardTitle>\n        </VCardItem>\n\n        <VCardText>\n          <VRow>\n            <VCol v-for=\"item in statistics\" :key=\"item.title\" cols=\"6\" sm=\"3\">\n              <div class=\"d-flex align-center\">\n                <div class=\"me-3\">\n                  <VAvatar :color=\"item.color\" rounded size=\"42\" class=\"elevation-1\">\n                    <VIcon size=\"24\" :icon=\"item.icon\" />\n                  </VAvatar>\n                </div>\n\n                <div class=\"d-flex flex-column\">\n                  <span class=\"text-caption\">\n                    {{ item.title }}\n                  </span>\n                  <span class=\"text-h6\">{{ item.stats }}</span>\n                </div>\n              </div>\n            </VCol>\n          </VRow>\n        </VCardText>\n      </VCard>\n    </template>\n  </VHover>\n</template>\n"
  },
  {
    "path": "src/views/dashboard/AnalyticsMemory.vue",
    "content": "<script setup lang=\"ts\">\nimport { useTheme } from 'vuetify'\nimport { hexToRgb } from '@layouts/utils'\nimport api from '@/api'\nimport { formatBytes } from '@/@core/utils/formatters'\nimport { useI18n } from 'vue-i18n'\nimport { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'\n\n// 国际化\nconst { t } = useI18n()\nconst { useDataRefresh } = useBackgroundOptimization()\n\n// 输入参数\nconst props = defineProps({\n  // 是否允许刷新数据\n  allowRefresh: {\n    type: Boolean,\n    default: true,\n  },\n})\n\nconst vuetifyTheme = useTheme()\n\nconst currentTheme = controlledComputed(\n  () => vuetifyTheme.name.value,\n  () => vuetifyTheme.current.value.colors,\n)\nconst variableTheme = controlledComputed(\n  () => vuetifyTheme.name.value,\n  () => vuetifyTheme.current.value.variables,\n)\n\nconst chartKey = ref(0)\n\n// 时间序列\nconst series = ref([\n  {\n    data: [0],\n  },\n])\n\n// 占用的内存\nconst usedMemory = ref(0)\n// 内存使用百分比\nconst memoryUsage = ref(0)\n\nconst chartOptions = controlledComputed(\n  () => vuetifyTheme.name.value,\n  () => {\n    return {\n      chart: {\n        parentHeightOffset: 0,\n        toolbar: { show: false },\n        animations: { enabled: false },\n      },\n      tooltip: { enabled: false },\n      grid: {\n        borderColor: `rgba(${hexToRgb(String(variableTheme.value['border-color']))},${\n          variableTheme.value['border-opacity']\n        })`,\n        strokeDashArray: 6,\n        xaxis: {\n          lines: { show: false },\n        },\n        yaxis: {\n          lines: { show: true },\n        },\n        padding: {\n          top: -10,\n          left: -7,\n          right: 5,\n          bottom: 5,\n        },\n      },\n      stroke: {\n        width: 3,\n        lineCap: 'butt',\n        curve: 'smooth',\n      },\n      colors: [currentTheme.value.primary],\n      markers: {\n        size: 6,\n        offsetY: 4,\n        offsetX: -2,\n        strokeWidth: 3,\n        colors: ['transparent'],\n        strokeColors: 'transparent',\n        discrete: [\n          {\n            size: 5.5,\n            seriesIndex: 0,\n            strokeColor: currentTheme.value.primary,\n            fillColor: currentTheme.value.surface,\n          },\n        ],\n        hover: { size: 7 },\n      },\n      dataLabels: {\n        enabled: false,\n      },\n      xaxis: {\n        labels: { show: false },\n        axisTicks: { show: false },\n        axisBorder: { show: false },\n      },\n      yaxis: {\n        labels: { show: false },\n        max: 100,\n      },\n    }\n  },\n)\n\n// 调用API接口获取最新内存使用量\nasync function loadMemoryData() {\n  if (!props.allowRefresh) return\n  try {\n    // 请求数据\n    ;[usedMemory.value, memoryUsage.value] = await api.get('dashboard/memory')\n    // 使用nextTick确保DOM更新完成后再更新图表数据\n    await nextTick()\n    series.value[0].data.push(memoryUsage.value)\n    // 序列超过30条记录时，清掉前面的\n    if (series.value[0].data.length > 30) series.value[0].data.shift()\n  } catch (e) {\n    console.log(e)\n  }\n}\n\n// 使用优化的数据刷新定时器\nconst { loading } = useDataRefresh(\n  'analytics-memory',\n  loadMemoryData,\n  3000, // 3秒间隔\n  true // 立即执行\n)\n\nonActivated(() => {\n  // 使用nextTick确保DOM准备完成后再更新chartKey\n  nextTick(() => {\n    chartKey.value += 1\n  })\n})\n</script>\n\n<template>\n  <VHover>\n    <template #default=\"hover\">\n      <VCard v-bind=\"hover.props\">\n        <VCardItem>\n          <template #append>\n            <VIcon class=\"cursor-move\" v-if=\"hover.isHovering\">mdi-drag</VIcon>\n          </template>\n          <VCardTitle>{{ t('dashboard.memory') }}</VCardTitle>\n        </VCardItem>\n        <VCardText>\n          <VApexChart :key=\"chartKey\" type=\"area\" :options=\"chartOptions\" :series=\"series\" :height=\"150\" />\n          <p class=\"text-center font-weight-medium mb-0\">{{ t('dashboard.current') }}：{{ formatBytes(usedMemory) }}</p>\n        </VCardText>\n      </VCard>\n    </template>\n  </VHover>\n</template>\n"
  },
  {
    "path": "src/views/dashboard/AnalyticsNetwork.vue",
    "content": "<script setup lang=\"ts\">\nimport { useTheme } from 'vuetify'\nimport { hexToRgb } from '@layouts/utils'\nimport api from '@/api'\nimport { useI18n } from 'vue-i18n'\nimport { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'\n\n// 国际化\nconst { t } = useI18n()\nconst { useDataRefresh } = useBackgroundOptimization()\n\n// 输入参数\nconst props = defineProps({\n  // 是否允许刷新数据\n  allowRefresh: {\n    type: Boolean,\n    default: true,\n  },\n})\n\nconst vuetifyTheme = useTheme()\n\nconst currentTheme = controlledComputed(\n  () => vuetifyTheme.name.value,\n  () => vuetifyTheme.current.value.colors,\n)\nconst variableTheme = controlledComputed(\n  () => vuetifyTheme.name.value,\n  () => vuetifyTheme.current.value.variables,\n)\n\nconst chartKey = ref(0)\n\n// 时间序列 - 上行和下行流量\nconst series = ref([\n  {\n    name: '上行流量',\n    data: [0],\n  },\n  {\n    name: '下行流量',\n    data: [0],\n  },\n])\n\n// 当前值\nconst currentUpload = ref(0)\nconst currentDownload = ref(0)\n\n// 格式化流量显示\nfunction formatBytes(bytes: number): string {\n  if (bytes === 0) return '0 B/s'\n  const k = 1024\n  const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s']\n  const i = Math.floor(Math.log(bytes) / Math.log(k))\n  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]\n}\n\nconst chartOptions = controlledComputed(\n  () => vuetifyTheme.name.value,\n  () => {\n    return {\n      chart: {\n        parentHeightOffset: 0,\n        toolbar: { show: false },\n        animations: { enabled: false },\n      },\n      tooltip: {\n        enabled: false,\n      },\n      grid: {\n        borderColor: `rgba(${hexToRgb(String(variableTheme.value['border-color']))},${\n          variableTheme.value['border-opacity']\n        })`,\n        strokeDashArray: 6,\n        xaxis: {\n          lines: { show: false },\n        },\n        yaxis: {\n          lines: { show: true },\n        },\n        padding: {\n          top: -10,\n          left: -7,\n          right: 5,\n          bottom: 5,\n        },\n      },\n      stroke: {\n        width: 3,\n        lineCap: 'butt',\n        curve: 'smooth',\n      },\n      colors: [currentTheme.value.warning, currentTheme.value.info],\n      markers: {\n        size: 6,\n        offsetY: 4,\n        offsetX: -2,\n        strokeWidth: 3,\n        colors: ['transparent'],\n        strokeColors: 'transparent',\n        discrete: [\n          {\n            size: 5.5,\n            seriesIndex: 0,\n            strokeColor: currentTheme.value.warning,\n            fillColor: currentTheme.value.surface,\n          },\n          {\n            size: 5.5,\n            seriesIndex: 1,\n            strokeColor: currentTheme.value.info,\n            fillColor: currentTheme.value.surface,\n          },\n        ],\n        hover: { size: 7 },\n      },\n      xaxis: {\n        labels: { show: false },\n        axisTicks: { show: false },\n        axisBorder: { show: false },\n      },\n      yaxis: {\n        labels: { show: false },\n      },\n      legend: {\n        show: true,\n        position: 'top',\n        horizontalAlign: 'left',\n        fontSize: '12px',\n        fontFamily: 'inherit',\n      },\n    }\n  },\n)\n\n// 调用API接口获取最新网络流量\nasync function getNetworkUsage() {\n  if (!props.allowRefresh) return\n  try {\n    // 请求数据 - 接口返回 [上行流量, 下行流量]\n    const data: [number, number] = (await api.get('dashboard/network')) ?? [0, 0]\n    currentUpload.value = data[0] || 0\n    currentDownload.value = data[1] || 0\n\n    // 使用nextTick确保DOM更新完成后再更新图表数据\n    await nextTick()\n\n    // 添加到序列\n    series.value[0].data.push(currentUpload.value)\n    series.value[1].data.push(currentDownload.value)\n\n    // 序列超过30条记录时，清掉前面的\n    if (series.value[0].data.length > 30) {\n      series.value[0].data.shift()\n      series.value[1].data.shift()\n    }\n  } catch (e) {\n    console.log(e)\n  }\n}\n\n// 使用优化的数据刷新定时器\nuseDataRefresh(\n  'dashboard-network',\n  getNetworkUsage,\n  2000, // 2秒间隔\n  true // 立即执行\n)\n\nonActivated(() => {\n  nextTick(() => {\n    chartKey.value += 1\n  })\n})\n</script>\n\n<template>\n  <VHover>\n    <template #default=\"hover\">\n      <VCard v-bind=\"hover.props\">\n        <VCardItem>\n          <template #append>\n            <VIcon class=\"cursor-move\" v-if=\"hover.isHovering\">mdi-drag</VIcon>\n          </template>\n          <VCardTitle>{{ t('dashboard.network') }}</VCardTitle>\n        </VCardItem>\n        <VCardText>\n          <VApexChart :key=\"chartKey\" type=\"line\" :options=\"chartOptions\" :series=\"series\" :height=\"150\" />\n          <div class=\"d-flex justify-space-between\">\n            <p class=\"text-center font-weight-medium mb-0\">\n              <span class=\"text-warning\">{{ t('dashboard.upload') }}</span\n              >：{{ formatBytes(currentUpload) }}\n            </p>\n            <p class=\"text-center font-weight-medium mb-0\">\n              <span class=\"text-info\">{{ t('dashboard.download') }}</span\n              >：{{ formatBytes(currentDownload) }}\n            </p>\n          </div>\n        </VCardText>\n      </VCard>\n    </template>\n  </VHover>\n</template>\n"
  },
  {
    "path": "src/views/dashboard/AnalyticsProcesses.vue",
    "content": "<script lang=\"ts\" setup>\nimport { formatSeconds } from '@/@core/utils/formatters'\nimport api from '@/api'\nimport type { Process } from '@/api/types'\nimport { useI18n } from 'vue-i18n'\nimport { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'\n\n// 国际化\nconst { t } = useI18n()\nconst { useDataRefresh } = useBackgroundOptimization()\n\n// 表头\nconst headers = [\n  t('dashboard.processes.pid'),\n  t('dashboard.processes.name'),\n  t('dashboard.processes.runtime'),\n  t('dashboard.processes.memory'),\n]\n\n// 数据列表\nconst processList = ref<Process[]>([])\n\n// 调用API加载数据\nasync function loadProcessList() {\n  try {\n    const res: Process[] = await api.get('dashboard/processes')\n\n    processList.value = res\n  } catch (e) {\n    console.log(e)\n  }\n}\n\n// 使用优化的数据刷新定时器\nuseDataRefresh(\n  'dashboard-processes',\n  loadProcessList,\n  5000, // 5秒间隔\n  true // 立即执行\n)\n</script>\n\n<template>\n  <VCard>\n    <VCardItem>\n      <template #append>\n        <VIcon class=\"cursor-move\">mdi-drag</VIcon>\n      </template>\n      <VCardTitle>{{ t('dashboard.processes.title') }}</VCardTitle>\n    </VCardItem>\n    <VTable item-key=\"fullName\" class=\"table-rounded\" hide-default-footer disable-sort>\n      <thead>\n        <tr>\n          <th v-for=\"header in headers\" :id=\"header\" :key=\"header\">\n            {{ header }}\n          </th>\n        </tr>\n      </thead>\n      <tbody>\n        <tr v-for=\"row in processList\" :key=\"row.pid\">\n          <td class=\"text-sm\" v-text=\"row.pid\" />\n          <!-- name -->\n          <td>\n            <h6 class=\"text-sm font-weight-medium\">\n              {{ row.name }}\n            </h6>\n          </td>\n          <td class=\"text-sm\" v-text=\"formatSeconds(row.run_time)\" />\n          <td class=\"text-sm\" v-text=\"`${row.memory} MB`\" />\n        </tr>\n      </tbody>\n    </VTable>\n  </VCard>\n</template>\n"
  },
  {
    "path": "src/views/dashboard/AnalyticsScheduler.vue",
    "content": "<script setup lang=\"ts\">\nimport api from '@/api'\nimport type { ScheduleInfo } from '@/api/types'\nimport { useI18n } from 'vue-i18n'\nimport { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'\n\n// 国际化\nconst { t } = useI18n()\nconst { useDataRefresh } = useBackgroundOptimization()\n\n// 输入参数\nconst props = defineProps({\n  // 是否允许刷新数据\n  allowRefresh: {\n    type: Boolean,\n    default: true,\n  },\n})\n\n// 定时服务列表\nconst schedulerList = ref<ScheduleInfo[]>([])\n\n// 调用API加载定时服务列表\nasync function loadSchedulerList() {\n  if (!props.allowRefresh) {\n    return\n  }\n  try {\n    const res: ScheduleInfo[] = await api.get('dashboard/schedule')\n\n    schedulerList.value = res\n  } catch (e) {\n    console.log(e)\n  }\n}\n\n// 使用优化的数据刷新定时器\nuseDataRefresh(\n  'dashboard-scheduler',\n  loadSchedulerList,\n  60000, // 60秒间隔\n  true // 立即执行\n)\n</script>\n\n<template>\n  <VHover>\n    <template #default=\"hover\">\n      <VCard v-bind=\"hover.props\">\n        <VCardItem>\n          <template #append>\n            <VIcon class=\"cursor-move\" v-if=\"hover.isHovering\">mdi-drag</VIcon>\n          </template>\n          <VCardTitle>{{ t('dashboard.scheduler') }}</VCardTitle>\n        </VCardItem>\n\n        <VCardText>\n          <VList class=\"card-list\" height=\"250\">\n            <VListItem v-for=\"item in schedulerList\" :key=\"item.id\">\n              <template #prepend>\n                <VAvatar size=\"40\" variant=\"tonal\" color=\"\" class=\"me-3\">\n                  {{ item.name[0] }}\n                </VAvatar>\n              </template>\n\n              <VListItemTitle class=\"mb-1\">\n                <span class=\"text-sm font-weight-medium\">{{ item.name }}</span>\n              </VListItemTitle>\n\n              <VListItemSubtitle class=\"text-xs\">\n                {{ item.next_run }}\n              </VListItemSubtitle>\n\n              <template #append>\n                <div>\n                  <h4 class=\"font-weight-medium\">\n                    {{ item.status }}\n                  </h4>\n                </div>\n              </template>\n            </VListItem>\n            <VListItem v-if=\"schedulerList.length === 0\">\n              <VListItemTitle class=\"text-center\"> {{ t('dashboard.noSchedulers') }} </VListItemTitle>\n            </VListItem>\n          </VList>\n        </VCardText>\n      </VCard>\n    </template>\n  </VHover>\n</template>\n\n<style lang=\"scss\" scoped>\n.card-list {\n  --v-card-list-gap: 1.5rem;\n}\n\n.card-list::-webkit-scrollbar {\n  display: none;\n}\n</style>\n"
  },
  {
    "path": "src/views/dashboard/AnalyticsSpeed.vue",
    "content": "<script setup lang=\"ts\">\nimport { formatFileSize } from '@/@core/utils/formatters'\nimport api from '@/api'\nimport type { DownloaderInfo } from '@/api/types'\nimport { useI18n } from 'vue-i18n'\nimport { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'\n\n// 国际化\nconst { t } = useI18n()\nconst { useDataRefresh } = useBackgroundOptimization()\n\n// 输入参数\nconst props = defineProps({\n  // 是否允许刷新数据\n  allowRefresh: {\n    type: Boolean,\n    default: true,\n  },\n})\n\n// 下载器信息\nconst downloadInfo = ref<DownloaderInfo>({\n  // 下载速度\n  download_speed: 0,\n\n  // 上传速度\n  upload_speed: 0,\n\n  // 下载量\n  download_size: 0,\n\n  // 上传量\n  upload_size: 0,\n\n  // 剩余空间\n  free_space: 0,\n})\n\n// 显示项\nconst infoItems = ref([\n  {\n    avatar: '',\n    title: '',\n    amount: '',\n  },\n])\n\n// 调用API查询下载器数据\nasync function loadDownloaderInfo() {\n  if (!props.allowRefresh) {\n    return\n  }\n\n  try {\n    const res: DownloaderInfo = await api.get('dashboard/downloader')\n\n    downloadInfo.value = res\n    infoItems.value = [\n      {\n        avatar: 'mdi-cloud-upload',\n        title: t('dashboard.speed.totalUpload'),\n        amount: formatFileSize(res.upload_size),\n      },\n      {\n        avatar: 'mdi-download-box',\n        title: t('dashboard.speed.totalDownload'),\n        amount: formatFileSize(res.download_size),\n      },\n      {\n        avatar: 'mdi-content-save',\n        title: t('dashboard.speed.freeSpace'),\n        amount: formatFileSize(res.free_space),\n      },\n    ]\n  } catch (e) {\n    console.log(e)\n  }\n}\n\n// 使用优化的数据刷新定时器\nconst { loading } = useDataRefresh(\n  'analytics-speed',\n  loadDownloaderInfo,\n  3000, // 3秒间隔\n  true // 立即执行\n)\n</script>\n\n<template>\n  <VHover>\n    <template #default=\"hover\">\n      <VCard v-bind=\"hover.props\">\n        <VCardItem>\n          <template #append>\n            <VIcon class=\"cursor-move\" v-if=\"hover.isHovering\">mdi-drag</VIcon>\n          </template>\n          <VCardTitle>{{ t('dashboard.realTimeSpeed') }}</VCardTitle>\n        </VCardItem>\n\n        <VCardText class=\"pt-4\">\n          <div>\n            <p class=\"text-h5 me-2\">↑{{ formatFileSize(downloadInfo.upload_speed) }}/s</p>\n            <p class=\"text-h4 me-2\">↓{{ formatFileSize(downloadInfo.download_speed) }}/s</p>\n          </div>\n          <VList class=\"card-list mt-9\">\n            <VListItem v-for=\"item in infoItems\" :key=\"item.title\">\n              <template #prepend>\n                <VIcon rounded :icon=\"item.avatar\" />\n              </template>\n\n              <VListItemTitle class=\"text-sm font-weight-medium mb-1\">\n                {{ item.title }}\n              </VListItemTitle>\n\n              <template #append>\n                <div>\n                  <h6 class=\"text-sm font-weight-medium mb-2\">\n                    {{ item.amount }}\n                  </h6>\n                </div>\n              </template>\n            </VListItem>\n          </VList>\n        </VCardText>\n      </VCard>\n    </template>\n  </VHover>\n</template>\n\n<style lang=\"scss\" scoped>\n.card-list {\n  --v-card-list-gap: 1rem;\n}\n</style>\n"
  },
  {
    "path": "src/views/dashboard/AnalyticsStorage.vue",
    "content": "<script setup lang=\"ts\">\nimport { useTheme } from 'vuetify'\nimport { formatFileSize } from '@/@core/utils/formatters'\nimport api from '@/api'\nimport trophy from '@images/misc/storage.png'\nimport triangleDark from '@images/misc/triangle-dark.png'\nimport triangleLight from '@images/misc/triangle-light.png'\nimport { useI18n } from 'vue-i18n'\n\n// 国际化\nconst { t } = useI18n()\n\nconst { global } = useTheme()\n\nconst triangleBg = computed(() => (global.name.value === 'light' ? triangleLight : triangleDark))\n\n// 总存储空间\nconst storage = ref(0)\n\n// 已使用存储空间\nconst used = ref(0)\n\n// 计算已使用存储空间百分比，精确到小数点后1位\nconst usedPercent = computed(() => {\n  return Math.round((used.value / (storage.value || 1)) * 1000) / 10\n})\n\n// 调用API，查询存储空间\nasync function getStorage() {\n  try {\n    const res: Storage = await api.get('dashboard/storage')\n\n    storage.value = res.total_storage\n    used.value = res.used_storage\n  } catch (e) {\n    console.log(e)\n  }\n}\n\nonMounted(() => {\n  getStorage()\n})\n\nonActivated(() => {\n  getStorage()\n})\n</script>\n\n<template>\n  <VHover>\n    <template #default=\"hover\">\n      <VCard v-bind=\"hover.props\">\n        <!-- Triangle Background -->\n        <VImg :src=\"triangleBg\" class=\"triangle-bg flip-in-rtl\" />\n        <VCardItem>\n          <template #append>\n            <VIcon class=\"cursor-move\" v-if=\"hover.isHovering\">mdi-drag</VIcon>\n          </template>\n          <VCardTitle>{{ t('dashboard.storage') }}</VCardTitle>\n        </VCardItem>\n        <VCardText>\n          <h5 class=\"text-2xl font-weight-medium text-primary\">\n            {{ formatFileSize(storage) }}\n          </h5>\n          <p class=\"mt-2\">{{ t('storage.usedPercent', { percent: usedPercent }) }} 🚀</p>\n          <p class=\"mt-1\">\n            <VProgressLinear :model-value=\"usedPercent\" color=\"primary\" />\n          </p>\n        </VCardText>\n        <!-- Trophy -->\n        <VImg :src=\"trophy\" class=\"trophy\" />\n      </VCard>\n    </template>\n  </VHover>\n</template>\n\n<style lang=\"scss\" scoped>\n@use '@layouts/styles/mixins' as layoutsMixins;\n\n.v-card .triangle-bg {\n  position: absolute;\n  inline-size: 8.75rem;\n  inset-block-end: 0;\n  inset-inline-end: 0;\n}\n\n.v-card .trophy {\n  position: absolute;\n  inline-size: 4.9375rem;\n  inset-block-end: 2rem;\n  inset-inline-end: 2rem;\n}\n</style>\n"
  },
  {
    "path": "src/views/dashboard/AnalyticsWeeklyOverview.vue",
    "content": "<script setup lang=\"ts\">\nimport { useTheme } from 'vuetify'\nimport api from '@/api'\nimport { hexToRgb } from '@layouts/utils'\nimport { useUserStore } from '@/stores'\nimport { useI18n } from 'vue-i18n'\n\n// 国际化\nconst { t } = useI18n()\n\nconst vuetifyTheme = useTheme()\n\n// 用户 Store\nconst userStore = useUserStore()\nconst superUser = userStore.superUser\n\nconst options = controlledComputed(\n  () => vuetifyTheme.name.value,\n  () => {\n    const currentTheme = ref(vuetifyTheme.current.value.colors)\n    const variableTheme = ref(vuetifyTheme.current.value.variables)\n\n    const disabledColor = `rgba(${hexToRgb(currentTheme.value['on-surface'])},${\n      variableTheme.value['disabled-opacity']\n    })`\n\n    const borderColor = `rgba(${hexToRgb(String(variableTheme.value['border-color']))},${\n      variableTheme.value['border-opacity']\n    })`\n\n    return {\n      chart: {\n        parentHeightOffset: 0,\n        toolbar: { show: false },\n      },\n      plotOptions: {\n        bar: {\n          borderRadius: 9,\n          distributed: true,\n          columnWidth: '40%',\n          endingShape: 'rounded',\n          startingShape: 'rounded',\n        },\n      },\n      stroke: {\n        width: 2,\n        colors: [currentTheme.value.surface],\n      },\n      legend: { show: false },\n      tooltip: {\n        enabled: false,\n      },\n      grid: {\n        borderColor,\n        strokeDashArray: 7,\n        padding: {\n          top: -1,\n          right: 0,\n          left: -12,\n          bottom: 5,\n        },\n      },\n      dataLabels: { enabled: false },\n      colors: [currentTheme.value.primary],\n      states: {\n        hover: { filter: { type: 'none' } },\n        active: { filter: { type: 'none' } },\n      },\n      xaxis: {\n        categories: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],\n        tickPlacement: 'on',\n        labels: { show: false },\n        crosshairs: { opacity: 0 },\n        axisTicks: { show: false },\n        axisBorder: { show: false },\n      },\n      yaxis: {\n        show: true,\n        tickAmount: 4,\n        labels: {\n          offsetX: -17,\n          style: {\n            colors: disabledColor,\n            fontSize: '12px',\n          },\n\n          formatter: (value: number) => {\n            if (value > 999) {\n              return (value / 1000).toFixed(1) + 'k'\n            } else {\n              return value.toString()\n            }\n          },\n        },\n      },\n    }\n  },\n)\n\n// 图表数据\nconst series = ref([{ data: [0, 0, 0, 0, 0, 0, 0] }])\n\n// 总数\nconst totalCount = computed(() => series.value[0].data.reduce((a, b) => a + b, 0))\n\n// 调用API接口获取数据近7天数据\nasync function getWeeklyData() {\n  try {\n    const res: number[] = await api.get('dashboard/transfer')\n    // 使用nextTick确保DOM更新完成后再更新图表数据\n    await nextTick()\n    series.value = [{ data: res }]\n  } catch (e) {\n    console.log(e)\n  }\n}\n\nonMounted(() => {\n  // 延迟启动，确保组件完全挂载\n  nextTick(() => {\n    getWeeklyData()\n  })\n})\n\nonActivated(() => {\n  // 使用nextTick确保DOM准备完成后再获取数据\n  nextTick(() => {\n    getWeeklyData()\n  })\n})\n</script>\n\n<template>\n  <VHover>\n    <template #default=\"hover\">\n      <VCard v-bind=\"hover.props\">\n        <VCardItem>\n          <template #append>\n            <VIcon class=\"cursor-move\" v-if=\"hover.isHovering\">mdi-drag</VIcon>\n          </template>\n          <VCardTitle>{{ t('dashboard.weeklyOverview') }}</VCardTitle>\n        </VCardItem>\n\n        <VCardText>\n          <VApexChart type=\"bar\" :options=\"options\" :series=\"series\" :height=\"160\" />\n          <div class=\"d-flex align-center mb-3\">\n            <h5 class=\"text-h5 me-4\">\n              {{ totalCount }}\n            </h5>\n            <p>{{ t('dashboard.weeklyOverviewDescription', { count: totalCount }) }} 😎</p>\n          </div>\n\n          <VBtn v-if=\"superUser\" block to=\"/history\"> {{ t('common.viewDetails') }} </VBtn>\n        </VCardText>\n      </VCard>\n    </template>\n  </VHover>\n</template>\n"
  },
  {
    "path": "src/views/dashboard/MediaServerLatest.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, onMounted } from 'vue'\nimport api from '@/api'\nimport type { MediaServerConf, MediaServerPlayItem } from '@/api/types'\nimport PosterCard from '@/components/cards/PosterCard.vue'\nimport { useI18n } from 'vue-i18n'\n\n// 国际化\nconst { t } = useI18n()\n\n// 最近入库列表\nconst latestList = ref<{ [key: string]: MediaServerPlayItem[] }>({})\n\n// 所有媒体服务器设置\nconst mediaServers = ref<MediaServerConf[]>([])\n\n// 调用API查询媒体服务器设置\nasync function loadMediaServerSetting() {\n  try {\n    const response: { data: { value: MediaServerConf[] } } = await api.get('system/setting/MediaServers')\n    mediaServers.value = response.data?.value ?? []\n  } catch (error) {\n    console.log(t('dashboard.errors.loadMediaServer'), error)\n  }\n}\n\n// 调用API查询最近入库\nasync function loadLatest(server: string) {\n  try {\n    const response: MediaServerPlayItem[] = await api.get('mediaserver/latest', { params: { server } })\n    // 仅在有数据时赋值\n    if (response && response.length > 0) {\n      latestList.value[server] = response\n    }\n  } catch (e) {\n    console.log(t('dashboard.errors.loadLatest', { server }), e)\n  }\n}\n\n// 加载数据\nasync function loadData() {\n  await loadMediaServerSetting()\n  const enabledServers = mediaServers.value.filter(server => server.enabled)\n  for (const server of enabledServers) {\n    loadLatest(server.name)\n  }\n}\n\nonMounted(() => {\n  loadData()\n})\n\nonActivated(() => {\n  loadData()\n})\n</script>\n\n<template>\n  <div>\n    <VHover v-for=\"(data, name) in latestList\" :key=\"name\">\n      <template #default=\"hover\">\n        <VCard v-bind=\"hover.props\">\n          <VCardItem>\n            <template #append>\n              <VIcon class=\"cursor-move\" v-if=\"hover.isHovering\">mdi-drag</VIcon>\n            </template>\n            <VCardTitle>{{ t('dashboard.latest') }} - {{ name }}</VCardTitle>\n          </VCardItem>\n\n          <div class=\"grid gap-4 grid-media-card mx-3 mb-3\" tabindex=\"0\">\n            <PosterCard v-for=\"item in data\" :key=\"item.id\" :media=\"item\" />\n          </div>\n        </VCard>\n      </template>\n    </VHover>\n  </div>\n</template>\n"
  },
  {
    "path": "src/views/dashboard/MediaServerLibrary.vue",
    "content": "<script setup lang=\"ts\">\nimport api from '@/api'\nimport type { MediaServerConf, MediaServerLibrary } from '@/api/types'\nimport LibraryCard from '@/components/cards/LibraryCard.vue'\nimport { useI18n } from 'vue-i18n'\n\n// 国际化\nconst { t } = useI18n()\n\n// 媒体库列表\nconst libraryList = ref<MediaServerLibrary[]>([])\n\n// 所有媒体服务器设置\nconst mediaServers = ref<MediaServerConf[]>([])\n\n// 调用API查询媒体服务器设置\nasync function loadMediaServerSetting() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/MediaServers')\n    mediaServers.value = result.data?.value ?? []\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 调用API查询\nasync function loadLibrary(server: string) {\n  try {\n    const result: MediaServerLibrary[] = await api.get('mediaserver/library', {\n      params: { server: server, hidden: true },\n    })\n    if (result && result.length > 0) {\n      // 不存在时添加\n      for (const item of result) {\n        const index = libraryList.value.findIndex(i => i.id === item.id)\n        if (index === -1) libraryList.value.push(item)\n      }\n    }\n  } catch (e) {\n    console.log(e)\n  }\n}\n\n// 加载数据\nasync function loadData() {\n  await loadMediaServerSetting()\n  const enabledServers = mediaServers.value.filter(server => server.enabled)\n  for (const server of enabledServers) {\n    loadLibrary(server.name)\n  }\n}\n\nonMounted(() => {\n  loadData()\n})\n\nonActivated(() => {\n  loadData()\n})\n</script>\n\n<template>\n  <VHover v-if=\"libraryList.length > 0\">\n    <template #default=\"hover\">\n      <VCard v-bind=\"hover.props\">\n        <VCardItem>\n          <template #append>\n            <VIcon class=\"cursor-move\" v-if=\"hover.isHovering\">mdi-drag</VIcon>\n          </template>\n          <VCardTitle>{{ t('dashboard.library') }}</VCardTitle>\n        </VCardItem>\n        <div class=\"grid gap-4 grid-backdrop-card mx-3 mb-3\" tabindex=\"0\">\n          <LibraryCard v-for=\"item in libraryList\" :key=\"item.id\" :media=\"item\" height=\"10rem\" />\n        </div>\n      </VCard>\n    </template>\n  </VHover>\n</template>\n"
  },
  {
    "path": "src/views/dashboard/MediaServerPlaying.vue",
    "content": "<script setup lang=\"ts\">\nimport api from '@/api'\nimport type { MediaServerConf, MediaServerPlayItem } from '@/api/types'\nimport BackdropCard from '@/components/cards/BackdropCard.vue'\nimport { useI18n } from 'vue-i18n'\n\n// 国际化\nconst { t } = useI18n()\n\n// 继续播放列表\nconst playingList = ref<MediaServerPlayItem[]>([])\n\n// 所有媒体服务器设置\nconst mediaServers = ref<MediaServerConf[]>([])\n\n// 调用API查询媒体服务器设置\nasync function loadMediaServerSetting() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/MediaServers')\n    mediaServers.value = result.data?.value ?? []\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 调用API查询\nasync function loadPlayingList(server: string) {\n  try {\n    const result: MediaServerPlayItem[] = await api.get('mediaserver/playing', { params: { server } })\n    if (result && result.length > 0) {\n      // 不存在时添加\n      for (const item of result) {\n        const index = playingList.value.findIndex(i => i.id === item.id)\n        if (index === -1) {\n          playingList.value.push(item)\n        }\n      }\n    }\n  } catch (e) {\n    console.log(e)\n  }\n}\n\n// 加载数据\nasync function loadData() {\n  await loadMediaServerSetting()\n  const enabledServers = mediaServers.value.filter(server => server.enabled)\n  for (const server of enabledServers) {\n    loadPlayingList(server.name)\n  }\n}\n\nonMounted(() => {\n  loadData()\n})\n\nonActivated(() => {\n  loadData()\n})\n</script>\n\n<template>\n  <VHover v-if=\"playingList.length > 0\">\n    <template #default=\"hover\">\n      <VCard v-bind=\"hover.props\">\n        <VCardItem>\n          <template #append>\n            <VIcon class=\"cursor-move\" v-if=\"hover.isHovering\">mdi-drag</VIcon>\n          </template>\n          <VCardTitle>{{ t('dashboard.playing') }}</VCardTitle>\n        </VCardItem>\n\n        <div class=\"grid gap-4 grid-backdrop-card mx-3 mb-3\" tabindex=\"0\">\n          <BackdropCard v-for=\"item in playingList\" :key=\"item.id\" :media=\"item\" height=\"10rem\" />\n        </div>\n      </VCard>\n    </template>\n  </VHover>\n</template>\n"
  },
  {
    "path": "src/views/discover/BangumiView.vue",
    "content": "<script setup lang=\"ts\">\nimport MediaCardListView from '@/views/discover/MediaCardListView.vue'\nimport { useI18n } from 'vue-i18n'\n\nconst { t } = useI18n()\n\n// 过滤参数\nconst filterParams = reactive({\n  'type': 2,\n  'cat': null,\n  'sort': 'rank', // date/rank\n  'year': null,\n})\n\n// Bangumi cat字典\n/**\n * 0 为 其他\n1 为 TV\n2 为 OVA\n3 为 Movie\n5 为 WEB\n */\nconst bangumiCatDict = {\n  '0': t('bangumi.cat.other'),\n  '1': t('bangumi.cat.tv'),\n  '2': t('bangumi.cat.ova'),\n  '3': t('bangumi.cat.movie'),\n  '5': t('bangumi.cat.web'),\n}\n\n// Bangumi排序字典\nconst bangumiSortDict = {\n  'rank': t('bangumi.sortType.rank'),\n  'date': t('bangumi.sortType.date'),\n}\n\n// 年份字典，自动生成最近10年\nconst yearDict: Record<number, number> = {}\nconst currentYear = new Date().getFullYear()\nfor (let i = 0; i < 10; i++) {\n  yearDict[currentYear - i] = currentYear - i\n}\n\n// 当前Key\nconst currentKey = ref(0)\n\n// 类型和过滤参数变化后重新刷新列表\nwatch([filterParams], () => {\n  currentKey.value++\n})\n</script>\n\n<template>\n  <div class=\"px-3\">\n    <div class=\"flex justify-start align-center\">\n      <div class=\"mr-5\">\n        <VLabel>{{ t('bangumi.category') }}</VLabel>\n      </div>\n      <VChipGroup v-model=\"filterParams.cat\">\n        <VChip\n          :color=\"filterParams.cat == key ? 'primary' : ''\"\n          filter\n          tile\n          :value=\"key\"\n          v-for=\"(value, key) in bangumiCatDict\"\n          :key=\"key\"\n        >\n          {{ value }}\n        </VChip>\n      </VChipGroup>\n    </div>\n    <div class=\"flex justify-start align-center\">\n      <div class=\"mr-5\">\n        <VLabel>{{ t('bangumi.sort') }}</VLabel>\n      </div>\n      <VChipGroup v-model=\"filterParams.sort\">\n        <VChip\n          :color=\"filterParams.sort == key ? 'primary' : ''\"\n          filter\n          tile\n          :value=\"key\"\n          v-for=\"(value, key) in bangumiSortDict\"\n          :key=\"key\"\n        >\n          {{ value }}\n        </VChip>\n      </VChipGroup>\n    </div>\n    <div class=\"flex justify-start align-center\">\n      <div class=\"mr-5\">\n        <VLabel>{{ t('bangumi.year') }}</VLabel>\n      </div>\n      <VChipGroup v-model=\"filterParams.year\">\n        <VChip\n          :color=\"filterParams.year == key ? 'primary' : ''\"\n          filter\n          tile\n          :value=\"key\"\n          v-for=\"(value, key) in yearDict\"\n          :key=\"key\"\n        >\n          {{ value }}\n        </VChip>\n      </VChipGroup>\n    </div>\n  </div>\n  <div>\n    <MediaCardListView :key=\"currentKey\" apipath=\"discover/bangumi\" :params=\"filterParams\" />\n  </div>\n</template>\n"
  },
  {
    "path": "src/views/discover/DoubanView.vue",
    "content": "<script setup lang=\"ts\">\nimport MediaCardListView from '@/views/discover/MediaCardListView.vue'\nimport { useI18n } from 'vue-i18n'\n\nconst { t } = useI18n()\n\n// 电影或者电视剧 movies/tvs\nconst type = ref('movies')\n\n// 过滤参数\nconst filterParams = reactive({\n  'sort': 'U',\n  'tags': '',\n})\n\n// 豆瓣风格类型\nconst doubanCategory = ref('')\n\n// 地区\nconst doubanZone = ref('')\n\n// 年代\nconst doubanYear = ref('')\n\n// 豆瓣风格字典\nconst categoryDict = {\n  '喜剧': t('douban.genreType.comedy'),\n  '爱情': t('douban.genreType.romance'),\n  '动作': t('douban.genreType.action'),\n  '科幻': t('douban.genreType.scienceFiction'),\n  '动画': t('douban.genreType.animation'),\n  '悬疑': t('douban.genreType.mystery'),\n  '犯罪': t('douban.genreType.crime'),\n  '惊悚': t('douban.genreType.thriller'),\n  '冒险': t('douban.genreType.adventure'),\n  '音乐': t('douban.genreType.music'),\n  '历史': t('douban.genreType.history'),\n  '奇幻': t('douban.genreType.fantasy'),\n  '恐怖': t('douban.genreType.horror'),\n  '战争': t('douban.genreType.war'),\n  '传记': t('douban.genreType.biography'),\n  '歌舞': t('douban.genreType.musical'),\n  '武侠': t('douban.genreType.martialArts'),\n  '情色': t('douban.genreType.erotic'),\n  '灾难': t('douban.genreType.disaster'),\n  '西部': t('douban.genreType.western'),\n  '纪录片': t('douban.genreType.documentary'),\n  '短片': t('douban.genreType.shortFilm'),\n}\n\n// 地区字典\nconst zoneDict = {\n  '华语': t('douban.zoneType.chinese'),\n  '欧美': t('douban.zoneType.europeanAmerican'),\n  '韩国': t('douban.zoneType.korean'),\n  '日本': t('douban.zoneType.japanese'),\n  '中国大陆': t('douban.zoneType.mainlandChina'),\n  '美国': t('douban.zoneType.usa'),\n  '中国香港': t('douban.zoneType.hongKong'),\n  '中国台湾': t('douban.zoneType.taiwan'),\n  '英国': t('douban.zoneType.uk'),\n  '法国': t('douban.zoneType.france'),\n  '德国': t('douban.zoneType.germany'),\n  '意大利': t('douban.zoneType.italy'),\n  '西班牙': t('douban.zoneType.spain'),\n  '印度': t('douban.zoneType.india'),\n  '泰国': t('douban.zoneType.thailand'),\n  '俄罗斯': t('douban.zoneType.russia'),\n  '加拿大': t('douban.zoneType.canada'),\n  '澳大利亚': t('douban.zoneType.australia'),\n  '爱尔兰': t('douban.zoneType.ireland'),\n  '瑞典': t('douban.zoneType.sweden'),\n  '巴西': t('douban.zoneType.brazil'),\n  '丹麦': t('douban.zoneType.denmark'),\n}\n\n// 年代字典\nconst yearDict: Record<string, string> = {\n  '2020年代': t('douban.yearType.2020s'),\n  '2010年代': t('douban.yearType.2010s'),\n  '2000年代': t('douban.yearType.2000s'),\n  '90年代': t('douban.yearType.1990s'),\n  '80年代': t('douban.yearType.1980s'),\n  '70年代': t('douban.yearType.1970s'),\n  '60年代': t('douban.yearType.1960s'),\n}\n\n// 往年代字典中追加当前年份及往前5年的字典\nconst currentYear = new Date().getFullYear()\nfor (let i = 0; i < 6; i++) {\n  yearDict[`${currentYear - i}`] = `${currentYear - i}`\n}\n\n// 豆瓣过滤参数\nconst doubanSortDict = {\n  'U': t('douban.sortType.comprehensive'),\n  'R': t('douban.sortType.releaseDate'),\n  'T': t('douban.sortType.recentHot'),\n  'S': t('douban.sortType.highScore'),\n}\n\n// 风格、年代、地区变化时，以,分隔拼接到tags参数\nwatch([doubanCategory, doubanZone, doubanYear], () => {\n  filterParams.tags = [doubanCategory.value, doubanZone.value, doubanYear.value].filter(Boolean).join(',')\n})\n\n// 当前Key\nconst currentKey = ref(0)\n\n// 类型和过滤参数变化后重新刷新列表\nwatch([type, filterParams], () => {\n  if (!type.value) {\n    type.value = 'movies'\n  }\n  if (!filterParams.sort) {\n    filterParams.sort = 'U'\n  }\n  currentKey.value++\n})\n</script>\n\n<template>\n  <div class=\"px-3\">\n    <div class=\"flex justify-start align-center\">\n      <div class=\"mr-5\">\n        <VLabel>{{ t('douban.type') }}</VLabel>\n      </div>\n      <VChipGroup v-model=\"type\">\n        <VChip :color=\"type == 'movies' ? 'primary' : ''\" filter tile value=\"movies\">{{ t('mediaType.movie') }}</VChip>\n        <VChip :color=\"type == 'tvs' ? 'primary' : ''\" filter tile value=\"tvs\">{{ t('mediaType.tv') }}</VChip>\n      </VChipGroup>\n    </div>\n    <div class=\"flex justify-start align-center\">\n      <div class=\"mr-5\">\n        <VLabel>{{ t('douban.sort') }}</VLabel>\n      </div>\n      <VChipGroup v-model=\"filterParams.sort\">\n        <VChip\n          :color=\"filterParams.sort == key ? 'primary' : ''\"\n          filter\n          tile\n          :value=\"key\"\n          v-for=\"(value, key) in doubanSortDict\"\n          :key=\"key\"\n        >\n          {{ value }}\n        </VChip>\n      </VChipGroup>\n    </div>\n    <div class=\"flex justify-start align-center\">\n      <div class=\"mr-5\">\n        <VLabel>{{ t('douban.genre') }}</VLabel>\n      </div>\n      <VChipGroup v-model=\"doubanCategory\">\n        <VChip\n          :color=\"doubanCategory == key ? 'primary' : ''\"\n          filter\n          tile\n          :value=\"key\"\n          v-for=\"(value, key) in categoryDict\"\n          :key=\"key\"\n        >\n          {{ value }}\n        </VChip>\n      </VChipGroup>\n    </div>\n    <div class=\"flex justify-start align-center\">\n      <div class=\"mr-5\">\n        <VLabel>{{ t('douban.zone') }}</VLabel>\n      </div>\n      <VChipGroup v-model=\"doubanZone\">\n        <VChip\n          :color=\"doubanZone == key ? 'primary' : ''\"\n          filter\n          tile\n          :value=\"key\"\n          v-for=\"(value, key) in zoneDict\"\n          :key=\"key\"\n        >\n          {{ value }}\n        </VChip>\n      </VChipGroup>\n    </div>\n    <div class=\"flex justify-start align-center\">\n      <div class=\"mr-5\">\n        <VLabel>{{ t('douban.year') }}</VLabel>\n      </div>\n      <VChipGroup v-model=\"doubanYear\">\n        <VChip\n          :color=\"doubanYear == key ? 'primary' : ''\"\n          filter\n          tile\n          :value=\"key\"\n          v-for=\"(value, key) in yearDict\"\n          :key=\"key\"\n        >\n          {{ value }}\n        </VChip>\n      </VChipGroup>\n    </div>\n  </div>\n  <div>\n    <MediaCardListView :key=\"currentKey\" :apipath=\"`discover/douban_${type}`\" :params=\"filterParams\" />\n  </div>\n</template>\n"
  },
  {
    "path": "src/views/discover/ExtraSourceView.vue",
    "content": "<script setup lang=\"ts\">\nimport { DiscoverSource } from '@/api/types'\nimport MediaCardListView from '@/views/discover/MediaCardListView.vue'\nimport FormRender from '@/components/render/FormRender.vue'\nimport { cloneDeep } from 'lodash-es'\n\n// 输入参数\nconst props = defineProps<{\n  source: DiscoverSource\n}>()\n\n// 默认输入参数\nconst default_params = cloneDeep(props.source.filter_params)\n\n// 过滤参数\nconst filterParams = reactive(props.source.filter_params)\n\n// 前一次的过滤参数\nlet previousParams = cloneDeep(props.source.filter_params)\n\n// 当前Key\nconst currentKey = ref(0)\n\n// 类型和过滤参数变化后重新刷新列表\nwatch(filterParams, newParams => {\n  // 检查每个值\n  for (const key in newParams) {\n    // 如果没有值但有默认值时，设置为默认值\n    if (!newParams[key] && default_params[key]) {\n      filterParams[key] = default_params[key]\n    }\n    // 检查依赖关系\n    const depends = props.source?.depends\n    if (depends) {\n      if (newParams[key] !== previousParams[key]) {\n        for (const dependKey in depends) {\n          if (key != dependKey && depends[dependKey] && depends[dependKey].includes(key)) {\n            filterParams[dependKey] = null\n          }\n        }\n      }\n    }\n  }\n  // 更新 previousParams\n  previousParams = cloneDeep(newParams)\n  // 刷新界面\n  currentKey.value++\n})\n</script>\n\n<template>\n  <div class=\"px-3\">\n    <FormRender v-for=\"(element, index) in source.filter_ui\" :key=\"index\" :config=\"element\" :model=\"filterParams\" />\n  </div>\n  <div>\n    <MediaCardListView :key=\"currentKey\" :apipath=\"source.api_path\" :params=\"filterParams\" />\n  </div>\n</template>\n\n<style>\n.v-chip--selected {\n  color: rgb(var(--v-theme-primary)) !important;\n}\n</style>\n"
  },
  {
    "path": "src/views/discover/MediaCardListView.vue",
    "content": "<script lang=\"ts\" setup>\nimport api from '@/api'\nimport type { MediaInfo } from '@/api/types'\nimport MediaCard from '@/components/cards/MediaCard.vue'\nimport NoDataFound from '@/components/NoDataFound.vue'\nimport { useI18n } from 'vue-i18n'\n\nconst { t } = useI18n()\n\n// 输入参数\nconst props = defineProps({\n  apipath: String,\n  params: Object as PropType<{ [key: string]: any }>,\n})\n\n// 判断是否有滚动条\nfunction hasScroll() {\n  return document.body.scrollHeight - (window.innerHeight || document.documentElement.clientHeight) > 2\n}\n\n// 当前页码\nconst page = ref(1)\n\n// 是否加载中\nconst loading = ref(false)\n\n// 是否加载完成\nconst isRefreshed = ref(false)\n\n// 数据列表\nconst dataList = ref<MediaInfo[]>([])\nconst currData = ref<MediaInfo[]>([])\n\n// 用于保存已处理过的 key\nconst seenKeys = ref<Set<string>>(new Set<string>())\n\n// 拼装参数\nfunction getParams() {\n  let params = {\n    page: page.value,\n  }\n  if (props.params) params = { ...params, ...props.params }\n\n  return params\n}\n\n// MediaInfo 去重的字段\nconst dedupFields = [\n  \"source\",\n  \"type\",\n  \"season\",\n  \"tmdb_id\",\n  \"imdb_id\",\n  \"tvdb_id\",\n  \"douban_id\",\n  \"bangumi_id\",\n  \"mediaid_prefix\",\n  \"media_id\",\n] as const;\n\nfunction deduplicate(items: MediaInfo[]): MediaInfo[] {\n  return items.filter(item => {\n    const key = dedupFields.map(field => String(item[field])).join('~');\n    if (seenKeys.value.has(key)) {\n      return false;\n    }\n    seenKeys.value.add(key);\n    return true;\n  });\n}\n\n// 获取列表数据\nasync function fetchData({ done }: { done: any }) {\n  try {\n    if (!props.apipath) return\n\n    // 如果正在加载中，直接返回\n    if (loading.value) {\n      done('ok')\n      return\n    }\n\n    // 加载到满屏或者加载出错\n    if (!hasScroll()) {\n      // 加载多次\n      while (!hasScroll()) {\n        // 设置加载中\n        loading.value = true\n        // 请求API\n        currData.value = await api.get(props.apipath, {\n          params: getParams(),\n        })\n        // 取消加载中\n        loading.value = false\n        // 标计为已请求完成\n        isRefreshed.value = true\n        if (currData.value.length === 0) {\n          // 如果没有数据，跳出\n          done('empty')\n          return\n        }\n        // 去重\n        currData.value = deduplicate(currData.value)\n        // 合并数据\n        dataList.value.push(...currData.value)\n        // 页码+1\n        page.value++\n        // 返回加载成功\n        done('ok')\n      }\n    } else {\n      // 加载一次\n      // 设置加载中\n      loading.value = true\n      // 请求API\n      currData.value = await api.get(props.apipath, {\n        params: getParams(),\n      })\n      // 标计为已请求完成\n      isRefreshed.value = true\n      if (currData.value.length === 0) {\n        // 如果没有数据，跳出\n        done('empty')\n      } else {\n        // 去重\n        currData.value = deduplicate(currData.value)\n        // 合并数据\n        dataList.value.push(...currData.value)\n        // 页码+1\n        page.value++\n        // 返回加载成功\n        done('ok')\n      }\n    }\n    // 取消加载中\n    loading.value = false\n  } catch (error) {\n    console.error(error)\n    // 返回加载失败\n    done('error')\n  }\n}\n</script>\n\n<template>\n  <LoadingBanner v-if=\"!isRefreshed\" class=\"mt-12\" />\n  <VInfiniteScroll mode=\"intersect\" side=\"end\" :items=\"dataList\" class=\"overflow-visible pt-3 px-2\" @load=\"fetchData\">\n    <template #loading />\n    <template #empty />\n    <div v-if=\"dataList.length > 0\" class=\"grid gap-4 grid-media-card\" tabindex=\"0\">\n      <MediaCard v-for=\"data in dataList\" :key=\"data.tmdb_id || data.douban_id\" :media=\"data\" />\n    </div>\n    <NoDataFound\n      v-if=\"dataList.length === 0 && isRefreshed\"\n      error-code=\"404\"\n      :error-title=\"t('common.noData')\"\n      :error-description=\"t('error.networkError')\"\n    />\n  </VInfiniteScroll>\n</template>\n"
  },
  {
    "path": "src/views/discover/MediaCardSlideView.vue",
    "content": "<script lang=\"ts\" setup>\nimport api from '@/api'\nimport type { MediaInfo } from '@/api/types'\nimport MediaCard from '@/components/cards/MediaCard.vue'\nimport SlideView from '@/components/slide/SlideView.vue'\nimport { useI18n } from 'vue-i18n'\nimport { useIntersectionObserver, until } from '@vueuse/core'\n\nconst { t } = useI18n()\n\n// 输入参数\nconst props = defineProps({\n  apipath: String,\n  linkurl: String,\n  title: String,\n  ready: {\n    type: Boolean,\n    default: true,\n  },\n})\n\n// 提供给子组件的属性\nprovide('rankingPropsKey', reactive({ ...props }))\n\n// 组件加载完成\nconst componentLoaded = ref(false)\n// 是否已尝试加载\nconst hasTriedLoading = ref(false)\n\n// 数据列表\nconst dataList = ref<MediaInfo[]>([])\n\n// 容器引用\nconst containerRef = ref<HTMLElement | null>(null)\n\n// 获取订阅列表数据\nasync function fetchData() {\n  try {\n    if (!props.apipath) return\n    dataList.value = await api.get(props.apipath)\n    if (dataList.value.length > 0) {\n      // 数据获取后，等待 ready 信号再渲染，避免阻塞动画\n      await until(() => props.ready).toBe(true)\n    }\n    componentLoaded.value = true\n  } catch (error) {\n    console.error(error)\n    componentLoaded.value = true\n  } finally {\n    hasTriedLoading.value = true\n  }\n}\n\n// 使用 IntersectionObserver 实现懒加载\nconst { stop } = useIntersectionObserver(\n  containerRef,\n  ([{ isIntersecting }]) => {\n    if (isIntersecting) {\n      fetchData()\n      stop()\n    }\n  },\n  {\n    rootMargin: '300px', // 提前加载距离\n  },\n)\n\nonActivated(() => {\n  if (dataList.value.length == 0 && hasTriedLoading.value) {\n    fetchData()\n  }\n})\n</script>\n\n<template>\n  <div ref=\"containerRef\">\n    <SlideView v-if=\"componentLoaded\">\n      <template #content>\n        <template v-for=\"data in dataList\" :key=\"data.tmdb_id || data.douban_id || data.bangumi_id\">\n          <MediaCard :media=\"data\" width=\"9rem\" />\n        </template>\n      </template>\n    </SlideView>\n    <SlideView v-else-if=\"!componentLoaded\">\n      <template #content>\n        <div v-for=\"i in 10\" :key=\"i\" style=\"width: 9rem\">\n          <VCard class=\"outline-none overflow-hidden\">\n            <div style=\"padding-bottom: 150%\"></div>\n          </VCard>\n        </div>\n      </template>\n    </SlideView>\n  </div>\n</template>\n"
  },
  {
    "path": "src/views/discover/MediaDetailView.vue",
    "content": "<script setup lang=\"ts\">\nimport { useToast } from 'vue-toastification'\nimport PersonCardSlideView from './PersonCardSlideView.vue'\nimport MediaCardSlideView from './MediaCardSlideView.vue'\nimport api from '@/api'\nimport type { MediaInfo, NotExistMediaInfo, Site, Subscribe, TmdbEpisode } from '@/api/types'\nimport NoDataFound from '@/components/NoDataFound.vue'\nimport { doneNProgress, startNProgress } from '@/api/nprogress'\nimport { formatSeason } from '@/@core/utils/formatters'\nimport router from '@/router'\nimport { isNullOrEmptyObject } from '@/@core/utils'\nimport { useUserStore } from '@/stores'\nimport SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue'\nimport SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'\nimport { useTheme } from 'vuetify'\nimport { useI18n } from 'vue-i18n'\nimport { hasPermission } from '@/utils/permission'\nimport { useGlobalSettingsStore } from '@/stores'\nimport { openMediaServerWithAutoDetect, openDoubanApp } from '@/utils/appDeepLink'\n\n// 国际化\nconst { t } = useI18n()\n\n// 输入参数\nconst mediaProps = defineProps({\n  mediaid: String,\n  title: String,\n  year: String,\n  type: String,\n})\n\n// 从 provide 中获取全局设置\n// 全局设置\nconst globalSettingsStore = useGlobalSettingsStore()\nconst globalSettings = globalSettingsStore.globalSettings\n\n// 用户 Store\nconst userStore = useUserStore()\n\n// 提示框\nconst $toast = useToast()\n\n// 获取主题信息\nconst theme = useTheme()\n\n// 媒体详情\nconst mediaDetail = ref<MediaInfo>({} as MediaInfo)\n\n// 订阅编辑弹窗\nconst subscribeEditDialog = ref(false)\n\n// 本地是否存在，存在则包括Item信息\nconst existsItemId = ref('')\n\n// 是否已订阅\nconst isSubscribed = ref(false)\n\n// 是否已加载完成\nconst isRefreshed = ref(false)\n\n// 存储每一季的集信息\nconst seasonEpisodesInfo = ref({} as { [key: number]: TmdbEpisode[] })\n\n// 存储存在的季集\nconst existsEpisodes = ref({} as { [key: number]: number[] })\n\n// 各季缺失状态：0-已入库 1-部分缺失 2-全部缺失，没有数据也是已入库\nconst seasonsNotExisted = ref<{ [key: number]: number }>({})\n\n// 各季的订阅状态\nconst seasonsSubscribed = ref<{ [key: number]: boolean }>({})\n\n// 订阅编号\nconst subscribeId = ref<number>()\n\n// 所有站点\nconst allSites = ref<Site[]>([])\n\n// 选中的站点\nconst selectedSites = ref<number[]>([])\n\n// 搜索方式 title/imdbid\nconst searchType = ref('title')\n\n// 选择站点对话框\nconst chooseSiteDialog = ref(false)\n\n// 计算主题是否为透明\nconst isTransparentTheme = computed(() => {\n  return theme.name.value === 'transparent'\n})\n\n// 查询所有站点\nasync function querySites() {\n  try {\n    const data: Site[] = await api.get('site/')\n\n    // 过滤站点，只有启用的站点才显示\n    allSites.value = data.filter(item => item.is_active)\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 查询用户选中的站点\nasync function querySelectedSites() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/IndexerSites')\n\n    selectedSites.value = result.data?.value ?? []\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 获得mediaid\nfunction getMediaId() {\n  if (mediaDetail.value?.tmdb_id) return `tmdb:${mediaDetail.value?.tmdb_id}`\n  else if (mediaDetail.value?.douban_id) return `douban:${mediaDetail.value?.douban_id}`\n  else if (mediaDetail.value?.bangumi_id) return `bangumi:${mediaDetail.value?.bangumi_id}`\n  else return `${mediaDetail.value?.mediaid_prefix}:${mediaDetail.value?.media_id}`\n}\n\n// 调用API查询详情\nasync function getMediaDetail() {\n  if (mediaProps.mediaid && mediaProps.type) {\n    mediaDetail.value = await api.get(`media/${mediaProps.mediaid}`, {\n      params: {\n        title: mediaProps.title,\n        year: mediaProps.year,\n        type_name: mediaProps.type,\n      },\n    })\n    isRefreshed.value = true\n    if (!mediaDetail.value.tmdb_id && !mediaDetail.value.douban_id && !mediaDetail.value.bangumi_id) return\n\n    // 检查存在状态\n    checkExists()\n    if (mediaDetail.value.type === '电视剧') checkSeasonsNotExists()\n    // 检查订阅状态\n    if (mediaDetail.value.type === '电影') checkMovieSubscribed()\n    else checkSeasonsSubscribed()\n  }\n}\n\n// 调用API加载季集信息（TMDB）\nasync function loadSeasonEpisodes(season: number) {\n  // 加载季集存在信息\n  loadEpisodeExists()\n  // 加载季集信息\n  if (seasonEpisodesInfo.value[season]) return\n  try {\n    const params = mediaDetail.value.episode_group ? { episode_group: mediaDetail.value.episode_group } : undefined\n    const result: TmdbEpisode[] = await api.get(`tmdb/${mediaDetail.value.tmdb_id}/${season}`, params ? { params } : undefined)\n    seasonEpisodesInfo.value[season] = result || []\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 调用API加载季集存在信息（媒体服务器）\nasync function loadEpisodeExists() {\n  // 查询季集存在状态\n  if (!isNullOrEmptyObject(existsEpisodes.value)) return\n  try {\n    const result: { [key: number]: number[] } = await api.post(`mediaserver/exists_remote`, mediaDetail.value)\n    existsEpisodes.value = result || {}\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 查询当前媒体是否已入库（数据库）\nasync function checkExists() {\n  try {\n    const result: { [key: string]: any } = await api.get('mediaserver/exists', {\n      params: {\n        tmdbid: mediaDetail.value.tmdb_id,\n        title: mediaDetail.value.title,\n        year: mediaDetail.value.year,\n        season: mediaDetail.value.season,\n        mtype: mediaDetail.value.type,\n      },\n    })\n\n    if (result.success) existsItemId.value = result.data.item.id\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 查询当前媒体是否已订阅\nasync function checkSubscribe(season: number | null = null) {\n  try {\n    const mediaid = getMediaId()\n\n    const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {\n      params: {\n        season,\n        title: mediaDetail.value.title,\n      },\n    })\n\n    if (result.id) return true\n  } catch (error) {\n    console.error(error)\n  }\n\n  return false\n}\n\n// 检查所有季的缺失状态\nasync function checkSeasonsNotExists() {\n  if (mediaDetail.value.type !== '电视剧') return\n  try {\n    const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', mediaDetail.value)\n    if (result) {\n      result.forEach(item => {\n        // 0-已入库 1-部分缺失 2-全部缺失\n        let state = 0\n        if (item.episodes.length === 0) state = 2\n        else if (item.episodes.length < item.total_episode) state = 1\n        seasonsNotExisted.value[item.season] = state\n      })\n    }\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 检查电影订阅状态\nasync function checkMovieSubscribed() {\n  if (mediaDetail.value.type !== '电影') return\n  isSubscribed.value = await checkSubscribe()\n}\n\n// 季列表，第0季排在最后\nconst getMediaSeasons = computed(() => {\n  if (!mediaDetail.value?.season_info) return []\n  return [...mediaDetail.value.season_info].sort((a, b) => {\n    if (a.season_number === 0) return 1\n    if (b.season_number === 0) return -1\n    return (a.season_number || 0) - (b.season_number || 0)\n  })\n})\n\n// 检查所有季的订阅状态\nasync function checkSeasonsSubscribed() {\n  if (mediaDetail.value.type !== '电视剧') return\n  try {\n    mediaDetail.value?.season_info?.forEach(async item => {\n      seasonsSubscribed.value[item.season_number ?? 0] = await checkSubscribe(item.season_number ?? null)\n    })\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 调用API添加订阅，电视剧的话需要指定季\nasync function addSubscribe(season: number | null) {\n  // 开始处理\n  startNProgress()\n  try {\n    // 是否洗版\n    let best_version = existsItemId.value ? 1 : 0\n    if (season !== null)\n      // 全部存在时洗版\n      best_version = !seasonsNotExisted.value[season] ? 1 : 0\n    // 请求API\n    const result: { [key: string]: any } = await api.post('subscribe/', {\n      name: mediaDetail.value?.title,\n      type: mediaDetail.value?.type,\n      year: mediaDetail.value?.year,\n      tmdbid: mediaDetail.value?.tmdb_id,\n      doubanid: mediaDetail.value?.douban_id,\n      bangumiid: mediaDetail.value?.bangumi_id,\n      season: mediaDetail.value?.type === '电影' ? null : season,\n      best_version,\n    })\n\n    // 订阅状态\n    if (result.success) {\n      // 订阅成功\n      isSubscribed.value = true\n      if (season !== null) seasonsSubscribed.value[season] = true\n    }\n\n    // 提示\n    showSubscribeAddToast(result.success, mediaDetail.value?.title ?? '', season, result.message, best_version)\n\n    // 显示编辑弹窗\n    if (result.success) {\n      const show_edit_dialog = await queryDefaultSubscribeConfig()\n      if (show_edit_dialog) {\n        subscribeId.value = result.data.id\n        subscribeEditDialog.value = true\n      }\n    }\n  } catch (error) {\n    console.error(error)\n  }\n  doneNProgress()\n}\n\n// 弹出添加订阅提示\nfunction showSubscribeAddToast(result: boolean, title: string, season: number | null, message: string, best_version: number) {\n  if (season !== null) title = `${title} ${formatSeason(season.toString())}`\n\n  let subname = t('media.subscribe.normal')\n  if (best_version > 0) subname = t('media.subscribe.bestVersion')\n\n  if (!result) $toast.error(`${title} ${t('media.subscribe.addFailed', { reason: message })}`)\n}\n\n// 调用API取消订阅\nasync function removeSubscribe(season: number | null) {\n  // 开始处理\n  startNProgress()\n  try {\n    const mediaid = getMediaId()\n\n    const result: { [key: string]: any } = await api.delete(`subscribe/media/${mediaid}`, {\n      params: {\n        season,\n      },\n    })\n\n    if (result.success) {\n      isSubscribed.value = false\n      if (season !== null) seasonsSubscribed.value[season] = false\n      $toast.success(`${mediaDetail.value?.title} ${t('media.subscribe.canceled')}`)\n    } else {\n      $toast.error(`${mediaDetail.value?.title} ${t('media.subscribe.cancelFailed', { reason: result.message })}`)\n    }\n  } catch (error) {\n    console.error(error)\n  }\n  doneNProgress()\n}\n\n// 订阅按钮响应\nfunction handleSubscribe(season: number | null = null) {\n  if (isSubscribed.value) removeSubscribe(season)\n  else addSubscribe(season)\n}\n\n// 从genres中获取name，使用、分隔\nfunction getGenresName(genres: any[]) {\n  return genres.map(genre => genre.name).join('、')\n}\n\n// 拼装TheMovieDb地址\nfunction getTheMovieDbLink() {\n  const mtype = mediaProps.type === '电影' ? 'movie' : 'tv'\n  return `https://www.themoviedb.org/${mtype}/${mediaDetail.value.tmdb_id}`\n}\n\n// 拼装豆瓣地址\nfunction getDoubanLink() {\n  return `https://movie.douban.com/subject/${mediaDetail.value.douban_id}`\n}\n\n// 处理豆瓣链接点击\nasync function handleDoubanClick() {\n  if (mediaDetail.value.douban_id) {\n    await openDoubanApp(\n      mediaDetail.value.douban_id,\n      mediaDetail.value.type,\n      mediaDetail.value.title,\n      mediaDetail.value.year,\n    )\n  }\n}\n\n// 拼装IMDB地址\nfunction getImdbLink() {\n  return `https://www.imdb.com/title/${mediaDetail.value.imdb_id}`\n}\n\n// 拼装TVDB地址\nfunction getTvdbLink() {\n  return `https://www.thetvdb.com/series/${mediaDetail.value.tvdb_id}`\n}\n\n// 拼装Bangumi地址\nfunction getBangumiLink() {\n  return `https://bgm.tv/subject/${mediaDetail.value.bangumi_id}`\n}\n\n// 拼装集图片地址\nfunction getEpisodeImage(stillPath: string) {\n  if (!stillPath) return ''\n  return `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w500${stillPath}`\n}\n\n// TMDB图片转换为w500大小\nfunction getW500Image(url = '') {\n  if (!url) return ''\n  url = url.replace('original', 'w500')\n  // 使用图片缓存\n  if (globalSettings.GLOBAL_IMAGE_CACHE)\n    return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`\n  return url\n}\n\n// 计算Poster地址\nconst getPosterUrl: Ref<string> = computed(() => {\n  const url = mediaDetail.value.poster_path ?? ''\n  // 使用图片缓存\n  if (globalSettings.GLOBAL_IMAGE_CACHE)\n    return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`\n  // 如果地址中包含douban则使用中转代理\n  if (url.includes('doubanio.com'))\n    return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`\n  return url\n})\n\n// 计算backdrop地址\nconst getBackdropUrl: Ref<string> = computed(() => {\n  const url = mediaDetail.value.backdrop_path ?? ''\n  // 使用图片缓存\n  if (globalSettings.GLOBAL_IMAGE_CACHE)\n    return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`\n  return url\n})\n\n// 获取发行国家名称\nconst getProductionCountries = computed(() => {\n  return mediaDetail.value.production_countries?.map(country => country.name)\n})\n\n// 获取发行公司名称\nconst getProductionCompanies = computed(() => {\n  return mediaDetail.value.production_companies?.map(company => company.name)\n})\n\n// 获取最早实体/数字发行日期\nconst getEarliestReleaseDate = computed(() => {\n  const filteredDates = mediaDetail.value.release_dates?.filter(date => [4, 5].includes(date.type))\n  if (!filteredDates || filteredDates.length === 0)\n    return null\n\n  return filteredDates.reduce((earliest, current) =>\n    new Date(current.date) < new Date(earliest.date) ? current : earliest,\n  )\n})\n\n// 计算存在状态的颜色\nfunction getExistColor(season: number) {\n  const state = seasonsNotExisted.value[season]\n  if (!state) return 'success'\n\n  if (state === 1) return 'warning'\n  else if (state === 2) return 'error'\n  else return 'success'\n}\n\n// 计算存在状态的文本\nfunction getExistText(season: number) {\n  const state = seasonsNotExisted.value[season]\n  if (!state) return t('media.status.inLibrary')\n\n  if (state === 1) return t('media.status.partiallyMissing')\n  else if (state === 2) return t('media.status.missing')\n  else return t('media.status.inLibrary')\n}\n\n// 计算订阅图标\nconst getSubscribeIcon = computed(() => {\n  if (isSubscribed.value) return 'mdi-heart'\n  else return 'mdi-heart-outline'\n})\n\n// 计算订阅按钮颜色\nconst getSubscribeColor = computed(() => {\n  if (isSubscribed.value) return 'error'\n  else return 'warning'\n})\n\n// 使用、拼装数组为字符串\nfunction joinArray(arr: string[]) {\n  return arr.join('、')\n}\n\n// 开始搜索\nfunction handleSearch() {\n  const keyword = getMediaId()\n  router.push({\n    path: '/resource',\n    query: {\n      keyword,\n      type: mediaDetail.value.type,\n      area: searchType.value,\n      title: mediaDetail.value.title,\n      year: mediaDetail.value.year,\n      season: mediaDetail.value.season,\n      sites: selectedSites.value.join(','),\n    },\n  })\n}\n\n// 跳转播放页面\nasync function handlePlay() {\n  // 获取播放链接地址\n  try {\n    const result: { [key: string]: any } = await api.get(`mediaserver/play/${existsItemId.value}`)\n    if (result?.success) {\n      // 使用深度链接工具，优先跳转到APP，失败后跳转到网页\n      await openMediaServerWithAutoDetect(result.data.url, undefined, result.data.server_type)\n    } else {\n      $toast.error(`获取播放链接失败：${result.message}！`)\n    }\n  } catch (error) {\n    console.error(error)\n  }\n}\n\nasync function queryDefaultSubscribeConfig() {\n  // 非管理员不显示\n  if (!userStore.superUser) return false\n  try {\n    let subscribe_config_url = ''\n    if (mediaProps.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'\n    else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'\n\n    const result: { [key: string]: any } = await api.get(subscribe_config_url)\n\n    if (result.data?.value) return result.data.value.show_edit_dialog\n  } catch (error) {\n    console.log(error)\n  }\n  return false\n}\n\n// 删除订阅处理\nfunction onSubscribeEditRemove() {\n  subscribeEditDialog.value = false\n  if (mediaDetail.value.type === '电影') checkMovieSubscribed()\n  else checkSeasonsSubscribed()\n}\n\n// 点击搜索\nasync function clickSearch(type: string) {\n  searchType.value = type\n  if (allSites.value?.length == 0) {\n    await querySites()\n    await querySelectedSites()\n  }\n  if (allSites.value?.length > 0) {\n    chooseSiteDialog.value = true\n  } else {\n    handleSearch()\n  }\n}\n\n// 搜索多站点\nfunction searchSites(sites: number[]) {\n  chooseSiteDialog.value = false\n  selectedSites.value = sites\n  handleSearch()\n}\n\nonBeforeMount(() => {\n  getMediaDetail()\n})\n</script>\n\n<template>\n  <LoadingBanner v-if=\"!isRefreshed\" class=\"mt-12\" />\n  <div\n    v-if=\"mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id\"\n    class=\"max-w-8xl mx-auto px-4\"\n    :class=\"{ 'media-detail-transparent': isTransparentTheme }\"\n  >\n    <template v-if=\"getBackdropUrl || getPosterUrl\">\n      <div class=\"vue-media-back vue-media-back-image absolute left-0 top-0 w-full h-96\">\n        <VImg class=\"h-96\" position=\"top\" :src=\"getBackdropUrl || getPosterUrl\" cover />\n      </div>\n      <div class=\"vue-media-back vue-media-back-overlay absolute left-0 top-0 w-full h-96\" />\n    </template>\n    <div class=\"media-page\">\n      <div class=\"media-header\">\n        <div class=\"media-poster\">\n          <VImg\n            :src=\"getW500Image(mediaDetail.poster_path)\"\n            cover\n            class=\"object-cover aspect-w-2 aspect-h-3 ring-1 ring-gray-500\"\n          >\n            <template #placeholder>\n              <div class=\"w-full h-full\">\n                <VSkeletonLoader class=\"object-cover aspect-w-2 aspect-h-3\" />\n              </div>\n            </template>\n          </VImg>\n        </div>\n        <div class=\"media-title\">\n          <div v-if=\"existsItemId\" class=\"media-status\">\n            <span\n              class=\"px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap transition !no-underline bg-green-500 bg-opacity-80 border border-green-500 !text-green-100 hover:bg-green-500 hover:bg-opacity-100 false overflow-hidden\"\n            >\n              <div class=\"relative z-20 flex items-center false\">\n                <span>{{ t('media.status.inLibrary') }}</span>\n              </div>\n            </span>\n          </div>\n          <h1 class=\"d-flex flex-column flex-lg-row align-baseline justify-center justify-lg-start\">\n            <div class=\"align-self-center align-self-lg-end\">\n              {{ mediaDetail.title }}\n            </div>\n            <div v-if=\"mediaDetail.year\" class=\"text-lg align-self-center align-self-lg-end\">\n              （{{ mediaDetail.year }}）\n            </div>\n          </h1>\n          <span class=\"media-attributes\">\n            <span v-if=\"mediaDetail.runtime || mediaDetail.episode_run_time[0]\"\n              >{{ mediaDetail.runtime || mediaDetail.episode_run_time[0] }} {{ t('media.minutes') }}</span\n            >\n            <span v-if=\"(mediaDetail.runtime || mediaDetail.episode_run_time[0]) && mediaDetail.genres\" class=\"mx-1\">\n              |\n            </span>\n            <span v-if=\"mediaDetail.genres\">{{ getGenresName(mediaDetail.genres || []) }}</span>\n          </span>\n        </div>\n        <div class=\"media-actions\">\n          <VBtn\n            v-if=\"\n              (mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id) &&\n              hasPermission({ is_superuser: userStore.superUser, ...userStore.permissions }, 'search')\n            \"\n            variant=\"tonal\"\n            color=\"info\"\n            class=\"mb-2\"\n          >\n            <template #prepend>\n              <VIcon icon=\"mdi-magnify\" />\n            </template>\n            {{ t('media.actions.searchResource') }}\n            <VMenu activator=\"parent\" close-on-content-click>\n              <VList>\n                <VListItem @click=\"clickSearch('title')\">\n                  <VListItemTitle>{{ t('media.search.byTitle') }}</VListItemTitle>\n                </VListItem>\n                <VListItem @click=\"clickSearch('imdbid')\">\n                  <VListItemTitle>{{ t('media.search.byImdb') }}</VListItemTitle>\n                </VListItem>\n              </VList>\n            </VMenu>\n          </VBtn>\n          <VBtn\n            v-if=\"mediaDetail.type === '电影' || mediaDetail.douban_id || mediaDetail.bangumi_id\"\n            class=\"ms-2 mb-2\"\n            :color=\"getSubscribeColor\"\n            variant=\"tonal\"\n            @click=\"handleSubscribe()\"\n          >\n            <template #prepend>\n              <VIcon :icon=\"getSubscribeIcon\" />\n            </template>\n            {{ isSubscribed ? t('media.status.subscribed') : t('media.actions.subscribe') }}\n          </VBtn>\n          <VBtn v-if=\"existsItemId\" class=\"ms-2 mb-2\" variant=\"tonal\" @click=\"handlePlay()\">\n            <template #prepend>\n              <VIcon icon=\"mdi-play\" />\n            </template>\n            {{ t('media.actions.playOnline') }}\n          </VBtn>\n        </div>\n      </div>\n      <div class=\"media-overview\">\n        <div class=\"media-overview-left\">\n          <div v-if=\"mediaDetail.tagline\" class=\"tagline\">\n            {{ mediaDetail.tagline }}\n          </div>\n          <h2 v-if=\"mediaDetail.overview\">{{ t('media.overview') }}</h2>\n          <p>{{ mediaDetail.overview }}</p>\n          <ul v-if=\"mediaDetail.tmdb_id\" class=\"media-crew\">\n            <li v-for=\"director in mediaDetail.directors\" :key=\"director.id\">\n              <span>{{ director.job }}</span>\n              <RouterLink :to=\"`/person?personid=${director.id}`\" class=\"crew-name\" target=\"_blank\">\n                {{ director.name }}\n              </RouterLink>\n            </li>\n          </ul>\n          <ul v-if=\"!mediaDetail.tmdb_id && mediaDetail.douban_id\" class=\"media-crew\">\n            <li v-for=\"director in mediaDetail.directors\" :key=\"director.id\">\n              <span>{{ joinArray(director.roles) }}</span>\n              <a class=\"crew-name\" :href=\"`${director.url}`\" target=\"_blank\">{{ director.name }}</a>\n            </li>\n          </ul>\n          <div class=\"mt-6\">\n            <a\n              v-if=\"mediaDetail.tmdb_id\"\n              class=\"mb-2 mr-2 inline-flex last:mr-0\"\n              :href=\"getTheMovieDbLink()\"\n              target=\"_blank\"\n            >\n              <div\n                class=\"inline-flex cursor-pointer items-center rounded-full bg-gray-600 px-2 py-1 text-sm text-gray-200 ring-1 ring-gray-500 transition hover:bg-gray-700\"\n              >\n                <VIcon icon=\"mdi-link\" />\n                <span class=\"ms-1\">TheMovieDb</span>\n              </div>\n            </a>\n            <div v-if=\"mediaDetail.douban_id\" class=\"mb-2 mr-2 inline-flex last:mr-0\" @click=\"handleDoubanClick\">\n              <div\n                class=\"inline-flex cursor-pointer items-center rounded-full bg-gray-600 px-2 py-1 text-sm text-gray-200 ring-1 ring-gray-500 transition hover:bg-gray-700\"\n              >\n                <VIcon icon=\"mdi-link\" />\n                <span class=\"ms-1\">豆瓣</span>\n              </div>\n            </div>\n            <a v-if=\"mediaDetail.imdb_id\" class=\"mb-2 mr-2 inline-flex last:mr-0\" :href=\"getImdbLink()\" target=\"_blank\">\n              <div\n                class=\"inline-flex cursor-pointer items-center rounded-full bg-gray-600 px-2 py-1 text-sm text-gray-200 ring-1 ring-gray-500 transition hover:bg-gray-700\"\n              >\n                <VIcon icon=\"mdi-link\" />\n                <span class=\"ms-1\">IMDb</span>\n              </div>\n            </a>\n            <a v-if=\"mediaDetail.tvdb_id\" class=\"mb-2 mr-2 inline-flex last:mr-0\" :href=\"getTvdbLink()\" target=\"_blank\">\n              <div\n                class=\"inline-flex cursor-pointer items-center rounded-full bg-gray-600 px-2 py-1 text-sm text-gray-200 ring-1 ring-gray-500 transition hover:bg-gray-700\"\n              >\n                <VIcon icon=\"mdi-link\" />\n                <span class=\"ms-1\">TheTvDb</span>\n              </div>\n            </a>\n            <a\n              v-if=\"mediaDetail.bangumi_id\"\n              class=\"mb-2 mr-2 inline-flex last:mr-0\"\n              :href=\"getBangumiLink()\"\n              target=\"_blank\"\n            >\n              <div\n                class=\"inline-flex cursor-pointer items-center rounded-full bg-gray-600 px-2 py-1 text-sm text-gray-200 ring-1 ring-gray-500 transition hover:bg-gray-700\"\n              >\n                <VIcon icon=\"mdi-link\" />\n                <span class=\"ms-1\">Bangumi</span>\n              </div>\n            </a>\n          </div>\n          <h2 v-if=\"mediaDetail.type === '电视剧' && mediaDetail.tmdb_id\" class=\"py-4\">{{ t('media.seasons') }}</h2>\n          <div v-if=\"mediaDetail.type === '电视剧' && mediaDetail.tmdb_id\" class=\"flex w-full flex-col space-y-2\">\n            <VExpansionPanels>\n              <VExpansionPanel\n                v-for=\"season in getMediaSeasons\"\n                :key=\"season.season_number\"\n                @group:selected=\"loadSeasonEpisodes(season.season_number || 0)\"\n              >\n                <VExpansionPanelTitle>\n                  <template #default>\n                    <div class=\"flex flex-row items-center justify-between\">\n                      <span class=\"font-weight-bold\">{{\n                        season.season_number === 0 && season.name ?\n                        season.name : t('media.seasonNumber', { number: season.season_number })\n                        }}</span>\n                      <VChip size=\"small\" class=\"ms-1\">\n                        {{ t('media.episodeCount', { count: season.episode_count }) }}\n                      </VChip>\n                      <div class=\"absolute right-12\">\n                        <VChip v-if=\"seasonsNotExisted\" :color=\"getExistColor(season.season_number || 0)\" flat>\n                          {{ getExistText(season.season_number || 0) }}\n                        </VChip>\n                        <IconBtn\n                          class=\"ms-1\"\n                          :color=\"seasonsSubscribed[season.season_number || 0] ? 'error' : 'warning'\"\n                          variant=\"text\"\n                          @click.stop=\"handleSubscribe(season.season_number ?? null)\"\n                        >\n                          <VIcon\n                            :icon=\"seasonsSubscribed[season.season_number || 0] ? 'mdi-heart' : 'mdi-heart-outline'\"\n                          />\n                        </IconBtn>\n                      </div>\n                    </div>\n                  </template>\n                </VExpansionPanelTitle>\n                <VExpansionPanelText>\n                  <template #default>\n                    <LoadingBanner v-if=\"!seasonEpisodesInfo[season.season_number || 0]\" class=\"mt-3\" />\n                    <div class=\"flex flex-col justify-center divide-y divide-gray-700\">\n                      <div\n                        v-for=\"episode in seasonEpisodesInfo[season.season_number || 0]\"\n                        :key=\"episode.episode_number\"\n                        class=\"flex flex-col space-y-4 py-4 xl:flex-row xl:space-y-4 xl:space-x-4\"\n                      >\n                        <div class=\"flex-1\">\n                          <div class=\"flex flex-col space-y-2 lg:flex-row lg:items-center lg:space-y-0 lg:space-x-2\">\n                            <h3 class=\"text-lg\">{{ episode.episode_number }} - {{ episode.name }}</h3>\n                            <div class=\"flex items-center space-x-2\">\n                              <span\n                                class=\"px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap cursor-default bg-gray-700 !text-gray-300\"\n                              >\n                                {{ episode.air_date }}\n                              </span>\n                            </div>\n                            <VIcon\n                              v-if=\"\n                                existsEpisodes[season.season_number || 0] &&\n                                existsEpisodes[season.season_number || 0].includes(episode.episode_number || 0)\n                              \"\n                              color=\"success\"\n                              icon=\"mdi-check-circle\"\n                              class=\"ms-2\"\n                              size=\"small\"\n                            />\n                          </div>\n                          <p>{{ episode.overview }}</p>\n                        </div>\n                        <VImg\n                          cover\n                          class=\"rounded-lg\"\n                          max-width=\"15rem\"\n                          :src=\"getEpisodeImage(episode.still_path || '')\"\n                          alt=\"\"\n                        />\n                      </div>\n                    </div>\n                  </template>\n                </VExpansionPanelText>\n              </VExpansionPanel>\n            </VExpansionPanels>\n          </div>\n        </div>\n        <div v-if=\"mediaDetail.tmdb_id\" class=\"media-overview-right\">\n          <div class=\"media-facts\">\n            <div v-if=\"mediaDetail.vote_average\" class=\"media-ratings\">\n              <VRating v-model=\"mediaDetail.vote_average\" density=\"compact\" length=\"10\" class=\"ma-2\" readonly />\n            </div>\n            <div v-if=\"mediaDetail.tmdb_id\" class=\"media-fact\">\n              <span>ID</span>\n              <span class=\"media-fact-value\">{{ mediaDetail.tmdb_id }}</span>\n            </div>\n            <div v-if=\"mediaDetail.original_title || mediaDetail.original_name\" class=\"media-fact\">\n              <span>{{ t('media.info.originalTitle') }}</span>\n              <span class=\"media-fact-value\">{{ mediaDetail.original_title || mediaDetail.original_name }}</span>\n            </div>\n            <div v-if=\"mediaDetail.status\" class=\"media-fact\">\n              <span>{{ t('media.info.status') }}</span>\n              <span class=\"media-fact-value\">{{ mediaDetail.status }}</span>\n            </div>\n            <div v-if=\"mediaDetail.release_date || mediaDetail.first_air_date\" class=\"media-fact\">\n              <span>{{ t('media.info.releaseDate') }}</span>\n              <span class=\"media-fact-value\">\n                <span class=\"flex items-center justify-end\">\n                  <svg\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                    fill=\"none\"\n                    viewBox=\"0 0 24 24\"\n                    stroke-width=\"1.5\"\n                    stroke=\"currentColor\"\n                    aria-hidden=\"true\"\n                    class=\"h-4 w-4\"\n                  >\n                    <path\n                      stroke-linecap=\"round\"\n                      stroke-linejoin=\"round\"\n                      d=\"M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z\"\n                    />\n                  </svg>\n                  <span class=\"ml-1.5\">{{ mediaDetail.release_date || mediaDetail.first_air_date }}</span>\n                </span>\n              </span>\n            </div>\n            <div v-if=\"mediaDetail.type === '电影' && getEarliestReleaseDate\" class=\"media-fact\">\n              <span>{{ t(getEarliestReleaseDate.type === 4 ? 'media.info.digitalRelease' : 'media.info.physicalRelease') }}</span>\n              <span class=\"media-fact-value\">\n                <span class=\"flex items-center justify-end\">\n                  <span class=\"inline-flex items-center justify-center h-4 w-4 text-[0.6rem] font-bold text-current border border-current leading-none\">\n                    {{ getEarliestReleaseDate.iso_code }}\n                  </span>\n                  <span class=\"ml-1.5\">{{ getEarliestReleaseDate.date.slice(0, 10) }}</span>\n                </span>\n              </span>\n            </div>\n            <div v-if=\"mediaDetail.original_language\" class=\"media-fact\">\n              <span>{{ t('media.info.originalLanguage') }}</span>\n              <span class=\"media-fact-value\">{{ mediaDetail.original_language }}</span>\n            </div>\n            <div v-if=\"mediaDetail.production_countries\" class=\"media-fact\">\n              <span>{{ t('media.info.productionCountries') }}</span>\n              <span class=\"media-fact-value\">\n                <span\n                  v-for=\"country in getProductionCountries\"\n                  :key=\"country\"\n                  class=\"flex items-center justify-end text-end\"\n                >\n                  {{ country }}\n                </span>\n              </span>\n            </div>\n            <div class=\"media-fact border-b-0\">\n              <span>{{ t('media.info.productionCompanies') }}</span>\n              <span class=\"media-fact-value text-end\">\n                <span v-for=\"company in getProductionCompanies\" :key=\"company\" class=\"block\">{{ company }}</span>\n              </span>\n            </div>\n          </div>\n        </div>\n        <div v-else-if=\"mediaDetail.douban_id\" class=\"media-overview-right\">\n          <div class=\"media-facts\">\n            <div v-if=\"mediaDetail.vote_average\" class=\"media-ratings\">\n              <VRating v-model=\"mediaDetail.vote_average\" density=\"compact\" length=\"10\" class=\"ma-2\" readonly />\n            </div>\n            <div v-if=\"mediaDetail.douban_id\" class=\"media-fact\">\n              <span>{{ t('media.info.doubanId') }}</span>\n              <span class=\"media-fact-value\">{{ mediaDetail.douban_id }}</span>\n            </div>\n            <div v-if=\"mediaDetail.original_title\" class=\"media-fact\">\n              <span>{{ t('media.info.originalTitle') }}</span>\n              <span class=\"media-fact-value\">{{ mediaDetail.original_title }}</span>\n            </div>\n            <div v-if=\"mediaDetail.release_date\" class=\"media-fact\">\n              <span>{{ t('media.info.releaseDate') }}</span>\n              <span class=\"media-fact-value\">\n                {{ mediaDetail.release_date }}\n              </span>\n            </div>\n            <div v-if=\"mediaDetail.production_countries\" class=\"media-fact border-b-0\">\n              <span>{{ t('media.info.productionCountries') }}</span>\n              <span class=\"media-fact-value\">\n                <span\n                  v-for=\"country in getProductionCountries\"\n                  :key=\"country\"\n                  class=\"flex items-center justify-end text-end\"\n                >\n                  {{ country }}\n                </span>\n              </span>\n            </div>\n          </div>\n        </div>\n        <div v-else-if=\"mediaDetail.bangumi_id\" class=\"media-overview-right\">\n          <div class=\"media-facts\">\n            <div v-if=\"mediaDetail.vote_average\" class=\"media-ratings\">\n              <VRating v-model=\"mediaDetail.vote_average\" density=\"compact\" length=\"10\" class=\"ma-2\" readonly />\n            </div>\n            <div v-if=\"mediaDetail.bangumi_id\" class=\"media-fact\">\n              <span>ID</span>\n              <span class=\"media-fact-value\">{{ mediaDetail.bangumi_id }}</span>\n            </div>\n            <div v-if=\"mediaDetail.original_title\" class=\"media-fact\">\n              <span>{{ t('media.info.originalTitle') }}</span>\n              <span class=\"media-fact-value\">{{ mediaDetail.original_title }}</span>\n            </div>\n            <div v-if=\"mediaDetail.release_date\" class=\"media-fact border-b-0\">\n              <span>{{ t('media.info.releaseDate') }}</span>\n              <span class=\"media-fact-value\">\n                {{ mediaDetail.release_date }}\n              </span>\n            </div>\n          </div>\n        </div>\n      </div>\n      <div v-if=\"mediaDetail.tmdb_id\">\n        <PersonCardSlideView\n          :apipath=\"`tmdb/credits/${mediaDetail.tmdb_id}/${mediaProps.type}`\"\n          :linkurl=\"`/credits/tmdb/credits/${mediaDetail.tmdb_id}/${mediaProps.type}?title=${t(\n            'media.castAndCrew',\n          )}&type=tmdb`\"\n          :title=\"t('media.castAndCrew')\"\n          type=\"tmdb\"\n        />\n      </div>\n      <div v-else-if=\"mediaDetail.douban_id\">\n        <PersonCardSlideView\n          :apipath=\"`douban/credits/${mediaDetail.douban_id}/${mediaProps.type}`\"\n          :linkurl=\"`/credits/douban/credits/${mediaDetail.douban_id}/${mediaProps.type}?title=${t(\n            'media.castAndCrew',\n          )}&type=douban`\"\n          :title=\"t('media.castAndCrew')\"\n          type=\"douban\"\n        />\n      </div>\n      <div v-else-if=\"mediaDetail.bangumi_id\">\n        <PersonCardSlideView\n          :apipath=\"`bangumi/credits/${mediaDetail.bangumi_id}`\"\n          :linkurl=\"`/credits/bangumi/credits/${mediaDetail.bangumi_id}?title=${t('media.castAndCrew')}&type=bangumi`\"\n          :title=\"t('media.castAndCrew')\"\n          type=\"bangumi\"\n        />\n      </div>\n      <div v-if=\"mediaDetail.tmdb_id\">\n        <MediaCardSlideView\n          :apipath=\"`tmdb/recommend/${mediaDetail.tmdb_id}/${mediaProps.type}`\"\n          :linkurl=\"`/browse/tmdb/recommend/${mediaDetail.tmdb_id}/${mediaProps.type}?title=${t(\n            'media.recommendations',\n          )}`\"\n          :title=\"t('media.recommendations')\"\n        />\n      </div>\n      <div v-else-if=\"mediaDetail.douban_id\">\n        <MediaCardSlideView\n          :apipath=\"`douban/recommend/${mediaDetail.douban_id}/${mediaProps.type}`\"\n          :linkurl=\"`/browse/douban/recommend/${mediaDetail.douban_id}/${mediaProps.type}?title=${t(\n            'media.recommendations',\n          )}`\"\n          :title=\"t('media.recommendations')\"\n        />\n      </div>\n      <div v-else-if=\"mediaDetail.bangumi_id\">\n        <MediaCardSlideView\n          :apipath=\"`bangumi/recommend/${mediaDetail.bangumi_id}`\"\n          :linkurl=\"`/browse/bangumi/recommend/${mediaDetail.bangumi_id}?title=${t('media.recommendations')}`\"\n          :title=\"t('media.recommendations')\"\n        />\n      </div>\n      <div v-if=\"mediaDetail.tmdb_id\">\n        <MediaCardSlideView\n          :apipath=\"`tmdb/similar/${mediaDetail.tmdb_id}/${mediaProps.type}`\"\n          :linkurl=\"`/browse/tmdb/similar/${mediaDetail.tmdb_id}/${mediaProps.type}?title=${t('media.similar')}`\"\n          :title=\"t('media.similar')\"\n        />\n      </div>\n    </div>\n  </div>\n  <NoDataFound\n    v-if=\"!mediaDetail.tmdb_id && !mediaDetail.douban_id && !mediaDetail.bangumi_id && isRefreshed\"\n    error-code=\"500\"\n    :error-title=\"t('media.error.title')\"\n    :error-description=\"t('media.error.noMediaInfo')\"\n  />\n  <!-- 订阅编辑弹窗 -->\n  <SubscribeEditDialog\n    v-if=\"subscribeEditDialog\"\n    v-model=\"subscribeEditDialog\"\n    :subid=\"subscribeId\"\n    @close=\"subscribeEditDialog = false\"\n    @save=\"subscribeEditDialog = false\"\n    @remove=\"onSubscribeEditRemove\"\n  />\n  <!-- 站点选择对话框 -->\n  <SearchSiteDialog\n    v-if=\"chooseSiteDialog\"\n    v-model=\"chooseSiteDialog\"\n    :sites=\"allSites\"\n    :selected=\"selectedSites\"\n    @search=\"searchSites\"\n    @close=\"chooseSiteDialog = false\"\n  />\n</template>\n\n<style lang=\"scss\" scoped>\n.vue-media-back {\n  --media-backdrop-edge-opacity: 1;\n\n  z-index: 0;\n  pointer-events: none;\n  background-image: linear-gradient(\n      180deg,\n      rgba(var(--v-theme-background), 0) 50%,\n      rgba(var(--v-theme-background), var(--media-backdrop-edge-opacity)) 100%\n    ),\n    linear-gradient(\n      0deg,\n      rgba(var(--v-theme-background), 0) 80%,\n      rgba(var(--v-theme-background), var(--media-backdrop-edge-opacity)) 100%\n    ),\n    linear-gradient(\n      90deg,\n      rgba(var(--v-theme-background), 0) 50%,\n      rgba(var(--v-theme-background), var(--media-backdrop-edge-opacity)) 100%\n    ),\n    linear-gradient(\n      270deg,\n      rgba(var(--v-theme-background), 0) 50%,\n      rgba(var(--v-theme-background), var(--media-backdrop-edge-opacity)) 100%\n    );\n  margin-block-start: calc(-70px - env(safe-area-inset-top));\n}\n\n.vue-media-back-image {\n  background-image: none;\n}\n\n.media-detail-transparent .vue-media-back-overlay {\n  display: none;\n}\n\n.media-detail-transparent .vue-media-back-image {\n  opacity: 0.78;\n  mask-image: linear-gradient(to bottom, transparent 0%, #000 16%, #000 58%, transparent 100%),\n    linear-gradient(to right, transparent 0%, #000 10%, #000 90%, transparent 100%);\n  mask-composite: intersect;\n  -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 16%, #000 58%, transparent 100%),\n    linear-gradient(to right, transparent 0%, #000 10%, #000 90%, transparent 100%);\n  -webkit-mask-composite: source-in;\n}\n\n.media-page {\n  position: relative;\n  z-index: 1;\n  background-position: 50%;\n  background-size: cover;\n  margin-block-start: calc(-4rem - env(safe-area-inset-top));\n  margin-inline: -1rem;\n  padding-block-start: calc(4rem + env(safe-area-inset-top));\n  padding-inline: 1rem;\n}\n\n.media-header {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  padding-block-start: 1rem;\n}\n\n@media (width >= 1280px) {\n  .media-header {\n    flex-direction: row;\n    align-items: flex-end;\n  }\n}\n\n.media-overview {\n  display: flex;\n  flex-direction: column;\n  padding-block: 2rem 1rem;\n}\n\n@media (width >= 1024px) {\n  .media-overview {\n    flex-direction: row;\n  }\n}\n\n.media-poster {\n  overflow: hidden;\n  border-radius: 0.25rem;\n  box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);\n  inline-size: 8rem;\n\n  --tw-shadow: 0 1px 3px 0 rgba(0, 0, 0, 10%), 0 1px 2px -1px rgba(0, 0, 0, 10%);\n  --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);\n}\n\n@media (width >= 1280px) {\n  .media-poster {\n    inline-size: 13rem;\n    margin-inline-end: 1rem;\n  }\n}\n\n@media (width >= 768px) {\n  .media-poster {\n    border-radius: 0.5rem;\n    box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);\n    inline-size: 11rem;\n\n    --tw-shadow: 0 25px 50px -12px rgba(0, 0, 0, 25%);\n    --tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color);\n  }\n}\n\n.media-title {\n  display: flex;\n  flex: 1 1 0%;\n  flex-direction: column;\n  margin-block-start: 1rem;\n  text-align: center;\n}\n\n@media (width >= 1280px) {\n  .media-title {\n    margin-block-start: 0;\n    margin-inline-end: 1rem;\n    text-align: start;\n  }\n}\n\n.media-title > h1 {\n  font-size: 1.5rem;\n  font-weight: 700;\n  line-height: 2rem;\n}\n\n@media (width >= 1280px) {\n  .media-title > h1 {\n    font-size: 2.25rem;\n    line-height: 2.5rem;\n  }\n}\n\nul.media-crew {\n  display: grid;\n  gap: 1.5rem;\n  grid-template-columns: repeat(2, minmax(0, 1fr));\n  margin-block-start: 1.5rem;\n}\n\n@media (width >= 640px) {\n  ul.media-crew {\n    grid-template-columns: repeat(3, minmax(0, 1fr));\n  }\n}\n\nul.media-crew > li {\n  display: flex;\n  flex-direction: column;\n  font-weight: 700;\n  grid-column: span 1 / span 1;\n}\n\na.crew-name {\n  font-weight: 400;\n}\n\n.media-status {\n  margin-block-end: 0.5rem;\n}\n\n.media-attributes {\n  display: flex;\n  flex-wrap: wrap;\n  align-items: center;\n  justify-content: center;\n  margin-block-start: 0.25rem;\n}\n\n@media (width >= 1280px) {\n  .media-attributes {\n    justify-content: flex-start;\n    font-size: 1rem;\n    line-height: 1.5rem;\n    margin-block-start: 0;\n  }\n}\n\n@media (width >= 640px) {\n  .media-attributes {\n    font-size: 0.875rem;\n    line-height: 1.25rem;\n  }\n}\n\n.media-actions {\n  position: relative;\n  display: flex;\n  flex-shrink: 0;\n  flex-wrap: wrap;\n  align-items: center;\n  justify-content: center;\n  margin-block-start: 1rem;\n}\n\n@media (width >= 1280px) {\n  .media-actions {\n    margin-block-start: 0;\n  }\n}\n\n@media (width >= 640px) {\n  .media-actions {\n    flex-wrap: nowrap;\n    justify-content: flex-end;\n  }\n}\n\n.media-overview-left {\n  flex: 1 1 0%;\n}\n\n@media (width >= 1024px) {\n  .media-overview-left {\n    margin-inline-end: 2rem;\n  }\n}\n\n.media-overview-right {\n  inline-size: 100%;\n  margin-block-start: 2rem;\n}\n\n@media (width >= 1024px) {\n  .media-overview-right {\n    inline-size: 20rem;\n    margin-block-start: 0;\n  }\n}\n\n.media-facts {\n  border-width: 1px;\n  border-color: rgb(55 65 81 / var(--tw-border-opacity));\n  border-radius: 0.5rem;\n  font-size: 0.875rem;\n  font-weight: 700;\n  line-height: 1.25rem;\n\n  --tw-border-opacity: 1;\n  --tw-bg-opacity: 1;\n  --tw-text-opacity: 1;\n}\n\n.media-ratings {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border-color: rgb(55 65 81 / var(--tw-border-opacity));\n  border-block-end-width: 1px;\n  font-weight: 500;\n  padding-block: 0.5rem;\n  padding-inline: 1rem;\n\n  --tw-border-opacity: 1;\n}\n\n.media-fact {\n  display: flex;\n  justify-content: space-between;\n  border-color: rgb(55 65 81 / var(--tw-border-opacity));\n  border-block-end-width: 1px;\n  padding-block: 0.5rem;\n  padding-inline: 1rem;\n\n  --tw-border-opacity: 1;\n}\n\n.media-overview h2 {\n  font-size: 1.25rem;\n  font-weight: 700;\n  line-height: 1.75rem;\n}\n\n@media (width >= 640px) {\n  .media-overview h2 {\n    font-size: 1.5rem;\n    line-height: 2rem;\n  }\n}\n\n.tagline {\n  font-size: 1.25rem;\n  font-style: italic;\n  line-height: 1.75rem;\n  margin-block-end: 1rem;\n}\n\n@media (width >= 1024px) {\n  .tagline {\n    font-size: 1.5rem;\n    line-height: 2rem;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/views/discover/PersonCardListView.vue",
    "content": "<script lang=\"ts\" setup>\nimport api from '@/api'\nimport PersonCard from '@/components/cards/PersonCard.vue'\nimport NoDataFound from '@/components/NoDataFound.vue'\nimport { useI18n } from 'vue-i18n'\n\nconst { t } = useI18n()\n\n// 输入参数\nconst props = defineProps({\n  apipath: String,\n  params: Object as PropType<{ [key: string]: any }>,\n  type: String,\n})\n\n// 判断是否有滚动条\nfunction hasScroll() {\n  return document.body.scrollHeight - (window.innerHeight || document.documentElement.clientHeight) > 2\n}\n\n// 当前页码\nconst page = ref(1)\n\n// 是否加载中\nconst loading = ref(false)\n\n// 是否加载完成\nconst isRefreshed = ref(false)\n\n// 数据列表\nconst dataList = ref<any>([])\nconst currData = ref<any>([])\n\n// 拼装参数\nfunction getParams() {\n  let params = {\n    page: page.value,\n  }\n  if (props.params) params = { ...params, ...props.params }\n\n  return params\n}\n\n// 获取列表数据\nasync function fetchData({ done }: { done: any }) {\n  try {\n    if (!props.apipath) return\n\n    // 如果正在加载中，直接返回\n    if (loading.value) {\n      done('ok')\n      return\n    }\n\n    // 加载到满屏或者加载出错\n    if (!hasScroll()) {\n      // 加载多次\n      while (!hasScroll()) {\n        // 设置加载中\n        loading.value = true\n        // 请求API\n        currData.value = await api.get(props.apipath, {\n          params: getParams(),\n        })\n        // 取消加载中\n        loading.value = false\n        // 标计为已请求完成\n        isRefreshed.value = true\n        if (currData.value.length === 0) {\n          // 如果没有数据，跳出\n          done('empty')\n          return\n        } else {\n          // 合并数据\n          dataList.value = [...dataList.value, ...currData.value]\n          // 页码+1\n          page.value++\n          // 返回加载成功\n          done('ok')\n        }\n      }\n    } else {\n      // 加载一次\n      // 设置加载中\n      loading.value = true\n      // 请求API\n      currData.value = await api.get(props.apipath, {\n        params: getParams(),\n      })\n      // 标计为已请求完成\n      isRefreshed.value = true\n      if (currData.value.length === 0) {\n        // 如果没有数据，跳出\n        done('empty')\n      } else {\n        // 合并数据\n        dataList.value = [...dataList.value, ...currData.value]\n        // 页码+1\n        page.value++\n        // 返回加载成功\n        done('ok')\n      }\n      // 取消加载中\n      loading.value = false\n    }\n  } catch (error) {\n    console.error(error)\n    // 返回加载失败\n    done('error')\n  }\n}\n</script>\n\n<template>\n  <LoadingBanner v-if=\"!isRefreshed\" class=\"mt-12\" />\n  <VInfiniteScroll mode=\"intersect\" side=\"end\" :items=\"dataList\" class=\"overflow-visible px-3\" @load=\"fetchData\">\n    <template #loading />\n    <template #empty />\n    <div v-if=\"dataList.length > 0\" class=\"grid gap-4 grid-media-card\" tabindex=\"0\">\n      <PersonCard v-for=\"data in dataList\" :key=\"data.id\" :person=\"data\" />\n    </div>\n    <NoDataFound\n      v-if=\"dataList.length === 0 && isRefreshed\"\n      error-code=\"404\"\n      :error-title=\"t('common.noData')\"\n      :error-description=\"t('error.networkError')\"\n    />\n  </VInfiniteScroll>\n</template>\n"
  },
  {
    "path": "src/views/discover/PersonCardSlideView.vue",
    "content": "<script lang=\"ts\" setup>\nimport PersonCard from '@/components/cards/PersonCard.vue'\nimport api from '@/api'\nimport SlideView from '@/components/slide/SlideView.vue'\n\n// 输入参数\nconst props = defineProps({\n  apipath: String,\n  linkurl: String,\n  title: String,\n  type: String,\n})\n\nprovide('rankingPropsKey', reactive({ ...props }))\n\n// 组件加载完成\nconst componentLoaded = ref(false)\n\n// 数据列表\nconst dataList = ref<any>([])\n\n// 获取订阅列表数据\nasync function fetchData() {\n  try {\n    if (!props.apipath) return\n\n    dataList.value = await api.get(props.apipath)\n    if (dataList.value.length > 0) componentLoaded.value = true\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 加载时获取数据\nonMounted(fetchData)\n</script>\n\n<template>\n  <SlideView v-if=\"componentLoaded\">\n    <template #content>\n      <template v-for=\"data in dataList\" :key=\"data.id\">\n        <PersonCard :person=\"data\" width=\"9rem\" />\n      </template>\n    </template>\n  </SlideView>\n</template>\n"
  },
  {
    "path": "src/views/discover/PersonDetailView.vue",
    "content": "<script setup lang=\"ts\">\nimport MediaCardListView from './MediaCardListView.vue'\nimport api from '@/api'\nimport personIcon from '@images/misc/person.png'\nimport type { Person } from '@/api/types'\nimport NoDataFound from '@/components/NoDataFound.vue'\nimport { useI18n } from 'vue-i18n'\nimport { useGlobalSettingsStore } from '@/stores'\n\n// 国际化\nconst { t } = useI18n()\n\n// 输入参数\nconst personProps = defineProps({\n  personid: String,\n  type: String,\n  source: String,\n})\n\n// 从 provide 中获取全局设置\n// 全局设置\nconst globalSettingsStore = useGlobalSettingsStore()\nconst globalSettings = globalSettingsStore.globalSettings\n\n// 媒体详情\nconst personDetail = ref<Person>({} as Person)\n\n// 是否已加载完成\nconst isRefreshed = ref(false)\n\n// 人物图片是否加载\nconst isImageLoaded = ref(false)\n\n// 调用API查询详情\nasync function getPersonDetail() {\n  if (personProps.personid) {\n    if (personProps.source === 'themoviedb') {\n      personDetail.value = await api.get(`tmdb/person/${personProps.personid}`)\n    } else if (personProps.source === 'douban') {\n      personDetail.value = await api.get(`douban/person/${personProps.personid}`)\n    } else if (personProps.source === 'bangumi') {\n      personDetail.value = await api.get(`bangumi/person/${personProps.personid}`)\n    }\n    isRefreshed.value = true\n  }\n}\n\n// 人物图片地址\nfunction getPersonImage() {\n  let url = ''\n  if (personProps.source === 'themoviedb') {\n    if (!personDetail.value?.profile_path) return personIcon\n    url = `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w600_and_h900_bestv2${personDetail.value?.profile_path}`\n  } else if (personProps.source === 'douban') {\n    if (!personDetail.value?.avatar) return personIcon\n    if (typeof personDetail.value?.avatar === 'object') {\n      url = personDetail.value?.avatar?.normal\n    } else {\n      url = personDetail.value?.avatar\n    }\n  } else if (personProps.source === 'bangumi') {\n    if (!personDetail.value?.images) return personIcon\n    url = personDetail.value?.images?.medium\n  } else {\n    return personIcon\n  }\n  // 使用图片缓存\n  if (globalSettings.GLOBAL_IMAGE_CACHE && url)\n    return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`\n  return url\n}\n\n// 将别名数组拆分为、分隔的字符串\nfunction getAlsoKnownAs() {\n  if (!personDetail.value?.also_known_as) return ''\n  if (personProps.source === 'themoviedb') {\n    return t('person.alias') + personDetail.value.also_known_as.join('、')\n  } else {\n    return personDetail.value.also_known_as.join('，')\n  }\n}\n\n// 参演作品路由地址\nfunction getPersonCreditsPath() {\n  let apipath = 'tmdb'\n  if (personProps.source === 'douban') {\n    apipath = 'douban'\n  } else if (personProps.source === 'bangumi') {\n    apipath = 'bangumi'\n  }\n  return `/browse/${apipath}/person/credits/${personDetail.value.id}?title=${t('person.credits')}`\n}\n\n// 参演作品API路径\nfunction getPersonCreditsApiPath() {\n  let apipath = 'tmdb'\n  if (personProps.source === 'douban') {\n    apipath = 'douban'\n  } else if (personProps.source === 'bangumi') {\n    apipath = 'bangumi'\n  }\n  return `${apipath}/person/credits/${personDetail.value.id}`\n}\n\nonBeforeMount(() => {\n  getPersonDetail()\n})\n</script>\n\n<template>\n  <LoadingBanner v-if=\"!isRefreshed\" class=\"mt-12\" />\n  <div v-if=\"personDetail.id\" class=\"max-w-8xl mx-auto px-4\">\n    <div class=\"relative z-10 mt-4 mb-8 flex flex-col items-center flex-md-row\">\n      <VAvatar\n        size=\"200\"\n        :class=\"{\n          'ring-1 ring-gray-700': isImageLoaded,\n        }\"\n      >\n        <VImg :src=\"getPersonImage()\" cover @load=\"isImageLoaded = true\" />\n      </VAvatar>\n      <div class=\"ms-3\">\n        <h1 class=\"text-3xl lg:text-4xl text-center text-lg-left\">\n          {{ personDetail.name }}\n        </h1>\n        <div class=\"mt-1 mb-2 space-y-1 text-xs sm:text-sm lg:text-base text-center text-lg-left\">\n          <div>\n            <span v-if=\"personDetail.birthday\">{{ personDetail.birthday }}</span>\n            <span v-if=\"personDetail.place_of_birth\"> | </span>\n            <span v-if=\"personDetail.place_of_birth\">{{ personDetail.place_of_birth }}</span>\n          </div>\n          <div v-if=\"personDetail.also_known_as\">{{ getAlsoKnownAs() }}</div>\n        </div>\n      </div>\n    </div>\n    <div class=\"relative text-left\">\n      <div class=\"group outline-none ring-0\" role=\"button\" tabindex=\"-1\">\n        <p class=\"pt-2 text-sm lg:text-base\" style=\"overflow-wrap: break-word\">\n          {{ personDetail.biography }}\n        </p>\n      </div>\n    </div>\n    <div>\n      <div class=\"slider-header\">\n        <RouterLink :to=\"getPersonCreditsPath()\" class=\"slider-title\">\n          <span>{{ t('person.credits') }}</span>\n          <VIcon icon=\"mdi-arrow-right-circle-outline\" class=\"ms-1\" />\n        </RouterLink>\n      </div>\n      <MediaCardListView :apipath=\"getPersonCreditsApiPath()\" />\n    </div>\n  </div>\n  <NoDataFound\n    v-if=\"!personDetail.id && isRefreshed\"\n    error-code=\"500\"\n    :error-title=\"t('error.title')\"\n    :error-description=\"t('error.networkError')\"\n  />\n</template>\n"
  },
  {
    "path": "src/views/discover/TheMovieDbView.vue",
    "content": "<script setup lang=\"ts\">\nimport MediaCardListView from '@/views/discover/MediaCardListView.vue'\nimport { useI18n } from 'vue-i18n'\n\nconst { t } = useI18n()\n\n// 电影或者电视剧 movies/tvs\nconst type = ref('movies')\n\n// 过滤参数\nconst filterParams = reactive({\n  sort_by: 'popularity.desc',\n  with_genres: '',\n  with_original_language: '',\n  with_keywords: '',\n  with_watch_providers: '',\n  vote_average: 0,\n  vote_count: 10,\n  release_date: '',\n})\n\n// TMDB 电影排序字典\nconst tmdbSortDict: Record<string, string> = {\n  'popularity.desc': t('tmdb.sortType.popularityDesc'),\n  'popularity.asc': t('tmdb.sortType.popularityAsc'),\n  'release_date.desc': t('tmdb.sortType.releaseDateDesc'),\n  'release_date.asc': t('tmdb.sortType.releaseDateAsc'),\n  'vote_average.desc': t('tmdb.sortType.voteAverageDesc'),\n  'vote_average.asc': t('tmdb.sortType.voteAverageAsc'),\n}\n\n// TMDB 电视剧排序字典\nconst tmdbTvSortDict: Record<string, string> = {\n  'popularity.desc': t('tmdb.sortType.popularityDesc'),\n  'popularity.asc': t('tmdb.sortType.popularityAsc'),\n  'first_air_date.desc': t('tmdb.sortType.firstAirDateDesc'),\n  'first_air_date.asc': t('tmdb.sortType.firstAirDateAsc'),\n  'vote_average.desc': t('tmdb.sortType.voteAverageDesc'),\n  'vote_average.asc': t('tmdb.sortType.voteAverageAsc'),\n}\n\n// TMDB电影风格字典\nconst tmdbMovieGenreDict: Record<string, string> = {\n  '28': t('tmdb.genreType.action'),\n  '12': t('tmdb.genreType.adventure'),\n  '16': t('tmdb.genreType.animation'),\n  '35': t('tmdb.genreType.comedy'),\n  '80': t('tmdb.genreType.crime'),\n  '99': t('tmdb.genreType.documentary'),\n  '18': t('tmdb.genreType.drama'),\n  '10751': t('tmdb.genreType.family'),\n  '14': t('tmdb.genreType.fantasy'),\n  '36': t('tmdb.genreType.history'),\n  '27': t('tmdb.genreType.horror'),\n  '10402': t('tmdb.genreType.music'),\n  '9648': t('tmdb.genreType.mystery'),\n  '10749': t('tmdb.genreType.romance'),\n  '878': t('tmdb.genreType.scienceFiction'),\n  '10770': t('tmdb.genreType.tvMovie'),\n  '53': t('tmdb.genreType.thriller'),\n  '10752': t('tmdb.genreType.war'),\n  '37': t('tmdb.genreType.western'),\n}\n\n// TMDB电视剧风格字典\nconst tmdbTvGenreDict: Record<string, string> = {\n  '10759': t('tmdb.genreType.actionAdventure'),\n  '16': t('tmdb.genreType.animation'),\n  '35': t('tmdb.genreType.comedy'),\n  '80': t('tmdb.genreType.crime'),\n  '99': t('tmdb.genreType.documentary'),\n  '18': t('tmdb.genreType.drama'),\n  '10751': t('tmdb.genreType.family'),\n  '10762': t('tmdb.genreType.kids'),\n  '9648': t('tmdb.genreType.mystery'),\n  '10763': t('tmdb.genreType.news'),\n  '10764': t('tmdb.genreType.reality'),\n  '10765': t('tmdb.genreType.sciFiFantasy'),\n  '10766': t('tmdb.genreType.soap'),\n  '10767': t('tmdb.genreType.talk'),\n  '10768': t('tmdb.genreType.warPolitics'),\n  '37': t('tmdb.genreType.western'),\n}\n\n// TMDB原始语言字典（主要语言）\nconst tmdbLanguageDict = {\n  'zh': t('tmdb.languageType.zh'),\n  'en': t('tmdb.languageType.en'),\n  'ja': t('tmdb.languageType.ja'),\n  'ko': t('tmdb.languageType.ko'),\n  'fr': t('tmdb.languageType.fr'),\n  'de': t('tmdb.languageType.de'),\n  'es': t('tmdb.languageType.es'),\n  'it': t('tmdb.languageType.it'),\n  'ru': t('tmdb.languageType.ru'),\n  'pt': t('tmdb.languageType.pt'),\n  'ar': t('tmdb.languageType.ar'),\n  'hi': t('tmdb.languageType.hi'),\n  'th': t('tmdb.languageType.th'),\n}\n\n// 当前Key\nconst currentKey = ref(0)\n\n// 类型变化\nwatch(type, () => {\n  if (!type.value) {\n    type.value = 'movies'\n  }\n  if (type.value === 'movies') {\n    if (!tmdbSortDict[filterParams.sort_by]) {\n      filterParams.sort_by = 'popularity.desc'\n    }\n    if (!tmdbMovieGenreDict[filterParams.with_genres]) {\n      filterParams.with_genres = ''\n    }\n  }\n  if (type.value === 'tvs') {\n    if (!tmdbTvSortDict[filterParams.sort_by]) {\n      filterParams.sort_by = 'popularity.desc'\n    }\n    if (!tmdbTvGenreDict[filterParams.with_genres]) {\n      filterParams.with_genres = ''\n    }\n  }\n  currentKey.value++\n})\n\n// 过滤参数变化\nwatch(filterParams, () => {\n  if (!filterParams.sort_by) {\n    filterParams.sort_by = 'popularity.desc'\n  }\n  currentKey.value++\n})\n</script>\n\n<template>\n  <div class=\"px-3\">\n    <div class=\"flex justify-start align-center\">\n      <div class=\"mr-5\">\n        <VLabel>{{ t('tmdb.type') }}</VLabel>\n      </div>\n      <VChipGroup v-model=\"type\">\n        <VChip :color=\"type == 'movies' ? 'primary' : ''\" filter tile value=\"movies\">{{ t('mediaType.movie') }}</VChip>\n        <VChip :color=\"type == 'tvs' ? 'primary' : ''\" filter tile value=\"tvs\">{{ t('mediaType.tv') }}</VChip>\n      </VChipGroup>\n    </div>\n    <div class=\"flex justify-start align-center\">\n      <div class=\"mr-5\">\n        <VLabel>{{ t('tmdb.sort') }}</VLabel>\n      </div>\n      <VChipGroup v-model=\"filterParams.sort_by\">\n        <VChip\n          :color=\"filterParams.sort_by == key ? 'primary' : ''\"\n          filter\n          tile\n          :value=\"key\"\n          v-for=\"(value, key) in type == 'movies' ? tmdbSortDict : tmdbTvSortDict\"\n          :key=\"key\"\n        >\n          {{ value }}\n        </VChip>\n      </VChipGroup>\n    </div>\n    <div class=\"flex justify-start align-center\">\n      <div class=\"mr-5\">\n        <VLabel>{{ t('tmdb.genre') }}</VLabel>\n      </div>\n      <VChipGroup v-model=\"filterParams.with_genres\">\n        <VChip\n          :color=\"filterParams.with_genres == key ? 'primary' : ''\"\n          filter\n          tile\n          :value=\"key\"\n          v-for=\"(value, key) in type == 'movies' ? tmdbMovieGenreDict : tmdbTvGenreDict\"\n          :key=\"key\"\n        >\n          {{ value }}\n        </VChip>\n      </VChipGroup>\n    </div>\n    <div class=\"flex justify-start align-center\">\n      <div class=\"mr-5\">\n        <VLabel>{{ t('tmdb.language') }}</VLabel>\n      </div>\n      <VChipGroup v-model=\"filterParams.with_original_language\">\n        <VChip\n          :color=\"filterParams.with_original_language == key ? 'primary' : ''\"\n          filter\n          tile\n          :value=\"key\"\n          v-for=\"(value, key) in tmdbLanguageDict\"\n          :key=\"key\"\n        >\n          {{ value }}\n        </VChip>\n      </VChipGroup>\n    </div>\n    <div class=\"flex justify-start align-center\">\n      <div class=\"mr-5\">\n        <VLabel>{{ t('tmdb.rating') }}</VLabel>\n      </div>\n      <VSlider\n        v-model=\"filterParams.vote_average\"\n        thumb-label\n        max=\"10\"\n        min=\"0\"\n        :step=\"1\"\n        class=\"align-center\"\n        hide-details\n      >\n        <template v-slot:append>\n          <VTextField\n            variant=\"outlined\"\n            width=\"5rem\"\n            v-model=\"filterParams.vote_count\"\n            density=\"compact\"\n            type=\"number\"\n            hide-details\n            single-line\n          />\n        </template>\n      </VSlider>\n    </div>\n  </div>\n\n  <div>\n    <MediaCardListView :key=\"currentKey\" :apipath=\"`discover/tmdb_${type}`\" :params=\"filterParams\" />\n  </div>\n</template>\n"
  },
  {
    "path": "src/views/plugin/PluginCardListView.vue",
    "content": "<script lang=\"ts\" setup>\nimport draggable from 'vuedraggable'\nimport { useToast } from 'vue-toastification'\nimport api from '@/api'\nimport type { Plugin } from '@/api/types'\nimport NoDataFound from '@/components/NoDataFound.vue'\nimport PluginAppCard from '@/components/cards/PluginAppCard.vue'\nimport { getLogoUrl } from '@/utils/imageUtils'\nimport { useDisplay } from 'vuetify'\nimport { isNullOrEmptyObject } from '@/@core/utils'\nimport { getPluginTabs } from '@/router/i18n-menu'\nimport PluginMarketSettingDialog from '@/components/dialog/PluginMarketSettingDialog.vue'\nimport { useDynamicButton } from '@/composables/useDynamicButton'\nimport { useI18n } from 'vue-i18n'\nimport PluginMixedSortCard from '@/components/cards/PluginMixedSortCard.vue'\nimport { usePWA } from '@/composables/usePWA'\nimport { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'\n\n// 国际化\nconst { t } = useI18n()\n\nconst route = useRoute()\n\n// 显示器宽度\nconst display = useDisplay()\n\n// APP\n// PWA模式检测\nconst { appMode } = usePWA()\n\n// 当前标签\nconst activeTab = ref('installed')\n\n// 获取插件标签页\nconst pluginTabs = computed(() => getPluginTabs(t))\n\n// 本地插件来源显示名称\nconst localRepoLabel = computed(() => t('plugin.local'))\n\n// 使用动态标签页\nconst { registerHeaderTab } = useDynamicHeaderTab()\n\n// 注册动态标签页（在setup顶层立即执行）\nregisterHeaderTab({\n  items: pluginTabs.value,\n  modelValue: activeTab,\n  appendButtons: [\n    {\n      icon: 'mdi-filter-multiple-outline',\n      variant: 'text',\n      color: computed(() =>\n        installedFilter.value || hasUpdateFilter.value || enabledFilter.value ? 'primary' : 'gray',\n      ),\n      class: 'settings-icon-button',\n      dataAttr: 'installed-filter-btn',\n      action: () => {\n        filterInstalledPluginDialog.value = true\n      },\n      show: computed(() => activeTab.value === 'installed'),\n    },\n    {\n      icon: 'mdi-filter-multiple-outline',\n      variant: 'text',\n      color: computed(() => (isFilterFormEmpty.value ? 'gray' : 'primary')),\n      class: 'settings-icon-button',\n      dataAttr: 'market-filter-btn',\n      action: () => {\n        filterMarketPluginDialog.value = true\n      },\n      show: computed(() => activeTab.value === 'market'),\n    },\n    {\n      icon: 'mdi-refresh',\n      variant: 'text',\n      color: 'gray',\n      class: 'settings-icon-button',\n      action: () => {\n        refreshMarket()\n      },\n      show: computed(() => activeTab.value === 'market'),\n    },\n    {\n      icon: 'mdi-arrow-left',\n      variant: 'text',\n      color: 'gray',\n      class: 'settings-icon-button',\n      action: () => {\n        backToMain()\n      },\n      show: computed(() => activeTab.value === 'installed' && !!currentFolder.value),\n    },\n  ],\n})\n\n// 插件ID参数\nconst pluginId = ref(route.query.id)\n\n// 当前排序字段\nconst activeSort = ref<string | null>(null)\n\n// 插件顺序配置\nconst orderConfig = ref<{ id: string; type?: string; order?: number }[]>([])\n\n// 排序选项\nconst sortOptions = computed(() => [\n  { title: t('plugin.sort.popular'), value: 'count' },\n  { title: t('plugin.sort.name'), value: 'plugin_name' },\n  { title: t('plugin.sort.author'), value: 'plugin_author' },\n  { title: t('plugin.sort.repository'), value: 'repo_url' },\n  { title: t('plugin.sort.latest'), value: 'add_time' },\n])\n\n// 加载中\nconst loading = ref(false)\n\n// 已安装插件列表\nconst dataList = ref<Plugin[]>([])\n\n// 计算已安装插件的名称列表\nconst installedPluginNames = computed(() => {\n  return dataList.value.map(item => item.plugin_name)\n})\n\n// 过滤后的已安装插件列表\nconst filteredDataList = ref<Plugin[]>([])\n\n// 未安装插件列表\nconst uninstalledList = ref<Plugin[]>([])\n\n// 插件市场插件列表\nconst marketList = ref<Plugin[]>([])\n\n// 排序后的未安装插件列表\nconst sortedUninstalledList = ref<Plugin[]>([])\n\n// 显示的未安装插件列表\nconst displayUninstalledList = ref<Plugin[]>([])\n\n// 是否刷新过\nconst isRefreshed = ref(false)\n\n// APP市场是否加载完成\nconst isAppMarketLoaded = ref(false)\n\n// APP市场窗口\nconst PluginAppDialog = ref(false)\n\n// 插件安装统计\nconst PluginStatistics = ref<{ [key: string]: number }>({})\n\n// 搜索窗口\nconst SearchDialog = ref(false)\n\n// 插件市场设置窗口\nconst MarketSettingDialog = ref(false)\n\n// 插件市场刷新状态\nconst isMarketRefreshing = ref(false)\n\n// 搜索关键字\nconst keyword = ref('')\n\n// 每一个插件的图标加载状态\nconst pluginIconLoaded = ref<{ [key: string]: boolean }>({})\n\n// 每一个插件的动作标识\nconst pluginActions: Ref<{ [key: string]: boolean }> = ref({})\n\n// 提示框\nconst $toast = useToast()\n\n// 进度框\nconst progressDialog = ref(false)\n\n// 进度框文本\nconst progressText = ref(t('plugin.installingPlugin'))\n\n// 过滤表单\nconst filterForm = reactive({\n  // 名称\n  name: '' as string,\n  // 作者\n  author: [] as string[],\n  // 标签\n  label: [] as string[],\n  // 插件库\n  repo: [] as string[],\n})\n\n// 默认背景\nconst defaultGradient =\n  'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(33, 150, 243, 0.7) 0%, rgba(33, 150, 243, 0.8) 100%)'\n// 默认文件夹图标\nconst defaultIcon = 'mdi-folder'\n// 默认文件夹颜色\nconst defaultColor = '#2196F3'\n\n// 计算过滤表单是否全部为空\nconst isFilterFormEmpty = computed(() => {\n  return (\n    !filterForm.name && filterForm.author.length === 0 && filterForm.label.length === 0 && filterForm.repo.length === 0\n  )\n})\n\n// 切换市场过滤器多选项\nfunction toggleMarketFilter(field: 'author' | 'label' | 'repo', value: string) {\n  const index = filterForm[field].indexOf(value)\n  if (index > -1) {\n    filterForm[field].splice(index, 1)\n  } else {\n    filterForm[field].push(value)\n  }\n}\n\n// 插件过滤条件\nconst installedFilter = ref(null)\n\n// 有新版本过滤条件\nconst hasUpdateFilter = ref(false)\n\n// 已启用过滤条件\nconst enabledFilter = ref(false)\n\n// 已安装插件过滤窗口\nconst filterInstalledPluginDialog = ref(false)\n\n// 插件市场过滤窗口\nconst filterMarketPluginDialog = ref(false)\n\n// 作者过滤项\nconst authorFilterOptions = ref<string[]>([])\n// 标签过滤项\nconst labelFilterOptions = ref<string[]>([])\n// 插件库过滤项\nconst repoFilterOptions = ref<string[]>([])\n\n// 插件文件夹配置\nconst pluginFolders: Ref<{ [key: string]: any }> = ref({})\n\n// 文件夹排序\nconst folderOrder = ref<string[]>([])\n\n// 当前查看的文件夹\nconst currentFolder = ref('')\n\n// 新建文件夹对话框\nconst newFolderDialog = ref(false)\n\n// 新文件夹名称\nconst newFolderName = ref('')\n\n// 获取文件夹内筛选后的插件\nconst getFilteredFolderPlugins = (folderName: string) => {\n  const folderData = pluginFolders.value[folderName]\n  const folderPluginIds = Array.isArray(folderData) ? folderData : folderData?.plugins || []\n\n  // 获取文件夹内的插件并应用筛选条件\n  const folderPlugins: Plugin[] = []\n  folderPluginIds.forEach((pluginId: string) => {\n    const plugin = dataList.value.find(p => p.id === pluginId)\n    if (plugin) {\n      folderPlugins.push(plugin)\n    }\n  })\n\n  // 应用筛选条件\n  return folderPlugins.filter(plugin => {\n    if (!installedFilter.value && !hasUpdateFilter.value && !enabledFilter.value) return true\n    if (hasUpdateFilter.value && enabledFilter.value) {\n      return plugin.has_update && plugin.state\n    }\n    if (hasUpdateFilter.value) return plugin.has_update\n    if (enabledFilter.value) return plugin.state\n    if (installedFilter.value) {\n      return plugin.plugin_name?.toLowerCase().includes((installedFilter.value as string).toLowerCase())\n    }\n    if (installedFilter.value) {\n      return plugin.plugin_name?.toLowerCase().includes((installedFilter.value as string).toLowerCase())\n    }\n    if (installedFilter.value) {\n      return plugin.plugin_name?.toLowerCase().includes((installedFilter.value as string).toLowerCase())\n    }\n    return true\n  })\n}\n\n// 显示的插件列表（考虑文件夹筛选）\nconst displayedPlugins = computed(() => {\n  if (!currentFolder.value) {\n    // 主列表：显示未归类的插件\n    const folderedPluginIds = new Set()\n    Object.values(pluginFolders.value).forEach(folderData => {\n      const plugins = Array.isArray(folderData) ? folderData : folderData.plugins || []\n      plugins.forEach((pid: string) => folderedPluginIds.add(pid))\n    })\n    return filteredDataList.value.filter(plugin => !folderedPluginIds.has(plugin.id))\n  } else {\n    // 文件夹内：返回筛选后的插件\n    return getFilteredFolderPlugins(currentFolder.value)\n  }\n})\n\n// 混合排序项目类型\ninterface MixedSortItem {\n  type: 'folder' | 'plugin'\n  id: string\n  data: any\n  order: number\n}\n\n// 混合排序列表（包含文件夹和插件）\nconst mixedSortList = ref<MixedSortItem[]>([])\n\n// 可拖拽的插件列表（文件夹内用）\nconst draggableFolderPlugins = ref<Plugin[]>([])\n\n// 是否正在拖拽排序中\nconst isDraggingSortMode = ref(false)\n\n// 显示的文件夹列表（按排序显示）\nconst displayedFolders = computed(() => {\n  if (currentFolder.value) return [] // 在文件夹内不显示其他文件夹\n\n  const folderNames = Object.keys(pluginFolders.value)\n\n  // 按排序显示文件夹\n  const sortedFolderNames = [...folderOrder.value].filter(name => folderNames.includes(name))\n  // 添加不在排序中的新文件夹\n  const unsortedFolders = folderNames.filter(name => !folderOrder.value.includes(name))\n  sortedFolderNames.push(...unsortedFolders)\n\n  return sortedFolderNames\n    .map(folderName => {\n      const folderData = pluginFolders.value[folderName]\n      const config = Array.isArray(folderData) ? {} : folderData\n\n      // 获取筛选后的插件数量\n      const filteredPlugins = getFilteredFolderPlugins(folderName)\n\n      return {\n        name: folderName,\n        pluginCount: filteredPlugins.length,\n        config: config,\n      }\n    })\n    .filter(folder => {\n      // 当有筛选条件时，只显示包含筛选后插件的文件夹\n      if (installedFilter.value || hasUpdateFilter.value || enabledFilter.value) {\n        return folder.pluginCount > 0\n      }\n      return true\n    })\n})\n\n// 更新混合排序列表\nfunction updateMixedSortList() {\n  if (isDraggingSortMode.value) return // 拖拽排序时跳过更新\n\n  if (!currentFolder.value) {\n    // 主列表：创建混合列表\n    const items: MixedSortItem[] = []\n\n    // 始终使用全局排序配置来创建混合列表\n    const allItems: { type: 'folder' | 'plugin'; id: string; data: any; order: number }[] = []\n\n    // 添加文件夹项目\n    displayedFolders.value.forEach(folder => {\n      const orderItem = orderConfig.value.find((item: any) => item.type === 'folder' && item.id === folder.name)\n      allItems.push({\n        type: 'folder',\n        id: folder.name,\n        data: folder,\n        order: orderItem?.order ?? 999,\n      })\n    })\n\n    // 添加插件项目\n    displayedPlugins.value.forEach(plugin => {\n      const orderItem = orderConfig.value.find((item: any) => item.type === 'plugin' && item.id === plugin.id)\n      allItems.push({\n        type: 'plugin',\n        id: plugin.id || '',\n        data: plugin,\n        order: orderItem?.order ?? 999,\n      })\n    })\n\n    // 按order排序\n    allItems.sort((a, b) => a.order - b.order)\n\n    // 转换为MixedSortItem格式\n    allItems.forEach((item, index) => {\n      items.push({\n        type: item.type,\n        id: item.id,\n        data: item.data,\n        order: index,\n      })\n    })\n\n    // 按order排序\n    items.sort((a, b) => a.order - b.order)\n    mixedSortList.value = items\n  } else {\n    // 文件夹内：只更新插件列表\n    draggableFolderPlugins.value = [...displayedPlugins.value]\n  }\n}\n\n// 监听相关数据变化，更新混合排序列表\nwatch(\n  [displayedPlugins, displayedFolders, orderConfig, folderOrder, installedFilter, hasUpdateFilter, enabledFilter],\n  () => {\n    // 只有在非拖拽状态下才更新\n    if (!isDraggingSortMode.value) {\n      updateMixedSortList()\n    }\n  },\n  {\n    immediate: true,\n    deep: true,\n  },\n)\n\n// 监听文件夹切换，更新列表\nwatch(currentFolder, () => {\n  // 只有在非拖拽状态下才更新\n  if (!isDraggingSortMode.value) {\n    updateMixedSortList()\n  }\n})\n\n// 加载插件顺序\nasync function loadPluginOrderConfig() {\n  try {\n    const response = await api.get('/user/config/PluginOrder')\n    if (response && response.data && response.data.value) {\n      const serverData = response.data.value\n      // 兼容服务端的旧格式和新格式\n      if (serverData.length > 0 && typeof serverData[0] === 'object' && 'type' in serverData[0]) {\n        orderConfig.value = serverData\n      } else {\n        // 旧格式，转换为新格式\n        orderConfig.value = serverData.map((item: any, index: number) => ({\n          id: typeof item === 'string' ? item : item.id,\n          type: 'plugin',\n          order: index,\n        }))\n      }\n    }\n  } catch (error) {\n    console.error('Failed to load plugin order config:', error)\n    orderConfig.value = []\n  }\n}\n\n// 按order的顺序对插件进行排序\nfunction sortPluginOrder() {\n  if (!orderConfig.value) {\n    return\n  }\n  if (dataList.value.length === 0) {\n    return\n  }\n  dataList.value.sort((a, b) => {\n    const aIndex = orderConfig.value.findIndex((item: { id: string }) => item.id === a.id)\n    const bIndex = orderConfig.value.findIndex((item: { id: string }) => item.id === b.id)\n    return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex)\n  })\n}\n\n// 保存混合排序\nasync function saveMixedSortOrder() {\n  try {\n    // 分离文件夹和插件，并记录它们的全局排序位置\n    const newFolderOrder: string[] = []\n    const newPluginOrder: Plugin[] = []\n    const globalOrder: { type: 'folder' | 'plugin'; id: string; order: number }[] = []\n\n    mixedSortList.value.forEach((item, index) => {\n      globalOrder.push({\n        type: item.type,\n        id: item.id,\n        order: index,\n      })\n\n      if (item.type === 'folder') {\n        newFolderOrder.push(item.id)\n      } else if (item.type === 'plugin') {\n        newPluginOrder.push(item.data)\n      }\n    })\n\n    // 更新文件夹排序并设置order属性\n    folderOrder.value = newFolderOrder\n    newFolderOrder.forEach((folderName, index) => {\n      if (pluginFolders.value[folderName]) {\n        // 找到该文件夹在全局排序中的位置\n        const globalOrderItem = globalOrder.find(item => item.type === 'folder' && item.id === folderName)\n        pluginFolders.value[folderName].order = globalOrderItem ? globalOrderItem.order : index\n      }\n    })\n\n    // 添加文件夹中的插件到插件列表末尾\n    Object.values(pluginFolders.value).forEach(folderData => {\n      const plugins = Array.isArray(folderData) ? folderData : folderData.plugins || []\n      plugins.forEach((id: string) => {\n        const folderPlugin = dataList.value.find(p => p.id === id)\n        if (folderPlugin && !newPluginOrder.find(p => p.id === id)) {\n          newPluginOrder.push(folderPlugin)\n        }\n      })\n    })\n\n    // 更新插件列表\n    filteredDataList.value = newPluginOrder\n\n    // 保存插件排序配置（包含全局排序信息）\n    const orderObj = globalOrder.map(item => ({\n      id: item.id,\n      type: item.type,\n      order: item.order,\n    }))\n    orderConfig.value = orderObj\n\n    // 保存到服务端\n    await api.post('/user/config/PluginOrder', orderObj)\n\n    // 保存文件夹排序\n    await savePluginFolders()\n  } catch (error) {\n    console.error(error)\n  } finally {\n    // 清除拖拽标志\n    isDraggingSortMode.value = false\n\n    // 在清除拖拽标志后更新混合排序列表显示\n    updateMixedSortList()\n  }\n}\n\n// 保存文件夹内插件顺序\nasync function saveFolderPluginOrder() {\n  if (!currentFolder.value) return\n\n  try {\n    // 更新文件夹内插件顺序\n    const folderData = pluginFolders.value[currentFolder.value]\n    if (folderData) {\n      const newPluginIds = draggableFolderPlugins.value.map(plugin => plugin.id)\n\n      if (Array.isArray(folderData)) {\n        // 旧格式，直接替换数组\n        pluginFolders.value[currentFolder.value] = newPluginIds\n      } else {\n        // 新格式，更新plugins字段\n        folderData.plugins = newPluginIds\n      }\n\n      // 更新全局排序配置中文件夹内插件的顺序\n      const folderOrderItem = orderConfig.value.find(\n        (item: any) => item.type === 'folder' && item.id === currentFolder.value,\n      )\n      const folderGlobalOrder = folderOrderItem?.order ?? 999\n\n      // 为文件夹内的插件分配连续的order值\n      newPluginIds.forEach((pluginId, index) => {\n        const existingItem = orderConfig.value.find((item: any) => item.type === 'plugin' && item.id === pluginId)\n        if (existingItem) {\n          existingItem.order = folderGlobalOrder + 0.1 + index * 0.01 // 使用小数确保在文件夹后面\n        } else {\n          orderConfig.value.push({\n            id: pluginId,\n            type: 'plugin',\n            order: folderGlobalOrder + 0.1 + index * 0.01,\n          })\n        }\n      })\n\n      // 保存全局排序配置\n      await api.post('/user/config/PluginOrder', orderConfig.value)\n\n      // 保存到后端\n      await savePluginFolders()\n    }\n  } catch (error) {\n    console.error(error)\n  } finally {\n    // 清除拖拽标志\n    isDraggingSortMode.value = false\n  }\n}\n\n// 初始化过滤选项\nfunction initOptions(item: Plugin) {\n  const optionValue = (options: Array<string>, value: string | undefined, preferred = false) => {\n    if (!value || options.includes(value)) return\n    if (preferred) options.unshift(value)\n    else options.push(value)\n  }\n  const optionMutipleValue = (options: Array<string>, value: string | undefined) => {\n    value && value.split(',').forEach(v => !options.includes(v) && options.push(v))\n  }\n  optionValue(authorFilterOptions.value, item.plugin_author)\n  optionMutipleValue(labelFilterOptions.value, item.plugin_label)\n  optionValue(\n    repoFilterOptions.value,\n    handleRepoUrl(item),\n    Boolean(item.is_local || item.repo_url?.startsWith('local://')),\n  )\n}\n\n// 关闭插件市场窗口\nfunction pluginDialogClose() {\n  PluginAppDialog.value = false\n}\n\n// 安装插件\nasync function installPlugin(item: Plugin) {\n  try {\n    // 显示等待提示框\n    progressDialog.value = true\n    progressText.value = t('plugin.installing', { name: item?.plugin_name, version: item?.plugin_version })\n\n    const result: { [key: string]: any } = await api.get(`plugin/install/${item?.id}`, {\n      params: {\n        repo_url: item?.repo_url,\n        force: item?.has_update,\n      },\n    })\n\n    // 隐藏等待提示框\n    progressDialog.value = false\n\n    if (result.success) {\n      $toast.success(t('plugin.installSuccess', { name: item?.plugin_name }))\n      // 清空过滤条件\n      hasUpdateFilter.value = false\n      enabledFilter.value = false\n      installedFilter.value = null\n      // 刷新\n      await refreshData()\n    } else {\n      $toast.error(t('plugin.installFailed', { name: item?.plugin_name, message: result.message }))\n    }\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 打开插件搜索结果\nfunction openPlugin(item: Plugin) {\n  // 如果是已安装插件则打开插件详情\n  if (item.installed === true) {\n    // 标记插件动作\n    pluginActions.value[item.id || '0'] = true\n  } else {\n    // 如果是未安装插件则安装\n    installPlugin(item)\n  }\n  closeSearchDialog()\n}\n\n// 关闭插件搜索窗口\nfunction closeSearchDialog() {\n  SearchDialog.value = false\n}\n\n// 插件图标加载错误\nfunction pluginIconError(item: Plugin) {\n  pluginIconLoaded.value[item.id || '0'] = false\n}\n\n// 插件图标地址\nfunction pluginIcon(item: Plugin) {\n  // 如果图片加载错误\n  if (pluginIconLoaded.value[item.id || '0'] === false) return getLogoUrl('plugin')\n  // 如果是网络图片则使用代理后返回\n  if (item?.plugin_icon?.startsWith('http'))\n    return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(item?.plugin_icon)}&cache=true`\n\n  return `./plugin_icon/${item?.plugin_icon}`\n}\n\n// 过滤插件\nconst filterPlugins = computed(() => {\n  const all_list = [...dataList.value, ...uninstalledList.value]\n  return all_list.filter((item: Plugin) => {\n    // 需要忽略大小写\n    return (\n      item.plugin_name?.toLowerCase().includes(keyword.value.toLowerCase()) ||\n      item.plugin_desc?.toLowerCase().includes(keyword.value.toLowerCase()) ||\n      !keyword\n    )\n  })\n})\n\n// 获取插件列表数据\nasync function fetchInstalledPlugins() {\n  try {\n    loading.value = true\n    dataList.value = await api.get('plugin/', {\n      params: {\n        state: 'installed',\n      },\n    })\n    // 排序\n    sortPluginOrder()\n    loading.value = false\n    isRefreshed.value = true\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 获取未安装插件列表数据\nasync function fetchUninstalledPlugins(force: boolean = false) {\n  try {\n    loading.value = true\n    uninstalledList.value = await api.get('plugin/', {\n      params: {\n        state: 'market',\n        force: force,\n      },\n    })\n    // 设置更新状态\n    for (const uninstalled of uninstalledList.value) {\n      for (const data of dataList.value) {\n        if (uninstalled.id === data.id) {\n          data.has_update = true\n          data.repo_url = uninstalled.repo_url\n          data.history = uninstalled.history\n        }\n      }\n    }\n    loading.value = false\n    isRefreshed.value = true\n    // 更新插件市场列表\n    // 排除已安装且有更新的，上面的问题在于\"本地存在未安装的旧版本插件且云端有更新时\"不会在插件市场展示\n    marketList.value = uninstalledList.value.filter(item => !(item.has_update && item.installed))\n    // 初始化过滤选项\n    repoFilterOptions.value = []\n    marketList.value.forEach(initOptions)\n    // 设置APP市场加载完成\n    isAppMarketLoaded.value = true\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 加载插件统计数据\nasync function getPluginStatistics() {\n  try {\n    PluginStatistics.value = await api.get('plugin/statistic')\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 加载所有数据\nasync function refreshData() {\n  await fetchInstalledPlugins()\n  await fetchUninstalledPlugins()\n  getPluginStatistics()\n  // 重新加载文件夹配置，确保分身插件能正确显示在文件夹中\n  await loadPluginFolders()\n}\n\n// 对uninstalledList进行排序到sortedUninstalledList\nwatch([marketList, filterForm, activeSort, PluginStatistics], () => {\n  // 匹配过滤函数\n  const match = (filter: Array<string>, value: string | undefined) =>\n    filter.length === 0 || (value && filter.includes(value))\n  const matchMultiple = (filter: Array<string>, value: string | undefined) =>\n    filter.length === 0 || (value && value.split(',').some(v => filter.includes(v)))\n  const filterText = (filter: string, value: string | undefined) =>\n    !filter || (value && value.toLowerCase().includes(filter.toLowerCase()))\n\n  sortedUninstalledList.value = []\n\n  // 过滤\n  marketList.value.forEach(value => {\n    if (value) {\n      if (\n        filterText(filterForm.name, `${value.plugin_name} ${value.plugin_desc}`) &&\n        match(filterForm.author, value.plugin_author) &&\n        matchMultiple(filterForm.label, value.plugin_label) &&\n        match(filterForm.repo, handleRepoUrl(value))\n      ) {\n        sortedUninstalledList.value.push(value)\n      }\n    }\n  })\n\n  // 排序\n  if (!isNullOrEmptyObject(PluginStatistics.value)) {\n    if (!activeSort.value || activeSort.value === 'count') {\n      sortedUninstalledList.value = sortedUninstalledList.value.sort((a, b) => {\n        return (PluginStatistics.value[b.id || '0'] ?? 0) - (PluginStatistics.value[a.id || '0'] ?? 0)\n      })\n    } else if (activeSort.value) {\n      sortedUninstalledList.value = sortedUninstalledList.value.sort((a: any, b: any) => {\n        return a[activeSort.value ?? ''] > b[activeSort.value ?? ''] ? 1 : -1\n      })\n    }\n  }\n\n  // 显示前20个\n  displayUninstalledList.value = sortedUninstalledList.value.splice(0, 20)\n})\n\n// 标签转换\nfunction pluginLabels(label: string | undefined) {\n  if (!label) return []\n  return label.split(',')\n}\n\n// 新安装了插件\nasync function pluginInstalled() {\n  pluginDialogClose()\n  await refreshData()\n}\n\n// 插件市场设置完成\nfunction marketSettingDone() {\n  MarketSettingDialog.value = false\n  // 重新加载数据\n  refreshData()\n}\n\n// 手动刷新插件市场\nasync function refreshMarket() {\n  isMarketRefreshing.value = true\n  try {\n    await fetchUninstalledPlugins(true)\n    getPluginStatistics()\n  } catch (error) {\n    console.error(error)\n  } finally {\n    isMarketRefreshing.value = false\n  }\n}\n\nfunction parseLocalRepoPath(repoUrl: string | undefined) {\n  if (!repoUrl?.startsWith('local://')) return ''\n\n  try {\n    return new URL(repoUrl).searchParams.get('path') || ''\n  } catch (error) {\n    return decodeURIComponent(repoUrl.match(/[?&]path=([^&]+)/)?.[1] || '')\n  }\n}\n\n// 处理掉github地址的前缀\nfunction handleRepoUrl(item: Plugin | string | undefined) {\n  const url = typeof item === 'string' ? item : item?.repo_url\n  if (!url) return ''\n  if (url.startsWith('local://')) return parseLocalRepoPath(url) || localRepoLabel.value\n  if (typeof item !== 'string' && item?.is_local) return parseLocalRepoPath(url) || localRepoLabel.value\n  return url.replace('https://github.com/', '').replace('https://raw.githubusercontent.com/', '')\n}\n\n// 监测dataList变化或installedFilter、hasUpdateFilter变化时更新filteredDataList\nwatch([dataList, installedFilter, hasUpdateFilter, enabledFilter], () => {\n  filteredDataList.value = dataList.value.filter(item => {\n    if (!installedFilter.value && !hasUpdateFilter.value && !enabledFilter.value) return true\n    if (hasUpdateFilter.value && enabledFilter.value) {\n      return item.has_update && item.state\n    }\n    if (hasUpdateFilter.value) return item.has_update\n    if (enabledFilter.value) return item.state\n    if (installedFilter.value) {\n      return item.plugin_name?.toLowerCase().includes((installedFilter.value as string).toLowerCase())\n    }\n    return true\n  })\n})\n\n// 插件市场加载更多数据\nfunction loadMarketMore({ done }: { done: any }) {\n  // 从 dataList 中获取最前面的 20 个元素\n  const itemsToMove = sortedUninstalledList.value.splice(0, 20)\n  displayUninstalledList.value.push(...itemsToMove)\n  done('ok')\n}\n\n// 组件挂载后\n\nonMounted(async () => {\n  await loadPluginOrderConfig()\n  await loadPluginFolders() // 加载文件夹配置\n  await refreshData()\n  if (activeTab.value != 'market' && pluginId.value) {\n    // 找到这个插件\n    const plugin = dataList.value.find(item => item.id === pluginId.value)\n    if (plugin) {\n      plugin.page_open = true\n    }\n  }\n})\n\nfunction openPluginSearchDialog() {\n  SearchDialog.value = true\n}\n\nfunction openMarketSettingDialog() {\n  MarketSettingDialog.value = true\n}\n\nconst showSearchAction = computed(() => activeTab.value === 'installed' || activeTab.value === 'market')\nconst showNewFolderAction = computed(() => activeTab.value === 'installed' && !currentFolder.value)\nconst showMarketSettingAction = computed(() => activeTab.value === 'market')\n\nconst pluginDynamicMenuItems = computed(() => {\n  if (!appMode.value) return undefined\n  if (!showSearchAction.value) return undefined\n\n  const items = [\n    {\n      titleKey: 'plugin.searchPlugins',\n      icon: 'mdi-magnify',\n      action: openPluginSearchDialog,\n    },\n  ]\n\n  if (showNewFolderAction.value) {\n    items.push({\n      titleKey: 'plugin.newFolder',\n      icon: 'mdi-folder-plus',\n      action: showNewFolderDialog,\n    })\n  }\n\n  if (showMarketSettingAction.value) {\n    items.push({\n      titleKey: 'dialog.pluginMarketSetting.title',\n      icon: 'mdi-store-cog',\n      action: openMarketSettingDialog,\n    })\n  }\n\n  return items.length > 1 ? items : undefined\n})\n\nuseDynamicButton({\n  icon: 'mdi-magnify',\n  onClick: openPluginSearchDialog,\n  menuItems: pluginDynamicMenuItems,\n  show: computed(() => appMode.value && showSearchAction.value && isRefreshed.value),\n})\n\n// 获取插件文件夹配置\nasync function loadPluginFolders() {\n  try {\n    const response = await api.get('plugin/folders')\n    const foldersData: any = response && typeof response === 'object' ? response : {}\n\n    // 处理旧格式兼容性（array）和新格式（object with config）\n    const processedFolders: any = {}\n    const order = []\n\n    Object.keys(foldersData).forEach(folderName => {\n      const folderData = foldersData[folderName]\n\n      if (Array.isArray(folderData)) {\n        // 旧格式：直接是插件数组\n        processedFolders[folderName] = {\n          plugins: folderData,\n          order: order.length,\n          icon: defaultIcon,\n          color: defaultColor,\n          gradient: defaultGradient,\n          background: '',\n          showIcon: true,\n        }\n      } else if (folderData && typeof folderData === 'object') {\n        // 新格式：包含配置的对象\n        processedFolders[folderName] = {\n          plugins: folderData.plugins || [],\n          order: folderData.order ?? order.length,\n          icon: folderData.icon || defaultIcon,\n          color: folderData.color || defaultColor,\n          gradient: folderData.gradient || defaultGradient,\n          background: folderData.background || '',\n          showIcon: folderData.showIcon !== undefined ? folderData.showIcon : true,\n        }\n      }\n\n      order.push(folderName)\n    })\n\n    pluginFolders.value = processedFolders\n\n    // 设置文件夹排序 - 使用全局排序配置\n    const folderNames = Object.keys(processedFolders)\n    folderOrder.value = folderNames.sort((a, b) => {\n      // 从全局排序配置中查找文件夹的order\n      const aOrderItem = orderConfig.value.find((item: any) => item.type === 'folder' && item.id === a)\n      const bOrderItem = orderConfig.value.find((item: any) => item.type === 'folder' && item.id === b)\n\n      const aOrder = aOrderItem?.order ?? processedFolders[a].order ?? 999\n      const bOrder = bOrderItem?.order ?? processedFolders[b].order ?? 999\n\n      return aOrder - bOrder\n    })\n  } catch (error) {\n    pluginFolders.value = {}\n    folderOrder.value = []\n  }\n}\n\n// 保存插件文件夹配置\nasync function savePluginFolders() {\n  try {\n    // 更新排序信息\n    const foldersToSave: any = {}\n    Object.keys(pluginFolders.value).forEach(folderName => {\n      const folderData = pluginFolders.value[folderName]\n      const orderIndex = folderOrder.value.indexOf(folderName)\n\n      foldersToSave[folderName] = {\n        ...folderData,\n        order: orderIndex >= 0 ? orderIndex : 999,\n      }\n    })\n\n    await api.post('plugin/folders', foldersToSave)\n  } catch (error) {\n    throw error\n  }\n}\n\n// 创建新文件夹\nasync function createNewFolder() {\n  if (!newFolderName.value.trim()) {\n    $toast.error(t('plugin.folderNameEmpty'))\n    return\n  }\n\n  if (pluginFolders.value[newFolderName.value]) {\n    $toast.error(t('plugin.folderExists'))\n    return\n  }\n\n  try {\n    // 直接在本地添加文件夹\n    pluginFolders.value[newFolderName.value] = {\n      plugins: [],\n      order: folderOrder.value.length,\n      icon: defaultIcon,\n      color: defaultColor,\n      gradient: defaultGradient,\n      background: '',\n      showIcon: true,\n    }\n\n    // 添加到排序列表\n    folderOrder.value.push(newFolderName.value)\n\n    // 保存到后端\n    await savePluginFolders()\n\n    newFolderDialog.value = false\n    newFolderName.value = ''\n    $toast.success(t('plugin.folderCreateSuccess'))\n  } catch (error) {\n    // 回滚本地更改\n    delete pluginFolders.value[newFolderName.value]\n    folderOrder.value = folderOrder.value.filter(name => name !== newFolderName.value)\n    $toast.error(t('plugin.folderCreateFailed'))\n  }\n}\n\n// 打开文件夹\nfunction openFolder(folderName: string) {\n  currentFolder.value = folderName\n}\n\n// 返回主列表\nfunction backToMain() {\n  currentFolder.value = ''\n}\n\n// 重命名文件夹\nasync function renameFolder(oldName: string, newName: string) {\n  if (pluginFolders.value[newName]) {\n    $toast.error(t('plugin.folderExists'))\n    return\n  }\n\n  try {\n    // 更新本地状态\n    const folderData = pluginFolders.value[oldName] || { plugins: [] }\n    pluginFolders.value[newName] = folderData\n    delete pluginFolders.value[oldName]\n\n    // 更新排序列表\n    const orderIndex = folderOrder.value.indexOf(oldName)\n    if (orderIndex >= 0) {\n      folderOrder.value[orderIndex] = newName\n    }\n\n    // 如果正在查看该文件夹，更新当前文件夹名\n    if (currentFolder.value === oldName) {\n      currentFolder.value = newName\n    }\n\n    // 保存到后端\n    await savePluginFolders()\n\n    $toast.success(t('plugin.folderRenameSuccess'))\n  } catch (error) {\n    console.error(error)\n    // 回滚本地更改\n    pluginFolders.value[oldName] = pluginFolders.value[newName] || { plugins: [] }\n    delete pluginFolders.value[newName]\n    const orderIndex = folderOrder.value.indexOf(newName)\n    if (orderIndex >= 0) {\n      folderOrder.value[orderIndex] = oldName\n    }\n    if (currentFolder.value === newName) {\n      currentFolder.value = oldName\n    }\n    $toast.error(t('plugin.folderRenameFailed'))\n  }\n}\n\n// 删除文件夹\nasync function deleteFolder(folderName: string) {\n  // 保存被删除的文件夹内容以便回滚\n  const deletedFolder = { ...pluginFolders.value[folderName] }\n  try {\n    delete pluginFolders.value[folderName]\n\n    // 从排序列表中移除\n    folderOrder.value = folderOrder.value.filter(name => name !== folderName)\n\n    // 如果正在查看该文件夹，返回主列表\n    if (currentFolder.value === folderName) {\n      currentFolder.value = ''\n    }\n\n    // 保存到后端\n    await savePluginFolders()\n\n    $toast.success(t('plugin.folderDeleteSuccess'))\n  } catch (error) {\n    // 回滚本地更改\n    pluginFolders.value[folderName] = deletedFolder\n    if (!folderOrder.value.includes(folderName)) {\n      folderOrder.value.push(folderName)\n    }\n    $toast.error(t('plugin.folderDeleteFailed'))\n  }\n}\n\n// 显示新建文件夹对话框\nfunction showNewFolderDialog() {\n  newFolderName.value = ''\n  newFolderDialog.value = true\n}\n\n// 移出文件夹\nasync function removeFromFolder(pluginId: string) {\n  if (!currentFolder.value) return\n\n  try {\n    // 从当前文件夹中移除插件\n    const folderData = pluginFolders.value[currentFolder.value]\n    const plugins = Array.isArray(folderData) ? folderData : folderData?.plugins || []\n    const index = plugins.indexOf(pluginId)\n    if (index > -1) {\n      plugins.splice(index, 1)\n      if (!Array.isArray(folderData)) {\n        folderData.plugins = plugins\n      }\n\n      // 保存配置\n      await savePluginFolders()\n\n      $toast.success(t('plugin.removeFromFolderSuccess'))\n    }\n  } catch (error) {\n    console.error(error)\n    $toast.error(t('plugin.operationFailed'))\n  }\n}\n\n// 更新文件夹配置\nasync function updateFolderConfig(folderName: string, config: any) {\n  try {\n    // 更新本地配置\n    if (pluginFolders.value[folderName]) {\n      pluginFolders.value[folderName] = {\n        ...pluginFolders.value[folderName],\n        ...config,\n      }\n\n      // 保存到后端\n      await savePluginFolders()\n    }\n  } catch (error) {\n    $toast.error(t('plugin.saveFolderConfigFailed'))\n  }\n}\n\n// 当前拖拽的插件ID\nconst currentDraggedPluginId = ref('')\n\n// 处理拖拽到文件夹的事件\nasync function handleDropToFolder(event: DragEvent, folderName: string) {\n  event.preventDefault()\n  event.stopPropagation()\n  const target = event.currentTarget as HTMLElement\n  target.classList.remove('drag-over')\n\n  // 使用跟踪的插件ID\n  const pluginId = currentDraggedPluginId.value\n\n  if (!pluginId) {\n    return\n  }\n\n  try {\n    // 检查是否是文件夹名（忽略文件夹拖入文件夹的情况）\n    if (Object.keys(pluginFolders.value).includes(pluginId)) {\n      return\n    }\n\n    // 验证插件ID\n    const plugin = filteredDataList.value.find(p => p.id === pluginId)\n\n    if (!plugin) {\n      return\n    }\n\n    // 获取目标文件夹数据\n    const targetFolderData = pluginFolders.value[folderName] || { plugins: [] }\n    const targetPlugins = Array.isArray(targetFolderData) ? targetFolderData : targetFolderData.plugins || []\n\n    // 检查插件是否已在此文件夹中\n    if (targetPlugins.includes(pluginId)) {\n      $toast.warning('插件已在此文件夹中')\n      return\n    }\n\n    // 从其他文件夹中移除该插件\n    Object.keys(pluginFolders.value).forEach(fname => {\n      if (fname !== folderName) {\n        const folderData = pluginFolders.value[fname]\n        const plugins = Array.isArray(folderData) ? folderData : folderData.plugins || []\n        const index = plugins.indexOf(pluginId)\n        if (index > -1) {\n          plugins.splice(index, 1)\n          if (!Array.isArray(folderData)) {\n            folderData.plugins = plugins\n          }\n        }\n      }\n    })\n\n    // 从主列表中移除（如果存在）\n    const mainIndex = mixedSortList.value.findIndex(item => item.type === 'plugin' && item.id === pluginId)\n    if (mainIndex > -1) {\n      mixedSortList.value.splice(mainIndex, 1)\n    }\n\n    // 添加到目标文件夹\n    if (!pluginFolders.value[folderName]) {\n      pluginFolders.value[folderName] = {\n        plugins: [],\n        order: folderOrder.value.length,\n        icon: defaultIcon,\n        color: defaultColor,\n        gradient: defaultGradient,\n        background: '',\n        showIcon: true,\n      }\n    }\n\n    const targetFolder = pluginFolders.value[folderName]\n    if (Array.isArray(targetFolder)) {\n      targetFolder.push(pluginId)\n    } else {\n      targetFolder.plugins = targetFolder.plugins || []\n      targetFolder.plugins.push(pluginId)\n    }\n\n    // 保存配置\n    await savePluginFolders()\n\n    // 更新混合排序列表\n    updateMixedSortList()\n\n    $toast.success(`插件已移动到文件夹 \"${folderName}\"`)\n  } catch (error) {\n    $toast.error('操作失败')\n  }\n}\n\n// 拖拽开始事件（修复版本）\nfunction onDragStartPlugin(evt: any) {\n  // 设置拖拽模式标志\n  isDraggingSortMode.value = true\n\n  // 从oldIndex获取插件ID\n  const oldIndex = evt.oldIndex\n  if (oldIndex !== undefined) {\n    if (currentFolder.value) {\n      const plugin = draggableFolderPlugins.value[oldIndex]\n      if (plugin && plugin.id) {\n        currentDraggedPluginId.value = plugin.id\n        return\n      }\n    } else {\n      const item = mixedSortList.value[oldIndex]\n      if (item && item.id) {\n        currentDraggedPluginId.value = item.id\n        return\n      }\n    }\n  }\n\n  // 从拖拽元素获取\n  const item = evt.item\n  if (item && item.dataset && item.dataset.pluginId) {\n    currentDraggedPluginId.value = item.dataset.pluginId\n    return\n  }\n\n  // 查找data-plugin-id属性\n  const pluginCard = item?.querySelector('[data-plugin-id]')\n  if (pluginCard) {\n    currentDraggedPluginId.value = pluginCard.getAttribute('data-plugin-id') || ''\n    return\n  }\n\n  // 直接从元素属性获取\n  if (item && item.getAttribute && item.getAttribute('data-plugin-id')) {\n    currentDraggedPluginId.value = item.getAttribute('data-plugin-id')\n  }\n}\n</script>\n\n<template>\n  <div>\n    <!-- 已安装插件过滤下拉菜单 -->\n    <Teleport to=\"body\" v-if=\"filterInstalledPluginDialog\">\n      <VMenu\n        v-model=\"filterInstalledPluginDialog\"\n        :close-on-content-click=\"false\"\n        :activator=\"'[data-menu-activator=installed-filter-btn]'\"\n        location=\"bottom end\"\n      >\n        <VCard min-width=\"220\">\n          <!-- 名称搜索 -->\n          <div class=\"pa-3\">\n            <VCombobox\n              v-model=\"installedFilter\"\n              :items=\"installedPluginNames\"\n              :placeholder=\"t('plugin.name')\"\n              prepend-inner-icon=\"mdi-magnify\"\n              density=\"compact\"\n              variant=\"outlined\"\n              hide-details\n              clearable\n            />\n          </div>\n          <VDivider class=\"mt-2\" />\n          <!-- 快捷筛选 -->\n          <VList density=\"compact\" class=\"px-2 py-1\">\n            <VListSubheader>{{ t('common.filter') }}</VListSubheader>\n            <VListItem :active=\"enabledFilter\" @click=\"enabledFilter = !enabledFilter\" density=\"compact\">\n              <template #prepend>\n                <VIcon icon=\"mdi-play-circle\" color=\"success\" size=\"small\" />\n              </template>\n              <VListItemTitle>{{ t('plugin.running') }}</VListItemTitle>\n              <template #append>\n                <VIcon v-if=\"enabledFilter\" icon=\"mdi-check\" color=\"primary\" size=\"small\" />\n              </template>\n            </VListItem>\n            <VListItem :active=\"hasUpdateFilter\" @click=\"hasUpdateFilter = !hasUpdateFilter\" density=\"compact\">\n              <template #prepend>\n                <VIcon icon=\"mdi-arrow-up-circle\" color=\"info\" size=\"small\" />\n              </template>\n              <VListItemTitle>{{ t('plugin.hasNewVersion') }}</VListItemTitle>\n              <template #append>\n                <VIcon v-if=\"hasUpdateFilter\" icon=\"mdi-check\" color=\"primary\" size=\"small\" />\n              </template>\n            </VListItem>\n          </VList>\n        </VCard>\n      </VMenu>\n    </Teleport>\n\n    <!-- 插件市场过滤下拉菜单 -->\n    <Teleport to=\"body\" v-if=\"filterMarketPluginDialog\">\n      <VMenu\n        v-model=\"filterMarketPluginDialog\"\n        :close-on-content-click=\"false\"\n        :activator=\"'[data-menu-activator=market-filter-btn]'\"\n        location=\"bottom end\"\n      >\n        <VCard min-width=\"260\" max-width=\"320\">\n          <!-- 名称搜索 -->\n          <div class=\"pa-3\">\n            <VTextField\n              v-model=\"filterForm.name\"\n              :placeholder=\"t('plugin.name')\"\n              prepend-inner-icon=\"mdi-magnify\"\n              density=\"compact\"\n              variant=\"outlined\"\n              hide-details\n              clearable\n            />\n          </div>\n          <VDivider class=\"mt-2\" />\n          <!-- 排序 -->\n          <VList density=\"compact\" class=\"px-2 py-1\">\n            <VListSubheader>{{ t('plugin.sortTitle') }}</VListSubheader>\n            <VListItem\n              v-for=\"option in sortOptions\"\n              :key=\"option.value\"\n              :active=\"(activeSort || 'count') === option.value\"\n              @click=\"activeSort = option.value\"\n              density=\"compact\"\n            >\n              <VListItemTitle>{{ option.title }}</VListItemTitle>\n              <template #append>\n                <VIcon v-if=\"(activeSort || 'count') === option.value\" icon=\"mdi-check\" color=\"primary\" size=\"small\" />\n              </template>\n            </VListItem>\n          </VList>\n          <!-- 下拉多选筛选项 -->\n          <VDivider />\n          <div class=\"px-3 py-2 d-flex flex-column gap-2\">\n            <VSelect\n              v-if=\"authorFilterOptions.length > 0\"\n              v-model=\"filterForm.author\"\n              :items=\"authorFilterOptions\"\n              :label=\"t('plugin.author')\"\n              multiple\n              chips\n              closable-chips\n              density=\"compact\"\n              variant=\"outlined\"\n              hide-details\n              clearable\n            />\n            <VSelect\n              v-if=\"labelFilterOptions.length > 0\"\n              v-model=\"filterForm.label\"\n              :items=\"labelFilterOptions\"\n              :label=\"t('plugin.label')\"\n              multiple\n              chips\n              closable-chips\n              density=\"compact\"\n              variant=\"outlined\"\n              hide-details\n              clearable\n            />\n            <VSelect\n              v-if=\"repoFilterOptions.length > 0\"\n              v-model=\"filterForm.repo\"\n              :items=\"repoFilterOptions\"\n              :label=\"t('plugin.repository')\"\n              multiple\n              chips\n              closable-chips\n              density=\"compact\"\n              variant=\"outlined\"\n              hide-details\n              clearable\n            />\n          </div>\n        </VCard>\n      </VMenu>\n    </Teleport>\n\n    <VWindow v-model=\"activeTab\" class=\"disable-tab-transition px-2\" :touch=\"false\">\n      <!-- 我的插件 -->\n      <VWindowItem value=\"installed\">\n        <transition name=\"fade-slide\" appear>\n          <div>\n            <VPageContentTitle v-if=\"installedFilter\" :title=\"t('plugin.filter', { name: installedFilter })\" />\n            <LoadingBanner v-if=\"!isRefreshed\" class=\"mt-12\" />\n\n            <!-- 文件夹和插件网格 -->\n            <div v-if=\"(mixedSortList.length > 0 || displayedPlugins.length > 0) && isRefreshed\">\n              <!-- 混合排序列表（文件夹和插件） -->\n              <template v-if=\"!currentFolder\">\n                <!-- 主列表：使用draggable进行混合排序 -->\n                <draggable\n                  v-model=\"mixedSortList\"\n                  @end=\"saveMixedSortOrder\"\n                  @start=\"onDragStartPlugin\"\n                  handle=\".cursor-move\"\n                  item-key=\"id\"\n                  tag=\"div\"\n                  class=\"grid gap-4 grid-plugin-card\"\n                  group=\"mixed\"\n                >\n                  <template #item=\"{ element }\">\n                    <PluginMixedSortCard\n                      :item=\"element\"\n                      :plugin-statistics=\"PluginStatistics\"\n                      :plugin-actions=\"pluginActions\"\n                      @open-folder=\"openFolder\"\n                      @delete-folder=\"deleteFolder\"\n                      @rename-folder=\"(oldName, newName) => renameFolder(oldName, newName)\"\n                      @update-folder-config=\"(folderName, config) => updateFolderConfig(folderName, config)\"\n                      @refresh-data=\"refreshData\"\n                      @action-done=\"\n                        pluginId => {\n                          pluginActions[pluginId] = false\n                        }\n                      \"\n                      @drop-to-folder=\"(event, folderName) => handleDropToFolder(event, folderName)\"\n                    />\n                  </template>\n                </draggable>\n              </template>\n\n              <template v-else>\n                <!-- 文件夹内：使用draggable排序 + 移出按钮 -->\n                <draggable\n                  v-model=\"draggableFolderPlugins\"\n                  @end=\"saveFolderPluginOrder\"\n                  @start=\"onDragStartPlugin\"\n                  handle=\".cursor-move\"\n                  item-key=\"id\"\n                  tag=\"div\"\n                  class=\"grid gap-4 grid-plugin-card\"\n                  group=\"plugins\"\n                >\n                  <template #item=\"{ element }\">\n                    <PluginMixedSortCard\n                      :item=\"{ type: 'plugin', id: element.id, data: element, order: 0 }\"\n                      :plugin-statistics=\"PluginStatistics\"\n                      :plugin-actions=\"pluginActions\"\n                      :show-remove-button=\"true\"\n                      @refresh-data=\"refreshData\"\n                      @action-done=\"\n                        pluginId => {\n                          pluginActions[pluginId] = false\n                        }\n                      \"\n                      @remove-from-folder=\"removeFromFolder\"\n                    />\n                  </template>\n                </draggable>\n              </template>\n            </div>\n\n            <NoDataFound\n              v-if=\"displayedFolders.length === 0 && displayedPlugins.length === 0 && isRefreshed\"\n              error-code=\"404\"\n              :error-title=\"t('common.noData')\"\n              :error-description=\"\n                installedFilter || hasUpdateFilter ? t('plugin.noMatchingContent') : t('plugin.pleaseInstallFromMarket')\n              \"\n            />\n          </div>\n        </transition>\n      </VWindowItem>\n      <!-- 插件市场 -->\n      <VWindowItem value=\"market\">\n        <transition name=\"fade-slide\" appear>\n          <div>\n            <LoadingBanner v-if=\"!isAppMarketLoaded || isMarketRefreshing\" class=\"mt-12\" />\n            <!-- 资源列表 -->\n            <VInfiniteScroll\n              v-if=\"isAppMarketLoaded && !isMarketRefreshing\"\n              mode=\"intersect\"\n              side=\"end\"\n              :items=\"displayUninstalledList\"\n              @load=\"loadMarketMore\"\n              class=\"overflow-visible\"\n            >\n              <template #loading />\n              <template #empty />\n              <div class=\"grid gap-4 grid-plugin-card\">\n                <template\n                  v-for=\"(data, index) in displayUninstalledList\"\n                  :key=\"`${data.id}_v${data.plugin_version}_${index}`\"\n                >\n                  <PluginAppCard :plugin=\"data\" :count=\"PluginStatistics[data.id || '0']\" @install=\"pluginInstalled\" />\n                </template>\n              </div>\n            </VInfiniteScroll>\n            <NoDataFound\n              v-if=\"displayUninstalledList.length === 0 && isAppMarketLoaded\"\n              error-code=\"404\"\n              :error-title=\"t('common.noData')\"\n              :error-description=\"t('plugin.allPluginsInstalled')\"\n            />\n          </div>\n        </transition>\n      </VWindowItem>\n    </VWindow>\n  </div>\n\n  <!-- 插件搜索图标 -->\n  <Teleport to=\"body\" v-if=\"route.path === '/plugins'\">\n    <div v-if=\"isRefreshed && !appMode && showSearchAction\" class=\"compact-fab-stack\">\n      <VFab\n        v-if=\"showMarketSettingAction\"\n        icon=\"mdi-store-cog\"\n        color=\"warning\"\n        variant=\"tonal\"\n        appear\n        class=\"compact-fab compact-fab--secondary\"\n        @click=\"openMarketSettingDialog\"\n      />\n      <VFab\n        v-if=\"showNewFolderAction\"\n        icon=\"mdi-folder-plus\"\n        color=\"success\"\n        variant=\"tonal\"\n        appear\n        class=\"compact-fab compact-fab--secondary\"\n        @click=\"showNewFolderDialog\"\n      />\n      <VFab\n        icon=\"mdi-magnify\"\n        color=\"primary\"\n        appear\n        class=\"compact-fab compact-fab--primary\"\n        @click=\"openPluginSearchDialog\"\n      />\n    </div>\n  </Teleport>\n  <!-- 插件市场设置窗口 -->\n  <PluginMarketSettingDialog\n    v-if=\"MarketSettingDialog\"\n    v-model=\"MarketSettingDialog\"\n    @close=\"MarketSettingDialog = false\"\n    @save=\"marketSettingDone\"\n  />\n\n  <!-- 插件搜索窗口 -->\n  <VDialog\n    v-if=\"SearchDialog\"\n    v-model=\"SearchDialog\"\n    scrollable\n    max-width=\"40rem\"\n    :max-height=\"!display.mdAndUp.value ? '' : '85vh'\"\n    :fullscreen=\"!display.mdAndUp.value\"\n  >\n    <VCard class=\"mx-auto\" width=\"100%\">\n      <VToolbar flat class=\"p-0\">\n        <VTextField\n          v-model=\"keyword\"\n          :label=\"t('plugin.searchPlugins')\"\n          single-line\n          :placeholder=\"t('plugin.searchPlaceholder')\"\n          variant=\"solo\"\n          prepend-inner-icon=\"mdi-magnify\"\n          flat\n          class=\"mx-1\"\n        />\n      </VToolbar>\n      <VDialogCloseBtn @click=\"closeSearchDialog\" />\n      <VList v-if=\"filterPlugins.length > 0\" lines=\"two\">\n        <VVirtualScroll :items=\"filterPlugins\">\n          <template #default=\"{ item }\">\n            <VListItem @click=\"openPlugin(item)\">\n              <template #prepend>\n                <VAvatar>\n                  <VImg :src=\"pluginIcon(item)\" @error=\"pluginIconError(item)\">\n                    <template #placeholder>\n                      <div class=\"w-full h-full\">\n                        <VSkeletonLoader class=\"object-cover aspect-w-1 aspect-h-1\" />\n                      </div>\n                    </template>\n                  </VImg>\n                </VAvatar>\n              </template>\n              <VListItemTitle>\n                {{ item.plugin_name }}<span class=\"text-sm ms-2 mt-1 text-gray-500\">v{{ item?.plugin_version }}</span>\n                <VIcon v-if=\"item.installed\" color=\"success\" icon=\"mdi-check-circle\" class=\"ms-2\" size=\"small\" />\n              </VListItemTitle>\n              <VListItemSubtitle>\n                <VChip\n                  v-for=\"label in pluginLabels(item.plugin_label)\"\n                  variant=\"tonal\"\n                  size=\"small\"\n                  class=\"me-1 my-1\"\n                  color=\"info\"\n                  label\n                >\n                  {{ label }}\n                </VChip>\n                {{ item.plugin_desc }}\n              </VListItemSubtitle>\n            </VListItem>\n          </template>\n        </VVirtualScroll>\n      </VList>\n    </VCard>\n  </VDialog>\n\n  <!-- 安装插件进度框 -->\n  <VDialog v-if=\"progressDialog\" v-model=\"progressDialog\" :scrim=\"false\" width=\"25rem\">\n    <VCard color=\"primary\">\n      <VCardText class=\"text-center\">\n        {{ progressText }}\n        <VProgressLinear indeterminate color=\"white\" class=\"mb-0 mt-1\" />\n      </VCardText>\n    </VCard>\n  </VDialog>\n\n  <!-- 新建文件夹对话框 -->\n  <VDialog v-if=\"newFolderDialog\" v-model=\"newFolderDialog\" max-width=\"400\">\n    <VCard>\n      <VDialogCloseBtn @click=\"newFolderDialog = false\" />\n      <VCardItem>\n        <VCardTitle>{{ t('plugin.newFolder') }}</VCardTitle>\n      </VCardItem>\n      <VDivider />\n      <VCardText>\n        <VTextField\n          v-model=\"newFolderName\"\n          :label=\"t('plugin.folderName')\"\n          variant=\"outlined\"\n          @keyup.enter=\"createNewFolder\"\n        />\n      </VCardText>\n      <VCardActions>\n        <VSpacer />\n        <VBtn color=\"primary\" @click=\"createNewFolder\" prepend-icon=\"mdi-folder-plus\" class=\"px-5\">{{\n          t('plugin.create')\n        }}</VBtn>\n      </VCardActions>\n    </VCard>\n  </VDialog>\n</template>\n"
  },
  {
    "path": "src/views/reorganize/DownloadingListView.vue",
    "content": "<script lang=\"ts\" setup>\nimport { VPullToRefresh } from 'vuetify/labs/VPullToRefresh'\nimport api from '@/api'\nimport type { DownloadingInfo } from '@/api/types'\nimport NoDataFound from '@/components/NoDataFound.vue'\nimport DownloadingCard from '@/components/cards/DownloadingCard.vue'\nimport { useUserStore } from '@/stores'\nimport { useI18n } from 'vue-i18n'\nimport { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'\n\n// 国际化\nconst { t } = useI18n()\nconst { useDataRefresh } = useBackgroundOptimization()\n\n// 定义输入参数\nconst props = defineProps<{\n  name: string\n}>()\n\n// 用户 Store\nconst userStore = useUserStore()\n\n// 数据列表\nconst dataList = ref<DownloadingInfo[]>([])\n\n// 是否刷新过\nconst isRefreshed = ref(false)\n\n// 获取订阅列表数据\nasync function fetchData() {\n  try {\n    dataList.value = await api.get('download/', { params: { name: props.name } })\n    isRefreshed.value = true\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 刷新状态\nconst loading = ref(false)\n\n// 下拉刷新\nfunction onRefresh() {\n  loading.value = true\n  fetchData()\n  loading.value = false\n}\n\n// 过滤数据，管理员用户显示全部，非管理员只显示自己的订阅\nconst filteredDataList = computed(() => {\n  // 从 Store 中获取用户信息\n  const superUser = userStore.superUser\n  const userName = userStore.userName\n  if (superUser) return dataList.value\n  else return dataList.value.filter(data => data.userid === userName || data.username === userName)\n})\n\n// 使用优化的数据刷新定时器\nconst { loading: dataLoading } = useDataRefresh(\n  'downloading-list',\n  fetchData,\n  3000, // 3秒间隔\n  true // 立即执行\n)\n</script>\n\n<template>\n  <LoadingBanner v-if=\"!isRefreshed\" class=\"mt-12\" />\n  <VPullToRefresh v-model=\"loading\" @load=\"onRefresh\" :pull-down-threshold=\"64\">\n    <div v-if=\"filteredDataList.length > 0\" class=\"grid gap-4 grid-downloading-card\">\n      <DownloadingCard\n        v-for=\"data in filteredDataList\"\n        :key=\"data.hash\"\n        :info=\"data\"\n        :downloader-name=\"props.name\"\n      />\n    </div>\n    <NoDataFound\n      v-if=\"filteredDataList.length === 0 && isRefreshed\"\n      error-code=\"404\"\n      :error-title=\"t('downloading.noTask')\"\n      :error-description=\"t('downloading.noTaskDescription')\"\n    />\n  </VPullToRefresh>\n</template>\n"
  },
  {
    "path": "src/views/reorganize/FileBrowserView.vue",
    "content": "<script lang=\"ts\" setup>\nimport api from '@/api'\nimport { FileItem, StorageConf, TransferDirectoryConf } from '@/api/types'\nimport FileBrowser from '@/components/FileBrowser.vue'\n\nconst endpoints = {\n  list: {\n    url: '/storage/list?sort={sort}',\n    method: 'post',\n  },\n  mkdir: {\n    url: '/storage/mkdir?name={name}',\n    method: 'post',\n  },\n  delete: {\n    url: '/storage/delete',\n    method: 'post',\n  },\n  download: {\n    url: '/storage/download',\n    method: 'post',\n  },\n  image: {\n    url: '/storage/image',\n    method: 'post',\n  },\n  rename: {\n    url: '/storage/rename?new_name={newname}',\n    method: 'post',\n  },\n}\n\n// 所有存储\nconst storages = ref<StorageConf[]>([])\nconst storageTypes = computed(() => storages.value.map(s => s.type))\n\n// 当前文件项\nconst operItem = ref<FileItem | undefined>(undefined)\n\n// fileid的堆栈\nconst itemstack = ref<FileItem[]>([])\n\n// 计算公共路径\nfunction findCommonPath(paths: string[]): string {\n  let commonPath\n  if (!paths || paths.length === 0) {\n    commonPath = '/'\n  } else if (paths.length === 1) {\n    commonPath = paths[0]\n    commonPath = commonPath.replace(/\\\\/g, '/')\n  } else {\n    const normalizedPaths = paths.map(path => path.replace(/\\\\/g, '/'))\n    const splitPaths = normalizedPaths.map(path => path.split('/'))\n    let commonParts: string[] = []\n    for (let i = 0; i < splitPaths[0].length; i++) {\n      const part = splitPaths[0][i]\n      if (splitPaths.every(pathParts => pathParts[i] === part)) {\n        commonParts.push(part)\n      } else {\n        break\n      }\n    }\n    commonPath = commonParts.join('/')\n  }\n\n  if (!commonPath.endsWith('/')) {\n    commonPath += '/'\n  }\n\n  if (commonPath.includes(':')) {\n    commonPath = commonPath.replace('\\\\', '/')\n  }\n\n  return commonPath\n}\n\nconst STORAGE_KEY = 'fileBrowserView.activeStorage'\n\ninterface BrowserInitialParams {\n  storage: string;\n  path: string;\n  name: string;\n}\n // determine which entry to select initially\nfunction determineBrowserInitialParams(downloadDirectories: TransferDirectoryConf[]): BrowserInitialParams {\n  const isAvailable = (storage: string) => storageTypes.value.includes(storage);\n  const buckets = downloadDirectories.reduce<Map<string, string[]>>((dict, item) => { \n    // filter out directories whose storage is not available\n    if (!isAvailable(item.storage)) {\n      return dict\n    }\n    if (item.download_path == undefined) {\n      return dict\n    }\n    if (!dict.has(item.storage)) {\n      dict.set(item.storage, [item.download_path])\n    } else {\n      dict.get(item.storage)!.push(item.download_path)\n    }\n    return dict\n  }, new Map());\n\n  const cachedStorage = localStorage.getItem(STORAGE_KEY) || '';\n  // if no download directories are configured, fall back to cached storage or first available storage\n  if (buckets.size === 0) {\n    return {\n      storage: isAvailable(cachedStorage)\n        ? cachedStorage\n        : (storageTypes.value[0] || 'local'),\n      path: '/',\n      name: '/',\n    }\n  }\n  let selectedEntry: [string, string[]];\n  if (cachedStorage && buckets.has(cachedStorage)) {\n    selectedEntry = [cachedStorage, buckets.get(cachedStorage)!];\n  } else {\n    // if no storage selected previously, use the most populous one\n    selectedEntry = Array.from(buckets.entries()).reduce((prev, curr) => {\n      return curr[1].length > prev[1].length ? curr : prev;\n    });\n  }\n\n  const path = findCommonPath(selectedEntry[1]);\n  return {\n    storage: selectedEntry[0],\n    path,\n    name: path.split('/').filter(Boolean).pop() ?? '',\n  }\n}\n      \n// 查询下载目录\nasync function loadDownloadDirectories() {\n  try {\n    // fetch available storages\n    const storageResult: { [key: string]: any } = await api.get('system/setting/Storages')\n    storages.value = storageResult.data?.value ?? []\n\n    const result: { [key: string]: any } = await api.get('system/setting/Directories')\n    if (result.success && result.data?.value) {\n      const { storage, path, name } = determineBrowserInitialParams(result.data.value);\n      // operItem初始化\n      operItem.value = {\n        type: 'dir',\n        storage,\n        name: name,\n        path: path,\n      }\n      // itemstack初始化\n      itemstack.value = [\n        {\n          storage: storage,\n          type: 'dir',\n          name: '/',\n          path: '/',\n          fileid: 'root',\n        }\n      ];\n      // 将初始数据拆分到堆栈中\n      const paths = path.split('/').filter(Boolean)\n      paths.map((name, index) => {\n        const path = '/' + paths.slice(0, index + 1).join('/') + '/'\n        itemstack.value.push({\n          storage,\n          type: 'dir',\n          name,\n          path,\n        })\n      })\n    }\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 目录变化\nfunction pathChanged(item: FileItem) {\n  // save storage to localStorage\n  if (item.storage !== operItem.value?.storage) {\n    localStorage.setItem(STORAGE_KEY, item.storage)\n  }\n\n  operItem.value = item\n  if (item.path == '/') {\n    itemstack.value = [\n      {\n        storage: item.storage,\n        type: 'dir',\n        name: '/',\n        path: '/',\n        fileid: item.fileid || 'root',\n      },\n    ]\n    return\n  }\n  const index = itemstack.value.findIndex(i => i.path === item.path)\n  if (index >= 0) {\n    itemstack.value = itemstack.value.slice(0, index + 1)\n  } else {\n    itemstack.value.push(item)\n  }\n}\n\n// 加载初始目录\nonMounted(loadDownloadDirectories)\n</script>\n\n<template>\n  <div class=\"file-browser-view\">\n    <FileBrowser\n      v-if=\"operItem\"\n      :storages=\"storages\"\n      :tree=\"false\"\n      :itemstack=\"itemstack\"\n      :endpoints=\"endpoints\"\n      :axios=\"api\"\n      :item=\"operItem\"\n      @pathchanged=\"pathChanged\"\n    />\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.file-browser-view {\n  position: relative;\n  block-size: 100%;\n}\n</style>\n"
  },
  {
    "path": "src/views/reorganize/TransferHistoryView.vue",
    "content": "<script setup lang=\"ts\">\nimport { debounce } from 'lodash-es'\nimport { useToast } from 'vue-toastification'\nimport api from '@/api'\nimport type { StorageConf, TransferHistory } from '@/api/types'\nimport ReorganizeDialog from '@/components/dialog/ReorganizeDialog.vue'\nimport TransferQueueDialog from '@/components/dialog/TransferQueueDialog.vue'\nimport ProgressDialog from '@/components/dialog/ProgressDialog.vue'\nimport { useRoute } from 'vue-router'\nimport router from '@/router'\nimport { useDisplay } from 'vuetify'\nimport { formatFileSize } from '@/@core/utils/formatters'\nimport { useI18n } from 'vue-i18n'\nimport { usePWA } from '@/composables/usePWA'\nimport { useDynamicButton } from '@/composables/useDynamicButton'\nimport { useAvailableHeight } from '@/composables/useAvailableHeight'\nimport { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'\nimport { useGlobalSettingsStore } from '@/stores'\n\n// i18n\nconst { t } = useI18n()\n\n// 全局设置\nconst globalSettingsStore = useGlobalSettingsStore()\n\n// APP\nconst display = useDisplay()\n// PWA模式检测\nconst { appMode } = usePWA()\nconst { useProgressSSE } = useBackgroundOptimization()\n\n// 计算列表可用高度\n// componentOffset = VCardItem搜索栏(68) + VDivider(1) + 分页栏(40) + VCard边距(2) = 111\nconst { availableHeight } = useAvailableHeight(125, 300)\n\n// 提示框\nconst $toast = useToast()\n\n// 路由\nconst route = useRoute()\n\n// 组合式输入法状态\nconst isComposing = ref(false)\n\n// 重新整理对话框\nconst redoDialog = ref(false)\n\n// 整理队列对话框\nconst transferQueueDialog = ref(false)\n\n// 当前操作记录\nconst currentHistory = ref<TransferHistory>()\n\n// AI整理中的记录\nconst aiRedoIds = ref<number[]>([])\n\n// AI整理进度\nconst aiRedoProgressDialog = ref(false)\nconst aiRedoProgressActive = ref(false)\nconst aiRedoProgressText = ref(t('transferHistory.actions.aiRedoPending'))\nconst aiRedoProgressSSE = ref<any>(null)\nconst aiRedoProgressHistoryId = ref<number>()\n\n// 重新整理IDS\nconst redoIds = ref<number[]>([])\nconst redoTargetStorage = ref<string>()\n\n// 已选中的数据\nconst selected = ref<TransferHistory[]>([])\n\nconst getNum = (s?: string) => (s ? parseInt(s.replace(/[^0-9]/g, ''), 10) || 0 : 0)\n\nfunction sortByTitle(a: TransferHistory, b: TransferHistory) {\n  if (a.type !== b.type) {\n    return (a.type ?? '').localeCompare(b.type ?? '')\n  }\n  if (a.title !== b.title) {\n    return (a.title ?? '').toLocaleLowerCase().localeCompare((b.title ?? '').toLocaleLowerCase())\n  }\n  if (a.type === '电视剧') {\n    if (a.seasons !== b.seasons) {\n      return getNum(a.seasons) - getNum(b.seasons)\n    }\n    if (a.episodes !== b.episodes) {\n      return getNum(a.episodes) - getNum(b.episodes)\n    }\n  }\n  return 0\n}\n\nfunction sortBySourceSize(a: TransferHistory, b: TransferHistory) {\n  return (a.src_fileitem?.size ?? 0) - (b.src_fileitem?.size ?? 0)\n}\n\n// 表头\nconst headers = [\n  {\n    title: t('transferHistory.titleColumn'),\n    key: 'title',\n    sortable: true,\n    sortRaw: sortByTitle,\n  },\n  {\n    title: t('transferHistory.pathColumn'),\n    key: 'src',\n    sortable: true,\n  },\n  {\n    title: t('transferHistory.modeColumn'),\n    key: 'mode',\n    sortable: true,\n  },\n  {\n    title: t('transferHistory.sizeColumn'),\n    key: 'size',\n    sortable: true,\n    sortRaw: sortBySourceSize,\n  },\n  {\n    title: t('transferHistory.dateColumn'),\n    key: 'date',\n    sortable: true,\n  },\n  {\n    title: t('transferHistory.statusColumn'),\n    key: 'status',\n    sortable: true,\n  },\n  {\n    title: '',\n    key: 'actions',\n    sortable: false,\n  },\n]\n\n// 分组表头\nconst groupHeaders = [\n  {\n    title: t('transferHistory.seasonEpisode'),\n    key: 'title',\n    sortable: true,\n    sortRaw: sortByTitle,\n  },\n  {\n    title: t('transferHistory.pathColumn'),\n    key: 'src',\n    sortable: true,\n  },\n  {\n    title: t('transferHistory.modeColumn'),\n    key: 'mode',\n    sortable: true,\n  },\n  {\n    title: t('transferHistory.sizeColumn'),\n    key: 'size',\n    sortable: true,\n    sortRaw: sortBySourceSize,\n  },\n  {\n    title: t('transferHistory.dateColumn'),\n    key: 'date',\n    sortable: true,\n  },\n  {\n    title: t('transferHistory.statusColumn'),\n    key: 'status',\n    sortable: true,\n  },\n  {\n    title: '',\n    key: 'actions',\n    sortable: false,\n  },\n]\n\nconst pageRange = [\n  { title: '25', value: 25 },\n  { title: '50', value: 50 },\n  { title: '100', value: 100 },\n  { title: '500', value: 500 },\n  { title: '1000', value: 1000 },\n  { title: 'All', value: -1 },\n]\n\n// 数据列表\nconst dataList = ref<TransferHistory[]>([])\n\n// 搜索\nconst search = ref(route.query.search as string)\n\n// 搜索提示词列表\nconst searchHintList = ref<string[]>([])\n\n// 加载状态\nconst loading = ref(false)\n\n// 总条数\nconst totalItems = ref(0)\n\n// 是否要分组\nconst group = ref<boolean>(route.query.grouped === 'true')\n\n// 分组条件\nconst groupBy = ref<any>([\n  {\n    key: 'title',\n  },\n])\n\n// 每页条数\nconst itemsPerPage = ref<number>(ensureNumber(route.query.itemsPerPage, 50))\n\n// 当前页码\nconst currentPage = ref<number>(ensureNumber(route.query.currentPage, 1))\n\n// 进度条\nconst progressDialog = ref(false)\n\n// 进度文本\nconst progressText = ref(t('transferHistory.progress.pleaseWait'))\n\n// 进度值\nconst progressValue = ref(0)\n\n// 是否已刷新\nconst isRefreshed = ref(false)\n\n// 删除确认对话框\nconst deleteConfirmDialog = ref(false)\n\n// 确认框标题\nconst confirmTitle = ref('')\n\n// 所有存储\nconst storages = ref<StorageConf[]>([])\n\n// 查询存储\nasync function loadStorages() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/Storages')\n\n    storages.value = result.data?.value ?? []\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 存储字典\nconst storageDict = computed(() => {\n  return storages.value.reduce(\n    (dict, item) => {\n      dict[item.type] = item.name\n      return dict\n    },\n    {} as Record<string, string>,\n  )\n})\n\n// 转移方式字典\nconst TransferDict: { [key: string]: string } = {\n  copy: t('transferHistory.transferMode.copy'),\n  move: t('transferHistory.transferMode.move'),\n  link: t('transferHistory.transferMode.link'),\n  softlink: t('transferHistory.transferMode.softlink'),\n  rclone_copy: t('transferHistory.transferMode.rclone_copy'),\n  rclone_move: t('transferHistory.transferMode.rclone_move'),\n}\n\n// 分页提示\nconst pageTip = computed(() => {\n  const begin = itemsPerPage.value * (currentPage.value - 1) + 1\n  const end = itemsPerPage.value * currentPage.value === -1 ? 'ALL' : itemsPerPage.value * currentPage.value\n  return {\n    begin,\n    end,\n  }\n})\n\n// 分页总数\nconst totalPage = computed(() => {\n  const total = Math.ceil(totalItems.value / itemsPerPage.value)\n  return total\n})\n\n// 切换页签\nwatch(\n  [() => currentPage.value, () => itemsPerPage.value],\n  debounce(async () => {\n    reloadPage()\n  }, 1000),\n)\n\n// 搜索监听\nwatch(\n  [() => search.value, () => isComposing.value],\n  debounce(async () => {\n    if (!isComposing.value) {\n      console.log('search: ' + search.value)\n      reloadPage(true)\n    }\n  }, 1000),\n)\n\n// 获取订阅列表数据\nasync function fetchData(page = currentPage.value, count = itemsPerPage.value) {\n  loading.value = true\n\n  try {\n    const result: { [key: string]: any } = await api.get('history/transfer', {\n      params: {\n        page,\n        count,\n        title: search.value,\n      },\n    })\n    isRefreshed.value = true\n    dataList.value = result.data?.list\n    totalItems.value = result.data?.total\n    searchHintList.value = ['失败', '成功', ...new Set(dataList.value.map(item => item.title || ''))].filter(\n      title => title !== '',\n    )\n  } catch (error) {\n    console.error(error)\n  }\n  loading.value = false\n}\n\n// 根据 type 返回不同的图标\nfunction getIcon(type: string) {\n  if (type === '电影') return 'mdi-movie'\n  else if (type === '电视剧') return 'mdi-television-classic'\n  else return 'mdi-help-circle'\n}\n\n// 删除历史记录\nasync function removeHistory(item: TransferHistory) {\n  currentHistory.value = item\n  confirmTitle.value = t('transferHistory.deleteConfirm', {\n    title: item.title,\n    seasons: item.seasons || '',\n    episodes: item.episodes || '',\n  })\n  deleteConfirmDialog.value = true\n}\n\n// 调用API删除记录\nasync function remove(item: TransferHistory, deleteSrc: boolean, deleteDest: boolean) {\n  try {\n    // 调用删除API\n    const result: {\n      [key: string]: any\n    } = await api.delete(`history/transfer?deletesrc=${deleteSrc}&deletedest=${deleteDest}`, {\n      data: item,\n    })\n\n    if (!result.success) $toast.error(`删除失败: ${result.message}`)\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 删除单条记录\nasync function removeSingle(deleteSrc: boolean, deleteDest: boolean) {\n  // 关闭弹窗\n  deleteConfirmDialog.value = false\n  if (!currentHistory.value) return\n\n  // 删除\n  await remove(currentHistory.value, deleteSrc, deleteDest)\n  // 刷新\n  fetchData()\n}\n\n// 批量删除记录\nasync function removeBatch(deleteSrc: boolean, deleteDest: boolean) {\n  // 关闭弹窗\n  deleteConfirmDialog.value = false\n  // 总条数\n  const total = selected.value.length\n  if (total === 0) return\n\n  // 已处理条数\n  let handled = 0\n  // 显示进度条\n  progressDialog.value = true\n  // 循环调用removeHistory\n  for (const item of selected.value) {\n    // 开始删除\n    progressText.value = `正在删除 ${item.title} ${item.seasons}${item.episodes} ...`\n    await remove(item, deleteSrc, deleteDest)\n    // 删除完成\n    handled++\n    progressValue.value = (handled / total) * 100\n  }\n  // 清空选中项\n  selected.value = []\n  // 隐藏进度条\n  progressDialog.value = false\n  // 重新获取数据\n  fetchData()\n}\n\n// 响应删除操作\nasync function deleteConfirmHandler(deleteSrc: boolean, deleteDest: boolean) {\n  if (currentHistory.value) await removeSingle(deleteSrc, deleteDest)\n  else await removeBatch(deleteSrc, deleteDest)\n}\n\n// 批量删除历史记录\nasync function removeHistoryBatch() {\n  if (selected.value.length === 0) return\n\n  // 清空当前操作记录\n  currentHistory.value = undefined\n  confirmTitle.value = t('transferHistory.deleteConfirmBatch', {\n    count: selected.value.length,\n  })\n  // 打开确认弹窗\n  deleteConfirmDialog.value = true\n}\n// 批量重新整理\nasync function retransferBatch() {\n  if (selected.value.length === 0) return\n\n  // 清空当前操作记录\n  currentHistory.value = undefined\n  // 重新整理IDS\n  redoIds.value = selected.value.map(item => item.id)\n  // 打开识别弹窗\n  redoDialog.value = true\n}\n\n// 整理完成\nfunction transferDone() {\n  redoDialog.value = false\n  // 清空当前操作记录\n  currentHistory.value = undefined\n  selected.value = []\n  // 刷新\n  fetchData()\n}\n\n// AI助手是否启用\nconst aiAgentEnabled = computed(() => Boolean(globalSettingsStore.globalSettings.AI_AGENT_ENABLE))\nconst hasRunningAiRedo = computed(() => aiRedoIds.value.length > 0)\n\n// AI整理中的记录\nfunction isAiRedoing(historyId: number) {\n  return aiRedoIds.value.includes(historyId)\n}\n\n// 停止AI整理进度\nfunction stopAiRedoProgress() {\n  aiRedoProgressActive.value = false\n\n  if (aiRedoProgressSSE.value) {\n    aiRedoProgressSSE.value.stop()\n    aiRedoProgressSSE.value = null\n  }\n}\n\n// AI整理完成\nasync function finishAiRedo(success: boolean, errorMessage?: string) {\n  const historyId = aiRedoProgressHistoryId.value\n\n  stopAiRedoProgress()\n  aiRedoProgressDialog.value = false\n  aiRedoProgressHistoryId.value = undefined\n\n  if (historyId !== undefined) {\n    aiRedoIds.value = aiRedoIds.value.filter(id => id !== historyId)\n  }\n\n  await fetchData()\n\n  if (!success && errorMessage) {\n    $toast.error(errorMessage)\n  }\n}\n\n// 处理AI整理进度\nasync function handleAiRedoProgressMessage(event: MessageEvent) {\n  const progress = JSON.parse(event.data)\n  if (!progress) return\n\n  aiRedoProgressText.value = progress.text || t('transferHistory.actions.aiRedoPending')\n\n  if (progress.enable === false) {\n    await finishAiRedo(progress.data?.success !== false, progress.data?.error)\n  }\n}\n\n// 开始监听整理进度\nfunction startAiRedoProgress(historyId: number, progressKey: string) {\n  stopAiRedoProgress()\n\n  aiRedoProgressHistoryId.value = historyId\n  aiRedoProgressDialog.value = true\n  aiRedoProgressActive.value = true\n  aiRedoProgressText.value = t('transferHistory.actions.aiRedoPending')\n\n  const url = `${import.meta.env.VITE_API_BASE_URL}system/progress/${progressKey}`\n\n  aiRedoProgressSSE.value = useProgressSSE(\n    url,\n    handleAiRedoProgressMessage,\n    `transfer-history-ai-redo-${progressKey}`,\n    aiRedoProgressActive,\n  )\n\n  aiRedoProgressSSE.value.start()\n}\n\n// 触发AI整理\nasync function triggerAiRedo(item: TransferHistory) {\n  if (!aiAgentEnabled.value) {\n    $toast.error(t('transferHistory.aiRedoDisabled'))\n    return\n  }\n  if (hasRunningAiRedo.value) return\n\n  aiRedoIds.value = [...aiRedoIds.value, item.id]\n  let progressStarted = false\n  try {\n    const result: { [key: string]: any } = await api.post(`history/transfer/${item.id}/ai-redo`)\n\n    const progressKey = result.data?.progress_key\n\n    if (!result.success || !progressKey) {\n      $toast.error(result.message || t('transferHistory.aiRedoFailed'))\n      return\n    }\n    startAiRedoProgress(item.id, progressKey)\n    progressStarted = true\n  } catch (error) {\n    console.error(error)\n    $toast.error(t('transferHistory.aiRedoFailed'))\n  } finally {\n    if (!progressStarted) {\n      aiRedoIds.value = aiRedoIds.value.filter(id => id !== item.id)\n    }\n  }\n}\n\n// 计算下拉菜单\nfunction getDropdownItems(item: TransferHistory) {\n  return [\n    {\n      title: isAiRedoing(item.id) ? t('transferHistory.actions.aiRedoPending') : t('transferHistory.actions.aiRedo'),\n      value: 0,\n      props: {\n        prependIcon: 'mdi-robot-outline',\n        disabled: !aiAgentEnabled.value || (hasRunningAiRedo.value && !isAiRedoing(item.id)),\n        click: () => {\n          triggerAiRedo(item)\n        },\n      },\n    },\n    {\n      title: t('transferHistory.actions.redo'),\n      value: 1,\n      props: {\n        prependIcon: 'mdi-redo-variant',\n        click: () => {\n          redoIds.value = [item.id]\n          redoTargetStorage.value = item.dest_storage\n          redoDialog.value = true\n        },\n      },\n    },\n    {\n      title: t('transferHistory.actions.delete'),\n      value: 2,\n      props: {\n        prependIcon: 'mdi-trash-can-outline',\n        color: 'error',\n        click: () => {\n          removeHistory(item)\n        },\n      },\n    },\n  ]\n}\n\n// 添加url参数\nfunction addUrlQuery(url: string, name: string, value: any) {\n  if (!url || !name || !value) return url\n  const separator = url.includes('?') ? '&' : '?'\n  return url + separator + name + '=' + encodeURIComponent(value)\n}\n\n// 重载页面\nfunction reloadPage(resetPage = false) {\n  let url = '/history'\n  if (search.value) {\n    url = addUrlQuery(url, 'search', search.value)\n  }\n  if (itemsPerPage.value) {\n    url = addUrlQuery(url, 'itemsPerPage', itemsPerPage.value)\n  }\n  if (currentPage.value) {\n    url = addUrlQuery(url, 'currentPage', resetPage ? 1 : currentPage.value)\n  }\n  if (group.value) {\n    url = addUrlQuery(url, 'grouped', 'true')\n  }\n  router.push(url)\n}\n\n// 确保值为number类型\nfunction ensureNumber(value: any, defaultValue: number = 0) {\n  value = Number(value)\n  // 如果不是数字\n  if (Number.isNaN(value)) {\n    value = defaultValue\n  }\n  return value\n}\n\n// 按标题分组后的选中数量统计，键为标题，值为对应分组的选中数\nconst selectedCountsGroupedByTitle = computed(() => {\n  return selected.value.reduce(\n    (acc, item) => {\n      const title = item.title || ''\n      acc[title] = (acc[title] || 0) + 1\n      return acc\n    },\n    {} as Record<string, number>,\n  )\n})\n\n// 控制分组内所有子项的选中状态\nconst toggleGroupSelection = (checked: boolean | null, items: readonly any[]) => {\n  const values = items.map(item => item.value)\n  if (checked) {\n    selected.value = [...new Set([...selected.value, ...values])]\n  } else {\n    const itemsSet = new Set(values)\n    selected.value = selected.value.filter(item => !itemsSet.has(item))\n  }\n}\n\nconst historyDynamicIcon = computed(() => (selected.value.length > 0 ? 'mdi-chevron-up' : 'mdi-timer-sand-paused'))\nconst historyDynamicMenuItems = computed(() => {\n  if (selected.value.length === 0) return undefined\n\n  return [\n    {\n      titleKey: 'dialog.transferQueue.title',\n      icon: 'mdi-timer-sand-paused',\n      action: () => {\n        transferQueueDialog.value = true\n      },\n    },\n    {\n      titleKey: 'transferHistory.actions.batchRedo',\n      icon: 'mdi-redo-variant',\n      action: () => {\n        retransferBatch()\n      },\n    },\n    {\n      titleKey: 'transferHistory.actions.batchDelete',\n      icon: 'mdi-trash-can-outline',\n      color: 'error',\n      action: () => {\n        removeHistoryBatch()\n      },\n    },\n  ]\n})\n\nuseDynamicButton({\n  icon: historyDynamicIcon,\n  onClick: () => {\n    transferQueueDialog.value = true\n  },\n  menuItems: historyDynamicMenuItems,\n  show: computed(() => appMode.value),\n})\n\n// 初始加载数据\nonMounted(() => {\n  loadStorages()\n  fetchData()\n})\n\nonUnmounted(() => {\n  stopAiRedoProgress()\n})\n</script>\n\n<template>\n  <VCard>\n    <VCardItem>\n      <VCardTitle>\n        <VRow>\n          <VCol cols=\"8\" md=\"6\" class=\"flex\">\n            <VCombobox\n              key=\"search_navbar\"\n              v-model=\"search\"\n              :items=\"searchHintList\"\n              @compositionstart=\"isComposing = true\"\n              @compositionend=\"isComposing = false\"\n              class=\"text-disabled\"\n              density=\"compact\"\n              :label=\"t('transferHistory.searchPlaceholder')\"\n              prepend-inner-icon=\"mdi-magnify\"\n              variant=\"solo-filled\"\n              max-width=\"25rem\"\n              single-line\n              hide-details\n              flat\n              rounded=\"pill\"\n              clearable\n            />\n          </VCol>\n          <VCol cols=\"4\" md=\"6\" class=\"text-end\">\n            <VBtnGroup variant=\"outlined\" divided rounded>\n              <VBtn :icon=\"group ? 'mdi-format-list-bulleted' : 'mdi-format-list-group'\" @click=\"group = !group\" />\n            </VBtnGroup>\n          </VCol>\n        </VRow>\n      </VCardTitle>\n    </VCardItem>\n    <!-- 分组模式 -->\n    <VDataTableVirtual\n      v-if=\"group\"\n      v-model=\"selected\"\n      :groupBy=\"groupBy\"\n      :headers=\"groupHeaders\"\n      :items=\"dataList\"\n      :loading=\"loading\"\n      density=\"compact\"\n      return-object\n      fixed-header\n      show-select\n      :loading-text=\"t('transferHistory.loading')\"\n      hover\n      :style=\"{ height: `${availableHeight}px` }\"\n    >\n      <template #header.data-table-group>\n        <span>{{ t('transferHistory.titleColumn') }}</span>\n      </template>\n      <template v-slot:group-header=\"{ item, columns, toggleGroup, isGroupOpen }\">\n        <tr>\n          <td :colspan=\"columns.length\">\n            <div class=\"d-flex align-center gap-2\">\n              <VBtn\n                :icon=\"isGroupOpen(item) ? '$expand' : '$next'\"\n                size=\"small\"\n                variant=\"text\"\n                @click=\"toggleGroup(item)\"\n              />\n              <VCheckbox\n                :model-value=\"selectedCountsGroupedByTitle[item.value] == item.items.length\"\n                :indeterminate=\"selectedCountsGroupedByTitle[item.value] < item.items.length\"\n                @update:modelValue=\"checked => toggleGroupSelection(checked, item.items)\"\n              />\n              {{ item.value }}\n            </div>\n          </td>\n        </tr>\n      </template>\n      <template #item.title=\"{ item }\">\n        <div class=\"d-flex align-center\">\n          <VAvatar>\n            <VIcon :icon=\"getIcon(item.type || '')\" />\n          </VAvatar>\n          <div class=\"d-flex flex-column ms-1\">\n            <span v-if=\"item.type === '电视剧'\" class=\"d-block text-high-emphasis min-w-20\">\n              {{ item?.seasons }}{{ item?.episodes }}\n            </span>\n            <small>{{ item?.category }}</small>\n          </div>\n        </div>\n      </template>\n      <template #item.src=\"{ item }\">\n        <div>\n          <span>\n            <VChip variant=\"tonal\" size=\"small\" label class=\"my-1\"> {{ storageDict[item?.src_storage || ''] }}</VChip>\n            <small>{{ item?.src }}</small>\n          </span>\n          <span class=\"text-high-emphasis text-bold\"> => </span>\n          <br />\n          <span v-if=\"item?.dest\">\n            <VChip variant=\"tonal\" size=\"small\" label class=\"my-1\"> {{ storageDict[item?.dest_storage || ''] }}</VChip>\n            <small>{{ item?.dest }}</small>\n          </span>\n        </div>\n      </template>\n      <template #item.mode=\"{ item }\">\n        <VChip variant=\"outlined\" color=\"primary\" size=\"small\">\n          {{ TransferDict[item?.mode ?? ''] || t('common.unknown') }}\n        </VChip>\n      </template>\n      <template #item.status=\"{ item }\">\n        <VChip v-if=\"item?.status\" color=\"success\" size=\"small\"> {{ t('transferHistory.status.success') }} </VChip>\n        <VTooltip v-else :text=\"item?.errmsg\">\n          <template #activator=\"{ props }\">\n            <VChip v-bind=\"props\" color=\"error\" size=\"small\"> {{ t('transferHistory.status.failed') }} </VChip>\n          </template>\n        </VTooltip>\n      </template>\n      <template #item.size=\"{ item }\">\n        <small>{{ formatFileSize(item?.src_fileitem?.size || 0) }}</small>\n      </template>\n      <template #item.date=\"{ item }\">\n        <small>{{ item?.date }}</small>\n      </template>\n      <template #item.actions=\"{ item }\">\n        <IconBtn>\n          <VIcon icon=\"mdi-dots-vertical\" />\n          <VMenu activator=\"parent\" close-on-content-click>\n            <VList>\n              <VListItem\n                v-for=\"(menu, i) in getDropdownItems(item)\"\n                :key=\"i\"\n                :base-color=\"menu.props.color\"\n                :disabled=\"menu.props.disabled\"\n                @click=\"menu.props.click()\"\n              >\n                <template #prepend>\n                  <VIcon :icon=\"menu.props.prependIcon\" />\n                </template>\n                <VListItemTitle v-text=\"menu.title\" />\n              </VListItem>\n            </VList>\n          </VMenu>\n        </IconBtn>\n      </template>\n      <template #no-data> {{ t('transferHistory.noData') }} </template>\n    </VDataTableVirtual>\n    <!-- 列表模式 -->\n    <VDataTableVirtual\n      v-else\n      v-model=\"selected\"\n      :headers=\"headers\"\n      :items=\"dataList\"\n      :loading=\"loading\"\n      density=\"compact\"\n      return-object\n      fixed-header\n      show-select\n      :loading-text=\"t('transferHistory.loading')\"\n      hover\n      :style=\"{ height: `${availableHeight}px` }\"\n    >\n      <template #item.title=\"{ item }\">\n        <div class=\"d-flex align-center\">\n          <VAvatar>\n            <VIcon :icon=\"getIcon(item.type || '')\" />\n          </VAvatar>\n          <div class=\"d-flex flex-column ms-1\">\n            <span v-if=\"item.type === '电视剧'\" class=\"d-block text-high-emphasis min-w-20\">\n              {{ item?.title }} {{ item?.seasons }}{{ item?.episodes }}\n            </span>\n            <span v-else class=\"d-block text-high-emphasis min-w-20\">\n              {{ item?.title }}\n            </span>\n            <small>{{ item?.category }}</small>\n          </div>\n        </div>\n      </template>\n      <template #item.src=\"{ item }\">\n        <div>\n          <span>\n            <VChip variant=\"tonal\" size=\"small\" label class=\"my-1\"> {{ storageDict[item?.src_storage || ''] }}</VChip>\n            <small>{{ item?.src }}</small>\n          </span>\n          <span class=\"text-high-emphasis text-bold\"> => </span>\n          <br />\n          <span v-if=\"item?.dest\">\n            <VChip variant=\"tonal\" size=\"small\" label class=\"my-1\"> {{ storageDict[item?.dest_storage || ''] }}</VChip>\n            <small>{{ item?.dest }}</small>\n          </span>\n        </div>\n      </template>\n      <template #item.mode=\"{ item }\">\n        <VChip variant=\"outlined\" color=\"primary\" size=\"small\">\n          {{ TransferDict[item?.mode ?? ''] || t('common.unknown') }}\n        </VChip>\n      </template>\n      <template #item.status=\"{ item }\">\n        <VChip v-if=\"item?.status\" color=\"success\" size=\"small\"> {{ t('transferHistory.status.success') }} </VChip>\n        <VTooltip v-else :text=\"item?.errmsg\">\n          <template #activator=\"{ props }\">\n            <VChip v-bind=\"props\" color=\"error\" size=\"small\"> {{ t('transferHistory.status.failed') }} </VChip>\n          </template>\n        </VTooltip>\n      </template>\n      <template #item.size=\"{ item }\">\n        <small>{{ formatFileSize(item?.src_fileitem?.size || 0) }}</small>\n      </template>\n      <template #item.date=\"{ item }\">\n        <small>{{ item?.date }}</small>\n      </template>\n      <template #item.actions=\"{ item }\">\n        <IconBtn>\n          <VIcon icon=\"mdi-dots-vertical\" />\n          <VMenu activator=\"parent\" close-on-content-click>\n            <VList>\n              <VListItem\n                v-for=\"(menu, i) in getDropdownItems(item)\"\n                :key=\"i\"\n                :base-color=\"menu.props.color\"\n                :disabled=\"menu.props.disabled\"\n                @click=\"menu.props.click()\"\n              >\n                <template #prepend>\n                  <VIcon :icon=\"menu.props.prependIcon\" />\n                </template>\n                <VListItemTitle v-text=\"menu.title\" />\n              </VListItem>\n            </VList>\n          </VMenu>\n        </IconBtn>\n      </template>\n      <template #no-data> {{ t('transferHistory.noData') }} </template>\n    </VDataTableVirtual>\n    <VDivider />\n    <div class=\"flex items-center justify-between\">\n      <div class=\"w-auto\">\n        <VSelect v-model=\"itemsPerPage\" :items=\"pageRange\" density=\"compact\" flat class=\"ms-1\" />\n      </div>\n      <div class=\"w-auto text-sm\">{{ t('transferHistory.pageInfo', pageTip) }} {{ totalItems }}</div>\n      <VPagination\n        v-model=\"currentPage\"\n        show-first-last-page\n        :length=\"totalPage\"\n        :total-visible=\"display.mdAndUp.value ? 7 : 0\"\n        @next=\"currentPage + 1\"\n        @prev=\"currentPage - 1\"\n      >\n      </VPagination>\n    </div>\n  </VCard>\n\n  \n  <!-- 底部弹窗 -->\n  <VBottomSheet v-model=\"deleteConfirmDialog\" inset>\n    <VCard class=\"text-center\">\n      <VDialogCloseBtn @click=\"deleteConfirmDialog = false\" />\n      <VCardTitle class=\"pe-10\">\n        {{ confirmTitle }}\n      </VCardTitle>\n      <div class=\"d-flex flex-column flex-lg-row justify-center my-3\">\n        <VBtn color=\"primary\" class=\"mb-2 mx-2\" @click=\"deleteConfirmHandler(false, false)\">\n          {{ t('transferHistory.deleteRecordOnly') }}\n        </VBtn>\n        <VBtn color=\"warning\" class=\"mb-2 mx-2\" @click=\"deleteConfirmHandler(true, false)\">\n          {{ t('transferHistory.deleteSourceOnly') }}\n        </VBtn>\n        <VBtn color=\"info\" class=\"mb-2 mx-2\" @click=\"deleteConfirmHandler(false, true)\">\n          {{ t('transferHistory.deleteDestOnly') }}\n        </VBtn>\n        <VBtn color=\"error\" class=\"mb-2 mx-2\" @click=\"deleteConfirmHandler(true, true)\">\n          {{ t('transferHistory.deleteAll') }}\n        </VBtn>\n      </div>\n    </VCard>\n  </VBottomSheet>\n  <!-- 进度框 -->\n  <ProgressDialog v-if=\"progressDialog\" v-model=\"progressDialog\" :text=\"progressText\" :value=\"progressValue\" />\n  <ProgressDialog v-if=\"aiRedoProgressDialog\" v-model=\"aiRedoProgressDialog\" :text=\"aiRedoProgressText\" />\n  <!-- 文件整理弹窗 -->\n  <ReorganizeDialog\n    v-if=\"redoDialog\"\n    v-model=\"redoDialog\"\n    :logids=\"redoIds\"\n    :target_storage=\"redoTargetStorage\"\n    @done=\"transferDone\"\n    @close=\"redoDialog = false\"\n  />\n  <!-- 整理队列进度弹窗 -->\n  <TransferQueueDialog v-if=\"transferQueueDialog\" v-model=\"transferQueueDialog\" @close=\"transferQueueDialog = false\" />\n\n  <!-- 非 app 模式下的 FAB 按钮 -->\n  <Teleport to=\"body\" v-if=\"!appMode && route.path === '/history'\">\n    <div v-if=\"isRefreshed\" class=\"compact-fab-stack compact-fab-stack--history\">\n      <VFab\n        v-if=\"selected.length > 0\"\n        icon=\"mdi-trash-can-outline\"\n        color=\"warning\"\n        variant=\"tonal\"\n        appear\n        class=\"compact-fab compact-fab--secondary\"\n        @click=\"removeHistoryBatch\"\n      />\n      <VFab\n        v-if=\"selected.length > 0\"\n        icon=\"mdi-redo-variant\"\n        color=\"success\"\n        variant=\"tonal\"\n        appear\n        class=\"compact-fab compact-fab--secondary\"\n        @click=\"retransferBatch\"\n      />\n      <VFab\n        icon=\"mdi-timer-sand-paused\"\n        color=\"primary\"\n        appear\n        class=\"compact-fab compact-fab--primary\"\n        @click=\"transferQueueDialog = true\"\n      />\n    </div>\n  </Teleport>\n\n</template>\n\n<style lang=\"scss\">\n.v-table th {\n  white-space: nowrap;\n}\n\n.v-table__wrapper {\n  border-radius: 0;\n}\n</style>\n"
  },
  {
    "path": "src/views/setting/AccountSettingDirectory.vue",
    "content": "<!-- eslint-disable sonarjs/no-duplicate-string -->\n<script lang=\"ts\" setup>\nimport { useToast } from 'vue-toastification'\nimport draggable from 'vuedraggable'\nimport { VRow } from 'vuetify/lib/components/index.mjs'\nimport api from '@/api'\nimport { TransferDirectoryConf, StorageConf } from '@/api/types'\nimport DirectoryCard from '@/components/cards/DirectoryCard.vue'\nimport StorageCard from '@/components/cards/StorageCard.vue'\nimport ProgressDialog from '@/components/dialog/ProgressDialog.vue'\nimport CategoryEditDialog from '@/components/dialog/CategoryEditDialog.vue'\nimport { useI18n } from 'vue-i18n'\nimport { useTheme } from 'vuetify'\nimport { storageAttributes } from '@/api/constants'\n\nconst { t } = useI18n()\nconst { global: globalTheme } = useTheme()\n\n// 所有下载目录\nconst directories = ref<TransferDirectoryConf[]>([])\n\n// 所有存储\nconst storages = ref<StorageConf[]>([])\n\n// 二级分类策略\nconst mediaCategories = ref<{ [key: string]: any }>({})\n\n// 提示框\nconst $toast = useToast()\n\n// 进度框\nconst progressDialog = ref(false)\n\n// 分类编辑对话框\nconst categoryDialog = ref(false)\n\n// 数据源\nconst sourceItems = [\n  { 'title': 'TheMovieDb', 'value': 'themoviedb' },\n  { 'title': '豆瓣', 'value': 'douban' },\n]\n\n// 存储选项（排除已添加的）\nconst storageOptions = computed(() => {\n  const existingTypes = storages.value.map(storage => storage.type)\n  return storageAttributes\n    .filter(item => !existingTypes.includes(item.type))\n    .map(item => ({\n      title: t(`storage.${item.type}`),\n      value: item.type,\n    }))\n})\n\n// 系统设置\nconst SystemSettings = ref<any>({\n  Basic: {\n    SCRAP_SOURCE: 'themoviedb',\n    MOVIE_RENAME_FORMAT: null,\n    TV_RENAME_FORMAT: null,\n  },\n})\n\n// 编辑器主题\nconst editorTheme = computed(() => (globalTheme.name.value === 'light' ? 'github' : 'monokai'))\n\nconst renameEditorOptions = {\n  fontSize: 14,\n  tabSize: 2,\n  showLineNumbers: true,\n  showGutter: true,\n}\n\nconst movieRenameFormat = computed({\n  get: () => SystemSettings.value.Basic.MOVIE_RENAME_FORMAT ?? '',\n  set: (value: string) => {\n    SystemSettings.value.Basic.MOVIE_RENAME_FORMAT = value || null\n  },\n})\n\nconst tvRenameFormat = computed({\n  get: () => SystemSettings.value.Basic.TV_RENAME_FORMAT ?? '',\n  set: (value: string) => {\n    SystemSettings.value.Basic.TV_RENAME_FORMAT = value || null\n  },\n})\n\n// 加载系统设置\nasync function loadSystemSettings() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/env')\n    if (result.success) {\n      // 将API返回的值赋值给SystemSettings\n      for (const sectionKey of Object.keys(SystemSettings.value) as Array<keyof typeof SystemSettings.value>) {\n        Object.keys(SystemSettings.value[sectionKey]).forEach((key: string) => {\n          if (result.data.hasOwnProperty(key)) (SystemSettings.value[sectionKey] as any)[key] = result.data[key]\n        })\n      }\n    }\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 移动结束\nfunction orderDirectoryCards() {\n  // 更新所有目录的优先级\n  directories.value.forEach((item, index) => {\n    item.priority = index\n  })\n}\n\n// 查询存储\nasync function loadStorages() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/Storages')\n\n    storages.value = result.data?.value ?? []\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 保存存储\nasync function saveStorages() {\n  try {\n    const result: { [key: string]: any } = await api.post('system/setting/Storages', storages.value)\n    if (result.success) $toast.success(t('setting.directory.storageSaveSuccess'))\n    else $toast.error(t('setting.directory.storageSaveFailed'))\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 查询目录\nasync function loadDirectories() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/Directories')\n    directories.value = result.data?.value ?? []\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 保存目录\nasync function saveDirectories() {\n  orderDirectoryCards()\n  try {\n    const names = directories.value.map(item => item.name)\n    if (new Set(names).size !== names.length) {\n      $toast.error(t('setting.directory.duplicateDirectoryName'))\n      return\n    }\n    const result: { [key: string]: any } = await api.post('system/setting/Directories', directories.value)\n    if (result.success) {\n      $toast.success(t('setting.directory.directorySaveSuccess'))\n    } else $toast.error(t('setting.directory.directorySaveFailed'))\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 添加媒体库目录\nfunction addDirectory() {\n  let name = `${t('setting.directory.defaultDirName')}${directories.value.length + 1}`\n  while (directories.value.some(item => item.name === name)) {\n    name = `${t('setting.directory.defaultDirName')}${\n      parseInt(name.split(t('setting.directory.defaultDirName'))[1]) + 1\n    }`\n  }\n  directories.value.push({\n    name: name,\n    storage: 'local',\n    download_path: '',\n    priority: -1,\n    monitor_type: '',\n    media_type: '',\n    media_category: '',\n    transfer_type: '',\n  })\n  orderDirectoryCards()\n}\n\n// 移除媒体库目录\nfunction removeDirectory(directory: TransferDirectoryConf) {\n  const index = directories.value.indexOf(directory)\n  if (index > -1) {\n    directories.value.splice(index, 1)\n  }\n}\n\n// 调用API查询自动分类配置\nasync function loadMediaCategories() {\n  try {\n    mediaCategories.value = await api.get('media/category')\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 添加存储\nfunction addStorage(storageType = 'custom') {\n  let name: string\n  let type: string\n\n  if (storageType === 'custom') {\n    // 自定义存储需要数字序号\n    name = `${t(`storage.${storageType}`)} ${storages.value.length + 1}`\n    while (storages.value.some(item => item.name === name)) {\n      const num = parseInt(name.match(/\\d+$/)?.[0] || '1') + 1\n      name = `${t(`storage.${storageType}`)} ${num}`\n    }\n    type = `custom${storages.value.length + 1}`\n  } else {\n    // 预定义存储类型直接使用类型名称\n    name = t(`storage.${storageType}`)\n    type = storageType\n  }\n\n  storages.value.push({\n    name: name,\n    type: type,\n    config: {},\n  })\n\n  // 保存存储\n  saveStorages()\n}\n\n// 移除存储\nfunction removeStorage(storage: StorageConf) {\n  const index = storages.value.indexOf(storage)\n  if (index > -1) {\n    storages.value.splice(index, 1)\n  }\n}\n\n// 保存设置\nasync function saveSystemSettings(value: any) {\n  try {\n    const result: { [key: string]: any } = await api.post('system/env', value)\n    if (result.success) {\n      $toast.success(t('setting.directory.organizeSaveSuccess'))\n    } else $toast.error(t('setting.directory.organizeSaveFailed'))\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 加载数据\nonMounted(() => {\n  loadDirectories()\n  loadStorages()\n  loadMediaCategories()\n  loadSystemSettings()\n})\n</script>\n\n<template>\n  <VRow>\n    <VCol cols=\"12\">\n      <VCard>\n        <VCardItem>\n          <VCardTitle>{{ t('setting.directory.storage') }}</VCardTitle>\n          <VCardSubtitle>{{ t('setting.directory.storageDesc') }}</VCardSubtitle>\n        </VCardItem>\n        <VCardText>\n          <draggable\n            v-model=\"storages\"\n            handle=\".cursor-move\"\n            item-key=\"name\"\n            tag=\"div\"\n            :component-data=\"{ 'class': 'grid gap-3 grid-app-card' }\"\n          >\n            <template #item=\"{ element }\">\n              <StorageCard :storage=\"element\" @close=\"removeStorage(element)\" @done=\"loadStorages\" />\n            </template>\n          </draggable>\n        </VCardText>\n        <VCardText>\n          <VForm @submit.prevent=\"() => {}\">\n            <div class=\"d-flex flex-wrap gap-4 mt-4\">\n              <VBtn type=\"submit\" class=\"me-2\" @click=\"saveStorages\" prepend-icon=\"mdi-content-save\">\n                {{ t('common.save') }}\n              </VBtn>\n              <VBtn color=\"success\" variant=\"tonal\">\n                <VIcon icon=\"mdi-plus\" />\n                <VMenu activator=\"parent\" close-on-content-click>\n                  <VList>\n                    <VListItem v-for=\"item in storageOptions\" :key=\"item.value\" @click=\"addStorage(item.value)\">\n                      <VListItemTitle>{{ item.title }}</VListItemTitle>\n                    </VListItem>\n                    <VListItem @click=\"addStorage('custom')\">\n                      <VListItemTitle>{{ t('storage.custom') }}</VListItemTitle>\n                    </VListItem>\n                  </VList>\n                </VMenu>\n              </VBtn>\n            </div>\n          </VForm>\n        </VCardText>\n      </VCard>\n    </VCol>\n  </VRow>\n  <VRow>\n    <VCol cols=\"12\">\n      <VCard>\n        <VCardItem>\n          <VCardTitle>{{ t('setting.directory.directory') }}</VCardTitle>\n          <VCardSubtitle>{{ t('setting.directory.directoryDesc') }}</VCardSubtitle>\n        </VCardItem>\n        <VCardText>\n          <draggable\n            v-model=\"directories\"\n            handle=\".cursor-move\"\n            item-key=\"pri\"\n            tag=\"div\"\n            @end=\"orderDirectoryCards\"\n            :component-data=\"{ 'class': 'grid gap-3 grid-directory-card items-start' }\"\n          >\n            <template #item=\"{ element }\">\n              <DirectoryCard\n                :directory=\"element\"\n                :categories=\"mediaCategories\"\n                :storages=\"storages\"\n                @update:modelValue=\"\n                  (value: any) => {\n                    element.download_path = value?.download\n                    element.library_path = value?.library\n                  }\n                \"\n                @close=\"removeDirectory(element)\"\n              />\n            </template>\n          </draggable>\n        </VCardText>\n        <VCardText>\n          <VForm @submit.prevent=\"() => {}\">\n            <div class=\"d-flex flex-wrap gap-4 mt-4\">\n              <VBtn type=\"submit\" @click=\"saveDirectories\" prepend-icon=\"mdi-content-save\">\n                {{ t('common.save') }}\n              </VBtn>\n              <VBtn color=\"success\" variant=\"tonal\" @click=\"addDirectory\" class=\"me-2\">\n                <VIcon icon=\"mdi-plus\" />\n              </VBtn>\n              <VSpacer />\n              <VBtn color=\"info\" variant=\"tonal\" prepend-icon=\"mdi-shape-plus\" @click=\"categoryDialog = true\">\n                {{ t('setting.category.title') }}\n              </VBtn>\n            </div>\n          </VForm>\n        </VCardText>\n      </VCard>\n    </VCol>\n  </VRow>\n  <VRow>\n    <VCol cols=\"12\">\n      <VCard>\n        <VCardItem>\n          <VCardTitle>{{ t('setting.directory.organizeAndScrap') }}</VCardTitle>\n          <VCardSubtitle>{{ t('setting.directory.organizeAndScrapDesc') }}</VCardSubtitle>\n        </VCardItem>\n        <VCardText>\n          <VRow>\n            <VCol cols=\"12\" md=\"6\">\n              <VSelect\n                v-model=\"SystemSettings.Basic.SCRAP_SOURCE\"\n                :items=\"sourceItems\"\n                :label=\"t('setting.directory.scrapSource')\"\n                :hint=\"t('setting.directory.scrapSourceHint')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-database\"\n              />\n            </VCol>\n            <VCol cols=\"12\">\n              <div class=\"rename-format-editor\">\n                <div class=\"rename-format-editor__label\">\n                  <VIcon icon=\"mdi-movie-open\" size=\"20\" class=\"me-2\" />\n                  <span>{{ t('setting.directory.movieRenameFormat') }}</span>\n                </div>\n                <VAceEditor\n                  v-model:value=\"movieRenameFormat\"\n                  lang=\"jinja2\"\n                  :theme=\"editorTheme\"\n                  :options=\"renameEditorOptions\"\n                  :print-margin=\"false\"\n                  :min-lines=\"4\"\n                  :max-lines=\"12\"\n                  wrap\n                  class=\"rename-format-editor__ace rounded\"\n                />\n                <div class=\"rename-format-editor__hint\">\n                  {{ t('setting.directory.movieRenameFormatHint') }}\n                </div>\n              </div>\n            </VCol>\n            <VCol cols=\"12\">\n              <div class=\"rename-format-editor\">\n                <div class=\"rename-format-editor__label\">\n                  <VIcon icon=\"mdi-television\" size=\"20\" class=\"me-2\" />\n                  <span>{{ t('setting.directory.tvRenameFormat') }}</span>\n                </div>\n                <VAceEditor\n                  v-model:value=\"tvRenameFormat\"\n                  lang=\"jinja2\"\n                  :theme=\"editorTheme\"\n                  :options=\"renameEditorOptions\"\n                  :print-margin=\"false\"\n                  :min-lines=\"4\"\n                  :max-lines=\"12\"\n                  wrap\n                  class=\"rename-format-editor__ace rounded\"\n                />\n                <div class=\"rename-format-editor__hint\">\n                  {{ t('setting.directory.tvRenameFormatHint') }}\n                </div>\n              </div>\n            </VCol>\n          </VRow>\n        </VCardText>\n        <VCardText>\n          <VForm @submit.prevent=\"() => {}\">\n            <div class=\"d-flex flex-wrap gap-4 mt-4\">\n              <VBtn type=\"submit\" @click=\"saveSystemSettings(SystemSettings.Basic)\" prepend-icon=\"mdi-content-save\">\n                {{ t('common.save') }}\n              </VBtn>\n            </div>\n          </VForm>\n        </VCardText>\n      </VCard>\n    </VCol>\n  </VRow>\n  <!-- 进度框 -->\n  <ProgressDialog v-if=\"progressDialog\" v-model=\"progressDialog\" :text=\"t('setting.system.reloading')\" />\n  <!-- 分类对话框 -->\n  <CategoryEditDialog\n    v-if=\"categoryDialog\"\n    v-model=\"categoryDialog\"\n    :categories=\"mediaCategories\"\n    @close=\"categoryDialog = false\"\n    @done=\"loadMediaCategories\"\n  />\n</template>\n\n<style scoped>\n.rename-format-editor__label {\n  display: flex;\n  align-items: center;\n  color: rgba(var(--v-theme-on-surface), 0.78);\n  font-size: 0.875rem;\n  font-weight: 500;\n  line-height: 1.375rem;\n  margin-block-end: 0.5rem;\n}\n\n.rename-format-editor__ace {\n  overflow: hidden;\n  border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));\n  min-block-size: 8rem;\n}\n\n.rename-format-editor__hint {\n  color: rgba(var(--v-theme-on-surface), 0.6);\n  font-size: 0.75rem;\n  line-height: 1.25rem;\n  margin-block-start: 0.375rem;\n}\n</style>\n"
  },
  {
    "path": "src/views/setting/AccountSettingNotification.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useToast } from 'vue-toastification'\nimport api from '@/api'\nimport draggable from 'vuedraggable'\nimport type { NotificationConf, NotificationSwitchConf } from '@/api/types'\nimport NotificationChannelCard from '@/components/cards/NotificationChannelCard.vue'\nimport ProgressDialog from '@/components/dialog/ProgressDialog.vue'\nimport { useI18n } from 'vue-i18n'\nimport { notificationSwitchDict } from '@/api/constants'\nimport { useTheme, useDisplay } from 'vuetify'\n\n// 显示器宽度\nconst display = useDisplay()\n\n// 国际化\nconst { t } = useI18n()\n\n// 初始化模板配置字典\nconst templateConfigs = ref<Record<string, string>>({\n  organizeSuccess: '{}',\n  downloadAdded: '{}',\n  subscribeAdded: '{}',\n  subscribeComplete: '{}',\n})\n\n// 模板类型配置\nconst templateTypes = ref([\n  {\n    type: 'organizeSuccess',\n    label: t('setting.notification.organizeSuccess'),\n  },\n  {\n    type: 'downloadAdded',\n    label: t('setting.notification.downloadAdded'),\n  },\n  {\n    type: 'subscribeAdded',\n    label: t('setting.notification.subscribeAdded'),\n  },\n  {\n    type: 'subscribeComplete',\n    label: t('setting.notification.subscribeComplete'),\n  },\n])\n\n// 编辑器主题\nconst { name: themeName, global: globalTheme } = useTheme()\nconst savedTheme = ref(localStorage.getItem('theme') ?? 'auto')\nconst currentThemeName = ref(savedTheme.value)\nconst editorTheme = computed(() => (currentThemeName.value === 'light' ? 'github' : 'monokai'))\n\n// 所有消息渠道\nconst notifications = ref<NotificationConf[]>([])\n\n// 提示框\nconst $toast = useToast()\n\n// 进度框\nconst progressDialog = ref(false)\nconst editorVisible = ref(false)\nconst currentTemplate = ref('')\nconst editorContent = ref('')\n\n// 消息类型开关\nconst notificationSwitchs = ref<NotificationSwitchConf[]>([\n  {\n    type: '资源下载',\n    action: 'all',\n  },\n  {\n    type: '整理入库',\n    action: 'all',\n  },\n  {\n    type: '订阅',\n    action: 'all',\n  },\n  {\n    type: '站点',\n    action: 'admin',\n  },\n  {\n    type: '媒体服务器',\n    action: 'admin',\n  },\n  {\n    type: '手动处理',\n    action: 'admin',\n  },\n  {\n    type: '插件',\n    action: 'admin',\n  },\n  {\n    type: '智能体',\n    action: 'admin',\n  },\n  {\n    type: '其它',\n    action: 'admin',\n  },\n])\n\n// 通知发送时间\nconst notificationTime = ref({\n  start: '00:00',\n  end: '23:59',\n})\n\n// 添加通知渠道\nfunction addNotification(notification: string) {\n  let name = `${t('setting.notification.channel')}${notifications.value.length + 1}`\n  while (notifications.value.some(item => item.name === name)) {\n    name = `${t('setting.notification.channel')}${parseInt(name.split(t('setting.notification.channel'))[1]) + 1}`\n  }\n  notifications.value.push({\n    name: name,\n    type: notification,\n    enabled: false,\n    config: {},\n  })\n}\n\n// 移除通知渠道\nfunction removeNotification(notification: NotificationConf) {\n  const index = notifications.value.indexOf(notification)\n  if (index > -1) notifications.value.splice(index, 1)\n}\n\n// 调用API查询通知渠道设置\nasync function loadNotificationSetting() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/Notifications')\n    notifications.value = result.data?.value ?? []\n  } catch (error) {\n    console.log(error)\n  }\n}\n\nasync function openEditor(type: string) {\n  try {\n    currentTemplate.value = type\n    const result: { [key: string]: any } = await api.get('system/setting/NotificationTemplates')\n    templateConfigs.value = result.data?.value || {}\n    editorContent.value = templateConfigs.value[type] || '{}'\n    editorVisible.value = true\n  } catch (error) {\n    console.error(error)\n    $toast.error(t('setting.notification.templateLoadFailed'))\n  }\n}\n\nasync function saveTemplate() {\n  try {\n    await api.post('system/setting/NotificationTemplates', {\n      ...templateConfigs.value,\n      [currentTemplate.value]: editorContent.value,\n    })\n    $toast.success(t('setting.notification.templateSaveSuccess'))\n    editorVisible.value = false\n  } catch (error) {\n    console.error(error)\n    $toast.error(t('setting.notification.templateSaveFailed'))\n  }\n}\n\nasync function loadTemplateConfigs() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/NotificationTemplates')\n    templateConfigs.value = result.data?.value || {}\n  } catch (error) {\n    console.error(error)\n    $toast.error(t('setting.notification.templateLoadFailed'))\n  }\n}\n\n// 调用API查询通知发送时间设置\nasync function loadNotificationTime() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/NotificationSendTime')\n    notificationTime.value = result.data?.value ?? { start: '00:00', end: '23:59' }\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 调用API保存通知设置\nasync function saveNotificationSetting() {\n  try {\n    const result: { [key: string]: any } = await api.post('system/setting/Notifications', notifications.value)\n    if (result.success) {\n      $toast.success(t('setting.notification.saveSuccess'))\n    } else $toast.error(t('setting.notification.saveFailed'))\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 调用API保存通知发送时间设置\nasync function saveNotificationTime() {\n  try {\n    const result: { [key: string]: any } = await api.post('system/setting/NotificationSendTime', notificationTime.value)\n    if (result.success) {\n      $toast.success(t('setting.notification.timeSaveSuccess'))\n    } else $toast.error(t('setting.notification.timeSaveFailed'))\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 通知渠道设置变化时赋值\nfunction changNotificationSetting(notification: NotificationConf, name: string) {\n  const index = notifications.value.findIndex(item => item.name === name)\n  if (index !== -1) notifications.value[index] = notification\n}\n\n// 加载消息类型开关\nasync function loadNotificationSwitchs() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/NotificationSwitchs')\n    if (result.data?.value && result.data?.value.length > 0) {\n      const savedSwitchs: NotificationSwitchConf[] = result.data.value\n      // 合并默认值中存在但后端数据中缺失的类型（如新增的类型）\n      const defaults = notificationSwitchs.value\n      for (const def of defaults) {\n        if (!savedSwitchs.find(item => item.type === def.type)) {\n          savedSwitchs.push(def)\n        }\n      }\n      notificationSwitchs.value = savedSwitchs\n    }\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 保存消息类型开关\nasync function saveNotificationSwitchs() {\n  try {\n    const result: { [key: string]: any } = await api.post(\n      'system/setting/NotificationSwitchs',\n      notificationSwitchs.value,\n    )\n    if (result.success) $toast.success(t('setting.notification.switchSaveSuccess'))\n    else $toast.error(t('setting.notification.switchSaveFailed'))\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 获取通知开关文本\nfunction getNotificationSwitchText(type: string | undefined) {\n  if (!type) return ''\n  return notificationSwitchDict[type]\n}\n\n// 加载数据\nonMounted(() => {\n  loadNotificationSetting()\n  loadNotificationSwitchs()\n  loadNotificationTime()\n  loadTemplateConfigs()\n})\n</script>\n\n<template>\n  <VRow>\n    <VCol cols=\"12\">\n      <VCard>\n        <VCardItem>\n          <VCardTitle>{{ t('setting.notification.channels') }}</VCardTitle>\n          <VCardSubtitle>{{ t('setting.notification.channelsDesc') }}</VCardSubtitle>\n        </VCardItem>\n        <VCardText>\n          <draggable\n            v-model=\"notifications\"\n            handle=\".cursor-move\"\n            item-key=\"name\"\n            tag=\"div\"\n            :component-data=\"{ 'class': 'grid gap-3 grid-app-card' }\"\n          >\n            <template #item=\"{ element }\">\n              <NotificationChannelCard\n                :notification=\"element\"\n                :notifications=\"notifications\"\n                @change=\"changNotificationSetting\"\n                @close=\"removeNotification(element)\"\n              />\n            </template>\n          </draggable>\n        </VCardText>\n        <VCardText>\n          <VForm @submit.prevent=\"() => {}\">\n            <div class=\"d-flex flex-wrap gap-4 mt-4\">\n              <VBtn mtype=\"submit\" @click=\"saveNotificationSetting\" prepend-icon=\"mdi-content-save\">\n                {{ t('common.save') }}\n              </VBtn>\n              <VBtn color=\"success\" variant=\"tonal\">\n                <VIcon icon=\"mdi-plus\" />\n                <VMenu :activator=\"'parent'\" :close-on-content-click=\"true\">\n                  <VList>\n                    <VListItem @click=\"addNotification('wechat')\">\n                      <VListItemTitle>{{ t('setting.notification.wechat') }}</VListItemTitle>\n                    </VListItem>\n                    <VListItem @click=\"addNotification('telegram')\">\n                      <VListItemTitle>{{ t('setting.notification.telegram') }}</VListItemTitle>\n                    </VListItem>\n                    <VListItem @click=\"addNotification('slack')\">\n                      <VListItemTitle>{{ t('setting.notification.slack') }}</VListItemTitle>\n                    </VListItem>\n                    <VListItem @click=\"addNotification('discord')\">\n                      <VListItemTitle>Discord</VListItemTitle>\n                    </VListItem>\n                    <VListItem @click=\"addNotification('synologychat')\">\n                      <VListItemTitle>{{ t('setting.notification.synologyChat') }}</VListItemTitle>\n                    </VListItem>\n                    <VListItem @click=\"addNotification('qqbot')\">\n                      <VListItemTitle>{{ t('setting.notification.qq') }}</VListItemTitle>\n                    </VListItem>\n                    <VListItem @click=\"addNotification('vocechat')\">\n                      <VListItemTitle>{{ t('setting.notification.voceChat') }}</VListItemTitle>\n                    </VListItem>\n                    <VListItem @click=\"addNotification('webpush')\">\n                      <VListItemTitle>{{ t('setting.notification.webPush') }}</VListItemTitle>\n                    </VListItem>\n                    <VListItem @click=\"addNotification('custom')\">\n                      <VListItemTitle>{{ t('setting.system.custom') }}</VListItemTitle>\n                    </VListItem>\n                  </VList>\n                </VMenu>\n              </VBtn>\n            </div>\n          </VForm>\n        </VCardText>\n      </VCard>\n    </VCol>\n  </VRow>\n  <VRow>\n    <VCol cols=\"12\">\n      <VCard>\n        <VCardItem>\n          <VCardTitle>{{ t('setting.notification.templateConfigTitle') }}</VCardTitle>\n          <VCardSubtitle>{{ t('setting.notification.templateConfigDesc') }}</VCardSubtitle>\n        </VCardItem>\n        <VCardText>\n          <VRow>\n            <VCol v-for=\"item in templateTypes\" :key=\"item.type\" cols=\"12\" sm=\"6\" md=\"3\">\n              <VCard variant=\"tonal\" class=\"template-card\" :class=\"{ 'on-hover': true }\" @click=\"openEditor(item.type)\">\n                <VCardItem>\n                  <template #prepend>\n                    <VAvatar color=\"primary\" variant=\"tonal\" rounded size=\"42\" class=\"me-3\">\n                      <VIcon\n                        size=\"24\"\n                        :icon=\"\n                          item.type === 'organizeSuccess'\n                            ? 'mdi-folder-check'\n                            : item.type === 'downloadAdded'\n                            ? 'mdi-download'\n                            : item.type === 'subscribeAdded'\n                            ? 'mdi-rss'\n                            : 'mdi-check-circle'\n                        \"\n                      />\n                    </VAvatar>\n                  </template>\n                  <VCardTitle>{{ item.label }}</VCardTitle>\n                  <template #append>\n                    <VIcon icon=\"mdi-chevron-right\" />\n                  </template>\n                </VCardItem>\n              </VCard>\n            </VCol>\n          </VRow>\n        </VCardText>\n      </VCard>\n    </VCol>\n  </VRow>\n  <VRow>\n    <VCol cols=\"12\">\n      <VCard>\n        <VCardItem>\n          <VCardTitle>{{ t('setting.notification.scope') }}</VCardTitle>\n          <VCardSubtitle>{{ t('setting.notification.scopeDesc') }}</VCardSubtitle>\n        </VCardItem>\n        <VTable class=\"text-no-wrap\">\n          <thead>\n            <tr>\n              <th scope=\"col\">{{ t('setting.notification.messageType') }}</th>\n              <th scope=\"col\">{{ t('setting.notification.scopeRange') }}</th>\n            </tr>\n          </thead>\n          <tbody>\n            <tr v-for=\"(item, index) in notificationSwitchs\" :key=\"index\">\n              <td>\n                {{ getNotificationSwitchText(item.type) }}\n              </td>\n              <td>\n                <VRadioGroup v-model=\"item.action\" inline>\n                  <VRadio value=\"user\" :label=\"t('setting.notification.operationUserOnly')\" />\n                  <VRadio value=\"admin\" :label=\"t('setting.notification.adminOnly')\" />\n                  <VRadio value=\"user,admin\" :label=\"t('setting.notification.userAndAdmin')\" />\n                  <VRadio value=\"all\" :label=\"t('setting.notification.allUsers')\" />\n                </VRadioGroup>\n              </td>\n            </tr>\n          </tbody>\n        </VTable>\n        <VCardText>\n          <VForm @submit.prevent=\"() => {}\">\n            <div class=\"d-flex flex-wrap gap-4 mt-4\">\n              <VBtn type=\"submit\" @click=\"saveNotificationSwitchs\" prepend-icon=\"mdi-content-save\">\n                {{ t('common.save') }}\n              </VBtn>\n            </div>\n          </VForm>\n        </VCardText>\n      </VCard>\n    </VCol>\n  </VRow>\n  <VRow>\n    <VCol cols=\"12\">\n      <VCard>\n        <VCardItem>\n          <VCardTitle>{{ t('setting.notification.sendTime') }}</VCardTitle>\n          <VCardSubtitle>{{ t('setting.notification.sendTimeDesc') }}</VCardSubtitle>\n        </VCardItem>\n        <VCardText>\n          <VRow>\n            <VCol cols=\"6\">\n              <VTextField\n                v-model=\"notificationTime.start\"\n                :label=\"t('setting.notification.startTime')\"\n                type=\"time\"\n                prepend-inner-icon=\"mdi-clock-start\"\n              />\n            </VCol>\n            <VCol cols=\"6\">\n              <VTextField\n                v-model=\"notificationTime.end\"\n                :label=\"t('setting.notification.endTime')\"\n                type=\"time\"\n                prepend-inner-icon=\"mdi-clock-end\"\n              />\n            </VCol>\n          </VRow>\n        </VCardText>\n        <VCardText>\n          <VForm @submit.prevent=\"() => {}\">\n            <div class=\"d-flex flex-wrap gap-4 mt-4\">\n              <VBtn type=\"submit\" @click=\"saveNotificationTime\" prepend-icon=\"mdi-content-save\">\n                {{ t('common.save') }}\n              </VBtn>\n            </div>\n          </VForm>\n        </VCardText>\n      </VCard>\n    </VCol>\n  </VRow>\n  <!-- 进度框 -->\n  <ProgressDialog\n    v-if=\"progressDialog\"\n    v-model=\"progressDialog\"\n    :text=\"t('setting.system.reloading')\"\n    :indeterminate=\"true\"\n  />\n  <!-- 模板编辑器对话框 -->\n  <VDialog v-model=\"editorVisible\" v-if=\"editorVisible\" max-width=\"50rem\" :fullscreen=\"!display.mdAndUp.value\">\n    <VCard>\n      <VCardItem class=\"py-2\">\n        <template #prepend>\n          <VIcon icon=\"mdi-code-json\" class=\"me-2\" />\n        </template>\n        <VCardTitle>\n          {{ t('setting.notification.templateConfigTitle') }}\n        </VCardTitle>\n        <VCardSubtitle>\n          {{ templateTypes.find(t => t.type === currentTemplate)?.label }}\n        </VCardSubtitle>\n        <VDialogCloseBtn @click=\"editorVisible = false\" />\n      </VCardItem>\n      <VCardText class=\"py-0\">\n        <VAceEditor\n          :key=\"`${currentTemplate}-jinja2-json`\"\n          v-model:value=\"editorContent\"\n          lang=\"jinja2_json\"\n          :theme=\"editorTheme\"\n          class=\"w-full h-full min-h-[30rem] rounded\"\n        />\n      </VCardText>\n      <VCardActions class=\"pt-3\">\n        <VBtn color=\"primary\" @click=\"saveTemplate\" prepend-icon=\"mdi-content-save\" class=\"px-5\">\n          {{ t('common.save') }}\n        </VBtn>\n      </VCardActions>\n    </VCard>\n  </VDialog>\n</template>\n<style scoped>\n/* Monaco编辑器容器样式 */\n.monaco-editor-container {\n  overflow: hidden;\n  border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));\n  border-radius: 8px;\n  margin-block-start: 1rem;\n}\n\n.template-card {\n  cursor: pointer;\n  transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;\n}\n\n.template-card.on-hover:hover {\n  transform: translateY(-4px);\n}\n</style>\n"
  },
  {
    "path": "src/views/setting/AccountSettingRule.vue",
    "content": "<!-- eslint-disable sonarjs/no-duplicate-string -->\n<script lang=\"ts\" setup>\nimport { useToast } from 'vue-toastification'\nimport { copyToClipboard } from '@/@core/utils/navigator'\nimport draggable from 'vuedraggable'\nimport api from '@/api'\nimport { CustomRule, FilterRuleGroup } from '@/api/types'\nimport CustomerRuleCard from '@/components/cards/CustomRuleCard.vue'\nimport FilterRuleGroupCard from '@/components/cards/FilterRuleGroupCard.vue'\nimport ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'\nimport { useI18n } from 'vue-i18n'\n\n// 国际化\nconst { t } = useI18n()\n\n// 自定义规则列表\nconst customRules = ref<CustomRule[]>([])\n\n// 所有规则组列表\nconst filterRuleGroups = ref<FilterRuleGroup[]>([])\n\n// 种子优先规则\nconst selectedTorrentPriority = ref<string[]>(['seeder'])\n\n// 二级分类策略\nconst mediaCategories = ref<{ [key: string]: any }>({})\n\n// 导入代码弹窗\nconst importCodeDialog = ref(false)\n\n// 导入代码类型\nconst importCodeType = ref('')\n\n// 提示框\nconst $toast = useToast()\n\n// 种子优先规则下拉框\nconst TorrentPriorityItems = [\n  { title: t('setting.rule.resourcePriority'), value: 'torrent' },\n  { title: t('setting.rule.sitePriority'), value: 'site' },\n  { title: t('setting.rule.siteUpload'), value: 'upload' },\n  { title: t('setting.rule.resourceSeeder'), value: 'seeder' },\n]\n\n// 调用API查询自动分类配置\nasync function loadMediaCategories() {\n  try {\n    mediaCategories.value = await api.get('media/category')\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 保存自定义规则\nasync function saveCustomRules() {\n  // 检查是否存在空id规则\n  if (customRules.value.some(item => !item.id)) {\n    $toast.error(t('setting.rule.emptyIdError'))\n    return\n  }\n  // 检查是否存在空的规则名称\n  if (customRules.value.some(item => !item.name)) {\n    $toast.error(t('setting.rule.emptyNameError'))\n    return\n  }\n  // 获取所有规则ID和名称\n  const ids = customRules.value.map(item => item.id)\n  const names = customRules.value.map(item => item.name)\n  // 检查是否存在重名的规则ID\n  if (new Set(ids).size !== ids.length) {\n    $toast.error(t('setting.rule.duplicateIdError'))\n    return\n  }\n  // 检查是否存在重名规则名称\n  if (new Set(names).size !== names.length) {\n    $toast.error(t('setting.rule.duplicateNameError'))\n    return\n  }\n  try {\n    const result: { [key: string]: any } = await api.post('system/setting/CustomFilterRules', customRules.value)\n    if (result.success) $toast.success(t('setting.rule.customRuleSaveSuccess'))\n    else $toast.error(t('setting.rule.customRuleSaveFailed'))\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 添加自定义规则\nasync function addCustomRule() {\n  let id = `RULE${customRules.value.length + 1}`\n  while (customRules.value.some(item => item.id === id)) {\n    id = `RULE${parseInt(id.split('RULE')[1]) + 1}`\n  }\n  let name = `规则${customRules.value.length + 1}`\n  while (customRules.value.some(item => item.name === name)) {\n    name = `规则${parseInt(name.split('规则')[1]) + 1}`\n  }\n  customRules.value.push({\n    id: id,\n    name: name,\n  })\n}\n\n// 移除自定义规则\nfunction removeCustomRule(rule: CustomRule) {\n  const index = customRules.value.findIndex(item => item.id === rule.id)\n  if (index !== -1) customRules.value.splice(index, 1)\n}\n\n// 加载规则组\nasync function queryFilterRuleGroups() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')\n    filterRuleGroups.value = result.data?.value ?? []\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 保存规则组\nasync function saveFilterRuleGroups() {\n  // 检查是否存在空的规则组名称\n  if (filterRuleGroups.value.some(item => !item.name)) {\n    $toast.error(t('setting.rule.emptyGroupNameError'))\n    return\n  }\n  // 检查是否存在重名规则组\n  const names = filterRuleGroups.value.map(item => item.name)\n  if (new Set(names).size !== names.length) {\n    $toast.error(t('setting.rule.duplicateGroupNameError'))\n    return\n  }\n  try {\n    const result: { [key: string]: any } = await api.post('system/setting/UserFilterRuleGroups', filterRuleGroups.value)\n    if (result.success) $toast.success(t('setting.rule.ruleGroupSaveSuccess'))\n    else $toast.error(t('setting.rule.ruleGroupSaveFailed'))\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 添加规则组\nfunction addFilterRuleGroup() {\n  let name = `规则组${filterRuleGroups.value.length + 1}`\n  while (filterRuleGroups.value.some(item => item.name === name)) {\n    name = `规则组${parseInt(name.split('规则组')[1]) + 1}`\n  }\n  filterRuleGroups.value.push({\n    name: name,\n    media_type: '',\n    category: '',\n  })\n}\n\n// 分享规则\nasync function shareRules(rules: CustomRule[] | FilterRuleGroup[], type: string) {\n  if (!rules || rules.length === 0) return\n\n  // 将卡片规则接装为字符串\n  const value = JSON.stringify(rules)\n\n  // 复制到剪贴板\n  try {\n    let success\n    success = copyToClipboard(value)\n    if (await success)\n      $toast.success(\n        type === 'custom' ? t('setting.rule.customRuleCopySuccess') : t('setting.rule.ruleGroupCopySuccess'),\n      )\n    else\n      $toast.error(type === 'custom' ? t('setting.rule.customRuleCopyFailed') : t('setting.rule.ruleGroupCopyFailed'))\n  } catch (e) {\n    $toast.error(type === 'custom' ? t('setting.rule.customRuleCopyError') : t('setting.rule.ruleGroupCopyError'))\n    console.error(e)\n  }\n}\n\n// 打开弹窗\nasync function importRules(ruleType: string) {\n  importCodeType.value = ruleType\n  importCodeDialog.value = true\n}\n\n// 保存导入的代码\nfunction saveCodeString(type: string, codeString: any) {\n  // codeString从子组件传递过来，从对象转换为JSON\n  let parsedCode\n  try {\n    parsedCode = JSON.parse(codeString.value)\n  } catch (e) {\n    $toast.error(t('setting.rule.importFailed'))\n    console.error(e)\n    return\n  }\n\n  // 更新数据\n  try {\n    if (type === 'custom') {\n      if (!checkValueValidity(parsedCode, type)) return false\n      const newCustomRules = extractCustomRules(parsedCode) || []\n      customRules.value = [...customRules.value, ...newCustomRules]\n    } else if (type === 'group') {\n      if (!checkValueValidity(parsedCode, type)) return false\n      const newFilterRuleGroups = extractFilterRuleGroups(parsedCode) || []\n      filterRuleGroups.value = [...filterRuleGroups.value, ...newFilterRuleGroups]\n    } else {\n      $toast.error(t('setting.rule.importUnknownType'))\n    }\n  } catch (e) {\n    $toast.error(t('setting.rule.importFailed'))\n    console.error(e)\n  }\n}\n\n// 赋值自定义规则，避免存在多余的属性\nfunction extractCustomRules(value: any) {\n  try {\n    return value.map((item: any) => {\n      return {\n        id: item.id,\n        name: item.name,\n        include: item.include,\n        exclude: item.exclude,\n        size_range: item.size_range,\n        seeders: item.seeders,\n        publish_time: item.publish_time,\n      }\n    })\n  } catch (e) {\n    console.error(e)\n  }\n}\n\n// 赋值规则组，避免存在多余的属性\nfunction extractFilterRuleGroups(value: any) {\n  try {\n    return value.map((item: any) => {\n      return {\n        name: item.name,\n        rule_string: item.rule_string,\n        media_type: item.media_type,\n        category: item.category,\n      }\n    })\n  } catch (e) {\n    console.error(e)\n  }\n}\n\n// 根据ID简单区分规则与规则组\nfunction checkValueValidity(values: any, type: string): boolean {\n  try {\n    if (!values) return true\n    if (!type) return false\n\n    for (const value of values) {\n      if (!isValidValue(value, type)) return false\n    }\n    return true\n  } catch (e) {\n    console.error(e)\n    return false\n  }\n}\n\nfunction isValidValue(value: any, type: string): boolean {\n  const keys = Object.keys(value)\n  const uniqueKeys = new Set(keys)\n  const hasName = keys.includes('name')\n  const hasId = keys.includes('id')\n  const noDuplicates = keys.length === uniqueKeys.size\n\n  if (type === 'custom') {\n    return validateCustomRule(hasName, hasId, noDuplicates)\n  } else if (type === 'group') {\n    return validateGroupRule(hasName, hasId, noDuplicates)\n  } else {\n    console.error(`传入了不合法的类型！`)\n    return false\n  }\n}\n\nfunction validateCustomRule(hasName: boolean, hasId: boolean, noDuplicates: boolean): boolean {\n  if (!hasName || !hasId || !noDuplicates) {\n    if (!noDuplicates) $toast.warning(t('setting.rule.duplicateValue'))\n    if (!hasId) $toast.error(t('setting.rule.importNoId'))\n    return false\n  }\n  return true\n}\n\nfunction validateGroupRule(hasName: boolean, hasId: boolean, noDuplicates: boolean): boolean {\n  if (!hasName || hasId || !noDuplicates) {\n    if (!noDuplicates) $toast.warning(t('setting.rule.duplicateValue'))\n    if (hasId) $toast.error(t('setting.rule.importHasId'))\n    return false\n  }\n  return true\n}\n\n// 清空规则（组）\nfunction deleteAllRules(dateType: string) {\n  if (!dateType) return\n  if (dateType === 'custom') {\n    customRules.value = []\n  } else if (dateType === 'group') {\n    filterRuleGroups.value = []\n  } else {\n    console.error(`传入了不支持的类型！`)\n  }\n}\n\n// 规则变化时赋值\nfunction onRuleChange(rule: CustomRule, id: string) {\n  const index = customRules.value.findIndex(item => item.id === id)\n  if (index !== -1) customRules.value[index] = rule\n}\n\n// 移除规则组\nfunction removeFilterRuleGroup(rule: FilterRuleGroup) {\n  const index = filterRuleGroups.value.findIndex(item => item.name === rule.name)\n  if (index !== -1) filterRuleGroups.value.splice(index, 1)\n}\n\n// 规则组变化时赋值\nfunction changeRuleGroup(group: FilterRuleGroup, name: string) {\n  const index = filterRuleGroups.value.findIndex(item => item.name === name)\n  if (index !== -1) filterRuleGroups.value[index] = group\n}\n\n// 查询种子优先规则\nasync function queryTorrentPriority() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/TorrentsPriority')\n\n    selectedTorrentPriority.value = result.data?.value\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 查询自定义规则项\nasync function queryCustomRules() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/CustomFilterRules')\n    customRules.value = result.data?.value ?? []\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 保存种子优先规则\nasync function saveTorrentPriority() {\n  try {\n    const result: { [key: string]: any } = await api.post(\n      'system/setting/TorrentsPriority',\n      selectedTorrentPriority.value,\n    )\n    if (result.success) $toast.success('优先规则保存成功')\n    else $toast.error('优先规则保存失败！')\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 加载数据\nonMounted(() => {\n  loadMediaCategories()\n  queryCustomRules()\n  queryFilterRuleGroups()\n  queryTorrentPriority()\n})\n</script>\n\n<template>\n  <VRow>\n    <VCol cols=\"12\">\n      <VCard>\n        <VCardItem>\n          <VCardTitle>{{ t('setting.rule.customRules') }}</VCardTitle>\n          <VCardSubtitle>{{ t('setting.rule.customRulesDesc') }}</VCardSubtitle>\n        </VCardItem>\n        <VCardText>\n          <draggable\n            v-model=\"customRules\"\n            handle=\".cursor-move\"\n            item-key=\"name\"\n            tag=\"div\"\n            :component-data=\"{ 'class': 'grid gap-3 grid-customrule-card' }\"\n          >\n            <template #item=\"{ element }\">\n              <CustomerRuleCard\n                :rule=\"element\"\n                :rules=\"customRules\"\n                @close=\"removeCustomRule(element)\"\n                @change=\"onRuleChange\"\n              />\n            </template>\n          </draggable>\n        </VCardText>\n        <VCardText>\n          <VForm @submit.prevent=\"() => {}\">\n            <div class=\"d-flex flex-wrap gap-4 mt-4\">\n              <VBtn type=\"submit\" class=\"me-2\" @click=\"saveCustomRules\" prepend-icon=\"mdi-content-save\">\n                {{ t('common.save') }}\n              </VBtn>\n              <VBtnGroup density=\"comfortable\">\n                <VBtn color=\"success\" variant=\"tonal\" @click=\"addCustomRule\">\n                  <VIcon icon=\"mdi-plus\" />\n                </VBtn>\n                <VBtn color=\"primary\" variant=\"tonal\" @click=\"importRules('custom')\">\n                  <VIcon icon=\"mdi-import\" />\n                </VBtn>\n                <VBtn color=\"info\" variant=\"tonal\" @click=\"shareRules(customRules, 'custom')\">\n                  <VIcon icon=\"mdi-share\" />\n                </VBtn>\n                <VBtn color=\"error\" variant=\"tonal\" @click=\"deleteAllRules('custom')\">\n                  <VIcon icon=\"mdi-delete-empty-outline\" />\n                </VBtn>\n              </VBtnGroup>\n            </div>\n          </VForm>\n        </VCardText>\n      </VCard>\n    </VCol>\n  </VRow>\n  <VRow>\n    <VCol cols=\"12\">\n      <VCard>\n        <VCardItem>\n          <VCardTitle>{{ t('setting.rule.priorityRuleGroups') }}</VCardTitle>\n          <VCardSubtitle>{{ t('setting.rule.priorityRuleGroupsDesc') }}</VCardSubtitle>\n        </VCardItem>\n        <VCardText>\n          <draggable\n            v-model=\"filterRuleGroups\"\n            handle=\".cursor-move\"\n            item-key=\"name\"\n            tag=\"div\"\n            :component-data=\"{ 'class': 'grid gap-3 grid-app-card' }\"\n          >\n            <template #item=\"{ element }\">\n              <FilterRuleGroupCard\n                :group=\"element\"\n                :groups=\"filterRuleGroups\"\n                :custom_rules=\"customRules\"\n                :categories=\"mediaCategories\"\n                @close=\"removeFilterRuleGroup(element)\"\n                @change=\"changeRuleGroup\"\n              />\n            </template>\n          </draggable>\n        </VCardText>\n        <VCardText>\n          <VForm @submit.prevent=\"() => {}\">\n            <div class=\"d-flex flex-wrap gap-4 mt-4\">\n              <VBtn type=\"submit\" class=\"me-2\" @click=\"saveFilterRuleGroups\" prepend-icon=\"mdi-content-save\">\n                {{ t('common.save') }}\n              </VBtn>\n              <VBtnGroup density=\"comfortable\">\n                <VBtn color=\"success\" variant=\"tonal\" @click=\"addFilterRuleGroup\">\n                  <VIcon icon=\"mdi-plus\" />\n                </VBtn>\n                <VBtn color=\"primary\" variant=\"tonal\" @click=\"importRules('group')\">\n                  <VIcon icon=\"mdi-import\" />\n                </VBtn>\n                <VBtn color=\"info\" variant=\"tonal\" @click=\"shareRules(filterRuleGroups, 'group')\">\n                  <VIcon icon=\"mdi-share\" />\n                </VBtn>\n                <VBtn color=\"error\" variant=\"tonal\" @click=\"deleteAllRules('group')\">\n                  <VIcon icon=\"mdi-delete-empty-outline\" />\n                </VBtn>\n              </VBtnGroup>\n            </div>\n          </VForm>\n        </VCardText>\n      </VCard>\n    </VCol>\n  </VRow>\n  <ImportCodeDialog\n    v-if=\"importCodeDialog\"\n    v-model=\"importCodeDialog\"\n    :title=\"importCodeType === 'custom' ? t('setting.rule.importCustomRules') : t('setting.rule.importRuleGroups')\"\n    :dataType=\"importCodeType\"\n    @close=\"importCodeDialog = false\"\n    @save=\"saveCodeString\"\n  />\n  <VRow>\n    <VCol cols=\"12\">\n      <VCard>\n        <VCardItem>\n          <VCardTitle>{{ t('setting.rule.downloadRules') }}</VCardTitle>\n          <VCardSubtitle>{{ t('setting.rule.downloadRulesDesc') }}</VCardSubtitle>\n        </VCardItem>\n        <VCardText>\n          <VForm>\n            <VRow>\n              <VCol cols=\"12\" md=\"6\">\n                <VSelect\n                  v-model=\"selectedTorrentPriority\"\n                  :items=\"TorrentPriorityItems\"\n                  multiple\n                  clearable\n                  chips\n                  :label=\"t('setting.rule.currentPriorityRules')\"\n                  :hint=\"t('setting.rule.currentPriorityRulesHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-priority-high\"\n                />\n              </VCol>\n            </VRow>\n          </VForm>\n        </VCardText>\n        <VCardText>\n          <VForm @submit.prevent=\"() => {}\">\n            <div class=\"d-flex flex-wrap gap-4 mt-4\">\n              <VBtn type=\"submit\" @click=\"saveTorrentPriority\" prepend-icon=\"mdi-content-save\">\n                {{ t('common.save') }}\n              </VBtn>\n            </div>\n          </VForm>\n        </VCardText>\n      </VCard>\n    </VCol>\n  </VRow>\n</template>\n"
  },
  {
    "path": "src/views/setting/AccountSettingSearch.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useToast } from 'vue-toastification'\nimport api from '@/api'\nimport type { FilterRuleGroup, Site } from '@/api/types'\nimport { useI18n } from 'vue-i18n'\n\n// 国际化\nconst { t } = useI18n()\n\n// 提示框\nconst $toast = useToast()\n\n// 所有站点\nconst allSites = ref<Site[]>([])\n\n// 选中订阅站点\nconst selectedSites = ref<number[]>([])\n\n// 系统设置\nconst SystemSettings = ref<any>({\n  Basic: {\n    SEARCH_MULTIPLE_NAME: false,\n    DOWNLOAD_SUBTITLE: false,\n    AUTO_DOWNLOAD_USER: null,\n    TORRENT_TAG: 'MOVIEPILOT',\n  },\n})\n\n// 媒体信息数据源字典\nconst mediaSourcesDict = [\n  {\n    title: 'TheMovieDb',\n    value: 'themoviedb',\n  },\n  {\n    title: '豆瓣',\n    value: 'douban',\n  },\n  {\n    title: 'Bangumi',\n    value: 'bangumi',\n  },\n]\n\n// 当前选中的媒体信息数据源\nconst selectedMediaSource = ref([])\n\n// 当前选中的过滤规则组\nconst selectedFilterGroup = ref([])\n\n// 过滤规则组选择项\nconst filterRuleGroupOptions = computed(() => {\n  return filterRuleGroups.value.map(item => ({\n    title: item.name,\n    value: item.name,\n  }))\n})\n\n// 所有规则组列表\nconst filterRuleGroups = ref<FilterRuleGroup[]>([])\n\n// 查询所有站点\nasync function querySites() {\n  try {\n    const data: Site[] = await api.get('site/')\n\n    // 过滤站点，只有启用的站点才显示\n    allSites.value = data.filter(item => item.is_active)\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 加载规则组\nasync function queryFilterRuleGroups() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')\n    filterRuleGroups.value = result.data?.value ?? []\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 查询用户选中的站点\nasync function querySelectedSites() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/IndexerSites')\n\n    selectedSites.value = result.data?.value ?? []\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 保存用户选中的站点\nasync function saveSelectedSites() {\n  try {\n    // 用户名密码\n    const result: { [key: string]: any } = await api.post('system/setting/IndexerSites', selectedSites.value)\n\n    if (result.success) $toast.success('搜索站点保存成功')\n    else $toast.error('搜索站点保存失败！')\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 调用API查询设置\nasync function loadSearchSetting() {\n  try {\n    const result1: { [key: string]: any } = await api.get('system/setting/SEARCH_SOURCE')\n    if (result1.success) selectedMediaSource.value = result1.data?.value?.split(',')\n    const result2: { [key: string]: any } = await api.get('system/setting/SearchFilterRuleGroups')\n    if (result2.success) selectedFilterGroup.value = result2.data?.value\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 调用API保存设置\nasync function saveSystemSetting(value: { [key: string]: any }) {\n  try {\n    const result: { [key: string]: any } = await api.post('system/env', value)\n\n    if (result.success) {\n      return true\n    }\n  } catch (error) {}\n  return false\n}\n\n// 调用API保存设置\nasync function saveSearchSetting() {\n  try {\n    const result1: { [key: string]: any } = await api.post(\n      'system/setting/SEARCH_SOURCE',\n      selectedMediaSource.value.join(','),\n    )\n\n    if (!result1 || !result1.success) {\n      $toast.error(`媒体搜索数据源保存失败：${result1?.message}！`)\n      return\n    }\n\n    const result2: { [key: string]: any } = await api.post(\n      'system/setting/SearchFilterRuleGroups',\n      selectedFilterGroup.value,\n    )\n\n    const result3 = await saveSystemSetting(SystemSettings.value.Basic)\n\n    if (result2.success && result3) {\n      $toast.success('搜索基础设置保存成功')\n    } else {\n      $toast.error('搜索基础设置保存失败！')\n    }\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 加载系统设置\nasync function loadSystemSettings() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/env')\n    if (result.success) {\n      // 将API返回的值赋值给SystemSettings\n      for (const sectionKey of Object.keys(SystemSettings.value) as Array<keyof typeof SystemSettings.value>) {\n        Object.keys(SystemSettings.value[sectionKey]).forEach((key: string) => {\n          if (result.data.hasOwnProperty(key)) (SystemSettings.value[sectionKey] as any)[key] = result.data[key]\n        })\n      }\n    }\n  } catch (error) {\n    console.log(error)\n  }\n}\n\nonMounted(() => {\n  querySites()\n  queryFilterRuleGroups()\n  querySelectedSites()\n  loadSearchSetting()\n  loadSystemSettings()\n})\n</script>\n\n<template>\n  <VRow>\n    <VCol cols=\"12\">\n      <VCard>\n        <VCardItem>\n          <VCardTitle>{{ t('setting.search.basicSettings') }}</VCardTitle>\n          <VCardSubtitle>{{ t('setting.search.basicSettingsDesc') }}</VCardSubtitle>\n        </VCardItem>\n        <VCardText>\n          <VRow>\n            <VCol cols=\"12\" md=\"6\">\n              <VSelect\n                v-model=\"selectedMediaSource\"\n                multiple\n                clearable\n                chips\n                :items=\"mediaSourcesDict\"\n                :label=\"t('setting.search.mediaSource')\"\n                :hint=\"t('setting.search.mediaSourceHint')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-database-search\"\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"6\">\n              <VAutocomplete\n                v-model=\"selectedFilterGroup\"\n                multiple\n                clearable\n                chips\n                :items=\"filterRuleGroupOptions\"\n                :label=\"t('setting.search.filterRuleGroup')\"\n                :hint=\"t('setting.search.filterRuleGroupHint')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-filter\"\n              />\n            </VCol>\n          </VRow>\n          <VRow>\n            <VCol cols=\"12\" md=\"6\">\n              <VTextField\n                v-model=\"SystemSettings.Basic.TORRENT_TAG\"\n                :label=\"t('setting.search.downloadLabel')\"\n                placeholder=\"MOVIEPILOT\"\n                :hint=\"t('setting.search.downloadLabelHint')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-tag\"\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"6\">\n              <VCombobox\n                v-model=\"SystemSettings.Basic.AUTO_DOWNLOAD_USER\"\n                :label=\"t('setting.search.downloadUser')\"\n                :placeholder=\"t('setting.search.downloadUserPlaceholder')\"\n                :hint=\"t('setting.search.downloadUserHint')\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-account\"\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"6\">\n              <VSwitch\n                v-model=\"SystemSettings.Basic.SEARCH_MULTIPLE_NAME\"\n                :label=\"t('setting.search.multipleNameSearch')\"\n                :hint=\"t('setting.search.multipleNameSearchHint')\"\n                persistent-hint\n              />\n            </VCol>\n            <VCol cols=\"12\" md=\"6\">\n              <VSwitch\n                v-model=\"SystemSettings.Basic.DOWNLOAD_SUBTITLE\"\n                :label=\"t('setting.search.downloadSubtitle')\"\n                :hint=\"t('setting.search.downloadSubtitleHint')\"\n                persistent-hint\n              />\n            </VCol>\n          </VRow>\n        </VCardText>\n        <VCardText>\n          <VForm @submit.prevent=\"() => {}\">\n            <div class=\"d-flex flex-wrap gap-4 mt-4\">\n              <VBtn type=\"submit\" @click=\"saveSearchSetting\" prepend-icon=\"mdi-content-save\">\n                {{ t('common.save') }}\n              </VBtn>\n            </div>\n          </VForm>\n        </VCardText>\n      </VCard>\n    </VCol>\n  </VRow>\n  <VRow>\n    <VCol cols=\"12\">\n      <VCard>\n        <VCardItem>\n          <VCardTitle>{{ t('setting.search.downloadSite') }}</VCardTitle>\n          <VCardSubtitle>{{ t('setting.search.downloadSiteDesc') }}</VCardSubtitle>\n        </VCardItem>\n        <VCardText>\n          <VChipGroup v-model=\"selectedSites\" column multiple>\n            <VChip\n              v-for=\"site in allSites\"\n              :key=\"site.id\"\n              :color=\"selectedSites.includes(site.id) ? 'primary' : ''\"\n              filter\n              variant=\"outlined\"\n              :value=\"site.id\"\n            >\n              {{ site.name }}\n            </VChip>\n          </VChipGroup>\n        </VCardText>\n        <VCardText>\n          <VForm @submit.prevent=\"() => {}\">\n            <div class=\"d-flex flex-wrap gap-4 mt-4\">\n              <VBtn type=\"submit\" @click=\"saveSelectedSites\" prepend-icon=\"mdi-content-save\">\n                {{ t('common.save') }}\n              </VBtn>\n            </div>\n          </VForm>\n        </VCardText>\n      </VCard>\n    </VCol>\n  </VRow>\n</template>\n"
  },
  {
    "path": "src/views/setting/AccountSettingSite.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useToast } from 'vue-toastification'\nimport api from '@/api'\nimport ProgressDialog from '@/components/dialog/ProgressDialog.vue'\nimport { useI18n } from 'vue-i18n'\n\n// 国际化\nconst { t } = useI18n()\n\n// 提示框\nconst $toast = useToast()\n\n// 进度框\nconst progressDialog = ref(false)\n\n// 站点重置\nconst isConfirmResetSites = ref(false)\n\n// 站点重置按钮文本\nconst resetSitesText = ref(t('setting.site.resetSites'))\n\n// 站点重置按钮可用状态\nconst resetSitesDisabled = ref(false)\n\nconst isPasswordVisible = ref(false)\n\n// 站点设置默认值\nconst siteSetting = ref<any>({\n  CookieCloud: {\n    COOKIECLOUD_HOST: '',\n    COOKIECLOUD_KEY: '',\n    COOKIECLOUD_PASSWORD: '',\n    COOKIECLOUD_INTERVAL: 0,\n    COOKIECLOUD_ENABLE_LOCAL: false,\n    COOKIECLOUD_BLACKLIST: '',\n  },\n  Site: {\n    SITEDATA_REFRESH_INTERVAL: 0,\n    SITE_MESSAGE: false,\n    BROWSER_EMULATION: 'playwright',\n    FLARESOLVERR_URL: '',\n  },\n})\n\n// 同步间隔下拉框\nconst CookieCloudIntervalItems = [\n  { title: t('setting.site.syncInterval.hourly'), value: 60 },\n  { title: t('setting.site.syncInterval.every6Hours'), value: 360 },\n  { title: t('setting.site.syncInterval.every12Hours'), value: 720 },\n  { title: t('setting.site.syncInterval.daily'), value: 1440 },\n  { title: t('setting.site.syncInterval.weekly'), value: 10080 },\n  { title: t('setting.site.syncInterval.monthly'), value: 43200 },\n  { title: t('setting.site.syncInterval.never'), value: 0 },\n]\n\n// 站点数据刷新间隔\nconst SiteDataRefreshIntervalItems = [\n  { title: t('setting.site.syncInterval.hourly'), value: 1 },\n  { title: t('setting.site.syncInterval.every6Hours'), value: 6 },\n  { title: t('setting.site.syncInterval.every12Hours'), value: 12 },\n  { title: t('setting.site.syncInterval.daily'), value: 24 },\n  { title: t('setting.site.syncInterval.weekly'), value: 168 },\n  { title: t('setting.site.syncInterval.never'), value: 0 },\n]\n\n// 站点访问仿真方式\nconst BrowserEmulationItems = [\n  { title: 'Playwright', value: 'playwright' },\n  { title: 'FlareSolverr', value: 'flaresolverr' },\n]\n\n// 重置站点\nasync function resetSites() {\n  try {\n    resetSitesDisabled.value = true\n    resetSitesText.value = t('setting.site.resettingSites')\n\n    const result: { [key: string]: any } = await api.get('site/reset')\n    if (result.success) $toast.success(t('setting.site.resetSuccess'))\n    else $toast.error(t('setting.site.resetFailed'))\n\n    resetSitesDisabled.value = false\n    resetSitesText.value = t('setting.site.resetSites')\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 加载站点设置\nasync function loadSiteSettings() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/env')\n    if (result.success) {\n      // 将API返回的值赋值给SystemSettings\n      for (const sectionKey of Object.keys(siteSetting.value) as Array<keyof typeof siteSetting.value>) {\n        Object.keys(siteSetting.value[sectionKey]).forEach((key: string) => {\n          if (result.data.hasOwnProperty(key)) (siteSetting.value[sectionKey] as any)[key] = result.data[key]\n        })\n      }\n    }\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 调用API保存设置\nasync function saveSiteSetting(value: { [key: string]: any }) {\n  try {\n    const result: { [key: string]: any } = await api.post('system/env', value)\n    if (result.success) {\n      $toast.success(t('setting.site.saveSuccess'))\n    } else {\n      $toast.error(t('setting.site.saveFailed'))\n    }\n  } catch (error) {\n    console.log(error)\n    $toast.error(t('setting.system.saveFailed', { message: error }))\n  }\n}\n\n// 加载数据\nonMounted(() => {\n  loadSiteSettings()\n})\n</script>\n\n<template>\n  <VRow>\n    <VCol cols=\"12\">\n      <VCard>\n        <VCardItem>\n          <VCardTitle>{{ t('setting.site.siteSync') }}</VCardTitle>\n          <VCardSubtitle>{{ t('setting.site.siteSyncDesc') }}</VCardSubtitle>\n        </VCardItem>\n        <VCardText>\n          <VForm>\n            <VRow>\n              <VCol cols=\"12\" md=\"6\">\n                <VCheckbox\n                  v-model=\"siteSetting.CookieCloud.COOKIECLOUD_ENABLE_LOCAL\"\n                  :label=\"t('setting.site.enableLocalCookieCloud')\"\n                  :hint=\"t('setting.site.enableLocalCookieCloudHint')\"\n                  persistent-hint\n                />\n              </VCol>\n            </VRow>\n            <VRow>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"siteSetting.CookieCloud.COOKIECLOUD_HOST\"\n                  :label=\"t('setting.site.serviceAddress')\"\n                  :placeholder=\"t('setting.site.serviceAddressPlaceholder')\"\n                  :disabled=\"siteSetting.CookieCloud.COOKIECLOUD_ENABLE_LOCAL\"\n                  :hint=\"t('setting.site.serviceAddressHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-server\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"siteSetting.CookieCloud.COOKIECLOUD_KEY\"\n                  :label=\"t('setting.site.userKey')\"\n                  :hint=\"t('setting.site.userKeyHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-key\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"siteSetting.CookieCloud.COOKIECLOUD_PASSWORD\"\n                  :type=\"isPasswordVisible ? 'text' : 'password'\"\n                  :append-inner-icon=\"isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'\"\n                  @click:append-inner=\"isPasswordVisible = !isPasswordVisible\"\n                  :label=\"t('setting.site.e2ePassword')\"\n                  :hint=\"t('setting.site.e2ePasswordHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-lock\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VSelect\n                  v-model=\"siteSetting.CookieCloud.COOKIECLOUD_INTERVAL\"\n                  :label=\"t('setting.site.autoSyncInterval')\"\n                  :items=\"CookieCloudIntervalItems\"\n                  :hint=\"t('setting.site.autoSyncIntervalHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-timer\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"siteSetting.CookieCloud.COOKIECLOUD_BLACKLIST\"\n                  :label=\"t('setting.site.syncBlacklist')\"\n                  :placeholder=\"t('setting.site.syncBlacklistPlaceholder')\"\n                  :hint=\"t('setting.site.syncBlacklistHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-block-helper\"\n                />\n              </VCol>\n            </VRow>\n          </VForm>\n        </VCardText>\n        <VCardText>\n          <VForm @submit.prevent=\"() => {}\">\n            <div class=\"d-flex flex-wrap gap-4 mt-4\">\n              <VBtn type=\"submit\" @click=\"saveSiteSetting(siteSetting.CookieCloud)\" prepend-icon=\"mdi-content-save\">\n                {{ t('common.save') }}\n              </VBtn>\n            </div>\n          </VForm>\n        </VCardText>\n      </VCard>\n    </VCol>\n  </VRow>\n  <VRow>\n    <VCol cols=\"12\">\n      <VCard :title=\"t('setting.site.siteOptions')\">\n        <VCardText>\n          <VForm>\n            <VRow>\n              <VCol cols=\"12\" md=\"6\">\n                <VSelect\n                  v-model=\"siteSetting.Site.SITEDATA_REFRESH_INTERVAL\"\n                  :label=\"t('setting.site.siteDataRefreshInterval')\"\n                  :items=\"SiteDataRefreshIntervalItems\"\n                  :hint=\"t('setting.site.siteDataRefreshIntervalHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-refresh\"\n                />\n              </VCol>\n\n              <VCol cols=\"12\" md=\"6\">\n                <VSelect\n                  v-model=\"siteSetting.Site.BROWSER_EMULATION\"\n                  :items=\"BrowserEmulationItems\"\n                  :label=\"t('setting.site.browserEmulation')\"\n                  :hint=\"t('setting.site.browserEmulationHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-web\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\" v-if=\"siteSetting.Site.BROWSER_EMULATION == 'flaresolverr'\">\n                <VTextField\n                  v-model=\"siteSetting.Site.FLARESOLVERR_URL\"\n                  :label=\"t('setting.site.flaresolverrUrl')\"\n                  :placeholder=\"'http://127.0.0.1:8191'\"\n                  :hint=\"t('setting.site.flaresolverrUrlHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-server\"\n                />\n              </VCol>\n            </VRow>\n            <VRow>\n              <VCol cols=\"12\" md=\"6\">\n                <VSwitch\n                  v-model=\"siteSetting.Site.SITE_MESSAGE\"\n                  :label=\"t('setting.site.readSiteMessage')\"\n                  :hint=\"t('setting.site.readSiteMessageHint')\"\n                  persistent-hint\n                />\n              </VCol>\n            </VRow>\n          </VForm>\n        </VCardText>\n        <VCardText>\n          <VForm @submit.prevent=\"() => {}\">\n            <div class=\"d-flex flex-wrap gap-4 mt-4\">\n              <VBtn type=\"submit\" @click=\"saveSiteSetting(siteSetting.Site)\" prepend-icon=\"mdi-content-save\">\n                {{ t('common.save') }}\n              </VBtn>\n            </div>\n          </VForm>\n        </VCardText>\n      </VCard>\n    </VCol>\n  </VRow>\n  <VRow>\n    <VCol cols=\"12\">\n      <VCard :title=\"t('setting.site.siteReset')\">\n        <VCardText>\n          <div>\n            <VCheckbox\n              v-model=\"isConfirmResetSites\"\n              :label=\"t('setting.site.confirmReset')\"\n              :hint=\"t('setting.site.confirmResetHint')\"\n              persistent-hint\n            />\n          </div>\n\n          <VBtn :disabled=\"!isConfirmResetSites || resetSitesDisabled\" color=\"error\" class=\"mt-3\" @click=\"resetSites\">\n            {{ resetSitesText }}\n          </VBtn>\n        </VCardText>\n      </VCard>\n    </VCol>\n  </VRow>\n  <!-- 进度框 -->\n  <ProgressDialog\n    v-if=\"progressDialog\"\n    v-model=\"progressDialog\"\n    :text=\"t('setting.system.reloading')\"\n    :indeterminate=\"true\"\n  />\n</template>\n"
  },
  {
    "path": "src/views/setting/AccountSettingSubscribe.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useToast } from 'vue-toastification'\nimport api from '@/api'\nimport type { FilterRuleGroup, Site } from '@/api/types'\nimport ProgressDialog from '@/components/dialog/ProgressDialog.vue'\nimport { useI18n } from 'vue-i18n'\n\n// 国际化\nconst { t } = useI18n()\n\n// 提示框\nconst $toast = useToast()\n\n// 进度框\nconst progressDialog = ref(false)\n\n// 所有站点\nconst allSites = ref<Site[]>([])\n\n// 选中订阅站点\nconst selectedRssSites = ref<number[]>([])\n\n// 选中的订阅规则组\nconst selectedFilterRuleGroup = ref([])\n\n// 选中的洗版规则组\nconst selectedBestVersionRuleGroup = ref([])\n\n// 订阅模式选择项\nconst subscribeModeItems = [\n  { title: t('setting.subscribe.modes.auto'), value: 'spider' },\n  { title: t('setting.subscribe.modes.rss'), value: 'rss' },\n]\n\n// 所有规则组列表\nconst filterRuleGroups = ref<FilterRuleGroup[]>([])\n\n// 过滤规则组选择项\nconst filterRuleGroupOptions = computed(() => {\n  return filterRuleGroups.value.map(item => ({\n    title: item.name,\n    value: item.name,\n  }))\n})\n\n// RSS运行周期选择项\nconst rssIntervalItems = [\n  { title: t('setting.subscribe.intervals.min5'), value: 5 },\n  { title: t('setting.subscribe.intervals.min10'), value: 10 },\n  { title: t('setting.subscribe.intervals.min20'), value: 20 },\n  { title: t('setting.subscribe.intervals.min30'), value: 30 },\n  { title: t('setting.subscribe.intervals.hour1'), value: 60 },\n  { title: t('setting.subscribe.intervals.hour12'), value: 720 },\n  { title: t('setting.subscribe.intervals.day1'), value: 1440 },\n]\n\n// 订阅搜索时间间隔选择项（小时）\nconst subscribeSearchIntervalItems = [\n  { title: t('setting.subscribe.intervals.day1'), value: 24 },\n  { title: t('setting.subscribe.intervals.day3'), value: 72 },\n  { title: t('setting.subscribe.intervals.week1'), value: 168 },\n]\n\n// 系统设置项\nconst SystemSettings = ref<any>({\n  // 基础设置\n  Basic: {\n    SUBSCRIBE_MODE: 'auto',\n    SUBSCRIBE_SEARCH: false,\n    SUBSCRIBE_SEARCH_INTERVAL: 24,\n    SUBSCRIBE_RSS_INTERVAL: 30,\n    LOCAL_EXISTS_SEARCH: false,\n  },\n})\n\n// 查询所有站点\nasync function querySites() {\n  try {\n    const data: Site[] = await api.get('site/')\n\n    // 过滤站点，只有启用的站点才显示\n    allSites.value = data.filter(item => item.is_active)\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 加载规则组\nasync function queryFilterRuleGroups() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')\n    filterRuleGroups.value = result.data?.value ?? []\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 查询用户选中的订阅站点\nasync function querySelectedRssSites() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/RssSites')\n\n    selectedRssSites.value = result.data?.value ?? []\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 保存用户选中的订阅站点\nasync function saveSelectedRssSites() {\n  try {\n    const result1: { [key: string]: any } = await api.post('system/setting/RssSites', selectedRssSites.value)\n\n    if (result1.success) $toast.success(t('setting.subscribe.saveSuccess'))\n    else $toast.error(t('setting.subscribe.saveFailed'))\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 加载系统设置\nasync function loadSystemSettings() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/env')\n    if (result.success) {\n      // 将API返回的值赋值给SystemSettings\n      for (const sectionKey of Object.keys(SystemSettings.value) as Array<keyof typeof SystemSettings.value>) {\n        Object.keys(SystemSettings.value[sectionKey]).forEach((key: string) => {\n          if (result.data.hasOwnProperty(key)) (SystemSettings.value[sectionKey] as any)[key] = result.data[key]\n        })\n      }\n    }\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 调用API保存设置\nasync function saveSystemSetting(value: { [key: string]: any }) {\n  try {\n    const result: { [key: string]: any } = await api.post('system/env', value)\n\n    if (result.success) {\n      return true\n    }\n  } catch (error) {}\n  return false\n}\n\n// 查询订阅设置\nasync function querySubscribeRules() {\n  try {\n    // 查询订阅规则组\n    const result1: { [key: string]: any } = await api.get('system/setting/SubscribeFilterRuleGroups')\n    if (result1.success) selectedFilterRuleGroup.value = result1.data?.value\n    // 查询洗版规则组\n    const result2: { [key: string]: any } = await api.get('system/setting/BestVersionFilterRuleGroups')\n    if (result2.success) selectedBestVersionRuleGroup.value = result2.data?.value\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 保存订阅设置\nasync function saveSubscribeSetting() {\n  try {\n    const result1: { [key: string]: any } = await api.post(\n      'system/setting/SubscribeFilterRuleGroups',\n      selectedFilterRuleGroup.value,\n    )\n\n    const result2: { [key: string]: any } = await api.post(\n      'system/setting/BestVersionFilterRuleGroups',\n      selectedBestVersionRuleGroup.value,\n    )\n\n    const result3 = await saveSystemSetting(SystemSettings.value.Basic)\n\n    if (result1.success && result2.success && result3) {\n      $toast.success(t('setting.subscribe.settingsSaveSuccess'))\n    } else $toast.error(t('setting.subscribe.settingsSaveFailed'))\n  } catch (error) {\n    console.log(error)\n  }\n}\n\nonMounted(() => {\n  querySites()\n  queryFilterRuleGroups()\n  querySelectedRssSites()\n  querySubscribeRules()\n  loadSystemSettings()\n})\n</script>\n\n<template>\n  <VRow>\n    <VCol cols=\"12\">\n      <VCard>\n        <VCardItem>\n          <VCardTitle>{{ t('setting.subscribe.basicSettings') }}</VCardTitle>\n          <VCardSubtitle>{{ t('setting.subscribe.basicSettingsDesc') }}</VCardSubtitle>\n        </VCardItem>\n        <VCardText>\n          <VForm>\n            <VRow>\n              <VCol cols=\"12\" md=\"6\">\n                <VSelect\n                  v-model=\"SystemSettings.Basic.SUBSCRIBE_MODE\"\n                  :items=\"subscribeModeItems\"\n                  :label=\"t('setting.subscribe.mode')\"\n                  :hint=\"t('setting.subscribe.modeHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-cog\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VSelect\n                  v-model=\"SystemSettings.Basic.SUBSCRIBE_RSS_INTERVAL\"\n                  :items=\"rssIntervalItems\"\n                  :label=\"t('setting.subscribe.rssInterval')\"\n                  :hint=\"t('setting.subscribe.rssIntervalHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-timer\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VAutocomplete\n                  v-model=\"selectedFilterRuleGroup\"\n                  :items=\"filterRuleGroupOptions\"\n                  chips\n                  multiple\n                  clearable\n                  :label=\"t('setting.subscribe.filterRuleGroup')\"\n                  :hint=\"t('setting.subscribe.filterRuleGroupHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-filter\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VAutocomplete\n                  v-model=\"selectedBestVersionRuleGroup\"\n                  :items=\"filterRuleGroupOptions\"\n                  chips\n                  multiple\n                  clearable\n                  :label=\"t('setting.subscribe.bestVersionRuleGroup')\"\n                  :hint=\"t('setting.subscribe.bestVersionRuleGroupHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-star\"\n                />\n              </VCol>\n            </VRow>\n            <VRow>\n              <VCol cols=\"12\" md=\"6\">\n                <VSwitch\n                  v-model=\"SystemSettings.Basic.SUBSCRIBE_SEARCH\"\n                  :label=\"t('setting.subscribe.timedSearch')\"\n                  :hint=\"t('setting.subscribe.timedSearchHint')\"\n                  persistent-hint\n                />\n              </VCol>\n              <VCol v-if=\"SystemSettings.Basic.SUBSCRIBE_SEARCH\" cols=\"12\" md=\"6\">\n                <VSelect\n                  v-model=\"SystemSettings.Basic.SUBSCRIBE_SEARCH_INTERVAL\"\n                  :items=\"subscribeSearchIntervalItems\"\n                  :label=\"t('setting.subscribe.searchInterval')\"\n                  :hint=\"t('setting.subscribe.searchIntervalHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-timer\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VSwitch\n                  v-model=\"SystemSettings.Basic.LOCAL_EXISTS_SEARCH\"\n                  :label=\"t('setting.subscribe.checkLocalMedia')\"\n                  :hint=\"t('setting.subscribe.checkLocalMediaHint')\"\n                  persistent-hint\n                />\n              </VCol>\n            </VRow>\n          </VForm>\n        </VCardText>\n        <VCardText>\n          <VForm @submit.prevent=\"() => {}\">\n            <div class=\"d-flex flex-wrap gap-4 mt-4\">\n              <VBtn type=\"submit\" @click=\"saveSubscribeSetting\" prepend-icon=\"mdi-content-save\">\n                {{ t('common.save') }}\n              </VBtn>\n            </div>\n          </VForm>\n        </VCardText>\n      </VCard>\n    </VCol>\n  </VRow>\n  <VRow>\n    <VCol cols=\"12\">\n      <VCard>\n        <VCardItem>\n          <VCardTitle>{{ t('setting.subscribe.subscribeSites') }}</VCardTitle>\n          <VCardSubtitle>{{ t('setting.subscribe.subscribeSitesDesc') }}</VCardSubtitle>\n        </VCardItem>\n        <VCardText>\n          <VChipGroup v-model=\"selectedRssSites\" column multiple>\n            <VChip\n              v-for=\"site in allSites\"\n              :key=\"site.id\"\n              :color=\"selectedRssSites.includes(site.id) ? 'primary' : ''\"\n              filter\n              variant=\"outlined\"\n              :value=\"site.id\"\n            >\n              {{ site.name }}\n            </VChip>\n          </VChipGroup>\n        </VCardText>\n        <VCardText>\n          <VForm @submit.prevent=\"() => {}\">\n            <div class=\"d-flex flex-wrap gap-4 mt-4\">\n              <VBtn type=\"submit\" @click=\"saveSelectedRssSites\" prepend-icon=\"mdi-content-save\">\n                {{ t('common.save') }}\n              </VBtn>\n            </div>\n          </VForm>\n        </VCardText>\n      </VCard>\n    </VCol>\n  </VRow>\n  <!-- 进度框 -->\n  <ProgressDialog v-if=\"progressDialog\" v-model=\"progressDialog\" :text=\"t('setting.system.reloading')\" />\n</template>\n"
  },
  {
    "path": "src/views/setting/AccountSettingSystem.vue",
    "content": "<!-- eslint-disable sonarjs/no-duplicate-string -->\n<script lang=\"ts\" setup>\nimport { useToast } from 'vue-toastification'\nimport { VRow } from 'vuetify/lib/components/index.mjs'\nimport draggable from 'vuedraggable'\nimport api from '@/api'\nimport { DownloaderConf, MediaServerConf } from '@/api/types'\nimport DownloaderCard from '@/components/cards/DownloaderCard.vue'\nimport MediaServerCard from '@/components/cards/MediaServerCard.vue'\nimport { copyToClipboard } from '@/@core/utils/navigator'\nimport ProgressDialog from '@/components/dialog/ProgressDialog.vue'\nimport { useI18n } from 'vue-i18n'\nimport { downloaderOptions, mediaServerOptions } from '@/api/constants'\nimport { useDisplay, useTheme } from 'vuetify'\n\nconst display = useDisplay()\nconst theme = useTheme()\n\nconst isTransparentTheme = computed(() => theme.name.value === 'transparent')\n\n// 国际化\nconst { t } = useI18n()\n\n// 系统设置项\nconst SystemSettings = ref<any>({\n  // 基础设置\n  Basic: {\n    DB_TYPE: 'sqlite',\n    APP_DOMAIN: null,\n    API_TOKEN: null,\n    WALLPAPER: 'tmdb',\n    MEDIASERVER_SYNC_INTERVAL: null,\n    RECOGNIZE_SOURCE: 'themoviedb',\n    GITHUB_TOKEN: null,\n    OCR_HOST: null,\n    CUSTOMIZE_WALLPAPER_API_URL: null,\n    AI_AGENT_ENABLE: false,\n    AI_AGENT_GLOBAL: false,\n    AI_AGENT_VERBOSE: false,\n    AI_AGENT_JOB_INTERVAL: 24,\n    LLM_PROVIDER: 'deepseek',\n    LLM_MODEL: 'deepseek-chat',\n    LLM_THINKING_LEVEL: 'off',\n    LLM_SUPPORT_IMAGE_INPUT: false,\n    LLM_API_KEY: null,\n    LLM_BASE_URL: 'https://api.deepseek.com',\n    AI_AGENT_RETRY_TRANSFER: false,\n    AI_RECOMMEND_ENABLED: false,\n    AI_RECOMMEND_USER_PREFERENCE: null,\n    AI_RECOMMEND_MAX_ITEMS: 50,\n    LLM_MAX_CONTEXT_TOKENS: 64,\n  },\n  // 高级系统设置\n  Advanced: {\n    // 全局\n    AUXILIARY_AUTH_ENABLE: false,\n    GLOBAL_IMAGE_CACHE: false,\n    SUBSCRIBE_STATISTIC_SHARE: true,\n    PLUGIN_STATISTIC_SHARE: true,\n    WORKFLOW_STATISTIC_SHARE: true,\n    BIG_MEMORY_MODE: false,\n    DB_WAL_ENABLE: false,\n    AUTO_UPDATE_RESOURCE: true,\n    MOVIEPILOT_AUTO_UPDATE: false,\n    // 媒体\n    RECOGNIZE_PLUGIN_FIRST: false,\n    TMDB_API_DOMAIN: null,\n    TMDB_IMAGE_DOMAIN: null,\n    TMDB_LOCALE: null,\n    META_CACHE_EXPIRE: 0,\n    SCRAP_FOLLOW_TMDB: true,\n    FANART_ENABLE: false,\n    FANART_LANG: 'zh,en',\n    TMDB_SCRAP_ORIGINAL_IMAGE: null,\n    // 网络\n    PROXY_HOST: null,\n    GITHUB_PROXY: null,\n    PIP_PROXY: null,\n    DOH_ENABLE: false,\n    DOH_RESOLVERS: null,\n    DOH_DOMAINS: null,\n    SECURITY_IMAGE_DOMAINS: [],\n    // 日志\n    DEBUG: false,\n    LOG_LEVEL: 'INFO',\n    LOG_MAX_FILE_SIZE: '5',\n    LOG_BACKUP_COUNT: '3',\n    LOG_FILE_FORMAT: '【%(levelname)s】%(asctime)s - %(message)s',\n    // 实验室\n    PLUGIN_AUTO_RELOAD: false,\n    PLUGIN_LOCAL_REPO_PATHS: '',\n    ENCODING_DETECTION_PERFORMANCE_MODE: true,\n    TRANSFER_THREADS: 1,\n  },\n})\n\n// 刮削配置\nconst scrapingConfig = [\n  {\n    section: 'movie',\n    items: [\n      { key: 'movie_nfo', label: 'setting.system.movieNfo' },\n      { key: 'movie_poster', label: 'setting.system.moviePoster' },\n      { key: 'movie_backdrop', label: 'setting.system.movieBackdrop' },\n      { key: 'movie_logo', label: 'setting.system.movieLogo' },\n      { key: 'movie_disc', label: 'setting.system.movieDisc' },\n      { key: 'movie_banner', label: 'setting.system.movieBanner' },\n      { key: 'movie_thumb', label: 'setting.system.movieThumb' },\n    ],\n  },\n  {\n    section: 'tv',\n    items: [\n      { key: 'tv_nfo', label: 'setting.system.tvNfo' },\n      { key: 'tv_poster', label: 'setting.system.tvPoster' },\n      { key: 'tv_backdrop', label: 'setting.system.tvBackdrop' },\n      { key: 'tv_banner', label: 'setting.system.tvBanner' },\n      { key: 'tv_logo', label: 'setting.system.tvLogo' },\n      { key: 'tv_thumb', label: 'setting.system.tvThumb' },\n    ],\n  },\n  {\n    section: 'season',\n    items: [\n      { key: 'season_nfo', label: 'setting.system.seasonNfo' },\n      { key: 'season_poster', label: 'setting.system.seasonPoster' },\n      { key: 'season_banner', label: 'setting.system.seasonBanner' },\n      { key: 'season_thumb', label: 'setting.system.seasonThumb' },\n    ],\n  },\n  {\n    section: 'episode',\n    items: [\n      { key: 'episode_nfo', label: 'setting.system.episodeNfo' },\n      { key: 'episode_thumb', label: 'setting.system.episodeThumb' },\n    ],\n  },\n]\n\n// 刮削策略设置\nconst ScrapingPolicies = ref<Record<string, 'skip' | 'missingOnly' | 'overwrite'>>(\n  Object.fromEntries(scrapingConfig.flatMap(section => section.items.map(item => [item.key, 'missingOnly']))),\n)\n\n// 是否发送请求的总开关\nconst isRequest = ref(true)\n\n// 选中的媒体服务器\nconst mediaServers = ref<MediaServerConf[]>([])\n\n// 下载器\nconst downloaders = ref<DownloaderConf[]>([])\n\n// 提示框\nconst $toast = useToast()\n\n// 进度框\nconst progressDialog = ref(false)\n\n// 高级设置对话框\nconst advancedDialog = ref(false)\n\n// LLM 模型列表\nconst llmModels = ref<string[]>([])\nconst loadingModels = ref(false)\nconst savingBasic = ref(false)\nconst testingLlm = ref(false)\n\ntype LlmSettingsSnapshot = {\n  AI_AGENT_ENABLE: boolean\n  LLM_PROVIDER: string\n  LLM_MODEL: string\n  LLM_THINKING_LEVEL: string\n  LLM_API_KEY: string\n  LLM_BASE_URL: string\n}\n\nlet llmTestRequestId = 0\nlet llmTestAbortController: AbortController | null = null\n\nfunction buildLlmSnapshot(): LlmSettingsSnapshot {\n  return {\n    AI_AGENT_ENABLE: Boolean(SystemSettings.value.Basic.AI_AGENT_ENABLE),\n    LLM_PROVIDER: String(SystemSettings.value.Basic.LLM_PROVIDER ?? ''),\n    LLM_MODEL: String(SystemSettings.value.Basic.LLM_MODEL ?? ''),\n    LLM_THINKING_LEVEL: String(SystemSettings.value.Basic.LLM_THINKING_LEVEL ?? 'off'),\n    LLM_API_KEY: String(SystemSettings.value.Basic.LLM_API_KEY ?? ''),\n    LLM_BASE_URL: String(SystemSettings.value.Basic.LLM_BASE_URL ?? ''),\n  }\n}\n\nfunction buildLlmSnapshotKey(snapshot: LlmSettingsSnapshot) {\n  return JSON.stringify(snapshot)\n}\n\nfunction buildLlmTestPayload(snapshot: LlmSettingsSnapshot) {\n  return {\n    enabled: snapshot.AI_AGENT_ENABLE,\n    provider: snapshot.LLM_PROVIDER.trim(),\n    model: snapshot.LLM_MODEL.trim(),\n    thinking_level: snapshot.LLM_THINKING_LEVEL.trim(),\n    api_key: snapshot.LLM_API_KEY.trim(),\n    base_url: snapshot.LLM_BASE_URL.trim(),\n  }\n}\n\nfunction normalizeThinkingLevelValue(value?: unknown) {\n  const normalized = String(value ?? '')\n    .trim()\n    .toLowerCase()\n  if (!normalized) return ''\n\n  const aliasMap: Record<string, string> = {\n    none: 'off',\n    disabled: 'off',\n    disable: 'off',\n    enabled: 'auto',\n    enable: 'auto',\n    default: 'auto',\n    dynamic: 'auto',\n  }\n\n  return aliasMap[normalized] || normalized\n}\n\nfunction resolveThinkingLevelValue(data?: Record<string, any>) {\n  const explicit = normalizeThinkingLevelValue(data?.LLM_THINKING_LEVEL)\n  if (explicit) return explicit\n\n  const legacyEffort = normalizeThinkingLevelValue(data?.LLM_REASONING_EFFORT)\n  if (data?.LLM_DISABLE_THINKING === true) return 'off'\n  if (data?.LLM_DISABLE_THINKING === false) return legacyEffort || 'auto'\n  return legacyEffort || 'off'\n}\n\nfunction showLlmTestFailedToast(message?: string) {\n  const normalizedMessage = String(message ?? '').trim()\n  if (normalizedMessage) {\n    $toast.error(t('setting.system.llmTestFailedToastWithMessage', { message: normalizedMessage }))\n    return\n  }\n  $toast.error(t('setting.system.llmTestFailedToast'))\n}\n\nfunction invalidateLlmTestState() {\n  llmTestRequestId += 1\n  if (llmTestAbortController) {\n    llmTestAbortController.abort()\n    llmTestAbortController = null\n  }\n  testingLlm.value = false\n}\n\nconst currentLlmSnapshot = computed(() => buildLlmSnapshot())\nconst currentLlmSnapshotKey = computed(() => buildLlmSnapshotKey(currentLlmSnapshot.value))\n\nconst canTestLlm = computed(() => {\n  const snapshot = currentLlmSnapshot.value\n  return (\n    snapshot.AI_AGENT_ENABLE &&\n    Boolean(snapshot.LLM_PROVIDER.trim()) &&\n    Boolean(snapshot.LLM_API_KEY.trim()) &&\n    Boolean(snapshot.LLM_MODEL.trim()) &&\n    !savingBasic.value &&\n    !testingLlm.value\n  )\n})\n\nconst thinkingLevelItems = computed(() => [\n  { title: t('setting.system.llmThinkingLevelOff'), value: 'off' },\n  { title: t('setting.system.llmThinkingLevelAuto'), value: 'auto' },\n  { title: t('setting.system.llmThinkingLevelMinimal'), value: 'minimal' },\n  { title: t('setting.system.llmThinkingLevelLow'), value: 'low' },\n  { title: t('setting.system.llmThinkingLevelMedium'), value: 'medium' },\n  { title: t('setting.system.llmThinkingLevelHigh'), value: 'high' },\n  { title: t('setting.system.llmThinkingLevelMax'), value: 'max' },\n  { title: t('setting.system.llmThinkingLevelXhigh'), value: 'xhigh' },\n])\n\nconst activeTab = ref('system')\n\n// 元数据语言\nconst tmdbLanguageItems = [\n  { title: t('setting.system.tmdbLanguage.zhCN'), value: 'zh' },\n  { title: t('setting.system.tmdbLanguage.zhTW'), value: 'zh-TW' },\n  { title: t('setting.system.tmdbLanguage.en'), value: 'en' },\n]\n\n// Fanart语言选项\nconst fanartLanguageItems = [\n  { title: t('setting.system.fanartLanguage.zh'), value: 'zh' },\n  { title: t('setting.system.fanartLanguage.en'), value: 'en' },\n  { title: t('setting.system.fanartLanguage.ja'), value: 'ja' },\n  { title: t('setting.system.fanartLanguage.ko'), value: 'ko' },\n  { title: t('setting.system.fanartLanguage.de'), value: 'de' },\n  { title: t('setting.system.fanartLanguage.fr'), value: 'fr' },\n  { title: t('setting.system.fanartLanguage.es'), value: 'es' },\n  { title: t('setting.system.fanartLanguage.it'), value: 'it' },\n  { title: t('setting.system.fanartLanguage.pt'), value: 'pt' },\n  { title: t('setting.system.fanartLanguage.ru'), value: 'ru' },\n]\n\n// 日志等级\nconst logLevelItems = [\n  { title: t('setting.system.logLevelItems.debug'), value: 'DEBUG' },\n  { title: t('setting.system.logLevelItems.info'), value: 'INFO' },\n  { title: t('setting.system.logLevelItems.warning'), value: 'WARNING' },\n  { title: t('setting.system.logLevelItems.error'), value: 'ERROR' },\n  { title: t('setting.system.logLevelItems.critical'), value: 'CRITICAL' },\n]\n\n// 安全域名添加变量\nconst newSecurityDomain = ref('')\n\n// 加载LLM模型列表\nasync function loadLlmModels() {\n  loadingModels.value = true\n  try {\n    const result: { [key: string]: any } = await api.get('system/llm-models', {\n      params: {\n        provider: SystemSettings.value.Basic.LLM_PROVIDER,\n        api_key: SystemSettings.value.Basic.LLM_API_KEY,\n        base_url: SystemSettings.value.Basic.LLM_BASE_URL,\n      },\n    })\n\n    if (result.success) {\n      llmModels.value = result.data\n      if (llmModels.value.length > 0) SystemSettings.value.Basic.LLM_MODEL = llmModels.value[0]\n    } else {\n      $toast.error(result.message)\n    }\n  } catch (error) {\n    console.log(error)\n  }\n  loadingModels.value = false\n}\n\n// 添加安全域名\nfunction addSecurityDomain() {\n  if (\n    newSecurityDomain.value &&\n    !SystemSettings.value.Advanced.SECURITY_IMAGE_DOMAINS.includes(newSecurityDomain.value)\n  ) {\n    SystemSettings.value.Advanced.SECURITY_IMAGE_DOMAINS.push(newSecurityDomain.value)\n    newSecurityDomain.value = ''\n  }\n}\n\n// 调用API查询下载器设置\nasync function loadDownloaderSetting() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/Downloaders')\n    downloaders.value = result.data?.value ?? []\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 调用API保存下载器设置\nasync function saveDownloaderSetting() {\n  try {\n    // 提取启用的下载器\n    const enabledDownloaders = downloaders.value.filter(item => item.enabled)\n    // 有启动的下载器时\n    if (enabledDownloaders.length > 0) {\n      downloaders.value = handleDefaultDownloaders(enabledDownloaders, downloaders.value)\n    }\n    const result: { [key: string]: any } = await api.post('system/setting/Downloaders', downloaders.value)\n    if (result.success) $toast.success(t('setting.system.downloaderSaveSuccess'))\n    else $toast.error(t('setting.system.downloaderSaveFailed'))\n\n    await loadDownloaderSetting()\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 处理默认下载器状态\nfunction handleDefaultDownloaders(enabledDownloaders: any[], downloaders: any[]) {\n  const enabledDefaultDownloader = enabledDownloaders.find(item => item.default)\n  if (enabledDownloaders.length > 0 && !enabledDefaultDownloader) {\n    downloaders = downloaders.map(item => {\n      if (item === enabledDownloaders[0]) {\n        $toast.info(t('setting.system.defaultDownloaderNotice', { name: item.name }))\n        return { ...item, default: true }\n      }\n      // 清除其他下载器的默认下载器状态\n      return { ...item, default: false }\n    })\n  }\n  return downloaders\n}\n\n// 调用API查询媒体服务器设置\nasync function loadMediaServerSetting() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/MediaServers')\n    mediaServers.value = result.data?.value ?? []\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 调用API保存媒体服务器设置\nasync function saveMediaServerSetting() {\n  try {\n    const result: { [key: string]: any } = await api.post('system/setting/MediaServers', mediaServers.value)\n    if (result.success) $toast.success(t('setting.system.mediaServerSaveSuccess'))\n    else $toast.error(t('setting.system.mediaServerSaveFailed'))\n\n    await loadMediaServerSetting()\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 加载系统设置\nasync function loadSystemSettings() {\n  invalidateLlmTestState()\n  try {\n    const result: { [key: string]: any } = await api.get('system/env')\n    if (result.success) {\n      // 将API返回的值赋值给SystemSettings\n      for (const sectionKey of Object.keys(SystemSettings.value) as Array<keyof typeof SystemSettings.value>) {\n        Object.keys(SystemSettings.value[sectionKey]).forEach((key: string) => {\n          if (result.data.hasOwnProperty(key)) (SystemSettings.value[sectionKey] as any)[key] = result.data[key]\n        })\n      }\n      SystemSettings.value.Basic.LLM_THINKING_LEVEL = resolveThinkingLevelValue(result.data)\n    }\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 调用API保存设置\nasync function saveSystemSetting(value: { [key: string]: any }) {\n  try {\n    const result: { [key: string]: any } = await api.post('system/env', value)\n    if (result.success) {\n      return true\n    } else {\n      $toast.error(t('setting.system.saveFailed', { message: result?.message }))\n      return false\n    }\n  } catch (error) {\n    console.log(error)\n  }\n  return false\n}\n\n// 保存基础设置\nasync function saveBasicSettings() {\n  savingBasic.value = true\n  try {\n    if (await saveSystemSetting(SystemSettings.value.Basic)) {\n      $toast.success(t('setting.system.basicSaveSuccess'))\n    }\n  } finally {\n    savingBasic.value = false\n  }\n}\n\nasync function testLlmConnection() {\n  if (!canTestLlm.value) return\n\n  const snapshot = buildLlmSnapshot()\n  const snapshotKey = buildLlmSnapshotKey(snapshot)\n  const payload = buildLlmTestPayload(snapshot)\n  const requestId = ++llmTestRequestId\n  if (llmTestAbortController) llmTestAbortController.abort()\n  const abortController = new AbortController()\n  llmTestAbortController = abortController\n\n  testingLlm.value = true\n  try {\n    const result: { [key: string]: any } = await api.post('system/llm-test', payload, {\n      signal: abortController.signal,\n    })\n    if (\n      requestId !== llmTestRequestId ||\n      abortController.signal.aborted ||\n      currentLlmSnapshotKey.value !== snapshotKey\n    ) {\n      return\n    }\n\n    if (result?.success) $toast.success(t('setting.system.llmTestSuccessToast'))\n    else showLlmTestFailedToast(result?.message)\n  } catch (error) {\n    if (\n      requestId !== llmTestRequestId ||\n      abortController.signal.aborted ||\n      currentLlmSnapshotKey.value !== snapshotKey\n    ) {\n      return\n    }\n    showLlmTestFailedToast(error instanceof Error ? error.message : String(error))\n    console.log(error)\n  } finally {\n    if (requestId !== llmTestRequestId) return\n    if (llmTestAbortController === abortController) llmTestAbortController = null\n    testingLlm.value = false\n  }\n}\n\n// 保存高级设置\nasync function saveAdvancedSettings() {\n  cleanEmptyFields(SystemSettings.value.Advanced, ['LOG_FILE_FORMAT'])\n\n  // 同时保存高级设置和刮削开关设置\n  const advancedResult = await saveSystemSetting(SystemSettings.value.Advanced)\n  const scrapingResult = await saveScrapingSwitchs()\n\n  if (advancedResult && scrapingResult) {\n    advancedDialog.value = false\n    $toast.success(t('setting.system.advancedSaveSuccess'))\n  }\n}\n\n// 当字段为空时，将其设置为 null 提交，以便后端恢复为默认值\nfunction cleanEmptyFields(settings: any, fields: string[]) {\n  fields.forEach(field => {\n    if (settings[field]?.trim?.() === '') {\n      settings[field] = null\n    }\n  })\n}\n\n// 快捷复制到剪贴板\nasync function copyValue(value: string) {\n  try {\n    let success\n    success = copyToClipboard(value)\n    if (await success) $toast.success(t('setting.system.copySuccess'))\n    else $toast.error(t('setting.system.copyFailed'))\n  } catch (error) {\n    $toast.error(t('setting.system.copyError'))\n    console.log(error)\n  }\n}\n\n// 登录首页壁纸来源\nconst wallpaperItems = [\n  { title: t('setting.system.wallpaperItems.tmdb'), value: 'tmdb' },\n  { title: t('setting.system.wallpaperItems.bing'), value: 'bing' },\n  { title: t('setting.system.wallpaperItems.mediaserver'), value: 'mediaserver' },\n  { title: t('setting.system.wallpaperItems.customize'), value: 'customize' },\n  { title: t('setting.system.wallpaperItems.none'), value: '' },\n]\n\n// 预设部分Github加速站\nconst githubMirrorsItems: string[] = [\n  // str: 'https://mirror.ghproxy.com/', // GitHub Proxy\n  // str: 'https://ghp.ci/', // GitHub Proxy 子站\n]\n\n// 预设部分PIP镜像站\nconst pipMirrorsItems = [\n  'https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple', // 清华大学\n  'https://pypi.mirrors.ustc.edu.cn/simple', // 中国科技大学\n  'https://mirrors.pku.edu.cn/pypi/web/simple', // 北京大学\n  'https://mirrors.aliyun.com/pypi/simple', // 阿里云\n  'https://mirrors.cloud.tencent.com/pypi/simple', // 腾讯云\n  'https://mirrors.163.com/pypi/simple', // 网易云\n  'https://pypi.doubanio.com/simple', // 豆瓣\n  'https://mirrors.hust.edu.cn/pypi/web/simple', // 华中理工大学\n  'https://mirrors.bfsu.edu.cn/pypi/web/simple', // 北京外国语大学\n]\n\n// Github加速代理显示处理\nconst githubProxyDisplay = computed({\n  get: () => {\n    return SystemSettings.value.Advanced.GITHUB_PROXY || null\n  },\n  set: val => {\n    SystemSettings.value.Advanced.GITHUB_PROXY = val === null ? '' : val\n  },\n})\n\n// PIP加速代理显示处理\nconst pipProxyDisplay = computed({\n  get: () => {\n    return SystemSettings.value.Advanced.PIP_PROXY || null\n  },\n  set: val => {\n    SystemSettings.value.Advanced.PIP_PROXY = val === null ? '' : val\n  },\n})\n\n// 创建随机字符串\nfunction createRandomString() {\n  const charset = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'\n  const array = new Uint8Array(32)\n  window.crypto.getRandomValues(array)\n  SystemSettings.value.Basic.API_TOKEN = Array.from(array, byte => charset[byte % charset.length]).join('')\n}\n\n// 添加下载器\nfunction addDownloader(downloader: string) {\n  let name = `下载器${downloaders.value.length + 1}`\n  while (downloaders.value.some(item => item.name === name)) {\n    name = `下载器${parseInt(name.split('下载器')[1]) + 1}`\n  }\n  downloaders.value.push({\n    name: name,\n    type: downloader,\n    default: false,\n    enabled: false,\n    config: {},\n  })\n}\n\n// 删除下载器\nfunction removeDownloader(ele: DownloaderConf) {\n  const index = downloaders.value.indexOf(ele)\n  downloaders.value.splice(index, 1)\n}\n\n// 下载器变化\nfunction onDownloaderChange(downloader: DownloaderConf, name: string) {\n  const index = downloaders.value.findIndex(item => item.name === name)\n  if (index !== -1) downloaders.value[index] = downloader\n}\n\n// 添加媒体服务器\nfunction addMediaServer(mediaserver: string) {\n  let name = `服务器${mediaServers.value.length + 1}`\n  while (mediaServers.value.some(item => item.name === name)) {\n    name = `服务器${parseInt(name.split('服务器')[1]) + 1}`\n  }\n  mediaServers.value.push({\n    name: name,\n    type: mediaserver,\n    enabled: false,\n    config: {},\n  })\n}\n\n// 删除媒体服务器\nfunction removeMediaServer(ele: MediaServerConf) {\n  const index = mediaServers.value.indexOf(ele)\n  if (index !== -1) mediaServers.value.splice(index, 1)\n}\n\n// 变更媒体服务器\nfunction onMediaServerChange(mediaserver: MediaServerConf, name: string) {\n  const index = mediaServers.value.findIndex(item => item.name === name)\n  if (index !== -1) mediaServers.value[index] = mediaserver\n}\n\n// 添加计算属性\nconst moviePilotAutoUpdate = computed({\n  get: () => {\n    return ['release', 'dev'].includes(SystemSettings.value.Advanced.MOVIEPILOT_AUTO_UPDATE)\n  },\n  set: val => {\n    SystemSettings.value.Advanced.MOVIEPILOT_AUTO_UPDATE = val ? 'release' : 'false'\n  },\n})\n\n// Fanart语言多选处理\nconst fanartLanguageSelection = computed({\n  get: () => {\n    if (!SystemSettings.value.Advanced.FANART_LANG) return []\n    return SystemSettings.value.Advanced.FANART_LANG.split(',')\n      .filter(Boolean)\n      .map((lang: any) => lang.trim())\n  },\n  set: (val: string[]) => {\n    SystemSettings.value.Advanced.FANART_LANG = val.join(',')\n  },\n})\n\n// 加载刮削开关设置\nasync function loadScrapingSwitchs() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/ScrapingSwitchs')\n    if (result.success && result.data?.value) {\n      const loadedSwitches = result.data.value\n      for (const key in loadedSwitches) {\n        if (typeof loadedSwitches[key] === 'boolean') {\n          // 兼容旧数据\n          loadedSwitches[key] = loadedSwitches[key] ? 'missingOnly' : 'skip'\n        }\n      }\n      ScrapingPolicies.value = { ...ScrapingPolicies.value, ...loadedSwitches }\n    }\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 保存刮削开关设置\nasync function saveScrapingSwitchs() {\n  try {\n    const result: { [key: string]: any } = await api.post('system/setting/ScrapingSwitchs', ScrapingPolicies.value)\n    if (result.success) {\n      return true\n    } else {\n      $toast.error(t('setting.system.scrapingSwitchSaveFailed', { message: result?.message }))\n      return false\n    }\n  } catch (error) {\n    console.log(error)\n    $toast.error(t('setting.system.scrapingSwitchSaveError'))\n    return false\n  }\n}\n\n// 加载数据\nonMounted(() => {\n  loadDownloaderSetting()\n  loadMediaServerSetting()\n  loadSystemSettings()\n  loadScrapingSwitchs()\n})\n\nonActivated(async () => {\n  isRequest.value = true\n})\n\nonDeactivated(() => {\n  isRequest.value = false\n})\n\nonBeforeUnmount(() => {\n  invalidateLlmTestState()\n})\n\nwatch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {\n  if (snapshotKey !== previousSnapshotKey) invalidateLlmTestState()\n})\n</script>\n\n<template>\n  <ProgressDialog\n    v-if=\"progressDialog\"\n    v-model=\"progressDialog\"\n    :text=\"t('setting.system.reloading')\"\n    :indeterminate=\"true\"\n  />\n\n  <VRow>\n    <VCol cols=\"12\">\n      <VCard>\n        <VCardItem>\n          <VCardTitle>{{ t('setting.system.basicSettings') }}</VCardTitle>\n          <VCardSubtitle>{{ t('setting.system.basicSettingsDesc') }}</VCardSubtitle>\n        </VCardItem>\n        <VCardText>\n          <VForm @submit.prevent=\"() => {}\">\n            <VRow>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"SystemSettings.Basic.APP_DOMAIN\"\n                  :label=\"t('setting.system.appDomain')\"\n                  :hint=\"t('setting.system.appDomainHint')\"\n                  placeholder=\"http://localhost:3000\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-web\"\n                />\n              </VCol>\n\n              <VCol cols=\"12\" md=\"6\">\n                <VRow>\n                  <VCol cols=\"12\" :md=\"SystemSettings.Basic.WALLPAPER === 'customize' ? 6 : 12\">\n                    <VSelect\n                      v-model=\"SystemSettings.Basic.WALLPAPER\"\n                      :label=\"t('setting.system.wallpaper')\"\n                      :hint=\"t('setting.system.wallpaperHint')\"\n                      persistent-hint\n                      :items=\"wallpaperItems\"\n                      prepend-inner-icon=\"mdi-image\"\n                    />\n                  </VCol>\n\n                  <VCol v-if=\"SystemSettings.Basic.WALLPAPER === 'customize'\" cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"SystemSettings.Basic.CUSTOMIZE_WALLPAPER_API_URL\"\n                      :label=\"t('setting.system.customizeWallpaperApi')\"\n                      :hint=\"t('setting.system.customizeWallpaperApiHint')\"\n                      :placeholder=\"t('setting.system.customizeWallpaperApi')\"\n                      persistent-hint\n                      :rules=\"[v => !!v || t('setting.system.customizeWallpaperApiRequired')]\"\n                      prepend-inner-icon=\"mdi-api\"\n                    />\n                  </VCol>\n                </VRow>\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VSelect\n                  v-model=\"SystemSettings.Basic.RECOGNIZE_SOURCE\"\n                  :label=\"t('setting.system.recognizeSource')\"\n                  :hint=\"t('setting.system.recognizeSourceHint')\"\n                  persistent-hint\n                  :items=\"[\n                    { title: 'TheMovieDb', value: 'themoviedb' },\n                    { title: '豆瓣', value: 'douban' },\n                  ]\"\n                  prepend-inner-icon=\"mdi-database\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"SystemSettings.Basic.MEDIASERVER_SYNC_INTERVAL\"\n                  :label=\"t('setting.system.mediaServerSyncInterval')\"\n                  :hint=\"t('setting.system.mediaServerSyncIntervalHint')\"\n                  persistent-hint\n                  :suffix=\"t('setting.system.hours')\"\n                  type=\"number\"\n                  min=\"1\"\n                  :rules=\"[\n                    (v: any) => !!v || t('setting.system.required'),\n                    (v: any) => !isNaN(v) || t('setting.system.numbersOnly'),\n                    (v: any) => v >= 1 || t('setting.system.minInterval'),\n                  ]\"\n                  prepend-inner-icon=\"mdi-sync\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"SystemSettings.Basic.API_TOKEN\"\n                  :label=\"t('setting.system.apiToken')\"\n                  :hint=\"t('setting.system.apiTokenHint')\"\n                  :placeholder=\"t('setting.system.apiTokenMinChars')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-key\"\n                  :append-inner-icon=\"SystemSettings.Basic.API_TOKEN ? 'mdi-content-copy' : 'mdi-reload'\"\n                  @click:append-inner=\"\n                    SystemSettings.Basic.API_TOKEN ? copyValue(SystemSettings.Basic.API_TOKEN) : createRandomString()\n                  \"\n                  :rules=\"[\n                    (v: string) => !!v || t('setting.system.apiTokenRequired'),\n                    (v: string) => v.length >= 16 || t('setting.system.apiTokenLength'),\n                  ]\"\n                />\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"SystemSettings.Basic.GITHUB_TOKEN\"\n                  :label=\"t('setting.system.githubToken')\"\n                  :placeholder=\"t('setting.system.githubTokenFormat')\"\n                  :hint=\"t('setting.system.githubTokenHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-github\"\n                >\n                </VTextField>\n              </VCol>\n              <VCol cols=\"12\" md=\"6\">\n                <VTextField\n                  v-model=\"SystemSettings.Basic.OCR_HOST\"\n                  :label=\"t('setting.system.ocrHost')\"\n                  placeholder=\"https://movie-pilot.org\"\n                  :hint=\"t('setting.system.ocrHostHint')\"\n                  persistent-hint\n                  prepend-inner-icon=\"mdi-text-recognition\"\n                />\n              </VCol>\n            </VRow>\n            <VCard\n              variant=\"outlined\"\n              :class=\"['mt-6', isTransparentTheme ? 'ai-agent-settings-card-transparent' : 'ai-agent-settings-card']\"\n            >\n              <VCardItem class=\"pb-2\">\n                <template #prepend>\n                  <VAvatar color=\"primary\" variant=\"tonal\" size=\"40\">\n                    <VIcon icon=\"mdi-robot-outline\" />\n                  </VAvatar>\n                </template>\n                <VCardTitle class=\"text-subtitle-1\">\n                  {{ t('setting.system.aiAgentSectionTitle') }}\n                </VCardTitle>\n                <VCardSubtitle>\n                  {{ t('setting.system.aiAgentSectionDesc') }}\n                </VCardSubtitle>\n              </VCardItem>\n              <VCardText class=\"pt-2\">\n                <VRow>\n                  <VCol cols=\"12\" md=\"4\">\n                    <VSwitch\n                      v-model=\"SystemSettings.Basic.AI_AGENT_ENABLE\"\n                      :label=\"t('setting.system.aiAgentEnable')\"\n                      :hint=\"t('setting.system.aiAgentEnableHint')\"\n                      persistent-hint\n                    />\n                  </VCol>\n                  <VCol v-if=\"SystemSettings.Basic.AI_AGENT_ENABLE\" cols=\"12\" md=\"4\">\n                    <VSwitch\n                      v-model=\"SystemSettings.Basic.AI_AGENT_GLOBAL\"\n                      :label=\"t('setting.system.aiAgentGlobal')\"\n                      :hint=\"t('setting.system.aiAgentGlobalHint')\"\n                      persistent-hint\n                    />\n                  </VCol>\n                  <VCol v-if=\"SystemSettings.Basic.AI_AGENT_ENABLE\" cols=\"12\" md=\"4\">\n                    <VSwitch\n                      v-model=\"SystemSettings.Basic.AI_AGENT_VERBOSE\"\n                      :label=\"t('setting.system.aiAgentVerbose')\"\n                      :hint=\"t('setting.system.aiAgentVerboseHint')\"\n                      persistent-hint\n                    />\n                  </VCol>\n                  <VCol v-if=\"SystemSettings.Basic.AI_AGENT_ENABLE\" cols=\"12\" md=\"6\">\n                    <VSelect\n                      v-model=\"SystemSettings.Basic.LLM_PROVIDER\"\n                      :label=\"t('setting.system.llmProvider')\"\n                      :hint=\"t('setting.system.llmProviderHint')\"\n                      persistent-hint\n                      :items=\"[\n                        { title: 'OpenAI', value: 'openai' },\n                        { title: 'Google', value: 'google' },\n                        { title: 'DeepSeek', value: 'deepseek' },\n                      ]\"\n                      prepend-inner-icon=\"mdi-robot\"\n                    />\n                  </VCol>\n                  <VCol v-if=\"SystemSettings.Basic.AI_AGENT_ENABLE\" cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"SystemSettings.Basic.LLM_BASE_URL\"\n                      :label=\"t('setting.system.llmBaseUrl')\"\n                      :hint=\"t('setting.system.llmBaseUrlHint')\"\n                      placeholder=\"https://api.deepseek.com\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-link\"\n                    />\n                  </VCol>\n                  <VCol v-if=\"SystemSettings.Basic.AI_AGENT_ENABLE\" cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"SystemSettings.Basic.LLM_API_KEY\"\n                      :label=\"t('setting.system.llmApiKey')\"\n                      :hint=\"t('setting.system.llmApiKeyHint')\"\n                      :placeholder=\"t('setting.system.llmApiKeyPlaceholder')\"\n                      persistent-hint\n                      type=\"password\"\n                      prepend-inner-icon=\"mdi-key-variant\"\n                    />\n                  </VCol>\n                  <VCol v-if=\"SystemSettings.Basic.AI_AGENT_ENABLE\" cols=\"12\" md=\"6\">\n                    <div>\n                      <VCombobox\n                        v-model=\"SystemSettings.Basic.LLM_MODEL\"\n                        :label=\"t('setting.system.llmModel')\"\n                        :hint=\"t('setting.system.llmModelHint')\"\n                        :placeholder=\"t('setting.system.llmModelHint')\"\n                        persistent-hint\n                        :items=\"llmModels\"\n                        :loading=\"loadingModels\"\n                        prepend-inner-icon=\"mdi-brain\"\n                      >\n                        <template #append-inner>\n                          <VBtn\n                            variant=\"text\"\n                            icon=\"mdi-refresh\"\n                            size=\"small\"\n                            @click=\"loadLlmModels\"\n                            :disabled=\"!SystemSettings.Basic.LLM_API_KEY\"\n                          />\n                        </template>\n                      </VCombobox>\n\n                      <div class=\"d-flex justify-end mt-2\">\n                        <VBtn\n                          color=\"info\"\n                          variant=\"tonal\"\n                          density=\"comfortable\"\n                          prepend-icon=\"mdi-connection\"\n                          :disabled=\"!canTestLlm\"\n                          :loading=\"testingLlm\"\n                          class=\"llm-test-trigger\"\n                          @click=\"testLlmConnection\"\n                        >\n                          {{ t('setting.system.llmTestAction') }}\n                        </VBtn>\n                      </div>\n                    </div>\n                  </VCol>\n                  <VCol v-if=\"SystemSettings.Basic.AI_AGENT_ENABLE\" cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model.number=\"SystemSettings.Basic.LLM_MAX_CONTEXT_TOKENS\"\n                      :label=\"t('setting.system.llmMaxContextTokens')\"\n                      :hint=\"t('setting.system.llmMaxContextTokensHint')\"\n                      persistent-hint\n                      type=\"number\"\n                      prepend-inner-icon=\"mdi-counter\"\n                    />\n                  </VCol>\n                  <VCol v-if=\"SystemSettings.Basic.AI_AGENT_ENABLE\" cols=\"12\" md=\"6\">\n                    <VSelect\n                      v-model=\"SystemSettings.Basic.LLM_THINKING_LEVEL\"\n                      :label=\"t('setting.system.llmThinking')\"\n                      :hint=\"t('setting.system.llmThinkingHint')\"\n                      :items=\"thinkingLevelItems\"\n                      persistent-hint\n                    />\n                  </VCol>\n                  <VCol v-if=\"SystemSettings.Basic.AI_AGENT_ENABLE\" cols=\"12\" md=\"6\">\n                    <VSelect\n                      v-model=\"SystemSettings.Basic.AI_AGENT_JOB_INTERVAL\"\n                      :label=\"t('setting.system.aiAgentJobInterval')\"\n                      :hint=\"t('setting.system.aiAgentJobIntervalHint')\"\n                      persistent-hint\n                      :items=\"[\n                        { title: t('setting.system.aiAgentJobIntervalDisabled'), value: 0 },\n                        { title: t('setting.system.aiAgentJobInterval1h'), value: 1 },\n                        { title: t('setting.system.aiAgentJobInterval3h'), value: 3 },\n                        { title: t('setting.system.aiAgentJobInterval6h'), value: 6 },\n                        { title: t('setting.system.aiAgentJobInterval12h'), value: 12 },\n                        { title: t('setting.system.aiAgentJobInterval24h'), value: 24 },\n                        { title: t('setting.system.aiAgentJobInterval1w'), value: 168 },\n                        { title: t('setting.system.aiAgentJobInterval1M'), value: 720 },\n                      ]\"\n                      prepend-inner-icon=\"mdi-timer-outline\"\n                    />\n                  </VCol>\n                </VRow>\n                <VRow>\n                  <VCol v-if=\"SystemSettings.Basic.AI_AGENT_ENABLE\" cols=\"12\" md=\"6\">\n                    <VSwitch\n                      v-model=\"SystemSettings.Basic.LLM_SUPPORT_IMAGE_INPUT\"\n                      :label=\"t('setting.system.llmSupportImageInput')\"\n                      :hint=\"t('setting.system.llmSupportImageInputHint')\"\n                      persistent-hint\n                    />\n                  </VCol>\n                  <VCol v-if=\"SystemSettings.Basic.AI_AGENT_ENABLE\" cols=\"12\" md=\"6\">\n                    <VSwitch\n                      v-model=\"SystemSettings.Basic.AI_AGENT_RETRY_TRANSFER\"\n                      :label=\"t('setting.system.aiAgentRetryTransfer')\"\n                      :hint=\"t('setting.system.aiAgentRetryTransferHint')\"\n                      persistent-hint\n                    />\n                  </VCol>\n                  <VCol v-if=\"SystemSettings.Basic.AI_AGENT_ENABLE\" cols=\"12\">\n                    <VSwitch\n                      v-model=\"SystemSettings.Basic.AI_RECOMMEND_ENABLED\"\n                      :label=\"t('setting.system.aiRecommendEnabled')\"\n                      :hint=\"t('setting.system.aiRecommendEnabledHint')\"\n                      persistent-hint\n                    />\n                  </VCol>\n                  <VCol\n                    v-if=\"SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.AI_RECOMMEND_ENABLED\"\n                    cols=\"12\"\n                    md=\"6\"\n                  >\n                    <VTextarea\n                      v-model=\"SystemSettings.Basic.AI_RECOMMEND_USER_PREFERENCE\"\n                      :label=\"t('setting.system.aiRecommendUserPreference')\"\n                      :hint=\"t('setting.system.aiRecommendUserPreferenceHint')\"\n                      persistent-hint\n                      rows=\"1\"\n                      auto-grow\n                      prepend-inner-icon=\"mdi-account-heart\"\n                    />\n                  </VCol>\n                  <VCol\n                    v-if=\"SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.AI_RECOMMEND_ENABLED\"\n                    cols=\"12\"\n                    md=\"6\"\n                  >\n                    <VTextField\n                      v-model.number=\"SystemSettings.Basic.AI_RECOMMEND_MAX_ITEMS\"\n                      :label=\"t('setting.system.aiRecommendMaxItems')\"\n                      :hint=\"t('setting.system.aiRecommendMaxItemsHint')\"\n                      persistent-hint\n                      type=\"number\"\n                      prepend-inner-icon=\"mdi-format-list-numbered\"\n                    />\n                  </VCol>\n                </VRow>\n              </VCardText>\n            </VCard>\n          </VForm>\n        </VCardText>\n        <VCardText>\n          <VForm @submit.prevent=\"() => {}\">\n            <div class=\"setting-actions mt-4\">\n              <VBtn\n                type=\"submit\"\n                @click=\"saveBasicSettings\"\n                prepend-icon=\"mdi-content-save\"\n                :loading=\"savingBasic\"\n                :disabled=\"testingLlm\"\n                class=\"text-no-wrap\"\n              >\n                {{ t('common.save') }}\n              </VBtn>\n              <VBtn\n                color=\"error\"\n                @click=\"advancedDialog = true\"\n                prepend-icon=\"mdi-cog\"\n                append-icon=\"mdi-dots-horizontal\"\n                class=\"text-no-wrap setting-actions__secondary\"\n              >\n                {{ t('setting.system.advancedSettings') }}\n              </VBtn>\n            </div>\n          </VForm>\n        </VCardText>\n      </VCard>\n    </VCol>\n  </VRow>\n\n  <VRow>\n    <VCol cols=\"12\">\n      <VCard>\n        <VCardItem>\n          <VCardTitle>{{ t('setting.system.downloaders') }}</VCardTitle>\n          <VCardSubtitle>{{ t('setting.system.downloadersDesc') }}</VCardSubtitle>\n        </VCardItem>\n        <VCardText>\n          <draggable\n            v-model=\"downloaders\"\n            handle=\".cursor-move\"\n            item-key=\"name\"\n            tag=\"div\"\n            :component-data=\"{ 'class': 'grid gap-3 grid-app-card' }\"\n          >\n            <template #item=\"{ element }\">\n              <DownloaderCard\n                :downloader=\"element\"\n                :downloaders=\"downloaders\"\n                @close=\"removeDownloader(element)\"\n                @change=\"onDownloaderChange\"\n                :allow-refresh=\"isRequest\"\n              />\n            </template>\n          </draggable>\n        </VCardText>\n        <VCardText>\n          <VForm @submit.prevent=\"() => {}\">\n            <div class=\"d-flex flex-wrap gap-4 mt-4\">\n              <VBtn type=\"submit\" @click=\"saveDownloaderSetting\" prepend-icon=\"mdi-content-save\">\n                {{ t('common.save') }}\n              </VBtn>\n              <VBtn color=\"success\" variant=\"tonal\">\n                <VIcon icon=\"mdi-plus\" />\n                <VMenu activator=\"parent\" close-on-content-click>\n                  <VList>\n                    <VListItem v-for=\"item in downloaderOptions\" @click=\"addDownloader(item.value)\">\n                      <VListItemTitle>{{ item.title }}</VListItemTitle>\n                    </VListItem>\n                    <VListItem @click=\"addDownloader('custom')\">\n                      <VListItemTitle>{{ t('setting.system.custom') }}</VListItemTitle>\n                    </VListItem>\n                  </VList>\n                </VMenu>\n              </VBtn>\n            </div>\n          </VForm>\n        </VCardText>\n      </VCard>\n    </VCol>\n  </VRow>\n  <VRow>\n    <VCol cols=\"12\">\n      <VCard>\n        <VCardItem>\n          <VCardTitle>{{ t('setting.system.mediaServers') }}</VCardTitle>\n          <VCardSubtitle>{{ t('setting.system.mediaServersDesc') }}</VCardSubtitle>\n        </VCardItem>\n        <VCardText>\n          <draggable\n            v-model=\"mediaServers\"\n            handle=\".cursor-move\"\n            item-key=\"name\"\n            tag=\"div\"\n            :component-data=\"{ 'class': 'grid gap-3 grid-app-card' }\"\n          >\n            <template #item=\"{ element }\">\n              <MediaServerCard\n                :mediaserver=\"element\"\n                :mediaservers=\"mediaServers\"\n                @close=\"removeMediaServer(element)\"\n                @change=\"onMediaServerChange\"\n              />\n            </template>\n          </draggable>\n        </VCardText>\n        <VCardText>\n          <VForm @submit.prevent=\"() => {}\">\n            <div class=\"d-flex flex-wrap gap-4 mt-4\">\n              <VBtn type=\"submit\" @click=\"saveMediaServerSetting\" prepend-icon=\"mdi-content-save\">\n                {{ t('common.save') }}\n              </VBtn>\n              <VBtn color=\"success\" variant=\"tonal\">\n                <VIcon icon=\"mdi-plus\" />\n                <VMenu activator=\"parent\" close-on-content-click>\n                  <VList>\n                    <VListItem v-for=\"item in mediaServerOptions\" @click=\"addMediaServer(item.value)\">\n                      <VListItemTitle>{{ item.title }}</VListItemTitle>\n                    </VListItem>\n                    <VListItem @click=\"addMediaServer('custom')\">\n                      <VListItemTitle>{{ t('setting.system.custom') }}</VListItemTitle>\n                    </VListItem>\n                  </VList>\n                </VMenu>\n              </VBtn>\n            </div>\n          </VForm>\n        </VCardText>\n      </VCard>\n    </VCol>\n  </VRow>\n\n  <!-- 高级系统设置 -->\n  <VDialog\n    v-if=\"advancedDialog\"\n    v-model=\"advancedDialog\"\n    scrollable\n    max-width=\"60rem\"\n    :fullscreen=\"!display.mdAndUp.value\"\n  >\n    <VCard>\n      <VCardItem class=\"py-2\">\n        <template #prepend>\n          <VIcon icon=\"mdi-cog\" class=\"me-2\" />\n        </template>\n        <VCardTitle>{{ t('setting.system.advancedSettings') }}</VCardTitle>\n        <VCardSubtitle>{{ t('setting.system.advancedSettingsDesc') }}</VCardSubtitle>\n      </VCardItem>\n      <VDialogCloseBtn @click=\"advancedDialog = false\" />\n      <VCardText>\n        <VTabs v-model=\"activeTab\" show-arrows>\n          <VTab value=\"system\">\n            <div>{{ t('setting.system.system') }}</div>\n          </VTab>\n          <VTab value=\"media\">\n            <div>{{ t('setting.system.media') }}</div>\n          </VTab>\n          <VTab value=\"network\">\n            <div>{{ t('setting.system.network') }}</div>\n          </VTab>\n          <VTab value=\"log\">\n            <div>{{ t('setting.system.log') }}</div>\n          </VTab>\n          <VTab value=\"dev\">\n            <div>{{ t('setting.system.lab') }}</div>\n          </VTab>\n        </VTabs>\n        <VWindow v-model=\"activeTab\" class=\"mt-5 disable-tab-transition\" :touch=\"false\">\n          <VWindowItem value=\"system\">\n            <div>\n              <VRow>\n                <VCol cols=\"12\" md=\"6\">\n                  <VSwitch\n                    v-model=\"SystemSettings.Advanced.AUXILIARY_AUTH_ENABLE\"\n                    :label=\"t('setting.system.auxAuthEnable')\"\n                    :hint=\"t('setting.system.auxAuthEnableHint')\"\n                    persistent-hint\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VSwitch\n                    v-model=\"SystemSettings.Advanced.GLOBAL_IMAGE_CACHE\"\n                    :label=\"t('setting.system.globalImageCache')\"\n                    :hint=\"t('setting.system.globalImageCacheHint')\"\n                    persistent-hint\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VSwitch\n                    v-model=\"SystemSettings.Advanced.SUBSCRIBE_STATISTIC_SHARE\"\n                    :label=\"t('setting.system.subscribeStatisticShare')\"\n                    :hint=\"t('setting.system.subscribeStatisticShareHint')\"\n                    persistent-hint\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VSwitch\n                    v-model=\"SystemSettings.Advanced.PLUGIN_STATISTIC_SHARE\"\n                    :label=\"t('setting.system.pluginStatisticShare')\"\n                    :hint=\"t('setting.system.pluginStatisticShareHint')\"\n                    persistent-hint\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VSwitch\n                    v-model=\"SystemSettings.Advanced.WORKFLOW_STATISTIC_SHARE\"\n                    :label=\"t('setting.system.workflowStatisticShare')\"\n                    :hint=\"t('setting.system.workflowStatisticShareHint')\"\n                    persistent-hint\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VSwitch\n                    v-model=\"SystemSettings.Advanced.BIG_MEMORY_MODE\"\n                    :label=\"t('setting.system.bigMemoryMode')\"\n                    :hint=\"t('setting.system.bigMemoryModeHint')\"\n                    persistent-hint\n                  />\n                </VCol>\n                <VCol v-if=\"SystemSettings.Basic.DB_TYPE === 'sqlite'\" cols=\"12\" md=\"6\">\n                  <VSwitch\n                    v-model=\"SystemSettings.Advanced.DB_WAL_ENABLE\"\n                    :label=\"t('setting.system.dbWalEnable')\"\n                    :hint=\"t('setting.system.dbWalEnableHint')\"\n                    persistent-hint\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VSwitch\n                    v-model=\"moviePilotAutoUpdate\"\n                    :label=\"t('setting.system.moviePilotAutoUpdate')\"\n                    :hint=\"t('setting.system.moviePilotAutoUpdateHint')\"\n                    persistent-hint\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VSwitch\n                    v-model=\"SystemSettings.Advanced.AUTO_UPDATE_RESOURCE\"\n                    :label=\"t('setting.system.autoUpdateResource')\"\n                    :hint=\"t('setting.system.autoUpdateResourceHint')\"\n                    persistent-hint\n                  />\n                </VCol>\n              </VRow>\n            </div>\n          </VWindowItem>\n          <VWindowItem value=\"media\">\n            <div>\n              <VRow>\n                <VCol cols=\"12\" md=\"6\">\n                  <VCombobox\n                    v-model=\"SystemSettings.Advanced.TMDB_API_DOMAIN\"\n                    :label=\"t('setting.system.tmdbApiDomain')\"\n                    :hint=\"t('setting.system.tmdbApiDomainHint')\"\n                    persistent-hint\n                    :placeholder=\"t('setting.system.tmdbApiDomainPlaceholder')\"\n                    :items=\"['api.themoviedb.org', 'api.tmdb.org']\"\n                    :rules=\"[(v: string) => !!v || t('setting.system.tmdbApiDomainRequired')]\"\n                    prepend-inner-icon=\"mdi-api\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VCombobox\n                    v-model=\"SystemSettings.Advanced.TMDB_IMAGE_DOMAIN\"\n                    :label=\"t('setting.system.tmdbImageDomain')\"\n                    :hint=\"t('setting.system.tmdbImageDomainHint')\"\n                    persistent-hint\n                    :placeholder=\"t('setting.system.tmdbImageDomainPlaceholder')\"\n                    :items=\"['image.tmdb.org']\"\n                    :rules=\"[(v: string) => !!v || t('setting.system.tmdbImageDomainRequired')]\"\n                    prepend-inner-icon=\"mdi-image\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VSelect\n                    v-model=\"SystemSettings.Advanced.TMDB_LOCALE\"\n                    :label=\"t('setting.system.tmdbLocale')\"\n                    :hint=\"t('setting.system.tmdbLocaleHint')\"\n                    persistent-hint\n                    :placeholder=\"t('setting.system.tmdbLocalePlaceholder')\"\n                    :items=\"tmdbLanguageItems\"\n                    prepend-inner-icon=\"mdi-translate\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"SystemSettings.Advanced.META_CACHE_EXPIRE\"\n                    :label=\"t('setting.system.metaCacheExpire')\"\n                    :hint=\"t('setting.system.metaCacheExpireHint')\"\n                    persistent-hint\n                    min=\"0\"\n                    type=\"number\"\n                    :suffix=\"t('setting.system.hour')\"\n                    :rules=\"[\n                      (v: any) => v === 0 || !!v || t('setting.system.metaCacheExpireRequired'),\n                      (v: any) => v >= 0 || t('setting.system.metaCacheExpireMin'),\n                    ]\"\n                    prepend-inner-icon=\"mdi-timer\"\n                  />\n                </VCol>\n              </VRow>\n              <VRow>\n                <VCol cols=\"12\" md=\"6\">\n                  <VSwitch\n                    v-model=\"SystemSettings.Advanced.SCRAP_FOLLOW_TMDB\"\n                    :label=\"t('setting.system.scrapFollowTmdb')\"\n                    :hint=\"t('setting.system.scrapFollowTmdbHint')\"\n                    persistent-hint\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VSwitch\n                    v-model=\"SystemSettings.Advanced.TMDB_SCRAP_ORIGINAL_IMAGE\"\n                    :label=\"t('setting.system.scrapOriginalImage')\"\n                    :hint=\"t('setting.system.scrapOriginalImageHint')\"\n                    persistent-hint\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VSwitch\n                    v-model=\"SystemSettings.Advanced.FANART_ENABLE\"\n                    :label=\"t('setting.system.fanartEnable')\"\n                    :hint=\"t('setting.system.fanartEnableHint')\"\n                    persistent-hint\n                  />\n                </VCol>\n                <VCol v-if=\"SystemSettings.Advanced.FANART_ENABLE\" cols=\"12\" md=\"6\">\n                  <VSelect\n                    v-model=\"fanartLanguageSelection\"\n                    :label=\"t('setting.system.fanartLang')\"\n                    :hint=\"t('setting.system.fanartLangHint')\"\n                    persistent-hint\n                    :items=\"fanartLanguageItems\"\n                    multiple\n                    chips\n                    closable-chips\n                    prepend-inner-icon=\"mdi-translate\"\n                  />\n                </VCol>\n              </VRow>\n              <VRow>\n                <VCol cols=\"12\" md=\"6\">\n                  <VSwitch\n                    v-model=\"SystemSettings.Advanced.RECOGNIZE_PLUGIN_FIRST\"\n                    :label=\"t('setting.system.recognizePluginFirst')\"\n                    :hint=\"t('setting.system.recognizePluginFirstHint')\"\n                    persistent-hint\n                  />\n                </VCol>\n              </VRow>\n\n              <!-- 刮削开关设置 -->\n              <VRow class=\"mt-4\">\n                <VCol cols=\"12\">\n                  <VExpansionPanels>\n                    <VExpansionPanel>\n                      <VExpansionPanelTitle class=\"text-lg\">\n                        <VIcon icon=\"mdi-checkbox-multiple-outline\" class=\"me-2\" />\n                        {{ t('setting.system.scrapingSwitchSettings') }}\n                        <!-- 帮助图标 -->\n                        <VTooltip location=\"bottom\" open-delay=\"200\">\n                          <template #activator=\"{ props: tooltipProps }\">\n                            <VBtn\n                              v-bind=\"tooltipProps\"\n                              icon=\"mdi-help-circle\"\n                              size=\"small\"\n                              variant=\"text\"\n                              color=\"medium-emphasis\"\n                              class=\"ml-2\"\n                              @click.stop\n                            />\n                          </template>\n                          <div class=\"d-flex flex-column gap-2 py-2\">\n                            <div class=\"d-flex align-center\">\n                              <VIcon icon=\"mdi-file-remove\" color=\"error\" class=\"mr-2\" />\n                              <span>{{ t('setting.system.policy.skipDesc') }}</span>\n                            </div>\n                            <div class=\"d-flex align-center\">\n                              <VIcon icon=\"mdi-file-plus\" color=\"success\" class=\"mr-2\" />\n                              <span>{{ t('setting.system.policy.missingOnlyDesc') }}</span>\n                            </div>\n                            <div class=\"d-flex align-center\">\n                              <VIcon icon=\"mdi-file-replace\" color=\"primary\" class=\"mr-2\" />\n                              <span>{{ t('setting.system.policy.overwriteDesc') }}</span>\n                            </div>\n                          </div>\n                        </VTooltip>\n                      </VExpansionPanelTitle>\n                      <VExpansionPanelText>\n                        <VRow v-for=\"section in scrapingConfig\" :key=\"section.section\">\n                          <VCol cols=\"12\" class=\"pb-2\">\n                            <VListSubheader class=\"text-lg\">\n                              {{ t(`setting.system.${section.section}`) }}\n                            </VListSubheader>\n                          </VCol>\n                          <VCol v-for=\"item in section.items\" :key=\"item.key\" cols=\"12\" md=\"4\">\n                            <div class=\"d-flex align-center\">\n                              <VBtnToggle\n                                :model-value=\"ScrapingPolicies[item.key]\"\n                                @update:model-value=\"ScrapingPolicies[item.key] = $event\"\n                                color=\"primary\"\n                                variant=\"tonal\"\n                                rounded=\"lg\"\n                              >\n                                <VBtn value=\"skip\" color=\"error\">\n                                  <VIcon icon=\"mdi-file-remove\" />\n                                </VBtn>\n                                <VBtn value=\"missingOnly\" color=\"success\">\n                                  <VIcon icon=\"mdi-file-plus\" />\n                                </VBtn>\n                                <VBtn value=\"overwrite\" color=\"primary\">\n                                  <VIcon icon=\"mdi-file-replace\" />\n                                </VBtn>\n                              </VBtnToggle>\n                              <span class=\"ml-2\">{{ t(item.label) }}</span>\n                            </div>\n                          </VCol>\n                          <VDivider v-if=\"section.section !== 'episode'\" class=\"my-4\" />\n                        </VRow>\n                      </VExpansionPanelText>\n                    </VExpansionPanel>\n                  </VExpansionPanels>\n                </VCol>\n              </VRow>\n            </div>\n          </VWindowItem>\n          <VWindowItem value=\"network\">\n            <div>\n              <VRow>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"SystemSettings.Advanced.PROXY_HOST\"\n                    :label=\"t('setting.system.proxyHost')\"\n                    placeholder=\"http://127.0.0.1:7890\"\n                    :hint=\"t('setting.system.proxyHostHint')\"\n                    persistent-hint\n                    prepend-inner-icon=\"mdi-server-network\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VCombobox\n                    v-model=\"githubProxyDisplay\"\n                    :label=\"t('setting.system.githubProxy')\"\n                    :placeholder=\"t('setting.system.githubProxyPlaceholder')\"\n                    :hint=\"t('setting.system.githubProxyHint')\"\n                    persistent-hint\n                    :items=\"githubMirrorsItems\"\n                    clearable\n                    prepend-inner-icon=\"mdi-github\"\n                  />\n                </VCol>\n                <VCol cols=\"12\">\n                  <VCombobox\n                    v-model=\"pipProxyDisplay\"\n                    :label=\"t('setting.system.pipProxy')\"\n                    :placeholder=\"t('setting.system.pipProxyPlaceholder')\"\n                    :hint=\"t('setting.system.pipProxyHint')\"\n                    persistent-hint\n                    :items=\"pipMirrorsItems\"\n                    clearable\n                    prepend-inner-icon=\"mdi-package\"\n                  />\n                </VCol>\n              </VRow>\n              <VRow>\n                <VCol cols=\"12\" md=\"6\">\n                  <VSwitch\n                    v-model=\"SystemSettings.Advanced.DOH_ENABLE\"\n                    :label=\"t('setting.system.dohEnable')\"\n                    :hint=\"t('setting.system.dohEnableHint')\"\n                    persistent-hint\n                  />\n                </VCol>\n                <VCol cols=\"12\" v-show=\"SystemSettings.Advanced.DOH_ENABLE\">\n                  <VTextarea\n                    v-model=\"SystemSettings.Advanced.DOH_RESOLVERS\"\n                    :label=\"t('setting.system.dohResolvers')\"\n                    :placeholder=\"t('setting.system.dohResolversPlaceholder')\"\n                    :hint=\"t('setting.system.dohResolversHint')\"\n                    persistent-hint\n                    prepend-inner-icon=\"mdi-dns\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" v-show=\"SystemSettings.Advanced.DOH_ENABLE\">\n                  <VTextarea\n                    v-model=\"SystemSettings.Advanced.DOH_DOMAINS\"\n                    :label=\"t('setting.system.dohDomains')\"\n                    :placeholder=\"t('setting.system.dohDomainsPlaceholder')\"\n                    :hint=\"t('setting.system.dohDomainsHint')\"\n                    persistent-hint\n                    prepend-inner-icon=\"mdi-domain\"\n                  />\n                </VCol>\n              </VRow>\n              <VRow>\n                <VCol cols=\"12\">\n                  <VExpansionPanels>\n                    <VExpansionPanel>\n                      <VExpansionPanelTitle class=\"text-lg\">\n                        <template #default>\n                          <VIcon icon=\"mdi-shield-check\" class=\"me-2\" />\n                          {{ t('setting.system.securityImageDomains') }}\n                        </template>\n                      </VExpansionPanelTitle>\n                      <VExpansionPanelText>\n                        <div class=\"d-flex flex-wrap gap-2 mb-3\">\n                          <VChip\n                            v-for=\"(domain, index) in SystemSettings.Advanced.SECURITY_IMAGE_DOMAINS\"\n                            :key=\"index\"\n                            closable\n                            @click:close=\"SystemSettings.Advanced.SECURITY_IMAGE_DOMAINS.splice(index, 1)\"\n                          >\n                            {{ domain }}\n                          </VChip>\n                          <VChip v-if=\"SystemSettings.Advanced.SECURITY_IMAGE_DOMAINS.length === 0\" color=\"warning\">\n                            {{ t('setting.system.noSecurityImageDomains') }}\n                          </VChip>\n                        </div>\n                        <div class=\"d-flex align-center gap-2\">\n                          <VTextField\n                            v-model=\"newSecurityDomain\"\n                            :placeholder=\"t('setting.system.securityImageDomainAdd')\"\n                            hide-details\n                            density=\"compact\"\n                            prepend-inner-icon=\"mdi-shield-check\"\n                          >\n                            <template #append>\n                              <VBtn icon color=\"primary\" @click=\"addSecurityDomain\" :disabled=\"!newSecurityDomain\">\n                                <VIcon icon=\"mdi-plus\" />\n                              </VBtn>\n                            </template>\n                          </VTextField>\n                        </div>\n                      </VExpansionPanelText>\n                    </VExpansionPanel>\n                  </VExpansionPanels>\n                </VCol>\n              </VRow>\n            </div>\n          </VWindowItem>\n          <VWindowItem value=\"log\">\n            <div>\n              <VRow>\n                <VCol cols=\"12\" md=\"6\">\n                  <VSwitch\n                    v-model=\"SystemSettings.Advanced.DEBUG\"\n                    :label=\"t('setting.system.debug')\"\n                    :hint=\"t('setting.system.debugHint')\"\n                    persistent-hint\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VSelect\n                    v-if=\"!SystemSettings.Advanced.DEBUG\"\n                    v-model=\"SystemSettings.Advanced.LOG_LEVEL\"\n                    :label=\"t('setting.system.logLevel')\"\n                    :hint=\"t('setting.system.logLevelHint')\"\n                    persistent-hint\n                    :items=\"logLevelItems\"\n                    prepend-inner-icon=\"mdi-format-list-bulleted\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"SystemSettings.Advanced.LOG_MAX_FILE_SIZE\"\n                    :label=\"t('setting.system.logMaxFileSize')\"\n                    :hint=\"t('setting.system.logMaxFileSizeHint')\"\n                    persistent-hint\n                    min=\"1\"\n                    type=\"number\"\n                    :suffix=\"t('setting.system.mb')\"\n                    :rules=\"[\n                      (v: any) => v === 0 || !!v || t('setting.system.logMaxFileSizeRequired'),\n                      (v: any) => v >= 1 || t('setting.system.logMaxFileSizeMin'),\n                    ]\"\n                    prepend-inner-icon=\"mdi-file-document\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"SystemSettings.Advanced.LOG_BACKUP_COUNT\"\n                    :label=\"t('setting.system.logBackupCount')\"\n                    :hint=\"t('setting.system.logBackupCountHint')\"\n                    persistent-hint\n                    min=\"1\"\n                    type=\"number\"\n                    :rules=\"[\n                      (v: any) => v === 0 || !!v || t('setting.system.logBackupCountRequired'),\n                      (v: any) => v >= 1 || t('setting.system.logBackupCountMin'),\n                    ]\"\n                    prepend-inner-icon=\"mdi-backup-restore\"\n                  />\n                </VCol>\n                <VCol cols=\"12\">\n                  <VTextField\n                    v-model=\"SystemSettings.Advanced.LOG_FILE_FORMAT\"\n                    :label=\"t('setting.system.logFileFormat')\"\n                    :hint=\"t('setting.system.logFileFormatHint')\"\n                    persistent-hint\n                    prepend-inner-icon=\"mdi-format-text\"\n                  />\n                </VCol>\n              </VRow>\n            </div>\n          </VWindowItem>\n          <VWindowItem value=\"dev\">\n            <div>\n              <VRow>\n                <VCol cols=\"12\" md=\"6\">\n                  <VSwitch\n                    v-model=\"SystemSettings.Advanced.PLUGIN_AUTO_RELOAD\"\n                    :label=\"t('setting.system.pluginAutoReload')\"\n                    :hint=\"t('setting.system.pluginAutoReloadHint')\"\n                    persistent-hint\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"SystemSettings.Advanced.PLUGIN_LOCAL_REPO_PATHS\"\n                    :label=\"t('setting.system.pluginLocalRepoPaths')\"\n                    :hint=\"t('setting.system.pluginLocalRepoPathsHint')\"\n                    persistent-hint\n                    prepend-inner-icon=\"mdi-folder\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VSwitch\n                    v-model=\"SystemSettings.Advanced.ENCODING_DETECTION_PERFORMANCE_MODE\"\n                    :label=\"t('setting.system.encodingDetectionPerformanceMode')\"\n                    :hint=\"t('setting.system.encodingDetectionPerformanceModeHint')\"\n                    persistent-hint\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model.number=\"SystemSettings.Advanced.TRANSFER_THREADS\"\n                    :label=\"t('setting.system.transferThreads')\"\n                    :hint=\"t('setting.system.transferThreadsHint')\"\n                    persistent-hint\n                    type=\"number\"\n                    min=\"1\"\n                    prepend-inner-icon=\"mdi-swap-horizontal\"\n                  />\n                </VCol>\n              </VRow>\n            </div>\n          </VWindowItem>\n        </VWindow>\n      </VCardText>\n      <VCardActions class=\"pt-3\">\n        <VForm @submit.prevent=\"() => {}\">\n          <div class=\"d-flex flex-wrap gap-4 mt-4\">\n            <VBtn color=\"primary\" prepend-icon=\"mdi-content-save\" @click=\"saveAdvancedSettings\" class=\"px-5\">\n              {{ t('common.save') }}\n            </VBtn>\n          </div>\n        </VForm>\n      </VCardActions>\n    </VCard>\n  </VDialog>\n</template>\n\n<style scoped>\n.ai-agent-settings-card {\n  border-color: rgba(var(--v-theme-primary), 0.15);\n  background: linear-gradient(180deg, rgba(var(--v-theme-primary), 0.04) 0%, rgba(var(--v-theme-surface), 0.92) 100%);\n}\n\n.ai-agent-settings-card-transparent {\n  border-color: rgba(var(--v-theme-primary), 0);\n  background-color: rgba(var(--v-theme-surface), 0) !important;\n}\n\n.setting-actions {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 1rem;\n}\n\n.setting-actions__secondary {\n  flex-shrink: 0;\n}\n\n.llm-test-trigger {\n  min-inline-size: 0;\n}\n</style>\n"
  },
  {
    "path": "src/views/setup/AgentSettingsStep.vue",
    "content": "<script lang=\"ts\" setup>\nimport { computed, onMounted, ref } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport api from '@/api'\nimport { useSetupWizard } from '@/composables/useSetupWizard'\n\nconst { t } = useI18n()\nconst { wizardData, validationErrors } = useSetupWizard()\n\nconst llmModels = ref<string[]>([])\nconst loadingModels = ref(false)\n\nconst providerItems = [\n  { title: 'OpenAI', value: 'openai' },\n  { title: 'Google', value: 'google' },\n  { title: 'DeepSeek', value: 'deepseek' },\n]\n\nconst jobIntervalItems = computed(() => [\n  { title: t('setting.system.aiAgentJobIntervalDisabled'), value: 0 },\n  { title: t('setting.system.aiAgentJobInterval1h'), value: 1 },\n  { title: t('setting.system.aiAgentJobInterval3h'), value: 3 },\n  { title: t('setting.system.aiAgentJobInterval6h'), value: 6 },\n  { title: t('setting.system.aiAgentJobInterval12h'), value: 12 },\n  { title: t('setting.system.aiAgentJobInterval24h'), value: 24 },\n  { title: t('setting.system.aiAgentJobInterval1w'), value: 168 },\n  { title: t('setting.system.aiAgentJobInterval1M'), value: 720 },\n])\n\nconst thinkingLevelItems = computed(() => [\n  { title: t('setting.system.llmThinkingLevelOff'), value: 'off' },\n  { title: t('setting.system.llmThinkingLevelAuto'), value: 'auto' },\n  { title: t('setting.system.llmThinkingLevelMinimal'), value: 'minimal' },\n  { title: t('setting.system.llmThinkingLevelLow'), value: 'low' },\n  { title: t('setting.system.llmThinkingLevelMedium'), value: 'medium' },\n  { title: t('setting.system.llmThinkingLevelHigh'), value: 'high' },\n  { title: t('setting.system.llmThinkingLevelMax'), value: 'max' },\n  { title: t('setting.system.llmThinkingLevelXhigh'), value: 'xhigh' },\n])\n\nasync function loadLlmModels() {\n  if (!wizardData.value.agent.provider || !wizardData.value.agent.apiKey) {\n    return\n  }\n\n  loadingModels.value = true\n  try {\n    const result: { [key: string]: any } = await api.get('system/llm-models', {\n      params: {\n        provider: wizardData.value.agent.provider,\n        api_key: wizardData.value.agent.apiKey,\n        base_url: wizardData.value.agent.baseUrl,\n      },\n    })\n\n    if (result.success) {\n      llmModels.value = result.data || []\n      if (!wizardData.value.agent.model && llmModels.value.length > 0) {\n        wizardData.value.agent.model = llmModels.value[0]\n      }\n    }\n  } catch (error) {\n    console.log('Load LLM models failed:', error)\n  } finally {\n    loadingModels.value = false\n  }\n}\n\nonMounted(() => {\n  if (wizardData.value.agent.enabled && wizardData.value.agent.apiKey) {\n    loadLlmModels()\n  }\n})\n</script>\n\n<template>\n  <VCard variant=\"outlined\">\n    <VCardText>\n      <div class=\"text-center mb-6\">\n        <h3 class=\"text-h4 mb-2\">{{ t('setupWizard.agent.title') }}</h3>\n        <p class=\"text-body-1 text-medium-emphasis\">{{ t('setupWizard.agent.description') }}</p>\n      </div>\n\n      <VRow>\n        <VCol cols=\"12\">\n          <VAlert type=\"info\" variant=\"tonal\" class=\"mb-4\">\n            <VAlertTitle>{{ t('setupWizard.agent.info') }}</VAlertTitle>\n            {{ t('setupWizard.agent.infoDesc') }}\n          </VAlert>\n        </VCol>\n\n        <VCol cols=\"12\">\n          <VSwitch\n            v-model=\"wizardData.agent.enabled\"\n            :label=\"t('setting.system.aiAgentEnable')\"\n            :hint=\"t('setting.system.aiAgentEnableHint')\"\n            persistent-hint\n            color=\"primary\"\n          />\n        </VCol>\n\n        <template v-if=\"wizardData.agent.enabled\">\n          <VCol cols=\"12\" md=\"4\">\n            <VSwitch\n              v-model=\"wizardData.agent.global\"\n              :label=\"t('setting.system.aiAgentGlobal')\"\n              :hint=\"t('setting.system.aiAgentGlobalHint')\"\n              persistent-hint\n              color=\"primary\"\n            />\n          </VCol>\n\n          <VCol cols=\"12\" md=\"4\">\n            <VSwitch\n              v-model=\"wizardData.agent.verbose\"\n              :label=\"t('setting.system.aiAgentVerbose')\"\n              :hint=\"t('setting.system.aiAgentVerboseHint')\"\n              persistent-hint\n              color=\"primary\"\n            />\n          </VCol>\n\n          <VCol cols=\"12\" md=\"4\">\n            <VSwitch\n              v-model=\"wizardData.agent.supportImageInput\"\n              :label=\"t('setting.system.llmSupportImageInput')\"\n              :hint=\"t('setting.system.llmSupportImageInputHint')\"\n              persistent-hint\n              color=\"primary\"\n            />\n          </VCol>\n\n          <VCol cols=\"12\" md=\"6\">\n            <VSelect\n              v-model=\"wizardData.agent.provider\"\n              :label=\"t('setting.system.llmProvider')\"\n              :hint=\"t('setting.system.llmProviderHint')\"\n              :items=\"providerItems\"\n              :error=\"validationErrors.agent.provider\"\n              :error-messages=\"validationErrors.agent.provider ? [t('setupWizard.agent.providerRequired')] : []\"\n              persistent-hint\n              prepend-inner-icon=\"mdi-robot-outline\"\n            />\n          </VCol>\n\n          <VCol cols=\"12\" md=\"6\">\n            <VTextField\n              v-model=\"wizardData.agent.baseUrl\"\n              :label=\"t('setting.system.llmBaseUrl')\"\n              :hint=\"t('setting.system.llmBaseUrlHint')\"\n              placeholder=\"https://api.deepseek.com\"\n              persistent-hint\n              prepend-inner-icon=\"mdi-link-variant\"\n            />\n          </VCol>\n\n          <VCol cols=\"12\" md=\"6\">\n            <VTextField\n              v-model=\"wizardData.agent.apiKey\"\n              :label=\"t('setting.system.llmApiKey')\"\n              :hint=\"t('setting.system.llmApiKeyHint')\"\n              :placeholder=\"t('setting.system.llmApiKeyPlaceholder')\"\n              :error=\"validationErrors.agent.apiKey\"\n              :error-messages=\"validationErrors.agent.apiKey ? [t('setupWizard.agent.apiKeyRequired')] : []\"\n              persistent-hint\n              prepend-inner-icon=\"mdi-key-variant\"\n              type=\"password\"\n            />\n          </VCol>\n\n          <VCol cols=\"12\" md=\"6\">\n            <VCombobox\n              v-model=\"wizardData.agent.model\"\n              :label=\"t('setting.system.llmModel')\"\n              :hint=\"t('setting.system.llmModelHint')\"\n              :items=\"llmModels\"\n              :loading=\"loadingModels\"\n              :error=\"validationErrors.agent.model\"\n              :error-messages=\"validationErrors.agent.model ? [t('setupWizard.agent.modelRequired')] : []\"\n              persistent-hint\n              prepend-inner-icon=\"mdi-brain\"\n            >\n              <template #append-inner>\n                <VBtn\n                  variant=\"text\"\n                  icon=\"mdi-refresh\"\n                  size=\"small\"\n                  :disabled=\"!wizardData.agent.provider || !wizardData.agent.apiKey\"\n                  @click=\"loadLlmModels\"\n                />\n              </template>\n            </VCombobox>\n          </VCol>\n\n          <VCol cols=\"12\" md=\"6\">\n            <VTextField\n              v-model.number=\"wizardData.agent.maxContextTokens\"\n              :label=\"t('setting.system.llmMaxContextTokens')\"\n              :hint=\"t('setting.system.llmMaxContextTokensHint')\"\n              :error=\"validationErrors.agent.maxContextTokens\"\n              :error-messages=\"\n                validationErrors.agent.maxContextTokens ? [t('setupWizard.agent.maxContextTokensRequired')] : []\n              \"\n              persistent-hint\n              prepend-inner-icon=\"mdi-counter\"\n              type=\"number\"\n              min=\"1\"\n            />\n          </VCol>\n\n          <VCol cols=\"12\" md=\"6\">\n            <VSelect\n              v-model=\"wizardData.agent.thinkingLevel\"\n              :label=\"t('setting.system.llmThinking')\"\n              :hint=\"t('setting.system.llmThinkingHint')\"\n              :items=\"thinkingLevelItems\"\n              persistent-hint\n              color=\"primary\"\n            />\n          </VCol>\n\n          <VCol cols=\"12\" md=\"6\">\n            <VSelect\n              v-model=\"wizardData.agent.jobInterval\"\n              :label=\"t('setting.system.aiAgentJobInterval')\"\n              :hint=\"t('setting.system.aiAgentJobIntervalHint')\"\n              :items=\"jobIntervalItems\"\n              persistent-hint\n              prepend-inner-icon=\"mdi-timer-outline\"\n            />\n          </VCol>\n\n          <VCol cols=\"12\">\n            <VSwitch\n              v-model=\"wizardData.agent.retryTransfer\"\n              :label=\"t('setting.system.aiAgentRetryTransfer')\"\n              :hint=\"t('setting.system.aiAgentRetryTransferHint')\"\n              persistent-hint\n              color=\"primary\"\n            />\n          </VCol>\n\n          <VCol cols=\"12\">\n            <VSwitch\n              v-model=\"wizardData.agent.recommendEnabled\"\n              :label=\"t('setting.system.aiRecommendEnabled')\"\n              :hint=\"t('setting.system.aiRecommendEnabledHint')\"\n              persistent-hint\n              color=\"primary\"\n            />\n          </VCol>\n\n          <VCol v-if=\"wizardData.agent.recommendEnabled\" cols=\"12\" md=\"6\">\n            <VTextarea\n              v-model=\"wizardData.agent.recommendUserPreference\"\n              :label=\"t('setting.system.aiRecommendUserPreference')\"\n              :hint=\"t('setting.system.aiRecommendUserPreferenceHint')\"\n              persistent-hint\n              prepend-inner-icon=\"mdi-account-heart-outline\"\n              rows=\"2\"\n              auto-grow\n            />\n          </VCol>\n\n          <VCol v-if=\"wizardData.agent.recommendEnabled\" cols=\"12\" md=\"6\">\n            <VTextField\n              v-model.number=\"wizardData.agent.recommendMaxItems\"\n              :label=\"t('setting.system.aiRecommendMaxItems')\"\n              :hint=\"t('setting.system.aiRecommendMaxItemsHint')\"\n              :error=\"validationErrors.agent.recommendMaxItems\"\n              :error-messages=\"\n                validationErrors.agent.recommendMaxItems ? [t('setupWizard.agent.recommendMaxItemsRequired')] : []\n              \"\n              persistent-hint\n              prepend-inner-icon=\"mdi-format-list-numbered\"\n              type=\"number\"\n              min=\"1\"\n            />\n          </VCol>\n        </template>\n      </VRow>\n    </VCardText>\n  </VCard>\n</template>\n"
  },
  {
    "path": "src/views/setup/BasicSettingsStep.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useI18n } from 'vue-i18n'\nimport { useSetupWizard } from '@/composables/useSetupWizard'\n\nconst { t } = useI18n()\nconst { wizardData, createRandomString, copyValue, validateCurrentStep } = useSetupWizard()\n\n// 密码可见性控制\nconst isPasswordVisible = ref(false)\nconst isConfirmPasswordVisible = ref(false)\n\n// 验证状态\nconst validation = computed(() => validateCurrentStep())\nconst hasErrors = computed(() => !validation.value.isValid)\n\n// 密码相关验证\nconst passwordError = computed(() => {\n  if (!wizardData.value.basic.password) return false\n  return wizardData.value.basic.password.length < 6\n})\n\nconst confirmPasswordError = computed(() => {\n  if (!wizardData.value.basic.password) return false\n  if (!wizardData.value.basic.confirmPassword) return true\n  return wizardData.value.basic.password !== wizardData.value.basic.confirmPassword\n})\n\nconst passwordErrorMessage = computed(() => {\n  if (passwordError.value) return t('dialog.userAddEdit.passwordMinLength')\n  return ''\n})\n\nconst confirmPasswordErrorMessage = computed(() => {\n  if (!wizardData.value.basic.password) return ''\n  if (!wizardData.value.basic.confirmPassword) return t('dialog.userAddEdit.confirmPasswordRequired')\n  if (confirmPasswordError.value) return t('dialog.userAddEdit.passwordMismatch')\n  return ''\n})\n\n// API Token验证\nconst apiTokenError = computed(() => {\n  return !wizardData.value.basic.apiToken && hasErrors.value\n})\n\nconst apiTokenErrorMessage = computed(() => {\n  if (apiTokenError.value) return t('setupWizard.basic.apiTokenRequired')\n  return ''\n})\n\n// 用户名验证（虽然是只读的，但为了完整性）\nconst usernameError = computed(() => {\n  return !wizardData.value.basic.username && hasErrors.value\n})\n\nconst usernameErrorMessage = computed(() => {\n  if (usernameError.value) return t('dialog.userAddEdit.usernameRequired')\n  return ''\n})\n</script>\n\n<template>\n  <VCard variant=\"outlined\">\n    <VCardText>\n      <div class=\"text-center mb-6\">\n        <h3 class=\"text-h4 mb-2\">{{ t('setupWizard.basic.title') }}</h3>\n        <p class=\"text-body-1 text-medium-emphasis\">{{ t('setupWizard.basic.description') }}</p>\n      </div>\n      <VRow>\n        <VCol cols=\"12\" md=\"6\">\n          <VTextField\n            v-model=\"wizardData.basic.appDomain\"\n            :label=\"t('setupWizard.basic.appDomain')\"\n            :hint=\"t('setupWizard.basic.appDomainHint')\"\n            placeholder=\"http://localhost:3000\"\n            persistent-hint\n            prepend-inner-icon=\"mdi-web\"\n          />\n        </VCol>\n        <VCol cols=\"12\" md=\"6\">\n          <VTextField\n            v-model=\"wizardData.basic.username\"\n            :label=\"t('user.username')\"\n            :hint=\"t('setupWizard.basic.currentUserHint')\"\n            persistent-hint\n            prepend-inner-icon=\"mdi-account\"\n            readonly\n            :error=\"usernameError\"\n            :error-messages=\"usernameError ? [usernameErrorMessage] : []\"\n          />\n        </VCol>\n        <VCol cols=\"12\" md=\"6\">\n          <VTextField\n            v-model=\"wizardData.basic.password\"\n            :type=\"isPasswordVisible ? 'text' : 'password'\"\n            :label=\"t('user.password')\"\n            :hint=\"t('setupWizard.basic.passwordOptionalHint')\"\n            persistent-hint\n            prepend-inner-icon=\"mdi-lock\"\n            :append-inner-icon=\"isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'\"\n            @click:append-inner=\"isPasswordVisible = !isPasswordVisible\"\n            :error=\"passwordError\"\n            :error-messages=\"passwordError ? [passwordErrorMessage] : []\"\n            clearable\n          />\n        </VCol>\n        <VCol cols=\"12\" md=\"6\">\n          <VTextField\n            v-model=\"wizardData.basic.confirmPassword\"\n            :type=\"isConfirmPasswordVisible ? 'text' : 'password'\"\n            :label=\"t('user.confirmPassword')\"\n            :hint=\"t('setupWizard.basic.confirmPasswordHint')\"\n            persistent-hint\n            prepend-inner-icon=\"mdi-lock-check\"\n            :append-inner-icon=\"isConfirmPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'\"\n            @click:append-inner=\"isConfirmPasswordVisible = !isConfirmPasswordVisible\"\n            :disabled=\"!wizardData.basic.password\"\n            :error=\"confirmPasswordError\"\n            :error-messages=\"confirmPasswordError ? [confirmPasswordErrorMessage] : []\"\n            clearable\n          />\n        </VCol>\n        <VCol cols=\"12\" md=\"6\">\n          <VTextField\n            v-model=\"wizardData.basic.ocrHost\"\n            :label=\"t('setting.system.ocrHost')\"\n            :hint=\"t('setting.system.ocrHostHint')\"\n            placeholder=\"https://movie-pilot.org\"\n            persistent-hint\n            prepend-inner-icon=\"mdi-text-recognition\"\n          />\n        </VCol>\n        <VCol cols=\"12\" md=\"6\">\n          <VTextField\n            v-model=\"wizardData.basic.proxyHost\"\n            :label=\"t('setting.system.proxyHost')\"\n            :hint=\"t('setting.system.proxyHostHint')\"\n            placeholder=\"http://127.0.0.1:7890\"\n            persistent-hint\n            prepend-inner-icon=\"mdi-server-network\"\n          />\n        </VCol>\n        <VCol cols=\"12\" md=\"6\">\n          <VTextField\n            v-model=\"wizardData.basic.githubToken\"\n            :label=\"t('setting.system.githubToken')\"\n            :placeholder=\"t('setting.system.githubTokenFormat')\"\n            :hint=\"t('setting.system.githubTokenHint')\"\n            persistent-hint\n            prepend-inner-icon=\"mdi-github\"\n          />\n        </VCol>\n        <VCol cols=\"12\" md=\"6\">\n          <VTextField\n            v-model=\"wizardData.basic.apiToken\"\n            :label=\"t('setupWizard.basic.apiToken')\"\n            :hint=\"t('setupWizard.basic.apiTokenHint')\"\n            persistent-hint\n            prepend-inner-icon=\"mdi-key\"\n            :append-inner-icon=\"wizardData.basic.apiToken ? 'mdi-content-copy' : 'mdi-reload'\"\n            @click:append-inner=\"\n              wizardData.basic.apiToken ? copyValue(wizardData.basic.apiToken) : createRandomString()\n            \"\n            :error=\"apiTokenError\"\n            :error-messages=\"apiTokenError ? [apiTokenErrorMessage] : []\"\n          />\n        </VCol>\n      </VRow>\n    </VCardText>\n  </VCard>\n</template>\n"
  },
  {
    "path": "src/views/setup/ConnectivityTest.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useI18n } from 'vue-i18n'\nimport { useSetupWizard } from '@/composables/useSetupWizard'\n\nconst { t } = useI18n()\nconst { connectivityTest } = useSetupWizard()\n</script>\n\n<template>\n  <!-- 连通性测试进度条 -->\n  <VCard v-if=\"connectivityTest.isTesting || connectivityTest.showResult\" variant=\"outlined\" class=\"mx-4 mb-4\">\n    <VCardText class=\"text-center py-4\">\n      <!-- 测试中 -->\n      <div v-if=\"connectivityTest.isTesting\">\n        <VIcon icon=\"mdi-cog-sync\" class=\"rotating mb-2\" color=\"primary\" size=\"24\" />\n        <div class=\"text-body-2 mb-2\">{{ connectivityTest.testMessage }}</div>\n        <VProgressLinear\n          v-model=\"connectivityTest.testProgress\"\n          color=\"primary\"\n          height=\"6\"\n          rounded\n          class=\"mb-2\"\n        />\n        <div class=\"text-caption text-medium-emphasis\">{{ Math.round(connectivityTest.testProgress) }}%</div>\n      </div>\n\n      <!-- 测试结果 -->\n      <div v-else-if=\"connectivityTest.showResult\">\n        <VIcon\n          :icon=\"connectivityTest.testResult === 'success' ? 'mdi-check-circle' : 'mdi-alert-circle'\"\n          :color=\"connectivityTest.testResult === 'success' ? 'success' : 'error'\"\n          size=\"24\"\n          class=\"mb-2\"\n        />\n        <div\n          :class=\"connectivityTest.testResult === 'success' ? 'text-success' : 'text-error'\"\n          class=\"text-body-2 mb-2 font-weight-medium\"\n        >\n          {{ connectivityTest.testMessage }}\n        </div>\n        <div v-if=\"connectivityTest.testResult === 'error'\" class=\"text-caption text-medium-emphasis\">\n          {{ t('setupWizard.testFailedHint') }}\n        </div>\n      </div>\n    </VCardText>\n  </VCard>\n</template>\n\n<style scoped>\n/* 旋转动画 */\n.rotating {\n  animation: rotate 2s linear infinite;\n}\n\n@keyframes rotate {\n  from {\n    transform: rotate(0deg);\n  }\n\n  to {\n    transform: rotate(360deg);\n  }\n}\n</style>"
  },
  {
    "path": "src/views/setup/DownloaderSettingsStep.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useI18n } from 'vue-i18n'\nimport { useSetupWizard } from '@/composables/useSetupWizard'\nimport { getLogoUrl } from '@/utils/imageUtils'\n\nconst { t } = useI18n()\nconst { wizardData, selectDownloader, validationErrors } = useSetupWizard()\n</script>\n\n<template>\n  <VCard variant=\"outlined\">\n    <VCardText>\n      <div class=\"text-center mb-6\">\n        <h3 class=\"text-h4 mb-2\">{{ t('setupWizard.downloader.title') }}</h3>\n        <p class=\"text-body-1 text-medium-emphasis\">{{ t('setupWizard.downloader.description') }}</p>\n      </div>\n      <VRow>\n        <VCol cols=\"12\">\n          <VAlert type=\"info\" variant=\"tonal\" class=\"mb-4\">\n            <VAlertTitle>{{ t('setupWizard.downloader.info') }}</VAlertTitle>\n            {{ t('setupWizard.downloader.infoDesc') }}\n          </VAlert>\n        </VCol>\n\n        <!-- 下载器选择 -->\n        <VCol cols=\"12\">\n          <div class=\"mb-4\">\n            <h4 class=\"text-h6 mb-4\">{{ t('setupWizard.downloader.type') }}</h4>\n            <VRow>\n              <VCol cols=\"12\" md=\"4\">\n                <VCard\n                  :color=\"wizardData.downloader.type === 'qbittorrent' ? 'primary' : 'default'\"\n                  :variant=\"wizardData.downloader.type === 'qbittorrent' ? 'tonal' : 'outlined'\"\n                  class=\"cursor-pointer\"\n                  @click=\"selectDownloader('qbittorrent')\"\n                >\n                  <VCardText class=\"text-center\">\n                    <VImg :src=\"getLogoUrl('qbittorrent')\" height=\"48\" width=\"48\" class=\"mx-auto mb-2\" />\n                    <div class=\"text-h6\">qBittorrent</div>\n                  </VCardText>\n                </VCard>\n              </VCol>\n              <VCol cols=\"12\" md=\"4\">\n                <VCard\n                  :color=\"wizardData.downloader.type === 'transmission' ? 'primary' : 'default'\"\n                  :variant=\"wizardData.downloader.type === 'transmission' ? 'tonal' : 'outlined'\"\n                  class=\"cursor-pointer\"\n                  @click=\"selectDownloader('transmission')\"\n                >\n                  <VCardText class=\"text-center\">\n                    <VImg :src=\"getLogoUrl('transmission')\" height=\"48\" width=\"48\" class=\"mx-auto mb-2\" />\n                    <div class=\"text-h6\">Transmission</div>\n                  </VCardText>\n                </VCard>\n              </VCol>\n              <VCol cols=\"12\" md=\"4\">\n                <VCard\n                  :color=\"wizardData.downloader.type === 'rtorrent' ? 'primary' : 'default'\"\n                  :variant=\"wizardData.downloader.type === 'rtorrent' ? 'tonal' : 'outlined'\"\n                  class=\"cursor-pointer\"\n                  @click=\"selectDownloader('rtorrent')\"\n                >\n                  <VCardText class=\"text-center\">\n                    <VImg :src=\"getLogoUrl('rtorrent')\" height=\"48\" width=\"48\" class=\"mx-auto mb-2\" />\n                    <div class=\"text-h6\">rTorrent</div>\n                  </VCardText>\n                </VCard>\n              </VCol>\n            </VRow>\n          </div>\n        </VCol>\n\n        <!-- 下载器配置 -->\n        <VCol v-if=\"wizardData.downloader.type\" cols=\"12\">\n          <VCard>\n            <VCardText>\n              <VForm>\n                <VRow v-if=\"wizardData.downloader.type === 'qbittorrent'\">\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.downloader.name\"\n                      :label=\"t('downloader.name')\"\n                      :placeholder=\"t('downloader.nameRequired')\"\n                      :hint=\"t('downloader.name')\"\n                      :error=\"validationErrors.downloader.name\"\n                      :error-messages=\"validationErrors.downloader.name ? [t('downloader.nameRequired')] : []\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-label\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.downloader.config.host\"\n                      :label=\"t('downloader.host')\"\n                      placeholder=\"http(s)://ip:port\"\n                      :hint=\"t('downloader.host')\"\n                      :error=\"validationErrors.downloader.host\"\n                      :error-messages=\"validationErrors.downloader.host ? [t('downloader.hostRequired')] : []\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-server\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.downloader.config.username\"\n                      :label=\"t('downloader.username')\"\n                      :hint=\"t('downloader.username')\"\n                      :error=\"validationErrors.downloader.username\"\n                      :error-messages=\"validationErrors.downloader.username ? [t('downloader.usernameRequired')] : []\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-account\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.downloader.config.password\"\n                      type=\"password\"\n                      :label=\"t('downloader.password')\"\n                      :hint=\"t('downloader.password')\"\n                      :error=\"validationErrors.downloader.password\"\n                      :error-messages=\"validationErrors.downloader.password ? [t('downloader.passwordRequired')] : []\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-lock\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VSwitch\n                      v-model=\"wizardData.downloader.config.sequentail\"\n                      :label=\"t('downloader.sequentail')\"\n                      :hint=\"t('downloader.sequentail')\"\n                      persistent-hint\n                      active\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VSwitch\n                      v-model=\"wizardData.downloader.config.force_resume\"\n                      :label=\"t('downloader.force_resume')\"\n                      :hint=\"t('downloader.force_resume')\"\n                      persistent-hint\n                      active\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VSwitch\n                      v-model=\"wizardData.downloader.config.first_last_piece\"\n                      :label=\"t('downloader.first_last_piece')\"\n                      :hint=\"t('downloader.first_last_piece')\"\n                      persistent-hint\n                      active\n                    />\n                  </VCol>\n                </VRow>\n                <VRow v-else-if=\"wizardData.downloader.type === 'transmission'\">\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.downloader.name\"\n                      :label=\"t('downloader.name')\"\n                      :placeholder=\"t('downloader.nameRequired')\"\n                      :hint=\"t('downloader.name')\"\n                      :error=\"validationErrors.downloader.name\"\n                      :error-messages=\"validationErrors.downloader.name ? [t('downloader.nameRequired')] : []\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-label\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.downloader.config.host\"\n                      :label=\"t('downloader.host')\"\n                      placeholder=\"http(s)://ip:port\"\n                      :hint=\"t('downloader.host')\"\n                      :error=\"validationErrors.downloader.host\"\n                      :error-messages=\"validationErrors.downloader.host ? [t('downloader.hostRequired')] : []\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-server\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.downloader.config.username\"\n                      :label=\"t('downloader.username')\"\n                      :hint=\"t('downloader.username')\"\n                      :error=\"validationErrors.downloader.username\"\n                      :error-messages=\"validationErrors.downloader.username ? [t('downloader.usernameRequired')] : []\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-account\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.downloader.config.password\"\n                      type=\"password\"\n                      :label=\"t('downloader.password')\"\n                      :hint=\"t('downloader.password')\"\n                      :error=\"validationErrors.downloader.password\"\n                      :error-messages=\"validationErrors.downloader.password ? [t('downloader.passwordRequired')] : []\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-lock\"\n                      required\n                    />\n                  </VCol>\n                </VRow>\n                <VRow v-else-if=\"wizardData.downloader.type === 'rtorrent'\">\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.downloader.name\"\n                      :label=\"t('downloader.name')\"\n                      :placeholder=\"t('downloader.nameRequired')\"\n                      :hint=\"t('downloader.name')\"\n                      :error=\"validationErrors.downloader.name\"\n                      :error-messages=\"validationErrors.downloader.name ? [t('downloader.nameRequired')] : []\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-label\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.downloader.config.host\"\n                      :label=\"t('downloader.host')\"\n                      placeholder=\"http(s)://ip:port/RPC2\"\n                      :hint=\"t('downloader.rtorrentHostHint')\"\n                      :error=\"validationErrors.downloader.host\"\n                      :error-messages=\"validationErrors.downloader.host ? [t('downloader.hostRequired')] : []\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-server\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.downloader.config.username\"\n                      :label=\"t('downloader.username')\"\n                      :hint=\"t('downloader.username')\"\n                      :error=\"validationErrors.downloader.username\"\n                      :error-messages=\"validationErrors.downloader.username ? [t('downloader.usernameRequired')] : []\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-account\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.downloader.config.password\"\n                      type=\"password\"\n                      :label=\"t('downloader.password')\"\n                      :hint=\"t('downloader.password')\"\n                      :error=\"validationErrors.downloader.password\"\n                      :error-messages=\"validationErrors.downloader.password ? [t('downloader.passwordRequired')] : []\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-lock\"\n                      required\n                    />\n                  </VCol>\n                </VRow>\n                <VRow v-else>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.downloader.type\"\n                      :label=\"t('downloader.type')\"\n                      :hint=\"t('downloader.customTypeHint')\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-cog\"\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.downloader.name\"\n                      :label=\"t('downloader.name')\"\n                      :hint=\"t('downloader.nameRequired')\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-label\"\n                    />\n                  </VCol>\n                </VRow>\n              </VForm>\n            </VCardText>\n          </VCard>\n        </VCol>\n      </VRow>\n    </VCardText>\n  </VCard>\n</template>\n\n<style scoped>\n.cursor-pointer {\n  cursor: pointer;\n  transition: all 0.2s ease;\n}\n\n.cursor-pointer:hover {\n  box-shadow: 0 4px 12px rgba(0, 0, 0, 15%);\n  transform: translateY(-2px);\n}\n\n.cursor-pointer:active {\n  transform: translateY(0);\n}\n\n/* 选中状态的样式 */\n.v-card--variant-tonal.v-theme--light {\n  border: 2px solid rgb(var(--v-theme-primary));\n  background-color: rgb(var(--v-theme-primary), 0.12);\n}\n\n.v-card--variant-tonal.v-theme--dark {\n  border: 2px solid rgb(var(--v-theme-primary));\n  background-color: rgb(var(--v-theme-primary), 0.2);\n}\n</style>\n"
  },
  {
    "path": "src/views/setup/MediaServerSettingsStep.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useI18n } from 'vue-i18n'\nimport { useSetupWizard } from '@/composables/useSetupWizard'\nimport api from '@/api'\nimport { getLogoUrl } from '@/utils/imageUtils'\n\nconst { t } = useI18n()\nconst { wizardData, selectMediaServer, validationErrors } = useSetupWizard()\n\n// 同步媒体库选项\nconst librariesOptions = ref<{ title: string; value: string | undefined }[]>([\n  {\n    title: t('common.all'),\n    value: 'all',\n  },\n])\n\nconst ugreenScanModeOptions = computed(() => [\n  { title: t('mediaserver.scanModeOptions.newAndModified'), value: 'new_and_modified' },\n  { title: t('mediaserver.scanModeOptions.supplementMissing'), value: 'supplement_missing' },\n  { title: t('mediaserver.scanModeOptions.fullOverride'), value: 'full_override' },\n])\n\nfunction ensureUgreenConfig() {\n  if (wizardData.value.mediaServer.type !== 'ugreen') return\n  wizardData.value.mediaServer.config = wizardData.value.mediaServer.config || {}\n  if (!wizardData.value.mediaServer.config.scan_mode) {\n    wizardData.value.mediaServer.config.scan_mode = 'supplement_missing'\n  }\n  if (wizardData.value.mediaServer.config.verify_ssl === undefined) {\n    wizardData.value.mediaServer.config.verify_ssl = true\n  }\n}\n\n// 调用API查询媒体库\nasync function loadLibrary(server: string) {\n  try {\n    console.log('Loading library for server:', server)\n    const result: any[] = await api.get('mediaserver/library', { params: { server } })\n    if (result && result.length > 0) {\n      librariesOptions.value = result.map(item => ({\n        title: item.name,\n        value: item.id?.toString(),\n      }))\n      console.log('Loaded libraries:', librariesOptions.value)\n    } else {\n      librariesOptions.value = []\n      console.log('No libraries found')\n    }\n    librariesOptions.value.unshift({\n      title: t('common.all'),\n      value: 'all',\n    })\n  } catch (e) {\n    console.log('Error loading library:', e)\n  }\n}\n\n// 选择媒体服务器并自动加载媒体库\nasync function selectMediaServerWithLibrary(type: string) {\n  selectMediaServer(type)\n  ensureUgreenConfig()\n  // 如果选择了媒体服务器类型，自动加载媒体库\n  if (type && wizardData.value.mediaServer.name) {\n    await loadLibrary(wizardData.value.mediaServer.name)\n  }\n}\n\n// 组件挂载时检查是否需要加载媒体库\nonMounted(async () => {\n  ensureUgreenConfig()\n  // 如果已经有媒体服务器配置，自动加载媒体库\n  if (wizardData.value.mediaServer.type && wizardData.value.mediaServer.name) {\n    await loadLibrary(wizardData.value.mediaServer.name)\n  }\n})\n\n// 监听媒体服务器配置变化，自动加载媒体库\nwatch(\n  () => [wizardData.value.mediaServer.type, wizardData.value.mediaServer.name],\n  async ([type, name]) => {\n    ensureUgreenConfig()\n    console.log('Media server changed:', { type, name })\n    if (type && name) {\n      await loadLibrary(name)\n    }\n  },\n  { immediate: true },\n)\n</script>\n\n<template>\n  <VCard variant=\"outlined\">\n    <VCardText>\n      <div class=\"text-center mb-6\">\n        <h3 class=\"text-h4 mb-2\">{{ t('setupWizard.mediaServer.title') }}</h3>\n        <p class=\"text-body-1 text-medium-emphasis\">{{ t('setupWizard.mediaServer.description') }}</p>\n      </div>\n      <VRow>\n        <VCol cols=\"12\">\n          <VAlert type=\"info\" variant=\"tonal\" class=\"mb-4\">\n            <VAlertTitle>{{ t('setupWizard.mediaServer.info') }}</VAlertTitle>\n            {{ t('setupWizard.mediaServer.infoDesc') }}\n          </VAlert>\n        </VCol>\n\n        <!-- 媒体服务器选择 -->\n        <VCol cols=\"12\">\n          <div class=\"mb-4\">\n            <h4 class=\"text-h6 mb-4\">{{ t('setupWizard.mediaServer.type') }}</h4>\n            <VRow>\n              <VCol cols=\"12\" md=\"3\">\n                <VCard\n                  :color=\"wizardData.mediaServer.type === 'emby' ? 'primary' : 'default'\"\n                  :variant=\"wizardData.mediaServer.type === 'emby' ? 'tonal' : 'outlined'\"\n                  class=\"cursor-pointer\"\n                  @click=\"selectMediaServerWithLibrary('emby')\"\n                >\n                  <VCardText class=\"text-center\">\n                    <VImg :src=\"getLogoUrl('emby')\" height=\"48\" width=\"48\" class=\"mx-auto mb-2\" />\n                    <div class=\"text-h6\">Emby</div>\n                  </VCardText>\n                </VCard>\n              </VCol>\n              <VCol cols=\"12\" md=\"3\">\n                <VCard\n                  :color=\"wizardData.mediaServer.type === 'jellyfin' ? 'primary' : 'default'\"\n                  :variant=\"wizardData.mediaServer.type === 'jellyfin' ? 'tonal' : 'outlined'\"\n                  class=\"cursor-pointer\"\n                  @click=\"selectMediaServerWithLibrary('jellyfin')\"\n                >\n                  <VCardText class=\"text-center\">\n                    <VImg :src=\"getLogoUrl('jellyfin')\" height=\"48\" width=\"48\" class=\"mx-auto mb-2\" />\n                    <div class=\"text-h6\">Jellyfin</div>\n                  </VCardText>\n                </VCard>\n              </VCol>\n              <VCol cols=\"12\" md=\"3\">\n                <VCard\n                  :color=\"wizardData.mediaServer.type === 'plex' ? 'primary' : 'default'\"\n                  :variant=\"wizardData.mediaServer.type === 'plex' ? 'tonal' : 'outlined'\"\n                  class=\"cursor-pointer\"\n                  @click=\"selectMediaServerWithLibrary('plex')\"\n                >\n                  <VCardText class=\"text-center\">\n                    <VImg :src=\"getLogoUrl('plex')\" height=\"48\" width=\"48\" class=\"mx-auto mb-2\" />\n                    <div class=\"text-h6\">Plex</div>\n                  </VCardText>\n                </VCard>\n              </VCol>\n              <VCol cols=\"12\" md=\"3\">\n                <VCard\n                  :color=\"wizardData.mediaServer.type === 'trimemedia' ? 'primary' : 'default'\"\n                  :variant=\"wizardData.mediaServer.type === 'trimemedia' ? 'tonal' : 'outlined'\"\n                  class=\"cursor-pointer\"\n                  @click=\"selectMediaServerWithLibrary('trimemedia')\"\n                >\n                  <VCardText class=\"text-center\">\n                    <VImg :src=\"getLogoUrl('trimemedia')\" height=\"48\" width=\"48\" class=\"mx-auto mb-2\" />\n                    <div class=\"text-h6\">飞牛影视</div>\n                  </VCardText>\n                </VCard>\n              </VCol>\n              <VCol cols=\"12\" md=\"3\">\n                <VCard\n                  :color=\"wizardData.mediaServer.type === 'ugreen' ? 'primary' : 'default'\"\n                  :variant=\"wizardData.mediaServer.type === 'ugreen' ? 'tonal' : 'outlined'\"\n                  class=\"cursor-pointer\"\n                  @click=\"selectMediaServerWithLibrary('ugreen')\"\n                >\n                  <VCardText class=\"text-center\">\n                    <VImg :src=\"getLogoUrl('ugreen')\" height=\"48\" width=\"48\" class=\"mx-auto mb-2\" />\n                    <div class=\"text-h6\">绿联影视</div>\n                  </VCardText>\n                </VCard>\n              </VCol>\n            </VRow>\n          </div>\n        </VCol>\n\n        <!-- 媒体服务器配置 -->\n        <VCol v-if=\"wizardData.mediaServer.type\" cols=\"12\">\n          <VCard>\n            <VCardText>\n              <VForm>\n                <VRow v-if=\"wizardData.mediaServer.type === 'emby'\">\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.mediaServer.name\"\n                      :label=\"t('common.name')\"\n                      :placeholder=\"t('mediaserver.nameRequired')\"\n                      :hint=\"t('mediaserver.serverAlias')\"\n                      :error=\"validationErrors.mediaServer.name\"\n                      :error-messages=\"validationErrors.mediaServer.name ? [t('mediaserver.nameRequired')] : []\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-label\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.mediaServer.config.host\"\n                      :label=\"t('mediaserver.host')\"\n                      :placeholder=\"t('mediaserver.hostPlaceholder')\"\n                      :hint=\"t('mediaserver.hostHint')\"\n                      :error=\"validationErrors.mediaServer.host\"\n                      :error-messages=\"validationErrors.mediaServer.host ? [t('mediaserver.hostRequired')] : []\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-server\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.mediaServer.config.play_host\"\n                      :label=\"t('mediaserver.playHost')\"\n                      :placeholder=\"t('mediaserver.playHostPlaceholder')\"\n                      :hint=\"t('mediaserver.playHostHint')\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-play-network\"\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.mediaServer.config.username\"\n                      :label=\"t('mediaserver.username')\"\n                      :hint=\"t('mediaserver.usernameHint')\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-account\"\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.mediaServer.config.apikey\"\n                      :label=\"t('mediaserver.apiKey')\"\n                      :hint=\"t('mediaserver.embyApiKeyHint')\"\n                      :error=\"validationErrors.mediaServer.apikey\"\n                      :error-messages=\"validationErrors.mediaServer.apikey ? [t('mediaserver.apiKeyRequired')] : []\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-key\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\">\n                    <VAutocomplete\n                      v-model=\"wizardData.mediaServer.sync_libraries\"\n                      :label=\"t('mediaserver.syncLibraries')\"\n                      :items=\"librariesOptions\"\n                      chips\n                      multiple\n                      clearable\n                      :hint=\"t('mediaserver.syncLibrariesHint')\"\n                      persistent-hint\n                      active\n                      append-inner-icon=\"mdi-refresh\"\n                      prepend-inner-icon=\"mdi-library\"\n                      @click:append-inner=\"loadLibrary(wizardData.mediaServer.name)\"\n                    />\n                  </VCol>\n                </VRow>\n                <VRow v-else-if=\"wizardData.mediaServer.type === 'jellyfin'\">\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.mediaServer.name\"\n                      :label=\"t('common.name')\"\n                      :placeholder=\"t('mediaserver.nameRequired')\"\n                      :hint=\"t('mediaserver.serverAlias')\"\n                      :error=\"validationErrors.mediaServer.name\"\n                      :error-messages=\"validationErrors.mediaServer.name ? [t('mediaserver.nameRequired')] : []\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-label\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.mediaServer.config.host\"\n                      :label=\"t('mediaserver.host')\"\n                      :placeholder=\"t('mediaserver.hostPlaceholder')\"\n                      :hint=\"t('mediaserver.hostHint')\"\n                      :error=\"validationErrors.mediaServer.host\"\n                      :error-messages=\"validationErrors.mediaServer.host ? [t('mediaserver.hostRequired')] : []\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-server\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.mediaServer.config.play_host\"\n                      :label=\"t('mediaserver.playHost')\"\n                      :placeholder=\"t('mediaserver.playHostPlaceholder')\"\n                      :hint=\"t('mediaserver.playHostHint')\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-play-network\"\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.mediaServer.config.apikey\"\n                      :label=\"t('mediaserver.apiKey')\"\n                      :hint=\"t('mediaserver.jellyfinApiKeyHint')\"\n                      :error=\"validationErrors.mediaServer.apikey\"\n                      :error-messages=\"validationErrors.mediaServer.apikey ? [t('mediaserver.apiKeyRequired')] : []\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-key\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\">\n                    <VAutocomplete\n                      v-model=\"wizardData.mediaServer.sync_libraries\"\n                      :label=\"t('mediaserver.syncLibraries')\"\n                      :items=\"librariesOptions\"\n                      chips\n                      multiple\n                      clearable\n                      :hint=\"t('mediaserver.syncLibrariesHint')\"\n                      persistent-hint\n                      active\n                      append-inner-icon=\"mdi-refresh\"\n                      prepend-inner-icon=\"mdi-library\"\n                      @click:append-inner=\"loadLibrary(wizardData.mediaServer.name)\"\n                    />\n                  </VCol>\n                </VRow>\n                <VRow v-else-if=\"wizardData.mediaServer.type === 'trimemedia'\">\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.mediaServer.name\"\n                      :label=\"t('common.name')\"\n                      :placeholder=\"t('mediaserver.nameRequired')\"\n                      :hint=\"t('mediaserver.serverAlias')\"\n                      :error=\"validationErrors.mediaServer.name\"\n                      :error-messages=\"validationErrors.mediaServer.name ? [t('mediaserver.nameRequired')] : []\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-label\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.mediaServer.config.host\"\n                      :label=\"t('mediaserver.host')\"\n                      :placeholder=\"t('mediaserver.hostPlaceholder')\"\n                      :hint=\"t('mediaserver.hostHint')\"\n                      :error=\"validationErrors.mediaServer.host\"\n                      :error-messages=\"validationErrors.mediaServer.host ? [t('mediaserver.hostRequired')] : []\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-server\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\">\n                    <VTextField\n                      v-model=\"wizardData.mediaServer.config.play_host\"\n                      :label=\"t('mediaserver.playHost')\"\n                      :placeholder=\"t('mediaserver.playHostPlaceholder')\"\n                      :hint=\"t('mediaserver.playHostHint')\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-play-network\"\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.mediaServer.config.username\"\n                      :label=\"t('mediaserver.username')\"\n                      :error=\"validationErrors.mediaServer.username\"\n                      :error-messages=\"validationErrors.mediaServer.username ? [t('mediaserver.usernameRequired')] : []\"\n                      active\n                      prepend-inner-icon=\"mdi-account\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      type=\"password\"\n                      v-model=\"wizardData.mediaServer.config.password\"\n                      :label=\"t('mediaserver.password')\"\n                      :error=\"validationErrors.mediaServer.password\"\n                      :error-messages=\"validationErrors.mediaServer.password ? [t('mediaserver.passwordRequired')] : []\"\n                      active\n                      prepend-inner-icon=\"mdi-lock\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\">\n                    <VAutocomplete\n                      v-model=\"wizardData.mediaServer.sync_libraries\"\n                      :label=\"t('mediaserver.syncLibraries')\"\n                      :items=\"librariesOptions\"\n                      chips\n                      multiple\n                      clearable\n                      :hint=\"t('mediaserver.syncLibrariesHint')\"\n                      persistent-hint\n                      active\n                      append-inner-icon=\"mdi-refresh\"\n                      prepend-inner-icon=\"mdi-library\"\n                      @click:append-inner=\"loadLibrary(wizardData.mediaServer.name)\"\n                    />\n                  </VCol>\n                </VRow>\n                <VRow v-else-if=\"wizardData.mediaServer.type === 'ugreen'\">\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.mediaServer.name\"\n                      :label=\"t('common.name')\"\n                      :placeholder=\"t('mediaserver.nameRequired')\"\n                      :hint=\"t('mediaserver.serverAlias')\"\n                      :error=\"validationErrors.mediaServer.name\"\n                      :error-messages=\"validationErrors.mediaServer.name ? [t('mediaserver.nameRequired')] : []\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-label\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.mediaServer.config.host\"\n                      :label=\"t('mediaserver.host')\"\n                      :placeholder=\"t('mediaserver.hostPlaceholder')\"\n                      :hint=\"t('mediaserver.hostHint')\"\n                      :error=\"validationErrors.mediaServer.host\"\n                      :error-messages=\"validationErrors.mediaServer.host ? [t('mediaserver.hostRequired')] : []\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-server\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\">\n                    <VTextField\n                      v-model=\"wizardData.mediaServer.config.play_host\"\n                      :label=\"t('mediaserver.playHost')\"\n                      :placeholder=\"t('mediaserver.playHostPlaceholder')\"\n                      :hint=\"t('mediaserver.playHostHint')\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-play-network\"\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.mediaServer.config.username\"\n                      :label=\"t('mediaserver.username')\"\n                      :error=\"validationErrors.mediaServer.username\"\n                      :error-messages=\"validationErrors.mediaServer.username ? [t('mediaserver.usernameRequired')] : []\"\n                      active\n                      prepend-inner-icon=\"mdi-account\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      type=\"password\"\n                      v-model=\"wizardData.mediaServer.config.password\"\n                      :label=\"t('mediaserver.password')\"\n                      :error=\"validationErrors.mediaServer.password\"\n                      :error-messages=\"validationErrors.mediaServer.password ? [t('mediaserver.passwordRequired')] : []\"\n                      active\n                      prepend-inner-icon=\"mdi-lock\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\">\n                    <VAutocomplete\n                      v-model=\"wizardData.mediaServer.sync_libraries\"\n                      :label=\"t('mediaserver.syncLibraries')\"\n                      :items=\"librariesOptions\"\n                      chips\n                      multiple\n                      clearable\n                      :hint=\"t('mediaserver.syncLibrariesHint')\"\n                      persistent-hint\n                      active\n                      append-inner-icon=\"mdi-refresh\"\n                      prepend-inner-icon=\"mdi-library\"\n                      @click:append-inner=\"loadLibrary(wizardData.mediaServer.name)\"\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VSelect\n                      v-model=\"wizardData.mediaServer.config.scan_mode\"\n                      :label=\"t('mediaserver.scanMode')\"\n                      :items=\"ugreenScanModeOptions\"\n                      :hint=\"t('mediaserver.scanModeHint')\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-radar\"\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VSwitch\n                      v-model=\"wizardData.mediaServer.config.verify_ssl\"\n                      :label=\"t('mediaserver.verifySsl')\"\n                      :hint=\"t('mediaserver.verifySslHint')\"\n                      persistent-hint\n                      color=\"primary\"\n                      inset\n                    />\n                  </VCol>\n                </VRow>\n                <VRow v-else-if=\"wizardData.mediaServer.type === 'plex'\">\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.mediaServer.name\"\n                      :label=\"t('common.name')\"\n                      :placeholder=\"t('mediaserver.nameRequired')\"\n                      :hint=\"t('mediaserver.serverAlias')\"\n                      :error=\"validationErrors.mediaServer.name\"\n                      :error-messages=\"validationErrors.mediaServer.name ? [t('mediaserver.nameRequired')] : []\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-label\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.mediaServer.config.host\"\n                      :label=\"t('mediaserver.host')\"\n                      :placeholder=\"t('mediaserver.hostPlaceholder')\"\n                      :hint=\"t('mediaserver.hostHint')\"\n                      :error=\"validationErrors.mediaServer.host\"\n                      :error-messages=\"validationErrors.mediaServer.host ? [t('mediaserver.hostRequired')] : []\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-server\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.mediaServer.config.play_host\"\n                      :label=\"t('mediaserver.playHost')\"\n                      :placeholder=\"t('mediaserver.playHostPlaceholder')\"\n                      :hint=\"t('mediaserver.playHostHint')\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-play-network\"\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.mediaServer.config.token\"\n                      :label=\"t('mediaserver.plexToken')\"\n                      :hint=\"t('mediaserver.plexTokenHint')\"\n                      :error=\"validationErrors.mediaServer.token\"\n                      :error-messages=\"validationErrors.mediaServer.token ? [t('mediaserver.tokenRequired')] : []\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-key\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\">\n                    <VAutocomplete\n                      v-model=\"wizardData.mediaServer.sync_libraries\"\n                      :label=\"t('mediaserver.syncLibraries')\"\n                      :items=\"librariesOptions\"\n                      chips\n                      multiple\n                      clearable\n                      :hint=\"t('mediaserver.syncLibrariesHint')\"\n                      persistent-hint\n                      active\n                      append-inner-icon=\"mdi-refresh\"\n                      prepend-inner-icon=\"mdi-library\"\n                      @click:append-inner=\"loadLibrary(wizardData.mediaServer.name)\"\n                    />\n                  </VCol>\n                </VRow>\n                <VRow v-else>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.mediaServer.type\"\n                      :label=\"t('mediaserver.type')\"\n                      :hint=\"t('mediaserver.customTypeHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-cog\"\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.mediaServer.name\"\n                      :label=\"t('common.name')\"\n                      :hint=\"t('mediaserver.nameRequired')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-label\"\n                    />\n                  </VCol>\n                </VRow>\n              </VForm>\n            </VCardText>\n          </VCard>\n        </VCol>\n      </VRow>\n    </VCardText>\n  </VCard>\n</template>\n\n<style scoped>\n.cursor-pointer {\n  cursor: pointer;\n  transition: all 0.2s ease;\n}\n\n.cursor-pointer:hover {\n  box-shadow: 0 4px 12px rgba(0, 0, 0, 15%);\n  transform: translateY(-2px);\n}\n\n.cursor-pointer:active {\n  transform: translateY(0);\n}\n\n/* 选中状态的样式 */\n.v-card--variant-tonal.v-theme--light {\n  border: 2px solid rgb(var(--v-theme-primary));\n  background-color: rgb(var(--v-theme-primary), 0.12);\n}\n\n.v-card--variant-tonal.v-theme--dark {\n  border: 2px solid rgb(var(--v-theme-primary));\n  background-color: rgb(var(--v-theme-primary), 0.2);\n}\n</style>\n"
  },
  {
    "path": "src/views/setup/NotificationSettingsStep.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useI18n } from 'vue-i18n'\nimport { useSetupWizard } from '@/composables/useSetupWizard'\nimport { getLogoUrl } from '@/utils/imageUtils'\n\nconst { t } = useI18n()\nconst { wizardData, selectNotification, validationErrors } = useSetupWizard()\n\n// 消息类型下拉字典\nconst notificationTypes = [\n  { value: '资源下载', title: t('notificationSwitch.resourceDownload') },\n  { value: '整理入库', title: t('notificationSwitch.organize') },\n  { value: '订阅', title: t('notificationSwitch.subscribe') },\n  { value: '站点', title: t('notificationSwitch.site') },\n  { value: '媒体服务器', title: t('notificationSwitch.mediaServer') },\n  { value: '手动处理', title: t('notificationSwitch.manual') },\n  { value: '插件', title: t('notificationSwitch.plugin') },\n  { value: '智能体', title: t('notificationSwitch.agent') },\n  { value: '其它', title: t('notificationSwitch.other') },\n]\n</script>\n\n<template>\n  <VCard variant=\"outlined\">\n    <VCardText>\n      <div class=\"text-center mb-6\">\n        <h3 class=\"text-h4 mb-2\">{{ t('setupWizard.notification.title') }}</h3>\n        <p class=\"text-body-1 text-medium-emphasis\">{{ t('setupWizard.notification.description') }}</p>\n      </div>\n      <VRow>\n        <VCol cols=\"12\">\n          <VAlert type=\"info\" variant=\"tonal\" class=\"mb-4\">\n            <VAlertTitle>{{ t('setupWizard.notification.info') }}</VAlertTitle>\n            {{ t('setupWizard.notification.infoDesc') }}\n          </VAlert>\n        </VCol>\n\n        <!-- 通知选择 -->\n        <VCol cols=\"12\">\n          <div class=\"mb-4\">\n            <h4 class=\"text-h6 mb-4\">{{ t('setupWizard.notification.type') }}</h4>\n            <VRow>\n              <VCol cols=\"12\" md=\"3\">\n                <VCard\n                  :color=\"wizardData.notification.type === 'wechat' ? 'primary' : 'default'\"\n                  :variant=\"wizardData.notification.type === 'wechat' ? 'tonal' : 'outlined'\"\n                  class=\"cursor-pointer\"\n                  @click=\"selectNotification('wechat')\"\n                >\n                  <VCardText class=\"text-center\">\n                    <VImg :src=\"getLogoUrl('wechat')\" height=\"48\" width=\"48\" class=\"mx-auto mb-2\" />\n                    <div class=\"text-h6\">微信</div>\n                  </VCardText>\n                </VCard>\n              </VCol>\n              <VCol cols=\"12\" md=\"3\">\n                <VCard\n                  :color=\"wizardData.notification.type === 'telegram' ? 'primary' : 'default'\"\n                  :variant=\"wizardData.notification.type === 'telegram' ? 'tonal' : 'outlined'\"\n                  class=\"cursor-pointer\"\n                  @click=\"selectNotification('telegram')\"\n                >\n                  <VCardText class=\"text-center\">\n                    <VImg :src=\"getLogoUrl('telegram')\" height=\"48\" width=\"48\" class=\"mx-auto mb-2\" />\n                    <div class=\"text-h6\">Telegram</div>\n                  </VCardText>\n                </VCard>\n              </VCol>\n              <VCol cols=\"12\" md=\"3\">\n                <VCard\n                  :color=\"wizardData.notification.type === 'slack' ? 'primary' : 'default'\"\n                  :variant=\"wizardData.notification.type === 'slack' ? 'tonal' : 'outlined'\"\n                  class=\"cursor-pointer\"\n                  @click=\"selectNotification('slack')\"\n                >\n                  <VCardText class=\"text-center\">\n                    <VImg :src=\"getLogoUrl('slack')\" height=\"48\" width=\"48\" class=\"mx-auto mb-2\" />\n                    <div class=\"text-h6\">Slack</div>\n                  </VCardText>\n                </VCard>\n              </VCol>\n              <VCol cols=\"12\" md=\"3\">\n                <VCard\n                  :color=\"wizardData.notification.type === 'synologychat' ? 'primary' : 'default'\"\n                  :variant=\"wizardData.notification.type === 'synologychat' ? 'tonal' : 'outlined'\"\n                  class=\"cursor-pointer\"\n                  @click=\"selectNotification('synologychat')\"\n                >\n                  <VCardText class=\"text-center\">\n                    <VImg :src=\"getLogoUrl('synologychat')\" height=\"48\" width=\"48\" class=\"mx-auto mb-2\" />\n                    <div class=\"text-h6\">Synology Chat</div>\n                  </VCardText>\n                </VCard>\n              </VCol>\n              <VCol cols=\"12\" md=\"3\">\n                <VCard\n                  :color=\"wizardData.notification.type === 'qqbot' ? 'primary' : 'default'\"\n                  :variant=\"wizardData.notification.type === 'qqbot' ? 'tonal' : 'outlined'\"\n                  class=\"cursor-pointer\"\n                  @click=\"selectNotification('qqbot')\"\n                >\n                  <VCardText class=\"text-center\">\n                    <VImg :src=\"getLogoUrl('notification')\" height=\"48\" width=\"48\" class=\"mx-auto mb-2\" />\n                    <div class=\"text-h6\">QQ</div>\n                  </VCardText>\n                </VCard>\n              </VCol>\n              <VCol cols=\"12\" md=\"3\">\n                <VCard\n                  :color=\"wizardData.notification.type === 'vocechat' ? 'primary' : 'default'\"\n                  :variant=\"wizardData.notification.type === 'vocechat' ? 'tonal' : 'outlined'\"\n                  class=\"cursor-pointer\"\n                  @click=\"selectNotification('vocechat')\"\n                >\n                  <VCardText class=\"text-center\">\n                    <VImg :src=\"getLogoUrl('vocechat')\" height=\"48\" width=\"48\" class=\"mx-auto mb-2\" />\n                    <div class=\"text-h6\">VoceChat</div>\n                  </VCardText>\n                </VCard>\n              </VCol>\n              <VCol cols=\"12\" md=\"3\">\n                <VCard\n                  :color=\"wizardData.notification.type === 'webpush' ? 'primary' : 'default'\"\n                  :variant=\"wizardData.notification.type === 'webpush' ? 'tonal' : 'outlined'\"\n                  class=\"cursor-pointer\"\n                  @click=\"selectNotification('webpush')\"\n                >\n                  <VCardText class=\"text-center\">\n                    <VIcon icon=\"mdi-apple-safari\" size=\"48\" class=\"mb-2\" />\n                    <div class=\"text-h6\">WebPush</div>\n                  </VCardText>\n                </VCard>\n              </VCol>\n            </VRow>\n          </div>\n        </VCol>\n\n        <!-- 通知配置 -->\n        <VCol v-if=\"wizardData.notification.type\" cols=\"12\">\n          <VCard>\n            <VCardText>\n              <VForm>\n                <VRow>\n                  <VCol cols=\"12\">\n                    <VAutocomplete\n                      v-model=\"wizardData.notification.switchs\"\n                      :items=\"notificationTypes\"\n                      :label=\"t('notification.type')\"\n                      :hint=\"t('notification.typeHint')\"\n                      multiple\n                      clearable\n                      chips\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-bell-outline\"\n                    />\n                  </VCol>\n                </VRow>\n                <VRow v-if=\"wizardData.notification.type === 'wechat'\">\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.name\"\n                      :label=\"t('notification.name')\"\n                      :placeholder=\"t('notification.name')\"\n                      :hint=\"t('notification.nameHint')\"\n                      :error=\"validationErrors.notification.name\"\n                      :error-messages=\"validationErrors.notification.name ? [t('notification.nameRequired')] : []\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-label\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.config.WECHAT_CORPID\"\n                      :label=\"t('notification.wechat.corpId')\"\n                      :hint=\"t('notification.wechat.corpIdHint')\"\n                      :error=\"validationErrors.notification.WECHAT_CORPID\"\n                      :error-messages=\"\n                        validationErrors.notification.WECHAT_CORPID ? [t('notification.wechat.corpIdRequired')] : []\n                      \"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-domain\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.config.WECHAT_APP_ID\"\n                      :label=\"t('notification.wechat.appId')\"\n                      :hint=\"t('notification.wechat.appIdHint')\"\n                      :error=\"validationErrors.notification.WECHAT_APP_ID\"\n                      :error-messages=\"\n                        validationErrors.notification.WECHAT_APP_ID ? [t('notification.wechat.appIdRequired')] : []\n                      \"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-application\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.config.WECHAT_APP_SECRET\"\n                      :label=\"t('notification.wechat.appSecret')\"\n                      :hint=\"t('notification.wechat.appSecretHint')\"\n                      :error=\"validationErrors.notification.WECHAT_APP_SECRET\"\n                      :error-messages=\"\n                        validationErrors.notification.WECHAT_APP_SECRET\n                          ? [t('notification.wechat.appSecretRequired')]\n                          : []\n                      \"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-key\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.config.WECHAT_PROXY\"\n                      :label=\"t('notification.wechat.proxy')\"\n                      :hint=\"t('notification.wechat.proxyHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-server-network\"\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.config.WECHAT_TOKEN\"\n                      :label=\"t('notification.wechat.token')\"\n                      :hint=\"t('notification.wechat.tokenHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-key-variant\"\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.config.WECHAT_ENCODING_AESKEY\"\n                      :label=\"t('notification.wechat.encodingAesKey')\"\n                      :hint=\"t('notification.wechat.encodingAesKeyHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-lock\"\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.config.WECHAT_ADMINS\"\n                      :label=\"t('notification.wechat.admins')\"\n                      :placeholder=\"t('notification.wechat.adminsPlaceholder')\"\n                      :hint=\"t('notification.wechat.adminsHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-account-supervisor\"\n                    />\n                  </VCol>\n                </VRow>\n                <VRow v-else-if=\"wizardData.notification.type === 'telegram'\">\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.name\"\n                      :label=\"t('notification.name')\"\n                      :placeholder=\"t('notification.name')\"\n                      :hint=\"t('notification.nameHint')\"\n                      :error=\"validationErrors.notification.name\"\n                      :error-messages=\"validationErrors.notification.name ? [t('notification.nameRequired')] : []\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-label\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.config.TELEGRAM_TOKEN\"\n                      :label=\"t('notification.telegram.token')\"\n                      :hint=\"t('notification.telegram.tokenHint')\"\n                      :error=\"validationErrors.notification.TELEGRAM_TOKEN\"\n                      :error-messages=\"\n                        validationErrors.notification.TELEGRAM_TOKEN ? [t('notification.telegram.tokenRequired')] : []\n                      \"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-key\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.config.TELEGRAM_CHAT_ID\"\n                      :label=\"t('notification.telegram.chatId')\"\n                      :hint=\"t('notification.telegram.chatIdHint')\"\n                      :error=\"validationErrors.notification.TELEGRAM_CHAT_ID\"\n                      :error-messages=\"\n                        validationErrors.notification.TELEGRAM_CHAT_ID\n                          ? [t('notification.telegram.chatIdRequired')]\n                          : []\n                      \"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-chat\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.config.TELEGRAM_USERS\"\n                      :label=\"t('notification.telegram.users')\"\n                      :placeholder=\"t('notification.telegram.usersPlaceholder')\"\n                      :hint=\"t('notification.telegram.usersHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-account-group\"\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.config.TELEGRAM_ADMINS\"\n                      :label=\"t('notification.telegram.admins')\"\n                      :placeholder=\"t('notification.telegram.adminsPlaceholder')\"\n                      :hint=\"t('notification.telegram.adminsHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-account-supervisor\"\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.config.API_URL\"\n                      :label=\"t('notification.telegram.apiUrl')\"\n                      :placeholder=\"t('notification.telegram.apiUrlPlaceholder')\"\n                      :hint=\"t('notification.telegram.apiUrlHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-web\"\n                    />\n                  </VCol>\n                </VRow>\n                <VRow v-else-if=\"wizardData.notification.type === 'qqbot'\">\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.name\"\n                      :label=\"t('notification.name')\"\n                      :placeholder=\"t('notification.name')\"\n                      :hint=\"t('notification.nameHint')\"\n                      :error=\"validationErrors.notification.name\"\n                      :error-messages=\"validationErrors.notification.name ? [t('notification.nameRequired')] : []\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-label\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.config.QQ_APP_ID\"\n                      :label=\"t('notification.qqbot.appId')\"\n                      :hint=\"t('notification.qqbot.appIdHint')\"\n                      :error=\"validationErrors.notification.QQ_APP_ID\"\n                      :error-messages=\"\n                        validationErrors.notification.QQ_APP_ID ? [t('notification.qqbot.appIdRequired')] : []\n                      \"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-application\"\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.config.QQ_APP_SECRET\"\n                      :label=\"t('notification.qqbot.appSecret')\"\n                      :hint=\"t('notification.qqbot.appSecretHint')\"\n                      :error=\"validationErrors.notification.QQ_APP_SECRET\"\n                      :error-messages=\"\n                        validationErrors.notification.QQ_APP_SECRET\n                          ? [t('notification.qqbot.appSecretRequired')]\n                          : []\n                      \"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-key\"\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.config.QQ_OPENID\"\n                      :label=\"t('notification.qqbot.openId')\"\n                      :placeholder=\"t('notification.qqbot.openIdPlaceholder')\"\n                      :hint=\"t('notification.qqbot.openIdHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-account\"\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.config.QQ_GROUP_OPENID\"\n                      :label=\"t('notification.qqbot.groupOpenId')\"\n                      :placeholder=\"t('notification.qqbot.groupOpenIdPlaceholder')\"\n                      :hint=\"t('notification.qqbot.groupOpenIdHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-account-group\"\n                    />\n                  </VCol>\n                </VRow>\n                <VRow v-else-if=\"wizardData.notification.type === 'slack'\">\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.name\"\n                      :label=\"t('notification.name')\"\n                      :placeholder=\"t('notification.name')\"\n                      :hint=\"t('notification.nameHint')\"\n                      :error=\"validationErrors.notification.name\"\n                      :error-messages=\"validationErrors.notification.name ? [t('notification.nameRequired')] : []\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-label\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.config.SLACK_OAUTH_TOKEN\"\n                      :label=\"t('notification.slack.oauthToken')\"\n                      :placeholder=\"t('notification.slack.oauthTokenPlaceholder')\"\n                      :hint=\"t('notification.slack.oauthTokenHint')\"\n                      :error=\"validationErrors.notification.SLACK_OAUTH_TOKEN\"\n                      :error-messages=\"\n                        validationErrors.notification.SLACK_OAUTH_TOKEN\n                          ? [t('notification.slack.oauthTokenRequired')]\n                          : []\n                      \"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-key\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.config.SLACK_APP_TOKEN\"\n                      :label=\"t('notification.slack.appToken')\"\n                      :placeholder=\"t('notification.slack.appTokenPlaceholder')\"\n                      :hint=\"t('notification.slack.appTokenHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-application\"\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.config.SLACK_CHANNEL\"\n                      :label=\"t('notification.slack.channel')\"\n                      :placeholder=\"t('notification.slack.channelPlaceholder')\"\n                      :hint=\"t('notification.slack.channelHint')\"\n                      :error=\"validationErrors.notification.SLACK_CHANNEL\"\n                      :error-messages=\"\n                        validationErrors.notification.SLACK_CHANNEL ? [t('notification.slack.channelRequired')] : []\n                      \"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-pound\"\n                      required\n                    />\n                  </VCol>\n                </VRow>\n                <VRow v-else-if=\"wizardData.notification.type === 'synologychat'\">\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.name\"\n                      :label=\"t('notification.name')\"\n                      :placeholder=\"t('notification.name')\"\n                      :hint=\"t('notification.nameHint')\"\n                      :error=\"validationErrors.notification.name\"\n                      :error-messages=\"validationErrors.notification.name ? [t('notification.nameRequired')] : []\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-label\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.config.SYNOLOGYCHAT_WEBHOOK\"\n                      :label=\"t('notification.synologychat.webhook')\"\n                      :hint=\"t('notification.synologychat.webhookHint')\"\n                      :error=\"validationErrors.notification.SYNOLOGYCHAT_WEBHOOK\"\n                      :error-messages=\"\n                        validationErrors.notification.SYNOLOGYCHAT_WEBHOOK\n                          ? [t('notification.synologychat.webhookRequired')]\n                          : []\n                      \"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-webhook\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.config.SYNOLOGYCHAT_TOKEN\"\n                      :label=\"t('notification.synologychat.token')\"\n                      :hint=\"t('notification.synologychat.tokenHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-key\"\n                    />\n                  </VCol>\n                </VRow>\n                <VRow v-else-if=\"wizardData.notification.type === 'vocechat'\">\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.name\"\n                      :label=\"t('notification.name')\"\n                      :placeholder=\"t('notification.name')\"\n                      :hint=\"t('notification.nameHint')\"\n                      :error=\"validationErrors.notification.name\"\n                      :error-messages=\"validationErrors.notification.name ? [t('notification.nameRequired')] : []\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-label\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.config.VOCECHAT_HOST\"\n                      :label=\"t('notification.vocechat.host')\"\n                      :hint=\"t('notification.vocechat.hostHint')\"\n                      :error=\"validationErrors.notification.VOCECHAT_HOST\"\n                      :error-messages=\"\n                        validationErrors.notification.VOCECHAT_HOST ? [t('notification.vocechat.hostRequired')] : []\n                      \"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-server\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.config.VOCECHAT_API_KEY\"\n                      :label=\"t('notification.vocechat.apiKey')\"\n                      :hint=\"t('notification.vocechat.apiKeyHint')\"\n                      :error=\"validationErrors.notification.VOCECHAT_API_KEY\"\n                      :error-messages=\"\n                        validationErrors.notification.VOCECHAT_API_KEY\n                          ? [t('notification.vocechat.apiKeyRequired')]\n                          : []\n                      \"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-key\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.config.VOCECHAT_CHANNEL_ID\"\n                      :label=\"t('notification.vocechat.channelId')\"\n                      :placeholder=\"t('notification.vocechat.channelIdPlaceholder')\"\n                      :hint=\"t('notification.vocechat.channelIdHint')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-pound\"\n                    />\n                  </VCol>\n                </VRow>\n                <VRow v-else-if=\"wizardData.notification.type === 'webpush'\">\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.name\"\n                      :label=\"t('notification.name')\"\n                      :placeholder=\"t('notification.name')\"\n                      :hint=\"t('notification.nameHint')\"\n                      :error=\"validationErrors.notification.name\"\n                      :error-messages=\"validationErrors.notification.name ? [t('notification.nameRequired')] : []\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-label\"\n                      required\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.config.WEBPUSH_USERNAME\"\n                      :label=\"t('notification.webpush.username')\"\n                      :hint=\"t('notification.webpush.usernameHint')\"\n                      :error=\"validationErrors.notification.WEBPUSH_USERNAME\"\n                      :error-messages=\"\n                        validationErrors.notification.WEBPUSH_USERNAME\n                          ? [t('notification.webpush.usernameRequired')]\n                          : []\n                      \"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-account\"\n                      required\n                    />\n                  </VCol>\n                </VRow>\n                <VRow v-else>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.type\"\n                      :label=\"t('notification.type')\"\n                      :hint=\"t('notification.customTypeHint')\"\n                      persistent-hint\n                      active\n                      prepend-inner-icon=\"mdi-cog\"\n                    />\n                  </VCol>\n                  <VCol cols=\"12\" md=\"6\">\n                    <VTextField\n                      v-model=\"wizardData.notification.name\"\n                      :label=\"t('notification.name')\"\n                      :hint=\"t('notification.nameRequired')\"\n                      persistent-hint\n                      prepend-inner-icon=\"mdi-label\"\n                    />\n                  </VCol>\n                </VRow>\n              </VForm>\n            </VCardText>\n          </VCard>\n        </VCol>\n      </VRow>\n    </VCardText>\n  </VCard>\n</template>\n\n<style scoped>\n.cursor-pointer {\n  cursor: pointer;\n  transition: all 0.2s ease;\n}\n\n.cursor-pointer:hover {\n  box-shadow: 0 4px 12px rgba(0, 0, 0, 15%);\n  transform: translateY(-2px);\n}\n\n.cursor-pointer:active {\n  transform: translateY(0);\n}\n\n/* 选中状态的样式 */\n.v-card--variant-tonal.v-theme--light {\n  border: 2px solid rgb(var(--v-theme-primary));\n  background-color: rgb(var(--v-theme-primary), 0.12);\n}\n\n.v-card--variant-tonal.v-theme--dark {\n  border: 2px solid rgb(var(--v-theme-primary));\n  background-color: rgb(var(--v-theme-primary), 0.2);\n}\n</style>\n"
  },
  {
    "path": "src/views/setup/PreferencesSettingsStep.vue",
    "content": "<script lang=\"ts\" setup>\nimport { ref, computed, onMounted } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport { useSetupWizard } from '@/composables/useSetupWizard'\nimport api from '@/api'\n\nconst { t } = useI18n()\nconst { updatePreferences } = useSetupWizard()\n\n// 个性化选项\nconst personalizationOptions = ref({\n  excludeDolbyVision: true, // 排除杜比视界\n  excludeBluray: true, // 排除蓝光原盘\n})\n\n// 预设配置 - 使用多语言\nconst presetConfigs = computed(() => ({\n  '4k-enthusiast': {\n    name: t('setupWizard.preferences.presets.4k-enthusiast.name'),\n    description: t('setupWizard.preferences.presets.4k-enthusiast.description'),\n    icon: 'mdi-4k',\n    color: 'primary',\n    ruleString:\n      ' SPECSUB & 4K & 60FPS & UHD & !BLU & !DOLBY > CNSUB & 4K & 60FPS & UHD & !BLU & !DOLBY > 4K & 60FPS & UHD & !BLU & !DOLBY > SPECSUB & 4K & UHD & !BLU & !DOLBY > CNSUB & 4K & UHD & !BLU & !DOLBY > 4K & UHD & !BLU & !DOLBY > SPECSUB & 4K & !BLU & !DOLBY > CNSUB & 4K & !BLU & !DOLBY > 4K & !BLU & !DOLBY ',\n  },\n  'balanced': {\n    name: t('setupWizard.preferences.presets.balanced.name'),\n    description: t('setupWizard.preferences.presets.balanced.description'),\n    icon: 'mdi-scale-unbalanced',\n    color: 'success',\n    ruleString:\n      ' SPECSUB & 4K & !BLU & !DOLBY & !UHD & !60FPS > CNSUB & 4K & !BLU & !DOLBY & !REMUX & !60FPS > SPECSUB & 1080P & !BLU & !DOLBY & !60FPS & !UHD > CNSUB & 1080P & !BLU & !DOLBY & !UHD & !60FPS > 4K & BLU & !DOLBY & !UHD & !60FPS > 1080P & !BLU & !DOLBY & !UHD & !60FPS ',\n  },\n  'space-saver': {\n    name: t('setupWizard.preferences.presets.space-saver.name'),\n    description: t('setupWizard.preferences.presets.space-saver.description'),\n    icon: 'mdi-harddisk',\n    color: 'warning',\n    ruleString:\n      ' SPECSUB & 1080P & !BLU & !UHD & !60FPS & !DOLBY > CNSUB & 1080P & !BLU & !UHD & !60FPS & !DOLBY > 1080P & !BLU & !UHD & !60FPS & !DOLBY > !BLU & !UHD & !60FPS & !DOLBY ',\n  },\n  'free-priority': {\n    name: t('setupWizard.preferences.presets.free-priority.name'),\n    description: t('setupWizard.preferences.presets.free-priority.description'),\n    icon: 'mdi-gift',\n    color: 'info',\n    ruleString:\n      ' SPECSUB & FREE & !BLU & !DOLBY > CNSUB & FREE & !BLU & !DOLBY > FREE & !BLU & !DOLBY > !BLU & !DOLBY ',\n  },\n}))\n\n// 当前选中的预设\nconst selectedPreset = ref('')\n\n// 加载用户当前的规则组设置\nasync function loadUserFilterRuleGroups() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')\n    if (result.success && result.data?.value && result.data.value.length > 0) {\n      const userRuleGroups = result.data.value\n\n      // 查找匹配的预设\n      for (const [presetKey, preset] of Object.entries(presetConfigs.value)) {\n        const matchingRule = userRuleGroups.find((rule: any) => rule.name === preset.name)\n        if (matchingRule) {\n          selectedPreset.value = presetKey\n\n          // 分析规则字符串，判断个性化选项\n          const ruleString = matchingRule.rule_string || ''\n          personalizationOptions.value.excludeDolbyVision = ruleString.includes('!DOLBY')\n          personalizationOptions.value.excludeBluray = ruleString.includes('!BLU')\n\n          // 更新向导数据\n          updateWizardData()\n          break\n        }\n      }\n    }\n  } catch (error) {\n    console.log('Load user filter rule groups failed:', error)\n  }\n}\n\n// 选择预设\nfunction selectPreset(presetKey: string) {\n  if (selectedPreset.value === presetKey) {\n    // 如果再次点击同一个预设，则取消选择\n    selectedPreset.value = ''\n    return\n  }\n\n  selectedPreset.value = presetKey\n  updateWizardData()\n}\n\n// 生成规则序列的逻辑\nconst generateRuleSequences = computed(() => {\n  if (!selectedPreset.value) {\n    return []\n  }\n\n  const preset = presetConfigs.value[selectedPreset.value as keyof typeof presetConfigs.value]\n  if (!preset) {\n    return []\n  }\n\n  let ruleString = preset.ruleString\n\n  // 根据个性化选项调整规则\n  if (!personalizationOptions.value.excludeDolbyVision) {\n    // 移除所有 !DOLBY 条件\n    ruleString = ruleString.replace(/ & !DOLBY/g, '').replace(/!DOLBY & /g, '')\n  }\n\n  if (!personalizationOptions.value.excludeBluray) {\n    // 移除所有 !BLU 条件\n    ruleString = ruleString.replace(/ & !BLU/g, '').replace(/!BLU & /g, '')\n  }\n\n  return [\n    {\n      name: preset.name,\n      rule_string: ruleString,\n      media_type: '',\n      category: '',\n    },\n  ]\n})\n\n// 监听偏好变化，更新到wizardData\nfunction updateWizardData() {\n  if (updatePreferences) {\n    updatePreferences(personalizationOptions.value, generateRuleSequences.value)\n  }\n}\n\n// 组件挂载时加载用户设置\nonMounted(() => {\n  loadUserFilterRuleGroups()\n})\n</script>\n\n<template>\n  <VCard variant=\"outlined\">\n    <VCardText>\n      <div class=\"text-center mb-6\">\n        <h3 class=\"text-h4 mb-2\">{{ t('setupWizard.preferences.title') }}</h3>\n        <p class=\"text-body-1 text-medium-emphasis\">{{ t('setupWizard.preferences.description') }}</p>\n      </div>\n\n      <!-- 快速预设 -->\n      <VCard class=\"mb-6\">\n        <VCardTitle class=\"text-h6 d-flex align-center\">\n          <VIcon icon=\"mdi-flash\" class=\"me-2\" />\n          {{ t('setupWizard.preferences.quickPresets') }}\n        </VCardTitle>\n        <VCardText>\n          <p class=\"text-body-2 text-medium-emphasis mb-4\">{{ t('setupWizard.preferences.quickPresetsDesc') }}</p>\n          <VRow>\n            <VCol v-for=\"(preset, key) in presetConfigs\" :key=\"key\" cols=\"12\" sm=\"6\" md=\"3\">\n              <VCard\n                :color=\"selectedPreset === key ? preset.color : 'default'\"\n                :variant=\"selectedPreset === key ? 'tonal' : 'outlined'\"\n                class=\"cursor-pointer preset-card\"\n                @click=\"selectPreset(key)\"\n              >\n                <VCardText class=\"text-center pa-4\">\n                  <VIcon :icon=\"preset.icon\" size=\"40\" class=\"mb-3\" />\n                  <div class=\"text-h6 mb-2\">{{ preset.name }}</div>\n                  <div class=\"text-body-2 text-medium-emphasis\">{{ preset.description }}</div>\n                </VCardText>\n              </VCard>\n            </VCol>\n          </VRow>\n        </VCardText>\n      </VCard>\n\n      <!-- 个性化选项 -->\n      <VCard class=\"mb-6\">\n        <VCardTitle class=\"text-h6 d-flex align-center\">\n          <VIcon icon=\"mdi-cog\" class=\"me-2\" />\n          {{ t('setupWizard.preferences.personalizationOptions') }}\n        </VCardTitle>\n        <VCardText>\n          <p class=\"text-body-2 text-medium-emphasis mb-4\">\n            {{ t('setupWizard.preferences.personalizationOptionsDesc') }}\n          </p>\n          <VRow>\n            <VCol cols=\"12\" md=\"6\">\n              <VSwitch\n                v-model=\"personalizationOptions.excludeDolbyVision\"\n                :label=\"t('setupWizard.preferences.excludeDolbyVision')\"\n                color=\"primary\"\n                hide-details\n                @change=\"updateWizardData\"\n              />\n              <p class=\"text-caption text-medium-emphasis mt-1\">\n                {{ t('setupWizard.preferences.excludeDolbyVisionHint') }}\n              </p>\n            </VCol>\n            <VCol cols=\"12\" md=\"6\">\n              <VSwitch\n                v-model=\"personalizationOptions.excludeBluray\"\n                :label=\"t('setupWizard.preferences.excludeBluray')\"\n                color=\"primary\"\n                hide-details\n                @change=\"updateWizardData\"\n              />\n              <p class=\"text-caption text-medium-emphasis mt-1\">{{ t('setupWizard.preferences.excludeBlurayHint') }}</p>\n            </VCol>\n          </VRow>\n        </VCardText>\n      </VCard>\n    </VCardText>\n  </VCard>\n</template>\n\n<style scoped>\n.cursor-pointer {\n  cursor: pointer;\n  transition: all 0.3s ease;\n}\n\n.preset-card:hover {\n  box-shadow: 0 8px 25px rgba(0, 0, 0, 15%);\n  transform: translateY(-4px);\n}\n\n.preset-card:active {\n  transform: translateY(-2px);\n}\n\n/* 预设卡片选中状态的样式 */\n.v-card--variant-tonal.v-theme--light {\n  border: 2px solid rgb(var(--v-theme-primary));\n  background-color: rgb(var(--v-theme-primary), 0.12);\n}\n\n.v-card--variant-tonal.v-theme--dark {\n  border: 2px solid rgb(var(--v-theme-primary));\n  background-color: rgb(var(--v-theme-primary), 0.2);\n}\n\n/* 规则代码样式 */\n.v-code {\n  padding: 12px;\n  border-radius: 8px;\n  background-color: rgba(var(--v-theme-surface-variant), 0.3);\n  font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;\n  font-size: 0.875rem;\n  line-height: 1.5;\n  white-space: pre-wrap;\n  word-break: break-all;\n}\n\n/* 展开面板样式 */\n.v-expansion-panel-title {\n  font-weight: 500;\n}\n\n.v-expansion-panel-text {\n  padding-block-start: 16px;\n}\n\n/* 开关组件样式优化 */\n.v-switch {\n  margin-block-end: 8px;\n}\n\n/* 芯片组样式 */\n.v-chip-group {\n  gap: 8px;\n}\n\n.v-chip {\n  margin-block: 4px;\n  margin-inline: 0;\n}\n</style>\n"
  },
  {
    "path": "src/views/setup/SiteAuthSettingsStep.vue",
    "content": "<script lang=\"ts\" setup>\nimport { computed } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport { useSetupWizard } from '@/composables/useSetupWizard'\n\nconst { t } = useI18n()\nconst { wizardData, authSites, validationErrors } = useSetupWizard()\n\nconst siteItems = computed(() => {\n  return Object.keys(authSites.value).map(key => ({\n    key,\n    name: authSites.value[key].name,\n    prependAvatar: authSites.value[key].icon,\n  }))\n})\n\nconst formFields = computed(() => {\n  const site = authSites.value[wizardData.value.siteAuth.site]\n  return Object.keys(site?.params || {})\n    .filter(key => site.params[key]?.name && site.params[key]?.type)\n    .map(key => ({\n      key,\n      site: wizardData.value.siteAuth.site,\n      name: site.params[key].name,\n      type: site.params[key].type,\n      placeholder: site.params[key].placeholder,\n      tooltip: site.params[key].tooltip,\n    }))\n})\n</script>\n\n<template>\n  <VCard variant=\"outlined\">\n    <VCardText>\n      <div class=\"text-center mb-6\">\n        <h3 class=\"text-h4 mb-2\">{{ t('setupWizard.siteAuth.title') }}</h3>\n        <p class=\"text-body-1 text-medium-emphasis\">{{ t('setupWizard.siteAuth.description') }}</p>\n      </div>\n\n      <VRow>\n        <VCol cols=\"12\">\n          <VAlert type=\"info\" variant=\"tonal\" class=\"mb-4\">\n            <VAlertTitle>{{ t('setupWizard.siteAuth.info') }}</VAlertTitle>\n            {{ t('setupWizard.siteAuth.infoDesc') }}\n          </VAlert>\n        </VCol>\n\n        <VCol cols=\"12\">\n          <VSwitch\n            v-model=\"wizardData.siteAuth.auxiliaryAuthEnable\"\n            :label=\"t('setting.system.auxAuthEnable')\"\n            :hint=\"t('setting.system.auxAuthEnableHint')\"\n            persistent-hint\n            color=\"primary\"\n          />\n        </VCol>\n\n        <VCol cols=\"12\">\n          <VSelect\n            v-model=\"wizardData.siteAuth.site\"\n            :items=\"siteItems\"\n            item-value=\"key\"\n            item-title=\"name\"\n            item-props\n            :label=\"t('dialog.userAuth.selectSite')\"\n            :hint=\"t('setupWizard.siteAuth.selectSiteHint')\"\n            :error=\"validationErrors.siteAuth.site\"\n            :error-messages=\"validationErrors.siteAuth.site ? [t('dialog.userAuth.selectSiteRequired')] : []\"\n            persistent-hint\n            prepend-inner-icon=\"mdi-web\"\n            clearable\n          />\n        </VCol>\n\n        <template v-if=\"wizardData.siteAuth.site\">\n          <VCol cols=\"12\">\n            <VAlert type=\"warning\" variant=\"tonal\">\n              {{ t('setupWizard.siteAuth.submitHint') }}\n            </VAlert>\n          </VCol>\n\n          <VCol v-for=\"param in formFields\" :key=\"param.key\" cols=\"12\" md=\"6\">\n            <VTextField\n              v-model=\"wizardData.siteAuth.params[param.site.toUpperCase() + '_' + param.key.toUpperCase()]\"\n              :type=\"param.type\"\n              :label=\"param.name\"\n              :placeholder=\"param.placeholder\"\n              :hint=\"param.tooltip\"\n              :error=\"validationErrors.siteAuth[param.site.toUpperCase() + '_' + param.key.toUpperCase()]\"\n              :error-messages=\"\n                validationErrors.siteAuth[param.site.toUpperCase() + '_' + param.key.toUpperCase()]\n                  ? [t('setupWizard.siteAuth.fieldRequired', { name: param.name })]\n                  : []\n              \"\n              clearable\n              persistent-hint\n            />\n          </VCol>\n        </template>\n      </VRow>\n    </VCardText>\n  </VCard>\n</template>\n"
  },
  {
    "path": "src/views/setup/StorageSettingsStep.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useI18n } from 'vue-i18n'\nimport { useSetupWizard } from '@/composables/useSetupWizard'\n\nconst { t } = useI18n()\nconst { wizardData, validateCurrentStep } = useSetupWizard()\n\n// 验证状态\nconst validation = computed(() => validateCurrentStep())\nconst hasErrors = computed(() => !validation.value.isValid)\n\n// 整理方式选项\nconst transferTypeItems = [\n  { title: '硬链接', value: 'link' },\n  { title: '软链接', value: 'softlink' },\n  { title: '复制', value: 'copy' },\n  { title: '移动', value: 'move' },\n]\n\n// 覆盖模式选项\nconst overwriteModeItems = [\n  { title: '从不覆盖', value: 'never' },\n  { title: '总是覆盖', value: 'always' },\n  { title: '按文件大小', value: 'size' },\n  { title: '仅保留最新', value: 'latest' },\n]\n</script>\n\n<template>\n  <VCard variant=\"outlined\">\n    <VCardText>\n      <div class=\"text-center mb-6\">\n        <h3 class=\"text-h4 mb-2\">{{ t('setupWizard.storage.title') }}</h3>\n        <p class=\"text-body-1 text-medium-emphasis\">{{ t('setupWizard.storage.description') }}</p>\n      </div>\n      <VRow>\n        <VCol cols=\"12\">\n          <VAlert type=\"info\" variant=\"tonal\" class=\"mb-4\">\n            <VAlertTitle>{{ t('setupWizard.storage.info') }}</VAlertTitle>\n            {{ t('setupWizard.storage.infoDesc') }}\n          </VAlert>\n        </VCol>\n        <VCol cols=\"12\" md=\"6\">\n          <VPathField\n            v-model=\"wizardData.storage.downloadPath\"\n            :label=\"t('setupWizard.storage.downloadPath')\"\n            :hint=\"t('setupWizard.storage.downloadPathHint')\"\n            persistent-hint\n            prepend-inner-icon=\"mdi-download\"\n            placeholder=\"/downloads\"\n            :error=\"!wizardData.storage.downloadPath && hasErrors\"\n            :error-messages=\"\n              !wizardData.storage.downloadPath && hasErrors ? [t('setupWizard.storage.downloadPathRequired')] : []\n            \"\n          />\n        </VCol>\n        <VCol cols=\"12\" md=\"6\">\n          <VPathField\n            v-model=\"wizardData.storage.libraryPath\"\n            :label=\"t('setupWizard.storage.libraryPath')\"\n            :hint=\"t('setupWizard.storage.libraryPathHint')\"\n            persistent-hint\n            prepend-inner-icon=\"mdi-folder-multiple\"\n            placeholder=\"/media\"\n            :error=\"!wizardData.storage.libraryPath && hasErrors\"\n            :error-messages=\"\n              !wizardData.storage.libraryPath && hasErrors ? [t('setupWizard.storage.libraryPathRequired')] : []\n            \"\n          />\n        </VCol>\n        <VCol cols=\"12\" md=\"6\">\n          <VSelect\n            v-model=\"wizardData.storage.transferType\"\n            :label=\"t('directory.transferType')\"\n            :hint=\"t('directory.transferTypeHint')\"\n            persistent-hint\n            :items=\"transferTypeItems\"\n            prepend-inner-icon=\"mdi-swap-horizontal\"\n          />\n        </VCol>\n        <VCol cols=\"12\" md=\"6\">\n          <VSelect\n            v-model=\"wizardData.storage.overwriteMode\"\n            :label=\"t('directory.overwriteMode')\"\n            :hint=\"t('directory.overwriteModeHint')\"\n            persistent-hint\n            :items=\"overwriteModeItems\"\n            prepend-inner-icon=\"mdi-file-replace\"\n          />\n        </VCol>\n      </VRow>\n    </VCardText>\n  </VCard>\n</template>\n"
  },
  {
    "path": "src/views/site/SiteCardListView.vue",
    "content": "<script lang=\"ts\" setup>\nimport draggable from 'vuedraggable'\nimport api from '@/api'\nimport type { Site, SiteUserData } from '@/api/types'\nimport SiteCard from '@/components/cards/SiteCard.vue'\nimport NoDataFound from '@/components/NoDataFound.vue'\nimport SiteAddEditDialog from '@/components/dialog/SiteAddEditDialog.vue'\nimport SiteStatisticsDialog from '@/components/dialog/SiteStatisticsDialog.vue'\nimport SiteImportDialog from '@/components/dialog/SiteImportDialog.vue'\nimport { useDisplay } from 'vuetify'\nimport { useDynamicButton } from '@/composables/useDynamicButton'\nimport { useI18n } from 'vue-i18n'\nimport { usePWA } from '@/composables/usePWA'\nimport { useToast } from 'vue-toastification'\n\n// 国际化\nconst { t } = useI18n()\n\n// 提示框\nconst $toast = useToast()\n\n// 路由\nconst route = useRoute()\n\n// APP\nconst display = useDisplay()\n// PWA模式检测\nconst { appMode } = usePWA()\n\n// 站点列表\nconst siteList = ref<Site[]>([])\n\n// 站点数据列表\nconst userDataList = ref<SiteUserData[]>([])\n\n// 站点统计数据列表\nconst siteStatsList = ref<{ [domain: string]: any }>({})\n\n// 是否刷新过\nconst isRefreshed = ref(false)\n\n// 是否加载中\nconst loading = ref(false)\n\n// 新增站点对话框\nconst siteAddDialog = ref(false)\n\n// 统计信息对话框\nconst siteStatsDialog = ref(false)\n\n// 导入站点对话框\nconst siteImportDialog = ref(false)\n\n// 筛选相关\nconst filterMenu = ref(false)\nconst filterOption = ref('all') // all, active, inactive, connected, slow, failed, unknown\n\n// 筛选选项\nconst filterOptions = computed(() => [\n  { value: 'all', label: t('common.all'), icon: 'mdi-filter-multiple-outline' },\n  { value: 'active', label: t('common.active'), icon: 'mdi-check-circle', color: 'success' },\n  { value: 'inactive', label: t('common.inactive'), icon: 'mdi-stop-circle', color: 'error' },\n  { value: 'connected', label: t('site.connectionNormal'), icon: 'mdi-wifi', color: 'success' },\n  { value: 'slow', label: t('site.connectionSlow'), icon: 'mdi-wifi-strength-2', color: 'warning' },\n  { value: 'failed', label: t('site.connectionFailed'), icon: 'mdi-wifi-off', color: 'error' },\n  { value: 'unknown', label: t('site.connectionUnknown'), icon: 'mdi-help-circle', color: 'secondary' },\n])\n\n// 筛选后的站点列表\nconst filteredSiteList = computed(() => {\n  if (filterOption.value === 'all') {\n    return siteList.value\n  }\n  return siteList.value.filter(site => {\n    if (filterOption.value === 'active') {\n      return site.is_active\n    } else if (filterOption.value === 'inactive') {\n      return !site.is_active\n    } else if (['connected', 'slow', 'failed', 'unknown'].includes(filterOption.value)) {\n      const connectionStatus = getConnectionStatus(site.domain)\n      return connectionStatus === filterOption.value\n    }\n    return true\n  })\n})\n\n// 用于拖拽排序的列表\nconst draggableSiteList = computed({\n  get() {\n    return filterOption.value === 'all' ? siteList.value : filteredSiteList.value\n  },\n  set(value) {\n    if (filterOption.value === 'all') {\n      siteList.value = value\n    }\n  },\n})\n\n// 当前筛选选项的显示信息\nconst currentFilter = computed(() => {\n  return filterOptions.value.find(option => option.value === filterOption.value)\n})\n\n// 获取站点列表数据\nasync function fetchData() {\n  try {\n    loading.value = true\n    siteList.value = await api.get('site/')\n    loading.value = false\n    isRefreshed.value = true\n    // 获取站点列表后，获取统计数据\n    await fetchSiteStats()\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 获取站点最新数据\nasync function fetchUserData() {\n  try {\n    userDataList.value = await api.get('site/userdata/latest')\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 获取站点统计数据\nasync function fetchSiteStats() {\n  try {\n    // 使用批量接口一次性获取所有站点统计数据\n    const response = await api.get('site/statistic')\n    const stats = response.data || response\n\n    // 将数组转换为以domain为键的对象\n    const statsMap: { [domain: string]: any } = {}\n    if (Array.isArray(stats)) {\n      stats.forEach((stat: any) => {\n        if (stat.domain) {\n          statsMap[stat.domain] = stat\n        }\n      })\n    }\n    siteStatsList.value = statsMap\n  } catch (error) {\n    console.error('Failed to fetch site statistics:', error)\n    siteStatsList.value = {}\n  }\n}\n\n// 根据站点统计数据判断连接状态\nfunction getConnectionStatus(domain: string) {\n  const stats = siteStatsList.value[domain]\n  if (!stats || Object.keys(stats).length === 0) {\n    return 'unknown'\n  }\n  if (stats.lst_state === 1) {\n    return 'failed'\n  } else if (stats.lst_state === 0) {\n    if (!stats.seconds) return 'unknown'\n    if (stats.seconds >= 5) return 'slow'\n    return 'connected'\n  }\n  return 'unknown'\n}\n\n// 保存站点排序\nasync function savaSitesPriority() {\n  // 只在显示全部站点时允许排序\n  if (filterOption.value !== 'all') {\n    return\n  }\n\n  // 重新排序\n  const priorities = draggableSiteList.value.map((site, index) => ({ id: site.id, pri: index + 1 }))\n  try {\n    const result: { [key: string]: any } = await api.post('site/priorities', priorities)\n    if (!result.success) {\n      fetchData()\n    }\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 根据站点ID获取站点数据\nfunction getUserData(domain: string) {\n  return userDataList.value.find(userData => userData.domain === domain)\n}\n\n// 根据站点域名获取统计数据\nfunction getSiteStats(domain: string) {\n  return siteStatsList.value[domain] || {}\n}\n\n// 处理站点统计数据刷新请求\nasync function handleRefreshStats(domain?: string) {\n  if (domain) {\n    // 刷新特定站点的统计数据\n    try {\n      const stats = await api.get(`site/statistic/${domain}`)\n      siteStatsList.value[domain] = stats\n    } catch (error) {\n      console.error(`Failed to refresh stats for ${domain}:`, error)\n    }\n  } else {\n    // 刷新所有站点统计数据\n    await fetchSiteStats()\n  }\n}\n\n// 更新站点事件时\nfunction onSiteSave() {\n  siteAddDialog.value = false\n  fetchData()\n}\n\n// 选择筛选选项\nfunction selectFilter(value: string) {\n  filterOption.value = value\n  filterMenu.value = false\n}\n\n// 导出站点数据\nasync function exportSites() {\n  try {\n    // 获取所有站点数据\n    const sites: Site[] = await api.get('site/')\n\n    // 创建导出数据，只包含必要的字段\n    const exportData = sites.map((site: Site) => ({\n      name: site.name,\n      domain: site.domain,\n      url: site.url,\n      rss: site.rss,\n      downloader: site.downloader,\n      cookie: site.cookie,\n      apikey: site.apikey,\n      token: site.token,\n      ua: site.ua,\n      proxy: site.proxy,\n      filter: site.filter,\n      render: site.render,\n      public: site.public,\n      note: site.note,\n      timeout: site.timeout,\n      limit_interval: site.limit_interval,\n      limit_count: site.limit_count,\n      limit_seconds: site.limit_seconds,\n      is_active: site.is_active,\n      pri: site.pri,\n    }))\n\n    // 创建Blob对象\n    const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })\n\n    // 创建下载链接\n    const url = URL.createObjectURL(blob)\n    const link = document.createElement('a')\n    link.href = url\n    link.download = `sites_export_${new Date().toISOString().split('T')[0]}.json`\n    document.body.appendChild(link)\n    link.click()\n    document.body.removeChild(link)\n    URL.revokeObjectURL(url)\n\n    // 显示成功提示\n    $toast.success(t('site.messages.exportSuccess'))\n  } catch (error) {\n    console.error('Export sites failed:', error)\n    $toast.error(t('site.messages.exportFailed'))\n  }\n}\n\n// 加载时获取数据\nonBeforeMount(() => {\n  fetchData()\n  fetchUserData()\n})\n\nonActivated(() => {\n  if (!loading.value) {\n    fetchData()\n    fetchUserData()\n  }\n})\n\n// 使用动态按钮钩子\nuseDynamicButton({\n  icon: 'mdi-web-plus',\n  onClick: () => {\n    siteAddDialog.value = true\n  },\n})\n</script>\n\n<template>\n  <div class=\"card-list-container\">\n    <!-- 页面标题和筛选按钮 -->\n    <div class=\"d-flex justify-space-between align-center mb-4\">\n      <VPageContentTitle :title=\"t('navItems.siteManager')\" class=\"mb-0\" />\n      <!-- 右侧按钮组 -->\n      <div class=\"d-flex align-center gap-2\">\n        <!-- 导入按钮 -->\n        <VBtn :icon=\"display.smAndDown.value\" variant=\"text\" color=\"success\" @click=\"siteImportDialog = true\">\n          <VIcon icon=\"mdi-import\" />\n          <span v-if=\"!display.smAndDown.value\" class=\"ml-2\">\n            {{ t('site.actions.import') }}\n          </span>\n        </VBtn>\n        <!-- 导出按钮 -->\n        <VBtn :icon=\"display.smAndDown.value\" variant=\"text\" color=\"warning\" @click=\"exportSites\">\n          <VIcon icon=\"mdi-export\" />\n          <span v-if=\"!display.smAndDown.value\" class=\"ml-2\">\n            {{ t('site.actions.export') }}\n          </span>\n        </VBtn>\n        <!-- 统计信息按钮 -->\n        <VBtn :icon=\"display.smAndDown.value\" variant=\"text\" color=\"info\" @click=\"siteStatsDialog = true\">\n          <VIcon icon=\"mdi-chart-line\" />\n          <span v-if=\"!display.smAndDown.value\" class=\"ml-2\">\n            {{ t('site.statistics') }}\n          </span>\n        </VBtn>\n        <!-- 筛选按钮 -->\n        <VMenu v-model=\"filterMenu\" offset-y :close-on-content-click=\"false\" location=\"bottom end\">\n          <template #activator=\"{ props }\">\n            <VBtn\n              v-bind=\"props\"\n              :icon=\"display.smAndDown.value\"\n              :variant=\"filterOption === 'all' ? 'text' : 'tonal'\"\n              :color=\"currentFilter?.color\"\n            >\n              <VIcon :icon=\"currentFilter?.icon || 'mdi-filter'\" />\n              <span v-if=\"!display.smAndDown.value\" class=\"ml-2\">\n                {{ currentFilter?.label }}\n              </span>\n              <VIcon v-if=\"!display.smAndDown.value\" icon=\"mdi-chevron-down\" class=\"ml-1\" />\n            </VBtn>\n          </template>\n\n          <!-- 筛选菜单 -->\n          <VCard min-width=\"200\">\n            <VList class=\"px-2\">\n              <VListSubheader>{{ t('common.filter') }}</VListSubheader>\n              <VListItem\n                v-for=\"option in filterOptions\"\n                :key=\"option.value\"\n                :active=\"filterOption === option.value\"\n                @click=\"selectFilter(option.value)\"\n              >\n                <template #prepend>\n                  <VIcon :icon=\"option.icon\" :color=\"option.color\" />\n                </template>\n                <VListItemTitle>{{ option.label }}</VListItemTitle>\n                <template #append>\n                  <VIcon v-if=\"filterOption === option.value\" icon=\"mdi-check\" color=\"primary\" />\n                </template>\n              </VListItem>\n            </VList>\n          </VCard>\n        </VMenu>\n      </div>\n    </div>\n\n    <LoadingBanner v-if=\"!isRefreshed\" class=\"mt-12\" />\n    <draggable\n      v-if=\"draggableSiteList.length > 0\"\n      v-model=\"draggableSiteList\"\n      @end=\"savaSitesPriority\"\n      handle=\".cursor-move\"\n      item-key=\"id\"\n      tag=\"div\"\n      :component-data=\"{ 'class': 'grid gap-4 grid-site-card px-2' }\"\n      :disabled=\"filterOption !== 'all'\"\n    >\n      <template #item=\"{ element }\">\n        <SiteCard\n          :site=\"element\"\n          :data=\"getUserData(element.domain)\"\n          :stats=\"getSiteStats(element.domain)\"\n          @remove=\"fetchData\"\n          @update=\"fetchData\"\n          @refresh-stats=\"handleRefreshStats\"\n        />\n      </template>\n    </draggable>\n  </div>\n  <NoDataFound\n    v-if=\"draggableSiteList.length === 0 && isRefreshed\"\n    error-code=\"404\"\n    :error-title=\"filterOption === 'all' ? t('site.noSites') : t('common.noMatchingData')\"\n    :error-description=\"filterOption === 'all' ? t('site.sitesWillBeShownHere') : t('common.tryChangingFilters')\"\n  />\n  <!-- 新增站点按钮 -->\n  <Teleport to=\"body\" v-if=\"route.path === '/site'\">\n    <div v-if=\"isRefreshed && !appMode\" class=\"compact-fab-stack\">\n      <VFab\n        icon=\"mdi-web-plus\"\n        color=\"primary\"\n        appear\n        class=\"compact-fab compact-fab--primary\"\n        @click=\"siteAddDialog = true\"\n      />\n    </div>\n  </Teleport>\n  <!-- 新增站点弹窗 -->\n  <SiteAddEditDialog\n    v-if=\"siteAddDialog\"\n    v-model=\"siteAddDialog\"\n    oper=\"add\"\n    @save=\"onSiteSave\"\n    @close=\"siteAddDialog = false\"\n  />\n\n  <!-- 统计信息弹窗 -->\n  <SiteStatisticsDialog v-if=\"siteStatsDialog\" v-model=\"siteStatsDialog\" :sites=\"siteList\" />\n\n  <!-- 导入站点弹窗 -->\n  <SiteImportDialog v-if=\"siteImportDialog\" v-model=\"siteImportDialog\" @import-success=\"fetchData\" />\n</template>\n"
  },
  {
    "path": "src/views/subscribe/FullCalendarView.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { CalendarOptions, EventSourceInput } from '@fullcalendar/core'\nimport dayGridPlugin from '@fullcalendar/daygrid'\nimport interactionPlugin from '@fullcalendar/interaction'\nimport timeGridPlugin from '@fullcalendar/timegrid'\nimport FullCalendar from '@fullcalendar/vue3'\nimport type { Ref } from 'vue'\nimport type { MediaInfo, Subscribe, TmdbEpisode } from '@/api/types'\nimport api from '@/api'\nimport { formatEp, parseDate } from '@/@core/utils/formatters'\nimport ProgressDialog from '@/components/dialog/ProgressDialog.vue'\nimport { useI18n } from 'vue-i18n'\nimport { getCurrentLocale } from '@/plugins/i18n'\n\n// 国际化\nconst { t } = useI18n()\n\n// 进度框\nconst progressDialog = ref(false)\n\n// 加载中\nconst loading = ref(false)\n\n// 已加载过\nconst isLoaded = ref(false)\n\n// 获取当前语言\nconst currentLocale = getCurrentLocale().split('-')[0]\n\n// 日历属性\nconst calendarOptions: Ref<CalendarOptions> = ref({\n  height: 'auto',\n  locale: currentLocale,\n  plugins: [\n    dayGridPlugin,\n    timeGridPlugin,\n    interactionPlugin, // needed for dateClick\n  ],\n  initialView: 'dayGridMonth',\n  weekends: true,\n  firstDay: 1,\n  headerToolbar: {\n    left: 'prev',\n    center: 'title',\n    right: 'next',\n  },\n  views: {\n    week: {\n      titleFormat: { day: 'numeric' },\n    },\n  },\n  events: [],\n})\n\nasync function eventsHander(subscribe: Subscribe) {\n  // 如果是电影直接返回\n  if (subscribe.type === '电影') {\n    // 调用API查询TMDB详情\n    const movie: MediaInfo = await api.get(`media/tmdb:${subscribe.tmdbid}`, {\n      params: { type_name: subscribe.type },\n    })\n\n    return {\n      title: subscribe.name,\n      subtitle: '',\n      start: parseDate(movie.release_date || ''),\n      allDay: false,\n      posterPath: subscribe.poster,\n      mediaType: subscribe.type,\n      len: 1,\n    }\n  } else {\n    // 调用API查询集信息\n    const params = subscribe.episode_group ? { episode_group: subscribe.episode_group } : undefined\n    const episodes: TmdbEpisode[] = await api.get(`tmdb/${subscribe.tmdbid}/${subscribe.season}`, params ? { params } : undefined)\n\n    interface EpisodeInfo {\n      title: string\n      subtitle: string\n      start: Date | null\n      allDay: boolean\n      posterPath: string | undefined\n      mediaType: string\n      len: number\n    }\n\n    interface EpisodesDictionary {\n      [key: string]: EpisodeInfo\n    }\n\n    const dictEpisode: EpisodesDictionary = {}\n    episodes.forEach((episode: TmdbEpisode) => {\n      const air_date = episode.air_date ?? ''\n      if (dictEpisode[air_date]) {\n        dictEpisode[air_date].subtitle += `,${episode.episode_number}`\n        dictEpisode[air_date].len++\n      } else {\n        dictEpisode[air_date] = {\n          title: subscribe.name,\n          subtitle: `${episode.episode_number}`,\n          start: parseDate(episode.air_date || ''),\n          allDay: false,\n          posterPath: subscribe.poster,\n          mediaType: subscribe.type,\n          len: 1,\n        }\n      }\n    })\n    for (const key in dictEpisode)\n      dictEpisode[key].subtitle = formatEp(dictEpisode[key].subtitle.split(',').map(Number))\n\n    return Object.values(dictEpisode)\n  }\n}\n\n// 调用API查询所有订阅\nasync function getSubscribes() {\n  if (!isLoaded.value) progressDialog.value = true\n  try {\n    // 订阅\n    loading.value = true\n    const subscribes: Subscribe[] = await api.get('subscribe/')\n    loading.value = false\n    const subEvents = await Promise.allSettled(subscribes.map(async sub => eventsHander(sub)))\n    const succEvents = subEvents.filter(result => result.status === 'fulfilled').map(result => result.value)\n    calendarOptions.value.events = succEvents.flat().filter(event => event.start) as EventSourceInput\n    isLoaded.value = true\n  } catch (error) {\n    console.error(error)\n  }\n  progressDialog.value = false\n}\n\n// 页面加载时调用API查询所有订阅\nonMounted(() => {\n  getSubscribes()\n})\n\nonActivated(() => {\n  if (!loading.value) {\n    getSubscribes()\n  }\n})\n</script>\n\n<template>\n  <FullCalendar :options=\"calendarOptions\">\n    <template #eventContent=\"arg\">\n      <div class=\"hidden md:block overflow-hidden\">\n        <VCard>\n          <div class=\"d-flex justify-space-between flex-nowrap flex-row\">\n            <div class=\"ma-auto\">\n              <VImg\n                height=\"75\"\n                width=\"50\"\n                :src=\"arg.event.extendedProps.posterPath\"\n                aspect-ratio=\"2/3\"\n                class=\"object-cover rounded ring-gray-500\"\n                cover\n              >\n                <template #placeholder>\n                  <div class=\"w-full h-full\">\n                    <VSkeletonLoader class=\"object-cover aspect-w-2 aspect-h-3\" />\n                  </div>\n                </template>\n              </VImg>\n            </div>\n            <div>\n              <VCardSubtitle class=\"pa-1 px-2 font-bold break-words whitespace-break-spaces\">\n                {{ arg.event.title }}\n              </VCardSubtitle>\n              <VCardText v-if=\"arg.event.extendedProps.subtitle\" class=\"pa-0 px-2 break-words\">\n                {{ t('calendar.episode', { number: arg.event.extendedProps.subtitle }) }}\n              </VCardText>\n            </div>\n          </div>\n        </VCard>\n      </div>\n      <div class=\"md:hidden\">\n        <VTooltip :text=\"`${arg.event.title} ${t('calendar.episode', { number: arg.event.extendedProps.subtitle })}`\">\n          <template #activator=\"{ props }\">\n            <VImg\n              height=\"60\"\n              width=\"40\"\n              :src=\"arg.event.extendedProps.posterPath\"\n              v-bind=\"props\"\n              aspect-ratio=\"2/3\"\n              class=\"object-cover rounded ring-gray-500\"\n              cover\n            >\n              <template #placeholder>\n                <div class=\"w-full h-full\">\n                  <VSkeletonLoader class=\"object-cover aspect-w-2 aspect-h-3\" />\n                </div>\n              </template>\n              <VChip\n                v-if=\"arg.event.extendedProps.len > 1\"\n                variant=\"elevated\"\n                color=\"primary\"\n                size=\"x-small\"\n                class=\"absolute right-0 top-0\"\n              >\n                {{ arg.event.extendedProps.len }}\n              </VChip>\n            </VImg>\n          </template>\n        </VTooltip>\n      </div>\n    </template>\n  </FullCalendar>\n  <ProgressDialog v-if=\"progressDialog\" v-model=\"progressDialog\" :text=\"t('common.loading') + ' ...'\" />\n</template>\n\n<style lang=\"scss\">\n.v-application .fc {\n  --fc-today-bg-color: rgba(var(--v-theme-on-surface), 0.04);\n  --fc-border-color: rgba(var(--v-border-color), var(--v-border-opacity));\n  --fc-neutral-bg-color: rgb(var(--v-theme-background), 0.3);\n  --fc-list-event-hover-bg-color: rgba(var(--v-theme-on-surface), 0.02);\n  --fc-page-bg-color: rgb(var(--v-theme-background), 0.3);\n  --fc-event-border-color: currentcolor;\n}\n\n// 当天背景渐变\n.fc-day-today {\n  background-image: linear-gradient(to bottom, #af85fd, rgba(var(--v-theme-on-surface), 0.04));\n}\n\n.v-application .fc a {\n  color: inherit;\n}\n\n.v-application .fc .fc-timegrid-divider {\n  padding: 0;\n}\n\n.v-application .fc .fc-toolbar-title {\n  display: inline-block;\n  overflow: hidden;\n  color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));\n  font-size: 1.25rem;\n  font-weight: 500;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.v-application .fc .fc-col-header-cell-cushion {\n  color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));\n  font-size: 0.875rem;\n  font-weight: 500;\n}\n\n.v-application .fc .fc-toolbar .fc-toolbar-title {\n  margin-inline-start: 0.25rem;\n}\n\n.v-application .fc .fc-event-time {\n  font-size: 0.75rem;\n  font-weight: 500;\n}\n\n.v-application .fc .fc-timegrid-event .fc-event-title {\n  font-size: 0.875rem;\n  font-weight: 400;\n}\n\n.v-application .fc .fc-prev-button {\n  padding-inline-start: 0;\n}\n\n.v-application .fc .fc-prev-button,\n.v-application .fc .fc-next-button {\n  padding: 0.25rem;\n}\n\n.v-application .fc .fc-col-header .fc-col-header-cell .fc-col-header-cell-cushion {\n  padding: 0.5rem;\n  text-decoration: none !important;\n}\n\n.v-application .fc .fc-timegrid .fc-timegrid-slots .fc-timegrid-slot {\n  block-size: 3rem;\n}\n\n.v-application .fc .fc-list {\n  border-inline-start: none;\n  font-size: 0.875rem;\n}\n\n.v-application .fc .fc-list .fc-list-day-cushion.fc-cell-shaded {\n  background-color: rgba(var(--v-custom-background));\n  color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));\n  font-weight: 500;\n}\n\n.v-application .fc .fc-list .fc-list-event-time,\n.v-application .fc .fc-list .fc-list-event-title {\n  color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));\n}\n\n.v-application .fc .fc-list .fc-list-day .fc-list-day-text,\n.v-application .fc .fc-list .fc-list-day .fc-list-day-side-text {\n  text-decoration: none;\n}\n\n.v-application .fc .fc-timegrid-axis {\n  color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));\n  font-size: 0.75rem;\n  text-transform: capitalize;\n}\n\n.v-application .fc .fc-timegrid-slot-label-frame {\n  color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));\n  font-size: 0.75rem;\n  text-align: center;\n  text-transform: uppercase;\n}\n\n.v-application .fc .fc-header-toolbar {\n  flex-wrap: nowrap;\n  row-gap: 0.5rem;\n}\n\n.v-application .fc .fc-button-primary {\n  border: none;\n  background-color: transparent;\n  color: var(--v-theme-on-surface);\n  outline: none;\n\n  &:hover {\n    background-color: transparent;\n    color: rgb(var(--v-theme-primary));\n  }\n}\n\n.v-application .fc .fc-toolbar-chunk .fc-button-group {\n  align-items: center;\n}\n\n.v-application .fc .fc-toolbar-chunk {\n  display: flex;\n  align-items: center;\n}\n\n.v-application .fc .fc-toolbar-chunk .fc-button-group .fc-button-primary,\n.v-application .fc .fc-toolbar-chunk .fc-button-group .fc-button-primary:hover,\n.v-application .fc .fc-toolbar-chunk .fc-button-group .fc-button-primary:not(.disabled):active {\n  border-color: transparent;\n  background-color: transparent;\n  color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));\n}\n\n.v-application .fc .fc-toolbar-chunk:last-child .fc-button-group {\n  border: 0.0625rem solid rgba(var(--v-theme-primary), var(--v-overlay-scrim-opacity));\n  border-radius: 0.375rem;\n}\n\n.v-application .fc .fc-toolbar-chunk:last-child .fc-button-group .fc-button {\n  color: rgb(var(--v-theme-primary));\n  font-size: 0.875rem;\n  font-weight: 500;\n  letter-spacing: 0.0187rem;\n  padding-inline: 1rem;\n  text-transform: uppercase;\n}\n\n.v-application .fc .fc-toolbar-chunk:last-child .fc-button-group .fc-button:not(:last-child) {\n  border-inline-end: 0.0625rem solid rgba(var(--v-theme-primary), var(--v-overlay-scrim-opacity));\n}\n\n.v-application .fc .fc-toolbar-chunk:last-child .fc-button-group .fc-button.fc-button-active {\n  background-color: rgba(var(--v-theme-primary), var(--v-activated-opacity));\n  color: rgb(var(--v-theme-primary));\n}\n\n.v-application .fc .fc-scrollgrid-section th {\n  border-inline: 0;\n}\n\n.v-application .fc .fc-view-harness {\n  min-block-size: 40.625rem;\n}\n\n.v-application .fc .fc-event {\n  border-color: transparent;\n  margin-block-end: 0.3rem;\n  padding-inline: 0.3125rem;\n}\n\n.v-application .fc .fc-event-main {\n  color: inherit;\n  font-size: 0.75rem;\n  font-weight: 500;\n  padding-inline: 0.25rem;\n}\n\n.v-application .fc tbody[role='rowgroup'] > tr > td[role='presentation'] {\n  border: none;\n}\n\n.v-application .fc .fc-scrollgrid {\n  border-inline-start: none;\n}\n\n.v-application .fc .fc-daygrid-day {\n  padding: 0.3125rem;\n}\n\n.v-application .fc .fc-daygrid-day-number {\n  padding-block: 0;\n  padding-inline: 0;\n}\n\n.v-application .fc .fc-list-event-dot {\n  color: inherit;\n\n  --fc-event-border-color: currentcolor;\n}\n\n.v-application .fc .fc-list-event {\n  background-color: transparent !important;\n}\n\n.v-application .fc .fc-popover {\n  border-radius: 6px;\n  box-shadow: 0 4px 14px -4px var(--v-shadow-key-umbra-opacity), 0 4px 8px -4px var(--v-shadow-key-penumbra-opacity),\n    0 4px 8px -4px var(--v-shadow-key-ambient-opacity);\n}\n\n.v-application .fc .fc-popover .fc-popover-header,\n.v-application .fc .fc-popover .fc-popover-body {\n  padding: 0.5rem;\n}\n\n.v-application .fc .fc-popover .fc-popover-title {\n  margin: 0;\n  font-size: 1rem;\n  font-weight: 500;\n}\n\n.v-application .fc .fc-toolbar-chunk .fc-button-group .fc-button .fc-icon {\n  vertical-align: bottom;\n}\n\n.v-application .fc .fc-toolbar-chunk .fc-button-group .fc-drawerToggler-button {\n  display: none;\n  background-image: url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='rgba(94,86,105,0.68)' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round' class='css-i6dzq1'%3E%3Cpath d='M3 12h18M3 6h18M3 18h18'/%3E%3C/svg%3E\");\n  background-position: 50%;\n  background-repeat: no-repeat;\n  block-size: 1.5625rem;\n  font-size: 0;\n  inline-size: 1.5625rem;\n  margin-inline-end: 0.25rem;\n}\n\n@media (width <= 1264px) {\n  .v-application .fc .fc-toolbar-chunk .fc-button-group .fc-drawerToggler-button {\n    display: block !important;\n  }\n}\n\n.v-theme--dark .v-application .fc .fc-toolbar-chunk .fc-button-group .fc-drawerToggler-button {\n  background-image: url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='rgba(232,232,241,0.68)' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round' class='css-i6dzq1'%3E%3Cpath d='M3 12h18M3 6h18M3 18h18'/%3E%3C/svg%3E\");\n}\n\n.v-application .fc .fc-col-header,\n.v-application .fc .fc-daygrid-body,\n.v-application .fc .fc-scrollgrid-sync-table,\n.v-application .fc .fc-timegrid-body,\n.v-application .fc .fc-timegrid-body table {\n  inline-size: 100% !important;\n}\n\n.calendars-checkbox .v-label {\n  color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));\n  opacity: var(--v-high-emphasis-opacity);\n}\n\n.calendar-add-event-drawer.v-navigation-drawer:not(.v-navigation-drawer--temporary) {\n  border-end-start-radius: 0.375rem;\n  border-start-start-radius: 0.375rem;\n}\n\n.v-layout[data-v-85990893] {\n  overflow: visible !important;\n}\n\n.v-layout .v-card[data-v-85990893] {\n  overflow: visible;\n}\n\n.v-application .fc-v-event {\n  background-color: transparent;\n}\n\n@media (width <= 776px) {\n  .fc-daygrid-event-harness {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/views/subscribe/SubscribeListView.vue",
    "content": "<script lang=\"ts\" setup>\nimport draggable from 'vuedraggable'\nimport api from '@/api'\nimport type { Subscribe } from '@/api/types'\nimport NoDataFound from '@/components/NoDataFound.vue'\nimport SubscribeCard from '@/components/cards/SubscribeCard.vue'\nimport SubscribeHistoryDialog from '@/components/dialog/SubscribeHistoryDialog.vue'\nimport { useUserStore } from '@/stores'\nimport { useI18n } from 'vue-i18n'\nimport { useToast } from 'vue-toastification'\nimport { useConfirm } from '@/composables/useConfirm'\n\n// 国际化\nconst { t } = useI18n()\n\n// 用户 Store\nconst userStore = useUserStore()\n\n// 提示框\nconst $toast = useToast()\n\n// 确认框\nconst createConfirm = useConfirm()\n\n// 从 Store 中获取用户信息\nconst superUser = userStore.superUser\nconst userName = userStore.userName\n\n// 输入参数\nconst props = defineProps({\n  type: String,\n  subid: String,\n  keyword: String,\n  statusFilter: String,\n})\n\n// 是否刷新过\nlet isRefreshed = ref(false)\n\n// 刷新状态\nconst loading = ref(false)\n\n// 数据列表\nconst dataList = ref<Subscribe[]>([])\n\n// 历史记录弹窗\nconst historyDialog = ref(false)\n\n// 订阅顺序配置\nconst orderConfig = ref<{ id: number }[]>([])\n\n// 显示的订阅列表\nconst displayList = ref<Subscribe[]>([])\n\n// 批量管理相关状态\nconst isBatchMode = ref(false)\nconst selectedSubscribes = ref<number[]>([])\n\n// 根据订阅数据判断订阅状态\nfunction getSubscribeStatus(subscribe: Subscribe) {\n  // 洗版中\n  if (subscribe.best_version) {\n    return 'best_version'\n  }\n\n  // 根据订阅状态判断\n  if (subscribe.state === 'P') {\n    return 'pending' // 待定\n  } else if (subscribe.state === 'S') {\n    return 'paused' // 暂停\n  }\n\n  // 如果是电影，只有洗版和状态\n  if (subscribe.type === '电影') {\n    return 'all'\n  }\n\n  // 电视剧根据集数情况判断\n  if (subscribe.total_episode && subscribe.total_episode > 0) {\n    const lackEpisode = subscribe.lack_episode || 0\n    const completedEpisode = subscribe.total_episode - lackEpisode\n\n    if (lackEpisode === 0) {\n      return 'completed' // 订阅完成\n    } else if (completedEpisode > 0) {\n      return 'subscribing' // 订阅中\n    } else {\n      return 'not_started' // 未开始\n    }\n  }\n\n  return 'not_started' // 默认未开始\n}\n\n// API请求键值（计算属性）\nconst orderRequestKey = computed(() => (props.type === '电影' ? 'SubscribeMovieOrder' : 'SubscribeTvOrder'))\n\n// 监听dataList变化，同步更新displayList\nwatch([dataList, () => props.keyword, () => props.statusFilter], () => {\n  if (superUser)\n    displayList.value = dataList.value.filter(\n      data =>\n        data.type === props.type &&\n        (!props.keyword || data.name.toLowerCase().includes(props.keyword.toLowerCase())) &&\n        (!props.statusFilter || props.statusFilter === 'all' || getSubscribeStatus(data) === props.statusFilter),\n    )\n  else\n    displayList.value = dataList.value.filter(\n      data =>\n        data.type === props.type &&\n        data.username === userName &&\n        (!props.keyword || data.name.toLowerCase().includes(props.keyword.toLowerCase())) &&\n        (!props.statusFilter || props.statusFilter === 'all' || getSubscribeStatus(data) === props.statusFilter),\n    )\n  // 排序\n  sortSubscribeOrder()\n})\n\n// 加载顺序\nasync function loadSubscribeOrderConfig() {\n  try {\n    const response = await api.get(`/user/config/${orderRequestKey.value}`)\n    if (response && response.data && response.data.value) {\n      orderConfig.value = response.data.value\n    }\n  } catch (error) {\n    console.error('Failed to load subscribe order config:', error)\n    orderConfig.value = []\n  }\n}\n\n// 按order的顺序排序\nasync function sortSubscribeOrder() {\n  if (!orderConfig.value) {\n    return\n  }\n  if (displayList.value.length === 0) {\n    return\n  }\n  await loadSubscribeOrderConfig()\n  displayList.value.sort((a, b) => {\n    const aIndex = orderConfig.value.findIndex((item: { id: number }) => item.id === a.id)\n    const bIndex = orderConfig.value.findIndex((item: { id: number }) => item.id === b.id)\n    return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex)\n  })\n}\n\n// 保存顺序设置\nasync function saveSubscribeOrder() {\n  // 顺序配置\n  const orderObj = displayList.value.map(item => ({ id: item.id }))\n  orderConfig.value = orderObj\n\n  // 保存到服务端\n  try {\n    await api.post(`/user/config/${orderRequestKey.value}`, orderObj)\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 获取订阅列表数据\nasync function fetchData() {\n  try {\n    loading.value = true\n    dataList.value = await api.get('subscribe/')\n    loading.value = false\n    isRefreshed.value = true\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 历史记录窗口完成\nfunction historyDone() {\n  historyDialog.value = false\n  fetchData()\n}\n\nfunction openHistoryDialog() {\n  historyDialog.value = true\n}\n\n// 批量管理相关函数\n// 切换批量模式\nfunction toggleBatchMode() {\n  isBatchMode.value = !isBatchMode.value\n  if (!isBatchMode.value) {\n    selectedSubscribes.value = []\n  }\n}\n\n// 全选/取消全选\nfunction toggleSelectAll() {\n  if (selectedSubscribes.value.length === displayList.value.length) {\n    selectedSubscribes.value = []\n  } else {\n    selectedSubscribes.value = displayList.value.map(item => item.id)\n  }\n}\n\n// 选择单个订阅\nfunction toggleSelectSubscribe(id: number) {\n  const index = selectedSubscribes.value.indexOf(id)\n  if (index > -1) {\n    selectedSubscribes.value.splice(index, 1)\n  } else {\n    selectedSubscribes.value.push(id)\n  }\n}\n\n// 批量删除订阅\nasync function batchDeleteSubscribes() {\n  if (selectedSubscribes.value.length === 0) {\n    $toast.warning(t('subscribe.noSelectedItems'))\n    return\n  }\n\n  const isConfirmed = await createConfirm({\n    title: t('common.confirm'),\n    content: t('subscribe.batchDeleteConfirm', { count: selectedSubscribes.value.length }),\n  })\n\n  if (!isConfirmed) return\n\n  try {\n    loading.value = true\n    const promises = selectedSubscribes.value.map(id => api.delete(`subscribe/${id}`))\n    const results = await Promise.allSettled(promises)\n\n    const successCount = results.filter(result => result.status === 'fulfilled').length\n    const failedCount = results.length - successCount\n\n    if (successCount > 0) {\n      $toast.success(t('subscribe.batchDeleteSuccess', { count: successCount }))\n    }\n    if (failedCount > 0) {\n      $toast.error(t('subscribe.batchDeleteFailed', { count: failedCount }))\n    }\n\n    // 刷新数据\n    await fetchData()\n    // 退出批量模式\n    isBatchMode.value = false\n    selectedSubscribes.value = []\n  } catch (error) {\n    console.error(error)\n    $toast.error(t('subscribe.batchDeleteError'))\n  } finally {\n    loading.value = false\n  }\n}\n\n// 批量启用订阅\nasync function batchEnableSubscribes() {\n  if (selectedSubscribes.value.length === 0) {\n    $toast.warning(t('subscribe.noSelectedItems'))\n    return\n  }\n\n  const isConfirmed = await createConfirm({\n    title: t('common.confirm'),\n    content: t('subscribe.batchEnableConfirm', { count: selectedSubscribes.value.length }),\n  })\n\n  if (!isConfirmed) return\n\n  try {\n    loading.value = true\n    const promises = selectedSubscribes.value.map(id => api.put(`subscribe/status/${id}?state=R`))\n    const results = await Promise.allSettled(promises)\n\n    const successCount = results.filter(result => result.status === 'fulfilled').length\n    const failedCount = results.length - successCount\n\n    if (successCount > 0) {\n      $toast.success(t('subscribe.batchEnableSuccess', { count: successCount }))\n    }\n    if (failedCount > 0) {\n      $toast.error(t('subscribe.batchEnableFailed', { count: failedCount }))\n    }\n\n    // 刷新数据\n    await fetchData()\n    // 退出批量模式\n    isBatchMode.value = false\n    selectedSubscribes.value = []\n  } catch (error) {\n    console.error(error)\n    $toast.error(t('subscribe.batchEnableError'))\n  } finally {\n    loading.value = false\n  }\n}\n\n// 批量暂停订阅\nasync function batchPauseSubscribes() {\n  if (selectedSubscribes.value.length === 0) {\n    $toast.warning(t('subscribe.noSelectedItems'))\n    return\n  }\n\n  const isConfirmed = await createConfirm({\n    title: t('common.confirm'),\n    content: t('subscribe.batchPauseConfirm', { count: selectedSubscribes.value.length }),\n  })\n\n  if (!isConfirmed) return\n\n  try {\n    loading.value = true\n    const promises = selectedSubscribes.value.map(id => api.put(`subscribe/status/${id}?state=S`))\n    const results = await Promise.allSettled(promises)\n\n    const successCount = results.filter(result => result.status === 'fulfilled').length\n    const failedCount = results.length - successCount\n\n    if (successCount > 0) {\n      $toast.success(t('subscribe.batchPauseSuccess', { count: successCount }))\n    }\n    if (failedCount > 0) {\n      $toast.error(t('subscribe.batchPauseFailed', { count: failedCount }))\n    }\n\n    // 刷新数据\n    await fetchData()\n    // 退出批量模式\n    isBatchMode.value = false\n    selectedSubscribes.value = []\n  } catch (error) {\n    console.error(error)\n    $toast.error(t('subscribe.batchPauseError'))\n  } finally {\n    loading.value = false\n  }\n}\n\n// 错误描述\nconst errorDescription = computed(() => {\n  if ((props.statusFilter && props.statusFilter !== 'all') || props.keyword) {\n    return t('common.tryChangingFilters')\n  }\n  return t('subscribe.noSubscribeData')\n})\n\n// 错误标题\nconst errorTitle = computed(() => {\n  if ((props.statusFilter && props.statusFilter !== 'all') || props.keyword) {\n    return t('common.noMatchingData')\n  }\n  return t('common.noData')\n})\n\nonMounted(async () => {\n  await fetchData()\n  if (props.subid) {\n    // 找到这个订阅\n    const sub = dataList.value.find(sub => sub.id.toString() == props.subid?.toString())\n    if (sub) {\n      // 打开编辑弹窗\n      sub.page_open = true\n    }\n  }\n\n  // 监听批量管理模式切换事件\n  window.addEventListener('toggle-batch-mode', toggleBatchMode)\n})\n\nonUnmounted(() => {\n  // 移除事件监听器\n  window.removeEventListener('toggle-batch-mode', toggleBatchMode)\n})\n\nonActivated(async () => {\n  if (!loading.value) {\n    fetchData()\n  }\n})\n\ndefineExpose({\n  openHistoryDialog,\n})\n</script>\n\n<template>\n  <LoadingBanner v-if=\"!isRefreshed\" class=\"mt-12\" />\n\n  <!-- 批量管理工具栏 -->\n  <div v-if=\"isBatchMode\" class=\"mb-4 px-2\">\n    <VCard class=\"pa-4\">\n      <div class=\"d-flex align-center justify-space-between\">\n        <div class=\"d-flex align-center\">\n          <VCheckbox\n            :model-value=\"selectedSubscribes.length === displayList.length\"\n            :indeterminate=\"selectedSubscribes.length > 0 && selectedSubscribes.length < displayList.length\"\n            @update:model-value=\"toggleSelectAll\"\n            hide-details\n            class=\"me-4\"\n          />\n          <span class=\"text-body-1 font-weight-medium\">\n            {{ t('subscribe.selectedCount', { count: selectedSubscribes.length, total: displayList.length }) }}\n          </span>\n        </div>\n        <div class=\"d-flex gap-2\">\n          <VBtn\n            color=\"success\"\n            variant=\"outlined\"\n            size=\"small\"\n            :disabled=\"selectedSubscribes.length === 0\"\n            @click=\"batchEnableSubscribes\"\n          >\n            <VIcon icon=\"mdi-play\" class=\"me-sm-1\" />\n            <span class=\"d-none d-sm-inline\">{{ t('subscribe.batchEnable') }}</span>\n          </VBtn>\n          <VBtn\n            color=\"info\"\n            variant=\"outlined\"\n            size=\"small\"\n            :disabled=\"selectedSubscribes.length === 0\"\n            @click=\"batchPauseSubscribes\"\n          >\n            <VIcon icon=\"mdi-pause\" class=\"me-sm-1\" />\n            <span class=\"d-none d-sm-inline\">{{ t('subscribe.batchPause') }}</span>\n          </VBtn>\n          <VBtn\n            color=\"error\"\n            variant=\"outlined\"\n            size=\"small\"\n            :disabled=\"selectedSubscribes.length === 0\"\n            @click=\"batchDeleteSubscribes\"\n          >\n            <VIcon icon=\"mdi-delete\" class=\"me-sm-1\" />\n            <span class=\"d-none d-sm-inline\">{{ t('subscribe.batchDelete') }}</span>\n          </VBtn>\n          <VBtn color=\"secondary\" variant=\"outlined\" size=\"small\" @click=\"toggleBatchMode\">\n            <VIcon icon=\"mdi-close\" class=\"me-sm-1\" />\n            <span class=\"d-none d-sm-inline\">{{ t('common.cancel') }}</span>\n          </VBtn>\n        </div>\n      </div>\n    </VCard>\n  </div>\n\n  <draggable\n    v-if=\"displayList.length > 0\"\n    v-model=\"displayList\"\n    @end=\"saveSubscribeOrder\"\n    handle=\".cursor-move\"\n    item-key=\"id\"\n    tag=\"div\"\n    :component-data=\"{ class: 'grid gap-4 grid-subscribe-card px-2' }\"\n    :disabled=\"props.keyword || (props.statusFilter && props.statusFilter !== 'all') || isBatchMode\"\n  >\n    <template #item=\"{ element }\">\n      <SubscribeCard\n        :key=\"element.id\"\n        :media=\"element\"\n        :batch-mode=\"isBatchMode\"\n        :selected=\"selectedSubscribes.includes(element.id)\"\n        @remove=\"fetchData\"\n        @save=\"fetchData\"\n        @select=\"toggleSelectSubscribe(element.id)\"\n      />\n    </template>\n  </draggable>\n  <NoDataFound\n    v-if=\"displayList.length === 0 && isRefreshed\"\n    error-code=\"404\"\n    :error-title=\"errorTitle\"\n    :error-description=\"errorDescription\"\n  />\n  <!-- 历史记录弹窗 -->\n  <SubscribeHistoryDialog\n    v-if=\"historyDialog\"\n    v-model=\"historyDialog\"\n    :type=\"props.type\"\n    @close=\"historyDialog = false\"\n    @save=\"historyDone\"\n  />\n</template>\n"
  },
  {
    "path": "src/views/subscribe/SubscribePopularView.vue",
    "content": "<script lang=\"ts\" setup>\nimport api from '@/api'\nimport type { MediaInfo } from '@/api/types'\nimport MediaCard from '@/components/cards/MediaCard.vue'\nimport NoDataFound from '@/components/NoDataFound.vue'\nimport { useI18n } from 'vue-i18n'\n\n// 国际化\nconst { t } = useI18n()\n\n// 输入参数\nconst props = defineProps({\n  type: String,\n})\n\n// 判断是否有滚动条\nfunction hasScroll() {\n  return document.body.scrollHeight - (window.innerHeight || document.documentElement.clientHeight) > 2\n}\n\n// API\nconst apipath = 'subscribe/popular'\n\n// 当前页码\nconst page = ref(1)\n\n// 是否加载中\nconst loading = ref(false)\n\n// 是否加载完成\nconst isRefreshed = ref(false)\n\n// 数据列表\nconst dataList = ref<MediaInfo[]>([])\nconst currData = ref<MediaInfo[]>([])\n\n// 筛选参数\nconst filterParams = reactive({\n  genre_id: '', // 空字符串表示选中\"全部\"\n  min_rating: 0,\n  max_rating: 10,\n  min_sub: 1,\n  sort_type: 'count', // 默认按热度排序\n})\n\n// 当前Key（用于重新加载数据）\nconst currentKey = ref(0)\n\n// TMDB电影风格字典\nconst tmdbMovieGenreDict: Record<string, string> = {\n  '28': t('tmdb.genreType.action'),\n  '12': t('tmdb.genreType.adventure'),\n  '16': t('tmdb.genreType.animation'),\n  '35': t('tmdb.genreType.comedy'),\n  '80': t('tmdb.genreType.crime'),\n  '99': t('tmdb.genreType.documentary'),\n  '18': t('tmdb.genreType.drama'),\n  '10751': t('tmdb.genreType.family'),\n  '14': t('tmdb.genreType.fantasy'),\n  '36': t('tmdb.genreType.history'),\n  '27': t('tmdb.genreType.horror'),\n  '10402': t('tmdb.genreType.music'),\n  '9648': t('tmdb.genreType.mystery'),\n  '10749': t('tmdb.genreType.romance'),\n  '878': t('tmdb.genreType.scienceFiction'),\n  '10770': t('tmdb.genreType.tvMovie'),\n  '53': t('tmdb.genreType.thriller'),\n  '10752': t('tmdb.genreType.war'),\n  '37': t('tmdb.genreType.western'),\n}\n\n// TMDB电视剧风格字典\nconst tmdbTvGenreDict: Record<string, string> = {\n  '10759': t('tmdb.genreType.actionAdventure'),\n  '16': t('tmdb.genreType.animation'),\n  '35': t('tmdb.genreType.comedy'),\n  '80': t('tmdb.genreType.crime'),\n  '99': t('tmdb.genreType.documentary'),\n  '18': t('tmdb.genreType.drama'),\n  '10751': t('tmdb.genreType.family'),\n  '10762': t('tmdb.genreType.kids'),\n  '9648': t('tmdb.genreType.mystery'),\n  '10763': t('tmdb.genreType.news'),\n  '10764': t('tmdb.genreType.reality'),\n  '10765': t('tmdb.genreType.sciFiFantasy'),\n  '10766': t('tmdb.genreType.soap'),\n  '10767': t('tmdb.genreType.talk'),\n  '10768': t('tmdb.genreType.warPolitics'),\n  '37': t('tmdb.genreType.western'),\n}\n\n// 获取当前类型对应的风格字典\nconst currentGenreDict = computed(() => {\n  return props.type === '电影' ? tmdbMovieGenreDict : tmdbTvGenreDict\n})\n\n// 监听筛选参数变化\nwatch(\n  filterParams,\n  () => {\n    // 重置数据\n    dataList.value = []\n    page.value = 1\n    isRefreshed.value = false\n    currentKey.value++\n  },\n  { deep: true },\n)\n\n// 拼装参数\nfunction getParams() {\n  let params: { [key: string]: any } = {\n    stype: props.type,\n    page: page.value,\n    count: 30,\n  }\n\n  // 添加筛选参数\n  if (filterParams.genre_id) {\n    params.genre_id = parseInt(filterParams.genre_id)\n  }\n  if (filterParams.min_rating > 0) {\n    params.min_rating = filterParams.min_rating\n  }\n  if (filterParams.max_rating < 10) {\n    params.max_rating = filterParams.max_rating\n  }\n  if (filterParams.min_sub > 1) {\n    params.min_sub = filterParams.min_sub\n  }\n  if (filterParams.sort_type) {\n    params.sort_type = filterParams.sort_type\n  }\n\n  return params\n}\n\n// 获取列表数据\nasync function fetchData({ done }: { done: any }) {\n  try {\n    // 如果正在加载中，直接返回\n    if (loading.value) {\n      done('ok')\n      return\n    }\n\n    // 加载到满屏或者加载出错\n    if (!hasScroll()) {\n      // 加载多次\n      while (!hasScroll()) {\n        // 设置加载中\n        loading.value = true\n        // 请求API\n        currData.value = await api.get(apipath, {\n          params: getParams(),\n        })\n        // 取消加载中\n        loading.value = false\n        // 标计为已请求完成\n        isRefreshed.value = true\n        if (currData.value.length === 0) {\n          // 如果没有数据，跳出\n          done('empty')\n          return\n        }\n        // 合并数据\n        dataList.value = [...dataList.value, ...currData.value]\n        // 页码+1\n        page.value++\n        // 返回加载成功\n        done('ok')\n      }\n    } else {\n      // 设置加载中\n      loading.value = true\n      // 请求API\n      currData.value = await api.get(apipath, {\n        params: getParams(),\n      })\n      loading.value = false\n      // 标计为已请求完成\n      isRefreshed.value = true\n      if (currData.value.length === 0) {\n        // 如果没有数据，跳出\n        done('empty')\n      } else {\n        // 合并数据\n        dataList.value = [...dataList.value, ...currData.value]\n        // 页码+1\n        page.value++\n        // 返回加载成功\n        done('ok')\n      }\n    }\n  } catch (error) {\n    console.error(error)\n    // 返回加载失败\n    done('error')\n  }\n}\n</script>\n\n<template>\n  <!-- 筛选器 -->\n  <div class=\"px-3 mb-4\">\n    <div class=\"flex justify-start align-center mb-3\">\n      <div class=\"mr-5\">\n        <VLabel>{{ t('tmdb.sort') }}</VLabel>\n      </div>\n      <VChipGroup v-model=\"filterParams.sort_type\">\n        <VChip :color=\"filterParams.sort_type == 'time' ? 'primary' : ''\" filter tile value=\"time\">\n          {{ t('tmdb.sortType.time') }}\n        </VChip>\n        <VChip :color=\"filterParams.sort_type == 'count' ? 'primary' : ''\" filter tile value=\"count\">\n          {{ t('tmdb.sortType.count') }}\n        </VChip>\n        <VChip :color=\"filterParams.sort_type == 'rating' ? 'primary' : ''\" filter tile value=\"rating\">\n          {{ t('tmdb.sortType.rating') }}\n        </VChip>\n      </VChipGroup>\n    </div>\n\n    <div class=\"flex justify-start align-center mb-3\">\n      <div class=\"mr-5\">\n        <VLabel>{{ t('tmdb.genre') }}</VLabel>\n      </div>\n      <VChipGroup v-model=\"filterParams.genre_id\">\n        <VChip\n          :color=\"filterParams.genre_id == '' ? 'primary' : ''\"\n          filter\n          tile\n          value=\"\"\n        >\n          {{ t('common.all') }}\n        </VChip>\n        <VChip\n          :color=\"filterParams.genre_id == key ? 'primary' : ''\"\n          filter\n          tile\n          :value=\"key\"\n          v-for=\"(value, key) in currentGenreDict\"\n          :key=\"key\"\n        >\n          {{ value }}\n        </VChip>\n      </VChipGroup>\n    </div>\n\n    <div class=\"flex justify-start align-center mb-3\">\n      <div class=\"mr-5\">\n        <VLabel>{{ t('tmdb.rating') }}</VLabel>\n      </div>\n      <VSlider\n        v-model=\"filterParams.min_rating\"\n        thumb-label\n        max=\"10\"\n        min=\"0\"\n        :step=\"1\"\n        class=\"align-center\"\n        hide-details\n      >\n      </VSlider>\n    </div>\n  </div>\n\n  <LoadingBanner v-if=\"!isRefreshed\" class=\"mt-12\" />\n  <VInfiniteScroll\n    mode=\"intersect\"\n    side=\"end\"\n    :items=\"dataList\"\n    class=\"overflow-visible px-2\"\n    @load=\"fetchData\"\n    :key=\"currentKey\"\n  >\n    <template #loading />\n    <template #empty />\n    <div v-if=\"dataList.length > 0\" class=\"grid gap-4 grid-media-card\" tabindex=\"0\">\n      <div v-for=\"data in dataList\" :key=\"data.tmdb_id || data.douban_id\">\n        <MediaCard :media=\"data\" />\n        <div v-if=\"data.popularity\" class=\"mt-2 flex flex-row justify-center align-center text-subtitle-2\">\n          <VIcon icon=\"mdi-fire\" color=\"error\" />\n          <span> {{ data.popularity.toLocaleString() }}</span>\n        </div>\n      </div>\n    </div>\n    <NoDataFound\n      v-if=\"dataList.length === 0 && isRefreshed\"\n      error-code=\"404\"\n      :error-title=\"t('common.noData')\"\n      :error-description=\"t('subscribe.noPopularData')\"\n    />\n  </VInfiniteScroll>\n</template>\n"
  },
  {
    "path": "src/views/subscribe/SubscribeShareView.vue",
    "content": "<script lang=\"ts\" setup>\nimport api from '@/api'\nimport type { SubscribeShare } from '@/api/types'\nimport NoDataFound from '@/components/NoDataFound.vue'\nimport SubscribeShareCard from '@/components/cards/SubscribeShareCard.vue'\nimport { useI18n } from 'vue-i18n'\n\n// 国际化\nconst { t } = useI18n()\n\n// 定义输入参数\nconst props = defineProps({\n  // 过滤关键字\n  keyword: String,\n})\n\n// 判断是否有滚动条\nfunction hasScroll() {\n  return document.body.scrollHeight - (window.innerHeight || document.documentElement.clientHeight) > 2\n}\n\n// API\nconst apipath = 'subscribe/shares'\n\n// 当前页码\nconst page = ref(1)\n\n// 搜索关键字\nconst keyword = ref(props.keyword)\n\n// 筛选参数\nconst filterParams = reactive({\n  genre_id: '', // 空字符串表示选中\"全部\"\n  min_rating: 0,\n  max_rating: 10,\n  sort_type: 'time', // 默认按时间排序\n})\n\n// 当前Key（用于重新加载数据）\nconst currentKey = ref(0)\n\n// TMDB电影风格字典\nconst tmdbMovieGenreDict: Record<string, string> = {\n  '28': t('tmdb.genreType.action'),\n  '12': t('tmdb.genreType.adventure'),\n  '16': t('tmdb.genreType.animation'),\n  '35': t('tmdb.genreType.comedy'),\n  '80': t('tmdb.genreType.crime'),\n  '99': t('tmdb.genreType.documentary'),\n  '18': t('tmdb.genreType.drama'),\n  '10751': t('tmdb.genreType.family'),\n  '14': t('tmdb.genreType.fantasy'),\n  '36': t('tmdb.genreType.history'),\n  '27': t('tmdb.genreType.horror'),\n  '10402': t('tmdb.genreType.music'),\n  '9648': t('tmdb.genreType.mystery'),\n  '10749': t('tmdb.genreType.romance'),\n  '878': t('tmdb.genreType.scienceFiction'),\n  '10770': t('tmdb.genreType.tvMovie'),\n  '53': t('tmdb.genreType.thriller'),\n  '10752': t('tmdb.genreType.war'),\n  '37': t('tmdb.genreType.western'),\n}\n\n// TMDB电视剧风格字典\nconst tmdbTvGenreDict: Record<string, string> = {\n  '10759': t('tmdb.genreType.actionAdventure'),\n  '16': t('tmdb.genreType.animation'),\n  '35': t('tmdb.genreType.comedy'),\n  '80': t('tmdb.genreType.crime'),\n  '99': t('tmdb.genreType.documentary'),\n  '18': t('tmdb.genreType.drama'),\n  '10751': t('tmdb.genreType.family'),\n  '10762': t('tmdb.genreType.kids'),\n  '9648': t('tmdb.genreType.mystery'),\n  '10763': t('tmdb.genreType.news'),\n  '10764': t('tmdb.genreType.reality'),\n  '10765': t('tmdb.genreType.sciFiFantasy'),\n  '10766': t('tmdb.genreType.soap'),\n  '10767': t('tmdb.genreType.talk'),\n  '10768': t('tmdb.genreType.warPolitics'),\n  '37': t('tmdb.genreType.western'),\n}\n\n// 获取当前类型对应的风格字典（订阅分享包含电影和电视剧，所以显示所有风格）\nconst currentGenreDict = computed(() => {\n  // 合并电影和电视剧风格字典\n  return { ...tmdbMovieGenreDict, ...tmdbTvGenreDict }\n})\n\n// 监听 props.keyword 变化\nwatch(\n  () => props.keyword,\n  newKeyword => {\n    keyword.value = newKeyword || ''\n    // 重置页码和数据\n    page.value = 1\n    dataList.value = []\n    isRefreshed.value = false\n    currentKey.value++\n  },\n)\n\n// 监听筛选参数变化\nwatch(\n  filterParams,\n  () => {\n    // 重置数据\n    dataList.value = []\n    page.value = 1\n    isRefreshed.value = false\n    currentKey.value++\n  },\n  { deep: true },\n)\n\n// 是否加载中\nconst loading = ref(false)\n\n// 是否加载完成\nconst isRefreshed = ref(false)\n\n// 数据列表\nconst dataList = ref<SubscribeShare[]>([])\nconst currData = ref<SubscribeShare[]>([])\n\n// 拼装参数\nfunction getParams() {\n  let params: { [key: string]: any } = {\n    page: page.value,\n    count: 30,\n    name: keyword.value,\n  }\n\n  // 添加筛选参数\n  if (filterParams.genre_id) {\n    params.genre_id = parseInt(filterParams.genre_id)\n  }\n  if (filterParams.min_rating > 0) {\n    params.min_rating = filterParams.min_rating\n  }\n  if (filterParams.max_rating < 10) {\n    params.max_rating = filterParams.max_rating\n  }\n  if (filterParams.sort_type) {\n    params.sort_type = filterParams.sort_type\n  }\n\n  return params\n}\n\n// 获取列表数据\nasync function fetchData({ done }: { done: any }) {\n  try {\n    // 如果正在加载中，直接返回\n    if (loading.value) {\n      done('ok')\n      return\n    }\n\n    // 加载到满屏或者加载出错\n    if (!hasScroll()) {\n      // 加载多次\n      while (!hasScroll()) {\n        // 设置加载中\n        loading.value = true\n        // 请求API\n        currData.value = await api.get(apipath, {\n          params: getParams(),\n        })\n        // 取消加载中\n        loading.value = false\n        // 标计为已请求完成\n        isRefreshed.value = true\n        if (currData.value.length === 0) {\n          // 如果没有数据，跳出\n          done('empty')\n          return\n        }\n        // 合并数据\n        dataList.value = [...dataList.value, ...currData.value]\n        // 页码+1\n        page.value++\n        // 返回加载成功\n        done('ok')\n      }\n    } else {\n      // 设置加载中\n      loading.value = true\n      // 请求API\n      currData.value = await api.get(apipath, {\n        params: getParams(),\n      })\n      loading.value = false\n      // 标计为已请求完成\n      isRefreshed.value = true\n      if (currData.value.length === 0) {\n        // 如果没有数据，跳出\n        done('empty')\n      } else {\n        // 合并数据\n        dataList.value = [...dataList.value, ...currData.value]\n        // 页码+1\n        page.value++\n        // 返回加载成功\n        done('ok')\n      }\n    }\n  } catch (error) {\n    console.error(error)\n    // 返回加载失败\n    done('error')\n  }\n}\n\n// 将数据从列表中移除\nfunction removeData(id: number) {\n  dataList.value = dataList.value.filter(item => item.id !== id)\n}\n</script>\n\n<template>\n  <!-- 筛选器 -->\n  <div class=\"px-3 mb-4\">\n    <div class=\"flex justify-start align-center mb-3\">\n      <div class=\"mr-5\">\n        <VLabel>{{ t('tmdb.sort') }}</VLabel>\n      </div>\n      <VChipGroup v-model=\"filterParams.sort_type\">\n        <VChip :color=\"filterParams.sort_type == 'time' ? 'primary' : ''\" filter tile value=\"time\">\n          {{ t('tmdb.sortType.time') }}\n        </VChip>\n        <VChip :color=\"filterParams.sort_type == 'count' ? 'primary' : ''\" filter tile value=\"count\">\n          {{ t('tmdb.sortType.count') }}\n        </VChip>\n        <VChip :color=\"filterParams.sort_type == 'rating' ? 'primary' : ''\" filter tile value=\"rating\">\n          {{ t('tmdb.sortType.rating') }}\n        </VChip>\n      </VChipGroup>\n    </div>\n\n    <div class=\"flex justify-start align-center mb-3\">\n      <div class=\"mr-5\">\n        <VLabel>{{ t('tmdb.genre') }}</VLabel>\n      </div>\n      <VChipGroup v-model=\"filterParams.genre_id\">\n        <VChip\n          :color=\"filterParams.genre_id == '' ? 'primary' : ''\"\n          filter\n          tile\n          value=\"\"\n        >\n          {{ t('common.all') }}\n        </VChip>\n        <VChip\n          :color=\"filterParams.genre_id == key ? 'primary' : ''\"\n          filter\n          tile\n          :value=\"key\"\n          v-for=\"(value, key) in currentGenreDict\"\n          :key=\"key\"\n        >\n          {{ value }}\n        </VChip>\n      </VChipGroup>\n    </div>\n\n    <div class=\"flex justify-start align-center mb-3\">\n      <div class=\"mr-5\">\n        <VLabel>{{ t('tmdb.rating') }}</VLabel>\n      </div>\n      <VSlider\n        v-model=\"filterParams.min_rating\"\n        thumb-label\n        max=\"10\"\n        min=\"0\"\n        :step=\"1\"\n        class=\"align-center\"\n        hide-details\n      >\n      </VSlider>\n    </div>\n  </div>\n\n  <VPageContentTitle v-if=\"keyword\" :title=\"`${t('common.search')}：${keyword}`\" />\n  <LoadingBanner v-if=\"!isRefreshed\" class=\"mt-12\" />\n  <VInfiniteScroll\n    mode=\"intersect\"\n    side=\"end\"\n    :items=\"dataList\"\n    class=\"overflow-visible px-2\"\n    @load=\"fetchData\"\n    :key=\"currentKey\"\n  >\n    <template #loading />\n    <template #empty />\n    <div v-if=\"dataList.length > 0\" class=\"grid gap-4 grid-subscribe-card\" tabindex=\"0\">\n      <div v-for=\"data in dataList\" :key=\"data.id\">\n        <SubscribeShareCard :media=\"data\" @delete=\"removeData(data.id || 0)\" />\n      </div>\n    </div>\n    <NoDataFound\n      v-if=\"dataList.length === 0 && isRefreshed\"\n      error-code=\"404\"\n      :error-title=\"t('common.noData')\"\n      :error-description=\"keyword ? t('common.noContent') : t('subscribe.noShareData')\"\n    />\n  </VInfiniteScroll>\n</template>\n"
  },
  {
    "path": "src/views/system/CacheView.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useToast } from 'vue-toastification'\nimport api from '@/api'\nimport type { TorrentCacheData, TorrentCacheItem } from '@/api/types'\nimport { useI18n } from 'vue-i18n'\nimport { formatFileSize, formatDateDifference } from '@core/utils/formatters'\nimport { useConfirm } from '@/composables/useConfirm'\nimport { useGlobalSettingsStore } from '@/stores'\nimport { usePWA } from '@/composables/usePWA'\n\n// 国际化\nconst { t } = useI18n()\n\n// PWA模式检测\nconst { appMode } = usePWA()\n\n// 全局设置\nconst globalSettingsStore = useGlobalSettingsStore()\nconst globalSettings = globalSettingsStore.globalSettings\n\n// 确认框\nconst createConfirm = useConfirm()\n\n// 提示框\nconst $toast = useToast()\n\n// 缓存数据\nconst cacheData = ref<TorrentCacheData>({\n  count: 0,\n  sites: 0,\n  data: [],\n})\n\n// 筛选条件\nconst titleFilter = ref<string | null>(null)\nconst siteFilter = ref<string | null>(null)\n\n// 获取所有站点选项\nconst siteOptions = computed(() => {\n  const sites = new Set<string>()\n  cacheData.value.data.forEach(item => {\n    if (item.site_name) {\n      sites.add(item.site_name)\n    }\n  })\n  return Array.from(sites).sort()\n})\n\n// 筛选后的数据\nconst filteredData = computed(() => {\n  return cacheData.value.data.filter(item => {\n    const titleMatch = !titleFilter.value || item.title?.toLowerCase().includes(titleFilter.value?.toLowerCase())\n    const siteMatch = !siteFilter.value || item.site_name === siteFilter.value\n    return titleMatch && siteMatch\n  })\n})\n\n// 选中的缓存项\nconst selectedItems = ref<string[]>([])\n\n// 加载状态\nconst loading = ref(false)\n\n// 重新识别对话框\nconst reidentifyDialog = ref(false)\nconst currentReidentifyItem = ref<TorrentCacheItem | null>(null)\nconst tmdbId = ref<number | undefined>()\nconst doubanId = ref<string | undefined>()\n\nconst tableStyle = computed(() => {\n  return appMode ? '' : 'height: calc(100vh - 21rem - env(safe-area-inset-bottom)'\n})\n\n// 调用API加载缓存数据\nasync function loadCacheData() {\n  try {\n    loading.value = true\n    const res: any = await api.get('torrent/cache')\n    cacheData.value = res.data\n  } catch (e) {\n    console.log(e)\n    $toast.error(t('setting.cache.loadFailed'))\n  } finally {\n    loading.value = false\n  }\n}\n\n// 清空所有缓存\nasync function clearAllCache() {\n  const isConfirmed = await createConfirm({\n    type: 'warn',\n    title: t('common.confirm'),\n    content: t('setting.cache.clearConfirm'),\n  })\n\n  if (!isConfirmed) return\n  try {\n    loading.value = true\n    await api.delete('torrent/cache')\n    $toast.success(t('setting.cache.clearSuccess'))\n    await loadCacheData()\n    selectedItems.value = []\n  } catch (e) {\n    console.log(e)\n    $toast.error(t('setting.cache.clearFailed'))\n  } finally {\n    loading.value = false\n  }\n}\n\n// 刷新缓存\nasync function refreshCache() {\n  try {\n    loading.value = true\n    const res: any = await api.post('torrent/cache/refresh')\n    $toast.success(res.message || t('setting.cache.refreshSuccess'))\n    await loadCacheData()\n  } catch (e) {\n    console.log(e)\n    $toast.error(t('setting.cache.refreshFailed'))\n  } finally {\n    loading.value = false\n  }\n}\n\n// 删除选中的缓存项\nasync function deleteSelectedItems() {\n  if (selectedItems.value.length === 0) {\n    $toast.warning(t('setting.cache.selectDeleteWarning'))\n    return\n  }\n\n  try {\n    loading.value = true\n    const deletePromises = selectedItems.value.map(hash => {\n      const item = cacheData.value.data.find(d => d.hash === hash)\n      if (item) {\n        return api.delete(`torrent/cache/${item.domain}/${hash}`)\n      }\n      return Promise.resolve()\n    })\n\n    await Promise.all(deletePromises)\n    $toast.success(t('setting.cache.deleteSelectedSuccess', { count: selectedItems.value.length }))\n    await loadCacheData()\n    selectedItems.value = []\n  } catch (e) {\n    console.log(e)\n    $toast.error(t('setting.cache.deleteSelectedFailed'))\n  } finally {\n    loading.value = false\n  }\n}\n\n// 删除单个缓存项\nasync function deleteSingleItem(item: TorrentCacheItem) {\n  try {\n    loading.value = true\n    await api.delete(`torrent/cache/${item.domain}/${item.hash}`)\n    $toast.success(t('setting.cache.deleteSuccess'))\n    await loadCacheData()\n    // 从选中列表中移除\n    const index = selectedItems.value.indexOf(item.hash)\n    if (index > -1) {\n      selectedItems.value.splice(index, 1)\n    }\n  } catch (e) {\n    console.log(e)\n    $toast.error(t('setting.cache.deleteFailed'))\n  } finally {\n    loading.value = false\n  }\n}\n\n// 打开重新识别对话框\nfunction openReidentifyDialog(item: TorrentCacheItem) {\n  currentReidentifyItem.value = item\n  tmdbId.value = undefined\n  doubanId.value = undefined\n  reidentifyDialog.value = true\n}\n\n// 重新识别\nasync function performReidentify() {\n  if (!currentReidentifyItem.value) return\n\n  try {\n    loading.value = true\n    const params: any = {}\n    if (tmdbId.value) params.tmdbid = tmdbId.value\n    if (doubanId.value) params.doubanid = doubanId.value\n\n    const res: any = await api.post(\n      `torrent/cache/reidentify/${currentReidentifyItem.value.domain}/${currentReidentifyItem.value.hash}`,\n      null,\n      {\n        params,\n      },\n    )\n\n    $toast.success(res.message || t('setting.cache.reidentifySuccess'))\n    await loadCacheData()\n    reidentifyDialog.value = false\n  } catch (e) {\n    console.log(e)\n    $toast.error(t('setting.cache.reidentifyFailed'))\n  } finally {\n    loading.value = false\n  }\n}\n\n// 获取媒体类型颜色\nfunction getMediaTypeColor(type: string): string {\n  switch (type) {\n    case t('setting.cache.mediaType.movie'):\n      return 'primary'\n    case t('setting.cache.mediaType.tv'):\n      return 'success'\n    default:\n      return 'default'\n  }\n}\n\n// 打开详情页面\nfunction openPageUrl(url: string) {\n  window.open(url, '_blank')\n}\n\nonMounted(() => {\n  loadCacheData()\n})\n</script>\n\n<template>\n  <div>\n    <!-- 工具栏统计信息和操作按钮 -->\n    <VCard class=\"mb-4\">\n      <VCardItem>\n        <!-- 移动端垂直布局，桌面端水平布局 -->\n        <div class=\"d-flex flex-column flex-md-row align-center justify-space-between w-100 gap-4\">\n          <!-- 左侧统计信息 -->\n          <div class=\"d-flex align-center justify-center justify-md-start gap-2 gap-md-6 w-100 w-md-auto\">\n            <!-- 统计信息卡片 -->\n            <div class=\"d-flex gap-2 gap-md-4 flex-wrap justify-center justify-md-start\">\n              <VCard variant=\"tonal\" color=\"primary\" class=\"pa-2 pa-md-3 flex-grow-1 flex-md-grow-0\" style=\"min-width: 120px;\">\n                <div class=\"d-flex align-center gap-2\">\n                  <VIcon color=\"primary\" size=\"small\">mdi-database</VIcon>\n                  <div>\n                    <div class=\"text-h6 text-md-h6 font-weight-bold\">{{ cacheData.count }}</div>\n                    <div class=\"text-caption text-medium-emphasis\">{{ t('setting.cache.totalCount') }}</div>\n                  </div>\n                </div>\n              </VCard>\n\n              <VCard variant=\"tonal\" color=\"success\" class=\"pa-2 pa-md-3 flex-grow-1 flex-md-grow-0\" style=\"min-width: 120px;\">\n                <div class=\"d-flex align-center gap-2\">\n                  <VIcon color=\"success\" size=\"small\">mdi-web</VIcon>\n                  <div>\n                    <div class=\"text-h6 text-md-h6 font-weight-bold\">{{ cacheData.sites }}</div>\n                    <div class=\"text-caption text-medium-emphasis\">{{ t('setting.cache.siteCount') }}</div>\n                  </div>\n                </div>\n              </VCard>\n            </div>\n          </div>\n\n          <!-- 右侧操作按钮 -->\n          <div class=\"d-flex gap-1 gap-md-2 flex-wrap justify-center justify-md-end\">\n            <VBtn icon color=\"primary\" :loading=\"loading\" @click=\"refreshCache\" size=\"small\">\n              <VIcon size=\"small\">mdi-refresh</VIcon>\n              <VTooltip activator=\"parent\" location=\"bottom\">{{ t('setting.cache.refresh') }}</VTooltip>\n            </VBtn>\n\n            <VBtn\n              icon\n              color=\"warning\"\n              :loading=\"loading\"\n              :disabled=\"selectedItems.length === 0\"\n              @click=\"deleteSelectedItems\"\n              size=\"small\"\n            >\n              <VIcon size=\"small\">mdi-delete-sweep</VIcon>\n              <VTooltip activator=\"parent\" location=\"bottom\"\n                >{{ t('setting.cache.deleteSelected') }} ({{ selectedItems.length }})</VTooltip\n              >\n            </VBtn>\n\n            <VBtn icon color=\"error\" :loading=\"loading\" @click=\"clearAllCache\" size=\"small\">\n              <VIcon size=\"small\">mdi-delete-variant</VIcon>\n              <VTooltip activator=\"parent\" location=\"bottom\">{{ t('setting.cache.clearAll') }}</VTooltip>\n            </VBtn>\n          </div>\n        </div>\n      </VCardItem>\n    </VCard>\n\n    <!-- 筛选框 -->\n    <VRow class=\"mb-4\">\n      <VCol cols=\"6\">\n        <VTextField\n          v-model=\"titleFilter\"\n          :label=\"t('setting.cache.filterByTitle')\"\n          prepend-inner-icon=\"mdi-magnify\"\n          clearable\n          density=\"compact\"\n        />\n      </VCol>\n      <VCol cols=\"6\">\n        <VAutocomplete\n          v-model=\"siteFilter\"\n          :label=\"t('setting.cache.filterBySite')\"\n          :items=\"siteOptions\"\n          prepend-inner-icon=\"mdi-web\"\n          clearable\n          density=\"compact\"\n          :placeholder=\"t('setting.cache.selectSite')\"\n        />\n      </VCol>\n    </VRow>\n\n    <!-- 缓存列表 -->\n    <VDataTable\n      v-model=\"selectedItems\"\n      :headers=\"[\n        { title: '', key: 'data-table-select', sortable: false, width: '48px' },\n        { title: t('setting.cache.poster'), key: 'poster', sortable: false, width: '80px' },\n        { title: t('setting.cache.torrentTitle'), key: 'title', sortable: true },\n        { title: t('setting.cache.site'), key: 'site_name', sortable: true, width: '120px' },\n        { title: t('setting.cache.size'), key: 'size', sortable: true, width: '100px' },\n        { title: t('setting.cache.publishTime'), key: 'pubdate', sortable: true, width: '150px' },\n        { title: t('setting.cache.recognitionResult'), key: 'media_info', sortable: false, width: '200px' },\n        { title: t('setting.cache.actions'), key: 'actions', sortable: false, width: '150px' },\n      ]\"\n      :items=\"filteredData\"\n      :loading=\"loading\"\n      item-value=\"hash\"\n      show-select\n      hover\n      fixed-header\n      :items-per-page-text=\"t('common.itemsPerPage')\"\n      :no-data-text=\"t('common.noDataText')\"\n      :loading-text=\"t('common.loadingText')\"\n      :style=\"tableStyle\"\n    >\n      <!-- 全选复选框 -->\n      <template #header.data-table-select=\"{ allSelected, selectAll, someSelected }\">\n        <VCheckbox\n          :indeterminate=\"someSelected && !allSelected\"\n          :model-value=\"allSelected\"\n          @update:model-value=\"(value: boolean | null) => selectAll(value as boolean)\"\n        />\n      </template>\n\n      <!-- 海报列 -->\n      <template #item.poster=\"{ item }\">\n        <div class=\"text-center\">\n          <VImg\n            v-if=\"item.poster_path\"\n            :src=\"item.poster_path\"\n            :alt=\"item.media_name || item.title\"\n            cover\n            rounded=\"md\"\n            class=\"w-12 my-1 ms-auto\"\n          />\n          <VIcon v-else size=\"x-large\" color=\"grey-lighten-1\">\n            {{ item.media_type === 'movie' ? 'mdi-movie-open' : 'mdi-television-play' }}\n          </VIcon>\n        </div>\n      </template>\n\n      <!-- 标题列 -->\n      <template #item.title=\"{ item }\">\n        <div class=\"d-flex flex-column min-w-40\">\n          <div class=\"text-subtitle-2 font-weight-bold\">\n            {{ item.title }}\n          </div>\n          <div v-if=\"item.description\" class=\"text-caption text-grey\">\n            {{ item.description }}\n          </div>\n          <div v-if=\"item.season_episode || item.resource_term\" class=\"text-caption text-primary mt-1\">\n            {{ item.season_episode }} {{ item.resource_term }}\n          </div>\n        </div>\n      </template>\n\n      <!-- 大小列 -->\n      <template #item.size=\"{ item }\">\n        {{ formatFileSize(item.size) }}\n      </template>\n\n      <!-- 发布时间列 -->\n      <template #item.pubdate=\"{ item }\">\n        {{ formatDateDifference(item.pubdate || '') }}\n      </template>\n\n      <!-- 识别结果列 -->\n      <template #item.media_info=\"{ item }\">\n        <div v-if=\"item.media_name\" class=\"d-flex flex-column\">\n          <div class=\"text-subtitle-2\">\n            {{ item.media_name }}\n            <span v-if=\"item.media_year\" class=\"text-caption text-grey\"> ({{ item.media_year }}) </span>\n          </div>\n          <div>\n            <VChip v-if=\"item.media_type\" :color=\"getMediaTypeColor(item.media_type)\" size=\"x-small\">\n              {{ item.media_type }}\n            </VChip>\n          </div>\n        </div>\n        <div v-else class=\"text-caption text-grey\">\n          {{ t('setting.cache.unrecognized') }}\n        </div>\n      </template>\n\n      <!-- 操作列 -->\n      <template #item.actions=\"{ item }\">\n        <div class=\"d-flex gap-1\">\n          <VBtn icon size=\"small\" color=\"primary\" variant=\"text\" @click=\"openReidentifyDialog(item)\">\n            <VIcon size=\"16\">mdi-text-recognition</VIcon>\n          </VBtn>\n\n          <VBtn icon size=\"small\" color=\"error\" variant=\"text\" @click=\"deleteSingleItem(item)\">\n            <VIcon size=\"16\">mdi-delete</VIcon>\n          </VBtn>\n\n          <VBtn\n            v-if=\"item.page_url\"\n            icon\n            size=\"small\"\n            color=\"info\"\n            variant=\"text\"\n            @click=\"openPageUrl(item.page_url || '')\"\n            target=\"_blank\"\n          >\n            <VIcon size=\"16\">mdi-open-in-new</VIcon>\n          </VBtn>\n        </div>\n      </template>\n\n      <!-- 空状态 -->\n      <template #no-data>\n        <div class=\"text-center pa-4\">\n          <VIcon size=\"64\" class=\"mb-4\"> mdi-database-off </VIcon>\n          <div class=\"text-body-2 text-grey\">\n            {{ t('setting.cache.noData') }}\n          </div>\n        </div>\n      </template>\n    </VDataTable>\n\n    <!-- 重新识别对话框 -->\n    <VDialog v-model=\"reidentifyDialog\" scrollable max-width=\"35rem\">\n      <VCard>\n        <VCardItem class=\"py-2\">\n          <template #prepend>\n            <VIcon>mdi-text-recognition</VIcon>\n          </template>\n          <VCardTitle>{{ t('setting.cache.reidentifyDialog.title') }}</VCardTitle>\n          <VCardSubtitle>{{ currentReidentifyItem?.title }}</VCardSubtitle>\n        </VCardItem>\n        <VDialogCloseBtn @click=\"reidentifyDialog = false\" />\n        <VDivider />\n        <VCardText>\n          <VRow>\n            <VCol cols=\"12\">\n              <VTextField\n                v-if=\"globalSettings.RECOGNIZE_SOURCE === 'themoviedb'\"\n                v-model=\"tmdbId\"\n                :label=\"t('setting.cache.reidentifyDialog.tmdbId')\"\n                :hint=\"t('setting.cache.reidentifyDialog.tmdbIdHint')\"\n                clearable\n                prepend-inner-icon=\"mdi-id-card\"\n                persistent-hint\n              />\n              <VTextField\n                v-else\n                v-model=\"doubanId\"\n                :label=\"t('setting.cache.reidentifyDialog.doubanId')\"\n                :hint=\"t('setting.cache.reidentifyDialog.doubanIdHint')\"\n                clearable\n                prepend-inner-icon=\"mdi-id-card\"\n                persistent-hint\n              />\n            </VCol>\n          </VRow>\n          <VAlert type=\"info\" variant=\"tonal\" class=\"mt-4\">\n            {{ t('setting.cache.reidentifyDialog.autoHint') }}\n          </VAlert>\n        </VCardText>\n\n        <VCardActions>\n          <VSpacer />\n          <VBtn color=\"primary\" :loading=\"loading\" prepend-icon=\"mdi-check\" @click=\"performReidentify\">\n            {{ t('setting.cache.reidentifyDialog.confirm') }}\n          </VBtn>\n        </VCardActions>\n      </VCard>\n    </VDialog>\n  </div>\n</template>\n"
  },
  {
    "path": "src/views/system/LoggingView.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useI18n } from 'vue-i18n'\nimport { isToday } from '@/@core/utils/index'\nimport dayjs from 'dayjs'\nimport { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'\n\n// 定义输入变量\nconst props = defineProps<{\n  logfile: string\n}>()\n\n// 国际化\nconst { t } = useI18n()\nconst { useSSE } = useBackgroundOptimization()\n\n// 已解析的日志列表\nconst parsedLogs = ref<{ level: string; date: string; time: string; program: string; content: string }[]>([])\n\n// 组件是否已挂载\nconst isMounted = ref(false)\n\n// 表头\nconst headers = [\n  { title: t('logging.level'), value: 'level' },\n  { title: t('logging.time'), value: 'time' },\n  { title: t('logging.program'), value: 'program' },\n  { title: t('logging.content'), value: 'content' },\n]\n\n// 日志颜色映射表\nconst logColorMap: Record<string, string> = {\n  DEBUG: 'secondary',\n  INFO: 'info',\n  WARNING: 'warning',\n  ERROR: 'error',\n}\n\n// 获取日志颜色\nfunction getLogColor(level: string): string {\n  return logColorMap[level] || 'secondary'\n}\n\n// 日志缓冲区和超时处理\nconst buffer: string[] = []\nlet timeoutId: number | null = null\n\n// SSE消息处理函数\nfunction handleSSEMessage(event: MessageEvent) {\n  const message = event.data\n  if (message) {\n    buffer.push(message)\n    if (!timeoutId) {\n      timeoutId = window.setTimeout(() => {\n        // 解析新日志\n        const newParsedLogs = buffer\n          .map(log => {\n            const logPattern = /^【(.*?)】\\s*([\\d]{4}-\\d{2}-\\d{2}(?:\\s+\\d{2}:\\d{2})?)\\s+(.*?)\\s*-\\s*(.*?)\\s*-\\s*(.*)$/\n            const matches = log.match(logPattern)\n            if (matches) {\n              const [, level, date, time, program, content] = matches\n              return { level, date, time, program, content }\n            }\n            return null\n          })\n          .filter(Boolean)\n        // 倒序后插入parsedLogs顶部\n        parsedLogs.value.unshift(...(newParsedLogs.reverse() as any[]))\n        // 保留最新的200条日志\n        parsedLogs.value = parsedLogs.value.slice(0, 200)\n        // 重置buffer\n        buffer.length = 0\n        timeoutId = null\n      }, 100)\n    }\n  }\n}\n\n// 使用优化的SSE连接，添加延迟确保弹窗完全打开\nconst { manager, isConnected } = useSSE(\n  `${import.meta.env.VITE_API_BASE_URL}system/logging?logfile=${encodeURIComponent(props.logfile) ?? 'moviepilot.log'}`,\n  handleSSEMessage,\n  `logging-${props.logfile}`,\n  {\n    backgroundCloseDelay: 5000,\n    reconnectDelay: 3000,\n    maxReconnectAttempts: 3,\n    connectDelay: 300, // 延迟300ms建立连接，确保弹窗完全打开\n  },\n)\n\n// 监听弹窗状态变化，确保弹窗完全打开后再建立连接\nonMounted(() => {\n  // 延迟标记组件已挂载，确保弹窗完全渲染\n  setTimeout(() => {\n    isMounted.value = true\n  }, 200)\n})\n\n// 监听连接状态变化\nwatch(isConnected, connected => {})\n\n// 监听日志数据变化\nwatch(parsedLogs, logs => {}, { deep: true })\n</script>\n\n<template>\n  <LoadingBanner\n    v-if=\"!isMounted || !isConnected || parsedLogs.length === 0\"\n    class=\"mt-12\"\n    :text=\"!isMounted ? t('logging.initializing') + ' ...' : t('logging.refreshing') + ' ...'\"\n  />\n  <div v-else>\n    <VTable class=\"table-rounded\" hide-default-footer disable-sort>\n      <tbody>\n        <VDataTableVirtual\n          :headers=\"headers\"\n          :items=\"parsedLogs\"\n          height=\"100%\"\n          density=\"compact\"\n          hover\n          hide-default-header\n        >\n          <template #item.level=\"{ item }\">\n            <VChip size=\"small\" :color=\"getLogColor(item.level)\" variant=\"elevated\" v-text=\"item.level\" />\n          </template>\n          <template #item.time=\"{ item }\">\n            <span class=\"text-sm\">\n              {{\n                isToday(dayjs(item.date).toDate())\n                  ? item.time\n                  : `${item.date}\n              ${item.time}`\n              }}\n            </span>\n          </template>\n          <template #item.program=\"{ item }\">\n            <h6 class=\"text-sm font-weight-medium\">{{ item.program }}</h6>\n          </template>\n          <template #item.content=\"{ item }\">\n            <span class=\"text-sm\" :class=\"`text-${getLogColor(item.level)}`\">\n              {{ item.content }}\n            </span>\n          </template>\n        </VDataTableVirtual>\n      </tbody>\n    </VTable>\n  </div>\n</template>\n"
  },
  {
    "path": "src/views/system/MessageView.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { Message } from '@/api/types'\nimport MessageCard from '@/components/cards/MessageCard.vue'\nimport api from '@/api'\nimport { useI18n } from 'vue-i18n'\nimport { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'\n\n// 国际化\nconst { t } = useI18n()\nconst { useSSE } = useBackgroundOptimization()\n\n// 定义事件\nconst emit = defineEmits(['scroll'])\n\n// 消息列表\nconst messages = ref<Message[]>([])\n// 当前页数据\nconst currData = ref<Message[]>([])\n\n// 是否完成加载\nconst isLoaded = ref(false)\n\n// 是否加载中\nconst loading = ref(false)\n\n// 当前页码\nconst page = ref(1)\n\n// 存量消息最新时间\nconst lastTime = ref('')\n\n// SSE消息处理函数\nfunction handleSSEMessage(event: MessageEvent) {\n  const message = event.data\n  if (message) {\n    const object = JSON.parse(message)\n    // 使用reg_time或date字段进行比较\n    const messageTime = object.reg_time || object.date\n    if (compareTime(messageTime, lastTime.value) <= 0) return\n    messages.value.push(object)\n    nextTick(() => {\n      emit('scroll') // 新消息到达时触发智能滚动\n    })\n  }\n}\n\n// 使用优化的SSE连接\nconst { manager, isConnected } = useSSE(\n  `${import.meta.env.VITE_API_BASE_URL}system/message?role=user`,\n  handleSSEMessage,\n  'message-view',\n  {\n    backgroundCloseDelay: 5000,\n    reconnectDelay: 3000,\n    maxReconnectAttempts: 3,\n  },\n)\n\n// 调用API加载存量消息\nasync function loadMessages({ done }: { done: any }) {\n  // 如果正在加载中，直接返回\n  if (loading.value) {\n    done('ok')\n    return\n  }\n  try {\n    // 设置加载中\n    loading.value = true\n    currData.value = await api.get('message/web', {\n      params: {\n        page: page.value,\n        size: 20,\n      },\n    })\n    // 已加载过\n    isLoaded.value = true\n    if (currData.value.length > 0) {\n      // 按时间排序，确保最新的消息在最后\n      currData.value.sort((a, b) => {\n        const timeA = a.reg_time || a.date || ''\n        const timeB = b.reg_time || b.date || ''\n        return compareTime(timeA, timeB)\n      })\n\n      // 取最后一条时间为存量消息最新时间\n      const lastMessage = currData.value[currData.value.length - 1]\n      lastTime.value = lastMessage.reg_time || lastMessage.date || ''\n\n      // 合并数据并重新排序\n      const allMessages = [...currData.value, ...messages.value]\n      allMessages.sort((a, b) => {\n        const timeA = a.reg_time || a.date || ''\n        const timeB = b.reg_time || b.date || ''\n        return compareTime(timeA, timeB)\n      })\n      messages.value = allMessages\n\n      // 首次加载时滚动到底部\n      if (page.value === 1) {\n        nextTick(() => {\n          emit('scroll')\n        })\n      }\n      // 页码+1\n      page.value++\n      // 完成\n      done('ok')\n    } else {\n      // 没有新数据\n      done('empty')\n    }\n    // 取消加载中\n    loading.value = false\n  } catch (error) {\n    console.error('加载消息失败:', error)\n    loading.value = false\n    done('error')\n  }\n}\n\n// 比较yyyy-MM-dd HH:mm:ss时间大小\nfunction compareTime(time1: string, time2: string) {\n  if (!time1 && !time2) return 0\n  if (!time1) return -1\n  if (!time2) return 1\n\n  try {\n    // 统一时间格式处理，支持多种格式\n    const normalizeTime = (time: string) => {\n      // 如果是ISO格式，直接使用\n      if (time.includes('T')) {\n        return new Date(time).getTime()\n      }\n      // 如果是yyyy-MM-dd HH:mm:ss格式，替换-为/\n      return new Date(time.replaceAll(/-/g, '/')).getTime()\n    }\n\n    const timestamp1 = normalizeTime(time1)\n    const timestamp2 = normalizeTime(time2)\n\n    return timestamp1 - timestamp2\n  } catch (error) {\n    console.error('时间比较错误:', error, 'time1:', time1, 'time2:', time2)\n    return 0\n  }\n}\n\n// 图片加载完成时触发智能滚动\nfunction handleImageLoad() {\n  emit('scroll')\n}\n\n// 暂停SSE连接\nfunction pauseSSE() {\n  if (manager) {\n    manager.removeMessageListener('message-view')\n  }\n}\n\n// 恢复SSE连接\nfunction resumeSSE() {\n  if (manager) {\n    manager.addMessageListener('message-view', handleSSEMessage)\n  }\n}\n\n// 暴露方法给父组件\ndefineExpose({\n  pauseSSE,\n  resumeSSE,\n})\n\nonMounted(() => {\n  // 组件挂载后触发一次滚动事件\n  nextTick(() => {\n    emit('scroll')\n  })\n})\n</script>\n\n<template>\n  <VInfiniteScroll\n    :mode=\"!isLoaded ? 'intersect' : 'manual'\"\n    side=\"start\"\n    :items=\"messages\"\n    class=\"overflow-auto h-full\"\n    @load=\"loadMessages\"\n    :load-more-text=\"t('message.loadMore') + ' ...'\"\n  >\n    <template #loading>\n      <LoadingBanner />\n    </template>\n    <template #empty> {{ t('message.noMoreData') }} </template>\n    <div>\n      <div\n        v-for=\"(msg, index) in messages\"\n        :key=\"index\"\n        class=\"chat-group d-flex mt-5 mb-8\"\n        :class=\"msg.action == 1 ? 'flex-row align-start' : 'flex-row-reverse align-end'\"\n      >\n        <div class=\"d-inline-flex flex-column\" :class=\"msg.action == 1 ? 'align-start' : 'align-end'\">\n          <MessageCard :message=\"msg\" @imageload=\"handleImageLoad\" />\n        </div>\n      </div>\n    </div>\n  </VInfiniteScroll>\n</template>\n"
  },
  {
    "path": "src/views/system/ModuleTestView.vue",
    "content": "<script setup lang=\"ts\">\nimport api from '@/api'\nimport { useI18n } from 'vue-i18n'\nimport { useTheme } from 'vuetify'\n\n// 国际化\nconst { t } = useI18n()\n\n// 主题\nconst theme = useTheme()\n\n// 定义所有的模块ID、名称列表\nconst modules = ref<\n  {\n    id: string\n    name: string\n    state: 'success' | 'error' | 'warning' | 'info' | undefined\n    errmsg: string\n    loading: boolean\n    visible: boolean\n    delay: number\n  }[]\n>([])\n\n// 总体进度\nconst overallProgress = ref(0)\nconst isChecking = ref(false)\nconst checkComplete = ref(false)\n\n// 调用API查询模块列表\nasync function getModules() {\n  try {\n    isChecking.value = true\n    overallProgress.value = 0\n\n    const result: { [key: string]: any } = await api.get('system/modulelist')\n    if (result.success) {\n      const moduleList = result.data?.modules\n      if (moduleList) {\n        // 初始化模块列表\n        modules.value = moduleList.map((module: { id: string; name: string }, index: number) => ({\n          id: module.id,\n          name: module.name,\n          state: undefined,\n          errmsg: '',\n          loading: false,\n          visible: false,\n          delay: index * 200, // 每个模块延迟200ms出现\n        }))\n\n        // 开始检查\n        await startModuleCheck()\n      }\n    }\n  } catch (error) {\n    console.error(error)\n    isChecking.value = false\n  }\n}\n\n// 开始模块检查\nasync function startModuleCheck() {\n  const totalModules = modules.value.length\n\n  for (let i = 0; i < modules.value.length; i++) {\n    const module = modules.value[i]\n\n    // 显示当前模块\n    setTimeout(() => {\n      module.visible = true\n    }, module.delay)\n\n    // 开始检查\n    await moduleTest(i)\n\n    // 更新总体进度\n    overallProgress.value = ((i + 1) / totalModules) * 100\n  }\n\n  // 检查完成\n  setTimeout(() => {\n    isChecking.value = false\n    checkComplete.value = true\n  }, 500)\n}\n\n// 调用API测试模块\nasync function moduleTest(index: number) {\n  try {\n    const target = modules.value[index]\n    const moduleid = target.id\n    target.loading = true\n\n    const result: { [key: string]: any } = await api.get(`system/moduletest/${moduleid}`)\n    target.loading = false\n\n    if (result.success) {\n      target.state = 'success'\n      target.name = `${target.name} - ${t('moduleTest.normal')}`\n    } else if (!result.message) {\n      target.state = undefined\n      target.name = `${target.name} - ${t('moduleTest.disabled')}`\n    } else {\n      target.state = 'error'\n      target.name = `${target.name} - ${t('moduleTest.error')}！`\n      target.errmsg = result.message\n    }\n  } catch (error) {\n    console.error(error)\n    const target = modules.value[index]\n    target.loading = false\n    target.state = 'error'\n    target.errmsg = '网络请求失败'\n  }\n}\n\n// 重新检查\nfunction recheck() {\n  modules.value = []\n  overallProgress.value = 0\n  isChecking.value = false\n  checkComplete.value = false\n  getModules()\n}\n\n// 加载\nonMounted(getModules)\n</script>\n\n<template>\n  <div class=\"system-health-check\">\n    <!-- 动态进度框 - 固定在顶部 -->\n    <div class=\"progress-container\">\n      <div class=\"progress-card\" :class=\"{ 'dark-theme': theme.global.current.value.dark }\">\n        <div class=\"progress-header\">\n          <VIcon\n            :icon=\"isChecking ? 'mdi-cog-sync' : checkComplete ? 'mdi-check-circle' : 'mdi-cog'\"\n            :class=\"isChecking ? 'rotating' : ''\"\n            size=\"28\"\n            color=\"white\"\n          />\n          <h3 class=\"progress-title text-white\">\n            {{\n              isChecking\n                ? t('moduleTest.checking')\n                : checkComplete\n                ? t('moduleTest.complete')\n                : t('moduleTest.preparing')\n            }}\n          </h3>\n        </div>\n\n        <div class=\"progress-bar-container\">\n          <VProgressLinear\n            v-model=\"overallProgress\"\n            :color=\"checkComplete ? 'success' : 'white'\"\n            height=\"6\"\n            rounded\n            class=\"progress-bar\"\n          />\n          <div class=\"progress-text\">{{ Math.round(overallProgress) }}%</div>\n        </div>\n\n        <div class=\"progress-stats\">\n          <div class=\"stat-item\">\n            <span class=\"stat-number\">{{ modules.length }}</span>\n            <span class=\"stat-label\">{{ t('moduleTest.totalModules') }}</span>\n          </div>\n          <div class=\"stat-item\">\n            <span class=\"stat-number success\">{{ modules.filter(m => m.state === 'success').length }}</span>\n            <span class=\"stat-label\">{{ t('moduleTest.normal') }}</span>\n          </div>\n          <div class=\"stat-item\">\n            <span class=\"stat-number error\">{{ modules.filter(m => m.state === 'error').length }}</span>\n            <span class=\"stat-label\">{{ t('moduleTest.error') }}</span>\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <!-- 检查结果列表 - 可滚动区域 -->\n    <div class=\"results-container\">\n      <div class=\"module-list\">\n        <Transition v-for=\"(module, index) in modules\" :key=\"module.id\" name=\"module-item\" appear>\n          <div\n            v-show=\"module.visible\"\n            class=\"module-item\"\n            :class=\"[module.state, { 'dark-theme': theme.global.current.value.dark }]\"\n          >\n            <div class=\"module-header\">\n              <div class=\"module-icon\">\n                <VIcon v-if=\"module.loading\" icon=\"mdi-loading\" class=\"rotating\" color=\"primary\" size=\"20\" />\n                <VIcon v-else-if=\"module.state === 'success'\" icon=\"mdi-check-circle\" color=\"success\" size=\"20\" />\n                <VIcon v-else-if=\"module.state === 'error'\" icon=\"mdi-alert-circle\" color=\"error\" size=\"20\" />\n                <VIcon v-else icon=\"mdi-minus-circle\" color=\"grey\" size=\"20\" />\n              </div>\n              <div class=\"module-info\">\n                <div class=\"module-name\">{{ module.name }}</div>\n                <div v-if=\"module.errmsg\" class=\"module-error\">{{ module.errmsg }}</div>\n              </div>\n              <div class=\"module-status\">\n                <VChip v-if=\"module.loading\" color=\"primary\" size=\"x-small\" variant=\"tonal\">\n                  {{ t('moduleTest.checking') }}\n                </VChip>\n                <VChip v-else-if=\"module.state === 'success'\" color=\"success\" size=\"x-small\" variant=\"tonal\">\n                  {{ t('moduleTest.normal') }}\n                </VChip>\n                <VChip v-else-if=\"module.state === 'error'\" color=\"error\" size=\"x-small\" variant=\"tonal\">\n                  {{ t('moduleTest.error') }}\n                </VChip>\n                <VChip v-else-if=\"module.state === undefined\" color=\"grey\" size=\"x-small\" variant=\"tonal\">\n                  {{ t('moduleTest.disabled') }}\n                </VChip>\n              </div>\n            </div>\n          </div>\n        </Transition>\n      </div>\n    </div>\n\n    <!-- 重新检查按钮 -->\n    <div v-if=\"checkComplete\" class=\"recheck-container\">\n      <VBtn color=\"primary\" variant=\"outlined\" prepend-icon=\"mdi-refresh\" size=\"small\" @click=\"recheck\">\n        {{ t('moduleTest.recheck') }}\n      </VBtn>\n    </div>\n  </div>\n</template>\n\n<style scoped>\n.system-health-check {\n  display: flex;\n  flex-direction: column;\n}\n\n.progress-container {\n  flex-shrink: 0;\n  background: var(--v-surface-variant);\n}\n\n.progress-card {\n  padding: 20px;\n  border-radius: 12px;\n  margin: 16px;\n  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\n  color: white;\n}\n\n.progress-header {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  margin-block-end: 16px;\n}\n\n.progress-title {\n  margin: 0;\n  font-size: 1.1rem;\n  font-weight: 600;\n}\n\n.progress-bar-container {\n  position: relative;\n  margin-block-end: 16px;\n}\n\n.progress-bar {\n  background: rgba(255, 255, 255, 20%) !important;\n}\n\n.progress-text {\n  position: absolute;\n  border-radius: 8px;\n  background: rgba(255, 255, 255, 90%);\n  color: #333;\n  font-size: 0.75rem;\n  font-weight: 600;\n  inset-block-start: -6px;\n  inset-inline-end: 0;\n  padding-block: 2px;\n  padding-inline: 6px;\n}\n\n.progress-stats {\n  display: flex;\n  justify-content: space-around;\n  gap: 12px;\n}\n\n.stat-item {\n  flex: 1;\n  text-align: center;\n}\n\n.stat-number {\n  display: block;\n  font-size: 1.25rem;\n  font-weight: 700;\n  margin-block-end: 2px;\n}\n\n.stat-number.success {\n  color: #4caf50;\n}\n\n.stat-number.error {\n  color: #f44336;\n}\n\n.stat-label {\n  font-size: 0.7rem;\n  opacity: 0.8;\n}\n\n.results-container {\n  flex: 1;\n  min-block-size: 0;\n  overflow-y: auto;\n  padding-block: 0 16px;\n  padding-inline: 16px;\n}\n\n.module-list {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n}\n\n.module-item {\n  padding: 12px;\n  border: 1px solid var(--v-border-color);\n  border-radius: 8px;\n  background: var(--v-surface);\n  transition: all 0.3s ease;\n}\n\n.module-item:hover {\n  transform: translateY(-2px);\n}\n\n.module-item.success {\n  border-color: #4caf50;\n  background: linear-gradient(135deg, #f8fff9 0%, #e8f5e8 100%);\n}\n\n.module-item.success.dark-theme {\n  border-color: #4caf50;\n  background: linear-gradient(135deg, rgba(31, 47, 31, 30%) 0%, rgba(24, 32, 24, 60%) 100%);\n}\n\n.module-item.error {\n  border-color: #f44336;\n  background: linear-gradient(135deg, #fff8f8 0%, #ffe8e8 100%);\n}\n\n.module-item.error.dark-theme {\n  border-color: #f44336;\n  background: linear-gradient(135deg, rgba(47, 31, 31, 30%) 0%, rgba(34, 24, 24, 60%) 100%);\n}\n\n.module-header {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n}\n\n.module-icon {\n  flex-shrink: 0;\n}\n\n.module-info {\n  flex: 1;\n  min-inline-size: 0;\n}\n\n.module-name {\n  color: var(--v-on-surface);\n  font-size: 0.875rem;\n  font-weight: 500;\n  margin-block-end: 2px;\n}\n\n.module-error {\n  color: #f44336;\n  font-size: 0.75rem;\n  margin-block-start: 2px;\n}\n\n.module-status {\n  flex-shrink: 0;\n}\n\n.recheck-container {\n  display: flex;\n  flex-shrink: 0;\n  justify-content: center;\n  padding: 16px;\n  background: var(--v-surface-variant);\n  border-block-start: 1px solid var(--v-border-color);\n}\n\n/* 动画效果 */\n.rotating {\n  animation: rotate 2s linear infinite;\n}\n\n@keyframes rotate {\n  from {\n    transform: rotate(0deg);\n  }\n\n  to {\n    transform: rotate(360deg);\n  }\n}\n\n/* 模块项单独动画 - 从下方滑出 */\n.module-item-enter-active {\n  transition: all 0.5s ease;\n}\n\n.module-item-enter-from {\n  opacity: 0;\n  transform: translateY(30px);\n}\n\n.module-item-enter-to {\n  opacity: 1;\n  transform: translateY(0);\n}\n</style>\n"
  },
  {
    "path": "src/views/system/NameTestView.vue",
    "content": "<script setup lang=\"ts\">\nimport { reactive, ref } from 'vue'\nimport { requiredValidator } from '@/@validators'\nimport api from '@/api'\nimport type { Context } from '@/api/types'\nimport MediaInfoCard from '@/components/cards/MediaInfoCard.vue'\nimport { useI18n } from 'vue-i18n'\n\n// 国际化\nconst { t } = useI18n()\n\n// 识别结果\nconst nameTestResult = ref<Context>()\n\n// 名称识别表单\nconst nameTestForm = reactive({\n  title: '',\n  subtitle: '',\n})\n\n// 识别按钮状态\nconst nameTestLoading = ref(false)\n\n// 识别按钮文本\nconst nameTestText = ref(t('nameTest.recognize'))\n\n// 是否显示结果\nconst showResult = ref(false)\n\n// 调用API识别\nasync function nameTest() {\n  if (!nameTestForm.title) return\n\n  try {\n    nameTestLoading.value = true\n    nameTestText.value = t('nameTest.recognizing')\n    showResult.value = false\n    nameTestResult.value = await api.get('media/recognize', {\n      params: {\n        title: nameTestForm.title,\n        subtitle: nameTestForm.subtitle,\n      },\n    })\n    nameTestLoading.value = false\n    nameTestText.value = t('nameTest.recognizeAgain')\n    showResult.value = true\n  } catch (error) {\n    console.error(error)\n  }\n}\n</script>\n\n<template>\n  <VForm @submit.prevent=\"() => {}\">\n    <VRow class=\"pt-2\">\n      <VCol cols=\"12\">\n        <VTextField\n          v-model=\"nameTestForm.title\"\n          :label=\"t('nameTest.title')\"\n          :rules=\"[requiredValidator]\"\n          prepend-inner-icon=\"mdi-movie-open\"\n        />\n      </VCol>\n      <VCol cols=\"12\">\n        <VTextarea\n          v-model=\"nameTestForm.subtitle\"\n          :label=\"t('nameTest.subtitle')\"\n          rows=\"2\"\n          auto-grow\n          prepend-inner-icon=\"mdi-subtitles\"\n        />\n      </VCol>\n    </VRow>\n    <VRow>\n      <VCol cols=\"12\" class=\"text-center\">\n        <VBtn :disabled=\"nameTestLoading\" @click=\"nameTest\">\n          <template #prepend>\n            <VIcon icon=\"mdi-text-recognition\" />\n          </template>\n          {{ nameTestText }}\n        </VBtn>\n      </VCol>\n    </VRow>\n  </VForm>\n  <VExpandTransition>\n    <div v-show=\"showResult\">\n      <MediaInfoCard :context=\"nameTestResult\" />\n    </div>\n  </VExpandTransition>\n</template>\n"
  },
  {
    "path": "src/views/system/NetTestView.vue",
    "content": "<script setup lang=\"ts\">\nimport api from '@/api'\nimport { getLogoUrl } from '@/utils/imageUtils'\nimport tvdb from '@images/logos/thetvdb.jpeg'\nimport { useI18n } from 'vue-i18n'\n\n// 国际化\nconst { t } = useI18n()\n\ninterface Status {\n  OK: string\n  Fail: string\n  Normal: string\n  Doing?: string\n}\n\ninterface TargetItem {\n  id: string\n  icon: string\n  name: string\n}\n\ninterface Address {\n  id: string\n  image: string\n  name: string\n  status: keyof Status\n  time: string\n  message: string\n  btndisable: boolean\n}\n\nfunction resolveTargetImage(icon: string) {\n  if (icon === 'tvdb') return tvdb\n  return getLogoUrl(icon)\n}\n\nconst targets = ref<Address[]>([])\n\nconst resolveStatusColor: Status = {\n  OK: 'success',\n  Fail: 'error',\n  Normal: '',\n  Doing: 'warning',\n}\n\nconst abortControllers = new Set<AbortController>()\nconst isUnmounting = ref(false)\n\nasync function loadTargets() {\n  // 测试项由后端下发，前端只负责展示，避免再把可测试目标和校验规则留在客户端。\n  const result: { [key: string]: any } = await api.get('system/nettest/targets')\n  if (!result.success || !Array.isArray(result.data)) {\n    targets.value = []\n    return\n  }\n\n  targets.value = result.data.map((item: TargetItem) => ({\n    id: item.id,\n    image: resolveTargetImage(item.icon),\n    name: item.name,\n    status: 'Normal',\n    time: '',\n    message: t('netTest.notTested'),\n    btndisable: false,\n  }))\n}\n\n// 调用API测试网络连接\nasync function netTest(index: number) {\n  const target = targets.value[index]\n  if (!target) return\n\n  // 页面切换时需要主动中止请求，否则自动轮询中的旧请求会回写已卸载页面状态。\n  const abortController = new AbortController()\n  abortControllers.add(abortController)\n\n  try {\n    const { signal } = abortController\n\n    target.btndisable = true\n    target.status = 'Doing'\n    target.message = t('netTest.testing')\n\n    const result: { [key: string]: any } = await api.get('system/nettest', {\n      params: {\n        target_id: target.id,\n      },\n      signal,\n    })\n\n    if (result.success) {\n      target.status = 'OK'\n      target.message = t('netTest.normal')\n    } else {\n      target.status = 'Fail'\n      target.message = result.message\n    }\n    target.time = result.data?.time\n    target.btndisable = false\n  } catch (error) {\n    if (!isUnmounting.value) {\n      target.status = 'Fail'\n      target.message = error instanceof Error ? error.message : t('netTest.notTested')\n      target.btndisable = false\n    }\n  } finally {\n    abortControllers.delete(abortController)\n  }\n}\n\n// 加载时测试所有连接\nonMounted(async () => {\n  isUnmounting.value = false\n  await loadTargets()\n  // 逐个串行测试，避免同时触发过多外部请求导致结果受限流或代理抖动影响。\n  for (let i = 0; !isUnmounting.value && i < targets.value.length; i++) await netTest(i)\n})\nonBeforeUnmount(() => {\n  isUnmounting.value = true\n  for (const controller of abortControllers) {\n    controller.abort()\n  }\n  abortControllers.clear()\n})\n</script>\n\n<template>\n  <VList lines=\"two\" rounded>\n    <template v-for=\"(target, index) of targets\" :key=\"target.id\">\n      <VListItem>\n        <template #prepend>\n          <VAvatar :image=\"target.image\" />\n        </template>\n        <VListItemTitle>\n          {{ target.name }}\n        </VListItemTitle>\n        <VListItemSubtitle class=\"mt-1 me-2\">\n          <VBadge dot location=\"start center\" offset-x=\"2\" :color=\"resolveStatusColor[target.status]\" class=\"me-3\">\n            <span class=\"ms-4\">{{ target.message }}</span>\n          </VBadge>\n\n          <span v-if=\"target.time\" class=\"text-xs text-wrap text-disabled\"> {{ target.time }} ms </span>\n        </VListItemSubtitle>\n        <template #append>\n          <VBtn size=\"small\" icon=\"mdi-connection\" :disabled=\"target.btndisable\" @click=\"netTest(index)\" />\n        </template>\n      </VListItem>\n      <VDivider inset v-if=\"index !== targets.length - 1\" />\n    </template>\n  </VList>\n</template>\n"
  },
  {
    "path": "src/views/system/RuleTestView.vue",
    "content": "<script setup lang=\"ts\">\nimport { reactive, ref } from 'vue'\nimport { requiredValidator } from '@/@validators'\nimport api from '@/api'\nimport { FilterRuleGroup } from '@/api/types'\nimport { useI18n } from 'vue-i18n'\n\n// 国际化\nconst { t } = useI18n()\n\n// 识别结果\nconst ruleTestResult = ref('')\n\n// 名称识别表单\nconst ruleTestForm = reactive({\n  title: null,\n  subtitle: null,\n  rulegroup: null,\n})\n\n// 识别按钮状态\nconst ruleTestLoading = ref(false)\n\n// 识别按钮文本\nconst ruleTestText = ref(t('ruleTest.test'))\n\n// 是否显示结果\nconst showResult = ref(false)\n\n// 所有规则组列表\nconst filterRuleGroups = ref<FilterRuleGroup[]>([])\n\n// 规则组选项\nconst filterRuleGroupItems = computed(() => {\n  return filterRuleGroups.value.map(item => ({ title: item.name, value: item.name }))\n})\n\n// 加载规则组\nasync function queryFilterRuleGroups() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')\n    filterRuleGroups.value = result.data?.value ?? []\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 调用API识别\nasync function ruleTest() {\n  if (!ruleTestForm.title || !ruleTestForm.rulegroup) return\n\n  try {\n    ruleTestLoading.value = true\n    ruleTestText.value = t('ruleTest.testing')\n    showResult.value = false\n    const result: { [key: string]: any } = await api.get('system/ruletest', {\n      params: {\n        title: ruleTestForm.title,\n        subtitle: ruleTestForm.subtitle,\n        rulegroup_name: ruleTestForm.rulegroup,\n      },\n    })\n    if (result.success) ruleTestResult.value = t('ruleTest.priority', { value: result.data.priority })\n    else ruleTestResult.value = t('ruleTest.noPriorityRule')\n\n    ruleTestLoading.value = false\n    ruleTestText.value = t('ruleTest.testAgain')\n    showResult.value = true\n  } catch (error) {\n    console.error(error)\n  }\n}\n\nonMounted(() => {\n  queryFilterRuleGroups()\n})\n</script>\n\n<template>\n  <VForm @submit.prevent=\"() => {}\">\n    <VRow class=\"pt-2\">\n      <VCol cols=\"12\" md=\"8\">\n        <VTextField\n          v-model=\"ruleTestForm.title\"\n          :label=\"t('ruleTest.title')\"\n          :rules=\"[requiredValidator]\"\n          prepend-inner-icon=\"mdi-movie-open\"\n        />\n      </VCol>\n      <VCol cols=\"12\" md=\"4\">\n        <VSelect\n          v-model=\"ruleTestForm.rulegroup\"\n          :label=\"t('ruleTest.ruleGroup')\"\n          :items=\"filterRuleGroupItems\"\n          prepend-inner-icon=\"mdi-filter\"\n        />\n      </VCol>\n      <VCol cols=\"12\">\n        <VTextarea\n          v-model=\"ruleTestForm.subtitle\"\n          :label=\"t('ruleTest.subtitle')\"\n          rows=\"2\"\n          auto-grow\n          prepend-inner-icon=\"mdi-subtitles\"\n        />\n      </VCol>\n    </VRow>\n    <VRow>\n      <VCol cols=\"12\" class=\"text-center\">\n        <VBtn :disabled=\"ruleTestLoading\" @click=\"ruleTest\">\n          <template #prepend>\n            <VIcon icon=\"mdi-filter-check-outline\" />\n          </template>\n          {{ ruleTestText }}\n        </VBtn>\n      </VCol>\n    </VRow>\n  </VForm>\n  <VExpandTransition>\n    <div v-show=\"showResult\">\n      <VCol>\n        <VAlert icon=\"mdi-alert-circle-outline\">\n          {{ ruleTestResult }}\n        </VAlert>\n      </VCol>\n    </div>\n  </VExpandTransition>\n</template>\n"
  },
  {
    "path": "src/views/system/ServiceView.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useToast } from 'vue-toastification'\nimport api from '@/api'\nimport type { ScheduleInfo } from '@/api/types'\nimport { useI18n } from 'vue-i18n'\nimport { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'\n\n// 国际化\nconst { t } = useI18n()\nconst { useDataRefresh } = useBackgroundOptimization()\n\n// 提示框\nconst $toast = useToast()\n\n// 定时服务列表\nconst schedulerList = ref<ScheduleInfo[]>([])\n\n// 调用API加载定时服务列表\nasync function loadSchedulerList() {\n  try {\n    const res: ScheduleInfo[] = await api.get('dashboard/schedule')\n\n    schedulerList.value = res\n  } catch (e) {\n    console.log(e)\n  }\n}\n\n// 任务状态颜色\nfunction getSchedulerColor(status: string) {\n  switch (status) {\n    case t('setting.scheduler.running'):\n      return 'success'\n    case t('setting.scheduler.stopped'):\n      return 'error'\n    case t('setting.scheduler.waiting'):\n      return ''\n    default:\n      return ''\n  }\n}\n\n// 执行命令\nfunction runCommand(id: string) {\n  try {\n    // 异步提交\n    api.get('system/runscheduler', {\n      params: {\n        jobid: id,\n      },\n    })\n    $toast.success(t('setting.scheduler.executeSuccess'))\n    // 1秒后刷新数据\n    setTimeout(() => {\n      loadSchedulerList()\n    }, 1000)\n  } catch (e) {\n    console.log(e)\n  }\n}\n\n// 使用优化的数据刷新定时器\nuseDataRefresh(\n  'scheduler-list',\n  loadSchedulerList,\n  5000, // 5秒间隔\n  true // 立即执行\n)\n</script>\n\n<template>\n  <VCard>\n    <VTable class=\"text-no-wrap\">\n      <thead>\n        <tr>\n          <th scope=\"col\">{{ t('setting.scheduler.provider') }}</th>\n          <th scope=\"col\">{{ t('setting.scheduler.taskName') }}</th>\n          <th scope=\"col\">{{ t('setting.scheduler.taskStatus') }}</th>\n          <th scope=\"col\">{{ t('setting.scheduler.nextRunTime') }}</th>\n          <th scope=\"col\" />\n        </tr>\n      </thead>\n      <tbody>\n        <tr v-for=\"scheduler in schedulerList\" :key=\"scheduler.id\">\n          <td>\n            {{ scheduler.provider }}\n          </td>\n          <td>\n            {{ scheduler.name }}\n          </td>\n          <td>\n            <VChip :color=\"getSchedulerColor(scheduler.status)\">\n              {{ scheduler.status }}\n            </VChip>\n          </td>\n          <td>\n            {{ scheduler.next_run }}\n          </td>\n          <td>\n            <VBtn\n              size=\"small\"\n              :disabled=\"scheduler.status === t('setting.scheduler.running')\"\n              @click=\"runCommand(scheduler.id)\"\n            >\n              <template #prepend>\n                <VIcon>mdi-play</VIcon>\n              </template>\n              {{ t('setting.scheduler.execute') }}\n            </VBtn>\n          </td>\n        </tr>\n        <tr v-if=\"schedulerList.length === 0\">\n          <td colspan=\"4\" class=\"text-center\">{{ t('setting.scheduler.noService') }}</td>\n        </tr>\n      </tbody>\n    </VTable>\n  </VCard>\n</template>\n"
  },
  {
    "path": "src/views/system/WordsView.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useToast } from 'vue-toastification'\nimport api from '@/api'\nimport { useI18n } from 'vue-i18n'\n\n// 国际化\nconst { t } = useI18n()\n\n// 提示框\nconst $toast = useToast()\n\n// 自定义识别词\nconst customIdentifiers = ref('')\n\n// 自定义制作组\nconst customReleaseGroups = ref('')\n\n// 自定义占位符\nconst customization = ref('')\n\n// 文件整理屏蔽词\nconst transferExcludeWords = ref('')\n\n// 查询已设置的识别词\nasync function queryCustomIdentifiers() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/CustomIdentifiers')\n    if (result && result.data && result.data.value) customIdentifiers.value = result.data.value.join('\\n')\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 查询已设置的制作组\nasync function queryCustomReleaseGroups() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/CustomReleaseGroups')\n    if (result && result.data && result.data.value) customReleaseGroups.value = result.data.value.join('\\n')\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 查询已设置的自定义占位符\nasync function queryCustomization() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/Customization')\n    if (result && result.data && result.data.value) customization.value = result.data?.value.join('\\n')\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 查询已设置的屏蔽词\nasync function queryTransferExcludeWords() {\n  try {\n    const result: { [key: string]: any } = await api.get('system/setting/TransferExcludeWords')\n    if (result && result.data && result.data.value) transferExcludeWords.value = result.data?.value.join('\\n')\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 保存用户设置的识别词\nasync function saveCustomIdentifiers() {\n  try {\n    const result: { [key: string]: any } = await api.post(\n      'system/setting/CustomIdentifiers',\n      customIdentifiers.value.split('\\n'),\n    )\n\n    if (result.success) $toast.success(t('setting.words.identifierSaveSuccess'))\n    else $toast.error(t('setting.words.identifierSaveFailed'))\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 保存自定义制作组\nasync function saveCustomReleaseGroups() {\n  try {\n    const result: { [key: string]: any } = await api.post(\n      'system/setting/CustomReleaseGroups',\n      customReleaseGroups.value.split('\\n'),\n    )\n\n    if (result.success) $toast.success(t('setting.words.releaseGroupSaveSuccess'))\n    else $toast.error(t('setting.words.releaseGroupSaveFailed'))\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 保存自定义占位符\nasync function saveCustomization() {\n  try {\n    const result: { [key: string]: any } = await api.post(\n      'system/setting/Customization',\n      customization.value.split('\\n'),\n    )\n\n    if (result.success) $toast.success(t('setting.words.customizationSaveSuccess'))\n    else $toast.error(t('setting.words.customizationSaveFailed'))\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 保存文件整理屏蔽词\nasync function saveTransferExcludeWords() {\n  try {\n    const result: { [key: string]: any } = await api.post(\n      'system/setting/TransferExcludeWords',\n      transferExcludeWords.value.split('\\n'),\n    )\n\n    if (result.success) $toast.success(t('setting.words.excludeWordsSaveSuccess'))\n    else $toast.error(t('setting.words.excludeWordsSaveFailed'))\n  } catch (error) {\n    console.log(error)\n  }\n}\n\nonMounted(() => {\n  queryCustomIdentifiers()\n  queryCustomReleaseGroups()\n  queryCustomization()\n  queryTransferExcludeWords()\n})\n</script>\n\n<template>\n  <VRow>\n    <VCol cols=\"12\">\n      <VCard>\n        <VCardItem>\n          <VCardTitle>{{ t('setting.words.customIdentifiers') }}</VCardTitle>\n          <VCardSubtitle>{{ t('setting.words.identifiersDesc') }}</VCardSubtitle>\n        </VCardItem>\n        <VCardText>\n          <VTextarea\n            v-model=\"customIdentifiers\"\n            :placeholder=\"t('setting.words.identifiersPlaceholder')\"\n            :hint=\"t('setting.words.identifiersHint')\"\n            persistent-hint\n            prepend-inner-icon=\"mdi-tag-text\"\n          />\n        </VCardText>\n        <VCardText>\n          <VAlert type=\"info\" variant=\"tonal\" :title=\"t('setting.words.formatTitle')\">\n            <div style=\"white-space: pre-line\" v-html=\"t('setting.words.formatContent').split('\\n').join('<br>')\"></div>\n          </VAlert>\n        </VCardText>\n        <VCardText>\n          <VForm @submit.prevent=\"() => {}\">\n            <div class=\"d-flex flex-wrap gap-4 mt-4\">\n              <VBtn type=\"submit\" @click=\"saveCustomIdentifiers\" prepend-icon=\"mdi-content-save\">\n                {{ t('common.save') }}\n              </VBtn>\n            </div>\n          </VForm>\n        </VCardText>\n      </VCard>\n    </VCol>\n  </VRow>\n  <VRow>\n    <VCol cols=\"12\">\n      <VCard>\n        <VCardItem>\n          <VCardTitle>{{ t('setting.words.customReleaseGroups') }}</VCardTitle>\n          <VCardSubtitle>{{ t('setting.words.releaseGroupsDesc') }}</VCardSubtitle>\n        </VCardItem>\n        <VCardText>\n          <VTextarea\n            v-model=\"customReleaseGroups\"\n            :placeholder=\"t('setting.words.releaseGroupsPlaceholder')\"\n            :hint=\"t('setting.words.releaseGroupsHint')\"\n            persistent-hint\n            prepend-inner-icon=\"mdi-account-group\"\n          />\n        </VCardText>\n        <VCardText>\n          <VForm @submit.prevent=\"() => {}\">\n            <div class=\"d-flex flex-wrap gap-4 mt-4\">\n              <VBtn type=\"submit\" @click=\"saveCustomReleaseGroups\" prepend-icon=\"mdi-content-save\">\n                {{ t('common.save') }}\n              </VBtn>\n            </div>\n          </VForm>\n        </VCardText>\n      </VCard>\n    </VCol>\n  </VRow>\n  <VRow>\n    <VCol cols=\"12\">\n      <VCard>\n        <VCardItem>\n          <VCardTitle>{{ t('setting.words.customization') }}</VCardTitle>\n          <VCardSubtitle>{{ t('setting.words.customizationDesc') }}</VCardSubtitle>\n        </VCardItem>\n        <VCardText>\n          <VTextarea\n            v-model=\"customization\"\n            :placeholder=\"t('setting.words.customizationPlaceholder')\"\n            :hint=\"t('setting.words.customizationHint')\"\n            persistent-hint\n            prepend-inner-icon=\"mdi-code-braces\"\n          />\n        </VCardText>\n        <VCardText>\n          <VForm @submit.prevent=\"() => {}\">\n            <div class=\"d-flex flex-wrap gap-4 mt-4\">\n              <VBtn type=\"submit\" @click=\"saveCustomization\" prepend-icon=\"mdi-content-save\">\n                {{ t('common.save') }}\n              </VBtn>\n            </div>\n          </VForm>\n        </VCardText>\n      </VCard>\n    </VCol>\n  </VRow>\n  <VRow>\n    <VCol cols=\"12\">\n      <VCard>\n        <VCardItem>\n          <VCardTitle>{{ t('setting.words.transferExcludeWords') }}</VCardTitle>\n          <VCardSubtitle>{{ t('setting.words.excludeWordsDesc') }}</VCardSubtitle>\n        </VCardItem>\n        <VCardText>\n          <VTextarea\n            v-model=\"transferExcludeWords\"\n            :placeholder=\"t('setting.words.excludeWordsPlaceholder')\"\n            :hint=\"t('setting.words.excludeWordsHint')\"\n            persistent-hint\n            prepend-inner-icon=\"mdi-block-helper\"\n          />\n        </VCardText>\n        <VCardText>\n          <VForm @submit.prevent=\"() => {}\">\n            <div class=\"d-flex flex-wrap gap-4 mt-4\">\n              <VBtn type=\"submit\" @click=\"saveTransferExcludeWords\" prepend-icon=\"mdi-content-save\">\n                {{ t('common.save') }}\n              </VBtn>\n            </div>\n          </VForm>\n        </VCardText>\n      </VCard>\n    </VCol>\n  </VRow>\n</template>\n"
  },
  {
    "path": "src/views/user/UserListView.vue",
    "content": "<script lang=\"ts\" setup>\nimport api from '@/api'\nimport type { User } from '@/api/types'\nimport NoDataFound from '@/components/NoDataFound.vue'\nimport UserCard from '@/components/cards/UserCard.vue'\nimport UserAddEditDialog from '@/components/dialog/UserAddEditDialog.vue'\nimport { useDynamicButton } from '@/composables/useDynamicButton'\nimport { useI18n } from 'vue-i18n'\nimport { usePWA } from '@/composables/usePWA'\n\n// 国际化\nconst { t } = useI18n()\n\n// 路由\nconst route = useRoute()\n\n// PWA模式检测\nconst { appMode } = usePWA()\n\n// 是否刷新过\nconst isRefreshed = ref(false)\n\n// 是否加载中\nconst loading = ref(false)\n\n// 新增用户窗口\nconst addUserDialog = ref(false)\n\n// 所有用户信息\nconst allUsers = ref<User[]>([])\n\n// 调用API，查询所有用户\nasync function loadAllUsers() {\n  try {\n    loading.value = true\n    const result: User[] = await api.get('/user/')\n    allUsers.value = result\n    loading.value = false\n    isRefreshed.value = true\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 用户新增完成\nconst onUserAdd = () => {\n  addUserDialog.value = false\n  loadAllUsers()\n}\n\n// 打开添加用户对话框\nconst openAddUserDialog = () => {\n  addUserDialog.value = true\n}\n\n// 加载当前用户数据\nonMounted(() => {\n  loadAllUsers()\n})\n\nonActivated(() => {\n  if (!loading.value) {\n    loadAllUsers()\n  }\n})\n\n// 使用动态按钮钩子\nuseDynamicButton({\n  icon: 'mdi-account-plus',\n  onClick: () => {\n    openAddUserDialog()\n  },\n})\n</script>\n\n<template>\n  <!-- 页面标题 -->\n  <VPageContentTitle :title=\"t('user.management')\" />\n  <div class=\"card-list-container\">\n    <!-- 加载中提示 -->\n    <LoadingBanner v-if=\"!isRefreshed\" class=\"mt-12\" />\n    <!-- 用户卡片网格 -->\n    <div v-if=\"allUsers.length > 0 && isRefreshed\" class=\"grid gap-4 grid-user-card px-2\">\n      <!-- 普通用户卡片 -->\n      <UserCard\n        v-for=\"user in allUsers\"\n        :key=\"user.id\"\n        :user=\"user\"\n        :users=\"allUsers\"\n        @remove=\"loadAllUsers\"\n        @save=\"loadAllUsers\"\n      />\n    </div>\n\n    <!-- 无数据提示 -->\n    <div v-if=\"allUsers.length === 0 && isRefreshed\">\n      <NoDataFound error-code=\"404\" :error-title=\"t('user.noUsers')\" :error-description=\"t('user.clickToAddUser')\" />\n    </div>\n\n    <!-- 新增用户按钮 -->\n    <Teleport to=\"body\" v-if=\"route.path === '/user'\">\n      <div v-if=\"isRefreshed && !appMode\" class=\"compact-fab-stack\">\n        <VFab\n          icon=\"mdi-account-plus\"\n          color=\"primary\"\n          appear\n          class=\"compact-fab compact-fab--primary\"\n          @click=\"openAddUserDialog\"\n        />\n      </div>\n    </Teleport>\n\n    <!-- 用户添加弹窗 -->\n    <UserAddEditDialog\n      v-if=\"addUserDialog\"\n      v-model=\"addUserDialog\"\n      oper=\"add\"\n      max-width=\"45rem\"\n      @save=\"onUserAdd\"\n      @close=\"addUserDialog = false\"\n    />\n  </div>\n</template>\n"
  },
  {
    "path": "src/views/user/UserProfileView.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useToast } from 'vue-toastification'\nimport { VForm } from 'vuetify/lib/components/index.mjs'\nimport api from '@/api'\nimport type { User, PassKey } from '@/api/types'\nimport avatar1 from '@images/avatars/avatar-1.png'\nimport { useDisplay } from 'vuetify'\nimport { useUserStore } from '@/stores'\nimport { useI18n } from 'vue-i18n'\nimport OTPAuthDialog from '@/components/dialog/OTPAuthDialog.vue'\nimport PasskeyDialog from '@/components/dialog/PasskeyDialog.vue'\n\n// 国际化\nconst { t, locale } = useI18n()\n\n// 显示器宽度\nconst display = useDisplay()\n\nconst isNewPasswordVisible = ref(false)\nconst isConfirmPasswordVisible = ref(false)\nconst newPassword = ref('')\nconst confirmPassword = ref('')\n\n// 用户 Store\nconst userStore = useUserStore()\n\n// 提示框\nconst $toast = useToast()\n\nconst refInputEl = ref<HTMLElement>()\n\n// 正在保存\nconst isSaving = ref(false)\n\n// 开启双重验证窗口\nconst otpDialog = ref(false)\n\n// 当前头像缓存\nconst currentAvatar = ref(avatar1)\n\n// 当前用户名\nconst currentUserName = ref('')\n\n// 当前用户信息\nconst accountInfo = ref<User>({\n  id: 0,\n  name: '',\n  password: '',\n  email: '',\n  is_active: false,\n  is_superuser: false,\n  avatar: '',\n  is_otp: false,\n  permissions: {},\n  settings: {},\n  nickname: '',\n})\n\n// PassKey列表\nconst passkeyList = ref<PassKey[]>([])\n\n// PassKey对话框\nconst passkeyDialog = ref(false)\n\n// 双重验证菜单\nconst mfaMenu = ref(false)\n\n// 密码验证对话框\nconst verifyPasswordDialog = ref(false)\n\n// 验证密码\nconst verifyPassword = ref('')\n\n// 验证后的回调\nconst verifyCallback = ref<((password: string) => void) | null>(null)\n\n// 验证对话框标题\nconst verifyTitle = ref('')\n\n// 验证对话框提示\nconst verifyText = ref('')\n\n// 检查是否已启用任何双重验证\nconst hasMfaEnabled = computed(() => {\n  return accountInfo.value.is_otp || passkeyList.value.length > 0\n})\n\n// 更新头像\nfunction changeAvatar(file: Event) {\n  const fileReader = new FileReader()\n  const { files } = file.target as HTMLInputElement\n  if (files && files.length > 0) {\n    const selectedFile = files[0]\n    const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']\n    const maxSize = 800 * 1024\n    // 检查文件是否为图片\n    if (!allowedTypes.includes(selectedFile.type)) {\n      $toast.error(t('profile.avatarFormatError'))\n      return\n    }\n    // 检查文件大小\n    if (selectedFile.size > maxSize) {\n      $toast.error(t('profile.avatarSizeError'))\n      return\n    }\n    fileReader.readAsDataURL(selectedFile)\n    fileReader.onload = () => {\n      if (typeof fileReader.result === 'string') {\n        currentAvatar.value = fileReader.result\n        $toast.success(t('profile.avatarUploadSuccess'))\n      }\n    }\n  }\n}\n\n// 重置默认头像\nfunction resetDefaultAvatar() {\n  currentAvatar.value = avatar1\n  $toast.success(t('profile.resetAvatarSuccess'))\n}\n\n// 还原当前头像\nfunction restoreCurrentAvatar() {\n  currentAvatar.value = accountInfo.value.avatar\n  $toast.success(t('profile.restoreAvatarSuccess'))\n}\n\n// 加载当前用户信息\nasync function fetchUserInfo() {\n  try {\n    const result: User = await api.get(`user/${userStore.userName}`)\n    if (result) {\n      accountInfo.value = result\n      accountInfo.value.avatar = accountInfo.value.avatar ? accountInfo.value.avatar : avatar1\n      accountInfo.value.nickname = accountInfo.value.settings?.nickname ?? ''\n      currentUserName.value = accountInfo.value.name\n      currentAvatar.value = accountInfo.value.avatar\n      // 同时加载PassKey列表\n      await fetchPassKeyList()\n    }\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 保存账户信息\nasync function saveAccountInfo() {\n  if (isSaving.value) {\n    $toast.error(t('profile.savingInProgress'))\n    return\n  }\n  if (!currentUserName.value) {\n    $toast.error(t('profile.usernameRequired'))\n    return\n  }\n  if (newPassword.value || confirmPassword.value) {\n    if (newPassword.value !== confirmPassword.value) {\n      $toast.error(t('profile.passwordMismatch'))\n      return\n    }\n    accountInfo.value.password = newPassword.value\n  }\n\n  // 将nickname保存到settings中，后端可以直接处理JSON对象\n  if (!accountInfo.value.settings) {\n    accountInfo.value.settings = {}\n  }\n  accountInfo.value.settings.nickname = accountInfo.value.nickname ?? ''\n\n  const oldUserName = accountInfo.value.name\n  const oldAvatar = accountInfo.value.avatar\n  accountInfo.value.avatar = currentAvatar.value\n  accountInfo.value.name = currentUserName.value\n  isSaving.value = true\n  try {\n    // 创建一个临时对象来保存用户数据，确保所有字段都会发送\n    const userData = { ...accountInfo.value }\n\n    const result: { [key: string]: any } = await api.put('user/', userData)\n\n    if (result.success) {\n      if (oldUserName !== currentUserName.value) {\n        $toast.success(t('profile.usernameChangeSuccess', { oldName: oldUserName, newName: currentUserName.value }))\n        // 更新本地用户名显示\n        userStore.setUserName(currentUserName.value)\n      } else {\n        $toast.success(t('profile.saveSuccess'))\n      }\n      // 更新本地头像显示\n      if (oldAvatar !== currentAvatar.value) {\n        userStore.setAvatar(currentAvatar.value)\n      }\n    } else {\n      if (oldAvatar !== currentAvatar.value) {\n        $toast.error(\n          t('profile.saveFailedWithNameChange', {\n            oldName: oldUserName,\n            newName: currentUserName.value,\n            message: result.message,\n          }),\n        )\n      } else {\n        $toast.error(t('profile.saveFailed', { message: result.message }))\n      }\n      // 失败缓存值还原\n      currentUserName.value = accountInfo.value.name\n      accountInfo.value.name = oldUserName\n      currentAvatar.value = accountInfo.value.avatar\n      accountInfo.value.avatar = oldAvatar\n    }\n  } catch (error) {\n    console.log('保存失败:', error)\n  }\n  isSaving.value = false\n}\n\n// 验证密码载荷接口\ninterface VerifyPasswordPayload {\n  title: string\n  text: string\n  callback: (password: string) => void\n}\n\n// 密码验证并执行回调\nfunction withPasswordVerification(title: string, text: string, callback: (password: string) => void) {\n  verifyTitle.value = title\n  verifyText.value = text\n  verifyCallback.value = callback\n  verifyPassword.value = ''\n  verifyPasswordDialog.value = true\n}\n\n// 弹窗请求密码验证\nfunction onVerifyPassword({ title, text, callback }: VerifyPasswordPayload) {\n  withPasswordVerification(title, text, callback)\n}\n\n// 确认密码验证\nasync function confirmVerifyPassword() {\n  if (!verifyPassword.value) {\n    $toast.error(t('user.passwordHint'))\n    return\n  }\n  if (verifyCallback.value) {\n    verifyCallback.value(verifyPassword.value)\n  }\n  verifyPasswordDialog.value = false\n}\n\n// 获取PassKey列表\nasync function fetchPassKeyList() {\n  try {\n    const result: { [key: string]: any } = await api.get('mfa/passkey/list')\n    if (result.success) {\n      passkeyList.value = result.data || []\n    }\n  } catch (error) {\n    console.log(error)\n  }\n}\n\n// 加载当前用户数据\nonMounted(() => {\n  fetchUserInfo()\n})\n\n// 监听 localStorage 中的用户头像变化\nwatch(\n  () => userStore.avatar,\n  () => {\n    currentAvatar.value = userStore.avatar\n  },\n)\n</script>\n\n<template>\n  <div>\n    <VRow>\n      <VCol cols=\"12\">\n        <VCard :title=\"t('profile.personalInfo')\">\n          <VCardText class=\"flex\">\n            <!-- 👉 Avatar -->\n            <VAvatar rounded=\"lg\" size=\"100\" class=\"me-6\" :image=\"currentAvatar\" />\n\n            <!-- 👉 Upload Photo -->\n            <form class=\"flex flex-col justify-center gap-5\">\n              <div class=\"flex flex-wrap gap-2\">\n                <VBtn color=\"primary\" @click=\"refInputEl?.click()\">\n                  <VIcon icon=\"mdi-cloud-upload-outline\" />\n                  <span v-if=\"display.mdAndUp.value\" class=\"ms-2\">{{ t('profile.uploadNewAvatar') }}</span>\n                </VBtn>\n\n                <input\n                  ref=\"refInputEl\"\n                  type=\"file\"\n                  name=\"file\"\n                  accept=\".jpeg,.png,.jpg,GIF\"\n                  hidden\n                  @input=\"changeAvatar\"\n                />\n\n                <VBtn type=\"reset\" color=\"info\" variant=\"tonal\" @click=\"restoreCurrentAvatar\">\n                  <VIcon icon=\"mdi-refresh\" />\n                  <span v-if=\"display.mdAndUp.value\" class=\"ms-2\">{{ t('common.reset') }}</span>\n                </VBtn>\n\n                <VBtn type=\"reset\" color=\"error\" variant=\"tonal\" @click=\"resetDefaultAvatar\">\n                  <VIcon icon=\"mdi-image-sync-outline\" />\n                  <span v-if=\"display.mdAndUp.value\" class=\"ms-2\">{{ t('common.default') }}</span>\n                </VBtn>\n\n                <!-- 双重验证菜单按钮 -->\n                <VMenu v-model=\"mfaMenu\" :close-on-content-click=\"false\">\n                  <template #activator=\"{ props }\">\n                    <VBtn :color=\"hasMfaEnabled ? 'warning' : 'success'\" variant=\"tonal\" v-bind=\"props\">\n                      <VIcon icon=\"mdi-shield-key\" />\n                      <span v-if=\"display.mdAndUp.value\" class=\"ms-2\">\n                        {{ hasMfaEnabled ? t('profile.setupMfa') : t('profile.enableMfa') }}\n                      </span>\n                      <VIcon icon=\"mdi-menu-down\" class=\"ms-1\" />\n                    </VBtn>\n                  </template>\n                  <VList>\n                    <VListItem\n                      @click=\"\n                        () => {\n                          otpDialog = true\n                          mfaMenu = false\n                        }\n                      \"\n                    >\n                      <template #prepend>\n                        <VIcon icon=\"mdi-cellphone-key\" />\n                      </template>\n                      <VListItemTitle>{{ t('profile.useAuthenticator') }}</VListItemTitle>\n                      <VListItemSubtitle v-if=\"accountInfo.is_otp\" class=\"text-success\">\n                        {{ t('profile.enabled') }}\n                      </VListItemSubtitle>\n                    </VListItem>\n                    <VListItem\n                      @click=\"\n                        () => {\n                          passkeyDialog = true\n                          mfaMenu = false\n                        }\n                      \"\n                    >\n                      <template #prepend>\n                        <VIcon icon=\"material-symbols:passkey\" />\n                      </template>\n                      <VListItemTitle>{{ t('profile.usePasskey') }}</VListItemTitle>\n                      <VListItemSubtitle v-if=\"passkeyList.length > 0\" class=\"text-success\">\n                        {{ t('profile.keysCount', { count: passkeyList.length }) }}\n                      </VListItemSubtitle>\n                    </VListItem>\n                  </VList>\n                </VMenu>\n              </div>\n\n              <p class=\"text-body-1 mb-0\">{{ t('profile.avatarFormatTip') }}</p>\n            </form>\n          </VCardText>\n\n          <VCardText>\n            <!-- 👉 Form -->\n            <VForm class=\"mt-6\">\n              <VRow>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"currentUserName\"\n                    density=\"comfortable\"\n                    readonly\n                    :label=\"t('user.username')\"\n                    prepend-inner-icon=\"mdi-account\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"accountInfo.email\"\n                    density=\"comfortable\"\n                    clearable\n                    :label=\"t('user.email')\"\n                    type=\"email\"\n                    prepend-inner-icon=\"mdi-email\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"newPassword\"\n                    density=\"comfortable\"\n                    :type=\"isNewPasswordVisible ? 'text' : 'password'\"\n                    :append-inner-icon=\"isNewPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'\"\n                    clearable\n                    :label=\"t('user.password')\"\n                    autocomplete=\"\"\n                    prepend-inner-icon=\"mdi-lock\"\n                    @click:append-inner=\"isNewPasswordVisible = !isNewPasswordVisible\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <!-- 👉 confirm password -->\n                  <VTextField\n                    v-model=\"confirmPassword\"\n                    density=\"comfortable\"\n                    :type=\"isConfirmPasswordVisible ? 'text' : 'password'\"\n                    :append-inner-icon=\"isConfirmPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'\"\n                    clearable\n                    :label=\"t('user.confirmPassword')\"\n                    prepend-inner-icon=\"mdi-lock-check\"\n                    @click:append-inner=\"isConfirmPasswordVisible = !isConfirmPasswordVisible\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"accountInfo.nickname\"\n                    density=\"comfortable\"\n                    clearable\n                    :label=\"t('profile.nickname')\"\n                    :placeholder=\"t('profile.nicknamePlaceholder')\"\n                    prepend-inner-icon=\"mdi-card-account-details\"\n                  />\n                </VCol>\n              </VRow>\n\n              <VDivider class=\"my-10\">\n                <span>{{ t('profile.accountBinding') }}</span>\n              </VDivider>\n\n              <VRow>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"accountInfo.settings.wechat_userid\"\n                    density=\"comfortable\"\n                    clearable\n                    :label=\"t('profile.wechatUser')\"\n                    prepend-inner-icon=\"mdi-wechat\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"accountInfo.settings.telegram_userid\"\n                    density=\"comfortable\"\n                    clearable\n                    :label=\"t('profile.telegramUser')\"\n                    prepend-inner-icon=\"mdi-send\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"accountInfo.settings.slack_userid\"\n                    density=\"comfortable\"\n                    clearable\n                    :label=\"t('profile.slackUser')\"\n                    prepend-inner-icon=\"mdi-slack\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"accountInfo.settings.discord_userid\"\n                    density=\"comfortable\"\n                    clearable\n                    :label=\"t('profile.discordUser')\"\n                    prepend-inner-icon=\"mdi-discord\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"accountInfo.settings.vocechat_userid\"\n                    density=\"comfortable\"\n                    clearable\n                    :label=\"t('profile.vocechatUser')\"\n                    prepend-inner-icon=\"mdi-chat\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"accountInfo.settings.synologychat_userid\"\n                    density=\"comfortable\"\n                    clearable\n                    :label=\"t('profile.synologychatUser')\"\n                    prepend-inner-icon=\"mdi-message\"\n                  />\n                </VCol>\n                <VCol cols=\"12\" md=\"6\">\n                  <VTextField\n                    v-model=\"accountInfo.settings.douban_userid\"\n                    density=\"comfortable\"\n                    clearable\n                    :label=\"t('profile.doubanUser')\"\n                    prepend-inner-icon=\"mdi-movie\"\n                  />\n                </VCol>\n              </VRow>\n              <VRow>\n                <!-- 👉 Form Actions -->\n                <VCol cols=\"12\" class=\"d-flex flex-wrap gap-4\">\n                  <VBtn @click=\"saveAccountInfo\" :disabled=\"isSaving\" prepend-icon=\"mdi-content-save\">\n                    <span v-if=\"isSaving\">{{ t('common.saving') }}...</span>\n                    <span v-else>{{ t('common.save') }}</span>\n                  </VBtn>\n                </VCol>\n              </VRow>\n            </VForm>\n          </VCardText>\n        </VCard>\n      </VCol>\n    </VRow>\n\n    <!-- 双重验证弹窗 -->\n    <OTPAuthDialog\n      v-model=\"otpDialog\"\n      v-model:is-otp=\"accountInfo.is_otp\"\n      :passkey-list=\"passkeyList\"\n      @verify-password=\"onVerifyPassword\"\n    />\n\n    <!-- PassKey管理对话框 -->\n    <PasskeyDialog\n      v-model=\"passkeyDialog\"\n      :is-otp=\"accountInfo.is_otp\"\n      v-model:passkey-list=\"passkeyList\"\n      @verify-password=\"onVerifyPassword\"\n    />\n\n    <!-- 密码验证对话框 -->\n    <VDialog v-model=\"verifyPasswordDialog\" max-width=\"30rem\">\n      <VCard>\n        <VCardTitle class=\"text-h5 text-center mt-4\">{{ verifyTitle }}</VCardTitle>\n        <VCardText>\n          <p class=\"mb-4\">{{ verifyText }}</p>\n          <VForm @submit.prevent=\"confirmVerifyPassword\">\n            <VTextField\n              v-model=\"verifyPassword\"\n              :type=\"isConfirmPasswordVisible ? 'text' : 'password'\"\n              :label=\"t('user.password')\"\n              :append-inner-icon=\"isConfirmPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'\"\n              variant=\"outlined\"\n              prepend-inner-icon=\"mdi-lock\"\n              autocomplete=\"current-password\"\n              @click:append-inner=\"isConfirmPasswordVisible = !isConfirmPasswordVisible\"\n            />\n            <div class=\"d-flex justify-end gap-4 mt-4\">\n              <VBtn variant=\"outlined\" color=\"secondary\" @click=\"verifyPasswordDialog = false\">\n                {{ t('common.cancel') }}\n              </VBtn>\n              <VBtn type=\"submit\" color=\"primary\">\n                {{ t('common.confirm') }}\n              </VBtn>\n            </div>\n          </VForm>\n        </VCardText>\n      </VCard>\n    </VDialog>\n  </div>\n</template>\n"
  },
  {
    "path": "src/views/workflow/WorkflowListView.vue",
    "content": "<script setup lang=\"ts\">\nimport api from '@/api'\nimport { Workflow } from '@/api/types'\nimport WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue'\nimport WorkflowTaskCard from '@/components/cards/WorkflowTaskCard.vue'\nimport NoDataFound from '@/components/NoDataFound.vue'\nimport { useI18n } from 'vue-i18n'\n\n// 国际化\nconst { t } = useI18n()\n\n// 是否刷新\nconst isRefreshed = ref(false)\n\n// 新增对话框\nconst addDialog = ref(false)\n\n// 所有任务\nconst workflowList = ref<Workflow[]>([])\n\n// 事件类型列表\nconst eventTypes = ref<Array<{ title: string; value: string }>>([])\n\n// 加载事件类型列表\nasync function loadEventTypes() {\n  try {\n    eventTypes.value = await api.get('workflow/event_types')\n  } catch (error) {\n    console.error('Failed to load event types:', error)\n  }\n}\n\n// 加载数据\nasync function fetchData() {\n  try {\n    workflowList.value = await api.get('workflow/')\n    isRefreshed.value = true\n  } catch (error) {\n    console.error(error)\n  }\n}\n\n// 新增完成\nfunction addDone() {\n  addDialog.value = false\n  fetchData()\n}\n\nonMounted(() => {\n  loadEventTypes()\n  fetchData()\n})\n\nonActivated(() => {\n  fetchData()\n})\n\nfunction openAddDialog() {\n  addDialog.value = true\n}\n\ndefineExpose({\n  openAddDialog,\n})\n</script>\n<template>\n  <div>\n    <LoadingBanner v-if=\"!isRefreshed\" class=\"mt-12\" />\n    <div v-if=\"workflowList.length > 0 && isRefreshed\" class=\"grid gap-4 grid-workflow-card px-2\">\n      <WorkflowTaskCard v-for=\"item in workflowList\" :key=\"item.id\" :workflow=\"item\" :event-types=\"eventTypes\" @refresh=\"fetchData\" />\n    </div>\n    <NoDataFound\n      v-if=\"workflowList.length === 0 && isRefreshed\"\n      error-code=\"404\"\n      :error-title=\"t('workflow.noWorkflow')\"\n      :error-description=\"t('workflow.noWorkflowDescription')\"\n    />\n    <!-- 新增对话框 -->\n    <WorkflowAddEditDialog v-if=\"addDialog\" v-model=\"addDialog\" @close=\"addDialog = false\" @save=\"addDone\" />\n  </div>\n</template>\n"
  },
  {
    "path": "src/views/workflow/WorkflowShareView.vue",
    "content": "<script lang=\"ts\" setup>\nimport api from '@/api'\nimport type { WorkflowShare } from '@/api/types'\nimport NoDataFound from '@/components/NoDataFound.vue'\nimport WorkflowShareCard from '@/components/cards/WorkflowShareCard.vue'\nimport { useI18n } from 'vue-i18n'\n\n// 国际化\nconst { t } = useI18n()\n\n// 定义输入参数\nconst props = defineProps({\n  // 过滤关键字\n  keyword: String,\n})\n\n// 定义事件\nconst emit = defineEmits(['update'])\n\n// 判断是否有滚动条\nfunction hasScroll() {\n  return document.body.scrollHeight - (window.innerHeight || document.documentElement.clientHeight) > 2\n}\n\n// API\nconst apipath = 'workflow/shares'\n\n// 当前页码\nconst page = ref(1)\n\n// 搜索关键字\nconst keyword = ref(props.keyword)\nconst currentKey = ref(0)\n\n// 是否加载中\nconst loading = ref(false)\n\n// 是否加载完成\nconst isRefreshed = ref(false)\n\n// 数据列表\nconst dataList = ref<WorkflowShare[]>([])\nconst currData = ref<WorkflowShare[]>([])\n\n// 事件类型列表\nconst eventTypes = ref<Array<{ title: string; value: string }>>([])\n\n// 加载事件类型列表\nasync function loadEventTypes() {\n  try {\n    eventTypes.value = await api.get('workflow/event_types')\n  } catch (error) {\n    console.error('Failed to load event types:', error)\n  }\n}\n\nwatch(\n  () => props.keyword,\n  newKeyword => {\n    keyword.value = newKeyword || ''\n    page.value = 1\n    dataList.value = []\n    isRefreshed.value = false\n    currentKey.value++\n  },\n)\n\n// 拼装参数\nfunction getParams() {\n  let params = {\n    page: page.value,\n    count: 30,\n    name: keyword.value,\n  }\n  return params\n}\n\n// 获取列表数据\nasync function fetchData({ done }: { done: any }) {\n  try {\n    // 如果正在加载中，直接返回\n    if (loading.value) {\n      done('ok')\n      return\n    }\n\n    // 加载到满屏或者加载出错\n    if (!hasScroll()) {\n      // 加载多次\n      while (!hasScroll()) {\n        // 设置加载中\n        loading.value = true\n        // 请求API\n        currData.value = await api.get(apipath, {\n          params: getParams(),\n        })\n        // 取消加载中\n        loading.value = false\n        // 标计为已请求完成\n        isRefreshed.value = true\n        if (currData.value.length === 0) {\n          // 如果没有数据，跳出\n          done('empty')\n          return\n        }\n        // 合并数据\n        dataList.value = [...dataList.value, ...currData.value]\n        // 页码+1\n        page.value++\n        // 返回加载成功\n        done('ok')\n      }\n    } else {\n      // 设置加载中\n      loading.value = true\n      // 请求API\n      currData.value = await api.get(apipath, {\n        params: getParams(),\n      })\n      loading.value = false\n      // 标计为已请求完成\n      isRefreshed.value = true\n      if (currData.value.length === 0) {\n        // 如果没有数据，跳出\n        done('empty')\n      } else {\n        // 合并数据\n        dataList.value = [...dataList.value, ...currData.value]\n        // 页码+1\n        page.value++\n        // 返回加载成功\n        done('ok')\n      }\n    }\n  } catch (error) {\n    console.error(error)\n    // 返回加载失败\n    done('error')\n  }\n}\n\n// 将数据从列表中移除\nfunction removeData(id: string) {\n  dataList.value = dataList.value.filter(item => item.id !== id)\n}\n\nonActivated(() => {\n  loadEventTypes()\n  fetchData({ done: () => {} })\n})\n</script>\n\n<template>\n  <VPageContentTitle v-if=\"keyword\" :title=\"`${t('common.search')}：${keyword}`\" />\n  <LoadingBanner v-if=\"!isRefreshed\" class=\"mt-12\" />\n  <VInfiniteScroll mode=\"intersect\" side=\"end\" :items=\"dataList\" class=\"overflow-visible px-2\" @load=\"fetchData\" :key=\"currentKey\">\n    <template #loading />\n    <template #empty />\n    <div v-if=\"dataList.length > 0\" class=\"grid gap-4 grid-workflow-share-card\" tabindex=\"0\">\n      <div v-for=\"data in dataList\" :key=\"data.id\">\n        <WorkflowShareCard\n          :workflow=\"data\"\n          :event-types=\"eventTypes\"\n          @delete=\"removeData(data.id || '')\"\n          @update=\"emit('update')\"\n        />\n      </div>\n    </div>\n    <NoDataFound\n      v-if=\"dataList.length === 0 && isRefreshed\"\n      error-code=\"404\"\n      :error-title=\"t('common.noData')\"\n      :error-description=\"keyword ? t('common.noContent') : t('workflow.noShareData')\"\n    />\n  </VInfiniteScroll>\n</template>\n"
  },
  {
    "path": "tailwind.config.js",
    "content": "/** @type {import('tailwindcss').Config} */\nmodule.exports = {\n  content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],\n  theme: {\n    extend: {},\n  },\n  future: {\n    hoverOnlyWhenSupported: true,\n  },\n  corePlugins: {\n    aspectRatio: false,\n  },\n  plugins: [\n    require('@tailwindcss/aspect-ratio'),\n    // ...\n  ],\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"baseUrl\": \"./\",\n    \"target\": \"esnext\",\n    \"useDefineForClassFields\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"isolatedModules\": true,\n    \"strict\": true,\n    \"jsx\": \"preserve\",\n    \"jsxFactory\": \"h\",\n    \"jsxFragmentFactory\": \"Fragment\",\n    \"sourceMap\": true,\n    \"resolveJsonModule\": true,\n    \"esModuleInterop\": true,\n    \"paths\": {\n      \"@/*\": [\n        \"src/*\"\n      ],\n      \"@layouts/*\": [\n        \"src/@layouts/*\"\n      ],\n      \"@layouts\": [\n        \"src/@layouts\"\n      ],\n      \"@core/*\": [\n        \"src/@core/*\"\n      ],\n      \"@core\": [\n        \"src/@core\"\n      ],\n      \"@validators\": [\n        \"src/@validators\"\n      ],\n      \"@validators/*\": [\n        \"src/@validators/*\"\n      ],\n      \"@images/*\": [\n        \"src/assets/images/*\"\n      ],\n      \"@styles/*\": [\n        \"src/styles/*\"\n      ],\n    },\n    \"lib\": [\n      \"esnext\",\n      \"dom\",\n      \"dom.iterable\",\n      \"scripthost\",\n      \"WebWorker\"\n    ],\n    \"skipLibCheck\": true,\n    \"types\": [\n      \"vite/client\",\n      \"vite-plugin-pages/client\",\n      \"vite-plugin-vue-layouts/client\"\n    ]\n  },\n  \"include\": [\n    \"vite.config.*\",\n    \"env.d.ts\",\n    \"shims.d.ts\",\n    \"src/**/*\",\n    \"src/**/*.vue\",\n    \"themeConfig.ts\",\n    \"auto-imports.d.ts\",\n    \"components.d.ts\",\n    \"src/service-worker.ts\",\n    \"public/service.js\"\n  ],\n  \"exclude\": [\n    \"dist\",\n    \"node_modules\",\n    \"src/@iconify/*\"\n  ]\n}"
  },
  {
    "path": "vite.config.ts",
    "content": "import { fileURLToPath } from 'node:url'\nimport vue from '@vitejs/plugin-vue'\nimport vueJsx from '@vitejs/plugin-vue-jsx'\nimport AutoImport from 'unplugin-auto-import/vite'\nimport Components from 'unplugin-vue-components/vite'\nimport { defineConfig } from 'vite'\nimport vuetify from 'vite-plugin-vuetify'\nimport { VitePWA } from 'vite-plugin-pwa'\nimport VueI18n from '@intlify/unplugin-vue-i18n/vite'\nimport { resolve } from 'node:path'\nimport federation from '@originjs/vite-plugin-federation'\nimport topLevelAwait from 'vite-plugin-top-level-await'\nimport { readFileSync } from 'node:fs'\n\n// 读取 package.json 获取版本号\nconst packageJson = JSON.parse(readFileSync('./package.json', 'utf-8'))\nconst buildTime = new Date().getTime().toString()\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n  base: './',\n  plugins: [\n    vue(),\n    vueJsx(),\n    vuetify({\n      styles: {\n        configFile: 'src/styles/variables/_vuetify.scss',\n      },\n    }),\n    Components({\n      dirs: ['src/@core/components'],\n      dts: true,\n    }),\n    AutoImport({\n      imports: ['vue', 'vue-router', '@vueuse/core', '@vueuse/math', 'pinia', 'vue-i18n'],\n      vueTemplate: true,\n    }),\n    VueI18n({\n      include: [resolve(__dirname, 'src/locales/*.ts')],\n    }),\n    federation({\n      name: 'MoviePilot',\n      filename: 'remoteEntry.js',\n      // @ts-ignore\n      remotes: {\n        // 动态remotes将在运行时注入\n        dummy: {\n          external: '',\n          format: 'var',\n        },\n      },\n      shared: ['vue', 'vuetify'],\n    }),\n    VitePWA({\n      injectRegister: 'script',\n      registerType: 'autoUpdate',\n      strategies: 'injectManifest',\n      srcDir: 'src',\n      filename: 'service-worker.ts',\n      injectManifest: {\n        rollupFormat: 'iife',\n        maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,\n        globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,webp,woff,woff2,ttf,otf,eot}'],\n      },\n      devOptions: {\n        enabled: true,\n        type: 'module',\n      },\n      manifest: {\n        'name': 'MoviePilot',\n        'short_name': 'MoviePilot',\n        'description': 'MoviePilot - 智能影视媒体库管理工具',\n        'start_url': './',\n        'scope': './',\n        'display': 'standalone',\n        'display_override': ['window-controls-overlay', 'standalone'],\n        'orientation': 'portrait-primary',\n        'lang': 'zh-CN',\n        'dir': 'ltr',\n        'categories': ['entertainment', 'multimedia', 'utilities'],\n        'icons': [\n          {\n            'src': './android-chrome-192x192.png',\n            'sizes': '192x192',\n            'type': 'image/png',\n            'purpose': 'any',\n          },\n          {\n            'src': './android-chrome-192x192_maskable.png',\n            'sizes': '192x192',\n            'type': 'image/png',\n            'purpose': 'maskable',\n          },\n          {\n            'src': './android-chrome-512x512.png',\n            'sizes': '512x512',\n            'type': 'image/png',\n            'purpose': 'any',\n          },\n          {\n            'src': './android-chrome-512x512_maskable.png',\n            'sizes': '512x512',\n            'type': 'image/png',\n            'purpose': 'maskable',\n          },\n        ],\n        'theme_color': '#0E1116',\n        'background_color': '#0E1116',\n        'edge_side_panel': {\n          'preferred_width': 320,\n        },\n        'launch_handler': {\n          'client_mode': 'navigate-existing',\n        },\n        'handle_links': 'preferred',\n        'id': 'moviepilot-app',\n        'shortcuts': [\n          {\n            'name': '推荐',\n            'short_name': '推荐',\n            'description': '查看推荐内容',\n            'url': './recommend',\n            'icons': [\n              {\n                'src': './sparkles-icon-192x192.png',\n                'sizes': '192x192',\n                'type': 'image/png',\n              },\n            ],\n          },\n          {\n            'name': '探索',\n            'short_name': '探索',\n            'description': '探索新内容',\n            'url': './discover',\n            'icons': [\n              {\n                'src': './clock-icon-192x192.png',\n                'sizes': '192x192',\n                'type': 'image/png',\n              },\n            ],\n          },\n          {\n            'name': '更多',\n            'short_name': '更多',\n            'description': '更多功能',\n            'url': './apps',\n            'icons': [\n              {\n                'src': './cog-icon-192x192.png',\n                'sizes': '192x192',\n                'type': 'image/png',\n              },\n            ],\n          },\n        ],\n        'screenshots': [\n          {\n            'src': './android-chrome-512x512.png',\n            'sizes': '512x512',\n            'type': 'image/png',\n            'form_factor': 'wide',\n            'label': 'MoviePilot 主界面',\n          },\n          {\n            'src': './android-chrome-192x192.png',\n            'sizes': '192x192',\n            'type': 'image/png',\n            'form_factor': 'narrow',\n            'label': 'MoviePilot 移动端',\n          },\n        ],\n        'protocol_handlers': [\n          {\n            'protocol': 'web+moviepilot',\n            'url': './?handler=%s',\n          },\n        ],\n        'prefer_related_applications': false,\n        'related_applications': [],\n      },\n    }),\n    topLevelAwait({\n      // The export name of top-level await promise for each chunk module\n      promiseExportName: '__mp_tla',\n      // The function to generate import names of top-level await promise in each chunk module\n      promiseImportName: i => `__mp_tla_${i}`,\n    }),\n  ],\n  define: {\n    'process.env': {},\n    '__APP_VERSION__': JSON.stringify(`v${packageJson.version}`),\n    '__BUILD_TIME__': JSON.stringify(buildTime),\n  },\n  resolve: {\n    alias: {\n      '@': fileURLToPath(new URL('./src', import.meta.url)),\n      '@core': fileURLToPath(new URL('./src/@core', import.meta.url)),\n      '@layouts': fileURLToPath(new URL('./src/@layouts', import.meta.url)),\n      '@images': fileURLToPath(new URL('./src/assets/images/', import.meta.url)),\n      '@styles': fileURLToPath(new URL('./src/styles/', import.meta.url)),\n      '@configured-variables': fileURLToPath(new URL('./src/styles/variables/_template.scss', import.meta.url)),\n      'apexcharts': fileURLToPath(new URL('node_modules/apexcharts', import.meta.url)),\n    },\n  },\n  build: {\n    target: 'esnext',\n    minify: 'terser',\n    terserOptions: {\n      compress: {\n        drop_console: true,\n        drop_debugger: true,\n      },\n    },\n    chunkSizeWarningLimit: 5000,\n    cssCodeSplit: false,\n  },\n  optimizeDeps: {\n    exclude: ['vuetify'],\n    entries: ['./src/**/*.vue'],\n  },\n  server: {\n    proxy: {\n      '/api/v1': {\n        target: 'http://localhost:3001',\n        changeOrigin: true,\n        secure: false,\n        cookieDomainRewrite: 'localhost',\n      },\n    },\n  },\n  css: {\n    preprocessorOptions: {\n      scss: {\n        api: 'modern-compiler',\n        quietDeps: true,\n      },\n    },\n  },\n})\n"
  }
]