Files
jindengchen-ai-report/cool-unix/uni_modules/cool-ui/components/cl-upload/cl-upload.uvue

448 lines
9.3 KiB
Plaintext
Raw Normal View History

2025-11-13 10:36:23 +08:00
<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>