[
  {
    "path": ".dockerignore",
    "content": "# Dependencies\nnode_modules\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# Git\n.git\n.gitignore\n.github\n\n# Documentation\nREADME.md\n*.md\n\n# Environment files\n.env\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\n# IDE\n.vscode\n.idea\n*.swp\n*.swo\n\n# OS\n.DS_Store\nThumbs.db\n\n# Testing\ncoverage\n.nyc_output\n*.lcov\n\n# Build outputs (exclude node_modules but keep build directory)\nclient/node_modules\nclient/coverage\n\n# Logs\nlogs\n*.log\n\n# Uploads (will be created at runtime)\nuploads\n\n# Docker\nDockerfile*\ndocker-compose*\n.dockerignore\n\n# CI/CD\n.github\n.gitlab-ci.yml\n.travis.yml\n\n# Temporary files\n*.tmp\n*.temp "
  },
  {
    "path": ".github/workflows/docker-publish.yml",
    "content": "name: Docker Publish\n\non:\n  push:\n    branches:\n      - main\n    tags:\n      - \"v*\"\n  workflow_dispatch:\n    inputs:\n      version:\n        description: \"Version to publish\"\n        required: true\n        default: \"latest\"\n      registry:\n        description: \"Registry to publish to\"\n        required: true\n        default: \"both\"\n        type: choice\n        options:\n          - ghcr\n          - dockerhub\n          - both\n      platforms:\n        description: \"Target platforms\"\n        required: true\n        default: \"linux/amd64,linux/arm64\"\n        type: choice\n        options:\n          - linux/amd64\n          - linux/amd64,linux/arm64\n\nconcurrency:\n  group: docker-publish-${{ github.ref }}\n  cancel-in-progress: true\n\njobs:\n  publish:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: write\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Login to GitHub Container Registry\n        if: github.event.inputs.registry == 'ghcr' || github.event.inputs.registry == 'both' || github.event_name == 'push'\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.PAT_TOKEN }}\n\n      - name: Login to Docker Hub\n        if: github.event.inputs.registry == 'dockerhub' || github.event.inputs.registry == 'both' || github.event_name == 'push'\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_USERNAME }}\n          password: ${{ secrets.DOCKER_PASSWORD }}\n\n      - name: Generate image names\n        id: image_names\n        run: |\n          OWNER=$(echo \"${{ github.repository_owner }}\" | tr '[:upper:]' '[:lower:]')\n          echo \"ghcr_image=ghcr.io/$OWNER/cloudimgs\" >> $GITHUB_OUTPUT\n          echo \"dockerhub_image=${{ secrets.DOCKER_USERNAME }}/cloudimgs\" >> $GITHUB_OUTPUT\n          echo \"Debug info:\"\n          echo \"- Event: ${{ github.event_name }}\"\n          echo \"- Ref: ${{ github.ref }}\"\n          echo \"- Tag: ${{ github.ref_name }}\"\n          echo \"- SHA: ${{ github.sha }}\"\n\n      - name: Extract metadata for GitHub Packages\n        id: meta-ghcr\n        if: github.event.inputs.registry == 'ghcr' || github.event.inputs.registry == 'both' || github.event_name == 'push'\n        uses: docker/metadata-action@v5\n        with:\n          images: ${{ steps.image_names.outputs.ghcr_image }}\n          tags: |\n            type=ref,event=branch\n            type=ref,event=pr\n            type=semver,pattern={{version}}\n            type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}\n\n      - name: Extract metadata for Docker Hub\n        id: meta-dockerhub\n        if: github.event.inputs.registry == 'dockerhub' || github.event.inputs.registry == 'both' || github.event_name == 'push'\n        uses: docker/metadata-action@v5\n        with:\n          images: ${{ steps.image_names.outputs.dockerhub_image }}\n          tags: |\n            type=ref,event=branch\n            type=ref,event=pr\n            type=semver,pattern={{version}}\n            type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}\n\n      - name: Build and push to GitHub Packages\n        if: github.event.inputs.registry == 'ghcr' || github.event.inputs.registry == 'both' || github.event_name == 'push'\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./Dockerfile.gha\n          push: true\n          tags: ${{ steps.meta-ghcr.outputs.tags }}\n          platforms: ${{ github.event.inputs.platforms || 'linux/amd64,linux/arm64' }}\n          build-args: |\n            NODE_OPTIONS=--max-old-space-size=4096\n\n      - name: Build and push to Docker Hub\n        if: github.event.inputs.registry == 'dockerhub' || github.event.inputs.registry == 'both' || github.event_name == 'push'\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./Dockerfile.gha\n          push: true\n          tags: ${{ steps.meta-dockerhub.outputs.tags }}\n          platforms: ${{ github.event.inputs.platforms || 'linux/amd64,linux/arm64' }}\n          build-args: |\n            NODE_OPTIONS=--max-old-space-size=4096\n\n      - name: Success notification\n        run: |\n          echo \"✅ Docker images published successfully!\"\n          if [[ \"${{ github.event.inputs.registry }}\" == \"ghcr\" || \"${{ github.event.inputs.registry }}\" == \"both\" || \"${{ github.event_name }}\" == \"push\" ]]; then\n            echo \"GitHub Packages: ${{ steps.image_names.outputs.ghcr_image }}\"\n            echo \"Tags: ${{ steps.meta-ghcr.outputs.tags }}\"\n            echo \"Debug - GitHub Packages tags:\"\n            echo \"${{ steps.meta-ghcr.outputs.tags }}\" | tr ',' '\\n' | sed 's/^/  /'\n          fi\n          if [[ \"${{ github.event.inputs.registry }}\" == \"dockerhub\" || \"${{ github.event.inputs.registry }}\" == \"both\" || \"${{ github.event_name }}\" == \"push\" ]]; then\n            echo \"Docker Hub: ${{ steps.image_names.outputs.dockerhub_image }}\"\n            echo \"Tags: ${{ steps.meta-dockerhub.outputs.tags }}\"\n            echo \"Debug - Docker Hub tags:\"\n            echo \"${{ steps.meta-dockerhub.outputs.tags }}\" | tr ',' '\\n' | sed 's/^/  /'\n          fi\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\n\non:\n  push:\n    tags:\n      - \"v*\"\n\npermissions:\n  contents: write\n\njobs:\n  release:\n    name: Create Release\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Build Changelog\n        id: build_changelog\n        uses: mikepenz/release-changelog-builder-action@v5\n        with:\n          commitMode: true\n          configurationJson: |\n            {\n              \"categories\": [\n                {\n                  \"title\": \"## 🚀 Features\",\n                  \"labels\": [\"feature\", \"feat\"]\n                },\n                {\n                  \"title\": \"## 🐛 Fixes\",\n                  \"labels\": [\"fix\", \"bug\"]\n                },\n                {\n                  \"title\": \"## 📦 Chore\",\n                  \"labels\": [\"chore\"]\n                },\n                {\n                  \"title\": \"## 💬 Other\",\n                  \"labels\": []\n                }\n              ],\n              \"label_extractor\": [\n                {\n                  \"pattern\": \"^(feat|fix|chore|bug|perf|refactor|test)(?:\\\\([^\\\\)]+\\\\))?: .+$\",\n                  \"target\": \"$1\"\n                }\n              ],\n              \"template\": \"#{{CHANGELOG}}\",\n              \"pr_template\": \"- #{{TITLE}} by @#{{AUTHOR}} in ##{{NUMBER}}\",\n              \"ignore_before\": \"v1.0.0\"\n            }\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Create Release\n        uses: softprops/action-gh-release@v2\n        with:\n          body: ${{ steps.build_changelog.outputs.changelog }}\n"
  },
  {
    "path": ".gitignore",
    "content": "# Dependencies\nnode_modules/\nclient/node_modules/\n\n# Production builds\nclient/build/\ndist/\n\n# Environment variables\n.env\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\n# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n\n# Coverage directory used by tools like istanbul\ncoverage/\n*.lcov\n\n# nyc test coverage\n.nyc_output\n\n# Dependency directories\njspm_packages/\n\n# Optional npm cache directory\n.npm\n\n# Optional eslint cache\n.eslintcache\n\n# Microbundle cache\n.rpt2_cache/\n.rts2_cache_cjs/\n.rts2_cache_es/\n.rts2_cache_umd/\n\n# Optional REPL history\n.node_repl_history\n\n# Output of 'npm pack'\n*.tgz\n\n# Yarn Integrity file\n.yarn-integrity\n\n# dotenv environment variables file\n.env.test\n\n# parcel-bundler cache (https://parceljs.org/)\n.cache\n.parcel-cache\n\n# Next.js build output\n.next\n\n# Nuxt.js build / generate output\n.nuxt\n\n# Gatsby files\n.cache/\n/public\n\n# Storybook build outputs\n.out\n.storybook-out\n\n# Temporary folders\ntmp/\ntemp/\n\n# Editor directories and files\n.vscode/\n.idea/\n*.swp\n*.swo\n*~\n\n# OS generated files\n.DS_Store\n.DS_Store?\n._*\n.Spotlight-V100\n.Trashes\nehthumbs.db\nThumbs.db\n\n# Uploads directory\nuploads/ "
  },
  {
    "path": ".npmrc",
    "content": "# Ensure consistent behavior\naudit=false\nfund=false\nprogress=false\nloglevel=error "
  },
  {
    "path": "Dockerfile",
    "content": "# 多阶段构建 - 构建阶段\nFROM node:18-bookworm-slim AS builder\n\n# 设置工作目录\nWORKDIR /app\n\n# 安装构建工具和依赖\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n    git \\\n    python3 \\\n    make \\\n    g++ \\\n    && rm -rf /var/lib/apt/lists/*\n\n# 设置Node.js内存限制（避免OOM）\nENV NODE_OPTIONS=\"--max-old-space-size=2048\"\n# 禁用 Source Map 以减少内存占用和加快构建速度\nENV GENERATE_SOURCEMAP=false\n# 禁用 ESLint 插件以避免构建期间的 Lint 错误中断\nENV DISABLE_ESLINT_PLUGIN=true\n\n# 设置npm配置\nENV NPM_CONFIG_AUDIT=false\nENV NPM_CONFIG_FUND=false\nENV NPM_CONFIG_PROGRESS=false\nENV NPM_CONFIG_LOGLEVEL=warn\n\n# 复制package.json文件\nCOPY package*.json ./\n\n# 安装所有依赖（包括开发依赖，用于构建）\nRUN npm install --no-audit --no-fund --prefer-offline --verbose --legacy-peer-deps\n\n# 复制客户端package.json\nCOPY client/package*.json ./client/\n\n# 安装客户端依赖（添加详细输出和错误处理）\nRUN cd client && \\\n    echo \"=== Installing client dependencies ===\" && \\\n    npm install --no-audit --no-fund --prefer-offline --no-optional --verbose --legacy-peer-deps && \\\n    echo \"=== Client dependencies installed successfully ===\"\n\n# 复制源代码\nCOPY . .\n\n# 显示构建环境信息\nRUN echo \"=== Build Environment Info ===\" && \\\n    node --version && \\\n    npm --version && \\\n    echo \"=== Current Directory ===\" && \\\n    pwd && \\\n    ls -la && \\\n    echo \"=== Client Directory ===\" && \\\n    ls -la client/\n\n# 验证客户端依赖安装\nRUN echo \"=== Client Dependencies Check ===\" && \\\n    cd client && \\\n    ls -la node_modules/ | head -10 && \\\n    echo \"=== React version ===\" && \\\n    npm list react && \\\n    echo \"=== React-scripts version ===\" && \\\n    npm list react-scripts\n\n# 构建客户端（添加详细输出和错误处理）\nRUN cd client && \\\n    echo \"=== Starting client build ===\" && \\\n    echo \"=== Available memory ===\" && \\\n    free -h || echo \"Memory info not available\" && \\\n    echo \"=== Node options ===\" && \\\n    echo $NODE_OPTIONS && \\\n    echo \"=== NPM version ===\" && \\\n    npm --version && \\\n    echo \"=== Node version ===\" && \\\n    node --version && \\\n    echo \"=== Starting build process ===\" && \\\n    echo \"=== Build environment ===\" && \\\n    echo \"CI: $CI\" && \\\n    echo \"NODE_ENV: $NODE_ENV\" && \\\n    echo \"=== Running build command ===\" && \\\n    CI=false npm run build || (echo \"Build failed with exit code $?\" && echo \"=== Build error details ===\" && cat npm-debug.log* 2>/dev/null || echo \"No npm debug log found\" && exit 1)\n\n# 清理开发依赖（优化镜像大小）\nRUN npm prune --production\n\n# 验证构建结果\nRUN echo \"=== Build Result ===\" && \\\n    ls -la client/build/ && \\\n    echo \"=== Build files count ===\" && \\\n    find client/build -type f | wc -l && \\\n    echo \"=== Main JS file size ===\" && \\\n    ls -lh client/build/static/js/ && \\\n    echo \"=== Build successful ===\"\n\n# 生产阶段\nFROM node:18-bookworm-slim AS production\n\n# 设置工作目录\nWORKDIR /app\n\n# 安装 gosu 和基础依赖\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n    gosu \\\n    && rm -rf /var/lib/apt/lists/*\n\n# 从构建阶段复制node_modules和应用文件\nCOPY --from=builder /app/node_modules ./node_modules\nCOPY --from=builder /app/package*.json ./\nCOPY --from=builder /app/server ./server\nCOPY --from=builder /app/config.js ./\nCOPY --from=builder /app/client/build ./client/build\n\n# 创建上传目录\nRUN mkdir -p uploads logs\n\n# 验证文件复制\nRUN echo \"=== Production Image Verification ===\" && \\\n    ls -la client/build/ && \\\n    echo \"=== Node modules verification ===\" && \\\n    ls -la node_modules/ | head -10\n\n# 暴露端口\nEXPOSE 3001\n\n# 设置环境变量\nENV NODE_ENV=production\nENV PORT=3001\nENV STORAGE_PATH=/app/uploads\nENV PUID=1000\nENV PGID=1000\nENV UMASK=002\n# 设置 HuggingFace 镜像地址，解决国内下载模型超时问题\nENV HF_ENDPOINT=https://hf-mirror.com\n\n# 复制入口脚本\nCOPY docker-entrypoint.sh /usr/local/bin/\nRUN chmod +x /usr/local/bin/docker-entrypoint.sh\n\n# 健康检查（使用环境变量 PORT）\nHEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\\n  CMD node -e \"require('http').get('http://localhost:' + (process.env.PORT || 3001) + '/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })\"\n\n# 使用入口脚本启动\nENTRYPOINT [\"docker-entrypoint.sh\"]\nCMD [\"npm\", \"start\"]\n \n"
  },
  {
    "path": "Dockerfile.gha",
    "content": "# GitHub Actions 优化的 Dockerfile\nFROM node:18-bookworm-slim AS builder\n\n# 设置工作目录\nWORKDIR /app\n\n# 安装构建工具\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n    git \\\n    python3 \\\n    make \\\n    g++ \\\n    && rm -rf /var/lib/apt/lists/*\n\n# 设置环境变量\nENV NODE_OPTIONS=\"--max-old-space-size=2048\"\nENV CI=false\nENV NODE_ENV=production\n# 禁用 Source Map 以减少内存占用和加快构建速度\nENV GENERATE_SOURCEMAP=false\n# 禁用 ESLint 插件以避免构建期间的 Lint 错误中断\nENV DISABLE_ESLINT_PLUGIN=true\n\n# 复制package.json文件\nCOPY package*.json ./\nCOPY client/package*.json ./client/\n\n# 安装依赖\nRUN npm install --no-audit --no-fund --prefer-offline --verbose --legacy-peer-deps\nRUN cd client && npm install --no-audit --no-fund --prefer-offline --no-optional --verbose --legacy-peer-deps\n\n# 复制源代码\nCOPY . .\n\n# 调试：检查复制的文件\nRUN echo \"=== Checking copied files ===\" && \\\n    ls -la && \\\n    echo \"=== Client directory ===\" && \\\n    ls -la client/ && \\\n    echo \"=== Client public directory ===\" && \\\n    ls -la client/public/ && \\\n    echo \"=== Public directory contents ===\" && \\\n    find client/public -type f\n\n# 构建客户端\nRUN cd client && \\\n    echo \"=== Build environment ===\" && \\\n    node --version && \\\n    npm --version && \\\n    echo \"NODE_OPTIONS: $NODE_OPTIONS\" && \\\n    echo \"CI: $CI\" && \\\n    echo \"NODE_ENV: $NODE_ENV\" && \\\n    echo \"=== Starting build ===\" && \\\n    npm run build\n\n# 清理开发依赖（优化镜像大小）\nRUN npm prune --production\n\n# 验证构建结果\nRUN echo \"=== Build verification ===\" && \\\n    ls -la client/build/ && \\\n    echo \"Build completed successfully\"\n\n# 生产阶段\nFROM node:18-bookworm-slim AS production\n\n# 设置工作目录\nWORKDIR /app\n\n# 安装 gosu 和基础依赖\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n    gosu \\\n    && rm -rf /var/lib/apt/lists/*\n\n# 从构建阶段复制node_modules和应用文件\nCOPY --from=builder /app/node_modules ./node_modules\nCOPY --from=builder /app/package*.json ./\nCOPY --from=builder /app/server ./server\nCOPY --from=builder /app/config.js ./\nCOPY --from=builder /app/client/build ./client/build\n\n# 创建上传目录\nRUN mkdir -p uploads logs\n\n# 验证文件复制\nRUN echo \"=== Production Image Verification ===\" && \\\n    ls -la client/build/ && \\\n    echo \"=== Node modules verification ===\" && \\\n    ls -la node_modules/ | head -10\n\n# 暴露端口\nEXPOSE 3001\n\n# 设置环境变量\nENV NODE_ENV=production\nENV PORT=3001\nENV STORAGE_PATH=/app/uploads\nENV PUID=1000\nENV PGID=1000\nENV UMASK=002\n# 设置 HuggingFace 镜像地址，解决国内下载模型超时问题\nENV HF_ENDPOINT=https://hf-mirror.com\n\n# 复制入口脚本\nCOPY docker-entrypoint.sh /usr/local/bin/\nRUN chmod +x /usr/local/bin/docker-entrypoint.sh\n\n# 健康检查（使用环境变量 PORT）\nHEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\\n  CMD node -e \"require('http').get('http://localhost:' + (process.env.PORT || 3001) + '/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })\"\n\n# 使用入口脚本启动\nENTRYPOINT [\"docker-entrypoint.sh\"]\nCMD [\"npm\", \"start\"]"
  },
  {
    "path": "README.md",
    "content": "# 云图\n\n> ☁️ **云端一隅，拾光深藏**  \n> 一个简单、开放且强大的自托管图像托管解决方案。\n\n[![Stars](https://img.shields.io/github/stars/qazzxxx/cloudimgs?style=flat-square&logo=github&label=Stars)](https://github.com/qazzxxx/cloudimgs/stargazers)\n[![Forks](https://img.shields.io/github/forks/qazzxxx/cloudimgs?style=flat-square&logo=github&label=Forks)](https://github.com/qazzxxx/cloudimgs/network/members)\n[![Release](https://img.shields.io/github/v/release/qazzxxx/cloudimgs?style=flat-square&color=blue)](https://github.com/qazzxxx/cloudimgs/releases)\n\n---\n\n## 📖 简介 | Introduction\n\n项目的开始是用 **N8N处理相关流程** 时有很多图片处理的需求，找了很多开源项目有的比较老无人维护，有的需要购买PRO版本才能有更多的功能。以上种种原因吧，再加上自己也有NAS，所以写了一个比较自由开放的图床项目。\n\n---\n\n## 🖥️ 在线演示 | Demo\n\n- **演示地址**：[https://yt.qazz.site](https://yt.qazz.site)\n- **文档地址**：[https://ytdoc.qazz.site/](https://ytdoc.qazz.site/)\n\n> [!NOTE]\n> 此演示为 **纯静态 Mock 模式** 部署，图片数据随机加载，不涉及真实后端调用。\n> - **访问密码**：`123456`\n> - **说明**：上传、删除等操作仅演示UI交互，数据不会保存，部分功能不可用。真实环境下通过 `thumbhash` 生成缩略图，体验会更流畅。\n\n---\n\n## 🚀 功能特点 | Features\n\n### 🛠️ 核心功能\n- [x] **多格式支持**：支持上传各种格式图片及其他文件，支持全局上传。\n- [x] **图片管理**：在线管理图片，瀑布流展示，批量圈选删除。\n- [x] **相册分享**：支持相册分享功能。\n- [x] **安全保护**：支持设置密钥，保护图片安全。\n- [x] **目录管理**：支持多级子目录管理。\n- [x] **移动适配**：完美适配移动端。\n\n### ⚡️ 高级特性\n- [x] **魔法搜索**：基于CLIP本地小模型，支持自然语言搜索（如搜“蓝天白云”）。\n- [x] **流量看板**：直观展示流量使用情况。\n- [x] **照片轨迹**：在地图上展示照片拍摄轨迹。\n- [x] **性能优化**：集成 `thumbhash` 无感生成缩略图，大幅优化加载体验。\n\n### 🔌 开放接口 (API)\n- [x] **上传/管理**：支持Base64上传、SVG转PNG、拖拽上传、图片删除/列表等。\n- [x] **图片处理**：支持实时 URL 参数处理（尺寸、质量、格式转换）。\n  - *示例*：`image.jpg?w=500&h=300&q=80&fmt=webp`\n- [x] **随机图/指定图**：支持获取随机图片或指定参数的图片。\n- [x] **生态集成**：支持 [PicGo 插件](https://github.com/qazzxxx/picgo-plugin-cloudimgs-uploader) 直接安装使用。\n\n### 🎨 图片工具\n- [x] **在线编辑**：内置图片编辑功能。\n- [x] **格式转换**：支持 SVG 转 PNG。\n- [x] **压缩工具**：自定义压缩质量和尺寸。\n- [x] **一键分享**：支持一键复制图片链接。\n\n---\n\n## 🖼️ 软件预览 | Preview\n\n<details open>\n<summary><b>✨ 点击收起/展开截图</b></summary>\n<br>\n\n### 魔法搜索 & 主要界面\n| 魔法搜索 (Magic Search) | 登录页面 (Login) |\n| :---: | :---: |\n| ![魔法搜索](client/public/magicsearch.jpeg) | ![登录页面](client/public/login.jpg) |\n\n| 图片管理 (Management) | 批量操作 (Batch Actions) |\n| :---: | :---: |\n| ![图片管理](client/public/cloudimgs.jpg) | ![批量操作](client/public/batch.jpg) |\n\n### 功能展示\n| 相册分享 (Share) | 整页上传 (Upload) |\n| :---: | :---: |\n| ![相册分享](client/public/share.jpg) | ![整页上传](client/public/upload.jpg) |\n\n| 轨迹地图 (Map) | 图片编辑 (Editor) |\n| :---: | :---: |\n| ![照片轨迹](client/public/map.jpg) | ![图片编辑](client/public/edit.jpg) |\n\n| 开放接口 (API) | 移动端 (Mobile) |\n| :---: | :---: |\n| ![开放接口](client/public/api.jpg) | ![移动端](client/public/mobile.jpg) |\n\n</details>\n\n---\n\n## 🛠️ 快速部署 | Quick Start\n\n推荐使用 **Docker Compose** 进行快速部署。\n\n### `docker-compose.yml`\n\n```yaml\nservices:\n  cloudimgs:\n    image: qazzxxx/cloudimgs:latest\n    container_name: cloudimgs-app\n    restart: unless-stopped\n    ports:\n      - \"3001:3001\"\n    volumes:\n      - ./uploads:/app/uploads:rw # 上传目录配置，明确读写权限\n    environment:\n      # 权限配置 (建议填写 NAS 用户真实 ID)\n      - PUID=1000  # id -u\n      - PGID=1000   # id -g\n      - UMASK=002\n      \n      # 基础配置\n      - NODE_ENV=production\n      - PORT=3001\n      - STORAGE_PATH=/app/uploads\n      \n      # 可选配置\n      # - MAX_FILE_SIZE=104857600 # 最大文件大小，默认 100MB\n      # - THUMBNAIL_WIDTH=0 # 瀑布流缩略图宽度（像素），默认 0 表示使用原图\n      # - PASSWORD=your_secure_password_here # 🔐 密码保护配置\n      # - ENABLE_MAGIC_SEARCH=true # ✨ 开启魔法搜索（使用本地CLIP小模型，占用内存较高）\n```\n\n### 🔐 环境变量说明\n\n| 变量名 | 说明 | 示例 / 默认值 |\n| :--- | :--- | :--- |\n| `PASSWORD` | 设置访问密码，留空则无需密码 | `123456` |\n| `ENABLE_MAGIC_SEARCH`| 是否开启 AI 魔法搜索 | `true` / `false` |\n| `MAX_FILE_SIZE` | 最大上传文件限制 (Byte) | `104857600` (100MB) |\n| `THUMBNAIL_WIDTH` | 列表缩略图宽度 (px) | `0` (原图) / `500` |\n\n> **注意**：\n> 1. 设置 `PASSWORD` 后，系统会自动启用登录保护。\n> 2. 登录状态会保存在浏览器本地存储中。\n\n---\n\n## 📈 历史 Star | Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=qazzxxx/cloudimgs&type=date&legend=top-left)](https://www.star-history.com/#qazzxxx/cloudimgs&type=date&legend=top-left)"
  },
  {
    "path": "client/.npmrc",
    "content": "legacy-peer-deps=true\n"
  },
  {
    "path": "client/package.json",
    "content": "{\n  \"name\": \"cloudimgs-client\",\n  \"version\": \"1.2.3\",\n  \"private\": true,\n  \"dependencies\": {\n    \"@ant-design/icons\": \"^5.6.1\",\n    \"@testing-library/jest-dom\": \"^5.17.0\",\n    \"@testing-library/react\": \"^13.4.0\",\n    \"@testing-library/user-event\": \"^13.5.0\",\n    \"antd\": \"^6.0.0\",\n    \"axios\": \"^1.10.0\",\n    \"coordtransform\": \"^2.1.2\",\n    \"cropperjs\": \"^1.6.2\",\n    \"dayjs\": \"^1.11.13\",\n    \"echarts\": \"^6.0.0\",\n    \"echarts-for-react\": \"^3.0.6\",\n    \"konva\": \"^9.3.20\",\n    \"leaflet\": \"^1.9.4\",\n    \"leaflet-rotatedmarker\": \"^0.2.0\",\n    \"leaflet.markercluster\": \"^1.5.3\",\n    \"react\": \"^18.3.1\",\n    \"react-cropper\": \"^2.3.3\",\n    \"react-dom\": \"^18.3.1\",\n    \"react-easy-crop\": \"^5.0.1\",\n    \"react-filerobot-image-editor\": \"^4.9.1\",\n    \"react-konva\": \"^18.2.10\",\n    \"react-leaflet\": \"^4.2.1\",\n    \"react-leaflet-cluster\": \"^4.0.0\",\n    \"react-router-dom\": \"^6.20.1\",\n    \"react-scripts\": \"5.0.1\",\n    \"styled-components\": \"^5.3.11\",\n    \"thumbhash\": \"^0.1.1\"\n  },\n  \"scripts\": {\n    \"start\": \"react-scripts start\",\n    \"start:mock\": \"REACT_APP_MOCK=true react-scripts start\",\n    \"build\": \"CI=false react-scripts build\",\n    \"build:mock\": \"REACT_APP_MOCK=true react-scripts build\",\n    \"test\": \"react-scripts test\",\n    \"eject\": \"react-scripts eject\"\n  },\n  \"eslintConfig\": {\n    \"extends\": [\n      \"react-app\",\n      \"react-app/jest\"\n    ]\n  },\n  \"browserslist\": {\n    \"production\": [\n      \">0.2%\",\n      \"not dead\",\n      \"not op_mini all\"\n    ],\n    \"development\": [\n      \"last 1 chrome version\",\n      \"last 1 firefox version\",\n      \"last 1 safari version\"\n    ]\n  },\n  \"proxy\": \"http://localhost:3001\"\n}\n"
  },
  {
    "path": "client/public/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <link rel=\"icon\" href=\"%PUBLIC_URL%/favicon.svg\" type=\"image/svg+xml\" />\n    <link rel=\"icon\" href=\"%PUBLIC_URL%/favicon.ico\" sizes=\"any\" />\n    <link rel=\"apple-touch-icon\" href=\"%PUBLIC_URL%/icon-192.png\" />\n    <link rel=\"manifest\" href=\"%PUBLIC_URL%/manifest.json\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n    <meta name=\"theme-color\" content=\"#1890ff\" />\n    <meta\n      name=\"description\"\n      content=\"云图 - 现代图床应用，支持图片上传、管理、预览和API接口\"\n    />\n    <meta name=\"keywords\" content=\"图床,图片上传,图片管理,云存储,API\" />\n    <meta name=\"author\" content=\"云图 Team\" />\n\n    <!-- Open Graph / Facebook -->\n    <meta property=\"og:type\" content=\"website\" />\n    <meta property=\"og:url\" content=\"https://github.com/Qazzxxx/cloudimgs\" />\n    <meta property=\"og:title\" content=\"云图\" />\n    <meta\n      property=\"og:description\"\n      content=\"基于 Node.js + React + Ant Design 的现代化图床应用\"\n    />\n    <meta property=\"og:image\" content=\"%PUBLIC_URL%/icon-512.png\" />\n\n    <!-- Twitter -->\n    <meta property=\"twitter:card\" content=\"summary_large_image\" />\n    <meta\n      property=\"twitter:url\"\n      content=\"https://github.com/Qazzxxx/cloudimgs\"\n    />\n    <meta property=\"twitter:title\" content=\"云图\" />\n    <meta\n      property=\"twitter:description\"\n      content=\"基于 Node.js + React + Ant Design 的现代化图床应用\"\n    />\n    <meta property=\"twitter:image\" content=\"%PUBLIC_URL%/icon-512.png\" />\n\n    <title>云图 - 云端一隅，拾光深藏</title>\n  </head>\n  <body>\n    <noscript>You need to enable JavaScript to run this app.</noscript>\n    <div id=\"root\"></div>\n  </body>\n</html>\n"
  },
  {
    "path": "client/public/manifest.json",
    "content": "{\n  \"short_name\": \"云图\",\n  \"name\": \"云图\",\n  \"description\": \"基于 Node.js + React + Ant Design 的现代化图床应用，支持图片上传、管理、预览和API接口\",\n  \"icons\": [\n    {\n      \"src\": \"favicon.svg\",\n      \"sizes\": \"any\",\n      \"type\": \"image/svg+xml\",\n      \"purpose\": \"any\"\n    },\n    {\n      \"src\": \"icon-192.png\",\n      \"type\": \"image/png\",\n      \"sizes\": \"192x192\",\n      \"purpose\": \"any maskable\"\n    },\n    {\n      \"src\": \"icon-512.png\",\n      \"type\": \"image/png\",\n      \"sizes\": \"512x512\",\n      \"purpose\": \"any maskable\"\n    }\n  ],\n  \"start_url\": \".\",\n  \"display\": \"standalone\",\n  \"theme_color\": \"#1890ff\",\n  \"background_color\": \"#ffffff\",\n  \"orientation\": \"portrait-primary\",\n  \"categories\": [\"productivity\", \"utilities\"],\n  \"lang\": \"zh-CN\"\n}\n"
  },
  {
    "path": "client/public/text.md",
    "content": "# 拒绝付费与臃肿！我为NAS党手撸了一个极简且强大的云图库——CloudImgs（云图）\n\n大家好，我是 **云舟实验室**。\n\n今天想和大家分享一个我最近开发的开源项目——**云图 (CloudImgs)**。\n\n这是一个极简风格的自建图床/云图库，支持 **Docker 一键部署**，完美适配 **NAS** 环境，并且拥有超灵活的 **API 接口**。\n\n![云图](https://fastly.jsdelivr.net/gh/bucketio/img12@main/2025/12/28/1766885915244-31e3c67a-ffc5-422f-afa7-98fd55ebb438.png)\n\n\n## 🛠️ 为什么要造这个轮子？\n\n说实话，起初我并没有打算写个图床。\n\n事情的起因是我在使用 **N8N** 处理自动化工作流时，遇到了大量的图片处理需求。我尝试寻找现有的开源解决方案，但结果并不理想：\n\n* **太老旧**：很多曾经优秀的开源项目已经几年没更新了，UI 停留在十年前，代码维护也停滞了。\n* **要付费**：好用一点的现代图床，往往需要购买 PRO 版本才能解锁高级功能（如图片压缩、格式转换等）。\n* **功能过剩或不足**：有的太复杂，有的又太简陋，不支持 API 自动化调用。\n\n既然我自己有 **NAS**，又懂一点代码，为什么不自己写一个呢？于是，**云图 (CloudImgs)** 诞生了。它主打**自由、开放、极简**，专为解决实际问题而来。\n\n---\n\n## 🖥️ 在线体验\n\n先别急着看技术细节，大家可以直接上手体验一下 UI 和交互。\n\n* **演示地址**：[https://yt.qazz.site](https://yt.qazz.site)\n* **访问密码**：`123456`\n\n> **⚠️ 注**：演示站为纯静态 Mock 模式，上传/删除仅演示 UI 交互，数据不保存。真实部署后，体验会更好（特别是缩略图加载）。\n\n---\n\n## ✨ 核心亮点：不仅仅是存图片\n\n### 1. 颜值即正义：极简瀑布流 & 丝滑交互\n\n我们抛弃了繁杂的后台界面，采用现代化的瀑布流布局。集成 **ThumbHash** 技术，在图片未完全加载时通过算法生成极小的占位哈希图，实现无感加载，告别“白屏”等待，视觉体验极佳。\n\n### 2. 生产力工具：PicGo 插件无缝集成\n\n对于写博客、Markdown 文档的朋友，图床的便捷性至关重要。云图**原生支持 PicGo**，我已经写好了对应的插件，安装即用。截图 -> 自动上传 -> 粘贴链接，一气呵成。\n\n* [PicGo 插件地址](https://github.com/qazzxxx/picgo-plugin-cloudimgs-uploader)\n\n### 3. 开发者福音：强大的实时处理 API\n\n这是我最自豪的功能之一。云图不仅仅是存储，还是一个**即时的图片处理引擎**。你可以通过 URL 参数实时处理图片：\n\n* **格式转换**：`image.jpg?fmt=webp` (自动转 WebP，节省带宽)\n* **尺寸调整**：`image.jpg?w=500&h=300` (强制缩放)\n* **质量压缩**：`image.jpg?q=80` (80% 质量压缩)\n\n这就意味着，你上传一张 4K 原图，在不同设备上可以通过参数调用不同尺寸的缩略图，极大减轻前端压力。\n\n### 4. 全能管理与安全\n\n* **多级目录**：支持文件夹管理，井井有条。\n* **隐私保护**：支持设置访问密钥，保护你的私有图片。\n* **全格式支持**：不仅仅是 JPG/PNG，SVG 甚至其他文件格式也能传。\n* **SVG 转 PNG**：专为设计师和前端优化的功能。\n* **批量操作**：支持圈选批量删除，效率拉满。\n\n---\n\n## 📸 更多界面预览\n\n**登录页面**：简洁大方，支持密码保护。\n![登录页面](https://fastly.jsdelivr.net/gh/bucketio/img13@main/2025/12/28/1766885631052-9ca708a9-3416-40fc-b063-fc20a067a73b.jpg)\n\n**瀑布流管理图片**：支持瀑布流展示管理图片。\n![瀑布流管理图片](https://fastly.jsdelivr.net/gh/bucketio/img10@main/2025/12/28/1766885826656-a2c26f51-c957-4e83-98e3-da69be485dcb.jpg)\n\n\n**批量操作**：支持圈选多图一键操作。\n![批量操作](https://fastly.jsdelivr.net/gh/bucketio/img4@main/2025/12/28/1766885797912-d3a5031b-764e-443a-b09d-84bb91aca4d3.jpg)\n\n\n**整页上传**：支持多图拖拽一键上传。\n![整页上传](https://fastly.jsdelivr.net/gh/bucketio/img18@main/2025/12/28/1766885649610-3feef228-a3d9-4553-876b-feee12f7396f.jpg)\n\n\n**相册分享**：一键生成分享链接，发给朋友。\n![相册分享](https://fastly.jsdelivr.net/gh/bucketio/img10@main/2025/12/28/1766885659322-a8c942d7-ee00-42df-b16d-7aa3401d519c.jpg)\n\n**开放API**：灵活调用开放API。\n![开放API](https://fastly.jsdelivr.net/gh/bucketio/img2@main/2025/12/28/1766885886202-17cc6cc7-0712-4383-a25a-61c30fc33201.jpg)\n\n\n---\n\n## 🚀 极速部署 (NAS/Docker)\n\n作为 NAS 党，我深知部署难度的痛点。云图完全 Docker 化，只需要一个 `docker-compose.yml` 即可跑起来。\n\n### 1. 创建 docker-compose.yml\n\n```yaml\nservices:\n  cloudimgs:\n    image: qazzxxx/cloudimgs:latest\n    ports:\n      - \"3001:3001\"\n    volumes:\n      - ./uploads:/app/uploads:rw # 图片数据存储位置\n    restart: unless-stopped\n    container_name: cloudimgs-app\n    environment:\n      - PUID=1000  # 替换为你 NAS 用户的 UID (终端输入 id -u 查看)\n      - PGID=1000  # 替换为你 NAS 用户组的 GID (终端输入 id -g 查看)\n      - UMASK=002\n      - NODE_ENV=production\n      - PORT=3001\n      - STORAGE_PATH=/app/uploads\n      # 👇 如果需要密码访问，请取消下面这行的注释并修改密码\n      # - PASSWORD=your_secure_password_here\n\n```\n\n### 2. 启动服务\n\n```bash\ndocker-compose up -d\n\n```\n\n启动后，访问 `http://ip:3001` 即可开始使用！\n\n### 关于密码保护\n\n如果你是在公网环境或者不想让别人随意查看，强烈建议在环境变量中配置 `PASSWORD`。配置后，访问系统需要输入密码，且状态会保存在本地浏览器中，既安全又不用频繁登录。\n\n---\n\n## 🔗 项目地址\n\n开源不易，如果你觉得「云图」还不错，或者帮到了你的忙，希望能去 GitHub 点个 **Star ⭐️** 支持一下！这也是我持续维护的动力。\n\n* **GitHub 项目地址**: [https://github.com/qazzxxx/cloudimgs](https://github.com/qazzxxx/cloudimgs)\n* **PicGo 插件**: [https://github.com/qazzxxx/picgo-plugin-cloudimgs-uploader](https://github.com/qazzxxx/picgo-plugin-cloudimgs-uploader)\n\n如果你在使用过程中遇到任何问题，欢迎在 GitHub 提 Issue 或在评论区留言，我会尽快回复大家！\n\n---\n\n**云舟实验室**\n*专注分享好用的开源项目与技术折腾心得*"
  },
  {
    "path": "client/src/App.js",
    "content": "import React, { useState, useEffect } from \"react\";\nimport { ConfigProvider, theme, message, Spin, Grid, Modal } from \"antd\";\nimport FloatingToolbar from \"./components/FloatingToolbar\";\nimport ImageGallery from \"./components/ImageGallery\";\nimport PasswordOverlay from \"./components/PasswordOverlay\";\nimport LogoWithText from \"./components/LogoWithText\";\nimport api from \"./utils/api\";\nimport ApiDocs from \"./components/ApiDocs\";\nimport MapPage from \"./components/MapPage\";\nimport ShareView from \"./components/ShareView\";\nimport DirectorySelector from \"./components/DirectorySelector\";\nimport TrafficDashboard from './components/TrafficDashboard';\nimport { getPassword, clearPassword } from \"./utils/secureStorage\";\n\nfunction App() {\n  const [currentTheme, setCurrentTheme] = useState(\"light\");\n  const [isAuthenticated, setIsAuthenticated] = useState(false);\n  const [passwordRequired, setPasswordRequired] = useState(false);\n  const [authLoading, setAuthLoading] = useState(true);\n  const [refreshTrigger, setRefreshTrigger] = useState(0);\n\n  // Batch Mode State\n  const [isBatchMode, setIsBatchMode] = useState(false);\n  const [selectedItems, setSelectedItems] = useState(new Set());\n\n  // Batch Move State\n  const [moveModalVisible, setMoveModalVisible] = useState(false);\n  const [targetMoveDir, setTargetMoveDir] = useState(\"\");\n  const [moving, setMoving] = useState(false);\n\n  // Simple router check\n  const isApiDocs = window.location.pathname === \"/opendocs\";\n  const isMapPage = window.location.pathname === \"/map\";\n  const isTrafficDashboard = window.location.pathname === \"/traffic\";\n  const isShareView = window.location.pathname.startsWith(\"/share\");\n\n  const { useBreakpoint } = Grid;\n  const screens = useBreakpoint();\n  const isMobile = !screens.md;\n\n  useEffect(() => {\n    const savedTheme = localStorage.getItem(\"theme\");\n    if (savedTheme) {\n      setCurrentTheme(savedTheme);\n    }\n  }, []);\n\n  const handleThemeChange = (theme) => {\n    setCurrentTheme(theme);\n    localStorage.setItem(\"theme\", theme);\n  };\n\n  useEffect(() => {\n    const checkAuthStatus = async () => {\n      try {\n        setAuthLoading(true);\n        const response = await api.get(\"/auth/status\");\n        const data = response.data;\n\n        if (data.data?.enabled || data.requiresPassword) {\n          setPasswordRequired(true);\n          const savedPassword = getPassword();\n          if (savedPassword) {\n            try {\n              await api.post(\"/auth/login\", { password: savedPassword });\n              setIsAuthenticated(true);\n            } catch (e) {\n              clearPassword();\n            }\n          }\n        } else {\n          setIsAuthenticated(true);\n        }\n      } catch (error) {\n        console.error(\"Auth check failed:\", error);\n      } finally {\n        setAuthLoading(false);\n      }\n    };\n\n    checkAuthStatus();\n  }, []);\n\n  const handleLoginSuccess = () => {\n    setIsAuthenticated(true);\n    message.success(\"欢迎回来\");\n  };\n\n  const handleRefresh = () => {\n    setRefreshTrigger(prev => prev + 1);\n  };\n\n  const toggleBatchMode = () => {\n    setIsBatchMode(prev => !prev);\n    setSelectedItems(new Set());\n  };\n\n  const handleSelectionChange = (newSelection) => {\n    setSelectedItems(newSelection);\n  };\n\n  const handleBatchDelete = async () => {\n    if (selectedItems.size === 0) return;\n\n    try {\n      const hide = message.loading(\"正在删除...\", 0);\n      // Execute deletions in parallel\n      const promises = Array.from(selectedItems).map(relPath =>\n        api.delete(`/images/${encodeURIComponent(relPath)}`)\n      );\n\n      await Promise.all(promises);\n      hide();\n      message.success(`成功删除 ${selectedItems.size} 张图片`);\n\n      // Reset state\n      setSelectedItems(new Set());\n      setIsBatchMode(false);\n      handleRefresh();\n    } catch (error) {\n      console.error(\"Batch delete error:\", error);\n      message.error(\"部分图片删除失败，请重试\");\n      handleRefresh(); // Refresh anyway to show what's left\n    }\n  };\n\n  const handleBatchMove = () => {\n    if (selectedItems.size === 0) return;\n    setTargetMoveDir(\"\"); // Reset\n    setMoveModalVisible(true);\n  };\n\n  const confirmBatchMove = async () => {\n    if (selectedItems.size === 0) return;\n\n    setMoving(true);\n    try {\n      const res = await api.post(\"/batch/move\", {\n        files: Array.from(selectedItems),\n        targetDir: targetMoveDir\n      });\n\n      if (res.data.success) {\n        message.success(res.data.message || \"移动成功\");\n        setMoveModalVisible(false);\n        setSelectedItems(new Set());\n        setIsBatchMode(false);\n        handleRefresh();\n      } else {\n        message.error(res.data.error || \"移动失败\");\n      }\n    } catch (e) {\n      message.error(\"移动失败，请重试\");\n    } finally {\n      setMoving(false);\n    }\n  };\n\n  // Global styles for glassmorphism and background\n  const globalStyles = `\n    body {\n      margin: 0;\n      padding: 0;\n      background: ${currentTheme === 'dark' ? '#0f0f0f' : '#f5f7fa'};\n      transition: background 0.3s ease;\n      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;\n    }\n    \n    /* Custom Scrollbar */\n    ::-webkit-scrollbar {\n      width: 8px;\n      height: 8px;\n    }\n    ::-webkit-scrollbar-track {\n      background: transparent;\n    }\n    ::-webkit-scrollbar-thumb {\n      background: ${currentTheme === 'dark' ? '#333' : '#ccc'};\n      border-radius: 4px;\n    }\n    ::-webkit-scrollbar-thumb:hover {\n      background: ${currentTheme === 'dark' ? '#555' : '#999'};\n    }\n    \n    /* Prevent dropdown scroll from affecting main page */\n    .directory-selector-dropdown .rc-virtual-list-holder {\n      overflow-y: auto !important;\n      overscroll-behavior: contain;\n    }\n\n    /* Force fix for Filerobot Image Editor Input Background */\n    .SfxInput-root {\n      background-color: ${currentTheme === 'dark' ? '#141414' : '#ffffff'} !important;\n    }\n  `;\n\n  return (\n    <ConfigProvider\n      theme={{\n        algorithm: currentTheme === \"dark\" ? theme.darkAlgorithm : theme.defaultAlgorithm,\n        token: {\n          colorPrimary: \"#1677ff\",\n          borderRadius: 12,\n        },\n      }}\n    >\n      <style>{globalStyles}</style>\n\n      {/* Main Content */}\n      <div style={{ position: \"relative\", minHeight: \"100vh\" }}>\n        {isApiDocs ? (\n          <ApiDocs />\n        ) : isMapPage ? (\n          <MapPage />\n        ) : isTrafficDashboard ? (\n          <TrafficDashboard />\n        ) : isShareView ? (\n          <ShareView currentTheme={currentTheme} onThemeChange={handleThemeChange} />\n        ) : authLoading ? (\n          <div style={{\n            display: \"flex\",\n            justifyContent: \"center\",\n            alignItems: \"center\",\n            height: \"100vh\",\n            flexDirection: \"column\",\n            gap: 20\n          }}>\n            <LogoWithText />\n            <Spin size=\"large\" />\n          </div>\n        ) : (\n          <>\n            {/* Waterfall Gallery */}\n            {/* Only render gallery if authenticated or if no password required, \n                OR render it but it might be empty if API blocks it. \n                We'll render it but PasswordOverlay will cover it. */}\n            <ImageGallery\n              api={api}\n              onRefresh={handleRefresh}\n              refreshTrigger={refreshTrigger}\n              isAuthenticated={!passwordRequired || isAuthenticated}\n              isBatchMode={isBatchMode}\n              selectedItems={selectedItems}\n              onSelectionChange={handleSelectionChange}\n            />\n\n            {/* Password Overlay */}\n            {passwordRequired && !isAuthenticated && (\n              <PasswordOverlay\n                onLoginSuccess={handleLoginSuccess}\n                isMobile={isMobile}\n              />\n            )}\n\n            {/* Floating Toolbar - Only show when authenticated */}\n            {(!passwordRequired || isAuthenticated) && (\n              <FloatingToolbar\n                onThemeChange={handleThemeChange}\n                currentTheme={currentTheme}\n                onRefresh={handleRefresh}\n                api={api}\n                isMobile={isMobile}\n                isBatchMode={isBatchMode}\n                toggleBatchMode={toggleBatchMode}\n                selectedCount={selectedItems.size}\n                onBatchDelete={handleBatchDelete}\n                onBatchMove={handleBatchMove}\n              />\n            )}\n\n            {/* Batch Move Modal */}\n            <Modal\n              open={moveModalVisible}\n              title=\"移动到...\"\n              onCancel={() => setMoveModalVisible(false)}\n              onOk={confirmBatchMove}\n              confirmLoading={moving}\n              okText=\"确认移动\"\n              cancelText=\"取消\"\n              destroyOnClose\n            >\n              <div style={{ padding: \"20px 0\" }}>\n                <p style={{ marginBottom: 12 }}>将选中的 {selectedItems.size} 张图片移动到：</p>\n                <DirectorySelector\n                  value={targetMoveDir}\n                  onChange={setTargetMoveDir}\n                  api={api}\n                  allowInput={true}\n                  placeholder=\"选择或输入目标相册\"\n                />\n              </div>\n            </Modal>\n          </>\n        )}\n      </div>\n    </ConfigProvider>\n  );\n}\n\nexport default App;\n"
  },
  {
    "path": "client/src/components/AlbumManager.js",
    "content": "import React, { useState, useEffect } from \"react\";\nimport {\n  Modal,\n  Typography,\n  Dropdown,\n  Button,\n  Space,\n  Input,\n  message,\n  Select,\n  Switch,\n  Empty,\n  Spin,\n  theme,\n} from \"antd\";\nimport {\n  MoreOutlined,\n  DeleteOutlined,\n  EditOutlined,\n  ShareAltOutlined,\n  FolderOpenOutlined,\n  CopyOutlined,\n  FireOutlined,\n  StopOutlined,\n  PlusOutlined,\n  LockOutlined,\n  UnlockOutlined\n} from \"@ant-design/icons\";\nimport dayjs from \"dayjs\";\n\nconst { Text } = Typography;\nconst { Option } = Select;\n\nconst CountdownTimer = ({ expireSeconds, createdAt }) => {\n  const [timeLeft, setTimeLeft] = useState(\"\");\n\n  useEffect(() => {\n    if (!expireSeconds) return;\n\n    const calculateTimeLeft = () => {\n      const expireTime = dayjs(createdAt).add(expireSeconds, 'second');\n      const now = dayjs();\n      const diff = expireTime.diff(now, 'second');\n\n      if (diff <= 0) {\n        return \"已过期\";\n      }\n\n      const days = Math.floor(diff / (3600 * 24));\n      const hours = Math.floor((diff % (3600 * 24)) / 3600);\n      const minutes = Math.floor((diff % 3600) / 60);\n\n      let str = \"\";\n      if (days > 0) str += `${days}天 `;\n      if (hours > 0) str += `${hours}小时 `;\n      if (minutes > 0 || (days === 0 && hours === 0)) str += `${minutes}分`;\n\n      return str;\n    };\n\n    setTimeLeft(calculateTimeLeft());\n\n    const timer = setInterval(() => {\n      const str = calculateTimeLeft();\n      setTimeLeft(str);\n      if (str === \"已过期\") clearInterval(timer);\n    }, 60000); // Update every minute\n\n    return () => clearInterval(timer);\n  }, [expireSeconds, createdAt]);\n\n  if (!expireSeconds) return \"永久有效\";\n  return `剩余: ${timeLeft}`;\n};\n\nconst AlbumManager = ({ visible, onClose, api, onSelectAlbum }) => {\n  const [albums, setAlbums] = useState([]);\n  const [loading, setLoading] = useState(true);\n  const [shareModalVisible, setShareModalVisible] = useState(false);\n  const [currentAlbum, setCurrentAlbum] = useState(null);\n\n\n  // ... (rest of state)\n  const [shareExpiry, setShareExpiry] = useState(3600 * 24); // 1 day\n  const [shareBurn, setShareBurn] = useState(false);\n  const [shareLink, setShareLink] = useState(\"\");\n  const [generatingLink, setGeneratingLink] = useState(false);\n  const [shareList, setShareList] = useState([]);\n  const [loadingShares, setLoadingShares] = useState(false);\n\n  // Rename State\n  const [renameModalVisible, setRenameModalVisible] = useState(false);\n  const [renameValue, setRenameValue] = useState(\"\");\n\n  // Create State\n  const [createModalVisible, setCreateModalVisible] = useState(false);\n  const [createValue, setCreateValue] = useState(\"\");\n\n  // Password State\n  const [passwordModalVisible, setPasswordModalVisible] = useState(false);\n  const [passwordValue, setPasswordValue] = useState(\"\");\n  const [isRemovingPassword, setIsRemovingPassword] = useState(false);\n\n  const { token } = theme.useToken();\n\n  const fetchAlbums = React.useCallback(async (abortSignal) => {\n    setLoading(true);\n    try {\n      const [dirRes, imgRes] = await Promise.all([\n        api.get(\"/directories\", { signal: abortSignal }),\n        api.get(\"/images?pageSize=3\", { signal: abortSignal }) // Fetch latest 3 images globally\n      ]);\n\n      if (dirRes.data.success) {\n        const allAlbums = dirRes.data.data || [];\n\n        // Use the actual global latest images for the \"All Images\" cover\n        // Fallback to empty if image fetch failed (though Promise.all would fail commonly, but we can handle it)\n        const globalPreviews = imgRes.data?.success\n          ? imgRes.data.data.map(img => img.url)\n          : [];\n\n        const allImagesAlbum = {\n          name: \"全部图片\",\n          path: \"\",\n          previews: globalPreviews,\n          mtime: new Date().toISOString(),\n          isSystem: true\n        };\n\n        // Order: [All Images, ...Real Albums]\n        // The \"New Album\" is rendered separately in the grid as the first item visually if we want,\n        // or we just prepend it here?\n        // In the render method: {albums.map...} matches `albums`.\n        // The render method ALSO renders a static \"New Album\" div BEFORE mapping albums.\n        // So `albums` should start with \"All Images\".\n        setAlbums([allImagesAlbum, ...allAlbums]);\n      }\n    } catch (e) {\n      // Ignore aborted requests\n      if (e.name === 'AbortError' || e.name === 'CanceledError') {\n        return;\n      }\n      console.error(e);\n      message.error(\"获取相册列表失败\");\n    } finally {\n      setLoading(false);\n    }\n  }, [api]);\n\n  useEffect(() => {\n    if (visible && api) {\n\n      const abortController = new AbortController();\n      fetchAlbums(abortController.signal);\n\n      return () => {\n        abortController.abort();\n      };\n    }\n  }, [visible, api, fetchAlbums]);\n\n  const fetchShareList = async (path) => {\n    setLoadingShares(true);\n    try {\n      const url = `/share/list?path=${encodeURIComponent(path)}`;\n      const res = await api.get(url);\n      if (res.data.success) {\n        // Sort: Active first, then by createdAt desc\n        const list = res.data.data || [];\n        list.sort((a, b) => {\n          const aActive = a.status === 'active';\n          const bActive = b.status === 'active';\n          if (aActive && !bActive) return -1;\n          if (!aActive && bActive) return 1;\n          return b.createdAt - a.createdAt;\n        });\n        setShareList(list);\n      }\n    } catch (e) {\n      message.error(\"获取分享列表失败\");\n    } finally {\n      setLoadingShares(false);\n    }\n  };\n\n  const handleShare = async () => {\n    if (!currentAlbum) return;\n    setGeneratingLink(true);\n    try {\n      const res = await api.post(\"/share/generate\", {\n        path: currentAlbum.path,\n        expireSeconds: shareExpiry,\n        burnAfterReading: shareBurn,\n      });\n      if (res.data.success) {\n        const url = `${window.location.origin}/share?token=${encodeURIComponent(res.data.token)}`;\n        setShareLink(url);\n        // Refresh list\n        await fetchShareList(currentAlbum.path);\n      }\n    } catch (e) {\n      message.error(\"生成分享链接失败\");\n    } finally {\n      setGeneratingLink(false);\n    }\n  };\n\n  const handleRevoke = async (signature) => {\n    try {\n      const res = await api.post(\"/share/revoke\", {\n        path: currentAlbum.path,\n        signature\n      });\n      if (res.data.success) {\n        message.success(\"链接已作废\");\n        fetchShareList(currentAlbum.path);\n      }\n    } catch (e) {\n      message.error(\"作废失败\");\n    }\n  };\n\n  const handleDeleteShare = async (signature) => {\n    try {\n      const res = await api.delete(\"/share/delete\", {\n        data: {\n          path: currentAlbum.path,\n          signature\n        }\n      });\n      if (res.data.success) {\n        message.success(\"删除成功\");\n        fetchShareList(currentAlbum.path);\n      }\n    } catch (e) {\n      message.error(\"删除失败\");\n    }\n  };\n\n  const handleCreate = async () => {\n    if (!createValue.trim()) return;\n    try {\n      // Use API to create directory\n      // Backend needs to support mkdir. \n      // Currently we don't have explicit mkdir API, but upload supports creating dir.\n      // Let's add a mkdir API or use a hack? \n      // Wait, server code `fs.ensureDirSync(dest)` in upload logic creates it.\n      // But we need a dedicated API.\n      // Let's check server/index.js if there is one.\n      // There isn't. I'll add one.\n\n      const res = await api.post(\"/directories\", {\n        name: createValue.trim()\n      });\n\n      if (res.data.success) {\n        message.success(\"相册创建成功\");\n        setCreateModalVisible(false);\n        setCreateValue(\"\");\n        fetchAlbums();\n      }\n    } catch (e) {\n      message.error(e.response?.data?.error || \"创建失败\");\n    }\n  };\n\n  const handleRename = async () => {\n    if (!currentAlbum || !renameValue.trim()) return;\n    try {\n      // Assuming PUT /api/images works for renaming directories if supported by backend,\n      // actually backend usually supports renaming files. \n      // Checking server code: `PUT /api/images/*` supports `fs.rename`.\n      // It works for directories too if `oldFilePath` points to a directory.\n      // `safeJoin` works for dirs. `fs.pathExists` works. `fs.rename` works.\n      // So yes, we can rename directories!\n\n      const res = await api.put(`/images/${encodeURIComponent(currentAlbum.path)}`, {\n        newName: renameValue.trim(),\n        newDir: currentAlbum.path.split(\"/\").slice(0, -1).join(\"/\") // Keep parent dir\n      });\n\n      if (res.data.success) {\n        message.success(\"重命名成功\");\n        setRenameModalVisible(false);\n        fetchAlbums();\n      }\n    } catch (e) {\n      message.error(\"重命名失败\");\n    }\n  };\n\n  const handleSavePassword = async () => {\n    if (!currentAlbum) return;\n    try {\n      const res = await api.post(\"/album/password\", {\n        dir: currentAlbum.path,\n        password: passwordValue\n      });\n      if (res.data.success) {\n        message.success(passwordValue ? \"密码设置成功\" : \"密码已移除\");\n        setPasswordModalVisible(false);\n        setPasswordValue(\"\");\n        fetchAlbums(); // Refresh to update lock status\n      }\n    } catch (e) {\n      message.error(\"操作失败\");\n    }\n  };\n\n  const handleDelete = async (album) => {\n    Modal.confirm({\n      title: \"删除相册\",\n      content: `确定要删除相册 \"${album.name}\" 及其所有内容吗？此操作不可恢复。`,\n      okText: \"删除\",\n      okType: \"danger\",\n      cancelText: \"取消\",\n      onOk: async () => {\n        try {\n          // DELETE /api/images/* works for directories too (fs.remove)\n          await api.delete(`/images/${encodeURIComponent(album.path)}`);\n          message.success(\"相册已删除\");\n          fetchAlbums();\n        } catch (e) {\n          message.error(\"删除失败\");\n        }\n      }\n    });\n  };\n\n  const copyToClipboard = (text) => {\n    navigator.clipboard.writeText(text).then(() => {\n      message.success(\"已复制到剪贴板\");\n    });\n  };\n\n  return (\n    <Modal\n      open={visible}\n      onCancel={onClose}\n      afterClose={() => {\n        setAlbums([]);\n        setLoading(true);\n      }}\n      destroyOnClose\n      transitionName=\"\"\n      maskTransitionName=\"\"\n      title={<div style={{ fontSize: 20, fontWeight: 600 }}>相册管理</div>}\n      width={1000}\n      footer={null}\n      styles={{ body: { padding: 0, minHeight: 400, background: token.colorBgLayout } }}\n    >\n      <div\n        style={{ padding: \"20px 32px\", maxHeight: \"60vh\", overflowY: \"auto\", overflowX: \"hidden\" }}\n      >\n        {loading ? (\n          <div style={{ textAlign: \"center\", padding: 50 }}>\n            <Spin size=\"large\" />\n          </div>\n        ) : albums.length === 0 ? (\n          <Empty description=\"暂无相册\" />\n        ) : (\n          <div\n            style={{\n              display: \"grid\",\n              gridTemplateColumns: \"repeat(auto-fill, minmax(240px, 1fr))\",\n              gap: 24,\n            }}\n          >\n            {/* Create New Album Card */}\n            <div\n              style={{\n                borderRadius: 12,\n                border: `2px dashed ${token.colorBorder}`,\n                display: \"flex\",\n                flexDirection: \"column\",\n                alignItems: \"center\",\n                justifyContent: \"center\",\n                cursor: \"pointer\",\n                background: token.colorFillAlter,\n                transition: \"all 0.3s\",\n                margin: 24,\n                height: 200\n              }}\n              onClick={() => setCreateModalVisible(true)}\n              onMouseEnter={e => {\n                e.currentTarget.style.borderColor = token.colorPrimary;\n                e.currentTarget.style.color = token.colorPrimary;\n              }}\n              onMouseLeave={e => {\n                e.currentTarget.style.borderColor = token.colorBorder;\n                e.currentTarget.style.color = token.colorText;\n              }}\n            >\n              <PlusOutlined style={{ fontSize: 32, marginBottom: 12 }} />\n              <div style={{ fontSize: 16, fontWeight: 500 }}>新建相册</div>\n            </div>\n\n            {albums.map((album) => (\n              <AlbumCard\n                key={album.path}\n                album={album}\n                token={token}\n                isSystem={album.isSystem}\n                onOpen={() => {\n                  onSelectAlbum(album.path);\n                  onClose();\n                }}\n                onShare={() => {\n                  setCurrentAlbum(album);\n                  setShareLink(\"\");\n                  setShareModalVisible(true);\n                  fetchShareList(album.path);\n                }}\n                onRename={() => {\n                  setCurrentAlbum(album);\n                  setRenameValue(album.name);\n                  setRenameModalVisible(true);\n                }}\n                onSetPassword={() => {\n                  setCurrentAlbum(album);\n                  setPasswordValue(\"\");\n                  setIsRemovingPassword(!!album.locked);\n                  setPasswordModalVisible(true);\n                }}\n                onDelete={() => handleDelete(album)}\n              />\n            ))}\n          </div>\n        )}\n      </div>\n\n      {/* Password Modal */}\n      <Modal\n        open={passwordModalVisible}\n        onOk={handleSavePassword}\n        onCancel={() => setPasswordModalVisible(false)}\n        title={isRemovingPassword ? \"修改/移除密码\" : \"设置相册密码\"}\n        okText=\"保存\"\n        cancelText=\"取消\"\n      >\n        <div style={{ marginBottom: 16 }}>\n          {isRemovingPassword ? \"此相册已设置密码。输入新密码以修改，或留空以移除密码。\" : \"设置密码后，访问该相册将需要输入密码。\"}\n        </div>\n        <Input.Password\n          value={passwordValue}\n          onChange={e => setPasswordValue(e.target.value)}\n          placeholder={isRemovingPassword ? \"留空移除密码\" : \"输入密码\"}\n          autoFocus\n        />\n      </Modal>\n\n      {/* Share Modal */}\n      <Modal\n        open={shareModalVisible}\n        onCancel={() => setShareModalVisible(false)}\n        title={<div style={{ fontSize: 18, fontWeight: 600 }}>分享相册 - {currentAlbum?.name}</div>}\n        footer={null}\n        width={600}\n        centered\n      >\n        <div style={{ maxHeight: \"60vh\", overflowY: \"auto\", paddingRight: 4 }}>\n          <Space direction=\"vertical\" style={{ width: \"100%\", marginTop: 12 }} size=\"middle\">\n\n            <div>\n              <div style={{ fontSize: 14, fontWeight: 600, marginBottom: 12 }}>生成新链接</div>\n\n              <div style={{ background: token.colorFillAlter, padding: 16, borderRadius: 8 }}>\n                <div style={{ marginBottom: 16 }}>\n                  <Text type=\"secondary\" style={{ fontSize: 12 }}>有效期</Text>\n                  <Select\n                    style={{ width: \"100%\", marginTop: 4 }}\n                    value={shareExpiry}\n                    onChange={setShareExpiry}\n                  >\n                    <Option value={3600}>1 小时</Option>\n                    <Option value={3600 * 24}>1 天</Option>\n                    <Option value={3600 * 24 * 7}>7 天</Option>\n                    <Option value={3600 * 24 * 30}>30 天</Option>\n                    <Option value={0}>永久有效</Option>\n                  </Select>\n                </div>\n\n                <div style={{ display: \"flex\", justifyContent: \"space-between\", alignItems: \"center\", marginBottom: 16 }}>\n                  <Space>\n                    <FireOutlined style={{ color: \"#ff4d4f\" }} />\n                    <span style={{ fontSize: 14 }}>阅后即焚 (一次性访问)</span>\n                  </Space>\n                  <Switch checked={shareBurn} onChange={setShareBurn} />\n                </div>\n\n                <Button type=\"primary\" block onClick={handleShare} loading={generatingLink} icon={<ShareAltOutlined />}>\n                  生成并复制链接\n                </Button>\n              </div>\n            </div>\n\n            {/* Show newly generated link specifically if needed, but list updates automatically */}\n            {shareLink && (\n              <div style={{ marginTop: 8, padding: 8, background: \"#f6ffed\", border: \"1px solid #b7eb8f\", borderRadius: 4, textAlign: \"center\", color: \"#52c41a\" }}>\n                <CheckCircleIcon /> 新链接已生成并添加到列表\n              </div>\n            )}\n\n            {/* Active Shares List */}\n            <div style={{ borderTop: `1px solid ${token.colorBorderSecondary}`, paddingTop: 16, marginTop: 8 }}>\n              <div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8 }}>分享列表</div>\n              {loadingShares ? (\n                <div style={{ textAlign: \"center\", padding: 20 }}><Spin /></div>\n              ) : shareList.length === 0 ? (\n                <div style={{ padding: \"20px 0\", textAlign: \"center\", color: token.colorTextSecondary, background: token.colorFillAlter, borderRadius: 8 }}>\n                  暂无分享记录\n                </div>\n              ) : (\n                <div style={{ display: \"flex\", flexDirection: \"column\", gap: 8 }}>\n                  {shareList.map((share, idx) => (\n                    <div key={idx} style={{\n                      border: `1px solid ${token.colorBorderSecondary}`,\n                      borderRadius: 8,\n                      padding: 12,\n                      display: \"flex\",\n                      justifyContent: \"space-between\",\n                      alignItems: \"center\",\n                      background: token.colorBgContainer\n                    }}>\n                      <div style={{ flex: 1, minWidth: 0, marginRight: 16 }}>\n                        <div style={{ display: \"flex\", alignItems: \"center\", gap: 8, marginBottom: 4 }}>\n                          {share.status === \"revoked\" ? (\n                            <div style={{ color: \"#ff4d4f\", fontSize: 12, border: \"1px solid #ff4d4f\", padding: \"0 4px\", borderRadius: 4 }}>已作废</div>\n                          ) : share.status === \"expired\" ? (\n                            <div style={{ color: \"#d9d9d9\", fontSize: 12, border: \"1px solid #d9d9d9\", padding: \"0 4px\", borderRadius: 4 }}>已过期</div>\n                          ) : share.status === \"burned\" ? (\n                            <div style={{ color: \"#d9d9d9\", fontSize: 12, border: \"1px solid #d9d9d9\", padding: \"0 4px\", borderRadius: 4 }}>已焚毁</div>\n                          ) : share.burnAfterReading ? (\n                            <div style={{ color: \"#faad14\", fontSize: 12, border: \"1px solid #faad14\", padding: \"0 4px\", borderRadius: 4 }}>阅后即焚</div>\n                          ) : (\n                            <div style={{ color: \"#52c41a\", fontSize: 12, border: \"1px solid #52c41a\", padding: \"0 4px\", borderRadius: 4 }}>\n                              <CountdownTimer expireSeconds={share.expireSeconds} createdAt={share.createdAt} />\n                            </div>\n                          )}\n                          <div style={{ fontSize: 12, color: token.colorTextSecondary }}>\n                            {dayjs(share.createdAt).format(\"MM-DD HH:mm\")}\n                          </div>\n                        </div>\n                        <div style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n                          <Input\n                            size=\"small\"\n                            value={`${window.location.origin}/share?token=${encodeURIComponent(share.token)}`}\n                            readOnly\n                            style={{ fontSize: 12, background: token.colorFillAlter, textDecoration: share.status !== 'active' ? 'line-through' : 'none', color: share.status !== 'active' ? token.colorTextDisabled : undefined }}\n                          />\n                          <Button size=\"small\" icon={<CopyOutlined />} onClick={() => copyToClipboard(`${window.location.origin}/share?token=${encodeURIComponent(share.token)}`)} disabled={share.status !== 'active'} />\n                        </div>\n                      </div>\n                      <Space size=\"small\">\n                        {share.status === 'active' && (\n                          <Button\n                            danger\n                            size=\"small\"\n                            type=\"text\"\n                            icon={<StopOutlined />}\n                            onClick={() => handleRevoke(share.signature)}\n                          >\n                            作废\n                          </Button>\n                        )}\n                        <Button\n                          size=\"small\"\n                          type=\"text\"\n                          icon={<DeleteOutlined />}\n                          onClick={() => handleDeleteShare(share.signature)}\n                        >\n                          删除\n                        </Button>\n                      </Space>\n                    </div>\n                  ))}\n                </div>\n              )}\n            </div>\n\n          </Space>\n        </div>\n      </Modal>\n\n      {/* Rename Modal */}\n      <Modal\n        open={renameModalVisible}\n        onOk={handleRename}\n        onCancel={() => setRenameModalVisible(false)}\n        title=\"重命名相册\"\n      >\n        <Input\n          value={renameValue}\n          onChange={e => setRenameValue(e.target.value)}\n          placeholder=\"输入新名称\"\n        />\n      </Modal>\n\n      {/* Create Modal */}\n      <Modal\n        open={createModalVisible}\n        onOk={handleCreate}\n        onCancel={() => setCreateModalVisible(false)}\n        title=\"新建相册\"\n      >\n        <Input\n          value={createValue}\n          onChange={e => setCreateValue(e.target.value)}\n          placeholder=\"输入相册名称 (支持多级如 A/B)\"\n          autoFocus\n        />\n      </Modal>\n    </Modal>\n  );\n};\n\nconst AlbumCard = React.memo(({ album, token, onOpen, onShare, onRename, onDelete, onSetPassword, isSystem }) => {\n  const [hover, setHover] = useState(false);\n\n  // Previews logic\n  const displayPreviews = React.useMemo(() => {\n    const previews = album.previews || [];\n    return [...previews].reverse().slice(0, 3);\n  }, [album.previews]);\n\n  return (\n    <div\n      style={{\n        position: \"relative\",\n        height: 200, // Match Create Card height\n        margin: 24,  // Match Create Card margin\n        cursor: \"pointer\",\n        perspective: \"1000px\",\n        contain: \"layout style\"\n      }}\n      onMouseEnter={() => setHover(true)}\n      onMouseLeave={() => setHover(false)}\n      onClick={onOpen}\n    >\n      {/* Lock Overlay */}\n      {album.locked && (\n        <div style={{\n          position: \"absolute\",\n          top: 10,\n          right: 10,\n          zIndex: 20,\n          background: \"rgba(0,0,0,0.6)\",\n          color: \"#fff\",\n          padding: 6,\n          borderRadius: \"50%\",\n          display: \"flex\",\n          alignItems: \"center\",\n          justifyContent: \"center\"\n        }}>\n          <LockOutlined />\n        </div>\n      )}\n\n      {/* Stacked Images */}\n      <div style={{ position: \"absolute\", top: 0, left: 0, right: 0, bottom: 60, contain: \"layout style\" }}>\n        {album.locked ? (\n          <div style={{\n            height: \"100%\",\n            background: token.colorFillAlter,\n            borderRadius: 12,\n            display: \"flex\",\n            flexDirection: \"column\",\n            alignItems: \"center\",\n            justifyContent: \"center\",\n            border: `1px dashed ${token.colorBorder}`\n          }}>\n            <LockOutlined style={{ fontSize: 32, color: token.colorTextQuaternary, marginBottom: 8 }} />\n            <div style={{ color: token.colorTextQuaternary, fontSize: 13, fontWeight: 500 }}>私密相册</div>\n          </div>\n        ) : displayPreviews.length > 0 ? (\n          displayPreviews.map((src, index) => {\n            // index 0 is bottom, index 2 is top\n            // We want top one to be index 0 in map if we reversed? \n            // Actually, let's just absolute position them.\n\n            // We need stable keys.\n            const offset = index * 4;\n            // 默认展开一定角度 (例如：5度，10px位移)，悬浮时进一步展开\n            const rotate = hover ? (index - 1) * 15 : (index - 1) * 5;\n            const translateY = hover ? -20 : -5;\n            const translateX = hover ? (index - 1) * 30 : (index - 1) * 10;\n            const scale = 1 - index * 0.05;\n            const zIndex = 10 - index;\n\n            return (\n              <div\n                key={index}\n                style={{\n                  position: \"absolute\",\n                  top: offset,\n                  left: offset,\n                  right: offset,\n                  bottom: offset,\n                  borderRadius: 12,\n                  background: token.colorBgContainer,\n                  backgroundImage: `url(${src})`,\n                  backgroundSize: \"cover\",\n                  backgroundPosition: \"center\",\n                  boxShadow: \"0 4px 12px rgba(0,0,0,0.1)\",\n                  zIndex: zIndex,\n                  transform: `translateY(${translateY}px) translateX(${translateX}px) rotate(${rotate}deg) scale(${scale})`,\n                  transformOrigin: \"bottom center\",\n                  // Performance: only transition transform, use GPU acceleration\n                  transition: \"transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)\",\n                  willChange: hover ? \"transform\" : \"auto\",\n                  backfaceVisibility: \"hidden\",\n                  border: `2px solid ${token.colorBgContainer}`,\n                  contain: \"paint\"\n                }}\n              />\n            );\n          })\n        ) : (\n          <div style={{\n            height: \"100%\",\n            background: token.colorFillAlter,\n            borderRadius: 12,\n            display: \"flex\",\n            alignItems: \"center\",\n            justifyContent: \"center\",\n            border: `1px dashed ${token.colorBorder}`\n          }}>\n            <FolderOpenOutlined style={{ fontSize: 32, color: token.colorTextQuaternary }} />\n          </div>\n        )}\n      </div>\n\n      {/* Info Area */}\n      <div style={{\n        position: \"absolute\",\n        bottom: 0,\n        left: 0,\n        right: 0,\n        height: 50,\n        display: \"flex\",\n        alignItems: \"center\",\n        justifyContent: \"space-between\",\n        padding: \"0 8px\"\n      }}>\n        <div style={{ flex: 1, minWidth: 0 }}>\n          <div style={{ fontWeight: 600, fontSize: 15, whiteSpace: \"nowrap\", overflow: \"hidden\", textOverflow: \"ellipsis\" }}>\n            {album.name}\n          </div>\n          <div style={{ fontSize: 12, color: token.colorTextSecondary }}>\n            {dayjs(album.mtime).format(\"YYYY-MM-DD\")}\n          </div>\n        </div>\n\n        <Dropdown\n          menu={{\n            items: [\n              { key: 'share', label: '分享相册', icon: <ShareAltOutlined />, onClick: (e) => { e.domEvent.stopPropagation(); onShare(); } },\n              !isSystem && { key: 'password', label: album.locked ? '管理密码' : '设置密码', icon: album.locked ? <UnlockOutlined /> : <LockOutlined />, onClick: (e) => { e.domEvent.stopPropagation(); onSetPassword(); } },\n              // System albums cannot be renamed or deleted\n              !isSystem && { key: 'rename', label: '重命名', icon: <EditOutlined />, onClick: (e) => { e.domEvent.stopPropagation(); onRename(); } },\n              !isSystem && { type: 'divider' },\n              !isSystem && { key: 'delete', label: '删除相册', icon: <DeleteOutlined />, danger: true, onClick: (e) => { e.domEvent.stopPropagation(); onDelete(); } },\n            ].filter(Boolean)\n          }}\n          trigger={['click']}\n        >\n          <Button\n            type=\"text\"\n            icon={<MoreOutlined />}\n            onClick={e => e.stopPropagation()}\n          />\n        </Dropdown>\n      </div>\n    </div>\n  );\n});\n\nconst CheckCircleIcon = () => (\n  <span style={{ marginRight: 6 }}>✓</span>\n);\n\nexport default AlbumManager;\n"
  },
  {
    "path": "client/src/components/ApiDocs.js",
    "content": "import React from 'react';\nimport { Typography, Card, Collapse, Tag, Divider, theme, Button, message, Tooltip } from 'antd';\nimport {\n  FileImageOutlined,\n  FolderOutlined,\n  InfoCircleOutlined,\n  CopyOutlined,\n  CodeOutlined,\n  FileTextOutlined,\n  LockOutlined\n} from '@ant-design/icons';\nimport { getPassword } from \"../utils/secureStorage\";\n\nconst { Title, Text, Paragraph } = Typography;\nconst { Panel } = Collapse;\n\nconst ApiDocs = () => {\n  const { token } = theme.useToken();\n  const origin = typeof window !== \"undefined\" ? window.location.origin : \"\";\n  const savedPassword = typeof window !== \"undefined\" ? (getPassword() || \"\") : \"\";\n\n  const containerStyle = {\n    maxWidth: 900,\n    margin: '0 auto',\n    padding: '40px 20px',\n  };\n\n  const endpointStyle = {\n    display: 'flex',\n    alignItems: 'center',\n    gap: 12,\n    marginBottom: 8,\n    flexWrap: 'wrap'\n  };\n\n  const methodTagStyle = (method) => {\n    return { minWidth: 60, textAlign: 'center', fontWeight: 'bold' };\n  };\n\n  const copyText = (text) => {\n    if (navigator.clipboard && window.isSecureContext) {\n      navigator.clipboard.writeText(text)\n        .then(() => message.success(\"已复制 CURL 命令\"))\n        .catch(() => message.error(\"复制失败\"));\n      return;\n    }\n    // Fallback\n    const input = document.createElement(\"input\");\n    input.style.position = \"fixed\";\n    input.style.top = \"-10000px\";\n    document.body.appendChild(input);\n    input.value = text;\n    input.focus();\n    input.select();\n    try {\n      document.execCommand(\"copy\");\n      message.success(\"已复制 CURL 命令\");\n    } catch (e) {\n      message.error(\"复制失败\");\n    } finally {\n      document.body.removeChild(input);\n    }\n  };\n\n  const buildCurl = (endpoint, method = 'GET', options = {}) => {\n    const fullUrl = `${origin}${endpoint}`;\n    const pwdHeader = savedPassword ? ` -H \"X-Access-Password: ${savedPassword}\"` : \"\";\n    const albumPwdHeader = options.albumPassword ? ` -H \"X-Album-Password: ${options.albumPassword}\"` : \"\";\n    let cmd = `curl -X ${method} \"${fullUrl}\"${pwdHeader}${albumPwdHeader}`;\n\n    if (method === 'POST') {\n      if (options.isMultipart) {\n        cmd += ` \\\\\\n  -F \"${options.fileParam || 'image'}=@/path/to/file\"`;\n        if (options.extraParams) {\n          options.extraParams.forEach(p => {\n            cmd += ` \\\\\\n  -F \"${p.key}=${p.value}\"`;\n          });\n        }\n      } else if (options.isJson) {\n        cmd += ` \\\\\\n  -H \"Content-Type: application/json\" \\\\\\n  -d '${JSON.stringify(options.body)}'`;\n      }\n    }\n\n    return cmd;\n  };\n\n  const CurlButton = ({ endpoint, method, options }) => (\n    <Tooltip title=\"复制 CURL 命令\">\n      <Button\n        size=\"small\"\n        icon={<CopyOutlined />}\n        onClick={(e) => {\n          e.stopPropagation();\n          copyText(buildCurl(endpoint, method, options));\n        }}\n      >\n        CURL\n      </Button>\n    </Tooltip>\n  );\n\n  return (\n    <div style={containerStyle}>\n      <div style={{ textAlign: 'center', marginBottom: 40 }}>\n        <Title level={1} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12 }}>\n          <img\n            src=\"/favicon.svg\"\n            alt=\"Logo\"\n            style={{\n              width: 48,\n              height: 48,\n              objectFit: 'contain',\n              filter: theme.useToken().token.colorBgContainer === '#141414' ? 'brightness(1.2)' : 'none'\n            }}\n          />\n          云图 - 开放接口文档\n        </Title>\n        <Paragraph type=\"secondary\" style={{ fontSize: 16 }}>\n          云图提供了一系列 RESTful API，方便您进行图片的上传、管理与检索。\n        </Paragraph>\n        {savedPassword && (\n          <Tag color=\"success\" icon={<CodeOutlined />}>\n            已自动在 CURL 示例中包含您的访问密码\n          </Tag>\n        )}\n      </div>\n\n      <Collapse defaultActiveKey={['1', '2', '3']} size=\"large\">\n        <Panel\n          header={<div style={{ fontWeight: 600, fontSize: 16 }}>认证管理 (Authentication)</div>}\n          key=\"0\"\n          extra={<LockOutlined />}\n        >\n          <Card type=\"inner\" title=\"检查认证状态\" bordered={false}>\n            <div style={endpointStyle}>\n              <Tag color=\"blue\" style={methodTagStyle('GET')}>GET</Tag>\n              <Text code copyable>/api/auth/status</Text>\n              <CurlButton endpoint=\"/api/auth/status\" method=\"GET\" />\n            </div>\n            <Paragraph>\n              检查当前系统是否开启了密码保护。\n            </Paragraph>\n          </Card>\n\n          <Divider />\n\n          <Card type=\"inner\" title=\"验证访问密码\" bordered={false}>\n            <div style={endpointStyle}>\n              <Tag color=\"green\" style={methodTagStyle('POST')}>POST</Tag>\n              <Text code copyable>/api/auth/login</Text>\n              <CurlButton\n                endpoint=\"/api/auth/login\"\n                method=\"POST\"\n                options={{\n                  isJson: true,\n                  body: { password: \"your_password\" }\n                }}\n              />\n            </div>\n            <Paragraph>\n              验证系统访问密码。验证成功后，请在后续请求 Header 中携带 <Text code>X-Access-Password</Text>。\n            </Paragraph>\n            <Divider orientation=\"left\" plain>Body (JSON)</Divider>\n            <ul>\n              <li><Text code>password</Text>: 访问密码</li>\n            </ul>\n          </Card>\n        </Panel>\n\n        <Panel\n          header={<div style={{ fontWeight: 600, fontSize: 16 }}>图片管理 (Images)</div>}\n          key=\"1\"\n          extra={<FileImageOutlined />}\n        >\n          <Card type=\"inner\" title=\"获取图片列表\" bordered={false}>\n            <div style={endpointStyle}>\n              <Tag color=\"blue\" style={methodTagStyle('GET')}>GET</Tag>\n              <Text code copyable>/api/images</Text>\n              <CurlButton endpoint=\"/api/images?page=1&pageSize=20\" method=\"GET\" />\n            </div>\n            <Paragraph>\n              分页获取图片列表，支持按目录筛选和关键词搜索。\n            </Paragraph>\n            <Divider orientation=\"left\" plain>参数</Divider>\n            <ul>\n              <li><Text code>page</Text>: 页码 (默认 1)</li>\n              <li><Text code>pageSize</Text>: 每页数量 (默认 50)</li>\n              <li><Text code>dir</Text>: 目录路径 (可选)</li>\n              <li><Text code>search</Text>: 搜索关键词 (可选)</li>\n            </ul>\n            <Divider orientation=\"left\" plain>Headers</Divider>\n            <ul>\n              <li><Text code>X-Album-Password</Text>: 相册访问密码 (如果访问的目录已加密)</li>\n            </ul>\n          </Card>\n\n          <Divider />\n\n          <Card type=\"inner\" title=\"上传图片\" bordered={false}>\n            <div style={endpointStyle}>\n              <Tag color=\"green\" style={methodTagStyle('POST')}>POST</Tag>\n              <Text code copyable>/api/upload</Text>\n              <CurlButton\n                endpoint=\"/api/upload\"\n                method=\"POST\"\n                options={{ isMultipart: true, extraParams: [{ key: 'dir', value: 'uploads' }] }}\n              />\n            </div>\n            <Paragraph>\n              上传单张或多张图片到指定目录。\n            </Paragraph>\n            <Divider orientation=\"left\" plain>Body (FormData)</Divider>\n            <ul>\n              <li><Text code>image</Text>: 图片文件 (支持多文件)</li>\n              <li><Text code>dir</Text>: 目标目录 (可选，默认为根目录)</li>\n            </ul>\n          </Card>\n\n          <Divider />\n\n          <Card type=\"inner\" title=\"获取随机图片\" bordered={false}>\n            <div style={endpointStyle}>\n              <Tag color=\"blue\" style={methodTagStyle('GET')}>GET</Tag>\n              <Text code copyable>/api/random</Text>\n              <CurlButton endpoint=\"/api/random?format=json\" method=\"GET\" />\n            </div>\n            <Paragraph>\n              随机获取一张图片。支持实时图像处理参数（缩放、格式转换等）。\n            </Paragraph>\n            <Divider orientation=\"left\" plain>参数</Divider>\n            <ul>\n              <li><Text code>dir</Text>: 目录路径 (可选)</li>\n              <li><Text code>format</Text>: 返回格式，<Text code>json</Text> 返回元数据（包含 <Text code>fullUrl</Text>），否则直接返回图片流</li>\n              <li><Text code>w</Text>: 目标宽度 (可选)</li>\n              <li><Text code>h</Text>: 目标高度 (可选)</li>\n              <li><Text code>q</Text>: 图片质量，1-100 (可选)</li>\n              <li><Text code>fmt</Text>: 目标格式，支持 <Text code>webp</Text>, <Text code>avif</Text>, <Text code>jpg</Text>, <Text code>png</Text> (可选)</li>\n            </ul>\n          </Card>\n\n          <Divider />\n\n          <Card type=\"inner\" title=\"上传图片 (Base64)\" bordered={false}>\n            <div style={endpointStyle}>\n              <Tag color=\"green\" style={methodTagStyle('POST')}>POST</Tag>\n              <Text code copyable>/api/upload-base64</Text>\n              <CurlButton\n                endpoint=\"/api/upload-base64\"\n                method=\"POST\"\n                options={{\n                  isJson: true,\n                  body: { base64Image: \"data:image/png;base64,iVBORw0KGgo...\", dir: \"uploads\", originalName: \"test.png\" }\n                }}\n              />\n            </div>\n            <Paragraph>\n              通过 Base64 字符串上传图片。\n            </Paragraph>\n            <Divider orientation=\"left\" plain>Body (JSON)</Divider>\n            <ul>\n              <li><Text code>base64Image</Text>: Base64 图片字符串 (包含 data URI scheme)</li>\n              <li><Text code>dir</Text>: 目标目录 (可选)</li>\n              <li><Text code>originalName</Text>: 原始文件名 (可选，用于保留扩展名)</li>\n            </ul>\n          </Card>\n\n          <Divider />\n\n          <Card type=\"inner\" title=\"重命名/移动图片\" bordered={false}>\n            <div style={endpointStyle}>\n              <Tag color=\"orange\" style={methodTagStyle('PUT')}>PUT</Tag>\n              <Text code copyable>/api/images/:path</Text>\n              <CurlButton\n                endpoint=\"/api/images/example.jpg\"\n                method=\"PUT\"\n                options={{\n                  isJson: true,\n                  body: { newName: \"new-name.jpg\", newDir: \"new/path\" }\n                }}\n              />\n            </div>\n            <Paragraph>\n              对图片进行重命名或移动到其他目录。\n            </Paragraph>\n            <Divider orientation=\"left\" plain>Body (JSON)</Divider>\n            <ul>\n              <li><Text code>newName</Text>: 新文件名 (可选)</li>\n              <li><Text code>newDir</Text>: 新目录路径 (可选)</li>\n            </ul>\n          </Card>\n\n          <Divider />\n\n          <Card type=\"inner\" title=\"删除图片\" bordered={false}>\n            <div style={endpointStyle}>\n              <Tag color=\"red\" style={methodTagStyle('DELETE')}>DELETE</Tag>\n              <Text code copyable>/api/images/:path</Text>\n              <CurlButton endpoint=\"/api/images/example.jpg\" method=\"DELETE\" />\n            </div>\n            <Paragraph>\n              删除指定路径的图片。\n            </Paragraph>\n          </Card>\n        </Panel>\n\n        <Panel\n          header={<div style={{ fontWeight: 600, fontSize: 16 }}>文件操作 (Files)</div>}\n          key=\"2\"\n          extra={<FileTextOutlined />}\n        >\n          <Card type=\"inner\" title=\"上传任意文件\" bordered={false}>\n            <div style={endpointStyle}>\n              <Tag color=\"green\" style={methodTagStyle('POST')}>POST</Tag>\n              <Text code copyable>/api/upload-file</Text>\n              <CurlButton\n                endpoint=\"/api/upload-file\"\n                method=\"POST\"\n                options={{\n                  isMultipart: true,\n                  fileParam: 'file',\n                  extraParams: [{ key: 'dir', value: 'files' }, { key: 'filename', value: 'custom.ext' }]\n                }}\n              />\n            </div>\n            <Paragraph>\n              上传任意类型文件，支持自动解析音视频时长。\n            </Paragraph>\n            <Divider orientation=\"left\" plain>Body (FormData)</Divider>\n            <ul>\n              <li><Text code>file</Text>: 文件对象</li>\n              <li><Text code>dir</Text>: 目标目录</li>\n              <li><Text code>filename</Text>: 自定义文件名 (可选)</li>\n            </ul>\n          </Card>\n        </Panel>\n\n        <Panel\n          header={<div style={{ fontWeight: 600, fontSize: 16 }}>目录管理 (Directories)</div>}\n          key=\"3\"\n          extra={<FolderOutlined />}\n        >\n          <Card type=\"inner\" title=\"获取目录列表\" bordered={false}>\n            <div style={endpointStyle}>\n              <Tag color=\"blue\" style={methodTagStyle('GET')}>GET</Tag>\n              <Text code copyable>/api/dirs</Text>\n              <CurlButton endpoint=\"/api/dirs\" method=\"GET\" />\n            </div>\n            <Paragraph>\n              获取当前所有的图片目录结构。\n            </Paragraph>\n          </Card>\n\n          <Divider />\n\n          <Card type=\"inner\" title=\"设置相册密码\" bordered={false}>\n            <div style={endpointStyle}>\n              <Tag color=\"green\" style={methodTagStyle('POST')}>POST</Tag>\n              <Text code copyable>/api/album/password</Text>\n              <CurlButton\n                endpoint=\"/api/album/password\"\n                method=\"POST\"\n                options={{\n                  isJson: true,\n                  body: { dir: \"private-album\", password: \"123\" }\n                }}\n              />\n            </div>\n            <Paragraph>\n              设置或移除相册的访问密码。\n            </Paragraph>\n            <Divider orientation=\"left\" plain>Body (JSON)</Divider>\n            <ul>\n              <li><Text code>dir</Text>: 目录路径</li>\n              <li><Text code>password</Text>: 新密码 (留空则移除密码)</li>\n            </ul>\n          </Card>\n\n          <Divider />\n\n          <Card type=\"inner\" title=\"验证相册密码\" bordered={false}>\n            <div style={endpointStyle}>\n              <Tag color=\"green\" style={methodTagStyle('POST')}>POST</Tag>\n              <Text code copyable>/api/album/verify</Text>\n              <CurlButton\n                endpoint=\"/api/album/verify\"\n                method=\"POST\"\n                options={{\n                  isJson: true,\n                  body: { dir: \"private-album\", password: \"123\" }\n                }}\n              />\n            </div>\n            <Paragraph>\n              验证相册密码是否正确。\n            </Paragraph>\n            <Divider orientation=\"left\" plain>Body (JSON)</Divider>\n            <ul>\n              <li><Text code>dir</Text>: 目录路径</li>\n              <li><Text code>password</Text>: 待验证的密码</li>\n            </ul>\n          </Card>\n        </Panel>\n\n        <Panel\n          header={<div style={{ fontWeight: 600, fontSize: 16 }}>系统信息 (System)</div>}\n          key=\"4\"\n          extra={<InfoCircleOutlined />}\n        >\n          <Card type=\"inner\" title=\"获取存储状态\" bordered={false}>\n            <div style={endpointStyle}>\n              <Tag color=\"blue\" style={methodTagStyle('GET')}>GET</Tag>\n              <Text code copyable>/api/stats</Text>\n              <CurlButton endpoint=\"/api/stats\" method=\"GET\" />\n            </div>\n            <Paragraph>\n              获取服务器存储空间使用情况及图片总数统计。\n            </Paragraph>\n          </Card>\n        </Panel>\n        <Panel\n          header={<div style={{ fontWeight: 600, fontSize: 16 }}>工具接口 (Tools)</div>}\n          key=\"5\"\n          extra={<CodeOutlined />}\n        >\n          <Card type=\"inner\" title=\"图片处理\" bordered={false}>\n            <div style={endpointStyle}>\n              <Tag color=\"green\" style={methodTagStyle('POST')}>POST</Tag>\n              <Text code copyable>/api/process-image</Text>\n              <CurlButton\n                endpoint=\"/api/process-image\"\n                method=\"POST\"\n                options={{\n                  isMultipart: true,\n                  extraParams: [\n                    { key: 'width', value: '300' },\n                    { key: 'height', value: '300' },\n                    { key: 'dir', value: 'processed' }\n                  ]\n                }}\n              />\n            </div>\n            <Paragraph>\n              上传并调整图片尺寸（保持纵横比缩放至目标尺寸）。\n            </Paragraph>\n            <Divider orientation=\"left\" plain>Body (FormData)</Divider>\n            <ul>\n              <li><Text code>image</Text>: 图片文件</li>\n              <li><Text code>width</Text>: 目标宽度</li>\n              <li><Text code>height</Text>: 目标高度</li>\n              <li><Text code>dir</Text>: 存储目录 (可选)</li>\n            </ul>\n          </Card>\n\n          <Divider />\n\n          <Card type=\"inner\" title=\"SVG 转 PNG\" bordered={false}>\n            <div style={endpointStyle}>\n              <Tag color=\"green\" style={methodTagStyle('POST')}>POST</Tag>\n              <Text code copyable>/api/svg2png</Text>\n              <CurlButton\n                endpoint=\"/api/svg2png\"\n                method=\"POST\"\n                options={{\n                  isJson: true,\n                  body: { svgCode: \"<svg>...</svg>\" }\n                }}\n              />\n            </div>\n            <Paragraph>\n              将 SVG 代码转换为 PNG 图片流。\n            </Paragraph>\n            <Divider orientation=\"left\" plain>Body (JSON)</Divider>\n            <ul>\n              <li><Text code>svgCode</Text>: SVG 源代码字符串</li>\n            </ul>\n          </Card>\n        </Panel>\n      </Collapse>\n\n      <div style={{ marginTop: 40, textAlign: 'center', color: token.colorTextSecondary }}>\n        <Text type=\"secondary\">© 2025 Cloud Gallery API. All rights reserved.</Text>\n      </div>\n    </div>\n  );\n};\n\nexport default ApiDocs;\n"
  },
  {
    "path": "client/src/components/DirectorySelector.js",
    "content": "import React, { useState, useEffect, useRef, useCallback } from \"react\";\nimport { Select, Input, Space, Typography, Divider, message } from \"antd\";\nimport { FolderOutlined, PlusOutlined, RightOutlined, DownOutlined } from \"@ant-design/icons\";\n\nconst { Option } = Select;\nconst { Text } = Typography;\n\nconst DirectorySelector = ({\n  value,\n  onChange,\n  placeholder = \"选择或输入相册\",\n  style = {},\n  allowClear = true,\n  showSearch = true,\n  size = \"middle\",\n  allowInput = true,\n  api,\n  refreshKey = 0,\n}) => {\n  const [directories, setDirectories] = useState([]);\n  const [loading, setLoading] = useState(false);\n  const [createName, setCreateName] = useState(\"\");\n  const [expandedPaths, setExpandedPaths] = useState([]);\n  const [isSearching, setIsSearching] = useState(false);\n  const inputRef = useRef(null);\n\n  // 获取相册列表\n  const fetchDirectories = useCallback(async () => {\n    setLoading(true);\n    try {\n      const response = await api.get(\"/directories?recursive=true\");\n      if (response.data.success) {\n        setDirectories(response.data.data || []);\n      }\n    } catch (error) {\n      console.error(\"获取相册列表失败:\", error);\n    } finally {\n      setLoading(false);\n    }\n  }, [api]);\n\n  useEffect(() => {\n    fetchDirectories();\n  }, [refreshKey, fetchDirectories]);\n\n  // Auto expand parents of the current value\n  useEffect(() => {\n    if (value && directories.length > 0) {\n      const parts = value.split(\"/\");\n      if (parts.length > 1) {\n        const pathsToExpand = [];\n        let currentPath = \"\";\n        for (let i = 0; i < parts.length - 1; i++) {\n          currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];\n          pathsToExpand.push(currentPath);\n        }\n        setExpandedPaths(prev => Array.from(new Set([...prev, ...pathsToExpand])));\n      }\n    }\n  }, [value, directories]);\n\n  const handleSelectChange = (selectedValue) => {\n    if (onChange) {\n      onChange(selectedValue);\n    }\n  };\n\n  const handleInputChange = (e) => {\n    setCreateName(e.target.value);\n  };\n\n  const handleInputKeyPress = (e) => {\n    if (e.key === \"Enter\") {\n      addNewDirectory();\n    }\n  };\n\n  const handleSearch = (searchValue) => {\n    setIsSearching(!!searchValue);\n  };\n\n  const toggleExpand = (e, path) => {\n    e.stopPropagation(); // Prevent selection\n    setExpandedPaths(prev =>\n      prev.includes(path)\n        ? prev.filter(p => p !== path)\n        : [...prev, path]\n    );\n  };\n\n  const addNewDirectory = async (e) => {\n    if (e) e.preventDefault?.();\n    const val = (createName || \"\").trim();\n    if (!val) return;\n\n    try {\n      const res = await api.post(\"/directories\", { name: val });\n      if (res.data.success) {\n        message.success(\"相册创建成功\");\n        await fetchDirectories();\n        if (onChange) {\n          // Use returned path if available, or input value\n          const newPath = res.data.data?.path || val;\n          onChange(newPath);\n        }\n        setCreateName(\"\");\n      }\n    } catch (e) {\n      message.error(e.response?.data?.error || \"创建失败\");\n    }\n\n    setTimeout(() => {\n      inputRef.current?.focus();\n    }, 0);\n  };\n\n  return (\n    <Space direction=\"vertical\" style={{ width: \"100%\" }}>\n      <Select\n        placeholder={placeholder}\n        value={value}\n        onChange={handleSelectChange}\n        style={{ width: \"100%\", ...style }}\n        allowClear={allowClear}\n        showSearch={showSearch}\n        size={size}\n        loading={loading}\n        onSearch={handleSearch}\n        optionLabelProp=\"label\"\n        filterOption={(input, option) => {\n          if (!input) return true;\n          // Use search text from the dir name or path\n          const searchContent = option.searchValue || \"\";\n          return searchContent.toLowerCase().indexOf(input.toLowerCase()) >= 0;\n        }}\n        notFoundContent={loading ? \"加载中...\" : \"暂无相册\"}\n        popupClassName=\"directory-selector-dropdown\"\n        dropdownRender={(menu) => (\n          <div>\n            {menu}\n            {allowInput && (\n              <>\n                <Divider style={{ margin: \"8px 0\" }} />\n                <div style={{ padding: \"0 8px 8px\" }}>\n                  <Input\n                    placeholder=\"输入新相册名称 (支持多级如 A/B)\"\n                    ref={inputRef}\n                    value={createName}\n                    onChange={handleInputChange}\n                    onKeyDown={(e) => e.stopPropagation()}\n                    onKeyPress={handleInputKeyPress}\n                    size={size}\n                    suffix={\n                      <PlusOutlined\n                        style={{ cursor: \"pointer\" }}\n                        onClick={addNewDirectory}\n                      />\n                    }\n                  />\n                </div>\n              </>\n            )}\n          </div>\n        )}\n      >\n        <Option value=\"\" searchValue=\"全部图片\" label=\"全部图片\">\n          <Space>\n            <FolderOutlined />\n            <Text>全部图片</Text>\n          </Space>\n        </Option>\n        {directories.map((dir) => {\n          const parts = (dir.path || \"\").split(\"/\").filter(Boolean);\n          const depth = parts.length - 1;\n          const isExpanded = expandedPaths.includes(dir.path);\n          const hasChildren = directories.some(d => d.path !== dir.path && d.path.startsWith(dir.path + '/'));\n\n          // Visibility check\n          let isVisible = true;\n          if (parts.length > 1 && !isSearching) {\n            let currentPath = \"\";\n            for (let i = 0; i < parts.length - 1; i++) {\n              currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];\n              if (!expandedPaths.includes(currentPath)) {\n                isVisible = false;\n                break;\n              }\n            }\n          }\n\n          if (!isVisible && !isSearching) return null;\n\n          return (\n            <Option key={dir.path} value={dir.path} searchValue={dir.name} label={dir.name}>\n              <div style={{ paddingLeft: depth * 16, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>\n                <Space size={4}>\n                  <FolderOutlined style={{ color: '#1890ff' }} />\n                  <Text>{dir.name}</Text>\n                </Space>\n                {hasChildren && !isSearching && (\n                  <div\n                    onClick={(e) => toggleExpand(e, dir.path)}\n                    style={{\n                      padding: '0 4px',\n                      cursor: 'pointer',\n                      display: 'flex',\n                      alignItems: 'center',\n                      height: '100%',\n                      marginLeft: '8px'\n                    }}\n                  >\n                    {isExpanded ? <DownOutlined style={{ fontSize: 10, color: '#999' }} /> : <RightOutlined style={{ fontSize: 10, color: '#999' }} />}\n                  </div>\n                )}\n              </div>\n            </Option>\n          );\n        })}\n      </Select>\n\n    </Space>\n  );\n};\n\nexport default DirectorySelector;\n"
  },
  {
    "path": "client/src/components/FloatingToolbar.js",
    "content": "import React, { useState } from \"react\";\nimport { \n  Modal, \n  Tooltip, \n  theme, \n  Button, \n  FloatButton, \n  Popconfirm \n} from \"antd\";\nimport {\n  CloudUploadOutlined,\n  ReloadOutlined,\n  SunOutlined,\n  MoonOutlined,\n  CheckSquareOutlined,\n  CloseOutlined,\n  DeleteOutlined,\n  DeliveredProcedureOutlined,\n  GlobalOutlined,\n} from \"@ant-design/icons\";\nimport UploadComponent from \"./UploadComponent\";\n\nconst FloatingToolbar = ({\n  onThemeChange,\n  currentTheme,\n  onRefresh,\n  api,\n  isMobile,\n  isBatchMode,\n  toggleBatchMode,\n  selectedCount,\n  onBatchDelete,\n  onBatchMove,\n}) => {\n  const [uploadVisible, setUploadVisible] = useState(false);\n  const { token } = theme.useToken();\n  \n  // Infer dark mode\n  const isDarkMode = currentTheme === \"dark\";\n\n  const handleUploadSuccess = () => {\n    setUploadVisible(false);\n    if (onRefresh) {\n      onRefresh();\n    }\n  };\n\n  const buttonStyle = {\n    background: \"transparent\",\n    border: \"none\",\n    color: isDarkMode ? \"rgba(255,255,255,0.85)\" : \"rgba(0,0,0,0.85)\",\n    boxShadow: \"none\",\n    width: 32,\n    height: 32,\n    minWidth: 32,\n    fontSize: 16,\n    display: \"flex\",\n    alignItems: \"center\",\n    justifyContent: \"center\",\n  };\n\n  return (\n    <>\n      <div\n        style={{\n          position: \"fixed\",\n          right: 24,\n          bottom: 24,\n          display: \"flex\",\n          alignItems: \"center\",\n          gap: 6, // Reduced gap\n          background: isDarkMode ? \"rgba(0, 0, 0, 0.6)\" : \"rgba(255, 255, 255, 0.6)\",\n          backdropFilter: \"blur(20px)\",\n          WebkitBackdropFilter: \"blur(20px)\",\n          padding: \"6px 10px\", // Reduced padding\n          borderRadius: \"100px\",\n          boxShadow: isDarkMode \n            ? \"0 8px 32px rgba(0, 0, 0, 0.4)\" \n            : \"0 8px 32px rgba(0, 0, 0, 0.1)\",\n          border: `1px solid ${isDarkMode ? \"rgba(255, 255, 255, 0.1)\" : \"rgba(255, 255, 255, 0.4)\"}`,\n          zIndex: 1000,\n          transition: \"all 0.3s ease\",\n        }}\n      >\n        {/* Batch Actions */}\n        {isBatchMode && selectedCount > 0 && (\n           <>\n            {/* Move Button */}\n            <Tooltip title=\"移动到相册\" placement=\"top\">\n                <Button\n                    shape=\"circle\"\n                    icon={<DeliveredProcedureOutlined />}\n                    type=\"primary\"\n                    size=\"middle\"\n                    onClick={onBatchMove}\n                    style={{\n                        ...buttonStyle,\n                        color: '#fff', \n                        background: token.colorPrimary,\n                        boxShadow: `0 2px 8px ${token.colorPrimary}50`\n                    }}\n                    className=\"toolbar-btn\"\n                />\n            </Tooltip>\n            <div style={{ width: 1, height: 16, background: isDarkMode ? \"rgba(255,255,255,0.2)\" : \"rgba(0,0,0,0.1)\" }} />\n\n            <Popconfirm\n                title={`确定删除选中的 ${selectedCount} 张图片?`}\n                onConfirm={onBatchDelete}\n                okText=\"是\"\n                cancelText=\"否\"\n                placement=\"topRight\"\n            >\n                <Tooltip title=\"批量删除\" placement=\"top\">\n                <Button\n                    shape=\"circle\"\n                    icon={<DeleteOutlined />}\n                    danger\n                    type=\"primary\"\n                    size=\"middle\"\n                    style={{\n                        ...buttonStyle,\n                        color: '#fff', \n                        background: '#ff4d4f',\n                        boxShadow: '0 2px 8px rgba(255, 77, 79, 0.35)'\n                    }}\n                    className=\"toolbar-btn\"\n                />\n                </Tooltip>\n            </Popconfirm>\n            <div style={{ width: 1, height: 16, background: isDarkMode ? \"rgba(255,255,255,0.2)\" : \"rgba(0,0,0,0.1)\" }} />\n           </>\n        )}\n\n        {/* Batch Mode Toggle */}\n        <Tooltip title={isBatchMode ? \"退出批量操作\" : \"批量操作\"} placement=\"top\">\n            <Button\n                shape=\"circle\"\n                icon={isBatchMode ? <CloseOutlined /> : <CheckSquareOutlined />}\n                onClick={toggleBatchMode}\n                size=\"middle\"\n                type={isBatchMode ? \"primary\" : \"text\"}\n                style={isBatchMode ? { \n                    ...buttonStyle, \n                    color: '#fff', \n                    background: token.colorPrimary,\n                    boxShadow: `0 2px 8px ${token.colorPrimary}50`\n                } : buttonStyle}\n                className=\"toolbar-btn\"\n            />\n        </Tooltip>\n\n        <div style={{ width: 1, height: 16, background: isDarkMode ? \"rgba(255,255,255,0.2)\" : \"rgba(0,0,0,0.1)\" }} />\n\n        <Tooltip title=\"轨迹地图\" placement=\"top\">\n          <Button\n            shape=\"circle\"\n            icon={<GlobalOutlined />}\n            onClick={() => window.location.href = '/map'}\n            size=\"middle\"\n            type=\"text\"\n            style={buttonStyle}\n            className=\"toolbar-btn\"\n          />\n        </Tooltip>\n\n        <div style={{ width: 1, height: 16, background: isDarkMode ? \"rgba(255,255,255,0.2)\" : \"rgba(0,0,0,0.1)\" }} />\n\n        <Tooltip title=\"刷新列表\" placement=\"top\">\n          <Button \n            shape=\"circle\" \n            icon={<ReloadOutlined />} \n            onClick={onRefresh} \n            size=\"middle\" // Reduced size\n            type=\"text\"\n            style={buttonStyle}\n            className=\"toolbar-btn\"\n          />\n        </Tooltip>\n        \n        <div style={{ width: 1, height: 16, background: isDarkMode ? \"rgba(255,255,255,0.2)\" : \"rgba(0,0,0,0.1)\" }} />\n\n        <Tooltip title={isDarkMode ? \"切换亮色\" : \"切换暗色\"} placement=\"top\">\n          <Button\n            shape=\"circle\"\n            icon={isDarkMode ? <SunOutlined /> : <MoonOutlined />}\n            onClick={() =>\n              onThemeChange(isDarkMode ? \"light\" : \"dark\")\n            }\n            size=\"middle\" // Reduced size\n            type=\"text\"\n            style={buttonStyle}\n            className=\"toolbar-btn\"\n          />\n        </Tooltip>\n\n        {isMobile && (\n          <>\n            <div style={{ width: 1, height: 16, background: isDarkMode ? \"rgba(255,255,255,0.2)\" : \"rgba(0,0,0,0.1)\" }} />\n\n            <Tooltip title=\"上传图片\" placement=\"top\">\n              <Button\n                shape=\"circle\"\n                type=\"primary\"\n                icon={<CloudUploadOutlined />}\n                onClick={() => setUploadVisible(true)}\n                size=\"middle\"\n                className=\"upload-btn\"\n                style={{\n                    width: 32,\n                    height: 32,\n                    minWidth: 32,\n                    fontSize: 16,\n                    color: '#fff',\n                    border: 'none',\n                    boxShadow: '0 4px 10px rgba(0,0,0,0.2)',\n                    display: \"flex\",\n                    alignItems: \"center\",\n                    justifyContent: \"center\",\n                }}\n              />\n            </Tooltip>\n          </>\n        )}\n      </div>\n\n      <style>{`\n        .toolbar-btn {\n          transition: background-color 0.3s ease !important;\n        }\n        .toolbar-btn:hover {\n          background-color: ${isDarkMode ? \"rgba(255,255,255,0.15)\" : \"rgba(0,0,0,0.06)\"} !important;\n        }\n        \n        .upload-btn {\n          background-color: ${token.colorPrimary} !important;\n          transition: filter 0.3s ease, transform 0.3s ease !important;\n        }\n        .upload-btn:hover {\n          filter: brightness(1.1);\n          transform: scale(1.05);\n        }\n\n        /* Custom style for BackTop to match glassmorphism */\n        .ant-float-btn-default {\n           background-color: ${isDarkMode ? \"rgba(0, 0, 0, 0.6)\" : \"rgba(255, 255, 255, 0.6)\"} !important;\n           backdrop-filter: blur(20px);\n           -webkit-backdrop-filter: blur(20px);\n           border: 1px solid ${isDarkMode ? \"rgba(255, 255, 255, 0.1)\" : \"rgba(255, 255, 255, 0.4)\"};\n           box-shadow: ${isDarkMode ? \"0 8px 32px rgba(0, 0, 0, 0.4)\" : \"0 8px 32px rgba(0, 0, 0, 0.1)\"} !important;\n        }\n        .ant-float-btn-default .ant-float-btn-icon {\n           color: ${isDarkMode ? \"rgba(255,255,255,0.85)\" : \"rgba(0,0,0,0.85)\"} !important;\n        }\n        .ant-float-btn-default:hover {\n           background-color: ${isDarkMode ? \"rgba(0, 0, 0, 0.7)\" : \"rgba(255, 255, 255, 0.8)\"} !important;\n        }\n      `}</style>\n\n      <FloatButton.BackTop \n        style={{ \n            right: 24, \n            bottom: 80, // Positioned above the toolbar (approx 24 + 44 + 12 gap)\n            zIndex: 999 \n        }}\n      />\n\n      <Modal\n        open={uploadVisible}\n        title={null}\n        footer={null}\n        onCancel={() => setUploadVisible(false)}\n        width={isMobile ? \"90%\" : 600}\n        centered\n        modalRender={(modal) => (\n            <div style={{ \n                background: isDarkMode ? \"rgba(0,0,0,0.6)\" : \"rgba(255,255,255,0.7)\",\n                backdropFilter: \"blur(20px)\",\n                WebkitBackdropFilter: \"blur(20px)\",\n                borderRadius: 24,\n                boxShadow: \"0 8px 32px 0 rgba(0, 0, 0, 0.37)\",\n                border: `1px solid ${isDarkMode ? \"rgba(255, 255, 255, 0.1)\" : \"rgba(255, 255, 255, 0.4)\"}`,\n                padding: 0,\n                overflow: 'hidden'\n            }}>\n                {modal}\n            </div>\n        )}\n        styles={{\n            content: {\n                background: 'transparent',\n                boxShadow: 'none',\n                padding: 0,\n            },\n            body: {\n                padding: 0,\n            }\n        }}\n        destroyOnClose\n        closeIcon={null}\n      >\n        <div style={{ position: 'relative' }}>\n             {/* Custom close button since we removed the default one */}\n             <Button \n                type=\"text\" \n                shape=\"circle\" \n                onClick={() => setUploadVisible(false)}\n                style={{ \n                    position: 'absolute', \n                    right: 12, \n                    top: 12, \n                    zIndex: 10,\n                    color: isDarkMode ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)' \n                }}\n             >\n                 ✕\n             </Button>\n            <UploadComponent onUploadSuccess={handleUploadSuccess} api={api} isModal={true} />\n        </div>\n      </Modal>\n    </>\n  );\n};\n\nexport default FloatingToolbar;\n"
  },
  {
    "path": "client/src/components/ImageCompressor.js",
    "content": "import React, { useState, useRef, useEffect } from \"react\";\nimport {\n  Card,\n  Typography,\n  Space,\n  Button,\n  Input,\n  message,\n  Row,\n  Col,\n  Slider,\n  Upload,\n  theme,\n} from \"antd\";\nimport {\n  FileZipOutlined,\n  UploadOutlined,\n  DownloadOutlined,\n  CopyOutlined,\n  PictureOutlined,\n} from \"@ant-design/icons\";\n// import axios from \"axios\";\n\nconst { Title, Text } = Typography;\nconst { Dragger } = Upload;\n\nconst ImageCompressor = ({ onUploadSuccess, api }) => {\n  const {\n    token: { colorBorder, colorFillTertiary },\n  } = theme.useToken();\n\n  const [originalImage, setOriginalImage] = useState(null);\n  const [compressedImage, setCompressedImage] = useState(null);\n  const [isCompressing, setIsCompressing] = useState(false);\n  const [isUploading, setIsUploading] = useState(false);\n  const [uploadedUrl, setUploadedUrl] = useState(\"\");\n  const [fileName, setFileName] = useState(\"\");\n  const [originalSize, setOriginalSize] = useState(0);\n  const [compressedSize, setCompressedSize] = useState(0);\n  const [originalAspectRatio, setOriginalAspectRatio] = useState(1);\n\n  // 压缩参数\n  const [width, setWidth] = useState(800);\n  const [height, setHeight] = useState(600);\n  const [quality, setQuality] = useState(100);\n  const [maintainAspectRatio, setMaintainAspectRatio] = useState(true);\n\n  const canvasRef = useRef(null);\n\n  // 处理粘贴事件\n  const handlePaste = async (event) => {\n    const items = event.clipboardData?.items;\n    if (!items) return;\n\n    for (let i = 0; i < items.length; i++) {\n      const item = items[i];\n      if (item.type.startsWith(\"image/\")) {\n        event.preventDefault();\n        const file = item.getAsFile();\n        if (file) {\n          await handlePastedImage(file);\n        }\n        break;\n      }\n    }\n  };\n\n  // 处理粘贴的图片\n  const handlePastedImage = async (file) => {\n    const isImage = file.type.startsWith(\"image/\");\n    if (!isImage) {\n      message.error(\"只能上传图片文件！\");\n      return;\n    }\n\n    const reader = new FileReader();\n    reader.onload = (e) => {\n      const img = new Image();\n      img.onload = () => {\n        // 设置原始图片信息\n        setOriginalImage(e.target.result);\n        setOriginalSize(file.size);\n        setFileName(`pasted-image-${Date.now()}`); // 生成文件名\n\n        // 设置默认尺寸为原始图片尺寸\n        setWidth(img.width);\n        setHeight(img.height);\n        setMaintainAspectRatio(true);\n        setOriginalAspectRatio(img.width / img.height);\n\n        message.success(\"图片粘贴成功！\");\n      };\n      img.src = e.target.result;\n    };\n    reader.readAsDataURL(file);\n  };\n\n  // 添加全局粘贴事件监听\n  useEffect(() => {\n    const handleGlobalPaste = (event) => {\n      // 检查是否在输入框中，如果是则不处理粘贴\n      const target = event.target;\n      if (\n        target.tagName === \"INPUT\" ||\n        target.tagName === \"TEXTAREA\" ||\n        target.contentEditable === \"true\"\n      ) {\n        return;\n      }\n\n      handlePaste(event);\n    };\n\n    document.addEventListener(\"paste\", handleGlobalPaste);\n\n    return () => {\n      document.removeEventListener(\"paste\", handleGlobalPaste);\n    };\n  }, []);\n\n  // 处理图片上传\n  const handleImageUpload = (file) => {\n    const isImage = file.type.startsWith(\"image/\");\n    if (!isImage) {\n      message.error(\"只能上传图片文件！\");\n      return false;\n    }\n\n    const reader = new FileReader();\n    reader.onload = (e) => {\n      const img = new Image();\n      img.onload = () => {\n        // 设置原始图片信息\n        setOriginalImage(e.target.result);\n        setOriginalSize(file.size);\n        setFileName(file.name.replace(/\\.[^/.]+$/, \"\")); // 移除扩展名\n\n        // 设置默认尺寸为原始图片尺寸\n        setWidth(img.width);\n        setHeight(img.height);\n        setMaintainAspectRatio(true);\n        setOriginalAspectRatio(img.width / img.height);\n\n        // 保存原始图片引用\n        // originalImageRef.current = img; // This line is removed\n\n        message.success(\"图片上传成功！\");\n      };\n      img.src = e.target.result;\n    };\n    reader.readAsDataURL(file);\n\n    return false; // 阻止默认上传行为\n  };\n\n  // 压缩图片\n  const compressImage = () => {\n    if (!originalImage || !canvasRef.current) {\n      message.error(\"请先上传图片\");\n      return;\n    }\n\n    setIsCompressing(true);\n    try {\n      const canvas = canvasRef.current;\n      const ctx = canvas.getContext(\"2d\");\n      const img = new Image();\n      img.onload = () => {\n        // 设置画布尺寸\n        canvas.width = width;\n        canvas.height = height;\n\n        // 清空画布（透明填充）\n        ctx.clearRect(0, 0, canvas.width, canvas.height);\n\n        // 提升缩放质量\n        ctx.imageSmoothingEnabled = true;\n        ctx.imageSmoothingQuality = \"high\";\n\n        // 计算缩放比例和居中坐标\n        const scale = Math.min(width / img.width, height / img.height);\n        // 如果目标尺寸比原图大，保持原图大小不放大\n        const drawWidth = scale > 1 ? img.width : img.width * scale;\n        const drawHeight = scale > 1 ? img.height : img.height * scale;\n        const offsetX = (width - drawWidth) / 2;\n        const offsetY = (height - drawHeight) / 2;\n\n        // 居中绘制图片，保持原图清晰度\n        ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight);\n\n        // 转换为压缩后的图片（PNG，保留透明像素）\n        const compressedDataUrl = canvas.toDataURL(\"image/png\");\n        setCompressedImage(compressedDataUrl);\n\n        // 计算压缩后的大小\n        const base64Length =\n          compressedDataUrl.length - \"data:image/png;base64,\".length;\n        const compressedBytes = Math.ceil(base64Length * 0.75);\n        setCompressedSize(compressedBytes);\n\n        message.success(\"图片压缩成功！\");\n      };\n      img.src = originalImage;\n    } catch (error) {\n      console.error(\"压缩错误:\", error);\n      message.error(\"压缩失败，请重试\");\n    } finally {\n      setIsCompressing(false);\n    }\n  };\n\n  // 处理宽度变化\n  const handleWidthChange = (value) => {\n    setWidth(value);\n    if (maintainAspectRatio) {\n      setHeight(Math.round(value / originalAspectRatio));\n    }\n  };\n\n  // 处理高度变化\n  const handleHeightChange = (value) => {\n    setHeight(value);\n    if (maintainAspectRatio) {\n      setWidth(Math.round(value * originalAspectRatio));\n    }\n  };\n\n  // 切换宽高比锁定\n  const toggleAspectRatio = () => {\n    setMaintainAspectRatio(!maintainAspectRatio);\n  };\n\n  // 下载压缩后的图片\n  const downloadCompressedImage = () => {\n    if (!compressedImage) {\n      message.error(\"请先压缩图片\");\n      return;\n    }\n\n    const link = document.createElement(\"a\");\n    link.download = `${fileName}-compressed.png`;\n    link.href = compressedImage;\n    document.body.appendChild(link);\n    link.click();\n    document.body.removeChild(link);\n    message.success(\"压缩图片下载成功！\");\n  };\n\n  // 上传压缩后的图片\n  const uploadCompressedImage = async () => {\n    if (!compressedImage) {\n      message.error(\"请先压缩图片\");\n      return;\n    }\n\n    setIsUploading(true);\n    try {\n      // 将Data URL转换为Blob\n      const response = await fetch(compressedImage);\n      const blob = await response.blob();\n\n      // 创建FormData\n      const formData = new FormData();\n      formData.append(\"image\", blob, `${fileName}-compressed.png`);\n\n      // 上传到服务器\n      const uploadResponse = await api.post(\"/upload\", formData, {\n        headers: {\n          \"Content-Type\": \"multipart/form-data\",\n        },\n      });\n\n      if (uploadResponse.data.success) {\n        const imageUrl = `${window.location.origin}${uploadResponse.data.data.url}`;\n        setUploadedUrl(imageUrl);\n        message.success(\"压缩图片上传成功！\");\n\n        if (onUploadSuccess) {\n          onUploadSuccess();\n        }\n      } else {\n        message.error(uploadResponse.data.error || \"上传失败\");\n      }\n    } catch (error) {\n      console.error(\"上传错误:\", error);\n      message.error(\"上传失败，请重试\");\n    } finally {\n      setIsUploading(false);\n    }\n  };\n\n  // 复制上传的URL\n  const copyUploadedUrl = () => {\n    if (!uploadedUrl) {\n      message.error(\"没有可复制的URL\");\n      return;\n    }\n\n    navigator.clipboard.writeText(uploadedUrl).then(() => {\n      message.success(\"URL已复制到剪贴板\");\n    });\n  };\n\n  // 格式化文件大小\n  const formatFileSize = (bytes) => {\n    if (bytes === 0) return \"0 Bytes\";\n    const k = 1024;\n    const sizes = [\"Bytes\", \"KB\", \"MB\", \"GB\"];\n    const i = Math.floor(Math.log(bytes) / Math.log(k));\n    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + \" \" + sizes[i];\n  };\n\n  // 计算压缩率\n  const compressionRatio =\n    originalSize > 0\n      ? (((originalSize - compressedSize) / originalSize) * 100).toFixed(1)\n      : 0;\n\n  return (\n    <div>\n      <Title level={2}>\n        <FileZipOutlined /> 图片压缩工具\n      </Title>\n\n      <Row gutter={[24, 24]} style={{ marginTop: 24 }}>\n        {/* 左侧：图片上传和参数设置 */}\n        <Col xs={24} lg={12}>\n          <Card title=\"图片上传\" size=\"small\" style={{ marginBottom: 16 }}>\n            <Dragger\n              accept=\"image/*\"\n              beforeUpload={handleImageUpload}\n              showUploadList={false}\n              disabled={isCompressing}\n            >\n              {originalImage ? (\n                <div style={{ textAlign: \"center\" }}>\n                  <img\n                    src={originalImage}\n                    alt=\"原始图片\"\n                    style={{\n                      maxWidth: \"100%\",\n                      maxHeight: \"200px\",\n                      border: `1px solid ${colorBorder}`,\n                      borderRadius: \"4px\",\n                    }}\n                  />\n                  <div style={{ marginTop: 8 }}>\n                    <Text type=\"secondary\">\n                      原始大小: {formatFileSize(originalSize)}\n                    </Text>\n                  </div>\n                </div>\n              ) : (\n                <div>\n                  <PictureOutlined\n                    style={{ fontSize: \"48px\", color: \"#999\" }}\n                  />\n                  <p>点击或拖拽图片到此区域上传</p>\n                  <p\n                    style={{\n                      color: \"#1890ff\",\n                      fontSize: \"12px\",\n                      marginTop: \"8px\",\n                    }}\n                  >\n                    支持 Ctrl+V 粘贴图片\n                  </p>\n                </div>\n              )}\n            </Dragger>\n          </Card>\n\n          {originalImage && (\n            <Card title=\"压缩参数\" size=\"small\">\n              <Space\n                direction=\"vertical\"\n                style={{ width: \"100%\" }}\n                size=\"middle\"\n              >\n                {/* 文件名 */}\n                <div>\n                  <Text strong>文件名：</Text>\n                  <Input\n                    value={fileName}\n                    onChange={(e) => setFileName(e.target.value)}\n                    placeholder=\"输入文件名（不含扩展名）\"\n                    style={{ marginTop: 8 }}\n                    addonAfter=\".png\"\n                  />\n                </div>\n\n                {/* 尺寸设置 */}\n                <div>\n                  <Text strong>尺寸设置：</Text>\n                  <div style={{ marginTop: 8 }}>\n                    <Row gutter={8}>\n                      <Col span={11}>\n                        <Input\n                          type=\"number\"\n                          placeholder=\"宽度\"\n                          value={width}\n                          onChange={(e) =>\n                            handleWidthChange(parseInt(e.target.value) || 0)\n                          }\n                          addonAfter=\"px\"\n                        />\n                      </Col>\n                      <Col\n                        span={2}\n                        style={{ textAlign: \"center\", lineHeight: \"32px\" }}\n                      >\n                        ×\n                      </Col>\n                      <Col span={11}>\n                        <Input\n                          type=\"number\"\n                          placeholder=\"高度\"\n                          value={height}\n                          onChange={(e) =>\n                            handleHeightChange(parseInt(e.target.value) || 0)\n                          }\n                          addonAfter=\"px\"\n                        />\n                      </Col>\n                    </Row>\n                    <Button\n                      type={maintainAspectRatio ? \"primary\" : \"default\"}\n                      size=\"small\"\n                      onClick={toggleAspectRatio}\n                      style={{ marginTop: 8 }}\n                    >\n                      {maintainAspectRatio ? \"锁定宽高比\" : \"解锁宽高比\"}\n                    </Button>\n                  </div>\n                </div>\n\n                {/* 质量设置 */}\n                <div>\n                  <Text strong>压缩质量：{quality}%</Text>\n                  <Slider\n                    min={1}\n                    max={100}\n                    value={quality}\n                    onChange={setQuality}\n                    style={{ marginTop: 8 }}\n                  />\n                </div>\n\n                {/* 压缩按钮 */}\n                <Button\n                  type=\"primary\"\n                  onClick={compressImage}\n                  loading={isCompressing}\n                  icon={<FileZipOutlined />}\n                  block\n                >\n                  {isCompressing ? \"压缩中...\" : \"开始压缩\"}\n                </Button>\n              </Space>\n            </Card>\n          )}\n        </Col>\n\n        {/* 右侧：压缩结果预览 */}\n        <Col xs={24} lg={12}>\n          <Card title=\"压缩结果\" size=\"small\">\n            <Space direction=\"vertical\" style={{ width: \"100%\" }} size=\"middle\">\n              {compressedImage ? (\n                <>\n                  <div style={{ textAlign: \"center\" }}>\n                    <img\n                      src={compressedImage}\n                      alt=\"压缩后的图片\"\n                      style={{\n                        maxWidth: \"100%\",\n                        maxHeight: \"300px\",\n                        border: `1px solid ${colorBorder}`,\n                        borderRadius: \"4px\",\n                      }}\n                    />\n                  </div>\n\n                  {/* 压缩信息 */}\n                  <div\n                    style={{\n                      padding: \"12px\",\n                      backgroundColor: colorFillTertiary,\n                      borderRadius: \"4px\",\n                    }}\n                  >\n                    <div>\n                      <Text strong>压缩信息：</Text>\n                    </div>\n                    <div style={{ marginTop: 8 }}>\n                      <Row gutter={16}>\n                        <Col span={12}>\n                          <Text type=\"secondary\">原始大小：</Text>\n                          <br />\n                          <Text>{formatFileSize(originalSize)}</Text>\n                        </Col>\n                        <Col span={12}>\n                          <Text type=\"secondary\">压缩后大小：</Text>\n                          <br />\n                          <Text>{formatFileSize(compressedSize)}</Text>\n                        </Col>\n                      </Row>\n                      <div style={{ marginTop: 8 }}>\n                        <Text type=\"secondary\">压缩率：</Text>\n                        <Text style={{ color: \"#52c41a\", fontWeight: \"bold\" }}>\n                          {compressionRatio}%\n                        </Text>\n                      </div>\n                    </div>\n                  </div>\n\n                  <Space>\n                    <Button\n                      icon={<DownloadOutlined />}\n                      onClick={downloadCompressedImage}\n                    >\n                      下载图片\n                    </Button>\n                    <Button\n                      type=\"primary\"\n                      icon={<UploadOutlined />}\n                      onClick={uploadCompressedImage}\n                      loading={isUploading}\n                    >\n                      {isUploading ? \"上传中...\" : \"上传到图床\"}\n                    </Button>\n                  </Space>\n                </>\n              ) : (\n                <div\n                  style={{\n                    textAlign: \"center\",\n                    padding: \"40px 20px\",\n                    color: \"#999\",\n                    border: `2px dashed ${colorBorder}`,\n                    borderRadius: \"4px\",\n                  }}\n                >\n                  <FileZipOutlined\n                    style={{ fontSize: \"48px\", marginBottom: \"16px\" }}\n                  />\n                  <div>压缩后的图片将在这里显示</div>\n                </div>\n              )}\n            </Space>\n          </Card>\n        </Col>\n      </Row>\n\n      {/* 上传结果 */}\n      {uploadedUrl && (\n        <Card title=\"上传结果\" style={{ marginTop: 24 }} size=\"small\">\n          <Space direction=\"vertical\" style={{ width: \"100%\" }}>\n            <div>\n              <Text strong>图片URL：</Text>\n              <Text code style={{ wordBreak: \"break-all\" }}>\n                {uploadedUrl}\n              </Text>\n            </div>\n            <Space>\n              <Button icon={<CopyOutlined />} onClick={copyUploadedUrl}>\n                复制URL\n              </Button>\n              <Button type=\"link\" href={uploadedUrl} target=\"_blank\">\n                在新窗口打开\n              </Button>\n            </Space>\n          </Space>\n        </Card>\n      )}\n\n      {/* 隐藏的Canvas用于压缩 */}\n      <canvas ref={canvasRef} style={{ display: \"none\" }} />\n\n      {/* 使用说明 */}\n      <Card\n        title={\n          <span>\n            <FileZipOutlined style={{ marginRight: 8, color: \"#1890ff\" }} />\n            使用技巧\n          </span>\n        }\n        style={{ marginTop: 24 }}\n        size=\"small\"\n      >\n        <Row gutter={[24, 16]}>\n          <Col xs={24} md={12}>\n            <div\n              style={{\n                padding: \"16px\",\n                backgroundColor: colorFillTertiary,\n                borderRadius: \"8px\",\n                border: `1px solid ${colorBorder}`,\n                height: \"100%\",\n              }}\n            >\n              <div\n                style={{\n                  display: \"flex\",\n                  alignItems: \"center\",\n                  marginBottom: \"12px\",\n                  color: \"#1890ff\",\n                  fontWeight: \"bold\",\n                }}\n              >\n                <PictureOutlined style={{ marginRight: 8, fontSize: \"16px\" }} />\n                压缩参数说明\n              </div>\n              <ul\n                style={{\n                  margin: 0,\n                  paddingLeft: \"20px\",\n                  lineHeight: \"1.8\",\n                }}\n              >\n                <li style={{ marginBottom: \"8px\" }}>\n                  <Text strong>尺寸：</Text>设置压缩后的图片尺寸，支持锁定宽高比\n                </li>\n                <li style={{ marginBottom: \"8px\" }}>\n                  <Text strong>宽高比：</Text>\n                  解锁后可自由设置尺寸，图片居中显示，多余部分用透明像素填充，避免变形\n                </li>\n                <li style={{ marginBottom: \"8px\" }}>\n                  <Text strong>质量：</Text>\n                  1-100%，数值越高图片质量越好，文件越大\n                </li>\n                <li style={{ marginBottom: \"8px\" }}>\n                  <Text strong>压缩率：</Text>显示压缩前后的大小对比\n                </li>\n                <li style={{ marginBottom: \"8px\" }}>\n                  <Text strong>格式：</Text>压缩后统一为PNG格式，支持透明像素\n                </li>\n                <li style={{ marginBottom: \"8px\" }}>\n                  <Text strong>清晰度保持：</Text>\n                  设置大尺寸时保持原图清晰度，不进行放大，多余部分用透明填充\n                </li>\n              </ul>\n            </div>\n          </Col>\n\n          <Col xs={24} md={12}>\n            <div\n              style={{\n                padding: \"16px\",\n                backgroundColor: colorFillTertiary,\n                borderRadius: \"8px\",\n                border: `1px solid ${colorBorder}`,\n                height: \"100%\",\n              }}\n            >\n              <div\n                style={{\n                  display: \"flex\",\n                  alignItems: \"center\",\n                  marginBottom: \"12px\",\n                  color: \"#52c41a\",\n                  fontWeight: \"bold\",\n                }}\n              >\n                <DownloadOutlined\n                  style={{ marginRight: 8, fontSize: \"16px\" }}\n                />\n                使用建议\n              </div>\n              <ul\n                style={{\n                  margin: 0,\n                  paddingLeft: \"20px\",\n                  lineHeight: \"1.8\",\n                }}\n              >\n                <li style={{ marginBottom: \"8px\" }}>\n                  <Text strong>网页使用：</Text>\n                  建议质量70-80%，文件大小和质量的平衡点\n                </li>\n                <li style={{ marginBottom: \"8px\" }}>\n                  <Text strong>移动端：</Text>建议尺寸不超过1200px，减少加载时间\n                </li>\n                <li style={{ marginBottom: \"8px\" }}>\n                  <Text strong>宽高比：</Text>保持宽高比可以避免图片变形\n                </li>\n                <li style={{ marginBottom: \"8px\" }}>\n                  <Text strong>透明填充：</Text>\n                  解锁宽高比时，适合制作固定尺寸的图标或背景图\n                </li>\n                <li style={{ marginBottom: \"8px\" }}>\n                  <Text strong>大尺寸设置：</Text>\n                  设置比原图大的尺寸时，图片保持原清晰度，周围用透明背景填充\n                </li>\n                <li style={{ marginBottom: \"8px\" }}>\n                  <Text strong>备份：</Text>压缩前建议备份原始图片\n                </li>\n              </ul>\n            </div>\n          </Col>\n        </Row>\n      </Card>\n    </div>\n  );\n};\n\nexport default ImageCompressor;\n"
  },
  {
    "path": "client/src/components/ImageCropperTool.js",
    "content": "import React, { useRef, useState, useEffect } from \"react\";\nimport Cropper from \"react-cropper\";\nimport \"cropperjs/dist/cropper.css\";\nimport {\n  Card,\n  Typography,\n  Button,\n  Upload,\n  message,\n  Space,\n  Row,\n  Col,\n  Input,\n} from \"antd\";\nimport {\n  UploadOutlined,\n  ScissorOutlined,\n  CopyOutlined,\n  RedoOutlined,\n  UndoOutlined,\n  ReloadOutlined,\n  SwapOutlined,\n} from \"@ant-design/icons\";\n\nconst { Title, Text } = Typography;\nconst { Dragger } = Upload;\n\nconst ImageCropperTool = ({ api, onUploadSuccess }) => {\n  const cropperRef = useRef(null);\n  const [imageSrc, setImageSrc] = useState(null);\n  const [croppedImageUrl, setCroppedImageUrl] = useState(null);\n  const [isUploading, setIsUploading] = useState(false);\n  const [uploadedUrl, setUploadedUrl] = useState(\"\");\n  const [fileName, setFileName] = useState(\"cropped-image\");\n  const [rotate, setRotate] = useState(0);\n  const [cropData, setCropData] = useState(null);\n  const [cropBoxData, setCropBoxData] = useState(null);\n  const [imgData, setImgData] = useState(null);\n\n  // 处理粘贴事件\n  const handlePaste = async (event) => {\n    const items = event.clipboardData?.items;\n    if (!items) return;\n\n    for (let i = 0; i < items.length; i++) {\n      const item = items[i];\n      if (item.type.startsWith(\"image/\")) {\n        event.preventDefault();\n        const file = item.getAsFile();\n        if (file) {\n          await handlePastedImage(file);\n        }\n        break;\n      }\n    }\n  };\n\n  // 处理粘贴的图片\n  const handlePastedImage = async (file) => {\n    const isImage = file.type.startsWith(\"image/\");\n    if (!isImage) {\n      message.error(\"只能上传图片文件！\");\n      return;\n    }\n\n    const reader = new FileReader();\n    reader.onload = (e) => {\n      setImageSrc(e.target.result);\n      setCroppedImageUrl(null);\n      setUploadedUrl(\"\");\n      setFileName(`pasted-image-${Date.now()}`);\n      setRotate(0);\n      setTimeout(() => {\n        const cropper = cropperRef.current?.cropper;\n        if (cropper) {\n          cropper.reset();\n          // 获取画布数据并设置裁剪框为整张图片\n          const canvasData = cropper.getCanvasData();\n          cropper.setCropBoxData({\n            left: canvasData.left,\n            top: canvasData.top,\n            width: canvasData.width,\n            height: canvasData.height,\n          });\n        }\n      }, 100);\n    };\n    reader.readAsDataURL(file);\n  };\n\n  // 添加全局粘贴事件监听\n  useEffect(() => {\n    const handleGlobalPaste = (event) => {\n      // 检查是否在输入框中，如果是则不处理粘贴\n      const target = event.target;\n      if (\n        target.tagName === \"INPUT\" ||\n        target.tagName === \"TEXTAREA\" ||\n        target.contentEditable === \"true\"\n      ) {\n        return;\n      }\n\n      handlePaste(event);\n    };\n\n    document.addEventListener(\"paste\", handleGlobalPaste);\n\n    return () => {\n      document.removeEventListener(\"paste\", handleGlobalPaste);\n    };\n  }, []);\n\n  const handleImageUpload = (file) => {\n    const isImage = file.type.startsWith(\"image/\");\n    if (!isImage) {\n      message.error(\"只能上传图片文件！\");\n      return false;\n    }\n    const reader = new FileReader();\n    reader.onload = (e) => {\n      setImageSrc(e.target.result);\n      setCroppedImageUrl(null);\n      setUploadedUrl(\"\");\n      setFileName(file.name.replace(/\\.[^/.]+$/, \"\"));\n      setRotate(0);\n      setTimeout(() => {\n        const cropper = cropperRef.current?.cropper;\n        if (cropper) {\n          cropper.reset();\n          // 获取画布数据并设置裁剪框为整张图片\n          const canvasData = cropper.getCanvasData();\n          cropper.setCropBoxData({\n            left: canvasData.left,\n            top: canvasData.top,\n            width: canvasData.width,\n            height: canvasData.height,\n          });\n        }\n      }, 100);\n    };\n    reader.readAsDataURL(file);\n    return false;\n  };\n\n  const handleCrop = () => {\n    const cropper = cropperRef.current?.cropper;\n    if (cropper && imageSrc) {\n      const croppedDataUrl = cropper.getCroppedCanvas()?.toDataURL();\n      setCroppedImageUrl(croppedDataUrl);\n      setCropBoxData(cropper.getCropBoxData());\n      setImgData(cropper.getData());\n    }\n  };\n\n  const uploadCroppedImage = async () => {\n    if (!croppedImageUrl) {\n      message.error(\"请先裁剪图片\");\n      return;\n    }\n    setIsUploading(true);\n    try {\n      const res = await fetch(croppedImageUrl);\n      const blob = await res.blob();\n      const formData = new FormData();\n      formData.append(\"image\", blob, fileName + \".png\");\n      const uploadResponse = await api.post(\"/upload\", formData, {\n        headers: { \"Content-Type\": \"multipart/form-data\" },\n      });\n      if (uploadResponse.data.success) {\n        const imageUrl = `${window.location.origin}${uploadResponse.data.data.url}`;\n        setUploadedUrl(imageUrl);\n        message.success(\"图片上传成功！\");\n        if (onUploadSuccess) onUploadSuccess();\n      } else {\n        message.error(uploadResponse.data.error || \"上传失败\");\n      }\n    } catch (error) {\n      message.error(\"上传失败，请重试\");\n    } finally {\n      setIsUploading(false);\n    }\n  };\n\n  const copyUploadedUrl = () => {\n    if (!uploadedUrl) {\n      message.error(\"没有可复制的URL\");\n      return;\n    }\n    navigator.clipboard.writeText(uploadedUrl).then(() => {\n      message.success(\"URL已复制到剪贴板\");\n    });\n  };\n\n  const handleRotate = (angle) => {\n    const cropper = cropperRef.current?.cropper;\n    if (cropper) {\n      cropper.rotate(angle);\n      setRotate((prev) => prev + angle);\n    }\n  };\n\n  const handleReset = () => {\n    const cropper = cropperRef.current?.cropper;\n    if (cropper) {\n      // 先清除状态，避免UI抖动\n      setCroppedImageUrl(null);\n      setCropBoxData(null);\n      setImgData(null);\n      setRotate(0);\n\n      // 使用更稳定的重置方法\n      try {\n        // 先重置到初始状态\n        cropper.reset();\n\n        // 等待DOM更新完成后再设置裁剪框\n        const resetCropBox = () => {\n          try {\n            const canvasData = cropper.getCanvasData();\n            if (canvasData && canvasData.width > 0 && canvasData.height > 0) {\n              cropper.setCropBoxData({\n                left: canvasData.left,\n                top: canvasData.top,\n                width: canvasData.width,\n                height: canvasData.height,\n              });\n            }\n          } catch (error) {\n            console.warn(\"设置裁剪框失败:\", error);\n          }\n        };\n\n        // 使用多重检查确保重置完成\n        setTimeout(resetCropBox, 100);\n        setTimeout(resetCropBox, 200);\n      } catch (error) {\n        console.warn(\"重置失败:\", error);\n      }\n    }\n  };\n\n  const handleFlipHorizontal = () => {\n    const cropper = cropperRef.current?.cropper;\n    if (cropper) {\n      cropper.scaleX(-cropper.getData().scaleX || -1);\n    }\n  };\n\n  const handleFlipVertical = () => {\n    const cropper = cropperRef.current?.cropper;\n    if (cropper) {\n      cropper.scaleY(-cropper.getData().scaleY || -1);\n    }\n  };\n\n  return (\n    <Card style={{ marginTop: 24 }}>\n      <Title level={3}>\n        <ScissorOutlined /> 图片裁剪\n      </Title>\n      <Row gutter={[24, 24]}>\n        <Col xs={24} lg={24}>\n          <Card title=\"图片上传\" size=\"small\" style={{ marginBottom: 16 }}>\n            <Dragger\n              accept=\"image/*\"\n              beforeUpload={handleImageUpload}\n              showUploadList={false}\n            >\n              {imageSrc ? (\n                <div style={{ textAlign: \"center\" }}>\n                  <img\n                    src={imageSrc}\n                    alt=\"原始图片\"\n                    style={{\n                      maxWidth: \"100%\",\n                      maxHeight: \"200px\",\n                      border: \"1px solid #eee\",\n                      borderRadius: 4,\n                    }}\n                  />\n                </div>\n              ) : (\n                <div>\n                  <UploadOutlined style={{ fontSize: 48, color: \"#999\" }} />\n                  <p>点击或拖拽图片到此区域上传</p>\n                  <p\n                    style={{\n                      color: \"#1890ff\",\n                      fontSize: \"12px\",\n                      marginTop: \"8px\",\n                    }}\n                  >\n                    支持 Ctrl+V 粘贴图片\n                  </p>\n                </div>\n              )}\n            </Dragger>\n          </Card>\n        </Col>\n      </Row>\n      {/* 裁剪与预览区域 */}\n      {imageSrc && (\n        <>\n          <Row gutter={[24, 24]} style={{ marginTop: 0 }}>\n            <Col span={24}>\n              <Card title=\"裁剪与预览\" size=\"small\">\n                <div\n                  style={{\n                    display: \"flex\",\n                    gap: 32,\n                    alignItems: \"flex-start\",\n                    flexWrap: \"wrap\",\n                  }}\n                >\n                  {/* 左侧裁剪区 */}\n                  <div style={{ minWidth: 320, flex: 1, minHeight: 320 }}>\n                    <div\n                      style={{ height: 320, width: \"100%\", overflow: \"hidden\" }}\n                    >\n                      <Cropper\n                        src={imageSrc}\n                        style={{ height: 320, width: \"100%\" }}\n                        initialAspectRatio={1}\n                        aspectRatio={NaN} // 允许任意比例\n                        guides={true}\n                        ref={cropperRef}\n                        viewMode={1}\n                        dragMode=\"move\"\n                        background={true}\n                        autoCropArea={1}\n                        checkOrientation={false}\n                        rotatable={true}\n                        scalable={true}\n                        zoomable={true}\n                        crop={handleCrop}\n                        ready={() => {\n                          // 组件准备就绪时，确保裁剪框设置正确\n                          const cropper = cropperRef.current?.cropper;\n                          if (cropper) {\n                            setTimeout(() => {\n                              try {\n                                const canvasData = cropper.getCanvasData();\n                                if (canvasData) {\n                                  cropper.setCropBoxData({\n                                    left: canvasData.left,\n                                    top: canvasData.top,\n                                    width: canvasData.width,\n                                    height: canvasData.height,\n                                  });\n                                }\n                              } catch (error) {\n                                console.warn(\"初始化裁剪框失败:\", error);\n                              }\n                            }, 100);\n                          }\n                        }}\n                      />\n                    </div>\n                    {cropBoxData && imgData && (\n                      <div\n                        style={{\n                          marginTop: 8,\n                          background: \"#222\",\n                          color: \"#fff\",\n                          padding: \"4px 8px\",\n                          borderRadius: 4,\n                          fontSize: 14,\n                          display: \"inline-block\",\n                        }}\n                      >\n                        裁剪区域: {Math.round(imgData.width)} ×{\" \"}\n                        {Math.round(imgData.height)} px\n                      </div>\n                    )}\n                  </div>\n                  {/* 右侧预览区 */}\n                  <div\n                    style={{\n                      minWidth: 280,\n                      maxWidth: 400,\n                      flex: 1,\n                      textAlign: \"center\",\n                    }}\n                  >\n                    <div style={{ fontWeight: 500, marginBottom: 12 }}>\n                      裁剪后预览\n                    </div>\n                    {croppedImageUrl ? (\n                      <img\n                        src={croppedImageUrl}\n                        alt=\"裁剪后图片\"\n                        style={{\n                          maxWidth: \"100%\",\n                          maxHeight: 280,\n                          border: \"1px solid #eee\",\n                          borderRadius: 6,\n                          background: \"#fafafa\",\n                          boxShadow: \"0 2px 8px rgba(0,0,0,0.1)\",\n                        }}\n                      />\n                    ) : (\n                      <div\n                        style={{\n                          color: \"#aaa\",\n                          height: 280,\n                          lineHeight: \"280px\",\n                          border: \"1px dashed #eee\",\n                          borderRadius: 6,\n                          background: \"#fafafa\",\n                        }}\n                      >\n                        暂无预览\n                      </div>\n                    )}\n                    <Button\n                      type=\"primary\"\n                      style={{ marginTop: 12, width: \"100%\" }}\n                      loading={isUploading}\n                      onClick={uploadCroppedImage}\n                      disabled={!croppedImageUrl}\n                    >\n                      上传到图床\n                    </Button>\n                    {uploadedUrl && (\n                      <div style={{ marginTop: 8 }}>\n                        <Input\n                          value={uploadedUrl}\n                          readOnly\n                          style={{ width: \"80%\" }}\n                        />\n                        <Button\n                          icon={<CopyOutlined />}\n                          onClick={copyUploadedUrl}\n                          style={{ marginLeft: 8 }}\n                        >\n                          复制URL\n                        </Button>\n                      </div>\n                    )}\n                  </div>\n                </div>\n              </Card>\n            </Col>\n          </Row>\n          {/* 工具栏区域 */}\n          <Row style={{ marginTop: 16 }}>\n            <Col span={24}>\n              <Card size=\"small\" bodyStyle={{ padding: 12 }}>\n                <Space size=\"large\" align=\"center\" wrap>\n                  <span style={{ fontWeight: 500 }}>工具栏：</span>\n                  <Button icon={<ReloadOutlined />} onClick={handleReset}>\n                    重置\n                  </Button>\n                  <Button\n                    icon={<UndoOutlined />}\n                    onClick={() => handleRotate(-90)}\n                  >\n                    左转90°\n                  </Button>\n                  <Button\n                    icon={<RedoOutlined />}\n                    onClick={() => handleRotate(90)}\n                  >\n                    右转90°\n                  </Button>\n                  <Button\n                    icon={<SwapOutlined />}\n                    onClick={handleFlipHorizontal}\n                  >\n                    水平翻转\n                  </Button>\n                  <Button\n                    icon={\n                      <SwapOutlined style={{ transform: \"rotate(90deg)\" }} />\n                    }\n                    onClick={handleFlipVertical}\n                  >\n                    垂直翻转\n                  </Button>\n                </Space>\n              </Card>\n            </Col>\n          </Row>\n        </>\n      )}\n    </Card>\n  );\n};\n\nexport default ImageCropperTool;\n"
  },
  {
    "path": "client/src/components/ImageDetailModal.js",
    "content": "import React, { useState, useEffect, useRef } from \"react\";\nimport {\n  Modal,\n  Button,\n  Tooltip,\n  Input,\n  Space,\n  Typography,\n  message,\n  Popconfirm,\n  theme,\n  Grid,\n  Spin,\n} from \"antd\";\nimport {\n  LeftOutlined,\n  RightOutlined,\n  CopyOutlined,\n  EditOutlined,\n  FolderOutlined,\n  DownloadOutlined,\n  DeleteOutlined,\n  EnvironmentOutlined,\n  CameraOutlined,\n  HistoryOutlined,\n} from \"@ant-design/icons\";\nimport dayjs from \"dayjs\";\nimport { thumbHashToDataURL } from \"thumbhash\";\nimport DirectorySelector from \"./DirectorySelector\";\n\nconst { Title, Text } = Typography;\n\n// Helper to convert base64 thumbhash to data URL\nconst getThumbHashUrl = (hash) => {\n  if (!hash) return null;\n  try {\n    const binary = Uint8Array.from(atob(hash), (c) => c.charCodeAt(0));\n    return thumbHashToDataURL(binary);\n  } catch (e) {\n    console.error(\"ThumbHash decode error:\", e);\n    return null;\n  }\n};\n\nconst encodePath = (path) => {\n  if (!path) return \"\";\n  return path.split('/').map(encodeURIComponent).join('/');\n};\n\nconst formatFileSize = (bytes) => {\n  if (bytes === 0) return \"0 Bytes\";\n  const k = 1024;\n  const sizes = [\"Bytes\", \"KB\", \"MB\", \"GB\"];\n  const i = Math.floor(Math.log(bytes) / Math.log(k));\n  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + \" \" + sizes[i];\n};\n\n// Helper: Format aperture\nconst formatFNumber = (val) => {\n  if (!val) return \"\";\n  const num = parseFloat(val);\n  return parseFloat(num.toFixed(1));\n};\n\n// Helper: Format exposure time\nconst formatExposureTime = (val) => {\n  if (!val) return \"\";\n  const num = parseFloat(val);\n  if (num >= 1) return parseFloat(num.toFixed(1)) + \"s\";\n  return `1/${Math.round(1 / num)}s`;\n};\n\nconst ImageDetailModal = ({\n  visible,\n  onCancel,\n  file,\n  api,\n  onNext,\n  onPrev,\n  hasNext,\n  hasPrev,\n  onDelete,\n  onUpdate, // Callback when file is renamed or moved\n}) => {\n  const {\n    token: { colorBgContainer, colorText, colorTextSecondary, colorPrimary },\n  } = theme.useToken();\n  const { useBreakpoint } = Grid;\n  const screens = useBreakpoint();\n  const isMobile = !screens.md;\n  const isDarkMode =\n    colorBgContainer === \"#141414\" ||\n    colorBgContainer === \"#000000\" ||\n    colorBgContainer === \"#1f1f1f\";\n\n  const [imageMeta, setImageMeta] = useState(null);\n  const [imgLoaded, setImgLoaded] = useState(false);\n  const [previewLocation, setPreviewLocation] = useState(\"\");\n  const [isEditingName, setIsEditingName] = useState(false);\n  const [renameValue, setRenameValue] = useState(\"\");\n  const [isEditingDir, setIsEditingDir] = useState(false);\n  const [dirValue, setDirValue] = useState(\"\");\n  const [renaming, setRenaming] = useState(false);\n  const [moving, setMoving] = useState(false);\n  const videoRef = useRef(null);\n  const scrollLockRef = useRef(null);\n  const touchStartXRef = useRef(null);\n  const touchStartYRef = useRef(null);\n\n  const [zoom, setZoom] = useState(1);\n  const [position, setPosition] = useState({ x: 0, y: 0 });\n  const [isDragging, setIsDragging] = useState(false);\n  const [dragStart, setDragStart] = useState({ x: 0, y: 0 });\n\n  // Lock body scroll when modal is open\n  useEffect(() => {\n    if (visible) {\n      const scrollY = window.scrollY;\n      scrollLockRef.current = scrollY;\n      document.body.style.overflow = 'hidden';\n      document.body.style.position = 'fixed';\n      document.body.style.top = `-${scrollY}px`;\n      document.body.style.width = '100%';\n    } else {\n      const scrollY = scrollLockRef.current || 0;\n      document.body.style.overflow = '';\n      document.body.style.position = '';\n      document.body.style.top = '';\n      document.body.style.width = '';\n      window.scrollTo(0, scrollY);\n    }\n    return () => {\n      document.body.style.overflow = '';\n      document.body.style.position = '';\n      document.body.style.top = '';\n      document.body.style.width = '';\n    };\n  }, [visible]);\n\n  // Reset state when file changes\n  useEffect(() => {\n    if (file) {\n      setZoom(1);\n      setPosition({ x: 0, y: 0 });\n      setImgLoaded(false);\n      // ... existing reset logic\n      setImageMeta(null);\n      setPreviewLocation(\"\");\n      setIsEditingName(false);\n      setIsEditingDir(false);\n\n      const ext = file.filename.includes(\".\")\n        ? file.filename.substring(file.filename.lastIndexOf(\".\"))\n        : \"\";\n      const base = ext ? file.filename.slice(0, -ext.length) : file.filename;\n      setRenameValue(base);\n\n      const currentDir =\n        file.relPath && file.relPath.includes(\"/\")\n          ? file.relPath.substring(0, file.relPath.lastIndexOf(\"/\"))\n          : \"\";\n      setDirValue(currentDir);\n\n      // Fetch Meta\n      let active = true;\n      api\n        .get(`/images/meta/${encodePath(file.relPath)}`)\n        .then((res) => {\n          if (active && res.data && res.data.success) {\n            setImageMeta(res.data.data);\n          }\n        })\n        .catch(() => { });\n\n      return () => {\n        active = false;\n      };\n    }\n  }, [file, api]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  const handleWheel = (e) => {\n    e.stopPropagation();\n    // 阻止默认滚动行为，避免页面滚动\n    // e.preventDefault(); // React synthetic event might not support this in all cases, better handle in container\n\n    const scaleAmount = -e.deltaY * 0.001;\n    setZoom((prevZoom) => {\n      const newZoom = prevZoom + scaleAmount;\n      return Math.max(1, Math.min(newZoom, 5)); // Limit zoom between 1x and 5x\n    });\n  };\n\n  const handleMouseDown = (e) => {\n    if (zoom > 1) {\n      setIsDragging(true);\n      setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y });\n      e.preventDefault(); // Prevent default drag behavior\n    }\n  };\n\n  const handleMouseMove = (e) => {\n    if (isDragging && zoom > 1) {\n      setPosition({\n        x: e.clientX - dragStart.x,\n        y: e.clientY - dragStart.y,\n      });\n    }\n  };\n\n  const handleMouseUp = () => {\n    setIsDragging(false);\n  };\n\n  // Reset position if zoomed out to 1\n  useEffect(() => {\n    if (zoom === 1) {\n      setPosition({ x: 0, y: 0 });\n    }\n  }, [zoom]);\n\n  // Fetch Location\n  useEffect(() => {\n    if (!visible || !imageMeta?.exif?.latitude) {\n      setPreviewLocation(\"\");\n      return;\n    }\n\n    const { latitude, longitude } = imageMeta.exif;\n    let active = true;\n\n    const fetchPreviewLoc = async () => {\n      try {\n        const geoRes = await fetch(\n          `https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=10&accept-language=zh-CN`\n        );\n        const geoData = await geoRes.json();\n\n        if (active && geoData) {\n          const addr = geoData.address;\n          const parts = [];\n          if (addr.province) parts.push(addr.province);\n          if (addr.city && addr.city !== addr.province) parts.push(addr.city);\n          if (addr.district || addr.county)\n            parts.push(addr.district || addr.county);\n          if (addr.road || addr.street || addr.pedestrian)\n            parts.push(addr.road || addr.street || addr.pedestrian);\n          if (addr.house_number) parts.push(addr.house_number);\n\n          const name = geoData.display_name.split(\",\")[0];\n          if (name && !parts.includes(name)) {\n            parts.push(name);\n          }\n\n          let fullAddr = parts.join(\" \");\n          if (!fullAddr) {\n            fullAddr = geoData.display_name;\n          }\n\n          setPreviewLocation(fullAddr);\n        }\n      } catch (e) { }\n    };\n\n    fetchPreviewLoc();\n    return () => {\n      active = false;\n    };\n  }, [visible, imageMeta]);\n\n  // Video Playback Control\n  useEffect(() => {\n    if (videoRef.current) {\n      if (visible) {\n        // Optionally reset and play when opened\n        videoRef.current.currentTime = 0;\n        videoRef.current.play().catch(() => { });\n      } else {\n        // Pause and reset when closed\n        videoRef.current.pause();\n        videoRef.current.currentTime = 0;\n      }\n    }\n  }, [visible]);\n\n  // Keyboard Navigation\n  useEffect(() => {\n    const handleKeyDown = (e) => {\n      if (!visible) return;\n      if (e.key === \"ArrowRight\" && hasNext) onNext();\n      if (e.key === \"ArrowLeft\" && hasPrev) onPrev();\n    };\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => window.removeEventListener(\"keydown\", handleKeyDown);\n  }, [visible, hasNext, hasPrev, onNext, onPrev]);\n\n  const handleDownload = () => {\n    const link = document.createElement(\"a\");\n    link.href = file.url;\n    link.download = file.filename;\n    document.body.appendChild(link);\n    link.click();\n    document.body.removeChild(link);\n    message.success(\"开始下载\");\n  };\n\n  const copyToClipboard = (text) => {\n    if (navigator.clipboard && window.isSecureContext) {\n      navigator.clipboard\n        .writeText(text)\n        .then(() => message.success(\"链接已复制到剪贴板\"))\n        .catch(() => message.error(\"复制失败\"));\n      return;\n    }\n    const input = document.createElement(\"input\");\n    input.style.position = \"fixed\";\n    input.style.top = \"-10000px\";\n    input.style.zIndex = \"-999\";\n    document.body.appendChild(input);\n    input.value = text;\n    input.focus();\n    input.select();\n    try {\n      document.execCommand(\"copy\");\n      message.success(\"链接已复制到剪贴板\");\n    } catch (e) {\n      message.error(\"复制失败\");\n    } finally {\n      document.body.removeChild(input);\n    }\n  };\n\n  const handleRename = async () => {\n    const oldRel = file.relPath;\n    const ext = file.filename.includes(\".\")\n      ? file.filename.substring(file.filename.lastIndexOf(\".\"))\n      : \"\";\n    const newNameRaw = renameValue.trim();\n    if (!newNameRaw) return;\n    const hasExt = /\\.[A-Za-z0-9]+$/.test(newNameRaw);\n    const newName = hasExt ? newNameRaw : `${newNameRaw}${ext}`;\n    try {\n      setRenaming(true);\n      const res = await api.put(`/images/${encodePath(oldRel)}`, {\n        newName,\n      });\n      if (res.data?.success) {\n        const updated = res.data.data;\n        message.success(\"重命名成功\");\n        setIsEditingName(false);\n        if (onUpdate) onUpdate(updated);\n      }\n    } catch (e) {\n      message.error(\"重命名失败\");\n    } finally {\n      setRenaming(false);\n    }\n  };\n\n  const handleMove = async () => {\n    const oldRel = file.relPath;\n    try {\n      setMoving(true);\n      const res = await api.post(\"/batch/move\", {\n        files: [oldRel],\n        targetDir: dirValue || \"\",\n      });\n      if (res.data?.success) {\n        const { successCount = 0, failCount = 0 } = res.data;\n        if (successCount > 0 && failCount === 0) {\n          message.success(\"已移动到: \" + (dirValue || \"根目录\"));\n          setIsEditingDir(false);\n        } else {\n          message.error(res.data?.error || \"移动失败\");\n        }\n      } else {\n        message.error(res.data?.error || \"移动失败\");\n      }\n    } catch (e) {\n      const errMsg = e?.response?.data?.error || e?.response?.data?.message || e?.message || \"移动失败\";\n      message.error(errMsg);\n    } finally {\n      setMoving(false);\n    }\n  };\n\n  const thumbUrl = React.useMemo(() => {\n    if (!file || !file.thumbhash) return null;\n    return getThumbHashUrl(file.thumbhash);\n  }, [file]);\n  const hasThumb = !!thumbUrl;\n  const isDarkBg = hasThumb || isDarkMode;\n  const isLight = !isDarkBg;\n\n  const textColor = hasThumb ? \"#fff\" : colorText;\n  const secondaryTextColor = hasThumb\n    ? \"rgba(255,255,255,0.75)\"\n    : colorTextSecondary;\n  const tertiaryTextColor = hasThumb\n    ? \"rgba(255,255,255,0.5)\"\n    : isDarkMode\n      ? \"rgba(255,255,255,0.45)\"\n      : \"rgba(0,0,0,0.45)\";\n  const inputBg = hasThumb\n    ? \"rgba(255,255,255,0.15)\"\n    : isDarkMode\n      ? \"rgba(255,255,255,0.1)\"\n      : \"rgba(0,0,0,0.06)\";\n\n  if (!file) return null;\n\n  return (\n    <Modal\n      open={visible}\n      title={null}\n      footer={null}\n      onCancel={onCancel}\n      width=\"100vw\"\n      style={{\n        top: 0,\n        margin: 0,\n        maxWidth: \"100vw\",\n        padding: 0,\n      }}\n      styles={{\n        body: {\n          padding: 0,\n          height: \"100vh\",\n          overflow: \"hidden\",\n          background: \"#000\",\n        },\n        content: {\n          padding: 0,\n          background: \"#000\",\n          boxShadow: \"none\",\n          height: \"100vh\",\n          overflow: \"hidden\",\n          borderRadius: 0,\n        },\n        wrapper: {\n          overflow: \"hidden\",\n        },\n        mask: {\n          touchAction: \"none\",\n        },\n        container: { padding: 0 }\n      }}\n      closeIcon={null}\n    >\n      <div\n        style={{\n          display: \"flex\",\n          height: \"100vh\",\n          position: \"relative\",\n          overflow: \"hidden\",\n        }}\n      >\n        {/* Close & Action Buttons */}\n        <div\n          style={{\n            position: \"absolute\",\n            top: 20,\n            right: isMobile ? 20 : 420,\n            zIndex: 1000,\n            display: \"flex\",\n            gap: 12,\n          }}\n        >\n          <Tooltip title=\"复制链接\">\n            <Button\n              shape=\"circle\"\n              icon={<CopyOutlined />}\n              onClick={(e) => {\n                e.stopPropagation();\n                e.preventDefault();\n                copyToClipboard(window.location.origin + file.url);\n              }}\n              onMouseDown={(e) => e.preventDefault()}\n              onTouchStart={(e) => {\n                e.stopPropagation();\n                e.preventDefault();\n              }}\n              onTouchEnd={(e) => {\n                e.stopPropagation();\n                e.preventDefault();\n                copyToClipboard(window.location.origin + file.url);\n              }}\n              style={{\n                background: \"rgba(0,0,0,0.5)\",\n                border: \"1px solid rgba(255,255,255,0.2)\",\n                color: \"#fff\",\n                width: 40,\n                height: 40,\n              }}\n            />\n          </Tooltip>\n          <Button\n            shape=\"circle\"\n            icon={<span style={{ fontSize: 24, lineHeight: 1 }}>×</span>}\n            onClick={(e) => {\n              e.stopPropagation();\n              e.preventDefault();\n              onCancel();\n            }}\n            onMouseDown={(e) => e.preventDefault()}\n            onTouchStart={(e) => {\n              e.stopPropagation();\n              e.preventDefault();\n            }}\n            onTouchEnd={(e) => {\n              e.stopPropagation();\n              e.preventDefault();\n              onCancel();\n            }}\n            style={{\n              background: \"rgba(0,0,0,0.5)\",\n              border: \"1px solid rgba(255,255,255,0.2)\",\n              color: \"#fff\",\n              width: 40,\n              height: 40,\n            }}\n          />\n        </div>\n\n        {/* Left: Image Viewer */}\n        <div\n          style={{\n            flex: 1,\n            height: \"100%\",\n            overflow: \"hidden\",\n            position: \"relative\",\n            backgroundColor: \"#0f0f0f\",\n            display: \"flex\",\n            alignItems: \"center\",\n            justifyContent: \"center\",\n          }}\n        >\n          {/* Nav Buttons */}\n          {!isMobile && hasPrev && (\n            <Button\n              type=\"text\"\n              icon={\n                <LeftOutlined\n                  style={{ fontSize: 24, color: \"rgba(255,255,255,0.8)\" }}\n                />\n              }\n              onClick={(e) => {\n                e.stopPropagation();\n                onPrev();\n              }}\n              style={{\n                position: \"absolute\",\n                left: 20,\n                zIndex: 100,\n                height: \"100%\",\n                width: 80,\n                background:\n                  \"linear-gradient(90deg, rgba(0,0,0,0.3) 0%, transparent 100%)\",\n                border: \"none\",\n                opacity: 0,\n                transition: \"opacity 0.3s\",\n              }}\n              onMouseEnter={(e) => (e.currentTarget.style.opacity = 1)}\n              onMouseLeave={(e) => (e.currentTarget.style.opacity = 0)}\n            />\n          )}\n          {!isMobile && hasNext && (\n            <Button\n              type=\"text\"\n              icon={\n                <RightOutlined\n                  style={{ fontSize: 24, color: \"rgba(255,255,255,0.8)\" }}\n                />\n              }\n              onClick={(e) => {\n                e.stopPropagation();\n                onNext();\n              }}\n              style={{\n                position: \"absolute\",\n                right: 20,\n                zIndex: 100,\n                height: \"100%\",\n                width: 80,\n                background:\n                  \"linear-gradient(-90deg, rgba(0,0,0,0.3) 0%, transparent 100%)\",\n                border: \"none\",\n                opacity: 0,\n                transition: \"opacity 0.3s\",\n              }}\n              onMouseEnter={(e) => (e.currentTarget.style.opacity = 1)}\n              onMouseLeave={(e) => (e.currentTarget.style.opacity = 0)}\n            />\n          )}\n\n          <div\n            style={{\n              width: \"100%\",\n              height: \"100%\",\n              position: \"relative\",\n              display: \"flex\",\n              alignItems: \"center\",\n              justifyContent: \"center\",\n              overflow: \"hidden\", // Ensure zoomed image doesn't overflow container\n              cursor: zoom > 1 ? (isDragging ? \"grabbing\" : \"grab\") : \"default\",\n            }}\n            onWheel={handleWheel}\n            onMouseDown={handleMouseDown}\n            onMouseMove={handleMouseMove}\n            onMouseUp={handleMouseUp}\n            onMouseLeave={handleMouseUp}\n            onTouchStart={(e) => {\n              touchStartXRef.current = e.touches[0].clientX;\n              touchStartYRef.current = e.touches[0].clientY;\n            }}\n            onTouchEnd={(e) => {\n              if (touchStartXRef.current === null) return;\n              const dx = e.changedTouches[0].clientX - touchStartXRef.current;\n              const dy = e.changedTouches[0].clientY - touchStartYRef.current;\n              // 只有水平滑动幅度明显大于垂直时才切换（避免上下滑误触）\n              if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5 && zoom === 1) {\n                if (dx < 0 && hasNext) {\n                  onNext();\n                } else if (dx > 0 && hasPrev) {\n                  onPrev();\n                }\n              }\n              touchStartXRef.current = null;\n              touchStartYRef.current = null;\n            }}\n          >\n            {/* Blurry Background */}\n            <div\n              style={{\n                position: \"absolute\",\n                top: 0,\n                right: 0,\n                bottom: 0,\n                left: 0,\n                backgroundImage: `url(${thumbUrl || file.url})`,\n                backgroundSize: \"cover\",\n                backgroundPosition: \"center\",\n                filter: \"blur(40px) brightness(0.5)\",\n                transform: \"scale(1.2)\",\n                zIndex: 0,\n              }}\n            />\n            {/\\.(mp4|webm)$/i.test(file.filename) ? (\n              <video\n                ref={videoRef}\n                controls\n                autoPlay\n                style={{\n                  maxWidth: \"100%\",\n                  maxHeight: \"100%\",\n                  width: \"auto\",\n                  height: \"auto\",\n                  boxShadow: \"0 20px 50px rgba(0,0,0,0.5)\",\n                  zIndex: 2,\n                  outline: \"none\",\n                }}\n                src={file.url}\n              />\n            ) : (\n              <>\n                {!imgLoaded && (\n                  <div\n                    style={{\n                      position: \"absolute\",\n                      top: \"50%\",\n                      left: \"50%\",\n                      transform: \"translate(-50%, -50%)\",\n                      zIndex: 3,\n                    }}\n                  >\n                    <Spin size=\"large\" />\n                  </div>\n                )}\n                <img\n                  key={file.url}\n                  alt=\"preview\"\n                  onLoad={() => setImgLoaded(true)}\n                  style={{\n                    maxWidth: \"100%\",\n                    maxHeight: \"100%\",\n                    width: \"auto\",\n                    height: \"auto\",\n                    objectFit: \"contain\",\n                    boxShadow: \"0 20px 50px rgba(0,0,0,0.5)\",\n                    zIndex: 2,\n                    transform: `scale(${zoom}) translate(${position.x / zoom}px, ${position.y / zoom}px)`,\n                    transition: isDragging ? \"none\" : \"transform 0.1s ease-out\", // Smooth zoom, instant drag\n                    pointerEvents: \"none\", // Let container handle events\n                    opacity: imgLoaded ? 1 : 0,\n                  }}\n                  src={\n                    // GIF 使用原始文件路径（保留完整动画），其他格式走 /api/images/（经过 sharp 处理）\n                    /\\.gif$/i.test(file.filename)\n                      ? file.url.replace(/^\\/api\\/images\\//, \"/api/files/\")\n                      : file.url\n                  }\n                  draggable={false}\n                />\n              </>\n            )}\n          </div>\n        </div>\n\n        {/* Right: Info Sidebar */}\n        <div\n          style={{\n            width: isMobile ? \"100%\" : 360,\n            background: hasThumb\n              ? `linear-gradient(to bottom, rgba(0,0,0,0.7), rgba(0,0,0,0.9)), url(${thumbUrl}) center/cover no-repeat`\n              : colorBgContainer,\n            color: textColor,\n            borderLeft: isDarkMode\n              ? \"1px solid rgba(255,255,255,0.1)\"\n              : \"none\",\n            display: isMobile ? \"none\" : \"flex\",\n            flexDirection: \"column\",\n            zIndex: 20,\n            transition: \"background 0.3s ease, color 0.3s ease\",\n          }}\n        >\n          <div style={{ flex: 1, overflowY: \"auto\", padding: \"32px 24px\" }}>\n            {/* Header Section */}\n            <div style={{ marginBottom: 24 }}>\n              <div\n                style={{\n                  display: \"flex\",\n                  alignItems: \"flex-start\",\n                  justifyContent: \"space-between\",\n                  gap: 12,\n                }}\n              >\n                <Title\n                  level={4}\n                  style={{\n                    margin: 0,\n                    wordBreak: \"break-all\",\n                    color: textColor,\n                    fontSize: 18,\n                  }}\n                >\n                  {file.filename}\n                </Title>\n                <Button\n                  type=\"text\"\n                  icon={\n                    <EditOutlined style={{ color: secondaryTextColor }} />\n                  }\n                  onClick={() => setIsEditingName(!isEditingName)}\n                />\n              </div>\n\n              {isEditingName && (\n                <div style={{ marginTop: 12, display: \"flex\", gap: 8 }}>\n                  <Input\n                    value={renameValue}\n                    onChange={(e) => setRenameValue(e.target.value)}\n                    onPressEnter={handleRename}\n                    style={{\n                      background: inputBg,\n                      color: textColor,\n                      border: \"none\",\n                    }}\n                  />\n                  <Button\n                    type=\"primary\"\n                    ghost={!isLight}\n                    loading={renaming}\n                    onClick={handleRename}\n                  >\n                    保存\n                  </Button>\n                </div>\n              )}\n\n              <div\n                style={{\n                  marginTop: 8,\n                  display: \"flex\",\n                  alignItems: \"center\",\n                  gap: 8,\n                }}\n              >\n                <FolderOutlined style={{ color: secondaryTextColor }} />\n                <Text style={{ fontSize: 13, color: secondaryTextColor }}>\n                  {dirValue || \"根目录\"}\n                </Text>\n                <Button\n                  type=\"link\"\n                  size=\"small\"\n                  onClick={() => setIsEditingDir(!isEditingDir)}\n                  style={{\n                    padding: 0,\n                    height: \"auto\",\n                    color: isLight ? colorPrimary : \"rgba(255,255,255,0.8)\",\n                  }}\n                >\n                  修改\n                </Button>\n              </div>\n              {isEditingDir && (\n                <div style={{ marginTop: 12, display: \"flex\", gap: 8 }}>\n                  <div style={{ flex: 1 }}>\n                    <DirectorySelector\n                      value={dirValue}\n                      onChange={setDirValue}\n                      size=\"small\"\n                      api={api}\n                      style={{\n                        background: inputBg,\n                        color: textColor,\n                        border: \"none\",\n                      }}\n                    />\n                  </div>\n                  <Button\n                    type=\"primary\"\n                    ghost={!isLight}\n                    size=\"small\"\n                    loading={moving}\n                    onClick={handleMove}\n                  >\n                    保存\n                  </Button>\n                </div>\n              )}\n            </div>\n\n            {/* Actions Row */}\n            <div style={{ display: \"flex\", gap: 12, marginBottom: 32 }}>\n              <Button\n                block\n                ghost\n                icon={<DownloadOutlined />}\n                onClick={handleDownload}\n                variant=\"outlined\"\n                color=\"primary\"\n              >\n                下载\n              </Button>\n              <Popconfirm\n                title=\"确定删除?\"\n                onConfirm={() => onDelete && onDelete(file.relPath)}\n                okText=\"是\"\n                cancelText=\"否\"\n              >\n                <Button block ghost danger icon={<DeleteOutlined />} variant=\"outlined\" color=\"danger\">\n                  删除\n                </Button>\n              </Popconfirm>\n            </div>\n\n            {/* Info Sections */}\n            <Space direction=\"vertical\" size={24} style={{ width: \"100%\" }}>\n              {/* Basic Info */}\n              <div>\n                <div\n                  style={{\n                    fontSize: 12,\n                    fontWeight: 600,\n                    color: tertiaryTextColor,\n                    textTransform: \"uppercase\",\n                    marginBottom: 12,\n                  }}\n                >\n                  基本信息\n                </div>\n                <div\n                  style={{\n                    display: \"grid\",\n                    gridTemplateColumns: \"1fr 1fr\",\n                    gap: 12,\n                  }}\n                >\n                  <div>\n                    <div\n                      style={{\n                        color: tertiaryTextColor,\n                        fontSize: 12,\n                        marginBottom: 2,\n                      }}\n                    >\n                      文件大小\n                    </div>\n                    <div style={{ fontSize: 13, color: textColor }}>\n                      {formatFileSize(file.size || 0)}\n                    </div>\n                  </div>\n                  <div>\n                    <div\n                      style={{\n                        color: tertiaryTextColor,\n                        fontSize: 12,\n                        marginBottom: 2,\n                      }}\n                    >\n                      格式\n                    </div>\n                    <div style={{ fontSize: 13, color: textColor }}>\n                      {file.filename.split(\".\").pop().toUpperCase()}\n                    </div>\n                  </div>\n                  {imageMeta && (\n                    <>\n                      <div>\n                        <div\n                          style={{\n                            color: tertiaryTextColor,\n                            fontSize: 12,\n                            marginBottom: 2,\n                          }}\n                        >\n                          分辨率\n                        </div>\n                        <div style={{ fontSize: 13, color: textColor }}>\n                          {imageMeta.width} × {imageMeta.height}\n                        </div>\n                      </div>\n                      <div>\n                        <div\n                          style={{\n                            color: tertiaryTextColor,\n                            fontSize: 12,\n                            marginBottom: 2,\n                          }}\n                        >\n                          色彩空间\n                        </div>\n                        <div style={{ fontSize: 13, color: textColor }}>\n                          {imageMeta.space || \"-\"}\n                        </div>\n                      </div>\n                    </>\n                  )}\n                  <div>\n                    <div\n                      style={{\n                        color: tertiaryTextColor,\n                        fontSize: 12,\n                        marginBottom: 2,\n                      }}\n                    >\n                      上传时间\n                    </div>\n                    <div style={{ fontSize: 13, color: textColor }}>\n                      {dayjs(file.uploadTime).format(\"YYYY-MM-DD\")}\n                    </div>\n                  </div>\n                  {previewLocation && (\n                    <div style={{ gridColumn: \"span 2\" }}>\n                      <div\n                        style={{\n                          color: tertiaryTextColor,\n                          fontSize: 12,\n                          marginBottom: 2,\n                        }}\n                      >\n                        拍摄地点\n                      </div>\n\n                      <div\n                        style={{\n                          fontSize: 13,\n                          color: textColor,\n                          display: \"flex\",\n                          alignItems: \"center\",\n                          gap: 4,\n                          marginBottom: 8,\n                        }}\n                      >\n                        <EnvironmentOutlined /> {previewLocation}\n                      </div>\n                      {imageMeta && imageMeta.exif && imageMeta.exif.latitude && imageMeta.exif.longitude && (\n                        <div style={{ position: \"relative\", height: 150, borderRadius: 8, overflow: \"hidden\", border: `1px solid ${isDarkMode ? \"rgba(255,255,255,0.2)\" : \"rgba(0,0,0,0.1)\"}` }}>\n                          <iframe\n                            title=\"Map Thumbnail\"\n                            width=\"100%\"\n                            height=\"200\"\n                            frameBorder=\"0\"\n                            scrolling=\"no\"\n                            marginHeight=\"0\"\n                            marginWidth=\"0\"\n                            src={`https://www.openstreetmap.org/export/embed.html?bbox=${imageMeta.exif.longitude - 0.01}%2C${imageMeta.exif.latitude - 0.01}%2C${imageMeta.exif.longitude + 0.01}%2C${imageMeta.exif.latitude + 0.01}&layer=mapnik&marker=${imageMeta.exif.latitude}%2C${imageMeta.exif.longitude}`}\n                            style={{ border: 0 }}\n                          />\n                          <div style={{\n                            position: \"absolute\",\n                            bottom: 0,\n                            right: 0,\n                            background: \"rgba(255, 255, 255, 0.7)\",\n                            padding: \"1px 4px\",\n                            fontSize: \"9px\",\n                            color: \"#000\",\n                            pointerEvents: \"none\",\n                            borderTopLeftRadius: 4\n                          }}>\n                            © OSM\n                          </div>\n                        </div>\n                      )}\n                    </div>\n                  )}\n                </div>\n              </div>\n\n              {/* EXIF Data */}\n              {imageMeta?.exif && (\n                <div>\n                  <div\n                    style={{\n                      fontSize: 12,\n                      fontWeight: 600,\n                      color: tertiaryTextColor,\n                      textTransform: \"uppercase\",\n                      marginBottom: 12,\n                    }}\n                  >\n                    拍摄参数\n                  </div>\n                  <Space\n                    direction=\"vertical\"\n                    size={12}\n                    style={{ width: \"100%\" }}\n                  >\n                    <div\n                      style={{ display: \"flex\", alignItems: \"center\", gap: 12 }}\n                    >\n                      <CameraOutlined\n                        style={{ fontSize: 16, color: tertiaryTextColor }}\n                      />\n                      <div>\n                        <div style={{ fontSize: 13, color: textColor }}>\n                          {[imageMeta.exif.make, imageMeta.exif.model]\n                            .filter(Boolean)\n                            .join(\" \")}\n                        </div>\n                        <div style={{ fontSize: 12, color: tertiaryTextColor }}>\n                          相机\n                        </div>\n                      </div>\n                    </div>\n                    {imageMeta.exif.dateTimeOriginal && (\n                      <div\n                        style={{\n                          display: \"flex\",\n                          alignItems: \"center\",\n                          gap: 12,\n                        }}\n                      >\n                        <HistoryOutlined\n                          style={{ fontSize: 16, color: tertiaryTextColor }}\n                        />\n                        <div>\n                          <div style={{ fontSize: 13, color: textColor }}>\n                            {dayjs(imageMeta.exif.dateTimeOriginal).format(\n                              \"YYYY-MM-DD HH:mm:ss\"\n                            )}\n                          </div>\n                          <div style={{ fontSize: 12, color: tertiaryTextColor }}>\n                            拍摄时间\n                          </div>\n                        </div>\n                      </div>\n                    )}\n                    {imageMeta.exif.lensModel && (\n                      <div\n                        style={{\n                          display: \"flex\",\n                          alignItems: \"center\",\n                          gap: 12,\n                        }}\n                      >\n                        <span\n                          style={{ fontSize: 16, color: tertiaryTextColor }}\n                        >\n                          ◎\n                        </span>\n                        <div>\n                          <div style={{ fontSize: 13, color: textColor }}>\n                            {imageMeta.exif.lensModel}\n                          </div>\n                          <div\n                            style={{ fontSize: 12, color: tertiaryTextColor }}\n                          >\n                            镜头\n                          </div>\n                        </div>\n                      </div>\n                    )}\n                    <div style={{ display: \"flex\", gap: 24, marginTop: 4, flexWrap: \"wrap\" }}>\n                      {imageMeta.exif.fNumber && (\n                        <div>\n                          <div\n                            style={{\n                              fontSize: 13,\n                              fontWeight: 500,\n                              color: textColor,\n                            }}\n                          >\n                            f/{formatFNumber(imageMeta.exif.fNumber)}\n                          </div>\n                          <div\n                            style={{ fontSize: 12, color: tertiaryTextColor }}\n                          >\n                            光圈\n                          </div>\n                        </div>\n                      )}\n                      {imageMeta.exif.exposureTime && (\n                        <div>\n                          <div\n                            style={{\n                              fontSize: 13,\n                              fontWeight: 500,\n                              color: textColor,\n                            }}\n                          >\n                            {formatExposureTime(imageMeta.exif.exposureTime)}\n                          </div>\n                          <div\n                            style={{ fontSize: 12, color: tertiaryTextColor }}\n                          >\n                            快门\n                          </div>\n                        </div>\n                      )}\n                      {imageMeta.exif.iso && (\n                        <div>\n                          <div\n                            style={{\n                              fontSize: 13,\n                              fontWeight: 500,\n                              color: textColor,\n                            }}\n                          >\n                            {imageMeta.exif.iso}\n                          </div>\n                          <div\n                            style={{ fontSize: 12, color: tertiaryTextColor }}\n                          >\n                            ISO\n                          </div>\n                        </div>\n                      )}\n                    </div>\n                  </Space>\n                </div>\n              )}\n            </Space>\n          </div>\n        </div>\n      </div >\n    </Modal >\n  );\n};\n\nexport default ImageDetailModal;\n"
  },
  {
    "path": "client/src/components/ImageEditModal.js",
    "content": "import React, { useMemo } from \"react\";\nimport { Modal, Button, theme as antdTheme } from \"antd\";\nimport FilerobotImageEditor from \"react-filerobot-image-editor\";\n\nconst ImageEditModal = ({\n  open,\n  file,\n  editorSaving,\n  onCancel,\n  onClose,\n  onOverwriteSave,\n  onSaveAs,\n  getEditorDefaults,\n  getCurrentImgDataFnRef,\n  theme,\n}) => {\n  const { token } = antdTheme.useToken();\n\n  const isDarkMode = useMemo(() => {\n    if (theme === \"dark\" || theme === true) return true;\n    if (theme === \"light\" || theme === false) return false;\n    const bg = (token?.colorBgContainer || \"\").toLowerCase();\n    return bg === \"#141414\" || bg === \"#000000\" || bg === \"#1f1f1f\";\n  }, [theme, token?.colorBgContainer]);\n\n  const editorSource = useMemo(() => {\n    if (!file?.url) return null;\n    if (/^https?:\\/\\//i.test(file.url)) return file.url;\n    return `${window.location.origin}${file.url}`;\n  }, [file]);\n\n  const filerobotTheme = useMemo(() => {\n    const toRgba = (hex, alpha) => {\n      if (typeof hex !== \"string\") return undefined;\n      const h = hex.trim();\n      if (!h.startsWith(\"#\")) return undefined;\n      const raw = h.slice(1);\n      const full =\n        raw.length === 3\n          ? raw\n            .split(\"\")\n            .map((c) => c + c)\n            .join(\"\")\n          : raw;\n      if (full.length !== 6) return undefined;\n      const r = parseInt(full.slice(0, 2), 16);\n      const g = parseInt(full.slice(2, 4), 16);\n      const b = parseInt(full.slice(4, 6), 16);\n      if ([r, g, b].some((n) => Number.isNaN(n))) return undefined;\n      return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n    };\n\n    const primary = token?.colorPrimary || \"#1677ff\";\n    const primaryActiveBg = toRgba(primary, isDarkMode ? 0.22 : 0.12) || \"#ECF3FF\";\n\n    if (!isDarkMode) {\n      return {\n        palette: {\n          \"accent-primary\": primary,\n          \"accent-primary-active\": primary,\n          \"bg-primary-active\": primaryActiveBg,\n        },\n        typography: { fontFamily: \"Roboto, Arial\" },\n      };\n    }\n\n    return {\n      palette: {\n        \"accent-primary\": primary,\n        \"accent-primary-active\": primary,\n        \"bg-primary\": token?.colorBgContainer || \"#141414\",\n        \"bg-secondary\": token?.colorBgContainer || \"#141414\",\n        \"bg-stateless\": token?.colorFillSecondary || \"#1f1f1f\",\n        \"bg-hover\": token?.colorBgLayout || \"#1f1f1f\",\n        \"bg-primary-active\": primaryActiveBg,\n        \"txt-primary\": token?.colorText || \"rgba(255,255,255,0.85)\",\n        \"txt-secondary\": token?.colorTextSecondary || \"rgba(255,255,255,0.45)\",\n        \"icon-primary\": token?.colorTextSecondary || \"rgba(255,255,255,0.65)\",\n        \"borders-secondary\": token?.colorBorder || \"rgba(255,255,255,0.12)\",\n        \"borders-primary\": token?.colorBorder || \"rgba(255,255,255,0.12)\",\n        \"bg-active\": token?.colorBgLayout || \"#1f1f1f\",\n        \"light-shadow\": \"rgba(0, 0, 0, 0.6)\",\n      },\n      typography: { fontFamily: \"Roboto, Arial\" },\n    };\n  }, [\n    isDarkMode,\n    token?.colorPrimary,\n    token?.colorBgContainer,\n    token?.colorFillSecondary,\n    token?.colorText,\n    token?.colorTextSecondary,\n    token?.colorBorder,\n    token?.colorBgLayout,\n  ]);\n\n\n  const translations = {\n    name: \"名称\",\n    save: \"保存\",\n    saveAs: \"另存为\",\n    back: \"返回\",\n    loading: \"加载中...\",\n    resetOperations: \"重置/删除全部操作\",\n    changesLoseWarningHint: \"如果点击“重置”按钮，您的更改将丢失。是否继续？\",\n    discardChangesWarningHint: \"如果关闭弹窗，您最后的更改将不会保存。\",\n    cancel: \"取消\",\n    apply: \"应用\",\n    warning: \"警告\",\n    confirm: \"确认\",\n    discardChanges: \"放弃更改\",\n    undoTitle: \"撤销上一步\",\n    redoTitle: \"重做上一步\",\n    showImageTitle: \"显示原图\",\n    zoomInTitle: \"放大\",\n    zoomOutTitle: \"缩小\",\n    toggleZoomMenuTitle: \"切换缩放菜单\",\n    adjustTab: \"调整\",\n    finetuneTab: \"微调\",\n    filtersTab: \"滤镜\",\n    watermarkTab: \"水印\",\n    annotateTabLabel: \"标注\",\n    resize: \"调整大小\",\n    resizeTab: \"调整大小\",\n    imageName: \"图片名称\",\n    invalidImageError: \"提供的图片无效\",\n    uploadImageError: \"上传图片时出错\",\n    areNotImages: \"不是图片\",\n    isNotImage: \"不是图片\",\n    toBeUploaded: \"待上传\",\n    cropTool: \"裁剪\",\n    original: \"原图\",\n    custom: \"自定义\",\n    square: \"方形\",\n    landscape: \"风景\",\n    portrait: \"人像\",\n    ellipse: \"椭圆\",\n    classicTv: \"传统电视\",\n    cinemascope: \"宽银幕\",\n    arrowTool: \"箭头\",\n    blurTool: \"模糊\",\n    brightnessTool: \"亮度\",\n    contrastTool: \"对比度\",\n    ellipseTool: \"椭圆\",\n    unFlipX: \"取消水平翻转\",\n    flipX: \"水平翻转\",\n    unFlipY: \"取消垂直翻转\",\n    flipY: \"垂直翻转\",\n    hsvTool: \"HSV\",\n    hue: \"色相\",\n    brightness: \"亮度\",\n    saturation: \"饱和度\",\n    value: \"明度\",\n    imageTool: \"图片\",\n    importing: \"导入中...\",\n    addImage: \"+ 添加图片\",\n    uploadImage: \"上传图片\",\n    fromGallery: \"从图库\",\n    lineTool: \"直线\",\n    penTool: \"画笔\",\n    polygonTool: \"多边形\",\n    sides: \"边数\",\n    rectangleTool: \"矩形\",\n    cornerRadius: \"圆角半径\",\n    resizeWidthTitle: \"宽度(像素)\",\n    resizeHeightTitle: \"高度(像素)\",\n    toggleRatioLockTitle: \"锁定/解锁比例\",\n    resetSize: \"重置为原始大小\",\n    rotateTool: \"旋转\",\n    textTool: \"文字\",\n    textSpacings: \"文字间距\",\n    textAlignment: \"文字对齐\",\n    fontFamily: \"字体\",\n    size: \"大小\",\n    letterSpacing: \"字间距\",\n    lineHeight: \"行高\",\n    warmthTool: \"色温\",\n    addWatermark: \"+ 添加水印\",\n    addTextWatermark: \"+ 添加文字水印\",\n    addWatermarkTitle: \"选择水印类型\",\n    uploadWatermark: \"上传水印\",\n    addWatermarkAsText: \"添加为文字\",\n    padding: \"内边距\",\n    paddings: \"内边距\",\n    shadow: \"阴影\",\n    horizontal: \"水平\",\n    vertical: \"垂直\",\n    blur: \"模糊\",\n    opacity: \"不透明度\",\n    transparency: \"透明度\",\n    position: \"位置\",\n    stroke: \"描边\",\n    saveAsModalTitle: \"另存为\",\n    extension: \"扩展名\",\n    format: \"格式\",\n    nameIsRequired: \"名称是必填项。\",\n    quality: \"质量\",\n    imageDimensionsHoverTitle: \"保存的图片尺寸 (宽 x 高)\",\n    cropSizeLowerThanResizedWarning: \"注意：选定的裁剪区域小于应用的调整大小，这可能会导致质量下降\",\n    actualSize: \"实际大小 (100%)\",\n    fitSize: \"适应屏幕\",\n    addImageTitle: \"选择要添加的图片...\",\n    mutualizedFailedToLoadImg: \"加载图片失败\",\n    tabsMenu: \"菜单\",\n    download: \"下载\",\n    width: \"宽度\",\n    height: \"高度\",\n    plus: \"+\",\n    cropItemNoEffect: \"此裁剪项无预览可用\"\n  };\n\n  return (\n    <Modal\n      open={open}\n      footer={null}\n      onCancel={onCancel}\n      width=\"100vw\"\n      style={{ top: 0, margin: 0, maxWidth: \"100vw\", padding: 0 }}\n      styles={{\n        body: { padding: 0, height: \"100vh\", overflow: \"hidden\" },\n        content: { padding: 0 },\n        container: { padding: 0 },\n      }}\n      closeIcon={null}\n      destroyOnClose\n    >\n      {editorSource && file && (\n        <div style={{ height: \"100vh\", position: \"relative\" }}>\n          <div\n            style={{\n              position: \"absolute\",\n              left: 16,\n              top: 16,\n              zIndex: 1000,\n              display: \"flex\",\n              flexDirection: \"row\",\n              gap: 12,\n              pointerEvents: \"auto\",\n            }}\n          >\n            <Button\n              type=\"primary\"\n              disabled={editorSaving}\n              loading={editorSaving}\n              onClick={onOverwriteSave}\n            >\n              覆盖保存\n            </Button>\n            <Button disabled={editorSaving} onClick={onSaveAs}>\n              另存为上传\n            </Button>\n          </div>\n          <FilerobotImageEditor\n            source={editorSource}\n            onClose={onClose}\n            closeAfterSave={false}\n            language=\"zh\"\n            translations={translations}\n            defaultSavedImageName={getEditorDefaults(file).baseName}\n            defaultSavedImageType={getEditorDefaults(file).type}\n            defaultSavedImageQuality={92}\n            savingPixelRatio={2}\n            previewPixelRatio={1}\n            getCurrentImgDataFnRef={getCurrentImgDataFnRef}\n            removeSaveButton={true}\n            theme={filerobotTheme}\n          />\n        </div>\n      )}\n    </Modal>\n  );\n};\n\nexport default ImageEditModal;\n"
  },
  {
    "path": "client/src/components/ImageGallery.js",
    "content": "import React, { useState, useEffect, useRef, useMemo, useCallback } from \"react\";\nimport {\n  Masonry,\n  Button,\n  Typography,\n  Modal,\n  message,\n  Popconfirm,\n  Tabs,\n  Input,\n  Empty,\n  Spin,\n  Grid,\n  theme,\n  Popover,\n} from \"antd\";\nimport {\n  DeleteOutlined,\n  DownloadOutlined,\n  CopyOutlined,\n  EditOutlined,\n  SearchOutlined,\n  FolderOutlined,\n  MenuOutlined,\n  ApiOutlined,\n  CloudUploadOutlined,\n  EnvironmentOutlined,\n  CodeOutlined,\n  CheckOutlined,\n  CloseOutlined,\n  AreaChartOutlined,\n  ThunderboltOutlined,\n} from \"@ant-design/icons\";\nimport { thumbHashToDataURL } from \"thumbhash\";\nimport DirectorySelector from \"./DirectorySelector\";\nimport SvgToolModal from \"./SvgToolModal\";\nimport AlbumManager from \"./AlbumManager\";\nimport ImageDetailModal from \"./ImageDetailModal\";\nimport ImageEditModal from \"./ImageEditModal\";\nimport dayjs from \"dayjs\";\n\nconst { Title, Text } = Typography;\n\nconst getCacheBustedUrl = (img, width = 0) => {\n  if (!img) return \"\";\n  let u = img.url;\n  if (!u) return \"\";\n  const t = img.mtime || (img.uploadTime ? new Date(img.uploadTime).getTime() : 0);\n  if (t) {\n    u += u.includes('?') ? `&t=${t}` : `?t=${t}`;\n  }\n  if (width > 0) {\n    u += u.includes('?') ? `&w=${width}` : `?w=${width}`;\n  }\n  return u;\n};\n// Helper to convert base64 thumbhash to data URL\nconst getThumbHashUrl = (hash) => {\n  if (!hash) return null;\n  try {\n    const binary = Uint8Array.from(atob(hash), c => c.charCodeAt(0));\n    return thumbHashToDataURL(binary);\n  } catch (e) {\n    console.error(\"ThumbHash decode error:\", e);\n    return null;\n  }\n};\n\nconst encodePath = (path) => {\n  if (!path) return \"\";\n  return path.split('/').map(encodeURIComponent).join('/');\n};\n\nconst ImageItem = ({\n  image,\n  hoverKey,\n  setHoverKey,\n  handlePreview,\n  formatFileSize,\n  isMobile,\n  handleDownload,\n  onCopyClick,\n  handleDelete,\n  handleEdit,\n  hoverLocation,\n  isBatchMode,\n  isSelected,\n  onToggleSelect,\n  registerRef,\n  thumbnailWidth = 0\n}) => {\n  const [loaded, setLoaded] = useState(false);\n  const videoRef = useRef(null);\n  const {\n    token: { colorBgContainer, colorPrimary },\n  } = theme.useToken();\n\n  useEffect(() => {\n    if (!videoRef.current) return;\n    const key = image.relPath || image.url || image.filename;\n\n    if (hoverKey === key) {\n      videoRef.current.currentTime = 0;\n      const playPromise = videoRef.current.play();\n      if (playPromise !== undefined) {\n        playPromise.catch(() => { });\n      }\n    } else {\n      videoRef.current.pause();\n      videoRef.current.currentTime = 0;\n    }\n  }, [hoverKey, image]);\n\n  return (\n    <div\n      ref={(node) => registerRef && registerRef(image.relPath, node)}\n      style={{\n        position: \"relative\",\n        overflow: \"hidden\",\n        borderRadius: \"0px\",\n        boxShadow: \"0 2px 8px rgba(0,0,0,0.05)\",\n        transition: \"transform 0.3s ease\",\n        background: colorBgContainer,\n        cursor: isBatchMode ? \"default\" : \"zoom-in\",\n        transform: isBatchMode && isSelected ? \"scale(0.95)\" : \"scale(1)\",\n      }}\n      onMouseEnter={() => {\n        if (!isBatchMode) {\n          setHoverKey(image.relPath || image.url || image.filename);\n        }\n      }}\n      onMouseLeave={() => {\n        if (!isBatchMode) {\n          setHoverKey(null);\n        }\n      }}\n      onClick={(e) => {\n        if (isBatchMode) {\n          e.stopPropagation();\n          onToggleSelect && onToggleSelect(image.relPath);\n        } else {\n          handlePreview(image);\n        }\n      }}\n    >\n      {/* Batch Selection Overlay */}\n      {isBatchMode && (\n        <>\n          <div style={{\n            position: 'absolute',\n            top: 8,\n            left: 8,\n            zIndex: 20,\n            pointerEvents: 'none',\n          }}>\n            <div style={{\n              width: 24,\n              height: 24,\n              borderRadius: '50%',\n              border: '2px solid #fff',\n              background: isSelected ? colorPrimary : 'rgba(0,0,0,0.3)',\n              display: 'flex',\n              alignItems: 'center',\n              justifyContent: 'center',\n              boxShadow: '0 2px 4px rgba(0,0,0,0.2)',\n              transition: 'background 0.2s'\n            }}>\n              {isSelected && <CheckOutlined style={{ color: '#fff', fontSize: 14 }} />}\n            </div>\n          </div>\n          {isSelected && (\n            <div style={{\n              position: \"absolute\",\n              top: 0,\n              left: 0,\n              right: 0,\n              bottom: 0,\n              border: `4px solid ${colorPrimary}`,\n              zIndex: 15,\n              pointerEvents: \"none\",\n            }} />\n          )}\n        </>\n      )}\n\n      <div\n        style={{\n          overflow: \"hidden\",\n          position: \"relative\",\n          // Use a simple div for background placeholder\n          background: \"#f0f0f0\",\n        }}\n      >\n        {/* ThumbHash Placeholder Layer */}\n        {image.thumbhash && (\n          <div\n            style={{\n              position: \"absolute\",\n              top: 0,\n              left: 0,\n              right: 0,\n              bottom: 0,\n              backgroundImage: `url(${getThumbHashUrl(image.thumbhash)})`,\n              backgroundSize: 'cover',\n              backgroundPosition: 'center',\n              filter: 'blur(5px)', // Optional: slight blur to smooth out artifacts\n              transform: 'scale(1.1)', // Prevent blur edges\n              opacity: loaded ? 0 : 1,\n              transition: \"opacity 0.5s ease-out\",\n              zIndex: 1,\n            }}\n          />\n        )}\n\n        {/* Real Image/Video Layer */}\n        {(() => {\n          const isVideo = /\\.(mp4|webm)$/i.test(image.filename);\n          const isGif = /\\.gif$/i.test(image.filename);\n          // GIF 使用原始文件路径，保留完整动画（与详情页策略一致）\n          const gifSrc = image.url.replace(/^\\/api\\/images\\//, \"/api/files/\");\n          if (isVideo) {\n            return (\n              <video\n                ref={videoRef}\n                src={getCacheBustedUrl(image)}\n                muted\n                loop\n                playsInline\n                preload=\"metadata\"\n                style={{\n                  width: \"100%\",\n                  height: \"100%\",\n                  display: \"block\",\n                  objectFit: \"cover\",\n                  transition: \"transform 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.5s ease-in\",\n                  transform:\n                    hoverKey === (image.relPath || image.url || image.filename)\n                      ? \"scale(1.05)\"\n                      : \"scale(1)\",\n                  opacity: loaded ? 1 : 0,\n                  position: \"relative\",\n                  zIndex: 2,\n                }}\n                onLoadedData={() => setLoaded(true)}\n              />\n            );\n          }\n          return (\n            <img\n              alt={image.filename}\n              src={isGif ? gifSrc : getCacheBustedUrl(image, thumbnailWidth)}\n              draggable={false}\n              loading=\"lazy\"\n              onLoad={() => setLoaded(true)}\n              style={{\n                width: \"100%\",\n                display: \"block\",\n                transition: \"transform 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.5s ease-in\",\n                transform:\n                  hoverKey ===\n                    (image.relPath || image.url || image.filename)\n                    ? \"scale(1.05)\"\n                    : \"scale(1)\",\n                opacity: loaded ? 1 : 0, // Fade in when loaded\n                position: \"relative\",\n                zIndex: 2,\n              }}\n            />\n          );\n        })()}\n      </div>\n\n      {/* Advanced Hover Overlay */}\n      {!isMobile && !isBatchMode && (\n        <div\n          style={{\n            position: \"absolute\",\n            top: 0,\n            left: 0,\n            right: 0,\n            bottom: 0,\n            background:\n              \"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 40%, rgba(0,0,0,0) 100%)\",\n            opacity:\n              hoverKey === (image.relPath || image.url || image.filename)\n                ? 1\n                : 0,\n            transition: \"opacity 0.3s ease\",\n            zIndex: 10,\n            display: \"flex\",\n            flexDirection: \"column\",\n            justifyContent: \"flex-end\",\n            padding: \"20px\",\n            pointerEvents: \"none\",\n          }}\n        >\n          <div\n            style={{\n              transform:\n                hoverKey === (image.relPath || image.url || image.filename)\n                  ? \"translateY(0)\"\n                  : \"translateY(10px)\",\n              transition: \"transform 0.3s ease\",\n              pointerEvents: \"auto\",\n            }}\n          >\n            {/* Title / Filename */}\n            <div\n              style={{\n                color: \"#fff\",\n                fontSize: \"18px\",\n                fontWeight: 700,\n                marginBottom: \"4px\",\n                lineHeight: 1.2,\n                textShadow: \"0 2px 4px rgba(0,0,0,0.3)\",\n                wordBreak: \"break-all\",\n              }}\n            >\n              {image.filename.replace(/\\.[^/.]+$/, \"\")}\n            </div>\n\n            {/* Metadata Row */}\n            <div\n              style={{\n                display: \"flex\",\n                alignItems: \"center\",\n                gap: \"8px\",\n                color: \"rgba(255,255,255,0.8)\",\n                fontSize: \"12px\",\n                marginBottom: \"12px\",\n                flexWrap: \"wrap\",\n              }}\n            >\n              <span>\n                {dayjs(image.uploadTime).format(\"YYYY-MM-DD\")}\n              </span>\n              <span>·</span>\n              <span>{formatFileSize(image.size)}</span>\n              {hoverLocation && (\n                <>\n                  <span>·</span>\n                  <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>\n                    <EnvironmentOutlined /> {hoverLocation}\n                  </span>\n                </>\n              )}\n            </div>\n\n            {/* Action Buttons */}\n            <div style={{ display: \"flex\", gap: \"8px\" }}>\n              <Button\n                size=\"small\"\n                type=\"text\"\n                icon={<DownloadOutlined />}\n                onClick={(e) => {\n                  e.stopPropagation();\n                  handleDownload(image);\n                }}\n                style={{\n                  color: \"#fff\",\n                  background: \"rgba(255,255,255,0.2)\",\n                  backdropFilter: \"blur(4px)\",\n                  border: \"1px solid rgba(255,255,255,0.1)\",\n                  borderRadius: \"4px\",\n                  fontSize: \"12px\",\n                }}\n              >\n                下载\n              </Button>\n              <Button\n                size=\"small\"\n                type=\"text\"\n                icon={<CopyOutlined />}\n                onClick={(e) => {\n                  e.stopPropagation();\n                  onCopyClick(image);\n                }}\n                style={{\n                  color: \"#fff\",\n                  background: \"rgba(255,255,255,0.2)\",\n                  backdropFilter: \"blur(4px)\",\n                  border: \"1px solid rgba(255,255,255,0.1)\",\n                  borderRadius: \"4px\",\n                  fontSize: \"12px\",\n                }}\n              >\n              </Button>\n              {!(/\\.(mp4|webm)$/i.test(image.filename)) && (\n                <Button\n                  size=\"small\"\n                  type=\"text\"\n                  icon={<EditOutlined />}\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    handleEdit(image);\n                  }}\n                  style={{\n                    color: \"#fff\",\n                    background: \"rgba(255,255,255,0.2)\",\n                    backdropFilter: \"blur(4px)\",\n                    border: \"1px solid rgba(255,255,255,0.1)\",\n                    borderRadius: \"4px\",\n                    fontSize: \"12px\",\n                  }}\n                >\n                </Button>\n              )}\n              <Popconfirm\n                title=\"确定删除?\"\n                onConfirm={(e) => {\n                  e.stopPropagation();\n                  handleDelete(image.relPath);\n                }}\n                onCancel={(e) => {\n                  e?.stopPropagation();\n                }}\n                okText=\"是\"\n                cancelText=\"否\"\n              >\n                <Button\n                  size=\"small\"\n                  type=\"text\"\n                  danger\n                  icon={<DeleteOutlined />}\n                  onClick={(e) => e.stopPropagation()}\n                  style={{\n                    background: \"rgba(0,0,0,0.4)\",\n                    backdropFilter: \"blur(4px)\",\n                    border: \"1px solid rgba(255,255,255,0.1)\",\n                    borderRadius: \"4px\",\n                    fontSize: \"12px\",\n                  }}\n                />\n              </Popconfirm>\n            </div>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n};\n\n\nconst MagicIcon = ({ active }) => (\n  <div style={{ position: 'relative', width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>\n    <svg\n      width=\"24\"\n      height=\"24\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      style={{\n        filter: active ? \"drop-shadow(0 0 8px rgba(139, 92, 246, 0.5))\" : \"none\",\n        transition: \"all 0.5s ease\"\n      }}\n    >\n      <defs>\n        <linearGradient id=\"star-gradient\" x1=\"0\" y1=\"0\" x2=\"24\" y2=\"24\" gradientUnits=\"userSpaceOnUse\">\n          <stop offset=\"0%\" stopColor={active ? \"#8B5CF6\" : \"#888\"} />\n          <stop offset=\"100%\" stopColor={active ? \"#3B82F6\" : \"#888\"} />\n        </linearGradient>\n      </defs>\n\n      {/* Main Star (Center-Left) */}\n      <path\n        d=\"M10 2L12 8L18 10L12 12L10 18L8 12L2 10L8 8L10 2Z\"\n        fill=\"url(#star-gradient)\"\n        className={active ? \"gemini-star-main\" : \"\"}\n        style={{ transformOrigin: \"10px 10px\", opacity: active ? 1 : 0.6 }}\n      />\n\n      {/* Medium Star (Top-Right) */}\n      <path\n        d=\"M19 2L20 5L23 6L20 7L19 10L18 7L15 6L18 5L19 2Z\"\n        fill=\"url(#star-gradient)\"\n        className={active ? \"gemini-star-medium\" : \"\"}\n        style={{ transformOrigin: \"19px 6px\", opacity: active ? 0.9 : 0 }}\n      />\n\n      {/* Small Star (Bottom-Right) */}\n      <path\n        d=\"M18 14L18.5 15.5L20 16L18.5 16.5L18 18L17.5 16.5L16 16L17.5 15.5L18 14Z\"\n        fill=\"url(#star-gradient)\"\n        className={active ? \"gemini-star-small\" : \"\"}\n        style={{ transformOrigin: \"18px 16px\", opacity: active ? 0.8 : 0 }}\n      />\n\n      <style>\n        {`\n          @keyframes star-pulse {\n             0%, 100% { transform: scale(1); opacity: 1; }\n             50% { transform: scale(0.85); opacity: 0.85; }\n          }\n          @keyframes star-twinkle {\n             0%, 100% { transform: scale(1) rotate(0deg); opacity: 1; }\n             50% { transform: scale(0.6) rotate(15deg); opacity: 0.7; }\n          }\n           @keyframes star-float {\n             0%, 100% { transform: translateY(0); }\n             50% { transform: translateY(-1px); }\n          }\n\n          .gemini-star-main {\n             animation: ${active ? \"star-pulse 3s ease-in-out infinite\" : \"none\"};\n          }\n          .gemini-star-medium {\n             animation: ${active ? \"star-twinkle 4s ease-in-out infinite\" : \"none\"};\n          }\n          .gemini-star-small {\n             animation: ${active ? \"star-twinkle 2.5s ease-in-out infinite 0.5s\" : \"none\"};\n          }\n          \n          /* Rotating Border Gradient Definition */\n          @property --angle {\n            syntax: '<angle>';\n            initial-value: 0deg;\n            inherits: false;\n          }\n          \n          @keyframes spin-border {\n            from { --angle: 0deg; }\n            to { --angle: 360deg; }\n          }\n          \n          .gemini-capsule-container {\n            position: relative;\n            z-index: 0;\n            /* Ensure no jumps: border is expanding OUTSIDE */\n          }\n          \n          .gemini-capsule-container.active::before {\n            content: \"\";\n            position: absolute;\n            inset: -1.5px; /* 1.5px Border Thickness */\n            z-index: -1;\n            border-radius: 100px;\n            padding: 1.5px; \n            background: conic-gradient(from 180deg, #4285F4, #9B72CB, #D96570, #F49CBB, #FBBC05, #34A853, #4285F4) border-box;\n            -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);\n            mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);\n            -webkit-mask-composite: xor;\n            mask-composite: exclude;\n          }\n          \n          /* Fallback for browsers not supporting @property for smooth conic rotation */\n          /* We can use a simpler background rotation if needed, but modern browsers support this */\n        `}\n      </style>\n    </svg>\n  </div>\n);\n\nconst ImageGallery = ({ onDelete, onRefresh, api, isAuthenticated, refreshTrigger, isBatchMode = false, selectedItems = new Set(), onSelectionChange = () => { } }) => {\n  const {\n    token: { colorBgContainer, colorPrimary, colorTextSecondary, colorText },\n  } = theme.useToken();\n  const { useBreakpoint } = Grid;\n  const screens = useBreakpoint();\n  const isMobile = !screens.md;\n  const isDark = theme.useToken().theme?.id === 1 || colorBgContainer === \"#141414\";\n  const isDarkMode = colorBgContainer === \"#141414\" || colorBgContainer === \"#000000\" || colorBgContainer === \"#1f1f1f\";\n\n  // Helper to determine if a color is light or dark (returns true if light)\n  const isLightColor = (r, g, b) => {\n    // Calculate relative luminance using standard formula\n    // Y = 0.2126R + 0.7152G + 0.0722B\n    const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;\n    return luminance > 0.6; // Threshold for considering it \"light\"\n  };\n\n  // Define capsule styles based on theme\n  const capsuleStyle = {\n    background: isDarkMode ? \"rgba(0, 0, 0, 0.65)\" : \"rgba(255, 255, 255, 0.65)\",\n    border: `1px solid ${isDarkMode ? \"rgba(255, 255, 255, 0.15)\" : \"rgba(255, 255, 255, 0.4)\"}`,\n    boxShadow: isDarkMode ? \"0 8px 32px rgba(0, 0, 0, 0.4)\" : \"0 8px 32px rgba(0, 0, 0, 0.08)\",\n    dividerColor: isDarkMode ? \"rgba(255, 255, 255, 0.15)\" : \"rgba(0,0,0,0.1)\",\n    iconColor: isDarkMode ? \"rgba(255, 255, 255, 0.45)\" : \"rgba(0,0,0,0.4)\",\n  };\n\n  const [searchText, setSearchText] = useState(\"\");\n  const [previewVisible, setPreviewVisible] = useState(false);\n  const [previewImage, setPreviewImage] = useState(\"\");\n  const [previewTitle, setPreviewTitle] = useState(\"\");\n  const [previewFile, setPreviewFile] = useState(null);\n  const [dir, setDir] = useState(\"\");\n  const [images, setImages] = useState([]);\n  const [loading, setLoading] = useState(false);\n  const [hoverKey, setHoverKey] = useState(null);\n  const [hoverLocation, setHoverLocation] = useState(\"\");\n  const [svgToolVisible, setSvgToolVisible] = useState(false);\n  const [albumManagerVisible, setAlbumManagerVisible] = useState(false);\n  const [directoryRefreshKey, setDirectoryRefreshKey] = useState(0);\n  const [copyModalVisible, setCopyModalVisible] = useState(false);\n  const [copyTargetImage, setCopyTargetImage] = useState(null);\n\n  // Album Password Logic\n  const [albumPasswords, setAlbumPasswords] = useState({}); // { \"dir\": \"password\" }\n  const [passwordPromptVisible, setPasswordPromptVisible] = useState(false);\n  const [passwordInput, setPasswordInput] = useState(\"\");\n  const [pendingDir, setPendingDir] = useState(null); // The directory that required password\n\n  // Thumbnail width from config (0 = use original)\n  // Thumbnail width from config (0 = use original)\n  const [thumbnailWidth, setThumbnailWidth] = useState(0);\n\n  // Magic Search\n  const [magicSearch, setMagicSearch] = useState(false);\n  const [magicSearchAvailable, setMagicSearchAvailable] = useState(false);\n\n  // Fetch config to get thumbnailWidth\n  useEffect(() => {\n    const fetchConfig = async () => {\n      try {\n        const response = await api.get(\"/config\");\n        if (response.data.success) {\n          if (response.data.data?.upload?.thumbnailWidth) {\n            setThumbnailWidth(response.data.data.upload.thumbnailWidth);\n          }\n          if (response.data.data?.magicSearch?.enabled) {\n            setMagicSearchAvailable(true);\n            setMagicSearch(true); // Default to enabled\n          }\n        }\n      } catch (error) {\n        console.warn(\"获取配置失败:\", error);\n      }\n    };\n    fetchConfig();\n  }, [api]);\n\n  useEffect(() => {\n    if (!hoverKey) {\n      setHoverLocation(\"\");\n      return;\n    }\n\n    // Find image\n    const img = images.find(i => (i.relPath || i.url || i.filename) === hoverKey);\n    if (!img) return;\n\n    // Debounce slightly or just fetch\n    let active = true;\n\n    const fetchLoc = async () => {\n      try {\n        // 1. Get Meta\n        const res = await api.get(`/images/meta/${encodePath(img.relPath)}`);\n        if (!active) return;\n\n        if (res.data?.success && res.data.data?.exif?.latitude) {\n          const { latitude, longitude } = res.data.data.exif;\n\n          // 2. Reverse Geocode\n          // Use a public API (Nominatim)\n          // Note: In production, consider caching this or moving to backend\n          const geoRes = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=10&accept-language=zh-CN`);\n          const geoData = await geoRes.json();\n\n          if (active && geoData) {\n            // Extract city/district\n            const addr = geoData.address;\n            // Try to find the most relevant \"city\" level name\n            const city = addr.city || addr.town || addr.county || addr.district || addr.state;\n            setHoverLocation(city ? `${city}` : (geoData.display_name ? geoData.display_name.split(',')[0] : \"未知位置\"));\n          }\n        }\n      } catch (e) {\n        // console.error(e); // Silent fail for location fetch\n      }\n    };\n\n    // Delay to avoid spamming on fast scroll\n    const timer = setTimeout(fetchLoc, 300);\n\n    return () => {\n      active = false;\n      clearTimeout(timer);\n    };\n  }, [hoverKey, images, api]);\n\n  const handleCopyClick = useCallback((image) => {\n    setCopyTargetImage(image);\n    setCopyModalVisible(true);\n  }, []);\n\n  const generateImageLinks = (image, type) => {\n    if (!image) return \"\";\n    const fullUrl = `${window.location.origin}${image.url}`;\n    switch (type) {\n      case \"markdown\":\n        return `![${image.filename}](${fullUrl})`;\n      case \"html\":\n        return `<img src=\"${fullUrl}\" alt=\"${image.filename}\" />`;\n      case \"url\":\n      default:\n        return fullUrl;\n    }\n  };\n\n  const CopyLinksModal = () => {\n    const [activeTab, setActiveTab] = useState(\"url\");\n    const content = generateImageLinks(copyTargetImage, activeTab);\n\n    const items = [\n      { key: \"url\", label: \"URL\" },\n      { key: \"markdown\", label: \"Markdown\" },\n      { key: \"html\", label: \"HTML\" },\n    ];\n\n    return (\n      <Modal\n        title=\"复制链接\"\n        open={copyModalVisible}\n        onCancel={() => {\n          setCopyModalVisible(false);\n          setCopyTargetImage(null);\n        }}\n        footer={null}\n        width={500}\n        centered\n        zIndex={1005} // Match upload overlay z-index\n      >\n        <div\n          style={{\n            background: isDarkMode ? \"rgba(255,255,255,0.05)\" : \"rgba(0,0,0,0.02)\",\n            padding: 16,\n            borderRadius: 8,\n          }}\n        >\n          <div\n            style={{\n              display: \"flex\",\n              justifyContent: \"space-between\",\n              alignItems: \"center\",\n              marginBottom: 12,\n            }}\n          >\n            <Tabs\n              activeKey={activeTab}\n              onChange={setActiveTab}\n              items={items}\n              size=\"small\"\n              style={{ marginBottom: 0 }}\n              tabBarStyle={{ marginBottom: 0, borderBottom: \"none\" }}\n            />\n            <Button\n              type=\"primary\"\n              size=\"small\"\n              icon={<CopyOutlined />}\n              onClick={() => {\n                copyToClipboard(content);\n                setCopyModalVisible(false);\n                setCopyTargetImage(null);\n              }}\n            >\n              一键复制\n            </Button>\n          </div>\n          <Input.TextArea\n            value={content}\n            autoSize={{ minRows: 3, maxRows: 6 }}\n            readOnly\n            style={{\n              fontFamily: \"monospace\",\n              fontSize: 12,\n              background: isDarkMode ? \"#141414\" : \"#fff\",\n              color: isDarkMode ? \"rgba(255,255,255,0.85)\" : undefined,\n            }}\n          />\n        </div>\n      </Modal>\n    );\n  };\n\n  const [loadingMore, setLoadingMore] = useState(false);\n  const [hasMore, setHasMore] = useState(true);\n  const loadMoreRef = useRef(null);\n  const [renameValue, setRenameValue] = useState(\"\");\n  const [renaming, setRenaming] = useState(false);\n  const [isEditingName, setIsEditingName] = useState(false);\n  const [imageMeta, setImageMeta] = useState(null);\n  const [metaLoading, setMetaLoading] = useState(false);\n  const [isEditingDir, setIsEditingDir] = useState(false);\n  const [dirValue, setDirValue] = useState(\"\");\n\n  const [isDragOver, setIsDragOver] = useState(false);\n  const [uploadQueue, setUploadQueue] = useState([]);\n  const [sessionUploadedFiles, setSessionUploadedFiles] = useState([]);\n  const uploading = uploadQueue.some(item => item.status === 'pending' || item.status === 'uploading');\n  const [editorVisible, setEditorVisible] = useState(false);\n  const [editorFile, setEditorFile] = useState(null);\n  const [editorSaving, setEditorSaving] = useState(false);\n  const editorGetCurrentImgDataRef = useRef(null);\n\n  // Drag Selection Logic\n  const imageRefs = useRef(new Map());\n  const [selectionBox, setSelectionBox] = useState(null);\n\n  const registerRef = useCallback((id, node) => {\n    if (node) {\n      imageRefs.current.set(id, node);\n    } else {\n      imageRefs.current.delete(id);\n    }\n  }, []);\n\n  const handleSelectionMouseDown = (e) => {\n    if (!isBatchMode) return;\n    if (e.button !== 0) return; // Only left click\n\n    // Prevent text selection\n    // document.body.style.userSelect = 'none'; // Done in effect\n\n    setSelectionBox({\n      startX: e.pageX,\n      startY: e.pageY,\n      currentX: e.pageX,\n      currentY: e.pageY,\n      isSelecting: true,\n      initialSelection: new Set(selectedItems)\n    });\n  };\n\n  useEffect(() => {\n    if (!selectionBox?.isSelecting) return;\n\n    document.body.style.userSelect = 'none';\n\n    const handleSelectionMouseMove = (e) => {\n      setSelectionBox(prev => ({\n        ...prev,\n        currentX: e.pageX,\n        currentY: e.pageY\n      }));\n    };\n\n    const handleSelectionMouseUp = (e) => {\n      document.body.style.userSelect = '';\n      setSelectionBox(null);\n    };\n\n    window.addEventListener('mousemove', handleSelectionMouseMove);\n    window.addEventListener('mouseup', handleSelectionMouseUp);\n\n    return () => {\n      window.removeEventListener('mousemove', handleSelectionMouseMove);\n      window.removeEventListener('mouseup', handleSelectionMouseUp);\n      document.body.style.userSelect = '';\n    };\n  }, [selectionBox?.isSelecting]);\n\n  // Real-time selection update\n  useEffect(() => {\n    if (!selectionBox?.isSelecting) return;\n\n    const { startX, startY, currentX, currentY, initialSelection } = selectionBox;\n    const left = Math.min(startX, currentX);\n    const top = Math.min(startY, currentY);\n    const width = Math.abs(currentX - startX);\n    const height = Math.abs(currentY - startY);\n\n    if (width < 5 && height < 5) return;\n\n    const animationFrame = requestAnimationFrame(() => {\n      const newSelected = new Set(initialSelection);\n      imageRefs.current.forEach((node, relPath) => {\n        if (!node) return;\n        const rect = node.getBoundingClientRect();\n        const nodeLeft = rect.left + window.scrollX;\n        const nodeTop = rect.top + window.scrollY;\n\n        if (\n          left < nodeLeft + rect.width &&\n          left + width > nodeLeft &&\n          top < nodeTop + rect.height &&\n          top + height > nodeTop\n        ) {\n          newSelected.add(relPath);\n        }\n      });\n\n      // Simple check to avoid unnecessary updates if size hasn't changed?\n      // But Set content might change.\n      onSelectionChange(newSelected);\n    });\n\n    return () => cancelAnimationFrame(animationFrame);\n  }, [selectionBox?.currentX, selectionBox?.currentY]);\n\n  const groups = useMemo(() => {\n    const map = new Map();\n    for (const img of images) {\n      const key = dayjs(img.uploadTime).format(\"YYYY年MM月DD日\");\n      const arr = map.get(key) || [];\n      arr.push(img);\n      map.set(key, arr);\n    }\n    const dates = Array.from(map.keys()).sort(\n      (a, b) => dayjs(b).valueOf() - dayjs(a).valueOf()\n    );\n    return dates.map((d) => ({ date: d, items: map.get(d) }));\n  }, [images]);\n\n  const getEditorDefaults = useCallback((file) => {\n    const filename = file?.filename || \"image\";\n    const lastDot = filename.lastIndexOf(\".\");\n    const baseName = lastDot > 0 ? filename.slice(0, lastDot) : filename;\n    const ext = lastDot > 0 ? filename.slice(lastDot + 1).toLowerCase() : \"png\";\n    const normalizedExt = ext === \"jpeg\" ? \"jpg\" : ext;\n    const type =\n      normalizedExt === \"jpg\" || normalizedExt === \"png\" || normalizedExt === \"webp\"\n        ? normalizedExt\n        : \"png\";\n    return { baseName, type };\n  }, []);\n\n  const handleEdit = useCallback((img) => {\n    setEditorFile(img);\n    setEditorVisible(true);\n  }, []);\n\n  const getDirFromRelPath = useCallback((relPath) => {\n    if (!relPath) return \"\";\n    const idx = relPath.lastIndexOf(\"/\");\n    return idx >= 0 ? relPath.slice(0, idx) : \"\";\n  }, []);\n\n  const splitFilename = useCallback((filename) => {\n    const safe = filename || \"image.png\";\n    const lastDot = safe.lastIndexOf(\".\");\n    if (lastDot <= 0) return { baseName: safe, ext: \"png\" };\n    return { baseName: safe.slice(0, lastDot), ext: safe.slice(lastDot + 1).toLowerCase() };\n  }, []);\n\n  const dataUrlToFile = useCallback((dataUrl, filename) => {\n    const commaIndex = dataUrl.indexOf(\",\");\n    const header = commaIndex >= 0 ? dataUrl.slice(0, commaIndex) : \"\";\n    const base64 = commaIndex >= 0 ? dataUrl.slice(commaIndex + 1) : dataUrl;\n    const mimeMatch = header.match(/data:([^;]+);base64/i);\n    const mimeType = mimeMatch?.[1] || \"application/octet-stream\";\n    const binary = atob(base64);\n    const bytes = new Uint8Array(binary.length);\n    for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);\n    return new File([bytes], filename, { type: mimeType });\n  }, []);\n\n  async function refreshAfterEdit(targetDir) {\n    setCurrentPage(1);\n    setHasMore(true);\n    await fetchImages(targetDir, 1, pageSize, searchText, false);\n  }\n\n  const exportFromEditor = useCallback((name, extension) => {\n    if (!editorGetCurrentImgDataRef.current) {\n      throw new Error(\"编辑器未就绪\");\n    }\n    const result = editorGetCurrentImgDataRef.current(\n      { name, extension, quality: 92 },\n      2,\n      true\n    );\n    return result;\n  }, []);\n\n  const uploadEdited = useCallback(\n    async ({ base64Image, targetDir, filename, overwrite }) => {\n      const file = dataUrlToFile(base64Image, filename);\n      const form = new FormData();\n      form.append(\"image\", file);\n      const params = overwrite ? { dir: targetDir, overwrite: \"true\" } : { dir: targetDir };\n      return api.post(\"/upload\", form, { params });\n    },\n    [api, dataUrlToFile]\n  );\n\n  const handleOverwriteSave = useCallback(async () => {\n    if (!editorFile) return;\n    const { baseName, ext } = splitFilename(editorFile.filename);\n    const targetDir = getDirFromRelPath(editorFile.relPath);\n\n    setEditorSaving(true);\n    let hideLoadingSpinner = null;\n    try {\n      const result = exportFromEditor(baseName, ext);\n      hideLoadingSpinner = result.hideLoadingSpinner;\n      const base64Image = result.imageData?.imageBase64;\n      if (!base64Image) throw new Error(\"导出失败\");\n\n      const res = await uploadEdited({\n        base64Image,\n        targetDir,\n        filename: editorFile.filename,\n        overwrite: true,\n      });\n\n      if (res.data?.success) {\n        message.success(\"已覆盖保存\");\n        setEditorVisible(false);\n        setEditorFile(null);\n        await refreshAfterEdit(targetDir);\n      } else {\n        message.error(res.data?.error || \"保存失败\");\n      }\n    } catch (e) {\n      message.error(e?.response?.data?.error || e?.message || \"保存失败\");\n    } finally {\n      try {\n        hideLoadingSpinner && hideLoadingSpinner();\n      } catch (e) { }\n      setEditorSaving(false);\n    }\n  }, [editorFile, exportFromEditor, getDirFromRelPath, refreshAfterEdit, splitFilename, uploadEdited]);\n\n  const handleSaveAs = useCallback(() => {\n    if (!editorFile) return;\n    const { baseName, ext } = splitFilename(editorFile.filename);\n    const targetDir = getDirFromRelPath(editorFile.relPath);\n    let nextName = `${baseName}-edited.${ext}`;\n\n    Modal.confirm({\n      title: \"另存为上传\",\n      content: (\n        <Input\n          defaultValue={nextName}\n          onChange={(e) => {\n            nextName = e.target.value;\n          }}\n          onPressEnter={() => { }}\n          autoFocus\n        />\n      ),\n      okText: \"上传\",\n      cancelText: \"取消\",\n      centered: true,\n      onOk: async () => {\n        const raw = (nextName || \"\").trim();\n        if (!raw) {\n          message.error(\"请输入文件名\");\n          throw new Error(\"invalid\");\n        }\n\n        const parts = splitFilename(raw);\n        setEditorSaving(true);\n        let hideLoadingSpinner = null;\n        try {\n          const result = exportFromEditor(parts.baseName, parts.ext);\n          hideLoadingSpinner = result.hideLoadingSpinner;\n          const base64Image = result.imageData?.imageBase64;\n          if (!base64Image) throw new Error(\"导出失败\");\n\n          const res = await uploadEdited({\n            base64Image,\n            targetDir,\n            filename: raw,\n            overwrite: false,\n          });\n\n          if (res.data?.success) {\n            message.success(\"已上传\");\n            setEditorVisible(false);\n            setEditorFile(null);\n            await refreshAfterEdit(targetDir);\n          } else {\n            message.error(res.data?.error || \"上传失败\");\n            throw new Error(\"failed\");\n          }\n        } catch (e) {\n          message.error(e?.response?.data?.error || e?.message || \"上传失败\");\n          throw e;\n        } finally {\n          try {\n            hideLoadingSpinner && hideLoadingSpinner();\n          } catch (e) { }\n          setEditorSaving(false);\n        }\n      },\n    });\n  }, [editorFile, exportFromEditor, getDirFromRelPath, refreshAfterEdit, splitFilename, uploadEdited]);\n\n  // 分页相关状态\n  const [currentPage, setCurrentPage] = useState(1);\n  const [pageSize, setPageSize] = useState(() => {\n    // 从localStorage读取分页大小，默认为10\n    const savedPageSize = localStorage.getItem(\"imageGalleryPageSize\");\n    return savedPageSize ? parseInt(savedPageSize) : 10;\n  });\n  const [pagination, setPagination] = useState({\n    current: 1,\n    pageSize: 10,\n    total: 0,\n    totalPages: 0,\n  });\n\n  async function fetchImages(\n    targetDir = dir,\n    targetPage = currentPage,\n    targetPageSize = pageSize,\n    targetSearch = searchText,\n    append = false\n  ) {\n    // Check authentication first\n    if (isAuthenticated === false) {\n      return;\n    }\n\n    if (append) {\n      setLoadingMore(true);\n    } else {\n      setLoading(true);\n    }\n    try {\n      const params = {\n        page: targetPage,\n        pageSize: targetPageSize,\n        ...(targetSearch && { search: targetSearch }),\n        ...(targetDir && { dir: targetDir }),\n      };\n\n      // Magic Search Branch\n      if (magicSearchAvailable && magicSearch && targetSearch && !targetDir) {\n        // Only allow global search for now, or filter by dir in backend? \n        // Backend implementation of 'search' currently searches ALL vectors.\n        // If we want to support directory filter, we need to update searchRoutes/ClipService.\n        // For now, let's assume global search.\n        if (append) {\n          setLoadingMore(false);\n          return; // No pagination for magic search yet\n        }\n\n        const searchRes = await api.post(\"/search/semantic\", { query: targetSearch, limit: 50 });\n        if (searchRes.data.success) {\n          setImages(searchRes.data.data);\n          setPagination({ current: 1, pageSize: 50, total: searchRes.data.data.length, totalPages: 1 });\n          setHasMore(false);\n          return;\n        }\n      }\n\n      const headers = {};\n      if (targetDir && albumPasswords[targetDir]) {\n        headers[\"x-album-password\"] = albumPasswords[targetDir];\n      }\n\n      const res = await api.get(\"/images\", { params, headers });\n      if (res.data.success) {\n        setImages((prev) => (append ? prev.concat(res.data.data) : res.data.data));\n        setPagination(res.data.pagination);\n        const p = res.data.pagination;\n        setHasMore(p.current < p.totalPages);\n      }\n    } catch (e) {\n      if (e.response && e.response.status === 403 && e.response.data?.locked) {\n        // Album is locked\n        setPendingDir(targetDir);\n        // Do NOT clear passwordInput here to avoid clearing user input if multiple requests fail (race condition)\n        // It is already cleared when dir changes.\n        setPasswordPromptVisible(true);\n        setLoading(false); // Stop loading spinner\n        return;\n      }\n      // Silent fail or minimal logging to avoid spamming user if it's just auth\n      if (e.response && e.response.status !== 401) {\n        message.error(\"获取图片列表失败\");\n      }\n    } finally {\n      if (append) {\n        setLoadingMore(false);\n      } else {\n        setLoading(false);\n      }\n    }\n  }\n\n  // 使用ref来跟踪是否是首次加载和防抖\n  const isInitialized = useRef(false);\n  const searchTimerRef = useRef(null);\n\n  // 统一的数据获取逻辑\n  useEffect(() => {\n    // Clear password when dir changes to force re-entry if navigating back\n    // But we need to be careful not to clear if we are just searching in the same dir?\n    // User requirement: \"每次都需要让输入子密码\" (Every time need to enter password).\n    // This implies if I leave a locked folder and come back, I need to enter password again.\n    // So clearing all passwords (or at least for this dir?) when `dir` changes is correct.\n    // However, if we clear ALL passwords, it's safer.\n\n    // We only want to clear passwords if the directory ACTUALLY changed.\n    // This effect runs on [dir, pageSize, searchText, isAuthenticated, refreshTrigger].\n    // We need to track previous dir.\n\n    // Actually, simply clearing `albumPasswords` here might cause infinite loops if fetchImages depends on it?\n    // fetchImages reads `albumPasswords` from state closure.\n\n    // Let's implement a dedicated effect for `dir` change to clear passwords.\n  }, [dir]); // Dummy placeholder, real logic below\n\n  // Track previous directory to detect changes\n  const prevDirRef = useRef(dir);\n\n  useEffect(() => {\n    if (prevDirRef.current !== dir) {\n      // Directory changed!\n      // Clear all stored passwords to enforce re-entry\n      setAlbumPasswords({});\n      prevDirRef.current = dir;\n\n      // Clear input and state to prevent race conditions\n      setPasswordInput(\"\");\n      setImages([]);\n      setHasMore(true);\n      setCurrentPage(1);\n      setPendingDir(null);\n\n      // Reset scroll position to top when switching folders\n      window.scrollTo(0, 0);\n    }\n  }, [dir]);\n\n  useEffect(() => {\n    if (!isInitialized.current) {\n      fetchImages(\"\", 1, pageSize, \"\");\n      isInitialized.current = true;\n      return;\n    }\n    if (searchTimerRef.current) {\n      clearTimeout(searchTimerRef.current);\n    }\n    searchTimerRef.current = setTimeout(() => {\n      setCurrentPage(1);\n      setHasMore(true);\n      fetchImages(dir, 1, pageSize, searchText, false);\n    }, searchText ? 500 : 0);\n    return () => {\n      if (searchTimerRef.current) {\n        clearTimeout(searchTimerRef.current);\n      }\n    };\n  }, [dir, pageSize, searchText, isAuthenticated, refreshTrigger]);\n\n  // 当搜索文本变化时重置到第一页\n  useEffect(() => {\n    if (isInitialized.current) {\n      setCurrentPage(1);\n    }\n  }, [searchText]);\n\n  useEffect(() => {\n    if (!isInitialized.current) return;\n    if (currentPage > 1) {\n      fetchImages(dir, currentPage, pageSize, searchText, true);\n    }\n  }, [currentPage]);\n\n  useEffect(() => {\n    const el = loadMoreRef.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(\n      (entries) => {\n        const entry = entries[0];\n        if (\n          entry.isIntersecting &&\n          hasMore &&\n          !loading &&\n          !loadingMore &&\n          images.length > 0\n        ) {\n          setCurrentPage((p) => p + 1);\n        }\n      },\n      { root: null, rootMargin: \"200px\", threshold: 0 }\n    );\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [hasMore, loading, loadingMore, images.length]);\n\n  const [previewLocation, setPreviewLocation] = useState(\"\");\n  const [menuOpen, setMenuOpen] = useState(false);\n\n  // Effect for fetching address in Preview Modal\n  useEffect(() => {\n    if (!previewVisible || !imageMeta?.exif?.latitude) {\n      setPreviewLocation(\"\");\n      return;\n    }\n\n    const { latitude, longitude } = imageMeta.exif;\n    let active = true;\n\n    const fetchPreviewLoc = async () => {\n      try {\n        const geoRes = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=10&accept-language=zh-CN`);\n        const geoData = await geoRes.json();\n\n        if (active && geoData) {\n          const addr = geoData.address;\n          // Construct detailed address: Province + City + District + Street + Name\n          // Example: 山东省 临沂市 兰山区 xx路 xx号\n          const parts = [];\n          if (addr.province) parts.push(addr.province);\n          if (addr.city && addr.city !== addr.province) parts.push(addr.city);\n          if (addr.district || addr.county) parts.push(addr.district || addr.county);\n          if (addr.road || addr.street || addr.pedestrian) parts.push(addr.road || addr.street || addr.pedestrian);\n          if (addr.house_number) parts.push(addr.house_number);\n\n          // If specific name exists (amenity, building, etc.), append it\n          const name = geoData.display_name.split(',')[0];\n          if (name && !parts.includes(name)) {\n            // Sometimes name is just street number or road, check if redundant\n            parts.push(name);\n          }\n\n          // If parts is empty or too short, fallback to display_name or city\n          let fullAddr = parts.join(\" \");\n\n          // Fallback logic\n          if (!fullAddr) {\n            fullAddr = geoData.display_name;\n          }\n\n          setPreviewLocation(fullAddr);\n        }\n      } catch (e) {\n        // console.error(e);\n      }\n    };\n\n    fetchPreviewLoc();\n\n    return () => { active = false; };\n  }, [previewVisible, imageMeta]);\n\n  // Helper for Upload Result\n  const generateLinks = (type) => {\n    return sessionUploadedFiles.map(file => {\n      const fullUrl = `${window.location.origin}${file.url}`;\n      switch (type) {\n        case 'markdown':\n          return `![${file.originalName}](${fullUrl})`;\n        case 'html':\n          return `<img src=\"${fullUrl}\" alt=\"${file.originalName}\" />`;\n        case 'url':\n        default:\n          return fullUrl;\n      }\n    }).join('\\n');\n  };\n\n  const UploadResult = () => {\n    const [activeTab, setActiveTab] = useState('url');\n    const content = generateLinks(activeTab);\n\n    const items = [\n      { key: 'url', label: 'URL' },\n      { key: 'markdown', label: 'Markdown' },\n      { key: 'html', label: 'HTML' },\n    ];\n\n    return (\n      <div style={{ marginTop: 16, background: isDarkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.02)', padding: 12, borderRadius: 8, width: '100%' }}>\n        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>\n          <Tabs\n            activeKey={activeTab}\n            onChange={setActiveTab}\n            items={items}\n            size=\"small\"\n            style={{ marginBottom: 0 }}\n            tabBarStyle={{ marginBottom: 0, borderBottom: 'none' }}\n          />\n          <Button\n            type=\"primary\"\n            size=\"small\"\n            icon={<CopyOutlined />}\n            onClick={() => copyToClipboard(content)}\n          >\n            一键复制\n          </Button>\n        </div>\n        <Input.TextArea\n          value={content}\n          autoSize={{ minRows: 3, maxRows: 6 }}\n          readOnly\n          style={{\n            fontFamily: 'monospace',\n            fontSize: 12,\n            background: isDarkMode ? '#141414' : '#fff',\n            color: isDarkMode ? 'rgba(255,255,255,0.85)' : undefined\n          }}\n        />\n      </div>\n    );\n  };\n\n  // Handle file uploads (Drag & Drop + Paste)\n  const handleUploadFiles = async (files) => {\n    if (!isAuthenticated) {\n      message.warning(\"请先登录\");\n      return;\n    }\n    if (!files || files.length === 0) return;\n\n    // Filter images and videos\n    const imageFiles = Array.from(files).filter(file =>\n      file.type.startsWith(\"image/\") || file.type.startsWith(\"video/\")\n    );\n\n    if (imageFiles.length === 0) {\n      message.warning(\"请选择图片或视频文件\");\n      return;\n    }\n\n    // Add to queue\n    const newQueueItems = imageFiles.map(file => ({\n      uid: `upload-${Date.now()}-${Math.random()}`,\n      file: file,\n      name: file.name,\n      progress: 0,\n      status: 'pending'\n    }));\n\n    setUploadQueue(newQueueItems);\n    setIsDragOver(false);\n\n    // Process queue\n    // We use a simple loop here, but could be concurrent if needed\n    // Using for...of loop to process sequentially or Promise.all for parallel?\n    // Parallel is better for user experience, maybe limit concurrency?\n    // Let's do simple Promise.all for now, browser limits connections anyway.\n\n    // Actually, let's process them one by one to ensure we don't overwhelm server if many files\n    // But Promise.all is faster. Let's do parallel.\n\n    // We need to define the upload function inside or outside\n    const uploadSingleFile = async (item) => {\n      const formData = new FormData();\n      if (dir) {\n        formData.append(\"dir\", dir);\n      }\n      formData.append(\"image\", item.file, item.file.name);\n\n      try {\n        // Update status to uploading\n        setUploadQueue(prev => prev.map(i => i.uid === item.uid ? { ...i, status: 'uploading' } : i));\n\n        const res = await api.post(\"/upload\", formData, {\n          headers: {\n            \"Content-Type\": \"multipart/form-data\",\n          },\n          onUploadProgress: (progressEvent) => {\n            const percentCompleted = Math.round(\n              (progressEvent.loaded * 100) / progressEvent.total\n            );\n            setUploadQueue(prev => prev.map(i => i.uid === item.uid ? { ...i, progress: percentCompleted } : i));\n          },\n        });\n\n        if (res.data && res.data.success) {\n          setUploadQueue(prev => prev.map(i => i.uid === item.uid ? { ...i, status: 'success', progress: 100 } : i));\n          setSessionUploadedFiles(prev => [...prev, res.data.data]);\n          return true;\n        } else {\n          throw new Error(res.data?.error || \"上传失败\");\n        }\n      } catch (error) {\n        console.error(\"Upload error:\", error);\n        setUploadQueue(prev => prev.map(i => i.uid === item.uid ? { ...i, status: 'error', errorMsg: error.message || \"上传出错\" } : i));\n        return false;\n      }\n    };\n\n    // Execute uploads\n    const results = await Promise.all(newQueueItems.map(item => uploadSingleFile(item)));\n\n    // Check if any success to refresh\n    if (results.some(r => r === true)) {\n      message.success(`上传完成`);\n      setCurrentPage(1);\n      fetchImages(dir, 1, pageSize, searchText, false);\n    }\n  };\n\n  // Handle URL upload (e.g., from clipboard image URL)\n  const handleUploadFilesFromUrl = async (url) => {\n    if (!isAuthenticated) {\n      message.warning(\"请先登录\");\n      return;\n    }\n    if (!url) return;\n\n    // Add to queue for UI feedback\n    const uid = `url-upload-${Date.now()}`;\n    const newQueueItem = {\n      uid,\n      name: url.split('/').pop() || 'url-image',\n      progress: 0,\n      status: 'uploading'\n    };\n    setUploadQueue(prev => [...prev, newQueueItem]);\n\n    try {\n      const response = await api.post('/upload-url', { url, dir });\n      if (response.data?.success) {\n        setUploadQueue(prev => prev.map(i => i.uid === uid ? { ...i, status: 'success', progress: 100 } : i));\n        setSessionUploadedFiles(prev => [...prev, response.data.data]);\n        message.success(`上传完成`);\n        setCurrentPage(1);\n        fetchImages(dir, 1, pageSize, searchText, false);\n      } else {\n        throw new Error(response.data?.error || '上传失败');\n      }\n    } catch (error) {\n      console.error('URL upload error:', error);\n      setUploadQueue(prev => prev.map(i => i.uid === uid ? { ...i, status: 'error', errorMsg: error.message || '上传出错' } : i));\n      message.error(error?.response?.data?.error || error.message || 'URL 上传失败');\n    }\n  };\n\n  // Global Paste Event Listener\n  useEffect(() => {\n    const handlePaste = async (e) => {\n      // Ignore paste if inside input/textarea\n      if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {\n        return;\n      }\n\n      const items = e.clipboardData?.items;\n      if (!items) return;\n\n      const files = [];\n      let imageUrl = null;\n\n      for (let i = 0; i < items.length; i++) {\n        if (items[i].type.indexOf('image') !== -1) {\n          const file = items[i].getAsFile();\n          if (file) files.push(file);\n        }\n      }\n\n      // Check for image URL in text clipboard\n      if (files.length === 0) {\n        // Try getData first (more reliable for plain text URLs)\n        const plainText = e.clipboardData?.getData('text/plain');\n        const uriList = e.clipboardData?.getData('text/uri-list');\n\n        const textToCheck = uriList || plainText;\n\n        if (textToCheck) {\n          const imageExtPattern = /\\.(jpg|jpeg|png|gif|webp|bmp|svg)(\\?.*)?$/i;\n          const urlCandidates = textToCheck.split(/\\r?\\n/).filter(line => line.trim());\n          for (const text of urlCandidates) {\n            const trimmed = text.trim();\n            if (imageExtPattern.test(trimmed) && trimmed.match(/^https?:\\/\\//)) {\n              imageUrl = trimmed;\n              break;\n            }\n          }\n        }\n\n        // Fallback: iterate clipboard items\n        if (!imageUrl) {\n          const textItems = e.clipboardData?.items;\n          if (textItems) {\n            for (let i = 0; i < textItems.length; i++) {\n              if (textItems[i].type === 'text/plain' || textItems[i].type === 'text/uri-list') {\n                const text = await new Promise((resolve) => {\n                  textItems[i].getAsString((str) => resolve(str));\n                });\n                if (text) {\n                  const imageExtPattern = /\\.(jpg|jpeg|png|gif|webp|bmp|svg)(\\?.*)?$/i;\n                  const urlCandidates = text.split(/\\r?\\n/).filter(line => line.trim());\n                  for (const line of urlCandidates) {\n                    const trimmed = line.trim();\n                    if (imageExtPattern.test(trimmed) && trimmed.match(/^https?:\\/\\//)) {\n                      imageUrl = trimmed;\n                      break;\n                    }\n                  }\n                  if (imageUrl) break;\n                }\n              }\n            }\n          }\n        }\n      }\n\n      if (files.length > 0) {\n        e.preventDefault();\n        handleUploadFiles(files);\n      } else if (imageUrl) {\n        e.preventDefault();\n        // Upload from URL\n        handleUploadFilesFromUrl(imageUrl);\n      }\n    };\n\n    window.addEventListener('paste', handlePaste);\n    return () => window.removeEventListener('paste', handlePaste);\n  }, [dir, isAuthenticated]); // Re-bind if dir changes so upload goes to correct dir\n\n  // Global Drag & Drop Listeners\n  useEffect(() => {\n    let dragCounter = 0;\n\n    const handleDragEnter = (e) => {\n      e.preventDefault();\n      e.stopPropagation();\n      dragCounter++;\n      if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {\n        setIsDragOver(true);\n      }\n    };\n\n    const handleDragLeave = (e) => {\n      e.preventDefault();\n      e.stopPropagation();\n      dragCounter--;\n      if (dragCounter === 0) {\n        setIsDragOver(false);\n      }\n    };\n\n    const handleDragOver = (e) => {\n      e.preventDefault();\n      e.stopPropagation();\n    };\n\n    const handleDrop = (e) => {\n      e.preventDefault();\n      e.stopPropagation();\n      setIsDragOver(false);\n      dragCounter = 0;\n\n      if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {\n        handleUploadFiles(e.dataTransfer.files);\n      }\n    };\n\n    window.addEventListener('dragenter', handleDragEnter);\n    window.addEventListener('dragleave', handleDragLeave);\n    window.addEventListener('dragover', handleDragOver);\n    window.addEventListener('drop', handleDrop);\n\n    return () => {\n      window.removeEventListener('dragenter', handleDragEnter);\n      window.removeEventListener('dragleave', handleDragLeave);\n      window.removeEventListener('dragover', handleDragOver);\n      window.removeEventListener('drop', handleDrop);\n    };\n  }, [dir, isAuthenticated]);\n\n  const handleDelete = async (relPath) => {\n    try {\n      await api.delete(`/images/${encodePath(relPath)}`);\n      message.success(\"删除成功\");\n      setImages((prev) => prev.filter((img) => img.relPath !== relPath));\n      const ps = pagination.pageSize || pageSize;\n      const newTotal = Math.max(0, (pagination.total || images.length) - 1);\n      const newTotalPages = Math.max(1, Math.ceil(newTotal / ps));\n      const newCurrent = Math.min(pagination.current || 1, newTotalPages);\n      setPagination({\n        ...pagination,\n        total: newTotal,\n        totalPages: newTotalPages,\n        current: newCurrent,\n      });\n      setHasMore(newCurrent < newTotalPages);\n      if (onDelete) {\n        onDelete(relPath);\n      }\n      return true; // Indicate success for callers\n    } catch (error) {\n      message.error(\"删除失败\");\n      return false;\n    }\n  };\n\n  const formatFileSize = (bytes) => {\n    if (bytes === 0) return \"0 Bytes\";\n    const k = 1024;\n    const sizes = [\"Bytes\", \"KB\", \"MB\", \"GB\"];\n    const i = Math.floor(Math.log(bytes) / Math.log(k));\n    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + \" \" + sizes[i];\n  };\n\n  const [previewIndex, setPreviewIndex] = useState(-1);\n  const pendingNavigateRef = useRef(null); // 'next' | null - Used to auto-navigate after loading more in preview mode\n\n  // ... (keep existing helper functions)\n\n  const handleUpdate = (updatedFile) => {\n    setImages(prev => prev.map(img => img.relPath === previewFile.relPath ? { ...img, ...updatedFile } : img));\n    setPreviewFile(updatedFile);\n    setPreviewTitle(updatedFile.filename);\n    setPreviewImage(getCacheBustedUrl(updatedFile));\n  };\n\n  const handlePreview = (file) => {\n    // Find index in current images list\n    const index = images.findIndex(img => img.relPath === file.relPath);\n    setPreviewIndex(index);\n    setPreviewImage(getCacheBustedUrl(file));\n    setPreviewVisible(true);\n    setPreviewTitle(file.filename);\n    setPreviewFile(file);\n\n    // Reset edit states\n    const ext = file.filename.includes(\".\")\n      ? file.filename.substring(file.filename.lastIndexOf(\".\"))\n      : \"\";\n    const base = ext ? file.filename.slice(0, -ext.length) : file.filename;\n    setRenameValue(base);\n    setIsEditingName(false);\n    setImageMeta(null);\n    setMetaLoading(true);\n\n    const currentDir =\n      file.relPath && file.relPath.includes(\"/\")\n        ? file.relPath.substring(0, file.relPath.lastIndexOf(\"/\"))\n        : \"\";\n    setDirValue(currentDir);\n    setIsEditingDir(false);\n\n    // Fetch meta\n    api\n      .get(`/images/meta/${encodePath(file.relPath)}`)\n      .then((res) => {\n        if (res.data && res.data.success) {\n          setImageMeta(res.data.data);\n        }\n      })\n      .catch(() => { })\n      .finally(() => setMetaLoading(false));\n  };\n\n  const showNext = () => {\n    if (previewIndex < images.length - 1) {\n      handlePreview(images[previewIndex + 1]);\n    } else if (hasMore && !loadingMore) {\n      // Reached the end of loaded images but more are available, trigger load more\n      setCurrentPage((p) => p + 1);\n      pendingNavigateRef.current = 'next';\n    }\n  };\n\n  const showPrev = () => {\n    if (previewIndex > 0) {\n      handlePreview(images[previewIndex - 1]);\n    }\n  };\n\n  // Handle auto-navigation to next image after loading more in preview mode\n  useEffect(() => {\n    if (pendingNavigateRef.current === 'next' && previewVisible && !loadingMore) {\n      // Images list has been updated and loading is complete, navigate to next\n      if (previewIndex < images.length - 1) {\n        handlePreview(images[previewIndex + 1]);\n      }\n      pendingNavigateRef.current = null;\n    }\n  }, [images.length, loadingMore]);\n\n  // Keyboard navigation\n  useEffect(() => {\n    const handleKeyDown = (e) => {\n      if (!previewVisible) return;\n      if (e.key === 'ArrowRight') showNext();\n      if (e.key === 'ArrowLeft') showPrev();\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [previewVisible, previewIndex, images]);\n\n  const handleDownload = (file) => {\n    const link = document.createElement(\"a\");\n    link.href = getCacheBustedUrl(file);\n    link.download = file.filename;\n    document.body.appendChild(link);\n    link.click();\n    document.body.removeChild(link);\n    message.success(\"开始下载\");\n  };\n\n  const copyToClipboard = (text) => {\n    if (navigator.clipboard && window.isSecureContext) {\n      navigator.clipboard\n        .writeText(text)\n        .then(() => message.success(\"链接已复制到剪贴板\"))\n        .catch(() => message.error(\"复制失败\"));\n      return;\n    }\n    const input = document.createElement(\"input\");\n    input.style.position = \"fixed\";\n    input.style.top = \"-10000px\";\n    input.style.zIndex = \"-999\";\n    document.body.appendChild(input);\n    input.value = text;\n    input.focus();\n    input.select();\n    try {\n      const ok = document.execCommand(\"copy\");\n      document.body.removeChild(input);\n      if (!ok) {\n        message.error(\"复制失败\");\n      } else {\n        message.success(\"链接已复制到剪贴板\");\n      }\n    } catch (e) {\n      document.body.removeChild(input);\n      message.error(\"当前浏览器不支持复制功能\");\n    }\n  };\n\n  // Helper to distribute items into columns\n  const getColumns = (items) => {\n    const columnsCount = isMobile ? 2 : screens.xl ? 5 : screens.lg ? 4 : screens.md ? 3 : 2;\n    const columns = Array.from({ length: columnsCount }, () => []);\n    items.forEach((item, index) => {\n      columns[index % columnsCount].push(item);\n    });\n    return columns;\n  };\n\n  const handlePasswordSubmit = () => {\n    if (!pendingDir) return;\n\n    // Store password in a temporary session-like way? \n    // User requested \"每次都需要让输入子密码\" (Every time need to enter password).\n    // So we should NOT store it in state persistently for auto-retry on subsequent navigations?\n    // Wait, if we don't store it, scrolling/pagination will fail because loadMore calls fetchImages which needs password.\n    // So we MUST store it at least for the current session while viewing this album.\n    // But if user navigates away and comes back, they should enter it again.\n    // Currently `albumPasswords` is state, so it persists as long as ImageGallery is mounted.\n    // If user switches dir via top menu, `dir` changes.\n    // If they switch back to locked dir, we check `albumPasswords[dir]`.\n    // To satisfy \"Every time need to enter password\", we should CLEAR the password when directory changes.\n\n    // We will implement clearing logic in the `dir` change effect.\n\n    // Store password\n    setAlbumPasswords(prev => ({\n      ...prev,\n      [pendingDir]: passwordInput\n    }));\n\n    // Close modal\n    setPasswordPromptVisible(false);\n\n    setLoading(true);\n    const params = {\n      page: 1,\n      pageSize: pageSize,\n      dir: pendingDir,\n      search: searchText\n    };\n    const headers = { \"x-album-password\": passwordInput };\n\n    api.get(\"/images\", { params, headers })\n      .then(res => {\n        if (res.data.success) {\n          setImages(res.data.data);\n          setPagination(res.data.pagination);\n          setHasMore(res.data.pagination.current < res.data.pagination.totalPages);\n        }\n      })\n      .catch(e => {\n        message.error(\"密码错误或访问失败\");\n        // Clear invalid password\n        setAlbumPasswords(prev => {\n          const next = { ...prev };\n          delete next[pendingDir];\n          return next;\n        });\n        setPasswordPromptVisible(true);\n      })\n      .finally(() => setLoading(false));\n  };\n\n  return (\n    <div\n      style={{ padding: isMobile ? \"12px\" : \"24px\", minHeight: \"100vh\" }}\n      onMouseDown={handleSelectionMouseDown}\n    >\n      {/* Drag Selection Box */}\n      {selectionBox?.isSelecting && (\n        <div\n          style={{\n            position: 'absolute',\n            left: Math.min(selectionBox.startX, selectionBox.currentX),\n            top: Math.min(selectionBox.startY, selectionBox.currentY),\n            width: Math.abs(selectionBox.currentX - selectionBox.startX),\n            height: Math.abs(selectionBox.currentY - selectionBox.startY),\n            border: `1px solid ${colorPrimary}`,\n            background: `${colorPrimary}33`, // 20% opacity\n            zIndex: 9999,\n            pointerEvents: 'none'\n          }}\n        />\n      )}\n\n      {/* Drag & Drop Overlay */}\n      {isDragOver && (\n        <div\n          style={{\n            position: 'fixed',\n            top: 0,\n            left: 0,\n            right: 0,\n            bottom: 0,\n            zIndex: 9999,\n            background: 'rgba(22, 119, 255, 0.15)',\n            backdropFilter: 'blur(4px)',\n            display: 'flex',\n            alignItems: 'center',\n            justifyContent: 'center',\n            border: `4px dashed ${colorPrimary}`,\n            pointerEvents: 'none', // Allow drops to pass through to window listener\n          }}\n        >\n          <div style={{\n            background: colorBgContainer,\n            padding: '40px 60px',\n            borderRadius: 24,\n            boxShadow: '0 8px 32px rgba(0,0,0,0.1)',\n            textAlign: 'center'\n          }}>\n            <CloudUploadOutlined style={{ fontSize: 64, color: colorPrimary, marginBottom: 16 }} />\n            <Title level={3} style={{ margin: 0 }}>释放以上传图片</Title>\n            <Text type=\"secondary\">支持多图上传</Text>\n          </div>\n        </div>\n      )}\n\n      {/* Upload Queue Overlay */}\n      {uploadQueue.length > 0 && (\n        <div style={{\n          position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,\n          backgroundColor: 'rgba(0,0,0,0.65)', backdropFilter: 'blur(4px)',\n          zIndex: 1005, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',\n          padding: '20px'\n        }}>\n          <div style={{\n            width: '100%', maxWidth: '600px',\n            background: isDarkMode ? '#1f1f1f' : '#fff',\n            borderRadius: '12px', padding: '24px',\n            boxShadow: '0 8px 32px rgba(0,0,0,0.3)',\n            maxHeight: '80vh', display: 'flex', flexDirection: 'column'\n          }}>\n            <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '16px', alignItems: 'center' }}>\n              <Title level={4} style={{ margin: 0, color: isDarkMode ? '#fff' : undefined }}>\n                正在上传 ({uploadQueue.filter(i => i.status === 'success').length}/{uploadQueue.length})\n              </Title>\n              <Button\n                type=\"text\"\n                icon={<CloseOutlined style={{ color: isDarkMode ? 'rgba(255,255,255,0.45)' : undefined }} />}\n                onClick={() => {\n                  setUploadQueue([]);\n                  setSessionUploadedFiles([]);\n                }}\n              />\n            </div>\n            <div style={{ overflowY: 'auto', flex: 1, paddingRight: '8px' }}>\n              {uploadQueue.map(item => (\n                <div key={item.uid} style={{ marginBottom: 12 }}>\n                  <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>\n                    <Text ellipsis style={{ maxWidth: '70%', color: isDarkMode ? '#fff' : undefined }}>{item.name}</Text>\n                    <Text type=\"secondary\" style={{ color: isDarkMode ? 'rgba(255,255,255,0.45)' : undefined }}>\n                      {item.status === 'error' ? '失败' : item.status === 'success' ? '完成' : `${item.progress}%`}\n                    </Text>\n                  </div>\n                  {/* antd Progress component is imported but we need to ensure correct props */}\n                  <div style={{ position: 'relative', height: 8, background: isDarkMode ? 'rgba(255,255,255,0.1)' : '#f5f5f5', borderRadius: 4, overflow: 'hidden' }}>\n                    <div style={{\n                      position: 'absolute', left: 0, top: 0, bottom: 0,\n                      width: `${item.progress}%`,\n                      background: item.status === 'error' ? '#ff4d4f' : item.status === 'success' ? '#52c41a' : '#1677ff',\n                      transition: 'width 0.3s ease'\n                    }} />\n                  </div>\n                  {item.errorMsg && <Text type=\"danger\" style={{ fontSize: 12 }}>{item.errorMsg}</Text>}\n                </div>\n              ))}\n            </div>\n            {!uploading && (\n              <>\n                {sessionUploadedFiles.length > 0 && <UploadResult />}\n                <div style={{ textAlign: 'center', marginTop: '16px' }}>\n                  <Button type=\"primary\" onClick={() => {\n                    setUploadQueue([]);\n                    setSessionUploadedFiles([]);\n                  }}>\n                    关闭\n                  </Button>\n                </div>\n              </>\n            )}\n          </div>\n        </div>\n      )}\n\n      {/* Floating Capsule Header */}\n      <div\n        style={{\n          display: \"flex\",\n          justifyContent: \"center\",\n          alignItems: \"center\",\n          marginBottom: 32,\n          position: \"sticky\",\n          top: 20,\n          zIndex: 100,\n          pointerEvents: \"none\", // Allow clicks to pass through the container area\n        }}\n      >\n        <div\n          style={{\n            pointerEvents: \"auto\",\n            background: capsuleStyle.background,\n            backdropFilter: \"blur(20px)\",\n            WebkitBackdropFilter: \"blur(20px)\",\n            padding: \"6px\",\n            borderRadius: \"100px\",\n            border: capsuleStyle.border,\n            boxShadow: capsuleStyle.boxShadow,\n            display: \"flex\",\n            alignItems: \"center\",\n            gap: 8,\n            maxWidth: \"90vw\",\n            width: \"auto\",\n            transition: \"all 0.3s ease\",\n          }}\n        >\n          <div\n            style={{\n              display: \"flex\",\n              alignItems: \"center\",\n              gap: 8,\n              paddingRight: 2,\n              paddingLeft: 4,\n            }}\n          >\n            <img\n              src=\"/favicon.svg\"\n              alt=\"云图\"\n              style={{\n                width: 24,\n                height: 24,\n                objectFit: \"contain\",\n                filter: isDarkMode ? \"brightness(1.2)\" : \"none\" // Slight adjust for dark mode if needed\n              }}\n            />\n          </div>\n          <div style={{ width: 180, transition: \"width 0.3s ease\" }}>\n            <DirectorySelector\n              value={dir}\n              onChange={setDir}\n              placeholder=\"所有目录\"\n              style={{ width: \"100%\" }}\n              allowInput={true}\n              api={api}\n              bordered={false}\n              size=\"middle\"\n              refreshKey={`${directoryRefreshKey}-${isAuthenticated}`}\n            />\n          </div>\n          <div\n            style={{\n              width: 1,\n              height: 20,\n              background: capsuleStyle.dividerColor,\n            }}\n          />\n          <div style={{ width: 200, transition: \"width 0.3s ease\" }}>\n            <Input\n              placeholder={magicSearch ? \"描述图片内容...\" : \"拖拽图片到页面即可上传...\"}\n              prefix={\n                magicSearchAvailable ? (\n                  <div\n                    onClick={() => {\n                      setMagicSearch(!magicSearch);\n                      // If clearing, maybe trigger refresh?\n                      // Let existing effects handle it (searchText dependency)\n                    }}\n                    style={{ cursor: 'pointer', marginRight: 4, display: 'flex' }}\n                    title=\"Magic Search\"\n                  >\n                    <MagicIcon active={magicSearch} />\n                  </div>\n                ) : (\n                  <SearchOutlined style={{ color: capsuleStyle.iconColor }} />\n                )\n              }\n              bordered={false}\n              value={searchText}\n              onChange={(e) => setSearchText(e.target.value)}\n              style={{ background: \"transparent\", color: colorText }}\n            />\n          </div>\n          <div\n            style={{\n              width: 1,\n              height: 20,\n              background: capsuleStyle.dividerColor,\n            }}\n          />\n          <Popover\n            open={menuOpen}\n            onOpenChange={setMenuOpen}\n            content={\n              <div style={{ padding: 4 }}>\n                <Button\n                  type=\"text\"\n                  icon={<FolderOutlined />}\n                  onClick={() => {\n                    setMenuOpen(false);\n                    // Defer modal opening to let popover close smoothly\n                    setTimeout(() => setAlbumManagerVisible(true), 300);\n                  }}\n                  style={{\n                    width: \"100%\",\n                    textAlign: \"left\",\n                    display: \"flex\",\n                    alignItems: \"center\",\n                    height: 40,\n                    fontSize: 14\n                  }}\n                >\n                  相册管理\n                </Button>\n                <Button\n                  type=\"text\"\n                  icon={<AreaChartOutlined />}\n                  onClick={() => {\n                    setMenuOpen(false);\n                    window.open(\"/traffic\", \"_blank\");\n                  }}\n                  style={{\n                    width: \"100%\",\n                    textAlign: \"left\",\n                    display: \"flex\",\n                    alignItems: \"center\",\n                    height: 40,\n                    fontSize: 14\n                  }}\n                >\n                  流量看板\n                </Button>\n                <Button\n                  type=\"text\"\n                  icon={<CodeOutlined />}\n                  onClick={() => {\n                    setMenuOpen(false);\n                    setSvgToolVisible(true);\n                  }}\n                  style={{\n                    width: \"100%\",\n                    textAlign: \"left\",\n                    display: \"flex\",\n                    alignItems: \"center\",\n                    height: 40,\n                    fontSize: 14\n                  }}\n                >\n                  SVG 工具\n                </Button>\n                <Button\n                  type=\"text\"\n                  icon={<ApiOutlined />}\n                  onClick={() => {\n                    setMenuOpen(false);\n                    window.open(\"/opendocs\", \"_blank\");\n                  }}\n                  style={{\n                    width: \"100%\",\n                    textAlign: \"left\",\n                    display: \"flex\",\n                    alignItems: \"center\",\n                    height: 40,\n                    fontSize: 14\n                  }}\n                >\n                  开放接口\n                </Button>\n                {/* Future menu items can be added here */}\n              </div>\n            }\n            trigger=\"hover\"\n            placement=\"bottomLeft\"\n            arrow={false}\n            overlayInnerStyle={{ padding: 0, borderRadius: 12, overflow: \"hidden\" }}\n          >\n            <div\n              style={{\n                padding: \"0 12px\",\n                cursor: \"pointer\",\n                display: \"flex\",\n                alignItems: \"center\",\n                height: \"100%\",\n                transition: \"opacity 0.2s\",\n              }}\n              onMouseEnter={(e) => e.currentTarget.style.opacity = 0.7}\n              onMouseLeave={(e) => e.currentTarget.style.opacity = 1}\n            >\n              <MenuOutlined style={{ color: capsuleStyle.iconColor, fontSize: 18 }} />\n            </div>\n          </Popover>\n        </div>\n      </div>\n\n      {loading ? (\n        <div style={{ textAlign: \"center\", padding: \"100px 0\" }}>\n          <Spin size=\"large\" />\n        </div>\n      ) : images.length === 0 ? (\n        <Empty description=\"暂无图片\" style={{ marginTop: 100 }} />\n      ) : (\n        <>\n          {groups.map((group) => (\n            <div key={group.date} style={{ marginBottom: 24 }}>\n              <div\n                style={{\n                  marginBottom: 16,\n                  paddingLeft: 8,\n                  opacity: 0.8,\n                  fontWeight: 600,\n                  fontSize: \"13px\",\n                  letterSpacing: \"0.5px\",\n                  textTransform: \"uppercase\",\n                  color: colorTextSecondary, // Applied theme color\n                }}\n              >\n                {group.date}\n              </div>\n\n              {/* Masonry Layout - with batch selection support */}\n              <Masonry\n                columns={\n                  isMobile ? 2 : screens.xl ? 5 : screens.lg ? 4 : screens.md ? 3 : 2\n                }\n                gutter={8}\n                items={group.items.map((imgItem, index) => ({\n                  key: imgItem.relPath || `item-${group.date}-${index}`,\n                  data: imgItem,\n                }))}\n                itemRender={({ data: imgItem }) => (\n                  <ImageItem\n                    image={imgItem}\n                    hoverKey={hoverKey}\n                    setHoverKey={setHoverKey}\n                    handlePreview={handlePreview}\n                    formatFileSize={formatFileSize}\n                    isMobile={isMobile}\n                    handleDownload={handleDownload}\n                    onCopyClick={handleCopyClick}\n                    handleDelete={handleDelete}\n                    handleEdit={handleEdit}\n                    hoverLocation={hoverLocation}\n                    isBatchMode={isBatchMode}\n                    isSelected={selectedItems.has(imgItem.relPath)}\n                    onToggleSelect={(id) => {\n                      const newSet = new Set(selectedItems);\n                      if (newSet.has(id)) newSet.delete(id);\n                      else newSet.add(id);\n                      onSelectionChange(newSet);\n                    }}\n                    registerRef={registerRef}\n                    thumbnailWidth={thumbnailWidth}\n                  />\n                )}\n              />\n\n            </div>\n          ))}\n          <div ref={loadMoreRef} style={{ height: 20 }} />\n          {loadingMore && (\n            <div style={{ textAlign: \"center\", padding: 20 }}>\n              <Spin />\n            </div>\n          )}\n        </>\n      )}\n\n      <ImageDetailModal\n        visible={previewVisible}\n        onCancel={() => {\n          setPreviewVisible(false);\n          setIsEditingName(false);\n        }}\n        file={previewFile}\n        api={api}\n        onNext={showNext}\n        onPrev={showPrev}\n        hasNext={previewIndex < images.length - 1 || hasMore}\n        hasPrev={previewIndex > 0}\n        onDelete={(relPath) => {\n          handleDelete(relPath);\n          setPreviewVisible(false);\n        }}\n        onUpdate={handleUpdate}\n      />\n      <ImageEditModal\n        open={editorVisible}\n        file={editorFile}\n        editorSaving={editorSaving}\n        onCancel={() => {\n          setEditorVisible(false);\n          setEditorFile(null);\n        }}\n        onClose={() => {\n          setEditorVisible(false);\n          setEditorFile(null);\n        }}\n        onOverwriteSave={handleOverwriteSave}\n        onSaveAs={handleSaveAs}\n        getEditorDefaults={getEditorDefaults}\n        getCurrentImgDataFnRef={editorGetCurrentImgDataRef}\n        theme={isDarkMode ? \"dark\" : \"light\"}\n      />\n      <SvgToolModal visible={svgToolVisible} onClose={() => setSvgToolVisible(false)} api={api} />\n      <AlbumManager\n        visible={albumManagerVisible}\n        onClose={() => {\n          setAlbumManagerVisible(false);\n          setDirectoryRefreshKey(prev => prev + 1);\n        }}\n        api={api}\n        onSelectAlbum={(path) => setDir(path)}\n      />\n\n      <CopyLinksModal />\n\n      {/* Album Password Prompt Modal */}\n      <Modal\n        open={passwordPromptVisible}\n        title=\"请输入相册密码\"\n        onOk={handlePasswordSubmit}\n        onCancel={() => {\n          setPasswordPromptVisible(false);\n          setDir(\"\"); // Go back to root or previous? Root is safer.\n        }}\n        okText=\"确认\"\n        cancelText=\"取消\"\n        centered\n        closable={false}\n        maskClosable={false}\n        width={360}\n      >\n        <div style={{ marginBottom: 20 }}>该相册受密码保护，请输入密码以访问。</div>\n        <Input.Password\n          size=\"large\"\n          value={passwordInput}\n          onChange={e => setPasswordInput(e.target.value)}\n          placeholder=\"输入密码\"\n          onPressEnter={handlePasswordSubmit}\n          autoFocus\n          style={{ height: 48, fontSize: 16 }}\n        />\n      </Modal>\n\n    </div>\n  );\n};\n\nexport default ImageGallery;\n"
  },
  {
    "path": "client/src/components/Logo.js",
    "content": "import React from \"react\";\n\nconst Logo = ({ size = 24, style = {} }) => {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 64 64\"\n      width={size}\n      height={size}\n      style={style}\n    >\n      {/* 背景圆形 */}\n      <circle\n        cx=\"32\"\n        cy=\"32\"\n        r=\"30\"\n        fill=\"#1890ff\"\n        stroke=\"#096dd9\"\n        strokeWidth=\"2\"\n      />\n\n      {/* 云朵 */}\n      <path\n        d=\"M20 28c0-4.4 3.6-8 8-8 1.2 0 2.4 0.3 3.4 0.8C33.2 18.4 36.8 16 41 16c5.5 0 10 4.5 10 10 0 0.6-0.1 1.2-0.2 1.8C52.8 29.2 56 32.8 56 37c0 4.4-3.6 8-8 8H20c-4.4 0-8-3.6-8-8s3.6-8 8-8z\"\n        fill=\"white\"\n        opacity=\"0.9\"\n      />\n\n      {/* 图片图标 */}\n      <rect\n        x=\"24\"\n        y=\"24\"\n        width=\"16\"\n        height=\"12\"\n        rx=\"2\"\n        fill=\"white\"\n        stroke=\"#1890ff\"\n        strokeWidth=\"1.5\"\n      />\n      <circle cx=\"28\" cy=\"28\" r=\"1.5\" fill=\"#1890ff\" />\n      <path d=\"M24 34l3-3 2 2 3-3 4 4v2H24v-2z\" fill=\"#1890ff\" />\n\n      {/* 装饰性元素 */}\n      <circle cx=\"48\" cy=\"20\" r=\"2\" fill=\"#52c41a\" opacity=\"0.8\" />\n      <circle cx=\"16\" cy=\"44\" r=\"1.5\" fill=\"#faad14\" opacity=\"0.8\" />\n    </svg>\n  );\n};\n\nexport default Logo;\n"
  },
  {
    "path": "client/src/components/LogoWithText.js",
    "content": "import React from \"react\";\nimport { Typography } from \"antd\";\n\nconst { Title } = Typography;\n\nconst LogoWithText = ({\n  size = 24,\n  titleLevel = 3,\n  showTitle = true,\n  style = {},\n  titleStyle = {},\n}) => {\n  return (\n    <span\n      style={{\n        display: \"inline-flex\",\n        alignItems: \"center\",\n        gap: 8,\n        ...style,\n      }}\n    >\n      <img\n        src={`${process.env.PUBLIC_URL}/favicon.svg`}\n        width={size}\n        height={size}\n        style={{ display: \"block\" }}\n        alt=\"logo\"\n      />\n      {showTitle && (\n        <Title\n          level={titleLevel}\n          style={{ margin: 0, color: \"#1890ff\", lineHeight: 1, ...titleStyle }}\n        >\n          云图\n        </Title>\n      )}\n    </span>\n  );\n};\n\nexport default LogoWithText;\n"
  },
  {
    "path": "client/src/components/MapPage.js",
    "content": "import React, { useState, useEffect, useCallback } from 'react';\nimport { MapContainer, TileLayer, useMap } from 'react-leaflet';\nimport MarkerClusterGroup from 'react-leaflet-cluster';\nimport { Spin, message, Button, Tooltip } from 'antd';\nimport { ArrowLeftOutlined, EnvironmentOutlined } from '@ant-design/icons';\nimport L from 'leaflet';\nimport 'leaflet/dist/leaflet.css';\nimport 'leaflet.markercluster/dist/MarkerCluster.css';\nimport coordtransform from 'coordtransform';\nimport api from '../utils/api';\nimport ImageDetailModal from './ImageDetailModal';\n\n// Fix Leaflet marker icon issue\ndelete L.Icon.Default.prototype._getIconUrl;\nL.Icon.Default.mergeOptions({\n  iconRetinaUrl: require('leaflet/dist/images/marker-icon-2x.png'),\n  iconUrl: require('leaflet/dist/images/marker-icon.png'),\n  shadowUrl: require('leaflet/dist/images/marker-shadow.png'),\n});\n\nconst createPhotoIcon = (thumbUrl) => {\n  return L.divIcon({\n    className: 'custom-photo-marker-container', // Use container class for positioning\n    html: `\n      <div class=\"glass-marker\">\n        <div class=\"marker-image\" style=\"background-image: url('${thumbUrl}')\"></div>\n        <div class=\"marker-arrow\"></div>\n      </div>\n    `,\n    iconSize: [48, 56], // Slightly larger for better touch targets\n    iconAnchor: [24, 56],\n    popupAnchor: [0, -56],\n  });\n};\n\n// Custom Cluster Icon\nconst createClusterCustomIcon = function (cluster) {\n  const count = cluster.getChildCount();\n  let size = 'small';\n  if (count > 10) size = 'medium';\n  if (count > 50) size = 'large';\n\n  // Get first child's image to use as background (optional, but cool)\n  // const children = cluster.getAllChildMarkers();\n  // const firstChildHtml = children[0].options.icon.options.html;\n  // const bgMatch = firstChildHtml.match(/url\\('([^']+)'\\)/);\n  // const bgUrl = bgMatch ? bgMatch[1] : '';\n\n  return L.divIcon({\n    html: `\n      <div class=\"glass-cluster glass-cluster-${size}\">\n        <span>${count}</span>\n      </div>\n    `,\n    className: 'custom-cluster-icon',\n    iconSize: L.point(40, 40, true),\n  });\n};\n\nconst getDisplayCoordinates = (lat, lng) => {\n  // Always convert WGS-84 to GCJ-02 for AutoNavi\n  const [lngGcj, latGcj] = coordtransform.wgs84togcj02(lng, lat);\n  return [latGcj, lngGcj];\n};\n\nconst MarkerCluster = ({ markers, onMarkerClick }) => {\n  const map = useMap();\n\n  useEffect(() => {\n    if (!map || !markers) return;\n\n    const markerClusterGroup = L.markerClusterGroup({\n      chunkedLoading: true,\n      iconCreateFunction: createClusterCustomIcon,\n      maxClusterRadius: 60,\n      spiderfyOnMaxZoom: true,\n      showCoverageOnHover: false\n    });\n\n    const leafletMarkers = markers.map((marker, idx) => {\n      if (!marker.lat || !marker.lng) return null;\n\n      const latLng = getDisplayCoordinates(marker.lat, marker.lng);\n      const leafletMarker = L.marker(latLng, {\n        icon: createPhotoIcon(marker.thumbUrl)\n      });\n\n      leafletMarker.on('click', () => {\n        onMarkerClick(idx);\n      });\n\n      return leafletMarker;\n    }).filter(Boolean);\n\n    markerClusterGroup.addLayers(leafletMarkers);\n    map.addLayer(markerClusterGroup);\n\n    return () => {\n      map.removeLayer(markerClusterGroup);\n    };\n  }, [map, markers, onMarkerClick]);\n\n  return null;\n};\n\nfunction MapPage() {\n  const [state, setState] = useState({\n    loading: true,\n    markers: [],\n    error: null\n  });\n\n  const [modalVisible, setModalVisible] = useState(false);\n  const [selectedIndex, setSelectedIndex] = useState(-1);\n\n  useEffect(() => {\n    const fetchMapData = async () => {\n      try {\n        const res = await api.get('/map-data');\n        if (res.data.success) {\n          // Filter out invalid coordinates just in case\n          const markersData = Array.isArray(res.data.data) ? res.data.data : [];\n          const validMarkers = markersData.filter(m => m.lat && m.lng);\n          setState({\n            loading: false,\n            markers: validMarkers,\n            error: null\n          });\n        } else {\n          throw new Error(res.data.error);\n        }\n      } catch (err) {\n        setState(prev => ({ ...prev, loading: false, error: err.message || \"加载失败\" }));\n        message.error('加载地图数据失败: ' + (err.message || \"未知错误\"));\n      }\n    };\n\n    fetchMapData();\n  }, []);\n\n  const handleBack = () => {\n    window.location.href = '/';\n  };\n\n  const handleMarkerClick = useCallback((index) => {\n    setSelectedIndex(index);\n    setModalVisible(true);\n  }, []);\n\n  const handleNext = () => {\n    if (selectedIndex < state.markers.length - 1) {\n      setSelectedIndex(prev => prev + 1);\n    }\n  };\n\n  const handlePrev = () => {\n    if (selectedIndex > 0) {\n      setSelectedIndex(prev => prev - 1);\n    }\n  };\n\n  const handleDelete = async (relPath) => {\n    try {\n      await api.delete(`/images/${encodeURIComponent(relPath)}`);\n      message.success(\"删除成功\");\n      setState(prev => ({\n        ...prev,\n        markers: prev.markers.filter(m => m.relPath !== relPath)\n      }));\n      setModalVisible(false); // Close modal after delete\n    } catch (e) {\n      message.error(\"删除失败\");\n    }\n  };\n\n  const handleUpdate = (updatedFile) => {\n    setState(prev => ({\n      ...prev,\n      markers: prev.markers.map(m =>\n        m.relPath === updatedFile.relPath || m.relPath === updatedFile.oldRelPath\n          ? { ...m, ...updatedFile, date: updatedFile.uploadTime, thumbUrl: updatedFile.url + '?w=200' } // Ensure essential props\n          : m\n      )\n    }));\n  };\n\n  const marker = selectedIndex >= 0 ? state.markers[selectedIndex] : null;\n\n  const currentFile = marker ? {\n    ...marker,\n    // Use existing URL if it's absolute (Mock mode), otherwise construct API URL\n    url: marker.url && (marker.url.startsWith('http') || marker.url.startsWith('blob'))\n      ? marker.url\n      : `/api/images/${marker.relPath.split('/').map(encodeURIComponent).join('/')}`,\n    uploadTime: marker.date || marker.uploadTime,\n    size: marker.size || 0\n  } : null;\n\n  // CSS Styles for Glassmorphism\n  const styles = `\n    .glass-marker {\n        width: 48px;\n        height: 48px;\n        background: rgba(255, 255, 255, 0.6);\n        backdrop-filter: blur(12px);\n        -webkit-backdrop-filter: blur(12px);\n        border: 1px solid rgba(0, 0, 0, 0.08);\n        border-radius: 8px;\n        padding: 3px;\n        box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);\n        position: relative;\n        transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);\n        display: flex;\n        justify-content: center;\n        align-items: center;\n    }\n    \n    .custom-photo-marker-container:hover .glass-marker {\n        transform: scale(1.15) translateY(-4px);\n        z-index: 1000;\n        box-shadow: 0 12px 24px rgba(0, 0, 0, 0.2);\n        border-color: rgba(0, 0, 0, 0.15);\n        background: rgba(255, 255, 255, 0.85);\n    }\n\n    .marker-image {\n        width: 100%;\n        height: 100%;\n        border-radius: 5px;\n        background-size: cover;\n        background-position: center;\n        background-color: #f0f0f0;\n        box-shadow: inset 0 0 0 1px rgba(0,0,0,0.05);\n    }\n\n    .marker-arrow {\n        position: absolute;\n        bottom: -6px;\n        left: 50%;\n        transform: translateX(-50%);\n        width: 0; \n        height: 0; \n        border-left: 6px solid transparent;\n        border-right: 6px solid transparent;\n        border-top: 6px solid rgba(255, 255, 255, 0.6);\n        filter: drop-shadow(0 2px 2px rgba(0,0,0,0.05));\n    }\n\n    /* Cluster Styles */\n    .glass-cluster {\n        width: 40px;\n        height: 40px;\n        border-radius: 50%;\n        background: rgba(20, 20, 20, 0.75);\n        backdrop-filter: blur(12px);\n        -webkit-backdrop-filter: blur(12px);\n        border: 1px solid rgba(255, 255, 255, 0.15);\n        display: flex;\n        justify-content: center;\n        align-items: center;\n        font-weight: 600;\n        color: #fff;\n        box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25);\n        animation: pulse-light 3s infinite;\n    }\n    \n    .glass-cluster span {\n        font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial;\n        font-size: 14px;\n    }\n\n    @keyframes pulse-light {\n        0% { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.2); }\n        70% { box-shadow: 0 0 0 8px rgba(0, 0, 0, 0); }\n        100% { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); }\n    }\n    \n    /* Controls Styling */\n    .map-control-btn {\n        background: rgba(20, 20, 20, 0.75) !important;\n        backdrop-filter: blur(8px) !important;\n        border: 1px solid rgba(255, 255, 255, 0.15) !important;\n        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important;\n        color: #fff !important;\n    }\n    .map-control-btn:hover {\n        background: rgba(40, 40, 40, 0.85) !important;\n        border-color: rgba(255, 255, 255, 0.3) !important;\n    }\n    .leaflet-control-zoom a {\n        background: rgba(20, 20, 20, 0.75) !important;\n        backdrop-filter: blur(4px) !important;\n        color: #fff !important;\n        border-color: rgba(255, 255, 255, 0.15) !important;\n    }\n  `;\n\n  if (state.loading) {\n    return <div style={{ height: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center' }}><Spin size=\"large\" tip=\"正在加载地图数据...\" /></div>;\n  }\n\n  if (state.error) {\n    return (\n      <div style={{ padding: 20, textAlign: 'center', paddingTop: 100 }}>\n        <h3>Error: {state.error}</h3>\n        <Button onClick={handleBack}>返回首页</Button>\n      </div>\n    );\n  }\n\n  const tileLayerProps = {\n    url: \"https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=7&x={x}&y={y}&z={z}\",\n    attribution: '&copy; 高德地图',\n    subdomains: ['1', '2', '3', '4'],\n    minZoom: 3,\n    maxZoom: 18\n  };\n\n  return (\n    <div style={{ height: '100vh', width: '100%', position: 'relative' }}>\n      <style>{styles}</style>\n\n      {/* Top Left Controls */}\n      <div style={{ position: 'absolute', top: 20, left: 20, zIndex: 1000, display: 'flex', gap: 12 }}>\n        <Tooltip title=\"返回列表\" placement=\"right\">\n          <Button\n            icon={<ArrowLeftOutlined />}\n            className=\"map-control-btn\"\n            onClick={handleBack}\n            size=\"large\"\n            shape=\"circle\"\n          />\n        </Tooltip>\n        <div style={{\n          background: 'rgba(20, 20, 20, 0.75)',\n          backdropFilter: 'blur(8px)',\n          padding: '0 16px',\n          borderRadius: 20,\n          display: 'flex',\n          alignItems: 'center',\n          border: '1px solid rgba(255, 255, 255, 0.15)',\n          boxShadow: '0 2px 8px rgba(0, 0, 0, 0.2)',\n          color: '#fff'\n        }}>\n          <EnvironmentOutlined style={{ marginRight: 8 }} />\n          <span style={{ fontWeight: 500 }}>{state.markers.length} 张照片</span>\n        </div>\n      </div>\n\n      <MapContainer\n        center={[35, 108]}\n        zoom={5}\n        style={{ height: '100%', width: '100%' }}\n        zoomControl={false}\n      >\n        <TileLayer\n          {...tileLayerProps}\n        />\n        <MarkerCluster markers={state.markers} onMarkerClick={handleMarkerClick} />\n      </MapContainer>\n\n      <ImageDetailModal\n        visible={modalVisible}\n        onCancel={() => setModalVisible(false)}\n        file={currentFile}\n        api={api}\n        onNext={handleNext}\n        onPrev={handlePrev}\n        hasNext={selectedIndex < state.markers.length - 1}\n        hasPrev={selectedIndex > 0}\n        onDelete={handleDelete}\n        onUpdate={handleUpdate}\n      />\n    </div>\n  );\n}\n\nexport default MapPage;\n"
  },
  {
    "path": "client/src/components/PasswordOverlay.js",
    "content": "import React, { useState } from \"react\";\nimport { Form, Input, Button, message, Typography } from \"antd\";\nimport { LockOutlined, ArrowRightOutlined } from \"@ant-design/icons\";\nimport { setPassword, clearPassword } from \"../utils/secureStorage\";\nimport ScrollingBackground from \"./ScrollingBackground\";\nimport api from \"../utils/api\";\n\nconst { Text, Title } = Typography;\n\nconst PasswordOverlay = ({ onLoginSuccess, isMobile }) => {\n  const [loading, setLoading] = useState(false);\n\n  const onFinish = async (values) => {\n    setLoading(true);\n    try {\n      setPassword(values.password);\n      await api.post(\"/auth/login\", { password: values.password });\n      onLoginSuccess();\n    } catch (error) {\n      console.error(\"验证失败:\", error);\n      const errorMsg = error.response?.data?.error || \"验证失败\";\n      message.error(errorMsg);\n      clearPassword();\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  return (\n    <div\n      style={{\n        position: \"fixed\",\n        top: 0,\n        left: 0,\n        right: 0,\n        bottom: 0,\n        zIndex: 1000,\n        display: \"flex\",\n        justifyContent: \"center\",\n        alignItems: \"center\",\n        background: \"#000\", // Dark base\n        overflow: \"hidden\",\n      }}\n    >\n      {/* Dynamic Background */}\n      <ScrollingBackground />\n\n      {/* Glassmorphism Overlay */}\n      <div\n        style={{\n          position: \"absolute\",\n          top: 0,\n          left: 0,\n          right: 0,\n          bottom: 0,\n          background: \"rgba(0, 0, 0, 0.4)\", // Darkening overlay\n          backdropFilter: \"blur(10px)\", // Global blur for the background\n          WebkitBackdropFilter: \"blur(10px)\",\n          zIndex: 1,\n        }}\n      />\n\n      {/* Login Card */}\n      <div\n        style={{\n          width: isMobile ? \"85%\" : \"380px\",\n          padding: \"40px\",\n          borderRadius: \"24px\",\n          background: \"rgba(255, 255, 255, 0.1)\",\n          boxShadow: \"0 20px 50px rgba(0,0,0,0.5)\",\n          backdropFilter: \"blur(20px)\",\n          WebkitBackdropFilter: \"blur(20px)\",\n          border: \"1px solid rgba(255, 255, 255, 0.15)\",\n          textAlign: \"center\",\n          zIndex: 2,\n          position: \"relative\",\n          transform: \"translateY(-20px)\",\n        }}\n      >\n        <div\n          style={{\n            width: 64,\n            height: 64,\n            borderRadius: \"50%\",\n            background: \"rgba(255,255,255,0.1)\",\n            display: \"flex\",\n            alignItems: \"center\",\n            justifyContent: \"center\",\n            margin: \"0 auto 24px\",\n            border: \"1px solid rgba(255,255,255,0.1)\",\n          }}\n        >\n          <LockOutlined\n            style={{\n              fontSize: \"28px\",\n              color: \"#fff\",\n            }}\n          />\n        </div>\n\n        <Title level={3} style={{ color: \"#fff\", marginBottom: 8, marginTop: 0 }}>\n          云图 - 云端一隅，拾光深藏\n        </Title>\n        <Text\n          style={{\n            display: \"block\",\n            marginBottom: \"32px\",\n            fontSize: \"14px\",\n            color: \"rgba(255,255,255,0.6)\",\n          }}\n        >\n          请输入访问密码以继续\n        </Text>\n\n        <Form name=\"password-protect\" onFinish={onFinish} autoComplete=\"off\">\n          <Form.Item\n            name=\"password\"\n            rules={[{ required: true, message: \"\" }]}\n            style={{ marginBottom: \"24px\" }}\n          >\n            <Input.Password\n              placeholder=\"密码\"\n              bordered={false}\n              size=\"large\"\n              style={{\n                background: \"rgba(0,0,0,0.3)\",\n                borderRadius: \"12px\",\n                padding: \"10px 16px\",\n                color: \"#fff\",\n                border: \"1px solid rgba(255,255,255,0.1)\",\n                textAlign: \"center\",\n                fontSize: \"16px\",\n                letterSpacing: \"2px\",\n              }}\n              className=\"password-input\"\n            />\n          </Form.Item>\n          <Form.Item style={{ marginBottom: 0 }}>\n            <Button\n              type=\"primary\"\n              htmlType=\"submit\"\n              loading={loading}\n              shape=\"round\"\n              size=\"large\"\n              icon={<ArrowRightOutlined />}\n              style={{\n                width: \"100%\",\n                height: \"48px\",\n                background: \"linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%)\",\n                border: \"none\",\n                color: \"#333\",\n                fontWeight: \"bold\",\n                fontSize: \"16px\",\n                boxShadow: \"0 4px 15px rgba(255,255,255,0.2)\",\n              }}\n            >\n              解锁进入\n            </Button>\n          </Form.Item>\n        </Form>\n      </div>\n\n      <style>{`\n        .password-input input {\n            color: #fff !important;\n            text-align: center;\n        }\n        .password-input input::placeholder {\n            color: rgba(255,255,255,0.3);\n            letter-spacing: normal;\n        }\n      `}</style>\n    </div>\n  );\n};\n\nexport default PasswordOverlay;\n"
  },
  {
    "path": "client/src/components/ScrollingBackground.js",
    "content": "import React, { useEffect, useState } from \"react\";\nimport api from \"../utils/api\";\n\nconst ScrollingBackground = ({ usePicsum = false }) => {\n  const [images, setImages] = useState([]);\n\n  useEffect(() => {\n    // Fetch some random images to display in the background\n    const fetchBackgroundImages = async () => {\n      if (usePicsum) {\n        setImages(Array.from({ length: 32 }).map((_, i) => ({\n          url: `https://picsum.photos/seed/${i + 500}/300/450`,\n          key: i\n        })));\n        return;\n      }\n\n      try {\n        // Try to fetch from our own API first to show actual content\n        const res = await api.get(\"/images\", { params: { page: 1, pageSize: 20 } });\n        if (res.data && res.data.success && res.data.data.length > 0) {\n          setImages(res.data.data);\n        } else {\n          // Fallback to placeholder if no images or empty\n          setImages(Array.from({ length: 24 }).map((_, i) => ({\n            url: `https://picsum.photos/seed/${i + 200}/300/450`,\n            key: i\n          })));\n        }\n      } catch (e) {\n        setImages(Array.from({ length: 32 }).map((_, i) => ({\n          url: `https://picsum.photos/seed/${i + 100}/300/450`,\n          key: i\n        })));\n      }\n    };\n    fetchBackgroundImages();\n  }, [usePicsum]);\n\n  // Prepare columns for masonry-like scroll\n  const columns = [[], [], [], [], []];\n  images.forEach((img, i) => {\n    columns[i % 5].push(img);\n  });\n\n  return (\n    <div\n      style={{\n        position: \"absolute\",\n        top: \"-20%\", // Extend beyond viewport to cover rotation gaps\n        left: \"-20%\",\n        width: \"140%\",\n        height: \"140%\",\n        zIndex: 0,\n        overflow: \"hidden\",\n        display: \"flex\",\n        gap: \"24px\",\n        transform: \"rotate(-8deg)\", // Artistic tilt\n        opacity: 0.5,\n        pointerEvents: \"none\", // Ensure clicks pass through\n      }}\n    >\n      {columns.map((col, i) => (\n        <div\n          key={i}\n          style={{\n            flex: 1,\n            position: \"relative\",\n            height: \"200%\", // Double height for scrolling container\n            display: \"flex\",\n            flexDirection: \"column\",\n            gap: \"24px\",\n            // Use transform3d for hardware acceleration\n            transform: \"translate3d(0, 0, 0)\",\n            willChange: \"transform\",\n            animation: `scrollColumn-${i % 2 === 0 ? 'up' : 'down'} ${45 + i * 8}s linear infinite`,\n          }}\n        >\n          {/* \n             Render duplicated content 3 times to ensure no gaps during the infinite scroll loop.\n             We need enough content to cover the viewport height plus the scroll distance.\n          */}\n          {[...col, ...col, ...col, ...col].map((img, idx) => (\n            <div\n              key={`${i}-${idx}`}\n              style={{\n                width: \"100%\",\n                borderRadius: \"16px\",\n                overflow: \"hidden\",\n                boxShadow: \"0 8px 20px rgba(0,0,0,0.15)\",\n                // Fixed height to ensure stability during loading\n                minHeight: \"200px\",\n                background: \"rgba(255,255,255,0.05)\",\n              }}\n            >\n              <img\n                src={img.url}\n                alt=\"\"\n                style={{\n                  width: \"100%\",\n                  height: \"auto\",\n                  display: \"block\",\n                  objectFit: \"cover\",\n                  // Fade in effect could be added here\n                }}\n                loading=\"lazy\"\n              />\n            </div>\n          ))}\n        </div>\n      ))}\n      <style>\n        {`\n          @keyframes scrollColumn-up {\n            0% { transform: translate3d(0, 0, 0); }\n            100% { transform: translate3d(0, -50%, 0); } /* Move half way (since we have 4x content, 50% is 2x content, enough for loop) */\n          }\n          @keyframes scrollColumn-down {\n            0% { transform: translate3d(0, -50%, 0); }\n            100% { transform: translate3d(0, 0, 0); }\n          }\n        `}\n      </style>\n    </div>\n  );\n};\n\nexport default ScrollingBackground;\n"
  },
  {
    "path": "client/src/components/ShareView.js",
    "content": "import React, { useState, useEffect, useMemo, useRef } from \"react\";\nimport { Masonry, Spin, Typography, Empty, message, theme, Modal, Button, Grid, Space } from \"antd\";\nimport {\n    EnvironmentOutlined, DownloadOutlined, LeftOutlined,\n    RightOutlined, CameraOutlined,\n    SunOutlined, MoonOutlined\n} from \"@ant-design/icons\";\nimport dayjs from \"dayjs\";\nimport { thumbHashToDataURL } from \"thumbhash\";\nimport api from \"../utils/api\";\nimport ScrollingBackground from \"./ScrollingBackground\";\n\nconst { Title, Text } = Typography;\nconst { useBreakpoint } = Grid;\n\nconst getCacheBustedUrl = (img, width = 0) => {\n    if (!img) return \"\";\n    let u = img.url || img; // Handle passing just URL string if needed, although we prefer the whole object\n    if (typeof u !== 'string') return \"\";\n    \n    // Check if we passed the full image object to get mtime\n    const mtime = img.mtime || (img.uploadTime ? new Date(img.uploadTime).getTime() : 0);\n    \n    if (mtime) {\n        u += u.includes('?') ? `&t=${mtime}` : `?t=${mtime}`;\n    }\n    if (width > 0) {\n        u += u.includes('?') ? `&w=${width}` : `?w=${width}`;\n    }\n    return u;\n};\n\n// Helper to convert base64 thumbhash to data URL\nconst getThumbHashUrl = (hash) => {\n    if (!hash) return null;\n    try {\n        const binary = Uint8Array.from(atob(hash), c => c.charCodeAt(0));\n        return thumbHashToDataURL(binary);\n    } catch (e) {\n        console.error(\"ThumbHash decode error:\", e);\n        return null;\n    }\n};\n\nconst formatFileSize = (bytes) => {\n    if (bytes === 0) return \"0 Bytes\";\n    const k = 1024;\n    const sizes = [\"Bytes\", \"KB\", \"MB\", \"GB\"];\n    const i = Math.floor(Math.log(bytes) / Math.log(k));\n    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + \" \" + sizes[i];\n};\n\nconst ImageItem = ({ image, hoverKey, setHoverKey, handlePreview, isMobile, handleDownload, copyToClipboard, thumbnailWidth = 0 }) => {\n    const [loaded, setLoaded] = useState(false);\n    const videoRef = useRef(null);\n    const { token: { colorBgContainer } } = theme.useToken();\n\n    useEffect(() => {\n        if (!videoRef.current) return;\n        const key = image.relPath || image.url || image.filename;\n\n        if (hoverKey === key) {\n            videoRef.current.currentTime = 0;\n            const playPromise = videoRef.current.play();\n            if (playPromise !== undefined) {\n                playPromise.catch(() => { });\n            }\n        } else {\n            videoRef.current.pause();\n            videoRef.current.currentTime = 0;\n        }\n    }, [hoverKey, image]);\n\n    return (\n        <div\n            style={{\n                position: \"relative\",\n                overflow: \"hidden\",\n                borderRadius: \"0px\",\n                boxShadow: \"0 2px 8px rgba(0,0,0,0.05)\",\n                transition: \"transform 0.3s ease\",\n                background: colorBgContainer,\n                cursor: \"zoom-in\",\n            }}\n            onMouseEnter={() => setHoverKey(image.relPath || image.url || image.filename)}\n            onMouseLeave={() => setHoverKey(null)}\n            onClick={() => handlePreview(image)}\n        >\n            <div style={{ overflow: \"hidden\", position: \"relative\", background: \"#f0f0f0\" }}>\n                {image.thumbhash && (\n                    <div\n                        style={{\n                            position: \"absolute\", top: 0, left: 0, right: 0, bottom: 0,\n                            backgroundImage: `url(${getThumbHashUrl(image.thumbhash)})`,\n                            backgroundSize: 'cover', backgroundPosition: 'center',\n                            filter: 'blur(5px)', transform: 'scale(1.1)',\n                            opacity: loaded ? 0 : 1, transition: \"opacity 0.5s ease-out\", zIndex: 1,\n                        }}\n                    />\n                )}\n                {(() => {\n                    const isVideo = /\\.(mp4|webm)$/i.test(image.filename);\n                    if (isVideo) {\n                        return (\n                            <video\n                                ref={videoRef}\n                                src={getCacheBustedUrl(image)}\n                                muted\n                                loop\n                                playsInline\n                                preload=\"metadata\"\n                                style={{\n                                    width: \"100%\", height: \"100%\", display: \"block\", objectFit: \"cover\",\n                                    transition: \"transform 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.5s ease-in\",\n                                    transform: hoverKey === (image.relPath || image.url || image.filename) ? \"scale(1.05)\" : \"scale(1)\",\n                                    opacity: loaded ? 1 : 0, position: \"relative\", zIndex: 2,\n                                }}\n                                onLoadedData={() => setLoaded(true)}\n                            />\n                        );\n                    }\n                    return (\n                        <img\n                            alt={image.filename}\n                            src={getCacheBustedUrl(image, thumbnailWidth)}\n                            draggable={false}\n                            loading=\"lazy\"\n                            onLoad={() => setLoaded(true)}\n                            style={{\n                                width: \"100%\", display: \"block\",\n                                transition: \"transform 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.5s ease-in\",\n                                transform: hoverKey === (image.relPath || image.url || image.filename) ? \"scale(1.05)\" : \"scale(1)\",\n                                opacity: loaded ? 1 : 0, position: \"relative\", zIndex: 2,\n                            }}\n                        />\n                    );\n                })()}\n            </div>\n\n            {!isMobile && (\n                <div\n                    style={{\n                        position: \"absolute\", top: 0, left: 0, right: 0, bottom: 0,\n                        background: \"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 40%, rgba(0,0,0,0) 100%)\",\n                        opacity: hoverKey === (image.relPath || image.url || image.filename) ? 1 : 0,\n                        transition: \"opacity 0.3s ease\", zIndex: 10,\n                        display: \"flex\", flexDirection: \"column\", justifyContent: \"flex-end\", padding: \"20px\", pointerEvents: \"none\",\n                    }}\n                >\n                    <div style={{\n                        transform: hoverKey === (image.relPath || image.url || image.filename) ? \"translateY(0)\" : \"translateY(10px)\",\n                        transition: \"transform 0.3s ease\", pointerEvents: \"auto\",\n                    }}>\n                        <div style={{ color: \"#fff\", fontSize: \"18px\", fontWeight: 700, marginBottom: \"4px\", lineHeight: 1.2, textShadow: \"0 2px 4px rgba(0,0,0,0.3)\", wordBreak: \"break-all\" }}>\n                            {image.filename.replace(/\\.[^/.]+$/, \"\")}\n                        </div>\n                        <div style={{ display: \"flex\", alignItems: \"center\", gap: \"8px\", color: \"rgba(255,255,255,0.8)\", fontSize: \"12px\", marginBottom: \"12px\", flexWrap: \"wrap\" }}>\n                            <span>{dayjs(image.uploadTime).format(\"YYYY-MM-DD\")}</span>\n                            <span>·</span>\n                            <span>{formatFileSize(image.size)}</span>\n                        </div>\n                        <div style={{ display: \"flex\", gap: \"8px\" }}>\n                            <Button size=\"small\" type=\"text\" icon={<DownloadOutlined />} onClick={(e) => { e.stopPropagation(); handleDownload(image); }} style={{ color: \"#fff\", background: \"rgba(255,255,255,0.2)\", backdropFilter: \"blur(4px)\", border: \"1px solid rgba(255,255,255,0.1)\", borderRadius: \"4px\", fontSize: \"12px\" }}>下载</Button>\n                        </div>\n                    </div>\n                </div>\n            )}\n        </div>\n    );\n};\n\nconst ModalVideoPlayer = ({ url, visible }) => {\n    const videoRef = useRef(null);\n\n    useEffect(() => {\n        if (videoRef.current) {\n            if (visible) {\n                videoRef.current.currentTime = 0;\n                videoRef.current.play().catch(() => { });\n            } else {\n                videoRef.current.pause();\n                videoRef.current.currentTime = 0;\n            }\n        }\n    }, [visible]);\n\n    return (\n        <video\n            ref={videoRef}\n            controls\n            autoPlay\n            src={url}\n            style={{ maxWidth: \"100%\", maxHeight: \"100%\", width: \"auto\", height: \"auto\", boxShadow: \"0 20px 50px rgba(0,0,0,0.5)\", zIndex: 2, outline: \"none\" }}\n        />\n    );\n};\n\nconst ShareView = ({ currentTheme, onThemeChange }) => {\n    const [loading, setLoading] = useState(true);\n    const [loadingMore, setLoadingMore] = useState(false);\n    const [images, setImages] = useState([]);\n    const [dirName, setDirName] = useState(\"\");\n    const [error, setError] = useState(null);\n    const [hoverKey, setHoverKey] = useState(null);\n    const [thumbnailWidth, setThumbnailWidth] = useState(0);\n\n    // Fetch config\n    useEffect(() => {\n        const fetchConfig = async () => {\n            try {\n                const response = await api.get(\"/config\");\n                if (response.data.success && response.data.data?.upload?.thumbnailWidth) {\n                    setThumbnailWidth(response.data.data.upload.thumbnailWidth);\n                }\n            } catch (error) {\n                console.warn(\"Failed to fetch config:\", error);\n            }\n        };\n        fetchConfig();\n    }, []);\n\n    // Pagination State\n    const [currentPage, setCurrentPage] = useState(1);\n    const [pageSize] = useState(20);\n    const [hasMore, setHasMore] = useState(true);\n    const loadMoreRef = useRef(null);\n\n    // Modal State\n    const [previewVisible, setPreviewVisible] = useState(false);\n    const [previewIndex, setPreviewIndex] = useState(-1);\n    const [previewFile, setPreviewFile] = useState(null);\n    const [previewLocation, setPreviewLocation] = useState(\"\");\n    const [imgLoaded, setImgLoaded] = useState(false);\n\n    const { token: themeToken } = theme.useToken();\n    const { colorBgContainer, colorText, colorTextSecondary } = themeToken;\n    const screens = useBreakpoint();\n    const isMobile = !screens.md;\n    const isDarkMode = themeToken.theme?.id === 1 || colorBgContainer === \"#141414\";\n\n    const fetchShare = React.useCallback(async (page = 1, append = false) => {\n        const params = new URLSearchParams(window.location.search);\n        const token = params.get(\"token\");\n\n        if (!token) {\n            setError(\"无效的分享链接\");\n            setLoading(false);\n            return;\n        }\n\n        if (append) {\n            setLoadingMore(true);\n        } else {\n            setLoading(true);\n        }\n\n        try {\n            const res = await api.get(`/share/access?token=${encodeURIComponent(token)}&page=${page}&pageSize=${pageSize}`);\n            if (res.data.success) {\n                setImages(prev => append ? prev.concat(res.data.data) : res.data.data);\n                setDirName(res.data.dirName);\n\n                const p = res.data.pagination;\n                if (p) {\n                    setHasMore(p.current < p.totalPages);\n                } else {\n                    setHasMore(false);\n                }\n            } else {\n                let errorMsg = res.data.error || \"获取分享内容失败\";\n                if (errorMsg.includes(\"Link already used\") || errorMsg.includes(\"Burned\")) {\n                    errorMsg = \"链接已失效 (阅后即焚)\";\n                } else if (errorMsg.includes(\"expired\") || errorMsg.includes(\"Invalid\")) {\n                    errorMsg = \"链接已过期\";\n                }\n                if (!append) setError(errorMsg);\n                else message.error(errorMsg);\n            }\n        } catch (e) {\n            let errorMsg = e.response?.data?.error || \"链接已失效或验证失败\";\n            if (errorMsg.includes(\"Link already used\") || errorMsg.includes(\"Burned\")) {\n                errorMsg = \"链接已失效 (阅后即焚)\";\n            } else if (errorMsg.includes(\"expired\")) {\n                errorMsg = \"链接已过期\";\n            }\n            if (!append) setError(errorMsg);\n        } finally {\n            if (append) {\n                setLoadingMore(false);\n            } else {\n                setLoading(false);\n            }\n        }\n    }, [pageSize]);\n\n    useEffect(() => {\n        fetchShare(1, false);\n    }, [fetchShare]);\n\n    useEffect(() => {\n        if (currentPage > 1) {\n            fetchShare(currentPage, true);\n        }\n    }, [currentPage, fetchShare]);\n\n    useEffect(() => {\n        const el = loadMoreRef.current;\n        if (!el) return;\n        const observer = new IntersectionObserver(\n            (entries) => {\n                const entry = entries[0];\n                if (\n                    entry.isIntersecting &&\n                    hasMore &&\n                    !loading &&\n                    !loadingMore &&\n                    images.length > 0\n                ) {\n                    setCurrentPage((p) => p + 1);\n                }\n            },\n            { root: null, rootMargin: \"200px\", threshold: 0 }\n        );\n        observer.observe(el);\n        return () => observer.disconnect();\n    }, [hasMore, loading, loadingMore, images.length]);\n\n    const groups = useMemo(() => {\n        const map = new Map();\n        for (const img of images) {\n            const key = dayjs(img.uploadTime).format(\"YYYY年MM月DD日\");\n            const arr = map.get(key) || [];\n            arr.push(img);\n            map.set(key, arr);\n        }\n        const dates = Array.from(map.keys()).sort(\n            (a, b) => dayjs(b, \"YYYY年MM月DD日\").valueOf() - dayjs(a, \"YYYY年MM月DD日\").valueOf()\n        );\n        return dates.map((d) => ({ date: d, items: map.get(d) }));\n    }, [images]);\n\n    const handlePreview = React.useCallback((file) => {\n        const index = images.findIndex(img => img.relPath === file.relPath);\n        setPreviewIndex(index);\n        setPreviewFile(file);\n        setPreviewVisible(true);\n        setPreviewLocation(\"\"); // Reset location\n        setImgLoaded(false);\n    }, [images]);\n\n    const showNext = React.useCallback(() => {\n        if (previewIndex < images.length - 1) {\n            handlePreview(images[previewIndex + 1]);\n        }\n    }, [previewIndex, images, handlePreview]);\n\n    const showPrev = React.useCallback(() => {\n        if (previewIndex > 0) {\n            handlePreview(images[previewIndex - 1]);\n        }\n    }, [previewIndex, images, handlePreview]);\n\n    useEffect(() => {\n        const handleKeyDown = (e) => {\n            if (!previewVisible) return;\n            if (e.key === 'ArrowRight') showNext();\n            if (e.key === 'ArrowLeft') showPrev();\n        };\n        window.addEventListener('keydown', handleKeyDown);\n        return () => window.removeEventListener('keydown', handleKeyDown);\n    }, [previewVisible, showNext, showPrev]);\n\n    // Fetch Location for Preview\n    useEffect(() => {\n        if (!previewVisible || !previewFile?.exif?.latitude) {\n            setPreviewLocation(\"\");\n            return;\n        }\n\n        const { latitude, longitude } = previewFile.exif;\n        let active = true;\n\n        const fetchPreviewLoc = async () => {\n            try {\n                const geoRes = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=10&accept-language=zh-CN`);\n                const geoData = await geoRes.json();\n\n                if (active && geoData) {\n                    setPreviewLocation(geoData.display_name);\n                }\n            } catch (e) { }\n        };\n\n        fetchPreviewLoc();\n        return () => { active = false; };\n    }, [previewVisible, previewFile]);\n\n    const handleDownload = (file) => {\n        const link = document.createElement(\"a\");\n        link.href = getCacheBustedUrl(file);\n        link.download = file.filename;\n        document.body.appendChild(link);\n        link.click();\n        document.body.removeChild(link);\n        message.success(\"开始下载\");\n    };\n\n    const copyToClipboard = (text) => {\n        if (navigator.clipboard && window.isSecureContext) {\n            navigator.clipboard.writeText(text).then(() => message.success(\"链接已复制到剪贴板\"));\n        } else {\n            // Fallback\n            const input = document.createElement(\"input\");\n            input.value = text;\n            document.body.appendChild(input);\n            input.select();\n            document.execCommand(\"copy\");\n            document.body.removeChild(input);\n            message.success(\"链接已复制到剪贴板\");\n        }\n    };\n\n    if (loading) {\n        return (\n            <div style={{ height: \"100vh\", display: \"flex\", justifyContent: \"center\", alignItems: \"center\" }}>\n                <Spin size=\"large\" tip=\"正在验证分享链接...\" />\n            </div>\n        );\n    }\n\n    if (error) {\n        return (\n            <div style={{ height: \"100vh\", display: \"flex\", flexDirection: \"column\", justifyContent: \"center\", alignItems: \"center\" }}>\n                <Empty description={error} image={Empty.PRESENTED_IMAGE_SIMPLE} />\n            </div>\n        );\n    }\n\n    return (\n        <div style={{ minHeight: \"100vh\", background: themeToken.colorBgLayout }}>\n            {/* Header Banner */}\n            <div style={{\n                position: \"relative\",\n                height: 300,\n                overflow: \"hidden\",\n                background: themeToken.colorBgContainer,\n                marginBottom: 40,\n                display: \"flex\",\n                alignItems: \"center\",\n                justifyContent: \"center\"\n            }}>\n                {/* Theme Toggle Button */}\n                <div style={{\n                    position: \"absolute\",\n                    top: 20,\n                    right: 20,\n                    zIndex: 100\n                }}>\n                    <Button\n                        shape=\"circle\"\n                        icon={currentTheme === 'dark' ? <SunOutlined /> : <MoonOutlined />}\n                        onClick={() => onThemeChange(currentTheme === 'dark' ? 'light' : 'dark')}\n                        style={{\n                            background: currentTheme === 'dark' ? \"rgba(255,255,255,0.15)\" : \"rgba(255,255,255,0.6)\",\n                            backdropFilter: \"blur(4px)\",\n                            border: `1px solid ${currentTheme === 'dark' ? \"rgba(255,255,255,0.2)\" : \"rgba(0,0,0,0.1)\"}`,\n                            color: currentTheme === 'dark' ? \"#fff\" : \"rgba(0,0,0,0.85)\"\n                        }}\n                    />\n                </div>\n\n                <ScrollingBackground usePicsum={true} />\n\n                {/* Overlay Gradient */}\n                <div style={{\n                    position: \"absolute\",\n                    top: 0, left: 0, right: 0, bottom: 0,\n                    background: `linear-gradient(to bottom, \n                      ${isDarkMode ? 'rgba(0,0,0,0.8)' : 'rgba(255,255,255,0.8)'} 0%, \n                      ${isDarkMode ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.6)'} 50%, \n                      ${themeToken.colorBgLayout} 100%)`,\n                    zIndex: 1\n                }} />\n\n                {/* Header Content */}\n                <div style={{ position: \"relative\", zIndex: 2, textAlign: \"center\" }}>\n                    <Title level={1} style={{ fontSize: 42, marginBottom: 8, letterSpacing: 1 }}>{dirName || \"分享的相册\"}</Title>\n                    <div style={{ marginTop: 8 }}>\n                        <Text type=\"secondary\" style={{ fontSize: 16 }}>共 {images.length} 张图片</Text>\n                    </div>\n                </div>\n            </div>\n\n            <div style={{ maxWidth: 1600, margin: \"0 auto\", padding: isMobile ? \"0 12px\" : \"0 24px\" }}>\n                {groups.map((group) => (\n                    <div key={group.date} style={{ marginBottom: 24 }}>\n                        <div\n                            style={{\n                                marginBottom: 16,\n                                paddingLeft: 8,\n                                opacity: 0.8,\n                                fontWeight: 600,\n                                fontSize: \"13px\",\n                                letterSpacing: \"0.5px\",\n                                textTransform: \"uppercase\",\n                                color: colorTextSecondary,\n                            }}\n                        >\n                            {group.date}\n                        </div>\n\n                        <Masonry\n                            columns={isMobile ? 2 : screens.xl ? 5 : screens.lg ? 4 : screens.md ? 3 : 2}\n                            gutter={8}\n                            items={group.items.map((imgItem, index) => ({\n                                key: imgItem.relPath || `item-${group.date}-${index}`,\n                                data: imgItem,\n                            }))}\n                            itemRender={({ data: imgItem }) => (\n                                <ImageItem\n                                    image={imgItem}\n                                    hoverKey={hoverKey}\n                                    setHoverKey={setHoverKey}\n                                    handlePreview={handlePreview}\n                                    formatFileSize={formatFileSize}\n                                    isMobile={isMobile}\n                                    handleDownload={handleDownload}\n                                    copyToClipboard={copyToClipboard}\n                                    thumbnailWidth={thumbnailWidth}\n                                />\n                            )}\n                        />\n                    </div>\n                ))}\n\n                <div ref={loadMoreRef} style={{ height: 20 }} />\n                {loadingMore && (\n                    <div style={{ textAlign: \"center\", padding: 20 }}>\n                        <Spin />\n                    </div>\n                )}\n            </div>\n\n            {/* Full Screen Modal */}\n            <Modal\n                open={previewVisible}\n                title={null}\n                footer={null}\n                onCancel={() => setPreviewVisible(false)}\n                width=\"100vw\"\n                style={{ top: 0, margin: 0, maxWidth: \"100vw\", padding: 0 }}\n                styles={{\n                    body: { padding: 0, height: \"100vh\", overflow: \"hidden\", background: \"#000\" },\n                    content: { padding: 0, background: \"#000\", boxShadow: \"none\" },\n                    container: { padding: 0 }\n                }}\n                destroyOnClose\n                closeIcon={null}\n            >\n                {previewFile && (\n                    <div style={{ display: \"flex\", height: \"100vh\", position: \"relative\" }}>\n                        {/* Close & Copy Buttons */}\n                        <div style={{ position: \"absolute\", top: 20, right: isMobile ? 20 : 380, zIndex: 1000, display: \"flex\", gap: 12 }}>\n                            <Button\n                                shape=\"circle\"\n                                icon={<span style={{ fontSize: 24, lineHeight: 1 }}>×</span>}\n                                onClick={() => setPreviewVisible(false)}\n                                style={{ background: \"rgba(0,0,0,0.5)\", border: \"1px solid rgba(255,255,255,0.2)\", color: \"#fff\", width: 40, height: 40 }}\n                            />\n                        </div>\n\n                        {/* Left: Image Viewer */}\n                        <div style={{ flex: 1, height: \"100%\", overflow: \"hidden\", position: \"relative\", backgroundColor: \"#0f0f0f\", display: \"flex\", alignItems: \"center\", justifyContent: \"center\" }}>\n                            {!isMobile && previewIndex > 0 && (\n                                <Button\n                                    type=\"text\" icon={<LeftOutlined style={{ fontSize: 24, color: 'rgba(255,255,255,0.8)' }} />}\n                                    onClick={(e) => { e.stopPropagation(); showPrev(); }}\n                                    style={{ position: 'absolute', left: 20, zIndex: 100, height: '100%', width: 80, background: 'linear-gradient(90deg, rgba(0,0,0,0.3) 0%, transparent 100%)', border: 'none', opacity: 0, transition: 'opacity 0.3s' }}\n                                    onMouseEnter={(e) => e.currentTarget.style.opacity = 1}\n                                    onMouseLeave={(e) => e.currentTarget.style.opacity = 0}\n                                />\n                            )}\n                            {!isMobile && previewIndex < images.length - 1 && (\n                                <Button\n                                    type=\"text\" icon={<RightOutlined style={{ fontSize: 24, color: 'rgba(255,255,255,0.8)' }} />}\n                                    onClick={(e) => { e.stopPropagation(); showNext(); }}\n                                    style={{ position: 'absolute', right: 20, zIndex: 100, height: '100%', width: 80, background: 'linear-gradient(-90deg, rgba(0,0,0,0.3) 0%, transparent 100%)', border: 'none', opacity: 0, transition: 'opacity 0.3s' }}\n                                    onMouseEnter={(e) => e.currentTarget.style.opacity = 1}\n                                    onMouseLeave={(e) => e.currentTarget.style.opacity = 0}\n                                />\n                            )}\n\n                            <div style={{ width: \"100%\", height: \"100%\", position: \"relative\", display: 'flex', alignItems: 'center', justifyContent: 'center' }}>\n                                <div style={{ position: \"absolute\", top: 0, right: 0, bottom: 0, left: 0, backgroundImage: `url(${previewFile.url})`, backgroundSize: \"cover\", backgroundPosition: \"center\", filter: \"blur(40px) brightness(0.5)\", transform: \"scale(1.2)\", zIndex: 0 }} />\n                                {/\\.(mp4|webm)$/i.test(previewFile.filename) ? (\n                                    <ModalVideoPlayer url={getCacheBustedUrl(previewFile)} visible={previewVisible} />\n                                ) : (\n                                    <>\n                                        {!imgLoaded && (\n                                            <div style={{ position: \"absolute\", top: \"50%\", left: \"50%\", transform: \"translate(-50%, -50%)\", zIndex: 3 }}>\n                                                <Spin size=\"large\" />\n                                            </div>\n                                        )}\n                                        <img\n                                            key={previewFile.url}\n                                            alt=\"preview\"\n                                            onLoad={() => setImgLoaded(true)}\n                                            style={{\n                                                maxWidth: \"100%\", maxHeight: \"100%\", width: \"auto\", height: \"auto\", objectFit: \"contain\",\n                                                boxShadow: \"0 20px 50px rgba(0,0,0,0.5)\", zIndex: 2,\n                                                opacity: imgLoaded ? 1 : 0, transition: \"opacity 0.3s ease\"\n                                            }}\n                                            src={getCacheBustedUrl(previewFile)}\n                                        />\n                                    </>\n                                )}\n                            </div>\n                        </div>\n\n                        {/* Right: Info Sidebar */}\n                        {(() => {\n                            const thumbUrl = getThumbHashUrl(previewFile.thumbhash);\n                            const hasThumb = !!thumbUrl;\n\n                            const textColor = hasThumb ? \"#fff\" : colorText;\n                            const secondaryTextColor = hasThumb ? \"rgba(255,255,255,0.75)\" : colorTextSecondary;\n                            const tertiaryTextColor = hasThumb ? \"rgba(255,255,255,0.5)\" : (isDarkMode ? \"rgba(255,255,255,0.45)\" : \"rgba(0,0,0,0.45)\");\n\n                            return (\n                                <div style={{ width: isMobile ? \"100%\" : 360, background: hasThumb ? `linear-gradient(to bottom, rgba(0,0,0,0.7), rgba(0,0,0,0.9)), url(${thumbUrl}) center/cover no-repeat` : colorBgContainer, color: textColor, borderLeft: isDarkMode ? \"1px solid rgba(255,255,255,0.1)\" : \"none\", display: isMobile ? \"none\" : \"flex\", flexDirection: \"column\", zIndex: 20, transition: \"background 0.3s ease, color 0.3s ease\" }}>\n                                    <div style={{ flex: 1, overflowY: \"auto\", padding: \"32px 24px\" }}>\n                                        <div style={{ marginBottom: 24 }}>\n                                            <Title level={4} style={{ margin: 0, wordBreak: 'break-all', color: textColor, fontSize: 18 }}>{previewFile.filename}</Title>\n                                        </div>\n\n                                        <div style={{ display: 'flex', gap: 12, marginBottom: 32 }}>\n                                            <Button block ghost icon={<DownloadOutlined />} onClick={() => handleDownload(previewFile)} style={{ color: textColor, borderColor: secondaryTextColor }}>下载</Button>\n                                        </div>\n\n                                        <Space direction=\"vertical\" size={24} style={{ width: '100%' }}>\n                                            <div>\n                                                <div style={{ fontSize: 12, fontWeight: 600, color: tertiaryTextColor, textTransform: 'uppercase', marginBottom: 12 }}>基本信息</div>\n                                                <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>\n                                                    <div><div style={{ color: tertiaryTextColor, fontSize: 12, marginBottom: 2 }}>文件大小</div><div style={{ fontSize: 13, color: textColor }}>{formatFileSize(previewFile.size)}</div></div>\n                                                    <div><div style={{ color: tertiaryTextColor, fontSize: 12, marginBottom: 2 }}>格式</div><div style={{ fontSize: 13, color: textColor }}>{previewFile.filename.split('.').pop().toUpperCase()}</div></div>\n                                                    <div><div style={{ color: tertiaryTextColor, fontSize: 12, marginBottom: 2 }}>上传时间</div><div style={{ fontSize: 13, color: textColor }}>{dayjs(previewFile.uploadTime).format(\"YYYY-MM-DD\")}</div></div>\n                                                    {previewLocation && (\n                                                        <div style={{ gridColumn: 'span 2' }}>\n                                                            <div style={{ color: tertiaryTextColor, fontSize: 12, marginBottom: 2 }}>拍摄地点</div>\n                                                            <div style={{ fontSize: 13, color: textColor, display: 'flex', alignItems: 'center', gap: 4, marginBottom: 8 }}><EnvironmentOutlined /> {previewLocation}</div>\n                                                            {previewFile.exif?.latitude && previewFile.exif?.longitude && (\n                                                                <div style={{ position: \"relative\", height: 150, borderRadius: 8, overflow: \"hidden\", border: `1px solid ${isDarkMode ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.1)'}` }}>\n                                                                    <iframe\n                                                                        title=\"Map Preview\"\n                                                                        width=\"100%\"\n                                                                        height=\"200\"\n                                                                        frameBorder=\"0\"\n                                                                        scrolling=\"no\"\n                                                                        marginHeight=\"0\"\n                                                                        marginWidth=\"0\"\n                                                                        src={`https://www.openstreetmap.org/export/embed.html?bbox=${previewFile.exif.longitude - 0.01}%2C${previewFile.exif.latitude - 0.01}%2C${previewFile.exif.longitude + 0.01}%2C${previewFile.exif.latitude + 0.01}&layer=mapnik&marker=${previewFile.exif.latitude}%2C${previewFile.exif.longitude}`}\n                                                                        style={{ border: 0 }}\n                                                                    />\n                                                                    <div style={{\n                                                                        position: \"absolute\",\n                                                                        bottom: 0,\n                                                                        right: 0,\n                                                                        background: \"rgba(255, 255, 255, 0.7)\",\n                                                                        padding: \"1px 4px\",\n                                                                        fontSize: \"9px\",\n                                                                        color: \"#000\",\n                                                                        pointerEvents: \"none\",\n                                                                        borderTopLeftRadius: 4\n                                                                    }}>\n                                                                        © OSM\n                                                                    </div>\n                                                                </div>\n                                                            )}\n                                                        </div>\n                                                    )}\n                                                </div>\n                                            </div>\n\n                                            {previewFile.exif && (\n                                                <div>\n                                                    <div style={{ fontSize: 12, fontWeight: 600, color: tertiaryTextColor, textTransform: 'uppercase', marginBottom: 12 }}>拍摄参数</div>\n                                                    <Space direction=\"vertical\" size={12} style={{ width: '100%' }}>\n                                                        <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>\n                                                            <CameraOutlined style={{ fontSize: 16, color: tertiaryTextColor }} />\n                                                            <div><div style={{ fontSize: 13, color: textColor }}>{[previewFile.exif.make, previewFile.exif.model].filter(Boolean).join(\" \")}</div><div style={{ fontSize: 12, color: tertiaryTextColor }}>相机</div></div>\n                                                        </div>\n                                                        <div style={{ display: 'flex', gap: 24, marginTop: 4 }}>\n                                                            {previewFile.exif.fNumber && <div><div style={{ fontSize: 13, fontWeight: 500, color: textColor }}>f/{previewFile.exif.fNumber}</div><div style={{ fontSize: 12, color: tertiaryTextColor }}>光圈</div></div>}\n                                                            {previewFile.exif.exposureTime && <div><div style={{ fontSize: 13, fontWeight: 500, color: textColor }}>{previewFile.exif.exposureTime}s</div><div style={{ fontSize: 12, color: tertiaryTextColor }}>快门</div></div>}\n                                                            {previewFile.exif.iso && <div><div style={{ fontSize: 13, fontWeight: 500, color: textColor }}>{previewFile.exif.iso}</div><div style={{ fontSize: 12, color: tertiaryTextColor }}>ISO</div></div>}\n                                                        </div>\n                                                    </Space>\n                                                </div>\n                                            )}\n                                        </Space>\n                                    </div>\n                                </div>\n                            );\n                        })()}\n                    </div>\n                )\n                }\n            </Modal >\n        </div >\n    );\n};\n\nexport default ShareView;\n"
  },
  {
    "path": "client/src/components/SvgToPngTool.js",
    "content": "import React, { useState, useRef, useEffect } from \"react\";\nimport {\n  Card,\n  Typography,\n  Space,\n  Button,\n  Input,\n  message,\n  Row,\n  Col,\n  theme,\n  Upload,\n} from \"antd\";\nimport {\n  CodeOutlined,\n  PictureOutlined,\n  DownloadOutlined,\n  UploadOutlined,\n  CopyOutlined,\n  ClearOutlined,\n  InboxOutlined,\n} from \"@ant-design/icons\";\n\nconst { TextArea } = Input;\nconst { Title, Text } = Typography;\nconst { Dragger } = Upload;\n\nconst SvgToPngTool = ({ onUploadSuccess, api }) => {\n  const {\n    token: { colorBorder, colorFillTertiary, colorBgContainer, colorText },\n  } = theme.useToken();\n\n  const [svgCode, setSvgCode] = useState(\"\");\n  const [pngDataUrl, setPngDataUrl] = useState(\"\");\n  const [isConverting, setIsConverting] = useState(false);\n  const [isUploading, setIsUploading] = useState(false);\n  const [uploadedUrl, setUploadedUrl] = useState(\"\");\n  const [fileName, setFileName] = useState(\"converted-image\");\n  const canvasRef = useRef(null);\n\n  // 处理粘贴事件\n  const handlePaste = async (event) => {\n    const items = event.clipboardData?.items;\n    if (!items) return;\n\n    for (let i = 0; i < items.length; i++) {\n      const item = items[i];\n\n      // 处理图片粘贴\n      if (item.type.startsWith(\"image/\")) {\n        event.preventDefault();\n        const file = item.getAsFile();\n        if (file) {\n          await handlePastedImage(file);\n        }\n        break;\n      }\n\n      // 处理文本粘贴（可能是SVG代码）\n      if (item.type === \"text/plain\") {\n        item.getAsString((text) => {\n          // 检查是否是SVG代码\n          if (\n            text.trim().startsWith(\"<svg\") ||\n            text.trim().startsWith(\"<?xml\")\n          ) {\n            event.preventDefault();\n            setSvgCode(text);\n            setFileName(`pasted-svg-${Date.now()}`);\n            message.success(\"SVG代码已粘贴！\");\n          }\n        });\n      }\n    }\n  };\n\n  // 处理粘贴的图片\n  const handlePastedImage = async (file) => {\n    const isImage = file.type.startsWith(\"image/\");\n    if (!isImage) {\n      message.error(\"只能处理图片文件！\");\n      return;\n    }\n\n    // 如果是SVG图片，尝试读取SVG代码\n    if (file.type === \"image/svg+xml\") {\n      const reader = new FileReader();\n      reader.onload = (e) => {\n        setSvgCode(e.target.result);\n        setFileName(`pasted-svg-${Date.now()}`);\n        message.success(\"SVG图片已粘贴，代码已加载！\");\n      };\n      reader.readAsText(file);\n    } else {\n      message.info(\"粘贴的图片不是SVG格式，请粘贴SVG图片或SVG代码\");\n    }\n  };\n\n  // 添加全局粘贴事件监听\n  useEffect(() => {\n    const handleGlobalPaste = (event) => {\n      // 检查是否在输入框中，如果是则不处理粘贴\n      const target = event.target;\n      if (\n        target.tagName === \"INPUT\" ||\n        target.tagName === \"TEXTAREA\" ||\n        target.contentEditable === \"true\"\n      ) {\n        return;\n      }\n\n      handlePaste(event);\n    };\n\n    document.addEventListener(\"paste\", handleGlobalPaste);\n\n    return () => {\n      document.removeEventListener(\"paste\", handleGlobalPaste);\n    };\n  }, []);\n\n  // 转换SVG为PNG\n  const convertSvgToPng = async () => {\n    if (!svgCode.trim()) {\n      message.error(\"请输入SVG代码\");\n      return;\n    }\n\n    setIsConverting(true);\n    try {\n      // 创建SVG Blob\n      const svgBlob = new Blob([svgCode], { type: \"image/svg+xml\" });\n      const svgUrl = URL.createObjectURL(svgBlob);\n\n      // 创建Image对象\n      const img = new Image();\n      img.crossOrigin = \"anonymous\";\n\n      img.onload = () => {\n        try {\n          const canvas = canvasRef.current;\n          const ctx = canvas.getContext(\"2d\");\n\n          // 设置画布尺寸\n          canvas.width = img.width;\n          canvas.height = img.height;\n\n          // 绘制图片到画布\n          ctx.drawImage(img, 0, 0);\n\n          // 转换为PNG\n          const pngDataUrl = canvas.toDataURL(\"image/png\");\n          setPngDataUrl(pngDataUrl);\n\n          // 清理URL\n          URL.revokeObjectURL(svgUrl);\n          message.success(\"SVG转换PNG成功！\");\n        } catch (error) {\n          console.error(\"转换错误:\", error);\n          message.error(\"转换失败，请检查SVG代码格式\");\n        } finally {\n          setIsConverting(false);\n        }\n      };\n\n      img.onerror = () => {\n        message.error(\"SVG代码格式错误，请检查代码\");\n        setIsConverting(false);\n        URL.revokeObjectURL(svgUrl);\n      };\n\n      img.src = svgUrl;\n    } catch (error) {\n      console.error(\"转换错误:\", error);\n      message.error(\"转换失败，请检查SVG代码\");\n      setIsConverting(false);\n    }\n  };\n\n  // 下载PNG图片\n  const downloadPng = () => {\n    if (!pngDataUrl) {\n      message.error(\"请先转换SVG为PNG\");\n      return;\n    }\n\n    const link = document.createElement(\"a\");\n    link.download = `${fileName}.png`;\n    link.href = pngDataUrl;\n    document.body.appendChild(link);\n    link.click();\n    document.body.removeChild(link);\n    message.success(\"PNG图片下载成功！\");\n  };\n\n  // 上传PNG到图床\n  const uploadToImageBed = async () => {\n    if (!pngDataUrl) {\n      message.error(\"请先转换SVG为PNG\");\n      return;\n    }\n\n    setIsUploading(true);\n    try {\n      // 将Data URL转换为Blob\n      const response = await fetch(pngDataUrl);\n      const blob = await response.blob();\n\n      // 创建FormData\n      const formData = new FormData();\n      formData.append(\"image\", blob, fileName + \".png\");\n\n      // 上传到服务器\n      const uploadResponse = await api.post(\"/upload\", formData, {\n        headers: {\n          \"Content-Type\": \"multipart/form-data\",\n        },\n      });\n\n      if (uploadResponse.data.success) {\n        const imageUrl = `${window.location.origin}${uploadResponse.data.data.url}`;\n        setUploadedUrl(imageUrl);\n        message.success(\"PNG图片上传成功！\");\n\n        if (onUploadSuccess) {\n          onUploadSuccess();\n        }\n      } else {\n        message.error(uploadResponse.data.error || \"上传失败\");\n      }\n    } catch (error) {\n      console.error(\"上传错误:\", error);\n      message.error(\"上传失败，请重试\");\n    } finally {\n      setIsUploading(false);\n    }\n  };\n\n  // 复制上传的URL\n  const copyUploadedUrl = () => {\n    if (!uploadedUrl) {\n      message.error(\"没有可复制的URL\");\n      return;\n    }\n\n    navigator.clipboard.writeText(uploadedUrl).then(() => {\n      message.success(\"URL已复制到剪贴板\");\n    });\n  };\n\n  // 清空所有内容\n  const clearAll = () => {\n    setSvgCode(\"\");\n    setPngDataUrl(\"\");\n    setUploadedUrl(\"\");\n    setFileName(\"converted-image\");\n    message.success(\"已清空所有内容\");\n  };\n\n  // 使用示例SVG\n  const useExample = () => {\n    setSvgCode(`<svg width=\"200\" height=\"200\" xmlns=\"http://www.w3.org/2000/svg\">\n  <circle cx=\"100\" cy=\"100\" r=\"80\" fill=\"#1890ff\" stroke=\"#096dd9\" stroke-width=\"3\"/>\n  <text x=\"100\" y=\"110\" text-anchor=\"middle\" fill=\"white\" font-size=\"16\" font-family=\"Arial\">SVG</text>\n</svg>`);\n    setFileName(\"svg-example\");\n    message.success(\"已加载示例SVG代码\");\n  };\n\n  // 自动生成文件名\n  const generateFileName = () => {\n    const timestamp = new Date()\n      .toISOString()\n      .slice(0, 19)\n      .replace(/[:-]/g, \"\");\n    const newFileName = `svg-${timestamp}`;\n    setFileName(newFileName);\n    message.success(`已生成文件名: ${newFileName}`);\n  };\n\n  // 处理SVG文件上传\n  const handleSvgFileUpload = (file) => {\n    const isSvg = file.type === 'image/svg+xml' || file.name.toLowerCase().endsWith('.svg');\n    if (!isSvg) {\n      message.error('请上传SVG格式的文件！');\n      return false;\n    }\n\n    const reader = new FileReader();\n    reader.onload = (e) => {\n      setSvgCode(e.target.result);\n      setFileName(file.name.replace(/\\.svg$/i, ''));\n      message.success('SVG文件已加载！');\n    };\n    reader.readAsText(file);\n    \n    return false; // 阻止默认上传行为\n  };\n\n  const uploadProps = {\n    name: 'file',\n    multiple: false,\n    accept: '.svg,image/svg+xml',\n    beforeUpload: handleSvgFileUpload,\n    showUploadList: false,\n  };\n\n  return (\n    <div>\n      <Title level={2}>\n        <CodeOutlined /> SVG转PNG工具\n      </Title>\n\n      <Row gutter={[24, 24]} style={{ marginTop: 24 }}>\n        {/* 左侧：SVG输入 */}\n        <Col xs={24} lg={12}>\n          <Card title=\"SVG代码输入\" size=\"small\">\n            <Space direction=\"vertical\" style={{ width: \"100%\" }} size=\"middle\">\n              {/* 添加紧凑的文件上传区域 */}\n              <Dragger \n                {...uploadProps} \n                style={{ \n                  padding: '12px',\n                  minHeight: '80px',\n                  marginBottom: '16px'\n                }}\n              >\n                <p className=\"ant-upload-drag-icon\" style={{ margin: '4px 0' }}>\n                  <InboxOutlined style={{ fontSize: '24px' }} />\n                </p>\n                <p className=\"ant-upload-text\" style={{ margin: '4px 0', fontSize: '14px' }}>\n                  点击或拖拽SVG文件到此处\n                </p>\n                <p className=\"ant-upload-hint\" style={{ margin: '0', fontSize: '12px', color: '#999' }}>\n                  支持 .svg 格式文件\n                </p>\n              </Dragger>\n\n              <div>\n                <Button\n                  type=\"dashed\"\n                  onClick={useExample}\n                  icon={<CodeOutlined />}\n                  style={{ marginBottom: 8 }}\n                >\n                  使用示例SVG\n                </Button>\n                <Button\n                  type=\"text\"\n                  onClick={clearAll}\n                  icon={<ClearOutlined />}\n                  danger\n                >\n                  清空所有\n                </Button>\n              </div>\n\n              <TextArea\n                rows={12}\n                placeholder=\"请输入SVG代码...\"\n                value={svgCode}\n                onChange={(e) => setSvgCode(e.target.value)}\n                style={{ fontFamily: \"monospace\" }}\n              />\n              <div\n                style={{\n                  textAlign: \"center\",\n                  color: \"#1890ff\",\n                  fontSize: \"12px\",\n                }}\n              >\n                支持 Ctrl+V 粘贴SVG代码或SVG图片\n              </div>\n\n              <Button\n                type=\"primary\"\n                onClick={convertSvgToPng}\n                loading={isConverting}\n                icon={<PictureOutlined />}\n                block\n              >\n                {isConverting ? \"转换中...\" : \"转换为PNG\"}\n              </Button>\n            </Space>\n          </Card>\n        </Col>\n\n        {/* 右侧：PNG预览和操作 */}\n        <Col xs={24} lg={12}>\n          <Card title=\"PNG预览\" size=\"small\">\n            <Space direction=\"vertical\" style={{ width: \"100%\" }} size=\"middle\">\n              {pngDataUrl ? (\n                <>\n                  <div style={{ textAlign: \"center\" }}>\n                    <img\n                      src={pngDataUrl}\n                      alt=\"转换后的PNG\"\n                      style={{\n                        maxWidth: \"100%\",\n                        maxHeight: \"300px\",\n                        border: \"1px solid #d9d9d9\",\n                        borderRadius: \"4px\",\n                      }}\n                    />\n                  </div>\n\n                  <div>\n                    <Text strong>文件名：</Text>\n                    <div style={{ marginTop: 8, display: \"flex\", gap: 8 }}>\n                      <Input\n                        value={fileName}\n                        onChange={(e) => setFileName(e.target.value)}\n                        placeholder=\"输入文件名（不含扩展名）\"\n                        addonAfter=\".png\"\n                        style={{ flex: 1 }}\n                      />\n                      <Button\n                        size=\"small\"\n                        onClick={generateFileName}\n                        title=\"自动生成基于时间戳的文件名\"\n                      >\n                        自动生成\n                      </Button>\n                    </div>\n                  </div>\n\n                  <Space>\n                    <Button icon={<DownloadOutlined />} onClick={downloadPng}>\n                      下载PNG\n                    </Button>\n                    <Button\n                      type=\"primary\"\n                      icon={<UploadOutlined />}\n                      onClick={uploadToImageBed}\n                      loading={isUploading}\n                    >\n                      {isUploading ? \"上传中...\" : \"上传到图床\"}\n                    </Button>\n                  </Space>\n                </>\n              ) : (\n                <div\n                  style={{\n                    textAlign: \"center\",\n                    padding: \"40px 20px\",\n                    color: \"#999\",\n                    border: \"2px dashed #d9d9d9\",\n                    borderRadius: \"4px\",\n                  }}\n                >\n                  <PictureOutlined\n                    style={{ fontSize: \"48px\", marginBottom: \"16px\" }}\n                  />\n                  <div>转换后的PNG图片将在这里显示</div>\n                </div>\n              )}\n            </Space>\n          </Card>\n        </Col>\n      </Row>\n\n      {/* 上传结果 */}\n      {uploadedUrl && (\n        <Card title=\"上传结果\" style={{ marginTop: 24 }} size=\"small\">\n          <Space direction=\"vertical\" style={{ width: \"100%\" }}>\n            <div>\n              <Text strong>图片URL：</Text>\n              <Text code style={{ wordBreak: \"break-all\" }}>\n                {uploadedUrl}\n              </Text>\n            </div>\n            <Space>\n              <Button icon={<CopyOutlined />} onClick={copyUploadedUrl}>\n                复制URL\n              </Button>\n              <Button type=\"link\" href={uploadedUrl} target=\"_blank\">\n                在新窗口打开\n              </Button>\n            </Space>\n          </Space>\n        </Card>\n      )}\n\n      {/* 隐藏的Canvas用于转换 */}\n      <canvas ref={canvasRef} style={{ display: \"none\" }} />\n\n      {/* 使用说明 */}\n      <Card\n        title={\n          <span>\n            <CodeOutlined style={{ marginRight: 8, color: \"#1890ff\" }} />\n            使用技巧\n          </span>\n        }\n        style={{ marginTop: 24 }}\n        size=\"small\"\n      >\n        <Row gutter={[24, 16]}>\n          <Col xs={24} md={8}>\n            <div\n              style={{\n                padding: \"16px\",\n                backgroundColor: colorFillTertiary,\n                borderRadius: \"8px\",\n                border: `1px solid ${colorBorder}`,\n                height: \"100%\",\n              }}\n            >\n              <div\n                style={{\n                  display: \"flex\",\n                  alignItems: \"center\",\n                  marginBottom: \"12px\",\n                  color: \"#1890ff\",\n                  fontWeight: \"bold\",\n                }}\n              >\n                <CodeOutlined style={{ marginRight: 8, fontSize: \"16px\" }} />\n                支持的SVG特性\n              </div>\n              <ul\n                style={{\n                  margin: 0,\n                  paddingLeft: \"20px\",\n                  lineHeight: \"1.6\",\n                  fontSize: \"13px\",\n                }}\n              >\n                <li style={{ marginBottom: \"6px\" }}>\n                  <Text strong>基本图形：</Text>circle, rect, line, path,\n                  polygon等\n                </li>\n                <li style={{ marginBottom: \"6px\" }}>\n                  <Text strong>文本：</Text>text元素\n                </li>\n                <li style={{ marginBottom: \"6px\" }}>\n                  <Text strong>渐变：</Text>linearGradient, radialGradient\n                </li>\n                <li style={{ marginBottom: \"6px\" }}>\n                  <Text strong>滤镜：</Text>filter, feGaussianBlur等\n                </li>\n                <li style={{ marginBottom: \"6px\" }}>\n                  <Text strong>动画：</Text>animate, animateTransform等\n                </li>\n              </ul>\n            </div>\n          </Col>\n\n          <Col xs={24} md={8}>\n            <div\n              style={{\n                padding: \"16px\",\n                backgroundColor: colorFillTertiary,\n                borderRadius: \"8px\",\n                border: `1px solid ${colorBorder}`,\n                height: \"100%\",\n              }}\n            >\n              <div\n                style={{\n                  display: \"flex\",\n                  alignItems: \"center\",\n                  marginBottom: \"12px\",\n                  color: \"#52c41a\",\n                  fontWeight: \"bold\",\n                }}\n              >\n                <DownloadOutlined\n                  style={{ marginRight: 8, fontSize: \"16px\" }}\n                />\n                文件名功能\n              </div>\n              <ul\n                style={{\n                  margin: 0,\n                  paddingLeft: \"20px\",\n                  lineHeight: \"1.6\",\n                  fontSize: \"13px\",\n                }}\n              >\n                <li style={{ marginBottom: \"6px\" }}>\n                  <Text strong>自定义：</Text>可以自定义上传和下载的文件名\n                </li>\n                <li style={{ marginBottom: \"6px\" }}>\n                  <Text strong>自动生成：</Text>\n                  点击\"自动生成\"按钮生成基于时间戳的文件名\n                </li>\n                <li style={{ marginBottom: \"6px\" }}>\n                  <Text strong>扩展名：</Text>文件名会自动添加.png扩展名\n                </li>\n                <li style={{ marginBottom: \"6px\" }}>\n                  <Text strong>示例：</Text>使用示例SVG时会自动设置合适的文件名\n                </li>\n              </ul>\n            </div>\n          </Col>\n\n          <Col xs={24} md={8}>\n            <div\n              style={{\n                padding: \"16px\",\n                backgroundColor: colorFillTertiary,\n                borderRadius: \"8px\",\n                border: `1px solid ${colorBorder}`,\n                height: \"100%\",\n              }}\n            >\n              <div\n                style={{\n                  display: \"flex\",\n                  alignItems: \"center\",\n                  marginBottom: \"12px\",\n                  color: \"#fa8c16\",\n                  fontWeight: \"bold\",\n                }}\n              >\n                <PictureOutlined style={{ marginRight: 8, fontSize: \"16px\" }} />\n                注意事项\n              </div>\n              <ul\n                style={{\n                  margin: 0,\n                  paddingLeft: \"20px\",\n                  lineHeight: \"1.6\",\n                  fontSize: \"13px\",\n                }}\n              >\n                <li style={{ marginBottom: \"6px\" }}>\n                  <Text strong>格式：</Text>确保SVG代码格式正确\n                </li>\n                <li style={{ marginBottom: \"6px\" }}>\n                  <Text strong>尺寸：</Text>建议设置明确的width和height属性\n                </li>\n                <li style={{ marginBottom: \"6px\" }}>\n                  <Text strong>资源：</Text>\n                  外部资源（如图片、字体）可能无法正常显示\n                </li>\n                <li style={{ marginBottom: \"6px\" }}>\n                  <Text strong>质量：</Text>转换后的PNG质量取决于SVG的尺寸设置\n                </li>\n                <li style={{ marginBottom: \"6px\" }}>\n                  <Text strong>文件名：</Text>不要包含特殊字符，避免上传失败\n                </li>\n              </ul>\n            </div>\n          </Col>\n        </Row>\n      </Card>\n    </div>\n  );\n};\n\nexport default SvgToPngTool;\n"
  },
  {
    "path": "client/src/components/SvgToolModal.js",
    "content": "import React, { useState, useRef, useEffect } from \"react\";\nimport { Modal, Button, Input, message, Upload, Space, Tooltip, theme, Row, Col, Typography } from \"antd\";\nimport {\n    InboxOutlined,\n    PictureOutlined,\n    DownloadOutlined,\n    UploadOutlined,\n    CodeOutlined,\n    CloseOutlined,\n    CopyOutlined,\n    FileTextOutlined\n} from \"@ant-design/icons\";\n\nconst { TextArea } = Input;\nconst { Text } = Typography;\n\nconst SvgToolModal = ({ visible, onClose, api }) => {\n    const { token } = theme.useToken();\n    const isDarkMode = token.colorBgContainer === \"#141414\" || token.colorBgContainer === \"#000000\" || token.colorBgContainer.includes(\"1f1f1f\");\n\n    const [svgCode, setSvgCode] = useState(\"\");\n    const [pngDataUrl, setPngDataUrl] = useState(\"\");\n    const [isConverting, setIsConverting] = useState(false);\n    const [isUploading, setIsUploading] = useState(false);\n    const [uploadedUrl, setUploadedUrl] = useState(\"\");\n    const canvasRef = useRef(null);\n\n    // Clear state when closing\n    useEffect(() => {\n        if (!visible) {\n            // Optional: don't clear immediately to allow reopening\n        }\n    }, [visible]);\n\n    const handlePaste = async (event) => {\n        // If inside input/textarea, let default behavior happen\n        if (['INPUT', 'TEXTAREA'].includes(event.target.tagName)) {\n            return;\n        }\n\n        const items = event.clipboardData?.items;\n        if (!items) return;\n\n        for (let i = 0; i < items.length; i++) {\n            const item = items[i];\n            if (item.type.indexOf(\"image\") !== -1) {\n                const file = item.getAsFile();\n                if (file.type === \"image/svg+xml\") {\n                    const reader = new FileReader();\n                    reader.onload = (e) => setSvgCode(e.target.result);\n                    reader.readAsText(file);\n                    event.preventDefault();\n                }\n            } else if (item.type === \"text/plain\") {\n                // If we are not focused on textarea, and pasted text looks like SVG\n                if (document.activeElement.tagName !== 'TEXTAREA') {\n                    item.getAsString((text) => {\n                        if (text.trim().startsWith(\"<svg\") || text.trim().startsWith(\"<?xml\")) {\n                            setSvgCode(text);\n                            message.success(\"已粘贴 SVG 代码\");\n                        }\n                    });\n                }\n            }\n        }\n    };\n\n    // Listen for global paste when modal is open\n    useEffect(() => {\n        if (visible) {\n            window.addEventListener('paste', handlePaste);\n            return () => window.removeEventListener('paste', handlePaste);\n        }\n    }, [visible]);\n\n    // Convert SVG to PNG\n    useEffect(() => {\n        if (!svgCode.trim()) {\n            setPngDataUrl(\"\");\n            return;\n        }\n\n        const convert = () => {\n            setIsConverting(true);\n            const svgBlob = new Blob([svgCode], { type: \"image/svg+xml\" });\n            const url = URL.createObjectURL(svgBlob);\n            const img = new Image();\n\n            img.onload = () => {\n                const canvas = canvasRef.current;\n                if (canvas) {\n                    // Handle high DPI\n                    const scale = 2; // Higher quality\n                    canvas.width = img.width * scale;\n                    canvas.height = img.height * scale;\n                    const ctx = canvas.getContext(\"2d\");\n                    ctx.scale(scale, scale);\n                    ctx.drawImage(img, 0, 0);\n                    setPngDataUrl(canvas.toDataURL(\"image/png\"));\n                }\n                URL.revokeObjectURL(url);\n                setIsConverting(false);\n            };\n\n            img.onerror = () => {\n                setIsConverting(false);\n                URL.revokeObjectURL(url);\n            };\n\n            img.src = url;\n        };\n\n        // Debounce conversion\n        const timer = setTimeout(convert, 500);\n        return () => clearTimeout(timer);\n    }, [svgCode]);\n\n    const handleUpload = async () => {\n        if (!pngDataUrl) return;\n        setIsUploading(true);\n        try {\n            const res = await fetch(pngDataUrl);\n            const blob = await res.blob();\n            const formData = new FormData();\n            formData.append(\"image\", blob, `svg-converted-${Date.now()}.png`);\n\n            const uploadRes = await api.post(\"/upload\", formData, {\n                headers: { \"Content-Type\": \"multipart/form-data\" }\n            });\n\n            if (uploadRes.data.success) {\n                setUploadedUrl(`${window.location.origin}${uploadRes.data.data.url}`);\n                message.success(\"上传成功\");\n            } else {\n                message.error(\"上传失败\");\n            }\n        } catch (e) {\n            message.error(\"上传出错\");\n        } finally {\n            setIsUploading(false);\n        }\n    };\n\n    const handleDownload = () => {\n        if (!pngDataUrl) return;\n        const link = document.createElement(\"a\");\n        link.download = `svg-${Date.now()}.png`;\n        link.href = pngDataUrl;\n        document.body.appendChild(link);\n        link.click();\n        document.body.removeChild(link);\n    };\n\n    const uploadProps = {\n        accept: \".svg,image/svg+xml\",\n        showUploadList: false,\n        beforeUpload: (file) => {\n            const reader = new FileReader();\n            reader.onload = (e) => setSvgCode(e.target.result);\n            reader.readAsText(file);\n            return false;\n        }\n    };\n\n    // Glass styles\n    const glassBg = isDarkMode ? \"rgba(30, 30, 30, 0.75)\" : \"rgba(255, 255, 255, 0.75)\";\n    const glassBorder = isDarkMode ? \"rgba(255, 255, 255, 0.1)\" : \"rgba(255, 255, 255, 0.6)\";\n    const textColor = isDarkMode ? \"rgba(255,255,255,0.85)\" : \"rgba(0,0,0,0.85)\";\n    const secondaryTextColor = isDarkMode ? \"rgba(255,255,255,0.45)\" : \"rgba(0,0,0,0.45)\";\n    const sectionBorder = isDarkMode ? \"rgba(255,255,255,0.1)\" : \"rgba(0,0,0,0.06)\";\n\n    return (\n        <Modal\n            open={visible}\n            onCancel={onClose}\n            footer={null}\n            width={900}\n            centered\n            destroyOnClose\n            closeIcon={null}\n            modalRender={(modal) => (\n                <div style={{\n                    background: glassBg,\n                    backdropFilter: \"blur(20px)\",\n                    WebkitBackdropFilter: \"blur(20px)\",\n                    borderRadius: 24,\n                    boxShadow: isDarkMode ? \"0 25px 50px -12px rgba(0, 0, 0, 0.5)\" : \"0 25px 50px -12px rgba(0, 0, 0, 0.1)\",\n                    border: `1px solid ${glassBorder}`,\n                    padding: 0,\n                    overflow: 'hidden'\n                }}>\n                    {modal}\n                </div>\n            )}\n            styles={{\n                content: { background: 'transparent', padding: 0, boxShadow: 'none' },\n                body: { padding: 0 },\n                container: { padding: 0 }\n            }}\n        >\n            <div style={{ display: 'flex', height: '600px', flexDirection: 'column' }}>\n                {/* Header */}\n                <div style={{\n                    padding: '16px 24px',\n                    borderBottom: `1px solid ${sectionBorder}`,\n                    display: 'flex',\n                    justifyContent: 'space-between',\n                    alignItems: 'center',\n                }}>\n                    <div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 16, fontWeight: 600, color: textColor }}>\n                        <CodeOutlined style={{ color: token.colorPrimary }} /> SVG 转 PNG 工具\n                    </div>\n                    <Button\n                        type=\"text\"\n                        icon={<CloseOutlined />}\n                        onClick={onClose}\n                        style={{ color: secondaryTextColor }}\n                    />\n                </div>\n\n                {/* Content Body */}\n                <div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>\n                    {/* Left: Input */}\n                    <div style={{ flex: 1, display: 'flex', flexDirection: 'column', borderRight: `1px solid ${sectionBorder}` }}>\n                        <div style={{ flex: 1, position: 'relative' }}>\n                            <TextArea\n                                value={svgCode}\n                                onChange={(e) => setSvgCode(e.target.value)}\n                                placeholder=\"在此处粘贴 SVG 代码...\"\n                                style={{\n                                    height: '100%',\n                                    resize: 'none',\n                                    background: 'transparent',\n                                    border: 'none',\n                                    color: textColor,\n                                    padding: '20px',\n                                    fontFamily: 'Menlo, Monaco, \"Courier New\", monospace',\n                                    fontSize: '13px',\n                                    lineHeight: '1.5'\n                                }}\n                            />\n                            {!svgCode && (\n                                <div style={{\n                                    position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,\n                                    display: 'flex', alignItems: 'center', justifyContent: 'center',\n                                    pointerEvents: 'none'\n                                }}>\n                                    <Upload {...uploadProps}>\n                                        <div style={{\n                                            pointerEvents: 'auto',\n                                            textAlign: 'center',\n                                            padding: '40px',\n                                            borderRadius: 12,\n                                            border: `2px dashed ${isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}`,\n                                            cursor: 'pointer',\n                                            transition: 'all 0.3s'\n                                        }}\n                                            className=\"upload-area\"\n                                        >\n                                            <p style={{ fontSize: 32, color: token.colorPrimary, marginBottom: 16 }}>\n                                                <InboxOutlined />\n                                            </p>\n                                            <p style={{ color: textColor, marginBottom: 4 }}>点击或拖拽 SVG 文件</p>\n                                            <p style={{ color: secondaryTextColor, fontSize: 12 }}>支持 .svg 文件或直接粘贴代码</p>\n                                        </div>\n                                    </Upload>\n                                </div>\n                            )}\n                        </div>\n                        {/* Toolbar for Input */}\n                        <div style={{ padding: '12px 24px', borderTop: `1px solid ${sectionBorder}`, display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: isDarkMode ? \"rgba(0,0,0,0.1)\" : \"rgba(0,0,0,0.02)\" }}>\n                            <Text style={{ fontSize: 12, color: secondaryTextColor }}>\n                                {svgCode ? `${svgCode.length} 字符` : \"等待输入...\"}\n                            </Text>\n                            <Space>\n                                <Upload {...uploadProps}>\n                                    <Button size=\"small\" icon={<FileTextOutlined />}>导入文件</Button>\n                                </Upload>\n                                {svgCode && (\n                                    <Button size=\"small\" danger type=\"text\" onClick={() => { setSvgCode(\"\"); setUploadedUrl(\"\"); }}>清空</Button>\n                                )}\n                            </Space>\n                        </div>\n                    </div>\n\n                    {/* Right: Preview */}\n                    <div style={{ flex: 1, display: 'flex', flexDirection: 'column', background: isDarkMode ? \"rgba(0,0,0,0.2)\" : \"rgba(0,0,0,0.04)\" }}>\n                        <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '24px', overflow: 'hidden', position: 'relative' }}>\n                            {/* Checkerboard background */}\n                            <div style={{\n                                position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 0,\n                                backgroundImage: `linear-gradient(45deg, ${isDarkMode ? '#333' : '#e0e0e0'} 25%, transparent 25%), linear-gradient(-45deg, ${isDarkMode ? '#333' : '#e0e0e0'} 25%, transparent 25%), linear-gradient(45deg, transparent 75%, ${isDarkMode ? '#333' : '#e0e0e0'} 75%), linear-gradient(-45deg, transparent 75%, ${isDarkMode ? '#333' : '#e0e0e0'} 75%)`,\n                                backgroundSize: '20px 20px',\n                                backgroundPosition: '0 0, 0 10px, 10px -10px, -10px 0px',\n                                opacity: 0.1\n                            }} />\n\n                            {isConverting ? (\n                                <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', color: token.colorPrimary }}>\n                                    <div className=\"ant-spin ant-spin-spinning\" style={{ marginBottom: 16 }}>\n                                        <span className=\"ant-spin-dot ant-spin-dot-spin\">\n                                            <i className=\"ant-spin-dot-item\"></i>\n                                            <i className=\"ant-spin-dot-item\"></i>\n                                            <i className=\"ant-spin-dot-item\"></i>\n                                            <i className=\"ant-spin-dot-item\"></i>\n                                        </span>\n                                    </div>\n                                    <div>正在转换...</div>\n                                </div>\n                            ) : pngDataUrl ? (\n                                <img\n                                    src={pngDataUrl}\n                                    alt=\"Preview\"\n                                    style={{\n                                        maxWidth: '100%',\n                                        maxHeight: '100%',\n                                        objectFit: 'contain',\n                                        zIndex: 1,\n                                        boxShadow: '0 10px 30px rgba(0,0,0,0.2)'\n                                    }}\n                                />\n                            ) : (\n                                <div style={{ textAlign: 'center', color: secondaryTextColor }}>\n                                    <PictureOutlined style={{ fontSize: 48, marginBottom: 16, opacity: 0.5 }} />\n                                    <div>预览区域</div>\n                                </div>\n                            )}\n                        </div>\n\n                        {/* Actions */}\n                        <div style={{ padding: '24px', borderTop: `1px solid ${sectionBorder}`, display: 'flex', flexDirection: 'column', gap: 16, background: isDarkMode ? \"rgba(0,0,0,0.2)\" : \"#fff\" }}>\n                            {uploadedUrl ? (\n                                <div style={{\n                                    background: isDarkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)',\n                                    padding: '12px 16px',\n                                    borderRadius: 8,\n                                    display: 'flex',\n                                    alignItems: 'center',\n                                    gap: 12,\n                                    border: `1px solid ${token.colorBorder}`\n                                }}>\n                                    <div style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: token.colorText, fontSize: 13 }}>\n                                        {uploadedUrl}\n                                    </div>\n                                    <Tooltip title=\"复制链接\">\n                                        <Button type=\"text\" icon={<CopyOutlined />} size=\"small\" onClick={() => {\n                                            navigator.clipboard.writeText(uploadedUrl);\n                                            message.success(\"已复制\");\n                                        }} />\n                                    </Tooltip>\n                                </div>\n                            ) : (\n                                <Row gutter={12}>\n                                    <Col span={12}>\n                                        <Button\n                                            type=\"primary\"\n                                            block\n                                            size=\"large\"\n                                            icon={<DownloadOutlined />}\n                                            disabled={!pngDataUrl}\n                                            onClick={handleDownload}\n                                            style={{ height: 44 }}\n                                        >\n                                            下载 PNG\n                                        </Button>\n                                    </Col>\n                                    <Col span={12}>\n                                        <Button\n                                            block\n                                            size=\"large\"\n                                            icon={<UploadOutlined />}\n                                            disabled={!pngDataUrl}\n                                            loading={isUploading}\n                                            onClick={handleUpload}\n                                            style={{ height: 44 }}\n                                        >\n                                            上传图床\n                                        </Button>\n                                    </Col>\n                                </Row>\n                            )}\n                        </div>\n                    </div>\n                </div>\n\n                {/* Hidden Canvas */}\n                <canvas ref={canvasRef} style={{ display: 'none' }} />\n            </div>\n        </Modal>\n    );\n};\n\nexport default SvgToolModal;\n"
  },
  {
    "path": "client/src/components/ThemeSwitcher.js",
    "content": "import React, { useState, useEffect } from \"react\";\nimport { Button, Dropdown } from \"antd\";\nimport { SunOutlined, MoonOutlined, SyncOutlined } from \"@ant-design/icons\";\n\nconst THEME_KEY = \"theme\";\nconst AUTO_KEY = \"themeAutoMode\";\n\nconst ThemeSwitcher = ({ theme, onThemeChange }) => {\n  const [autoMode, setAutoMode] = useState(false);\n\n  // 加载自动模式\n  useEffect(() => {\n    const savedAutoMode = localStorage.getItem(AUTO_KEY);\n    if (savedAutoMode !== null) {\n      setAutoMode(JSON.parse(savedAutoMode));\n    }\n  }, []);\n\n  // 自动模式定时器\n  useEffect(() => {\n    if (!autoMode) return;\n    const checkTimeAndUpdateTheme = () => {\n      const hour = new Date().getHours();\n      const shouldBeDark = hour < 6 || hour >= 18;\n      const newTheme = shouldBeDark ? \"dark\" : \"light\";\n      if (theme !== newTheme) {\n        onThemeChange(newTheme);\n        localStorage.setItem(THEME_KEY, newTheme);\n      }\n    };\n    checkTimeAndUpdateTheme();\n    const interval = setInterval(checkTimeAndUpdateTheme, 60000);\n    return () => clearInterval(interval);\n  }, [autoMode, theme, onThemeChange]);\n\n  // 菜单点击\n  const handleMenuClick = ({ key }) => {\n    if (key === \"auto\") {\n      setAutoMode(true);\n      localStorage.setItem(AUTO_KEY, \"true\");\n      // 立即切换一次\n      const hour = new Date().getHours();\n      const shouldBeDark = hour < 6 || hour >= 18;\n      const newTheme = shouldBeDark ? \"dark\" : \"light\";\n      onThemeChange(newTheme);\n      localStorage.setItem(THEME_KEY, newTheme);\n    } else {\n      setAutoMode(false);\n      localStorage.setItem(AUTO_KEY, \"false\");\n      onThemeChange(key);\n      localStorage.setItem(THEME_KEY, key);\n    }\n  };\n\n  // 当前高亮\n  const selectedKey = autoMode ? \"auto\" : theme;\n\n  const items = [\n    {\n      key: \"light\",\n      icon: (\n        <SunOutlined\n          style={{ color: selectedKey === \"light\" ? \"#1677ff\" : undefined }}\n        />\n      ),\n      label: (\n        <span\n          style={{ color: selectedKey === \"light\" ? \"#1677ff\" : undefined }}\n        >\n          浅色主题\n        </span>\n      ),\n    },\n    {\n      key: \"dark\",\n      icon: (\n        <MoonOutlined\n          style={{ color: selectedKey === \"dark\" ? \"#1677ff\" : undefined }}\n        />\n      ),\n      label: (\n        <span style={{ color: selectedKey === \"dark\" ? \"#1677ff\" : undefined }}>\n          暗色主题\n        </span>\n      ),\n    },\n    {\n      key: \"auto\",\n      icon: (\n        <SyncOutlined\n          style={{ color: selectedKey === \"auto\" ? \"#1677ff\" : undefined }}\n        />\n      ),\n      label: (\n        <span style={{ color: selectedKey === \"auto\" ? \"#1677ff\" : undefined }}>\n          自动切换\n        </span>\n      ),\n    },\n  ];\n\n  const getThemeIcon = () => {\n    if (autoMode) return <SyncOutlined />;\n    return theme === \"dark\" ? <MoonOutlined /> : <SunOutlined />;\n  };\n  const getThemeText = () => {\n    if (autoMode) return \"自动切换\";\n    return theme === \"dark\" ? \"暗色主题\" : \"浅色主题\";\n  };\n\n  return (\n    <Dropdown\n      menu={{\n        items,\n        selectable: true,\n        selectedKeys: [selectedKey],\n        onClick: handleMenuClick,\n      }}\n      placement=\"bottomRight\"\n      trigger={[\"click\"]}\n    >\n      <Button type=\"text\" icon={getThemeIcon()} style={{ color: \"inherit\" }}>\n        {getThemeText()}\n      </Button>\n    </Dropdown>\n  );\n};\n\nexport default ThemeSwitcher;\n"
  },
  {
    "path": "client/src/components/TrafficDashboard.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport ReactECharts from 'echarts-for-react';\nimport { Card, Row, Col, Typography, Table, Spin, message, Empty, Segmented } from 'antd';\nimport { AreaChartOutlined, FireOutlined, EyeOutlined, VideoCameraOutlined } from '@ant-design/icons';\nimport api from '../utils/api';\nimport dayjs from 'dayjs';\n\nconst { Title, Text } = Typography;\n\nconst TrafficDashboard = () => {\n    const [loading, setLoading] = useState(true);\n    const [trafficData, setTrafficData] = useState([]);\n    const [topImages, setTopImages] = useState([]);\n    const [days, setDays] = useState(30);\n\n    useEffect(() => {\n        const fetchData = async () => {\n            setLoading(true);\n            try {\n                const [trafficRes, topRes] = await Promise.all([\n                    api.get(`/stats/traffic?days=${days}`),\n                    api.get('/stats/top?limit=10')\n                ]);\n\n                if (trafficRes.data.success) {\n                    setTrafficData(trafficRes.data.data);\n                }\n                if (topRes.data.success) {\n                    setTopImages(topRes.data.data);\n                }\n            } catch (e) {\n                console.error(e);\n                message.error(\"获取统计数据失败\");\n            } finally {\n                setLoading(false);\n            }\n        };\n        fetchData();\n    }, [days]);\n\n    // Chart Configs\n    const trafficOption = {\n        title: { text: '流量与上传趋势', left: 'center' },\n        tooltip: { trigger: 'axis' },\n        legend: { data: ['访问流量 (MB)', '上传流量 (MB)', '访问次数', '上传次数'], bottom: 0 },\n        grid: { left: '3%', right: '4%', bottom: '10%', containLabel: true },\n        xAxis: { type: 'category', data: trafficData.map(d => d.date) },\n        yAxis: [\n            { type: 'value', name: '流量 (MB)', position: 'left' },\n            { type: 'value', name: '次数', position: 'right' }\n        ],\n        series: [\n            {\n                name: '访问流量 (MB)',\n                type: 'line',\n                smooth: true,\n                data: trafficData.map(d => (d.views_size / 1024 / 1024).toFixed(2)),\n                areaStyle: { opacity: 0.1 },\n                itemStyle: { color: '#52c41a' }\n            },\n            {\n                name: '上传流量 (MB)',\n                type: 'line',\n                smooth: true,\n                data: trafficData.map(d => (d.uploads_size / 1024 / 1024).toFixed(2)),\n                areaStyle: { opacity: 0.1 },\n                itemStyle: { color: '#1890ff' }\n            },\n            {\n                name: '访问次数',\n                type: 'bar',\n                yAxisIndex: 1,\n                data: trafficData.map(d => d.views_count),\n                itemStyle: { color: '#95de64', opacity: 0.5 }\n            },\n            {\n                name: '上传次数',\n                type: 'bar',\n                yAxisIndex: 1,\n                data: trafficData.map(d => d.uploads_count),\n                itemStyle: { color: '#69c0ff', opacity: 0.5 }\n            }\n        ]\n    };\n\n    const topImagesColumns = [\n        {\n            title: '图片',\n            dataIndex: 'url',\n            key: 'url',\n            render: (url, record) => {\n                const isVideo = /\\.(mp4|webm|mov)$/i.test(record.filename);\n                if (isVideo) {\n                    return (\n                        <div style={{ height: 40, width: 40, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.05)', borderRadius: 4 }}>\n                            <VideoCameraOutlined style={{ fontSize: 20, color: '#1890ff' }} />\n                        </div>\n                    );\n                }\n                return <img src={url} alt=\"preview\" style={{ height: 40, borderRadius: 4 }} />;\n            }\n        },\n        {\n            title: '文件名',\n            dataIndex: 'filename',\n            key: 'filename',\n            ellipsis: true,\n        },\n        {\n            title: '浏览量',\n            dataIndex: 'views',\n            key: 'views',\n            sorter: (a, b) => a.views - b.views,\n            defaultSortOrder: 'descend',\n            render: (v) => <Text strong><EyeOutlined /> {v}</Text>\n        },\n        {\n            title: '上传时间',\n            dataIndex: 'uploadTime',\n            key: 'uploadTime',\n            render: (t) => dayjs(t).format('YYYY-MM-DD HH:mm')\n        }\n    ];\n\n    if (loading && trafficData.length === 0) {\n        return <div style={{ padding: 40, textAlign: 'center' }}><Spin size=\"large\" /></div>;\n    }\n\n    return (\n        <div style={{ maxWidth: 1200, margin: '0 auto', padding: '20px' }}>\n            <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>\n                <Title level={2} style={{ margin: 0 }}><AreaChartOutlined /> 流量看板</Title>\n                <Segmented\n                    options={[\n                        { label: '近 7 天', value: 7 },\n                        { label: '近 30 天', value: 30 },\n                        { label: '近 90 天', value: 90 }\n                    ]}\n                    value={days}\n                    onChange={setDays}\n                />\n            </div>\n\n            <Row gutter={[20, 20]}>\n                <Col span={24}>\n                    <Card style={{ borderRadius: 12, boxShadow: '0 4px 12px rgba(0,0,0,0.05)' }}>\n                        {trafficData.length > 0 ? (\n                            <ReactECharts option={trafficOption} style={{ height: 400 }} />\n                        ) : (\n                            <Empty description=\"暂无流量数据\" />\n                        )}\n                    </Card>\n                </Col>\n\n                <Col span={24}>\n                    <Card\n                        title={<><FireOutlined style={{ color: '#ff4d4f' }} /> 热门图片 (Top 10)</>}\n                        style={{ borderRadius: 12, boxShadow: '0 4px 12px rgba(0,0,0,0.05)' }}\n                    >\n                        <Table\n                            dataSource={topImages}\n                            columns={topImagesColumns}\n                            rowKey=\"relPath\"\n                            pagination={false}\n                            size=\"small\"\n                        />\n                    </Card>\n                </Col>\n            </Row>\n        </div>\n    );\n};\n\nexport default TrafficDashboard;\n"
  },
  {
    "path": "client/src/components/UploadComponent.js",
    "content": "import React, { useState, useEffect } from \"react\";\nimport {\n  Upload,\n  Button,\n  message,\n  Card,\n  Typography,\n  Space,\n  Tag,\n  Progress,\n  Row,\n  Col,\n  theme,\n  Grid,\n  Tabs,\n  Input,\n} from \"antd\";\nimport { InboxOutlined, CheckCircleOutlined, CloseOutlined, CopyOutlined, LinkOutlined } from \"@ant-design/icons\";\nimport DirectorySelector from \"./DirectorySelector\";\n\nconst { Dragger } = Upload;\nconst { Title, Text } = Typography;\n\nfunction sanitizeDir(input) {\n  let dir = (input || \"\").trim().replace(/\\\\+/g, \"/\").replace(/\\/+/g, \"/\");\n  dir = dir.replace(/\\/+$/, \"\"); // 去除末尾斜杠\n  dir = dir.replace(/^\\/+/, \"\"); // 去除开头斜杠\n  dir = dir.replace(/\\/+/, \"/\"); // 合并多余斜杠\n  return dir;\n}\n\n// 并发请求限制器\nclass ConcurrencyLimiter {\n  constructor(limit) {\n    this.limit = limit;\n    this.active = 0;\n    this.queue = [];\n  }\n\n  add(task) {\n    return new Promise((resolve, reject) => {\n      this.queue.push(() => task().then(resolve).catch(reject));\n      this.next();\n    });\n  }\n\n  next() {\n    if (this.active < this.limit && this.queue.length > 0) {\n      const task = this.queue.shift();\n      this.active++;\n      task().finally(() => {\n        this.active--;\n        this.next();\n      });\n    }\n  }\n}\nconst uploadLimiter = new ConcurrencyLimiter(5); // 限制并发数为5\n\nconst UploadComponent = ({ onUploadSuccess, api, isModal }) => {\n  const {\n    token: { colorBgContainer },\n  } = theme.useToken();\n  const { useBreakpoint } = Grid;\n  const screens = useBreakpoint();\n  const isMobile = !screens.md;\n  const isDarkMode = theme.useToken().theme?.id === 1 || colorBgContainer === \"#141414\" || colorBgContainer === \"#000000\" || colorBgContainer === \"#1f1f1f\";\n\n  const [uploadQueue, setUploadQueue] = useState([]);\n  const [uploadedFiles, setUploadedFiles] = useState([]);\n  // Separate list for currently completed session uploads to show in overlay\n  const [sessionUploadedFiles, setSessionUploadedFiles] = useState([]);\n  const [dir, setDir] = useState(\"\");\n  const [urlInput, setUrlInput] = useState(\"\");\n  const [config, setConfig] = useState({\n    allowedExtensions: [\n      \".jpg\",\n      \".jpeg\",\n      \".png\",\n      \".gif\",\n      \".webp\",\n      \".bmp\",\n      \".svg\",\n    ],\n    maxFileSize: 10 * 1024 * 1024,\n    maxFileSizeMB: 10,\n    allowedFormats: \"JPG, JPEG, PNG, GIF, WEBP, BMP, SVG\",\n  });\n\n  const uploading = uploadQueue.some(item => item.status === 'pending' || item.status === 'uploading');\n\n  // 获取配置\n  useEffect(() => {\n    const fetchConfig = async () => {\n      try {\n        const response = await api.get(\"/config\");\n        if (response.data.success) {\n          setConfig(response.data.data.upload);\n        }\n      } catch (error) {\n        console.warn(\"获取配置失败，使用默认配置:\", error);\n      }\n    };\n    fetchConfig();\n  }, [api]);\n\n  const updateQueueItem = (uid, updates) => {\n    setUploadQueue((prev) =>\n      prev.map((item) => (item.uid === uid ? { ...item, ...updates } : item))\n    );\n  };\n\n  // 上传文件的通用方法\n  const uploadFile = React.useCallback(async (file, onProgress) => {\n    let safeDir = sanitizeDir(dir);\n    if (safeDir.includes(\"..\")) {\n      throw new Error(\"目录不能包含 .. 等非法字符\");\n    }\n\n    const formData = new FormData();\n\n    // 确保文件名编码正确，特别是中文文件名\n    const fileName = file.name;\n    formData.append(\"image\", file, fileName);\n\n    const url = safeDir\n      ? `/upload?dir=${encodeURIComponent(safeDir)}`\n      : \"/upload\";\n\n    try {\n      const response = await api.post(url, formData, {\n        timeout: 0, // 取消单次上传超时限制，防止大文件长连接断开\n        headers: {\n          \"Content-Type\": \"multipart/form-data\",\n        },\n        onUploadProgress: (progressEvent) => {\n          const percentCompleted = Math.round(\n            (progressEvent.loaded * 100) / progressEvent.total\n          );\n          if (onProgress) onProgress(percentCompleted);\n        },\n      });\n\n      if (response.data.success) {\n        const fileData = response.data.data;\n        setUploadedFiles((prev) => [...prev, fileData]);\n        // Add to session uploaded files for the result view\n        setSessionUploadedFiles(prev => [...prev, fileData]);\n        message.success(`${fileName} 上传成功！`);\n        if (onUploadSuccess) {\n          onUploadSuccess();\n        }\n      } else {\n        throw new Error(response.data.error || \"上传失败\");\n      }\n    } catch (error) {\n      const msg = error?.response?.data?.error || error.message || \"上传失败\";\n      message.error(msg);\n      throw new Error(msg);\n    }\n  }, [dir, api, onUploadSuccess]);\n\n  // 处理粘贴的图片/视频\n  const handlePastedImage = React.useCallback(async (file) => {\n    // 验证文件类型\n    const isAllowedType = file.type.startsWith(\"image/\") || file.type.startsWith(\"video/\");\n    if (!isAllowedType) {\n      const allowedExts = config.allowedExtensions.join(\", \");\n      message.error(`不支持的文件类型！仅支持: ${allowedExts}`);\n      return;\n    }\n\n    // 验证文件大小\n    const isLtMax = file.size <= config.maxFileSize;\n    if (!isLtMax) {\n      message.error(`文件大小不能超过${config.maxFileSizeMB}MB！`);\n      return;\n    }\n\n    // 生成文件名\n    const timestamp = new Date().getTime();\n    const extension = file.type.split(\"/\")[1] || \"unknown\";\n    const fileName = `pasted-file-${timestamp}.${extension}`;\n\n    // 创建新的File对象，设置文件名\n    const renamedFile = new File([file], fileName, { type: file.type });\n    // 添加uid\n    renamedFile.uid = `pasted-${timestamp}`;\n\n    // 添加到队列\n    setUploadQueue(prev => [...prev, { uid: renamedFile.uid, name: fileName, progress: 0, status: 'pending' }]);\n\n    // 上传文件\n    try {\n      await uploadLimiter.add(async () => {\n        updateQueueItem(renamedFile.uid, { status: 'uploading' });\n        await uploadFile(renamedFile, (progress) => {\n          updateQueueItem(renamedFile.uid, { progress, status: 'uploading' });\n        });\n      });\n      updateQueueItem(renamedFile.uid, { progress: 100, status: 'success' });\n    } catch (error) {\n      updateQueueItem(renamedFile.uid, { status: 'error', errorMsg: error.message });\n    }\n  }, [config, uploadFile]);\n\n  // 处理粘贴事件\n  const handlePaste = React.useCallback(async (event) => {\n    const items = event.clipboardData?.items;\n    if (!items) return;\n\n    for (let i = 0; i < items.length; i++) {\n      const item = items[i];\n      if (item.type.startsWith(\"image/\") || item.type.startsWith(\"video/\")) {\n        // Clear previous queue for new paste action\n        setUploadQueue([]);\n        setSessionUploadedFiles([]);\n\n        event.preventDefault();\n        const file = item.getAsFile();\n        if (file) {\n          await handlePastedImage(file);\n        }\n        break;\n      }\n    }\n  }, [handlePastedImage]);\n\n  // Handle URL upload\n  const handleUrlUpload = React.useCallback(async () => {\n    const url = urlInput.trim();\n    if (!url) {\n      message.warning(\"请输入图片 URL\");\n      return;\n    }\n\n    const imageExtPattern = /\\.(jpg|jpeg|png|gif|webp|bmp|svg)(\\?.*)?$/i;\n    if (!imageExtPattern.test(url)) {\n      message.warning(\"URL 必须指向图片文件\");\n      return;\n    }\n\n    if (!url.match(/^https?:\\/\\//)) {\n      message.warning(\"请输入有效的 HTTP/HTTPS URL\");\n      return;\n    }\n\n    // Clear previous queue\n    setUploadQueue([]);\n    setSessionUploadedFiles([]);\n\n    // Add to queue for UI feedback\n    const uid = `url-upload-${Date.now()}`;\n    const filename = url.split('/').pop() || 'url-image';\n    setUploadQueue([{ uid, name: filename, progress: 0, status: 'uploading' }]);\n\n    try {\n      const response = await api.post('/upload-url', { url, dir });\n      if (response.data?.success) {\n        setUploadQueue([{ uid, name: filename, progress: 100, status: 'success' }]);\n        setSessionUploadedFiles([response.data.data]);\n        message.success(\"上传成功\");\n        if (onUploadSuccess) {\n          onUploadSuccess();\n        }\n      } else {\n        throw new Error(response.data?.error || '上传失败');\n      }\n    } catch (error) {\n      console.error('URL upload error:', error);\n      setUploadQueue([{ uid, name: filename, status: 'error', errorMsg: error.message || '上传出错' }]);\n      message.error(error?.response?.data?.error || error.message || 'URL 上传失败');\n    }\n\n    setUrlInput(\"\");\n  }, [urlInput, dir, api, onUploadSuccess]);\n\n  // 添加全局粘贴事件监听\n  useEffect(() => {\n    const handleGlobalPaste = (event) => {\n      // 检查是否在输入框中，如果是则不处理粘贴\n      const target = event.target;\n      if (\n        target.tagName === \"INPUT\" ||\n        target.tagName === \"TEXTAREA\" ||\n        target.contentEditable === \"true\"\n      ) {\n        return;\n      }\n\n      handlePaste(event);\n    };\n\n    document.addEventListener(\"paste\", handleGlobalPaste);\n\n    return () => {\n      document.removeEventListener(\"paste\", handleGlobalPaste);\n    };\n  }, [handlePaste]);\n\n  const uploadProps = {\n    name: \"image\",\n    multiple: true,\n    accept: config.allowedExtensions\n      .map((ext) => {\n        const extName = ext.replace(\".\", \"\");\n        if (['mp4', 'webm', 'ogg'].includes(extName)) {\n          return `video/${extName}`;\n        }\n        return `image/${extName}`;\n      })\n      .join(\",\"),\n    beforeUpload: (file) => {\n      const isAllowed = file.type.startsWith(\"image/\") || file.type.startsWith(\"video/\");\n      if (!isAllowed) {\n        message.error(\"不支持的文件类型！\");\n        return false;\n      }\n      const isLtMax = file.size <= config.maxFileSize;\n      if (!isLtMax) {\n        message.error(`文件大小不能超过${config.maxFileSizeMB}MB！`);\n        return false;\n      }\n      return true;\n    },\n    customRequest: async ({ file, onSuccess, onError }) => {\n      const uid = file.uid;\n      setUploadQueue(prev => [...prev, { uid, name: file.name, progress: 0, status: 'pending' }]);\n\n      try {\n        await uploadLimiter.add(async () => {\n          updateQueueItem(uid, { status: 'uploading' });\n          await uploadFile(file, (progress) => {\n            updateQueueItem(uid, { progress, status: 'uploading' });\n          });\n        });\n        updateQueueItem(uid, { progress: 100, status: 'success' });\n        onSuccess();\n      } catch (error) {\n        updateQueueItem(uid, { status: 'error', errorMsg: error.message });\n        onError(error);\n      }\n    },\n  };\n\n  const copyToClipboard = (text) => {\n    if (navigator.clipboard && window.isSecureContext) {\n      navigator.clipboard\n        .writeText(text)\n        .then(() => message.success(\"链接已复制到剪贴板\"))\n        .catch(() => message.error(\"复制失败\"));\n      return;\n    }\n    const input = document.createElement(\"input\");\n    input.style.position = \"fixed\";\n    input.style.top = \"-10000px\";\n    input.style.zIndex = \"-999\";\n    document.body.appendChild(input);\n    input.value = text;\n    input.focus();\n    input.select();\n    try {\n      const ok = document.execCommand(\"copy\");\n      document.body.removeChild(input);\n      if (!ok) {\n        message.error(\"复制失败\");\n      } else {\n        message.success(\"链接已复制到剪贴板\");\n      }\n    } catch (e) {\n      document.body.removeChild(input);\n      message.error(\"当前浏览器不支持复制功能\");\n    }\n  };\n\n  const generateLinks = (type) => {\n    return sessionUploadedFiles.map(file => {\n      const fullUrl = `${window.location.origin}${file.url}`;\n      switch (type) {\n        case 'markdown':\n          return `![${file.originalName}](${fullUrl})`;\n        case 'html':\n          return `<img src=\"${fullUrl}\" alt=\"${file.originalName}\" />`;\n        case 'url':\n        default:\n          return fullUrl;\n      }\n    }).join('\\n');\n  };\n\n  const UploadResult = () => {\n    const [activeTab, setActiveTab] = useState('url');\n    const content = generateLinks(activeTab);\n\n    const items = [\n      { key: 'url', label: 'URL' },\n      { key: 'markdown', label: 'Markdown' },\n      { key: 'html', label: 'HTML' },\n    ];\n\n    return (\n      <div style={{ marginTop: 16, background: isDarkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.02)', padding: 12, borderRadius: 8 }}>\n        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>\n          <Tabs\n            activeKey={activeTab}\n            onChange={setActiveTab}\n            items={items}\n            size=\"small\"\n            style={{ marginBottom: 0 }}\n            tabBarStyle={{ marginBottom: 0, borderBottom: 'none' }}\n          />\n          <Button\n            type=\"primary\"\n            size=\"small\"\n            icon={<CopyOutlined />}\n            onClick={() => copyToClipboard(content)}\n          >\n            一键复制\n          </Button>\n        </div>\n        <Input.TextArea\n          value={content}\n          autoSize={{ minRows: 3, maxRows: 6 }}\n          readOnly\n          style={{\n            fontFamily: 'monospace',\n            fontSize: 12,\n            background: isDarkMode ? '#141414' : '#fff',\n            color: isDarkMode ? 'rgba(255,255,255,0.85)' : undefined\n          }}\n        />\n      </div>\n    );\n  };\n\n  const formatFileSize = (bytes) => {\n    if (bytes === 0) return \"0 Bytes\";\n    const k = 1024;\n    const sizes = [\"Bytes\", \"KB\", \"MB\", \"GB\"];\n    const i = Math.floor(Math.log(bytes) / Math.log(k));\n    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + \" \" + sizes[i];\n  };\n\n  return (\n    <div style={{ padding: isModal ? '24px' : 0 }}>\n      <Title\n        level={isMobile ? 4 : 3}\n        style={{\n          marginTop: 0,\n          marginBottom: 16,\n          textAlign: isModal ? 'center' : 'left',\n          color: isModal && !isDarkMode ? 'rgba(0,0,0,0.85)' : isModal ? '#fff' : undefined\n        }}\n      >\n        上传图片\n      </Title>\n\n      <Space\n        direction=\"vertical\"\n        style={{ width: \"100%\", marginBottom: isMobile ? 12 : 16 }}\n        size={isMobile ? \"small\" : \"middle\"}\n      >\n        <DirectorySelector\n          value={dir}\n          onChange={setDir}\n          placeholder=\"选择或输入子目录（可选）\"\n          api={api}\n          style={{\n            background: isModal ? (isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)') : undefined,\n            color: isModal && !isDarkMode ? 'rgba(0,0,0,0.85)' : undefined\n          }}\n        />\n      </Space>\n\n      {/* URL Upload Input */}\n      <Card\n        style={{\n          marginBottom: isMobile ? 16 : 16,\n          background: isModal ? (isDarkMode ? 'rgba(255,255,255,0.05)' : 'rgba(255,255,255,0.4)') : undefined,\n          border: isModal ? (isDarkMode ? '1px dashed rgba(255,255,255,0.2)' : '1px dashed rgba(0,0,0,0.2)') : undefined\n        }}\n        bordered={!isModal}\n        styles={{ body: { padding: isModal ? '12px' : '16px' } }}\n      >\n        <Space.Compact style={{ width: '100%' }}>\n          <Input\n            placeholder=\"粘贴图片 URL（支持 JPG、PNG、GIF 等格式）\"\n            prefix={<LinkOutlined style={{ color: isDarkMode ? 'rgba(255,255,255,0.45)' : undefined }} />}\n            value={urlInput}\n            onChange={(e) => setUrlInput(e.target.value)}\n            onPressEnter={handleUrlUpload}\n            disabled={uploading}\n            style={{\n              background: isModal ? (isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)') : undefined\n            }}\n          />\n          <Button\n            type=\"primary\"\n            onClick={handleUrlUpload}\n            disabled={uploading || !urlInput.trim()}\n            loading={uploading}\n          >\n            上传\n          </Button>\n        </Space.Compact>\n      </Card>\n\n      <Card\n        style={{\n          marginBottom: isMobile ? 16 : 24,\n          background: isModal ? (isDarkMode ? 'rgba(255,255,255,0.05)' : 'rgba(255,255,255,0.4)') : undefined,\n          border: isModal ? (isDarkMode ? '1px dashed rgba(255,255,255,0.2)' : '1px dashed rgba(0,0,0,0.2)') : undefined\n        }}\n        bordered={!isModal}\n        styles={{ body: { padding: isModal ? '16px' : '24px' } }}\n      >\n        <Dragger {...uploadProps} disabled={uploading} style={{ background: 'transparent', border: 'none' }}>\n          <p className=\"ant-upload-drag-icon\">\n            <InboxOutlined style={{ color: isModal ? (isDarkMode ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.6)') : undefined }} />\n          </p>\n          <p className=\"ant-upload-text\" style={{ color: isModal ? (isDarkMode ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.85)') : undefined, fontSize: 14 }}>\n            点击或拖拽图片到此区域\n          </p>\n          <p\n            style={{\n              fontSize: isMobile ? \"11px\" : \"12px\",\n              color: isModal ? (isDarkMode ? \"rgba(255,255,255,0.45)\" : \"rgba(0,0,0,0.45)\") : \"#999\",\n              marginTop: \"8px\",\n              marginBottom: \"0\",\n            }}\n          >\n            支持 {config.allowedFormats} 格式，最大 {config.maxFileSizeMB}MB\n          </p>\n        </Dragger>\n      </Card>\n\n      {/* Full Page Upload Overlay */}\n      {uploadQueue.length > 0 && (\n        <div style={{\n          position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,\n          backgroundColor: 'rgba(0,0,0,0.65)', backdropFilter: 'blur(4px)',\n          zIndex: 1005, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',\n          padding: '20px'\n        }}>\n          <div style={{\n            width: '100%', maxWidth: '600px',\n            background: isDarkMode ? '#1f1f1f' : '#fff',\n            borderRadius: '12px', padding: '24px',\n            boxShadow: '0 8px 32px rgba(0,0,0,0.3)',\n            maxHeight: '80vh', display: 'flex', flexDirection: 'column'\n          }}>\n            <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '16px', alignItems: 'center' }}>\n              <Title level={4} style={{ margin: 0, color: isDarkMode ? '#fff' : undefined }}>\n                正在上传 ({uploadQueue.filter(i => i.status === 'success').length}/{uploadQueue.length})\n              </Title>\n              <Button\n                type=\"text\"\n                icon={<CloseOutlined style={{ color: isDarkMode ? 'rgba(255,255,255,0.45)' : undefined }} />}\n                onClick={() => {\n                  setUploadQueue([]);\n                  setSessionUploadedFiles([]);\n                }}\n              />\n            </div>\n            <div style={{ overflowY: 'auto', flex: 1, paddingRight: '8px' }}>\n              {uploadQueue.map(item => (\n                <div key={item.uid} style={{ marginBottom: 12 }}>\n                  <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>\n                    <Text ellipsis style={{ maxWidth: '70%', color: isDarkMode ? '#fff' : undefined }}>{item.name}</Text>\n                    <Text type=\"secondary\" style={{ color: isDarkMode ? 'rgba(255,255,255,0.45)' : undefined }}>\n                      {item.status === 'error' ? '失败' : item.status === 'success' ? '完成' : item.status === 'pending' ? '排队中...' : `${item.progress}%`}\n                    </Text>\n                  </div>\n                  <Progress\n                    percent={item.progress}\n                    status={item.status === 'error' ? 'exception' : item.status === 'success' ? 'success' : 'active'}\n                    showInfo={false}\n                    size=\"small\"\n                    strokeColor={item.status === 'success' ? '#52c41a' : undefined}\n                  />\n                  {item.errorMsg && <Text type=\"danger\" style={{ fontSize: 12 }}>{item.errorMsg}</Text>}\n                </div>\n              ))}\n            </div>\n            {!uploading && (\n              <>\n                {sessionUploadedFiles.length > 0 && <UploadResult />}\n                <div style={{ textAlign: 'center', marginTop: '16px' }}>\n                  <Button type=\"primary\" onClick={() => {\n                    setUploadQueue([]);\n                    setSessionUploadedFiles([]);\n                  }}>\n                    关闭\n                  </Button>\n                </div>\n              </>\n            )}\n          </div>\n        </div>\n      )}\n\n      {uploadedFiles.length > 0 && (\n        <Card title=\"最近上传\" style={{ marginTop: isMobile ? 16 : 24 }}>\n          <Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 16]}>\n            {uploadedFiles\n              .slice(-6)\n              .reverse()\n              .map((file, index) => (\n                <Col xs={24} sm={12} md={8} lg={6} key={index}>\n                  <Card\n                    size=\"small\"\n                    hoverable\n                    cover={\n                      <img\n                        alt={file.originalName}\n                        src={file.url}\n                        style={{\n                          height: isMobile ? 100 : 120,\n                          objectFit: \"cover\",\n                        }}\n                      />\n                    }\n                    actions={[\n                      <Button\n                        type=\"text\"\n                        icon={<CheckCircleOutlined />}\n                        size={isMobile ? \"small\" : \"middle\"}\n                        onClick={() =>\n                          copyToClipboard(\n                            `${window.location.origin}${file.url}`\n                          )\n                        }\n                      >\n                        {isMobile ? \"复制\" : \"复制链接\"}\n                      </Button>,\n                    ]}\n                  >\n                    <Card.Meta\n                      title={\n                        <Text ellipsis style={{ maxWidth: \"100%\" }}>\n                          {file.originalName}\n                        </Text>\n                      }\n                      description={\n                        <Space direction=\"vertical\" size=\"small\">\n                          <Text\n                            type=\"secondary\"\n                            style={{ fontSize: isMobile ? \"11px\" : \"12px\" }}\n                          >\n                            {formatFileSize(file.size)}\n                          </Text>\n                          <Tag\n                            color=\"blue\"\n                            style={{ fontSize: isMobile ? \"11px\" : \"12px\" }}\n                          >\n                            {file.mimetype}\n                          </Tag>\n                        </Space>\n                      }\n                    />\n                  </Card>\n                </Col>\n              ))}\n          </Row>\n        </Card>\n      )}\n    </div>\n  );\n};\n\nexport default UploadComponent;"
  },
  {
    "path": "client/src/index.js",
    "content": "import React from \"react\";\nimport ReactDOM from \"react-dom/client\";\nimport App from \"./App\";\nimport { ConfigProvider } from \"antd\";\nimport zhCN from \"antd/locale/zh_CN\";\n\nconst root = ReactDOM.createRoot(document.getElementById(\"root\"));\nroot.render(\n  <React.StrictMode>\n    <ConfigProvider locale={zhCN}>\n      <App />\n    </ConfigProvider>\n  </React.StrictMode>\n);\n"
  },
  {
    "path": "client/src/utils/api.js",
    "content": "import axios from \"axios\";\nimport { getPassword, clearPassword } from \"./secureStorage\";\n\nconst isMock = process.env.REACT_APP_MOCK === \"true\";\n\n// Mock Data Generator\nconst generateMockImages = () => {\n  const images = [];\n  const dates = [0, 1, 2]; // Days offset from today (3 days)\n\n  let idCounter = 1;\n\n  dates.forEach((dayOffset) => {\n    // Generate 18-22 images per day (around 20)\n    const count = Math.floor(Math.random() * 5) + 18;\n    const baseTime = Date.now() - dayOffset * 24 * 60 * 60 * 1000;\n\n    for (let i = 0; i < count; i++) {\n      const width = Math.floor(Math.random() * (1600 - 1000) + 1000);\n      const height = Math.floor(Math.random() * (1200 - 800) + 800);\n      images.push({\n        relPath: `mock-image-${idCounter}.jpg`,\n        filename: `Mock Image ${idCounter}.jpg`,\n        url: `https://picsum.photos/${width}/${height}?random=${idCounter}`,\n        size: Math.floor(Math.random() * 5000000),\n        uploadTime: baseTime - Math.floor(Math.random() * 1000000), // Slightly vary time within day\n        thumbhash: null, // Optional\n      });\n      idCounter++;\n    }\n  });\n\n  return images.sort((a, b) => b.uploadTime - a.uploadTime);\n};\n\nconst mockImages = generateMockImages();\n\nconst mockAdapter = async (config) => {\n  return new Promise((resolve, reject) => {\n    const { url, method, params, data } = config;\n    const cleanUrl = url.replace(/^\\/api/, \"\");\n\n    console.log(`[Mock API] ${method.toUpperCase()} ${url}`, params || data);\n\n    setTimeout(() => {\n      // Auth Status\n      if (cleanUrl === \"/auth/status\" && method === \"get\") {\n        resolve({\n          data: { success: true, data: { enabled: true } },\n          status: 200,\n          statusText: \"OK\",\n          headers: {},\n          config,\n        });\n        return;\n      }\n\n      // Auth Verify\n      if (cleanUrl === \"/auth/login\" && method === \"post\") {\n        const body = JSON.parse(data);\n        if (body.password === \"123456\") {\n          resolve({\n            data: { success: true },\n            status: 200,\n            statusText: \"OK\",\n            headers: {},\n            config,\n          });\n        } else {\n          reject({\n            response: {\n              status: 401,\n              data: { success: false, error: \"密码错误\" },\n            },\n          });\n        }\n        return;\n      }\n\n      // Image List\n      if (cleanUrl === \"/images\" && method === \"get\") {\n        const page = params?.page || 1;\n        const pageSize = params?.pageSize || 10;\n        const start = (page - 1) * pageSize;\n        const end = start + pageSize;\n        const pageData = mockImages.slice(start, end);\n\n        resolve({\n          data: {\n            success: true,\n            data: pageData,\n            pagination: {\n              current: parseInt(page),\n              pageSize: parseInt(pageSize),\n              total: mockImages.length,\n              totalPages: Math.ceil(mockImages.length / pageSize),\n            },\n          },\n          status: 200,\n          statusText: \"OK\",\n          headers: {},\n          config,\n        });\n        return;\n      }\n\n      // Image Meta\n      if (cleanUrl.startsWith(\"/images/meta/\") && method === \"get\") {\n        resolve({\n          data: {\n            success: true,\n            data: {\n              width: 800,\n              height: 600,\n              space: \"sRGB\",\n              exif: {\n                make: \"Mock Camera\",\n                model: \"M-1\",\n                fNumber: 1.8,\n                exposureTime: \"1/1000\",\n                iso: 100,\n              },\n            },\n          },\n          status: 200,\n          statusText: \"OK\",\n          headers: {},\n          config,\n        });\n        return;\n      }\n\n      // Directories\n      // Directories\n      if (cleanUrl.split(\"?\")[0] === \"/directories\" && method === \"get\") {\n        const previews = mockImages.slice(0, 3).map(img => img.url);\n        resolve({\n          data: {\n            success: true,\n            data: [\n              { name: \"mock-dir-1\", path: \"mock-dir-1\", fullUrl: \"mock-dir-1\", previews, imageCount: 10, mtime: new Date() },\n              { name: \"mock-dir-2\", path: \"mock-dir-2\", fullUrl: \"mock-dir-2\", previews, imageCount: 5, mtime: new Date() },\n            ],\n          },\n          status: 200,\n          statusText: \"OK\",\n          headers: {},\n          config,\n        });\n        return;\n      }\n\n      // Create Directory\n      if (cleanUrl.split(\"?\")[0] === \"/directories\" && method === \"post\") {\n        resolve({\n          data: { success: true, message: \"Directory created (mock)\" },\n          status: 200,\n          statusText: \"OK\",\n          headers: {},\n          config,\n        });\n        return;\n      }\n\n      // Share Generate\n      if (cleanUrl === \"/share/generate\" && method === \"post\") {\n        resolve({\n          data: { success: true, token: \"mock-token-\" + Date.now() },\n          status: 200,\n          statusText: \"OK\",\n          headers: {},\n          config,\n        });\n        return;\n      }\n\n      // Share Access\n      if (cleanUrl.startsWith(\"/share/access\") && method === \"get\") {\n        resolve({\n          data: {\n            success: true,\n            data: mockImages.slice(0, 10),\n            dirName: \"Mock Share Album\"\n          },\n          status: 200,\n          statusText: \"OK\",\n          headers: {},\n          config,\n        });\n        return;\n      }\n\n      // Delete Image\n      if (cleanUrl.startsWith(\"/images/\") && method === \"delete\") {\n        resolve({\n          data: { success: true },\n          status: 200,\n          statusText: \"OK\",\n          headers: {},\n          config,\n        });\n        return;\n      }\n\n      // Update Image (Rename/Move)\n      if (cleanUrl.startsWith(\"/images/\") && method === \"put\") {\n        const body = JSON.parse(data);\n        const originalRelPath = decodeURIComponent(cleanUrl.split(\"/images/\")[1]);\n        const newName = body.newName;\n        // const newDir = body.newDir;\n\n        let updatedRelPath = originalRelPath;\n        let updatedFilename = originalRelPath.split(\"/\").pop();\n\n        if (newName) {\n          updatedFilename = newName;\n          const dir = originalRelPath.includes(\"/\") ? originalRelPath.substring(0, originalRelPath.lastIndexOf(\"/\")) : \"\";\n          updatedRelPath = dir ? `${dir}/${newName}` : newName;\n        }\n\n        resolve({\n          data: {\n            success: true,\n            data: {\n              relPath: updatedRelPath,\n              filename: updatedFilename,\n              url: `https://picsum.photos/800/600?random=${Math.random()}`, // Just return a valid obj\n              size: 1024,\n              uploadTime: Date.now(),\n              thumbhash: null\n            }\n          },\n          status: 200,\n          statusText: \"OK\",\n          headers: {},\n          config,\n        });\n        return;\n      }\n\n      // Upload\n      if (cleanUrl === \"/upload\" && method === \"post\") {\n        resolve({\n          data: { success: true, data: [] }, // Return empty or fake\n          status: 200,\n          statusText: \"OK\",\n          headers: {},\n          config,\n        });\n        return;\n      }\n\n      // System Health\n      if (cleanUrl === \"/health\" && method === \"get\") {\n        resolve({\n          data: { status: \"ok\" },\n          status: 200,\n          statusText: \"OK\",\n          headers: {},\n          config,\n        });\n        return;\n      }\n\n      // System Config\n      if (cleanUrl === \"/config\" && method === \"get\") {\n        resolve({\n          data: {\n            success: true,\n            data: {\n              upload: { maxFileSize: 104857600, allowedExtensions: [\".jpg\", \".png\", \".gif\", \".mp4\"] },\n              storage: { filename: { keepOriginalName: true } },\n              magicSearch: { enabled: true }\n            }\n          },\n          status: 200,\n          statusText: \"OK\",\n          headers: {},\n          config,\n        });\n        return;\n      }\n\n      // Stats Traffic\n      if (cleanUrl.startsWith(\"/stats/traffic\") && method === \"get\") {\n        const days = params?.days || 30;\n        const data = [];\n        for (let i = 0; i < days; i++) {\n          data.push({\n            date: new Date(Date.now() - i * 86400000).toISOString().split('T')[0],\n            views: Math.floor(Math.random() * 1000),\n            traffic: Math.floor(Math.random() * 50000000)\n          });\n        }\n        resolve({\n          data: { success: true, data: data.reverse() },\n          status: 200,\n          statusText: \"OK\",\n          headers: {},\n          config,\n        });\n        return;\n      }\n\n      // Stats Top\n      if (cleanUrl.startsWith(\"/stats/top\") && method === \"get\") {\n        resolve({\n          data: {\n            success: true,\n            data: mockImages.slice(0, 10).map(img => ({\n              ...img,\n              views: Math.floor(Math.random() * 5000)\n            }))\n          },\n          status: 200,\n          statusText: \"OK\",\n          headers: {},\n          config,\n        });\n        return;\n      }\n\n      // Semantic Search\n      if (cleanUrl === \"/search/semantic\" && method === \"post\") {\n        resolve({\n          data: {\n            success: true,\n            data: mockImages.slice(0, 8).map(img => ({\n              ...img,\n              score: Math.random()\n            }))\n          },\n          status: 200,\n          statusText: \"OK\",\n          headers: {},\n          config,\n        });\n        return;\n      }\n\n      // Batch Move\n      if (cleanUrl === \"/batch/move\" && method === \"post\") {\n        resolve({\n          data: { success: true, successCount: 1, failCount: 0 },\n          status: 200,\n          statusText: \"OK\",\n          headers: {},\n          config,\n        });\n        return;\n      }\n\n      // Map Data\n      if (cleanUrl === \"/map-data\" && method === \"get\") {\n        const mapImages = mockImages\n          .slice(0, 20)\n          .map((img, index) => ({\n            ...img,\n            // Random coordinates around central China for demo\n            lat: 30 + Math.random() * 10 - 5,\n            lng: 110 + Math.random() * 10 - 5,\n            thumbUrl: img.url,\n          }));\n\n        resolve({\n          data: {\n            success: true,\n            data: mapImages,\n          },\n          status: 200,\n          statusText: \"OK\",\n          headers: {},\n          config,\n        });\n        return;\n      }\n\n      // Default Success for others\n      resolve({\n        data: { success: true },\n        status: 200,\n        statusText: \"OK\",\n        headers: {},\n        config,\n      });\n    }, 300); // Simulate latency\n  });\n};\n\n// 创建axios实例\nconst api = axios.create({\n  baseURL: \"/api\",\n  timeout: 30000,\n  adapter: isMock ? mockAdapter : undefined,\n});\n\n// 请求拦截器 - 添加密码到请求头\napi.interceptors.request.use(\n  (config) => {\n    const password = getPassword();\n    if (password) {\n      config.headers[\"X-Access-Password\"] = password;\n    }\n    return config;\n  },\n  (error) => {\n    return Promise.reject(error);\n  }\n);\n\n// 响应拦截器 - 处理密码错误\napi.interceptors.response.use(\n  (response) => {\n    return response;\n  },\n  (error) => {\n    if (error.response && error.response.status === 401) {\n      clearPassword();\n      // Don't reload, let the app handle the auth state change\n      // window.location.reload(); \n    }\n    return Promise.reject(error);\n  }\n);\n\nexport default api;\n"
  },
  {
    "path": "client/src/utils/secureStorage.js",
    "content": "const STORAGE_KEY = \"cloudimgs_password\";\nconst SALT = \"cloudimgs-salt-2025\";\nconst EXPIRATION_TIME = 24 * 60 * 60 * 1000; // 24 hours in milliseconds\n\nfunction xorCipher(input) {\n  const salt = SALT;\n  let out = \"\";\n  for (let i = 0; i < input.length; i++) {\n    const code = input.charCodeAt(i) ^ salt.charCodeAt(i % salt.length);\n    out += String.fromCharCode(code);\n  }\n  return out;\n}\n\nexport function setPassword(plain) {\n  try {\n    const x = xorCipher(plain);\n    const b64 = btoa(x);\n    const data = {\n        value: `v1:${b64}`,\n        timestamp: Date.now()\n    };\n    localStorage.setItem(STORAGE_KEY, JSON.stringify(data));\n  } catch (e) {\n    // Fallback for simple string if something fails (though logic above is robust)\n    localStorage.setItem(STORAGE_KEY, JSON.stringify({\n        value: plain,\n        timestamp: Date.now()\n    }));\n  }\n}\n\nexport function getPassword() {\n  try {\n    const raw = localStorage.getItem(STORAGE_KEY);\n    if (!raw) return null;\n\n    // Check if it's new JSON format or old legacy string\n    let data;\n    try {\n        data = JSON.parse(raw);\n    } catch {\n        // Legacy format (direct string) - treat as expired to force re-login and upgrade format,\n        // or just accept it once. Let's just return it for backward compatibility but it won't have expiry.\n        // Actually, better to migrate it or just treat as valid for now.\n        // But user asked for expiry. So let's return it, and next setPassword will fix format.\n        // To be strict: clear it if not JSON? No, let's parse.\n        // If parsing fails, it's the old format string.\n        \n        // Let's migrate legacy storage to new format with current time if we want to keep them logged in,\n        // OR just return it. \n        // Logic: if string, return it.\n        const stored = raw;\n        if (stored.startsWith(\"v1:\")) {\n             const b64 = stored.slice(3);\n             const x = atob(b64);\n             return xorCipher(x);\n        }\n        return stored;\n    }\n\n    if (!data || !data.value) return null;\n\n    // Check Expiry\n    if (Date.now() - data.timestamp > EXPIRATION_TIME) {\n        clearPassword();\n        return null;\n    }\n\n    const stored = data.value;\n    if (stored.startsWith(\"v1:\")) {\n      const b64 = stored.slice(3);\n      const x = atob(b64);\n      return xorCipher(x);\n    }\n    return stored;\n  } catch (e) {\n    return null;\n  }\n}\n\nexport function clearPassword() {\n  localStorage.removeItem(STORAGE_KEY);\n}"
  },
  {
    "path": "config.js",
    "content": "// 云图 配置文件\nmodule.exports = {\n  // 上传配置\n  upload: {\n    // 允许的文件格式（扩展名）\n    allowedExtensions: process.env.ALLOWED_EXTENSIONS\n      ? process.env.ALLOWED_EXTENSIONS.split(\",\")\n      : [\".jpg\", \".jpeg\", \".png\", \".gif\", \".webp\", \".bmp\", \".svg\", \".avif\", \".mp4\", \".webm\"],\n\n    // 允许的MIME类型\n    allowedMimeTypes: [\n      \"image/jpeg\",\n      \"image/jpg\",\n      \"image/png\",\n      \"image/gif\",\n      \"image/webp\",\n      \"image/avif\",\n      \"image/bmp\",\n      \"image/svg+xml\",\n      \"audio/mpeg\",\n      \"video/mp4\",\n      \"video/webm\",\n    ],\n\n    // 文件大小限制（字节）\n    maxFileSize: process.env.MAX_FILE_SIZE\n      ? parseInt(process.env.MAX_FILE_SIZE)\n      : 100 * 1024 * 1024, // 100MB\n\n    // 是否允许重复文件名\n    allowDuplicateNames: process.env.ALLOW_DUPLICATE_NAMES === \"true\",\n\n    // 文件名冲突时的处理策略: 'timestamp' | 'counter' | 'overwrite'\n    duplicateStrategy: process.env.DUPLICATE_STRATEGY || \"timestamp\",\n\n    // 瀑布流缩略图宽度（像素），0 表示使用原图\n    thumbnailWidth: process.env.THUMBNAIL_WIDTH\n      ? parseInt(process.env.THUMBNAIL_WIDTH)\n      : 0,\n  },\n\n  // 存储配置\n  storage: {\n    // 存储路径\n    path: process.env.STORAGE_PATH || \"./uploads\",\n\n    // 是否自动创建目录\n    autoCreateDirs: process.env.AUTO_CREATE_DIRS !== \"false\",\n\n    // 文件名处理\n    filename: {\n      // 是否保留原始文件名\n      keepOriginalName: process.env.KEEP_ORIGINAL_NAME !== \"false\",\n\n      // 是否处理特殊字符\n      sanitizeSpecialChars: process.env.SANITIZE_SPECIAL_CHARS !== \"false\",\n\n      // 特殊字符替换符\n      specialCharReplacement: process.env.SPECIAL_CHAR_REPLACEMENT || \"_\",\n    },\n  },\n\n  // 服务器配置\n  server: {\n    port: process.env.PORT || 3001,\n    host: process.env.HOST || \"0.0.0.0\",\n  },\n\n  // 安全配置\n  security: {\n    // 是否启用路径安全检查\n    enablePathValidation: process.env.ENABLE_PATH_VALIDATION !== \"false\",\n\n    // 禁止的路径字符\n    forbiddenPathChars: process.env.FORBIDDEN_PATH_CHARS\n      ? process.env.FORBIDDEN_PATH_CHARS.split(\",\")\n      : [\"..\", \"\\\\\"],\n\n    // 最大目录深度\n    maxDirectoryDepth: process.env.MAX_DIRECTORY_DEPTH\n      ? parseInt(process.env.MAX_DIRECTORY_DEPTH)\n      : 10,\n\n    // 密码保护配置\n    password: {\n      // 访问密码\n      accessPassword: process.env.PASSWORD || null,\n\n      // 是否启用密码保护\n      enabled: !!process.env.PASSWORD,\n    },\n  },\n\n  // 魔法搜图配置\n  magicSearch: {\n    enabled: process.env.ENABLE_MAGIC_SEARCH === \"true\",\n    modelName: \"Xenova/clip-vit-base-patch32\", // @huggingface/transformers 默认量化\n    concurrency: 1, // N100 优化：严格串行处理\n  },\n};\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "version: \"3.8\"\n\nservices:\n  cloudimgs:\n    # 使用 GitHub Packages 镜像\n    image: qazzxxx/cloudimgs:latest\n    ports:\n      - \"3001:3001\"\n    volumes:\n      - ./uploads:/app/uploads:rw # 上传目录配置，明确读写权限\n    restart: unless-stopped\n    container_name: cloudimgs-app\n    environment:\n      - PUID=1000  # 替换为您 NAS 用户的实际 ID (id -u)\n      - PGID=1000   # 替换为您 NAS 用户组的实际 ID (id -g)\n      - UMASK=002\n      - NODE_ENV=production\n      - PORT=3001\n      - STORAGE_PATH=/app/uploads\n      # 密码保护配置（可选）\n      # - PASSWORD=your_password_here\n"
  },
  {
    "path": "docker-entrypoint.sh",
    "content": "#!/bin/sh\nset -e\n\n# 设置 umask\nUMASK=${UMASK:-0022}\numask \"$UMASK\"\n\n# 获取 PUID 和 PGID，默认为 1000\nPUID=${PUID:-1000}\nPGID=${PGID:-1000}\n\n# 处理用户组\nGROUP_NAME=$(getent group \"$PGID\" | cut -d: -f1)\nif [ -z \"$GROUP_NAME\" ]; then\n    # GID 未被占用，创建新组 cloudimgs\n    groupadd -g \"$PGID\" cloudimgs\n    GROUP_NAME=cloudimgs\n    echo \"[INFO] Created new group 'cloudimgs' with GID $PGID\"\nfi\n\n# 处理用户\nUSER_NAME=$(getent passwd \"$PUID\" | cut -d: -f1)\nif [ -z \"$USER_NAME\" ]; then\n    # UID 未被占用，创建新用户 cloudimgs\n    # -M 不创建主目录 (Debian)\n    useradd -u \"$PUID\" -g \"$GROUP_NAME\" -M -d /app cloudimgs\n    USER_NAME=cloudimgs\n    echo \"[INFO] Created new user 'cloudimgs' with UID $PUID\"\nfi\n\n# 确保目录存在\nmkdir -p \"$STORAGE_PATH\" logs\n\n# 修正权限（如果使用 root 启动容器，则修正所有权）\nif [ \"$(id -u)\" = \"0\" ]; then\n    chown -R \"$USER_NAME:$GROUP_NAME\" \"$STORAGE_PATH\" logs /app\n    \n    # 使用 gosu 降权运行应用\n    # 设置 HOME=/app\n    exec gosu \"$USER_NAME:$GROUP_NAME\" env HOME=/app \"$@\"\nelse\n    # 如果已经是普通用户，直接运行\n    exec \"$@\"\nfi"
  },
  {
    "path": "env.example",
    "content": "# 服务器配置\nPORT=3001\nHOST=0.0.0.0\n\n# 存储配置\nSTORAGE_PATH=./uploads\n\n# 上传配置（可选，默认值在 config.js 中设置）\n# MAX_FILE_SIZE=104857600  # 100MB in bytes\n# ALLOWED_EXTENSIONS=.jpg,.jpeg,.png,.gif,.webp,.bmp,.svg\n# THUMBNAIL_WIDTH=0  # 瀑布流缩略图宽度（像素），0 表示使用原图，推荐值 400-800\n\n# 密码保护配置（可选）\n# 设置此环境变量将启用密码保护，用户需要输入密码才能访问系统\nPASSWORD=qaz123\n\n# 环境\nNODE_ENV=production "
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"cloudimgs\",\n  \"version\": \"1.2.3\",\n  \"description\": \"A modern image hosting application with React frontend and Node.js backend\",\n  \"main\": \"server/index.js\",\n  \"scripts\": {\n    \"start\": \"node server/index.js\",\n    \"dev\": \"nodemon server/index.js\",\n    \"build\": \"cd client && npm run build\",\n    \"install-client\": \"cd client && npm install\",\n    \"build-client\": \"cd client && npm run build\",\n    \"heroku-postbuild\": \"npm run install-client && npm run build-client\"\n  },\n  \"keywords\": [\n    \"image-hosting\",\n    \"react\",\n    \"nodejs\",\n    \"express\"\n  ],\n  \"author\": \"Your Name\",\n  \"license\": \"MIT\",\n  \"dependencies\": {\n    \"@huggingface/transformers\": \"^3.8.1\",\n    \"axios\": \"^1.10.0\",\n    \"better-sqlite3\": \"^12.6.2\",\n    \"cors\": \"^2.8.5\",\n    \"dotenv\": \"^16.6.1\",\n    \"exifr\": \"^7.1.3\",\n    \"express\": \"^4.21.2\",\n    \"fs-extra\": \"^11.3.0\",\n    \"mime\": \"^4.0.7\",\n    \"mime-types\": \"^3.0.1\",\n    \"multer\": \"^1.4.5-lts.2\",\n    \"music-metadata\": \"^7.14.0\",\n    \"path\": \"^0.12.7\",\n    \"sharp\": \"^0.34.2\",\n    \"sqlite-vec\": \"^0.1.7-alpha.2\",\n    \"thumbhash\": \"^0.1.1\",\n    \"uuid\": \"^9.0.1\"\n  },\n  \"devDependencies\": {\n    \"nodemon\": \"^3.1.10\"\n  }\n}\n"
  },
  {
    "path": "server/db/database.js",
    "content": "const Database = require('better-sqlite3');\nconst path = require('path');\nconst fs = require('fs-extra');\nconst config = require('../../config');\n\n// 确保数据库目录存在\nconst dbPath = path.resolve(config.storage.path, '.cache', 'cloudimgs.db');\nfs.ensureDirSync(path.dirname(dbPath));\n\nconst db = new Database(dbPath, { verbose: process.env.NODE_ENV === 'development' ? console.log : null });\n\n// Load sqlite-vec extension if Magic Search is enabled\nif (config.magicSearch.enabled) {\n  try {\n    const sqliteVec = require('sqlite-vec');\n    let extensionPath = sqliteVec.getLoadablePath();\n    console.log(`[MagicSearch] sqlite-vec path: ${extensionPath}`);\n\n    // Fix for Docker/Linux where underlying sqlite might append .so automatically (causing .so.so)\n    if (process.platform === 'linux' && extensionPath.endsWith('.so')) {\n      extensionPath = extensionPath.slice(0, -3);\n    }\n\n    db.loadExtension(extensionPath);\n    console.log(\"sqlite-vec extension loaded successfully\");\n  } catch (err) {\n    console.error(\"Failed to load sqlite-vec extension:\", err);\n  }\n}\n\n// 初始化 Schema\nfunction init() {\n  db.exec(`\n    CREATE TABLE IF NOT EXISTS images (\n      id INTEGER PRIMARY KEY AUTOINCREMENT,\n      filename TEXT NOT NULL,\n      rel_path TEXT NOT NULL UNIQUE,\n      size INTEGER,\n      mtime INTEGER,\n      upload_time TEXT,\n      width INTEGER,\n      height INTEGER,\n      orientation INTEGER,\n      thumbhash TEXT,\n      meta_json TEXT,\n      views INTEGER DEFAULT 0,\n      last_viewed INTEGER\n    );\n    \n    CREATE INDEX IF NOT EXISTS idx_rel_path ON images(rel_path);\n    CREATE INDEX IF NOT EXISTS idx_mtime ON images(mtime);\n    CREATE INDEX IF NOT EXISTS idx_upload_time ON images(upload_time DESC);\n\n    CREATE TABLE IF NOT EXISTS shares (\n        token TEXT PRIMARY KEY,\n        path TEXT NOT NULL,\n        created_at INTEGER NOT NULL,\n        expire_seconds INTEGER,\n        burn_after_reading INTEGER DEFAULT 0,\n        is_revoked INTEGER DEFAULT 0,\n        views INTEGER DEFAULT 0\n    );\n\n    CREATE TABLE IF NOT EXISTS daily_stats (\n        date TEXT PRIMARY KEY, \n        uploads_count INTEGER DEFAULT 0,\n        uploads_size INTEGER DEFAULT 0,\n        views_count INTEGER DEFAULT 0,\n        views_size INTEGER DEFAULT 0\n    );\n  `);\n\n  if (config.magicSearch.enabled) {\n    db.exec(`\n      CREATE VIRTUAL TABLE IF NOT EXISTS vec_images USING vec0(\n        image_id INTEGER PRIMARY KEY,\n        embedding float[512]\n      );\n    `);\n  }\n\n  // Migration for existing tables\n  try {\n    const columns = db.prepare(\"PRAGMA table_info(images)\").all();\n    if (!columns.find(c => c.name === 'views')) {\n      console.log(\"Migrating: Adding views column to images\");\n      db.prepare(\"ALTER TABLE images ADD COLUMN views INTEGER DEFAULT 0\").run();\n      db.prepare(\"ALTER TABLE images ADD COLUMN last_viewed INTEGER\").run();\n    }\n\n    // Create index safely after ensuring columns exist\n    db.prepare(\"CREATE INDEX IF NOT EXISTS idx_views ON images(views DESC)\").run();\n\n  } catch (e) {\n    console.error(\"Migration failed:\", e);\n  }\n}\n\ninit();\n\nmodule.exports = db;\n"
  },
  {
    "path": "server/db/imageRepository.js",
    "content": "const db = require('./database');\n\nconst insertImage = db.prepare(`\n  INSERT INTO images (filename, rel_path, size, mtime, upload_time, width, height, orientation, thumbhash, meta_json)\n  VALUES (@filename, @rel_path, @size, @mtime, @upload_time, @width, @height, @orientation, @thumbhash, @meta_json)\n`);\n\nconst updateImage = db.prepare(`\n  UPDATE images \n  SET filename = @filename, size = @size, mtime = @mtime, upload_time = @upload_time, \n      width = @width, height = @height, orientation = @orientation, thumbhash = @thumbhash, meta_json = @meta_json\n  WHERE rel_path = @rel_path\n`);\n\nconst getImageByPath = db.prepare('SELECT * FROM images WHERE rel_path = ?');\nconst getAllImagesQuery = db.prepare('SELECT * FROM images ORDER BY upload_time DESC');\nconst deleteImageByPath = db.prepare('DELETE FROM images WHERE rel_path = ?');\nconst countImages = db.prepare('SELECT COUNT(*) as count FROM images');\nconst getImagesByDir = db.prepare(\"SELECT * FROM images WHERE rel_path LIKE ? || '/%' ORDER BY upload_time DESC\");\nconst getPreviewsQuery = db.prepare(\"SELECT * FROM images WHERE rel_path LIKE ? || '/%' ORDER BY upload_time DESC LIMIT ?\");\nconst countImagesByDirQuery = db.prepare(\"SELECT COUNT(*) as count FROM images WHERE rel_path LIKE ? || '/%'\");\nconst getAllImagesByViewsQuery = db.prepare('SELECT * FROM images ORDER BY views DESC');\n\n// 批量操作\nconst insertMany = db.transaction((images) => {\n    for (const img of images) insertImage.run(img);\n});\n\n// 重命名（原子替换路径）\nconst renameImage = db.transaction((oldRelPath, newRelPath, newFilename) => {\n    const existing = getImageByPath.get(oldRelPath);\n    if (!existing) return null;\n    deleteImageByPath.run(oldRelPath);\n    existing.rel_path = newRelPath;\n    existing.filename = newFilename;\n    insertImage.run(existing);\n    return existing;\n});\n\n// 统计数据 SQL\nconst incrementViewQuery = db.prepare('UPDATE images SET views = views + 1, last_viewed = @now WHERE rel_path = @relPath');\n\nconst recordDailyUploadQuery = db.prepare(`\n  INSERT INTO daily_stats (date, uploads_count, uploads_size)\n  VALUES (@date, 1, @size)\n  ON CONFLICT(date) DO UPDATE SET\n  uploads_count = uploads_count + 1,\n  uploads_size = uploads_size + @size\n`);\n\nconst recordDailyViewQuery = db.prepare(`\n  INSERT INTO daily_stats (date, views_count, views_size)\n  VALUES (@date, 1, @size)\n  ON CONFLICT(date) DO UPDATE SET\n  views_count = views_count + 1,\n  views_size = views_size + @size\n`);\n\nconst getDailyStatsQuery = db.prepare('SELECT * FROM daily_stats ORDER BY date DESC LIMIT ?');\nconst getTopImagesQuery = db.prepare('SELECT * FROM images ORDER BY views DESC LIMIT ?');\n\nmodule.exports = {\n    add: (image) => {\n        try {\n            return insertImage.run(image);\n        } catch (e) {\n            if (e.code === 'SQLITE_CONSTRAINT_UNIQUE') {\n                // 如果已存在，尝试更新\n                // 目前仅记录日志或重新抛出，或者可以使用 INSERT OR REPLACE\n                console.warn(`Image ${image.relPath} already exists in DB. Attempting update.`);\n                return updateImage.run(image);\n            }\n            throw e;\n        }\n    },\n    update: (image) => updateImage.run(image),\n    rename: (oldRelPath, newRelPath, newFilename) => renameImage(oldRelPath, newRelPath, newFilename),\n    getByPath: (relPath) => getImageByPath.get(relPath),\n    getAll: () => getAllImagesQuery.all(),\n    getAllByViews: () => getAllImagesByViewsQuery.all(),\n    delete: (relPath) => deleteImageByPath.run(relPath),\n    count: () => countImages.get().count,\n    getByDir: (dir) => {\n        // 处理根目录特殊情况，通常 dir 为空字符串表示根\n        // 如果 dir 为空，返回所有？还是仅根目录项？\n        // getAllImagesQuery 返回所有\n        // 如果提供了 dir，使用 LIKE 匹配\n        if (!dir) return getAllImagesQuery.all();\n        return getImagesByDir.all(dir);\n    },\n    getPreviews: (dir, limit = 3) => getPreviewsQuery.all(dir, limit),\n    countByDir: (dir) => countImagesByDirQuery.get(dir).count,\n    insertMany,\n    // 事务辅助函数\n    transaction: (fn) => db.transaction(fn),\n\n    // Stats Methods\n    incrementViews: (relPath) => incrementViewQuery.run({ relPath, now: Date.now() }),\n    recordUpload: (size) => {\n        const date = new Date().toISOString().split('T')[0];\n        recordDailyUploadQuery.run({ date, size });\n    },\n    recordView: (size) => {\n        const date = new Date().toISOString().split('T')[0];\n        recordDailyViewQuery.run({ date, size });\n    },\n    getDailyStats: (limit = 30) => getDailyStatsQuery.all(limit),\n    getTopImages: (limit = 10) => getTopImagesQuery.all(limit),\n};\n"
  },
  {
    "path": "server/db/shareRepository.js",
    "content": "const db = require('./database');\nconst crypto = require('crypto');\n\nconst createShare = db.prepare(`\n    INSERT INTO shares (token, path, created_at, expire_seconds, burn_after_reading)\n    VALUES (@token, @path, @createdAt, @expireSeconds, @burnAfterReading)\n`);\n\nconst getShare = db.prepare('SELECT * FROM shares WHERE token = ?');\n\nconst getSharesByPath = db.prepare('SELECT * FROM shares WHERE path = ? ORDER BY created_at DESC');\n\nconst revokeShare = db.prepare('UPDATE shares SET is_revoked = 1 WHERE token = ?');\n\nconst deleteShare = db.prepare('DELETE FROM shares WHERE token = ?');\n\nconst incrementView = db.prepare('UPDATE shares SET views = views + 1 WHERE token = ?');\n\nmodule.exports = {\n    create: (data) => {\n        const token = crypto.randomBytes(16).toString('hex');\n        const info = {\n            token,\n            path: data.path,\n            createdAt: Date.now(),\n            expireSeconds: data.expireSeconds || 0,\n            burnAfterReading: data.burnAfterReading ? 1 : 0\n        };\n        createShare.run(info);\n        return token;\n    },\n\n    getByToken: (token) => {\n        return getShare.get(token);\n    },\n\n    listByPath: (path) => {\n        return getSharesByPath.all(path);\n    },\n\n    revoke: (token) => {\n        return revokeShare.run(token);\n    },\n\n    delete: (token) => {\n        return deleteShare.run(token);\n    },\n\n    incrementView: (token) => {\n        return incrementView.run(token);\n    }\n};\n"
  },
  {
    "path": "server/index.js",
    "content": "require(\"dotenv\").config();\nconst express = require(\"express\");\nconst cors = require(\"cors\");\nconst path = require(\"path\");\nconst fs = require(\"fs-extra\");\nconst config = require(\"../config\"); // config.js is in root usually? checking old index.js: require(\"../config\")\n// 等等，index.js 在 server/ 中，所以 ../config 在根目录。正确。\n\nconst uploadRoutes = require(\"./routes/uploadRoutes\");\nconst imageRoutes = require(\"./routes/imageRoutes\");\nconst manageRoutes = require(\"./routes/manageRoutes\");\nconst systemRoutes = require(\"./routes/systemRoutes\");\nconst statsRoutes = require(\"./routes/statsRoutes\");\nconst searchRoutes = require(\"./routes/searchRoutes\");\nconst shareRoutes = require(\"./routes/shareRoutes\");\nconst { migrateFromLegacyJson, syncFileSystem } = require(\"./services/syncService\");\n\n\nconst app = express();\nconst PORT = config.server.port || 5000; // fallback\n\n// 中间件\napp.use(cors());\napp.use(express.json({ limit: '50mb' }));\napp.use(express.static(path.join(__dirname, \"../client/build\")));\napp.enable(\"trust proxy\");\n\n// 路由\n// 顺序很重要！\n// /api/health 可能最先\napp.use(\"/api\", systemRoutes);\n\n// 流量统计\napp.use(\"/api/stats\", statsRoutes);\n\n// 魔法搜图\napp.use(\"/api/search\", searchRoutes);\n\n// 上传\napp.use(\"/api\", uploadRoutes); // /upload, /upload-base64\n\n// 分享\napp.use(\"/api/share\", shareRoutes);\n\n// 管理（密码、回收站、批量移动）\napp.use(\"/api\", manageRoutes); // /batch/move, /album/*, /images/* (DELETE)\n\n// 图片 (GET) - 放在最后捕获 /images/*\napp.use(\"/api\", imageRoutes); // /images, /images/*, /files/*\n\n// 数据库迁移和同步\n(async () => {\n  try {\n    console.log(\"Initializing database...\");\n    await migrateFromLegacyJson();\n    await syncFileSystem();\n\n    if (config.magicSearch.enabled) {\n      // 触发后台扫描任何丢失的嵌入 (低优先级)\n      // 这里没有 await，以便让服务器立即启动\n      const clipService = require('./services/clipService');\n      clipService.scanAll().catch(e => console.error(\"Background scan failed:\", e));\n    }\n  } catch (e) {\n    console.error(\"Initialization failed:\", e);\n  }\n})();\n\n// 回收站清理任务\nconst { TRASH_DIR_NAME, safeJoin } = require(\"./utils/fileUtils\");\nconst STORAGE_PATH = config.storage.path;\n\nasync function cleanTrash() {\n  const trashDir = path.join(STORAGE_PATH, TRASH_DIR_NAME);\n  if (!(await fs.pathExists(trashDir))) return;\n\n  try {\n    const files = await fs.readdir(trashDir);\n    const now = Date.now();\n    const EXPIRE_TIME = 30 * 24 * 60 * 60 * 1000; // 30 Days\n\n    for (const file of files) {\n      const filePath = path.join(trashDir, file);\n      try {\n        const stats = await fs.stat(filePath);\n        if (now - stats.mtimeMs > EXPIRE_TIME) {\n          await fs.remove(filePath);\n          console.log(`[Trash] Cleaned expired file: ${file}`);\n        }\n      } catch (e) {\n        // ignore\n      }\n    }\n  } catch (e) {\n    console.error(\"[Trash] Cleanup failed:\", e);\n  }\n}\n\n// 启动清理任务\ncleanTrash();\nsetInterval(cleanTrash, 24 * 60 * 60 * 1000);\n\n\n// 所有其他 GET 请求都返回 React 应用 (SPA 支持)\napp.get('*', (req, res) => {\n  // 避免 API 请求返回 HTML\n  if (req.path.startsWith('/api/')) {\n    return res.status(404).json({ error: \"Not Found\" });\n  }\n  res.sendFile(path.join(__dirname, \"../client/build\", \"index.html\"));\n});\n\n// 启动服务器\napp.listen(PORT, () => {\n  console.log(`Server running on port ${PORT}`);\n});\n"
  },
  {
    "path": "server/middleware/auth.js",
    "content": "const config = require('../../config');\n\nfunction requirePassword(req, res, next) {\n    if (!config.security.password.enabled) {\n        return next();\n    }\n\n    const password =\n        req.headers[\"x-access-password\"] || req.body.password || req.query.password;\n\n    if (!password) {\n        return res.status(401).json({ error: \"需要提供访问密码\" });\n    }\n\n    if (password !== config.security.password.accessPassword) {\n        return res.status(401).json({ error: \"密码错误\" });\n    }\n\n    next();\n}\n\nmodule.exports = { requirePassword };\n"
  },
  {
    "path": "server/middleware/upload.js",
    "content": "const multer = require('multer');\nconst path = require('path');\nconst fs = require('fs-extra');\nconst config = require('../../config');\nconst { safeJoin, sanitizeFilename } = require('../utils/fileUtils');\n\nconst STORAGE_PATH = config.storage.path;\n\nconst isAllowedFile = (file) => {\n    const ext = path.extname(file.originalname).toLowerCase();\n    const isAllowedExt = config.upload.allowedExtensions.includes(ext);\n    const isAllowedMime = config.upload.allowedMimeTypes.includes(file.mimetype);\n    return isAllowedExt && isAllowedMime;\n};\n\nconst FORBIDDEN_EXTENSIONS = [\n    \".php\", \".html\", \".htm\", \".js\", \".mjs\", \".ts\", \".sh\", \".bat\", \".exe\", \".dll\",\n    \".com\", \".cgi\", \".pl\", \".py\", \".jar\", \".apk\", \".msi\"\n];\nconst FORBIDDEN_MIME_PREFIXES = [\n    \"text/html\", \"application/x-httpd-php\", \"application/javascript\",\n    \"text/javascript\", \"application/x-sh\", \"application/x-msdownload\",\n    \"application/vnd.android.package-archive\"\n];\n\nconst isForbiddenFile = (file) => {\n    const ext = path.extname(file.originalname).toLowerCase();\n    if (FORBIDDEN_EXTENSIONS.includes(ext)) return true;\n    const mime = (file.mimetype || \"\").toLowerCase();\n    if (FORBIDDEN_MIME_PREFIXES.some((m) => mime.startsWith(m))) return true;\n    return false;\n};\n\nconst storage = multer.diskStorage({\n    destination: (req, file, cb) => {\n        let dir = req.query.dir || req.body.dir || \"\";\n        dir = dir.replace(/\\\\/g, \"/\");\n        const dest = safeJoin(STORAGE_PATH, dir);\n        try {\n            fs.ensureDirSync(dest);\n            cb(null, dest);\n        } catch (error) {\n            cb(error);\n        }\n    },\n    filename: (req, file, cb) => {\n        let originalName = file.originalname;\n        if (!/[^\\u0000-\\u00ff]/.test(originalName)) {\n            try {\n                originalName = Buffer.from(originalName, \"latin1\").toString(\"utf8\");\n            } catch (e) { }\n        }\n\n        const sanitizedName = sanitizeFilename(originalName);\n        const forceOverwrite =\n            req.query.overwrite === \"true\" ||\n            req.body?.overwrite === \"true\" ||\n            req.query.overwrite === true ||\n            req.body?.overwrite === true;\n\n        if (forceOverwrite) {\n            return cb(null, sanitizedName);\n        }\n\n        const ext = path.extname(sanitizedName);\n        const nameWithoutExt = path.basename(sanitizedName, ext);\n        let finalName = sanitizedName;\n        let counter = 1;\n\n        let dir = req.query.dir || req.body.dir || \"\";\n        dir = dir.replace(/\\\\/g, \"/\");\n        const dest = safeJoin(STORAGE_PATH, dir);\n\n        if (!config.upload.allowDuplicateNames) {\n            while (fs.existsSync(path.join(dest, finalName))) {\n                if (config.upload.duplicateStrategy === \"timestamp\") {\n                    finalName = `${nameWithoutExt}_${Date.now()}_${counter}${ext}`;\n                } else if (config.upload.duplicateStrategy === \"counter\") {\n                    finalName = `${nameWithoutExt}_${counter}${ext}`;\n                } else if (config.upload.duplicateStrategy === \"overwrite\") {\n                    break;\n                }\n                counter++;\n            }\n        }\n        cb(null, finalName);\n    },\n});\n\nconst upload = multer({\n    storage: storage,\n    fileFilter: (req, file, cb) => {\n        if (isAllowedFile(file)) {\n            cb(null, true);\n        } else {\n            const allowedFormats = config.upload.allowedExtensions.join(\", \");\n            cb(new Error(`只支持以下图片格式: ${allowedFormats}`));\n        }\n    },\n    limits: { fileSize: config.upload.maxFileSize },\n});\n\nconst uploadAny = multer({\n    storage: storage,\n    fileFilter: (req, file, cb) => {\n        if (isForbiddenFile(file)) {\n            return cb(new Error(\"不允许上传可执行或危险文件类型\"));\n        }\n        cb(null, true);\n    },\n    limits: { fileSize: config.upload.maxFileSize },\n});\n\nconst handleMulterError = (err, req, res, next) => {\n    if (err instanceof multer.MulterError) {\n        if (err.code === 'LIMIT_FILE_SIZE') {\n            return res.status(413).json({\n                success: false,\n                error: `文件大小超过限制，最大允许 ${Math.round((config.upload.maxFileSize / (1024 * 1024)) * 100) / 100}MB`\n            });\n        }\n        return res.status(400).json({ success: false, error: `上传错误: ${err.message}` });\n    } else if (err) {\n        return res.status(400).json({ success: false, error: err.message });\n    }\n    next();\n};\n\nmodule.exports = {\n    upload,\n    uploadAny,\n    handleMulterError\n};\n"
  },
  {
    "path": "server/routes/imageRoutes.js",
    "content": "const express = require('express');\nconst path = require('path');\nconst fs = require('fs-extra');\nconst mime = require('mime-types');\nconst sharp = require('sharp');\nsharp.cache({ memory: 128, items: 50 });\nconst config = require('../../config');\nconst imageRepository = require('../db/imageRepository');\nconst { requirePassword } = require('../middleware/auth');\nconst { safeJoin, getThumbHash, generateThumbHash } = require('../utils/fileUtils');\nconst { formatImageResponse } = require('../utils/urlUtils');\n\nconst router = express.Router();\nconst STORAGE_PATH = config.storage.path;\n\nconst { isAlbumLocked, verifyAlbumPassword, getAllLockedDirectories } = require('../utils/albumUtils');\n\n// 地图数据 (旧端点支持，现在仅返回 DB 中的所有图像？)\n// 原始 updateMapCache 返回所有图像。\nrouter.get('/map-data', requirePassword, async (req, res) => {\n    // 返回所有带 GPS 数据的图像\n    // 我们可以在 SQL 或 JS 中过滤。\n    // 目前获取所有并返回所需字段。\n    const lockedDirs = await getAllLockedDirectories();\n    const images = imageRepository.getAll();\n    const mapData = images.filter(img => {\n        if (lockedDirs.some(lockedDir => img.rel_path.startsWith(lockedDir + \"/\"))) return false;\n        const meta = JSON.parse(img.meta_json || '{}');\n        return meta.gps;\n    }).map(img => {\n        const formatted = formatImageResponse(req, img);\n        const meta = JSON.parse(img.meta_json || '{}');\n        return {\n            filename: img.filename,\n            relPath: img.rel_path,\n            lat: meta.gps.lat,\n            lng: meta.gps.lng,\n            date: img.upload_time,\n            thumbUrl: `${formatted.url}?w=200`,\n            thumbhash: img.thumbhash,\n            fullUrl: formatted.fullUrl,\n            url: formatted.url\n        };\n    });\n    res.json({ success: true, data: mapData });\n});\n\n// 目录列表\nrouter.get('/directories', requirePassword, async (req, res) => {\n    try {\n        const { CACHE_DIR_NAME, CONFIG_DIR_NAME, TRASH_DIR_NAME } = require('../utils/fileUtils');\n\n        // 扫描目录\n        async function getDirectories(dir) {\n            const absDir = safeJoin(STORAGE_PATH, dir);\n            let results = [];\n            try {\n                const files = await fs.readdir(absDir);\n                for (const file of files) {\n                    if (file === CACHE_DIR_NAME || file === CONFIG_DIR_NAME || file === TRASH_DIR_NAME) continue;\n                    if (file.startsWith('.')) continue; // 跳过隐藏文件\n\n                    const filePath = path.join(absDir, file);\n                    const stats = await fs.stat(filePath);\n                    if (stats.isDirectory()) {\n                        const relPath = path.join(dir, file).replace(/\\\\/g, \"/\");\n\n                        // 从 DB 获取预览图和计数\n                        // 注意：这会递归获取计数/预览，这通常是用户期望的“相册”封面\n                        const isLocked = await isAlbumLocked(relPath);\n                        let previews = [];\n                        if (!isLocked) {\n                            previews = imageRepository.getPreviews(relPath, 3).map(img =>\n                                `/api/images/${img.rel_path.split(\"/\").map(encodeURIComponent).join(\"/\")}?w=400`\n                            );\n                        }\n                        const count = imageRepository.countByDir(relPath);\n\n                        results.push({\n                            name: file,\n                            path: relPath,\n                            fullUrl: relPath, // alias\n                            previews,\n                            locked: isLocked, // 标记为锁定隐私状态\n                            imageCount: count,\n                            mtime: stats.mtime // 文件夹本身的最后修改时间\n                        });\n\n                        // 递归扫描子相册？\n                        // 如果显示树形结构，我们需要子项。\n                        // 但通常典型的“相册视图”是直接子文件夹的平铺列表。\n                        // 之前的代码是递归的：`results = results.concat(children);`\n                        // 如果保持递归，我们将获得所有扁平化的子文件夹。\n                        // UI 是否期望这样？\n                        // `AlbumManager` 使用 `allAlbums`。它似乎将它们显示为卡片。\n                        // 如果我有 A/B/C，我会看到 A、B 和 C 吗？\n                        // 如果我看到 A并在其中点击，通常会进入 A。\n                        // UI `AlbumManager` 过滤？\n                        // `const allAlbums = res.data.data || [];`\n                        // `setAlbums([allImagesAlbum, ...allAlbums]);`\n                        // 它配置网格。\n                        // 如果 API 返回所有目录的扁平列表，那么是的，递归是可以的。\n                        // 让我们保持递归结构原样。\n\n                        const children = await getDirectories(relPath);\n                        results = results.concat(children);\n                    }\n                }\n            } catch (e) { }\n            return results;\n        }\n\n        const directories = await getDirectories(\"\");\n        res.json({ success: true, data: directories });\n\n    } catch (e) {\n        console.error(\"List directories error:\", e);\n        res.status(500).json({ error: \"Get directories failed\" });\n    }\n});\n\n// 图片列表\nrouter.get('/images', requirePassword, async (req, res) => {\n    try {\n        let dir = req.query.dir || \"\";\n        dir = dir.replace(/\\\\/g, \"/\");\n        const page = parseInt(req.query.page) || 1;\n        const pageSize = parseInt(req.query.pageSize) || 10;\n        const search = req.query.search || \"\";\n\n        const albumPassword = req.headers[\"x-album-password\"];\n        if (dir && await isAlbumLocked(dir)) {\n            if (!albumPassword || !(await verifyAlbumPassword(dir, albumPassword))) {\n                return res.status(403).json({ success: false, error: \"需要访问密码\", locked: true });\n            }\n        }\n\n        // DB 查询？\n        // SQLite 没有原生的递归目录过滤，除非我们使用 GLOB\n        // 但 `rel_path` 允许 `dir/*` 通配符？\n        // 或者我们可以获取全部并在内存中过滤？\n        // `imageRepository.getAll` 返回所有。\n        // 如果库很大（10万张图片），内存过滤很糟糕。\n        // 我应该使用 LIKE 'dir/%' AND NOT LIKE 'dir/%/%' 向存储库添加 `getByDir`？\n        // 原来的 `getAllImages` 是递归的！\n        // `getAllImages(dir)` 递归返回 `dir` 中的所有内容。\n        // 所以 `WHERE rel_path LIKE 'dir/%'` 是正确的（对根目录处理正确）。\n\n        let allImages = imageRepository.getAll();\n\n        if (dir) {\n            allImages = allImages.filter(img => img.rel_path.startsWith(dir !== \"\" ? (dir + \"/\") : \"\"));\n        } else {\n            const lockedDirs = await getAllLockedDirectories();\n            allImages = allImages.filter(img => !lockedDirs.some(lockedDir => img.rel_path.startsWith(lockedDir + \"/\")));\n        }\n\n        if (search) {\n            allImages = allImages.filter(img => img.filename.toLowerCase().includes(search.toLowerCase()));\n        }\n\n        const total = allImages.length;\n        const startIndex = (page - 1) * pageSize;\n        const paginated = allImages.slice(startIndex, startIndex + pageSize);\n\n        const result = paginated.map(img => formatImageResponse(req, img));\n\n        res.setHeader(\"Cache-Control\", \"no-store, no-cache, must-revalidate, proxy-revalidate\");\n        res.json({\n            success: true,\n            data: result,\n            pagination: {\n                current: page,\n                pageSize,\n                total,\n                totalPages: Math.ceil(total / pageSize)\n            }\n        });\n\n    } catch (e) {\n        console.error(\"List images error:\", e);\n        res.status(500).json({ error: \"获取图片列表失败\" });\n    }\n});\n\n// 提取图片元数据\nrouter.get('/images/meta/*', requirePassword, async (req, res) => {\n    const relPath = decodeURIComponent(req.params[0]);\n    const dbImage = imageRepository.getByPath(relPath);\n\n    if (!dbImage) {\n        // 回退到 FS 检查？\n        if (!await fs.pathExists(safeJoin(STORAGE_PATH, relPath))) {\n            return res.status(404).json({ success: false, error: \"图片不存在\" });\n        }\n        // 如果存在于 FS 但不在 DB 中，也许同步服务丢失了它？返回基本信息\n    }\n\n    // DB lookup first\n    let fileInfo = {};\n    if (dbImage) {\n        fileInfo = {\n            width: dbImage.width,\n            height: dbImage.height,\n            orientation: dbImage.orientation,\n            ...JSON.parse(dbImage.meta_json || '{}')\n        };\n    }\n\n    try {\n        const filePath = safeJoin(STORAGE_PATH, relPath);\n        const fstats = await fs.stat(filePath);\n        const mimeType = mime.lookup(filePath) || \"application/octet-stream\";\n\n        // If DB miss or missing critical info (e.g. detailed EXIF or Space not in DB for old records),\n        // we might want to re-parse from file on the fly given this is the \"Detail View\"\n        // But for performance, trust DB if available.\n        // However, user just asked to \"fix\" it, so for existing images that don't have the new fields,\n        // we should probably re-extract if missing.\n\n        let needsUpdate = false;\n        if (!fileInfo.space || !fileInfo.width) {\n            const { getFileMetadata } = require('../services/metadataService');\n            const freshMeta = await getFileMetadata(filePath, relPath, fstats);\n\n            // Merge fresh meta\n            const freshJson = JSON.parse(freshMeta.meta_json);\n            fileInfo = {\n                ...fileInfo,\n                width: freshMeta.width,\n                height: freshMeta.height,\n                orientation: freshMeta.orientation,\n                ...freshJson\n            };\n\n            // Optionally update DB here? Maybe too heavy for a GET request. \n            // Let's just return it for now.\n        }\n\n        const rawInfo = {\n            filename: path.basename(relPath),\n            rel_path: relPath, // helper expects rel_path\n            size: fstats.size,\n            upload_time: fstats.mtime.toISOString(), // helper expects upload_time\n            mime_type: mimeType,\n            width: fileInfo.width,\n            height: fileInfo.height,\n            meta_json: fileInfo // helper can take object\n        };\n\n        res.json({\n            success: true,\n            data: formatImageResponse(req, rawInfo)\n        });\n\n    } catch (e) {\n        console.error(\"Meta error:\", e);\n        res.status(400).json({ success: false, error: \"Error fetching metadata\" });\n    }\n});\n\n// 辅助函数：处理并发送图片\nasync function serveImage(req, res, relPath) {\n    try {\n        const filePath = safeJoin(STORAGE_PATH, relPath);\n        if (!await fs.pathExists(filePath)) return res.status(404).json({ error: \"Not found\" });\n\n        // Thumbhash 触发器\n        getThumbHash(filePath).then(h => { if (!h) generateThumbHash(filePath); });\n\n        const { w, h, q, fmt, rows, cols, idx } = req.query;\n\n        // 对 GIF 文件且无任何处理参数时，直接返回原始文件（保留动画）\n        const fileMime = (mime.lookup(filePath) || \"\").toLowerCase();\n        const isGif = fileMime.includes(\"gif\");\n        if (isGif && !w && !h && !q && !fmt && !rows && !cols) {\n            try {\n                const stats = await fs.stat(filePath);\n                imageRepository.recordView(stats.size);\n                imageRepository.incrementViews(relPath);\n            } catch (e) { }\n            res.setHeader(\"Content-Type\", \"image/gif\");\n            res.setHeader(\"Cache-Control\", \"public, max-age=31536000\");\n            return res.sendFile(filePath);\n        }\n\n        // Sharp 逻辑\n        try {\n            // GIF 缩略图：明确只取第一帧（animated: false 是 sharp 默认行为，此处显式声明）\n            let img = isGif\n                ? sharp(filePath, { animated: false }).rotate()\n                : sharp(filePath).rotate();\n\n            // 2. 处理网格切分 (Slicing)\n            if (rows && cols && idx !== undefined) {\n                const r = parseInt(rows);\n                const c = parseInt(cols);\n                const i = parseInt(idx);\n\n                if (r > 0 && c > 0 && i >= 0 && i < r * c) {\n                    const meta = await img.metadata();\n                    const width = meta.width;\n                    const height = meta.height;\n\n                    const subW = Math.floor(width / c);\n                    const subH = Math.floor(height / r);\n\n                    const row = Math.floor(i / c);\n                    const col = i % c;\n\n                    const left = col * subW;\n                    const top = row * subH;\n\n                    // 防止舍入误差导致溢出\n                    const extractW = Math.min(subW, width - left);\n                    const extractH = Math.min(subH, height - top);\n\n                    img.extract({ left, top, width: extractW, height: extractH });\n\n                }\n            }\n\n            // 3. 处理缩放 (Resize) - 针对切分后的图（或者原图）\n            if (w || h) {\n                img = img.resize({\n                    width: w ? parseInt(w) : null,\n                    height: h ? parseInt(h) : null,\n                    fit: \"cover\",\n                    position: \"center\",\n                    withoutEnlargement: true,\n                });\n            }\n\n            let outMime = mime.lookup(filePath) || \"application/octet-stream\";\n            if (fmt === \"webp\") {\n                img = img.webp({ quality: q ?? 80 });\n                outMime = \"image/webp\";\n            } else if (fmt === \"jpeg\") {\n                img = img.jpeg({ quality: q ?? 80 });\n                outMime = \"image/jpeg\";\n            } else if (fmt === \"png\") {\n                img = img.png();\n                outMime = \"image/png\";\n            } else if (fmt === \"avif\") {\n                img = img.avif({ quality: q ?? 50 });\n                outMime = \"image/avif\";\n            } else if (q) {\n                const orig = (mime.lookup(filePath) || \"\").toLowerCase();\n                if (orig.includes(\"jpeg\") || orig.includes(\"jpg\")) {\n                    img = img.jpeg({ quality: q });\n                    outMime = \"image/jpeg\";\n                } else if (orig.includes(\"webp\")) {\n                    img = img.webp({ quality: q });\n                    outMime = \"image/webp\";\n                } else if (orig.includes(\"avif\")) {\n                    img = img.avif({ quality: q });\n                    outMime = \"image/avif\";\n                } else {\n                    img = img.png();\n                    outMime = \"image/png\";\n                }\n            }\n\n            const buffer = await img.toBuffer();\n\n            // Record Stats (Fire and forget)\n            try {\n                imageRepository.recordView(buffer.length);\n                imageRepository.incrementViews(relPath);\n            } catch (e) {\n                console.error(\"Stats error\", e);\n            }\n\n            res.setHeader(\"Content-Type\", outMime);\n            res.setHeader(\"Cache-Control\", \"public, max-age=31536000\");\n            res.send(buffer);\n        } catch (e) {\n            // 对非图像文件或 sharp 错误的回退\n            if (!w && !h && !q && !fmt) {\n                // Record Stats for raw file\n                try {\n                    const stats = await fs.stat(filePath);\n                    imageRepository.recordView(stats.size);\n                    imageRepository.incrementViews(relPath);\n                } catch (e) { }\n\n                res.setHeader(\"Content-Type\", mime.lookup(filePath) || 'application/octet-stream');\n                return res.sendFile(filePath);\n            }\n            res.status(500).json({ error: \"Image processing failed\" });\n        }\n\n    } catch (e) {\n        res.status(400).json({ error: \"Error\" });\n    }\n}\n\n// 随机图片 (GET)\n// 支持 ?dir=xxx 参数来限定目录\nrouter.get('/random', async (req, res) => {\n    try {\n        let dir = req.query.dir || \"\";\n        dir = dir.replace(/\\\\/g, \"/\");\n\n        let allImages = imageRepository.getAll();\n\n        if (dir) {\n            allImages = allImages.filter(img => img.rel_path.startsWith(dir !== \"\" ? (dir + \"/\") : \"\"));\n        } else {\n            const lockedDirs = await getAllLockedDirectories();\n            allImages = allImages.filter(img => !lockedDirs.some(lockedDir => img.rel_path.startsWith(lockedDir + \"/\")));\n        }\n\n        if (allImages.length === 0) {\n            return res.status(404).json({ error: \"Not Found\" });\n        }\n\n        const randomIndex = Math.floor(Math.random() * allImages.length);\n        const randomImage = allImages[randomIndex];\n\n        // START CHANGE: Support format=json\n        // START CHANGE: Support format=json\n        if (req.query.format === 'json') {\n            return res.json(formatImageResponse(req, randomImage));\n        }\n        // END CHANGE\n        // END CHANGE\n\n        // 复用 serveImage\n        await serveImage(req, res, randomImage.rel_path);\n\n    } catch (e) {\n        console.error(\"Random image error:\", e);\n        res.status(500).json({ error: \"Failed to get random image\" });\n    }\n});\n\n// 服务图片内容\nrouter.get('/images/*', async (req, res) => {\n    const relPath = decodeURIComponent(req.params[0]);\n    await serveImage(req, res, relPath);\n});\n\n// 服务原始文件（无处理）\nrouter.get('/files/*', (req, res) => {\n    const relPath = decodeURIComponent(req.params[0]);\n    try {\n        const filePath = safeJoin(STORAGE_PATH, relPath);\n        if (fs.existsSync(filePath)) {\n            res.sendFile(filePath);\n        } else {\n            res.status(404).json({ error: \"Not found\" });\n        }\n    } catch (e) {\n        res.status(400).json({ error: \"Error\" });\n    }\n});\n\nmodule.exports = router;\n"
  },
  {
    "path": "server/routes/manageRoutes.js",
    "content": "const express = require('express');\nconst path = require('path');\nconst fs = require('fs-extra');\nconst config = require('../../config');\nconst { requirePassword } = require('../middleware/auth');\nconst imageRepository = require('../db/imageRepository');\nconst { syncFileSystem } = require('../services/syncService');\nconst { safeJoin, TRASH_DIR_NAME, CACHE_DIR_NAME } = require('../utils/fileUtils');\n\nconst router = express.Router();\nconst STORAGE_PATH = config.storage.path;\n\nconst { getAlbumPasswordPath, verifyAlbumPassword } = require('../utils/albumUtils');\n\n// 0. 手动同步\nrouter.post('/sync', requirePassword, async (req, res) => {\n    try {\n        await syncFileSystem();\n        res.json({ success: true, message: \"同步完成\" });\n    } catch (e) {\n        console.error(\"Sync failed:\", e);\n        res.status(500).json({ success: false, error: \"同步失败\" });\n    }\n});\n\n// 1. 相册密码管理\nrouter.post('/album/password', requirePassword, async (req, res) => {\n    try {\n        const { dir, password } = req.body;\n        if (dir === undefined) return res.status(400).json({ error: \"Missing directory\" });\n\n        const configPath = await getAlbumPasswordPath(dir);\n\n        if (!password) {\n            if (await fs.pathExists(configPath)) {\n                await fs.remove(configPath);\n            }\n            return res.json({ success: true, message: \"密码已移除\" });\n        }\n\n        await fs.ensureDir(path.dirname(configPath));\n        await fs.writeJSON(configPath, { password });\n        res.json({ success: true, message: \"密码设置成功\" });\n    } catch (e) {\n        console.error(\"Set album password error:\", e);\n        res.status(500).json({ error: \"设置密码失败\" });\n    }\n});\n\nrouter.post('/album/verify', requirePassword, async (req, res) => {\n    try {\n        const { dir, password } = req.body;\n        if (dir === undefined) return res.status(400).json({ error: \"Missing directory\" });\n\n        const isValid = await verifyAlbumPassword(dir, password);\n        if (isValid) {\n            res.json({ success: true, message: \"验证通过\" });\n        } else {\n            res.status(401).json({ success: false, error: \"密码错误\" });\n        }\n    } catch (e) {\n        res.status(500).json({ error: \"验证失败\" });\n    }\n});\n\n// 2. 回收站逻辑\nasync function moveToTrash(filePath) {\n    try {\n        const fileName = path.basename(filePath);\n        const ext = path.extname(fileName);\n        const nameWithoutExt = path.basename(fileName, ext);\n        const timestamp = Date.now();\n        const trashName = `${nameWithoutExt}_${timestamp}${ext}`;\n        const trashPath = path.join(STORAGE_PATH, TRASH_DIR_NAME, trashName);\n\n        await fs.ensureDir(path.dirname(trashPath));\n        await fs.move(filePath, trashPath, { overwrite: true });\n        return true;\n    } catch (error) {\n        console.error(\"[Trash] Move failed:\", error);\n        throw error;\n    }\n}\n\n// 3. 删除图片\nrouter.delete('/images/*', requirePassword, async (req, res) => {\n    const relPath = decodeURIComponent(req.params[0]);\n    try {\n        const filePath = safeJoin(STORAGE_PATH, relPath);\n        if (await fs.pathExists(filePath)) {\n            await moveToTrash(filePath);\n\n            // 移除 thumbhash\n            const dir = path.dirname(filePath);\n            const filename = path.basename(filePath);\n            const cacheFile = path.join(dir, CACHE_DIR_NAME, `${filename}.th`);\n            if (await fs.pathExists(cacheFile)) await fs.remove(cacheFile);\n\n            // 从 DB 移除\n            imageRepository.delete(relPath);\n\n            res.json({ success: true });\n        } else {\n            // 如果不在磁盘上但在 DB 中？\n            imageRepository.delete(relPath);\n            res.status(404).json({ error: \"图片不存在 (但在数据库中已清理)\" });\n        }\n    } catch (e) {\n        res.status(400).json({ error: \"操作失败\" });\n    }\n});\n\n// 4. 删除文件\nrouter.delete('/files/*', requirePassword, async (req, res) => {\n    const relPath = decodeURIComponent(req.params[0]);\n    try {\n        const filePath = safeJoin(STORAGE_PATH, relPath);\n        if (await fs.pathExists(filePath)) {\n            await moveToTrash(filePath);\n            // 如果存在则从 DB 移除（可能是通过 upload-file 上传的）\n            imageRepository.delete(relPath);\n            res.json({ success: true, message: \"文件已移至回收站\" });\n        } else {\n            res.status(404).json({ error: \"文件不存在\" });\n        }\n    } catch (e) {\n        res.status(400).json({ error: \"操作失败\" });\n    }\n});\n\n// 5. 批量移动\nrouter.post('/batch/move', requirePassword, async (req, res) => {\n    try {\n        const { files, targetDir } = req.body;\n        if (!Array.isArray(files) || files.length === 0) {\n            return res.status(400).json({ error: \"未选择文件\" });\n        }\n\n        let newDir = targetDir || \"\";\n        newDir = newDir.replace(/\\\\/g, \"/\").trim();\n        const absTargetDir = safeJoin(STORAGE_PATH, newDir);\n        await fs.ensureDir(absTargetDir);\n\n        let successCount = 0;\n        let failCount = 0;\n\n        for (const relPath of files) {\n            try {\n                const oldRelPath = decodeURIComponent(relPath).replace(/\\\\/g, \"/\");\n                const oldFilePath = safeJoin(STORAGE_PATH, oldRelPath);\n\n                if (await fs.pathExists(oldFilePath)) {\n                    const filename = path.basename(oldFilePath);\n                    let newRelPath = path.join(newDir, filename).replace(/\\\\/g, \"/\");\n                    let newFilePath = safeJoin(STORAGE_PATH, newRelPath);\n\n                    // Handle duplicates\n                    if (await fs.pathExists(newFilePath)) {\n                        let counter = 1;\n                        const ext = path.extname(filename);\n                        const nameBase = path.basename(filename, ext);\n                        while (await fs.pathExists(newFilePath)) {\n                            const newName = `${nameBase}_${Date.now()}_${counter}${ext}`;\n                            newRelPath = path.join(newDir, newName).replace(/\\\\/g, \"/\");\n                            newFilePath = safeJoin(STORAGE_PATH, newRelPath);\n                            counter++;\n                        }\n                    }\n\n                    await fs.move(oldFilePath, newFilePath);\n\n                    // 更新 DB：删除旧的，添加新的（重新扫描元数据？或者只是更新路径？）\n                    // 元数据应该不会改变太多，除非移动影响 mtime（通常在同一 FS 上不会）\n                    // 但更新路径最简单。\n                    // 但是，thumbhash 缓存文件也需要移动！\n\n                    // 移动 thumbhash\n                    const oldCachePath = path.join(path.dirname(oldFilePath), CACHE_DIR_NAME, `${filename}.th`);\n                    if (await fs.pathExists(oldCachePath)) {\n                        const newCacheDir = path.join(path.dirname(newFilePath), CACHE_DIR_NAME);\n                        await fs.ensureDir(newCacheDir);\n                        const newCachePath = path.join(newCacheDir, `${path.basename(newFilePath)}.th`);\n                        await fs.move(oldCachePath, newCachePath);\n                    }\n\n                    // 更新 DB\n                    const dbImage = imageRepository.getByPath(oldRelPath);\n                    if (dbImage) {\n                        dbImage.rel_path = newRelPath;\n                        dbImage.filename = path.basename(newFilePath);\n                        // 更新缓存中的 thumbhash 路径？不，DB 直接在 'thumbhash' 列中存储内容？\n                        // 等等，Schema 中 'thumbhash' 是 TEXT (base64)。\n                        // 所以我们不需要更新 DB thumbhash 内容，除非重新生成。\n                        // 我们只需更新路径。\n                        imageRepository.delete(oldRelPath);\n                        imageRepository.add(dbImage);\n                        // 或者在此处更新 rel_path = old... 但主键是 ID。\n                        // rel_path 是唯一的。\n                        // 其实 `imageRepository.update` 使用 rel_path 作为键。\n                        // 所以我不能轻易用我写的 `update` 函数更改 rel_path。\n                        // `updateImage` SQL: WHERE rel_path = @relPath。\n                        // 所以我必须删除并添加。\n                    }\n\n                    successCount++;\n                } else {\n                    failCount++;\n                }\n            } catch (e) {\n                console.error(`Move failed for ${relPath}:`, e);\n                failCount++;\n            }\n        }\n        res.json({ success: true, successCount, failCount });\n\n    } catch (e) {\n        res.status(500).json({ error: \"批量移动失败\" });\n    }\n});\n\n// 6. 创建目录\nrouter.post('/directories', requirePassword, async (req, res) => {\n    try {\n        const { name } = req.body;\n        if (!name) return res.status(400).json({ error: \"Missing directory name\" });\n\n        // Basic validation\n        if (name.includes(\"..\") || name.includes(\"\\\\\") || name.startsWith(\"/\")) {\n            return res.status(400).json({ error: \"Invalid directory name\" });\n        }\n\n        const absDir = safeJoin(STORAGE_PATH, name);\n        if (await fs.pathExists(absDir)) {\n            return res.status(400).json({ error: \"Directory already exists\" });\n        }\n\n        await fs.ensureDir(absDir);\n        res.json({ success: true, message: \"目录创建成功\" });\n    } catch (e) {\n        console.error(\"Create directory failed:\", e);\n        res.status(500).json({ error: \"创建目录失败\" });\n    }\n});\n\n// 7. 重命名图片\nrouter.put('/images/*', requirePassword, async (req, res) => {\n    const relPath = decodeURIComponent(req.params[0]);\n    const { newName } = req.body;\n\n    if (!newName || !newName.trim()) {\n        return res.status(400).json({ success: false, error: \"新文件名不能为空\" });\n    }\n\n    // 安全校验：不允许路径穿越或绝对路径\n    const safeName = path.basename(newName.trim());\n    if (!safeName || safeName !== newName.trim()) {\n        return res.status(400).json({ success: false, error: \"非法文件名\" });\n    }\n\n    try {\n        const oldFilePath = safeJoin(STORAGE_PATH, relPath);\n        if (!await fs.pathExists(oldFilePath)) {\n            return res.status(404).json({ success: false, error: \"原文件不存在\" });\n        }\n\n        const dir = path.dirname(relPath);\n        const newRelPath = (dir && dir !== '.') ? `${dir}/${safeName}` : safeName;\n        const newFilePath = safeJoin(STORAGE_PATH, newRelPath);\n\n        // 不允许重命名为自身\n        if (oldFilePath === newFilePath) {\n            return res.json({ success: true, data: { relPath, filename: path.basename(relPath) } });\n        }\n\n        // 目标已存在则报错\n        if (await fs.pathExists(newFilePath)) {\n            return res.status(409).json({ success: false, error: \"目标文件名已存在\" });\n        }\n\n        // 重命名文件\n        await fs.rename(oldFilePath, newFilePath);\n\n        // 移动 thumbhash 缓存（如存在）\n        const oldCacheFile = path.join(path.dirname(oldFilePath), CACHE_DIR_NAME, `${path.basename(oldFilePath)}.th`);\n        if (await fs.pathExists(oldCacheFile)) {\n            const newCacheFile = path.join(path.dirname(newFilePath), CACHE_DIR_NAME, `${safeName}.th`);\n            await fs.ensureDir(path.dirname(newCacheFile));\n            await fs.rename(oldCacheFile, newCacheFile);\n        }\n\n        // 原子更新数据库\n        const updated = imageRepository.rename(relPath, newRelPath, safeName);\n\n        const { formatImageResponse } = require('../utils/urlUtils');\n        const responseData = updated\n            ? formatImageResponse(req, updated)\n            : { relPath: newRelPath, filename: safeName };\n\n        res.json({ success: true, data: responseData });\n    } catch (e) {\n        console.error(\"Rename failed:\", e);\n        res.status(500).json({ success: false, error: \"重命名失败: \" + (e.message || e) });\n    }\n});\n\nmodule.exports = router;\n"
  },
  {
    "path": "server/routes/searchRoutes.js",
    "content": "const express = require('express');\nconst router = express.Router();\nconst clipService = require('../services/clipService');\nconst { formatImageResponse } = require('../utils/urlUtils');\n\n// 语义搜索\nrouter.post('/semantic', async (req, res) => {\n    try {\n        const { query, limit } = req.body;\n        if (!query) return res.status(400).json({ success: false, error: \"Query is required\" });\n\n        const results = await clipService.search(query, limit || 50);\n\n        // 使用 formatImageResponse 标准化输出\n        const finalResults = results.map(r => {\n            const formatted = formatImageResponse(req, r);\n            return {\n                ...formatted,\n                score: r.distance\n            };\n        });\n\n        res.json({ success: true, data: finalResults });\n    } catch (error) {\n        console.error(\"Semantic search error:\", error);\n        res.status(500).json({ success: false, error: \"Search failed\" });\n    }\n});\n\n// 触发全量扫描\nrouter.post('/scan', async (req, res) => {\n    try {\n        const result = await clipService.scanAll();\n        res.json({ success: true, ...result });\n    } catch (error) {\n        res.status(500).json({ success: false, error: error.message });\n    }\n});\n\n// 重新索引所有图片 (清除 DB 并重新扫描)\nrouter.post('/reindex', async (req, res) => {\n    try {\n        const result = await clipService.reindex();\n        res.json({ success: true, ...result });\n    } catch (error) {\n        res.status(500).json({ success: false, error: error.message });\n    }\n});\n\n// 状态\nrouter.get('/status', (req, res) => {\n    res.json({\n        success: true,\n        queueLength: clipService.queue.length,\n        processing: clipService.processing\n    });\n});\n\nmodule.exports = router;\n"
  },
  {
    "path": "server/routes/shareRoutes.js",
    "content": "const express = require('express');\nconst router = express.Router();\nconst shareRepository = require('../db/shareRepository');\nconst imageRepository = require('../db/imageRepository');\nconst { requirePassword } = require('../middleware/auth');\nconst { formatImageResponse } = require('../utils/urlUtils');\nconst path = require('path');\n\n// List Share Links for a path\nrouter.get('/list', requirePassword, (req, res) => {\n    try {\n        const { path: sharePath } = req.query;\n        // if (!sharePath) return res.status(400).json({ error: \"Missing path\" }); \n        // Allow listing ALL if path missing? Logic in AlbumManager passes path.\n\n        const shares = shareRepository.listByPath(sharePath || \"\");\n\n        // Calculate status for each share\n        const now = Date.now();\n        const result = shares.map(s => {\n            let status = 'active';\n            if (s.is_revoked) status = 'revoked';\n            else if (s.burn_after_reading && s.views > 0) status = 'burned';\n            else if (s.expire_seconds > 0) {\n                const expireTime = s.created_at + (s.expire_seconds * 1000);\n                if (now > expireTime) status = 'expired';\n            }\n            return {\n                token: s.token,\n                signature: s.token, // Using token as signature for now\n                path: s.path,\n                createdAt: s.created_at,\n                expireSeconds: s.expire_seconds,\n                burnAfterReading: !!s.burn_after_reading,\n                status,\n                views: s.views\n            };\n        });\n\n        res.json({ success: true, data: result });\n    } catch (e) {\n        console.error(\"List shares error:\", e);\n        res.status(500).json({ error: \"Failed to list shares\" });\n    }\n});\n\n// Generate Share Link\nrouter.post('/generate', requirePassword, (req, res) => {\n    try {\n        const { path, expireSeconds, burnAfterReading } = req.body;\n        if (path === undefined) return res.status(400).json({ error: \"Missing path\" });\n\n        const token = shareRepository.create({\n            path,\n            expireSeconds: expireSeconds || 0,\n            burnAfterReading: !!burnAfterReading\n        });\n\n        res.json({ success: true, token });\n    } catch (e) {\n        console.error(\"Generate share error:\", e);\n        res.status(500).json({ error: \"Failed to generate share\" });\n    }\n});\n\n// Revoke Share Link\nrouter.post('/revoke', requirePassword, (req, res) => {\n    try {\n        const { signature } = req.body;\n        if (!signature) return res.status(400).json({ error: \"Missing signature\" });\n\n        shareRepository.revoke(signature);\n        res.json({ success: true });\n    } catch (e) {\n        console.error(\"Revoke share error:\", e);\n        res.status(500).json({ error: \"Failed to revoke share\" });\n    }\n});\n\n// Delete Share Link (History)\nrouter.delete('/delete', requirePassword, (req, res) => {\n    try {\n        const { signature } = req.body; // or req.body.data if axios sends it there? Express req.body handles it if JSON middleware is on.\n        // Wait, DELETE with body? Client sends `data: { ... }`. Express `req.body` should have it.\n\n        if (!signature) return res.status(400).json({ error: \"Missing signature\" });\n\n        shareRepository.delete(signature);\n        res.json({ success: true });\n    } catch (e) {\n        console.error(\"Delete share error:\", e);\n        res.status(500).json({ error: \"Failed to delete share\" });\n    }\n});\n\n// Access Shared Content (Public)\nrouter.get('/access', (req, res) => {\n    try {\n        const { token, page = 1, pageSize = 20 } = req.query;\n        if (!token) return res.status(400).json({ error: \"Missing token\" });\n\n        const share = shareRepository.getByToken(token);\n        if (!share) return res.status(404).json({ error: \"Invalid link\" });\n\n        // Check verification (expiry, burn, revoked)\n        if (share.is_revoked) return res.status(403).json({ error: \"Link has been revoked\" });\n\n        const now = Date.now();\n        if (share.expire_seconds > 0) {\n            const expireTime = share.created_at + (share.expire_seconds * 1000);\n            if (now > expireTime) return res.status(403).json({ error: \"Link expired\" });\n        }\n\n        if (share.burn_after_reading && share.views > 0) {\n            return res.status(403).json({ error: \"Link already used (Burned)\" });\n        }\n\n        // Increment view count\n        shareRepository.incrementView(token);\n\n        // Get images\n        let images = imageRepository.getByDir(share.path);\n\n        // Pagination\n        const p = parseInt(page);\n        const ps = parseInt(pageSize);\n        const total = images.length;\n        const totalPages = Math.ceil(total / ps);\n        const start = (p - 1) * ps;\n        const end = start + ps;\n        const sliced = images.slice(start, end); // Basic memory pagination. For huge sets, DB limit/offset is better but we use `LIKE` which is tricky for deep pagination without more logic.\n\n        // Get dirname\n        const dirName = share.path.split('/').pop() || (share.path === \"\" ? \"全部图片\" : share.path);\n\n        res.json({\n            success: true,\n            data: sliced.map(img => formatImageResponse(req, img)),\n            dirName,\n            pagination: {\n                current: p,\n                pageSize: ps,\n                total,\n                totalPages\n            }\n        });\n\n    } catch (e) {\n        console.error(\"Share access error:\", e);\n        res.status(500).json({ error: \"Failed to access share\" });\n    }\n});\n\nmodule.exports = router;\n"
  },
  {
    "path": "server/routes/statsRoutes.js",
    "content": "const express = require('express');\nconst router = express.Router();\nconst imageRepository = require('../db/imageRepository');\nconst { requirePassword } = require('../middleware/auth');\n\n// 获取每日流量统计\nrouter.get('/traffic', requirePassword, async (req, res) => {\n    try {\n        const days = req.query.days ? parseInt(req.query.days) : 30;\n        const stats = imageRepository.getDailyStats(days);\n        // Ensure chronological order for charts (DB returns DESC)\n        const sorted = stats.reverse();\n        res.json({ success: true, data: sorted });\n    } catch (e) {\n        console.error(\"Fetch traffic stats error:\", e);\n        res.status(500).json({ error: \"获取流量数据失败\" });\n    }\n});\n\n// 获取热门图片\nrouter.get('/top', requirePassword, async (req, res) => {\n    try {\n        const limit = req.query.limit ? parseInt(req.query.limit) : 10;\n        \n        const { getAllLockedDirectories } = require('../utils/albumUtils');\n        const lockedDirs = await getAllLockedDirectories();\n        \n        const allImagesSorted = imageRepository.getAllByViews();\n        const filteredImages = allImagesSorted.filter(img => \n            !lockedDirs.some(lockedDir => img.rel_path.startsWith(lockedDir + \"/\"))\n        );\n        const topImages = filteredImages.slice(0, limit);\n\n        // Map to standard response format if needed, or just return DB rows\n        const data = topImages.map(img => ({\n            filename: img.filename,\n            relPath: img.rel_path,\n            views: img.views,\n            size: img.size,\n            uploadTime: img.upload_time,\n            url: `/api/images/${img.rel_path.split(\"/\").map(encodeURIComponent).join(\"/\")}?w=200`\n        }));\n\n        res.json({ success: true, data });\n    } catch (e) {\n        console.error(\"Fetch top images error:\", e);\n        res.status(500).json({ error: \"获取热门图片失败\" });\n    }\n});\n\nmodule.exports = router;\n"
  },
  {
    "path": "server/routes/systemRoutes.js",
    "content": "const express = require('express');\nconst imageRepository = require('../db/imageRepository');\n\nconst router = express.Router();\n\nrouter.get('/health', (req, res) => {\n    res.status(200).json({ status: \"ok\" });\n});\n\nrouter.get('/stats', (req, res) => {\n    // 基本统计\n    const count = imageRepository.count();\n    res.json({\n        success: true,\n        data: {\n            imageCount: count\n        }\n    });\n});\n\nrouter.get('/config', (req, res) => {\n    // 返回安全公开配置\n    const config = require('../../config');\n    res.json({\n        success: true,\n        data: {\n            upload: {\n                maxFileSize: config.upload.maxFileSize,\n                allowedExtensions: config.upload.allowedExtensions\n            },\n            storage: {\n                filename: config.storage.filename\n            },\n            magicSearch: {\n                enabled: config.magicSearch.enabled\n            }\n        }\n    });\n});\n\nrouter.get('/auth/status', (req, res) => {\n    const config = require('../../config');\n    res.json({\n        success: true,\n        data: {\n            enabled: config.security.password.enabled,\n            // 此处不验证密码，仅返回状态\n            // 前端调用此接口以检查是否需要提示输入密码\n        }\n    });\n});\n\nrouter.post('/auth/login', (req, res) => {\n    const config = require('../../config');\n    const { password } = req.body;\n    if (!config.security.password.enabled) {\n        return res.json({ success: true, message: \"No password required\" });\n    }\n    if (password === config.security.password.accessPassword) {\n        return res.json({ success: true, message: \"Login successful\" });\n    }\n    res.status(401).json({ success: false, error: \"Incorrect password\" });\n});\n\nmodule.exports = router;\n"
  },
  {
    "path": "server/routes/uploadRoutes.js",
    "content": "const express = require('express');\nconst path = require('path');\nconst fs = require('fs-extra');\nconst config = require('../../config');\nconst { upload, uploadAny, handleMulterError } = require('../middleware/upload');\nconst { requirePassword } = require('../middleware/auth');\nconst { saveBase64Image, safeJoin, sanitizeFilename, generateThumbHash, downloadFromUrl } = require('../utils/fileUtils');\nconst { formatImageResponse } = require('../utils/urlUtils');\nconst imageRepository = require('../db/imageRepository');\nconst { getFileMetadata, parseAudioDuration } = require('../services/metadataService');\nconst clipService = require('../services/clipService'); // 引入 ClipService\nconst sharp = require('sharp');\n\nconst router = express.Router();\nconst STORAGE_PATH = config.storage.path;\n\nfunction getBaseUrl(req) {\n    const proto = req.headers[\"x-forwarded-proto\"] || req.protocol;\n    const protocol = Array.isArray(proto) ? proto[0] : String(proto).split(\",\")[0].trim();\n    const host = req.headers[\"x-forwarded-host\"] || req.get(\"host\");\n    return `${protocol}://${host}`;\n}\n\n// 1. Base64 上传\nrouter.post('/upload-base64', requirePassword, async (req, res) => {\n    try {\n        let dir = req.body.dir || req.query.dir || \"\";\n        dir = dir.replace(/\\\\/g, \"/\");\n\n        if (!req.body.base64Image) {\n            return res.status(400).json({ success: false, error: \"缺少 base64Image 参数\" });\n        }\n\n        const { filename, filePath, size, mimetype } = await saveBase64Image(req.body.base64Image, dir);\n        const relPath = path.join(dir, filename).replace(/\\\\/g, \"/\");\n\n        // 生成元数据和 DB 条目\n        const metadata = await getFileMetadata(filePath, relPath);\n        // Base64 上传通常没有原始名称，使用清理后的文件名或提供的名称\n        const originalName = req.body.originalName || filename;\n\n        const fileInfo = {\n            filename: metadata.filename || filename, // metadata might not have filename set if constructed manually\n            rel_path: relPath,\n            ...metadata\n        };\n\n        // 确保文件名在 DB 对象中设置（getFileMetadata 返回带有 size, mtime 等的对象）\n        // imageRepository 期望：filename, rel_path, ...metadata\n        const dbResult = imageRepository.add({\n            filename: sanitizeFilename(originalName),\n            rel_path: relPath,\n            ...metadata\n        });\n\n        // 添加到魔法搜图队列\n        try {\n            let imageId = dbResult.lastInsertRowid;\n            if (!imageId || imageId.toString() === '0') {\n                const existing = imageRepository.getByPath(relPath);\n                if (existing) imageId = existing.id;\n            }\n            if (imageId) {\n                clipService.addToQueue({ id: imageId, rel_path, filename: fileInfo.filename });\n            }\n        } catch (queueErr) {\n            console.error(\"Queue error:\", queueErr);\n        }\n\n        // 添加到魔法搜图队列\n        try {\n            let imageId = dbResult.lastInsertRowid;\n            if (!imageId || imageId.toString() === '0') {\n                const existing = imageRepository.getByPath(relPath);\n                if (existing) imageId = existing.id;\n            }\n            if (imageId) {\n                clipService.addToQueue({ id: imageId, rel_path, filename: fileInfo.filename });\n            }\n        } catch (queueErr) {\n            console.error(\"Queue error:\", queueErr);\n        }\n\n        // 记录上传统计信息\n        imageRepository.recordUpload(size);\n\n        // 使用 helper 格式化\n        const formatted = formatImageResponse(req, imageRepository.getByPath(relPath) || {\n            filename: fileInfo.filename,\n            rel_path: relPath,\n            width: metadata.width,\n            height: metadata.height,\n            size: size,\n            upload_time: fileInfo.upload_time,\n            mime_type: mimetype,\n            thumbhash: metadata.thumbhash\n        });\n\n        res.json({\n            success: true,\n            message: \"base64 图片上传成功\",\n            data: {\n                ...formatted,\n                originalName: originalName,\n                mimetype: mimetype\n            }\n        });\n    } catch (error) {\n        console.error(\"base64 上传错误:\", error);\n        return res.status(400).json({ success: false, error: error.message || \"base64 图片处理失败\" });\n    }\n});\n\n// 1.0 URL 上传\nrouter.post('/upload-url', requirePassword, async (req, res) => {\n    try {\n        const { url } = req.body;\n        if (!url) {\n            return res.status(400).json({ success: false, error: \"缺少 url 参数\" });\n        }\n\n        let dir = req.body.dir || \"\";\n        dir = dir.replace(/\\\\/g, \"/\");\n\n        // Download image from URL\n        let imageData;\n        try {\n            imageData = await downloadFromUrl(url);\n        } catch (downloadErr) {\n            return res.status(400).json({ success: false, error: `下载图片失败: ${downloadErr.message}` });\n        }\n\n        // Convert to base64 and save\n        const base64Data = `data:${imageData.mimetype};base64,${imageData.buffer.toString('base64')}`;\n        const { filename, filePath, size, mimetype } = await saveBase64Image(base64Data, dir);\n        const relPath = path.join(dir, filename).replace(/\\\\/g, \"/\");\n\n        // Generate metadata\n        const metadata = await getFileMetadata(filePath, relPath);\n\n        // Extract original name from URL if possible\n        const urlPathname = new URL(url).pathname;\n        const urlFilename = decodeURIComponent(urlPathname.split('/').pop() || filename);\n        const ext = path.extname(urlFilename);\n        const nameWithoutExt = path.basename(urlFilename, ext);\n        const originalName = ext ? `${nameWithoutExt}${ext}` : filename;\n\n        const dbResult = imageRepository.add({\n            filename: sanitizeFilename(originalName),\n            rel_path: relPath,\n            ...metadata\n        });\n\n        // Add to magic search queue\n        try {\n            let imageId = dbResult.lastInsertRowid;\n            if (!imageId || imageId.toString() === '0') {\n                const existing = imageRepository.getByPath(relPath);\n                if (existing) imageId = existing.id;\n            }\n            if (imageId) {\n                clipService.addToQueue({ id: imageId, rel_path: relPath, filename: originalName });\n            }\n        } catch (queueErr) {\n            console.error(\"Queue error:\", queueErr);\n        }\n\n        // Record upload stats\n        imageRepository.recordUpload(size);\n\n        const formatted = formatImageResponse(req, imageRepository.getByPath(relPath) || {\n            filename: originalName,\n            rel_path: relPath,\n            width: metadata.width,\n            height: metadata.height,\n            size: size,\n            upload_time: metadata.upload_time,\n            mime_type: mimetype,\n            thumbhash: metadata.thumbhash\n        });\n\n        res.json({\n            success: true,\n            message: \"URL 图片上传成功\",\n            data: {\n                ...formatted,\n                originalName: originalName,\n                mimetype: mimetype\n            }\n        });\n    } catch (error) {\n        console.error(\"URL 上传错误:\", error);\n        return res.status(500).json({ success: false, error: error.message || \"URL 图片上传失败\" });\n    }\n});\n\n// 1.1 上传图片 (Multer)\nrouter.post('/upload', requirePassword, upload.any(), handleMulterError, async (req, res) => {\n    try {\n        let dir = req.body.dir || req.query.dir || \"\";\n        dir = dir.replace(/\\\\/g, \"/\");\n\n        if (req.files && req.files.length > 0) req.file = req.files[0];\n        if (!req.file) return res.status(400).json({ success: false, error: \"没有选择文件\" });\n\n        // 如果需要移动文件（multer storage 逻辑基本已处理，但需再次检查？）\n        // 自定义 multer storage 已经将其放置在正确的目录和名称下。\n        // 所以 req.file.path 是正确的。\n\n        const relPath = path.join(dir, req.file.filename).replace(/\\\\/g, \"/\");\n\n        // 元数据与数据库\n        const metadata = await getFileMetadata(req.file.path, relPath);\n\n        // 原始名称处理\n        let originalName = req.file.originalname;\n        if (!/[^\\u0000-\\u00ff]/.test(originalName)) {\n            try { originalName = Buffer.from(originalName, \"latin1\").toString(\"utf8\"); } catch (e) { }\n        }\n\n        const dbResult = imageRepository.add({\n            filename: req.file.filename, // 这是磁盘上的保存文件名\n            rel_path: relPath,\n            ...metadata\n        });\n\n        // 检查是否覆盖了现有文件，如果是则清除 sharp 缓存\n        const forceOverwrite =\n            req.query.overwrite === \"true\" ||\n            req.body?.overwrite === \"true\" ||\n            req.query.overwrite === true ||\n            req.body?.overwrite === true;\n        if (forceOverwrite) {\n            try {\n                // 清除 sharp 缓存以确保下次访问读取新文件\n                sharp.cache(false);\n                sharp.cache(true);\n            } catch (e) { }\n        }\n\n        // 添加到魔法搜图队列\n        try {\n            let imageId = dbResult.lastInsertRowid;\n            if (!imageId || imageId.toString() === '0') {\n                const existing = imageRepository.getByPath(relPath);\n                if (existing) imageId = existing.id;\n            }\n            if (imageId) {\n                clipService.addToQueue({ id: imageId, rel_path: relPath, filename: req.file.filename }, 'high');\n            }\n        } catch (queueErr) {\n            console.error(\"Queue error:\", queueErr);\n        }\n\n        // 记录上传统计信息\n        imageRepository.recordUpload(req.file.size);\n\n        // Helper\n        const formatted = formatImageResponse(req, {\n            filename: req.file.filename,\n            rel_path: relPath,\n            width: metadata.width,\n            height: metadata.height,\n            size: req.file.size,\n            upload_time: metadata.upload_time,\n            mime_type: req.file.mimetype,\n            thumbhash: metadata.thumbhash\n        });\n\n        res.json({\n            success: true,\n            message: \"图片上传成功\",\n            data: {\n                ...formatted,\n                originalName: originalName,\n                mimetype: req.file.mimetype\n            }\n        });\n\n    } catch (error) {\n        console.error(\"上传错误:\", error);\n        res.status(500).json({ success: false, error: \"上传失败，请稍后重试\" });\n    }\n});\n\n// 1.2 上传文件 (任意)\nrouter.post('/upload-file', requirePassword, uploadAny.single(\"file\"), handleMulterError, async (req, res) => {\n\n    try {\n        if (!req.file) return res.status(400).json({ success: false, error: \"没有选择文件\" });\n\n        let dir = req.body.dir || req.query.dir || \"\";\n        dir = dir.replace(/\\\\/g, \"/\");\n\n        // ... (来自原始 index.js 的重命名逻辑) ...\n        // 我将在此处实现重命名逻辑，还是仅依赖 multer？\n        // Multer 处理了基本命名。`upload-file` 具有自定义的“手动重命名”逻辑。\n        // 我需要手动移植该逻辑。\n\n        const customFilename = req.body.filename || req.query.filename;\n        let finalFilename = req.file.filename;\n        let displayName = req.file.originalname;\n\n        if (customFilename) {\n            // 重命名逻辑...\n            const safeCustom = sanitizeFilename(customFilename);\n            const targetDir = safeJoin(STORAGE_PATH, dir);\n            const oldPath = req.file.path;\n            let newPath = path.join(targetDir, safeCustom);\n\n            // 重复检查\n            let counter = 1;\n            const ext = path.extname(safeCustom);\n            const nameBase = path.basename(safeCustom, ext);\n\n            if (!config.upload.allowDuplicateNames) {\n                while (fs.existsSync(newPath)) {\n                    if (config.upload.duplicateStrategy === 'timestamp') {\n                        newPath = path.join(targetDir, `${nameBase}_${Date.now()}_${counter}${ext}`);\n                    } else {\n                        newPath = path.join(targetDir, `${nameBase}_${counter}${ext}`);\n                    }\n                    counter++;\n                }\n            }\n            finalFilename = path.basename(newPath);\n            displayName = customFilename;\n            if (oldPath !== newPath) {\n                fs.renameSync(oldPath, newPath);\n            }\n        }\n\n        const relPath = path.join(dir, finalFilename).replace(/\\\\/g, \"/\");\n        const filePath = safeJoin(STORAGE_PATH, relPath);\n\n        // 检查我们是否应该索引它\n        const ext = path.extname(finalFilename).toLowerCase();\n        // 仅在匹配“图片列表”的允许扩展名时索引\n        // 如果用户上传了允许图片之外的通用文件，我们将其保留在磁盘上\n        // 但不添加到 DB。\n        if (config.upload.allowedExtensions.includes(ext)) {\n            const metadata = await getFileMetadata(filePath, relPath);\n            imageRepository.add({\n                filename: finalFilename,\n                rel_path: relPath,\n                ...metadata\n            });\n        }\n\n        // 时长逻辑\n        let duration = null;\n        if (req.file.mimetype === 'audio/mpeg' || (customFilename && customFilename.toLowerCase().endsWith('.mp3'))) {\n            try {\n                // 我们可以使用 metadataService 中的逻辑！\n                const d = await parseAudioDuration(filePath);\n                if (d) duration = parseFloat((Math.ceil(d * 1000) / 1000).toFixed(2));\n            } catch (e) { }\n        }\n\n        // 记录上传统计信息\n        imageRepository.recordUpload(req.file.size);\n\n        const isImage = config.upload.allowedExtensions.includes(path.extname(finalFilename).toLowerCase());\n        const relPathStr = relPath.split(\"/\").map(encodeURIComponent).join(\"/\");\n        const endpoint = isImage ? 'images' : 'files';\n        const url = `/api/${endpoint}/${relPathStr}`;\n        const fullUrl = `${req.protocol}://${req.get('host')}${url}`;\n\n        res.json({\n            success: true,\n            message: \"文件上传成功\",\n            data: {\n                filename: finalFilename,\n                originalName: displayName,\n                size: req.file.size,\n                mimetype: req.file.mimetype,\n                uploadTime: new Date().toISOString(),\n                url: url,\n                relPath,\n                fullUrl: fullUrl, // Standardized field\n                ...(duration && { duration })\n            }\n        });\n\n    } catch (error) {\n        console.error(\"文件上传错误:\", error);\n        res.status(500).json({ success: false, error: \"文件上传失败\" });\n    }\n});\n\nmodule.exports = router;\n"
  },
  {
    "path": "server/services/clipService.js",
    "content": "const config = require('../../config');\nconst db = require('../db/database');\nconst path = require('path');\nconst fs = require('fs-extra');\n\nlet Pipeline = null;\n\nclass ClipService {\n    constructor() {\n        this.modelName = config.magicSearch.modelName || 'Xenova/clip-vit-base-patch32';\n        this.tokenizer = null;\n        this.processor = null;\n        this.model = null;\n        this.visionModel = null;\n        this.textModel = null;\n        this.translator = null;\n        this.pipeline = null;\n\n        // 队列状态\n        this.queue = [];\n        this.processing = false;\n        this.queueInterval = 2000; // 每个项目之间延迟 2 秒，为 N100 留出呼吸空间\n    }\n\n    static getInstance() {\n        if (!ClipService.instance) {\n            ClipService.instance = new ClipService();\n        }\n        return ClipService.instance;\n    }\n\n    async getModels() {\n        if (this.processor && this.tokenizer && this.visionModel && this.textModel) {\n            return {\n                processor: this.processor,\n                tokenizer: this.tokenizer,\n                visionModel: this.visionModel,\n                textModel: this.textModel\n            };\n        }\n\n        console.log(`[MagicSearch] Loading model components: ${this.modelName}...`);\n        try {\n            // Dynamic import for ESM module\n            const {\n                AutoProcessor,\n                AutoTokenizer,\n                CLIPVisionModelWithProjection,\n                CLIPTextModelWithProjection,\n                RawImage,\n                env\n            } = await import('@huggingface/transformers');\n\n            this.RawImage = RawImage;\n\n            // 配置为使用本地缓存\n            env.cacheDir = path.resolve(__dirname, '../../.cache/huggingface');\n            env.allowLocalModels = false;\n            env.useBrowserCache = false;\n\n            // 允许自定义 HuggingFace 端点 (用于国内镜像，如 https://hf-mirror.com)\n            if (process.env.HF_ENDPOINT) {\n                env.remoteHost = process.env.HF_ENDPOINT;\n                console.log(`[MagicSearch] Using custom HF endpoint: ${env.remoteHost}`);\n            }\n\n            // 加载组件\n            this.processor = await AutoProcessor.from_pretrained(this.modelName);\n            this.tokenizer = await AutoTokenizer.from_pretrained(this.modelName);\n            this.visionModel = await CLIPVisionModelWithProjection.from_pretrained(this.modelName, {\n                quantized: true,\n                dtype: 'q8', // 显式指定量化类型，消除 N100 上的 fp32 警告\n            });\n            this.textModel = await CLIPTextModelWithProjection.from_pretrained(this.modelName, {\n                quantized: true,\n                dtype: 'q8', // 显式指定量化类型，消除 N100 上的 fp32 警告\n            });\n\n            console.log(`[MagicSearch] Models loaded successfully.`);\n            return {\n                processor: this.processor,\n                tokenizer: this.tokenizer,\n                visionModel: this.visionModel,\n                textModel: this.textModel\n            };\n        } catch (error) {\n            console.error(`[MagicSearch] Failed to load models:`, error);\n            throw error;\n        }\n    }\n\n    normalize(vector) {\n        const magnitude = Math.sqrt(vector.reduce((sum, val) => sum + val * val, 0));\n        return vector.map(val => val / magnitude);\n    }\n\n    // 生成文本嵌入\n    async getTextEmbedding(text) {\n        const { tokenizer, textModel } = await this.getModels();\n\n        // 分词\n        const inputs = await tokenizer([text], { padding: true, truncation: true });\n\n        // 推理\n        const { text_embeds } = await textModel(inputs);\n\n        // 输出为 Tensor，需要转换为数组并归一化\n        // text_embeds 为 [batch_size, embed_dim] -> [1, 512]\n        const rawEmbedding = Array.from(text_embeds.data);\n        return this.normalize(rawEmbedding);\n    }\n\n    // 生成图片嵌入\n    async getImageEmbedding(imagePath) {\n        const { processor, visionModel } = await this.getModels();\n\n        try {\n            // 读取图片并处理\n            const image = await this.RawImage.read(imagePath);\n            const image_inputs = await processor(image);\n\n            // 推理\n            const { image_embeds } = await visionModel(image_inputs);\n\n            // 转换并归一化\n            const rawEmbedding = Array.from(image_embeds.data);\n            return this.normalize(rawEmbedding);\n        } catch (e) {\n            console.error(`[MagicSearch] Image processing failed for ${imagePath}:`, e);\n            throw e;\n        }\n    }\n\n    // 添加图片到处理队列\n    // 优先级: 'high' (上传) -> unshift (前), 'low' (历史) -> push (后)\n    addToQueue(image, priority = 'low') {\n        if (!config.magicSearch.enabled) return;\n\n        // 去重：检查图片是否已在队列中\n        if (this.queue.find(item => item.id === image.id)) return;\n\n        if (priority === 'high') {\n            this.queue.unshift(image);\n            console.log(`[MagicSearch] Added image ${image.id} (High Priority) to queue. Queue size: ${this.queue.length}`);\n        } else {\n            this.queue.push(image);\n            console.log(`[MagicSearch] Added image ${image.id} (Low Priority) to queue. Queue size: ${this.queue.length}`);\n        }\n\n        this.processQueue();\n    }\n\n    // 处理队列\n    async processQueue() {\n        if (this.processing || this.queue.length === 0) return;\n\n        this.processing = true;\n\n        while (this.queue.length > 0) {\n            const image = this.queue.shift();\n\n            try {\n                await this.processImage(image);\n            } catch (err) {\n                console.error(`[MagicSearch] Error processing image ${image.id}:`, err);\n            }\n\n            // 等待片刻让 CPU 喘口气 (N100 优化)\n            if (this.queue.length > 0) {\n                await new Promise(resolve => setTimeout(resolve, this.queueInterval));\n            }\n        }\n\n        this.processing = false;\n    }\n\n    async processImage(image) {\n        // 过滤非图片文件 (如视频)\n        const ext = path.extname(image.rel_path).toLowerCase();\n        const supportedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.avif'];\n        if (!supportedExts.includes(ext)) {\n            // console.log(`[MagicSearch] Skipping unsupported file type: ${image.filename}`);\n            return;\n        }\n\n        const imagePath = path.resolve(config.storage.path, image.rel_path);\n\n        if (!fs.existsSync(imagePath)) {\n            console.warn(`[MagicSearch] File not found: ${imagePath}`);\n            return;\n        }\n\n        // 检查嵌入是否已存在\n        const existing = db.prepare('SELECT image_id FROM vec_images WHERE image_id = ?').get(image.id);\n        if (existing) {\n            // console.log(`[MagicSearch] Embedding already exists for ${image.id}, skipping.`);\n            return;\n        }\n\n        // console.log(`[MagicSearch] Generating embedding for ${image.filename}...`);\n        const embedding = await this.getImageEmbedding(imagePath);\n\n        // 保存到向量数据库\n        // sqlite-vec 期望原始 float32 字节数组或特定处理 \n        // better-sqlite3 with sqlite-vec extension usually handles Float32Array directly if registered,\n        // otherwise we might need to serialize. \n        // The `vec0` virtual table accepts JSON array string or binary blob. \n        // Let's try passing Float32Array directly (better-sqlite3 typed array support)\n        // or JSON string as fallback.\n\n        // IMPORTANT: sqlite-vec usually requires the embedding to be inserted.\n        // Ensure we are using transaction if batching, but here is single.\n\n        try {\n            // 准备插入语句\n            // 对于 vec0，标准 INSERT INTO 有效\n            // 我们使用 JSON.stringify(embedding) 作为首次尝试以确保安全，\n            // 因为 better-sqlite3 可能会将数组绑定为其他类型\n            // 但 vec0 支持 JSON 文本输入\n            // sqlite-vec 期望原始 float32 字节数组或有效的 JSON\n            // 我们使用 'image_id'，它是主键\n            const stmt = db.prepare(\"INSERT INTO vec_images(image_id, embedding) VALUES (?, ?)\");\n\n            // 调试 ID\n            // console.log(`[MagicSearch] Inserting ${image.id} (type: ${typeof image.id})`);\n\n            // 使用 BigInt 作为 ID 以匹配 INTEGER 亲和性，并使用 Float32Array 作为嵌入\n            stmt.run(BigInt(image.id), new Float32Array(embedding));\n\n            // console.log(`[MagicSearch] Saved embedding for ${image.id}`);\n        } catch (dbErr) {\n            console.error(`[MagicSearch] DB Insert Error for ${image.id}:`, dbErr);\n        }\n    }\n\n    // 清除所有嵌入并重新扫描\n    async reindex() {\n        if (!config.magicSearch.enabled) return { success: false, message: \"Magic Search disabled\" };\n\n        console.log(\"[MagicSearch] Reindexing requested. Clearing vector table...\");\n\n        try {\n            db.prepare(\"DELETE FROM vec_images\").run();\n            console.log(\"[MagicSearch] Vector table cleared.\");\n        } catch (e) {\n            console.error(\"[MagicSearch] Failed to clear vector table:\", e);\n            throw e;\n        }\n\n        return this.scanAll();\n    }\n\n    // 触发扫描所有没有嵌入的图片\n    async scanAll() {\n        if (!config.magicSearch.enabled) return { success: false, message: \"Magic Search disabled\" };\n\n        console.log(\"[MagicSearch] Starting background scan for missing embeddings...\");\n\n        // 在 `images` 表中查找不存在于 `vec_images` 中的所有图片\n        const images = db.prepare(`\n      SELECT i.id, i.filename, i.rel_path \n      FROM images i \n      LEFT JOIN vec_images v ON i.id = v.image_id \n      WHERE v.image_id IS NULL\n    `).all();\n\n        console.log(`[MagicSearch] Found ${images.length} historical images to process.`);\n\n        // 全部添加到队列 (低优先级) - 仅过滤支持的图片\n        const supportedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.avif'];\n        let queuedCount = 0;\n\n        for (const img of images) {\n            const ext = path.extname(img.rel_path).toLowerCase();\n            if (supportedExts.includes(ext)) {\n                this.addToQueue(img, 'low');\n                queuedCount++;\n            }\n        }\n\n        console.log(`[MagicSearch] Queued ${queuedCount} images for background processing.`);\n\n        return { success: true, count: queuedCount };\n    }\n\n    // 加载翻译模型 (懒加载)\n    async getTranslator() {\n        if (this.translator) return this.translator;\n\n        console.log(`[MagicSearch] Loading translation model (opus-mt-zh-en)...`);\n        try {\n            const { pipeline, env } = await import('@huggingface/transformers');\n            env.cacheDir = path.resolve(__dirname, '../../.cache/huggingface');\n\n            if (process.env.HF_ENDPOINT) {\n                env.remoteHost = process.env.HF_ENDPOINT;\n            }\n\n            this.translator = await pipeline('translation', 'Xenova/opus-mt-zh-en', {\n                // 开启量化，显著降低 N100 的内存压力和 CPU 占用\n                quantized: true,\n                dtype: 'q8', // 显式指定 q8 类型，消除 fp32 警告\n            });\n            console.log(`[MagicSearch] Translation model loaded.`);\n            return this.translator;\n        } catch (e) {\n            console.error(`[MagicSearch] Failed to load translator:`, e);\n            return null;\n        }\n    }\n\n    // 按需翻译\n    async translate(text) {\n        // 简单检查是否包含中文字符\n        if (!/[\\u4e00-\\u9fa5]/.test(text)) return text;\n\n        try {\n            const translator = await this.getTranslator();\n            if (!translator) return text;\n\n            const output = await translator(text, {\n                max_new_tokens: 40,\n                temperature: 0.1 // 降低随机性，让翻译更准确\n            });\n            // Output format: [{ translation_text: '...' }]\n            if (output && output[0] && output[0].translation_text) {\n                const translated = output[0].translation_text;\n                console.log(`[MagicSearch] Translated: \"${text}\" -> \"${translated}\"`);\n                return translated;\n            }\n        } catch (e) {\n            console.warn(`[MagicSearch] Translation failed for \"${text}\":`, e);\n        }\n        return text;\n    }\n\n    // 语义搜索\n    async search(queryText, limit = 50) {\n        if (!config.magicSearch.enabled) return [];\n\n        try {\n            // 自动翻译中文查询\n            const finalQuery = await this.translate(queryText);\n\n            const embedding = await this.getTextEmbedding(finalQuery);\n\n            // 查询向量数据库\n            // 我们联接回 images 表以获取文件详情\n            const results = db.prepare(`\n        SELECT \n          i.*, \n          vec_distance_cosine(v.embedding, ?) as distance\n        FROM vec_images v\n        JOIN images i ON v.image_id = i.id\n        ORDER BY distance\n        LIMIT ?\n      `).all(JSON.stringify(embedding), limit);\n\n            return results;\n        } catch (e) {\n            console.error(\"[MagicSearch] Search failed:\", e);\n            throw e;\n        }\n    }\n}\n\nmodule.exports = ClipService.getInstance();\n"
  },
  {
    "path": "server/services/metadataService.js",
    "content": "const exifr = require(\"exifr\");\nconst mm = require(\"music-metadata\");\nconst fs = require(\"fs-extra\");\nconst path = require(\"path\");\nconst sharp = require('sharp');\nsharp.cache(false);\nconst { getThumbHash, generateThumbHash } = require(\"../utils/fileUtils\");\n\nasync function parseImageMetadata(filePath) {\n    try {\n        const meta = await exifr.parse(filePath, {\n            gps: true,\n            tiff: true,\n            ifd0: true,\n            exif: true\n        });\n        return meta || {};\n    } catch (e) {\n        return {};\n    }\n}\n\nasync function parseAudioDuration(filePath) {\n    try {\n        const metadata = await mm.parseFile(filePath, { duration: true });\n        return metadata.format.duration;\n    } catch (error) {\n        // console.error('解析音频时长失败:', error);\n        return null;\n    }\n}\n\nasync function parseVideoDuration(filePath) {\n    return parseAudioDuration(filePath);\n}\n\n// 组合所有信息以返回标准化的 DB 对象\nasync function getFileMetadata(filePath, relPath, existingStat = null) {\n    const stat = existingStat || await fs.stat(filePath);\n    const ext = path.extname(filePath).toLowerCase();\n\n    let width = null;\n    let height = null;\n    let orientation = null;\n    let metaJson = {};\n    let duration = null;\n\n    // 图片元数据 (Sharp 用于获取可靠的统计信息 + Exifr 用于获取详细信息)\n    const IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.webp', '.tiff', '.tif', '.heif', '.heic', '.avif', '.gif', '.svg'];\n\n    if (IMAGE_EXTS.includes(ext)) {\n        // 1. Sharp 提取 (可靠的尺寸和空间信息)\n        try {\n            const sharpMeta = await sharp(filePath).metadata();\n            width = sharpMeta.width;\n            height = sharpMeta.height;\n            metaJson.space = sharpMeta.space; // srgb, cmyk, 等\n            metaJson.channels = sharpMeta.channels;\n            metaJson.density = sharpMeta.density;\n            metaJson.format = sharpMeta.format;\n            metaJson.hasAlpha = sharpMeta.hasAlpha;\n            orientation = sharpMeta.orientation; // Sharp 通常会标准化，但我们保留它\n        } catch (e) {\n            // console.log(\"Sharp metadata failed:\", e.message);\n        }\n\n        // 2. EXIF 提取 (照片详细信息)\n        // 仅适用于通常支持 EXIF 的格式\n        if (['.jpg', '.jpeg', '.png', '.webp', '.tiff', '.heif', '.heic'].includes(ext)) {\n            const exif = await parseImageMetadata(filePath);\n            if (exif) {\n                metaJson.exif = {};\n\n                if (exif.latitude && exif.longitude) {\n                    metaJson.gps = { lat: exif.latitude, lng: exif.longitude };\n                    metaJson.exif.latitude = exif.latitude;\n                    metaJson.exif.longitude = exif.longitude;\n                }\n\n                // 日期\n                if (exif.DateTimeOriginal || exif.CreateDate) {\n                    metaJson.date = exif.DateTimeOriginal || exif.CreateDate;\n                    metaJson.exif.dateTimeOriginal = metaJson.date;\n                }\n\n                // 相机规格\n                if (exif.Make) metaJson.exif.make = exif.Make;\n                if (exif.Model) metaJson.exif.model = exif.Model;\n                if (exif.LensModel) metaJson.exif.lensModel = exif.LensModel;\n                if (exif.FNumber) metaJson.exif.fNumber = exif.FNumber;\n                if (exif.ExposureTime) metaJson.exif.exposureTime = exif.ExposureTime;\n                if (exif.ISO) metaJson.exif.iso = exif.ISO;\n\n                // 如果 sharp 失败，回退到 exif 尺寸\n                if (!width && exif.ExifImageWidth) width = exif.ExifImageWidth;\n                if (!height && exif.ExifImageHeight) height = exif.ExifImageHeight;\n                if (!orientation && exif.Orientation) orientation = exif.Orientation;\n            }\n        }\n    }\n\n    // 音频/视频时长\n    if (EXT_AUDIO.includes(ext) || EXT_VIDEO.includes(ext)) {\n        duration = await parseAudioDuration(filePath);\n        if (duration) metaJson.duration = duration;\n    }\n\n    // Thumbhash\n    let thumbhash = await getThumbHash(filePath);\n    if (!thumbhash && IMAGE_EXTS.includes(ext)) {\n        // 尝试生成，如果失败则忽略\n        try {\n            thumbhash = await generateThumbHash(filePath);\n        } catch (e) { }\n    }\n\n    // 优先使用 EXIF 日期作为 upload_time (用户请求: 智能日期提取)\n    // 如果我们有实际的照片拍摄日期，请使用它而不是文件创建时间 (复制/移动时会重置)\n    let uploadTime = stat.birthtime;\n    if (metaJson.date) {\n        const exifDate = new Date(metaJson.date);\n        const exifTime = exifDate.getTime();\n        \n        // 检查 EXIF 日期是否有效\n        // 如果日期是 1970年附近（epoch 0）或之前，说明没有有效的拍照时间，使用文件创建时间\n        const minValidDate = new Date('1980-01-01').getTime(); // 假设照片不会早于1980年\n        \n        if (exifTime > minValidDate) {\n            uploadTime = metaJson.date;\n        } else {\n            // EXIF 日期无效，使用文件创建时间替代\n            console.log(`Invalid EXIF date detected (${exifDate.toISOString()}), using file birthtime instead`);\n        }\n    }\n\n    return {\n        size: stat.size,\n        mtime: stat.mtime.getTime(),\n        upload_time: uploadTime instanceof Date ? uploadTime.toISOString() : new Date(uploadTime).toISOString(),\n        width,\n        height,\n        orientation,\n        thumbhash,\n        meta_json: JSON.stringify(metaJson)\n    };\n}\n\nconst EXT_AUDIO = ['.mp3', '.wav', '.ogg', '.m4a', '.flac'];\nconst EXT_VIDEO = ['.mp4', '.webm', '.mov', '.avi', '.mkv'];\n\nmodule.exports = {\n    getFileMetadata,\n    parseImageMetadata,\n    parseAudioDuration\n};\n"
  },
  {
    "path": "server/services/syncService.js",
    "content": "const fs = require('fs-extra');\nconst path = require('path');\nconst config = require('../../config');\nconst imageRepository = require('../db/imageRepository');\nconst { getFileMetadata } = require('./metadataService');\nconst { CACHE_DIR_NAME, safeJoin } = require('../utils/fileUtils');\n\nconst STORAGE_PATH = config.storage.path;\nconst CONFIG_DIR_NAME = \"config\";\nconst TRASH_DIR_NAME = \".trash\";\nconst LEGACY_CACHE_PATH = path.join(STORAGE_PATH, CACHE_DIR_NAME, \"img_metadata.json\");\n\nasync function migrateFromLegacyJson() {\n    if (imageRepository.count() > 0) {\n        console.log(\"Database not empty, skipping JSON migration.\");\n        return;\n    }\n\n    if (!await fs.pathExists(LEGACY_CACHE_PATH)) {\n        console.log(\"No legacy metadata file found.\");\n        return;\n    }\n\n    console.log(\"Migrating from legacy img_metadata.json...\");\n    try {\n        const rawData = await fs.readJson(LEGACY_CACHE_PATH);\n        const imagesToInsert = [];\n\n        // legacy data format: object where values are image objects\n        // or array? The code said `Object.values(newCache)` so the file is likely a map: { \"rel/path\": { ... } }\n        const items = Array.isArray(rawData) ? rawData : Object.values(rawData);\n\n        for (const item of items) {\n            // Adapt legacy fields to new Schema\n            const metaJson = {};\n            if (item.lat && item.lng) {\n                metaJson.gps = { lat: item.lat, lng: item.lng };\n            }\n            if (item.date) {\n                metaJson.date = item.date;\n            }\n\n            imagesToInsert.push({\n                filename: item.filename,\n                rel_path: item.relPath,\n                size: 0, // Legacy might not have size, handled by sync later if needed, or we accept 0\n                mtime: item.lastModified || 0,\n                upload_time: item.date || new Date().toISOString(),\n                width: null, // Legacy didn't store dimensions explicitly often\n                height: null,\n                orientation: item.orientation,\n                thumbhash: item.thumbhash,\n                meta_json: JSON.stringify(metaJson)\n            });\n        }\n\n        if (imagesToInsert.length > 0) {\n            imageRepository.insertMany(imagesToInsert);\n            console.log(`Migrated ${imagesToInsert.length} images from JSON.`);\n        }\n    } catch (e) {\n        console.error(\"Migration failed:\", e);\n    }\n}\n\nasync function getAllFiles(dir) {\n    let results = [];\n    const absDir = safeJoin(STORAGE_PATH, dir);\n    try {\n        const files = await fs.readdir(absDir);\n        for (const file of files) {\n            if (file === CACHE_DIR_NAME || file === CONFIG_DIR_NAME || file === TRASH_DIR_NAME) continue;\n\n            const filePath = path.join(absDir, file);\n            const relPath = path.join(dir, file).replace(/\\\\/g, \"/\");\n            const stat = await fs.stat(filePath);\n\n            if (stat.isDirectory()) {\n                results = results.concat(await getAllFiles(relPath));\n            } else {\n                const ext = path.extname(file).toLowerCase();\n                if (config.upload.allowedExtensions.includes(ext)) {\n                    results.push({\n                        relPath,\n                        filePath,\n                        stat\n                    });\n                }\n            }\n        }\n    } catch (e) {\n        // ignore\n    }\n    return results;\n}\n\nasync function syncFileSystem() {\n    console.log(\"Starting file system sync...\");\n    const diskFiles = await getAllFiles(\"\");\n    const dbImages = imageRepository.getAll();\n\n    const diskMap = new Map(diskFiles.map(f => [f.relPath, f]));\n    const dbMap = new Map(dbImages.map(i => [i.rel_path, i]));\n\n    // 1. 磁盘上的文件但不在 DB 中（新增）\n    // 2. 磁盘上的文件在 DB 中（如果修改则更新）\n    for (const file of diskFiles) {\n        const dbEntry = dbMap.get(file.relPath);\n\n        if (!dbEntry) {\n            // 新文件\n            try {\n                const metadata = await getFileMetadata(file.filePath, file.relPath, file.stat);\n                imageRepository.add({\n                    filename: path.basename(file.relPath),\n                    rel_path: file.relPath,\n                    ...metadata\n                });\n                // console.log(`Synced new file: ${file.relPath}`);\n            } catch (e) {\n                console.error(`Failed to sync file ${file.relPath}`, e);\n            }\n        } else {\n            // 现有文件，检查 mtime\n            // 注意：dbEntry.mtime 来自 DB\n            if (Math.abs(dbEntry.mtime - file.stat.mtime.getTime()) > 1000) { // 1 秒容差\n                console.log(`Updating modified file: ${file.relPath}`);\n                try {\n                    const metadata = await getFileMetadata(file.filePath, file.relPath, file.stat);\n                    imageRepository.update({\n                        filename: path.basename(file.relPath),\n                        rel_path: file.relPath,\n                        ...metadata\n                    });\n                } catch (e) { console.error(`Failed to update ${file.relPath}`, e); }\n            }\n        }\n    }\n\n    // 3. 在 DB 中但不在磁盘上（删除）\n    for (const img of dbImages) {\n        if (!diskMap.has(img.rel_path)) {\n            console.log(`Removing missing file from DB: ${img.rel_path}`);\n            imageRepository.delete(img.rel_path);\n        }\n    }\n    console.log(\"Sync completed.\");\n}\n\nmodule.exports = {\n    migrateFromLegacyJson,\n    syncFileSystem\n};\n"
  },
  {
    "path": "server/utils/albumUtils.js",
    "content": "const path = require('path');\nconst fs = require('fs-extra');\nconst config = require('../../config');\nconst { safeJoin, CACHE_DIR_NAME, CONFIG_DIR_NAME, TRASH_DIR_NAME } = require('./fileUtils');\n\nconst STORAGE_PATH = config.storage.path;\n\nasync function getAlbumPasswordPath(dirPath) {\n    const absDir = safeJoin(STORAGE_PATH, dirPath);\n    return path.join(absDir, \"config\", \"album_password.json\");\n}\n\nasync function verifyAlbumPassword(dirPath, password) {\n    try {\n        const configPath = await getAlbumPasswordPath(dirPath);\n        if (await fs.pathExists(configPath)) {\n            const data = await fs.readJson(configPath);\n            return data.password === password;\n        }\n        return true;\n    } catch (e) {\n        return false;\n    }\n}\n\nasync function isAlbumLocked(dirPath) {\n    try {\n        const configPath = await getAlbumPasswordPath(dirPath);\n        if (await fs.pathExists(configPath)) {\n            const data = await fs.readJson(configPath);\n            return !!data.password;\n        }\n    } catch (e) { }\n    return false;\n}\n\nasync function getAllLockedDirectories() {\n    const lockedDirs = [];\n    async function scan(dir) {\n        const absDir = safeJoin(STORAGE_PATH, dir);\n        try {\n            const files = await fs.readdir(absDir);\n            for (const file of files) {\n                if (file === CACHE_DIR_NAME || file === CONFIG_DIR_NAME || file === TRASH_DIR_NAME) continue;\n                if (file.startsWith('.')) continue;\n\n                const filePath = path.join(absDir, file);\n                const stats = await fs.stat(filePath);\n                if (stats.isDirectory()) {\n                    const relPath = path.join(dir, file).replace(/\\\\/g, \"/\");\n                    if (await isAlbumLocked(relPath)) {\n                        lockedDirs.push(relPath);\n                    }\n                    await scan(relPath);\n                }\n            }\n        } catch (e) { }\n    }\n    await scan(\"\");\n    return lockedDirs;\n}\n\nmodule.exports = {\n    getAlbumPasswordPath,\n    verifyAlbumPassword,\n    isAlbumLocked,\n    getAllLockedDirectories\n};\n"
  },
  {
    "path": "server/utils/fileUtils.js",
    "content": "const path = require('path');\nconst fs = require('fs-extra');\nconst sharp = require('sharp');\nconst config = require('../../config');\n\nconst CACHE_DIR_NAME = \".cache\";\n\nfunction safeJoin(base, target) {\n    const targetPath = path.resolve(base, target || \"\");\n    if (!targetPath.startsWith(path.resolve(base))) {\n        throw new Error(\"非法目录路径\");\n    }\n    return targetPath;\n}\n\nfunction sanitizeFilename(filename) {\n    try {\n        if (filename.includes(\"%\")) {\n            filename = decodeURIComponent(filename);\n        }\n        if (Buffer.isBuffer(filename)) {\n            filename = filename.toString(\"utf8\");\n        }\n        if (config.storage.filename.sanitizeSpecialChars) {\n            filename = filename.replace(\n                /[<>:\"/\\\\|?*]/g,\n                config.storage.filename.specialCharReplacement\n            );\n        }\n        return filename;\n    } catch (error) {\n        console.warn(\"文件名处理错误:\", error);\n        return filename.replace(\n            /[<>:\"/\\\\|?*]/g,\n            config.storage.filename.specialCharReplacement\n        );\n    }\n}\n\nasync function generateThumbHash(filePath) {\n    try {\n        const dir = path.dirname(filePath);\n        const filename = path.basename(filePath);\n\n        const ext = path.extname(filename).toLowerCase();\n        if (['.mp4', '.webm'].includes(ext)) {\n            return null;\n        }\n\n        const cacheDir = path.join(dir, CACHE_DIR_NAME);\n        const cacheFile = path.join(cacheDir, `${filename}.th`);\n\n        await fs.ensureDir(cacheDir);\n\n        const image = sharp(filePath).resize(100, 100, { fit: 'inside' });\n        const { data, info } = await image\n            .ensureAlpha()\n            .raw()\n            .toBuffer({ resolveWithObject: true });\n\n        const { rgbaToThumbHash } = await import(\"thumbhash\");\n        const binaryHash = rgbaToThumbHash(info.width, info.height, data);\n        await fs.writeFile(cacheFile, Buffer.from(binaryHash));\n        return Buffer.from(binaryHash).toString('base64');\n    } catch (err) {\n        console.error(`Failed to generate thumbhash for ${filePath}:`, err);\n        return null;\n    }\n}\n\nasync function getThumbHash(filePath) {\n    try {\n        const dir = path.dirname(filePath);\n        const filename = path.basename(filePath);\n        const cacheFile = path.join(dir, CACHE_DIR_NAME, `${filename}.th`);\n\n        if (await fs.pathExists(cacheFile)) {\n            const buffer = await fs.readFile(cacheFile);\n            return buffer.toString('base64');\n        }\n        return null;\n    } catch (err) {\n        return null;\n    }\n}\n\nasync function saveBase64Image(base64Data, dir) {\n    const matches = base64Data.match(/^data:([A-Za-z-+\\/]+);base64,(.+)$/);\n    if (!matches || matches.length !== 3) {\n        throw new Error('无效的 base64 图片格式');\n    }\n\n    const mimetype = matches[1];\n    if (!/^image\\//.test(mimetype)) {\n        throw new Error('仅允许图片类型的 base64 上传');\n    }\n    const buffer = Buffer.from(matches[2], 'base64');\n\n    const ext = mimetype.split('/')[1] || 'png';\n    const filename = `${Date.now()}-${Math.floor(Math.random() * 1000)}.${ext}`;\n\n    const targetDir = safeJoin(config.storage.path, dir);\n    await fs.ensureDir(targetDir);\n\n    const filePath = path.join(targetDir, filename);\n    await fs.promises.writeFile(filePath, buffer);\n\n    return {\n        filename,\n        filePath,\n        size: buffer.length,\n        mimetype\n    };\n}\n\nasync function downloadFromUrl(imageUrl) {\n    return new Promise((resolve, reject) => {\n        const protocol = imageUrl.startsWith('https') ? require('https') : require('http');\n        const urlObj = new URL(imageUrl);\n\n        const options = {\n            hostname: urlObj.hostname,\n            port: urlObj.port || (imageUrl.startsWith('https') ? 443 : 80),\n            path: urlObj.pathname + urlObj.search,\n            method: 'GET',\n            headers: {\n                'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'\n            },\n            timeout: 30000\n        };\n\n        const req = protocol.request(options, (res) => {\n            // Handle redirects\n            if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {\n                downloadFromUrl(res.headers.location).then(resolve).catch(reject);\n                req.destroy();\n                return;\n            }\n\n            if (res.statusCode !== 200) {\n                reject(new Error(`下载失败: HTTP ${res.statusCode}`));\n                return;\n            }\n\n            const contentType = res.headers['content-type'] || '';\n            if (!contentType.startsWith('image/')) {\n                reject(new Error('URL 不是图片类型'));\n                return;\n            }\n\n            const chunks = [];\n            res.on('data', (chunk) => chunks.push(chunk));\n            res.on('end', () => {\n                const buffer = Buffer.concat(chunks);\n                resolve({\n                    buffer,\n                    mimetype: contentType\n                });\n            });\n            res.on('error', reject);\n        });\n\n        req.on('error', reject);\n        req.on('timeout', () => {\n            req.destroy();\n            reject(new Error('下载超时'));\n        });\n\n        req.end();\n    });\n}\n\nmodule.exports = {\n    safeJoin,\n    sanitizeFilename,\n    generateThumbHash,\n    getThumbHash,\n    saveBase64Image,\n    downloadFromUrl,\n    CACHE_DIR_NAME,\n    TRASH_DIR_NAME: \".trash\",\n    CONFIG_DIR_NAME: \"config\"\n};\n"
  },
  {
    "path": "server/utils/urlUtils.js",
    "content": "const path = require('path');\n\n/**\n * Formats an image object for JSON response, ensuring fullUrl is an absolute URL.\n * @param {Object} req - Express request object\n * @param {Object} image - Image object (must have rel_path)\n * @returns {Object} Formatted image object\n */\nfunction formatImageResponse(req, image) {\n    // Basic validation\n    if (!image || !image.rel_path) return image;\n\n    const relPathStr = image.rel_path.split(\"/\").map(encodeURIComponent).join(\"/\");\n    const url = `/api/images/${relPathStr}`;\n    const fullUrl = `${req.protocol}://${req.get('host')}${url}`;\n\n    // Parse meta_json if it exists and is a string\n    let meta = {};\n    if (typeof image.meta_json === 'string') {\n        try {\n            meta = JSON.parse(image.meta_json);\n        } catch (e) { }\n    } else if (typeof image.meta_json === 'object') {\n        meta = image.meta_json;\n    }\n\n    return {\n        // Standard fields\n        filename: image.filename,\n        relPath: image.rel_path,\n        fullUrl: fullUrl, // Absolute URL\n        url: url,           // Relative API URL\n        width: image.width,\n        height: image.height,\n        size: image.size,\n        uploadTime: image.upload_time,\n        mtime: image.mtime,\n        mime: image.mime_type, // Some places user mime_type\n\n        // Merge extra fields if present\n        ...meta,\n\n        // Allow overriding or adding specific fields if they exist on the input object\n        // but were not in the standard list above (e.g. thumbhash)\n        thumbhash: image.thumbhash,\n    };\n}\n\nmodule.exports = {\n    formatImageResponse\n};\n"
  },
  {
    "path": "start.sh",
    "content": "#!/bin/bash\n\necho \"🚀 启动 云图 应用...\"\n\n# 检查 Node.js 是否安装\nif ! command -v node &> /dev/null; then\n    echo \"❌ Node.js 未安装，请先安装 Node.js\"\n    exit 1\nfi\n\n# 检查 npm 是否安装\nif ! command -v npm &> /dev/null; then\n    echo \"❌ npm 未安装，请先安装 npm\"\n    exit 1\nfi\n\necho \"📦 安装后端依赖...\"\nnpm install\n\necho \"📦 安装前端依赖...\"\ncd client && npm install && cd ..\n\necho \"🔨 构建前端...\"\ncd client && npm run build && cd ..\n\necho \"🌐 启动服务器...\"\necho \"✅ 应用已启动！\"\necho \"📍 访问地址: http://localhost:3001\"\necho \"🛑 按 Ctrl+C 停止服务\"\n\nnpm start "
  }
]