Repository: qazzxxx/cloudimgs Branch: main Commit: a2c69f035c26 Files: 62 Total size: 497.2 KB Directory structure: gitextract_9h9_xbii/ ├── .dockerignore ├── .github/ │ └── workflows/ │ ├── docker-publish.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── Dockerfile ├── Dockerfile.gha ├── README.md ├── client/ │ ├── .npmrc │ ├── package.json │ ├── public/ │ │ ├── index.html │ │ ├── manifest.json │ │ └── text.md │ └── src/ │ ├── App.js │ ├── components/ │ │ ├── AlbumManager.js │ │ ├── ApiDocs.js │ │ ├── DirectorySelector.js │ │ ├── FloatingToolbar.js │ │ ├── ImageCompressor.js │ │ ├── ImageCropperTool.js │ │ ├── ImageDetailModal.js │ │ ├── ImageEditModal.js │ │ ├── ImageGallery.js │ │ ├── Logo.js │ │ ├── LogoWithText.js │ │ ├── MapPage.js │ │ ├── PasswordOverlay.js │ │ ├── ScrollingBackground.js │ │ ├── ShareView.js │ │ ├── SvgToPngTool.js │ │ ├── SvgToolModal.js │ │ ├── ThemeSwitcher.js │ │ ├── TrafficDashboard.js │ │ └── UploadComponent.js │ ├── index.js │ └── utils/ │ ├── api.js │ └── secureStorage.js ├── config.js ├── docker-compose.yml ├── docker-entrypoint.sh ├── env.example ├── package.json ├── server/ │ ├── db/ │ │ ├── database.js │ │ ├── imageRepository.js │ │ └── shareRepository.js │ ├── index.js │ ├── middleware/ │ │ ├── auth.js │ │ └── upload.js │ ├── routes/ │ │ ├── imageRoutes.js │ │ ├── manageRoutes.js │ │ ├── searchRoutes.js │ │ ├── shareRoutes.js │ │ ├── statsRoutes.js │ │ ├── systemRoutes.js │ │ └── uploadRoutes.js │ ├── services/ │ │ ├── clipService.js │ │ ├── metadataService.js │ │ └── syncService.js │ └── utils/ │ ├── albumUtils.js │ ├── fileUtils.js │ └── urlUtils.js └── start.sh ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ # Dependencies node_modules npm-debug.log* yarn-debug.log* yarn-error.log* # Git .git .gitignore .github # Documentation README.md *.md # Environment files .env .env.local .env.development.local .env.test.local .env.production.local # IDE .vscode .idea *.swp *.swo # OS .DS_Store Thumbs.db # Testing coverage .nyc_output *.lcov # Build outputs (exclude node_modules but keep build directory) client/node_modules client/coverage # Logs logs *.log # Uploads (will be created at runtime) uploads # Docker Dockerfile* docker-compose* .dockerignore # CI/CD .github .gitlab-ci.yml .travis.yml # Temporary files *.tmp *.temp ================================================ FILE: .github/workflows/docker-publish.yml ================================================ name: Docker Publish on: push: branches: - main tags: - "v*" workflow_dispatch: inputs: version: description: "Version to publish" required: true default: "latest" registry: description: "Registry to publish to" required: true default: "both" type: choice options: - ghcr - dockerhub - both platforms: description: "Target platforms" required: true default: "linux/amd64,linux/arm64" type: choice options: - linux/amd64 - linux/amd64,linux/arm64 concurrency: group: docker-publish-${{ github.ref }} cancel-in-progress: true jobs: publish: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to GitHub Container Registry if: github.event.inputs.registry == 'ghcr' || github.event.inputs.registry == 'both' || github.event_name == 'push' uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.PAT_TOKEN }} - name: Login to Docker Hub if: github.event.inputs.registry == 'dockerhub' || github.event.inputs.registry == 'both' || github.event_name == 'push' uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Generate image names id: image_names run: | OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') echo "ghcr_image=ghcr.io/$OWNER/cloudimgs" >> $GITHUB_OUTPUT echo "dockerhub_image=${{ secrets.DOCKER_USERNAME }}/cloudimgs" >> $GITHUB_OUTPUT echo "Debug info:" echo "- Event: ${{ github.event_name }}" echo "- Ref: ${{ github.ref }}" echo "- Tag: ${{ github.ref_name }}" echo "- SHA: ${{ github.sha }}" - name: Extract metadata for GitHub Packages id: meta-ghcr if: github.event.inputs.registry == 'ghcr' || github.event.inputs.registry == 'both' || github.event_name == 'push' uses: docker/metadata-action@v5 with: images: ${{ steps.image_names.outputs.ghcr_image }} tags: | type=ref,event=branch type=ref,event=pr type=semver,pattern={{version}} type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} - name: Extract metadata for Docker Hub id: meta-dockerhub if: github.event.inputs.registry == 'dockerhub' || github.event.inputs.registry == 'both' || github.event_name == 'push' uses: docker/metadata-action@v5 with: images: ${{ steps.image_names.outputs.dockerhub_image }} tags: | type=ref,event=branch type=ref,event=pr type=semver,pattern={{version}} type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} - name: Build and push to GitHub Packages if: github.event.inputs.registry == 'ghcr' || github.event.inputs.registry == 'both' || github.event_name == 'push' uses: docker/build-push-action@v5 with: context: . file: ./Dockerfile.gha push: true tags: ${{ steps.meta-ghcr.outputs.tags }} platforms: ${{ github.event.inputs.platforms || 'linux/amd64,linux/arm64' }} build-args: | NODE_OPTIONS=--max-old-space-size=4096 - name: Build and push to Docker Hub if: github.event.inputs.registry == 'dockerhub' || github.event.inputs.registry == 'both' || github.event_name == 'push' uses: docker/build-push-action@v5 with: context: . file: ./Dockerfile.gha push: true tags: ${{ steps.meta-dockerhub.outputs.tags }} platforms: ${{ github.event.inputs.platforms || 'linux/amd64,linux/arm64' }} build-args: | NODE_OPTIONS=--max-old-space-size=4096 - name: Success notification run: | echo "✅ Docker images published successfully!" if [[ "${{ github.event.inputs.registry }}" == "ghcr" || "${{ github.event.inputs.registry }}" == "both" || "${{ github.event_name }}" == "push" ]]; then echo "GitHub Packages: ${{ steps.image_names.outputs.ghcr_image }}" echo "Tags: ${{ steps.meta-ghcr.outputs.tags }}" echo "Debug - GitHub Packages tags:" echo "${{ steps.meta-ghcr.outputs.tags }}" | tr ',' '\n' | sed 's/^/ /' fi if [[ "${{ github.event.inputs.registry }}" == "dockerhub" || "${{ github.event.inputs.registry }}" == "both" || "${{ github.event_name }}" == "push" ]]; then echo "Docker Hub: ${{ steps.image_names.outputs.dockerhub_image }}" echo "Tags: ${{ steps.meta-dockerhub.outputs.tags }}" echo "Debug - Docker Hub tags:" echo "${{ steps.meta-dockerhub.outputs.tags }}" | tr ',' '\n' | sed 's/^/ /' fi ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: tags: - "v*" permissions: contents: write jobs: release: name: Create Release runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 - name: Build Changelog id: build_changelog uses: mikepenz/release-changelog-builder-action@v5 with: commitMode: true configurationJson: | { "categories": [ { "title": "## 🚀 Features", "labels": ["feature", "feat"] }, { "title": "## 🐛 Fixes", "labels": ["fix", "bug"] }, { "title": "## 📦 Chore", "labels": ["chore"] }, { "title": "## 💬 Other", "labels": [] } ], "label_extractor": [ { "pattern": "^(feat|fix|chore|bug|perf|refactor|test)(?:\\([^\\)]+\\))?: .+$", "target": "$1" } ], "template": "#{{CHANGELOG}}", "pr_template": "- #{{TITLE}} by @#{{AUTHOR}} in ##{{NUMBER}}", "ignore_before": "v1.0.0" } env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Create Release uses: softprops/action-gh-release@v2 with: body: ${{ steps.build_changelog.outputs.changelog }} ================================================ FILE: .gitignore ================================================ # Dependencies node_modules/ client/node_modules/ # Production builds client/build/ dist/ # Environment variables .env .env.local .env.development.local .env.test.local .env.production.local # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* # Runtime data pids *.pid *.seed *.pid.lock # Coverage directory used by tools like istanbul coverage/ *.lcov # nyc test coverage .nyc_output # Dependency directories jspm_packages/ # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variables file .env.test # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache # Next.js build output .next # Nuxt.js build / generate output .nuxt # Gatsby files .cache/ /public # Storybook build outputs .out .storybook-out # Temporary folders tmp/ temp/ # Editor directories and files .vscode/ .idea/ *.swp *.swo *~ # OS generated files .DS_Store .DS_Store? ._* .Spotlight-V100 .Trashes ehthumbs.db Thumbs.db # Uploads directory uploads/ ================================================ FILE: .npmrc ================================================ # Ensure consistent behavior audit=false fund=false progress=false loglevel=error ================================================ FILE: Dockerfile ================================================ # 多阶段构建 - 构建阶段 FROM node:18-bookworm-slim AS builder # 设置工作目录 WORKDIR /app # 安装构建工具和依赖 RUN apt-get update && apt-get install -y --no-install-recommends \ git \ python3 \ make \ g++ \ && rm -rf /var/lib/apt/lists/* # 设置Node.js内存限制(避免OOM) ENV NODE_OPTIONS="--max-old-space-size=2048" # 禁用 Source Map 以减少内存占用和加快构建速度 ENV GENERATE_SOURCEMAP=false # 禁用 ESLint 插件以避免构建期间的 Lint 错误中断 ENV DISABLE_ESLINT_PLUGIN=true # 设置npm配置 ENV NPM_CONFIG_AUDIT=false ENV NPM_CONFIG_FUND=false ENV NPM_CONFIG_PROGRESS=false ENV NPM_CONFIG_LOGLEVEL=warn # 复制package.json文件 COPY package*.json ./ # 安装所有依赖(包括开发依赖,用于构建) RUN npm install --no-audit --no-fund --prefer-offline --verbose --legacy-peer-deps # 复制客户端package.json COPY client/package*.json ./client/ # 安装客户端依赖(添加详细输出和错误处理) RUN cd client && \ echo "=== Installing client dependencies ===" && \ npm install --no-audit --no-fund --prefer-offline --no-optional --verbose --legacy-peer-deps && \ echo "=== Client dependencies installed successfully ===" # 复制源代码 COPY . . # 显示构建环境信息 RUN echo "=== Build Environment Info ===" && \ node --version && \ npm --version && \ echo "=== Current Directory ===" && \ pwd && \ ls -la && \ echo "=== Client Directory ===" && \ ls -la client/ # 验证客户端依赖安装 RUN echo "=== Client Dependencies Check ===" && \ cd client && \ ls -la node_modules/ | head -10 && \ echo "=== React version ===" && \ npm list react && \ echo "=== React-scripts version ===" && \ npm list react-scripts # 构建客户端(添加详细输出和错误处理) RUN cd client && \ echo "=== Starting client build ===" && \ echo "=== Available memory ===" && \ free -h || echo "Memory info not available" && \ echo "=== Node options ===" && \ echo $NODE_OPTIONS && \ echo "=== NPM version ===" && \ npm --version && \ echo "=== Node version ===" && \ node --version && \ echo "=== Starting build process ===" && \ echo "=== Build environment ===" && \ echo "CI: $CI" && \ echo "NODE_ENV: $NODE_ENV" && \ echo "=== Running build command ===" && \ 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) # 清理开发依赖(优化镜像大小) RUN npm prune --production # 验证构建结果 RUN echo "=== Build Result ===" && \ ls -la client/build/ && \ echo "=== Build files count ===" && \ find client/build -type f | wc -l && \ echo "=== Main JS file size ===" && \ ls -lh client/build/static/js/ && \ echo "=== Build successful ===" # 生产阶段 FROM node:18-bookworm-slim AS production # 设置工作目录 WORKDIR /app # 安装 gosu 和基础依赖 RUN apt-get update && apt-get install -y --no-install-recommends \ gosu \ && rm -rf /var/lib/apt/lists/* # 从构建阶段复制node_modules和应用文件 COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package*.json ./ COPY --from=builder /app/server ./server COPY --from=builder /app/config.js ./ COPY --from=builder /app/client/build ./client/build # 创建上传目录 RUN mkdir -p uploads logs # 验证文件复制 RUN echo "=== Production Image Verification ===" && \ ls -la client/build/ && \ echo "=== Node modules verification ===" && \ ls -la node_modules/ | head -10 # 暴露端口 EXPOSE 3001 # 设置环境变量 ENV NODE_ENV=production ENV PORT=3001 ENV STORAGE_PATH=/app/uploads ENV PUID=1000 ENV PGID=1000 ENV UMASK=002 # 设置 HuggingFace 镜像地址,解决国内下载模型超时问题 ENV HF_ENDPOINT=https://hf-mirror.com # 复制入口脚本 COPY docker-entrypoint.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/docker-entrypoint.sh # 健康检查(使用环境变量 PORT) HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD node -e "require('http').get('http://localhost:' + (process.env.PORT || 3001) + '/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })" # 使用入口脚本启动 ENTRYPOINT ["docker-entrypoint.sh"] CMD ["npm", "start"] ================================================ FILE: Dockerfile.gha ================================================ # GitHub Actions 优化的 Dockerfile FROM node:18-bookworm-slim AS builder # 设置工作目录 WORKDIR /app # 安装构建工具 RUN apt-get update && apt-get install -y --no-install-recommends \ git \ python3 \ make \ g++ \ && rm -rf /var/lib/apt/lists/* # 设置环境变量 ENV NODE_OPTIONS="--max-old-space-size=2048" ENV CI=false ENV NODE_ENV=production # 禁用 Source Map 以减少内存占用和加快构建速度 ENV GENERATE_SOURCEMAP=false # 禁用 ESLint 插件以避免构建期间的 Lint 错误中断 ENV DISABLE_ESLINT_PLUGIN=true # 复制package.json文件 COPY package*.json ./ COPY client/package*.json ./client/ # 安装依赖 RUN npm install --no-audit --no-fund --prefer-offline --verbose --legacy-peer-deps RUN cd client && npm install --no-audit --no-fund --prefer-offline --no-optional --verbose --legacy-peer-deps # 复制源代码 COPY . . # 调试:检查复制的文件 RUN echo "=== Checking copied files ===" && \ ls -la && \ echo "=== Client directory ===" && \ ls -la client/ && \ echo "=== Client public directory ===" && \ ls -la client/public/ && \ echo "=== Public directory contents ===" && \ find client/public -type f # 构建客户端 RUN cd client && \ echo "=== Build environment ===" && \ node --version && \ npm --version && \ echo "NODE_OPTIONS: $NODE_OPTIONS" && \ echo "CI: $CI" && \ echo "NODE_ENV: $NODE_ENV" && \ echo "=== Starting build ===" && \ npm run build # 清理开发依赖(优化镜像大小) RUN npm prune --production # 验证构建结果 RUN echo "=== Build verification ===" && \ ls -la client/build/ && \ echo "Build completed successfully" # 生产阶段 FROM node:18-bookworm-slim AS production # 设置工作目录 WORKDIR /app # 安装 gosu 和基础依赖 RUN apt-get update && apt-get install -y --no-install-recommends \ gosu \ && rm -rf /var/lib/apt/lists/* # 从构建阶段复制node_modules和应用文件 COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package*.json ./ COPY --from=builder /app/server ./server COPY --from=builder /app/config.js ./ COPY --from=builder /app/client/build ./client/build # 创建上传目录 RUN mkdir -p uploads logs # 验证文件复制 RUN echo "=== Production Image Verification ===" && \ ls -la client/build/ && \ echo "=== Node modules verification ===" && \ ls -la node_modules/ | head -10 # 暴露端口 EXPOSE 3001 # 设置环境变量 ENV NODE_ENV=production ENV PORT=3001 ENV STORAGE_PATH=/app/uploads ENV PUID=1000 ENV PGID=1000 ENV UMASK=002 # 设置 HuggingFace 镜像地址,解决国内下载模型超时问题 ENV HF_ENDPOINT=https://hf-mirror.com # 复制入口脚本 COPY docker-entrypoint.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/docker-entrypoint.sh # 健康检查(使用环境变量 PORT) HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD node -e "require('http').get('http://localhost:' + (process.env.PORT || 3001) + '/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })" # 使用入口脚本启动 ENTRYPOINT ["docker-entrypoint.sh"] CMD ["npm", "start"] ================================================ FILE: README.md ================================================ # 云图 > ☁️ **云端一隅,拾光深藏** > 一个简单、开放且强大的自托管图像托管解决方案。 [![Stars](https://img.shields.io/github/stars/qazzxxx/cloudimgs?style=flat-square&logo=github&label=Stars)](https://github.com/qazzxxx/cloudimgs/stargazers) [![Forks](https://img.shields.io/github/forks/qazzxxx/cloudimgs?style=flat-square&logo=github&label=Forks)](https://github.com/qazzxxx/cloudimgs/network/members) [![Release](https://img.shields.io/github/v/release/qazzxxx/cloudimgs?style=flat-square&color=blue)](https://github.com/qazzxxx/cloudimgs/releases) --- ## 📖 简介 | Introduction 项目的开始是用 **N8N处理相关流程** 时有很多图片处理的需求,找了很多开源项目有的比较老无人维护,有的需要购买PRO版本才能有更多的功能。以上种种原因吧,再加上自己也有NAS,所以写了一个比较自由开放的图床项目。 --- ## 🖥️ 在线演示 | Demo - **演示地址**:[https://yt.qazz.site](https://yt.qazz.site) - **文档地址**:[https://ytdoc.qazz.site/](https://ytdoc.qazz.site/) > [!NOTE] > 此演示为 **纯静态 Mock 模式** 部署,图片数据随机加载,不涉及真实后端调用。 > - **访问密码**:`123456` > - **说明**:上传、删除等操作仅演示UI交互,数据不会保存,部分功能不可用。真实环境下通过 `thumbhash` 生成缩略图,体验会更流畅。 --- ## 🚀 功能特点 | Features ### 🛠️ 核心功能 - [x] **多格式支持**:支持上传各种格式图片及其他文件,支持全局上传。 - [x] **图片管理**:在线管理图片,瀑布流展示,批量圈选删除。 - [x] **相册分享**:支持相册分享功能。 - [x] **安全保护**:支持设置密钥,保护图片安全。 - [x] **目录管理**:支持多级子目录管理。 - [x] **移动适配**:完美适配移动端。 ### ⚡️ 高级特性 - [x] **魔法搜索**:基于CLIP本地小模型,支持自然语言搜索(如搜“蓝天白云”)。 - [x] **流量看板**:直观展示流量使用情况。 - [x] **照片轨迹**:在地图上展示照片拍摄轨迹。 - [x] **性能优化**:集成 `thumbhash` 无感生成缩略图,大幅优化加载体验。 ### 🔌 开放接口 (API) - [x] **上传/管理**:支持Base64上传、SVG转PNG、拖拽上传、图片删除/列表等。 - [x] **图片处理**:支持实时 URL 参数处理(尺寸、质量、格式转换)。 - *示例*:`image.jpg?w=500&h=300&q=80&fmt=webp` - [x] **随机图/指定图**:支持获取随机图片或指定参数的图片。 - [x] **生态集成**:支持 [PicGo 插件](https://github.com/qazzxxx/picgo-plugin-cloudimgs-uploader) 直接安装使用。 ### 🎨 图片工具 - [x] **在线编辑**:内置图片编辑功能。 - [x] **格式转换**:支持 SVG 转 PNG。 - [x] **压缩工具**:自定义压缩质量和尺寸。 - [x] **一键分享**:支持一键复制图片链接。 --- ## 🖼️ 软件预览 | Preview
✨ 点击收起/展开截图
### 魔法搜索 & 主要界面 | 魔法搜索 (Magic Search) | 登录页面 (Login) | | :---: | :---: | | ![魔法搜索](client/public/magicsearch.jpeg) | ![登录页面](client/public/login.jpg) | | 图片管理 (Management) | 批量操作 (Batch Actions) | | :---: | :---: | | ![图片管理](client/public/cloudimgs.jpg) | ![批量操作](client/public/batch.jpg) | ### 功能展示 | 相册分享 (Share) | 整页上传 (Upload) | | :---: | :---: | | ![相册分享](client/public/share.jpg) | ![整页上传](client/public/upload.jpg) | | 轨迹地图 (Map) | 图片编辑 (Editor) | | :---: | :---: | | ![照片轨迹](client/public/map.jpg) | ![图片编辑](client/public/edit.jpg) | | 开放接口 (API) | 移动端 (Mobile) | | :---: | :---: | | ![开放接口](client/public/api.jpg) | ![移动端](client/public/mobile.jpg) |
--- ## 🛠️ 快速部署 | Quick Start 推荐使用 **Docker Compose** 进行快速部署。 ### `docker-compose.yml` ```yaml services: cloudimgs: image: qazzxxx/cloudimgs:latest container_name: cloudimgs-app restart: unless-stopped ports: - "3001:3001" volumes: - ./uploads:/app/uploads:rw # 上传目录配置,明确读写权限 environment: # 权限配置 (建议填写 NAS 用户真实 ID) - PUID=1000 # id -u - PGID=1000 # id -g - UMASK=002 # 基础配置 - NODE_ENV=production - PORT=3001 - STORAGE_PATH=/app/uploads # 可选配置 # - MAX_FILE_SIZE=104857600 # 最大文件大小,默认 100MB # - THUMBNAIL_WIDTH=0 # 瀑布流缩略图宽度(像素),默认 0 表示使用原图 # - PASSWORD=your_secure_password_here # 🔐 密码保护配置 # - ENABLE_MAGIC_SEARCH=true # ✨ 开启魔法搜索(使用本地CLIP小模型,占用内存较高) ``` ### 🔐 环境变量说明 | 变量名 | 说明 | 示例 / 默认值 | | :--- | :--- | :--- | | `PASSWORD` | 设置访问密码,留空则无需密码 | `123456` | | `ENABLE_MAGIC_SEARCH`| 是否开启 AI 魔法搜索 | `true` / `false` | | `MAX_FILE_SIZE` | 最大上传文件限制 (Byte) | `104857600` (100MB) | | `THUMBNAIL_WIDTH` | 列表缩略图宽度 (px) | `0` (原图) / `500` | > **注意**: > 1. 设置 `PASSWORD` 后,系统会自动启用登录保护。 > 2. 登录状态会保存在浏览器本地存储中。 --- ## 📈 历史 Star | Star History [![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) ================================================ FILE: client/.npmrc ================================================ legacy-peer-deps=true ================================================ FILE: client/package.json ================================================ { "name": "cloudimgs-client", "version": "1.2.3", "private": true, "dependencies": { "@ant-design/icons": "^5.6.1", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "antd": "^6.0.0", "axios": "^1.10.0", "coordtransform": "^2.1.2", "cropperjs": "^1.6.2", "dayjs": "^1.11.13", "echarts": "^6.0.0", "echarts-for-react": "^3.0.6", "konva": "^9.3.20", "leaflet": "^1.9.4", "leaflet-rotatedmarker": "^0.2.0", "leaflet.markercluster": "^1.5.3", "react": "^18.3.1", "react-cropper": "^2.3.3", "react-dom": "^18.3.1", "react-easy-crop": "^5.0.1", "react-filerobot-image-editor": "^4.9.1", "react-konva": "^18.2.10", "react-leaflet": "^4.2.1", "react-leaflet-cluster": "^4.0.0", "react-router-dom": "^6.20.1", "react-scripts": "5.0.1", "styled-components": "^5.3.11", "thumbhash": "^0.1.1" }, "scripts": { "start": "react-scripts start", "start:mock": "REACT_APP_MOCK=true react-scripts start", "build": "CI=false react-scripts build", "build:mock": "REACT_APP_MOCK=true react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject" }, "eslintConfig": { "extends": [ "react-app", "react-app/jest" ] }, "browserslist": { "production": [ ">0.2%", "not dead", "not op_mini all" ], "development": [ "last 1 chrome version", "last 1 firefox version", "last 1 safari version" ] }, "proxy": "http://localhost:3001" } ================================================ FILE: client/public/index.html ================================================ 云图 - 云端一隅,拾光深藏
================================================ FILE: client/public/manifest.json ================================================ { "short_name": "云图", "name": "云图", "description": "基于 Node.js + React + Ant Design 的现代化图床应用,支持图片上传、管理、预览和API接口", "icons": [ { "src": "favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }, { "src": "icon-192.png", "type": "image/png", "sizes": "192x192", "purpose": "any maskable" }, { "src": "icon-512.png", "type": "image/png", "sizes": "512x512", "purpose": "any maskable" } ], "start_url": ".", "display": "standalone", "theme_color": "#1890ff", "background_color": "#ffffff", "orientation": "portrait-primary", "categories": ["productivity", "utilities"], "lang": "zh-CN" } ================================================ FILE: client/public/text.md ================================================ # 拒绝付费与臃肿!我为NAS党手撸了一个极简且强大的云图库——CloudImgs(云图) 大家好,我是 **云舟实验室**。 今天想和大家分享一个我最近开发的开源项目——**云图 (CloudImgs)**。 这是一个极简风格的自建图床/云图库,支持 **Docker 一键部署**,完美适配 **NAS** 环境,并且拥有超灵活的 **API 接口**。 ![云图](https://fastly.jsdelivr.net/gh/bucketio/img12@main/2025/12/28/1766885915244-31e3c67a-ffc5-422f-afa7-98fd55ebb438.png) ## 🛠️ 为什么要造这个轮子? 说实话,起初我并没有打算写个图床。 事情的起因是我在使用 **N8N** 处理自动化工作流时,遇到了大量的图片处理需求。我尝试寻找现有的开源解决方案,但结果并不理想: * **太老旧**:很多曾经优秀的开源项目已经几年没更新了,UI 停留在十年前,代码维护也停滞了。 * **要付费**:好用一点的现代图床,往往需要购买 PRO 版本才能解锁高级功能(如图片压缩、格式转换等)。 * **功能过剩或不足**:有的太复杂,有的又太简陋,不支持 API 自动化调用。 既然我自己有 **NAS**,又懂一点代码,为什么不自己写一个呢?于是,**云图 (CloudImgs)** 诞生了。它主打**自由、开放、极简**,专为解决实际问题而来。 --- ## 🖥️ 在线体验 先别急着看技术细节,大家可以直接上手体验一下 UI 和交互。 * **演示地址**:[https://yt.qazz.site](https://yt.qazz.site) * **访问密码**:`123456` > **⚠️ 注**:演示站为纯静态 Mock 模式,上传/删除仅演示 UI 交互,数据不保存。真实部署后,体验会更好(特别是缩略图加载)。 --- ## ✨ 核心亮点:不仅仅是存图片 ### 1. 颜值即正义:极简瀑布流 & 丝滑交互 我们抛弃了繁杂的后台界面,采用现代化的瀑布流布局。集成 **ThumbHash** 技术,在图片未完全加载时通过算法生成极小的占位哈希图,实现无感加载,告别“白屏”等待,视觉体验极佳。 ### 2. 生产力工具:PicGo 插件无缝集成 对于写博客、Markdown 文档的朋友,图床的便捷性至关重要。云图**原生支持 PicGo**,我已经写好了对应的插件,安装即用。截图 -> 自动上传 -> 粘贴链接,一气呵成。 * [PicGo 插件地址](https://github.com/qazzxxx/picgo-plugin-cloudimgs-uploader) ### 3. 开发者福音:强大的实时处理 API 这是我最自豪的功能之一。云图不仅仅是存储,还是一个**即时的图片处理引擎**。你可以通过 URL 参数实时处理图片: * **格式转换**:`image.jpg?fmt=webp` (自动转 WebP,节省带宽) * **尺寸调整**:`image.jpg?w=500&h=300` (强制缩放) * **质量压缩**:`image.jpg?q=80` (80% 质量压缩) 这就意味着,你上传一张 4K 原图,在不同设备上可以通过参数调用不同尺寸的缩略图,极大减轻前端压力。 ### 4. 全能管理与安全 * **多级目录**:支持文件夹管理,井井有条。 * **隐私保护**:支持设置访问密钥,保护你的私有图片。 * **全格式支持**:不仅仅是 JPG/PNG,SVG 甚至其他文件格式也能传。 * **SVG 转 PNG**:专为设计师和前端优化的功能。 * **批量操作**:支持圈选批量删除,效率拉满。 --- ## 📸 更多界面预览 **登录页面**:简洁大方,支持密码保护。 ![登录页面](https://fastly.jsdelivr.net/gh/bucketio/img13@main/2025/12/28/1766885631052-9ca708a9-3416-40fc-b063-fc20a067a73b.jpg) **瀑布流管理图片**:支持瀑布流展示管理图片。 ![瀑布流管理图片](https://fastly.jsdelivr.net/gh/bucketio/img10@main/2025/12/28/1766885826656-a2c26f51-c957-4e83-98e3-da69be485dcb.jpg) **批量操作**:支持圈选多图一键操作。 ![批量操作](https://fastly.jsdelivr.net/gh/bucketio/img4@main/2025/12/28/1766885797912-d3a5031b-764e-443a-b09d-84bb91aca4d3.jpg) **整页上传**:支持多图拖拽一键上传。 ![整页上传](https://fastly.jsdelivr.net/gh/bucketio/img18@main/2025/12/28/1766885649610-3feef228-a3d9-4553-876b-feee12f7396f.jpg) **相册分享**:一键生成分享链接,发给朋友。 ![相册分享](https://fastly.jsdelivr.net/gh/bucketio/img10@main/2025/12/28/1766885659322-a8c942d7-ee00-42df-b16d-7aa3401d519c.jpg) **开放API**:灵活调用开放API。 ![开放API](https://fastly.jsdelivr.net/gh/bucketio/img2@main/2025/12/28/1766885886202-17cc6cc7-0712-4383-a25a-61c30fc33201.jpg) --- ## 🚀 极速部署 (NAS/Docker) 作为 NAS 党,我深知部署难度的痛点。云图完全 Docker 化,只需要一个 `docker-compose.yml` 即可跑起来。 ### 1. 创建 docker-compose.yml ```yaml services: cloudimgs: image: qazzxxx/cloudimgs:latest ports: - "3001:3001" volumes: - ./uploads:/app/uploads:rw # 图片数据存储位置 restart: unless-stopped container_name: cloudimgs-app environment: - PUID=1000 # 替换为你 NAS 用户的 UID (终端输入 id -u 查看) - PGID=1000 # 替换为你 NAS 用户组的 GID (终端输入 id -g 查看) - UMASK=002 - NODE_ENV=production - PORT=3001 - STORAGE_PATH=/app/uploads # 👇 如果需要密码访问,请取消下面这行的注释并修改密码 # - PASSWORD=your_secure_password_here ``` ### 2. 启动服务 ```bash docker-compose up -d ``` 启动后,访问 `http://ip:3001` 即可开始使用! ### 关于密码保护 如果你是在公网环境或者不想让别人随意查看,强烈建议在环境变量中配置 `PASSWORD`。配置后,访问系统需要输入密码,且状态会保存在本地浏览器中,既安全又不用频繁登录。 --- ## 🔗 项目地址 开源不易,如果你觉得「云图」还不错,或者帮到了你的忙,希望能去 GitHub 点个 **Star ⭐️** 支持一下!这也是我持续维护的动力。 * **GitHub 项目地址**: [https://github.com/qazzxxx/cloudimgs](https://github.com/qazzxxx/cloudimgs) * **PicGo 插件**: [https://github.com/qazzxxx/picgo-plugin-cloudimgs-uploader](https://github.com/qazzxxx/picgo-plugin-cloudimgs-uploader) 如果你在使用过程中遇到任何问题,欢迎在 GitHub 提 Issue 或在评论区留言,我会尽快回复大家! --- **云舟实验室** *专注分享好用的开源项目与技术折腾心得* ================================================ FILE: client/src/App.js ================================================ import React, { useState, useEffect } from "react"; import { ConfigProvider, theme, message, Spin, Grid, Modal } from "antd"; import FloatingToolbar from "./components/FloatingToolbar"; import ImageGallery from "./components/ImageGallery"; import PasswordOverlay from "./components/PasswordOverlay"; import LogoWithText from "./components/LogoWithText"; import api from "./utils/api"; import ApiDocs from "./components/ApiDocs"; import MapPage from "./components/MapPage"; import ShareView from "./components/ShareView"; import DirectorySelector from "./components/DirectorySelector"; import TrafficDashboard from './components/TrafficDashboard'; import { getPassword, clearPassword } from "./utils/secureStorage"; function App() { const [currentTheme, setCurrentTheme] = useState("light"); const [isAuthenticated, setIsAuthenticated] = useState(false); const [passwordRequired, setPasswordRequired] = useState(false); const [authLoading, setAuthLoading] = useState(true); const [refreshTrigger, setRefreshTrigger] = useState(0); // Batch Mode State const [isBatchMode, setIsBatchMode] = useState(false); const [selectedItems, setSelectedItems] = useState(new Set()); // Batch Move State const [moveModalVisible, setMoveModalVisible] = useState(false); const [targetMoveDir, setTargetMoveDir] = useState(""); const [moving, setMoving] = useState(false); // Simple router check const isApiDocs = window.location.pathname === "/opendocs"; const isMapPage = window.location.pathname === "/map"; const isTrafficDashboard = window.location.pathname === "/traffic"; const isShareView = window.location.pathname.startsWith("/share"); const { useBreakpoint } = Grid; const screens = useBreakpoint(); const isMobile = !screens.md; useEffect(() => { const savedTheme = localStorage.getItem("theme"); if (savedTheme) { setCurrentTheme(savedTheme); } }, []); const handleThemeChange = (theme) => { setCurrentTheme(theme); localStorage.setItem("theme", theme); }; useEffect(() => { const checkAuthStatus = async () => { try { setAuthLoading(true); const response = await api.get("/auth/status"); const data = response.data; if (data.data?.enabled || data.requiresPassword) { setPasswordRequired(true); const savedPassword = getPassword(); if (savedPassword) { try { await api.post("/auth/login", { password: savedPassword }); setIsAuthenticated(true); } catch (e) { clearPassword(); } } } else { setIsAuthenticated(true); } } catch (error) { console.error("Auth check failed:", error); } finally { setAuthLoading(false); } }; checkAuthStatus(); }, []); const handleLoginSuccess = () => { setIsAuthenticated(true); message.success("欢迎回来"); }; const handleRefresh = () => { setRefreshTrigger(prev => prev + 1); }; const toggleBatchMode = () => { setIsBatchMode(prev => !prev); setSelectedItems(new Set()); }; const handleSelectionChange = (newSelection) => { setSelectedItems(newSelection); }; const handleBatchDelete = async () => { if (selectedItems.size === 0) return; try { const hide = message.loading("正在删除...", 0); // Execute deletions in parallel const promises = Array.from(selectedItems).map(relPath => api.delete(`/images/${encodeURIComponent(relPath)}`) ); await Promise.all(promises); hide(); message.success(`成功删除 ${selectedItems.size} 张图片`); // Reset state setSelectedItems(new Set()); setIsBatchMode(false); handleRefresh(); } catch (error) { console.error("Batch delete error:", error); message.error("部分图片删除失败,请重试"); handleRefresh(); // Refresh anyway to show what's left } }; const handleBatchMove = () => { if (selectedItems.size === 0) return; setTargetMoveDir(""); // Reset setMoveModalVisible(true); }; const confirmBatchMove = async () => { if (selectedItems.size === 0) return; setMoving(true); try { const res = await api.post("/batch/move", { files: Array.from(selectedItems), targetDir: targetMoveDir }); if (res.data.success) { message.success(res.data.message || "移动成功"); setMoveModalVisible(false); setSelectedItems(new Set()); setIsBatchMode(false); handleRefresh(); } else { message.error(res.data.error || "移动失败"); } } catch (e) { message.error("移动失败,请重试"); } finally { setMoving(false); } }; // Global styles for glassmorphism and background const globalStyles = ` body { margin: 0; padding: 0; background: ${currentTheme === 'dark' ? '#0f0f0f' : '#f5f7fa'}; transition: background 0.3s ease; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; } /* Custom Scrollbar */ ::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: ${currentTheme === 'dark' ? '#333' : '#ccc'}; border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { background: ${currentTheme === 'dark' ? '#555' : '#999'}; } /* Prevent dropdown scroll from affecting main page */ .directory-selector-dropdown .rc-virtual-list-holder { overflow-y: auto !important; overscroll-behavior: contain; } /* Force fix for Filerobot Image Editor Input Background */ .SfxInput-root { background-color: ${currentTheme === 'dark' ? '#141414' : '#ffffff'} !important; } `; return ( {/* Main Content */}
{isApiDocs ? ( ) : isMapPage ? ( ) : isTrafficDashboard ? ( ) : isShareView ? ( ) : authLoading ? (
) : ( <> {/* Waterfall Gallery */} {/* Only render gallery if authenticated or if no password required, OR render it but it might be empty if API blocks it. We'll render it but PasswordOverlay will cover it. */} {/* Password Overlay */} {passwordRequired && !isAuthenticated && ( )} {/* Floating Toolbar - Only show when authenticated */} {(!passwordRequired || isAuthenticated) && ( )} {/* Batch Move Modal */} setMoveModalVisible(false)} onOk={confirmBatchMove} confirmLoading={moving} okText="确认移动" cancelText="取消" destroyOnClose >

将选中的 {selectedItems.size} 张图片移动到:

)}
); } export default App; ================================================ FILE: client/src/components/AlbumManager.js ================================================ import React, { useState, useEffect } from "react"; import { Modal, Typography, Dropdown, Button, Space, Input, message, Select, Switch, Empty, Spin, theme, } from "antd"; import { MoreOutlined, DeleteOutlined, EditOutlined, ShareAltOutlined, FolderOpenOutlined, CopyOutlined, FireOutlined, StopOutlined, PlusOutlined, LockOutlined, UnlockOutlined } from "@ant-design/icons"; import dayjs from "dayjs"; const { Text } = Typography; const { Option } = Select; const CountdownTimer = ({ expireSeconds, createdAt }) => { const [timeLeft, setTimeLeft] = useState(""); useEffect(() => { if (!expireSeconds) return; const calculateTimeLeft = () => { const expireTime = dayjs(createdAt).add(expireSeconds, 'second'); const now = dayjs(); const diff = expireTime.diff(now, 'second'); if (diff <= 0) { return "已过期"; } const days = Math.floor(diff / (3600 * 24)); const hours = Math.floor((diff % (3600 * 24)) / 3600); const minutes = Math.floor((diff % 3600) / 60); let str = ""; if (days > 0) str += `${days}天 `; if (hours > 0) str += `${hours}小时 `; if (minutes > 0 || (days === 0 && hours === 0)) str += `${minutes}分`; return str; }; setTimeLeft(calculateTimeLeft()); const timer = setInterval(() => { const str = calculateTimeLeft(); setTimeLeft(str); if (str === "已过期") clearInterval(timer); }, 60000); // Update every minute return () => clearInterval(timer); }, [expireSeconds, createdAt]); if (!expireSeconds) return "永久有效"; return `剩余: ${timeLeft}`; }; const AlbumManager = ({ visible, onClose, api, onSelectAlbum }) => { const [albums, setAlbums] = useState([]); const [loading, setLoading] = useState(true); const [shareModalVisible, setShareModalVisible] = useState(false); const [currentAlbum, setCurrentAlbum] = useState(null); // ... (rest of state) const [shareExpiry, setShareExpiry] = useState(3600 * 24); // 1 day const [shareBurn, setShareBurn] = useState(false); const [shareLink, setShareLink] = useState(""); const [generatingLink, setGeneratingLink] = useState(false); const [shareList, setShareList] = useState([]); const [loadingShares, setLoadingShares] = useState(false); // Rename State const [renameModalVisible, setRenameModalVisible] = useState(false); const [renameValue, setRenameValue] = useState(""); // Create State const [createModalVisible, setCreateModalVisible] = useState(false); const [createValue, setCreateValue] = useState(""); // Password State const [passwordModalVisible, setPasswordModalVisible] = useState(false); const [passwordValue, setPasswordValue] = useState(""); const [isRemovingPassword, setIsRemovingPassword] = useState(false); const { token } = theme.useToken(); const fetchAlbums = React.useCallback(async (abortSignal) => { setLoading(true); try { const [dirRes, imgRes] = await Promise.all([ api.get("/directories", { signal: abortSignal }), api.get("/images?pageSize=3", { signal: abortSignal }) // Fetch latest 3 images globally ]); if (dirRes.data.success) { const allAlbums = dirRes.data.data || []; // Use the actual global latest images for the "All Images" cover // Fallback to empty if image fetch failed (though Promise.all would fail commonly, but we can handle it) const globalPreviews = imgRes.data?.success ? imgRes.data.data.map(img => img.url) : []; const allImagesAlbum = { name: "全部图片", path: "", previews: globalPreviews, mtime: new Date().toISOString(), isSystem: true }; // Order: [All Images, ...Real Albums] // The "New Album" is rendered separately in the grid as the first item visually if we want, // or we just prepend it here? // In the render method: {albums.map...} matches `albums`. // The render method ALSO renders a static "New Album" div BEFORE mapping albums. // So `albums` should start with "All Images". setAlbums([allImagesAlbum, ...allAlbums]); } } catch (e) { // Ignore aborted requests if (e.name === 'AbortError' || e.name === 'CanceledError') { return; } console.error(e); message.error("获取相册列表失败"); } finally { setLoading(false); } }, [api]); useEffect(() => { if (visible && api) { const abortController = new AbortController(); fetchAlbums(abortController.signal); return () => { abortController.abort(); }; } }, [visible, api, fetchAlbums]); const fetchShareList = async (path) => { setLoadingShares(true); try { const url = `/share/list?path=${encodeURIComponent(path)}`; const res = await api.get(url); if (res.data.success) { // Sort: Active first, then by createdAt desc const list = res.data.data || []; list.sort((a, b) => { const aActive = a.status === 'active'; const bActive = b.status === 'active'; if (aActive && !bActive) return -1; if (!aActive && bActive) return 1; return b.createdAt - a.createdAt; }); setShareList(list); } } catch (e) { message.error("获取分享列表失败"); } finally { setLoadingShares(false); } }; const handleShare = async () => { if (!currentAlbum) return; setGeneratingLink(true); try { const res = await api.post("/share/generate", { path: currentAlbum.path, expireSeconds: shareExpiry, burnAfterReading: shareBurn, }); if (res.data.success) { const url = `${window.location.origin}/share?token=${encodeURIComponent(res.data.token)}`; setShareLink(url); // Refresh list await fetchShareList(currentAlbum.path); } } catch (e) { message.error("生成分享链接失败"); } finally { setGeneratingLink(false); } }; const handleRevoke = async (signature) => { try { const res = await api.post("/share/revoke", { path: currentAlbum.path, signature }); if (res.data.success) { message.success("链接已作废"); fetchShareList(currentAlbum.path); } } catch (e) { message.error("作废失败"); } }; const handleDeleteShare = async (signature) => { try { const res = await api.delete("/share/delete", { data: { path: currentAlbum.path, signature } }); if (res.data.success) { message.success("删除成功"); fetchShareList(currentAlbum.path); } } catch (e) { message.error("删除失败"); } }; const handleCreate = async () => { if (!createValue.trim()) return; try { // Use API to create directory // Backend needs to support mkdir. // Currently we don't have explicit mkdir API, but upload supports creating dir. // Let's add a mkdir API or use a hack? // Wait, server code `fs.ensureDirSync(dest)` in upload logic creates it. // But we need a dedicated API. // Let's check server/index.js if there is one. // There isn't. I'll add one. const res = await api.post("/directories", { name: createValue.trim() }); if (res.data.success) { message.success("相册创建成功"); setCreateModalVisible(false); setCreateValue(""); fetchAlbums(); } } catch (e) { message.error(e.response?.data?.error || "创建失败"); } }; const handleRename = async () => { if (!currentAlbum || !renameValue.trim()) return; try { // Assuming PUT /api/images works for renaming directories if supported by backend, // actually backend usually supports renaming files. // Checking server code: `PUT /api/images/*` supports `fs.rename`. // It works for directories too if `oldFilePath` points to a directory. // `safeJoin` works for dirs. `fs.pathExists` works. `fs.rename` works. // So yes, we can rename directories! const res = await api.put(`/images/${encodeURIComponent(currentAlbum.path)}`, { newName: renameValue.trim(), newDir: currentAlbum.path.split("/").slice(0, -1).join("/") // Keep parent dir }); if (res.data.success) { message.success("重命名成功"); setRenameModalVisible(false); fetchAlbums(); } } catch (e) { message.error("重命名失败"); } }; const handleSavePassword = async () => { if (!currentAlbum) return; try { const res = await api.post("/album/password", { dir: currentAlbum.path, password: passwordValue }); if (res.data.success) { message.success(passwordValue ? "密码设置成功" : "密码已移除"); setPasswordModalVisible(false); setPasswordValue(""); fetchAlbums(); // Refresh to update lock status } } catch (e) { message.error("操作失败"); } }; const handleDelete = async (album) => { Modal.confirm({ title: "删除相册", content: `确定要删除相册 "${album.name}" 及其所有内容吗?此操作不可恢复。`, okText: "删除", okType: "danger", cancelText: "取消", onOk: async () => { try { // DELETE /api/images/* works for directories too (fs.remove) await api.delete(`/images/${encodeURIComponent(album.path)}`); message.success("相册已删除"); fetchAlbums(); } catch (e) { message.error("删除失败"); } } }); }; const copyToClipboard = (text) => { navigator.clipboard.writeText(text).then(() => { message.success("已复制到剪贴板"); }); }; return ( { setAlbums([]); setLoading(true); }} destroyOnClose transitionName="" maskTransitionName="" title={
相册管理
} width={1000} footer={null} styles={{ body: { padding: 0, minHeight: 400, background: token.colorBgLayout } }} >
{loading ? (
) : albums.length === 0 ? ( ) : (
{/* Create New Album Card */}
setCreateModalVisible(true)} onMouseEnter={e => { e.currentTarget.style.borderColor = token.colorPrimary; e.currentTarget.style.color = token.colorPrimary; }} onMouseLeave={e => { e.currentTarget.style.borderColor = token.colorBorder; e.currentTarget.style.color = token.colorText; }} >
新建相册
{albums.map((album) => ( { onSelectAlbum(album.path); onClose(); }} onShare={() => { setCurrentAlbum(album); setShareLink(""); setShareModalVisible(true); fetchShareList(album.path); }} onRename={() => { setCurrentAlbum(album); setRenameValue(album.name); setRenameModalVisible(true); }} onSetPassword={() => { setCurrentAlbum(album); setPasswordValue(""); setIsRemovingPassword(!!album.locked); setPasswordModalVisible(true); }} onDelete={() => handleDelete(album)} /> ))}
)}
{/* Password Modal */} setPasswordModalVisible(false)} title={isRemovingPassword ? "修改/移除密码" : "设置相册密码"} okText="保存" cancelText="取消" >
{isRemovingPassword ? "此相册已设置密码。输入新密码以修改,或留空以移除密码。" : "设置密码后,访问该相册将需要输入密码。"}
setPasswordValue(e.target.value)} placeholder={isRemovingPassword ? "留空移除密码" : "输入密码"} autoFocus />
{/* Share Modal */} setShareModalVisible(false)} title={
分享相册 - {currentAlbum?.name}
} footer={null} width={600} centered >
生成新链接
有效期
阅后即焚 (一次性访问)
{/* Show newly generated link specifically if needed, but list updates automatically */} {shareLink && (
新链接已生成并添加到列表
)} {/* Active Shares List */}
分享列表
{loadingShares ? (
) : shareList.length === 0 ? (
暂无分享记录
) : (
{shareList.map((share, idx) => (
{share.status === "revoked" ? (
已作废
) : share.status === "expired" ? (
已过期
) : share.status === "burned" ? (
已焚毁
) : share.burnAfterReading ? (
阅后即焚
) : (
)}
{dayjs(share.createdAt).format("MM-DD HH:mm")}
{share.status === 'active' && ( )}
))}
)}
{/* Rename Modal */} setRenameModalVisible(false)} title="重命名相册" > setRenameValue(e.target.value)} placeholder="输入新名称" /> {/* Create Modal */} setCreateModalVisible(false)} title="新建相册" > setCreateValue(e.target.value)} placeholder="输入相册名称 (支持多级如 A/B)" autoFocus />
); }; const AlbumCard = React.memo(({ album, token, onOpen, onShare, onRename, onDelete, onSetPassword, isSystem }) => { const [hover, setHover] = useState(false); // Previews logic const displayPreviews = React.useMemo(() => { const previews = album.previews || []; return [...previews].reverse().slice(0, 3); }, [album.previews]); return (
setHover(true)} onMouseLeave={() => setHover(false)} onClick={onOpen} > {/* Lock Overlay */} {album.locked && (
)} {/* Stacked Images */}
{album.locked ? (
私密相册
) : displayPreviews.length > 0 ? ( displayPreviews.map((src, index) => { // index 0 is bottom, index 2 is top // We want top one to be index 0 in map if we reversed? // Actually, let's just absolute position them. // We need stable keys. const offset = index * 4; // 默认展开一定角度 (例如:5度,10px位移),悬浮时进一步展开 const rotate = hover ? (index - 1) * 15 : (index - 1) * 5; const translateY = hover ? -20 : -5; const translateX = hover ? (index - 1) * 30 : (index - 1) * 10; const scale = 1 - index * 0.05; const zIndex = 10 - index; return (
); }) ) : (
)}
{/* Info Area */}
{album.name}
{dayjs(album.mtime).format("YYYY-MM-DD")}
, onClick: (e) => { e.domEvent.stopPropagation(); onShare(); } }, !isSystem && { key: 'password', label: album.locked ? '管理密码' : '设置密码', icon: album.locked ? : , onClick: (e) => { e.domEvent.stopPropagation(); onSetPassword(); } }, // System albums cannot be renamed or deleted !isSystem && { key: 'rename', label: '重命名', icon: , onClick: (e) => { e.domEvent.stopPropagation(); onRename(); } }, !isSystem && { type: 'divider' }, !isSystem && { key: 'delete', label: '删除相册', icon: , danger: true, onClick: (e) => { e.domEvent.stopPropagation(); onDelete(); } }, ].filter(Boolean) }} trigger={['click']} >
); }); const CheckCircleIcon = () => ( ); export default AlbumManager; ================================================ FILE: client/src/components/ApiDocs.js ================================================ import React from 'react'; import { Typography, Card, Collapse, Tag, Divider, theme, Button, message, Tooltip } from 'antd'; import { FileImageOutlined, FolderOutlined, InfoCircleOutlined, CopyOutlined, CodeOutlined, FileTextOutlined, LockOutlined } from '@ant-design/icons'; import { getPassword } from "../utils/secureStorage"; const { Title, Text, Paragraph } = Typography; const { Panel } = Collapse; const ApiDocs = () => { const { token } = theme.useToken(); const origin = typeof window !== "undefined" ? window.location.origin : ""; const savedPassword = typeof window !== "undefined" ? (getPassword() || "") : ""; const containerStyle = { maxWidth: 900, margin: '0 auto', padding: '40px 20px', }; const endpointStyle = { display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8, flexWrap: 'wrap' }; const methodTagStyle = (method) => { return { minWidth: 60, textAlign: 'center', fontWeight: 'bold' }; }; const copyText = (text) => { if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text) .then(() => message.success("已复制 CURL 命令")) .catch(() => message.error("复制失败")); return; } // Fallback const input = document.createElement("input"); input.style.position = "fixed"; input.style.top = "-10000px"; document.body.appendChild(input); input.value = text; input.focus(); input.select(); try { document.execCommand("copy"); message.success("已复制 CURL 命令"); } catch (e) { message.error("复制失败"); } finally { document.body.removeChild(input); } }; const buildCurl = (endpoint, method = 'GET', options = {}) => { const fullUrl = `${origin}${endpoint}`; const pwdHeader = savedPassword ? ` -H "X-Access-Password: ${savedPassword}"` : ""; const albumPwdHeader = options.albumPassword ? ` -H "X-Album-Password: ${options.albumPassword}"` : ""; let cmd = `curl -X ${method} "${fullUrl}"${pwdHeader}${albumPwdHeader}`; if (method === 'POST') { if (options.isMultipart) { cmd += ` \\\n -F "${options.fileParam || 'image'}=@/path/to/file"`; if (options.extraParams) { options.extraParams.forEach(p => { cmd += ` \\\n -F "${p.key}=${p.value}"`; }); } } else if (options.isJson) { cmd += ` \\\n -H "Content-Type: application/json" \\\n -d '${JSON.stringify(options.body)}'`; } } return cmd; }; const CurlButton = ({ endpoint, method, options }) => ( ); return (
<img src="/favicon.svg" alt="Logo" style={{ width: 48, height: 48, objectFit: 'contain', filter: theme.useToken().token.colorBgContainer === '#141414' ? 'brightness(1.2)' : 'none' }} /> 云图 - 开放接口文档 云图提供了一系列 RESTful API,方便您进行图片的上传、管理与检索。 {savedPassword && ( }> 已自动在 CURL 示例中包含您的访问密码 )}
认证管理 (Authentication)
} key="0" extra={} >
GET /api/auth/status
检查当前系统是否开启了密码保护。
POST /api/auth/login
验证系统访问密码。验证成功后,请在后续请求 Header 中携带 X-Access-Password Body (JSON)
  • password: 访问密码
图片管理 (Images)
} key="1" extra={} >
GET /api/images
分页获取图片列表,支持按目录筛选和关键词搜索。 参数 Headers
POST /api/upload
上传单张或多张图片到指定目录。 Body (FormData)
  • image: 图片文件 (支持多文件)
  • dir: 目标目录 (可选,默认为根目录)
GET /api/random
随机获取一张图片。支持实时图像处理参数(缩放、格式转换等)。 参数
  • dir: 目录路径 (可选)
  • format: 返回格式,json 返回元数据(包含 fullUrl),否则直接返回图片流
  • w: 目标宽度 (可选)
  • h: 目标高度 (可选)
  • q: 图片质量,1-100 (可选)
  • fmt: 目标格式,支持 webp, avif, jpg, png (可选)
POST /api/upload-base64
通过 Base64 字符串上传图片。 Body (JSON)
  • base64Image: Base64 图片字符串 (包含 data URI scheme)
  • dir: 目标目录 (可选)
  • originalName: 原始文件名 (可选,用于保留扩展名)
PUT /api/images/:path
对图片进行重命名或移动到其他目录。 Body (JSON)
  • newName: 新文件名 (可选)
  • newDir: 新目录路径 (可选)
DELETE /api/images/:path
删除指定路径的图片。
文件操作 (Files)} key="2" extra={} >
POST /api/upload-file
上传任意类型文件,支持自动解析音视频时长。 Body (FormData)
  • file: 文件对象
  • dir: 目标目录
  • filename: 自定义文件名 (可选)
目录管理 (Directories)} key="3" extra={} >
GET /api/dirs
获取当前所有的图片目录结构。
POST /api/album/password
设置或移除相册的访问密码。 Body (JSON)
  • dir: 目录路径
  • password: 新密码 (留空则移除密码)
POST /api/album/verify
验证相册密码是否正确。 Body (JSON)
  • dir: 目录路径
  • password: 待验证的密码
系统信息 (System)} key="4" extra={} >
GET /api/stats
获取服务器存储空间使用情况及图片总数统计。
工具接口 (Tools)} key="5" extra={} >
POST /api/process-image
上传并调整图片尺寸(保持纵横比缩放至目标尺寸)。 Body (FormData)
  • image: 图片文件
  • width: 目标宽度
  • height: 目标高度
  • dir: 存储目录 (可选)
POST /api/svg2png ..." } }} />
将 SVG 代码转换为 PNG 图片流。 Body (JSON)
  • svgCode: SVG 源代码字符串
© 2025 Cloud Gallery API. All rights reserved.
); }; export default ApiDocs; ================================================ FILE: client/src/components/DirectorySelector.js ================================================ import React, { useState, useEffect, useRef, useCallback } from "react"; import { Select, Input, Space, Typography, Divider, message } from "antd"; import { FolderOutlined, PlusOutlined, RightOutlined, DownOutlined } from "@ant-design/icons"; const { Option } = Select; const { Text } = Typography; const DirectorySelector = ({ value, onChange, placeholder = "选择或输入相册", style = {}, allowClear = true, showSearch = true, size = "middle", allowInput = true, api, refreshKey = 0, }) => { const [directories, setDirectories] = useState([]); const [loading, setLoading] = useState(false); const [createName, setCreateName] = useState(""); const [expandedPaths, setExpandedPaths] = useState([]); const [isSearching, setIsSearching] = useState(false); const inputRef = useRef(null); // 获取相册列表 const fetchDirectories = useCallback(async () => { setLoading(true); try { const response = await api.get("/directories?recursive=true"); if (response.data.success) { setDirectories(response.data.data || []); } } catch (error) { console.error("获取相册列表失败:", error); } finally { setLoading(false); } }, [api]); useEffect(() => { fetchDirectories(); }, [refreshKey, fetchDirectories]); // Auto expand parents of the current value useEffect(() => { if (value && directories.length > 0) { const parts = value.split("/"); if (parts.length > 1) { const pathsToExpand = []; let currentPath = ""; for (let i = 0; i < parts.length - 1; i++) { currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]; pathsToExpand.push(currentPath); } setExpandedPaths(prev => Array.from(new Set([...prev, ...pathsToExpand]))); } } }, [value, directories]); const handleSelectChange = (selectedValue) => { if (onChange) { onChange(selectedValue); } }; const handleInputChange = (e) => { setCreateName(e.target.value); }; const handleInputKeyPress = (e) => { if (e.key === "Enter") { addNewDirectory(); } }; const handleSearch = (searchValue) => { setIsSearching(!!searchValue); }; const toggleExpand = (e, path) => { e.stopPropagation(); // Prevent selection setExpandedPaths(prev => prev.includes(path) ? prev.filter(p => p !== path) : [...prev, path] ); }; const addNewDirectory = async (e) => { if (e) e.preventDefault?.(); const val = (createName || "").trim(); if (!val) return; try { const res = await api.post("/directories", { name: val }); if (res.data.success) { message.success("相册创建成功"); await fetchDirectories(); if (onChange) { // Use returned path if available, or input value const newPath = res.data.data?.path || val; onChange(newPath); } setCreateName(""); } } catch (e) { message.error(e.response?.data?.error || "创建失败"); } setTimeout(() => { inputRef.current?.focus(); }, 0); }; return ( e.stopPropagation()} onKeyPress={handleInputKeyPress} size={size} suffix={ } /> )} )} > {directories.map((dir) => { const parts = (dir.path || "").split("/").filter(Boolean); const depth = parts.length - 1; const isExpanded = expandedPaths.includes(dir.path); const hasChildren = directories.some(d => d.path !== dir.path && d.path.startsWith(dir.path + '/')); // Visibility check let isVisible = true; if (parts.length > 1 && !isSearching) { let currentPath = ""; for (let i = 0; i < parts.length - 1; i++) { currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]; if (!expandedPaths.includes(currentPath)) { isVisible = false; break; } } } if (!isVisible && !isSearching) return null; return ( ); })} ); }; export default DirectorySelector; ================================================ FILE: client/src/components/FloatingToolbar.js ================================================ import React, { useState } from "react"; import { Modal, Tooltip, theme, Button, FloatButton, Popconfirm } from "antd"; import { CloudUploadOutlined, ReloadOutlined, SunOutlined, MoonOutlined, CheckSquareOutlined, CloseOutlined, DeleteOutlined, DeliveredProcedureOutlined, GlobalOutlined, } from "@ant-design/icons"; import UploadComponent from "./UploadComponent"; const FloatingToolbar = ({ onThemeChange, currentTheme, onRefresh, api, isMobile, isBatchMode, toggleBatchMode, selectedCount, onBatchDelete, onBatchMove, }) => { const [uploadVisible, setUploadVisible] = useState(false); const { token } = theme.useToken(); // Infer dark mode const isDarkMode = currentTheme === "dark"; const handleUploadSuccess = () => { setUploadVisible(false); if (onRefresh) { onRefresh(); } }; const buttonStyle = { background: "transparent", border: "none", color: isDarkMode ? "rgba(255,255,255,0.85)" : "rgba(0,0,0,0.85)", boxShadow: "none", width: 32, height: 32, minWidth: 32, fontSize: 16, display: "flex", alignItems: "center", justifyContent: "center", }; return ( <>
{/* Batch Actions */} {isBatchMode && selectedCount > 0 && ( <> {/* Move Button */}
setUploadVisible(false)} width={isMobile ? "90%" : 600} centered modalRender={(modal) => (
{modal}
)} styles={{ content: { background: 'transparent', boxShadow: 'none', padding: 0, }, body: { padding: 0, } }} destroyOnClose closeIcon={null} >
{/* Custom close button since we removed the default one */}
); }; export default FloatingToolbar; ================================================ FILE: client/src/components/ImageCompressor.js ================================================ import React, { useState, useRef, useEffect } from "react"; import { Card, Typography, Space, Button, Input, message, Row, Col, Slider, Upload, theme, } from "antd"; import { FileZipOutlined, UploadOutlined, DownloadOutlined, CopyOutlined, PictureOutlined, } from "@ant-design/icons"; // import axios from "axios"; const { Title, Text } = Typography; const { Dragger } = Upload; const ImageCompressor = ({ onUploadSuccess, api }) => { const { token: { colorBorder, colorFillTertiary }, } = theme.useToken(); const [originalImage, setOriginalImage] = useState(null); const [compressedImage, setCompressedImage] = useState(null); const [isCompressing, setIsCompressing] = useState(false); const [isUploading, setIsUploading] = useState(false); const [uploadedUrl, setUploadedUrl] = useState(""); const [fileName, setFileName] = useState(""); const [originalSize, setOriginalSize] = useState(0); const [compressedSize, setCompressedSize] = useState(0); const [originalAspectRatio, setOriginalAspectRatio] = useState(1); // 压缩参数 const [width, setWidth] = useState(800); const [height, setHeight] = useState(600); const [quality, setQuality] = useState(100); const [maintainAspectRatio, setMaintainAspectRatio] = useState(true); const canvasRef = useRef(null); // 处理粘贴事件 const handlePaste = async (event) => { const items = event.clipboardData?.items; if (!items) return; for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.type.startsWith("image/")) { event.preventDefault(); const file = item.getAsFile(); if (file) { await handlePastedImage(file); } break; } } }; // 处理粘贴的图片 const handlePastedImage = async (file) => { const isImage = file.type.startsWith("image/"); if (!isImage) { message.error("只能上传图片文件!"); return; } const reader = new FileReader(); reader.onload = (e) => { const img = new Image(); img.onload = () => { // 设置原始图片信息 setOriginalImage(e.target.result); setOriginalSize(file.size); setFileName(`pasted-image-${Date.now()}`); // 生成文件名 // 设置默认尺寸为原始图片尺寸 setWidth(img.width); setHeight(img.height); setMaintainAspectRatio(true); setOriginalAspectRatio(img.width / img.height); message.success("图片粘贴成功!"); }; img.src = e.target.result; }; reader.readAsDataURL(file); }; // 添加全局粘贴事件监听 useEffect(() => { const handleGlobalPaste = (event) => { // 检查是否在输入框中,如果是则不处理粘贴 const target = event.target; if ( target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.contentEditable === "true" ) { return; } handlePaste(event); }; document.addEventListener("paste", handleGlobalPaste); return () => { document.removeEventListener("paste", handleGlobalPaste); }; }, []); // 处理图片上传 const handleImageUpload = (file) => { const isImage = file.type.startsWith("image/"); if (!isImage) { message.error("只能上传图片文件!"); return false; } const reader = new FileReader(); reader.onload = (e) => { const img = new Image(); img.onload = () => { // 设置原始图片信息 setOriginalImage(e.target.result); setOriginalSize(file.size); setFileName(file.name.replace(/\.[^/.]+$/, "")); // 移除扩展名 // 设置默认尺寸为原始图片尺寸 setWidth(img.width); setHeight(img.height); setMaintainAspectRatio(true); setOriginalAspectRatio(img.width / img.height); // 保存原始图片引用 // originalImageRef.current = img; // This line is removed message.success("图片上传成功!"); }; img.src = e.target.result; }; reader.readAsDataURL(file); return false; // 阻止默认上传行为 }; // 压缩图片 const compressImage = () => { if (!originalImage || !canvasRef.current) { message.error("请先上传图片"); return; } setIsCompressing(true); try { const canvas = canvasRef.current; const ctx = canvas.getContext("2d"); const img = new Image(); img.onload = () => { // 设置画布尺寸 canvas.width = width; canvas.height = height; // 清空画布(透明填充) ctx.clearRect(0, 0, canvas.width, canvas.height); // 提升缩放质量 ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = "high"; // 计算缩放比例和居中坐标 const scale = Math.min(width / img.width, height / img.height); // 如果目标尺寸比原图大,保持原图大小不放大 const drawWidth = scale > 1 ? img.width : img.width * scale; const drawHeight = scale > 1 ? img.height : img.height * scale; const offsetX = (width - drawWidth) / 2; const offsetY = (height - drawHeight) / 2; // 居中绘制图片,保持原图清晰度 ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight); // 转换为压缩后的图片(PNG,保留透明像素) const compressedDataUrl = canvas.toDataURL("image/png"); setCompressedImage(compressedDataUrl); // 计算压缩后的大小 const base64Length = compressedDataUrl.length - "data:image/png;base64,".length; const compressedBytes = Math.ceil(base64Length * 0.75); setCompressedSize(compressedBytes); message.success("图片压缩成功!"); }; img.src = originalImage; } catch (error) { console.error("压缩错误:", error); message.error("压缩失败,请重试"); } finally { setIsCompressing(false); } }; // 处理宽度变化 const handleWidthChange = (value) => { setWidth(value); if (maintainAspectRatio) { setHeight(Math.round(value / originalAspectRatio)); } }; // 处理高度变化 const handleHeightChange = (value) => { setHeight(value); if (maintainAspectRatio) { setWidth(Math.round(value * originalAspectRatio)); } }; // 切换宽高比锁定 const toggleAspectRatio = () => { setMaintainAspectRatio(!maintainAspectRatio); }; // 下载压缩后的图片 const downloadCompressedImage = () => { if (!compressedImage) { message.error("请先压缩图片"); return; } const link = document.createElement("a"); link.download = `${fileName}-compressed.png`; link.href = compressedImage; document.body.appendChild(link); link.click(); document.body.removeChild(link); message.success("压缩图片下载成功!"); }; // 上传压缩后的图片 const uploadCompressedImage = async () => { if (!compressedImage) { message.error("请先压缩图片"); return; } setIsUploading(true); try { // 将Data URL转换为Blob const response = await fetch(compressedImage); const blob = await response.blob(); // 创建FormData const formData = new FormData(); formData.append("image", blob, `${fileName}-compressed.png`); // 上传到服务器 const uploadResponse = await api.post("/upload", formData, { headers: { "Content-Type": "multipart/form-data", }, }); if (uploadResponse.data.success) { const imageUrl = `${window.location.origin}${uploadResponse.data.data.url}`; setUploadedUrl(imageUrl); message.success("压缩图片上传成功!"); if (onUploadSuccess) { onUploadSuccess(); } } else { message.error(uploadResponse.data.error || "上传失败"); } } catch (error) { console.error("上传错误:", error); message.error("上传失败,请重试"); } finally { setIsUploading(false); } }; // 复制上传的URL const copyUploadedUrl = () => { if (!uploadedUrl) { message.error("没有可复制的URL"); return; } navigator.clipboard.writeText(uploadedUrl).then(() => { message.success("URL已复制到剪贴板"); }); }; // 格式化文件大小 const formatFileSize = (bytes) => { if (bytes === 0) return "0 Bytes"; const k = 1024; const sizes = ["Bytes", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; }; // 计算压缩率 const compressionRatio = originalSize > 0 ? (((originalSize - compressedSize) / originalSize) * 100).toFixed(1) : 0; return (
<FileZipOutlined /> 图片压缩工具 {/* 左侧:图片上传和参数设置 */} {originalImage ? (
原始图片
原始大小: {formatFileSize(originalSize)}
) : (

点击或拖拽图片到此区域上传

支持 Ctrl+V 粘贴图片

)}
{originalImage && ( {/* 文件名 */}
文件名: setFileName(e.target.value)} placeholder="输入文件名(不含扩展名)" style={{ marginTop: 8 }} addonAfter=".png" />
{/* 尺寸设置 */}
尺寸设置:
handleWidthChange(parseInt(e.target.value) || 0) } addonAfter="px" /> × handleHeightChange(parseInt(e.target.value) || 0) } addonAfter="px" />
{/* 质量设置 */}
压缩质量:{quality}%
{/* 压缩按钮 */}
)} {/* 右侧:压缩结果预览 */} {compressedImage ? ( <>
压缩后的图片
{/* 压缩信息 */}
压缩信息:
原始大小:
{formatFileSize(originalSize)} 压缩后大小:
{formatFileSize(compressedSize)}
压缩率: {compressionRatio}%
) : (
压缩后的图片将在这里显示
)}
{/* 上传结果 */} {uploadedUrl && (
图片URL: {uploadedUrl}
)} {/* 隐藏的Canvas用于压缩 */} {/* 使用说明 */} 使用技巧 } style={{ marginTop: 24 }} size="small" >
压缩参数说明
  • 尺寸:设置压缩后的图片尺寸,支持锁定宽高比
  • 宽高比: 解锁后可自由设置尺寸,图片居中显示,多余部分用透明像素填充,避免变形
  • 质量: 1-100%,数值越高图片质量越好,文件越大
  • 压缩率:显示压缩前后的大小对比
  • 格式:压缩后统一为PNG格式,支持透明像素
  • 清晰度保持: 设置大尺寸时保持原图清晰度,不进行放大,多余部分用透明填充
使用建议
  • 网页使用: 建议质量70-80%,文件大小和质量的平衡点
  • 移动端:建议尺寸不超过1200px,减少加载时间
  • 宽高比:保持宽高比可以避免图片变形
  • 透明填充: 解锁宽高比时,适合制作固定尺寸的图标或背景图
  • 大尺寸设置: 设置比原图大的尺寸时,图片保持原清晰度,周围用透明背景填充
  • 备份:压缩前建议备份原始图片
); }; export default ImageCompressor; ================================================ FILE: client/src/components/ImageCropperTool.js ================================================ import React, { useRef, useState, useEffect } from "react"; import Cropper from "react-cropper"; import "cropperjs/dist/cropper.css"; import { Card, Typography, Button, Upload, message, Space, Row, Col, Input, } from "antd"; import { UploadOutlined, ScissorOutlined, CopyOutlined, RedoOutlined, UndoOutlined, ReloadOutlined, SwapOutlined, } from "@ant-design/icons"; const { Title, Text } = Typography; const { Dragger } = Upload; const ImageCropperTool = ({ api, onUploadSuccess }) => { const cropperRef = useRef(null); const [imageSrc, setImageSrc] = useState(null); const [croppedImageUrl, setCroppedImageUrl] = useState(null); const [isUploading, setIsUploading] = useState(false); const [uploadedUrl, setUploadedUrl] = useState(""); const [fileName, setFileName] = useState("cropped-image"); const [rotate, setRotate] = useState(0); const [cropData, setCropData] = useState(null); const [cropBoxData, setCropBoxData] = useState(null); const [imgData, setImgData] = useState(null); // 处理粘贴事件 const handlePaste = async (event) => { const items = event.clipboardData?.items; if (!items) return; for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.type.startsWith("image/")) { event.preventDefault(); const file = item.getAsFile(); if (file) { await handlePastedImage(file); } break; } } }; // 处理粘贴的图片 const handlePastedImage = async (file) => { const isImage = file.type.startsWith("image/"); if (!isImage) { message.error("只能上传图片文件!"); return; } const reader = new FileReader(); reader.onload = (e) => { setImageSrc(e.target.result); setCroppedImageUrl(null); setUploadedUrl(""); setFileName(`pasted-image-${Date.now()}`); setRotate(0); setTimeout(() => { const cropper = cropperRef.current?.cropper; if (cropper) { cropper.reset(); // 获取画布数据并设置裁剪框为整张图片 const canvasData = cropper.getCanvasData(); cropper.setCropBoxData({ left: canvasData.left, top: canvasData.top, width: canvasData.width, height: canvasData.height, }); } }, 100); }; reader.readAsDataURL(file); }; // 添加全局粘贴事件监听 useEffect(() => { const handleGlobalPaste = (event) => { // 检查是否在输入框中,如果是则不处理粘贴 const target = event.target; if ( target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.contentEditable === "true" ) { return; } handlePaste(event); }; document.addEventListener("paste", handleGlobalPaste); return () => { document.removeEventListener("paste", handleGlobalPaste); }; }, []); const handleImageUpload = (file) => { const isImage = file.type.startsWith("image/"); if (!isImage) { message.error("只能上传图片文件!"); return false; } const reader = new FileReader(); reader.onload = (e) => { setImageSrc(e.target.result); setCroppedImageUrl(null); setUploadedUrl(""); setFileName(file.name.replace(/\.[^/.]+$/, "")); setRotate(0); setTimeout(() => { const cropper = cropperRef.current?.cropper; if (cropper) { cropper.reset(); // 获取画布数据并设置裁剪框为整张图片 const canvasData = cropper.getCanvasData(); cropper.setCropBoxData({ left: canvasData.left, top: canvasData.top, width: canvasData.width, height: canvasData.height, }); } }, 100); }; reader.readAsDataURL(file); return false; }; const handleCrop = () => { const cropper = cropperRef.current?.cropper; if (cropper && imageSrc) { const croppedDataUrl = cropper.getCroppedCanvas()?.toDataURL(); setCroppedImageUrl(croppedDataUrl); setCropBoxData(cropper.getCropBoxData()); setImgData(cropper.getData()); } }; const uploadCroppedImage = async () => { if (!croppedImageUrl) { message.error("请先裁剪图片"); return; } setIsUploading(true); try { const res = await fetch(croppedImageUrl); const blob = await res.blob(); const formData = new FormData(); formData.append("image", blob, fileName + ".png"); const uploadResponse = await api.post("/upload", formData, { headers: { "Content-Type": "multipart/form-data" }, }); if (uploadResponse.data.success) { const imageUrl = `${window.location.origin}${uploadResponse.data.data.url}`; setUploadedUrl(imageUrl); message.success("图片上传成功!"); if (onUploadSuccess) onUploadSuccess(); } else { message.error(uploadResponse.data.error || "上传失败"); } } catch (error) { message.error("上传失败,请重试"); } finally { setIsUploading(false); } }; const copyUploadedUrl = () => { if (!uploadedUrl) { message.error("没有可复制的URL"); return; } navigator.clipboard.writeText(uploadedUrl).then(() => { message.success("URL已复制到剪贴板"); }); }; const handleRotate = (angle) => { const cropper = cropperRef.current?.cropper; if (cropper) { cropper.rotate(angle); setRotate((prev) => prev + angle); } }; const handleReset = () => { const cropper = cropperRef.current?.cropper; if (cropper) { // 先清除状态,避免UI抖动 setCroppedImageUrl(null); setCropBoxData(null); setImgData(null); setRotate(0); // 使用更稳定的重置方法 try { // 先重置到初始状态 cropper.reset(); // 等待DOM更新完成后再设置裁剪框 const resetCropBox = () => { try { const canvasData = cropper.getCanvasData(); if (canvasData && canvasData.width > 0 && canvasData.height > 0) { cropper.setCropBoxData({ left: canvasData.left, top: canvasData.top, width: canvasData.width, height: canvasData.height, }); } } catch (error) { console.warn("设置裁剪框失败:", error); } }; // 使用多重检查确保重置完成 setTimeout(resetCropBox, 100); setTimeout(resetCropBox, 200); } catch (error) { console.warn("重置失败:", error); } } }; const handleFlipHorizontal = () => { const cropper = cropperRef.current?.cropper; if (cropper) { cropper.scaleX(-cropper.getData().scaleX || -1); } }; const handleFlipVertical = () => { const cropper = cropperRef.current?.cropper; if (cropper) { cropper.scaleY(-cropper.getData().scaleY || -1); } }; return ( <ScissorOutlined /> 图片裁剪 {imageSrc ? (
原始图片
) : (

点击或拖拽图片到此区域上传

支持 Ctrl+V 粘贴图片

)}
{/* 裁剪与预览区域 */} {imageSrc && ( <>
{/* 左侧裁剪区 */}
{ // 组件准备就绪时,确保裁剪框设置正确 const cropper = cropperRef.current?.cropper; if (cropper) { setTimeout(() => { try { const canvasData = cropper.getCanvasData(); if (canvasData) { cropper.setCropBoxData({ left: canvasData.left, top: canvasData.top, width: canvasData.width, height: canvasData.height, }); } } catch (error) { console.warn("初始化裁剪框失败:", error); } }, 100); } }} />
{cropBoxData && imgData && (
裁剪区域: {Math.round(imgData.width)} ×{" "} {Math.round(imgData.height)} px
)}
{/* 右侧预览区 */}
裁剪后预览
{croppedImageUrl ? ( 裁剪后图片 ) : (
暂无预览
)} {uploadedUrl && (
)}
{/* 工具栏区域 */} 工具栏: )}
); }; export default ImageCropperTool; ================================================ FILE: client/src/components/ImageDetailModal.js ================================================ import React, { useState, useEffect, useRef } from "react"; import { Modal, Button, Tooltip, Input, Space, Typography, message, Popconfirm, theme, Grid, Spin, } from "antd"; import { LeftOutlined, RightOutlined, CopyOutlined, EditOutlined, FolderOutlined, DownloadOutlined, DeleteOutlined, EnvironmentOutlined, CameraOutlined, HistoryOutlined, } from "@ant-design/icons"; import dayjs from "dayjs"; import { thumbHashToDataURL } from "thumbhash"; import DirectorySelector from "./DirectorySelector"; const { Title, Text } = Typography; // Helper to convert base64 thumbhash to data URL const getThumbHashUrl = (hash) => { if (!hash) return null; try { const binary = Uint8Array.from(atob(hash), (c) => c.charCodeAt(0)); return thumbHashToDataURL(binary); } catch (e) { console.error("ThumbHash decode error:", e); return null; } }; const encodePath = (path) => { if (!path) return ""; return path.split('/').map(encodeURIComponent).join('/'); }; const formatFileSize = (bytes) => { if (bytes === 0) return "0 Bytes"; const k = 1024; const sizes = ["Bytes", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; }; // Helper: Format aperture const formatFNumber = (val) => { if (!val) return ""; const num = parseFloat(val); return parseFloat(num.toFixed(1)); }; // Helper: Format exposure time const formatExposureTime = (val) => { if (!val) return ""; const num = parseFloat(val); if (num >= 1) return parseFloat(num.toFixed(1)) + "s"; return `1/${Math.round(1 / num)}s`; }; const ImageDetailModal = ({ visible, onCancel, file, api, onNext, onPrev, hasNext, hasPrev, onDelete, onUpdate, // Callback when file is renamed or moved }) => { const { token: { colorBgContainer, colorText, colorTextSecondary, colorPrimary }, } = theme.useToken(); const { useBreakpoint } = Grid; const screens = useBreakpoint(); const isMobile = !screens.md; const isDarkMode = colorBgContainer === "#141414" || colorBgContainer === "#000000" || colorBgContainer === "#1f1f1f"; const [imageMeta, setImageMeta] = useState(null); const [imgLoaded, setImgLoaded] = useState(false); const [previewLocation, setPreviewLocation] = useState(""); const [isEditingName, setIsEditingName] = useState(false); const [renameValue, setRenameValue] = useState(""); const [isEditingDir, setIsEditingDir] = useState(false); const [dirValue, setDirValue] = useState(""); const [renaming, setRenaming] = useState(false); const [moving, setMoving] = useState(false); const videoRef = useRef(null); const scrollLockRef = useRef(null); const touchStartXRef = useRef(null); const touchStartYRef = useRef(null); const [zoom, setZoom] = useState(1); const [position, setPosition] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); // Lock body scroll when modal is open useEffect(() => { if (visible) { const scrollY = window.scrollY; scrollLockRef.current = scrollY; document.body.style.overflow = 'hidden'; document.body.style.position = 'fixed'; document.body.style.top = `-${scrollY}px`; document.body.style.width = '100%'; } else { const scrollY = scrollLockRef.current || 0; document.body.style.overflow = ''; document.body.style.position = ''; document.body.style.top = ''; document.body.style.width = ''; window.scrollTo(0, scrollY); } return () => { document.body.style.overflow = ''; document.body.style.position = ''; document.body.style.top = ''; document.body.style.width = ''; }; }, [visible]); // Reset state when file changes useEffect(() => { if (file) { setZoom(1); setPosition({ x: 0, y: 0 }); setImgLoaded(false); // ... existing reset logic setImageMeta(null); setPreviewLocation(""); setIsEditingName(false); setIsEditingDir(false); const ext = file.filename.includes(".") ? file.filename.substring(file.filename.lastIndexOf(".")) : ""; const base = ext ? file.filename.slice(0, -ext.length) : file.filename; setRenameValue(base); const currentDir = file.relPath && file.relPath.includes("/") ? file.relPath.substring(0, file.relPath.lastIndexOf("/")) : ""; setDirValue(currentDir); // Fetch Meta let active = true; api .get(`/images/meta/${encodePath(file.relPath)}`) .then((res) => { if (active && res.data && res.data.success) { setImageMeta(res.data.data); } }) .catch(() => { }); return () => { active = false; }; } }, [file, api]); // eslint-disable-line react-hooks/exhaustive-deps const handleWheel = (e) => { e.stopPropagation(); // 阻止默认滚动行为,避免页面滚动 // e.preventDefault(); // React synthetic event might not support this in all cases, better handle in container const scaleAmount = -e.deltaY * 0.001; setZoom((prevZoom) => { const newZoom = prevZoom + scaleAmount; return Math.max(1, Math.min(newZoom, 5)); // Limit zoom between 1x and 5x }); }; const handleMouseDown = (e) => { if (zoom > 1) { setIsDragging(true); setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y }); e.preventDefault(); // Prevent default drag behavior } }; const handleMouseMove = (e) => { if (isDragging && zoom > 1) { setPosition({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y, }); } }; const handleMouseUp = () => { setIsDragging(false); }; // Reset position if zoomed out to 1 useEffect(() => { if (zoom === 1) { setPosition({ x: 0, y: 0 }); } }, [zoom]); // Fetch Location useEffect(() => { if (!visible || !imageMeta?.exif?.latitude) { setPreviewLocation(""); return; } const { latitude, longitude } = imageMeta.exif; let active = true; const fetchPreviewLoc = async () => { try { const geoRes = await fetch( `https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=10&accept-language=zh-CN` ); const geoData = await geoRes.json(); if (active && geoData) { const addr = geoData.address; const parts = []; if (addr.province) parts.push(addr.province); if (addr.city && addr.city !== addr.province) parts.push(addr.city); if (addr.district || addr.county) parts.push(addr.district || addr.county); if (addr.road || addr.street || addr.pedestrian) parts.push(addr.road || addr.street || addr.pedestrian); if (addr.house_number) parts.push(addr.house_number); const name = geoData.display_name.split(",")[0]; if (name && !parts.includes(name)) { parts.push(name); } let fullAddr = parts.join(" "); if (!fullAddr) { fullAddr = geoData.display_name; } setPreviewLocation(fullAddr); } } catch (e) { } }; fetchPreviewLoc(); return () => { active = false; }; }, [visible, imageMeta]); // Video Playback Control useEffect(() => { if (videoRef.current) { if (visible) { // Optionally reset and play when opened videoRef.current.currentTime = 0; videoRef.current.play().catch(() => { }); } else { // Pause and reset when closed videoRef.current.pause(); videoRef.current.currentTime = 0; } } }, [visible]); // Keyboard Navigation useEffect(() => { const handleKeyDown = (e) => { if (!visible) return; if (e.key === "ArrowRight" && hasNext) onNext(); if (e.key === "ArrowLeft" && hasPrev) onPrev(); }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [visible, hasNext, hasPrev, onNext, onPrev]); const handleDownload = () => { const link = document.createElement("a"); link.href = file.url; link.download = file.filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); message.success("开始下载"); }; const copyToClipboard = (text) => { if (navigator.clipboard && window.isSecureContext) { navigator.clipboard .writeText(text) .then(() => message.success("链接已复制到剪贴板")) .catch(() => message.error("复制失败")); return; } const input = document.createElement("input"); input.style.position = "fixed"; input.style.top = "-10000px"; input.style.zIndex = "-999"; document.body.appendChild(input); input.value = text; input.focus(); input.select(); try { document.execCommand("copy"); message.success("链接已复制到剪贴板"); } catch (e) { message.error("复制失败"); } finally { document.body.removeChild(input); } }; const handleRename = async () => { const oldRel = file.relPath; const ext = file.filename.includes(".") ? file.filename.substring(file.filename.lastIndexOf(".")) : ""; const newNameRaw = renameValue.trim(); if (!newNameRaw) return; const hasExt = /\.[A-Za-z0-9]+$/.test(newNameRaw); const newName = hasExt ? newNameRaw : `${newNameRaw}${ext}`; try { setRenaming(true); const res = await api.put(`/images/${encodePath(oldRel)}`, { newName, }); if (res.data?.success) { const updated = res.data.data; message.success("重命名成功"); setIsEditingName(false); if (onUpdate) onUpdate(updated); } } catch (e) { message.error("重命名失败"); } finally { setRenaming(false); } }; const handleMove = async () => { const oldRel = file.relPath; try { setMoving(true); const res = await api.post("/batch/move", { files: [oldRel], targetDir: dirValue || "", }); if (res.data?.success) { const { successCount = 0, failCount = 0 } = res.data; if (successCount > 0 && failCount === 0) { message.success("已移动到: " + (dirValue || "根目录")); setIsEditingDir(false); } else { message.error(res.data?.error || "移动失败"); } } else { message.error(res.data?.error || "移动失败"); } } catch (e) { const errMsg = e?.response?.data?.error || e?.response?.data?.message || e?.message || "移动失败"; message.error(errMsg); } finally { setMoving(false); } }; const thumbUrl = React.useMemo(() => { if (!file || !file.thumbhash) return null; return getThumbHashUrl(file.thumbhash); }, [file]); const hasThumb = !!thumbUrl; const isDarkBg = hasThumb || isDarkMode; const isLight = !isDarkBg; const textColor = hasThumb ? "#fff" : colorText; const secondaryTextColor = hasThumb ? "rgba(255,255,255,0.75)" : colorTextSecondary; const tertiaryTextColor = hasThumb ? "rgba(255,255,255,0.5)" : isDarkMode ? "rgba(255,255,255,0.45)" : "rgba(0,0,0,0.45)"; const inputBg = hasThumb ? "rgba(255,255,255,0.15)" : isDarkMode ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.06)"; if (!file) return null; return (
{/* Close & Action Buttons */}
{/* Left: Image Viewer */}
{/* Nav Buttons */} {!isMobile && hasPrev && (
{isEditingName && (
setRenameValue(e.target.value)} onPressEnter={handleRename} style={{ background: inputBg, color: textColor, border: "none", }} />
)}
{dirValue || "根目录"}
{isEditingDir && (
)}
{/* Actions Row */}
onDelete && onDelete(file.relPath)} okText="是" cancelText="否" >
{/* Info Sections */} {/* Basic Info */}
基本信息
文件大小
{formatFileSize(file.size || 0)}
格式
{file.filename.split(".").pop().toUpperCase()}
{imageMeta && ( <>
分辨率
{imageMeta.width} × {imageMeta.height}
色彩空间
{imageMeta.space || "-"}
)}
上传时间
{dayjs(file.uploadTime).format("YYYY-MM-DD")}
{previewLocation && (
拍摄地点
{previewLocation}
{imageMeta && imageMeta.exif && imageMeta.exif.latitude && imageMeta.exif.longitude && (