项目背景

前端开发过程中不可避免会用到图片、视频等多媒体物料,常见的处理方案通常会进行动静分离,将图片等资源放置在图床上,除了使用业界常用的图床资源,比如:七牛云、微博图床等,除了借助第三方图床外,我们也可以自己搭建一个图床,为团队业务开发提供更好的基础服务,提升开发体验及效率。本文旨在回顾总结下自建图床的前端部分实现方案,希望能够给有类似需求的同学一些借鉴和方案。

方案

前端部分架构选型,考虑到Vue3即将成为主版本,作为前端基建侧的应用,考虑想要使用Vue3全家桶来进行前端侧的相关实现,这里使用了vite(vue-template-ts)+vue3+vuex@next+vue-router@next的使用方案,也为vite的打包构建进行一步的技术预(cai)研(keng)。(ps:vite确实快,但是目前直接上工业环境还需要考量,还有不少坑,个人认为跨语言的前端工程化可能会是后续前端工程化的发展方向)

目录

  • src

    • assets
    • components

      • index.ts
      • Card.vue
      • Login.vue
      • Upload.vue
      • WrapperLayouts.vue
      • WrapperLogin.vue
      • WrapperUpload.vue
    • config

      • index.ts
      • menuMap.ts
      • routes.ts
    • layouts

      • index.ts
      • Aside.vue
      • Layouts.vue
      • Main.vue
      • Nav.vue
    • route

      • index.ts
    • store

      • index.ts
    • utils

      • index.ts
      • reg.ts
      • validate.ts
    • views

      • Page.vue
    • App.vue
    • index.scss
    • main.ts
    • vue-app-env.d.ts
  • index.html
  • tsconfig.json
  • vite.config.ts

实践

前端图床涉及到权限验证,对于获取图片不进行认证确认,而对于需要进行上传及删除图片操作会需要进行登录鉴权

源码

vue3中可以通过class以及template两种方案来书写,使用composition-api的方案,个人建议还是使用class-component更加舒服,也更像react的写法,这里夹杂使用了composition-api和options-api的使用,目前vue是兼容的,对于从vue2中过来的同学,可以逐步去适应composition-api的写法,然后逐步按照hooks的函数式的思路去进行前端的业务实现

vite.config.ts

vite构建相关的一些配置,可以根据项目需求进行环境配置

const path = require('path')
// vite.config.js # or vite.config.ts
console.log(path.resolve(__dirname, './src'))

module.exports = {
  alias: {
    // 键必须以斜线开始和结束
    '/@/': path.resolve(__dirname, './src'),
  },
  /**
   * 在生产中服务时的基本公共路径。
   * @default '/'
   */
  base: './',
  /**
   * 与“根”相关的目录,构建输出将放在其中。如果目录存在,它将在构建之前被删除。
   * @default 'dist'
   */
  outDir: 'dist',
  port: 3000,
  // 是否自动在浏览器打开
  open: false,
  // 是否开启 https
  https: false,
  // 服务端渲染
  ssr: false,
  // 引入第三方的配置
  //   optimizeDeps: {
  //     include: ["moment", "echarts", "axios", "mockjs"],
  //   },
  proxy: {
    // 如果是 /bff 打头,则访问地址如下
    '/bff/': {
      target: 'http://localhost:30096/',// 'http://10.186.2.55:8170/',
      changeOrigin: true,
      rewrite: (path) => path.replace(/^\/bff/, ''),
    }
  },
  optimizeDeps: {
    include: ['element-plus/lib/locale/lang/zh-cn', 'axios'],
  },
}

Page.vue

每个子项目页面的展示,只需要一个组件,进行不同的数据渲染即可

<template>
  <div class="page-header">
    <el-row>
      <el-col :span="12">
        <el-page-header
          :content="$route.fullPath.split('/').slice(2).join(' > ')"
          @back="handleBack"
        />
      </el-col>
      <el-col :span="12">
        <section class="header-button">
          <!-- <el-button class="folder-add" :icon="FolderAdd" @click="handleFolder" >新建文件夹</el-button> -->
          <el-button class="upload" :icon="Upload" type="success" @click="handleImage">上传图片</el-button>
        </section>
      </el-col>
    </el-row>
  </div>
  <div class="page">
    <el-row :gutter="10">
      <el-col v-for="(item, index) in cards" :xs="12" :sm="8" :md="6" :lg="4" :xl="4">
        <Card
          @next="handleRouteView(item.ext, item.name)"
          @delete="handleDelete"
          :name="item.name"
          :src="item.src"
          :ext="item.ext"
          :key="index"
        />
      </el-col>
    </el-row>
    <el-pagination
      layout="sizes, prev, pager, next, total"
      @size-change="handleSizeChange"
      @current-change="handlePageChange"
      :current-page.sync="pageNum"
      :page-size="pageSize"
      :total="total"
    ></el-pagination>
    <router-view />
  </div>
  <WrapperUpload ref="wrapper-upload" :headers="computedHeaders" />
  <WrapperLogin ref="wrapper-login" />
</template>

<script lang="ts">
import {
  defineComponent,
} from 'vue';
import { useRoute } from 'vue-router'
import {
  FolderAdd,
  Upload
} from '@element-plus/icons-vue'

import { Card, WrapperUpload, WrapperLogin } from '../components'

export default defineComponent({
  name: 'Page',
  components: {
    Card,
    WrapperUpload,
    WrapperLogin
  },
  props: {

  },
  setup() {
    return {
      FolderAdd,
      Upload
    }
  },
  data() {
    return {
      cards: [],
      total: 30,
      pageSize: 30,
      pageNum: 1,
      bucketName: '',
      prefix: '',

    }
  },
  watch: {
    $route: {
      immediate: true,
      handler(val) {
        console.log('val', val)
        if (val) {
          this.handleCards()
        }
      }
    }
  },
  methods: {
    handleBack() {
      this.$router.go(-1)
    },
    handleFolder() {

    },
    handleDelete(useName) {
      console.log('useName', useName)
      const [bucketName, ...objectName] = useName.split('/');
      console.log('bukcetName', bucketName);
      console.log('objectName', objectName.join('/'));
      if (sessionStorage.getItem('token')) {
        this.$http.post("/bff/imagepic/object/removeObject", {
          bucketName: bucketName,
          objectName: objectName.join('/')
        }, {
          headers: {
            'Authorization': sessionStorage.getItem('token'),
          }
        }).then(res => {
          console.log('removeObject', res)
          if (res.data.success) {
            this.$message.success(`${objectName.pop()}图片删除成功`);
            setTimeout(() => {
              this.$router.go(0)
            }, 100)

          } else {
            this.$message.error(`${objectName.pop()}图片删除失败,失败原因:${res.data.data}`)
          }
        })
      } else {
        this.$refs[`wrapper-login`].handleOpen()
      }
    },
    handleImage() {
      sessionStorage.getItem('token')
        ? this.$refs[`wrapper-upload`].handleOpen()
        : this.$refs[`wrapper-login`].handleOpen()
    },
    handleRouteView(ext, name) {
      // console.log('extsss', ext)
      if (ext == 'file') {
        console.log('$router', this.$router)

        console.log('$route.name', this.$route.name, this.$route.path)


        this.$router.addRoute(this.$route.name,
          {
            path: `:${name}`,
            name: name,
            component: () => import('./Page.vue')
          }
        )

        console.log('$router.options.routes', this.$router.options.routes)


        this.$router.push({
          path: `/page/${this.$route.params.id}/${name}`
        })
      } else {

      }
    },
    handlePageChange(val) {
      this.pageNum = val;
      this.handleCards();
    },
    handleSizeChange(val) {
      this.pageSize = val;
      this.handleCards();
    },
    handleCards() {
      this.cards = [];
      let [bucketName, prefix] = this.$route.path.split('/').splice(2);
      this.bucketName = bucketName;
      this.prefix = prefix;
      console.log('bucketName', bucketName, prefix)
      this.$http.post("/bff/imagepic/object/listObjects", {
        bucketName: bucketName,
        prefix: prefix ? prefix + '/' : '',
        pageSize: this.pageSize,
        pageNum: this.pageNum
      }).then(res => {
        console.log('listObjects', res.data)
        if (res.data.success) {
          this.total = res.data.data.total;
          if (prefix) {
            this.total -= 1;
            return res.data.data.lists.filter(f => f.name != prefix + '/')
          }
          return res.data.data.lists
        }
      }).then(data => {
        console.log('data', data)
        data.forEach(d => {
          // 当前目录下
          if (d.name) {
            this.$http.post('/bff/imagepic/object/presignedGetObject', {
              bucketName: bucketName,
              objectName: d.name
            }).then(url => {
              // console.log('url', url)
              if (url.data.success) {
                const ext = url.data.data.split('?')[0];
                // console.log('ext', ext)
                let src = '', ext_type = '';
                switch (true) {
                  case /\.(png|jpg|jpeg|gif|svg|webp)$/.test(ext):
                    src = url.data.data;
                    ext_type = 'image';
                    break;
                  case /\.(mp4)$/.test(ext):
                    src = 'icon_mp4';
                    ext_type = 'mp4';
                    break;
                  case /\.(xls)$/.test(ext):
                    src = 'icon_xls';
                    ext_type = 'xls';
                    break;
                  case /\.(xlsx)$/.test(ext):
                    src = 'icon_xlsx';
                    ext_type = 'xlsx';
                    break;
                  case /\.(pdf)$/.test(ext):
                    src = 'icon_pdf';
                    ext_type = 'pdf';
                    break;
                  default:
                    src = 'icon_unknow';
                    ext_type = 'unknown';
                    break;
                }


                this.cards.push({
                  name: d.name,
                  src: src,
                  ext: ext_type
                })
              }
            })
          } else {
            if (d.prefix) {
              const src = 'icon_file', ext_type = 'file';
              this.cards.push({
                name: d.prefix.slice(0, -1),
                src: src,
                ext: ext_type
              })
            }

          }

        })
      })
    }
  },
  computed: {
    computedHeaders: function () {
      console.log('this.$route.fullPath', this.$route.fullPath)
      return {
        'Authorization': sessionStorage.getItem('token'),
        'bucket': this.bucketName,
        'folder': this.$route.fullPath.split('/').slice(3).join('/')
      }
    }
  }
})
</script>

<style lang="scss">
@import "../index.scss";
.page-header {
  margin: 1rem;

  .header-info {
    display: flex;
    align-items: center;
    justify-content: space-between;
  }

  .header-button {
    display: flex;
    align-items: center;
    justify-content: right;

    .el-button.upload {
      background-color: $color-primary;
    }

    .el-button.upload:hover {
      background-color: lighten($color: $color-primary, $amount: 10%);
    }
  }
}

.page {
  margin: 1rem;
  height: 90vh;

  .el-row {
    height: calc(100% - 6rem);
    overflow-y: scroll;
  }

  .el-pagination {
    margin: 1rem 0;
  }
}
</style>

Login.vue

进行基础的登录/注册实现,可在外侧进行弹窗及嵌入的包裹,将业务逻辑与展现形式分离

<template>
  <div :class="loginClass">
    <section class="login-header">
      <span class="title">{{ title }}</span>
    </section>
    <section class="login-form">
      <template v-if="form == 'login'">
        <el-form
          ref="login-form"
          label-width="70px"
          label-position="left"
          :model="loginForm"
          :rules="loginRules"
        >
          <el-form-item
            :key="item.prop"
            v-for="item in loginFormItems"
            :label="item.label"
            :prop="item.prop"
          >
            <el-input
              v-model="loginForm[`${item.prop}`]"
              :placeholder="item.placeholder"
              :type="item.type"
            ></el-input>
          </el-form-item>
        </el-form>
      </template>
      <template v-else-if="form == 'register'">
        <el-form
          ref="register-form"
          label-width="100px"
          label-position="left"
          :model="registerForm"
          :rules="registerRules"
        >
          <el-form-item
            :key="item.prop"
            v-for="item in registerFormItems"
            :label="item.label"
            :prop="item.prop"
          >
            <el-input
              v-model="registerForm[`${item.prop}`]"
              :placeholder="item.placeholder"
              :type="item.type"
            ></el-input>
          </el-form-item>
        </el-form>
      </template>
    </section>
    <section class="login-select">
      <span class="change" v-if="form == 'login'" @click="isShow = true">修改密码</span>
      <span class="go" @click="handleGo(form)">{{ form == 'login' ? ' 去注册 >>' : ' 去登录 >>' }}</span>
    </section>
    <section class="login-button">
      <template v-if="form == 'login'">
        <el-button @click="handleLogin">登录</el-button>
      </template>
      <template v-else-if="form == 'register'">
        <el-button @click="handleRegister">注册</el-button>
      </template>
    </section>
  </div>
  <el-dialog v-model="isShow">
    <el-form
      ref="change-form"
      label-width="130px"
      label-position="left"
      :model="changeForm"
      :rules="changeRules"
    >
      <el-form-item
        :key="item.prop"
        v-for="item in changeFormItems"
        :label="item.label"
        :prop="item.prop"
      >
        <el-input
          v-model="changeForm[`${item.prop}`]"
          :placeholder="item.placeholder"
          :type="item.type"
        ></el-input>
      </el-form-item>
    </el-form>
    <div class="change-button">
      <el-button class="cancel" @click="isShow = false">取消</el-button>
      <el-button class="confirm" @click="handleConfirm" type="primary">确认</el-button>
    </div>
  </el-dialog>
</template>

<script lang="ts">
import {
  defineComponent
} from 'vue';

import { validatePwd, validateEmail, validateName, validatePhone } from '../utils/index';


export default defineComponent({
  name: 'Login',
  props: {
    title: {
      type: String,
      default: ''
    },
    border: {
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      form: 'login',
      isShow: false,
      loginForm: {
        phone: '',
        upwd: ''
      },
      loginRules: {
        phone: [
          {
            required: true,
            validator: validatePhone,
            trigger: 'blur',
          }
        ],
        upwd: [
          {
            validator: validatePwd,
            required: true,
            trigger: 'blur',
          }
        ]
      },
      loginFormItems: [
        {
          label: "手机号",
          prop: "phone",
          placeholder: '请输入手机号'
        },
        {
          label: "密码",
          prop: "upwd",
          placeholder: '',
          type: 'password'
        }
      ],
      registerForm: {
        name: '',
        tfs: '',
        email: '',
        phone: '',
        upwd: '',
        rpwd: ''
      },
      registerFormItems: [
        {
          label: "姓名",
          prop: "name",
          placeholder: ''
        },
        {
          label: "TFS账号",
          prop: "tfs",
          placeholder: ''
        },
        {
          label: "邮箱",
          prop: "email",
          placeholder: ''
        },
        {
          label: "手机号",
          prop: "phone",
          placeholder: ''
        },
        {
          label: "请输入密码",
          prop: "upwd",
          placeholder: '',
          type: 'password'
        },
        {
          label: "请确认密码",
          prop: "rpwd",
          placeholder: '',
          type: 'password'
        }
      ],
      registerRules: {
        name: [
          {
            validator: validateName,
            trigger: 'blur',
          }
        ],
        tfs: [
          {
            required: true,
            message: '请按要求输入tfs账号',
            trigger: 'blur',
          }
        ],
        email: [
          {
            required: true,
            validator: validateEmail,
            trigger: 'blur',
          }
        ],
        phone: [
          {
            required: true,
            validator: validatePhone,
            trigger: 'blur',
          }
        ],
        upwd: [
          {
            required: true,
            validator: validatePwd,
            trigger: 'blur',
          }
        ],
        rpwd: [
          {
            required: true,
            validator: validatePwd,
            trigger: 'blur',
          },
          {
            validator(rule: any, value: any, callback: any) {
              if (value != this.registerForm.upwd) {
                callback(new Error('输入的密码不同'))
              }
            },
            trigger: 'blur',
          }
        ],
      },
      changeForm: {
        phone: '',
        opwd: '',
        npwd: '',
        rpwd: ''
      },
      changeFormItems: [
        {
          label: "手机号",
          prop: "phone",
          placeholder: '请输入手机号'
        },
        {
          label: "请输入原始密码",
          prop: "opwd",
          placeholder: '',
          type: 'password'
        },
        {
          label: "请输入新密码",
          prop: "npwd",
          placeholder: '',
          type: 'password'
        },
        {
          label: "请重复新密码",
          prop: "rpwd",
          placeholder: '',
          type: 'password'
        }
      ],
      changeRules: {
        phone: [
          {
            required: true,
            validator: validatePhone,
            trigger: 'blur',
          }
        ],
        opwd: [
          {
            required: true,
            validator: validatePwd,
            trigger: 'blur',
          }
        ],
        npwd: [
          {
            required: true,
            validator: validatePwd,
            trigger: 'blur',
          }
        ],
        rpwd: [
          {
            required: true,
            validator: validatePwd,
            trigger: 'blur',
          },
          {
            validator(rule: any, value: any, callback: any) {
              if (value != this.changeForm.npwd) {
                callback(new Error('输入的密码不同'))
              }
            },
            trigger: 'blur',
          }
        ],
      }
    }
  },
  computed: {
    loginClass() {
      return this.border ? 'login login-unwrapper' : 'login login-wrapper'
    }
  },
  methods: {
    handleGo(form) {
      if (form == 'login') {
        this.form = 'register'
      } else if (form == 'register') {
        this.form = 'login'
      }
    },
    handleLogin() {
      this.$http.post("/bff/imagepic/auth/login", {
        phone: this.loginForm.phone,
        upwd: this.loginForm.upwd
      }).then(res => {
        if (res.data.success) {
          this.$message.success('登录成功');
          sessionStorage.setItem('token', res.data.data.token);
          this.$router.go(0);
        } else {
          this.$message.error(res.data.data.err);
        }
      })
    },
    handleRegister() {
      this.$http.post("/bff/imagepic/auth/register", {
        name: this.registerForm.name,
        tfs: this.registerForm.tfs,
        email: this.registerForm.email,
        phone: this.registerForm.phone,
        upwd: this.registerForm.upwd
      }).then(res => {
        if (res.data.success) {
          this.$message.success('注册成功');
        } else {
          this.$message.error(res.data.data.err);
        }
      })
    },
    handleConfirm() {
      this.$http.post("/bff/imagepic/auth/change", {
        phone: this.changeForm.phone,
        opwd: this.changeForm.opwd,
        npwd: this.changeForm.npwd
      }).then(res => {
        if (res.data.success) {
          this.$message.success('修改密码成功');
        } else {
          this.$message.error(res.data.data.err);
        }
      })

    }
  }

})
</script>

<style lang="scss">
@import "../index.scss";
.login-wrapper {
}

.login-unwrapper {
  border: 1px solid #ececec;
  border-radius: 4px;
}

.login {
  &-header {
    text-align: center;
    .title {
      font-size: 1.875rem;
      font-size: bold;
      color: #333;
    }
  }

  &-form {
    margin-top: 2rem;
  }

  &-select {
    display: flex;
    justify-content: right;
    align-items: center;
    cursor: pointer;

    .go {
      color: orange;
      text-decoration: underline;
      margin-left: 0.5rem;
    }

    .go:hover {
      color: orangered;
    }

    .change {
      color: skyblue;
    }

    .change:hover {
      color: rgb(135, 178, 235);
    }
  }

  &-button {
    margin-top: 2rem;
    .el-button {
      width: 100%;
      background-color: $color-primary;
      color: white;
    }
  }
}

.change-button {
  display: flex;
  justify-content: space-around;
  align-items: center;

  .confirm {
    background-color: $color-primary;
  }
}
</style>

routes.ts

vue-router@next中的动态路由方案略有不同,有类似rank的排名机制,具体可以参考vue-router@next的官方文档

import { WrapperLayouts } from '../components';
import menuMap from './menuMap'
// 1. 定义路由组件, 注意,这里一定要使用 文件的全名(包含文件后缀名)
const routes = [
    {
        path: "/",
        component: WrapperLayouts,
        redirect: `/page/${Object.keys(menuMap)[0]}`,
        children: [
            {
                path: '/page/:id',
                name: 'page',
                component: () => import('../views/Page.vue'),
                children: [
                {
                    path: '/page/:id(.*)*',
                    // redirect: `/page/${Object.keys(menuMap)[0]}`,
                    name: 'pageno',
                    component: () => import('../views/Page.vue')
                }
                ]
            }
        ]
    },
];

export default routes;
import {createRouter, createWebHashHistory} from 'vue-router';

import { routes } from '../config';


// Vue-router新版本中,需要使用createRouter来创建路由
export default  createRouter({
  // 指定路由的模式,此处使用的是hash模式
  history: createWebHashHistory(),
  routes // short for `routes: routes`
})

Aside.vue

结合路由进行左边侧边栏的路由跳转及显示

<template>
  <div class="aside">
    <el-menu @select="handleSelect" :default-active="Array.isArray($route.params.id) ? $route.params.id[0] : $route.params.id">
      <el-menu-item v-for="(menu, index) in menuLists" :index="menu.id" >
        <span>{{menu.label}}</span>
      </el-menu-item>
    </el-menu>
  </div>
</template>

<script lang="ts">
import {
  computed,
  defineComponent,
  getCurrentInstance,
  onMounted,
  reactive,
  ref,
  toRefs,
} from 'vue';

export default defineComponent({
  name: 'Aside',
  props: {
    menuMap: {
      type: Object,
      default: () => {}
    }
  },
  components: {

  },
  methods: {
    handleSelect(e) {
      console.log('$route', this.$route.params.id)
      console.log('select', e)
      this.$router.push(`/page/${e}`)
    }
  },
  setup(props, context) {
    console.log('props', props.menuMap)
    //引用全局变量
    const { proxy } = getCurrentInstance();

    const menuMap = props.menuMap;

    let menuLists = reactive([]);

    //dom挂载后
    onMounted(() => {
      handleMenuLists();
    });

    function handleMenuLists() {
      (proxy as any).$http.get('/bff/imagepic/bucket/listBuckets').then(res => {
        console.log('listBuckets', res);
        if(res.data.success) {
          res.data.data.forEach(element => {
            menuMap[`${element.name}`] && menuLists.push({
              id: element.name,
              label: menuMap[`${element.name}`]
            })
          })
        }
      })
    }


    return {
      ...toRefs(menuLists),
      handleMenuLists,
      menuLists
    };
  }
})
</script>

<style lang="scss">
.aside {
  height: 100%;
  background-color: #fff;
  width: 100%;
  border-right: 1px solid #d7d7d7;
}
</style>

总结

前端图床作为前端基建侧的一项重要的开发工具,不仅能够为业务开发人员提供更好的开发体验,也能节省业务开发过程中造成的效率降低,从而提升开发效率,降低成本损耗。前端展示的实现有多种不同的方案,对于有着更高要求的前端图床实现也可以基于需求进行更高层次的展示与提升。

03-05 16:39