[
  {
    "path": ".editorconfig",
    "content": "# https://editorconfig.org\nroot = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\nindent_size = 2\nindent_style = space\ninsert_final_newline = true\nmax_line_length = 80\ntrim_trailing_whitespace = true\n\n[*.md]\nmax_line_length = 0\ntrim_trailing_whitespace = false\n\n[COMMIT_EDITMSG]\nmax_line_length = 0\n"
  },
  {
    "path": ".github/copilot-instructions.md",
    "content": "# Copilot Instructions\n\nThis repository is a pnpm monorepo containing a Vue 3 web application, a VSCode extension, and a core markdown rendering library.\n\n## Build, Test, and Lint\n\n### Global Commands\n- **Install Dependencies:** `pnpm install`\n- **Lint (ESLint + Prettier):** `pnpm run lint`\n- **Type Check (Vue/TS):** `pnpm run type-check`\n\n### Web App (@md/web)\n- **Development Server:** `pnpm web dev`\n- **Build for Production:** `pnpm web build`\n- **Build Browser Extension:** `pnpm web ext:zip` (uses WXT)\n\n### VSCode Extension (@md/vscode)\n- **Development:** `pnpm vscode`\n\n### CLI (@doocs/md-cli)\n- **Build CLI:** `pnpm run build:cli`\n\n## High-Level Architecture\n\n### Monorepo Structure\n- **apps/web**: The main application. Built with Vue 3, Vite, Pinia, and Tailwind CSS. It functions as both a web app and a browser extension (via WXT).\n- **packages/core**: The markdown rendering engine. It wraps `marked` and implements custom extensions (Mermaid, PlantUML, Ruby, etc.) and theme injection.\n- **packages/shared**: Shared utilities and configurations.\n- **packages/md-cli**: A CLI wrapper that serves the built web application.\n\n### Key Technologies\n- **Frontend Framework:** Vue 3 (Composition API)\n- **Build System:** Vite\n- **State Management:** Pinia\n- **Styling:** Tailwind CSS + Custom CSS Variables for themes.\n- **Markdown Parsing:** `marked` (in `@md/core`)\n- **Editor Component:** CodeMirror 6\n- **Extension Framework:** WXT (Web Extension Tools)\n\n## Key Conventions\n\n### Development Patterns\n- **Direct TypeScript Imports:** The `@md/core` and `@md/shared` packages export TypeScript source files directly (`src/index.ts`). Do not attempt to build these packages separately; they are compiled by the consumer's build tool (Vite).\n- **UI Components:** The project uses Shadcn-Vue style components located in `apps/web/src/components/ui`. Prefer using these over raw HTML/CSS.\n- **Store Structure:** State is divided into domain-specific Pinia stores (e.g., `useEditorStore`, `useThemeStore`, `useUiStore`) located in `apps/web/src/stores`.\n\n### Styling & Theming\n- **Theme Injection:** Theming is handled by `@md/core/src/theme`. Themes are applied by injecting CSS variables into the DOM.\n- **CSS processing:** Uses PostCSS and Tailwind. Global styles are in `apps/web/src/assets`.\n\n### Markdown Extensions\n- **Implementation:** New markdown features should be implemented as extensions in `@md/core/src/extensions`.\n- **Registration:** Extensions must be registered in the renderer configuration.\n\n### Git Conventions\n- **Commit Messages:** Follow Conventional Commits (`feat`, `fix`, `docs`, `chore`, etc.).\n- **Branch Naming:** `feat/description`, `fix/description`.\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: npm\n    directory: /\n    schedule:\n      interval: weekly\n      day: tuesday\n      time: '14:00'\n      timezone: Asia/Shanghai\n    target-branch: main\n    ignore:\n      - dependency-name: vue\n      - dependency-name: vite\n\n    open-pull-requests-limit: 100\n    groups:\n      minor-and-patch:\n        applies-to: version-updates\n        update-types:\n          - minor\n          - patch\n\n  - package-ecosystem: github-actions\n    directory: /\n    schedule:\n      interval: daily\n    target-branch: main\n    open-pull-requests-limit: 100\n"
  },
  {
    "path": ".github/secret_scanning.yml",
    "content": "paths-ignore:\n  - \"src/**\"\n"
  },
  {
    "path": ".github/workflows/cloudflare-preview-cleanup.yml",
    "content": "name: Cleanup Cloudflare Preview\n\non:\n  pull_request:\n    types: [closed]\n\njobs:\n  cleanup-preview:\n    runs-on: ubuntu-latest\n    if: github.repository == 'doocs/md'\n    permissions:\n      pull-requests: write\n    steps:\n      - name: Delete preview deployment\n        id: delete\n        continue-on-error: true\n        run: |\n          WORKER_NAME=\"md-pr-${{ github.event.pull_request.number }}\"\n          echo \"Attempting to delete $WORKER_NAME\"\n          RESPONSE=$(curl -s -X DELETE \\\n            \"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/workers/scripts/$WORKER_NAME\" \\\n            -H \"Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}\" \\\n            -H \"Content-Type: application/json\")\n\n          echo \"API Response: $RESPONSE\"\n\n          if echo \"$RESPONSE\" | jq -e '.success == true' > /dev/null 2>&1; then\n            echo \"Successfully deleted $WORKER_NAME\"\n            echo \"status=success\" >> $GITHUB_OUTPUT\n          else\n            echo \"Failed to delete $WORKER_NAME or worker doesn't exist\"\n            echo \"status=failed\" >> $GITHUB_OUTPUT\n          fi\n\n      - name: Comment on PR\n        if: steps.delete.outputs.status == 'success'\n        uses: actions/github-script@v7\n        with:\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n          script: |\n            const prNumber = context.issue.number;\n\n            await github.rest.issues.createComment({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              issue_number: prNumber,\n              body: '🗑️ Cloudflare Workers preview deployment has been cleaned up.'\n            });\n"
  },
  {
    "path": ".github/workflows/cloudflare-preview.yml",
    "content": "name: Cloudflare Workers Preview\n\non:\n  pull_request:\n    types: [opened, synchronize, reopened]\n  workflow_dispatch:\n\nconcurrency:\n  group: cloudflare-preview-${{ github.event.pull_request.number }}\n  cancel-in-progress: true\n\njobs:\n  deploy-preview:\n    runs-on: ubuntu-latest\n    if: github.repository == 'doocs/md' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository\n    permissions:\n      contents: read\n      deployments: write\n      pull-requests: write\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          ref: ${{ github.event.pull_request.head.sha }}\n\n      - name: Set up node\n        uses: actions/setup-node@v6\n        with:\n          node-version: 22\n\n      - name: Setup pnpm\n        uses: pnpm/action-setup@v5\n        with:\n          version: 10\n\n      - name: Install dependencies\n        run: pnpm install\n\n      - name: Build for Cloudflare Workers\n        run: pnpm web build:h5-netlify\n        env:\n          CF_WORKERS: 1\n\n      - name: Deploy to Cloudflare Workers\n        id: deploy\n        uses: cloudflare/wrangler-action@v3\n        with:\n          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}\n          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}\n          command: deploy --name md-pr-${{ github.event.pull_request.number }}\n          workingDirectory: apps/web\n        env:\n          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}\n          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}\n\n      - name: Get deployment URL\n        id: deployment-url\n        run: |\n          PREVIEW_URL=\"https://md-pr-${{ github.event.pull_request.number }}.doocs.workers.dev\"\n          echo \"url=$PREVIEW_URL\" >> $GITHUB_OUTPUT\n          echo \"Preview URL: $PREVIEW_URL\"\n\n      - name: Comment PR with preview link\n        uses: actions-cool/maintain-one-comment@v3\n        with:\n          token: ${{ secrets.GITHUB_TOKEN }}\n          body: |\n            🚀 Cloudflare Workers Preview has been successfully deployed!\n\n            **Preview URL:** ${{ steps.deployment-url.outputs.url }}\n\n            <sub>Built with commit ${{ github.event.pull_request.head.sha }}</sub>\n\n            <!-- Cloudflare Preview Comment -->\n          body-include: '<!-- Cloudflare Preview Comment -->'\n          number: ${{ github.event.pull_request.number }}\n"
  },
  {
    "path": ".github/workflows/deploy-gitee.yml",
    "content": "name: Build and Deploy to Gitee Pages\n\non:\n  push:\n    branches: [main]\n  workflow_dispatch:\n\nconcurrency:\n  group: deploy-gitee-${{ github.ref }}\n  cancel-in-progress: true\n\njobs:\n  build-and-deploy:\n    runs-on: ubuntu-latest\n    if: github.repository == 'doocs/md'\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0  # 获取完整历史，便于推送到新分支\n\n      - name: Set up node\n        uses: actions/setup-node@v6\n        with:\n          node-version: 22\n\n      - name: Setup pnpm\n        uses: pnpm/action-setup@v5\n        with:\n          version: 10\n\n      - name: Install dependencies\n        run: pnpm install\n\n      - name: Build project\n        run: pnpm web build\n\n      - name: Deploy to GitHub Pages\n        uses: peaceiris/actions-gh-pages@v4\n        with:\n          github_token: ${{ secrets.GITHUB_TOKEN }}\n          publish_dir: ./apps/web/dist\n          publish_branch: dist\n          force_orphan: true\n          user_name: 'github-actions[bot]'\n          user_email: 'github-actions[bot]@users.noreply.github.com'\n          commit_message: 'Deploy: ${{ github.sha }}'\n\n      - name: Sync to Gitee\n        id: gitee-sync\n        uses: Yikun/hub-mirror-action@master\n        continue-on-error: true\n        with:\n          src: github/doocs\n          dst: gitee/doocs\n          dst_key: ${{ secrets.GITEE_RSA_PRIVATE_KEY }}\n          dst_token: ${{ secrets.GITEE_TOKEN }}\n          static_list: \"md\"\n          force_update: true\n          debug: true\n\n      - name: Deploy Gitee Pages\n        id: gitee-deploy\n        if: steps.gitee-sync.outcome == 'success'\n        continue-on-error: true\n        uses: yanglbme/gitee-pages-action@main\n        with:\n          gitee-username: ${{ secrets.GITEE_USERNAME }}\n          gitee-password: ${{ secrets.GITEE_PASSWORD }}\n          gitee-repo: doocs/md\n          branch: dist\n\n      - name: Deployment Summary\n        run: |\n          echo \"✅ Build completed successfully!\"\n          echo \"📦 Artifacts pushed to dist branch\"\n          if [ \"${{ steps.gitee-sync.outcome }}\" == \"success\" ]; then\n            echo \"🔄 Synced to Gitee repository\"\n            if [ \"${{ steps.gitee-deploy.outcome }}\" == \"success\" ]; then\n              echo \"🚀 Gitee Pages deployed\"\n              echo \"\"\n              echo \"Gitee Pages: https://doocs.gitee.io/md/\"\n            else\n              echo \"⚠️ Gitee Pages deployment failed or skipped\"\n            fi\n          else\n            echo \"⚠️ Gitee sync failed or skipped\"\n          fi\n"
  },
  {
    "path": ".github/workflows/deploy.yml",
    "content": "name: Build and Deploy\n\non:\n  push:\n    branches: [main]\n  workflow_dispatch:\n\nconcurrency:\n  group: ${{ github.workflow }} - ${{ github.ref }}\n  cancel-in-progress: true\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    if: github.repository == 'doocs/md'\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          persist-credentials: false\n\n      - name: Set up node\n        uses: actions/setup-node@v6\n        with:\n          node-version: 22\n\n      - name: Setup pnpm\n        uses: pnpm/action-setup@v5\n        with:\n          version: 10\n\n      - name: Install dependencies\n        run: pnpm install\n\n      - name: Determine build command\n        id: build-command\n        run: |\n          if [[ \"${{ secrets.BUILD_COMMAND }}\" == \"build:h5-netlify\" ]]; then\n            echo \"BUILD_COMMAND=build:h5-netlify\" >> $GITHUB_ENV\n          else\n            echo \"BUILD_COMMAND=build\" >> $GITHUB_ENV\n          fi\n\n      - name: Run build\n        run: pnpm web ${{ env.BUILD_COMMAND }}\n\n      - name: Generate CNAME\n        run: |\n          mkdir -p apps/web/dist\n          echo \"md.doocs.org\" > apps/web/dist/CNAME\n\n      - name: Upload artifact\n        uses: actions/upload-pages-artifact@v4\n        with:\n          path: apps/web/dist\n\n  deploy-pages:\n    needs: build\n    permissions:\n      pages: write\n      id-token: write\n    environment:\n      name: github_pages\n      url: ${{ steps.deployment.outputs.page_url }}\n    runs-on: ubuntu-latest\n    steps:\n      - name: Deploy to GitHub Pages\n        id: deployment\n        uses: actions/deploy-pages@v4\n\n  deploy-cloudflare:\n    needs: build\n    runs-on: ubuntu-latest\n    if: github.repository == 'doocs/md'\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n\n      - name: Set up node\n        uses: actions/setup-node@v6\n        with:\n          node-version: 22\n\n      - name: Setup pnpm\n        uses: pnpm/action-setup@v5\n        with:\n          version: 10\n\n      - name: Install dependencies\n        run: pnpm install\n\n      - name: Build for Cloudflare Workers\n        run: pnpm web build:h5-netlify\n        env:\n          CF_WORKERS: 1\n\n      - name: Deploy to Cloudflare Workers\n        uses: cloudflare/wrangler-action@v3\n        with:\n          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}\n          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}\n          command: deploy --name md\n          workingDirectory: apps/web\n        env:\n          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}\n          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}\n\n      - name: Deployment Summary\n        run: |\n          echo \"🚀 Deployed to Cloudflare Workers!\"\n          echo \"📍 URL: https://md.doocs.workers.dev\"\n"
  },
  {
    "path": ".github/workflows/docker.yml",
    "content": "name: Build and Push Docker Images\n\non:\n  push:\n    branches:\n      - main\n  workflow_dispatch:\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    if: github.repository == 'doocs/md'\n    steps:\n    - name: Checkout code\n      uses: actions/checkout@v6\n\n    - name: Set up QEMU\n      uses: docker/setup-qemu-action@v4\n\n    - name: Set up Docker Buildx\n      uses: docker/setup-buildx-action@v4\n\n    - name: Log in to Docker Hub\n      uses: docker/login-action@v4\n      with:\n        username: ${{ secrets.DOCKER_USERNAME }}\n        password: ${{ secrets.DOCKER_PASSWORD }}\n\n    - name: Build and push multi-arch images\n      run: |\n        chmod +x scripts/build-multiarch.sh\n        bash scripts/build-multiarch.sh\n"
  },
  {
    "path": ".github/workflows/release-cli.yml",
    "content": "name: Create Cli Release\n\non:\n  push:\n    tags:\n      - 'cli-v*'\n\npermissions:\n  id-token: write  # Required for OIDC trusted publishing\n  contents: read\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    if: github.repository == 'doocs/md'\n    steps:\n      - uses: actions/checkout@v6\n\n      - uses: actions/setup-node@v6\n        with:\n          node-version: 22\n          registry-url: https://registry.npmjs.org/\n\n      - name: Setup pnpm\n        uses: pnpm/action-setup@v5\n        with:\n          version: 10\n\n      - name: Update npm\n        run: npm install -g npm@latest\n\n      - run: pnpm install --frozen-lockfile\n      - run: pnpm run build:cli\n\n      - run: cd packages/md-cli && npm publish --registry=https://registry.npmjs.org/\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Create Release\n\non:\n  push:\n    tags:\n      - \"v*\"\n\njobs:\n  release:\n    name: Create GitHub Release\n    runs-on: ubuntu-latest\n    if: github.repository == 'doocs/md'\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Extract Changelog for Tag\n        id: changelog\n        run: |\n          TAG_NAME=\"${GITHUB_REF##*/}\"\n          echo \"Extracting changelog for $TAG_NAME\"\n\n          # 提取 CHANGELOG.md 中对应版本块的内容\n          CHANGELOG=$(awk \"/^## \\\\[$TAG_NAME\\\\]/ {flag=1; next} /^## \\\\[/ {flag=0} flag\" CHANGELOG.md)\n\n          # 如果为空就设置默认信息\n          if [ -z \"$CHANGELOG\" ]; then\n            CHANGELOG=\"No changelog entry found for $TAG_NAME.\"\n          fi\n\n          echo \"changelog<<EOF\" >> $GITHUB_OUTPUT\n          echo \"$CHANGELOG\" >> $GITHUB_OUTPUT\n          echo \"EOF\" >> $GITHUB_OUTPUT\n\n      - name: Create GitHub Release\n        uses: actions/create-release@v1\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        with:\n          tag_name: ${{ github.ref }}\n          release_name: ${{ github.ref }}\n          body: |\n            # 微信 Markdown 编辑器 ${{ github.ref_name }} 发布🎈\n\n            [![github](https://badgen.net/badge/>>/GitHub/cyan)](https://github.com/doocs/md/releases) [![gitee](https://badgen.net/badge/>>/Gitee/cyan)](https://gitee.com/doocs/md/releases) [![gitcode](https://badgen.net/badge/>>/GitCode/cyan)](https://gitcode.com/doocs/md/releases)\n\n            > Markdown 文档自动即时渲染为微信图文，让你不再为微信内容排版而发愁！\n\n            ${{ steps.changelog.outputs.changelog }}\n\n          draft: false\n          prerelease: false\n"
  },
  {
    "path": ".github/workflows/stale-bot.yml",
    "content": "name: Stale Bot\n\non:\n  schedule:\n    - cron: \"0 6 * * *\" # 每天北京时间 14:00 运行\n\njobs:\n  stale:\n    runs-on: ubuntu-latest\n    permissions:\n      issues: write\n      pull-requests: write\n    steps:\n      - uses: actions/stale@v10\n        with:\n          repo-token: ${{ secrets.GITHUB_TOKEN }}\n\n          # Issue 设置\n          days-before-stale: 60      # 超过 60 天无活动 -> 标记 stale\n          days-before-close: 7       # 被标记后 7 天仍无活动 -> 关闭\n          stale-issue-message: \"此 Issue 因长期无回复而被标记为过期，如果 7 天内无回复将自动关闭。\"\n          close-issue-message: \"此 Issue 已因无长期无回复自动关闭。\"\n\n          days-before-pr-stale: -1   # 不处理 PR\n          days-before-pr-close: -1\n\n          # 以下标签不会被标记为 stale\n          exempt-issue-labels: \"help wanted, good first issue, never gets stale, enhancement, bug, issue: author provided repro\"\n\n  stale-needs-author-feedback:\n    runs-on: ubuntu-latest\n    permissions:\n      issues: write\n      pull-requests: write\n    steps:\n      - uses: actions/stale@v10\n        with:\n          repo-token: ${{ secrets.GITHUB_TOKEN }}\n          # 仅处理包含 \"needs: author feedback\" 标签的 Issue\n          any-of-labels: 'needs: author feedback'\n\n          # Issue 设置\n          days-before-stale: 24\n          days-before-close: 7\n          stale-issue-message: \"此 Issue 已等待作者反馈 24 天，请提供所需信息，否则 7 天后将自动关闭。\"\n          close-issue-message: \"此 Issue 因作者未在 7 天内提供所需反馈而自动关闭。\"\n\n          days-before-pr-stale: -1   # 不处理 PR\n          days-before-pr-close: -1\n\n          # 以下标签不会被标记为 stale\n          exempt-issue-labels: \"help wanted, good first issue, never gets stale, enhancement, bug, issue: author provided repro\"\n"
  },
  {
    "path": ".github/workflows/surge-preview-build.yml",
    "content": "name: Surge Preview Build\n\non:\n  pull_request:\n    types: [opened, synchronize, reopened]\n\njobs:\n  build-preview:\n    runs-on: ubuntu-latest\n    if: github.repository == 'doocs/md'\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          ref: ${{ github.event.pull_request.head.sha }}\n\n      - name: Set up node\n        uses: actions/setup-node@v6\n        with:\n          node-version: 22\n\n      - name: Setup pnpm\n        uses: pnpm/action-setup@v5\n        with:\n          version: 10\n\n      - name: Build\n        run: |\n          pnpm install\n          pnpm web build:h5-netlify\n\n      - name: Upload dist artifact\n        uses: actions/upload-artifact@v7\n        with:\n          name: dist\n          path: apps/web/dist\n          retention-days: 5\n\n      - name: Save PR number\n        if: ${{ always() }}\n        run: echo ${{ github.event.number }} > ./pr-id.txt\n\n      - name: Upload PR number\n        if: ${{ always() }}\n        uses: actions/upload-artifact@v7\n        with:\n          name: pr\n          path: ./pr-id.txt\n"
  },
  {
    "path": ".github/workflows/surge-preview-deploy.yml",
    "content": "name: Surge Preview Deploy\n\non:\n  workflow_run:\n    workflows: [\"Surge Preview Build\"]\n    types:\n      - completed\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' && github.repository == 'doocs/md'\n    steps:\n      - name: Download PR artifact\n        uses: dawidd6/action-download-artifact@v19\n        with:\n          workflow: ${{ github.event.workflow_run.workflow_id }}\n          run_id: ${{ github.event.workflow_run.id }}\n          name: pr\n\n      - name: Save PR id\n        id: pr\n        run: |\n          pr_id=$(<pr-id.txt)\n          if ! [[ \"$pr_id\" =~ ^[0-9]+$ ]]; then\n            echo \"Error: pr-id.txt does not contain a valid numeric PR id. Please check.\"\n            exit 1\n          fi\n          echo \"id=$pr_id\" >> $GITHUB_OUTPUT\n\n      - name: Download dist artifact\n        uses: dawidd6/action-download-artifact@v19\n        with:\n          workflow: ${{ github.event.workflow_run.workflow_id }}\n          run_id: ${{ github.event.workflow_run.id }}\n          workflow_conclusion: success\n          name: dist\n\n      - name: Upload surge service\n        id: deploy\n        run: |\n          export DEPLOY_DOMAIN=https://doocs-md-preview-pr-${{ steps.pr.outputs.id }}.surge.sh\n          npx surge --project ./ --domain $DEPLOY_DOMAIN --token ${{ secrets.SURGE_TOKEN }}\n\n      - name: Comment PR with preview link\n        uses: actions-cool/maintain-one-comment@v3\n        with:\n          token: ${{ secrets.GITHUB_TOKEN }}\n          body: |\n            🚀 Surge Preview has been successfully deployed!\n\n            **Preview URL:** https://doocs-md-preview-pr-${{ steps.pr.outputs.id }}.surge.sh\n\n            <sub>Built with commit ${{ github.event.workflow_run.head_sha }}</sub>\n\n            <!-- Surge Preview Comment -->\n          body-include: '<!-- Surge Preview Comment -->'\n          number: ${{ steps.pr.outputs.id }}\n\n      - name: Deploy failed\n        if: ${{ failure() }}\n        uses: actions-cool/maintain-one-comment@v3\n        with:\n          token: ${{ secrets.GITHUB_TOKEN }}\n          body: |\n            😭 Surge Preview deployment failed.\n\n            Please check the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.\n\n            <!-- Surge Preview Comment -->\n          body-include: '<!-- Surge Preview Comment -->'\n          number: ${{ steps.pr.outputs.id }}\n\n  failed:\n    runs-on: ubuntu-latest\n    if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'failure' && github.repository == 'doocs/md'\n    steps:\n      - name: Download PR artifact\n        uses: dawidd6/action-download-artifact@v19\n        with:\n          workflow: ${{ github.event.workflow_run.workflow_id }}\n          run_id: ${{ github.event.workflow_run.id }}\n          name: pr\n\n      - name: Save PR id\n        id: pr\n        run: |\n          pr_id=$(<pr-id.txt)\n          if ! [[ \"$pr_id\" =~ ^[0-9]+$ ]]; then\n            echo \"Error: pr-id.txt does not contain a valid numeric PR id. Please check.\"\n            exit 1\n          fi\n          echo \"id=$pr_id\" >> $GITHUB_OUTPUT\n\n      - name: Comment PR with build failure\n        uses: actions-cool/maintain-one-comment@v3\n        with:\n          token: ${{ secrets.GITHUB_TOKEN }}\n          body: |\n            😭 Surge Preview build failed.\n\n            Please check the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}) for details.\n\n            <!-- Surge Preview Comment -->\n          body-include: '<!-- Surge Preview Comment -->'\n          number: ${{ steps.pr.outputs.id }}\n"
  },
  {
    "path": ".gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\nnode_modules\n/.pnp\n.pnp.js\n\n# testing\n/coverage\n\n# production\n/build\ndist\n\n# misc\n.DS_Store\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# Editor directories and files\n.idea\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n\n# mockm\nhttpData\n\npublic/upload/**\n!public/upload/*.gitkeep\n.history\n\n# Package manager lock file\npackage-lock.json\nyarn.lock\n# pnpm-lock.yaml\nauto-imports.d.ts\ncomponents.d.ts\n\n.wxt\n.output\nweb-ext.config.ts\n.wrangler\n\n# vite-plugin-pwa dev output\ndev-dist\n\n# uTools build artifacts\napps/utools/dist\napps/utools/release\n\n# uTools local libs (只在打包时需要，不提交到仓库)\napps/web/public/static/libs/mathjax\napps/web/public/static/libs/mermaid\napps/web/public/static/libs/article-syncjs\n"
  },
  {
    "path": ".husky/pre-commit",
    "content": "#!/bin/sh\n\nif [ \"$SKIP_SIMPLE_GIT_HOOKS\" = \"1\" ]; then\n    echo \"[INFO] SKIP_SIMPLE_GIT_HOOKS is set to 1, skipping hook.\"\n    exit 0\nfi\n\nif [ -f \"$SIMPLE_GIT_HOOKS_RC\" ]; then\n    . \"$SIMPLE_GIT_HOOKS_RC\"\nfi\n\nnpx lint-staged"
  },
  {
    "path": ".npmrc",
    "content": "registry=https://registry.npmmirror.com"
  },
  {
    "path": ".nvmrc",
    "content": "v22.16.0\n"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\"Vue.volar\"]\n}\n"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n  \"version\": \"0.2.0\",\n  \"configurations\": [\n    {\n      \"name\": \"Run Extension\",\n      \"type\": \"extensionHost\",\n      \"request\": \"launch\",\n      \"trace\": true,\n      \"sourceMaps\": true,\n      \"cwd\": \"${workspaceFolder}/apps/vscode\",\n      \"args\": [\"--extensionDevelopmentPath=${workspaceFolder}/apps/vscode\", \"--enable-proposed-api=vscode.vscode-js-debug\"],\n      \"runtimeExecutable\": \"${execPath}\",\n      \"outFiles\": [\"${workspaceFolder}/apps/vscode/dist/*.js\"],\n      \"preLaunchTask\": \"compile extension\"\n    }\n  ]\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  // Disable the default formatter, use eslint instead\n  \"prettier.enable\": false,\n  \"editor.formatOnSave\": false,\n\n  // Auto fix\n  \"editor.codeActionsOnSave\": {\n    \"source.fixAll.eslint\": \"explicit\",\n    \"source.organizeImports\": \"never\"\n  },\n\n  // Silent the stylistic rules in you IDE, but still auto fix them\n  \"eslint.rules.customizations\": [\n    { \"rule\": \"style/*\", \"severity\": \"off\" },\n    { \"rule\": \"format/*\", \"severity\": \"off\" },\n    { \"rule\": \"*-indent\", \"severity\": \"off\" },\n    { \"rule\": \"*-spacing\", \"severity\": \"off\" },\n    { \"rule\": \"*-spaces\", \"severity\": \"off\" },\n    { \"rule\": \"*-order\", \"severity\": \"off\" },\n    { \"rule\": \"*-dangle\", \"severity\": \"off\" },\n    { \"rule\": \"*-newline\", \"severity\": \"off\" },\n    { \"rule\": \"*quotes\", \"severity\": \"off\" },\n    { \"rule\": \"*semi\", \"severity\": \"off\" }\n  ],\n\n  // Enable eslint for all supported languages\n  \"eslint.validate\": [\n    \"javascript\",\n    \"javascriptreact\",\n    \"typescript\",\n    \"typescriptreact\",\n    \"vue\",\n    \"html\",\n    \"markdown\",\n    \"json\",\n    \"jsonc\",\n    \"yaml\",\n    \"toml\",\n    \"xml\",\n    \"gql\",\n    \"graphql\",\n    \"astro\",\n    \"css\",\n    \"less\",\n    \"scss\",\n    \"pcss\",\n    \"postcss\"\n  ]\n}\n"
  },
  {
    "path": ".vscode/tasks.json",
    "content": "{\n  \"version\": \"2.0.0\",\n  \"tasks\": [\n    {\n      \"type\": \"npm\",\n      \"script\": \"compile\",\n      \"path\": \"apps/vscode\",\n      \"group\": \"build\",\n      \"problemMatcher\": [],\n      \"label\": \"compile extension\",\n      \"detail\": \"webpack\"\n    }\n  ]\n}\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "## [v2.1.0] - 2025-10-17\n\n### ✨ 新特性\n\n- **AI 助手侧边栏 & 文生图**：新增独立的 AI 助手侧边栏，支持智能对话、文本生成与 AI 图像生成功能，让创作更高效便捷。  \n- **PlantUML & Ruby 语法支持**：支持 PlantUML 图表渲染和 Ruby 文本标注（注音符号），扩展图表与多语言表达能力。  \n- **图片压缩 & 上传进度条**：上传图片时自动压缩并显示进度条，优化存储空间与用户体验。  \n- **移动端适配增强**：进一步优化移动端显示与交互体验，支持更流畅的移动端创作。\n\n### 🏗️ 架构与部署\n\n- **CodeMirror 升级至 v6**：编辑器核心组件全面升级，提升性能与稳定性。\n- **Vite 升级至 v7**：构建工具全面升级，显著提升构建速度与开发体验。  \n- **pnpm Monorepo 重构**：项目重构为 pnpm monorepo 架构，提升代码组织与维护效率。  \n- **Cloudflare Workers 部署**：支持 Cloudflare Workers 部署与自动化 CI/CD，增强全球访问性能。  \n\n### 👏 贡献者\n\n感谢以下贡献者的杰出贡献：\n\n@yanglbme @YangFong @zeevenn @honwhy @xxxxxxxjy @lihuacai168 @simonzhangs 等\n\n> 感谢所有贡献者的努力！🚀  \n> **doocs/md** 将持续为打造更流畅、更强大的 Markdown 创作体验不断前行！\n\n## [v2.0.4] - 2025-06-20\n\n### ✨ 新特性\n\n- **编辑器搜索与替换**：编辑器新增搜索和替换快捷键，提升文本编辑效率。  \n- **标题快捷键支持**：新增 `Ctrl/Cmd + 1~6` 快捷键，可快速插入对应级别的 Markdown 标题。\n- **VSCode 插件初步集成**：开始支持 VSCode 插件，为桌面端用户提供另一种创作体验。  \n- **浏览器插件扩展 SitePanel 支持**：浏览器插件支持 SitePanel 集成，增强页面侧边栏能力。\n\n### 🛠 功能优化与问题修复\n\n- **重置问题修复**：修复重置编辑器文档时未正确清空的问题。  \n- **XSS 漏洞修复**：修复潜在的跨站脚本攻击问题，提升系统安全性。  \n- **Docker 镜像问题修复**：解决构建与运行中的镜像异常问题，增强部署稳定性。\n\n### 👏 贡献者\n\n感谢以下贡献者的杰出贡献：\n\n@syhxzzz @yanglbme @YangFong @honwhy @bygsn @lurenyang418 等\n更强大的 Markdown 创作体验不断前行！\n\n> 感谢所有贡献者的努力！🚀  \n> **doocs/md** 将持续为打造更流畅、更强大的 Markdown 创作体验不断前行！\n\n## [v2.0.3] - 2025-05-25\n\n### ✨ 新特性\n\n- **AI 引用全文 & 快捷指令**：AI 现在可直接引用整篇文档并支持自定义快捷指令，编辑器与 AI 能力深度融合，提升交互效率。  \n- **一键清空文档**：新增“清空”按钮，支持一键删除全部内容，快速重置编辑环境。  \n- **公众号名片插入**：支持在 Markdown 中便捷插入公众号名片，丰富文章展示形式。  \n- **Telegram & Cloudinary 图床**：新增 Telegram、Cloudinary 图床选项，进一步扩充多图床生态。  \n\n### 🛠 功能优化与问题修复\n\n- **AI Prompt 优化**：改进提示词生成策略，使 AI 回答更精准、上下文衔接更自然。  \n- **文件名修复**：解决保存与导出时偶发的文件名错误问题。  \n- **行内公式样式优化**：调整行内公式渲染样式，提升可读性与排版一致性。  \n- **编辑器快捷键优化**：重新梳理常用快捷键映射，操作更顺手。  \n\n### 👏 贡献者\n\n@yanglbme @syhxzzz @YangFong @Nefelibata-Zhu @biggerboy @SiZV200 @XAihan @zzydannyer @quiet-river  \n\n> 感谢所有贡献者的努力！🚀  \n> **doocs/md** 将持续为打造更流畅、更强大的 Markdown 创作体验不断前行！\n\n## [v2.0.2] - 2025-05-06\n\n### ✨ 新特性\n\n- **AI 工具箱**：支持智能优化文本、翻译、文本纠错、内容总结等功能，进一步提升内容创作效率。  \n- **配置导入与导出**：支持导出、导入配置，实现跨设备同步，简化配置管理。\n- **又拍云图床支持**：新增又拍云图床，丰富图床选择。  \n- **多预览模式**：支持移动端和电脑端两种预览模式，适配不同设备的阅读和编辑体验。  \n- **AI 推理过程展示**：AI 对话功能支持展示推理过程，提升对话透明度与可解释性。\n- **脚注支持**：支持 Markdown 脚注功能，方便用户为文档添加注释与引用。\n\n### 🛠 功能优化与问题修复\n\n- **修复消息错乱**：修复 AI 对话过程中删除消息导致消息错乱的问题。  \n- **排序问题修复**：修复内容管理模块中排序方式失效的问题。  \n\n### 👏 贡献者\n\n感谢以下贡献者的杰出贡献：\n\n@yanglbme @YangFong @honwhy @wNing50 @zzydannyer @XAihan @dodolalorc @codedogQBY @quiet-river @acbin\n\n> 感谢所有贡献者的努力！🚀  \n> **doocs/md** 将持续为打造更流畅、更强大的 Markdown 创作体验不断前行！\n\n\n## [v2.0.1] - 2025-04-28\n\n### ✨ 新特性\n\n- **AI 能力增强**：支持多种主流 AI 模型，包括 DeepSeek、OpenAI、通讯千问、腾讯混元、智谱 AI、百川智能、月之暗面等。内置默认 AI 服务，用户无需配置 sk，即可免费使用智能助手功能，提升内容创作与处理体验。\n- **公众号图片上传体验优化**：通过引入 Cloudflare Functions & Pages，进一步优化了公众号图床的配置与上传体验。\n- **内容管理功能提升**：支持自定义内容排序方式，帮助用户更灵活地管理和查找内容。\n- **支持导出为 PNG**：可将文档内容一键导出为 PNG 图片，方便快速分享与保存。\n- **初步适配移动端**：针对移动端进行了初步适配，优化了浏览与编辑体验，为不同设备使用场景打下基础。\n\n### 🛠 功能优化与问题修复\n\n- **主题样式修复**：修复了部分主题存在的样式问题，提升整体界面一致性和视觉体验。\n- **编辑界面优化**：优化了编辑器的界面布局与交互体验，提升用户操作的流畅性。\n\n### 👏 贡献者\n\n感谢以下贡献者的杰出贡献：\n\n@honwhy @YangFong @ting772 @yanglbme @acbin @chinenkai @wNing50\n\n> 感谢所有贡献者的努力！🚀  \n> **doocs/md** 将持续为打造更流畅、更强大的 Markdown 创作体验不断前行！\n\n## [v2.0.0] - 2025-04-18\n\n### 1. 新特性亮点\n\n- **数学公式与 Mermaid 流程图支持**：全面支持 Markdown 基础语法、数学公式、Mermaid 图表等，提升内容表达能力。\n- **自定义样式面板**：新增样式自定义面板，支持主题色和 CSS 定制，适配浅/暗模式。\n- **本地内容管理**：支持一键导入导出和自动草稿保存，提升编辑效率与安全性。\n- **图床支持扩展**：新增公众号与 Cloudflare R2 图床支持，灵活的上传逻辑配置。\n- **插件支持**：新增浏览器扩展插件，支持 Chrome、Edge、Firefox 等主流浏览器。\n- **AI 助手集成**：集成智能 AI 助手功能，支持与主流 AI 模型（如 DeepSeek、OpenAI、通义千问）进行自然语言对话，辅助内容创作、语法优化、格式转换等场景，极大提升写作效率。\n\n### 2. 框架、镜像升级\n\n- **Node.js 20+ 与 Vue3 + Vite**：全面升级依赖，基于 Vue3 和 Vite，显著提升性能与兼容性。\n- **Docker 多架构镜像**：支持 `linux/arm64` 和 `linux/amd64` 多架构镜像。\n\n### 3. 贡献者\n\n@YangFong @yanglbme @honwhy @bravekingzhang @dribble-njr @lurenyang418 @chensirup @wll8 @thinkasany @arunsathiya @realskyrin @rwecho\n\n## [v1.6.0] - 2023-12-05\n\n### 1. 新特性亮点\n\n- **Mac 风格代码块样式支持**：增加 Mac 风格的代码块渲染样式，提升视觉一致性与可读性。\n- **LATEX 数学公式支持**：引入 LATEX 编辑与渲染能力，支持科学公式表达，适用于技术写作与学术场景。\n\n### 2. 功能优化与修复\n\n- **组件重构与性能优化**：对部分组件结构进行重构与优化，提升整体性能与维护性。\n- **Bug 修复**：修复部分用户反馈的问题，提升使用稳定性与用户体验。\n\n### 3. 框架与部署支持\n\n- **Node 版本升级**：升级 Node.js 版本以增强兼容性和构建性能。\n- **Docker 镜像同步推送**：更新版本已同步发布至 Docker Hub，可通过以下命令快速启动本地实例 `docker run -d -p 8080:80 doocs/md:latest`\n\n### 4. 贡献者\n\n@YangFong @yanglbme @bravekingzhang @DandelionCloud\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# 贡献指南\n\n感谢你对 **doocs/md** 的兴趣！我们欢迎任何形式的贡献，包括但不限于报告缺陷、改进文档、提交新特性或修复 Bug。本指南旨在帮助你快速地为项目做出贡献。\n\n## 目录\n\n- [贡献指南](#贡献指南)\n  - [目录](#目录)\n  - [前置条件](#前置条件)\n  - [快速开始](#快速开始)\n  - [开发流程](#开发流程)\n  - [代码规范](#代码规范)\n  - [提交规范](#提交规范)\n    - [Branch 命名](#branch-命名)\n    - [Pull Request 标题](#pull-request-标题)\n  - [Pull Request 流程](#pull-request-流程)\n  - [Issue 报告](#issue-报告)\n  - [行为准则](#行为准则)\n  - [沟通渠道](#沟通渠道)\n\n## 前置条件\n\n- **Node.js ≥ 22**\n- **pnpm ≥ 10**\n\n## 快速开始\n\n该项目为 pnpm monorepo 项目，使用 pnpm 管理依赖。\n\n项目结构如下：\n\n```shell\n- apps\n  - web           # 网页及浏览器插件\n  - vscode        # VSCode 插件\n- packages\n  - config        # 项目级别配置\n  - core          # 核心 markdown 渲染器\n  - shared        # 共享的配置、常量、类型和工具函数\n  - example       # 公众号 openapi 接口代理服务示例\n  - md-cli        # 命令行工具\n```\n\n以开发 `@md/web` 为例：\n\n```bash\n# 1. Fork 本仓库并克隆\ngit clone https://github.com/<你的用户名>/md.git\ncd md\n\n# 2. 配置上游仓库\ngit remote add upstream https://github.com/doocs/md.git\n\n# 3. 安装依赖\npnpm install\n\n# 4. 启动本地开发\npnpm web dev\n```\n\n## 开发流程\n\n1. 从 `main` 分支拉取最新代码：\n\n   ```bash\n   git checkout main\n   git pull upstream main\n   ```\n\n2. 基于 `main` 创建功能分支：\n\n   ```bash\n   git checkout -b feat/awesome-feature\n   ```\n\n3. 编码 & 编写/更新测试。\n4. 运行检查：\n\n   ```bash\n   pnpm run lint        # ESLint + Prettier\n   pnpm run type-check  # TypeScript 类型检查\n   pnpm run web build       # 产物验证\n   ```\n\n5. 提交并推送：\n\n   ```bash\n   git add .\n   git commit -m \"feat: awesome feature\"\n   git push origin feat/awesome-feature\n   ```\n\n6. 在 GitHub 页面发起 **Pull Request**。\n\n> [!TIP]\n> 开发时可在 `apps/web` 目录下新建 `.env.local` 文件，配置 `VITE_LAUNCH_EDITOR` 为 `code` （默认值）或其他 [支持的编辑器](https://github.com/yyx990803/launch-editor?tab=readme-ov-file#supported-editors)，方便调试。\n>\n> 例如：\n>\n> ```\n> VITE_LAUNCH_EDITOR=cursor\n> ```\n\n## 代码规范\n\n- 遵循项目自带的 **ESLint**、**Prettier** 与 **Stylelint** 配置。\n- 所有提交必须通过 `pnpm run lint` 检查，无警告、无错误。\n- 推荐在 IDE 中启用 **ESLint** 与 **Prettier** 自动修复。\n\n## 提交规范\n\n| 类型     | 说明                       |\n| -------- | -------------------------- |\n| feat     | 新功能                     |\n| fix      | Bug 修复                   |\n| docs     | 文档变更                   |\n| style    | 代码格式（不影响逻辑）     |\n| refactor | 重构（非修复亦非新增功能） |\n| perf     | 性能优化                   |\n| test     | 测试相关                   |\n| build    | 构建系统或依赖变动         |\n| chore    | 其他辅助变动               |\n\n### Branch 命名\n\n```\nfeat/<简要描述>\nfix/<简要描述>\ndocs/<简要描述>\n```\n\n### Pull Request 标题\n\n保持与首条 commit message 一致，建议附带影响范围（Scope）与简要描述，例如：\n\n```\nfeat(editor): 支持自定义快捷键\n```\n\n## Pull Request 流程\n\n1. **描述清晰**：在 PR 模板中说明变更动机、相关 Issue、实现方案及影响范围。\n2. **保持小而聚焦**：一个 PR 只做一件事，方便审阅。\n3. **确保测试**：新增/变更功能需自测，确保没问题。\n4. **更新文档**：公共 API 或行为变更必须同步更新文档。\n5. **CI 通过**：PR 必须通过所有 CI 检查（类型、lint、单测、构建）。\n6. **等待审核**：维护者会在 1 ～ 3 个工作日内回复。请耐心等待并根据建议进行修订。\n\n## Issue 报告\n\n- 先 **搜索** 已有 Issue，避免重复。\n- 提供 **可复现仓库 / 代码片段 / 截图 / 终端输出**。\n- 说明 **期望行为** 与 **实际行为**。\n- 指明 **运行环境**（操作系统、浏览器、Node 版本等）。\n- Bug 标签由维护者分配，请勿自行指定。\n\n## 行为准则\n\n我们遵循 [Contributor Covenant](https://www.contributor-covenant.org/) v2.1。\n任何违反行为准则的行为都可能导致暂时或永久的禁言、封号。请保持友善。\n\n## 沟通渠道\n\n- **GitHub Discussions**：[https://github.com/doocs/md/discussions](https://github.com/doocs/md/discussions)\n- **Issues**：仅限缺陷反馈和功能需求\n- **微信群**：添加项目维护者微信，备注 `md`，拉你进群\n\n---\n\n❤️ 感谢每一位贡献者！让我们一起让 **doocs/md** 变得更好。\n"
  },
  {
    "path": "LICENSE",
    "content": "            DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE\n                    Version 2, December 2004\n\n Copyright (C) 2025 Doocs <admin@doocs.org>\n\n Everyone is permitted to copy and distribute verbatim or modified\n copies of this license document, and changing it is allowed as long\n as the name is changed.\n\n            DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE\n   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION\n\n  0. You just DO WHAT THE FUCK YOU WANT TO.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n\n[![doocs-md](https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/doocs/md/images/logo-2.png)](https://github.com/doocs/md)\n\n</div>\n\n<h1 align=\"center\">微信 Markdown 编辑器</h1>\n\n<div align=\"center\">\n\n[![status](https://img.shields.io/github/actions/workflow/status/doocs/md/deploy.yml?style=flat-square&labelColor=564341&color=42cc23)](https://github.com/doocs/md/actions) [![node](https://img.shields.io/badge/node-%3E%3D22-42cc23?style=flat-square&labelColor=564341)](https://nodejs.org/en/about/previous-releases) [![pr](https://img.shields.io/badge/prs-welcome-42cc23?style=flat-square&labelColor=564341)](https://github.com/doocs/md/pulls) [![stars](https://img.shields.io/github/stars/doocs/md?style=flat-square&labelColor=564341&color=42cc23)](https://github.com/doocs/md/stargazers) [![forks](https://img.shields.io/github/forks/doocs/md?style=flat-square&labelColor=564341&color=42cc23)](https://github.com/doocs/md)<br> [![release](https://img.shields.io/github/v/release/doocs/md?style=flat-square&labelColor=564341&color=42cc23)](https://github.com/doocs/md/releases) [![npm](https://img.shields.io/npm/v/@doocs/md-cli?style=flat-square&labelColor=564341&color=42cc23)](https://www.npmjs.com/package/@doocs/md-cli) [![docker](https://img.shields.io/badge/docker-latest-42cc23?style=flat-square&labelColor=564341)](https://hub.docker.com/r/doocs/md)\n\n</div>\n\n## 🎯 赞助商\n\n<div align=\"center\">\n\n[![302.AI](https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/doocs/md/images/sponsor-1.jpg)](https://share.302.ai/ftIXIE)\n\n</div>\n\n> **[302.AI](https://share.302.ai/ftIXIE)** 是一个按用量付费的企业级 AI 资源平台，提供市场上最新、最全面的 AI 模型和 API，以及多种开箱即用的在线 AI 应用。\n\n## 📝 项目介绍\n\n**Markdown 文档自动即时渲染为微信图文**，让你不再为微信内容排版而发愁！只要你会基本的 Markdown 语法（现在有了 AI，你甚至不需要会 Markdown），就能做出一篇样式简洁而又美观大方的微信图文。\n\n**如果这个项目对你有帮助，请给我们点个 Star ⭐️**，我们会持续更新和维护！\n\n## 🌐 在线编辑器地址\n\n[https://md.doocs.org](https://md.doocs.org)\n\n> **推荐使用 Chrome 浏览器**，效果最佳。\n\n## 🤔 为何开发这款编辑器\n\n现有的开源微信 Markdown 编辑器样式繁杂，排版过程中往往需要额外调整，影响使用效率。为了解决这一问题，我们打造了一款更加**简洁、优雅**的编辑器，提供更流畅的排版体验。\n\n欢迎各位朋友随时提交 PR，让这款微信 Markdown 编辑器变得更好！如果你有新的想法，也欢迎在 [💬 Discussions 讨论区](https://github.com/doocs/md/discussions)反馈。\n\n## ✨ 功能特性\n\n### 🎨 核心功能\n\n- ✅ **完整 Markdown 支持** - 支持所有基础语法、数学公式\n- ✅ **图表渲染** - 支持 Mermaid 图表和 [GFM 警告块](https://github.com/orgs/community/discussions/16925)\n- ✅ **PlantUML 支持** - 强大的 UML 图表渲染\n- ✅ **Ruby 注音扩展** - 支持 `[文字]{注音}`、`[文字]^(注音)` 格式，支持多种分隔符\n\n### 🎯 编辑体验\n\n- ✅ **代码高亮** - 丰富的代码块高亮主题，提升代码可读性\n- ✅ **自定义样式** - 允许自定义主题色和 CSS 样式，灵活定制展示效果\n- ✅ **草稿保存** - 内置本地内容管理功能，支持草稿自动保存\n\n### 🚀 高级功能\n\n- ✅ **多图床支持** - 提供多种图床选择，便捷的图片上传功能\n- ✅ **文件管理** - 便捷的文件导入、导出功能，提升工作效率\n- ✅ **AI 集成** - 集成主流 AI 模型（DeepSeek、OpenAI、通义千问、腾讯混元、火山方舟、302.AI 等），智能辅助内容创作\n\n## 🖼️ 支持的图床服务\n\n| #   | 图床                                                   | 使用时是否需要配置                                                         | 备注                                                                                                                   |\n| --- | ------------------------------------------------------ | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |\n| 1   | 默认                                                   | 否                                                                         | -                                                                                                                      |\n| 2   | [GitHub](https://github.com)                           | 配置 `Repo`、`Token` 参数                                                  | [如何获取 GitHub token？](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token) |\n| 3   | [阿里云](https://www.aliyun.com/product/oss)           | 配置 `AccessKey ID`、`AccessKey Secret`、`Bucket`、`Region` 参数           | [如何使用阿里云 OSS？](https://help.aliyun.com/document_detail/31883.html)                                             |\n| 4   | [腾讯云](https://cloud.tencent.com/act/pro/cos)        | 配置 `SecretId`、`SecretKey`、`Bucket`、`Region` 参数                      | [如何使用腾讯云 COS？](https://cloud.tencent.com/document/product/436/38484)                                           |\n| 5   | [七牛云](https://www.qiniu.com/products/kodo)          | 配置 `AccessKey`、`SecretKey`、`Bucket`、`Domain`、`Region` 参数           | [如何使用七牛云 Kodo？](https://developer.qiniu.com/kodo)                                                              |\n| 6   | [MinIO](https://min.io/)                               | 配置 `Endpoint`、`Port`、`UseSSL`、`Bucket`、`AccessKey`、`SecretKey` 参数 | [如何使用 MinIO？](http://docs.minio.org.cn/docs/master/)                                                              |\n| 7   | [S3 协议](https://aws.amazon.com/s3/)                  | 配置 `Endpoint`、`Region`、`Bucket`、`AccessKey`、`SecretKey` 参数         | 支持 AWS S3、Oracle、DigitalOcean 等兼容 S3 的存储服务                                                                 |\n| 8   | [公众号](https://mp.weixin.qq.com/)                    | 配置 `appID`、`appsecret`、`代理域名` 参数                                 | [如何使用公众号图床？](https://md-pages.doocs.org/tutorial)                                                            |\n| 9   | [Cloudflare R2](https://developers.cloudflare.com/r2/) | 配置 `AccountId`、`AccessKey`、`SecretKey`、`Bucket`、`Domain` 参数        | [如何使用 S3 API 操作 R2？](https://developers.cloudflare.com/r2/api/s3/api/)                                          |\n| 10  | [又拍云](https://www.upyun.com/)                       | 配置 `Bucket`、`Operator`、`Password`、`Domain` 参数                       | [如何使用 又拍云？](https://help.upyun.com/)                                                                           |\n| 11  | [Telegram](https://core.telegram.org/api)              | 配置 `Bot Token`、`Chat ID` 参数                                           | [如何使用 Telegram 图床？](https://github.com/doocs/md/blob/main/docs/telegram-usage.md)                               |\n| 12  | [Cloudinary](https://cloudinary.com/)                  | 配置 `Cloud Name`、`API Key`、`API Secret` 参数                            | [如何使用 Cloudinary？](https://cloudinary.com/documentation/upload_images)                                            |\n| 13  | 自定义上传                                             | 是                                                                         | [如何自定义上传？](/docs/custom-upload.md)                                                                             |\n\n## 🎬 产品演示\n\n<div align=\"center\">\n\n|                                      🎨 主题切换                                      |                                      🖼️ 图片上传                                      |\n| :-----------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------: |\n| ![demo1](https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/doocs/md/images/demo1.gif) | ![demo2](https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/doocs/md/images/demo2.gif) |\n\n|                                      📝 样式扩展                                      |                                      🤖 一键排版                                      |\n| :-----------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------: |\n| ![demo3](https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/doocs/md/images/demo3.gif) | ![demo4](https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/doocs/md/images/demo4.gif) |\n\n</div>\n\n## 🛠️ 开发与部署\n\n```sh\n# 安装 node 版本\nnvm i && nvm use\n\n# 安装依赖\npnpm i\n\n# 启动开发模式\npnpm web dev\n# 访问 http://localhost:5173/md/\n\n# 部署在 /md 目录\npnpm web build\n\n# 部署在根目录\npnpm web build:h5-netlify\n\n# Chrome 插件启动及调试\npnpm web ext:dev\n# 访问 chrome://extensions/ 打开开发者模式，加载已解压的扩展程序，选择 apps/web/.output/chrome-mv3-dev 目录\n\n# Chrome 插件打包\npnpm web ext:zip\n\n# Firefox 扩展打包(how to build Firefox addon)\npnpm web firefox:zip # output zip file at in apps/web/.output/md-{version}-firefox.zip\n\n# uTools 插件打包\npnpm utools:package # output zip file at apps/utools/release/md-utools-v{version}.zip\n\n# cloudflare workers\npnpm web wrangler:dev # cloudflare workers dev 模式\npnpm web wrangler:deploy # cloudflare workers 部署命令\n```\n\n## 🚀 快速搭建私有服务\n\n### 📦 方式 1. 使用 npm cli\n\n通过我们的 npm cli 你可以轻易搭建属于自己的微信 Markdown 编辑器。\n\n```sh\n# 安装\nnpm i -g @doocs/md-cli\n\n# 启动\nmd-cli\n\n# 访问\nopen http://127.0.0.1:8800\n\n# 启动并指定端口\nmd-cli port=8899\n\n# 访问\nopen http://127.0.0.1:8899\n```\n\nmd-cli 支持以下命令行参数：\n\n- `port` 指定端口号，默认 8800，如果被占用会随机使用一个新端口。\n- `spaceId` dcloud 服务空间配置\n- `clientSecret` dcloud 服务空间配置\n\n### 🐳 方式 2. 使用 Docker 镜像\n\n如果你是 Docker 用户，也可以直接使用一条命令，启动**完全属于你的、私有化运行的实例**。\n\n```sh\ndocker run -d -p 8080:80 doocs/md:latest\n```\n\n容器运行起来之后，打开浏览器，访问 http://localhost:8080 即可。\n\n关于本项目 Docker 镜像的更多详细信息，可以关注 https://github.com/doocs/docker-md\n\n## 👥 谁在使用\n\n请查看 [📋 USERS.md](USERS.md) 文件，了解使用本项目的公众号。\n\n## 🤝 贡献指南\n\n我们欢迎任何形式的贡献！请查看 [📖 CONTRIBUTING.md](./CONTRIBUTING.md) 获取提交 PR、Issue 的流程与规范。\n\n## ☕ 支持我们\n\n如果本项目对你有所帮助，可以通过以下方式支持我们的持续开发。\n\n<table style=\"margin: 0 auto\">\n  <tbody>\n    <tr>\n      <td align=\"center\" style=\"width: 260px\">\n        <img\n          src=\"https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/doocs/md/images/support1.jpg\"\n          style=\"width: 200px\"\n        /><br />\n      </td>\n      <td align=\"center\" style=\"width: 260px\">\n        <img\n          src=\"https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/doocs/md/images/support2.jpg\"\n          style=\"width: 200px\"\n        /><br />\n      </td>\n    </tr>\n  </tbody>\n</table>\n\n## 💬 反馈与交流\n\n如果你在使用过程中遇到问题，或者有好的建议，欢迎在 [🐛 Issues](https://github.com/doocs/md/issues) 中反馈。你也可以加入我们的交流群，和我们一起讨论，若群二维码失效，请添加好友，备注 `md`，我们会拉你进群。\n\n<table style=\"margin: 0 auto\">\n  <tbody>\n    <tr>\n      <td align=\"center\" style=\"width: 260px\">\n        <img\n          src=\"https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/doocs/md/images/doocs-md-wechat-group.jpg\"\n          style=\"width: 200px\"\n        /><br />\n      </td>\n      <td align=\"center\" style=\"width: 260px\">\n        <img\n          src=\"https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/doocs/md/images/wechat-ylb.jpg\"\n          style=\"width: 200px\"\n        /><br />\n      </td>\n    </tr>\n  </tbody>\n</table>\n"
  },
  {
    "path": "USERS.md",
    "content": "## 谁在使用\n\n- [Doocs](https://mp.weixin.qq.com/s/RNKDCK2KoyeuMeEs6GUrow)\n- [ApachePulsar](https://mp.weixin.qq.com/s/udU2ZICg60HbspgWTQdYpg)\n- [码云 Gitee](https://mp.weixin.qq.com/s/bnlWqzCarDlR4F27HHXNUg)\n- [掘墓人的小铲子](https://mp.weixin.qq.com/s/FpGIX9viQR6Z9iSCEPH86g)\n- [全网重点](https://mp.weixin.qq.com/s/yB3ZH3jmcF_LbzuKmnR9BQ)\n- [爱码士的内心独白](https://mp.weixin.qq.com/s/oc5Z2t9ykbu_Dezjnw5mfQ)\n- [乐玩 nodejs npm 工具库](https://mp.weixin.qq.com/s/SFde8OsZ8FzNGMHwpmDtrg)\n- [简静慢](https://mp.weixin.qq.com/s/7UG24ZugfI5ZnhUpo8vfvQ)\n- [编程图解](https://mp.weixin.qq.com/s/7bfpKACg7HP-PhBrkpM9IQ)\n- [好酸一柠檬](https://mp.weixin.qq.com/s/CVqmcu_OGG8TQO4FViAQ3w)\n- [不知所云 Hub](https://mp.weixin.qq.com/s/leDCdpvnfk8eZRPRRHwg5w)\n- [柯宁申的叙事屋](https://mp.weixin.qq.com/s/AHHrxu7aIYBpvn3PpVHE_Q)\n- [我的 Beta 世界](https://mp.weixin.qq.com/s/6BO977YG5e_4qYxL4oVQJw)\n- [生化环材](https://mp.weixin.qq.com/s/fqNxIRxTkn6QEPmi4atW9w)\n- [秀宇笔记](https://mp.weixin.qq.com/s/VUlOBFA93eiqZ5ZYGmXzmQ)\n- [IT 王小二](https://mp.weixin.qq.com/s/UU3cH8LvpO_3aeAkkYvZZQ)\n- [小二来碗饭](https://mp.weixin.qq.com/s/49wUuhOEYG-OZPbFc6_NrQ)\n- [青年技术宅](https://mp.weixin.qq.com/s/YDUZ0t_spzeqXiE_Idv3OA)\n- [路引科研](https://mp.weixin.qq.com/s/oinGHCmer1vNE6Hg2OsH1g)\n- [凯文有事找你](https://mp.weixin.qq.com/s/ap_JhwgmfxgqFAIcTF3nKQ)\n- [软件部落库](https://mp.weixin.qq.com/s/itkJtMY-1IkZjIn5fWtShw)\n- [网文小密圈](https://mp.weixin.qq.com/s/_44Ya309DeQzemXLnJUNdQ)\n- [潇洒哥和黑大帅](https://mp.weixin.qq.com/s/k9WbW0zmxl0S2WX2CXQ6cQ)\n- [云原生指北](https://mp.weixin.qq.com/s/qFQBBpjUoqdfnmCeOGqRJQ)\n- [全栈民工](https://mp.weixin.qq.com/s/i7hTPuuJAtcK9G55tep0Uw)\n- [睡不醒的鲤鱼](https://mp.weixin.qq.com/s/14HNDbDIvfDnV7ePEfbyuQ)\n- [Dmego](https://mp.weixin.qq.com/s/4QeZsTL84lbN_HO3kCwEwg)\n- [红岸](https://mp.weixin.qq.com/s/_cNyKqRr8E1ENg9r7IO70Q)\n- [HelloCoder](https://mp.weixin.qq.com/s/ekCoyhT-JjbYsysKBgdJzQ)\n- [前端黑板报](https://mp.weixin.qq.com/s/bnZebWPd5-TgiXgQVUKdaQ)\n- [Web3HackerWorld](https://mp.weixin.qq.com/s/eLuC6e93RR1zCD3w2FgpVA)\n- [StruggleYang](https://mp.weixin.qq.com/s/fKKQrsatC9en3PwWiCL-KQ)\n- [比心技术](https://mp.weixin.qq.com/s/DYzzci2paf10CgW22pkyUQ)\n- [Pyvan](https://mp.weixin.qq.com/s/YeIev850YlFLFrmzxwUcdg)\n- [CloudberryDB](https://mp.weixin.qq.com/s/8-YRch1U4DiXbpbUHQ1rWQ)\n- [也无言](https://mp.weixin.qq.com/s/pxykYtxQtvG1SAFz9SO5gw)\n- [易学历史](https://mp.weixin.qq.com/s/ICOb210BFzuyP49Zf5kj0A)\n- [小盒子的技术分享](https://mp.weixin.qq.com/s/ilKtA4c3_xQK5ZjwrCZIFw)\n- [Code365](https://mp.weixin.qq.com/s/WXBZTqkK1JvYlMg5GWyPhA)\n- [IT 智行](https://mp.weixin.qq.com/s/4eSGBiUX6aC-f6rG5xBq7g)\n- [哪里不会点哪里](https://mp.weixin.qq.com/s/dDe3pyziFjFMbiFO249U4g)\n- [AI 思维车间订阅号](https://mp.weixin.qq.com/s/f3Z0kWtEa5qjNDl8s_wArA)\n- [肖恩聊技术](https://mp.weixin.qq.com/s/hzZHwjKH5IE6H0yNXVhDPQ)\n- [极客范](https://mp.weixin.qq.com/s/AjOTuwY9Cz5Ir7iOVxLn8Q)\n- [AI 决策者洞察](https://mp.weixin.qq.com/s/8To24gWM5RFEZZ7SIHu46w)\n- [小墨是前端](https://mp.weixin.qq.com/s/G7Nw9uBadRGbvTUtv2OtrA)\n- [豆福 AI 笔记](https://mp.weixin.qq.com/s/b_OqX__jVeqgi8QCT9qMBA)\n- [运维前沿](https://mp.weixin.qq.com/s/X6x2ziLZGjCelJgXECdhPg)\n- [鱼 da 王](https://mp.weixin.qq.com/s/DdxK3j31TUWLNVhZtWTuVA)\n- [程序员小宋](https://mp.weixin.qq.com/s/llgdqSN3AIXMlEbBuPkKNQ)\n- [架构师修行之路](https://mp.weixin.qq.com/s/-HWx7VZC6NthROGBaATcLA)\n- [前端徐徐](https://mp.weixin.qq.com/s/OQriNzz3LrheOWgchKpvrw)\n- [科妙知行](https://mp.weixin.qq.com/s/smcivd8MNAbo0MtXdoVKaw)\n- [西建大 iOS 众创空间俱乐部](https://mp.weixin.qq.com/s/YQooBjWoAg4WFIp5A4k9tw)\n- [AMC 真题库](https://mp.weixin.qq.com/s/LOzNVEXtlRv_3vIDhYjyFg)\n- [不止于 python](https://mp.weixin.qq.com/s/0zd3t7k9CYcwTLevh0KFHw)\n- [Daily 词语仓](https://mp.weixin.qq.com/s/3SPtQuvC3ohmQICtg4tbAw)\n- [没事学点 AI 小知识](https://mp.weixin.qq.com/s/rV3eNxWsJbAs93azg9q74Q)\n- [攻城狮成长日记](https://mp.weixin.qq.com/s/PqtqTCWAlDsInjamND94Jw)\n- [口袋狗](https://mp.weixin.qq.com/s/YZzhUjDIhF5JD_ierQc5Ng)\n- [原来开源](https://mp.weixin.qq.com/s/BYXUaF9xK8aTjTSYSkl89g)\n- [Jackywine](https://mp.weixin.qq.com/s/6ZT_oUQMDVskdHdA6T1gQA)\n- [轱辘凯 glookai](https://mp.weixin.qq.com/s/d-CFbMnX4ABEWB-abd2p_A)\n- [小竹读研在养鱼](https://mp.weixin.qq.com/s/NJ_GpCBjQzZIZTbZz3btTg)\n\n注：如果你使用了本 Markdown 编辑器进行内容排版，并且希望在本项目 README 中展示你的公众号，请到 [#5](https://github.com/doocs/md/discussions/5) 留言。\n"
  },
  {
    "path": "apps/utools/README.md",
    "content": "# uTools 插件打包指引\n\n该目录包含将微信 Markdown 编辑器打包为 [uTools](https://u.tools) 插件所需的脚本与配置。\n\n## 快速开始\n\n```sh\npnpm utools:package\n```\n\n该命令将完成以下动作：\n\n1. **下载本地资源**：下载 MathJax、Mermaid、WeChat Sync 等库文件到 `apps/web/public/static/libs`（仅 uTools 打包需要，已添加到 `.gitignore`）。\n2. 调用 `pnpm --filter @md/web run build:utools` 构建前端资源至 `apps/utools/dist`，该构建将自动使用相对路径，确保在 uTools 的 `file://` 协议下能够正常加载。构建过程中会自动将远程 CDN 资源替换为本地资源路径。\n3. 将仓库根目录的版本号写入 `apps/utools/plugin.json`，保持与主项目同步。\n4. 从 `public/mpmd/icon-256.png` 拷贝插件图标至 `apps/utools/logo.png`。\n5. 生成形如 `apps/utools/release/md-utools-vX.Y.Z.zip` 的安装包，可直接导入到 uTools。\n\n> 注意：命令执行前请确认已安装 pnpm 10+ 与 Node.js 22+，并在仓库根目录执行 `pnpm install` 安装依赖。\n\n## 本地资源说明\n\nuTools 审核要求插件不能加载远程资源。打包时会自动下载以下库文件到本地（这些文件不会提交到 Git 仓库）：\n\n- **MathJax** - 数学公式渲染库\n- **Mermaid** - 流程图渲染库\n- **WeChat Sync** - 文章同步脚本\n\n构建时，Vite 插件会自动将 HTML 中的 CDN 链接替换为本地资源路径，确保插件可以离线运行。\n\n### 手动下载资源\n\n如需单独下载资源文件：\n\n```sh\nnode scripts/download-utools-libs.mjs\n```\n\n## 手动导入调试\n\n1. 运行 `pnpm --filter @md/web run build:utools`。\n2. 打开 uTools，进入插件面板中的「开发者工具」。\n3. 选择「载入本地插件」，指向 `apps/utools` 目录即可。\n\n## 目录说明\n\n- `plugin.json`：uTools 插件的清单文件。\n- `preload.js`：在 uTools 渲染进程和 Web 前端之间建立通信的脚本，用于处理插件唤起事件。\n- `package.json`：将此目录标记为 CommonJS 模块以兼容 uTools。\n- `dist/`：由 Vite 构建输出的静态资源目录。\n- `release/`：运行打包命令后生成的插件安装包。\n"
  },
  {
    "path": "apps/utools/package.json",
    "content": "{\n  \"type\": \"commonjs\",\n  \"private\": true\n}\n"
  },
  {
    "path": "apps/utools/plugin.json",
    "content": "{\n  \"pluginName\": \"微信 Markdown 编辑器\",\n  \"description\": \"Markdown 文档自动排版为微信图文,随时在 uTools 中打开使用\",\n  \"version\": \"2.1.0\",\n  \"author\": \"doocs\",\n  \"homepage\": \"https://github.com/doocs/md\",\n  \"pluginType\": \"tool\",\n  \"platform\": [\n    \"win32\",\n    \"darwin\",\n    \"linux\"\n  ],\n  \"logo\": \"logo.png\",\n  \"main\": \"dist/index.html\",\n  \"preload\": \"preload.js\",\n  \"features\": [\n    {\n      \"code\": \"wechat-md\",\n      \"explain\": \"打开微信 Markdown 编辑器\",\n      \"cmds\": [\n        \"微信排版\",\n        \"Markdown 编辑器\",\n        \"doocs md\"\n      ]\n    }\n  ]\n}\n"
  },
  {
    "path": "apps/utools/preload.js",
    "content": "(() => {\n  if (typeof window === `undefined`)\n    return\n\n  // 标识当前环境为 uTools\n  window.__MD_UTOOLS__ = true\n\n  /**\n   * 安全调用 uTools API 方法\n   * @param {string} method - 方法名\n   * @param {...any} args - 方法参数\n   */\n  const safeCall = (method, ...args) => {\n    try {\n      if (typeof window.utools?.[method] === `function`) {\n        window.utools[method](...args)\n      }\n    }\n    catch (error) {\n      console.warn(`[md][utools] ${method} failed:`, error)\n    }\n  }\n\n  /**\n   * 插件进入回调\n   * @param {object} action - 插件动作参数\n   */\n  const enter = (action) => {\n    // 配置 uTools 窗口行为\n    safeCall(`hideMainWindowWhenBlur`, false)\n    safeCall(`showMainWindow`)\n    safeCall(`setExpendHeight`, 680)\n\n    // 通知前端应用\n    window.postMessage({ type: `utools:enter`, payload: action }, `*`)\n  }\n\n  /**\n   * 插件退出回调\n   */\n  const leave = () => {\n    window.postMessage({ type: `utools:leave` }, `*`)\n  }\n\n  // 注册生命周期回调\n  safeCall(`onPluginEnter`, enter)\n  safeCall(`onPluginOut`, leave)\n\n  // 导出插件配置\n  window.exports = {\n    'wechat-md': {\n      mode: `none`,\n      args: {\n        enter,\n        leave,\n      },\n    },\n  }\n})()\n"
  },
  {
    "path": "apps/vscode/.gitignore",
    "content": "out\ndist\nnode_modules\n.vscode-test/\n*.vsix\n"
  },
  {
    "path": "apps/vscode/.npmrc",
    "content": "registry=https://registry.npmmirror.com"
  },
  {
    "path": "apps/vscode/.vscodeignore",
    "content": ".vscode/**\n.vscode-test/**\nout/**\nnode_modules/**\nsrc/**\n.gitignore\n.yarnrc\nwebpack.config.js\nvsc-extension-quickstart.md\n**/tsconfig.json\n**/eslint.config.mjs\n**/*.map\n**/*.ts\n**/.vscode-test.*\n.npmrc"
  },
  {
    "path": "apps/vscode/CHANGELOG.md",
    "content": "# doocs-md changelog\n\n## [Unreleased] - 2025-06-04\n\n### ✨ Features\n\n- 侧边栏Markdown预览视图功能\n- 支持微信图文特有的样式渲染\n- 可自定义字体和字体大小\n- 支持自定义文本主题颜色和主题样式\n- 显示字数统计状态栏\n- 支持 Mac 风格代码块切换\n"
  },
  {
    "path": "apps/vscode/LICENSE",
    "content": "\n            DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE\n                    Version 2, December 2004\n\n Copyright (C) 2025 Doocs <admin@doocs.org>\n\n Everyone is permitted to copy and distribute verbatim or modified\n copies of this license document, and changing it is allowed as long\n as the name is changed.\n\n            DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE\n   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION\n\n  0. You just DO WHAT THE FUCK YOU WANT TO.\n"
  },
  {
    "path": "apps/vscode/README.md",
    "content": "# doocs-md VS Code Extension\n\n为 doocs-md 提供的 VS Code 扩展，支持在编辑器内实时预览 Markdown 渲染效果。\n\n## 功能特性\n\n- 侧边栏 Markdown 预览视图\n- 支持微信图文特有的样式渲染\n- 可自定义字体\n- 支持自定义字体大小\n- 支持自定义文本主题颜色\n- 支持自定义主题样式\n- 显示字数统计状态\n- 支持 Mac 风格代码块切换\n\n## 使用方法\n\n1. 安装扩展后，打开 Markdown 文件\n2. 点击活动栏中的 doocs-md 的 icon 图标\n3. 在侧边栏查看实时渲染效果\n\n## 命令\n\n- `markdown.preview`: 打开 Markdown 预览\n- `markdown.setFontFamily`: 设置预览字体\n- `markdown.toggleCountStatus`: 切换字数统计显示\n- `markdown.toggleMacCodeBlock`: 切换 Mac 风格代码块\n\n## 与主项目的关系\n\n本扩展是[doocs-md](https://github.com/doocs/md)的配套工具，使用相同的渲染方式，确保预览效果与最终微信图文完全一致。\n\n## 开发\n\n- **Node.js ≥ 22**\n\n```sh\n# 安装依赖\nnpm install\n\n# 开发模式\nnpm run watch\n\n# 打包\nnpm run build\n```\n"
  },
  {
    "path": "apps/vscode/package.json",
    "content": "{\n  \"publisher\": \"doocs\",\n  \"name\": \"doocs-md\",\n  \"displayName\": \"doocs-md\",\n  \"version\": \"0.0.1\",\n  \"description\": \"\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/doocs/md\"\n  },\n  \"categories\": [\n    \"Other\"\n  ],\n  \"main\": \"./dist/extension.js\",\n  \"engines\": {\n    \"vscode\": \"^1.91.0\"\n  },\n  \"activationEvents\": [],\n  \"contributes\": {\n    \"viewsContainers\": {\n      \"activitybar\": [\n        {\n          \"id\": \"markdown-sidebar\",\n          \"title\": \"Markdown Preview\",\n          \"icon\": \"./public/icon-256-gray.png\"\n        }\n      ]\n    },\n    \"views\": {\n      \"markdown-sidebar\": [\n        {\n          \"id\": \"markdown.preview.view\",\n          \"name\": \"Preview\",\n          \"icon\": \"./public/icon-256-gray.png\"\n        }\n      ]\n    },\n    \"commands\": [\n      {\n        \"command\": \"markdown.preview\",\n        \"title\": \"Open Markdown Preview\",\n        \"icon\": {\n          \"light\": \"./public/icon-256-gray.png\",\n          \"dark\": \"./public/icon-256-gray.png\"\n        }\n      },\n      {\n        \"command\": \"markdown.setFontFamily\",\n        \"title\": \"Set Font Family\",\n        \"category\": \"Markdown Preview\"\n      },\n      {\n        \"command\": \"markdown.toggleCountStatus\",\n        \"title\": \"Toggle Count Status\",\n        \"category\": \"Markdown Preview\"\n      },\n      {\n        \"command\": \"markdown.toggleMacCodeBlock\",\n        \"title\": \"Toggle Mac Code Block\",\n        \"category\": \"Markdown Preview\"\n      }\n    ],\n    \"menus\": {\n      \"editor/title\": [\n        {\n          \"command\": \"markdown.preview\",\n          \"group\": \"navigation\",\n          \"when\": \"editorLangId == markdown\"\n        }\n      ]\n    }\n  },\n  \"scripts\": {\n    \"vscode:prepublish\": \"npm run build\",\n    \"compile\": \"webpack\",\n    \"watch\": \"webpack --watch\",\n    \"build\": \"webpack --mode production --devtool hidden-source-map\",\n    \"package\": \"vsce package --no-dependencies --allow-package-secrets github\"\n  },\n  \"dependencies\": {\n    \"@md/core\": \"workspace:*\",\n    \"@md/shared\": \"workspace:*\",\n    \"@types/webpack\": \"^5.28.5\",\n    \"isomorphic-dompurify\": \"^3.5.1\",\n    \"postcss\": \"^8.5.8\",\n    \"ts-loader\": \"^9.5.4\",\n    \"tsconfig-paths-webpack-plugin\": \"^4.2.0\"\n  },\n  \"devDependencies\": {\n    \"@types/vscode\": \"^1.110.0\",\n    \"@vscode/vsce\": \"^3.7.1\",\n    \"webpack-cli\": \"^7.0.2\"\n  }\n}\n"
  },
  {
    "path": "apps/vscode/src/css/index.ts",
    "content": "export const css = `\n:root {\n  --background: 0 0% 100%;\n  --foreground: 0 0% 3.9%;\n\n  --card: 0 0% 100%;\n  --card-foreground: 0 0% 3.9%;\n\n  --popover: 0 0% 100%;\n  --popover-foreground: 0 0% 3.9%;\n\n  --primary: 0 0% 9%;\n  --primary-foreground: 0 0% 98%;\n\n  --secondary: 0 0% 96.1%;\n  --secondary-foreground: 0 0% 9%;\n\n  --muted: 0 0% 96.1%;\n  --muted-foreground: 0 0% 45.1%;\n\n  --accent: 0 0% 96.1%;\n  --accent-foreground: 0 0% 9%;\n\n  --destructive: 0 84.2% 60.2%;\n  --destructive-foreground: 0 0% 98%;\n\n  --border: 0 0% 89.8%;\n  --input: 0 0% 89.8%;\n  --ring: 0 0% 3.9%;\n  --radius: 0.5rem;\n\n  --blockquote-background: #f7f7f7;\n}\n\n.dark {\n  --background: 0 0% 3.9%;\n  --foreground: 0 0% 98%;\n\n  --card: 0 0% 3.9%;\n  --card-foreground: 0 0% 98%;\n\n  --popover: 0 0% 3.9%;\n  --popover-foreground: 0 0% 98%;\n\n  --primary: 0 0% 98%;\n  --primary-foreground: 0 0% 9%;\n\n  --secondary: 0 0% 14.9%;\n  --secondary-foreground: 0 0% 98%;\n\n  --muted: 0 0% 14.9%;\n  --muted-foreground: 0 0% 63.9%;\n\n  --accent: 0 0% 14.9%;\n  --accent-foreground: 0 0% 98%;\n\n  --destructive: 0 62.8% 30.6%;\n  --destructive-foreground: 0 0% 98%;\n\n  --border: 0 0% 14.9%;\n  --input: 0 0% 14.9%;\n  --ring: 0 0% 83.1%;\n\n  --blockquote-background: #212121;\n}\n`\n"
  },
  {
    "path": "apps/vscode/src/extension.ts",
    "content": "import type { ThemeName } from '@md/shared'\nimport { initRenderer } from '@md/core/renderer'\nimport { generateCSSVariables } from '@md/core/theme'\nimport { modifyHtmlContent } from '@md/core/utils'\nimport { baseCSSContent, themeMap } from '@md/shared'\nimport * as vscode from 'vscode'\nimport { css } from './css'\nimport { MarkdownTreeDataProvider } from './treeDataProvider'\n\nlet activePanel: vscode.WebviewPanel | undefined\n\nexport function activate(context: vscode.ExtensionContext) {\n  // Register TreeDataProvider\n  const treeDataProvider = new MarkdownTreeDataProvider(context)\n  vscode.window.registerTreeDataProvider(`markdown.preview.view`, treeDataProvider)\n\n  // Command for registering style settings\n  context.subscriptions.push(\n    vscode.commands.registerCommand(`markdown.setFontSize`, (size: string) => {\n      treeDataProvider.updateFontSize(size)\n    }),\n    vscode.commands.registerCommand(`markdown.setTheme`, (theme: ThemeName) => {\n      treeDataProvider.updateTheme(theme)\n    }),\n    vscode.commands.registerCommand(`markdown.setPrimaryColor`, (color: string) => {\n      treeDataProvider.updatePrimaryColor(color)\n    }),\n    vscode.commands.registerCommand(`markdown.setFontFamily`, (font: string) => {\n      treeDataProvider.updateFontFamily(font)\n    }),\n    vscode.commands.registerCommand(`markdown.toggleCountStatus`, () => {\n      treeDataProvider.updateCountStatus(!treeDataProvider.getCurrentCountStatus())\n    }),\n    vscode.commands.registerCommand(`markdown.toggleMacCodeBlock`, () => {\n      treeDataProvider.updateMacCodeBlock(!treeDataProvider.getCurrentMacCodeBlock())\n    }),\n  )\n\n  const disposable = vscode.commands.registerCommand(`markdown.preview`, () => {\n    const editor = vscode.window.activeTextEditor\n    if (!editor || editor.document.languageId !== `markdown`) {\n      return\n    }\n\n    // 如果已有面板且未关闭，则直接显示\n    if (activePanel) {\n      activePanel.reveal(vscode.ViewColumn.Two)\n      return\n    }\n\n    // Create and display a new webview panel\n    const panel = vscode.window.createWebviewPanel(\n      `markdownPreview`, // 视图类型\n      `Markdown Preview - ${editor.document.fileName}`, // 面板标题\n      vscode.ViewColumn.Two, // 在第二栏显示\n      {\n        enableScripts: true, // 启用JS\n        retainContextWhenHidden: true, // 保持状态\n      },\n    )\n\n    activePanel = panel\n\n    panel.onDidDispose(() => {\n      activePanel = undefined\n    })\n\n    treeDataProvider.onDidChangeTreeData(updateWebview)\n    function updateWebview() {\n      if (!editor)\n        return\n\n      // 使用新主题系统\n      const renderer = initRenderer({\n        countStatus: treeDataProvider.getCurrentCountStatus(),\n        isMacCodeBlock: treeDataProvider.getCurrentMacCodeBlock(),\n        legend: `none`,\n      })\n\n      const markdownContent = editor.document.getText()\n      const html = modifyHtmlContent(markdownContent, renderer)\n\n      // 生成主题 CSS\n      const variables = generateCSSVariables({\n        primaryColor: treeDataProvider.getCurrentPrimaryColor(),\n        fontFamily: treeDataProvider.getCurrentFontFamily(),\n        fontSize: treeDataProvider.getCurrentFontSize(),\n        isUseIndent: false,\n        isUseJustify: false,\n      })\n\n      const themeCSS = themeMap[treeDataProvider.getCurrentTheme() as ThemeName]\n      const completeCss = `${variables}\\n\\n${baseCSSContent}\\n\\n${themeCSS}\\n\\n${css}`\n\n      panel.webview.html = wrapHtmlTag(html, completeCss)\n    }\n\n    // render first webview\n    updateWebview()\n\n    // Monitor the changes of documents\n    const changeSubscription = vscode.workspace.onDidChangeTextDocument((e: vscode.TextDocumentChangeEvent) => {\n      if (e.document === editor.document) {\n        updateWebview()\n      }\n    })\n\n    // Cancel the subscription when the panel is closed\n    panel.onDidDispose(() => {\n      changeSubscription.dispose()\n    })\n  })\n\n  context.subscriptions.push(disposable)\n\n  // When the Markdown file is opened, the preview button is displayed in the status bar.\n  vscode.window.onDidChangeActiveTextEditor((editor: vscode.TextEditor | undefined) => {\n    if (editor && editor.document.languageId === `markdown`) {\n      vscode.commands.executeCommand(`setContext`, `markdownFileActive`, true)\n    }\n    else {\n      vscode.commands.executeCommand(`setContext`, `markdownFileActive`, false)\n    }\n  })\n}\n\nfunction wrapHtmlTag(html: string, css: string) {\n  return `<html><head><meta charset=\"utf-8\" /><style>${css}</style></head><body><div style=\"width: 375px; margin: auto;padding:20px;background:white;position: relative;min-height: 100%;margin: 0 auto;padding: 20px;font-size: 14px;box-sizing: border-box;outline: none;transition: all 300ms ease-in-out;word-wrap: break-word;\">${html}</div></body></html>`\n}\n"
  },
  {
    "path": "apps/vscode/src/styleChoices.ts",
    "content": "import { codeBlockThemeOptions, colorOptions, fontFamilyOptions, fontSizeOptions, legendOptions, themeOptions } from '@md/shared/configs'\n\nexport {\n  codeBlockThemeOptions,\n  colorOptions,\n  fontFamilyOptions,\n  fontSizeOptions,\n  legendOptions,\n  themeOptions,\n}\n"
  },
  {
    "path": "apps/vscode/src/treeDataProvider.ts",
    "content": "import type { ThemeName } from '@md/shared/configs'\nimport * as vscode from 'vscode'\nimport { colorOptions, fontFamilyOptions, fontSizeOptions, themeOptions } from './styleChoices'\n\nexport class MarkdownTreeDataProvider implements vscode.TreeDataProvider<vscode.TreeItem> {\n  private _onDidChangeTreeData: vscode.EventEmitter<vscode.TreeItem | undefined> = new vscode.EventEmitter<vscode.TreeItem | undefined>()\n  readonly onDidChangeTreeData: vscode.Event<vscode.TreeItem | undefined> = this._onDidChangeTreeData.event\n  private currentFontSize: string\n  private currentTheme: ThemeName\n  private currentPrimaryColor: string\n  private currentFontFamily: string\n  private countStatus: boolean\n  private isMacCodeBlock: boolean\n  private context: vscode.ExtensionContext\n\n  constructor(context: vscode.ExtensionContext) {\n    this.context = context\n    this.currentFontSize = this.context.workspaceState.get(`markdownPreview.fontSize`, fontSizeOptions[0].value)\n    this.currentTheme = this.context.workspaceState.get(`markdownPreview.theme`, themeOptions[0].value)\n    this.currentPrimaryColor = this.context.workspaceState.get(`markdownPreview.primaryColor`, colorOptions[0].value)\n    this.currentFontFamily = this.context.workspaceState.get(`markdownPreview.fontFamily`, fontFamilyOptions[0].value)\n    this.countStatus = this.context.workspaceState.get(`markdownPreview.countStatus`, false)\n    this.isMacCodeBlock = this.context.workspaceState.get(`markdownPreview.isMacCodeBlock`, false)\n  }\n\n  getTreeItem(element: vscode.TreeItem): vscode.TreeItem {\n    return element\n  }\n\n  updateCountStatus(status: boolean): void {\n    this.countStatus = status\n    this.context.workspaceState.update(`markdownPreview.countStatus`, status)\n    this._onDidChangeTreeData.fire(undefined)\n  }\n\n  updateMacCodeBlock(status: boolean): void {\n    this.isMacCodeBlock = status\n    this.context.workspaceState.update(`markdownPreview.isMacCodeBlock`, status)\n    this._onDidChangeTreeData.fire(undefined)\n  }\n\n  getCurrentMacCodeBlock(): boolean {\n    return this.isMacCodeBlock\n  }\n\n  getCurrentCountStatus(): boolean {\n    return this.countStatus\n  }\n\n  getChildren(element?: vscode.TreeItem): Thenable<vscode.TreeItem[]> {\n    if (!element) {\n      return Promise.resolve([\n        new vscode.TreeItem(`字号`, vscode.TreeItemCollapsibleState.Expanded),\n        new vscode.TreeItem(`字体`, vscode.TreeItemCollapsibleState.Expanded),\n        new vscode.TreeItem(`主题`, vscode.TreeItemCollapsibleState.Expanded),\n        new vscode.TreeItem(`主题色`, vscode.TreeItemCollapsibleState.Expanded),\n        new vscode.TreeItem(`计数状态`, vscode.TreeItemCollapsibleState.None),\n        new vscode.TreeItem(`Mac代码块`, vscode.TreeItemCollapsibleState.None),\n      ].map((item) => {\n        if (item.label === `计数状态`) {\n          item.command = {\n            command: `markdown.toggleCountStatus`,\n            title: `Toggle Count Status`,\n            arguments: [],\n          }\n          if (this.countStatus) {\n            item.iconPath = new vscode.ThemeIcon(`check`)\n          }\n        }\n        else if (item.label === `Mac代码块`) {\n          item.command = {\n            command: `markdown.toggleMacCodeBlock`,\n            title: `Toggle Mac Code Block`,\n            arguments: [],\n          }\n          if (this.isMacCodeBlock) {\n            item.iconPath = new vscode.ThemeIcon(`check`)\n          }\n        }\n        return item\n      }))\n    }\n    else if (element.label === `字号`) {\n      return Promise.resolve(fontSizeOptions.map((option) => {\n        const size = option.value\n        const label = option.label\n        const desc = option.desc\n        const item = new vscode.TreeItem(`${label}  ${desc}`)\n        item.command = {\n          command: `markdown.setFontSize`,\n          title: `Set Font Size`,\n          arguments: [size],\n        }\n        if (size === this.currentFontSize) {\n          item.iconPath = new vscode.ThemeIcon(`check`)\n        }\n        return item\n      }))\n    }\n    else if (element.label === `字体`) {\n      return Promise.resolve(fontFamilyOptions.map((option) => {\n        const font = option.value\n        const label = option.label\n        const desc = option.desc\n        const item = new vscode.TreeItem(`${label}  ${desc}`)\n        item.command = {\n          command: `markdown.setFontFamily`,\n          title: `Set Font Family`,\n          arguments: [font],\n        }\n        if (font === this.currentFontFamily) {\n          item.iconPath = new vscode.ThemeIcon(`check`)\n        }\n        return item\n      }))\n    }\n    else if (element.label === `主题`) {\n      return Promise.resolve(themeOptions.map((option) => {\n        const theme = option.value\n        const label = option.label\n        const desc = option.desc\n        const item = new vscode.TreeItem(`${label}  ${desc}`)\n        item.command = {\n          command: `markdown.setTheme`,\n          title: `Set Theme`,\n          arguments: [theme],\n        }\n        if (theme === this.currentTheme) {\n          item.iconPath = new vscode.ThemeIcon(`check`)\n        }\n        return item\n      }))\n    }\n    else if (element.label === `主题色`) {\n      return Promise.resolve(colorOptions.map((option) => {\n        const color = option.value\n        const label = option.label\n        const desc = option.desc\n        const item = new vscode.TreeItem(`${label}  ${desc}`)\n        item.command = {\n          command: `markdown.setPrimaryColor`,\n          title: `Set Primary Color`,\n          arguments: [color],\n        }\n        if (color === this.currentPrimaryColor) {\n          item.iconPath = new vscode.ThemeIcon(`check`)\n        }\n        return item\n      }))\n    }\n    return Promise.resolve([])\n  }\n\n  updateFontSize(size: string) {\n    this.currentFontSize = size\n    this.context.workspaceState.update(`markdownPreview.fontSize`, size)\n    this._onDidChangeTreeData.fire(undefined)\n  }\n\n  updateTheme(theme: ThemeName) {\n    this.currentTheme = theme\n    this.context.workspaceState.update(`markdownPreview.theme`, theme)\n    this._onDidChangeTreeData.fire(undefined)\n  }\n\n  updatePrimaryColor(color: string) {\n    this.currentPrimaryColor = color\n    this.context.workspaceState.update(`markdownPreview.primaryColor`, color)\n    this._onDidChangeTreeData.fire(undefined)\n  }\n\n  updateFontFamily(font: string) {\n    this.currentFontFamily = font\n    this.context.workspaceState.update(`markdownPreview.fontFamily`, font)\n    this._onDidChangeTreeData.fire(undefined)\n  }\n\n  getCurrentFontSize() {\n    return this.currentFontSize\n  }\n\n  getCurrentFontSizeNumber() {\n    return Number(this.currentFontSize.replace(`px`, ``))\n  }\n\n  getCurrentTheme(): ThemeName {\n    return this.currentTheme\n  }\n\n  getCurrentPrimaryColor() {\n    return this.currentPrimaryColor\n  }\n\n  getCurrentFontFamily() {\n    return this.currentFontFamily\n  }\n}\n"
  },
  {
    "path": "apps/vscode/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"lib\": [\"ES2022\"],\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"bundler\",\n    \"paths\": {\n      \"@/*\": [\"../*\"]\n    },\n    \"types\": [\"vscode\"],\n    \"strict\": true,\n    \"sourceMap\": true,\n    \"esModuleInterop\": true\n  },\n  \"include\": [\n    \"src/**/*\",\n    \"../../packages/shared/src/global.d.ts\"\n  ],\n  \"exclude\": [\"node_modules\", \"dist\"]\n}\n"
  },
  {
    "path": "apps/vscode/webpack.config.mjs",
    "content": "'use strict'\n\nimport path from 'node:path'\nimport { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin'\n\nconst currentDir = import.meta.dirname\n\n// @ts-check\n/** @typedef {import('webpack').Configuration} WebpackConfig */\n\n/** @type WebpackConfig */\n\nexport default function config() {\n  return {\n    target: `node`,\n    mode: `none`,\n    entry: `./src/extension.ts`,\n    output: {\n      path: path.resolve(currentDir, `dist`),\n      filename: `extension.js`,\n      libraryTarget: `commonjs2`,\n    },\n    externals: {\n      vscode: `commonjs vscode`,\n    },\n    resolve: {\n      extensions: [`.ts`, `.js`],\n      fallback: {\n        'bufferutil': false,\n        'utf-8-validate': false,\n        'canvas': false,\n      },\n      plugins: [new TsconfigPathsPlugin({ configFile: path.resolve(currentDir, `tsconfig.json`) })],\n    },\n    module: {\n      rules: [\n        {\n          test: /\\.ts$/,\n          exclude: /node_modules/,\n          use: [\n            {\n              loader: `ts-loader`,\n            },\n          ],\n        },\n        {\n          test: /\\.(css|txt)$/,\n          type: 'asset/source',\n        },\n      ],\n    },\n    devtool: `nosources-source-map`,\n    infrastructureLogging: {\n      level: `log`,\n    },\n    optimization: {\n      usedExports: true,\n      sideEffects: true,\n    },\n  }\n}\n"
  },
  {
    "path": "apps/web/components.json",
    "content": "{\n  \"$schema\": \"https://shadcn-vue.com/schema.json\",\n  \"style\": \"new-york\",\n  \"typescript\": true,\n  \"tailwind\": {\n    \"config\": \"tailwind.config.cjs\",\n    \"css\": \"src/assets/index.css\",\n    \"baseColor\": \"neutral\",\n    \"cssVariables\": true,\n    \"prefix\": \"\"\n  },\n  \"aliases\": {\n    \"components\": \"@/components\",\n    \"composables\": \"@/composables\",\n    \"utils\": \"@/lib/utils\",\n    \"ui\": \"@/components/ui\",\n    \"lib\": \"@/lib\"\n  },\n  \"iconLibrary\": \"lucide\"\n}\n"
  },
  {
    "path": "apps/web/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge,chrome=1\" />\n    <meta name=\"keywords\" content=\"md,markdown,markdown-editor,wechat,official-account,yanglbme,doocs\" />\n    <meta name=\"description\" content=\"Wechat Markdown Editor | 一款高度简洁的微信 Markdown 编辑器\" />\n    <meta\n      name=\"viewport\"\n      content=\"width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0\"\n    />\n    <title>微信 Markdown 编辑器 | Doocs 开源社区</title>\n    <link rel=\"shortcut icon\" href=\"https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/doocs/md/images/favicon.png\" />\n    <link\n      rel=\"apple-touch-icon-precomposed\"\n      href=\"https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/doocs/md/images/1648303220922-7e14aefa-816e-44c1-8604-ade709ca1c69.png\"\n    />\n    <style>\n      .loading {\n        position: fixed;\n        top: 0;\n        left: 0;\n        z-index: 99999;\n        display: flex;\n        flex-direction: column;\n        justify-content: center;\n        align-items: center;\n        width: 100vw;\n        height: 100vh;\n        font-size: 18px;\n      }\n\n      .loading::before {\n        content: url('/src/assets/images/favicon.png');\n        width: 100px;\n        height: 100px;\n        margin-bottom: 26px;\n      }\n\n      .loading.dark {\n        color: #ffffff;\n        background-color: #141414;\n      }\n\n      .loading .txt {\n        position: absolute;\n        bottom: 10%;\n      }\n\n      .loading .txt::after {\n        content: '...';\n        animation: dots 1.5s steps(4, end) infinite;\n      }\n\n      @keyframes dots {\n        0% {\n          content: ' ';\n        }\n        25% {\n          content: '.';\n        }\n        50% {\n          content: '..';\n        }\n        75% {\n          content: '...';\n        }\n      }\n    </style>\n  </head>\n\n  <body>\n    <noscript>\n      <strong>Please enable JavaScript to continue.</strong>\n    </noscript>\n    <div id=\"app\">\n      <div class=\"loading\">\n        <strong>致力于让 Markdown 编辑更简单</strong>\n        <p class=\"txt\">正在加载编辑器</p>\n        <p class=\"timeout-tip\" style=\"display: none; color: #e53935; font-size: 16px; margin-top: 2em\">\n          加载时间过长，请尝试刷新页面或按 F12 查看控制台是否有异常信息\n        </p>\n      </div>\n    </div>\n    <script>\n      const theme = localStorage.getItem('vueuse-color-scheme')\n      if (theme === 'dark' || (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {\n        document.querySelector('.loading').classList.add('dark')\n      }\n\n      setTimeout(() => {\n        const tip = document.querySelector('.loading .timeout-tip')\n        if (tip) {\n          tip.style.display = 'block'\n        }\n      }, 30000)\n    </script>\n    <script>\n      window.MathJax = {\n        tex: { tags: 'ams' },\n        svg: { fontCache: 'none' },\n      }\n    </script>\n    <script\n      id=\"MathJax-script\"\n      src=\"https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/npm/mathjax@3/es5/tex-svg.js\"\n    ></script>\n    <script src=\"https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/npm/mermaid@11/dist/mermaid.min.js\"></script>\n    <script type=\"module\" src=\"/src/main.ts\"></script>\n    <script type=\"module\" src=\"/src/sidepanel.ts\"></script>\n  </body>\n  <script src=\"https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/wechatsync/article-syncjs@latest/dist/main.js\"></script>\n</html>\n"
  },
  {
    "path": "apps/web/netlify.toml",
    "content": "[build]\ncommand = \"pnpm run build:h5-netlify\"\npublish = \"dist\"\n\n# 设置重定向规则，确保SPA路由正常工作\n[[redirects]]\nfrom = \"/*\"\nto = \"/index.html\"\nstatus = 200\n"
  },
  {
    "path": "apps/web/package.json",
    "content": "{\n  \"name\": \"@md/web\",\n  \"type\": \"module\",\n  \"private\": true,\n  \"engines\": {\n    \"node\": \">=22.16.0\"\n  },\n  \"scripts\": {\n    \"start\": \"pnpm run dev\",\n    \"dev\": \"vite\",\n    \"build\": \"run-p type-check \\\"build:only {@}\\\" --\",\n    \"build:only\": \"vite build\",\n    \"build:h5-netlify\": \"run-p type-check \\\"build:h5-netlify:only {@}\\\" --\",\n    \"build:h5-netlify:only\": \"cross-env SERVER_ENV=NETLIFY vite build\",\n    \"build:utools\": \"cross-env SERVER_ENV=UTOOLS vite build --outDir ../utools/dist --emptyOutDir\",\n    \"build:cli\": \"pnpm run build && npx shx rm -rf packages/md-cli/dist && npx shx rm -rf dist/**/*.map && npx shx cp -r dist packages/md-cli/ && cd packages/md-cli && npm pack\",\n    \"build:analyze\": \"cross-env ANALYZE=true vite build\",\n    \"compile:extension\": \"pnpm --prefix ./src/extension run compile\",\n    \"preview\": \"pnpm run build && vite preview\",\n    \"wrangler:dev\": \"cross-env CF_WORKERS=1 vite --host\",\n    \"wrangler:deploy\": \"cross-env CF_WORKERS=1 pnpm run build:h5-netlify && wrangler deploy\",\n    \"release:cli\": \"node ./scripts/release.js\",\n    \"ext:dev\": \"wxt\",\n    \"ext:zip\": \"cross-env NODE_OPTIONS=--max-old-space-size=4096 wxt zip\",\n    \"firefox:dev\": \"wxt -b firefox\",\n    \"firefox:zip\": \"cross-env NODE_OPTIONS=--max-old-space-size=4096 wxt zip -b firefox\",\n    \"type-check\": \"vue-tsc --build --force\",\n    \"postinstall\": \"wxt prepare\",\n    \"package:extension\": \"pnpm --prefix ./src/extension run package\"\n  },\n  \"dependencies\": {\n    \"@aws-sdk/client-s3\": \"^3.1013.0\",\n    \"@aws-sdk/s3-request-presigner\": \"^3.1013.0\",\n    \"@exercism/highlightjs-gdscript\": \"^0.0.1\",\n    \"@md/core\": \"workspace:*\",\n    \"@md/shared\": \"workspace:*\",\n    \"@ssttevee/multipart-parser\": \"^0.1.9\",\n    \"@vee-validate/yup\": \"^4.15.1\",\n    \"@vueuse/core\": \"^14.2.1\",\n    \"browser-image-compression\": \"^2.0.2\",\n    \"buffer-from\": \"^1.1.2\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"codemirror\": \"^6.0.2\",\n    \"crypto-js\": \"^4.2.0\",\n    \"es-toolkit\": \"^1.45.1\",\n    \"html-to-image\": \"^1.11.13\",\n    \"juice\": \"11.0.3\",\n    \"lucide-vue-next\": \"^0.577.0\",\n    \"marked\": \"^17.0.4\",\n    \"pinia\": \"^3.0.4\",\n    \"qiniu-js\": \"^3.4.3\",\n    \"radix-vue\": \"^1.9.17\",\n    \"reka-ui\": \"^2.9.2\",\n    \"spark-md5\": \"3.0.2\",\n    \"tailwind-merge\": \"^3.5.0\",\n    \"tailwindcss-animate\": \"^1.0.7\",\n    \"uuid\": \"^13.0.0\",\n    \"vee-validate\": \"^4.15.1\",\n    \"vue\": \"^3.5.30\",\n    \"vue-pick-colors\": \"^1.8.0\",\n    \"vue-sonner\": \"^2.0.9\",\n    \"yup\": \"^1.7.1\"\n  },\n  \"devDependencies\": {\n    \"@cloudflare/vite-plugin\": \"1.29.1\",\n    \"@cloudflare/workers-types\": \"^4.20260317.1\",\n    \"@md/config\": \"workspace:*\",\n    \"@tailwindcss/postcss\": \"^4.2.2\",\n    \"@tailwindcss/vite\": \"^4.2.2\",\n    \"@types/buffer-from\": \"^1.1.3\",\n    \"@types/crypto-js\": \"^4.2.2\",\n    \"@types/spark-md5\": \"3.0.5\",\n    \"@vitejs/plugin-vue\": \"^6.0.5\",\n    \"less\": \"^4.6.4\",\n    \"linkedom\": \"^0.18.12\",\n    \"ohash\": \"^2.0.11\",\n    \"postcss\": \"^8.5.8\",\n    \"rollup\": \"^4.59.0\",\n    \"rollup-plugin-visualizer\": \"^7.0.1\",\n    \"shx\": \"^0.4.0\",\n    \"tailwindcss\": \"^4.2.2\",\n    \"unplugin-auto-import\": \"^21.0.0\",\n    \"unplugin-vue-components\": \"^31.0.0\",\n    \"vite\": \"^8.0.1\",\n    \"vite-plugin-radar\": \"^0.10.1\",\n    \"vite-plugin-vue-devtools\": \"^8.1.0\",\n    \"vue-tsc\": \"^3.2.6\",\n    \"wrangler\": \"^4.75.0\",\n    \"wxt\": \"^0.20.20\"\n  }\n}\n"
  },
  {
    "path": "apps/web/plugins/vite-plugin-utools-local-assets.ts",
    "content": "import type { Plugin } from 'vite'\nimport process from 'node:process'\n\n/**\n * Vite 插件：在 uTools 构建时将远程资源替换为本地资源\n */\nexport function utoolsLocalAssetsPlugin(): Plugin {\n  const isUTools = process.env.SERVER_ENV === `UTOOLS`\n\n  return {\n    name: `vite-plugin-utools-local-assets`,\n    apply: `build`,\n    transformIndexHtml: {\n      order: `post`,\n      handler(html) {\n        if (!isUTools)\n          return html\n\n        // 替换 favicon\n        html = html.replace(\n          /https:\\/\\/cdn-doocs\\.oss-cn-shenzhen\\.aliyuncs\\.com\\/gh\\/doocs\\/md\\/images\\/favicon\\.png/g,\n          `./src/assets/images/favicon.png`,\n        )\n\n        // 替换 apple-touch-icon\n        html = html.replace(\n          /https:\\/\\/cdn-doocs\\.oss-cn-shenzhen\\.aliyuncs\\.com\\/gh\\/doocs\\/md\\/images\\/1648303220922-7e14aefa-816e-44c1-8604-ade709ca1c69\\.png/g,\n          `./src/assets/images/favicon.png`,\n        )\n\n        // 替换 MathJax\n        html = html.replace(\n          /https:\\/\\/cdn-doocs\\.oss-cn-shenzhen\\.aliyuncs\\.com\\/npm\\/mathjax@3\\/es5\\/tex-svg\\.js/g,\n          `./static/libs/mathjax/tex-svg.js`,\n        )\n\n        // 替换 Mermaid\n        html = html.replace(\n          /https:\\/\\/cdn-doocs\\.oss-cn-shenzhen\\.aliyuncs\\.com\\/npm\\/mermaid@11\\/dist\\/mermaid\\.min\\.js/g,\n          `./static/libs/mermaid/mermaid.min.js`,\n        )\n\n        // 替换 WeChat Sync\n        html = html.replace(\n          /https:\\/\\/cdn-doocs\\.oss-cn-shenzhen\\.aliyuncs\\.com\\/gh\\/wechatsync\\/article-syncjs@latest\\/dist\\/main\\.js/g,\n          `./static/libs/article-syncjs/main.js`,\n        )\n\n        return html\n      },\n    },\n  }\n}\n"
  },
  {
    "path": "apps/web/postcss.config.js",
    "content": "export default {\n  plugins: {\n    '@tailwindcss/postcss': {},\n  },\n}\n"
  },
  {
    "path": "apps/web/public/upload/.gitkeep",
    "content": ""
  },
  {
    "path": "apps/web/src/App.vue",
    "content": "<script setup lang=\"ts\">\nimport { onMounted, ref } from 'vue'\nimport { Toaster } from '@/components/ui/sonner'\nimport { useUIStore } from '@/stores/ui'\nimport CodemirrorEditor from '@/views/CodemirrorEditor.vue'\n\nconst uiStore = useUIStore()\nconst { isDark } = storeToRefs(uiStore)\n\nconst isUtools = ref(false)\n\nonMounted(() => {\n  // 检测是否为 Utools 环境\n  isUtools.value = !!(window as any).__MD_UTOOLS__\n  if (isUtools.value) {\n    document.documentElement.classList.add(`is-utools`)\n  }\n\n  // 若 URL 带有 open 参数（Markdown 链接），打开导入对话框并自动导入\n  const params = new URLSearchParams(window.location.search)\n  const openUrl = params.get(`open`)\n  if (openUrl && URL.canParse(openUrl) && /^https?:\\/\\//i.test(openUrl)) {\n    uiStore.importMdOpenUrl = openUrl\n    uiStore.isShowImportMdDialog = true\n    params.delete(`open`)\n    const newSearch = params.toString()\n    const newUrl = window.location.pathname + (newSearch ? `?${newSearch}` : ``) + window.location.hash\n    window.history.replaceState({}, ``, newUrl)\n  }\n})\n</script>\n\n<template>\n  <AppSplash />\n  <CodemirrorEditor />\n\n  <Toaster\n    rich-colors\n    position=\"top-center\"\n    :theme=\"isDark ? 'dark' : 'light'\"\n  />\n</template>\n\n<style lang=\"less\">\nhtml,\nbody,\n#app {\n  width: 100vw;\n  height: 100vh;\n  margin: 0;\n  padding: 0;\n}\n\n// 抵消下拉菜单开启时带来的样式\nbody {\n  pointer-events: initial !important;\n}\n\n::-webkit-scrollbar {\n  width: 6px;\n  height: 6px;\n  background-color: rgba(243, 244, 247, 0.5);\n}\n\n::-webkit-scrollbar-track {\n  border-radius: 6px;\n  background-color: rgba(200, 200, 200, 0.3);\n}\n\n::-webkit-scrollbar-thumb {\n  border-radius: 6px;\n  background-color: rgba(144, 146, 152, 0.5);\n}\n\n// Utools 模式下隐藏所有滚动条\n.is-utools {\n  ::-webkit-scrollbar {\n    display: none;\n  }\n\n  // Firefox\n  * {\n    scrollbar-width: none;\n  }\n\n  // IE and Edge\n  * {\n    -ms-overflow-style: none;\n  }\n}\n\n/* CSS-hints */\n.CodeMirror-hints {\n  position: absolute;\n  z-index: 10;\n  overflow-y: auto;\n  margin: 0;\n  padding: 2px;\n  border-radius: 4px;\n  max-height: 20em;\n  min-width: 200px;\n  font-size: 12px;\n  font-family: monospace;\n\n  color: #333333;\n  background-color: #ffffff;\n  box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.12), 0 2px 4px 0 rgba(0, 0, 0, 0.08);\n}\n\n.CodeMirror-hint {\n  margin-top: 10px;\n  padding: 4px 6px;\n  border-radius: 2px;\n  white-space: pre;\n  color: #000000;\n  cursor: pointer;\n\n  &:first-of-type {\n    margin-top: 0;\n  }\n  &:hover {\n    background: #f0f0f0;\n  }\n}\n.search-match {\n  background-color: #ffeb3b; /* 所有匹配项颜色 */\n}\n.current-match {\n  background-color: #ff5722; /* 当前匹配项更鲜艳的颜色 */\n}\n</style>\n"
  },
  {
    "path": "apps/web/src/assets/example/markdown.md",
    "content": "# 探索 Markdown 的奇妙世界\n\n欢迎来到 Markdown 的奇妙世界！无论你是写作爱好者、开发者、博主，还是想要简单记录点什么的人，Markdown 都能成为你新的好伙伴。它不仅让写作变得简单明了，还能轻松地将内容转化为漂亮的网页格式。今天，我们将全面探讨 Markdown 的基础和进阶语法，让你在这个过程中充分享受写作的乐趣！\n\nMarkdown 是一种轻量级标记语言，用于格式化纯文本。它以简单、直观的语法而著称，可以快速地生成 HTML。Markdown 是写作与代码的完美结合，既简单又强大。\n\n## Markdown 基础语法\n\n### 1. 标题：让你的内容层次分明\n\n用 `#` 号来创建标题。标题从 `#` 开始，`#` 的数量表示标题的级别。\n\n```markdown\n# 一级标题\n\n## 二级标题\n\n### 三级标题\n\n#### 四级标题\n```\n\n以上代码将渲染出一组层次分明的标题，使你的内容井井有条。\n\n### 2. 段落与换行：自然流畅\n\nMarkdown 中的段落就是一行接一行的文本。要创建新段落，只需在两行文本之间空一行。\n\n### 3. 字体样式：强调你的文字\n\n- **粗体**：用两个星号或下划线包裹文字，如 `**粗体**` 或 `__粗体__`。\n- _斜体_：用一个星号或下划线包裹文字，如 `*斜体*` 或 `_斜体_`。\n- ~~删除线~~：用两个波浪线包裹文字，如 `~~删除线~~`。\n- ==高亮==：用两个等号包裹文字，如 `==高亮==`。\n- ++下划线++：用两个加号包裹文字，如 `++下划线++`。\n- ~波浪线~：用一个波浪线包裹文字，如 `~波浪线~`。\n\n这些简单的标记可以让你的内容更有层次感和重点突出。\n\n### 4. 列表：整洁有序\n\n- **无序列表**：用 `-`、`*` 或 `+` 加空格开始一行。\n- **有序列表**：使用数字加点号（`1.`、`2.`）开始一行。\n\n在列表中嵌套其他内容？只需缩进即可实现嵌套效果。\n\n- 无序列表项 1\n  1. 嵌套有序列表项 1\n  2. 嵌套有序列表项 2\n- 无序列表项 2\n\n1. 有序列表项 1\n2. 有序列表项 2\n\n### 5. 链接与图片：丰富内容\n\n- **链接**：用方括号和圆括号创建链接 `[显示文本](链接地址)`。\n- **图片**：和链接类似，只需在前面加上 `!`，如 `![描述文本](图片链接)`。\n\n[访问 Doocs](https://github.com/doocs)\n\n![doocs](https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/doocs/md/images/logo-2.png)\n\n轻松实现富媒体内容展示！\n\n> 因微信公众号平台不支持除公众号内容以外的链接，故其他平台的链接，会呈现链接样式但无法点击跳转。\n\n> 对于这些链接请注意明文书写，或点击左上角「格式->微信外链接转底部引用」开启引用，这样就可以在底部观察到链接指向。\n\n另外，使用 `<![alt](url),![alt](url)>` 语法可以创建横屏滑动幻灯片，支持微信公众号平台。建议使用相似尺寸的图片以获得最佳显示效果。\n\n### 6. 引用：引用名言或引人深思的句子\n\n使用 `>` 来创建引用，只需在文本前面加上它。多层引用？在前一层 `>` 后再加一个就行。\n\n> 这是一个引用\n>\n> > 这是一个嵌套引用\n\n这让你的引用更加富有层次感。\n\n### 7. 代码块：展示你的代码\n\n- **行内代码**：用反引号包裹，如 `code`。\n- **代码块**：用三个反引号包裹，并指定语言，如：\n\n```js\nconsole.log(`Hello, Doocs!`)\n```\n\n语法高亮让你的代码更易读。\n\n### 8. 分割线：分割内容\n\n用三个或更多的 `-`、`*` 或 `_` 来创建分割线。\n\n---\n\n为你的内容添加视觉分隔。\n\n### 9. 表格：清晰展示数据\n\nMarkdown 支持简单的表格，用 `|` 和 `-` 分隔单元格和表头。\n\n| 项目人员                                    | 邮箱                   | 微信号       |\n| ------------------------------------------- | ---------------------- | ------------ |\n| [yanglbme](https://github.com/yanglbme)     | contact@yanglibin.info | YLB0109      |\n| [YangFong](https://github.com/YangFong)     | yangfong2022@gmail.com | yq2419731931 |\n| [thinkasany](https://github.com/thinkasany) | thinkasany@gmail.com   | thinkasany   |\n\n这样的表格让数据展示更为清爽！\n\n> 手动编写标记太麻烦？我们提供了便捷方式。左上方点击「编辑->插入表格」，即可快速实现表格渲染。\n\n## Markdown 进阶技巧\n\n### 1. LaTeX 公式：完美展示数学表达式\n\nMarkdown 允许嵌入 LaTeX 语法展示数学公式：\n\n- **行内公式**：用 `$` 包裹公式，如 $E = mc^2$。\n- **块级公式**：用 `$$` 包裹公式，如：\n\n$$\n\\begin{aligned}\nd_{i, j} &\\leftarrow d_{i, j} + 1 \\\\\nd_{i, y + 1} &\\leftarrow d_{i, y + 1} - 1 \\\\\nd_{x + 1, j} &\\leftarrow d_{x + 1, j} - 1 \\\\\nd_{x + 1, y + 1} &\\leftarrow d_{x + 1, y + 1} + 1\n\\end{aligned}\n$$\n\n现在还支持 **LaTeX 标准格式**：\n\n- **行内公式**：用 `\\(...\\)` 包裹公式，如 \\(x^2 + y^2 = z^2\\)。\n- **块级公式**：用 `\\[...\\]` 包裹公式，如：\n\n\\[\n\\int\\_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}\n\\]\n\n混合使用示例：传统格式 $a + b = c$ 和 LaTeX 格式 \\(d + e = f\\) 可以在同一段落中共存。\n\n1. 列表内块公式 1\n\n$$\n\\chi^2 = \\sum \\frac{(O - E)^2}{E}\n$$\n\n2. 列表内块公式 2\n\n$$\n\\chi^2 = \\sum \\frac{(|O - E| - 0.5)^2}{E}\n$$\n\n这是展示复杂数学表达的利器！\n\n### 2. Mermaid 流程图：可视化流程\n\nMermaid 是强大的可视化工具，可以在 Markdown 中创建流程图、时序图等。\n\n```mermaid\ngraph LR\n  A[GraphCommand] --> B[update]\n  A --> C[goto]\n  A --> D[send]\n\n  B --> B1[更新状态]\n  C --> C1[流程控制]\n  D --> D1[消息传递]\n```\n\n```mermaid\ngraph TD;\n  A-->B;\n  A-->C;\n  B-->D;\n  C-->D;\n```\n\n```mermaid\npie\n  title Key elements in Product X\n  \"Calcium\" : 42.96\n  \"Potassium\" : 50.05\n  \"Magnesium\" : 10.01\n  \"Iron\" : 5\n```\n\n```mermaid\npie\n  title 为什么总是宅在家里？\n  \"喜欢宅\" : 45\n  \"天气太热\" : 70\n  \"穷\" : 500\n  \"没人约\" : 95\n```\n\n这种方式不仅能直观展示流程，还能提升文档的专业性。\n\n> 更多用法，参见：[Mermaid User Guide](https://mermaid.js.org/intro/getting-started.html)。\n\n### 3. PlantUML 流程图：可视化流程\n\nPlantUML 是强大的可视化工具，可以在 Markdown 中创建流程图、时序图等。\n\n```plantuml\n@startuml\nparticipant Participant as Foo\nactor       Actor       as Foo1\nboundary    Boundary    as Foo2\ncontrol     Control     as Foo3\nentity      Entity      as Foo4\ndatabase    Database    as Foo5\ncollections Collections as Foo6\nqueue       Queue       as Foo7\nFoo -> Foo1 : To actor\nFoo -> Foo2 : To boundary\nFoo -> Foo3 : To control\nFoo -> Foo4 : To entity\nFoo -> Foo5 : To database\nFoo -> Foo6 : To collections\nFoo -> Foo7: To queue\n@enduml\n```\n\n> 更多用法，参见：[PlantUML 主页](https://plantuml.com/zh/)。\n\n### 4. Infographic 信息图：可视化数据\n\n新一代信息图可视化引擎，让文字信息栩栩如生！\n\n```infographic\ninfographic list-row-horizontal-icon-arrow\ndata\n  title 客户增长引擎\n  desc 多渠道触达与复购提升\n  items\n    - label 线索获取\n      value 18.6\n      desc 渠道投放与内容获客\n      icon rocket-launch\n    - label 转化提效\n      value 12.4\n      desc 线索评分与自动跟进\n      icon progress-check\n    - label 复购提升\n      value 9.8\n      desc 会员体系与权益运营\n      icon account-sync\n    - label 口碑传播\n      value 6.2\n      desc 社群激励与推荐裂变\n      icon account-group\n```\n\n> 更多用法，参见：[AntV Infographic Gallery](https://infographic.antv.vision/gallery)。\n\n### 5. Ruby 注音：注音标注\n\n支持两种格式：\n\n```md\n1. [文字]{注音}\n2. [文字]^(注音)\n```\n\n渲染效果如下：\n\n[你好]{nǐ hǎo} [世界]{shì jiè}\n\n支持四种分隔符： `・`（中点）、`．` (全角句点)、`。` (中文句号)、`-` (英文减号)\n\n示例：\n\n```md\n[你好世界]{nǐ・hǎo・shì・jiè}\n[小夜時雨]^(さ・よ・しぐれ)\n```\n\n[你好世界]{nǐ・hǎo・shì・jiè}\n[小夜時雨]^(さ・よ・しぐれ)\n\n当字符串数量与分隔符数量不匹配时，会自动匹配到最合适的分隔符。\n\n```md\n[小夜時雨]{さ・よ・しぐれ}\n[小夜時雨]{さ・よ}\n[小夜]{さ・よ・しぐれ}\n[小夜時雨]{さ・よ・しぐれ・extra}\n```\n\n[小夜時雨]{さ・よ・しぐれ}\n[小夜時雨]{さ・よ}\n[小夜]{さ・よ・しぐれ}\n[小夜時雨]{さ・よ・しぐれ・extra}\n\n## 结语\n\nMarkdown 是一种简单、强大且易于掌握的标记语言，通过学习基础和进阶语法，你可以快速创作内容并有效传达信息。无论是技术文档、个人博客还是项目说明，Markdown 都是你的得力助手。希望这篇内容能够带你全面了解 Markdown 的潜力，让你的写作更加丰富多彩！\n\n现在，拿起 Markdown 编辑器，开始创作吧！探索 Markdown 的世界，你会发现它远比想象中更精彩！\n\n#### 推荐阅读\n\n- [阿里又一个 20k+ stars 开源项目诞生，恭喜 fastjson！](https://mp.weixin.qq.com/s/RNKDCK2KoyeuMeEs6GUrow)\n- [刷掉 90% 候选人的互联网大厂海量数据面试题（附题解 + 方法总结）](https://mp.weixin.qq.com/s/rjGqxUvrEqJNlo09GrT1Dw)\n- [好用！期待已久的文本块功能究竟如何在 Java 13 中发挥作用？](https://mp.weixin.qq.com/s/kalGv5T8AZGxTnLHr2wDsA)\n- [2019 GitHub 开源贡献排行榜新鲜出炉！微软谷歌领头，阿里跻身前 12！](https://mp.weixin.qq.com/s/_q812aGD1b9QvZ2WFI0Qgw)\n\n---\n\n<center>\n    <img src=\"https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/doocs/md/images/1648303220922-7e14aefa-816e-44c1-8604-ade709ca1c69.png\" style=\"width: 100px;\">\n</center>\n"
  },
  {
    "path": "apps/web/src/assets/index.css",
    "content": "@import 'tailwindcss';\n@config '../../tailwind.config.cjs';\n\n/*\n  The default border color has changed to `currentcolor` in Tailwind CSS v4,\n  so we've added these compatibility styles to make sure everything still\n  looks the same as it did with Tailwind CSS v3.\n\n  If we ever want to remove these styles, we need to add an explicit border\n  color utility to any element that depends on these defaults.\n*/\n@layer base {\n  *,\n  ::after,\n  ::before,\n  ::backdrop,\n  ::file-selector-button {\n    border-color: var(--color-gray-200, currentcolor);\n  }\n}\n\n@layer base {\n  :root {\n    --background: 0 0% 100%;\n    --foreground: 0 0% 3.9%;\n\n    --card: 0 0% 100%;\n    --card-foreground: 0 0% 3.9%;\n\n    --popover: 0 0% 100%;\n    --popover-foreground: 0 0% 3.9%;\n\n    --primary: 0 0% 9%;\n    --primary-foreground: 0 0% 98%;\n\n    --secondary: 0 0% 96.1%;\n    --secondary-foreground: 0 0% 9%;\n\n    --muted: 0 0% 96.1%;\n    --muted-foreground: 0 0% 45.1%;\n\n    --accent: 0 0% 96.1%;\n    --accent-foreground: 0 0% 9%;\n\n    --destructive: 0 84.2% 60.2%;\n    --destructive-foreground: 0 0% 98%;\n\n    --border: 0 0% 89.8%;\n    --input: 0 0% 89.8%;\n    --ring: 0 0% 3.9%;\n    --radius: 0.5rem;\n\n    --blockquote-background: #f7f7f7;\n  }\n\n  .dark {\n    --background: 0 0% 3.9%;\n    --foreground: 0 0% 98%;\n\n    --card: 0 0% 3.9%;\n    --card-foreground: 0 0% 98%;\n\n    --popover: 0 0% 3.9%;\n    --popover-foreground: 0 0% 98%;\n\n    --primary: 0 0% 98%;\n    --primary-foreground: 0 0% 9%;\n\n    --secondary: 0 0% 14.9%;\n    --secondary-foreground: 0 0% 98%;\n\n    --muted: 0 0% 14.9%;\n    --muted-foreground: 0 0% 63.9%;\n\n    --accent: 0 0% 14.9%;\n    --accent-foreground: 0 0% 98%;\n\n    --destructive: 0 62.8% 30.6%;\n    --destructive-foreground: 0 0% 98%;\n\n    --border: 0 0% 14.9%;\n    --input: 0 0% 14.9%;\n    --ring: 0 0% 83.1%;\n\n    --blockquote-background: #212121;\n  }\n}\n\n@layer base {\n  * {\n    @apply border-border outline-ring/50;\n  }\n  body {\n    @apply bg-background text-foreground;\n  }\n}\n"
  },
  {
    "path": "apps/web/src/assets/less/app.less",
    "content": "//* {\n//  box-sizing: border-box;\n//  margin: 0;\n//  padding: 0;\n//}\n\nhtml,\nbody {\n  height: 100%;\n  font-family: 'PingFang SC', BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif;\n}\n\ninput,\nbutton,\ntextarea {\n  font-family: inherit;\n}\n\nh1,\nh2,\nh3,\nh4,\nh5,\nh6 {\n  font-weight: normal;\n}\n\nem {\n  font-style: normal !important;\n}\n\nsection {\n  height: 100%;\n}\n\n.web-title {\n  margin: 0 15px 0 5px;\n}\n\n.web-icon {\n  width: auto;\n  height: 1.5rem;\n  vertical-align: middle;\n}\n\n#editor {\n  display: block;\n  height: 100%;\n  width: 100%;\n  border: none;\n}\n\n.ctrl {\n  flex-basis: 60px;\n  flex-grow: 1;\n  flex-shrink: 1;\n  display: flex;\n  align-items: center;\n}\n\n.preview-wrapper {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  padding: 0;\n  overflow-y: scroll;\n  width: 100%;\n}\n\n.hint {\n  opacity: 0.6;\n  margin: 20px 0;\n}\n\n.preview {\n  position: relative;\n  // margin: 0 -20px;\n  // width: 375px;\n  min-height: 100%;\n  margin: 0 auto;\n  padding: 20px;\n  font-size: 14px;\n  box-sizing: border-box;\n  outline: none;\n  transition: all 300ms ease-in-out;\n  word-wrap: break-word;\n}\n\n.preview table {\n  margin-bottom: 10px;\n  border-collapse: collapse;\n  display: table;\n  width: 100% !important;\n}\n"
  },
  {
    "path": "apps/web/src/assets/less/theme.less",
    "content": "@nightPreviewColor: #191919;\n@nightCodeMirrorColor: #191919;\n@nightActiveCodeMirrorColor: gray;\n@nightFontColor: gray;\n@nightLinkColor: #8e9eb9;\n@nightLinkTextColor: #84868b;\n@nightLineColor: #84868b;\n\n.dark {\n  .container {\n    // CodeMirror v6 兼容\n    .cm-editor,\n    .CodeMirror-wrap {\n      background-color: @nightCodeMirrorColor;\n    }\n\n    .output_night {\n      .preview {\n        background-color: @nightPreviewColor;\n        box-shadow: 0 0 70px rgba(0, 0, 0, 0.3);\n      }\n\n      .preview-wrapper {\n        background-color: @nightCodeMirrorColor;\n        box-shadow: inset 0 0 0 1px rgba(233, 231, 231, 0.102);\n      }\n\n      .code-snippet__fix {\n        background-color: rgb(238, 238, 238);\n      }\n    }\n\n    ::-webkit-scrollbar {\n      background-color: @nightCodeMirrorColor;\n    }\n  }\n}\n\n// CodeMirror v5 兼容样式\n.CodeMirror {\n  padding-bottom: 0;\n  height: 100% !important;\n  font-size: 16px;\n  font-family: Consolas, 'Courier New', monospace !important;\n}\n\n.CodeMirror-vscrollbar:focus {\n  outline: none;\n}\n\n.CodeMirror-vscrollbar {\n  width: 0px;\n  height: 0px;\n}\n\n.CodeMirror-wrap {\n  padding-top: 20px;\n  padding-bottom: 20px;\n  box-sizing: border-box;\n}\n\n// CodeMirror v6 样式\n.cm-editor {\n  height: 100% !important;\n  font-size: 16px;\n  font-family: Consolas, 'Courier New', monospace !important;\n\n  .cm-scroller {\n    overflow-x: auto !important;\n    overflow-y: auto !important;\n\n    // 只隐藏 x 方向的滚动条\n    &::-webkit-scrollbar:horizontal {\n      display: none; /* Chrome/Safari/Webkit - 横向滚动条 */\n    }\n  }\n\n  .cm-content {\n    padding-bottom: 20px;\n  }\n\n  &.cm-focused {\n    outline: none;\n  }\n}\n\n.codemirror-container {\n  height: 100%;\n  width: 100%;\n\n  .cm-scroller {\n    padding: 10px;\n  }\n}\n\n.cssEditor-wrapper {\n  .CodeMirror-scroll,\n  .cm-scroller {\n    margin-right: 0;\n  }\n}\n\n.cm-em {\n  font-style: normal;\n}\n\n.cm-comment {\n  font-style: normal !important;\n}\n"
  },
  {
    "path": "apps/web/src/components/AppSplash.vue",
    "content": "<script setup lang=\"ts\">\nconst loading = ref(true)\n\nonMounted(() => {\n  setTimeout(() => {\n    loading.value = false\n  }, 100)\n})\n</script>\n\n<template>\n  <transition name=\"fade\">\n    <div\n      v-if=\"loading\"\n      class=\"loading\"\n    >\n      <strong>致力于让 Markdown 编辑更简单</strong>\n    </div>\n  </transition>\n</template>\n\n<style lang=\"less\" scoped>\n.loading {\n  position: fixed;\n  top: 0;\n  left: 0;\n  z-index: 99999;\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n  align-items: center;\n  width: 100vw;\n  height: 100vh;\n  font-size: 18px;\n  background-color: hsl(var(--background));\n\n  &::before {\n    content: url('../assets/images/favicon.png');\n    width: 100px;\n    height: 100px;\n    margin-bottom: 26px;\n  }\n}\n\n.fade-enter,\n.fade-leave-to {\n  opacity: 0;\n}\n\n.fade-enter-to,\n.fade-leave {\n  opacity: 1;\n}\n\n.fade-enter-active,\n.fade-leave-active {\n  transition: opacity 1s;\n}\n</style>\n"
  },
  {
    "path": "apps/web/src/components/ai/SidebarAIToolbar.vue",
    "content": "<script setup lang=\"ts\">\nimport { Bot, Image as ImageIcon, Settings2, Wand2 } from 'lucide-vue-next'\nimport { useEditorStore } from '@/stores/editor'\nimport { useUIStore } from '@/stores/ui'\nimport AIAssistantPanel from './chat-box/AIAssistantPanel.vue'\nimport AIImageGeneratorPanel from './image-generator/AIImageGeneratorPanel.vue'\nimport { AIPolishPopover } from './tool-box'\n\ndefineProps<{\n  isMobile: boolean\n  showEditor: boolean\n}>()\n\nconst uiStore = useUIStore()\nconst { aiDialogVisible, aiImageDialogVisible } = storeToRefs(uiStore)\nconst { toggleAIDialog, toggleAIImageDialog } = uiStore\n\nconst editorStore = useEditorStore()\nconst { editor } = storeToRefs(editorStore)\n\nconst { hasShownAIToolboxHint } = storeToRefs(uiStore)\n\n// 工具栏状态：false=默认(只显示贴边栏), true=展开(显示AI图标)\nconst isExpanded = ref(false) // 默认收起状态\n\n// AI 工具箱相关状态\nconst toolBoxVisible = ref(false)\n\n// 是否显示选中文本提示动画\nconst showSelectionHint = ref(false)\nlet selectionHintTimer: NodeJS.Timeout | null = null\nlet selectionCheckInterval: NodeJS.Timeout | null = null\nlet lastSelectedText = ``\n\n// 检查选中文本的函数\nfunction getSelectedText() {\n  try {\n    if (!editor.value)\n      return ``\n    const selection = editor.value.state.selection.main\n    return editor.value.state.doc.sliceString(selection.from, selection.to).trim()\n  }\n  catch {\n    return ``\n  }\n}\n\n// 检查并更新选中文本提示\nfunction checkSelectionAndUpdateHint() {\n  // 如果已经显示过提示，就不再显示\n  if (hasShownAIToolboxHint.value) {\n    return\n  }\n\n  const selected = getSelectedText()\n\n  // 如果选中状态发生变化\n  if (selected !== lastSelectedText) {\n    lastSelectedText = selected\n\n    // 清除之前的定时器\n    if (selectionHintTimer) {\n      clearTimeout(selectionHintTimer)\n      selectionHintTimer = null\n    }\n\n    // 如果有选中文本且工具栏未展开\n    if (selected && !isExpanded.value) {\n      showSelectionHint.value = true\n\n      // 标记已经显示过提示\n      hasShownAIToolboxHint.value = true\n\n      // 3秒后自动隐藏提示\n      selectionHintTimer = setTimeout(() => {\n        showSelectionHint.value = false\n      }, 3000)\n    }\n    else {\n      showSelectionHint.value = false\n    }\n  }\n}\n\n// 动态计算是否有选中文本\nconst hasSelectedText = computed(() => {\n  if (!editor.value || !isExpanded.value)\n    return false\n  return getSelectedText().length > 0\n})\n\n// 当打开工具箱时，获取当前选中的文本\nconst currentSelectedText = computed(() => {\n  return toolBoxVisible.value ? getSelectedText() : ``\n})\n\n// 切换展开/收起状态\nfunction toggleExpanded() {\n  isExpanded.value = !isExpanded.value\n\n  // 展开后隐藏提示\n  if (isExpanded.value) {\n    showSelectionHint.value = false\n    if (selectionHintTimer) {\n      clearTimeout(selectionHintTimer)\n      selectionHintTimer = null\n    }\n  }\n}\n\n// 打开AI助手\nfunction openAIChat() {\n  toggleAIDialog(true)\n}\n\n// 打开AI文生图\nfunction openAIImageGenerator() {\n  toggleAIImageDialog(true)\n}\n\n// 打开AI工具箱\nfunction openAIToolBox() {\n  toolBoxVisible.value = true\n}\n\n// 监听编辑区点击，自动收起工具栏\nonMounted(() => {\n  // 启动定时检查选中文本\n  selectionCheckInterval = setInterval(() => {\n    checkSelectionAndUpdateHint()\n  }, 300) // 每300ms检查一次\n\n  const handleInteraction = (e: Event) => {\n    // 只有在展开状态才需要处理\n    if (!isExpanded.value)\n      return\n\n    const target = e.target as Element\n    if (!target)\n      return\n\n    const toolbar = document.querySelector(`.editor-ai-toolbar`)\n\n    // 如果点击的是工具栏及其子元素，不处理\n    if (toolbar && toolbar.contains(target))\n      return\n\n    // 排除不应该收起的区域\n    const excludeSelectors = [\n      `dialog`,\n      `.popover`,\n      `.modal`,\n      `[role=\"dialog\"]`,\n      `nav`,\n      `.menu`,\n      `.dropdown`,\n      `.tooltip`,\n      `.floating`,\n      `.ai-assistant-panel`,\n      `.ai-image-generator-panel`,\n    ]\n\n    const shouldNotCollapse = excludeSelectors.some(selector => target.closest(selector))\n\n    if (!shouldNotCollapse) {\n      isExpanded.value = false\n    }\n  }\n\n  // 同时监听点击和触摸事件，覆盖桌面端和移动端\n  document.addEventListener(`click`, handleInteraction, true)\n  document.addEventListener(`touchstart`, handleInteraction, true)\n\n  onUnmounted(() => {\n    document.removeEventListener(`click`, handleInteraction, true)\n    document.removeEventListener(`touchstart`, handleInteraction, true)\n\n    // 清理定时器\n    if (selectionHintTimer) {\n      clearTimeout(selectionHintTimer)\n      selectionHintTimer = null\n    }\n\n    // 清理轮询\n    if (selectionCheckInterval) {\n      clearInterval(selectionCheckInterval)\n      selectionCheckInterval = null\n    }\n  })\n})\n</script>\n\n<template>\n  <!-- 编辑区内侧AI工具栏 -->\n  <div\n    v-if=\"(!isMobile || (isMobile && showEditor))\"\n    class=\"editor-ai-toolbar absolute top-1/2 -translate-y-1/2 right-0 z-30 transition-all duration-300 ease-out\"\n  >\n    <!-- 默认状态：贴边栏 -->\n    <div\n      v-if=\"!isExpanded\"\n      class=\"w-5 h-16 bg-gradient-to-b from-blue-500/90 to-purple-500/90 hover:from-blue-600/95 hover:to-purple-600/95 dark:from-blue-400/90 dark:to-purple-400/90 dark:hover:from-blue-500/95 dark:hover:to-purple-500/95 backdrop-blur-lg border-l border-y border-blue-300/50 dark:border-blue-600/50 cursor-pointer transition-all duration-200 flex items-center justify-center rounded-l-lg shadow-lg group utools-sidebar-edge\"\n      :class=\"{ 'animate-pulse-hint': showSelectionHint }\"\n      title=\"展开AI工具栏\"\n      @click=\"toggleExpanded\"\n    >\n      <Settings2 class=\"h-4 w-4 text-white drop-shadow-sm group-hover:scale-110 transition-transform duration-200\" />\n\n      <!-- 选中文本提示气泡 -->\n      <Transition name=\"hint-fade\">\n        <div\n          v-if=\"showSelectionHint\"\n          class=\"hint-bubble absolute right-full mr-3 px-4 py-2 bg-gradient-to-r from-blue-500 to-purple-500 text-white text-sm font-medium rounded-lg shadow-xl whitespace-nowrap pointer-events-none animate-bounce-gentle z-50\"\n          style=\"top: 50%; transform: translateY(-50%);\"\n        >\n          <div class=\"relative flex items-center gap-2\">\n            <Wand2 class=\"h-4 w-4\" />\n            <span>点击打开 AI 工具箱</span>\n            <!-- 箭头 -->\n            <div class=\"hint-arrow absolute top-1/2 -right-2 -translate-y-1/2 w-0 h-0 border-t-[6px] border-b-[6px] border-l-[6px] border-transparent border-l-purple-500\" />\n          </div>\n        </div>\n      </Transition>\n    </div>\n\n    <!-- 展开状态：显示AI图标 -->\n    <div\n      v-else\n      class=\"bg-white/95 dark:bg-gray-900/95 backdrop-blur-lg border-l border-gray-200/50 dark:border-gray-700/50 shadow-lg overflow-hidden transition-all duration-300 w-12 rounded-l-md\"\n      :style=\"{ height: 'auto' }\"\n    >\n      <!-- 展开状态的AI按钮 -->\n      <div class=\"flex flex-col py-2 gap-2\">\n        <!-- AI助手按钮 -->\n        <div class=\"flex flex-col items-center gap-1 px-1\">\n          <button\n            class=\"group relative w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white shadow-md hover:shadow-lg transform hover:scale-105 active:scale-95 transition-all duration-200 flex items-center justify-center utools-ai-button\"\n            title=\"AI助手\"\n            @click=\"openAIChat\"\n          >\n            <Bot class=\"h-4 w-4\" />\n          </button>\n\n          <!-- 标签 -->\n          <span class=\"text-[9px] text-gray-500 dark:text-gray-400 font-medium text-center leading-tight\">\n            助手\n          </span>\n        </div>\n\n        <!-- 分割线 -->\n        <div class=\"mx-1.5\">\n          <div class=\"h-px bg-gray-200/50 dark:bg-gray-700/50\" />\n        </div>\n\n        <!-- AI文生图按钮 -->\n        <div class=\"flex flex-col items-center gap-1 px-1\">\n          <button\n            class=\"group relative w-7 h-7 rounded-lg bg-gradient-to-br from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white shadow-md hover:shadow-lg transform hover:scale-105 active:scale-95 transition-all duration-200 flex items-center justify-center utools-ai-button\"\n            title=\"AI文生图\"\n            @click=\"openAIImageGenerator\"\n          >\n            <ImageIcon class=\"h-4 w-4\" />\n          </button>\n\n          <!-- 标签 -->\n          <span class=\"text-[9px] text-gray-500 dark:text-gray-400 font-medium text-center leading-tight\">\n            文生图\n          </span>\n        </div>\n\n        <!-- 分割线 -->\n        <div v-if=\"hasSelectedText && isExpanded\" class=\"mx-1.5\">\n          <div class=\"h-px bg-gray-200/50 dark:bg-gray-700/50\" />\n        </div>\n\n        <!-- AI工具箱按钮 (只有选中文本且展开时才显示) -->\n        <div v-if=\"hasSelectedText && isExpanded\" class=\"flex flex-col items-center gap-1 px-1\">\n          <button\n            class=\"group relative w-7 h-7 rounded-lg bg-gradient-to-br from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white shadow-md hover:shadow-lg transform hover:scale-105 active:scale-95 transition-all duration-200 flex items-center justify-center utools-ai-button\"\n            title=\"AI工具箱\"\n            @click=\"openAIToolBox\"\n          >\n            <Wand2 class=\"h-4 w-4\" />\n          </button>\n\n          <!-- 标签 -->\n          <span class=\"text-[9px] text-gray-500 dark:text-gray-400 font-medium text-center leading-tight\">\n            工具箱\n          </span>\n        </div>\n      </div>\n    </div>\n\n    <!-- AI面板组件 -->\n    <AIAssistantPanel v-model:open=\"aiDialogVisible\" />\n    <AIImageGeneratorPanel v-model:open=\"aiImageDialogVisible\" />\n\n    <!-- AI工具箱弹窗 -->\n    <AIPolishPopover\n      v-model:open=\"toolBoxVisible\"\n      :selected-text=\"currentSelectedText\"\n      :is-mobile=\"isMobile\"\n    />\n  </div>\n</template>\n\n<style scoped>\n/* 确保工具栏与编辑器完美集成 */\n.editor-ai-toolbar {\n  z-index: 30;\n  contain: layout style;\n  pointer-events: auto;\n  max-width: calc(100% - 0.5rem);\n}\n\n/* 工具栏自适应高度 */\n.editor-ai-toolbar > div {\n  height: auto;\n  min-height: fit-content;\n}\n\n/* 确保按钮悬浮提示正确显示 */\n.editor-ai-toolbar .absolute {\n  overflow: visible;\n}\n\n/* 选中文本时的脉冲动画 */\n@keyframes pulse-hint {\n  0%,\n  100% {\n    box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7);\n  }\n  50% {\n    box-shadow: 0 0 0 8px rgba(59, 130, 246, 0);\n  }\n}\n\n.animate-pulse-hint {\n  animation: pulse-hint 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;\n}\n\n/* 提示气泡的轻微弹跳动画 */\n@keyframes bounce-gentle {\n  0%,\n  100% {\n    transform: translateX(0);\n  }\n  50% {\n    transform: translateX(-4px);\n  }\n}\n\n.animate-bounce-gentle {\n  animation: bounce-gentle 1s ease-in-out infinite;\n}\n\n/* 提示气泡淡入淡出过渡 */\n.hint-fade-enter-active,\n.hint-fade-leave-active {\n  transition: opacity 0.3s ease, transform 0.3s ease;\n}\n\n.hint-fade-enter-from {\n  opacity: 0;\n  transform: translateX(8px);\n}\n\n.hint-fade-leave-to {\n  opacity: 0;\n  transform: translateX(8px);\n}\n\n.hint-fade-enter-to,\n.hint-fade-leave-from {\n  opacity: 1;\n  transform: translateX(0);\n}\n\n/* 响应式调整 */\n@media (max-width: 768px) {\n  .editor-ai-toolbar {\n    transform: translateY(-50%);\n    transform-origin: right center;\n  }\n\n  /* 移动端图标稍微再大一点 */\n  .editor-ai-toolbar .lucide {\n    width: 1.125rem !important; /* h-4.5 w-4.5 */\n    height: 1.125rem !important;\n  }\n}\n\n/* 提高可访问性 */\n@media (prefers-reduced-motion: reduce) {\n  .transition-all,\n  .transform {\n    transition: none !important;\n  }\n\n  .hover\\:scale-105:hover,\n  .active\\:scale-95:active {\n    transform: none !important;\n  }\n\n  .backdrop-blur-lg {\n    backdrop-filter: none;\n  }\n}\n\n/* 确保在小屏幕上不会遮挡内容 */\n@media (max-height: 500px) {\n  .min-h-\\[120px\\] {\n    min-height: 80px;\n  }\n\n  .min-h-\\[80px\\] {\n    min-height: 60px;\n  }\n}\n\n/* 毛玻璃效果优化 */\n@supports (backdrop-filter: blur(16px)) {\n  .backdrop-blur-lg {\n    backdrop-filter: blur(16px);\n    -webkit-backdrop-filter: blur(16px);\n  }\n}\n\n/* 确保渐变按钮在深色模式下显示正确 */\n.bg-gradient-to-br {\n  background-attachment: fixed;\n}\n\n/* 悬浮提示样式优化 */\n.group:hover > div {\n  animation: fadeIn 0.2s ease-out;\n}\n\n@keyframes fadeIn {\n  from {\n    opacity: 0;\n    transform: translateX(4px);\n  }\n  to {\n    opacity: 1;\n    transform: translateX(0);\n  }\n}\n\n/* uTools 插件模式下使用黑白风格 */\n.is-utools .utools-sidebar-edge {\n  background: rgb(0 0 0 / 0.9) !important;\n  border-color: rgb(0 0 0 / 0.5) !important;\n}\n\n.is-utools .utools-sidebar-edge:hover {\n  background: rgb(0 0 0 / 0.95) !important;\n}\n\n.is-utools.dark .utools-sidebar-edge {\n  background: rgb(255 255 255 / 0.9) !important;\n  border-color: rgb(255 255 255 / 0.5) !important;\n}\n\n.is-utools.dark .utools-sidebar-edge:hover {\n  background: rgb(255 255 255 / 0.95) !important;\n}\n\n.is-utools.dark .utools-sidebar-edge .lucide {\n  color: rgb(0 0 0) !important;\n}\n\n/* uTools 模式下提示气泡使用黑白风格 */\n.is-utools .hint-bubble {\n  background: rgb(0 0 0 / 0.9) !important;\n  background-image: none !important;\n  color: rgb(255 255 255) !important;\n}\n\n.is-utools.dark .hint-bubble {\n  background: rgb(255 255 255 / 0.9) !important;\n  background-image: none !important;\n  color: rgb(0 0 0) !important;\n}\n\n.is-utools .hint-arrow {\n  border-left-color: rgb(0 0 0 / 0.9) !important;\n}\n\n.is-utools.dark .hint-arrow {\n  border-left-color: rgb(255 255 255 / 0.9) !important;\n}\n\n/* uTools 模式下脉冲动画使用黑白风格 */\n@keyframes pulse-hint-utools {\n  0%,\n  100% {\n    box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.7);\n  }\n  50% {\n    box-shadow: 0 0 0 8px rgba(0, 0, 0, 0);\n  }\n}\n\n@keyframes pulse-hint-utools-dark {\n  0%,\n  100% {\n    box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7);\n  }\n  50% {\n    box-shadow: 0 0 0 8px rgba(255, 255, 255, 0);\n  }\n}\n\n.is-utools .animate-pulse-hint {\n  animation: pulse-hint-utools 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;\n}\n\n.is-utools.dark .animate-pulse-hint {\n  animation: pulse-hint-utools-dark 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;\n}\n\n/* uTools 模式下 AI 按钮使用黑白风格 */\n.is-utools .utools-ai-button {\n  background: rgb(0 0 0 / 0.85) !important;\n  background-image: none !important;\n}\n\n.is-utools .utools-ai-button:hover {\n  background: rgb(0 0 0 / 0.95) !important;\n  background-image: none !important;\n}\n\n.is-utools.dark .utools-ai-button {\n  background: rgb(255 255 255 / 0.85) !important;\n  background-image: none !important;\n  color: rgb(0 0 0) !important;\n}\n\n.is-utools.dark .utools-ai-button:hover {\n  background: rgb(255 255 255 / 0.95) !important;\n  background-image: none !important;\n}\n\n.is-utools.dark .utools-ai-button .lucide {\n  color: rgb(0 0 0) !important;\n}\n</style>\n"
  },
  {
    "path": "apps/web/src/components/ai/chat-box/AIAssistantPanel.vue",
    "content": "<script setup lang=\"ts\">\nimport type { QuickCommandRuntime } from '@/stores/quickCommands'\nimport {\n  Check,\n  Copy,\n  FolderOpen,\n  Image as ImageIcon,\n  MessageCircle,\n  Pause,\n  Plus,\n  RefreshCcw,\n  Send,\n  Settings,\n  Trash2,\n} from 'lucide-vue-next'\nimport { Button } from '@/components/ui/button'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog'\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\nimport { Textarea } from '@/components/ui/textarea'\nimport useAIConfigStore from '@/stores/aiConfig'\nimport { useEditorStore } from '@/stores/editor'\nimport { useQuickCommands } from '@/stores/quickCommands'\nimport { useUIStore } from '@/stores/ui'\nimport { copyPlain } from '@/utils/clipboard'\nimport { store } from '@/utils/storage'\n\nconst props = defineProps<{ open: boolean }>()\nconst emit = defineEmits([`update:open`])\n\nconst editorStore = useEditorStore()\nconst { editor } = storeToRefs(editorStore)\nconst uiStore = useUIStore()\nconst { toggleAIImageDialog } = uiStore\n\nconst dialogVisible = ref(props.open)\nwatch(() => props.open, val => (dialogVisible.value = val))\nwatch(dialogVisible, (val) => {\n  emit(`update:open`, val)\n  if (val)\n    scrollToBottom(true)\n})\n\nconst input = ref<string>(``)\nconst inputHistory = ref<string[]>([])\nconst historyIndex = ref<number | null>(null)\n\nconst configVisible = ref(false)\nconst loading = ref(false)\nconst fetchController = ref<AbortController | null>(null)\nconst copiedIndex = ref<number | null>(null)\nconst memoryKey = `ai_memory_context`\nconst isQuoteAllContent = ref(false)\nconst cmdMgrOpen = ref(false)\n\nconst conversationListKey = `ai_conversation_list`\nconst currentConversationId = ref<string | null>(null)\nconst conversationList = ref<Array<{ id: string, name: string, timestamp: number }>>([])\n\ninterface ChatMessage {\n  role: `user` | `assistant` | `system`\n  content: string\n  reasoning?: string\n  done?: boolean\n  id?: string\n}\n\nconst messages = ref<ChatMessage[]>([])\nconst AIConfigStore = useAIConfigStore()\nconst { apiKey, endpoint, model, temperature, maxToken, type } = storeToRefs(AIConfigStore)\n\nconst quickCmdStore = useQuickCommands()\n\nfunction getSelectedText(): string {\n  try {\n    const cm: any = editor.value\n    if (!cm)\n      return ``\n    if (typeof cm.getSelection === `function`)\n      return cm.getSelection() || ``\n    return ``\n  }\n  catch (e) {\n    console.warn(`获取选中文本失败`, e)\n    return ``\n  }\n}\n\nfunction applyQuickCommand(cmd: QuickCommandRuntime) {\n  const selected = getSelectedText()\n  input.value = cmd.buildPrompt(selected)\n  historyIndex.value = null\n  nextTick(() => {\n    const textarea = document.querySelector(\n      `textarea[placeholder*=\"说些什么\" ]`,\n    ) as HTMLTextAreaElement | null\n    textarea?.focus()\n    if (textarea) {\n      textarea.setSelectionRange(textarea.value.length, textarea.value.length)\n    }\n  })\n}\n\nonMounted(async () => {\n  const savedList = await store.get(conversationListKey)\n  if (savedList) {\n    conversationList.value = JSON.parse(savedList)\n  }\n\n  const saved = await store.get(memoryKey)\n  messages.value = saved\n    ? JSON.parse(saved).map((msg: ChatMessage) => ({\n        ...msg,\n        id: msg.id || crypto.randomUUID(),\n      }))\n    : getDefaultMessages()\n  await scrollToBottom(true)\n})\n\nfunction getDefaultMessages(): ChatMessage[] {\n  return [{ role: `assistant`, content: `你好，我是 AI 助手，有什么可以帮你的？`, id: crypto.randomUUID() }]\n}\n\nfunction generateConversationTitle(): string {\n  const firstUserMessage = messages.value.find(m => m.role === `user`)\n  if (!firstUserMessage)\n    return `对话 ${new Date().toLocaleString()}`\n\n  let title = firstUserMessage.content.trim()\n  if (title.length > 20) {\n    title = `${title.substring(0, 20)}...`\n  }\n\n  return title\n}\n\nasync function autoSaveCurrentConversation() {\n  if (messages.value.length <= 1)\n    return\n\n  if (!currentConversationId.value) {\n    currentConversationId.value = crypto.randomUUID()\n\n    const conversation = {\n      id: currentConversationId.value,\n      name: generateConversationTitle(),\n      timestamp: Date.now(),\n    }\n    conversationList.value.unshift(conversation)\n    await store.setJSON(conversationListKey, conversationList.value)\n  }\n  else {\n    const conv = conversationList.value.find(c => c.id === currentConversationId.value)\n    if (conv) {\n      conv.timestamp = Date.now()\n      await store.setJSON(conversationListKey, conversationList.value)\n    }\n  }\n\n  await store.setJSON(`ai_conversation_${currentConversationId.value}`, messages.value)\n}\n\nasync function createNewConversation() {\n  await autoSaveCurrentConversation()\n\n  currentConversationId.value = null\n  messages.value = getDefaultMessages()\n  await store.setJSON(memoryKey, messages.value)\n  await scrollToBottom(true)\n  toast.success(`已创建新会话`)\n}\n\nasync function loadConversation(id: string) {\n  await autoSaveCurrentConversation()\n\n  const saved = await store.getJSON<ChatMessage[]>(`ai_conversation_${id}`, [])\n  if (saved.length > 0) {\n    messages.value = saved.map(msg => ({\n      ...msg,\n      id: msg.id || crypto.randomUUID(),\n    }))\n    currentConversationId.value = id\n    await store.setJSON(memoryKey, messages.value)\n    await scrollToBottom(true)\n    toast.success(`对话已加载`)\n  }\n}\n\nasync function deleteConversation(id: string) {\n  conversationList.value = conversationList.value.filter(c => c.id !== id)\n  await store.setJSON(conversationListKey, conversationList.value)\n  await store.remove(`ai_conversation_${id}`)\n\n  if (currentConversationId.value === id) {\n    currentConversationId.value = null\n    messages.value = getDefaultMessages()\n    await store.setJSON(memoryKey, messages.value)\n  }\n\n  toast.success(`对话已删除`)\n}\n\nfunction handleConfigSaved() {\n  configVisible.value = false\n  scrollToBottom(true)\n}\n\nfunction switchToImageGenerator() {\n  emit(`update:open`, false)\n  setTimeout(() => {\n    toggleAIImageDialog(true)\n  }, 100)\n}\n\nfunction handleKeydown(e: KeyboardEvent) {\n  if (e.isComposing || e.keyCode === 229)\n    return\n\n  if (e.key === `Enter` && !e.shiftKey) {\n    e.preventDefault()\n    sendMessage()\n  }\n  else if (e.key === `ArrowUp`) {\n    e.preventDefault()\n    if (inputHistory.value.length === 0)\n      return\n    if (historyIndex.value === null) {\n      historyIndex.value = inputHistory.value.length - 1\n    }\n    else if (historyIndex.value > 0) {\n      historyIndex.value--\n    }\n    input.value = inputHistory.value[historyIndex.value] || ``\n  }\n  else if (e.key === `ArrowDown`) {\n    e.preventDefault()\n    if (historyIndex.value === null)\n      return\n    if (historyIndex.value < inputHistory.value.length - 1) {\n      historyIndex.value++\n      input.value = inputHistory.value[historyIndex.value] || ``\n    }\n    else {\n      historyIndex.value = null\n      input.value = ``\n    }\n  }\n}\n\nasync function copyToClipboard(text: string, index: number) {\n  copyPlain(text)\n  copiedIndex.value = index\n  setTimeout(() => (copiedIndex.value = null), 1500)\n}\n\nasync function resetMessages() {\n  if (fetchController.value) {\n    fetchController.value.abort()\n    fetchController.value = null\n  }\n\n  if (currentConversationId.value) {\n    conversationList.value = conversationList.value.filter(c => c.id !== currentConversationId.value)\n    await store.setJSON(conversationListKey, conversationList.value)\n    await store.remove(`ai_conversation_${currentConversationId.value}`)\n    currentConversationId.value = null\n  }\n\n  messages.value = getDefaultMessages()\n  await store.setJSON(memoryKey, messages.value)\n  scrollToBottom(true)\n  toast.success(`会话已清空`)\n}\n\nfunction pauseStreaming() {\n  if (fetchController.value) {\n    fetchController.value.abort()\n    fetchController.value = null\n  }\n  loading.value = false\n  const last = messages.value[messages.value.length - 1]\n  if (last?.role === `assistant`)\n    last.done = true\n  scrollToBottom(true)\n}\n\nasync function scrollToBottom(force = false) {\n  await nextTick()\n  const container = document.querySelector(`.chat-container`)\n  if (container) {\n    const isNearBottom = (container.scrollTop + container.clientHeight)\n      >= (container.scrollHeight - 50)\n    if (force || isNearBottom) {\n      container.scrollTop = container.scrollHeight\n      await new Promise(resolve => setTimeout(resolve, 50))\n    }\n  }\n}\n\nfunction quoteAllContent() {\n  isQuoteAllContent.value = !isQuoteAllContent.value\n}\n\nasync function regenerateLast() {\n  if (loading.value)\n    return\n\n  const lastAssistantIdx = messages.value.length - 1\n  if (lastAssistantIdx < 0 || messages.value[lastAssistantIdx].role !== `assistant`)\n    return\n\n  messages.value.splice(lastAssistantIdx, 1)\n\n  loading.value = true\n  const replyMessage: ChatMessage = { role: `assistant`, content: ``, reasoning: ``, done: false }\n  messages.value.push(replyMessage)\n  const replyMessageProxy = messages.value[messages.value.length - 1]\n  await scrollToBottom(true)\n\n  await streamResponse(replyMessageProxy)\n}\n\nasync function streamResponse(replyMessageProxy: ChatMessage) {\n  const allHistory = messages.value\n    .slice(-12)\n    .filter((msg, idx, arr) =>\n      !(idx === arr.length - 1 && msg.role === `assistant` && !msg.done)\n      && !(idx === 0 && msg.role === `assistant`),\n    )\n\n  let contextHistory: ChatMessage[]\n  if (isQuoteAllContent.value) {\n    const latest: ChatMessage[] = []\n    for (let i = allHistory.length - 1; i >= 0 && latest.length < 2; i--) {\n      const m = allHistory[i]\n      if (latest.length === 0 || m.role === `user`)\n        latest.unshift(m)\n      else if (m.role === `assistant`)\n        latest.unshift(m)\n    }\n    contextHistory = latest\n  }\n  else {\n    contextHistory = allHistory.slice(-10)\n  }\n  const quoteMessages: ChatMessage[] = isQuoteAllContent.value\n    ? [{\n        role: `system`,\n        content:\n          `下面是一篇 Markdown 文章全文，请严格以此为主完成后续指令：\\n\\n${editor.value?.state.doc.toString()}`,\n      }]\n    : []\n\n  const payloadMessages: ChatMessage[] = [\n    {\n      role: `system`,\n      content: `你是一个专业的 Markdown 编辑器助手，请用简洁中文回答。`,\n    },\n    ...quoteMessages,\n    ...contextHistory,\n  ]\n\n  const payload = {\n    model: model.value,\n    messages: payloadMessages,\n    temperature: temperature.value,\n    max_tokens: maxToken.value,\n    stream: true,\n  }\n  const headers: Record<string, string> = { 'Content-Type': `application/json` }\n  if (apiKey.value && type.value !== `default`)\n    headers.Authorization = `Bearer ${apiKey.value}`\n\n  fetchController.value = new AbortController()\n  const signal = fetchController.value.signal\n\n  try {\n    const url = new URL(endpoint.value)\n    if (!url.pathname.endsWith(`/chat/completions`))\n      url.pathname = url.pathname.replace(/\\/?$/, `/chat/completions`)\n\n    const res = await window.fetch(url.toString(), {\n      method: `POST`,\n      headers,\n      body: JSON.stringify(payload),\n      signal,\n    })\n    if (!res.ok || !res.body)\n      throw new Error(`响应错误：${res.status} ${res.statusText}`)\n\n    const reader = res.body.getReader()\n    const decoder = new TextDecoder(`utf-8`)\n    let buffer = ``\n\n    while (true) {\n      const { value, done } = await reader.read()\n      if (done) {\n        const last = messages.value[messages.value.length - 1]\n        if (last.role === `assistant`) {\n          last.done = true\n          await scrollToBottom(true)\n        }\n        break\n      }\n\n      buffer += decoder.decode(value, { stream: true })\n      const lines = buffer.split(`\\n`)\n      buffer = lines.pop() || ``\n\n      for (const line of lines) {\n        if (!line.trim() || line.trim() === `data: [DONE]`)\n          continue\n        try {\n          const json = JSON.parse(line.replace(/^data: /, ``))\n          const delta = json.choices?.[0]?.delta || {}\n          const last = messages.value[messages.value.length - 1]\n          if (last !== replyMessageProxy)\n            return\n          if (delta.content)\n            last.content += delta.content\n          else if (delta.reasoning_content)\n            last.reasoning = (last.reasoning || ``) + delta.reasoning_content\n          await scrollToBottom()\n        }\n        catch {\n        }\n      }\n    }\n  }\n  catch (e) {\n    if ((e as Error).name !== `AbortError`) {\n      messages.value[messages.value.length - 1].content\n        = `❌ 请求失败: ${(e as Error).message}`\n    }\n    await scrollToBottom(true)\n  }\n  finally {\n    await store.setJSON(memoryKey, messages.value)\n    await autoSaveCurrentConversation()\n    loading.value = false\n    fetchController.value = null\n  }\n}\n\nasync function sendMessage() {\n  if (!input.value.trim() || loading.value)\n    return\n\n  inputHistory.value.push(input.value.trim())\n  historyIndex.value = null\n\n  loading.value = true\n  const userInput = input.value.trim()\n  messages.value.push({ role: `user`, content: userInput })\n  input.value = ``\n\n  const replyMessage: ChatMessage = { role: `assistant`, content: ``, reasoning: ``, done: false }\n  messages.value.push(replyMessage)\n  const replyMessageProxy = messages.value[messages.value.length - 1]\n  await scrollToBottom(true)\n\n  await streamResponse(replyMessageProxy)\n}\n</script>\n\n<template>\n  <Dialog v-model:open=\"dialogVisible\">\n    <DialogContent\n      class=\"bg-card text-card-foreground h-dvh max-h-dvh w-full flex flex-col rounded-none shadow-xl sm:max-h-[80vh] sm:max-w-2xl sm:rounded-xl\"\n    >\n      <!-- ============ 头部 ============ -->\n      <DialogHeader class=\"space-y-1 flex flex-col items-start\">\n        <div class=\"space-x-1 flex items-center\">\n          <DialogTitle>AI 对话</DialogTitle>\n\n          <Button\n            :title=\"configVisible ? 'AI 对话' : '配置参数'\"\n            :aria-label=\"configVisible ? 'AI 对话' : '配置参数'\"\n            variant=\"ghost\"\n            size=\"icon\"\n            @click=\"configVisible = !configVisible\"\n          >\n            <MessageCircle v-if=\"configVisible\" class=\"h-4 w-4\" />\n            <Settings v-else class=\"h-4 w-4\" />\n          </Button>\n\n          <Button\n            title=\"AI 文生图\"\n            aria-label=\"AI 文生图\"\n            variant=\"ghost\"\n            size=\"icon\"\n            @click=\"switchToImageGenerator()\"\n          >\n            <ImageIcon class=\"h-4 w-4\" />\n          </Button>\n\n          <Button\n            title=\"新建会话\"\n            aria-label=\"新建会话\"\n            variant=\"ghost\"\n            size=\"icon\"\n            @click=\"createNewConversation\"\n          >\n            <Plus class=\"h-4 w-4\" />\n          </Button>\n\n          <DropdownMenu>\n            <DropdownMenuTrigger as-child>\n              <Button\n                title=\"加载对话\"\n                aria-label=\"加载对话\"\n                variant=\"ghost\"\n                size=\"icon\"\n              >\n                <FolderOpen class=\"h-4 w-4\" />\n              </Button>\n            </DropdownMenuTrigger>\n            <DropdownMenuContent align=\"end\" class=\"max-h-64 overflow-y-auto w-64 z-[9999]\">\n              <DropdownMenuItem\n                v-if=\"conversationList.length === 0\"\n                disabled\n                class=\"text-muted-foreground text-sm\"\n              >\n                暂无保存的对话\n              </DropdownMenuItem>\n              <DropdownMenuItem\n                v-for=\"conv in conversationList\"\n                :key=\"conv.id\"\n                class=\"flex items-center justify-between gap-2 cursor-pointer\"\n                @click=\"loadConversation(conv.id)\"\n              >\n                <span class=\"flex-1 truncate\">\n                  {{ conv.name }}\n                </span>\n                <Button\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  class=\"h-6 w-6 flex-shrink-0\"\n                  @click.stop=\"deleteConversation(conv.id)\"\n                >\n                  <Trash2 class=\"h-3 w-3\" />\n                </Button>\n              </DropdownMenuItem>\n            </DropdownMenuContent>\n          </DropdownMenu>\n\n          <Button\n            title=\"清空对话内容\"\n            aria-label=\"清空对话内容\"\n            variant=\"ghost\"\n            size=\"icon\"\n            @click=\"resetMessages\"\n          >\n            <Trash2 class=\"h-4 w-4\" />\n          </Button>\n        </div>\n        <DialogDescription class=\"text-muted-foreground text-sm\">\n          使用 AI 助手帮助您编写和优化内容\n        </DialogDescription>\n      </DialogHeader>\n\n      <!-- ============ 快捷指令 ============ -->\n      <div\n        v-if=\"!configVisible\"\n        class=\"mb-3 flex flex-wrap gap-2 overflow-x-auto pb-1\"\n      >\n        <template v-if=\"quickCmdStore.commands.length\">\n          <Button\n            v-for=\"cmd in quickCmdStore.commands\"\n            :key=\"cmd.id\"\n            variant=\"secondary\"\n            size=\"sm\"\n            class=\"text-xs\"\n            @click=\"applyQuickCommand(cmd)\"\n          >\n            {{ cmd.label }}\n          </Button>\n        </template>\n        <template v-else>\n          <div\n            class=\"text-muted-foreground flex items-center gap-2 border rounded-md border-dashed px-3 py-1 text-xs\"\n          >\n            还没有任何快捷指令，点击右侧添加\n          </div>\n        </template>\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          title=\"管理指令\"\n          @click=\"cmdMgrOpen = true\"\n        >\n          <Plus class=\"h-4 w-4\" />\n        </Button>\n\n        <!-- 指令管理弹窗 -->\n        <QuickCommandManager v-model:open=\"cmdMgrOpen\" />\n      </div>\n\n      <!-- ============ 参数配置面板 ============ -->\n      <AIConfig\n        v-if=\"configVisible\"\n        class=\"mb-4 w-full border rounded-md p-4\"\n        @saved=\"handleConfigSaved\"\n      />\n\n      <!-- ============ 聊天内容 ============ -->\n      <div\n        v-if=\"!configVisible\"\n        class=\"custom-scroll space-y-3 chat-container mb-4 flex-1 overflow-y-auto pr-2\"\n      >\n        <div\n          v-for=\"(msg, index) in messages\"\n          :key=\"msg.id || index\"\n          class=\"relative flex\"\n          :class=\"msg.role === 'user' ? 'justify-end' : 'justify-start'\"\n        >\n          <div\n            class=\"ring-border/20 max-w-[75%] rounded-2xl px-4 py-2 text-sm leading-relaxed shadow-xs ring-1\"\n            :class=\"msg.role === 'user'\n              ? 'bg-black text-white dark:bg-primary dark:text-primary-foreground'\n              : 'bg-gray-100 text-gray-800 dark:bg-muted/60 dark:text-muted-foreground'\"\n          >\n            <!-- reasoning -->\n            <div v-if=\"msg.reasoning\" class=\"text-muted-foreground mb-1 italic\">\n              {{ msg.reasoning }}\n            </div>\n\n            <!-- 消息内容 -->\n            <div\n              class=\"whitespace-pre-wrap\"\n              :class=\"msg.content ? '' : 'animate-pulse text-muted-foreground'\"\n            >\n              {{\n                msg.content\n                  || (msg.role === 'assistant' && !msg.done ? '思考中…' : '')\n              }}\n            </div>\n\n            <!-- 工具按钮 -->\n            <div\n              class=\"mt-1 flex\"\n              :class=\"msg.role === 'user' ? 'justify-end' : 'justify-start'\"\n            >\n              <Button\n                v-if=\"index > 0 && !(msg.role === 'assistant' && index === messages.length - 1 && !msg.done)\"\n                variant=\"ghost\"\n                size=\"icon\"\n                class=\"ml-0 h-5 w-5 p-1\"\n                aria-label=\"复制内容\"\n                @click=\"copyToClipboard(msg.content, index)\"\n              >\n                <Check\n                  v-if=\"copiedIndex === index\"\n                  class=\"h-3 w-3 text-green-600\"\n                />\n                <Copy v-else class=\"text-muted-foreground h-3 w-3\" />\n              </Button>\n              <Button\n                v-if=\"msg.role === 'assistant' && msg.done && index === messages.length - 1\"\n                variant=\"ghost\"\n                size=\"icon\"\n                class=\"ml-1 h-5 w-5 p-1\"\n                aria-label=\"重新生成\"\n                @click=\"regenerateLast\"\n              >\n                <RefreshCcw class=\"text-muted-foreground h-3 w-3\" />\n              </Button>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <!-- ============ 输入框 ============ -->\n      <div v-if=\"!configVisible\" class=\"relative mt-2\">\n        <div\n          class=\"bg-background border-border flex flex-col items-baseline gap-2 border rounded-xl px-3 py-2 pr-12 shadow-inner\"\n        >\n          <Textarea\n            v-model=\"input\"\n            placeholder=\"说些什么… (Enter 发送，Shift+Enter 换行)\"\n            rows=\"2\"\n            class=\"custom-scroll min-h-16 w-full resize-none border-none bg-transparent p-0 focus-visible:outline-hidden focus:outline-hidden focus-visible:ring-0 focus:ring-0 focus-visible:ring-offset-0 focus:ring-offset-0 focus-visible:ring-transparent focus:ring-transparent\"\n            @keydown=\"handleKeydown\"\n          />\n\n          <!-- 引用全文按钮 -->\n          <Button\n            size=\"sm\"\n            variant=\"outline\"\n            class=\"h-8 flex items-center gap-1 rounded-md px-3 font-medium transition-colors duration-150\"\n            :class=\"[\n              isQuoteAllContent\n                ? 'bg-primary text-white border-primary dark:bg-white dark:text-black dark:border-white'\n                : 'bg-background text-muted-foreground border-border hover:text-foreground hover:border-foreground dark:bg-muted dark:text-gray-400 dark:hover:text-white dark:hover:border-white/60',\n            ]\"\n            aria-label=\"引用全文\"\n            @click=\"quoteAllContent\"\n          >\n            <component :is=\"isQuoteAllContent ? Check : Copy\" class=\"h-4 w-4\" />\n            <span class=\"text-xs\">引用全文</span>\n          </Button>\n\n          <!-- 发送 / 暂停按钮 -->\n          <Button\n            :disabled=\"!input.trim() && !loading\"\n            size=\"icon\"\n            :class=\"[\n              // eslint-disable-next-line vue/prefer-separate-static-class\n              'absolute bottom-3 right-3 rounded-full disabled:opacity-40',\n              // eslint-disable-next-line vue/prefer-separate-static-class\n              'bg-primary hover:bg-primary/90 text-primary-foreground',\n            ]\"\n            :aria-label=\"loading ? '暂停' : '发送'\"\n            @click=\"loading ? pauseStreaming() : sendMessage()\"\n          >\n            <Pause v-if=\"loading\" class=\"h-4 w-4\" />\n            <Send v-else class=\"h-4 w-4\" />\n          </Button>\n        </div>\n      </div>\n    </DialogContent>\n  </Dialog>\n</template>\n\n<style scoped>\n@reference 'tailwindcss';\n\n:root {\n  --safe-bottom: env(safe-area-inset-bottom);\n}\n\n/* 聊天容器底部内边距，适配安全区 */\n.chat-container {\n  padding-bottom: calc(1rem + var(--safe-bottom));\n}\n\n/* 让代码块可横向滚动 */\n.chat-container pre {\n  overflow-x: auto;\n}\n\n/* highlight.js 暗黑主题适配 */\n.dark .hljs {\n  background: #0d1117 !important;\n  color: #c9d1d9 !important;\n}\n\n/* 自定义滚动条 */\n@media (pointer: coarse) {\n  .custom-scroll::-webkit-scrollbar {\n    width: 3px;\n  }\n}\n.custom-scroll::-webkit-scrollbar {\n  width: 6px;\n}\n.custom-scroll::-webkit-scrollbar-thumb {\n  @apply rounded-full bg-gray-400/40 hover:bg-gray-400/60;\n  @apply dark:bg-gray-500/40 dark:hover:bg-gray-500/70;\n}\n.custom-scroll {\n  scrollbar-width: thin;\n  scrollbar-color: rgb(156 163 175 / 0.4) transparent;\n}\n.dark .custom-scroll {\n  scrollbar-color: rgb(107 114 128 / 0.4) transparent;\n}\n</style>\n"
  },
  {
    "path": "apps/web/src/components/ai/chat-box/AIConfig.vue",
    "content": "<script setup lang=\"ts\">\nimport { serviceOptions } from '@md/shared/configs'\nimport { DEFAULT_SERVICE_TYPE } from '@md/shared/constants'\nimport { Info } from 'lucide-vue-next'\nimport { PasswordInput } from '@/components/ui/password-input'\nimport useAIConfigStore from '@/stores/aiConfig'\n\n/* -------------------------- 基础数据 -------------------------- */\n\nconst emit = defineEmits([`saved`])\n\nconst AIConfigStore = useAIConfigStore()\nconst { type, endpoint, model, apiKey, temperature, maxToken } = storeToRefs(AIConfigStore)\n\n/** UI 状态 */\nconst loading = ref(false)\nconst testResult = ref(``)\n\n/** 当前服务信息 */\nconst currentService = computed(\n  () => serviceOptions.find(s => s.value === type.value) || serviceOptions[0],\n)\n\n/* -------------------------- 监听 -------------------------- */\n\n// 监听服务类型变化，清空测试结果\nwatch(type, () => {\n  testResult.value = ``\n})\n\n// 监听模型变化，清空测试结果\nwatch(model, () => {\n  testResult.value = ``\n})\n\n/* -------------------------- 操作 -------------------------- */\n\nfunction saveConfig(emitEvent = true) {\n  if (emitEvent) {\n    testResult.value = `✅ 配置已保存`\n    emit(`saved`)\n  }\n}\n\nfunction clearConfig() {\n  AIConfigStore.reset()\n  testResult.value = `🗑️ 当前 AI 配置已清除`\n}\n\nasync function testConnection() {\n  testResult.value = ``\n  loading.value = true\n\n  const headers: Record<string, string> = { 'Content-Type': `application/json` }\n  if (apiKey.value && type.value !== DEFAULT_SERVICE_TYPE)\n    headers.Authorization = `Bearer ${apiKey.value}`\n\n  try {\n    const url = new URL(endpoint.value)\n    if (!url.pathname.endsWith(`/chat/completions`))\n      url.pathname = url.pathname.replace(/\\/?$/, `/chat/completions`)\n\n    const payload = {\n      model: model.value,\n      messages: [{ role: `user`, content: `ping` }],\n      temperature: 0,\n      max_tokens: 1,\n      stream: false,\n    }\n\n    const res = await window.fetch(url.toString(), {\n      method: `POST`,\n      headers,\n      body: JSON.stringify(payload),\n    })\n\n    if (res.ok) {\n      testResult.value = `✅ 测试成功，/chat/completions 可用`\n      saveConfig(false)\n    }\n    else {\n      const text = await res.text()\n      try {\n        const { error } = JSON.parse(text)\n        if (\n          res.status === 404\n          && (error?.code === `ModelNotOpen`\n            || /not activated|未开通/i.test(error?.message))\n        ) {\n          testResult.value = `⚠️ 测试成功，但当前模型未开通：${model.value}`\n          saveConfig(false)\n          return\n        }\n      }\n      catch {}\n      testResult.value = `❌ 测试失败：${res.status} ${res.statusText}，${text}`\n    }\n  }\n  catch (err) {\n    testResult.value = `❌ 测试失败：${(err as Error).message}`\n  }\n  finally {\n    loading.value = false\n  }\n}\n</script>\n\n<template>\n  <div class=\"custom-scroll space-y-4 max-h-[calc(100dvh-10rem)] overflow-y-auto pr-1 text-xs sm:max-h-none sm:text-sm\">\n    <div class=\"font-medium\">\n      AI 配置\n    </div>\n\n    <!-- 服务类型 -->\n    <div>\n      <Label class=\"mb-1 block text-sm font-medium\">服务类型</Label>\n      <Select v-model=\"type\">\n        <SelectTrigger class=\"w-full\">\n          <SelectValue>\n            {{ currentService.label }}\n          </SelectValue>\n        </SelectTrigger>\n        <SelectContent>\n          <SelectItem\n            v-for=\"service in serviceOptions\"\n            :key=\"service.value\"\n            :value=\"service.value\"\n          >\n            {{ service.label }}\n          </SelectItem>\n        </SelectContent>\n      </Select>\n    </div>\n\n    <!-- API 端点 -->\n    <div v-if=\"type !== DEFAULT_SERVICE_TYPE\">\n      <Label class=\"mb-1 block text-sm font-medium\">API 端点</Label>\n      <Input\n        v-model=\"endpoint\"\n        placeholder=\"输入 API 端点 URL\"\n        class=\"focus:border-gray-400 focus:ring-1 focus:ring-gray-300\"\n      />\n    </div>\n\n    <!-- API 密钥，仅非 default 显示 -->\n    <div v-if=\"type !== DEFAULT_SERVICE_TYPE\">\n      <Label class=\"mb-1 block text-sm font-medium\">API 密钥</Label>\n      <PasswordInput\n        v-model=\"apiKey\"\n        placeholder=\"sk-...\"\n        class=\"focus:border-gray-400 focus:ring-1 focus:ring-gray-300\"\n      />\n    </div>\n\n    <!-- 模型名称 -->\n    <div>\n      <Label class=\"mb-1 block text-sm font-medium\">模型名称</Label>\n      <Select v-if=\"currentService.models.length > 0\" v-model=\"model\">\n        <SelectTrigger class=\"w-full\">\n          <SelectValue>\n            {{ model || '请选择模型' }}\n          </SelectValue>\n        </SelectTrigger>\n        <SelectContent>\n          <SelectItem\n            v-for=\"_model in currentService.models\"\n            :key=\"_model\"\n            :value=\"_model\"\n          >\n            {{ _model }}\n          </SelectItem>\n        </SelectContent>\n      </Select>\n      <Input\n        v-else\n        v-model=\"model\"\n        placeholder=\"输入模型名称\"\n        class=\"focus:border-gray-400 focus:ring-1 focus:ring-gray-300\"\n      />\n    </div>\n\n    <!-- 温度 temperature -->\n    <div>\n      <Label class=\"mb-1 flex items-center gap-1 text-sm font-medium\">\n        温度\n        <TooltipProvider>\n          <Tooltip>\n            <TooltipTrigger as-child>\n              <Info class=\"text-gray-500\" :size=\"16\" />\n            </TooltipTrigger>\n            <TooltipContent side=\"top\" class=\"z-[250]\">\n              <div>控制输出的随机性：较小值使输出更确定，较大值使其更随机。</div>\n            </TooltipContent>\n          </Tooltip>\n        </TooltipProvider>\n      </Label>\n      <Input\n        v-model.number=\"temperature\"\n        type=\"number\"\n        step=\"0.1\"\n        min=\"0\"\n        max=\"2\"\n        placeholder=\"0 ~ 2，默认 1\"\n        class=\"focus:border-gray-400 focus:ring-1 focus:ring-gray-300\"\n      />\n    </div>\n\n    <!-- 最大 Token 数 -->\n    <div>\n      <Label class=\"mb-1 block text-sm font-medium\">最大 Token 数</Label>\n      <Input\n        v-model.number=\"maxToken\"\n        type=\"number\"\n        min=\"1\"\n        max=\"32768\"\n        placeholder=\"比如 1024\"\n        class=\"focus:border-gray-400 focus:ring-1 focus:ring-gray-300\"\n      />\n    </div>\n\n    <!-- 操作按钮区域 -->\n    <div class=\"mt-2 flex flex-col gap-2 sm:flex-row\">\n      <Button size=\"sm\" @click=\"saveConfig\">\n        保存\n      </Button>\n      <Button size=\"sm\" variant=\"ghost\" @click=\"clearConfig\">\n        清空\n      </Button>\n      <Button\n        size=\"sm\"\n        variant=\"outline\"\n        :disabled=\"loading\"\n        @click=\"testConnection\"\n      >\n        {{ loading ? '测试中...' : '测试连接' }}\n      </Button>\n    </div>\n\n    <!-- 测试结果显示 -->\n    <div v-if=\"testResult\" class=\"mt-1 text-xs text-gray-500\">\n      {{ testResult }}\n    </div>\n  </div>\n</template>\n\n<style scoped>\n@reference 'tailwindcss';\n\n.custom-scroll::-webkit-scrollbar {\n  width: 6px;\n}\n@media (pointer: coarse) {\n  /* 触屏设备更细 */\n  .custom-scroll::-webkit-scrollbar {\n    width: 3px;\n  }\n}\n\n.custom-scroll::-webkit-scrollbar-thumb {\n  @apply rounded-full bg-gray-400/40 hover:bg-gray-400/60;\n  @apply dark:bg-gray-500/40 dark:hover:bg-gray-500/70;\n}\n.custom-scroll {\n  scrollbar-width: thin;\n  scrollbar-color: rgb(156 163 175 / 0.4) transparent;\n}\n.dark .custom-scroll {\n  scrollbar-color: rgb(107 114 128 / 0.4) transparent;\n}\n</style>\n"
  },
  {
    "path": "apps/web/src/components/ai/chat-box/QuickCommandManager.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, watch } from 'vue'\nimport { Button } from '@/components/ui/button'\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'\nimport { Input } from '@/components/ui/input'\nimport { Textarea } from '@/components/ui/textarea'\nimport { useQuickCommands } from '@/stores/quickCommands'\n\n/* ---------- 弹窗开关 ---------- */\nconst props = defineProps<{ open: boolean }>()\nconst emit = defineEmits([`update:open`])\n\nconst dialogOpen = ref(props.open)\nwatch(() => props.open, v => (dialogOpen.value = v))\nwatch(dialogOpen, v => emit(`update:open`, v))\n\n/* ---------- store & 新增 ---------- */\nconst store = useQuickCommands()\nconst label = ref(``)\nconst template = ref(``)\n\nfunction addCmd() {\n  if (!label.value.trim() || !template.value.trim())\n    return\n  store.add(label.value.trim(), template.value.trim())\n  label.value = ``\n  template.value = ``\n}\n\n/* ---------- 编辑 ---------- */\nconst editingId = ref<string | null>(null)\nconst editLabel = ref(``)\nconst editTemplate = ref(``)\n\nfunction beginEdit(cmd: { id: string, label: string, template: string }) {\n  editingId.value = cmd.id\n  editLabel.value = cmd.label\n  editTemplate.value = cmd.template\n}\nfunction cancelEdit() {\n  editingId.value = null\n}\nfunction saveEdit() {\n  if (!editLabel.value.trim() || !editTemplate.value.trim())\n    return\n  store.update(editingId.value!, editLabel.value.trim(), editTemplate.value.trim())\n  editingId.value = null\n}\n</script>\n\n<template>\n  <Dialog v-model:open=\"dialogOpen\">\n    <DialogContent\n      class=\"max-h-[90vh] w-[92vw] flex flex-col sm:max-w-lg\"\n    >\n      <DialogHeader>\n        <DialogTitle>管理快捷指令</DialogTitle>\n      </DialogHeader>\n\n      <!-- 列表：独立滚动区域 -->\n      <div class=\"space-y-4 flex-1 overflow-y-auto pr-1\">\n        <div\n          v-for=\"cmd in store.commands\"\n          :key=\"cmd.id\"\n          class=\"flex flex-col gap-2 border rounded-md p-3\"\n        >\n          <!-- 编辑态 -->\n          <template v-if=\"editingId === cmd.id\">\n            <Input v-model=\"editLabel\" placeholder=\"指令名称\" />\n            <Textarea\n              v-model=\"editTemplate\"\n              rows=\"2\"\n              placeholder=\"模板内容，支持 {{sel}} 占位\"\n            />\n            <div class=\"flex justify-end gap-2\">\n              <Button size=\"xs\" @click=\"saveEdit\">\n                保存\n              </Button>\n              <Button variant=\"ghost\" size=\"xs\" @click=\"cancelEdit\">\n                取消\n              </Button>\n            </div>\n          </template>\n\n          <!-- 查看态 -->\n          <template v-else>\n            <div class=\"flex items-center justify-between\">\n              <span class=\"break-all text-sm\">{{ cmd.label }}</span>\n              <div class=\"flex gap-1\">\n                <Button variant=\"ghost\" size=\"xs\" @click=\"beginEdit(cmd)\">\n                  编辑\n                </Button>\n                <Button variant=\"outline\" size=\"xs\" @click=\"store.remove(cmd.id)\">\n                  删除\n                </Button>\n              </div>\n            </div>\n          </template>\n        </div>\n      </div>\n\n      <!-- 新增表单：固定在滚动区下方 -->\n      <div class=\"space-y-2 mt-4 border rounded-md p-3\">\n        <Input v-model=\"label\" placeholder=\"指令名称 (如：改写为 SEO 文案)\" />\n        <Textarea\n          v-model=\"template\"\n          rows=\"2\"\n          placeholder=\"模板，可用 {{sel}} 占位，例如：\\n请把以下文字改写为 SEO 友好的标题：\\n\\n{{sel}}\"\n        />\n        <Button class=\"w-full\" @click=\"addCmd\">\n          添加\n        </Button>\n      </div>\n    </DialogContent>\n  </Dialog>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ai/image-generator/AIImageConfig.vue",
    "content": "<script setup lang=\"ts\">\nimport { imageServiceOptions } from '@md/shared/configs'\nimport { DEFAULT_SERVICE_TYPE } from '@md/shared/constants'\nimport { Info } from 'lucide-vue-next'\nimport { Button } from '@/components/ui/button'\nimport { Label } from '@/components/ui/label'\nimport { PasswordInput } from '@/components/ui/password-input'\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select'\nimport useAIImageConfigStore from '@/stores/aiImageConfig'\n\n/* -------------------------- 基础数据 -------------------------- */\n\nconst emit = defineEmits([`saved`])\n\nconst AIImageConfigStore = useAIImageConfigStore()\nconst { type, endpoint, model, apiKey, size, quality, style } = storeToRefs(AIImageConfigStore)\n\n/** UI 状态 */\nconst loading = ref(false)\nconst testResult = ref(``)\n\n/** 当前服务信息 */\nconst currentService = computed(\n  () => imageServiceOptions.find(s => s.value === type.value) || imageServiceOptions[0],\n)\n\n/* -------------------------- 监听 -------------------------- */\n\n// 监听服务类型变化，清空测试结果\nwatch(type, () => {\n  testResult.value = ``\n})\n\n// 监听模型变化，清空测试结果\nwatch(model, () => {\n  testResult.value = ``\n})\n\n// 监听端点变化，清空测试结果\nwatch(endpoint, () => {\n  testResult.value = ``\n})\n\n/* -------------------------- 表单提交 -------------------------- */\n\nfunction saveConfig() {\n  if (!endpoint.value.trim() || !model.value.trim()) {\n    testResult.value = `❌ 请检查配置项是否完整`\n    return\n  }\n\n  if (type.value !== DEFAULT_SERVICE_TYPE && !apiKey.value.trim()) {\n    testResult.value = `❌ 请输入 API Key`\n    return\n  }\n\n  try {\n    // eslint-disable-next-line no-new\n    new URL(endpoint.value)\n  }\n  catch {\n    testResult.value = `❌ 端点格式有误`\n    return\n  }\n\n  testResult.value = `✅ 配置已保存`\n  emit(`saved`)\n}\n\nfunction clearConfig() {\n  AIImageConfigStore.reset()\n  testResult.value = `🗑️ 当前 AI 图像配置已清除`\n}\n\nasync function testConnection() {\n  testResult.value = ``\n  loading.value = true\n\n  const headers: Record<string, string> = { 'Content-Type': `application/json` }\n  if (apiKey.value && type.value !== DEFAULT_SERVICE_TYPE)\n    headers.Authorization = `Bearer ${apiKey.value}`\n\n  try {\n    const url = new URL(endpoint.value)\n    if (!url.pathname.includes(`/images/`) && !url.pathname.endsWith(`/images/generations`)) {\n      url.pathname = url.pathname.replace(/\\/?$/, `/images/generations`)\n    }\n\n    const payload = {\n      model: model.value,\n      prompt: `test connection`,\n      size: size.value,\n      quality: quality.value,\n      style: style.value,\n      n: 1,\n    }\n\n    const res = await window.fetch(url.toString(), {\n      method: `POST`,\n      headers,\n      body: JSON.stringify(payload),\n    })\n\n    if (res.ok) {\n      testResult.value = `✅ 连接成功`\n    }\n    else {\n      const errorText = await res.text()\n      testResult.value = `❌ 连接失败：${res.status} ${errorText}`\n    }\n  }\n  catch (error) {\n    testResult.value = `❌ 连接失败：${(error as Error).message}`\n  }\n  finally {\n    loading.value = false\n  }\n}\n\n/* -------------------------- 图像尺寸选项 -------------------------- */\n\nconst sizeOptions = [\n  { label: `正方形 (1024x1024)`, value: `1024x1024` },\n  { label: `横版 (1792x1024)`, value: `1792x1024` },\n  { label: `竖版 (1024x1792)`, value: `1024x1792` },\n]\n\nconst qualityOptions = [\n  { label: `标准`, value: `standard` },\n  { label: `高清`, value: `hd` },\n]\n\nconst styleOptions = [\n  { label: `自然`, value: `natural` },\n  { label: `鲜明`, value: `vivid` },\n]\n</script>\n\n<template>\n  <div class=\"space-y-4 max-w-full\">\n    <div class=\"text-lg font-semibold border-b pb-2\">\n      AI 图像生成配置\n    </div>\n\n    <!-- 服务商选择 -->\n    <div>\n      <Label class=\"mb-1 block text-sm font-medium\">服务商</Label>\n      <Select v-model=\"type\">\n        <SelectTrigger class=\"w-full\">\n          <SelectValue>\n            {{ currentService.label }}\n          </SelectValue>\n        </SelectTrigger>\n        <SelectContent>\n          <SelectItem\n            v-for=\"option in imageServiceOptions\"\n            :key=\"option.value\"\n            :value=\"option.value\"\n          >\n            {{ option.label }}\n          </SelectItem>\n        </SelectContent>\n      </Select>\n    </div>\n\n    <!-- 端点配置 -->\n    <div>\n      <Label class=\"mb-1 block text-sm font-medium\">API 端点</Label>\n      <input\n        v-model=\"endpoint\"\n        type=\"url\"\n        class=\"w-full mt-1 p-2 border rounded-md bg-background focus:ring-2 focus:ring-primary focus:border-primary transition-colors\"\n        placeholder=\"https://api.openai.com/v1\"\n        :readonly=\"type !== 'custom'\"\n      >\n    </div>\n\n    <!-- API Key -->\n    <div v-if=\"type !== 'default'\">\n      <Label class=\"mb-1 block text-sm font-medium\">API Key</Label>\n      <PasswordInput\n        v-model=\"apiKey\"\n        class=\"w-full mt-1 focus:ring-2 focus:ring-primary focus:border-primary transition-colors\"\n        placeholder=\"sk-...\"\n      />\n    </div>\n\n    <!-- 模型选择 -->\n    <div>\n      <Label class=\"mb-1 block text-sm font-medium\">模型</Label>\n      <Select v-if=\"type !== 'custom' && currentService.models.length > 0\" v-model=\"model\">\n        <SelectTrigger class=\"w-full\">\n          <SelectValue>\n            {{ model || '请选择模型' }}\n          </SelectValue>\n        </SelectTrigger>\n        <SelectContent>\n          <SelectItem\n            v-for=\"modelName in currentService.models\"\n            :key=\"modelName\"\n            :value=\"modelName\"\n          >\n            {{ modelName }}\n          </SelectItem>\n        </SelectContent>\n      </Select>\n      <input\n        v-else\n        v-model=\"model\"\n        type=\"text\"\n        class=\"w-full mt-1 p-2 border rounded-md bg-background focus:ring-2 focus:ring-primary focus:border-primary transition-colors\"\n        placeholder=\"输入模型名称，如：dall-e-3\"\n      >\n    </div>\n\n    <!-- 图像尺寸 -->\n    <div>\n      <Label class=\"mb-1 block text-sm font-medium\">图像尺寸</Label>\n      <Select v-model=\"size\">\n        <SelectTrigger class=\"w-full\">\n          <SelectValue>\n            {{ sizeOptions.find(opt => opt.value === size)?.label || size }}\n          </SelectValue>\n        </SelectTrigger>\n        <SelectContent>\n          <SelectItem\n            v-for=\"option in sizeOptions\"\n            :key=\"option.value\"\n            :value=\"option.value\"\n          >\n            {{ option.label }}\n          </SelectItem>\n        </SelectContent>\n      </Select>\n    </div>\n\n    <!-- 图像质量 -->\n    <div v-if=\"model.includes('dall-e')\">\n      <Label class=\"mb-1 block text-sm font-medium\">图像质量</Label>\n      <Select v-model=\"quality\">\n        <SelectTrigger class=\"w-full\">\n          <SelectValue>\n            {{ qualityOptions.find(opt => opt.value === quality)?.label || quality }}\n          </SelectValue>\n        </SelectTrigger>\n        <SelectContent>\n          <SelectItem\n            v-for=\"option in qualityOptions\"\n            :key=\"option.value\"\n            :value=\"option.value\"\n          >\n            {{ option.label }}\n          </SelectItem>\n        </SelectContent>\n      </Select>\n    </div>\n\n    <!-- 图像风格 -->\n    <div v-if=\"model.includes('dall-e')\">\n      <Label class=\"mb-1 block text-sm font-medium\">图像风格</Label>\n      <Select v-model=\"style\">\n        <SelectTrigger class=\"w-full\">\n          <SelectValue>\n            {{ styleOptions.find(opt => opt.value === style)?.label || style }}\n          </SelectValue>\n        </SelectTrigger>\n        <SelectContent>\n          <SelectItem\n            v-for=\"option in styleOptions\"\n            :key=\"option.value\"\n            :value=\"option.value\"\n          >\n            {{ option.label }}\n          </SelectItem>\n        </SelectContent>\n      </Select>\n    </div>\n\n    <!-- 说明 -->\n    <div v-if=\"type === 'default'\" class=\"flex items-start gap-2 p-3 bg-blue-50 dark:bg-blue-950/30 rounded-md text-sm\">\n      <Info class=\"h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0\" />\n      <div class=\"text-blue-700 dark:text-blue-300\">\n        <p class=\"font-medium\">\n          默认图像服务\n        </p>\n        <p>免费使用，无需配置 API Key，支持 Kwai-Kolors/Kolors 模型。</p>\n      </div>\n    </div>\n\n    <!-- 自定义服务说明 -->\n    <div v-else-if=\"type === 'custom'\" class=\"flex items-start gap-2 p-3 bg-orange-50 dark:bg-orange-950/30 rounded-md text-sm\">\n      <Info class=\"h-4 w-4 text-orange-500 mt-0.5 flex-shrink-0\" />\n      <div class=\"text-orange-700 dark:text-orange-300\">\n        <p class=\"font-medium\">\n          自定义服务\n        </p>\n        <p>可配置任何兼容 OpenAI 图像生成 API 的服务，如自建的 API 代理或其他第三方服务。</p>\n        <p class=\"mt-1 text-xs\">\n          端点格式示例：https://your-api.com/v1\n        </p>\n      </div>\n    </div>\n\n    <!-- 操作按钮 -->\n    <div class=\"flex flex-wrap gap-2\">\n      <Button\n        type=\"button\"\n        class=\"flex-1 min-w-[100px]\"\n        @click=\"saveConfig\"\n      >\n        保存配置\n      </Button>\n      <Button\n        variant=\"outline\"\n        type=\"button\"\n        class=\"flex-1 min-w-[80px]\"\n        @click=\"clearConfig\"\n      >\n        清空\n      </Button>\n      <Button\n        size=\"sm\"\n        variant=\"outline\"\n        class=\"flex-1 min-w-[100px]\"\n        :disabled=\"loading\"\n        @click=\"testConnection\"\n      >\n        {{ loading ? '测试中...' : '测试连接' }}\n      </Button>\n    </div>\n\n    <!-- 测试结果显示 -->\n    <div v-if=\"testResult\" class=\"mt-1 text-xs text-gray-500\">\n      {{ testResult }}\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ai/image-generator/AIImageGeneratorPanel.vue",
    "content": "<script setup lang=\"ts\">\nimport {\n  Copy,\n  Download,\n  Image as ImageIcon,\n  Loader2,\n  MessageCircle,\n  RefreshCcw,\n  Settings,\n  Trash2,\n} from 'lucide-vue-next'\nimport { onBeforeUnmount, onMounted, ref, watch } from 'vue'\nimport { Button } from '@/components/ui/button'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog'\nimport { Textarea } from '@/components/ui/textarea'\nimport useAIImageConfigStore from '@/stores/aiImageConfig'\nimport { useEditorStore } from '@/stores/editor'\nimport { useUIStore } from '@/stores/ui'\nimport { copyPlain } from '@/utils/clipboard'\nimport { store } from '@/utils/storage'\nimport AIImageConfig from './AIImageConfig.vue'\n\n/* ---------- 组件属性 ---------- */\nconst props = defineProps<{ open: boolean }>()\nconst emit = defineEmits([`update:open`])\n\n/* ---------- 编辑器引用 ---------- */\nconst editorStore = useEditorStore()\nconst { editor } = storeToRefs(editorStore)\nconst uiStore = useUIStore()\nconst { toggleAIDialog } = uiStore\n\n/* ---------- 弹窗开关 ---------- */\nconst dialogVisible = ref(props.open)\nwatch(() => props.open, (val) => {\n  dialogVisible.value = val\n  // 每次打开面板时检查并清理过期图片\n  if (val) {\n    cleanExpiredImages()\n  }\n})\nwatch(dialogVisible, val => emit(`update:open`, val))\n\n/* ---------- 状态管理 ---------- */\nconst configVisible = ref(false)\nconst loading = ref(false)\nconst prompt = ref<string>(``)\nconst lastUsedPrompt = ref<string>(``) // 存储最后一次使用的提示词，用于重新生成\nconst generatedImages = ref<string[]>([])\nconst imagePrompts = ref<string[]>([]) // 存储每张图片对应的prompt\nconst imageTimestamps = ref<number[]>([]) // 存储每张图片的生成时间戳\nconst abortController = ref<AbortController | null>(null)\nconst currentImageIndex = ref(0)\nconst timeUpdateInterval = ref<NodeJS.Timeout | null>(null)\n\n/* ---------- AI 配置 ---------- */\nconst AIImageConfigStore = useAIImageConfigStore()\nconst { apiKey, endpoint, model, type, size, quality, style } = storeToRefs(AIImageConfigStore)\n\n/* ---------- 过期检查函数 ---------- */\nfunction isImageExpired(timestamp: number): boolean {\n  const EXPIRY_TIME = 60 * 60 * 1000 // 1小时，单位毫秒\n  const now = Date.now()\n  return now - timestamp > EXPIRY_TIME\n}\n\nasync function cleanExpiredImages() {\n  const savedImages = await store.get(`ai_generated_images`)\n  const savedTimestamps = await store.get(`ai_image_timestamps`)\n\n  if (!savedImages) {\n    return\n  }\n\n  const images = await store.getJSON(`ai_generated_images`, [])\n  const prompts = await store.getJSON(`ai_image_prompts`, [])\n  const timestamps = await store.getJSON(`ai_image_timestamps`, [])\n\n  // 如果没有时间戳数据，说明是旧版本，默认清除所有数据\n  if (!savedTimestamps || timestamps.length === 0) {\n    console.log(`🧹 检测到旧版本数据，清除所有过期图片`)\n    generatedImages.value = []\n    imagePrompts.value = []\n    imageTimestamps.value = []\n    await store.remove(`ai_generated_images`)\n    await store.remove(`ai_image_prompts`)\n    await store.remove(`ai_image_timestamps`)\n    return\n  }\n\n  // 过滤掉过期的图片\n  const validIndices: number[] = []\n  timestamps.forEach((timestamp: number, index: number) => {\n    if (!isImageExpired(timestamp)) {\n      validIndices.push(index)\n    }\n  })\n\n  const validImages = validIndices.map(i => images[i]).filter(Boolean)\n  const validPrompts = validIndices.map(i => prompts[i] || ``).filter((_, index) => validImages[index])\n  const validTimestamps = validIndices.map(i => timestamps[i]).filter(Boolean)\n\n  // 更新数据\n  generatedImages.value = validImages\n  imagePrompts.value = validPrompts\n  imageTimestamps.value = validTimestamps\n\n  // 如果有数据被清除，更新存储\n  if (validImages.length < images.length) {\n    console.log(`🧹 清除了 ${images.length - validImages.length} 张过期图片`)\n    if (validImages.length > 0) {\n      await store.setJSON(`ai_generated_images`, validImages)\n      await store.setJSON(`ai_image_prompts`, validPrompts)\n      await store.setJSON(`ai_image_timestamps`, validTimestamps)\n    }\n    else {\n      await store.remove(`ai_generated_images`)\n      await store.remove(`ai_image_prompts`)\n      await store.remove(`ai_image_timestamps`)\n    }\n  }\n\n  console.log(`📊 过期检查完成，有效图片数量:`, validImages.length)\n}\n\n/* ---------- 初始数据 ---------- */\nonMounted(async () => {\n  // 先进行过期检查和清理\n  await cleanExpiredImages()\n\n  // 确保数组长度一致\n  const imagesLength = generatedImages.value.length\n  const promptsLength = imagePrompts.value.length\n  const timestampsLength = imageTimestamps.value.length\n\n  const maxLength = Math.max(imagesLength, promptsLength, timestampsLength)\n\n  if (imagesLength < maxLength) {\n    // 如果图片少于其他数组，说明数据不一致，清除所有数据\n    console.warn(`⚠️ 数据不一致，清除所有数据`)\n    generatedImages.value = []\n    imagePrompts.value = []\n    imageTimestamps.value = []\n    await store.remove(`ai_generated_images`)\n    await store.remove(`ai_image_prompts`)\n    await store.remove(`ai_image_timestamps`)\n  }\n  else {\n    // 补齐较短的数组\n    if (promptsLength < imagesLength) {\n      imagePrompts.value = [...imagePrompts.value, ...Array.from({ length: imagesLength - promptsLength }, () => ``)]\n    }\n    if (timestampsLength < imagesLength) {\n      imageTimestamps.value = [...imageTimestamps.value, ...Array.from({ length: imagesLength - timestampsLength }, () => Date.now())]\n    }\n  }\n\n  // 启动定时器，每30秒检查一次过期图片并更新时间显示\n  timeUpdateInterval.value = setInterval(() => {\n    // 检查并清理过期图片\n    if (generatedImages.value.length > 0) {\n      cleanExpiredImages()\n    }\n  }, 30000) // 30秒\n})\n\nonBeforeUnmount(() => {\n  // 清除定时器\n  if (timeUpdateInterval.value) {\n    clearInterval(timeUpdateInterval.value)\n    timeUpdateInterval.value = null\n  }\n})\n\n/* ---------- 事件处理 ---------- */\nfunction handleConfigSaved() {\n  configVisible.value = false\n}\n\nfunction switchToChat() {\n  // 先关闭当前文生图对话框\n  emit(`update:open`, false)\n  // 然后打开聊天对话框\n  setTimeout(() => {\n    toggleAIDialog(true)\n  }, 100)\n}\n\nfunction handleKeydown(e: KeyboardEvent) {\n  if (e.isComposing || e.keyCode === 229)\n    return\n\n  if (e.key === `Enter` && !e.shiftKey) {\n    e.preventDefault()\n    generateImage()\n  }\n}\n\n/* ---------- 生成图像 ---------- */\nasync function generateImage() {\n  if (!prompt.value.trim() || loading.value)\n    return\n\n  // 保存当前提示词用于重新生成\n  const currentPrompt = prompt.value.trim()\n  lastUsedPrompt.value = currentPrompt\n\n  loading.value = true\n  abortController.value = new AbortController()\n\n  const headers: Record<string, string> = { 'Content-Type': `application/json` }\n  if (apiKey.value && type.value !== `default`)\n    headers.Authorization = `Bearer ${apiKey.value}`\n\n  try {\n    const url = new URL(endpoint.value)\n    if (!url.pathname.includes(`/images/`) && !url.pathname.endsWith(`/images/generations`)) {\n      url.pathname = url.pathname.replace(/\\/?$/, `/images/generations`)\n    }\n\n    const payload: any = {\n      model: model.value,\n      prompt: currentPrompt,\n      size: size.value,\n      n: 1,\n    }\n\n    // 只对 DALL-E 模型添加额外参数\n    if (model.value.includes(`dall-e`)) {\n      payload.quality = quality.value\n      payload.style = style.value\n    }\n\n    const res = await window.fetch(url.toString(), {\n      method: `POST`,\n      headers,\n      body: JSON.stringify(payload),\n      signal: abortController.value.signal,\n    })\n\n    if (!res.ok) {\n      const errorText = await res.text()\n      throw new Error(`${res.status}: ${errorText}`)\n    }\n\n    const data = await res.json()\n\n    if (data.data && data.data.length > 0) {\n      const imageUrl = data.data[0].url || data.data[0].b64_json\n\n      if (imageUrl) {\n        // 如果是 base64 格式，转换为 data URL\n        const finalUrl = imageUrl.startsWith(`data:`) || imageUrl.startsWith(`http`)\n          ? imageUrl\n          : `data:image/png;base64,${imageUrl}`\n\n        const currentTimestamp = Date.now()\n\n        generatedImages.value.unshift(finalUrl)\n        imagePrompts.value.unshift(currentPrompt) // 保存对应的prompt\n        imageTimestamps.value.unshift(currentTimestamp) // 保存生成时间戳\n        currentImageIndex.value = 0\n\n        // 限制存储的图片数量，避免占用过多存储空间\n        if (generatedImages.value.length > 20) {\n          generatedImages.value = generatedImages.value.slice(0, 20)\n          imagePrompts.value = imagePrompts.value.slice(0, 20)\n          imageTimestamps.value = imageTimestamps.value.slice(0, 20)\n        }\n\n        await store.setJSON(`ai_generated_images`, generatedImages.value)\n        await store.setJSON(`ai_image_prompts`, imagePrompts.value)\n        await store.setJSON(`ai_image_timestamps`, imageTimestamps.value)\n\n        // 清空输入框\n        prompt.value = ``\n      }\n    }\n    else {\n      throw new Error(`未收到有效的图像数据`)\n    }\n  }\n  catch (e) {\n    if ((e as Error).name === `AbortError`) {\n      console.log(`图像生成请求中止`)\n    }\n    else {\n      console.error(`图像生成失败:`, e)\n      // 可以在这里添加错误提示\n    }\n  }\n  finally {\n    loading.value = false\n    abortController.value = null\n  }\n}\n\n/* ---------- 取消生成 ---------- */\nfunction cancelGeneration() {\n  if (abortController.value) {\n    abortController.value.abort()\n    abortController.value = null\n  }\n  loading.value = false\n}\n\n/* ---------- 清空图像 ---------- */\nasync function clearImages() {\n  generatedImages.value = []\n  imagePrompts.value = []\n  imageTimestamps.value = []\n  currentImageIndex.value = 0\n  await store.remove(`ai_generated_images`)\n  await store.remove(`ai_image_prompts`)\n  await store.remove(`ai_image_timestamps`)\n}\n\n/* ---------- 下载图像 ---------- */\nasync function downloadImage(imageUrl: string, index: number) {\n  try {\n    const response = await fetch(imageUrl)\n    const blob = await response.blob()\n    const url = window.URL.createObjectURL(blob)\n    const a = document.createElement(`a`)\n    a.href = url\n\n    // 生成包含prompt信息的文件名\n    const relatedPrompt = imagePrompts.value[index] || ``\n    const promptPart = relatedPrompt\n      ? relatedPrompt.substring(0, 20).replace(/[^\\w\\s-]/g, ``).replace(/\\s+/g, `-`)\n      : `no-prompt`\n    a.download = `ai-image-${index + 1}-${promptPart}.png`\n\n    document.body.appendChild(a)\n    a.click()\n    document.body.removeChild(a)\n    window.URL.revokeObjectURL(url)\n  }\n  catch (error) {\n    console.error(`下载图像失败:`, error)\n  }\n}\n\n/* ---------- 复制图像URL ---------- */\nasync function copyImageUrl(imageUrl: string) {\n  try {\n    await copyPlain(imageUrl)\n    console.log(`✅ 图片链接已复制到剪贴板`)\n    if (typeof toast !== `undefined`) {\n      toast.success(`图片链接已复制到剪贴板`)\n    }\n  }\n  catch (error) {\n    console.error(`❌ 复制失败:`, error)\n    if (typeof toast !== `undefined`) {\n      toast.error(`复制失败，请重试`)\n    }\n  }\n}\n\n/* ---------- 重新生成 ---------- */\nfunction regenerateImage() {\n  // 使用当前图片对应的prompt\n  const currentPrompt = imagePrompts.value[currentImageIndex.value]\n  if (currentPrompt) {\n    console.log(`🔄 重新生成图像，使用当前图片的prompt:`, currentPrompt)\n    // 直接使用当前图片的prompt生成，不修改输入框内容\n    regenerateWithPrompt(currentPrompt)\n  }\n  else {\n    console.warn(`⚠️ 没有找到当前图片的prompt`)\n  }\n}\n\n/* ---------- 使用指定prompt重新生成 ---------- */\nasync function regenerateWithPrompt(promptText: string) {\n  if (!promptText.trim() || loading.value)\n    return\n\n  loading.value = true\n  abortController.value = new AbortController()\n\n  const headers: Record<string, string> = { 'Content-Type': `application/json` }\n  if (apiKey.value && type.value !== `default`)\n    headers.Authorization = `Bearer ${apiKey.value}`\n\n  try {\n    const url = new URL(endpoint.value)\n    if (!url.pathname.includes(`/images/`) && !url.pathname.endsWith(`/images/generations`)) {\n      url.pathname = url.pathname.replace(/\\/?$/, `/images/generations`)\n    }\n\n    const payload: any = {\n      model: model.value,\n      prompt: promptText.trim(),\n      size: size.value,\n      n: 1,\n    }\n\n    // 只对 DALL-E 模型添加额外参数\n    if (model.value.includes(`dall-e`)) {\n      payload.quality = quality.value\n      payload.style = style.value\n    }\n\n    const res = await window.fetch(url.toString(), {\n      method: `POST`,\n      headers,\n      body: JSON.stringify(payload),\n      signal: abortController.value.signal,\n    })\n\n    if (!res.ok) {\n      const errorText = await res.text()\n      throw new Error(`${res.status}: ${errorText}`)\n    }\n\n    const data = await res.json()\n\n    if (data.data && data.data.length > 0) {\n      const imageUrl = data.data[0].url || data.data[0].b64_json\n\n      if (imageUrl) {\n        // 如果是 base64 格式，转换为 data URL\n        const finalUrl = imageUrl.startsWith(`data:`) || imageUrl.startsWith(`http`)\n          ? imageUrl\n          : `data:image/png;base64,${imageUrl}`\n\n        const currentTimestamp = Date.now()\n\n        generatedImages.value.unshift(finalUrl)\n        imagePrompts.value.unshift(promptText.trim()) // 保存对应的prompt\n        imageTimestamps.value.unshift(currentTimestamp) // 保存生成时间戳\n        currentImageIndex.value = 0\n\n        // 限制存储的图片数量，避免占用过多存储空间\n        if (generatedImages.value.length > 20) {\n          generatedImages.value = generatedImages.value.slice(0, 20)\n          imagePrompts.value = imagePrompts.value.slice(0, 20)\n          imageTimestamps.value = imageTimestamps.value.slice(0, 20)\n        }\n\n        await store.setJSON(`ai_generated_images`, generatedImages.value)\n        await store.setJSON(`ai_image_prompts`, imagePrompts.value)\n        await store.setJSON(`ai_image_timestamps`, imageTimestamps.value)\n      }\n    }\n    else {\n      throw new Error(`未收到有效的图像数据`)\n    }\n  }\n  catch (e) {\n    if ((e as Error).name === `AbortError`) {\n      console.log(`图像生成请求中止`)\n    }\n    else {\n      console.error(`图像生成失败:`, e)\n    }\n  }\n  finally {\n    loading.value = false\n    abortController.value = null\n  }\n}\n\n/* ---------- 切换图像 ---------- */\nfunction previousImage() {\n  if (currentImageIndex.value > 0) {\n    currentImageIndex.value--\n  }\n}\n\nfunction nextImage() {\n  if (currentImageIndex.value < generatedImages.value.length - 1) {\n    currentImageIndex.value++\n  }\n}\n\n/* ---------- 插入图像到光标位置 ---------- */\nfunction insertImageToCursor(imageUrl: string) {\n  if (!editor.value) {\n    console.warn(`编辑器未初始化`)\n    return\n  }\n\n  try {\n    // 获取当前图片对应的prompt\n    const imagePrompt = imagePrompts.value[currentImageIndex.value] || ``\n    console.log(`🔗 插入图片，使用关联的prompt:`, imagePrompt)\n\n    // 生成简洁的alt文本\n    const altText = imagePrompt.trim()\n      ? imagePrompt.trim().substring(0, 30).replace(/\\n/g, ` `)\n      : `AI生成的图像`\n\n    // 生成Markdown图片语法\n    const markdownImage = `![${altText}](${imageUrl})`\n\n    // 获取当前光标位置并插入\n    const pos = editor.value.state.selection.main.head\n    editor.value.dispatch({\n      changes: { from: pos, insert: markdownImage },\n      selection: { anchor: pos + markdownImage.length },\n    })\n\n    // 聚焦编辑器\n    editor.value.focus()\n\n    // 关闭弹窗\n    dialogVisible.value = false\n\n    console.log(`✅ 图像已成功插入到光标位置`)\n  }\n  catch (error) {\n    console.error(`❌ 插入图像到光标位置失败:`, error)\n  }\n}\n\n/* ---------- 查看大图 ---------- */\nfunction viewFullImage(imageUrl: string) {\n  console.log(`🔍 点击查看大图:`, imageUrl)\n  if (!imageUrl) {\n    console.error(`❌ 图片URL为空`)\n    return\n  }\n\n  try {\n    // 在新窗口中打开图片\n    const newWindow = window.open(imageUrl, `_blank`, `width=800,height=600,scrollbars=yes,resizable=yes`)\n    if (!newWindow) {\n      console.error(`❌ 无法打开新窗口，可能被浏览器阻止`)\n      // 备用方案：在当前标签页打开\n      window.open(imageUrl, `_blank`)\n    }\n  }\n  catch (error) {\n    console.error(`❌ 打开图片失败:`, error)\n  }\n}\n\n/* ---------- 时间相关函数 ---------- */\nconst currentTime = ref(Date.now())\n\n// 每秒更新当前时间，用于实时显示剩余时间\nonMounted(() => {\n  const updateTime = () => {\n    currentTime.value = Date.now()\n  }\n\n  // 启动定时器更新时间显示\n  const timeDisplayInterval = setInterval(updateTime, 1000)\n\n  // 组件卸载时清理定时器\n  onBeforeUnmount(() => {\n    clearInterval(timeDisplayInterval)\n  })\n})\n\nfunction getTimeRemaining(index: number): string {\n  if (!imageTimestamps.value[index]) {\n    return `未知`\n  }\n\n  const EXPIRY_TIME = 60 * 60 * 1000 // 1小时\n  const timestamp = imageTimestamps.value[index]\n  const elapsed = currentTime.value - timestamp\n  const remaining = EXPIRY_TIME - elapsed\n\n  if (remaining <= 0) {\n    return `已过期`\n  }\n\n  const minutes = Math.floor(remaining / (60 * 1000))\n  const seconds = Math.floor((remaining % (60 * 1000)) / 1000)\n\n  if (minutes > 0) {\n    return `${minutes}分${seconds}秒`\n  }\n  else {\n    return `${seconds}秒`\n  }\n}\n\nfunction getTimeRemainingClass(index: number): string {\n  if (!imageTimestamps.value[index]) {\n    return `text-muted-foreground`\n  }\n\n  const EXPIRY_TIME = 60 * 60 * 1000 // 1小时\n  const timestamp = imageTimestamps.value[index]\n  const elapsed = currentTime.value - timestamp\n  const remaining = EXPIRY_TIME - elapsed\n\n  if (remaining <= 0) {\n    return `text-red-500 font-medium`\n  }\n  else if (remaining < 10 * 60 * 1000) { // 少于10分钟\n    return `text-orange-500 font-medium`\n  }\n  else if (remaining < 30 * 60 * 1000) { // 少于30分钟\n    return `text-yellow-600`\n  }\n  else {\n    return `text-green-600`\n  }\n}\n</script>\n\n<template>\n  <Dialog v-model:open=\"dialogVisible\">\n    <DialogContent\n      class=\"bg-card text-card-foreground flex flex-col w-[95vw] max-h-[90vh] sm:max-h-[85vh] sm:max-w-4xl overflow-y-auto\"\n    >\n      <!-- ============ 头部 ============ -->\n      <DialogHeader class=\"space-y-1 flex flex-col items-start\">\n        <div class=\"space-x-1 flex items-center\">\n          <DialogTitle>AI 文生图</DialogTitle>\n\n          <Button\n            :title=\"configVisible ? 'AI 文生图' : '配置参数'\"\n            :aria-label=\"configVisible ? 'AI 文生图' : '配置参数'\"\n            variant=\"ghost\"\n            size=\"icon\"\n            @click=\"configVisible = !configVisible\"\n          >\n            <ImageIcon v-if=\"configVisible\" class=\"h-4 w-4\" />\n            <Settings v-else class=\"h-4 w-4\" />\n          </Button>\n\n          <Button\n            title=\"AI 对话\"\n            aria-label=\"AI 对话\"\n            variant=\"ghost\"\n            size=\"icon\"\n            @click=\"switchToChat()\"\n          >\n            <MessageCircle class=\"h-4 w-4\" />\n          </Button>\n\n          <Button\n            title=\"清空图像\"\n            aria-label=\"清空图像\"\n            variant=\"ghost\"\n            size=\"icon\"\n            @click=\"clearImages\"\n          >\n            <Trash2 class=\"h-4 w-4\" />\n          </Button>\n        </div>\n        <DialogDescription class=\"text-muted-foreground text-sm\">\n          使用 AI 根据文字描述生成图像\n        </DialogDescription>\n      </DialogHeader>\n\n      <!-- ============ 参数配置面板 ============ -->\n      <div\n        v-if=\"configVisible\"\n        class=\"mb-4 w-full border rounded-md p-4 max-h-[60vh] overflow-y-auto flex-shrink-0\"\n      >\n        <AIImageConfig @saved=\"handleConfigSaved\" />\n      </div>\n\n      <!-- ============ 图像展示区域 ============ -->\n      <div\n        v-if=\"!configVisible && (loading || generatedImages.length > 0)\"\n        class=\"flex flex-col space-y-4 flex-shrink-0\"\n      >\n        <!-- 图像显示 -->\n        <div class=\"flex items-center justify-center bg-gray-50 dark:bg-gray-800 rounded-lg min-h-[250px] sm:min-h-[300px]\">\n          <div v-if=\"loading\" class=\"flex flex-col items-center gap-4\">\n            <Loader2 class=\"h-8 w-8 animate-spin text-primary\" />\n            <p class=\"text-sm text-muted-foreground\">\n              正在生成图像...\n            </p>\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              @click=\"cancelGeneration\"\n            >\n              取消生成\n            </Button>\n          </div>\n\n          <div v-else-if=\"generatedImages.length > 0\" class=\"w-full flex flex-col space-y-3\">\n            <!-- 图像导航 -->\n            <div v-if=\"generatedImages.length > 1\" class=\"flex items-center justify-between p-2 bg-muted/20 rounded\">\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                :disabled=\"currentImageIndex <= 0\"\n                @click=\"previousImage\"\n              >\n                上一张\n              </Button>\n              <span class=\"text-sm text-muted-foreground\">\n                {{ currentImageIndex + 1 }} / {{ generatedImages.length }}\n              </span>\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                :disabled=\"currentImageIndex >= generatedImages.length - 1\"\n                @click=\"nextImage\"\n              >\n                下一张\n              </Button>\n            </div>\n\n            <!-- 图像显示 -->\n            <div class=\"flex items-center justify-center p-2 sm:p-4\">\n              <div class=\"relative group cursor-pointer w-full max-w-sm\" @click=\"viewFullImage(generatedImages[currentImageIndex])\">\n                <img\n                  :src=\"generatedImages[currentImageIndex]\"\n                  :alt=\"`生成的图像 ${currentImageIndex + 1}`\"\n                  class=\"w-full h-auto max-h-[300px] sm:max-h-[350px] object-contain rounded-lg shadow-lg border border-border transition-transform hover:scale-105\"\n                >\n                <!-- 点击查看大图提示 -->\n                <div class=\"absolute inset-0 bg-black/0 group-hover:bg-black/10 rounded-lg flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none\">\n                  <div class=\"bg-black/70 text-white px-3 py-1 rounded-md text-sm\">\n                    点击查看大图\n                  </div>\n                </div>\n              </div>\n            </div>\n\n            <!-- 图像信息 -->\n            <div class=\"px-2 sm:px-4 py-2 bg-muted/10 rounded space-y-1\">\n              <p class=\"text-xs text-muted-foreground text-center\">\n                尺寸: {{ size }}\n              </p>\n              <!-- 提示词 -->\n              <div class=\"text-xs text-muted-foreground break-words text-center\">\n                <span class=\"font-medium\">提示词:</span>\n                <span class=\"ml-1\">{{ imagePrompts[currentImageIndex] || '无关联提示词' }}</span>\n              </div>\n              <div class=\"text-xs text-muted-foreground text-center\">\n                <span class=\"font-medium\">剩余有效期:</span>\n                <span class=\"ml-1\" :class=\"getTimeRemainingClass(currentImageIndex)\">\n                  {{ getTimeRemaining(currentImageIndex) }}\n                </span>\n                <span class=\"font-medium\">，请及时下载保存</span>\n              </div>\n            </div>\n\n            <!-- 图像操作按钮 -->\n            <div class=\"flex flex-wrap justify-center gap-2 p-2 sm:p-4 bg-muted/20 border-t border-border rounded-b-lg\">\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                class=\"flex-shrink-0 bg-background text-xs sm:text-sm\"\n                @click=\"insertImageToCursor(generatedImages[currentImageIndex])\"\n              >\n                <ImageIcon class=\"h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2\" />\n                插入\n              </Button>\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                class=\"flex-shrink-0 bg-background text-xs sm:text-sm\"\n                @click=\"downloadImage(generatedImages[currentImageIndex], currentImageIndex)\"\n              >\n                <Download class=\"h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2\" />\n                下载\n              </Button>\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                class=\"flex-shrink-0 bg-background text-xs sm:text-sm\"\n                @click=\"copyImageUrl(generatedImages[currentImageIndex])\"\n              >\n                <Copy class=\"h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2\" />\n                复制\n              </Button>\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                class=\"flex-shrink-0 bg-background text-xs sm:text-sm\"\n                @click=\"regenerateImage\"\n              >\n                <RefreshCcw class=\"h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2\" />\n                重新生成\n              </Button>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <!-- ============ 输入框 ============ -->\n      <div v-if=\"!configVisible\" class=\"relative flex-shrink-0 mt-auto\">\n        <div\n          class=\"bg-background border-border flex flex-col items-baseline gap-2 border rounded-xl px-3 py-2 pr-12 shadow-inner\"\n        >\n          <Textarea\n            v-model=\"prompt\"\n            placeholder=\"描述你想要生成的图像... (Enter 生成，Shift+Enter 换行)\"\n            rows=\"2\"\n            class=\"custom-scroll min-h-16 w-full resize-none border-none bg-transparent p-0 focus-visible:outline-hidden focus:outline-hidden focus-visible:ring-0 focus:ring-0 focus-visible:ring-offset-0 focus:ring-offset-0 focus-visible:ring-transparent focus:ring-transparent\"\n            @keydown=\"handleKeydown\"\n          />\n\n          <!-- 生成按钮 -->\n          <Button\n            :disabled=\"!prompt.trim() && !loading\"\n            size=\"icon\"\n            :class=\"[\n              // eslint-disable-next-line vue/prefer-separate-static-class\n              'absolute bottom-3 right-3 rounded-full disabled:opacity-40',\n              // eslint-disable-next-line vue/prefer-separate-static-class\n              'bg-primary hover:bg-primary/90 text-primary-foreground',\n            ]\"\n            :aria-label=\"loading ? '取消' : '生成'\"\n            @click=\"loading ? cancelGeneration() : generateImage()\"\n          >\n            <Loader2 v-if=\"loading\" class=\"h-4 w-4 animate-spin\" />\n            <ImageIcon v-else class=\"h-4 w-4\" />\n          </Button>\n        </div>\n      </div>\n    </DialogContent>\n  </Dialog>\n</template>\n\n<style scoped>\n.custom-scroll::-webkit-scrollbar {\n  width: 6px;\n}\n@media (pointer: coarse) {\n  /* 触屏设备更细 */\n  .custom-scroll::-webkit-scrollbar {\n    width: 3px;\n  }\n}\n\n.custom-scroll::-webkit-scrollbar-thumb {\n  border-radius: 9999px;\n  background-color: rgba(156, 163, 175, 0.4);\n}\n\n.custom-scroll::-webkit-scrollbar-thumb:hover {\n  background-color: rgba(156, 163, 175, 0.6);\n}\n\nhtml.dark .custom-scroll::-webkit-scrollbar-thumb {\n  background-color: rgba(107, 114, 128, 0.4);\n}\n\nhtml.dark .custom-scroll::-webkit-scrollbar-thumb:hover {\n  background-color: rgba(107, 114, 128, 0.7);\n}\n\n.custom-scroll {\n  scrollbar-width: thin;\n}\n</style>\n"
  },
  {
    "path": "apps/web/src/components/ai/image-generator/index.ts",
    "content": "export { default as AIImageConfig } from './AIImageConfig.vue'\nexport { default as AIImageGeneratorPanel } from './AIImageGeneratorPanel.vue'\n"
  },
  {
    "path": "apps/web/src/components/ai/index.ts",
    "content": "export * from './image-generator'\nexport { default as SidebarAIToolbar } from './SidebarAIToolbar.vue'\nexport * from './tool-box'\n"
  },
  {
    "path": "apps/web/src/components/ai/tool-box/ToolBoxPopover.vue",
    "content": "<script setup lang=\"ts\">\nimport { Pause, Settings, Wand2, X } from 'lucide-vue-next'\nimport { Button } from '@/components/ui/button'\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog'\nimport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select'\nimport useAIConfigStore from '@/stores/aiConfig'\nimport { useEditorStore } from '@/stores/editor'\n\n/* -------------------- props / emits -------------------- */\nconst props = defineProps<{\n  open: boolean\n  selectedText: string\n  isMobile: boolean\n}>()\nconst emit = defineEmits([`update:open`])\n\n/* -------------------- reactive state -------------------- */\nconst configVisible = ref(false)\nconst dialogVisible = ref(props.open)\nconst message = ref(``)\nconst loading = ref(false)\nconst abortController = ref<AbortController | null>(null)\nconst customPrompts = ref<string[]>([])\nconst hasResult = ref(false)\nconst selectedAction = ref<\n  `optimize` | `summarize` | `spellcheck` | `translate-zh` | `translate-en` | `custom`\n>(`optimize`)\nconst currentText = ref(``)\nconst error = ref(``)\n\n/* -------------------- store & refs -------------------- */\nconst editorStore = useEditorStore()\nconst resultContainer = ref<HTMLElement | null>(null)\n\n/* -------------------- dialog state sync -------------------- */\nwatch(() => props.open, (val) => {\n  dialogVisible.value = val\n  if (val && props.selectedText.trim()) {\n    currentText.value = props.selectedText\n    resetState()\n  }\n})\nwatch(dialogVisible, val => emit(`update:open`, val))\n\n/* -------------------- AI config -------------------- */\nconst AIConfigStore = useAIConfigStore()\nconst { apiKey, endpoint, model, temperature, maxToken, type }\n  = storeToRefs(AIConfigStore)\n\n/* -------------------- action options -------------------- */\ninterface ActionOption {\n  value: string\n  label: string\n  defaultPrompt: string\n}\n\nconst actionOptions: ActionOption[] = [\n  {\n    value: `optimize`,\n    label: `优化文本`,\n    defaultPrompt: `请优化文本，使其更通顺易读。`,\n  },\n  {\n    value: `summarize`,\n    label: `文章总结`,\n    defaultPrompt: `请对文本进行摘要，输出主要观点和结论。`,\n  },\n  {\n    value: `spellcheck`,\n    label: `错别字纠正`,\n    defaultPrompt: `请找出并纠正文本中的错别字、标点和语法错误。`,\n  },\n  {\n    value: `translate-zh`,\n    label: `翻译为中文`,\n    defaultPrompt: `请将文本翻译为地道的中文。`,\n  },\n  {\n    value: `translate-en`,\n    label: `翻译为英文`,\n    defaultPrompt: `请将文本翻译为自然流畅的英文。`,\n  },\n  { value: `custom`, label: `自定义`, defaultPrompt: `` },\n]\n\n/* -------------------- watchers -------------------- */\nwatch(message, async () => {\n  await nextTick()\n  resultContainer.value?.scrollTo({ top: resultContainer.value.scrollHeight })\n})\n\nwatch(selectedAction, (val) => {\n  if (val !== `custom`)\n    customPrompts.value = []\n})\n\n// 当 dialogVisible 且 props.selectedText 变更时，更新原文并重置状态\nwatch(\n  () => props.selectedText,\n  (val) => {\n    if (dialogVisible.value) {\n      currentText.value = val\n      resetState()\n    }\n  },\n)\n\n/* -------------------- prompt handlers -------------------- */\nfunction addPrompt(e: KeyboardEvent) {\n  const input = e.target as HTMLInputElement\n  const prompt = input.value.trim()\n  if (prompt && !customPrompts.value.includes(prompt)) {\n    customPrompts.value.push(prompt)\n  }\n  input.value = ``\n}\n\nfunction removePrompt(index: number) {\n  customPrompts.value.splice(index, 1)\n}\n\nfunction resetState() {\n  message.value = ``\n  loading.value = false\n  hasResult.value = false\n  error.value = ``\n\n  abortController.value?.abort()\n  abortController.value = null\n}\n\n/* -------------------- AI call -------------------- */\nasync function runAIAction() {\n  const text = currentText.value.trim()\n  if (!text || loading.value)\n    return\n\n  resetState()\n  loading.value = true\n  abortController.value = new AbortController()\n\n  const systemPrompt\n    = `你是一名专业的多语言文本助手，请根据用户的指令处理下列内容。在输出时，不要输出任何额外的信息，只输出处理后的文本。`\n  const picked = actionOptions.find(o => o.value === selectedAction.value)!\n  const parts: string[] = []\n\n  if (picked.defaultPrompt)\n    parts.push(picked.defaultPrompt)\n  if (customPrompts.value.length)\n    parts.push(`请同时满足以下要求：${customPrompts.value.join(`、`)}。`)\n  if (!parts.length)\n    parts.push(`请根据最佳实践优化文本。`)\n\n  const userCommand = parts.join(` `)\n  const messages = [\n    { role: `system`, content: systemPrompt },\n    { role: `user`, content: `${userCommand}\\n\\n待处理文本：\\n${text}` },\n  ]\n\n  const payload = {\n    model: model.value,\n    messages,\n    temperature: temperature.value,\n    max_tokens: maxToken.value,\n    stream: true,\n  }\n\n  const headers: Record<string, string> = {\n    'Content-Type': `application/json`,\n  }\n  if (apiKey.value && type.value !== `default`) {\n    headers.Authorization = `Bearer ${apiKey.value}`\n  }\n\n  try {\n    const url = new URL(endpoint.value)\n    if (!url.pathname.endsWith(`/chat/completions`)) {\n      url.pathname = url.pathname.replace(/\\/?$/, `/chat/completions`)\n    }\n\n    const res = await window.fetch(url.toString(), {\n      method: `POST`,\n      headers,\n      body: JSON.stringify(payload),\n      signal: abortController.value!.signal,\n    })\n\n    if (!res.ok || !res.body)\n      throw new Error(`响应错误：${res.status}`)\n\n    const reader = res.body.getReader()\n    const decoder = new TextDecoder(`utf-8`)\n    let buffer = ``\n\n    while (true) {\n      const { value, done } = await reader.read()\n      if (done)\n        break\n\n      buffer += decoder.decode(value, { stream: true })\n      const lines = buffer.split(`\\n`)\n      buffer = lines.pop() || ``\n\n      for (const line of lines) {\n        if (!line.trim() || line.trim() === `data: [DONE]`)\n          continue\n        try {\n          const json = JSON.parse(line.replace(/^data: /, ``))\n          const delta = json.choices?.[0]?.delta?.content\n          if (delta?.trim()) {\n            message.value += delta\n            hasResult.value = true\n          }\n        }\n        catch {}\n      }\n    }\n  }\n  catch (e: any) {\n    if (e.name === `AbortError`) {\n      console.log(`Request aborted by user.`)\n    }\n    else {\n      console.error(`请求失败：`, e)\n      error.value = e.message || `请求失败`\n    }\n  }\n  finally {\n    loading.value = false\n  }\n}\n\n/* -------------------- abort handler -------------------- */\nfunction stopAI() {\n  if (loading.value && abortController.value) {\n    abortController.value.abort()\n    loading.value = false\n  }\n}\n\n/* -------------------- actions -------------------- */\nfunction replaceText() {\n  const editorView = toRaw(editorStore.editor!)!\n  const selection = editorView.state.selection.main\n  editorView.dispatch(editorView.state.replaceSelection(message.value))\n\n  // 选中替换后的文本\n  const newSelection = editorView.state.selection.main\n  editorView.dispatch({\n    selection: { anchor: selection.from, head: newSelection.head },\n  })\n  editorView.focus()\n\n  currentText.value = message.value\n  resetState()\n}\n\nfunction show() {\n  dialogVisible.value = true\n}\n\nfunction close() {\n  dialogVisible.value = false\n  customPrompts.value = []\n  selectedAction.value = `optimize`\n  resetState()\n}\n\ndefineExpose({ dialogVisible, runAIAction, replaceText, show, close, stopAI })\n</script>\n\n<template>\n  <Dialog v-model:open=\"dialogVisible\">\n    <DialogContent\n      class=\"bg-card text-card-foreground flex flex-col w-[95vw] max-h-[90vh] sm:max-h-[85vh] sm:max-w-2xl overflow-hidden p-0\"\n    >\n      <!-- ============ 头部 ============ -->\n      <DialogHeader class=\"space-y-1 flex flex-col items-start px-6 pt-6 pb-4\">\n        <div class=\"space-x-1 flex items-center\">\n          <DialogTitle>AI 工具箱</DialogTitle>\n\n          <Button\n            :title=\"configVisible ? 'AI 工具箱' : '配置参数'\"\n            :aria-label=\"configVisible ? 'AI 工具箱' : '配置参数'\"\n            variant=\"ghost\"\n            size=\"icon\"\n            @click=\"configVisible = !configVisible\"\n          >\n            <Wand2 v-if=\"configVisible\" class=\"h-4 w-4\" />\n            <Settings v-else class=\"h-4 w-4\" />\n          </Button>\n        </div>\n      </DialogHeader>\n\n      <!-- ============ 内容区域 ============ -->\n      <!-- config panel -->\n      <AIConfig\n        v-if=\"configVisible\"\n        class=\"border-border mx-6 mb-4 w-auto border rounded-md p-4\"\n        @saved=\"() => (configVisible = false)\"\n      />\n\n      <!-- main content -->\n      <div v-else class=\"custom-scroll space-y-3 flex-1 overflow-y-auto px-6 pb-3\">\n        <!-- action selector -->\n        <div>\n          <div class=\"mb-1.5 text-sm font-medium\">\n            选择操作\n          </div>\n          <Select v-model=\"selectedAction\">\n            <SelectTrigger class=\"w-full\">\n              <SelectValue placeholder=\"请选择要执行的操作\" />\n            </SelectTrigger>\n            <SelectContent>\n              <SelectGroup>\n                <SelectItem\n                  v-for=\"opt in actionOptions\"\n                  :key=\"opt.value\"\n                  :value=\"opt.value\"\n                >\n                  {{ opt.label }}\n                </SelectItem>\n              </SelectGroup>\n            </SelectContent>\n          </Select>\n        </div>\n\n        <!-- original text -->\n        <div>\n          <div class=\"mb-1.5 text-sm font-medium\">\n            原文\n          </div>\n          <div\n            class=\"border-border custom-scroll bg-muted/20 text-muted-foreground max-h-32 overflow-y-auto whitespace-pre-line border rounded px-3 py-2 text-sm\"\n          >\n            {{ currentText }}\n          </div>\n        </div>\n\n        <!-- custom prompts -->\n        <div v-if=\"selectedAction === 'custom'\">\n          <div class=\"mb-1.5 text-sm font-medium\">\n            自定义提示词（可选）\n          </div>\n          <div\n            class=\"custom-scroll border-border max-h-24 min-h-[40px] flex flex-wrap gap-2 overflow-y-auto border rounded px-2 py-1\"\n          >\n            <template v-for=\"(prompt, index) in customPrompts\" :key=\"index\">\n              <div\n                class=\"text-muted-foreground bg-muted flex items-center gap-1 rounded-full px-2 py-1 text-sm\"\n              >\n                <span>{{ prompt }}</span>\n                <button\n                  class=\"hover:bg-muted/60 h-4 w-4 flex items-center justify-center rounded-full\"\n                  @click=\"removePrompt(index)\"\n                >\n                  <X class=\"h-3 w-3\" />\n                </button>\n              </div>\n            </template>\n            <input\n              class=\"min-w-[100px] flex-1 bg-transparent py-1 text-sm focus:outline-hidden\"\n              placeholder=\"输入提示词后按回车\"\n              @keydown.enter=\"addPrompt\"\n            >\n          </div>\n        </div>\n\n        <!-- error -->\n        <div v-if=\"error\" class=\"min-h-[20px] flex items-center text-xs text-red-500\">\n          {{ error }}\n        </div>\n\n        <!-- result -->\n        <div v-if=\"message\">\n          <div class=\"mb-1.5 text-sm font-medium\">\n            处理结果\n          </div>\n          <div\n            ref=\"resultContainer\"\n            class=\"custom-scroll border-border bg-background max-h-40 min-h-[60px] overflow-y-auto whitespace-pre-line border rounded px-3 py-2 text-sm\"\n          >\n            {{ message }}\n          </div>\n        </div>\n      </div>\n\n      <!-- ============ 底部按钮 ============ -->\n      <div v-if=\"!configVisible\" class=\"flex justify-end gap-2 px-6 py-3.5 mt-auto\">\n        <Button v-if=\"loading\" variant=\"secondary\" @click=\"stopAI\">\n          <Pause class=\"mr-1 h-4 w-4\" /> 终止\n        </Button>\n        <Button\n          v-if=\"hasResult && !loading\"\n          variant=\"default\"\n          @click=\"replaceText\"\n        >\n          接受\n        </Button>\n        <Button\n          v-if=\"!loading\"\n          variant=\"outline\"\n          :disabled=\"!hasResult && !!message\"\n          @click=\"runAIAction\"\n        >\n          {{ hasResult ? '重试' : 'AI 处理' }}\n        </Button>\n      </div>\n    </DialogContent>\n  </Dialog>\n</template>\n\n<style scoped>\n.custom-scroll::-webkit-scrollbar {\n  width: 6px;\n}\n.custom-scroll::-webkit-scrollbar-thumb {\n  /* Tailwind @apply in <style> needs explicit classes when using <style scoped> */\n  background-color: rgba(156, 163, 175, 0.4);\n  border-radius: 9999px;\n}\n.custom-scroll::-webkit-scrollbar-thumb:hover {\n  background-color: rgba(156, 163, 175, 0.6);\n}\n.custom-scroll {\n  scrollbar-width: thin;\n  scrollbar-color: rgba(156, 163, 175, 0.4) transparent;\n}\n:deep(.dark) .custom-scroll {\n  scrollbar-color: rgba(107, 114, 128, 0.4) transparent;\n}\n\n@media (pointer: coarse) {\n  .custom-scroll::-webkit-scrollbar {\n    width: 3px;\n  }\n}\n</style>\n"
  },
  {
    "path": "apps/web/src/components/ai/tool-box/index.ts",
    "content": "import type { EditorView } from '@codemirror/view'\nimport AIPolishPopover from './ToolBoxPopover.vue'\n\n/* ---------- 简化的组合式函数 ---------- */\nfunction useAIPolish() {\n  // 现在工具箱已移到侧边栏，不再需要复杂的位置计算和事件监听\n  // 保留最基本的功能以维持兼容性\n\n  const selectedText = ref(``)\n\n  // 获取当前编辑器选中文本的简单函数\n  function getCurrentSelection(editor: EditorView | null): string {\n    try {\n      if (!editor)\n        return ``\n      const selection = editor.state.selection.main\n      return editor.state.doc.sliceString(selection.from, selection.to).trim()\n    }\n    catch {\n      return ``\n    }\n  }\n\n  /* =============== 简化的对外导出 =============== */\n  return {\n    selectedText,\n    getCurrentSelection,\n  }\n}\n\nexport { AIPolishPopover, useAIPolish }\n"
  },
  {
    "path": "apps/web/src/components/editor/CssEditor.vue",
    "content": "<script setup lang=\"ts\">\nimport { exportMergedTheme } from '@md/core'\nimport { themeMap, themeOptionsMap } from '@md/shared'\nimport { Download, Edit3, Eye, Plus, X } from 'lucide-vue-next'\nimport { useCssEditorStore } from '@/stores/cssEditor'\nimport { useEditorStore } from '@/stores/editor'\nimport { useRenderStore } from '@/stores/render'\nimport { useThemeStore } from '@/stores/theme'\nimport { useUIStore } from '@/stores/ui'\nimport { copyPlain } from '@/utils/clipboard'\n\nconst cssEditorStore = useCssEditorStore()\nconst uiStore = useUIStore()\nconst renderStore = useRenderStore()\nconst editorStore = useEditorStore()\nconst themeStore = useThemeStore()\n\nconst { isMobile } = storeToRefs(uiStore)\nconst { cssContentConfig } = storeToRefs(cssEditorStore)\n\n// 控制是否启用动画\nconst enableAnimation = ref(false)\n\n// 监听 CssEditor 开关状态变化\nwatch(() => uiStore.isShowCssEditor, () => {\n  if (isMobile.value) {\n    // 在移动端,用户操作时启用动画\n    enableAnimation.value = true\n  }\n})\n\n// 监听设备类型变化，重置动画状态\nwatch(() => isMobile.value, () => {\n  enableAnimation.value = false\n})\n\nconst isOpenEditDialog = ref(false)\nconst editInputVal = ref(``)\n\n// 滚动到活跃的 tab\nasync function scrollToActiveTab() {\n  await nextTick()\n  // 使用 data-state=\"active\" 查找活跃的 tab\n  const activeTab = document.querySelector('[role=\"tab\"][data-state=\"active\"]')\n  if (activeTab) {\n    activeTab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })\n  }\n}\n\nfunction rename(name: string) {\n  editInputVal.value = name\n  isOpenEditDialog.value = true\n}\n\nfunction editTabName() {\n  if (!(editInputVal.value).trim()) {\n    toast.error(`新建失败，方案名不可为空`)\n    return\n  }\n\n  if (!cssEditorStore.validatorTabName(editInputVal.value)) {\n    toast.error(`不能与现有方案重名`)\n    return\n  }\n  cssEditorStore.renameTab(editInputVal.value)\n  isOpenEditDialog.value = false\n  toast.success(`修改成功`)\n}\n\nconst isOpenAddDialog = ref(false)\n\nconst addInputVal = ref(``)\n// 新建方案时选择的基础主题\nconst baseThemeForNew = ref<'blank' | 'default' | 'grace' | 'simple'>('blank')\n\nasync function addTab() {\n  if (!(addInputVal.value).trim()) {\n    toast.error(`新建失败，方案名不可为空`)\n    return\n  }\n\n  if (!cssEditorStore.validatorTabName(addInputVal.value)) {\n    toast.error(`不能与现有方案重名`)\n    return\n  }\n\n  // 根据选择的基础主题来初始化内容\n  let initialContent = ''\n  if (baseThemeForNew.value === 'blank') {\n    initialContent = '' // 空白方案\n  }\n  else {\n    // 基于内置主题\n    initialContent = themeMap[baseThemeForNew.value]\n  }\n\n  const newTabName = addInputVal.value\n\n  // addCssContentTab 会自动设置 active 并触发回调\n  cssEditorStore.addCssContentTab(newTabName, initialContent)\n\n  isOpenAddDialog.value = false\n  toast.success(`新建成功`)\n\n  // 重置为空白\n  baseThemeForNew.value = 'blank'\n\n  // 滚动到新创建的 tab\n  scrollToActiveTab()\n}\n\nconst isOpenDelTabConfirmDialog = ref(false)\nconst delTargetName = ref(``)\n\nfunction removeHandler(targetName: string) {\n  delTargetName.value = targetName\n  isOpenDelTabConfirmDialog.value = true\n}\n\nfunction delTab() {\n  const tabs = cssContentConfig.value.tabs\n  if (tabs.length === 1) {\n    toast.warning(`至少保留一个方案`)\n    return\n  }\n\n  let activeName = cssContentConfig.value.active\n  if (activeName === delTargetName.value) {\n    tabs.forEach((tab, index) => {\n      if (tab.name === delTargetName.value) {\n        const nextTab = tabs[index + 1] || tabs[index - 1]\n        if (nextTab) {\n          activeName = nextTab.name\n        }\n      }\n    })\n  }\n\n  cssEditorStore.tabChanged(activeName)\n  cssContentConfig.value.tabs = tabs.filter(tab => tab.name !== delTargetName.value)\n\n  toast.success(`删除成功`)\n}\n\nfunction addHandler() {\n  addInputVal.value = `方案${cssContentConfig.value.tabs.length + 1}`\n  baseThemeForNew.value = 'blank' // 重置选择\n  isOpenAddDialog.value = true\n}\n\n// 查看内置主题功能\nconst isOpenViewThemeDialog = ref(false)\nconst selectedViewTheme = ref<'default' | 'grace' | 'simple'>('default')\n\n// 打开查看内置主题对话框\nfunction openViewThemeDialog() {\n  selectedViewTheme.value = 'default'\n  isOpenViewThemeDialog.value = true\n}\n\n// 复制主题 CSS\nasync function copyThemeCSS() {\n  const css = themeMap[selectedViewTheme.value]\n  await copyPlain(css)\n  toast.success('已复制到剪贴板')\n}\n\n// 基于当前查看的主题新建方案\nfunction createFromViewTheme() {\n  isOpenViewThemeDialog.value = false\n  // 设置基础主题并打开新建对话框\n  baseThemeForNew.value = selectedViewTheme.value\n  addInputVal.value = `基于${themeOptionsMap[selectedViewTheme.value].label}主题`\n  isOpenAddDialog.value = true\n}\n\nfunction tabChanged(tabName: string | number) {\n  console.log(`tabChanged`, tabName)\n  cssEditorStore.tabChanged(tabName as string)\n  // 切换后滚动到活跃的 tab\n  scrollToActiveTab()\n}\n\n// 初始化 CSS 编辑器\nonMounted(() => {\n  // CSS 内容更新回调\n  const handleCssUpdate = () => {\n    // 1. 使用新主题系统应用 CSS\n    themeStore.applyCurrentTheme()\n\n    // 2. 触发编辑器刷新，重新渲染内容\n    themeStore.updateCodeTheme()\n    const raw = editorStore.getContent()\n    renderStore.render(raw)\n  }\n\n  // 设置切换方案时的回调（与编辑时使用相同的逻辑）\n  cssEditorStore.setOnTabChangedCallback(handleCssUpdate)\n\n  // 初始化 CSS 编辑器\n  cssEditorStore.initCssEditor(handleCssUpdate)\n\n  // 初始化时滚动到活跃的 tab\n  scrollToActiveTab()\n})\n\n// 导出合并后的主题\nfunction exportCurrentTheme() {\n  const currentTab = cssContentConfig.value.tabs.find(tab => tab.name === cssContentConfig.value.active)\n  if (!currentTab) {\n    toast.error(`未找到当前方案`)\n    return\n  }\n\n  const currentThemeName = currentTab.title || currentTab.name\n\n  // 使用新的导出函数（包含 default 基础）\n  const baseTheme = themeStore.theme === `default`\n    ? themeMap.default\n    : `${themeMap.default}\\n\\n${themeMap[themeStore.theme]}`\n\n  exportMergedTheme(\n    currentTab.content,\n    baseTheme,\n    {\n      primaryColor: themeStore.primaryColor,\n      fontFamily: themeStore.fontFamily,\n      fontSize: themeStore.fontSize,\n    },\n    `${currentThemeName}-merged`,\n  )\n\n  toast.success(`主题导出成功`)\n}\n</script>\n\n<template>\n  <!-- 移动端遮罩层 -->\n  <div\n    v-if=\"isMobile && uiStore.isShowCssEditor\"\n    class=\"fixed inset-0 bg-black/50 z-40\"\n    @click=\"uiStore.isShowCssEditor = false\"\n  />\n\n  <transition enter-active-class=\"bounceInRight\">\n    <div\n      v-show=\"uiStore.isShowCssEditor\"\n      class=\"cssEditor-wrapper h-full flex flex-col mobile-css-editor overflow-y-auto\"\n      :class=\"{\n        // 移动端样式\n        'fixed top-0 right-0 w-full h-full z-100 bg-background border-l shadow-lg': isMobile,\n        'animate': isMobile && enableAnimation,\n        // 桌面端样式\n        'border-l-2 flex-1 order-2 border-gray/50 min-w-0': !isMobile,\n      }\"\n      :style=\"{\n        transform: isMobile ? (uiStore.isShowCssEditor ? 'translateX(0)' : 'translateX(100%)') : 'none',\n      }\"\n    >\n      <!-- 移动端标题栏 -->\n      <div v-if=\"isMobile\" class=\"sticky top-0 z-10 flex items-center justify-between px-4 py-3 border-b mb-2 bg-background\">\n        <h2 class=\"text-lg font-semibold\">\n          自定义 CSS\n        </h2>\n        <Button variant=\"ghost\" size=\"sm\" @click=\"uiStore.isShowCssEditor = false\">\n          <X class=\"h-4 w-4\" />\n        </Button>\n      </div>\n      <Tabs\n        v-model=\"cssContentConfig.active\"\n        @update:model-value=\"tabChanged\"\n      >\n        <div class=\"flex items-center border-b bg-muted\">\n          <TabsList class=\"flex-1 overflow-x-auto justify-start border-0 bg-transparent h-auto p-0 custom-scrollbar\">\n            <TabsTrigger\n              v-for=\"item in cssContentConfig.tabs\"\n              :key=\"item.name\"\n              :value=\"item.name\"\n              class=\"flex-1\"\n            >\n              {{ item.title }}\n              <Edit3\n                v-show=\"cssContentConfig.active === item.name\" class=\"cursor-pointer inline size-4 rounded-full p-0.5 transition-color hover:bg-gray-200 dark:hover:bg-gray-600\"\n                @click=\"rename(item.name)\"\n              />\n              <X\n                v-show=\"cssContentConfig.active === item.name\" class=\"cursor-pointer inline size-4 rounded-full p-0.5 transition-color hover:bg-gray-200 dark:hover:bg-gray-600\"\n                @click.self=\"removeHandler(item.name)\"\n              />\n            </TabsTrigger>\n          </TabsList>\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            class=\"h-9 w-9 shrink-0 hover:bg-accent\"\n            @click=\"addHandler\"\n          >\n            <Plus class=\"h-5 w-5\" />\n          </Button>\n        </div>\n      </Tabs>\n      <!-- CSS编辑器内容区域 -->\n      <div class=\"flex-1 min-h-0\">\n        <textarea\n          id=\"cssEditor\"\n          type=\"textarea\"\n          placeholder=\"Your custom css here.\"\n        />\n      </div>\n\n      <!-- 新增弹窗 -->\n      <Dialog v-model:open=\"isOpenAddDialog\">\n        <DialogContent class=\"sm:max-w-[425px]\">\n          <DialogHeader>\n            <DialogTitle>新建自定义 CSS</DialogTitle>\n            <DialogDescription>\n              请输入方案名称，并选择初始模板\n            </DialogDescription>\n          </DialogHeader>\n          <div class=\"space-y-4\">\n            <div class=\"space-y-2\">\n              <label class=\"text-sm font-medium\">方案名称</label>\n              <Input v-model=\"addInputVal\" placeholder=\"输入方案名称\" />\n            </div>\n            <div class=\"space-y-2\">\n              <label class=\"text-sm font-medium\">初始模板</label>\n              <Select v-model=\"baseThemeForNew\">\n                <SelectTrigger>\n                  <SelectValue placeholder=\"选择初始模板\" />\n                </SelectTrigger>\n                <SelectContent>\n                  <SelectItem value=\"blank\">\n                    空白方案\n                  </SelectItem>\n                  <SelectItem value=\"default\">\n                    基于经典主题\n                  </SelectItem>\n                  <SelectItem value=\"grace\">\n                    基于优雅主题\n                  </SelectItem>\n                  <SelectItem value=\"simple\">\n                    基于简洁主题\n                  </SelectItem>\n                </SelectContent>\n              </Select>\n              <p class=\"text-xs text-muted-foreground\">\n                选择一个内置主题作为起点，可以在其基础上进行修改\n              </p>\n            </div>\n          </div>\n          <DialogFooter>\n            <Button variant=\"outline\" @click=\"isOpenAddDialog = false\">\n              取消\n            </Button>\n            <Button @click=\"addTab()\">\n              创建\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n\n      <!-- 重命名弹窗 -->\n      <Dialog v-model:open=\"isOpenEditDialog\">\n        <DialogContent class=\"sm:max-w-[425px]\">\n          <DialogHeader>\n            <DialogTitle>编辑方案名称</DialogTitle>\n            <DialogDescription>\n              请输入新的方案名称\n            </DialogDescription>\n          </DialogHeader>\n          <Input v-model=\"editInputVal\" />\n          <DialogFooter>\n            <Button variant=\"outline\" @click=\"isOpenEditDialog = false\">\n              取消\n            </Button>\n            <Button @click=\"editTabName\">\n              保存\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n\n      <AlertDialog v-model:open=\"isOpenDelTabConfirmDialog\">\n        <AlertDialogContent>\n          <AlertDialogHeader>\n            <AlertDialogTitle>提示</AlertDialogTitle>\n            <AlertDialogDescription>\n              此操作将删除该自定义方案，是否继续？\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel>取消</AlertDialogCancel>\n            <AlertDialogAction @click=\"delTab\">\n              确定\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </div>\n  </transition>\n\n  <Button\n    v-show=\"uiStore.isShowCssEditor\"\n    class=\"fixed z-100 shadow-lg hover:bg-accent cursor-pointer transition-shadow bg-background text-background-foreground border\" :class=\"[\n      isMobile ? 'bottom-16 right-4' : 'bottom-22 right-4',\n    ]\"\n    size=\"sm\"\n    variant=\"outline\"\n    @click=\"openViewThemeDialog\"\n  >\n    <Eye class=\"h-4 w-4 mr-2\" />\n    内置主题\n  </Button>\n\n  <Button\n    v-show=\"uiStore.isShowCssEditor\"\n    class=\"fixed z-100 shadow-lg hover:bg-accent cursor-pointer transition-shadow bg-background text-background-foreground border\" :class=\"[\n      isMobile ? 'bottom-4 right-4' : 'bottom-10 right-4',\n    ]\"\n    size=\"sm\"\n    @click=\"exportCurrentTheme\"\n  >\n    <Download class=\"h-4 w-4 mr-2\" />\n    导出主题\n  </Button>\n\n  <!-- 查看内置主题对话框 -->\n  <Dialog v-model:open=\"isOpenViewThemeDialog\">\n    <DialogContent class=\"sm:max-w-4xl max-h-[90vh] flex flex-col\" @open-auto-focus.prevent>\n      <DialogHeader>\n        <DialogTitle>查看内置主题样式</DialogTitle>\n        <DialogDescription>\n          查看并复制内置主题的 CSS 代码，或基于它们创建新方案\n        </DialogDescription>\n      </DialogHeader>\n\n      <div class=\"space-y-4 flex-1 min-h-0 flex flex-col\">\n        <!-- 主题选择器 -->\n        <div class=\"space-y-2\">\n          <label class=\"text-sm font-medium\">选择主题</label>\n          <Select v-model=\"selectedViewTheme\">\n            <SelectTrigger class=\"w-full mt-2 sm:w-[200px] focus-visible:ring-0 focus-visible:ring-offset-0\">\n              <SelectValue placeholder=\"选择主题\" />\n            </SelectTrigger>\n            <SelectContent>\n              <SelectItem value=\"default\">\n                {{ themeOptionsMap.default.label }}\n              </SelectItem>\n              <SelectItem value=\"grace\">\n                {{ themeOptionsMap.grace.label }}\n              </SelectItem>\n              <SelectItem value=\"simple\">\n                {{ themeOptionsMap.simple.label }}\n              </SelectItem>\n            </SelectContent>\n          </Select>\n        </div>\n\n        <!-- CSS 代码查看器 -->\n        <div class=\"flex-1 min-h-0 border rounded-lg overflow-auto\">\n          <pre class=\"h-full overflow-auto p-4 bg-muted text-sm\"><code>{{ themeMap[selectedViewTheme] }}</code></pre>\n        </div>\n      </div>\n\n      <DialogFooter class=\"flex-col sm:flex-row gap-2\">\n        <Button variant=\"outline\" @click=\"isOpenViewThemeDialog = false\">\n          关闭\n        </Button>\n        <Button variant=\"outline\" @click=\"copyThemeCSS\">\n          复制全部\n        </Button>\n        <Button variant=\"outline\" @click=\"createFromViewTheme\">\n          基于此主题新建\n        </Button>\n      </DialogFooter>\n    </DialogContent>\n  </Dialog>\n</template>\n\n<style lang=\"less\" scoped>\n/* 隐藏滚动条但保持滚动功能 */\n.custom-scrollbar {\n  /* Firefox */\n  scrollbar-width: none;\n\n  /* Chrome, Edge, Safari */\n  &::-webkit-scrollbar {\n    display: none;\n  }\n}\n\n/* 移动端CSS编辑器动画 - 只有添加了 animate 类才启用 */\n.mobile-css-editor.animate {\n  transition: transform 300ms cubic-bezier(0.16, 1, 0.3, 1);\n}\n\n/* 桌面端的bounceInRight动画 */\n.bounceInRight {\n  animation-name: bounceInRight;\n  animation-duration: 1s;\n  animation-fill-mode: both;\n}\n\n@keyframes bounceInRight {\n  0%,\n  60%,\n  75%,\n  90%,\n  100% {\n    transition-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);\n  }\n\n  0% {\n    opacity: 0;\n    transform: translate3d(3000px, 0, 0);\n  }\n\n  60% {\n    opacity: 1;\n    transform: translate3d(-25px, 0, 0);\n  }\n\n  75% {\n    transform: translate3d(10px, 0, 0);\n  }\n\n  90% {\n    transform: translate3d(-5px, 0, 0);\n  }\n\n  100% {\n    transform: none;\n  }\n}\n</style>\n"
  },
  {
    "path": "apps/web/src/components/editor/CustomUploadForm.vue",
    "content": "<script setup lang='ts'>\nimport { Compartment } from '@codemirror/state'\nimport { EditorView } from '@codemirror/view'\nimport { javascriptSetup, theme } from '@md/shared'\nimport { useUIStore } from '@/stores/ui'\nimport { removeLeft } from '@/utils'\nimport { store } from '@/utils/storage'\n\nconst code = store.reactive(`formCustomConfig`, removeLeft(`\n  const { file, util, okCb, errCb } = CUSTOM_ARG\n  param = new FormData()\n  param.append('file', file)\n  util.axios.post('${window.location.origin}/upload', param, {\n    headers: { 'Content-Type': 'multipart/form-data' }\n  }).then(res => {\n    okCb(res.url)\n  }).catch(err => {\n    errCb(err)\n  })\n`).trim())\n\nconst formCustomTextarea = useTemplateRef<HTMLDivElement>(`formCustomTextarea`)\n\nconst uiStore = useUIStore()\nconst { isDark } = storeToRefs(uiStore)\n\nconst editor = ref<EditorView | null>(null)\n\nconst themeCompartment = new Compartment()\n\nonMounted(() => {\n  const editorView = new EditorView({\n    parent: formCustomTextarea.value!,\n    extensions: [javascriptSetup(), themeCompartment.of(theme(isDark.value))],\n    doc: code.value,\n  })\n\n  editor.value = editorView\n})\n\nwatch(isDark, (dark) => {\n  editor.value?.dispatch({\n    effects: themeCompartment.reconfigure(theme(dark)),\n  })\n})\n\nonUnmounted(() => {\n  if (editor.value) {\n    editor.value.destroy()\n  }\n})\n\nfunction formCustomSave() {\n  const str = editor.value!.state.doc.toString()\n  code.value = str\n  toast.success(`保存成功`)\n}\n</script>\n\n<template>\n  <div class=\"flex flex-col flex-1 overflow-hidden\">\n    <div class=\"flex-1 overflow-y-auto p-1 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden space-y-4\">\n      <div class=\"h-60 border border-gray-200 dark:border-gray-700 rounded-lg flex flex-col overflow-y-auto\">\n        <div\n          ref=\"formCustomTextarea\"\n          class=\"flex-1 custom-codemirror\"\n        />\n      </div>\n      <Button\n        variant=\"link\"\n        class=\"p-0 h-auto text-left whitespace-normal\"\n        as=\"a\"\n        href=\"https://github.com/doocs/md/blob/main/docs/custom-upload.md\"\n        target=\"_blank\"\n      >\n        参数详情？\n      </Button>\n    </div>\n    <DialogFooter class=\"p-1\">\n      <Button @click=\"formCustomSave\">\n        保存配置\n      </Button>\n    </DialogFooter>\n  </div>\n</template>\n\n<style scoped>\n@media (max-width: 768px) {\n  :deep(.CodeMirror) {\n    font-size: 12px;\n  }\n}\n</style>\n"
  },
  {
    "path": "apps/web/src/components/editor/EditorContextMenu.vue",
    "content": "<script setup lang='ts'>\nimport { altSign, headingLevels as baseHeadingLevels, ctrlKey, ctrlSign, shiftSign } from '@md/shared/configs'\nimport {\n  Bold,\n  ClipboardPaste,\n  Copy,\n  FileCode,\n  FileDown,\n  FileImage,\n  FileText,\n  FileUp,\n  Heading1,\n  Heading2,\n  Heading3,\n  Heading4,\n  Heading5,\n  Heading6,\n  Image,\n  Import,\n  Italic,\n  Link,\n  List,\n  ListOrdered,\n  RefreshCw,\n  RotateCcw,\n  Strikethrough,\n  Table,\n  Trash2,\n  Wand2,\n} from 'lucide-vue-next'\nimport DEFAULT_CONTENT from '@/assets/example/markdown.md?raw'\nimport { useEditorFormat } from '@/composables/useEditorFormat'\nimport { useEditorStore } from '@/stores/editor'\nimport { useExportStore } from '@/stores/export'\nimport { usePostStore } from '@/stores/post'\nimport { useUIStore } from '@/stores/ui'\nimport { copyPlain } from '@/utils/clipboard'\n\nconst editorStore = useEditorStore()\nconst postStore = usePostStore()\nconst exportStore = useExportStore()\nconst uiStore = useUIStore()\n\nconst {\n  toggleShowInsertFormDialog,\n  toggleShowInsertMpCardDialog,\n  toggleShowUploadImgDialog,\n  toggleShowImportMdDialog,\n} = uiStore\n\nconst { editor } = storeToRefs(editorStore)\n\nconst { addFormat } = useEditorFormat(editor)\n\nconst headingIcons = [Heading1, Heading2, Heading3, Heading4, Heading5, Heading6]\nconst headingLevels = baseHeadingLevels.map((item, index) => ({\n  ...item,\n  icon: headingIcons[index],\n}))\n\n// 格式化文档\nasync function formatContent() {\n  const doc = await editorStore.formatContent()\n  if (doc && postStore.currentPost) {\n    postStore.updatePostContent(postStore.currentPostId, doc)\n  }\n}\n\n// 导入默认内容\nfunction importDefaultContent() {\n  editorStore.importContent(DEFAULT_CONTENT)\n  toast.success(`文档已重置`)\n}\n\n// 清空内容\nfunction clearContent() {\n  editorStore.clearContent()\n}\n\n// 复制到剪贴板\nasync function copyToClipboard() {\n  const selectedText = editorStore.getSelection()\n  copyPlain(selectedText)\n}\n\n// 从剪贴板粘贴\nasync function pasteFromClipboard() {\n  try {\n    const text = await navigator.clipboard.readText()\n    editorStore.replaceSelection(text)\n  }\n  catch (error) {\n    console.log(`粘贴失败`, error)\n  }\n}\n\n// 重置样式确认\nfunction resetStyleConfirm() {\n  uiStore.isOpenConfirmDialog = true\n}\n\n// 导出函数\nfunction exportEditorContent2HTML() {\n  exportStore.exportEditorContent2HTML()\n}\n\nfunction exportEditorContent2PDF() {\n  exportStore.exportEditorContent2PDF()\n}\n\nfunction exportEditorContent2MD() {\n  exportStore.exportEditorContent2MD(editorStore.getContent())\n}\n\nfunction downloadAsCardImage() {\n  exportStore.downloadAsCardImage()\n}\n</script>\n\n<template>\n  <ContextMenu>\n    <ContextMenuTrigger>\n      <slot />\n    </ContextMenuTrigger>\n    <ContextMenuContent class=\"w-64 max-h-[80vh] overflow-y-auto\">\n      <!-- 插入子菜单 -->\n      <ContextMenuSub>\n        <ContextMenuSubTrigger>\n          <Import class=\"mr-2 h-4 w-4\" />\n          插入\n        </ContextMenuSubTrigger>\n        <ContextMenuSubContent class=\"w-48\">\n          <ContextMenuItem @click=\"toggleShowUploadImgDialog()\">\n            <Image class=\"mr-2 h-4 w-4\" />\n            图片\n          </ContextMenuItem>\n          <ContextMenuItem @click=\"toggleShowInsertFormDialog()\">\n            <Table class=\"mr-2 h-4 w-4\" />\n            表格\n          </ContextMenuItem>\n          <ContextMenuItem @click=\"toggleShowInsertMpCardDialog()\">\n            <FileText class=\"mr-2 h-4 w-4\" />\n            公众号名片\n          </ContextMenuItem>\n        </ContextMenuSubContent>\n      </ContextMenuSub>\n\n      <!-- 格式化子菜单 -->\n      <ContextMenuSub>\n        <ContextMenuSubTrigger>\n          <Wand2 class=\"mr-2 h-4 w-4\" />\n          文本格式\n        </ContextMenuSubTrigger>\n        <ContextMenuSubContent class=\"w-48\">\n          <ContextMenuItem @click=\"addFormat(`${ctrlKey}-B`)\">\n            <Bold class=\"mr-2 h-4 w-4\" />\n            加粗\n            <ContextMenuShortcut>\n              <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n              <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">B</kbd>\n            </ContextMenuShortcut>\n          </ContextMenuItem>\n          <ContextMenuItem @click=\"addFormat(`${ctrlKey}-I`)\">\n            <Italic class=\"mr-2 h-4 w-4\" />\n            斜体\n            <ContextMenuShortcut>\n              <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n              <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">I</kbd>\n            </ContextMenuShortcut>\n          </ContextMenuItem>\n          <ContextMenuItem @click=\"addFormat(`${ctrlKey}-D`)\">\n            <Strikethrough class=\"mr-2 h-4 w-4\" />\n            删除线\n            <ContextMenuShortcut>\n              <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n              <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">D</kbd>\n            </ContextMenuShortcut>\n          </ContextMenuItem>\n          <ContextMenuSeparator />\n          <ContextMenuItem @click=\"addFormat(`${ctrlKey}-K`)\">\n            <Link class=\"mr-2 h-4 w-4\" />\n            超链接\n            <ContextMenuShortcut>\n              <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n              <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">K</kbd>\n            </ContextMenuShortcut>\n          </ContextMenuItem>\n          <ContextMenuItem @click=\"addFormat(`${ctrlKey}-E`)\">\n            <FileCode class=\"mr-2 h-4 w-4\" />\n            行内代码\n            <ContextMenuShortcut>\n              <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n              <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">E</kbd>\n            </ContextMenuShortcut>\n          </ContextMenuItem>\n        </ContextMenuSubContent>\n      </ContextMenuSub>\n\n      <!-- 标题子菜单 -->\n      <ContextMenuSub>\n        <ContextMenuSubTrigger>\n          <Heading1 class=\"mr-2 h-4 w-4\" />\n          标题\n        </ContextMenuSubTrigger>\n        <ContextMenuSubContent class=\"w-48\">\n          <ContextMenuItem\n            v-for=\"{ level, icon, label } in headingLevels\"\n            :key=\"level\"\n            @click=\"addFormat(`${ctrlKey}-${level}`)\"\n          >\n            <component :is=\"icon\" class=\"mr-2 h-4 w-4\" />\n            {{ label }}\n            <ContextMenuShortcut>\n              <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n              <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ level }}</kbd>\n            </ContextMenuShortcut>\n          </ContextMenuItem>\n        </ContextMenuSubContent>\n      </ContextMenuSub>\n\n      <!-- 列表 -->\n      <ContextMenuItem @click=\"addFormat(`${ctrlKey}-U`)\">\n        <List class=\"mr-2 h-4 w-4\" />\n        无序列表\n        <ContextMenuShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">U</kbd>\n        </ContextMenuShortcut>\n      </ContextMenuItem>\n      <ContextMenuItem @click=\"addFormat(`${ctrlKey}-O`)\">\n        <ListOrdered class=\"mr-2 h-4 w-4\" />\n        有序列表\n        <ContextMenuShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">O</kbd>\n        </ContextMenuShortcut>\n      </ContextMenuItem>\n\n      <ContextMenuSeparator />\n\n      <!-- 导入导出操作 -->\n      <ContextMenuItem @click=\"toggleShowImportMdDialog(true)\">\n        <FileUp class=\"mr-2 h-4 w-4\" />\n        导入 .md 文档\n      </ContextMenuItem>\n      <ContextMenuSub>\n        <ContextMenuSubTrigger>\n          <FileDown class=\"mr-2 h-4 w-4\" />\n          导出\n        </ContextMenuSubTrigger>\n        <ContextMenuSubContent class=\"w-48\">\n          <ContextMenuItem @click=\"exportEditorContent2MD()\">\n            <FileDown class=\"mr-2 h-4 w-4\" />\n            导出 .md 文档\n          </ContextMenuItem>\n          <ContextMenuItem @click=\"exportEditorContent2HTML()\">\n            <FileCode class=\"mr-2 h-4 w-4\" />\n            导出 .html\n          </ContextMenuItem>\n          <ContextMenuItem @click=\"exportEditorContent2PDF()\">\n            <FileText class=\"mr-2 h-4 w-4\" />\n            导出 .pdf\n          </ContextMenuItem>\n          <ContextMenuItem @click=\"downloadAsCardImage()\">\n            <FileImage class=\"mr-2 h-4 w-4\" />\n            导出 .png\n          </ContextMenuItem>\n        </ContextMenuSubContent>\n      </ContextMenuSub>\n\n      <ContextMenuSeparator />\n\n      <!-- 文档操作 -->\n      <ContextMenuItem @click=\"formatContent()\">\n        <Wand2 class=\"mr-2 h-4 w-4\" />\n        格式化\n        <ContextMenuShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ altSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ shiftSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">F</kbd>\n        </ContextMenuShortcut>\n      </ContextMenuItem>\n      <ContextMenuItem @click=\"importDefaultContent()\">\n        <RefreshCw class=\"mr-2 h-4 w-4\" />\n        重置文档\n      </ContextMenuItem>\n      <ContextMenuItem @click=\"resetStyleConfirm()\">\n        <RotateCcw class=\"mr-2 h-4 w-4\" />\n        重置样式\n      </ContextMenuItem>\n      <ContextMenuItem @click=\"clearContent()\">\n        <Trash2 class=\"mr-2 h-4 w-4\" />\n        清空内容\n      </ContextMenuItem>\n\n      <ContextMenuSeparator />\n\n      <!-- 编辑操作 -->\n      <ContextMenuItem @click=\"copyToClipboard()\">\n        <Copy class=\"mr-2 h-4 w-4\" />\n        复制\n        <ContextMenuShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">C</kbd>\n        </ContextMenuShortcut>\n      </ContextMenuItem>\n      <ContextMenuItem @click=\"pasteFromClipboard\">\n        <ClipboardPaste class=\"mr-2 h-4 w-4\" />\n        粘贴\n        <ContextMenuShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">V</kbd>\n        </ContextMenuShortcut>\n      </ContextMenuItem>\n    </ContextMenuContent>\n  </ContextMenu>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/editor/EditorStateDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport { storeLabels } from '@md/shared/configs'\nimport { Expand, UploadCloud } from 'lucide-vue-next'\nimport { useCssEditorStore } from '@/stores/cssEditor'\nimport { usePostStore } from '@/stores/post'\nimport { useRenderStore } from '@/stores/render'\nimport { useThemeStore } from '@/stores/theme'\nimport { useUIStore } from '@/stores/ui'\nimport { downloadFile } from '@/utils'\nimport { copyPlain } from '@/utils/clipboard'\n\nconst props = defineProps({\n  visible: {\n    type: Boolean,\n    default: false,\n  },\n})\nconst emit = defineEmits([`close`])\nconst themeStore = useThemeStore()\nconst uiStore = useUIStore()\nconst postStore = usePostStore()\nconst cssEditorStore = useCssEditorStore()\nconst renderStore = useRenderStore()\n\nwatch(\n  () => props.visible,\n  (val) => {\n    if (val)\n      fetchStoreStates()\n  },\n)\n\nfunction onUpdate(val: boolean) {\n  if (!val) {\n    emit(`close`)\n  }\n}\n\nconst activeName = ref(`import`)\nconst tabs = ref([\n  {\n    value: `import`,\n    label: `导入配置`,\n  },\n  {\n    value: `export`,\n    label: `导出配置`,\n  },\n])\n\n// 使用响应式对象存储状态和选中状态\nconst storeStates = ref<{\n  data: Record<string, any>\n  selected: Record<string, boolean>\n}>({\n  data: {},\n  selected: {},\n})\n\n// 获取状态并初始化选中状态\nfunction getAllStoreStates() {\n  return {\n    // UI store 的状态\n    isDark: uiStore.isDark,\n    isEditOnLeft: uiStore.isEditOnLeft,\n    isOpenRightSlider: uiStore.isOpenRightSlider,\n    isOpenPostSlider: uiStore.isOpenPostSlider,\n    showAIToolbox: uiStore.showAIToolbox,\n\n    // Theme store 的状态\n    theme: themeStore.theme,\n    fontFamily: themeStore.fontFamily,\n    fontSize: themeStore.fontSize,\n    primaryColor: themeStore.primaryColor,\n    codeBlockTheme: themeStore.codeBlockTheme,\n    legend: themeStore.legend,\n    isMacCodeBlock: themeStore.isMacCodeBlock,\n    isShowLineNumber: themeStore.isShowLineNumber,\n    isCiteStatus: themeStore.isCiteStatus,\n    isCountStatus: themeStore.isCountStatus,\n    isUseIndent: themeStore.isUseIndent,\n    isUseJustify: themeStore.isUseJustify,\n\n    // Post store 的状态\n    currentPostId: postStore.currentPostId,\n    currentPostIndex: postStore.currentPostIndex,\n    posts: postStore.posts,\n\n    // CSS Editor store 的状态\n    cssContentConfig: cssEditorStore.cssContentConfig,\n\n    // Render store 的状态\n    titleList: renderStore.titleList,\n    readingTime: renderStore.readingTime,\n\n    // Display store 的状态\n    isShowCssEditor: uiStore.isShowCssEditor,\n    isShowInsertFormDialog: uiStore.isShowInsertFormDialog,\n    isShowUploadImgDialog: uiStore.isShowUploadImgDialog,\n    isShowInsertMpCardDialog: uiStore.isShowInsertMpCardDialog,\n    aiDialogVisible: uiStore.aiDialogVisible,\n    aiImageDialogVisible: uiStore.aiImageDialogVisible,\n  }\n}\n\nasync function fetchStoreStates() {\n  try {\n    const states = getAllStoreStates()\n    storeStates.value = {\n      data: states,\n      selected: Object.keys(states).reduce((acc, key) => {\n        acc[key] = true // 默认全部选中\n        return acc\n      }, {} as Record<string, boolean>),\n    }\n  }\n  catch {\n  }\n}\n\n// 计算属性：根据选中状态过滤后的JSON\nconst filteredExportJSON = computed(() => {\n  if (!storeStates.value.data)\n    return {}\n\n  return Object.keys(storeStates.value.data).reduce((acc, key) => {\n    if (storeStates.value.selected[key]) {\n      acc[key] = storeStates.value.data[key]\n    }\n    return acc\n  }, {} as Record<string, any>)\n})\n\n// 导入的配置数据\nconst importStates = ref<{\n  data: Record<string, any>\n  selected: Record<string, boolean>\n}>({\n  data: {},\n  selected: {},\n})\nconst originalImportData = ref<Record<string, any> | null>(null)\n\nconst filteredImportJSON = computed(() => {\n  if (!importStates.value.data)\n    return {}\n\n  return Object.keys(importStates.value.data).reduce((acc, key) => {\n    if (importStates.value.selected[key]) {\n      acc[key] = importStates.value.data[key]\n    }\n    return acc\n  }, {} as Record<string, any>)\n})\nfunction exportSelectedConfig() {\n  const selectedConfig = Object.keys(storeStates.value.data).reduce((acc, key) => {\n    if (storeStates.value.selected[key]) {\n      acc[key] = storeStates.value.data[key]\n    }\n    return acc\n  }, {} as Record<string, any>)\n\n  downloadFile(JSON.stringify(selectedConfig, null, 2), `exported_config.json`, `application/json`)\n  toast.success(`配置文件导出成功`)\n  emit(`close`)\n}\n\n// 处理最大化弹窗预览代码\nconst isMaximized = ref(false)\nconst currentMaximizedJSON = computed(() => {\n  if (activeName.value === `export`) {\n    return filteredExportJSON.value\n  }\n  else if (activeName.value === `import`) {\n    return filteredImportJSON.value\n  }\n  return {}\n})\n\nfunction copyToClipboard(text: string) {\n  copyPlain(text)\n  toast.success(`复制成功`)\n}\n\n// 处理文件导入\nconst fileInputRef = ref<HTMLInputElement | null>(null)\n\nfunction triggerFileInput() {\n  fileInputRef.value?.click()\n}\n\nfunction handleFileImport(event: Event) {\n  const input = event.target as HTMLInputElement\n  if (!input.files?.length)\n    return\n\n  const file = input.files[0]\n  const reader = new FileReader()\n\n  reader.onload = (e) => {\n    try {\n      const content = e.target?.result as string\n      const importedData = JSON.parse(content) as Record<string, any>\n      // 检查导入的数据是否符合预期\n      if (typeof importedData !== `object` || Array.isArray(importedData)) {\n        toast.error(`导入的文件格式不正确`)\n        return\n      }\n\n      // 过滤导入的数据项，只接受允许的项，与getLable函数对应\n      const allowedKeys = Object.keys(storeStates.value.data).concat(Object.keys(importStates.value.data))\n      const filteredData = Object.keys(importedData).reduce((acc, key) => {\n        if (allowedKeys.includes(key)) {\n          acc[key] = importedData[key]\n        }\n        return acc\n      }, {} as Record<string, any>)\n      // 检查导入的数据是否符合预期\n      if (Object.keys(filteredData).length === 0) {\n        toast.error(`导入的文件无可应用项目配置`)\n        return\n      }\n\n      originalImportData.value = importedData // 保存原始导入数据\n      importStates.value = {\n        data: importedData,\n        selected: Object.keys(importedData).reduce((acc, key) => {\n          acc[key] = true // 默认全部选中\n          return acc\n        }, {} as Record<string, boolean>),\n      }\n      toast.success(`配置文件导入成功`)\n    }\n    catch {\n      toast.error(`文件解析失败，请检查JSON格式`)\n    }\n  }\n\n  reader.readAsText(file)\n  input.value = `` // 重置input，允许重复选择同一文件\n}\n\n// 应用导入的配置\nfunction applyImportedConfig() {\n  if (!filteredImportJSON.value)\n    return\n\n  Object.keys(importStates.value.selected).forEach((key) => {\n    if (importStates.value.selected[key] && importStates.value.data?.[key] !== undefined) {\n      const value = importStates.value.data[key]\n\n      // UI store 的状态\n      if (key === `isDark`)\n        uiStore.isDark = value\n      else if (key === `isEditOnLeft`)\n        uiStore.isEditOnLeft = value\n      else if (key === `isOpenRightSlider`)\n        uiStore.isOpenRightSlider = value\n      else if (key === `isOpenPostSlider`)\n        uiStore.isOpenPostSlider = value\n      else if (key === `showAIToolbox`)\n        uiStore.showAIToolbox = value\n\n      // Theme store 的状态\n      else if (key === `theme`)\n        themeStore.theme = value\n      else if (key === `fontFamily`)\n        themeStore.fontFamily = value\n      else if (key === `fontSize`)\n        themeStore.fontSize = value\n      else if (key === `primaryColor`)\n        themeStore.primaryColor = value\n      else if (key === `codeBlockTheme`)\n        themeStore.codeBlockTheme = value\n      else if (key === `legend`)\n        themeStore.legend = value\n      else if (key === `isMacCodeBlock`)\n        themeStore.isMacCodeBlock = value\n      else if (key === `isShowLineNumber`)\n        themeStore.isShowLineNumber = value\n      else if (key === `isCiteStatus`)\n        themeStore.isCiteStatus = value\n      else if (key === `isCountStatus`)\n        themeStore.isCountStatus = value\n      else if (key === `isUseIndent`)\n        themeStore.isUseIndent = value\n      else if (key === `isUseJustify`)\n        themeStore.isUseJustify = value\n\n      // Post store 的状态\n      else if (key === `currentPostId`)\n        postStore.currentPostId = value\n      else if (key === `currentPostIndex`)\n        postStore.currentPostIndex = value\n      else if (key === `posts`)\n        postStore.posts = value\n\n      // CSS Editor store 的状态\n      else if (key === `cssContentConfig`)\n        cssEditorStore.cssContentConfig = value\n\n      // Render store 的状态\n      else if (key === `titleList`)\n        renderStore.titleList = value\n      else if (key === `readingTime`)\n        renderStore.readingTime = value\n\n      // Display store 的状态\n      else if (key === `isShowCssEditor`)\n        uiStore.isShowCssEditor = value\n      else if (key === `isShowInsertFormDialog`)\n        uiStore.isShowInsertFormDialog = value\n      else if (key === `isShowUploadImgDialog`)\n        uiStore.isShowUploadImgDialog = value\n      else if (key === `isShowInsertMpCardDialog`)\n        uiStore.isShowInsertMpCardDialog = value\n      else if (key === `aiDialogVisible`)\n        uiStore.aiDialogVisible = value\n      else if (key === `aiImageDialogVisible`)\n        uiStore.aiImageDialogVisible = value\n    }\n  })\n\n  toast.success(`配置应用成功，请刷新页面查看效果`)\n  emit(`close`)\n}\n</script>\n\n<template>\n  <Dialog :open=\"props.visible\" @update:open=\"onUpdate\">\n    <DialogContent class=\"md:max-w-2/3\">\n      <DialogHeader>\n        <DialogTitle>导入/导出项目配置</DialogTitle>\n        <DialogDescription>\n          导入的配置将覆盖当前项目的配置，请谨慎操作。\n        </DialogDescription>\n      </DialogHeader>\n      <Tabs v-model=\"activeName\" class=\"w-full\">\n        <TabsList>\n          <TabsTrigger value=\"import\">\n            导入配置\n          </TabsTrigger>\n          <TabsTrigger v-for=\"item in tabs.filter(tab => tab.value !== 'import')\" :key=\"item.value\" :value=\"item.value\">\n            {{ item.label }}\n          </TabsTrigger>\n        </TabsList>\n\n        <TabsContent value=\"export\">\n          <div class=\"grid grid-cols-1 lg:grid-cols-2 my-5 h-[60vh] lg:h-96 gap-4 text-center\">\n            <div class=\"flex flex-col overflow-hidden\">\n              <p class=\"bg-white p-2 dark:bg-gray-900\">\n                请选择需要导出的配置\n              </p>\n              <ul v-if=\"storeStates.data\" class=\"space-y-2 overflow-auto\">\n                <li v-for=\"(_, key) in storeStates.data\" :key=\"key\" class=\"space-x-2 flex items-center\">\n                  <input\n                    :id=\"`export-${key}`\"\n                    v-model=\"storeStates.selected[key]\"\n                    type=\"checkbox\"\n                    class=\"h-4 w-4 border-gray-300 rounded text-indigo-600 focus:ring-indigo-500\"\n                  >\n                  <label :for=\"`export-${key}`\" class=\"text-sm text-gray-700 dark:text-gray-300\">\n                    {{ storeLabels[key] || key }}\n                  </label>\n                </li>\n              </ul>\n              <div v-else>\n                <p class=\"text-sm text-gray-500 dark:text-gray-400\">\n                  加载中...\n                </p>\n              </div>\n            </div>\n            <div class=\"flex flex-col overflow-hidden\">\n              <p class=\"relative bg-white p-2 dark:bg-gray-900\">\n                <span>当前 JSON 预览</span>\n                <Expand class=\"absolute right-2 top-2 cursor-pointer p-1 text-gray-500 dark:text-gray-400\" @click=\"isMaximized = true\" />\n              </p>\n              <div class=\"w-full overflow-auto border rounded-md bg-gray-50 p-2 dark:bg-gray-800\">\n                <pre class=\"text-left text-sm text-gray-500 dark:text-gray-400\">{{ JSON.stringify(filteredExportJSON, null, 2) }}</pre>\n              </div>\n            </div>\n            <div class=\"col-span-1 lg:col-span-2 flex justify-end\">\n              <Button\n                type=\"primary\"\n                :disabled=\"Object.values(storeStates.selected).every(v => !v)\"\n                @click=\"exportSelectedConfig\"\n              >\n                导出选中配置\n              </Button>\n            </div>\n          </div>\n        </TabsContent>\n\n        <TabsContent value=\"import\">\n          <div class=\"grid grid-cols-1 lg:grid-cols-2 my-5 h-[60vh] lg:h-96 gap-4 text-center\">\n            <div class=\"overflow-auto h-full flex flex-col\">\n              <p class=\"sticky top-0 z-10 bg-white p-2 dark:bg-gray-900\">\n                <span>导入 JSON 配置文件</span>\n                <Expand class=\"absolute right-2 top-2 cursor-pointer p-1 text-gray-500 dark:text-gray-400\" @click=\"isMaximized = true\" />\n              </p>\n              <div v-if=\"!originalImportData\" class=\"m-4 flex-1 flex flex-col items-center justify-center border-2 rounded-lg border-dashed\">\n                <input\n                  id=\"json-import-input\"\n                  ref=\"fileInputRef\"\n                  type=\"file\"\n                  accept=\".json\"\n                  class=\"hidden\"\n                  @change=\"handleFileImport\"\n                >\n                <label\n                  for=\"json-import-input\"\n                  class=\"flex-1 w-full flex flex-col cursor-pointer items-center justify-center rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800\"\n                >\n                  <UploadCloud class=\"mb-2 size-16 text-gray-500 dark:text-gray-400\" />\n                  <span class=\"text-sm text-gray-500 dark:text-gray-400\">\n                    点击或拖拽 JSON 文件到此处\n                  </span>\n                  <span class=\"mt-1 text-xs text-gray-400 dark:text-gray-500\">\n                    支持格式: .json\n                  </span>\n                </label>\n              </div>\n              <div v-else class=\"mt-4 border rounded-md bg-gray-50 p-2 dark:bg-gray-800\">\n                <pre class=\"text-left text-sm text-gray-500 dark:text-gray-400\">{{ JSON.stringify(filteredImportJSON, null, 2) }}</pre>\n              </div>\n            </div>\n\n            <div class=\"overflow-auto h-full flex flex-col\">\n              <p class=\"sticky top-0 z-10 bg-white p-2 dark:bg-gray-900\">\n                选择要导入的配置项\n              </p>\n              <div v-if=\"originalImportData\">\n                <ul class=\"space-y-2\">\n                  <li v-for=\"(_, key) in importStates.data\" :key=\"key\" class=\"space-x-2 flex items-center\">\n                    <input\n                      :id=\"`import-${key}`\"\n                      v-model=\"importStates.selected[key]\"\n                      type=\"checkbox\"\n                      class=\"h-4 w-4 border-gray-300 rounded text-indigo-600 focus:ring-indigo-500\"\n                    >\n                    <label :for=\"`import-${key}`\" class=\"text-sm text-gray-700 dark:text-gray-300\">\n                      {{ storeLabels[key] || key }}\n                    </label>\n                  </li>\n                </ul>\n              </div>\n              <div v-else class=\"flex-1 flex items-center justify-center text-gray-500 dark:text-gray-400\">\n                请先导入JSON文件\n              </div>\n            </div>\n            <div class=\"flex-1 col-span-1 lg:col-span-2 flex justify-end\">\n              <input\n                id=\"json-import-input\"\n                ref=\"fileInputRef\"\n                type=\"file\"\n                accept=\".json\"\n                class=\"hidden\"\n                @change=\"handleFileImport\"\n              >\n              <Button\n                variant=\"ghost\"\n                class=\"mr-2\"\n                @click=\"triggerFileInput\"\n              >\n                重新导入\n              </Button>\n\n              <Button\n                :disabled=\"Object.values(importStates.selected).every(v => !v)\"\n                @click=\"applyImportedConfig\"\n              >\n                应用选中配置\n              </Button>\n            </div>\n          </div>\n        </TabsContent>\n      </Tabs>\n    </DialogContent>\n  </Dialog>\n\n  <!-- 最大化弹窗 -->\n  <Dialog :open=\"isMaximized\" @update:open=\"(val) => isMaximized = val\">\n    <DialogContent class=\"max-h-[90vh] max-w-[90vw] overflow-auto\">\n      <DialogHeader>\n        <DialogTitle>JSON 全屏预览</DialogTitle>\n        <DialogDescription>\n          当前配置的完整 JSON 数据\n        </DialogDescription>\n      </DialogHeader>\n      <div class=\"max-h-[70vh] overflow-hidden\">\n        <div class=\"h-full overflow-auto border rounded-md bg-gray-50 p-4 dark:bg-gray-800\">\n          <pre class=\"break-all text-left text-sm text-gray-500 dark:text-gray-400\">{{ JSON.stringify(currentMaximizedJSON, null, 2) }}</pre>\n        </div>\n      </div>\n      <DialogFooter>\n        <Button\n          variant=\"outline\"\n          @click=\"copyToClipboard(JSON.stringify(currentMaximizedJSON, null, 2))\"\n        >\n          复制 JSON 到剪贴板\n        </Button>\n      </DialogFooter>\n    </DialogContent>\n  </Dialog>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/editor/FloatingToc.vue",
    "content": "<script setup lang='ts'>\nimport { List } from 'lucide-vue-next'\nimport { useRenderStore } from '@/stores/render'\nimport { useUIStore } from '@/stores/ui'\n\nconst renderStore = useRenderStore()\nconst uiStore = useUIStore()\nconst { isPinFloatingToc, isShowFloatingToc } = storeToRefs(uiStore)\n\nconst isOpenHeadingSlider = ref(false)\n</script>\n\n<template>\n  <div\n    v-show=\"isShowFloatingToc\"\n    class=\"bg-background absolute left-0 top-0 border rounded-br-lg rounded-tr-lg rounded-bl-lg p-2 text-sm shadow-sm\"\n    @mouseenter=\"() => (isOpenHeadingSlider = true)\"\n    @mouseleave=\"() => (isOpenHeadingSlider = false)\"\n  >\n    <List class=\"size-6\" />\n    <ul\n      class=\"overflow-auto transition-all\"\n      :class=\"{\n        'max-h-0 w-0': !isOpenHeadingSlider && !isPinFloatingToc,\n        'max-h-100 w-60 mt-2': isOpenHeadingSlider || isPinFloatingToc,\n      }\"\n    >\n      <li\n        v-for=\"(item, index) in renderStore.titleList\"\n        :key=\"index\"\n        class=\"line-clamp-1 py-1 leading-6 hover:bg-gray-300 dark:hover:bg-gray-600\"\n        :style=\"{ paddingLeft: `${item.level - 0.5}em` }\"\n      >\n        <a :href=\"item.url\">\n          {{ item.title }}\n        </a>\n      </li>\n    </ul>\n  </div>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/editor/FolderSourcePanel.vue",
    "content": "<script setup lang=\"ts\">\nimport {\n  FolderClosed,\n  FolderOpen,\n  FolderPlus,\n  FolderTree as FolderTreeIcon,\n  Loader2,\n  RefreshCw,\n  X,\n} from 'lucide-vue-next'\nimport { useFolderFileSync } from '@/composables/useFolderFileSync'\nimport { useFolderSourceStore } from '@/stores/folderSource'\nimport { usePostStore } from '@/stores/post'\nimport FolderTree from './FolderTree.vue'\n\nconst folderSourceStore = useFolderSourceStore()\nconst postStore = usePostStore()\nconst { setCurrentFilePath } = useFolderFileSync()\n\nconst {\n  currentFolderHandle,\n  fileTree,\n  selectedFilePath,\n  isLoading,\n  loadError,\n  isFileSystemAPISupported,\n} = storeToRefs(folderSourceStore)\n\nconst expandedPaths = ref<Set<string>>(new Set())\n\nfunction handleToggleExpand(path: string) {\n  if (expandedPaths.value.has(path)) {\n    expandedPaths.value.delete(path)\n  }\n  else {\n    expandedPaths.value.add(path)\n  }\n  // 触发响应式更新\n  expandedPaths.value = new Set(expandedPaths.value)\n}\n\nasync function handleSelectFolder() {\n  await folderSourceStore.selectFolder()\n  // 等待下一个 tick，确保 fileTree 已经更新\n  await nextTick()\n  // 展开根节点\n  if (fileTree.value.length > 0) {\n    expandedPaths.value.add(fileTree.value[0].path)\n  }\n}\n\nasync function handleRefreshFolder() {\n  if (currentFolderHandle.value) {\n    await folderSourceStore.loadFileTree(currentFolderHandle.value.handle)\n  }\n}\n\nfunction handleCloseFolder() {\n  folderSourceStore.closeFolder()\n  expandedPaths.value.clear()\n  setCurrentFilePath(null)\n}\n\nasync function handleOpenFile(node: any) {\n  try {\n    const content = await folderSourceStore.readFile(node.path)\n    // 从文件名中提取标题（移除 .md 扩展名）\n    const title = node.name.replace(/\\.md$/i, ``)\n\n    // 创建新文章并设置内容\n    postStore.addPost(title)\n    postStore.updatePostContent(postStore.currentPostId, content)\n\n    // 记录当前文件路径以便自动同步\n    setCurrentFilePath(node.path)\n\n    toast.success(`已加载文件: ${node.name}`)\n  }\n  catch (error) {\n    console.error(`打开文件失败:`, error)\n  }\n}\n</script>\n\n<template>\n  <div class=\"folder-source-panel h-full flex flex-col\">\n    <!-- 头部工具栏 -->\n    <div class=\"panel-header sticky top-0 z-10 bg-background border-b p-2\">\n      <div class=\"flex items-center justify-between mb-2\">\n        <h3 class=\"text-sm font-semibold flex items-center gap-2\">\n          <FolderTreeIcon class=\"h-4 w-4\" />\n          本地文件夹\n        </h3>\n        <Button\n          v-if=\"currentFolderHandle\"\n          variant=\"ghost\"\n          size=\"sm\"\n          class=\"h-7 w-7 p-0\"\n          @click=\"handleCloseFolder\"\n        >\n          <X class=\"h-3 w-3\" />\n        </Button>\n      </div>\n\n      <!-- 操作按钮 -->\n      <div class=\"flex gap-1\">\n        <Button\n          variant=\"outline\"\n          size=\"sm\"\n          class=\"flex-1 text-xs\"\n          :disabled=\"isLoading || !isFileSystemAPISupported\"\n          @click=\"handleSelectFolder\"\n        >\n          <FolderPlus v-if=\"!isLoading\" class=\"h-3 w-3 mr-1\" />\n          <Loader2 v-else class=\"h-3 w-3 mr-1 animate-spin\" />\n          打开文件夹\n        </Button>\n\n        <Button\n          v-if=\"currentFolderHandle\"\n          variant=\"outline\"\n          size=\"sm\"\n          class=\"text-xs\"\n          :disabled=\"isLoading\"\n          @click=\"handleRefreshFolder\"\n        >\n          <RefreshCw class=\"h-3 w-3\" :class=\"{ 'animate-spin': isLoading }\" />\n        </Button>\n      </div>\n    </div>\n\n    <!-- 内容区域 -->\n    <div class=\"panel-content flex-1 overflow-y-auto p-2\">\n      <!-- 不支持 API 的提示 -->\n      <div\n        v-if=\"!isFileSystemAPISupported\"\n        class=\"flex flex-col items-center justify-center h-full text-center p-4 text-muted-foreground\"\n      >\n        <FolderClosed class=\"h-12 w-12 mb-2 opacity-50\" />\n        <p class=\"text-sm\">\n          您的浏览器不支持本地文件夹访问\n        </p>\n        <p class=\"text-xs mt-1\">\n          请使用 Chrome、Edge 或 Opera 浏览器\n        </p>\n      </div>\n\n      <!-- 加载中 -->\n      <div\n        v-else-if=\"isLoading\"\n        class=\"flex flex-col items-center justify-center h-full\"\n      >\n        <Loader2 class=\"h-8 w-8 animate-spin text-primary\" />\n        <p class=\"text-sm text-muted-foreground mt-2\">\n          加载中...\n        </p>\n      </div>\n\n      <!-- 错误提示 -->\n      <div\n        v-else-if=\"loadError\"\n        class=\"flex flex-col items-center justify-center h-full text-center p-4 text-destructive\"\n      >\n        <p class=\"text-sm\">\n          {{ loadError }}\n        </p>\n      </div>\n\n      <!-- 空状态 -->\n      <div\n        v-else-if=\"!currentFolderHandle\"\n        class=\"flex flex-col items-center justify-center h-full text-center p-4 text-muted-foreground\"\n      >\n        <FolderOpen class=\"h-12 w-12 mb-2 opacity-50\" />\n        <p class=\"text-sm\">\n          未打开文件夹\n        </p>\n        <p class=\"text-xs mt-1\">\n          点击上方按钮打开本地文件夹\n        </p>\n      </div>\n\n      <!-- 文件树 -->\n      <div v-else class=\"file-tree-container\">\n        <div class=\"text-xs text-muted-foreground mb-2 px-2\">\n          {{ currentFolderHandle.name }}\n        </div>\n        <FolderTree\n          :nodes=\"fileTree\"\n          :selected-path=\"selectedFilePath\"\n          :expanded-paths=\"expandedPaths\"\n          @select=\"handleOpenFile\"\n          @toggle-expand=\"handleToggleExpand\"\n        />\n      </div>\n    </div>\n  </div>\n</template>\n\n<style scoped>\n.folder-source-panel {\n  background-color: hsl(var(--background));\n}\n\n.panel-header {\n  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);\n}\n\n.panel-content {\n  min-height: 0;\n}\n\n.file-tree-container {\n  min-height: 100%;\n}\n</style>\n"
  },
  {
    "path": "apps/web/src/components/editor/FolderTree.vue",
    "content": "<script setup lang=\"ts\">\nimport type { FileSystemNode } from '@/stores/folderSource'\nimport { ChevronDown, ChevronRight, File, Folder, FolderOpen } from 'lucide-vue-next'\n\ninterface Props {\n  nodes: FileSystemNode[]\n  selectedPath?: string\n  expandedPaths?: Set<string>\n  level?: number\n}\n\ninterface Emits {\n  (e: 'select', node: FileSystemNode): void\n  (e: 'toggleExpand', path: string): void\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  level: 0,\n  expandedPaths: () => new Set<string>(),\n})\n\nconst emit = defineEmits<Emits>()\n\nconst isSelected = (path: string) => props.selectedPath === path\n\nconst isExpanded = (path: string) => props.expandedPaths.has(path)\n\nfunction handleNodeClick(node: FileSystemNode, event: MouseEvent) {\n  event.stopPropagation()\n  if (node.type === `file`) {\n    emit(`select`, node)\n  }\n  else {\n    emit(`toggleExpand`, node.path)\n  }\n}\n\nfunction handleToggleClick(node: FileSystemNode, event: MouseEvent) {\n  event.stopPropagation()\n  if (node.type === `directory`) {\n    emit(`toggleExpand`, node.path)\n  }\n}\n</script>\n\n<template>\n  <div class=\"folder-tree\">\n    <template v-for=\"node in nodes\" :key=\"node.path\">\n      <!-- 节点本身 -->\n      <div\n        class=\"tree-node\"\n        :class=\"{\n          selected: isSelected(node.path),\n          directory: node.type === 'directory',\n          file: node.type === 'file',\n        }\"\n        :style=\"{ paddingLeft: `${level * 16 + 8}px` }\"\n        @click=\"handleNodeClick(node, $event)\"\n      >\n        <!-- 展开/折叠图标 -->\n        <span\n          v-if=\"node.type === 'directory'\"\n          class=\"toggle-icon\"\n          @click=\"handleToggleClick(node, $event)\"\n        >\n          <ChevronRight v-if=\"!isExpanded(node.path)\" class=\"h-4 w-4\" />\n          <ChevronDown v-else class=\"h-4 w-4\" />\n        </span>\n        <span v-else class=\"toggle-icon-placeholder\" />\n\n        <!-- 文件/文件夹图标 -->\n        <span class=\"node-icon\">\n          <Folder v-if=\"node.type === 'directory' && !isExpanded(node.path)\" class=\"h-4 w-4\" />\n          <FolderOpen v-else-if=\"node.type === 'directory' && isExpanded(node.path)\" class=\"h-4 w-4\" />\n          <File v-else class=\"h-4 w-4\" />\n        </span>\n\n        <!-- 节点名称 -->\n        <span class=\"node-name\" :title=\"node.name\">\n          {{ node.name }}\n        </span>\n      </div>\n\n      <!-- 递归渲染子节点（紧接在父节点之后） -->\n      <FolderTree\n        v-if=\"node.type === 'directory' && isExpanded(node.path) && node.children\"\n        :nodes=\"node.children\"\n        :selected-path=\"selectedPath\"\n        :expanded-paths=\"expandedPaths\"\n        :level=\"level + 1\"\n        @select=\"emit('select', $event)\"\n        @toggle-expand=\"emit('toggleExpand', $event)\"\n      />\n    </template>\n  </div>\n</template>\n\n<style scoped>\n.folder-tree {\n  user-select: none;\n}\n\n.tree-node {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  padding: 4px 8px;\n  cursor: pointer;\n  border-radius: 4px;\n  transition: background-color 0.15s ease;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n.tree-node:hover {\n  background-color: hsl(var(--accent) / 0.1);\n}\n\n.tree-node.selected {\n  background-color: hsl(var(--accent) / 0.2);\n  font-weight: 500;\n}\n\n.toggle-icon {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 16px;\n  height: 16px;\n  flex-shrink: 0;\n}\n\n.toggle-icon-placeholder {\n  width: 16px;\n  flex-shrink: 0;\n}\n\n.node-icon {\n  display: flex;\n  align-items: center;\n  flex-shrink: 0;\n  color: hsl(var(--muted-foreground));\n}\n\n.tree-node.selected .node-icon {\n  color: hsl(var(--primary));\n}\n\n.node-name {\n  flex: 1;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n.tree-node.directory .node-name {\n  font-weight: 500;\n}\n</style>\n"
  },
  {
    "path": "apps/web/src/components/editor/Footer.vue",
    "content": "<script setup lang=\"ts\">\nimport { useRenderStore } from '@/stores/render'\n\nconst renderStore = useRenderStore()\nconst { readingTime } = storeToRefs(renderStore)\n</script>\n\n<template>\n  <footer\n    class=\"flex select-none items-center justify-end px-5 py-2 text-xs\"\n  >\n    <div class=\"space-x-2\">\n      <span> {{ readingTime.words }} 个词 </span>\n      <span> {{ readingTime.chars }} 个字符 </span>\n      <span> 阅读大约需 {{ readingTime.minutes }} 分钟 </span>\n    </div>\n  </footer>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/editor/FormItem.vue",
    "content": "<script setup lang=\"ts\">\nconst props = defineProps<{\n  label?: string\n  required?: boolean\n  error?: string\n  width?: number\n}>()\n</script>\n\n<template>\n  <div class=\"min-h-[auto] md:min-h-[64px]\">\n    <Label class=\"mb-4 md:mb-[24px] flex flex-col md:flex-row md:items-start\" :style=\"{ '--label-width': props.width ? `${props.width}px` : '150px' }\">\n      <span class=\"mb-1 md:mb-0 md:mr-4 min-h-[auto] md:min-h-[40px] w-full md:w-[var(--label-width)] flex shrink-0 items-center justify-start md:justify-end text-sm whitespace-nowrap font-medium\" :class=\"{ required: props.required }\">\n        {{ props.label }}\n      </span>\n      <div class=\"flex flex-1 flex-col justify-between\">\n        <div class=\"min-h-[40px] flex items-center w-full\">\n          <slot />\n        </div>\n        <p v-if=\"error\" class=\"mt-1 min-h-[20px] md:min-h-[24px] flex items-center text-[12px] text-red-500\">\n          {{ error }}\n        </p>\n      </div>\n    </Label>\n  </div>\n</template>\n\n<style scoped lang='less'>\n.required::before {\n  content: '*';\n  color: #f00;\n  margin-right: 0.25em;\n}\n</style>\n"
  },
  {
    "path": "apps/web/src/components/editor/ImportMarkdownDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport { FileText, Globe, Loader2, Upload } from 'lucide-vue-next'\nimport { useEditorStore } from '@/stores/editor'\nimport { useUIStore } from '@/stores/ui'\n\nconst editorStore = useEditorStore()\nconst uiStore = useUIStore()\n\nconst { isShowImportMdDialog } = storeToRefs(uiStore)\n\n// 当前选中的 tab\nconst activeTab = ref<'url' | 'file'>(`file`)\n\n// ==================== 网络链接导入 ====================\nconst url = ref(``)\nconst isUrlLoading = ref(false)\nconst urlError = ref(``)\nlet abortController: AbortController | null = null\n\nconst ANYTHING_MD_API = `https://anything-md.doocs.org/`\n\n/** 判断链接是否直接指向 Markdown 文件 */\nfunction isMarkdownUrl(rawUrl: string): boolean {\n  try {\n    const { pathname } = new URL(rawUrl)\n    return /\\.(?:md|markdown|txt)$/i.test(pathname)\n  }\n  catch {\n    return false\n  }\n}\n\n/** 直接获取 Markdown 文件内容 */\nasync function fetchMarkdownFile(rawUrl: string, signal: AbortSignal): Promise<string> {\n  const response = await fetch(rawUrl, { signal })\n  if (!response.ok) {\n    throw new Error(`请求失败: ${response.status} ${response.statusText}`)\n  }\n  const content = await response.text()\n  if (!content.trim()) {\n    throw new Error(`该链接返回的内容为空`)\n  }\n  return content\n}\n\n/** 通过 Anything-MD 将网页转换为 Markdown */\nasync function fetchViaAnythingMd(rawUrl: string, signal: AbortSignal): Promise<string> {\n  const response = await fetch(ANYTHING_MD_API, {\n    method: `POST`,\n    headers: { 'Content-Type': `application/json` },\n    body: JSON.stringify({ url: rawUrl }),\n    signal,\n  })\n\n  if (!response.ok) {\n    throw new Error(`请求失败: ${response.status} ${response.statusText}`)\n  }\n\n  const data = await response.json()\n  if (signal.aborted)\n    throw new DOMException(``, `AbortError`)\n\n  if (!data.success) {\n    throw new Error(data.error || `转换失败`)\n  }\n\n  const markdown = data.markdown?.trim()\n  if (!markdown) {\n    throw new Error(`转换结果为空`)\n  }\n  return markdown\n}\n\nasync function importFromUrl() {\n  const rawUrl = url.value.trim()\n  if (!rawUrl) {\n    urlError.value = `请输入链接`\n    return\n  }\n\n  if (!URL.canParse(rawUrl) || !/^https?:\\/\\//i.test(rawUrl)) {\n    urlError.value = `请输入有效的 URL 地址（仅支持 http/https）`\n    return\n  }\n\n  urlError.value = ``\n  isUrlLoading.value = true\n  abortController?.abort()\n  abortController = new AbortController()\n  const { signal } = abortController\n\n  try {\n    const content = isMarkdownUrl(rawUrl)\n      ? await fetchMarkdownFile(rawUrl, signal)\n      : await fetchViaAnythingMd(rawUrl, signal)\n\n    editorStore.importContent(content)\n    closeDialog()\n  }\n  catch (err) {\n    if ((err as Error).name === `AbortError`)\n      return\n    urlError.value = (err as Error).message || `导入失败，请检查链接是否有效`\n  }\n  finally {\n    isUrlLoading.value = false\n  }\n}\n\n// ==================== 本地文件导入 ====================\nconst isDragover = ref(false)\nconst { open: openFileDialog, reset: resetFileDialog, onChange: onFileChange } = useFileDialog({\n  accept: `.md,.markdown,.txt`,\n  multiple: true,\n})\n\nonFileChange((files) => {\n  if (files == null || files.length === 0)\n    return\n  readAndImportFiles(Array.from(files))\n})\n\nfunction handleDrop(event: DragEvent) {\n  event.preventDefault()\n  isDragover.value = false\n\n  const files = event.dataTransfer?.files\n  if (!files || files.length === 0)\n    return\n\n  const validFiles = Array.from(files).filter(file =>\n    file.name.match(/\\.(md|markdown|txt)$/i),\n  )\n\n  if (validFiles.length === 0) {\n    toast.error(`请拖入 Markdown 文件（.md / .markdown / .txt）`)\n    return\n  }\n\n  readAndImportFiles(validFiles)\n}\n\nfunction readFileAsText(file: File): Promise<string> {\n  return new Promise((resolve) => {\n    const reader = new FileReader()\n    reader.readAsText(file, `UTF-8`)\n    reader.onload = (event) => {\n      resolve((event.target?.result as string) || ``)\n    }\n    reader.onerror = () => resolve(``)\n  })\n}\n\nasync function readAndImportFiles(files: File[]) {\n  const contents = await Promise.all(files.map(readFileAsText))\n  const merged = contents.filter(c => c.trim()).join(`\\n\\n`)\n  if (merged) {\n    editorStore.importContent(merged)\n    closeDialog()\n  }\n}\n\n// ==================== 对话框控制 ====================\nfunction closeDialog() {\n  abortController?.abort()\n  abortController = null\n  isShowImportMdDialog.value = false\n  url.value = ``\n  urlError.value = ``\n  isUrlLoading.value = false\n  isDragover.value = false\n  resetFileDialog()\n}\n\nfunction onOpenChange(val: boolean) {\n  if (!val) {\n    closeDialog()\n  }\n}\n\n// URL 参数 open 传入的链接：打开对话框时自动填入并执行导入\nwatch(isShowImportMdDialog, (visible) => {\n  if (!visible || !uiStore.importMdOpenUrl)\n    return\n  const urlToImport = uiStore.importMdOpenUrl\n  uiStore.importMdOpenUrl = null\n  url.value = urlToImport\n  activeTab.value = `url`\n  urlError.value = ``\n  nextTick(() => importFromUrl())\n})\n</script>\n\n<template>\n  <Dialog :open=\"isShowImportMdDialog\" @update:open=\"onOpenChange\">\n    <DialogContent class=\"sm:max-w-xl\">\n      <DialogHeader>\n        <DialogTitle>导入 Markdown</DialogTitle>\n        <DialogDescription>\n          从网络链接或本地文件导入内容，支持公众号文章、博客等任意网页链接\n        </DialogDescription>\n      </DialogHeader>\n\n      <Tabs v-model=\"activeTab\" class=\"w-full\">\n        <TabsList class=\"grid w-full grid-cols-2\">\n          <TabsTrigger value=\"file\">\n            <span class=\"inline-flex items-center\">\n              <Upload class=\"mr-2 size-4 shrink-0\" />\n              本地文件\n            </span>\n          </TabsTrigger>\n          <TabsTrigger value=\"url\">\n            <span class=\"inline-flex items-center\">\n              <Globe class=\"mr-2 size-4 shrink-0\" />\n              网络链接\n            </span>\n          </TabsTrigger>\n        </TabsList>\n\n        <!-- 本地文件导入 -->\n        <TabsContent value=\"file\" class=\"mt-4\">\n          <div\n            class=\"relative flex h-40 cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed transition-colors\"\n            :class=\"{\n              'border-primary bg-primary/5': isDragover,\n              'border-muted-foreground/25 hover:border-muted-foreground/50': !isDragover,\n            }\"\n            @click=\"openFileDialog()\"\n            @dragover.prevent=\"isDragover = true\"\n            @dragleave.prevent=\"isDragover = false\"\n            @drop=\"handleDrop\"\n          >\n            <FileText class=\"mb-3 size-10 text-muted-foreground\" />\n            <p class=\"text-sm text-muted-foreground\">\n              点击选择文件或拖拽文件到此处\n            </p>\n            <p class=\"mt-1 text-xs text-muted-foreground/70\">\n              支持 .md、.markdown、.txt 格式\n            </p>\n          </div>\n        </TabsContent>\n\n        <!-- 网络链接导入 -->\n        <TabsContent value=\"url\" class=\"mt-4\">\n          <div class=\"space-y-4\">\n            <div class=\"space-y-2\">\n              <Input\n                v-model=\"url\"\n                placeholder=\"如：https://mp.weixin.qq.com/s/xxxxx\"\n                :class=\"{ 'border-destructive': urlError }\"\n                @keydown.enter=\"importFromUrl\"\n                @input=\"urlError = ``\"\n              />\n              <p v-if=\"urlError\" class=\"text-xs text-destructive\">\n                {{ urlError }}\n              </p>\n              <p v-else class=\"text-xs text-muted-foreground\">\n                支持 Markdown 文件链接直接导入，或网页链接自动转换\n              </p>\n            </div>\n            <Button\n              class=\"w-full\"\n              :disabled=\"isUrlLoading || !url.trim()\"\n              @click=\"importFromUrl\"\n            >\n              <Loader2 v-if=\"isUrlLoading\" class=\"mr-2 size-4 animate-spin\" />\n              {{ isUrlLoading ? '导入中...' : '导入' }}\n            </Button>\n            <p class=\"text-center text-xs text-muted-foreground/60\">\n              基于\n              <a\n                href=\"https://github.com/doocs/anything-md\"\n                target=\"_blank\"\n                class=\"underline hover:text-muted-foreground\"\n              >Anything-MD</a>\n              提供转换服务\n            </p>\n          </div>\n        </TabsContent>\n      </Tabs>\n    </DialogContent>\n  </Dialog>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/editor/InsertFormDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport { useEditorStore } from '@/stores/editor'\nimport { useUIStore } from '@/stores/ui'\nimport { createTable } from '@/utils'\n\nconst editorStore = useEditorStore()\nconst uiStore = useUIStore()\n\nconst { toggleShowInsertFormDialog } = uiStore\n\nconst rowNum = ref(3)\nconst colNum = ref(3)\nconst tableData = ref<Record<string, string>>({})\n\nfunction resetVal() {\n  rowNum.value = 3\n  colNum.value = 3\n  tableData.value = {}\n}\n\n// 插入表格\nfunction insertTable() {\n  const table = createTable({\n    rows: rowNum.value,\n    cols: colNum.value,\n    data: tableData.value,\n  })\n  const editor = toRaw(editorStore.editor!)\n  const selection = editor.state.selection.main\n  editor.dispatch({\n    changes: { from: selection.from, to: selection.to, insert: `\\n${table}\\n` },\n  })\n  resetVal()\n  toggleShowInsertFormDialog()\n}\n\nfunction onUpdate(val: boolean) {\n  if (!val) {\n    toggleShowInsertFormDialog(false)\n  }\n}\n</script>\n\n<template>\n  <Dialog :open=\"uiStore.isShowInsertFormDialog\" @update:open=\"onUpdate\">\n    <DialogContent>\n      <DialogHeader>\n        <DialogTitle>插入表格</DialogTitle>\n      </DialogHeader>\n      <div class=\"space-x-2 flex justify-between\">\n        <NumberField v-model=\"rowNum\" :min=\"1\" :max=\"100\">\n          <Label>行数</Label>\n          <NumberFieldContent>\n            <NumberFieldDecrement />\n            <NumberFieldInput />\n            <NumberFieldIncrement />\n          </NumberFieldContent>\n        </NumberField>\n        <NumberField v-model=\"colNum\" :min=\"1\" :max=\"100\">\n          <Label>列数</Label>\n          <NumberFieldContent>\n            <NumberFieldDecrement />\n            <NumberFieldInput />\n            <NumberFieldIncrement />\n          </NumberFieldContent>\n        </NumberField>\n      </div>\n      <div class=\"space-y-2 border rounded p-2\">\n        <div v-for=\"row in rowNum + 1\" :key=\"row\" :class=\"{ 'head-style': row === 1 }\" class=\"space-x-2 flex\">\n          <Input\n            v-for=\"col in colNum\" :key=\"col\"\n            v-model=\"tableData[`k_${row - 1}_${col - 1}`]\"\n            :class=\"{\n              'bg-gray-100 dark:bg-gray-900': row === 1,\n            }\"\n            :placeholder=\"row === 1 ? '表头' : ''\"\n          />\n        </div>\n      </div>\n      <DialogFooter>\n        <Button variant=\"outline\" @click=\"toggleShowInsertFormDialog(false)\">\n          取 消\n        </Button>\n        <Button @click=\"insertTable\">\n          确 定\n        </Button>\n      </DialogFooter>\n    </DialogContent>\n  </Dialog>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/editor/InsertMpCardDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport { toTypedSchema } from '@vee-validate/yup'\nimport { Field, Form } from 'vee-validate'\nimport * as yup from 'yup'\nimport { useEditorStore } from '@/stores/editor'\nimport { addPrefix } from '@/utils'\nimport { store } from '@/utils/storage'\n\n/** 编辑器实例和全局弹窗状态 */\nconst editorStore = useEditorStore()\nconst uiStore = useUIStore()\nconst { toggleShowInsertMpCardDialog } = uiStore\n\ninterface Config {\n  id: string\n  name: string\n  logo: string\n  desc: string\n  /**\n   * 1: 公众号\n   * 2: 服务号\n   */\n  serviceType: `1` | `2`\n  /**\n   * 0: 无标识\n   * 1: 个人认证\n   * 2: 企业认证\n   */\n  verify: `0` | `1` | `2`\n}\n\n/** 表单字段 */\nconst config = store.reactive<Config>(addPrefix(`mp-profile`), {\n  id: ``,\n  name: ``,\n  logo: ``,\n  desc: ``,\n  serviceType: `1`,\n  verify: `0`,\n})\n\nconst schema = toTypedSchema(yup.object({\n  id: yup.string().required(`公众号 ID 不能为空`),\n  name: yup.string().required(`公众号名称 不能为空`),\n  logo: yup.string().optional().url(`公众号 Logo 必须是一个有效的 URL`),\n  desc: yup.string().optional(),\n  serviceType: yup.string().required(),\n  verify: yup.string().required(),\n}))\n\n/** 组装 HTML 片段 */\nfunction buildMpHtml(config: Config) {\n  const logo = config.logo || `https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/doocs/md/images/mp-logo.png`\n  const attrs = [\n    `data-pluginname=\"mpprofile\"`,\n    `data-id=\"${config.id}\"`,\n    `data-nickname=\"${config.name}\"`,\n    `data-headimg=\"${logo}\"`,\n    config.desc && `data-signature=\"${config.desc}\"`,\n    `data-service_type=\"${config.serviceType || `1`}\"`,\n    `data-verify_status=\"${config.verify || `0`}\"`,\n  ].filter(Boolean).join(` `)\n\n  return `<section class=\"mp_profile_iframe_wrp custom_select_card_wrp\" nodeleaf=\"\">\n  <mp-common-profile class=\"mpprofile js_uneditable custom_select_card mp_profile_iframe\" ${attrs}></mp-common-profile>\n  <br class=\"ProseMirror-trailingBreak\">\n</section>`\n}\n\nfunction submit(formValues: any) {\n  config.value = formValues as Config\n  const html = buildMpHtml(formValues as Config)\n  const editor = toRaw(editorStore.editor!)\n  const selection = editor.state.selection.main\n  editor.dispatch({\n    changes: { from: selection.from, to: selection.to, insert: `\\n${html}\\n` },\n  })\n  toast.success(`公众号名片插入成功`)\n  toggleShowInsertMpCardDialog(false)\n}\n</script>\n\n<template>\n  <Dialog v-model:open=\"uiStore.isShowInsertMpCardDialog\">\n    <DialogContent class=\"!w-[750px] !max-w-[95vw] max-h-[85vh] flex flex-col overflow-hidden\">\n      <DialogHeader>\n        <DialogTitle>插入公众号名片</DialogTitle>\n      </DialogHeader>\n\n      <Form :validation-schema=\"schema\" :initial-values=\"config\" class=\"flex flex-col flex-1 overflow-hidden\" @submit=\"submit\">\n        <div class=\"flex-1 overflow-y-auto p-1 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden flex flex-col\">\n          <Field v-slot=\"{ field, errorMessage }\" name=\"id\">\n            <FormItem label=\"ID\" required :error=\"errorMessage\" :width=\"50\">\n              <Input\n                v-bind=\"field\"\n                v-model.trim=\"field.value\"\n                placeholder=\"例：MzIxNjA5ODQ0OQ==\"\n              />\n            </FormItem>\n          </Field>\n\n          <Field v-slot=\"{ field, errorMessage }\" name=\"name\">\n            <FormItem label=\"名称\" required :error=\"errorMessage\" :width=\"50\">\n              <Input\n                v-bind=\"field\"\n                v-model.trim=\"field.value\"\n                placeholder=\"例：Doocs\"\n              />\n            </FormItem>\n          </Field>\n\n          <Field v-slot=\"{ field, errorMessage }\" name=\"logo\">\n            <FormItem label=\"Logo\" :error=\"errorMessage\" :width=\"50\">\n              <Input\n                v-bind=\"field\"\n                v-model.trim=\"field.value\"\n                placeholder=\"例：https://doocs.com/mp-logo.png\"\n              />\n            </FormItem>\n          </Field>\n\n          <Field v-slot=\"{ field, errorMessage }\" name=\"desc\">\n            <FormItem label=\"描述\" :error=\"errorMessage\" :width=\"50\">\n              <Textarea\n                v-bind=\"field\"\n                v-model.trim=\"field.value\"\n                rows=\"3\"\n                class=\"resize-none\"\n                placeholder=\"例：GitHub 开源组织 @Doocs 旗下唯一公众号，专注分享技术领域相关知识及行业最新资讯。\"\n              />\n            </FormItem>\n          </Field>\n\n          <Field v-slot=\"{ field, errorMessage }\" name=\"serviceType\">\n            <FormItem label=\"类型\" required :error=\"errorMessage\" :width=\"50\">\n              <RadioGroup class=\"flex gap-5\" v-bind=\"field\" :default-value=\"field.value\">\n                <div class=\"inline-flex items-center space-x-2 w-20\">\n                  <RadioGroupItem id=\"option-one\" value=\"1\" />\n                  <Label for=\"option-one\">公众号</Label>\n                </div>\n                <div class=\"inline-flex items-center space-x-2 w-20\">\n                  <RadioGroupItem id=\"option-two\" value=\"2\" />\n                  <Label for=\"option-two\">服务号</Label>\n                </div>\n              </RadioGroup>\n            </FormItem>\n          </Field>\n\n          <Field v-slot=\"{ field, errorMessage }\" name=\"verify\">\n            <FormItem label=\"认证\" required :error=\"errorMessage\" :width=\"50\">\n              <RadioGroup class=\"flex gap-5\" v-bind=\"field\" :default-value=\"field.value\">\n                <div class=\"inline-flex items-center space-x-2 w-20\">\n                  <RadioGroupItem id=\"service-type-option-one\" value=\"0\" />\n                  <Label for=\"service-type-option-one\">无</Label>\n                </div>\n                <div class=\"inline-flex items-center space-x-2 w-20\">\n                  <RadioGroupItem id=\"service-type-option-two\" value=\"1\" />\n                  <Label for=\"service-type-option-two\">个人</Label>\n                </div>\n                <div class=\"inline-flex items-center space-x-2 w-20\">\n                  <RadioGroupItem id=\"service-type-option-three\" value=\"2\" />\n                  <Label for=\"service-type-option-three\">企业</Label>\n                </div>\n              </RadioGroup>\n            </FormItem>\n          </Field>\n\n          <FormItem :width=\"50\">\n            <Button\n              variant=\"link\"\n              class=\"p-0 h-auto text-left whitespace-normal\"\n              as=\"a\"\n              href=\"https://github.com/doocs/md/blob/main/docs/mp-card.md\"\n              target=\"_blank\"\n            >\n              如何获取公众号 ID？\n            </Button>\n          </FormItem>\n        </div>\n\n        <DialogFooter class=\"p-1\">\n          <Button type=\"submit\">\n            确定\n          </Button>\n        </DialogFooter>\n      </Form>\n    </DialogContent>\n  </Dialog>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/editor/RightSlider.vue",
    "content": "<script setup lang=\"ts\">\nimport type {\n  HeadingLevel,\n  HeadingStyleType,\n  themeMap,\n} from '@md/shared/configs'\nimport type { Format } from 'vue-pick-colors'\nimport {\n  codeBlockThemeOptions,\n  colorOptions,\n  fontFamilyOptions,\n  fontSizeOptions,\n  headingLevelOptions,\n  headingStyleOptions,\n  legendOptions,\n  themeOptions,\n} from '@md/shared/configs'\nimport { X } from 'lucide-vue-next'\nimport PickColors from 'vue-pick-colors'\nimport { useCssEditorStore } from '@/stores/cssEditor'\nimport { useEditorStore } from '@/stores/editor'\nimport { useRenderStore } from '@/stores/render'\nimport { useThemeStore } from '@/stores/theme'\nimport { useUIStore } from '@/stores/ui'\n\nconst cssEditorStore = useCssEditorStore()\nconst uiStore = useUIStore()\nconst themeStore = useThemeStore()\nconst {\n  theme,\n  fontFamily,\n  fontSize,\n  primaryColor,\n  codeBlockTheme,\n  legend,\n  isMacCodeBlock,\n  isShowLineNumber,\n  isCiteStatus,\n  isUseIndent,\n  isUseJustify,\n} = storeToRefs(themeStore)\n\n// 标题样式选择器状态\nconst selectedHeadingLevel = ref<HeadingLevel>(`h2`)\nconst selectedHeadingStyle = computed({\n  get: () => themeStore.getHeadingStyle(selectedHeadingLevel.value),\n  set: (val: HeadingStyleType) => {\n    themeStore.setHeadingStyle(selectedHeadingLevel.value, val)\n    if (val === `custom`) {\n      // 打开 CSS 编辑器并滚动到对应标题区域\n      uiStore.isShowCssEditor = true\n      // 等待 CSS 编辑器打开后再滚动\n      nextTick(() => {\n        setTimeout(() => {\n          cssEditorStore.scrollToHeading(selectedHeadingLevel.value)\n        }, 100)\n      })\n    }\n    // 无论选择预设还是自定义，都立即应用主题，确保标题样式及时恢复/更新\n    themeStore.applyCurrentTheme()\n    editorRefresh()\n  },\n})\n\nconst { isMobile, isOpenRightSlider, isDark } = storeToRefs(uiStore)\n\nconst editorStore = useEditorStore()\nconst renderStore = useRenderStore()\n\n// Editor refresh function - triggers re-render with current theme settings\nfunction editorRefresh() {\n  themeStore.updateCodeTheme()\n\n  const raw = editorStore.getContent()\n  renderStore.render(raw)\n}\n\n// Theme change handlers\nfunction themeChanged(newTheme: keyof typeof themeMap) {\n  themeStore.theme = newTheme\n  // 使用新主题系统\n  themeStore.applyCurrentTheme()\n  editorRefresh()\n}\n\nfunction fontChanged(fonts: string) {\n  themeStore.fontFamily = fonts\n  // 使用新主题系统\n  themeStore.applyCurrentTheme()\n  editorRefresh()\n}\n\nfunction sizeChanged(size: string) {\n  themeStore.fontSize = size\n  // 使用新主题系统\n  themeStore.applyCurrentTheme()\n  editorRefresh()\n}\n\nfunction colorChanged(newColor: string) {\n  themeStore.primaryColor = newColor\n  // 使用新主题系统\n  themeStore.applyCurrentTheme()\n  editorRefresh()\n}\n\nfunction codeBlockThemeChanged(newTheme: string) {\n  themeStore.codeBlockTheme = newTheme\n  editorRefresh()\n}\n\nfunction legendChanged(newVal: string) {\n  themeStore.legend = newVal\n  editorRefresh()\n}\n\nfunction macCodeBlockChanged() {\n  themeStore.isMacCodeBlock = !themeStore.isMacCodeBlock\n  editorRefresh()\n}\n\nfunction showLineNumberChanged() {\n  themeStore.isShowLineNumber = !themeStore.isShowLineNumber\n  editorRefresh()\n}\n\nfunction citeStatusChanged() {\n  themeStore.isCiteStatus = !themeStore.isCiteStatus\n  editorRefresh()\n}\n\nfunction useIndentChanged() {\n  themeStore.isUseIndent = !themeStore.isUseIndent\n  // 使用新主题系统\n  themeStore.applyCurrentTheme()\n  editorRefresh()\n}\n\nfunction useJustifyChanged() {\n  themeStore.isUseJustify = !themeStore.isUseJustify\n  // 使用新主题系统\n  themeStore.applyCurrentTheme()\n  editorRefresh()\n}\n\nfunction resetStyleConfirm() {\n  uiStore.isOpenConfirmDialog = true\n}\n\n// 控制是否启用动画\nconst enableAnimation = ref(false)\n\n// 监听 RightSlider 开关状态变化\nwatch(isOpenRightSlider, () => {\n  if (isMobile.value) {\n    // 在移动端，用户操作时启用动画\n    enableAnimation.value = true\n  }\n})\n\n// 监听设备类型变化，重置动画状态\nwatch(isMobile, () => {\n  enableAnimation.value = false\n})\n\nconst isOpen = ref(false)\n\nconst addPostInputVal = ref(``)\n\nwatch(isOpen, () => {\n  if (isOpen.value) {\n    addPostInputVal.value = ``\n  }\n})\n\nconst pickColorsContainer = useTemplateRef<HTMLElement | undefined>(`pickColorsContainer`)\nconst format = ref<Format>(`rgb`)\nconst formatOptions = ref<Format[]>([`rgb`, `hex`, `hsl`, `hsv`])\n</script>\n\n<template>\n  <!-- 移动端遮罩层 -->\n  <div\n    v-if=\"isMobile && isOpenRightSlider\"\n    class=\"fixed inset-0 bg-black/50 z-40\"\n    @click=\"isOpenRightSlider = false\"\n  />\n\n  <div\n    class=\"overflow-hidden mobile-right-drawer\"\n    :class=\"{\n      // 移动端样式\n      'fixed top-0 right-0 w-full h-full z-55 bg-background border-l shadow-lg': isMobile,\n      'animate': isMobile && enableAnimation,\n      // 桌面端样式\n      'border-l-2 order-2 border-gray/20 bg-white transition-width duration-300 dark:bg-[#191919]': !isMobile,\n      'w-100': !isMobile && isOpenRightSlider,\n      'w-0 border-l-0': !isMobile && !isOpenRightSlider,\n    }\"\n    :style=\"{\n      transform: isMobile ? (isOpenRightSlider ? 'translateX(0)' : 'translateX(100%)') : 'none',\n    }\"\n  >\n    <div\n      class=\"space-y-4 h-full overflow-auto p-4\"\n      :class=\"{\n        // 移动端不需要额外的transform\n        'pt-0': isMobile,\n        // 桌面端保持原有的动画\n        'transition-transform': !isMobile,\n        'translate-x-0': !isMobile && isOpenRightSlider,\n        'translate-x-full': !isMobile && !isOpenRightSlider,\n      }\"\n    >\n      <!-- 移动端标题栏 -->\n      <div v-if=\"isMobile\" class=\"sticky top-0 z-10 flex items-center justify-between -mx-4 px-4 py-3 border-b mb-4 bg-background\">\n        <h2 class=\"text-lg font-semibold\">\n          样式设置\n        </h2>\n        <Button variant=\"ghost\" size=\"sm\" @click=\"isOpenRightSlider = false\">\n          <X class=\"h-4 w-4\" />\n        </Button>\n      </div>\n      <div class=\"space-y-2\">\n        <h2>主题</h2>\n        <div class=\"grid grid-cols-3 justify-items-center gap-2\">\n          <Button\n            v-for=\"{ label, value } in themeOptions\" :key=\"value\" class=\"w-full\" variant=\"outline\" :class=\"{\n              'border-black dark:border-white border-2': theme === value,\n            }\" @click=\"themeChanged(value)\"\n          >\n            {{ label }}\n          </Button>\n        </div>\n      </div>\n      <div class=\"space-y-2\">\n        <h2>字体</h2>\n        <div class=\"grid grid-cols-3 justify-items-center gap-2\">\n          <Button\n            v-for=\"{ label, value } in fontFamilyOptions\" :key=\"value\" variant=\"outline\" class=\"w-full\"\n            :class=\"{ 'border-black dark:border-white border-2': fontFamily === value }\" @click=\"fontChanged(value)\"\n          >\n            {{ label }}\n          </Button>\n        </div>\n      </div>\n      <div class=\"space-y-2\">\n        <h2>字号</h2>\n        <div class=\"grid grid-cols-5 justify-items-center gap-2\">\n          <Button\n            v-for=\"{ value, desc } in fontSizeOptions\" :key=\"value\" variant=\"outline\" class=\"w-full\" :class=\"{\n              'border-black dark:border-white border-2': fontSize === value,\n            }\" @click=\"sizeChanged(value)\"\n          >\n            {{ desc }}\n          </Button>\n        </div>\n      </div>\n      <div class=\"space-y-2\">\n        <h2>主题色</h2>\n        <div class=\"grid grid-cols-3 justify-items-center gap-2\">\n          <Button\n            v-for=\"{ label, value } in colorOptions\" :key=\"value\" class=\"w-full\" variant=\"outline\" :class=\"{\n              'border-black dark:border-white border-2': primaryColor === value,\n            }\" @click=\"colorChanged(value)\"\n          >\n            <span\n              class=\"mr-2 inline-block h-4 w-4 rounded-full\" :style=\"{\n                background: value,\n              }\"\n            />\n            {{ label }}\n          </Button>\n        </div>\n      </div>\n      <div class=\"space-y-2\">\n        <h2>自定义主题色</h2>\n        <div ref=\"pickColorsContainer\">\n          <PickColors\n            v-if=\"pickColorsContainer\" v-model:value=\"primaryColor\" show-alpha :format=\"format\"\n            :format-options=\"formatOptions\" :theme=\"isDark ? 'dark' : 'light'\"\n            :popup-container=\"pickColorsContainer\" @change=\"colorChanged\"\n          />\n        </div>\n      </div>\n      <div class=\"space-y-2\">\n        <h2>标题样式</h2>\n        <div class=\"flex gap-2\">\n          <Select v-model=\"selectedHeadingLevel\">\n            <SelectTrigger class=\"w-[120px]\">\n              <SelectValue placeholder=\"选择标题\" />\n            </SelectTrigger>\n            <SelectContent>\n              <SelectItem v-for=\"{ label, value } in headingLevelOptions\" :key=\"value\" :value=\"value\">\n                {{ label }}\n              </SelectItem>\n            </SelectContent>\n          </Select>\n          <Select v-model=\"selectedHeadingStyle\">\n            <SelectTrigger class=\"flex-1\">\n              <SelectValue placeholder=\"选择样式\" />\n            </SelectTrigger>\n            <SelectContent>\n              <SelectItem v-for=\"{ label, value } in headingStyleOptions\" :key=\"value\" :value=\"value\">\n                {{ label }}\n              </SelectItem>\n            </SelectContent>\n          </Select>\n        </div>\n      </div>\n      <div class=\"space-y-2\">\n        <h2>代码块主题</h2>\n        <div>\n          <Select v-model=\"codeBlockTheme\" @update:model-value=\"codeBlockThemeChanged\">\n            <SelectTrigger>\n              <SelectValue placeholder=\"Select a code block theme\" />\n            </SelectTrigger>\n            <SelectContent>\n              <SelectItem v-for=\"{ label, value } in codeBlockThemeOptions\" :key=\"label\" :value=\"value\">\n                {{ label }}\n              </SelectItem>\n            </SelectContent>\n          </Select>\n        </div>\n      </div>\n      <div class=\"space-y-2\">\n        <h2>图注格式</h2>\n        <div class=\"grid grid-cols-3 justify-items-center gap-2\">\n          <Button\n            v-for=\"{ label, value } in legendOptions\" :key=\"value\" class=\"w-full\" variant=\"outline\" :class=\"{\n              'border-black dark:border-white border-2': legend === value,\n            }\" @click=\"legendChanged(value)\"\n          >\n            {{ label }}\n          </Button>\n        </div>\n      </div>\n\n      <div class=\"space-y-2\">\n        <h2>Mac 代码块</h2>\n        <div class=\"grid grid-cols-5 justify-items-center gap-2\">\n          <Button\n            class=\"w-full\" variant=\"outline\" :class=\"{\n              'border-black dark:border-white border-2': isMacCodeBlock,\n            }\" @click=\"!isMacCodeBlock && macCodeBlockChanged()\"\n          >\n            开启\n          </Button>\n          <Button\n            class=\"w-full\" variant=\"outline\" :class=\"{\n              'border-black dark:border-white border-2': !isMacCodeBlock,\n            }\" @click=\"isMacCodeBlock && macCodeBlockChanged()\"\n          >\n            关闭\n          </Button>\n        </div>\n      </div>\n      <div class=\"space-y-2\">\n        <h2>代码块行号</h2>\n        <div class=\"grid grid-cols-5 justify-items-center gap-2\">\n          <Button\n            class=\"w-full\" variant=\"outline\" :class=\"{\n              'border-black dark:border-white border-2': isShowLineNumber,\n            }\" @click=\"!isShowLineNumber && showLineNumberChanged()\"\n          >\n            开启\n          </Button>\n          <Button\n            class=\"w-full\" variant=\"outline\" :class=\"{\n              'border-black dark:border-white border-2': !isShowLineNumber,\n            }\" @click=\"isShowLineNumber && showLineNumberChanged()\"\n          >\n            关闭\n          </Button>\n        </div>\n      </div>\n\n      <div class=\"space-y-2\">\n        <h2>微信外链转底部引用</h2>\n        <div class=\"grid grid-cols-5 justify-items-center gap-2\">\n          <Button\n            class=\"w-full\" variant=\"outline\" :class=\"{\n              'border-black dark:border-white border-2': isCiteStatus,\n            }\" @click=\"!isCiteStatus && citeStatusChanged()\"\n          >\n            开启\n          </Button>\n          <Button\n            class=\"w-full\" variant=\"outline\" :class=\"{\n              'border-black dark:border-white border-2': !isCiteStatus,\n            }\" @click=\"isCiteStatus && citeStatusChanged()\"\n          >\n            关闭\n          </Button>\n        </div>\n      </div>\n      <div class=\"space-y-2\">\n        <h2>段落首行缩进</h2>\n        <div class=\"grid grid-cols-5 justify-items-center gap-2\">\n          <Button\n            class=\"w-full\" variant=\"outline\" :class=\"{\n              'border-black dark:border-white border-2': isUseIndent,\n            }\" @click=\"!isUseIndent && useIndentChanged()\"\n          >\n            开启\n          </Button>\n          <Button\n            class=\"w-full\" variant=\"outline\" :class=\"{\n              'border-black dark:border-white border-2': !isUseIndent,\n            }\" @click=\"isUseIndent && useIndentChanged()\"\n          >\n            关闭\n          </Button>\n        </div>\n      </div>\n      <div class=\"space-y-2\">\n        <h2>段落两端对齐</h2>\n        <div class=\"grid grid-cols-5 justify-items-center gap-2\">\n          <Button\n            class=\"w-full\" variant=\"outline\" :class=\"{\n              'border-black dark:border-white border-2': isUseJustify,\n            }\" @click=\"!isUseJustify && useJustifyChanged()\"\n          >\n            开启\n          </Button>\n          <Button\n            class=\"w-full\" variant=\"outline\" :class=\"{\n              'border-black dark:border-white border-2': !isUseJustify,\n            }\" @click=\"isUseJustify && useJustifyChanged()\"\n          >\n            关闭\n          </Button>\n        </div>\n      </div>\n      <div class=\"space-y-2\">\n        <h2>样式配置</h2>\n        <Button variant=\"destructive\" @click=\"resetStyleConfirm\">\n          重置\n        </Button>\n      </div>\n    </div>\n  </div>\n</template>\n\n<style scoped>\n/* 移动端右侧栏动画 - 只有添加了 animate 类才启用 */\n.mobile-right-drawer.animate {\n  transition: transform 300ms cubic-bezier(0.16, 1, 0.3, 1);\n}\n</style>\n"
  },
  {
    "path": "apps/web/src/components/editor/TemplateDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport type { Template } from '@md/shared'\nimport { Calendar, Clock, FileDown, FileInput, FileText, Package, Pencil, Plus, Search, Trash2 } from 'lucide-vue-next'\nimport { useEditorStore } from '@/stores/editor'\nimport { usePostStore } from '@/stores/post'\nimport { useTemplateStore } from '@/stores/template'\nimport { useUIStore } from '@/stores/ui'\n\nconst editorStore = useEditorStore()\nconst postStore = usePostStore()\nconst templateStore = useTemplateStore()\nconst uiStore = useUIStore()\n\nconst { toggleShowTemplateDialog } = uiStore\n\n// 搜索关键词\nconst searchKeyword = ref('')\n\n// 搜索结果\nconst filteredTemplates = computed(() => {\n  return templateStore.searchTemplates(searchKeyword.value)\n})\n\n// 是否显示新建/编辑模板表单\nconst isShowForm = ref(false)\nconst formMode = ref<'create' | 'edit'>('create')\nconst editingTemplateId = ref<string>('')\n\n// 表单数据\nconst formData = reactive({\n  name: '',\n  description: '',\n  content: '',\n})\n\n// 表单验证错误\nconst formErrors = reactive({\n  name: '',\n})\n\n// 打开创建模板表单\nfunction openCreateForm() {\n  formMode.value = 'create'\n  formData.name = ''\n  formData.description = ''\n  formData.content = editorStore.getContent()\n  formErrors.name = ''\n  isShowForm.value = true\n}\n\n// 打开编辑模板表单\nfunction openEditForm(template: Template) {\n  formMode.value = 'edit'\n  editingTemplateId.value = template.id\n  formData.name = template.name\n  formData.description = template.description || ''\n  formData.content = template.content\n  formErrors.name = ''\n  isShowForm.value = true\n}\n\n// 验证表单\nfunction validateForm(): boolean {\n  formErrors.name = ''\n\n  if (!formData.name.trim()) {\n    formErrors.name = '模板名称不能为空'\n    return false\n  }\n\n  if (formData.name.trim().length > 50) {\n    formErrors.name = '模板名称不能超过 50 个字符'\n    return false\n  }\n\n  return true\n}\n\n// 保存模板\nfunction saveTemplate() {\n  if (!validateForm())\n    return\n\n  if (formMode.value === 'create') {\n    templateStore.createTemplate({\n      name: formData.name.trim(),\n      description: formData.description.trim() || undefined,\n      content: formData.content,\n    })\n  }\n  else {\n    templateStore.updateTemplate(editingTemplateId.value, {\n      name: formData.name.trim(),\n      description: formData.description.trim() || undefined,\n      content: formData.content,\n    })\n  }\n\n  isShowForm.value = false\n}\n\n// 取消表单\nfunction cancelForm() {\n  isShowForm.value = false\n}\n\n// 应用模板到当前文章\nfunction applyTemplate(template: Template) {\n  const currentPost = postStore.currentPost\n  if (currentPost) {\n    postStore.updatePostContent(currentPost.id, template.content)\n    editorStore.importContent(template.content)\n    toast.success(`已应用模板「${template.name}」到当前文章`)\n  }\n  else {\n    editorStore.importContent(template.content)\n    toast.success(`已应用模板「${template.name}」`)\n  }\n  toggleShowTemplateDialog(false)\n}\n\n// 在光标位置插入模板\nfunction insertTemplate(template: Template) {\n  editorStore.insertAtCursor(template.content)\n\n  const currentPost = postStore.currentPost\n  if (currentPost) {\n    postStore.updatePostContent(currentPost.id, editorStore.getContent())\n  }\n\n  toast.success(`已插入模板「${template.name}」`)\n  toggleShowTemplateDialog(false)\n}\n\n// 删除确认对话框\nconst deleteConfirmDialog = ref(false)\nconst templateToDelete = ref<Template | null>(null)\n\n// 打开删除确认对话框\nfunction openDeleteConfirm(template: Template) {\n  templateToDelete.value = template\n  deleteConfirmDialog.value = true\n}\n\n// 确认删除模板\nfunction confirmDelete() {\n  if (templateToDelete.value) {\n    templateStore.deleteTemplate(templateToDelete.value.id)\n    templateToDelete.value = null\n  }\n  deleteConfirmDialog.value = false\n}\n\n// 格式化日期\nfunction formatDate(timestamp: number): string {\n  const date = new Date(timestamp)\n  return date.toLocaleString('zh-CN', {\n    year: 'numeric',\n    month: '2-digit',\n    day: '2-digit',\n    hour: '2-digit',\n    minute: '2-digit',\n  })\n}\n\n// 对话框关闭回调\nfunction onUpdate(val: boolean) {\n  if (!val) {\n    toggleShowTemplateDialog(false)\n    isShowForm.value = false\n  }\n}\n</script>\n\n<template>\n  <Dialog :open=\"uiStore.isShowTemplateDialog\" @update:open=\"onUpdate\">\n    <DialogContent class=\"max-w-4xl max-h-[85vh] flex flex-col p-0\">\n      <DialogHeader class=\"px-6 pt-6 pb-4 border-b\">\n        <DialogTitle class=\"flex items-center gap-2\">\n          <Package class=\"size-5\" />\n          模板管理\n        </DialogTitle>\n        <DialogDescription>\n          保存和管理您的 Markdown 模板，快速复用常用内容\n        </DialogDescription>\n      </DialogHeader>\n\n      <!-- 主体内容区域 -->\n      <div class=\"flex-1 overflow-auto px-6 py-4\">\n        <!-- 新建/编辑表单 -->\n        <div v-if=\"isShowForm\" class=\"space-y-4 mb-6 p-4 border rounded-lg bg-muted/30\">\n          <div class=\"flex items-center justify-between\">\n            <h3 class=\"text-lg font-semibold\">\n              {{ formMode === 'create' ? '新建模板' : '编辑模板' }}\n            </h3>\n          </div>\n\n          <div class=\"space-y-4\">\n            <!-- 模板名称 -->\n            <div class=\"space-y-2\">\n              <Label for=\"template-name\">模板名称 *</Label>\n              <Input\n                id=\"template-name\"\n                v-model=\"formData.name\"\n                placeholder=\"请输入模板名称\"\n                :class=\"{ 'border-red-500': formErrors.name }\"\n              />\n              <p v-if=\"formErrors.name\" class=\"text-sm text-red-500\">\n                {{ formErrors.name }}\n              </p>\n            </div>\n\n            <!-- 模板描述 -->\n            <div class=\"space-y-2\">\n              <Label for=\"template-description\">模板描述</Label>\n              <Textarea\n                id=\"template-description\"\n                v-model=\"formData.description\"\n                placeholder=\"请输入模板描述（可选）\"\n                class=\"resize-none h-20\"\n              />\n            </div>\n\n            <!-- 模板内容编辑 -->\n            <div class=\"space-y-2\">\n              <Label for=\"template-content\">模板内容</Label>\n              <Textarea\n                id=\"template-content\"\n                v-model=\"formData.content\"\n                placeholder=\"请输入模板内容\"\n                class=\"resize-none h-40 font-mono text-sm\"\n              />\n            </div>\n          </div>\n\n          <!-- 表单操作按钮 -->\n          <div class=\"flex gap-2 justify-end\">\n            <Button variant=\"outline\" @click=\"cancelForm\">\n              取消\n            </Button>\n            <Button @click=\"saveTemplate\">\n              {{ formMode === 'create' ? '创建' : '保存' }}\n            </Button>\n          </div>\n        </div>\n\n        <!-- 搜索栏和新建按钮 -->\n        <div v-if=\"!isShowForm\" class=\"flex gap-2 mb-4\">\n          <div class=\"relative flex-1\">\n            <Search class=\"absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground\" />\n            <Input\n              v-model=\"searchKeyword\"\n              placeholder=\"搜索模板名称、描述...\"\n              class=\"pl-9\"\n            />\n          </div>\n          <Button @click=\"openCreateForm\">\n            <Plus class=\"mr-2 size-4\" />\n            新建模板\n          </Button>\n        </div>\n\n        <!-- 模板列表 -->\n        <div v-if=\"!isShowForm\" class=\"space-y-3\">\n          <!-- 空状态 -->\n          <div v-if=\"filteredTemplates.length === 0\" class=\"text-center py-12\">\n            <Package class=\"mx-auto size-12 text-muted-foreground mb-4\" />\n            <p class=\"text-muted-foreground mb-2\">\n              {{ searchKeyword ? '未找到匹配的模板' : '暂无模板' }}\n            </p>\n            <p v-if=\"!searchKeyword\" class=\"text-sm text-muted-foreground mb-4\">\n              点击「新建模板」按钮创建您的第一个模板\n            </p>\n          </div>\n\n          <!-- 模板卡片列表 -->\n          <div\n            v-for=\"template in filteredTemplates\"\n            :key=\"template.id\"\n            class=\"border rounded-lg p-4 hover:bg-muted/50 transition-colors\"\n          >\n            <div class=\"flex items-start justify-between gap-4\">\n              <!-- 模板信息 -->\n              <div class=\"flex-1 min-w-0\">\n                <div class=\"flex items-center gap-2 mb-2\">\n                  <FileText class=\"size-4 text-muted-foreground flex-shrink-0\" />\n                  <h4 class=\"font-medium truncate\">\n                    {{ template.name }}\n                  </h4>\n                </div>\n\n                <p v-if=\"template.description\" class=\"text-sm text-muted-foreground mb-2 line-clamp-2\">\n                  {{ template.description }}\n                </p>\n\n                <div class=\"flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground\">\n                  <span class=\"flex items-center gap-1\">\n                    <Calendar class=\"size-3\" />\n                    创建：{{ formatDate(template.createdAt) }}\n                  </span>\n                  <span class=\"flex items-center gap-1\">\n                    <Clock class=\"size-3\" />\n                    更新：{{ formatDate(template.updatedAt) }}\n                  </span>\n                </div>\n              </div>\n\n              <!-- 操作按钮 -->\n              <div class=\"flex gap-1 flex-shrink-0\">\n                <Button\n                  variant=\"outline\"\n                  size=\"icon\"\n                  class=\"size-8\"\n                  title=\"应用模板（替换全部内容）\"\n                  @click=\"applyTemplate(template)\"\n                >\n                  <FileInput class=\"size-4\" />\n                </Button>\n                <Button\n                  variant=\"outline\"\n                  size=\"icon\"\n                  class=\"size-8\"\n                  title=\"插入模板（在光标处插入）\"\n                  @click=\"insertTemplate(template)\"\n                >\n                  <FileDown class=\"size-4\" />\n                </Button>\n                <Button\n                  variant=\"outline\"\n                  size=\"icon\"\n                  class=\"size-8\"\n                  title=\"编辑模板\"\n                  @click=\"openEditForm(template)\"\n                >\n                  <Pencil class=\"size-4\" />\n                </Button>\n                <Button\n                  variant=\"outline\"\n                  size=\"icon\"\n                  class=\"size-8 text-destructive hover:text-destructive\"\n                  title=\"删除模板\"\n                  @click=\"openDeleteConfirm(template)\"\n                >\n                  <Trash2 class=\"size-4\" />\n                </Button>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <DialogFooter v-if=\"!isShowForm\" class=\"px-6 pb-6 pt-4 border-t\">\n        <div class=\"flex items-center justify-between w-full\">\n          <p class=\"text-sm text-muted-foreground\">\n            共 {{ templateStore.templateCount }} 个模板\n          </p>\n          <Button variant=\"outline\" @click=\"toggleShowTemplateDialog(false)\">\n            关闭\n          </Button>\n        </div>\n      </DialogFooter>\n    </DialogContent>\n  </Dialog>\n\n  <!-- 删除确认对话框 -->\n  <AlertDialog v-model:open=\"deleteConfirmDialog\">\n    <AlertDialogContent>\n      <AlertDialogHeader>\n        <AlertDialogTitle>确认删除</AlertDialogTitle>\n        <AlertDialogDescription>\n          确定要删除模板「{{ templateToDelete?.name }}」吗？此操作不可恢复。\n        </AlertDialogDescription>\n      </AlertDialogHeader>\n      <AlertDialogFooter>\n        <AlertDialogCancel>取消</AlertDialogCancel>\n        <AlertDialogAction @click=\"confirmDelete\">\n          确定\n        </AlertDialogAction>\n      </AlertDialogFooter>\n    </AlertDialogContent>\n  </AlertDialog>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/editor/ThemeCustomizer.vue",
    "content": "<script setup lang=\"ts\">\nimport { widthOptions } from '@md/shared/configs'\nimport { Moon, Sun } from 'lucide-vue-next'\nimport { useEditorStore } from '@/stores/editor'\nimport { useRenderStore } from '@/stores/render'\nimport { useThemeStore } from '@/stores/theme'\nimport { useUIStore } from '@/stores/ui'\n\nconst themeStore = useThemeStore()\nconst { previewWidth } = storeToRefs(themeStore)\n\nconst uiStore = useUIStore()\nconst { isDark, isEditOnLeft } = storeToRefs(uiStore)\nconst { toggleDark, toggleEditOnLeft } = uiStore\n\nconst editorStore = useEditorStore()\nconst renderStore = useRenderStore()\n\nfunction previewWidthChanged(newWidth: string) {\n  themeStore.previewWidth = newWidth\n  // Trigger editor refresh after preview width changed\n  editorRefresh()\n}\n\nfunction editorRefresh() {\n  themeStore.updateCodeTheme()\n\n  const raw = editorStore.getContent()\n  renderStore.render(raw)\n}\n\nfunction customStyle() {\n  uiStore.toggleShowCssEditor()\n}\n</script>\n\n<template>\n  <div class=\"theme-customizer space-y-4\">\n    <div class=\"space-y-2 hidden sm:block\">\n      <h2 class=\"text-sm font-medium\">\n        编辑区位置\n      </h2>\n      <div class=\"grid grid-cols-2 justify-items-center gap-2\">\n        <Button\n          class=\"w-full\" variant=\"outline\" :class=\"{\n            'border-black dark:border-white border-2': isEditOnLeft,\n          }\" @click=\"!isEditOnLeft && toggleEditOnLeft()\"\n        >\n          左侧\n        </Button>\n        <Button\n          class=\"w-full\" variant=\"outline\" :class=\"{\n            'border-black dark:border-white border-2': !isEditOnLeft,\n          }\" @click=\"isEditOnLeft && toggleEditOnLeft()\"\n        >\n          右侧\n        </Button>\n      </div>\n    </div>\n    <div class=\"space-y-2 hidden sm:block\">\n      <h2 class=\"text-sm font-medium\">\n        预览模式\n      </h2>\n      <div class=\"grid grid-cols-2 justify-items-center gap-2\">\n        <Button\n          v-for=\"{ label, value } in widthOptions\" :key=\"value\" class=\"w-full\" variant=\"outline\" :class=\"{\n            'border-black dark:border-white border-2': previewWidth === value,\n          }\" @click=\"previewWidthChanged(value)\"\n        >\n          {{ label }}\n        </Button>\n      </div>\n    </div>\n    <div class=\"space-y-2\">\n      <h2 class=\"text-sm font-medium\">\n        自定义 CSS 面板\n      </h2>\n      <div class=\"grid grid-cols-2 justify-items-center gap-2\">\n        <Button\n          class=\"w-full\" variant=\"outline\" :class=\"{\n            'border-black dark:border-white border-2': uiStore.isShowCssEditor,\n          }\" @click=\"!uiStore.isShowCssEditor && customStyle()\"\n        >\n          开启\n        </Button>\n        <Button\n          class=\"w-full\" variant=\"outline\" :class=\"{\n            'border-black dark:border-white border-2': !uiStore.isShowCssEditor,\n          }\" @click=\"uiStore.isShowCssEditor && customStyle()\"\n        >\n          关闭\n        </Button>\n      </div>\n    </div>\n    <div class=\"space-y-2\">\n      <h2 class=\"text-sm font-medium\">\n        浮动目录\n      </h2>\n      <div class=\"grid grid-cols-2 justify-items-center gap-2\">\n        <Button\n          class=\"w-full\" variant=\"outline\" :class=\"{\n            'border-black dark:border-white border-2': uiStore.isPinFloatingToc,\n          }\" @click=\"!uiStore.isPinFloatingToc && uiStore.togglePinFloatingToc()\"\n        >\n          常驻显示\n        </Button>\n        <Button\n          class=\"w-full\" variant=\"outline\" :class=\"{\n            'border-black dark:border-white border-2': !uiStore.isPinFloatingToc,\n          }\" @click=\"uiStore.isPinFloatingToc && uiStore.togglePinFloatingToc()\"\n        >\n          移入触发\n        </Button>\n      </div>\n    </div>\n    <div class=\"space-y-2\">\n      <h2 class=\"text-sm font-medium\">\n        模式\n      </h2>\n      <div class=\"grid grid-cols-2 justify-items-center gap-2\">\n        <Button\n          class=\"w-full\" variant=\"outline\" :class=\"{\n            'border-black dark:border-white border-2': !isDark,\n          }\" @click=\"toggleDark(false)\"\n        >\n          <Sun class=\"h-4 w-4\" />\n        </Button>\n        <Button\n          class=\"w-full\" variant=\"outline\" :class=\"{\n            'border-black dark:border-white border-2': isDark,\n          }\" @click=\"toggleDark(true)\"\n        >\n          <Moon class=\"h-4 w-4\" />\n        </Button>\n      </div>\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/editor/UploadImgDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport { toTypedSchema } from '@vee-validate/yup'\nimport { UploadCloud } from 'lucide-vue-next'\nimport { Field, Form } from 'vee-validate'\nimport * as yup from 'yup'\nimport { useUIStore } from '@/stores/ui'\nimport { checkImage } from '@/utils'\nimport { store } from '@/utils/storage'\n\nconst emit = defineEmits([`uploadImage`])\n\nconst uiStore = useUIStore()\nconst { enableImageReupload } = storeToRefs(uiStore)\nconst { toggleImageReupload } = uiStore\n\n// github\nconst githubSchema = toTypedSchema(yup.object({\n  repo: yup.string().required(`GitHub 仓库不能为空`),\n  branch: yup.string().optional(),\n  accessToken: yup.string().required(`GitHub Token 不能为空`),\n  useCDN: yup.boolean().required(),\n}))\n\nconst githubConfig = store.reactive(`githubConfig`, { repo: ``, branch: ``, accessToken: ``, useCDN: false })\n\nasync function githubSubmit(formValues: any) {\n  Object.assign(githubConfig.value, formValues)\n  toast.success(`保存成功`)\n}\n\n// 阿里云\nconst aliOSSSchema = toTypedSchema(yup.object({\n  accessKeyId: yup.string().required(`AccessKey ID 不能为空`),\n  accessKeySecret: yup.string().required(`AccessKey Secret 不能为空`),\n  bucket: yup.string().required(`Bucket 不能为空`),\n  region: yup.string().required(`Region 不能为空`),\n  useSSL: yup.boolean().required(),\n  cdnHost: yup.string().optional(),\n  path: yup.string().optional(),\n}))\n\nconst aliOSSConfig = store.reactive(`aliOSSConfig`, {\n  accessKeyId: ``,\n  accessKeySecret: ``,\n  bucket: ``,\n  region: ``,\n  useSSL: true,\n  cdnHost: ``,\n  path: ``,\n})\n\nasync function aliOSSSubmit(formValues: any) {\n  Object.assign(aliOSSConfig.value, formValues)\n  toast.success(`保存成功`)\n}\n\n// 腾讯云\nconst txCOSSchema = toTypedSchema(yup.object({\n  secretId: yup.string().required(`Secret ID 不能为空`),\n  secretKey: yup.string().required(`Secret Key 不能为空`),\n  bucket: yup.string().required(`Bucket 不能为空`),\n  region: yup.string().required(`Region 不能为空`),\n  cdnHost: yup.string().optional(),\n  path: yup.string().optional(),\n}))\n\nconst txCOSConfig = store.reactive(`txCOSConfig`, {\n  secretId: ``,\n  secretKey: ``,\n  bucket: ``,\n  region: ``,\n  cdnHost: ``,\n  path: ``,\n})\n\nasync function txCOSSubmit(formValues: any) {\n  Object.assign(txCOSConfig.value, formValues)\n  toast.success(`保存成功`)\n}\n\n// 七牛云\nconst qiniuSchema = toTypedSchema(yup.object({\n  accessKey: yup.string().required(`AccessKey 不能为空`),\n  secretKey: yup.string().required(`SecretKey 不能为空`),\n  bucket: yup.string().required(`Bucket 不能为空`),\n  domain: yup.string().required(`Bucket 对应域名不能为空`),\n  region: yup.string().optional(),\n  path: yup.string().optional(),\n}))\n\nconst qiniuConfig = store.reactive(`qiniuConfig`, {\n  accessKey: ``,\n  secretKey: ``,\n  bucket: ``,\n  domain: ``,\n  region: ``,\n  path: ``,\n})\n\nasync function qiniuSubmit(formValues: any) {\n  Object.assign(qiniuConfig.value, formValues)\n  toast.success(`保存成功`)\n}\n\n// MinIO\nconst minioOSSSchema = toTypedSchema(yup.object({\n  endpoint: yup.string().required(`Endpoint 不能为空`),\n  port: yup.string().optional(),\n  useSSL: yup.boolean().required(),\n  bucket: yup.string().required(`Bucket 不能为空`),\n  accessKey: yup.string().required(`AccessKey 不能为空`),\n  secretKey: yup.string().required(`SecretKey 不能为空`),\n}))\n\nconst minioOSSConfig = store.reactive(`minioConfig`, {\n  endpoint: ``,\n  port: ``,\n  useSSL: true,\n  bucket: ``,\n  accessKey: ``,\n  secretKey: ``,\n})\n\nasync function minioOSSSubmit(formValues: any) {\n  Object.assign(minioOSSConfig.value, formValues)\n  toast.success(`保存成功`)\n}\n\n// S3\nconst s3Schema = toTypedSchema(yup.object({\n  endpoint: yup.string().optional(),\n  region: yup.string().required(`Region 不能为空`),\n  bucket: yup.string().required(`Bucket 不能为空`),\n  accessKeyId: yup.string().required(`AccessKey ID 不能为空`),\n  accessKeySecret: yup.string().required(`Secret AccessKey 不能为空`),\n  path: yup.string().optional(),\n  cdnHost: yup.string().optional(),\n  pathStyle: yup.boolean().optional(),\n}))\n\nconst s3Config = store.reactive(`s3Config`, {\n  endpoint: ``,\n  region: ``,\n  bucket: ``,\n  accessKeyId: ``,\n  accessKeySecret: ``,\n  path: ``,\n  cdnHost: ``,\n  pathStyle: false,\n})\n\nasync function s3Submit(formValues: any) {\n  Object.assign(s3Config.value, formValues)\n  toast.success(`保存成功`)\n}\n\n// Telegram 图床\nconst telegramSchema = toTypedSchema(\n  yup.object({\n    token: yup.string().required(`Bot Token 不能为空`),\n    chatId: yup.string().required(`Chat ID 不能为空`),\n  }),\n)\n\nconst telegramConfig = store.reactive(`telegramConfig`, { token: ``, chatId: `` })\n\nasync function telegramSubmit(values: any) {\n  Object.assign(telegramConfig.value, values)\n  toast.success(`保存成功`)\n}\n\n// 公众号\n// 当前是否为网页（http/https 协议）\nconst isWebsite = window.location.protocol.startsWith(`http`)\n\n// Cloudflare Workers 环境\nconst isCfWorkers = import.meta.env.CF_WORKERS === `1`\n\n// 插件模式运行（如 chrome-extension://）\nconst isPluginMode = !isWebsite\n\n// 是否需要填写 proxyOrigin（只在 非插件 且 非CF页面 时需要）\nconst isProxyRequired = computed(() => {\n  return !isPluginMode && !isCfWorkers\n})\n\nconst mpPlaceholder = computed(() => {\n  if (isProxyRequired.value) {\n    return `如：http://proxy.example.com`\n  }\n  return `可不填`\n})\nconst mpSchema = computed(() =>\n  toTypedSchema(yup.object({\n    proxyOrigin: isProxyRequired.value\n      ? yup.string().required(`代理域名不能为空`)\n      : yup.string().optional(),\n    appID: yup.string().required(`AppID 不能为空`),\n    appsecret: yup.string().required(`AppSecret 不能为空`),\n  })),\n)\n\nconst mpConfig = store.reactive(`mpConfig`, {\n  proxyOrigin: ``,\n  appID: ``,\n  appsecret: ``,\n})\n\nasync function mpSubmit(formValues: any) {\n  Object.assign(mpConfig.value, formValues)\n  toast.success(`保存成功`)\n}\n\n// Cloudflare R2\nconst r2Schema = toTypedSchema(yup.object({\n  accountId: yup.string().required(`Account ID 不能为空`),\n  accessKey: yup.string().required(`AccessKey 不能为空`),\n  secretKey: yup.string().required(`SecretKey 不能为空`),\n  bucket: yup.string().required(`Bucket 不能为空`),\n  domain: yup.string().required(`Bucket 对应域名不能为空`),\n  path: yup.string().optional(),\n}))\n\nconst r2Config = store.reactive(`r2Config`, {\n  accountId: ``,\n  accessKey: ``,\n  secretKey: ``,\n  bucket: ``,\n  domain: ``,\n  path: ``,\n})\n\nasync function r2Submit(formValues: any) {\n  Object.assign(r2Config.value, formValues)\n  toast.success(`保存成功`)\n}\n\n// 又拍云\nconst upyunSchema = computed(() => toTypedSchema(\n  yup.object({\n    bucket: yup.string().required(`Bucket 不能为空`),\n    operator: yup.string().required(`操作员 不能为空`),\n    password: yup.string().required(`密码 不能为空`),\n    domain: yup.string().required(`CDN 域名不能为空`),\n    path: yup.string().optional(),\n  }),\n))\n\nconst upyunConfig = store.reactive(`upyunConfig`, {\n  bucket: ``,\n  operator: ``,\n  password: ``,\n  domain: ``,\n  path: ``,\n})\n\nasync function upyunSubmit(formValues: any) {\n  Object.assign(upyunConfig.value, formValues)\n  toast.success(`保存成功`)\n}\n\n// Cloudinary\nconst cloudinarySchema = toTypedSchema(\n  yup.object({\n    cloudName: yup.string().required(`Cloud Name 不能为空`),\n    apiKey: yup.string().required(`API Key 不能为空`),\n    apiSecret: yup.string().optional(),\n    uploadPreset: yup.string().when(`apiSecret`, {\n      is: (v: string | undefined) => !v || v.length === 0,\n      then: s => s.required(`未填写 apiSecret 时必须提供上传预设名`),\n      otherwise: s => s.optional(),\n    }),\n    folder: yup.string().optional(),\n    domain: yup.string().optional(),\n  }),\n)\n\nconst cloudinaryConfig = store.reactive(`cloudinaryConfig`, {\n  cloudName: ``,\n  apiKey: ``,\n  apiSecret: ``,\n  uploadPreset: ``,\n  folder: ``,\n  domain: ``,\n})\n\nasync function cloudinarySubmit(formValues: any) {\n  Object.assign(cloudinaryConfig.value, formValues)\n  toast.success(`保存成功`)\n}\n\nconst options = [\n  {\n    value: `default`,\n    label: `默认`,\n  },\n  {\n    value: `github`,\n    label: `GitHub`,\n  },\n  {\n    value: `aliOSS`,\n    label: `阿里云`,\n  },\n  {\n    value: `txCOS`,\n    label: `腾讯云`,\n  },\n  {\n    value: `qiniu`,\n    label: `七牛云`,\n  },\n  {\n    value: `minio`,\n    label: `MinIO`,\n  },\n  {\n    value: `s3`,\n    label: `S3`,\n  },\n  {\n    value: `mp`,\n    label: `公众号图床`,\n  },\n  {\n    value: `r2`,\n    label: `Cloudflare R2`,\n  },\n  {\n    value: `upyun`,\n    label: `又拍云`,\n  },\n  { value: `telegram`, label: `Telegram` },\n  {\n    value: `cloudinary`,\n    label: `Cloudinary`,\n  },\n\n  {\n    value: `formCustom`,\n    label: `自定义代码`,\n  },\n]\n\nconst imgHost = store.reactive(`imgHost`, `default`)\nconst useCompression = store.reactive(`useCompression`, false)\nconst activeName = ref(`upload`)\n\nasync function changeImgHost() {\n  toast.success(`图床已切换`)\n}\n\nasync function changeCompression() {\n  // reactive 会自动保存，不需要手动操作\n}\n\nasync function beforeImageUpload(file: File) {\n  // check image\n  const checkResult = checkImage(file)\n  if (!checkResult.ok) {\n    toast.error(checkResult.msg)\n    return false\n  }\n  // check image host\n  const imgHostValue = imgHost.value || `default`\n\n  const config = await store.get(`${imgHostValue}Config`)\n  const isValidHost = imgHostValue === `default` || config\n  if (!isValidHost) {\n    toast.error(`请先配置 ${imgHostValue} 图床参数`)\n    return false\n  }\n  return true\n}\n\nconst dragover = ref(false)\n\nconst { open, reset, onChange } = useFileDialog({\n  accept: `image/*`,\n})\n\nonChange(async (files) => {\n  if (files == null) {\n    return\n  }\n\n  const file = files[0]\n\n  if (await beforeImageUpload(file)) {\n    emitUploads(file)\n  }\n  reset()\n})\n\nasync function onDrop(e: DragEvent) {\n  dragover.value = false\n  e.stopPropagation()\n  const file = [...e.dataTransfer!.files][0]\n  if (await beforeImageUpload(file)) {\n    emitUploads(file)\n  }\n}\nconst progressValue = ref(0)\nconst imageUrl = ref(``)\nfunction emitUploads(file: File) {\n  progressValue.value = 0\n  const intervalId = setInterval(() => {\n    const newProgress = progressValue.value + 1\n    if (newProgress >= 100) {\n      return\n    }\n    progressValue.value = newProgress\n  }, 100)\n\n  // 监听上传完成事件，在真正完成后清除定时器和设置100%\n  const cleanup = (_url: string, data: string) => {\n    clearInterval(intervalId)\n    progressValue.value = 100 // 设置完成状态\n    if (data) {\n      imageUrl.value = `data:image/png;base64,${data}`\n    }\n    // 可选：延迟一段时间后重置进度\n    setTimeout(() => {\n      progressValue.value = 0\n      imageUrl.value = ``\n    }, 1000)\n  }\n\n  // 假设有一个上传完成的事件可以监听\n  // 或者需要修改 uploadImage 方法使其返回 Promise\n  emit(`uploadImage`, file, cleanup, true)\n}\n\nfunction onTabScroll(e: WheelEvent) {\n  if (e.deltaY !== 0) {\n    e.preventDefault()\n    const target = e.currentTarget as HTMLElement\n    target.scrollLeft += e.deltaY\n  }\n}\n</script>\n\n<template>\n  <Dialog v-model:open=\"uiStore.isShowUploadImgDialog\">\n    <DialogContent class=\"md:max-w-3xl max-h-[90vh] flex flex-col overflow-hidden\" @pointer-down-outside=\"ev => ev.preventDefault()\">\n      <DialogHeader>\n        <DialogTitle>本地上传</DialogTitle>\n      </DialogHeader>\n      <Tabs v-model=\"activeName\" class=\"w-full md:w-full flex flex-col flex-1 overflow-hidden\">\n        <TabsList\n          class=\"flex w-full justify-start overflow-x-auto flex-nowrap gap-1 pb-1 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden\"\n          @wheel=\"onTabScroll\"\n        >\n          <TabsTrigger value=\"upload\" class=\"text-xs md:text-sm whitespace-nowrap\">\n            选择上传\n          </TabsTrigger>\n          <TabsTrigger\n            v-for=\"item in options.filter(item => item.value !== 'default')\"\n            :key=\"item.value\"\n            :value=\"item.value\"\n            class=\"text-xs md:text-sm whitespace-nowrap\"\n          >\n            {{ item.label }}\n          </TabsTrigger>\n        </TabsList>\n\n        <TabsContent value=\"upload\" class=\"flex-1 overflow-y-auto p-1 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden\">\n          <Select v-model=\"imgHost\" class=\"my-4\" @update:model-value=\"changeImgHost\">\n            <SelectTrigger>\n              <SelectValue placeholder=\"请选择图床\" />\n            </SelectTrigger>\n            <SelectContent class=\"max-h-64 md:max-h-96\">\n              <SelectItem\n                v-for=\"item in options\"\n                :key=\"item.value\"\n                :label=\"item.label\"\n                :value=\"item.value\"\n              >\n                {{ item.label }}\n              </SelectItem>\n            </SelectContent>\n          </Select>\n\n          <div class=\"space-y-3 my-4\">\n            <div class=\"flex items-center justify-between gap-4\">\n              <span class=\"text-sm\">\n                开启图片压缩\n              </span>\n              <Switch\n                v-model:checked=\"useCompression\"\n                name=\"UseCompression\"\n                @update:checked=\"changeCompression\"\n              />\n            </div>\n\n            <div class=\"flex items-center justify-between gap-4\">\n              <span class=\"text-sm\">\n                粘贴图片时自动转存\n              </span>\n              <Switch\n                v-model:checked=\"enableImageReupload\"\n                name=\"EnableImageReupload\"\n                @update:checked=\"toggleImageReupload\"\n              />\n            </div>\n            <p class=\"text-xs text-muted-foreground mt-1.5\">\n              粘贴 Markdown 图片链接时自动转存到配置的图床\n            </p>\n          </div>\n\n          <div\n            class=\"bg-clip-padding mt-4 h-50 relative flex flex-col cursor-pointer items-center justify-evenly border-2 rounded border-dashed transition-colors hover:border-gray-700 hover:bg-gray-400/50 dark:hover:border-gray-200 dark:hover:bg-gray-500/50\"\n            :class=\"{\n              'border-gray-700 bg-gray-400/50 dark:border-gray-200 dark:bg-gray-500/50': dragover,\n            }\"\n            @click=\"open()\"\n            @drop.prevent=\"onDrop\"\n            @dragover.prevent=\"dragover = true\"\n            @dragleave.prevent=\"dragover = false\"\n          >\n            <Progress v-model=\"progressValue\" class=\"absolute left-0 right-0 rounded-none\" style=\"top: -24px; height: 2px;\" />\n            <UploadCloud class=\"size-16 md:size-20\" />\n            <p class=\"text-center text-sm md:text-base px-4\">\n              将图片拖到此处，或\n              <strong>点击上传</strong>\n            </p>\n            <div v-if=\"imageUrl\" class=\"absolute left-0 right-0 h-full w-full flex items-center justify-center bg-white dark:bg-black\">\n              <img :src=\"imageUrl\" class=\"max-h-40 object-contain\">\n            </div>\n          </div>\n        </TabsContent>\n\n        <TabsContent value=\"github\" class=\"flex-1 flex flex-col overflow-hidden\">\n          <Form :validation-schema=\"githubSchema\" :initial-values=\"githubConfig\" class=\"flex flex-col flex-1 overflow-hidden\" @submit=\"githubSubmit\">\n            <div class=\"flex-1 overflow-y-auto p-1 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden\">\n              <Field v-slot=\"{ field, errorMessage }\" name=\"repo\">\n                <FormItem label=\"GitHub 仓库\" required :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：github.com/yanglbme/resource\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"branch\">\n                <FormItem label=\"分支\" :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：release，可不填，默认 master\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"accessToken\">\n                <FormItem label=\"Token\" required :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    type=\"password\"\n                    placeholder=\"如：cc1d0c1426d0fd0902bd2d7184b14da61b8abc46\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"useCDN\" type=\"boolean\">\n                <FormItem label=\"CDN 加速\" :error=\"errorMessage\">\n                  <Switch\n                    :checked=\"field.value\"\n                    :name=\"field.name\"\n                    @update:checked=\"field.onChange\"\n                    @blur=\"field.onBlur\"\n                  />\n                </FormItem>\n              </Field>\n\n              <FormItem>\n                <Button\n                  variant=\"link\"\n                  class=\"p-0 h-auto text-left whitespace-normal\"\n                  as=\"a\"\n                  href=\"https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token\"\n                  target=\"_blank\"\n                >\n                  如何获取 GitHub Token？\n                </Button>\n              </FormItem>\n            </div>\n\n            <DialogFooter class=\"p-1\">\n              <Button type=\"submit\">\n                保存配置\n              </Button>\n            </DialogFooter>\n          </Form>\n        </TabsContent>\n\n        <TabsContent value=\"aliOSS\" class=\"flex-1 flex flex-col overflow-hidden\">\n          <Form :validation-schema=\"aliOSSSchema\" :initial-values=\"aliOSSConfig\" class=\"flex flex-col flex-1 overflow-hidden\" @submit=\"aliOSSSubmit\">\n            <div class=\"flex-1 overflow-y-auto p-1 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden\">\n              <Field v-slot=\"{ field, errorMessage }\" name=\"accessKeyId\">\n                <FormItem label=\"AccessKey ID\" required :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：LTAI4GdoocsmdoxUf13ylbaNHk\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"accessKeySecret\">\n                <FormItem label=\"AccessKey Secret\" required :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    type=\"password\"\n                    placeholder=\"如：cc1d0c142doocs0902bd2d7md4b14da6ylbabc46\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"bucket\">\n                <FormItem label=\"Bucket\" required :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：doocs\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"region\">\n                <FormItem label=\"Bucket 所在区域\" required :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：oss-cn-shenzhen\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"useSSL\" type=\"boolean\">\n                <FormItem label=\"UseSSL\" required :error=\"errorMessage\">\n                  <Switch\n                    :checked=\"field.value\"\n                    :name=\"field.name\"\n                    @update:checked=\"field.onChange\"\n                    @blur=\"field.onBlur\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"cdnHost\">\n                <FormItem label=\"自定义 CDN 域名\" :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：https://imagecdn.alidaodao.com，可不填\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"path\">\n                <FormItem label=\"存储路径\" :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：img，可不填，默认为根目录\"\n                  />\n                </FormItem>\n              </Field>\n\n              <FormItem>\n                <Button\n                  variant=\"link\"\n                  class=\"p-0 h-auto text-left whitespace-normal\"\n                  as=\"a\"\n                  href=\"https://help.aliyun.com/document_detail/31883.html\"\n                  target=\"_blank\"\n                >\n                  如何使用阿里云 OSS？\n                </Button>\n              </FormItem>\n            </div>\n\n            <DialogFooter class=\"p-1\">\n              <Button type=\"submit\">\n                保存配置\n              </Button>\n            </DialogFooter>\n          </Form>\n        </TabsContent>\n\n        <TabsContent value=\"txCOS\" class=\"flex-1 flex flex-col overflow-hidden\">\n          <Form :validation-schema=\"txCOSSchema\" :initial-values=\"txCOSConfig\" class=\"flex flex-col flex-1 overflow-hidden\" @submit=\"txCOSSubmit\">\n            <div class=\"flex-1 overflow-y-auto p-1 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden\">\n              <Field v-slot=\"{ field, errorMessage }\" name=\"secretId\">\n                <FormItem label=\"SecretId\" required :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：AKIDnQp1w3DOOCSs8F5MDp9tdoocsmdUPonW3\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"secretKey\">\n                <FormItem label=\"SecretKey\" required :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    type=\"password\"\n                    placeholder=\"如：ukLmdtEJ9271f3DOocsMDsCXdS3YlbW0\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"bucket\">\n                <FormItem label=\"Bucket\" required :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：doocs-3212520134\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"region\">\n                <FormItem label=\"Bucket 所在区域\" required :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：ap-guangzhou\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"cdnHost\">\n                <FormItem label=\"自定义 CDN 域名\" :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：https://imagecdn.alidaodao.com，可不填\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"path\">\n                <FormItem label=\"存储路径\" :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：img，可不填，默认根目录\"\n                  />\n                </FormItem>\n              </Field>\n\n              <FormItem>\n                <Button\n                  variant=\"link\"\n                  class=\"p-0 h-auto text-left whitespace-normal\"\n                  as=\"a\"\n                  href=\"https://cloud.tencent.com/document/product/436/38484\"\n                  target=\"_blank\"\n                >\n                  如何使用腾讯云 COS？\n                </Button>\n              </FormItem>\n            </div>\n\n            <DialogFooter class=\"p-1\">\n              <Button type=\"submit\">\n                保存配置\n              </Button>\n            </DialogFooter>\n          </Form>\n        </TabsContent>\n\n        <TabsContent value=\"qiniu\" class=\"flex-1 flex flex-col overflow-hidden\">\n          <Form :validation-schema=\"qiniuSchema\" :initial-values=\"qiniuConfig\" class=\"flex flex-col flex-1 overflow-hidden\" @submit=\"qiniuSubmit\">\n            <div class=\"flex-1 overflow-y-auto p-1 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden\">\n              <Field v-slot=\"{ field, errorMessage }\" name=\"accessKey\">\n                <FormItem label=\"AccessKey\" required :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：6DD3VaLJ_SQgOdoocsyTV_YWaDmdnL2n8EGx7kG\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"secretKey\">\n                <FormItem label=\"SecretKey\" required :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    type=\"password\"\n                    placeholder=\"如：qgZa5qrvDOOcsmdKStD1oCjZ9nB7MDvJUs_34SIm\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"bucket\">\n                <FormItem label=\"Bucket\" required :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：md\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"domain\">\n                <FormItem label=\"Bucket 对应域名\" required :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：https://images.123ylb.cn\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"region\">\n                <FormItem label=\"存储区域\" :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：z2，可不填\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"path\">\n                <FormItem label=\"存储路径\" :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：img，可不填，默认为根目录\"\n                  />\n                </FormItem>\n              </Field>\n\n              <FormItem>\n                <Button\n                  variant=\"link\"\n                  class=\"p-0 h-auto text-left whitespace-normal\"\n                  as=\"a\"\n                  href=\"https://developer.qiniu.com/kodo\"\n                  target=\"_blank\"\n                >\n                  如何使用七牛云 Kodo？\n                </Button>\n              </FormItem>\n            </div>\n\n            <DialogFooter class=\"p-1\">\n              <Button type=\"submit\">\n                保存配置\n              </Button>\n            </DialogFooter>\n          </Form>\n        </TabsContent>\n\n        <TabsContent value=\"minio\" class=\"flex-1 flex flex-col overflow-hidden\">\n          <Form :validation-schema=\"minioOSSSchema\" :initial-values=\"minioOSSConfig\" class=\"flex flex-col flex-1 overflow-hidden\" @submit=\"minioOSSSubmit\">\n            <div class=\"flex-1 overflow-y-auto p-1 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden\">\n              <Field v-slot=\"{ field, errorMessage }\" name=\"endpoint\">\n                <FormItem label=\"Endpoint\" required :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：play.min.io\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"port\">\n                <FormItem label=\"Port\" :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    type=\"number\"\n                    placeholder=\"如：9000，可不填，http 默认为 80，https 默认为 443\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"useSSL\" type=\"boolean\">\n                <FormItem label=\"UseSSL\" required :error=\"errorMessage\">\n                  <Switch\n                    :checked=\"field.value\"\n                    :name=\"field.name\"\n                    @update:checked=\"field.onChange\"\n                    @blur=\"field.onBlur\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"bucket\">\n                <FormItem label=\"Bucket\" required :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：doocs\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"accessKey\">\n                <FormItem label=\"AccessKey\" required :error=\"errorMessage\">\n                  <Input v-bind=\"field\" v-model=\"field.value\" placeholder=\"如：zhangsan\" />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"secretKey\">\n                <FormItem label=\"SecretKey\" required :error=\"errorMessage\">\n                  <Input v-bind=\"field\" v-model=\"field.value\" placeholder=\"如：asdasdasd\" />\n                </FormItem>\n              </Field>\n\n              <FormItem>\n                <Button\n                  variant=\"link\"\n                  class=\"p-0 h-auto text-left whitespace-normal\"\n                  as=\"a\"\n                  href=\"http://docs.minio.org.cn/docs/master/minio-client-complete-guide\"\n                  target=\"_blank\"\n                >\n                  如何使用 MinIO？\n                </Button>\n              </FormItem>\n            </div>\n\n            <DialogFooter class=\"p-1\">\n              <Button type=\"submit\">\n                保存配置\n              </Button>\n            </DialogFooter>\n          </Form>\n        </TabsContent>\n\n        <TabsContent value=\"s3\" class=\"flex-1 flex flex-col overflow-hidden\">\n          <Form :validation-schema=\"s3Schema\" :initial-values=\"s3Config\" class=\"flex flex-col flex-1 overflow-hidden\" @submit=\"s3Submit\">\n            <div class=\"flex-1 overflow-y-auto p-1 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden\">\n              <Field v-slot=\"{ field, errorMessage }\" name=\"endpoint\">\n                <FormItem label=\"Endpoint\" :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：s3.amazonaws.com，可不填\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"region\">\n                <FormItem label=\"Region\" required :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：us-east-1\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"bucket\">\n                <FormItem label=\"Bucket\" required :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：bucket-name\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"accessKeyId\">\n                <FormItem label=\"AccessKey ID\" required :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：AKIAIOSFODNN7EXAMPLE\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"accessKeySecret\">\n                <FormItem label=\"AccessKey Secret\" required :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    type=\"password\"\n                    placeholder=\"如：wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"path\">\n                <FormItem label=\"存储路径\" :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：img，可不填，默认根目录\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"cdnHost\">\n                <FormItem label=\"自定义域名\" :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：https://cdn.example.com\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"pathStyle\" type=\"boolean\">\n                <FormItem label=\"Force Path Style\" :error=\"errorMessage\">\n                  <Switch\n                    :checked=\"field.value\"\n                    :name=\"field.name\"\n                    @update:checked=\"field.onChange\"\n                    @blur=\"field.onBlur\"\n                  />\n                </FormItem>\n              </Field>\n            </div>\n\n            <DialogFooter class=\"p-1\">\n              <Button type=\"submit\">\n                保存配置\n              </Button>\n            </DialogFooter>\n          </Form>\n        </TabsContent>\n\n        <TabsContent value=\"mp\" class=\"flex-1 flex flex-col overflow-hidden\">\n          <Form :validation-schema=\"mpSchema\" :initial-values=\"mpConfig\" class=\"flex flex-col flex-1 overflow-hidden\" @submit=\"mpSubmit\">\n            <div class=\"flex-1 overflow-y-auto p-1 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden\">\n              <!-- 只有在需要代理时才显示 proxyOrigin 字段 -->\n              <Field\n                v-if=\"isProxyRequired\"\n                v-slot=\"{ field, errorMessage }\"\n                name=\"proxyOrigin\"\n              >\n                <FormItem label=\"代理域名\" required :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    :placeholder=\"mpPlaceholder\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"appID\">\n                <FormItem label=\"appID\" required :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：wx6e1234567890efa3\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"appsecret\">\n                <FormItem label=\"appsecret\" required :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：d9f1abcdef01234567890abcdef82397\"\n                  />\n                </FormItem>\n              </Field>\n\n              <FormItem>\n                <div class=\"flex flex-col items-start\">\n                  <Button\n                    variant=\"link\"\n                    class=\"p-0 h-auto text-left whitespace-normal\"\n                    as=\"a\"\n                    href=\"https://developers.weixin.qq.com/doc/offiaccount/Getting_Started/Getting_Started_Guide.html\"\n                    target=\"_blank\"\n                  >\n                    如何开启公众号开发者模式并获取应用账号密钥？\n                  </Button>\n                  <Button\n                    variant=\"link\"\n                    class=\"p-0 h-auto text-left whitespace-normal\"\n                    as=\"a\"\n                    href=\"https://md-pages.doocs.org/tutorial/\"\n                    target=\"_blank\"\n                  >\n                    如何在浏览器插件中使用公众号图床？\n                  </Button>\n                </div>\n              </FormItem>\n            </div>\n\n            <DialogFooter class=\"p-1\">\n              <Button type=\"submit\">\n                保存配置\n              </Button>\n            </DialogFooter>\n          </Form>\n        </TabsContent>\n\n        <TabsContent value=\"r2\" class=\"flex-1 flex flex-col overflow-hidden\">\n          <Form :validation-schema=\"r2Schema\" :initial-values=\"r2Config\" class=\"flex flex-col flex-1 overflow-hidden\" @submit=\"r2Submit\">\n            <div class=\"flex-1 overflow-y-auto p-1 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden\">\n              <Field v-slot=\"{ field, errorMessage }\" name=\"accountId\">\n                <FormItem label=\"AccountId\" required :error=\"errorMessage\">\n                  <Input v-bind=\"field\" v-model=\"field.value\" placeholder=\"如: 0030f123e55a57546f4c281c564e560\" class=\"w-full min-w-0 md:min-w-[350px]\" />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"accessKey\">\n                <FormItem label=\"AccessKey\" required :error=\"errorMessage\">\n                  <Input v-bind=\"field\" v-model=\"field.value\" placeholder=\"如: 358090b3a12824a6b0787gae7ad0fc72\" />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"secretKey\">\n                <FormItem label=\"SecretKey\" required :error=\"errorMessage\">\n                  <Input v-bind=\"field\" v-model=\"field.value\" type=\"password\" placeholder=\"如: c1c4dbcb0b6b785ac6633422a06dff3dac055fe74fe40xj1b5c5fcf1bf128010\" />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"bucket\">\n                <FormItem label=\"Bucket\" required :error=\"errorMessage\">\n                  <Input v-bind=\"field\" v-model=\"field.value\" placeholder=\"如：md\" />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"domain\">\n                <FormItem label=\"域名\" required :error=\"errorMessage\">\n                  <Input v-bind=\"field\" v-model=\"field.value\" placeholder=\"如：https://oss.example.com\" />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"path\">\n                <FormItem label=\"存储路径\" :error=\"errorMessage\">\n                  <Input v-bind=\"field\" v-model=\"field.value\" placeholder=\"如：img，可不填，默认为根目录\" />\n                </FormItem>\n              </Field>\n\n              <FormItem>\n                <div class=\"flex flex-col items-start\">\n                  <Button\n                    variant=\"link\"\n                    class=\"p-0 h-auto text-left whitespace-normal\"\n                    as=\"a\"\n                    href=\"https://developers.cloudflare.com/r2/api/s3/api/\"\n                    target=\"_blank\"\n                  >\n                    如何使用 S3 API 操作 Cloudflare R2？\n                  </Button>\n                  <Button\n                    variant=\"link\"\n                    class=\"p-0 h-auto text-left whitespace-normal\"\n                    as=\"a\"\n                    href=\"https://developers.cloudflare.com/r2/buckets/cors/\"\n                    target=\"_blank\"\n                  >\n                    如何设置跨域(CORS)？\n                  </Button>\n                </div>\n              </FormItem>\n            </div>\n\n            <DialogFooter class=\"p-1\">\n              <Button type=\"submit\">\n                保存配置\n              </Button>\n            </DialogFooter>\n          </Form>\n        </TabsContent>\n\n        <TabsContent value=\"upyun\" class=\"flex-1 flex flex-col overflow-hidden\">\n          <Form :validation-schema=\"upyunSchema\" :initial-values=\"upyunConfig\" class=\"flex flex-col flex-1 overflow-hidden\" @submit=\"upyunSubmit\">\n            <div class=\"flex-1 overflow-y-auto p-1 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden\">\n              <Field v-slot=\"{ field, errorMessage }\" name=\"bucket\">\n                <FormItem label=\"Bucket\" required :error=\"errorMessage\">\n                  <Input v-bind=\"field\" v-model=\"field.value\" placeholder=\"如: md\" class=\"w-full min-w-0 md:min-w-[350px]\" />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"operator\">\n                <FormItem label=\"操作员\" required :error=\"errorMessage\">\n                  <Input v-bind=\"field\" v-model=\"field.value\" placeholder=\"如: operator\" />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"password\">\n                <FormItem label=\"操作员密码\" required :error=\"errorMessage\">\n                  <Input v-bind=\"field\" v-model=\"field.value\" type=\"password\" placeholder=\"如: c1c4dbcb0b6b785ac6633422a06dff3dac055fe74fe40xj1b5c5fcf1bf128010\" />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"domain\">\n                <FormItem label=\"域名\" required :error=\"errorMessage\">\n                  <Input v-bind=\"field\" v-model=\"field.value\" placeholder=\"如：http://xxx.test.upcdn.net\" />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"path\">\n                <FormItem label=\"存储路径\" :error=\"errorMessage\">\n                  <Input v-bind=\"field\" v-model=\"field.value\" placeholder=\"如：img，可不填，默认为根目录\" />\n                </FormItem>\n              </Field>\n\n              <FormItem>\n                <Button\n                  variant=\"link\"\n                  class=\"p-0 h-auto text-left whitespace-normal\"\n                  as=\"a\"\n                  href=\"https://help.upyun.com/\"\n                  target=\"_blank\"\n                >\n                  如何使用 又拍云？\n                </Button>\n              </FormItem>\n            </div>\n\n            <DialogFooter class=\"p-1\">\n              <Button type=\"submit\">\n                保存配置\n              </Button>\n            </DialogFooter>\n          </Form>\n        </TabsContent>\n\n        <TabsContent value=\"telegram\" class=\"flex-1 flex flex-col overflow-hidden\">\n          <Form :validation-schema=\"telegramSchema\" :initial-values=\"telegramConfig\" class=\"flex flex-col flex-1 overflow-hidden\" @submit=\"telegramSubmit\">\n            <div class=\"flex-1 overflow-y-auto p-1 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden\">\n              <Field v-slot=\"{ field, errorMessage }\" name=\"token\">\n                <FormItem label=\"Bot Token\" required :error=\"errorMessage\">\n                  <Input v-bind=\"field\" v-model=\"field.value\" placeholder=\"如：123456789:ABCdefGHIjkl-MNOPqrSTUvwxYZ\" />\n                </FormItem>\n              </Field>\n              <Field v-slot=\"{ field, errorMessage }\" name=\"chatId\">\n                <FormItem label=\"Chat ID\" required :error=\"errorMessage\">\n                  <Input v-bind=\"field\" v-model=\"field.value\" placeholder=\"如：-1001234567890\" />\n                </FormItem>\n              </Field>\n              <FormItem>\n                <Button\n                  variant=\"link\"\n                  class=\"p-0 h-auto text-left whitespace-normal\"\n                  as=\"a\"\n                  href=\"https://github.com/doocs/md/blob/main/docs/telegram-usage.md\"\n                  target=\"_blank\"\n                >\n                  如何使用 Telegram？\n                </Button>\n              </FormItem>\n            </div>\n\n            <DialogFooter class=\"p-1\">\n              <Button type=\"submit\">\n                保存配置\n              </Button>\n            </DialogFooter>\n          </Form>\n        </TabsContent>\n\n        <TabsContent value=\"cloudinary\" class=\"flex-1 flex flex-col overflow-hidden\">\n          <Form\n            :validation-schema=\"cloudinarySchema\"\n            :initial-values=\"cloudinaryConfig\"\n            class=\"flex flex-col flex-1 overflow-hidden\"\n            @submit=\"cloudinarySubmit\"\n          >\n            <div class=\"flex-1 overflow-y-auto p-1 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden\">\n              <Field v-slot=\"{ field, errorMessage }\" name=\"cloudName\">\n                <FormItem label=\"Cloud Name\" required :error=\"errorMessage\">\n                  <Input v-bind=\"field\" v-model=\"field.value\" placeholder=\"如：demo\" />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"apiKey\">\n                <FormItem label=\"API Key\" required :error=\"errorMessage\">\n                  <Input v-bind=\"field\" v-model=\"field.value\" placeholder=\"如：1234567890\" />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"apiSecret\">\n                <FormItem label=\"API Secret\" :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    type=\"password\"\n                    placeholder=\"用于签名上传，可不填\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"uploadPreset\">\n                <FormItem label=\"Upload Preset\" :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"unsigned 时必填，signed 时可不填\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"folder\">\n                <FormItem label=\"Folder\" :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：blog/image，可不填\"\n                  />\n                </FormItem>\n              </Field>\n\n              <Field v-slot=\"{ field, errorMessage }\" name=\"domain\">\n                <FormItem label=\"自定义域名 / CDN\" :error=\"errorMessage\">\n                  <Input\n                    v-bind=\"field\"\n                    v-model=\"field.value\"\n                    placeholder=\"如：https://cdn.example.com，可不填\"\n                  />\n                </FormItem>\n              </Field>\n\n              <FormItem>\n                <Button\n                  variant=\"link\"\n                  class=\"p-0 h-auto text-left whitespace-normal\"\n                  as=\"a\"\n                  href=\"https://cloudinary.com/documentation/upload_images\"\n                  target=\"_blank\"\n                >\n                  Cloudinary 使用文档\n                </Button>\n              </FormItem>\n            </div>\n\n            <DialogFooter class=\"p-1\">\n              <Button type=\"submit\">\n                保存配置\n              </Button>\n            </DialogFooter>\n          </Form>\n        </TabsContent>\n\n        <TabsContent value=\"formCustom\" class=\"flex-1 flex flex-col overflow-hidden\">\n          <CustomUploadForm />\n        </TabsContent>\n      </Tabs>\n    </DialogContent>\n  </Dialog>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/editor/editor-header/AboutDialog.vue",
    "content": "<script setup lang=\"ts\">\nconst props = defineProps({\n  visible: {\n    type: Boolean,\n    default: false,\n  },\n})\n\nconst emit = defineEmits([`close`])\n\nfunction onUpdate(val: boolean) {\n  if (!val) {\n    emit(`close`)\n  }\n}\n\nconst links = [\n  { label: `GitHub 仓库`, url: `https://github.com/doocs/md` },\n  { label: `Gitee 仓库`, url: `https://gitee.com/doocs/md` },\n  { label: `GitCode 仓库`, url: `https://gitcode.com/doocs/md` },\n]\n\nfunction onRedirect(url: string) {\n  window.open(url, `_blank`)\n}\n</script>\n\n<template>\n  <Dialog :open=\"props.visible\" @update:open=\"onUpdate\">\n    <DialogContent>\n      <DialogHeader>\n        <DialogTitle>关于</DialogTitle>\n      </DialogHeader>\n      <div class=\"text-center\">\n        <h3>一款高度简洁的微信 Markdown 编辑器</h3>\n        <p>扫码关注公众号 Doocs，原创技术内容第一时间推送！</p>\n        <img\n          class=\"mx-auto my-5\"\n          src=\"https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/doocs/md/images/1648303220922-7e14aefa-816e-44c1-8604-ade709ca1c69.png\"\n          alt=\"Doocs Markdown 编辑器\"\n          style=\"width: 40%\"\n        >\n      </div>\n      <DialogFooter class=\"sm:justify-evenly flex flex-wrap gap-2\">\n        <Button\n          v-for=\"link in links\"\n          :key=\"link.url\"\n          @click=\"onRedirect(link.url)\"\n        >\n          {{ link.label }}\n        </Button>\n      </DialogFooter>\n    </DialogContent>\n  </Dialog>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/editor/editor-header/EditDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport type { EditorView } from '@codemirror/view'\nimport { altSign, ctrlSign, shiftSign } from '@md/shared/configs'\nimport { redoAction, undoAction } from '@md/shared/editor'\nimport {\n  ClipboardPaste,\n  Copy,\n  Redo2,\n  Replace,\n  Search,\n  Undo2,\n  WandSparkles,\n} from 'lucide-vue-next'\nimport { useEditorStore } from '@/stores/editor'\nimport { usePostStore } from '@/stores/post'\nimport { useUIStore } from '@/stores/ui'\nimport { copyPlain } from '@/utils/clipboard'\n\nconst props = withDefaults(defineProps<{\n  asSub?: boolean\n}>(), {\n  asSub: false,\n})\n\nconst emit = defineEmits(['copy'])\n\nconst { asSub } = toRefs(props)\n\nconst editorStore = useEditorStore()\nconst postStore = usePostStore()\nconst uiStore = useUIStore()\n\nconst { editor } = storeToRefs(editorStore)\n\n// Format content function\nasync function formatContent() {\n  const doc = await editorStore.formatContent()\n  if (doc && postStore.currentPost) {\n    postStore.updatePostContent(postStore.currentPostId, doc)\n  }\n}\n\n// Clipboard operations\nasync function copyToClipboard() {\n  const selectedText = editorStore.getSelection()\n  copyPlain(selectedText)\n}\n\nasync function pasteFromClipboard() {\n  try {\n    const text = await navigator.clipboard.readText()\n    editorStore.replaceSelection(text)\n  }\n  catch (error) {\n    console.log(`粘贴失败`, error)\n  }\n}\n\n// Undo/Redo\nfunction undo() {\n  if (!editor.value)\n    return\n\n  try {\n    const editorView = toRaw(editor.value) as EditorView\n    undoAction(editorView)\n    editorView.focus()\n  }\n  catch (error) {\n    console.error('Undo failed:', error)\n  }\n}\n\nfunction redo() {\n  if (!editor.value)\n    return\n\n  try {\n    const editorView = toRaw(editor.value) as EditorView\n    redoAction(editorView)\n    editorView.focus()\n  }\n  catch (error) {\n    console.error('Redo failed:', error)\n  }\n}\n\n// Search/Replace - 使用项目已有的 SearchTab 组件\nfunction openSearch() {\n  // 触发打开搜索面板\n  if (editor.value) {\n    const selection = editor.value.state.selection.main\n    const selected = editor.value.state.doc.sliceString(selection.from, selection.to).trim()\n\n    // 使用 UI store 来触发搜索面板的打开\n    uiStore.openSearchTab(selected)\n  }\n}\n\nfunction openReplace() {\n  // 打开搜索面板并展开替换功能\n  if (editor.value) {\n    const selection = editor.value.state.selection.main\n    const selected = editor.value.state.doc.sliceString(selection.from, selection.to).trim()\n\n    // 使用 UI store 来触发搜索面板的打开，并显示替换选项\n    uiStore.openSearchTab(selected, true)\n  }\n}\n</script>\n\n<template>\n  <!-- 作为 MenubarSub 使用 -->\n  <MenubarSub v-if=\"asSub\">\n    <MenubarSubTrigger>\n      编辑\n    </MenubarSubTrigger>\n    <MenubarSubContent class=\"w-64\">\n      <!-- 历史操作 -->\n      <MenubarItem @click=\"undo()\">\n        <Undo2 class=\"mr-2 h-4 w-4\" />\n        撤销\n        <MenubarShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">Z</kbd>\n        </MenubarShortcut>\n      </MenubarItem>\n      <MenubarItem @click=\"redo()\">\n        <Redo2 class=\"mr-2 h-4 w-4\" />\n        重做\n        <MenubarShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">Y</kbd>\n        </MenubarShortcut>\n      </MenubarItem>\n\n      <MenubarSeparator />\n\n      <!-- 剪贴板操作 -->\n      <MenubarSub>\n        <MenubarSubTrigger>\n          <Copy class=\"mr-2 h-4 w-4\" />\n          复制\n        </MenubarSubTrigger>\n        <MenubarSubContent>\n          <MenubarItem @click=\"emit('copy', 'txt')\">\n            公众号格式\n          </MenubarItem>\n          <MenubarItem @click=\"emit('copy', 'html')\">\n            HTML 格式\n          </MenubarItem>\n          <MenubarItem @click=\"emit('copy', 'html-without-style')\">\n            HTML 格式（无样式）\n          </MenubarItem>\n          <MenubarItem @click=\"emit('copy', 'html-and-style')\">\n            HTML 格式（兼容样式）\n          </MenubarItem>\n          <MenubarItem @click=\"emit('copy', 'md')\">\n            MD 格式\n          </MenubarItem>\n          <MenubarSeparator />\n          <MenubarItem @click=\"copyToClipboard()\">\n            复制选中内容\n            <MenubarShortcut>\n              <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n              <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">C</kbd>\n            </MenubarShortcut>\n          </MenubarItem>\n        </MenubarSubContent>\n      </MenubarSub>\n      <MenubarItem @click=\"pasteFromClipboard()\">\n        <ClipboardPaste class=\"mr-2 h-4 w-4\" />\n        粘贴\n        <MenubarShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">V</kbd>\n        </MenubarShortcut>\n      </MenubarItem>\n\n      <MenubarSeparator />\n\n      <!-- 格式化文档 -->\n      <MenubarItem @click=\"formatContent()\">\n        <WandSparkles class=\"mr-2 h-4 w-4\" />\n        格式化文档\n        <MenubarShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ altSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ shiftSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">F</kbd>\n        </MenubarShortcut>\n      </MenubarItem>\n\n      <MenubarSeparator />\n\n      <!-- 查找替换 -->\n      <MenubarItem @click=\"openSearch()\">\n        <Search class=\"mr-2 h-4 w-4\" />\n        查找\n        <MenubarShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">F</kbd>\n        </MenubarShortcut>\n      </MenubarItem>\n      <MenubarItem @click=\"openReplace()\">\n        <Replace class=\"mr-2 h-4 w-4\" />\n        替换\n        <MenubarShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">H</kbd>\n        </MenubarShortcut>\n      </MenubarItem>\n    </MenubarSubContent>\n  </MenubarSub>\n\n  <!-- 作为 MenubarMenu 使用（默认） -->\n  <MenubarMenu v-else>\n    <MenubarTrigger>\n      编辑\n    </MenubarTrigger>\n    <MenubarContent class=\"w-64\" align=\"start\">\n      <!-- 历史操作 -->\n      <MenubarItem @click=\"undo()\">\n        <Undo2 class=\"mr-2 h-4 w-4\" />\n        撤销\n        <MenubarShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">Z</kbd>\n        </MenubarShortcut>\n      </MenubarItem>\n      <MenubarItem @click=\"redo()\">\n        <Redo2 class=\"mr-2 h-4 w-4\" />\n        重做\n        <MenubarShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">Y</kbd>\n        </MenubarShortcut>\n      </MenubarItem>\n\n      <MenubarSeparator />\n\n      <!-- 剪贴板操作 -->\n      <MenubarSub>\n        <MenubarSubTrigger>\n          <Copy class=\"mr-2 h-4 w-4\" />\n          复制\n        </MenubarSubTrigger>\n        <MenubarSubContent>\n          <MenubarItem @click=\"emit('copy', 'txt')\">\n            公众号格式\n          </MenubarItem>\n          <MenubarItem @click=\"emit('copy', 'html')\">\n            HTML 格式\n          </MenubarItem>\n          <MenubarItem @click=\"emit('copy', 'html-without-style')\">\n            HTML 格式（无样式）\n          </MenubarItem>\n          <MenubarItem @click=\"emit('copy', 'html-and-style')\">\n            HTML 格式（兼容样式）\n          </MenubarItem>\n          <MenubarItem @click=\"emit('copy', 'md')\">\n            MD 格式\n          </MenubarItem>\n          <MenubarSeparator />\n          <MenubarItem @click=\"copyToClipboard()\">\n            复制选中内容\n            <MenubarShortcut>\n              <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n              <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">C</kbd>\n            </MenubarShortcut>\n          </MenubarItem>\n        </MenubarSubContent>\n      </MenubarSub>\n      <MenubarItem @click=\"pasteFromClipboard()\">\n        <ClipboardPaste class=\"mr-2 h-4 w-4\" />\n        粘贴\n        <MenubarShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">V</kbd>\n        </MenubarShortcut>\n      </MenubarItem>\n\n      <MenubarSeparator />\n\n      <!-- 格式化文档 -->\n      <MenubarItem @click=\"formatContent()\">\n        <WandSparkles class=\"mr-2 h-4 w-4\" />\n        格式化文档\n        <MenubarShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ altSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ shiftSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">F</kbd>\n        </MenubarShortcut>\n      </MenubarItem>\n\n      <MenubarSeparator />\n\n      <!-- 查找替换 -->\n      <MenubarItem @click=\"openSearch()\">\n        <Search class=\"mr-2 h-4 w-4\" />\n        查找\n        <MenubarShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">F</kbd>\n        </MenubarShortcut>\n      </MenubarItem>\n      <MenubarItem @click=\"openReplace()\">\n        <Replace class=\"mr-2 h-4 w-4\" />\n        替换\n        <MenubarShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">H</kbd>\n        </MenubarShortcut>\n      </MenubarItem>\n    </MenubarContent>\n  </MenubarMenu>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/editor/editor-header/FileDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport { Download, FileCode, FileCog, FileText, FolderKanban, FolderOpen, Package, Upload } from 'lucide-vue-next'\nimport { useEditorStore } from '@/stores/editor'\nimport { useExportStore } from '@/stores/export'\nimport { useUIStore } from '@/stores/ui'\n\nconst props = withDefaults(defineProps<{\n  asSub?: boolean\n}>(), {\n  asSub: false,\n})\n\nconst emit = defineEmits([`openEditorState`])\n\nconst { asSub } = toRefs(props)\n\nconst editorStore = useEditorStore()\nconst exportStore = useExportStore()\nconst uiStore = useUIStore()\n\nconst { isOpenPostSlider, isOpenFolderPanel } = storeToRefs(uiStore)\nconst { toggleShowTemplateDialog, toggleShowImportMdDialog } = uiStore\n\nfunction openEditorStateDialog() {\n  emit(`openEditorState`)\n}\n\nfunction openTemplateDialog() {\n  toggleShowTemplateDialog(true)\n}\n\n// Export functions\nfunction exportEditorContent2HTML() {\n  exportStore.exportEditorContent2HTML()\n}\n\nfunction exportEditorContent2PureHTML() {\n  exportStore.exportEditorContent2PureHTML(editorStore.getContent())\n}\n\nfunction exportEditorContent2MD() {\n  exportStore.exportEditorContent2MD(editorStore.getContent())\n}\n\nfunction downloadAsCardImage() {\n  exportStore.downloadAsCardImage()\n}\n\nfunction exportEditorContent2PDF() {\n  exportStore.exportEditorContent2PDF()\n}\n</script>\n\n<template>\n  <!-- 作为 MenubarSub 使用 -->\n  <MenubarSub v-if=\"asSub\">\n    <MenubarSubTrigger>\n      文件\n    </MenubarSubTrigger>\n    <MenubarSubContent class=\"w-56\">\n      <!-- 本地文件夹 -->\n      <MenubarItem @click=\"isOpenFolderPanel = !isOpenFolderPanel\">\n        <FolderOpen class=\"mr-2 size-4\" />\n        本地文件夹\n      </MenubarItem>\n\n      <MenubarSeparator />\n\n      <!-- 导入子菜单 -->\n      <MenubarSub>\n        <MenubarSubTrigger>\n          <Upload class=\"mr-2 size-4\" />\n          导入\n        </MenubarSubTrigger>\n        <MenubarSubContent class=\"w-56\">\n          <MenubarItem @click=\"toggleShowImportMdDialog(true)\">\n            <FileText class=\"mr-2 size-4\" />\n            导入 Markdown\n          </MenubarItem>\n        </MenubarSubContent>\n      </MenubarSub>\n\n      <!-- 导出子菜单 -->\n      <MenubarSub>\n        <MenubarSubTrigger>\n          <Download class=\"mr-2 size-4\" />\n          导出\n        </MenubarSubTrigger>\n        <MenubarSubContent class=\"w-56\">\n          <MenubarItem @click=\"exportEditorContent2MD()\">\n            <FileText class=\"mr-2 size-4\" />\n            Markdown 文件\n          </MenubarItem>\n          <MenubarSeparator />\n          <MenubarItem @click=\"exportEditorContent2HTML()\">\n            <FileCode class=\"mr-2 size-4\" />\n            HTML 文件\n          </MenubarItem>\n          <MenubarItem @click=\"exportEditorContent2PureHTML()\">\n            <FileCode class=\"mr-2 size-4\" />\n            HTML（无样式）\n          </MenubarItem>\n          <MenubarSeparator />\n          <MenubarItem @click=\"exportEditorContent2PDF()\">\n            <FileText class=\"mr-2 size-4\" />\n            PDF 文档\n          </MenubarItem>\n          <MenubarItem @click=\"downloadAsCardImage()\">\n            <Download class=\"mr-2 size-4\" />\n            PNG 图片\n          </MenubarItem>\n        </MenubarSubContent>\n      </MenubarSub>\n\n      <MenubarSeparator />\n\n      <!-- 模板管理 -->\n      <MenubarItem @click=\"openTemplateDialog()\">\n        <Package class=\"mr-2 size-4\" />\n        模板管理\n      </MenubarItem>\n\n      <!-- 内容管理 -->\n      <MenubarItem @click=\"isOpenPostSlider = !isOpenPostSlider\">\n        <FolderKanban class=\"mr-2 size-4\" />\n        内容管理\n      </MenubarItem>\n\n      <MenubarSeparator />\n\n      <!-- 项目配置 -->\n      <MenubarItem @click=\"openEditorStateDialog()\">\n        <FileCog class=\"mr-2 size-4\" />\n        项目配置\n      </MenubarItem>\n    </MenubarSubContent>\n  </MenubarSub>\n\n  <!-- 作为 MenubarMenu 使用（默认） -->\n  <MenubarMenu v-else>\n    <MenubarTrigger>\n      文件\n    </MenubarTrigger>\n    <MenubarContent class=\"w-56\" align=\"start\">\n      <!-- 本地文件夹 -->\n      <MenubarItem @click=\"isOpenFolderPanel = !isOpenFolderPanel\">\n        <FolderOpen class=\"mr-2 size-4\" />\n        本地文件夹\n      </MenubarItem>\n\n      <MenubarSeparator />\n\n      <!-- 导入子菜单 -->\n      <MenubarSub>\n        <MenubarSubTrigger>\n          <Upload class=\"mr-2 size-4\" />\n          导入\n        </MenubarSubTrigger>\n        <MenubarSubContent class=\"w-56\">\n          <MenubarItem @click=\"toggleShowImportMdDialog(true)\">\n            <FileText class=\"mr-2 size-4\" />\n            导入 Markdown\n          </MenubarItem>\n        </MenubarSubContent>\n      </MenubarSub>\n\n      <!-- 导出子菜单 -->\n      <MenubarSub>\n        <MenubarSubTrigger>\n          <Download class=\"mr-2 size-4\" />\n          导出\n        </MenubarSubTrigger>\n        <MenubarSubContent class=\"w-56\">\n          <MenubarItem @click=\"exportEditorContent2MD()\">\n            <FileText class=\"mr-2 size-4\" />\n            Markdown 文件\n          </MenubarItem>\n          <MenubarSeparator />\n          <MenubarItem @click=\"exportEditorContent2HTML()\">\n            <FileCode class=\"mr-2 size-4\" />\n            HTML 文件\n          </MenubarItem>\n          <MenubarItem @click=\"exportEditorContent2PureHTML()\">\n            <FileCode class=\"mr-2 size-4\" />\n            HTML（无样式）\n          </MenubarItem>\n          <MenubarSeparator />\n          <MenubarItem @click=\"exportEditorContent2PDF()\">\n            <FileText class=\"mr-2 size-4\" />\n            PDF 文档\n          </MenubarItem>\n          <MenubarItem @click=\"downloadAsCardImage()\">\n            <Download class=\"mr-2 size-4\" />\n            PNG 图片\n          </MenubarItem>\n        </MenubarSubContent>\n      </MenubarSub>\n\n      <MenubarSeparator />\n\n      <!-- 模板管理 -->\n      <MenubarItem @click=\"openTemplateDialog()\">\n        <Package class=\"mr-2 size-4\" />\n        模板管理\n      </MenubarItem>\n\n      <!-- 内容管理 -->\n      <MenubarItem @click=\"isOpenPostSlider = !isOpenPostSlider\">\n        <FolderKanban class=\"mr-2 size-4\" />\n        内容管理\n      </MenubarItem>\n\n      <MenubarSeparator />\n\n      <!-- 项目配置 -->\n      <MenubarItem @click=\"openEditorStateDialog()\">\n        <FileCog class=\"mr-2 size-4\" />\n        项目配置\n      </MenubarItem>\n    </MenubarContent>\n  </MenubarMenu>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/editor/editor-header/FormatDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport type { EditorView } from '@codemirror/view'\nimport type { Format } from 'vue-pick-colors'\nimport { headingLevels as baseHeadingLevels, ctrlKey, ctrlSign } from '@md/shared/configs'\nimport {\n  formatColor,\n} from '@md/shared/editor'\nimport {\n  Bold,\n  Clock,\n  Code,\n  Heading1,\n  Heading2,\n  Heading3,\n  Heading4,\n  Heading5,\n  Heading6,\n  Italic,\n  Link,\n  Link2,\n  List,\n  ListOrdered,\n  Paintbrush,\n  Strikethrough,\n} from 'lucide-vue-next'\nimport PickColors from 'vue-pick-colors'\nimport { useEditorFormat } from '@/composables/useEditorFormat'\nimport { useEditorStore } from '@/stores/editor'\nimport { useRenderStore } from '@/stores/render'\nimport { useThemeStore } from '@/stores/theme'\nimport { useUIStore } from '@/stores/ui'\n\nconst props = withDefaults(defineProps<{\n  asSub?: boolean\n}>(), {\n  asSub: false,\n})\n\nconst { asSub } = toRefs(props)\n\nconst editorStore = useEditorStore()\nconst themeStore = useThemeStore()\nconst renderStore = useRenderStore()\nconst uiStore = useUIStore()\nconst { editor } = storeToRefs(editorStore)\n\nconst { addFormat } = useEditorFormat(editor)\n\n// Editor refresh function\nfunction editorRefresh() {\n  themeStore.updateCodeTheme()\n\n  const raw = editorStore.getContent()\n  renderStore.render(raw)\n}\n\nfunction citeStatusChanged() {\n  themeStore.isCiteStatus = !themeStore.isCiteStatus\n  editorRefresh()\n}\n\nfunction countStatusChanged() {\n  themeStore.isCountStatus = !themeStore.isCountStatus\n  editorRefresh()\n}\n\nconst pickColorsContainer = useTemplateRef(`pickColorsContainer`)\nconst colorState = reactive({\n  format: `rgb` as Format,\n  formatOptions: [`rgb`] as Format[],\n  textColor: `rgba(0, 0, 0, 1)`,\n})\n\nconst headingIcons = [Heading1, Heading2, Heading3, Heading4, Heading5, Heading6]\nconst headingLevels = baseHeadingLevels.map((item, index) => ({\n  ...item,\n  icon: headingIcons[index],\n}))\n\nfunction textColorChanged(color: string) {\n  colorState.textColor = color\n  const editorView = editor.value as EditorView\n  if (!editor.value)\n    return\n  formatColor(editorView, color)\n}\n</script>\n\n<template>\n  <!-- 作为 MenubarSub 使用 -->\n  <MenubarSub v-if=\"asSub\">\n    <MenubarSubTrigger>\n      格式\n    </MenubarSubTrigger>\n    <MenubarSubContent class=\"w-64\">\n      <!-- 文本格式化 -->\n      <MenubarItem @click=\"addFormat(`${ctrlKey}-B`)\">\n        <Bold class=\"mr-2 h-4 w-4\" />\n        加粗\n        <MenubarShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">B</kbd>\n        </MenubarShortcut>\n      </MenubarItem>\n      <MenubarItem @click=\"addFormat(`${ctrlKey}-I`)\">\n        <Italic class=\"mr-2 h-4 w-4\" />\n        斜体\n        <MenubarShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">I</kbd>\n        </MenubarShortcut>\n      </MenubarItem>\n      <MenubarItem @click=\"addFormat(`${ctrlKey}-D`)\">\n        <Strikethrough class=\"mr-2 h-4 w-4\" />\n        删除线\n        <MenubarShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">D</kbd>\n        </MenubarShortcut>\n      </MenubarItem>\n      <MenubarItem @click=\"addFormat(`${ctrlKey}-K`)\">\n        <Link class=\"mr-2 h-4 w-4\" />\n        超链接\n        <MenubarShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">K</kbd>\n        </MenubarShortcut>\n      </MenubarItem>\n      <MenubarItem @click=\"addFormat(`${ctrlKey}-E`)\">\n        <Code class=\"mr-2 h-4 w-4\" />\n        行内代码\n        <MenubarShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">E</kbd>\n        </MenubarShortcut>\n      </MenubarItem>\n\n      <HoverCard :open-delay=\"100\">\n        <HoverCardTrigger as-child>\n          <MenubarItem @click.prevent>\n            <Paintbrush class=\"mr-2 h-4 w-4\" />\n            文字颜色\n          </MenubarItem>\n        </HoverCardTrigger>\n        <HoverCardContent side=\"right\" class=\"w-min\">\n          <div ref=\"pickColorsContainer\">\n            <PickColors\n              :value=\"colorState.textColor\"\n              show-alpha\n              :format=\"colorState.format\" :format-options=\"colorState.formatOptions\"\n              :theme=\"uiStore.isDark ? 'dark' : 'light'\"\n              :popup-container=\"pickColorsContainer!\"\n              @change=\"textColorChanged\"\n            />\n          </div>\n        </HoverCardContent>\n      </HoverCard>\n\n      <MenubarSeparator />\n\n      <!-- 标题和列表 -->\n      <MenubarSub>\n        <MenubarSubTrigger>\n          <Heading1 class=\"mr-2 h-4 w-4\" />\n          标题\n        </MenubarSubTrigger>\n        <MenubarSubContent class=\"w-48\">\n          <MenubarItem\n            v-for=\"{ level, icon, label } in headingLevels\"\n            :key=\"level\"\n            @click=\"addFormat(`${ctrlKey}-${level}`)\"\n          >\n            <component :is=\"icon\" class=\"mr-2 h-4 w-4\" />\n            {{ label }}\n            <MenubarShortcut>\n              <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n              <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ level }}</kbd>\n            </MenubarShortcut>\n          </MenubarItem>\n        </MenubarSubContent>\n      </MenubarSub>\n      <MenubarItem @click=\"addFormat(`${ctrlKey}-U`)\">\n        <List class=\"mr-2 h-4 w-4\" />\n        无序列表\n        <MenubarShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">U</kbd>\n        </MenubarShortcut>\n      </MenubarItem>\n      <MenubarItem @click=\"addFormat(`${ctrlKey}-O`)\">\n        <ListOrdered class=\"mr-2 h-4 w-4\" />\n        有序列表\n        <MenubarShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">O</kbd>\n        </MenubarShortcut>\n      </MenubarItem>\n\n      <MenubarSeparator />\n\n      <MenubarItem @click=\"citeStatusChanged()\">\n        <Link2 class=\"mr-2 h-4 w-4\" />\n        微信外链转引用\n      </MenubarItem>\n      <MenubarItem @click=\"countStatusChanged()\">\n        <Clock class=\"mr-2 h-4 w-4\" />\n        统计字数时间\n      </MenubarItem>\n    </MenubarSubContent>\n  </MenubarSub>\n\n  <!-- 作为 MenubarMenu 使用（默认） -->\n  <MenubarMenu v-else>\n    <MenubarTrigger>\n      格式\n    </MenubarTrigger>\n    <MenubarContent class=\"w-64\" align=\"start\">\n      <!-- 文本格式化 -->\n      <MenubarItem @click=\"addFormat(`${ctrlKey}-B`)\">\n        <Bold class=\"mr-2 h-4 w-4\" />\n        加粗\n        <MenubarShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">B</kbd>\n        </MenubarShortcut>\n      </MenubarItem>\n      <MenubarItem @click=\"addFormat(`${ctrlKey}-I`)\">\n        <Italic class=\"mr-2 h-4 w-4\" />\n        斜体\n        <MenubarShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">I</kbd>\n        </MenubarShortcut>\n      </MenubarItem>\n      <MenubarItem @click=\"addFormat(`${ctrlKey}-D`)\">\n        <Strikethrough class=\"mr-2 h-4 w-4\" />\n        删除线\n        <MenubarShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">D</kbd>\n        </MenubarShortcut>\n      </MenubarItem>\n      <MenubarItem @click=\"addFormat(`${ctrlKey}-K`)\">\n        <Link class=\"mr-2 h-4 w-4\" />\n        超链接\n        <MenubarShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">K</kbd>\n        </MenubarShortcut>\n      </MenubarItem>\n      <MenubarItem @click=\"addFormat(`${ctrlKey}-E`)\">\n        <Code class=\"mr-2 h-4 w-4\" />\n        行内代码\n        <MenubarShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">E</kbd>\n        </MenubarShortcut>\n      </MenubarItem>\n\n      <HoverCard :open-delay=\"100\">\n        <HoverCardTrigger as-child>\n          <MenubarItem @click.prevent>\n            <Paintbrush class=\"mr-2 h-4 w-4\" />\n            文字颜色\n          </MenubarItem>\n        </HoverCardTrigger>\n        <HoverCardContent side=\"right\" class=\"w-min\">\n          <div ref=\"pickColorsContainer\">\n            <PickColors\n              v-model:value=\"colorState.textColor\"\n              show-alpha\n              :format=\"colorState.format\" :format-options=\"colorState.formatOptions\"\n              :theme=\"uiStore.isDark ? 'dark' : 'light'\"\n              :popup-container=\"pickColorsContainer!\"\n              @change=\"textColorChanged\"\n            />\n          </div>\n        </HoverCardContent>\n      </HoverCard>\n\n      <MenubarSeparator />\n\n      <!-- 标题和列表 -->\n      <MenubarSub>\n        <MenubarSubTrigger>\n          <Heading1 class=\"mr-2 h-4 w-4\" />\n          标题\n        </MenubarSubTrigger>\n        <MenubarSubContent class=\"w-48\">\n          <MenubarItem\n            v-for=\"{ level, icon, label } in headingLevels\"\n            :key=\"level\"\n            @click=\"addFormat(`${ctrlKey}-${level}`)\"\n          >\n            <component :is=\"icon\" class=\"mr-2 h-4 w-4\" />\n            {{ label }}\n            <MenubarShortcut>\n              <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n              <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ level }}</kbd>\n            </MenubarShortcut>\n          </MenubarItem>\n        </MenubarSubContent>\n      </MenubarSub>\n      <MenubarItem @click=\"addFormat(`${ctrlKey}-U`)\">\n        <List class=\"mr-2 h-4 w-4\" />\n        无序列表\n        <MenubarShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">U</kbd>\n        </MenubarShortcut>\n      </MenubarItem>\n      <MenubarItem @click=\"addFormat(`${ctrlKey}-O`)\">\n        <ListOrdered class=\"mr-2 h-4 w-4\" />\n        有序列表\n        <MenubarShortcut>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">{{ ctrlSign }}</kbd>\n          <kbd class=\"mx-1 bg-gray-2 dark:bg-stone-9\">O</kbd>\n        </MenubarShortcut>\n      </MenubarItem>\n\n      <MenubarSeparator />\n\n      <MenubarItem @click=\"citeStatusChanged()\">\n        <Link2 class=\"mr-2 h-4 w-4\" />\n        微信外链转引用\n      </MenubarItem>\n      <MenubarItem @click=\"countStatusChanged()\">\n        <Clock class=\"mr-2 h-4 w-4\" />\n        统计字数时间\n      </MenubarItem>\n    </MenubarContent>\n  </MenubarMenu>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/editor/editor-header/FundDialog.vue",
    "content": "<script setup lang=\"ts\">\nconst props = defineProps({\n  visible: {\n    type: Boolean,\n    default: false,\n  },\n})\n\nconst emit = defineEmits([`close`])\n\nconst contributors = [\n  {\n    name: `yanglbme`,\n    imageUrl: `https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/doocs/md/images/support1.jpg`,\n    altText: `赞赏二维码 1`,\n  },\n  {\n    name: `yangfong`,\n    imageUrl: `https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/doocs/md/images/support2.jpg`,\n    altText: `赞赏二维码 2`,\n  },\n]\n\nfunction onUpdate(val: boolean) {\n  if (!val) {\n    emit(`close`)\n  }\n}\n</script>\n\n<template>\n  <Dialog :open=\"props.visible\" @update:open=\"onUpdate\">\n    <DialogContent>\n      <DialogHeader>\n        <DialogTitle>赞赏</DialogTitle>\n      </DialogHeader>\n      <div class=\"text-center\">\n        <p>若觉得项目不错，可以通过以下方式支持我们～</p>\n        <div class=\"grid grid-cols-2 my-5 gap-4\">\n          <div v-for=\"contributor in contributors\" :key=\"contributor.name\" class=\"text-center\">\n            <img\n              :src=\"contributor.imageUrl\"\n              :alt=\"contributor.altText\"\n              class=\"mx-auto\"\n              style=\"width: 90%; max-width: 200px;border-radius: 10%;\"\n            >\n          </div>\n        </div>\n      </div>\n\n      <DialogFooter class=\"sm:justify-evenly\">\n        <Button @click=\"emit('close')\">\n          关闭\n        </Button>\n      </DialogFooter>\n    </DialogContent>\n  </Dialog>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/editor/editor-header/HelpDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport { Heart, HelpCircle, MessageSquare, Tag } from 'lucide-vue-next'\n\nconst props = withDefaults(defineProps<{\n  asSub?: boolean\n}>(), {\n  asSub: false,\n})\n\nconst emit = defineEmits([`openAbout`, `openFund`])\n\nconst { asSub } = toRefs(props)\n\nfunction openAboutDialog() {\n  emit(`openAbout`)\n}\n\nfunction openFundDialog() {\n  emit(`openFund`)\n}\n\nfunction openFeedback() {\n  window.open('https://github.com/doocs/md/issues', '_blank')\n}\n\nfunction openReleases() {\n  window.open('https://github.com/doocs/md/releases', '_blank')\n}\n</script>\n\n<template>\n  <!-- 作为 MenubarSub 使用 -->\n  <MenubarSub v-if=\"asSub\">\n    <MenubarSubTrigger>\n      帮助\n    </MenubarSubTrigger>\n    <MenubarSubContent align=\"start\">\n      <MenubarItem @click=\"openFeedback()\">\n        <MessageSquare class=\"mr-2 h-4 w-4\" />\n        反馈\n      </MenubarItem>\n      <MenubarItem @click=\"openReleases()\">\n        <Tag class=\"mr-2 h-4 w-4\" />\n        版本历史\n      </MenubarItem>\n      <MenubarItem @click=\"openAboutDialog()\">\n        <HelpCircle class=\"mr-2 h-4 w-4\" />\n        关于\n      </MenubarItem>\n      <MenubarItem @click=\"openFundDialog()\">\n        <Heart class=\"mr-2 h-4 w-4\" />\n        赞赏\n      </MenubarItem>\n    </MenubarSubContent>\n  </MenubarSub>\n\n  <!-- 作为 MenubarMenu 使用（默认） -->\n  <MenubarMenu v-else>\n    <MenubarTrigger>帮助</MenubarTrigger>\n    <MenubarContent align=\"start\">\n      <MenubarItem @click=\"openFeedback()\">\n        <MessageSquare class=\"mr-2 h-4 w-4\" />\n        反馈\n      </MenubarItem>\n      <MenubarItem @click=\"openReleases()\">\n        <Tag class=\"mr-2 h-4 w-4\" />\n        版本历史\n      </MenubarItem>\n      <MenubarItem @click=\"openAboutDialog()\">\n        <HelpCircle class=\"mr-2 h-4 w-4\" />\n        关于\n      </MenubarItem>\n      <MenubarItem @click=\"openFundDialog()\">\n        <Heart class=\"mr-2 h-4 w-4\" />\n        赞赏\n      </MenubarItem>\n    </MenubarContent>\n  </MenubarMenu>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/editor/editor-header/InsertDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport { Contact, Image, Table } from 'lucide-vue-next'\nimport { useUIStore } from '@/stores/ui'\n\nconst props = withDefaults(defineProps<{\n  asSub?: boolean\n}>(), {\n  asSub: false,\n})\n\nconst { asSub } = toRefs(props)\nconst uiStore = useUIStore()\n\nconst { toggleShowInsertFormDialog, toggleShowUploadImgDialog, toggleShowInsertMpCardDialog } = uiStore\n</script>\n\n<template>\n  <!-- 作为 MenubarSub 使用 -->\n  <MenubarSub v-if=\"asSub\">\n    <MenubarSubTrigger>\n      插入\n    </MenubarSubTrigger>\n    <MenubarSubContent class=\"w-52\">\n      <MenubarItem @click=\"toggleShowUploadImgDialog()\">\n        <Image class=\"mr-2 h-4 w-4\" />\n        插入图片\n      </MenubarItem>\n      <MenubarItem @click=\"toggleShowInsertFormDialog()\">\n        <Table class=\"mr-2 h-4 w-4\" />\n        插入表格\n      </MenubarItem>\n      <MenubarItem @click=\"toggleShowInsertMpCardDialog()\">\n        <Contact class=\"mr-2 h-4 w-4\" />\n        公众号名片\n      </MenubarItem>\n    </MenubarSubContent>\n  </MenubarSub>\n\n  <!-- 作为 MenubarMenu 使用（默认） -->\n  <MenubarMenu v-else>\n    <MenubarTrigger>\n      插入\n    </MenubarTrigger>\n    <MenubarContent class=\"w-52\" align=\"start\">\n      <MenubarItem @click=\"toggleShowUploadImgDialog()\">\n        <Image class=\"mr-2 h-4 w-4\" />\n        插入图片\n      </MenubarItem>\n      <MenubarItem @click=\"toggleShowInsertFormDialog()\">\n        <Table class=\"mr-2 h-4 w-4\" />\n        插入表格\n      </MenubarItem>\n      <MenubarItem @click=\"toggleShowInsertMpCardDialog()\">\n        <Contact class=\"mr-2 h-4 w-4\" />\n        公众号名片\n      </MenubarItem>\n    </MenubarContent>\n  </MenubarMenu>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/editor/editor-header/PostInfo.vue",
    "content": "<script setup lang=\"ts\">\nimport type { Post, PostAccount } from '@md/shared/types'\nimport { Check, ChevronDown, ChevronRight, Info, Loader2, Minus, Send } from 'lucide-vue-next'\nimport { CheckboxIndicator, CheckboxRoot, Primitive } from 'radix-vue'\nimport { useEditorStore } from '@/stores/editor'\nimport { useRenderStore } from '@/stores/render'\nimport { useUIStore } from '@/stores/ui'\n\ndefineOptions({\n  inheritAttrs: false,\n})\n\nconst editorStore = useEditorStore()\nconst { editor } = storeToRefs(editorStore)\n\nconst renderStore = useRenderStore()\nconst { output } = storeToRefs(renderStore)\n\nconst uiStore = useUIStore()\nconst { isMobile } = storeToRefs(uiStore)\n\nconst dialogVisible = ref(false)\nconst extensionInstalled = ref(false)\nconst allAccounts = ref<PostAccount[]>([])\nconst postTaskDialogVisible = ref(false)\nconst isCheckingLogin = ref(false)\n\nconst form = ref<Post>({\n  title: ``,\n  desc: ``,\n  thumb: ``,\n  content: ``,\n  markdown: ``,\n  accounts: [] as PostAccount[],\n})\n\nconst allowPost = computed(() => extensionInstalled.value && allAccounts.value.some(a => a.checked && a.loggedIn))\n\n// 平台分类配置\nconst platformCategories = [\n  {\n    name: `媒体平台`,\n    platforms: [`wechat`, `toutiao`, `zhihu`, `baijiahao`, `wangyihao`, `sohu`, `weibo`, `bilibili`, `sspai`, `twitter`, `douyin`, `xiaohongshu`],\n  },\n  {\n    name: `博客平台`,\n    platforms: [`csdn`, `cnblogs`, `juejin`, `medium`, `cto51`, `segmentfault`, `oschina`, `infoq`, `jianshu`],\n  },\n  {\n    name: `云平台及开发者社区`,\n    platforms: [`tencentcloud`, `aliyun`, `huaweicloud`, `huaweidev`, `qianfan`, `alipayopen`, `modelscope`, `volcengine`, `elecfans`],\n  },\n]\n\n// 分类折叠状态（默认折叠云平台及开发者社区）\nconst collapsedCategories = ref<Set<string>>(new Set([`云平台及开发者社区`]))\n\nfunction toggleCategory(categoryName: string) {\n  if (collapsedCategories.value.has(categoryName)) {\n    collapsedCategories.value.delete(categoryName)\n  }\n  else {\n    collapsedCategories.value.add(categoryName)\n  }\n}\n\n// 按分类获取账号\nconst accountsByCategory = computed(() => {\n  return platformCategories.map(category => ({\n    name: category.name,\n    accounts: category.platforms\n      .map(type => allAccounts.value.find(a => a.type === type))\n      .filter((a): a is PostAccount => a !== undefined),\n  }))\n})\n\n// 判断分类是否全选（只考虑已登录的账号）\nfunction isCategoryAllSelected(accounts: PostAccount[]) {\n  const loggedInAccounts = accounts.filter(a => a.loggedIn)\n  return loggedInAccounts.length > 0 && loggedInAccounts.every(a => a.checked)\n}\n\n// 判断分类是否部分选中\nfunction isCategoryIndeterminate(accounts: PostAccount[]) {\n  const loggedInAccounts = accounts.filter(a => a.loggedIn)\n  const checkedCount = loggedInAccounts.filter(a => a.checked).length\n  return checkedCount > 0 && checkedCount < loggedInAccounts.length\n}\n\n// 切换分类全选\nfunction toggleCategorySelectAll(accounts: PostAccount[]) {\n  const loggedInAccounts = accounts.filter(a => a.loggedIn)\n  const allSelected = loggedInAccounts.every(a => a.checked)\n  loggedInAccounts.forEach(a => a.checked = !allSelected)\n}\n\nasync function prePost() {\n  // 如果扩展已安装且还没有账号数据，则开始检测\n  if (extensionInstalled.value && allAccounts.value.length === 0) {\n    // 不 await，让检测在后台进行\n    startLoginDetection()\n  }\n\n  let auto: Post = {\n    thumb: ``,\n    title: ``,\n    desc: ``,\n    content: ``,\n    markdown: ``,\n    accounts: [],\n  }\n  const accounts = allAccounts.value.filter(a => ![`ipfs`].includes(a.type))\n  try {\n    auto = {\n      thumb: document.querySelector<HTMLImageElement>(`#output img`)?.src ?? ``,\n      title: [1, 2, 3, 4, 5, 6]\n        .map(h => document.querySelector(`#output h${h}`)!)\n        .find(h => h)\n        ?.textContent ?? ``,\n      desc: document.querySelector(`#output p`)?.textContent?.trim() ?? ``,\n      content: output.value,\n      markdown: editor.value?.state.doc.toString() ?? ``,\n      accounts,\n    }\n  }\n  catch (error) {\n    console.log(`error`, error)\n  }\n  finally {\n    form.value = {\n      ...auto,\n    }\n  }\n}\n\n// 监听对话框打开，自动加载数据\nwatch(dialogVisible, (newVal) => {\n  if (newVal) {\n    prePost()\n  }\n})\n\ndeclare global {\n  interface Window {\n    syncPost: (data: { thumb: string, title: string, desc: string, content: string }) => void\n    $cose: any\n  }\n}\n\n// 获取初始平台列表（不带登录状态，用于立即显示）\nfunction getInitialPlatforms(): PostAccount[] {\n  if (window.$cose !== undefined && typeof window.$cose.getPlatforms === 'function') {\n    return window.$cose.getPlatforms().map((p: any) => ({\n      ...p,\n      checked: false,\n      loggedIn: false,\n      isChecking: true, // 标记正在检测中\n    }))\n  }\n  return []\n}\n\n// 开始登录检测（异步，不阻塞 UI，渐进式更新）\nfunction startLoginDetection() {\n  if (window.$cose === undefined)\n    return\n\n  // 立即显示平台列表（带检测中状态）\n  const initialPlatforms = getInitialPlatforms()\n  if (initialPlatforms.length > 0) {\n    allAccounts.value = initialPlatforms\n  }\n\n  isCheckingLogin.value = true\n  let hasReceivedAny = false\n\n  // 设置超时机制：如果 15 秒内没有任何响应，则停止检测\n  const timeoutId = setTimeout(() => {\n    if (!hasReceivedAny) {\n      console.log('[COSE] 登录检测超时，停止检测')\n      allAccounts.value = allAccounts.value.map(a => ({ ...a, isChecking: false }))\n      isCheckingLogin.value = false\n    }\n  }, 15000)\n\n  // 检查是否支持渐进式 API\n  if (typeof window.$cose.getAccountsProgressive === 'function') {\n    // 使用渐进式 API：每个平台检测完成后立即更新 UI\n    window.$cose.getAccountsProgressive(\n      // onProgress: 每个平台完成时调用\n      (account: PostAccount, _completed: number, _total: number) => {\n        hasReceivedAny = true\n        // 更新对应平台的状态\n        const idx = allAccounts.value.findIndex(a => a.type === account.type)\n        if (idx !== -1) {\n          allAccounts.value[idx] = { ...account, checked: false, isChecking: false }\n        }\n      },\n      // onComplete: 所有平台完成时调用\n      () => {\n        clearTimeout(timeoutId)\n        isCheckingLogin.value = false\n      },\n    )\n  }\n  else {\n    // 回退到原有 API\n    window.$cose.getAccounts((resp: PostAccount[]) => {\n      hasReceivedAny = true\n      clearTimeout(timeoutId)\n      allAccounts.value = resp.map(a => ({ ...a, checked: false, isChecking: false }))\n      isCheckingLogin.value = false\n    })\n  }\n}\n\n// 兼容旧的 getAccounts 调用（checkExtension 使用）\nasync function getAccounts(): Promise<void> {\n  return new Promise((resolve) => {\n    startLoginDetection()\n    // 立即 resolve，不等待检测完成\n    resolve()\n  })\n}\n\nfunction post() {\n  // 从 allAccounts 获取用户选择的平台（checkbox 绑定在 allAccounts 上）\n  form.value.accounts = allAccounts.value.filter(a => a.checked && a.loggedIn)\n  postTaskDialogVisible.value = true\n  dialogVisible.value = false\n}\n\nfunction onUpdate(val: boolean) {\n  if (!val) {\n    dialogVisible.value = false\n  }\n}\n\nfunction getPlatformUrl(type: string): string {\n  const urls: Record<string, string> = {\n    csdn: 'https://blog.csdn.net',\n    juejin: 'https://juejin.cn',\n    wechat: 'https://mp.weixin.qq.com',\n    zhihu: 'https://www.zhihu.com/signin',\n    toutiao: 'https://mp.toutiao.com',\n    segmentfault: 'https://segmentfault.com/user/login',\n    cnblogs: 'https://i.cnblogs.com/articles/edit',\n    oschina: 'https://my.oschina.net/blog/write',\n    cto51: 'https://blog.51cto.com/blogger/publish?&newBloger=2',\n    infoq: 'https://xie.infoq.cn/draft/',\n    jianshu: 'https://www.jianshu.com/sign_in',\n    baijiahao: 'https://baijiahao.baidu.com',\n    wangyihao: 'https://mp.163.com/subscribe_v4/index.html#/article-publish',\n    tencentcloud: 'https://cloud.tencent.com/developer',\n    medium: 'https://medium.com/m/signin',\n    sspai: 'https://sspai.com/write',\n    sohu: 'https://mp.sohu.com/mpfe/v4/login',\n    bilibili: 'https://passport.bilibili.com/login',\n    weibo: 'https://passport.weibo.com/sso/signin',\n    aliyun: 'https://account.aliyun.com/login/login.htm',\n    huaweicloud: 'https://bbs.huaweicloud.com/blogs/article',\n    huaweidev: 'https://developer.huawei.com/consumer/cn/blog/create',\n    twitter: 'https://x.com/compose/articles/edit/',\n    qianfan: 'https://qianfan.cloud.baidu.com/qianfandev/topic/create',\n    alipayopen: 'https://open.alipay.com/portal/forum/post/add#article',\n    modelscope: 'https://modelscope.cn/learn/create',\n    volcengine: 'https://developer.volcengine.com/articles/draft',\n    douyin: 'https://creator.douyin.com/creator-micro/content/post/article?default-tab=5&enter_from=publish_page&media_type=article&type=new',\n    xiaohongshu: 'https://creator.xiaohongshu.com/publish/publish?from=menu&target=article',\n    elecfans: 'https://www.elecfans.com/d/article/md/',\n  }\n  return urls[type] || '#'\n}\n\nfunction checkExtension() {\n  if (window.$cose !== undefined) {\n    extensionInstalled.value = true\n    getAccounts() // 立即开始登录检测\n    return\n  }\n\n  // 如果插件还没加载，5秒内每 500ms 检查一次\n  let count = 0\n  const timer = setInterval(async () => {\n    if (window.$cose !== undefined) {\n      extensionInstalled.value = true\n      await getAccounts()\n      clearInterval(timer)\n      return\n    }\n\n    count++\n    if (count > 10) {\n      clearInterval(timer)\n    }\n  }, 500)\n}\n\nonBeforeMount(() => {\n  checkExtension()\n})\n</script>\n\n<template>\n  <div v-bind=\"$attrs\">\n    <Dialog v-model:open=\"dialogVisible\" @update:open=\"onUpdate\">\n      <DialogTrigger>\n        <Button v-if=\"!isMobile\" variant=\"outline\" class=\"h-9\">\n          <Send class=\"mr-2 h-4 w-4\" />\n          发布\n        </Button>\n      </DialogTrigger>\n      <DialogContent class=\"!w-[750px] !max-w-[95vw] max-h-[85vh] flex flex-col overflow-hidden\">\n        <DialogHeader>\n          <DialogTitle>发布</DialogTitle>\n          <DialogDescription>\n            将文章发布到多个平台\n          </DialogDescription>\n        </DialogHeader>\n        <div class=\"flex-1 overflow-y-auto p-1 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden flex flex-col gap-4\">\n          <Alert>\n            <Info class=\"h-4 w-4\" />\n            <AlertDescription>\n              此功能由 <a href=\"https://github.com/doocs/cose\" target=\"_blank\" class=\"underline\"> GitHub 开源插件 COSE</a> 支持，完全本地运行，不收集、不存储任何用户信息。<br>如需添加更多平台或改善同步准确度，欢迎提 <a href=\"https://github.com/doocs/cose/issues\" target=\"_blank\" class=\"underline\">Issue</a> 或 PR。\n            </AlertDescription>\n          </Alert>\n\n          <Alert v-if=\"!extensionInstalled\">\n            <Info class=\"h-4 w-4\" />\n            <AlertTitle>未检测到插件</AlertTitle>\n            <AlertDescription>\n              请安装 <a href=\"https://chromewebstore.google.com/detail/ilhikcdphhpjofhlnbojifbihhfmmhfk\" target=\"_blank\" class=\"underline text-primary\">cose 文章同步助手</a> 浏览器扩展\n            </AlertDescription>\n          </Alert>\n\n          <div class=\"w-full flex items-center gap-4\">\n            <Label for=\"thumb\" class=\"w-10 text-end\">\n              封面\n            </Label>\n            <Input id=\"thumb\" v-model=\"form.thumb\" placeholder=\"自动提取第一张图\" />\n          </div>\n          <div class=\"w-full flex items-center gap-4\">\n            <Label for=\"title\" class=\"w-10 text-end\">\n              标题\n            </Label>\n            <Input id=\"title\" v-model=\"form.title\" placeholder=\"自动提取第一个标题\" />\n          </div>\n          <div class=\"w-full flex items-start gap-4\">\n            <Label for=\"desc\" class=\"w-10 text-end\">\n              描述\n            </Label>\n            <Textarea id=\"desc\" v-model=\"form.desc\" placeholder=\"自动提取第一个段落\" />\n          </div>\n\n          <div class=\"w-full flex items-start gap-4\">\n            <Label class=\"w-10 text-end\">\n              平台\n            </Label>\n            <div class=\"flex-1 space-y-3\">\n              <div v-for=\"category in accountsByCategory\" :key=\"category.name\">\n                <div class=\"flex items-center gap-2 mb-2\">\n                  <div\n                    class=\"flex items-center gap-1 cursor-pointer select-none text-sm font-medium text-muted-foreground hover:text-foreground\"\n                    @click=\"toggleCategory(category.name)\"\n                  >\n                    <ChevronDown v-if=\"!collapsedCategories.has(category.name)\" class=\"h-4 w-4\" />\n                    <ChevronRight v-else class=\"h-4 w-4\" />\n                    <span>{{ category.name }}</span>\n                    <span class=\"text-xs\">({{ category.accounts.length }})</span>\n                  </div>\n                  <div class=\"flex items-center gap-1 ml-2\">\n                    <CheckboxRoot\n                      :checked=\"isCategoryAllSelected(category.accounts) ? true : isCategoryIndeterminate(category.accounts) ? 'indeterminate' : false\"\n                      class=\"bg-background hover:bg-muted h-[18px] w-[18px] flex shrink-0 appearance-none items-center justify-center border border-gray-300 rounded-[3px] outline-hidden\"\n                      @click.stop=\"toggleCategorySelectAll(category.accounts)\"\n                    >\n                      <CheckboxIndicator>\n                        <Check v-if=\"isCategoryAllSelected(category.accounts)\" class=\"h-3 w-3\" />\n                        <Minus v-else-if=\"isCategoryIndeterminate(category.accounts)\" class=\"h-3 w-3\" />\n                      </CheckboxIndicator>\n                    </CheckboxRoot>\n                    <span class=\"text-xs text-muted-foreground\">全选</span>\n                  </div>\n                </div>\n                <div v-show=\"!collapsedCategories.has(category.name)\" class=\"grid grid-cols-2 gap-x-8 gap-y-2 pl-5\">\n                  <div\n                    v-for=\"account in category.accounts\"\n                    :key=\"account.uid\"\n                    class=\"flex items-center gap-2 whitespace-nowrap\"\n                  >\n                    <CheckboxRoot\n                      v-model:checked=\"account.checked\"\n                      :disabled=\"!account.loggedIn\"\n                      class=\"bg-background hover:bg-muted h-[18px] w-[18px] flex shrink-0 appearance-none items-center justify-center border border-gray-300 rounded-[3px] outline-hidden disabled:opacity-50 disabled:cursor-not-allowed\"\n                    >\n                      <CheckboxIndicator>\n                        <Check v-if=\"account.checked\" class=\"h-3 w-3\" />\n                      </CheckboxIndicator>\n                    </CheckboxRoot>\n                    <img\n                      :src=\"account.icon\"\n                      alt=\"\"\n                      class=\"inline-block h-[16px] w-[16px] shrink-0\"\n                    >\n                    <span class=\"text-sm font-medium\">{{ account.title }}</span>\n                    <!-- 检测中：显示转圈动画 -->\n                    <template v-if=\"account.isChecking\">\n                      <Loader2 class=\"ml-1 h-3.5 w-3.5 animate-spin text-muted-foreground\" />\n                      <span class=\"text-xs text-muted-foreground\">检测中</span>\n                    </template>\n                    <!-- 已登录：显示头像和用户名 -->\n                    <template v-else-if=\"account.loggedIn\">\n                      <img\n                        v-if=\"account.avatar\"\n                        :src=\"account.avatar\"\n                        alt=\"\"\n                        class=\"ml-1 h-4 w-4 rounded-full object-cover\"\n                        crossorigin=\"anonymous\"\n                        @error=\"(e: Event) => (e.target as HTMLImageElement).style.display = 'none'\"\n                      >\n                      <span class=\"text-sm text-muted-foreground\">@{{ account.displayName }}</span>\n                    </template>\n                    <!-- 未登录：显示登录链接 -->\n                    <Primitive\n                      v-else\n                      as=\"a\"\n                      :href=\"getPlatformUrl(account.type)\"\n                      target=\"_blank\"\n                      class=\"ml-1 text-sm text-muted-foreground hover:underline\"\n                    >\n                      登录\n                    </Primitive>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <DialogFooter>\n          <Button variant=\"outline\" @click=\"dialogVisible = false\">\n            取 消\n          </Button>\n          <Button :disabled=\"!allowPost\" @click=\"post\">\n            确 定\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n\n    <PostTaskDialog v-model:open=\"postTaskDialogVisible\" :post=\"form\" />\n  </div>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/editor/editor-header/PostTaskDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport type { Post } from '@md/shared/types'\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'\n\ndeclare global {\n  interface Window {\n    $cose: any\n  }\n}\n\nconst props = defineProps<{\n  post: Post\n  open: boolean\n}>()\n\nconst emit = defineEmits([`update:open`])\n\nconst dialogVisible = computed({\n  get: () => props.open,\n  set: value => emit(`update:open`, value),\n})\n\nconst taskStatus = ref<any>(null)\nconst submitting = ref(false)\n\nasync function startPost() {\n  if (!props.post)\n    return\n\n  const taskData = {\n    post: {\n      title: props.post.title,\n      content: props.post.content,\n      markdown: props.post.markdown,\n      thumb: props.post.thumb,\n      desc: props.post.desc,\n    },\n    accounts: props.post.accounts.filter(a => a.checked),\n  }\n\n  const onProgress = (newStatus: any) => {\n    taskStatus.value = newStatus\n  }\n\n  const onComplete = () => {\n    submitting.value = false\n  }\n\n  try {\n    window.$cose?.addTask(taskData, onProgress, onComplete)\n  }\n  catch (error) {\n    console.error(`发布失败:`, error)\n  }\n}\n\nwatch(() => props.open, (newVal) => {\n  if (newVal) {\n    startPost()\n  }\n})\n</script>\n\n<template>\n  <Dialog v-model:open=\"dialogVisible\">\n    <DialogContent>\n      <DialogHeader>\n        <DialogTitle>提交发布任务</DialogTitle>\n      </DialogHeader>\n\n      <div class=\"mt-4\">\n        <div v-if=\"!taskStatus\" class=\"py-4 text-center\">\n          等待发布..\n        </div>\n        <div v-else class=\"max-h-[400px] flex flex-col overflow-y-auto\">\n          <div\n            v-for=\"account in taskStatus?.accounts\"\n            :key=\"account.uid + account.displayName\"\n            class=\"border-b py-4 last:border-b-0\"\n          >\n            <div class=\"mb-2 flex items-center gap-2\">\n              <img\n                v-if=\"account.icon\"\n                :src=\"account.icon\"\n                class=\"object-cover h-5 w-5\"\n                alt=\"\"\n              >\n              <span>{{ account.title }} - {{ account.displayName || account.home }}</span>\n            </div>\n            <div\n              class=\"w-full flex-1 gap-2 overflow-auto pl-7 text-sm\" :class=\"{\n                'text-yellow-600': account.status === 'uploading',\n                'text-red-600': account.status === 'failed',\n                'text-green-600': account.status === 'done',\n              }\"\n            >\n              <template v-if=\"account.status === 'uploading'\">\n                {{ account.msg || '发布中' }}\n              </template>\n\n              <template v-if=\"account.status === 'failed'\">\n                同步失败, 错误内容：{{ account.error }}\n              </template>\n\n              <template v-if=\"account.status === 'done' && account.editResp\">\n                同步成功\n                <a\n                  v-if=\"account.type !== 'wordpress' && account.editResp\"\n                  :href=\"account.editResp.draftLink\"\n                  class=\"ml-2 text-blue-500 hover:underline\"\n                  referrerPolicy=\"no-referrer\"\n                  target=\"_blank\"\n                >查看草稿</a>\n              </template>\n            </div>\n          </div>\n        </div>\n      </div>\n    </DialogContent>\n  </Dialog>\n</template>\n\n<style scoped>\n.account-item {\n  margin-bottom: 1rem;\n}\n</style>\n"
  },
  {
    "path": "apps/web/src/components/editor/editor-header/StyleDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport type {\n  themeMap,\n} from '@md/shared/configs'\nimport type { Format } from 'vue-pick-colors'\nimport {\n  codeBlockThemeOptions,\n  colorOptions,\n  fontFamilyOptions,\n  fontSizeOptions,\n  legendOptions,\n  themeOptions,\n} from '@md/shared/configs'\nimport { ALargeSmall, Code, Droplet, FileCode, ImageIcon, Palette, Pipette, RotateCcw, SquareCode, Type } from 'lucide-vue-next'\nimport PickColors from 'vue-pick-colors'\nimport { useEditorStore } from '@/stores/editor'\nimport { useRenderStore } from '@/stores/render'\nimport { useThemeStore } from '@/stores/theme'\nimport { useUIStore } from '@/stores/ui'\n\nconst props = withDefaults(defineProps<{\n  asSub?: boolean\n}>(), {\n  asSub: false,\n})\n\nconst { asSub } = toRefs(props)\n\nconst themeStore = useThemeStore()\nconst uiStore = useUIStore()\nconst editorStore = useEditorStore()\nconst renderStore = useRenderStore()\n\nconst { toggleShowCssEditor } = uiStore\n\nconst {\n  theme,\n  fontFamily,\n  fontSize,\n  primaryColor,\n  codeBlockTheme,\n  legend,\n} = storeToRefs(themeStore)\n\nconst { isDark } = storeToRefs(uiStore)\n\n// Editor refresh function - triggers re-render with current theme settings\nfunction editorRefresh() {\n  themeStore.updateCodeTheme()\n\n  const raw = editorStore.getContent()\n  renderStore.render(raw)\n}\n\n// Theme change handlers\nfunction themeChanged(newTheme: keyof typeof themeMap) {\n  themeStore.theme = newTheme\n  // 使用新主题系统\n  themeStore.applyCurrentTheme()\n  editorRefresh()\n}\n\nfunction fontChanged(fonts: string) {\n  themeStore.fontFamily = fonts\n  // 使用新主题系统\n  themeStore.applyCurrentTheme()\n  editorRefresh()\n}\n\nfunction sizeChanged(size: string) {\n  themeStore.fontSize = size\n  // 使用新主题系统\n  themeStore.applyCurrentTheme()\n  editorRefresh()\n}\n\nfunction colorChanged(newColor: string) {\n  themeStore.primaryColor = newColor\n  // 使用新主题系统\n  themeStore.applyCurrentTheme()\n  editorRefresh()\n}\n\nfunction codeBlockThemeChanged(newTheme: string) {\n  themeStore.codeBlockTheme = newTheme\n  editorRefresh()\n}\n\nfunction legendChanged(newVal: string) {\n  themeStore.legend = newVal\n  editorRefresh()\n}\n\nfunction macCodeBlockChanged() {\n  themeStore.isMacCodeBlock = !themeStore.isMacCodeBlock\n  editorRefresh()\n}\n\nfunction resetStyleConfirm() {\n  uiStore.isOpenConfirmDialog = true\n}\n\nconst colorPicker = ref<HTMLElement & { show: () => void } | null>(null)\n\nfunction showPicker() {\n  colorPicker.value?.show()\n}\n\n// 自定义CSS样式\nfunction customStyle() {\n  toggleShowCssEditor()\n}\n\nconst pickColorsContainer = useTemplateRef(`pickColorsContainer`)\nconst format = ref<Format>(`rgb`)\nconst formatOptions = ref<Format[]>([`rgb`, `hex`, `hsl`, `hsv`])\n</script>\n\n<template>\n  <!-- 作为 MenubarSub 使用 -->\n  <MenubarSub v-if=\"asSub\">\n    <MenubarSubTrigger>\n      样式\n    </MenubarSubTrigger>\n    <MenubarSubContent class=\"w-56\">\n      <StyleOptionMenu\n        title=\"主题\"\n        :options=\"themeOptions\"\n        :current=\"theme\"\n        :change=\"themeChanged\"\n        :icon=\"Palette\"\n      />\n      <MenubarSeparator />\n      <StyleOptionMenu\n        title=\"字体\"\n        :options=\"fontFamilyOptions\"\n        :current=\"fontFamily\"\n        :change=\"fontChanged\"\n        :icon=\"Type\"\n      />\n      <StyleOptionMenu\n        title=\"字号\"\n        :options=\"fontSizeOptions\"\n        :current=\"fontSize\"\n        :change=\"sizeChanged\"\n        :icon=\"ALargeSmall\"\n      />\n      <StyleOptionMenu\n        title=\"主题色\"\n        :options=\"colorOptions\"\n        :current=\"primaryColor\"\n        :change=\"colorChanged\"\n        :icon=\"Droplet\"\n      />\n      <StyleOptionMenu\n        title=\"代码块主题\"\n        :options=\"codeBlockThemeOptions\"\n        :current=\"codeBlockTheme\"\n        :change=\"codeBlockThemeChanged\"\n        :icon=\"Code\"\n      />\n      <StyleOptionMenu\n        title=\"图注格式\"\n        :options=\"legendOptions\"\n        :current=\"legend\"\n        :change=\"legendChanged\"\n        :icon=\"ImageIcon\"\n      />\n      <MenubarSeparator />\n      <MenubarCheckboxItem class=\"pl-2\" @click.self.prevent=\"showPicker\">\n        <HoverCard :open-delay=\"100\">\n          <HoverCardTrigger class=\"w-full flex\">\n            <Pipette class=\"mr-2 h-4 w-4\" />\n            自定义主题色\n          </HoverCardTrigger>\n          <HoverCardContent side=\"right\" class=\"w-min\">\n            <div ref=\"pickColorsContainer\">\n              <PickColors\n                v-model:value=\"primaryColor\"\n                show-alpha\n                :format=\"format\" :format-options=\"formatOptions\"\n                :theme=\"isDark ? 'dark' : 'light'\"\n                :popup-container=\"pickColorsContainer!\"\n                @change=\"colorChanged\"\n              />\n            </div>\n          </HoverCardContent>\n        </HoverCard>\n      </MenubarCheckboxItem>\n      <MenubarCheckboxItem class=\"pl-2\" @click=\"customStyle\">\n        <FileCode class=\"mr-2 h-4 w-4\" />\n        自定义 CSS\n      </MenubarCheckboxItem>\n      <MenubarSeparator />\n      <MenubarCheckboxItem class=\"pl-2\" @click=\"macCodeBlockChanged\">\n        <SquareCode class=\"mr-2 h-4 w-4\" />\n        Mac 代码块\n      </MenubarCheckboxItem>\n      <MenubarSeparator />\n      <MenubarCheckboxItem class=\"pl-2\" divided @click=\"resetStyleConfirm\">\n        <RotateCcw class=\"mr-2 h-4 w-4\" />\n        重置\n      </MenubarCheckboxItem>\n    </MenubarSubContent>\n  </MenubarSub>\n\n  <!-- 作为 MenubarMenu 使用（默认） -->\n  <MenubarMenu v-else>\n    <MenubarTrigger>\n      样式\n    </MenubarTrigger>\n    <MenubarContent class=\"w-56\" align=\"start\">\n      <StyleOptionMenu\n        title=\"主题\"\n        :options=\"themeOptions\"\n        :current=\"theme\"\n        :change=\"themeChanged\"\n        :icon=\"Palette\"\n      />\n      <MenubarSeparator />\n      <StyleOptionMenu\n        title=\"字体\"\n        :options=\"fontFamilyOptions\"\n        :current=\"fontFamily\"\n        :change=\"fontChanged\"\n        :icon=\"Type\"\n      />\n      <StyleOptionMenu\n        title=\"字号\"\n        :options=\"fontSizeOptions\"\n        :current=\"fontSize\"\n        :change=\"sizeChanged\"\n        :icon=\"ALargeSmall\"\n      />\n      <StyleOptionMenu\n        title=\"主题色\"\n        :options=\"colorOptions\"\n        :current=\"primaryColor\"\n        :change=\"colorChanged\"\n        :icon=\"Droplet\"\n      />\n      <StyleOptionMenu\n        title=\"代码块主题\"\n        :options=\"codeBlockThemeOptions\"\n        :current=\"codeBlockTheme\"\n        :change=\"codeBlockThemeChanged\"\n        :icon=\"Code\"\n      />\n      <StyleOptionMenu\n        title=\"图注格式\"\n        :options=\"legendOptions\"\n        :current=\"legend\"\n        :change=\"legendChanged\"\n        :icon=\"ImageIcon\"\n      />\n      <MenubarSeparator />\n      <MenubarCheckboxItem class=\"pl-2\" @click.self.prevent=\"showPicker\">\n        <HoverCard :open-delay=\"100\">\n          <HoverCardTrigger class=\"w-full flex\">\n            <Pipette class=\"mr-2 h-4 w-4\" />\n            自定义主题色\n          </HoverCardTrigger>\n          <HoverCardContent side=\"right\" class=\"w-min\">\n            <div ref=\"pickColorsContainer\">\n              <PickColors\n                v-model:value=\"primaryColor\"\n                show-alpha\n                :format=\"format\" :format-options=\"formatOptions\"\n                :theme=\"isDark ? 'dark' : 'light'\"\n                :popup-container=\"pickColorsContainer!\"\n                @change=\"colorChanged\"\n              />\n            </div>\n          </HoverCardContent>\n        </HoverCard>\n      </MenubarCheckboxItem>\n      <MenubarCheckboxItem class=\"pl-2\" @click=\"customStyle\">\n        <FileCode class=\"mr-2 h-4 w-4\" />\n        自定义 CSS\n      </MenubarCheckboxItem>\n      <MenubarSeparator />\n      <MenubarCheckboxItem class=\"pl-2\" @click=\"macCodeBlockChanged\">\n        <SquareCode class=\"mr-2 h-4 w-4\" />\n        Mac 代码块\n      </MenubarCheckboxItem>\n      <MenubarSeparator />\n      <MenubarCheckboxItem class=\"pl-2\" divided @click=\"resetStyleConfirm\">\n        <RotateCcw class=\"mr-2 h-4 w-4\" />\n        重置\n      </MenubarCheckboxItem>\n    </MenubarContent>\n  </MenubarMenu>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/editor/editor-header/StyleOptionMenu.vue",
    "content": "<script setup lang=\"ts\">\nimport type { IConfigOption } from '@md/shared/types'\nimport type { Component } from 'vue'\n\nconst props = defineProps<{\n  title: string\n  options: IConfigOption[]\n  current: string\n  change: (val: any) => void\n  icon?: Component\n}>()\n\nfunction setStyle(title: string, value: string) {\n  switch (title) {\n    case `字体`:\n      return { fontFamily: value }\n    case `字号`:\n      return { fontSize: value }\n    case `主题色`:\n    case `文字颜色`:\n      return { color: value }\n    default:\n      return {}\n  }\n}\n</script>\n\n<template>\n  <MenubarSub>\n    <MenubarSubTrigger>\n      <component :is=\"props.icon\" v-if=\"props.icon\" class=\"mr-2 h-4 w-4\" />\n      <span v-else class=\"mr-2 h-4 w-4\" />\n      <span>{{ props.title }}</span>\n    </MenubarSubTrigger>\n    <MenubarSubContent class=\"max-h-56 overflow-auto\">\n      <MenubarCheckboxItem\n        v-for=\"{ label, value, desc } in options\"\n        :key=\"value\"\n        :label=\"label\"\n        :model-value=\"value\"\n        class=\"w-50\"\n        :checked=\"current === value\"\n        @click=\"change(value)\"\n      >\n        {{ label }}\n        <DropdownMenuShortcut :style=\"setStyle(title, value)\">\n          {{ desc }}\n        </DropdownMenuShortcut>\n      </MenubarCheckboxItem>\n    </MenubarSubContent>\n  </MenubarSub>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/editor/editor-header/ViewDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport { widthOptions } from '@md/shared/configs'\nimport { FileCode, Monitor, Moon, Palette, PanelLeft, Smartphone, Sun } from 'lucide-vue-next'\nimport { useThemeStore } from '@/stores/theme'\nimport { useUIStore } from '@/stores/ui'\n\nconst props = withDefaults(defineProps<{\n  asSub?: boolean\n}>(), {\n  asSub: false,\n})\n\nconst { asSub } = toRefs(props)\n\nconst uiStore = useUIStore()\nconst themeStore = useThemeStore()\n\nconst { isDark, isEditOnLeft, isShowCssEditor, isOpenRightSlider } = storeToRefs(uiStore)\nconst { previewWidth } = storeToRefs(themeStore)\n\n// Get mobile and desktop width values\nconst mobileWidth = widthOptions[0].value\nconst desktopWidth = widthOptions[1].value\n\n// Set preview mode\nfunction setPreviewMode(width: string) {\n  themeStore.previewWidth = width\n}\n</script>\n\n<template>\n  <!-- 作为 MenubarSub 使用 -->\n  <MenubarSub v-if=\"asSub\">\n    <MenubarSubTrigger>\n      视图\n    </MenubarSubTrigger>\n    <MenubarSubContent>\n      <!-- 外观子菜单 -->\n      <MenubarSub>\n        <MenubarSubTrigger>\n          <component :is=\"isDark ? Moon : Sun\" class=\"mr-2 h-4 w-4\" />\n          外观\n        </MenubarSubTrigger>\n        <MenubarSubContent>\n          <MenubarCheckboxItem :checked=\"!isDark\" @click=\"isDark = false\">\n            浅色模式\n          </MenubarCheckboxItem>\n          <MenubarCheckboxItem :checked=\"isDark\" @click=\"isDark = true\">\n            深色模式\n          </MenubarCheckboxItem>\n        </MenubarSubContent>\n      </MenubarSub>\n\n      <!-- 编辑模式子菜单 -->\n      <MenubarSub>\n        <MenubarSubTrigger>\n          <PanelLeft class=\"mr-2 h-4 w-4\" />\n          编辑模式\n        </MenubarSubTrigger>\n        <MenubarSubContent>\n          <MenubarCheckboxItem :checked=\"isEditOnLeft\" @click=\"isEditOnLeft = true\">\n            左侧编辑\n          </MenubarCheckboxItem>\n          <MenubarCheckboxItem :checked=\"!isEditOnLeft\" @click=\"isEditOnLeft = false\">\n            右侧编辑\n          </MenubarCheckboxItem>\n        </MenubarSubContent>\n      </MenubarSub>\n\n      <!-- 预览模式子菜单 -->\n      <MenubarSub>\n        <MenubarSubTrigger>\n          <component :is=\"previewWidth === mobileWidth ? Smartphone : Monitor\" class=\"mr-2 h-4 w-4\" />\n          预览模式\n        </MenubarSubTrigger>\n        <MenubarSubContent>\n          <MenubarCheckboxItem :checked=\"previewWidth === mobileWidth\" @click=\"setPreviewMode(mobileWidth)\">\n            移动端\n          </MenubarCheckboxItem>\n          <MenubarCheckboxItem :checked=\"previewWidth === desktopWidth\" @click=\"setPreviewMode(desktopWidth)\">\n            电脑端\n          </MenubarCheckboxItem>\n        </MenubarSubContent>\n      </MenubarSub>\n\n      <!-- 浮动目录子菜单 -->\n      <MenubarSub>\n        <MenubarSubTrigger>\n          <PanelLeft class=\"mr-2 h-4 w-4\" />\n          浮动目录\n        </MenubarSubTrigger>\n        <MenubarSubContent>\n          <MenubarCheckboxItem\n            :checked=\"uiStore.isShowFloatingToc && uiStore.isPinFloatingToc\"\n            @click=\"() => { uiStore.isShowFloatingToc = true; uiStore.isPinFloatingToc = true }\"\n          >\n            常驻显示\n          </MenubarCheckboxItem>\n          <MenubarCheckboxItem\n            :checked=\"uiStore.isShowFloatingToc && !uiStore.isPinFloatingToc\"\n            @click=\"() => { uiStore.isShowFloatingToc = true; uiStore.isPinFloatingToc = false }\"\n          >\n            移入触发\n          </MenubarCheckboxItem>\n          <MenubarCheckboxItem\n            :checked=\"!uiStore.isShowFloatingToc\"\n            @click=\"() => { uiStore.isShowFloatingToc = false }\"\n          >\n            隐藏\n          </MenubarCheckboxItem>\n        </MenubarSubContent>\n      </MenubarSub>\n\n      <MenubarSeparator />\n\n      <MenubarItem @click=\"isOpenRightSlider = !isOpenRightSlider\">\n        <Palette class=\"mr-2 h-4 w-4\" />\n        样式面板\n      </MenubarItem>\n      <MenubarItem @click=\"isShowCssEditor = !isShowCssEditor\">\n        <FileCode class=\"mr-2 h-4 w-4\" />\n        CSS 编辑器\n      </MenubarItem>\n    </MenubarSubContent>\n  </MenubarSub>\n\n  <!-- 作为 MenubarMenu 使用（默认） -->\n  <MenubarMenu v-else>\n    <MenubarTrigger>\n      视图\n    </MenubarTrigger>\n    <MenubarContent align=\"start\">\n      <!-- 外观子菜单 -->\n      <MenubarSub>\n        <MenubarSubTrigger>\n          <component :is=\"isDark ? Moon : Sun\" class=\"mr-2 h-4 w-4\" />\n          外观\n        </MenubarSubTrigger>\n        <MenubarSubContent>\n          <MenubarCheckboxItem :checked=\"!isDark\" @click=\"isDark = false\">\n            浅色模式\n          </MenubarCheckboxItem>\n          <MenubarCheckboxItem :checked=\"isDark\" @click=\"isDark = true\">\n            深色模式\n          </MenubarCheckboxItem>\n        </MenubarSubContent>\n      </MenubarSub>\n\n      <!-- 编辑模式子菜单 -->\n      <MenubarSub>\n        <MenubarSubTrigger>\n          <PanelLeft class=\"mr-2 h-4 w-4\" />\n          编辑模式\n        </MenubarSubTrigger>\n        <MenubarSubContent>\n          <MenubarCheckboxItem :checked=\"isEditOnLeft\" @click=\"isEditOnLeft = true\">\n            左侧编辑\n          </MenubarCheckboxItem>\n          <MenubarCheckboxItem :checked=\"!isEditOnLeft\" @click=\"isEditOnLeft = false\">\n            右侧编辑\n          </MenubarCheckboxItem>\n        </MenubarSubContent>\n      </MenubarSub>\n\n      <!-- 预览模式子菜单 -->\n      <MenubarSub>\n        <MenubarSubTrigger>\n          <component :is=\"previewWidth === mobileWidth ? Smartphone : Monitor\" class=\"mr-2 h-4 w-4\" />\n          预览模式\n        </MenubarSubTrigger>\n        <MenubarSubContent>\n          <MenubarCheckboxItem :checked=\"previewWidth === mobileWidth\" @click=\"setPreviewMode(mobileWidth)\">\n            移动端\n          </MenubarCheckboxItem>\n          <MenubarCheckboxItem :checked=\"previewWidth === desktopWidth\" @click=\"setPreviewMode(desktopWidth)\">\n            电脑端\n          </MenubarCheckboxItem>\n        </MenubarSubContent>\n      </MenubarSub>\n\n      <!-- 浮动目录子菜单 -->\n      <MenubarSub>\n        <MenubarSubTrigger>\n          <PanelLeft class=\"mr-2 h-4 w-4\" />\n          浮动目录\n        </MenubarSubTrigger>\n        <MenubarSubContent>\n          <MenubarCheckboxItem\n            :checked=\"uiStore.isShowFloatingToc && uiStore.isPinFloatingToc\"\n            @click=\"() => { uiStore.isShowFloatingToc = true; uiStore.isPinFloatingToc = true }\"\n          >\n            常驻显示\n          </MenubarCheckboxItem>\n          <MenubarCheckboxItem\n            :checked=\"uiStore.isShowFloatingToc && !uiStore.isPinFloatingToc\"\n            @click=\"() => { uiStore.isShowFloatingToc = true; uiStore.isPinFloatingToc = false }\"\n          >\n            移入触发\n          </MenubarCheckboxItem>\n          <MenubarCheckboxItem\n            :checked=\"!uiStore.isShowFloatingToc\"\n            @click=\"() => { uiStore.isShowFloatingToc = false }\"\n          >\n            隐藏\n          </MenubarCheckboxItem>\n        </MenubarSubContent>\n      </MenubarSub>\n\n      <MenubarSeparator />\n\n      <MenubarItem @click=\"isOpenRightSlider = !isOpenRightSlider\">\n        <Palette class=\"mr-2 h-4 w-4\" />\n        样式面板\n      </MenubarItem>\n      <MenubarItem @click=\"isShowCssEditor = !isShowCssEditor\">\n        <FileCode class=\"mr-2 h-4 w-4\" />\n        CSS 编辑器\n      </MenubarItem>\n    </MenubarContent>\n  </MenubarMenu>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/editor/editor-header/index.vue",
    "content": "<script setup lang=\"ts\">\nimport { Copy, Menu, Palette } from 'lucide-vue-next'\nimport { useEditorStore } from '@/stores/editor'\nimport { useExportStore } from '@/stores/export'\nimport { useRenderStore } from '@/stores/render'\nimport { useThemeStore } from '@/stores/theme'\nimport { useUIStore } from '@/stores/ui'\nimport { addPrefix, generatePureHTML, processClipboardContent } from '@/utils'\nimport { store } from '@/utils/storage'\nimport EditDropdown from './EditDropdown.vue'\nimport FileDropdown from './FileDropdown.vue'\nimport FormatDropdown from './FormatDropdown.vue'\nimport HelpDropdown from './HelpDropdown.vue'\nimport InsertDropdown from './InsertDropdown.vue'\nimport StyleDropdown from './StyleDropdown.vue'\nimport ViewDropdown from './ViewDropdown.vue'\n\nconst emit = defineEmits([`startCopy`, `endCopy`])\n\nconst editorStore = useEditorStore()\nconst themeStore = useThemeStore()\nconst renderStore = useRenderStore()\nconst uiStore = useUIStore()\nconst exportStore = useExportStore()\n\nconst { editor } = storeToRefs(editorStore)\nconst { output } = storeToRefs(renderStore)\nconst { primaryColor } = storeToRefs(themeStore)\nconst { isOpenRightSlider } = storeToRefs(uiStore)\n\n// Editor refresh function\nfunction editorRefresh() {\n  themeStore.updateCodeTheme()\n\n  const raw = editorStore.getContent()\n  renderStore.render(raw)\n}\n\n// 对话框状态\nconst aboutDialogVisible = ref(false)\nconst fundDialogVisible = ref(false)\nconst editorStateDialogVisible = ref(false)\n\n// 处理帮助菜单事件\nfunction handleOpenAbout() {\n  aboutDialogVisible.value = true\n}\n\nfunction handleOpenFund() {\n  fundDialogVisible.value = true\n}\n\nfunction handleOpenEditorState() {\n  editorStateDialogVisible.value = true\n}\n\nconst copyMode = store.reactive(addPrefix(`copyMode`), `txt`)\n\nconst { copy: copyContent } = useClipboard({\n  legacy: true,\n})\n\nconst delay = (ms: number) => new Promise<void>(resolve => window.setTimeout(resolve, ms))\n\nconst normalizeErrorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error))\n\nasync function writeClipboardItems(items: ClipboardItem[]) {\n  if (!navigator.clipboard?.write) {\n    throw new Error(`Clipboard API not available.`)\n  }\n\n  await delay(0)\n  await navigator.clipboard.write(items)\n}\n\nfunction fallbackCopyUsingExecCommand(htmlContent: string) {\n  const selection = window.getSelection()\n\n  if (!selection) {\n    return false\n  }\n\n  const tempContainer = document.createElement(`div`)\n  tempContainer.innerHTML = htmlContent\n  tempContainer.style.position = `fixed`\n  tempContainer.style.left = `-9999px`\n  tempContainer.style.top = `0`\n  tempContainer.style.opacity = `0`\n  tempContainer.style.pointerEvents = `none`\n  tempContainer.style.setProperty(`background-color`, `#ffffff`, `important`)\n  tempContainer.style.setProperty(`color`, `#000000`, `important`)\n\n  document.body.appendChild(tempContainer)\n\n  const htmlElement = document.documentElement\n  const wasDark = htmlElement.classList.contains(`dark`)\n  let successful = false\n\n  try {\n    if (wasDark) {\n      htmlElement.classList.remove(`dark`)\n    }\n\n    const range = document.createRange()\n    range.selectNodeContents(tempContainer)\n    selection.removeAllRanges()\n    selection.addRange(range)\n\n    successful = document.execCommand(`copy`)\n  }\n  catch {\n    successful = false\n  }\n  finally {\n    selection.removeAllRanges()\n    tempContainer.remove()\n\n    if (wasDark) {\n      htmlElement.classList.add(`dark`)\n    }\n  }\n\n  return successful\n}\n\n// 复制到微信公众号\nasync function copy() {\n  // 如果是 Markdown 源码，直接复制并返回\n  if (copyMode.value === `md`) {\n    const mdContent = editor.value?.state.doc.toString() || ``\n    await copyContent(mdContent)\n    toast.success(`已复制 Markdown 源码到剪贴板。`)\n    return\n  }\n\n  // 以下处理非 Markdown 的复制流程\n  emit(`startCopy`)\n\n  setTimeout(() => {\n    nextTick(async () => {\n      try {\n        await processClipboardContent(primaryColor.value)\n      }\n      catch (error) {\n        toast.error(`处理 HTML 失败，请联系开发者。${normalizeErrorMessage(error)}`)\n        editorRefresh()\n        emit(`endCopy`)\n        return\n      }\n\n      const clipboardDiv = document.getElementById(`output`)\n\n      if (!clipboardDiv) {\n        toast.error(`未找到复制输出区域，请刷新页面后重试。`)\n        editorRefresh()\n        emit(`endCopy`)\n        return\n      }\n\n      clipboardDiv.focus()\n      window.getSelection()?.removeAllRanges()\n\n      const temp = clipboardDiv.innerHTML\n\n      if (copyMode.value === `txt`) {\n        try {\n          if (typeof ClipboardItem === `undefined`) {\n            throw new TypeError(`ClipboardItem is not supported in this browser.`)\n          }\n\n          const plainText = clipboardDiv.textContent || ``\n          const clipboardItem = new ClipboardItem({\n            'text/html': new Blob([temp], { type: `text/html` }),\n            'text/plain': new Blob([plainText], { type: `text/plain` }),\n          })\n\n          await writeClipboardItems([clipboardItem])\n        }\n        catch (error) {\n          const fallbackSucceeded = fallbackCopyUsingExecCommand(temp)\n          if (!fallbackSucceeded) {\n            clipboardDiv.innerHTML = output.value\n            window.getSelection()?.removeAllRanges()\n            editorRefresh()\n            toast.error(`复制失败，请联系开发者。${normalizeErrorMessage(error)}`)\n            emit(`endCopy`)\n            return\n          }\n        }\n      }\n\n      clipboardDiv.innerHTML = output.value\n\n      if (copyMode.value === `html`) {\n        await copyContent(temp)\n      }\n      else if (copyMode.value === `html-without-style`) {\n        await copyContent(await generatePureHTML(editor.value!.state.doc.toString()))\n      }\n      else if (copyMode.value === `html-and-style`) {\n        await copyContent(exportStore.editorContent2HTML())\n      }\n\n      // 输出提示\n      toast.success(\n        copyMode.value === `html`\n          ? `已复制 HTML 源码，请进行下一步操作。`\n          : `已复制渲染后的内容到剪贴板，可直接到公众号后台粘贴。`,\n      )\n      window.dispatchEvent(\n        new CustomEvent(`copyToMp`, {\n          detail: {\n            content: output.value,\n          },\n        }),\n      )\n      editorRefresh()\n      emit(`endCopy`)\n    })\n  }, 350)\n}\n\nfunction handleCopy(mode: string) {\n  copyMode.value = mode\n  copy()\n}\n\nfunction copyToWeChat() {\n  copyMode.value = 'txt'\n  copy()\n}\n</script>\n\n<template>\n  <header\n    class=\"header-container h-15 flex flex-wrap items-center justify-between px-5 relative\"\n  >\n    <!-- 桌面端左侧菜单 -->\n    <div class=\"space-x-1 hidden md:flex\">\n      <Menubar class=\"menubar border-0\">\n        <FileDropdown @open-editor-state=\"handleOpenEditorState\" />\n        <EditDropdown @copy=\"handleCopy\" />\n        <FormatDropdown />\n        <InsertDropdown />\n        <StyleDropdown />\n        <ViewDropdown />\n        <HelpDropdown @open-about=\"handleOpenAbout\" @open-fund=\"handleOpenFund\" />\n      </Menubar>\n    </div>\n\n    <!-- 移动端汉堡菜单按钮 -->\n    <div class=\"md:hidden\">\n      <Menubar class=\"menubar border-0 p-0\">\n        <MenubarMenu>\n          <MenubarTrigger class=\"p-0\">\n            <Button variant=\"outline\" size=\"icon\">\n              <Menu class=\"size-4\" />\n            </Button>\n          </MenubarTrigger>\n          <MenubarContent align=\"start\">\n            <FileDropdown :as-sub=\"true\" @open-editor-state=\"handleOpenEditorState\" />\n            <EditDropdown :as-sub=\"true\" @copy=\"handleCopy\" />\n            <FormatDropdown :as-sub=\"true\" />\n            <InsertDropdown :as-sub=\"true\" />\n            <StyleDropdown :as-sub=\"true\" />\n            <ViewDropdown :as-sub=\"true\" />\n            <HelpDropdown :as-sub=\"true\" @open-about=\"handleOpenAbout\" @open-fund=\"handleOpenFund\" />\n          </MenubarContent>\n        </MenubarMenu>\n      </Menubar>\n    </div>\n\n    <!-- 右侧操作区 -->\n    <div class=\"flex flex-wrap items-center gap-2\">\n      <!-- 复制按钮 -->\n      <Button\n        variant=\"outline\"\n        class=\"h-9\"\n        @click=\"copyToWeChat\"\n      >\n        <Copy class=\"mr-2 h-4 w-4\" />\n        <span>复制</span>\n      </Button>\n\n      <!-- 文章信息（移动端隐藏） -->\n      <PostInfo class=\"hidden md:inline-flex\" />\n\n      <!-- 样式面板 -->\n      <Button\n        variant=\"outline\"\n        class=\"h-9\"\n        :class=\"{ 'bg-accent text-accent-foreground': isOpenRightSlider }\"\n        @click=\"isOpenRightSlider = !isOpenRightSlider\"\n      >\n        <Palette class=\"mr-2 h-4 w-4\" />\n        <span>样式</span>\n      </Button>\n    </div>\n  </header>\n\n  <!-- 对话框组件，嵌套菜单无法正常挂载，需要提取层级 -->\n  <AboutDialog :visible=\"aboutDialogVisible\" @close=\"aboutDialogVisible = false\" />\n  <FundDialog :visible=\"fundDialogVisible\" @close=\"fundDialogVisible = false\" />\n  <EditorStateDialog :visible=\"editorStateDialogVisible\" @close=\"editorStateDialogVisible = false\" />\n  <AIImageGeneratorPanel v-model:open=\"uiStore.aiImageDialogVisible\" />\n</template>\n\n<style lang=\"less\" scoped>\n.header-container {\n  background: hsl(var(--background) / 0.95);\n  border-bottom: 1px solid hsl(var(--border));\n  backdrop-filter: blur(12px);\n  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n  z-index: 50;\n\n  @media (max-width: 768px) {\n    padding-left: 1rem;\n    padding-right: 1rem;\n  }\n}\n\n.menubar {\n  user-select: none;\n\n  :deep([data-radix-menubar-trigger]) {\n    font-size: 0.875rem;\n    font-weight: 500;\n    padding: 0.5rem 0.875rem;\n    border-radius: 6px;\n    transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n    position: relative;\n\n    &:hover {\n      background: hsl(var(--accent) / 0.8);\n      color: hsl(var(--accent-foreground));\n      transform: translateY(-1px);\n    }\n\n    &[data-state='open'] {\n      background: hsl(var(--accent));\n      color: hsl(var(--accent-foreground));\n      box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);\n    }\n\n    &:active {\n      transform: translateY(0);\n    }\n  }\n\n  :deep([data-radix-menubar-content]) {\n    animation: slideDownAndFade 0.2s cubic-bezier(0.16, 1, 0.3, 1);\n  }\n\n  :deep([data-radix-menubar-item]) {\n    border-radius: 4px;\n    transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);\n\n    &:hover {\n      background: hsl(var(--accent) / 0.8);\n    }\n  }\n\n  :deep([data-radix-menubar-sub-trigger]) {\n    border-radius: 4px;\n    transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);\n\n    &:hover {\n      background: hsl(var(--accent) / 0.8);\n    }\n  }\n}\n\nkbd {\n  display: inline-flex;\n  justify-content: center;\n  align-items: center;\n  min-width: 1.5rem;\n  height: 1.375rem;\n  border: 1px solid hsl(var(--border));\n  background: linear-gradient(to bottom, hsl(var(--muted)), hsl(var(--muted) / 0.9));\n  padding: 0 0.375rem;\n  border-radius: 4px;\n  font-size: 0.6875rem;\n  font-weight: 600;\n  line-height: 1;\n  font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;\n  box-shadow: 0 1px 0 hsl(var(--border)), inset 0 0.5px 0 hsl(var(--background));\n  transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);\n  text-transform: uppercase;\n  letter-spacing: 0.025em;\n}\n\n@keyframes slideDownAndFade {\n  from {\n    opacity: 0;\n    transform: translateY(-4px);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n@media (max-width: 768px) {\n  .menubar {\n    flex-direction: column;\n    align-items: flex-start;\n    width: 100%;\n\n    > * {\n      width: 100%;\n      justify-content: flex-start;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "apps/web/src/components/editor/post-slider/PostItem.vue",
    "content": "<script setup lang=\"ts\">\nimport {\n  ChevronRight,\n  Edit3,\n  Ellipsis,\n  FileInput,\n  History,\n  Package,\n  PlusSquare,\n  Trash2,\n} from 'lucide-vue-next'\nimport { usePostStore } from '@/stores/post'\nimport { useTemplateStore } from '@/stores/template'\nimport { useUIStore } from '@/stores/ui'\n\ninterface Post {\n  id: string\n  title: string\n  content: string\n  history: {\n    datetime: string\n    content: string\n  }[]\n  createDatetime: Date\n  updateDatetime: Date\n  // 父标签\n  parentId?: string | null\n  // 展开状态\n  collapsed?: boolean\n}\n\nconst props = defineProps<{\n  // 父文章的 ID，如果值是 null，则日表示这是第一层文件\n  parentId: string | null\n  // 排序好的文章列表\n  sortedPosts: Post[]\n  // 开始重命名文章\n  startRenamePost: (id: string) => void\n  // 打开历史记录对话框\n  openHistoryDialog: (id: string) => void\n  // 开始删除文章\n  startDelPost: (id: string) => void\n  // 拖拽目的地 ID\n  dropTargetId: string | null\n  // 设置拖拽目的地\n  setDropTargetId: (id: string | null) => void\n  // 被拖拽对象\n  dragSourceId: string | null\n  // 设置被拖拽对象\n  setDragSourceId: (id: string | null) => void\n  handleDrop: (targetId: string | null) => void\n  handleDragEnd: () => void\n  // 以添加子文章的方式打开对话框\n  openAddPostDialog: (parentId: string) => void\n}>()\n\nconst postStore = usePostStore()\nconst templateStore = useTemplateStore()\nconst uiStore = useUIStore()\nconst { posts, currentPostId } = storeToRefs(postStore)\nconst { toggleShowTemplateDialog } = uiStore\n\n/* ============ 新增内容 ============ */\nconst isOpenAddDialog = ref(false)\nconst addPostInputVal = ref(``)\nwatch(isOpenAddDialog, (o) => {\n  if (o)\n    addPostInputVal.value = ``\n})\n\n// 新增：拖拽开始时记录ID并设置数据\nfunction handleDragStart(id: string, e: DragEvent) {\n  props.setDragSourceId(id)\n  e.dataTransfer?.setData(`text/plain`, id)\n  e.dataTransfer!.effectAllowed = `move` // 明确拖拽效果\n}\n\n/* ============ 折叠展开 ============ */\nfunction togglePostExpanded(postId: string) {\n  const targetPost = posts.value.find(p => p.id === postId)\n  if (targetPost) {\n    targetPost.collapsed = !targetPost.collapsed\n  }\n}\n\n/*\n * 判断文章是否有子文章\n */\nfunction isHasChild(postId: string) {\n  return props.sortedPosts.some(p => p.parentId === postId)\n}\n\n/*\n * 保存为模板\n */\nfunction saveAsTemplate(postId: string) {\n  const post = posts.value.find(p => p.id === postId)\n  if (!post)\n    return\n\n  templateStore.createTemplate({\n    name: post.title,\n    content: post.content,\n    description: `从「${post.title}」创建于 ${new Date().toLocaleString('zh-CN')}`,\n  })\n}\n\n/*\n * 应用模板\n */\nfunction applyTemplate(postId: string) {\n  currentPostId.value = postId\n  toggleShowTemplateDialog(true)\n}\n</script>\n\n<template>\n  <div v-for=\"post in props.sortedPosts.filter(p => (props.parentId == null && p.parentId == null) || p.parentId === props.parentId)\" :key=\"post.id\">\n    <!-- 根文章外层容器 -->\n    <a\n      class=\"w-full inline-flex cursor-pointer items-center gap-1 rounded p-2 text-sm transition-colors\"\n      :class=\"[\n        // eslint-disable-next-line vue/prefer-separate-static-class\n        'hover:text-primary-foreground hover:bg-primary',\n        {\n          'bg-primary text-primary-foreground shadow-sm': currentPostId === post.id,\n          'opacity-50': props.dragSourceId === post.id,\n          'outline-2 outline-dashed outline-primary  border-gray-200 bg-gray-400/50 dark:border-gray-200 dark:bg-gray-500/50':\n            props.dropTargetId === post.id,\n        },\n      ]\"\n      draggable=\"true\"\n      @dragstart=\"handleDragStart(post.id, $event)\"\n      @dragend=\"props.handleDragEnd\"\n      @drop.prevent=\"props.handleDrop(post.id)\"\n      @dragover.stop.prevent=\"props.setDropTargetId(post.id)\"\n      @dragleave.prevent=\"props.setDropTargetId(null)\"\n      @click=\"currentPostId = post.id\"\n    >\n      <!-- 折叠展开图标 -->\n      <Button\n        size=\"xs\"\n        variant=\"ghost\"\n        class=\"h-max p-0.5\"\n        :class=\"isHasChild(post.id) ? 'opacity-100' : 'opacity-0'\"\n        @click.stop=\"isHasChild(post.id) && togglePostExpanded(post.id)\"\n      >\n        <ChevronRight\n          class=\"size-4 transition-transform\"\n          :class=\"{ 'rotate-90': !post.collapsed }\"\n        />\n      </Button>\n\n      <span class=\"line-clamp-1\">{{ post.title }}</span>\n\n      <!-- 每条文章操作 -->\n      <DropdownMenu>\n        <DropdownMenuTrigger as-child>\n          <Button\n            size=\"xs\"\n            variant=\"ghost\"\n            class=\"ml-auto h-max p-0.5\"\n          >\n            <Ellipsis class=\"size-4\" />\n          </Button>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent>\n          <DropdownMenuItem @click.stop=\"props.openAddPostDialog(post.id)\">\n            <PlusSquare class=\"mr-2 size-4\" /> 新增内容\n          </DropdownMenuItem>\n          <DropdownMenuItem @click.stop=\"props.startRenamePost(post.id)\">\n            <Edit3 class=\"mr-2 size-4\" /> 重命名\n          </DropdownMenuItem>\n          <DropdownMenuItem @click.stop=\"props.openHistoryDialog(post.id)\">\n            <History class=\"mr-2 size-4\" /> 历史记录\n          </DropdownMenuItem>\n          <DropdownMenuSeparator />\n          <DropdownMenuItem @click.stop=\"saveAsTemplate(post.id)\">\n            <Package class=\"mr-2 size-4\" /> 存储为模板\n          </DropdownMenuItem>\n          <DropdownMenuItem @click.stop=\"applyTemplate(post.id)\">\n            <FileInput class=\"mr-2 size-4\" /> 应用模板\n          </DropdownMenuItem>\n          <DropdownMenuSeparator />\n          <DropdownMenuItem\n            v-if=\"posts.length > 1\"\n            @click.stop=\"props.startDelPost(post.id)\"\n          >\n            <Trash2 class=\"mr-2 size-4\" /> 删除\n          </DropdownMenuItem>\n        </DropdownMenuContent>\n      </DropdownMenu>\n    </a>\n\n    <div\n      v-if=\"isHasChild(post.id) && !post.collapsed\"\n      class=\"space-y-1 ml-4 mt-1 border-l-2 border-gray-300 pl-1 dark:border-gray-700\"\n    >\n      <PostItem\n        :parent-id=\"post.id\"\n        :sorted-posts=\"props.sortedPosts\"\n        :start-rename-post=\"props.startRenamePost\"\n        :open-history-dialog=\"props.openHistoryDialog\"\n        :start-del-post=\"props.startDelPost\"\n        :drag-source-id=\"props.dragSourceId\"\n        :set-drag-source-id=\"props.setDragSourceId\"\n        :drop-target-id=\"props.dropTargetId\"\n        :set-drop-target-id=\"props.setDropTargetId\"\n        :handle-drag-end=\"props.handleDragEnd\"\n        :handle-drop=\"props.handleDrop\"\n        :open-add-post-dialog=\"props.openAddPostDialog\"\n      />\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/editor/post-slider/index.vue",
    "content": "<script setup lang=\"ts\">\nimport { ArrowUpNarrowWide, ChevronsDownUp, ChevronsUpDown, PlusSquare, X } from 'lucide-vue-next'\nimport { useEditorStore } from '@/stores/editor'\nimport { usePostStore } from '@/stores/post'\nimport { useUIStore } from '@/stores/ui'\nimport { addPrefix } from '@/utils'\nimport { store } from '@/utils/storage'\n\nconst uiStore = useUIStore()\nconst { isMobile, isOpenPostSlider } = storeToRefs(uiStore)\n\nconst postStore = usePostStore()\nconst { posts } = storeToRefs(postStore)\n\nconst editorStore = useEditorStore()\nconst { editor } = storeToRefs(editorStore)\n\n// 控制是否启用动画\nconst enableAnimation = ref(false)\n\n// 监听 PostSlider 开关状态变化\nwatch(isOpenPostSlider, () => {\n  if (isMobile.value) {\n    // 在移动端，用户操作时启用动画\n    enableAnimation.value = true\n  }\n})\n\n// 监听设备类型变化，重置动画状态\nwatch(isMobile, () => {\n  enableAnimation.value = false\n})\n\n/* ============ 新增内容 ============ */\nconst parentId = ref<string | null>(null)\nconst isOpenAddDialog = ref(false)\nconst addPostInputVal = ref(``)\nwatch(isOpenAddDialog, (o) => {\n  if (o) {\n    addPostInputVal.value = ``\n    parentId.value = null\n  }\n})\n\nfunction openAddPostDialog(id: string) {\n  isOpenAddDialog.value = true\n  nextTick(() => {\n    parentId.value = id\n  })\n}\n\nfunction addPost() {\n  if (!addPostInputVal.value.trim())\n    return toast.error(`内容标题不可为空`)\n  if (posts.value.some(post => post.title === addPostInputVal.value.trim()))\n    return toast.error(`内容标题已存在`)\n  postStore.addPost(addPostInputVal.value.trim(), parentId.value)\n  isOpenAddDialog.value = false\n  toast.success(`内容新增成功`)\n}\n\n/* ============ 重命名 / 删除 / 历史 对象 ============ */\nconst editId = ref<string | null>(null)\nconst isOpenEditDialog = ref(false)\nconst renamePostInputVal = ref(``)\n\nfunction startRenamePost(id: string) {\n  editId.value = id\n  renamePostInputVal.value = postStore.getPostById(id)!.title\n  isOpenEditDialog.value = true\n}\nfunction renamePost() {\n  if (!renamePostInputVal.value.trim()) {\n    return toast.error(`内容标题不可为空`)\n  }\n\n  if (\n    posts.value.some(\n      post => post.title === renamePostInputVal.value.trim() && post.id !== editId.value,\n    )\n  ) {\n    return toast.error(`内容标题已存在`)\n  }\n\n  if (renamePostInputVal.value === postStore.getPostById(editId.value!)?.title) {\n    isOpenEditDialog.value = false\n    return\n  }\n\n  postStore.renamePost(editId.value!, renamePostInputVal.value.trim())\n  toast.success(`内容重命名成功`)\n  isOpenEditDialog.value = false\n}\n\nconst delId = ref<string | null>(null)\nconst isOpenDelPostConfirmDialog = ref(false)\n\nconst delConfirmText = computed(() => {\n  const title = postStore.getPostById(delId.value || ``)?.title ?? ``\n  const short = title.length > 20 ? `${title.slice(0, 20)}…` : title\n  return `此操作将删除「${short}」，是否继续？`\n})\n\nfunction startDelPost(id: string) {\n  delId.value = id\n  isOpenDelPostConfirmDialog.value = true\n}\nfunction delPost() {\n  postStore.delPost(delId.value!)\n  isOpenDelPostConfirmDialog.value = false\n  toast.success(`内容删除成功`)\n}\n\n/* ============ 历史记录 ============ */\nconst isOpenHistoryDialog = ref(false)\nconst currentPostId = ref<string | null>(null)\nconst currentHistoryIndex = ref(0)\n\nfunction openHistoryDialog(id: string) {\n  currentPostId.value = id\n  currentHistoryIndex.value = 0\n  isOpenHistoryDialog.value = true\n}\nfunction recoverHistory() {\n  const post = postStore.getPostById(currentPostId.value!)\n  if (!post) {\n    isOpenHistoryDialog.value = false\n    return\n  }\n\n  const content = post.history[currentHistoryIndex.value].content\n  post.content = content\n  const ed = toRaw(editor.value!)\n  ed.dispatch({\n    changes: { from: 0, to: ed.state.doc.length, insert: content },\n  })\n  toast.success(`记录恢复成功`)\n  isOpenHistoryDialog.value = false\n}\n\n/* ============ 排序 ============ */\nconst sortMode = store.reactive(addPrefix(`sort_mode`), `create-old-new`)\nconst sortedPosts = computed(() => {\n  return [...posts.value].sort((a, b) => {\n    switch (sortMode.value) {\n      case `A-Z`:\n        return a.title.localeCompare(b.title)\n      case `Z-A`:\n        return b.title.localeCompare(a.title)\n      case `update-new-old`:\n        return +new Date(b.updateDatetime) - +new Date(a.updateDatetime)\n      case `update-old-new`:\n        return +new Date(a.updateDatetime) - +new Date(b.updateDatetime)\n      case `create-new-old`:\n        return +new Date(b.createDatetime) - +new Date(a.createDatetime)\n      default:\n        /* create-old-new */\n        return +new Date(a.createDatetime) - +new Date(b.createDatetime)\n    }\n  })\n})\n\n/* ============ 拖拽功能 ============ */\nconst dragover = ref(false)\nconst dragSourceId = ref<string | null>(null)\nconst dropTargetId = ref<string | null>(null)\n\nfunction handleDrop(targetId: string | null) {\n  const sourceId = dragSourceId.value\n  if (!sourceId) {\n    return\n  }\n\n  // 递归检索 ID，是不是父文件拖拽到了子文件上面\n  const isParent = (id: string | null | undefined) => {\n    if (!id) {\n      return false\n    }\n\n    const post = postStore.getPostById(id)\n    if (!post) {\n      return false\n    }\n\n    if (post.parentId === sourceId) {\n      return true\n    }\n\n    return isParent(post.parentId)\n  }\n\n  if (isParent(targetId)) {\n    toast.error(`不能将内容拖拽到其子内容下面`)\n  }\n  else if (sourceId !== targetId) {\n    postStore.updatePostParentId(sourceId, targetId || null)\n  }\n\n  dragSourceId.value = null\n}\n\nfunction handleDragOver(e: DragEvent) {\n  e.preventDefault()\n}\n\nfunction handleDragEnd() {\n  dragSourceId.value = null\n  dropTargetId.value = null\n  dragover.value = false\n}\n</script>\n\n<template>\n  <!-- 移动端遮罩层 -->\n  <div\n    v-if=\"isMobile && isOpenPostSlider\"\n    class=\"fixed inset-0 bg-black/50 z-40\"\n    @click=\"isOpenPostSlider = false\"\n  />\n\n  <!-- 侧栏容器 -->\n  <div\n    class=\"h-full w-full overflow-hidden mobile-drawer\"\n    :class=\"{\n      // 移动端样式\n      'fixed top-0 left-0 z-55 bg-background border-r shadow-lg': isMobile,\n      'animate': isMobile && enableAnimation,\n      // 桌面端样式\n      'border-2 border-[#0000] border-dashed bg-gray/20 transition-colors': !isMobile,\n      'border-gray-700 bg-gray-400/50 dark:border-gray-200 dark:bg-gray-500/50': !isMobile && dragover,\n    }\"\n    :style=\"{\n      transform: isMobile && isOpenPostSlider ? 'translateX(0)'\n        : isMobile && !isOpenPostSlider ? 'translateX(-100%)'\n          : undefined,\n    }\"\n    @dragover.prevent=\"dragover = true\"\n    @dragleave.prevent=\"dragover = false\"\n    @dragend=\"handleDragEnd\"\n  >\n    <nav\n      class=\"h-full flex flex-col transition-transform overflow-hidden\"\n      :class=\"{ 'p-2': isMobile }\"\n      @dragover=\"handleDragOver\"\n      @drop.prevent=\"handleDrop(null)\"\n    >\n      <!-- 移动端标题栏 -->\n      <div v-if=\"isMobile\" class=\"sticky top-0 z-10 flex items-center justify-between px-4 py-3 border-b mb-2 bg-background\">\n        <h2 class=\"text-lg font-semibold\">\n          内容管理\n        </h2>\n        <Button variant=\"ghost\" size=\"sm\" @click=\"isOpenPostSlider = false\">\n          <X class=\"h-4 w-4\" />\n        </Button>\n      </div>\n\n      <!-- 顶部：新增 + 排序按钮 -->\n      <div class=\"space-x-4 mb-2 flex justify-center shrink-0 py-2\">\n        <!-- 新增 -->\n        <Dialog v-model:open=\"isOpenAddDialog\">\n          <DialogTrigger>\n            <TooltipProvider :delay-duration=\"200\">\n              <Tooltip>\n                <TooltipTrigger as-child>\n                  <Button variant=\"ghost\" size=\"xs\" class=\"h-max p-1\">\n                    <PlusSquare class=\"size-5\" />\n                  </Button>\n                </TooltipTrigger>\n                <TooltipContent side=\"bottom\">\n                  新增内容\n                </TooltipContent>\n              </Tooltip>\n            </TooltipProvider>\n          </DialogTrigger>\n          <DialogContent>\n            <DialogHeader>\n              <DialogTitle>新增内容</DialogTitle>\n              <DialogDescription>请输入内容名称</DialogDescription>\n            </DialogHeader>\n            <Input v-model=\"addPostInputVal\" @keyup.enter=\"addPost\" />\n            <DialogFooter>\n              <Button @click=\"addPost\">\n                确 定\n              </Button>\n            </DialogFooter>\n          </DialogContent>\n        </Dialog>\n\n        <!-- 排序 -->\n        <DropdownMenu>\n          <DropdownMenuTrigger>\n            <TooltipProvider :delay-duration=\"200\">\n              <Tooltip>\n                <TooltipTrigger as-child>\n                  <Button variant=\"ghost\" size=\"xs\" class=\"h-max p-1\">\n                    <ArrowUpNarrowWide class=\"size-5\" />\n                  </Button>\n                </TooltipTrigger>\n                <TooltipContent side=\"bottom\">\n                  排序模式\n                </TooltipContent>\n              </Tooltip>\n            </TooltipProvider>\n          </DropdownMenuTrigger>\n          <DropdownMenuContent>\n            <DropdownMenuRadioGroup v-model=\"sortMode\">\n              <DropdownMenuRadioItem value=\"A-Z\">\n                文件名（A-Z）\n              </DropdownMenuRadioItem>\n              <DropdownMenuRadioItem value=\"Z-A\">\n                文件名（Z-A）\n              </DropdownMenuRadioItem>\n              <DropdownMenuSeparator />\n              <DropdownMenuRadioItem value=\"update-new-old\">\n                编辑时间（新→旧）\n              </DropdownMenuRadioItem>\n              <DropdownMenuRadioItem value=\"update-old-new\">\n                编辑时间（旧→新）\n              </DropdownMenuRadioItem>\n              <DropdownMenuSeparator />\n              <DropdownMenuRadioItem value=\"create-new-old\">\n                创建时间（新→旧）\n              </DropdownMenuRadioItem>\n              <DropdownMenuRadioItem value=\"create-old-new\">\n                创建时间（旧→新）\n              </DropdownMenuRadioItem>\n            </DropdownMenuRadioGroup>\n          </DropdownMenuContent>\n        </DropdownMenu>\n\n        <TooltipProvider :delay-duration=\"200\">\n          <Tooltip>\n            <TooltipTrigger as-child>\n              <Button variant=\"ghost\" size=\"xs\" class=\"h-max p-1\" @click=\"postStore.collapseAllPosts\">\n                <ChevronsDownUp class=\"size-5\" />\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent side=\"bottom\">\n              全部收起\n            </TooltipContent>\n          </Tooltip>\n        </TooltipProvider>\n\n        <TooltipProvider :delay-duration=\"200\">\n          <Tooltip>\n            <TooltipTrigger as-child>\n              <Button variant=\"ghost\" size=\"xs\" class=\"h-max p-1\" @click=\"postStore.expandAllPosts\">\n                <ChevronsUpDown class=\"size-5\" />\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent side=\"bottom\">\n              全部展开\n            </TooltipContent>\n          </Tooltip>\n        </TooltipProvider>\n      </div>\n\n      <!-- 列表 -->\n      <div class=\"flex-1 overflow-y-auto space-y-1 px-1\">\n        <!-- 包裹根文章和子文章，保持间距 -->\n        <PostItem\n          :parent-id=\"null\"\n          :sorted-posts=\"sortedPosts\"\n          :start-rename-post=\"startRenamePost\"\n          :open-history-dialog=\"openHistoryDialog\"\n          :start-del-post=\"startDelPost\"\n          :drop-target-id=\"dropTargetId\"\n          :set-drop-target-id=\"(id: string | null) => (dropTargetId = id)\"\n          :drag-source-id=\"dragSourceId\"\n          :set-drag-source-id=\"(id: string | null) => (dragSourceId = id)\"\n          :handle-drop=\"handleDrop\"\n          :handle-drag-end=\"handleDragEnd\"\n          :open-add-post-dialog=\"openAddPostDialog\"\n        />\n      </div>\n    </nav>\n  </div>\n\n  <!-- 重命名弹窗 -->\n  <Dialog v-model:open=\"isOpenEditDialog\">\n    <DialogContent class=\"sm:max-w-[425px]\">\n      <DialogHeader>\n        <DialogTitle>编辑内容名称</DialogTitle>\n        <DialogDescription>请输入新的内容名称</DialogDescription>\n      </DialogHeader>\n      <Input v-model=\"renamePostInputVal\" @keyup.enter=\"renamePost\" />\n      <DialogFooter>\n        <Button variant=\"outline\" @click=\"isOpenEditDialog = false\">\n          取消\n        </Button>\n        <Button @click=\"renamePost\">\n          保存\n        </Button>\n      </DialogFooter>\n    </DialogContent>\n  </Dialog>\n\n  <!-- 删除确认 -->\n  <AlertDialog v-model:open=\"isOpenDelPostConfirmDialog\">\n    <AlertDialogContent>\n      <AlertDialogHeader>\n        <AlertDialogTitle>提示</AlertDialogTitle>\n        <AlertDialogDescription>{{ delConfirmText }}</AlertDialogDescription>\n      </AlertDialogHeader>\n      <AlertDialogFooter>\n        <AlertDialogCancel>取消</AlertDialogCancel>\n        <AlertDialogAction @click=\"delPost\">\n          确定\n        </AlertDialogAction>\n      </AlertDialogFooter>\n    </AlertDialogContent>\n  </AlertDialog>\n\n  <!-- 历史记录 -->\n  <Dialog v-model:open=\"isOpenHistoryDialog\">\n    <DialogContent class=\"sm:max-w-4xl\">\n      <DialogHeader>\n        <DialogTitle>历史记录</DialogTitle>\n        <DialogDescription>每隔 30 秒自动保存，最多保留 10 条</DialogDescription>\n      </DialogHeader>\n\n      <div class=\"h-[50vh] flex\">\n        <!-- 左侧时间轴 -->\n        <ul class=\"space-y-1.5 w-[180px]\">\n          <li\n            v-for=\"(item, idx) in postStore.getPostById(currentPostId!)?.history\"\n            :key=\"item.datetime\"\n            class=\"min-h-[2.75rem] w-full inline-flex cursor-pointer items-center gap-2 rounded-md px-3 py-2.5 text-sm transition-colors leading-tight\"\n            :class=\"[\n              // eslint-disable-next-line vue/prefer-separate-static-class\n              'hover:bg-primary hover:text-primary-foreground',\n              {\n                'bg-primary text-primary-foreground shadow-sm':\n                  currentHistoryIndex === idx,\n              },\n            ]\"\n            @click=\"currentHistoryIndex = idx\"\n          >\n            <span class=\"break-words w-full\">{{ item.datetime }}</span>\n          </li>\n        </ul>\n\n        <Separator orientation=\"vertical\" class=\"mx-2\" />\n\n        <!-- 右侧内容 -->\n        <div class=\"space-y-2 max-h-full flex-1 overflow-y-auto\">\n          <div\n            class=\"whitespace-pre-wrap p-2\"\n            style=\"word-wrap: break-word; overflow-wrap: break-word; word-break: break-all; hyphens: auto;\"\n          >\n            {{ postStore.getPostById(currentPostId!)?.history[currentHistoryIndex].content ?? '' }}\n          </div>\n        </div>\n      </div>\n\n      <DialogFooter>\n        <AlertDialog>\n          <AlertDialogTrigger><Button>恢 复</Button></AlertDialogTrigger>\n          <AlertDialogContent>\n            <AlertDialogHeader>\n              <AlertDialogTitle>提示</AlertDialogTitle>\n              <AlertDialogDescription>\n                此操作将用该记录替换当前文章内容，是否继续？\n              </AlertDialogDescription>\n            </AlertDialogHeader>\n            <AlertDialogFooter>\n              <AlertDialogCancel>取消</AlertDialogCancel>\n              <AlertDialogAction @click=\"recoverHistory\">\n                恢 复\n              </AlertDialogAction>\n            </AlertDialogFooter>\n          </AlertDialogContent>\n        </AlertDialog>\n      </DialogFooter>\n    </DialogContent>\n  </Dialog>\n</template>\n\n<style scoped>\n/* 移动端侧边栏动画 - 只有添加了 animate 类才启用 */\n.mobile-drawer.animate {\n  transition: transform 300ms cubic-bezier(0.16, 1, 0.3, 1);\n}\n</style>\n"
  },
  {
    "path": "apps/web/src/components/ui/alert/Alert.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport type { AlertVariants } from '.'\nimport { cn } from '@/lib/utils'\nimport { alertVariants } from '.'\n\nconst props = defineProps<{\n  class?: HTMLAttributes[`class`]\n  variant?: AlertVariants[`variant`]\n}>()\n</script>\n\n<template>\n  <div :class=\"cn(alertVariants({ variant }), props.class)\" role=\"alert\">\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/alert/AlertDescription.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<{\n  class?: HTMLAttributes[`class`]\n}>()\n</script>\n\n<template>\n  <div :class=\"cn('text-sm [&_p]:leading-relaxed', props.class)\">\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/alert/AlertTitle.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<{\n  class?: HTMLAttributes[`class`]\n}>()\n</script>\n\n<template>\n  <h5 :class=\"cn('mb-1 font-medium leading-none tracking-tight', props.class)\">\n    <slot />\n  </h5>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/alert/index.ts",
    "content": "import type { VariantProps } from 'class-variance-authority'\nimport { cva } from 'class-variance-authority'\n\nexport { default as Alert } from './Alert.vue'\nexport { default as AlertDescription } from './AlertDescription.vue'\nexport { default as AlertTitle } from './AlertTitle.vue'\n\nexport const alertVariants = cva(\n  `relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground`,\n  {\n    variants: {\n      variant: {\n        default: `bg-background text-foreground`,\n        destructive:\n          `border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive`,\n      },\n    },\n    defaultVariants: {\n      variant: `default`,\n    },\n  },\n)\n\nexport type AlertVariants = VariantProps<typeof alertVariants>\n"
  },
  {
    "path": "apps/web/src/components/ui/alert-dialog/AlertDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport type { AlertDialogEmits, AlertDialogProps } from 'radix-vue'\nimport { AlertDialogRoot, useForwardPropsEmits } from 'radix-vue'\n\nconst props = defineProps<AlertDialogProps>()\nconst emits = defineEmits<AlertDialogEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <AlertDialogRoot v-bind=\"forwarded\">\n    <slot />\n  </AlertDialogRoot>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/alert-dialog/AlertDialogAction.vue",
    "content": "<script setup lang=\"ts\">\nimport type { AlertDialogActionProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { AlertDialogAction } from 'radix-vue'\nimport { buttonVariants } from '@/components/ui/button'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes[`class`] }>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n</script>\n\n<template>\n  <AlertDialogAction v-bind=\"delegatedProps\" :class=\"cn(buttonVariants(), props.class)\">\n    <slot />\n  </AlertDialogAction>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/alert-dialog/AlertDialogCancel.vue",
    "content": "<script setup lang=\"ts\">\nimport type { AlertDialogCancelProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { AlertDialogCancel } from 'radix-vue'\nimport { buttonVariants } from '@/components/ui/button'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes[`class`] }>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n</script>\n\n<template>\n  <AlertDialogCancel\n    v-bind=\"delegatedProps\"\n    :class=\"cn(\n      buttonVariants({ variant: 'outline' }),\n      'mt-2 sm:mt-0',\n      props.class,\n    )\"\n  >\n    <slot />\n  </AlertDialogCancel>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/alert-dialog/AlertDialogContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { AlertDialogContentEmits, AlertDialogContentProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport {\n  AlertDialogContent,\n\n  AlertDialogOverlay,\n  AlertDialogPortal,\n  useForwardPropsEmits,\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes[`class`] }>()\nconst emits = defineEmits<AlertDialogContentEmits>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <AlertDialogPortal>\n    <AlertDialogOverlay\n      class=\"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80\"\n    />\n    <AlertDialogContent\n      v-bind=\"forwarded\"\n      :class=\"\n        cn(\n          'fixed left-1/2 top-1/2 z-200 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',\n          props.class,\n        )\n      \"\n    >\n      <slot />\n    </AlertDialogContent>\n  </AlertDialogPortal>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/alert-dialog/AlertDialogDescription.vue",
    "content": "<script setup lang=\"ts\">\nimport type { AlertDialogDescriptionProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport {\n  AlertDialogDescription,\n\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes[`class`] }>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n</script>\n\n<template>\n  <AlertDialogDescription\n    v-bind=\"delegatedProps\"\n    :class=\"cn('text-sm text-muted-foreground', props.class)\"\n  >\n    <slot />\n  </AlertDialogDescription>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/alert-dialog/AlertDialogFooter.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<{\n  class?: HTMLAttributes[`class`]\n}>()\n</script>\n\n<template>\n  <div\n    :class=\"\n      cn(\n        'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',\n        props.class,\n      )\n    \"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/alert-dialog/AlertDialogHeader.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<{\n  class?: HTMLAttributes[`class`]\n}>()\n</script>\n\n<template>\n  <div\n    :class=\"cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)\"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/alert-dialog/AlertDialogTitle.vue",
    "content": "<script setup lang=\"ts\">\nimport type { AlertDialogTitleProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { AlertDialogTitle } from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes[`class`] }>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n</script>\n\n<template>\n  <AlertDialogTitle\n    v-bind=\"delegatedProps\"\n    :class=\"cn('text-lg font-semibold', props.class)\"\n  >\n    <slot />\n  </AlertDialogTitle>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/alert-dialog/AlertDialogTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { AlertDialogTriggerProps } from 'radix-vue'\nimport { AlertDialogTrigger } from 'radix-vue'\n\nconst props = defineProps<AlertDialogTriggerProps>()\n</script>\n\n<template>\n  <AlertDialogTrigger v-bind=\"props\">\n    <slot />\n  </AlertDialogTrigger>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/alert-dialog/index.ts",
    "content": "export { default as AlertDialog } from './AlertDialog.vue'\nexport { default as AlertDialogAction } from './AlertDialogAction.vue'\nexport { default as AlertDialogCancel } from './AlertDialogCancel.vue'\nexport { default as AlertDialogContent } from './AlertDialogContent.vue'\nexport { default as AlertDialogDescription } from './AlertDialogDescription.vue'\nexport { default as AlertDialogFooter } from './AlertDialogFooter.vue'\nexport { default as AlertDialogHeader } from './AlertDialogHeader.vue'\nexport { default as AlertDialogTitle } from './AlertDialogTitle.vue'\nexport { default as AlertDialogTrigger } from './AlertDialogTrigger.vue'\n"
  },
  {
    "path": "apps/web/src/components/ui/back-top/BackTop.vue",
    "content": "<script setup lang=\"ts\">\nimport { throttle } from 'es-toolkit'\nimport { ArrowUpFromLine } from 'lucide-vue-next'\n\ntype Target = HTMLElement | Window | null\n\nconst props = defineProps<{\n  left?: number\n  top?: number\n  right?: number\n  bottom?: number\n  visibilityHeight?: number\n  target?: string\n  onClick?: (e: MouseEvent) => void\n}>()\n\nconst visibilityHeight = ref(props.visibilityHeight ?? 400)\nconst visible = ref(false)\n\nconst target = ref<Target>(null)\n\nfunction scrollToTop(e: MouseEvent) {\n  target.value?.scrollTo({ top: 0, left: 0, behavior: `smooth` })\n  props.onClick?.(e)\n}\n\nconst throttledScroll = throttle((el: Target) => {\n  if (el instanceof HTMLElement) {\n    visible.value = el.scrollTop > visibilityHeight.value\n  }\n  else {\n    visible.value = window.scrollY > visibilityHeight.value\n  }\n}, 200, { edges: [`leading`, `trailing`] })\n\nonMounted(() => {\n  if (props.target) {\n    target.value = document.getElementById(props.target)\n  }\n  else {\n    target.value = window\n  }\n\n  target.value!.addEventListener(`scroll`, () => {\n    throttledScroll(target.value)\n  })\n})\n\nonUnmounted(() => {\n  target.value!.removeEventListener(`scroll`, () => {\n    throttledScroll(target.value)\n  })\n})\n</script>\n\n<template>\n  <Button v-if=\"visible\" variant=\"outline\" size=\"icon\" class=\"absolute z-50 rounded-full\" :style=\"{ left: `${left}px`, top: `${top}px`, right: `${right}px`, bottom: `${bottom}px` }\" @click=\"scrollToTop\">\n    <ArrowUpFromLine />\n  </Button>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/back-top/index.ts",
    "content": "export { default as BackTop } from './BackTop.vue'\n"
  },
  {
    "path": "apps/web/src/components/ui/button/Button.vue",
    "content": "<script setup lang=\"ts\">\nimport type { PrimitiveProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport type { ButtonVariants } from '.'\nimport { Primitive } from 'radix-vue'\nimport { cn } from '@/lib/utils'\nimport { buttonVariants } from '.'\n\ninterface Props extends PrimitiveProps {\n  variant?: ButtonVariants[`variant`]\n  size?: ButtonVariants[`size`]\n  class?: HTMLAttributes[`class`]\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  as: `button`,\n})\n</script>\n\n<template>\n  <Primitive\n    :as=\"as\"\n    :as-child=\"asChild\"\n    :class=\"cn(buttonVariants({ variant, size }), props.class, 'cursor-pointer')\"\n  >\n    <slot />\n  </Primitive>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/button/index.ts",
    "content": "import type { VariantProps } from 'class-variance-authority'\nimport { cva } from 'class-variance-authority'\n\nexport { default as Button } from './Button.vue'\n\nexport const buttonVariants = cva(\n  `inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50`,\n  {\n    variants: {\n      variant: {\n        default: `bg-primary text-primary-foreground hover:bg-primary/90`,\n        destructive:\n          `bg-destructive text-destructive-foreground hover:bg-destructive/90`,\n        outline:\n          `border border-input bg-background hover:bg-accent hover:text-accent-foreground`,\n        secondary:\n          `bg-secondary text-secondary-foreground hover:bg-secondary/80`,\n        ghost: `hover:bg-accent hover:text-accent-foreground`,\n        link: `text-primary underline-offset-4 hover:underline`,\n      },\n      size: {\n        default: `h-10 px-4 py-2`,\n        xs: `h-7 rounded px-2`,\n        sm: `h-9 rounded-md px-3`,\n        lg: `h-11 rounded-md px-8`,\n        icon: `h-10 w-10`,\n      },\n    },\n    defaultVariants: {\n      variant: `default`,\n      size: `default`,\n    },\n  },\n)\n\nexport type ButtonVariants = VariantProps<typeof buttonVariants>\n"
  },
  {
    "path": "apps/web/src/components/ui/context-menu/ContextMenu.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ContextMenuRootEmits, ContextMenuRootProps } from 'radix-vue'\nimport { ContextMenuRoot, useForwardPropsEmits } from 'radix-vue'\n\nconst props = defineProps<ContextMenuRootProps>()\nconst emits = defineEmits<ContextMenuRootEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <ContextMenuRoot v-bind=\"forwarded\">\n    <slot />\n  </ContextMenuRoot>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/context-menu/ContextMenuCheckboxItem.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ContextMenuCheckboxItemEmits, ContextMenuCheckboxItemProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { Check } from 'lucide-vue-next'\nimport {\n  ContextMenuCheckboxItem,\n\n  ContextMenuItemIndicator,\n  useForwardPropsEmits,\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<ContextMenuCheckboxItemProps & { class?: HTMLAttributes[`class`] }>()\nconst emits = defineEmits<ContextMenuCheckboxItemEmits>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <ContextMenuCheckboxItem\n    v-bind=\"forwarded\"\n    :class=\"cn(\n      'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',\n      props.class,\n    )\"\n  >\n    <span class=\"absolute left-2 h-3.5 w-3.5 flex items-center justify-center\">\n      <ContextMenuItemIndicator>\n        <Check class=\"h-4 w-4\" />\n      </ContextMenuItemIndicator>\n    </span>\n    <slot />\n  </ContextMenuCheckboxItem>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/context-menu/ContextMenuContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ContextMenuContentEmits, ContextMenuContentProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport {\n  ContextMenuContent,\n\n  ContextMenuPortal,\n  useForwardPropsEmits,\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<ContextMenuContentProps & { class?: HTMLAttributes[`class`] }>()\nconst emits = defineEmits<ContextMenuContentEmits>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <ContextMenuPortal>\n    <ContextMenuContent\n      v-bind=\"forwarded\"\n      :class=\"cn(\n        'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n        props.class,\n      )\"\n    >\n      <slot />\n    </ContextMenuContent>\n  </ContextMenuPortal>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/context-menu/ContextMenuGroup.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ContextMenuGroupProps } from 'radix-vue'\nimport { ContextMenuGroup } from 'radix-vue'\n\nconst props = defineProps<ContextMenuGroupProps>()\n</script>\n\n<template>\n  <ContextMenuGroup v-bind=\"props\">\n    <slot />\n  </ContextMenuGroup>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/context-menu/ContextMenuItem.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ContextMenuItemEmits, ContextMenuItemProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport {\n  ContextMenuItem,\n\n  useForwardPropsEmits,\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<ContextMenuItemProps & { class?: HTMLAttributes[`class`], inset?: boolean }>()\nconst emits = defineEmits<ContextMenuItemEmits>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <ContextMenuItem\n    v-bind=\"forwarded\"\n    :class=\"cn(\n      'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',\n      inset && 'pl-8',\n      props.class,\n    )\"\n  >\n    <slot />\n  </ContextMenuItem>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/context-menu/ContextMenuLabel.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ContextMenuLabelProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { ContextMenuLabel } from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<ContextMenuLabelProps & { class?: HTMLAttributes[`class`], inset?: boolean }>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n</script>\n\n<template>\n  <ContextMenuLabel\n    v-bind=\"delegatedProps\"\n    :class=\"\n      cn('px-2 py-1.5 text-sm font-semibold text-foreground',\n         inset && 'pl-8', props.class,\n      )\"\n  >\n    <slot />\n  </ContextMenuLabel>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/context-menu/ContextMenuPortal.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ContextMenuPortalProps } from 'radix-vue'\nimport { ContextMenuPortal } from 'radix-vue'\n\nconst props = defineProps<ContextMenuPortalProps>()\n</script>\n\n<template>\n  <ContextMenuPortal v-bind=\"props\">\n    <slot />\n  </ContextMenuPortal>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/context-menu/ContextMenuRadioGroup.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ContextMenuRadioGroupEmits, ContextMenuRadioGroupProps } from 'radix-vue'\nimport {\n  ContextMenuRadioGroup,\n\n  useForwardPropsEmits,\n} from 'radix-vue'\n\nconst props = defineProps<ContextMenuRadioGroupProps>()\nconst emits = defineEmits<ContextMenuRadioGroupEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <ContextMenuRadioGroup v-bind=\"forwarded\">\n    <slot />\n  </ContextMenuRadioGroup>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/context-menu/ContextMenuRadioItem.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ContextMenuRadioItemEmits, ContextMenuRadioItemProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { Circle } from 'lucide-vue-next'\nimport {\n  ContextMenuItemIndicator,\n  ContextMenuRadioItem,\n\n  useForwardPropsEmits,\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<ContextMenuRadioItemProps & { class?: HTMLAttributes[`class`] }>()\nconst emits = defineEmits<ContextMenuRadioItemEmits>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <ContextMenuRadioItem\n    v-bind=\"forwarded\"\n    :class=\"cn(\n      'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',\n      props.class,\n    )\"\n  >\n    <span class=\"absolute left-2 h-3.5 w-3.5 flex items-center justify-center\">\n      <ContextMenuItemIndicator>\n        <Circle class=\"h-2 w-2 fill-current\" />\n      </ContextMenuItemIndicator>\n    </span>\n    <slot />\n  </ContextMenuRadioItem>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/context-menu/ContextMenuSeparator.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ContextMenuSeparatorProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport {\n  ContextMenuSeparator,\n\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<ContextMenuSeparatorProps & { class?: HTMLAttributes[`class`] }>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n</script>\n\n<template>\n  <ContextMenuSeparator v-bind=\"delegatedProps\" :class=\"cn('-mx-1 my-1 h-px bg-border', props.class)\" />\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/context-menu/ContextMenuShortcut.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<{\n  class?: HTMLAttributes[`class`]\n}>()\n</script>\n\n<template>\n  <span :class=\"cn('ml-auto text-xs tracking-widest text-muted-foreground', props.class)\">\n    <slot />\n  </span>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/context-menu/ContextMenuSub.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ContextMenuSubEmits, ContextMenuSubProps } from 'radix-vue'\nimport {\n  ContextMenuSub,\n\n  useForwardPropsEmits,\n} from 'radix-vue'\n\nconst props = defineProps<ContextMenuSubProps>()\nconst emits = defineEmits<ContextMenuSubEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <ContextMenuSub v-bind=\"forwarded\">\n    <slot />\n  </ContextMenuSub>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/context-menu/ContextMenuSubContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport {\n  ContextMenuSubContent,\n\n  useForwardPropsEmits,\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes[`class`] }>()\nconst emits = defineEmits<DropdownMenuSubContentEmits>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <ContextMenuSubContent\n    v-bind=\"forwarded\"\n    :class=\"\n      cn(\n        'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n        props.class,\n      )\n    \"\n  >\n    <slot />\n  </ContextMenuSubContent>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/context-menu/ContextMenuSubTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ContextMenuSubTriggerProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { ChevronRight } from 'lucide-vue-next'\nimport {\n  ContextMenuSubTrigger,\n\n  useForwardProps,\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<ContextMenuSubTriggerProps & { class?: HTMLAttributes[`class`], inset?: boolean }>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <ContextMenuSubTrigger\n    v-bind=\"forwardedProps\"\n    :class=\"cn(\n      'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',\n      inset && 'pl-8',\n      props.class,\n    )\"\n  >\n    <slot />\n    <ChevronRight class=\"ml-auto h-4 w-4\" />\n  </ContextMenuSubTrigger>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/context-menu/ContextMenuTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ContextMenuTriggerProps } from 'radix-vue'\nimport { ContextMenuTrigger, useForwardProps } from 'radix-vue'\n\nconst props = defineProps<ContextMenuTriggerProps>()\n\nconst forwardedProps = useForwardProps(props)\n</script>\n\n<template>\n  <ContextMenuTrigger v-bind=\"forwardedProps\">\n    <slot />\n  </ContextMenuTrigger>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/context-menu/index.ts",
    "content": "export { default as ContextMenu } from './ContextMenu.vue'\nexport { default as ContextMenuCheckboxItem } from './ContextMenuCheckboxItem.vue'\nexport { default as ContextMenuContent } from './ContextMenuContent.vue'\nexport { default as ContextMenuGroup } from './ContextMenuGroup.vue'\nexport { default as ContextMenuItem } from './ContextMenuItem.vue'\nexport { default as ContextMenuLabel } from './ContextMenuLabel.vue'\nexport { default as ContextMenuRadioGroup } from './ContextMenuRadioGroup.vue'\nexport { default as ContextMenuRadioItem } from './ContextMenuRadioItem.vue'\nexport { default as ContextMenuSeparator } from './ContextMenuSeparator.vue'\nexport { default as ContextMenuShortcut } from './ContextMenuShortcut.vue'\nexport { default as ContextMenuSub } from './ContextMenuSub.vue'\nexport { default as ContextMenuSubContent } from './ContextMenuSubContent.vue'\nexport { default as ContextMenuSubTrigger } from './ContextMenuSubTrigger.vue'\nexport { default as ContextMenuTrigger } from './ContextMenuTrigger.vue'\n"
  },
  {
    "path": "apps/web/src/components/ui/dialog/Dialog.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogRootEmits, DialogRootProps } from 'radix-vue'\nimport { DialogRoot, useForwardPropsEmits } from 'radix-vue'\n\nconst props = defineProps<DialogRootProps>()\nconst emits = defineEmits<DialogRootEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <DialogRoot v-bind=\"forwarded\">\n    <slot />\n  </DialogRoot>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/dialog/DialogClose.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogCloseProps } from 'radix-vue'\nimport { DialogClose } from 'radix-vue'\n\nconst props = defineProps<DialogCloseProps>()\n</script>\n\n<template>\n  <DialogClose v-bind=\"props\">\n    <slot />\n  </DialogClose>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/dialog/DialogContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogContentEmits, DialogContentProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { X } from 'lucide-vue-next'\nimport {\n  DialogClose,\n  DialogContent,\n\n  DialogOverlay,\n  DialogPortal,\n  useForwardPropsEmits,\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<DialogContentProps & { class?: HTMLAttributes[`class`] }>()\nconst emits = defineEmits<DialogContentEmits>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <DialogPortal>\n    <DialogOverlay\n      class=\"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-200 bg-black/80\"\n    />\n    <DialogContent\n      v-bind=\"forwarded\"\n      :class=\"\n        cn(\n          'fixed left-1/2 top-1/2 z-200 grid w-[90vw] max-w-md sm:max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',\n          props.class,\n        )\"\n    >\n      <slot />\n\n      <DialogClose\n        class=\"data-[state=open]:bg-accent ring-offset-background data-[state=open]:text-muted-foreground focus:ring-ring absolute right-4 top-4 rounded-sm opacity-70 transition-opacity disabled:pointer-events-none hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-offset-2\"\n      >\n        <X class=\"h-4 w-4\" />\n        <span class=\"sr-only\">Close</span>\n      </DialogClose>\n    </DialogContent>\n  </DialogPortal>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/dialog/DialogDescription.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogDescriptionProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { DialogDescription, useForwardProps } from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes[`class`] }>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <DialogDescription\n    v-bind=\"forwardedProps\"\n    :class=\"cn('text-sm text-muted-foreground', props.class)\"\n  >\n    <slot />\n  </DialogDescription>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/dialog/DialogFooter.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<{ class?: HTMLAttributes[`class`] }>()\n</script>\n\n<template>\n  <div\n    :class=\"\n      cn(\n        'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',\n        props.class,\n      )\n    \"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/dialog/DialogHeader.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<{\n  class?: HTMLAttributes[`class`]\n}>()\n</script>\n\n<template>\n  <div\n    :class=\"cn('flex flex-col gap-y-1.5 text-center sm:text-left', props.class)\"\n  >\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/dialog/DialogScrollContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogContentEmits, DialogContentProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { X } from 'lucide-vue-next'\nimport {\n  DialogClose,\n  DialogContent,\n\n  DialogOverlay,\n  DialogPortal,\n  useForwardPropsEmits,\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<DialogContentProps & { class?: HTMLAttributes[`class`] }>()\nconst emits = defineEmits<DialogContentEmits>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <DialogPortal>\n    <DialogOverlay\n      class=\"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80\"\n    >\n      <DialogContent\n        :class=\"\n          cn(\n            'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',\n            props.class,\n          )\n        \"\n        v-bind=\"forwarded\"\n        @pointer-down-outside=\"(event) => {\n          const originalEvent = event.detail.originalEvent;\n          const target = originalEvent.target as HTMLElement;\n          if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {\n            event.preventDefault();\n          }\n        }\"\n      >\n        <slot />\n\n        <DialogClose\n          class=\"hover:bg-secondary absolute right-3 top-3 rounded-md p-0.5 transition-colors\"\n        >\n          <X class=\"h-4 w-4\" />\n          <span class=\"sr-only\">Close</span>\n        </DialogClose>\n      </DialogContent>\n    </DialogOverlay>\n  </DialogPortal>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/dialog/DialogTitle.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogTitleProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { DialogTitle, useForwardProps } from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<DialogTitleProps & { class?: HTMLAttributes[`class`] }>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <DialogTitle\n    v-bind=\"forwardedProps\"\n    :class=\"\n      cn(\n        'text-lg font-semibold leading-none tracking-tight',\n        props.class,\n      )\n    \"\n  >\n    <slot />\n  </DialogTitle>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/dialog/DialogTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DialogTriggerProps } from 'radix-vue'\nimport { DialogTrigger } from 'radix-vue'\n\nconst props = defineProps<DialogTriggerProps>()\n</script>\n\n<template>\n  <DialogTrigger v-bind=\"props\">\n    <slot />\n  </DialogTrigger>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/dialog/index.ts",
    "content": "export { default as Dialog } from './Dialog.vue'\nexport { default as DialogClose } from './DialogClose.vue'\nexport { default as DialogContent } from './DialogContent.vue'\nexport { default as DialogDescription } from './DialogDescription.vue'\nexport { default as DialogFooter } from './DialogFooter.vue'\nexport { default as DialogHeader } from './DialogHeader.vue'\nexport { default as DialogScrollContent } from './DialogScrollContent.vue'\nexport { default as DialogTitle } from './DialogTitle.vue'\nexport { default as DialogTrigger } from './DialogTrigger.vue'\n"
  },
  {
    "path": "apps/web/src/components/ui/dropdown-menu/DropdownMenu.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuRootEmits, DropdownMenuRootProps } from 'radix-vue'\nimport { DropdownMenuRoot, useForwardPropsEmits } from 'radix-vue'\n\nconst props = defineProps<DropdownMenuRootProps>()\nconst emits = defineEmits<DropdownMenuRootEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <DropdownMenuRoot v-bind=\"forwarded\">\n    <slot />\n  </DropdownMenuRoot>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { Check } from 'lucide-vue-next'\nimport {\n  DropdownMenuCheckboxItem,\n\n  DropdownMenuItemIndicator,\n  useForwardPropsEmits,\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes[`class`] }>()\nconst emits = defineEmits<DropdownMenuCheckboxItemEmits>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <DropdownMenuCheckboxItem\n    v-bind=\"forwarded\"\n    :class=\" cn(\n      'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',\n      props.class,\n    )\"\n  >\n    <span class=\"absolute left-2 h-3.5 w-3.5 flex items-center justify-center\">\n      <DropdownMenuItemIndicator>\n        <Check class=\"h-4 w-4\" />\n      </DropdownMenuItemIndicator>\n    </span>\n    <slot />\n  </DropdownMenuCheckboxItem>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/dropdown-menu/DropdownMenuContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuContentEmits, DropdownMenuContentProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport {\n  DropdownMenuContent,\n\n  DropdownMenuPortal,\n  useForwardPropsEmits,\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = withDefaults(\n  defineProps<DropdownMenuContentProps & { class?: HTMLAttributes[`class`] }>(),\n  {\n    sideOffset: 4,\n  },\n)\nconst emits = defineEmits<DropdownMenuContentEmits>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <DropdownMenuPortal>\n    <DropdownMenuContent\n      v-bind=\"forwarded\"\n      :class=\"cn('z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', props.class)\"\n    >\n      <slot />\n    </DropdownMenuContent>\n  </DropdownMenuPortal>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/dropdown-menu/DropdownMenuGroup.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuGroupProps } from 'radix-vue'\nimport { DropdownMenuGroup } from 'radix-vue'\n\nconst props = defineProps<DropdownMenuGroupProps>()\n</script>\n\n<template>\n  <DropdownMenuGroup v-bind=\"props\">\n    <slot />\n  </DropdownMenuGroup>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/dropdown-menu/DropdownMenuItem.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuItemProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { DropdownMenuItem, useForwardProps } from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<DropdownMenuItemProps & { class?: HTMLAttributes[`class`], inset?: boolean }>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <DropdownMenuItem\n    v-bind=\"forwardedProps\"\n    :class=\"cn(\n      'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',\n      inset && 'pl-8',\n      props.class,\n    )\"\n  >\n    <slot />\n  </DropdownMenuItem>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/dropdown-menu/DropdownMenuLabel.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuLabelProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { DropdownMenuLabel, useForwardProps } from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<DropdownMenuLabelProps & { class?: HTMLAttributes[`class`], inset?: boolean }>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <DropdownMenuLabel\n    v-bind=\"forwardedProps\"\n    :class=\"cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', props.class)\"\n  >\n    <slot />\n  </DropdownMenuLabel>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuRadioGroupEmits, DropdownMenuRadioGroupProps } from 'radix-vue'\nimport {\n  DropdownMenuRadioGroup,\n\n  useForwardPropsEmits,\n} from 'radix-vue'\n\nconst props = defineProps<DropdownMenuRadioGroupProps>()\nconst emits = defineEmits<DropdownMenuRadioGroupEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <DropdownMenuRadioGroup v-bind=\"forwarded\">\n    <slot />\n  </DropdownMenuRadioGroup>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { Circle } from 'lucide-vue-next'\nimport {\n  DropdownMenuItemIndicator,\n  DropdownMenuRadioItem,\n\n  useForwardPropsEmits,\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes[`class`] }>()\n\nconst emits = defineEmits<DropdownMenuRadioItemEmits>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <DropdownMenuRadioItem\n    v-bind=\"forwarded\"\n    :class=\"cn(\n      'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',\n      props.class,\n    )\"\n  >\n    <span class=\"absolute left-2 h-3.5 w-3.5 flex items-center justify-center\">\n      <DropdownMenuItemIndicator>\n        <Circle class=\"h-2 w-2 fill-current\" />\n      </DropdownMenuItemIndicator>\n    </span>\n    <slot />\n  </DropdownMenuRadioItem>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/dropdown-menu/DropdownMenuSeparator.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuSeparatorProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport {\n  DropdownMenuSeparator,\n\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<DropdownMenuSeparatorProps & {\n  class?: HTMLAttributes[`class`]\n}>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n</script>\n\n<template>\n  <DropdownMenuSeparator v-bind=\"delegatedProps\" :class=\"cn('-mx-1 my-1 h-px bg-muted', props.class)\" />\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<{\n  class?: HTMLAttributes[`class`]\n}>()\n</script>\n\n<template>\n  <span :class=\"cn('ml-auto text-xs tracking-widest opacity-60', props.class)\">\n    <slot />\n  </span>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/dropdown-menu/DropdownMenuSub.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuSubEmits, DropdownMenuSubProps } from 'radix-vue'\nimport {\n  DropdownMenuSub,\n\n  useForwardPropsEmits,\n} from 'radix-vue'\n\nconst props = defineProps<DropdownMenuSubProps>()\nconst emits = defineEmits<DropdownMenuSubEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <DropdownMenuSub v-bind=\"forwarded\">\n    <slot />\n  </DropdownMenuSub>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport {\n  DropdownMenuSubContent,\n\n  useForwardPropsEmits,\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes[`class`] }>()\nconst emits = defineEmits<DropdownMenuSubContentEmits>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <DropdownMenuSubContent\n    v-bind=\"forwarded\"\n    :class=\"cn('z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', props.class)\"\n  >\n    <slot />\n  </DropdownMenuSubContent>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuSubTriggerProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { ChevronRight } from 'lucide-vue-next'\nimport {\n  DropdownMenuSubTrigger,\n\n  useForwardProps,\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes[`class`] }>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <DropdownMenuSubTrigger\n    v-bind=\"forwardedProps\"\n    :class=\"cn(\n      'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent data-[state=open]:bg-accent',\n      props.class,\n    )\"\n  >\n    <slot />\n    <ChevronRight class=\"ml-auto h-4 w-4\" />\n  </DropdownMenuSubTrigger>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DropdownMenuTriggerProps } from 'radix-vue'\nimport { DropdownMenuTrigger, useForwardProps } from 'radix-vue'\n\nconst props = defineProps<DropdownMenuTriggerProps>()\n\nconst forwardedProps = useForwardProps(props)\n</script>\n\n<template>\n  <DropdownMenuTrigger class=\"outline-hidden\" v-bind=\"forwardedProps\">\n    <slot />\n  </DropdownMenuTrigger>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/dropdown-menu/index.ts",
    "content": "export { default as DropdownMenu } from './DropdownMenu.vue'\n\nexport { default as DropdownMenuCheckboxItem } from './DropdownMenuCheckboxItem.vue'\nexport { default as DropdownMenuContent } from './DropdownMenuContent.vue'\nexport { default as DropdownMenuGroup } from './DropdownMenuGroup.vue'\nexport { default as DropdownMenuItem } from './DropdownMenuItem.vue'\nexport { default as DropdownMenuLabel } from './DropdownMenuLabel.vue'\nexport { default as DropdownMenuRadioGroup } from './DropdownMenuRadioGroup.vue'\nexport { default as DropdownMenuRadioItem } from './DropdownMenuRadioItem.vue'\nexport { default as DropdownMenuSeparator } from './DropdownMenuSeparator.vue'\nexport { default as DropdownMenuShortcut } from './DropdownMenuShortcut.vue'\nexport { default as DropdownMenuSub } from './DropdownMenuSub.vue'\nexport { default as DropdownMenuSubContent } from './DropdownMenuSubContent.vue'\nexport { default as DropdownMenuSubTrigger } from './DropdownMenuSubTrigger.vue'\nexport { default as DropdownMenuTrigger } from './DropdownMenuTrigger.vue'\nexport { DropdownMenuPortal } from 'radix-vue'\n"
  },
  {
    "path": "apps/web/src/components/ui/hover-card/HoverCard.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HoverCardRootEmits, HoverCardRootProps } from 'radix-vue'\nimport { HoverCardRoot, useForwardPropsEmits } from 'radix-vue'\n\nconst props = defineProps<HoverCardRootProps>()\nconst emits = defineEmits<HoverCardRootEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <HoverCardRoot v-bind=\"forwarded\">\n    <slot />\n  </HoverCardRoot>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/hover-card/HoverCardContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HoverCardContentProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport {\n  HoverCardContent,\n\n  HoverCardPortal,\n  useForwardProps,\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = withDefaults(\n  defineProps<HoverCardContentProps & { class?: HTMLAttributes[`class`] }>(),\n  {\n    sideOffset: 4,\n  },\n)\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <HoverCardPortal>\n    <HoverCardContent\n      v-bind=\"forwardedProps\"\n      :class=\"\n        cn(\n          'z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n          props.class,\n        )\n      \"\n    >\n      <slot />\n    </HoverCardContent>\n  </HoverCardPortal>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/hover-card/HoverCardTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HoverCardTriggerProps } from 'radix-vue'\nimport { HoverCardTrigger } from 'radix-vue'\n\nconst props = defineProps<HoverCardTriggerProps>()\n</script>\n\n<template>\n  <HoverCardTrigger v-bind=\"props\">\n    <slot />\n  </HoverCardTrigger>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/hover-card/index.ts",
    "content": "export { default as HoverCard } from './HoverCard.vue'\nexport { default as HoverCardContent } from './HoverCardContent.vue'\nexport { default as HoverCardTrigger } from './HoverCardTrigger.vue'\n"
  },
  {
    "path": "apps/web/src/components/ui/input/Input.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { useVModel } from '@vueuse/core'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<{\n  defaultValue?: string | number\n  modelValue?: string | number\n  class?: HTMLAttributes[`class`]\n}>()\n\nconst emits = defineEmits<{\n  (e: `update:modelValue`, payload: string | number): void\n}>()\n\nconst modelValue = useVModel(props, `modelValue`, emits, {\n  passive: true,\n  defaultValue: props.defaultValue,\n})\n\nconst attrs = useAttrs()\n\nconst inputRef = ref<HTMLInputElement>()\n\ndefineExpose({\n  focus: () => inputRef.value?.focus(),\n  blur: () => inputRef.value?.blur(),\n  select: () => inputRef.value?.select(),\n  setSelectionRange: (start: number, end: number) => inputRef.value?.setSelectionRange(start, end),\n  inputElement: inputRef,\n})\n</script>\n\n<template>\n  <input\n    ref=\"inputRef\"\n    v-model=\"modelValue\"\n    v-bind=\"attrs\"\n    :class=\"cn('flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', props.class)\"\n  >\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/input/index.ts",
    "content": "export { default as Input } from './Input.vue'\n"
  },
  {
    "path": "apps/web/src/components/ui/label/Label.vue",
    "content": "<script setup lang=\"ts\">\nimport type { LabelProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { Label } from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<LabelProps & { class?: HTMLAttributes[`class`] }>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n</script>\n\n<template>\n  <Label\n    v-bind=\"delegatedProps\"\n    :class=\"\n      cn(\n        'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',\n        props.class,\n      )\n    \"\n  >\n    <slot />\n  </Label>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/label/index.ts",
    "content": "export { default as Label } from './Label.vue'\n"
  },
  {
    "path": "apps/web/src/components/ui/menubar/Menubar.vue",
    "content": "<script setup lang=\"ts\">\nimport type { MenubarRootEmits, MenubarRootProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport {\n  MenubarRoot,\n\n  useForwardPropsEmits,\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<MenubarRootProps & { class?: HTMLAttributes[`class`] }>()\nconst emits = defineEmits<MenubarRootEmits>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <MenubarRoot\n    v-bind=\"forwarded\"\n    :class=\"\n      cn(\n        'flex h-10 items-center gap-x-1 rounded-md border bg-background p-1',\n        props.class,\n      )\n    \"\n  >\n    <slot />\n  </MenubarRoot>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/menubar/MenubarCheckboxItem.vue",
    "content": "<script setup lang=\"ts\">\nimport type { MenubarCheckboxItemEmits, MenubarCheckboxItemProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { Check } from 'lucide-vue-next'\nimport {\n  MenubarCheckboxItem,\n\n  MenubarItemIndicator,\n  useForwardPropsEmits,\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<MenubarCheckboxItemProps & { class?: HTMLAttributes[`class`] }>()\nconst emits = defineEmits<MenubarCheckboxItemEmits>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <MenubarCheckboxItem\n    v-bind=\"forwarded\"\n    :class=\"cn(\n      'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',\n      props.class,\n    )\"\n  >\n    <span class=\"absolute left-2 h-3.5 w-3.5 flex items-center justify-center\">\n      <MenubarItemIndicator>\n        <Check class=\"h-4 w-4\" />\n      </MenubarItemIndicator>\n    </span>\n    <slot />\n  </MenubarCheckboxItem>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/menubar/MenubarContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { MenubarContentProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport {\n  MenubarContent,\n\n  MenubarPortal,\n  useForwardProps,\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = withDefaults(\n  defineProps<MenubarContentProps & { class?: HTMLAttributes[`class`] }>(),\n  {\n    align: `start`,\n    alignOffset: -4,\n    sideOffset: 8,\n  },\n)\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <MenubarPortal>\n    <MenubarContent\n      v-bind=\"forwardedProps\"\n      :class=\"\n        cn(\n          'z-50 min-w-48 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n          props.class,\n        )\n      \"\n    >\n      <slot />\n    </MenubarContent>\n  </MenubarPortal>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/menubar/MenubarGroup.vue",
    "content": "<script setup lang=\"ts\">\nimport type { MenubarGroupProps } from 'radix-vue'\nimport { MenubarGroup } from 'radix-vue'\n\nconst props = defineProps<MenubarGroupProps>()\n</script>\n\n<template>\n  <MenubarGroup v-bind=\"props\">\n    <slot />\n  </MenubarGroup>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/menubar/MenubarItem.vue",
    "content": "<script setup lang=\"ts\">\nimport type { MenubarItemEmits, MenubarItemProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport {\n  MenubarItem,\n\n  useForwardPropsEmits,\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<MenubarItemProps & { class?: HTMLAttributes[`class`], inset?: boolean }>()\n\nconst emits = defineEmits<MenubarItemEmits>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <MenubarItem\n    v-bind=\"forwarded\"\n    :class=\"cn(\n      'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',\n      inset && 'pl-8',\n      props.class,\n    )\"\n  >\n    <slot />\n  </MenubarItem>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/menubar/MenubarLabel.vue",
    "content": "<script setup lang=\"ts\">\nimport type { MenubarLabelProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { MenubarLabel } from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<MenubarLabelProps & { class?: HTMLAttributes[`class`], inset?: boolean }>()\n</script>\n\n<template>\n  <MenubarLabel :class=\"cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', props.class)\">\n    <slot />\n  </MenubarLabel>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/menubar/MenubarMenu.vue",
    "content": "<script setup lang=\"ts\">\nimport type { MenubarMenuProps } from 'radix-vue'\nimport { MenubarMenu } from 'radix-vue'\n\nconst props = defineProps<MenubarMenuProps>()\n</script>\n\n<template>\n  <MenubarMenu v-bind=\"props\">\n    <slot />\n  </MenubarMenu>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/menubar/MenubarRadioGroup.vue",
    "content": "<script setup lang=\"ts\">\nimport type { MenubarRadioGroupEmits, MenubarRadioGroupProps } from 'radix-vue'\nimport {\n  MenubarRadioGroup,\n\n  useForwardPropsEmits,\n} from 'radix-vue'\n\nconst props = defineProps<MenubarRadioGroupProps>()\n\nconst emits = defineEmits<MenubarRadioGroupEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <MenubarRadioGroup v-bind=\"forwarded\">\n    <slot />\n  </MenubarRadioGroup>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/menubar/MenubarRadioItem.vue",
    "content": "<script setup lang=\"ts\">\nimport type { MenubarRadioItemEmits, MenubarRadioItemProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { Circle } from 'lucide-vue-next'\nimport {\n  MenubarItemIndicator,\n  MenubarRadioItem,\n\n  useForwardPropsEmits,\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<MenubarRadioItemProps & { class?: HTMLAttributes[`class`] }>()\nconst emits = defineEmits<MenubarRadioItemEmits>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <MenubarRadioItem\n    v-bind=\"forwarded\"\n    :class=\"cn(\n      'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',\n      props.class,\n    )\"\n  >\n    <span class=\"absolute left-2 h-3.5 w-3.5 flex items-center justify-center\">\n      <MenubarItemIndicator>\n        <Circle class=\"h-2 w-2 fill-current\" />\n      </MenubarItemIndicator>\n    </span>\n    <slot />\n  </MenubarRadioItem>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/menubar/MenubarSeparator.vue",
    "content": "<script setup lang=\"ts\">\nimport type { MenubarSeparatorProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { MenubarSeparator, useForwardProps } from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<MenubarSeparatorProps & { class?: HTMLAttributes[`class`] }>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <MenubarSeparator :class=\" cn('-mx-1 my-1 h-px bg-muted', props.class)\" v-bind=\"forwardedProps\" />\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/menubar/MenubarShortcut.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<{\n  class?: HTMLAttributes[`class`]\n}>()\n</script>\n\n<template>\n  <span :class=\"cn('ml-auto text-xs tracking-widest text-muted-foreground', props.class)\">\n    <slot />\n  </span>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/menubar/MenubarSub.vue",
    "content": "<script setup lang=\"ts\">\nimport type { MenubarSubEmits } from 'radix-vue'\nimport { MenubarSub, useForwardPropsEmits } from 'radix-vue'\n\ninterface MenubarSubRootProps {\n  defaultOpen?: boolean\n  open?: boolean\n}\n\nconst props = defineProps<MenubarSubRootProps>()\nconst emits = defineEmits<MenubarSubEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <MenubarSub v-bind=\"forwarded\">\n    <slot />\n  </MenubarSub>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/menubar/MenubarSubContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { MenubarSubContentEmits, MenubarSubContentProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport {\n  MenubarPortal,\n  MenubarSubContent,\n\n  useForwardPropsEmits,\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<MenubarSubContentProps & { class?: HTMLAttributes[`class`] }>()\n\nconst emits = defineEmits<MenubarSubContentEmits>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <MenubarPortal>\n    <MenubarSubContent\n      v-bind=\"forwarded\"\n      :class=\"\n        cn(\n          'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n          props.class,\n        )\n      \"\n    >\n      <slot />\n    </MenubarSubContent>\n  </MenubarPortal>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/menubar/MenubarSubTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { MenubarSubTriggerProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { ChevronRight } from 'lucide-vue-next'\nimport { MenubarSubTrigger, useForwardProps } from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<MenubarSubTriggerProps & { class?: HTMLAttributes[`class`], inset?: boolean }>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <MenubarSubTrigger\n    v-bind=\"forwardedProps\"\n    :class=\"cn(\n      'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',\n      inset && 'pl-8',\n      props.class,\n    )\"\n  >\n    <slot />\n    <ChevronRight class=\"ml-auto h-4 w-4\" />\n  </MenubarSubTrigger>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/menubar/MenubarTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { MenubarTriggerProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { MenubarTrigger, useForwardProps } from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<MenubarTriggerProps & { class?: HTMLAttributes[`class`] }>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <MenubarTrigger\n    v-bind=\"forwardedProps\"\n    :class=\"\n      cn(\n        'flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-hidden hover:bg-accent focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',\n        props.class,\n      )\n    \"\n  >\n    <slot />\n  </MenubarTrigger>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/menubar/index.ts",
    "content": "export { default as Menubar } from './Menubar.vue'\nexport { default as MenubarCheckboxItem } from './MenubarCheckboxItem.vue'\nexport { default as MenubarContent } from './MenubarContent.vue'\nexport { default as MenubarGroup } from './MenubarGroup.vue'\nexport { default as MenubarItem } from './MenubarItem.vue'\nexport { default as MenubarLabel } from './MenubarLabel.vue'\nexport { default as MenubarMenu } from './MenubarMenu.vue'\nexport { default as MenubarRadioGroup } from './MenubarRadioGroup.vue'\nexport { default as MenubarRadioItem } from './MenubarRadioItem.vue'\nexport { default as MenubarSeparator } from './MenubarSeparator.vue'\nexport { default as MenubarShortcut } from './MenubarShortcut.vue'\nexport { default as MenubarSub } from './MenubarSub.vue'\nexport { default as MenubarSubContent } from './MenubarSubContent.vue'\nexport { default as MenubarSubTrigger } from './MenubarSubTrigger.vue'\nexport { default as MenubarTrigger } from './MenubarTrigger.vue'\n"
  },
  {
    "path": "apps/web/src/components/ui/number-field/NumberField.vue",
    "content": "<script setup lang=\"ts\">\nimport type { NumberFieldRootEmits, NumberFieldRootProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { NumberFieldRoot, useForwardPropsEmits } from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<NumberFieldRootProps & { class?: HTMLAttributes[`class`] }>()\nconst emits = defineEmits<NumberFieldRootEmits>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <NumberFieldRoot v-bind=\"forwarded\" :class=\"cn('grid gap-1.5', props.class)\">\n    <slot />\n  </NumberFieldRoot>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/number-field/NumberFieldContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<{\n  class?: HTMLAttributes[`class`]\n}>()\n</script>\n\n<template>\n  <div :class=\"cn('relative has-data-[slot=increment]:*:data-[slot=input]:pr-5 has-data-[slot=decrement]:*:data-[slot=input]:pl-5', props.class)\">\n    <slot />\n  </div>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/number-field/NumberFieldDecrement.vue",
    "content": "<script setup lang=\"ts\">\nimport type { NumberFieldDecrementProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { Minus } from 'lucide-vue-next'\nimport { NumberFieldDecrement, useForwardProps } from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<NumberFieldDecrementProps & { class?: HTMLAttributes[`class`] }>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwarded = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <NumberFieldDecrement data-slot=\"decrement\" v-bind=\"forwarded\" :class=\"cn('absolute top-1/2 -translate-y-1/2 left-0 p-3 disabled:cursor-not-allowed disabled:opacity-20', props.class)\">\n    <slot>\n      <Minus class=\"h-4 w-4\" />\n    </slot>\n  </NumberFieldDecrement>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/number-field/NumberFieldIncrement.vue",
    "content": "<script setup lang=\"ts\">\nimport type { NumberFieldIncrementProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { Plus } from 'lucide-vue-next'\nimport { NumberFieldIncrement, useForwardProps } from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<NumberFieldIncrementProps & { class?: HTMLAttributes[`class`] }>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwarded = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <NumberFieldIncrement data-slot=\"increment\" v-bind=\"forwarded\" :class=\"cn('absolute top-1/2 -translate-y-1/2 right-0 disabled:cursor-not-allowed disabled:opacity-20 p-3', props.class)\">\n    <slot>\n      <Plus class=\"h-4 w-4\" />\n    </slot>\n  </NumberFieldIncrement>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/number-field/NumberFieldInput.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { NumberFieldInput } from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<{\n  class?: HTMLAttributes[`class`]\n}>()\n</script>\n\n<template>\n  <NumberFieldInput\n    data-slot=\"input\"\n    :class=\"cn('flex h-10 w-full rounded-md border border-input bg-background py-2 text-sm text-center ring-offset-background placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', props.class)\"\n  />\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/number-field/index.ts",
    "content": "export { default as NumberField } from './NumberField.vue'\nexport { default as NumberFieldContent } from './NumberFieldContent.vue'\nexport { default as NumberFieldDecrement } from './NumberFieldDecrement.vue'\nexport { default as NumberFieldIncrement } from './NumberFieldIncrement.vue'\nexport { default as NumberFieldInput } from './NumberFieldInput.vue'\n"
  },
  {
    "path": "apps/web/src/components/ui/password-input/PasswordInput.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { useVModel } from '@vueuse/core'\nimport { Eye, EyeOff } from 'lucide-vue-next'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<{\n  defaultValue?: string\n  modelValue?: string\n  class?: HTMLAttributes[`class`]\n  placeholder?: string\n}>()\n\nconst emits = defineEmits<{\n  (e: `update:modelValue`, payload: string): void\n}>()\n\nconst modelValue = useVModel(props, `modelValue`, emits, {\n  passive: true,\n  defaultValue: props.defaultValue,\n})\n\nconst showPassword = ref(false)\n\nfunction togglePasswordVisibility() {\n  showPassword.value = !showPassword.value\n}\n</script>\n\n<template>\n  <div class=\"relative\">\n    <input\n      v-model=\"modelValue\"\n      :type=\"showPassword ? 'text' : 'password'\"\n      :placeholder=\"placeholder\"\n      :class=\"cn('flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', props.class)\"\n    >\n    <button\n      type=\"button\"\n      class=\"absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors\"\n      aria-label=\"切换密码可见性\"\n      @click=\"togglePasswordVisibility\"\n    >\n      <Eye v-if=\"!showPassword\" class=\"h-4 w-4\" />\n      <EyeOff v-else class=\"h-4 w-4\" />\n    </button>\n  </div>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/password-input/index.ts",
    "content": "export { default as PasswordInput } from './PasswordInput.vue'\n"
  },
  {
    "path": "apps/web/src/components/ui/popover/Popover.vue",
    "content": "<script setup lang=\"ts\">\nimport type { PopoverRootEmits, PopoverRootProps } from 'radix-vue'\nimport { PopoverRoot, useForwardPropsEmits } from 'radix-vue'\n\nconst props = defineProps<PopoverRootProps>()\nconst emits = defineEmits<PopoverRootEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <PopoverRoot v-bind=\"forwarded\">\n    <slot />\n  </PopoverRoot>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/popover/PopoverContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { PopoverContentEmits, PopoverContentProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport {\n  PopoverContent,\n\n  PopoverPortal,\n  useForwardPropsEmits,\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\ndefineOptions({\n  inheritAttrs: false,\n})\n\nconst props = withDefaults(\n  defineProps<PopoverContentProps & { class?: HTMLAttributes[`class`] }>(),\n  {\n    align: `center`,\n    sideOffset: 4,\n  },\n)\nconst emits = defineEmits<PopoverContentEmits>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <PopoverPortal>\n    <PopoverContent\n      v-bind=\"{ ...forwarded, ...$attrs }\"\n      :class=\"\n        cn(\n          'z-200 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n          props.class,\n        )\n      \"\n    >\n      <slot />\n    </PopoverContent>\n  </PopoverPortal>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/popover/PopoverTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { PopoverTriggerProps } from 'radix-vue'\nimport { PopoverTrigger } from 'radix-vue'\n\nconst props = defineProps<PopoverTriggerProps>()\n</script>\n\n<template>\n  <PopoverTrigger v-bind=\"props\">\n    <slot />\n  </PopoverTrigger>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/popover/index.ts",
    "content": "export { default as Popover } from './Popover.vue'\nexport { default as PopoverContent } from './PopoverContent.vue'\nexport { default as PopoverTrigger } from './PopoverTrigger.vue'\n"
  },
  {
    "path": "apps/web/src/components/ui/progress/Progress.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ProgressRootProps } from 'radix-vue'\nimport { ProgressIndicator, ProgressRoot } from 'radix-vue'\n\nconst props = defineProps<ProgressRootProps>()\n\nconst modelValue = computed(() => props.modelValue ?? 0)\n</script>\n\n<template>\n  <ProgressRoot\n    v-bind=\"props\"\n    :model-value=\"modelValue\"\n    class=\"relative overflow-hidden bg-blackA9 rounded-full w-full h-4 sm:h-5\"\n    style=\"transform: translateZ(0)\"\n  >\n    <ProgressIndicator\n      class=\"bg-primary rounded-full w-full h-full transition-transform duration-[660ms] ease-[cubic-bezier(0.65, 0, 0.35, 1)]\"\n      :style=\"{ transform: `translateX(-${100 - (Number(modelValue) || 0)}%)` }\"\n    />\n  </ProgressRoot>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/progress/index.ts",
    "content": "export { default as progress } from './Progress.vue'\n"
  },
  {
    "path": "apps/web/src/components/ui/radio-group/RadioGroup.vue",
    "content": "<script setup lang=\"ts\">\r\nimport type { RadioGroupRootEmits, RadioGroupRootProps } from 'reka-ui'\r\nimport type { HTMLAttributes } from 'vue'\r\nimport { reactiveOmit } from '@vueuse/core'\r\nimport { RadioGroupRoot, useForwardPropsEmits } from 'reka-ui'\r\nimport { cn } from '@/lib/utils'\r\n\r\nconst props = defineProps<RadioGroupRootProps & { class?: HTMLAttributes[`class`] }>()\r\nconst emits = defineEmits<RadioGroupRootEmits>()\r\n\r\nconst delegatedProps = reactiveOmit(props, `class`)\r\n\r\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\r\n</script>\r\n\r\n<template>\r\n  <RadioGroupRoot\r\n    data-slot=\"radio-group\"\r\n    :class=\"cn('grid gap-3', props.class)\"\r\n    v-bind=\"forwarded\"\r\n  >\r\n    <slot />\r\n  </RadioGroupRoot>\r\n</template>\r\n"
  },
  {
    "path": "apps/web/src/components/ui/radio-group/RadioGroupItem.vue",
    "content": "<script setup lang=\"ts\">\r\nimport type { RadioGroupItemProps } from 'reka-ui'\r\nimport type { HTMLAttributes } from 'vue'\r\nimport { reactiveOmit } from '@vueuse/core'\r\nimport { CircleIcon } from 'lucide-vue-next'\r\nimport {\r\n  RadioGroupIndicator,\r\n  RadioGroupItem,\r\n\r\n  useForwardProps,\r\n} from 'reka-ui'\r\nimport { cn } from '@/lib/utils'\r\n\r\nconst props = defineProps<RadioGroupItemProps & { class?: HTMLAttributes[`class`] }>()\r\n\r\nconst delegatedProps = reactiveOmit(props, `class`)\r\n\r\nconst forwardedProps = useForwardProps(delegatedProps)\r\n</script>\r\n\r\n<template>\r\n  <RadioGroupItem\r\n    data-slot=\"radio-group-item\"\r\n    v-bind=\"forwardedProps\"\r\n    :class=\"\r\n      cn(\r\n        'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',\r\n        props.class,\r\n      )\r\n    \"\r\n  >\r\n    <RadioGroupIndicator\r\n      data-slot=\"radio-group-indicator\"\r\n      class=\"relative flex items-center justify-center\"\r\n    >\r\n      <CircleIcon class=\"fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2\" />\r\n    </RadioGroupIndicator>\r\n  </RadioGroupItem>\r\n</template>\r\n"
  },
  {
    "path": "apps/web/src/components/ui/radio-group/index.ts",
    "content": "export { default as RadioGroup } from './RadioGroup.vue'\nexport { default as RadioGroupItem } from './RadioGroupItem.vue'\n"
  },
  {
    "path": "apps/web/src/components/ui/resizable/ResizableHandle.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SplitterResizeHandleEmits, SplitterResizeHandleProps } from 'reka-ui'\nimport type { HTMLAttributes } from 'vue'\nimport { reactiveOmit } from '@vueuse/core'\nimport { GripVertical } from 'lucide-vue-next'\nimport { SplitterResizeHandle, useForwardPropsEmits } from 'reka-ui'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<SplitterResizeHandleProps & { class?: HTMLAttributes[`class`], withHandle?: boolean }>()\nconst emits = defineEmits<SplitterResizeHandleEmits>()\n\nconst delegatedProps = reactiveOmit(props, `class`)\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <SplitterResizeHandle v-bind=\"forwarded\" :class=\"cn('relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[orientation=vertical]:h-px data-[orientation=vertical]:w-full data-[orientation=vertical]:after:left-0 data-[orientation=vertical]:after:h-1 data-[orientation=vertical]:after:w-full data-[orientation=vertical]:after:-translate-y-1/2 data-[orientation=vertical]:after:translate-x-0 [&[data-orientation=vertical]>div]:rotate-90', props.class)\">\n    <template v-if=\"props.withHandle\">\n      <div class=\"bg-border z-10 h-4 w-3 flex items-center justify-center border rounded-sm\">\n        <GripVertical class=\"h-2.5 w-2.5\" />\n      </div>\n    </template>\n  </SplitterResizeHandle>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/resizable/ResizablePanelGroup.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SplitterGroupEmits, SplitterGroupProps } from 'reka-ui'\nimport type { HTMLAttributes } from 'vue'\nimport { reactiveOmit } from '@vueuse/core'\nimport { SplitterGroup, useForwardPropsEmits } from 'reka-ui'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<SplitterGroupProps & { class?: HTMLAttributes[`class`] }>()\nconst emits = defineEmits<SplitterGroupEmits>()\n\nconst delegatedProps = reactiveOmit(props, `class`)\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <SplitterGroup v-bind=\"forwarded\" :class=\"cn('flex h-full w-full data-[panel-group-direction=vertical]:flex-col', props.class)\">\n    <slot />\n  </SplitterGroup>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/resizable/index.ts",
    "content": "export { default as ResizableHandle } from './ResizableHandle.vue'\nexport { default as ResizablePanelGroup } from './ResizablePanelGroup.vue'\nexport { SplitterPanel as ResizablePanel } from 'reka-ui'\n"
  },
  {
    "path": "apps/web/src/components/ui/search-tab/SearchTab.vue",
    "content": "<script setup lang=\"ts\">\nimport type { DecorationSet } from '@codemirror/view'\nimport { StateEffect, StateField } from '@codemirror/state'\nimport { Decoration, EditorView } from '@codemirror/view'\nimport { CaseSensitive, ChevronDown, ChevronRight, ChevronUp, Regex, Replace, ReplaceAll, WholeWord, X } from 'lucide-vue-next'\n\nconst props = defineProps<{\n  editorView: EditorView\n}>()\n\nconst showSearchTab = ref(false)\nconst searchInputRef = ref<{ focus: () => void, select: () => void } | null>(null)\n\nconst searchWord = ref(``)\nconst isRegex = ref(false)\nconst isCaseSensitive = ref(false)\nconst findInSelection = ref(false)\nconst indexOfMatch = ref(0)\nconst showReplace = ref(false)\nconst replaceWord = ref(``)\nconst selectionRange = ref<{ from: number, to: number } | null>(null)\n\nconst matchPositions = ref<Array<Array<{ line: number, ch: number }>>>([])\nconst numberOfMatches = computed(() => {\n  return matchPositions.value.length\n})\n\nconst currentMatchPosition = computed(() => {\n  if (!checkMatchNumber())\n    return null\n  return matchPositions.value[indexOfMatch.value]\n})\n\n// 定义高亮样式的 StateEffect\nconst setSearchHighlights = StateEffect.define<DecorationSet>()\n\n// 创建搜索高亮的 StateField（需要在编辑器初始化时添加）\nconst searchHighlightField = StateField.define<DecorationSet>({\n  create() {\n    return Decoration.none\n  },\n  update(highlights, tr) {\n    for (const effect of tr.effects) {\n      if (effect.is(setSearchHighlights)) {\n        return effect.value\n      }\n    }\n    return highlights\n  },\n  provide: f => EditorView.decorations.from(f),\n})\n\n// 在组件挂载时动态添加 searchHighlightField\nonMounted(() => {\n  // 检查编辑器是否已经有这个 field\n  if (!props.editorView.state.field(searchHighlightField, false)) {\n    // 动态添加 extension\n    props.editorView.dispatch({\n      effects: StateEffect.appendConfig.of(searchHighlightField),\n    })\n  }\n})\n\nwatch([searchWord, isRegex, isCaseSensitive, findInSelection], () => {\n  const debouncedSearch = useDebounceFn(() => {\n    matchPositions.value = []\n\n    if (searchWord.value === ``) {\n      clearAllMarks()\n    }\n    else {\n      indexOfMatch.value = 0\n      findAllMatches()\n    }\n  }, 300)\n\n  debouncedSearch()\n})\n\nwatch([indexOfMatch, matchPositions], () => {\n  markMatch()\n})\n\nwatch(showSearchTab, async () => {\n  if (!showSearchTab.value) {\n    clearAllMarks()\n    findInSelection.value = false\n    selectionRange.value = null\n  }\n  else {\n    // 如果有选中文本，自动启用 find in selection\n    const selection = props.editorView.state.selection.main\n    if (!selection.empty) {\n      findInSelection.value = true\n      selectionRange.value = { from: selection.from, to: selection.to }\n    }\n    markMatch()\n    // 等待DOM更新后聚焦输入框，但不触发编辑器失焦\n    await nextTick()\n    // 使用 setTimeout 确保编辑器的选区不会因为输入框聚焦而丢失\n    setTimeout(() => {\n      searchInputRef.value?.focus()\n      searchInputRef.value?.select()\n    }, 0)\n  }\n})\n\nfunction clearAllMarks() {\n  // 清除所有搜索高亮\n  props.editorView.dispatch({\n    effects: setSearchHighlights.of(Decoration.none),\n  })\n}\n\nfunction markMatch() {\n  // 清除旧的高亮\n  const decorations: any[] = []\n\n  // 为所有匹配项添加高亮装饰\n  matchPositions.value.forEach((match, idx) => {\n    const from = match[0]\n    const to = match[1]\n    const fromLine = props.editorView.state.doc.line(from.line + 1)\n    const toLine = props.editorView.state.doc.line(to.line + 1)\n    const fromPos = fromLine.from + from.ch\n    const toPos = toLine.from + to.ch\n\n    // 当前选中的匹配项使用不同的样式\n    const isCurrentMatch = idx === indexOfMatch.value\n    const mark = Decoration.mark({\n      class: isCurrentMatch ? `cm-searchMatch-selected` : `cm-searchMatch`,\n    })\n\n    decorations.push(mark.range(fromPos, toPos))\n  })\n\n  // 应用装饰\n  const decorationSet = Decoration.set(decorations, true)\n  props.editorView.dispatch({\n    effects: setSearchHighlights.of(decorationSet),\n  })\n\n  // 滚动到当前匹配位置\n  if (matchPositions.value[indexOfMatch.value]?.[0]) {\n    const pos = matchPositions.value[indexOfMatch.value][0]\n    const docLine = props.editorView.state.doc.line(pos.line + 1)\n    const offset = docLine.from + pos.ch\n    props.editorView.dispatch({\n      selection: { anchor: offset, head: offset },\n      scrollIntoView: true,\n    })\n  }\n}\n\nfunction findAllMatches() {\n  if (!searchWord.value || !showSearchTab.value)\n    return\n\n  // 确定搜索范围\n  let searchFrom = 0\n  let searchTo = props.editorView.state.doc.length\n  if (findInSelection.value && selectionRange.value) {\n    searchFrom = selectionRange.value.from\n    searchTo = selectionRange.value.to\n  }\n\n  const content = props.editorView.state.sliceDoc(searchFrom, searchTo)\n  const searchTerm = searchWord.value\n  const _matchPositions: Array<Array<{ line: number, ch: number }>> = []\n\n  if (searchTerm) {\n    if (isRegex.value) {\n      try {\n        const flags = `gm${isCaseSensitive.value ? `` : `i`}`\n        const regex = new RegExp(searchTerm, flags)\n        let match\n        // eslint-disable-next-line no-cond-assign\n        while ((match = regex.exec(content)) !== null) {\n          if (match[0].length === 0) {\n            regex.lastIndex++\n            continue\n          }\n          const startPos = match.index + searchFrom\n          const endPos = match.index + match[0].length + searchFrom\n\n          const startLineObj = props.editorView.state.doc.lineAt(startPos)\n          const endLineObj = props.editorView.state.doc.lineAt(endPos)\n\n          _matchPositions.push([\n            { line: startLineObj.number - 1, ch: startPos - startLineObj.from },\n            { line: endLineObj.number - 1, ch: endPos - endLineObj.from },\n          ])\n        }\n      }\n      catch (e) {\n        console.warn(`Invalid Regex`, e)\n      }\n    }\n    else {\n      const lines = content.split(`\\n`)\n      const searchTermForCompare = isCaseSensitive.value ? searchTerm : searchTerm.toLowerCase()\n\n      lines.forEach((line, lineIndex) => {\n        const lineForCompare = isCaseSensitive.value ? line : line.toLowerCase()\n        let startIndex = 0\n        let index = lineForCompare.indexOf(searchTermForCompare, startIndex)\n\n        while (index !== -1) {\n          const actualLineObj = props.editorView.state.doc.lineAt(searchFrom)\n          const actualLineNumber = actualLineObj.number - 1 + lineIndex\n\n          _matchPositions.push([\n            { line: actualLineNumber, ch: index },\n            { line: actualLineNumber, ch: index + searchTerm.length },\n          ])\n          startIndex = index + 1\n          index = lineForCompare.indexOf(searchTermForCompare, startIndex)\n        }\n      })\n    }\n  }\n\n  matchPositions.value = _matchPositions\n  if (matchPositions.value.length > 0 && indexOfMatch.value >= matchPositions.value.length) {\n    indexOfMatch.value = matchPositions.value.length - 1\n  }\n}\n\nfunction nextMatch() {\n  if (!checkMatchNumber())\n    return\n  indexOfMatch.value = (indexOfMatch.value + 1) % numberOfMatches.value\n}\nfunction prevMatch() {\n  if (!checkMatchNumber())\n    return\n  indexOfMatch.value = (indexOfMatch.value - 1 + numberOfMatches.value) % numberOfMatches.value\n}\n\nfunction toggleShowReplace() {\n  showReplace.value = !showReplace.value\n}\n\nfunction toggleRegex() {\n  isRegex.value = !isRegex.value\n}\n\nfunction toggleCaseSensitive() {\n  isCaseSensitive.value = !isCaseSensitive.value\n}\n\nfunction toggleFindInSelection() {\n  if (!findInSelection.value) {\n    // 启用时，保存当前选区\n    const selection = props.editorView.state.selection.main\n    if (!selection.empty) {\n      selectionRange.value = { from: selection.from, to: selection.to }\n    }\n    else {\n      // 如果没有选区，使用整个文档\n      selectionRange.value = { from: 0, to: props.editorView.state.doc.length }\n    }\n  }\n  else {\n    // 禁用时，清除选区\n    selectionRange.value = null\n  }\n  findInSelection.value = !findInSelection.value\n}\n\nfunction closeSearchTab() {\n  showSearchTab.value = false\n}\n\nfunction handleSearchInputKeyDown(e: KeyboardEvent) {\n  switch (e.key) {\n    case `Enter`:\n      nextMatch()\n      e.preventDefault()\n  }\n}\n\nfunction handleReplaceInputKeyDown(e: KeyboardEvent) {\n  switch (e.key) {\n    case `Enter`:\n      handleReplace()\n      e.preventDefault()\n  }\n}\n\nfunction handleReplace() {\n  if (!checkMatchNumber())\n    return\n  if (!currentMatchPosition.value)\n    return\n\n  const from = currentMatchPosition.value[0]\n  const to = currentMatchPosition.value[1]\n  const fromLine = props.editorView.state.doc.line(from.line + 1)\n  const toLine = props.editorView.state.doc.line(to.line + 1)\n  const fromPos = fromLine.from + from.ch\n  const toPos = toLine.from + to.ch\n\n  let insertText = replaceWord.value\n  if (isRegex.value) {\n    try {\n      const matchedText = props.editorView.state.sliceDoc(fromPos, toPos)\n      insertText = matchedText.replace(new RegExp(searchWord.value, `gm`), replaceWord.value)\n    }\n    catch (e) {\n      console.warn(`Invalid Regex Replacement`, e)\n    }\n  }\n\n  props.editorView.dispatch({\n    changes: { from: fromPos, to: toPos, insert: insertText },\n    selection: { anchor: fromPos + insertText.length },\n  })\n  findAllMatches()\n}\n\nfunction handleReplaceAll() {\n  if (!checkMatchNumber())\n    return\n  if (!currentMatchPosition.value)\n    return\n\n  // 从后往前替换，避免位置偏移\n  const sortedPositions = [...matchPositions.value].sort((a, b) => {\n    if (a[0].line !== b[0].line) {\n      return b[0].line - a[0].line\n    }\n    return b[0].ch - a[0].ch\n  })\n\n  const changes = sortedPositions.map((pos: any) => {\n    const from = pos[0]\n    const to = pos[1]\n    const fromLine = props.editorView.state.doc.line(from.line + 1)\n    const toLine = props.editorView.state.doc.line(to.line + 1)\n    const fromPos = fromLine.from + from.ch\n    const toPos = toLine.from + to.ch\n\n    let insertText = replaceWord.value\n    if (isRegex.value) {\n      try {\n        const matchedText = props.editorView.state.sliceDoc(fromPos, toPos)\n        insertText = matchedText.replace(new RegExp(searchWord.value, `gm`), replaceWord.value)\n      }\n      catch (e) {\n        console.warn(`Invalid Regex Replacement`, e)\n      }\n    }\n\n    return { from: fromPos, to: toPos, insert: insertText }\n  })\n\n  props.editorView.dispatch({ changes })\n  findAllMatches()\n}\n\n// function handleEditorChange() {\n//   const debouncedSearch = useDebounceFn(findAllMatches, 300)\n//   debouncedSearch()\n// }\n\nfunction setSearchWord(word: string) {\n  searchWord.value = word\n  if (!showSearchTab.value) {\n    showSearchTab.value = true\n  }\n  else {\n    setTimeout(() => {\n      searchInputRef.value?.focus()\n      searchInputRef.value?.select()\n    }, 0)\n  }\n}\n\n/**\n * 打开搜索面板并展开替换功能\n */\nfunction setSearchWithReplace(word: string) {\n  searchWord.value = word\n  showReplace.value = true\n  if (!showSearchTab.value) {\n    showSearchTab.value = true\n  }\n  else {\n    setTimeout(() => {\n      searchInputRef.value?.focus()\n      searchInputRef.value?.select()\n    }, 0)\n  }\n}\n\nonUnmounted(() => {\n  // 清理搜索高亮\n  clearAllMarks()\n})\n\n/**\n * 检查是否有匹配项\n * 返回 false 表示没有匹配项\n * 返回 true 表示有匹配项\n */\nfunction checkMatchNumber(): boolean {\n  return numberOfMatches.value > 0\n}\n\ndefineExpose({\n  showSearchTab,\n  searchWord,\n  setSearchWord,\n  setSearchWithReplace,\n  showReplace,\n})\n</script>\n\n<template>\n  <Transition name=\"slide-down\">\n    <div\n      v-if=\"showSearchTab\"\n      class=\"bg-background absolute right-0 top-0 z-50 min-w-[300px] w-fit flex gap-1 border rounded-lg px-2 py-1 shadow-md transition-all\"\n      :class=\"showReplace ? 'items-start' : 'items-center'\"\n    >\n      <!-- 折叠/展开按钮 -->\n      <Button\n        variant=\"ghost\"\n        title=\"切换替换\"\n        aria-label=\"切换替换\"\n        class=\"h-7 w-5 flex items-center justify-center p-0\"\n        @click=\"toggleShowReplace\"\n      >\n        <component :is=\"showReplace ? ChevronDown : ChevronRight\" class=\"h-3.5 w-3.5\" />\n      </Button>\n\n      <!-- 查找 / 替换主体 -->\n      <div class=\"flex flex-col gap-0.5\">\n        <!-- 查找行 -->\n        <div class=\"flex items-center gap-1\">\n          <Input\n            ref=\"searchInputRef\"\n            v-model=\"searchWord\"\n            placeholder=\"查找\"\n            class=\"h-7 w-40 text-sm\"\n            @keydown=\"handleSearchInputKeyDown\"\n          />\n          <Button\n            variant=\"ghost\"\n            size=\"xs\"\n            title=\"区分大小写\"\n            aria-label=\"区分大小写\"\n            class=\"h-6 w-6 p-0\"\n            :class=\"{ 'bg-accent': isCaseSensitive }\"\n            @click=\"toggleCaseSensitive\"\n          >\n            <CaseSensitive class=\"h-3 w-3\" />\n          </Button>\n          <Button\n            variant=\"ghost\"\n            size=\"xs\"\n            title=\"正则表达式\"\n            aria-label=\"正则表达式\"\n            class=\"h-6 w-6 p-0\"\n            :class=\"{ 'bg-accent': isRegex }\"\n            @click=\"toggleRegex\"\n          >\n            <Regex class=\"h-3 w-3\" />\n          </Button>\n          <Button\n            variant=\"ghost\"\n            size=\"xs\"\n            title=\"在选区内查找\"\n            aria-label=\"在选区内查找\"\n            class=\"h-6 w-6 p-0\"\n            :class=\"{ 'bg-accent': findInSelection }\"\n            @click=\"toggleFindInSelection\"\n          >\n            <WholeWord class=\"h-3 w-3\" />\n          </Button>\n          <span class=\"w-10 select-none text-center text-xs\">\n            {{ numberOfMatches ? indexOfMatch + 1 : 0 }}/{{ numberOfMatches }}\n          </span>\n          <Button\n            variant=\"ghost\"\n            size=\"xs\"\n            title=\"上一处\"\n            aria-label=\"上一处\"\n            class=\"h-6 w-6 p-0\"\n            @click=\"prevMatch\"\n          >\n            <ChevronUp class=\"h-3 w-3\" />\n          </Button>\n          <Button\n            variant=\"ghost\"\n            size=\"xs\"\n            title=\"下一处\"\n            aria-label=\"下一处\"\n            class=\"h-6 w-6 p-0\"\n            @click=\"nextMatch\"\n          >\n            <ChevronDown class=\"h-3 w-3\" />\n          </Button>\n          <Button\n            variant=\"ghost\"\n            size=\"xs\"\n            title=\"关闭\"\n            aria-label=\"关闭\"\n            class=\"h-6 w-6 p-0\"\n            @click=\"closeSearchTab\"\n          >\n            <X class=\"h-3 w-3\" />\n          </Button>\n        </div>\n\n        <!-- 替换行（可折叠） -->\n        <div v-if=\"showReplace\" class=\"flex items-center gap-1\">\n          <Input\n            v-model=\"replaceWord\"\n            placeholder=\"替换\"\n            class=\"h-7 w-40 text-sm\"\n            @keydown=\"handleReplaceInputKeyDown\"\n          />\n          <Button\n            variant=\"ghost\"\n            size=\"xs\"\n            title=\"替换\"\n            aria-label=\"替换\"\n            class=\"h-6 w-6 p-0\"\n            @click=\"handleReplace\"\n          >\n            <Replace class=\"h-3 w-3\" />\n          </Button>\n          <Button\n            variant=\"ghost\"\n            size=\"xs\"\n            title=\"全部替换\"\n            aria-label=\"全部替换\"\n            class=\"h-6 w-6 p-0\"\n            @click=\"handleReplaceAll\"\n          >\n            <ReplaceAll class=\"h-3 w-3\" />\n          </Button>\n        </div>\n      </div>\n    </div>\n  </Transition>\n</template>\n\n<style scoped lang=\"less\">\n.slide-down-enter-active,\n.slide-down-leave-active {\n  transition: transform 0.2s ease, opacity 0.2s ease;\n}\n.slide-down-enter-from,\n.slide-down-leave-to {\n  transform: translateY(-100%);\n  opacity: 0;\n}\n</style>\n\n<style lang=\"less\">\n/* 搜索匹配项高亮样式（全局，不使用 scoped） */\n.cm-searchMatch {\n  background-color: rgba(255, 237, 100, 0.4);\n  border-radius: 2px;\n  box-shadow: 0 0 0 1px rgba(255, 193, 7, 0.3);\n}\n\n.cm-searchMatch-selected {\n  background-color: rgba(255, 152, 0, 0.6);\n  border-radius: 2px;\n  box-shadow: 0 0 0 2px rgba(255, 152, 0, 0.8);\n  font-weight: 500;\n}\n\n/* 暗色主题适配 */\n.dark .cm-searchMatch {\n  background-color: rgba(255, 235, 59, 0.3);\n  box-shadow: 0 0 0 1px rgba(255, 235, 59, 0.4);\n}\n\n.dark .cm-searchMatch-selected {\n  background-color: rgba(255, 152, 0, 0.5);\n  box-shadow: 0 0 0 2px rgba(255, 152, 0, 0.7);\n}\n</style>\n"
  },
  {
    "path": "apps/web/src/components/ui/search-tab/index.ts",
    "content": "export { default as SearchTab } from './SearchTab.vue'\n"
  },
  {
    "path": "apps/web/src/components/ui/select/Select.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectRootEmits, SelectRootProps } from 'radix-vue'\nimport { SelectRoot, useForwardPropsEmits } from 'radix-vue'\n\nconst props = defineProps<SelectRootProps>()\nconst emits = defineEmits<SelectRootEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <SelectRoot v-bind=\"forwarded\">\n    <slot />\n  </SelectRoot>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/select/SelectContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectContentEmits, SelectContentProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport {\n  SelectContent,\n\n  SelectPortal,\n  SelectViewport,\n  useForwardPropsEmits,\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\nimport { SelectScrollDownButton, SelectScrollUpButton } from '.'\n\ndefineOptions({\n  inheritAttrs: false,\n})\n\nconst props = withDefaults(\n  defineProps<SelectContentProps & { class?: HTMLAttributes[`class`] }>(),\n  {\n    position: `popper`,\n  },\n)\nconst emits = defineEmits<SelectContentEmits>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <SelectPortal>\n    <SelectContent\n      v-bind=\"{ ...forwarded, ...$attrs }\" :class=\"cn(\n        'relative z-200 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n        position === 'popper'\n          && 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',\n        props.class,\n      )\n      \"\n    >\n      <SelectScrollUpButton />\n      <SelectViewport :class=\"cn('p-1', position === 'popper' && 'h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)')\">\n        <slot />\n      </SelectViewport>\n      <SelectScrollDownButton />\n    </SelectContent>\n  </SelectPortal>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/select/SelectGroup.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectGroupProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { SelectGroup } from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<SelectGroupProps & { class?: HTMLAttributes[`class`] }>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n</script>\n\n<template>\n  <SelectGroup :class=\"cn('p-1 w-full', props.class)\" v-bind=\"delegatedProps\">\n    <slot />\n  </SelectGroup>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/select/SelectItem.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectItemProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { Check } from 'lucide-vue-next'\nimport {\n  SelectItem,\n  SelectItemIndicator,\n\n  SelectItemText,\n  useForwardProps,\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<SelectItemProps & { class?: HTMLAttributes[`class`] }>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <SelectItem\n    v-bind=\"forwardedProps\"\n    :class=\"\n      cn(\n        'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',\n        props.class,\n      )\n    \"\n  >\n    <span class=\"absolute left-2 h-3.5 w-3.5 flex items-center justify-center\">\n      <SelectItemIndicator>\n        <Check class=\"h-4 w-4\" />\n      </SelectItemIndicator>\n    </span>\n\n    <SelectItemText>\n      <slot />\n    </SelectItemText>\n  </SelectItem>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/select/SelectItemText.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectItemTextProps } from 'radix-vue'\nimport { SelectItemText } from 'radix-vue'\n\nconst props = defineProps<SelectItemTextProps>()\n</script>\n\n<template>\n  <SelectItemText v-bind=\"props\">\n    <slot />\n  </SelectItemText>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/select/SelectLabel.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectLabelProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { SelectLabel } from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<SelectLabelProps & { class?: HTMLAttributes[`class`] }>()\n</script>\n\n<template>\n  <SelectLabel :class=\"cn('py-1.5 pl-8 pr-2 text-sm font-semibold', props.class)\">\n    <slot />\n  </SelectLabel>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/select/SelectScrollDownButton.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectScrollDownButtonProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { ChevronDown } from 'lucide-vue-next'\nimport { SelectScrollDownButton, useForwardProps } from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes[`class`] }>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <SelectScrollDownButton v-bind=\"forwardedProps\" :class=\"cn('flex cursor-default items-center justify-center py-1', props.class)\">\n    <slot>\n      <ChevronDown class=\"h-4 w-4\" />\n    </slot>\n  </SelectScrollDownButton>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/select/SelectScrollUpButton.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectScrollUpButtonProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { ChevronUp } from 'lucide-vue-next'\nimport { SelectScrollUpButton, useForwardProps } from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes[`class`] }>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <SelectScrollUpButton v-bind=\"forwardedProps\" :class=\"cn('flex cursor-default items-center justify-center py-1', props.class)\">\n    <slot>\n      <ChevronUp class=\"h-4 w-4\" />\n    </slot>\n  </SelectScrollUpButton>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/select/SelectSeparator.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectSeparatorProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { SelectSeparator } from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes[`class`] }>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n</script>\n\n<template>\n  <SelectSeparator v-bind=\"delegatedProps\" :class=\"cn('-mx-1 my-1 h-px bg-muted', props.class)\" />\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/select/SelectTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectTriggerProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { ChevronDown } from 'lucide-vue-next'\nimport { SelectIcon, SelectTrigger, useForwardProps } from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<SelectTriggerProps & { class?: HTMLAttributes[`class`] }>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <SelectTrigger\n    v-bind=\"forwardedProps\"\n    :class=\"cn(\n      'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start',\n      props.class,\n    )\"\n  >\n    <slot />\n    <SelectIcon as-child>\n      <ChevronDown class=\"h-4 w-4 shrink-0 opacity-50\" />\n    </SelectIcon>\n  </SelectTrigger>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/select/SelectValue.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectValueProps } from 'radix-vue'\nimport { SelectValue } from 'radix-vue'\n\nconst props = defineProps<SelectValueProps>()\n</script>\n\n<template>\n  <SelectValue v-bind=\"props\">\n    <slot />\n  </SelectValue>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/select/index.ts",
    "content": "export { default as Select } from './Select.vue'\nexport { default as SelectContent } from './SelectContent.vue'\nexport { default as SelectGroup } from './SelectGroup.vue'\nexport { default as SelectItem } from './SelectItem.vue'\nexport { default as SelectItemText } from './SelectItemText.vue'\nexport { default as SelectLabel } from './SelectLabel.vue'\nexport { default as SelectScrollDownButton } from './SelectScrollDownButton.vue'\nexport { default as SelectScrollUpButton } from './SelectScrollUpButton.vue'\nexport { default as SelectSeparator } from './SelectSeparator.vue'\nexport { default as SelectTrigger } from './SelectTrigger.vue'\nexport { default as SelectValue } from './SelectValue.vue'\n"
  },
  {
    "path": "apps/web/src/components/ui/separator/Separator.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SeparatorProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { Separator } from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<\n  SeparatorProps & { class?: HTMLAttributes[`class`], label?: string }\n>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n</script>\n\n<template>\n  <Separator\n    v-bind=\"delegatedProps\"\n    :class=\"\n      cn(\n        'shrink-0 bg-border relative',\n        props.orientation === 'vertical' ? 'w-px h-full' : 'h-px w-full',\n        props.class,\n      )\n    \"\n  >\n    <span\n      v-if=\"props.label\"\n      :class=\"cn('text-xs text-muted-foreground bg-background absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex justify-center items-center',\n                 props.orientation === 'vertical' ? 'w-px px-1 py-2' : 'h-px py-1 px-2',\n      )\"\n    >{{ props.label }}</span>\n  </Separator>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/separator/index.ts",
    "content": "export { default as Separator } from './Separator.vue'\n"
  },
  {
    "path": "apps/web/src/components/ui/sonner/Sonner.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { ToasterProps } from 'vue-sonner'\nimport { Toaster as Sonner } from 'vue-sonner'\n\nconst props = defineProps<ToasterProps>()\n</script>\n\n<template>\n  <Sonner\n    class=\"toaster group\"\n    v-bind=\"props\"\n  />\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/sonner/index.ts",
    "content": "export { default as Toaster } from './Sonner.vue'\n"
  },
  {
    "path": "apps/web/src/components/ui/switch/Switch.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SwitchRootEmits, SwitchRootProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport {\n  SwitchRoot,\n\n  SwitchThumb,\n  useForwardPropsEmits,\n} from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<SwitchRootProps & { class?: HTMLAttributes[`class`] }>()\n\nconst emits = defineEmits<SwitchRootEmits>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <SwitchRoot\n    v-bind=\"forwarded\"\n    :class=\"cn(\n      'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',\n      props.class,\n    )\"\n  >\n    <SwitchThumb\n      :class=\"cn('pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5')\"\n    >\n      <slot name=\"thumb\" />\n    </SwitchThumb>\n  </SwitchRoot>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/switch/index.ts",
    "content": "export { default as Switch } from './Switch.vue'\n"
  },
  {
    "path": "apps/web/src/components/ui/tabs/Tabs.vue",
    "content": "<script setup lang=\"ts\">\nimport type { TabsRootEmits, TabsRootProps } from 'radix-vue'\nimport { TabsRoot, useForwardPropsEmits } from 'radix-vue'\n\nconst props = defineProps<TabsRootProps>()\nconst emits = defineEmits<TabsRootEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <TabsRoot v-bind=\"forwarded\">\n    <slot />\n  </TabsRoot>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/tabs/TabsContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { TabsContentProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { TabsContent } from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<TabsContentProps & { class?: HTMLAttributes[`class`] }>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n</script>\n\n<template>\n  <TabsContent\n    :class=\"cn('mt-2 ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', props.class)\"\n    v-bind=\"delegatedProps\"\n  >\n    <slot />\n  </TabsContent>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/tabs/TabsList.vue",
    "content": "<script setup lang=\"ts\">\nimport type { TabsListProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { TabsList } from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<TabsListProps & { class?: HTMLAttributes[`class`] }>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n</script>\n\n<template>\n  <TabsList\n    v-bind=\"delegatedProps\"\n    :class=\"cn(\n      'inline-flex items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',\n      props.class,\n    )\"\n  >\n    <slot />\n  </TabsList>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/tabs/TabsTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { TabsTriggerProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { TabsTrigger, useForwardProps } from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<TabsTriggerProps & { class?: HTMLAttributes[`class`] }>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwardedProps = useForwardProps(delegatedProps)\n</script>\n\n<template>\n  <TabsTrigger\n    v-bind=\"forwardedProps\"\n    :class=\"cn(\n      'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-xs',\n      props.class,\n    )\"\n  >\n    <span class=\"truncate\">\n      <slot />\n    </span>\n  </TabsTrigger>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/tabs/index.ts",
    "content": "export { default as Tabs } from './Tabs.vue'\nexport { default as TabsContent } from './TabsContent.vue'\nexport { default as TabsList } from './TabsList.vue'\nexport { default as TabsTrigger } from './TabsTrigger.vue'\n"
  },
  {
    "path": "apps/web/src/components/ui/textarea/Textarea.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { useVModel } from '@vueuse/core'\nimport { cn } from '@/lib/utils'\n\nconst props = defineProps<{\n  class?: HTMLAttributes[`class`]\n  defaultValue?: string | number\n  modelValue?: string | number\n}>()\n\nconst emits = defineEmits<{\n  (e: `update:modelValue`, payload: string | number): void\n}>()\n\nconst modelValue = useVModel(props, `modelValue`, emits, {\n  passive: true,\n  defaultValue: props.defaultValue,\n})\n</script>\n\n<template>\n  <textarea v-model=\"modelValue\" :class=\"cn('flex min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', props.class)\" />\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/textarea/index.ts",
    "content": "export { default as Textarea } from './Textarea.vue'\n"
  },
  {
    "path": "apps/web/src/components/ui/tooltip/Tooltip.vue",
    "content": "<script setup lang=\"ts\">\nimport type { TooltipRootEmits, TooltipRootProps } from 'radix-vue'\nimport { TooltipRoot, useForwardPropsEmits } from 'radix-vue'\n\nconst props = defineProps<TooltipRootProps>()\nconst emits = defineEmits<TooltipRootEmits>()\n\nconst forwarded = useForwardPropsEmits(props, emits)\n</script>\n\n<template>\n  <TooltipRoot v-bind=\"forwarded\">\n    <slot />\n  </TooltipRoot>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/tooltip/TooltipContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { TooltipContentEmits, TooltipContentProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { TooltipContent, TooltipPortal, useForwardPropsEmits } from 'radix-vue'\nimport { cn } from '@/lib/utils'\n\ndefineOptions({\n  inheritAttrs: false,\n})\n\nconst props = withDefaults(defineProps<TooltipContentProps & { class?: HTMLAttributes[`class`] }>(), {\n  sideOffset: 4,\n})\n\nconst emits = defineEmits<TooltipContentEmits>()\n\nconst delegatedProps = computed(() => {\n  const { class: _, ...delegated } = props\n\n  return delegated\n})\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits)\n</script>\n\n<template>\n  <TooltipPortal>\n    <TooltipContent v-bind=\"{ ...forwarded, ...$attrs }\" :class=\"cn('z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', props.class)\">\n      <slot />\n    </TooltipContent>\n  </TooltipPortal>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/tooltip/TooltipProvider.vue",
    "content": "<script setup lang=\"ts\">\nimport type { TooltipProviderProps } from 'radix-vue'\nimport { TooltipProvider } from 'radix-vue'\n\nconst props = defineProps<TooltipProviderProps>()\n</script>\n\n<template>\n  <TooltipProvider v-bind=\"props\">\n    <slot />\n  </TooltipProvider>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/tooltip/TooltipTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { TooltipTriggerProps } from 'radix-vue'\nimport { TooltipTrigger } from 'radix-vue'\n\nconst props = defineProps<TooltipTriggerProps>()\n</script>\n\n<template>\n  <TooltipTrigger v-bind=\"props\">\n    <slot />\n  </TooltipTrigger>\n</template>\n"
  },
  {
    "path": "apps/web/src/components/ui/tooltip/index.ts",
    "content": "export { default as Tooltip } from './Tooltip.vue'\nexport { default as TooltipContent } from './TooltipContent.vue'\nexport { default as TooltipProvider } from './TooltipProvider.vue'\nexport { default as TooltipTrigger } from './TooltipTrigger.vue'\n"
  },
  {
    "path": "apps/web/src/composables/index.ts",
    "content": ""
  },
  {
    "path": "apps/web/src/composables/useEditorFormat.ts",
    "content": "import type { EditorView } from '@codemirror/view'\nimport { ctrlKey } from '@md/shared/configs'\nimport {\n  applyHeading,\n  formatBold,\n  formatCode,\n  formatItalic,\n  formatLink,\n  formatOrderedList,\n  formatStrikethrough,\n  formatUnorderedList,\n} from '@md/shared/editor'\nimport { unref } from 'vue'\n\n/**\n * 编辑器格式化操作 composable\n * 使用泛型和 unref 来支持任意类型的 ref（包括 readonly ref）\n */\nexport function useEditorFormat<T extends { value: any }>(editor: T) {\n  function addFormat(cmd: string) {\n    const editorView = unref(editor) as EditorView\n    if (!unref(editor))\n      return\n\n    switch (cmd) {\n      case `${ctrlKey}-B`:\n        formatBold(editorView)\n        break\n      case `${ctrlKey}-I`:\n        formatItalic(editorView)\n        break\n      case `${ctrlKey}-D`:\n        formatStrikethrough(editorView)\n        break\n      case `${ctrlKey}-K`:\n        formatLink(editorView)\n        break\n      case `${ctrlKey}-E`:\n        formatCode(editorView)\n        break\n      case `${ctrlKey}-1`:\n        applyHeading(editorView, 1)\n        break\n      case `${ctrlKey}-2`:\n        applyHeading(editorView, 2)\n        break\n      case `${ctrlKey}-3`:\n        applyHeading(editorView, 3)\n        break\n      case `${ctrlKey}-4`:\n        applyHeading(editorView, 4)\n        break\n      case `${ctrlKey}-5`:\n        applyHeading(editorView, 5)\n        break\n      case `${ctrlKey}-6`:\n        applyHeading(editorView, 6)\n        break\n      case `${ctrlKey}-U`:\n        formatUnorderedList(editorView)\n        break\n      case `${ctrlKey}-O`:\n        formatOrderedList(editorView)\n        break\n    }\n  }\n\n  return {\n    addFormat,\n  }\n}\n"
  },
  {
    "path": "apps/web/src/composables/useFolderFileSync.ts",
    "content": "import { useFolderSourceStore } from '@/stores/folderSource'\nimport { usePostStore } from '@/stores/post'\n\n/**\n * 文件夹文件同步 Composable\n * 监听编辑器内容变化，实时同步到本地文件夹\n */\nexport function useFolderFileSync() {\n  const postStore = usePostStore()\n  const folderStore = useFolderSourceStore()\n\n  // 当前打开的文件路径\n  const currentFilePath = ref<string | null>(null)\n\n  // 防抖定时器\n  let syncTimeoutId: ReturnType<typeof setTimeout> | null = null\n\n  // 同步延迟（毫秒）\n  const SYNC_DELAY = 1000\n\n  /**\n   * 设置当前文件路径\n   */\n  function setCurrentFilePath(filePath: string | null) {\n    currentFilePath.value = filePath\n  }\n\n  /**\n   * 执行同步\n   */\n  async function performSync(filePath: string, content: string) {\n    if (!filePath) {\n      return\n    }\n\n    try {\n      await folderStore.writeFile(filePath, content)\n    }\n    catch (error: any) {\n      console.error('文件同步失败:', error)\n    }\n  }\n\n  /**\n   * 防抖同步\n   */\n  function debouncedSync() {\n    if (!currentFilePath.value || !postStore.currentPost) {\n      return\n    }\n\n    // 清除之前的定时器\n    if (syncTimeoutId) {\n      clearTimeout(syncTimeoutId)\n    }\n\n    // 设置新的定时器\n    syncTimeoutId = setTimeout(() => {\n      const content = postStore.currentPost?.content || ''\n      performSync(currentFilePath.value!, content)\n    }, SYNC_DELAY)\n  }\n\n  /**\n   * 监听当前文章内容变化\n   */\n  watch(\n    () => postStore.currentPost?.content,\n    () => {\n      debouncedSync()\n    },\n    { deep: false },\n  )\n\n  /**\n   * 当切换文件时，同步旧文件\n   */\n  watch(\n    () => currentFilePath.value,\n    (newPath, oldPath) => {\n      // 如果从一个文件切换到另一个文件，先同步旧文件\n      if (oldPath && newPath !== oldPath && syncTimeoutId) {\n        clearTimeout(syncTimeoutId)\n        syncTimeoutId = null\n      }\n    },\n  )\n\n  /**\n   * 清理\n   */\n  onBeforeUnmount(() => {\n    if (syncTimeoutId) {\n      clearTimeout(syncTimeoutId)\n    }\n  })\n\n  return {\n    currentFilePath,\n    setCurrentFilePath,\n  }\n}\n"
  },
  {
    "path": "apps/web/src/composables/useImageUploader.ts",
    "content": "import SparkMD5 from 'spark-md5'\nimport { ref } from 'vue'\nimport { toBase64 } from '@/utils'\nimport { fileUpload } from '@/utils/file'\n\nconst STORAGE_KEY = 'uploaded_image_map'\n\nexport function useImageUploader() {\n  const isUploading = ref(false)\n  const error = ref<string | null>(null)\n\n  // 获取本地缓存\n  const getStorageMap = (): Record<string, string> => {\n    try {\n      const str = localStorage.getItem(STORAGE_KEY)\n      return str ? JSON.parse(str) : {}\n    }\n    catch {\n      return {}\n    }\n  }\n\n  // 更新本地缓存\n  const updateStorageMap = (hash: string, url: string) => {\n    const map = getStorageMap()\n    map[hash] = url\n    localStorage.setItem(STORAGE_KEY, JSON.stringify(map))\n  }\n\n  // 计算 Blob/File 的 MD5\n  const calculateHash = (file: Blob): Promise<string> => {\n    return new Promise((resolve, reject) => {\n      const fileReader = new FileReader()\n      const spark = new SparkMD5.ArrayBuffer()\n\n      fileReader.onload = (e) => {\n        if (e.target?.result) {\n          spark.append(e.target.result as ArrayBuffer)\n          resolve(spark.end())\n        }\n        else {\n          reject(new Error('文件读取失败'))\n        }\n      }\n      fileReader.onerror = () => reject(new Error('文件读取错误'))\n      fileReader.readAsArrayBuffer(file)\n    })\n  }\n\n  // URL 转 File (需注意 CORS)\n  const urlToFile = async (url: string): Promise<File> => {\n    // 提取文件名\n    const getFilename = (u: string) => u.split('/').pop()?.split('?')[0] || `image-${Date.now()}.png`\n    const filename = getFilename(url)\n\n    // 内部函数：尝试获取 Blob\n    const fetchBlob = async (targetUrl: string, options?: RequestInit) => {\n      const res = await fetch(targetUrl, options)\n      if (!res.ok)\n        throw new Error(`Status: ${res.status}`)\n      return await res.blob()\n    }\n\n    try {\n      // 1. 尝试直接请求 (设置 no-referrer 以尝试绕过部分防盗链)\n      const blob = await fetchBlob(url, { referrerPolicy: 'no-referrer' })\n      return new File([blob], filename, { type: blob.type })\n    }\n    catch (directErr) {\n      console.warn(`Direct fetch failed for ${url}, trying proxy...`, directErr)\n\n      // 2. 失败后尝试通过 wsrv.nl 代理请求\n      try {\n        const proxyUrl = `https://wsrv.nl/?url=${encodeURIComponent(url)}`\n        const blob = await fetchBlob(proxyUrl)\n        return new File([blob], filename, { type: blob.type })\n      }\n      catch (proxyErr: any) {\n        // 3. 代理也失败，抛出异常\n        console.error(`Proxy fetch failed for ${url}`, proxyErr)\n        const isCors = proxyErr.message.includes('Failed to fetch') || proxyErr.name === 'TypeError'\n        const msg = isCors\n          ? '跨域请求失败：目标图片禁止了跨域访问，且代理服务也无法获取。'\n          : `图片下载失败: ${proxyErr.message}`\n        throw new Error(msg)\n      }\n    }\n  }\n\n  // 核心上传方法\n  const upload = async (resource: string | File): Promise<string> => {\n    isUploading.value = true\n    error.value = null\n\n    try {\n      let file: File\n      if (typeof resource === 'string') {\n        file = await urlToFile(resource)\n      }\n      else {\n        file = resource\n      }\n\n      // 1. 计算 Hash\n      const hash = await calculateHash(file)\n      console.log('File Hash:', hash)\n\n      // 2. 检查缓存\n      const cache = getStorageMap()\n      if (cache[hash]) {\n        console.log('⚡️ 命中缓存，跳过上传')\n        return cache[hash]\n      }\n\n      // 3. 准备上传：转换 Base64 (fileUpload 需要)\n      const base64Content = await toBase64(file)\n\n      // 4. 调用项目现有 API 上传\n      console.log('🚀 调用 fileUpload 上传...')\n      const url = await fileUpload(base64Content, file)\n\n      // 5. 写入缓存\n      if (url) {\n        updateStorageMap(hash, url)\n      }\n\n      return url\n    }\n    catch (err: any) {\n      console.error(err)\n      const msg = err.message || '上传失败'\n      error.value = msg\n      throw new Error(msg)\n    }\n    finally {\n      isUploading.value = false\n    }\n  }\n\n  return { upload, isUploading, error }\n}\n"
  },
  {
    "path": "apps/web/src/entrypoints/appmsg.content.ts",
    "content": "import { defineContentScript, injectScript } from '#imports'\n\nexport default defineContentScript({\n  matches: [`https://mp.weixin.qq.com/cgi-bin/appmsg*`],\n  async main() {\n    await injectScript(`/injected.js`, {\n      keepInDom: true,\n    })\n    browser.runtime.onMessage.addListener((message) => {\n      if (message.type === `copyToMp`) {\n        console.log(`Copying content to MP editor:`, message.content)\n        const customEventData = { type: `copyToMp`, content: message.content }\n        window.postMessage(customEventData)\n        return Promise.resolve(true)\n      }\n      return true\n    })\n  },\n})\n"
  },
  {
    "path": "apps/web/src/entrypoints/background.ts",
    "content": "import { browser, defineBackground } from '#imports'\n\nexport default defineBackground({\n  type: `module`,\n  main() {\n    browser.runtime.onInstalled.addListener((detail) => {\n      if (import.meta.env.COMMAND === `serve`) {\n        browser.runtime.openOptionsPage()\n        return\n      }\n      if (detail.reason === `install`) {\n        browser.tabs.create({ url: `https://md-pages.doocs.org/welcome` })\n      }\n      else if (detail.reason === `update`) {\n        browser.runtime.openOptionsPage()\n      }\n    })\n\n    browser.runtime.onInstalled.addListener(() => {\n      if (typeof browser.sidePanel === `undefined`)\n        return\n      browser.contextMenus.create({\n        id: `openSidePanel`,\n        title: `MD 公众号编辑器`,\n        documentUrlPatterns: [`https://mp.weixin.qq.com/cgi-bin/appmsg*`],\n        contexts: [`all`],\n      })\n    })\n\n    browser.contextMenus.onClicked.addListener((info, tab) => {\n      if (info.menuItemId === `openSidePanel`) {\n        browser.sidePanel.open({ tabId: tab!.id! })\n      }\n    })\n  },\n})\n"
  },
  {
    "path": "apps/web/src/entrypoints/injected.ts",
    "content": "export default defineUnlistedScript(() => {\n  window.addEventListener(`message`, (event) => {\n    console.log(`收到 copyToMp 事件`, event)\n    if (event.data.type !== `copyToMp`)\n      return\n    window.__MP_Editor_JSAPI__.invoke({\n      apiName: `mp_editor_set_content`,\n      apiParam: {\n        content: event.data.content,\n      },\n      sucCb: (res) => { console.log(`设置成功`, res) },\n      errCb: (err) => { console.log(`设置失败`, err) },\n    })\n  })\n})\n"
  },
  {
    "path": "apps/web/src/entrypoints/popup/App.vue",
    "content": "<script setup lang=\"ts\">\nimport { browser } from 'wxt/browser'\nimport logo from '/mpmd/logo.svg'\n\nfunction onOpenOption() {\n  browser.runtime.openOptionsPage()\n}\n</script>\n\n<template>\n  <div class=\"container popup-body\">\n    <div\n      class=\"title\"\n      style=\"height: 40px; display: inline-flex; padding-left: 60px;\"\n    >\n      <img style=\"height: 40px\" :src=\"logo\">\n      <span\n        style=\"\n          font-size: 16px;\n          line-height: 40px;\n          font-weight: bold;\n          margin-left: 8px;\n        \"\n      >使用必读</span>\n    </div>\n    <section style=\"margin-top: 12px; line-height: 28px\">\n      <div>如果您希望使用微信公众号素材库作为图床功能，需要进行以下配置：</div>\n      <div>\n        1.开启公众号开发者模式\n        <span><a\n          href=\"https://developers.weixin.qq.com/doc/offiaccount/Getting_Started/Getting_Started_Guide.html\"\n          target=\"_blank\"\n        >查看文档</a></span>\n      </div>\n      <div>\n        2.配置IP白名单<span><a href=\"https://md-pages.doocs.org/tutorial\" target=\"_blank\">使用教程</a></span>\n      </div>\n      <div>\n        <button class=\"button\" @click=\"onOpenOption\">\n          开始使用\n        </button>\n      </div>\n    </section>\n  </div>\n</template>\n\n<style scoped lang=\"less\">\n.popup-body {\n  min-width: 300px;\n  scroll-behavior: auto;\n  margin-top: 20px;\n}\n.container {\n  width: 100%;\n  box-sizing: border-box;\n  padding-right: 15px;\n  padding-left: 15px;\n  padding-bottom: 15px;\n  margin-right: auto;\n  margin-left: auto;\n  font-size: 14px;\n}\n.button {\n  padding: 2px 6px;\n  background: #07c060;\n  color: #fff;\n  border-radius: 4px;\n}\nsection a {\n  text-decoration: underline;\n}\n</style>\n"
  },
  {
    "path": "apps/web/src/entrypoints/popup/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>公众号内容编辑器</title>\n    <meta name=\"manifest.type\" content=\"browser_action\" />\n  </head>\n  <body>\n    <div id=\"app\"></div>\n    <script type=\"module\" src=\"./popup.ts\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "apps/web/src/entrypoints/popup/popup.ts",
    "content": "import { createApp } from 'vue'\nimport App from './App.vue'\n\n/* 每个页面公共css */\nimport '@/assets/index.css'\nimport '@/assets/less/theme.less'\n\ncreateApp(App).mount(`#app`)\n"
  },
  {
    "path": "apps/web/src/lib/utils.ts",
    "content": "import type { ClassValue } from 'clsx'\nimport { clsx } from 'clsx'\nimport { twMerge } from 'tailwind-merge'\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"
  },
  {
    "path": "apps/web/src/main.ts",
    "content": "import { initializeMermaid } from '@md/core/utils'\nimport { createPinia } from 'pinia'\nimport { createApp } from 'vue'\nimport App from './App.vue'\n\nimport { setupComponents } from './utils/setup-components'\n\nimport 'vue-sonner/style.css'\n\n/* 每个页面公共css */\nimport '@/assets/index.css'\nimport '@/assets/less/theme.less'\n\n// 异步初始化 mermaid，避免初始化顺序问题\ninitializeMermaid().catch(console.error)\n\nsetupComponents()\n\nconst app = createApp(App)\n\napp.use(createPinia())\n\napp.mount(`#app`)\n"
  },
  {
    "path": "apps/web/src/modules/build-extension.ts",
    "content": "import type { OutputOptions } from 'rollup'\nimport type * as vite from 'vite'\nimport type * as wxt from 'wxt'\nimport { writeFile } from 'node:fs/promises'\nimport path from 'node:path'\nimport { parseHTML } from 'linkedom'\nimport { hash } from 'ohash'\nimport {\n  addViteConfig,\n  defineWxtModule,\n} from 'wxt/modules'\n\ntype AddedViteConfig = ReturnType<Parameters<typeof addViteConfig>[1]>\ntype AddedVitePlugins = NonNullable<NonNullable<AddedViteConfig>['plugins']>\n\nexport default defineWxtModule({\n  async setup(wxt) {\n    wxt.config.alias[`/src/main.ts`] = `./src/main.ts`\n    wxt.config.alias[`/src/sidepanel.ts`] = `./src/sidepanel.ts`\n    wxt.config.manifest.options_page = `options.html`\n    wxt.hook(`entrypoints:grouped`, (_, groups) => {\n      groups.push([{\n        type: `options`,\n        name: `options`,\n        options: { openInTab: true },\n        inputPath: path.resolve(wxt.config.root, `./index.html`),\n        outputDir: wxt.config.outDir,\n        skipped: false,\n      }])\n      groups.push([{\n        type: `sidepanel`,\n        name: `sidepanel`,\n        options: { openAtInstall: true, browserStyle: true },\n        inputPath: path.resolve(wxt.config.root, `./index.html`),\n        outputDir: wxt.config.outDir,\n        skipped: false,\n      }])\n    })\n    wxt.hook(`vite:build:extendConfig`, (_, config) => {\n      if (config.build?.rollupOptions?.input && config.build?.rollupOptions?.output) {\n        const input = config.build?.rollupOptions.input as Record<string, string>\n        const wxtOutput = config.build?.rollupOptions.output as OutputOptions\n        if (input.options || input.sidepanel) {\n          wxtOutput.manualChunks = (id) => {\n            if (id.includes(`node_modules`)) {\n              if (id.includes(`prettier`))\n                return `prettier`\n              if (id.includes(`katex`))\n                return `katex`\n              if (id.includes(`mermaid`) || id.includes(`highlight.js`))\n                return `mermaid-vendors`\n            }\n          }\n        }\n      }\n    })\n    addViteConfig(wxt, () => ({\n      plugins: toWxtPluginOptions([\n        htmlScriptToVirtual(wxt.config, () => wxt.server),\n        vueDevtoolsHack(wxt.config, () => wxt.server),\n        wxt.config.command === `build`\n          ? htmlScriptToLocal(wxt)\n          : undefined,\n      ]),\n    }))\n  },\n})\n\n// Stored outside the plugin to effect all instances of the htmlScriptToVirtual plugin.\nconst inlineScriptContents: Record<string, string> = {}\nconst SCRIPT_FILE_NAME_REGEX = /\\/([^/]+)\\.js$/\n\nfunction isDefined<T>(value: T | undefined): value is T {\n  return value !== undefined\n}\n\nfunction toWxtPluginOptions(\n  plugins: ReadonlyArray<vite.PluginOption | undefined>,\n): AddedVitePlugins {\n  return plugins.filter(isDefined) as unknown as AddedVitePlugins\n}\n\nexport function htmlScriptToVirtual(\n  config: wxt.ResolvedConfig,\n  getWxtDevServer: () => wxt.WxtDevServer | undefined,\n): vite.PluginOption {\n  const virtualInlineScript = `virtual:md-inline-script`\n  const resolvedVirtualInlineScript = `\\0${virtualInlineScript}`\n\n  const server = getWxtDevServer?.()\n  return [\n    {\n      name: `md:dev-html-prerender`,\n      apply: `build`,\n      transformIndexHtml: {\n        order: `post`,\n        async handler(html) {\n          if (server == null) {\n            return html\n          }\n          const { document } = parseHTML(html)\n          // Replace inline script with virtual module served via dev server.\n          // Extension CSP blocks inline scripts, so that's why we're pulling them out.\n          const promises: Promise<void>[] = []\n          const inlineScripts = document.querySelectorAll(`script[src^=http]`)\n          inlineScripts.forEach(async (script) => {\n            promises.push(new Promise<void>((resolve) => {\n              const url = script.getAttribute(`src`) ?? ``\n              if (url?.startsWith(`http://localhost`)) {\n                resolve()\n                return\n              }\n              doFetch(url).then((textContent) => {\n                const key = hash(textContent)\n                inlineScriptContents[key] = textContent\n                script.setAttribute(`src`, `${server.origin}/@id/${virtualInlineScript}?${key}`)\n                if (script.hasAttribute(`id`)) {\n                  script.setAttribute(`type`, `module`)\n                }\n                resolve()\n              })\n            }))\n          })\n          await Promise.all(promises)\n          const newHtml = document.toString()\n          config.logger.debug(`\\nhtmlScriptToVirtual Old HTML:\\n${html}`)\n          config.logger.debug(`\\nhtmlScriptToVirtual New HTML:\\n${newHtml}`)\n          return newHtml\n        },\n      },\n    },\n    {\n      name: `md:virtualize-react-refresh`,\n      apply: `serve`,\n      resolveId(id) {\n        // Resolve inline scripts\n        if (id.startsWith(virtualInlineScript)) {\n          return `\\0${id}`\n        }\n\n        // Ignore chunks during HTML file pre-rendering\n        if (id.startsWith(`/chunks/`)) {\n          return `\\0noop`\n        }\n      },\n      load(id) {\n        // Resolve virtualized inline scripts\n        if (id.startsWith(resolvedVirtualInlineScript)) {\n          // id=\"virtual:md-inline-script?<hash>\"\n          const key = id.substring(id.indexOf(`?`) + 1)\n          return inlineScriptContents[key]\n        }\n\n        // Ignore chunks during HTML file pre-rendering\n        if (id === `\\0noop`) {\n          return ``\n        }\n      },\n    },\n  ]\n}\n\nexport function htmlScriptToLocal(\n  wxt: wxt.Wxt,\n): vite.Plugin {\n  return {\n    name: `md:build-html-prerender`,\n    apply: `build`,\n    transformIndexHtml: {\n      order: `post`,\n      async handler(html) {\n        const { document } = parseHTML(html)\n        const promises: Promise<void>[] = []\n        const httpScripts = document.querySelectorAll(`script[src^=http]`)\n        if (httpScripts.length > 0) {\n          httpScripts.forEach(async (script) => {\n            /* eslint-disable no-async-promise-executor */\n            promises.push(new Promise<void>(async (resolve) => {\n              const url = script.getAttribute(`src`) ?? ``\n              if (url?.startsWith(`http://localhost`)) {\n                resolve()\n                return\n              }\n              const textContent = await doFetch(url)\n              const key = hash(textContent)\n              let jsName = url.match(SCRIPT_FILE_NAME_REGEX)?.[1] ?? `.js`\n              if (url.indexOf(`?`) > 0) {\n                jsName = `${url.substring(url.indexOf(`?`) + 1)}.js`\n              }\n              const fileName = `${jsName.split(`.`)[0]}-${key}.js`\n              // write to file\n              const outFile = path.resolve(wxt.config.outDir, `./${fileName}`)\n              await writeFile(outFile, textContent, `utf8`)\n              script.setAttribute(`src`, `/${fileName}`)\n              // script.setAttribute(`type`, `module`)\n              resolve()\n            }))\n          })\n        }\n\n        // Replace inline script with virtual module served via dev server.\n        // Extension CSP blocks inline scripts, so that's why we're pulling them\n        // out.\n        const inlineScripts = document.querySelectorAll(`script:not([src])`)\n        if (inlineScripts.length > 0) {\n          inlineScripts.forEach(async (script) => {\n            promises.push(new Promise<void>(async (resolve) => {\n              // Save the text content for later\n              const textContent = script.textContent ?? ``\n              const key = hash(textContent)\n              const fileName = `md-inline-${key}.js`\n              // write to file\n              const outFile = path.resolve(wxt.config.outDir, `./${fileName}`)\n              await writeFile(outFile, textContent, `utf8`)\n              // Replace unsafe inline script\n              const virtualScript = document.createElement(`script`)\n              // virtualScript.type = `module`\n              virtualScript.src = `/${fileName}`\n              script.replaceWith(virtualScript)\n              resolve()\n            }),\n            )\n          })\n        }\n        await Promise.all(promises)\n        const newHtml = document.toString()\n        wxt.config.logger.debug(`\\nhtmlScriptToLocal Old HTML:\\n${html}`)\n        wxt.config.logger.debug(`\\nhtmlScriptToLocal New HTML:\\n${newHtml}`)\n        return newHtml\n      },\n    },\n  }\n}\nexport function vueDevtoolsHack(\n  config: wxt.ResolvedConfig,\n  getWxtDevServer: () => wxt.WxtDevServer | undefined,\n): vite.Plugin {\n  const server = getWxtDevServer?.()\n  return {\n    name: `md:vue-devtools-hack`,\n    apply: `build`,\n    transformIndexHtml: {\n      order: `post`,\n      handler(html) {\n        const { document } = parseHTML(html)\n        const inlineScripts = document.querySelectorAll(`script[src^='/@id/virtual:']`)\n        inlineScripts.forEach((script) => {\n          const src = script.getAttribute(`src`)\n          const newSrc = `${server?.origin}${src}`\n          script.setAttribute(`src`, newSrc)\n        })\n        const newHtml = document.toString()\n        config.logger.debug(`Old HTML:\\n${html}`)\n        config.logger.debug(`New HTML:\\n${newHtml}`)\n\n        return newHtml\n      },\n    },\n  }\n}\n\nasync function doFetch(\n  url: string,\n): Promise<string> {\n  let content: string = ``\n  const res = await fetch(url)\n  if (res.status < 300) {\n    content = await res.text()\n  }\n  else {\n    throw new Error(\n      `Failed to fetch \"${url}\". `,\n    )\n  }\n  return content\n}\n"
  },
  {
    "path": "apps/web/src/sidepanel.ts",
    "content": "// types/chrome.d.ts\ndeclare namespace chrome {\n  namespace runtime {\n    const id: string | undefined\n  }\n  namespace tabs {\n    interface Tab {\n      id?: number\n      index: number\n      windowId: number\n    }\n    function query(queryOptions: { active: boolean, lastFocusedWindow: boolean }): Promise<[chrome.tabs.Tab]>\n    function sendMessage(tabId: number, message: any): void\n  }\n}\n\nconst isInExtension = typeof chrome !== `undefined` && chrome.runtime && chrome.runtime.id\n\nasync function getCurrentTab() {\n  const queryOptions = { active: true, lastFocusedWindow: true }\n  if (typeof browser !== `undefined` && browser.tabs && browser.tabs.query) {\n    const [tab] = await browser.tabs.query(queryOptions)\n    return tab\n  }\n  const [tab] = await chrome.tabs.query(queryOptions)\n  return tab\n}\nwindow.addEventListener(`copyToMp`, (e) => {\n  const customEvent = e as CustomEvent\n  if (!isInExtension)\n    return\n  getCurrentTab().then((tab) => {\n    chrome.tabs.sendMessage(tab.id!, {\n      type: `copyToMp`,\n      content: customEvent.detail.content,\n    })\n  })\n})\n"
  },
  {
    "path": "apps/web/src/stores/aiConfig.ts",
    "content": "import { serviceOptions } from '@md/shared/configs'\nimport {\n  DEFAULT_SERVICE_KEY,\n  DEFAULT_SERVICE_MAX_TOKEN,\n  DEFAULT_SERVICE_TEMPERATURE,\n  DEFAULT_SERVICE_TYPE,\n} from '@md/shared/constants'\nimport { store } from '@/utils/storage'\n\n/**\n * AI 配置 Store\n * 负责管理 AI 服务的配置，包括服务类型、模型、温度等参数\n */\nexport const useAIConfigStore = defineStore(`AIConfig`, () => {\n  // ==================== 全局配置 ====================\n\n  // 服务类型\n  const type = store.reactive<string>(`openai_type`, DEFAULT_SERVICE_TYPE)\n\n  // 温度参数（0-2，控制随机性）\n  const temperature = store.reactive<number>(`openai_temperature`, DEFAULT_SERVICE_TEMPERATURE)\n\n  // 最大 token 数\n  const maxToken = store.reactive<number>(`openai_max_token`, DEFAULT_SERVICE_MAX_TOKEN)\n\n  // ==================== 服务相关字段 ====================\n\n  // 服务端点（由 watch(type) 自动初始化）\n  const endpoint = ref<string>(``)\n\n  // 模型名称（由 watch(type) 自动初始化）\n  const model = ref<string>(``)\n\n  // ==================== API Key 管理 ====================\n\n  // API Key（按服务类型分别持久化）\n  const apiKey = customRef<string>((track, trigger) => {\n    let cachedKey = ``\n\n    // 异步加载初始值\n    store.get(`openai_key_${type.value}`).then((value) => {\n      cachedKey = value || DEFAULT_SERVICE_KEY\n    })\n\n    return {\n      get() {\n        track()\n        return cachedKey\n      },\n      set(val: string) {\n        cachedKey = val\n        trigger()\n\n        if (type.value !== DEFAULT_SERVICE_TYPE) {\n          store.set(`openai_key_${type.value}`, val)\n        }\n      },\n    }\n  })\n\n  // ==================== 响应式逻辑 ====================\n\n  // 监听服务类型变化，自动同步端点和模型\n  watch(\n    type,\n    async (newType) => {\n      const svc = serviceOptions.find(s => s.value === newType) ?? serviceOptions[0]\n\n      // 更新服务端点\n      endpoint.value = svc.endpoint\n\n      // 读取已保存的模型，如果不存在或不在列表中，则使用默认模型\n      const saved = await store.get(`openai_model_${newType}`) || ``\n      model.value = svc.models.includes(saved) ? saved : svc.models[0]\n\n      // 保存当前模型\n      await store.set(`openai_model_${newType}`, model.value)\n    },\n    { immediate: true }, // 首次加载时也执行\n  )\n\n  // 监听模型变化，持久化存储\n  watch(model, async (val) => {\n    await store.set(`openai_model_${type.value}`, val)\n  })\n\n  // ==================== Actions ====================\n\n  /**\n   * 重置所有配置到默认值\n   */\n  const reset = async () => {\n    type.value = DEFAULT_SERVICE_TYPE\n    temperature.value = DEFAULT_SERVICE_TEMPERATURE\n    maxToken.value = DEFAULT_SERVICE_MAX_TOKEN\n\n    // 清理所有服务相关的持久化数据\n    await Promise.all(\n      serviceOptions.map(async ({ value }) => {\n        await store.remove(`openai_key_${value}`)\n        await store.remove(`openai_model_${value}`)\n      }),\n    )\n  }\n\n  return {\n    // State\n    type,\n    endpoint,\n    model,\n    temperature,\n    maxToken,\n    apiKey,\n\n    // Actions\n    reset,\n  }\n})\n\n// 默认导出（向后兼容）\nexport default useAIConfigStore\n"
  },
  {
    "path": "apps/web/src/stores/aiImageConfig.ts",
    "content": "import { imageServiceOptions } from '@md/shared/configs'\nimport {\n  DEFAULT_SERVICE_KEY,\n  DEFAULT_SERVICE_TYPE,\n} from '@md/shared/constants'\nimport { store } from '@/utils/storage'\n\n/**\n * AI 图片生成配置 Store\n * 负责管理 AI 图片生成服务的配置，包括服务类型、尺寸、质量等参数\n */\nexport const useAIImageConfigStore = defineStore(`AIImageConfig`, () => {\n  // ==================== 全局配置 ====================\n\n  // 服务类型\n  const type = store.reactive<string>(`openai_image_type`, DEFAULT_SERVICE_TYPE)\n\n  // 图片尺寸\n  const size = store.reactive<string>(`openai_image_size`, `1024x1024`)\n\n  // 图片质量\n  const quality = store.reactive<string>(`openai_image_quality`, `standard`)\n\n  // 图片风格\n  const style = store.reactive<string>(`openai_image_style`, `natural`)\n\n  // ==================== 服务相关字段 ====================\n\n  // 服务端点（支持自定义服务）\n  const endpoint = customRef<string>((track, trigger) => {\n    let cachedEndpoint = ``\n\n    // 异步加载初始值\n    const loadEndpoint = async () => {\n      if (type.value === `custom`) {\n        cachedEndpoint = await store.get(`openai_image_endpoint_${type.value}`) || ``\n      }\n      else {\n        const svc = imageServiceOptions.find(s => s.value === type.value) ?? imageServiceOptions[0]\n        cachedEndpoint = svc.endpoint\n      }\n    }\n    loadEndpoint()\n\n    return {\n      get() {\n        track()\n        return cachedEndpoint\n      },\n      set(val: string) {\n        cachedEndpoint = val\n        trigger()\n\n        if (type.value === `custom`) {\n          store.set(`openai_image_endpoint_${type.value}`, val)\n        }\n      },\n    }\n  })\n\n  // 模型名称（由 watch(type) 自动初始化）\n  const model = ref<string>(``)\n\n  // ==================== API Key 管理 ====================\n\n  // API Key（按服务类型分别持久化）\n  const apiKey = customRef<string>((track, trigger) => {\n    let cachedKey = ``\n\n    // 异步加载初始值\n    store.get(`openai_image_key_${type.value}`).then((value) => {\n      cachedKey = value || DEFAULT_SERVICE_KEY\n    })\n\n    return {\n      get() {\n        track()\n        return cachedKey\n      },\n      set(val: string) {\n        cachedKey = val\n        trigger()\n\n        if (type.value !== DEFAULT_SERVICE_TYPE) {\n          store.set(`openai_image_key_${type.value}`, val)\n        }\n      },\n    }\n  })\n\n  // ==================== 响应式逻辑 ====================\n\n  // 监听服务类型变化，自动同步模型\n  watch(\n    type,\n    async (newType) => {\n      const svc = imageServiceOptions.find(s => s.value === newType) ?? imageServiceOptions[0]\n\n      if (newType === `custom`) {\n        // 自定义服务：从存储读取模型\n        const savedModel = await store.get(`openai_image_model_${newType}`) || ``\n        model.value = savedModel\n      }\n      else {\n        // 预设服务：读取已保存的模型，如果不存在或不在列表中，则使用默认模型\n        const saved = await store.get(`openai_image_model_${newType}`) || ``\n        model.value = svc.models.includes(saved) ? saved : svc.models[0]\n\n        // 如果需要回退到默认模型，则保存\n        if (!svc.models.includes(saved) && svc.models[0]) {\n          await store.set(`openai_image_model_${newType}`, svc.models[0])\n        }\n      }\n    },\n    { immediate: true }, // 首次加载时也执行\n  )\n\n  // 监听模型变化，持久化存储\n  watch(model, async (val) => {\n    await store.set(`openai_image_model_${type.value}`, val)\n  })\n\n  // ==================== Actions ====================\n\n  /**\n   * 重置所有配置到默认值\n   */\n  const reset = async () => {\n    type.value = DEFAULT_SERVICE_TYPE\n    size.value = `1024x1024`\n    quality.value = `standard`\n    style.value = `natural`\n\n    // 清理所有服务相关的持久化数据\n    await Promise.all(\n      imageServiceOptions.map(async ({ value }) => {\n        await store.remove(`openai_image_key_${value}`)\n        await store.remove(`openai_image_model_${value}`)\n        await store.remove(`openai_image_endpoint_${value}`)\n      }),\n    )\n  }\n\n  return {\n    // State\n    type,\n    endpoint,\n    model,\n    size,\n    quality,\n    style,\n    apiKey,\n\n    // Actions\n    reset,\n  }\n})\n\n// 默认导出（向后兼容）\nexport default useAIImageConfigStore\n"
  },
  {
    "path": "apps/web/src/stores/cssEditor.ts",
    "content": "import type { EditorView } from '@codemirror/view'\nimport { Compartment, EditorState } from '@codemirror/state'\nimport { EditorView as CMEditorView } from '@codemirror/view'\nimport { cssSetup, DEFAULT_CUSTOM_THEME, theme as editorTheme } from '@md/shared'\nimport { addPrefix } from '@/utils'\nimport { store } from '@/utils/storage'\n\nconst DEFAULT_CSS_CONTENT = DEFAULT_CUSTOM_THEME\n\n/**\n * CSS 编辑器配置接口\n */\nexport interface CssContentConfig {\n  active: string\n  tabs: {\n    title: string\n    name: string\n    content: string\n  }[]\n}\n\n/**\n * CSS 编辑器 Store\n * 负责管理自定义 CSS 编辑器及其配置\n */\nexport const useCssEditorStore = defineStore(`cssEditor`, () => {\n  const isDark = useDark()\n\n  // CSS 编辑器实例\n  const cssEditor = ref<EditorView | null>(null)\n  const cssEditorThemeCompartment = ref<Compartment | null>(null)\n\n  /**\n   * 自定义 CSS 内容\n   * @deprecated 在后续版本中将会移除\n   */\n  const cssContent = store.reactive(`__css_content`, DEFAULT_CSS_CONTENT)\n\n  // CSS 内容配置\n  const cssContentConfig = store.reactive<CssContentConfig>(addPrefix(`css_content_config`), {\n    active: `方案1`,\n    tabs: [\n      {\n        title: `方案1`,\n        name: `方案1`,\n        content: cssContent.value || DEFAULT_CSS_CONTENT,\n      },\n    ],\n  })\n\n  // 获取当前激活的 Tab\n  const getCurrentTab = () => {\n    return cssContentConfig.value.tabs.find((tab) => {\n      return tab.name === cssContentConfig.value.active\n    })!\n  }\n\n  // 获取当前 Tab 的内容\n  const getCurrentTabContent = () => {\n    return getCurrentTab().content\n  }\n\n  // 设置编辑器内容\n  const setCssEditorValue = (content: string) => {\n    if (cssEditor.value) {\n      cssEditor.value.dispatch({\n        changes: { from: 0, to: cssEditor.value.state.doc.length, insert: content },\n      })\n    }\n  }\n\n  // 切换 Tab 的回调（由外部传入，用于触发渲染刷新）\n  let onTabChangedCallback: ((content: string) => void) | null = null\n\n  // 设置切换 Tab 的回调\n  const setOnTabChangedCallback = (callback: (content: string) => void) => {\n    onTabChangedCallback = callback\n  }\n\n  // 切换 Tab\n  const tabChanged = (name: string) => {\n    console.log(`tabChanged`, name)\n    cssContentConfig.value.active = name\n    const content = cssContentConfig.value.tabs.find((tab) => {\n      return tab.name === name\n    })!.content\n    setCssEditorValue(content)\n\n    // 触发回调以刷新渲染\n    if (onTabChangedCallback) {\n      onTabChangedCallback(content)\n    }\n  }\n\n  // 重命名 Tab\n  const renameTab = (name: string) => {\n    const tab = getCurrentTab()\n    tab.title = name\n    tab.name = name\n    cssContentConfig.value.active = name\n  }\n\n  // 添加 CSS 方案\n  const addCssContentTab = (name: string, initialContent?: string) => {\n    const content = initialContent || DEFAULT_CSS_CONTENT\n    cssContentConfig.value.tabs.push({\n      name,\n      title: name,\n      content,\n    })\n    cssContentConfig.value.active = name\n    console.log(`addCssContentTab`, name)\n    setCssEditorValue(content)\n\n    // 触发回调以刷新渲染（使用新方案的 CSS）\n    if (onTabChangedCallback) {\n      onTabChangedCallback(content)\n    }\n  }\n\n  // 验证 Tab 名称\n  const validatorTabName = (val: string) => {\n    return cssContentConfig.value.tabs.every(({ name }) => name !== val)\n  }\n\n  // 重置 CSS 配置\n  const resetCssConfig = () => {\n    cssContentConfig.value = {\n      active: `方案 1`,\n      tabs: [\n        {\n          title: `方案 1`,\n          name: `方案 1`,\n          content: DEFAULT_CSS_CONTENT,\n        },\n      ],\n    }\n\n    if (cssEditor.value) {\n      cssEditor.value.dispatch({\n        changes: { from: 0, to: cssEditor.value.state.doc.length, insert: DEFAULT_CSS_CONTENT },\n      })\n    }\n  }\n\n  // 初始化 CSS 编辑器\n  const initCssEditor = (onUpdate: (content: string) => void) => {\n    const cssEditorDom = document.querySelector<HTMLTextAreaElement>(`#cssEditor`)\n    if (!cssEditorDom)\n      return\n\n    cssEditorDom.value = getCurrentTab().content\n\n    // 创建 CSS 编辑器的容器\n    const cssContainer = document.createElement(`div`)\n    cssContainer.className = 'w-full h-full'\n    cssEditorDom.parentNode?.replaceChild(cssContainer, cssEditorDom)\n\n    // 创建主题 Compartment 用于动态切换\n    cssEditorThemeCompartment.value = new Compartment()\n\n    // 创建 CSS 编辑器\n    const state = EditorState.create({\n      doc: getCurrentTab().content,\n      extensions: [\n        cssSetup(),\n        cssEditorThemeCompartment.value.of(editorTheme(isDark.value)),\n        CMEditorView.updateListener.of((update) => {\n          if (update.docChanged) {\n            const content = update.state.doc.toString()\n            getCurrentTab().content = content\n            onUpdate(content)\n          }\n        }),\n      ],\n    })\n\n    cssEditor.value = markRaw(new CMEditorView({\n      state,\n      parent: cssContainer,\n    }))\n  }\n\n  // 监听深色模式变化\n  watch(isDark, () => {\n    if (cssEditor.value && cssEditorThemeCompartment.value) {\n      cssEditor.value.dispatch({\n        effects: cssEditorThemeCompartment.value.reconfigure(editorTheme(isDark.value)),\n      })\n    }\n  })\n\n  // 清空过往历史记录\n  onMounted(() => {\n    cssContent.value = ``\n  })\n\n  // 滚动到指定标题级别的 CSS 区域并选中\n  const scrollToHeading = (level: string) => {\n    if (!cssEditor.value)\n      return\n\n    const doc = cssEditor.value.state.doc.toString()\n    // 匹配 h1 { 或 h2 { 等模式（支持换行和空格）\n    const pattern = new RegExp(`^${level}\\\\s*\\\\{`, `m`)\n    const match = doc.match(pattern)\n\n    if (match && match.index !== undefined) {\n      const startPos = match.index\n      // 查找对应的结束括号\n      let braceCount = 0\n      let endPos = startPos\n      let foundStart = false\n\n      for (let i = startPos; i < doc.length; i++) {\n        if (doc[i] === `{`) {\n          braceCount++\n          foundStart = true\n        }\n        else if (doc[i] === `}`) {\n          braceCount--\n          if (foundStart && braceCount === 0) {\n            endPos = i + 1\n            break\n          }\n        }\n      }\n\n      // 滚动到位置并选中该区域\n      cssEditor.value.dispatch({\n        selection: { anchor: startPos, head: endPos },\n        scrollIntoView: true,\n      })\n\n      // 聚焦编辑器\n      cssEditor.value.focus()\n    }\n  }\n\n  return {\n    // State\n    cssEditor,\n    cssContentConfig,\n\n    // Getters\n    getCurrentTab,\n    getCurrentTabContent,\n\n    // Actions\n    setCssEditorValue,\n    setOnTabChangedCallback,\n    tabChanged,\n    renameTab,\n    addCssContentTab,\n    validatorTabName,\n    resetCssConfig,\n    initCssEditor,\n    scrollToHeading,\n  }\n})\n"
  },
  {
    "path": "apps/web/src/stores/editor.ts",
    "content": "import type { EditorView } from '@codemirror/view'\nimport { formatDoc } from '@/utils'\n\n/**\n * 编辑器 Store\n * 负责管理 CodeMirror 编辑器实例和基础操作\n */\nexport const useEditorStore = defineStore(`editor`, () => {\n  // 内容编辑器实例\n  const editor = ref<EditorView | null>(null)\n\n  // 格式化文档\n  const formatContent = async () => {\n    if (!editor.value)\n      return\n\n    const doc = await formatDoc(editor.value.state.doc.toString())\n    editor.value.dispatch({\n      changes: { from: 0, to: editor.value.state.doc.length, insert: doc },\n    })\n    return doc\n  }\n\n  // 导入默认文档\n  const importContent = (content: string) => {\n    if (!editor.value)\n      return\n\n    editor.value.dispatch({\n      changes: { from: 0, to: editor.value.state.doc.length, insert: content },\n    })\n  }\n\n  // 清空内容\n  const clearContent = () => {\n    if (!editor.value)\n      return\n\n    editor.value.dispatch({\n      changes: { from: 0, to: editor.value.state.doc.length, insert: `` },\n    })\n    toast.success(`内容已清空`)\n  }\n\n  // 获取当前内容\n  const getContent = () => {\n    return editor.value?.state.doc.toString() ?? ``\n  }\n\n  // 获取选中的文本\n  const getSelection = () => {\n    if (!editor.value)\n      return ``\n\n    const selection = editor.value.state.selection.main\n    return editor.value.state.doc.sliceString(selection.from, selection.to)\n  }\n\n  // 替换选中的文本\n  const replaceSelection = (text: string) => {\n    if (!editor.value)\n      return\n\n    editor.value.dispatch(editor.value.state.replaceSelection(text))\n  }\n\n  // 在光标位置插入文本\n  const insertAtCursor = (text: string) => {\n    if (!editor.value)\n      return\n\n    const selection = editor.value.state.selection.main\n    editor.value.dispatch({\n      changes: { from: selection.from, to: selection.to, insert: text },\n      selection: { anchor: selection.from + text.length },\n    })\n    editor.value.focus()\n  }\n\n  return {\n    editor,\n    formatContent,\n    importContent,\n    clearContent,\n    getContent,\n    getSelection,\n    replaceSelection,\n    insertAtCursor,\n  }\n})\n"
  },
  {
    "path": "apps/web/src/stores/export.ts",
    "content": "import { toPng } from 'html-to-image'\nimport {\n  downloadFile,\n  downloadMD,\n  exportHTML,\n  exportPDF,\n  exportPureHTML,\n  getHtmlContent,\n  sanitizeTitle,\n} from '@/utils'\nimport { usePostStore } from './post'\nimport { useRenderStore } from './render'\nimport { useUIStore } from './ui'\n\n/**\n * 导出功能 Store\n * 负责处理各种导出功能：HTML、PDF、MD、图片等\n */\nexport const useExportStore = defineStore(`export`, () => {\n  const postStore = usePostStore()\n  const renderStore = useRenderStore()\n  const uiStore = useUIStore()\n\n  // 将编辑器内容转换为 HTML\n  const editorContent2HTML = () => {\n    const temp = getHtmlContent()\n    document.querySelector(`#output`)!.innerHTML = renderStore.output\n    return temp\n  }\n\n  // 导出编辑器内容为 HTML，并且下载到本地\n  const exportEditorContent2HTML = async () => {\n    const currentPost = postStore.currentPost\n    if (!currentPost)\n      return\n\n    await exportHTML(currentPost.title)\n    document.querySelector(`#output`)!.innerHTML = renderStore.output\n  }\n\n  // 导出编辑器内容为无样式 HTML\n  const exportEditorContent2PureHTML = (content: string) => {\n    const currentPost = postStore.currentPost\n    if (!currentPost)\n      return\n\n    exportPureHTML(content, currentPost.title)\n  }\n\n  // 下载卡片图片\n  const downloadAsCardImage = async () => {\n    const currentPost = postStore.currentPost\n    if (!currentPost)\n      return\n\n    const el = document.querySelector<HTMLElement>(`#output-wrapper>.preview`)\n    if (!el)\n      return\n\n    // 添加临时样式：禁用代码块滚动，启用换行\n    const style = document.createElement('style')\n    style.textContent = `\n      .preview pre.code__pre,\n      .preview .hljs.code__pre,\n      .preview pre.code__pre > code,\n      .preview .hljs.code__pre > code,\n      .preview .code-scroll,\n      .preview pre section,\n      .preview code section {\n        overflow: visible !important;\n      }\n      .preview pre.code__pre > code,\n      .preview .code-scroll,\n      .preview .code-scroll > div {\n        white-space: pre-wrap !important;\n        word-break: break-all !important;\n        min-width: auto !important;\n      }\n    `\n    document.head.appendChild(style)\n\n    try {\n      await new Promise(resolve => setTimeout(resolve, 100))\n      const url = await toPng(el, {\n        backgroundColor: uiStore.isDark ? `` : `#fff`,\n        skipFonts: true,\n        pixelRatio: Math.max(window.devicePixelRatio || 1, 2),\n        style: { margin: `0` },\n      })\n      downloadFile(url, `${sanitizeTitle(currentPost.title)}.png`, `image/png`)\n    }\n    finally {\n      style.remove()\n    }\n  }\n\n  // 导出编辑器内容为 PDF\n  const exportEditorContent2PDF = async () => {\n    const currentPost = postStore.currentPost\n    if (!currentPost)\n      return\n\n    await exportPDF(currentPost.title)\n    document.querySelector(`#output`)!.innerHTML = renderStore.output\n  }\n\n  // 导出编辑器内容到本地（Markdown）\n  const exportEditorContent2MD = (content: string) => {\n    const currentPost = postStore.currentPost\n    if (!currentPost)\n      return\n\n    downloadMD(content, currentPost.title)\n  }\n\n  return {\n    editorContent2HTML,\n    exportEditorContent2HTML,\n    exportEditorContent2PureHTML,\n    downloadAsCardImage,\n    exportEditorContent2PDF,\n    exportEditorContent2MD,\n  }\n})\n"
  },
  {
    "path": "apps/web/src/stores/folderSource.ts",
    "content": "/**\n * 文件系统节点接口\n */\nexport interface FileSystemNode {\n  name: string\n  path: string\n  type: 'file' | 'directory'\n  children?: FileSystemNode[]\n  handle?: FileSystemFileHandle | FileSystemDirectoryHandle\n}\n\n/**\n * 运行时文件夹信息（包含 handle，仅在内存中）\n */\ninterface RuntimeFolderInfo {\n  id: string\n  name: string\n  handle: FileSystemDirectoryHandle\n}\n\n/**\n * 本地文件夹源 Store\n * 负责管理本地文件夹的访问、文件树结构和文件读写\n */\nexport const useFolderSourceStore = defineStore(`folderSource`, () => {\n  // 内存中的运行时文件夹信息（不持久化）\n  const runtimeFolderMap = new Map<string, RuntimeFolderInfo>()\n\n  // 当前激活的文件夹 ID（不持久化）\n  const currentFolderId = ref<string | null>(null)\n\n  // 当前文件夹的文件树（不持久化，因为包含不可序列化的 handle）\n  const fileTree = ref<FileSystemNode[]>([])\n\n  // 选中的文件路径\n  const selectedFilePath = ref<string>(``)\n\n  // 是否正在加载\n  const isLoading = ref(false)\n\n  // 加载错误信息\n  const loadError = ref<string>(``)\n\n  // 当前运行时文件夹\n  const currentRuntimeFolder = computed(() => {\n    if (!currentFolderId.value)\n      return null\n    return runtimeFolderMap.get(currentFolderId.value) || null\n  })\n\n  // 兼容旧代码的属性\n  const folderHandles = computed(() => {\n    return Array.from(runtimeFolderMap.values()).map(folder => ({\n      id: folder.id,\n      name: folder.name,\n      handle: folder.handle,\n      permission: true,\n    }))\n  })\n\n  const currentFolderHandle = computed(() => {\n    if (!currentRuntimeFolder.value)\n      return null\n    return {\n      id: currentRuntimeFolder.value.id,\n      name: currentRuntimeFolder.value.name,\n      handle: currentRuntimeFolder.value.handle,\n      permission: true,\n    }\n  })\n\n  // 兼容：savedFolders 返回空数组\n  const savedFolders = ref<any[]>([])\n\n  // 检查浏览器是否支持 File System Access API\n  const isFileSystemAPISupported = computed(() => {\n    return typeof window !== `undefined` && `showDirectoryPicker` in window\n  })\n\n  /**\n   * 选择并打开本地文件夹\n   */\n  async function selectFolder() {\n    if (!isFileSystemAPISupported.value) {\n      toast.error(`您的浏览器不支持 File System Access API`)\n      return\n    }\n\n    try {\n      isLoading.value = true\n      loadError.value = ``\n\n      const handle = await window.showDirectoryPicker({\n        mode: `readwrite`,\n        startIn: `documents`,\n      })\n\n      // 请求权限\n      const permission = await handle.requestPermission({ mode: `readwrite` })\n      if (permission !== `granted`) {\n        toast.error(`未授予文件夹访问权限`)\n        return\n      }\n\n      // 检查是否已经打开过这个文件夹\n      let folderId: string\n      const existingFolder = Array.from(runtimeFolderMap.values()).find(f => f.name === handle.name)\n\n      if (existingFolder) {\n        folderId = existingFolder.id\n        // 更新 handle\n        existingFolder.handle = handle\n      }\n      else {\n        // 创建新文件夹信息\n        folderId = generateFolderId()\n        const folderInfo: RuntimeFolderInfo = {\n          id: folderId,\n          name: handle.name,\n          handle,\n        }\n        runtimeFolderMap.set(folderId, folderInfo)\n      }\n\n      currentFolderId.value = folderId\n\n      // 加载文件树\n      await loadFileTree(handle)\n\n      toast.success(`文件夹「${handle.name}」已打开`)\n    }\n    catch (error: any) {\n      if (error.name === `AbortError`) {\n        // 用户取消了选择\n        return\n      }\n      loadError.value = error.message || `打开文件夹失败`\n      toast.error(`打开文件夹失败: ${error.message}`)\n    }\n    finally {\n      isLoading.value = false\n    }\n  }\n\n  /**\n   * 关闭当前文件夹\n   */\n  function closeFolder() {\n    currentFolderId.value = null\n    fileTree.value = []\n    selectedFilePath.value = ``\n  }\n\n  /**\n   * 从列表中移除文件夹\n   */\n  function removeFolder(folderId: string) {\n    runtimeFolderMap.delete(folderId)\n\n    // 如果关闭的是当前文件夹，清空当前状态\n    if (currentFolderId.value === folderId) {\n      closeFolder()\n    }\n  }\n\n  /**\n   * 加载文件树\n   */\n  async function loadFileTree(handle: FileSystemDirectoryHandle): Promise<void> {\n    try {\n      const tree = await buildFileTree(handle, handle.name)\n      fileTree.value = [tree]\n    }\n    catch (error: any) {\n      loadError.value = error.message || `加载文件树失败`\n      throw error\n    }\n  }\n\n  /**\n   * 递归构建文件树\n   */\n  async function buildFileTree(\n    handle: FileSystemDirectoryHandle,\n    path: string,\n  ): Promise<FileSystemNode> {\n    const node: FileSystemNode = {\n      name: handle.name,\n      path,\n      type: `directory`,\n      children: [],\n      handle,\n    }\n\n    try {\n      for await (const entry of handle.values()) {\n        const entryPath = `${path}/${entry.name}`\n        if (entry.kind === `file`) {\n          // 只添加 Markdown 文件\n          if (entry.name.toLowerCase().endsWith(`.md`)) {\n            node.children!.push({\n              name: entry.name,\n              path: entryPath,\n              type: `file`,\n              handle: entry as FileSystemFileHandle,\n            })\n          }\n        }\n        else if (entry.kind === `directory`) {\n          // 递归处理子目录\n          const childNode = await buildFileTree(entry as FileSystemDirectoryHandle, entryPath)\n          node.children!.push(childNode)\n        }\n      }\n\n      // 排序：目录在前，文件在后，按名称排序\n      node.children!.sort((a, b) => {\n        if (a.type !== b.type) {\n          return a.type === `directory` ? -1 : 1\n        }\n        return a.name.localeCompare(b.name, `zh-CN`)\n      })\n    }\n    catch (error: any) {\n      console.error(`读取目录失败: ${path}`, error)\n    }\n\n    return node\n  }\n\n  /**\n   * 读取文件内容\n   */\n  async function readFile(filePath: string): Promise<string> {\n    if (!currentRuntimeFolder.value) {\n      throw new Error(`未选择文件夹`)\n    }\n\n    try {\n      // 直接从文件树中查找节点\n      const node = findNodeByPath(fileTree.value, filePath)\n      if (!node) {\n        throw new Error(`文件不存在: ${filePath}`)\n      }\n\n      if (node.type !== `file`) {\n        throw new Error(`不是文件: ${filePath}`)\n      }\n\n      // 使用节点中存储的文件句柄\n      const fileHandle = node.handle as FileSystemFileHandle\n      const file = await fileHandle.getFile()\n      return await file.text()\n    }\n    catch (error: any) {\n      toast.error(`读取文件失败: ${error.message}`)\n      throw error\n    }\n  }\n\n  /**\n   * 写入文件内容\n   */\n  async function writeFile(filePath: string, content: string): Promise<void> {\n    if (!currentRuntimeFolder.value) {\n      throw new Error(`未选择文件夹`)\n    }\n\n    try {\n      // 解析路径，找到对应的目录句柄\n      const pathParts = filePath.split(`/`).slice(1) // 移除第一部分（文件夹名）\n      let currentHandle = currentRuntimeFolder.value.handle as FileSystemDirectoryHandle\n\n      // 遍历路径，创建不存在的目录\n      for (let i = 0; i < pathParts.length - 1; i++) {\n        const dirName = pathParts[i]\n        try {\n          currentHandle = await currentHandle.getDirectoryHandle(dirName)\n        }\n        catch {\n          // 目录不存在，创建它\n          currentHandle = await currentHandle.getDirectoryHandle(dirName, { create: true })\n        }\n      }\n\n      // 获取或创建文件句柄\n      const fileName = pathParts[pathParts.length - 1]\n      const fileHandle = await currentHandle.getFileHandle(fileName, { create: true })\n\n      // 写入内容\n      const writable = await fileHandle.createWritable()\n      await writable.write(content)\n      await writable.close()\n    }\n    catch (error: any) {\n      console.error(`保存文件失败: ${error.message}`)\n      throw error\n    }\n  }\n\n  /**\n   * 在文件树中查找节点\n   */\n  function findNodeByPath(nodes: FileSystemNode[], path: string): FileSystemNode | null {\n    for (const node of nodes) {\n      if (node.path === path) {\n        return node\n      }\n      if (node.children) {\n        const found = findNodeByPath(node.children, path)\n        if (found)\n          return found\n      }\n    }\n    return null\n  }\n\n  /**\n   * 获取所有 Markdown 文件列表\n   */\n  function getAllMarkdownFiles(nodes: FileSystemNode[] = fileTree.value): FileSystemNode[] {\n    const files: FileSystemNode[] = []\n    for (const node of nodes) {\n      if (node.type === `file`) {\n        files.push(node)\n      }\n      if (node.children) {\n        files.push(...getAllMarkdownFiles(node.children))\n      }\n    }\n    return files\n  }\n\n  /**\n   * 生成文件夹 ID\n   */\n  function generateFolderId(): string {\n    return `folder_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`\n  }\n\n  return {\n    // State\n    folderHandles, // 兼容旧代码\n    currentFolderHandle, // 兼容旧代码\n    savedFolders,\n    fileTree,\n    selectedFilePath,\n    isLoading,\n    loadError,\n\n    // Computed\n    isFileSystemAPISupported,\n\n    // Actions\n    selectFolder,\n    closeFolder,\n    removeFolder,\n    loadFileTree,\n    readFile,\n    writeFile,\n    findNodeByPath,\n    getAllMarkdownFiles,\n  }\n})\n"
  },
  {
    "path": "apps/web/src/stores/post.ts",
    "content": "import { v4 as uuid } from 'uuid'\nimport DEFAULT_CONTENT from '@/assets/example/markdown.md?raw'\nimport { addPrefix } from '@/utils'\nimport { store } from '@/utils/storage'\n\n/**\n * Post 结构接口\n */\nexport interface Post {\n  id: string\n  title: string\n  content: string\n  history: {\n    datetime: string\n    content: string\n  }[]\n  createDatetime: Date\n  updateDatetime: Date\n  // 父标签\n  parentId?: string | null\n  // 展开状态\n  collapsed?: boolean\n}\n\n/**\n * 文章管理 Store\n * 负责管理文章列表、当前文章、文章 CRUD 操作\n */\nexport const usePostStore = defineStore(`post`, () => {\n  // 内容列表\n  const posts = store.reactive<Post[]>(addPrefix(`posts`), [\n    {\n      id: uuid(),\n      title: `内容1`,\n      content: DEFAULT_CONTENT,\n      history: [\n        { datetime: new Date().toLocaleString(`zh-cn`), content: DEFAULT_CONTENT },\n      ],\n      createDatetime: new Date(),\n      updateDatetime: new Date(),\n    },\n  ])\n\n  // 当前文章 ID\n  const currentPostId = store.reactive(addPrefix(`current_post_id`), ``)\n\n  // 在补齐 id 后，若 currentPostId 无效 ➜ 自动指向第一篇\n  onBeforeMount(() => {\n    posts.value = posts.value.map((post, index) => {\n      const now = Date.now()\n      return {\n        ...post,\n        id: post.id ?? uuid(),\n        createDatetime: post.createDatetime ?? new Date(now + index),\n        updateDatetime: post.updateDatetime ?? new Date(now + index),\n      }\n    })\n\n    // 兼容：如果本地没有 currentPostId，或指向的文章已不存在\n    if (!currentPostId.value || !posts.value.some(p => p.id === currentPostId.value)) {\n      currentPostId.value = posts.value[0]?.id ?? ``\n    }\n  })\n\n  // 根据 id 找索引\n  const findIndexById = (id: string) => posts.value.findIndex(p => p.id === id)\n\n  // computed: 让旧代码还能用 index，但底层映射 id\n  const currentPostIndex = computed<number>({\n    get: () => findIndexById(currentPostId.value),\n    set: (idx) => {\n      if (idx >= 0 && idx < posts.value.length) {\n        currentPostId.value = posts.value[idx].id\n      }\n    },\n  })\n\n  // 获取 Post\n  const getPostById = (id: string) => posts.value.find(p => p.id === id)\n\n  // 获取当前文章\n  const currentPost = computed(() => getPostById(currentPostId.value))\n\n  // 添加文章\n  const addPost = (title: string, parentId: string | null = null) => {\n    const newPost: Post = {\n      id: uuid(),\n      title,\n      content: `# ${title}`,\n      history: [\n        { datetime: new Date().toLocaleString(`zh-cn`), content: `# ${title}` },\n      ],\n      createDatetime: new Date(),\n      updateDatetime: new Date(),\n      parentId,\n    }\n    posts.value.push(newPost)\n    currentPostId.value = newPost.id\n  }\n\n  // 重命名文章\n  const renamePost = (id: string, title: string) => {\n    const post = getPostById(id)\n    if (post) {\n      post.title = title\n      post.updateDatetime = new Date()\n    }\n  }\n\n  // 删除文章\n  const delPost = (id: string) => {\n    const idx = findIndexById(id)\n    if (idx === -1)\n      return\n\n    posts.value.splice(idx, 1)\n    currentPostId.value = posts.value[Math.min(idx, posts.value.length - 1)]?.id ?? ``\n  }\n\n  // 更新文章父 ID\n  const updatePostParentId = (postId: string, parentId: string | null) => {\n    const post = getPostById(postId)\n    if (post) {\n      post.parentId = parentId\n      post.updateDatetime = new Date()\n    }\n  }\n\n  // 更新文章内容\n  const updatePostContent = (id: string, content: string) => {\n    const post = getPostById(id)\n    if (post) {\n      post.content = content\n      post.updateDatetime = new Date()\n    }\n  }\n\n  // 收起所有文章\n  const collapseAllPosts = () => {\n    posts.value.forEach((post) => {\n      post.collapsed = true\n    })\n  }\n\n  // 展开所有文章\n  const expandAllPosts = () => {\n    posts.value.forEach((post) => {\n      post.collapsed = false\n    })\n  }\n\n  return {\n    // State\n    posts,\n    currentPostId,\n    currentPostIndex,\n    currentPost,\n\n    // Getters\n    getPostById,\n    findIndexById,\n\n    // Actions\n    addPost,\n    renamePost,\n    delPost,\n    updatePostParentId,\n    updatePostContent,\n    collapseAllPosts,\n    expandAllPosts,\n  }\n})\n"
  },
  {
    "path": "apps/web/src/stores/quickCommands.ts",
    "content": "import { ref, watch } from 'vue'\nimport { store } from '@/utils/storage'\n\nexport interface QuickCommandPersisted {\n  id: string\n  label: string\n  template: string // 用 {{sel}} 占位\n}\n\nexport interface QuickCommandRuntime extends QuickCommandPersisted {\n  buildPrompt: (sel?: string) => string\n}\n\nconst STORAGE_KEY = `quick_commands`\n\n// 把持久化的对象转换为可执行的 buildPrompt\nfunction hydrate(cmd: QuickCommandPersisted): QuickCommandRuntime {\n  return {\n    ...cmd,\n    buildPrompt: (sel = ``) =>\n      cmd.template.replace(/\\{\\{\\s*sel\\s*\\}\\}/gi, sel),\n  }\n}\n\n// 4 条默认指令\nconst DEFAULT_COMMANDS: QuickCommandPersisted[] = [\n  { id: `polish`, label: `润色`, template: `请润色以下内容：\\n\\n{{sel}}` },\n  { id: `to-en`, label: `翻译成英文`, template: `请将以下内容翻译为英文：\\n\\n{{sel}}` },\n  { id: `to-zh`, label: `翻译成中文`, template: `Please translate the following content into Chinese:\\n\\n{{sel}}` },\n  { id: `summary`, label: `总结`, template: `请对以下内容进行总结：\\n\\n{{sel}}` },\n]\n\nexport const useQuickCommands = defineStore(`quickCommands`, () => {\n  // ---------- state ----------\n  const commands = ref<QuickCommandRuntime[]>([])\n\n  // ---------- helpers ----------\n  async function save() {\n    const toSave: QuickCommandPersisted[] = commands.value.map(\n      ({ id, label, template }) => ({ id, label, template }),\n    )\n    await store.setJSON(STORAGE_KEY, toSave)\n  }\n\n  async function load() {\n    const parsed = await store.getJSON<QuickCommandPersisted[]>(STORAGE_KEY)\n\n    if (parsed && Array.isArray(parsed)) {\n      try {\n        commands.value = parsed.map(hydrate)\n      }\n      catch (e) {\n        console.warn(`解析快捷指令失败，已恢复默认值`, e)\n        commands.value = DEFAULT_COMMANDS.map(hydrate)\n        await save()\n      }\n    }\n    else {\n      commands.value = DEFAULT_COMMANDS.map(hydrate)\n      await save()\n    }\n  }\n\n  // ---------- CRUD ----------\n  function add(label: string, template: string) {\n    const id = crypto.randomUUID()\n    commands.value.push(hydrate({ id, label, template }))\n  }\n\n  function update(id: string, label: string, template: string) {\n    const idx = commands.value.findIndex(c => c.id === id)\n    if (idx !== -1)\n      commands.value[idx] = hydrate({ id, label, template })\n  }\n\n  function remove(id: string) {\n    commands.value = commands.value.filter(c => c.id !== id)\n  }\n\n  // ---------- init ----------\n  load()\n  watch(commands, save, { deep: true })\n\n  return { commands, add, update, remove }\n})\n"
  },
  {
    "path": "apps/web/src/stores/render.ts",
    "content": "import { initRenderer } from '@md/core'\nimport { postProcessHtml, renderMarkdown } from '@/utils'\nimport { useThemeStore } from './theme'\nimport { useUIStore } from './ui'\n\n/**\n * 渲染 Store\n * 负责 Markdown 渲染、HTML 输出、标题提取等\n */\nexport const useRenderStore = defineStore(`render`, () => {\n  // 输出的 HTML\n  const output = ref(``)\n\n  // 阅读时间统计\n  const readingTime = reactive({\n    chars: 0,\n    words: 0,\n    minutes: 0,\n  })\n\n  // 文章标题列表（用于生成目录）\n  const titleList = ref<{\n    url: string\n    title: string\n    level: number\n  }[]>([])\n\n  // 渲染器实例（延迟初始化）\n  let renderer: ReturnType<typeof initRenderer> | null = null\n\n  /**\n   * 初始化渲染器（新主题系统）\n   * 主题样式通过 useThemeStore().applyCurrentTheme() 注入到 <style> 标签\n   */\n  const initRendererInstance = (options?: {\n    isMacCodeBlock?: boolean\n    isShowLineNumber?: boolean\n  }) => {\n    renderer = initRenderer(options || {})\n    return renderer\n  }\n\n  // 获取渲染器\n  const getRenderer = () => renderer\n\n  // 提取标题\n  const extractTitles = () => {\n    const div = document.createElement(`div`)\n    div.innerHTML = output.value\n    const list = div.querySelectorAll<HTMLElement>(`[data-heading]`)\n\n    titleList.value = []\n    let i = 0\n    for (const item of list) {\n      item.setAttribute(`id`, `${i}`)\n      titleList.value.push({\n        url: `#${i}`,\n        title: `${item.textContent}`,\n        level: Number(item.tagName.slice(1)),\n      })\n      i++\n    }\n    output.value = div.innerHTML\n  }\n\n  // 渲染内容\n  const render = (content: string) => {\n    if (!renderer) {\n      throw new Error(`Renderer not initialized. Call initRendererInstance first.`)\n    }\n\n    const themeStore = useThemeStore()\n    const uiStore = useUIStore()\n\n    // 重置渲染器配置\n    // 注意：isUseIndent 和 isUseJustify 通过 CSS 变量处理，不需要传递给渲染器\n    renderer.reset({\n      citeStatus: themeStore.isCiteStatus,\n      legend: themeStore.legend,\n      countStatus: themeStore.isCountStatus,\n      isMacCodeBlock: themeStore.isMacCodeBlock,\n      isShowLineNumber: themeStore.isShowLineNumber,\n      themeMode: uiStore.isDark ? 'dark' : 'light',\n    })\n\n    // 渲染 Markdown\n    const { html: baseHtml, readingTime: readingTimeResult } = renderMarkdown(content, renderer)\n\n    // 更新统计信息\n    readingTime.chars = content.length\n    readingTime.words = readingTimeResult.words\n    readingTime.minutes = Math.ceil(readingTimeResult.minutes)\n\n    // 后处理 HTML\n    output.value = postProcessHtml(baseHtml, readingTimeResult, renderer)\n\n    // 提取标题\n    extractTitles()\n\n    return output.value\n  }\n\n  return {\n    // State\n    output,\n    readingTime,\n    titleList,\n\n    // Actions\n    initRendererInstance,\n    getRenderer,\n    render,\n    extractTitles,\n  }\n})\n"
  },
  {
    "path": "apps/web/src/stores/template.ts",
    "content": "import type { CreateTemplateParams, Template, UpdateTemplateParams } from '@md/shared'\nimport { v4 as uuidv4 } from 'uuid'\nimport { addPrefix } from '@/utils'\nimport { store } from '@/utils/storage'\n\n/**\n * 模板管理 Store\n * 负责管理 Markdown 模板的增删改查\n */\nexport const useTemplateStore = defineStore(`template`, () => {\n  // ==================== 状态 ====================\n  // 模板列表 - 使用响应式存储，自动持久化到 localStorage\n  const templates = store.reactive<Template[]>(addPrefix(`templates`), [])\n\n  // ==================== 计算属性 ====================\n  // 按创建时间倒序排列的模板列表\n  const sortedTemplates = computed(() => {\n    return [...templates.value].sort((a, b) => b.createdAt - a.createdAt)\n  })\n\n  // 模板总数\n  const templateCount = computed(() => templates.value.length)\n\n  // ==================== 方法 ====================\n  /**\n   * 创建新模板\n   */\n  function createTemplate(params: CreateTemplateParams): Template {\n    const now = Date.now()\n    const newTemplate: Template = {\n      id: uuidv4(),\n      name: params.name,\n      content: params.content,\n      description: params.description,\n      tags: params.tags,\n      createdAt: now,\n      updatedAt: now,\n    }\n\n    templates.value.push(newTemplate)\n    toast.success(`模板「${params.name}」创建成功`)\n    return newTemplate\n  }\n\n  /**\n   * 根据 ID 获取模板\n   */\n  function getTemplateById(id: string): Template | undefined {\n    return templates.value.find(t => t.id === id)\n  }\n\n  /**\n   * 更新模板\n   */\n  function updateTemplate(id: string, params: UpdateTemplateParams): boolean {\n    const index = templates.value.findIndex(t => t.id === id)\n    if (index === -1) {\n      toast.error(`模板不存在`)\n      return false\n    }\n\n    templates.value[index] = {\n      ...templates.value[index],\n      ...params,\n      updatedAt: Date.now(),\n    }\n\n    toast.success(`模板已更新`)\n    return true\n  }\n\n  /**\n   * 删除模板\n   */\n  function deleteTemplate(id: string): boolean {\n    const index = templates.value.findIndex(t => t.id === id)\n    if (index === -1) {\n      toast.error(`模板不存在`)\n      return false\n    }\n\n    const templateName = templates.value[index].name\n    templates.value.splice(index, 1)\n    toast.success(`模板「${templateName}」已删除`)\n    return true\n  }\n\n  /**\n   * 根据名称搜索模板\n   */\n  function searchTemplates(keyword: string): Template[] {\n    if (!keyword.trim()) {\n      return sortedTemplates.value\n    }\n\n    const lowerKeyword = keyword.toLowerCase()\n    return sortedTemplates.value.filter((template) => {\n      return (\n        template.name.toLowerCase().includes(lowerKeyword)\n        || template.description?.toLowerCase().includes(lowerKeyword)\n        || template.tags?.some(tag => tag.toLowerCase().includes(lowerKeyword))\n      )\n    })\n  }\n\n  /**\n   * 批量删除模板\n   */\n  function deleteTemplates(ids: string[]): number {\n    let deletedCount = 0\n    ids.forEach((id) => {\n      const index = templates.value.findIndex(t => t.id === id)\n      if (index !== -1) {\n        templates.value.splice(index, 1)\n        deletedCount++\n      }\n    })\n\n    if (deletedCount > 0) {\n      toast.success(`已删除 ${deletedCount} 个模板`)\n    }\n\n    return deletedCount\n  }\n\n  /**\n   * 清空所有模板\n   */\n  function clearAllTemplates(): void {\n    const count = templates.value.length\n    templates.value = []\n    toast.success(`已清空所有模板（共 ${count} 个）`)\n  }\n\n  /**\n   * 导出所有模板为 JSON\n   */\n  function exportTemplates(): string {\n    return JSON.stringify(templates.value, null, 2)\n  }\n\n  /**\n   * 从 JSON 导入模板\n   */\n  function importTemplates(jsonData: string): boolean {\n    try {\n      const importedTemplates = JSON.parse(jsonData) as Template[]\n\n      if (!Array.isArray(importedTemplates)) {\n        toast.error(`导入失败：数据格式不正确`)\n        return false\n      }\n\n      // 验证每个模板的必需字段\n      const validTemplates = importedTemplates.filter((t) => {\n        return t.id && t.name && t.content && t.createdAt && t.updatedAt\n      })\n\n      if (validTemplates.length === 0) {\n        toast.error(`导入失败：没有有效的模板数据`)\n        return false\n      }\n\n      // 合并模板（避免 ID 重复）\n      validTemplates.forEach((importedTemplate) => {\n        const existingIndex = templates.value.findIndex(t => t.id === importedTemplate.id)\n        if (existingIndex !== -1) {\n          // ID 重复，生成新 ID\n          templates.value.push({\n            ...importedTemplate,\n            id: uuidv4(),\n          })\n        }\n        else {\n          templates.value.push(importedTemplate)\n        }\n      })\n\n      toast.success(`成功导入 ${validTemplates.length} 个模板`)\n      return true\n    }\n    catch (error) {\n      console.error(`Import templates failed:`, error)\n      toast.error(`导入失败：数据解析错误`)\n      return false\n    }\n  }\n\n  return {\n    // 状态\n    templates,\n\n    // 计算属性\n    sortedTemplates,\n    templateCount,\n\n    // 方法\n    createTemplate,\n    getTemplateById,\n    updateTemplate,\n    deleteTemplate,\n    searchTemplates,\n    deleteTemplates,\n    clearAllTemplates,\n    exportTemplates,\n    importTemplates,\n  }\n})\n"
  },
  {
    "path": "apps/web/src/stores/theme.ts",
    "content": "import type { HeadingLevel, HeadingStyles, HeadingStyleType, ThemeName } from '@md/shared/configs'\nimport { applyTheme } from '@md/core'\nimport { defaultStyleConfig, widthOptions } from '@md/shared/configs'\nimport { useCssEditorStore } from '@/stores/cssEditor'\nimport { addPrefix } from '@/utils'\nimport { store } from '@/utils/storage'\n\n/**\n * 主题和样式配置 Store\n * 负责管理所有与主题、字体、颜色相关的配置\n */\nexport const useThemeStore = defineStore(`theme`, () => {\n  // 文本主题\n  const theme = store.reactive<ThemeName>(addPrefix(`theme`), defaultStyleConfig.theme)\n\n  // 文本字体\n  const fontFamily = store.reactive(`fonts`, defaultStyleConfig.fontFamily)\n\n  // 文本大小\n  const fontSize = store.reactive(`size`, defaultStyleConfig.fontSize)\n\n  // 主色\n  const primaryColor = store.reactive(`color`, defaultStyleConfig.primaryColor)\n\n  // 代码块主题\n  const codeBlockTheme = store.reactive(`codeBlockTheme`, defaultStyleConfig.codeBlockTheme)\n\n  // 图注格式\n  const legend = store.reactive(`legend`, defaultStyleConfig.legend)\n\n  // 是否开启 Mac 代码块\n  const isMacCodeBlock = store.reactive(`isMacCodeBlock`, defaultStyleConfig.isMacCodeBlock)\n\n  // 是否开启代码块行号显示\n  const isShowLineNumber = store.reactive(`isShowLineNumber`, defaultStyleConfig.isShowLineNumber)\n\n  // 是否开启微信外链接底部引用\n  const isCiteStatus = store.reactive(`isCiteStatus`, defaultStyleConfig.isCiteStatus)\n\n  // 是否统计字数和阅读时间\n  const isCountStatus = store.reactive(`isCountStatus`, defaultStyleConfig.isCountStatus)\n\n  // 是否开启段落首行缩进\n  const isUseIndent = store.reactive(addPrefix(`use_indent`), false)\n\n  // 是否开启两端对齐\n  const isUseJustify = store.reactive(addPrefix(`use_justify`), false)\n\n  // 预览宽度\n  const previewWidth = store.reactive(`previewWidth`, widthOptions[0].value)\n\n  // 标题样式\n  const headingStyles = store.reactive<HeadingStyles>(`headingStyles`, defaultStyleConfig.headingStyles)\n\n  // 计算属性\n  const fontSizeNumber = computed(() => Number(fontSize.value.replace(`px`, ``)))\n\n  // Toggle 方法\n  const toggleMacCodeBlock = useToggle(isMacCodeBlock)\n  const toggleShowLineNumber = useToggle(isShowLineNumber)\n  const toggleCiteStatus = useToggle(isCiteStatus)\n  const toggleCountStatus = useToggle(isCountStatus)\n  const toggleUseIndent = useToggle(isUseIndent)\n  const toggleUseJustify = useToggle(isUseJustify)\n\n  // 重置样式\n  const resetStyle = () => {\n    isCiteStatus.value = defaultStyleConfig.isCiteStatus\n    isMacCodeBlock.value = defaultStyleConfig.isMacCodeBlock\n    isShowLineNumber.value = defaultStyleConfig.isShowLineNumber\n    isCountStatus.value = defaultStyleConfig.isCountStatus\n\n    theme.value = defaultStyleConfig.theme\n    fontFamily.value = defaultStyleConfig.fontFamily\n    fontSize.value = defaultStyleConfig.fontSize\n    primaryColor.value = defaultStyleConfig.primaryColor\n    codeBlockTheme.value = defaultStyleConfig.codeBlockTheme\n    legend.value = defaultStyleConfig.legend\n    headingStyles.value = { ...defaultStyleConfig.headingStyles }\n\n    isUseIndent.value = false\n    isUseJustify.value = false\n  }\n\n  // 设置标题样式\n  const setHeadingStyle = (level: HeadingLevel, style: HeadingStyleType) => {\n    headingStyles.value = {\n      ...headingStyles.value,\n      [level]: style === `default` ? undefined : style,\n    }\n  }\n\n  // 获取标题样式\n  const getHeadingStyle = (level: HeadingLevel): HeadingStyleType => {\n    return headingStyles.value[level] || `default`\n  }\n\n  // 切换 highlight.js 代码主题\n  const updateCodeTheme = () => {\n    const cssUrl = codeBlockTheme.value\n    const el = document.querySelector(`#hljs`)\n\n    if (el) {\n      el.setAttribute(`href`, cssUrl)\n    }\n    else {\n      const link = document.createElement(`link`)\n      link.setAttribute(`type`, `text/css`)\n      link.setAttribute(`rel`, `stylesheet`)\n      link.setAttribute(`href`, cssUrl)\n      link.setAttribute(`id`, `hljs`)\n      document.head.appendChild(link)\n    }\n  }\n\n  /**\n   * 应用当前主题配置（新主题系统）\n   * 使用 CSS 注入而非内联样式\n   */\n  const applyCurrentTheme = async () => {\n    try {\n      const cssEditorStore = useCssEditorStore()\n      const customCSS = cssEditorStore.getCurrentTabContent()\n\n      await applyTheme({\n        themeName: theme.value,\n        customCSS,\n        variables: {\n          primaryColor: primaryColor.value,\n          fontFamily: fontFamily.value,\n          fontSize: fontSize.value,\n          isUseIndent: isUseIndent.value,\n          isUseJustify: isUseJustify.value,\n          headingStyles: headingStyles.value,\n        },\n      })\n    }\n    catch (error) {\n      console.error(`[applyCurrentTheme] 主题应用失败:`, error)\n    }\n  }\n\n  return {\n    // State\n    theme,\n    fontFamily,\n    fontSize,\n    fontSizeNumber,\n    primaryColor,\n    codeBlockTheme,\n    legend,\n    isMacCodeBlock,\n    isShowLineNumber,\n    isCiteStatus,\n    isCountStatus,\n    isUseIndent,\n    isUseJustify,\n    previewWidth,\n    headingStyles,\n\n    // Actions\n    toggleMacCodeBlock,\n    toggleShowLineNumber,\n    toggleCiteStatus,\n    toggleCountStatus,\n    toggleUseIndent,\n    toggleUseJustify,\n    resetStyle,\n    updateCodeTheme,\n    applyCurrentTheme,\n    setHeadingStyle,\n    getHeadingStyle,\n  }\n})\n"
  },
  {
    "path": "apps/web/src/stores/ui.ts",
    "content": "import { addPrefix } from '@/utils'\nimport { store } from '@/utils/storage'\n\n/**\n * UI 状态 Store\n * 负责管理全局 UI 状态，包括深色模式、侧边栏、对话框等\n */\nexport const useUIStore = defineStore(`ui`, () => {\n  // ==================== 全局 UI 状态 ====================\n  // 是否开启深色模式\n  const isDark = useDark()\n  const toggleDark = useToggle(isDark)\n\n  // 是否在左侧编辑\n  const isEditOnLeft = store.reactive(`isEditOnLeft`, true)\n  const toggleEditOnLeft = useToggle(isEditOnLeft)\n\n  // 是否开启 AI 工具箱\n  const showAIToolbox = store.reactive(`showAIToolbox`, true)\n  const toggleAIToolbox = useToggle(showAIToolbox)\n\n  // 是否已经显示过 AI 工具箱选中文本提示\n  const hasShownAIToolboxHint = store.reactive(`hasShownAIToolboxHint`, false)\n\n  // 是否打开右侧滑块\n  const isOpenRightSlider = store.reactive(addPrefix(`is_open_right_slider`), false)\n\n  // 是否打开文章列表滑块\n  const isOpenPostSlider = store.reactive(addPrefix(`is_open_post_slider`), false)\n\n  // 是否打开本地文件夹面板\n  const isOpenFolderPanel = store.reactive(addPrefix(`is_open_folder_panel`), false)\n\n  // 是否为移动端\n  const isMobile = store.reactive(`isMobile`, false)\n\n  // 是否固定显示浮动目录\n  const isPinFloatingToc = store.reactive(addPrefix(`isPinFloatingToc`), false)\n  const togglePinFloatingToc = useToggle(isPinFloatingToc)\n\n  // 是否显示浮动目录\n  const isShowFloatingToc = store.reactive(addPrefix(`isShowFloatingToc`), true)\n  const toggleShowFloatingToc = useToggle(isShowFloatingToc)\n\n  // 是否启用图片转存（默认关闭）\n  const enableImageReupload = store.reactive(addPrefix(`enableImageReupload`), false)\n  const toggleImageReupload = useToggle(enableImageReupload)\n\n  // ==================== 对话框状态 ====================\n  // 是否展示 CSS 编辑器\n  const isShowCssEditor = store.reactive(`isShowCssEditor`, false)\n  const toggleShowCssEditor = useToggle(isShowCssEditor)\n\n  // 是否展示插入表格对话框\n  const isShowInsertFormDialog = ref(false)\n  const toggleShowInsertFormDialog = useToggle(isShowInsertFormDialog)\n\n  // 是否展示插入公众号名片对话框\n  const isShowInsertMpCardDialog = ref(false)\n  const toggleShowInsertMpCardDialog = useToggle(isShowInsertMpCardDialog)\n\n  // 是否展示上传图片对话框\n  const isShowUploadImgDialog = ref(false)\n  const toggleShowUploadImgDialog = useToggle(isShowUploadImgDialog)\n\n  // 是否展示导入 Markdown 对话框\n  const isShowImportMdDialog = ref(false)\n  const toggleShowImportMdDialog = useToggle(isShowImportMdDialog)\n  /** 通过 URL 参数 open 打开时传入的待导入链接，对话框打开后会据此自动执行导入 */\n  const importMdOpenUrl = ref<string | null>(null)\n\n  // 是否展示模板管理对话框\n  const isShowTemplateDialog = ref(false)\n  const toggleShowTemplateDialog = useToggle(isShowTemplateDialog)\n\n  // 是否打开重置样式确认对话框\n  const isOpenConfirmDialog = ref(false)\n\n  // AI 对话框\n  const aiDialogVisible = ref(false)\n  const aiImageDialogVisible = ref(false)\n\n  function toggleAIDialog(value?: boolean) {\n    aiDialogVisible.value = value ?? !aiDialogVisible.value\n  }\n\n  function toggleAIImageDialog(value?: boolean) {\n    aiImageDialogVisible.value = value ?? !aiImageDialogVisible.value\n  }\n\n  // 搜索面板状态\n  const searchTabRequest = ref<{ word: string, showReplace: boolean } | null>(null)\n\n  function openSearchTab(searchWord: string = '', showReplace: boolean = false) {\n    searchTabRequest.value = { word: searchWord, showReplace }\n  }\n\n  function clearSearchTabRequest() {\n    searchTabRequest.value = null\n  }\n\n  // ==================== 工具函数 ====================\n  // 处理窗口大小变化\n  function handleResize() {\n    isMobile.value = window.innerWidth <= 768\n  }\n\n  onMounted(() => {\n    handleResize()\n    window.addEventListener(`resize`, handleResize)\n  })\n\n  onBeforeUnmount(() => {\n    window.removeEventListener(`resize`, handleResize)\n  })\n\n  return {\n    // ==================== 全局 UI 状态 ====================\n    isDark,\n    isEditOnLeft,\n    showAIToolbox,\n    hasShownAIToolboxHint,\n    isOpenRightSlider,\n    isOpenPostSlider,\n    isMobile,\n    isPinFloatingToc,\n    isShowFloatingToc,\n    isOpenFolderPanel,\n    enableImageReupload,\n\n    // ==================== 对话框状态 ====================\n    isShowCssEditor,\n    toggleShowCssEditor,\n    isShowInsertFormDialog,\n    toggleShowInsertFormDialog,\n    isShowInsertMpCardDialog,\n    toggleShowInsertMpCardDialog,\n    isShowUploadImgDialog,\n    toggleShowUploadImgDialog,\n    isShowImportMdDialog,\n    toggleShowImportMdDialog,\n    importMdOpenUrl,\n    isShowTemplateDialog,\n    toggleShowTemplateDialog,\n    isOpenConfirmDialog,\n    aiDialogVisible,\n    toggleAIDialog,\n    aiImageDialogVisible,\n    toggleAIImageDialog,\n\n    // ==================== 搜索面板 ====================\n    searchTabRequest,\n    openSearchTab,\n    clearSearchTabRequest,\n\n    // ==================== Actions ====================\n    toggleDark,\n    toggleEditOnLeft,\n    toggleAIToolbox,\n    togglePinFloatingToc,\n    toggleShowFloatingToc,\n    toggleImageReupload,\n  }\n})\n"
  },
  {
    "path": "apps/web/src/types/global.d.ts",
    "content": "interface Window {\n  __MP_Editor_JSAPI__: {\n    invoke: (params: {\n      apiName: string\n      apiParam: any\n      sucCb: (res: any) => void\n      errCb: (err: any) => void\n    }) => void\n  }\n\n  // File System Access API\n  showDirectoryPicker: (options?: {\n    mode?: 'read' | 'readwrite'\n    startIn?: 'desktop' | 'documents' | 'downloads' | 'music' | 'pictures' | 'videos'\n  }) => Promise<FileSystemDirectoryHandle>\n}\n\n// Extend FileSystemDirectoryHandle with full File System Access API methods\ninterface FileSystemDirectoryHandle {\n  // Permission management\n  requestPermission: (descriptor?: { mode?: 'read' | 'readwrite' }) => Promise<PermissionState>\n\n  // Directory operations\n  getDirectoryHandle: (name: string, options?: { create?: boolean }) => Promise<FileSystemDirectoryHandle>\n  getFileHandle: (name: string, options?: { create?: boolean }) => Promise<FileSystemFileHandle>\n  removeEntry: (name: string, options?: { recursive?: boolean }) => Promise<void>\n\n  // Resolve path\n  resolve: (fileSystemHandle: FileSystemHandle) => Promise<string[] | null>\n\n  // Async iteration\n  values: () => AsyncIterableIterator<FileSystemHandle>\n  [Symbol.asyncIterator]: () => AsyncIterableIterator<FileSystemHandle>\n}\n"
  },
  {
    "path": "apps/web/src/utils/clipboard.ts",
    "content": "function legacyCopy(text: string): Promise<void> {\n  return new Promise((resolve, reject) => {\n    try {\n      const textarea = document.createElement(`textarea`)\n      textarea.value = text\n      textarea.setAttribute(`readonly`, `true`)\n      textarea.style.position = `fixed`\n      textarea.style.opacity = `0`\n      document.body.appendChild(textarea)\n\n      textarea.select()\n      const ok = document.execCommand(`copy`)\n      document.body.removeChild(textarea)\n\n      ok ? resolve() : reject(new Error(`execCommand failed`))\n    }\n    catch (err) {\n      reject(err)\n    }\n  })\n}\n\nexport async function copyPlain(text: string): Promise<void> {\n  if (window.isSecureContext && navigator.clipboard?.writeText) {\n    try {\n      await navigator.clipboard.writeText(text)\n      return\n    }\n    catch {\n    }\n  }\n  await legacyCopy(text)\n}\n\nexport async function copyHtml(html: string, fallback?: string): Promise<void> {\n  const plain = fallback ?? html.replace(/<[^>]+>/g, ``)\n  if (window.isSecureContext && navigator.clipboard?.write) {\n    try {\n      const item = new ClipboardItem({\n        'text/html': new Blob([html], { type: `text/html` }),\n        'text/plain': new Blob([plain], { type: `text/plain` }),\n      })\n      await navigator.clipboard.write([item])\n      return\n    }\n    catch {\n    }\n  }\n  await copyPlain(plain)\n}\n"
  },
  {
    "path": "apps/web/src/utils/file.ts",
    "content": "import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'\nimport { getSignedUrl } from '@aws-sdk/s3-request-presigner'\nimport { giteeConfig, githubConfig } from '@md/shared/configs'\n\nimport fetch from '@md/shared/utils/fetch'\nimport * as tokenTools from '@md/shared/utils/tokenTools'\nimport { base64encode, safe64, utf16to8 } from '@md/shared/utils/tokenTools'\nimport Buffer from 'buffer-from'\nimport CryptoJS from 'crypto-js'\nimport * as qiniu from 'qiniu-js'\nimport { v4 as uuidv4 } from 'uuid'\nimport { store } from './storage'\n\nasync function getConfig(useDefault: boolean, platform: string) {\n  if (useDefault) {\n    // load default config file\n    const config = platform === `github` ? githubConfig : giteeConfig\n    const { username, repoList, branch, accessTokenList } = config\n\n    // choose random token from access_token list\n    const tokenIndex = Math.floor(Math.random() * accessTokenList.length)\n    const accessToken = accessTokenList[tokenIndex].replace(`doocsmd`, ``)\n\n    // choose random repo from repo list\n    const repoIndex = Math.floor(Math.random() * repoList.length)\n    const repo = repoList[repoIndex]\n\n    return { username, repo, branch, accessToken }\n  }\n\n  // load configuration from storage\n  const customConfig = await store.getJSON<any>(`${platform}Config`, {}) || {}\n\n  // split username/repo\n  const repoUrl = customConfig.repo\n    .replace(`https://${platform}.com/`, ``)\n    .replace(`http://${platform}.com/`, ``)\n    .replace(`${platform}.com/`, ``)\n    .split(`/`)\n  return {\n    username: repoUrl[0],\n    repo: repoUrl[1],\n    branch: customConfig.branch || `master`,\n    accessToken: customConfig.accessToken,\n    useCDN: customConfig.useCDN ?? false,\n  }\n}\n\n/**\n * 获取 `年/月/日` 形式的目录\n * @returns string\n */\nfunction getDir() {\n  const date = new Date()\n  const year = date.getFullYear()\n  const month = (date.getMonth() + 1).toString().padStart(2, `0`)\n  const day = date.getDate().toString().padStart(2, `0`)\n  return `${year}/${month}/${day}`\n}\n\n/**\n * 根据文件名获取它以 `时间戳+uuid` 的形式\n * @param {string} filename 文件名\n * @returns {string} `时间戳+uuid`\n */\nfunction getDateFilename(filename: string) {\n  const currentTimestamp = Date.now()\n  // 获取最后一个点号后的内容作为文件扩展名\n  const fileSuffix = filename.split(`.`).pop()\n  return `${currentTimestamp}-${uuidv4()}.${fileSuffix}`\n}\n\n// -----------------------------------------------------------------------\n// GitHub File Upload\n// -----------------------------------------------------------------------\n\nasync function ghFileUpload(content: string, filename: string) {\n  const useDefault = await store.get(`imgHost`) === `default`\n  const { username, repo, branch, accessToken, useCDN } = await getConfig(\n    useDefault,\n    `github`,\n  )\n  const dir = getDir()\n  const url = `https://api.github.com/repos/${username}/${repo}/contents/${dir}/`\n  const dateFilename = getDateFilename(filename)\n  const res = await fetch<{ content: {\n    download_url: string\n  } }, {\n    content: {\n      download_url: string\n    }\n    data?: {\n      content: {\n        download_url: string\n      }\n    }\n  }>({\n    url: url + dateFilename,\n    method: `put`,\n    headers: {\n      Authorization: `token ${accessToken}`,\n    },\n    data: {\n      content,\n      branch,\n      message: `Upload by ${window.location.href}`,\n    },\n  })\n  const githubResourceUrl = `raw.githubusercontent.com/${username}/${repo}/${branch}/`\n  const cdnResourceUrl = `fastly.jsdelivr.net/gh/${username}/${repo}@${branch}/`\n  res.content = res.data?.content || res.content\n  const shouldUseCDN = useDefault || useCDN\n  return shouldUseCDN\n    ? res.content.download_url.replace(githubResourceUrl, cdnResourceUrl)\n    : res.content.download_url\n}\n\n// -----------------------------------------------------------------------\n// Gitee File Upload\n// -----------------------------------------------------------------------\n\nasync function giteeUpload(content: any, filename: string) {\n  const useDefault = await store.get(`imgHost`) === `default`\n  const { username, repo, branch, accessToken } = await getConfig(useDefault, `gitee`)\n  const dir = getDir()\n  const dateFilename = getDateFilename(filename)\n  const url = `https://gitee.com/api/v5/repos/${username}/${repo}/contents/${dir}/${dateFilename}`\n  const res = await fetch<{ content: {\n    download_url: string\n  } }, {\n    content: {\n      download_url: string\n    }\n    data: {\n      content: {\n        download_url: string\n      }\n    }\n  }>({\n    url,\n    method: `POST`,\n    data: {\n      content,\n      branch,\n      access_token: accessToken,\n      message: `Upload by ${window.location.href}`,\n    },\n  })\n  res.content = res.data?.content || res.content\n  return encodeURI(res.content.download_url)\n}\n\n// -----------------------------------------------------------------------\n// Qiniu File Upload\n// -----------------------------------------------------------------------\n\nfunction getQiniuToken(accessKey: string, secretKey: string, putPolicy: {\n  scope: string\n  deadline: number\n}) {\n  const policy = JSON.stringify(putPolicy)\n  const encoded = base64encode(utf16to8(policy))\n  const hash = CryptoJS.HmacSHA1(encoded, secretKey)\n  const encodedSigned = hash.toString(CryptoJS.enc.Base64)\n  return `${accessKey}:${safe64(encodedSigned)}:${encoded}`\n}\n\nasync function qiniuUpload(file: File) {\n  const configStr = await store.get(`qiniuConfig`)\n  const { accessKey, secretKey, bucket, region, path, domain } = JSON.parse(configStr!)\n  const token = getQiniuToken(accessKey, secretKey, {\n    scope: bucket,\n    deadline: Math.trunc(Date.now() / 1000) + 3600,\n  })\n  const dir = path ? `${path}/` : ``\n  const dateFilename = dir + getDateFilename(file.name)\n  const observable = qiniu.upload(file, dateFilename, token, {}, { region })\n  return new Promise<string>((resolve, reject) => {\n    observable.subscribe({\n      next: (result) => {\n        console.log(result)\n      },\n      error: (err) => {\n        reject(err.message)\n      },\n      complete: (result) => {\n        resolve(`${domain}/${result.key}`)\n      },\n    })\n  })\n}\n\n// -----------------------------------------------------------------------\n// AliOSS File Upload\n// -----------------------------------------------------------------------\n\nasync function aliOSSFileUpload(file: File) {\n  const dateFilename = getDateFilename(file.name)\n  const config = await store.getJSON(`aliOSSConfig`, { region: ``, bucket: ``, accessKeyId: ``, accessKeySecret: ``, useSSL: true, cdnHost: ``, path: `` })\n  const { region, bucket, accessKeyId, accessKeySecret, useSSL, cdnHost, path }\n    = config || { region: ``, bucket: ``, accessKeyId: ``, accessKeySecret: ``, useSSL: true, cdnHost: ``, path: `` }\n\n  // Transform aliOSSConfig to s3Config format\n  // Aliyun OSS endpoints follow pattern: https://<bucket>.<region>.aliyuncs.com or https://<region>.aliyuncs.com\n  const secure = useSSL === undefined || useSSL\n  const protocol = secure ? `https` : `http`\n  const endpoint = `${protocol}://${region}.aliyuncs.com`\n\n  const clientConfig: any = {\n    region,\n    credentials: {\n      accessKeyId,\n      secretAccessKey: accessKeySecret,\n    },\n    endpoint,\n    forcePathStyle: false, // OSS recommends virtual-hosted style\n  }\n\n  const s3Client = new S3Client(clientConfig)\n\n  const dir = path ? `${path}/` : ``\n  const key = dir + dateFilename\n\n  const command = new PutObjectCommand({\n    Bucket: bucket,\n    Key: key,\n    ContentType: file.type,\n  })\n\n  try {\n    const presignedUrl = await getSignedUrl(s3Client, command, { expiresIn: 300 })\n    const response = await window.fetch(presignedUrl, {\n      method: `PUT`,\n      headers: {\n        'Content-Type': file.type,\n      },\n      body: file,\n    })\n\n    if (!response.ok) {\n      throw new Error(`Upload failed: ${response.statusText}`)\n    }\n\n    if (cdnHost) {\n      const host = cdnHost.endsWith('/') ? cdnHost.slice(0, -1) : cdnHost\n      return `${host}/${key}`\n    }\n\n    // Default OSS URL format\n    return `${protocol}://${bucket}.${region}.aliyuncs.com/${key}`\n  }\n  catch (e) {\n    return Promise.reject(e)\n  }\n}\n\n// -----------------------------------------------------------------------\n// TxCOS File Upload\n// -----------------------------------------------------------------------\n\nasync function txCOSFileUpload(file: File) {\n  const dateFilename = getDateFilename(file.name)\n  const configStr = await store.get(`txCOSConfig`)\n  const { secretId, secretKey, bucket, region, path, cdnHost } = JSON.parse(configStr!)\n\n  // Transform txCOSConfig to S3 format\n  // Tencent Cloud COS S3 endpoint: https://cos.<Region>.myqcloud.com\n  const endpoint = `https://cos.${region}.myqcloud.com`\n\n  const clientConfig: any = {\n    region,\n    credentials: {\n      accessKeyId: secretId,\n      secretAccessKey: secretKey,\n    },\n    endpoint,\n    forcePathStyle: false, // COS supports virtual-hosted style\n  }\n\n  const s3Client = new S3Client(clientConfig)\n\n  const dir = path ? `${path}/` : ``\n  const key = dir + dateFilename\n\n  const command = new PutObjectCommand({\n    Bucket: bucket,\n    Key: key,\n    ContentType: file.type,\n  })\n\n  try {\n    const presignedUrl = await getSignedUrl(s3Client, command, { expiresIn: 300 })\n    const response = await window.fetch(presignedUrl, {\n      method: `PUT`,\n      headers: {\n        'Content-Type': file.type,\n      },\n      body: file,\n    })\n\n    if (!response.ok) {\n      throw new Error(`Upload failed: ${response.statusText}`)\n    }\n\n    if (cdnHost) {\n      return path === ``\n        ? `${cdnHost}/${dateFilename}`\n        : `${cdnHost}/${path}/${dateFilename}`\n    }\n\n    // Default COS URL: https://<BucketName-APPID>.cos.<Region>.myqcloud.com/<Key>\n    // The 'bucket' param in COS usually is 'name-appid', if not, user might need to check.\n    // However, for S3 client, we just use the bucket name provided.\n\n    return `https://${bucket}.cos.${region}.myqcloud.com/${key}`\n  }\n  catch (e) {\n    return Promise.reject(e)\n  }\n}\n\n// -----------------------------------------------------------------------\n// Minio File Upload\n// -----------------------------------------------------------------------\n\nasync function minioFileUpload(file: File) {\n  const dateFilename = getDateFilename(file.name)\n  const configStr = await store.get(`minioConfig`)\n  const { endpoint, port, useSSL, bucket, accessKey, secretKey } = JSON.parse(configStr!)\n  const s3Client = new S3Client({\n    endpoint: `${useSSL ? `https` : `http`}://${endpoint}${port ? `:${port}` : ``}`,\n    credentials: {\n      accessKeyId: accessKey,\n      secretAccessKey: secretKey,\n    },\n    region: `auto`,\n    forcePathStyle: true,\n  })\n\n  const command = new PutObjectCommand({\n    Bucket: bucket,\n    Key: dateFilename,\n    ContentType: file.type,\n  })\n  const presignedUrl = await getSignedUrl(s3Client, command, { expiresIn: 300 })\n  await fetch(presignedUrl, {\n    method: `PUT`,\n    headers: {\n      'Content-Type': file.type,\n    },\n    data: file,\n  }).catch((err) => { console.log(err) })\n  return `${useSSL ? `https` : `http`}://${endpoint}${port ? `:${port}` : ``}/${bucket}/${dateFilename}`\n}\n\n// -----------------------------------------------------------------------\n// S3 File Upload\n// -----------------------------------------------------------------------\n\nconst PROTOCOL_REGEX = /^https?:\\/\\//\n\nasync function s3Upload(file: File) {\n  const dateFilename = getDateFilename(file.name)\n  const config = await store.getJSON(`s3Config`, {\n    endpoint: ``,\n    region: ``,\n    bucket: ``,\n    accessKeyId: ``,\n    accessKeySecret: ``,\n    path: ``,\n    cdnHost: ``,\n    pathStyle: false,\n  })\n  const { endpoint, region, bucket, accessKeyId, accessKeySecret, path, cdnHost, pathStyle } = config\n\n  const clientConfig: any = {\n    region,\n    credentials: {\n      accessKeyId,\n      secretAccessKey: accessKeySecret,\n    },\n    forcePathStyle: pathStyle,\n  }\n\n  if (endpoint) {\n    clientConfig.endpoint = endpoint.startsWith('http') ? endpoint : `https://${endpoint}`\n  }\n\n  const s3Client = new S3Client(clientConfig)\n\n  const dir = path ? `${path}/` : ``\n  const key = dir + dateFilename\n\n  const command = new PutObjectCommand({\n    Bucket: bucket,\n    Key: key,\n    ContentType: file.type,\n  })\n\n  try {\n    const presignedUrl = await getSignedUrl(s3Client, command, { expiresIn: 300 })\n    const response = await window.fetch(presignedUrl, {\n      method: `PUT`,\n      headers: {\n        'Content-Type': file.type,\n      },\n      body: file,\n    })\n\n    if (!response.ok) {\n      throw new Error(`Upload failed: ${response.statusText}`)\n    }\n\n    if (cdnHost) {\n      const host = cdnHost.endsWith('/') ? cdnHost.slice(0, -1) : cdnHost\n      return `${host}/${key}`\n    }\n\n    if (endpoint) {\n      const proto = clientConfig.endpoint.startsWith('https') ? 'https' : 'http'\n      const host = clientConfig.endpoint.replace(PROTOCOL_REGEX, '')\n      if (pathStyle) {\n        return `${proto}://${host}/${bucket}/${key}`\n      }\n      else {\n        return `${proto}://${bucket}.${host}/${key}`\n      }\n    }\n\n    return `https://${bucket}.s3.${region}.amazonaws.com/${key}`\n  }\n  catch (err) {\n    console.error(err)\n    throw err\n  }\n}\n\n// -----------------------------------------------------------------------\n// mp File Upload\n// -----------------------------------------------------------------------\ninterface MpResponse {\n  access_token: string\n  expires_in: number\n  errcode: number\n  errmsg: string\n}\nasync function getMpToken(appID: string, appsecret: string, proxyOrigin: string) {\n  const data = await store.get(`mpToken:${appID}`)\n  if (data) {\n    const token = JSON.parse(data)\n    if (token.expire && token.expire > Date.now()) {\n      return token.access_token\n    }\n  }\n  const requestOptions = {\n    method: `POST`,\n    data: {\n      grant_type: `client_credential`,\n      appid: appID,\n      secret: appsecret,\n    },\n  }\n  let url = `https://api.weixin.qq.com/cgi-bin/stable_token`\n  if (proxyOrigin) {\n    url = `${proxyOrigin}/cgi-bin/stable_token`\n  }\n  const res = await fetch<any, MpResponse>(url, requestOptions)\n  if (res.access_token) {\n    const tokenInfo = {\n      ...res,\n      expire: Date.now() + res.expires_in * 1000,\n    }\n    await store.setJSON(`mpToken:${appID}`, tokenInfo)\n    return res.access_token\n  }\n  return ``\n}\n// Cloudflare Workers 环境\nconst isCfWorkers = import.meta.env.CF_WORKERS === `1`\n\nasync function mpFileUpload(file: File) {\n  const configStr = await store.get(`mpConfig`)\n  let { appID, appsecret, proxyOrigin } = JSON.parse(configStr!)\n  // 未填写代理域名且是 Cloudflare Workers 环境时，使用当前域名作为代理域名\n  if (!proxyOrigin && isCfWorkers) {\n    proxyOrigin = window.location.origin\n  }\n  const access_token = await getMpToken(appID, appsecret, proxyOrigin)\n  if (!access_token) {\n    throw new Error(`获取 access_token 失败`)\n  }\n\n  const formdata = new FormData()\n  formdata.append(`media`, file, file.name)\n\n  const requestOptions = {\n    method: `POST`,\n    data: formdata,\n  }\n\n  let url = `https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=${access_token}&type=image`\n  const fileSizeInMB = file.size / (1024 * 1024)\n  const fileType = file.type.toLowerCase()\n  if (fileSizeInMB < 1 && (fileType === `image/jpeg` || fileType === `image/png`)) {\n    url = `https://api.weixin.qq.com/cgi-bin/media/uploadimg?access_token=${access_token}`\n  }\n  if (proxyOrigin) {\n    url = url.replace(`https://api.weixin.qq.com`, proxyOrigin)\n  }\n\n  const res = await fetch<any, { url: string }>(url, requestOptions)\n\n  if (!res.url) {\n    throw new Error(`上传失败，未获取到URL`)\n  }\n\n  let imageUrl = res.url\n  if (proxyOrigin && window.location.href.startsWith(`http`)) {\n    imageUrl = `https://wsrv.nl?url=${encodeURIComponent(imageUrl)}`\n  }\n\n  return imageUrl\n}\n\n// -----------------------------------------------------------------------\n// Cloudflare R2 File Upload\n// -----------------------------------------------------------------------\n\nasync function r2Upload(file: File) {\n  const configStr = await store.get(`r2Config`)\n  const { accountId, accessKey, secretKey, bucket, path, domain } = JSON.parse(configStr!)\n  const dir = path ? `${path}/` : ``\n  const filename = dir + getDateFilename(file.name)\n  const client = new S3Client({ region: `auto`, endpoint: `https://${accountId}.r2.cloudflarestorage.com`, credentials: { accessKeyId: accessKey, secretAccessKey: secretKey } })\n  const signedUrl = await getSignedUrl(\n    client,\n    new PutObjectCommand({ Bucket: bucket, Key: filename, ContentType: file.type }),\n    { expiresIn: 300 },\n  )\n  await fetch(signedUrl, {\n    method: `PUT`,\n    headers: {\n      'Content-Type': file.type,\n    },\n    data: file,\n  }).catch((err) => { console.log(err) })\n  return `${domain}/${filename}`\n}\n\n// -----------------------------------------------------------------------\n// Upyun File Upload\n// -----------------------------------------------------------------------\n\nasync function upyunUpload(file: File) {\n  const configStr = await store.get(`upyunConfig`)\n  const { bucket, operator, password, path, domain } = JSON.parse(configStr!)\n  const filename = `${path}/${getDateFilename(file.name)}`\n  const uri = `/${bucket}/${filename}`\n  const arrayBuffer = await file.arrayBuffer()\n  const date = new Date().toUTCString()\n  const method = `PUT`\n  const signStr = [method, uri, date].join(`&`)\n  const passwordMd5 = CryptoJS.MD5(password).toString()\n  const signature = CryptoJS.HmacSHA1(signStr, passwordMd5).toString(CryptoJS.enc.Base64)\n  const authorization = `UPYUN ${operator}:${signature}`\n  const url = `https://v0.api.upyun.com${uri}`\n  const res = await window.fetch(url, {\n    method: `PUT`,\n    headers: {\n      'Authorization': authorization,\n      'X-Date': date,\n      'Content-Type': file.type,\n    },\n    body: arrayBuffer,\n  })\n\n  if (!res.ok) {\n    throw new Error(`上传失败: ${await res.text()}`)\n  }\n\n  return `${domain}/${filename}`\n}\n\n// -----------------------------------------------------------------------\n// Telegram File Upload\n// -----------------------------------------------------------------------\nasync function telegramUpload(file: File): Promise<string> {\n  const config = await store.getJSON(`telegramConfig`, { token: ``, chatId: `` })\n  const { token, chatId } = config || { token: ``, chatId: `` }\n\n  // 1. sendPhoto\n  const form = new FormData()\n  form.append(`chat_id`, chatId)\n  form.append(`photo`, file, file.name)\n\n  const sendRes = await fetch<any, {\n    ok: boolean\n    result: {\n      photo: { file_id: string }[]\n    }\n  }>({\n    url: `https://api.telegram.org/bot${token}/sendPhoto`,\n    method: `POST`,\n    data: form,\n  })\n\n  if (!sendRes.ok || !sendRes.result.photo.length) {\n    throw new Error(`Telegram sendPhoto 失败`)\n  }\n  // 取最大的分辨率那张图\n  const fileId = sendRes.result.photo[sendRes.result.photo.length - 1].file_id\n\n  // 2. getFile\n  const fileRes = await fetch<any, {\n    ok: boolean\n    result: { file_path: string }\n  }>({\n    url: `https://api.telegram.org/bot${token}/getFile?file_id=${fileId}`,\n    method: `GET`,\n  })\n  if (!fileRes.ok) {\n    throw new Error(`Telegram getFile 失败`)\n  }\n\n  const filePath = fileRes.result.file_path\n  // 3. 拼出下载地址\n  return `https://api.telegram.org/file/bot${token}/${filePath}`\n}\n\n// -----------------------------------------------------------------------\n// Cloudinary File Upload\n// -----------------------------------------------------------------------\n\n/**\n * cloudinaryConfig 配置示例：\n * {\n *   \"cloudName\": \"demo\",\n *   \"apiKey\": \"1234567890\",\n *   \"apiSecret\": \"abcdefg1234567890\",     // 可选：若未填写则走 unsigned preset\n *   \"uploadPreset\": \"unsigned_preset\",     // 可选：有 apiSecret 时可省略\n *   \"folder\": \"blog/image\",                // 可选：Cloudinary 目录，留空则根路径\n *   \"domain\": \"https://cdn.example.com\"    // 可选：自定义访问域名 / CDN 域名\n * }\n */\nasync function cloudinaryUpload(file: File): Promise<string> {\n  const config = await store.getJSON(`cloudinaryConfig`, { cloudName: ``, apiKey: ``, apiSecret: ``, uploadPreset: ``, folder: ``, domain: `` })\n  const {\n    cloudName,\n    apiKey,\n    apiSecret,\n    uploadPreset,\n    folder = ``,\n    domain,\n  } = config || { cloudName: ``, apiKey: ``, apiSecret: ``, uploadPreset: ``, folder: ``, domain: `` }\n\n  if (!cloudName || !apiKey)\n    throw new Error(`Cloudinary 配置缺少 cloudName / apiKey`)\n\n  const timestamp = Math.floor(Date.now() / 1000) // Cloudinary 要求秒级时间戳\n  const formData = new FormData()\n  formData.append(`file`, file)\n  formData.append(`api_key`, apiKey)\n  formData.append(`timestamp`, `${timestamp}`)\n\n  // ---------- 1) 需要签名的场景 ----------\n  if (apiSecret) {\n    // 参与签名的字段需按字典序排列并拼接成 a=b&c=d… 的格式\n    const params: string[] = []\n    if (folder)\n      params.push(`folder=${folder}`)\n    if (uploadPreset)\n      params.push(`upload_preset=${uploadPreset}`)\n    params.push(`timestamp=${timestamp}`)\n\n    const signatureBase = params.sort().join(`&`)\n    const signature = CryptoJS.SHA1(signatureBase + apiSecret).toString()\n    formData.append(`signature`, signature)\n  }\n  // ---------- 2) unsigned preset ----------\n  else if (uploadPreset) {\n    formData.append(`upload_preset`, uploadPreset)\n  }\n  else {\n    throw new Error(`未配置 apiSecret 时必须提供 uploadPreset`)\n  }\n\n  if (folder)\n    formData.append(`folder`, folder)\n\n  const uploadUrl = `https://api.cloudinary.com/v1_1/${cloudName}/image/upload`\n  const res = await fetch<any, { secure_url?: string, url?: string }>(uploadUrl, {\n    method: `POST`,\n    data: formData,\n  })\n\n  const originUrl = res.secure_url || res.url\n  if (!originUrl)\n    throw new Error(`Cloudinary 返回缺少 url 字段`)\n\n  // 如果配置了自定义域名，则把 host 换掉\n  if (domain) {\n    const { pathname, search } = new URL(originUrl)\n    return `${domain}${pathname}${search}`\n  }\n\n  return originUrl\n}\n\n// -----------------------------------------------------------------------\n// formCustom File Upload\n// -----------------------------------------------------------------------\n\nasync function formCustomUpload(content: string, file: File) {\n  const customConfig = await store.get(`formCustomConfig`)\n  const str = `\n    async (CUSTOM_ARG) => {\n      ${customConfig}\n    }\n  `\n  return new Promise<string>((resolve, reject) => {\n    const exportObj = {\n      content, // 待上传图片的 base64\n      file, // 待上传图片的 file 对象\n      util: {\n        axios: fetch, // axios 实例\n        CryptoJS, // 加密库\n        // OSS, // OSS references removed\n        // COS, // COS references removed\n        Buffer, // buffer-from\n        uuidv4, // uuid\n        qiniu, // qiniu-js\n        tokenTools, // 一些编码转换函数\n        getDir, // 获取 年/月/日 形式的目录\n        getDateFilename, // 根据文件名获取它以 时间戳+uuid 的形式\n        S3: {\n          S3Client,\n          PutObjectCommand,\n          getSignedUrl,\n        },\n      },\n      okCb: resolve, // 重要: 上传成功后给此回调传 url 即可\n      errCb: reject, // 上传失败调用的函数\n    }\n    // Use Function constructor instead of eval\n    // eslint-disable-next-line no-new-func\n    const fn = new Function(`return ${str}`)()\n    fn(exportObj).catch((err: any) => {\n      console.error(err)\n      reject(err)\n    })\n  })\n}\n\nexport async function fileUpload(content: string, file: File) {\n  const imgHost = await store.get(`imgHost`)\n  if (!imgHost) {\n    await store.set(`imgHost`, `default`)\n  }\n  switch (imgHost) {\n    case `aliOSS`:\n      return aliOSSFileUpload(file)\n    case `minio`:\n      return minioFileUpload(file)\n    case `s3`:\n      return s3Upload(file)\n    case `txCOS`:\n      return txCOSFileUpload(file)\n    case `qiniu`:\n      return qiniuUpload(file)\n    case `gitee`:\n      return giteeUpload(content, file.name)\n    case `github`:\n      return ghFileUpload(content, file.name)\n    case `mp`:\n      return mpFileUpload(file)\n    case `r2`:\n      return r2Upload(file)\n    case `upyun`:\n      return upyunUpload(file)\n    case `telegram`:\n      return telegramUpload(file)\n    case `cloudinary`:\n      return cloudinaryUpload(file)\n    case `formCustom`:\n      return formCustomUpload(content, file)\n    default:\n      // return file.size / 1024 < 1024\n      //     ? giteeUpload(content, file.name)\n      //     : ghFileUpload(content, file.name);\n      return ghFileUpload(content, file.name)\n  }\n}\n"
  },
  {
    "path": "apps/web/src/utils/index.ts",
    "content": "import { markedAlert, MDKatex } from '@md/core'\nimport { prefix } from '@md/shared/configs'\n// 直接导入供本文件内部使用\nimport {\n  checkImage,\n  createTable,\n  downloadFile,\n  formatDoc,\n  removeLeft,\n  sanitizeTitle,\n  toBase64,\n} from '@md/shared/utils'\n\nimport juice from 'juice'\nimport { Marked } from 'marked'\n\nexport {\n  LocalStorageEngine as LocalEngine,\n  RestfulStorageEngine as RestfulEngine,\n  type StorageEngine,\n} from './storage'\n\n// 重新导出供外部使用\nexport {\n  checkImage,\n  createTable,\n  downloadFile,\n  formatDoc,\n  removeLeft,\n  sanitizeTitle,\n  toBase64,\n}\n\n// 导出新主题系统需要的函数\nexport {\n  modifyHtmlContent,\n  postProcessHtml,\n  renderMarkdown,\n} from '@md/core/utils'\n\nexport function addPrefix(str: string) {\n  return `${prefix}__${str}`\n}\n\n/**\n * 导出原始 Markdown 文档\n * @param {string} doc - 文档内容\n * @param {string} title - 文档标题\n */\nexport function downloadMD(doc: string, title: string = `untitled`) {\n  const safeTitle = sanitizeTitle(title)\n  downloadFile(doc, `${safeTitle}.md`, `text/markdown;charset=utf-8`)\n}\n\n/**\n * 获取 HTML 内容\n * @returns {string} HTML 字符串\n */\nexport function getHtmlContent(): string {\n  const element = document.querySelector(`#output`)!\n  return element.innerHTML\n}\n\n/**\n * 导出 HTML 生成内容\n */\nexport async function exportHTML(title: string = `untitled`) {\n  const htmlStr = getHtmlContent()\n  const stylesToAdd = await getStylesToAdd()\n\n  const fullHtml = `<!DOCTYPE html>\n<html>\n<head>\n  <meta charset=\"utf-8\">\n  <title>${sanitizeTitle(title)}</title>\n  ${stylesToAdd}\n</head>\n<body>\n  <div style=\"width: 750px; margin: auto; padding: 20px;\">\n    ${htmlStr}\n  </div>\n</body>\n</html>`\n\n  downloadFile(fullHtml, `${sanitizeTitle(title)}.html`, `text/html`)\n}\n\n/**\n * 生成无样式 HTML\n * @param raw - 原始 Markdown 内容\n * @returns string\n */\nexport async function generatePureHTML(raw: string): Promise<string> {\n  const markedInstance = new Marked()\n  markedInstance.use(markedAlert({ withoutStyle: true }))\n  markedInstance.use(\n    MDKatex({ nonStandard: true }, false),\n  )\n  const pureHtml = await markedInstance.parse(raw)\n  return pureHtml\n}\n\n/**\n * 导出无样式 HTML 文件\n * @param raw - 原始 Markdown 内容\n * @param title - 文档标题\n */\nexport async function exportPureHTML(raw: string, title: string = `untitled`) {\n  const safeTitle = sanitizeTitle(title)\n\n  const pureHtml = await generatePureHTML(raw)\n\n  downloadFile(pureHtml, `${safeTitle}.html`, `text/html`)\n}\n\n/**\n * 导出 PDF 文档（新主题系统）\n * @param {string} title - 文档标题\n */\nexport async function exportPDF(title: string = `untitled`) {\n  const htmlStr = getHtmlContent()\n  const stylesToAdd = await getStylesToAdd()\n  const safeTitle = sanitizeTitle(title)\n\n  // 创建新窗口用于打印\n  const printWindow = window.open(``, `_blank`)\n  if (!printWindow) {\n    console.error(`无法打开打印窗口`)\n    return\n  }\n\n  // 写入HTML内容，包含主题样式和自定义页眉页脚\n  printWindow.document.write(`\n    <!DOCTYPE html>\n    <html>\n    <head>\n      <meta charset=\"utf-8\">\n      <title>${safeTitle}</title>\n      ${stylesToAdd}\n      <style>\n        /* 强制打印背景颜色和图片 */\n        * {\n          -webkit-print-color-adjust: exact !important;\n          print-color-adjust: exact !important;\n          color-adjust: exact !important;\n        }\n\n        /* 打印页面设置 */\n        @page {\n          @top-center {\n            content: \"${safeTitle}\";\n            font-size: 12px;\n            color: #666;\n          }\n          @bottom-left {\n            content: \"https://md.doocs.org\";\n            font-size: 10px;\n            color: #999;\n          }\n          @bottom-right {\n            content: \"第 \" counter(page) \" 页，共 \" counter(pages) \" 页\";\n            font-size: 10px;\n            color: #999;\n          }\n        }\n\n        @media print {\n          body { margin: 0; }\n        }\n      </style>\n    </head>\n    <body>\n      <div style=\"width: 100%; max-width: 750px; margin: auto;\">\n        ${htmlStr}\n      </div>\n    </body>\n    </html>\n  `)\n\n  printWindow.document.close()\n\n  // 等待内容加载完成后自动打开打印对话框\n  printWindow.onload = () => {\n    printWindow.print()\n    // 打印完成后关闭窗口\n    printWindow.onafterprint = () => {\n      printWindow.close()\n    }\n  }\n}\n\nexport function solveWeChatImage() {\n  const clipboardDiv = document.getElementById(`output`)!\n  const images = clipboardDiv.getElementsByTagName(`img`)\n\n  Array.from(images).forEach((image) => {\n    const width = image.getAttribute(`width`)\n    const height = image.getAttribute(`height`)\n\n    if (width) {\n      image.removeAttribute(`width`)\n      // 如果是纯数字，添加 px 单位；否则保持原值\n      image.style.width = /^\\d+$/.test(width) ? `${width}px` : width\n    }\n\n    if (height) {\n      image.removeAttribute(`height`)\n      // 如果是纯数字，添加 px 单位；否则保持原值\n      image.style.height = /^\\d+$/.test(height) ? `${height}px` : height\n    }\n  })\n}\n\nasync function getHljsStyles(): Promise<string> {\n  const hljsLink = document.querySelector(`#hljs`) as HTMLLinkElement\n  if (!hljsLink)\n    return ``\n\n  try {\n    const response = await fetch(hljsLink.href)\n    const cssText = await response.text()\n    return `<style>${cssText}</style>`\n  }\n  catch (error) {\n    console.warn(`Failed to fetch highlight.js styles:`, error)\n    return ``\n  }\n}\n\nfunction getThemeStyles(): string {\n  const themeStyle = document.querySelector(`#md-theme`) as HTMLStyleElement\n\n  if (!themeStyle || !themeStyle.textContent) {\n    console.warn('[getThemeStyles] 未找到主题样式')\n    return ``\n  }\n\n  // 移除 #output 作用域前缀，因为复制后的 HTML 不在 #output 容器中\n  let cssContent = themeStyle.textContent\n\n  // 处理 #output {} 为 body {}，避免出现 {} 无效样式\n  cssContent = cssContent.replace(/#output\\s*\\{/g, 'body {')\n\n  // 将 \"#output h1\" 替换为 \"h1\"，\"#output .class\" 替换为 \".class\" 等\n  // 同时处理换行和多个空格的情况\n  cssContent = cssContent.replace(/#output\\s+/g, '')\n  // 处理选择器开头的 #output（如果没有后续内容）\n  cssContent = cssContent.replace(/^#output\\s*/gm, '')\n\n  const styleContent = `<style>${cssContent}</style>`\n  return styleContent\n}\n\nfunction mergeCss(html: string): string {\n  return juice(html, {\n    inlinePseudoElements: true,\n    preserveImportant: true,\n    // 禁用 CSS 变量解析，避免 juice 处理时的错误\n    // 新主题系统已通过 postcss 处理 CSS 变量\n    resolveCSSVariables: false,\n  })\n}\n\nfunction modifyHtmlStructure(htmlString: string): string {\n  const tempDiv = document.createElement(`div`)\n  tempDiv.innerHTML = htmlString\n\n  // 移动 `li > ul` 和 `li > ol` 到 `li` 后面\n  tempDiv.querySelectorAll(`li > ul, li > ol`).forEach((originalItem) => {\n    originalItem.parentElement!.insertAdjacentElement(`afterend`, originalItem)\n  })\n\n  return tempDiv.innerHTML\n}\n\nfunction createEmptyNode(): HTMLElement {\n  const node = document.createElement(`p`)\n  node.style.fontSize = `0`\n  node.style.lineHeight = `0`\n  node.style.margin = `0`\n  node.innerHTML = `&nbsp;`\n  return node\n}\n\n/**\n * 获取需要添加的样式\n * @returns {Promise<string>} 样式字符串\n */\nasync function getStylesToAdd(): Promise<string> {\n  const themeStyles = getThemeStyles()\n  const hljsStyles = await getHljsStyles()\n  return [themeStyles, hljsStyles].filter(Boolean).join(``)\n}\n\nexport async function processClipboardContent(primaryColor: string) {\n  const clipboardDiv = document.getElementById(`output`)!\n\n  const stylesToAdd = await getStylesToAdd()\n\n  if (stylesToAdd) {\n    clipboardDiv.innerHTML = stylesToAdd + clipboardDiv.innerHTML\n  }\n\n  // 先合并 CSS 和修改 HTML 结构\n  clipboardDiv.innerHTML = modifyHtmlStructure(mergeCss(clipboardDiv.innerHTML))\n\n  // 处理样式和颜色变量\n  clipboardDiv.innerHTML = clipboardDiv.innerHTML\n    .replace(/([^-])top:(.*?)em/g, `$1transform: translateY($2em)`)\n    .replace(/hsl\\(var\\(--foreground\\)\\)/g, `#3f3f3f`)\n    .replace(/var\\(--blockquote-background\\)/g, `#f7f7f7`)\n    .replace(/var\\(--md-primary-color\\)/g, primaryColor)\n    .replace(/--md-primary-color:.+?;/g, ``)\n    .replace(/--md-font-family:.+?;/g, ``)\n    .replace(/--md-font-size:.+?;/g, ``)\n    .replace(\n      /<span class=\"nodeLabel\"([^>]*)><p[^>]*>(.*?)<\\/p><\\/span>/g,\n      `<span class=\"nodeLabel\"$1>$2</span>`,\n    )\n    .replace(\n      /<span class=\"edgeLabel\"([^>]*)><p[^>]*>(.*?)<\\/p><\\/span>/g,\n      `<span class=\"edgeLabel\"$1>$2</span>`,\n    )\n\n  // 处理图片大小\n  solveWeChatImage()\n\n  // 添加空白节点用于兼容 SVG 复制\n  const beforeNode = createEmptyNode()\n  const afterNode = createEmptyNode()\n  clipboardDiv.insertBefore(beforeNode, clipboardDiv.firstChild)\n  clipboardDiv.appendChild(afterNode)\n\n  // 兼容 Mermaid\n  const nodes = clipboardDiv.querySelectorAll(`.nodeLabel`)\n  nodes.forEach((node) => {\n    const parent = node.parentElement!\n    const xmlns = parent.getAttribute(`xmlns`)!\n    const style = parent.getAttribute(`style`)!\n    const section = document.createElement(`section`)\n    section.setAttribute(`xmlns`, xmlns)\n    section.setAttribute(`style`, style)\n    section.innerHTML = parent.innerHTML\n\n    const grand = parent.parentElement!\n    // 清空父元素\n    grand.innerHTML = ``\n    grand.appendChild(section)\n  })\n\n  // fix: mermaid 部分文本颜色被 stroke 覆盖\n  clipboardDiv.innerHTML = clipboardDiv.innerHTML\n    .replace(\n      /<tspan([^>]*)>/g,\n      `<tspan$1 style=\"fill: #333333 !important; color: #333333 !important; stroke: none !important;\">`,\n    )\n\n  // fix: antv infographic 复制到微信公众平台时 <text></text> 被自动转为 <text><tspan></tspan></text> 导致在 Safari 浏览器中文字异常的问题\n  clipboardDiv.querySelectorAll('.infographic-diagram').forEach((diagram) => {\n    diagram.querySelectorAll('text').forEach((textElem) => {\n      // 如果有 dominant-baseline 属性，替换为 dy\n      const dominantBaseline = textElem.getAttribute('dominant-baseline')\n      const variantMap = {\n        'alphabetic': '',\n        'central': '0.35em',\n        'middle': '0.35em',\n        'hanging': '-0.55em',\n        'ideographic': '0.18em',\n        'text-before-edge': '-0.85em',\n        'text-after-edge': '0.15em',\n      }\n      if (dominantBaseline) {\n        textElem.removeAttribute('dominant-baseline')\n        const dy = variantMap[dominantBaseline as keyof typeof variantMap]\n        if (dy) {\n          textElem.setAttribute('dy', dy)\n        }\n      }\n    })\n  })\n}\n"
  },
  {
    "path": "apps/web/src/utils/setup-components.ts",
    "content": "class MpCommonProfile extends HTMLElement {\n  constructor() {\n    super()\n    this.attachShadow({ mode: `open` })\n  }\n\n  connectedCallback() {\n    const nickname = this.dataset.nickname || ``\n    const headimg = this.dataset.headimg || ``\n    const signature = this.dataset.signature || ``\n    const serviceType = this.dataset.service_type || `1`\n    const verifyStatus = this.dataset.verify_status || `0`\n\n    this.shadowRoot!.innerHTML = `\n      <style>\n        :host {\n          all: initial;\n          -webkit-text-size-adjust: inherit;\n\n        }\n      </style>\n      <style>@media (prefers-color-scheme:dark){.wx-root:not([data-weui-theme=light]),body:not([data-weui-theme=light]){--weui-BG-0:#111;--weui-BG-1:#1e1e1e;--weui-BG-2:#191919;--weui-BG-3:#202020;--weui-BG-4:#404040;--weui-BG-5:#2c2c2c;--weui-BLUE-100:#10aeff;--weui-BLUE-120:#0c8bcc;--weui-BLUE-170:#04344d;--weui-BLUE-80:#3fbeff;--weui-BLUE-90:#28b6ff;--weui-BLUE-BG-100:#48a6e2;--weui-BLUE-BG-110:#4095cb;--weui-BLUE-BG-130:#32749e;--weui-BLUE-BG-90:#5aafe4;--weui-BRAND-100:#07c160;--weui-BRAND-120:#059a4c;--weui-BRAND-170:#023a1c;--weui-BRAND-80:#38cd7f;--weui-BRAND-90:#20c770;--weui-BRAND-BG-100:#2aae67;--weui-BRAND-BG-110:#259c5c;--weui-BRAND-BG-130:#1d7a48;--weui-BRAND-BG-90:#3eb575;--weui-FG-0:hsla(0,0%,100%,0.8);--weui-FG-0_5:hsla(0,0%,100%,0.6);--weui-FG-1:hsla(0,0%,100%,0.5);--weui-FG-2:hsla(0,0%,100%,0.3);--weui-FG-3:hsla(0,0%,100%,0.1);--weui-FG-4:hsla(0,0%,100%,0.15);--weui-GLYPH-0:hsla(0,0%,100%,0.8);--weui-GLYPH-1:hsla(0,0%,100%,0.5);--weui-GLYPH-2:hsla(0,0%,100%,0.3);--weui-GLYPH-WHITE-0:hsla(0,0%,100%,0.8);--weui-GLYPH-WHITE-1:hsla(0,0%,100%,0.5);--weui-GLYPH-WHITE-2:hsla(0,0%,100%,0.3);--weui-GLYPH-WHITE-3:#fff;--weui-GREEN-100:#74a800;--weui-GREEN-120:#5c8600;--weui-GREEN-170:#233200;--weui-GREEN-80:#8fb933;--weui-GREEN-90:#82b01a;--weui-GREEN-BG-100:#789833;--weui-GREEN-BG-110:#6b882d;--weui-GREEN-BG-130:#65802b;--weui-GREEN-BG-90:#85a247;--weui-INDIGO-100:#1196ff;--weui-INDIGO-120:#0d78cc;--weui-INDIGO-170:#052d4d;--weui-INDIGO-80:#40abff;--weui-INDIGO-90:#28a0ff;--weui-INDIGO-BG-100:#0d78cc;--weui-INDIGO-BG-110:#0b6bb7;--weui-INDIGO-BG-130:#09548f;--weui-INDIGO-BG-90:#2585d1;--weui-LIGHTGREEN-100:#3eb575;--weui-LIGHTGREEN-120:#31905d;--weui-LIGHTGREEN-170:#123522;--weui-LIGHTGREEN-80:#64c390;--weui-LIGHTGREEN-90:#51bc83;--weui-LIGHTGREEN-BG-100:#31905d;--weui-LIGHTGREEN-BG-110:#2c8153;--weui-LIGHTGREEN-BG-130:#226541;--weui-LIGHTGREEN-BG-90:#31905d;--weui-LINK-100:#7d90a9;--weui-LINK-120:#647387;--weui-LINK-170:#252a32;--weui-LINK-80:#97a6ba;--weui-LINK-90:#899ab1;--weui-LINKFINDER-100:#dee9ff;--weui-MATERIAL-ATTACHMENTCOLUMN:rgba(32,32,32,0.93);--weui-MATERIAL-NAVIGATIONBAR:rgba(18,18,18,0.9);--weui-MATERIAL-REGULAR:rgba(37,37,37,0.6);--weui-MATERIAL-THICK:rgba(34,34,34,0.9);--weui-MATERIAL-THIN:rgba(95,95,95,0.4);--weui-MATERIAL-TOOLBAR:rgba(35,35,35,0.93);--weui-ORANGE-100:#c87d2f;--weui-ORANGE-120:#a06425;--weui-ORANGE-170:#3b250e;--weui-ORANGE-80:#d39758;--weui-ORANGE-90:#cd8943;--weui-ORANGE-BG-100:#bb6000;--weui-ORANGE-BG-110:#a85600;--weui-ORANGE-BG-130:#824300;--weui-ORANGE-BG-90:#c1701a;--weui-ORANGERED-100:#ff6146;--weui-OVERLAY:rgba(0,0,0,0.8);--weui-OVERLAY-WHITE:hsla(0,0%,94.9%,0.8);--weui-PURPLE-100:#8183ff;--weui-PURPLE-120:#6768cc;--weui-PURPLE-170:#26274c;--weui-PURPLE-80:#9a9bff;--weui-PURPLE-90:#8d8fff;--weui-PURPLE-BG-100:#6768cc;--weui-PURPLE-BG-110:#5c5db7;--weui-PURPLE-BG-130:#48498f;--weui-PURPLE-BG-90:#7677d1;--weui-RED-100:#fa5151;--weui-RED-120:#c84040;--weui-RED-170:#4b1818;--weui-RED-80:#fb7373;--weui-RED-90:#fa6262;--weui-RED-BG-100:#cf5148;--weui-RED-BG-110:#ba4940;--weui-RED-BG-130:#913832;--weui-RED-BG-90:#d3625a;--weui-SECONDARY-BG:hsla(0,0%,100%,0.1);--weui-SEPARATOR-0:hsla(0,0%,100%,0.05);--weui-SEPARATOR-1:hsla(0,0%,100%,0.15);--weui-STATELAYER-HOVERED:rgba(0,0,0,0.02);--weui-STATELAYER-PRESSED:hsla(0,0%,100%,0.1);--weui-STATELAYER-PRESSEDSTRENGTHENED:hsla(0,0%,100%,0.2);--weui-YELLOW-100:#cc9c00;--weui-YELLOW-120:#a37c00;--weui-YELLOW-170:#3d2f00;--weui-YELLOW-80:#d6af33;--weui-YELLOW-90:#d1a519;--weui-YELLOW-BG-100:#bf9100;--weui-YELLOW-BG-110:#ab8200;--weui-YELLOW-BG-130:#866500;--weui-YELLOW-BG-90:#c59c1a;--weui-FG-HALF:hsla(0,0%,100%,0.6);--weui-RED:#fa5151;--weui-ORANGERED:#ff6146;--weui-ORANGE:#c87d2f;--weui-YELLOW:#cc9c00;--weui-GREEN:#74a800;--weui-LIGHTGREEN:#3eb575;--weui-TEXTGREEN:#259c5c;--weui-BRAND:#07c160;--weui-BLUE:#10aeff;--weui-INDIGO:#1196ff;--weui-PURPLE:#8183ff;--weui-LINK:#7d90a9;--weui-REDORANGE:#ff6146;--weui-TAG-TEXT-BLACK:hsla(0,0%,100%,0.5);--weui-TAG-BACKGROUND-BLACK:hsla(0,0%,100%,0.05);--weui-WHITE:hsla(0,0%,100%,0.8);--weui-FG:#fff;--weui-BG:#000;--weui-FG-5:hsla(0,0%,100%,0.1);--weui-TAG-BACKGROUND-ORANGE:rgba(250,157,59,0.1);--weui-TAG-BACKGROUND-GREEN:rgba(6,174,86,0.1);--weui-TAG-TEXT-RED:rgba(250,81,81,0.6);--weui-TAG-BACKGROUND-RED:rgba(250,81,81,0.1);--weui-TAG-BACKGROUND-BLUE:rgba(16,174,255,0.1);--weui-TAG-TEXT-ORANGE:rgba(250,157,59,0.6);--weui-TAG-TEXT-GREEN:rgba(6,174,86,0.6);--weui-TAG-TEXT-BLUE:rgba(16,174,255,0.6)}}@media (prefers-color-scheme:dark){.wx-root[data-weui-mode=care]:not([data-weui-theme=light]),body[data-weui-mode=care]:not([data-weui-theme=light]){--weui-BG-0:#111;--weui-BG-1:#1e1e1e;--weui-BG-2:#191919;--weui-BG-3:#202020;--weui-BG-4:#404040;--weui-BG-5:#2c2c2c;--weui-BLUE-100:#10aeff;--weui-BLUE-120:#0c8bcc;--weui-BLUE-170:#04344d;--weui-BLUE-80:#3fbeff;--weui-BLUE-90:#28b6ff;--weui-BLUE-BG-100:#48a6e2;--weui-BLUE-BG-110:#4095cb;--weui-BLUE-BG-130:#32749e;--weui-BLUE-BG-90:#5aafe4;--weui-BRAND-100:#07c160;--weui-BRAND-120:#059a4c;--weui-BRAND-170:#023a1c;--weui-BRAND-80:#38cd7f;--weui-BRAND-90:#20c770;--weui-BRAND-BG-100:#2aae67;--weui-BRAND-BG-110:#259c5c;--weui-BRAND-BG-130:#1d7a48;--weui-BRAND-BG-90:#3eb575;--weui-FG-0:hsla(0,0%,100%,0.85);--weui-FG-0_5:hsla(0,0%,100%,0.65);--weui-FG-1:hsla(0,0%,100%,0.55);--weui-FG-2:hsla(0,0%,100%,0.35);--weui-FG-3:hsla(0,0%,100%,0.1);--weui-FG-4:hsla(0,0%,100%,0.15);--weui-GLYPH-0:hsla(0,0%,100%,0.85);--weui-GLYPH-1:hsla(0,0%,100%,0.55);--weui-GLYPH-2:hsla(0,0%,100%,0.35);--weui-GLYPH-WHITE-0:hsla(0,0%,100%,0.85);--weui-GLYPH-WHITE-1:hsla(0,0%,100%,0.55);--weui-GLYPH-WHITE-2:hsla(0,0%,100%,0.35);--weui-GLYPH-WHITE-3:#fff;--weui-GREEN-100:#74a800;--weui-GREEN-120:#5c8600;--weui-GREEN-170:#233200;--weui-GREEN-80:#8fb933;--weui-GREEN-90:#82b01a;--weui-GREEN-BG-100:#789833;--weui-GREEN-BG-110:#6b882d;--weui-GREEN-BG-130:#65802b;--weui-GREEN-BG-90:#85a247;--weui-INDIGO-100:#1196ff;--weui-INDIGO-120:#0d78cc;--weui-INDIGO-170:#052d4d;--weui-INDIGO-80:#40abff;--weui-INDIGO-90:#28a0ff;--weui-INDIGO-BG-100:#0d78cc;--weui-INDIGO-BG-110:#0b6bb7;--weui-INDIGO-BG-130:#09548f;--weui-INDIGO-BG-90:#2585d1;--weui-LIGHTGREEN-100:#3eb575;--weui-LIGHTGREEN-120:#31905d;--weui-LIGHTGREEN-170:#123522;--weui-LIGHTGREEN-80:#64c390;--weui-LIGHTGREEN-90:#51bc83;--weui-LIGHTGREEN-BG-100:#31905d;--weui-LIGHTGREEN-BG-110:#2c8153;--weui-LIGHTGREEN-BG-130:#226541;--weui-LIGHTGREEN-BG-90:#31905d;--weui-LINK-100:#7d90a9;--weui-LINK-120:#647387;--weui-LINK-170:#252a32;--weui-LINK-80:#97a6ba;--weui-LINK-90:#899ab1;--weui-LINKFINDER-100:#dee9ff;--weui-MATERIAL-ATTACHMENTCOLUMN:rgba(32,32,32,0.93);--weui-MATERIAL-NAVIGATIONBAR:rgba(18,18,18,0.9);--weui-MATERIAL-REGULAR:rgba(37,37,37,0.6);--weui-MATERIAL-THICK:rgba(34,34,34,0.9);--weui-MATERIAL-THIN:hsla(0,0%,96.1%,0.4);--weui-MATERIAL-TOOLBAR:rgba(35,35,35,0.93);--weui-ORANGE-100:#c87d2f;--weui-ORANGE-120:#a06425;--weui-ORANGE-170:#3b250e;--weui-ORANGE-80:#d39758;--weui-ORANGE-90:#cd8943;--weui-ORANGE-BG-100:#bb6000;--weui-ORANGE-BG-110:#a85600;--weui-ORANGE-BG-130:#824300;--weui-ORANGE-BG-90:#c1701a;--weui-ORANGERED-100:#ff6146;--weui-OVERLAY:rgba(0,0,0,0.8);--weui-OVERLAY-WHITE:hsla(0,0%,94.9%,0.8);--weui-PURPLE-100:#8183ff;--weui-PURPLE-120:#6768cc;--weui-PURPLE-170:#26274c;--weui-PURPLE-80:#9a9bff;--weui-PURPLE-90:#8d8fff;--weui-PURPLE-BG-100:#6768cc;--weui-PURPLE-BG-110:#5c5db7;--weui-PURPLE-BG-130:#48498f;--weui-PURPLE-BG-90:#7677d1;--weui-RED-100:#fa5151;--weui-RED-120:#c84040;--weui-RED-170:#4b1818;--weui-RED-80:#fb7373;--weui-RED-90:#fa6262;--weui-RED-BG-100:#cf5148;--weui-RED-BG-110:#ba4940;--weui-RED-BG-130:#913832;--weui-RED-BG-90:#d3625a;--weui-SECONDARY-BG:hsla(0,0%,100%,0.15);--weui-SEPARATOR-0:hsla(0,0%,100%,0.05);--weui-SEPARATOR-1:hsla(0,0%,100%,0.15);--weui-STATELAYER-HOVERED:rgba(0,0,0,0.02);--weui-STATELAYER-PRESSED:hsla(0,0%,100%,0.1);--weui-STATELAYER-PRESSEDSTRENGTHENED:hsla(0,0%,100%,0.2);--weui-YELLOW-100:#cc9c00;--weui-YELLOW-120:#a37c00;--weui-YELLOW-170:#3d2f00;--weui-YELLOW-80:#d6af33;--weui-YELLOW-90:#d1a519;--weui-YELLOW-BG-100:#bf9100;--weui-YELLOW-BG-110:#ab8200;--weui-YELLOW-BG-130:#866500;--weui-YELLOW-BG-90:#c59c1a;--weui-FG-HALF:hsla(0,0%,100%,0.65);--weui-RED:#fa5151;--weui-ORANGERED:#ff6146;--weui-ORANGE:#c87d2f;--weui-YELLOW:#cc9c00;--weui-GREEN:#74a800;--weui-LIGHTGREEN:#3eb575;--weui-TEXTGREEN:#259c5c;--weui-BRAND:#07c160;--weui-BLUE:#10aeff;--weui-INDIGO:#1196ff;--weui-PURPLE:#8183ff;--weui-LINK:#7d90a9;--weui-REDORANGE:#ff6146;--weui-TAG-BACKGROUND-BLACK:hsla(0,0%,100%,0.05);--weui-FG:#fff;--weui-WHITE:hsla(0,0%,100%,0.8);--weui-FG-5:hsla(0,0%,100%,0.1);--weui-TAG-BACKGROUND-ORANGE:rgba(250,157,59,0.1);--weui-TAG-BACKGROUND-GREEN:rgba(6,174,86,0.1);--weui-TAG-TEXT-RED:rgba(250,81,81,0.6);--weui-TAG-BACKGROUND-RED:rgba(250,81,81,0.1);--weui-TAG-BACKGROUND-BLUE:rgba(16,174,255,0.1);--weui-TAG-TEXT-ORANGE:rgba(250,157,59,0.6);--weui-BG:#000;--weui-TAG-TEXT-GREEN:rgba(6,174,86,0.6);--weui-TAG-TEXT-BLUE:rgba(16,174,255,0.6);--weui-TAG-TEXT-BLACK:hsla(0,0%,100%,0.5)}}.wx-root,body,page{--weui-BTN-HEIGHT:48;--weui-BTN-HEIGHT-MEDIUM:40;--weui-BTN-HEIGHT-SMALL:32}.wx-root,body{--weui-BTN-ACTIVE-MASK:rgba(0,0,0,0.1)}.wx-root[data-weui-theme=dark],body[data-weui-theme=dark]{--weui-BTN-ACTIVE-MASK:hsla(0,0%,100%,0.1)}@media (prefers-color-scheme:dark){.wx-root:not([data-weui-theme=light]),body:not([data-weui-theme=light]){--weui-BTN-ACTIVE-MASK:hsla(0,0%,100%,0.1)}}.wx-root,body{--weui-BTN-DEFAULT-ACTIVE-BG:#e6e6e6}.wx-root[data-weui-theme=dark],body[data-weui-theme=dark]{--weui-BTN-DEFAULT-ACTIVE-BG:hsla(0,0%,100%,0.126)}@media (prefers-color-scheme:dark){.wx-root:not([data-weui-theme=light]),body:not([data-weui-theme=light]){--weui-BTN-DEFAULT-ACTIVE-BG:hsla(0,0%,100%,0.126)}}.wx-root,body{--weui-DIALOG-LINE-COLOR:rgba(0,0,0,0.1)}.wx-root[data-weui-theme=dark],body[data-weui-theme=dark]{--weui-DIALOG-LINE-COLOR:hsla(0,0%,100%,0.1)}@media (prefers-color-scheme:dark){.wx-root:not([data-weui-theme=light]),body:not([data-weui-theme=light]){--weui-DIALOG-LINE-COLOR:hsla(0,0%,100%,0.1)}}.weui-flex{display:flex}.weui-flex__item{flex:1}.wx-root,body{--weui-BG-COLOR-ACTIVE:#ececec}.wx-root[data-weui-theme=dark],body[data-weui-theme=dark]{--weui-BG-COLOR-ACTIVE:#373737}@media (prefers-color-scheme:dark){.wx-root:not([data-weui-theme=light]),body:not([data-weui-theme=light]){--weui-BG-COLOR-ACTIVE:#373737}}[class*=\" weui-icon-\"][class*=\" weui-icon-\"],[class*=\" weui-icon-\"][class^=weui-icon-],[class^=weui-icon-][class*=\" weui-icon-\"],[class^=weui-icon-][class^=weui-icon-]{display:inline-block;vertical-align:middle;font-size:10px;width:2.4em;height:2.4em;-webkit-mask-position:50% 50%;mask-position:50% 50%;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100%;mask-size:100%;background-color:currentColor}.weui-icon-circle{-webkit-mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='1000' height='1000' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M500 916.667C269.881 916.667 83.333 730.119 83.333 500 83.333 269.881 269.881 83.333 500 83.333c230.119 0 416.667 186.548 416.667 416.667 0 230.119-186.548 416.667-416.667 416.667zm0-50c202.504 0 366.667-164.163 366.667-366.667 0-202.504-164.163-366.667-366.667-366.667-202.504 0-366.667 164.163-366.667 366.667 0 202.504 164.163 366.667 366.667 366.667z' fill-rule='evenodd' fill-opacity='.9'/%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='1000' height='1000' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M500 916.667C269.881 916.667 83.333 730.119 83.333 500 83.333 269.881 269.881 83.333 500 83.333c230.119 0 416.667 186.548 416.667 416.667 0 230.119-186.548 416.667-416.667 416.667zm0-50c202.504 0 366.667-164.163 366.667-366.667 0-202.504-164.163-366.667-366.667-366.667-202.504 0-366.667 164.163-366.667 366.667 0 202.504 164.163 366.667 366.667 366.667z' fill-rule='evenodd' fill-opacity='.9'/%3E%3C/svg%3E\")}.weui-icon-download{-webkit-mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M11.25 12.04l-1.72-1.72-1.06 1.06 2.828 2.83a1 1 0 001.414-.001l2.828-2.828-1.06-1.061-1.73 1.73V7h-1.5v5.04zm0-5.04V2h1.5v5h6.251c.55 0 .999.446.999.996v13.008a.998.998 0 01-.996.996H4.996A.998.998 0 014 21.004V7.996A1 1 0 014.999 7h6.251z'/%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M11.25 12.04l-1.72-1.72-1.06 1.06 2.828 2.83a1 1 0 001.414-.001l2.828-2.828-1.06-1.061-1.73 1.73V7h-1.5v5.04zm0-5.04V2h1.5v5h6.251c.55 0 .999.446.999.996v13.008a.998.998 0 01-.996.996H4.996A.998.998 0 014 21.004V7.996A1 1 0 014.999 7h6.251z'/%3E%3C/svg%3E\")}.weui-icon-info{-webkit-mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-.75-12v7h1.5v-7h-1.5zM12 9a1 1 0 100-2 1 1 0 000 2z'/%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-.75-12v7h1.5v-7h-1.5zM12 9a1 1 0 100-2 1 1 0 000 2z'/%3E%3C/svg%3E\")}.weui-icon-safe-success{-webkit-mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1000 1000'%3E%3Cpath d='M500.9 4.6C315.5 46.7 180.4 93.1 57.6 132c0 129.3.2 231.7.2 339.7 0 304.2 248.3 471.6 443.1 523.7C695.7 943.3 944 775.9 944 471.7c0-108 .2-210.4.2-339.7C821.4 93.1 686.3 46.7 500.9 4.6zm248.3 349.1l-299.7 295c-2.1 2-5.3 2-7.4-.1L304.4 506.1c-2-2.1-2.3-5.7-.6-8l18.3-24.9c1.7-2.3 5-2.8 7.2-1l112.2 86c2.3 1.8 6 1.7 8.1-.1l274.7-228.9c2.2-1.8 5.7-1.7 7.7.3l17 16.8c2.2 2.1 2.2 5.3.2 7.4z' fill-rule='evenodd' clip-rule='evenodd' fill='%23070202'/%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1000 1000'%3E%3Cpath d='M500.9 4.6C315.5 46.7 180.4 93.1 57.6 132c0 129.3.2 231.7.2 339.7 0 304.2 248.3 471.6 443.1 523.7C695.7 943.3 944 775.9 944 471.7c0-108 .2-210.4.2-339.7C821.4 93.1 686.3 46.7 500.9 4.6zm248.3 349.1l-299.7 295c-2.1 2-5.3 2-7.4-.1L304.4 506.1c-2-2.1-2.3-5.7-.6-8l18.3-24.9c1.7-2.3 5-2.8 7.2-1l112.2 86c2.3 1.8 6 1.7 8.1-.1l274.7-228.9c2.2-1.8 5.7-1.7 7.7.3l17 16.8c2.2 2.1 2.2 5.3.2 7.4z' fill-rule='evenodd' clip-rule='evenodd' fill='%23070202'/%3E%3C/svg%3E\")}.weui-icon-safe-warn{-webkit-mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1000 1000'%3E%3Cpath d='M500.9 4.5c-185.4 42-320.4 88.4-443.2 127.3 0 129.3.2 231.7.2 339.6 0 304.1 248.2 471.4 443 523.6 194.7-52.2 443-219.5 443-523.6 0-107.9.2-210.3.2-339.6C821.3 92.9 686.2 46.5 500.9 4.5zm-26.1 271.1h52.1c5.8 0 10.3 4.7 10.1 10.4l-11.6 313.8c-.1 2.8-2.5 5.2-5.4 5.2h-38.2c-2.9 0-5.3-2.3-5.4-5.2L464.8 286c-.2-5.8 4.3-10.4 10-10.4zm26.1 448.3c-20.2 0-36.5-16.3-36.5-36.5s16.3-36.5 36.5-36.5 36.5 16.3 36.5 36.5-16.4 36.5-36.5 36.5z' fill-rule='evenodd' clip-rule='evenodd' fill='%23020202'/%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1000 1000'%3E%3Cpath d='M500.9 4.5c-185.4 42-320.4 88.4-443.2 127.3 0 129.3.2 231.7.2 339.6 0 304.1 248.2 471.4 443 523.6 194.7-52.2 443-219.5 443-523.6 0-107.9.2-210.3.2-339.6C821.3 92.9 686.2 46.5 500.9 4.5zm-26.1 271.1h52.1c5.8 0 10.3 4.7 10.1 10.4l-11.6 313.8c-.1 2.8-2.5 5.2-5.4 5.2h-38.2c-2.9 0-5.3-2.3-5.4-5.2L464.8 286c-.2-5.8 4.3-10.4 10-10.4zm26.1 448.3c-20.2 0-36.5-16.3-36.5-36.5s16.3-36.5 36.5-36.5 36.5 16.3 36.5 36.5-16.4 36.5-36.5 36.5z' fill-rule='evenodd' clip-rule='evenodd' fill='%23020202'/%3E%3C/svg%3E\")}.weui-icon-success{-webkit-mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-1.177-7.86l-2.765-2.767L7 12.431l3.119 3.121a1 1 0 001.414 0l5.952-5.95-1.062-1.062-5.6 5.6z'/%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-1.177-7.86l-2.765-2.767L7 12.431l3.119 3.121a1 1 0 001.414 0l5.952-5.95-1.062-1.062-5.6 5.6z'/%3E%3C/svg%3E\")}.weui-icon-success-circle{-webkit-mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-1.2a8.8 8.8 0 100-17.6 8.8 8.8 0 000 17.6zm-1.172-6.242l5.809-5.808.848.849-5.95 5.95a1 1 0 01-1.414 0L7 12.426l.849-.849 2.98 2.98z'/%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-1.2a8.8 8.8 0 100-17.6 8.8 8.8 0 000 17.6zm-1.172-6.242l5.809-5.808.848.849-5.95 5.95a1 1 0 01-1.414 0L7 12.426l.849-.849 2.98 2.98z'/%3E%3C/svg%3E\")}.weui-icon-success-no-circle{-webkit-mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M8.657 18.435L3 12.778l1.414-1.414 4.95 4.95L20.678 5l1.414 1.414-12.02 12.021a1 1 0 01-1.415 0z' fill-rule='evenodd'/%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M8.657 18.435L3 12.778l1.414-1.414 4.95 4.95L20.678 5l1.414 1.414-12.02 12.021a1 1 0 01-1.415 0z' fill-rule='evenodd'/%3E%3C/svg%3E\")}.weui-icon-waiting{-webkit-mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12.75 11.38V6h-1.5v6l4.243 4.243 1.06-1.06-3.803-3.804zM12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10z' fill-rule='evenodd'/%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12.75 11.38V6h-1.5v6l4.243 4.243 1.06-1.06-3.803-3.804zM12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10z' fill-rule='evenodd'/%3E%3C/svg%3E\")}.weui-icon-waiting-circle{-webkit-mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12.6 11.503l3.891 3.891-.848.849L11.4 12V6h1.2v5.503zM12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-1.2a8.8 8.8 0 100-17.6 8.8 8.8 0 000 17.6z'/%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12.6 11.503l3.891 3.891-.848.849L11.4 12V6h1.2v5.503zM12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-1.2a8.8 8.8 0 100-17.6 8.8 8.8 0 000 17.6z'/%3E%3C/svg%3E\")}.weui-icon-warn{-webkit-mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-.763-15.864l.11 7.596h1.305l.11-7.596h-1.525zm.759 10.967c.512 0 .902-.383.902-.882 0-.5-.39-.882-.902-.882a.878.878 0 00-.896.882c0 .499.396.882.896.882z'/%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-.763-15.864l.11 7.596h1.305l.11-7.596h-1.525zm.759 10.967c.512 0 .902-.383.902-.882 0-.5-.39-.882-.902-.882a.878.878 0 00-.896.882c0 .499.396.882.896.882z'/%3E%3C/svg%3E\")}.weui-icon-outlined-warn{-webkit-mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M2 12c0 5.523 4.477 10 10 10s10-4.477 10-10S17.523 2 12 2 2 6.477 2 12zm18.8 0a8.8 8.8 0 11-17.6 0 8.8 8.8 0 0117.6 0zm-8.14-5.569l-.089 7.06H11.43l-.088-7.06h1.318zm-1.495 9.807c0 .469.366.835.835.835a.82.82 0 00.835-.835.817.817 0 00-.835-.835.821.821 0 00-.835.835z' fill='%23000'/%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M2 12c0 5.523 4.477 10 10 10s10-4.477 10-10S17.523 2 12 2 2 6.477 2 12zm18.8 0a8.8 8.8 0 11-17.6 0 8.8 8.8 0 0117.6 0zm-8.14-5.569l-.089 7.06H11.43l-.088-7.06h1.318zm-1.495 9.807c0 .469.366.835.835.835a.82.82 0 00.835-.835.817.817 0 00-.835-.835.821.821 0 00-.835.835z' fill='%23000'/%3E%3C/svg%3E\")}.weui-icon-info-circle{-webkit-mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-1.2a8.8 8.8 0 100-17.6 8.8 8.8 0 000 17.6zM11.4 10h1.2v7h-1.2v-7zm.6-1a1 1 0 110-2 1 1 0 010 2z'/%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-1.2a8.8 8.8 0 100-17.6 8.8 8.8 0 000 17.6zM11.4 10h1.2v7h-1.2v-7zm.6-1a1 1 0 110-2 1 1 0 010 2z'/%3E%3C/svg%3E\")}.weui-icon-cancel{-webkit-mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill-rule='evenodd'%3E%3Cpath d='M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-1.2a8.8 8.8 0 100-17.6 8.8 8.8 0 000 17.6z' fill-rule='nonzero'/%3E%3Cpath d='M12.849 12l3.11 3.111-.848.849L12 12.849l-3.111 3.11-.849-.848L11.151 12l-3.11-3.111.848-.849L12 11.151l3.111-3.11.849.848L12.849 12z'/%3E%3C/g%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill-rule='evenodd'%3E%3Cpath d='M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-1.2a8.8 8.8 0 100-17.6 8.8 8.8 0 000 17.6z' fill-rule='nonzero'/%3E%3Cpath d='M12.849 12l3.11 3.111-.848.849L12 12.849l-3.111 3.11-.849-.848L11.151 12l-3.11-3.111.848-.849L12 11.151l3.111-3.11.849.848L12.849 12z'/%3E%3C/g%3E%3C/svg%3E\")}.weui-icon-search{-webkit-mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M16.31 15.561l4.114 4.115-.848.848-4.123-4.123a7 7 0 11.857-.84zM16.8 11a5.8 5.8 0 10-11.6 0 5.8 5.8 0 0011.6 0z' fill-rule='evenodd'/%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M16.31 15.561l4.114 4.115-.848.848-4.123-4.123a7 7 0 11.857-.84zM16.8 11a5.8 5.8 0 10-11.6 0 5.8 5.8 0 0011.6 0z' fill-rule='evenodd'/%3E%3C/svg%3E\")}.weui-icon-clear{-webkit-mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M13.06 12l3.006-3.005-1.06-1.06L12 10.938 8.995 7.934l-1.06 1.06L10.938 12l-3.005 3.005 1.06 1.06L12 13.062l3.005 3.005 1.06-1.06L13.062 12zM12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10z'/%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M13.06 12l3.006-3.005-1.06-1.06L12 10.938 8.995 7.934l-1.06 1.06L10.938 12l-3.005 3.005 1.06 1.06L12 13.062l3.005 3.005 1.06-1.06L13.062 12zM12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10z'/%3E%3C/svg%3E\")}.weui-icon-back{-webkit-mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm1.999-6.563L10.68 12 14 8.562 12.953 7.5 9.29 11.277a1.045 1.045 0 000 1.446l3.663 3.777L14 15.437z' fill-rule='evenodd'/%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm1.999-6.563L10.68 12 14 8.562 12.953 7.5 9.29 11.277a1.045 1.045 0 000 1.446l3.663 3.777L14 15.437z' fill-rule='evenodd'/%3E%3C/svg%3E\")}.weui-icon-delete{-webkit-mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6.774 6.4l.812 13.648a.8.8 0 00.798.752h7.232a.8.8 0 00.798-.752L17.226 6.4H6.774zm11.655 0l-.817 13.719A2 2 0 0115.616 22H8.384a2 2 0 01-1.996-1.881L5.571 6.4H3.5v-.7a.5.5 0 01.5-.5h16a.5.5 0 01.5.5v.7h-2.071zM14 3a.5.5 0 01.5.5v.7h-5v-.7A.5.5 0 0110 3h4zM9.5 9h1.2l.5 9H10l-.5-9zm3.8 0h1.2l-.5 9h-1.2l.5-9z'/%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6.774 6.4l.812 13.648a.8.8 0 00.798.752h7.232a.8.8 0 00.798-.752L17.226 6.4H6.774zm11.655 0l-.817 13.719A2 2 0 0115.616 22H8.384a2 2 0 01-1.996-1.881L5.571 6.4H3.5v-.7a.5.5 0 01.5-.5h16a.5.5 0 01.5.5v.7h-2.071zM14 3a.5.5 0 01.5.5v.7h-5v-.7A.5.5 0 0110 3h4zM9.5 9h1.2l.5 9H10l-.5-9zm3.8 0h1.2l-.5 9h-1.2l.5-9z'/%3E%3C/svg%3E\")}.weui-icon-success-no-circle-thin{-webkit-mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M8.864 16.617l-5.303-5.303-1.061 1.06 5.657 5.657a1 1 0 001.414 0L21.238 6.364l-1.06-1.06L8.864 16.616z' fill-rule='evenodd'/%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M8.864 16.617l-5.303-5.303-1.061 1.06 5.657 5.657a1 1 0 001.414 0L21.238 6.364l-1.06-1.06L8.864 16.616z' fill-rule='evenodd'/%3E%3C/svg%3E\")}.weui-icon-arrow{-webkit-mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='12' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M2.454 6.58l1.06-1.06 5.78 5.779a.996.996 0 010 1.413l-5.78 5.779-1.06-1.061 5.425-5.425-5.425-5.424z' fill='%23B2B2B2' fill-rule='evenodd'/%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='12' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M2.454 6.58l1.06-1.06 5.78 5.779a.996.996 0 010 1.413l-5.78 5.779-1.06-1.061 5.425-5.425-5.425-5.424z' fill='%23B2B2B2' fill-rule='evenodd'/%3E%3C/svg%3E\")}.weui-icon-arrow-bold{-webkit-mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg height='24' width='12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10.157 12.711L4.5 18.368l-1.414-1.414 4.95-4.95-4.95-4.95L4.5 5.64l5.657 5.657a1 1 0 010 1.414z' fill-rule='evenodd'/%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg height='24' width='12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10.157 12.711L4.5 18.368l-1.414-1.414 4.95-4.95-4.95-4.95L4.5 5.64l5.657 5.657a1 1 0 010 1.414z' fill-rule='evenodd'/%3E%3C/svg%3E\")}.weui-icon-back-arrow{-webkit-mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='12' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M3.343 12l7.071 7.071L9 20.485l-7.778-7.778a1 1 0 010-1.414L9 3.515l1.414 1.414L3.344 12z' fill-rule='evenodd'/%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='12' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M3.343 12l7.071 7.071L9 20.485l-7.778-7.778a1 1 0 010-1.414L9 3.515l1.414 1.414L3.344 12z' fill-rule='evenodd'/%3E%3C/svg%3E\")}.weui-icon-back-arrow-thin{-webkit-mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='12' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10 19.438L8.955 20.5l-7.666-7.79a1.02 1.02 0 010-1.42L8.955 3.5 10 4.563 2.682 12 10 19.438z' fill-rule='evenodd'/%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='12' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10 19.438L8.955 20.5l-7.666-7.79a1.02 1.02 0 010-1.42L8.955 3.5 10 4.563 2.682 12 10 19.438z' fill-rule='evenodd'/%3E%3C/svg%3E\")}.weui-icon-close{-webkit-mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M12.25 10.693L6.057 4.5 5 5.557l6.193 6.193L5 17.943 6.057 19l6.193-6.193L18.443 19l1.057-1.057-6.193-6.193L19.5 5.557 18.443 4.5l-6.193 6.193z' fill='%23000'/%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M12.25 10.693L6.057 4.5 5 5.557l6.193 6.193L5 17.943 6.057 19l6.193-6.193L18.443 19l1.057-1.057-6.193-6.193L19.5 5.557 18.443 4.5l-6.193 6.193z' fill='%23000'/%3E%3C/svg%3E\")}.weui-icon-close-thin{-webkit-mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12.25 10.693L6.057 4.5 5 5.557l6.193 6.193L5 17.943 6.057 19l6.193-6.193L18.443 19l1.057-1.057-6.193-6.193L19.5 5.557 18.443 4.5z' fill-rule='evenodd'/%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12.25 10.693L6.057 4.5 5 5.557l6.193 6.193L5 17.943 6.057 19l6.193-6.193L18.443 19l1.057-1.057-6.193-6.193L19.5 5.557 18.443 4.5z' fill-rule='evenodd'/%3E%3C/svg%3E\")}.weui-icon-back-circle{-webkit-mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-1.2a8.8 8.8 0 100-17.6 8.8 8.8 0 000 17.6zm1.999-5.363L12.953 16.5 9.29 12.723a1.045 1.045 0 010-1.446L12.953 7.5 14 8.563 10.68 12 14 15.438z'/%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-1.2a8.8 8.8 0 100-17.6 8.8 8.8 0 000 17.6zm1.999-5.363L12.953 16.5 9.29 12.723a1.045 1.045 0 010-1.446L12.953 7.5 14 8.563 10.68 12 14 15.438z'/%3E%3C/svg%3E\")}.weui-icon-success{color:var(--weui-BRAND)}.weui-icon-waiting{color:var(--weui-BLUE)}.weui-icon-warn{color:var(--weui-RED)}.weui-icon-info{color:var(--weui-BLUE)}.weui-icon-success-circle,.weui-icon-success-no-circle,.weui-icon-success-no-circle-thin{color:var(--weui-BRAND)}.weui-icon-waiting-circle{color:var(--weui-BLUE)}.weui-icon-circle{color:var(--weui-FG-2)}.weui-icon-download{color:var(--weui-BRAND)}.weui-icon-info-circle{color:var(--weui-FG-2)}.weui-icon-safe-success{color:var(--weui-BRAND)}.weui-icon-safe-warn{color:var(--weui-YELLOW)}.weui-icon-cancel{color:var(--weui-RED)}.weui-icon-search{color:var(--weui-FG-1)}.weui-icon-clear{color:var(--weui-FG-2)}.weui-icon-clear:active{color:var(--weui-FG-1)}.weui-icon-delete.weui-icon_gallery-delete{color:var(--weui-WHITE)}.weui-icon-arrow-bold.weui-icon-arrow,.weui-icon-arrow-bold.weui-icon-arrow-bold,.weui-icon-arrow-bold.weui-icon-back-arrow,.weui-icon-arrow-bold.weui-icon-back-arrow-thin,.weui-icon-arrow.weui-icon-arrow,.weui-icon-arrow.weui-icon-arrow-bold,.weui-icon-arrow.weui-icon-back-arrow,.weui-icon-arrow.weui-icon-back-arrow-thin,.weui-icon-back-arrow-thin.weui-icon-arrow,.weui-icon-back-arrow-thin.weui-icon-arrow-bold,.weui-icon-back-arrow-thin.weui-icon-back-arrow,.weui-icon-back-arrow-thin.weui-icon-back-arrow-thin,.weui-icon-back-arrow.weui-icon-arrow,.weui-icon-back-arrow.weui-icon-arrow-bold,.weui-icon-back-arrow.weui-icon-back-arrow,.weui-icon-back-arrow.weui-icon-back-arrow-thin{width:1.2em}.weui-icon-arrow,.weui-icon-arrow-bold{color:var(--weui-FG-2)}.weui-icon-back,.weui-icon-back-arrow,.weui-icon-back-arrow-thin,.weui-icon-back-circle{color:var(--weui-FG-0)}.weui-icon_msg.weui-icon_msg{width:6.4em;height:6.4em}.weui-icon_msg.weui-icon_msg.weui-icon-warn{color:var(--weui-RED)}.weui-icon_msg.weui-icon_msg.weui-icon-info-circle{color:var(--weui-BLUE)}.weui-icon_msg-primary.weui-icon_msg-primary{width:6.4em;height:6.4em}.weui-icon_msg-primary.weui-icon_msg-primary.weui-icon-warn{color:var(--weui-YELLOW)}.wx-root,body{--weui-BG-0:#ededed;--weui-BG-1:#f7f7f7;--weui-BG-2:#fff;--weui-BG-3:#f7f7f7;--weui-BG-4:#4c4c4c;--weui-BG-5:#fff;--weui-BLUE-100:#10aeff;--weui-BLUE-120:#3fbeff;--weui-BLUE-170:#b7e6ff;--weui-BLUE-80:#0c8bcc;--weui-BLUE-90:#0e9ce6;--weui-BLUE-BG-100:#48a6e2;--weui-BLUE-BG-110:#5aafe4;--weui-BLUE-BG-130:#7fc0ea;--weui-BLUE-BG-90:#4095cb;--weui-BRAND-100:#07c160;--weui-BRAND-120:#38cd7f;--weui-BRAND-170:#b4ecce;--weui-BRAND-80:#059a4c;--weui-BRAND-90:#06ae56;--weui-BRAND-BG-100:#2aae67;--weui-BRAND-BG-110:#3eb575;--weui-BRAND-BG-130:#69c694;--weui-BRAND-BG-90:#259c5c;--weui-FG-0:rgba(0,0,0,0.9);--weui-FG-0_5:rgba(0,0,0,0.9);--weui-FG-1:rgba(0,0,0,0.55);--weui-FG-2:rgba(0,0,0,0.3);--weui-FG-3:rgba(0,0,0,0.1);--weui-FG-4:rgba(0,0,0,0.15);--weui-GLYPH-0:rgba(0,0,0,0.9);--weui-GLYPH-1:rgba(0,0,0,0.55);--weui-GLYPH-2:rgba(0,0,0,0.3);--weui-GLYPH-WHITE-0:hsla(0,0%,100%,0.8);--weui-GLYPH-WHITE-1:hsla(0,0%,100%,0.5);--weui-GLYPH-WHITE-2:hsla(0,0%,100%,0.3);--weui-GLYPH-WHITE-3:#fff;--weui-GREEN-100:#91d300;--weui-GREEN-120:#a7db33;--weui-GREEN-170:#def1b3;--weui-GREEN-80:#74a800;--weui-GREEN-90:#82bd00;--weui-GREEN-BG-100:#96be40;--weui-GREEN-BG-110:#a0c452;--weui-GREEN-BG-130:#b5d179;--weui-GREEN-BG-90:#86aa39;--weui-INDIGO-100:#1485ee;--weui-INDIGO-120:#439df1;--weui-INDIGO-170:#b8daf9;--weui-INDIGO-80:#106abe;--weui-INDIGO-90:#1277d6;--weui-INDIGO-BG-100:#2b77bf;--weui-INDIGO-BG-110:#3f84c5;--weui-INDIGO-BG-130:#6ba0d2;--weui-INDIGO-BG-90:#266aab;--weui-LIGHTGREEN-100:#95ec69;--weui-LIGHTGREEN-120:#aaef87;--weui-LIGHTGREEN-170:#def9d1;--weui-LIGHTGREEN-80:#77bc54;--weui-LIGHTGREEN-90:#85d35e;--weui-LIGHTGREEN-BG-100:#72cf60;--weui-LIGHTGREEN-BG-110:#80d370;--weui-LIGHTGREEN-BG-130:#9cdd90;--weui-LIGHTGREEN-BG-90:#66b956;--weui-LINK-100:#576b95;--weui-LINK-120:#7888aa;--weui-LINK-170:#ccd2de;--weui-LINK-80:#455577;--weui-LINK-90:#4e6085;--weui-LINKFINDER-100:#002666;--weui-MATERIAL-ATTACHMENTCOLUMN:hsla(0,0%,96.1%,0.95);--weui-MATERIAL-NAVIGATIONBAR:hsla(0,0%,92.9%,0.94);--weui-MATERIAL-REGULAR:hsla(0,0%,96.9%,0.3);--weui-MATERIAL-THICK:hsla(0,0%,96.9%,0.8);--weui-MATERIAL-THIN:hsla(0,0%,100%,0.2);--weui-MATERIAL-TOOLBAR:hsla(0,0%,96.5%,0.82);--weui-ORANGE-100:#fa9d3b;--weui-ORANGE-120:#fbb062;--weui-ORANGE-170:#fde1c3;--weui-ORANGE-80:#c87d2f;--weui-ORANGE-90:#e08c34;--weui-ORANGE-BG-100:#ea7800;--weui-ORANGE-BG-110:#ec8519;--weui-ORANGE-BG-130:#f0a04d;--weui-ORANGE-BG-90:#d26b00;--weui-ORANGERED-100:#ff6146;--weui-OVERLAY:rgba(0,0,0,0.5);--weui-OVERLAY-WHITE:hsla(0,0%,94.9%,0.8);--weui-PURPLE-100:#6467f0;--weui-PURPLE-120:#8385f3;--weui-PURPLE-170:#d0d1fa;--weui-PURPLE-80:#5052c0;--weui-PURPLE-90:#595cd7;--weui-PURPLE-BG-100:#6769ba;--weui-PURPLE-BG-110:#7678c1;--weui-PURPLE-BG-130:#9496ce;--weui-PURPLE-BG-90:#5c5ea7;--weui-RED-100:#fa5151;--weui-RED-120:#fb7373;--weui-RED-170:#fdcaca;--weui-RED-80:#c84040;--weui-RED-90:#e14949;--weui-RED-BG-100:#cf5148;--weui-RED-BG-110:#d3625a;--weui-RED-BG-130:#dd847e;--weui-RED-BG-90:#b94840;--weui-SECONDARY-BG:rgba(0,0,0,0.05);--weui-SEPARATOR-0:rgba(0,0,0,0.1);--weui-SEPARATOR-1:rgba(0,0,0,0.15);--weui-STATELAYER-HOVERED:rgba(0,0,0,0.02);--weui-STATELAYER-PRESSED:rgba(0,0,0,0.1);--weui-STATELAYER-PRESSEDSTRENGTHENED:rgba(0,0,0,0.2);--weui-YELLOW-100:#ffc300;--weui-YELLOW-120:#ffcf33;--weui-YELLOW-170:#ffecb2;--weui-YELLOW-80:#cc9c00;--weui-YELLOW-90:#e6af00;--weui-YELLOW-BG-100:#efb600;--weui-YELLOW-BG-110:#f0bd19;--weui-YELLOW-BG-130:#f3cc4d;--weui-YELLOW-BG-90:#d7a400;--weui-FG-HALF:rgba(0,0,0,0.9);--weui-RED:#fa5151;--weui-ORANGERED:#ff6146;--weui-ORANGE:#fa9d3b;--weui-YELLOW:#ffc300;--weui-GREEN:#91d300;--weui-LIGHTGREEN:#95ec69;--weui-TEXTGREEN:#06ae56;--weui-BRAND:#07c160;--weui-BLUE:#10aeff;--weui-INDIGO:#1485ee;--weui-PURPLE:#6467f0;--weui-LINK:#576b95;--weui-TAG-TEXT-ORANGE:#fa9d3b;--weui-TAG-TEXT-GREEN:#06ae56;--weui-TAG-TEXT-BLUE:#10aeff;--weui-REDORANGE:#ff6146;--weui-TAG-TEXT-BLACK:rgba(0,0,0,0.5);--weui-TAG-BACKGROUND-BLACK:rgba(0,0,0,0.05);--weui-WHITE:#fff;--weui-BG:#fff;--weui-FG:#000;--weui-FG-5:rgba(0,0,0,0.05);--weui-TAG-BACKGROUND-ORANGE:rgba(250,157,59,0.1);--weui-TAG-BACKGROUND-GREEN:rgba(6,174,86,0.1);--weui-TAG-TEXT-RED:rgba(250,81,81,0.6);--weui-TAG-BACKGROUND-RED:rgba(250,81,81,0.1);--weui-TAG-BACKGROUND-BLUE:rgba(16,174,255,0.1)}@media (prefers-color-scheme:dark){.wx-root:not([data-weui-theme=light]),body:not([data-weui-theme=light]){--weui-BG-0:#111;--weui-BG-1:#1e1e1e;--weui-BG-2:#191919;--weui-BG-3:#202020;--weui-BG-4:#404040;--weui-BG-5:#2c2c2c;--weui-BLUE-100:#10aeff;--weui-BLUE-120:#0c8bcc;--weui-BLUE-170:#04344d;--weui-BLUE-80:#3fbeff;--weui-BLUE-90:#28b6ff;--weui-BLUE-BG-100:#48a6e2;--weui-BLUE-BG-110:#4095cb;--weui-BLUE-BG-130:#32749e;--weui-BLUE-BG-90:#5aafe4;--weui-BRAND-100:#07c160;--weui-BRAND-120:#059a4c;--weui-BRAND-170:#023a1c;--weui-BRAND-80:#38cd7f;--weui-BRAND-90:#20c770;--weui-BRAND-BG-100:#2aae67;--weui-BRAND-BG-110:#259c5c;--weui-BRAND-BG-130:#1d7a48;--weui-BRAND-BG-90:#3eb575;--weui-FG-0:hsla(0,0%,100%,0.8);--weui-FG-0_5:hsla(0,0%,100%,0.6);--weui-FG-1:hsla(0,0%,100%,0.5);--weui-FG-2:hsla(0,0%,100%,0.3);--weui-FG-3:hsla(0,0%,100%,0.1);--weui-FG-4:hsla(0,0%,100%,0.15);--weui-GLYPH-0:hsla(0,0%,100%,0.8);--weui-GLYPH-1:hsla(0,0%,100%,0.5);--weui-GLYPH-2:hsla(0,0%,100%,0.3);--weui-GLYPH-WHITE-0:hsla(0,0%,100%,0.8);--weui-GLYPH-WHITE-1:hsla(0,0%,100%,0.5);--weui-GLYPH-WHITE-2:hsla(0,0%,100%,0.3);--weui-GLYPH-WHITE-3:#fff;--weui-GREEN-100:#74a800;--weui-GREEN-120:#5c8600;--weui-GREEN-170:#233200;--weui-GREEN-80:#8fb933;--weui-GREEN-90:#82b01a;--weui-GREEN-BG-100:#789833;--weui-GREEN-BG-110:#6b882d;--weui-GREEN-BG-130:#65802b;--weui-GREEN-BG-90:#85a247;--weui-INDIGO-100:#1196ff;--weui-INDIGO-120:#0d78cc;--weui-INDIGO-170:#052d4d;--weui-INDIGO-80:#40abff;--weui-INDIGO-90:#28a0ff;--weui-INDIGO-BG-100:#0d78cc;--weui-INDIGO-BG-110:#0b6bb7;--weui-INDIGO-BG-130:#09548f;--weui-INDIGO-BG-90:#2585d1;--weui-LIGHTGREEN-100:#3eb575;--weui-LIGHTGREEN-120:#31905d;--weui-LIGHTGREEN-170:#123522;--weui-LIGHTGREEN-80:#64c390;--weui-LIGHTGREEN-90:#51bc83;--weui-LIGHTGREEN-BG-100:#31905d;--weui-LIGHTGREEN-BG-110:#2c8153;--weui-LIGHTGREEN-BG-130:#226541;--weui-LIGHTGREEN-BG-90:#31905d;--weui-LINK-100:#7d90a9;--weui-LINK-120:#647387;--weui-LINK-170:#252a32;--weui-LINK-80:#97a6ba;--weui-LINK-90:#899ab1;--weui-LINKFINDER-100:#dee9ff;--weui-MATERIAL-ATTACHMENTCOLUMN:rgba(32,32,32,0.93);--weui-MATERIAL-NAVIGATIONBAR:rgba(18,18,18,0.9);--weui-MATERIAL-REGULAR:rgba(37,37,37,0.6);--weui-MATERIAL-THICK:rgba(34,34,34,0.9);--weui-MATERIAL-THIN:rgba(95,95,95,0.4);--weui-MATERIAL-TOOLBAR:rgba(35,35,35,0.93);--weui-ORANGE-100:#c87d2f;--weui-ORANGE-120:#a06425;--weui-ORANGE-170:#3b250e;--weui-ORANGE-80:#d39758;--weui-ORANGE-90:#cd8943;--weui-ORANGE-BG-100:#bb6000;--weui-ORANGE-BG-110:#a85600;--weui-ORANGE-BG-130:#824300;--weui-ORANGE-BG-90:#c1701a;--weui-ORANGERED-100:#ff6146;--weui-OVERLAY:rgba(0,0,0,0.8);--weui-OVERLAY-WHITE:hsla(0,0%,94.9%,0.8);--weui-PURPLE-100:#8183ff;--weui-PURPLE-120:#6768cc;--weui-PURPLE-170:#26274c;--weui-PURPLE-80:#9a9bff;--weui-PURPLE-90:#8d8fff;--weui-PURPLE-BG-100:#6768cc;--weui-PURPLE-BG-110:#5c5db7;--weui-PURPLE-BG-130:#48498f;--weui-PURPLE-BG-90:#7677d1;--weui-RED-100:#fa5151;--weui-RED-120:#c84040;--weui-RED-170:#4b1818;--weui-RED-80:#fb7373;--weui-RED-90:#fa6262;--weui-RED-BG-100:#cf5148;--weui-RED-BG-110:#ba4940;--weui-RED-BG-130:#913832;--weui-RED-BG-90:#d3625a;--weui-SECONDARY-BG:hsla(0,0%,100%,0.1);--weui-SEPARATOR-0:hsla(0,0%,100%,0.05);--weui-SEPARATOR-1:hsla(0,0%,100%,0.15);--weui-STATELAYER-HOVERED:rgba(0,0,0,0.02);--weui-STATELAYER-PRESSED:hsla(0,0%,100%,0.1);--weui-STATELAYER-PRESSEDSTRENGTHENED:hsla(0,0%,100%,0.2);--weui-YELLOW-100:#cc9c00;--weui-YELLOW-120:#a37c00;--weui-YELLOW-170:#3d2f00;--weui-YELLOW-80:#d6af33;--weui-YELLOW-90:#d1a519;--weui-YELLOW-BG-100:#bf9100;--weui-YELLOW-BG-110:#ab8200;--weui-YELLOW-BG-130:#866500;--weui-YELLOW-BG-90:#c59c1a;--weui-FG-HALF:hsla(0,0%,100%,0.6);--weui-RED:#fa5151;--weui-ORANGERED:#ff6146;--weui-ORANGE:#c87d2f;--weui-YELLOW:#cc9c00;--weui-GREEN:#74a800;--weui-LIGHTGREEN:#3eb575;--weui-TEXTGREEN:#259c5c;--weui-BRAND:#07c160;--weui-BLUE:#10aeff;--weui-INDIGO:#1196ff;--weui-PURPLE:#8183ff;--weui-LINK:#7d90a9;--weui-REDORANGE:#ff6146;--weui-TAG-TEXT-BLACK:hsla(0,0%,100%,0.5);--weui-TAG-BACKGROUND-BLACK:hsla(0,0%,100%,0.05);--weui-WHITE:hsla(0,0%,100%,0.8);--weui-FG:#fff;--weui-BG:#000;--weui-FG-5:hsla(0,0%,100%,0.1);--weui-TAG-BACKGROUND-ORANGE:rgba(250,157,59,0.1);--weui-TAG-BACKGROUND-GREEN:rgba(6,174,86,0.1);--weui-TAG-TEXT-RED:rgba(250,81,81,0.6);--weui-TAG-BACKGROUND-RED:rgba(250,81,81,0.1);--weui-TAG-BACKGROUND-BLUE:rgba(16,174,255,0.1);--weui-TAG-TEXT-ORANGE:rgba(250,157,59,0.6);--weui-TAG-TEXT-GREEN:rgba(6,174,86,0.6);--weui-TAG-TEXT-BLUE:rgba(16,174,255,0.6)}}.wx-root[data-weui-theme=dark],body[data-weui-theme=dark]{--weui-BG-0:#111;--weui-BG-1:#1e1e1e;--weui-BG-2:#191919;--weui-BG-3:#202020;--weui-BG-4:#404040;--weui-BG-5:#2c2c2c;--weui-BLUE-100:#10aeff;--weui-BLUE-120:#0c8bcc;--weui-BLUE-170:#04344d;--weui-BLUE-80:#3fbeff;--weui-BLUE-90:#28b6ff;--weui-BLUE-BG-100:#48a6e2;--weui-BLUE-BG-110:#4095cb;--weui-BLUE-BG-130:#32749e;--weui-BLUE-BG-90:#5aafe4;--weui-BRAND-100:#07c160;--weui-BRAND-120:#059a4c;--weui-BRAND-170:#023a1c;--weui-BRAND-80:#38cd7f;--weui-BRAND-90:#20c770;--weui-BRAND-BG-100:#2aae67;--weui-BRAND-BG-110:#259c5c;--weui-BRAND-BG-130:#1d7a48;--weui-BRAND-BG-90:#3eb575;--weui-FG-0:hsla(0,0%,100%,0.8);--weui-FG-0_5:hsla(0,0%,100%,0.6);--weui-FG-1:hsla(0,0%,100%,0.5);--weui-FG-2:hsla(0,0%,100%,0.3);--weui-FG-3:hsla(0,0%,100%,0.1);--weui-FG-4:hsla(0,0%,100%,0.15);--weui-GLYPH-0:hsla(0,0%,100%,0.8);--weui-GLYPH-1:hsla(0,0%,100%,0.5);--weui-GLYPH-2:hsla(0,0%,100%,0.3);--weui-GLYPH-WHITE-0:hsla(0,0%,100%,0.8);--weui-GLYPH-WHITE-1:hsla(0,0%,100%,0.5);--weui-GLYPH-WHITE-2:hsla(0,0%,100%,0.3);--weui-GLYPH-WHITE-3:#fff;--weui-GREEN-100:#74a800;--weui-GREEN-120:#5c8600;--weui-GREEN-170:#233200;--weui-GREEN-80:#8fb933;--weui-GREEN-90:#82b01a;--weui-GREEN-BG-100:#789833;--weui-GREEN-BG-110:#6b882d;--weui-GREEN-BG-130:#65802b;--weui-GREEN-BG-90:#85a247;--weui-INDIGO-100:#1196ff;--weui-INDIGO-120:#0d78cc;--weui-INDIGO-170:#052d4d;--weui-INDIGO-80:#40abff;--weui-INDIGO-90:#28a0ff;--weui-INDIGO-BG-100:#0d78cc;--weui-INDIGO-BG-110:#0b6bb7;--weui-INDIGO-BG-130:#09548f;--weui-INDIGO-BG-90:#2585d1;--weui-LIGHTGREEN-100:#3eb575;--weui-LIGHTGREEN-120:#31905d;--weui-LIGHTGREEN-170:#123522;--weui-LIGHTGREEN-80:#64c390;--weui-LIGHTGREEN-90:#51bc83;--weui-LIGHTGREEN-BG-100:#31905d;--weui-LIGHTGREEN-BG-110:#2c8153;--weui-LIGHTGREEN-BG-130:#226541;--weui-LIGHTGREEN-BG-90:#31905d;--weui-LINK-100:#7d90a9;--weui-LINK-120:#647387;--weui-LINK-170:#252a32;--weui-LINK-80:#97a6ba;--weui-LINK-90:#899ab1;--weui-LINKFINDER-100:#dee9ff;--weui-MATERIAL-ATTACHMENTCOLUMN:rgba(32,32,32,0.93);--weui-MATERIAL-NAVIGATIONBAR:rgba(18,18,18,0.9);--weui-MATERIAL-REGULAR:rgba(37,37,37,0.6);--weui-MATERIAL-THICK:rgba(34,34,34,0.9);--weui-MATERIAL-THIN:rgba(95,95,95,0.4);--weui-MATERIAL-TOOLBAR:rgba(35,35,35,0.93);--weui-ORANGE-100:#c87d2f;--weui-ORANGE-120:#a06425;--weui-ORANGE-170:#3b250e;--weui-ORANGE-80:#d39758;--weui-ORANGE-90:#cd8943;--weui-ORANGE-BG-100:#bb6000;--weui-ORANGE-BG-110:#a85600;--weui-ORANGE-BG-130:#824300;--weui-ORANGE-BG-90:#c1701a;--weui-ORANGERED-100:#ff6146;--weui-OVERLAY:rgba(0,0,0,0.8);--weui-OVERLAY-WHITE:hsla(0,0%,94.9%,0.8);--weui-PURPLE-100:#8183ff;--weui-PURPLE-120:#6768cc;--weui-PURPLE-170:#26274c;--weui-PURPLE-80:#9a9bff;--weui-PURPLE-90:#8d8fff;--weui-PURPLE-BG-100:#6768cc;--weui-PURPLE-BG-110:#5c5db7;--weui-PURPLE-BG-130:#48498f;--weui-PURPLE-BG-90:#7677d1;--weui-RED-100:#fa5151;--weui-RED-120:#c84040;--weui-RED-170:#4b1818;--weui-RED-80:#fb7373;--weui-RED-90:#fa6262;--weui-RED-BG-100:#cf5148;--weui-RED-BG-110:#ba4940;--weui-RED-BG-130:#913832;--weui-RED-BG-90:#d3625a;--weui-SECONDARY-BG:hsla(0,0%,100%,0.1);--weui-SEPARATOR-0:hsla(0,0%,100%,0.05);--weui-SEPARATOR-1:hsla(0,0%,100%,0.15);--weui-STATELAYER-HOVERED:rgba(0,0,0,0.02);--weui-STATELAYER-PRESSED:hsla(0,0%,100%,0.1);--weui-STATELAYER-PRESSEDSTRENGTHENED:hsla(0,0%,100%,0.2);--weui-YELLOW-100:#cc9c00;--weui-YELLOW-120:#a37c00;--weui-YELLOW-170:#3d2f00;--weui-YELLOW-80:#d6af33;--weui-YELLOW-90:#d1a519;--weui-YELLOW-BG-100:#bf9100;--weui-YELLOW-BG-110:#ab8200;--weui-YELLOW-BG-130:#866500;--weui-YELLOW-BG-90:#c59c1a;--weui-FG-HALF:hsla(0,0%,100%,0.6);--weui-RED:#fa5151;--weui-ORANGERED:#ff6146;--weui-ORANGE:#c87d2f;--weui-YELLOW:#cc9c00;--weui-GREEN:#74a800;--weui-LIGHTGREEN:#3eb575;--weui-TEXTGREEN:#259c5c;--weui-BRAND:#07c160;--weui-BLUE:#10aeff;--weui-INDIGO:#1196ff;--weui-PURPLE:#8183ff;--weui-LINK:#7d90a9;--weui-REDORANGE:#ff6146;--weui-TAG-TEXT-BLACK:hsla(0,0%,100%,0.5);--weui-TAG-BACKGROUND-BLACK:hsla(0,0%,100%,0.05);--weui-WHITE:hsla(0,0%,100%,0.8);--weui-FG:#fff;--weui-BG:#000;--weui-FG-5:hsla(0,0%,100%,0.1);--weui-TAG-BACKGROUND-ORANGE:rgba(250,157,59,0.1);--weui-TAG-BACKGROUND-GREEN:rgba(6,174,86,0.1);--weui-TAG-TEXT-RED:rgba(250,81,81,0.6);--weui-TAG-BACKGROUND-RED:rgba(250,81,81,0.1);--weui-TAG-BACKGROUND-BLUE:rgba(16,174,255,0.1);--weui-TAG-TEXT-ORANGE:rgba(250,157,59,0.6);--weui-TAG-TEXT-GREEN:rgba(6,174,86,0.6);--weui-TAG-TEXT-BLUE:rgba(16,174,255,0.6)}.wx-root[data-weui-mode=care],body[data-weui-mode=care]{--weui-BG-0:#ededed;--weui-BG-1:#f7f7f7;--weui-BG-2:#fff;--weui-BG-3:#f7f7f7;--weui-BG-4:#4c4c4c;--weui-BG-5:#fff;--weui-BLUE-100:#007dbb;--weui-BLUE-120:#3fbeff;--weui-BLUE-170:#b7e6ff;--weui-BLUE-80:#0c8bcc;--weui-BLUE-90:#0e9ce6;--weui-BLUE-BG-100:#48a6e2;--weui-BLUE-BG-110:#5aafe4;--weui-BLUE-BG-130:#7fc0ea;--weui-BLUE-BG-90:#4095cb;--weui-BRAND-100:#018942;--weui-BRAND-120:#38cd7f;--weui-BRAND-170:#b4ecce;--weui-BRAND-80:#059a4c;--weui-BRAND-90:#06ae56;--weui-BRAND-BG-100:#2aae67;--weui-BRAND-BG-110:#3eb575;--weui-BRAND-BG-130:#69c694;--weui-BRAND-BG-90:#259c5c;--weui-FG-0:#000;--weui-FG-0_5:#000;--weui-FG-1:rgba(0,0,0,0.6);--weui-FG-2:rgba(0,0,0,0.42);--weui-FG-3:rgba(0,0,0,0.1);--weui-FG-4:rgba(0,0,0,0.15);--weui-GLYPH-0:#000;--weui-GLYPH-1:rgba(0,0,0,0.6);--weui-GLYPH-2:rgba(0,0,0,0.42);--weui-GLYPH-WHITE-0:hsla(0,0%,100%,0.85);--weui-GLYPH-WHITE-1:hsla(0,0%,100%,0.55);--weui-GLYPH-WHITE-2:hsla(0,0%,100%,0.35);--weui-GLYPH-WHITE-3:#fff;--weui-GREEN-100:#4f8400;--weui-GREEN-120:#a7db33;--weui-GREEN-170:#def1b3;--weui-GREEN-80:#74a800;--weui-GREEN-90:#82bd00;--weui-GREEN-BG-100:#96be40;--weui-GREEN-BG-110:#a0c452;--weui-GREEN-BG-130:#b5d179;--weui-GREEN-BG-90:#86aa39;--weui-INDIGO-100:#0075e2;--weui-INDIGO-120:#439df1;--weui-INDIGO-170:#b8daf9;--weui-INDIGO-80:#106abe;--weui-INDIGO-90:#1277d6;--weui-INDIGO-BG-100:#2b77bf;--weui-INDIGO-BG-110:#3f84c5;--weui-INDIGO-BG-130:#6ba0d2;--weui-INDIGO-BG-90:#266aab;--weui-LIGHTGREEN-100:#2e8800;--weui-LIGHTGREEN-120:#aaef87;--weui-LIGHTGREEN-170:#def9d1;--weui-LIGHTGREEN-80:#77bc54;--weui-LIGHTGREEN-90:#85d35e;--weui-LIGHTGREEN-BG-100:#72cf60;--weui-LIGHTGREEN-BG-110:#80d370;--weui-LIGHTGREEN-BG-130:#9cdd90;--weui-LIGHTGREEN-BG-90:#66b956;--weui-LINK-100:#576b95;--weui-LINK-120:#7888aa;--weui-LINK-170:#ccd2de;--weui-LINK-80:#455577;--weui-LINK-90:#4e6085;--weui-LINKFINDER-100:#002666;--weui-MATERIAL-ATTACHMENTCOLUMN:hsla(0,0%,96.1%,0.95);--weui-MATERIAL-NAVIGATIONBAR:hsla(0,0%,92.9%,0.94);--weui-MATERIAL-REGULAR:hsla(0,0%,96.9%,0.3);--weui-MATERIAL-THICK:hsla(0,0%,96.9%,0.8);--weui-MATERIAL-THIN:hsla(0,0%,100%,0.2);--weui-MATERIAL-TOOLBAR:hsla(0,0%,96.5%,0.82);--weui-ORANGE-100:#e17719;--weui-ORANGE-120:#fbb062;--weui-ORANGE-170:#fde1c3;--weui-ORANGE-80:#c87d2f;--weui-ORANGE-90:#e08c34;--weui-ORANGE-BG-100:#ea7800;--weui-ORANGE-BG-110:#ec8519;--weui-ORANGE-BG-130:#f0a04d;--weui-ORANGE-BG-90:#d26b00;--weui-ORANGERED-100:#d14730;--weui-OVERLAY:rgba(0,0,0,0.5);--weui-OVERLAY-WHITE:hsla(0,0%,94.9%,0.8);--weui-PURPLE-100:#6265f1;--weui-PURPLE-120:#8385f3;--weui-PURPLE-170:#d0d1fa;--weui-PURPLE-80:#5052c0;--weui-PURPLE-90:#595cd7;--weui-PURPLE-BG-100:#6769ba;--weui-PURPLE-BG-110:#7678c1;--weui-PURPLE-BG-130:#9496ce;--weui-PURPLE-BG-90:#5c5ea7;--weui-RED-100:#dc3636;--weui-RED-120:#fb7373;--weui-RED-170:#fdcaca;--weui-RED-80:#c84040;--weui-RED-90:#e14949;--weui-RED-BG-100:#cf5148;--weui-RED-BG-110:#d3625a;--weui-RED-BG-130:#dd847e;--weui-RED-BG-90:#b94840;--weui-SECONDARY-BG:rgba(0,0,0,0.1);--weui-SEPARATOR-0:rgba(0,0,0,0.1);--weui-SEPARATOR-1:rgba(0,0,0,0.15);--weui-STATELAYER-HOVERED:rgba(0,0,0,0.02);--weui-STATELAYER-PRESSED:rgba(0,0,0,0.1);--weui-STATELAYER-PRESSEDSTRENGTHENED:rgba(0,0,0,0.2);--weui-YELLOW-100:#bb8e00;--weui-YELLOW-120:#ffcf33;--weui-YELLOW-170:#ffecb2;--weui-YELLOW-80:#cc9c00;--weui-YELLOW-90:#e6af00;--weui-YELLOW-BG-100:#efb600;--weui-YELLOW-BG-110:#f0bd19;--weui-YELLOW-BG-130:#f3cc4d;--weui-YELLOW-BG-90:#d7a400;--weui-FG-HALF:#000;--weui-RED:#dc3636;--weui-ORANGERED:#d14730;--weui-ORANGE:#e17719;--weui-YELLOW:#bb8e00;--weui-GREEN:#4f8400;--weui-LIGHTGREEN:#2e8800;--weui-TEXTGREEN:#06ae56;--weui-BRAND:#018942;--weui-BLUE:#007dbb;--weui-INDIGO:#0075e2;--weui-PURPLE:#6265f1;--weui-LINK:#576b95;--weui-TAG-TEXT-ORANGE:#e17719;--weui-TAG-TEXT-GREEN:#06ae56;--weui-TAG-TEXT-BLUE:#007dbb;--weui-REDORANGE:#d14730;--weui-TAG-TEXT-BLACK:rgba(0,0,0,0.5);--weui-WHITE:#fff;--weui-BG:#fff;--weui-FG:#000;--weui-FG-5:rgba(0,0,0,0.05);--weui-TAG-BACKGROUND-ORANGE:rgba(225,119,25,0.1);--weui-TAG-BACKGROUND-GREEN:rgba(6,174,86,0.1);--weui-TAG-TEXT-RED:rgba(250,81,81,0.6);--weui-TAG-BACKGROUND-RED:rgba(250,81,81,0.1);--weui-TAG-BACKGROUND-BLUE:rgba(0,125,187,0.1);--weui-TAG-BACKGROUND-BLACK:rgba(0,0,0,0.05)}@media (prefers-color-scheme:dark){.wx-root[data-weui-mode=care]:not([data-weui-theme=light]),body[data-weui-mode=care]:not([data-weui-theme=light]){--weui-BG-0:#111;--weui-BG-1:#1e1e1e;--weui-BG-2:#191919;--weui-BG-3:#202020;--weui-BG-4:#404040;--weui-BG-5:#2c2c2c;--weui-BLUE-100:#10aeff;--weui-BLUE-120:#0c8bcc;--weui-BLUE-170:#04344d;--weui-BLUE-80:#3fbeff;--weui-BLUE-90:#28b6ff;--weui-BLUE-BG-100:#48a6e2;--weui-BLUE-BG-110:#4095cb;--weui-BLUE-BG-130:#32749e;--weui-BLUE-BG-90:#5aafe4;--weui-BRAND-100:#07c160;--weui-BRAND-120:#059a4c;--weui-BRAND-170:#023a1c;--weui-BRAND-80:#38cd7f;--weui-BRAND-90:#20c770;--weui-BRAND-BG-100:#2aae67;--weui-BRAND-BG-110:#259c5c;--weui-BRAND-BG-130:#1d7a48;--weui-BRAND-BG-90:#3eb575;--weui-FG-0:hsla(0,0%,100%,0.85);--weui-FG-0_5:hsla(0,0%,100%,0.65);--weui-FG-1:hsla(0,0%,100%,0.55);--weui-FG-2:hsla(0,0%,100%,0.35);--weui-FG-3:hsla(0,0%,100%,0.1);--weui-FG-4:hsla(0,0%,100%,0.15);--weui-GLYPH-0:hsla(0,0%,100%,0.85);--weui-GLYPH-1:hsla(0,0%,100%,0.55);--weui-GLYPH-2:hsla(0,0%,100%,0.35);--weui-GLYPH-WHITE-0:hsla(0,0%,100%,0.85);--weui-GLYPH-WHITE-1:hsla(0,0%,100%,0.55);--weui-GLYPH-WHITE-2:hsla(0,0%,100%,0.35);--weui-GLYPH-WHITE-3:#fff;--weui-GREEN-100:#74a800;--weui-GREEN-120:#5c8600;--weui-GREEN-170:#233200;--weui-GREEN-80:#8fb933;--weui-GREEN-90:#82b01a;--weui-GREEN-BG-100:#789833;--weui-GREEN-BG-110:#6b882d;--weui-GREEN-BG-130:#65802b;--weui-GREEN-BG-90:#85a247;--weui-INDIGO-100:#1196ff;--weui-INDIGO-120:#0d78cc;--weui-INDIGO-170:#052d4d;--weui-INDIGO-80:#40abff;--weui-INDIGO-90:#28a0ff;--weui-INDIGO-BG-100:#0d78cc;--weui-INDIGO-BG-110:#0b6bb7;--weui-INDIGO-BG-130:#09548f;--weui-INDIGO-BG-90:#2585d1;--weui-LIGHTGREEN-100:#3eb575;--weui-LIGHTGREEN-120:#31905d;--weui-LIGHTGREEN-170:#123522;--weui-LIGHTGREEN-80:#64c390;--weui-LIGHTGREEN-90:#51bc83;--weui-LIGHTGREEN-BG-100:#31905d;--weui-LIGHTGREEN-BG-110:#2c8153;--weui-LIGHTGREEN-BG-130:#226541;--weui-LIGHTGREEN-BG-90:#31905d;--weui-LINK-100:#7d90a9;--weui-LINK-120:#647387;--weui-LINK-170:#252a32;--weui-LINK-80:#97a6ba;--weui-LINK-90:#899ab1;--weui-LINKFINDER-100:#dee9ff;--weui-MATERIAL-ATTACHMENTCOLUMN:rgba(32,32,32,0.93);--weui-MATERIAL-NAVIGATIONBAR:rgba(18,18,18,0.9);--weui-MATERIAL-REGULAR:rgba(37,37,37,0.6);--weui-MATERIAL-THICK:rgba(34,34,34,0.9);--weui-MATERIAL-THIN:hsla(0,0%,96.1%,0.4);--weui-MATERIAL-TOOLBAR:rgba(35,35,35,0.93);--weui-ORANGE-100:#c87d2f;--weui-ORANGE-120:#a06425;--weui-ORANGE-170:#3b250e;--weui-ORANGE-80:#d39758;--weui-ORANGE-90:#cd8943;--weui-ORANGE-BG-100:#bb6000;--weui-ORANGE-BG-110:#a85600;--weui-ORANGE-BG-130:#824300;--weui-ORANGE-BG-90:#c1701a;--weui-ORANGERED-100:#ff6146;--weui-OVERLAY:rgba(0,0,0,0.8);--weui-OVERLAY-WHITE:hsla(0,0%,94.9%,0.8);--weui-PURPLE-100:#8183ff;--weui-PURPLE-120:#6768cc;--weui-PURPLE-170:#26274c;--weui-PURPLE-80:#9a9bff;--weui-PURPLE-90:#8d8fff;--weui-PURPLE-BG-100:#6768cc;--weui-PURPLE-BG-110:#5c5db7;--weui-PURPLE-BG-130:#48498f;--weui-PURPLE-BG-90:#7677d1;--weui-RED-100:#fa5151;--weui-RED-120:#c84040;--weui-RED-170:#4b1818;--weui-RED-80:#fb7373;--weui-RED-90:#fa6262;--weui-RED-BG-100:#cf5148;--weui-RED-BG-110:#ba4940;--weui-RED-BG-130:#913832;--weui-RED-BG-90:#d3625a;--weui-SECONDARY-BG:hsla(0,0%,100%,0.15);--weui-SEPARATOR-0:hsla(0,0%,100%,0.05);--weui-SEPARATOR-1:hsla(0,0%,100%,0.15);--weui-STATELAYER-HOVERED:rgba(0,0,0,0.02);--weui-STATELAYER-PRESSED:hsla(0,0%,100%,0.1);--weui-STATELAYER-PRESSEDSTRENGTHENED:hsla(0,0%,100%,0.2);--weui-YELLOW-100:#cc9c00;--weui-YELLOW-120:#a37c00;--weui-YELLOW-170:#3d2f00;--weui-YELLOW-80:#d6af33;--weui-YELLOW-90:#d1a519;--weui-YELLOW-BG-100:#bf9100;--weui-YELLOW-BG-110:#ab8200;--weui-YELLOW-BG-130:#866500;--weui-YELLOW-BG-90:#c59c1a;--weui-FG-HALF:hsla(0,0%,100%,0.65);--weui-RED:#fa5151;--weui-ORANGERED:#ff6146;--weui-ORANGE:#c87d2f;--weui-YELLOW:#cc9c00;--weui-GREEN:#74a800;--weui-LIGHTGREEN:#3eb575;--weui-TEXTGREEN:#259c5c;--weui-BRAND:#07c160;--weui-BLUE:#10aeff;--weui-INDIGO:#1196ff;--weui-PURPLE:#8183ff;--weui-LINK:#7d90a9;--weui-REDORANGE:#ff6146;--weui-TAG-BACKGROUND-BLACK:hsla(0,0%,100%,0.05);--weui-FG:#fff;--weui-WHITE:hsla(0,0%,100%,0.8);--weui-FG-5:hsla(0,0%,100%,0.1);--weui-TAG-BACKGROUND-ORANGE:rgba(250,157,59,0.1);--weui-TAG-BACKGROUND-GREEN:rgba(6,174,86,0.1);--weui-TAG-TEXT-RED:rgba(250,81,81,0.6);--weui-TAG-BACKGROUND-RED:rgba(250,81,81,0.1);--weui-TAG-BACKGROUND-BLUE:rgba(16,174,255,0.1);--weui-TAG-TEXT-ORANGE:rgba(250,157,59,0.6);--weui-BG:#000;--weui-TAG-TEXT-GREEN:rgba(6,174,86,0.6);--weui-TAG-TEXT-BLUE:rgba(16,174,255,0.6);--weui-TAG-TEXT-BLACK:hsla(0,0%,100%,0.5)}}.wx-root[data-weui-mode=care][data-weui-theme=dark],body[data-weui-mode=care][data-weui-theme=dark]{--weui-BG-0:#111;--weui-BG-1:#1e1e1e;--weui-BG-2:#191919;--weui-BG-3:#202020;--weui-BG-4:#404040;--weui-BG-5:#2c2c2c;--weui-BLUE-100:#10aeff;--weui-BLUE-120:#0c8bcc;--weui-BLUE-170:#04344d;--weui-BLUE-80:#3fbeff;--weui-BLUE-90:#28b6ff;--weui-BLUE-BG-100:#48a6e2;--weui-BLUE-BG-110:#4095cb;--weui-BLUE-BG-130:#32749e;--weui-BLUE-BG-90:#5aafe4;--weui-BRAND-100:#07c160;--weui-BRAND-120:#059a4c;--weui-BRAND-170:#023a1c;--weui-BRAND-80:#38cd7f;--weui-BRAND-90:#20c770;--weui-BRAND-BG-100:#2aae67;--weui-BRAND-BG-110:#259c5c;--weui-BRAND-BG-130:#1d7a48;--weui-BRAND-BG-90:#3eb575;--weui-FG-0:hsla(0,0%,100%,0.85);--weui-FG-0_5:hsla(0,0%,100%,0.65);--weui-FG-1:hsla(0,0%,100%,0.55);--weui-FG-2:hsla(0,0%,100%,0.35);--weui-FG-3:hsla(0,0%,100%,0.1);--weui-FG-4:hsla(0,0%,100%,0.15);--weui-GLYPH-0:hsla(0,0%,100%,0.85);--weui-GLYPH-1:hsla(0,0%,100%,0.55);--weui-GLYPH-2:hsla(0,0%,100%,0.35);--weui-GLYPH-WHITE-0:hsla(0,0%,100%,0.85);--weui-GLYPH-WHITE-1:hsla(0,0%,100%,0.55);--weui-GLYPH-WHITE-2:hsla(0,0%,100%,0.35);--weui-GLYPH-WHITE-3:#fff;--weui-GREEN-100:#74a800;--weui-GREEN-120:#5c8600;--weui-GREEN-170:#233200;--weui-GREEN-80:#8fb933;--weui-GREEN-90:#82b01a;--weui-GREEN-BG-100:#789833;--weui-GREEN-BG-110:#6b882d;--weui-GREEN-BG-130:#65802b;--weui-GREEN-BG-90:#85a247;--weui-INDIGO-100:#1196ff;--weui-INDIGO-120:#0d78cc;--weui-INDIGO-170:#052d4d;--weui-INDIGO-80:#40abff;--weui-INDIGO-90:#28a0ff;--weui-INDIGO-BG-100:#0d78cc;--weui-INDIGO-BG-110:#0b6bb7;--weui-INDIGO-BG-130:#09548f;--weui-INDIGO-BG-90:#2585d1;--weui-LIGHTGREEN-100:#3eb575;--weui-LIGHTGREEN-120:#31905d;--weui-LIGHTGREEN-170:#123522;--weui-LIGHTGREEN-80:#64c390;--weui-LIGHTGREEN-90:#51bc83;--weui-LIGHTGREEN-BG-100:#31905d;--weui-LIGHTGREEN-BG-110:#2c8153;--weui-LIGHTGREEN-BG-130:#226541;--weui-LIGHTGREEN-BG-90:#31905d;--weui-LINK-100:#7d90a9;--weui-LINK-120:#647387;--weui-LINK-170:#252a32;--weui-LINK-80:#97a6ba;--weui-LINK-90:#899ab1;--weui-LINKFINDER-100:#dee9ff;--weui-MATERIAL-ATTACHMENTCOLUMN:rgba(32,32,32,0.93);--weui-MATERIAL-NAVIGATIONBAR:rgba(18,18,18,0.9);--weui-MATERIAL-REGULAR:rgba(37,37,37,0.6);--weui-MATERIAL-THICK:rgba(34,34,34,0.9);--weui-MATERIAL-THIN:hsla(0,0%,96.1%,0.4);--weui-MATERIAL-TOOLBAR:rgba(35,35,35,0.93);--weui-ORANGE-100:#c87d2f;--weui-ORANGE-120:#a06425;--weui-ORANGE-170:#3b250e;--weui-ORANGE-80:#d39758;--weui-ORANGE-90:#cd8943;--weui-ORANGE-BG-100:#bb6000;--weui-ORANGE-BG-110:#a85600;--weui-ORANGE-BG-130:#824300;--weui-ORANGE-BG-90:#c1701a;--weui-ORANGERED-100:#ff6146;--weui-OVERLAY:rgba(0,0,0,0.8);--weui-OVERLAY-WHITE:hsla(0,0%,94.9%,0.8);--weui-PURPLE-100:#8183ff;--weui-PURPLE-120:#6768cc;--weui-PURPLE-170:#26274c;--weui-PURPLE-80:#9a9bff;--weui-PURPLE-90:#8d8fff;--weui-PURPLE-BG-100:#6768cc;--weui-PURPLE-BG-110:#5c5db7;--weui-PURPLE-BG-130:#48498f;--weui-PURPLE-BG-90:#7677d1;--weui-RED-100:#fa5151;--weui-RED-120:#c84040;--weui-RED-170:#4b1818;--weui-RED-80:#fb7373;--weui-RED-90:#fa6262;--weui-RED-BG-100:#cf5148;--weui-RED-BG-110:#ba4940;--weui-RED-BG-130:#913832;--weui-RED-BG-90:#d3625a;--weui-SECONDARY-BG:hsla(0,0%,100%,0.15);--weui-SEPARATOR-0:hsla(0,0%,100%,0.05);--weui-SEPARATOR-1:hsla(0,0%,100%,0.15);--weui-STATELAYER-HOVERED:rgba(0,0,0,0.02);--weui-STATELAYER-PRESSED:hsla(0,0%,100%,0.1);--weui-STATELAYER-PRESSEDSTRENGTHENED:hsla(0,0%,100%,0.2);--weui-YELLOW-100:#cc9c00;--weui-YELLOW-120:#a37c00;--weui-YELLOW-170:#3d2f00;--weui-YELLOW-80:#d6af33;--weui-YELLOW-90:#d1a519;--weui-YELLOW-BG-100:#bf9100;--weui-YELLOW-BG-110:#ab8200;--weui-YELLOW-BG-130:#866500;--weui-YELLOW-BG-90:#c59c1a;--weui-FG-HALF:hsla(0,0%,100%,0.65);--weui-RED:#fa5151;--weui-ORANGERED:#ff6146;--weui-ORANGE:#c87d2f;--weui-YELLOW:#cc9c00;--weui-GREEN:#74a800;--weui-LIGHTGREEN:#3eb575;--weui-TEXTGREEN:#259c5c;--weui-BRAND:#07c160;--weui-BLUE:#10aeff;--weui-INDIGO:#1196ff;--weui-PURPLE:#8183ff;--weui-LINK:#7d90a9;--weui-REDORANGE:#ff6146;--weui-TAG-BACKGROUND-BLACK:hsla(0,0%,100%,0.05);--weui-FG:#fff;--weui-WHITE:hsla(0,0%,100%,0.8);--weui-FG-5:hsla(0,0%,100%,0.1);--weui-TAG-BACKGROUND-ORANGE:rgba(250,157,59,0.1);--weui-TAG-BACKGROUND-GREEN:rgba(6,174,86,0.1);--weui-TAG-TEXT-RED:rgba(250,81,81,0.6);--weui-TAG-BACKGROUND-RED:rgba(250,81,81,0.1);--weui-TAG-BACKGROUND-BLUE:rgba(16,174,255,0.1);--weui-TAG-TEXT-ORANGE:rgba(250,157,59,0.6);--weui-BG:#000;--weui-TAG-TEXT-GREEN:rgba(6,174,86,0.6);--weui-TAG-TEXT-BLUE:rgba(16,174,255,0.6);--weui-TAG-TEXT-BLACK:hsla(0,0%,100%,0.5)}.wx-root{pointer-events:auto;font-family:system-ui,-apple-system,BlinkMacSystemFont,Helvetica Neue,PingFang SC,Hiragino Sans GB,Microsoft YaHei UI,Microsoft YaHei,Arial,sans-serif}.wx-root,.wx_card_root{position:relative}.wxw_hide{display:none!important}.wx_uninteractive{pointer-events:none}.wx-root,body{--APPMSGCARD-BG:#fafafa}.wx-root[data-weui-theme=dark],body[data-weui-theme=dark]{--APPMSGCARD-BG:#1e1e1e}@media (prefers-color-scheme:dark){.wx-root:not([data-weui-theme=light]),body:not([data-weui-theme=light]){--APPMSGCARD-BG:#1e1e1e}}.wx-root,body{--APPMSGCARD-LINE-BG:rgba(0,0,0,0.07)}.wx-root[data-weui-theme=dark],body[data-weui-theme=dark]{--APPMSGCARD-LINE-BG:hsla(0,0%,100%,0.07)}@media (prefers-color-scheme:dark){.wx-root:not([data-weui-theme=light]),body:not([data-weui-theme=light]){--APPMSGCARD-LINE-BG:hsla(0,0%,100%,0.07)}}.appmsg_card_context{position:relative;background-color:var(--APPMSGCARD-BG);border-radius:8px;-webkit-user-select:none;-moz-user-select:none;user-select:none;-webkit-tap-highlight-color:rgba(0,0,0,0)}:host(.wx_tap_highlight_active) .wx_tap_link{opacity:.5}:host(.wx_tap_highlight_active) .wx_tap_card{background-color:#f3f3f3}:host(.wx_tap_highlight_active) .wx_tap_cell{background-color:rgba(0,0,0,.05)}@media (prefers-color-scheme:dark){:host(.wx_tap_highlight_active) .wx_tap_card{background-color:#252525}:host(.wx_tap_highlight_active) .wx_tap_cell{background-color:hsla(0,0%,100%,.1)}}.wx_css_active :active{opacity:.5}.weui-flex__item{min-width:0}.weui-flex_align-center{align-items:center}[tabindex]{outline:0}.wx_hover_card:before{border-radius:8px;border:1px solid rgba(7,193,96,.3)}.wx_hover_card:before,.wx_selected_card:before{content:\" \";position:absolute;top:0;left:0;right:0;bottom:0;box-sizing:border-box;pointer-events:none;z-index:9}.wx_selected_card:before{border-radius:8px;border:1.5px solid #07c160;background:rgba(7,193,96,.1)}img{pointer-events:none}.wx_profile_card{line-height:1.4;text-align:left;text-decoration:none;clear:both;position:relative}.wx_profile_card_ft{padding:8px 16px;align-items:center;position:relative;color:var(--weui-FG-2);font-size:14px}.wx_profile_card_ft:before{content:\"\";content:\" \";position:absolute;left:0;top:0;right:0;height:1px;border-top:1px solid var(--APPMSGCARD-LINE-BG);color:var(--APPMSGCARD-LINE-BG);transform-origin:0 0;transform:scaleY(.5);left:16px;right:16px}.wx_profile_msg{position:absolute;top:0;left:0;width:100%;height:100%;display:flex;align-items:center;justify-content:center;z-index:1;background:hsla(0,0%,100%,.5);font-size:14px;font-weight:400}.common-web{margin:0 auto;max-width:350px}.wx_card_disabled .wx_profile_card_bd{filter:blur(2px)}.wx_profile{padding:20px 16px}.wx_profile .weui-icon-arrow{width:1em;height:2em}.wx_profile_hd{display:flex;padding-right:10px}.wx_profile_hd .wx_profile_avatar{width:44px;height:44px!important;border-radius:100%}.wx_profile_ft{padding-left:10px}.wx_profile_bd{align-items:center}.wx_profile_bd>.weui-flex__item{padding-right:16px}.wx_profile_nickname{display:inline-block;flex-shrink:1;color:var(--weui-FG-0);font-weight:500}.wx_profile_desc{width:auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;word-wrap:normal}.wx_profile_desc,.wx_profile_tips{color:var(--weui-FG-1);font-size:14px;margin-top:4px}.wx_profile_tips{display:flex;min-height:1.4em}.wx_profile_tips:empty{display:none}.wx_profile_nickname_wrp{position:relative;display:flex;flex-direction:row;align-items:center;width:auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;word-wrap:normal;font-size:17px;line-height:1.2;font-weight:500}.wx_follow_verify{flex-shrink:0;height:14px;width:14px;margin-left:2px;background-size:cover;background-position:50%;background-repeat:no-repeat}.wx_follow_verify.show-verify-personal{display:block;background-image:url(https://res.wx.qq.com/op_res/nLnAiLrrETuU96Aym1ZDNuddJ2beY0iOs-D3h-7MPQeIIoXE5kLrgfPY_Vr_hrKamxAjISc12pBthrd7Ja4S4w)}.wx_follow_verify.show-verify-company{display:block;background-image:url(https://res.wx.qq.com/op_res/nLnAiLrrETuU96Aym1ZDNjhMga6Fe1hiYp332DlZsT_u4THJyu8XegVlG723G5FblhAwxLO31iFVMkzq62jS3w)}.wx_follow_verify.show-verify-media{display:block;background-image:url(https://res.wx.qq.com/op_res/n1-Xym4hWn0AbVImOFGmT9sRdHV1rjoe3lnMHwxRdfbguJjDQH16CE7AIfDZy1KVMHWCPJIoAC4jrMEFqmqR4A)}</style>\n      <div role=\"option\" tabindex=\"0\"\n           aria-labelledby=\"js_a11y_wx_profile_nickname js_a11y_comma js_a11y_wx_profile_desc js_a11y_comma0 js_a11y_wx_profile_tips js_a11y_comma1 js_a11y_wx_profile_logo\"\n           class=\"appmsg_card_context wx_profile_card wx-root wx_tap_card wx_card_root common-web\"\n           data-weui-theme=\"light\">\n        <div class=\"wx_profile_card_inner\">\n          <div class=\"wx_profile_card_bd\" aria-hidden=\"true\">\n            <div class=\"wx_profile weui-flex\">\n              <div class=\"wx_profile_hd\">\n                <img src=\"${headimg}\" alt=\"\" class=\"wx_profile_avatar\">\n              </div>\n              <div class=\"wx_profile_bd weui-flex weui-flex__item\">\n                <div class=\"weui-flex__item\">\n                  <div class=\"wx_profile_nickname_wrp\">\n                    <strong id=\"js_a11y_wx_profile_nickname\" class=\"wx_profile_nickname\">${nickname}</strong>\n                    <span class=\"wx_follow_verify ${verifyStatus === `1` ? `show-verify-personal` : verifyStatus === `2` ? `show-verify-company` : ``}\"></span>\n                  </div>\n                  <div id=\"js_a11y_wx_profile_desc\" class=\"wx_profile_desc\">${signature}</div>\n                </div>\n                <i class=\"weui-icon-arrow\"></i>\n              </div>\n            </div>\n          </div>\n          <div id=\"js_a11y_wx_profile_logo\" aria-hidden=\"true\" class=\"wx_profile_card_ft\">\n            ${serviceType === `1` ? `公众号` : `服务号`}\n          </div>\n        </div>\n        <span aria-hidden=\"true\" id=\"js_a11y_comma\" class=\"weui-a11y_ref\" style=\"display: none;\">，</span>\n      </div>\n    `\n  }\n}\n\nexport function setupComponents() {\n  customElements.define(`mp-common-profile`, MpCommonProfile)\n}\n"
  },
  {
    "path": "apps/web/src/utils/storage.ts",
    "content": "/**\n * 现代化存储抽象层 - 完全异步化设计\n * 支持本地存储和 RESTful API 存储，便于后续扩展\n */\n\nimport type { Ref } from 'vue'\nimport { customRef, ref, watch } from 'vue'\n\n/**\n * 存储引擎接口 - 完全异步化\n */\nexport interface StorageEngine {\n  get: (key: string) => Promise<string | null>\n  set: (key: string, value: string) => Promise<void>\n  remove: (key: string) => Promise<void>\n  has: (key: string) => Promise<boolean>\n  clear: () => Promise<void>\n  keys: () => Promise<string[]>\n}\n\n/**\n * 本地存储引擎 - 使用 localStorage\n */\nexport class LocalStorageEngine implements StorageEngine {\n  async get(key: string): Promise<string | null> {\n    try {\n      return localStorage.getItem(key)\n    }\n    catch (error) {\n      console.error(`[Storage] Failed to get item:`, key, error)\n      return null\n    }\n  }\n\n  async set(key: string, value: string): Promise<void> {\n    try {\n      localStorage.setItem(key, value)\n    }\n    catch (error) {\n      console.error(`[Storage] Failed to set item:`, key, error)\n      throw error\n    }\n  }\n\n  async remove(key: string): Promise<void> {\n    try {\n      localStorage.removeItem(key)\n    }\n    catch (error) {\n      console.error(`[Storage] Failed to remove item:`, key, error)\n    }\n  }\n\n  async has(key: string): Promise<boolean> {\n    try {\n      return localStorage.getItem(key) !== null\n    }\n    catch {\n      return false\n    }\n  }\n\n  async clear(): Promise<void> {\n    try {\n      localStorage.clear()\n    }\n    catch (error) {\n      console.error(`[Storage] Failed to clear storage:`, error)\n    }\n  }\n\n  async keys(): Promise<string[]> {\n    try {\n      return Object.keys(localStorage)\n    }\n    catch {\n      return []\n    }\n  }\n}\n\n/**\n * RESTful API 存储引擎 - 用于远程存储\n */\nexport class RestfulStorageEngine implements StorageEngine {\n  constructor(\n    private baseURL: string,\n    private getAuthToken?: () => string | null,\n  ) {}\n\n  private async request(method: string, endpoint: string, data?: any): Promise<any> {\n    const headers: HeadersInit = {\n      'Content-Type': `application/json`,\n    }\n\n    const token = this.getAuthToken?.()\n    if (token) {\n      headers.Authorization = `Bearer ${token}`\n    }\n\n    const response = await fetch(`${this.baseURL}${endpoint}`, {\n      method,\n      headers,\n      body: data ? JSON.stringify(data) : undefined,\n    })\n\n    if (!response.ok) {\n      throw new Error(`Storage API error: ${response.statusText}`)\n    }\n\n    return response.json()\n  }\n\n  async get(key: string): Promise<string | null> {\n    try {\n      const result = await this.request(`GET`, `/storage/${encodeURIComponent(key)}`)\n      return result.value ?? null\n    }\n    catch {\n      return null\n    }\n  }\n\n  async set(key: string, value: string): Promise<void> {\n    await this.request(`PUT`, `/storage/${encodeURIComponent(key)}`, { value })\n  }\n\n  async remove(key: string): Promise<void> {\n    await this.request(`DELETE`, `/storage/${encodeURIComponent(key)}`)\n  }\n\n  async has(key: string): Promise<boolean> {\n    try {\n      await this.request(`HEAD`, `/storage/${encodeURIComponent(key)}`)\n      return true\n    }\n    catch {\n      return false\n    }\n  }\n\n  async clear(): Promise<void> {\n    await this.request(`DELETE`, `/storage`)\n  }\n\n  async keys(): Promise<string[]> {\n    const result = await this.request(`GET`, `/storage/keys`)\n    return result.keys ?? []\n  }\n}\n\n/**\n * 统一存储管理器\n */\nclass StorageManager {\n  private engine: StorageEngine = new LocalStorageEngine()\n\n  /**\n   * 切换存储引擎\n   */\n  setEngine(engine: StorageEngine): void {\n    this.engine = engine\n  }\n\n  /**\n   * 获取当前引擎\n   */\n  getEngine(): StorageEngine {\n    return this.engine\n  }\n\n  /**\n   * 获取字符串值\n   */\n  async get(key: string): Promise<string | null> {\n    return this.engine.get(key)\n  }\n\n  /**\n   * 设置字符串值\n   */\n  async set(key: string, value: string): Promise<void> {\n    return this.engine.set(key, value)\n  }\n\n  /**\n   * 获取 JSON 值（带默认值重载）\n   */\n  async getJSON<T>(key: string, defaultValue: T): Promise<T>\n  async getJSON<T>(key: string): Promise<T | null>\n  async getJSON<T>(key: string, defaultValue?: T): Promise<T | null> {\n    const value = await this.engine.get(key)\n    if (!value) {\n      return (defaultValue ?? null) as T | null\n    }\n\n    try {\n      return JSON.parse(value) as T\n    }\n    catch (error) {\n      console.error(`[Storage] Failed to parse JSON for key:`, key, error)\n      return (defaultValue ?? null) as T | null\n    }\n  }\n\n  /**\n   * 设置 JSON 值\n   */\n  async setJSON<T>(key: string, value: T): Promise<void> {\n    try {\n      const jsonString = JSON.stringify(value)\n      return this.engine.set(key, jsonString)\n    }\n    catch (error) {\n      console.error(`[Storage] Failed to stringify JSON for key:`, key, error)\n      throw error\n    }\n  }\n\n  /**\n   * 删除值\n   */\n  async remove(key: string): Promise<void> {\n    return this.engine.remove(key)\n  }\n\n  /**\n   * 检查键是否存在\n   */\n  async has(key: string): Promise<boolean> {\n    return this.engine.has(key)\n  }\n\n  /**\n   * 清空所有存储\n   */\n  async clear(): Promise<void> {\n    return this.engine.clear()\n  }\n\n  /**\n   * 获取所有键\n   */\n  async keys(): Promise<string[]> {\n    return this.engine.keys()\n  }\n\n  /**\n   * 创建响应式存储引用\n   * - 对于 LocalStorageEngine：同步读取初始值，确保首次渲染正确\n   * - 对于其他引擎：异步加载，加载完成后更新\n   * - 自动监听变化并保存到存储\n   */\n  reactive<T>(key: string, defaultValue: T): Ref<T> {\n    const isStringType = typeof defaultValue === `string`\n    let initialValue: T = defaultValue\n\n    // LocalStorageEngine 同步读取初始值\n    if (this.engine instanceof LocalStorageEngine) {\n      try {\n        const stored = localStorage.getItem(key)\n        if (stored !== null) {\n          initialValue = isStringType ? (stored as T) : this.parseJSON(stored, defaultValue)\n        }\n      }\n      catch (error) {\n        console.error(`[Storage] Failed to read initial value:`, key, error)\n      }\n    }\n\n    const data = ref<T>(initialValue) as Ref<T>\n\n    // 非 LocalStorageEngine 异步加载\n    if (!(this.engine instanceof LocalStorageEngine)) {\n      const loadAsync = isStringType\n        ? this.get(key).then(value => value !== null ? (value as T) : null)\n        : this.getJSON<T>(key, defaultValue)\n\n      loadAsync.then((value) => {\n        if (value !== null) {\n          data.value = value\n        }\n      })\n    }\n\n    // 监听变化并自动保存\n    // 使用 Promise.resolve() 确保在下一个微任务中设置 watch，避免初始赋值触发保存\n    Promise.resolve().then(() => {\n      watch(\n        data,\n        (newValue) => {\n          const savePromise = isStringType\n            ? this.set(key, newValue as string)\n            : this.setJSON(key, newValue)\n\n          savePromise.catch((error) => {\n            console.error(`[Storage] Failed to save reactive data:`, key, error)\n          })\n        },\n        { deep: true },\n      )\n    })\n\n    return data\n  }\n\n  /**\n   * 创建自定义响应式存储引用\n   * 支持自定义 getter/setter 转换逻辑\n   */\n  customReactive<T>(\n    key: string,\n    defaultValue: T,\n    options?: {\n      get?: (stored: T | null) => T\n      set?: (value: T) => T\n    },\n  ): Ref<T> {\n    let cachedValue: T = defaultValue\n\n    // 异步加载初始值\n    this.getJSON<T>(key, defaultValue).then((value) => {\n      const stored = value ?? defaultValue\n      cachedValue = options?.get ? options.get(stored) : stored\n    })\n\n    return customRef<T>((track, trigger) => ({\n      get() {\n        track()\n        return cachedValue\n      },\n      set: (newValue: T) => {\n        const valueToStore = options?.set ? options.set(newValue) : newValue\n        cachedValue = valueToStore\n        trigger()\n\n        // 异步保存\n        this.setJSON(key, valueToStore).catch((error: any) => {\n          console.error(`[Storage] Failed to save custom reactive data:`, key, error)\n        })\n      },\n    }))\n  }\n\n  /**\n   * 解析 JSON 字符串的辅助方法\n   */\n  private parseJSON<T>(value: string, fallback: T): T {\n    try {\n      return JSON.parse(value) as T\n    }\n    catch {\n      console.warn(`[Storage] Failed to parse JSON, using fallback`)\n      return fallback\n    }\n  }\n}\n\n/**\n * 全局存储实例 - 统一通过 store.xxx 调用\n */\nexport const store = new StorageManager()\n"
  },
  {
    "path": "apps/web/src/utils/toast/index.ts",
    "content": "export { toast } from 'vue-sonner'\n"
  },
  {
    "path": "apps/web/src/views/CodemirrorEditor.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ComponentPublicInstance } from 'vue'\n\nimport { Compartment, EditorState } from '@codemirror/state'\nimport { EditorView } from '@codemirror/view'\nimport { highlightPendingBlocks, hljs } from '@md/core'\nimport { markdownSetup, theme } from '@md/shared/editor'\nimport imageCompression from 'browser-image-compression'\nimport { Eye, Pen } from 'lucide-vue-next'\nimport { SidebarAIToolbar } from '@/components/ai'\nimport FolderSourcePanel from '@/components/editor/FolderSourcePanel.vue'\nimport {\n  ResizableHandle,\n  ResizablePanel,\n  ResizablePanelGroup,\n} from '@/components/ui/resizable'\nimport { SearchTab } from '@/components/ui/search-tab'\nimport { useImageUploader } from '@/composables/useImageUploader'\nimport { useCssEditorStore } from '@/stores/cssEditor'\nimport { useEditorStore } from '@/stores/editor'\nimport { usePostStore } from '@/stores/post'\nimport { useRenderStore } from '@/stores/render'\nimport { useThemeStore } from '@/stores/theme'\nimport { useUIStore } from '@/stores/ui'\nimport { checkImage, toBase64 } from '@/utils'\nimport { fileUpload } from '@/utils/file'\nimport { store } from '@/utils/storage'\n\nconst editorStore = useEditorStore()\nconst postStore = usePostStore()\nconst renderStore = useRenderStore()\nconst themeStore = useThemeStore()\nconst uiStore = useUIStore()\nconst cssEditorStore = useCssEditorStore()\nconst { upload } = useImageUploader()\n\nconst { editor } = storeToRefs(editorStore)\nconst { output } = storeToRefs(renderStore)\nconst { isDark } = storeToRefs(uiStore)\nconst { posts, currentPostIndex } = storeToRefs(postStore)\nconst { previewWidth } = storeToRefs(themeStore)\nconst {\n  isMobile,\n  isEditOnLeft,\n  isOpenPostSlider,\n  isOpenFolderPanel,\n  isOpenRightSlider,\n  isOpenConfirmDialog,\n  enableImageReupload,\n} = storeToRefs(uiStore)\n\nconst { toggleShowUploadImgDialog } = uiStore\n\n// Editor refresh function\nfunction editorRefresh() {\n  themeStore.updateCodeTheme()\n\n  const raw = editorStore.getContent()\n  renderStore.render(raw)\n}\n\n// Reset style function\nfunction resetStyle() {\n  themeStore.resetStyle()\n  cssEditorStore.resetCssConfig()\n  // 使用新主题系统\n  themeStore.applyCurrentTheme()\n  editorRefresh()\n  toast.success(`样式已重置`)\n}\n\nwatch(output, () => {\n  nextTick(() => {\n    const outputElement = document.getElementById(`output`)\n    if (outputElement) {\n      highlightPendingBlocks(hljs, outputElement)\n    }\n  })\n})\n\nconst backLight = ref(false)\nconst isCoping = ref(false)\n\n// 辅助函数：查找 CodeMirror 滚动容器\nfunction findCodeMirrorScroller(): HTMLElement | null {\n  return document.querySelector<HTMLElement>(`.cm-scroller`)\n    || document.querySelector<HTMLElement>(`.CodeMirror-scroll`)\n}\n\nfunction startCopy() {\n  backLight.value = true\n  isCoping.value = true\n}\n\n// 拷贝结束\nfunction endCopy() {\n  backLight.value = false\n  setTimeout(() => {\n    isCoping.value = false\n  }, 800)\n}\n\nconst showEditor = ref(true)\n\n// 切换编辑/预览视图（仅限移动端）\nfunction toggleView() {\n  showEditor.value = !showEditor.value\n}\n\n// AI 工具箱已移到侧边栏\n\nconst previewRef = useTemplateRef<HTMLDivElement>(`previewRef`)\n\nconst timeout = ref<NodeJS.Timeout>()\nconst codeMirrorView = ref<EditorView | null>(null)\nconst themeCompartment = new Compartment()\n\n// 使浏览区与编辑区滚动条建立同步联系\nfunction leftAndRightScroll() {\n  const scrollCB = (text: string) => {\n    // AIPolishBtnRef.value?.close()\n\n    let source: HTMLElement | null\n    let target: HTMLElement | null\n\n    clearTimeout(timeout.value)\n    if (text === `preview`) {\n      source = previewRef.value!\n      target = findCodeMirrorScroller()\n      if (!target) {\n        console.warn(`Cannot find CodeMirror scroll container`)\n        return\n      }\n      // CodeMirror v6 使用 DOM 事件\n      const scrollEl = findCodeMirrorScroller()\n      if (scrollEl) {\n        scrollEl.removeEventListener(`scroll`, editorScrollCB)\n        timeout.value = setTimeout(() => {\n          scrollEl.addEventListener(`scroll`, editorScrollCB)\n        }, 300)\n      }\n    }\n    else {\n      source = findCodeMirrorScroller()\n      target = previewRef.value!\n      if (!source) {\n        console.warn(`Cannot find CodeMirror scroll container`)\n        return\n      }\n      target.removeEventListener(`scroll`, previewScrollCB, false)\n      timeout.value = setTimeout(() => {\n        target!.addEventListener(`scroll`, previewScrollCB, false)\n      }, 300)\n    }\n\n    if (!source || !target) {\n      return\n    }\n\n    const sourceHeight = source.scrollHeight - source.offsetHeight\n    const targetHeight = target.scrollHeight - target.offsetHeight\n\n    if (sourceHeight <= 0 || targetHeight <= 0) {\n      return\n    }\n\n    const percentage = source.scrollTop / sourceHeight\n    const height = percentage * targetHeight\n\n    target.scrollTo(0, height)\n  }\n\n  function editorScrollCB() {\n    scrollCB(`editor`)\n  }\n\n  function previewScrollCB() {\n    scrollCB(`preview`)\n  }\n\n  if (previewRef.value) {\n    previewRef.value.addEventListener(`scroll`, previewScrollCB, false)\n  }\n  const scrollEl = findCodeMirrorScroller()\n  if (scrollEl) {\n    scrollEl.addEventListener(`scroll`, editorScrollCB)\n  }\n}\n\nonMounted(() => {\n  setTimeout(() => {\n    leftAndRightScroll()\n  }, 300)\n})\n\nconst searchTabRef\n  = useTemplateRef<InstanceType<typeof SearchTab>>(`searchTabRef`)\n\n// 用于存储待处理的搜索请求\nconst pendingSearchRequest = ref<{ selected: string } | null>(null)\n\nfunction openSearchWithSelection(view: EditorView) {\n  const selection = view.state.selection.main\n  const selected = view.state.doc.sliceString(selection.from, selection.to).trim()\n\n  if (searchTabRef.value) {\n    // SearchTab 已准备好，直接使用\n    if (selected) {\n      searchTabRef.value.setSearchWord(selected)\n    }\n    else {\n      searchTabRef.value.showSearchTab = true\n    }\n  }\n  else {\n    // SearchTab 还没准备好，保存请求\n    pendingSearchRequest.value = { selected }\n  }\n}\n\nfunction openReplaceWithSelection(view: EditorView) {\n  const selection = view.state.selection.main\n  const selected = view.state.doc.sliceString(selection.from, selection.to).trim()\n\n  if (searchTabRef.value) {\n    // SearchTab 已准备好，直接使用\n    searchTabRef.value.setSearchWithReplace(selected)\n  }\n  else {\n    // SearchTab 还没准备好，通过 UI Store 触发\n    uiStore.openSearchTab(selected, true)\n  }\n}\n\n// 监听 searchTabRef 的变化，处理待处理的请求\nwatch(searchTabRef, (newRef) => {\n  if (newRef && pendingSearchRequest.value) {\n    const { selected } = pendingSearchRequest.value\n    if (selected) {\n      newRef.setSearchWord(selected)\n    }\n    else {\n      newRef.showSearchTab = true\n    }\n    pendingSearchRequest.value = null\n  }\n})\n\n// 监听 UI Store 中的搜索请求\nconst { searchTabRequest } = storeToRefs(uiStore)\nwatch(searchTabRequest, (request) => {\n  if (request && searchTabRef.value) {\n    const { word, showReplace } = request\n\n    // 根据是否需要替换功能，调用不同的方法\n    if (showReplace) {\n      searchTabRef.value.setSearchWithReplace(word)\n    }\n    else {\n      if (word) {\n        searchTabRef.value.setSearchWord(word)\n      }\n      else {\n        searchTabRef.value.showSearchTab = true\n      }\n    }\n\n    // 清除请求\n    uiStore.clearSearchTabRequest()\n  }\n})\n\nfunction handleGlobalKeydown(e: KeyboardEvent) {\n  // 处理 ESC 键关闭搜索\n  const editorView = codeMirrorView.value\n\n  if (e.key === `Escape` && searchTabRef.value?.showSearchTab) {\n    searchTabRef.value.showSearchTab = false\n    e.preventDefault()\n    editorView?.focus()\n  }\n}\n\nonMounted(() => {\n  // 使用较低优先级确保 CodeMirror 键盘事件先处理\n  document.addEventListener(`keydown`, handleGlobalKeydown, { passive: false, capture: false })\n})\n\nasync function beforeImageUpload(file: File) {\n  const checkResult = checkImage(file)\n  if (!checkResult.ok) {\n    toast.error(checkResult.msg)\n    return false\n  }\n\n  // check image host\n  const imgHost = (await store.get(`imgHost`)) || `default`\n  await store.set(`imgHost`, imgHost)\n\n  const config = await store.get(`${imgHost}Config`)\n  const isValidHost = imgHost === `default` || config\n  if (!isValidHost) {\n    toast.error(`请先配置 ${imgHost} 图床参数`)\n    return false\n  }\n\n  return true\n}\n\n// 图片上传结束\nfunction uploaded(imageUrl: string) {\n  if (!imageUrl) {\n    toast.error(`上传图片未知异常`)\n    return\n  }\n  setTimeout(() => {\n    toggleShowUploadImgDialog(false)\n  }, 1000)\n  // 上传成功，插入图片\n  const markdownImage = `![](${imageUrl})`\n  // 将 Markdown 形式的 URL 插入编辑框光标所在位置\n  if (codeMirrorView.value) {\n    codeMirrorView.value.dispatch(codeMirrorView.value.state.replaceSelection(`\\n${markdownImage}\\n`))\n  }\n  toast.success(`图片上传成功`)\n}\n\nconst isImgLoading = ref(false)\nasync function compressImage(file: File) {\n  const options = {\n    maxSizeMB: 1,\n    maxWidthOrHeight: 1920,\n    useWebWorker: true,\n  }\n  const compressedFile = await imageCompression(file, options)\n  return compressedFile\n}\nasync function uploadImage(\n  file: File,\n  cb?: { (url: any, data: string): void, (arg0: unknown): void } | undefined,\n  applyUrl?: boolean,\n) {\n  try {\n    isImgLoading.value = true\n    // compress image if useCompression is true\n    const useCompression = (await store.get(`useCompression`)) === `true`\n    if (useCompression) {\n      file = await compressImage(file)\n    }\n    const base64Content = await toBase64(file)\n    const url = await fileUpload(base64Content, file)\n    if (cb) {\n      cb(url, base64Content)\n    }\n    else {\n      uploaded(url)\n    }\n    if (applyUrl) {\n      return uploaded(url)\n    }\n  }\n  catch (err) {\n    toast.error((err as any).message)\n  }\n  finally {\n    isImgLoading.value = false\n  }\n}\n\n// 从文件列表中查找一个 md 文件并解析\nasync function getMd({ list }: { list: { path: string, file: File }[] }) {\n  return new Promise<{ str: string, file: File, path: string }>((resolve) => {\n    const { path, file } = list.find(item => item.path.match(/\\.md$/))!\n    const reader = new FileReader()\n    reader.readAsText(file!, `UTF-8`)\n    reader.onload = (evt) => {\n      resolve({\n        str: evt.target!.result as string,\n        file,\n        path,\n      })\n    }\n  })\n}\n\n// 转换文件系统句柄中的文件为文件列表\nasync function showFileStructure(root: any) {\n  const result = []\n  let cwd = ``\n  try {\n    const dirs = [root]\n    for (const dir of dirs) {\n      cwd += `${dir.name}/`\n      for await (const [, handle] of dir) {\n        if (handle.kind === `file`) {\n          result.push({\n            path: cwd + handle.name,\n            file: await handle.getFile(),\n          })\n        }\n        else {\n          result.push({\n            path: `${cwd + handle.name}/`,\n          })\n          dirs.push(handle)\n        }\n      }\n    }\n  }\n  catch (err) {\n    console.error(err)\n  }\n  return result\n}\n\n// 上传 md 中的图片\nasync function uploadMdImg({\n  md,\n  list,\n}: {\n  md: { str: string, path: string, file: File }\n  list: { path: string, file: File }[]\n}) {\n  // 获取所有相对地址的图片\n  const mdImgList = [...(md.str.matchAll(/!\\[(.*?)\\]\\((.*?)\\)/g) || [])].filter(item => item)\n  const root = md.path.match(/.+?\\//)![0]\n  const resList = await Promise.all<{ matchStr: string, url: string }>(\n    mdImgList.map((item) => {\n      return new Promise((resolve) => {\n        let [, , matchStr] = item\n        matchStr = matchStr.replace(/^.\\//, ``) // 处理 ./img/ 为 img/ 统一相对路径风格\n        const { file }\n          = list.find(f => f.path === `${root}${matchStr}`) || {}\n        uploadImage(file!, url => resolve({ matchStr, url }))\n      })\n    }),\n  )\n  resList.forEach((item) => {\n    md.str = md.str\n      .replace(`](./${item.matchStr})`, `](${item.url})`)\n      .replace(`](${item.matchStr})`, `](${item.url})`)\n  })\n  if (codeMirrorView.value) {\n    codeMirrorView.value.dispatch({\n      changes: { from: 0, to: codeMirrorView.value.state.doc.length, insert: md.str },\n    })\n  }\n}\n\nconst codeMirrorWrapper = useTemplateRef<ComponentPublicInstance<HTMLDivElement>>(`codeMirrorWrapper`)\n\n// 转换 markdown 中的本地图片为线上图片\n// todo 处理事件覆盖\nfunction mdLocalToRemote() {\n  const dom = codeMirrorWrapper.value!\n\n  dom.ondragover = evt => evt.preventDefault()\n  dom.ondrop = async (evt) => {\n    evt.preventDefault()\n    if (evt.dataTransfer == null || !Array.isArray(evt.dataTransfer.items)) {\n      return\n    }\n\n    for (const item of evt.dataTransfer.items.filter(item => item.kind === `file`)) {\n      item\n        .getAsFileSystemHandle()\n        .then(async (handle: { kind: string, getFile: () => any }) => {\n          if (handle.kind === `directory`) {\n            const list = (await showFileStructure(handle)) as {\n              path: string\n              file: File\n            }[]\n            const md = await getMd({ list })\n            uploadMdImg({ md, list })\n          }\n          else {\n            const file = await handle.getFile()\n            console.log(`file`, file)\n            if (await beforeImageUpload(file)) {\n              uploadImage(file)\n            }\n          }\n        })\n    }\n  }\n}\n\nconst changeTimer = ref<NodeJS.Timeout>()\n\nconst editorRef = useTemplateRef<HTMLDivElement>(`editorRef`)\nconst progressValue = ref(0)\n\nfunction createFormTextArea(dom: HTMLDivElement) {\n  // 创建编辑器状态\n  const state = EditorState.create({\n    doc: posts.value[currentPostIndex.value].content,\n    extensions: [\n      markdownSetup({\n        onSearch: openSearchWithSelection,\n        onReplace: openReplaceWithSelection,\n      }),\n      themeCompartment.of(theme(isDark.value)),\n      EditorView.updateListener.of((update) => {\n        if (update.docChanged) {\n          const value = update.state.doc.toString()\n          clearTimeout(changeTimer.value)\n          changeTimer.value = setTimeout(() => {\n            editorRefresh()\n\n            const currentPost = posts.value[currentPostIndex.value]\n            if (value === currentPost.content) {\n              return\n            }\n\n            currentPost.updateDatetime = new Date()\n            currentPost.content = value\n          }, 300)\n        }\n      }),\n      EditorView.domEventHandlers({\n        paste: (event, view) => {\n          // 1. 处理剪贴板中的文件 (截图/复制文件)\n          if (event.clipboardData?.items && [...event.clipboardData.items].some(item => item.kind === 'file')) {\n            if (isImgLoading.value) {\n              return true\n            }\n            Promise.all(\n              [...event.clipboardData.items]\n                .map(item => item.getAsFile())\n                .filter(item => item != null)\n                .map(async item => (await beforeImageUpload(item!)) ? item : null),\n            ).then((items) => {\n              const validItems = items.filter(item => item != null) as File[]\n              if (validItems.length === 0) {\n                return\n              }\n              // start progress\n              const intervalId = setInterval(() => {\n                const newProgress = progressValue.value + 1\n                if (newProgress >= 100) {\n                  return\n                }\n                progressValue.value = newProgress\n              }, 100)\n\n              const processFiles = async () => {\n                for (const item of validItems) {\n                  await uploadImage(item)\n                }\n                clearInterval(intervalId)\n                progressValue.value = 100\n                setTimeout(() => {\n                  progressValue.value = 0\n                }, 1000)\n              }\n              processFiles()\n            })\n            return true\n          }\n\n          // 2. 处理剪贴板中的文本 (检测 Markdown 图片链接)\n          const text = event.clipboardData?.getData('text/plain')\n          if (text) {\n            // 匹配 ![alt](url) 格式\n            const mdImgRegex = /!\\[(.*?)\\]\\((https?:\\/\\/[^)]+)\\)/g\n            const matches = [...text.matchAll(mdImgRegex)]\n\n            if (matches.length > 0) {\n              isImgLoading.value = true\n\n              // 2.1 插入带有唯一 ID 的占位文本\n              let previewText = text\n              const placeholderMap = new Map<string, { originalUrl: string, originalAlt: string }>()\n\n              // 使用 replace 来生成唯一的占位符\n              let matchIndex = 0\n              previewText = previewText.replace(mdImgRegex, (_, alt, url) => {\n                const id = `LOADING_${Date.now()}_${matchIndex++}`\n                placeholderMap.set(id, { originalUrl: url, originalAlt: alt })\n                return `![⏳ 转存中...](${id})`\n              })\n\n              // 插入占位文本到编辑器\n              view.dispatch(view.state.replaceSelection(previewText))\n\n              // 2.2 提取唯一 URL 进行并发转存\n              const uniqueUrls = [...new Set(matches.map(m => m[2]))]\n\n              // 并发处理\n              Promise.all(uniqueUrls.map(async (url) => {\n                try {\n                  // 根据开关决定是否转存\n                  const newUrl = enableImageReupload.value ? await upload(url) : url\n\n                  // 2.3 转存成功后（或直接使用原URL），精确替换编辑器中的对应内容\n                  // 遍历 map，找到所有 originalUrl 为当前 url 的占位符 ID\n                  for (const [id, info] of placeholderMap.entries()) {\n                    if (info.originalUrl === url) {\n                      // 查找该 ID 在文档中的位置\n                      const searchStr = `![⏳ 转存中...](${id})`\n                      const currentDoc = view.state.doc.toString()\n                      const pos = currentDoc.indexOf(searchStr)\n\n                      if (pos !== -1) {\n                        const newText = `![${info.originalAlt}](${newUrl})`\n                        view.dispatch({\n                          changes: { from: pos, to: pos + searchStr.length, insert: newText },\n                        })\n                      }\n                    }\n                  }\n                }\n                catch (e) {\n                  console.error(`转存失败: ${url}`, e)\n                  // 失败时，将占位符恢复为原样\n                  for (const [id, info] of placeholderMap.entries()) {\n                    if (info.originalUrl === url) {\n                      const searchStr = `![⏳ 转存中...](${id})`\n                      const currentDoc = view.state.doc.toString()\n                      const pos = currentDoc.indexOf(searchStr)\n\n                      if (pos !== -1) {\n                        const newText = `![${info.originalAlt}](${info.originalUrl})`\n                        view.dispatch({\n                          changes: { from: pos, to: pos + searchStr.length, insert: newText },\n                        })\n                      }\n                    }\n                  }\n                  toast.error(`图片转存失败，已保留原链接`)\n                }\n              })).finally(() => {\n                isImgLoading.value = false\n              })\n\n              return true\n            }\n          }\n          return false\n        },\n      }),\n    ],\n  })\n\n  // 创建编辑器视图\n  const view = new EditorView({\n    state,\n    parent: dom,\n  })\n\n  codeMirrorView.value = view\n\n  // 返回编辑器 view\n  return view\n}\n\n// 初始化编辑器\nonMounted(() => {\n  const editorDom = editorRef.value\n\n  if (editorDom == null) {\n    return\n  }\n\n  // 初始化渲染器（新主题系统）\n  renderStore.initRendererInstance({\n    isMacCodeBlock: themeStore.isMacCodeBlock,\n    isShowLineNumber: themeStore.isShowLineNumber,\n  })\n\n  // 应用主题样式（新主题系统）\n  themeStore.applyCurrentTheme()\n\n  nextTick(() => {\n    const editorView = createFormTextArea(editorDom)\n    editor.value = editorView\n\n    // AI 工具箱已移到侧边栏，不再需要初始化编辑器事件\n    editorRefresh()\n    mdLocalToRemote()\n  })\n})\n\n// 监听暗色模式变化并更新编辑器主题\nwatch(isDark, () => {\n  if (codeMirrorView.value) {\n    codeMirrorView.value.dispatch({\n      effects: themeCompartment.reconfigure(theme(isDark.value)),\n    })\n  }\n  // 重新渲染 markdown 以更新 infographic 等扩展的主题\n  editorRefresh()\n})\n\n// 监听当前文章切换，更新编辑器内容\nwatch(currentPostIndex, () => {\n  if (!codeMirrorView.value)\n    return\n\n  const currentPost = posts.value[currentPostIndex.value]\n  if (!currentPost)\n    return\n\n  const currentContent = codeMirrorView.value.state.doc.toString()\n\n  // 只有当内容不同时才更新，避免不必要的更新\n  if (currentContent !== currentPost.content) {\n    codeMirrorView.value.dispatch({\n      changes: {\n        from: 0,\n        to: codeMirrorView.value.state.doc.length,\n        insert: currentPost.content,\n      },\n    })\n\n    // 更新编辑器后刷新渲染\n    editorRefresh()\n  }\n})\n\n// 历史记录的定时器\nconst historyTimer = ref<NodeJS.Timeout>()\nonMounted(() => {\n  // 定时，30 秒记录一次文章的历史记录\n  historyTimer.value = setInterval(() => {\n    const currentPost = posts.value[currentPostIndex.value]\n\n    // 与最后一篇记录对比\n    const pre = (currentPost.history || [])[0]?.content\n    if (pre === currentPost.content) {\n      return\n    }\n\n    currentPost.history ??= []\n    currentPost.history.unshift({\n      content: currentPost.content,\n      datetime: new Date().toLocaleString(`zh-CN`),\n    })\n\n    currentPost.history.length = Math.min(currentPost.history.length, 10)\n  }, 30 * 1000)\n})\n\n// 销毁时清理定时器和全局事件监听器\nonUnmounted(() => {\n  // 清理定时器 - 防止回调访问已销毁的DOM\n  clearTimeout(historyTimer.value)\n  clearTimeout(timeout.value)\n  clearTimeout(changeTimer.value)\n\n  // 清理全局事件监听器 - 防止全局事件触发已销毁的组件\n  document.removeEventListener(`keydown`, handleGlobalKeydown)\n})\n</script>\n\n<template>\n  <div class=\"container flex flex-col\">\n    <Progress v-model=\"progressValue\" class=\"absolute left-0 right-0 rounded-none\" style=\"height: 2px;\" />\n    <EditorHeader\n      @start-copy=\"startCopy\"\n      @end-copy=\"endCopy\"\n    />\n\n    <main class=\"container-main flex flex-1 flex-col\">\n      <div\n        class=\"container-main-section border-radius-10 relative flex flex-1 overflow-hidden border\"\n      >\n        <ResizablePanelGroup direction=\"horizontal\">\n          <ResizablePanel\n            :default-size=\"15\"\n            :max-size=\"isOpenPostSlider ? 20 : 0\"\n            :min-size=\"isOpenPostSlider ? 10 : 0\"\n          >\n            <PostSlider />\n          </ResizablePanel>\n          <ResizableHandle class=\"hidden md:block\" />\n          <ResizablePanel\n            :default-size=\"isOpenFolderPanel ? 15 : 0\"\n            :max-size=\"isOpenFolderPanel ? 25 : 0\"\n            :min-size=\"isOpenFolderPanel ? 10 : 0\"\n          >\n            <FolderSourcePanel />\n          </ResizablePanel>\n          <ResizableHandle v-if=\"isOpenFolderPanel\" class=\"hidden md:block\" />\n          <ResizablePanel class=\"flex\">\n            <div\n              v-show=\"!isMobile || (isMobile && showEditor)\"\n              ref=\"codeMirrorWrapper\"\n              class=\"codeMirror-wrapper relative flex-1\"\n              :class=\"{\n                'order-1 border-l': !isEditOnLeft,\n                'border-r': isEditOnLeft,\n              }\"\n            >\n              <SearchTab v-if=\"codeMirrorView\" ref=\"searchTabRef\" :editor-view=\"codeMirrorView as any\" />\n              <SidebarAIToolbar\n                :is-mobile=\"isMobile\"\n                :show-editor=\"showEditor\"\n              />\n\n              <EditorContextMenu>\n                <div\n                  id=\"editor\"\n                  ref=\"editorRef\"\n                  class=\"codemirror-container\"\n                />\n              </EditorContextMenu>\n            </div>\n            <div\n              v-show=\"!isMobile || (isMobile && !showEditor)\"\n              class=\"relative flex-1 overflow-x-hidden transition-width\"\n              :class=\"[isOpenRightSlider ? 'w-0' : 'w-100']\"\n            >\n              <div\n                id=\"preview\"\n                ref=\"previewRef\"\n                class=\"preview-wrapper w-full p-5 flex justify-center\"\n              >\n                <div\n                  id=\"output-wrapper\"\n                  class=\"w-full max-w-full\"\n                  :class=\"{ output_night: !backLight }\"\n                >\n                  <div\n                    class=\"preview border-x shadow-xl mx-auto\"\n                    :class=\"[\n                      isMobile ? 'w-full' : previewWidth,\n                      themeStore.previewWidth === 'w-[375px]' ? 'max-w-full' : '',\n                    ]\"\n                  >\n                    <section id=\"output\" class=\"w-full\" v-html=\"output\" />\n                    <div v-if=\"isCoping\" class=\"loading-mask\">\n                      <div class=\"loading-mask-box\">\n                        <div class=\"loading__img\" />\n                        <span>正在生成</span>\n                      </div>\n                    </div>\n                  </div>\n                </div>\n                <BackTop\n                  target=\"preview\"\n                  :right=\"isMobile ? 24 : 20\"\n                  :bottom=\"isMobile ? 90 : 20\"\n                />\n              </div>\n\n              <FloatingToc />\n            </div>\n            <CssEditor />\n            <RightSlider />\n          </ResizablePanel>\n        </ResizablePanelGroup>\n      </div>\n\n      <!-- 移动端浮动按钮组 -->\n      <div v-if=\"isMobile\" class=\"fixed bottom-16 right-6 z-50 flex flex-col gap-2\">\n        <!-- 切换编辑/预览按钮 -->\n        <button\n          class=\"bg-primary flex items-center justify-center rounded-full p-3 text-white shadow-lg transition active:scale-95 hover:scale-105 dark:bg-gray-700 dark:text-white dark:ring-2 dark:ring-white/30\"\n          aria-label=\"切换编辑/预览\"\n          @click=\"toggleView\"\n        >\n          <component :is=\"showEditor ? Eye : Pen\" class=\"h-5 w-5\" />\n        </button>\n      </div>\n\n      <!-- AI工具箱已移到侧边栏，这里不再显示 -->\n\n      <UploadImgDialog @upload-image=\"uploadImage\" />\n\n      <InsertFormDialog />\n\n      <InsertMpCardDialog />\n\n      <ImportMarkdownDialog />\n\n      <TemplateDialog />\n\n      <AlertDialog v-model:open=\"isOpenConfirmDialog\">\n        <AlertDialogContent>\n          <AlertDialogHeader>\n            <AlertDialogTitle>提示</AlertDialogTitle>\n            <AlertDialogDescription>\n              此操作将丢失本地自定义样式，是否继续？\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel>取消</AlertDialogCancel>\n            <AlertDialogAction @click=\"resetStyle\">\n              确定\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </main>\n\n    <Footer />\n  </div>\n</template>\n\n<style lang=\"less\" scoped>\n@import url('../assets/less/app.less');\n</style>\n\n<style lang=\"less\" scoped>\n.container {\n  height: 100vh;\n  min-width: 100%;\n  padding: 0;\n}\n\n.container-main {\n  overflow: hidden;\n}\n\n#output-wrapper {\n  position: relative;\n  user-select: text;\n  height: 100%;\n}\n\n.loading-mask {\n  position: absolute;\n  left: 0;\n  top: 0;\n  width: 100%;\n  height: 100%;\n  text-align: center;\n  color: hsl(var(--foreground));\n  background-color: hsl(var(--background));\n\n  .loading-mask-box {\n    position: sticky;\n    top: 50%;\n    transform: translateY(-50%);\n\n    .loading__img {\n      width: 75px;\n      height: 75px;\n      background: url('../assets/images/favicon.png') no-repeat;\n      margin: 1em auto;\n      background-size: cover;\n    }\n  }\n}\n\n:deep(.preview-table) {\n  border-spacing: 0;\n}\n\n.codeMirror-wrapper,\n.preview-wrapper {\n  height: 100%;\n}\n\n.codeMirror-wrapper {\n  overflow-x: hidden;\n  height: 100%;\n  position: relative;\n}\n</style>\n"
  },
  {
    "path": "apps/web/src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n"
  },
  {
    "path": "apps/web/tailwind.config.cjs",
    "content": "const animate = require(`tailwindcss-animate`)\n\n/** @type {import('tailwindcss').Config} */\nmodule.exports = {\n  darkMode: [`class`],\n  safelist: [`dark`],\n  prefix: ``,\n  experimental: {\n    optimizeUniversalDefaults: true,\n  },\n\n  content: [\n    `./src/**/*.{ts,tsx,vue}`,\n    `../../packages/shared/src/**/*.{ts,tsx,vue}`,\n  ],\n\n  theme: {\n    extend: {\n      colors: {\n        border: `hsl(var(--border))`,\n        input: `hsl(var(--input))`,\n        ring: `hsl(var(--ring))`,\n        background: `hsl(var(--background))`,\n        foreground: `hsl(var(--foreground))`,\n        primary: {\n          DEFAULT: `hsl(var(--primary))`,\n          foreground: `hsl(var(--primary-foreground))`,\n        },\n        secondary: {\n          DEFAULT: `hsl(var(--secondary))`,\n          foreground: `hsl(var(--secondary-foreground))`,\n        },\n        destructive: {\n          DEFAULT: `hsl(var(--destructive))`,\n          foreground: `hsl(var(--destructive-foreground))`,\n        },\n        muted: {\n          DEFAULT: `hsl(var(--muted))`,\n          foreground: `hsl(var(--muted-foreground))`,\n        },\n        accent: {\n          DEFAULT: `hsl(var(--accent))`,\n          foreground: `hsl(var(--accent-foreground))`,\n        },\n        popover: {\n          DEFAULT: `hsl(var(--popover))`,\n          foreground: `hsl(var(--popover-foreground))`,\n        },\n        card: {\n          DEFAULT: `hsl(var(--card))`,\n          foreground: `hsl(var(--card-foreground))`,\n        },\n      },\n      borderRadius: {\n        xl: `calc(var(--radius) + 4px)`,\n        lg: `var(--radius)`,\n        md: `calc(var(--radius) - 2px)`,\n        sm: `calc(var(--radius) - 4px)`,\n      },\n      keyframes: {\n        'accordion-down': {\n          from: { height: 0 },\n          to: { height: `var(--radix-accordion-content-height)` },\n        },\n        'accordion-up': {\n          from: { height: `var(--radix-accordion-content-height)` },\n          to: { height: 0 },\n        },\n        'collapsible-down': {\n          from: { height: 0 },\n          to: { height: `var(--radix-collapsible-content-height)` },\n        },\n        'collapsible-up': {\n          from: { height: `var(--radix-collapsible-content-height)` },\n          to: { height: 0 },\n        },\n      },\n      animation: {\n        'accordion-down': `accordion-down 0.2s ease-out`,\n        'accordion-up': `accordion-up 0.2s ease-out`,\n        'collapsible-down': `collapsible-down 0.2s ease-in-out`,\n        'collapsible-up': `collapsible-up 0.2s ease-in-out`,\n      },\n    },\n  },\n  plugins: [animate],\n}\n"
  },
  {
    "path": "apps/web/tsconfig.app.json",
    "content": "{\n  \"extends\": \"@md/config/tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  },\n  \"include\": [\n    \"src/**/*.ts\",\n    \"src/**/*.tsx\",\n    \"src/**/*.vue\",\n    \"components.d.ts\",\n    \"auto-imports.d.ts\",\n    \"./.wxt/wxt.d.ts\"\n  ]\n}\n"
  },
  {
    "path": "apps/web/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  },\n  \"references\": [\n    {\n      \"path\": \"./tsconfig.app.json\"\n    },\n    {\n      \"path\": \"./tsconfig.node.json\"\n    },\n    {\n      \"path\": \"./tsconfig.worker.json\"\n    }\n  ],\n  \"files\": []\n}\n"
  },
  {
    "path": "apps/web/tsconfig.node.json",
    "content": "{\n  \"extends\": \"@md/config/tsconfig.node.json\",\n  \"include\": [\"vite.config.ts\", \"plugins/**/*.ts\"]\n}\n"
  },
  {
    "path": "apps/web/tsconfig.worker.json",
    "content": "{\n  \"extends\": \"./tsconfig.node.json\",\n  \"compilerOptions\": {\n    \"types\": [\"@cloudflare/workers-types\"]\n  }\n}\n"
  },
  {
    "path": "apps/web/vite.config.ts",
    "content": "import path from 'node:path'\nimport process from 'node:process'\n\nimport { cloudflare } from '@cloudflare/vite-plugin'\nimport tailwindcss from '@tailwindcss/vite'\nimport vue from '@vitejs/plugin-vue'\nimport { visualizer } from 'rollup-plugin-visualizer'\nimport AutoImport from 'unplugin-auto-import/vite'\nimport Components from 'unplugin-vue-components/vite'\nimport { defineConfig, loadEnv } from 'vite'\nimport { VitePluginRadar } from 'vite-plugin-radar'\nimport vueDevTools from 'vite-plugin-vue-devtools'\n\nimport { utoolsLocalAssetsPlugin } from './plugins/vite-plugin-utools-local-assets'\n\nconst isNetlify = process.env.SERVER_ENV === `NETLIFY`\nconst isUTools = process.env.SERVER_ENV === `UTOOLS`\nconst isCfWorkers = process.env.CF_WORKERS === `1`\nconst isCfPages = process.env.CF_PAGES === `1`\n\nconst base = isNetlify || isCfWorkers || isCfPages ? `/` : isUTools ? `./` : `/md/`\n\nconst PKG_NAME_SPECIAL_CHARS = /[^\\w-]/g\n\nexport default defineConfig(({ mode }) => {\n  const env = loadEnv(mode, process.cwd())\n\n  return {\n    base,\n    define: { process },\n    envPrefix: [`VITE_`, `CF_`],\n    plugins: [\n      vue(),\n      isCfWorkers && cloudflare(),\n      tailwindcss(),\n      vueDevTools({\n        launchEditor: env.VITE_LAUNCH_EDITOR ?? `code`,\n      }),\n      VitePluginRadar({\n        analytics: { id: `G-7NZL3PZ0NK` },\n      }),\n      ...(process.env.ANALYZE === `true` ? [visualizer({ emitFile: true, filename: `stats.html` }) as any] : []),\n      AutoImport({\n        imports: [`vue`, `pinia`, `@vueuse/core`],\n        dirs: [`./src/stores`, `./src/utils/toast`, `./src/composables`],\n      }),\n      Components({\n        resolvers: [],\n      }),\n      isUTools && utoolsLocalAssetsPlugin(),\n    ],\n    resolve: {\n      alias: { '@': path.resolve(__dirname, `./src`) },\n      dedupe: [`@codemirror/state`, `@codemirror/view`],\n    },\n    css: { devSourcemap: true },\n    build: {\n      rollupOptions: {\n        external: [`mermaid`],\n        output: {\n          chunkFileNames: `static/js/md-[name]-[hash].js`,\n          entryFileNames: `static/js/md-[name]-[hash].js`,\n          assetFileNames: `static/[ext]/md-[name]-[hash].[ext]`,\n          globals: { mermaid: `mermaid` },\n          manualChunks(id) {\n            if (id.includes(`node_modules`)) {\n              // @lezer/* are CodeMirror's parser primitives, keep together\n              if (id.includes(`codemirror`) || id.includes(`@lezer`))\n                return `codemirror`\n              if (id.includes(`katex`))\n                return `katex`\n              if (id.includes(`prettier`))\n                return `prettier`\n              if (id.includes(`highlight.js`))\n                return `highlight`\n\n              // Handle pnpm virtual store (symlink-resolved paths contain /.pnpm/)\n              if (id.includes(`/.pnpm/`)) {\n                // Group Vue core ecosystem together to avoid circular chunk dependencies\n                if (\n                  id.includes(`/@vue/`)\n                  || id.includes(`/@vue+`)\n                  || id.includes(`/node_modules/vue/`)\n                  || id.includes(`/node_modules/pinia/`)\n                ) {\n                  return `vendor_vue`\n                }\n                if (id.includes(`/@vueuse+`) || id.includes(`/@vueuse/`))\n                  return `vendor_vueuse`\n\n                // Extract actual package name from the real package path within .pnpm store\n                // Format: .pnpm/<outer>@version/node_modules/<actual-pkg>/...\n                const nmIndex = id.lastIndexOf(`/node_modules/`)\n                if (nmIndex !== -1) {\n                  const afterNm = id.slice(nmIndex + `/node_modules/`.length)\n                  const parts = afterNm.split(`/`)\n                  const pkgName = afterNm.startsWith(`@`)\n                    ? `${parts[0].slice(1)}_${parts[1]}`\n                    : parts[0]\n                  return `vendor_${pkgName.replace(PKG_NAME_SPECIAL_CHARS, `_`)}`\n                }\n                return\n              }\n\n              // Handle regular (non-pnpm) node_modules paths\n              const pkg = id\n                .split(`node_modules/`)[1]\n                .split(`/`)[0]\n                .replace(`@`, `npm_`)\n              return `vendor_${pkg}`\n            }\n          },\n        },\n      },\n      chunkSizeWarningLimit: 1700,\n    },\n  }\n})\n"
  },
  {
    "path": "apps/web/web-ext.config.ts.example",
    "content": "import { defineWebExtConfig } from 'wxt'\n\nexport default defineWebExtConfig({\n  // binaries: {\n  //   chrome: 'D:/Applications/Scoop/shims/chrome.exe', // Use Chrome Beta instead of regular Chrome\n  //   firefox: 'D:/Applications/Scoop/shims/firefox.exe', // Use Firefox Developer Edition instead of regular Firefox\n  //   edge: '/path/to/edge', // Open MS Edge when running \"wxt -b edge\"\n  // },\n  disabled: true\n})"
  },
  {
    "path": "apps/web/worker/index.ts",
    "content": "import { WorkerEntrypoint } from 'cloudflare:workers'\n\nconst MP_HOST = `https://api.weixin.qq.com`\n\nexport default class extends WorkerEntrypoint {\n  async fetch(request: Request): Promise<Response> {\n    // 1️⃣ 获取原请求 URL 与路径\n    const url = new URL(request.url)\n\n    // 拼接转发目标，例如请求 /cgi-bin/stable_token 就会转发到\n    // https://api.weixin.qq.com/cgi-bin/stable_token\n    const targetUrl = `${MP_HOST}${url.pathname}${url.search}`\n\n    // 2️⃣ 克隆请求头\n    const headers = new Headers(request.headers)\n\n    // 可选：删除或修改一些可能引起冲突的头\n    headers.delete(`host`)\n    headers.delete(`content-length`)\n    headers.delete(`cf-connecting-ip`)\n    headers.delete(`x-forwarded-for`)\n\n    // 3️⃣ 构造新的请求\n    const init: RequestInit = {\n      method: request.method,\n      headers,\n      redirect: `follow`,\n    }\n\n    // ⚙️ 特别处理带 body 的请求（POST/PUT 等）\n    if (request.method !== `GET` && request.method !== `HEAD`) {\n      // 对文件上传、JSON、表单都可直接转发\n      init.body = request.body\n    }\n\n    try {\n      // 4️⃣ 发起转发请求\n      const resp = await fetch(targetUrl, init)\n\n      // 5️⃣ 克隆返回的响应头\n      const respHeaders = new Headers(resp.headers)\n      // 可选：允许跨域访问（如果你需要在网页端调用）\n      respHeaders.set(`Access-Control-Allow-Origin`, `*`)\n      respHeaders.set(`Access-Control-Allow-Headers`, `*`)\n\n      return new Response(resp.body, {\n        status: resp.status,\n        headers: respHeaders,\n      })\n    }\n    catch (err: any) {\n      return new Response(JSON.stringify({ error: err.message }), {\n        status: 500,\n        headers: { 'Content-Type': `application/json` },\n      })\n    }\n  }\n}\n"
  },
  {
    "path": "apps/web/wrangler.jsonc",
    "content": "/**\n * For more details on how to configure Wrangler, refer to:\n * https://developers.cloudflare.com/workers/wrangler/configuration/\n */\n{\n  \"$schema\": \"node_modules/wrangler/config-schema.json\",\n  \"name\": \"md\",\n  \"compatibility_date\": \"2025-09-06\",\n  \"main\": \"worker/index.ts\",\n  \"assets\": {\n    \"not_found_handling\": \"single-page-application\",\n    \"directory\": \"./public/\"\n  },\n  \"observability\": {\n    \"enabled\": true\n  },\n  \"compatibility_flags\": [\n    \"nodejs_compat\",\n    \"nodejs_compat_populate_process_env\"\n  ]\n  /**\n   * Smart Placement\n   * Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement\n   */\n  // \"placement\": { \"mode\": \"smart\" }\n  /**\n   * Bindings\n   * Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including\n   * databases, object storage, AI inference, real-time communication and more.\n   * https://developers.cloudflare.com/workers/runtime-apis/bindings/\n   */\n  /**\n   * Environment Variables\n   * https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables\n   */\n  // \"vars\": { \"MY_VARIABLE\": \"production_value\" }\n  /**\n   * Note: Use secrets to store sensitive data.\n   * https://developers.cloudflare.com/workers/configuration/secrets/\n   */\n  /**\n   * Static Assets\n   * https://developers.cloudflare.com/workers/static-assets/binding/\n   */\n  // \"assets\": { \"directory\": \"./public/\", \"binding\": \"ASSETS\" }\n  /**\n   * Service Bindings (communicate between multiple Workers)\n   * https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings\n   */\n  // \"services\": [{ \"binding\": \"MY_SERVICE\", \"service\": \"my-service\" }]\n}\n"
  },
  {
    "path": "apps/web/wxt.config.ts",
    "content": "import type { ConfigEnv } from 'vite'\nimport fs from 'node:fs'\nimport path from 'node:path'\nimport { defineConfig } from 'wxt'\nimport ViteConfig from './vite.config'\n\nfunction getRootPackageVersion() {\n  let dir = __dirname\n  while (dir !== path.parse(dir).root) {\n    const pkgPath = path.join(dir, `package.json`)\n    if (fs.existsSync(pkgPath)) {\n      const pkg = JSON.parse(fs.readFileSync(pkgPath, `utf-8`))\n      if (pkg.version)\n        return pkg.version\n    }\n    dir = path.dirname(dir)\n  }\n  return `0.0.0`\n}\n\nconst version = getRootPackageVersion()\n\nexport default defineConfig({\n  srcDir: `src`,\n  modulesDir: `src/modules`,\n  manifest: ({ mode, browser }) => ({\n    name: `公众号内容编辑器`,\n    version,\n    icons: {\n      256: mode === `development` ? `/mpmd/icon-256-gray.png` : `/mpmd/icon-256.png`,\n    },\n    permissions: [`storage`, `activeTab`, `sidePanel`, `contextMenus`],\n    host_permissions: [\n      `https://*.github.com/*`,\n      `https://*.githubusercontent.com/*`,\n      `https://*.gitee.com/*`,\n      `https://*.weixin.qq.com/*`,\n      // 微信公众号图片\n      `https://*.qpic.cn/*`,\n      `https://www.plantuml.com/*`,\n    ],\n    web_accessible_resources: [\n      {\n        resources: [`*.png`, `*.svg`, `injected.js`],\n        matches: [`<all_urls>`],\n      },\n    ],\n    side_panel: browser === `chrome`\n      ? {\n          default_path: `sidepanel.html`,\n        }\n      : undefined,\n    sidebar_action: browser === `firefox`\n      ? {\n          default_panel: `sidepanel.html`,\n          default_icon: {\n            256: `mpmd/icon-256.png`,\n          },\n          default_title: `MD 公众号编辑器`,\n        }\n      : undefined,\n    commands: {\n      _execute_sidebar_action: {\n        description: `Open MD Editor Side Panel`,\n        suggested_key: {\n          default: `Ctrl+Shift+Y`,\n        },\n      },\n    },\n  }),\n  zip: {\n    excludeSources: [\n      `dist/**`,\n      `docker/**`,\n      `docs/**`,\n      `example/**`,\n      `functions/**`,\n      `md-cli/**`,\n      `scripts/**`,\n      `src/extension/**`,\n    ],\n  },\n  analysis: {\n    open: true,\n  },\n  vite: ({ mode }) => {\n    const config = ViteConfig({ mode } as ConfigEnv)\n\n    return {\n      ...config,\n      plugins: config.plugins!.filter((plugin) => {\n        if (typeof plugin === `object` && plugin != null && `name` in plugin && plugin?.name === `vite-plugin-Radar`) {\n          return false\n        }\n        return true\n      }),\n      define: undefined,\n      build: undefined,\n      base: `/`,\n    }\n  },\n})\n"
  },
  {
    "path": "docker/latest/Dockerfile.base",
    "content": "FROM --platform=$BUILDPLATFORM node:22-alpine AS builder\nENV LANG=\"en_US.UTF-8\"\nENV LANGUAGE=\"en_US.UTF-8\"\nENV LC_ALL=\"en_US.UTF-8\"\nRUN apk add --no-cache curl unzip\nRUN curl -L \"https://github.com/doocs/md/archive/refs/heads/main.zip\" -o \"main.zip\" && unzip \"main.zip\" && mv \"md-main\" /app\nWORKDIR /app\nRUN npm install -g pnpm\nENV NODE_OPTIONS=\"--openssl-legacy-provider\"\nRUN pnpm install && pnpm web build:h5-netlify\n\nFROM scratch\nLABEL MAINTAINER=\"ylb<contact@yanglibin.info>\"\nCOPY --from=builder /app/apps/web/dist /app/assets\n"
  },
  {
    "path": "docker/latest/Dockerfile.nginx",
    "content": "ARG VER_NGX=\"1.21.6-alpine\"\n\nFROM --platform=$BUILDPLATFORM doocs/md:latest-assets AS assets\nFROM --platform=$TARGETPLATFORM nginx:${VER_NGX}\nLABEL MAINTAINER=\"ylb<contact@yanglibin.info>\"\nCOPY --from=assets /app/assets /usr/share/nginx/html\n"
  },
  {
    "path": "docker/latest/Dockerfile.standalone",
    "content": "ARG VER_GOLANG=1.25.2-alpine\nARG VER_ALPINE=3.20\n\nFROM --platform=$BUILDPLATFORM \"doocs/md:latest-assets\" AS assets\n\nFROM --platform=$BUILDPLATFORM \"golang:$VER_GOLANG\" AS gobuilder\nARG TARGETARCH\nARG TARGETOS\nCOPY --from=assets /app/* /app/assets/\nCOPY server/main.go /app\nRUN apk add git bash gcc musl-dev upx\nWORKDIR /app\nENV GOOS=$TARGETOS GOARCH=$TARGETARCH\nRUN go build -ldflags \"-w -s\" -o md main.go && \\\n    apk add upx && \\\n    if [ \"$TARGETARCH\" = \"amd64\" ]; then upx -9 -o md.minify md; else cp md md.minify; fi\n\nFROM --platform=$TARGETPLATFORM \"alpine:$VER_ALPINE\"\nLABEL MAINTAINER=\"ylb<contact@yanglibin.info>\"\nCOPY --from=gobuilder /app/md.minify /bin/md\nEXPOSE 80\nCMD [\"md\"]\n"
  },
  {
    "path": "docker/latest/Dockerfile.static",
    "content": "FROM --platform=$BUILDPLATFORM doocs/md:latest-assets AS assets\n\n# detail https://github.com/lipanski/docker-static-website/blob/master/Dockerfile\nFROM --platform=$TARGETPLATFORM lipanski/docker-static-website\n\nWORKDIR /home/static\n\nCOPY --from=assets /app/assets /home/static\n\nEXPOSE 80\n\nCMD [\"/busybox-httpd\", \"-f\", \"-v\", \"-p\", \"80\", \"-c\", \"httpd.conf\"]\n"
  },
  {
    "path": "docker/latest/server/main.go",
    "content": "package main\n\nimport (\n\t\"embed\"\n\t\"io/fs\"\n\t\"log\"\n\t\"net/http\"\n)\n\n//go:embed assets\nvar assets embed.FS\n\nfunc main() {\n\tmutex := http.NewServeMux()\n\tmd, _ := fs.Sub(assets, \"assets\")\n\tmutex.Handle(\"/\", http.FileServer(http.FS(md)))\n\terr := http.ListenAndServe(\":80\", mutex)\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n}\n"
  },
  {
    "path": "docs/custom-upload.md",
    "content": "# 自定义上传\n\n在工具上没有提供预定义图床的情况下，你只需要自定义上传逻辑即可，这对于例如你不方便使用公共图床，而是使用自己的上传服务时非常有用。\n\n你只需要在给定的函数中更改上传代码即可，为了方便，这个函数提供了可能使用的一些参数：\n\n示例代码：\n\n```js\nconst { file, util, okCb, errCb } = CUSTOM_ARG\nconst param = new FormData()\nparam.append(`file`, file)\nutil.axios\n  .post(`http://127.0.0.1:8800/upload`, param, {\n    headers: { 'Content-Type': `multipart/form-data` },\n  })\n  .then((res) => {\n    okCb(res.url)\n  })\n  .catch((err) => {\n    errCb(err)\n  })\n\n// 提供的可用参数:\n// CUSTOM_ARG = {\n//   content, // 待上传图片的 base64\n//   file, // 待上传图片的 file 对象\n//   util: {\n//     axios, // axios 实例\n//     CryptoJS, // 加密库\n//     S3, // @aws-sdk/client-s3 (S3Client, PutObjectCommand)\n//     Buffer, // buffer-from\n//     uuidv4, // uuid\n//     qiniu, // qiniu-js\n//     tokenTools, // 一些编码转换函数\n//     getDir, // 获取 年/月/日 形式的目录\n//     getDateFilename, // 根据文件名获取它以 时间戳+uuid 的形式\n//   },\n//   okCb: resolve, // 重要！上传成功后给此回调传 url 即可\n//   errCb: reject, // 上传失败调用的函数\n// }\n```\n\n如果你创建了适用于其他第三方图床的上传代码，我们非常欢迎你分享它。\n"
  },
  {
    "path": "docs/mp-card.md",
    "content": "# 微信公众号 ID 获取说明\n\n## 为什么需要公众号 ID\n\n在开发或运营过程中，有时需要获取某个公众号的唯一标识（即 **fakeid**），比如用于文章抓取、接口调用或做账号绑定。\n\n## 获取公众号 ID 的方法\n\n### 1. 打开公众号后台\n\n进入 [微信公众平台](https://mp.weixin.qq.com)，用管理员账号登录。\n\n### 2. 进入文章编辑器\n\n点击【素材管理】→【新建图文素材】，进入文章编辑页面。\n\n### 3. 添加公众号名片\n\n在编辑器里点击【账号名片】，输入你要查询的公众号名称（如 **Doocs**），并插入到正文中。\n\n### 4. 抓取网络请求\n\n按下 `F12` 打开浏览器开发者工具，切换到 **Network（网络）** 标签。\n在插入名片时，后台会发起一个接口请求，通常类似：\n\n```\nhttps://mp.weixin.qq.com/cgi-bin/searchbiz?action=search_biz&scene=1&begin=0...\n```\n\n### 5. 查找 fakeid\n\n点击该请求，切换到 **Response（响应）**，可以看到返回的 JSON 数据。\n其中会包含类似：\n\n```json\n{\n  \"list\": [\n    {\n      \"fakeid\": \"MzIxNjA5ODQ0OQ==\",\n      \"nickname\": \"Doocs\",\n      \"alias\": \"idoocs\",\n      \"service_type\": 1,\n      \"signature\": \"GitHub 开源组织 @Doocs 旗下唯一公众号，专注分享技术领域相关知识及行业最新资讯。\",\n      \"verify_status\": 1\n    }\n  ]\n}\n```\n\n这里的 `fakeid` 就是该公众号的唯一标识。将获取到的公众号 ID（fakeid）保存下来，就可以在工具中使用啦。\n"
  },
  {
    "path": "docs/telegram-usage.md",
    "content": "# Telegram 图床使用说明\n\n## 如何获取 Bot Token 和 Chat ID\n\nTelegram 图床需要两个参数：**Bot Token** 和 **Chat ID**。\n\n### 1. 申请 Bot Token\n\n- 在 Telegram 里搜索并打开机器人大号 [@BotFather](https://t.me/BotFather)。\n- 发送 `/newbot`，然后按照提示给你的机器人取个名字和用户名（唯一的以 “bot” 结尾的用户名）。\n- 完成后，BotFather 会返回一段类似 `123456789:ABCdefGHIjkl-MNOPqrSTUvwxYZ` 的字符串，这就是你的 **Bot Token**，复制保存到 `telegramConfig.token`。\n\n### 2. 获取 Chat ID\n\nChat ID 决定图片发到哪个对话／频道／群组里，它有三种常见场景：\n\n#### 发给自己的私聊\n\n1. 在 Telegram 里搜索你的 bot 用户名，打开对话，点击“开始”（或发送 `/start`）。\n2. 在浏览器里打开：\n\n   ```\n   https://api.telegram.org/bot<你的BotToken>/getUpdates\n   ```\n\n   将 `<你的BotToken>` 替换成上一步拿到的 Token。\n\n3. 页面会返回 JSON，其中 `\"chat\":{\"id\":123456789,...}` 里的数字 `123456789` 就是你的 Chat ID。\n\n#### 发到群组（推荐）\n\n1. 先把 bot 添加进目标群，然后在群里发送一条消息给 bot（例如发 `/start`）。 如何将 bot 添加到群组？打开机器人，点击 `View Profile`，然后点击 `Add to Group`，选择你要添加的群组即可。\n2. 同样用 `getUpdates` 接口查看返回的 JSON，取出 `\"chat\":{\"id\":-100987654321,...}` 里的那个负数（频道／超级群组 ID 通常是以 `-100` 开头）。\n\n#### 发到频道\n\n1. 把 bot 设为该频道的管理员（至少要有“写入消息”权限）。\n2. 发送一条测试消息到该频道（比如在频道里发个图片或文字）。\n3. 调用 `getUpdates` 接口检查返回的 JSON，里面同样会出现 `\"chat\":{\"id\":-1001234567890}`，这个就是你的频道 Chat ID。\n\n```bash\n# 假设你的 Token 是 123:ABC\ncurl https://api.telegram.org/bot123:ABC/getUpdates\n```\n\n响应中找 `\"chat\":{\"id\":-1001234567890}` → `chatId = -1001234567890`\n\n## 最后\n\n把上面获取到的 `Bot Token` 和 `Chat ID` 填到你在页面上的配置表单里，就可以用 Telegram 图床了。\n"
  },
  {
    "path": "eslint.config.mjs",
    "content": "import antfu from '@antfu/eslint-config'\n\nexport default antfu({\n  vue: true,\n  typescript: true,\n  formatters: true,\n  ignores: [`.github`, `scripts`, `docker`, `packages/md-cli`, `src/assets`, `example`],\n}, {\n  rules: {\n    'semi': [`error`, `never`],\n    'no-unused-vars': `off`,\n    'no-console': `off`,\n    'no-debugger': `off`,\n    'e18e/prefer-array-at': `off`,\n    'ts/no-namespace': `off`,\n    'style/max-statements-per-line': `off`,\n  },\n})\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"md\",\n  \"type\": \"module\",\n  \"version\": \"2.1.0\",\n  \"private\": false,\n  \"description\": \"WeChat Markdown Editor | 一款高度简洁的微信 Markdown 编辑器：支持 Markdown 语法、自定义主题样式、内容管理、多图床、AI 助手等特性\",\n  \"homepage\": \"https://github.com/doocs/md\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/doocs/md\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/doocs/md/issues\"\n  },\n  \"engines\": {\n    \"node\": \">=22.16.0\"\n  },\n  \"scripts\": {\n    \"start\": \"pnpm web dev\",\n    \"web\": \"pnpm --filter @md/web\",\n    \"vscode\": \"pnpm --prefix ./apps/vscode\",\n    \"cli\": \"pnpm --filter @doocs/md-cli\",\n    \"build:cli\": \"pnpm web build && npx shx rm -rf packages/md-cli/dist && npx shx rm -rf dist/**/*.map && npx shx cp -r apps/web/dist packages/md-cli/ && cd packages/md-cli && npm pack\",\n    \"release:cli\": \"node ./scripts/release.js\",\n    \"lint\": \"eslint . --fix\",\n    \"type-check\": \"vue-tsc --build --force\",\n    \"postinstall\": \"simple-git-hooks\",\n    \"inspector\": \"pnpx node-modules-inspector\",\n    \"utools:package\": \"node ./scripts/package-utools.mjs\"\n  },\n  \"dependencies\": {\n    \"@codemirror/state\": \"6.6.0\",\n    \"@codemirror/view\": \"6.40.0\"\n  },\n  \"devDependencies\": {\n    \"@antfu/eslint-config\": \"7.7.3\",\n    \"@types/node\": \"^25.5.0\",\n    \"archiver\": \"^7.0.1\",\n    \"cross-env\": \"^10.1.0\",\n    \"eslint\": \"^10.0.3\",\n    \"eslint-plugin-format\": \"^2.0.1\",\n    \"npm-run-all2\": \"^8.0.4\",\n    \"prettier\": \"2.8.8\",\n    \"shx\": \"^0.4.0\",\n    \"simple-git-hooks\": \"^2.13.1\",\n    \"typescript\": \"~5.9.3\"\n  },\n  \"simple-git-hooks\": {\n    \"pre-commit\": \"npx lint-staged\"\n  },\n  \"lint-staged\": {\n    \"*\": \"eslint --fix\"\n  }\n}\n"
  },
  {
    "path": "packages/config/package.json",
    "content": "{\n  \"name\": \"@md/config\",\n  \"type\": \"module\",\n  \"private\": true,\n  \"exports\": {\n    \"./tsconfig.base.json\": \"./tsconfig.base.json\",\n    \"./tsconfig.node.json\": \"./tsconfig.node.json\"\n  }\n}\n"
  },
  {
    "path": "packages/config/tsconfig.base.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.app.tsbuildinfo\",\n    \"target\": \"ES2020\",\n    \"jsx\": \"preserve\",\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n    \"moduleDetection\": \"force\",\n    \"useDefineForClassFields\": true,\n    \"module\": \"ESNext\",\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"resolveJsonModule\": true,\n    \"types\": [],\n    \"allowImportingTsExtensions\": true,\n\n    /* Linting */\n    \"strict\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noEmit\": true,\n    \"isolatedModules\": true,\n    \"skipLibCheck\": true\n  }\n}\n"
  },
  {
    "path": "packages/config/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.node.tsbuildinfo\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"types\": [],\n    \"strict\": true,\n    \"noEmit\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"skipLibCheck\": true\n  }\n}\n"
  },
  {
    "path": "packages/core/README.md",
    "content": "# @md/core\n\n核心渲染引擎，用于将 Markdown 文本渲染为 HTML 内容。\n"
  },
  {
    "path": "packages/core/package.json",
    "content": "{\n  \"name\": \"@md/core\",\n  \"type\": \"module\",\n  \"version\": \"2.1.0\",\n  \"private\": true,\n  \"exports\": {\n    \".\": \"./src/index.ts\",\n    \"./renderer\": \"./src/renderer/index.ts\",\n    \"./extensions\": \"./src/extensions/index.ts\",\n    \"./utils\": \"./src/utils/index.ts\",\n    \"./theme\": \"./src/theme/index.ts\"\n  },\n  \"dependencies\": {\n    \"@antv/infographic\": \"^0.2.16\",\n    \"@md/shared\": \"workspace:*\",\n    \"es-toolkit\": \"^1.45.1\",\n    \"fflate\": \"^0.8.2\",\n    \"front-matter\": \"^4.0.2\",\n    \"highlight.js\": \"^11.11.1\",\n    \"isomorphic-dompurify\": \"^3.5.1\",\n    \"marked\": \"^17.0.4\",\n    \"mermaid\": \"^11.13.0\",\n    \"postcss\": \"^8.5.8\",\n    \"postcss-calc\": \"^10.1.1\",\n    \"postcss-custom-properties\": \"^15.0.1\"\n  },\n  \"devDependencies\": {\n    \"@md/config\": \"workspace:*\"\n  }\n}\n"
  },
  {
    "path": "packages/core/src/extensions/alert.ts",
    "content": "import type { AlertOptions, AlertVariantItem } from '@md/shared/types'\nimport type { MarkedExtension, Tokens } from 'marked'\nimport { ucfirst } from '../utils'\n\n/**\n * https://github.com/bent10/marked-extensions/tree/main/packages/alert\n * To support theme, we need to modify the source code.\n * A [marked](https://marked.js.org/) extension to support [GFM alerts](https://github.com/orgs/community/discussions/16925).\n */\nexport function markedAlert(options: AlertOptions = {}): MarkedExtension {\n  const { className = `markdown-alert`, variants = [], withoutStyle = false } = options\n  const resolvedVariants = resolveVariants(variants)\n\n  // 提取公共的元数据构建逻辑\n  function buildMeta(variantType: string, matchedVariant: AlertVariantItem, fromContainer = false) {\n    return {\n      className,\n      variant: variantType,\n      icon: matchedVariant.icon,\n      title: matchedVariant.title ?? ucfirst(variantType),\n      titleClassName: `${className}-title`,\n      fromContainer,\n    }\n  }\n\n  // 提取公共的渲染逻辑\n  function renderAlert(token: any) {\n    const { meta, tokens = [] } = token\n    // @ts-expect-error marked renderer context has parser property\n    const text = this.parser.parse(tokens)\n    // 新主题系统：使用 CSS 选择器而非内联样式\n    let tmpl = `<blockquote class=\"${meta.className} ${meta.className}-${meta.variant}\">\\n`\n    tmpl += `<p class=\"${meta.titleClassName} alert-title-${meta.variant}\">`\n    if (!withoutStyle) {\n      // 给 SVG 添加 class，通过 CSS 控制颜色\n      tmpl += meta.icon.replace(\n        `<svg`,\n        `<svg class=\"alert-icon-${meta.variant}\"`,\n      )\n    }\n    tmpl += meta.title\n    tmpl += `</p>\\n`\n    tmpl += text\n    tmpl += `</blockquote>\\n`\n\n    return tmpl\n  }\n\n  return {\n    walkTokens(token) {\n      if (token.type !== `blockquote`)\n        return\n\n      const matchedVariant = resolvedVariants.find(({ type }) =>\n        new RegExp(createSyntaxPattern(type), `i`).test(token.text),\n      )\n\n      if (matchedVariant) {\n        const { type: variantType } = matchedVariant\n        const typeRegexp = new RegExp(createSyntaxPattern(variantType), `i`)\n\n        Object.assign(token, {\n          type: `alert`,\n          meta: buildMeta(variantType, matchedVariant),\n        })\n\n        const firstLine = token.tokens?.[0] as Tokens.Paragraph\n        const firstLineText = firstLine.raw?.replace(typeRegexp, ``).trim()\n\n        if (firstLineText) {\n          const patternToken = firstLine.tokens[0] as Tokens.Text\n\n          Object.assign(patternToken, {\n            raw: patternToken.raw.replace(typeRegexp, ``),\n            text: patternToken.text.replace(typeRegexp, ``),\n          })\n\n          if (firstLine.tokens[1]?.type === `br`) {\n            firstLine.tokens.splice(1, 1)\n          }\n        }\n        else {\n          token.tokens?.shift()\n        }\n      }\n    },\n    extensions: [\n      {\n        name: `alert`,\n        level: `block`,\n        renderer: renderAlert,\n      },\n      {\n        name: `alertContainer`,\n        level: `block`,\n        start(src) {\n          return src.match(/^:::/)?.index\n        },\n        tokenizer(src, _tokens) {\n          // eslint-disable-next-line regexp/no-super-linear-backtracking\n          const match = /^:::\\s*(\\w+)\\s*\\n([\\s\\S]*?)\\n:::/.exec(src)\n\n          if (match) {\n            const [raw, variant, content] = match\n            const matchedVariant = resolvedVariants.find(v => v.type === variant)\n            if (!matchedVariant)\n              return\n\n            return {\n              type: `alert`,\n              raw,\n              text: content.trim(),\n              tokens: this.lexer.blockTokens(content.trim()),\n              meta: buildMeta(variant, matchedVariant, true),\n            }\n          }\n        },\n        renderer: renderAlert,\n      },\n    ],\n  }\n}\n\n/**\n * The default configuration for alert variants.\n */\nconst defaultAlertVariant: AlertVariantItem[] = [\n  {\n    type: `note`,\n    icon: `<svg class=\"octicon octicon-info\" style=\"margin-right: 0.25em;\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z\"></path></svg>`,\n  },\n  {\n    type: `info`,\n    icon: `<svg class=\"octicon octicon-info\" style=\"margin-right: 0.25em;\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z\"></path></svg>`,\n  },\n  {\n    type: `tip`,\n    icon: `<svg class=\"octicon octicon-light-bulb\" style=\"margin-right: 0.25em;\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z\"></path></svg>`,\n  },\n  {\n    type: `important`,\n    icon: `<svg class=\"octicon octicon-report\" style=\"margin-right: 0.25em;\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z\"></path></svg>`,\n  },\n  {\n    type: `warning`,\n    icon: `<svg class=\"octicon octicon-alert\" style=\"margin-right: 0.25em;\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z\"></path></svg>`,\n  },\n  {\n    type: `caution`,\n    icon: `<svg class=\"octicon octicon-stop\" style=\"margin-right: 0.25em;\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25c.141.14.22.331.22.53v6a.749.749 0 0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 11V5c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31L10.69 1.5ZM8 4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z\"></path></svg>`,\n  },\n  // Obsidian-style callouts\n  {\n    type: `abstract`,\n    title: `Abstract`,\n    icon: `<svg class=\"octicon octicon-clipboard\" style=\"margin-right: 0.25em;\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M5.75 1a.75.75 0 0 0-.75.75v.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.5a.75.75 0 0 0-.75-.75Zm4.5-1.5a2.25 2.25 0 0 1 2.122 1.5H13a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h.628A2.25 2.25 0 0 1 5.75-.5ZM3.5 3v10a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5Z\"></path></svg>`,\n  },\n  {\n    type: `summary`,\n    title: `Summary`,\n    icon: `<svg class=\"octicon octicon-clipboard\" style=\"margin-right: 0.25em;\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M5.75 1a.75.75 0 0 0-.75.75v.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.5a.75.75 0 0 0-.75-.75Zm4.5-1.5a2.25 2.25 0 0 1 2.122 1.5H13a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h.628A2.25 2.25 0 0 1 5.75-.5ZM3.5 3v10a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5Z\"></path></svg>`,\n  },\n  {\n    type: `tldr`,\n    title: `TL;DR`,\n    icon: `<svg class=\"octicon octicon-clipboard\" style=\"margin-right: 0.25em;\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M5.75 1a.75.75 0 0 0-.75.75v.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.5a.75.75 0 0 0-.75-.75Zm4.5-1.5a2.25 2.25 0 0 1 2.122 1.5H13a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h.628A2.25 2.25 0 0 1 5.75-.5ZM3.5 3v10a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5Z\"></path></svg>`,\n  },\n  {\n    type: `todo`,\n    title: `Todo`,\n    icon: `<svg class=\"octicon octicon-checklist\" style=\"margin-right: 0.25em;\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M2.5 1.75v11.5c0 .138.112.25.25.25h3.17a.75.75 0 0 1 0 1.5H2.75A1.75 1.75 0 0 1 1 13.25V1.75C1 .784 1.784 0 2.75 0h8.5C12.216 0 13 .784 13 1.75v7.736a.75.75 0 0 1-1.5 0V1.75a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm10.97 8.72a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1-1.06 1.06l-1.22-1.22v4.94a.75.75 0 0 1-1.5 0v-4.94l-1.22 1.22a.75.75 0 0 1-1.06-1.06Z\"></path></svg>`,\n  },\n  {\n    type: `success`,\n    title: `Success`,\n    icon: `<svg class=\"octicon octicon-check-circle\" style=\"margin-right: 0.25em;\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16Zm3.78-9.72a.751.751 0 0 0-.018-1.042.751.751 0 0 0-1.042-.018L6.75 9.19 5.28 7.72a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042l2 2a.75.75 0 0 0 1.06 0Z\"></path></svg>`,\n  },\n  {\n    type: `done`,\n    title: `Done`,\n    icon: `<svg class=\"octicon octicon-check-circle\" style=\"margin-right: 0.25em;\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16Zm3.78-9.72a.751.751 0 0 0-.018-1.042.751.751 0 0 0-1.042-.018L6.75 9.19 5.28 7.72a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042l2 2a.75.75 0 0 0 1.06 0Z\"></path></svg>`,\n  },\n  {\n    type: `question`,\n    title: `Question`,\n    icon: `<svg class=\"octicon octicon-question\" style=\"margin-right: 0.25em;\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.756 2.756 0 0 1 1.637.525c.503.377.863.965.863 1.725 0 .448-.115.83-.329 1.15-.205.307-.47.513-.692.662-.109.072-.22.138-.313.195l-.006.004a6.24 6.24 0 0 0-.26.16.952.952 0 0 0-.276.245.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661.103-.067.207-.132.313-.195l.007-.004c.1-.061.182-.11.258-.161a.969.969 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.612.612 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1.01 1.01 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z\"></path></svg>`,\n  },\n  {\n    type: `help`,\n    title: `Help`,\n    icon: `<svg class=\"octicon octicon-question\" style=\"margin-right: 0.25em;\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.756 2.756 0 0 1 1.637.525c.503.377.863.965.863 1.725 0 .448-.115.83-.329 1.15-.205.307-.47.513-.692.662-.109.072-.22.138-.313.195l-.006.004a6.24 6.24 0 0 0-.26.16.952.952 0 0 0-.276.245.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661.103-.067.207-.132.313-.195l.007-.004c.1-.061.182-.11.258-.161a.969.969 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.612.612 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1.01 1.01 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z\"></path></svg>`,\n  },\n  {\n    type: `faq`,\n    title: `FAQ`,\n    icon: `<svg class=\"octicon octicon-question\" style=\"margin-right: 0.25em;\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.756 2.756 0 0 1 1.637.525c.503.377.863.965.863 1.725 0 .448-.115.83-.329 1.15-.205.307-.47.513-.692.662-.109.072-.22.138-.313.195l-.006.004a6.24 6.24 0 0 0-.26.16.952.952 0 0 0-.276.245.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661.103-.067.207-.132.313-.195l.007-.004c.1-.061.182-.11.258-.161a.969.969 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.612.612 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1.01 1.01 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z\"></path></svg>`,\n  },\n  {\n    type: `failure`,\n    title: `Failure`,\n    icon: `<svg class=\"octicon octicon-x-circle\" style=\"margin-right: 0.25em;\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z\"></path></svg>`,\n  },\n  {\n    type: `fail`,\n    title: `Fail`,\n    icon: `<svg class=\"octicon octicon-x-circle\" style=\"margin-right: 0.25em;\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z\"></path></svg>`,\n  },\n  {\n    type: `missing`,\n    title: `Missing`,\n    icon: `<svg class=\"octicon octicon-x-circle\" style=\"margin-right: 0.25em;\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z\"></path></svg>`,\n  },\n  {\n    type: `danger`,\n    title: `Danger`,\n    icon: `<svg class=\"octicon octicon-zap\" style=\"margin-right: 0.25em;\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M9.504.43a1.516 1.516 0 0 1 2.437 1.713L10.415 5.5h2.123c1.57 0 2.346 1.909 1.22 3.004l-7.34 7.142a1.249 1.249 0 0 1-.871.354h-.302a1.25 1.25 0 0 1-1.157-1.723L5.633 10.5H3.462c-1.57 0-2.346-1.909-1.22-3.004ZM9.98 1.873a.016.016 0 0 0-.016.006L2.252 9.021a.75.75 0 0 0 .488 1.212h3.838a.75.75 0 0 1 .694 1.034L5.545 15.02a.016.016 0 0 0 .003.017.017.017 0 0 0 .018.004h.302a.016.016 0 0 0 .012-.005l7.34-7.142a.75.75 0 0 0-.488-1.212h-3.838a.75.75 0 0 1-.694-1.034l1.527-3.628a.016.016 0 0 0-.003-.017.017.017 0 0 0-.018-.004h-.302Z\"></path></svg>`,\n  },\n  {\n    type: `error`,\n    title: `Error`,\n    icon: `<svg class=\"octicon octicon-zap\" style=\"margin-right: 0.25em;\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M9.504.43a1.516 1.516 0 0 1 2.437 1.713L10.415 5.5h2.123c1.57 0 2.346 1.909 1.22 3.004l-7.34 7.142a1.249 1.249 0 0 1-.871.354h-.302a1.25 1.25 0 0 1-1.157-1.723L5.633 10.5H3.462c-1.57 0-2.346-1.909-1.22-3.004ZM9.98 1.873a.016.016 0 0 0-.016.006L2.252 9.021a.75.75 0 0 0 .488 1.212h3.838a.75.75 0 0 1 .694 1.034L5.545 15.02a.016.016 0 0 0 .003.017.017.017 0 0 0 .018.004h.302a.016.016 0 0 0 .012-.005l7.34-7.142a.75.75 0 0 0-.488-1.212h-3.838a.75.75 0 0 1-.694-1.034l1.527-3.628a.016.016 0 0 0-.003-.017.017.017 0 0 0-.018-.004h-.302Z\"></path></svg>`,\n  },\n  {\n    type: `bug`,\n    title: `Bug`,\n    icon: `<svg class=\"octicon octicon-bug\" style=\"margin-right: 0.25em;\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M4.72.22a.75.75 0 0 1 1.06 0l1 .999a3.488 3.488 0 0 1 2.441 0l.999-1a.748.748 0 0 1 1.265.332.75.75 0 0 1-.205.729l-.775.776c.616.63.995 1.493.995 2.444v.327c0 .1-.009.197-.025.292l.727.726a.75.75 0 1 1-1.06 1.06l-.727-.727a2.17 2.17 0 0 1-.292.026H9.25V7.5a.75.75 0 0 1-1.5 0V6.125H6.875a2.17 2.17 0 0 1-.292-.026l-.727.727a.75.75 0 1 1-1.06-1.06l.727-.726a2.17 2.17 0 0 1-.025-.292V4.5c0-.951.379-1.814.995-2.444l-.775-.776a.75.75 0 0 1 0-1.06Zm6.437 6.003A.608.608 0 0 0 11 6.072v-.026a3.999 3.999 0 0 0-.11-.936 2.488 2.488 0 0 0-5.78 0 3.992 3.992 0 0 0-.11.936v.026c0 .05.008.098.02.147h4.937a.612.612 0 0 0 .2-.02ZM2.25 7.5a.75.75 0 0 0 0 1.5h.5v1.25a4.75 4.75 0 0 0 2.478 4.166l.247.137a.75.75 0 1 0 .722-1.313l-.246-.137A3.25 3.25 0 0 1 4.25 10.25V9h7.5v1.25a3.25 3.25 0 0 1-1.701 2.853l-.246.137a.75.75 0 1 0 .722 1.313l.247-.137A4.75 4.75 0 0 0 13.25 10.25V9h.5a.75.75 0 0 0 0-1.5Z\"></path></svg>`,\n  },\n  {\n    type: `example`,\n    title: `Example`,\n    icon: `<svg class=\"octicon octicon-list-unordered\" style=\"margin-right: 0.25em;\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M5.75 2.5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5ZM2 14a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm1-6a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM2 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z\"></path></svg>`,\n  },\n  {\n    type: `quote`,\n    title: `Quote`,\n    icon: `<svg class=\"octicon octicon-quote\" style=\"margin-right: 0.25em;\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25v-8.5C0 2.784.784 2 1.75 2ZM1.5 12.25c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25ZM4 5.25a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5A.75.75 0 0 1 4 5.25Zm0 4a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1-.75-.75Z\"></path></svg>`,\n  },\n  {\n    type: `cite`,\n    title: `Cite`,\n    icon: `<svg class=\"octicon octicon-quote\" style=\"margin-right: 0.25em;\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25v-8.5C0 2.784.784 2 1.75 2ZM1.5 12.25c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25ZM4 5.25a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5A.75.75 0 0 1 4 5.25Zm0 4a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1-.75-.75Z\"></path></svg>`,\n  },\n]\n\n/**\n * Resolves the variants configuration, combining the provided variants with\n * the default variants.\n */\nexport function resolveVariants(variants: AlertVariantItem[]) {\n  if (!variants.length)\n    return defaultAlertVariant\n\n  return Object.values(\n    [...defaultAlertVariant, ...variants].reduce(\n      (map, item) => {\n        map[item.type] = item\n        return map\n      },\n      {} as { [key: string]: AlertVariantItem },\n    ),\n  )\n}\n\n/**\n * Returns regex pattern to match alert syntax.\n */\nexport function createSyntaxPattern(type: string) {\n  return `^(?:\\\\[!${type}])\\\\s*?\\n*`\n}\n"
  },
  {
    "path": "packages/core/src/extensions/footnotes.ts",
    "content": "import type { MarkedExtension, Tokens } from 'marked'\n/**\n * A marked extension to support footnotes syntax.\n * Syntax:\n *  This is a footnote reference[^1][^2].\n *\n *  [^1]: .....\n *  [^2]: .....\n */\n\ninterface MapContent {\n  index: number\n  text: string\n}\nconst fnMap = new Map<string, MapContent>()\n\nexport function markedFootnotes(): MarkedExtension {\n  return {\n    extensions: [\n      {\n        name: `footnoteDef`,\n        level: `block`,\n        start(src: string) {\n          fnMap.clear()\n          return src.match(/^\\[\\^/)?.index\n        },\n        tokenizer(src: string) {\n          const match = src.match(/^\\[\\^(.*)\\]:(.*)/)\n          if (match) {\n            const [raw, fnId, text] = match\n            const index = fnMap.size + 1\n            fnMap.set(fnId, { index, text })\n            return {\n              type: `footnoteDef`,\n              raw,\n              fnId,\n              index,\n              text,\n            }\n          }\n          return undefined\n        },\n        renderer(token: Tokens.Generic) {\n          const { index, text, fnId } = token\n          const fnInner = `\n                <code>${index}.</code> \n                <span>${text}</span> \n                    <a id=\"fnDef-${fnId}\" href=\"#fnRef-${fnId}\" style=\"color: var(--md-primary-color);\">\\u21A9\\uFE0E</a>\n                <br>`\n          if (index === 1) {\n            return `\n            <p style=\"font-size: 80%;margin: 0.5em 8px;word-break:break-all;\">${fnInner}`\n          }\n          if (index === fnMap.size) {\n            return `${fnInner}</p>`\n          }\n          return fnInner\n        },\n      },\n      {\n        name: `footnoteRef`,\n        level: `inline`,\n        start(src: string) {\n          return src.match(/\\[\\^/)?.index\n        },\n        tokenizer(src: string) {\n          const match = src.match(/^\\[\\^(.*?)\\]/)\n          if (match) {\n            const [raw, fnId] = match\n            if (fnMap.has(fnId)) {\n              return {\n                type: `footnoteRef`,\n                raw,\n                fnId,\n              }\n            }\n          }\n        },\n        renderer(token: Tokens.Generic) {\n          const { fnId } = token\n          const { index } = fnMap.get(fnId) as MapContent\n          return `<sup style=\"color: var(--md-primary-color);\">\n                    <a href=\"#fnDef-${fnId}\" id=\"fnRef-${fnId}\">\\[${index}\\]</a>\n                </sup>`\n        },\n      },\n    ],\n  }\n}\n"
  },
  {
    "path": "packages/core/src/extensions/index.ts",
    "content": "// Markdown 扩展导出\nexport * from './alert'\nexport * from './footnotes'\nexport * from './infographic'\nexport * from './katex'\nexport * from './markup'\nexport * from './mermaid'\nexport * from './plantuml'\nexport * from './ruby'\nexport * from './slider'\nexport * from './toc'\n"
  },
  {
    "path": "packages/core/src/extensions/infographic.ts",
    "content": "import type { MarkedExtension } from 'marked'\nimport { simpleHash } from '../utils/basicHelpers'\n\ninterface InfographicOptions {\n  themeMode?: 'dark' | 'light'\n}\n\n// key -> svg\nconst svgCache = new Map<string, string>()\n// 上一次渲染的结果（用于在新渲染完成前显示旧图片）\nlet lastRenderedSvg: string | null = null\n\nconst RE_INFOGRAPHIC_START = /^```infographic/m\nconst RE_INFOGRAPHIC_BLOCK = /^```infographic\\r?\\n([\\s\\S]*?)\\r?\\n```/\n\nasync function renderInfographic(containerId: string, code: string, cacheKey: string, options?: InfographicOptions) {\n  if (typeof window === 'undefined')\n    return\n\n  try {\n    const { Infographic, setDefaultFont, setFontExtendFactor, exportToSVG } = await import('@antv/infographic')\n\n    setFontExtendFactor(1.1)\n    setDefaultFont('-apple-system-font, \"system-ui\", \"Helvetica Neue\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei UI\", \"Microsoft YaHei\", Arial, sans-serif')\n\n    const findContainer = (retries = 5, delay = 100) => {\n      const container = document.getElementById(containerId)\n      if (container) {\n        const isDark = options?.themeMode === 'dark'\n\n        // 从 CSS 变量中读取主题颜色\n        const root = document.documentElement\n        const computedStyle = getComputedStyle(root)\n        const primaryColor = computedStyle.getPropertyValue('--md-primary-color').trim()\n        const backgroundColor = computedStyle.getPropertyValue('--background').trim()\n\n        // 转换 HSL 格式\n        const toHSLString = (variant: string) => {\n          const vars = variant.split(' ')\n          if (vars.length === 3)\n            return `hsl(${vars.join(', ')})`\n          if (vars.length === 4)\n            return `hsla(${vars.join(', ')})`\n          return ''\n        }\n\n        const instance = new Infographic({\n          container,\n          svg: {\n            style: {\n              width: '100%',\n              height: '100%',\n              background: isDark ? '#000' : 'transparent',\n            },\n            background: false,\n          },\n          theme: isDark ? 'dark' : 'default',\n          themeConfig: {\n            colorPrimary: primaryColor || undefined,\n            colorBg: toHSLString(backgroundColor) || undefined,\n          },\n        })\n\n        instance.on('loaded', ({ node }) => {\n          exportToSVG(node, { removeIds: true }).then((svg) => {\n            container.replaceChildren(svg)\n            svgCache.set(cacheKey, container.innerHTML)\n            lastRenderedSvg = container.innerHTML\n          })\n        })\n\n        instance.render(code)\n\n        return\n      }\n\n      if (retries > 0) {\n        setTimeout(findContainer, delay, retries - 1, delay)\n      }\n    }\n\n    findContainer()\n  }\n  catch (error) {\n    console.error('Failed to render Infographic:', error)\n    const container = document.getElementById(containerId)\n    if (container) {\n      container.innerHTML = `<div style=\"color: red; padding: 10px; border: 1px solid red;\">Infographic 渲染失败: ${error instanceof Error ? error.message : String(error)}</div>`\n    }\n  }\n}\n\nexport function markedInfographic(options?: InfographicOptions): MarkedExtension {\n  const className = 'infographic-diagram'\n\n  return {\n    extensions: [\n      {\n        name: 'infographic',\n        level: 'block',\n        start(src: string) {\n          return src.match(RE_INFOGRAPHIC_START)?.index\n        },\n        tokenizer(src: string) {\n          const match = RE_INFOGRAPHIC_BLOCK.exec(src)\n          if (match) {\n            return {\n              type: 'infographic',\n              raw: match[0],\n              text: match[1].trim(),\n            }\n          }\n        },\n        renderer(token: any) {\n          const code = token.text\n          const cacheKey = simpleHash(`${code}-${options?.themeMode || 'light'}`)\n\n          // 有缓存直接返回\n          const cached = svgCache.get(cacheKey)\n          if (cached) {\n            return `<!--infographic-start--><div class=\"${className}\" style=\"width: 100%;\">${cached}</div><!--infographic-end-->`\n          }\n\n          // 没有缓存，触发渲染\n          const id = `infographic-${cacheKey}`\n          renderInfographic(id, code, cacheKey, options)\n\n          // 如果有上一次渲染的结果，显示旧图片；否则显示占位符\n          if (lastRenderedSvg) {\n            return `<!--infographic-start--><div id=\"${id}\" class=\"${className}\" style=\"width: 100%;\">${lastRenderedSvg}</div><!--infographic-end-->`\n          }\n\n          return `<!--infographic-start--><div id=\"${id}\" class=\"${className}\" style=\"width: 100%;\">正在加载 Infographic...</div><!--infographic-end-->`\n        },\n      },\n    ],\n    walkTokens(token: any) {\n      if (token.type === 'code' && token.lang === 'infographic') {\n        token.type = 'infographic'\n      }\n    },\n  }\n}\n"
  },
  {
    "path": "packages/core/src/extensions/katex.ts",
    "content": "import type { MarkedExtension } from 'marked'\n\nexport interface MarkedKatexOptions {\n  nonStandard?: boolean\n}\n\nconst inlineRule = /^(\\${1,2})(?!\\$)((?:\\\\.|[^\\\\\\n])*?(?:\\\\.|[^\\\\\\n$]))\\1(?=[\\s?!.,:？！。，：]|$)/\nconst inlineRuleNonStandard = /^(\\${1,2})(?!\\$)((?:\\\\.|[^\\\\\\n])*?(?:\\\\.|[^\\\\\\n$]))\\1/ // Non-standard, even if there are no spaces before and after $ or $$, try to parse\n\nconst blockRule = /^\\s{0,3}(\\${1,2})[ \\t]*\\n([\\s\\S]+?)\\n\\s{0,3}\\1[ \\t]*(?:\\n|$)/\n\n// LaTeX style rules for \\( ... \\) and \\[ ... \\]\nconst inlineLatexRule = /^\\\\\\(([^\\\\]*(?:\\\\.[^\\\\]*)*?)\\\\\\)/\nconst blockLatexRule = /^\\\\\\[([^\\\\]*(?:\\\\.[^\\\\]*)*?)\\\\\\]/\n\nfunction createRenderer(defaultDisplay: boolean, withStyle: boolean = true) {\n  return (token: any) => {\n    const display = token.displayMode ?? defaultDisplay\n\n    // @ts-expect-error MathJax is a global variable\n    window.MathJax.texReset()\n    // @ts-expect-error MathJax is a global variable\n    const mjxContainer = window.MathJax.tex2svg(token.text, { display })\n    const svg = mjxContainer.firstChild\n    const width = svg.style[`min-width`] || svg.getAttribute(`width`)\n    svg.removeAttribute(`width`)\n\n    // 行内公式对齐 https://groups.google.com/g/mathjax-users/c/zThKffrrCvE?pli=1\n    // 直接覆盖 style 会覆盖 MathJax 的样式，需要手动设置\n    // svg.style = `max-width: 300vw !important; display: initial; flex-shrink: 0;`\n\n    if (withStyle) {\n      svg.style.display = `initial`\n      svg.style.setProperty(`max-width`, `300vw`, `important`)\n      svg.style.flexShrink = `0`\n      svg.style.width = width\n    }\n\n    if (!display) {\n      // 新主题系统：使用 class 而非内联样式\n      return `<span class=\"katex-inline\">${svg.outerHTML}</span>`\n    }\n\n    return `<section class=\"katex-block\">${svg.outerHTML}</section>`\n  }\n}\n\nfunction inlineKatex(options: MarkedKatexOptions | undefined, renderer: any) {\n  const nonStandard = options && options.nonStandard\n  const ruleReg = nonStandard ? inlineRuleNonStandard : inlineRule\n  return {\n    name: `inlineKatex`,\n    level: `inline`,\n    start(src: string) {\n      let index\n      let indexSrc = src\n\n      while (indexSrc) {\n        index = indexSrc.indexOf(`$`)\n        if (index === -1) {\n          return\n        }\n        const f = nonStandard ? index > -1 : index === 0 || indexSrc.charAt(index - 1) === ` `\n        if (f) {\n          const possibleKatex = indexSrc.substring(index)\n\n          if (possibleKatex.match(ruleReg)) {\n            return index\n          }\n        }\n\n        indexSrc = indexSrc.substring(index + 1).replace(/^\\$+/, ``)\n      }\n    },\n    tokenizer(src: string) {\n      const match = src.match(ruleReg)\n      if (match) {\n        return {\n          type: `inlineKatex`,\n          raw: match[0],\n          text: match[2].trim(),\n          displayMode: match[1].length === 2,\n        }\n      }\n    },\n    renderer,\n  }\n}\n\nfunction blockKatex(_options: MarkedKatexOptions | undefined, renderer: any) {\n  return {\n    name: `blockKatex`,\n    level: `block`,\n    tokenizer(src: string) {\n      const match = src.match(blockRule)\n      if (match) {\n        return {\n          type: `blockKatex`,\n          raw: match[0],\n          text: match[2].trim(),\n          displayMode: true,\n        }\n      }\n    },\n    renderer,\n  }\n}\n\nfunction inlineLatexKatex(_options: MarkedKatexOptions | undefined, renderer: any) {\n  return {\n    name: `inlineLatexKatex`,\n    level: `inline`,\n    start(src: string) {\n      const index = src.indexOf(`\\\\(`)\n      return index !== -1 ? index : undefined\n    },\n    tokenizer(src: string) {\n      const match = src.match(inlineLatexRule)\n      if (match) {\n        return {\n          type: `inlineLatexKatex`,\n          raw: match[0],\n          text: match[1].trim(),\n          displayMode: false,\n        }\n      }\n    },\n    renderer,\n  }\n}\n\nfunction blockLatexKatex(_options: MarkedKatexOptions | undefined, renderer: any) {\n  return {\n    name: `blockLatexKatex`,\n    level: `block`,\n    start(src: string) {\n      const index = src.indexOf(`\\\\[`)\n      return index !== -1 ? index : undefined\n    },\n    tokenizer(src: string) {\n      const match = src.match(blockLatexRule)\n      if (match) {\n        return {\n          type: `blockLatexKatex`,\n          raw: match[0],\n          text: match[1].trim(),\n          displayMode: true,\n        }\n      }\n    },\n    renderer,\n  }\n}\n\nexport function MDKatex(options: MarkedKatexOptions | undefined, withStyle: boolean = true): MarkedExtension {\n  return {\n    extensions: [\n      inlineKatex(options, createRenderer(false, withStyle)),\n      blockKatex(options, createRenderer(true, withStyle)),\n      inlineLatexKatex(options, createRenderer(false, withStyle)),\n      blockLatexKatex(options, createRenderer(true, withStyle)),\n    ],\n  }\n}\n"
  },
  {
    "path": "packages/core/src/extensions/markup.ts",
    "content": "import type { MarkedExtension } from 'marked'\n\n/**\n * 扩展标记语法：\n * - 高亮: ==文本==\n * - 下划线: ++文本++\n * - 波浪线: ~文本~\n */\nexport function markedMarkup(): MarkedExtension {\n  return {\n    extensions: [\n      // 高亮语法 ==文本==\n      {\n        name: `markup_highlight`,\n        level: `inline`,\n        start(src: string) {\n          return src.match(/==(?!=)/)?.index\n        },\n        tokenizer(src: string) {\n          const rule = /^==((?:[^=]|=(?!=))+)==/\n          const match = rule.exec(src)\n          if (match) {\n            return {\n              type: `markup_highlight`,\n              raw: match[0],\n              text: match[1],\n            }\n          }\n        },\n        renderer(token: any) {\n          // 新主题系统：使用 class 而非内联样式\n          return `<span class=\"markup-highlight\">${token.text}</span>`\n        },\n      },\n\n      // 下划线语法 ++文本++\n      {\n        name: `markup_underline`,\n        level: `inline`,\n        start(src: string) {\n          return src.match(/\\+\\+(?!\\+)/)?.index\n        },\n        tokenizer(src: string) {\n          const rule = /^\\+\\+((?:[^+]|\\+(?!\\+))+)\\+\\+/\n          const match = rule.exec(src)\n          if (match) {\n            return {\n              type: `markup_underline`,\n              raw: match[0],\n              text: match[1],\n            }\n          }\n        },\n        renderer(token: any) {\n          // 新主题系统：使用 class 而非内联样式\n          return `<span class=\"markup-underline\">${token.text}</span>`\n        },\n      },\n\n      // 波浪线语法 ~文本~\n      {\n        name: `markup_wavyline`,\n        level: `inline`,\n        start(src: string) {\n          // 查找单个 ~ 但不是连续的 ~~\n          return src.match(/~(?!~)/)?.index\n        },\n        tokenizer(src: string) {\n          // 匹配 ~文本~ 但确保不是 ~~文本~~\n          const rule = /^~([^~\\n]+)~(?!~)/\n          const match = rule.exec(src)\n          if (match) {\n            return {\n              type: `markup_wavyline`,\n              raw: match[0],\n              text: match[1],\n            }\n          }\n        },\n        renderer(token: any) {\n          // 新主题系统：使用 class 而非内联样式\n          return `<span class=\"markup-wavyline\">${token.text}</span>`\n        },\n      },\n    ],\n  }\n}\n"
  },
  {
    "path": "packages/core/src/extensions/mermaid.ts",
    "content": "import type { MarkedExtension } from 'marked'\nimport { simpleHash } from '../utils/basicHelpers'\n\n// key -> svg\nconst svgCache = new Map<string, string>()\n// 上一次渲染的结果（用于在新渲染完成前显示旧图片）\nlet lastRenderedSvg: string | null = null\n\nfunction renderMermaid(id: string, code: string, cacheKey: string) {\n  if (typeof window === 'undefined')\n    return\n\n  const handleResult = (svg: string) => {\n    svgCache.set(cacheKey, svg)\n    lastRenderedSvg = svg\n\n    const el = document.getElementById(id)\n    if (el) {\n      el.innerHTML = svg\n    }\n  }\n\n  const handleError = (error: unknown) => {\n    console.error('Failed to render Mermaid:', error)\n    const el = document.getElementById(id)\n    if (el) {\n      el.innerHTML = `<div style=\"color: red; padding: 10px; border: 1px solid red;\">Mermaid 渲染失败: ${error instanceof Error ? error.message : String(error)}</div>`\n    }\n  }\n\n  // 优先使用全局 CDN 的 mermaid\n  if ((window as any).mermaid) {\n    const mermaid = (window as any).mermaid\n    mermaid.render(`mermaid-svg-${cacheKey}`, code)\n      .then((result: { svg: string }) => handleResult(result.svg))\n      .catch(handleError)\n  }\n  else {\n    // 回退到动态导入（开发环境）\n    import('mermaid')\n      .then(mermaid => mermaid.default.render(`mermaid-svg-${cacheKey}`, code))\n      .then((result: { svg: string }) => handleResult(result.svg))\n      .catch(handleError)\n  }\n}\n\nexport function markedMermaid(): MarkedExtension {\n  const className = 'mermaid-diagram'\n\n  return {\n    extensions: [\n      {\n        name: 'mermaid',\n        level: 'block',\n        start(src: string) {\n          return src.match(/^```mermaid/m)?.index\n        },\n        tokenizer(src: string) {\n          const match = /^```mermaid\\r?\\n([\\s\\S]*?)\\r?\\n```/.exec(src)\n          if (match) {\n            return {\n              type: 'mermaid',\n              raw: match[0],\n              text: match[1].trim(),\n            }\n          }\n        },\n        renderer(token: any) {\n          const code = token.text\n          const cacheKey = simpleHash(code)\n\n          // 有缓存直接返回\n          const cached = svgCache.get(cacheKey)\n          if (cached) {\n            return `<!--mermaid-start--><div class=\"${className}\">${cached}</div><!--mermaid-end-->`\n          }\n\n          // 没有缓存，触发渲染\n          const id = `mermaid-${cacheKey}`\n          renderMermaid(id, code, cacheKey)\n\n          // 如果有上一次渲染的结果，显示旧图片；否则显示占位符\n          if (lastRenderedSvg) {\n            return `<!--mermaid-start--><div id=\"${id}\" class=\"${className}\">${lastRenderedSvg}</div><!--mermaid-end-->`\n          }\n\n          return `<!--mermaid-start--><div id=\"${id}\" class=\"${className}\">正在加载 Mermaid...</div><!--mermaid-end-->`\n        },\n      },\n    ],\n    walkTokens(token: any) {\n      if (token.type === 'code' && token.lang === 'mermaid') {\n        token.type = 'mermaid'\n      }\n    },\n  }\n}\n"
  },
  {
    "path": "packages/core/src/extensions/plantuml.ts",
    "content": "import type { MarkedExtension, Tokens } from 'marked'\nimport { deflateSync } from 'fflate'\nimport { simpleHash } from '../utils/basicHelpers'\n\n// key -> svg\nconst svgCache = new Map<string, string>()\n// 上一次渲染的结果（用于在新渲染完成前显示旧图片）\nlet lastRenderedSvg: string | null = null\n\nexport interface PlantUMLOptions {\n  /**\n   * PlantUML 服务器地址\n   * @default 'https://www.plantuml.com/plantuml'\n   */\n  serverUrl?: string\n  /**\n   * 渲染格式\n   * @default 'svg'\n   */\n  format?: `svg` | `png`\n  /**\n   * CSS 类名\n   * @default 'plantuml-diagram'\n   */\n  className?: string\n  /**\n   * 是否内嵌SVG内容（用于微信公众号等不支持外链图片的环境）\n   * @default false\n   */\n  inlineSvg?: boolean\n  /**\n   * 自定义样式\n   */\n  styles?: {\n    container?: Record<string, string | number>\n  }\n}\n\n/**\n * PlantUML 专用的 6-bit 编码函数\n * 基于官方文档 https://plantuml.com/text-encoding\n */\nfunction encode6bit(b: number): string {\n  if (b < 10) {\n    return String.fromCharCode(48 + b)\n  }\n  b -= 10\n  if (b < 26) {\n    return String.fromCharCode(65 + b)\n  }\n  b -= 26\n  if (b < 26) {\n    return String.fromCharCode(97 + b)\n  }\n  b -= 26\n  if (b === 0) {\n    return `-`\n  }\n  if (b === 1) {\n    return `_`\n  }\n  return `?`\n}\n\n/**\n * 将 3 个字节附加到编码字符串中\n * 基于官方文档 https://plantuml.com/text-encoding\n */\nfunction append3bytes(b1: number, b2: number, b3: number): string {\n  const c1 = b1 >> 2\n  const c2 = ((b1 & 0x3) << 4) | (b2 >> 4)\n  const c3 = ((b2 & 0xF) << 2) | (b3 >> 6)\n  const c4 = b3 & 0x3F\n  let r = ``\n  r += encode6bit(c1 & 0x3F)\n  r += encode6bit(c2 & 0x3F)\n  r += encode6bit(c3 & 0x3F)\n  r += encode6bit(c4 & 0x3F)\n  return r\n}\n\n/**\n * PlantUML 专用的 base64 编码函数\n * 基于官方文档 https://plantuml.com/text-encoding\n */\nfunction encode64(data: string): string {\n  let r = ``\n  for (let i = 0; i < data.length; i += 3) {\n    if (i + 2 === data.length) {\n      r += append3bytes(data.charCodeAt(i), data.charCodeAt(i + 1), 0)\n    }\n    else if (i + 1 === data.length) {\n      r += append3bytes(data.charCodeAt(i), 0, 0)\n    }\n    else {\n      r += append3bytes(data.charCodeAt(i), data.charCodeAt(i + 1), data.charCodeAt(i + 2))\n    }\n  }\n  return r\n}\n\n/**\n * 使用 fflate 库进行 Deflate 压缩\n * 按照官方规范进行压缩\n */\nfunction performDeflate(input: string): string {\n  try {\n    // 将字符串转换为字节数组\n    const inputBytes = new TextEncoder().encode(input)\n\n    // 使用 fflate 进行 deflate 压缩（最高压缩级别 9）\n    const compressed = deflateSync(inputBytes, { level: 9 })\n\n    // 将压缩后的字节数组转换为二进制字符串\n    return String.fromCharCode(...compressed)\n  }\n  catch (error) {\n    console.warn(`Deflate compression failed:`, error)\n    // 如果压缩失败，返回原始输入\n    return input\n  }\n}\n\n/**\n * 编码 PlantUML 代码为服务器可识别的格式\n * 按照官方规范：UTF-8 编码 -> Deflate 压缩 -> PlantUML Base64 编码\n */\nfunction encodePlantUML(plantumlCode: string): string {\n  try {\n    // 步骤 1 & 2: UTF-8 编码 + Deflate 压缩\n    const deflated = performDeflate(plantumlCode)\n\n    // 步骤 3: PlantUML 专用的 base64 编码\n    return encode64(deflated)\n  }\n  catch (error) {\n    // 如果编码失败，回退到简单方案\n    console.warn(`PlantUML encoding failed, using fallback:`, error)\n    const utf8Bytes = new TextEncoder().encode(plantumlCode)\n    const base64 = btoa(String.fromCharCode(...utf8Bytes))\n    return `~1${base64.replace(/\\+/g, `-`).replace(/\\//g, `_`).replace(/=/g, ``)}`\n  }\n}\n\n/**\n * 生成 PlantUML 图片 URL\n */\nfunction generatePlantUMLUrl(code: string, options: Required<PlantUMLOptions>): string {\n  const encoded = encodePlantUML(code)\n  const formatPath = options.format === `svg` ? `svg` : `png`\n  return `${options.serverUrl}/${formatPath}/${encoded}`\n}\n\n/**\n * 渲染 PlantUML 图表\n */\nfunction renderPlantUMLDiagram(token: Tokens.Code, options: Required<PlantUMLOptions>, cacheKey: string): string {\n  const { text: code } = token\n\n  // 检查代码是否包含 PlantUML 标记\n  const finalCode = (!code.trim().includes(`@start`) || !code.trim().includes(`@end`))\n    ? `@startuml\\n${code.trim()}\\n@enduml`\n    : code\n\n  const imageUrl = generatePlantUMLUrl(finalCode, options)\n\n  // 如果启用了内嵌SVG且格式是SVG\n  if (options.inlineSvg && options.format === `svg`) {\n    const placeholder = `plantuml-${cacheKey}`\n\n    // 异步获取SVG内容并替换\n    fetchSvgContent(imageUrl).then((svgContent) => {\n      const placeholderElement = document.querySelector(`[data-placeholder=\"${placeholder}\"]`) as HTMLElement\n      if (placeholderElement) {\n        const html = createPlantUMLHTML(imageUrl, options, svgContent)\n        placeholderElement.outerHTML = html\n        svgCache.set(cacheKey, html)\n        lastRenderedSvg = svgContent\n      }\n    })\n\n    const containerStyles = options.styles.container\n      ? Object.entries(options.styles.container)\n          .map(([key, value]) => `${key.replace(/([A-Z])/g, `-$1`).toLowerCase()}: ${value}`)\n          .join(`; `)\n      : ``\n\n    // 如果有上一次渲染的结果，显示旧图片；否则显示占位符\n    if (lastRenderedSvg) {\n      return `<div class=\"${options.className}\" style=\"${containerStyles}\" data-placeholder=\"${placeholder}\">${lastRenderedSvg}</div>`\n    }\n\n    return `<div class=\"${options.className}\" style=\"${containerStyles}\" data-placeholder=\"${placeholder}\">\n      <div style=\"color: #666; font-style: italic;\">正在加载PlantUML图表...</div>\n    </div>`\n  }\n\n  return createPlantUMLHTML(imageUrl, options)\n}\n\n/**\n * 获取SVG内容\n */\nasync function fetchSvgContent(svgUrl: string): Promise<string> {\n  try {\n    const response = await fetch(svgUrl)\n    if (!response.ok) {\n      throw new Error(`HTTP ${response.status}`)\n    }\n    const svgContent = await response.text()\n    // 移除SVG根元素的固定尺寸，使其响应式\n    return svgContent\n      // 移除width和height属性\n      .replace(/(<svg[^>]*)\\swidth=\"[^\"]*\"/g, `$1`)\n      .replace(/(<svg[^>]*)\\sheight=\"[^\"]*\"/g, `$1`)\n      // 移除style中的width和height\n      .replace(/(<svg[^>]*style=\"[^\"]*?)width:[^;]*;?/g, `$1`)\n      .replace(/(<svg[^>]*style=\"[^\"]*?)height:[^;]*;?/g, `$1`)\n  }\n  catch (error) {\n    console.warn(`Failed to fetch SVG content from ${svgUrl}:`, error)\n    return `<div style=\"color: #666; font-style: italic;\">PlantUML图表加载失败</div>`\n  }\n}\n\n/**\n * 创建 PlantUML HTML 元素\n */\nfunction createPlantUMLHTML(imageUrl: string, options: Required<PlantUMLOptions>, svgContent?: string): string {\n  const containerStyles = options.styles.container\n    ? Object.entries(options.styles.container)\n        .map(([key, value]) => `${key.replace(/([A-Z])/g, `-$1`).toLowerCase()}: ${value}`)\n        .join(`; `)\n    : ``\n\n  // 如果有SVG内容，直接嵌入\n  if (svgContent) {\n    return `<div class=\"${options.className}\" style=\"${containerStyles}\">\n      ${svgContent}\n    </div>`\n  }\n\n  // 否则使用图片链接\n  return `<div class=\"${options.className}\" style=\"${containerStyles}\">\n    <img src=\"${imageUrl}\" alt=\"PlantUML Diagram\" style=\"max-width: 100%; height: auto;\" />\n  </div>`\n}\n\n/**\n * PlantUML marked 扩展\n */\nexport function markedPlantUML(options: PlantUMLOptions = {}): MarkedExtension {\n  const resolvedOptions: Required<PlantUMLOptions> = {\n    serverUrl: options.serverUrl || `https://www.plantuml.com/plantuml`,\n    format: options.format || `svg`,\n    className: options.className || `plantuml-diagram`,\n    inlineSvg: options.inlineSvg || false,\n    styles: {\n      container: {\n        textAlign: `center`,\n        margin: `16px 8px`,\n        overflowX: `auto`,\n        ...options.styles?.container,\n      },\n    },\n  }\n\n  return {\n    extensions: [\n      {\n        name: `plantuml`,\n        level: `block`,\n        start(src: string) {\n          // 匹配 ```plantuml 代码块\n          return src.match(/^```plantuml/m)?.index\n        },\n        tokenizer(src: string) {\n          // 匹配完整的 plantuml 代码块\n          const match = /^```plantuml\\r?\\n([\\s\\S]*?)\\r?\\n```/.exec(src)\n\n          if (match) {\n            const [raw, code] = match\n            return {\n              type: `plantuml`,\n              raw,\n              text: code.trim(),\n            }\n          }\n        },\n        renderer(token: any) {\n          const cacheKey = simpleHash(token.text)\n\n          // 有缓存直接返回\n          const cached = svgCache.get(cacheKey)\n          if (cached) {\n            return cached\n          }\n\n          return renderPlantUMLDiagram(token, resolvedOptions, cacheKey)\n        },\n      },\n    ],\n    walkTokens(token: any) {\n      // 处理现有的代码块，如果语言是 plantuml 就转换类型\n      if (token.type === `code` && token.lang === `plantuml`) {\n        token.type = `plantuml`\n      }\n    },\n  }\n}\n"
  },
  {
    "path": "packages/core/src/extensions/ruby.ts",
    "content": "import type { MarkedExtension } from 'marked'\n\n/**\n * 注音/拼音标注扩展\n * https://talk.commonmark.org/t/proper-ruby-text-rb-syntax-support-in-markdown/2279\n * https://www.w3.org/TR/ruby/\n *\n * 支持的格式：\n * 1. [文字]{注音}\n * 2. [文字]^(注音)\n *\n * 分隔符：\n * - `・` (中点)\n * - `．` (全角句点)\n * - `。` (中文句号)\n * - `-` (英文减号)\n */\nexport function markedRuby(): MarkedExtension {\n  return {\n    extensions: [\n      {\n        name: `ruby`,\n        level: `inline`,\n        start(src: string) {\n          // 匹配以 [ 开头的格式\n          return src.match(/\\[/)?.index\n        },\n        tokenizer(src: string) {\n          // 1. [文字]{注音}\n          const rule1 = /^\\[([^\\]]+)\\]\\{([^}]+)\\}/\n          let match = rule1.exec(src)\n          if (match) {\n            return {\n              type: `ruby`,\n              raw: match[0],\n              text: match[1].trim(),\n              ruby: match[2].trim(),\n              format: `basic`,\n            }\n          }\n\n          // 2. [文字]^(注音)\n          const rule2 = /^\\[([^\\]]+)\\]\\^\\(([^)]+)\\)/\n          match = rule2.exec(src)\n          if (match) {\n            return {\n              type: `ruby`,\n              raw: match[0],\n              text: match[1].trim(),\n              ruby: match[2].trim(),\n              format: `basic-hat`,\n            }\n          }\n\n          return undefined\n        },\n        renderer(token: any) {\n          const { text, ruby, format } = token\n\n          // 检查是否有分隔符\n          const separatorRegex = /[・．。-]/g\n          const hasSeparators = separatorRegex.test(ruby)\n\n          if (hasSeparators) {\n            // 分割注音部分\n            const rubyParts = ruby.split(separatorRegex).filter((part: string) => part.trim() !== ``)\n\n            const textChars = text.split(``)\n            const result = []\n\n            if (textChars.length >= rubyParts.length) {\n              // 文字字符数量 >= 注音部分数量\n              // 按注音部分数量分割文字\n              let currentIndex = 0\n\n              for (let i = 0; i < rubyParts.length; i++) {\n                const rubyPart = rubyParts[i]\n                const remainingChars = textChars.length - currentIndex\n                const remainingParts = rubyParts.length - i\n\n                // 计算当前部分应该包含多少个字符，默认为 1\n                let charCount = 1\n                if (remainingParts === 1) {\n                  // 最后一个部分，包含所有剩余字符\n                  charCount = remainingChars\n                }\n\n                // 提取当前部分的文字\n                const currentText = textChars.slice(currentIndex, currentIndex + charCount).join(``)\n\n                result.push(`<ruby data-text=\"${currentText}\" data-ruby=\"${rubyPart}\" data-format=\"${format}\">${currentText}<rp>(</rp><rt>${rubyPart}</rt><rp>)</rp></ruby>`)\n\n                currentIndex += charCount\n              }\n\n              // 处理剩余的字符\n              if (currentIndex < textChars.length) {\n                result.push(textChars.slice(currentIndex).join(``))\n              }\n            }\n            else {\n              // 文字字符数量 < 注音部分数量\n              // 每个字符对应一个注音部分，多余的注音被忽略\n              for (let i = 0; i < textChars.length; i++) {\n                const char = textChars[i]\n                const rubyPart = rubyParts[i] || ``\n\n                if (rubyPart) {\n                  result.push(`<ruby data-text=\"${char}\" data-ruby=\"${rubyPart}\" data-format=\"${format}\">${char}<rp>(</rp><rt>${rubyPart}</rt><rp>)</rp></ruby>`)\n                }\n                else {\n                  result.push(char)\n                }\n              }\n            }\n\n            return result.join(``)\n          }\n\n          return `<ruby data-text=\"${text}\" data-ruby=\"${ruby}\" data-format=\"${format}\">${text}<rp>(</rp><rt>${ruby}</rt><rp>)</rp></ruby>`\n        },\n      },\n    ],\n  }\n}\n"
  },
  {
    "path": "packages/core/src/extensions/slider.ts",
    "content": "import type { MarkedExtension, Tokens } from 'marked'\n\n/**\n * A marked extension to support horizontal sliding images.\n * Syntax: <![alt1](url1),![alt2](url2),![alt3](url3)>\n */\nexport function markedSlider(): MarkedExtension {\n  return {\n    extensions: [\n      {\n        name: `horizontalSlider`,\n        level: `block`,\n        start(src: string) {\n          return src.match(/^<!\\[/)?.index\n        },\n        tokenizer(src: string) {\n          const rule = /^<(!\\[.*?\\]\\(.*?\\)(?:,!\\[.*?\\]\\(.*?\\))*)>/\n          const match = src.match(rule)\n          if (match) {\n            return {\n              type: `horizontalSlider`,\n              raw: match[0],\n              text: match[1],\n            }\n          }\n          return undefined\n        },\n        renderer(token: Tokens.Generic) {\n          const { text } = token\n          const imageMatches = text.match(/!\\[(.*?)\\]\\((.*?)\\)/g) || []\n\n          if (imageMatches.length === 0) {\n            return ``\n          }\n\n          const images = imageMatches.map((img: string) => {\n            const altMatch = img.match(/!\\[(.*?)\\]/) || []\n            const srcMatch = img.match(/\\]\\((.*?)\\)/) || []\n            const alt = altMatch[1] || ``\n            const src = srcMatch[1] || ``\n\n            // 新主题系统：不再需要内联样式\n            return { src, alt }\n          })\n\n          // 使用微信公众号兼容的滑动容器布局\n          // 使用微信支持的section标签和特殊样式组合\n\n          return `\n            <section style=\"box-sizing: border-box; font-size: 16px;\">\n              <section data-role=\"outer\" style=\"font-family: 微软雅黑; font-size: 16px;\">\n                <section data-role=\"paragraph\" style=\"margin: 0px auto; box-sizing: border-box; width: 100%;\">\n                  <section style=\"margin: 0px auto; text-align: center;\">\n                    <section style=\"display: inline-block; width: 100%;\">\n                      <!-- 微信公众号支持的滑动图片容器 -->\n                      <section style=\"overflow-x: scroll; -webkit-overflow-scrolling: touch; white-space: nowrap; width: 100%; text-align: center;\">\n                        ${images.map((img: { src: string, alt: string }, _index: number) => `<section style=\"display: inline-block; width: 100%; margin-right: 0; vertical-align: top;\">\n                          <img src=\"${img.src}\" alt=\"${img.alt}\" title=\"${img.alt}\" style=\"width: 100%; height: auto; border-radius: 4px; vertical-align: top;\"/>\n                          <p style=\"margin-top: 5px; font-size: 14px; color: #666; text-align: center; white-space: normal;\">${img.alt}</p>\n                        </section>`).join(``)}\n                      </section>\n                    </section>\n                  </section>\n                </section>\n              </section>\n              <p style=\"font-size: 14px; color: #999; text-align: center; margin-top: 5px;\"><<< 左右滑动看更多 >>></p>\n            </section>\n          `\n        },\n      },\n    ],\n  }\n}\n"
  },
  {
    "path": "packages/core/src/extensions/toc.ts",
    "content": "import type { MarkedExtension } from 'marked'\n\n/**\n * marked 插件：支持 [TOC] 语法，自动生成嵌套目录\n */\nexport function markedToc(): MarkedExtension {\n  let headings: { text: string, depth: number, index: number }[] = []\n\n  let firstToken = true\n\n  return {\n    walkTokens(token) {\n      if (firstToken) {\n        headings = []\n        firstToken = false\n      }\n      if (token.type === `heading`) {\n        const text = token.text || ``\n        const depth = token.depth || 1\n        const index = headings.length\n        headings.push({ text, depth, index })\n      }\n    },\n    extensions: [\n      {\n        name: `toc`,\n        level: `block`,\n        start(src) {\n          // 只匹配独立一行的 [TOC]，避免误伤\n          const match = src.match(/^\\s*\\[TOC\\]\\s*$/m)\n          return match ? match.index : undefined\n        },\n        tokenizer(src) {\n          const match = /^\\[TOC\\]/.exec(src)\n          if (match) {\n            return {\n              type: `toc`,\n              raw: match[0],\n            }\n          }\n        },\n        renderer() {\n          if (!headings.length)\n            return ``\n          let html = `<nav class=\"markdown-toc\"><ul class=\"toc-ul toc-level-1 pl-4 border-l ml-2\">`\n          let lastDepth = 1\n          headings.forEach(({ text, depth, index }) => {\n            if (depth > lastDepth) {\n              for (let i = lastDepth + 1; i <= depth; i++) {\n                html += `<ul class=\"toc-ul toc-level-${i} pl-4 border-l ml-2\">`\n              }\n            }\n            else if (depth < lastDepth) {\n              for (let i = lastDepth; i > depth; i--) {\n                html += `</ul>`\n              }\n            }\n            html += `<li class=\"toc-li toc-level-${depth} mb-1\"><a class=\"text-gray-700 hover:text-blue-600 underline transition-colors\" href=\"#${index}\">${text}</a></li>`\n            lastDepth = depth\n          })\n\n          for (let i = lastDepth; i > 1; i--) {\n            html += `</ul>`\n          }\n\n          html += `</ul></nav>`\n\n          firstToken = true\n          return html\n        },\n      },\n    ],\n  }\n}\n"
  },
  {
    "path": "packages/core/src/index.ts",
    "content": "export * from './extensions'\nexport * from './renderer'\nexport * from './theme'\nexport * from './utils'\n"
  },
  {
    "path": "packages/core/src/renderer/index.ts",
    "content": "// 主渲染器导出\nexport * from './renderer-impl'\nexport type { RendererAPI } from '@md/shared/types'\n"
  },
  {
    "path": "packages/core/src/renderer/renderer-impl.ts",
    "content": "import type { IOpts, RendererAPI } from '@md/shared/types'\nimport type { ReadTimeResults } from '@md/shared/utils/readingTime'\nimport type { RendererObject, Tokens } from 'marked'\nimport readingTime from '@md/shared/utils/readingTime'\nimport frontMatter from 'front-matter'\nimport hljs from 'highlight.js/lib/core'\nimport { marked } from 'marked'\nimport {\n  markedAlert,\n  markedFootnotes,\n  markedInfographic,\n  markedMarkup,\n  markedMermaid,\n  markedPlantUML,\n  markedRuby,\n  markedSlider,\n  markedToc,\n  MDKatex,\n} from '../extensions'\nimport { COMMON_LANGUAGES, highlightAndFormatCode } from '../utils/languages'\n\nObject.entries(COMMON_LANGUAGES).forEach(([name, lang]) => {\n  hljs.registerLanguage(name, lang)\n})\n\nexport { hljs }\n\nmarked.setOptions({\n  breaks: true,\n})\nmarked.use(markedSlider())\n\nconst AMPERSAND_REGEX = /&/g\nconst LESS_THAN_REGEX = /</g\nconst GREATER_THAN_REGEX = />/g\nconst DOUBLE_QUOTE_REGEX = /\"/g\nconst SINGLE_QUOTE_REGEX = /'/g\nconst BACKTICK_REGEX = /`/g\nconst UNDERSCORE_REGEX = /_/g\nconst HEADING_TAG_REGEX = /^h\\d$/\nconst PARAGRAPH_WRAPPER_REGEX = /^<p(?:\\s[^>]*)?>([\\s\\S]*?)<\\/p>/\nconst MP_WEIXIN_LINK_REGEX = /^https?:\\/\\/mp\\.weixin\\.qq\\.com/\n\nfunction escapeHtml(text: string): string {\n  return text\n    .replace(AMPERSAND_REGEX, `&amp;`) // 转义 &\n    .replace(LESS_THAN_REGEX, `&lt;`) // 转义 <\n    .replace(GREATER_THAN_REGEX, `&gt;`) // 转义 >\n    .replace(DOUBLE_QUOTE_REGEX, `&quot;`) // 转义 \"\n    .replace(SINGLE_QUOTE_REGEX, `&#39;`) // 转义 '\n    .replace(BACKTICK_REGEX, `&#96;`) // 转义 `\n}\n\nfunction buildAddition(): string {\n  return `\n    <style>\n      .preview-wrapper pre::before {\n        position: absolute;\n        top: 0;\n        right: 0;\n        color: #ccc;\n        text-align: center;\n        font-size: 0.8em;\n        padding: 5px 10px 0;\n        line-height: 15px;\n        height: 15px;\n        font-weight: 600;\n      }\n    </style>\n  `\n}\n\nfunction buildFootnoteArray(footnotes: [number, string, string][]): string {\n  return footnotes\n    .map(([index, title, link]) =>\n      link === title\n        ? `<code style=\"font-size: 90%; opacity: 0.6;\">[${index}]</code>: <i style=\"word-break: break-all\">${title}</i><br/>`\n        : `<code style=\"font-size: 90%; opacity: 0.6;\">[${index}]</code> ${title}: <i style=\"word-break: break-all\">${link}</i><br/>`,\n    )\n    .join(`\\n`)\n}\n\nfunction extractFileName(href: string): string {\n  try {\n    // 移除查询参数和哈希\n    const urlPath = href.split('?')[0].split('#')[0]\n    // 获取最后一个 / 之后的部分\n    const fileName = urlPath.split('/').pop() || ''\n    // 移除文件扩展名\n    const nameWithoutExt = fileName.replace(/\\.[^.]*$/, '')\n    return nameWithoutExt\n  }\n  catch {\n    return ''\n  }\n}\n\nfunction transform(legend: string, text: string | null, title: string | null, href: string = ''): string {\n  const options = legend.split(`-`)\n  for (const option of options) {\n    if (option === `alt` && text) {\n      return text\n    }\n    if (option === `title` && title) {\n      return title\n    }\n    if (option === `filename` && href) {\n      const fileName = extractFileName(href)\n      if (fileName) {\n        return escapeHtml(fileName)\n      }\n    }\n  }\n  return ``\n}\n\nconst macCodeSvg = `\n  <svg xmlns=\"http://www.w3.org/2000/svg\" version=\"1.1\" x=\"0px\" y=\"0px\" width=\"45px\" height=\"13px\" viewBox=\"0 0 450 130\">\n    <ellipse cx=\"50\" cy=\"65\" rx=\"50\" ry=\"52\" stroke=\"rgb(220,60,54)\" stroke-width=\"2\" fill=\"rgb(237,108,96)\" />\n    <ellipse cx=\"225\" cy=\"65\" rx=\"50\" ry=\"52\" stroke=\"rgb(218,151,33)\" stroke-width=\"2\" fill=\"rgb(247,193,81)\" />\n    <ellipse cx=\"400\" cy=\"65\" rx=\"50\" ry=\"52\" stroke=\"rgb(27,161,37)\" stroke-width=\"2\" fill=\"rgb(100,200,86)\" />\n  </svg>\n`.trim()\n\ninterface ParseResult {\n  yamlData: Record<string, any>\n  markdownContent: string\n  readingTime: ReadTimeResults\n}\n\nfunction parseFrontMatterAndContent(markdownText: string): ParseResult {\n  try {\n    const parsed = frontMatter(markdownText)\n    const yamlData = parsed.attributes\n    const markdownContent = parsed.body\n\n    const readingTimeResult = readingTime(markdownContent)\n\n    return {\n      yamlData: yamlData as Record<string, any>,\n      markdownContent,\n      readingTime: readingTimeResult,\n    }\n  }\n  catch (error) {\n    console.error(`Error parsing front-matter:`, error)\n    return {\n      yamlData: {},\n      markdownContent: markdownText,\n      readingTime: readingTime(markdownText),\n    }\n  }\n}\n\nexport function initRenderer(opts: IOpts = {}): RendererAPI {\n  const footnotes: [number, string, string][] = []\n  let footnoteIndex: number = 0\n  const listOrderedStack: boolean[] = []\n  const listCounters: number[] = []\n\n  function getOpts(): IOpts {\n    return opts\n  }\n\n  /**\n   * 生成带 CSS 类的内容（新主题系统）\n   * @param styleLabel CSS 类名标识\n   * @param content 内容\n   * @param tagName HTML 标签名（可选）\n   */\n  function styledContent(styleLabel: string, content: string, tagName?: string): string {\n    const tag = tagName ?? styleLabel\n    const className = `${styleLabel.replace(UNDERSCORE_REGEX, `-`)}`\n    const headingAttr = HEADING_TAG_REGEX.test(tag) ? ` data-heading=\"true\"` : ``\n    return `<${tag} class=\"${className}\"${headingAttr}>${content}</${tag}>`\n  }\n\n  function addFootnote(title: string, link: string): number {\n    // 检查是否已经存在相同的链接\n    const existingFootnote = footnotes.find(([, , existingLink]) => existingLink === link)\n    if (existingFootnote) {\n      return existingFootnote[0] // 返回已存在的脚注索引\n    }\n\n    // 如果不存在，创建新的脚注\n    footnotes.push([++footnoteIndex, title, link])\n    return footnoteIndex\n  }\n\n  function reset(newOpts: Partial<IOpts>): void {\n    footnotes.length = 0\n    footnoteIndex = 0\n    setOptions(newOpts)\n  }\n\n  function setOptions(newOpts: Partial<IOpts>): void {\n    opts = { ...opts, ...newOpts }\n    marked.use(markedInfographic({ themeMode: newOpts.themeMode }))\n  }\n\n  function buildReadingTime(readingTime: ReadTimeResults): string {\n    if (!opts.countStatus) {\n      return ``\n    }\n    if (!readingTime.words) {\n      return ``\n    }\n    return `\n      <blockquote class=\"md-blockquote\">\n        <p class=\"md-blockquote-p\">字数 ${readingTime?.words}，阅读大约需 ${Math.ceil(readingTime?.minutes)} 分钟</p>\n      </blockquote>\n    `\n  }\n\n  const buildFootnotes = () => {\n    if (!footnotes.length) {\n      return ``\n    }\n\n    return (\n      styledContent(`h4`, `引用链接`)\n      + styledContent(`footnotes`, buildFootnoteArray(footnotes), `p`)\n    )\n  }\n\n  const renderer: RendererObject = {\n    heading({ tokens, depth }: Tokens.Heading) {\n      const text = this.parser.parseInline(tokens)\n      const tag = `h${depth}`\n      return styledContent(tag, text)\n    },\n\n    paragraph({ tokens }: Tokens.Paragraph): string {\n      const text = this.parser.parseInline(tokens)\n      const isFigureImage = text.includes(`<figure`) && text.includes(`<img`)\n      const isEmpty = text.trim() === ``\n      if (isFigureImage || isEmpty) {\n        return text\n      }\n      return styledContent(`p`, text)\n    },\n\n    blockquote({ tokens }: Tokens.Blockquote): string {\n      const text = this.parser.parse(tokens)\n      // 新主题系统：blockquote 内的 p 标签由 CSS 选择器 `blockquote p` 控制\n      return styledContent(`blockquote`, text)\n    },\n\n    code({ text, lang = `` }: Tokens.Code): string {\n      const langText = lang.split(` `)[0]\n      const isLanguageRegistered = hljs.getLanguage(langText)\n      const language = isLanguageRegistered ? langText : `plaintext`\n\n      const highlighted = highlightAndFormatCode(text, language, hljs, !!opts.isShowLineNumber)\n\n      const span = `<span class=\"mac-sign\" style=\"padding: 10px 14px 0;\">${macCodeSvg}</span>`\n      // 如果语言未注册，添加 data-language-pending 属性和原始代码文本用于后续动态加载\n      let pendingAttr = ``\n      if (!isLanguageRegistered && langText !== `plaintext`) {\n        const escapedText = text.replace(DOUBLE_QUOTE_REGEX, `&quot;`)\n        pendingAttr = ` data-language-pending=\"${langText}\" data-raw-code=\"${escapedText}\" data-show-line-number=\"${opts.isShowLineNumber}\"`\n      }\n      const code = `<code class=\"language-${lang}\"${pendingAttr}>${highlighted}</code>`\n\n      return `<pre class=\"hljs code__pre\">${span}${code}</pre>`\n    },\n\n    codespan({ text }: Tokens.Codespan): string {\n      const escapedText = escapeHtml(text)\n      return styledContent(`codespan`, escapedText, `code`)\n    },\n\n    list({ ordered, items, start = 1 }: Tokens.List) {\n      listOrderedStack.push(ordered)\n      listCounters.push(Number(start))\n\n      const html = items\n        .map(item => this.listitem(item))\n        .join(``)\n\n      listOrderedStack.pop()\n      listCounters.pop()\n\n      return styledContent(\n        ordered ? `ol` : `ul`,\n        html,\n      )\n    },\n\n    // 2. listitem：从栈顶取 ordered + counter，计算 prefix 并自增\n    listitem(token: Tokens.ListItem) {\n      const ordered = listOrderedStack[listOrderedStack.length - 1]\n      const idx = listCounters[listCounters.length - 1]!\n\n      // 准备下一个\n      listCounters[listCounters.length - 1] = idx + 1\n\n      const prefix = ordered\n        ? `${idx}. `\n        : `• `\n\n      // 渲染内容：优先 inline，fallback 去掉 <p> 包裹\n      let content: string\n      try {\n        content = this.parser.parseInline(token.tokens)\n      }\n      catch {\n        content = this.parser\n          .parse(token.tokens)\n          .replace(PARAGRAPH_WRAPPER_REGEX, `$1`)\n      }\n\n      return styledContent(\n        `listitem`,\n        `${prefix}${content}`,\n        `li`,\n      )\n    },\n\n    image({ href, title, text }: Tokens.Image): string {\n      const newText = opts.legend ? transform(opts.legend, text, title, href) : ``\n      const subText = newText ? styledContent(`figcaption`, newText) : ``\n      const titleAttr = title ? ` title=\"${title}\"` : ``\n      return `<figure><img src=\"${href}\"${titleAttr} alt=\"${text}\"/>${subText}</figure>`\n    },\n\n    link({ href, title, text, tokens }: Tokens.Link): string {\n      const parsedText = this.parser.parseInline(tokens)\n      if (MP_WEIXIN_LINK_REGEX.test(href)) {\n        return `<a href=\"${href}\" title=\"${title || text}\">${parsedText}</a>`\n      }\n      if (href === text) {\n        return parsedText\n      }\n      if (opts.citeStatus) {\n        const ref = addFootnote(title || text, href)\n        return `<a href=\"${href}\" title=\"${title || text}\">${parsedText}<sup>[${ref}]</sup></a>`\n      }\n      return `<a href=\"${href}\" title=\"${title || text}\">${parsedText}</a>`\n    },\n\n    strong({ tokens }: Tokens.Strong): string {\n      return styledContent(`strong`, this.parser.parseInline(tokens))\n    },\n\n    em({ tokens }: Tokens.Em): string {\n      return styledContent(`em`, this.parser.parseInline(tokens))\n    },\n\n    table({ header, rows }: Tokens.Table): string {\n      const headerRow = header\n        .map((cell) => {\n          const text = this.parser.parseInline(cell.tokens)\n          return styledContent(`th`, text)\n        })\n        .join(``)\n      const body = rows\n        .map((row) => {\n          const rowContent = row\n            .map(cell => this.tablecell(cell))\n            .join(``)\n          return styledContent(`tr`, rowContent)\n        })\n        .join(``)\n      return `\n        <section style=\"max-width: 100%; overflow: auto\">\n          <table class=\"preview-table\">\n            <thead>${headerRow}</thead>\n            <tbody>${body}</tbody>\n          </table>\n        </section>\n      `\n    },\n\n    tablecell(token: Tokens.TableCell): string {\n      const text = this.parser.parseInline(token.tokens)\n      return styledContent(`td`, text)\n    },\n\n    hr(_: Tokens.Hr): string {\n      return styledContent(`hr`, ``)\n    },\n  }\n\n  marked.use({ renderer })\n  // 新主题系统：扩展不再需要 styles 参数\n  marked.use(markedMarkup())\n  marked.use(markedToc())\n  marked.use(markedSlider())\n  marked.use(markedAlert({}))\n  marked.use(MDKatex({ nonStandard: true }, true))\n  marked.use(markedFootnotes())\n  marked.use(markedMermaid())\n  marked.use(markedPlantUML({\n    inlineSvg: true, // 启用SVG内嵌，适用于微信公众号\n  }))\n  marked.use(markedInfographic({ themeMode: opts.themeMode }))\n  marked.use(markedRuby())\n\n  return {\n    buildAddition,\n    buildFootnotes,\n    setOptions,\n    reset,\n    parseFrontMatterAndContent,\n    buildReadingTime,\n    createContainer(content: string) {\n      return styledContent(`container`, content, `section`)\n    },\n    getOpts,\n  }\n}\n"
  },
  {
    "path": "packages/core/src/theme/cssProcessor.ts",
    "content": "/**\n * CSS 运行时处理工具\n * 使用 PostCSS 在运行时处理动态注入的 CSS\n */\n\nimport postcss from 'postcss'\nimport postcssCalc from 'postcss-calc'\nimport postcssCustomProperties from 'postcss-custom-properties'\n\n/**\n * 使用 PostCSS 处理 CSS 字符串\n * 处理步骤：\n * 1. 使用 postcss-custom-properties 替换 CSS 变量为实际值\n * 2. 使用 postcss-calc 处理 calc() 表达式，简化可计算的表达式\n *\n * @param css - 原始 CSS 字符串\n * @returns 处理后的 CSS 字符串\n */\nexport async function processCSS(css: string): Promise<string> {\n  try {\n    const result = await postcss([\n      postcssCustomProperties({\n        preserve: false, // 不保留原始 CSS 变量定义\n      }),\n      postcssCalc({\n        preserve: false, // 不保留 calc()，尽可能简化\n        mediaQueries: false, // 不处理媒体查询中的 calc()\n        selectors: false, // 不处理选择器中的 calc()\n      }),\n    ]).process(css, {\n      from: undefined, // 不指定源文件\n    })\n\n    return result.css\n  }\n  catch (error) {\n    console.warn(`[processCSS] CSS 处理失败，使用原始 CSS:`, error)\n    return css\n  }\n}\n"
  },
  {
    "path": "packages/core/src/theme/cssScopeWrapper.ts",
    "content": "/**\n * CSS 作用域包装器\n * 给 CSS 选择器添加作用域前缀，限制样式只在预览区域生效\n */\n\nimport { SELECTOR_MAPPING } from './selectorMapping'\n\n/**\n * 给 CSS 添加作用域前缀，并使用映射表转换旧选择器\n * @param css - 原始 CSS 字符串\n * @param scope - 作用域选择器，默认为 #output\n * @returns 添加作用域后的 CSS\n */\nexport function wrapCSSWithScope(css: string, scope: string = `#output`): string {\n  // 处理每个 CSS 规则\n  return css.replace(\n    /([^{}]+)\\{([^}]*)\\}/g,\n    (match, selectors, properties) => {\n      // 跳过 @规则（如 @keyframes, @media）和 :root\n      const trimmedSelectors = selectors.trim()\n      if (trimmedSelectors.startsWith(`@`) || trimmedSelectors.startsWith(`:root`)) {\n        return match\n      }\n\n      // 分割多个选择器（用逗号分隔）\n      const wrappedSelectors = selectors\n        .split(`,`)\n        .map((selector: string) => {\n          let trimmed = selector.trim()\n\n          // 跳过已经有作用域前缀的\n          if (trimmed.startsWith(scope)) {\n            return trimmed\n          }\n\n          // 跳过空选择器\n          if (!trimmed) {\n            return trimmed\n          }\n\n          // 获取选择器的第一部分（基础选择器）\n          const baseSelector = trimmed.split(/[\\s>+~:[]/, 1)[0].trim()\n\n          // 使用映射表转换旧选择器到新类名\n          if (baseSelector && SELECTOR_MAPPING[baseSelector]) {\n            // 替换基础选择器为新类名\n            trimmed = trimmed.replace(baseSelector, `.${SELECTOR_MAPPING[baseSelector]}`)\n          }\n\n          // 对 h1-h6 标题选择器添加 section 以匹配预设标题样式的选择器优先级\n          const headingMatch = trimmed.match(/^(h[1-6])(\\s|$|::|[:[])/)\n          if (headingMatch) {\n            return `${scope} section ${trimmed}`\n          }\n\n          // 添加作用域前缀\n          return `${scope} ${trimmed}`\n        })\n        .filter(Boolean)\n        .join(`,\\n`)\n\n      return `${wrappedSelectors} {${properties}}`\n    },\n  )\n}\n"
  },
  {
    "path": "packages/core/src/theme/cssVariables.ts",
    "content": "/**\n * CSS 变量生成工具\n * 根据配置动态生成 CSS 变量样式\n */\n\nimport type { HeadingLevel, HeadingStyles, HeadingStyleType } from '@md/shared/configs'\n\nexport interface CSSVariableConfig {\n  primaryColor: string\n  fontFamily: string\n  fontSize: string\n  isUseIndent?: boolean\n  isUseJustify?: boolean\n  headingStyles?: HeadingStyles\n}\n\n/**\n * 生成 CSS 变量样式\n * @param config - 配置对象\n * @returns CSS 变量字符串\n */\nexport function generateCSSVariables(config: CSSVariableConfig): string {\n  return `\n:root {\n  /* 动态配置变量 */\n  --md-primary-color: ${config.primaryColor};\n  --md-font-family: ${config.fontFamily};\n  --md-font-size: ${config.fontSize};\n}\n\n/* 段落缩进和对齐 */\n#output p {\n  ${config.isUseIndent ? 'text-indent: 2em;' : ''}\n  ${config.isUseJustify ? 'text-align: justify;' : ''}\n}\n  `.trim()\n}\n\n/**\n * 生成标题样式 CSS（单独导出，用于在主题 CSS 之后应用）\n */\nexport function generateHeadingStyles(config: CSSVariableConfig): string {\n  return generateHeadingStylesCSS(config.headingStyles)\n}\n\n/**\n * 生成标题样式 CSS\n */\nfunction generateHeadingStylesCSS(headingStyles?: HeadingStyles): string {\n  if (!headingStyles)\n    return ``\n\n  const levels: HeadingLevel[] = [`h1`, `h2`, `h3`, `h4`, `h5`, `h6`]\n  const cssRules: string[] = []\n\n  for (const level of levels) {\n    const style = headingStyles[level]\n    // 自定义样式由用户在 CSS 编辑器中直接编辑，这里只处理预设样式\n    if (style && style !== `default` && style !== `custom`) {\n      cssRules.push(generateHeadingCSS(level, style))\n    }\n  }\n\n  return cssRules.join(`\\n\\n`)\n}\n\n/**\n * 生成单个标题级别的样式 CSS\n */\nfunction generateHeadingCSS(level: HeadingLevel, style: HeadingStyleType): string {\n  const baseStyles = `\n  display: block;\n  text-align: left;\n  background: transparent;`\n\n  switch (style) {\n    case `color-only`:\n      return `#output ${level} {\n  color: var(--md-primary-color);\n  background: transparent;\n}`\n\n    case `border-bottom`:\n      return `#output ${level} {${baseStyles}\n  padding-bottom: 0.3em;\n  border-bottom: 2px solid var(--md-primary-color);\n  color: var(--md-primary-color);\n}`\n\n    case `border-left`:\n      return `#output ${level} {${baseStyles}\n  margin-left: 0;\n  padding-left: 10px;\n  border-left: 4px solid var(--md-primary-color);\n  color: var(--md-primary-color);\n}`\n\n    default:\n      return ``\n  }\n}\n"
  },
  {
    "path": "packages/core/src/theme/index.ts",
    "content": "export * from './cssProcessor'\nexport * from './cssScopeWrapper'\nexport * from './cssVariables'\nexport * from './selectorMapping'\nexport * from './themeApplicator'\nexport * from './themeExporter'\nexport * from './themeInjector'\n"
  },
  {
    "path": "packages/core/src/theme/selectorMapping.ts",
    "content": "/**\n * CSS 选择器映射表\n * 将旧的自定义选择器映射到新的规范类名（kebab-case）\n * 实现向后兼容\n */\n\n/**\n * 选择器映射表\n * 旧选择器 → 新类名\n */\nexport const SELECTOR_MAPPING: Record<string, string> = {\n  // GFM Alert 相关\n  blockquote_note: `markdown-alert-note`,\n  blockquote_tip: `markdown-alert-tip`,\n  blockquote_info: `markdown-alert-info`,\n  blockquote_important: `markdown-alert-important`,\n  blockquote_warning: `markdown-alert-warning`,\n  blockquote_caution: `markdown-alert-caution`,\n\n  // Obsidian-style Callouts\n  blockquote_abstract: `markdown-alert-abstract`,\n  blockquote_summary: `markdown-alert-summary`,\n  blockquote_tldr: `markdown-alert-tldr`,\n  blockquote_todo: `markdown-alert-todo`,\n  blockquote_success: `markdown-alert-success`,\n  blockquote_done: `markdown-alert-done`,\n  blockquote_question: `markdown-alert-question`,\n  blockquote_help: `markdown-alert-help`,\n  blockquote_faq: `markdown-alert-faq`,\n  blockquote_failure: `markdown-alert-failure`,\n  blockquote_fail: `markdown-alert-fail`,\n  blockquote_missing: `markdown-alert-missing`,\n  blockquote_danger: `markdown-alert-danger`,\n  blockquote_error: `markdown-alert-error`,\n  blockquote_bug: `markdown-alert-bug`,\n  blockquote_example: `markdown-alert-example`,\n  blockquote_quote: `markdown-alert-quote`,\n  blockquote_cite: `markdown-alert-cite`,\n\n  blockquote_title: `alert-title`,\n  blockquote_title_note: `alert-title-note`,\n  blockquote_title_tip: `alert-title-tip`,\n  blockquote_title_info: `alert-title-info`,\n  blockquote_title_important: `alert-title-important`,\n  blockquote_title_warning: `alert-title-warning`,\n  blockquote_title_caution: `alert-title-caution`,\n\n  // Obsidian-style Callout titles\n  blockquote_title_abstract: `alert-title-abstract`,\n  blockquote_title_summary: `alert-title-summary`,\n  blockquote_title_tldr: `alert-title-tldr`,\n  blockquote_title_todo: `alert-title-todo`,\n  blockquote_title_success: `alert-title-success`,\n  blockquote_title_done: `alert-title-done`,\n  blockquote_title_question: `alert-title-question`,\n  blockquote_title_help: `alert-title-help`,\n  blockquote_title_faq: `alert-title-faq`,\n  blockquote_title_failure: `alert-title-failure`,\n  blockquote_title_fail: `alert-title-fail`,\n  blockquote_title_missing: `alert-title-missing`,\n  blockquote_title_danger: `alert-title-danger`,\n  blockquote_title_error: `alert-title-error`,\n  blockquote_title_bug: `alert-title-bug`,\n  blockquote_title_example: `alert-title-example`,\n  blockquote_title_quote: `alert-title-quote`,\n  blockquote_title_cite: `alert-title-cite`,\n\n  blockquote_p: `alert-content`,\n  blockquote_p_note: `alert-content-note`,\n  blockquote_p_tip: `alert-content-tip`,\n  blockquote_p_info: `alert-content-info`,\n  blockquote_p_important: `alert-content-important`,\n  blockquote_p_warning: `alert-content-warning`,\n  blockquote_p_caution: `alert-content-caution`,\n\n  // Obsidian-style Callout content\n  blockquote_p_abstract: `alert-content-abstract`,\n  blockquote_p_summary: `alert-content-summary`,\n  blockquote_p_tldr: `alert-content-tldr`,\n  blockquote_p_todo: `alert-content-todo`,\n  blockquote_p_success: `alert-content-success`,\n  blockquote_p_done: `alert-content-done`,\n  blockquote_p_question: `alert-content-question`,\n  blockquote_p_help: `alert-content-help`,\n  blockquote_p_faq: `alert-content-faq`,\n  blockquote_p_failure: `alert-content-failure`,\n  blockquote_p_fail: `alert-content-fail`,\n  blockquote_p_missing: `alert-content-missing`,\n  blockquote_p_danger: `alert-content-danger`,\n  blockquote_p_error: `alert-content-error`,\n  blockquote_p_bug: `alert-content-bug`,\n  blockquote_p_example: `alert-content-example`,\n  blockquote_p_quote: `alert-content-quote`,\n  blockquote_p_cite: `alert-content-cite`,\n\n  // 代码相关\n  code_pre: `code-block`,\n  codespan: `code-inline`,\n\n  // KaTeX 公式\n  inline_katex: `katex-inline`,\n  block_katex: `katex-block`,\n\n  // Markup 标记\n  markup_highlight: `markup-highlight`,\n  markup_underline: `markup-underline`,\n  markup_wavyline: `markup-wavyline`,\n\n  listitem: `listitem`,\n}\n"
  },
  {
    "path": "packages/core/src/theme/themeApplicator.ts",
    "content": "/**\n * 主题应用工具\n * 负责将主题样式应用到页面\n */\n\nimport type { ThemeName } from '@md/shared/configs'\nimport type { CSSVariableConfig } from './cssVariables'\nimport { baseCSSContent, themeMap } from '@md/shared/configs'\nimport { processCSS } from './cssProcessor'\nimport { wrapCSSWithScope } from './cssScopeWrapper'\nimport { generateCSSVariables, generateHeadingStyles } from './cssVariables'\nimport { getThemeInjector } from './themeInjector'\n\nexport interface ThemeConfig {\n  themeName: string // 主题名称\n  customCSS?: string // 用户自定义 CSS\n  variables: CSSVariableConfig\n}\n\n/**\n * 应用主题\n * @param config - 主题配置\n */\nexport async function applyTheme(config: ThemeConfig): Promise<void> {\n  // 1. 生成 CSS 变量\n  const variablesCSS = generateCSSVariables(config.variables)\n\n  // 2. 构建主题 CSS（模拟旧系统的合并行为）\n  let themeCSS = themeMap.default // 默认主题作为基础\n\n  // 3. 如果不是 default 主题，叠加主题特定样式\n  if (config.themeName !== `default`) {\n    const specificThemeCSS = themeMap[config.themeName as ThemeName]\n    if (specificThemeCSS) {\n      themeCSS = `${themeCSS}\\n\\n${specificThemeCSS}`\n    }\n  }\n\n  // 4. 给主题 CSS 添加作用域（只影响 #output 预览区域）\n  const scopedThemeCSS = wrapCSSWithScope(themeCSS, `#output`)\n\n  // 5. 生成标题样式 CSS（在主题 CSS 之后应用，确保覆盖主题默认样式）\n  const headingStylesCSS = generateHeadingStyles(config.variables)\n\n  // 6. 处理用户自定义 CSS（添加作用域）\n  const scopedCustomCSS = config.customCSS\n    ? wrapCSSWithScope(config.customCSS, `#output`)\n    : ``\n\n  // 7. 拼接完整 CSS（用户自定义 CSS 在最后，优先级最高）\n  let mergedCSS = [\n    variablesCSS, // CSS 变量（全局）\n    baseCSSContent, // 基础样式（全局）\n    scopedThemeCSS, // 主题样式（限制在 #output）\n    headingStylesCSS, // 标题样式\n    scopedCustomCSS, // 用户自定义 CSS（最后应用，可覆盖预设样式）\n  ].filter(Boolean).join(`\\n\\n`)\n\n  // 7. 使用 PostCSS 处理 CSS（简化 calc() 表达式等）\n  mergedCSS = await processCSS(mergedCSS)\n\n  // 8. 注入到页面\n  const injector = getThemeInjector()\n  injector.inject(mergedCSS)\n}\n"
  },
  {
    "path": "packages/core/src/theme/themeExporter.ts",
    "content": "/**\n * 主题导出工具\n * 导出合并后的主题CSS\n */\n\nimport type { CSSVariableConfig } from './cssVariables'\nimport { downloadFile } from '@md/shared/utils'\nimport { generateCSSVariables } from './cssVariables'\n\n/**\n * 导出合并后的主题CSS\n * @param customCSS - 用户自定义的CSS内容\n * @param baseThemeCSS - 基础主题CSS\n * @param config - 配置项\n * @param fileName - 导出文件名\n */\nexport function exportMergedTheme(\n  customCSS: string,\n  baseThemeCSS: string,\n  config: CSSVariableConfig,\n  fileName: string,\n) {\n  // 1. 生成 CSS 变量\n  const variablesCSS = generateCSSVariables(config)\n\n  // 2. 拼接完整 CSS\n  const mergedCSS = [\n    `/**`,\n    ` * MD 主题导出`,\n    ` * 导出时间: ${new Date().toLocaleString()}`,\n    ` * 说明: 该文件包含完整的主题样式，可直接使用`,\n    ` */`,\n    ``,\n    variablesCSS,\n    ``,\n    baseThemeCSS,\n    ``,\n    customCSS,\n  ].filter(Boolean).join(`\\n`)\n\n  downloadFile(mergedCSS, `${fileName}.css`, `text/css`)\n\n  return mergedCSS\n}\n"
  },
  {
    "path": "packages/core/src/theme/themeInjector.ts",
    "content": "/**\n * 主题样式注入器\n * 负责管理动态注入的 <style> 标签\n */\n\n/**\n * 主题样式注入器类\n */\nexport class ThemeInjector {\n  private styleElement: HTMLStyleElement | null = null\n  private readonly styleId = `md-theme`\n\n  /**\n   * 注入或更新主题样式\n   * @param cssContent - CSS 内容\n   */\n  inject(cssContent: string): void {\n    if (!this.styleElement) {\n      this.styleElement = document.createElement(`style`)\n      this.styleElement.id = this.styleId\n      document.head.appendChild(this.styleElement)\n    }\n    this.styleElement.textContent = cssContent\n  }\n\n  /**\n   * 移除主题样式\n   */\n  remove(): void {\n    if (this.styleElement) {\n      this.styleElement.remove()\n      this.styleElement = null\n    }\n  }\n\n  /**\n   * 检查是否已注入\n   */\n  isInjected(): boolean {\n    return this.styleElement !== null\n  }\n}\n\n// 单例模式\nlet injectorInstance: ThemeInjector | null = null\n\n/**\n * 获取主题注入器单例\n */\nexport function getThemeInjector(): ThemeInjector {\n  if (!injectorInstance) {\n    injectorInstance = new ThemeInjector()\n  }\n  return injectorInstance\n}\n"
  },
  {
    "path": "packages/core/src/utils/basicHelpers.ts",
    "content": "/**\n * 转义 HTML 特殊字符\n */\nexport function escapeHtml(text: string): string {\n  return text\n    .replace(/&/g, `&amp;`) // 转义 &\n    .replace(/</g, `&lt;`) // 转义 <\n    .replace(/>/g, `&gt;`) // 转义 >\n    .replace(/\"/g, `&quot;`) // 转义 \"\n    .replace(/'/g, `&#39;`) // 转义 '\n}\n\n/**\n * 首字母大写\n */\nexport function ucfirst(str: string) {\n  return str.slice(0, 1).toUpperCase() + str.slice(1).toLowerCase()\n}\n\nexport function simpleHash(str: string): string {\n  let hash = 0\n  for (let i = 0; i < str.length; i++) {\n    const char = str.charCodeAt(i)\n    hash = ((hash << 5) - hash) + char\n    hash = hash & hash\n  }\n  return Math.abs(hash).toString(36)\n}\n"
  },
  {
    "path": "packages/core/src/utils/index.ts",
    "content": "export * from './basicHelpers'\nexport * from './initializeMermaid'\nexport * from './languages'\nexport * from './markdownHelpers'\n"
  },
  {
    "path": "packages/core/src/utils/initializeMermaid.ts",
    "content": "export async function initializeMermaid() {\n  // 优先使用全局 CDN 的 mermaid\n  if (typeof window !== `undefined` && (window as any).mermaid) {\n    const mermaid = (window as any).mermaid\n    mermaid.initialize({ startOnLoad: false })\n  }\n  else {\n    // 回退到动态导入（开发环境）\n    const mermaid = await import(`mermaid`)\n    mermaid.default.initialize({ startOnLoad: false })\n  }\n}\n"
  },
  {
    "path": "packages/core/src/utils/languages.ts",
    "content": "import type { LanguageFn } from 'highlight.js'\nimport bash from 'highlight.js/lib/languages/bash'\nimport c from 'highlight.js/lib/languages/c'\nimport cpp from 'highlight.js/lib/languages/cpp'\nimport csharp from 'highlight.js/lib/languages/csharp'\nimport css from 'highlight.js/lib/languages/css'\nimport diff from 'highlight.js/lib/languages/diff'\nimport go from 'highlight.js/lib/languages/go'\nimport graphql from 'highlight.js/lib/languages/graphql'\nimport ini from 'highlight.js/lib/languages/ini'\nimport java from 'highlight.js/lib/languages/java'\nimport javascript from 'highlight.js/lib/languages/javascript'\nimport json from 'highlight.js/lib/languages/json'\nimport kotlin from 'highlight.js/lib/languages/kotlin'\nimport less from 'highlight.js/lib/languages/less'\nimport lua from 'highlight.js/lib/languages/lua'\nimport makefile from 'highlight.js/lib/languages/makefile'\nimport markdown from 'highlight.js/lib/languages/markdown'\nimport objectivec from 'highlight.js/lib/languages/objectivec'\nimport perl from 'highlight.js/lib/languages/perl'\nimport php from 'highlight.js/lib/languages/php'\nimport phpTemplate from 'highlight.js/lib/languages/php-template'\nimport plaintext from 'highlight.js/lib/languages/plaintext'\nimport python from 'highlight.js/lib/languages/python'\nimport pythonRepl from 'highlight.js/lib/languages/python-repl'\nimport r from 'highlight.js/lib/languages/r'\nimport ruby from 'highlight.js/lib/languages/ruby'\nimport rust from 'highlight.js/lib/languages/rust'\nimport scss from 'highlight.js/lib/languages/scss'\nimport shell from 'highlight.js/lib/languages/shell'\nimport sql from 'highlight.js/lib/languages/sql'\nimport swift from 'highlight.js/lib/languages/swift'\nimport typescript from 'highlight.js/lib/languages/typescript'\nimport vbnet from 'highlight.js/lib/languages/vbnet'\nimport wasm from 'highlight.js/lib/languages/wasm'\nimport xml from 'highlight.js/lib/languages/xml'\nimport yaml from 'highlight.js/lib/languages/yaml'\n\nexport const COMMON_LANGUAGES: Record<string, LanguageFn> = {\n  bash,\n  c,\n  cpp,\n  csharp,\n  css,\n  diff,\n  go,\n  graphql,\n  ini,\n  java,\n  javascript,\n  json,\n  kotlin,\n  less,\n  lua,\n  makefile,\n  markdown,\n  objectivec,\n  perl,\n  php,\n  'php-template': phpTemplate,\n  plaintext,\n  python,\n  'python-repl': pythonRepl,\n  r,\n  ruby,\n  rust,\n  scss,\n  shell,\n  sql,\n  swift,\n  typescript,\n  vbnet,\n  wasm,\n  xml,\n  yaml,\n}\n\n// highlight.js CDN 配置\nconst HLJS_VERSION = `11.11.1`\nconst HLJS_CDN_BASE = `https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/npm/highlightjs/${HLJS_VERSION}`\n\n// 缓存正在加载的语言\nconst loadingLanguages = new Map<string, Promise<void>>()\n\n/**\n * 生成语言包的 CDN URL\n */\nfunction grammarUrlFor(language: string): string {\n  return `${HLJS_CDN_BASE}/es/languages/${language}.min.js`\n}\n\n/**\n * 动态加载并注册语言\n * @param language 语言名称\n * @param hljs highlight.js 实例\n */\nexport async function loadAndRegisterLanguage(language: string, hljs: any): Promise<void> {\n  // 如果已经注册，直接返回\n  if (hljs.getLanguage(language)) {\n    return\n  }\n\n  // 如果正在加载，等待加载完成\n  if (loadingLanguages.has(language)) {\n    await loadingLanguages.get(language)\n    return\n  }\n\n  // 开始加载\n  const loadPromise = (async () => {\n    try {\n      const module = await import(/* @vite-ignore */ grammarUrlFor(language))\n      hljs.registerLanguage(language, module.default)\n    }\n    catch (error) {\n      console.warn(`Failed to load language: ${language}`, error)\n      throw error\n    }\n    finally {\n      loadingLanguages.delete(language)\n    }\n  })()\n\n  loadingLanguages.set(language, loadPromise)\n  await loadPromise\n}\n\n/**\n * 格式化高亮后的代码，处理空格和制表符\n */\nfunction formatHighlightedCode(html: string, preserveNewlines = false): string {\n  let formatted = html\n  // 将 span 之间的空格移到 span 内部\n  formatted = formatted.replace(/(<span[^>]*>[^<]*<\\/span>)(\\s+)(<span[^>]*>[^<]*<\\/span>)/g, (_: string, span1: string, spaces: string, span2: string) => span1 + span2.replace(/^(<span[^>]*>)/, `$1${spaces}`))\n  formatted = formatted.replace(/(\\s+)(<span[^>]*>)/g, (_: string, spaces: string, span: string) => span.replace(/^(<span[^>]*>)/, `$1${spaces}`))\n  // 替换制表符为4个空格\n  formatted = formatted.replace(/\\t/g, `    `)\n\n  if (preserveNewlines) {\n    // 替换换行符为 <br/>，并将空格转换为 &nbsp;\n    formatted = formatted.replace(/\\r\\n/g, `<br/>`).replace(/\\n/g, `<br/>`).replace(/(>[^<]+)|(^[^<]+)/g, (str: string) => str.replace(/\\s/g, `&nbsp;`))\n  }\n  else {\n    // 只将空格转换为 &nbsp;\n    formatted = formatted.replace(/(>[^<]+)|(^[^<]+)/g, (str: string) => str.replace(/\\s/g, `&nbsp;`))\n  }\n\n  return formatted\n}\n\n/**\n * 高亮代码并格式化（支持行号）\n * @param text 原始代码文本\n * @param language 语言名称\n * @param hljs highlight.js 实例\n * @param showLineNumber 是否显示行号\n * @returns 格式化后的 HTML\n */\nexport function highlightAndFormatCode(text: string, language: string, hljs: any, showLineNumber: boolean): string {\n  let highlighted = ``\n\n  if (showLineNumber) {\n    const rawLines = text.replace(/\\r\\n/g, `\\n`).split(`\\n`)\n\n    const highlightedLines = rawLines.map((lineRaw) => {\n      const lineHtml = hljs.highlight(lineRaw, { language }).value\n      const formatted = formatHighlightedCode(lineHtml, false)\n      return formatted === `` ? `&nbsp;` : formatted\n    })\n\n    const lineNumbersHtml = highlightedLines.map((_, idx) => `<section style=\"padding:0 10px 0 0;line-height:1.75\">${idx + 1}</section>`).join(``)\n    const codeInnerHtml = highlightedLines.join(`<br/>`)\n    const codeLinesHtml = `<div style=\"white-space:pre;min-width:max-content;line-height:1.75\">${codeInnerHtml}</div>`\n    const lineNumberColumnStyles = `text-align:right;padding:8px 0;border-right:1px solid rgba(0,0,0,0.04);user-select:none;background:var(--code-bg,transparent);`\n\n    highlighted = `\n      <section style=\"display:flex;align-items:flex-start;overflow-x:hidden;overflow-y:auto;width:100%;max-width:100%;padding:0;box-sizing:border-box\">\n        <section class=\"line-numbers\" style=\"${lineNumberColumnStyles}\">${lineNumbersHtml}</section>\n        <section class=\"code-scroll\" style=\"flex:1 1 auto;overflow-x:auto;overflow-y:visible;padding:8px;min-width:0;box-sizing:border-box\">${codeLinesHtml}</section>\n      </section>\n    `\n  }\n  else {\n    const rawHighlighted = hljs.highlight(text, { language }).value\n    highlighted = formatHighlightedCode(rawHighlighted, true)\n  }\n\n  return highlighted\n}\n\nexport function highlightCodeBlock(codeBlock: Element, language: string, hljs: any): void {\n  const rawCode = codeBlock.getAttribute(`data-raw-code`)\n  const showLineNumber = codeBlock.getAttribute(`data-show-line-number`) === `true`\n\n  if (!rawCode)\n    return\n\n  const text = rawCode.replace(/&quot;/g, `\"`)\n\n  const highlighted = highlightAndFormatCode(text, language, hljs, showLineNumber)\n\n  codeBlock.innerHTML = highlighted\n  codeBlock.removeAttribute(`data-language-pending`)\n  codeBlock.removeAttribute(`data-raw-code`)\n  codeBlock.removeAttribute(`data-show-line-number`)\n}\n\n/**\n * 高亮 DOM 中待处理的代码块\n * 查找带有 data-language-pending 属性的代码块，动态加载语言后重新高亮\n * @param hljs highlight.js 实例\n * @param container 容器元素（可选，默认为 document）\n */\nexport function highlightPendingBlocks(hljs: any, container: Document | Element = document): void {\n  const pendingBlocks = container.querySelectorAll(`code[data-language-pending]`)\n\n  pendingBlocks.forEach((codeBlock) => {\n    const language = codeBlock.getAttribute(`data-language-pending`)\n    if (!language)\n      return\n\n    if (hljs.getLanguage(language)) {\n      // 语言已加载，直接高亮\n      highlightCodeBlock(codeBlock, language, hljs)\n    }\n    else {\n      // 动态加载语言后重新高亮\n      loadAndRegisterLanguage(language, hljs).then(() => {\n        highlightCodeBlock(codeBlock, language, hljs)\n      }).catch(() => {\n        // 加载失败，移除标记\n        codeBlock.removeAttribute(`data-language-pending`)\n        codeBlock.removeAttribute(`data-raw-code`)\n        codeBlock.removeAttribute(`data-show-line-number`)\n      })\n    }\n  })\n}\n"
  },
  {
    "path": "packages/core/src/utils/markdownHelpers.ts",
    "content": "import type { RendererAPI } from '@md/shared/types'\nimport type { ReadTimeResults } from '@md/shared/utils/readingTime'\nimport DOMPurify from 'isomorphic-dompurify'\nimport { marked } from 'marked'\n\nconst INFOGRAPHIC_PLACEHOLDER_REGEX = /<!--infographic-start-->[\\s\\S]*?<!--infographic-end-->/g\nconst MERMAID_PLACEHOLDER_REGEX = /<!--mermaid-start-->[\\s\\S]*?<!--mermaid-end-->/g\nconst PROTECTED_SPAN_REGEX = /<span data-md-protected=\"(\\d+)\"><\\/span>/g\n\n/**\n * DOMPurify v3.1.7+ 会强制移除 foreignObject 内容\n * https://github.com/kkomelin/isomorphic-dompurify/pull/290\n * https://github.com/cure53/DOMPurify/issues/1152\n * 使用占位符方案：在 sanitize 前保护特定内容，sanitize 后还原\n * 注意：HTML 注释会被 DOMPurify 移除，所以使用 span 元素作为占位符\n */\nfunction sanitizeHtml(html: string): string {\n  const protectedContents: string[] = []\n\n  // 保护 infographic-diagram（使用注释标记定界，避免嵌套 div 问题）\n  html = html.replace(\n    INFOGRAPHIC_PLACEHOLDER_REGEX,\n    (match) => {\n      protectedContents.push(match)\n      return `<span data-md-protected=\"${protectedContents.length - 1}\"></span>`\n    },\n  )\n\n  // 保护 mermaid-diagram（使用注释标记定界，避免嵌套 div 问题）\n  html = html.replace(\n    MERMAID_PLACEHOLDER_REGEX,\n    (match) => {\n      protectedContents.push(match)\n      return `<span data-md-protected=\"${protectedContents.length - 1}\"></span>`\n    },\n  )\n\n  // XSS 处理\n  html = DOMPurify.sanitize(html, { ADD_TAGS: [`mp-common-profile`] })\n\n  // 还原被保护的内容\n  html = html.replace(\n    PROTECTED_SPAN_REGEX,\n    (_, i) => protectedContents[Number(i)],\n  )\n\n  return html\n}\n\n/**\n * 渲染 Markdown 内容\n * @param raw - 原始 markdown 字符串\n * @param renderer - 渲染器 API\n * @returns 渲染结果，包含 HTML 和阅读时间\n */\nexport function renderMarkdown(raw: string, renderer: RendererAPI) {\n  // 解析 front-matter 和正文\n  const { markdownContent, readingTime }\n    = renderer.parseFrontMatterAndContent(raw)\n\n  // marked -> html\n  let html = marked.parse(markdownContent) as string\n  html = sanitizeHtml(html)\n  return { html, readingTime }\n}\n\n/**\n * 后处理 HTML 内容\n * @param baseHtml - 基础 HTML 字符串\n * @param reading - 阅读时间结果\n * @param renderer - 渲染器 API\n * @returns 处理后的 HTML 字符串\n */\nexport function postProcessHtml(baseHtml: string, reading: ReadTimeResults, renderer: RendererAPI): string {\n  // 阅读时间及字数统计\n  let html = baseHtml\n  html = renderer.buildReadingTime(reading) + html\n  // 新主题系统：通过 CSS 去除第一行的 margin-top\n  // html = html.replace(/(style=\".*?)\"/, `$1;margin-top: 0\"`)\n  // 引用脚注\n  html += renderer.buildFootnotes()\n  // 附加的一些 style\n  html += renderer.buildAddition()\n  html += `\n    <style>\n      .hljs.code__pre > .mac-sign {\n        display: ${renderer.getOpts().isMacCodeBlock ? `flex` : `none`};\n      }\n    </style>\n  `\n  html += `\n    <style>\n      h2 strong {\n        color: inherit !important;\n      }\n    </style>\n  `\n  // 包裹 HTML\n  return renderer.createContainer(html)\n}\n\n/**\n * 修改 HTML 内容\n * @param content - 原始内容\n * @param renderer - 渲染器 API\n * @returns 修改后的 HTML 字符串\n */\nexport function modifyHtmlContent(content: string, renderer: RendererAPI): string {\n  const {\n    markdownContent,\n    readingTime: readingTimeResult,\n  } = renderer.parseFrontMatterAndContent(content)\n\n  let html = marked.parse(markdownContent) as string\n  html = sanitizeHtml(html)\n  return postProcessHtml(html, readingTimeResult, renderer)\n}\n"
  },
  {
    "path": "packages/core/tsconfig.json",
    "content": "{\n  \"extends\": \"@md/config/tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  },\n  \"include\": [\n    \"src/**/*\"\n  ],\n  \"exclude\": [\n    \"node_modules\",\n    \"dist\"\n  ]\n}\n"
  },
  {
    "path": "packages/example/.gitignore",
    "content": ".wrangler\nnode_modules\npnpm-lock.yaml"
  },
  {
    "path": "packages/example/README.md",
    "content": "# Example\n\n## worker.js\n\n公众号 openapi 接口代理服务示例，该项目将请求转发至微信公众号 api。\n\n开发调试：\n\n```bash\ncd packages/example\nnpx wrangler dev worker.js\n```\n\n或者安装依赖后启动\n\n```bash\ncd packages/example\npnpm i\npnpm dev\n```\n\n部署：\n\n请将其部署到 cloudflare workers。\n"
  },
  {
    "path": "packages/example/package.json",
    "content": "{\n  \"scripts\": {\n    \"dev\": \"wrangler dev\",\n    \"deploy\": \"wrangler deploy --minify\"\n  },\n  \"devDependencies\": {\n    \"wrangler\": \"^4.75.0\"\n  }\n}\n"
  },
  {
    "path": "packages/example/worker.js",
    "content": "export default {\n  /**\n   * @param {Request} request\n   * @param {Env} _env\n   * @param {ExecutionContext} _ctx\n   * @returns {Promise<Response>} promise\n   */\n  async fetch(request, _env, _ctx) {\n    const url = new URL(request.url)\n    const targetUrl = `https://api.weixin.qq.com`\n    const proxyRequest = new Request(targetUrl + url.pathname + url.search, {\n      method: request.method,\n      headers: request.headers,\n      body: request.body,\n    })\n    const response = await fetch(proxyRequest)\n    const proxyResponse = new Response(response.body, {\n      status: response.status,\n      statusText: response.statusText,\n      headers: response.headers,\n    })\n    setCorsHeaders(proxyResponse.headers)\n    return proxyResponse\n  },\n}\n// 设置 CORS 头部\nfunction setCorsHeaders(headers) {\n  headers.set(`Access-Control-Allow-Origin`, `*`)\n  headers.set(`Access-Control-Allow-Methods`, `GET, POST, PUT, DELETE`)\n  headers.set(`Access-Control-Allow-Headers`, `*`)\n}\n"
  },
  {
    "path": "packages/example/wrangler.toml",
    "content": "main = \"./worker.js\"\ncompatibility_date = \"2024-04-01\"\n"
  },
  {
    "path": "packages/md-cli/.gitignore",
    "content": "doocs-md-cli-*"
  },
  {
    "path": "packages/md-cli/.npmignore",
    "content": ".tgz\nhttpData/\n"
  },
  {
    "path": "packages/md-cli/.npmrc",
    "content": "registry=https://registry.npmmirror.com\n"
  },
  {
    "path": "packages/md-cli/README.md",
    "content": "# md-cli\n\nA powerful yet simple tool for rendering Markdown documents locally during development.\n\n## Installation\n\nTo get started with `md-cli`, you can install it either globally or locally, depending on your needs.\n\n### Install locally\n\nIf you only need it for a specific project, you can install it locally by running:\n\n```bash\nnpm install @doocs/md-cli\n```\n\n### Install globally\n\nFor global access across all your projects, install it globally with:\n\n```bash\nnpm install -g @doocs/md-cli\n```\n\n## Usage\n\nOnce installed, running `md-cli` is a breeze. Here’s how to get started:\n\n### Default setup\n\nTo launch `md-cli` with the default settings, simply run:\n\n```bash\nmd-cli\n```\n\n### Custom port\n\nIf you prefer to run `md-cli` on a different port, say `8899`, just specify it like this:\n\n```bash\nmd-cli port=8899\n```\n\n## Maintainers\n\n- [yanglbme](https://github.com/yanglbme) – Core maintainer.\n- [YangFong](https://github.com/yangfong) – Core maintainer.\n- [xw](https://github.com/wll8) – Contributor.\n- [thinkasany](https://www.npmjs.com/~thinkerwing) – Contributor.\n"
  },
  {
    "path": "packages/md-cli/index.js",
    "content": "#!/usr/bin/env node\n\nimport { readFileSync } from 'fs'\nimport getPort from 'get-port'\nimport {\n  colors,\n  parseArgv,\n} from './util.js'\nimport { createServer } from './server.js'\n\nconst packageJson = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'))\n\nconst arg = parseArgv()\n\nasync function startServer() {\n  try {\n    let { port = 8800 } = arg\n    port = Number(port)\n\n    port = await getPort({ port }).catch(_ => {\n      console.log(`端口 ${port} 被占用，正在寻找可用端口...`)\n      return getPort()\n    })\n\n    console.log(`doocs/md-cli v${packageJson.version}`)\n    console.log(`服务启动中...`)\n\n    const app = createServer(port)\n\n    app.listen(port, '127.0.0.1', () => {\n      console.log(`服务已启动:`)\n      console.log(`打开链接 ${colors.green(`http://127.0.0.1:${port}`)} 即刻使用吧~`)\n      console.log(``)\n\n      const { spaceId, clientSecret } = arg\n      if (spaceId && clientSecret) {\n        console.log(`${colors.green('✅ 云存储已配置，可通过自定义代码上传图片')}`)\n      }\n    })\n\n    process.once('SIGINT', () => {\n      console.log('\\n服务器已关闭')\n      process.exit(0)\n    })\n\n    process.once('SIGTERM', () => {\n      console.log('\\n服务器已关闭')\n      process.exit(0)\n    })\n\n  } catch (err) {\n    console.error('启动服务器失败:', err)\n    process.exit(1)\n  }\n}\n\nstartServer()\n"
  },
  {
    "path": "packages/md-cli/package.json",
    "content": "{\n  \"name\": \"@doocs/md-cli\",\n  \"version\": \"2.1.3\",\n  \"type\": \"module\",\n  \"description\": \"WeChat Markdown Editor | 一款高度简洁的微信 Markdown 编辑器：支持 Markdown 语法、自定义主题样式、内容管理、多图床、AI 助手等特性\",\n  \"main\": \"index.js\",\n  \"scripts\": {\n    \"test\": \"echo \\\"Error: no test specified\\\" && exit 1\",\n    \"dev\": \"nodemon index.js\"\n  },\n  \"bin\": {\n    \"md-cli\": \"index.js\"\n  },\n  \"files\": [\n    \"dist\",\n    \"public\",\n    \"index.js\",\n    \"server.js\",\n    \"util.js\"\n  ],\n  \"keywords\": [],\n  \"author\": \"yanglbme\",\n  \"license\": \"ISC\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/doocs/md\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/doocs/md/issues\"\n  },\n  \"dependencies\": {\n    \"express\": \"^5.2.1\",\n    \"form-data\": \"4.0.5\",\n    \"get-port\": \"7.1.0\",\n    \"http-proxy-middleware\": \"^3.0.5\",\n    \"multer\": \"^2.1.1\"\n  },\n  \"devDependencies\": {\n    \"nodemon\": \"^3.1.14\"\n  }\n}\n"
  },
  {
    "path": "packages/md-cli/public/upload/.gitkeep",
    "content": ""
  },
  {
    "path": "packages/md-cli/server.js",
    "content": "import express from 'express'\nimport multer from 'multer'\nimport path from 'node:path'\nimport fs from 'node:fs'\nimport { fileURLToPath } from 'node:url'\nimport { dirname } from 'node:path'\nimport { createProxyMiddleware } from 'http-proxy-middleware'\nimport {\n  dcloud,\n  parseArgv,\n  colors\n} from './util.js'\n\nconst __filename = fileURLToPath(import.meta.url)\nconst __dirname = dirname(__filename)\nconst arg = parseArgv()\n\n// unicloud 服务空间配置\nconst spaceInfo = {\n  spaceId: ``,\n  clientSecret: ``,\n  ...arg,\n}\n\n/**\n * 创建 Express 服务器\n * @param {number} port - 服务器端口\n */\nexport function createServer(port = 8800) {\n  const app = express()\n\n  // 确保上传目录存在\n  const uploadDir = path.join(__dirname, 'public/upload')\n  if (!fs.existsSync(uploadDir)) {\n    fs.mkdirSync(uploadDir, { recursive: true })\n  }\n\n  // 配置 multer 用于文件上传\n  const storage = multer.diskStorage({\n    destination: (req, file, cb) => {\n      cb(null, uploadDir)\n    },\n    filename: (req, file, cb) => {\n      cb(null, file.originalname)\n    }\n  })\n\n  const upload = multer({ storage })\n\n  // 中间件\n  app.use(express.json())\n  app.use(express.urlencoded({ extended: true }))\n\n  app.use('/public', express.static(path.join(__dirname, 'public')))\n\n  // 文件上传 API\n  app.post('/upload', upload.single('file'), async (req, res) => {\n    try {\n      if (!req.file) {\n        return res.status(400).json({ error: 'No file uploaded' })\n      }\n\n      const file = req.file\n      let url = `http://127.0.0.1:${port}/public/upload/${file.filename}`\n\n      try {\n        if (spaceInfo.spaceId && spaceInfo.clientSecret) {\n          url = await dcloud(spaceInfo)({\n            name: file.originalname,\n            file: fs.createReadStream(file.path)\n          })\n\n          // 上传成功后删除本地临时文件\n          fs.unlinkSync(file.path)\n          console.log('文件已上传到云端:', url)\n        } else {\n          console.log(`${colors.yellow('未配置云存储，降级到本地存储')}`)\n        }\n      } catch (err) {\n        // 云上传失败，降级到本地存储\n        console.log('云存储上传失败，降级到本地存储:', err.message)\n      }\n\n      res.json({ url })\n    } catch (error) {\n      console.error('Upload error:', error)\n      res.status(500).json({ error: error.message })\n    }\n  })\n\n  console.log('代理到: https://md.doocs.org/')\n  app.use(createProxyMiddleware({\n    target: 'https://md.doocs.org/',\n    changeOrigin: true,\n    on: {\n      error: (err, req, res) => {\n        console.error(`代理错误 ${req.path}:`, err)\n        res.status(502).send(`代理服务暂不可用，请检查网络连接 ${err.message}`)\n      },\n    },\n  }))\n\n  return app\n}\n"
  },
  {
    "path": "packages/md-cli/util.js",
    "content": "import FormData from 'form-data'\nimport process from 'node:process'\nimport util from 'node:util'\nimport crypto from 'node:crypto'\n\n/**\n * 自定义控制台颜色\n * https://stackoverflow.com/questions/9781218/how-to-change-node-jss-console-font-color\n * nodejs 内置颜色: https://nodejs.org/api/util.html#util_foreground_colors\n */\nfunction colors() {\n  function colorize(color, text) {\n    const codes = util.inspect.colors[color]\n    return `\\x1B[${codes[0]}m${text}\\x1B[${codes[1]}m`\n  }\n\n  const returnValue = {}\n  Object.keys(util.inspect.colors).forEach((color) => {\n    returnValue[color] = text => colorize(color, text)\n  })\n\n  const colorTable = new Proxy(returnValue, {\n    get(obj, prop) {\n      // 在没有对应的具名颜色函数时, 返回空函数作为兼容处理\n      const res = obj[prop] ? obj[prop] : arg => arg\n      return res\n    },\n  })\n\n  // 取消下行注释, 查看所有的颜色和名字:\n  // Object.keys(returnValue).forEach((color) => console.log(returnValue[color](color)))\n  return colorTable\n}\n\n/**\n * 解析命令行参数\n * @param {*} arr\n * @returns {Record<string, string | boolean>}\n */\nfunction parseArgv(arr) {\n  return (arr || process.argv.slice(2)).reduce((acc, arg) => {\n    let [k, ...v] = arg.split(`=`)\n    v = v.join(`=`) // 把带有 = 的值合并为字符串\n    acc[k] = v === `` // 没有值时, 则表示为 true\n      ? true\n      : (\n        /^(true|false)$/.test(v) // 转换指明的 true/false\n          ? v === `true`\n          : (\n            /[\\d|.]+/.test(v)\n              ? (Number.isNaN(Number(v)) ? v : Number(v)) // 如果转换为数字失败, 则使用原始字符\n              : v\n          )\n      )\n    return acc\n  }, {})\n}\n\nfunction dcloud(spaceInfo) {\n  if (Boolean(spaceInfo.spaceId && spaceInfo.clientSecret) === false) {\n    throw new Error(`请填写 spaceInfo`)\n  }\n\n  function sign(data, secret) {\n    const hmac = crypto.createHmac(`md5`, secret)\n    // 排序 obj 再转换为 key=val&key=val 的格式\n    const str = Object.keys(data).sort().reduce((acc, cur) => `${acc}&${cur}=${data[cur]}`, ``).slice(1)\n    hmac.update(str)\n    return hmac.digest(`hex`)\n  }\n\n  async function anonymousAuthorize() {\n    const data = {\n      method: `serverless.auth.user.anonymousAuthorize`,\n      params: `{}`,\n      spaceId: spaceInfo.spaceId,\n      timestamp: Date.now(),\n    }\n    return await fetch(`https://api.bspapp.com/client`, {\n      headers: {\n        'x-serverless-sign': sign(data, spaceInfo.clientSecret),\n      },\n      body: `{\"method\":\"serverless.auth.user.anonymousAuthorize\",\"params\":\"{}\",\"spaceId\":\"${spaceInfo.spaceId}\",\"timestamp\":${data.timestamp}}`,\n      method: `POST`,\n    }).then(res => res.json())\n  }\n\n  async function report({ id, token }) {\n    const reportReq = {\n      method: `serverless.file.resource.report`,\n      params: `{\"id\":\"${id}\"}`,\n      spaceId: spaceInfo.spaceId,\n      timestamp: Date.now(),\n      token,\n    }\n    return await fetch(`https://api.bspapp.com/client`, {\n      headers: {\n        'x-basement-token': reportReq.token,\n        'x-serverless-sign': sign(reportReq, spaceInfo.clientSecret),\n      },\n      body: JSON.stringify(reportReq),\n      method: `POST`,\n    }).then(res => res.json())\n  }\n\n  async function generateProximalSign({ name, token }) {\n    const data = {\n      method: `serverless.file.resource.generateProximalSign`,\n      params: `{\"env\":\"public\",\"filename\":\"${name}\"}`,\n      spaceId: spaceInfo.spaceId,\n      timestamp: Date.now(),\n      token,\n    }\n    const res = await fetch(`https://api.bspapp.com/client`, {\n      headers: {\n        'x-basement-token': data.token,\n        'x-serverless-sign': sign(data, spaceInfo.clientSecret),\n      },\n      body: JSON.stringify(data),\n      method: `POST`,\n    }).then(res => res.json())\n    return res\n  }\n\n  async function upload({ data, file }) {\n    const formdata = new FormData()\n    Object.entries({\n      'Cache-Control': `max-age=2592000`,\n      'Content-Disposition': `attachment`,\n      'OSSAccessKeyId': data.accessKeyId,\n      'Signature': data.signature,\n      'host': data.host,\n      'id': data.id,\n      'key': data.ossPath,\n      'policy': data.policy,\n      'success_action_status': 200,\n      file,\n    }).forEach(([key, val]) => formdata.append(key, val))\n\n    return await fetch(`https://${data.host}`, {\n      headers: {\n        'X-OSS-server-side-encrpytion': `AES256`,\n      },\n      body: formdata,\n      method: `POST`,\n    })\n  }\n\n  async function uploadFile({ name = `unnamed.file`, file }) {\n    const token = (await anonymousAuthorize()).data.accessToken\n    const res = await generateProximalSign({ name, token })\n    await upload({ data: res.data, file })\n    await report({ id: res.data.id, token })\n    const fileUrl = `https://${res.data.cdnDomain}/${res.data.ossPath}`\n    return fileUrl\n  }\n\n  return uploadFile\n}\n\nconst colorsInstance = colors()\n\nexport {\n  parseArgv,\n  dcloud,\n  colorsInstance as colors,\n}\n"
  },
  {
    "path": "packages/shared/README.md",
    "content": "# @md/shared\n\n共享的配置、常量、类型和工具函数。\n"
  },
  {
    "path": "packages/shared/package.json",
    "content": "{\n  \"name\": \"@md/shared\",\n  \"type\": \"module\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"exports\": {\n    \".\": \"./src/index.ts\",\n    \"./configs\": \"./src/configs/index.ts\",\n    \"./configs/*\": \"./src/configs/*.ts\",\n    \"./constants\": \"./src/constants/index.ts\",\n    \"./constants/*\": \"./src/constants/*.ts\",\n    \"./types\": \"./src/types/index.ts\",\n    \"./types/*\": \"./src/types/*.ts\",\n    \"./utils\": \"./src/utils/index.ts\",\n    \"./utils/*\": \"./src/utils/*.ts\",\n    \"./editor\": \"./src/editor/index.ts\",\n    \"./editor/*\": \"./src/editor/*.ts\"\n  },\n  \"dependencies\": {\n    \"@codemirror/autocomplete\": \"^6.20.1\",\n    \"@codemirror/commands\": \"^6.10.3\",\n    \"@codemirror/lang-css\": \"^6.3.1\",\n    \"@codemirror/lang-javascript\": \"^6.2.5\",\n    \"@codemirror/lang-markdown\": \"^6.5.0\",\n    \"@codemirror/language\": \"^6.12.2\",\n    \"@codemirror/language-data\": \"^6.5.2\",\n    \"@codemirror/lint\": \"^6.9.5\",\n    \"@codemirror/search\": \"^6.6.0\",\n    \"@fsegurai/codemirror-theme-vscode-dark\": \"^6.2.4\",\n    \"@fsegurai/codemirror-theme-vscode-light\": \"^6.2.4\",\n    \"@replit/codemirror-indentation-markers\": \"^6.5.3\",\n    \"axios\": \"^1.13.6\",\n    \"es-toolkit\": \"^1.45.1\",\n    \"marked\": \"^17.0.4\"\n  },\n  \"devDependencies\": {\n    \"@md/config\": \"workspace:*\"\n  }\n}\n"
  },
  {
    "path": "packages/shared/src/assets/default-custom-theme.txt",
    "content": "/**\n * 按 Alt/Option + Shift + F 可格式化\n * 如需使用主题色，请使用 var(--md-primary-color) 代替颜色值\n * 如：color: var(--md-primary-color);\n * 新主题系统已全面支持 CSS 写法，你可以 F12 打开控制台查看具体的类名及标签\n *\n * 召集令：如果你有好看的主题样式，欢迎分享，让更多人能够使用到你的主题。\n * 提交区：https://github.com/doocs/md/discussions/426\n */\n\n/* 全局变量定义 */\n:root {\n}\n\n/* 根容器 */\n.md-container {\n}\n\n/* 标题样式 */\nh1 {\n}\n\nh2 {\n}\n\nh3 {\n}\n\nh4 {\n}\n\nh5 {\n}\n\nh6 {\n}\n\n/* 段落和文本 */\np {\n}\n\nstrong {\n}\n\nem {\n}\n\n/* 引用块 */\nblockquote {\n}\n\nblockquote > p {\n}\n\n/* 代码 */\n/* 行内代码 */\ncode {\n}\n\n/* 代码块容器 */\npre.code__pre,\n.hljs.code__pre {\n}\n\n/* 代码块内的 code */\npre.code__pre > code,\n.hljs.code__pre > code {\n}\n\n/* 列表 */\nol {\n}\n\nul {\n}\n\nli {\n}\n\n/* 表格 */\ntable {\n}\n\nthead {\n}\n\nth {\n}\n\ntd {\n}\n\n/* 其他元素 */\nimg {\n}\n\nhr {\n}\n\nfigure {\n}\n\nfigcaption {\n}\n\n/* KaTeX 公式 */\n.katex-inline {\n}\n\n.katex-block {\n}\n\n/* Markup 标记 */\n/* 高亮 ==文本== */\n.markup-highlight {\n}\n\n/* 下划线 ++文本++ */\n.markup-underline {\n}\n\n/* 波浪线 ~文本~ */\n.markup-wavyline {\n}\n\n/* GFM Alert 警告块 */\n/* Alert 标题 */\n.alert-title-note {\n}\n\n.alert-title-tip {\n}\n\n.alert-title-info {\n}\n\n.alert-title-important {\n}\n\n.alert-title-warning {\n}\n\n.alert-title-caution {\n}\n\n.alert-title-abstract {\n}\n\n.alert-title-summary {\n}\n\n.alert-title-tldr {\n}\n\n.alert-title-todo {\n}\n\n.alert-title-success {\n}\n\n.alert-title-done {\n}\n\n.alert-title-question {\n}\n\n.alert-title-help {\n}\n\n.alert-title-faq {\n}\n\n.alert-title-failure {\n}\n\n.alert-title-fail {\n}\n\n.alert-title-missing {\n}\n\n.alert-title-danger {\n}\n\n.alert-title-error {\n}\n\n.alert-title-bug {\n}\n\n.alert-title-example {\n}\n\n.alert-title-quote {\n}\n\n.alert-title-cite {\n}\n\n/* Alert SVG 图标 */\n.alert-icon-note {\n}\n\n.alert-icon-tip {\n}\n\n.alert-icon-info {\n}\n\n.alert-icon-important {\n}\n\n.alert-icon-warning {\n}\n\n.alert-icon-caution {\n}\n\n/* Obsidian-style Callout SVG 图标 */\n.alert-icon-abstract {\n}\n\n.alert-icon-summary {\n}\n\n.alert-icon-tldr {\n}\n\n.alert-icon-todo {\n}\n\n.alert-icon-success {\n}\n\n.alert-icon-done {\n}\n\n.alert-icon-question {\n}\n\n.alert-icon-help {\n}\n\n.alert-icon-faq {\n}\n\n.alert-icon-failure {\n}\n\n.alert-icon-fail {\n}\n\n.alert-icon-missing {\n}\n\n.alert-icon-danger {\n}\n\n.alert-icon-error {\n}\n\n.alert-icon-bug {\n}\n\n.alert-icon-example {\n}\n\n.alert-icon-quote {\n}\n\n.alert-icon-cite {\n}\n"
  },
  {
    "path": "packages/shared/src/assets/index.ts",
    "content": "/**\n * 共享资源导出\n */\n\n// 默认自定义主题模板\nexport { default as DEFAULT_CUSTOM_THEME } from './default-custom-theme.txt?raw'\n"
  },
  {
    "path": "packages/shared/src/configs/ai-service-options.ts",
    "content": "import type { ImageServiceOption, ServiceOption } from '../types'\nimport { DEFAULT_SERVICE_ENDPOINT } from '../constants'\n\nexport const serviceOptions: ServiceOption[] = [\n  {\n    value: `default`,\n    label: `内置服务`,\n    endpoint: DEFAULT_SERVICE_ENDPOINT,\n    models: [\n      `Qwen/Qwen2.5-7B-Instruct`,\n      `Qwen/Qwen2.5-Coder-7B-Instruct`,\n      `Qwen/Qwen2-7B-Instruct`,\n      `deepseek-ai/DeepSeek-R1-Distill-Qwen-7B`,\n      `THUDM/GLM-Z1-9B-0414`,\n      `THUDM/GLM-4-9B-0414`,\n      `internlm/internlm2_5-7b-chat`,\n      `qwen/qwen3-30b-a3b:free`,\n      `qwen/qwen3-8b:free`,\n      `qwen/qwen3-14b:free`,\n      `qwen/qwen3-32b:free`,\n      `qwen/qwen3-235b-a22b:free`,\n      `tngtech/deepseek-r1t-chimera:free`,\n      `thudm/glm-z1-9b:free`,\n      `thudm/glm-z1-32b:free`,\n      `thudm/glm-4-9b:free`,\n      `thudm/glm-4-32b:free`,\n      `microsoft/mai-ds-r1:free`,\n      `arliai/qwq-32b-arliai-rpr-v1:free`,\n      `nvidia/llama-3.3-nemotron-super-49b-v1:free`,\n      `nvidia/llama-3.1-nemotron-ultra-253b-v1:free`,\n      `meta-llama/llama-4-maverick:free`,\n      `meta-llama/llama-4-scout:free`,\n      `deepseek/deepseek-v3-base:free`,\n      `qwen/qwen2.5-vl-3b-instruct:free`,\n      `qwen/qwen2.5-vl-32b-instruct:free`,\n    ],\n  },\n  {\n    value: `deepseek`,\n    label: `DeepSeek`,\n    endpoint: `https://api.deepseek.com/v1`,\n    models: [`deepseek-chat`, `deepseek-reasoner`],\n  },\n  {\n    value: `openai`,\n    label: `OpenAI`,\n    endpoint: `https://api.openai.com/v1`,\n    models: [`gpt-4.1`, `gpt-4.1-mini`, `gpt-4.1-nano`, `gpt-4-turbo`, `gpt-4o`, `gpt-3.5-turbo`],\n  },\n  {\n    value: `qwen`,\n    label: `通义千问`,\n    endpoint: `https://dashscope.aliyuncs.com/compatible-mode/v1`,\n    models: [\n      `qwen-vl-max-2025-04-02`,\n      `deepseek-v3`,\n      `deepseek-r1-distill-llama-70b`,\n      `deepseek-r1-distill-qwen-32b`,\n      `deepseek-r1-distill-qwen-14b`,\n      `deepseek-r1-distill-llama-8b`,\n      `deepseek-r1-distill-qwen-1.5b`,\n      `deepseek-r1-distill-qwen-7b`,\n      `deepseek-r1`,\n      `qwen1.5-7b-chat`,\n      `qwen-coder-plus-1106`,\n      `qwen-coder-plus`,\n      `qwen-coder-plus-latest`,\n      `qwen2.5-coder-3b-instruct`,\n      `qwen2.5-coder-0.5b-instruct`,\n      `qwen2.5-coder-14b-instruct`,\n      `qwen2.5-coder-32b-instruct`,\n      `qwen-coder-turbo-0919`,\n      `qwen2.5-0.5b-instruct`,\n      `qwen2.5-1.5b-instruct`,\n      `qwen2.5-3b-instruct`,\n      `qwen2.5-7b-instruct`,\n      `qwen2.5-14b-instruct`,\n      `qwen2.5-32b-instruct`,\n      `qwen2.5-72b-instruct`,\n      `qwen2.5-coder-7b-instruct`,\n      `qwen2.5-math-1.5b-instruct`,\n      `qwen2.5-math-7b-instruct`,\n      `qwen2.5-math-72b-instruct`,\n      `qwen-turbo-0919`,\n      `qwen-turbo-latest`,\n      `qwen-plus-0919`,\n      `qwen-plus-latest`,\n      `qwen-max-0919`,\n      `qwen-max-latest`,\n      `qwen-coder-turbo`,\n      `qwen-coder-turbo-latest`,\n      `qwen-math-turbo-0919`,\n      `qwen-math-turbo`,\n      `qwen-math-turbo-latest`,\n      `qwen-math-plus-0919`,\n      `qwen-math-plus`,\n      `qwen-math-plus-latest`,\n      `qwen2-57b-a14b-instruct`,\n      `qwen2-72b-instruct`,\n      `qwen2-7b-instruct`,\n      `qwen2-0.5b-instruct`,\n      `qwen2-1.5b-instruct`,\n      `qwen-long`,\n      `qwen-vl-max`,\n      `qwen-vl-plus`,\n      `qwen-max-0428`,\n      `qwen1.5-110b-chat`,\n      `qwen-72b-chat`,\n      `codeqwen1.5-7b-chat`,\n      `qwen1.5-0.5b-chat`,\n      `qwen-1.8b-chat`,\n      `qwen-1.8b-longcontext-chat`,\n      `qwen-7b-chat`,\n      `qwen-14b-chat`,\n      `qwen1.5-14b-chat`,\n      `qwen1.5-1.8b-chat`,\n      `qwen1.5-32b-chat`,\n      `qwen1.5-72b-chat`,\n      `qwen-max-1201`,\n      `qwen-max-longcontext`,\n      `qwen-max-0403`,\n      `qwen-max-0107`,\n      `qwen-turbo`,\n      `qwen-max`,\n      `qwen-plus`,\n    ],\n  },\n  {\n    value: `hunyuan`,\n    label: `腾讯混元`,\n    endpoint: `https://api.hunyuan.cloud.tencent.com/v1`,\n    models: [\n      `hunyuan-pro`,\n      `hunyuan-vision`,\n      `hunyuan-lite`,\n      `hunyuan-standard`,\n      `hunyuan-standard-32K`,\n      `hunyuan-standard-256k`,\n      `hunyuan-code`,\n      `hunyuan-role`,\n      `hunyuan-functioncall`,\n      `hunyuan-turbo-vision`,\n      `hunyuan-turbo`,\n    ],\n  },\n  {\n    value: `doubao`,\n    label: `火山方舟`,\n    endpoint: `https://ark.cn-beijing.volces.com/api/v3`,\n    models: [\n      `doubao-1-5-thinking-pro-250415`,\n      `doubao-1-5-thinking-pro-m-250415`,\n      `deepseek-r1-250120`,\n      `deepseek-r1-distill-qwen-32b-250120`,\n      `deepseek-r1-distill-qwen-7b-250120`,\n      `deepseek-v3-250324`,\n      `deepseek-v3-241226`,\n      `doubao-1-5-vision-pro-250328`,\n      `doubao-1-5-vision-lite-250315`,\n      `doubao-1-5-vision-pro-32k-250115`,\n      `doubao-1-5-ui-tars-250328`,\n      `doubao-vision-pro-32k-241028`,\n      `doubao-vision-lite-32k-241015`,\n      `doubao-1-5-pro-32k-250115`,\n      `doubao-1-5-pro-256k-250115`,\n      `doubao-1-5-lite-32k-250115`,\n      `doubao-pro-4k-240515`,\n      `doubao-pro-32k-241215`,\n      `doubao-pro-32k-240828`,\n      `doubao-pro-32k-240615`,\n      `doubao-pro-256k-241115`,\n      `doubao-lite-4k-character-240828`,\n      `doubao-lite-32k-240828`,\n      `doubao-lite-32k-character-241015`,\n      `doubao-lite-128k-240828`,\n      `moonshot-v1-8k`,\n      `moonshot-v1-32k`,\n      `moonshot-v1-128k`,\n    ],\n  },\n  {\n    value: `siliconflow`,\n    label: `硅基流动`,\n    endpoint: `https://api.siliconflow.cn/v1`,\n    models: [\n      `Qwen/Qwen3-235B-A22B`,\n      `Qwen/Qwen3-30B-A3B`,\n      `Qwen/Qwen3-32B`,\n      `Qwen/Qwen3-14B`,\n      `Qwen/Qwen3-8B`,\n      `THUDM/GLM-Z1-32B-0414`,\n      `THUDM/GLM-4-32B-0414`,\n      `THUDM/GLM-Z1-Rumination-32B-0414`,\n      `THUDM/GLM-4-9B-0414`,\n      `Qwen/QwQ-32B`,\n      `Pro/deepseek-ai/DeepSeek-R1`,\n      `Pro/deepseek-ai/DeepSeek-V3`,\n      `deepseek-ai/DeepSeek-R1`,\n      `deepseek-ai/DeepSeek-V3`,\n      `deepseek-ai/DeepSeek-R1-Distill-Qwen-32B`,\n      `deepseek-ai/DeepSeek-R1-Distill-Qwen-14B`,\n      `deepseek-ai/DeepSeek-R1-Distill-Qwen-7B`,\n      `Pro/deepseek-ai/DeepSeek-R1-Distill-Qwen-7B`,\n      `Pro/deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B`,\n      `deepseek-ai/DeepSeek-V2.5`,\n      `Qwen/Qwen2.5-72B-Instruct-128K`,\n      `Qwen/Qwen2.5-72B-Instruct`,\n      `Qwen/Qwen2.5-32B-Instruct`,\n      `Qwen/Qwen2.5-14B-Instruct`,\n      `Qwen/Qwen2.5-7B-Instruct`,\n      `Qwen/Qwen2.5-Coder-32B-Instruct`,\n      `Qwen/Qwen2.5-Coder-7B-Instruct`,\n      `Qwen/Qwen2-7B-Instruct`,\n      `Qwen/QwQ-32B-Preview`,\n      `TeleAI/TeleChat2`,\n      `THUDM/glm-4-9b-chat`,\n      `Vendor-A/Qwen/Qwen2.5-72B-Instruct`,\n      `internlm/internlm2_5-7b-chat`,\n      `internlm/internlm2_5-20b-chat`,\n      `Pro/Qwen/Qwen2.5-7B-Instruct`,\n      `Pro/Qwen/Qwen2-7B-Instruct`,\n      `Pro/Qwen/Qwen2-1.5B-Instruct`,\n      `Pro/THUDM/chatglm3-6b`,\n      `Pro/THUDM/glm-4-9b-chat`,\n    ],\n  },\n  {\n    value: `302ai`,\n    label: `302.AI`,\n    endpoint: ` https://api.302.ai/v1`,\n    models: [`chatgpt-4o-latest`, `gpt-4o`, `gpt-4o-mini`, `gpt-4-turbo`, `o1-preview`, `o1-mini`, `claude-3-5-sonnet-latest`, `claude-3-5-sonnet-20241022`, `claude-3-5-haiku-20241022`, `grok-beta`],\n  },\n  {\n    value: `bigmodel`,\n    label: `智谱 AI`,\n    endpoint: `https://open.bigmodel.cn/api/paas/v4/`,\n    models: [\n      `glm-4.7`,\n      `glm-4.7-flashx`,\n      `glm-4.6`,\n      `glm-4.5-air`,\n      `glm-4.5-airx`,\n      `glm-4-long`,\n      `glm-4.7-flash`,\n      `glm-4-flash-250414`,\n      `glm-4-flashx-250414`,\n    ],\n  },\n  {\n    value: `baichuan`,\n    label: `百川智能`,\n    endpoint: `https://api.baichuan-ai.com/v1`,\n    models: [\n      `Baichuan4`,\n      `Baichuan3-Turbo`,\n      `Baichuan3-Turbo-128k`,\n      `Baichuan2-Turbo`,\n    ],\n  },\n\n  {\n    value: `lingyiwanwu`,\n    label: `零一万物`,\n    endpoint: `https://api.lingyiwanwu.com/v1`,\n    models: [\n      `yi-lightning`,\n    ],\n  },\n\n  {\n    value: `moonshot`,\n    label: `月之暗面`,\n    endpoint: `https://api.moonshot.cn/v1`,\n    models: [\n      `moonshot-v1-8k`,\n      `moonshot-v1-32k`,\n      `moonshot-v1-128k`,\n    ],\n  },\n  {\n    value: `ernie`,\n    label: `百度千帆`,\n    endpoint: `https://qianfan.baidubce.com/v2`,\n    models: [\n      `ernie-4.5-turbo-128k`,\n      `ernie-4.5-turbo-32k`,\n      `ernie-4.5-8k-preview`,\n      `ernie-4.0-8k`,\n      `ernie-4.0-8k-latest`,\n      `ernie-4.0-8k-preview`,\n      `ernie-4.0-turbo-128k`,\n      `ernie-4.0-turbo-8k`,\n      `ernie-4.0-turbo-8k-latest`,\n      `ernie-4.0-turbo-8k-preview`,\n      `ernie-3.5-128k`,\n      `ernie-3.5-8k`,\n      `ernie-3.5-8k-preview`,\n      `ernie-speed-128k`,\n      `ernie-speed-8k`,\n      `ernie-speed-pro-128k`,\n      `ernie-lite-8k`,\n      `ernie-lite-pro-128k`,\n      `ernie-tiny-8k`,\n      `ernie-novel-8k`,\n    ],\n  },\n  {\n    value: `custom`,\n    label: `自定义兼容 OpenAI API 的服务`,\n    endpoint: ``,\n    models: [],\n  },\n]\n\nexport const DEFAULT_SERVICE_MODEL = serviceOptions[0].models[0]\n\n// 图片模型\nexport const imageServiceOptions: ImageServiceOption[] = [\n  {\n    value: `default`,\n    label: `内置服务`,\n    endpoint: DEFAULT_SERVICE_ENDPOINT,\n    models: [\n      `Kwai-Kolors/Kolors`,\n    ],\n  },\n  {\n    value: `openai`,\n    label: `OpenAI`,\n    endpoint: `https://api.openai.com/v1`,\n    models: [`gpt-image-1`, `dall-e-3`],\n  },\n  {\n    value: `siliconflow`,\n    label: `硅基流动`,\n    endpoint: `https://api.siliconflow.cn/v1`,\n    models: [\n      `Kwai-Kolors/Kolors`,\n      `Qwen/Qwen-Image`,\n    ],\n  },\n  {\n    value: `302ai`,\n    label: `302.AI`,\n    endpoint: `https://api.302.ai/302`,\n    models: [\n      `302ai-flux-1-srpo`,\n      `bagel-image`,\n      `baidu-irag-01`,\n      `dall-e-3`,\n      `dall-e-2`,\n      `doubao-seedream-3-0-t2i-250415`,\n      `doubao-v2`,\n      `doubao-v2-l`,\n      `doubao-v2.1`,\n      `doubao-v3`,\n      `doubao-seedream-4-0-250828`,\n      `flux-kontext-max`,\n      `flux-kontext-pro`,\n      `google-v3`,\n      `google-v3-fast`,\n      `google-v4-preview`,\n      `gemini-2.5-flash-image`,\n      `302-gemini-2.5-flash-image`,\n      `gpt-image-1`,\n      `hidream-i1-full`,\n      `hidream-i1-dev`,\n      `hidream-i1-fast`,\n      `higgsfield`,\n      `ideogram-v1`,\n      `ideogram-v1-turbo`,\n      `ideogram-v2`,\n      `ideogram-v2-turbo`,\n      `ideogram-v2a`,\n      `ideogram-v2a-turbo`,\n      `ideogram-v3-turbo`,\n      `ideogram-v3-quality`,\n      `ideogram-v3`,\n      `kling-v1`,\n      `kling-v1-5`,\n      `kling-v2`,\n      `luma`,\n      `luma-flash`,\n      `midjourney-v6`,\n      `midjourney-v6-1`,\n      `midjourney-v7`,\n      `nijijourney-v6`,\n      `minimaxi-image-01`,\n      `qwen`,\n      `qwen-lora`,\n      `official-qwen-image`,\n      `official-qwen-image-plus`,\n      `wan2.2-t2i-flash`,\n      `wan2.2-t2i-plus`,\n      `wanx2.1-t2i-turbo`,\n      `wanx2.1-t2i-plus`,\n      `wanx2.0-t2i-turbo`,\n      `wan2.5-t2i-preview`,\n      `recraft-v3`,\n      `recraft-20b`,\n      `stable-v1`,\n      `stable-sd2`,\n      `stable-sd3-ultra`,\n      `stable-sd3`,\n      `stable-sd3-medium`,\n      `stable-sd3-large`,\n      `stable-sd3-turbo`,\n      `cogview-4-250304`,\n      `cogview-4`,\n    ],\n  },\n  {\n    value: `custom`,\n    label: `自定义兼容 OpenAI API 的服务`,\n    endpoint: ``,\n    models: [],\n  },\n]\n\nexport const DEFAULT_IMAGE_MODEL = imageServiceOptions[0].models[0]\n"
  },
  {
    "path": "packages/shared/src/configs/api.ts",
    "content": "export const githubConfig = {\n  username: `bucketio`,\n  repoList: Array.from({ length: 20 }, (_, i) => `img${i}`),\n  branch: `main`,\n  accessTokenList: [\n    `ghp_sqQg5y7XC7Fy8XdoocsmdVEYRiRiTZPvbwzTL4MRjQc`,\n    `ghp_jB5JXzBjpGbgzdoocsmdogWfSHhfCKGVstozw1cAsPv`,\n    `ghp_zvy8wkHo259g7doocsmdJnUKOQd1WO1SPzZ9G0O9cJD`,\n    `ghp_DnCJc2Ms0RVZ1doocsmdiWOAN78FurfSeD1Pv2Y28pO`,\n    `ghp_EsMYDv9WVjXWP5doocsmd1nnDml2DEP95rOiz44bSo0`,\n    `ghp_L4isHf01nllOOdoocsmdHBGoDG6jscCA09WV44QDvlg`,\n    `ghp_qWciwYXHPakAUGdoocsmdBOBZdRcV08JThKey3mBZNJ`,\n    `ghp_rxkvIO08wVL2DMdoocsmd2jDEhcatp2rfVyhd3A7RiS`,\n    `ghp_1RvkWKboSxr0yVdoocsmd7OtBCpecYwoV6deh3utifJ`,\n    `ghp_cduanDnAug60ngdoocsmdF1uDstXUi6S9RMhY1qdada`,\n    `ghp_q6mxuJIkqAcsCXdoocsmdkkjWvzGlMVRuy5zI0IWNDx`,\n    `ghp_Pv4npPeJpChKFMTdoocsmdCQneopUcqJrqrjl3vrt9A`,\n    `ghp_gKMCFqMaQiLTqhjdoocsmd7BJE8RyK6AdRw4b42CutS`,\n    `ghp_2oShgb33qFlqBmadoocsmdludmuLYxBFY5bao1XrsVo`,\n    `ghp_eYyd3kxWTZmsV8doocsmdDFbAa7AEGQTJgmOd0GUmtY`,\n  ],\n}\n\nexport const giteeConfig = {\n  username: `filesss`,\n  repoList: Array.from({ length: 20 }, (_, i) => `img${i}`),\n  branch: `main`,\n  accessTokenList: [\n    `ed5fc9866bd6c2fdoocsmddd433f806fd2f399c`,\n    `5448ffebbbf1151doocsmdc4e337cf814fc8a62`,\n    `25b05efd2557ca2doocsmd75b5c0835e3395911`,\n    `11628c7a5aef015doocsmd2eeff9fb9566f0458`,\n    `cb2f5145ed938dedoocsmdbd063b4ed244eecf8`,\n    `d8c0b57500672c1doocsmd55f48b866b5ebcd98`,\n    `78c56eadb88e453doocsmd43ddd95753351771a`,\n    `03e1a688003948fdoocsmda16fcf41e6f03f1f0`,\n    `c49121cf4d191fbdoocsmdd6a7877ed537e474a`,\n    `adfeb2fadcdc4aadoocsmdfe1ee869ac9c968ff`,\n    `116c94549ca4a0ddoocsmd192653af5c0694616`,\n    `ecf30ed7f2eb184doocsmd51ea4ec8300371d9e`,\n    `5837cf2bd5afd93doocsmd73904bed31934949e`,\n    `b5b7e1c7d57e01fdoocsmd5266f552574297d78`,\n    `684d55564ffbd0bdoocsmd7d747e5cc23aed6d6`,\n    `3fc04a9d272ab71doocsmd010c56cb57d88d2ba`,\n  ],\n}\n"
  },
  {
    "path": "packages/shared/src/configs/index.ts",
    "content": "export * from './ai-service-options'\nexport * from './api'\nexport * from './prefix'\nexport * from './shortcut-key'\nexport * from './store'\nexport * from './style'\nexport * from './theme'\n"
  },
  {
    "path": "packages/shared/src/configs/prefix.ts",
    "content": "export const prefix = `MD`\n"
  },
  {
    "path": "packages/shared/src/configs/shortcut-key.ts",
    "content": "const isMac = /Mac/i.test(navigator.userAgent)\n\nexport const ctrlKey = `Mod`\nexport const altKey = `Alt`\nexport const shiftKey = `Shift`\n\nexport const ctrlSign = isMac ? `⌘` : `Ctrl`\nexport const altSign = isMac ? `⌥` : `Alt`\nexport const shiftSign = isMac ? `⇧` : `Shift`\n\nexport const headingLevels = [\n  { level: 1, label: '标题 1' },\n  { level: 2, label: '标题 2' },\n  { level: 3, label: '标题 3' },\n  { level: 4, label: '标题 4' },\n  { level: 5, label: '标题 5' },\n  { level: 6, label: '标题 6' },\n]\n"
  },
  {
    "path": "packages/shared/src/configs/store.ts",
    "content": "export const storeLabels: Record<string, string> = {\n  // Main store states\n  isDark: `深色模式`,\n  isEditOnLeft: `左侧编辑`,\n  isMacCodeBlock: `Mac 代码块`,\n  isShowLineNumber: `代码块行号`,\n  isCiteStatus: `微信外链接底部引用状态`,\n  isCountStatus: `字数统计状态`,\n  isUseIndent: `使用缩进`,\n  isUseJustify: `使用两端对齐`,\n  isOpenRightSlider: `开启右侧滑块`,\n  isOpenPostSlider: `开启右侧发布滑块`,\n  showAIToolbox: `AI 工具箱状态`,\n  theme: `主题`,\n  fontFamily: `字体`,\n  fontSize: `字体大小`,\n  primaryColor: `自定义主题色`,\n  codeBlockTheme: `代码块主题`,\n  legend: `图注格式`,\n  fontSizeNumber: `字体大小`,\n  currentPostId: `当前文章 ID`,\n  currentPostIndex: `当前文章索引`,\n  posts: `内容列表`,\n  cssContentConfig: `自定义 CSS`,\n  titleList: `文章标题列表`,\n  readingTime: `阅读时间`,\n\n  // Display store states\n  isShowCssEditor: `显示 CSS 编辑器`,\n  isShowInsertFormDialog: `显示插入表单对话框`,\n  isShowInsertMpCardDialog: `显示插入公众号名片对话框`,\n  isShowUploadImgDialog: `显示上传图片对话框`,\n  aiDialogVisible: `AI 对话框可见`,\n  aiImageDialogVisible: `AI 图片生成对话框可见`,\n}\n"
  },
  {
    "path": "packages/shared/src/configs/style.ts",
    "content": "import type { IConfigOption } from '../types'\nimport { themeOptions } from './theme'\n\nexport const fontFamilyOptions: IConfigOption[] = [\n  {\n    label: `无衬线`,\n    value: `-apple-system-font,BlinkMacSystemFont, Helvetica Neue, PingFang SC, Hiragino Sans GB , Microsoft YaHei UI , Microsoft YaHei ,Arial,sans-serif`,\n    desc: `字体123Abc`,\n  },\n  {\n    label: `衬线`,\n    value: `Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, 'PingFang SC', Cambria, Cochin, Georgia, Times, 'Times New Roman', serif`,\n    desc: `字体123Abc`,\n  },\n  {\n    label: `等宽`,\n    value: `Menlo, Monaco, 'Courier New', monospace`,\n    desc: `字体123Abc`,\n  },\n]\n\nexport const fontSizeOptions: IConfigOption[] = [\n  {\n    label: `14px`,\n    value: `14px`,\n    desc: `更小`,\n  },\n  {\n    label: `15px`,\n    value: `15px`,\n    desc: `稍小`,\n  },\n  {\n    label: `16px`,\n    value: `16px`,\n    desc: `推荐`,\n  },\n  {\n    label: `17px`,\n    value: `17px`,\n    desc: `稍大`,\n  },\n  {\n    label: `18px`,\n    value: `18px`,\n    desc: `更大`,\n  },\n]\n\nexport const colorOptions: IConfigOption[] = [\n  {\n    label: `经典蓝`,\n    value: `#0F4C81`,\n    desc: `稳重冷静`,\n  },\n  {\n    label: `翡翠绿`,\n    value: `#009874`,\n    desc: `自然平衡`,\n  },\n  {\n    label: `活力橘`,\n    value: `#FA5151`,\n    desc: `热情活力`,\n  },\n  {\n    label: `柠檬黄`,\n    value: `#FECE00`,\n    desc: `明亮温暖`,\n  },\n  {\n    label: `薰衣紫`,\n    value: `#92617E`,\n    desc: `优雅神秘`,\n  },\n  {\n    label: `天空蓝`,\n    value: `#55C9EA`,\n    desc: `清爽自由`,\n  },\n  {\n    label: `玫瑰金`,\n    value: `#B76E79`,\n    desc: `奢华现代`,\n  },\n  {\n    label: `橄榄绿`,\n    value: `#556B2F`,\n    desc: `沉稳自然`,\n  },\n  {\n    label: `石墨黑`,\n    value: `#333333`,\n    desc: `内敛极简`,\n  },\n  {\n    label: `雾烟灰`,\n    value: `#A9A9A9`,\n    desc: `柔和低调`,\n  },\n  {\n    label: `樱花粉`,\n    value: `#FFB7C5`,\n    desc: `浪漫甜美`,\n  },\n]\n\nexport const widthOptions: IConfigOption[] = [\n  {\n    label: `移动端`,\n    value: `w-[375px]`,\n    desc: `固定`,\n  },\n  {\n    label: `电脑端`,\n    value: `w-full`,\n    desc: `适应`,\n  },\n]\n\nconst codeBlockUrlPrefix = `https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/npm/highlightjs/11.11.1/styles/`\nconst codeBlockThemeList = [\n  `1c-light`,\n  `a11y-dark`,\n  `a11y-light`,\n  `agate`,\n  `an-old-hope`,\n  `androidstudio`,\n  `arduino-light`,\n  `arta`,\n  `ascetic`,\n  `atom-one-dark-reasonable`,\n  `atom-one-dark`,\n  `atom-one-light`,\n  `brown-paper`,\n  `codepen-embed`,\n  `color-brewer`,\n  `dark`,\n  `default`,\n  `devibeans`,\n  `docco`,\n  `far`,\n  `felipec`,\n  `foundation`,\n  `github-dark-dimmed`,\n  `github-dark`,\n  `github`,\n  `gml`,\n  `googlecode`,\n  `gradient-dark`,\n  `gradient-light`,\n  `grayscale`,\n  `hybrid`,\n  `idea`,\n  `intellij-light`,\n  `ir-black`,\n  `isbl-editor-dark`,\n  `isbl-editor-light`,\n  `kimbie-dark`,\n  `kimbie-light`,\n  `lightfair`,\n  `lioshi`,\n  `magula`,\n  `mono-blue`,\n  `monokai-sublime`,\n  `monokai`,\n  `night-owl`,\n  `nnfx-dark`,\n  `nnfx-light`,\n  `nord`,\n  `obsidian`,\n  `panda-syntax-dark`,\n  `panda-syntax-light`,\n  `paraiso-dark`,\n  `paraiso-light`,\n  `pojoaque`,\n  `purebasic`,\n  `qtcreator-dark`,\n  `qtcreator-light`,\n  `rainbow`,\n  `routeros`,\n  `school-book`,\n  `shades-of-purple`,\n  `srcery`,\n  `stackoverflow-dark`,\n  `stackoverflow-light`,\n  `sunburst`,\n  `tokyo-night-dark`,\n  `tokyo-night-light`,\n  `tomorrow-night-blue`,\n  `tomorrow-night-bright`,\n  `vs`,\n  `vs2015`,\n  `xcode`,\n  `xt256`,\n]\n\nexport const codeBlockThemeOptions: IConfigOption[] = codeBlockThemeList.map(codeBlockTheme => ({\n  label: codeBlockTheme,\n  value: `${codeBlockUrlPrefix}${codeBlockTheme}.min.css`,\n  desc: ``,\n}))\n\nexport const headingLevelOptions: IConfigOption[] = [\n  { label: `一级标题`, value: `h1`, desc: `` },\n  { label: `二级标题`, value: `h2`, desc: `` },\n  { label: `三级标题`, value: `h3`, desc: `` },\n  { label: `四级标题`, value: `h4`, desc: `` },\n  { label: `五级标题`, value: `h5`, desc: `` },\n  { label: `六级标题`, value: `h6`, desc: `` },\n]\n\nexport const headingStyleOptions: IConfigOption[] = [\n  { label: `默认`, value: `default`, desc: `` },\n  { label: `主题色文字`, value: `color-only`, desc: `` },\n  { label: `下边框`, value: `border-bottom`, desc: `` },\n  { label: `左边框`, value: `border-left`, desc: `` },\n  { label: `自定义`, value: `custom`, desc: `` },\n]\n\nexport type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'\nexport type HeadingStyleType = 'default' | 'color-only' | 'border-bottom' | 'border-left' | 'custom'\n\nexport type HeadingStyles = {\n  [K in HeadingLevel]?: HeadingStyleType\n}\n\nexport const defaultHeadingStyles: HeadingStyles = {}\n\nexport const legendOptions: IConfigOption[] = [\n  {\n    label: `title 优先`,\n    value: `title-alt`,\n    desc: ``,\n  },\n  {\n    label: `alt 优先`,\n    value: `alt-title`,\n    desc: ``,\n  },\n  {\n    label: `只显示 title`,\n    value: `title`,\n    desc: ``,\n  },\n  {\n    label: `只显示 alt`,\n    value: `alt`,\n    desc: ``,\n  },\n  {\n    label: `文件名`,\n    value: `filename`,\n    desc: ``,\n  },\n  {\n    label: `不显示`,\n    value: `none`,\n    desc: ``,\n  },\n]\n\nexport const defaultStyleConfig = {\n  isCiteStatus: false,\n  isMacCodeBlock: true,\n  isShowLineNumber: false,\n  isCountStatus: false,\n  theme: themeOptions[0].value,\n  fontFamily: fontFamilyOptions[0].value,\n  fontSize: fontSizeOptions[2].value,\n  primaryColor: colorOptions[0].value,\n  codeBlockTheme: codeBlockThemeOptions[23].value,\n  legend: legendOptions[3].value,\n  headingStyles: defaultHeadingStyles as HeadingStyles,\n}\n"
  },
  {
    "path": "packages/shared/src/configs/theme-css/base.css",
    "content": "/**\n * MD 基础主题样式\n * 包含所有元素的基础样式和 CSS 变量定义\n */\n\n/* ==================== 容器样式 ==================== */\nsection,\ncontainer {\n  font-family: var(--md-font-family);\n  font-size: var(--md-font-size);\n  line-height: 1.75;\n  text-align: left;\n}\n\n/* 确保 #output 容器应用基础样式 */\n#output {\n  font-family: var(--md-font-family);\n  font-size: var(--md-font-size);\n  line-height: 1.75;\n  text-align: left;\n}\n\n/* 去除第一个元素的 margin-top */\n#output section > :first-child {\n  margin-top: 0 !important;\n}\n\n.mermaid-diagram .nodeLabel p {\n  color: unset !important;\n  letter-spacing: unset !important;\n}\n"
  },
  {
    "path": "packages/shared/src/configs/theme-css/default.css",
    "content": "/**\n * MD 默认主题（经典主题）\n * 按 Alt/Option + Shift + F 可格式化\n * 如需使用主题色，请使用 var(--md-primary-color) 代替颜色值\n */\n\n/* ==================== 一级标题 ==================== */\nh1 {\n  display: table;\n  padding: 0 1em;\n  border-bottom: 2px solid var(--md-primary-color);\n  margin: 2em auto 1em;\n  color: hsl(var(--foreground));\n  font-size: calc(var(--md-font-size) * 1.2);\n  font-weight: bold;\n  text-align: center;\n}\n\n/* ==================== 二级标题 ==================== */\nh2 {\n  display: table;\n  padding: 0 0.2em;\n  margin: 4em auto 2em;\n  color: #fff;\n  background: var(--md-primary-color);\n  font-size: calc(var(--md-font-size) * 1.2);\n  font-weight: bold;\n  text-align: center;\n}\n\n/* ==================== 三级标题 ==================== */\nh3 {\n  padding-left: 8px;\n  border-left: 3px solid var(--md-primary-color);\n  margin: 2em 8px 0.75em 0;\n  color: hsl(var(--foreground));\n  font-size: calc(var(--md-font-size) * 1.1);\n  font-weight: bold;\n  line-height: 1.2;\n}\n\n/* ==================== 四级标题 ==================== */\nh4 {\n  margin: 2em 8px 0.5em;\n  color: var(--md-primary-color);\n  font-size: calc(var(--md-font-size) * 1);\n  font-weight: bold;\n}\n\n/* ==================== 五级标题 ==================== */\nh5 {\n  margin: 1.5em 8px 0.5em;\n  color: var(--md-primary-color);\n  font-size: calc(var(--md-font-size) * 1);\n  font-weight: bold;\n}\n\n/* ==================== 六级标题 ==================== */\nh6 {\n  margin: 1.5em 8px 0.5em;\n  font-size: calc(var(--md-font-size) * 1);\n  color: var(--md-primary-color);\n}\n\n/* ==================== 段落 ==================== */\np {\n  margin: 1.5em 8px;\n  letter-spacing: 0.1em;\n  color: hsl(var(--foreground));\n}\n\n/* ==================== 引用块 ==================== */\nblockquote {\n  font-style: normal;\n  padding: 1em;\n  border-left: 4px solid var(--md-primary-color);\n  border-radius: 6px;\n  color: hsl(var(--foreground));\n  background: var(--blockquote-background);\n  margin-bottom: 1em;\n}\n\nblockquote > p {\n  display: block;\n  font-size: 1em;\n  letter-spacing: 0.1em;\n  color: hsl(var(--foreground));\n  margin: 0;\n}\n\n/* ==================== GFM 警告块 ==================== */\n.alert-title-note,\n.alert-title-tip,\n.alert-title-info,\n.alert-title-important,\n.alert-title-warning,\n.alert-title-caution,\n.alert-title-abstract,\n.alert-title-summary,\n.alert-title-tldr,\n.alert-title-todo,\n.alert-title-success,\n.alert-title-done,\n.alert-title-question,\n.alert-title-help,\n.alert-title-faq,\n.alert-title-failure,\n.alert-title-fail,\n.alert-title-missing,\n.alert-title-danger,\n.alert-title-error,\n.alert-title-bug,\n.alert-title-example,\n.alert-title-quote,\n.alert-title-cite {\n  display: flex;\n  align-items: center;\n  gap: 0.5em;\n  margin-bottom: 0.5em;\n}\n\n.alert-title-note {\n  color: #478be6;\n}\n\n.alert-title-tip {\n  color: #57ab5a;\n}\n\n.alert-title-info {\n  color: #93c5fd;\n}\n\n.alert-title-important {\n  color: #986ee2;\n}\n\n.alert-title-warning {\n  color: #c69026;\n}\n\n.alert-title-caution {\n  color: #e5534b;\n}\n\n/* Obsidian-style callout colors */\n.alert-title-abstract,\n.alert-title-summary,\n.alert-title-tldr {\n  color: #00bfff;\n}\n\n.alert-title-todo {\n  color: #478be6;\n}\n\n.alert-title-success,\n.alert-title-done {\n  color: #57ab5a;\n}\n\n.alert-title-question,\n.alert-title-help,\n.alert-title-faq {\n  color: #c69026;\n}\n\n.alert-title-failure,\n.alert-title-fail,\n.alert-title-missing {\n  color: #e5534b;\n}\n\n.alert-title-danger,\n.alert-title-error {\n  color: #e5534b;\n}\n\n.alert-title-bug {\n  color: #e5534b;\n}\n\n.alert-title-example {\n  color: #986ee2;\n}\n\n.alert-title-quote,\n.alert-title-cite {\n  color: #9ca3af;\n}\n\n/* GFM Alert SVG 图标颜色 */\n.alert-icon-note {\n  fill: #478be6;\n}\n\n.alert-icon-tip {\n  fill: #57ab5a;\n}\n\n.alert-icon-info {\n  fill: #93c5fd;\n}\n\n.alert-icon-important {\n  fill: #986ee2;\n}\n\n.alert-icon-warning {\n  fill: #c69026;\n}\n\n.alert-icon-caution {\n  fill: #e5534b;\n}\n\n/* Obsidian-style callout icon colors */\n.alert-icon-abstract,\n.alert-icon-summary,\n.alert-icon-tldr {\n  fill: #00bfff;\n}\n\n.alert-icon-todo {\n  fill: #478be6;\n}\n\n.alert-icon-success,\n.alert-icon-done {\n  fill: #57ab5a;\n}\n\n.alert-icon-question,\n.alert-icon-help,\n.alert-icon-faq {\n  fill: #c69026;\n}\n\n.alert-icon-failure,\n.alert-icon-fail,\n.alert-icon-missing {\n  fill: #e5534b;\n}\n\n.alert-icon-danger,\n.alert-icon-error {\n  fill: #e5534b;\n}\n\n.alert-icon-bug {\n  fill: #e5534b;\n}\n\n.alert-icon-example {\n  fill: #986ee2;\n}\n\n.alert-icon-quote,\n.alert-icon-cite {\n  fill: #9ca3af;\n}\n\n/* ==================== 代码块 ==================== */\npre.code__pre,\n.hljs.code__pre {\n  font-size: 90%;\n  overflow-x: auto;\n  border-radius: 8px;\n  padding: 0 !important;\n  line-height: 1.5;\n  margin: 10px 8px;\n}\n\n/* ==================== 图片 ==================== */\nimg {\n  display: block;\n  max-width: 100%;\n  margin: 0.1em auto 0.5em;\n  border-radius: 4px;\n}\n\n/* ==================== 列表 ==================== */\nol {\n  padding-left: 1em;\n  margin-left: 0;\n  color: hsl(var(--foreground));\n}\n\nul {\n  list-style: circle;\n  padding-left: 1em;\n  margin-left: 0;\n  color: hsl(var(--foreground));\n}\n\nli {\n  display: block;\n  margin: 0.2em 8px;\n  color: hsl(var(--foreground));\n}\n\n/* ==================== 脚注 ==================== */\n/* footnotes 在 buildFootnotes() 中渲染为 <p> 标签 */\np.footnotes {\n  margin: 0.5em 8px;\n  font-size: 80%;\n  color: hsl(var(--foreground));\n}\n\n/* ==================== 图表 ==================== */\nfigure {\n  margin: 1.5em 8px;\n  color: hsl(var(--foreground));\n}\n\nfigcaption,\n.md-figcaption {\n  text-align: center;\n  color: #888;\n  font-size: 0.8em;\n}\n\n/* ==================== 分隔线 ==================== */\nhr {\n  border-style: solid;\n  border-width: 2px 0 0;\n  border-color: rgba(0, 0, 0, 0.1);\n  -webkit-transform-origin: 0 0;\n  -webkit-transform: scale(1, 0.5);\n  transform-origin: 0 0;\n  transform: scale(1, 0.5);\n  height: 0.4em;\n  margin: 1.5em 0;\n}\n\n/* ==================== 行内代码 ==================== */\ncode {\n  font-size: 90%;\n  color: #d14;\n  background: rgba(27, 31, 35, 0.05);\n  padding: 3px 5px;\n  border-radius: 4px;\n}\n\n/* 代码块内的 code 标签需要特殊处理（覆盖行内 code 样式） */\npre.code__pre > code,\n.hljs.code__pre > code {\n  display: -webkit-box;\n  padding: 0.5em 1em 1em;\n  overflow-x: auto;\n  text-indent: 0;\n  color: inherit;\n  background: none;\n  white-space: nowrap;\n  margin: 0;\n}\n\n/* ==================== 强调 ==================== */\nem {\n  font-style: italic;\n  font-size: inherit;\n}\n\n/* ==================== 链接 ==================== */\na {\n  color: #576b95;\n  text-decoration: none;\n}\n\n/* ==================== 粗体 ==================== */\nstrong {\n  color: var(--md-primary-color);\n  font-weight: bold;\n  font-size: inherit;\n}\n\n/* ==================== 表格 ==================== */\ntable {\n  color: hsl(var(--foreground));\n}\n\nthead {\n  font-weight: bold;\n  color: hsl(var(--foreground));\n}\n\nth {\n  border: 1px solid #dfdfdf;\n  padding: 0.25em 0.5em;\n  color: hsl(var(--foreground));\n  word-break: keep-all;\n  background: rgba(0, 0, 0, 0.05);\n}\n\ntd {\n  border: 1px solid #dfdfdf;\n  padding: 0.25em 0.5em;\n  color: hsl(var(--foreground));\n  word-break: keep-all;\n}\n\n/* ==================== KaTeX 公式 ==================== */\n.katex-inline {\n  max-width: 100%;\n  overflow-x: auto;\n}\n\n.katex-block {\n  max-width: 100%;\n  overflow-x: auto;\n  -webkit-overflow-scrolling: touch;\n  padding: 0.5em 0;\n  text-align: center;\n}\n\n/* ==================== 标记高亮 ==================== */\n.markup-highlight {\n  background-color: var(--md-primary-color);\n  padding: 2px 4px;\n  border-radius: 2px;\n  color: #fff;\n}\n\n.markup-underline {\n  text-decoration: underline;\n  text-decoration-color: var(--md-primary-color);\n}\n\n.markup-wavyline {\n  text-decoration: underline wavy;\n  text-decoration-color: var(--md-primary-color);\n  text-decoration-thickness: 2px;\n}\n"
  },
  {
    "path": "packages/shared/src/configs/theme-css/grace.css",
    "content": "/**\n * MD 优雅主题 (@brzhang)\n * 在默认主题基础上添加优雅的视觉效果\n */\n\n/* ==================== 标题样式 ==================== */\nh1 {\n  padding: 0.5em 1em;\n  border-bottom: 2px solid var(--md-primary-color);\n  font-size: calc(var(--md-font-size) * 1.4);\n  text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);\n}\n\nh2 {\n  padding: 0.3em 1em;\n  border-radius: 8px;\n  font-size: calc(var(--md-font-size) * 1.3);\n  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);\n}\n\nh3 {\n  padding-left: 12px;\n  font-size: calc(var(--md-font-size) * 1.2);\n  border-left: 4px solid var(--md-primary-color);\n  border-bottom: 1px dashed var(--md-primary-color);\n}\n\nh4 {\n  font-size: calc(var(--md-font-size) * 1.1);\n}\n\nh5 {\n  font-size: var(--md-font-size);\n}\n\nh6 {\n  font-size: var(--md-font-size);\n}\n\n/* ==================== 引用块 ==================== */\nblockquote {\n  font-style: italic;\n  padding: 1em 1em 1em 2em;\n  border-left: 4px solid var(--md-primary-color);\n  border-radius: 6px;\n  color: rgba(0, 0, 0, 0.6);\n  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);\n  margin-bottom: 1em;\n}\n\n.markdown-alert {\n  font-style: italic;\n}\n\n/* ==================== 代码块 ==================== */\npre.code__pre,\n.hljs.code__pre {\n  box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05);\n}\n\npre.code__pre > code,\n.hljs.code__pre > code {\n  font-family: 'Fira Code', Menlo, Operator Mono, Consolas, Monaco, monospace;\n}\n\n/* ==================== 图片 ==================== */\nimg {\n  border-radius: 8px;\n  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);\n}\n\nfigcaption,\n.md-figcaption {\n  text-align: center;\n  color: #888;\n  font-size: 0.8em;\n}\n\n/* ==================== 列表 ==================== */\nol {\n  padding-left: 1.5em;\n}\n\nul {\n  list-style: none;\n  padding-left: 1.5em;\n}\n\nli {\n  margin: 0.5em 8px;\n}\n\n/* ==================== 分隔线 ==================== */\nhr {\n  height: 1px;\n  border: none;\n  margin: 2em 0;\n  background: linear-gradient(to right, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0));\n}\n\n/* ==================== 表格 ==================== */\ntable {\n  border-collapse: separate;\n  border-spacing: 0;\n  border-radius: 8px;\n  margin: 1em 8px;\n  color: hsl(var(--foreground));\n  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);\n  overflow: hidden;\n}\n\nthead {\n  color: #fff;\n}\n\ntd {\n  padding: 0.5em 1em;\n}\n\n/* ==================== 强调 ==================== */\nem {\n  font-style: italic;\n  font-size: inherit;\n}\n\n/* ==================== 链接 ==================== */\na {\n  color: #576b95;\n  text-decoration: none;\n}\n"
  },
  {
    "path": "packages/shared/src/configs/theme-css/index.ts",
    "content": "/**\n * CSS 主题导出\n * 将 CSS 文件作为字符串导出供 JavaScript 使用\n */\n\nimport baseCSS from './base.css?raw'\nimport defaultCSS from './default.css?raw'\nimport graceCSS from './grace.css?raw'\nimport simpleCSS from './simple.css?raw'\n\n/**\n * 基础样式 CSS\n */\nexport const baseCSSContent = baseCSS\n\n/**\n * CSS 主题映射表\n */\nexport const themeMap = {\n  default: defaultCSS,\n  grace: graceCSS,\n  simple: simpleCSS,\n} as const\n\nexport type ThemeName = keyof typeof themeMap\n"
  },
  {
    "path": "packages/shared/src/configs/theme-css/simple.css",
    "content": "/**\n * MD 简洁主题 (@okooo5km)\n * 简洁现代的设计风格\n */\n\n/* ==================== 标题样式 ==================== */\nh1 {\n  padding: 0.5em 1em;\n  font-size: calc(var(--md-font-size) * 1.4);\n  text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.05);\n}\n\nh2 {\n  padding: 0.3em 1.2em;\n  font-size: calc(var(--md-font-size) * 1.3);\n  border-radius: 8px 24px 8px 24px;\n  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);\n}\n\nh3 {\n  padding-left: 12px;\n  font-size: calc(var(--md-font-size) * 1.2);\n  border-radius: 6px;\n  line-height: 2.4em;\n  border-left: 4px solid var(--md-primary-color);\n  border-right: 1px solid color-mix(in srgb, var(--md-primary-color) 10%, transparent);\n  border-bottom: 1px solid color-mix(in srgb, var(--md-primary-color) 10%, transparent);\n  border-top: 1px solid color-mix(in srgb, var(--md-primary-color) 10%, transparent);\n  background: color-mix(in srgb, var(--md-primary-color) 8%, transparent);\n}\n\nh4 {\n  font-size: calc(var(--md-font-size) * 1.1);\n  border-radius: 6px;\n}\n\nh5 {\n  font-size: var(--md-font-size);\n  border-radius: 6px;\n}\n\nh6 {\n  font-size: var(--md-font-size);\n  border-radius: 6px;\n}\n\n/* ==================== 引用块 ==================== */\nblockquote {\n  font-style: italic;\n  padding: 1em 1em 1em 2em;\n  color: rgba(0, 0, 0, 0.6);\n  border-bottom: 0.2px solid rgba(0, 0, 0, 0.04);\n  border-top: 0.2px solid rgba(0, 0, 0, 0.04);\n  border-right: 0.2px solid rgba(0, 0, 0, 0.04);\n}\n\n/* GFM Alert 样式覆盖 */\n.markdown-alert-note,\n.markdown-alert-tip,\n.markdown-alert-info,\n.markdown-alert-important,\n.markdown-alert-warning,\n.markdown-alert-caution {\n  font-style: italic;\n}\n\n/* ==================== 代码块 ==================== */\npre.code__pre,\n.hljs.code__pre {\n  border: 1px solid rgba(0, 0, 0, 0.04);\n}\n\npre.code__pre > code,\n.hljs.code__pre > code {\n  font-family: 'Fira Code', Menlo, Operator Mono, Consolas, Monaco, monospace;\n}\n\n/* ==================== 图片 ==================== */\nimg {\n  border-radius: 8px;\n  border: 1px solid rgba(0, 0, 0, 0.04);\n}\n\nfigcaption,\n.md-figcaption {\n  text-align: center;\n  color: #888;\n  font-size: 0.8em;\n}\n\n/* ==================== 列表 ==================== */\nol {\n  padding-left: 1.5em;\n}\n\nul {\n  list-style: none;\n  padding-left: 1.5em;\n}\n\nli {\n  margin: 0.5em 8px;\n}\n\n/* ==================== 分隔线 ==================== */\nhr {\n  height: 1px;\n  border: none;\n  margin: 2em 0;\n  background: linear-gradient(to right, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0));\n}\n\n/* ==================== 强调 ==================== */\nem {\n  font-style: italic;\n  font-size: inherit;\n}\n\n/* ==================== 链接 ==================== */\na {\n  color: #576b95;\n  text-decoration: none;\n}\n"
  },
  {
    "path": "packages/shared/src/configs/theme.ts",
    "content": "import type { IConfigOption } from '../types'\nimport type { ThemeName } from './theme-css'\n\n// 导出 CSS 主题（新主题系统）\nexport { baseCSSContent, themeMap, type ThemeName } from './theme-css'\n\nexport const themeOptionsMap = {\n  default: {\n    label: `经典`,\n    value: `default`,\n    desc: ``,\n  },\n  grace: {\n    label: `优雅`,\n    value: `grace`,\n    desc: `@brzhang`,\n  },\n  simple: {\n    label: `简洁`,\n    value: `simple`,\n    desc: `@okooo5km`,\n  },\n}\n\nexport const themeOptions: IConfigOption<ThemeName>[] = [\n  {\n    label: `经典`,\n    value: `default`,\n    desc: ``,\n  },\n  {\n    label: `优雅`,\n    value: `grace`,\n    desc: `@brzhang`,\n  },\n  {\n    label: `简洁`,\n    value: `simple`,\n    desc: `@okooo5km`,\n  },\n]\n"
  },
  {
    "path": "packages/shared/src/constants/ai-config.ts",
    "content": "export const DEFAULT_SERVICE_ENDPOINT = `https://proxy-ai.doocs.org/v1`\nexport const DEFAULT_SERVICE_TEMPERATURE = 1\nexport const DEFAULT_SERVICE_MAX_TOKEN = 1024\nexport const DEFAULT_SERVICE_TYPE = `default`\nexport const DEFAULT_SERVICE_KEY = ``\n"
  },
  {
    "path": "packages/shared/src/constants/index.ts",
    "content": "export * from './ai-config'\n"
  },
  {
    "path": "packages/shared/src/editor/basicSetup.ts",
    "content": "import type { Extension } from '@codemirror/state'\nimport { acceptCompletion, autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete'\nimport { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'\nimport { bracketMatching, defaultHighlightStyle, foldGutter, foldKeymap, indentOnInput, syntaxHighlighting } from '@codemirror/language'\nimport { lintKeymap } from '@codemirror/lint'\nimport { highlightSelectionMatches, searchKeymap } from '@codemirror/search'\nimport { EditorState } from '@codemirror/state'\nimport { crosshairCursor, drawSelection, dropCursor, highlightActiveLine, highlightActiveLineGutter, highlightSpecialChars, keymap, rectangularSelection } from '@codemirror/view'\nimport { indentationMarkers } from '@replit/codemirror-indentation-markers'\n\n// (The superfluous function calls around the list of extensions work\n// around current limitations in tree-shaking software.)\n\n/// This is an extension value that just pulls together a number of\n/// extensions that you might want in a basic editor. It is meant as a\n/// convenient helper to quickly set up CodeMirror without installing\n/// and importing a lot of separate packages.\n///\n/// Specifically, it includes...\n///\n///  - [the default command bindings](#commands.defaultKeymap)\n///  - [line numbers](#view.lineNumbers)\n///  - [special character highlighting](#view.highlightSpecialChars)\n///  - [the undo history](#commands.history)\n///  - [a fold gutter](#language.foldGutter)\n///  - [custom selection drawing](#view.drawSelection)\n///  - [drop cursor](#view.dropCursor)\n///  - [multiple selections](#state.EditorState^allowMultipleSelections)\n///  - [reindentation on input](#language.indentOnInput)\n///  - [the default highlight style](#language.defaultHighlightStyle) (as fallback)\n///  - [bracket matching](#language.bracketMatching)\n///  - [bracket closing](#autocomplete.closeBrackets)\n///  - [autocompletion](#autocomplete.autocompletion)\n///  - [rectangular selection](#view.rectangularSelection) and [crosshair cursor](#view.crosshairCursor)\n///  - [active line highlighting](#view.highlightActiveLine)\n///  - [active line gutter highlighting](#view.highlightActiveLineGutter)\n///  - [selection match highlighting](#search.highlightSelectionMatches)\n///  - [search](#search.searchKeymap)\n///  - [linting](#lint.lintKeymap)\n///\n/// (You'll probably want to add some language package to your setup\n/// too.)\n///\n/// This extension does not allow customization. The idea is that,\n/// once you decide you want to configure your editor more precisely,\n/// you take this package's source (which is just a bunch of imports\n/// and an array literal), copy it into your own code, and adjust it\n/// as desired.\nexport const basicSetup: Extension = (() => [\n  // lineNumbers(), // 移除行号显示\n  highlightActiveLineGutter(),\n  highlightSpecialChars(),\n  history(),\n  foldGutter(),\n  drawSelection(),\n  dropCursor(),\n  EditorState.allowMultipleSelections.of(true),\n  indentOnInput(),\n  syntaxHighlighting(defaultHighlightStyle, { fallback: true }),\n  bracketMatching(),\n  closeBrackets(),\n  autocompletion(),\n  rectangularSelection(),\n  crosshairCursor(),\n  highlightActiveLine(),\n  highlightSelectionMatches(),\n  indentationMarkers(), // 添加缩进标记\n  keymap.of([\n    ...closeBracketsKeymap,\n    ...defaultKeymap,\n    ...searchKeymap,\n    ...historyKeymap,\n    ...foldKeymap,\n    ...completionKeymap,\n    ...lintKeymap,\n    { key: `Tab`, run: acceptCompletion }, // use tab to completion\n    indentWithTab,\n  ]),\n])()\n\n/// A minimal set of extensions to create a functional editor. Only\n/// includes [the default keymap](#commands.defaultKeymap), [undo\n/// history](#commands.history), [special character\n/// highlighting](#view.highlightSpecialChars), [custom selection\n/// drawing](#view.drawSelection), and [default highlight\n/// style](#language.defaultHighlightStyle).\nexport const minimalSetup: Extension = (() => [\n  highlightSpecialChars(),\n  history(),\n  drawSelection(),\n  syntaxHighlighting(defaultHighlightStyle, { fallback: true }),\n  keymap.of([\n    ...defaultKeymap,\n    ...historyKeymap,\n  ]),\n])()\n\nexport { EditorView } from '@codemirror/view'\n"
  },
  {
    "path": "packages/shared/src/editor/css.ts",
    "content": "import { css } from '@codemirror/lang-css'\nimport { EditorView, keymap } from '@codemirror/view'\nimport { formatDoc } from '../utils/fileHelpers'\nimport { basicSetup } from './basicSetup'\n\n/**\n * CSS 格式化处理函数\n */\nasync function formatCSS(view: EditorView) {\n  const content = view.state.doc.toString()\n  const formatted = await formatDoc(content, `css`)\n  view.dispatch({\n    changes: { from: 0, to: view.state.doc.length, insert: formatted },\n  })\n}\n\n/**\n * CSS 编辑器的基础扩展集合\n *\n * 包含：\n * - basicSetup（无行号、代码折叠、自动补全、括号匹配等完整功能）\n * - 自动换行（无横向滚动）\n * - CSS 语言支持\n * - 格式化功能（Shift-Alt-f）\n *\n * basicSetup 包含的功能详见 ./basicSetup.ts\n *\n * 主题请使用统一的 theme() 函数，从 './themes' 导入\n */\nexport function cssSetup() {\n  return [\n    basicSetup,\n    css(),\n    // 自动换行，禁用横向滚动\n    EditorView.lineWrapping,\n    // 格式化快捷键\n    keymap.of([\n      { key: `Shift-Alt-f`, run: (view: EditorView) => { formatCSS(view); return true } },\n    ]),\n  ]\n}\n"
  },
  {
    "path": "packages/shared/src/editor/format.ts",
    "content": "import type { EditorView } from '@codemirror/view'\nimport { redo, undo } from '@codemirror/commands'\n\ninterface ToggleFormatOptions {\n  prefix: string\n  suffix: string\n  check?: (selected: string) => boolean\n  afterInsertCursorOffset?: number\n}\n\n/**\n * 切换格式（加粗、斜体、删除线等）\n */\nexport function toggleFormat(\n  view: EditorView,\n  {\n    prefix,\n    suffix,\n    check,\n    afterInsertCursorOffset = 0,\n  }: ToggleFormatOptions,\n): void {\n  const selection = view.state.selection.main\n  const selected = view.state.doc.sliceString(selection.from, selection.to)\n  const isFormatted = check?.(selected) ?? false\n\n  let newText: string\n\n  if (isFormatted) {\n    // Remove formatting (e.g. **abc** -> abc)\n    newText = selected.slice(prefix.length, selected.length - suffix.length)\n    view.dispatch(view.state.replaceSelection(newText))\n  }\n  else {\n    // Apply formatting\n    newText = `${prefix}${selected}${suffix}`\n    view.dispatch(view.state.replaceSelection(newText))\n\n    // Optional cursor shift (e.g. for `]()` links)\n    if (afterInsertCursorOffset !== 0) {\n      const newSelection = view.state.selection.main\n      const newPos = newSelection.head + afterInsertCursorOffset\n      view.dispatch({ selection: { anchor: newPos } })\n    }\n  }\n}\n\n/**\n * 应用标题级别\n */\nexport function applyHeading(view: EditorView, level: number) {\n  const ranges = view.state.selection.ranges\n  const changes: Array<{ from: number, to: number, insert: string }> = []\n  const headingPrefix = `${`#`.repeat(level)} `\n\n  ranges.forEach((range) => {\n    const fromLine = view.state.doc.lineAt(range.from)\n    const toLine = view.state.doc.lineAt(range.to)\n\n    for (let lineNum = fromLine.number; lineNum <= toLine.number; lineNum++) {\n      const line = view.state.doc.line(lineNum)\n      const text = view.state.doc.sliceString(line.from, line.to)\n      // 去掉已有的 # 前缀（1~6 个）+ 空格\n      const cleaned = text.replace(/^#{1,6}\\s+/, ``).trimStart()\n      const heading = headingPrefix + cleaned\n\n      changes.push({\n        from: line.from,\n        to: line.to,\n        insert: heading,\n      })\n    }\n  })\n\n  if (changes.length > 0) {\n    const firstLine = view.state.doc.lineAt(ranges[0].from)\n    // 计算光标应该在的位置：行首 + 标题前缀长度（如 \"# \" = 2）\n    const newCursorPos = firstLine.from + headingPrefix.length\n\n    view.dispatch({\n      changes,\n      selection: { anchor: newCursorPos },\n    })\n  }\n}\n\n/**\n * 便捷格式化函数\n */\nexport function formatBold(view: EditorView) {\n  toggleFormat(view, {\n    prefix: `**`,\n    suffix: `**`,\n    check: s => s.startsWith(`**`) && s.endsWith(`**`),\n    afterInsertCursorOffset: -2,\n  })\n}\n\nexport function formatItalic(view: EditorView) {\n  toggleFormat(view, {\n    prefix: `*`,\n    suffix: `*`,\n    check: s => s.startsWith(`*`) && s.endsWith(`*`),\n    afterInsertCursorOffset: -1,\n  })\n}\n\nexport function formatStrikethrough(view: EditorView) {\n  toggleFormat(view, {\n    prefix: `~~`,\n    suffix: `~~`,\n    check: s => s.startsWith(`~~`) && s.endsWith(`~~`),\n    afterInsertCursorOffset: -2,\n  })\n}\n\nexport function formatLink(view: EditorView) {\n  toggleFormat(view, {\n    prefix: `[`,\n    suffix: `]()`,\n    check: s => s.startsWith(`[`) && s.endsWith(`]()`),\n    afterInsertCursorOffset: -1,\n  })\n}\n\nexport function formatCode(view: EditorView) {\n  toggleFormat(view, {\n    prefix: `\\``,\n    suffix: `\\``,\n    check: s => s.startsWith(`\\``) && s.endsWith(`\\``),\n    afterInsertCursorOffset: -1,\n  })\n}\n\n/**\n * 设置文字颜色\n */\nexport function formatColor(view: EditorView, color: string) {\n  const selection = view.state.selection.main\n  const selected = view.state.doc.sliceString(selection.from, selection.to)\n\n  const spanRegex = /^\\s*<span\\s+style=\"color:\\s*([^\"\\s][^\"]*)\"\\s*>([\\s\\S]*)<\\/span>\\s*$/i\n  const match = selected.match(spanRegex)\n\n  if (match) {\n    const content = match[2]\n    const insert = `<span style=\"color: ${color}\">${content}</span>`\n    view.dispatch({\n      changes: { from: selection.from, to: selection.to, insert },\n      selection: { anchor: selection.from, head: selection.from + insert.length },\n    })\n  }\n  else {\n    const insert = `<span style=\"color: ${color}\">${selected}</span>`\n    view.dispatch({\n      changes: { from: selection.from, to: selection.to, insert },\n      selection: { anchor: selection.from, head: selection.from + insert.length },\n    })\n  }\n}\n\nexport function formatUnorderedList(view: EditorView) {\n  const selection = view.state.selection.main\n  const selected = view.state.doc.sliceString(selection.from, selection.to)\n  const lines = selected.split(`\\n`)\n  const isList = lines.every(line => line.trim().startsWith(`- `))\n  const updated = isList\n    ? lines.map(line => line.replace(/^- +/, ``)).join(`\\n`)\n    : lines.map(line => `- ${line}`).join(`\\n`)\n  view.dispatch(view.state.replaceSelection(updated))\n}\n\nexport function formatOrderedList(view: EditorView) {\n  const selection = view.state.selection.main\n  const selected = view.state.doc.sliceString(selection.from, selection.to)\n  const lines = selected.split(`\\n`)\n  const isList = lines.every(line => /^\\d+\\.\\s/.test(line.trim()))\n  const updated = isList\n    ? lines.map(line => line.replace(/^\\d+\\.\\s+/, ``)).join(`\\n`)\n    : lines.map((line, i) => `${i + 1}. ${line}`).join(`\\n`)\n  view.dispatch(view.state.replaceSelection(updated))\n}\n\n/**\n * 撤销/重做\n */\nexport function undoAction(view: EditorView): boolean {\n  return undo(view)\n}\n\nexport function redoAction(view: EditorView): boolean {\n  return redo(view)\n}\n"
  },
  {
    "path": "packages/shared/src/editor/index.ts",
    "content": "export * from './basicSetup'\nexport * from './css'\nexport * from './format'\nexport * from './javascript'\nexport * from './markdown'\nexport * from './themes'\n"
  },
  {
    "path": "packages/shared/src/editor/javascript.ts",
    "content": "import type { EditorView } from '@codemirror/view'\nimport { javascript } from '@codemirror/lang-javascript'\nimport { keymap } from '@codemirror/view'\nimport { formatDoc } from '../utils/fileHelpers'\nimport { basicSetup } from './basicSetup'\n\n/**\n * JavaScript 格式化处理函数\n */\nasync function formatJavaScript(view: EditorView) {\n  const content = view.state.doc.toString()\n  const formatted = await formatDoc(content, `javascript`)\n  view.dispatch({\n    changes: { from: 0, to: view.state.doc.length, insert: formatted },\n  })\n}\n\n/**\n * JavaScript 编辑器的基础扩展集合\n *\n * 包含：\n * - basicSetup（行号、代码折叠、自动补全、括号匹配等完整功能）\n * - JavaScript 语言支持\n * - 格式化功能（Shift-Alt-f）\n *\n * basicSetup 包含的功能详见 ./basicSetup.ts\n *\n * 主题请使用统一的 theme() 函数，从 './themes' 导入\n */\nexport function javascriptSetup() {\n  return [\n    basicSetup,\n    javascript(),\n    // 格式化快捷键\n    keymap.of([\n      { key: `Shift-Alt-f`, run: (view: EditorView) => { formatJavaScript(view); return true } },\n    ]),\n  ]\n}\n"
  },
  {
    "path": "packages/shared/src/editor/markdown.ts",
    "content": "import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'\nimport { defaultKeymap, history, historyKeymap } from '@codemirror/commands'\nimport { markdown, markdownLanguage } from '@codemirror/lang-markdown'\nimport { languages } from '@codemirror/language-data'\nimport { highlightSelectionMatches } from '@codemirror/search'\nimport { EditorSelection, EditorState, Prec } from '@codemirror/state'\nimport { EditorView, keymap, placeholder } from '@codemirror/view'\nimport { indentationMarkers } from '@replit/codemirror-indentation-markers'\nimport { formatDoc } from '../utils/fileHelpers'\nimport { applyHeading, formatBold, formatCode, formatItalic, formatLink, formatOrderedList, formatStrikethrough, formatUnorderedList, redoAction, undoAction } from './format'\n\n/**\n * Markdown 格式化处理函数\n */\nasync function formatMarkdown(view: EditorView) {\n  const content = view.state.doc.toString()\n  const formatted = await formatDoc(content, `markdown`)\n  view.dispatch({\n    changes: { from: 0, to: view.state.doc.length, insert: formatted },\n  })\n}\n\n/**\n * 在光标位置插入缩进（空格）\n */\nfunction insertTabAtCursor(view: EditorView): boolean {\n  const spaces = `  ` // 2 个空格作为缩进\n  const changes = view.state.changeByRange(range => ({\n    changes: { from: range.from, to: range.to, insert: spaces },\n    range: EditorSelection.range(range.from + spaces.length, range.from + spaces.length),\n  }))\n  view.dispatch(changes)\n  return true\n}\n\ninterface MarkdownKeymapOptions {\n  onSearch?: (view: EditorView) => void\n  onReplace?: (view: EditorView) => void\n}\n\n/**\n * Markdown 编辑器的快捷键映射\n *\n * @param options - 配置选项\n * @param options.onSearch - 搜索回调（可选）\n */\nexport function markdownKeymap(options?: MarkdownKeymapOptions) {\n  const { onSearch, onReplace } = options || {}\n\n  return keymap.of([\n    // Tab 键在光标位置插入缩进\n    { key: `Tab`, run: insertTabAtCursor },\n\n    // 撤销/重做\n    { key: `Mod-z`, run: undoAction },\n    { key: `Mod-y`, run: redoAction },\n\n    // 文本格式\n    { key: `Mod-b`, run: (view) => { formatBold(view); return true } },\n    { key: `Mod-i`, run: (view) => { formatItalic(view); return true } },\n    { key: `Mod-d`, run: (view) => { formatStrikethrough(view); return true } },\n    { key: `Mod-k`, run: (view) => { formatLink(view); return true } },\n    { key: `Mod-e`, run: (view) => { formatCode(view); return true } },\n\n    // 标题（使用 Mod-1 到 Mod-6）\n    { key: `Mod-1`, run: (view) => { applyHeading(view, 1); return true } },\n    { key: `Mod-2`, run: (view) => { applyHeading(view, 2); return true } },\n    { key: `Mod-3`, run: (view) => { applyHeading(view, 3); return true } },\n    { key: `Mod-4`, run: (view) => { applyHeading(view, 4); return true } },\n    { key: `Mod-5`, run: (view) => { applyHeading(view, 5); return true } },\n    { key: `Mod-6`, run: (view) => { applyHeading(view, 6); return true } },\n\n    // 列表\n    { key: `Mod-u`, run: (view) => { formatUnorderedList(view); return true } },\n    { key: `Mod-o`, run: (view) => { formatOrderedList(view); return true } },\n\n    // 搜索和替换（可选）\n    ...(onSearch ? [{ key: `Mod-f`, run: (view: EditorView) => { onSearch(view); return true } }] : []),\n    ...(onReplace ? [{ key: `Mod-h`, run: (view: EditorView) => { onReplace(view); return true } }] : []),\n\n    // 格式化\n    { key: `Shift-Alt-f`, run: (view: EditorView) => { formatMarkdown(view); return true } },\n\n    // 阻止 Mod-g（避免触发 CodeMirror 内置搜索）\n    { key: `Mod-g`, run: () => true },\n  ])\n}\n\n/**\n * Markdown 编辑器的基础扩展集合\n * 包含语言支持、历史记录、括号匹配等基础功能\n *\n * 主题请使用统一的 theme() 函数，从 './themes' 导入\n *\n * @param options - 配置选项（可选）\n * @param options.onSearch - 搜索回调，触发快捷键 Mod-f\n *\n */\nexport function markdownSetup(options?: MarkdownKeymapOptions) {\n  return [\n    // 基础功能\n    history(),\n    highlightSelectionMatches(),\n    closeBrackets(),\n\n    // 缩进标记\n    indentationMarkers(),\n\n    // 语言支持\n    markdown({\n      base: markdownLanguage,\n      codeLanguages: languages,\n      addKeymap: true,\n    }),\n\n    // Markdown 快捷键（高优先级，优先于默认快捷键）\n    Prec.high(markdownKeymap(options)),\n\n    // 默认快捷键\n    keymap.of([\n      ...defaultKeymap,\n      ...historyKeymap,\n      ...closeBracketsKeymap,\n    ]),\n\n    // 编辑器配置\n    EditorView.lineWrapping,\n    EditorState.allowMultipleSelections.of(true),\n\n    placeholder(`开始写作...`),\n  ]\n}\n"
  },
  {
    "path": "packages/shared/src/editor/themes.ts",
    "content": "import { EditorView } from '@codemirror/view'\nimport { vsCodeDark } from '@fsegurai/codemirror-theme-vscode-dark'\nimport { vsCodeLight } from '@fsegurai/codemirror-theme-vscode-light'\n\nconst customStyles = EditorView.theme({\n  // 垂直居中\n  '.cm-gutterElement': {\n    display: `flex`,\n    justifyContent: `right`,\n    alignItems: `center`,\n  },\n})\n\nexport function lightTheme() {\n  return [vsCodeLight, customStyles]\n}\n\nexport function darkTheme() {\n  return [vsCodeDark, customStyles]\n}\n\n// 根据主题模式获取主题扩展\nexport function theme(isDark: boolean) {\n  return isDark ? darkTheme() : lightTheme()\n}\n"
  },
  {
    "path": "packages/shared/src/global.d.ts",
    "content": "declare module '*.css?raw' {\n  const content: string\n  export default content\n}\n\ndeclare module '*.txt?raw' {\n  const content: string\n  export default content\n}\n"
  },
  {
    "path": "packages/shared/src/index.ts",
    "content": "export * from './assets'\nexport * from './configs'\nexport * from './constants'\nexport * from './editor'\nexport * from './types'\nexport * from './utils'\n"
  },
  {
    "path": "packages/shared/src/types/ai-services-types.ts",
    "content": "export interface ServiceOption {\n  value: string\n  label: string\n  endpoint: string\n  models: string[]\n}\n\nexport interface ImageServiceOption {\n  value: string\n  label: string\n  endpoint: string\n  models: string[]\n}\n"
  },
  {
    "path": "packages/shared/src/types/common.ts",
    "content": "import type { Token } from 'marked'\n\n/**\n * 渲染器选项（新主题系统）\n * 主题样式通过 CSS 注入，不再通过 JS 对象传递\n * 注意：isUseIndent 和 isUseJustify 现在通过 CSS 变量系统处理，不需要传递给渲染器\n */\nexport interface IOpts {\n  legend?: string\n  citeStatus?: boolean\n  countStatus?: boolean\n  isMacCodeBlock?: boolean\n  isShowLineNumber?: boolean\n  themeMode?: 'light' | 'dark'\n}\n\nexport interface IConfigOption<VT = string> {\n  label: string\n  value: VT\n  desc: string\n}\n\n/**\n * Options for the `markedAlert` extension.\n */\nexport interface AlertOptions {\n  className?: string\n  variants?: AlertVariantItem[]\n  withoutStyle?: boolean\n}\n\n/**\n * Configuration for an alert type.\n */\nexport interface AlertVariantItem {\n  type: string\n  icon: string\n  title?: string\n  titleClassName?: string\n}\n\n/**\n * Represents an alert token.\n */\nexport interface Alert {\n  type: `alert`\n  meta: {\n    className: string\n    variant: string\n    icon: string\n    title: string\n    titleClassName: string\n  }\n  raw: string\n  text: string\n  tokens: Token[]\n}\n\nexport interface PostAccount {\n  avatar?: string\n  displayName: string\n  home: string\n  icon: string\n  supportTypes?: string[]\n  title: string\n  type: string\n  uid: string\n  checked: boolean\n  loggedIn?: boolean\n  isChecking?: boolean\n  status?: string\n  error?: string\n}\n\nexport interface Post {\n  title: string\n  desc: string\n  thumb: string\n  content: string\n  markdown: string\n  accounts: PostAccount[]\n}\n"
  },
  {
    "path": "packages/shared/src/types/index.ts",
    "content": "export * from './ai-services-types'\nexport * from './common'\nexport * from './renderer-types'\nexport * from './template'\n"
  },
  {
    "path": "packages/shared/src/types/raw-imports.d.ts",
    "content": "/**\n * 类型声明：支持 ?raw 后缀的文件导入\n */\n\ndeclare module '*.css?raw' {\n  const content: string\n  export default content\n}\n\ndeclare module '*.txt?raw' {\n  const content: string\n  export default content\n}\n"
  },
  {
    "path": "packages/shared/src/types/renderer-types.ts",
    "content": "import type { ReadTimeResults } from '../utils/readingTime'\nimport type { IOpts } from './common'\n\nexport interface RendererAPI {\n  /* —— 生命周期 —— */\n  reset: (newOpts: Partial<IOpts>) => void\n  setOptions: (newOpts: Partial<IOpts>) => void\n  getOpts: () => IOpts\n\n  /* —— Markdown 处理 —— */\n  parseFrontMatterAndContent: (markdown: string) => {\n    yamlData: Record<string, any>\n    markdownContent: string\n    readingTime: ReadTimeResults\n  }\n\n  /* —— HTML 拼装 —— */\n  buildReadingTime: (reading: ReadTimeResults) => string\n  buildFootnotes: () => string\n  buildAddition: () => string\n  createContainer: (html: string) => string\n}\n"
  },
  {
    "path": "packages/shared/src/types/template.ts",
    "content": "/**\n * 模板管理相关类型定义\n */\n\n/**\n * 模板接口\n */\nexport interface Template {\n  /** 模板唯一标识符 (UUID) */\n  id: string\n  /** 模板名称 */\n  name: string\n  /** 模板内容 (Markdown) */\n  content: string\n  /** 模板描述（可选） */\n  description?: string\n  /** 创建时间戳 */\n  createdAt: number\n  /** 最后修改时间戳 */\n  updatedAt: number\n  /** 模板标签（可选） */\n  tags?: string[]\n}\n\n/**\n * 创建模板的参数\n */\nexport interface CreateTemplateParams {\n  /** 模板名称 */\n  name: string\n  /** 模板内容 */\n  content: string\n  /** 模板描述（可选） */\n  description?: string\n  /** 模板标签（可选） */\n  tags?: string[]\n}\n\n/**\n * 更新模板的参数\n */\nexport interface UpdateTemplateParams {\n  /** 模板名称（可选） */\n  name?: string\n  /** 模板内容（可选） */\n  content?: string\n  /** 模板描述（可选） */\n  description?: string\n  /** 模板标签（可选） */\n  tags?: string[]\n}\n"
  },
  {
    "path": "packages/shared/src/utils/basicHelpers.ts",
    "content": "/**\n * 清理文件标题，移除非法字符\n * @param title - 原始标题\n * @returns 清理后的安全标题\n */\nexport function sanitizeTitle(title: string) {\n  const MAX_FILENAME_LENGTH = 100\n\n  // Windows 禁止字符，包含所有平台非法字符合集\n  const INVALID_CHARS = /[\\\\/:*?\"<>|]/g\n\n  if (!INVALID_CHARS.test(title) && title.length <= MAX_FILENAME_LENGTH) {\n    return title.trim() || `untitled`\n  }\n\n  const replaced = title.replace(INVALID_CHARS, `_`).trim()\n  const safe = replaced.length > MAX_FILENAME_LENGTH\n    ? replaced.slice(0, MAX_FILENAME_LENGTH)\n    : replaced\n\n  return safe || `untitled`\n}\n\n/**\n * 移除左边多余空格\n * @param str - 要处理的字符串\n * @returns 处理后的字符串\n */\nexport function removeLeft(str: string) {\n  const lines = str.split(`\\n`)\n  // 获取应该删除的空白符数量\n  const minSpaceNum = lines\n    .filter(item => item.trim())\n    .map(item => (item.match(/(^\\s+)?/)!)[0].length)\n    .sort((a, b) => a - b)[0]\n  // 删除空白符\n  return lines.map(item => item.slice(minSpaceNum)).join(`\\n`)\n}\n\n/**\n * 检查图片文件是否符合要求\n * @param file - 要检查的文件\n * @returns 检查结果\n */\nexport function checkImage(file: File) {\n  // 检查文件名后缀\n  const isValidSuffix = /\\.(?:gif|pjp|jfif|jpe|pjpeg|jpe?g|png|webp)$/i.test(file.name)\n  if (!isValidSuffix) {\n    return {\n      ok: false,\n      msg: `请上传 GIF/JPG/JPEG/PNG/WEBP 格式的图片`,\n    }\n  }\n\n  // 检查文件大小\n  const maxSizeMB = 10\n  if (file.size > maxSizeMB * 1024 * 1024) {\n    return {\n      ok: false,\n      msg: `由于公众号限制，图片大小不能超过 ${maxSizeMB}M`,\n    }\n  }\n\n  return { ok: true, msg: `` }\n}\n"
  },
  {
    "path": "packages/shared/src/utils/fetch.ts",
    "content": "import axios from 'axios'\n\n// 创建axios实例\nconst service = axios.create({\n  baseURL: ``,\n  timeout: 30 * 1000, // 请求超时时间\n})\n\nservice.interceptors.request.use(\n  (config) => {\n    if (/^(?:post|put|delete)$/i.test(`${config.method}`)) {\n      if (config.data && config.data.upload) {\n        config.headers[`Content-Type`] = `multipart/form-data`\n      }\n    }\n    return config\n  },\n  (error) => {\n    Promise.reject(error)\n  },\n)\n\nservice.interceptors.response.use(\n  (res) => {\n    return res.data ? res.data : Promise.reject(res)\n  },\n  error => Promise.reject(error),\n)\n\nexport default service\n"
  },
  {
    "path": "packages/shared/src/utils/fileHelpers.ts",
    "content": "// @ts-expect-error - prettier v2.8.8 doesn't have proper TypeScript declarations\nimport parserBabel from 'prettier/parser-babel'\n// @ts-expect-error - prettier v2.8.8 doesn't have proper TypeScript declarations\nimport parserMarkdown from 'prettier/parser-markdown'\n// @ts-expect-error - prettier v2.8.8 doesn't have proper TypeScript declarations\nimport parserPostcss from 'prettier/parser-postcss'\n// @ts-expect-error - prettier v2.8.8 doesn't have proper TypeScript declarations\nimport * as prettier from 'prettier/standalone'\n\n/**\n * 通用文件下载函数\n * @param content - 文件内容\n * @param filename - 文件名\n * @param mimeType - MIME 类型，默认为 text/plain\n */\nexport function downloadFile(content: string, filename: string, mimeType: string = `text/plain`) {\n  if (typeof document === `undefined`) {\n    throw new TypeError(`downloadFile can only be used in browser environment`)\n  }\n\n  const downLink = document.createElement(`a`)\n  downLink.download = filename\n  downLink.style.display = `none`\n\n  // 检查是否是 base64 data URL\n  if (content.startsWith(`data:`)) {\n    downLink.href = content\n  }\n  else if (mimeType === `text/html`) {\n    downLink.href = `data:text/html;charset=utf-8,${encodeURIComponent(content)}`\n  }\n  else {\n    const blob = new Blob([content], { type: mimeType })\n    downLink.href = URL.createObjectURL(blob)\n  }\n\n  document.body.appendChild(downLink)\n  downLink.click()\n  document.body.removeChild(downLink)\n\n  // 如果是 blob URL，释放内存\n  if (!content.startsWith(`data:`) && mimeType !== `text/html`) {\n    URL.revokeObjectURL(downLink.href)\n  }\n}\n\n/**\n * 将文件转换为 Base64 格式\n * @param file - 要转换的文件\n * @returns Base64 字符串的 Promise\n */\nexport function toBase64(file: Blob): Promise<string> {\n  return new Promise<string>((resolve, reject) => {\n    const reader = new FileReader()\n    reader.readAsDataURL(file)\n    reader.onload = () => resolve((reader.result as string).split(`,`).pop()!)\n    reader.onerror = error => reject(error)\n  })\n}\n\n/**\n * 根据数据生成 Markdown 表格\n * @param options - 表格选项\n * @param options.data - 表格数据对象\n * @param options.rows - 表格行数\n * @param options.cols - 表格列数\n * @returns 生成的 Markdown 表格字符串\n */\nexport function createTable({ data, rows, cols }: {\n  data: { [k: string]: string }\n  rows: number\n  cols: number\n}): string {\n  let table = ``\n  for (let i = 0; i < rows + 2; ++i) {\n    table += `| `\n    const currRow = []\n    for (let j = 0; j < cols; ++j) {\n      const rowIdx = i > 1 ? i - 1 : i\n      currRow.push(i === 1 ? `---` : data[`k_${rowIdx}_${j}`] || `     `)\n    }\n    table += currRow.join(` | `)\n    table += ` |\\n`\n  }\n\n  return table\n}\n\n/**\n * 格式化文档内容\n * @param content - 要格式化的内容\n * @param type - 内容类型，决定使用的解析器，默认为 'markdown'\n * @returns 格式化后的内容\n */\nexport async function formatDoc(content: string, type: `markdown` | `css` | `javascript` = `markdown`): Promise<string> {\n  const parser = type === `css` ? `css` : type === `javascript` ? `babel` : `markdown`\n  const plugins = type === `css` ? [parserPostcss] : type === `javascript` ? [parserBabel] : [parserMarkdown, parserBabel]\n\n  return await prettier.format(content, {\n    parser,\n    plugins,\n    // prettier v2.8.8 配置选项\n    printWidth: 80,\n    tabWidth: 2,\n    useTabs: false,\n    semi: false,\n    singleQuote: true,\n    quoteProps: `as-needed`,\n    trailingComma: `es5`,\n    bracketSpacing: true,\n    bracketSameLine: false,\n    arrowParens: `avoid`,\n    proseWrap: `preserve`,\n    htmlWhitespaceSensitivity: `css`,\n    endOfLine: `lf`,\n  })\n}\n"
  },
  {
    "path": "packages/shared/src/utils/index.ts",
    "content": "export * from './basicHelpers'\nexport * from './fetch'\nexport * from './fileHelpers'\nexport * from './readingTime'\nexport * from './tokenTools'\n"
  },
  {
    "path": "packages/shared/src/utils/readingTime.ts",
    "content": "export interface ReadingTimeOptions {\n  wordBound?: (char: string) => boolean\n  wordsPerMinute?: number\n}\n\nexport interface ReadTimeResults {\n  text: string\n  time: number\n  words: number\n  minutes: number\n}\n\nexport type IOptions = ReadingTimeOptions\nexport type IReadTimeResults = ReadTimeResults\n\nfunction codeIsInRanges(number: number, ranges: ReadonlyArray<readonly [number, number]>): boolean {\n  return ranges.some(([lowerBound, upperBound]) => lowerBound <= number && number <= upperBound)\n}\n\nfunction isCJK(char: string | undefined): boolean {\n  if (typeof char !== `string`) {\n    return false\n  }\n\n  const charCode = char.charCodeAt(0)\n  return codeIsInRanges(charCode, [\n    [0x3040, 0x309F],\n    [0x4E00, 0x9FFF],\n    [0xAC00, 0xD7A3],\n    [0x20000, 0x2EBE0],\n  ])\n}\n\nfunction isAnsiWordBound(char: string | undefined): boolean {\n  return typeof char === `string` && ` \\n\\r\\t`.includes(char)\n}\n\nfunction isPunctuation(char: string | undefined): boolean {\n  if (typeof char !== `string`) {\n    return false\n  }\n\n  const charCode = char.charCodeAt(0)\n  return codeIsInRanges(charCode, [\n    [0x21, 0x2F],\n    [0x3A, 0x40],\n    [0x5B, 0x60],\n    [0x7B, 0x7E],\n    [0x3000, 0x303F],\n    [0xFF00, 0xFFEF],\n  ])\n}\n\nexport default function readingTime(text: string, options: ReadingTimeOptions = {}): ReadTimeResults {\n  let words = 0\n  let start = 0\n  let end = text.length - 1\n\n  const wordsPerMinute = options.wordsPerMinute || 200\n  const isWordBound = options.wordBound || isAnsiWordBound\n\n  while (isWordBound(text[start])) start++\n  while (isWordBound(text[end])) end--\n\n  const normalizedText = `${text}\\n`\n\n  for (let index = start; index <= end; index++) {\n    if (\n      isCJK(normalizedText[index])\n      || (!isWordBound(normalizedText[index])\n        && (isWordBound(normalizedText[index + 1]) || isCJK(normalizedText[index + 1])))\n    ) {\n      words++\n    }\n\n    if (isCJK(normalizedText[index])) {\n      while (\n        index <= end\n        && (isPunctuation(normalizedText[index + 1]) || isWordBound(normalizedText[index + 1]))\n      ) {\n        index++\n      }\n    }\n  }\n\n  const minutes = words / wordsPerMinute\n  const time = Math.round(minutes * 60 * 1000)\n  const displayed = Math.ceil(Number(minutes.toFixed(2)))\n\n  return {\n    text: `${displayed} min read`,\n    minutes,\n    time,\n    words,\n  }\n}\n"
  },
  {
    "path": "packages/shared/src/utils/tokenTools.ts",
    "content": "export function utf16to8(str: string) {\n  let out = ``\n  const len = str.length\n\n  for (let i = 0; i < len; i++) {\n    const c = str.charCodeAt(i)\n\n    if (c >= 0x0001 && c <= 0x007F) {\n      out += str.charAt(i)\n    }\n    else if (c > 0x07FF) {\n      out += String.fromCharCode(0xE0 | ((c >> 12) & 0x0F))\n      out += String.fromCharCode(0x80 | ((c >> 6) & 0x3F))\n      out += String.fromCharCode(0x80 | (c & 0x3F))\n    }\n    else {\n      out += String.fromCharCode(0xC0 | ((c >> 6) & 0x1F))\n      out += String.fromCharCode(0x80 | (c & 0x3F))\n    }\n  }\n\n  return out\n}\n\nexport function utf8to16(str: string) {\n  let out = ``\n  let i = 0\n  const len = str.length\n\n  while (i < len) {\n    const c = str.charCodeAt(i++)\n    let char2, char3\n\n    switch (c >> 4) {\n      case 0:\n      case 1:\n      case 2:\n      case 3:\n      case 4:\n      case 5:\n      case 6:\n      case 7:\n        // 0xxxxxxx\n        out += str.charAt(i - 1)\n        break\n      case 12:\n      case 13:\n        // 110x xxxx 10xx xxxx\n        char2 = str.charCodeAt(i++)\n        out += String.fromCharCode(((c & 0x1F) << 6) | (char2 & 0x3F))\n        break\n      case 14:\n        // 1110 xxxx 10xx xxxx 10xx xxxx\n        char2 = str.charCodeAt(i++)\n        char3 = str.charCodeAt(i++)\n        out += String.fromCharCode(\n          ((c & 0x0F) << 12) | ((char2 & 0x3F) << 6) | (char3 & 0x3F),\n        )\n        break\n    }\n  }\n\n  return out\n}\n\nconst base64EncodeChars = `ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_`\n\nconst base64DecodeChars = [\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  62,\n  -1,\n  -1,\n  -1,\n  63,\n  52,\n  53,\n  54,\n  55,\n  56,\n  57,\n  58,\n  59,\n  60,\n  61,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  0,\n  1,\n  2,\n  3,\n  4,\n  5,\n  6,\n  7,\n  8,\n  9,\n  10,\n  11,\n  12,\n  13,\n  14,\n  15,\n  16,\n  17,\n  18,\n  19,\n  20,\n  21,\n  22,\n  23,\n  24,\n  25,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n  26,\n  27,\n  28,\n  29,\n  30,\n  31,\n  32,\n  33,\n  34,\n  35,\n  36,\n  37,\n  38,\n  39,\n  40,\n  41,\n  42,\n  43,\n  44,\n  45,\n  46,\n  47,\n  48,\n  49,\n  50,\n  51,\n  -1,\n  -1,\n  -1,\n  -1,\n  -1,\n]\n\nexport function base64encode(str: string) {\n  let out = ``\n  let i = 0\n  const len = str.length\n\n  while (i < len) {\n    const c1 = str.charCodeAt(i++) & 0xFF\n\n    if (i === len) {\n      out += base64EncodeChars.charAt(c1 >> 2)\n      out += base64EncodeChars.charAt((c1 & 0x3) << 4)\n      out += `==`\n      break\n    }\n\n    const c2 = str.charCodeAt(i++)\n\n    if (i === len) {\n      out += base64EncodeChars.charAt(c1 >> 2)\n      out += base64EncodeChars.charAt(((c1 & 0x3) << 4) | ((c2 & 0xF0) >> 4))\n      out += base64EncodeChars.charAt((c2 & 0xF) << 2)\n      out += `=`\n      break\n    }\n\n    const c3 = str.charCodeAt(i++)\n\n    out += base64EncodeChars.charAt(c1 >> 2)\n    out += base64EncodeChars.charAt(((c1 & 0x3) << 4) | ((c2 & 0xF0) >> 4))\n    out += base64EncodeChars.charAt(((c2 & 0xF) << 2) | ((c3 & 0xC0) >> 6))\n    out += base64EncodeChars.charAt(c3 & 0x3F)\n  }\n\n  return out\n}\n\nexport function base64decode(str: string) {\n  let c1, c2, c3, c4\n  let i = 0\n  const len = str.length\n  let out = ``\n\n  while (i < len) {\n    /* c1 */\n    do {\n      c1 = base64DecodeChars[str.charCodeAt(i++) & 0xFF]\n    } while (i < len && c1 === -1)\n    if (c1 === -1)\n      break\n\n    /* c2 */\n    do {\n      c2 = base64DecodeChars[str.charCodeAt(i++) & 0xFF]\n    } while (i < len && c2 === -1)\n    if (c2 === -1)\n      break\n\n    out += String.fromCharCode((c1 << 2) | ((c2 & 0x30) >> 4))\n\n    /* c3 */\n    do {\n      c3 = str.charCodeAt(i++) & 0xFF\n      if (c3 === 61)\n        return out\n      c3 = base64DecodeChars[c3]\n    } while (i < len && c3 === -1)\n    if (c3 === -1)\n      break\n\n    out += String.fromCharCode(((c2 & 0xF) << 4) | ((c3 & 0x3C) >> 2))\n\n    /* c4 */\n    do {\n      c4 = str.charCodeAt(i++) & 0xFF\n      if (c4 === 61)\n        return out\n      c4 = base64DecodeChars[c4]\n    } while (i < len && c4 === -1)\n    if (c4 === -1)\n      break\n\n    out += String.fromCharCode(((c3 & 0x03) << 6) | c4)\n  }\n\n  return out\n}\n\nexport function safe64(base64: string) {\n  base64 = base64.replace(/\\+/g, `-`)\n  base64 = base64.replace(/\\//g, `_`)\n  return base64\n}\n"
  },
  {
    "path": "packages/shared/tsconfig.json",
    "content": "{\n  \"extends\": \"@md/config/tsconfig.base.json\",\n  \"include\": [\n    \"src/**/*\"\n  ],\n  \"exclude\": [\"node_modules\"]\n}\n"
  },
  {
    "path": "patches/@codemirror__view@6.40.0.patch",
    "content": "diff --git a/dist/index.d.ts b/dist/index.d.ts\n--- a/dist/index.d.ts\n+++ b/dist/index.d.ts\n@@ -515,7 +515,7 @@ declare class ViewPlugin<V extends PluginValue, Arg = undefined> {\n         new (view: EditorView, arg: Arg): V;\n     }, spec?: PluginSpec<V>): ViewPlugin<V, Arg>;\n }\n-interface MeasureRequest<T> {\n+export interface MeasureRequest<T> {\n     /**\n     Called in a DOM read phase to gather information that requires\n     DOM layout. Should _not_ mutate the document.\ndiff --git a/dist/index.js b/dist/index.js\n--- a/dist/index.js\n+++ b/dist/index.js\n@@ -9093,7 +9093,8 @@ function runHandlers(map, event, view, scope) {\n             // Ctrl-Alt may be used for AltGr on Windows\n             !(browser.windows && event.ctrlKey && event.altKey) &&\n             // Alt-combinations on macOS tend to be typed characters\n-            !(browser.mac && event.altKey && !(event.ctrlKey || event.metaKey)) &&\n+            // 但 Alt+Shift 组合应该被视为快捷键而不是特殊字符输入\n+            !(browser.mac && event.altKey && !event.shiftKey && !(event.ctrlKey || event.metaKey)) &&\n             (baseName = base[event.keyCode]) && baseName != name) {\n             if (runFor(scopeObj[prefix + modifiers(baseName, event, true)])) {\n                 handled = true;\n"
  },
  {
    "path": "pnpm-workspace.yaml",
    "content": "shellEmulator: true\n\ntrustPolicy: no-downgrade\npackages:\n  - apps/*\n  - packages/*\n\noverrides:\n  '@codemirror/state': 6.6.0\n  '@codemirror/view': 6.40.0\n  ajv@^8: ^8.18.0\n  bn.js: '>=5.2.3'\n  bn.js@^5: ^5.2.3\n  confbox: 0.2.2\n  core-js: ^3.48.0\n  cross-spawn@<7: ^7.0.6\n  crypto-browserify: npm:empty-module@0.0.2\n  dompurify@<3.3.2: ^3.3.2\n  flatted@<3.4.0: ^3.4.0\n  glob@<8: ^10.5.0\n  glob@^10: ^10.5.0\n  inflight: npm:@hishprorg/voluptates-laborum@^2.0.0\n  juice: 11.0.3\n  lodash-es: ^4.17.23\n  markdown-it: ^14.1.1\n  minimatch: ^10.2.4\n  minimatch@^10: ^10.2.4\n  minimatch@^5: ^10.2.4\n  minimatch@^9: ^10.2.4\n  prettier: 2.8.8\n  qs: ^6.14.2\n  querystring: npm:qs@^6.14.2\n  serialize-javascript@<7.0.3: ^7.0.4\n  source-map@0.8.0-beta.0: 0.7.4\n  underscore@<1.13.8: 1.13.8\n  undici@^6: ^6.24.0\n  undici@^7: ^7.24.0\n  workbox-build>source-map: 0.7.4\n  yauzl: ^3.2.1\n\npatchedDependencies:\n  '@codemirror/view@6.40.0': patches/@codemirror__view@6.40.0.patch\n"
  },
  {
    "path": "scripts/build-base-image.sh",
    "content": "#!/bin/bash\n\nRELEASE_DIR='./docker';\nREPO_NAME='doocs/md'\n\nfor app_ver in $RELEASE_DIR/*; do\n\n    if [ -f \"$app_ver/Dockerfile.base\" ]; then\n\n        tag=$(echo $app_ver | cut -b 10-);\n        echo \"Build: $tag\";\n        set -a\n            . \"$app_ver/.env\"\n        set +a\n\n        echo $app_ver\n        echo \"VER_APP: $VER_APP\"\n        echo \"VER_NGX: $VER_NGX\"\n        echo \"VER_GOLANG: $VER_GOLANG\"\n        echo \"VER_ALPINE: $VER_ALPINE\"\n\n        docker build --build-arg VER_APP=$VER_APP -f \"$app_ver/Dockerfile.base\" -t \"$REPO_NAME:${VER_APP}-assets\" \"$app_ver\"\n    fi\n\ndone"
  },
  {
    "path": "scripts/build-multiarch.sh",
    "content": "#!/bin/bash\n\nset -euo pipefail\n\nRELEASE_DIR=\"./docker\"\nREPO_NAME=\"doocs/md\"\nPLATFORMS=\"linux/amd64,linux/arm64\"\n\necho \"🔧 Multi-arch Docker build started...\"\necho \"📁 Scanning directory: $RELEASE_DIR\"\n\nfor app_ver in \"$RELEASE_DIR\"/*; do\n    [ -d \"$app_ver\" ] || continue\n\n    tag=$(basename \"$app_ver\")\n    env_file=\"$app_ver/.env\"\n\n    if [ ! -f \"$env_file\" ]; then\n        echo \"⚠️ Skipping $tag - missing .env file\"\n        continue\n    fi\n\n    set -a\n    . \"$env_file\"\n    set +a\n\n    echo \"🚀 Building images for version: $tag\"\n    echo \"    VER_APP: $VER_APP\"\n    echo \"    VER_NGX: $VER_NGX\"\n    echo \"    VER_GOLANG: $VER_GOLANG\"\n    echo \"    VER_ALPINE: $VER_ALPINE\"\n\n    # 构建 base 镜像\n    if [ -f \"$app_ver/Dockerfile.base\" ]; then\n        echo \"📦 Building base image: $REPO_NAME:${VER_APP}-assets\"\n        docker buildx build \\\n            --platform \"$PLATFORMS\" \\\n            --build-arg VER_APP=\"$VER_APP\" \\\n            -f \"$app_ver/Dockerfile.base\" \\\n            -t \"$REPO_NAME:${VER_APP}-assets\" \\\n            --push \\\n            \"$app_ver\"\n    fi\n\n    # 构建 nginx 镜像\n    if [ -f \"$app_ver/Dockerfile.nginx\" ]; then\n        echo \"📦 Building nginx image: $REPO_NAME:${VER_APP}-nginx\"\n        docker buildx build \\\n            --platform \"$PLATFORMS\" \\\n            --build-arg VER_APP=\"$VER_APP\" \\\n            --build-arg VER_NGX=\"$VER_NGX\" \\\n            -f \"$app_ver/Dockerfile.nginx\" \\\n            -t \"$REPO_NAME:${VER_APP}-nginx\" \\\n            --push \\\n            \"$app_ver\"\n    fi\n\n    # 构建 standalone 镜像\n    if [ -f \"$app_ver/Dockerfile.standalone\" ]; then\n        echo \"📦 Building standalone image: $REPO_NAME:${VER_APP}\"\n        docker buildx build \\\n            --platform \"$PLATFORMS\" \\\n            --build-arg VER_APP=\"$VER_APP\" \\\n            --build-arg VER_NGX=\"$VER_NGX\" \\\n            -f \"$app_ver/Dockerfile.standalone\" \\\n            -t \"$REPO_NAME:${VER_APP}\" \\\n            --push \\\n            \"$app_ver\"\n    fi\n\n    # 构建 static 镜像\n    if [ -f \"$app_ver/Dockerfile.static\" ]; then\n        echo \"📦 Building static image: $REPO_NAME:${VER_APP}-static\"\n        docker buildx build \\\n            --platform \"$PLATFORMS\" \\\n            --build-arg VER_APP=\"$VER_APP\" \\\n            --build-arg VER_NGX=\"$VER_NGX\" \\\n            -f \"$app_ver/Dockerfile.static\" \\\n            -t \"$REPO_NAME:${VER_APP}-static\" \\\n            --push \\\n            \"$app_ver\"\n    fi\n\n    echo \"✅ Completed version: $tag\"\ndone\n\necho \"🎉 All images built and pushed successfully.\"\n"
  },
  {
    "path": "scripts/build-nginx.sh",
    "content": "#!/bin/bash\n\nRELEASE_DIR='./docker';\nREPO_NAME='doocs/md'\n\nfor app_ver in $RELEASE_DIR/*; do\n\n    if [ -f \"$app_ver/Dockerfile.nginx\" ]; then\n\n        tag=$(echo $app_ver | cut -b 10-);\n        echo \"Build: $tag\";\n        set -a\n            . \"$app_ver/.env\"\n        set +a\n\n        echo $app_ver\n        echo \"VER_APP: $VER_APP\"\n        echo \"VER_NGX: $VER_NGX\"\n        echo \"VER_GOLANG: $VER_GOLANG\"\n        echo \"VER_ALPINE: $VER_ALPINE\"\n\n        docker build --build-arg VER_APP=$VER_APP --build-arg VER_NGX=$VER_NGX -f \"$app_ver/Dockerfile.nginx\" -t \"$REPO_NAME:${VER_APP}-nginx\" \"$app_ver\"\n    fi\n\ndone"
  },
  {
    "path": "scripts/build-standalone.sh",
    "content": "#!/bin/bash\n\nRELEASE_DIR='./docker';\nREPO_NAME='doocs/md'\n\nfor app_ver in $RELEASE_DIR/*; do\n\n    if [ -f \"$app_ver/Dockerfile.standalone\" ]; then\n\n        tag=$(echo $app_ver | cut -b 10-);\n        echo \"Build: $tag\";\n        set -a\n            . \"$app_ver/.env\"\n        set +a\n\n        echo $app_ver\n        echo \"VER_APP: $VER_APP\"\n        echo \"VER_NGX: $VER_NGX\"\n        echo \"VER_GOLANG: $VER_GOLANG\"\n        echo \"VER_ALPINE: $VER_ALPINE\"\n\n        docker build --build-arg VER_APP=$VER_APP --build-arg VER_NGX=$VER_NGX -f \"$app_ver/Dockerfile.standalone\" -t \"$REPO_NAME:${VER_APP}\" \"$app_ver\"\n    fi\n\ndone"
  },
  {
    "path": "scripts/build-static.sh",
    "content": "#!/bin/bash\n\nRELEASE_DIR='./docker';\nREPO_NAME='doocs/md'\n\nfor app_ver in $RELEASE_DIR/*; do\n\n    if [ -f \"$app_ver/Dockerfile.static\" ]; then\n\n        tag=$(echo $app_ver | cut -b 10-);\n        echo \"Build: $tag\";\n        set -a\n            . \"$app_ver/.env\"\n        set +a\n\n        echo $app_ver\n        echo \"VER_APP: $VER_APP\"\n        echo \"VER_NGX: $VER_NGX\"\n        echo \"VER_GOLANG: $VER_GOLANG\"\n        echo \"VER_ALPINE: $VER_ALPINE\"\n\n        docker build --build-arg VER_APP=$VER_APP --build-arg VER_NGX=$VER_NGX -f \"$app_ver/Dockerfile.static\" -t \"$REPO_NAME:${VER_APP}-static\" \"$app_ver\"\n    fi\n\ndone"
  },
  {
    "path": "scripts/download-utools-libs.mjs",
    "content": "#!/usr/bin/env node\n/**\n * 下载 uTools 插件所需的本地资源\n * 用于替代 CDN 加载的远程资源\n */\nimport { createWriteStream, existsSync } from 'node:fs'\nimport { mkdir } from 'node:fs/promises'\nimport { get as httpsGet } from 'node:https'\nimport path from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst __dirname = path.dirname(fileURLToPath(import.meta.url))\nconst rootDir = path.resolve(__dirname, `..`)\nconst libsDir = path.join(rootDir, `apps`, `web`, `public`, `static`, `libs`)\n\nconst resources = [\n  {\n    name: `MathJax`,\n    url: `https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/npm/mathjax@3/es5/tex-svg.js`,\n    output: path.join(libsDir, `mathjax`, `tex-svg.js`),\n  },\n  {\n    name: `Mermaid`,\n    url: `https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/npm/mermaid@11/dist/mermaid.min.js`,\n    output: path.join(libsDir, `mermaid`, `mermaid.min.js`),\n  },\n  {\n    name: `WeChat Sync`,\n    url: `https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/wechatsync/article-syncjs@latest/dist/main.js`,\n    output: path.join(libsDir, `article-syncjs`, `main.js`),\n  },\n]\n\nasync function downloadFile(url, outputPath) {\n  const dir = path.dirname(outputPath)\n  await mkdir(dir, { recursive: true })\n\n  return new Promise((resolve, reject) => {\n    const file = createWriteStream(outputPath)\n\n    httpsGet(url, (response) => {\n      if (response.statusCode === 301 || response.statusCode === 302) {\n        // 处理重定向\n        downloadFile(response.headers.location, outputPath).then(resolve).catch(reject)\n        return\n      }\n\n      if (response.statusCode !== 200) {\n        reject(new Error(`Failed to download ${url}: ${response.statusCode}`))\n        return\n      }\n\n      response.pipe(file)\n\n      file.on(`finish`, () => {\n        file.close()\n        resolve()\n      })\n\n      file.on(`error`, (err) => {\n        file.close()\n        reject(err)\n      })\n    }).on(`error`, reject)\n  })\n}\n\nasync function main() {\n  console.log(`> 开始下载 uTools 插件所需的本地资源...`)\n\n  for (const resource of resources) {\n    try {\n      if (existsSync(resource.output)) {\n        console.log(`✓ ${resource.name} 已存在，跳过下载`)\n        continue\n      }\n\n      console.log(`  正在下载 ${resource.name}...`)\n      await downloadFile(resource.url, resource.output)\n      console.log(`✓ ${resource.name} 下载完成`)\n    }\n    catch (error) {\n      console.error(`✗ ${resource.name} 下载失败:`, error.message)\n      throw error\n    }\n  }\n\n  console.log(`\\n✔ 所有资源下载完成`)\n}\n\nmain().catch((error) => {\n  console.error(error)\n  process.exitCode = 1\n})\n"
  },
  {
    "path": "scripts/package-utools.mjs",
    "content": "#!/usr/bin/env node\nimport { spawn } from 'node:child_process'\nimport fs from 'node:fs'\nimport { cp, mkdir, readFile, rm, writeFile, access } from 'node:fs/promises'\nimport path from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nimport archiver from 'archiver'\n\nconst __dirname = path.dirname(fileURLToPath(import.meta.url))\nconst rootDir = path.resolve(__dirname, `..`)\nconst utoolsDir = path.join(rootDir, `apps`, `utools`)\nconst distDir = path.join(utoolsDir, `dist`)\nconst releaseDir = path.join(utoolsDir, `release`)\nconst iconSource = path.join(rootDir, `public`, `mpmd`, `icon-256.png`)\nconst iconTarget = path.join(utoolsDir, `logo.png`)\nconst manifestPath = path.join(utoolsDir, `plugin.json`)\n\nfunction run(command, args, options = {}) {\n  return new Promise((resolve, reject) => {\n    const spawnOptions = {\n      stdio: `inherit`,\n      shell: true,\n      ...options,\n    }\n    const child = spawn(command, args, spawnOptions)\n    child.on(`close`, (code) => {\n      if (code === 0)\n        resolve(code)\n      else\n        reject(new Error(`${[command, ...args].join(` `)} exited with code ${code}`))\n    })\n    child.on(`error`, reject)\n  })\n}\n\nasync function ensureFileExists(filePath, friendlyName) {\n  try {\n    await access(filePath, fs.constants.F_OK)\n  }\n  catch (error) {\n    throw new Error(`${friendlyName ?? filePath} 不存在，请确认路径是否正确。`)\n  }\n}\n\nasync function main() {\n  const pkg = JSON.parse(await readFile(path.join(rootDir, `package.json`), `utf8`))\n  const version = pkg.version\n\n  console.log(`> 下载 uTools 插件所需的本地资源`)\n  await run(`node`, [path.join(__dirname, `download-utools-libs.mjs`)], { cwd: rootDir })\n\n  console.log(`> 构建 uTools 前端资源（version: ${version}）`)\n  await run(`pnpm`, [`--filter`, `@md/web`, `run`, `build:utools`], { cwd: rootDir })\n\n  await ensureFileExists(distDir, `apps/utools/dist`)\n  await ensureFileExists(manifestPath, `apps/utools/plugin.json`)\n  await ensureFileExists(iconSource, `public/mpmd/icon-256.png`)\n\n  const manifest = JSON.parse(await readFile(manifestPath, `utf8`))\n  manifest.version = version\n  await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + `\\n`, `utf8`)\n\n  await cp(iconSource, iconTarget)\n\n  await rm(releaseDir, { recursive: true, force: true })\n  await mkdir(releaseDir, { recursive: true })\n\n  const packageName = `md-utools-v${version}`\n  const packageRoot = path.join(releaseDir, packageName)\n  await rm(packageRoot, { recursive: true, force: true })\n  await mkdir(packageRoot, { recursive: true })\n\n  await cp(distDir, path.join(packageRoot, `dist`), { recursive: true })\n\n  for (const file of [`plugin.json`, `preload.js`, `logo.png`, `README.md`, `package.json`]) {\n    const source = path.join(utoolsDir, file)\n    await ensureFileExists(source, `apps/utools/${file}`)\n    await cp(source, path.join(packageRoot, file), { recursive: true })\n  }\n\n  const zipPath = path.join(releaseDir, `${packageName}.zip`)\n  await new Promise((resolve, reject) => {\n    const output = fs.createWriteStream(zipPath)\n    const archive = archiver(`zip`, { zlib: { level: 9 } })\n\n    output.on(`close`, resolve)\n    archive.on(`error`, reject)\n\n    archive.pipe(output)\n    archive.directory(packageRoot, false)\n    archive.finalize()\n  })\n\n  console.log(`✔ uTools 插件打包完成 => ${path.relative(rootDir, zipPath)}`)\n}\n\nmain().catch((error) => {\n  console.error(error)\n  process.exitCode = 1\n})\n"
  },
  {
    "path": "scripts/push-images.sh",
    "content": "#!/bin/bash\n\nRELEASE_DIR='./docker';\nREPO_NAME='doocs/md'\n\nfor app_ver in $RELEASE_DIR/*; do\n\n    tag=$(echo $app_ver | cut -b 10-);\n\n    if [ -f \"$app_ver/Dockerfile.base\" ]; then\n        # 推送构建产物，方便其他的用户和爱好者进行二次封装\n        docker push $REPO_NAME:$tag-assets\n    fi\n\n    if [ -f \"$app_ver/Dockerfile.standalone\" ]; then\n        # 推送单个二进制的镜像\n        docker push $REPO_NAME:$tag\n    fi\n\n    if [ -f \"$app_ver/Dockerfile.nginx\" ]; then\n        # 推送使用 Nginx 的镜像\n        docker push $REPO_NAME:$tag-nginx\n    fi\n\n    if [ -f \"$app_ver/Dockerfile.static\" ]; then\n        # 推送使用 lipanski/docker-static-website 的镜像\n        docker push $REPO_NAME:$tag-static\n    fi\n\ndone"
  },
  {
    "path": "scripts/release.js",
    "content": "import fs from 'fs/promises';\nimport path from 'path';\nimport { fileURLToPath } from 'url';\nimport packageJson from '../packages/md-cli/package.json' assert { type: 'json' };\nimport child_process from 'child_process';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\n(async function () {\n  const execCommand = (arr) =>\n    (Array.isArray(arr) ? arr : [arr]).forEach((c) => {\n      try {\n        console.log(`start: ${c}...`);\n        console.log(child_process.execSync(c).toString(\"utf8\"));\n      } catch (error) {\n        console.log(\"\\x1B[31m%s\\x1B[0m\", error.stdout.toString());\n        process.exit(1);\n      }\n    });\n  const getNewVersion = (oldVersion, version = \"patch\") => {\n    // [<newversion> | major | minor | patch]\n    if (/^([0-9]+\\.*)+$/.test(version)) return version;\n    const types = [\"major\", \"minor\", \"patch\"];\n    const index = types.indexOf(version);\n    if (index >= 0) {\n      const versionArr = oldVersion.split(\".\");\n      versionArr[index] = Number(versionArr[index]) + 1;\n      return versionArr.map((e, i) => (i > index ? 0 : e)).join(\".\");\n    }\n    return getNewVersion(oldVersion);\n  };\n  const newVersionObj = {\n    version: getNewVersion(packageJson.version, process.argv[2]),\n  };\n  await fs.writeFile(\n    path.resolve(__dirname, \"../packages/md-cli/package.json\"),\n    JSON.stringify(Object.assign({}, packageJson, newVersionObj), null, 2) +\n      \"\\n\"\n  );\n  console.log(newVersionObj);\n  execCommand([\n    `git commit -a -m 'chore: update version cli-v${newVersionObj.version}'`,\n    `git tag cli-v${newVersionObj.version}`,\n    \"git push && git push --tags\",\n  ]);\n  console.log(\"\\x1B[32m%s\\x1B[0m\", \"发布完成，请关注 GitHub CI 构建\");\n})();\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"extends\": \"./packages/config/tsconfig.base.json\",\n  \"exclude\": [\"apps\", \"packages\"]\n}\n"
  },
  {
    "path": "zbpack.json",
    "content": "{\n  \"build_command\": \"pnpm web build\",\n  \"output_dir\": \"apps/web/dist\"\n}\n"
  }
]