美文网首页
前端图床搭建实践(前端篇)

前端图床搭建实践(前端篇)

作者: 维李设论 | 来源:发表于2022-02-07 17:35 被阅读0次
    前端 | 前端图床搭建实践(前端).png

    项目背景

    图片

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

    方案

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

    总结

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

    相关文章

      网友评论

          本文标题:前端图床搭建实践(前端篇)

          本文链接:https://www.haomeiwen.com/subject/olgmkrtx.html