diff --git a/.ua/backlog.json b/.ua/backlog.json index 243130b..208adb8 100644 --- a/.ua/backlog.json +++ b/.ua/backlog.json @@ -1012,6 +1012,14 @@ "acceptance": "只有当前行 tabindex=0 其余 -1;焦点落到哪行停点就跟到哪行(focusin 同步),下次 Tab 直接回到该行;↑↓/Home/End 移动后停点随焦点迁移;初始停点为首个未禁用根节点;tree ≤ 200 行;npm run check 全绿", "notes": "自补:无障碍;keyOf 抽出复用,去掉 keydown 里的内联查找", "status": "done" + }, + { + "id": "g82", + "title": "文件选择补类型/体积/数量校验与拒收提示", + "motivation": "accept 只设到原生 input 上,拖拽进来的文件完全绕过校验;也没有体积与数量上限,超限只能靠调用方在 onChange 里事后过滤,用户看不到为什么文件没进清单(静默丢弃)", + "acceptance": "新增 maxSize(字节)/ maxCount(多选上限)两个可选参数;accept 支持 .ext / type/subtype / type/* 且对拖拽同样生效;被拒文件不进清单并在 role=status 提示行写明原因(类型不符 / 超上限 / 数量已满);不改onChange 语义;文档与演示同步;npm run check 全绿", + "notes": "自补:可用性;校验入口收敛到 add(),点选与拖拽共用同一条路径", + "status": "open" } ] } diff --git a/.ua/index.json b/.ua/index.json index b3098c5..8e5cfd9 100644 --- a/.ua/index.json +++ b/.ua/index.json @@ -1,6 +1,6 @@ { "$schema": "ui-arch-registry-index/0.1", - "generatedAt": "2026-10-26T04:57:50.936Z", + "generatedAt": "2026-10-26T05:00:02.358Z", "repoRoot": ".", "packages": [ { @@ -2118,6 +2118,16 @@ "t": "string", "d": "提示文案;缺省「点击或拖拽文件到此处」" }, + { + "n": "maxSize", + "t": "number", + "d": "单文件体积上限(字节);超限丢弃并在提示行说明,缺省不限" + }, + { + "n": "maxCount", + "t": "number", + "d": "multiple 时的文件数上限;超出部分丢弃并提示,缺省不限" + }, { "n": "disabled", "t": "boolean", @@ -2132,11 +2142,10 @@ ], "example": "upload({ multiple: true, accept: 'image/*', hint: '拖拽图片到此处', onChange: (files) => console.log(files.map((f) => f.name)) })", "steps": [ - "组件只做「选文件 + 回传」,onChange 里拿 File[] 自行决定上传接口(fetch/FormData 都行)", - "multiple: true 允许多选,重复的文件(同名同大小)自动跳过;单选模式下新选择覆盖旧文件", - "accept 透传原生 accept 限制可选类型,但仍需在 onChange 里二次校验扩展名", - "键盘:区域可 Tab 聚焦,Enter/空格 打开选择器;拖拽时区域高亮,拖出恢复", - "disabled 时不可点选、不接受拖拽;已选清单的 × 会触发 onChange 回传剩余文件" + "把 upload 放进表单,accept 限定类型、multiple 允许多选", + "需要拦体积就给 maxSize(字节),限制数量给 maxCount", + "被拒的文件不会进清单,原因显示在下方提示行(拖拽与点选走同一套校验)", + "onChange 拿到的 File[] 是副本,自行发请求上传(组件不内置网络层)" ] }, { diff --git a/apps/demo/main.ts b/apps/demo/main.ts index c358fa6..9acd6b9 100644 --- a/apps/demo/main.ts +++ b/apps/demo/main.ts @@ -730,10 +730,10 @@ const content = h( h( 'div', { class: 'row' }, - upload({ multiple: true, hint: '点击或拖拽多个文件到此处', onChange: (fs2) => pickedFiles(fs2.map((f) => f.name).join('、')) }), + upload({ multiple: true, maxCount: 3, maxSize: 2 * 1024 * 1024, hint: '多选(上限 3 个 / 单个 2 MB)', onChange: (fs2) => pickedFiles(fs2.map((f) => f.name).join('、')) }), h('span', { class: 'out' }, () => (pickedFiles() ? `已选:${pickedFiles()}` : '未选择文件')), ), - h('div', { class: 'row' }, upload({ accept: 'image/*', hint: '仅图片(accept: image/*)' }), upload({ disabled: true, hint: '禁用态' })), + h('div', { class: 'row' }, upload({ accept: 'image/*', hint: '仅图片(accept: image/*,拖拽同样拦)' }), upload({ disabled: true, hint: '禁用态' })), ), section( 'sec-notification', diff --git a/llms-full.txt b/llms-full.txt index 3fc6960..1c44ae9 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @ui-arch 组件库 Agent 文档(无图形版) -> 自动生成于 2026-10-26T04:57:50.936Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 +> 自动生成于 2026-10-26T05:00:02.358Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 > 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。 ## 快速上手 @@ -1158,6 +1158,8 @@ const sel = signal(''); tree({ nodes: [{ key: 'src', label: 'src', children: [{ - accept:string —— 接受的类型(同原生 accept),如 'image/*,.pdf' - multiple:boolean —— 允许多选;缺省 false(单选时新选择覆盖旧的) - hint:string —— 提示文案;缺省「点击或拖拽文件到此处」 +- maxSize:number —— 单文件体积上限(字节);超限丢弃并在提示行说明,缺省不限 +- maxCount:number —— multiple 时的文件数上限;超出部分丢弃并提示,缺省不限 - disabled:boolean —— 禁用:不可点选、不接受拖拽 - onChange(回调):(files: File[]) => void —— 文件变化回调:选择/拖拽/移除后都触发,回传副本 @@ -1169,11 +1171,10 @@ upload({ multiple: true, accept: 'image/*', hint: '拖拽图片到此处', onCha 手把手: -1. 组件只做「选文件 + 回传」,onChange 里拿 File[] 自行决定上传接口(fetch/FormData 都行) -2. multiple: true 允许多选,重复的文件(同名同大小)自动跳过;单选模式下新选择覆盖旧文件 -3. accept 透传原生 accept 限制可选类型,但仍需在 onChange 里二次校验扩展名 -4. 键盘:区域可 Tab 聚焦,Enter/空格 打开选择器;拖拽时区域高亮,拖出恢复 -5. disabled 时不可点选、不接受拖拽;已选清单的 × 会触发 onChange 回传剩余文件 +1. 把 upload 放进表单,accept 限定类型、multiple 允许多选 +2. 需要拦体积就给 maxSize(字节),限制数量给 maxCount +3. 被拒的文件不会进清单,原因显示在下方提示行(拖拽与点选走同一套校验) +4. onChange 拿到的 File[] 是副本,自行发请求上传(组件不内置网络层) 现场演示:apps/docs/demo.html#sec-upload diff --git a/packages/skin/ui/src/types.ts b/packages/skin/ui/src/types.ts index cac9eec..45eefd0 100644 --- a/packages/skin/ui/src/types.ts +++ b/packages/skin/ui/src/types.ts @@ -458,6 +458,8 @@ export interface UploadProps { accept?: string; // 接受的类型(同原生 accept),如 'image/*,.pdf' multiple?: boolean; // 允许多选;缺省 false(单选覆盖) hint?: string; // 提示文案;缺省「点击或拖拽文件到此处」 + maxSize?: number; // 单文件体积上限(字节);超限丢弃并在提示行说明,缺省不限 + maxCount?: number; // multiple 时的文件数上限;超出部分丢弃并提示,缺省不限 disabled?: boolean; // 禁用:不可点选、不接受拖拽 onChange?: (files: File[]) => void; // 文件变化回调(选择/拖拽/移除后触发,传副本) } diff --git a/packages/skin/ui/src/upload.ts b/packages/skin/ui/src/upload.ts index 574a88d..db8cf9a 100644 --- a/packages/skin/ui/src/upload.ts +++ b/packages/skin/ui/src/upload.ts @@ -1,7 +1,7 @@ import { h } from '@ui-arch/frame'; import type { UploadProps } from './types.js'; -/*** 文件选择:点击或拖拽入列,已选清单可逐个移除;只负责选文件并回传,不内置上传请求(网络层交给调用方) ***/ +/*** 文件选择:点击或拖拽入列,已选清单可逐个移除;accept / maxSize / maxCount 对拖拽同样生效,被拒文件在提示行说明原因;不内置上传请求(网络层交给调用方) ***/ export function upload(props: UploadProps): HTMLElement { // —— 状态集中声明 —— const files: File[] = []; // 已选文件(顺序即选择顺序) @@ -9,11 +9,27 @@ export function upload(props: UploadProps): HTMLElement { 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); @@ -21,7 +37,7 @@ export function upload(props: UploadProps): HTMLElement { }; /*** 重绘清单并回传(选择/拖拽/移除都走这里,保证单一出口) ***/ - const sync = () => { + const sync = (tip = '') => { listEl.replaceChildren( ...files.map((file, i) => h( @@ -33,18 +49,24 @@ export function upload(props: UploadProps): HTMLElement { ), ), ); + tipEl.textContent = tip; // 本轮拒收原因(空串即无) + tipEl.hidden = !tip; props.onChange?.(files.slice()); // 回传副本,避免调用方改动内部数组 }; /*** 追加文件:单选模式直接替换,多选模式追加并去重(同名同大小视为同一个) ***/ const add = (picked: FileList | File[]) => { + let tip = ''; // 本轮第一条拒收原因,覆盖式提示(避免刷屏) 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); // 单选:只留最新一个 + 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(); + sync(tip); }; const zone = h( @@ -83,5 +105,5 @@ export function upload(props: UploadProps): HTMLElement { input.value = ''; // 清空以便重复选择同一文件也能触发 change }); - return h('div', { class: 'ua-upload' }, zone, listEl); + return h('div', { class: 'ua-upload' }, zone, listEl, tipEl); } diff --git a/packages/skin/ui/ua.manifest.json b/packages/skin/ui/ua.manifest.json index 5e136de..d75a69a 100644 --- a/packages/skin/ui/ua.manifest.json +++ b/packages/skin/ui/ua.manifest.json @@ -1989,6 +1989,16 @@ "t": "string", "d": "提示文案;缺省「点击或拖拽文件到此处」" }, + { + "n": "maxSize", + "t": "number", + "d": "单文件体积上限(字节);超限丢弃并在提示行说明,缺省不限" + }, + { + "n": "maxCount", + "t": "number", + "d": "multiple 时的文件数上限;超出部分丢弃并提示,缺省不限" + }, { "n": "disabled", "t": "boolean", @@ -2003,11 +2013,10 @@ ], "example": "upload({ multiple: true, accept: 'image/*', hint: '拖拽图片到此处', onChange: (files) => console.log(files.map((f) => f.name)) })", "steps": [ - "组件只做「选文件 + 回传」,onChange 里拿 File[] 自行决定上传接口(fetch/FormData 都行)", - "multiple: true 允许多选,重复的文件(同名同大小)自动跳过;单选模式下新选择覆盖旧文件", - "accept 透传原生 accept 限制可选类型,但仍需在 onChange 里二次校验扩展名", - "键盘:区域可 Tab 聚焦,Enter/空格 打开选择器;拖拽时区域高亮,拖出恢复", - "disabled 时不可点选、不接受拖拽;已选清单的 × 会触发 onChange 回传剩余文件" + "把 upload 放进表单,accept 限定类型、multiple 允许多选", + "需要拦体积就给 maxSize(字节),限制数量给 maxCount", + "被拒的文件不会进清单,原因显示在下方提示行(拖拽与点选走同一套校验)", + "onChange 拿到的 File[] 是副本,自行发请求上传(组件不内置网络层)" ] }, { diff --git a/packages/skin/ui/ui.css b/packages/skin/ui/ui.css index 7635108..5f3d7a6 100644 --- a/packages/skin/ui/ui.css +++ b/packages/skin/ui/ui.css @@ -2317,6 +2317,11 @@ button.ua-step-dot:hover { } /* —— 文件选择 —— */ +.ua-upload-tip { + margin: 0; + font-size: 12px; + color: var(--ua-color-danger); +} .ua-upload { display: flex; flex-direction: column;