import { h } from '@ui-arch/frame'; import type { UploadProps } from './types.js'; /*** 文件选择:点击或拖拽入列,已选清单可逐个移除;accept / maxSize / maxCount 对拖拽同样生效,被拒文件在提示行说明原因;不内置上传请求(网络层交给调用方) ***/ export function upload(props: UploadProps): HTMLElement { // —— 状态集中声明 —— const files: File[] = []; // 已选文件(顺序即选择顺序) const input = h('input', { type: 'file', class: 'ua-upload-input' }) as HTMLInputElement; // 隐藏的原生选择器 if (props.accept) input.accept = props.accept; if (props.multiple) input.multiple = true; const listEl = h('div', { class: 'ua-upload-list' }) as HTMLElement; // 已选文件清单 const tipEl = h('p', { class: 'ua-upload-tip', role: 'status' }) as HTMLElement; // 拒收原因播报行(类型/体积/数量超限) tipEl.hidden = true; /*** 按字节数换算可读体积 ***/ const size = (bytes: number) => bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1048576).toFixed(1)} MB`; /*** 是否命中 accept 规则:支持 .ext / type/subtype / type/* 三种写法,缺省放行 ***/ const accepts = (file: File): boolean => { const rule = props.accept; if (!rule) return true; // 卫语句:未限制类型 const name = file.name.toLowerCase(); return rule.split(',').some((raw) => { const t = raw.trim().toLowerCase(); if (!t) return false; // 卫语句:空规则跳过 if (t.startsWith('.')) return name.endsWith(t); // 扩展名形式 if (t.endsWith('/*')) return file.type.startsWith(t.slice(0, -1)); // 大类型通配 return file.type === t; // 完整 MIME }); }; /*** 移除指定下标的文件 ***/ const remove = (i: number) => { files.splice(i, 1); sync(); }; // —— 图片缩略图:对象 URL 按 File 缓存,文件离场即释放(否则整页存活期都在泄漏) —— const urls = new Map(); const thumbOf = (file: File): HTMLElement | null => { if (!file.type.startsWith('image/')) return null; // 卫语句:非图片不缩略,只给文件名 let url = urls.get(file); if (!url) { url = URL.createObjectURL(file); urls.set(file, url); } return h('img', { class: 'ua-upload-thumb', src: url, alt: '', loading: 'lazy' }); }; /*** 重绘清单并回传(选择/拖拽/移除都走这里,保证单一出口) ***/ const sync = (tip = '') => { for (const [file, url] of urls) if (!files.includes(file)) { URL.revokeObjectURL(url); urls.delete(file); } // 已离场的文件先释放 listEl.replaceChildren( ...files.map((file, i) => h( 'div', { class: 'ua-upload-item' }, thumbOf(file), h('span', { class: 'ua-upload-name' }, file.name), h('span', { class: 'ua-upload-size' }, size(file.size)), h('button', { type: 'button', class: 'ua-upload-remove', 'aria-label': `移除 ${file.name}`, onclick: () => remove(i) }, '×'), ), ), ); tipEl.textContent = tip; // 本轮拒收原因(空串即无) tipEl.hidden = !tip; props.onChange?.(files.slice()); // 回传副本,避免调用方改动内部数组 }; /*** 追加文件:单选模式直接替换,多选模式追加并去重(同名同大小视为同一个) ***/ const add = (picked: FileList | File[]) => { let tip = ''; // 本轮第一条拒收原因,覆盖式提示(避免刷屏) for (const file of Array.from(picked)) { if (!accepts(file)) { tip = `「${file.name}」类型不符:${props.accept}`; continue; } if (props.maxSize && file.size > props.maxSize) { tip = `「${file.name}」超过 ${size(props.maxSize)} 上限`; continue; } if (files.some((f) => f.name === file.name && f.size === file.size)) continue; // 卫语句:重复文件跳过 if (props.multiple) { if (props.maxCount && files.length >= props.maxCount) { tip = `最多选 ${props.maxCount} 个文件`; continue; } files.push(file); } else files.splice(0, files.length, file); // 单选:只留最新一个 } sync(tip); }; const zone = h( 'div', { class: props.disabled ? 'ua-upload-zone ua-upload-disabled' : 'ua-upload-zone', tabindex: props.disabled ? '-1' : '0', role: 'button', 'aria-label': '选择文件', onclick: () => { if (!props.disabled) input.click(); }, onkeydown: (e: KeyboardEvent) => { if (props.disabled) return; // 卫语句:禁用不响应 if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); input.click(); } // 键盘等效点击 }, ondragover: (e: Event) => { if (props.disabled) return; // 卫语句:禁用不接受拖拽 e.preventDefault(); (e.currentTarget as HTMLElement).classList.add('ua-upload-over'); }, ondragleave: (e: Event) => (e.currentTarget as HTMLElement).classList.remove('ua-upload-over'), ondrop: (e: Event) => { const ev = e as DragEvent; ev.preventDefault(); (ev.currentTarget as HTMLElement).classList.remove('ua-upload-over'); if (props.disabled || !ev.dataTransfer?.files?.length) return; // 卫语句:无文件或已禁用 add(ev.dataTransfer.files); }, }, h('span', { class: 'ua-upload-icon', 'aria-hidden': 'true' }, '⤒'), h('span', { class: 'ua-upload-hint' }, props.hint ?? '点击或拖拽文件到此处'), input, ); input.addEventListener('change', () => { if (input.files?.length) add(input.files); input.value = ''; // 清空以便重复选择同一文件也能触发 change }); return h('div', { class: 'ua-upload' }, zone, listEl, tipEl); }