|
@@ -0,0 +1,2449 @@
|
|
|
|
|
+<template>
|
|
|
|
|
+ <view
|
|
|
|
|
+ class="kex-fu"
|
|
|
|
|
+ :class="{
|
|
|
|
|
+ 'kex-fu--disabled': disabled || readonly,
|
|
|
|
|
+ 'kex-fu--readonly': readonly,
|
|
|
|
|
+ 'kex-fu--headless': isHeadless
|
|
|
|
|
+ }"
|
|
|
|
|
+ >
|
|
|
|
|
+ <!-- list 模式:完整 UI -->
|
|
|
|
|
+ <template v-if="!isHeadless">
|
|
|
|
|
+ <!-- 触发按钮:顶部 -->
|
|
|
|
|
+ <template v-if="triggerVisible && triggerPosition === 'top'">
|
|
|
|
|
+ <slot
|
|
|
|
|
+ v-if="hasTriggerSlot"
|
|
|
|
|
+ name="trigger"
|
|
|
|
|
+ v-bind="triggerSlotProps"
|
|
|
|
|
+ />
|
|
|
|
|
+ <view
|
|
|
|
|
+ v-else
|
|
|
|
|
+ class="kex-fu__add"
|
|
|
|
|
+ :class="addClass"
|
|
|
|
|
+ :style="addStyle"
|
|
|
|
|
+ @click="onChoose"
|
|
|
|
|
+ >
|
|
|
|
|
+ <slot name="add" :count="innerList.length" :max-count="maxCount">
|
|
|
|
|
+ <text class="kex-fu__add-icon">+</text>
|
|
|
|
|
+ <text class="kex-fu__add-text">{{ addText }}</text>
|
|
|
|
|
+ <text v-if="showCount" class="kex-fu__add-count">{{ innerList.length }}/{{ maxCount }}</text>
|
|
|
|
|
+ </slot>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </template>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 文件列表 -->
|
|
|
|
|
+ <view
|
|
|
|
|
+ v-for="(item, index) in innerList"
|
|
|
|
|
+ :key="item.uid"
|
|
|
|
|
+ class="kex-fu__item"
|
|
|
|
|
+ >
|
|
|
|
|
+ <slot
|
|
|
|
|
+ name="item"
|
|
|
|
|
+ :file="toPublicFile(item)"
|
|
|
|
|
+ :index="index"
|
|
|
|
|
+ :remove="() => onRemove(index)"
|
|
|
|
|
+ :retry="() => onRetry(index)"
|
|
|
|
|
+ :abort="() => abort(index)"
|
|
|
|
|
+ :preview="() => onPreview(item)"
|
|
|
|
|
+ >
|
|
|
|
|
+ <!-- 图片缩略图 / 类型图标 -->
|
|
|
|
|
+ <image
|
|
|
|
|
+ v-if="isImageItem(item) && thumbSrc(item)"
|
|
|
|
|
+ class="kex-fu__thumb"
|
|
|
|
|
+ :src="thumbSrc(item)"
|
|
|
|
|
+ mode="aspectFill"
|
|
|
|
|
+ @click="onPreview(item)"
|
|
|
|
|
+ />
|
|
|
|
|
+ <view
|
|
|
|
|
+ v-else
|
|
|
|
|
+ class="kex-fu__icon"
|
|
|
|
|
+ :class="'kex-fu__icon--' + iconType(item)"
|
|
|
|
|
+ @click="onPreview(item)"
|
|
|
|
|
+ >
|
|
|
|
|
+ <text class="kex-fu__icon-text">{{ iconLabel(item) }}</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <view class="kex-fu__body" @click="onPreview(item)">
|
|
|
|
|
+ <text class="kex-fu__name">{{ item.name }}</text>
|
|
|
|
|
+ <text class="kex-fu__meta">
|
|
|
|
|
+ {{ sizeText(item.size) }}
|
|
|
|
|
+ <text v-if="item.status === 'uploading'"> · {{ item.progress || 0 }}%</text>
|
|
|
|
|
+ <text v-else-if="item.status === 'fail'" class="kex-fu__fail"> · 上传失败</text>
|
|
|
|
|
+ <text v-else-if="item.status === 'success'" class="kex-fu__ok"> · 已上传</text>
|
|
|
|
|
+ </text>
|
|
|
|
|
+ <view v-if="item.status === 'uploading'" class="kex-fu__bar">
|
|
|
|
|
+ <view class="kex-fu__bar-inner" :style="{ width: (item.progress || 0) + '%' }" />
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <view class="kex-fu__actions">
|
|
|
|
|
+ <text
|
|
|
|
|
+ v-if="item.status === 'uploading' && !readonly"
|
|
|
|
|
+ class="kex-fu__abort"
|
|
|
|
|
+ @click.stop="abort(index)"
|
|
|
|
|
+ >取消</text>
|
|
|
|
|
+ <text
|
|
|
|
|
+ v-if="item.status === 'fail' && uploadUrl && !readonly && !disabled"
|
|
|
|
|
+ class="kex-fu__retry"
|
|
|
|
|
+ @click.stop="onRetry(index)"
|
|
|
|
|
+ >重试</text>
|
|
|
|
|
+ <text
|
|
|
|
|
+ v-if="canRemoveItem(item)"
|
|
|
|
|
+ class="kex-fu__remove"
|
|
|
|
|
+ @click.stop="onRemove(index)"
|
|
|
|
|
+ >×</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </slot>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 触发按钮:底部 -->
|
|
|
|
|
+ <template v-if="triggerVisible && triggerPosition === 'bottom'">
|
|
|
|
|
+ <slot
|
|
|
|
|
+ v-if="hasTriggerSlot"
|
|
|
|
|
+ name="trigger"
|
|
|
|
|
+ v-bind="triggerSlotProps"
|
|
|
|
|
+ />
|
|
|
|
|
+ <view
|
|
|
|
|
+ v-else
|
|
|
|
|
+ class="kex-fu__add"
|
|
|
|
|
+ :class="addClass"
|
|
|
|
|
+ :style="addStyle"
|
|
|
|
|
+ @click="onChoose"
|
|
|
|
|
+ >
|
|
|
|
|
+ <slot name="add" :count="innerList.length" :max-count="maxCount">
|
|
|
|
|
+ <text class="kex-fu__add-icon">+</text>
|
|
|
|
|
+ <text class="kex-fu__add-text">{{ addText }}</text>
|
|
|
|
|
+ <text v-if="showCount" class="kex-fu__add-count">{{ innerList.length }}/{{ maxCount }}</text>
|
|
|
|
|
+ </slot>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </template>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 图片内置预览(dataURL / blob,App 真机 uni.previewImage 不可用) -->
|
|
|
|
|
+ <view
|
|
|
|
|
+ v-if="previewVisible"
|
|
|
|
|
+ class="kex-fu__preview"
|
|
|
|
|
+ @click="closePreview"
|
|
|
|
|
+ @touchmove.stop.prevent="noop"
|
|
|
|
|
+ >
|
|
|
|
|
+ <image
|
|
|
|
|
+ class="kex-fu__preview-img"
|
|
|
|
|
+ :src="previewSrc"
|
|
|
|
|
+ mode="aspectFit"
|
|
|
|
|
+ @click.stop="noop"
|
|
|
|
|
+ @error="onPreviewImgError"
|
|
|
|
|
+ />
|
|
|
|
|
+ <text class="kex-fu__preview-close">点击关闭</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- App renderjs(list / headless 都需要) -->
|
|
|
|
|
+ <!-- #ifdef APP-PLUS -->
|
|
|
|
|
+ <view
|
|
|
|
|
+ id="kex-fu-render"
|
|
|
|
|
+ ref="renderHost"
|
|
|
|
|
+ class="kex-fu__render"
|
|
|
|
|
+ :change:appChoose="render.chooseHandle"
|
|
|
|
|
+ :appChoose="appChooseToken"
|
|
|
|
|
+ :change:appUpload="render.uploadHandle"
|
|
|
|
|
+ :appUpload="appUploadToken"
|
|
|
|
|
+ :change:appRemove="render.removeHandle"
|
|
|
|
|
+ :appRemove="appRemoveToken"
|
|
|
|
|
+ :change:appAbort="render.abortHandle"
|
|
|
|
|
+ :appAbort="appAbortToken"
|
|
|
|
|
+ :change:appPreview="render.previewHandle"
|
|
|
|
|
+ :appPreview="appPreviewToken"
|
|
|
|
|
+ :change:appRead="render.readHandle"
|
|
|
|
|
+ :appRead="appReadToken"
|
|
|
|
|
+ />
|
|
|
|
|
+ <!-- #endif -->
|
|
|
|
|
+ </view>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script>
|
|
|
|
|
+ /**
|
|
|
|
|
+ * kex-file-uploader:附件上传
|
|
|
|
|
+ * list / headless · 多端含鸿蒙 · 并发 · abort · 缩略图
|
|
|
|
|
+ *
|
|
|
|
|
+ * 平台说明:
|
|
|
|
|
+ * - Android/iOS App(APP-PLUS):renderjs <input> + XHR
|
|
|
|
|
+ * - 鸿蒙 App(APP-HARMONY,非 APP-PLUS):uni.chooseFile + uni.uploadFile
|
|
|
|
|
+ * - H5 / 小程序:uni.chooseFile(或各端专用 API)+ uni.uploadFile
|
|
|
|
|
+ */
|
|
|
|
|
+ const DEFAULT_UPLOAD_OPTIONS = Object.freeze({
|
|
|
|
|
+ name: 'file',
|
|
|
|
|
+ header: {},
|
|
|
|
|
+ formData: {},
|
|
|
|
|
+ /** App XHR 请求方法,默认 POST */
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ /** App:true 时以 Base64 字符串作为表单字段上传 */
|
|
|
|
|
+ toBase: false,
|
|
|
|
|
+ /** App XHR / 需带 cookie 跨域时设为 true */
|
|
|
|
|
+ withCredentials: false
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ let uidSeed = 0
|
|
|
|
|
+ const nextUid = () => {
|
|
|
|
|
+ uidSeed += 1
|
|
|
|
|
+ return 'kex-fu-' + uidSeed + '-' + Date.now()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const IMAGE_EXTS = new Set([
|
|
|
|
|
+ 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico', 'heic', 'heif'
|
|
|
|
|
+ ])
|
|
|
|
|
+ const DOC_EXTS = new Set([
|
|
|
|
|
+ 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'csv', 'md', 'rtf'
|
|
|
|
|
+ ])
|
|
|
|
|
+ const VIDEO_EXTS = new Set([
|
|
|
|
|
+ 'mp4', 'mov', 'avi', 'wmv', 'flv', 'mkv', 'webm', 'm4v'
|
|
|
|
|
+ ])
|
|
|
|
|
+ const AUDIO_EXTS = new Set([
|
|
|
|
|
+ 'mp3', 'wav', 'aac', 'flac', 'ogg', 'm4a', 'wma'
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ function getExt(name = '') {
|
|
|
|
|
+ const i = String(name).lastIndexOf('.')
|
|
|
|
|
+ return i >= 0 ? String(name).substring(i + 1).toLowerCase() : ''
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function inferName(path) {
|
|
|
|
|
+ if (!path) return 'file_' + Date.now()
|
|
|
|
|
+ const base = String(path).substring(String(path).lastIndexOf('/') + 1)
|
|
|
|
|
+ return base || 'file_' + Date.now()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function formatBytes(n) {
|
|
|
|
|
+ const num = Number(n) || 0
|
|
|
|
|
+ if (num <= 0) return ''
|
|
|
|
|
+ if (num < 1024) return num + 'B'
|
|
|
|
|
+ if (num < 1024 * 1024) return (num / 1024).toFixed(num < 10 * 1024 ? 1 : 0) + 'K'
|
|
|
|
|
+ return (num / 1024 / 1024).toFixed(2) + 'M'
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function parseFormats(formats) {
|
|
|
|
|
+ if (!formats) return []
|
|
|
|
|
+ if (Array.isArray(formats)) {
|
|
|
|
|
+ return formats.map((s) => String(s).replace(/^\./, '').trim().toLowerCase()).filter(Boolean)
|
|
|
|
|
+ }
|
|
|
|
|
+ return String(formats)
|
|
|
|
|
+ .replace(/[\[\]]/g, '')
|
|
|
|
|
+ .split(',')
|
|
|
|
|
+ .map((s) => s.replace(/^\./, '').trim().toLowerCase())
|
|
|
|
|
+ .filter(Boolean)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function getByPath(obj, path) {
|
|
|
|
|
+ if (!path || obj == null) return ''
|
|
|
|
|
+ const parts = String(path).split('.').filter(Boolean)
|
|
|
|
|
+ let cur = obj
|
|
|
|
|
+ for (let i = 0; i < parts.length; i++) {
|
|
|
|
|
+ if (cur == null) return ''
|
|
|
|
|
+ cur = cur[parts[i]]
|
|
|
|
|
+ }
|
|
|
|
|
+ if (cur == null || cur === '') return ''
|
|
|
|
|
+ return typeof cur === 'string' || typeof cur === 'number' ? String(cur) : ''
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function parseResponseData(resData) {
|
|
|
|
|
+ if (resData == null || resData === '') return null
|
|
|
|
|
+ if (typeof resData === 'object') return resData
|
|
|
|
|
+ try {
|
|
|
|
|
+ return JSON.parse(resData)
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ return resData
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function defaultParseUploadUrl(resData) {
|
|
|
|
|
+ if (!resData) return ''
|
|
|
|
|
+ try {
|
|
|
|
|
+ const data = typeof resData === 'string' ? JSON.parse(resData) : resData
|
|
|
|
|
+ if (typeof data === 'string') return data
|
|
|
|
|
+ return (
|
|
|
|
|
+ data.url ||
|
|
|
|
|
+ data.path ||
|
|
|
|
|
+ (data.data && (data.data.url || data.data.path || data.data.fileUrl)) ||
|
|
|
|
|
+ (data.result && (data.result.url || data.result.path)) ||
|
|
|
|
|
+ ''
|
|
|
|
|
+ )
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ return ''
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ export default {
|
|
|
|
|
+ name: 'KexFileUploader',
|
|
|
|
|
+ emits: [
|
|
|
|
|
+ 'update:modelValue',
|
|
|
|
|
+ 'change',
|
|
|
|
|
+ 'select',
|
|
|
|
|
+ 'oversize',
|
|
|
|
|
+ 'exceed',
|
|
|
|
|
+ 'remove',
|
|
|
|
|
+ 'uploadSuccess',
|
|
|
|
|
+ 'uploadFail',
|
|
|
|
|
+ 'uploadAbort',
|
|
|
|
|
+ 'preview'
|
|
|
|
|
+ ],
|
|
|
|
|
+ props: {
|
|
|
|
|
+ modelValue: {
|
|
|
|
|
+ type: Array,
|
|
|
|
|
+ default() {
|
|
|
|
|
+ return []
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ /**
|
|
|
|
|
+ * list:列表 UI(默认)
|
|
|
|
|
+ * headless:无 UI,仅能力(choose / upload / abort),完全自绘
|
|
|
|
|
+ */
|
|
|
|
|
+ mode: {
|
|
|
|
|
+ type: String,
|
|
|
|
|
+ default: 'list'
|
|
|
|
|
+ },
|
|
|
|
|
+ maxCount: { type: Number, default: 9 },
|
|
|
|
|
+ maxSize: { type: Number, default: 20 },
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 是否允许多选(单次选择多个文件)
|
|
|
|
|
+ * false 时每次只能选 1 个,但仍可多次点选直到 maxCount
|
|
|
|
|
+ */
|
|
|
|
|
+ multiple: { type: Boolean, default: true },
|
|
|
|
|
+ formats: {
|
|
|
|
|
+ type: [String, Array],
|
|
|
|
|
+ default: ''
|
|
|
|
|
+ },
|
|
|
|
|
+ fileType: {
|
|
|
|
|
+ type: String,
|
|
|
|
|
+ default: 'all'
|
|
|
|
|
+ },
|
|
|
|
|
+ accept: {
|
|
|
|
|
+ type: String,
|
|
|
|
|
+ default: ''
|
|
|
|
|
+ },
|
|
|
|
|
+ disabled: { type: Boolean, default: false },
|
|
|
|
|
+ /** 只读:不可选、不可删、不可上传操作,仅展示 */
|
|
|
|
|
+ readonly: { type: Boolean, default: false },
|
|
|
|
|
+ showRemove: { type: Boolean, default: true },
|
|
|
|
|
+ showCount: { type: Boolean, default: true },
|
|
|
|
|
+ /** 图片是否显示缩略图 */
|
|
|
|
|
+ showThumb: { type: Boolean, default: true },
|
|
|
|
|
+ addText: { type: String, default: '添加附件' },
|
|
|
|
|
+ showTrigger: { type: Boolean, default: true },
|
|
|
|
|
+ triggerPosition: {
|
|
|
|
|
+ type: String,
|
|
|
|
|
+ default: 'bottom'
|
|
|
|
|
+ },
|
|
|
|
|
+ addClass: {
|
|
|
|
|
+ type: [String, Array, Object],
|
|
|
|
|
+ default: ''
|
|
|
|
|
+ },
|
|
|
|
|
+ addStyle: {
|
|
|
|
|
+ type: [Object, String],
|
|
|
|
|
+ default() {
|
|
|
|
|
+ return {}
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ toast: { type: Boolean, default: true },
|
|
|
|
|
+ autoUpload: { type: Boolean, default: false },
|
|
|
|
|
+ uploadUrl: { type: String, default: '' },
|
|
|
|
|
+ uploadOptions: {
|
|
|
|
|
+ type: Object,
|
|
|
|
|
+ default() {
|
|
|
|
|
+ return {
|
|
|
|
|
+ name: 'file',
|
|
|
|
|
+ header: {},
|
|
|
|
|
+ formData: {},
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ toBase: false,
|
|
|
|
|
+ withCredentials: false
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 并发上传数,默认 3;≤0 视为 1
|
|
|
|
|
+ */
|
|
|
|
|
+ concurrent: {
|
|
|
|
|
+ type: Number,
|
|
|
|
|
+ default: 3
|
|
|
|
|
+ },
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 从响应中取远程 url 的路径,如 'data.url'、'data.fileUrl'
|
|
|
|
|
+ * 优先于内置默认解析
|
|
|
|
|
+ */
|
|
|
|
|
+ responseUrlKey: {
|
|
|
|
|
+ type: String,
|
|
|
|
|
+ default: ''
|
|
|
|
|
+ },
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 自定义解析响应:(rawData, res) => string | { url, data? }
|
|
|
|
|
+ * 优先级最高
|
|
|
|
|
+ */
|
|
|
|
|
+ parseResponse: {
|
|
|
|
|
+ type: Function,
|
|
|
|
|
+ default: null
|
|
|
|
|
+ },
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 删除前钩子:({ index, file }) => boolean | Promise<boolean>
|
|
|
|
|
+ * 返回 false 拦截删除。常用于确认弹窗。
|
|
|
|
|
+ */
|
|
|
|
|
+ beforeRemove: {
|
|
|
|
|
+ type: Function,
|
|
|
|
|
+ default: null
|
|
|
|
|
+ },
|
|
|
|
|
+ /** 调试日志 */
|
|
|
|
|
+ debug: {
|
|
|
|
|
+ type: Boolean,
|
|
|
|
|
+ default: false
|
|
|
|
|
+ },
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 同名文件是否覆盖
|
|
|
|
|
+ * false:自动重命名 name(1).ext;true:覆盖同名
|
|
|
|
|
+ */
|
|
|
|
|
+ distinct: {
|
|
|
|
|
+ type: Boolean,
|
|
|
|
|
+ default: false
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ data() {
|
|
|
|
|
+ return {
|
|
|
|
|
+ innerList: [],
|
|
|
|
|
+ syncing: false,
|
|
|
|
|
+ uploadTasks: {},
|
|
|
|
|
+ appChooseToken: null,
|
|
|
|
|
+ appUploadToken: null,
|
|
|
|
|
+ appRemoveToken: null,
|
|
|
|
|
+ appAbortToken: null,
|
|
|
|
|
+ appPreviewToken: null,
|
|
|
|
|
+ appReadToken: null,
|
|
|
|
|
+ appUploadCallbacks: {},
|
|
|
|
|
+ /** chooseFile 一次性回调 */
|
|
|
|
|
+ chooseFileSuccess: null,
|
|
|
|
|
+ chooseFileFail: null,
|
|
|
|
|
+ /** 命令式 upload / 读文件回调 Map */
|
|
|
|
|
+ uploadCallbackMap: {},
|
|
|
|
|
+ readCallbackMap: {},
|
|
|
|
|
+ previewVisible: false,
|
|
|
|
|
+ previewSrc: '',
|
|
|
|
|
+ previewFallback: '',
|
|
|
|
|
+ previewBlobUrls: []
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ computed: {
|
|
|
|
|
+ isHeadless() {
|
|
|
|
|
+ return this.mode === 'headless'
|
|
|
|
|
+ },
|
|
|
|
|
+ canAddMore() {
|
|
|
|
|
+ return !this.disabled && !this.readonly && this.innerList.length < this.maxCount
|
|
|
|
|
+ },
|
|
|
|
|
+ triggerVisible() {
|
|
|
|
|
+ if (this.isHeadless) return false
|
|
|
|
|
+ return this.showTrigger !== false && this.canAddMore
|
|
|
|
|
+ },
|
|
|
|
|
+ hasTriggerSlot() {
|
|
|
|
|
+ return !!(this.$slots && this.$slots.trigger)
|
|
|
|
|
+ },
|
|
|
|
|
+ triggerSlotProps() {
|
|
|
|
|
+ return {
|
|
|
|
|
+ count: this.innerList.length,
|
|
|
|
|
+ maxCount: this.maxCount,
|
|
|
|
|
+ disabled: this.disabled || this.readonly,
|
|
|
|
|
+ choose: this.onChoose,
|
|
|
|
|
+ readonly: this.readonly
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ formatList() {
|
|
|
|
|
+ return parseFormats(this.formats)
|
|
|
|
|
+ },
|
|
|
|
|
+ resolvedUploadOptions() {
|
|
|
|
|
+ const o = this.uploadOptions || {}
|
|
|
|
|
+ const method = (o.method || DEFAULT_UPLOAD_OPTIONS.method || 'POST').toString().toUpperCase()
|
|
|
|
|
+ return {
|
|
|
|
|
+ name: o.name || DEFAULT_UPLOAD_OPTIONS.name,
|
|
|
|
|
+ header: o.header && typeof o.header === 'object' ? o.header : {},
|
|
|
|
|
+ formData: o.formData && typeof o.formData === 'object' ? o.formData : {},
|
|
|
|
|
+ method,
|
|
|
|
|
+ toBase: o.toBase === true,
|
|
|
|
|
+ withCredentials: o.withCredentials === true
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ poolSize() {
|
|
|
|
|
+ const n = Number(this.concurrent)
|
|
|
|
|
+ if (!n || n < 1) return 1
|
|
|
|
|
+ return Math.floor(n)
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ watch: {
|
|
|
|
|
+ modelValue: {
|
|
|
|
|
+ immediate: true,
|
|
|
|
|
+ deep: true,
|
|
|
|
|
+ handler(val) {
|
|
|
|
|
+ if (this.syncing) return
|
|
|
|
|
+ this.syncFromModel(val)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ beforeUnmount() {
|
|
|
|
|
+ this.abortAll()
|
|
|
|
|
+ this.closePreview()
|
|
|
|
|
+ },
|
|
|
|
|
+ methods: {
|
|
|
|
|
+ noop() {},
|
|
|
|
|
+ tip(msg) {
|
|
|
|
|
+ if (!this.toast || !msg) return
|
|
|
|
|
+ uni.showToast({ title: String(msg), icon: 'none' })
|
|
|
|
|
+ },
|
|
|
|
|
+ isRemoteOrFileUrl(src) {
|
|
|
|
|
+ if (!src || typeof src !== 'string') return false
|
|
|
|
|
+ if (/^data:|^blob:/i.test(src)) return false
|
|
|
|
|
+ return /^(https?:|file:|\/|_doc\/|_www\/|wxfile:|ttfile:|content:)/i.test(src) || src.indexOf('/') >= 0
|
|
|
|
|
+ },
|
|
|
|
|
+ openInnerPreview(src, fallback) {
|
|
|
|
|
+ if (!src && !fallback) {
|
|
|
|
|
+ this.tip('暂无可预览内容')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ this.previewFallback = fallback || ''
|
|
|
|
|
+ this.previewSrc = src || fallback
|
|
|
|
|
+ this.previewVisible = true
|
|
|
|
|
+ },
|
|
|
|
|
+ onPreviewImgError() {
|
|
|
|
|
+ if (this.previewFallback && this.previewSrc !== this.previewFallback) {
|
|
|
|
|
+ this.previewSrc = this.previewFallback
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ this.tip('图片预览失败')
|
|
|
|
|
+ },
|
|
|
|
|
+ closePreview() {
|
|
|
|
|
+ this.previewVisible = false
|
|
|
|
|
+ this.previewSrc = ''
|
|
|
|
|
+ this.previewFallback = ''
|
|
|
|
|
+ // #ifdef APP-PLUS
|
|
|
|
|
+ if (this.previewBlobUrls.length) {
|
|
|
|
|
+ this.appPreviewToken = {
|
|
|
|
|
+ t: Date.now(),
|
|
|
|
|
+ action: 'revoke',
|
|
|
|
|
+ urls: this.previewBlobUrls.slice()
|
|
|
|
|
+ }
|
|
|
|
|
+ this.previewBlobUrls = []
|
|
|
|
|
+ }
|
|
|
|
|
+ // #endif
|
|
|
|
|
+ },
|
|
|
|
|
+ sizeText(n) {
|
|
|
|
|
+ return formatBytes(n)
|
|
|
|
|
+ },
|
|
|
|
|
+ isImageItem(item) {
|
|
|
|
|
+ if (!item) return false
|
|
|
|
|
+ const ext = item.ext || getExt(item.name)
|
|
|
|
|
+ if (IMAGE_EXTS.has(ext)) return true
|
|
|
|
|
+ const mime = item.mime || item.type || ''
|
|
|
|
|
+ return typeof mime === 'string' && mime.indexOf('image/') === 0
|
|
|
|
|
+ },
|
|
|
|
|
+ thumbSrc(item) {
|
|
|
|
|
+ if (!this.showThumb || !item) return ''
|
|
|
|
|
+ // App 选图常无本地 path,用 renderjs 生成的 thumb(dataURL)
|
|
|
|
|
+ return item.thumb || item.url || item.path || ''
|
|
|
|
|
+ },
|
|
|
|
|
+ iconType(item) {
|
|
|
|
|
+ const ext = item.ext || getExt(item.name)
|
|
|
|
|
+ if (IMAGE_EXTS.has(ext)) return 'image'
|
|
|
|
|
+ if (DOC_EXTS.has(ext)) return 'doc'
|
|
|
|
|
+ if (VIDEO_EXTS.has(ext)) return 'video'
|
|
|
|
|
+ if (AUDIO_EXTS.has(ext)) return 'audio'
|
|
|
|
|
+ return 'file'
|
|
|
|
|
+ },
|
|
|
|
|
+ iconLabel(item) {
|
|
|
|
|
+ const map = { image: '图', doc: '文', video: '视', audio: '音', file: '件' }
|
|
|
|
|
+ return map[this.iconType(item)] || '件'
|
|
|
|
|
+ },
|
|
|
|
|
+ canRemoveItem(item) {
|
|
|
|
|
+ if (this.readonly || this.disabled || !this.showRemove) return false
|
|
|
|
|
+ if (!item) return false
|
|
|
|
|
+ return item.status !== 'uploading'
|
|
|
|
|
+ },
|
|
|
|
|
+ toPublicFile(item) {
|
|
|
|
|
+ if (!item) return null
|
|
|
|
|
+ return {
|
|
|
|
|
+ name: item.name,
|
|
|
|
|
+ url: item.url,
|
|
|
|
|
+ path: item.path,
|
|
|
|
|
+ size: item.size,
|
|
|
|
|
+ ext: item.ext,
|
|
|
|
|
+ status: item.status,
|
|
|
|
|
+ progress: item.progress,
|
|
|
|
|
+ uid: item.uid,
|
|
|
|
|
+ message: item.message || ''
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ normalizeItem(raw, fallback = {}) {
|
|
|
|
|
+ const name = raw.name || inferName(raw.url || raw.path) || fallback.name || 'file'
|
|
|
|
|
+ const path = raw.path || raw.tempFilePath || ''
|
|
|
|
|
+ // url 只保留真实路径/远程地址,不用 thumb(避免 base64 进 v-model)
|
|
|
|
|
+ const url = raw.url || path
|
|
|
|
|
+ const ext = raw.ext || getExt(name)
|
|
|
|
|
+ const hasRemoteOnly = !!(raw.url && !raw.path && !path && !raw.tempFilePath)
|
|
|
|
|
+ const thumb = raw.thumb || ''
|
|
|
|
|
+ return {
|
|
|
|
|
+ uid: raw.uid || nextUid(),
|
|
|
|
|
+ name,
|
|
|
|
|
+ url,
|
|
|
|
|
+ path: path || (hasRemoteOnly ? '' : url),
|
|
|
|
|
+ thumb,
|
|
|
|
|
+ size: Number(raw.size) || 0,
|
|
|
|
|
+ ext,
|
|
|
|
|
+ mime: raw.mime || raw.type || '',
|
|
|
|
|
+ status: raw.status || (hasRemoteOnly ? 'success' : 'ready'),
|
|
|
|
|
+ progress:
|
|
|
|
|
+ raw.progress != null
|
|
|
|
|
+ ? raw.progress
|
|
|
|
|
+ : raw.status === 'success' || hasRemoteOnly
|
|
|
|
|
+ ? 100
|
|
|
|
|
+ : 0,
|
|
|
|
|
+ message: raw.message || '',
|
|
|
|
|
+ appKey: raw.appKey || ''
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ toEmitList(list) {
|
|
|
|
|
+ return (list || this.innerList).map((i) => this.toPublicFile(i))
|
|
|
|
|
+ },
|
|
|
|
|
+ emitChange(extra) {
|
|
|
|
|
+ const fileList = this.toEmitList()
|
|
|
|
|
+ this.syncing = true
|
|
|
|
|
+ this.$emit('update:modelValue', fileList)
|
|
|
|
|
+ this.$emit('change', { fileList, ...(extra || {}) })
|
|
|
|
|
+ this.$nextTick(() => {
|
|
|
|
|
+ this.syncing = false
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ syncFromModel(val) {
|
|
|
|
|
+ const list = Array.isArray(val) ? val : []
|
|
|
|
|
+ const old = this.innerList
|
|
|
|
|
+ this.innerList = list.map((raw, i) => {
|
|
|
|
|
+ const prev =
|
|
|
|
|
+ old.find((o) => o.uid === raw.uid) ||
|
|
|
|
|
+ old.find((o) => o.url && o.url === raw.url) ||
|
|
|
|
|
+ old[i]
|
|
|
|
|
+ if (prev && (prev.uid === raw.uid || (prev.url && prev.url === raw.url))) {
|
|
|
|
|
+ return this.normalizeItem({
|
|
|
|
|
+ ...prev,
|
|
|
|
|
+ ...raw,
|
|
|
|
|
+ uid: prev.uid,
|
|
|
|
|
+ appKey: prev.appKey,
|
|
|
|
|
+ thumb: raw.thumb || prev.thumb
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ return this.normalizeItem(raw)
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ /** 解析上传成功后的远程 url */
|
|
|
|
|
+ resolveRemoteUrl(resData, res) {
|
|
|
|
|
+ if (typeof this.parseResponse === 'function') {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const out = this.parseResponse(resData, res)
|
|
|
|
|
+ if (typeof out === 'string') return out
|
|
|
|
|
+ if (out && typeof out === 'object') {
|
|
|
|
|
+ if (out.url) return String(out.url)
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ console.error('[kex-file-uploader] parseResponse error', e)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ const data = parseResponseData(resData)
|
|
|
|
|
+ if (this.responseUrlKey) {
|
|
|
|
|
+ const byKey = getByPath(data, this.responseUrlKey)
|
|
|
|
|
+ if (byKey) return byKey
|
|
|
|
|
+ // 若 raw 是字符串再试一次路径(少见)
|
|
|
|
|
+ const byRaw = getByPath(
|
|
|
|
|
+ typeof resData === 'object' ? resData : { value: resData },
|
|
|
|
|
+ this.responseUrlKey
|
|
|
|
|
+ )
|
|
|
|
|
+ if (byRaw) return byRaw
|
|
|
|
|
+ }
|
|
|
|
|
+ return defaultParseUploadUrl(resData)
|
|
|
|
|
+ },
|
|
|
|
|
+ resolveResponsePayload(resData, res) {
|
|
|
|
|
+ const data = parseResponseData(resData)
|
|
|
|
|
+ if (typeof this.parseResponse === 'function') {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const out = this.parseResponse(resData, res)
|
|
|
|
|
+ if (out && typeof out === 'object' && out.data !== undefined) {
|
|
|
|
|
+ return { url: out.url || this.resolveRemoteUrl(resData, res), data: out.data }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {}
|
|
|
|
|
+ }
|
|
|
|
|
+ return { url: this.resolveRemoteUrl(resData, res), data }
|
|
|
|
|
+ },
|
|
|
|
|
+ choose() {
|
|
|
|
|
+ this.onChoose()
|
|
|
|
|
+ },
|
|
|
|
|
+ /**
|
|
|
|
|
+ * chooseFile({ success, fail, count, type, formats, size, accept, multiple })
|
|
|
|
|
+ */
|
|
|
|
|
+ chooseFile(e = {}) {
|
|
|
|
|
+ const { success, fail, ...option } = e || {}
|
|
|
|
|
+ this.chooseFileSuccess = typeof success === 'function' ? success : null
|
|
|
|
|
+ this.chooseFileFail = typeof fail === 'function' ? fail : null
|
|
|
|
|
+ this.onChoose(option)
|
|
|
|
|
+ },
|
|
|
|
|
+ /**
|
|
|
|
|
+ * upload(index) 按列表下标上传
|
|
|
|
|
+ * upload({ url, file, ... }) 命令式上传
|
|
|
|
|
+ * upload() 无参时等同 uploadAll
|
|
|
|
|
+ */
|
|
|
|
|
+ upload(arg) {
|
|
|
|
|
+ if (arg == null || arg === '') {
|
|
|
|
|
+ return this.uploadAll()
|
|
|
|
|
+ }
|
|
|
|
|
+ if (typeof arg === 'number') {
|
|
|
|
|
+ return this.uploadOne(arg)
|
|
|
|
|
+ }
|
|
|
|
|
+ if (typeof arg === 'object') {
|
|
|
|
|
+ return this.uploadByParams(arg)
|
|
|
|
|
+ }
|
|
|
|
|
+ return this.uploadOne(arg)
|
|
|
|
|
+ },
|
|
|
|
|
+ uploadAll() {
|
|
|
|
|
+ const indexes = []
|
|
|
|
|
+ this.innerList.forEach((it, i) => {
|
|
|
|
|
+ if (it.status === 'ready' || it.status === 'fail') indexes.push(i)
|
|
|
|
|
+ })
|
|
|
|
|
+ return this.runUploadPool(indexes)
|
|
|
|
|
+ },
|
|
|
|
|
+ clear() {
|
|
|
|
|
+ this.abortAll()
|
|
|
|
|
+ // #ifdef APP-PLUS
|
|
|
|
|
+ this.appRemoveToken = { all: true, t: Date.now() }
|
|
|
|
|
+ // #endif
|
|
|
|
|
+ this.innerList = []
|
|
|
|
|
+ this.emitChange({ cleared: true })
|
|
|
|
|
+ },
|
|
|
|
|
+ getFileList() {
|
|
|
|
|
+ return this.toEmitList()
|
|
|
|
|
+ },
|
|
|
|
|
+ /**
|
|
|
|
|
+ * openDocument({ file, success, fail })
|
|
|
|
|
+ */
|
|
|
|
|
+ openDocument({ file, success, fail } = {}) {
|
|
|
|
|
+ if (!file) {
|
|
|
|
|
+ const err = new Error('未传入文件')
|
|
|
|
|
+ if (typeof fail === 'function') fail(err)
|
|
|
|
|
+ else this.tip('未传入文件')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ this.getTempFilePath({
|
|
|
|
|
+ file,
|
|
|
|
|
+ success: (e) => {
|
|
|
|
|
+ const path = e && e.result
|
|
|
|
|
+ if (!path) {
|
|
|
|
|
+ const err = new Error('无法获取文件路径')
|
|
|
|
|
+ if (typeof fail === 'function') fail(err)
|
|
|
|
|
+ else this.tip('无法预览该文件')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ // #ifdef H5
|
|
|
|
|
+ try {
|
|
|
|
|
+ window.open(path, '_blank')
|
|
|
|
|
+ if (typeof success === 'function') success({ path })
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ if (typeof fail === 'function') fail(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ // #endif
|
|
|
|
|
+ // #ifndef H5
|
|
|
|
|
+ uni.openDocument({
|
|
|
|
|
+ filePath: path,
|
|
|
|
|
+ showMenu: true,
|
|
|
|
|
+ success: (res) => {
|
|
|
|
|
+ if (typeof success === 'function') success(res)
|
|
|
|
|
+ },
|
|
|
|
|
+ fail: (error) => {
|
|
|
|
|
+ if (typeof fail === 'function') fail(error)
|
|
|
|
|
+ else this.tip('打开文档失败')
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ // #endif
|
|
|
|
|
+ },
|
|
|
|
|
+ fail: (error) => {
|
|
|
|
|
+ if (typeof fail === 'function') fail(error)
|
|
|
|
|
+ else this.tip('打开文档失败')
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ /**
|
|
|
|
|
+ * getTempFilePath:有 path 直接返回;App renderjs 无 path 时走读文件写临时路径
|
|
|
|
|
+ */
|
|
|
|
|
+ getTempFilePath(params = {}) {
|
|
|
|
|
+ const file = params.file
|
|
|
|
|
+ if (!file || !file.name) {
|
|
|
|
|
+ const err = new Error('ERROR: file is undefined')
|
|
|
|
|
+ if (typeof params.fail === 'function') params.fail(err)
|
|
|
|
|
+ else throw err
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const path = file.path || file.url || file.tempFilePath
|
|
|
|
|
+ if (path && this.isRemoteOrFileUrl(path) && !/^data:|^blob:/i.test(path)) {
|
|
|
|
|
+ if (typeof params.success === 'function') {
|
|
|
|
|
+ params.success({ name: file.name, result: path, status: 'success' })
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ // #ifdef APP-PLUS
|
|
|
|
|
+ const key = file.appKey || file.name
|
|
|
|
|
+ this.readCallbackMap[key] = {
|
|
|
|
|
+ type: 'getTempFilePath',
|
|
|
|
|
+ success: params.success,
|
|
|
|
|
+ fail: params.fail,
|
|
|
|
|
+ chunks: [],
|
|
|
|
|
+ received: new Set(),
|
|
|
|
|
+ total: 0,
|
|
|
|
|
+ fileType: file.type || file.mime || ''
|
|
|
|
|
+ }
|
|
|
|
|
+ this.appReadToken = {
|
|
|
|
|
+ t: Date.now(),
|
|
|
|
|
+ action: 'getTempFilePath',
|
|
|
|
|
+ name: key
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ // #endif
|
|
|
|
|
+ // #ifndef APP-PLUS
|
|
|
|
|
+ if (typeof params.fail === 'function') {
|
|
|
|
|
+ params.fail(new Error('无可用本地路径'))
|
|
|
|
|
+ }
|
|
|
|
|
+ // #endif
|
|
|
|
|
+ },
|
|
|
|
|
+ /** getBase:读取为 Base64 */
|
|
|
|
|
+ getBase(params = {}) {
|
|
|
|
|
+ this._readFileContent(params, 'getBase')
|
|
|
|
|
+ },
|
|
|
|
|
+ /** getArrayBuffer:读取为 ArrayBuffer */
|
|
|
|
|
+ getArrayBuffer(params = {}) {
|
|
|
|
|
+ this._readFileContent(params, 'getArrayBuffer')
|
|
|
|
|
+ },
|
|
|
|
|
+ _readFileContent(params, kind) {
|
|
|
|
|
+ const file = params.file
|
|
|
|
|
+ if (!file || !file.name) {
|
|
|
|
|
+ const err = new Error('ERROR: file is undefined')
|
|
|
|
|
+ if (typeof params.fail === 'function') params.fail(err)
|
|
|
|
|
+ else throw err
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const path = file.path || file.url || file.tempFilePath
|
|
|
|
|
+ // #ifndef APP-PLUS
|
|
|
|
|
+ if (!path) {
|
|
|
|
|
+ if (typeof params.fail === 'function') params.fail(new Error('无本地路径'))
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ this._wxReadFile(params, kind === 'getBase' ? 'base64' : 'arrayBuffer')
|
|
|
|
|
+ return
|
|
|
|
|
+ // #endif
|
|
|
|
|
+ // #ifdef APP-PLUS
|
|
|
|
|
+ const key = file.appKey || file.name
|
|
|
|
|
+ this.readCallbackMap[key] = {
|
|
|
|
|
+ type: kind,
|
|
|
|
|
+ success: params.success,
|
|
|
|
|
+ fail: params.fail,
|
|
|
|
|
+ chunks: [],
|
|
|
|
|
+ received: new Set(),
|
|
|
|
|
+ total: 0,
|
|
|
|
|
+ fileType: file.type || file.mime || ''
|
|
|
|
|
+ }
|
|
|
|
|
+ this.appReadToken = {
|
|
|
|
|
+ t: Date.now(),
|
|
|
|
|
+ action: kind,
|
|
|
|
|
+ name: key
|
|
|
|
|
+ }
|
|
|
|
|
+ // #endif
|
|
|
|
|
+ },
|
|
|
|
|
+ _wxReadFile(params, encoding) {
|
|
|
|
|
+ const file = params.file
|
|
|
|
|
+ const path = file.path || file.url
|
|
|
|
|
+ const fs = typeof uni.getFileSystemManager === 'function' ? uni.getFileSystemManager() : null
|
|
|
|
|
+ if (!fs || typeof fs.readFile !== 'function') {
|
|
|
|
|
+ // H5:无文件系统 API 时尝试 fetch
|
|
|
|
|
+ // #ifdef H5
|
|
|
|
|
+ if (typeof fetch === 'function' && path) {
|
|
|
|
|
+ fetch(path)
|
|
|
|
|
+ .then((r) => r.blob())
|
|
|
|
|
+ .then(
|
|
|
|
|
+ (blob) =>
|
|
|
|
|
+ new Promise((resolve, reject) => {
|
|
|
|
|
+ const reader = new FileReader()
|
|
|
|
|
+ reader.onload = () => resolve(reader.result)
|
|
|
|
|
+ reader.onerror = reject
|
|
|
|
|
+ if (encoding === 'base64') reader.readAsDataURL(blob)
|
|
|
|
|
+ else reader.readAsArrayBuffer(blob)
|
|
|
|
|
+ })
|
|
|
|
|
+ )
|
|
|
|
|
+ .then((result) => {
|
|
|
|
|
+ let out = result
|
|
|
|
|
+ if (encoding === 'base64' && typeof result === 'string') {
|
|
|
|
|
+ const i = result.indexOf(',')
|
|
|
|
|
+ out = i >= 0 ? result.substring(i + 1) : result
|
|
|
|
|
+ }
|
|
|
|
|
+ if (typeof params.success === 'function') {
|
|
|
|
|
+ params.success({
|
|
|
|
|
+ name: file.name,
|
|
|
|
|
+ status: 'success',
|
|
|
|
|
+ result: out
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch((err) => {
|
|
|
|
|
+ if (typeof params.fail === 'function') params.fail(err)
|
|
|
|
|
+ })
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ // #endif
|
|
|
|
|
+ if (typeof params.fail === 'function') {
|
|
|
|
|
+ params.fail(new Error('当前平台不支持读文件'))
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ fs.readFile({
|
|
|
|
|
+ filePath: path,
|
|
|
|
|
+ encoding: encoding === 'base64' ? 'base64' : undefined,
|
|
|
|
|
+ success: (res) => {
|
|
|
|
|
+ if (typeof params.success === 'function') {
|
|
|
|
|
+ params.success({
|
|
|
|
|
+ name: file.name,
|
|
|
|
|
+ status: 'success',
|
|
|
|
|
+ result: res.data
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ fail: (err) => {
|
|
|
|
|
+ if (typeof params.fail === 'function') params.fail(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ base64ToArrayBuffer(base64) {
|
|
|
|
|
+ const binaryString = typeof atob === 'function' ? atob(base64) : ''
|
|
|
|
|
+ const bytes = new Uint8Array(binaryString.length)
|
|
|
|
|
+ for (let i = 0; i < binaryString.length; i++) {
|
|
|
|
|
+ bytes[i] = binaryString.charCodeAt(i)
|
|
|
|
|
+ }
|
|
|
|
|
+ return bytes.buffer
|
|
|
|
|
+ },
|
|
|
|
|
+ base64ToPath(base64, fileName) {
|
|
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
|
|
+ // #ifdef APP-PLUS
|
|
|
|
|
+ const docDir = plus.io.convertLocalFileSystemURL('_doc/')
|
|
|
|
|
+ plus.io.resolveLocalFileSystemURL(
|
|
|
|
|
+ docDir,
|
|
|
|
|
+ (dirEntry) => {
|
|
|
|
|
+ dirEntry.getFile(
|
|
|
|
|
+ fileName,
|
|
|
|
|
+ { create: true },
|
|
|
|
|
+ (fileEntry) => {
|
|
|
|
|
+ fileEntry.createWriter(
|
|
|
|
|
+ async (writer) => {
|
|
|
|
|
+ const chunkSize = 10 * 1024 * 1024
|
|
|
|
|
+ let currentIndex = 0
|
|
|
|
|
+ try {
|
|
|
|
|
+ const writeChunk = (chunk) =>
|
|
|
|
|
+ new Promise((res, rej) => {
|
|
|
|
|
+ writer.onerror = (e) => rej(e)
|
|
|
|
|
+ writer.onwrite = () => res()
|
|
|
|
|
+ writer.writeAsBinary(chunk)
|
|
|
|
|
+ })
|
|
|
|
|
+ while (currentIndex < base64.length) {
|
|
|
|
|
+ const chunk = base64.substring(
|
|
|
|
|
+ currentIndex,
|
|
|
|
|
+ currentIndex + chunkSize
|
|
|
|
|
+ )
|
|
|
|
|
+ currentIndex += chunk.length
|
|
|
|
|
+ await writeChunk(chunk)
|
|
|
|
|
+ }
|
|
|
|
|
+ if (this.debug) console.log('[kex-fu] 写入成功', `_doc/${fileName}`)
|
|
|
|
|
+ resolve(`_doc/${fileName}`)
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ reject(e)
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ (error) => reject(error)
|
|
|
|
|
+ )
|
|
|
|
|
+ },
|
|
|
|
|
+ (error) => reject(error)
|
|
|
|
|
+ )
|
|
|
|
|
+ },
|
|
|
|
|
+ (error) => reject(error)
|
|
|
|
|
+ )
|
|
|
|
|
+ // #endif
|
|
|
|
|
+ // #ifndef APP-PLUS
|
|
|
|
|
+ reject(new Error('该功能仅支持 Android/iOS App(plus.io)'))
|
|
|
|
|
+ // #endif
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ logDebug(...args) {
|
|
|
|
|
+ if (this.debug) console.log('[kex-file-uploader]', ...args)
|
|
|
|
|
+ },
|
|
|
|
|
+ /** 取消指定下标上传 */
|
|
|
|
|
+ abort(index) {
|
|
|
|
|
+ const item = this.innerList[index]
|
|
|
|
|
+ if (!item || item.status !== 'uploading') return
|
|
|
|
|
+ const uid = item.uid
|
|
|
|
|
+ const task = this.uploadTasks[uid]
|
|
|
|
|
+ if (task && typeof task.abort === 'function') {
|
|
|
|
|
+ try {
|
|
|
|
|
+ task.abort()
|
|
|
|
|
+ } catch (e) {}
|
|
|
|
|
+ }
|
|
|
|
|
+ // #ifdef APP-PLUS
|
|
|
|
|
+ if (item.appKey) {
|
|
|
|
|
+ this.appAbortToken = { name: item.appKey, t: Date.now() }
|
|
|
|
|
+ }
|
|
|
|
|
+ // #endif
|
|
|
|
|
+ delete this.uploadTasks[uid]
|
|
|
|
|
+ item.status = 'fail'
|
|
|
|
|
+ item.message = '已取消'
|
|
|
|
|
+ item.progress = 0
|
|
|
|
|
+ this.$emit('uploadAbort', { index, file: this.toPublicFile(item) })
|
|
|
|
|
+ this.emitChange({ aborted: true })
|
|
|
|
|
+ },
|
|
|
|
|
+ abortAll() {
|
|
|
|
|
+ this.innerList.forEach((it, i) => {
|
|
|
|
|
+ if (it.status === 'uploading') this.abort(i)
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ async runUploadPool(indexes) {
|
|
|
|
|
+ const queue = indexes.slice()
|
|
|
|
|
+ if (!queue.length) return []
|
|
|
|
|
+ const size = Math.min(this.poolSize, queue.length)
|
|
|
|
|
+ const workers = []
|
|
|
|
|
+ for (let w = 0; w < size; w++) {
|
|
|
|
|
+ workers.push(
|
|
|
|
|
+ (async () => {
|
|
|
|
|
+ while (queue.length) {
|
|
|
|
|
+ const idx = queue.shift()
|
|
|
|
|
+ await this.uploadOne(idx)
|
|
|
|
|
+ }
|
|
|
|
|
+ })()
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ return Promise.all(workers)
|
|
|
|
|
+ },
|
|
|
|
|
+ onChoose(override) {
|
|
|
|
|
+ if (this.disabled || this.readonly) {
|
|
|
|
|
+ if (this.chooseFileFail) {
|
|
|
|
|
+ this.chooseFileFail({ status: 'fail', message: 'disabled' })
|
|
|
|
|
+ this.chooseFileSuccess = null
|
|
|
|
|
+ this.chooseFileFail = null
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const remain = this.maxCount - this.innerList.length
|
|
|
|
|
+ if (remain <= 0) {
|
|
|
|
|
+ this.$emit('exceed', { maxCount: this.maxCount })
|
|
|
|
|
+ this.tip('最多上传 ' + this.maxCount + ' 个文件')
|
|
|
|
|
+ if (this.chooseFileFail) {
|
|
|
|
|
+ this.chooseFileFail({ status: 'fail', message: 'exceed' })
|
|
|
|
|
+ this.chooseFileSuccess = null
|
|
|
|
|
+ this.chooseFileFail = null
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const ov = override && typeof override === 'object' ? override : {}
|
|
|
|
|
+ const multi =
|
|
|
|
|
+ ov.multiple !== undefined ? !!ov.multiple : this.multiple !== false
|
|
|
|
|
+ const askCount =
|
|
|
|
|
+ ov.count != null ? Math.max(1, Number(ov.count) || 1) : multi ? remain : 1
|
|
|
|
|
+ const pickCount = Math.min(askCount, remain, multi ? remain : 1)
|
|
|
|
|
+ const formatsRaw =
|
|
|
|
|
+ ov.formats !== undefined
|
|
|
|
|
+ ? ov.formats
|
|
|
|
|
+ : this.formatList.join(',')
|
|
|
|
|
+ const formatsStr = Array.isArray(formatsRaw)
|
|
|
|
|
+ ? formatsRaw.join(',')
|
|
|
|
|
+ : String(formatsRaw || '')
|
|
|
|
|
+ const option = {
|
|
|
|
|
+ count: pickCount,
|
|
|
|
|
+ size: ov.size != null ? Number(ov.size) : this.maxSize,
|
|
|
|
|
+ type: ov.type || this.fileType || 'all',
|
|
|
|
|
+ formats: formatsStr,
|
|
|
|
|
+ accept: ov.accept != null ? ov.accept : this.accept || '',
|
|
|
|
|
+ multiple: multi && pickCount > 1,
|
|
|
|
|
+ distinct: this.distinct
|
|
|
|
|
+ }
|
|
|
|
|
+ this.logDebug('choose', option)
|
|
|
|
|
+ // Android / iOS App:renderjs input(鸿蒙不在 APP-PLUS,走下方 chooseOnUni)
|
|
|
|
|
+ // #ifdef APP-PLUS
|
|
|
|
|
+ this.chooseOnApp(option)
|
|
|
|
|
+ // #endif
|
|
|
|
|
+ // #ifdef MP-ALIPAY
|
|
|
|
|
+ this.chooseOnAlipay(option)
|
|
|
|
|
+ // #endif
|
|
|
|
|
+ // H5 / 小程序 / 鸿蒙 App(APP-HARMONY):uni.chooseFile + 后续 uni.uploadFile
|
|
|
|
|
+ // #ifndef APP-PLUS
|
|
|
|
|
+ // #ifndef MP-ALIPAY
|
|
|
|
|
+ this.chooseOnUni(option)
|
|
|
|
|
+ // #endif
|
|
|
|
|
+ // #endif
|
|
|
|
|
+ },
|
|
|
|
|
+ chooseOnUni(option) {
|
|
|
|
|
+ const { count, type, formats, size, multiple } = option
|
|
|
|
|
+ let chooseFn = uni.chooseFile
|
|
|
|
|
+ let extension = parseFormats(formats).map((e) => (e.startsWith('.') ? e : '.' + e))
|
|
|
|
|
+ // #ifdef MP-WEIXIN || MP-QQ
|
|
|
|
|
+ chooseFn =
|
|
|
|
|
+ typeof wx !== 'undefined' && typeof wx.chooseMessageFile === 'function'
|
|
|
|
|
+ ? wx.chooseMessageFile
|
|
|
|
|
+ : uni.chooseMessageFile
|
|
|
|
|
+ extension = parseFormats(formats)
|
|
|
|
|
+ // #endif
|
|
|
|
|
+ if (typeof chooseFn !== 'function') {
|
|
|
|
|
+ this.tip('当前平台不支持选择文件')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const params = {
|
|
|
|
|
+ // 多选关键:count > 1;部分端还会看 multiple
|
|
|
|
|
+ count: Math.max(1, Number(count) || 1),
|
|
|
|
|
+ type: type || 'all',
|
|
|
|
|
+ success: (res) => {
|
|
|
|
|
+ this.handleChosenFiles(res.tempFiles || [], size)
|
|
|
|
|
+ },
|
|
|
|
|
+ fail: (err) => {
|
|
|
|
|
+ if (err && err.errMsg && /cancel|取消/i.test(err.errMsg)) return
|
|
|
|
|
+ this.tip((err && err.errMsg) || '选择文件失败')
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (multiple) params.multiple = true
|
|
|
|
|
+ if (formats) params.extension = extension
|
|
|
|
|
+ chooseFn(params)
|
|
|
|
|
+ },
|
|
|
|
|
+ // #ifdef MP-ALIPAY
|
|
|
|
|
+ chooseOnAlipay(option) {
|
|
|
|
|
+ const { count, type, formats, size } = option
|
|
|
|
|
+ const onFail = (err) => {
|
|
|
|
|
+ const msg = (err && (err.errorMessage || err.errMsg)) || ''
|
|
|
|
|
+ if (msg && !/cancel|取消|error:\s*11|error:\s*15/i.test(msg)) {
|
|
|
|
|
+ this.tip(msg)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ const isImage =
|
|
|
|
|
+ type === 'image' ||
|
|
|
|
|
+ (parseFormats(formats).length > 0 &&
|
|
|
|
|
+ parseFormats(formats).every((e) => IMAGE_EXTS.has(e)))
|
|
|
|
|
+ if (isImage) {
|
|
|
|
|
+ const api =
|
|
|
|
|
+ typeof my !== 'undefined' && typeof my.chooseImage === 'function'
|
|
|
|
|
+ ? my.chooseImage.bind(my)
|
|
|
|
|
+ : uni.chooseImage.bind(uni)
|
|
|
|
|
+ api({
|
|
|
|
|
+ count: Math.min(Math.max(count || 1, 1), 9),
|
|
|
|
|
+ sourceType: ['album', 'camera'],
|
|
|
|
|
+ success: (res) => {
|
|
|
|
|
+ const paths = res.tempFilePaths || res.apFilePaths || []
|
|
|
|
|
+ const files = (res.tempFiles || paths).map((f, i) => {
|
|
|
|
|
+ if (typeof f === 'string') {
|
|
|
|
|
+ return { name: inferName(f), path: f }
|
|
|
|
|
+ }
|
|
|
|
|
+ const path = f.path || paths[i]
|
|
|
|
|
+ let name = f.name || inferName(path)
|
|
|
|
|
+ if (/\.image$/i.test(name)) name = name.replace(/\.image$/i, '.jpg')
|
|
|
|
|
+ return { name, path, size: f.size }
|
|
|
|
|
+ })
|
|
|
|
|
+ this.handleChosenFiles(files, size)
|
|
|
|
|
+ },
|
|
|
|
|
+ fail: onFail
|
|
|
|
|
+ })
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (type === 'video') {
|
|
|
|
|
+ uni.chooseVideo({
|
|
|
|
|
+ sourceType: ['album', 'camera'],
|
|
|
|
|
+ success: (res) => {
|
|
|
|
|
+ this.handleChosenFiles(
|
|
|
|
|
+ [
|
|
|
|
|
+ {
|
|
|
|
|
+ name: (res.tempFile && res.tempFile.name) || inferName(res.tempFilePath),
|
|
|
|
|
+ path: res.tempFilePath,
|
|
|
|
|
+ size: res.size
|
|
|
|
|
+ }
|
|
|
|
|
+ ],
|
|
|
|
|
+ size
|
|
|
|
|
+ )
|
|
|
|
|
+ },
|
|
|
|
|
+ fail: onFail
|
|
|
|
|
+ })
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (typeof my === 'undefined' || typeof my.chooseFileFromDisk !== 'function') {
|
|
|
|
|
+ this.tip('请升级支付宝客户端后重试')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ my.chooseFileFromDisk({
|
|
|
|
|
+ success: (res) => {
|
|
|
|
|
+ if (!res || !res.apFilePath) {
|
|
|
|
|
+ onFail({ errMsg: 'empty' })
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ this.handleChosenFiles(
|
|
|
|
|
+ [
|
|
|
|
|
+ {
|
|
|
|
|
+ name: res.fileName || inferName(res.apFilePath),
|
|
|
|
|
+ path: res.apFilePath
|
|
|
|
|
+ }
|
|
|
|
|
+ ],
|
|
|
|
|
+ size
|
|
|
|
|
+ )
|
|
|
|
|
+ },
|
|
|
|
|
+ fail: onFail
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ // #endif
|
|
|
|
|
+ // #ifdef APP-PLUS
|
|
|
|
|
+ chooseOnApp(option) {
|
|
|
|
|
+ this.appChooseToken = {
|
|
|
|
|
+ t: Date.now(),
|
|
|
|
|
+ count: Math.max(1, Number(option.count) || 1),
|
|
|
|
|
+ size: option.size,
|
|
|
|
|
+ formats: option.formats,
|
|
|
|
|
+ accept: option.accept || '',
|
|
|
|
|
+ // 明确传给 renderjs,避免仅靠 count 推断失败
|
|
|
|
|
+ multiple: option.multiple !== false && Number(option.count) > 1,
|
|
|
|
|
+ distinct: !!this.distinct
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ onAppFilesChosen(files) {
|
|
|
|
|
+ this.handleChosenFiles(files || [], this.maxSize, { fromApp: true })
|
|
|
|
|
+ },
|
|
|
|
|
+ onAppToast(msg) {
|
|
|
|
|
+ this.tip(msg)
|
|
|
|
|
+ },
|
|
|
|
|
+ async onAppReadDone(e) {
|
|
|
|
|
+ if (!e || !e.name) return
|
|
|
|
|
+ const map = this.readCallbackMap[e.name]
|
|
|
|
|
+ if (!map) return
|
|
|
|
|
+ if (e.status === 'fail') {
|
|
|
|
|
+ delete this.readCallbackMap[e.name]
|
|
|
|
|
+ if (typeof map.fail === 'function') map.fail(e)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (e.result && e.result.tempFilePath) {
|
|
|
|
|
+ delete this.readCallbackMap[e.name]
|
|
|
|
|
+ if (typeof map.success === 'function') {
|
|
|
|
|
+ map.success({
|
|
|
|
|
+ name: e.name,
|
|
|
|
|
+ status: 'success',
|
|
|
|
|
+ result: e.result.tempFilePath
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const chunkInfo = e.result || {}
|
|
|
|
|
+ if (chunkInfo.totalChunks != null) {
|
|
|
|
|
+ map.total = chunkInfo.totalChunks
|
|
|
|
|
+ map.chunks[chunkInfo.index] = chunkInfo.chunk
|
|
|
|
|
+ map.received.add(chunkInfo.index)
|
|
|
|
|
+ if (map.received.size < map.total) return
|
|
|
|
|
+ const base64 = map.chunks.filter((c) => c !== undefined).join('')
|
|
|
|
|
+ delete this.readCallbackMap[e.name]
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (map.type === 'getBase') {
|
|
|
|
|
+ if (typeof map.success === 'function') {
|
|
|
|
|
+ map.success({
|
|
|
|
|
+ name: e.name,
|
|
|
|
|
+ status: 'success',
|
|
|
|
|
+ result: base64,
|
|
|
|
|
+ fileType: e.fileType || map.fileType
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if (map.type === 'getArrayBuffer') {
|
|
|
|
|
+ if (typeof map.success === 'function') {
|
|
|
|
|
+ map.success({
|
|
|
|
|
+ name: e.name,
|
|
|
|
|
+ status: 'success',
|
|
|
|
|
+ result: this.base64ToArrayBuffer(base64),
|
|
|
|
|
+ fileType: e.fileType || map.fileType
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if (map.type === 'getTempFilePath') {
|
|
|
|
|
+ const path = await this.base64ToPath(base64, e.name)
|
|
|
|
|
+ if (typeof map.success === 'function') {
|
|
|
|
|
+ map.success({
|
|
|
|
|
+ name: e.name,
|
|
|
|
|
+ status: 'success',
|
|
|
|
|
+ result: path
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ this.appReadToken = {
|
|
|
|
|
+ t: Date.now(),
|
|
|
|
|
+ action: 'asyncFileTempFilePath',
|
|
|
|
|
+ name: e.name,
|
|
|
|
|
+ tempFilePath: path
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ if (typeof map.fail === 'function') map.fail(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ delete this.readCallbackMap[e.name]
|
|
|
|
|
+ if (typeof map.success === 'function') {
|
|
|
|
|
+ map.success({
|
|
|
|
|
+ name: e.name,
|
|
|
|
|
+ status: 'success',
|
|
|
|
|
+ result: e.result,
|
|
|
|
|
+ fileType: e.fileType || map.fileType
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ onAppUploadProgress(e) {
|
|
|
|
|
+ const cbMap = this.uploadCallbackMap[e.name]
|
|
|
|
|
+ if (cbMap && typeof cbMap.onprogress === 'function') {
|
|
|
|
|
+ cbMap.onprogress({
|
|
|
|
|
+ name: e.name,
|
|
|
|
|
+ progress: e.progress || 0,
|
|
|
|
|
+ status: 'onprogress'
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ const idx = this.innerList.findIndex((i) => i.appKey === e.name || i.name === e.name)
|
|
|
|
|
+ if (idx < 0) return
|
|
|
|
|
+ this.innerList[idx].progress = e.progress || 0
|
|
|
|
|
+ this.innerList[idx].status = 'uploading'
|
|
|
|
|
+ },
|
|
|
|
|
+ onAppUploadDone(e) {
|
|
|
|
|
+ const cb = this.appUploadCallbacks[e.name]
|
|
|
|
|
+ if (cb) {
|
|
|
|
|
+ delete this.appUploadCallbacks[e.name]
|
|
|
|
|
+ cb(e)
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ onAppPreviewReady(e) {
|
|
|
|
|
+ if (!e || e.status === 'fail') {
|
|
|
|
|
+ const item = this.innerList.find((i) => i.appKey === (e && e.name))
|
|
|
|
|
+ const fallback = item && (item.thumb || item.url || item.path)
|
|
|
|
|
+ if (fallback && !this.previewVisible) this.openInnerPreview(fallback)
|
|
|
|
|
+ else if (!this.previewVisible) this.tip((e && e.message) || '无法预览')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (e.url) {
|
|
|
|
|
+ this.previewBlobUrls.push(e.url)
|
|
|
|
|
+ // 用原图 blob 替换;失败则回退到当前已显示的缩略图
|
|
|
|
|
+ this.openInnerPreview(e.url, this.previewSrc || this.previewFallback)
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ // #endif
|
|
|
|
|
+ onPreview(item) {
|
|
|
|
|
+ this.$emit('preview', { file: this.toPublicFile(item) })
|
|
|
|
|
+ if (!this.isImageItem(item)) {
|
|
|
|
|
+ const docPath = item.path || item.url
|
|
|
|
|
+ // #ifndef H5
|
|
|
|
|
+ if (docPath && this.isRemoteOrFileUrl(docPath)) {
|
|
|
|
|
+ uni.openDocument({
|
|
|
|
|
+ filePath: docPath,
|
|
|
|
|
+ showMenu: true,
|
|
|
|
|
+ fail: () => {
|
|
|
|
|
+ this.tip('无法预览该文件')
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this.tip('无法预览该文件')
|
|
|
|
|
+ }
|
|
|
|
|
+ // #endif
|
|
|
|
|
+ // #ifdef H5
|
|
|
|
|
+ if (item.url && this.isRemoteOrFileUrl(item.url)) {
|
|
|
|
|
+ window.open(item.url, '_blank')
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this.tip('无法预览该文件')
|
|
|
|
|
+ }
|
|
|
|
|
+ // #endif
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const fileSrc = item.url || item.path
|
|
|
|
|
+ const thumb = item.thumb || ''
|
|
|
|
|
+
|
|
|
|
|
+ // 网络图 / 本地文件路径:系统预览(可左右滑多图)
|
|
|
|
|
+ if (fileSrc && this.isRemoteOrFileUrl(fileSrc)) {
|
|
|
|
|
+ const urls = this.innerList
|
|
|
|
|
+ .filter((i) => this.isImageItem(i))
|
|
|
|
|
+ .map((i) => i.url || i.path)
|
|
|
|
|
+ .filter((u) => u && this.isRemoteOrFileUrl(u))
|
|
|
|
|
+ uni.previewImage({
|
|
|
|
|
+ urls: urls.length ? urls : [fileSrc],
|
|
|
|
|
+ current: fileSrc,
|
|
|
|
|
+ fail: () => {
|
|
|
|
|
+ this.openInnerPreview(fileSrc || thumb)
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // #ifdef APP-PLUS
|
|
|
|
|
+ // App 选图无本地 path:先用 thumb(dataURL)内置预览,再尝试原图 blob
|
|
|
|
|
+ if (item.appKey) {
|
|
|
|
|
+ if (thumb) {
|
|
|
|
|
+ this.openInnerPreview(thumb)
|
|
|
|
|
+ this.appPreviewToken = {
|
|
|
|
|
+ t: Date.now(),
|
|
|
|
|
+ action: 'open',
|
|
|
|
|
+ name: item.appKey
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ this.appPreviewToken = {
|
|
|
|
|
+ t: Date.now(),
|
|
|
|
|
+ action: 'open',
|
|
|
|
|
+ name: item.appKey
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ // #endif
|
|
|
|
|
+
|
|
|
|
|
+ // dataURL / 其它:组件内预览
|
|
|
|
|
+ const src = this.thumbSrc(item)
|
|
|
|
|
+ if (src) {
|
|
|
|
|
+ this.openInnerPreview(src)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ this.tip('暂无可预览内容')
|
|
|
|
|
+ },
|
|
|
|
|
+ handleChosenFiles(tempFiles, sizeLimitMb, opts = {}) {
|
|
|
|
|
+ const maxBytes = sizeLimitMb > 0 ? 1024 * 1024 * Math.abs(sizeLimitMb) : 0
|
|
|
|
|
+ const allowExt = this.formatList
|
|
|
|
|
+ const accepted = []
|
|
|
|
|
+ const start = this.innerList.length
|
|
|
|
|
+
|
|
|
|
|
+ for (let i = 0; i < (tempFiles || []).length; i++) {
|
|
|
|
|
+ if (this.innerList.length + accepted.length >= this.maxCount) {
|
|
|
|
|
+ this.$emit('exceed', { maxCount: this.maxCount })
|
|
|
|
|
+ this.tip('最多上传 ' + this.maxCount + ' 个文件')
|
|
|
|
|
+ break
|
|
|
|
|
+ }
|
|
|
|
|
+ const f = tempFiles[i]
|
|
|
|
|
+ let name = f.name || inferName(f.path || f.tempFilePath)
|
|
|
|
|
+ const ext = getExt(name) || (f.type && String(f.type).split('/')[1]) || ''
|
|
|
|
|
+ const size = Number(f.size) || 0
|
|
|
|
|
+ const path = f.path || f.tempFilePath || ''
|
|
|
|
|
+ const thumb = f.thumb || ''
|
|
|
|
|
+ const mime = f.mime || f.type || ''
|
|
|
|
|
+
|
|
|
|
|
+ if (allowExt.length && ext && allowExt.indexOf(ext) === -1) {
|
|
|
|
|
+ this.tip('不支持 ' + ext.toUpperCase() + ' 格式')
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ if (maxBytes > 0 && size > maxBytes) {
|
|
|
|
|
+ this.$emit('oversize', { name, path, size, maxSize: sizeLimitMb })
|
|
|
|
|
+ this.tip('文件不能超过 ' + sizeLimitMb + 'MB')
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // distinct=true:同名覆盖列表项(逻辑层)
|
|
|
|
|
+ if (this.distinct) {
|
|
|
|
|
+ const existIdx = this.innerList.findIndex((it) => it.name === name)
|
|
|
|
|
+ if (existIdx >= 0) {
|
|
|
|
|
+ const old = this.innerList[existIdx]
|
|
|
|
|
+ // #ifdef APP-PLUS
|
|
|
|
|
+ if (old.appKey) {
|
|
|
|
|
+ this.appRemoveToken = { name: old.appKey, t: Date.now() }
|
|
|
|
|
+ }
|
|
|
|
|
+ // #endif
|
|
|
|
|
+ this.innerList.splice(existIdx, 1)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const item = this.normalizeItem({
|
|
|
|
|
+ name,
|
|
|
|
|
+ path,
|
|
|
|
|
+ url: path,
|
|
|
|
|
+ thumb,
|
|
|
|
|
+ size,
|
|
|
|
|
+ ext,
|
|
|
|
|
+ mime,
|
|
|
|
|
+ status: 'ready',
|
|
|
|
|
+ progress: 0,
|
|
|
|
|
+ appKey: opts.fromApp ? name : ''
|
|
|
|
|
+ })
|
|
|
|
|
+ if (opts.fromApp) item.appKey = name
|
|
|
|
|
+ accepted.push(item)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!accepted.length) {
|
|
|
|
|
+ if (this.chooseFileFail) {
|
|
|
|
|
+ this.chooseFileFail({ status: 'fail', files: [] })
|
|
|
|
|
+ this.chooseFileSuccess = null
|
|
|
|
|
+ this.chooseFileFail = null
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ this.innerList = this.innerList.concat(accepted)
|
|
|
|
|
+ const publicAccepted = this.toEmitList(accepted)
|
|
|
|
|
+ this.$emit('select', {
|
|
|
|
|
+ files: publicAccepted,
|
|
|
|
|
+ count: accepted.length,
|
|
|
|
|
+ fileList: this.toEmitList()
|
|
|
|
|
+ })
|
|
|
|
|
+ this.emitChange({ selected: true })
|
|
|
|
|
+ if (this.chooseFileSuccess) {
|
|
|
|
|
+ this.chooseFileSuccess(
|
|
|
|
|
+ publicAccepted.map((f) => ({
|
|
|
|
|
+ ...f,
|
|
|
|
|
+ status: f.status || 'waiting',
|
|
|
|
|
+ progress: f.progress || 0
|
|
|
|
|
+ }))
|
|
|
|
|
+ )
|
|
|
|
|
+ this.chooseFileSuccess = null
|
|
|
|
|
+ this.chooseFileFail = null
|
|
|
|
|
+ }
|
|
|
|
|
+ if (this.autoUpload && this.uploadUrl) {
|
|
|
|
|
+ this.$nextTick(() => {
|
|
|
|
|
+ const indexes = []
|
|
|
|
|
+ for (let i = 0; i < accepted.length; i++) indexes.push(start + i)
|
|
|
|
|
+ this.runUploadPool(indexes)
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ /**
|
|
|
|
|
+ * upload({ url, file, name, method, header, formData, toBase, success, fail, onprogress })
|
|
|
|
|
+ */
|
|
|
|
|
+ uploadByParams(params = {}) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (!params.url) throw new Error('upload ERROR: url is undefined')
|
|
|
|
|
+ if (!params.file || !params.file.name) {
|
|
|
|
|
+ throw new Error('upload ERROR: file is undefined')
|
|
|
|
|
+ }
|
|
|
|
|
+ const file = params.file
|
|
|
|
|
+ const key = file.appKey || file.name
|
|
|
|
|
+ const header = params.header || {}
|
|
|
|
|
+ const formData = params.formData || {}
|
|
|
|
|
+ const name = params.name || 'file'
|
|
|
|
|
+ const method = (params.method || 'POST').toString().toUpperCase()
|
|
|
|
|
+ const toBase = params.toBase === true
|
|
|
|
|
+ const withCredentials =
|
|
|
|
|
+ params.withCredentials === true ||
|
|
|
|
|
+ this.resolvedUploadOptions.withCredentials
|
|
|
|
|
+
|
|
|
|
|
+ this.logDebug('uploadByParams', params.url, key)
|
|
|
|
|
+
|
|
|
|
|
+ // #ifdef APP-PLUS
|
|
|
|
|
+ if (file.appKey || (!file.path && key)) {
|
|
|
|
|
+ this.uploadCallbackMap[key] = {
|
|
|
|
|
+ success: params.success,
|
|
|
|
|
+ fail: params.fail,
|
|
|
|
|
+ onprogress: params.onprogress
|
|
|
|
|
+ }
|
|
|
|
|
+ this.appUploadCallbacks[key] = (e) => {
|
|
|
|
|
+ const cb = this.uploadCallbackMap[key]
|
|
|
|
|
+ delete this.uploadCallbackMap[key]
|
|
|
|
|
+ if (!cb) return
|
|
|
|
|
+ if (e.status === 'success' && typeof cb.success === 'function') {
|
|
|
|
|
+ cb.success({
|
|
|
|
|
+ name: key,
|
|
|
|
|
+ status: 'success',
|
|
|
|
|
+ message: '上传完成',
|
|
|
|
|
+ result: e.result
|
|
|
|
|
+ })
|
|
|
|
|
+ } else if (e.status !== 'abort' && typeof cb.fail === 'function') {
|
|
|
|
|
+ cb.fail(e)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ this.appUploadToken = {
|
|
|
|
|
+ t: Date.now(),
|
|
|
|
|
+ url: params.url,
|
|
|
|
|
+ name,
|
|
|
|
|
+ header,
|
|
|
|
|
+ formData,
|
|
|
|
|
+ method,
|
|
|
|
|
+ toBase,
|
|
|
|
|
+ withCredentials,
|
|
|
|
|
+ fileName: key
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ // #endif
|
|
|
|
|
+
|
|
|
|
|
+ const filePath = file.path || file.url || file.tempFilePath
|
|
|
|
|
+ if (!filePath) throw new Error('upload ERROR: file path is undefined')
|
|
|
|
|
+ const task = uni.uploadFile({
|
|
|
|
|
+ url: params.url,
|
|
|
|
|
+ filePath,
|
|
|
|
|
+ name,
|
|
|
|
|
+ header,
|
|
|
|
|
+ formData,
|
|
|
|
|
+ success: (res) => {
|
|
|
|
|
+ if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
|
|
|
+ if (typeof params.success === 'function') {
|
|
|
|
|
+ params.success({
|
|
|
|
|
+ name: file.name,
|
|
|
|
|
+ status: 'success',
|
|
|
|
|
+ message: '上传完成',
|
|
|
|
|
+ result: res.data
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if (typeof params.fail === 'function') {
|
|
|
|
|
+ params.fail(res)
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ fail: (err) => {
|
|
|
|
|
+ if (typeof params.fail === 'function') params.fail(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ if (task && typeof task.onProgressUpdate === 'function' && params.onprogress) {
|
|
|
|
|
+ task.onProgressUpdate(({ progress = 0 }) => {
|
|
|
|
|
+ params.onprogress({ name: file.name, progress })
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ return task
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error(error.message || error)
|
|
|
|
|
+ if (typeof params.fail === 'function') params.fail(error)
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ uploadOne(index) {
|
|
|
|
|
+ const item = this.innerList[index]
|
|
|
|
|
+ if (!item || !this.uploadUrl) return Promise.resolve()
|
|
|
|
|
+ if (this.readonly || this.disabled) return Promise.resolve()
|
|
|
|
|
+ if (item.status === 'uploading') return Promise.resolve()
|
|
|
|
|
+
|
|
|
|
|
+ item.status = 'uploading'
|
|
|
|
|
+ item.progress = 0
|
|
|
|
|
+ item.message = ''
|
|
|
|
|
+
|
|
|
|
|
+ // Android/iOS App 无本地 path:renderjs XHR
|
|
|
|
|
+ // 鸿蒙等同 H5:有 path,走 uni.uploadFile(下面 uploadWithUni)
|
|
|
|
|
+ // #ifdef APP-PLUS
|
|
|
|
|
+ if (item.appKey) {
|
|
|
|
|
+ return this.uploadOnApp(index, item)
|
|
|
|
|
+ }
|
|
|
|
|
+ // #endif
|
|
|
|
|
+ return this.uploadWithUni(index, item)
|
|
|
|
|
+ },
|
|
|
|
|
+ uploadWithUni(index, item) {
|
|
|
|
|
+ const filePath = item.path || item.url
|
|
|
|
|
+ if (!filePath) {
|
|
|
|
|
+ item.status = 'fail'
|
|
|
|
|
+ item.message = '无本地路径'
|
|
|
|
|
+ this.$emit('uploadFail', {
|
|
|
|
|
+ index,
|
|
|
|
|
+ file: this.toPublicFile(item),
|
|
|
|
|
+ err: item.message
|
|
|
|
|
+ })
|
|
|
|
|
+ return Promise.resolve()
|
|
|
|
|
+ }
|
|
|
|
|
+ const opt = this.resolvedUploadOptions
|
|
|
|
|
+ const uid = item.uid
|
|
|
|
|
+ return new Promise((resolve) => {
|
|
|
|
|
+ let settled = false
|
|
|
|
|
+ const done = () => {
|
|
|
|
|
+ if (settled) return
|
|
|
|
|
+ settled = true
|
|
|
|
|
+ delete this.uploadTasks[uid]
|
|
|
|
|
+ resolve()
|
|
|
|
|
+ }
|
|
|
|
|
+ const task = uni.uploadFile({
|
|
|
|
|
+ url: this.uploadUrl,
|
|
|
|
|
+ filePath,
|
|
|
|
|
+ name: opt.name,
|
|
|
|
|
+ header: opt.header,
|
|
|
|
|
+ formData: opt.formData,
|
|
|
|
|
+ success: (res) => {
|
|
|
|
|
+ if (item.status !== 'uploading') {
|
|
|
|
|
+ done()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const payload = this.resolveResponsePayload(res.data, res)
|
|
|
|
|
+ if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
|
|
|
+ item.status = 'success'
|
|
|
|
|
+ item.progress = 100
|
|
|
|
|
+ if (payload.url) {
|
|
|
|
|
+ item.url = payload.url
|
|
|
|
|
+ // 远程地址可作缩略图,本地 thumb 可保留作兜底
|
|
|
|
|
+ }
|
|
|
|
|
+ this.$emit('uploadSuccess', {
|
|
|
|
|
+ index,
|
|
|
|
|
+ file: this.toPublicFile(item),
|
|
|
|
|
+ url: item.url,
|
|
|
|
|
+ statusCode: res.statusCode,
|
|
|
|
|
+ data: payload.data,
|
|
|
|
|
+ res,
|
|
|
|
|
+ fileList: this.toEmitList()
|
|
|
|
|
+ })
|
|
|
|
|
+ this.emitChange()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ item.status = 'fail'
|
|
|
|
|
+ item.message = 'HTTP ' + res.statusCode
|
|
|
|
|
+ this.$emit('uploadFail', {
|
|
|
|
|
+ index,
|
|
|
|
|
+ file: this.toPublicFile(item),
|
|
|
|
|
+ err: item.message,
|
|
|
|
|
+ statusCode: res.statusCode,
|
|
|
|
|
+ data: payload.data,
|
|
|
|
|
+ res
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ done()
|
|
|
|
|
+ },
|
|
|
|
|
+ fail: (err) => {
|
|
|
|
|
+ if (item.status !== 'uploading') {
|
|
|
|
|
+ done()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const msg = (err && err.errMsg) || '上传失败'
|
|
|
|
|
+ if (/abort/i.test(msg)) {
|
|
|
|
|
+ done()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ item.status = 'fail'
|
|
|
|
|
+ item.message = msg
|
|
|
|
|
+ this.$emit('uploadFail', {
|
|
|
|
|
+ index,
|
|
|
|
|
+ file: this.toPublicFile(item),
|
|
|
|
|
+ err: item.message,
|
|
|
|
|
+ res: err
|
|
|
|
|
+ })
|
|
|
|
|
+ done()
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ this.uploadTasks[uid] = task
|
|
|
|
|
+ if (task && typeof task.onProgressUpdate === 'function') {
|
|
|
|
|
+ task.onProgressUpdate((e) => {
|
|
|
|
|
+ if (item.status === 'uploading') item.progress = e.progress || 0
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ // #ifdef APP-PLUS
|
|
|
|
|
+ uploadOnApp(index, item) {
|
|
|
|
|
+ const opt = this.resolvedUploadOptions
|
|
|
|
|
+ const key = item.appKey || item.name
|
|
|
|
|
+ const uid = item.uid
|
|
|
|
|
+ return new Promise((resolve) => {
|
|
|
|
|
+ let settled = false
|
|
|
|
|
+ const done = () => {
|
|
|
|
|
+ if (settled) return
|
|
|
|
|
+ settled = true
|
|
|
|
|
+ delete this.uploadTasks[uid]
|
|
|
|
|
+ resolve()
|
|
|
|
|
+ }
|
|
|
|
|
+ this.uploadTasks[uid] = {
|
|
|
|
|
+ abort: () => {
|
|
|
|
|
+ this.appAbortToken = { name: key, t: Date.now() }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ this.appUploadCallbacks[key] = (e) => {
|
|
|
|
|
+ if (item.status !== 'uploading') {
|
|
|
|
|
+ done()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (e.status === 'success') {
|
|
|
|
|
+ const payload = this.resolveResponsePayload(e.result, e)
|
|
|
|
|
+ item.status = 'success'
|
|
|
|
|
+ item.progress = 100
|
|
|
|
|
+ if (payload.url) item.url = payload.url
|
|
|
|
|
+ this.$emit('uploadSuccess', {
|
|
|
|
|
+ index,
|
|
|
|
|
+ file: this.toPublicFile(item),
|
|
|
|
|
+ url: item.url,
|
|
|
|
|
+ statusCode: 200,
|
|
|
|
|
+ data: payload.data,
|
|
|
|
|
+ res: e,
|
|
|
|
|
+ fileList: this.toEmitList()
|
|
|
|
|
+ })
|
|
|
|
|
+ this.emitChange()
|
|
|
|
|
+ } else if (e.status === 'abort') {
|
|
|
|
|
+ // abort() 已处理状态
|
|
|
|
|
+ } else {
|
|
|
|
|
+ item.status = 'fail'
|
|
|
|
|
+ item.message = e.message || '上传失败'
|
|
|
|
|
+ this.$emit('uploadFail', {
|
|
|
|
|
+ index,
|
|
|
|
|
+ file: this.toPublicFile(item),
|
|
|
|
|
+ err: item.message,
|
|
|
|
|
+ res: e
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ done()
|
|
|
|
|
+ }
|
|
|
|
|
+ this.appUploadToken = {
|
|
|
|
|
+ t: Date.now(),
|
|
|
|
|
+ url: this.uploadUrl,
|
|
|
|
|
+ name: opt.name,
|
|
|
|
|
+ header: opt.header,
|
|
|
|
|
+ formData: opt.formData,
|
|
|
|
|
+ method: opt.method || 'POST',
|
|
|
|
|
+ toBase: !!opt.toBase,
|
|
|
|
|
+ withCredentials: !!opt.withCredentials,
|
|
|
|
|
+ fileName: key
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ // #endif
|
|
|
|
|
+ onRetry(index) {
|
|
|
|
|
+ if (!this.uploadUrl || this.readonly || this.disabled) return
|
|
|
|
|
+ this.uploadOne(index)
|
|
|
|
|
+ },
|
|
|
|
|
+ async onRemove(index) {
|
|
|
|
|
+ if (this.readonly || this.disabled || !this.showRemove) return
|
|
|
|
|
+ const item = this.innerList[index]
|
|
|
|
|
+ if (!item || item.status === 'uploading') return
|
|
|
|
|
+
|
|
|
|
|
+ if (typeof this.beforeRemove === 'function') {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const ok = await Promise.resolve(
|
|
|
|
|
+ this.beforeRemove({
|
|
|
|
|
+ index,
|
|
|
|
|
+ file: this.toPublicFile(item)
|
|
|
|
|
+ })
|
|
|
|
|
+ )
|
|
|
|
|
+ if (ok === false) return
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const removed = this.innerList.splice(index, 1)[0]
|
|
|
|
|
+ // #ifdef APP-PLUS
|
|
|
|
|
+ if (removed && removed.appKey) {
|
|
|
|
|
+ this.appRemoveToken = { name: removed.appKey, t: Date.now() }
|
|
|
|
|
+ }
|
|
|
|
|
+ // #endif
|
|
|
|
|
+ this.$emit('remove', { index, file: this.toPublicFile(removed) })
|
|
|
|
|
+ this.emitChange()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<!-- #ifdef APP-PLUS -->
|
|
|
|
|
+<script module="render" lang="renderjs">
|
|
|
|
|
+ export default {
|
|
|
|
|
+ data() {
|
|
|
|
|
+ return {
|
|
|
|
|
+ files: new Map(),
|
|
|
|
|
+ xmls: new Map(),
|
|
|
|
|
+ inputEl: null
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ methods: {
|
|
|
|
|
+ chooseHandle(payload) {
|
|
|
|
|
+ if (!payload || !payload.t) return
|
|
|
|
|
+ const options = {
|
|
|
|
|
+ count: Number(payload.count) || 9,
|
|
|
|
|
+ size: Number(payload.size) || 20,
|
|
|
|
|
+ formats: String(payload.formats || '').toLowerCase(),
|
|
|
|
|
+ accept: payload.accept || '',
|
|
|
|
|
+ multiple: !!payload.multiple,
|
|
|
|
|
+ distinct: !!payload.distinct
|
|
|
|
|
+ }
|
|
|
|
|
+ const imageExts = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico', 'heic', 'heif']
|
|
|
|
|
+ /** App 选图无本地 path,生成小图 dataURL 供逻辑层 <image> 显示 */
|
|
|
|
|
+ const fileToThumb = (file) => {
|
|
|
|
|
+ return new Promise((resolve) => {
|
|
|
|
|
+ const blobUrl = URL.createObjectURL(file)
|
|
|
|
|
+ const img = new Image()
|
|
|
|
|
+ img.onload = () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const maxW = 240
|
|
|
|
|
+ const scale = Math.min(1, maxW / (img.width || maxW))
|
|
|
|
|
+ const w = Math.max(1, Math.round((img.width || maxW) * scale))
|
|
|
|
|
+ const h = Math.max(1, Math.round((img.height || maxW) * scale))
|
|
|
|
|
+ const canvas = document.createElement('canvas')
|
|
|
|
|
+ canvas.width = w
|
|
|
|
|
+ canvas.height = h
|
|
|
|
|
+ const ctx = canvas.getContext('2d')
|
|
|
|
|
+ ctx.drawImage(img, 0, 0, w, h)
|
|
|
|
|
+ URL.revokeObjectURL(blobUrl)
|
|
|
|
|
+ resolve(canvas.toDataURL('image/jpeg', 0.72))
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ URL.revokeObjectURL(blobUrl)
|
|
|
|
|
+ resolve('')
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ img.onerror = () => {
|
|
|
|
|
+ URL.revokeObjectURL(blobUrl)
|
|
|
|
|
+ try {
|
|
|
|
|
+ const reader = new FileReader()
|
|
|
|
|
+ reader.onload = () => resolve(reader.result || '')
|
|
|
|
|
+ reader.onerror = () => resolve('')
|
|
|
|
|
+ reader.readAsDataURL(file)
|
|
|
|
|
+ } catch (e2) {
|
|
|
|
|
+ resolve('')
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ img.src = blobUrl
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ this.$nextTick(() => {
|
|
|
|
|
+ // 每次重建 input,避免安卓 WebView 缓存导致 multiple 不生效
|
|
|
|
|
+ let old = document.getElementById('kex-fu-input')
|
|
|
|
|
+ if (old && old.parentNode) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ old.parentNode.removeChild(old)
|
|
|
|
|
+ } catch (e) {}
|
|
|
|
|
+ }
|
|
|
|
|
+ const input = document.createElement('input')
|
|
|
|
|
+ input.type = 'file'
|
|
|
|
|
+ input.id = 'kex-fu-input'
|
|
|
|
|
+ input.style.cssText = 'position:fixed;left:-9999px;opacity:0;width:0;height:0;'
|
|
|
|
|
+ const allowMultiple = !!(options.multiple && options.count > 1)
|
|
|
|
|
+ if (allowMultiple) {
|
|
|
|
|
+ input.multiple = true
|
|
|
|
|
+ input.setAttribute('multiple', 'multiple')
|
|
|
|
|
+ }
|
|
|
|
|
+ if (options.accept) {
|
|
|
|
|
+ input.accept = options.accept
|
|
|
|
|
+ input.setAttribute('accept', options.accept)
|
|
|
|
|
+ }
|
|
|
|
|
+ document.body.appendChild(input)
|
|
|
|
|
+ this.inputEl = input
|
|
|
|
|
+ input.value = ''
|
|
|
|
|
+ input.onchange = async () => {
|
|
|
|
|
+ const list = input.files || []
|
|
|
|
|
+ const out = []
|
|
|
|
|
+ for (let i = 0; i < list.length; i++) {
|
|
|
|
|
+ if (out.length >= options.count) {
|
|
|
|
|
+ this.$ownerInstance.callMethod('onAppToast', '最多选择' + options.count + '个文件')
|
|
|
|
|
+ break
|
|
|
|
|
+ }
|
|
|
|
|
+ const file = list[i]
|
|
|
|
|
+ const name = file.name
|
|
|
|
|
+ const suffix = name.substring(name.lastIndexOf('.') + 1).toLowerCase()
|
|
|
|
|
+ if (options.formats) {
|
|
|
|
|
+ const allow = options.formats.split(',').map((s) => s.trim()).filter(Boolean)
|
|
|
|
|
+ if (allow.length && allow.indexOf(suffix) === -1) {
|
|
|
|
|
+ this.$ownerInstance.callMethod('onAppToast', '不支持' + suffix.toUpperCase() + '格式')
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (options.size > 0 && file.size > 1024 * 1024 * Math.abs(options.size)) {
|
|
|
|
|
+ this.$ownerInstance.callMethod('onAppToast', '文件不能超过' + options.size + 'MB')
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ let key = name
|
|
|
|
|
+ if (options.distinct) {
|
|
|
|
|
+ // 同名覆盖:删除旧 File
|
|
|
|
|
+ this.files.delete(key)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ let n = 1
|
|
|
|
|
+ while (this.files.has(key)) {
|
|
|
|
|
+ const dot = name.lastIndexOf('.')
|
|
|
|
|
+ const base = dot >= 0 ? name.substring(0, dot) : name
|
|
|
|
|
+ const ext = dot >= 0 ? name.substring(dot) : ''
|
|
|
|
|
+ key = base + '(' + n + ')' + ext
|
|
|
|
|
+ n += 1
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ this.files.set(key, file)
|
|
|
|
|
+ const isImage =
|
|
|
|
|
+ imageExts.indexOf(suffix) !== -1 ||
|
|
|
|
|
+ (file.type && String(file.type).indexOf('image/') === 0)
|
|
|
|
|
+ let thumb = ''
|
|
|
|
|
+ if (isImage) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ thumb = await fileToThumb(file)
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ thumb = ''
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ out.push({
|
|
|
|
|
+ name: key,
|
|
|
|
|
+ size: file.size,
|
|
|
|
|
+ path: '',
|
|
|
|
|
+ thumb,
|
|
|
|
|
+ type: file.type || '',
|
|
|
|
|
+ mime: file.type || ''
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ this.$ownerInstance.callMethod('onAppFilesChosen', out)
|
|
|
|
|
+ }
|
|
|
|
|
+ input.click()
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ removeHandle(payload) {
|
|
|
|
|
+ if (!payload) return
|
|
|
|
|
+ if (payload.all) {
|
|
|
|
|
+ this.files.clear()
|
|
|
|
|
+ this.xmls.forEach((xhr) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ xhr.abort()
|
|
|
|
|
+ } catch (e) {}
|
|
|
|
|
+ })
|
|
|
|
|
+ this.xmls.clear()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (payload.name) {
|
|
|
|
|
+ this.files.delete(payload.name)
|
|
|
|
|
+ const xhr = this.xmls.get(payload.name)
|
|
|
|
|
+ if (xhr) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ xhr.abort()
|
|
|
|
|
+ } catch (e) {}
|
|
|
|
|
+ this.xmls.delete(payload.name)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ abortHandle(payload) {
|
|
|
|
|
+ if (!payload || !payload.name) return
|
|
|
|
|
+ const xhr = this.xmls.get(payload.name)
|
|
|
|
|
+ if (xhr) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ xhr.abort()
|
|
|
|
|
+ } catch (e) {}
|
|
|
|
|
+ this.xmls.delete(payload.name)
|
|
|
|
|
+ }
|
|
|
|
|
+ this.$ownerInstance.callMethod('onAppUploadDone', {
|
|
|
|
|
+ name: payload.name,
|
|
|
|
|
+ status: 'abort',
|
|
|
|
|
+ message: '已取消'
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ previewHandle(payload) {
|
|
|
|
|
+ if (!payload || !payload.t) return
|
|
|
|
|
+ if (payload.action === 'revoke') {
|
|
|
|
|
+ const urls = payload.urls || []
|
|
|
|
|
+ urls.forEach((u) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ URL.revokeObjectURL(u)
|
|
|
|
|
+ } catch (e) {}
|
|
|
|
|
+ })
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (payload.action !== 'open' || !payload.name) return
|
|
|
|
|
+ const file = this.files.get(payload.name)
|
|
|
|
|
+ if (!file) {
|
|
|
|
|
+ this.$ownerInstance.callMethod('onAppPreviewReady', {
|
|
|
|
|
+ name: payload.name,
|
|
|
|
|
+ status: 'fail',
|
|
|
|
|
+ message: '文件未找到'
|
|
|
|
|
+ })
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ const url = URL.createObjectURL(file)
|
|
|
|
|
+ this.$ownerInstance.callMethod('onAppPreviewReady', {
|
|
|
|
|
+ name: payload.name,
|
|
|
|
|
+ status: 'success',
|
|
|
|
|
+ url
|
|
|
|
|
+ })
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ this.$ownerInstance.callMethod('onAppPreviewReady', {
|
|
|
|
|
+ name: payload.name,
|
|
|
|
|
+ status: 'fail',
|
|
|
|
|
+ message: '预览失败'
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ uploadHandle(payload) {
|
|
|
|
|
+ if (!payload || !payload.t || !payload.fileName) return
|
|
|
|
|
+ const fileEntry = this.files.get(payload.fileName)
|
|
|
|
|
+ const file = fileEntry && fileEntry.size != null && !fileEntry.slice ? fileEntry : (fileEntry && fileEntry.file) || fileEntry
|
|
|
|
|
+ if (!file) {
|
|
|
|
|
+ this.$ownerInstance.callMethod('onAppUploadDone', {
|
|
|
|
|
+ name: payload.fileName,
|
|
|
|
|
+ status: 'fail',
|
|
|
|
|
+ message: '文件未找到'
|
|
|
|
|
+ })
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const run = async () => {
|
|
|
|
|
+ const form = new FormData()
|
|
|
|
|
+ const formData = payload.formData || {}
|
|
|
|
|
+ Object.keys(formData).forEach((k) => {
|
|
|
|
|
+ form.append(k, formData[k])
|
|
|
|
|
+ })
|
|
|
|
|
+ const fieldName = payload.name || 'file'
|
|
|
|
|
+ if (payload.toBase) {
|
|
|
|
|
+ const base64 = await this.fileToBase(payload.fileName)
|
|
|
|
|
+ form.append(fieldName, base64)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ form.append(fieldName, file, payload.fileName)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const xhr = new XMLHttpRequest()
|
|
|
|
|
+ this.xmls.set(payload.fileName, xhr)
|
|
|
|
|
+ xhr.upload.onprogress = (event) => {
|
|
|
|
|
+ if (!event.lengthComputable) return
|
|
|
|
|
+ const progress = Math.ceil((event.loaded * 100) / event.total)
|
|
|
|
|
+ this.$ownerInstance.callMethod('onAppUploadProgress', {
|
|
|
|
|
+ name: payload.fileName,
|
|
|
|
|
+ progress,
|
|
|
|
|
+ status: 'onprogress'
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ xhr.onerror = () => {
|
|
|
|
|
+ this.xmls.delete(payload.fileName)
|
|
|
|
|
+ this.$ownerInstance.callMethod('onAppUploadDone', {
|
|
|
|
|
+ name: payload.fileName,
|
|
|
|
|
+ status: 'fail',
|
|
|
|
|
+ message: '网络错误'
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ xhr.ontimeout = () => {
|
|
|
|
|
+ this.xmls.delete(payload.fileName)
|
|
|
|
|
+ this.$ownerInstance.callMethod('onAppUploadDone', {
|
|
|
|
|
+ name: payload.fileName,
|
|
|
|
|
+ status: 'fail',
|
|
|
|
|
+ message: '请求超时'
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ xhr.onabort = () => {
|
|
|
|
|
+ this.xmls.delete(payload.fileName)
|
|
|
|
|
+ }
|
|
|
|
|
+ xhr.onreadystatechange = () => {
|
|
|
|
|
+ if (xhr.readyState !== 4) return
|
|
|
|
|
+ this.xmls.delete(payload.fileName)
|
|
|
|
|
+ if (xhr.status === 0) return
|
|
|
|
|
+ if (xhr.status >= 200 && xhr.status < 300) {
|
|
|
|
|
+ this.$ownerInstance.callMethod('onAppUploadDone', {
|
|
|
|
|
+ name: payload.fileName,
|
|
|
|
|
+ status: 'success',
|
|
|
|
|
+ message: '上传完成',
|
|
|
|
|
+ result: xhr.responseText
|
|
|
|
|
+ })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this.$ownerInstance.callMethod('onAppUploadDone', {
|
|
|
|
|
+ name: payload.fileName,
|
|
|
|
|
+ status: 'fail',
|
|
|
|
|
+ message: '上传失败 HTTP ' + xhr.status,
|
|
|
|
|
+ result: xhr.status
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ const method = (payload.method || 'POST').toString().toUpperCase()
|
|
|
|
|
+ xhr.open(method, payload.url, true)
|
|
|
|
|
+ // 跨域带 cookie(需服务端 Access-Control-Allow-Credentials)
|
|
|
|
|
+ xhr.withCredentials = !!payload.withCredentials
|
|
|
|
|
+ const header = payload.header || {}
|
|
|
|
|
+ Object.keys(header).forEach((k) => {
|
|
|
|
|
+ xhr.setRequestHeader(k, header[k])
|
|
|
|
|
+ })
|
|
|
|
|
+ if (this.isContentTypeJson(header)) {
|
|
|
|
|
+ const formObject = {}
|
|
|
|
|
+ if (form.entries) {
|
|
|
|
|
+ for (const pair of form.entries()) {
|
|
|
|
|
+ formObject[pair[0]] = pair[1]
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ xhr.send(JSON.stringify(formObject))
|
|
|
|
|
+ } else {
|
|
|
|
|
+ xhr.send(form)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ run().catch((err) => {
|
|
|
|
|
+ this.$ownerInstance.callMethod('onAppUploadDone', {
|
|
|
|
|
+ name: payload.fileName,
|
|
|
|
|
+ status: 'fail',
|
|
|
|
|
+ message: (err && err.message) || '上传失败'
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ isContentTypeJson(header) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const contentType = header && (header['Content-Type'] || header['content-type'])
|
|
|
|
|
+ return contentType && String(contentType).indexOf('application/json') !== -1
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ resolveFile(name) {
|
|
|
|
|
+ const entry = this.files.get(name)
|
|
|
|
|
+ if (!entry) return null
|
|
|
|
|
+ // 直接存 File,或 { file, tempFilePath, base64 }
|
|
|
|
|
+ if (entry.slice && entry.size != null) return entry
|
|
|
|
|
+ return entry.file || entry
|
|
|
|
|
+ },
|
|
|
|
|
+ fileToBase(name) {
|
|
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
|
|
+ const entry = this.files.get(name)
|
|
|
|
|
+ if (!entry) return reject(new Error('文件未找到'))
|
|
|
|
|
+ if (entry.base64) {
|
|
|
|
|
+ return resolve(String(entry.base64).replace(/[\r\n]/g, ''))
|
|
|
|
|
+ }
|
|
|
|
|
+ const file = this.resolveFile(name)
|
|
|
|
|
+ if (!file) return reject(new Error('文件未找到'))
|
|
|
|
|
+ const reader = new FileReader()
|
|
|
|
|
+ reader.onload = (e) => {
|
|
|
|
|
+ let base64 = e.target.result
|
|
|
|
|
+ if (entry && typeof entry === 'object' && !entry.slice) {
|
|
|
|
|
+ entry.base64 = base64
|
|
|
|
|
+ this.files.set(name, entry)
|
|
|
|
|
+ }
|
|
|
|
|
+ resolve(String(base64).replace(/[\r\n]/g, ''))
|
|
|
|
|
+ }
|
|
|
|
|
+ reader.onerror = (e) => reject(e)
|
|
|
|
|
+ reader.readAsDataURL(file)
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ sliceToBase64(slice) {
|
|
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
|
|
+ const reader = new FileReader()
|
|
|
|
|
+ reader.onload = (e) => {
|
|
|
|
|
+ resolve(e.target.result.split(',')[1])
|
|
|
|
|
+ }
|
|
|
|
|
+ reader.onerror = (e) => reject(e)
|
|
|
|
|
+ reader.readAsDataURL(slice)
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ async readHandle(payload) {
|
|
|
|
|
+ if (!payload || !payload.t || !payload.name) return
|
|
|
|
|
+ const name = payload.name
|
|
|
|
|
+ const action = payload.action
|
|
|
|
|
+ if (action === 'asyncFileTempFilePath') {
|
|
|
|
|
+ const entry = this.files.get(name)
|
|
|
|
|
+ if (entry && typeof entry === 'object' && !entry.slice) {
|
|
|
|
|
+ entry.tempFilePath = payload.tempFilePath
|
|
|
|
|
+ this.files.set(name, entry)
|
|
|
|
|
+ } else if (entry) {
|
|
|
|
|
+ this.files.set(name, { file: entry, tempFilePath: payload.tempFilePath })
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const file = this.resolveFile(name)
|
|
|
|
|
+ if (!file) {
|
|
|
|
|
+ this.$ownerInstance.callMethod('onAppReadDone', {
|
|
|
|
|
+ name,
|
|
|
|
|
+ status: 'fail',
|
|
|
|
|
+ message: '文件未找到'
|
|
|
|
|
+ })
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (action === 'getTempFilePath') {
|
|
|
|
|
+ const entry = this.files.get(name)
|
|
|
|
|
+ const cached =
|
|
|
|
|
+ entry && entry.tempFilePath
|
|
|
|
|
+ ? entry.tempFilePath
|
|
|
|
|
+ : null
|
|
|
|
|
+ if (cached) {
|
|
|
|
|
+ this.$ownerInstance.callMethod('onAppReadDone', {
|
|
|
|
|
+ name,
|
|
|
|
|
+ status: 'success',
|
|
|
|
|
+ result: { tempFilePath: cached }
|
|
|
|
|
+ })
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ const chunkSize = 5 * 1024 * 1024
|
|
|
|
|
+ const totalSize = file.size
|
|
|
|
|
+ const totalChunks = Math.ceil(totalSize / chunkSize) || 1
|
|
|
|
|
+ const fileType = file.type || ''
|
|
|
|
|
+ for (let i = 0; i < totalChunks; i++) {
|
|
|
|
|
+ const start = i * chunkSize
|
|
|
|
|
+ const end = Math.min(start + chunkSize, totalSize)
|
|
|
|
|
+ const chunk = file.slice(start, end)
|
|
|
|
|
+ const base64Chunk = await this.sliceToBase64(chunk)
|
|
|
|
|
+ this.$ownerInstance.callMethod('onAppReadDone', {
|
|
|
|
|
+ name,
|
|
|
|
|
+ fileType,
|
|
|
|
|
+ status: 'success',
|
|
|
|
|
+ result: {
|
|
|
|
|
+ chunk: base64Chunk,
|
|
|
|
|
+ index: i,
|
|
|
|
|
+ totalChunks
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ this.$ownerInstance.callMethod('onAppReadDone', {
|
|
|
|
|
+ name,
|
|
|
|
|
+ status: 'fail',
|
|
|
|
|
+ message: (error && error.message) || '读取失败'
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+</script>
|
|
|
|
|
+<!-- #endif -->
|
|
|
|
|
+
|
|
|
|
|
+<style scoped>
|
|
|
|
|
+ .kex-fu {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu--disabled {
|
|
|
|
|
+ opacity: 0.6;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu--headless {
|
|
|
|
|
+ width: 0;
|
|
|
|
|
+ height: 0;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ opacity: 0;
|
|
|
|
|
+ position: fixed;
|
|
|
|
|
+ left: -9999px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu__item {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: row;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ padding: 20rpx 0;
|
|
|
|
|
+ border-bottom: 1px solid #f0f0f0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu__thumb {
|
|
|
|
|
+ width: 72rpx;
|
|
|
|
|
+ height: 72rpx;
|
|
|
|
|
+ border-radius: 12rpx;
|
|
|
|
|
+ margin-right: 20rpx;
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+ background: #f5f6f8;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu__icon {
|
|
|
|
|
+ width: 72rpx;
|
|
|
|
|
+ height: 72rpx;
|
|
|
|
|
+ border-radius: 12rpx;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ margin-right: 20rpx;
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+ background: #eef2ff;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu__icon--image {
|
|
|
|
|
+ background: #e8f5e9;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu__icon--doc {
|
|
|
|
|
+ background: #e3f2fd;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu__icon--video {
|
|
|
|
|
+ background: #fce4ec;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu__icon--audio {
|
|
|
|
|
+ background: #fff3e0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu__icon-text {
|
|
|
|
|
+ font-size: 24rpx;
|
|
|
|
|
+ color: #2979ff;
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu__body {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu__name {
|
|
|
|
|
+ font-size: 28rpx;
|
|
|
|
|
+ color: #303133;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ text-overflow: ellipsis;
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu__meta {
|
|
|
|
|
+ margin-top: 6rpx;
|
|
|
|
|
+ font-size: 22rpx;
|
|
|
|
|
+ color: #909399;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu__fail {
|
|
|
|
|
+ color: #f56c6c;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu__ok {
|
|
|
|
|
+ color: #67c23a;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu__bar {
|
|
|
|
|
+ margin-top: 10rpx;
|
|
|
|
|
+ height: 6rpx;
|
|
|
|
|
+ background: #ebeef5;
|
|
|
|
|
+ border-radius: 4rpx;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu__bar-inner {
|
|
|
|
|
+ height: 100%;
|
|
|
|
|
+ background: #2979ff;
|
|
|
|
|
+ border-radius: 4rpx;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu__actions {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: row;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ margin-left: 12rpx;
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu__abort,
|
|
|
|
|
+ .kex-fu__retry {
|
|
|
|
|
+ font-size: 24rpx;
|
|
|
|
|
+ color: #2979ff;
|
|
|
|
|
+ padding: 8rpx 12rpx;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu__abort {
|
|
|
|
|
+ color: #e6a23c;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu__remove {
|
|
|
|
|
+ width: 48rpx;
|
|
|
|
|
+ height: 48rpx;
|
|
|
|
|
+ line-height: 48rpx;
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ font-size: 36rpx;
|
|
|
|
|
+ color: #c0c4cc;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu__add {
|
|
|
|
|
+ margin-top: 16rpx;
|
|
|
|
|
+ padding: 28rpx;
|
|
|
|
|
+ border: 1px dashed #dcdfe6;
|
|
|
|
|
+ border-radius: 12rpx;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: row;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ background: #fafafa;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu__add-icon {
|
|
|
|
|
+ font-size: 36rpx;
|
|
|
|
|
+ color: #2979ff;
|
|
|
|
|
+ margin-right: 8rpx;
|
|
|
|
|
+ line-height: 1;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu__add-text {
|
|
|
|
|
+ font-size: 28rpx;
|
|
|
|
|
+ color: #606266;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu__add-count {
|
|
|
|
|
+ margin-left: 12rpx;
|
|
|
|
|
+ font-size: 22rpx;
|
|
|
|
|
+ color: #909399;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu__render {
|
|
|
|
|
+ position: fixed;
|
|
|
|
|
+ left: -9999px;
|
|
|
|
|
+ width: 0;
|
|
|
|
|
+ height: 0;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ opacity: 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu__preview {
|
|
|
|
|
+ position: fixed;
|
|
|
|
|
+ left: 0;
|
|
|
|
|
+ right: 0;
|
|
|
|
|
+ top: 0;
|
|
|
|
|
+ bottom: 0;
|
|
|
|
|
+ z-index: 99999;
|
|
|
|
|
+ background: rgba(0, 0, 0, 0.92);
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu__preview-img {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ height: 80%;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .kex-fu__preview-close {
|
|
|
|
|
+ margin-top: 24rpx;
|
|
|
|
|
+ font-size: 26rpx;
|
|
|
|
|
+ color: rgba(255, 255, 255, 0.75);
|
|
|
|
|
+ }
|
|
|
|
|
+</style>
|