feat: 新增文件管理

This commit is contained in:
2024-04-12 22:10:30 +08:00
parent 9c9a29ae05
commit df14bd00dd
22 changed files with 1203 additions and 0 deletions

View File

@@ -18,3 +18,8 @@ export function listRoleDict(query?: { name: string; status: number }) {
export function listCommonDict(code: string) {
return http.get<LabelValueState[]>(`${BASE_URL}/dict/${code}`)
}
/** @desc 上传文件 */
export function uploadFile(data: FormData) {
return http.post(`${BASE_URL}/file`, data)
}

19
src/apis/system/file.ts Normal file
View File

@@ -0,0 +1,19 @@
import http from '@/utils/http'
import type * as System from './type'
const BASE_URL = '/system/file'
/** @desc 查询文件列表 */
export function listFile(query: System.FileQuery) {
return http.get<System.FileItem[]>(`${BASE_URL}/list`, query)
}
/** @desc 修改文件 */
export function updateFile(data: any, id: string) {
return http.put(`${BASE_URL}/${id}`, data)
}
/** @desc 删除文件 */
export function deleteFile(ids: string | Array<string>) {
return http.del(`${BASE_URL}/${ids}`)
}

View File

@@ -1,4 +1,5 @@
export * from './dept'
export * from './log'
export * from './dict'
export * from './file'
export * from './storage'

View File

@@ -91,6 +91,25 @@ export interface DictItemQuery extends PageQuery {
dictId: string
}
/** 系统文件类型 */
export type FileItem = {
id: string
name: string
size: number
url: string
extension: string
type: number
storageId: string
createUserString: string
createTime: string
updateUserString: string
updateTime: string
}
export interface FileQuery extends PageQuery {
name?: string
type?: string
}
/** 系统存储类型 */
export type StorageResp = {
id: string

View File

@@ -0,0 +1 @@
<svg t="1684652847211" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2238" width="200" height="200"><path d="M0 0m372.363636 0l279.272728 0q372.363636 0 372.363636 372.363636l0 279.272728q0 372.363636-372.363636 372.363636l-279.272728 0q-372.363636 0-372.363636-372.363636l0-279.272728q0-372.363636 372.363636-372.363636Z" fill="#F7A647" p-id="2239"></path><path d="M232.727273 303.872C232.727273 290.327273 243.781818 279.272727 257.326545 279.272727h148.631273c5.620364 0 10.984727 2.292364 14.848 6.283637l50.897455 52.805818c1.186909 1.233455 2.210909 2.594909 3.037091 4.049454h259.874909c31.138909 0 56.657455 25.460364 56.657454 56.610909V688.058182c0 31.150545-25.518545 56.669091-56.657454 56.669091H289.396364C258.245818 744.727273 232.727273 719.208727 232.727273 688.058182V303.872zM726.702545 556.218182h-239.825454a10.216727 10.216727 0 0 0-10.205091 10.205091v25.390545c0 5.620364 4.584727 10.205091 10.205091 10.205091h239.825454a10.216727 10.216727 0 0 0 10.205091-10.205091v-25.390545a10.216727 10.216727 0 0 0-10.205091-10.205091z m-0.058181-87.691637h-239.825455a10.216727 10.216727 0 0 0-10.205091 10.205091h-0.069818v25.390546c0 5.632 4.573091 10.216727 10.205091 10.216727h239.895273a10.216727 10.216727 0 0 0 10.205091-10.216727V478.72a10.216727 10.216727 0 0 0-10.205091-10.205091z" fill="#FFFFFF" p-id="2240"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,140 @@
<template>
<transition name="slide-dynamic-origin">
<div class="audio-box" ref="audioRef" :style="audioStyle" v-show="visible">
<section style="padding: 10px 14px 14px 14px">
<div class="audio-box__header" ref="audioHeadRef">
<div class="audio-name">
<icon-music :size="16" spin />
<span>{{ props.data?.name }}.{{ props.data?.extension }}</span>
</div>
<div class="close-icon" @click="close">
<icon-close :size="12" />
</div>
</div>
<!-- 音频组件 -->
<audio class="audio" :src="audioSrc" controls autoplay></audio>
</section>
</div>
</transition>
</template>
<script setup lang="ts">
import { useDraggable, useWindowSize, useElementSize } from '@vueuse/core'
import type { FileItem } from '@/apis'
interface Props {
data: FileItem
onClose: () => void
}
const props = withDefaults(defineProps<Props>(), {})
const visible = ref(false)
const audioRef = ref<HTMLElement | null>(null)
const audioHeadRef = ref<HTMLElement | null>(null)
const audioSrc = computed(() => {
return props.data?.url || ''
})
onMounted(() => {
visible.value = true
})
const { width: windowWidth, height: windowHeight } = useWindowSize()
const { width: boxWidth, height: boxHeight } = useElementSize(audioRef)
const axis = ref({ top: 40, left: windowWidth.value - boxWidth.value })
const obj = JSON.parse(sessionStorage.getItem('AudioDialogXY') as string)
if (obj && obj.top && obj.left) {
axis.value.top = obj.top
axis.value.left = obj.left
}
const { x, y } = useDraggable(audioRef, {
initialValue: { x: axis.value.left - boxWidth.value, y: axis.value.top }
})
const audioStyle = computed(() => {
let left: number | string = x.value
let top: number | string = y.value
if (x.value > windowWidth.value - boxWidth.value) {
left = windowWidth.value - boxWidth.value
}
if (x.value < 0) {
left = 0
}
if (y.value > windowHeight.value - boxHeight.value) {
top = windowHeight.value - boxHeight.value
}
if (y.value < 0) {
top = 0
}
sessionStorage.setItem('AudioDialogXY', JSON.stringify({ top, left }))
return {
left: left + 'px',
top: top + 'px'
}
})
const close = () => {
visible.value = false
props.onClose && props.onClose()
}
</script>
<style lang="scss" scoped>
.audio-box {
width: 300px;
position: fixed;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
background: linear-gradient(to right, $color-theme, rgb(var(--primary-2)));
z-index: 9999;
&__header {
color: #fff;
font-size: 16px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: move;
user-select: none;
&:active {
cursor: move;
}
.audio-name {
display: flex;
align-items: center;
> span {
margin-left: 8px;
}
}
.close-icon {
width: 24px;
height: 24px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
background: rgba(0, 0, 0, 0);
transition: all 0.2s;
cursor: pointer;
svg {
transition: all 0.2s;
}
&:hover {
background: rgba(0, 0, 0, 0.1);
svg {
transform: scale(1.3);
}
}
}
}
.audio {
width: 100%;
&::-webkit-media-controls-enclosure {
background: #fff;
}
}
}
</style>

View File

@@ -0,0 +1,45 @@
import type { Component } from 'vue'
import { createApp } from 'vue'
import ArcoVueIcon from '@arco-design/web-vue/es/icon'
import ArcoVue from '@arco-design/web-vue'
import type { FileItem } from '@/apis'
import ModalContent from './ModalContent.vue'
function createModal<T extends { callback?: () => void }>(component: Component, options?: T) {
// 创建一个挂载容器
const el: HTMLElement = document.createElement('div')
// 挂载组件
document.body.appendChild(el)
// 实例化组件, createApp 第二个参数是 props
const instance = createApp(component, {
...options,
onClose: () => {
setTimeout(() => {
instance.unmount()
document.body.removeChild(el)
options?.callback && options?.callback()
}, 350)
}
})
instance.use(ArcoVue)
instance.use(ArcoVueIcon)
instance.mount(el)
}
type TFileOptions = { data: FileItem; callback?: () => void }
/** 预览 音频文件 弹窗 */
let fileAudioId = ''
export function previewFileAudioModal(data: FileItem) {
if (fileAudioId) return // 防止重复打开
fileAudioId = data.id
return createModal<TFileOptions>(ModalContent, {
data: data,
// 关闭的回调
callback: () => {
fileAudioId = ''
}
})
}

View File

@@ -0,0 +1,48 @@
<template>
<a-row justify="center" align="center">
<div style="height: 100px">
<FileImage :data="data" style="border-radius: 5px" />
</div>
</a-row>
<a-row style="margin-top: 15px">
<a-descriptions :column="1" title="详细信息" layout="inline-vertical">
<a-descriptions-item :label="data.name">{{ formatFileSize(data.size) }}</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ data.createTime }}</a-descriptions-item>
<a-descriptions-item label="修改时间">{{ data.updateTime }}</a-descriptions-item>
</a-descriptions>
</a-row>
</template>
<script setup lang="ts">
import type { FileItem } from '@/apis'
import FileImage from '../../main/FileMain/FileImage.vue'
import { formatFileSize } from '@/utils'
interface Props {
data: FileItem
}
withDefaults(defineProps<Props>(), {})
</script>
<style lang="less" scoped>
.label {
color: var(--color-text-2);
}
:deep(.arco-form-item) {
margin-bottom: 0;
}
:deep(.arco-form-item-label-col > label) {
white-space: nowrap;
}
:deep(.arco-descriptions-title) {
font-size: 14px;
}
:deep(.arco-descriptions-item-label-inline) {
font-size: 12px;
}
:deep(.arco-descriptions-item-value-inline) {
font-size: 12px;
margin-bottom: 10px;
}
</style>

View File

@@ -0,0 +1,17 @@
import type { FileItem } from '@/apis'
import { h } from 'vue'
import { Modal } from '@arco-design/web-vue'
import ModalContent from './ModalContent.vue'
/** 打开 详情 弹窗 */
export function openFileDetailModal(fileItem: FileItem) {
return Modal.open({
title: fileItem.extension ? `${fileItem.name}.${fileItem.extension}` : `${fileItem.name}`,
titleAlign: 'start',
modalAnimationName: 'el-fade',
modalStyle: { maxWidth: '320px' },
width: '90%',
footer: false,
content: () => h(ModalContent, { data: fileItem })
})
}

View File

@@ -0,0 +1,27 @@
<template>
<a-row justify="center" align="center" style="padding: 0 5%">
<a-form ref="formRef" :model="form" auto-label-width class="w-full">
<a-form-item
label="文件名称"
field="name"
:rules="[{ required: true, message: '请输入文件名称' }]"
style="margin-bottom: 0"
>
<a-input v-model="form.name" placeholder="请输入文件名称" allow-clear />
</a-form-item>
</a-form>
</a-row>
</template>
<script lang="ts" setup>
import type { FormInstance } from '@arco-design/web-vue'
const formRef = ref<FormInstance>()
const form = reactive({
name: ''
})
defineExpose({ formRef })
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,27 @@
import { updateFile, type FileItem } from '@/apis'
import { ref, h } from 'vue'
import { Modal, Message } from '@arco-design/web-vue'
import ModalContent from './ModalContent.vue'
export function openFileRenameModal(data: FileItem) {
const ModalContentRef = ref<InstanceType<typeof ModalContent>>()
return Modal.open({
title: '重命名',
modalAnimationName: 'el-fade',
modalStyle: { maxWidth: '450px' },
width: '90%',
content: () =>
h(ModalContent, {
ref: (e) => {
ModalContentRef.value = e as any
}
}),
onBeforeOk: async () => {
const isInvalid = await ModalContentRef.value?.formRef?.validate()
if (isInvalid) return false
await updateFile({ name: data.name }, data.id)
Message.success('重命名成功')
return true
}
})
}

View File

@@ -0,0 +1,26 @@
<template>
<div id="videoId"></div>
</template>
<script lang="ts" setup>
import Player from 'xgplayer'
import type { FileItem } from '@/apis'
interface Props {
data: FileItem
}
const props = withDefaults(defineProps<Props>(), {})
onMounted(() => {
new Player({
id: 'videoId',
url: props.data?.url ?? '',
lang: 'zh-cn',
autoplay: true,
closeVideoClick: true,
videoInit: true
})
})
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,13 @@
import { h } from 'vue'
import { Modal } from '@arco-design/web-vue'
import ModalContent from './ModalContent.vue'
import type { FileItem } from '@/apis'
export function previewFileVideoModal(data: FileItem) {
return Modal.open({
title: '视频播放',
width: 'auto',
modalStyle: {},
content: () => h(ModalContent, { data: data })
})
}

View File

@@ -0,0 +1,4 @@
export * from '../components/FileDetailModal/index'
export * from '../components/FileRenameModal/index'
export * from '../components/FileVideoModal/index'
export * from '../components/FileAudioModal/index'

View File

@@ -0,0 +1,25 @@
<template>
<a-row align="stretch" :gutter="14" class="file-manage">
<a-col :xs="0" :sm="8" :md="7" :lg="6" :xl="5" :xxl="4" flex="220px" class="h-full ov-hidden">
<FileAside></FileAside>
</a-col>
<a-col :xs="24" :sm="16" :md="17" :lg="18" :xl="19" :xxl="20" flex="1" class="h-full ov-hidden">
<FileMain></FileMain>
</a-col>
</a-row>
</template>
<script setup lang="ts">
import FileAside from './main/FileAside.vue'
import FileMain from './main/FileMain/index.vue'
defineOptions({ name: 'File' })
</script>
<style lang="scss" scoped>
.file-manage {
flex: 1;
padding: $margin;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,104 @@
<template>
<div>
<a-card title="文件管理" :bordered="false" :body-style="{ padding: 0 }">
<a-menu :default-open-keys="['0']" :selected-keys="[selectedKey]">
<a-sub-menu key="0">
<template #icon>
<icon-apps></icon-apps>
</template>
<template #title>文件类型</template>
<a-menu-item v-for="item in FileTypeList" :key="item.value.toString()" @click="onClickItem(item)">
<template #icon>
<GiSvgIcon :size="28" :name="item.menuIcon"></GiSvgIcon>
</template>
<span>{{ item.name }}</span>
</a-menu-item>
</a-sub-menu>
</a-menu>
</a-card>
<section class="percent">
<a-row justify="space-between">
<a-statistic title="总存储量" :value="512" :value-style="{ color: '#5856D6' }">
<template #prefix>
<icon-cloud />
</template>
<template #suffix>GB</template>
</a-statistic>
</a-row>
<a-space size="mini" fill direction="vertical" :key="selectedKey" class="gi_mt">
<a-progress
v-for="i in filePercentList"
:key="i.label"
:percent="i.value"
:stroke-width="8"
:color="i.color"
:animation="true"
>
<template #text>{{ i.label }}</template>
</a-progress>
</a-space>
</section>
</div>
</template>
<script setup lang="ts">
import { FileTypeList, type FileTypeListItem } from '@/constant/file'
const route = useRoute()
const router = useRouter()
const selectedKey = ref('0')
const filePercentList = [
{ label: '图片', value: 0.7, color: '#4F6BF6' },
{ label: '文档', value: 0.3, color: '#FFA000' },
{ label: '视频', value: 0.4, color: '#A15FDE' },
{ label: '音频', value: 0.2, color: '#FD6112' },
{ label: '其他', value: 0.5, color: '#52B852' }
]
// 监听路由变化
watch(
() => route.query,
() => {
if (route.query.type) {
selectedKey.value = route.query.type as string
}
},
{
immediate: true
}
)
// 点击事件
const onClickItem = (item: FileTypeListItem) => {
router.push({ name: 'File', query: { type: item.value } })
}
</script>
<style lang="scss" scoped>
:deep(.arco-card) {
.arco-card-header {
border-bottom-style: dashed;
margin: 0 16px;
padding-left: 0;
padding-right: 0;
}
}
:deep(.arco-progress) {
.arco-progress-line,
.arco-progress-line-bar-buffer,
.arco-progress-line-bar {
border-radius: 0;
}
}
.percent {
margin-top: 10px;
padding: 14px 12px;
box-sizing: border-box;
background-color: var(--color-bg-1);
}
</style>

View File

@@ -0,0 +1,170 @@
<template>
<div class="file-grid">
<a-grid :cols="{ xs: 4, sm: 4, md: 5, lg: 7, xl: 8, xxl: 9 }" :col-gap="12" :row-gap="12">
<a-trigger
v-for="item in data"
:key="item.id"
trigger="contextMenu"
align-point
animation-name="slide-dynamic-origin"
auto-fit-transform-origin
position="bl"
update-at-scroll
scroll-to-close
>
<a-grid-item>
<div class="file-grid-item" @click.stop="handleClickFile(item)">
<section class="file-grid-item__wrapper">
<div class="file-icon">
<FileImage :data="item" :title="item.name"></FileImage>
</div>
<p class="gi_line_1 file-name">{{ getFileName(item) }}</p>
</section>
<!-- 勾选模式 -->
<section
v-show="props.isBatchMode"
class="check-mode"
:class="{ checked: props.selectedFileIds.includes(item.id) }"
@click.stop="handleCheckFile(item)"
>
<a-checkbox
class="checkbox"
:model-value="props.selectedFileIds.includes(item.id)"
@change="handleCheckFile(item)"
></a-checkbox>
</section>
</div>
</a-grid-item>
<template #content>
<FileRightMenu :data="item" @click="handleRightMenuClick($event, item)"></FileRightMenu>
</template>
</a-trigger>
</a-grid>
</div>
</template>
<script setup lang="ts">
import FileImage from './FileImage.vue'
import FileRightMenu from './FileRightMenu.vue'
import type { FileItem } from '@/apis'
interface Props {
data?: FileItem[]
selectedFileIds?: string[]
isBatchMode?: boolean
}
const props = withDefaults(defineProps<Props>(), {
data: () => [], // 文件数据
selectedFileIds: () => [], // 批量模式下选中的文件id数组
isBatchMode: false // 是否是批量模式
})
const emit = defineEmits<{
(e: 'click', record: FileItem): void
(e: 'select', record: FileItem): void
(e: 'right-menu-click', mode: string, item: FileItem): void
}>()
// 文件名称带后缀
const getFileName = (item: FileItem) => {
return `${item.name}${item.extension ? `.${item.extension}` : ''}`
}
// 点击事件
const handleClickFile = (item: FileItem) => {
emit('click', item)
}
// 选中事件
const handleCheckFile = (item: FileItem) => {
emit('select', item)
}
// 右键菜单点击事件
const handleRightMenuClick = (mode: string, item: FileItem) => {
emit('right-menu-click', mode, item)
}
</script>
<style lang="scss" scoped>
.file-grid {
flex: 1;
margin-top: 12px;
overflow: scroll;
background: var(--color-bg-2);
}
.file-grid-item {
width: 100%;
height: 100px;
display: flex;
justify-content: center;
align-items: center;
position: relative;
cursor: pointer;
&:hover {
background: var(--color-primary-light-1);
}
&:active {
svg,
img {
transform: scale(0.9);
}
}
.check-mode {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.1);
z-index: 9;
&.checked {
background: none;
}
.checkbox {
position: absolute;
top: 5px;
left: 5px;
padding-left: 0;
}
}
&__wrapper {
width: 76%;
max-width: 100px;
height: 100%;
position: relative;
overflow: hidden;
z-index: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.file-icon {
width: 100%;
height: 60px;
display: flex;
justify-content: center;
overflow: hidden;
> img {
width: auto;
height: 100%;
transition: all 0.3s;
}
> svg {
height: 100%;
transition: all 0.3s;
}
}
.file-name {
width: 100%;
font-size: 12px;
margin-top: 8px;
padding: 0 5px;
text-align: center;
box-sizing: border-box;
}
}
}
</style>

View File

@@ -0,0 +1,41 @@
<template>
<img v-if="isImage" class="file-image" :src="props.data.url" alt="" />
<GiSvgIcon v-else size="100%" :name="getFileImg"></GiSvgIcon>
</template>
<script setup lang="ts">
import { FileIcon, ImageTypes } from '@/constant/file'
import type { FileItem } from '@/apis'
interface Props {
data: FileItem
}
const props = withDefaults(defineProps<Props>(), {})
// 是否是图片类型文件
const isImage = computed(() => {
const extension = props.data.extension.toLowerCase()
return ImageTypes.includes(extension)
})
// 获取文件图标,如果是图片就显示图片
const getFileImg = computed<string>(() => {
const extension = props.data.extension.toLowerCase()
if (ImageTypes.includes(extension)) {
return props.data.url || ''
}
if (!Object.keys(FileIcon).includes(extension)) {
return FileIcon['other']
}
return FileIcon[extension]
})
</script>
<style lang="scss" scoped>
.file-image {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>

View File

@@ -0,0 +1,135 @@
<template>
<div class="file-list">
<a-table
row-key="id"
:scroll="{ x: '100%', y: '100%', minWidth: 800 }"
:data="props.data"
:bordered="false"
:pagination="false"
:row-selection="isBatchMode ? rowSelection : undefined"
:selected-keys="selectedFileIds"
@select="select"
@row-click="handleRowClick"
>
<template #columns>
<a-table-column title="名称">
<template #cell="{ record }">
<a-trigger
trigger="contextMenu"
align-point
animation-name="slide-dynamic-origin"
auto-fit-transform-origin
position="bl"
update-at-scroll
scroll-to-close
>
<section class="file-name">
<div class="file-image">
<FileImage :data="record"></FileImage>
</div>
<span>{{ record.name }}</span>
</section>
<template #content>
<FileRightMenu :data="record" @click="handleRightMenuClick($event, record)"></FileRightMenu>
</template>
</a-trigger>
</template>
</a-table-column>
<a-table-column title="文件大小" data-index="size" :width="150">
<template #cell="{ record }">{{ formatFileSize(record.size) }}</template>
</a-table-column>
<a-table-column title="扩展名" data-index="extension" :width="100">
<template #cell="{ record }">
<a-tag v-if="record.extension" size="small" color="purple">{{ record.extension }}</a-tag>
</template>
</a-table-column>
<a-table-column title="修改时间" data-index="updateTime" :width="200"></a-table-column>
<a-table-column title="操作" :width="120" align="center">
<template #cell="{ record }">
<a-popover trigger="click" position="bottom" :content-style="{ padding: 0, 'margin-top': 0 }">
<a-button type="text" @click.stop><icon-more :size="16" /></a-button>
<template #content>
<FileRightMenu
:file-info="record"
:shadow="false"
@click="handleRightMenuClick($event, record)"
></FileRightMenu>
</template>
</a-popover>
</template>
</a-table-column>
</template>
</a-table>
</div>
</template>
<script setup lang="ts">
import type { FileItem } from '@/apis'
import type { TableRowSelection, TableInstance } from '@arco-design/web-vue'
import FileImage from './FileImage.vue'
import FileRightMenu from './FileRightMenu.vue'
import { formatFileSize } from '@/utils'
interface Props {
data?: FileItem[]
selectedFileIds?: string[]
isBatchMode?: boolean
}
const props = withDefaults(defineProps<Props>(), {
data: () => [], // 文件数据
selectedFileIds: () => [],
isBatchMode: false // 是否是批量模式
})
const rowSelection: TableRowSelection = reactive({
type: 'checkbox',
showCheckedAll: true
})
const emit = defineEmits<{
(e: 'click', record: FileItem): void
(e: 'select', record: FileItem): void
(e: 'right-menu-click', mode: string, item: FileItem): void
}>()
// 多选
const select: TableInstance['onSelect'] = (rowKeys, rowKey, record) => {
emit('select', record as unknown as FileItem)
}
// 行点击事件
const handleRowClick: TableInstance['onRowClick'] = (record) => {
emit('click', record as unknown as FileItem)
}
// 右键菜单点击事件
const handleRightMenuClick = (mode: string, item: FileItem) => {
emit('right-menu-click', mode, item)
}
</script>
<style lang="scss" scoped>
:deep(.arco-table-td .arco-table-cell) {
padding-top: 0;
padding-bottom: 0;
}
.file-list {
width: 100%;
padding-top: 12px;
overflow: hidden;
.file-name {
height: 100%;
display: flex;
align-items: center;
padding-top: 6px;
padding-bottom: 6px;
cursor: pointer;
}
.file-image {
width: 30px;
height: 30px;
margin-right: 10px;
}
}
</style>

View File

@@ -0,0 +1,42 @@
<template>
<GiOption :class="{ shadow: props.shadow }">
<GiOptionItem label="重命名" icon="menu-edit" @click="onClickItem('rename')"> </GiOptionItem>
<GiOptionItem label="详情" icon="menu-detail" @click="onClickItem('detail')"> </GiOptionItem>
<GiOptionItem label="下载" icon="menu-download" @click="onClickItem('download')"></GiOptionItem>
<GiOptionItem label="删除" icon="menu-delete" @click="onClickItem('delete')"> </GiOptionItem>
</GiOption>
</template>
<script setup lang="ts">
import GiOption from '@/components/GiOption/index.vue'
import GiOptionItem from '@/components/GiOptionItem/index.vue'
import type { FileItem } from '@/apis'
interface Props {
data?: FileItem
shadow?: boolean
}
const props = withDefaults(defineProps<Props>(), {
shadow: true
})
const emit = defineEmits<{
(e: 'click', mode: string): void
}>()
const onClickItem = (mode: string) => {
emit('click', mode)
}
</script>
<style lang="scss" scoped>
.shadow {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.12), 0 0 6px rgba(0, 0, 0, 0.04);
border-radius: 4px;
overflow: hidden;
border: 1px solid var(--color-border-2);
box-sizing: border-box;
background: var(--color-bg-popup);
}
</style>

View File

@@ -0,0 +1,261 @@
<template>
<div class="file-main">
<a-row justify="space-between" class="file-main__search">
<!-- 左侧区域 -->
<a-space wrap>
<a-dropdown>
<a-upload :show-file-list="false" :custom-request="handleUpload">
<template #upload-button>
<a-button type="primary" shape="round">
<template #icon>
<icon-upload />
</template>
<template #default>上传</template>
</a-button>
</template>
</a-upload>
</a-dropdown>
<a-input-group>
<a-input v-model="queryForm.name" placeholder="请输入文件名" allow-clear @change="search" />
<a-button type="primary" @click="search">
<template #icon>
<icon-search />
</template>
<template #default>查询</template>
</a-button>
</a-input-group>
</a-space>
<!-- 右侧区域 -->
<a-space wrap>
<a-button
v-if="isBatchMode"
:disabled="!selectedFileIds.length"
type="primary"
status="danger"
@click="handleMulDelete"
>
<template #icon>
<icon-delete />
</template>
</a-button>
<a-button type="primary" @click="isBatchMode = !isBatchMode">
<template #icon>
<icon-select-all />
</template>
<template #default>{{ isBatchMode ? '取消批量' : '批量操作' }}</template>
</a-button>
<a-button-group>
<a-tooltip content="视图" position="bottom">
<a-button @click="toggleMode">
<template #icon>
<icon-apps v-if="mode === 'grid'" />
<icon-list v-else />
</template>
</a-button>
</a-tooltip>
</a-button-group>
</a-space>
</a-row>
<!-- 文件列表-宫格模式 -->
<a-spin class="file-main__list" :loading="loading">
<FileGrid
v-show="fileList.length && mode == 'grid'"
:data="fileList"
:is-batch-mode="isBatchMode"
:selected-file-ids="selectedFileIds"
@click="handleClickFile"
@select="handleSelectFile"
@right-menu-click="handleRightMenuClick"
></FileGrid>
<!-- 文件列表-列表模式 -->
<FileList
v-show="fileList.length && mode == 'list'"
:data="fileList"
:is-batch-mode="isBatchMode"
:selected-file-ids="selectedFileIds"
@click="handleClickFile"
@select="handleSelectFile"
@right-menu-click="handleRightMenuClick"
></FileList>
<a-empty v-show="!fileList.length"></a-empty>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { listFile, uploadFile, deleteFile, type FileItem, type FileQuery } from '@/apis'
import { Message, Modal, type RequestOption } from '@arco-design/web-vue'
import FileGrid from './FileGrid.vue'
import {
openFileDetailModal,
openFileRenameModal,
previewFileVideoModal,
previewFileAudioModal
} from '../../components/index'
import useFileManage from './useFileManage'
import { ImageTypes } from '@/constant/file'
import { api as viewerApi } from 'v-viewer'
import 'viewerjs/dist/viewer.css'
const FileList = defineAsyncComponent(() => import('./FileList.vue'))
const route = useRoute()
const { mode, selectedFileIds, toggleMode, addSelectedFileItem } = useFileManage()
const queryForm = reactive({
name: undefined,
type: route.query.type?.toString() || undefined,
sort: ['updateTime,desc']
})
const fileList = ref<FileItem[]>([])
const isBatchMode = ref(false)
const loading = ref(false)
// 查询文件列表
const getFileList = async (query: FileQuery = { ...queryForm, page: 1, size: 50 }) => {
try {
loading.value = true
isBatchMode.value = false
query.type = query.type === '0' ? undefined : query.type
const res = await listFile(query)
fileList.value = res.data
} catch (error) {
return error
} finally {
loading.value = false
}
}
// 点击文件
const handleClickFile = (item: FileItem) => {
if (ImageTypes.includes(item.extension)) {
if (item.url) {
const imgList: string[] = fileList.value.filter((i) => ImageTypes.includes(i.extension)).map((a) => a.url || '')
const index = imgList.findIndex((i) => i === item.url)
if (imgList.length) {
viewerApi({
options: {
initialViewIndex: index
},
images: imgList
})
}
}
}
if (item.extension === 'mp4') {
previewFileVideoModal(item)
}
if (item.extension === 'mp3') {
previewFileAudioModal(item)
}
}
// 右键菜单
const handleRightMenuClick = (mode: string, fileInfo: FileItem) => {
if (mode === 'delete') {
Modal.warning({
title: '提示',
content: `是否确定删除文件 [${fileInfo.name}]`,
hideCancel: false,
okButtonProps: { status: 'danger' },
onOk: async () => {
await deleteFile(fileInfo.id)
Message.success('删除成功')
search()
}
})
} else if (mode === 'rename') {
openFileRenameModal(fileInfo)
} else if (mode === 'detail') {
openFileDetailModal(fileInfo)
}
}
// 勾选文件
const handleSelectFile = (item: FileItem) => {
addSelectedFileItem(item)
}
// 批量删除
const handleMulDelete = () => {
Modal.warning({
title: '提示',
content: `是否确定删除所选的${selectedFileIds.value.length}个文件?`,
hideCancel: false,
onOk: () => {
deleteFile(selectedFileIds.value)
Message.success('删除成功')
search()
}
})
}
// 上传
const handleUpload = (options: RequestOption) => {
const controller = new AbortController()
;(async function requestWrap() {
const { onProgress, onError, onSuccess, fileItem, name = 'file' } = options
onProgress(20)
const formData = new FormData()
formData.append(name as string, fileItem.file as Blob)
try {
const res = uploadFile(formData)
Message.success('上传成功')
onSuccess(res)
search()
} catch (error) {
onError(error)
}
})()
return {
abort() {
controller.abort()
}
}
}
// 查询
const search = () => {
getFileList()
}
onBeforeRouteUpdate((to) => {
if (!to.query.type) return
queryForm.type = to.query.type?.toString()
search()
})
onMounted(() => {
search()
})
</script>
<style lang="scss" scoped>
.file-main {
height: 100%;
background: var(--color-bg-1);
border-radius: $radius-box;
display: flex;
flex-direction: column;
overflow: hidden;
&__search {
border-bottom: 1px dashed var(--color-border-3);
margin: 16px $padding 0;
}
&__list {
flex: 1;
padding: 0 $padding $padding;
box-sizing: border-box;
overflow: hidden;
display: flex;
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,33 @@
import { ref, computed } from 'vue'
import type { FileItem } from '@/apis'
type Mode = 'grid' | 'list'
export default function () {
const mode = ref<Mode>('grid')
const selectedFileList = ref<FileItem[]>([]) // 已选的文件列表
const selectedFileIds = computed(() => selectedFileList.value.map((i) => i.id))
// 切换视图
const toggleMode = () => {
mode.value = mode.value === 'grid' ? 'list' : 'grid'
}
// 添加选择的文件
const addSelectedFileItem = (item: FileItem) => {
if (selectedFileIds.value.includes(item.id)) {
const index = selectedFileList.value.findIndex((i) => i.id === item.id)
selectedFileList.value.splice(index, 1)
} else {
selectedFileList.value.push(item)
}
}
return {
mode,
selectedFileList,
selectedFileIds,
toggleMode,
addSelectedFileItem
}
}