im 管理后台

This commit is contained in:
richjohnrambo
2026-08-11 23:13:51 +08:00
parent 0397fe5f22
commit 331a55751d
1407 changed files with 162069 additions and 2 deletions
+1
View File
@@ -0,0 +1 @@
export { default as BasicUpload } from './src/BasicUpload.vue';
@@ -0,0 +1,197 @@
<template>
<div>
<n-modal
v-model:show="showModal"
:mask-closable="false"
:show-icon="false"
preset="dialog"
:on-after-leave="handleRemove"
:style="{
width: width,
}"
title="上传大文件"
>
<n-upload
directory-dnd
:custom-request="handleUpload"
:on-remove="handleRemove"
name="file"
:disabled="uploadStatus != 0 && uploadStatus != 3"
>
<n-upload-dragger>
<div style="margin-bottom: 12px">
<n-icon size="48" :depth="3">
<FileAddOutlined />
</n-icon>
</div>
<template v-if="uploadStatus == 0 || uploadStatus == 3">
<n-text style="font-size: 16px">点击或者拖动{{ typeTag }}到该区域来上传</n-text>
<n-p depth="3" style="margin: 8px 0 0 0">支持大文件分片上传,支持断点续传</n-p>
</template>
<template v-else-if="uploadStatus == 1">
<span style="font-weight: 600">解析中,请稍候...</span>
</template>
<template v-else-if="uploadStatus == 2">
<span style="font-weight: 600">正在上传({{ progress }}%)...</span>
<n-p depth="3" style="margin: 8px 0 0 0">文件大小:{{ sizeFormat }}</n-p>
</template>
</n-upload-dragger>
</n-upload>
</n-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { NModal, UploadCustomRequestOptions, useMessage, useDialog } from 'naive-ui';
import { FileAddOutlined } from '@vicons/antd';
import SparkMD5 from 'spark-md5';
import { Attachment, FileType, getFileType } from '@/components/FileChooser/src/model';
import { CheckMultipart, UploadPart } from '@/api/base';
import type { UploadFileParams } from '@/utils/http/axios/types';
export interface Props {
width?: string;
uploadType?: FileType;
}
const props = withDefaults(defineProps<Props>(), {
width: '60%',
uploadType: 'default',
});
const emit = defineEmits(['onFinish']);
const message = useMessage();
const dialog = useDialog();
const showModal = ref(false);
const chunkSize = 2 * 1024 * 1024; // 每个分片大小限制,默认2M
const uploadStatus = ref(0); // 上传状态 0等待上传 1解析中 2上传中 3已取消
const progress = ref(0);
const sizeFormat = ref('0B');
const typeTag = computed(() => {
return getFileType(props.uploadType);
});
// 取消上传
function handleRemove() {
if (uploadStatus.value == 1 || uploadStatus.value == 2) {
uploadStatus.value = 3;
dialog.info({
title: '提示',
content: '已取消大文件上传,已上传的文件不会自动删除,重新操作可进行断点续传',
positiveText: '确定',
});
}
}
// 开始上传
function handleUpload(options: UploadCustomRequestOptions) {
uploadStatus.value = 1;
// 初始化上传进度
updateProgress(options, 0);
const file = options.file.file as File;
const fileReader = new FileReader();
fileReader.readAsArrayBuffer(file);
fileReader.onload = async (e) => {
const spark = new SparkMD5.ArrayBuffer();
spark.append(e.target.result);
let md5 = spark.end();
let start = 0;
let end = 0;
let index = 0;
let shards: any[] = [];
while (end < file.size) {
start = index * chunkSize;
end = (index + 1) * chunkSize;
const params: UploadFileParams = {
uploadType: props.uploadType,
md5: md5,
index: index + 1,
fileName: file.name,
file: file.slice(start, end),
};
const shard = { index: index + 1, params: params };
shards.push(shard);
index++;
}
uploadStatus.value = 2;
const params = {
uploadType: props.uploadType,
fileName: file.name,
size: file.size,
md5: md5,
shardCount: shards.length,
};
CheckMultipart(params)
.then(async (res) => {
// 已存在
if (!res.waitUploadIndex || res.waitUploadIndex.length == 0) {
onFinish(options, res.attachment);
return;
}
// 断点续传,过滤掉已上传成功的分片文件
shards = shards.filter((shard) => res.waitUploadIndex.includes(shard.index));
if (shards.length == 0) {
onFinish(options, res.attachment);
return;
}
// 导入断点续传进度
updateProgress(options, res.progress);
sizeFormat.value = res.sizeFormat;
for (const item of shards) {
if (uploadStatus.value == 3) {
break;
}
item.params.uploadId = res.uploadId;
await handleUploadPart(options, item);
}
})
.catch(() => {
uploadStatus.value = 0;
options.onError();
});
};
}
// 上传分片文件
async function handleUploadPart(options: UploadCustomRequestOptions, item) {
const res = await UploadPart(item.params);
updateProgress(options, res.progress);
if (res.finish) {
onFinish(options, res.attachment);
}
}
// 更新上传进度
function updateProgress(options: UploadCustomRequestOptions, value: number) {
options.onProgress({ percent: value });
progress.value = value;
}
// 上传成功后的回调
function onFinish(options: UploadCustomRequestOptions, result: Attachment) {
options.onFinish();
message.success('上传成功');
uploadStatus.value = 0;
emit('onFinish', result, true);
}
function openModal() {
showModal.value = true;
uploadStatus.value = 0;
}
defineExpose({
openModal,
});
</script>
@@ -0,0 +1,412 @@
<template>
<div>
<div class="w-full">
<div class="upload">
<div class="upload-card">
<!--图片列表-->
<div
class="upload-card-item"
:style="getCSSProperties"
v-for="(item, index) in imgList"
:key="`img_${index}`"
>
<div class="upload-card-item-info">
<div class="img-box">
<template v-if="fileType === 'image'">
<img :src="item" @error="errorImg($event)" />
</template>
<template v-else>
<n-avatar :style="fileAvatarCSS">{{ getFileExt(item) }}</n-avatar>
</template>
</div>
<div class="img-box-actions">
<template v-if="fileType === 'image'">
<n-icon size="18" class="mx-2 action-icon" @click="preview(item)">
<EyeOutlined />
</n-icon>
</template>
<template v-else>
<n-icon size="18" class="mx-2 action-icon" @click="download(item)">
<CloudDownloadOutlined />
</n-icon>
</template>
<n-icon size="18" class="mx-2 action-icon" @click="remove(index)">
<DeleteOutlined />
</n-icon>
</div>
</div>
</div>
<!--上传图片-->
<div
class="upload-card-item upload-card-item-select-picture"
:style="getCSSProperties"
v-if="imgList.length < maxNumber"
>
<n-upload
v-bind="$props"
:file-list-style="{ display: 'none' }"
@before-upload="beforeUpload"
@finish="finish"
>
<div class="flex flex-col justify-center">
<n-icon size="18" class="m-auto">
<PlusOutlined />
</n-icon>
<span class="upload-title">{{ uploadTitle }}</span>
</div>
</n-upload>
</div>
</div>
</div>
<!--上传图片-->
<n-space>
<n-alert title="提示" type="info" v-if="helpText" class="flex w-full">
{{ helpText }}
</n-alert>
</n-space>
</div>
<Preview ref="previewRef" />
</div>
</template>
<script lang="ts">
import { defineComponent, toRefs, reactive, computed, watch, onMounted, ref } from 'vue';
import { EyeOutlined, DeleteOutlined, PlusOutlined, CloudDownloadOutlined } from '@vicons/antd';
import { basicProps } from './props';
import { useMessage, useDialog } from 'naive-ui';
import Preview from '@/components/FileChooser/src/Preview.vue';
import { ResultEnum } from '@/enums/httpEnum';
import componentSetting from '@/settings/componentSetting';
import { useGlobSetting } from '@/hooks/setting';
import { isArray, isJsonString, isNullOrUnDef, isUrl } from '@/utils/is';
import { getFileExt } from '@/utils/urlUtils';
import { errorImg } from '@/utils/hotgo';
const globSetting = useGlobSetting();
export default defineComponent({
name: 'BasicUpload',
components: { EyeOutlined, DeleteOutlined, PlusOutlined, CloudDownloadOutlined },
props: {
...basicProps,
},
emits: ['uploadChange', 'delete'],
setup(props, { emit }) {
const getCSSProperties = computed(() => {
return {
width: `${props.width}px`,
height: `${props.height}px`,
};
});
const previewRef = ref();
const message = useMessage();
const dialog = useDialog();
const uploadTitle = ref(props.fileType === 'image' ? '上传图片' : '上传附件');
const maxNumber = computed(() => {
return props.maxNumber;
});
const fileAvatarCSS = computed(() => {
return {
'--n-merged-size': `var(--n-avatar-size-override, ${props.width * 0.8}px)`,
'--n-font-size': `18px`,
};
});
const state = reactive({
showModal: false,
previewUrl: '',
originalImgList: [] as string[],
imgList: [] as string[],
});
//赋值默认图片显示
watch(
() => props.value,
() => {
loadValue(props.value);
return;
},
{
immediate: true,
deep: true,
}
);
watch(
() => props.values,
() => {
loadValue(props.values);
return;
},
{
immediate: true,
deep: true,
}
);
// 加载默认
function loadValue(value: any) {
if (value === undefined || value === null) {
return;
}
let data: string[] = [];
if (isJsonString(value)) {
value = JSON.parse(value);
}
// 单图模式
if (typeof value === 'string') {
if (value !== '') {
data.push(value);
}
} else {
// 多图模式
data = value;
}
if (!isArray(data) || data.length === 0) {
return;
}
state.imgList = data.map((item) => {
return getImgUrl(item);
});
state.originalImgList = state.imgList;
}
//预览
function preview(url: string) {
previewRef.value.openPreview(url);
}
//下载
function download(url: string) {
window.open(url);
}
//删除
function remove(index: number) {
dialog.info({
title: '提示',
content: '你确定要删除吗?',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
state.imgList.splice(index, 1);
state.originalImgList.splice(index, 1);
if (props.maxNumber === 1) {
emit('uploadChange', '');
} else {
emit('uploadChange', state.originalImgList);
}
emit('delete', state.originalImgList);
},
onNegativeClick: () => {},
});
}
//组装完整图片地址
function getImgUrl(url: string): string {
const { imgUrl } = globSetting;
return isUrl(url) ? url : `${imgUrl}${url}`;
}
function checkFileType(map: string[], fileType: string) {
if (isNullOrUnDef(map)) {
return true;
}
return map.includes(fileType);
}
//上传之前
function beforeUpload({ file }) {
const fileInfo = file.file;
// 设置最大值,则判断
if (props.maxSize && fileInfo.size / 1024 / 1024 >= props.maxSize) {
message.error(`上传文件最大值不能超过${props.maxSize}M`);
return false;
}
// 设置类型,则判断
const fileType =
props.fileType === 'image'
? componentSetting.upload.imageType
: componentSetting.upload.fileType;
if (!checkFileType(fileType, fileInfo.type)) {
console.log('checkFileType fileInfo.type:' + fileInfo.type);
message.error(`只能上传文件类型为${fileType.join(',')}`);
return false;
}
return true;
}
//上传结束
function finish({ event: Event }) {
const res = JSON.parse(Event.target.response);
const infoField = componentSetting.upload.apiSetting.infoField;
const imgField = componentSetting.upload.apiSetting.imgField;
const { code } = res;
const msg = res.msg || res.message || '上传失败';
const result = res[infoField];
//成功
if (code === ResultEnum.SUCCESS) {
let imgUrl: string = getImgUrl(result[imgField]);
state.imgList.push(imgUrl);
state.originalImgList = state.imgList;
if (props.maxNumber === 1) {
emit('uploadChange', imgUrl);
} else {
emit('uploadChange', state.originalImgList);
}
} else {
message.error(msg);
}
}
onMounted(async () => {
setTimeout(function () {
if (props.maxNumber === 1) {
loadValue(props.value);
} else {
loadValue(props.values);
}
}, 50);
});
return {
errorImg,
...toRefs(state),
finish,
preview,
download,
remove,
beforeUpload,
getCSSProperties,
uploadTitle,
fileAvatarCSS,
getFileExt,
Preview,
previewRef,
maxNumber,
};
},
});
</script>
<style lang="less">
.n-upload {
width: auto; /** 居中 */
}
.upload {
width: 100%;
overflow: hidden;
&-card {
width: auto;
height: auto;
display: flex;
flex-wrap: wrap;
align-items: center;
&-item {
margin: 0 8px 8px 0;
position: relative;
padding: 8px;
border: 1px solid #d9d9d9;
border-radius: 2px;
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
&:hover {
background: 0 0;
.upload-card-item-info::before {
opacity: 1;
}
&-info::before {
opacity: 1;
}
}
&-info {
position: relative;
height: 100%;
padding: 0;
overflow: hidden;
&:hover {
.img-box-actions {
opacity: 1;
}
}
&::before {
position: absolute;
z-index: 1;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: all 0.3s;
content: ' ';
}
.img-box {
position: relative;
//padding: 8px;
//border: 1px solid #d9d9d9;
border-radius: 2px;
}
.img-box-actions {
position: absolute;
top: 50%;
left: 50%;
z-index: 10;
white-space: nowrap;
transform: translate(-50%, -50%);
opacity: 0;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: space-between;
&:hover {
background: 0 0;
}
.action-icon {
color: rgba(255, 255, 255, 0.85);
&:hover {
cursor: pointer;
color: #fff;
}
}
}
}
}
&-item-select-picture {
border: 1px dashed #d9d9d9;
border-radius: 2px;
cursor: pointer;
background: #fafafa;
color: #666;
.upload-title {
color: #666;
}
}
}
}
</style>
+42
View File
@@ -0,0 +1,42 @@
import type { PropType } from 'vue';
import { NUpload } from 'naive-ui';
export const basicProps = {
...NUpload.props,
fileType: {
type: String,
default: 'image',
},
accept: {
type: String,
default: '.jpg,.png,.jpeg,.svg,.gif,.webp',
},
helpText: {
type: String as PropType<string>,
default: '',
},
maxSize: {
type: Number as PropType<number>,
default: 2,
},
maxNumber: {
type: Number as PropType<number>,
default: Infinity,
},
value: {
type: String as PropType<string>,
default: () => '',
},
values: {
type: (Array as PropType<string[]>) || (Object as PropType<object>),
default: () => [],
},
width: {
type: Number as PropType<number>,
default: 104,
},
height: {
type: Number as PropType<number>,
default: 104, //建议不小于这个尺寸 太小页面可能显示有异常
},
};
@@ -0,0 +1,7 @@
export interface BasicProps {
title?: string;
dataSource: Function;
columns: any[];
pagination: object;
showPagination: boolean;
}
+80
View File
@@ -0,0 +1,80 @@
<template>
<div>
<BasicUpload
:action="`${uploadUrl}${urlPrefix}/upload/file`"
:headers="uploadHeaders"
:data="{ type: 0 }"
accept="*"
name="file"
:width="100"
:height="100"
fileType="file"
:maxNumber="maxNumber"
:helpText="helpText"
@uploadChange="uploadChange"
v-model:value="image"
v-model:values="images"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, unref, reactive, watch } from 'vue';
import { BasicUpload } from '@/components/Upload';
import { useGlobSetting } from '@/hooks/setting';
import { useUserStoreWidthOut } from '@/store/modules/user';
export interface Props {
value: string | string[] | null;
maxNumber: number;
helpText?: string;
}
const globSetting = useGlobSetting();
const urlPrefix = globSetting.urlPrefix || '';
const { uploadUrl } = globSetting;
const useUserStore = useUserStoreWidthOut();
const uploadHeaders = reactive({
Authorization: useUserStore.token,
uploadType: 'default',
});
const emit = defineEmits(['update:value']);
const props = withDefaults(defineProps<Props>(), { value: '', maxNumber: 1, helpText: '' });
const image = ref<string>('');
const images = ref<string[] | object>([]);
function uploadChange(list: string | string[]) {
if (props.maxNumber === 1) {
image.value = unref(list as string);
emit('update:value', image.value);
} else {
images.value = unref(list as string[]);
emit('update:value', images.value);
}
}
function loadImage() {
if (props.maxNumber === 1) {
image.value = props.value as string;
} else {
images.value = props.value as string[];
}
}
watch(
() => props.value,
() => {
loadImage();
},
{
immediate: true,
deep: true,
}
);
onMounted(async () => {
loadImage();
});
</script>
<style lang="less"></style>
+76
View File
@@ -0,0 +1,76 @@
<template>
<BasicUpload
:action="`${uploadUrl}${urlPrefix}/upload/file`"
:headers="uploadHeaders"
:data="{ type: 0 }"
name="file"
:width="100"
:height="100"
:maxNumber="maxNumber"
:helpText="helpText"
@uploadChange="uploadChange"
v-model:value="image"
v-model:values="images"
/>
</template>
<script lang="ts" setup>
import { onMounted, reactive, ref, unref, watch } from 'vue';
import { BasicUpload } from '@/components/Upload';
import { useGlobSetting } from '@/hooks/setting';
import { useUserStoreWidthOut } from '@/store/modules/user';
export interface Props {
value: string | string[] | null;
maxNumber: number;
helpText?: string;
}
const emit = defineEmits(['update:value']);
const props = withDefaults(defineProps<Props>(), { value: '', maxNumber: 1, helpText: '' });
const image = ref<string>('');
const images = ref<string[]>([]);
const globSetting = useGlobSetting();
const urlPrefix = globSetting.urlPrefix || '';
const { uploadUrl } = globSetting;
const useUserStore = useUserStoreWidthOut();
const uploadHeaders = reactive({
Authorization: useUserStore.token,
uploadType: 'image',
});
function uploadChange(list: string | string[]) {
if (props.maxNumber === 1) {
image.value = unref(list as string);
emit('update:value', image.value);
} else {
images.value = unref(list as string[]);
emit('update:value', images.value);
}
}
function loadImage() {
if (props.maxNumber === 1) {
image.value = props.value as string;
} else {
images.value = props.value as string[];
}
}
watch(
() => props.value,
() => {
loadImage();
},
{
immediate: true,
deep: true,
}
);
onMounted(async () => {
loadImage();
});
</script>
<style lang="less"></style>