Files
UI/packages/skin/ui/src/upload.ts
T
2026-10-26 16:20:30 +08:00

121 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<File, string>();
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);
}