From f20e977fdf47591241929b03741157705b329821 Mon Sep 17 00:00:00 2001 From: HIsheR Date: Mon, 26 Oct 2026 11:44:34 +0800 Subject: [PATCH] =?UTF-8?q?iter(g33):=20=E6=96=B0=E5=A2=9E=20upload=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E9=80=89=E6=8B=A9=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ua/backlog.json | 8 +++ .ua/index.json | 46 +++++++++++++++- AGENTS.md | 2 +- ARCHITECTURE.md | 2 +- README.md | 6 +-- apps/demo/main.ts | 14 +++++ llms-full.txt | 29 ++++++++++- llms.txt | 1 + packages/skin/ui/src/index.ts | 1 + packages/skin/ui/src/types.ts | 9 ++++ packages/skin/ui/src/upload.ts | 87 +++++++++++++++++++++++++++++++ packages/skin/ui/ua.manifest.json | 44 +++++++++++++++- packages/skin/ui/ui.css | 87 +++++++++++++++++++++++++++++++ 13 files changed, 327 insertions(+), 9 deletions(-) create mode 100644 packages/skin/ui/src/upload.ts diff --git a/.ua/backlog.json b/.ua/backlog.json index 04d9871..df2ab7e 100644 --- a/.ua/backlog.json +++ b/.ua/backlog.json @@ -620,6 +620,14 @@ "acceptance": "↑↓ 在当前可见节点间移动、Home/End 到首末行,只改焦点不改选中,收起分支的子节点不参与遍历;npm run check 全绿", "status": "done", "notes": "自补:既有组件可用性细节;NodeList 需 Array.from 展开(downlevel 迭代)" + }, + { + "id": "g33", + "title": "新增 upload 文件选择组件", + "motivation": "表单里选附件只能裸写 input[type=file],样式脱节、无拖拽、无已选清单,也没有统一的 onChange 出口", + "acceptance": "upload({ accept, multiple, hint, disabled, onChange }) 支持点击/拖拽/键盘选择、清单逐个移除、同名同大小去重、disabled 全禁;组件数 41→42 三处计数同步;npm run check 全绿", + "status": "open", + "notes": "自补:组件补充" } ] } diff --git a/.ua/index.json b/.ua/index.json index e88a88c..e239a87 100644 --- a/.ua/index.json +++ b/.ua/index.json @@ -1,6 +1,6 @@ { "$schema": "ui-arch-registry-index/0.1", - "generatedAt": "2026-10-26T03:43:04.201Z", + "generatedAt": "2026-10-26T03:44:14.447Z", "repoRoot": ".", "packages": [ { @@ -184,7 +184,8 @@ "statistic(props):统计数值,标题 + 大号数字 + 前后缀 + 环比趋势,value 变化时 rAF 滚动过渡(400ms easeOutCubic),减弱动效偏好下直落终值", "list(props):列表,header/footer 外壳 + 条目 avatar/title/description/extra,onItemClick 让条目可点击,items 为空回落空状态", "carousel(props):轮播,items 横排位移,指示点/箭头/←→ 键切换,autoplay 毫秒自动前进且悬停暂停,index 信号可外部受控", - "tree(props):树形,递归节点 + 箭头展开/收起 + 点选节点,键盘 Enter 选中、→ 展开、← 收起,禁用节点不响应" + "tree(props):树形,递归节点 + 箭头展开/收起 + 点选节点,键盘 Enter 选中、→ 展开、← 收起,禁用节点不响应", + "upload(props):文件选择,点击或拖拽入列,清单可逐个移除,onChange 回传文件数组(不内置上传请求)" ], "keyTypes": [ "ButtonProps", @@ -1904,6 +1905,47 @@ "键盘:↑↓ 在可见节点间移动、Home/End 到首末行(只移动焦点),Enter/空格 选中、→ 展开、← 收起;disabled 节点点击与键盘都不响应", "defaultExpandAll 首屏展开全部分支(外部已给 expanded 初值时以外部为准)" ] + }, + { + "id": "upload", + "title": "文件选择 Upload", + "demo": "sec-upload", + "props": [ + { + "n": "accept", + "t": "string", + "d": "接受的类型(同原生 accept),如 'image/*,.pdf'" + }, + { + "n": "multiple", + "t": "boolean", + "d": "允许多选;缺省 false(单选时新选择覆盖旧的)" + }, + { + "n": "hint", + "t": "string", + "d": "提示文案;缺省「点击或拖拽文件到此处」" + }, + { + "n": "disabled", + "t": "boolean", + "d": "禁用:不可点选、不接受拖拽" + }, + { + "n": "onChange", + "t": "(files: File[]) => void", + "k": 1, + "d": "文件变化回调:选择/拖拽/移除后都触发,回传副本" + } + ], + "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 回传剩余文件" + ] } ] }, diff --git a/AGENTS.md b/AGENTS.md index b61049d..2471e7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ - `packages/core/table-core`(@ui-arch/table-core):Headless 表格逻辑内核 - `packages/core/frame`(@ui-arch/frame):signal + hyperscript 微框架 -- `packages/skin/ui`(@ui-arch/ui):41 个 DOM 组件(表单 / 反馈 / 浮层 / 导航 / 表格),样式只消费令牌 +- `packages/skin/ui`(@ui-arch/ui):42 个 DOM 组件(表单 / 反馈 / 浮层 / 导航 / 表格),样式只消费令牌 - `packages/design/tokens`(@ui-arch/tokens):设计令牌 - `packages/tools/cli`(@ui-arch/cli):`ua` 命令行 + MCP 注册表服务(private) - `apps/demo`(@ui-arch/demo):交互演示,产物 apps/demo/dist/demo.html diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e449d36..f09f6c9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -34,7 +34,7 @@ ui-arch-mvp/ │ │ ├── table-core/ # 表格状态内核 │ │ └── frame/ # 信号 + hyperscript 微框架 │ ├── skin/ # 皮肤层(DOM 组件,可发布) -│ │ └── ui/ # 41 个组件 + ui.css +│ │ └── ui/ # 42 个组件 + ui.css │ ├── design/ # 资产层(可发布) │ │ └── tokens/ # --ua-* 设计令牌 │ └── tools/ # 工程层(private,不发布) diff --git a/README.md b/README.md index 908b36f..eb9748d 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,10 @@ npm run demo # 打开演示:apps/demo/dist/demo.html npm run docs # 生成人类文档站 apps/docs/index.html + Agent 全量文档 llms-full.txt ``` -## 组件(共 41 个) +## 组件(共 42 个) -accordion · alert · avatar · badge · breadcrumb · button · carousel · card · checkbox · dataTable · descriptions · divider · drawer · empty · form · image · inputNumber · list · menu · modal · pagination · popover · progress · radioGroup · rate · segmented · select · skeleton · slider · spinner · statistic · stepper · switchButton · tabs · tag · textField · textarea · timeline · toast · tooltip · tree +accordion · alert · avatar · badge · breadcrumb · button · carousel · card · checkbox · dataTable · descriptions · divider · drawer · empty · form · image · inputNumber · list · menu · modal · pagination · popover · progress · radioGroup · rate · segmented · select · skeleton · slider · spinner · statistic · stepper · switchButton · tabs · tag · textField · textarea · timeline · toast · tooltip · tree · upload 分组:表单(button / textField / inputNumber / textarea / checkbox / switchButton / select / radioGroup / slider / rate / segmented / form / formItem)、反馈(alert / toast / spinner / progress / skeleton / badge / empty / statistic)、浮层(tooltip / popover / modal / drawer / menu)、导航(tabs / breadcrumb / pagination / accordion / stepper)、数据(dataTable / descriptions / tag / avatar / card)、其他(divider / timeline)。 @@ -25,7 +25,7 @@ accordion · alert · avatar · badge · breadcrumb · button · carousel · car |---|---| | `packages/core/frame` | @ui-arch/frame:signal / computed / effect + hyperscript(无虚拟 DOM) | | `packages/core/table-core` | @ui-arch/table-core:headless 表格(filter → sort → paginate 派生管线) | -| `packages/skin/ui` | @ui-arch/ui:41 个 DOM 组件 + ui.css(只消费 `--ua-*` 令牌) | +| `packages/skin/ui` | @ui-arch/ui:42 个 DOM 组件 + ui.css(只消费 `--ua-*` 令牌) | | `packages/design/tokens` | @ui-arch/tokens:设计令牌(暗色只换值不换名) | | `packages/tools/cli` | @ui-arch/cli:`ua` 命令行与 stdio MCP server | | `apps/demo` | 交互演示(esbuild 打包成单文件) | diff --git a/apps/demo/main.ts b/apps/demo/main.ts index 0e4895f..db7ce4e 100644 --- a/apps/demo/main.ts +++ b/apps/demo/main.ts @@ -28,6 +28,7 @@ import { list, carousel, tree, + upload, menu, modal, pagination, @@ -178,6 +179,7 @@ const menuPick = signal(''); const listPick = signal(''); // 列表点击选中的条目标题 const slideIndex = signal(0); // 轮播当前下标(外部受控演示) const treePick = signal('core'); // 树形选中节点 key +const pickedFiles = signal(''); // 文件选择结果(名称串) // —— 禁用态演示:开关信号同时绑定 switch 的 checked 与 checkbox 的 disabled —— const locked = signal(true); @@ -643,6 +645,18 @@ const content = h( h('span', { class: 'out' }, () => (treePick() ? `已选节点:${treePick()}` : '未选中节点')), ), ), + section( + 'sec-upload', + '文件选择', + 'upload:点击或拖拽入列,清单可逐个移除;只负责选文件并回传 onChange,上传请求交给调用方。', + h( + 'div', + { class: 'row' }, + upload({ multiple: true, hint: '点击或拖拽多个文件到此处', 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: '禁用态' })), + ), section( 'sec-menu', '下拉菜单', diff --git a/llms-full.txt b/llms-full.txt index 3f9c435..9ee2ee2 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @ui-arch 组件库 Agent 文档(无图形版) -> 自动生成于 2026-10-26T03:43:04.201Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 +> 自动生成于 2026-10-26T03:44:14.447Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 > 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。 ## 快速上手 @@ -1089,6 +1089,32 @@ const sel = signal(''); tree({ nodes: [{ key: 'src', label: 'src', children: [{ 现场演示:apps/docs/demo.html#sec-tree +### upload — 文件选择 Upload + +参数: + +- accept:string —— 接受的类型(同原生 accept),如 'image/*,.pdf' +- multiple:boolean —— 允许多选;缺省 false(单选时新选择覆盖旧的) +- hint:string —— 提示文案;缺省「点击或拖拽文件到此处」 +- disabled:boolean —— 禁用:不可点选、不接受拖拽 +- onChange(回调):(files: File[]) => void —— 文件变化回调:选择/拖拽/移除后都触发,回传副本 + +示例: + +``` +upload({ multiple: true, accept: 'image/*', hint: '拖拽图片到此处', onChange: (files) => console.log(files.map((f) => f.name)) }) +``` + +手把手: + +1. 组件只做「选文件 + 回传」,onChange 里拿 File[] 自行决定上传接口(fetch/FormData 都行) +2. multiple: true 允许多选,重复的文件(同名同大小)自动跳过;单选模式下新选择覆盖旧文件 +3. accept 透传原生 accept 限制可选类型,但仍需在 onChange 里二次校验扩展名 +4. 键盘:区域可 Tab 聚焦,Enter/空格 打开选择器;拖拽时区域高亮,拖出恢复 +5. disabled 时不可点选、不接受拖拽;已选清单的 × 会触发 onChange 回传剩余文件 + +现场演示:apps/docs/demo.html#sec-upload + API 速览: - button(props):按钮,variant = primary | ghost | danger,disabled 支持布尔或信号禁用 @@ -1134,6 +1160,7 @@ API 速览: - list(props):列表,header/footer 外壳 + 条目 avatar/title/description/extra,onItemClick 让条目可点击,items 为空回落空状态 - carousel(props):轮播,items 横排位移,指示点/箭头/←→ 键切换,autoplay 毫秒自动前进且悬停暂停,index 信号可外部受控 - tree(props):树形,递归节点 + 箭头展开/收起 + 点选节点,键盘 Enter 选中、→ 展开、← 收起,禁用节点不响应 +- upload(props):文件选择,点击或拖拽入列,清单可逐个移除,onChange 回传文件数组(不内置上传请求) 一行用法: diff --git a/llms.txt b/llms.txt index 248b4f3..5612ef9 100644 --- a/llms.txt +++ b/llms.txt @@ -90,6 +90,7 @@ - list(props):列表,header/footer 外壳 + 条目 avatar/title/description/extra,onItemClick 让条目可点击,items 为空回落空状态 - carousel(props):轮播,items 横排位移,指示点/箭头/←→ 键切换,autoplay 毫秒自动前进且悬停暂停,index 信号可外部受控 - tree(props):树形,递归节点 + 箭头展开/收起 + 点选节点,键盘 Enter 选中、→ 展开、← 收起,禁用节点不响应 + - upload(props):文件选择,点击或拖拽入列,清单可逐个移除,onChange 回传文件数组(不内置上传请求) - 用法: `const checked = signal(false); checkbox({ label: '同意', checked }); const open = signal(false); modal({ title: '确认', open }, '内容')` - 约定: 皮肤不持有业务状态,一律委托 headless 内核或调用方信号 - 约定: 表单类组件统一约定:*Props.value/checked/active 为 Signal 双向绑定,onChange/onInput 为回调 diff --git a/packages/skin/ui/src/index.ts b/packages/skin/ui/src/index.ts index 3f2fc6d..dca01ab 100644 --- a/packages/skin/ui/src/index.ts +++ b/packages/skin/ui/src/index.ts @@ -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'; diff --git a/packages/skin/ui/src/types.ts b/packages/skin/ui/src/types.ts index da56506..85a2b15 100644 --- a/packages/skin/ui/src/types.ts +++ b/packages/skin/ui/src/types.ts @@ -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 记) diff --git a/packages/skin/ui/src/upload.ts b/packages/skin/ui/src/upload.ts new file mode 100644 index 0000000..574a88d --- /dev/null +++ b/packages/skin/ui/src/upload.ts @@ -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); +} diff --git a/packages/skin/ui/ua.manifest.json b/packages/skin/ui/ua.manifest.json index 1aa428d..af3e679 100644 --- a/packages/skin/ui/ua.manifest.json +++ b/packages/skin/ui/ua.manifest.json @@ -55,7 +55,8 @@ "statistic(props):统计数值,标题 + 大号数字 + 前后缀 + 环比趋势,value 变化时 rAF 滚动过渡(400ms easeOutCubic),减弱动效偏好下直落终值", "list(props):列表,header/footer 外壳 + 条目 avatar/title/description/extra,onItemClick 让条目可点击,items 为空回落空状态", "carousel(props):轮播,items 横排位移,指示点/箭头/←→ 键切换,autoplay 毫秒自动前进且悬停暂停,index 信号可外部受控", - "tree(props):树形,递归节点 + 箭头展开/收起 + 点选节点,键盘 Enter 选中、→ 展开、← 收起,禁用节点不响应" + "tree(props):树形,递归节点 + 箭头展开/收起 + 点选节点,键盘 Enter 选中、→ 展开、← 收起,禁用节点不响应", + "upload(props):文件选择,点击或拖拽入列,清单可逐个移除,onChange 回传文件数组(不内置上传请求)" ], "keyTypes": [ "ButtonProps", @@ -1775,6 +1776,47 @@ "键盘:↑↓ 在可见节点间移动、Home/End 到首末行(只移动焦点),Enter/空格 选中、→ 展开、← 收起;disabled 节点点击与键盘都不响应", "defaultExpandAll 首屏展开全部分支(外部已给 expanded 初值时以外部为准)" ] + }, + { + "id": "upload", + "title": "文件选择 Upload", + "demo": "sec-upload", + "props": [ + { + "n": "accept", + "t": "string", + "d": "接受的类型(同原生 accept),如 'image/*,.pdf'" + }, + { + "n": "multiple", + "t": "boolean", + "d": "允许多选;缺省 false(单选时新选择覆盖旧的)" + }, + { + "n": "hint", + "t": "string", + "d": "提示文案;缺省「点击或拖拽文件到此处」" + }, + { + "n": "disabled", + "t": "boolean", + "d": "禁用:不可点选、不接受拖拽" + }, + { + "n": "onChange", + "t": "(files: File[]) => void", + "k": 1, + "d": "文件变化回调:选择/拖拽/移除后都触发,回传副本" + } + ], + "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 回传剩余文件" + ] } ] } diff --git a/packages/skin/ui/ui.css b/packages/skin/ui/ui.css index 8d26e86..d8c1341 100644 --- a/packages/skin/ui/ui.css +++ b/packages/skin/ui/ui.css @@ -2195,3 +2195,90 @@ button.ua-step-dot:hover { transition: none; } } + +/* —— 文件选择 —— */ +.ua-upload { + display: flex; + flex-direction: column; + gap: var(--ua-space-2); + width: 100%; + max-width: 360px; +} +.ua-upload-zone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--ua-space-1); + padding: var(--ua-space-4) var(--ua-space-3); + border: 1px dashed var(--ua-color-border); + border-radius: var(--ua-radius-md); + background: var(--ua-color-surface); + color: var(--ua-color-text-muted); + cursor: pointer; + outline: none; + transition: border-color 0.15s ease, background 0.15s ease; +} +.ua-upload-zone:hover, +.ua-upload-over { + border-color: var(--ua-color-accent); + background: color-mix(in srgb, var(--ua-color-accent) 6%, transparent); +} +.ua-upload-zone:focus-visible { + box-shadow: 0 0 0 2px color-mix(in srgb, var(--ua-color-accent) 40%, transparent); +} +.ua-upload-disabled { + cursor: not-allowed; + opacity: 0.6; +} +.ua-upload-icon { + font-size: var(--ua-font-size-lg); + color: var(--ua-color-accent); +} +.ua-upload-hint { + font-size: var(--ua-font-size-sm); +} +.ua-upload-input { + display: none; /* 原生选择器只作为能力载体,界面自绘 */ +} +.ua-upload-list { + display: flex; + flex-direction: column; + gap: var(--ua-space-1); +} +.ua-upload-item { + display: flex; + align-items: center; + gap: var(--ua-space-2); + padding: var(--ua-space-1) var(--ua-space-2); + border: 1px solid var(--ua-color-border); + border-radius: var(--ua-radius-sm); + font-size: var(--ua-font-size-sm); +} +.ua-upload-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--ua-color-text); +} +.ua-upload-size { + color: var(--ua-color-text-muted); +} +.ua-upload-remove { + border: none; + background: none; + color: var(--ua-color-text-muted); + cursor: pointer; + line-height: 1; + padding: 0 2px; +} +.ua-upload-remove:hover { + color: var(--ua-color-danger); +} +@media (prefers-reduced-motion: reduce) { + .ua-upload-zone { + transition: none; + } +}