first commit

This commit is contained in:
lingxiao865
2026-02-10 08:05:03 +08:00
commit c5af079d8c
1094 changed files with 97530 additions and 0 deletions

View File

@@ -0,0 +1,887 @@
<template>
<view
:style="tools._style([customStyle])"
:class="classPrefix + ' ' + tClass"
>
<t-grid
:gutter="gutter"
:border="false"
align="center"
:column="column"
:custom-style="draggable ? 'overflow: visible' : ''"
>
<block v-if="!dragLayout">
<t-grid-item
v-for="(file, index) in customFiles"
:key="index"
:t-class="classPrefix + '__grid ' + classPrefix + '__grid-file'"
:t-class-content="classPrefix + '__grid-content'"
aria-role="presentation"
>
<view
:class="classPrefix + '__wrapper ' + (disabled ? classPrefix + '__wrapper--disabled' : '')"
:style="gridItemStyle"
:aria-role="ariaRole || getWrapperAriaRole(file)"
:aria-label="ariaLabel || getWrapperAriaLabel(file)"
>
<t-image
v-if="file.type !== 'video'"
:data-file="file"
:data-index="index"
:t-class="classPrefix + '__thumbnail'"
:custom-style="(imageProps && imageProps.style) || ''"
:src="file.thumb || file.url"
:mode="(imageProps && imageProps.mode) || 'aspectFill'"
:error="(imageProps && imageProps.error) || 'default'"
:lazy="(imageProps && imageProps.lazy) || false"
:loading="(imageProps && imageProps.loading) || 'default'"
:shape="(imageProps && imageProps.shape) || 'round'"
:webp="(imageProps && imageProps.webp) || false"
:show-menu-by-longpress="(imageProps && imageProps.showMenuByLongpress) || false"
@click="onPreview($event, { file, index })"
/>
<video
v-if="file.type === 'video'"
:class="classPrefix + '__thumbnail'"
:src="file.url"
:poster="file.thumb"
controls
:autoplay="false"
objectFit="contain"
:data-file="file"
@click.stop="onFileClick"
/>
<view
v-if="file.status && file.status != 'done'"
:class="classPrefix + '__progress-mask'"
:data-index="index"
:data-file="file"
@click.stop="onFileClick"
>
<block v-if="file.status == 'loading'">
<t-icon
:t-class="classPrefix + '__progress-loading'"
name="loading"
size="48rpx"
aria-hidden
/>
<view :class="classPrefix + '__progress-text'">
{{ file.percent ? file.percent + '%' : '上传中...' }}
</view>
</block>
<t-icon
v-else
:name="file.status == 'reload' ? 'refresh' : 'close-circle'"
size="48rpx"
aria-hidden
/>
<view
v-if="file.status == 'reload' || file.status == 'failed'"
:class="classPrefix + '__progress-text'"
>
{{ file.status == 'reload' ? '重新上传' : '上传失败' }}
</view>
</view>
<view
v-if="tools.isBoolean(file.removeBtn) ? file.removeBtn : removeBtn"
:class="classPrefix + '__close-btn hotspot-expanded'"
:data-index="index"
aria-role="button"
aria-label="删除"
@click.stop="onDelete"
>
<t-icon
name="close"
size="32rpx"
color="#fff"
/>
</view>
</view>
</t-grid-item>
<t-grid-item
v-if="addBtn && customLimit > 0"
:t-class="classPrefix + '__grid'"
:t-class-content="classPrefix + '__grid-content'"
aria-label="上传"
@click="onAddTap"
>
<view
:class="classPrefix + '__wrapper'"
:style="gridItemStyle"
>
<slot name="add-content" />
<block v-if="addContent">
{{ addContent }}
</block>
<view
v-else
:class="classPrefix + '__add-icon ' + (disabled ? classPrefix + '__add-icon--disabled' : '')"
>
<t-icon name="add" />
</view>
</view>
</t-grid-item>
</block>
<block v-else>
<view
:class="classPrefix + '__drag'"
:list="dragList"
:style="dragWrapStyle + ';'"
:drag-base-data="dragBaseData"
>
<view
v-for="(file, index) in customFiles"
:key="index"
:ref="classPrefix + '__drag-item'"
:class="getDragItemClass(index)"
:style="getDragItemStyle(index)"
:data-index="index"
@longpress="parseEventDynamicCode($event, 'longPress', index)"
@touchmove.stop.prevent="parseEventDynamicCode($event, dragging ? 'touchMove' : '', index)"
@touchend.stop.prevent="parseEventDynamicCode($event, dragging ? 'touchEnd' : '', index)"
>
<t-grid-item
:t-class="classPrefix + '__grid ' + classPrefix + '__grid-file'"
:t-class-content="classPrefix + '__grid-content'"
aria-role="presentation"
custom-style="width: 100%"
>
<view
:class="classPrefix + '__wrapper ' + (disabled ? classPrefix + '__wrapper--disabled' : '')"
:style="gridItemStyle + ';'"
:aria-role="ariaRole || getWrapperAriaRole(file)"
:aria-label="ariaLabel || getWrapperAriaLabel(file)"
>
<t-image
v-if="file.type !== 'video'"
:data-file="file"
:data-index="index"
:t-class="classPrefix + '__thumbnail'"
:custom-style="(imageProps && imageProps.style) || ''"
:src="file.thumb || file.url"
:mode="(imageProps && imageProps.mode) || 'aspectFill'"
:error="(imageProps && imageProps.error) || 'default'"
:lazy="(imageProps && imageProps.lazy) || false"
:loading="(imageProps && imageProps.loading) || 'default'"
:shape="(imageProps && imageProps.shape) || 'round'"
:webp="(imageProps && imageProps.webp) || false"
:show-menu-by-longpress="(imageProps && imageProps.showMenuByLongpress) || false"
@click="onPreview($event, { file, index })"
/>
<video
v-if="file.type === 'video'"
:class="classPrefix + '__thumbnail'"
:src="file.url"
:poster="file.thumb"
controls
:autoplay="false"
objectFit="contain"
:data-file="file"
@click.stop="onFileClick"
/>
<view
v-if="file.status && file.status != 'done'"
:class="classPrefix + '__progress-mask'"
:data-index="index"
:data-file="file"
@click.stop="onFileClick"
>
<block v-if="file.status == 'loading'">
<t-icon
:t-class="classPrefix + '__progress-loading'"
name="loading"
size="48rpx"
aria-hidden
/>
<view :class="classPrefix + '__progress-text'">
{{ file.percent ? file.percent + '%' : '上传中...' }}
</view>
</block>
<t-icon
v-else
:name="file.status == 'reload' ? 'refresh' : 'close-circle'"
size="48rpx"
aria-hidden
/>
<view
v-if="file.status == 'reload' || file.status == 'failed'"
:class="classPrefix + '__progress-text'"
>
{{ file.status == 'reload' ? '重新上传' : '上传失败' }}
</view>
</view>
<view
v-if="tools.isBoolean(file.removeBtn) ? file.removeBtn : removeBtn"
:class="classPrefix + '__close-btn hotspot-expanded'"
:data-index="index"
:data-url="file.url"
aria-role="button"
aria-label="删除"
@click.stop="onDelete"
>
<t-icon
name="close"
size="32rpx"
color="#fff"
/>
</view>
</view>
</t-grid-item>
</view>
<view
v-if="addBtn && customLimit > 0"
:ref="classPrefix + '__drag-item'"
:class="getDragItemClass(customFiles.length)"
:style="getDragItemStyle(customFiles.length)"
>
<t-grid-item
:t-class="classPrefix + '__grid'"
:t-class-content="classPrefix + '__grid-content'"
aria-label="上传"
custom-style="width: 100%"
@click="onAddTap"
>
<view
:class="classPrefix + '__wrapper'"
:style="gridItemStyle"
>
<slot name="add-content" />
<block v-if="addContent">
{{ addContent }}
</block>
<view
v-else
:class="classPrefix + '__add-icon ' + (disabled ? classPrefix + '__add-icon--disabled' : '')"
>
<t-icon name="add" />
</view>
</view>
</t-grid-item>
</view>
</view>
</block>
</t-grid>
</view>
</template>
<script>
import TGrid from '../grid/grid';
import TGridItem from '../grid-item/grid-item';
import TIcon from '../icon/icon';
import TImage from '../image/image';
import { uniComponent } from '../common/src/index';
import props from './props';
import { prefix } from '../common/config';
import { isOverSize, coalesce, isWxWork, isPC } from '../common/utils';
import { isObject } from '../common/validator';
import tools from '../common/utils.wxs';
import {
getWrapperAriaRole,
getWrapperAriaLabel,
} from './upload.computed.js';
import {
longPress,
touchMove,
touchEnd,
baseDataObserver,
listObserver,
} from './drag.computed.js';
import { parseEventDynamicCode } from '../common/event/dynamic';
const name = `${prefix}-upload`;
const makeMethods = () => [
[longPress, 'longPress'],
[touchMove, 'touchMove'],
[touchEnd, 'touchEnd'],
[baseDataObserver, 'baseDataObserver'],
[listObserver, 'listObserver'],
].reduce((acc, item) => {
const func = item[0];
return {
...acc,
[item[1]](...args) {
func.call(this, ...args);
},
};
}, {});
export default uniComponent({
name,
options: {
styleIsolation: 'shared',
},
controlledProps: [
{
key: 'files',
event: 'success',
},
],
externalClasses: [`${prefix}-class`],
components: {
TGrid,
TGridItem,
TIcon,
TImage,
},
props: {
...props,
},
data() {
return {
classPrefix: name,
prefix,
current: false,
proofs: [],
customFiles: [], // 内部动态修改的files
customLimit: 0, // 内部动态修改的limit
column: 4,
dragBaseData: {}, // 拖拽所需要页面数据
rows: 0, // 行数
dragWrapStyle: '', // 拖拽容器的样式
dragList: [], // 拖拽的数据列
dragging: true, // 是否开始拖拽
dragLayout: false, // 是否开启拖拽布局
tools,
gridItemStyle: '',
fakeState: {},
dragItemClassList: [],
dragItemStyleList: [],
};
},
watch: {
files: {
handler() {
this.onWatchFilesLimit();
},
deep: true,
},
max: 'onWatchFilesLimit',
draggable: {
handler() {
this.onWatchFilesLimit();
},
deep: true,
},
gridConfig: {
handler() {
this.updateGrid();
},
deep: true,
},
dragList: {
handler(val) {
setTimeout(() => {
this.listObserver(val);
}, 33);
},
deep: true,
immediate: true,
},
dragBaseData: {
handler(val) {
this.baseDataObserver(val);
},
deep: true,
immediate: true,
},
},
mounted() {
this.handleLimit(this.files, this.max);
this.updateGrid();
},
methods: {
getWrapperAriaRole,
getWrapperAriaLabel,
...makeMethods(),
handleLimit(customFiles, max) {
if (max === 0) {
max = Number.MAX_SAFE_INTEGER;
}
this.customFiles = customFiles.length > max ? customFiles.slice(0, max) : customFiles;
this.customLimit = max - customFiles.length;
this.dragging = true;
this.initDragLayout();
},
triggerSuccessEvent(files) {
this._trigger('success', { files: [...this.customFiles, ...files] });
},
triggerFailEvent(err) {
this.$emit('fail', err);
},
onFileClick(e) {
const { file, index } = e.currentTarget.dataset;
this.$emit('click', { index, file });
},
/**
* 由于小程序暂时在ios上不支持返回上传文件的fileType这里用文件的后缀来判断
* @param mediaType
* @param tempFilePath
* @returns string
* @link https://developers.weixin.qq.com/community/develop/doc/00042820b28ee8fb41fc4d0c254c00
*/
getFileType(mediaType, tempFilePath, fileType) {
if (fileType) return fileType; // 如果有返回fileType就直接用
if (mediaType.length === 1) {
// 在单选媒体类型的时候直接使用单选媒体类型
return mediaType[0];
}
// 否则根据文件后缀进行判读
const videoType = ['avi', 'wmv', 'mkv', 'mp4', 'mov', 'rm', '3gp', 'flv', 'mpg', 'rmvb'];
const temp = tempFilePath.split('.');
const postfix = temp[temp.length - 1];
if (videoType.includes(postfix.toLocaleLowerCase())) {
return 'video';
}
return 'image';
},
// 选中文件之后,计算一个随机的短文件名
getRandFileName(filePath) {
const extIndex = filePath.lastIndexOf('.');
const extName = extIndex === -1 ? '' : filePath.substr(extIndex);
return parseInt(`${Date.now()}${Math.floor(Math.random() * 900 + 100)}`, 10).toString(36) + extName;
},
checkFileSize(size, sizeLimit, fileType) {
if (isOverSize(size, sizeLimit)) {
let title = `${fileType === 'video' ? '视频' : '图片'}大小超过限制`;
if (isObject(sizeLimit)) {
const { size: limitSize, message: limitMessage } = sizeLimit;
title = limitMessage?.replace('{sizeLimit}', String(limitSize));
}
uni.showToast({ icon: 'none', title });
return true;
}
return false;
},
onDelete(e) {
const { index } = e.currentTarget.dataset;
this.deleteHandle(index);
},
deleteHandle(index) {
const { customFiles } = this;
const delFile = customFiles[index];
this.$emit('remove', { index, file: delFile });
},
updateGrid() {
let { gridConfig = {} } = this;
if (!isObject(gridConfig)) gridConfig = {};
const { column = 4, width = 160, height = 160 } = gridConfig;
this.gridItemStyle = `width:${width}rpx;height:${height}rpx`;
this.column = column;
},
resetDragLayout() {
this.dragBaseData = {};
this.dragWrapStyle = '';
this.dragLayout = false;
},
initDragLayout() {
const { draggable, disabled, customFiles } = this;
if (!draggable || disabled || customFiles.length === 0) {
this.resetDragLayout();
return;
}
this.initDragList();
setTimeout(() => {
this.initDragBaseData();
}, 33)
;
},
initDragList() {
let i = 0;
const { column, customFiles, customLimit } = this;
const dragList = [];
customFiles.forEach((item, index) => {
dragList.push({
realKey: i, // 真实顺序
sortKey: index, // 整体顺序
tranX: `${(index % column) * 100}%`,
tranY: `${Math.floor(index / column) * 100}%`,
data: { ...item },
});
i += 1;
});
if (customLimit > 0) {
const listLength = dragList.length;
dragList.push({
realKey: listLength, // 真实顺序
sortKey: listLength, // 整体顺序
tranX: `${(listLength % column) * 100}%`,
tranY: `${Math.floor(listLength / column) * 100}%`,
fixed: true,
});
}
this.rows = Math.ceil(dragList.length / column);
this.dragList = dragList;
},
initDragBaseData() {
const { classPrefix, rows, column } = this;
let query;
// #ifdef H5 || APP-PLUS
query = uni.createSelectorQuery().in(this);
// #endif
if (!query) {
query = this.createSelectorQuery();
}
let selectorGridItem;
let selectorGrid;
// #ifdef H5 || APP-PLUS
selectorGridItem = '.t-grid-item';
selectorGrid = '.t-grid';
// #endif
if (!selectorGridItem) {
selectorGridItem = `.${classPrefix} >>> .t-grid-item`;
selectorGrid = `.${classPrefix} >>> .t-grid`;
}
query.select(selectorGridItem).boundingClientRect();
query.select(selectorGrid).boundingClientRect();
query.selectViewport().scrollOffset();
query.exec((res) => {
const [{ width, height }, { left, top }, { scrollTop }] = res;
const dragBaseData = {
rows,
classPrefix,
itemWidth: width,
itemHeight: height,
wrapLeft: left,
wrapTop: top + scrollTop,
columns: column,
};
const dragWrapStyle = `height: ${rows * height}px`;
this.dragBaseData = dragBaseData;
this.dragWrapStyle = dragWrapStyle;
this.dragLayout = true;
// 为了给拖拽元素加上拖拽方法,同时控制不拖拽时不取消穿透
const timer = setTimeout(() => {
this.dragging = false;
clearTimeout(timer);
}, 0);
});
},
getPreviewMediaSources() {
const previewMediaSources = [];
this.customFiles.forEach((ele) => {
const mediaSource = {
url: ele.url,
type: ele.type,
poster: ele.thumb || undefined,
};
previewMediaSources.push(mediaSource);
});
return previewMediaSources;
},
onPreview(e) {
this.onFileClick(e);
const { preview } = this;
if (!preview) return;
const usePreviewMedia = this.customFiles.some(file => file.type === 'video');
if (usePreviewMedia) {
this.onPreviewMedia(e);
} else {
this.onPreviewImage(e);
}
},
onPreviewImage(e) {
const { index } = e.currentTarget.dataset;
const urls = this.customFiles.filter(file => file.percent !== -1).map(file => file.url);
const current = this.customFiles[index]?.url;
uni.previewImage({
urls,
current,
fail() {
uni.showToast({ title: '预览图片失败', icon: 'none' });
},
});
},
onPreviewMedia(e) {
const { index: current } = e.currentTarget.dataset;
const sources = this.getPreviewMediaSources();
uni.previewMedia({
sources,
current,
fail() {
uni.showToast({ title: '预览视频失败', icon: 'none' });
},
});
},
uploadFiles(files) {
return Promise.resolve().then(() => {
// 开始调用上传函数
const task = this.data.requestMethod(files);
if (task instanceof Promise) {
return task;
}
return Promise.resolve({});
});
},
startUpload(files) {
// 如果传入了上传函数则进度设为0并开始上传否则跳过上传
if (typeof this.requestMethod === 'function') {
return this.uploadFiles(files)
.then(() => {
files.forEach((file) => {
file.percent = 100;
});
this.triggerSuccessEvent(files);
})
.catch((err) => {
this.triggerFailEvent(err);
});
}
// 如果没有上传函数success事件与微信api上传成功关联
this.triggerSuccessEvent(files);
this.handleLimit(this.customFiles, this.max);
return Promise.resolve();
},
onWatchFilesLimit() {
this.handleLimit(this.files, this.max);
},
onAddTap() {
const { disabled, mediaType, source } = this;
if (disabled) return;
if (source === 'media') {
this.chooseMedia(mediaType);
} else {
this.chooseMessageFile(mediaType);
}
},
chooseMedia(mediaType) {
const { customLimit } = this;
const { config, sizeLimit } = this;
let func = 'chooseMedia';
// #ifdef H5 || MP-ALIPAY
func = 'chooseImage';
// #endif
// #ifdef MP-WEIXIN
if (isPC || isWxWork) {
func = 'chooseImage';
}
// #endif
uni[func]({
count: Math.min(20, customLimit),
mediaType,
...config,
success: (res) => {
const files = [];
// 支持单/多文件
res.tempFiles.forEach((temp) => {
const { size, fileType, tempFilePath, width, height, duration, thumbTempFilePath, ...res } = temp;
if (this.checkFileSize(size, sizeLimit, fileType)) return;
const name = temp.name || this.getRandFileName(tempFilePath);
files.push({
name,
type: this.getFileType(mediaType, temp.name || tempFilePath, fileType),
url: tempFilePath,
size,
width,
height,
duration,
thumb: thumbTempFilePath,
percent: 0,
...res,
});
});
this.afterSelect(files);
},
fail: (err) => {
this.triggerFailEvent(err);
},
complete: (res) => {
this.$emit('complete', res);
},
});
},
chooseMessageFile(mediaType) {
const { customLimit } = this;
const { config, sizeLimit } = this;
uni.chooseMessageFile({
count: Math.min(100, customLimit),
type: Array.isArray(mediaType) ? 'all' : mediaType,
...config,
success: (res) => {
const files = [];
// 支持单/多文件
res.tempFiles.forEach((temp) => {
const { size, type: fileType, path: tempFilePath, ...res } = temp;
if (this.checkFileSize(size, sizeLimit, fileType)) return;
const name = this.getRandFileName(tempFilePath);
files.push({
name,
type: this.getFileType(mediaType, tempFilePath, fileType),
url: tempFilePath,
size,
percent: 0,
...res,
});
});
this.afterSelect(files);
},
fail: err => this.triggerFailEvent(err),
complete: res => this.$emit('complete', res),
});
},
afterSelect(files) {
this._trigger('select-change', {
files: [...this.customFiles],
currentSelectedFiles: [files],
});
this._trigger('add', { files });
this.startUpload(files);
},
dragVibrate(e) {
const { vibrateType } = e;
const { draggable } = this;
const dragVibrate = coalesce(draggable?.vibrate, true);
const dragCollisionVibrate = draggable?.collisionVibrate;
if ((dragVibrate && vibrateType === 'longPress') || (dragCollisionVibrate && vibrateType === 'touchMove')) {
uni.vibrateShort({
type: 'light',
});
}
},
dragStatusChange(e) {
const { dragging } = e;
this.dragging = dragging;
},
dragEnd(e) {
const { dragCollisionList } = e;
let files = [];
if (dragCollisionList.length === 0) {
files = this.customFiles;
} else {
files = dragCollisionList.reduce((list, item) => {
const { realKey, data, fixed } = item;
if (!fixed) {
list[realKey] = {
...data,
};
}
return list;
}, []);
}
this.triggerDropEvent(files);
},
triggerDropEvent(files) {
const { transition } = this;
if (transition.backTransition) {
const timer = setTimeout(() => {
this.$emit('drop', { files });
clearTimeout(timer);
}, transition.duration);
} else {
this.$emit('drop', { files });
}
},
getState() {
return this.fakeState || {};
},
callMethod(...args) {
return this[args[0]]?.(...args.slice(1));
},
parseEventDynamicCode,
setDragItemClass(index, operation, val) {
if (!this.dragItemClassList[index]) {
this.dragItemClassList[index] = [];
}
const valList = Array.isArray(val) ? val : [val];
if (operation === 'add') {
this.dragItemClassList[index].push(...valList);
return;
}
if (operation === 'remove') {
this.dragItemClassList[index] = this.dragItemClassList[index].filter(item => !valList.includes(item));
}
},
getDragItemClass(index) {
const { classPrefix } = this;
const base = [
`${classPrefix}__drag-item`,
];
return [
...base,
...(this.dragItemClassList[index] || []),
].join(' ');
},
setDragItemStyle(index, val) {
if (!this.dragItemStyleList[index]) {
this.dragItemStyleList[index] = [];
}
this.dragItemStyleList[index].push(val);
},
getDragItemStyle(index) {
const { column, transition } = this;
const base = [
`width: ${100 / column}%`,
`--td-upload-drag-transition-duration: ${transition.duration}ms`,
`--td-upload-drag-transition-timing-function: ${transition.timingFunction}`,
];
return [
...base,
...(this.dragItemStyleList[index] || []),
].join(';');
},
},
});
</script>
<style scoped>
@import './upload.css';
</style>