448 lines
9.3 KiB
Plaintext
448 lines
9.3 KiB
Plaintext
<template>
|
||
<view class="cl-upload-list" :class="[pt.className]">
|
||
<view
|
||
v-for="(item, index) in list"
|
||
:key="item.uid"
|
||
class="cl-upload"
|
||
:class="[
|
||
{
|
||
'is-dark': isDark,
|
||
'is-disabled': isDisabled
|
||
},
|
||
pt.item?.className
|
||
]"
|
||
:style="uploadStyle"
|
||
@tap="choose(index)"
|
||
>
|
||
<image
|
||
class="cl-upload__image"
|
||
:class="[
|
||
{
|
||
'is-uploading': item.progress < 100
|
||
},
|
||
pt.image?.className
|
||
]"
|
||
:src="item.preview"
|
||
mode="aspectFill"
|
||
></image>
|
||
|
||
<cl-icon
|
||
name="close-line"
|
||
color="white"
|
||
:pt="{
|
||
className: 'cl-upload__close'
|
||
}"
|
||
@tap.stop="remove(item.uid)"
|
||
v-if="!isDisabled"
|
||
></cl-icon>
|
||
|
||
<view class="cl-upload__progress" v-if="item.progress < 100">
|
||
<cl-progress :value="item.progress" :show-text="false"></cl-progress>
|
||
</view>
|
||
</view>
|
||
|
||
<view
|
||
v-if="isAdd"
|
||
class="cl-upload is-add"
|
||
:class="[
|
||
{
|
||
'is-dark': isDark,
|
||
'is-disabled': isDisabled
|
||
},
|
||
pt.add?.className
|
||
]"
|
||
:style="uploadStyle"
|
||
@tap="choose(-1)"
|
||
>
|
||
<cl-icon
|
||
:name="icon"
|
||
:pt="{
|
||
className: parseClass([
|
||
[isDark, 'text-white', 'text-surface-400'],
|
||
pt.icon?.className
|
||
])
|
||
}"
|
||
:size="50"
|
||
></cl-icon>
|
||
|
||
<cl-text
|
||
:pt="{
|
||
className: parseClass([
|
||
[isDark, 'text-white', 'text-surface-500'],
|
||
'text-xs mt-1 text-center',
|
||
pt.text?.className
|
||
])
|
||
}"
|
||
>{{ text }}</cl-text
|
||
>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import { forInObject, isDark, parseClass, parsePt, parseRpx, uploadFile, uuid } from "@/cool";
|
||
import { t } from "@/locale";
|
||
import { computed, reactive, ref, watch, type PropType } from "vue";
|
||
import type { ClUploadItem, PassThroughProps } from "../../types";
|
||
import { useForm } from "../../hooks";
|
||
|
||
defineOptions({
|
||
name: "cl-upload"
|
||
});
|
||
|
||
const props = defineProps({
|
||
// 透传属性,用于自定义样式类名
|
||
pt: {
|
||
type: Object,
|
||
default: () => ({})
|
||
},
|
||
// 双向绑定的值,支持字符串或字符串数组
|
||
modelValue: {
|
||
type: [Array, String] as PropType<string[] | string>,
|
||
default: () => []
|
||
},
|
||
// 上传按钮的图标
|
||
icon: {
|
||
type: String,
|
||
default: "camera-fill"
|
||
},
|
||
// 上传按钮显示的文本
|
||
text: {
|
||
type: String,
|
||
default: () => t("上传 / 拍摄")
|
||
},
|
||
// 图片压缩方式:original原图,compressed压缩图
|
||
sizeType: {
|
||
type: [String, Array] as PropType<string[] | string>,
|
||
default: () => ["original", "compressed"]
|
||
},
|
||
// 图片选择来源:album相册,camera拍照
|
||
sourceType: {
|
||
type: Array as PropType<string[]>,
|
||
default: () => ["album", "camera"]
|
||
},
|
||
// 上传区域高度
|
||
height: {
|
||
type: [Number, String],
|
||
default: 150
|
||
},
|
||
// 上传区域宽度
|
||
width: {
|
||
type: [Number, String],
|
||
default: 150
|
||
},
|
||
// 是否支持多选
|
||
multiple: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
// 最大上传数量限制
|
||
limit: {
|
||
type: Number,
|
||
default: 9
|
||
},
|
||
// 是否禁用组件
|
||
disabled: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
// 演示用,本地预览
|
||
test: {
|
||
type: Boolean,
|
||
default: false
|
||
}
|
||
});
|
||
|
||
const emit = defineEmits([
|
||
"update:modelValue", // 更新modelValue值
|
||
"change", // 值发生变化时触发
|
||
"exceed", // 超出数量限制时触发
|
||
"success", // 上传成功时触发
|
||
"error", // 上传失败时触发
|
||
"progress" // 上传进度更新时触发
|
||
]);
|
||
|
||
// cl-form 上下文
|
||
const { disabled } = useForm();
|
||
|
||
// 是否禁用
|
||
const isDisabled = computed(() => {
|
||
return disabled.value || props.disabled;
|
||
});
|
||
|
||
// 透传属性的类型定义
|
||
type PassThrough = {
|
||
className?: string;
|
||
item?: PassThroughProps;
|
||
add?: PassThroughProps;
|
||
image?: PassThroughProps;
|
||
icon?: PassThroughProps;
|
||
text?: PassThroughProps;
|
||
};
|
||
|
||
// 解析透传属性
|
||
const pt = computed(() => parsePt<PassThrough>(props.pt));
|
||
|
||
// 上传文件列表
|
||
const list = ref<ClUploadItem[]>([]);
|
||
|
||
// 当前操作的文件索引,-1表示新增,其他表示替换指定索引的文件
|
||
const activeIndex = ref(0);
|
||
|
||
// 计算是否显示添加按钮
|
||
const isAdd = computed(() => {
|
||
const n = list.value.length;
|
||
|
||
if (isDisabled.value) {
|
||
// 禁用状态下,只有没有文件时才显示添加按钮
|
||
return n == 0;
|
||
} else {
|
||
// 根据multiple模式判断是否可以继续添加
|
||
return n < (props.multiple ? props.limit : 1);
|
||
}
|
||
});
|
||
|
||
// 计算上传区域的样式
|
||
const uploadStyle = computed(() => {
|
||
return {
|
||
height: parseRpx(props.height),
|
||
width: parseRpx(props.width)
|
||
};
|
||
});
|
||
|
||
/**
|
||
* 获取已成功上传的文件URL列表
|
||
*/
|
||
function getUrls() {
|
||
return list.value.filter((e) => e.url != "" && e.progress == 100).map((e) => e.url);
|
||
}
|
||
|
||
/**
|
||
* 获取当前的值,根据multiple模式返回不同格式
|
||
*/
|
||
function getValue() {
|
||
const urls = getUrls();
|
||
|
||
if (props.multiple) {
|
||
return urls;
|
||
} else {
|
||
return urls[0];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 添加新的上传项或更新已有项
|
||
* @param {string} url - 预览图片的本地路径
|
||
*/
|
||
function append(url: string) {
|
||
// 创建新的上传项
|
||
const item =
|
||
activeIndex.value == -1
|
||
? reactive<ClUploadItem>({
|
||
uid: uuid(), // 生成唯一ID
|
||
preview: url, // 预览图片路径
|
||
url: "", // 最终上传后的URL
|
||
progress: 0 // 上传进度
|
||
})
|
||
: list.value[activeIndex.value];
|
||
|
||
// 更新已有项或添加新项
|
||
if (activeIndex.value == -1) {
|
||
// 添加新项到列表末尾
|
||
list.value.push(item);
|
||
} else {
|
||
// 替换已有项的内容
|
||
item.progress = 0;
|
||
item.preview = url;
|
||
item.url = "";
|
||
}
|
||
|
||
return item.uid;
|
||
}
|
||
|
||
/**
|
||
* 触发值变化事件
|
||
*/
|
||
function change() {
|
||
const value = getValue();
|
||
|
||
emit("update:modelValue", value);
|
||
emit("change", value);
|
||
}
|
||
|
||
/**
|
||
* 更新指定上传项的数据
|
||
* @param {string} uid - 上传项的唯一ID
|
||
* @param {any} data - 要更新的数据对象
|
||
*/
|
||
function update(uid: string, data: any) {
|
||
const item = list.value.find((e) => e.uid == uid);
|
||
|
||
if (item != null) {
|
||
// 遍历更新数据对象的所有属性
|
||
forInObject(data, (value, key) => {
|
||
item[key] = value;
|
||
});
|
||
|
||
// 当上传完成且有URL时,触发change事件
|
||
if (item.progress == 100 && item.url != "") {
|
||
change();
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除指定的上传项
|
||
* @param {string} uid - 要删除的上传项唯一ID
|
||
*/
|
||
function remove(uid: string) {
|
||
list.value.splice(
|
||
list.value.findIndex((e) => e.uid == uid),
|
||
1
|
||
);
|
||
change();
|
||
}
|
||
|
||
/**
|
||
* 选择图片文件
|
||
* @param {number} index - 操作的索引,-1表示新增,其他表示替换
|
||
*/
|
||
function choose(index: number) {
|
||
if (isDisabled.value) {
|
||
return;
|
||
}
|
||
|
||
activeIndex.value = index;
|
||
|
||
// 计算可选择的图片数量
|
||
const count = activeIndex.value == -1 ? props.limit - list.value.length : 1;
|
||
|
||
if (count <= 0) {
|
||
// 超出数量限制
|
||
emit("exceed", list.value);
|
||
return;
|
||
}
|
||
|
||
// 调用uni-app的选择图片API
|
||
uni.chooseImage({
|
||
count: count, // 最多可以选择的图片张数
|
||
sizeType: props.sizeType as string[], // 压缩方式
|
||
sourceType: props.sourceType as string[], // 图片来源
|
||
success(res) {
|
||
// 选择成功后处理每个文件
|
||
if (Array.isArray(res.tempFiles)) {
|
||
(res.tempFiles as ChooseImageTempFile[]).forEach((file) => {
|
||
// 添加到列表并获取唯一ID
|
||
const uid = append(file.path);
|
||
|
||
// 测试用,本地预览
|
||
if (props.test) {
|
||
update(uid, { url: file.path, progress: 100 });
|
||
emit("success", file.path, uid);
|
||
return;
|
||
}
|
||
|
||
// 开始上传文件
|
||
uploadFile(file, {
|
||
// 上传进度回调
|
||
onProgressUpdate: ({ progress }) => {
|
||
update(uid, { progress });
|
||
emit("progress", progress);
|
||
}
|
||
})
|
||
.then((url) => {
|
||
// 上传成功,更新URL和进度
|
||
update(uid, { url, progress: 100 });
|
||
emit("success", url, uid);
|
||
})
|
||
.catch((err) => {
|
||
// 上传失败,触发错误事件并删除该项
|
||
emit("error", err as string);
|
||
remove(uid);
|
||
});
|
||
});
|
||
}
|
||
},
|
||
fail(err) {
|
||
// 选择图片失败
|
||
console.error(err);
|
||
emit("error", err.errMsg);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 监听modelValue的变化,同步更新内部列表
|
||
watch(
|
||
computed(() => props.modelValue!),
|
||
(val: string | string[]) => {
|
||
// 将当前列表的URL转为字符串用于比较
|
||
const currentUrls = getUrls().join(",");
|
||
|
||
// 将传入的值标准化为字符串进行比较
|
||
const newUrls = Array.isArray(val) ? val.join(",") : val;
|
||
|
||
// 如果值发生变化,更新内部列表
|
||
if (currentUrls != newUrls) {
|
||
// 标准化为数组格式
|
||
const urls = Array.isArray(val) ? val : [val];
|
||
|
||
// 过滤空值并转换为Item对象
|
||
list.value = urls
|
||
.filter((url) => url != "")
|
||
.map((url) => {
|
||
return {
|
||
uid: uuid(),
|
||
preview: url,
|
||
url,
|
||
progress: 100 // 外部传入的URL认为已上传完成
|
||
} as ClUploadItem;
|
||
});
|
||
}
|
||
},
|
||
{
|
||
immediate: true // 立即执行一次
|
||
}
|
||
);
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.cl-upload-list {
|
||
@apply flex flex-row flex-wrap;
|
||
|
||
.cl-upload {
|
||
@apply relative bg-surface-100 rounded-xl flex flex-col items-center justify-center;
|
||
@apply mr-2 mb-2;
|
||
|
||
&.is-dark {
|
||
@apply bg-surface-700;
|
||
}
|
||
|
||
&.is-disabled {
|
||
@apply opacity-50;
|
||
}
|
||
|
||
&.is-add {
|
||
@apply p-1;
|
||
}
|
||
|
||
&__image {
|
||
@apply w-full h-full absolute top-0 left-0;
|
||
transition-property: opacity;
|
||
transition-duration: 0.2s;
|
||
|
||
&.is-uploading {
|
||
@apply opacity-70;
|
||
}
|
||
}
|
||
|
||
&__close {
|
||
@apply absolute top-1 right-1;
|
||
}
|
||
|
||
&__progress {
|
||
@apply absolute bottom-2 left-0 w-full z-10 px-2;
|
||
}
|
||
}
|
||
}
|
||
</style>
|