<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
				const systemInfo = uni.getSystemInfoSync();
				const isIOS = systemInfo.platform === 'ios';
				
				if (isIOS) {
				    // iOS 强制走 uni.uploadFile（需要 path）
				    if (item.path) {
				        // 已有路径，直接上传
				        return this.uploadWithUni(index, item);
				    } else if (item.appKey) {
				        // 有 appKey 但无 path，先获取临时路径
				        return this.getTempFilePathAsync(item)
				            .then((tempPath) => {
				                item.path = tempPath;   // 更新 item
				                return this.uploadWithUni(index, item);
				            })
				            .catch((err) => {
				                item.status = 'fail';
				                item.message = '获取文件路径失败';
				                this.$emit('uploadFail', { index, file: this.toPublicFile(item), err });
				                return Promise.reject(err);
				            });
				    } else {
				        // 无 appKey 也无 path
				        item.status = 'fail';
				        item.message = '文件无有效路径';
				        this.$emit('uploadFail', { index, file: this.toPublicFile(item), err: item.message });
				        return Promise.reject(item.message);
				    }
				} else {
				    // Android 保持原有逻辑（走 renderjs XHR）
				    if (item.appKey) {
				        return this.uploadOnApp(index, item);
				    }
				}
				// #endif
				return this.uploadWithUni(index, item)
			},
			getTempFilePathAsync(file) {
			    return new Promise((resolve, reject) => {
			        this.getTempFilePath({
			            file,
			            success: (res) => resolve(res.result),
			            fail: (err) => reject(err)
			        });
			    });
			},
			uploadWithUni(index, item) {
				const filePath = item.path || item.url || item.thumb
				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) => {
							console.log(filePath,res,99999)
							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>
