上传Upload
把手机里的文件交给服务端(实名认证的身份证照、报销的发票、工单的附件)
数据录入 规格 46 · 上传 Upload 6 端实现 触摸优先
<!-- ① 令牌:PC 令牌 + 移动端 --kole-m-* 合成单文件,引一次 -->
<link rel="stylesheet" href="kole-ui/mobile/tokens.css">
<!-- ② 本组件样式(全量则用 kole-ui/mobile/components/index.css) -->
<link rel="stylesheet" href="kole-ui/mobile/components/mobile-upload.css">
<!-- ③ 结构照抄下方任一演示块(类名与 6 端实现一致) -->
演示
每个演示都是真实渲染:预览帧加载 frameworks-mobile/Upload.html?demo=<id>(只显示该演示块),代码是该演示块在演示页里的原文,可复制。全部演示同屏可看 演示页 ↗。
01 组件类型
点一下调起系统文件选择器:移动端没有拖拽,这是唯一入口。
查看代码(演示页原文 · 16 行)
<section class="demo-block" data-demo="trigger">
<p class="demo-label">选择触发(点一下调起系统文件选择器:移动端没有拖拽,这是唯一入口)</p>
<div class="demo-pad" data-assert="upload-trigger">
<div class="kole-m-upload" id="up-trigger" data-count="0" data-max="5">
<div class="kole-m-upload__header">
<span class="kole-m-upload__title">附件</span>
<span class="kole-m-upload__count" id="up-trigger-count">0 / 5</span>
</div>
<input class="kole-m-upload__native" id="up-trigger-input" type="file" multiple tabindex="-1" aria-hidden="true">
<button class="kole-m-upload__trigger" type="button" id="up-trigger-btn"
data-behavior="click-sets-attr:#up-trigger|data-picked|true"
aria-describedby="up-trigger-hint">+ 选择文件</button>
<p class="kole-m-upload__empty" id="up-trigger-hint">支持 jpg / png / pdf,单个不超过 10 MB</p>
</div>
</div>
</section>待上传 / 上传中(60%)/ 成功 / 失败;进度由宿主回传,组件不发请求。
查看代码(演示页原文 · 78 行)
<section class="demo-block" data-demo="states">
<p class="demo-label">四种状态(待上传 / 上传中 60% / 成功 / 失败,均不发真实请求,进度由宿主回传)</p>
<div class="demo-pad" data-assert="upload-states">
<div class="kole-m-upload kole-m-upload--button" id="up-states" data-count="4">
<div class="kole-m-upload__header">
<span class="kole-m-upload__title">上传队列</span>
<span class="kole-m-upload__count">4 个文件</span>
</div>
<ul class="kole-m-upload__list">
<li class="kole-m-upload__item is-pending" data-assert="upload-state-pending">
<span class="kole-m-upload__thumb" aria-hidden="true">pdf</span>
<span class="kole-m-upload__body">
<span class="kole-m-upload__name">合同扫描件.pdf</span>
<span class="kole-m-upload__meta">
<span class="kole-m-upload__track" aria-hidden="true"><span class="kole-m-upload__bar"></span></span>
<span class="kole-m-upload__status">待上传</span>
</span>
</span>
<span class="kole-m-upload__actions">
<button class="kole-m-upload__action kole-m-upload__action--remove" type="button"
aria-label="删除 合同扫描件.pdf" id="up-remove-pending"
data-behavior="click-sets-attr:#up-states|data-removed|pending">删除</button>
</span>
</li>
<li class="kole-m-upload__item is-uploading" style="--kole-m-upload-percent: 60%">
<span class="kole-m-upload__thumb" aria-hidden="true">png</span>
<span class="kole-m-upload__body">
<span class="kole-m-upload__name">产品主图.png</span>
<span class="kole-m-upload__meta">
<span class="kole-m-upload__track" role="progressbar" aria-valuemin="0" aria-valuemax="100"
aria-valuenow="60" aria-label="产品主图.png 上传进度">
<span class="kole-m-upload__bar"></span>
</span>
<span class="kole-m-upload__status">60%</span>
</span>
</span>
<span class="kole-m-upload__actions">
<button class="kole-m-upload__action kole-m-upload__action--remove" type="button"
aria-label="取消 产品主图.png">取消</button>
</span>
</li>
<li class="kole-m-upload__item is-success">
<span class="kole-m-upload__thumb" aria-hidden="true">jpg</span>
<span class="kole-m-upload__body">
<span class="kole-m-upload__name">营业执照.jpg</span>
<span class="kole-m-upload__meta">
<span class="kole-m-upload__track" aria-hidden="true"><span class="kole-m-upload__bar"></span></span>
<span class="kole-m-upload__status">已上传</span>
</span>
</span>
<span class="kole-m-upload__actions">
<button class="kole-m-upload__action kole-m-upload__action--remove" type="button"
aria-label="删除 营业执照.jpg">删除</button>
</span>
</li>
<li class="kole-m-upload__item is-error">
<span class="kole-m-upload__thumb" aria-hidden="true">zip</span>
<span class="kole-m-upload__body">
<span class="kole-m-upload__name">素材包.zip</span>
<span class="kole-m-upload__meta">
<span class="kole-m-upload__track" role="progressbar" aria-valuemin="0" aria-valuemax="100"
aria-valuenow="100" aria-label="素材包.zip 上传失败">
<span class="kole-m-upload__bar"></span>
</span>
<span class="kole-m-upload__status">超出 10 MB</span>
</span>
</span>
<span class="kole-m-upload__actions">
<button class="kole-m-upload__action" type="button" aria-label="重试 素材包.zip" id="up-retry"
data-behavior="click-sets-attr:#up-states|data-retried|zip">重试</button>
<button class="kole-m-upload__action kole-m-upload__action--remove" type="button"
aria-label="删除 素材包.zip">删除</button>
</span>
</li>
</ul>
</div>
</div>
</section>02 组件状态
删除按钮自己 44px 热区;删完后回到空态。
查看代码(演示页原文 · 38 行)
<section class="demo-block" data-demo="removable">
<p class="demo-label">可删除(删除按钮自己 44px 热区;删完后回到空态)</p>
<div class="demo-pad" data-assert="upload-removable">
<div class="kole-m-upload" id="up-removable" data-count="2" data-max="5">
<div class="kole-m-upload__header">
<span class="kole-m-upload__title">发票</span>
<span class="kole-m-upload__count" id="up-removable-count">2 / 5</span>
</div>
<ul class="kole-m-upload__list" id="up-removable-list">
<li class="kole-m-upload__item is-success" data-assert="upload-removable-row">
<span class="kole-m-upload__thumb" aria-hidden="true">jpg</span>
<span class="kole-m-upload__body">
<span class="kole-m-upload__name">发票-09月.jpg</span>
<span class="kole-m-upload__meta"><span class="kole-m-upload__status">已上传</span></span>
</span>
<span class="kole-m-upload__actions">
<button class="kole-m-upload__action kole-m-upload__action--remove" type="button"
aria-label="删除 发票-09月.jpg" data-remove="row"
data-behavior="click-sets-attr:#up-removable|data-removed|row-1">删除</button>
</span>
</li>
<li class="kole-m-upload__item is-success" id="up-removable-row-1">
<span class="kole-m-upload__thumb" aria-hidden="true">pdf</span>
<span class="kole-m-upload__body">
<span class="kole-m-upload__name">对账单-09月.pdf</span>
<span class="kole-m-upload__meta"><span class="kole-m-upload__status">已上传</span></span>
</span>
<span class="kole-m-upload__actions">
<button class="kole-m-upload__action kole-m-upload__action--remove" type="button"
aria-label="删除 对账单-09月.pdf" data-remove="row">删除</button>
</span>
</li>
</ul>
<p class="kole-m-upload__empty" id="up-removable-empty" hidden>还没有上传文件</p>
<button class="kole-m-upload__trigger" type="button">+ 选择文件</button>
</div>
</div>
</section>disabled 时触发器与删除都不可用;到 maxCount 后触发器改成提示行。
查看代码(演示页原文 · 48 行)
<section class="demo-block" data-demo="disabled">
<p class="demo-label">禁用与到顶(disabled:触发器不可点、删除按钮置灰;到 maxCount 触发器改成提示行)</p>
<div class="demo-pad" data-assert="upload-disabled">
<div class="kole-m-upload is-disabled" id="up-disabled" data-count="1" data-max="5">
<div class="kole-m-upload__header">
<span class="kole-m-upload__title">已锁定</span>
<span class="kole-m-upload__count">1 / 5</span>
</div>
<ul class="kole-m-upload__list">
<li class="kole-m-upload__item is-success">
<span class="kole-m-upload__thumb" aria-hidden="true">jpg</span>
<span class="kole-m-upload__body">
<span class="kole-m-upload__name">身份证正面.jpg</span>
<span class="kole-m-upload__meta"><span class="kole-m-upload__status">已上传</span></span>
</span>
<span class="kole-m-upload__actions">
<button class="kole-m-upload__action kole-m-upload__action--remove" type="button"
aria-label="删除 身份证正面.jpg" aria-disabled="true" disabled>删除</button>
</span>
</li>
</ul>
<button class="kole-m-upload__trigger" type="button" aria-disabled="true" disabled>+ 选择文件</button>
</div>
<div class="kole-m-upload kole-m-upload--full" id="up-full" data-count="2" data-max="2" style="margin-top: var(--kole-space-16)">
<div class="kole-m-upload__header">
<span class="kole-m-upload__title">已到上限</span>
<span class="kole-m-upload__count">2 / 2</span>
</div>
<ul class="kole-m-upload__list">
<li class="kole-m-upload__item is-success">
<span class="kole-m-upload__thumb" aria-hidden="true">png</span>
<span class="kole-m-upload__body">
<span class="kole-m-upload__name">门头照.png</span>
<span class="kole-m-upload__meta"><span class="kole-m-upload__status">已上传</span></span>
</span>
</li>
<li class="kole-m-upload__item is-success">
<span class="kole-m-upload__thumb" aria-hidden="true">png</span>
<span class="kole-m-upload__body">
<span class="kole-m-upload__name">内景照.png</span>
<span class="kole-m-upload__meta"><span class="kole-m-upload__status">已上传</span></span>
</span>
</li>
</ul>
<p class="kole-m-upload__empty">已达到 2 个文件上限,删除后可继续添加</p>
</div>
</div>
</section>API
props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs
Props
| 名称 | 类型 | 默认值 | 说明 | 必传 |
|---|---|---|---|---|
files | Array<{ name: string, uid?: string, status?: 'pending'|'uploading'|'success'|'error', percent?: number }> | [] | 文件列表(宿主提供,状态与进度都由宿主回传)(规格 §46.4) | N |
maxCount | number | 9 | 文件数上限,到达后触发器变成提示行(规格 §46.3) | N |
disabled | boolean | false | 变体 disabled:触发器与删除按钮都不可用(规格 §46.3) | N |
variant | 'dashed' | 'button' | 'dashed' | 变体 variant:dashed 虚线框(页面级),button 实心按钮(列表内嵌)(规格 §46.3) | N |
title | string | '附件' | 标题行文案(规格 §46.2 upload__header) | N |
emptyText | string | '还没有上传文件' | 状态 empty 的占位文案(规格 §46.4) | N |
accept | string | '' | 可接受的文件类型(透传给文件选择器;服务端校验规则不由本组件代为判断)(规格 §46.7) | N |
「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。
事件
| 名称 | 参数 | 说明 |
|---|---|---|
select | — | 点触发器时触发,宿主在此调起系统文件选择器(规格 §46.5) |
remove | index: number | 点删除(uploading 时是取消)时触发,回传条目下标(规格 §46.5) |
retry | index: number | 失败行点重试时触发,回传条目下标(规格 §46.5) |
插槽
| 名称 | 说明 |
|---|---|
default | 自定义内容(追加在文件列表之后) |
CSS 变量
组件级变量(在组件样式表里定义)。业务侧可在自己的作用域内覆盖,不必改组件源码。
| 名称 | 默认值 | 说明 |
|---|---|---|
--kole-m-upload-thumb | 40px | 缩略图/文件图标边长 |
--kole-m-upload-row-height | var(--kole-m-touch-target) | 文件行最小高度 |
何时使用
- 把手机里的文件交给服务端(实名认证的身份证照、报销的发票、工单的附件)
- 移动端没有拖拽也没有 hover,触发按钮是唯一入口,虚线框只表达「这里可以放东西」
- 点触发器调起系统文件选择器(本组件不发请求,选完由宿主拿文件并自行上传)
- 进度不自己走:没有内置定时器或假进度,百分比全部由宿主回传
- 删除按钮自己 44px 热区,点一次移除该项;删除进行中(uploading)时文案是「取消」
- 失败行提供「重试」,重试由宿主重新发起,组件只回传下标
交互与触控
- 点触发器调起系统文件选择器(本组件不发请求,选完由宿主拿文件并自行上传)
- 删除按钮自己 44px 热区,点一次移除该项;删除进行中(uploading)时文案是「取消」
- 失败行提供「重试」,重试由宿主重新发起,组件只回传下标
- 进度不自己走:没有内置定时器或假进度,百分比全部由宿主回传,避免出现「看起来在上传其实没动」
- 列表为空时显示空态文案;到 maxCount 后触发器改成不可点的提示行
无障碍
- 触发器是原生 button,带 aria-disabled;到上限时用 disabled 让键盘跳过
- 进度条写 role="progressbar" + aria-valuemin / aria-valuemax / aria-valuenow,aria-label 说明是哪个文件的进度
- 删除 / 重试按钮各自带 aria-label(含文件名),读屏播报「删除 合同扫描件.pdf」而不是孤零零的「删除」
- 状态文案是文字而不只是颜色(「已上传」「上传失败」),颜色不作为唯一信息通道
相似组件
从「该用哪一个」的角度区分;PC 端的对应实现见 PC 文档站。
| 组件 | 何时用它而不是本组件 |
|---|---|
| 头像Avatar | 单张头像的更换是更窄的场景,用头像组件自带的编辑态,不需要整个上传列表 |
| 对话框Dialog | 上传前要用户确认条款或选择来源时用它承载一步确认 |
| 列表List | 只读的附件陈列(不可增删)用列表更轻,上传的列表带状态与动作 |
| 轻提示Toast | 上传成功 / 失败的单次反馈用它,不必在列表里堆第二条状态文案 |
| 加载Loading | 等待服务端返回文件地址这类短暂阻塞用它,进度有多少用上传自己的进度条 |
规格未定 / 禁止发明
| 类别 | 条目 |
|---|---|
| 禁止发明 | 真实的传输:请求、分片、断点续传、并发数(本组件只回传事件,不发任何请求) |
| 禁止发明 | 图片压缩、裁剪、水印与方向纠正 |
| 禁止发明 | 服务端的校验规则(大小上限、类型白名单以文案与 accept 表达,不代为判断) |
| 禁止发明 | 拍照与相册的原生调起(由宿主在 select 事件里自行调用平台 API) |
| 规格未定 | 上传中能否同时继续添加文件(当前允许,列表各自独立) |
| 规格未定 | 失败自动重试的次数与退避策略 |
| 规格未定 | 是否需要在成功行上展示服务端返回的文件 id / URL |
结构(anatomy)
| 字段 | 说明 |
|---|---|
upload | 根元素,纵向排列「标题行 + 触发器 + 文件列表」 |
upload__header | 标题与计数(已选 N / 上限 M) |
upload__trigger | 选择触发器,点击调起系统文件选择器;dashed 是虚线框,button 是实心按钮 |
upload__list | 文件列表容器 |
upload__item | 单个文件行,行高不小于 44px |
upload__thumb | 缩略图位(图片用背景图,其他类型放扩展名文字块) |
upload__body | 文件名与「进度条 + 状态文案」的容器 |
upload__track | 进度条轨道,role=progressbar |
upload__bar | 进度段,宽度取 --kole-m-upload-percent |
upload__actions | 行内动作(重试 / 删除),各自 44px 热区 |
变体维度与类名映射
类名映射由构建脚本从契约 variantClasses 生成,并被 verify:mobile-docs 逐条对照组件 CSS 校验(类/变量必须真实存在)。
| 维度 | 取值 | 对应类名 / 变量 |
|---|---|---|
variant | dashed / button | dashed .kole-m-upload--dashed button .kole-m-upload--button |
maxCount | reached / not-reached | reached .kole-m-upload--full not-reached (由数据驱动,无专属类) |
代表变体
| 变体 | 标签 |
|---|---|
variant=dashed · maxCount=not-reached | 虚线框(页面级上传区) |
variant=button · maxCount=not-reached | 实心按钮(列表内嵌的「+ 添加」) |
variant=dashed · maxCount=reached | 到上限(触发器变成不可点的提示行) |
variant=dashed · maxCount=not-reached | 禁用(触发器与删除按钮都不可用) |
用到的令牌
构建时从本组件样式表扫描得出。蓝色为移动端自有令牌,绿色为继承的 PC 令牌(改一处两端生效)。
6 端源码
同一组件的六份实现(生产环境的类名与结构一致,差异只在技术栈写法与单位)。点开查看,右侧可复制。
frameworks-mobile/Upload.css · 纯样式(CSS) · 334 行
/* Kole UI Mobile · Upload 样式 — 对齐移动端规格 §46
上传:选择触发 + 文件列表 + 四种状态(待上传 / 上传中 / 成功 / 失败)+ 可删除。
本组件**不发请求**:进度与成败由宿主的 upload 回调回传(file.status / file.percent)。
移动端与桌面端的关键差别是**没有拖拽**:手机上没有 hover、也没有拖放,
选择动作只能是点一下「选择文件」,因此触发器是主入口。文件行高 ≥44px,删除按钮 44px 热区。 */
.kole-m-upload {
/* 组件级变量:业务侧可在容器上覆盖 */
--kole-m-upload-thumb: 40px; /* 缩略图/文件图标边长 */
--kole-m-upload-row-height: var(--kole-m-touch-target); /* 文件行最小高度 */
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: var(--kole-space-12);
width: 100%;
background: var(--kole-color-card-bg);
color: var(--kole-color-text-body);
font-family: var(--kole-font-family);
font-size: var(--kole-m-font-size-body);
line-height: 1.4;
}
.kole-m-upload__mask {
position: fixed;
inset: 0;
z-index: 2000;
background: var(--kole-color-mask);
opacity: 0;
pointer-events: none;
transition: opacity var(--kole-m-duration-slide) var(--kole-m-ease-slide);
}
.kole-m-upload__mask.is-open {
opacity: 1;
pointer-events: auto;
}
.kole-m-upload__header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--kole-space-8);
}
.kole-m-upload__title {
color: var(--kole-color-text-title);
font-weight: 500;
}
.kole-m-upload__count {
color: var(--kole-color-text-secondary);
font-size: var(--kole-m-font-size-label);
font-family: var(--kole-font-family-num);
}
/* 选择触发器:点一下调起系统文件选择器(移动端唯一的入口,没有拖拽) */
.kole-m-upload__trigger {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
gap: var(--kole-space-8);
width: 100%;
min-height: var(--kole-m-touch-target);
padding: var(--kole-space-12) var(--kole-m-gutter);
margin: 0;
border: 1px dashed var(--kole-color-border);
border-radius: var(--kole-radius-base);
background: var(--kole-color-card-bg);
color: var(--kole-color-brand);
font-family: inherit;
font-size: var(--kole-m-font-size-label);
line-height: 1.3;
cursor: pointer;
touch-action: manipulation;
}
/* 变体 variant=dashed:虚线框(页面级上传区;触发按钮是唯一入口,框本身不接收拖放) */
.kole-m-upload--dashed .kole-m-upload__trigger { border-style: dashed; }
/* 变体 variant=button:实心按钮式(列表内嵌的「+ 添加」用) */
.kole-m-upload--button .kole-m-upload__trigger {
border-style: solid;
border-color: var(--kole-color-brand);
background: var(--kole-color-brand);
color: var(--kole-color-text-inverse);
font-weight: 500;
}
.kole-m-upload__trigger:active { background: var(--kole-color-brand-bg); }
.kole-m-upload--button .kole-m-upload__trigger:active {
background: var(--kole-color-brand-active);
border-color: var(--kole-color-brand-active);
}
.kole-m-upload__trigger:focus-visible {
outline: 2px solid var(--kole-color-focus-ring);
outline-offset: 2px;
}
/* 状态 disabled:触发器与删除按钮都不可用 */
.kole-m-upload.is-disabled .kole-m-upload__trigger,
.kole-m-upload__trigger:disabled {
border-color: var(--kole-color-border);
background: var(--kole-color-disabled-bg);
color: var(--kole-color-text-disabled);
cursor: not-allowed;
}
/* 文件列表 */
.kole-m-upload__list {
display: flex;
flex-direction: column;
gap: var(--kole-space-8);
width: 100%;
margin: 0;
padding: 0;
list-style: none;
}
.kole-m-upload__item {
box-sizing: border-box;
display: flex;
align-items: center;
gap: var(--kole-space-12);
width: 100%;
min-height: var(--kole-m-upload-row-height);
padding: var(--kole-space-8) 0;
}
/* 文件行之间画分隔线(画在包裹层上沿,与 List 同一策略) */
.kole-m-upload__item + .kole-m-upload__item { border-top: 1px solid var(--kole-color-border); }
/* 单行进度:由宿主以行内自定义属性传入百分比(如 style="--kole-m-upload-percent: 60%"),
样式表只消费,不写死任何长度 —— 百分比是数据。 */
.kole-m-upload__item { --kole-m-upload-percent: 0%; }
/* 缩略图位:图片是背景图,其他类型是天生的扩展名文字块 */
.kole-m-upload__thumb {
flex: 0 0 auto;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
width: var(--kole-m-upload-thumb);
height: var(--kole-m-upload-thumb);
overflow: hidden;
border: 1px solid var(--kole-color-border);
border-radius: var(--kole-radius-base);
background: var(--kole-color-table-header-bg);
background-position: center;
background-repeat: no-repeat;
background-size: cover;
color: var(--kole-color-text-secondary);
font-family: var(--kole-font-family-num);
font-size: var(--kole-m-font-size-caption);
text-transform: uppercase;
}
/* 内容区:文件名 +(进度条 / 状态文案) */
.kole-m-upload__body {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--kole-space-4);
}
.kole-m-upload__name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--kole-color-text-body);
}
.kole-m-upload__meta {
display: flex;
align-items: center;
gap: var(--kole-space-8);
color: var(--kole-color-text-secondary);
font-size: var(--kole-m-font-size-label);
}
/* 进度条:只在上传中 / 成功 / 失败时出现(待上传不画,长度无意义) */
.kole-m-upload__track {
position: relative;
flex: 1 1 auto;
min-width: 0;
height: 2px;
overflow: hidden;
border-radius: 1px;
background: var(--kole-color-border);
}
.kole-m-upload__bar {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 0;
border-radius: 1px;
background: var(--kole-color-brand);
transition: width var(--kole-duration-base) var(--kole-ease-standard);
}
/* 状态 pending:待上传(灰点,未开始;进度条不占宽) */
.kole-m-upload__item.is-pending .kole-m-upload__bar { width: 0; background: var(--kole-color-border); }
/* 状态 uploading:上传中(品牌色进度条,宽度取 --kole-m-upload-percent) */
.kole-m-upload__item.is-uploading .kole-m-upload__bar {
width: var(--kole-m-upload-percent);
background: var(--kole-color-brand);
}
/* 状态 success:成功(满格 + 品牌成功色 + 勾号) */
.kole-m-upload__item.is-success .kole-m-upload__bar {
width: 100%;
background: var(--kole-color-success);
}
.kole-m-upload__item.is-success .kole-m-upload__status { color: var(--kole-color-success); }
/* 状态 error:失败(满格 + 错误色 + 可重试) */
.kole-m-upload__item.is-error .kole-m-upload__bar {
width: 100%;
background: var(--kole-color-error);
}
.kole-m-upload__item.is-error .kole-m-upload__status { color: var(--kole-color-error); }
.kole-m-upload__status {
flex: 0 0 auto;
color: var(--kole-color-text-secondary);
font-size: var(--kole-m-font-size-label);
}
/* 行内动作(重试 / 删除):各自 44px 热区 */
.kole-m-upload__actions {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: var(--kole-space-4);
margin-right: calc(var(--kole-space-8) * -1);
}
.kole-m-upload__action {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: var(--kole-m-touch-target);
min-height: var(--kole-m-touch-target);
padding: 0 var(--kole-space-4);
margin: 0;
border: 0;
border-radius: var(--kole-radius-base);
background: none;
color: var(--kole-color-text-secondary);
font-family: inherit;
font-size: var(--kole-m-font-size-label);
line-height: 1;
cursor: pointer;
touch-action: manipulation;
}
.kole-m-upload__action:active { background: var(--kole-color-table-header-bg); }
.kole-m-upload__action:focus-visible {
outline: 2px solid var(--kole-color-focus-ring);
outline-offset: 2px;
}
.kole-m-upload__action--remove { color: var(--kole-color-error); }
.kole-m-upload__action:disabled {
color: var(--kole-color-text-disabled);
cursor: not-allowed;
}
/* 变体 maxCount 到顶:触发器改成提示行(不再可点) */
.kole-m-upload--full .kole-m-upload__trigger {
border-style: solid;
color: var(--kole-color-text-secondary);
cursor: default;
}
/* 状态 empty:还没有任何文件时的说明 */
.kole-m-upload__empty {
margin: 0;
padding: var(--kole-space-16) 0;
color: var(--kole-color-text-secondary);
font-size: var(--kole-m-font-size-label);
text-align: center;
}
/* 视觉隐藏但读屏可读(原生 file input 的替身) */
.kole-m-upload__native {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.kole-m-upload__native:focus-visible + .kole-m-upload__trigger {
outline: 2px solid var(--kole-color-focus-ring);
outline-offset: 2px;
}
/* 演示页触发器(非组件交付样式) */
.kole-m-upload__demo-btn {
box-sizing: border-box;
min-height: var(--kole-m-touch-target);
padding: 0 var(--kole-m-gutter);
border: 1px solid var(--kole-color-border);
border-radius: var(--kole-radius-base);
background: var(--kole-color-card-bg);
color: var(--kole-color-text-body);
font-family: inherit;
font-size: var(--kole-m-font-size-label);
cursor: pointer;
touch-action: manipulation;
}
.kole-m-upload__demo-btn:focus-visible {
outline: 2px solid var(--kole-color-focus-ring);
outline-offset: 2px;
}
frameworks-mobile/Upload.html · H5 原生(无框架) · 284 行
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Kole UI Mobile · Upload(H5)</title>
<link rel="stylesheet" href="../.design_library/kole-ui-mobile/colors_and_type.css">
<link rel="stylesheet" href="Upload.css">
<style>
body { margin: 0; background: var(--kole-color-page-bg); font-family: var(--kole-font-family); color: var(--kole-color-text-body); }
.demo { max-width: 375px; margin: 0 auto; padding: var(--kole-m-gutter) 0; }
.demo-label { margin: 0; padding: var(--kole-space-12) var(--kole-m-gutter) var(--kole-space-8);
font-size: var(--kole-m-font-size-label); color: var(--kole-color-text-secondary); }
.demo-pad { padding: var(--kole-m-gutter); background: var(--kole-color-card-bg); border-block: 1px solid var(--kole-color-border); }
.demo-block[hidden] { display: none; }
</style>
</head>
<body>
<div class="demo">
<section class="demo-block" data-demo="trigger">
<p class="demo-label">选择触发(点一下调起系统文件选择器:移动端没有拖拽,这是唯一入口)</p>
<div class="demo-pad" data-assert="upload-trigger">
<div class="kole-m-upload" id="up-trigger" data-count="0" data-max="5">
<div class="kole-m-upload__header">
<span class="kole-m-upload__title">附件</span>
<span class="kole-m-upload__count" id="up-trigger-count">0 / 5</span>
</div>
<input class="kole-m-upload__native" id="up-trigger-input" type="file" multiple tabindex="-1" aria-hidden="true">
<button class="kole-m-upload__trigger" type="button" id="up-trigger-btn"
data-behavior="click-sets-attr:#up-trigger|data-picked|true"
aria-describedby="up-trigger-hint">+ 选择文件</button>
<p class="kole-m-upload__empty" id="up-trigger-hint">支持 jpg / png / pdf,单个不超过 10 MB</p>
</div>
</div>
</section>
<section class="demo-block" data-demo="states">
<p class="demo-label">四种状态(待上传 / 上传中 60% / 成功 / 失败,均不发真实请求,进度由宿主回传)</p>
<div class="demo-pad" data-assert="upload-states">
<div class="kole-m-upload kole-m-upload--button" id="up-states" data-count="4">
<div class="kole-m-upload__header">
<span class="kole-m-upload__title">上传队列</span>
<span class="kole-m-upload__count">4 个文件</span>
</div>
<ul class="kole-m-upload__list">
<li class="kole-m-upload__item is-pending" data-assert="upload-state-pending">
<span class="kole-m-upload__thumb" aria-hidden="true">pdf</span>
<span class="kole-m-upload__body">
<span class="kole-m-upload__name">合同扫描件.pdf</span>
<span class="kole-m-upload__meta">
<span class="kole-m-upload__track" aria-hidden="true"><span class="kole-m-upload__bar"></span></span>
<span class="kole-m-upload__status">待上传</span>
</span>
</span>
<span class="kole-m-upload__actions">
<button class="kole-m-upload__action kole-m-upload__action--remove" type="button"
aria-label="删除 合同扫描件.pdf" id="up-remove-pending"
data-behavior="click-sets-attr:#up-states|data-removed|pending">删除</button>
</span>
</li>
<li class="kole-m-upload__item is-uploading" style="--kole-m-upload-percent: 60%">
<span class="kole-m-upload__thumb" aria-hidden="true">png</span>
<span class="kole-m-upload__body">
<span class="kole-m-upload__name">产品主图.png</span>
<span class="kole-m-upload__meta">
<span class="kole-m-upload__track" role="progressbar" aria-valuemin="0" aria-valuemax="100"
aria-valuenow="60" aria-label="产品主图.png 上传进度">
<span class="kole-m-upload__bar"></span>
</span>
<span class="kole-m-upload__status">60%</span>
</span>
</span>
<span class="kole-m-upload__actions">
<button class="kole-m-upload__action kole-m-upload__action--remove" type="button"
aria-label="取消 产品主图.png">取消</button>
</span>
</li>
<li class="kole-m-upload__item is-success">
<span class="kole-m-upload__thumb" aria-hidden="true">jpg</span>
<span class="kole-m-upload__body">
<span class="kole-m-upload__name">营业执照.jpg</span>
<span class="kole-m-upload__meta">
<span class="kole-m-upload__track" aria-hidden="true"><span class="kole-m-upload__bar"></span></span>
<span class="kole-m-upload__status">已上传</span>
</span>
</span>
<span class="kole-m-upload__actions">
<button class="kole-m-upload__action kole-m-upload__action--remove" type="button"
aria-label="删除 营业执照.jpg">删除</button>
</span>
</li>
<li class="kole-m-upload__item is-error">
<span class="kole-m-upload__thumb" aria-hidden="true">zip</span>
<span class="kole-m-upload__body">
<span class="kole-m-upload__name">素材包.zip</span>
<span class="kole-m-upload__meta">
<span class="kole-m-upload__track" role="progressbar" aria-valuemin="0" aria-valuemax="100"
aria-valuenow="100" aria-label="素材包.zip 上传失败">
<span class="kole-m-upload__bar"></span>
</span>
<span class="kole-m-upload__status">超出 10 MB</span>
</span>
</span>
<span class="kole-m-upload__actions">
<button class="kole-m-upload__action" type="button" aria-label="重试 素材包.zip" id="up-retry"
data-behavior="click-sets-attr:#up-states|data-retried|zip">重试</button>
<button class="kole-m-upload__action kole-m-upload__action--remove" type="button"
aria-label="删除 素材包.zip">删除</button>
</span>
</li>
</ul>
</div>
</div>
</section>
<section class="demo-block" data-demo="removable">
<p class="demo-label">可删除(删除按钮自己 44px 热区;删完后回到空态)</p>
<div class="demo-pad" data-assert="upload-removable">
<div class="kole-m-upload" id="up-removable" data-count="2" data-max="5">
<div class="kole-m-upload__header">
<span class="kole-m-upload__title">发票</span>
<span class="kole-m-upload__count" id="up-removable-count">2 / 5</span>
</div>
<ul class="kole-m-upload__list" id="up-removable-list">
<li class="kole-m-upload__item is-success" data-assert="upload-removable-row">
<span class="kole-m-upload__thumb" aria-hidden="true">jpg</span>
<span class="kole-m-upload__body">
<span class="kole-m-upload__name">发票-09月.jpg</span>
<span class="kole-m-upload__meta"><span class="kole-m-upload__status">已上传</span></span>
</span>
<span class="kole-m-upload__actions">
<button class="kole-m-upload__action kole-m-upload__action--remove" type="button"
aria-label="删除 发票-09月.jpg" data-remove="row"
data-behavior="click-sets-attr:#up-removable|data-removed|row-1">删除</button>
</span>
</li>
<li class="kole-m-upload__item is-success" id="up-removable-row-1">
<span class="kole-m-upload__thumb" aria-hidden="true">pdf</span>
<span class="kole-m-upload__body">
<span class="kole-m-upload__name">对账单-09月.pdf</span>
<span class="kole-m-upload__meta"><span class="kole-m-upload__status">已上传</span></span>
</span>
<span class="kole-m-upload__actions">
<button class="kole-m-upload__action kole-m-upload__action--remove" type="button"
aria-label="删除 对账单-09月.pdf" data-remove="row">删除</button>
</span>
</li>
</ul>
<p class="kole-m-upload__empty" id="up-removable-empty" hidden>还没有上传文件</p>
<button class="kole-m-upload__trigger" type="button">+ 选择文件</button>
</div>
</div>
</section>
<section class="demo-block" data-demo="disabled">
<p class="demo-label">禁用与到顶(disabled:触发器不可点、删除按钮置灰;到 maxCount 触发器改成提示行)</p>
<div class="demo-pad" data-assert="upload-disabled">
<div class="kole-m-upload is-disabled" id="up-disabled" data-count="1" data-max="5">
<div class="kole-m-upload__header">
<span class="kole-m-upload__title">已锁定</span>
<span class="kole-m-upload__count">1 / 5</span>
</div>
<ul class="kole-m-upload__list">
<li class="kole-m-upload__item is-success">
<span class="kole-m-upload__thumb" aria-hidden="true">jpg</span>
<span class="kole-m-upload__body">
<span class="kole-m-upload__name">身份证正面.jpg</span>
<span class="kole-m-upload__meta"><span class="kole-m-upload__status">已上传</span></span>
</span>
<span class="kole-m-upload__actions">
<button class="kole-m-upload__action kole-m-upload__action--remove" type="button"
aria-label="删除 身份证正面.jpg" aria-disabled="true" disabled>删除</button>
</span>
</li>
</ul>
<button class="kole-m-upload__trigger" type="button" aria-disabled="true" disabled>+ 选择文件</button>
</div>
<div class="kole-m-upload kole-m-upload--full" id="up-full" data-count="2" data-max="2" style="margin-top: var(--kole-space-16)">
<div class="kole-m-upload__header">
<span class="kole-m-upload__title">已到上限</span>
<span class="kole-m-upload__count">2 / 2</span>
</div>
<ul class="kole-m-upload__list">
<li class="kole-m-upload__item is-success">
<span class="kole-m-upload__thumb" aria-hidden="true">png</span>
<span class="kole-m-upload__body">
<span class="kole-m-upload__name">门头照.png</span>
<span class="kole-m-upload__meta"><span class="kole-m-upload__status">已上传</span></span>
</span>
</li>
<li class="kole-m-upload__item is-success">
<span class="kole-m-upload__thumb" aria-hidden="true">png</span>
<span class="kole-m-upload__body">
<span class="kole-m-upload__name">内景照.png</span>
<span class="kole-m-upload__meta"><span class="kole-m-upload__status">已上传</span></span>
</span>
</li>
</ul>
<p class="kole-m-upload__empty">已达到 2 个文件上限,删除后可继续添加</p>
</div>
</div>
</section>
</div>
<script>
/* 演示页交互:**不发任何真实请求**。
触发器只调起本页隐藏的 file input(真实浏览器里会弹系统选择器;无头环境里不选文件也合法),
并打 data-picked 标记。状态的推进由宿主的 upload 回调负责(这里不模拟进度的定时器,
否则演示页会自己动起来,预览帧不稳定)。每条演示块独立成立。 */
(function () {
var btn = document.getElementById('up-trigger-btn');
var input = document.getElementById('up-trigger-input');
var root = document.getElementById('up-trigger');
if (btn && input) {
btn.addEventListener('click', function () {
if (root && root.getAttribute('data-disabled') === 'true') return;
try { input.click(); } catch (e) { /* 无头环境可能拒绝,标记仍然打上 */ }
if (root) root.setAttribute('data-picked', 'true');
});
}
/* 状态推进演示:删除 / 重试只打标记到本块的根节点(真实业务里由宿主移除该项并发请求)。
演示页不发任何网络请求。 */
var states = document.getElementById('up-states');
var removePending = document.getElementById('up-remove-pending');
var retry = document.getElementById('up-retry');
if (states && removePending) {
removePending.addEventListener('click', function () {
states.setAttribute('data-removed', 'pending');
});
}
if (states && retry) {
retry.addEventListener('click', function () {
states.setAttribute('data-retried', 'zip');
});
}
/* 可删除块:点删除即移除该行;列表空了切到空态 */
var list = document.getElementById('up-removable-list');
var empty = document.getElementById('up-removable-empty');
var counter = document.getElementById('up-removable-count');
var roo2 = document.getElementById('up-removable');
function syncEmpty() {
if (!list || !empty) return;
var left = list.children.length;
empty.hidden = left > 0;
if (counter) counter.textContent = left + ' / 5';
if (roo2) roo2.setAttribute('data-count', String(left));
}
if (list) {
list.addEventListener('click', function (e) {
var b = e.target.closest ? e.target.closest('[data-remove="row"]') : null;
if (!b) return;
var row = b.closest('.kole-m-upload__item');
if (row && row.parentNode) row.parentNode.removeChild(row);
if (roo2) roo2.setAttribute('data-removed', 'row-1');
syncEmpty();
});
syncEmpty();
}
})();
</script>
<script>
/* ?demo=<id> → 只显示该演示块(文档站按块预览用;无参数时全部显示,测试与回归走无参数路径) */
(function () {
var id = new URLSearchParams(location.search).get('demo');
if (!id) return;
var blocks = Array.prototype.slice.call(document.querySelectorAll('.demo-block'));
var hit = false;
blocks.forEach(function (b) {
var on = b.getAttribute('data-demo') === id;
if (on) hit = true;
b.hidden = !on;
});
if (!hit) { blocks.forEach(function (b) { b.hidden = false; }); return; }
document.body.classList.add('demo-single');
blocks.forEach(function (b) {
var label = b.querySelector('.demo-label');
if (label && !b.hidden) label.hidden = true;
});
})();
</script>
</body>
</html>
frameworks-mobile/Upload.jsx · React · 152 行
import React from 'react';
import './Upload.css';
/* 上传(移动端)— 规格 §46。
结构:header(标题 + 计数)> trigger(选择触发器)> list(文件行)+ empty。
移动端没有拖拽:选择动作只能是点一下触发器(规格 §46.1),因此触发器是唯一入口。
**本组件不发请求**:进度与成败全部由宿主回传(file.status / file.percent),
组件只做四态呈现与删除 / 重试的动作回传(规格 §46.7)。
file.status 取值:'pending'(待上传)/ 'uploading'(上传中)/ 'success'(成功)/ 'error'(失败)。 */
const STATUS_TEXT = {
pending: '待上传',
uploading: '上传中',
success: '已上传',
error: '上传失败',
};
function statusOf(file) {
const s = file && file.status ? String(file.status) : 'pending';
return STATUS_TEXT[s] ? s : 'pending';
}
function percentOf(file) {
const n = file && typeof file.percent === 'number' ? file.percent : 0;
return Math.max(0, Math.min(100, Math.round(n)));
}
function extOf(file) {
const name = file && file.name ? String(file.name) : '';
const hit = name.match(/\.([a-z0-9]+)$/i);
return hit ? hit[1].toLowerCase() : 'file';
}
export default function Upload({
files = [],
maxCount = 9,
disabled = false,
variant = 'dashed',
title = '附件',
emptyText = '还没有上传文件',
accept = '',
onSelect,
onRemove,
onRetry,
children = null,
}) {
const full = files.length >= maxCount;
const cls =
'kole-m-upload' +
` kole-m-upload--${variant === 'button' ? 'button' : 'dashed'}` +
(full ? ' kole-m-upload--full' : '') +
(disabled ? ' is-disabled' : '');
return (
<div className={cls}>
<div className="kole-m-upload__header">
<span className="kole-m-upload__title">{title}</span>
<span className="kole-m-upload__count">
{files.length} / {maxCount}
</span>
</div>
{/* 触发器:调起系统文件选择器(本端不绑定隐藏 input,由宿主套壳或在 onSelect 里自行取文件) */}
<button
className="kole-m-upload__trigger"
type="button"
disabled={disabled || full}
aria-disabled={disabled || full ? 'true' : undefined}
onClick={() => {
if (disabled || full) return;
if (onSelect) onSelect();
}}
>
+ 选择文件
</button>
{files.length ? (
<ul className="kole-m-upload__list">
{files.map((file, i) => {
const st = statusOf(file);
const pct = percentOf(file);
const name = file && file.name ? String(file.name) : '未命名文件';
return (
<li
className={'kole-m-upload__item is-' + st}
key={file && file.uid ? String(file.uid) : name + i}
style={{ '--kole-m-upload-percent': pct + '%' }}
>
<span className="kole-m-upload__thumb" aria-hidden="true">
{extOf(file)}
</span>
<span className="kole-m-upload__body">
<span className="kole-m-upload__name">{name}</span>
<span className="kole-m-upload__meta">
<span
className="kole-m-upload__track"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={pct}
aria-label={name + ' 上传进度'}
>
<span className="kole-m-upload__bar" />
</span>
<span className="kole-m-upload__status">
{st === 'uploading' ? pct + '%' : STATUS_TEXT[st]}
</span>
</span>
</span>
<span className="kole-m-upload__actions">
{st === 'error' ? (
<button
className="kole-m-upload__action"
type="button"
disabled={disabled}
aria-label={'重试 ' + name}
onClick={() => {
if (disabled) return;
if (onRetry) onRetry(i);
}}
>
重试
</button>
) : null}
<button
className="kole-m-upload__action kole-m-upload__action--remove"
type="button"
disabled={disabled}
aria-label={'删除 ' + name}
onClick={() => {
if (disabled) return;
if (onRemove) onRemove(i);
}}
>
删除
</button>
</span>
</li>
);
})}
</ul>
) : (
<p className="kole-m-upload__empty">{emptyText}</p>
)}
{/* 隐藏的文件选择器(由宿主提供,这里用 accept 说明可选项) */}
<input className="kole-m-upload__native" type="file" multiple accept={accept} tabIndex={-1} aria-hidden="true" />
{children}
</div>
);
}
frameworks-mobile/Upload.vue2.vue · Vue 2 · 160 行
<template>
<div class="kole-m-upload" :class="rootClass">
<div class="kole-m-upload__header">
<span class="kole-m-upload__title">{{ title }}</span>
<span class="kole-m-upload__count">{{ files.length }} / {{ maxCount }}</span>
</div>
<button
class="kole-m-upload__trigger"
type="button"
:disabled="disabled || full"
:aria-disabled="disabled || full ? 'true' : null"
@click="onSelectClick"
>+ 选择文件</button>
<ul v-if="files.length" class="kole-m-upload__list">
<li
v-for="(file, i) in files"
:key="file && file.uid ? file.uid : (file && file.name ? file.name : i) + '-' + i"
class="kole-m-upload__item"
:class="'is-' + statusOf(file)"
:style="{ '--kole-m-upload-percent': percentOf(file) + '%' }"
>
<span class="kole-m-upload__thumb" aria-hidden="true">{{ extOf(file) }}</span>
<span class="kole-m-upload__body">
<span class="kole-m-upload__name">{{ nameOf(file) }}</span>
<span class="kole-m-upload__meta">
<span
class="kole-m-upload__track"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="percentOf(file)"
:aria-label="nameOf(file) + ' 上传进度'"
>
<span class="kole-m-upload__bar"></span>
</span>
<span class="kole-m-upload__status">
{{ statusOf(file) === 'uploading' ? percentOf(file) + '%' : statusText(file) }}
</span>
</span>
</span>
<span class="kole-m-upload__actions">
<button
v-if="statusOf(file) === 'error'"
class="kole-m-upload__action"
type="button"
:disabled="disabled"
:aria-label="'重试 ' + nameOf(file)"
@click="onRetryClick(i)"
>重试</button>
<button
class="kole-m-upload__action kole-m-upload__action--remove"
type="button"
:disabled="disabled"
:aria-label="'删除 ' + nameOf(file)"
@click="onRemoveClick(i)"
>删除</button>
</span>
</li>
</ul>
<p v-else class="kole-m-upload__empty">{{ emptyText }}</p>
<input
class="kole-m-upload__native"
type="file"
multiple
:accept="accept"
tabindex="-1"
aria-hidden="true"
>
<slot></slot>
</div>
</template>
<script>
/* 上传(移动端)— 规格 §46。
结构:header(标题 + 计数)> trigger(选择触发器)> list(文件行)+ empty。
移动端没有拖拽:选择动作只能是点一下触发器(规格 §46.1),因此触发器是唯一入口。
**本组件不发请求**:进度与成败全部由宿主回传(file.status / file.percent),
组件只做四态呈现与删除 / 重试的动作回传(规格 §46.7)。
file.status 取值:'pending'(待上传)/ 'uploading'(上传中)/ 'success'(成功)/ 'error'(失败)。 */
var STATUS_TEXT = {
pending: '待上传',
uploading: '上传中',
success: '已上传',
error: '上传失败'
};
function statusOf(file) {
var s = file && file.status ? String(file.status) : 'pending';
return STATUS_TEXT[s] ? s : 'pending';
}
function statusText(file) {
return STATUS_TEXT[statusOf(file)];
}
function percentOf(file) {
var n = file && typeof file.percent === 'number' ? file.percent : 0;
return Math.max(0, Math.min(100, Math.round(n)));
}
function nameOf(file) {
return file && file.name ? String(file.name) : '未命名文件';
}
function extOf(file) {
var hit = nameOf(file).match(/\.([a-z0-9]+)$/i);
return hit ? hit[1].toLowerCase() : 'file';
}
export default {
name: 'KoleMUpload',
props: {
files: { type: Array, default: function () { return []; } },
maxCount: { type: Number, default: 9 },
disabled: { type: Boolean, default: false },
variant: { type: String, default: 'dashed' },
title: { type: String, default: '附件' },
emptyText: { type: String, default: '还没有上传文件' },
accept: { type: String, default: '' }
},
computed: {
full: function () {
return this.files.length >= this.maxCount;
},
rootClass: function () {
return [
'kole-m-upload--' + (this.variant === 'button' ? 'button' : 'dashed'),
this.full ? 'kole-m-upload--full' : '',
this.disabled ? 'is-disabled' : ''
].filter(Boolean);
}
},
methods: {
statusOf: statusOf,
statusText: statusText,
percentOf: percentOf,
nameOf: nameOf,
extOf: extOf,
onSelectClick: function () {
if (this.disabled || this.full) return;
this.$emit('select');
},
onRemoveClick: function (i) {
if (this.disabled) return;
this.$emit('remove', i);
},
onRetryClick: function (i) {
if (this.disabled) return;
this.$emit('retry', i);
}
}
};
</script>
<style src="./Upload.css"></style>
frameworks-mobile/Upload.vue3.vue · Vue 3 · 150 行
<template>
<div class="kole-m-upload" :class="rootClass">
<div class="kole-m-upload__header">
<span class="kole-m-upload__title">{{ title }}</span>
<span class="kole-m-upload__count">{{ files.length }} / {{ maxCount }}</span>
</div>
<button
class="kole-m-upload__trigger"
type="button"
:disabled="disabled || full"
:aria-disabled="disabled || full ? 'true' : null"
@click="onSelectClick"
>+ 选择文件</button>
<ul v-if="files.length" class="kole-m-upload__list">
<li
v-for="(file, i) in files"
:key="file && file.uid ? file.uid : (file && file.name ? file.name : i) + '-' + i"
class="kole-m-upload__item"
:class="'is-' + statusOf(file)"
:style="{ '--kole-m-upload-percent': percentOf(file) + '%' }"
>
<span class="kole-m-upload__thumb" aria-hidden="true">{{ extOf(file) }}</span>
<span class="kole-m-upload__body">
<span class="kole-m-upload__name">{{ nameOf(file) }}</span>
<span class="kole-m-upload__meta">
<span
class="kole-m-upload__track"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="percentOf(file)"
:aria-label="nameOf(file) + ' 上传进度'"
>
<span class="kole-m-upload__bar"></span>
</span>
<span class="kole-m-upload__status">
{{ statusOf(file) === 'uploading' ? percentOf(file) + '%' : statusText(file) }}
</span>
</span>
</span>
<span class="kole-m-upload__actions">
<button
v-if="statusOf(file) === 'error'"
class="kole-m-upload__action"
type="button"
:disabled="disabled"
:aria-label="'重试 ' + nameOf(file)"
@click="onRetryClick(i)"
>重试</button>
<button
class="kole-m-upload__action kole-m-upload__action--remove"
type="button"
:disabled="disabled"
:aria-label="'删除 ' + nameOf(file)"
@click="onRemoveClick(i)"
>删除</button>
</span>
</li>
</ul>
<p v-else class="kole-m-upload__empty">{{ emptyText }}</p>
<input
class="kole-m-upload__native"
type="file"
multiple
:accept="accept"
tabindex="-1"
aria-hidden="true"
>
<slot></slot>
</div>
</template>
<script setup>
/* 上传(移动端)— 规格 §46。
结构:header(标题 + 计数)> trigger(选择触发器)> list(文件行)+ empty。
移动端没有拖拽:选择动作只能是点一下触发器(规格 §46.1),因此触发器是唯一入口。
**本组件不发请求**:进度与成败全部由宿主回传(file.status / file.percent),
组件只做四态呈现与删除 / 重试的动作回传(规格 §46.7)。
file.status 取值:'pending'(待上传)/ 'uploading'(上传中)/ 'success'(成功)/ 'error'(失败)。 */
import { computed } from 'vue';
const props = defineProps({
files: { type: Array, default: () => [] },
maxCount: { type: Number, default: 9 },
disabled: { type: Boolean, default: false },
variant: { type: String, default: 'dashed' },
title: { type: String, default: '附件' },
emptyText: { type: String, default: '还没有上传文件' },
accept: { type: String, default: '' }
});
const emit = defineEmits(['select', 'remove', 'retry']);
const STATUS_TEXT = {
pending: '待上传',
uploading: '上传中',
success: '已上传',
error: '上传失败'
};
function statusOf(file) {
const s = file && file.status ? String(file.status) : 'pending';
return STATUS_TEXT[s] ? s : 'pending';
}
function statusText(file) {
return STATUS_TEXT[statusOf(file)];
}
function percentOf(file) {
const n = file && typeof file.percent === 'number' ? file.percent : 0;
return Math.max(0, Math.min(100, Math.round(n)));
}
function nameOf(file) {
return file && file.name ? String(file.name) : '未命名文件';
}
function extOf(file) {
const hit = nameOf(file).match(/\.([a-z0-9]+)$/i);
return hit ? hit[1].toLowerCase() : 'file';
}
const full = computed(() => props.files.length >= props.maxCount);
const rootClass = computed(() => [
'kole-m-upload--' + (props.variant === 'button' ? 'button' : 'dashed'),
full.value ? 'kole-m-upload--full' : '',
props.disabled ? 'is-disabled' : ''
].filter(Boolean));
function onSelectClick() {
if (props.disabled || full.value) return;
emit('select');
}
function onRemoveClick(i) {
if (props.disabled) return;
emit('remove', i);
}
function onRetryClick(i) {
if (props.disabled) return;
emit('retry', i);
}
</script>
<style src="./Upload.css"></style>
frameworks-mobile/Upload.uniapp.vue · uni-app(跨端:小程序 / App / H5) · 332 行
<template>
<view class="kole-m-upload" :class="rootClass">
<view class="kole-m-upload__header">
<text class="kole-m-upload__title">{{ title }}</text>
<text class="kole-m-upload__count">{{ files.length }} / {{ maxCount }}</text>
</view>
<!-- 选择触发:uni 基础组件的 picker 不够用(它只选目录/日期),
这里用 view + role="button" 触发宿主的 uni.chooseImage / chooseFile 回调 -->
<view
class="kole-m-upload__trigger"
:class="{ 'is-disabled': disabled || full }"
:role="disabled || full ? '' : 'button'"
:aria-disabled="disabled || full ? 'true' : 'false'"
@tap="onSelectTap"
>
<text>+ 选择文件</text>
</view>
<view v-if="files.length" class="kole-m-upload__list">
<view
v-for="(file, i) in files"
:key="file && file.uid ? file.uid : (file && file.name ? file.name : i) + '-' + i"
class="kole-m-upload__item"
:class="'is-' + statusOf(file)"
:style="{ '--kole-m-upload-percent': percentOf(file) + '%' }"
>
<view class="kole-m-upload__thumb" aria-hidden="true">
<text>{{ extOf(file) }}</text>
</view>
<view class="kole-m-upload__body">
<text class="kole-m-upload__name">{{ nameOf(file) }}</text>
<view class="kole-m-upload__meta">
<view
class="kole-m-upload__track"
:role="'progressbar'"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="String(percentOf(file))"
:aria-label="nameOf(file) + ' 上传进度'"
>
<view class="kole-m-upload__bar"></view>
</view>
<text class="kole-m-upload__status">
{{ statusOf(file) === 'uploading' ? percentOf(file) + '%' : statusText(file) }}
</text>
</view>
</view>
<view class="kole-m-upload__actions">
<view
v-if="statusOf(file) === 'error'"
class="kole-m-upload__action"
role="button"
:aria-label="'重试 ' + nameOf(file)"
@tap="onRetryTap(i)"
>
<text>重试</text>
</view>
<view
class="kole-m-upload__action kole-m-upload__action--remove"
role="button"
:aria-disabled="disabled ? 'true' : 'false'"
:aria-label="'删除 ' + nameOf(file)"
@tap="onRemoveTap(i)"
>
<text>删除</text>
</view>
</view>
</view>
</view>
<text v-else class="kole-m-upload__empty">{{ emptyText }}</text>
<slot></slot>
</view>
</template>
<script setup>
/* uni-app 端 · 上传(移动端)— 规格 §46
跨端差异:用 view / text;选择文件由宿主调 uni.chooseImage / uni.chooseFile 完成,
本端只回传 select 事件(小程序与 App 端没有 <input type="file">,也不该在此发明选文件 API)。
进度条用 view + 行内自定义属性 `--kole-m-upload-percent` 表示宽度(百分比是数据)。
**本组件不发请求**:进度与成败全部由宿主回传(file.status / file.percent)(规格 §46.7)。
尺寸用 rpx(88rpx = 375pt 下的 44px 触控最小边长)。 */
import { computed } from 'vue';
const props = defineProps({
files: { type: Array, default: () => [] },
maxCount: { type: Number, default: 9 },
disabled: { type: Boolean, default: false },
variant: { type: String, default: 'dashed' },
title: { type: String, default: '附件' },
emptyText: { type: String, default: '还没有上传文件' },
accept: { type: String, default: '' }
});
const emit = defineEmits(['select', 'remove', 'retry']);
const STATUS_TEXT = {
pending: '待上传',
uploading: '上传中',
success: '已上传',
error: '上传失败'
};
function statusOf(file) {
const s = file && file.status ? String(file.status) : 'pending';
return STATUS_TEXT[s] ? s : 'pending';
}
function statusText(file) {
return STATUS_TEXT[statusOf(file)];
}
function percentOf(file) {
const n = file && typeof file.percent === 'number' ? file.percent : 0;
return Math.max(0, Math.min(100, Math.round(n)));
}
function nameOf(file) {
return file && file.name ? String(file.name) : '未命名文件';
}
function extOf(file) {
const hit = nameOf(file).match(/\.([a-z0-9]+)$/i);
return hit ? hit[1].toLowerCase() : 'file';
}
const full = computed(() => props.files.length >= props.maxCount);
const rootClass = computed(() => [
'kole-m-upload--' + (props.variant === 'button' ? 'button' : 'dashed'),
full.value ? 'kole-m-upload--full' : '',
props.disabled ? 'is-disabled' : ''
].filter(Boolean));
function onSelectTap() {
if (props.disabled || full.value) return;
emit('select');
}
function onRemoveTap(i) {
if (props.disabled) return;
emit('remove', i);
}
function onRetryTap(i) {
if (props.disabled) return;
emit('retry', i);
}
</script>
<style>
.kole-m-upload {
--kole-m-upload-thumb: 80rpx;
--kole-m-upload-row-height: 88rpx;
--kole-m-touch-target: 88rpx;
--kole-m-font-size-body: 32rpx;
--kole-m-font-size-label: 28rpx;
--kole-m-font-size-caption: 22rpx;
--kole-m-gutter: 32rpx;
box-sizing: border-box;
display: flex;
flex-direction: column;
width: 100%;
background-color: var(--kole-color-card-bg);
color: var(--kole-color-text-body);
font-size: var(--kole-m-font-size-body);
}
.kole-m-upload__header {
display: flex;
align-items: baseline;
justify-content: space-between;
padding-bottom: 16rpx;
}
.kole-m-upload__title {
color: var(--kole-color-text-title);
font-size: var(--kole-m-font-size-body);
}
.kole-m-upload__count {
color: var(--kole-color-text-secondary);
font-size: var(--kole-m-font-size-label);
}
/* 选择触发器:点一下由宿主调起系统选择器(移动端唯一入口,没有拖拽) */
.kole-m-upload__trigger {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 100%;
min-height: var(--kole-m-touch-target);
padding: 24rpx var(--kole-m-gutter);
border-width: 1rpx;
border-style: dashed;
border-color: var(--kole-color-border);
border-radius: 8rpx;
color: var(--kole-color-brand);
font-size: var(--kole-m-font-size-label);
}
.kole-m-upload__trigger.is-disabled {
border-color: var(--kole-color-border);
background-color: var(--kole-color-disabled-bg);
color: var(--kole-color-text-disabled);
}
/* 变体 variant=dashed:虚线框(页面级上传区;触发按钮是唯一入口) */
.kole-m-upload--dashed .kole-m-upload__trigger { border-style: dashed; }
/* 变体 variant=button:实心按钮式 */
.kole-m-upload--button .kole-m-upload__trigger {
border-style: solid;
border-color: var(--kole-color-brand);
background-color: var(--kole-color-brand);
color: var(--kole-color-text-inverse);
}
.kole-m-upload__list {
display: flex;
flex-direction: column;
width: 100%;
}
.kole-m-upload__item {
--kole-m-upload-percent: 0%;
box-sizing: border-box;
display: flex;
align-items: center;
width: 100%;
min-height: var(--kole-m-upload-row-height);
padding: 16rpx 0;
}
.kole-m-upload__item + .kole-m-upload__item { border-top: 1rpx solid var(--kole-color-border); }
.kole-m-upload__thumb {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: var(--kole-m-upload-thumb);
height: var(--kole-m-upload-thumb);
margin-right: 24rpx;
border: 1rpx solid var(--kole-color-border);
border-radius: 8rpx;
background-color: var(--kole-color-table-header-bg);
color: var(--kole-color-text-secondary);
font-size: var(--kole-m-font-size-caption);
}
.kole-m-upload__body { flex: 1; min-width: 0; }
.kole-m-upload__name {
color: var(--kole-color-text-body);
font-size: var(--kole-m-font-size-body);
}
.kole-m-upload__meta {
display: flex;
align-items: center;
margin-top: 8rpx;
}
/* 进度条:宽度由宿主的百分比驱动(待上传不占宽,成功后满格) */
.kole-m-upload__track {
flex: 1;
min-width: 0;
height: 4rpx;
margin-right: 16rpx;
border-radius: 2rpx;
background-color: var(--kole-color-border);
}
.kole-m-upload__bar {
width: var(--kole-m-upload-percent);
height: 4rpx;
border-radius: 2rpx;
background-color: var(--kole-color-brand);
}
.kole-m-upload__item.is-uploading .kole-m-upload__bar { background-color: var(--kole-color-brand); }
.kole-m-upload__item.is-success .kole-m-upload__bar {
width: 100%;
background-color: var(--kole-color-success);
}
.kole-m-upload__item.is-error .kole-m-upload__bar {
width: 100%;
background-color: var(--kole-color-error);
}
.kole-m-upload__item.is-success .kole-m-upload__status { color: var(--kole-color-success); }
.kole-m-upload__item.is-error .kole-m-upload__status { color: var(--kole-color-error); }
.kole-m-upload__status {
flex-shrink: 0;
color: var(--kole-color-text-secondary);
font-size: var(--kole-m-font-size-label);
}
.kole-m-upload__actions {
display: flex;
align-items: center;
flex-shrink: 0;
}
.kole-m-upload__action {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
min-width: var(--kole-m-touch-target);
min-height: var(--kole-m-touch-target);
padding: 0 8rpx;
color: var(--kole-color-text-secondary);
font-size: var(--kole-m-font-size-label);
}
.kole-m-upload__action--remove { color: var(--kole-color-error); }
.kole-m-upload__empty {
display: block;
padding: 32rpx 0;
color: var(--kole-color-text-secondary);
font-size: var(--kole-m-font-size-label);
text-align: center;
}
</style>
测试与回归
断言在真实的 375×640 设备帧里跑(引擎与 PC 侧共用 tests/_runtime.js,触控行为动词来自移动端 tests/mobile/_behaviors.js)。
断言 19 条 · 全部通过 报告 2026-09-20 16:50:33
node site/dev-server.js &
REG_BASE=http://127.0.0.1:3311 node tools/run-mobile-regression.mjs # 全量 5 个组件
npm run verify:mobile-docs # 本页内容完整性 + API 与源码一致性
设计契约
components/mobile-upload.json(点击展开原始 JSON)
{
"schemaVersion": 1,
"sourceKind": "authored-spec",
"provenance": "authored-in-repo",
"specFile": "spec/移动端规格.md",
"specSection": "46 · 上传 Upload",
"confidence": "high",
"slug": "mobile-upload",
"name": "上传 Upload",
"semanticTypeCandidates": [
"upload",
"file-input",
"attachment"
],
"variantDimensions": [
{
"name": "variant",
"values": [
"dashed",
"button"
]
},
{
"name": "maxCount",
"values": [
"reached",
"not-reached"
]
}
],
"representativeVariants": [
{
"variant": "dashed",
"maxCount": "not-reached",
"label": "虚线框(页面级上传区)"
},
{
"variant": "button",
"maxCount": "not-reached",
"label": "实心按钮(列表内嵌的「+ 添加」)"
},
{
"variant": "dashed",
"maxCount": "reached",
"label": "到上限(触发器变成不可点的提示行)"
},
{
"variant": "dashed",
"maxCount": "not-reached",
"label": "禁用(触发器与删除按钮都不可用)"
}
],
"anatomy": {
"upload": "根元素,纵向排列「标题行 + 触发器 + 文件列表」",
"upload__header": "标题与计数(已选 N / 上限 M)",
"upload__trigger": "选择触发器,点击调起系统文件选择器;dashed 是虚线框,button 是实心按钮",
"upload__list": "文件列表容器",
"upload__item": "单个文件行,行高不小于 44px",
"upload__thumb": "缩略图位(图片用背景图,其他类型放扩展名文字块)",
"upload__body": "文件名与「进度条 + 状态文案」的容器",
"upload__track": "进度条轨道,role=progressbar",
"upload__bar": "进度段,宽度取 --kole-m-upload-percent",
"upload__actions": "行内动作(重试 / 删除),各自 44px 热区"
},
"structurePatterns": {
"variant": "dashed(虚线框,页面级上传区)/ button(实心按钮,列表内嵌的「+ 添加」)",
"maxCount": "文件数上限(到达上限后触发器变成提示行,不再可点)",
"disabled": "false(可交互)/ true(触发器与删除按钮都不可用)"
},
"usageHints": [
"把手机里的文件交给服务端(实名认证的身份证照、报销的发票、工单的附件)",
"移动端没有拖拽也没有 hover,触发按钮是唯一入口,虚线框只表达「这里可以放东西」",
"点触发器调起系统文件选择器(本组件不发请求,选完由宿主拿文件并自行上传)",
"进度不自己走:没有内置定时器或假进度,百分比全部由宿主回传",
"删除按钮自己 44px 热区,点一次移除该项;删除进行中(uploading)时文案是「取消」",
"失败行提供「重试」,重试由宿主重新发起,组件只回传下标"
],
"doNotInvent": [
"真实的传输:请求、分片、断点续传、并发数(本组件只回传事件,不发任何请求)",
"图片压缩、裁剪、水印与方向纠正",
"服务端的校验规则(大小上限、类型白名单以文案与 accept 表达,不代为判断)",
"拍照与相册的原生调起(由宿主在 select 事件里自行调用平台 API)"
],
"unknowns": [
"上传中能否同时继续添加文件(当前允许,列表各自独立)",
"失败自动重试的次数与退避策略",
"是否需要在成功行上展示服务端返回的文件 id / URL"
],
"interaction": [
"点触发器调起系统文件选择器(本组件不发请求,选完由宿主拿文件并自行上传)",
"删除按钮自己 44px 热区,点一次移除该项;删除进行中(uploading)时文案是「取消」",
"失败行提供「重试」,重试由宿主重新发起,组件只回传下标",
"进度不自己走:没有内置定时器或假进度,百分比全部由宿主回传,避免出现「看起来在上传其实没动」",
"列表为空时显示空态文案;到 maxCount 后触发器改成不可点的提示行"
],
"accessibility": [
"触发器是原生 button,带 aria-disabled;到上限时用 disabled 让键盘跳过",
"进度条写 role=\"progressbar\" + aria-valuemin / aria-valuemax / aria-valuenow,aria-label 说明是哪个文件的进度",
"删除 / 重试按钮各自带 aria-label(含文件名),读屏播报「删除 合同扫描件.pdf」而不是孤零零的「删除」",
"状态文案是文字而不只是颜色(「已上传」「上传失败」),颜色不作为唯一信息通道"
],
"api": {
"source": "implementation",
"note": "props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs",
"requiredNote": "「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。",
"props": [
{
"name": "files",
"type": "Array<{ name: string, uid?: string, status?: 'pending'|'uploading'|'success'|'error', percent?: number }>",
"default": "[]",
"desc": "文件列表(宿主提供,状态与进度都由宿主回传)(规格 §46.4)",
"required": false
},
{
"name": "maxCount",
"type": "number",
"default": "9",
"desc": "文件数上限,到达后触发器变成提示行(规格 §46.3)",
"required": false
},
{
"name": "disabled",
"type": "boolean",
"default": "false",
"desc": "变体 disabled:触发器与删除按钮都不可用(规格 §46.3)",
"required": false
},
{
"name": "variant",
"type": "'dashed' | 'button'",
"default": "'dashed'",
"desc": "变体 variant:dashed 虚线框(页面级),button 实心按钮(列表内嵌)(规格 §46.3)",
"required": false
},
{
"name": "title",
"type": "string",
"default": "'附件'",
"desc": "标题行文案(规格 §46.2 upload__header)",
"required": false
},
{
"name": "emptyText",
"type": "string",
"default": "'还没有上传文件'",
"desc": "状态 empty 的占位文案(规格 §46.4)",
"required": false
},
{
"name": "accept",
"type": "string",
"default": "''",
"desc": "可接受的文件类型(透传给文件选择器;服务端校验规则不由本组件代为判断)(规格 §46.7)",
"required": false
}
],
"events": [
{
"name": "select",
"params": "—",
"desc": "点触发器时触发,宿主在此调起系统文件选择器(规格 §46.5)"
},
{
"name": "remove",
"params": "index: number",
"desc": "点删除(uploading 时是取消)时触发,回传条目下标(规格 §46.5)"
},
{
"name": "retry",
"params": "index: number",
"desc": "失败行点重试时触发,回传条目下标(规格 §46.5)"
}
],
"slots": [
{
"name": "default",
"desc": "自定义内容(追加在文件列表之后)"
}
]
},
"variantClasses": {
"variant": {
"dashed": [
".kole-m-upload--dashed"
],
"button": [
".kole-m-upload--button"
]
},
"maxCount": {
"reached": [
".kole-m-upload--full"
],
"not-reached": []
}
},
"demos": [
{
"id": "trigger",
"group": "01 组件类型",
"title": "选择触发",
"desc": "点一下调起系统文件选择器:移动端没有拖拽,这是唯一入口。",
"variant": "variant=dashed"
},
{
"id": "states",
"group": "01 组件类型",
"title": "四种状态",
"desc": "待上传 / 上传中(60%)/ 成功 / 失败;进度由宿主回传,组件不发请求。",
"variant": "variant=button"
},
{
"id": "removable",
"group": "02 组件状态",
"title": "可删除",
"desc": "删除按钮自己 44px 热区;删完后回到空态。",
"variant": "状态 removable"
},
{
"id": "disabled",
"group": "02 组件状态",
"title": "禁用与到顶",
"desc": "disabled 时触发器与删除都不可用;到 maxCount 后触发器改成提示行。",
"variant": "状态 disabled | 变体 maxCount=reached"
}
],
"related": [
{
"slug": "mobile-avatar",
"why": "单张头像的更换是更窄的场景,用头像组件自带的编辑态,不需要整个上传列表"
},
{
"slug": "mobile-dialog",
"why": "上传前要用户确认条款或选择来源时用它承载一步确认"
},
{
"slug": "mobile-list",
"why": "只读的附件陈列(不可增删)用列表更轻,上传的列表带状态与动作"
},
{
"slug": "mobile-toast",
"why": "上传成功 / 失败的单次反馈用它,不必在列表里堆第二条状态文案"
},
{
"slug": "mobile-loading",
"why": "等待服务端返回文件地址这类短暂阻塞用它,进度有多少用上传自己的进度条"
}
]
}