# 前端图床搭建实践(前端篇)
# 项目背景
前端开发过程中不可避免会用到图片、视频等多媒体物料,常见的处理方案通常会进行动静分离,将图片等资源放置在图床上,除了使用业界常用的图床资源,比如:七牛云、微博图床等,除了借助第三方图床外,我们也可以自己搭建一个图床,为团队业务开发提供更好的基础服务,提升开发体验及效率。本文旨在回顾总结下自建图床的前端部分实现方案,希望能够给有类似需求的同学一些借鉴和方案。
# 方案
前端部分架构选型,考虑到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>
# 总结
前端图床作为前端基建侧的一项重要的开发工具,不仅能够为业务开发人员提供更好的开发体验,也能节省业务开发过程中造成的效率降低,从而提升开发效率,降低成本损耗。前端展示的实现有多种不同的方案,对于有着更高要求的前端图床实现也可以基于需求进行更高层次的展示与提升。