移动端导航上传

上传Upload

把手机里的文件交给服务端(实名认证的身份证照、报销的发票、工单的附件)

数据录入 规格 46 · 上传 Upload 6 端实现 触摸优先

引入(H5 原生;其余 5 端见「快速开始」)
<!-- ① 令牌: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 组件类型

选择触发variant=dashed

点一下调起系统文件选择器:移动端没有拖拽,这是唯一入口。

查看代码(演示页原文 · 16 行)
frameworks-mobile/Upload.html · trigger
<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>
四种状态variant=button

待上传 / 上传中(60%)/ 成功 / 失败;进度由宿主回传,组件不发请求。

查看代码(演示页原文 · 78 行)
frameworks-mobile/Upload.html · states
<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 组件状态

可删除状态 removable

删除按钮自己 44px 热区;删完后回到空态。

查看代码(演示页原文 · 38 行)
frameworks-mobile/Upload.html · removable
<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=reached

disabled 时触发器与删除都不可用;到 maxCount 后触发器改成提示行。

查看代码(演示页原文 · 48 行)
frameworks-mobile/Upload.html · disabled
<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

名称类型默认值说明必传
filesArray<{ name: string, uid?: string, status?: 'pending'|'uploading'|'success'|'error', percent?: number }>[]文件列表(宿主提供,状态与进度都由宿主回传)(规格 §46.4)N
maxCountnumber9文件数上限,到达后触发器变成提示行(规格 §46.3)N
disabledbooleanfalse变体 disabled:触发器与删除按钮都不可用(规格 §46.3)N
variant'dashed' | 'button''dashed'变体 variant:dashed 虚线框(页面级),button 实心按钮(列表内嵌)(规格 §46.3)N
titlestring'附件'标题行文案(规格 §46.2 upload__header)N
emptyTextstring'还没有上传文件'状态 empty 的占位文案(规格 §46.4)N
acceptstring''可接受的文件类型(透传给文件选择器;服务端校验规则不由本组件代为判断)(规格 §46.7)N

「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。

事件

名称参数说明
select—点触发器时触发,宿主在此调起系统文件选择器(规格 §46.5)
removeindex: number点删除(uploading 时是取消)时触发,回传条目下标(规格 §46.5)
retryindex: number失败行点重试时触发,回传条目下标(规格 §46.5)

插槽

名称说明
default自定义内容(追加在文件列表之后)

CSS 变量

组件级变量(在组件样式表里定义)。业务侧可在自己的作用域内覆盖,不必改组件源码。

名称默认值说明
--kole-m-upload-thumb40px缩略图/文件图标边长
--kole-m-upload-row-heightvar(--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 校验(类/变量必须真实存在)。

维度取值对应类名 / 变量
variantdashed / button
dashed .kole-m-upload--dashed
button .kole-m-upload--button
maxCountreached / 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 令牌(改一处两端生效)。

--kole-m-duration-slide --kole-m-ease-slide --kole-m-font-size-body --kole-m-font-size-caption --kole-m-font-size-label --kole-m-gutter --kole-m-touch-target --kole-color-border --kole-color-brand --kole-color-brand-active --kole-color-brand-bg --kole-color-card-bg --kole-color-disabled-bg --kole-color-error --kole-color-focus-ring --kole-color-mask --kole-color-success --kole-color-table-header-bg --kole-color-text-body --kole-color-text-disabled --kole-color-text-inverse --kole-color-text-secondary --kole-color-text-title --kole-duration-base --kole-ease-standard --kole-font-family --kole-font-family-num --kole-radius-base --kole-space-12 --kole-space-16 --kole-space-4 --kole-space-8 --kole-m-upload-percent --kole-m-upload-row-height --kole-m-upload-thumb

6 端源码

同一组件的六份实现(生产环境的类名与结构一致,差异只在技术栈写法与单位)。点开查看,右侧可复制。

frameworks-mobile/Upload.css · 纯样式(CSS) · 334 行
frameworks-mobile/Upload.css
/* 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 行
frameworks-mobile/Upload.html
<!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 行
frameworks-mobile/Upload.jsx
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 行
frameworks-mobile/Upload.vue2.vue
<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 行
frameworks-mobile/Upload.vue3.vue
<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 行
frameworks-mobile/Upload.uniapp.vue
<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": "等待服务端返回文件地址这类短暂阻塞用它,进度有多少用上传自己的进度条"
    }
  ]
}