695 lines
22 KiB
Vue
695 lines
22 KiB
Vue
<template>
|
|
<div class="image-share-right">
|
|
<div class="image-share-right-top">
|
|
<h3>照片快传</h3>
|
|
</div>
|
|
<div class="image-share-right-bottom">
|
|
<div class="image-share-right-bottom-content">
|
|
<div class="image-share-right-bottom-upload" ref="qrContainer"
|
|
v-if="fileList.length <= 0 && !uploadSuccess">
|
|
<AUploadDragger
|
|
name="file"
|
|
:multiple="true"
|
|
:showUploadList="false"
|
|
:beforeUpload="beforeUpload"
|
|
v-model:fileList="fileList"
|
|
:customRequest="customRequest"
|
|
class="image-share-right-upload"
|
|
>
|
|
<div class="image-share-right-upload-item">
|
|
<p class="ant-upload-drag-icon">
|
|
<ABadge :offset="[-15, 20]" :count="fileList.length">
|
|
<AAvatar shape="square" :size="folderIconSize" :src="folder"/>
|
|
</ABadge>
|
|
</p>
|
|
<p class="ant-upload-text" style="font-size: 2.6vh;font-weight: bolder">单击或拖动文件到此区域以上传</p>
|
|
<AButton type="primary" size="large" shape="round" style="width: 70%">
|
|
<template #icon>
|
|
<CloudUploadOutlined/>
|
|
</template>
|
|
上 传 照 片
|
|
</AButton>
|
|
<APopover placement="top" trigger="click">
|
|
<template #content>
|
|
<AQrcode :bordered="false" color="rgba(126, 126, 135, 0.48)"
|
|
:size="qrcodeSize"
|
|
:value="`git.landaiqing.cneyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiNjI1MTEyMjE3MzQyMDIxIiwidHlwZSI6ImFjY2VzcyIsImV4cCI6MTczOTg3ODIyOCwibmJmIjoxNzM5ODcxMDI4LCJpYXQiOjE3Mzk4NzEwMjh9.EUiZsVjhGqHx1V5o90S3W5li6nIqucxy9eEY9LWgqXY`"
|
|
:icon="phone"
|
|
:iconSize="iconSize"
|
|
:status="`active`"
|
|
/>
|
|
</template>
|
|
<AButton @click.stop type="default" size="large" shape="round" style="width: 70%">
|
|
<template #icon>
|
|
<QrcodeOutlined/>
|
|
</template>
|
|
手 机 上 传
|
|
</AButton>
|
|
</APopover>
|
|
|
|
</div>
|
|
</AUploadDragger>
|
|
</div>
|
|
<div class="image-share-right-bottom-container" v-if="fileList.length > 0 && !loading">
|
|
<div class="image-share-right-bottom-container-header">
|
|
<AInput v-model:value="titleName" :bordered="false" size="large" placeholder="给快传起个标题"/>
|
|
<ADropdown placement="bottomLeft" :trigger="['click']">
|
|
<template #overlay>
|
|
<AMenu>
|
|
<AMenuItem key="1">
|
|
<AUpload
|
|
name="file"
|
|
:multiple="true"
|
|
:showUploadList="false"
|
|
:beforeUpload="beforeUpload"
|
|
v-model:fileList="fileList"
|
|
>
|
|
上传文件
|
|
</AUpload>
|
|
</AMenuItem>
|
|
<AMenuItem key="2">
|
|
<APopover placement="bottomLeft" trigger="hover">
|
|
<template #content>
|
|
<AQrcode :bordered="false" color="rgba(126, 126, 135, 0.48)"
|
|
:size="qrcodeSize"
|
|
:value="`git.landaiqing.cneyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiNjI1MTEyMjE3MzQyMDIxIiwidHlwZSI6ImFjY2VzcyIsImV4cCI6MTczOTg3ODIyOCwibmJmIjoxNzM5ODcxMDI4LCJpYXQiOjE3Mzk4NzEwMjh9.EUiZsVjhGqHx1V5o90S3W5li6nIqucxy9eEY9LWgqXY`"
|
|
:icon="phone"
|
|
:iconSize="iconSize"
|
|
:status="`active`"
|
|
/>
|
|
</template>
|
|
手机上传
|
|
</APopover>
|
|
</AMenuItem>
|
|
</AMenu>
|
|
</template>
|
|
<AButton size="middle" shape="circle">
|
|
<template #icon>
|
|
<PlusOutlined/>
|
|
</template>
|
|
</AButton>
|
|
</ADropdown>
|
|
</div>
|
|
<div class="image-share-right-bottom-content-list">
|
|
<p style="font-size: 2.0vh;color: #999999;cursor: default">共{{ fileList.length }}个文件
|
|
{{ calculateTotalSize(fileList) }}</p>
|
|
<div class="image-share-right-bottom-content-list-wrapper">
|
|
<div class="image-share-right-bottom-content-list-item"
|
|
v-for="(item, index) in fileList" :key="index">
|
|
<div class="file-thumbnail" ref="fileContainer">
|
|
<AImage :width="50" :height="50"
|
|
:src="convertFileToUrl(item.originFileObj)">
|
|
<template #previewMask>
|
|
</template>
|
|
</AImage>
|
|
</div>
|
|
<div class="file-info">
|
|
<p style="font-size: 2.0vh;color: #333333;cursor: default;font-weight: bold">{{ item.name }}</p>
|
|
<p style="font-size: 1.5vh;color: #999999;cursor: default">{{
|
|
formatByteSize(item.size)
|
|
}}</p>
|
|
</div>
|
|
<div class="file-operation">
|
|
<AButton size="middle" shape="circle" type="text" @click="removeImage(index)">
|
|
<template #icon>
|
|
<CloseOutlined/>
|
|
</template>
|
|
</AButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<ADivider/>
|
|
<div class="image-share-right-bottom-operation">
|
|
<div class="image-share-right-operation-select">
|
|
<div class="image-share-right-operation-item">
|
|
<span class="label-text">访问时效</span>
|
|
<ASelect style="width: 50%" placeholder="请选择" :defaultValue="1" v-model:value="expire_date">
|
|
<ASelectOption value="1">1天</ASelectOption>
|
|
<ASelectOption value="3">3天</ASelectOption>
|
|
<ASelectOption value="7">7天</ASelectOption>
|
|
<ASelectOption value="15">15天</ASelectOption>
|
|
<ASelectOption value="30">30天</ASelectOption>
|
|
<ASelectOption value="0">永久</ASelectOption>
|
|
</ASelect>
|
|
</div>
|
|
<div class="image-share-right-operation-item">
|
|
<span class="label-text">访问密码</span>
|
|
<AInputPassword style="width: 50%" v-model:value="access_password" :maxlength="10"
|
|
:showCount="true"></AInputPassword>
|
|
</div>
|
|
<div class="image-share-right-operation-item">
|
|
<span class="label-text">访问限制</span>
|
|
<AInputNumber style="width: 50%" :defaultValue="100" :min="1"
|
|
v-model:value="access_limit"></AInputNumber>
|
|
</div>
|
|
</div>
|
|
<div class="image-share-right-bottom-operation-btn">
|
|
<AButton type="primary" size="middle" shape="round" style="width: 100%"
|
|
:loading="loading"
|
|
@click="customUploader">
|
|
开始上传
|
|
</AButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="loading && !uploadSuccess" class="image-share-right-bottom-loading">
|
|
<div class="image-share-right-bottom-loading-content">
|
|
<AProgress
|
|
type="circle"
|
|
:stroke-color="{
|
|
'0%': '#108ee9',
|
|
'100%': '#87d068',
|
|
}"
|
|
:percent="percent"
|
|
:size="180"
|
|
/>
|
|
<p>{{ fileList.length }} 个文件 / {{ calculateTotalSize(fileList) }}</p>
|
|
</div>
|
|
<div class="image-share-right-bottom-loading-footer">
|
|
<AButton type="primary" size="large" shape="round" style="width: 80%" @click="abort">
|
|
取 消
|
|
</AButton>
|
|
</div>
|
|
</div>
|
|
<div v-if="uploadSuccess" class="image-share-right-bottom-success">
|
|
<div class="image-share-right-bottom-success-header">
|
|
<div class="image-share-right-bottom-success-header-title">
|
|
<CheckCircleOutlined style="font-size: 3vh;color: #52c41a"/>
|
|
<h3>上 传 成 功</h3>
|
|
</div>
|
|
<div class="image-share-right-bottom-success-header-close">
|
|
<AButton type="text" size="middle" shape="circle" @click="uploadSuccess = false">
|
|
<CloseOutlined/>
|
|
</AButton>
|
|
</div>
|
|
</div>
|
|
<div class="image-share-right-bottom-success-content">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<script setup lang="ts">
|
|
|
|
import folder from "@/assets/svgs/folder.svg";
|
|
import phone from "@/assets/svgs/qr-phone.svg";
|
|
import useStore from "@/store";
|
|
import {NSFWJS} from "nsfwjs";
|
|
import {initNSFWJs, predictNSFW} from "@/utils/tfjs/nsfw.ts";
|
|
import {message} from "ant-design-vue";
|
|
import i18n from "@/locales";
|
|
import {useRequest} from "alova/client";
|
|
import {shareImageUploadApi} from "@/api/share";
|
|
import imageCompression from "browser-image-compression";
|
|
import {generateThumbnail} from "@/utils/imageUtils/generateThumb.ts";
|
|
|
|
const titleName = ref<string>("");
|
|
|
|
const upload = useStore().upload;
|
|
const percent = ref<number>(0);
|
|
const uploadSuccess = ref<boolean>(false);
|
|
const qrContainer = ref<HTMLDivElement | null>(null);
|
|
|
|
/**
|
|
* 格式化字节大小
|
|
* @param bytes
|
|
*/
|
|
function formatByteSize(bytes: number) {
|
|
if (bytes < 1024) {
|
|
return `${bytes} Bytes`;
|
|
} else if (bytes < 1024 * 1024) {
|
|
return `${(bytes / 1024).toFixed(2)} KB`;
|
|
} else if (bytes < 1024 * 1024 * 1024) {
|
|
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
} else {
|
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 格式化字节大小
|
|
* @param bytes
|
|
* @param decimals
|
|
*/
|
|
function formatBytes(bytes: number, decimals: number = 2): string {
|
|
if (bytes === 0) return '0 Bytes';
|
|
const k = 1024;
|
|
const dm = decimals < 0 ? 0 : decimals;
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
|
}
|
|
|
|
/**
|
|
* 计算文件总大小
|
|
* @param fileDataArray
|
|
*/
|
|
function calculateTotalSize(fileDataArray: { size: number }[]): string {
|
|
const totalSize = fileDataArray.reduce((acc, file) => acc + file.size, 0);
|
|
return formatBytes(totalSize);
|
|
}
|
|
|
|
const qrcodeSize = ref<number>(220);
|
|
const iconSize = ref<number>(30);
|
|
const folderIconSize = ref<number>(100);
|
|
const loading = ref<boolean>(false);
|
|
|
|
|
|
/**
|
|
* 更新二维码大小
|
|
*/
|
|
const updateQrcodeSize = () => {
|
|
if (qrContainer.value) {
|
|
// 设置 QRCode 大小
|
|
const containerWidth = qrContainer.value.clientWidth;
|
|
qrcodeSize.value = containerWidth * 0.5; // 设置 QRCode 为父盒子宽度的80%
|
|
folderIconSize.value = containerWidth * 0.3; // 设置文件夹图标大小为父盒子宽度的10%
|
|
iconSize.value = Math.min(containerWidth * 0.1, 40); // 设置 icon 大小为父盒子宽度的10%
|
|
}
|
|
};
|
|
|
|
const fileList = ref<any[]>([]);
|
|
|
|
const expire_date = ref<string>("1");
|
|
|
|
const access_limit = ref<number>(100);
|
|
|
|
const access_password = ref<string>("");
|
|
|
|
/**
|
|
* 上传文件前置
|
|
* @param file
|
|
*/
|
|
async function beforeUpload(file: any) {
|
|
if (!window.FileReader) return false; // 判断是否支持FileReader
|
|
const reader = new FileReader();
|
|
reader.readAsDataURL(file); // 文件转换
|
|
reader.onloadend = async function () {
|
|
const img: HTMLImageElement = document.createElement('img');
|
|
img.src = reader.result as string;
|
|
img.onload = async () => {
|
|
// 图片 NSFW 检测
|
|
const nsfw: NSFWJS = await initNSFWJs();
|
|
const isNSFW: boolean = await predictNSFW(nsfw, img);
|
|
if (isNSFW) {
|
|
message.error(i18n.global.t('comment.illegalImage'));
|
|
return false;
|
|
}
|
|
};
|
|
};
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 自定义请求
|
|
* @param _file
|
|
* @param _fileList
|
|
*/
|
|
function customRequest(_file: any, _fileList: any) {
|
|
return;
|
|
}
|
|
|
|
const {uploading, send: submitFile, abort} = useRequest(shareImageUploadApi, {
|
|
immediate: false,
|
|
debounce: 500,
|
|
});
|
|
|
|
/**
|
|
* 自定义上传器
|
|
*/
|
|
async function customUploader() {
|
|
if (fileList.value.length <= 0) return;
|
|
loading.value = true;
|
|
uploadSuccess.value = false;
|
|
// 存储所有图片信息的数组
|
|
const images: any[] = [];
|
|
for (const file of fileList.value) {
|
|
// 压缩图片
|
|
const compressedFile = await imageCompression(file.originFileObj, {
|
|
maxSizeMB: 0.1,
|
|
maxWidthOrHeight: 750,
|
|
maxIteration: 10,
|
|
useWebWorker: true,
|
|
initialQuality: 0.6,
|
|
});
|
|
|
|
// 生成缩略图
|
|
const {binaryData, width, height, size} = await generateThumbnail(compressedFile);
|
|
// 将文件转换为 Base64 编码
|
|
const base64Thumbnail = binaryData ? await toBase64(binaryData) : '';
|
|
const base64Image = await toBase64(file.originFileObj);
|
|
// 创建文件的元数据对象
|
|
const fileObj = {
|
|
file_name: file.name, // 文件名
|
|
origin_image: base64Image, // 原始图片的 base64 数据
|
|
file_type: file.type, // 文件类型
|
|
thumbnail: base64Thumbnail, // 缩略图的 base64 数据
|
|
thumb_w: width, // 缩略图宽度
|
|
thumb_h: height, // 缩略图高度
|
|
thumb_size: size, // 缩略图文件大小
|
|
};
|
|
|
|
// 将文件对象添加到图片数组
|
|
images.push(fileObj);
|
|
}
|
|
|
|
// 准备发送给后端的请求数据
|
|
const requestData = {
|
|
title: titleName.value,
|
|
expire_date: expire_date.value,
|
|
access_limit: access_limit.value,
|
|
access_password: access_password.value,
|
|
provider: upload.storageSelected?.[0],
|
|
bucket: upload.storageSelected?.[1],
|
|
images: images,
|
|
};
|
|
watch(
|
|
() => uploading.value,
|
|
(upload) => {
|
|
if (upload && upload.loaded && upload.total) {
|
|
percent.value = Number(Math.round((upload.loaded / upload.total) * 100).toFixed(2));
|
|
}
|
|
},
|
|
);
|
|
const res: any = await submitFile(requestData);
|
|
if (res && res.code === 200) {
|
|
uploadSuccess.value = true;
|
|
fileList.value = [];
|
|
}
|
|
loading.value = false;
|
|
}
|
|
|
|
/**
|
|
* 将文件转换为 Base64 编码
|
|
* @param file
|
|
*/
|
|
function toBase64(file: Blob) {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
|
|
reader.onloadend = () => {
|
|
// 确保 reader.result 是字符串类型
|
|
if (typeof reader.result === 'string') {
|
|
resolve(reader.result.split(',')[1]); // 只获取 base64 部分
|
|
} else {
|
|
reject(new Error('FileReader result is not a string.'));
|
|
}
|
|
};
|
|
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
|
|
/**
|
|
* 删除图片
|
|
* @param index
|
|
*/
|
|
async function removeImage(index: number) {
|
|
fileList.value.splice(index, 1);
|
|
}
|
|
|
|
/**
|
|
* 转换文件为 URL
|
|
* @param file
|
|
*/
|
|
function convertFileToUrl(file: any) {
|
|
return URL.createObjectURL(file);
|
|
}
|
|
|
|
const user = useStore().user;
|
|
const websocket = useStore().websocket;
|
|
const wsOptions = {
|
|
url: import.meta.env.VITE_FILE_SOCKET_URL + "?user_id=" + user.user.uid,
|
|
protocols: [user.token.accessToken],
|
|
};
|
|
|
|
|
|
onMounted(() => {
|
|
window.addEventListener("resize", updateQrcodeSize);
|
|
websocket.initialize(wsOptions);
|
|
websocket.on("message", async (res: any) => {
|
|
if (res && res.code === 200) {
|
|
const {data} = res;
|
|
console.log(data);
|
|
}
|
|
});
|
|
});
|
|
onBeforeUnmount(() => {
|
|
websocket.close(false);
|
|
});
|
|
</script>
|
|
<style scoped lang="scss">
|
|
.image-share-right {
|
|
height: 100%;
|
|
width: 35%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
gap: 10px;
|
|
|
|
.image-share-right-top {
|
|
width: 100%;
|
|
height: 6%;
|
|
display: flex;
|
|
flex-direction: row;
|
|
justify-content: flex-start;
|
|
align-items: center;
|
|
}
|
|
|
|
.image-share-right-bottom {
|
|
width: 100%;
|
|
height: 94%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
|
|
.image-share-right-bottom-content {
|
|
width: 90%;
|
|
height: 100%;
|
|
padding: 20px;
|
|
background: #ffffff;
|
|
border-radius: 10px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
justify-content: flex-start;
|
|
gap: 10px;
|
|
|
|
.image-share-right-bottom-upload {
|
|
width: 100%;
|
|
height: 100%;
|
|
overflow: auto;
|
|
|
|
.image-share-right-upload {
|
|
width: 100%;
|
|
height: 100%;
|
|
|
|
}
|
|
}
|
|
|
|
.image-share-right-bottom-container {
|
|
width: 100%;
|
|
height: 100%;
|
|
|
|
.image-share-right-bottom-container-header {
|
|
width: 100%;
|
|
height: 10%;
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.image-share-right-bottom-content-list {
|
|
width: 95%;
|
|
height: 40%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-content: flex-start;
|
|
justify-content: flex-start;
|
|
gap: 15px;
|
|
flex-wrap: nowrap;
|
|
padding: 10px;
|
|
overflow: auto;
|
|
background-color: #f5f5f5;
|
|
border-radius: 10px;
|
|
|
|
.image-share-right-bottom-content-list-wrapper {
|
|
width: 100%;
|
|
height: 27vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-content: flex-start;
|
|
justify-content: flex-start;
|
|
overflow: auto;
|
|
gap: 10px;
|
|
|
|
.image-share-right-bottom-content-list-item {
|
|
width: 100%;
|
|
height: 50px;
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
|
|
.file-thumbnail {
|
|
height: 100%;
|
|
width: 17%;
|
|
}
|
|
|
|
.file-info {
|
|
height: 100%;
|
|
width: 63%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.file-operation {
|
|
height: 100%;
|
|
width: 20%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
.image-share-right-bottom-operation {
|
|
width: 100%;
|
|
height: 40%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
|
|
.image-share-right-operation-select {
|
|
width: 100%;
|
|
height: 75%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
flex-wrap: nowrap;
|
|
|
|
.image-share-right-operation-item {
|
|
width: 100%;
|
|
height: 5vh;
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: baseline;
|
|
justify-content: space-between;
|
|
|
|
.label-text {
|
|
width: 50%;
|
|
color: #999999;
|
|
font-size: 2.2vh;
|
|
}
|
|
}
|
|
}
|
|
|
|
.image-share-right-bottom-operation-btn {
|
|
width: 100%;
|
|
height: 35%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
}
|
|
}
|
|
|
|
.image-share-right-bottom-loading {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
justify-content: flex-start;
|
|
|
|
.image-share-right-bottom-loading-content {
|
|
width: 100%;
|
|
height: 80%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 20px;
|
|
}
|
|
|
|
.image-share-right-bottom-loading-footer {
|
|
width: 100%;
|
|
height: 20%;
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
}
|
|
|
|
.image-share-right-bottom-success {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
justify-content: flex-start;
|
|
|
|
.image-share-right-bottom-success-header {
|
|
width: 100%;
|
|
height: 10%;
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
|
|
.image-share-right-bottom-success-header-title {
|
|
height: 100%;
|
|
width: 90%;
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.image-share-right-bottom-success-header-close {
|
|
height: 100%;
|
|
width: 10%;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
cursor: pointer;
|
|
}
|
|
}
|
|
|
|
.image-share-right-bottom-success-content {
|
|
width: 100%;
|
|
height: 90%;
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
}
|
|
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.image-share-right-upload-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 5vh;
|
|
}
|
|
</style>
|