前端圖床搭建實踐(前端篇)

野林發表於2022-02-07

專案背景

圖片

前端開發過程中不可避免會用到圖片、視訊等多媒體物料,常見的處理方案通常會進行動靜分離,將圖片等資源放置在圖床上,除了使用業界常用的圖床資源,比如:七牛雲、微博圖床等,除了藉助第三方圖床外,我們也可以自己搭建一個圖床,為團隊業務開發提供更好的基礎服務,提升開發體驗及效率。本文旨在回顧總結下自建圖床的前端部分實現方案,希望能夠給有類似需求的同學一些借鑑和方案。

方案

前端部分架構選型,考慮到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>

總結

前端圖床作為前端基建側的一項重要的開發工具,不僅能夠為業務開發人員提供更好的開發體驗,也能節省業務開發過程中造成的效率降低,從而提升開發效率,降低成本損耗。前端展示的實現有多種不同的方案,對於有著更高要求的前端圖床實現也可以基於需求進行更高層次的展示與提升。

相關文章