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
================================================
# 云图
> ☁️ **云端一隅,拾光深藏**
> 一个简单、开放且强大的自托管图像托管解决方案。
[](https://github.com/qazzxxx/cloudimgs/stargazers)
[](https://github.com/qazzxxx/cloudimgs/network/members)
[](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) |
| :---: | :---: |
|  |  |
| 图片管理 (Management) | 批量操作 (Batch Actions) |
| :---: | :---: |
|  |  |
### 功能展示
| 相册分享 (Share) | 整页上传 (Upload) |
| :---: | :---: |
|  |  |
| 轨迹地图 (Map) | 图片编辑 (Editor) |
| :---: | :---: |
|  |  |
| 开放接口 (API) | 移动端 (Mobile) |
| :---: | :---: |
|  |  |
---
## 🛠️ 快速部署 | 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
[](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
================================================
云图 - 云端一隅,拾光深藏
You need to enable JavaScript to run this app.
================================================
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 接口**。

## 🛠️ 为什么要造这个轮子?
说实话,起初我并没有打算写个图床。
事情的起因是我在使用 **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**:专为设计师和前端优化的功能。
* **批量操作**:支持圈选批量删除,效率拉满。
---
## 📸 更多界面预览
**登录页面**:简洁大方,支持密码保护。

**瀑布流管理图片**:支持瀑布流展示管理图片。

**批量操作**:支持圈选多图一键操作。

**整页上传**:支持多图拖拽一键上传。

**相册分享**:一键生成分享链接,发给朋友。

**开放API**:灵活调用开放API。

---
## 🚀 极速部署 (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
>
生成新链接
有效期
1 小时
1 天
7 天
30 天
永久有效
阅后即焚 (一次性访问)
}>
生成并复制链接
{/* 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")}
} onClick={() => copyToClipboard(`${window.location.origin}/share?token=${encodeURIComponent(share.token)}`)} disabled={share.status !== 'active'} />
{share.status === 'active' && (
}
onClick={() => handleRevoke(share.signature)}
>
作废
)}
}
onClick={() => handleDeleteShare(share.signature)}
>
删除
))}
)}
{/* 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']}
>
}
onClick={e => e.stopPropagation()}
/>
);
});
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 }) => (
}
onClick={(e) => {
e.stopPropagation();
copyText(buildCurl(endpoint, method, options));
}}
>
CURL
);
return (
云图 - 开放接口文档
云图提供了一系列 RESTful API,方便您进行图片的上传、管理与检索。
{savedPassword && (
}>
已自动在 CURL 示例中包含您的访问密码
)}
认证管理 (Authentication) }
key="0"
extra={ }
>
GET
/api/auth/status
检查当前系统是否开启了密码保护。
POST
/api/auth/login
验证系统访问密码。验证成功后,请在后续请求 Header 中携带 X-Access-Password 。
Body (JSON)
图片管理 (Images)}
key="1"
extra={ }
>
GET
/api/images
分页获取图片列表,支持按目录筛选和关键词搜索。
参数
page : 页码 (默认 1)
pageSize : 每页数量 (默认 50)
dir : 目录路径 (可选)
search : 搜索关键词 (可选)
Headers
X-Album-Password : 相册访问密码 (如果访问的目录已加密)
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)
© 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 (
{
if (!input) return true;
// Use search text from the dir name or path
const searchContent = option.searchValue || "";
return searchContent.toLowerCase().indexOf(input.toLowerCase()) >= 0;
}}
notFoundContent={loading ? "加载中..." : "暂无相册"}
popupClassName="directory-selector-dropdown"
dropdownRender={(menu) => (
{menu}
{allowInput && (
<>
>
)}
)}
>
全部图片
{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 (
{dir.name}
{hasChildren && !isSearching && (
toggleExpand(e, dir.path)}
style={{
padding: '0 4px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
height: '100%',
marginLeft: '8px'
}}
>
{isExpanded ? : }
)}
);
})}
);
};
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 */}
}
type="primary"
size="middle"
onClick={onBatchMove}
style={{
...buttonStyle,
color: '#fff',
background: token.colorPrimary,
boxShadow: `0 2px 8px ${token.colorPrimary}50`
}}
className="toolbar-btn"
/>
}
danger
type="primary"
size="middle"
style={{
...buttonStyle,
color: '#fff',
background: '#ff4d4f',
boxShadow: '0 2px 8px rgba(255, 77, 79, 0.35)'
}}
className="toolbar-btn"
/>
>
)}
{/* Batch Mode Toggle */}
: }
onClick={toggleBatchMode}
size="middle"
type={isBatchMode ? "primary" : "text"}
style={isBatchMode ? {
...buttonStyle,
color: '#fff',
background: token.colorPrimary,
boxShadow: `0 2px 8px ${token.colorPrimary}50`
} : buttonStyle}
className="toolbar-btn"
/>
}
onClick={() => window.location.href = '/map'}
size="middle"
type="text"
style={buttonStyle}
className="toolbar-btn"
/>
}
onClick={onRefresh}
size="middle" // Reduced size
type="text"
style={buttonStyle}
className="toolbar-btn"
/>
: }
onClick={() =>
onThemeChange(isDarkMode ? "light" : "dark")
}
size="middle" // Reduced size
type="text"
style={buttonStyle}
className="toolbar-btn"
/>
{isMobile && (
<>
}
onClick={() => setUploadVisible(true)}
size="middle"
className="upload-btn"
style={{
width: 32,
height: 32,
minWidth: 32,
fontSize: 16,
color: '#fff',
border: 'none',
boxShadow: '0 4px 10px rgba(0,0,0,0.2)',
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
/>
>
)}
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 */}
setUploadVisible(false)}
style={{
position: 'absolute',
right: 12,
top: 12,
zIndex: 10,
color: isDarkMode ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)'
}}
>
✕
>
);
};
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 (
图片压缩工具
{/* 左侧:图片上传和参数设置 */}
{originalImage ? (
原始大小: {formatFileSize(originalSize)}
) : (
点击或拖拽图片到此区域上传
支持 Ctrl+V 粘贴图片
)}
{originalImage && (
{/* 文件名 */}
文件名:
setFileName(e.target.value)}
placeholder="输入文件名(不含扩展名)"
style={{ marginTop: 8 }}
addonAfter=".png"
/>
{/* 尺寸设置 */}
{/* 质量设置 */}
压缩质量:{quality}%
{/* 压缩按钮 */}
}
block
>
{isCompressing ? "压缩中..." : "开始压缩"}
)}
{/* 右侧:压缩结果预览 */}
{compressedImage ? (
<>
{/* 压缩信息 */}
压缩信息:
原始大小:
{formatFileSize(originalSize)}
压缩后大小:
{formatFileSize(compressedSize)}
压缩率:
{compressionRatio}%
}
onClick={downloadCompressedImage}
>
下载图片
}
onClick={uploadCompressedImage}
loading={isUploading}
>
{isUploading ? "上传中..." : "上传到图床"}
>
) : (
)}
{/* 上传结果 */}
{uploadedUrl && (
图片URL:
{uploadedUrl}
} onClick={copyUploadedUrl}>
复制URL
在新窗口打开
)}
{/* 隐藏的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 (
图片裁剪
{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 && (
}
onClick={copyUploadedUrl}
style={{ marginLeft: 8 }}
>
复制URL
)}
{/* 工具栏区域 */}
工具栏:
} onClick={handleReset}>
重置
}
onClick={() => handleRotate(-90)}
>
左转90°
}
onClick={() => handleRotate(90)}
>
右转90°
}
onClick={handleFlipHorizontal}
>
水平翻转
}
onClick={handleFlipVertical}
>
垂直翻转
>
)}
);
};
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 */}
}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
copyToClipboard(window.location.origin + file.url);
}}
onMouseDown={(e) => e.preventDefault()}
onTouchStart={(e) => {
e.stopPropagation();
e.preventDefault();
}}
onTouchEnd={(e) => {
e.stopPropagation();
e.preventDefault();
copyToClipboard(window.location.origin + file.url);
}}
style={{
background: "rgba(0,0,0,0.5)",
border: "1px solid rgba(255,255,255,0.2)",
color: "#fff",
width: 40,
height: 40,
}}
/>
×}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onCancel();
}}
onMouseDown={(e) => e.preventDefault()}
onTouchStart={(e) => {
e.stopPropagation();
e.preventDefault();
}}
onTouchEnd={(e) => {
e.stopPropagation();
e.preventDefault();
onCancel();
}}
style={{
background: "rgba(0,0,0,0.5)",
border: "1px solid rgba(255,255,255,0.2)",
color: "#fff",
width: 40,
height: 40,
}}
/>
{/* Left: Image Viewer */}
{/* Nav Buttons */}
{!isMobile && hasPrev && (
}
onClick={(e) => {
e.stopPropagation();
onPrev();
}}
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",
}}
onMouseEnter={(e) => (e.currentTarget.style.opacity = 1)}
onMouseLeave={(e) => (e.currentTarget.style.opacity = 0)}
/>
)}
{!isMobile && hasNext && (
}
onClick={(e) => {
e.stopPropagation();
onNext();
}}
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",
}}
onMouseEnter={(e) => (e.currentTarget.style.opacity = 1)}
onMouseLeave={(e) => (e.currentTarget.style.opacity = 0)}
/>
)}
1 ? (isDragging ? "grabbing" : "grab") : "default",
}}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onTouchStart={(e) => {
touchStartXRef.current = e.touches[0].clientX;
touchStartYRef.current = e.touches[0].clientY;
}}
onTouchEnd={(e) => {
if (touchStartXRef.current === null) return;
const dx = e.changedTouches[0].clientX - touchStartXRef.current;
const dy = e.changedTouches[0].clientY - touchStartYRef.current;
// 只有水平滑动幅度明显大于垂直时才切换(避免上下滑误触)
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5 && zoom === 1) {
if (dx < 0 && hasNext) {
onNext();
} else if (dx > 0 && hasPrev) {
onPrev();
}
}
touchStartXRef.current = null;
touchStartYRef.current = null;
}}
>
{/* Blurry Background */}
{/\.(mp4|webm)$/i.test(file.filename) ? (
) : (
<>
{!imgLoaded && (
)}
setImgLoaded(true)}
style={{
maxWidth: "100%",
maxHeight: "100%",
width: "auto",
height: "auto",
objectFit: "contain",
boxShadow: "0 20px 50px rgba(0,0,0,0.5)",
zIndex: 2,
transform: `scale(${zoom}) translate(${position.x / zoom}px, ${position.y / zoom}px)`,
transition: isDragging ? "none" : "transform 0.1s ease-out", // Smooth zoom, instant drag
pointerEvents: "none", // Let container handle events
opacity: imgLoaded ? 1 : 0,
}}
src={
// GIF 使用原始文件路径(保留完整动画),其他格式走 /api/images/(经过 sharp 处理)
/\.gif$/i.test(file.filename)
? file.url.replace(/^\/api\/images\//, "/api/files/")
: file.url
}
draggable={false}
/>
>
)}
{/* Right: Info Sidebar */}
{/* Header Section */}
{file.filename}
}
onClick={() => setIsEditingName(!isEditingName)}
/>
{isEditingName && (
setRenameValue(e.target.value)}
onPressEnter={handleRename}
style={{
background: inputBg,
color: textColor,
border: "none",
}}
/>
保存
)}
{dirValue || "根目录"}
setIsEditingDir(!isEditingDir)}
style={{
padding: 0,
height: "auto",
color: isLight ? colorPrimary : "rgba(255,255,255,0.8)",
}}
>
修改
{isEditingDir && (
)}
{/* Actions Row */}
}
onClick={handleDownload}
variant="outlined"
color="primary"
>
下载
onDelete && onDelete(file.relPath)}
okText="是"
cancelText="否"
>
} variant="outlined" color="danger">
删除
{/* 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 && (
)}
)}
{/* EXIF Data */}
{imageMeta?.exif && (
拍摄参数
{[imageMeta.exif.make, imageMeta.exif.model]
.filter(Boolean)
.join(" ")}
相机
{imageMeta.exif.dateTimeOriginal && (
{dayjs(imageMeta.exif.dateTimeOriginal).format(
"YYYY-MM-DD HH:mm:ss"
)}
拍摄时间
)}
{imageMeta.exif.lensModel && (
◎
{imageMeta.exif.lensModel}
镜头
)}
{imageMeta.exif.fNumber && (
f/{formatFNumber(imageMeta.exif.fNumber)}
光圈
)}
{imageMeta.exif.exposureTime && (
{formatExposureTime(imageMeta.exif.exposureTime)}
快门
)}
{imageMeta.exif.iso && (
)}
)}
);
};
export default ImageDetailModal;
================================================
FILE: client/src/components/ImageEditModal.js
================================================
import React, { useMemo } from "react";
import { Modal, Button, theme as antdTheme } from "antd";
import FilerobotImageEditor from "react-filerobot-image-editor";
const ImageEditModal = ({
open,
file,
editorSaving,
onCancel,
onClose,
onOverwriteSave,
onSaveAs,
getEditorDefaults,
getCurrentImgDataFnRef,
theme,
}) => {
const { token } = antdTheme.useToken();
const isDarkMode = useMemo(() => {
if (theme === "dark" || theme === true) return true;
if (theme === "light" || theme === false) return false;
const bg = (token?.colorBgContainer || "").toLowerCase();
return bg === "#141414" || bg === "#000000" || bg === "#1f1f1f";
}, [theme, token?.colorBgContainer]);
const editorSource = useMemo(() => {
if (!file?.url) return null;
if (/^https?:\/\//i.test(file.url)) return file.url;
return `${window.location.origin}${file.url}`;
}, [file]);
const filerobotTheme = useMemo(() => {
const toRgba = (hex, alpha) => {
if (typeof hex !== "string") return undefined;
const h = hex.trim();
if (!h.startsWith("#")) return undefined;
const raw = h.slice(1);
const full =
raw.length === 3
? raw
.split("")
.map((c) => c + c)
.join("")
: raw;
if (full.length !== 6) return undefined;
const r = parseInt(full.slice(0, 2), 16);
const g = parseInt(full.slice(2, 4), 16);
const b = parseInt(full.slice(4, 6), 16);
if ([r, g, b].some((n) => Number.isNaN(n))) return undefined;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
const primary = token?.colorPrimary || "#1677ff";
const primaryActiveBg = toRgba(primary, isDarkMode ? 0.22 : 0.12) || "#ECF3FF";
if (!isDarkMode) {
return {
palette: {
"accent-primary": primary,
"accent-primary-active": primary,
"bg-primary-active": primaryActiveBg,
},
typography: { fontFamily: "Roboto, Arial" },
};
}
return {
palette: {
"accent-primary": primary,
"accent-primary-active": primary,
"bg-primary": token?.colorBgContainer || "#141414",
"bg-secondary": token?.colorBgContainer || "#141414",
"bg-stateless": token?.colorFillSecondary || "#1f1f1f",
"bg-hover": token?.colorBgLayout || "#1f1f1f",
"bg-primary-active": primaryActiveBg,
"txt-primary": token?.colorText || "rgba(255,255,255,0.85)",
"txt-secondary": token?.colorTextSecondary || "rgba(255,255,255,0.45)",
"icon-primary": token?.colorTextSecondary || "rgba(255,255,255,0.65)",
"borders-secondary": token?.colorBorder || "rgba(255,255,255,0.12)",
"borders-primary": token?.colorBorder || "rgba(255,255,255,0.12)",
"bg-active": token?.colorBgLayout || "#1f1f1f",
"light-shadow": "rgba(0, 0, 0, 0.6)",
},
typography: { fontFamily: "Roboto, Arial" },
};
}, [
isDarkMode,
token?.colorPrimary,
token?.colorBgContainer,
token?.colorFillSecondary,
token?.colorText,
token?.colorTextSecondary,
token?.colorBorder,
token?.colorBgLayout,
]);
const translations = {
name: "名称",
save: "保存",
saveAs: "另存为",
back: "返回",
loading: "加载中...",
resetOperations: "重置/删除全部操作",
changesLoseWarningHint: "如果点击“重置”按钮,您的更改将丢失。是否继续?",
discardChangesWarningHint: "如果关闭弹窗,您最后的更改将不会保存。",
cancel: "取消",
apply: "应用",
warning: "警告",
confirm: "确认",
discardChanges: "放弃更改",
undoTitle: "撤销上一步",
redoTitle: "重做上一步",
showImageTitle: "显示原图",
zoomInTitle: "放大",
zoomOutTitle: "缩小",
toggleZoomMenuTitle: "切换缩放菜单",
adjustTab: "调整",
finetuneTab: "微调",
filtersTab: "滤镜",
watermarkTab: "水印",
annotateTabLabel: "标注",
resize: "调整大小",
resizeTab: "调整大小",
imageName: "图片名称",
invalidImageError: "提供的图片无效",
uploadImageError: "上传图片时出错",
areNotImages: "不是图片",
isNotImage: "不是图片",
toBeUploaded: "待上传",
cropTool: "裁剪",
original: "原图",
custom: "自定义",
square: "方形",
landscape: "风景",
portrait: "人像",
ellipse: "椭圆",
classicTv: "传统电视",
cinemascope: "宽银幕",
arrowTool: "箭头",
blurTool: "模糊",
brightnessTool: "亮度",
contrastTool: "对比度",
ellipseTool: "椭圆",
unFlipX: "取消水平翻转",
flipX: "水平翻转",
unFlipY: "取消垂直翻转",
flipY: "垂直翻转",
hsvTool: "HSV",
hue: "色相",
brightness: "亮度",
saturation: "饱和度",
value: "明度",
imageTool: "图片",
importing: "导入中...",
addImage: "+ 添加图片",
uploadImage: "上传图片",
fromGallery: "从图库",
lineTool: "直线",
penTool: "画笔",
polygonTool: "多边形",
sides: "边数",
rectangleTool: "矩形",
cornerRadius: "圆角半径",
resizeWidthTitle: "宽度(像素)",
resizeHeightTitle: "高度(像素)",
toggleRatioLockTitle: "锁定/解锁比例",
resetSize: "重置为原始大小",
rotateTool: "旋转",
textTool: "文字",
textSpacings: "文字间距",
textAlignment: "文字对齐",
fontFamily: "字体",
size: "大小",
letterSpacing: "字间距",
lineHeight: "行高",
warmthTool: "色温",
addWatermark: "+ 添加水印",
addTextWatermark: "+ 添加文字水印",
addWatermarkTitle: "选择水印类型",
uploadWatermark: "上传水印",
addWatermarkAsText: "添加为文字",
padding: "内边距",
paddings: "内边距",
shadow: "阴影",
horizontal: "水平",
vertical: "垂直",
blur: "模糊",
opacity: "不透明度",
transparency: "透明度",
position: "位置",
stroke: "描边",
saveAsModalTitle: "另存为",
extension: "扩展名",
format: "格式",
nameIsRequired: "名称是必填项。",
quality: "质量",
imageDimensionsHoverTitle: "保存的图片尺寸 (宽 x 高)",
cropSizeLowerThanResizedWarning: "注意:选定的裁剪区域小于应用的调整大小,这可能会导致质量下降",
actualSize: "实际大小 (100%)",
fitSize: "适应屏幕",
addImageTitle: "选择要添加的图片...",
mutualizedFailedToLoadImg: "加载图片失败",
tabsMenu: "菜单",
download: "下载",
width: "宽度",
height: "高度",
plus: "+",
cropItemNoEffect: "此裁剪项无预览可用"
};
return (
{editorSource && file && (
)}
);
};
export default ImageEditModal;
================================================
FILE: client/src/components/ImageGallery.js
================================================
import React, { useState, useEffect, useRef, useMemo, useCallback } from "react";
import {
Masonry,
Button,
Typography,
Modal,
message,
Popconfirm,
Tabs,
Input,
Empty,
Spin,
Grid,
theme,
Popover,
} from "antd";
import {
DeleteOutlined,
DownloadOutlined,
CopyOutlined,
EditOutlined,
SearchOutlined,
FolderOutlined,
MenuOutlined,
ApiOutlined,
CloudUploadOutlined,
EnvironmentOutlined,
CodeOutlined,
CheckOutlined,
CloseOutlined,
AreaChartOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
import { thumbHashToDataURL } from "thumbhash";
import DirectorySelector from "./DirectorySelector";
import SvgToolModal from "./SvgToolModal";
import AlbumManager from "./AlbumManager";
import ImageDetailModal from "./ImageDetailModal";
import ImageEditModal from "./ImageEditModal";
import dayjs from "dayjs";
const { Title, Text } = Typography;
const getCacheBustedUrl = (img, width = 0) => {
if (!img) return "";
let u = img.url;
if (!u) return "";
const t = img.mtime || (img.uploadTime ? new Date(img.uploadTime).getTime() : 0);
if (t) {
u += u.includes('?') ? `&t=${t}` : `?t=${t}`;
}
if (width > 0) {
u += u.includes('?') ? `&w=${width}` : `?w=${width}`;
}
return u;
};
// 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 ImageItem = ({
image,
hoverKey,
setHoverKey,
handlePreview,
formatFileSize,
isMobile,
handleDownload,
onCopyClick,
handleDelete,
handleEdit,
hoverLocation,
isBatchMode,
isSelected,
onToggleSelect,
registerRef,
thumbnailWidth = 0
}) => {
const [loaded, setLoaded] = useState(false);
const videoRef = useRef(null);
const {
token: { colorBgContainer, colorPrimary },
} = theme.useToken();
useEffect(() => {
if (!videoRef.current) return;
const key = image.relPath || image.url || image.filename;
if (hoverKey === key) {
videoRef.current.currentTime = 0;
const playPromise = videoRef.current.play();
if (playPromise !== undefined) {
playPromise.catch(() => { });
}
} else {
videoRef.current.pause();
videoRef.current.currentTime = 0;
}
}, [hoverKey, image]);
return (
registerRef && registerRef(image.relPath, node)}
style={{
position: "relative",
overflow: "hidden",
borderRadius: "0px",
boxShadow: "0 2px 8px rgba(0,0,0,0.05)",
transition: "transform 0.3s ease",
background: colorBgContainer,
cursor: isBatchMode ? "default" : "zoom-in",
transform: isBatchMode && isSelected ? "scale(0.95)" : "scale(1)",
}}
onMouseEnter={() => {
if (!isBatchMode) {
setHoverKey(image.relPath || image.url || image.filename);
}
}}
onMouseLeave={() => {
if (!isBatchMode) {
setHoverKey(null);
}
}}
onClick={(e) => {
if (isBatchMode) {
e.stopPropagation();
onToggleSelect && onToggleSelect(image.relPath);
} else {
handlePreview(image);
}
}}
>
{/* Batch Selection Overlay */}
{isBatchMode && (
<>
{isSelected && (
)}
>
)}
{/* ThumbHash Placeholder Layer */}
{image.thumbhash && (
)}
{/* Real Image/Video Layer */}
{(() => {
const isVideo = /\.(mp4|webm)$/i.test(image.filename);
const isGif = /\.gif$/i.test(image.filename);
// GIF 使用原始文件路径,保留完整动画(与详情页策略一致)
const gifSrc = image.url.replace(/^\/api\/images\//, "/api/files/");
if (isVideo) {
return (
setLoaded(true)}
/>
);
}
return (
setLoaded(true)}
style={{
width: "100%",
display: "block",
transition: "transform 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.5s ease-in",
transform:
hoverKey ===
(image.relPath || image.url || image.filename)
? "scale(1.05)"
: "scale(1)",
opacity: loaded ? 1 : 0, // Fade in when loaded
position: "relative",
zIndex: 2,
}}
/>
);
})()}
{/* Advanced Hover Overlay */}
{!isMobile && !isBatchMode && (
{/* Title / Filename */}
{image.filename.replace(/\.[^/.]+$/, "")}
{/* Metadata Row */}
{dayjs(image.uploadTime).format("YYYY-MM-DD")}
·
{formatFileSize(image.size)}
{hoverLocation && (
<>
·
{hoverLocation}
>
)}
{/* Action Buttons */}
}
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",
}}
>
下载
}
onClick={(e) => {
e.stopPropagation();
onCopyClick(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",
}}
>
{!(/\.(mp4|webm)$/i.test(image.filename)) && (
}
onClick={(e) => {
e.stopPropagation();
handleEdit(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",
}}
>
)}
{
e.stopPropagation();
handleDelete(image.relPath);
}}
onCancel={(e) => {
e?.stopPropagation();
}}
okText="是"
cancelText="否"
>
}
onClick={(e) => e.stopPropagation()}
style={{
background: "rgba(0,0,0,0.4)",
backdropFilter: "blur(4px)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: "4px",
fontSize: "12px",
}}
/>
)}
);
};
const MagicIcon = ({ active }) => (
{/* Main Star (Center-Left) */}
{/* Medium Star (Top-Right) */}
{/* Small Star (Bottom-Right) */}
);
const ImageGallery = ({ onDelete, onRefresh, api, isAuthenticated, refreshTrigger, isBatchMode = false, selectedItems = new Set(), onSelectionChange = () => { } }) => {
const {
token: { colorBgContainer, colorPrimary, colorTextSecondary, colorText },
} = theme.useToken();
const { useBreakpoint } = Grid;
const screens = useBreakpoint();
const isMobile = !screens.md;
const isDark = theme.useToken().theme?.id === 1 || colorBgContainer === "#141414";
const isDarkMode = colorBgContainer === "#141414" || colorBgContainer === "#000000" || colorBgContainer === "#1f1f1f";
// Helper to determine if a color is light or dark (returns true if light)
const isLightColor = (r, g, b) => {
// Calculate relative luminance using standard formula
// Y = 0.2126R + 0.7152G + 0.0722B
const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
return luminance > 0.6; // Threshold for considering it "light"
};
// Define capsule styles based on theme
const capsuleStyle = {
background: isDarkMode ? "rgba(0, 0, 0, 0.65)" : "rgba(255, 255, 255, 0.65)",
border: `1px solid ${isDarkMode ? "rgba(255, 255, 255, 0.15)" : "rgba(255, 255, 255, 0.4)"}`,
boxShadow: isDarkMode ? "0 8px 32px rgba(0, 0, 0, 0.4)" : "0 8px 32px rgba(0, 0, 0, 0.08)",
dividerColor: isDarkMode ? "rgba(255, 255, 255, 0.15)" : "rgba(0,0,0,0.1)",
iconColor: isDarkMode ? "rgba(255, 255, 255, 0.45)" : "rgba(0,0,0,0.4)",
};
const [searchText, setSearchText] = useState("");
const [previewVisible, setPreviewVisible] = useState(false);
const [previewImage, setPreviewImage] = useState("");
const [previewTitle, setPreviewTitle] = useState("");
const [previewFile, setPreviewFile] = useState(null);
const [dir, setDir] = useState("");
const [images, setImages] = useState([]);
const [loading, setLoading] = useState(false);
const [hoverKey, setHoverKey] = useState(null);
const [hoverLocation, setHoverLocation] = useState("");
const [svgToolVisible, setSvgToolVisible] = useState(false);
const [albumManagerVisible, setAlbumManagerVisible] = useState(false);
const [directoryRefreshKey, setDirectoryRefreshKey] = useState(0);
const [copyModalVisible, setCopyModalVisible] = useState(false);
const [copyTargetImage, setCopyTargetImage] = useState(null);
// Album Password Logic
const [albumPasswords, setAlbumPasswords] = useState({}); // { "dir": "password" }
const [passwordPromptVisible, setPasswordPromptVisible] = useState(false);
const [passwordInput, setPasswordInput] = useState("");
const [pendingDir, setPendingDir] = useState(null); // The directory that required password
// Thumbnail width from config (0 = use original)
// Thumbnail width from config (0 = use original)
const [thumbnailWidth, setThumbnailWidth] = useState(0);
// Magic Search
const [magicSearch, setMagicSearch] = useState(false);
const [magicSearchAvailable, setMagicSearchAvailable] = useState(false);
// Fetch config to get thumbnailWidth
useEffect(() => {
const fetchConfig = async () => {
try {
const response = await api.get("/config");
if (response.data.success) {
if (response.data.data?.upload?.thumbnailWidth) {
setThumbnailWidth(response.data.data.upload.thumbnailWidth);
}
if (response.data.data?.magicSearch?.enabled) {
setMagicSearchAvailable(true);
setMagicSearch(true); // Default to enabled
}
}
} catch (error) {
console.warn("获取配置失败:", error);
}
};
fetchConfig();
}, [api]);
useEffect(() => {
if (!hoverKey) {
setHoverLocation("");
return;
}
// Find image
const img = images.find(i => (i.relPath || i.url || i.filename) === hoverKey);
if (!img) return;
// Debounce slightly or just fetch
let active = true;
const fetchLoc = async () => {
try {
// 1. Get Meta
const res = await api.get(`/images/meta/${encodePath(img.relPath)}`);
if (!active) return;
if (res.data?.success && res.data.data?.exif?.latitude) {
const { latitude, longitude } = res.data.data.exif;
// 2. Reverse Geocode
// Use a public API (Nominatim)
// Note: In production, consider caching this or moving to backend
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) {
// Extract city/district
const addr = geoData.address;
// Try to find the most relevant "city" level name
const city = addr.city || addr.town || addr.county || addr.district || addr.state;
setHoverLocation(city ? `${city}` : (geoData.display_name ? geoData.display_name.split(',')[0] : "未知位置"));
}
}
} catch (e) {
// console.error(e); // Silent fail for location fetch
}
};
// Delay to avoid spamming on fast scroll
const timer = setTimeout(fetchLoc, 300);
return () => {
active = false;
clearTimeout(timer);
};
}, [hoverKey, images, api]);
const handleCopyClick = useCallback((image) => {
setCopyTargetImage(image);
setCopyModalVisible(true);
}, []);
const generateImageLinks = (image, type) => {
if (!image) return "";
const fullUrl = `${window.location.origin}${image.url}`;
switch (type) {
case "markdown":
return ``;
case "html":
return ` `;
case "url":
default:
return fullUrl;
}
};
const CopyLinksModal = () => {
const [activeTab, setActiveTab] = useState("url");
const content = generateImageLinks(copyTargetImage, activeTab);
const items = [
{ key: "url", label: "URL" },
{ key: "markdown", label: "Markdown" },
{ key: "html", label: "HTML" },
];
return (
{
setCopyModalVisible(false);
setCopyTargetImage(null);
}}
footer={null}
width={500}
centered
zIndex={1005} // Match upload overlay z-index
>
}
onClick={() => {
copyToClipboard(content);
setCopyModalVisible(false);
setCopyTargetImage(null);
}}
>
一键复制
);
};
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const loadMoreRef = useRef(null);
const [renameValue, setRenameValue] = useState("");
const [renaming, setRenaming] = useState(false);
const [isEditingName, setIsEditingName] = useState(false);
const [imageMeta, setImageMeta] = useState(null);
const [metaLoading, setMetaLoading] = useState(false);
const [isEditingDir, setIsEditingDir] = useState(false);
const [dirValue, setDirValue] = useState("");
const [isDragOver, setIsDragOver] = useState(false);
const [uploadQueue, setUploadQueue] = useState([]);
const [sessionUploadedFiles, setSessionUploadedFiles] = useState([]);
const uploading = uploadQueue.some(item => item.status === 'pending' || item.status === 'uploading');
const [editorVisible, setEditorVisible] = useState(false);
const [editorFile, setEditorFile] = useState(null);
const [editorSaving, setEditorSaving] = useState(false);
const editorGetCurrentImgDataRef = useRef(null);
// Drag Selection Logic
const imageRefs = useRef(new Map());
const [selectionBox, setSelectionBox] = useState(null);
const registerRef = useCallback((id, node) => {
if (node) {
imageRefs.current.set(id, node);
} else {
imageRefs.current.delete(id);
}
}, []);
const handleSelectionMouseDown = (e) => {
if (!isBatchMode) return;
if (e.button !== 0) return; // Only left click
// Prevent text selection
// document.body.style.userSelect = 'none'; // Done in effect
setSelectionBox({
startX: e.pageX,
startY: e.pageY,
currentX: e.pageX,
currentY: e.pageY,
isSelecting: true,
initialSelection: new Set(selectedItems)
});
};
useEffect(() => {
if (!selectionBox?.isSelecting) return;
document.body.style.userSelect = 'none';
const handleSelectionMouseMove = (e) => {
setSelectionBox(prev => ({
...prev,
currentX: e.pageX,
currentY: e.pageY
}));
};
const handleSelectionMouseUp = (e) => {
document.body.style.userSelect = '';
setSelectionBox(null);
};
window.addEventListener('mousemove', handleSelectionMouseMove);
window.addEventListener('mouseup', handleSelectionMouseUp);
return () => {
window.removeEventListener('mousemove', handleSelectionMouseMove);
window.removeEventListener('mouseup', handleSelectionMouseUp);
document.body.style.userSelect = '';
};
}, [selectionBox?.isSelecting]);
// Real-time selection update
useEffect(() => {
if (!selectionBox?.isSelecting) return;
const { startX, startY, currentX, currentY, initialSelection } = selectionBox;
const left = Math.min(startX, currentX);
const top = Math.min(startY, currentY);
const width = Math.abs(currentX - startX);
const height = Math.abs(currentY - startY);
if (width < 5 && height < 5) return;
const animationFrame = requestAnimationFrame(() => {
const newSelected = new Set(initialSelection);
imageRefs.current.forEach((node, relPath) => {
if (!node) return;
const rect = node.getBoundingClientRect();
const nodeLeft = rect.left + window.scrollX;
const nodeTop = rect.top + window.scrollY;
if (
left < nodeLeft + rect.width &&
left + width > nodeLeft &&
top < nodeTop + rect.height &&
top + height > nodeTop
) {
newSelected.add(relPath);
}
});
// Simple check to avoid unnecessary updates if size hasn't changed?
// But Set content might change.
onSelectionChange(newSelected);
});
return () => cancelAnimationFrame(animationFrame);
}, [selectionBox?.currentX, selectionBox?.currentY]);
const groups = useMemo(() => {
const map = new Map();
for (const img of images) {
const key = dayjs(img.uploadTime).format("YYYY年MM月DD日");
const arr = map.get(key) || [];
arr.push(img);
map.set(key, arr);
}
const dates = Array.from(map.keys()).sort(
(a, b) => dayjs(b).valueOf() - dayjs(a).valueOf()
);
return dates.map((d) => ({ date: d, items: map.get(d) }));
}, [images]);
const getEditorDefaults = useCallback((file) => {
const filename = file?.filename || "image";
const lastDot = filename.lastIndexOf(".");
const baseName = lastDot > 0 ? filename.slice(0, lastDot) : filename;
const ext = lastDot > 0 ? filename.slice(lastDot + 1).toLowerCase() : "png";
const normalizedExt = ext === "jpeg" ? "jpg" : ext;
const type =
normalizedExt === "jpg" || normalizedExt === "png" || normalizedExt === "webp"
? normalizedExt
: "png";
return { baseName, type };
}, []);
const handleEdit = useCallback((img) => {
setEditorFile(img);
setEditorVisible(true);
}, []);
const getDirFromRelPath = useCallback((relPath) => {
if (!relPath) return "";
const idx = relPath.lastIndexOf("/");
return idx >= 0 ? relPath.slice(0, idx) : "";
}, []);
const splitFilename = useCallback((filename) => {
const safe = filename || "image.png";
const lastDot = safe.lastIndexOf(".");
if (lastDot <= 0) return { baseName: safe, ext: "png" };
return { baseName: safe.slice(0, lastDot), ext: safe.slice(lastDot + 1).toLowerCase() };
}, []);
const dataUrlToFile = useCallback((dataUrl, filename) => {
const commaIndex = dataUrl.indexOf(",");
const header = commaIndex >= 0 ? dataUrl.slice(0, commaIndex) : "";
const base64 = commaIndex >= 0 ? dataUrl.slice(commaIndex + 1) : dataUrl;
const mimeMatch = header.match(/data:([^;]+);base64/i);
const mimeType = mimeMatch?.[1] || "application/octet-stream";
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return new File([bytes], filename, { type: mimeType });
}, []);
async function refreshAfterEdit(targetDir) {
setCurrentPage(1);
setHasMore(true);
await fetchImages(targetDir, 1, pageSize, searchText, false);
}
const exportFromEditor = useCallback((name, extension) => {
if (!editorGetCurrentImgDataRef.current) {
throw new Error("编辑器未就绪");
}
const result = editorGetCurrentImgDataRef.current(
{ name, extension, quality: 92 },
2,
true
);
return result;
}, []);
const uploadEdited = useCallback(
async ({ base64Image, targetDir, filename, overwrite }) => {
const file = dataUrlToFile(base64Image, filename);
const form = new FormData();
form.append("image", file);
const params = overwrite ? { dir: targetDir, overwrite: "true" } : { dir: targetDir };
return api.post("/upload", form, { params });
},
[api, dataUrlToFile]
);
const handleOverwriteSave = useCallback(async () => {
if (!editorFile) return;
const { baseName, ext } = splitFilename(editorFile.filename);
const targetDir = getDirFromRelPath(editorFile.relPath);
setEditorSaving(true);
let hideLoadingSpinner = null;
try {
const result = exportFromEditor(baseName, ext);
hideLoadingSpinner = result.hideLoadingSpinner;
const base64Image = result.imageData?.imageBase64;
if (!base64Image) throw new Error("导出失败");
const res = await uploadEdited({
base64Image,
targetDir,
filename: editorFile.filename,
overwrite: true,
});
if (res.data?.success) {
message.success("已覆盖保存");
setEditorVisible(false);
setEditorFile(null);
await refreshAfterEdit(targetDir);
} else {
message.error(res.data?.error || "保存失败");
}
} catch (e) {
message.error(e?.response?.data?.error || e?.message || "保存失败");
} finally {
try {
hideLoadingSpinner && hideLoadingSpinner();
} catch (e) { }
setEditorSaving(false);
}
}, [editorFile, exportFromEditor, getDirFromRelPath, refreshAfterEdit, splitFilename, uploadEdited]);
const handleSaveAs = useCallback(() => {
if (!editorFile) return;
const { baseName, ext } = splitFilename(editorFile.filename);
const targetDir = getDirFromRelPath(editorFile.relPath);
let nextName = `${baseName}-edited.${ext}`;
Modal.confirm({
title: "另存为上传",
content: (
{
nextName = e.target.value;
}}
onPressEnter={() => { }}
autoFocus
/>
),
okText: "上传",
cancelText: "取消",
centered: true,
onOk: async () => {
const raw = (nextName || "").trim();
if (!raw) {
message.error("请输入文件名");
throw new Error("invalid");
}
const parts = splitFilename(raw);
setEditorSaving(true);
let hideLoadingSpinner = null;
try {
const result = exportFromEditor(parts.baseName, parts.ext);
hideLoadingSpinner = result.hideLoadingSpinner;
const base64Image = result.imageData?.imageBase64;
if (!base64Image) throw new Error("导出失败");
const res = await uploadEdited({
base64Image,
targetDir,
filename: raw,
overwrite: false,
});
if (res.data?.success) {
message.success("已上传");
setEditorVisible(false);
setEditorFile(null);
await refreshAfterEdit(targetDir);
} else {
message.error(res.data?.error || "上传失败");
throw new Error("failed");
}
} catch (e) {
message.error(e?.response?.data?.error || e?.message || "上传失败");
throw e;
} finally {
try {
hideLoadingSpinner && hideLoadingSpinner();
} catch (e) { }
setEditorSaving(false);
}
},
});
}, [editorFile, exportFromEditor, getDirFromRelPath, refreshAfterEdit, splitFilename, uploadEdited]);
// 分页相关状态
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(() => {
// 从localStorage读取分页大小,默认为10
const savedPageSize = localStorage.getItem("imageGalleryPageSize");
return savedPageSize ? parseInt(savedPageSize) : 10;
});
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
totalPages: 0,
});
async function fetchImages(
targetDir = dir,
targetPage = currentPage,
targetPageSize = pageSize,
targetSearch = searchText,
append = false
) {
// Check authentication first
if (isAuthenticated === false) {
return;
}
if (append) {
setLoadingMore(true);
} else {
setLoading(true);
}
try {
const params = {
page: targetPage,
pageSize: targetPageSize,
...(targetSearch && { search: targetSearch }),
...(targetDir && { dir: targetDir }),
};
// Magic Search Branch
if (magicSearchAvailable && magicSearch && targetSearch && !targetDir) {
// Only allow global search for now, or filter by dir in backend?
// Backend implementation of 'search' currently searches ALL vectors.
// If we want to support directory filter, we need to update searchRoutes/ClipService.
// For now, let's assume global search.
if (append) {
setLoadingMore(false);
return; // No pagination for magic search yet
}
const searchRes = await api.post("/search/semantic", { query: targetSearch, limit: 50 });
if (searchRes.data.success) {
setImages(searchRes.data.data);
setPagination({ current: 1, pageSize: 50, total: searchRes.data.data.length, totalPages: 1 });
setHasMore(false);
return;
}
}
const headers = {};
if (targetDir && albumPasswords[targetDir]) {
headers["x-album-password"] = albumPasswords[targetDir];
}
const res = await api.get("/images", { params, headers });
if (res.data.success) {
setImages((prev) => (append ? prev.concat(res.data.data) : res.data.data));
setPagination(res.data.pagination);
const p = res.data.pagination;
setHasMore(p.current < p.totalPages);
}
} catch (e) {
if (e.response && e.response.status === 403 && e.response.data?.locked) {
// Album is locked
setPendingDir(targetDir);
// Do NOT clear passwordInput here to avoid clearing user input if multiple requests fail (race condition)
// It is already cleared when dir changes.
setPasswordPromptVisible(true);
setLoading(false); // Stop loading spinner
return;
}
// Silent fail or minimal logging to avoid spamming user if it's just auth
if (e.response && e.response.status !== 401) {
message.error("获取图片列表失败");
}
} finally {
if (append) {
setLoadingMore(false);
} else {
setLoading(false);
}
}
}
// 使用ref来跟踪是否是首次加载和防抖
const isInitialized = useRef(false);
const searchTimerRef = useRef(null);
// 统一的数据获取逻辑
useEffect(() => {
// Clear password when dir changes to force re-entry if navigating back
// But we need to be careful not to clear if we are just searching in the same dir?
// User requirement: "每次都需要让输入子密码" (Every time need to enter password).
// This implies if I leave a locked folder and come back, I need to enter password again.
// So clearing all passwords (or at least for this dir?) when `dir` changes is correct.
// However, if we clear ALL passwords, it's safer.
// We only want to clear passwords if the directory ACTUALLY changed.
// This effect runs on [dir, pageSize, searchText, isAuthenticated, refreshTrigger].
// We need to track previous dir.
// Actually, simply clearing `albumPasswords` here might cause infinite loops if fetchImages depends on it?
// fetchImages reads `albumPasswords` from state closure.
// Let's implement a dedicated effect for `dir` change to clear passwords.
}, [dir]); // Dummy placeholder, real logic below
// Track previous directory to detect changes
const prevDirRef = useRef(dir);
useEffect(() => {
if (prevDirRef.current !== dir) {
// Directory changed!
// Clear all stored passwords to enforce re-entry
setAlbumPasswords({});
prevDirRef.current = dir;
// Clear input and state to prevent race conditions
setPasswordInput("");
setImages([]);
setHasMore(true);
setCurrentPage(1);
setPendingDir(null);
// Reset scroll position to top when switching folders
window.scrollTo(0, 0);
}
}, [dir]);
useEffect(() => {
if (!isInitialized.current) {
fetchImages("", 1, pageSize, "");
isInitialized.current = true;
return;
}
if (searchTimerRef.current) {
clearTimeout(searchTimerRef.current);
}
searchTimerRef.current = setTimeout(() => {
setCurrentPage(1);
setHasMore(true);
fetchImages(dir, 1, pageSize, searchText, false);
}, searchText ? 500 : 0);
return () => {
if (searchTimerRef.current) {
clearTimeout(searchTimerRef.current);
}
};
}, [dir, pageSize, searchText, isAuthenticated, refreshTrigger]);
// 当搜索文本变化时重置到第一页
useEffect(() => {
if (isInitialized.current) {
setCurrentPage(1);
}
}, [searchText]);
useEffect(() => {
if (!isInitialized.current) return;
if (currentPage > 1) {
fetchImages(dir, currentPage, pageSize, searchText, true);
}
}, [currentPage]);
useEffect(() => {
const el = loadMoreRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (
entry.isIntersecting &&
hasMore &&
!loading &&
!loadingMore &&
images.length > 0
) {
setCurrentPage((p) => p + 1);
}
},
{ root: null, rootMargin: "200px", threshold: 0 }
);
observer.observe(el);
return () => observer.disconnect();
}, [hasMore, loading, loadingMore, images.length]);
const [previewLocation, setPreviewLocation] = useState("");
const [menuOpen, setMenuOpen] = useState(false);
// Effect for fetching address in Preview Modal
useEffect(() => {
if (!previewVisible || !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;
// Construct detailed address: Province + City + District + Street + Name
// Example: 山东省 临沂市 兰山区 xx路 xx号
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);
// If specific name exists (amenity, building, etc.), append it
const name = geoData.display_name.split(',')[0];
if (name && !parts.includes(name)) {
// Sometimes name is just street number or road, check if redundant
parts.push(name);
}
// If parts is empty or too short, fallback to display_name or city
let fullAddr = parts.join(" ");
// Fallback logic
if (!fullAddr) {
fullAddr = geoData.display_name;
}
setPreviewLocation(fullAddr);
}
} catch (e) {
// console.error(e);
}
};
fetchPreviewLoc();
return () => { active = false; };
}, [previewVisible, imageMeta]);
// Helper for Upload Result
const generateLinks = (type) => {
return sessionUploadedFiles.map(file => {
const fullUrl = `${window.location.origin}${file.url}`;
switch (type) {
case 'markdown':
return ``;
case 'html':
return ` `;
case 'url':
default:
return fullUrl;
}
}).join('\n');
};
const UploadResult = () => {
const [activeTab, setActiveTab] = useState('url');
const content = generateLinks(activeTab);
const items = [
{ key: 'url', label: 'URL' },
{ key: 'markdown', label: 'Markdown' },
{ key: 'html', label: 'HTML' },
];
return (
}
onClick={() => copyToClipboard(content)}
>
一键复制
);
};
// Handle file uploads (Drag & Drop + Paste)
const handleUploadFiles = async (files) => {
if (!isAuthenticated) {
message.warning("请先登录");
return;
}
if (!files || files.length === 0) return;
// Filter images and videos
const imageFiles = Array.from(files).filter(file =>
file.type.startsWith("image/") || file.type.startsWith("video/")
);
if (imageFiles.length === 0) {
message.warning("请选择图片或视频文件");
return;
}
// Add to queue
const newQueueItems = imageFiles.map(file => ({
uid: `upload-${Date.now()}-${Math.random()}`,
file: file,
name: file.name,
progress: 0,
status: 'pending'
}));
setUploadQueue(newQueueItems);
setIsDragOver(false);
// Process queue
// We use a simple loop here, but could be concurrent if needed
// Using for...of loop to process sequentially or Promise.all for parallel?
// Parallel is better for user experience, maybe limit concurrency?
// Let's do simple Promise.all for now, browser limits connections anyway.
// Actually, let's process them one by one to ensure we don't overwhelm server if many files
// But Promise.all is faster. Let's do parallel.
// We need to define the upload function inside or outside
const uploadSingleFile = async (item) => {
const formData = new FormData();
if (dir) {
formData.append("dir", dir);
}
formData.append("image", item.file, item.file.name);
try {
// Update status to uploading
setUploadQueue(prev => prev.map(i => i.uid === item.uid ? { ...i, status: 'uploading' } : i));
const res = await api.post("/upload", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
setUploadQueue(prev => prev.map(i => i.uid === item.uid ? { ...i, progress: percentCompleted } : i));
},
});
if (res.data && res.data.success) {
setUploadQueue(prev => prev.map(i => i.uid === item.uid ? { ...i, status: 'success', progress: 100 } : i));
setSessionUploadedFiles(prev => [...prev, res.data.data]);
return true;
} else {
throw new Error(res.data?.error || "上传失败");
}
} catch (error) {
console.error("Upload error:", error);
setUploadQueue(prev => prev.map(i => i.uid === item.uid ? { ...i, status: 'error', errorMsg: error.message || "上传出错" } : i));
return false;
}
};
// Execute uploads
const results = await Promise.all(newQueueItems.map(item => uploadSingleFile(item)));
// Check if any success to refresh
if (results.some(r => r === true)) {
message.success(`上传完成`);
setCurrentPage(1);
fetchImages(dir, 1, pageSize, searchText, false);
}
};
// Handle URL upload (e.g., from clipboard image URL)
const handleUploadFilesFromUrl = async (url) => {
if (!isAuthenticated) {
message.warning("请先登录");
return;
}
if (!url) return;
// Add to queue for UI feedback
const uid = `url-upload-${Date.now()}`;
const newQueueItem = {
uid,
name: url.split('/').pop() || 'url-image',
progress: 0,
status: 'uploading'
};
setUploadQueue(prev => [...prev, newQueueItem]);
try {
const response = await api.post('/upload-url', { url, dir });
if (response.data?.success) {
setUploadQueue(prev => prev.map(i => i.uid === uid ? { ...i, status: 'success', progress: 100 } : i));
setSessionUploadedFiles(prev => [...prev, response.data.data]);
message.success(`上传完成`);
setCurrentPage(1);
fetchImages(dir, 1, pageSize, searchText, false);
} else {
throw new Error(response.data?.error || '上传失败');
}
} catch (error) {
console.error('URL upload error:', error);
setUploadQueue(prev => prev.map(i => i.uid === uid ? { ...i, status: 'error', errorMsg: error.message || '上传出错' } : i));
message.error(error?.response?.data?.error || error.message || 'URL 上传失败');
}
};
// Global Paste Event Listener
useEffect(() => {
const handlePaste = async (e) => {
// Ignore paste if inside input/textarea
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
return;
}
const items = e.clipboardData?.items;
if (!items) return;
const files = [];
let imageUrl = null;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
const file = items[i].getAsFile();
if (file) files.push(file);
}
}
// Check for image URL in text clipboard
if (files.length === 0) {
// Try getData first (more reliable for plain text URLs)
const plainText = e.clipboardData?.getData('text/plain');
const uriList = e.clipboardData?.getData('text/uri-list');
const textToCheck = uriList || plainText;
if (textToCheck) {
const imageExtPattern = /\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?.*)?$/i;
const urlCandidates = textToCheck.split(/\r?\n/).filter(line => line.trim());
for (const text of urlCandidates) {
const trimmed = text.trim();
if (imageExtPattern.test(trimmed) && trimmed.match(/^https?:\/\//)) {
imageUrl = trimmed;
break;
}
}
}
// Fallback: iterate clipboard items
if (!imageUrl) {
const textItems = e.clipboardData?.items;
if (textItems) {
for (let i = 0; i < textItems.length; i++) {
if (textItems[i].type === 'text/plain' || textItems[i].type === 'text/uri-list') {
const text = await new Promise((resolve) => {
textItems[i].getAsString((str) => resolve(str));
});
if (text) {
const imageExtPattern = /\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?.*)?$/i;
const urlCandidates = text.split(/\r?\n/).filter(line => line.trim());
for (const line of urlCandidates) {
const trimmed = line.trim();
if (imageExtPattern.test(trimmed) && trimmed.match(/^https?:\/\//)) {
imageUrl = trimmed;
break;
}
}
if (imageUrl) break;
}
}
}
}
}
}
if (files.length > 0) {
e.preventDefault();
handleUploadFiles(files);
} else if (imageUrl) {
e.preventDefault();
// Upload from URL
handleUploadFilesFromUrl(imageUrl);
}
};
window.addEventListener('paste', handlePaste);
return () => window.removeEventListener('paste', handlePaste);
}, [dir, isAuthenticated]); // Re-bind if dir changes so upload goes to correct dir
// Global Drag & Drop Listeners
useEffect(() => {
let dragCounter = 0;
const handleDragEnter = (e) => {
e.preventDefault();
e.stopPropagation();
dragCounter++;
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setIsDragOver(true);
}
};
const handleDragLeave = (e) => {
e.preventDefault();
e.stopPropagation();
dragCounter--;
if (dragCounter === 0) {
setIsDragOver(false);
}
};
const handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
dragCounter = 0;
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
handleUploadFiles(e.dataTransfer.files);
}
};
window.addEventListener('dragenter', handleDragEnter);
window.addEventListener('dragleave', handleDragLeave);
window.addEventListener('dragover', handleDragOver);
window.addEventListener('drop', handleDrop);
return () => {
window.removeEventListener('dragenter', handleDragEnter);
window.removeEventListener('dragleave', handleDragLeave);
window.removeEventListener('dragover', handleDragOver);
window.removeEventListener('drop', handleDrop);
};
}, [dir, isAuthenticated]);
const handleDelete = async (relPath) => {
try {
await api.delete(`/images/${encodePath(relPath)}`);
message.success("删除成功");
setImages((prev) => prev.filter((img) => img.relPath !== relPath));
const ps = pagination.pageSize || pageSize;
const newTotal = Math.max(0, (pagination.total || images.length) - 1);
const newTotalPages = Math.max(1, Math.ceil(newTotal / ps));
const newCurrent = Math.min(pagination.current || 1, newTotalPages);
setPagination({
...pagination,
total: newTotal,
totalPages: newTotalPages,
current: newCurrent,
});
setHasMore(newCurrent < newTotalPages);
if (onDelete) {
onDelete(relPath);
}
return true; // Indicate success for callers
} catch (error) {
message.error("删除失败");
return false;
}
};
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 [previewIndex, setPreviewIndex] = useState(-1);
const pendingNavigateRef = useRef(null); // 'next' | null - Used to auto-navigate after loading more in preview mode
// ... (keep existing helper functions)
const handleUpdate = (updatedFile) => {
setImages(prev => prev.map(img => img.relPath === previewFile.relPath ? { ...img, ...updatedFile } : img));
setPreviewFile(updatedFile);
setPreviewTitle(updatedFile.filename);
setPreviewImage(getCacheBustedUrl(updatedFile));
};
const handlePreview = (file) => {
// Find index in current images list
const index = images.findIndex(img => img.relPath === file.relPath);
setPreviewIndex(index);
setPreviewImage(getCacheBustedUrl(file));
setPreviewVisible(true);
setPreviewTitle(file.filename);
setPreviewFile(file);
// Reset edit states
const ext = file.filename.includes(".")
? file.filename.substring(file.filename.lastIndexOf("."))
: "";
const base = ext ? file.filename.slice(0, -ext.length) : file.filename;
setRenameValue(base);
setIsEditingName(false);
setImageMeta(null);
setMetaLoading(true);
const currentDir =
file.relPath && file.relPath.includes("/")
? file.relPath.substring(0, file.relPath.lastIndexOf("/"))
: "";
setDirValue(currentDir);
setIsEditingDir(false);
// Fetch meta
api
.get(`/images/meta/${encodePath(file.relPath)}`)
.then((res) => {
if (res.data && res.data.success) {
setImageMeta(res.data.data);
}
})
.catch(() => { })
.finally(() => setMetaLoading(false));
};
const showNext = () => {
if (previewIndex < images.length - 1) {
handlePreview(images[previewIndex + 1]);
} else if (hasMore && !loadingMore) {
// Reached the end of loaded images but more are available, trigger load more
setCurrentPage((p) => p + 1);
pendingNavigateRef.current = 'next';
}
};
const showPrev = () => {
if (previewIndex > 0) {
handlePreview(images[previewIndex - 1]);
}
};
// Handle auto-navigation to next image after loading more in preview mode
useEffect(() => {
if (pendingNavigateRef.current === 'next' && previewVisible && !loadingMore) {
// Images list has been updated and loading is complete, navigate to next
if (previewIndex < images.length - 1) {
handlePreview(images[previewIndex + 1]);
}
pendingNavigateRef.current = null;
}
}, [images.length, loadingMore]);
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (e) => {
if (!previewVisible) return;
if (e.key === 'ArrowRight') showNext();
if (e.key === 'ArrowLeft') showPrev();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [previewVisible, previewIndex, images]);
const handleDownload = (file) => {
const link = document.createElement("a");
link.href = getCacheBustedUrl(file);
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 {
const ok = document.execCommand("copy");
document.body.removeChild(input);
if (!ok) {
message.error("复制失败");
} else {
message.success("链接已复制到剪贴板");
}
} catch (e) {
document.body.removeChild(input);
message.error("当前浏览器不支持复制功能");
}
};
// Helper to distribute items into columns
const getColumns = (items) => {
const columnsCount = isMobile ? 2 : screens.xl ? 5 : screens.lg ? 4 : screens.md ? 3 : 2;
const columns = Array.from({ length: columnsCount }, () => []);
items.forEach((item, index) => {
columns[index % columnsCount].push(item);
});
return columns;
};
const handlePasswordSubmit = () => {
if (!pendingDir) return;
// Store password in a temporary session-like way?
// User requested "每次都需要让输入子密码" (Every time need to enter password).
// So we should NOT store it in state persistently for auto-retry on subsequent navigations?
// Wait, if we don't store it, scrolling/pagination will fail because loadMore calls fetchImages which needs password.
// So we MUST store it at least for the current session while viewing this album.
// But if user navigates away and comes back, they should enter it again.
// Currently `albumPasswords` is state, so it persists as long as ImageGallery is mounted.
// If user switches dir via top menu, `dir` changes.
// If they switch back to locked dir, we check `albumPasswords[dir]`.
// To satisfy "Every time need to enter password", we should CLEAR the password when directory changes.
// We will implement clearing logic in the `dir` change effect.
// Store password
setAlbumPasswords(prev => ({
...prev,
[pendingDir]: passwordInput
}));
// Close modal
setPasswordPromptVisible(false);
setLoading(true);
const params = {
page: 1,
pageSize: pageSize,
dir: pendingDir,
search: searchText
};
const headers = { "x-album-password": passwordInput };
api.get("/images", { params, headers })
.then(res => {
if (res.data.success) {
setImages(res.data.data);
setPagination(res.data.pagination);
setHasMore(res.data.pagination.current < res.data.pagination.totalPages);
}
})
.catch(e => {
message.error("密码错误或访问失败");
// Clear invalid password
setAlbumPasswords(prev => {
const next = { ...prev };
delete next[pendingDir];
return next;
});
setPasswordPromptVisible(true);
})
.finally(() => setLoading(false));
};
return (
{/* Drag Selection Box */}
{selectionBox?.isSelecting && (
)}
{/* Drag & Drop Overlay */}
{isDragOver && (
)}
{/* Upload Queue Overlay */}
{uploadQueue.length > 0 && (
正在上传 ({uploadQueue.filter(i => i.status === 'success').length}/{uploadQueue.length})
}
onClick={() => {
setUploadQueue([]);
setSessionUploadedFiles([]);
}}
/>
{uploadQueue.map(item => (
{item.name}
{item.status === 'error' ? '失败' : item.status === 'success' ? '完成' : `${item.progress}%`}
{/* antd Progress component is imported but we need to ensure correct props */}
{item.errorMsg &&
{item.errorMsg} }
))}
{!uploading && (
<>
{sessionUploadedFiles.length > 0 &&
}
{
setUploadQueue([]);
setSessionUploadedFiles([]);
}}>
关闭
>
)}
)}
{/* Floating Capsule Header */}
{
setMagicSearch(!magicSearch);
// If clearing, maybe trigger refresh?
// Let existing effects handle it (searchText dependency)
}}
style={{ cursor: 'pointer', marginRight: 4, display: 'flex' }}
title="Magic Search"
>
) : (
)
}
bordered={false}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ background: "transparent", color: colorText }}
/>
}
onClick={() => {
setMenuOpen(false);
// Defer modal opening to let popover close smoothly
setTimeout(() => setAlbumManagerVisible(true), 300);
}}
style={{
width: "100%",
textAlign: "left",
display: "flex",
alignItems: "center",
height: 40,
fontSize: 14
}}
>
相册管理
}
onClick={() => {
setMenuOpen(false);
window.open("/traffic", "_blank");
}}
style={{
width: "100%",
textAlign: "left",
display: "flex",
alignItems: "center",
height: 40,
fontSize: 14
}}
>
流量看板
}
onClick={() => {
setMenuOpen(false);
setSvgToolVisible(true);
}}
style={{
width: "100%",
textAlign: "left",
display: "flex",
alignItems: "center",
height: 40,
fontSize: 14
}}
>
SVG 工具
}
onClick={() => {
setMenuOpen(false);
window.open("/opendocs", "_blank");
}}
style={{
width: "100%",
textAlign: "left",
display: "flex",
alignItems: "center",
height: 40,
fontSize: 14
}}
>
开放接口
{/* Future menu items can be added here */}
}
trigger="hover"
placement="bottomLeft"
arrow={false}
overlayInnerStyle={{ padding: 0, borderRadius: 12, overflow: "hidden" }}
>
e.currentTarget.style.opacity = 0.7}
onMouseLeave={(e) => e.currentTarget.style.opacity = 1}
>
{loading ? (
) : images.length === 0 ? (
) : (
<>
{groups.map((group) => (
{group.date}
{/* Masonry Layout - with batch selection support */}
({
key: imgItem.relPath || `item-${group.date}-${index}`,
data: imgItem,
}))}
itemRender={({ data: imgItem }) => (
{
const newSet = new Set(selectedItems);
if (newSet.has(id)) newSet.delete(id);
else newSet.add(id);
onSelectionChange(newSet);
}}
registerRef={registerRef}
thumbnailWidth={thumbnailWidth}
/>
)}
/>
))}
{loadingMore && (
)}
>
)}
{
setPreviewVisible(false);
setIsEditingName(false);
}}
file={previewFile}
api={api}
onNext={showNext}
onPrev={showPrev}
hasNext={previewIndex < images.length - 1 || hasMore}
hasPrev={previewIndex > 0}
onDelete={(relPath) => {
handleDelete(relPath);
setPreviewVisible(false);
}}
onUpdate={handleUpdate}
/>
{
setEditorVisible(false);
setEditorFile(null);
}}
onClose={() => {
setEditorVisible(false);
setEditorFile(null);
}}
onOverwriteSave={handleOverwriteSave}
onSaveAs={handleSaveAs}
getEditorDefaults={getEditorDefaults}
getCurrentImgDataFnRef={editorGetCurrentImgDataRef}
theme={isDarkMode ? "dark" : "light"}
/>
setSvgToolVisible(false)} api={api} />
{
setAlbumManagerVisible(false);
setDirectoryRefreshKey(prev => prev + 1);
}}
api={api}
onSelectAlbum={(path) => setDir(path)}
/>
{/* Album Password Prompt Modal */}
{
setPasswordPromptVisible(false);
setDir(""); // Go back to root or previous? Root is safer.
}}
okText="确认"
cancelText="取消"
centered
closable={false}
maskClosable={false}
width={360}
>
该相册受密码保护,请输入密码以访问。
setPasswordInput(e.target.value)}
placeholder="输入密码"
onPressEnter={handlePasswordSubmit}
autoFocus
style={{ height: 48, fontSize: 16 }}
/>
);
};
export default ImageGallery;
================================================
FILE: client/src/components/Logo.js
================================================
import React from "react";
const Logo = ({ size = 24, style = {} }) => {
return (
{/* 背景圆形 */}
{/* 云朵 */}
{/* 图片图标 */}
{/* 装饰性元素 */}
);
};
export default Logo;
================================================
FILE: client/src/components/LogoWithText.js
================================================
import React from "react";
import { Typography } from "antd";
const { Title } = Typography;
const LogoWithText = ({
size = 24,
titleLevel = 3,
showTitle = true,
style = {},
titleStyle = {},
}) => {
return (
{showTitle && (
云图
)}
);
};
export default LogoWithText;
================================================
FILE: client/src/components/MapPage.js
================================================
import React, { useState, useEffect, useCallback } from 'react';
import { MapContainer, TileLayer, useMap } from 'react-leaflet';
import MarkerClusterGroup from 'react-leaflet-cluster';
import { Spin, message, Button, Tooltip } from 'antd';
import { ArrowLeftOutlined, EnvironmentOutlined } from '@ant-design/icons';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import 'leaflet.markercluster/dist/MarkerCluster.css';
import coordtransform from 'coordtransform';
import api from '../utils/api';
import ImageDetailModal from './ImageDetailModal';
// Fix Leaflet marker icon issue
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: require('leaflet/dist/images/marker-icon-2x.png'),
iconUrl: require('leaflet/dist/images/marker-icon.png'),
shadowUrl: require('leaflet/dist/images/marker-shadow.png'),
});
const createPhotoIcon = (thumbUrl) => {
return L.divIcon({
className: 'custom-photo-marker-container', // Use container class for positioning
html: `
`,
iconSize: [48, 56], // Slightly larger for better touch targets
iconAnchor: [24, 56],
popupAnchor: [0, -56],
});
};
// Custom Cluster Icon
const createClusterCustomIcon = function (cluster) {
const count = cluster.getChildCount();
let size = 'small';
if (count > 10) size = 'medium';
if (count > 50) size = 'large';
// Get first child's image to use as background (optional, but cool)
// const children = cluster.getAllChildMarkers();
// const firstChildHtml = children[0].options.icon.options.html;
// const bgMatch = firstChildHtml.match(/url\('([^']+)'\)/);
// const bgUrl = bgMatch ? bgMatch[1] : '';
return L.divIcon({
html: `
${count}
`,
className: 'custom-cluster-icon',
iconSize: L.point(40, 40, true),
});
};
const getDisplayCoordinates = (lat, lng) => {
// Always convert WGS-84 to GCJ-02 for AutoNavi
const [lngGcj, latGcj] = coordtransform.wgs84togcj02(lng, lat);
return [latGcj, lngGcj];
};
const MarkerCluster = ({ markers, onMarkerClick }) => {
const map = useMap();
useEffect(() => {
if (!map || !markers) return;
const markerClusterGroup = L.markerClusterGroup({
chunkedLoading: true,
iconCreateFunction: createClusterCustomIcon,
maxClusterRadius: 60,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false
});
const leafletMarkers = markers.map((marker, idx) => {
if (!marker.lat || !marker.lng) return null;
const latLng = getDisplayCoordinates(marker.lat, marker.lng);
const leafletMarker = L.marker(latLng, {
icon: createPhotoIcon(marker.thumbUrl)
});
leafletMarker.on('click', () => {
onMarkerClick(idx);
});
return leafletMarker;
}).filter(Boolean);
markerClusterGroup.addLayers(leafletMarkers);
map.addLayer(markerClusterGroup);
return () => {
map.removeLayer(markerClusterGroup);
};
}, [map, markers, onMarkerClick]);
return null;
};
function MapPage() {
const [state, setState] = useState({
loading: true,
markers: [],
error: null
});
const [modalVisible, setModalVisible] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
useEffect(() => {
const fetchMapData = async () => {
try {
const res = await api.get('/map-data');
if (res.data.success) {
// Filter out invalid coordinates just in case
const markersData = Array.isArray(res.data.data) ? res.data.data : [];
const validMarkers = markersData.filter(m => m.lat && m.lng);
setState({
loading: false,
markers: validMarkers,
error: null
});
} else {
throw new Error(res.data.error);
}
} catch (err) {
setState(prev => ({ ...prev, loading: false, error: err.message || "加载失败" }));
message.error('加载地图数据失败: ' + (err.message || "未知错误"));
}
};
fetchMapData();
}, []);
const handleBack = () => {
window.location.href = '/';
};
const handleMarkerClick = useCallback((index) => {
setSelectedIndex(index);
setModalVisible(true);
}, []);
const handleNext = () => {
if (selectedIndex < state.markers.length - 1) {
setSelectedIndex(prev => prev + 1);
}
};
const handlePrev = () => {
if (selectedIndex > 0) {
setSelectedIndex(prev => prev - 1);
}
};
const handleDelete = async (relPath) => {
try {
await api.delete(`/images/${encodeURIComponent(relPath)}`);
message.success("删除成功");
setState(prev => ({
...prev,
markers: prev.markers.filter(m => m.relPath !== relPath)
}));
setModalVisible(false); // Close modal after delete
} catch (e) {
message.error("删除失败");
}
};
const handleUpdate = (updatedFile) => {
setState(prev => ({
...prev,
markers: prev.markers.map(m =>
m.relPath === updatedFile.relPath || m.relPath === updatedFile.oldRelPath
? { ...m, ...updatedFile, date: updatedFile.uploadTime, thumbUrl: updatedFile.url + '?w=200' } // Ensure essential props
: m
)
}));
};
const marker = selectedIndex >= 0 ? state.markers[selectedIndex] : null;
const currentFile = marker ? {
...marker,
// Use existing URL if it's absolute (Mock mode), otherwise construct API URL
url: marker.url && (marker.url.startsWith('http') || marker.url.startsWith('blob'))
? marker.url
: `/api/images/${marker.relPath.split('/').map(encodeURIComponent).join('/')}`,
uploadTime: marker.date || marker.uploadTime,
size: marker.size || 0
} : null;
// CSS Styles for Glassmorphism
const styles = `
.glass-marker {
width: 48px;
height: 48px;
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 8px;
padding: 3px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
position: relative;
transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
display: flex;
justify-content: center;
align-items: center;
}
.custom-photo-marker-container:hover .glass-marker {
transform: scale(1.15) translateY(-4px);
z-index: 1000;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.2);
border-color: rgba(0, 0, 0, 0.15);
background: rgba(255, 255, 255, 0.85);
}
.marker-image {
width: 100%;
height: 100%;
border-radius: 5px;
background-size: cover;
background-position: center;
background-color: #f0f0f0;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.05);
}
.marker-arrow {
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid rgba(255, 255, 255, 0.6);
filter: drop-shadow(0 2px 2px rgba(0,0,0,0.05));
}
/* Cluster Styles */
.glass-cluster {
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(20, 20, 20, 0.75);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.15);
display: flex;
justify-content: center;
align-items: center;
font-weight: 600;
color: #fff;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25);
animation: pulse-light 3s infinite;
}
.glass-cluster span {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial;
font-size: 14px;
}
@keyframes pulse-light {
0% { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.2); }
70% { box-shadow: 0 0 0 8px rgba(0, 0, 0, 0); }
100% { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); }
}
/* Controls Styling */
.map-control-btn {
background: rgba(20, 20, 20, 0.75) !important;
backdrop-filter: blur(8px) !important;
border: 1px solid rgba(255, 255, 255, 0.15) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important;
color: #fff !important;
}
.map-control-btn:hover {
background: rgba(40, 40, 40, 0.85) !important;
border-color: rgba(255, 255, 255, 0.3) !important;
}
.leaflet-control-zoom a {
background: rgba(20, 20, 20, 0.75) !important;
backdrop-filter: blur(4px) !important;
color: #fff !important;
border-color: rgba(255, 255, 255, 0.15) !important;
}
`;
if (state.loading) {
return
;
}
if (state.error) {
return (
Error: {state.error}
返回首页
);
}
const tileLayerProps = {
url: "https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=7&x={x}&y={y}&z={z}",
attribution: '© 高德地图',
subdomains: ['1', '2', '3', '4'],
minZoom: 3,
maxZoom: 18
};
return (
{/* Top Left Controls */}
}
className="map-control-btn"
onClick={handleBack}
size="large"
shape="circle"
/>
{state.markers.length} 张照片
setModalVisible(false)}
file={currentFile}
api={api}
onNext={handleNext}
onPrev={handlePrev}
hasNext={selectedIndex < state.markers.length - 1}
hasPrev={selectedIndex > 0}
onDelete={handleDelete}
onUpdate={handleUpdate}
/>
);
}
export default MapPage;
================================================
FILE: client/src/components/PasswordOverlay.js
================================================
import React, { useState } from "react";
import { Form, Input, Button, message, Typography } from "antd";
import { LockOutlined, ArrowRightOutlined } from "@ant-design/icons";
import { setPassword, clearPassword } from "../utils/secureStorage";
import ScrollingBackground from "./ScrollingBackground";
import api from "../utils/api";
const { Text, Title } = Typography;
const PasswordOverlay = ({ onLoginSuccess, isMobile }) => {
const [loading, setLoading] = useState(false);
const onFinish = async (values) => {
setLoading(true);
try {
setPassword(values.password);
await api.post("/auth/login", { password: values.password });
onLoginSuccess();
} catch (error) {
console.error("验证失败:", error);
const errorMsg = error.response?.data?.error || "验证失败";
message.error(errorMsg);
clearPassword();
} finally {
setLoading(false);
}
};
return (
{/* Dynamic Background */}
{/* Glassmorphism Overlay */}
{/* Login Card */}
云图 - 云端一隅,拾光深藏
请输入访问密码以继续
}
style={{
width: "100%",
height: "48px",
background: "linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%)",
border: "none",
color: "#333",
fontWeight: "bold",
fontSize: "16px",
boxShadow: "0 4px 15px rgba(255,255,255,0.2)",
}}
>
解锁进入
);
};
export default PasswordOverlay;
================================================
FILE: client/src/components/ScrollingBackground.js
================================================
import React, { useEffect, useState } from "react";
import api from "../utils/api";
const ScrollingBackground = ({ usePicsum = false }) => {
const [images, setImages] = useState([]);
useEffect(() => {
// Fetch some random images to display in the background
const fetchBackgroundImages = async () => {
if (usePicsum) {
setImages(Array.from({ length: 32 }).map((_, i) => ({
url: `https://picsum.photos/seed/${i + 500}/300/450`,
key: i
})));
return;
}
try {
// Try to fetch from our own API first to show actual content
const res = await api.get("/images", { params: { page: 1, pageSize: 20 } });
if (res.data && res.data.success && res.data.data.length > 0) {
setImages(res.data.data);
} else {
// Fallback to placeholder if no images or empty
setImages(Array.from({ length: 24 }).map((_, i) => ({
url: `https://picsum.photos/seed/${i + 200}/300/450`,
key: i
})));
}
} catch (e) {
setImages(Array.from({ length: 32 }).map((_, i) => ({
url: `https://picsum.photos/seed/${i + 100}/300/450`,
key: i
})));
}
};
fetchBackgroundImages();
}, [usePicsum]);
// Prepare columns for masonry-like scroll
const columns = [[], [], [], [], []];
images.forEach((img, i) => {
columns[i % 5].push(img);
});
return (
{columns.map((col, i) => (
{/*
Render duplicated content 3 times to ensure no gaps during the infinite scroll loop.
We need enough content to cover the viewport height plus the scroll distance.
*/}
{[...col, ...col, ...col, ...col].map((img, idx) => (
))}
))}
);
};
export default ScrollingBackground;
================================================
FILE: client/src/components/ShareView.js
================================================
import React, { useState, useEffect, useMemo, useRef } from "react";
import { Masonry, Spin, Typography, Empty, message, theme, Modal, Button, Grid, Space } from "antd";
import {
EnvironmentOutlined, DownloadOutlined, LeftOutlined,
RightOutlined, CameraOutlined,
SunOutlined, MoonOutlined
} from "@ant-design/icons";
import dayjs from "dayjs";
import { thumbHashToDataURL } from "thumbhash";
import api from "../utils/api";
import ScrollingBackground from "./ScrollingBackground";
const { Title, Text } = Typography;
const { useBreakpoint } = Grid;
const getCacheBustedUrl = (img, width = 0) => {
if (!img) return "";
let u = img.url || img; // Handle passing just URL string if needed, although we prefer the whole object
if (typeof u !== 'string') return "";
// Check if we passed the full image object to get mtime
const mtime = img.mtime || (img.uploadTime ? new Date(img.uploadTime).getTime() : 0);
if (mtime) {
u += u.includes('?') ? `&t=${mtime}` : `?t=${mtime}`;
}
if (width > 0) {
u += u.includes('?') ? `&w=${width}` : `?w=${width}`;
}
return u;
};
// 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 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 ImageItem = ({ image, hoverKey, setHoverKey, handlePreview, isMobile, handleDownload, copyToClipboard, thumbnailWidth = 0 }) => {
const [loaded, setLoaded] = useState(false);
const videoRef = useRef(null);
const { token: { colorBgContainer } } = theme.useToken();
useEffect(() => {
if (!videoRef.current) return;
const key = image.relPath || image.url || image.filename;
if (hoverKey === key) {
videoRef.current.currentTime = 0;
const playPromise = videoRef.current.play();
if (playPromise !== undefined) {
playPromise.catch(() => { });
}
} else {
videoRef.current.pause();
videoRef.current.currentTime = 0;
}
}, [hoverKey, image]);
return (
setHoverKey(image.relPath || image.url || image.filename)}
onMouseLeave={() => setHoverKey(null)}
onClick={() => handlePreview(image)}
>
{image.thumbhash && (
)}
{(() => {
const isVideo = /\.(mp4|webm)$/i.test(image.filename);
if (isVideo) {
return (
setLoaded(true)}
/>
);
}
return (
setLoaded(true)}
style={{
width: "100%", display: "block",
transition: "transform 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.5s ease-in",
transform: hoverKey === (image.relPath || image.url || image.filename) ? "scale(1.05)" : "scale(1)",
opacity: loaded ? 1 : 0, position: "relative", zIndex: 2,
}}
/>
);
})()}
{!isMobile && (
{image.filename.replace(/\.[^/.]+$/, "")}
{dayjs(image.uploadTime).format("YYYY-MM-DD")}
·
{formatFileSize(image.size)}
} 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" }}>下载
)}
);
};
const ModalVideoPlayer = ({ url, visible }) => {
const videoRef = useRef(null);
useEffect(() => {
if (videoRef.current) {
if (visible) {
videoRef.current.currentTime = 0;
videoRef.current.play().catch(() => { });
} else {
videoRef.current.pause();
videoRef.current.currentTime = 0;
}
}
}, [visible]);
return (
);
};
const ShareView = ({ currentTheme, onThemeChange }) => {
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [images, setImages] = useState([]);
const [dirName, setDirName] = useState("");
const [error, setError] = useState(null);
const [hoverKey, setHoverKey] = useState(null);
const [thumbnailWidth, setThumbnailWidth] = useState(0);
// Fetch config
useEffect(() => {
const fetchConfig = async () => {
try {
const response = await api.get("/config");
if (response.data.success && response.data.data?.upload?.thumbnailWidth) {
setThumbnailWidth(response.data.data.upload.thumbnailWidth);
}
} catch (error) {
console.warn("Failed to fetch config:", error);
}
};
fetchConfig();
}, []);
// Pagination State
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(20);
const [hasMore, setHasMore] = useState(true);
const loadMoreRef = useRef(null);
// Modal State
const [previewVisible, setPreviewVisible] = useState(false);
const [previewIndex, setPreviewIndex] = useState(-1);
const [previewFile, setPreviewFile] = useState(null);
const [previewLocation, setPreviewLocation] = useState("");
const [imgLoaded, setImgLoaded] = useState(false);
const { token: themeToken } = theme.useToken();
const { colorBgContainer, colorText, colorTextSecondary } = themeToken;
const screens = useBreakpoint();
const isMobile = !screens.md;
const isDarkMode = themeToken.theme?.id === 1 || colorBgContainer === "#141414";
const fetchShare = React.useCallback(async (page = 1, append = false) => {
const params = new URLSearchParams(window.location.search);
const token = params.get("token");
if (!token) {
setError("无效的分享链接");
setLoading(false);
return;
}
if (append) {
setLoadingMore(true);
} else {
setLoading(true);
}
try {
const res = await api.get(`/share/access?token=${encodeURIComponent(token)}&page=${page}&pageSize=${pageSize}`);
if (res.data.success) {
setImages(prev => append ? prev.concat(res.data.data) : res.data.data);
setDirName(res.data.dirName);
const p = res.data.pagination;
if (p) {
setHasMore(p.current < p.totalPages);
} else {
setHasMore(false);
}
} else {
let errorMsg = res.data.error || "获取分享内容失败";
if (errorMsg.includes("Link already used") || errorMsg.includes("Burned")) {
errorMsg = "链接已失效 (阅后即焚)";
} else if (errorMsg.includes("expired") || errorMsg.includes("Invalid")) {
errorMsg = "链接已过期";
}
if (!append) setError(errorMsg);
else message.error(errorMsg);
}
} catch (e) {
let errorMsg = e.response?.data?.error || "链接已失效或验证失败";
if (errorMsg.includes("Link already used") || errorMsg.includes("Burned")) {
errorMsg = "链接已失效 (阅后即焚)";
} else if (errorMsg.includes("expired")) {
errorMsg = "链接已过期";
}
if (!append) setError(errorMsg);
} finally {
if (append) {
setLoadingMore(false);
} else {
setLoading(false);
}
}
}, [pageSize]);
useEffect(() => {
fetchShare(1, false);
}, [fetchShare]);
useEffect(() => {
if (currentPage > 1) {
fetchShare(currentPage, true);
}
}, [currentPage, fetchShare]);
useEffect(() => {
const el = loadMoreRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (
entry.isIntersecting &&
hasMore &&
!loading &&
!loadingMore &&
images.length > 0
) {
setCurrentPage((p) => p + 1);
}
},
{ root: null, rootMargin: "200px", threshold: 0 }
);
observer.observe(el);
return () => observer.disconnect();
}, [hasMore, loading, loadingMore, images.length]);
const groups = useMemo(() => {
const map = new Map();
for (const img of images) {
const key = dayjs(img.uploadTime).format("YYYY年MM月DD日");
const arr = map.get(key) || [];
arr.push(img);
map.set(key, arr);
}
const dates = Array.from(map.keys()).sort(
(a, b) => dayjs(b, "YYYY年MM月DD日").valueOf() - dayjs(a, "YYYY年MM月DD日").valueOf()
);
return dates.map((d) => ({ date: d, items: map.get(d) }));
}, [images]);
const handlePreview = React.useCallback((file) => {
const index = images.findIndex(img => img.relPath === file.relPath);
setPreviewIndex(index);
setPreviewFile(file);
setPreviewVisible(true);
setPreviewLocation(""); // Reset location
setImgLoaded(false);
}, [images]);
const showNext = React.useCallback(() => {
if (previewIndex < images.length - 1) {
handlePreview(images[previewIndex + 1]);
}
}, [previewIndex, images, handlePreview]);
const showPrev = React.useCallback(() => {
if (previewIndex > 0) {
handlePreview(images[previewIndex - 1]);
}
}, [previewIndex, images, handlePreview]);
useEffect(() => {
const handleKeyDown = (e) => {
if (!previewVisible) return;
if (e.key === 'ArrowRight') showNext();
if (e.key === 'ArrowLeft') showPrev();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [previewVisible, showNext, showPrev]);
// Fetch Location for Preview
useEffect(() => {
if (!previewVisible || !previewFile?.exif?.latitude) {
setPreviewLocation("");
return;
}
const { latitude, longitude } = previewFile.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) {
setPreviewLocation(geoData.display_name);
}
} catch (e) { }
};
fetchPreviewLoc();
return () => { active = false; };
}, [previewVisible, previewFile]);
const handleDownload = (file) => {
const link = document.createElement("a");
link.href = getCacheBustedUrl(file);
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("链接已复制到剪贴板"));
} else {
// Fallback
const input = document.createElement("input");
input.value = text;
document.body.appendChild(input);
input.select();
document.execCommand("copy");
document.body.removeChild(input);
message.success("链接已复制到剪贴板");
}
};
if (loading) {
return (
);
}
if (error) {
return (
);
}
return (
{/* Header Banner */}
{/* Theme Toggle Button */}
: }
onClick={() => onThemeChange(currentTheme === 'dark' ? 'light' : 'dark')}
style={{
background: currentTheme === 'dark' ? "rgba(255,255,255,0.15)" : "rgba(255,255,255,0.6)",
backdropFilter: "blur(4px)",
border: `1px solid ${currentTheme === 'dark' ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.1)"}`,
color: currentTheme === 'dark' ? "#fff" : "rgba(0,0,0,0.85)"
}}
/>
{/* Overlay Gradient */}
{/* Header Content */}
{dirName || "分享的相册"}
共 {images.length} 张图片
{groups.map((group) => (
{group.date}
({
key: imgItem.relPath || `item-${group.date}-${index}`,
data: imgItem,
}))}
itemRender={({ data: imgItem }) => (
)}
/>
))}
{loadingMore && (
)}
{/* Full Screen Modal */}
setPreviewVisible(false)}
width="100vw"
style={{ top: 0, margin: 0, maxWidth: "100vw", padding: 0 }}
styles={{
body: { padding: 0, height: "100vh", overflow: "hidden", background: "#000" },
content: { padding: 0, background: "#000", boxShadow: "none" },
container: { padding: 0 }
}}
destroyOnClose
closeIcon={null}
>
{previewFile && (
{/* Close & Copy Buttons */}
×}
onClick={() => setPreviewVisible(false)}
style={{ background: "rgba(0,0,0,0.5)", border: "1px solid rgba(255,255,255,0.2)", color: "#fff", width: 40, height: 40 }}
/>
{/* Left: Image Viewer */}
{!isMobile && previewIndex > 0 && (
}
onClick={(e) => { e.stopPropagation(); showPrev(); }}
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' }}
onMouseEnter={(e) => e.currentTarget.style.opacity = 1}
onMouseLeave={(e) => e.currentTarget.style.opacity = 0}
/>
)}
{!isMobile && previewIndex < images.length - 1 && (
}
onClick={(e) => { e.stopPropagation(); showNext(); }}
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' }}
onMouseEnter={(e) => e.currentTarget.style.opacity = 1}
onMouseLeave={(e) => e.currentTarget.style.opacity = 0}
/>
)}
{/\.(mp4|webm)$/i.test(previewFile.filename) ? (
) : (
<>
{!imgLoaded && (
)}
setImgLoaded(true)}
style={{
maxWidth: "100%", maxHeight: "100%", width: "auto", height: "auto", objectFit: "contain",
boxShadow: "0 20px 50px rgba(0,0,0,0.5)", zIndex: 2,
opacity: imgLoaded ? 1 : 0, transition: "opacity 0.3s ease"
}}
src={getCacheBustedUrl(previewFile)}
/>
>
)}
{/* Right: Info Sidebar */}
{(() => {
const thumbUrl = getThumbHashUrl(previewFile.thumbhash);
const hasThumb = !!thumbUrl;
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)");
return (
{previewFile.filename}
} onClick={() => handleDownload(previewFile)} style={{ color: textColor, borderColor: secondaryTextColor }}>下载
基本信息
文件大小
{formatFileSize(previewFile.size)}
格式
{previewFile.filename.split('.').pop().toUpperCase()}
上传时间
{dayjs(previewFile.uploadTime).format("YYYY-MM-DD")}
{previewLocation && (
拍摄地点
{previewLocation}
{previewFile.exif?.latitude && previewFile.exif?.longitude && (
)}
)}
{previewFile.exif && (
拍摄参数
{[previewFile.exif.make, previewFile.exif.model].filter(Boolean).join(" ")}
相机
{previewFile.exif.fNumber &&
f/{previewFile.exif.fNumber}
光圈
}
{previewFile.exif.exposureTime &&
{previewFile.exif.exposureTime}s
快门
}
{previewFile.exif.iso &&
{previewFile.exif.iso}
ISO
}
)}
);
})()}
)
}
);
};
export default ShareView;
================================================
FILE: client/src/components/SvgToPngTool.js
================================================
import React, { useState, useRef, useEffect } from "react";
import {
Card,
Typography,
Space,
Button,
Input,
message,
Row,
Col,
theme,
Upload,
} from "antd";
import {
CodeOutlined,
PictureOutlined,
DownloadOutlined,
UploadOutlined,
CopyOutlined,
ClearOutlined,
InboxOutlined,
} from "@ant-design/icons";
const { TextArea } = Input;
const { Title, Text } = Typography;
const { Dragger } = Upload;
const SvgToPngTool = ({ onUploadSuccess, api }) => {
const {
token: { colorBorder, colorFillTertiary, colorBgContainer, colorText },
} = theme.useToken();
const [svgCode, setSvgCode] = useState("");
const [pngDataUrl, setPngDataUrl] = useState("");
const [isConverting, setIsConverting] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [uploadedUrl, setUploadedUrl] = useState("");
const [fileName, setFileName] = useState("converted-image");
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;
}
// 处理文本粘贴(可能是SVG代码)
if (item.type === "text/plain") {
item.getAsString((text) => {
// 检查是否是SVG代码
if (
text.trim().startsWith(" {
const isImage = file.type.startsWith("image/");
if (!isImage) {
message.error("只能处理图片文件!");
return;
}
// 如果是SVG图片,尝试读取SVG代码
if (file.type === "image/svg+xml") {
const reader = new FileReader();
reader.onload = (e) => {
setSvgCode(e.target.result);
setFileName(`pasted-svg-${Date.now()}`);
message.success("SVG图片已粘贴,代码已加载!");
};
reader.readAsText(file);
} else {
message.info("粘贴的图片不是SVG格式,请粘贴SVG图片或SVG代码");
}
};
// 添加全局粘贴事件监听
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);
};
}, []);
// 转换SVG为PNG
const convertSvgToPng = async () => {
if (!svgCode.trim()) {
message.error("请输入SVG代码");
return;
}
setIsConverting(true);
try {
// 创建SVG Blob
const svgBlob = new Blob([svgCode], { type: "image/svg+xml" });
const svgUrl = URL.createObjectURL(svgBlob);
// 创建Image对象
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
try {
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
// 设置画布尺寸
canvas.width = img.width;
canvas.height = img.height;
// 绘制图片到画布
ctx.drawImage(img, 0, 0);
// 转换为PNG
const pngDataUrl = canvas.toDataURL("image/png");
setPngDataUrl(pngDataUrl);
// 清理URL
URL.revokeObjectURL(svgUrl);
message.success("SVG转换PNG成功!");
} catch (error) {
console.error("转换错误:", error);
message.error("转换失败,请检查SVG代码格式");
} finally {
setIsConverting(false);
}
};
img.onerror = () => {
message.error("SVG代码格式错误,请检查代码");
setIsConverting(false);
URL.revokeObjectURL(svgUrl);
};
img.src = svgUrl;
} catch (error) {
console.error("转换错误:", error);
message.error("转换失败,请检查SVG代码");
setIsConverting(false);
}
};
// 下载PNG图片
const downloadPng = () => {
if (!pngDataUrl) {
message.error("请先转换SVG为PNG");
return;
}
const link = document.createElement("a");
link.download = `${fileName}.png`;
link.href = pngDataUrl;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
message.success("PNG图片下载成功!");
};
// 上传PNG到图床
const uploadToImageBed = async () => {
if (!pngDataUrl) {
message.error("请先转换SVG为PNG");
return;
}
setIsUploading(true);
try {
// 将Data URL转换为Blob
const response = await fetch(pngDataUrl);
const blob = await response.blob();
// 创建FormData
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("PNG图片上传成功!");
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 clearAll = () => {
setSvgCode("");
setPngDataUrl("");
setUploadedUrl("");
setFileName("converted-image");
message.success("已清空所有内容");
};
// 使用示例SVG
const useExample = () => {
setSvgCode(`
SVG
`);
setFileName("svg-example");
message.success("已加载示例SVG代码");
};
// 自动生成文件名
const generateFileName = () => {
const timestamp = new Date()
.toISOString()
.slice(0, 19)
.replace(/[:-]/g, "");
const newFileName = `svg-${timestamp}`;
setFileName(newFileName);
message.success(`已生成文件名: ${newFileName}`);
};
// 处理SVG文件上传
const handleSvgFileUpload = (file) => {
const isSvg = file.type === 'image/svg+xml' || file.name.toLowerCase().endsWith('.svg');
if (!isSvg) {
message.error('请上传SVG格式的文件!');
return false;
}
const reader = new FileReader();
reader.onload = (e) => {
setSvgCode(e.target.result);
setFileName(file.name.replace(/\.svg$/i, ''));
message.success('SVG文件已加载!');
};
reader.readAsText(file);
return false; // 阻止默认上传行为
};
const uploadProps = {
name: 'file',
multiple: false,
accept: '.svg,image/svg+xml',
beforeUpload: handleSvgFileUpload,
showUploadList: false,
};
return (
SVG转PNG工具
{/* 左侧:SVG输入 */}
{/* 添加紧凑的文件上传区域 */}
点击或拖拽SVG文件到此处
支持 .svg 格式文件
}
style={{ marginBottom: 8 }}
>
使用示例SVG
}
danger
>
清空所有
{/* 右侧:PNG预览和操作 */}
{pngDataUrl ? (
<>
} onClick={downloadPng}>
下载PNG
}
onClick={uploadToImageBed}
loading={isUploading}
>
{isUploading ? "上传中..." : "上传到图床"}
>
) : (
)}
{/* 上传结果 */}
{uploadedUrl && (
图片URL:
{uploadedUrl}
} onClick={copyUploadedUrl}>
复制URL
在新窗口打开
)}
{/* 隐藏的Canvas用于转换 */}
{/* 使用说明 */}
使用技巧
}
style={{ marginTop: 24 }}
size="small"
>
支持的SVG特性
基本图形: circle, rect, line, path,
polygon等
文本: text元素
渐变: linearGradient, radialGradient
滤镜: filter, feGaussianBlur等
动画: animate, animateTransform等
文件名功能
自定义: 可以自定义上传和下载的文件名
自动生成:
点击"自动生成"按钮生成基于时间戳的文件名
扩展名: 文件名会自动添加.png扩展名
示例: 使用示例SVG时会自动设置合适的文件名
格式: 确保SVG代码格式正确
尺寸: 建议设置明确的width和height属性
资源:
外部资源(如图片、字体)可能无法正常显示
质量: 转换后的PNG质量取决于SVG的尺寸设置
文件名: 不要包含特殊字符,避免上传失败
);
};
export default SvgToPngTool;
================================================
FILE: client/src/components/SvgToolModal.js
================================================
import React, { useState, useRef, useEffect } from "react";
import { Modal, Button, Input, message, Upload, Space, Tooltip, theme, Row, Col, Typography } from "antd";
import {
InboxOutlined,
PictureOutlined,
DownloadOutlined,
UploadOutlined,
CodeOutlined,
CloseOutlined,
CopyOutlined,
FileTextOutlined
} from "@ant-design/icons";
const { TextArea } = Input;
const { Text } = Typography;
const SvgToolModal = ({ visible, onClose, api }) => {
const { token } = theme.useToken();
const isDarkMode = token.colorBgContainer === "#141414" || token.colorBgContainer === "#000000" || token.colorBgContainer.includes("1f1f1f");
const [svgCode, setSvgCode] = useState("");
const [pngDataUrl, setPngDataUrl] = useState("");
const [isConverting, setIsConverting] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [uploadedUrl, setUploadedUrl] = useState("");
const canvasRef = useRef(null);
// Clear state when closing
useEffect(() => {
if (!visible) {
// Optional: don't clear immediately to allow reopening
}
}, [visible]);
const handlePaste = async (event) => {
// If inside input/textarea, let default behavior happen
if (['INPUT', 'TEXTAREA'].includes(event.target.tagName)) {
return;
}
const items = event.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.indexOf("image") !== -1) {
const file = item.getAsFile();
if (file.type === "image/svg+xml") {
const reader = new FileReader();
reader.onload = (e) => setSvgCode(e.target.result);
reader.readAsText(file);
event.preventDefault();
}
} else if (item.type === "text/plain") {
// If we are not focused on textarea, and pasted text looks like SVG
if (document.activeElement.tagName !== 'TEXTAREA') {
item.getAsString((text) => {
if (text.trim().startsWith(" {
if (visible) {
window.addEventListener('paste', handlePaste);
return () => window.removeEventListener('paste', handlePaste);
}
}, [visible]);
// Convert SVG to PNG
useEffect(() => {
if (!svgCode.trim()) {
setPngDataUrl("");
return;
}
const convert = () => {
setIsConverting(true);
const svgBlob = new Blob([svgCode], { type: "image/svg+xml" });
const url = URL.createObjectURL(svgBlob);
const img = new Image();
img.onload = () => {
const canvas = canvasRef.current;
if (canvas) {
// Handle high DPI
const scale = 2; // Higher quality
canvas.width = img.width * scale;
canvas.height = img.height * scale;
const ctx = canvas.getContext("2d");
ctx.scale(scale, scale);
ctx.drawImage(img, 0, 0);
setPngDataUrl(canvas.toDataURL("image/png"));
}
URL.revokeObjectURL(url);
setIsConverting(false);
};
img.onerror = () => {
setIsConverting(false);
URL.revokeObjectURL(url);
};
img.src = url;
};
// Debounce conversion
const timer = setTimeout(convert, 500);
return () => clearTimeout(timer);
}, [svgCode]);
const handleUpload = async () => {
if (!pngDataUrl) return;
setIsUploading(true);
try {
const res = await fetch(pngDataUrl);
const blob = await res.blob();
const formData = new FormData();
formData.append("image", blob, `svg-converted-${Date.now()}.png`);
const uploadRes = await api.post("/upload", formData, {
headers: { "Content-Type": "multipart/form-data" }
});
if (uploadRes.data.success) {
setUploadedUrl(`${window.location.origin}${uploadRes.data.data.url}`);
message.success("上传成功");
} else {
message.error("上传失败");
}
} catch (e) {
message.error("上传出错");
} finally {
setIsUploading(false);
}
};
const handleDownload = () => {
if (!pngDataUrl) return;
const link = document.createElement("a");
link.download = `svg-${Date.now()}.png`;
link.href = pngDataUrl;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const uploadProps = {
accept: ".svg,image/svg+xml",
showUploadList: false,
beforeUpload: (file) => {
const reader = new FileReader();
reader.onload = (e) => setSvgCode(e.target.result);
reader.readAsText(file);
return false;
}
};
// Glass styles
const glassBg = isDarkMode ? "rgba(30, 30, 30, 0.75)" : "rgba(255, 255, 255, 0.75)";
const glassBorder = isDarkMode ? "rgba(255, 255, 255, 0.1)" : "rgba(255, 255, 255, 0.6)";
const textColor = isDarkMode ? "rgba(255,255,255,0.85)" : "rgba(0,0,0,0.85)";
const secondaryTextColor = isDarkMode ? "rgba(255,255,255,0.45)" : "rgba(0,0,0,0.45)";
const sectionBorder = isDarkMode ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.06)";
return (
(
{modal}
)}
styles={{
content: { background: 'transparent', padding: 0, boxShadow: 'none' },
body: { padding: 0 },
container: { padding: 0 }
}}
>
{/* Header */}
SVG 转 PNG 工具
}
onClick={onClose}
style={{ color: secondaryTextColor }}
/>
{/* Content Body */}
{/* Left: Input */}
{/* Toolbar for Input */}
{svgCode ? `${svgCode.length} 字符` : "等待输入..."}
}>导入文件
{svgCode && (
{ setSvgCode(""); setUploadedUrl(""); }}>清空
)}
{/* Right: Preview */}
{/* Checkerboard background */}
{isConverting ? (
) : pngDataUrl ? (
) : (
)}
{/* Actions */}
{uploadedUrl ? (
{uploadedUrl}
} size="small" onClick={() => {
navigator.clipboard.writeText(uploadedUrl);
message.success("已复制");
}} />
) : (
}
disabled={!pngDataUrl}
onClick={handleDownload}
style={{ height: 44 }}
>
下载 PNG
}
disabled={!pngDataUrl}
loading={isUploading}
onClick={handleUpload}
style={{ height: 44 }}
>
上传图床
)}
{/* Hidden Canvas */}
);
};
export default SvgToolModal;
================================================
FILE: client/src/components/ThemeSwitcher.js
================================================
import React, { useState, useEffect } from "react";
import { Button, Dropdown } from "antd";
import { SunOutlined, MoonOutlined, SyncOutlined } from "@ant-design/icons";
const THEME_KEY = "theme";
const AUTO_KEY = "themeAutoMode";
const ThemeSwitcher = ({ theme, onThemeChange }) => {
const [autoMode, setAutoMode] = useState(false);
// 加载自动模式
useEffect(() => {
const savedAutoMode = localStorage.getItem(AUTO_KEY);
if (savedAutoMode !== null) {
setAutoMode(JSON.parse(savedAutoMode));
}
}, []);
// 自动模式定时器
useEffect(() => {
if (!autoMode) return;
const checkTimeAndUpdateTheme = () => {
const hour = new Date().getHours();
const shouldBeDark = hour < 6 || hour >= 18;
const newTheme = shouldBeDark ? "dark" : "light";
if (theme !== newTheme) {
onThemeChange(newTheme);
localStorage.setItem(THEME_KEY, newTheme);
}
};
checkTimeAndUpdateTheme();
const interval = setInterval(checkTimeAndUpdateTheme, 60000);
return () => clearInterval(interval);
}, [autoMode, theme, onThemeChange]);
// 菜单点击
const handleMenuClick = ({ key }) => {
if (key === "auto") {
setAutoMode(true);
localStorage.setItem(AUTO_KEY, "true");
// 立即切换一次
const hour = new Date().getHours();
const shouldBeDark = hour < 6 || hour >= 18;
const newTheme = shouldBeDark ? "dark" : "light";
onThemeChange(newTheme);
localStorage.setItem(THEME_KEY, newTheme);
} else {
setAutoMode(false);
localStorage.setItem(AUTO_KEY, "false");
onThemeChange(key);
localStorage.setItem(THEME_KEY, key);
}
};
// 当前高亮
const selectedKey = autoMode ? "auto" : theme;
const items = [
{
key: "light",
icon: (
),
label: (
浅色主题
),
},
{
key: "dark",
icon: (
),
label: (
暗色主题
),
},
{
key: "auto",
icon: (
),
label: (
自动切换
),
},
];
const getThemeIcon = () => {
if (autoMode) return ;
return theme === "dark" ? : ;
};
const getThemeText = () => {
if (autoMode) return "自动切换";
return theme === "dark" ? "暗色主题" : "浅色主题";
};
return (
{getThemeText()}
);
};
export default ThemeSwitcher;
================================================
FILE: client/src/components/TrafficDashboard.js
================================================
import React, { useEffect, useState } from 'react';
import ReactECharts from 'echarts-for-react';
import { Card, Row, Col, Typography, Table, Spin, message, Empty, Segmented } from 'antd';
import { AreaChartOutlined, FireOutlined, EyeOutlined, VideoCameraOutlined } from '@ant-design/icons';
import api from '../utils/api';
import dayjs from 'dayjs';
const { Title, Text } = Typography;
const TrafficDashboard = () => {
const [loading, setLoading] = useState(true);
const [trafficData, setTrafficData] = useState([]);
const [topImages, setTopImages] = useState([]);
const [days, setDays] = useState(30);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const [trafficRes, topRes] = await Promise.all([
api.get(`/stats/traffic?days=${days}`),
api.get('/stats/top?limit=10')
]);
if (trafficRes.data.success) {
setTrafficData(trafficRes.data.data);
}
if (topRes.data.success) {
setTopImages(topRes.data.data);
}
} catch (e) {
console.error(e);
message.error("获取统计数据失败");
} finally {
setLoading(false);
}
};
fetchData();
}, [days]);
// Chart Configs
const trafficOption = {
title: { text: '流量与上传趋势', left: 'center' },
tooltip: { trigger: 'axis' },
legend: { data: ['访问流量 (MB)', '上传流量 (MB)', '访问次数', '上传次数'], bottom: 0 },
grid: { left: '3%', right: '4%', bottom: '10%', containLabel: true },
xAxis: { type: 'category', data: trafficData.map(d => d.date) },
yAxis: [
{ type: 'value', name: '流量 (MB)', position: 'left' },
{ type: 'value', name: '次数', position: 'right' }
],
series: [
{
name: '访问流量 (MB)',
type: 'line',
smooth: true,
data: trafficData.map(d => (d.views_size / 1024 / 1024).toFixed(2)),
areaStyle: { opacity: 0.1 },
itemStyle: { color: '#52c41a' }
},
{
name: '上传流量 (MB)',
type: 'line',
smooth: true,
data: trafficData.map(d => (d.uploads_size / 1024 / 1024).toFixed(2)),
areaStyle: { opacity: 0.1 },
itemStyle: { color: '#1890ff' }
},
{
name: '访问次数',
type: 'bar',
yAxisIndex: 1,
data: trafficData.map(d => d.views_count),
itemStyle: { color: '#95de64', opacity: 0.5 }
},
{
name: '上传次数',
type: 'bar',
yAxisIndex: 1,
data: trafficData.map(d => d.uploads_count),
itemStyle: { color: '#69c0ff', opacity: 0.5 }
}
]
};
const topImagesColumns = [
{
title: '图片',
dataIndex: 'url',
key: 'url',
render: (url, record) => {
const isVideo = /\.(mp4|webm|mov)$/i.test(record.filename);
if (isVideo) {
return (
);
}
return ;
}
},
{
title: '文件名',
dataIndex: 'filename',
key: 'filename',
ellipsis: true,
},
{
title: '浏览量',
dataIndex: 'views',
key: 'views',
sorter: (a, b) => a.views - b.views,
defaultSortOrder: 'descend',
render: (v) => {v}
},
{
title: '上传时间',
dataIndex: 'uploadTime',
key: 'uploadTime',
render: (t) => dayjs(t).format('YYYY-MM-DD HH:mm')
}
];
if (loading && trafficData.length === 0) {
return
;
}
return (
{trafficData.length > 0 ? (
) : (
)}
热门图片 (Top 10)>}
style={{ borderRadius: 12, boxShadow: '0 4px 12px rgba(0,0,0,0.05)' }}
>
);
};
export default TrafficDashboard;
================================================
FILE: client/src/components/UploadComponent.js
================================================
import React, { useState, useEffect } from "react";
import {
Upload,
Button,
message,
Card,
Typography,
Space,
Tag,
Progress,
Row,
Col,
theme,
Grid,
Tabs,
Input,
} from "antd";
import { InboxOutlined, CheckCircleOutlined, CloseOutlined, CopyOutlined, LinkOutlined } from "@ant-design/icons";
import DirectorySelector from "./DirectorySelector";
const { Dragger } = Upload;
const { Title, Text } = Typography;
function sanitizeDir(input) {
let dir = (input || "").trim().replace(/\\+/g, "/").replace(/\/+/g, "/");
dir = dir.replace(/\/+$/, ""); // 去除末尾斜杠
dir = dir.replace(/^\/+/, ""); // 去除开头斜杠
dir = dir.replace(/\/+/, "/"); // 合并多余斜杠
return dir;
}
// 并发请求限制器
class ConcurrencyLimiter {
constructor(limit) {
this.limit = limit;
this.active = 0;
this.queue = [];
}
add(task) {
return new Promise((resolve, reject) => {
this.queue.push(() => task().then(resolve).catch(reject));
this.next();
});
}
next() {
if (this.active < this.limit && this.queue.length > 0) {
const task = this.queue.shift();
this.active++;
task().finally(() => {
this.active--;
this.next();
});
}
}
}
const uploadLimiter = new ConcurrencyLimiter(5); // 限制并发数为5
const UploadComponent = ({ onUploadSuccess, api, isModal }) => {
const {
token: { colorBgContainer },
} = theme.useToken();
const { useBreakpoint } = Grid;
const screens = useBreakpoint();
const isMobile = !screens.md;
const isDarkMode = theme.useToken().theme?.id === 1 || colorBgContainer === "#141414" || colorBgContainer === "#000000" || colorBgContainer === "#1f1f1f";
const [uploadQueue, setUploadQueue] = useState([]);
const [uploadedFiles, setUploadedFiles] = useState([]);
// Separate list for currently completed session uploads to show in overlay
const [sessionUploadedFiles, setSessionUploadedFiles] = useState([]);
const [dir, setDir] = useState("");
const [urlInput, setUrlInput] = useState("");
const [config, setConfig] = useState({
allowedExtensions: [
".jpg",
".jpeg",
".png",
".gif",
".webp",
".bmp",
".svg",
],
maxFileSize: 10 * 1024 * 1024,
maxFileSizeMB: 10,
allowedFormats: "JPG, JPEG, PNG, GIF, WEBP, BMP, SVG",
});
const uploading = uploadQueue.some(item => item.status === 'pending' || item.status === 'uploading');
// 获取配置
useEffect(() => {
const fetchConfig = async () => {
try {
const response = await api.get("/config");
if (response.data.success) {
setConfig(response.data.data.upload);
}
} catch (error) {
console.warn("获取配置失败,使用默认配置:", error);
}
};
fetchConfig();
}, [api]);
const updateQueueItem = (uid, updates) => {
setUploadQueue((prev) =>
prev.map((item) => (item.uid === uid ? { ...item, ...updates } : item))
);
};
// 上传文件的通用方法
const uploadFile = React.useCallback(async (file, onProgress) => {
let safeDir = sanitizeDir(dir);
if (safeDir.includes("..")) {
throw new Error("目录不能包含 .. 等非法字符");
}
const formData = new FormData();
// 确保文件名编码正确,特别是中文文件名
const fileName = file.name;
formData.append("image", file, fileName);
const url = safeDir
? `/upload?dir=${encodeURIComponent(safeDir)}`
: "/upload";
try {
const response = await api.post(url, formData, {
timeout: 0, // 取消单次上传超时限制,防止大文件长连接断开
headers: {
"Content-Type": "multipart/form-data",
},
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
if (onProgress) onProgress(percentCompleted);
},
});
if (response.data.success) {
const fileData = response.data.data;
setUploadedFiles((prev) => [...prev, fileData]);
// Add to session uploaded files for the result view
setSessionUploadedFiles(prev => [...prev, fileData]);
message.success(`${fileName} 上传成功!`);
if (onUploadSuccess) {
onUploadSuccess();
}
} else {
throw new Error(response.data.error || "上传失败");
}
} catch (error) {
const msg = error?.response?.data?.error || error.message || "上传失败";
message.error(msg);
throw new Error(msg);
}
}, [dir, api, onUploadSuccess]);
// 处理粘贴的图片/视频
const handlePastedImage = React.useCallback(async (file) => {
// 验证文件类型
const isAllowedType = file.type.startsWith("image/") || file.type.startsWith("video/");
if (!isAllowedType) {
const allowedExts = config.allowedExtensions.join(", ");
message.error(`不支持的文件类型!仅支持: ${allowedExts}`);
return;
}
// 验证文件大小
const isLtMax = file.size <= config.maxFileSize;
if (!isLtMax) {
message.error(`文件大小不能超过${config.maxFileSizeMB}MB!`);
return;
}
// 生成文件名
const timestamp = new Date().getTime();
const extension = file.type.split("/")[1] || "unknown";
const fileName = `pasted-file-${timestamp}.${extension}`;
// 创建新的File对象,设置文件名
const renamedFile = new File([file], fileName, { type: file.type });
// 添加uid
renamedFile.uid = `pasted-${timestamp}`;
// 添加到队列
setUploadQueue(prev => [...prev, { uid: renamedFile.uid, name: fileName, progress: 0, status: 'pending' }]);
// 上传文件
try {
await uploadLimiter.add(async () => {
updateQueueItem(renamedFile.uid, { status: 'uploading' });
await uploadFile(renamedFile, (progress) => {
updateQueueItem(renamedFile.uid, { progress, status: 'uploading' });
});
});
updateQueueItem(renamedFile.uid, { progress: 100, status: 'success' });
} catch (error) {
updateQueueItem(renamedFile.uid, { status: 'error', errorMsg: error.message });
}
}, [config, uploadFile]);
// 处理粘贴事件
const handlePaste = React.useCallback(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/") || item.type.startsWith("video/")) {
// Clear previous queue for new paste action
setUploadQueue([]);
setSessionUploadedFiles([]);
event.preventDefault();
const file = item.getAsFile();
if (file) {
await handlePastedImage(file);
}
break;
}
}
}, [handlePastedImage]);
// Handle URL upload
const handleUrlUpload = React.useCallback(async () => {
const url = urlInput.trim();
if (!url) {
message.warning("请输入图片 URL");
return;
}
const imageExtPattern = /\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?.*)?$/i;
if (!imageExtPattern.test(url)) {
message.warning("URL 必须指向图片文件");
return;
}
if (!url.match(/^https?:\/\//)) {
message.warning("请输入有效的 HTTP/HTTPS URL");
return;
}
// Clear previous queue
setUploadQueue([]);
setSessionUploadedFiles([]);
// Add to queue for UI feedback
const uid = `url-upload-${Date.now()}`;
const filename = url.split('/').pop() || 'url-image';
setUploadQueue([{ uid, name: filename, progress: 0, status: 'uploading' }]);
try {
const response = await api.post('/upload-url', { url, dir });
if (response.data?.success) {
setUploadQueue([{ uid, name: filename, progress: 100, status: 'success' }]);
setSessionUploadedFiles([response.data.data]);
message.success("上传成功");
if (onUploadSuccess) {
onUploadSuccess();
}
} else {
throw new Error(response.data?.error || '上传失败');
}
} catch (error) {
console.error('URL upload error:', error);
setUploadQueue([{ uid, name: filename, status: 'error', errorMsg: error.message || '上传出错' }]);
message.error(error?.response?.data?.error || error.message || 'URL 上传失败');
}
setUrlInput("");
}, [urlInput, dir, api, onUploadSuccess]);
// 添加全局粘贴事件监听
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);
};
}, [handlePaste]);
const uploadProps = {
name: "image",
multiple: true,
accept: config.allowedExtensions
.map((ext) => {
const extName = ext.replace(".", "");
if (['mp4', 'webm', 'ogg'].includes(extName)) {
return `video/${extName}`;
}
return `image/${extName}`;
})
.join(","),
beforeUpload: (file) => {
const isAllowed = file.type.startsWith("image/") || file.type.startsWith("video/");
if (!isAllowed) {
message.error("不支持的文件类型!");
return false;
}
const isLtMax = file.size <= config.maxFileSize;
if (!isLtMax) {
message.error(`文件大小不能超过${config.maxFileSizeMB}MB!`);
return false;
}
return true;
},
customRequest: async ({ file, onSuccess, onError }) => {
const uid = file.uid;
setUploadQueue(prev => [...prev, { uid, name: file.name, progress: 0, status: 'pending' }]);
try {
await uploadLimiter.add(async () => {
updateQueueItem(uid, { status: 'uploading' });
await uploadFile(file, (progress) => {
updateQueueItem(uid, { progress, status: 'uploading' });
});
});
updateQueueItem(uid, { progress: 100, status: 'success' });
onSuccess();
} catch (error) {
updateQueueItem(uid, { status: 'error', errorMsg: error.message });
onError(error);
}
},
};
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 {
const ok = document.execCommand("copy");
document.body.removeChild(input);
if (!ok) {
message.error("复制失败");
} else {
message.success("链接已复制到剪贴板");
}
} catch (e) {
document.body.removeChild(input);
message.error("当前浏览器不支持复制功能");
}
};
const generateLinks = (type) => {
return sessionUploadedFiles.map(file => {
const fullUrl = `${window.location.origin}${file.url}`;
switch (type) {
case 'markdown':
return ``;
case 'html':
return ` `;
case 'url':
default:
return fullUrl;
}
}).join('\n');
};
const UploadResult = () => {
const [activeTab, setActiveTab] = useState('url');
const content = generateLinks(activeTab);
const items = [
{ key: 'url', label: 'URL' },
{ key: 'markdown', label: 'Markdown' },
{ key: 'html', label: 'HTML' },
];
return (
}
onClick={() => copyToClipboard(content)}
>
一键复制
);
};
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];
};
return (
上传图片
{/* URL Upload Input */}
}
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
onPressEnter={handleUrlUpload}
disabled={uploading}
style={{
background: isModal ? (isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)') : undefined
}}
/>
上传
点击或拖拽图片到此区域
支持 {config.allowedFormats} 格式,最大 {config.maxFileSizeMB}MB
{/* Full Page Upload Overlay */}
{uploadQueue.length > 0 && (
正在上传 ({uploadQueue.filter(i => i.status === 'success').length}/{uploadQueue.length})
}
onClick={() => {
setUploadQueue([]);
setSessionUploadedFiles([]);
}}
/>
{uploadQueue.map(item => (
{item.name}
{item.status === 'error' ? '失败' : item.status === 'success' ? '完成' : item.status === 'pending' ? '排队中...' : `${item.progress}%`}
{item.errorMsg &&
{item.errorMsg} }
))}
{!uploading && (
<>
{sessionUploadedFiles.length > 0 &&
}
{
setUploadQueue([]);
setSessionUploadedFiles([]);
}}>
关闭
>
)}
)}
{uploadedFiles.length > 0 && (
{uploadedFiles
.slice(-6)
.reverse()
.map((file, index) => (
}
actions={[
}
size={isMobile ? "small" : "middle"}
onClick={() =>
copyToClipboard(
`${window.location.origin}${file.url}`
)
}
>
{isMobile ? "复制" : "复制链接"}
,
]}
>
{file.originalName}
}
description={
{formatFileSize(file.size)}
{file.mimetype}
}
/>
))}
)}
);
};
export default UploadComponent;
================================================
FILE: client/src/index.js
================================================
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { ConfigProvider } from "antd";
import zhCN from "antd/locale/zh_CN";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
);
================================================
FILE: client/src/utils/api.js
================================================
import axios from "axios";
import { getPassword, clearPassword } from "./secureStorage";
const isMock = process.env.REACT_APP_MOCK === "true";
// Mock Data Generator
const generateMockImages = () => {
const images = [];
const dates = [0, 1, 2]; // Days offset from today (3 days)
let idCounter = 1;
dates.forEach((dayOffset) => {
// Generate 18-22 images per day (around 20)
const count = Math.floor(Math.random() * 5) + 18;
const baseTime = Date.now() - dayOffset * 24 * 60 * 60 * 1000;
for (let i = 0; i < count; i++) {
const width = Math.floor(Math.random() * (1600 - 1000) + 1000);
const height = Math.floor(Math.random() * (1200 - 800) + 800);
images.push({
relPath: `mock-image-${idCounter}.jpg`,
filename: `Mock Image ${idCounter}.jpg`,
url: `https://picsum.photos/${width}/${height}?random=${idCounter}`,
size: Math.floor(Math.random() * 5000000),
uploadTime: baseTime - Math.floor(Math.random() * 1000000), // Slightly vary time within day
thumbhash: null, // Optional
});
idCounter++;
}
});
return images.sort((a, b) => b.uploadTime - a.uploadTime);
};
const mockImages = generateMockImages();
const mockAdapter = async (config) => {
return new Promise((resolve, reject) => {
const { url, method, params, data } = config;
const cleanUrl = url.replace(/^\/api/, "");
console.log(`[Mock API] ${method.toUpperCase()} ${url}`, params || data);
setTimeout(() => {
// Auth Status
if (cleanUrl === "/auth/status" && method === "get") {
resolve({
data: { success: true, data: { enabled: true } },
status: 200,
statusText: "OK",
headers: {},
config,
});
return;
}
// Auth Verify
if (cleanUrl === "/auth/login" && method === "post") {
const body = JSON.parse(data);
if (body.password === "123456") {
resolve({
data: { success: true },
status: 200,
statusText: "OK",
headers: {},
config,
});
} else {
reject({
response: {
status: 401,
data: { success: false, error: "密码错误" },
},
});
}
return;
}
// Image List
if (cleanUrl === "/images" && method === "get") {
const page = params?.page || 1;
const pageSize = params?.pageSize || 10;
const start = (page - 1) * pageSize;
const end = start + pageSize;
const pageData = mockImages.slice(start, end);
resolve({
data: {
success: true,
data: pageData,
pagination: {
current: parseInt(page),
pageSize: parseInt(pageSize),
total: mockImages.length,
totalPages: Math.ceil(mockImages.length / pageSize),
},
},
status: 200,
statusText: "OK",
headers: {},
config,
});
return;
}
// Image Meta
if (cleanUrl.startsWith("/images/meta/") && method === "get") {
resolve({
data: {
success: true,
data: {
width: 800,
height: 600,
space: "sRGB",
exif: {
make: "Mock Camera",
model: "M-1",
fNumber: 1.8,
exposureTime: "1/1000",
iso: 100,
},
},
},
status: 200,
statusText: "OK",
headers: {},
config,
});
return;
}
// Directories
// Directories
if (cleanUrl.split("?")[0] === "/directories" && method === "get") {
const previews = mockImages.slice(0, 3).map(img => img.url);
resolve({
data: {
success: true,
data: [
{ name: "mock-dir-1", path: "mock-dir-1", fullUrl: "mock-dir-1", previews, imageCount: 10, mtime: new Date() },
{ name: "mock-dir-2", path: "mock-dir-2", fullUrl: "mock-dir-2", previews, imageCount: 5, mtime: new Date() },
],
},
status: 200,
statusText: "OK",
headers: {},
config,
});
return;
}
// Create Directory
if (cleanUrl.split("?")[0] === "/directories" && method === "post") {
resolve({
data: { success: true, message: "Directory created (mock)" },
status: 200,
statusText: "OK",
headers: {},
config,
});
return;
}
// Share Generate
if (cleanUrl === "/share/generate" && method === "post") {
resolve({
data: { success: true, token: "mock-token-" + Date.now() },
status: 200,
statusText: "OK",
headers: {},
config,
});
return;
}
// Share Access
if (cleanUrl.startsWith("/share/access") && method === "get") {
resolve({
data: {
success: true,
data: mockImages.slice(0, 10),
dirName: "Mock Share Album"
},
status: 200,
statusText: "OK",
headers: {},
config,
});
return;
}
// Delete Image
if (cleanUrl.startsWith("/images/") && method === "delete") {
resolve({
data: { success: true },
status: 200,
statusText: "OK",
headers: {},
config,
});
return;
}
// Update Image (Rename/Move)
if (cleanUrl.startsWith("/images/") && method === "put") {
const body = JSON.parse(data);
const originalRelPath = decodeURIComponent(cleanUrl.split("/images/")[1]);
const newName = body.newName;
// const newDir = body.newDir;
let updatedRelPath = originalRelPath;
let updatedFilename = originalRelPath.split("/").pop();
if (newName) {
updatedFilename = newName;
const dir = originalRelPath.includes("/") ? originalRelPath.substring(0, originalRelPath.lastIndexOf("/")) : "";
updatedRelPath = dir ? `${dir}/${newName}` : newName;
}
resolve({
data: {
success: true,
data: {
relPath: updatedRelPath,
filename: updatedFilename,
url: `https://picsum.photos/800/600?random=${Math.random()}`, // Just return a valid obj
size: 1024,
uploadTime: Date.now(),
thumbhash: null
}
},
status: 200,
statusText: "OK",
headers: {},
config,
});
return;
}
// Upload
if (cleanUrl === "/upload" && method === "post") {
resolve({
data: { success: true, data: [] }, // Return empty or fake
status: 200,
statusText: "OK",
headers: {},
config,
});
return;
}
// System Health
if (cleanUrl === "/health" && method === "get") {
resolve({
data: { status: "ok" },
status: 200,
statusText: "OK",
headers: {},
config,
});
return;
}
// System Config
if (cleanUrl === "/config" && method === "get") {
resolve({
data: {
success: true,
data: {
upload: { maxFileSize: 104857600, allowedExtensions: [".jpg", ".png", ".gif", ".mp4"] },
storage: { filename: { keepOriginalName: true } },
magicSearch: { enabled: true }
}
},
status: 200,
statusText: "OK",
headers: {},
config,
});
return;
}
// Stats Traffic
if (cleanUrl.startsWith("/stats/traffic") && method === "get") {
const days = params?.days || 30;
const data = [];
for (let i = 0; i < days; i++) {
data.push({
date: new Date(Date.now() - i * 86400000).toISOString().split('T')[0],
views: Math.floor(Math.random() * 1000),
traffic: Math.floor(Math.random() * 50000000)
});
}
resolve({
data: { success: true, data: data.reverse() },
status: 200,
statusText: "OK",
headers: {},
config,
});
return;
}
// Stats Top
if (cleanUrl.startsWith("/stats/top") && method === "get") {
resolve({
data: {
success: true,
data: mockImages.slice(0, 10).map(img => ({
...img,
views: Math.floor(Math.random() * 5000)
}))
},
status: 200,
statusText: "OK",
headers: {},
config,
});
return;
}
// Semantic Search
if (cleanUrl === "/search/semantic" && method === "post") {
resolve({
data: {
success: true,
data: mockImages.slice(0, 8).map(img => ({
...img,
score: Math.random()
}))
},
status: 200,
statusText: "OK",
headers: {},
config,
});
return;
}
// Batch Move
if (cleanUrl === "/batch/move" && method === "post") {
resolve({
data: { success: true, successCount: 1, failCount: 0 },
status: 200,
statusText: "OK",
headers: {},
config,
});
return;
}
// Map Data
if (cleanUrl === "/map-data" && method === "get") {
const mapImages = mockImages
.slice(0, 20)
.map((img, index) => ({
...img,
// Random coordinates around central China for demo
lat: 30 + Math.random() * 10 - 5,
lng: 110 + Math.random() * 10 - 5,
thumbUrl: img.url,
}));
resolve({
data: {
success: true,
data: mapImages,
},
status: 200,
statusText: "OK",
headers: {},
config,
});
return;
}
// Default Success for others
resolve({
data: { success: true },
status: 200,
statusText: "OK",
headers: {},
config,
});
}, 300); // Simulate latency
});
};
// 创建axios实例
const api = axios.create({
baseURL: "/api",
timeout: 30000,
adapter: isMock ? mockAdapter : undefined,
});
// 请求拦截器 - 添加密码到请求头
api.interceptors.request.use(
(config) => {
const password = getPassword();
if (password) {
config.headers["X-Access-Password"] = password;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器 - 处理密码错误
api.interceptors.response.use(
(response) => {
return response;
},
(error) => {
if (error.response && error.response.status === 401) {
clearPassword();
// Don't reload, let the app handle the auth state change
// window.location.reload();
}
return Promise.reject(error);
}
);
export default api;
================================================
FILE: client/src/utils/secureStorage.js
================================================
const STORAGE_KEY = "cloudimgs_password";
const SALT = "cloudimgs-salt-2025";
const EXPIRATION_TIME = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
function xorCipher(input) {
const salt = SALT;
let out = "";
for (let i = 0; i < input.length; i++) {
const code = input.charCodeAt(i) ^ salt.charCodeAt(i % salt.length);
out += String.fromCharCode(code);
}
return out;
}
export function setPassword(plain) {
try {
const x = xorCipher(plain);
const b64 = btoa(x);
const data = {
value: `v1:${b64}`,
timestamp: Date.now()
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch (e) {
// Fallback for simple string if something fails (though logic above is robust)
localStorage.setItem(STORAGE_KEY, JSON.stringify({
value: plain,
timestamp: Date.now()
}));
}
}
export function getPassword() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
// Check if it's new JSON format or old legacy string
let data;
try {
data = JSON.parse(raw);
} catch {
// Legacy format (direct string) - treat as expired to force re-login and upgrade format,
// or just accept it once. Let's just return it for backward compatibility but it won't have expiry.
// Actually, better to migrate it or just treat as valid for now.
// But user asked for expiry. So let's return it, and next setPassword will fix format.
// To be strict: clear it if not JSON? No, let's parse.
// If parsing fails, it's the old format string.
// Let's migrate legacy storage to new format with current time if we want to keep them logged in,
// OR just return it.
// Logic: if string, return it.
const stored = raw;
if (stored.startsWith("v1:")) {
const b64 = stored.slice(3);
const x = atob(b64);
return xorCipher(x);
}
return stored;
}
if (!data || !data.value) return null;
// Check Expiry
if (Date.now() - data.timestamp > EXPIRATION_TIME) {
clearPassword();
return null;
}
const stored = data.value;
if (stored.startsWith("v1:")) {
const b64 = stored.slice(3);
const x = atob(b64);
return xorCipher(x);
}
return stored;
} catch (e) {
return null;
}
}
export function clearPassword() {
localStorage.removeItem(STORAGE_KEY);
}
================================================
FILE: config.js
================================================
// 云图 配置文件
module.exports = {
// 上传配置
upload: {
// 允许的文件格式(扩展名)
allowedExtensions: process.env.ALLOWED_EXTENSIONS
? process.env.ALLOWED_EXTENSIONS.split(",")
: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg", ".avif", ".mp4", ".webm"],
// 允许的MIME类型
allowedMimeTypes: [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
"image/avif",
"image/bmp",
"image/svg+xml",
"audio/mpeg",
"video/mp4",
"video/webm",
],
// 文件大小限制(字节)
maxFileSize: process.env.MAX_FILE_SIZE
? parseInt(process.env.MAX_FILE_SIZE)
: 100 * 1024 * 1024, // 100MB
// 是否允许重复文件名
allowDuplicateNames: process.env.ALLOW_DUPLICATE_NAMES === "true",
// 文件名冲突时的处理策略: 'timestamp' | 'counter' | 'overwrite'
duplicateStrategy: process.env.DUPLICATE_STRATEGY || "timestamp",
// 瀑布流缩略图宽度(像素),0 表示使用原图
thumbnailWidth: process.env.THUMBNAIL_WIDTH
? parseInt(process.env.THUMBNAIL_WIDTH)
: 0,
},
// 存储配置
storage: {
// 存储路径
path: process.env.STORAGE_PATH || "./uploads",
// 是否自动创建目录
autoCreateDirs: process.env.AUTO_CREATE_DIRS !== "false",
// 文件名处理
filename: {
// 是否保留原始文件名
keepOriginalName: process.env.KEEP_ORIGINAL_NAME !== "false",
// 是否处理特殊字符
sanitizeSpecialChars: process.env.SANITIZE_SPECIAL_CHARS !== "false",
// 特殊字符替换符
specialCharReplacement: process.env.SPECIAL_CHAR_REPLACEMENT || "_",
},
},
// 服务器配置
server: {
port: process.env.PORT || 3001,
host: process.env.HOST || "0.0.0.0",
},
// 安全配置
security: {
// 是否启用路径安全检查
enablePathValidation: process.env.ENABLE_PATH_VALIDATION !== "false",
// 禁止的路径字符
forbiddenPathChars: process.env.FORBIDDEN_PATH_CHARS
? process.env.FORBIDDEN_PATH_CHARS.split(",")
: ["..", "\\"],
// 最大目录深度
maxDirectoryDepth: process.env.MAX_DIRECTORY_DEPTH
? parseInt(process.env.MAX_DIRECTORY_DEPTH)
: 10,
// 密码保护配置
password: {
// 访问密码
accessPassword: process.env.PASSWORD || null,
// 是否启用密码保护
enabled: !!process.env.PASSWORD,
},
},
// 魔法搜图配置
magicSearch: {
enabled: process.env.ENABLE_MAGIC_SEARCH === "true",
modelName: "Xenova/clip-vit-base-patch32", // @huggingface/transformers 默认量化
concurrency: 1, // N100 优化:严格串行处理
},
};
================================================
FILE: docker-compose.yml
================================================
version: "3.8"
services:
cloudimgs:
# 使用 GitHub Packages 镜像
image: qazzxxx/cloudimgs:latest
ports:
- "3001:3001"
volumes:
- ./uploads:/app/uploads:rw # 上传目录配置,明确读写权限
restart: unless-stopped
container_name: cloudimgs-app
environment:
- PUID=1000 # 替换为您 NAS 用户的实际 ID (id -u)
- PGID=1000 # 替换为您 NAS 用户组的实际 ID (id -g)
- UMASK=002
- NODE_ENV=production
- PORT=3001
- STORAGE_PATH=/app/uploads
# 密码保护配置(可选)
# - PASSWORD=your_password_here
================================================
FILE: docker-entrypoint.sh
================================================
#!/bin/sh
set -e
# 设置 umask
UMASK=${UMASK:-0022}
umask "$UMASK"
# 获取 PUID 和 PGID,默认为 1000
PUID=${PUID:-1000}
PGID=${PGID:-1000}
# 处理用户组
GROUP_NAME=$(getent group "$PGID" | cut -d: -f1)
if [ -z "$GROUP_NAME" ]; then
# GID 未被占用,创建新组 cloudimgs
groupadd -g "$PGID" cloudimgs
GROUP_NAME=cloudimgs
echo "[INFO] Created new group 'cloudimgs' with GID $PGID"
fi
# 处理用户
USER_NAME=$(getent passwd "$PUID" | cut -d: -f1)
if [ -z "$USER_NAME" ]; then
# UID 未被占用,创建新用户 cloudimgs
# -M 不创建主目录 (Debian)
useradd -u "$PUID" -g "$GROUP_NAME" -M -d /app cloudimgs
USER_NAME=cloudimgs
echo "[INFO] Created new user 'cloudimgs' with UID $PUID"
fi
# 确保目录存在
mkdir -p "$STORAGE_PATH" logs
# 修正权限(如果使用 root 启动容器,则修正所有权)
if [ "$(id -u)" = "0" ]; then
chown -R "$USER_NAME:$GROUP_NAME" "$STORAGE_PATH" logs /app
# 使用 gosu 降权运行应用
# 设置 HOME=/app
exec gosu "$USER_NAME:$GROUP_NAME" env HOME=/app "$@"
else
# 如果已经是普通用户,直接运行
exec "$@"
fi
================================================
FILE: env.example
================================================
# 服务器配置
PORT=3001
HOST=0.0.0.0
# 存储配置
STORAGE_PATH=./uploads
# 上传配置(可选,默认值在 config.js 中设置)
# MAX_FILE_SIZE=104857600 # 100MB in bytes
# ALLOWED_EXTENSIONS=.jpg,.jpeg,.png,.gif,.webp,.bmp,.svg
# THUMBNAIL_WIDTH=0 # 瀑布流缩略图宽度(像素),0 表示使用原图,推荐值 400-800
# 密码保护配置(可选)
# 设置此环境变量将启用密码保护,用户需要输入密码才能访问系统
PASSWORD=qaz123
# 环境
NODE_ENV=production
================================================
FILE: package.json
================================================
{
"name": "cloudimgs",
"version": "1.2.3",
"description": "A modern image hosting application with React frontend and Node.js backend",
"main": "server/index.js",
"scripts": {
"start": "node server/index.js",
"dev": "nodemon server/index.js",
"build": "cd client && npm run build",
"install-client": "cd client && npm install",
"build-client": "cd client && npm run build",
"heroku-postbuild": "npm run install-client && npm run build-client"
},
"keywords": [
"image-hosting",
"react",
"nodejs",
"express"
],
"author": "Your Name",
"license": "MIT",
"dependencies": {
"@huggingface/transformers": "^3.8.1",
"axios": "^1.10.0",
"better-sqlite3": "^12.6.2",
"cors": "^2.8.5",
"dotenv": "^16.6.1",
"exifr": "^7.1.3",
"express": "^4.21.2",
"fs-extra": "^11.3.0",
"mime": "^4.0.7",
"mime-types": "^3.0.1",
"multer": "^1.4.5-lts.2",
"music-metadata": "^7.14.0",
"path": "^0.12.7",
"sharp": "^0.34.2",
"sqlite-vec": "^0.1.7-alpha.2",
"thumbhash": "^0.1.1",
"uuid": "^9.0.1"
},
"devDependencies": {
"nodemon": "^3.1.10"
}
}
================================================
FILE: server/db/database.js
================================================
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs-extra');
const config = require('../../config');
// 确保数据库目录存在
const dbPath = path.resolve(config.storage.path, '.cache', 'cloudimgs.db');
fs.ensureDirSync(path.dirname(dbPath));
const db = new Database(dbPath, { verbose: process.env.NODE_ENV === 'development' ? console.log : null });
// Load sqlite-vec extension if Magic Search is enabled
if (config.magicSearch.enabled) {
try {
const sqliteVec = require('sqlite-vec');
let extensionPath = sqliteVec.getLoadablePath();
console.log(`[MagicSearch] sqlite-vec path: ${extensionPath}`);
// Fix for Docker/Linux where underlying sqlite might append .so automatically (causing .so.so)
if (process.platform === 'linux' && extensionPath.endsWith('.so')) {
extensionPath = extensionPath.slice(0, -3);
}
db.loadExtension(extensionPath);
console.log("sqlite-vec extension loaded successfully");
} catch (err) {
console.error("Failed to load sqlite-vec extension:", err);
}
}
// 初始化 Schema
function init() {
db.exec(`
CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
rel_path TEXT NOT NULL UNIQUE,
size INTEGER,
mtime INTEGER,
upload_time TEXT,
width INTEGER,
height INTEGER,
orientation INTEGER,
thumbhash TEXT,
meta_json TEXT,
views INTEGER DEFAULT 0,
last_viewed INTEGER
);
CREATE INDEX IF NOT EXISTS idx_rel_path ON images(rel_path);
CREATE INDEX IF NOT EXISTS idx_mtime ON images(mtime);
CREATE INDEX IF NOT EXISTS idx_upload_time ON images(upload_time DESC);
CREATE TABLE IF NOT EXISTS shares (
token TEXT PRIMARY KEY,
path TEXT NOT NULL,
created_at INTEGER NOT NULL,
expire_seconds INTEGER,
burn_after_reading INTEGER DEFAULT 0,
is_revoked INTEGER DEFAULT 0,
views INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS daily_stats (
date TEXT PRIMARY KEY,
uploads_count INTEGER DEFAULT 0,
uploads_size INTEGER DEFAULT 0,
views_count INTEGER DEFAULT 0,
views_size INTEGER DEFAULT 0
);
`);
if (config.magicSearch.enabled) {
db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS vec_images USING vec0(
image_id INTEGER PRIMARY KEY,
embedding float[512]
);
`);
}
// Migration for existing tables
try {
const columns = db.prepare("PRAGMA table_info(images)").all();
if (!columns.find(c => c.name === 'views')) {
console.log("Migrating: Adding views column to images");
db.prepare("ALTER TABLE images ADD COLUMN views INTEGER DEFAULT 0").run();
db.prepare("ALTER TABLE images ADD COLUMN last_viewed INTEGER").run();
}
// Create index safely after ensuring columns exist
db.prepare("CREATE INDEX IF NOT EXISTS idx_views ON images(views DESC)").run();
} catch (e) {
console.error("Migration failed:", e);
}
}
init();
module.exports = db;
================================================
FILE: server/db/imageRepository.js
================================================
const db = require('./database');
const insertImage = db.prepare(`
INSERT INTO images (filename, rel_path, size, mtime, upload_time, width, height, orientation, thumbhash, meta_json)
VALUES (@filename, @rel_path, @size, @mtime, @upload_time, @width, @height, @orientation, @thumbhash, @meta_json)
`);
const updateImage = db.prepare(`
UPDATE images
SET filename = @filename, size = @size, mtime = @mtime, upload_time = @upload_time,
width = @width, height = @height, orientation = @orientation, thumbhash = @thumbhash, meta_json = @meta_json
WHERE rel_path = @rel_path
`);
const getImageByPath = db.prepare('SELECT * FROM images WHERE rel_path = ?');
const getAllImagesQuery = db.prepare('SELECT * FROM images ORDER BY upload_time DESC');
const deleteImageByPath = db.prepare('DELETE FROM images WHERE rel_path = ?');
const countImages = db.prepare('SELECT COUNT(*) as count FROM images');
const getImagesByDir = db.prepare("SELECT * FROM images WHERE rel_path LIKE ? || '/%' ORDER BY upload_time DESC");
const getPreviewsQuery = db.prepare("SELECT * FROM images WHERE rel_path LIKE ? || '/%' ORDER BY upload_time DESC LIMIT ?");
const countImagesByDirQuery = db.prepare("SELECT COUNT(*) as count FROM images WHERE rel_path LIKE ? || '/%'");
const getAllImagesByViewsQuery = db.prepare('SELECT * FROM images ORDER BY views DESC');
// 批量操作
const insertMany = db.transaction((images) => {
for (const img of images) insertImage.run(img);
});
// 重命名(原子替换路径)
const renameImage = db.transaction((oldRelPath, newRelPath, newFilename) => {
const existing = getImageByPath.get(oldRelPath);
if (!existing) return null;
deleteImageByPath.run(oldRelPath);
existing.rel_path = newRelPath;
existing.filename = newFilename;
insertImage.run(existing);
return existing;
});
// 统计数据 SQL
const incrementViewQuery = db.prepare('UPDATE images SET views = views + 1, last_viewed = @now WHERE rel_path = @relPath');
const recordDailyUploadQuery = db.prepare(`
INSERT INTO daily_stats (date, uploads_count, uploads_size)
VALUES (@date, 1, @size)
ON CONFLICT(date) DO UPDATE SET
uploads_count = uploads_count + 1,
uploads_size = uploads_size + @size
`);
const recordDailyViewQuery = db.prepare(`
INSERT INTO daily_stats (date, views_count, views_size)
VALUES (@date, 1, @size)
ON CONFLICT(date) DO UPDATE SET
views_count = views_count + 1,
views_size = views_size + @size
`);
const getDailyStatsQuery = db.prepare('SELECT * FROM daily_stats ORDER BY date DESC LIMIT ?');
const getTopImagesQuery = db.prepare('SELECT * FROM images ORDER BY views DESC LIMIT ?');
module.exports = {
add: (image) => {
try {
return insertImage.run(image);
} catch (e) {
if (e.code === 'SQLITE_CONSTRAINT_UNIQUE') {
// 如果已存在,尝试更新
// 目前仅记录日志或重新抛出,或者可以使用 INSERT OR REPLACE
console.warn(`Image ${image.relPath} already exists in DB. Attempting update.`);
return updateImage.run(image);
}
throw e;
}
},
update: (image) => updateImage.run(image),
rename: (oldRelPath, newRelPath, newFilename) => renameImage(oldRelPath, newRelPath, newFilename),
getByPath: (relPath) => getImageByPath.get(relPath),
getAll: () => getAllImagesQuery.all(),
getAllByViews: () => getAllImagesByViewsQuery.all(),
delete: (relPath) => deleteImageByPath.run(relPath),
count: () => countImages.get().count,
getByDir: (dir) => {
// 处理根目录特殊情况,通常 dir 为空字符串表示根
// 如果 dir 为空,返回所有?还是仅根目录项?
// getAllImagesQuery 返回所有
// 如果提供了 dir,使用 LIKE 匹配
if (!dir) return getAllImagesQuery.all();
return getImagesByDir.all(dir);
},
getPreviews: (dir, limit = 3) => getPreviewsQuery.all(dir, limit),
countByDir: (dir) => countImagesByDirQuery.get(dir).count,
insertMany,
// 事务辅助函数
transaction: (fn) => db.transaction(fn),
// Stats Methods
incrementViews: (relPath) => incrementViewQuery.run({ relPath, now: Date.now() }),
recordUpload: (size) => {
const date = new Date().toISOString().split('T')[0];
recordDailyUploadQuery.run({ date, size });
},
recordView: (size) => {
const date = new Date().toISOString().split('T')[0];
recordDailyViewQuery.run({ date, size });
},
getDailyStats: (limit = 30) => getDailyStatsQuery.all(limit),
getTopImages: (limit = 10) => getTopImagesQuery.all(limit),
};
================================================
FILE: server/db/shareRepository.js
================================================
const db = require('./database');
const crypto = require('crypto');
const createShare = db.prepare(`
INSERT INTO shares (token, path, created_at, expire_seconds, burn_after_reading)
VALUES (@token, @path, @createdAt, @expireSeconds, @burnAfterReading)
`);
const getShare = db.prepare('SELECT * FROM shares WHERE token = ?');
const getSharesByPath = db.prepare('SELECT * FROM shares WHERE path = ? ORDER BY created_at DESC');
const revokeShare = db.prepare('UPDATE shares SET is_revoked = 1 WHERE token = ?');
const deleteShare = db.prepare('DELETE FROM shares WHERE token = ?');
const incrementView = db.prepare('UPDATE shares SET views = views + 1 WHERE token = ?');
module.exports = {
create: (data) => {
const token = crypto.randomBytes(16).toString('hex');
const info = {
token,
path: data.path,
createdAt: Date.now(),
expireSeconds: data.expireSeconds || 0,
burnAfterReading: data.burnAfterReading ? 1 : 0
};
createShare.run(info);
return token;
},
getByToken: (token) => {
return getShare.get(token);
},
listByPath: (path) => {
return getSharesByPath.all(path);
},
revoke: (token) => {
return revokeShare.run(token);
},
delete: (token) => {
return deleteShare.run(token);
},
incrementView: (token) => {
return incrementView.run(token);
}
};
================================================
FILE: server/index.js
================================================
require("dotenv").config();
const express = require("express");
const cors = require("cors");
const path = require("path");
const fs = require("fs-extra");
const config = require("../config"); // config.js is in root usually? checking old index.js: require("../config")
// 等等,index.js 在 server/ 中,所以 ../config 在根目录。正确。
const uploadRoutes = require("./routes/uploadRoutes");
const imageRoutes = require("./routes/imageRoutes");
const manageRoutes = require("./routes/manageRoutes");
const systemRoutes = require("./routes/systemRoutes");
const statsRoutes = require("./routes/statsRoutes");
const searchRoutes = require("./routes/searchRoutes");
const shareRoutes = require("./routes/shareRoutes");
const { migrateFromLegacyJson, syncFileSystem } = require("./services/syncService");
const app = express();
const PORT = config.server.port || 5000; // fallback
// 中间件
app.use(cors());
app.use(express.json({ limit: '50mb' }));
app.use(express.static(path.join(__dirname, "../client/build")));
app.enable("trust proxy");
// 路由
// 顺序很重要!
// /api/health 可能最先
app.use("/api", systemRoutes);
// 流量统计
app.use("/api/stats", statsRoutes);
// 魔法搜图
app.use("/api/search", searchRoutes);
// 上传
app.use("/api", uploadRoutes); // /upload, /upload-base64
// 分享
app.use("/api/share", shareRoutes);
// 管理(密码、回收站、批量移动)
app.use("/api", manageRoutes); // /batch/move, /album/*, /images/* (DELETE)
// 图片 (GET) - 放在最后捕获 /images/*
app.use("/api", imageRoutes); // /images, /images/*, /files/*
// 数据库迁移和同步
(async () => {
try {
console.log("Initializing database...");
await migrateFromLegacyJson();
await syncFileSystem();
if (config.magicSearch.enabled) {
// 触发后台扫描任何丢失的嵌入 (低优先级)
// 这里没有 await,以便让服务器立即启动
const clipService = require('./services/clipService');
clipService.scanAll().catch(e => console.error("Background scan failed:", e));
}
} catch (e) {
console.error("Initialization failed:", e);
}
})();
// 回收站清理任务
const { TRASH_DIR_NAME, safeJoin } = require("./utils/fileUtils");
const STORAGE_PATH = config.storage.path;
async function cleanTrash() {
const trashDir = path.join(STORAGE_PATH, TRASH_DIR_NAME);
if (!(await fs.pathExists(trashDir))) return;
try {
const files = await fs.readdir(trashDir);
const now = Date.now();
const EXPIRE_TIME = 30 * 24 * 60 * 60 * 1000; // 30 Days
for (const file of files) {
const filePath = path.join(trashDir, file);
try {
const stats = await fs.stat(filePath);
if (now - stats.mtimeMs > EXPIRE_TIME) {
await fs.remove(filePath);
console.log(`[Trash] Cleaned expired file: ${file}`);
}
} catch (e) {
// ignore
}
}
} catch (e) {
console.error("[Trash] Cleanup failed:", e);
}
}
// 启动清理任务
cleanTrash();
setInterval(cleanTrash, 24 * 60 * 60 * 1000);
// 所有其他 GET 请求都返回 React 应用 (SPA 支持)
app.get('*', (req, res) => {
// 避免 API 请求返回 HTML
if (req.path.startsWith('/api/')) {
return res.status(404).json({ error: "Not Found" });
}
res.sendFile(path.join(__dirname, "../client/build", "index.html"));
});
// 启动服务器
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
================================================
FILE: server/middleware/auth.js
================================================
const config = require('../../config');
function requirePassword(req, res, next) {
if (!config.security.password.enabled) {
return next();
}
const password =
req.headers["x-access-password"] || req.body.password || req.query.password;
if (!password) {
return res.status(401).json({ error: "需要提供访问密码" });
}
if (password !== config.security.password.accessPassword) {
return res.status(401).json({ error: "密码错误" });
}
next();
}
module.exports = { requirePassword };
================================================
FILE: server/middleware/upload.js
================================================
const multer = require('multer');
const path = require('path');
const fs = require('fs-extra');
const config = require('../../config');
const { safeJoin, sanitizeFilename } = require('../utils/fileUtils');
const STORAGE_PATH = config.storage.path;
const isAllowedFile = (file) => {
const ext = path.extname(file.originalname).toLowerCase();
const isAllowedExt = config.upload.allowedExtensions.includes(ext);
const isAllowedMime = config.upload.allowedMimeTypes.includes(file.mimetype);
return isAllowedExt && isAllowedMime;
};
const FORBIDDEN_EXTENSIONS = [
".php", ".html", ".htm", ".js", ".mjs", ".ts", ".sh", ".bat", ".exe", ".dll",
".com", ".cgi", ".pl", ".py", ".jar", ".apk", ".msi"
];
const FORBIDDEN_MIME_PREFIXES = [
"text/html", "application/x-httpd-php", "application/javascript",
"text/javascript", "application/x-sh", "application/x-msdownload",
"application/vnd.android.package-archive"
];
const isForbiddenFile = (file) => {
const ext = path.extname(file.originalname).toLowerCase();
if (FORBIDDEN_EXTENSIONS.includes(ext)) return true;
const mime = (file.mimetype || "").toLowerCase();
if (FORBIDDEN_MIME_PREFIXES.some((m) => mime.startsWith(m))) return true;
return false;
};
const storage = multer.diskStorage({
destination: (req, file, cb) => {
let dir = req.query.dir || req.body.dir || "";
dir = dir.replace(/\\/g, "/");
const dest = safeJoin(STORAGE_PATH, dir);
try {
fs.ensureDirSync(dest);
cb(null, dest);
} catch (error) {
cb(error);
}
},
filename: (req, file, cb) => {
let originalName = file.originalname;
if (!/[^\u0000-\u00ff]/.test(originalName)) {
try {
originalName = Buffer.from(originalName, "latin1").toString("utf8");
} catch (e) { }
}
const sanitizedName = sanitizeFilename(originalName);
const forceOverwrite =
req.query.overwrite === "true" ||
req.body?.overwrite === "true" ||
req.query.overwrite === true ||
req.body?.overwrite === true;
if (forceOverwrite) {
return cb(null, sanitizedName);
}
const ext = path.extname(sanitizedName);
const nameWithoutExt = path.basename(sanitizedName, ext);
let finalName = sanitizedName;
let counter = 1;
let dir = req.query.dir || req.body.dir || "";
dir = dir.replace(/\\/g, "/");
const dest = safeJoin(STORAGE_PATH, dir);
if (!config.upload.allowDuplicateNames) {
while (fs.existsSync(path.join(dest, finalName))) {
if (config.upload.duplicateStrategy === "timestamp") {
finalName = `${nameWithoutExt}_${Date.now()}_${counter}${ext}`;
} else if (config.upload.duplicateStrategy === "counter") {
finalName = `${nameWithoutExt}_${counter}${ext}`;
} else if (config.upload.duplicateStrategy === "overwrite") {
break;
}
counter++;
}
}
cb(null, finalName);
},
});
const upload = multer({
storage: storage,
fileFilter: (req, file, cb) => {
if (isAllowedFile(file)) {
cb(null, true);
} else {
const allowedFormats = config.upload.allowedExtensions.join(", ");
cb(new Error(`只支持以下图片格式: ${allowedFormats}`));
}
},
limits: { fileSize: config.upload.maxFileSize },
});
const uploadAny = multer({
storage: storage,
fileFilter: (req, file, cb) => {
if (isForbiddenFile(file)) {
return cb(new Error("不允许上传可执行或危险文件类型"));
}
cb(null, true);
},
limits: { fileSize: config.upload.maxFileSize },
});
const handleMulterError = (err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({
success: false,
error: `文件大小超过限制,最大允许 ${Math.round((config.upload.maxFileSize / (1024 * 1024)) * 100) / 100}MB`
});
}
return res.status(400).json({ success: false, error: `上传错误: ${err.message}` });
} else if (err) {
return res.status(400).json({ success: false, error: err.message });
}
next();
};
module.exports = {
upload,
uploadAny,
handleMulterError
};
================================================
FILE: server/routes/imageRoutes.js
================================================
const express = require('express');
const path = require('path');
const fs = require('fs-extra');
const mime = require('mime-types');
const sharp = require('sharp');
sharp.cache({ memory: 128, items: 50 });
const config = require('../../config');
const imageRepository = require('../db/imageRepository');
const { requirePassword } = require('../middleware/auth');
const { safeJoin, getThumbHash, generateThumbHash } = require('../utils/fileUtils');
const { formatImageResponse } = require('../utils/urlUtils');
const router = express.Router();
const STORAGE_PATH = config.storage.path;
const { isAlbumLocked, verifyAlbumPassword, getAllLockedDirectories } = require('../utils/albumUtils');
// 地图数据 (旧端点支持,现在仅返回 DB 中的所有图像?)
// 原始 updateMapCache 返回所有图像。
router.get('/map-data', requirePassword, async (req, res) => {
// 返回所有带 GPS 数据的图像
// 我们可以在 SQL 或 JS 中过滤。
// 目前获取所有并返回所需字段。
const lockedDirs = await getAllLockedDirectories();
const images = imageRepository.getAll();
const mapData = images.filter(img => {
if (lockedDirs.some(lockedDir => img.rel_path.startsWith(lockedDir + "/"))) return false;
const meta = JSON.parse(img.meta_json || '{}');
return meta.gps;
}).map(img => {
const formatted = formatImageResponse(req, img);
const meta = JSON.parse(img.meta_json || '{}');
return {
filename: img.filename,
relPath: img.rel_path,
lat: meta.gps.lat,
lng: meta.gps.lng,
date: img.upload_time,
thumbUrl: `${formatted.url}?w=200`,
thumbhash: img.thumbhash,
fullUrl: formatted.fullUrl,
url: formatted.url
};
});
res.json({ success: true, data: mapData });
});
// 目录列表
router.get('/directories', requirePassword, async (req, res) => {
try {
const { CACHE_DIR_NAME, CONFIG_DIR_NAME, TRASH_DIR_NAME } = require('../utils/fileUtils');
// 扫描目录
async function getDirectories(dir) {
const absDir = safeJoin(STORAGE_PATH, dir);
let results = [];
try {
const files = await fs.readdir(absDir);
for (const file of files) {
if (file === CACHE_DIR_NAME || file === CONFIG_DIR_NAME || file === TRASH_DIR_NAME) continue;
if (file.startsWith('.')) continue; // 跳过隐藏文件
const filePath = path.join(absDir, file);
const stats = await fs.stat(filePath);
if (stats.isDirectory()) {
const relPath = path.join(dir, file).replace(/\\/g, "/");
// 从 DB 获取预览图和计数
// 注意:这会递归获取计数/预览,这通常是用户期望的“相册”封面
const isLocked = await isAlbumLocked(relPath);
let previews = [];
if (!isLocked) {
previews = imageRepository.getPreviews(relPath, 3).map(img =>
`/api/images/${img.rel_path.split("/").map(encodeURIComponent).join("/")}?w=400`
);
}
const count = imageRepository.countByDir(relPath);
results.push({
name: file,
path: relPath,
fullUrl: relPath, // alias
previews,
locked: isLocked, // 标记为锁定隐私状态
imageCount: count,
mtime: stats.mtime // 文件夹本身的最后修改时间
});
// 递归扫描子相册?
// 如果显示树形结构,我们需要子项。
// 但通常典型的“相册视图”是直接子文件夹的平铺列表。
// 之前的代码是递归的:`results = results.concat(children);`
// 如果保持递归,我们将获得所有扁平化的子文件夹。
// UI 是否期望这样?
// `AlbumManager` 使用 `allAlbums`。它似乎将它们显示为卡片。
// 如果我有 A/B/C,我会看到 A、B 和 C 吗?
// 如果我看到 A并在其中点击,通常会进入 A。
// UI `AlbumManager` 过滤?
// `const allAlbums = res.data.data || [];`
// `setAlbums([allImagesAlbum, ...allAlbums]);`
// 它配置网格。
// 如果 API 返回所有目录的扁平列表,那么是的,递归是可以的。
// 让我们保持递归结构原样。
const children = await getDirectories(relPath);
results = results.concat(children);
}
}
} catch (e) { }
return results;
}
const directories = await getDirectories("");
res.json({ success: true, data: directories });
} catch (e) {
console.error("List directories error:", e);
res.status(500).json({ error: "Get directories failed" });
}
});
// 图片列表
router.get('/images', requirePassword, async (req, res) => {
try {
let dir = req.query.dir || "";
dir = dir.replace(/\\/g, "/");
const page = parseInt(req.query.page) || 1;
const pageSize = parseInt(req.query.pageSize) || 10;
const search = req.query.search || "";
const albumPassword = req.headers["x-album-password"];
if (dir && await isAlbumLocked(dir)) {
if (!albumPassword || !(await verifyAlbumPassword(dir, albumPassword))) {
return res.status(403).json({ success: false, error: "需要访问密码", locked: true });
}
}
// DB 查询?
// SQLite 没有原生的递归目录过滤,除非我们使用 GLOB
// 但 `rel_path` 允许 `dir/*` 通配符?
// 或者我们可以获取全部并在内存中过滤?
// `imageRepository.getAll` 返回所有。
// 如果库很大(10万张图片),内存过滤很糟糕。
// 我应该使用 LIKE 'dir/%' AND NOT LIKE 'dir/%/%' 向存储库添加 `getByDir`?
// 原来的 `getAllImages` 是递归的!
// `getAllImages(dir)` 递归返回 `dir` 中的所有内容。
// 所以 `WHERE rel_path LIKE 'dir/%'` 是正确的(对根目录处理正确)。
let allImages = imageRepository.getAll();
if (dir) {
allImages = allImages.filter(img => img.rel_path.startsWith(dir !== "" ? (dir + "/") : ""));
} else {
const lockedDirs = await getAllLockedDirectories();
allImages = allImages.filter(img => !lockedDirs.some(lockedDir => img.rel_path.startsWith(lockedDir + "/")));
}
if (search) {
allImages = allImages.filter(img => img.filename.toLowerCase().includes(search.toLowerCase()));
}
const total = allImages.length;
const startIndex = (page - 1) * pageSize;
const paginated = allImages.slice(startIndex, startIndex + pageSize);
const result = paginated.map(img => formatImageResponse(req, img));
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate");
res.json({
success: true,
data: result,
pagination: {
current: page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize)
}
});
} catch (e) {
console.error("List images error:", e);
res.status(500).json({ error: "获取图片列表失败" });
}
});
// 提取图片元数据
router.get('/images/meta/*', requirePassword, async (req, res) => {
const relPath = decodeURIComponent(req.params[0]);
const dbImage = imageRepository.getByPath(relPath);
if (!dbImage) {
// 回退到 FS 检查?
if (!await fs.pathExists(safeJoin(STORAGE_PATH, relPath))) {
return res.status(404).json({ success: false, error: "图片不存在" });
}
// 如果存在于 FS 但不在 DB 中,也许同步服务丢失了它?返回基本信息
}
// DB lookup first
let fileInfo = {};
if (dbImage) {
fileInfo = {
width: dbImage.width,
height: dbImage.height,
orientation: dbImage.orientation,
...JSON.parse(dbImage.meta_json || '{}')
};
}
try {
const filePath = safeJoin(STORAGE_PATH, relPath);
const fstats = await fs.stat(filePath);
const mimeType = mime.lookup(filePath) || "application/octet-stream";
// If DB miss or missing critical info (e.g. detailed EXIF or Space not in DB for old records),
// we might want to re-parse from file on the fly given this is the "Detail View"
// But for performance, trust DB if available.
// However, user just asked to "fix" it, so for existing images that don't have the new fields,
// we should probably re-extract if missing.
let needsUpdate = false;
if (!fileInfo.space || !fileInfo.width) {
const { getFileMetadata } = require('../services/metadataService');
const freshMeta = await getFileMetadata(filePath, relPath, fstats);
// Merge fresh meta
const freshJson = JSON.parse(freshMeta.meta_json);
fileInfo = {
...fileInfo,
width: freshMeta.width,
height: freshMeta.height,
orientation: freshMeta.orientation,
...freshJson
};
// Optionally update DB here? Maybe too heavy for a GET request.
// Let's just return it for now.
}
const rawInfo = {
filename: path.basename(relPath),
rel_path: relPath, // helper expects rel_path
size: fstats.size,
upload_time: fstats.mtime.toISOString(), // helper expects upload_time
mime_type: mimeType,
width: fileInfo.width,
height: fileInfo.height,
meta_json: fileInfo // helper can take object
};
res.json({
success: true,
data: formatImageResponse(req, rawInfo)
});
} catch (e) {
console.error("Meta error:", e);
res.status(400).json({ success: false, error: "Error fetching metadata" });
}
});
// 辅助函数:处理并发送图片
async function serveImage(req, res, relPath) {
try {
const filePath = safeJoin(STORAGE_PATH, relPath);
if (!await fs.pathExists(filePath)) return res.status(404).json({ error: "Not found" });
// Thumbhash 触发器
getThumbHash(filePath).then(h => { if (!h) generateThumbHash(filePath); });
const { w, h, q, fmt, rows, cols, idx } = req.query;
// 对 GIF 文件且无任何处理参数时,直接返回原始文件(保留动画)
const fileMime = (mime.lookup(filePath) || "").toLowerCase();
const isGif = fileMime.includes("gif");
if (isGif && !w && !h && !q && !fmt && !rows && !cols) {
try {
const stats = await fs.stat(filePath);
imageRepository.recordView(stats.size);
imageRepository.incrementViews(relPath);
} catch (e) { }
res.setHeader("Content-Type", "image/gif");
res.setHeader("Cache-Control", "public, max-age=31536000");
return res.sendFile(filePath);
}
// Sharp 逻辑
try {
// GIF 缩略图:明确只取第一帧(animated: false 是 sharp 默认行为,此处显式声明)
let img = isGif
? sharp(filePath, { animated: false }).rotate()
: sharp(filePath).rotate();
// 2. 处理网格切分 (Slicing)
if (rows && cols && idx !== undefined) {
const r = parseInt(rows);
const c = parseInt(cols);
const i = parseInt(idx);
if (r > 0 && c > 0 && i >= 0 && i < r * c) {
const meta = await img.metadata();
const width = meta.width;
const height = meta.height;
const subW = Math.floor(width / c);
const subH = Math.floor(height / r);
const row = Math.floor(i / c);
const col = i % c;
const left = col * subW;
const top = row * subH;
// 防止舍入误差导致溢出
const extractW = Math.min(subW, width - left);
const extractH = Math.min(subH, height - top);
img.extract({ left, top, width: extractW, height: extractH });
}
}
// 3. 处理缩放 (Resize) - 针对切分后的图(或者原图)
if (w || h) {
img = img.resize({
width: w ? parseInt(w) : null,
height: h ? parseInt(h) : null,
fit: "cover",
position: "center",
withoutEnlargement: true,
});
}
let outMime = mime.lookup(filePath) || "application/octet-stream";
if (fmt === "webp") {
img = img.webp({ quality: q ?? 80 });
outMime = "image/webp";
} else if (fmt === "jpeg") {
img = img.jpeg({ quality: q ?? 80 });
outMime = "image/jpeg";
} else if (fmt === "png") {
img = img.png();
outMime = "image/png";
} else if (fmt === "avif") {
img = img.avif({ quality: q ?? 50 });
outMime = "image/avif";
} else if (q) {
const orig = (mime.lookup(filePath) || "").toLowerCase();
if (orig.includes("jpeg") || orig.includes("jpg")) {
img = img.jpeg({ quality: q });
outMime = "image/jpeg";
} else if (orig.includes("webp")) {
img = img.webp({ quality: q });
outMime = "image/webp";
} else if (orig.includes("avif")) {
img = img.avif({ quality: q });
outMime = "image/avif";
} else {
img = img.png();
outMime = "image/png";
}
}
const buffer = await img.toBuffer();
// Record Stats (Fire and forget)
try {
imageRepository.recordView(buffer.length);
imageRepository.incrementViews(relPath);
} catch (e) {
console.error("Stats error", e);
}
res.setHeader("Content-Type", outMime);
res.setHeader("Cache-Control", "public, max-age=31536000");
res.send(buffer);
} catch (e) {
// 对非图像文件或 sharp 错误的回退
if (!w && !h && !q && !fmt) {
// Record Stats for raw file
try {
const stats = await fs.stat(filePath);
imageRepository.recordView(stats.size);
imageRepository.incrementViews(relPath);
} catch (e) { }
res.setHeader("Content-Type", mime.lookup(filePath) || 'application/octet-stream');
return res.sendFile(filePath);
}
res.status(500).json({ error: "Image processing failed" });
}
} catch (e) {
res.status(400).json({ error: "Error" });
}
}
// 随机图片 (GET)
// 支持 ?dir=xxx 参数来限定目录
router.get('/random', async (req, res) => {
try {
let dir = req.query.dir || "";
dir = dir.replace(/\\/g, "/");
let allImages = imageRepository.getAll();
if (dir) {
allImages = allImages.filter(img => img.rel_path.startsWith(dir !== "" ? (dir + "/") : ""));
} else {
const lockedDirs = await getAllLockedDirectories();
allImages = allImages.filter(img => !lockedDirs.some(lockedDir => img.rel_path.startsWith(lockedDir + "/")));
}
if (allImages.length === 0) {
return res.status(404).json({ error: "Not Found" });
}
const randomIndex = Math.floor(Math.random() * allImages.length);
const randomImage = allImages[randomIndex];
// START CHANGE: Support format=json
// START CHANGE: Support format=json
if (req.query.format === 'json') {
return res.json(formatImageResponse(req, randomImage));
}
// END CHANGE
// END CHANGE
// 复用 serveImage
await serveImage(req, res, randomImage.rel_path);
} catch (e) {
console.error("Random image error:", e);
res.status(500).json({ error: "Failed to get random image" });
}
});
// 服务图片内容
router.get('/images/*', async (req, res) => {
const relPath = decodeURIComponent(req.params[0]);
await serveImage(req, res, relPath);
});
// 服务原始文件(无处理)
router.get('/files/*', (req, res) => {
const relPath = decodeURIComponent(req.params[0]);
try {
const filePath = safeJoin(STORAGE_PATH, relPath);
if (fs.existsSync(filePath)) {
res.sendFile(filePath);
} else {
res.status(404).json({ error: "Not found" });
}
} catch (e) {
res.status(400).json({ error: "Error" });
}
});
module.exports = router;
================================================
FILE: server/routes/manageRoutes.js
================================================
const express = require('express');
const path = require('path');
const fs = require('fs-extra');
const config = require('../../config');
const { requirePassword } = require('../middleware/auth');
const imageRepository = require('../db/imageRepository');
const { syncFileSystem } = require('../services/syncService');
const { safeJoin, TRASH_DIR_NAME, CACHE_DIR_NAME } = require('../utils/fileUtils');
const router = express.Router();
const STORAGE_PATH = config.storage.path;
const { getAlbumPasswordPath, verifyAlbumPassword } = require('../utils/albumUtils');
// 0. 手动同步
router.post('/sync', requirePassword, async (req, res) => {
try {
await syncFileSystem();
res.json({ success: true, message: "同步完成" });
} catch (e) {
console.error("Sync failed:", e);
res.status(500).json({ success: false, error: "同步失败" });
}
});
// 1. 相册密码管理
router.post('/album/password', requirePassword, async (req, res) => {
try {
const { dir, password } = req.body;
if (dir === undefined) return res.status(400).json({ error: "Missing directory" });
const configPath = await getAlbumPasswordPath(dir);
if (!password) {
if (await fs.pathExists(configPath)) {
await fs.remove(configPath);
}
return res.json({ success: true, message: "密码已移除" });
}
await fs.ensureDir(path.dirname(configPath));
await fs.writeJSON(configPath, { password });
res.json({ success: true, message: "密码设置成功" });
} catch (e) {
console.error("Set album password error:", e);
res.status(500).json({ error: "设置密码失败" });
}
});
router.post('/album/verify', requirePassword, async (req, res) => {
try {
const { dir, password } = req.body;
if (dir === undefined) return res.status(400).json({ error: "Missing directory" });
const isValid = await verifyAlbumPassword(dir, password);
if (isValid) {
res.json({ success: true, message: "验证通过" });
} else {
res.status(401).json({ success: false, error: "密码错误" });
}
} catch (e) {
res.status(500).json({ error: "验证失败" });
}
});
// 2. 回收站逻辑
async function moveToTrash(filePath) {
try {
const fileName = path.basename(filePath);
const ext = path.extname(fileName);
const nameWithoutExt = path.basename(fileName, ext);
const timestamp = Date.now();
const trashName = `${nameWithoutExt}_${timestamp}${ext}`;
const trashPath = path.join(STORAGE_PATH, TRASH_DIR_NAME, trashName);
await fs.ensureDir(path.dirname(trashPath));
await fs.move(filePath, trashPath, { overwrite: true });
return true;
} catch (error) {
console.error("[Trash] Move failed:", error);
throw error;
}
}
// 3. 删除图片
router.delete('/images/*', requirePassword, async (req, res) => {
const relPath = decodeURIComponent(req.params[0]);
try {
const filePath = safeJoin(STORAGE_PATH, relPath);
if (await fs.pathExists(filePath)) {
await moveToTrash(filePath);
// 移除 thumbhash
const dir = path.dirname(filePath);
const filename = path.basename(filePath);
const cacheFile = path.join(dir, CACHE_DIR_NAME, `${filename}.th`);
if (await fs.pathExists(cacheFile)) await fs.remove(cacheFile);
// 从 DB 移除
imageRepository.delete(relPath);
res.json({ success: true });
} else {
// 如果不在磁盘上但在 DB 中?
imageRepository.delete(relPath);
res.status(404).json({ error: "图片不存在 (但在数据库中已清理)" });
}
} catch (e) {
res.status(400).json({ error: "操作失败" });
}
});
// 4. 删除文件
router.delete('/files/*', requirePassword, async (req, res) => {
const relPath = decodeURIComponent(req.params[0]);
try {
const filePath = safeJoin(STORAGE_PATH, relPath);
if (await fs.pathExists(filePath)) {
await moveToTrash(filePath);
// 如果存在则从 DB 移除(可能是通过 upload-file 上传的)
imageRepository.delete(relPath);
res.json({ success: true, message: "文件已移至回收站" });
} else {
res.status(404).json({ error: "文件不存在" });
}
} catch (e) {
res.status(400).json({ error: "操作失败" });
}
});
// 5. 批量移动
router.post('/batch/move', requirePassword, async (req, res) => {
try {
const { files, targetDir } = req.body;
if (!Array.isArray(files) || files.length === 0) {
return res.status(400).json({ error: "未选择文件" });
}
let newDir = targetDir || "";
newDir = newDir.replace(/\\/g, "/").trim();
const absTargetDir = safeJoin(STORAGE_PATH, newDir);
await fs.ensureDir(absTargetDir);
let successCount = 0;
let failCount = 0;
for (const relPath of files) {
try {
const oldRelPath = decodeURIComponent(relPath).replace(/\\/g, "/");
const oldFilePath = safeJoin(STORAGE_PATH, oldRelPath);
if (await fs.pathExists(oldFilePath)) {
const filename = path.basename(oldFilePath);
let newRelPath = path.join(newDir, filename).replace(/\\/g, "/");
let newFilePath = safeJoin(STORAGE_PATH, newRelPath);
// Handle duplicates
if (await fs.pathExists(newFilePath)) {
let counter = 1;
const ext = path.extname(filename);
const nameBase = path.basename(filename, ext);
while (await fs.pathExists(newFilePath)) {
const newName = `${nameBase}_${Date.now()}_${counter}${ext}`;
newRelPath = path.join(newDir, newName).replace(/\\/g, "/");
newFilePath = safeJoin(STORAGE_PATH, newRelPath);
counter++;
}
}
await fs.move(oldFilePath, newFilePath);
// 更新 DB:删除旧的,添加新的(重新扫描元数据?或者只是更新路径?)
// 元数据应该不会改变太多,除非移动影响 mtime(通常在同一 FS 上不会)
// 但更新路径最简单。
// 但是,thumbhash 缓存文件也需要移动!
// 移动 thumbhash
const oldCachePath = path.join(path.dirname(oldFilePath), CACHE_DIR_NAME, `${filename}.th`);
if (await fs.pathExists(oldCachePath)) {
const newCacheDir = path.join(path.dirname(newFilePath), CACHE_DIR_NAME);
await fs.ensureDir(newCacheDir);
const newCachePath = path.join(newCacheDir, `${path.basename(newFilePath)}.th`);
await fs.move(oldCachePath, newCachePath);
}
// 更新 DB
const dbImage = imageRepository.getByPath(oldRelPath);
if (dbImage) {
dbImage.rel_path = newRelPath;
dbImage.filename = path.basename(newFilePath);
// 更新缓存中的 thumbhash 路径?不,DB 直接在 'thumbhash' 列中存储内容?
// 等等,Schema 中 'thumbhash' 是 TEXT (base64)。
// 所以我们不需要更新 DB thumbhash 内容,除非重新生成。
// 我们只需更新路径。
imageRepository.delete(oldRelPath);
imageRepository.add(dbImage);
// 或者在此处更新 rel_path = old... 但主键是 ID。
// rel_path 是唯一的。
// 其实 `imageRepository.update` 使用 rel_path 作为键。
// 所以我不能轻易用我写的 `update` 函数更改 rel_path。
// `updateImage` SQL: WHERE rel_path = @relPath。
// 所以我必须删除并添加。
}
successCount++;
} else {
failCount++;
}
} catch (e) {
console.error(`Move failed for ${relPath}:`, e);
failCount++;
}
}
res.json({ success: true, successCount, failCount });
} catch (e) {
res.status(500).json({ error: "批量移动失败" });
}
});
// 6. 创建目录
router.post('/directories', requirePassword, async (req, res) => {
try {
const { name } = req.body;
if (!name) return res.status(400).json({ error: "Missing directory name" });
// Basic validation
if (name.includes("..") || name.includes("\\") || name.startsWith("/")) {
return res.status(400).json({ error: "Invalid directory name" });
}
const absDir = safeJoin(STORAGE_PATH, name);
if (await fs.pathExists(absDir)) {
return res.status(400).json({ error: "Directory already exists" });
}
await fs.ensureDir(absDir);
res.json({ success: true, message: "目录创建成功" });
} catch (e) {
console.error("Create directory failed:", e);
res.status(500).json({ error: "创建目录失败" });
}
});
// 7. 重命名图片
router.put('/images/*', requirePassword, async (req, res) => {
const relPath = decodeURIComponent(req.params[0]);
const { newName } = req.body;
if (!newName || !newName.trim()) {
return res.status(400).json({ success: false, error: "新文件名不能为空" });
}
// 安全校验:不允许路径穿越或绝对路径
const safeName = path.basename(newName.trim());
if (!safeName || safeName !== newName.trim()) {
return res.status(400).json({ success: false, error: "非法文件名" });
}
try {
const oldFilePath = safeJoin(STORAGE_PATH, relPath);
if (!await fs.pathExists(oldFilePath)) {
return res.status(404).json({ success: false, error: "原文件不存在" });
}
const dir = path.dirname(relPath);
const newRelPath = (dir && dir !== '.') ? `${dir}/${safeName}` : safeName;
const newFilePath = safeJoin(STORAGE_PATH, newRelPath);
// 不允许重命名为自身
if (oldFilePath === newFilePath) {
return res.json({ success: true, data: { relPath, filename: path.basename(relPath) } });
}
// 目标已存在则报错
if (await fs.pathExists(newFilePath)) {
return res.status(409).json({ success: false, error: "目标文件名已存在" });
}
// 重命名文件
await fs.rename(oldFilePath, newFilePath);
// 移动 thumbhash 缓存(如存在)
const oldCacheFile = path.join(path.dirname(oldFilePath), CACHE_DIR_NAME, `${path.basename(oldFilePath)}.th`);
if (await fs.pathExists(oldCacheFile)) {
const newCacheFile = path.join(path.dirname(newFilePath), CACHE_DIR_NAME, `${safeName}.th`);
await fs.ensureDir(path.dirname(newCacheFile));
await fs.rename(oldCacheFile, newCacheFile);
}
// 原子更新数据库
const updated = imageRepository.rename(relPath, newRelPath, safeName);
const { formatImageResponse } = require('../utils/urlUtils');
const responseData = updated
? formatImageResponse(req, updated)
: { relPath: newRelPath, filename: safeName };
res.json({ success: true, data: responseData });
} catch (e) {
console.error("Rename failed:", e);
res.status(500).json({ success: false, error: "重命名失败: " + (e.message || e) });
}
});
module.exports = router;
================================================
FILE: server/routes/searchRoutes.js
================================================
const express = require('express');
const router = express.Router();
const clipService = require('../services/clipService');
const { formatImageResponse } = require('../utils/urlUtils');
// 语义搜索
router.post('/semantic', async (req, res) => {
try {
const { query, limit } = req.body;
if (!query) return res.status(400).json({ success: false, error: "Query is required" });
const results = await clipService.search(query, limit || 50);
// 使用 formatImageResponse 标准化输出
const finalResults = results.map(r => {
const formatted = formatImageResponse(req, r);
return {
...formatted,
score: r.distance
};
});
res.json({ success: true, data: finalResults });
} catch (error) {
console.error("Semantic search error:", error);
res.status(500).json({ success: false, error: "Search failed" });
}
});
// 触发全量扫描
router.post('/scan', async (req, res) => {
try {
const result = await clipService.scanAll();
res.json({ success: true, ...result });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// 重新索引所有图片 (清除 DB 并重新扫描)
router.post('/reindex', async (req, res) => {
try {
const result = await clipService.reindex();
res.json({ success: true, ...result });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// 状态
router.get('/status', (req, res) => {
res.json({
success: true,
queueLength: clipService.queue.length,
processing: clipService.processing
});
});
module.exports = router;
================================================
FILE: server/routes/shareRoutes.js
================================================
const express = require('express');
const router = express.Router();
const shareRepository = require('../db/shareRepository');
const imageRepository = require('../db/imageRepository');
const { requirePassword } = require('../middleware/auth');
const { formatImageResponse } = require('../utils/urlUtils');
const path = require('path');
// List Share Links for a path
router.get('/list', requirePassword, (req, res) => {
try {
const { path: sharePath } = req.query;
// if (!sharePath) return res.status(400).json({ error: "Missing path" });
// Allow listing ALL if path missing? Logic in AlbumManager passes path.
const shares = shareRepository.listByPath(sharePath || "");
// Calculate status for each share
const now = Date.now();
const result = shares.map(s => {
let status = 'active';
if (s.is_revoked) status = 'revoked';
else if (s.burn_after_reading && s.views > 0) status = 'burned';
else if (s.expire_seconds > 0) {
const expireTime = s.created_at + (s.expire_seconds * 1000);
if (now > expireTime) status = 'expired';
}
return {
token: s.token,
signature: s.token, // Using token as signature for now
path: s.path,
createdAt: s.created_at,
expireSeconds: s.expire_seconds,
burnAfterReading: !!s.burn_after_reading,
status,
views: s.views
};
});
res.json({ success: true, data: result });
} catch (e) {
console.error("List shares error:", e);
res.status(500).json({ error: "Failed to list shares" });
}
});
// Generate Share Link
router.post('/generate', requirePassword, (req, res) => {
try {
const { path, expireSeconds, burnAfterReading } = req.body;
if (path === undefined) return res.status(400).json({ error: "Missing path" });
const token = shareRepository.create({
path,
expireSeconds: expireSeconds || 0,
burnAfterReading: !!burnAfterReading
});
res.json({ success: true, token });
} catch (e) {
console.error("Generate share error:", e);
res.status(500).json({ error: "Failed to generate share" });
}
});
// Revoke Share Link
router.post('/revoke', requirePassword, (req, res) => {
try {
const { signature } = req.body;
if (!signature) return res.status(400).json({ error: "Missing signature" });
shareRepository.revoke(signature);
res.json({ success: true });
} catch (e) {
console.error("Revoke share error:", e);
res.status(500).json({ error: "Failed to revoke share" });
}
});
// Delete Share Link (History)
router.delete('/delete', requirePassword, (req, res) => {
try {
const { signature } = req.body; // or req.body.data if axios sends it there? Express req.body handles it if JSON middleware is on.
// Wait, DELETE with body? Client sends `data: { ... }`. Express `req.body` should have it.
if (!signature) return res.status(400).json({ error: "Missing signature" });
shareRepository.delete(signature);
res.json({ success: true });
} catch (e) {
console.error("Delete share error:", e);
res.status(500).json({ error: "Failed to delete share" });
}
});
// Access Shared Content (Public)
router.get('/access', (req, res) => {
try {
const { token, page = 1, pageSize = 20 } = req.query;
if (!token) return res.status(400).json({ error: "Missing token" });
const share = shareRepository.getByToken(token);
if (!share) return res.status(404).json({ error: "Invalid link" });
// Check verification (expiry, burn, revoked)
if (share.is_revoked) return res.status(403).json({ error: "Link has been revoked" });
const now = Date.now();
if (share.expire_seconds > 0) {
const expireTime = share.created_at + (share.expire_seconds * 1000);
if (now > expireTime) return res.status(403).json({ error: "Link expired" });
}
if (share.burn_after_reading && share.views > 0) {
return res.status(403).json({ error: "Link already used (Burned)" });
}
// Increment view count
shareRepository.incrementView(token);
// Get images
let images = imageRepository.getByDir(share.path);
// Pagination
const p = parseInt(page);
const ps = parseInt(pageSize);
const total = images.length;
const totalPages = Math.ceil(total / ps);
const start = (p - 1) * ps;
const end = start + ps;
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.
// Get dirname
const dirName = share.path.split('/').pop() || (share.path === "" ? "全部图片" : share.path);
res.json({
success: true,
data: sliced.map(img => formatImageResponse(req, img)),
dirName,
pagination: {
current: p,
pageSize: ps,
total,
totalPages
}
});
} catch (e) {
console.error("Share access error:", e);
res.status(500).json({ error: "Failed to access share" });
}
});
module.exports = router;
================================================
FILE: server/routes/statsRoutes.js
================================================
const express = require('express');
const router = express.Router();
const imageRepository = require('../db/imageRepository');
const { requirePassword } = require('../middleware/auth');
// 获取每日流量统计
router.get('/traffic', requirePassword, async (req, res) => {
try {
const days = req.query.days ? parseInt(req.query.days) : 30;
const stats = imageRepository.getDailyStats(days);
// Ensure chronological order for charts (DB returns DESC)
const sorted = stats.reverse();
res.json({ success: true, data: sorted });
} catch (e) {
console.error("Fetch traffic stats error:", e);
res.status(500).json({ error: "获取流量数据失败" });
}
});
// 获取热门图片
router.get('/top', requirePassword, async (req, res) => {
try {
const limit = req.query.limit ? parseInt(req.query.limit) : 10;
const { getAllLockedDirectories } = require('../utils/albumUtils');
const lockedDirs = await getAllLockedDirectories();
const allImagesSorted = imageRepository.getAllByViews();
const filteredImages = allImagesSorted.filter(img =>
!lockedDirs.some(lockedDir => img.rel_path.startsWith(lockedDir + "/"))
);
const topImages = filteredImages.slice(0, limit);
// Map to standard response format if needed, or just return DB rows
const data = topImages.map(img => ({
filename: img.filename,
relPath: img.rel_path,
views: img.views,
size: img.size,
uploadTime: img.upload_time,
url: `/api/images/${img.rel_path.split("/").map(encodeURIComponent).join("/")}?w=200`
}));
res.json({ success: true, data });
} catch (e) {
console.error("Fetch top images error:", e);
res.status(500).json({ error: "获取热门图片失败" });
}
});
module.exports = router;
================================================
FILE: server/routes/systemRoutes.js
================================================
const express = require('express');
const imageRepository = require('../db/imageRepository');
const router = express.Router();
router.get('/health', (req, res) => {
res.status(200).json({ status: "ok" });
});
router.get('/stats', (req, res) => {
// 基本统计
const count = imageRepository.count();
res.json({
success: true,
data: {
imageCount: count
}
});
});
router.get('/config', (req, res) => {
// 返回安全公开配置
const config = require('../../config');
res.json({
success: true,
data: {
upload: {
maxFileSize: config.upload.maxFileSize,
allowedExtensions: config.upload.allowedExtensions
},
storage: {
filename: config.storage.filename
},
magicSearch: {
enabled: config.magicSearch.enabled
}
}
});
});
router.get('/auth/status', (req, res) => {
const config = require('../../config');
res.json({
success: true,
data: {
enabled: config.security.password.enabled,
// 此处不验证密码,仅返回状态
// 前端调用此接口以检查是否需要提示输入密码
}
});
});
router.post('/auth/login', (req, res) => {
const config = require('../../config');
const { password } = req.body;
if (!config.security.password.enabled) {
return res.json({ success: true, message: "No password required" });
}
if (password === config.security.password.accessPassword) {
return res.json({ success: true, message: "Login successful" });
}
res.status(401).json({ success: false, error: "Incorrect password" });
});
module.exports = router;
================================================
FILE: server/routes/uploadRoutes.js
================================================
const express = require('express');
const path = require('path');
const fs = require('fs-extra');
const config = require('../../config');
const { upload, uploadAny, handleMulterError } = require('../middleware/upload');
const { requirePassword } = require('../middleware/auth');
const { saveBase64Image, safeJoin, sanitizeFilename, generateThumbHash, downloadFromUrl } = require('../utils/fileUtils');
const { formatImageResponse } = require('../utils/urlUtils');
const imageRepository = require('../db/imageRepository');
const { getFileMetadata, parseAudioDuration } = require('../services/metadataService');
const clipService = require('../services/clipService'); // 引入 ClipService
const sharp = require('sharp');
const router = express.Router();
const STORAGE_PATH = config.storage.path;
function getBaseUrl(req) {
const proto = req.headers["x-forwarded-proto"] || req.protocol;
const protocol = Array.isArray(proto) ? proto[0] : String(proto).split(",")[0].trim();
const host = req.headers["x-forwarded-host"] || req.get("host");
return `${protocol}://${host}`;
}
// 1. Base64 上传
router.post('/upload-base64', requirePassword, async (req, res) => {
try {
let dir = req.body.dir || req.query.dir || "";
dir = dir.replace(/\\/g, "/");
if (!req.body.base64Image) {
return res.status(400).json({ success: false, error: "缺少 base64Image 参数" });
}
const { filename, filePath, size, mimetype } = await saveBase64Image(req.body.base64Image, dir);
const relPath = path.join(dir, filename).replace(/\\/g, "/");
// 生成元数据和 DB 条目
const metadata = await getFileMetadata(filePath, relPath);
// Base64 上传通常没有原始名称,使用清理后的文件名或提供的名称
const originalName = req.body.originalName || filename;
const fileInfo = {
filename: metadata.filename || filename, // metadata might not have filename set if constructed manually
rel_path: relPath,
...metadata
};
// 确保文件名在 DB 对象中设置(getFileMetadata 返回带有 size, mtime 等的对象)
// imageRepository 期望:filename, rel_path, ...metadata
const dbResult = imageRepository.add({
filename: sanitizeFilename(originalName),
rel_path: relPath,
...metadata
});
// 添加到魔法搜图队列
try {
let imageId = dbResult.lastInsertRowid;
if (!imageId || imageId.toString() === '0') {
const existing = imageRepository.getByPath(relPath);
if (existing) imageId = existing.id;
}
if (imageId) {
clipService.addToQueue({ id: imageId, rel_path, filename: fileInfo.filename });
}
} catch (queueErr) {
console.error("Queue error:", queueErr);
}
// 添加到魔法搜图队列
try {
let imageId = dbResult.lastInsertRowid;
if (!imageId || imageId.toString() === '0') {
const existing = imageRepository.getByPath(relPath);
if (existing) imageId = existing.id;
}
if (imageId) {
clipService.addToQueue({ id: imageId, rel_path, filename: fileInfo.filename });
}
} catch (queueErr) {
console.error("Queue error:", queueErr);
}
// 记录上传统计信息
imageRepository.recordUpload(size);
// 使用 helper 格式化
const formatted = formatImageResponse(req, imageRepository.getByPath(relPath) || {
filename: fileInfo.filename,
rel_path: relPath,
width: metadata.width,
height: metadata.height,
size: size,
upload_time: fileInfo.upload_time,
mime_type: mimetype,
thumbhash: metadata.thumbhash
});
res.json({
success: true,
message: "base64 图片上传成功",
data: {
...formatted,
originalName: originalName,
mimetype: mimetype
}
});
} catch (error) {
console.error("base64 上传错误:", error);
return res.status(400).json({ success: false, error: error.message || "base64 图片处理失败" });
}
});
// 1.0 URL 上传
router.post('/upload-url', requirePassword, async (req, res) => {
try {
const { url } = req.body;
if (!url) {
return res.status(400).json({ success: false, error: "缺少 url 参数" });
}
let dir = req.body.dir || "";
dir = dir.replace(/\\/g, "/");
// Download image from URL
let imageData;
try {
imageData = await downloadFromUrl(url);
} catch (downloadErr) {
return res.status(400).json({ success: false, error: `下载图片失败: ${downloadErr.message}` });
}
// Convert to base64 and save
const base64Data = `data:${imageData.mimetype};base64,${imageData.buffer.toString('base64')}`;
const { filename, filePath, size, mimetype } = await saveBase64Image(base64Data, dir);
const relPath = path.join(dir, filename).replace(/\\/g, "/");
// Generate metadata
const metadata = await getFileMetadata(filePath, relPath);
// Extract original name from URL if possible
const urlPathname = new URL(url).pathname;
const urlFilename = decodeURIComponent(urlPathname.split('/').pop() || filename);
const ext = path.extname(urlFilename);
const nameWithoutExt = path.basename(urlFilename, ext);
const originalName = ext ? `${nameWithoutExt}${ext}` : filename;
const dbResult = imageRepository.add({
filename: sanitizeFilename(originalName),
rel_path: relPath,
...metadata
});
// Add to magic search queue
try {
let imageId = dbResult.lastInsertRowid;
if (!imageId || imageId.toString() === '0') {
const existing = imageRepository.getByPath(relPath);
if (existing) imageId = existing.id;
}
if (imageId) {
clipService.addToQueue({ id: imageId, rel_path: relPath, filename: originalName });
}
} catch (queueErr) {
console.error("Queue error:", queueErr);
}
// Record upload stats
imageRepository.recordUpload(size);
const formatted = formatImageResponse(req, imageRepository.getByPath(relPath) || {
filename: originalName,
rel_path: relPath,
width: metadata.width,
height: metadata.height,
size: size,
upload_time: metadata.upload_time,
mime_type: mimetype,
thumbhash: metadata.thumbhash
});
res.json({
success: true,
message: "URL 图片上传成功",
data: {
...formatted,
originalName: originalName,
mimetype: mimetype
}
});
} catch (error) {
console.error("URL 上传错误:", error);
return res.status(500).json({ success: false, error: error.message || "URL 图片上传失败" });
}
});
// 1.1 上传图片 (Multer)
router.post('/upload', requirePassword, upload.any(), handleMulterError, async (req, res) => {
try {
let dir = req.body.dir || req.query.dir || "";
dir = dir.replace(/\\/g, "/");
if (req.files && req.files.length > 0) req.file = req.files[0];
if (!req.file) return res.status(400).json({ success: false, error: "没有选择文件" });
// 如果需要移动文件(multer storage 逻辑基本已处理,但需再次检查?)
// 自定义 multer storage 已经将其放置在正确的目录和名称下。
// 所以 req.file.path 是正确的。
const relPath = path.join(dir, req.file.filename).replace(/\\/g, "/");
// 元数据与数据库
const metadata = await getFileMetadata(req.file.path, relPath);
// 原始名称处理
let originalName = req.file.originalname;
if (!/[^\u0000-\u00ff]/.test(originalName)) {
try { originalName = Buffer.from(originalName, "latin1").toString("utf8"); } catch (e) { }
}
const dbResult = imageRepository.add({
filename: req.file.filename, // 这是磁盘上的保存文件名
rel_path: relPath,
...metadata
});
// 检查是否覆盖了现有文件,如果是则清除 sharp 缓存
const forceOverwrite =
req.query.overwrite === "true" ||
req.body?.overwrite === "true" ||
req.query.overwrite === true ||
req.body?.overwrite === true;
if (forceOverwrite) {
try {
// 清除 sharp 缓存以确保下次访问读取新文件
sharp.cache(false);
sharp.cache(true);
} catch (e) { }
}
// 添加到魔法搜图队列
try {
let imageId = dbResult.lastInsertRowid;
if (!imageId || imageId.toString() === '0') {
const existing = imageRepository.getByPath(relPath);
if (existing) imageId = existing.id;
}
if (imageId) {
clipService.addToQueue({ id: imageId, rel_path: relPath, filename: req.file.filename }, 'high');
}
} catch (queueErr) {
console.error("Queue error:", queueErr);
}
// 记录上传统计信息
imageRepository.recordUpload(req.file.size);
// Helper
const formatted = formatImageResponse(req, {
filename: req.file.filename,
rel_path: relPath,
width: metadata.width,
height: metadata.height,
size: req.file.size,
upload_time: metadata.upload_time,
mime_type: req.file.mimetype,
thumbhash: metadata.thumbhash
});
res.json({
success: true,
message: "图片上传成功",
data: {
...formatted,
originalName: originalName,
mimetype: req.file.mimetype
}
});
} catch (error) {
console.error("上传错误:", error);
res.status(500).json({ success: false, error: "上传失败,请稍后重试" });
}
});
// 1.2 上传文件 (任意)
router.post('/upload-file', requirePassword, uploadAny.single("file"), handleMulterError, async (req, res) => {
try {
if (!req.file) return res.status(400).json({ success: false, error: "没有选择文件" });
let dir = req.body.dir || req.query.dir || "";
dir = dir.replace(/\\/g, "/");
// ... (来自原始 index.js 的重命名逻辑) ...
// 我将在此处实现重命名逻辑,还是仅依赖 multer?
// Multer 处理了基本命名。`upload-file` 具有自定义的“手动重命名”逻辑。
// 我需要手动移植该逻辑。
const customFilename = req.body.filename || req.query.filename;
let finalFilename = req.file.filename;
let displayName = req.file.originalname;
if (customFilename) {
// 重命名逻辑...
const safeCustom = sanitizeFilename(customFilename);
const targetDir = safeJoin(STORAGE_PATH, dir);
const oldPath = req.file.path;
let newPath = path.join(targetDir, safeCustom);
// 重复检查
let counter = 1;
const ext = path.extname(safeCustom);
const nameBase = path.basename(safeCustom, ext);
if (!config.upload.allowDuplicateNames) {
while (fs.existsSync(newPath)) {
if (config.upload.duplicateStrategy === 'timestamp') {
newPath = path.join(targetDir, `${nameBase}_${Date.now()}_${counter}${ext}`);
} else {
newPath = path.join(targetDir, `${nameBase}_${counter}${ext}`);
}
counter++;
}
}
finalFilename = path.basename(newPath);
displayName = customFilename;
if (oldPath !== newPath) {
fs.renameSync(oldPath, newPath);
}
}
const relPath = path.join(dir, finalFilename).replace(/\\/g, "/");
const filePath = safeJoin(STORAGE_PATH, relPath);
// 检查我们是否应该索引它
const ext = path.extname(finalFilename).toLowerCase();
// 仅在匹配“图片列表”的允许扩展名时索引
// 如果用户上传了允许图片之外的通用文件,我们将其保留在磁盘上
// 但不添加到 DB。
if (config.upload.allowedExtensions.includes(ext)) {
const metadata = await getFileMetadata(filePath, relPath);
imageRepository.add({
filename: finalFilename,
rel_path: relPath,
...metadata
});
}
// 时长逻辑
let duration = null;
if (req.file.mimetype === 'audio/mpeg' || (customFilename && customFilename.toLowerCase().endsWith('.mp3'))) {
try {
// 我们可以使用 metadataService 中的逻辑!
const d = await parseAudioDuration(filePath);
if (d) duration = parseFloat((Math.ceil(d * 1000) / 1000).toFixed(2));
} catch (e) { }
}
// 记录上传统计信息
imageRepository.recordUpload(req.file.size);
const isImage = config.upload.allowedExtensions.includes(path.extname(finalFilename).toLowerCase());
const relPathStr = relPath.split("/").map(encodeURIComponent).join("/");
const endpoint = isImage ? 'images' : 'files';
const url = `/api/${endpoint}/${relPathStr}`;
const fullUrl = `${req.protocol}://${req.get('host')}${url}`;
res.json({
success: true,
message: "文件上传成功",
data: {
filename: finalFilename,
originalName: displayName,
size: req.file.size,
mimetype: req.file.mimetype,
uploadTime: new Date().toISOString(),
url: url,
relPath,
fullUrl: fullUrl, // Standardized field
...(duration && { duration })
}
});
} catch (error) {
console.error("文件上传错误:", error);
res.status(500).json({ success: false, error: "文件上传失败" });
}
});
module.exports = router;
================================================
FILE: server/services/clipService.js
================================================
const config = require('../../config');
const db = require('../db/database');
const path = require('path');
const fs = require('fs-extra');
let Pipeline = null;
class ClipService {
constructor() {
this.modelName = config.magicSearch.modelName || 'Xenova/clip-vit-base-patch32';
this.tokenizer = null;
this.processor = null;
this.model = null;
this.visionModel = null;
this.textModel = null;
this.translator = null;
this.pipeline = null;
// 队列状态
this.queue = [];
this.processing = false;
this.queueInterval = 2000; // 每个项目之间延迟 2 秒,为 N100 留出呼吸空间
}
static getInstance() {
if (!ClipService.instance) {
ClipService.instance = new ClipService();
}
return ClipService.instance;
}
async getModels() {
if (this.processor && this.tokenizer && this.visionModel && this.textModel) {
return {
processor: this.processor,
tokenizer: this.tokenizer,
visionModel: this.visionModel,
textModel: this.textModel
};
}
console.log(`[MagicSearch] Loading model components: ${this.modelName}...`);
try {
// Dynamic import for ESM module
const {
AutoProcessor,
AutoTokenizer,
CLIPVisionModelWithProjection,
CLIPTextModelWithProjection,
RawImage,
env
} = await import('@huggingface/transformers');
this.RawImage = RawImage;
// 配置为使用本地缓存
env.cacheDir = path.resolve(__dirname, '../../.cache/huggingface');
env.allowLocalModels = false;
env.useBrowserCache = false;
// 允许自定义 HuggingFace 端点 (用于国内镜像,如 https://hf-mirror.com)
if (process.env.HF_ENDPOINT) {
env.remoteHost = process.env.HF_ENDPOINT;
console.log(`[MagicSearch] Using custom HF endpoint: ${env.remoteHost}`);
}
// 加载组件
this.processor = await AutoProcessor.from_pretrained(this.modelName);
this.tokenizer = await AutoTokenizer.from_pretrained(this.modelName);
this.visionModel = await CLIPVisionModelWithProjection.from_pretrained(this.modelName, {
quantized: true,
dtype: 'q8', // 显式指定量化类型,消除 N100 上的 fp32 警告
});
this.textModel = await CLIPTextModelWithProjection.from_pretrained(this.modelName, {
quantized: true,
dtype: 'q8', // 显式指定量化类型,消除 N100 上的 fp32 警告
});
console.log(`[MagicSearch] Models loaded successfully.`);
return {
processor: this.processor,
tokenizer: this.tokenizer,
visionModel: this.visionModel,
textModel: this.textModel
};
} catch (error) {
console.error(`[MagicSearch] Failed to load models:`, error);
throw error;
}
}
normalize(vector) {
const magnitude = Math.sqrt(vector.reduce((sum, val) => sum + val * val, 0));
return vector.map(val => val / magnitude);
}
// 生成文本嵌入
async getTextEmbedding(text) {
const { tokenizer, textModel } = await this.getModels();
// 分词
const inputs = await tokenizer([text], { padding: true, truncation: true });
// 推理
const { text_embeds } = await textModel(inputs);
// 输出为 Tensor,需要转换为数组并归一化
// text_embeds 为 [batch_size, embed_dim] -> [1, 512]
const rawEmbedding = Array.from(text_embeds.data);
return this.normalize(rawEmbedding);
}
// 生成图片嵌入
async getImageEmbedding(imagePath) {
const { processor, visionModel } = await this.getModels();
try {
// 读取图片并处理
const image = await this.RawImage.read(imagePath);
const image_inputs = await processor(image);
// 推理
const { image_embeds } = await visionModel(image_inputs);
// 转换并归一化
const rawEmbedding = Array.from(image_embeds.data);
return this.normalize(rawEmbedding);
} catch (e) {
console.error(`[MagicSearch] Image processing failed for ${imagePath}:`, e);
throw e;
}
}
// 添加图片到处理队列
// 优先级: 'high' (上传) -> unshift (前), 'low' (历史) -> push (后)
addToQueue(image, priority = 'low') {
if (!config.magicSearch.enabled) return;
// 去重:检查图片是否已在队列中
if (this.queue.find(item => item.id === image.id)) return;
if (priority === 'high') {
this.queue.unshift(image);
console.log(`[MagicSearch] Added image ${image.id} (High Priority) to queue. Queue size: ${this.queue.length}`);
} else {
this.queue.push(image);
console.log(`[MagicSearch] Added image ${image.id} (Low Priority) to queue. Queue size: ${this.queue.length}`);
}
this.processQueue();
}
// 处理队列
async processQueue() {
if (this.processing || this.queue.length === 0) return;
this.processing = true;
while (this.queue.length > 0) {
const image = this.queue.shift();
try {
await this.processImage(image);
} catch (err) {
console.error(`[MagicSearch] Error processing image ${image.id}:`, err);
}
// 等待片刻让 CPU 喘口气 (N100 优化)
if (this.queue.length > 0) {
await new Promise(resolve => setTimeout(resolve, this.queueInterval));
}
}
this.processing = false;
}
async processImage(image) {
// 过滤非图片文件 (如视频)
const ext = path.extname(image.rel_path).toLowerCase();
const supportedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.avif'];
if (!supportedExts.includes(ext)) {
// console.log(`[MagicSearch] Skipping unsupported file type: ${image.filename}`);
return;
}
const imagePath = path.resolve(config.storage.path, image.rel_path);
if (!fs.existsSync(imagePath)) {
console.warn(`[MagicSearch] File not found: ${imagePath}`);
return;
}
// 检查嵌入是否已存在
const existing = db.prepare('SELECT image_id FROM vec_images WHERE image_id = ?').get(image.id);
if (existing) {
// console.log(`[MagicSearch] Embedding already exists for ${image.id}, skipping.`);
return;
}
// console.log(`[MagicSearch] Generating embedding for ${image.filename}...`);
const embedding = await this.getImageEmbedding(imagePath);
// 保存到向量数据库
// sqlite-vec 期望原始 float32 字节数组或特定处理
// better-sqlite3 with sqlite-vec extension usually handles Float32Array directly if registered,
// otherwise we might need to serialize.
// The `vec0` virtual table accepts JSON array string or binary blob.
// Let's try passing Float32Array directly (better-sqlite3 typed array support)
// or JSON string as fallback.
// IMPORTANT: sqlite-vec usually requires the embedding to be inserted.
// Ensure we are using transaction if batching, but here is single.
try {
// 准备插入语句
// 对于 vec0,标准 INSERT INTO 有效
// 我们使用 JSON.stringify(embedding) 作为首次尝试以确保安全,
// 因为 better-sqlite3 可能会将数组绑定为其他类型
// 但 vec0 支持 JSON 文本输入
// sqlite-vec 期望原始 float32 字节数组或有效的 JSON
// 我们使用 'image_id',它是主键
const stmt = db.prepare("INSERT INTO vec_images(image_id, embedding) VALUES (?, ?)");
// 调试 ID
// console.log(`[MagicSearch] Inserting ${image.id} (type: ${typeof image.id})`);
// 使用 BigInt 作为 ID 以匹配 INTEGER 亲和性,并使用 Float32Array 作为嵌入
stmt.run(BigInt(image.id), new Float32Array(embedding));
// console.log(`[MagicSearch] Saved embedding for ${image.id}`);
} catch (dbErr) {
console.error(`[MagicSearch] DB Insert Error for ${image.id}:`, dbErr);
}
}
// 清除所有嵌入并重新扫描
async reindex() {
if (!config.magicSearch.enabled) return { success: false, message: "Magic Search disabled" };
console.log("[MagicSearch] Reindexing requested. Clearing vector table...");
try {
db.prepare("DELETE FROM vec_images").run();
console.log("[MagicSearch] Vector table cleared.");
} catch (e) {
console.error("[MagicSearch] Failed to clear vector table:", e);
throw e;
}
return this.scanAll();
}
// 触发扫描所有没有嵌入的图片
async scanAll() {
if (!config.magicSearch.enabled) return { success: false, message: "Magic Search disabled" };
console.log("[MagicSearch] Starting background scan for missing embeddings...");
// 在 `images` 表中查找不存在于 `vec_images` 中的所有图片
const images = db.prepare(`
SELECT i.id, i.filename, i.rel_path
FROM images i
LEFT JOIN vec_images v ON i.id = v.image_id
WHERE v.image_id IS NULL
`).all();
console.log(`[MagicSearch] Found ${images.length} historical images to process.`);
// 全部添加到队列 (低优先级) - 仅过滤支持的图片
const supportedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.avif'];
let queuedCount = 0;
for (const img of images) {
const ext = path.extname(img.rel_path).toLowerCase();
if (supportedExts.includes(ext)) {
this.addToQueue(img, 'low');
queuedCount++;
}
}
console.log(`[MagicSearch] Queued ${queuedCount} images for background processing.`);
return { success: true, count: queuedCount };
}
// 加载翻译模型 (懒加载)
async getTranslator() {
if (this.translator) return this.translator;
console.log(`[MagicSearch] Loading translation model (opus-mt-zh-en)...`);
try {
const { pipeline, env } = await import('@huggingface/transformers');
env.cacheDir = path.resolve(__dirname, '../../.cache/huggingface');
if (process.env.HF_ENDPOINT) {
env.remoteHost = process.env.HF_ENDPOINT;
}
this.translator = await pipeline('translation', 'Xenova/opus-mt-zh-en', {
// 开启量化,显著降低 N100 的内存压力和 CPU 占用
quantized: true,
dtype: 'q8', // 显式指定 q8 类型,消除 fp32 警告
});
console.log(`[MagicSearch] Translation model loaded.`);
return this.translator;
} catch (e) {
console.error(`[MagicSearch] Failed to load translator:`, e);
return null;
}
}
// 按需翻译
async translate(text) {
// 简单检查是否包含中文字符
if (!/[\u4e00-\u9fa5]/.test(text)) return text;
try {
const translator = await this.getTranslator();
if (!translator) return text;
const output = await translator(text, {
max_new_tokens: 40,
temperature: 0.1 // 降低随机性,让翻译更准确
});
// Output format: [{ translation_text: '...' }]
if (output && output[0] && output[0].translation_text) {
const translated = output[0].translation_text;
console.log(`[MagicSearch] Translated: "${text}" -> "${translated}"`);
return translated;
}
} catch (e) {
console.warn(`[MagicSearch] Translation failed for "${text}":`, e);
}
return text;
}
// 语义搜索
async search(queryText, limit = 50) {
if (!config.magicSearch.enabled) return [];
try {
// 自动翻译中文查询
const finalQuery = await this.translate(queryText);
const embedding = await this.getTextEmbedding(finalQuery);
// 查询向量数据库
// 我们联接回 images 表以获取文件详情
const results = db.prepare(`
SELECT
i.*,
vec_distance_cosine(v.embedding, ?) as distance
FROM vec_images v
JOIN images i ON v.image_id = i.id
ORDER BY distance
LIMIT ?
`).all(JSON.stringify(embedding), limit);
return results;
} catch (e) {
console.error("[MagicSearch] Search failed:", e);
throw e;
}
}
}
module.exports = ClipService.getInstance();
================================================
FILE: server/services/metadataService.js
================================================
const exifr = require("exifr");
const mm = require("music-metadata");
const fs = require("fs-extra");
const path = require("path");
const sharp = require('sharp');
sharp.cache(false);
const { getThumbHash, generateThumbHash } = require("../utils/fileUtils");
async function parseImageMetadata(filePath) {
try {
const meta = await exifr.parse(filePath, {
gps: true,
tiff: true,
ifd0: true,
exif: true
});
return meta || {};
} catch (e) {
return {};
}
}
async function parseAudioDuration(filePath) {
try {
const metadata = await mm.parseFile(filePath, { duration: true });
return metadata.format.duration;
} catch (error) {
// console.error('解析音频时长失败:', error);
return null;
}
}
async function parseVideoDuration(filePath) {
return parseAudioDuration(filePath);
}
// 组合所有信息以返回标准化的 DB 对象
async function getFileMetadata(filePath, relPath, existingStat = null) {
const stat = existingStat || await fs.stat(filePath);
const ext = path.extname(filePath).toLowerCase();
let width = null;
let height = null;
let orientation = null;
let metaJson = {};
let duration = null;
// 图片元数据 (Sharp 用于获取可靠的统计信息 + Exifr 用于获取详细信息)
const IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.webp', '.tiff', '.tif', '.heif', '.heic', '.avif', '.gif', '.svg'];
if (IMAGE_EXTS.includes(ext)) {
// 1. Sharp 提取 (可靠的尺寸和空间信息)
try {
const sharpMeta = await sharp(filePath).metadata();
width = sharpMeta.width;
height = sharpMeta.height;
metaJson.space = sharpMeta.space; // srgb, cmyk, 等
metaJson.channels = sharpMeta.channels;
metaJson.density = sharpMeta.density;
metaJson.format = sharpMeta.format;
metaJson.hasAlpha = sharpMeta.hasAlpha;
orientation = sharpMeta.orientation; // Sharp 通常会标准化,但我们保留它
} catch (e) {
// console.log("Sharp metadata failed:", e.message);
}
// 2. EXIF 提取 (照片详细信息)
// 仅适用于通常支持 EXIF 的格式
if (['.jpg', '.jpeg', '.png', '.webp', '.tiff', '.heif', '.heic'].includes(ext)) {
const exif = await parseImageMetadata(filePath);
if (exif) {
metaJson.exif = {};
if (exif.latitude && exif.longitude) {
metaJson.gps = { lat: exif.latitude, lng: exif.longitude };
metaJson.exif.latitude = exif.latitude;
metaJson.exif.longitude = exif.longitude;
}
// 日期
if (exif.DateTimeOriginal || exif.CreateDate) {
metaJson.date = exif.DateTimeOriginal || exif.CreateDate;
metaJson.exif.dateTimeOriginal = metaJson.date;
}
// 相机规格
if (exif.Make) metaJson.exif.make = exif.Make;
if (exif.Model) metaJson.exif.model = exif.Model;
if (exif.LensModel) metaJson.exif.lensModel = exif.LensModel;
if (exif.FNumber) metaJson.exif.fNumber = exif.FNumber;
if (exif.ExposureTime) metaJson.exif.exposureTime = exif.ExposureTime;
if (exif.ISO) metaJson.exif.iso = exif.ISO;
// 如果 sharp 失败,回退到 exif 尺寸
if (!width && exif.ExifImageWidth) width = exif.ExifImageWidth;
if (!height && exif.ExifImageHeight) height = exif.ExifImageHeight;
if (!orientation && exif.Orientation) orientation = exif.Orientation;
}
}
}
// 音频/视频时长
if (EXT_AUDIO.includes(ext) || EXT_VIDEO.includes(ext)) {
duration = await parseAudioDuration(filePath);
if (duration) metaJson.duration = duration;
}
// Thumbhash
let thumbhash = await getThumbHash(filePath);
if (!thumbhash && IMAGE_EXTS.includes(ext)) {
// 尝试生成,如果失败则忽略
try {
thumbhash = await generateThumbHash(filePath);
} catch (e) { }
}
// 优先使用 EXIF 日期作为 upload_time (用户请求: 智能日期提取)
// 如果我们有实际的照片拍摄日期,请使用它而不是文件创建时间 (复制/移动时会重置)
let uploadTime = stat.birthtime;
if (metaJson.date) {
const exifDate = new Date(metaJson.date);
const exifTime = exifDate.getTime();
// 检查 EXIF 日期是否有效
// 如果日期是 1970年附近(epoch 0)或之前,说明没有有效的拍照时间,使用文件创建时间
const minValidDate = new Date('1980-01-01').getTime(); // 假设照片不会早于1980年
if (exifTime > minValidDate) {
uploadTime = metaJson.date;
} else {
// EXIF 日期无效,使用文件创建时间替代
console.log(`Invalid EXIF date detected (${exifDate.toISOString()}), using file birthtime instead`);
}
}
return {
size: stat.size,
mtime: stat.mtime.getTime(),
upload_time: uploadTime instanceof Date ? uploadTime.toISOString() : new Date(uploadTime).toISOString(),
width,
height,
orientation,
thumbhash,
meta_json: JSON.stringify(metaJson)
};
}
const EXT_AUDIO = ['.mp3', '.wav', '.ogg', '.m4a', '.flac'];
const EXT_VIDEO = ['.mp4', '.webm', '.mov', '.avi', '.mkv'];
module.exports = {
getFileMetadata,
parseImageMetadata,
parseAudioDuration
};
================================================
FILE: server/services/syncService.js
================================================
const fs = require('fs-extra');
const path = require('path');
const config = require('../../config');
const imageRepository = require('../db/imageRepository');
const { getFileMetadata } = require('./metadataService');
const { CACHE_DIR_NAME, safeJoin } = require('../utils/fileUtils');
const STORAGE_PATH = config.storage.path;
const CONFIG_DIR_NAME = "config";
const TRASH_DIR_NAME = ".trash";
const LEGACY_CACHE_PATH = path.join(STORAGE_PATH, CACHE_DIR_NAME, "img_metadata.json");
async function migrateFromLegacyJson() {
if (imageRepository.count() > 0) {
console.log("Database not empty, skipping JSON migration.");
return;
}
if (!await fs.pathExists(LEGACY_CACHE_PATH)) {
console.log("No legacy metadata file found.");
return;
}
console.log("Migrating from legacy img_metadata.json...");
try {
const rawData = await fs.readJson(LEGACY_CACHE_PATH);
const imagesToInsert = [];
// legacy data format: object where values are image objects
// or array? The code said `Object.values(newCache)` so the file is likely a map: { "rel/path": { ... } }
const items = Array.isArray(rawData) ? rawData : Object.values(rawData);
for (const item of items) {
// Adapt legacy fields to new Schema
const metaJson = {};
if (item.lat && item.lng) {
metaJson.gps = { lat: item.lat, lng: item.lng };
}
if (item.date) {
metaJson.date = item.date;
}
imagesToInsert.push({
filename: item.filename,
rel_path: item.relPath,
size: 0, // Legacy might not have size, handled by sync later if needed, or we accept 0
mtime: item.lastModified || 0,
upload_time: item.date || new Date().toISOString(),
width: null, // Legacy didn't store dimensions explicitly often
height: null,
orientation: item.orientation,
thumbhash: item.thumbhash,
meta_json: JSON.stringify(metaJson)
});
}
if (imagesToInsert.length > 0) {
imageRepository.insertMany(imagesToInsert);
console.log(`Migrated ${imagesToInsert.length} images from JSON.`);
}
} catch (e) {
console.error("Migration failed:", e);
}
}
async function getAllFiles(dir) {
let results = [];
const absDir = safeJoin(STORAGE_PATH, dir);
try {
const files = await fs.readdir(absDir);
for (const file of files) {
if (file === CACHE_DIR_NAME || file === CONFIG_DIR_NAME || file === TRASH_DIR_NAME) continue;
const filePath = path.join(absDir, file);
const relPath = path.join(dir, file).replace(/\\/g, "/");
const stat = await fs.stat(filePath);
if (stat.isDirectory()) {
results = results.concat(await getAllFiles(relPath));
} else {
const ext = path.extname(file).toLowerCase();
if (config.upload.allowedExtensions.includes(ext)) {
results.push({
relPath,
filePath,
stat
});
}
}
}
} catch (e) {
// ignore
}
return results;
}
async function syncFileSystem() {
console.log("Starting file system sync...");
const diskFiles = await getAllFiles("");
const dbImages = imageRepository.getAll();
const diskMap = new Map(diskFiles.map(f => [f.relPath, f]));
const dbMap = new Map(dbImages.map(i => [i.rel_path, i]));
// 1. 磁盘上的文件但不在 DB 中(新增)
// 2. 磁盘上的文件在 DB 中(如果修改则更新)
for (const file of diskFiles) {
const dbEntry = dbMap.get(file.relPath);
if (!dbEntry) {
// 新文件
try {
const metadata = await getFileMetadata(file.filePath, file.relPath, file.stat);
imageRepository.add({
filename: path.basename(file.relPath),
rel_path: file.relPath,
...metadata
});
// console.log(`Synced new file: ${file.relPath}`);
} catch (e) {
console.error(`Failed to sync file ${file.relPath}`, e);
}
} else {
// 现有文件,检查 mtime
// 注意:dbEntry.mtime 来自 DB
if (Math.abs(dbEntry.mtime - file.stat.mtime.getTime()) > 1000) { // 1 秒容差
console.log(`Updating modified file: ${file.relPath}`);
try {
const metadata = await getFileMetadata(file.filePath, file.relPath, file.stat);
imageRepository.update({
filename: path.basename(file.relPath),
rel_path: file.relPath,
...metadata
});
} catch (e) { console.error(`Failed to update ${file.relPath}`, e); }
}
}
}
// 3. 在 DB 中但不在磁盘上(删除)
for (const img of dbImages) {
if (!diskMap.has(img.rel_path)) {
console.log(`Removing missing file from DB: ${img.rel_path}`);
imageRepository.delete(img.rel_path);
}
}
console.log("Sync completed.");
}
module.exports = {
migrateFromLegacyJson,
syncFileSystem
};
================================================
FILE: server/utils/albumUtils.js
================================================
const path = require('path');
const fs = require('fs-extra');
const config = require('../../config');
const { safeJoin, CACHE_DIR_NAME, CONFIG_DIR_NAME, TRASH_DIR_NAME } = require('./fileUtils');
const STORAGE_PATH = config.storage.path;
async function getAlbumPasswordPath(dirPath) {
const absDir = safeJoin(STORAGE_PATH, dirPath);
return path.join(absDir, "config", "album_password.json");
}
async function verifyAlbumPassword(dirPath, password) {
try {
const configPath = await getAlbumPasswordPath(dirPath);
if (await fs.pathExists(configPath)) {
const data = await fs.readJson(configPath);
return data.password === password;
}
return true;
} catch (e) {
return false;
}
}
async function isAlbumLocked(dirPath) {
try {
const configPath = await getAlbumPasswordPath(dirPath);
if (await fs.pathExists(configPath)) {
const data = await fs.readJson(configPath);
return !!data.password;
}
} catch (e) { }
return false;
}
async function getAllLockedDirectories() {
const lockedDirs = [];
async function scan(dir) {
const absDir = safeJoin(STORAGE_PATH, dir);
try {
const files = await fs.readdir(absDir);
for (const file of files) {
if (file === CACHE_DIR_NAME || file === CONFIG_DIR_NAME || file === TRASH_DIR_NAME) continue;
if (file.startsWith('.')) continue;
const filePath = path.join(absDir, file);
const stats = await fs.stat(filePath);
if (stats.isDirectory()) {
const relPath = path.join(dir, file).replace(/\\/g, "/");
if (await isAlbumLocked(relPath)) {
lockedDirs.push(relPath);
}
await scan(relPath);
}
}
} catch (e) { }
}
await scan("");
return lockedDirs;
}
module.exports = {
getAlbumPasswordPath,
verifyAlbumPassword,
isAlbumLocked,
getAllLockedDirectories
};
================================================
FILE: server/utils/fileUtils.js
================================================
const path = require('path');
const fs = require('fs-extra');
const sharp = require('sharp');
const config = require('../../config');
const CACHE_DIR_NAME = ".cache";
function safeJoin(base, target) {
const targetPath = path.resolve(base, target || "");
if (!targetPath.startsWith(path.resolve(base))) {
throw new Error("非法目录路径");
}
return targetPath;
}
function sanitizeFilename(filename) {
try {
if (filename.includes("%")) {
filename = decodeURIComponent(filename);
}
if (Buffer.isBuffer(filename)) {
filename = filename.toString("utf8");
}
if (config.storage.filename.sanitizeSpecialChars) {
filename = filename.replace(
/[<>:"/\\|?*]/g,
config.storage.filename.specialCharReplacement
);
}
return filename;
} catch (error) {
console.warn("文件名处理错误:", error);
return filename.replace(
/[<>:"/\\|?*]/g,
config.storage.filename.specialCharReplacement
);
}
}
async function generateThumbHash(filePath) {
try {
const dir = path.dirname(filePath);
const filename = path.basename(filePath);
const ext = path.extname(filename).toLowerCase();
if (['.mp4', '.webm'].includes(ext)) {
return null;
}
const cacheDir = path.join(dir, CACHE_DIR_NAME);
const cacheFile = path.join(cacheDir, `${filename}.th`);
await fs.ensureDir(cacheDir);
const image = sharp(filePath).resize(100, 100, { fit: 'inside' });
const { data, info } = await image
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const { rgbaToThumbHash } = await import("thumbhash");
const binaryHash = rgbaToThumbHash(info.width, info.height, data);
await fs.writeFile(cacheFile, Buffer.from(binaryHash));
return Buffer.from(binaryHash).toString('base64');
} catch (err) {
console.error(`Failed to generate thumbhash for ${filePath}:`, err);
return null;
}
}
async function getThumbHash(filePath) {
try {
const dir = path.dirname(filePath);
const filename = path.basename(filePath);
const cacheFile = path.join(dir, CACHE_DIR_NAME, `${filename}.th`);
if (await fs.pathExists(cacheFile)) {
const buffer = await fs.readFile(cacheFile);
return buffer.toString('base64');
}
return null;
} catch (err) {
return null;
}
}
async function saveBase64Image(base64Data, dir) {
const matches = base64Data.match(/^data:([A-Za-z-+\/]+);base64,(.+)$/);
if (!matches || matches.length !== 3) {
throw new Error('无效的 base64 图片格式');
}
const mimetype = matches[1];
if (!/^image\//.test(mimetype)) {
throw new Error('仅允许图片类型的 base64 上传');
}
const buffer = Buffer.from(matches[2], 'base64');
const ext = mimetype.split('/')[1] || 'png';
const filename = `${Date.now()}-${Math.floor(Math.random() * 1000)}.${ext}`;
const targetDir = safeJoin(config.storage.path, dir);
await fs.ensureDir(targetDir);
const filePath = path.join(targetDir, filename);
await fs.promises.writeFile(filePath, buffer);
return {
filename,
filePath,
size: buffer.length,
mimetype
};
}
async function downloadFromUrl(imageUrl) {
return new Promise((resolve, reject) => {
const protocol = imageUrl.startsWith('https') ? require('https') : require('http');
const urlObj = new URL(imageUrl);
const options = {
hostname: urlObj.hostname,
port: urlObj.port || (imageUrl.startsWith('https') ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
},
timeout: 30000
};
const req = protocol.request(options, (res) => {
// Handle redirects
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
downloadFromUrl(res.headers.location).then(resolve).catch(reject);
req.destroy();
return;
}
if (res.statusCode !== 200) {
reject(new Error(`下载失败: HTTP ${res.statusCode}`));
return;
}
const contentType = res.headers['content-type'] || '';
if (!contentType.startsWith('image/')) {
reject(new Error('URL 不是图片类型'));
return;
}
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const buffer = Buffer.concat(chunks);
resolve({
buffer,
mimetype: contentType
});
});
res.on('error', reject);
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error('下载超时'));
});
req.end();
});
}
module.exports = {
safeJoin,
sanitizeFilename,
generateThumbHash,
getThumbHash,
saveBase64Image,
downloadFromUrl,
CACHE_DIR_NAME,
TRASH_DIR_NAME: ".trash",
CONFIG_DIR_NAME: "config"
};
================================================
FILE: server/utils/urlUtils.js
================================================
const path = require('path');
/**
* Formats an image object for JSON response, ensuring fullUrl is an absolute URL.
* @param {Object} req - Express request object
* @param {Object} image - Image object (must have rel_path)
* @returns {Object} Formatted image object
*/
function formatImageResponse(req, image) {
// Basic validation
if (!image || !image.rel_path) return image;
const relPathStr = image.rel_path.split("/").map(encodeURIComponent).join("/");
const url = `/api/images/${relPathStr}`;
const fullUrl = `${req.protocol}://${req.get('host')}${url}`;
// Parse meta_json if it exists and is a string
let meta = {};
if (typeof image.meta_json === 'string') {
try {
meta = JSON.parse(image.meta_json);
} catch (e) { }
} else if (typeof image.meta_json === 'object') {
meta = image.meta_json;
}
return {
// Standard fields
filename: image.filename,
relPath: image.rel_path,
fullUrl: fullUrl, // Absolute URL
url: url, // Relative API URL
width: image.width,
height: image.height,
size: image.size,
uploadTime: image.upload_time,
mtime: image.mtime,
mime: image.mime_type, // Some places user mime_type
// Merge extra fields if present
...meta,
// Allow overriding or adding specific fields if they exist on the input object
// but were not in the standard list above (e.g. thumbhash)
thumbhash: image.thumbhash,
};
}
module.exports = {
formatImageResponse
};
================================================
FILE: start.sh
================================================
#!/bin/bash
echo "🚀 启动 云图 应用..."
# 检查 Node.js 是否安装
if ! command -v node &> /dev/null; then
echo "❌ Node.js 未安装,请先安装 Node.js"
exit 1
fi
# 检查 npm 是否安装
if ! command -v npm &> /dev/null; then
echo "❌ npm 未安装,请先安装 npm"
exit 1
fi
echo "📦 安装后端依赖..."
npm install
echo "📦 安装前端依赖..."
cd client && npm install && cd ..
echo "🔨 构建前端..."
cd client && npm run build && cd ..
echo "🌐 启动服务器..."
echo "✅ 应用已启动!"
echo "📍 访问地址: http://localhost:3001"
echo "🛑 按 Ctrl+C 停止服务"
npm start