打造一个自己的图床

465 阅读1分钟

实现一个自己的图床

服务端

nest

关键技术点

  • 搭建nest项目
  • node的fs模块的使用
  • nest项目的部署

环境

  • node (>= 10.13.0, v13版本除外)

安装

$ npm i -g @nestjs/cli
$ nest new file-server

开发

使用vscode打开项目,进入项目之后生成以下文件

$ nest g module file
$ nest g controller file
$ nest g s file

执行以下命令安装一些声明包

$ npm i @types/multer -D

在file.controller.ts中

import { FileService } from './file.service';
import {
  Controller,
  Post,
  UploadedFile,
  UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
@Controller('file')
export class FileController {
  constructor(private fileService: FileService) {}
  @Post('upload')
  @UseInterceptors(FileInterceptor('file'))
  upload(@UploadedFile() file: Express.Multer.File): string {
    return this.fileService.upload(file);
  }
}

在file.service.ts中

import { Injectable } from '@nestjs/common';

@Injectable()
export class FileService {
  upload(file: Express.Multer.File): string {
    console.log(file);
    return file.filename;
  }
}

封装两个方法,getPath用于获得文件写入路径和访问路径,getNanoId作为修改文件名,防止重名文件覆盖的问题

import * as path from 'path';
function getPath(fileName: string) {
  const year = new Date().getFullYear();
  const month = new Date().getMonth() + 1;
  const nanoFileName = `${getNanoId()}${fileName}`;
  const staticPath = path.join(
    __dirname,
    '../static',
    `/${year}`,
    `/${month}`,
    '/',
  );
  const serverPath = `http://1.15.165.67:3000/static/${year}/${month}/${nanoFileName}`;
  // console.log(fs.statSync(staticPath, { throwIfNoEntry: false }));

  if (!fs.statSync(staticPath, { throwIfNoEntry: false })) {
    fs.mkdirSync(staticPath, { recursive: true });
  }
  const result = {
    staticPath: path.join(staticPath, nanoFileName),
    serverPath: serverPath,
  };
  return result;
}
function getNanoId() {
  return Math.random().toString(36).substr(2, 9);
}

在service中使用

import { Injectable } from '@nestjs/common';
import * as fs from 'fs';
@Injectable()
export class FileService {
  upload(file: Express.Multer.File): string {
    console.log(file);
    const filePath = getPath(file.originalname);
    console.log(filePath);

    fs.writeFileSync(filePath.staticPath, file.buffer);
    return filePath.serverPath;
  }
}

文件写入成功后,要让项目可以托管静态文件

在main.ts中

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  // 文件托管
  app.useStaticAssets(join(__dirname, '/static'), {
    prefix: '/static/',
    // maxAge: '30d',
  });
  // 解决跨域问题
  app.enableCors();
  await app.listen(3000);
}
bootstrap();

可以试用postman或者apifox或其他工具测试接口

打包与部署

打包先执行npm run build

之后得到disk文件,将dist文件夹、package.json、package-lock.json文件夹上传到服务器

进入到对应目录,执行npm i --production

接下来可以使用node dist/main.js测试服务是否可以正常运行

使用pm2执行服务
npm i -g pm2
pm2 start dist/main.js --name fileServer
pm2 list

客户端

electron+vite+vue

关键技术点

  • 搭建electron项目
  • 实现文件上传的多种方式
  • 剪切板的使用

搭建项目

使用electron-vite-vue模板

npm create electron-vite // 选择vue
cd ./electron-vite-vue
npm i
npm i element-plus @element-plus/icons-vue axios
npm i sass -D

开发

引入基本的样式文件cssnormal.scss

#app {
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  width: 100%;
  height: 100%;
}


* {
  margin: 0;
  padding: 0;
}

button,
input {
  outline: none;
}

html,
body {
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
}

ul {
  list-style: none;
}

a {
  text-decoration: none;
}

img {
  vertical-align: top;
  border: none;
}

.clearf:after,
.clearf:before {
  content: '';
  display: block;
  clear: both;
}

在app.vue中

<style lang="scss">
@import './assets/css/cssnormal.scss';
</style>

引入element-plus的上传文件组件和图标组件,并略作修改

<template>
  <div class="upload-box">
    <el-upload
      class="upload-demo"
      drag
      action="#"
      multiple
    >
      <el-icon class="el-icon--upload"><upload-filled /></el-icon>
      <div class="el-upload__text">
        Drop file here or <em>click to upload</em>
      </div>
    </el-upload>
  </div>
</template>

<script setup lang="ts">
import { ElUpload, ElIcon } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
</script>

<style lang="scss">
@import './assets/css/cssnormal.scss';

.upload-box {
  width: 80%;
  margin: auto;
  padding-top: 40px;
  .el-upload-dragger {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: space-evenly;
    height: 300px;
  }
  .el-upload-list {
    display: none;
  } 
}
</style>

使用自定义接口实现上传文件

<template>
  <div class="upload-box">
    <el-upload
      class="upload-demo"
      drag
      :http-request="handleRequest"
      multiple
    >
      <el-icon class="el-icon--upload"><upload-filled /></el-icon>
      <div class="el-upload__text">
        Drop file here or <em>click to upload</em>
      </div>
    </el-upload>
  </div>
</template>
<script setup lang="ts">
import { ElUpload, ElIcon, UploadRequestOptions, ElMessage } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import axios from 'axios'
import { ref } from 'vue'
const handleRequest = (options: UploadRequestOptions) => {
  const { file } = options
  upload(file)
  return Promise.resolve('')
}
interface ImageItem {
  fileName: string
  url: string
}
const imageList = ref<ImageItem[]>([])
const upload = async (file: File) => {
  const formData = new FormData()
  formData.append('file', file)
  const res = await axios.post(
    '/file/upload', // 放自己服务器的接口
    formData,
    {
      headers: {
        'Content-Type': 'multipart/form-data',
      },
    }
  )
  console.log(res)
  if (res) {
    imageList.value.push({
      fileName: file.name,
      url: res.data,
    })
  }
}

</script>

讲获得的图片数组渲染到页面上

<template>
  <div class="container-box">
      
    <div class="image-list">
      <div class="image-item" v-for="item in imageList">
        <img :src="item.url" />
        <div class="imageItem-clipboard">
          <el-icon class="el-icon--upload" @click="handleCopy(item)"
            ><upload-filled
          /></el-icon>
        </div>
      </div>
    </div>
  </div>
</template>
<script setup lang="ts">
// ···
const handleCopy = (item: ImageItem) => {
  const mdUrl = `![${item.fileName}](${item.url})`
  clipboard.writeText(mdUrl)
  ElMessage.success(`复制成功,若失败请手动复制`)
}
</script>
<style lang="scss">
// ···
.image-list {
  display: flex;
  flex-wrap: wrap;
  width: 80%;
  margin: auto;
  .image-item {
    width: 200px;
    height: 200px;
    margin: 0 10px;
    text-align: center;

    img {
      width: 180px;
      height: 180px;
      object-fit: contain;
    }
    .imageItem-clipboard {
      .el-icon--upload {
        font-size: 20px;
        color: #666;
        cursor: pointer;
        &:hover {
          color: skyblue;
        }
      }
    }
  }
}
</style>

最后,实现直接粘贴即上传的功能

<template>
  <div @paste="handlePaste" class="container-box">
    
  </div>
</template>
<script setup lang="ts">


const handlePaste = (e: ClipboardEvent) => {
  console.log(e.clipboardData)

  const { items } = e.clipboardData as DataTransfer
  if (items.length === 0) {
    return
  }
  const file = items[0].getAsFile()
  if (!file) {
    return
  }
  upload(file)
}
</script>
<style lang="scss">

.container-box {
  width: 100%;
  height: 100%;
}
</style>

打包

可选:配置package.json中的author,LISCENSE中的Copyright,electron-builder.json5中的procductName及win中的icon

打包时执行命令npm run build,然后在/release/${版本号}内可以看到安装包,执行即可安装

扩展

  • 数据库或者本地数据库(indexdb)保存历史图片
  • md5计算图片是否已上传过
  • 缩略图
  • 小窗口拖拽
  • ···

一些链接

nest文档

node的fs模块

electron打包配置项

electron-vite-vue

element-plus

生成ico