用于图片上传到服务器

一个前后端一体的图片上传网站:

后端 (server.js,Node + Express + Multer)

  • POST /api/upload:接收图片,支持多文件、拖拽、进度反馈
  • GET /api/images:返回已上传图片列表
  • /uploads:对外提供已保存的图片访问
  • 安全限制:仅允许图片类型(jpg/png/gif/webp/bmp/svg)、单文件最大 20MB、用随机文件名存储避免覆盖
  • 文件落盘到项目内的 uploads/ 目录

前端 (public/)

  • 拖拽 / 点击上传,实时进度条
  • 上传后自动刷新画廊,缩略图网格展示,点击可查看原图
  • 干净的移动端自适应界面

server.js

复制代码
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');

const app = express();
const PORT = process.env.PORT || 3000;

// 上传目录:放在项目根目录下的 uploads 文件夹(也作为静态资源对外提供)
const uploadsDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadsDir)) {
  fs.mkdirSync(uploadsDir, { recursive: true });
}

// 磁盘存储 + 唯一文件名,避免覆盖
const storage = multer.diskStorage({
  destination: (req, file, cb) => cb(null, uploadsDir),
  filename: (req, file, cb) => {
    const ext = path.extname(file.originalname).toLowerCase() || '.bin';
    const name = crypto.randomBytes(16).toString('hex') + ext;
    cb(null, name);
  }
});

// 不限制文件类型,仅按大小限制(单文件最大 20MB)
const upload = multer({
  storage,
  limits: { fileSize: 8000 * 1024 * 1024 }
});

// 托管前端静态资源
app.use(express.static(path.join(__dirname, 'public')));
// 对外提供已上传图片
app.use('/uploads', express.static(uploadsDir));

// 上传接口:支持多文件,字段名 images
app.post('/api/upload', (req, res) => {
  upload.array('images', 30)(req, res, (err) => {
    if (err) {
      return res.status(400).json({ success: false, message: err.message });
    }
    if (!req.files || req.files.length === 0) {
      return res.status(400).json({ success: false, message: '未选择任何文件' });
    }
    const files = req.files.map(f => ({
      url: '/uploads/' + f.filename,
      name: f.originalname,
      size: f.size
    }));
    res.json({ success: true, files });
  });
});

// 列出已上传图片
app.get('/api/images', (req, res) => {
  fs.readdir(uploadsDir, (err, items) => {
    if (err) return res.status(500).json({ success: false, message: '读取失败' });
    const files = items
      .filter(f => !f.startsWith('.'))
      .map(f => ({
        url: '/uploads/' + f,
        name: f,
        size: fs.statSync(path.join(uploadsDir, f)).size
      }));
    res.json({ success: true, files });
  });
});

app.listen(PORT, () => {
  console.log(`图片上传服务已启动: http://localhost:${PORT}`);
});

app.js

复制代码
const dropzone = document.getElementById('dropzone');
const fileInput = document.getElementById('fileInput');
const progressWrap = document.getElementById('progressWrap');
const progressFill = document.getElementById('progressFill');
const progressText = document.getElementById('progressText');
const statusEl = document.getElementById('status');
const gallery = document.getElementById('gallery');
const refreshBtn = document.getElementById('refreshBtn');

function formatSize(bytes) {
  if (bytes < 1024) return bytes + ' B';
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
  return (bytes / 1024 / 1024).toFixed(2) + ' MB';
}

function setStatus(msg, type) {
  statusEl.textContent = msg;
  statusEl.className = 'status' + (type ? ' ' + type : '');
}

// 点击选择文件
dropzone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => {
  if (fileInput.files.length) uploadFiles(fileInput.files);
  fileInput.value = '';
});

// 拖拽
['dragenter', 'dragover'].forEach(evt =>
  dropzone.addEventListener(evt, e => {
    e.preventDefault();
    dropzone.classList.add('dragover');
  })
);
['dragleave', 'drop'].forEach(evt =>
  dropzone.addEventListener(evt, e => {
    e.preventDefault();
    dropzone.classList.remove('dragover');
  })
);
dropzone.addEventListener('drop', e => {
  const files = e.dataTransfer.files;
  if (files.length) uploadFiles(files);
});

function uploadFiles(fileList) {
  const files = Array.from(fileList);
  if (!files.length) {
    setStatus('未选择任何文件', 'err');
    return;
  }

  const form = new FormData();
  files.forEach(f => form.append('images', f));

  progressWrap.classList.remove('hidden');
  progressFill.style.width = '0%';
  progressText.textContent = '0%';
  setStatus('上传中...');

  const xhr = new XMLHttpRequest();
  xhr.open('POST', '/api/upload');

  xhr.upload.onprogress = e => {
    if (e.lengthComputable) {
      const pct = Math.round((e.loaded / e.total) * 100);
      progressFill.style.width = pct + '%';
      progressText.textContent = pct + '%';
    }
  };

  xhr.onload = () => {
    let data;
    try { data = JSON.parse(xhr.responseText); } catch { data = {}; }
    if (xhr.status === 200 && data.success) {
      setStatus(`上传成功,共 ${data.files.length} 张`, 'ok');
      setTimeout(() => progressWrap.classList.add('hidden'), 800);
      loadGallery();
    } else {
      setStatus('上传失败:' + (data.message || xhr.status), 'err');
    }
  };

  xhr.onerror = () => setStatus('网络错误,上传失败', 'err');
  xhr.send(form);
}

async function loadGallery() {
  try {
    const res = await fetch('/api/images');
    const data = await res.json();
    if (!data.success) return;
    renderGallery(data.files);
  } catch {
    gallery.innerHTML = '<p class="empty">加载失败</p>';
  }
}

const IMG_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'avif', 'ico'];
function isImage(name) {
  const ext = name.split('.').pop().toLowerCase();
  return IMG_EXT.includes(ext);
}

function renderGallery(files) {
  if (!files.length) {
    gallery.innerHTML = '<p class="empty">还没有文件,快上传一个吧~</p>';
    return;
  }
  gallery.innerHTML = files.map(f => {
    const thumb = isImage(f.name)
      ? `<img src="${f.url}" alt="${f.name}" loading="lazy" />`
      : `<div class="file-thumb">📄</div>`;
    return `
      <a class="card" href="${f.url}" target="_blank" title="${f.name}">
        ${thumb}
        <div class="info">
          <span class="name">${f.name}</span>
          <span>${formatSize(f.size)}</span>
        </div>
      </a>`;
  }).join('');
}

refreshBtn.addEventListener('click', loadGallery);
loadGallery();

style.css

复制代码
:root {
  --bg: #f5f7fa;
  --card: #ffffff;
  --primary: #2f6fed;
  --primary-dark: #1f56c4;
  --text: #1f2937;
  --muted: #6b7280;
  --border: #e5e7eb;
  --danger: #e23b3b;
  --success: #1f9d55;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  font-family: -apple-system, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
  background: var(--bg);
  color: var(--text);
}

.container {
  max-width: 880px;
  margin: 0 auto;
  padding: 32px 20px 64px;
}

header { text-align: center; margin-bottom: 28px; }
header h1 { margin: 0 0 6px; font-size: 28px; }
.subtitle { margin: 0; color: var(--muted); font-size: 14px; }

/* 上传区 */
.uploader { margin-bottom: 36px; }
.dropzone {
  background: var(--card);
  border: 2px dashed var(--border);
  border-radius: 14px;
  padding: 48px 20px;
  text-align: center;
  cursor: pointer;
  transition: border-color .2s, background .2s;
}
.dropzone:hover { border-color: var(--primary); }
.dropzone.dragover {
  border-color: var(--primary);
  background: #eef3ff;
}
.dropzone-inner .icon { font-size: 40px; margin-bottom: 8px; }
.hint { margin: 0 0 6px; font-size: 16px; }
.hint .link { color: var(--primary); text-decoration: underline; }
.meta { margin: 0; font-size: 12px; color: var(--muted); }

/* 进度条 */
.progress-wrap {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-top: 16px;
}
.progress-bar {
  flex: 1;
  height: 10px;
  background: var(--border);
  border-radius: 6px;
  overflow: hidden;
}
.progress-fill {
  height: 100%;
  width: 0%;
  background: var(--primary);
  transition: width .15s ease;
}
.progress-text { font-size: 13px; color: var(--muted); min-width: 38px; }

/* 状态提示 */
.status { margin-top: 14px; font-size: 14px; min-height: 20px; }
.status.ok { color: var(--success); }
.status.err { color: var(--danger); }

/* 画廊 */
.gallery-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 14px;
}
.gallery-head h2 { margin: 0; font-size: 18px; }
.btn-ghost {
  background: transparent;
  border: 1px solid var(--border);
  color: var(--muted);
  padding: 6px 14px;
  border-radius: 8px;
  cursor: pointer;
  font-size: 13px;
}
.btn-ghost:hover { border-color: var(--primary); color: var(--primary); }

.gallery {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 14px;
}
.gallery .empty {
  grid-column: 1 / -1;
  text-align: center;
  color: var(--muted);
  padding: 30px 0;
}
.card {
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 10px;
  overflow: hidden;
  box-shadow: 0 1px 3px rgba(0,0,0,.04);
}
.card img {
  width: 100%;
  height: 130px;
  object-fit: cover;
  display: block;
  background: #f0f0f0;
}
.card .file-thumb {
  width: 100%;
  height: 130px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 48px;
  background: #f0f0f0;
}
.card .info {
  padding: 8px 10px;
  font-size: 12px;
  color: var(--muted);
}
.card .info .name {
  color: var(--text);
  display: block;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  margin-bottom: 2px;
}

.hidden { display: none !important; }

index.html

复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>图片上传服务</title>
  <link rel="stylesheet" href="/style.css" />
</head>
<body>
  <div class="container">
    <header>
      <h1>📷 图片上传服务</h1>
      <p class="subtitle">拖拽或点击上传图片,自动保存到服务器</p>
    </header>

    <section class="uploader">
      <div id="dropzone" class="dropzone">
        <input type="file" id="fileInput" multiple hidden />
        <div class="dropzone-inner">
          <div class="icon">⬆️</div>
          <p class="hint">把图片拖到这里,或 <span class="link">点击选择</span></p>
          <p class="meta">支持任意文件类型,单文件最大 20MB</p>
        </div>
      </div>

      <div id="progressWrap" class="progress-wrap hidden">
        <div class="progress-bar"><div id="progressFill" class="progress-fill"></div></div>
        <span id="progressText" class="progress-text">0%</span>
      </div>

      <div id="status" class="status"></div>
    </section>

    <section class="gallery-section">
      <div class="gallery-head">
        <h2>已上传图片</h2>
        <button id="refreshBtn" class="btn-ghost">刷新</button>
      </div>
      <div id="gallery" class="gallery">
        <p class="empty">还没有图片,快上传一张吧~</p>
      </div>
    </section>
  </div>

  <script src="/app.js"></script>
</body>
</html>

启动:

node.exe server.js

相关推荐
迅猛龙办公室1 小时前
Python实现PM空气质量提醒
服务器·前端·python
2501_937767161 小时前
别手动上传:小团队内网文件自动归集方案选型与踩坑记录
运维
智恒百亿1 小时前
5090 八卡服务器踩坑实录:架构、多卡通信、PyTorch Distributed 一次说清楚
服务器·pytorch·架构
adinnet20261 小时前
条件路由与状态传递:自动化员工怎么“分情况办事“和“记上下文“
运维·自动化·github
cccccc语言我来了2 小时前
Linux (11) 进程控制
linux·运维·数据库
菜菜的院子2 小时前
WSL2 运行 Ubuntu 26.04 完整 GNOME 桌面(WSLg Wayland)
linux·运维·ubuntu·wsl
暖核2 小时前
从 CentOS 到 Ubuntu24.04 运维迁移手册:命令对照表 + 实操 + 避坑
linux·运维·centos
分布式存储与RustFS2 小时前
训练集放在 RustFS 上,怎么像本地盘一样快?用 Curvine 加一层 POSIX 缓存
运维·云原生·开源·对象存储·分布式存储·s3·性能基准
pride.li2 小时前
第12章 Python 教程-流程控制:循环结构
linux·服务器·python·算法