iter(g33): 新增 upload 文件选择组件

This commit is contained in:
HIsheR
2026-10-26 11:44:34 +08:00
parent 052c215609
commit f20e977fdf
13 changed files with 327 additions and 9 deletions
+1
View File
@@ -34,6 +34,7 @@ export { divider } from './divider.js';
export { list } from './list.js';
export { carousel } from './carousel.js';
export { tree } from './tree.js';
export { upload } from './upload.js';
export { descriptions } from './descriptions.js';
export { statistic } from './statistic.js';
export { timeline } from './timeline.js';
+9
View File
@@ -420,6 +420,15 @@ export interface ListProps {
onItemClick?: (item: ListItem, index: number) => void; // 条目点击回调;提供后条目可点击并带 hover 反馈
}
/*** 文件选择入参 ***/
export interface UploadProps {
accept?: string; // 接受的类型(同原生 accept),如 'image/*,.pdf'
multiple?: boolean; // 允许多选;缺省 false(单选覆盖)
hint?: string; // 提示文案;缺省「点击或拖拽文件到此处」
disabled?: boolean; // 禁用:不可点选、不接受拖拽
onChange?: (files: File[]) => void; // 文件变化回调(选择/拖拽/移除后触发,传副本)
}
/*** 树节点 ***/
export interface TreeNode {
key: string; // 唯一键(选中/展开都按 key 记)
+87
View File
@@ -0,0 +1,87 @@
import { h } from '@ui-arch/frame';
import type { UploadProps } from './types.js';
/*** 文件选择:点击或拖拽入列,已选清单可逐个移除;只负责选文件并回传,不内置上传请求(网络层交给调用方) ***/
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 size = (bytes: number) =>
bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1048576).toFixed(1)} MB`;
/*** 移除指定下标的文件 ***/
const remove = (i: number) => {
files.splice(i, 1);
sync();
};
/*** 重绘清单并回传(选择/拖拽/移除都走这里,保证单一出口) ***/
const sync = () => {
listEl.replaceChildren(
...files.map((file, i) =>
h(
'div',
{ class: 'ua-upload-item' },
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) }, '×'),
),
),
);
props.onChange?.(files.slice()); // 回传副本,避免调用方改动内部数组
};
/*** 追加文件:单选模式直接替换,多选模式追加并去重(同名同大小视为同一个) ***/
const add = (picked: FileList | File[]) => {
for (const file of Array.from(picked)) {
const dup = files.some((f) => f.name === file.name && f.size === file.size);
if (dup) continue; // 卫语句:重复文件跳过
if (props.multiple) files.push(file);
else files.splice(0, files.length, file); // 单选:只留最新一个
}
sync();
};
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);
}