关于图床:前端图床搭建实践前端篇

106次阅读

共计 14775 个字符,预计需要花费 37 分钟才能阅读完成。

我的项目背景

前端开发过程中不可避免会用到图片、视频等多媒体物料,常见的解决计划通常会进行动静拆散,将图片等资源搁置在图床上,除了应用业界罕用的图床资源,比方:七牛云、微博图床等,除了借助第三方图床外,咱们也能够本人搭建一个图床,为团队业务开发提供更好的根底服务,晋升开发体验及效率。本文旨在回顾总结下自建图床的前端局部实现计划,心愿可能给有相似需要的同学一些借鉴和计划。

计划

前端局部架构选型,思考到 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>

总结

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

正文完
 0