mirror of
https://github.com/keven1024/015.git
synced 2026-05-26 07:08:02 +00:00
feat(front): 重构文件上传进度视图,支持多文件上传和动态状态更新,优化上传任务管理
This commit is contained in:
@@ -1,180 +1,346 @@
|
||||
<script lang="ts" setup>
|
||||
import CircularProgress from '@/components/CircularProgress.vue'
|
||||
import { chunk, get, shuffle, times } from 'lodash-es'
|
||||
import { cx } from 'class-variance-authority'
|
||||
<script setup lang="ts">
|
||||
import { LucidePlay, LucideSettings, LucideSquare } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import dayjs from 'dayjs'
|
||||
import FileUploadBlockProgressView from '@/components/FileUploadBlockProgressView.vue'
|
||||
import { motion } from 'motion-v'
|
||||
import { filesize } from 'filesize'
|
||||
import { toast } from 'vue-sonner'
|
||||
import asyncWorker from '~/lib/asyncWorker'
|
||||
import calcFileHashWorker from '~/lib/calcFileHashWorker?worker'
|
||||
import { cx } from 'class-variance-authority'
|
||||
import asyncWait from '@/lib/asyncWait'
|
||||
import asyncWorker from '@/lib/asyncWorker'
|
||||
import calcFileHashWorker from '@/lib/calcFileHashWorker?worker'
|
||||
import { clamp, get, isEmpty, isNumber, isString, sample, shuffle, times } from 'lodash-es'
|
||||
import { nanoid } from 'nanoid'
|
||||
import asyncRetry from '@/lib/asyncRetry'
|
||||
import asyncTimeout from '@/lib/asyncTimeout'
|
||||
|
||||
const props = defineProps<{
|
||||
data: { file: File; config: any; handle_type: string }
|
||||
data: { file: File[]; config: any; handle_type: string }
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'change', key: string): void
|
||||
}>()
|
||||
|
||||
const form = useFormContext()
|
||||
|
||||
const step = ref<'hash' | 'upload'>('hash')
|
||||
const calcHashTime = ref(0)
|
||||
const chunkSize = ref(0)
|
||||
const fileSliceUploadStatusList = ref<
|
||||
const selectedFile = ref()
|
||||
const uploadfiles = ref<
|
||||
{
|
||||
status: string
|
||||
index: number
|
||||
fileId: string
|
||||
file: File
|
||||
status: 'start' | 'pause' | 'finish' | 'error'
|
||||
hash?: string
|
||||
procressType: 'hash' | 'create' | 'upload'
|
||||
uploadInfo?: {
|
||||
chunks: Record<number, { status: 'success' | 'error' | 'processing'; createdAt: number }>
|
||||
chunkLength: number
|
||||
}
|
||||
queue: {
|
||||
taskId: string
|
||||
taskType: 'hash' | 'create' | 'chunk' | 'upload'
|
||||
queueType: 'sync' | 'async' // sync任务禁止并发
|
||||
index?: number
|
||||
retry?: number
|
||||
}[]
|
||||
}[]
|
||||
>([])
|
||||
const selectedUploadfile = computed(() => uploadfiles.value.find((item) => item.fileId === selectedFile.value))
|
||||
const selectedUploadfileChunk = computed(() => Object.values(selectedUploadfile.value?.uploadInfo?.chunks || {}))
|
||||
const selectedUploadfileViewMode = ref<'progress' | 'heatmap'>('progress')
|
||||
|
||||
const successCount = computed(() => fileSliceUploadStatusList.value.filter((item) => item.status === 'success').length)
|
||||
const alreadyUploadSize = computed(() => Math.min(successCount.value * chunkSize.value, props?.data?.file?.size))
|
||||
const uploadProgress = computed(() => Math.round((alreadyUploadSize.value / (props?.data?.file?.size || 0)) * 100))
|
||||
const procressTaskList = ref<Map<string, any>>(new Map())
|
||||
const taskList = computed(() => uploadfiles.value.filter((r) => r.queue.length > 0 && r.status === 'start').flatMap((r) => r.queue))
|
||||
const batchNum = ref(3)
|
||||
|
||||
const { error } = useAsyncState(async () => {
|
||||
const { file } = props.data || {}
|
||||
if (!file) return
|
||||
const { size, type = 'application/octet-stream' } = file || {}
|
||||
const now = Date.now()
|
||||
const res = await asyncWorker(calcFileHashWorker, { data: { file } })
|
||||
const { hash } = res?.data || {}
|
||||
if (hash) {
|
||||
step.value = 'upload'
|
||||
calcHashTime.value = Date.now() - now
|
||||
}
|
||||
|
||||
const createData = await $fetch<{
|
||||
data: {
|
||||
id: string
|
||||
type: 'init' | 'already'
|
||||
chunk_size: number
|
||||
}
|
||||
}>('/api/file/create', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
size,
|
||||
mime_type: get(type, '', 'application/octet-stream'),
|
||||
hash,
|
||||
},
|
||||
})
|
||||
const { id, chunk_size, type: createType } = createData?.data || {}
|
||||
if (createType !== 'init') {
|
||||
// 文件存在
|
||||
form.setFieldValue('file_id', id)
|
||||
emit('change', 'result')
|
||||
return
|
||||
}
|
||||
chunkSize.value = chunk_size
|
||||
const chunks = Math.ceil(size / chunk_size)
|
||||
fileSliceUploadStatusList.value = times(chunks, (i) => ({
|
||||
status: 'pending',
|
||||
index: i,
|
||||
}))
|
||||
|
||||
const readChunk = (start: number): Promise<ArrayBuffer> => {
|
||||
const fileReader = new FileReader()
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunk = file.slice(start, start + chunk_size)
|
||||
fileReader.onload = (e) => resolve(e.target?.result as ArrayBuffer)
|
||||
fileReader.onerror = reject
|
||||
fileReader.readAsArrayBuffer(chunk)
|
||||
onMounted(() => {
|
||||
props.data.file.forEach((file) => {
|
||||
uploadfiles.value.push({
|
||||
fileId: `${file.name}_${file.size}`,
|
||||
file,
|
||||
status: 'start',
|
||||
procressType: 'hash',
|
||||
queue: [
|
||||
{ taskType: 'hash', queueType: 'sync', taskId: nanoid() },
|
||||
{ taskType: 'create', queueType: 'sync', taskId: nanoid() },
|
||||
{ taskType: 'chunk', queueType: 'sync', taskId: nanoid() },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const chunkedUploadTasks = chunk(shuffle([...fileSliceUploadStatusList.value]), 3)
|
||||
for (let i = 0; i < chunkedUploadTasks?.length; i++) {
|
||||
await Promise.all(
|
||||
chunkedUploadTasks?.[i]?.map(async (item: any) => {
|
||||
const { index } = item || {}
|
||||
try {
|
||||
const chunk = await readChunk(index * chunk_size)
|
||||
// console.log('chunk', chunk)
|
||||
const formData = new FormData()
|
||||
formData.append('file', new Blob([chunk]))
|
||||
formData.append('index', index + 1)
|
||||
formData.append('id', id)
|
||||
fileSliceUploadStatusList.value[index].status = 'uploading'
|
||||
const res = await $fetch<{
|
||||
code: number
|
||||
}>('/api/file/slice', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
const { code } = res || {}
|
||||
if (code !== 200) {
|
||||
throw new Error('上传失败')
|
||||
}
|
||||
fileSliceUploadStatusList.value[index].status = 'success'
|
||||
} catch (error) {
|
||||
console.log('error', error)
|
||||
// fileSliceStatusList.value[index].status = 'error'
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
const r = await $fetch<{
|
||||
code: number
|
||||
}>('/api/file/finish', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
id,
|
||||
},
|
||||
})
|
||||
if (r?.code !== 200) {
|
||||
})
|
||||
|
||||
const { error, execute, isLoading } = useAsyncState(
|
||||
async () => {
|
||||
while (taskList.value.length > 0) {
|
||||
console.log('hasTask')
|
||||
const taskList = uploadfiles?.value?.filter((r) => r.queue.length > 0 && r.status === 'start')
|
||||
await Promise.all(
|
||||
times(batchNum.value, async (i: number) => {
|
||||
const file = sample(taskList)
|
||||
const fileId = file?.fileId as string
|
||||
const task = get(file?.queue, '0')
|
||||
if (!task) return
|
||||
const { taskId, index, queueType, taskType } = task || {}
|
||||
if (procressTaskList.value.has(taskId)) return
|
||||
procressTaskList.value.set(taskId, task)
|
||||
|
||||
const uploadFileIndex = uploadfiles.value.findIndex((r) => r.fileId === file?.fileId)
|
||||
|
||||
if (queueType === 'async') {
|
||||
uploadfiles.value[uploadFileIndex]?.queue.shift()
|
||||
}
|
||||
console.log('[start]', taskType, taskId, queueType)
|
||||
try {
|
||||
if (taskType === 'hash') {
|
||||
await asyncRetry(() => asyncTimeout(() => handleHash(fileId), 10000))
|
||||
}
|
||||
if (taskType === 'create') {
|
||||
await asyncRetry(() => asyncTimeout(() => handleCreate(fileId), 10000))
|
||||
}
|
||||
if (taskType === 'chunk') {
|
||||
await asyncRetry(() => asyncTimeout(() => handleChunk(fileId), 10000))
|
||||
}
|
||||
if (taskType === 'upload' && isNumber(index)) {
|
||||
await asyncRetry(() => asyncTimeout(() => handleUpload(fileId, index), 10000))
|
||||
}
|
||||
console.log('[finish]', taskType, taskId)
|
||||
if (queueType === 'sync') {
|
||||
uploadfiles.value[uploadFileIndex]?.queue.shift()
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('error', error)
|
||||
// todo 重新加入队列
|
||||
if (queueType === 'async') {
|
||||
uploadfiles.value[uploadFileIndex]?.queue.push({ ...task, retry: (task?.retry || 0) + 1 })
|
||||
}
|
||||
if (queueType === 'sync') {
|
||||
uploadfiles.value[uploadFileIndex].status = 'error'
|
||||
}
|
||||
} finally {
|
||||
procressTaskList.value.delete(taskId)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
null,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
watchEffect(async () => {
|
||||
if (taskList.value.length > 0 && !isLoading.value) {
|
||||
console.log('task队列已更新', `当前任务 ${taskList.value.length} 个 正在消化`)
|
||||
execute()
|
||||
console.log('开始执行任务...')
|
||||
}
|
||||
})
|
||||
|
||||
const handleHash = async (fileId: string) => {
|
||||
const uploadfile = uploadfiles.value.find((item) => item.fileId === fileId)
|
||||
if (!uploadfile?.file) return
|
||||
uploadfile.procressType = 'hash'
|
||||
const res = await asyncWorker(calcFileHashWorker, { data: { file: uploadfile.file } })
|
||||
const { hash } = res?.data || {}
|
||||
uploadfile.hash = hash
|
||||
}
|
||||
|
||||
const handleCreate = async (fileId: string) => {
|
||||
const uploadfile = uploadfiles.value.find((item) => item.fileId === fileId)
|
||||
if (!uploadfile?.file) return
|
||||
uploadfile.procressType = 'create'
|
||||
await asyncWait(1000)
|
||||
console.log('create', fileId)
|
||||
}
|
||||
|
||||
const handleChunk = async (fileId: string) => {
|
||||
const uploadfile = uploadfiles.value.find((item) => item.fileId === fileId)
|
||||
if (!uploadfile?.file) return
|
||||
uploadfile.procressType = 'upload'
|
||||
uploadfile.uploadInfo = {
|
||||
chunks: {},
|
||||
chunkLength: 1000,
|
||||
}
|
||||
const tasks = shuffle(times(1000, (i: number) => ({ taskType: 'upload' as const, queueType: 'async' as const, taskId: nanoid(), index: i })))
|
||||
uploadfile.queue.push(...tasks)
|
||||
console.log('chunk', fileId)
|
||||
}
|
||||
|
||||
const handleUpload = async (fileId: string, index: number) => {
|
||||
const uploadfile = uploadfiles.value.find((item) => item.fileId === fileId)
|
||||
if (!uploadfile?.file) return
|
||||
if (uploadfile.procressType !== 'upload') uploadfile.procressType = 'upload'
|
||||
let chunkInfo = uploadfile.uploadInfo?.chunks?.[index]
|
||||
if (!chunkInfo) {
|
||||
uploadfile.uploadInfo!.chunks[index] = {
|
||||
status: 'processing',
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
}
|
||||
await asyncWait(500)
|
||||
if (index % 3 === 0) {
|
||||
uploadfile.uploadInfo!.chunks[index].status = 'error'
|
||||
throw new Error('上传失败')
|
||||
}
|
||||
form.setFieldValue('file_id', id)
|
||||
emit('change', 'result')
|
||||
}, null)
|
||||
|
||||
watch(error, (newVal) => {
|
||||
if (newVal) {
|
||||
toast.error('上传失败')
|
||||
}
|
||||
emit('change', 'input')
|
||||
})
|
||||
uploadfile.uploadInfo!.chunks[index].status = 'success'
|
||||
console.log('upload', fileId, index)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-xl font-normal">正在上传</div>
|
||||
<div class="flex flex-col items-center gap-4 md:flex-row md:justify-evenly">
|
||||
<div :class="cx('flex flex-row items-center gap-5', step !== 'hash' && 'opacity-50')">
|
||||
<div class="flex flex-col gap-0.5 items-center">
|
||||
<div class="text-xs opacity-50">1.计算hash</div>
|
||||
<div class="text-3xl font-light">
|
||||
<LucideLoaderCircle v-if="step === 'hash'" class="size-5 my-1 animate-spin" />
|
||||
<div v-else>{{ calcHashTime }}ms</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-5">
|
||||
<div class="rounded-xl p-3 bg-white/80 flex flex-col gap-2 col-span-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-xs opacity-70">总上传速度</div>
|
||||
<div class="text-2xl font-bold">144.0MB/s</div>
|
||||
</div>
|
||||
<div class="flex-1 relative overflow-hidden flex flex-row gap-0.5 justify-end items-end">
|
||||
<motion.div
|
||||
class="w-2 shrink-0 bg-primary relative"
|
||||
:style="{ height: (i.value + 1) * 4 + 'px' }"
|
||||
:layoutId="i.time"
|
||||
v-for="i in data"
|
||||
:key="i.time"
|
||||
:initial="{ x: 10, opacity: 0 }"
|
||||
:animate="{ x: 0, opacity: 1 }"
|
||||
:transition="{ duration: 1 }"
|
||||
>
|
||||
</motion.div>
|
||||
<!-- <BarChart class="h-full" :data="data" index="time" :categories="['value']" :showTooltip="false"
|
||||
:showLegend="false" :showXAxis="false" :showYAxis="false" :showGrid="false" :groupMaxWidth="10" /> -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-xl bg-white/80 aspect-square flex flex-col gap-2 relative overflow-hidden">
|
||||
<div class="absolute top-0 left-0 w-full h-full z-[0] flex flex-col justify-end">
|
||||
<div class="w-full bg-green-100 h-1/2 border-t border-green-500"></div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 justify-between p-3 h-full relative z-[1]">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-xs opacity-70">总上传进度</div>
|
||||
<div class="text-4xl font-bold">44.0%</div>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button class="aspect-square bg-green-200 hover:bg-green-300 text-green-500 hover:text-white p-0">
|
||||
<LucidePlay class="size-1/2" />
|
||||
</Button>
|
||||
<Button
|
||||
class="aspect-square bg-red-200 hover:bg-red-300 text-red-500 hover:text-white p-0"
|
||||
@click="
|
||||
() => {
|
||||
console.log('暂停')
|
||||
}
|
||||
"
|
||||
>
|
||||
<LucideSquare class="size-1/2" />
|
||||
</Button>
|
||||
<Button class="aspect-square bg-blue-200 hover:bg-blue-300 text-blue-500 hover:text-white p-0">
|
||||
<LucideSettings class="size-1/2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="cx('flex flex-row items-center gap-5 min-w-32', step !== 'upload' && 'opacity-50')">
|
||||
<CircularProgress :size="80" :value="step !== 'upload' ? 0 : uploadProgress" />
|
||||
<div class="flex flex-col gap-0.5 items-center">
|
||||
<div class="text-xs opacity-50">2.上传文件</div>
|
||||
<div class="text-3xl font-light">{{ step !== 'upload' ? 0 : uploadProgress }}%</div>
|
||||
<div class="text-sm opacity-50" v-if="alreadyUploadSize">
|
||||
{{ filesize(alreadyUploadSize) }} / {{ filesize(data?.file?.size) }}
|
||||
</div>
|
||||
<div class="col-span-4 flex flex-col bg-white/80 rounded-xl p-3 text-md gap-5">
|
||||
<div>文件列表</div>
|
||||
<div class="flex flex-col -mx-3 text-sm">
|
||||
<div class="grid grid-cols-[2fr_5rem_5rem_4fr] gap-2 border-b border-black/20 pb-2 px-3">
|
||||
<div>文件名</div>
|
||||
<div>文件大小</div>
|
||||
<div>上传速度</div>
|
||||
<div>进度</div>
|
||||
</div>
|
||||
<div
|
||||
:class="
|
||||
cx(
|
||||
'grid grid-cols-[2fr_5rem_5rem_4fr] gap-2 py-2 border-b border-black/20 items-center hover:bg-primary/30 px-3 cursor-pointer',
|
||||
selectedFile === item?.fileId && 'bg-primary text-white hover:!bg-primary'
|
||||
)
|
||||
"
|
||||
v-for="(item, index) in uploadfiles"
|
||||
@click="
|
||||
() => {
|
||||
if (selectedFile === item?.fileId) {
|
||||
selectedFile = null
|
||||
} else {
|
||||
selectedFile = item?.fileId
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<div class="flex flex-row gap-2 items-center grow-0 overflow-hidden">
|
||||
<Button
|
||||
class="size-8 p-0 hover:bg-white/50"
|
||||
variant="ghost"
|
||||
@click="
|
||||
(e: Event) => {
|
||||
e.stopPropagation()
|
||||
if (item?.status === 'start') {
|
||||
uploadfiles[index].status = 'pause'
|
||||
} else {
|
||||
uploadfiles[index].status = 'start'
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<LucidePlay class="size-4 text-green-500" v-if="item?.status === 'start'" />
|
||||
<LucidePause class="size-4 text-orange-500" v-if="item?.status === 'pause'" />
|
||||
</Button>
|
||||
<div class="truncate">{{ item?.file?.name }}</div>
|
||||
</div>
|
||||
<div>{{ filesize(item?.file?.size) }}</div>
|
||||
<div>100MB/s</div>
|
||||
<div class="flex flex-row gap-2 items-center" v-if="item?.procressType !== 'upload'">
|
||||
<LucideLoaderCircle class="size-4 animate-spin" />
|
||||
<div>正在{{ item?.procressType }}中...</div>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2 items-center" v-else>
|
||||
<div class="rounded-full bg-white/50 w-full h-2 overflow-hidden border border-white">
|
||||
<div
|
||||
class="h-full bg-primary"
|
||||
:style="`width: ${clamp(((Object.keys(item?.uploadInfo?.chunks || {})?.length || 0) / (item?.uploadInfo?.chunkLength || 0)) * 100, 0, 100)}%`"
|
||||
></div>
|
||||
</div>
|
||||
<div>
|
||||
{{
|
||||
clamp(
|
||||
((Object.keys(item?.uploadInfo?.chunks || {})?.length || 0) / (item?.uploadInfo?.chunkLength || 0)) * 100,
|
||||
0,
|
||||
100
|
||||
)?.toFixed(2)
|
||||
}}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2 items-baseline">
|
||||
<div class="text-md font-normal">详细信息</div>
|
||||
<div class="text-xs opacity-50" v-if="step === 'upload' && fileSliceUploadStatusList?.length > 0">
|
||||
当前正在上传分块{{ `${successCount}/${Math.ceil(data?.file?.size / chunkSize)}` }} 并发:3
|
||||
<div class="col-span-4 flex flex-col bg-white/80 rounded-xl p-3 gap-5" v-if="selectedFile">
|
||||
<div>上传详情</div>
|
||||
<div class="grid grid-cols-3 text-sm gap-3">
|
||||
<div>区块: {{ selectedUploadfile?.uploadInfo?.chunkLength }} x 256.0 KiB</div>
|
||||
<div class="truncate col-span-2">hash: {{ selectedUploadfile?.hash }}</div>
|
||||
<div>已完成: {{ selectedUploadfileChunk?.filter((r) => r.status === 'success')?.length || 0 }}</div>
|
||||
<div>已丢弃: {{ selectedUploadfileChunk?.filter((r) => r.status === 'error')?.length || 0 }}</div>
|
||||
<div>
|
||||
待完成:
|
||||
{{ (selectedUploadfile?.uploadInfo?.chunkLength || 0) - (selectedUploadfileChunk?.length || 0) }}
|
||||
</div>
|
||||
<div class="col-span-3 flex flex-row justify-between items-center">
|
||||
<div class="text-md font-bold">{{ selectedUploadfileViewMode === 'progress' ? '区块进度条' : '区块热力图' }}</div>
|
||||
<Button
|
||||
size="sm"
|
||||
class="ml-auto text-xs"
|
||||
@click="selectedUploadfileViewMode = selectedUploadfileViewMode === 'progress' ? 'heatmap' : 'progress'"
|
||||
>
|
||||
<LucideArrowDownUp class="size-4" />
|
||||
{{ selectedUploadfileViewMode === 'progress' ? '热力图' : '进度条' }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="h-7 col-span-3 flex flex-row gap-2 items-center" v-if="selectedUploadfileViewMode === 'progress'">
|
||||
<div class="flex-1 h-full">
|
||||
<FileUploadBlockProgressView :data="selectedUploadfile?.uploadInfo" />
|
||||
</div>
|
||||
{{
|
||||
clamp(((selectedUploadfileChunk?.length || 0) / (selectedUploadfile?.uploadInfo?.chunkLength || 0)) * 100, 0, 100)?.toFixed(
|
||||
2
|
||||
)
|
||||
}}%
|
||||
</div>
|
||||
<div v-if="selectedUploadfileViewMode === 'heatmap'" class="col-span-3 bg-black/5 rounded p-2">
|
||||
<FileUploadHeatMapView :size="12" :data="selectedUploadfile?.uploadInfo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row flex-wrap gap-1">
|
||||
<div
|
||||
v-for="i in fileSliceUploadStatusList"
|
||||
:class="
|
||||
cx(
|
||||
'rounded size-4 ',
|
||||
i.status === 'pending' && 'bg-white/90',
|
||||
i.status === 'uploading' && 'bg-yellow-500',
|
||||
i.status === 'success' && 'bg-green-500'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user