# kex-file-uploader

附件上传组件（uni-app **Vue3**）：可选图片、PDF、Office 等任意文件，支持列表展示、进度上传、自定义 UI。

支持：**H5 / App（vue 页）/ 微信小程序 / 支付宝小程序 / 鸿蒙** 等主流端。

---

## 目录

1. [功能一览](#功能一览)
2. [安装](#安装)
3. [三种用法怎么选](#三种用法怎么选)
4. [快速上手](#快速上手)
5. [完整示例](#完整示例)
6. [Props](#props)
7. [Events](#events)
8. [方法 ref](#方法-ref)
9. [插槽](#插槽)
10. [v-model 数据结构](#v-model-数据结构)
11. [上传与响应解析](#上传与响应解析)
12. [多端说明](#多端说明)
13. [常见问题](#常见问题)
14. [注意事项](#注意事项)
15. [版本](#版本)

---

## 功能一览

| 能力 | 说明 |
| --- | --- |
| 任意文件 | 图片 / 文档 / 压缩包等；`formats` 为空则不限制（后端不拦即可） |
| 单次多选 | `multiple` 默认 `true`，受 `maxCount` 剩余名额限制 |
| 内置列表 | 文件名、大小、进度、删除、重试、取消；图片缩略图 |
| 自定义按钮 | `#trigger` 完全自定义，或 `show-trigger=false` + 外部按钮 |
| 自定义列表 | `#item` 改每一行，或 `mode="headless"` 完全自绘 |
| 自动 / 手动上传 | `autoUpload` + `uploadUrl`；也可 `ref.uploadAll()` |
| 上传配置 | `uploadOptions`：`name` / `header` / `formData`（有默认值，只填 url 也能传） |
| 并发 / 取消 | `concurrent`（默认 3）、`abort` / `abortAll` |
| 响应解析 | `responseUrlKey` / `parseResponse` |
| 只读 / 确认删 | `readonly`、`beforeRemove` |

---

## 安装

将插件拷贝到业务项目：

```text
你的项目/uni_modules/kex-file-uploader/
```

uni-app **easycom** 会自动注册，页面里直接写标签，无需 `import`。

要求：

- uni-app **Vue3**
- App 请使用 **vue 页面**（不要用 nvue；App 选任意文件依赖 renderjs）

演示页（本示例工程）：首页 → `kex-file-uploader`，或路径 `/pages/demos/file-uploader/index`。

---

## 三种用法怎么选

| 场景 | 写法 | 内置按钮 | 内置列表 |
| --- | --- | --- | --- |
| 开箱即用 | 默认 `mode="list"` | ✅ | ✅ |
| 按钮自己画，列表用默认 | `:show-trigger="false"` + `ref.choose()` | ❌ | ✅ |
| 按钮和列表都自己画 | `mode="headless"` + `ref.choose()` + 自己 `v-for` | ❌ | ❌ |

`show-trigger="false"` / `headless` 时，组件可放在页面任意位置，只要 `ref` 能调到即可。

**其它 Props（`uploadUrl`、`multiple`、`maxCount`、`formats` 等）在三种模式下都生效。**

---

## 快速上手

### 1. 只选文件（不上传）

```vue
<template>
  <kex-file-uploader v-model="files" :max-count="5" />
</template>

<script setup>
import { ref } from 'vue'
const files = ref([])
</script>
```

### 2. 自动上传（最少只填接口）

`name` / `header` / `formData` 有默认值，只配 `upload-url` 即可发起 `uni.uploadFile`。

```vue
<template>
  <kex-file-uploader
    v-model="files"
    :auto-upload="true"
    upload-url="https://example.com/api/upload"
    response-url-key="data.url"
    @uploadSuccess="onOk"
    @uploadFail="onFail"
  />
</template>

<script setup>
import { ref } from 'vue'
const files = ref([])
const onOk = (e) => console.log('成功', e.url, e.data)
const onFail = (e) => console.log('失败', e.err)
</script>
```

### 3. 带 Token / 业务字段

```vue
<script setup>
import { ref, computed } from 'vue'

const files = ref([])
const token = ref('Bearer xxx')

const uploadOptions = computed(() => ({
  // name 默认 'file'，可不写
  header: { Authorization: token.value },
  // 值必须是字符串
  formData: { folderId: '123', module: 'attach' }
}))
</script>

<template>
  <kex-file-uploader
    v-model="files"
    :auto-upload="true"
    upload-url="https://example.com/api/upload"
    :upload-options="uploadOptions"
  />
</template>
```

### 4. 限制类型、大小、多选

```vue
<!-- 单次可多选，列表最多 9 个；仅 pdf/word；单文件 ≤ 10MB -->
<kex-file-uploader
  v-model="files"
  :max-count="9"
  :multiple="true"
  :max-size="10"
  formats="pdf,doc,docx"
/>

<!-- 单次只能选 1 个，仍可多次点选直到 maxCount -->
<kex-file-uploader v-model="files" :max-count="9" :multiple="false" />
```

### 5. 删除前确认

```vue
<script setup>
const beforeRemove = ({ file }) =>
  new Promise((resolve) => {
    uni.showModal({
      title: '确认删除',
      content: `删除「${file.name}」？`,
      success: (r) => resolve(!!r.confirm),
      fail: () => resolve(false)
    })
  })
</script>

<template>
  <kex-file-uploader v-model="files" :before-remove="beforeRemove" />
</template>
```

### 6. 只读回显

```vue
<script setup>
const files = ref([
  { name: '合同.pdf', url: 'https://cdn.example.com/a.pdf', size: 102400, status: 'success' }
])
</script>

<template>
  <kex-file-uploader v-model="files" :readonly="true" />
</template>
```

### 7. 自定义选择按钮（保留默认列表）

```vue
<!-- 方式 A：插槽完全替换按钮 UI -->
<kex-file-uploader v-model="files" :max-count="5">
  <template #trigger="{ choose, count, maxCount }">
    <view class="my-btn" @click="choose">上传材料 {{ count }}/{{ maxCount }}</view>
  </template>
</kex-file-uploader>

<!-- 方式 B：隐藏内置按钮，外部任意位置调用 -->
<kex-file-uploader ref="fu" v-model="files" :show-trigger="false" />
<button @click="fu.choose()">选择附件</button>
```

### 8. 自定义列表行（保留组件结构）

```vue
<kex-file-uploader v-model="files">
  <template #item="{ file, remove, retry, abort, preview }">
    <view @click="preview">
      <text>{{ file.name }} · {{ file.status }}</text>
      <text v-if="file.status === 'uploading'" @click.stop="abort">取消</text>
      <text v-else-if="file.status === 'fail'" @click.stop="retry">重试</text>
      <text v-else @click.stop="remove">删除</text>
    </view>
  </template>
</kex-file-uploader>
```

### 9. 外部打开 + 完全自定义列表（headless）

无内置按钮、无内置列表；组件可藏在页面任意处。

```vue
<template>
  <kex-file-uploader
    ref="fu"
    v-model="files"
    mode="headless"
    :max-count="9"
    :multiple="true"
    :auto-upload="true"
    upload-url="https://example.com/api/upload"
    :upload-options="uploadOptions"
    @select="onSelect"
    @uploadSuccess="onOk"
    @uploadFail="onFail"
  />

  <button @click="fu.choose()">选择附件</button>
  <button @click="fu.uploadAll()">上传全部</button>
  <button @click="fu.clear()">清空</button>

  <view v-for="(f, i) in files" :key="f.uid || i">
    <text>{{ f.name }} · {{ f.status }} {{ f.progress }}%</text>
    <text v-if="f.status === 'uploading'" @click="fu.abort(i)">取消</text>
    <text v-else-if="f.status === 'fail'" @click="fu.upload(i)">重试</text>
    <text @click="files.splice(i, 1)">删除</text>
  </view>
</template>

<script setup>
import { ref, computed } from 'vue'

const fu = ref(null)
const files = ref([])
const uploadOptions = computed(() => ({
  header: { Authorization: 'Bearer xxx' }
}))

const onSelect = (e) => console.log('选中', e.count, e.fileList)
const onOk = (e) => console.log('成功', e.url, e.data)
const onFail = (e) => console.log('失败', e.err)
</script>
```

### 10. 手动上传（先选后传）

```vue
<template>
  <kex-file-uploader
    ref="fu"
    v-model="files"
    :auto-upload="false"
    upload-url="https://example.com/api/upload"
  />
  <button @click="fu.uploadAll()">开始上传</button>
</template>
```

---

## 完整示例

业务页常见写法（自动上传 + Token + 确认删除 + 事件）：

```vue
<template>
  <kex-file-uploader
    v-model="files"
    :max-count="9"
    :max-size="20"
    :multiple="true"
    formats=""
    :auto-upload="true"
    upload-url="https://example.com/api/upload"
    :upload-options="uploadOptions"
    response-url-key="data.url"
    :concurrent="3"
    :before-remove="beforeRemove"
    @select="onSelect"
    @change="onChange"
    @uploadSuccess="onOk"
    @uploadFail="onFail"
    @uploadAbort="onAbort"
    @remove="onRemove"
  />
</template>

<script setup>
import { ref, computed } from 'vue'

const files = ref([])
const token = ref('') // 从登录态取值

const uploadOptions = computed(() => ({
  name: 'file',
  header: token.value ? { Authorization: token.value } : {},
  formData: { module: 'attachment' }
}))

const beforeRemove = ({ file }) =>
  new Promise((resolve) => {
    uni.showModal({
      title: '确认删除',
      content: `删除「${file.name}」？`,
      success: (r) => resolve(!!r.confirm)
    })
  })

const onSelect = (e) => console.log('选图', e.count)
const onChange = (e) => console.log('列表', e.fileList)
const onOk = (e) => console.log('成功', e.url, e.data)
const onFail = (e) => console.log('失败', e.err)
const onAbort = (e) => console.log('取消', e.file)
const onRemove = (e) => console.log('删除', e.index, e.file)
</script>
```

提交表单时，一般只取已成功项的远程地址：

```js
const urls = files.value
  .filter((f) => f.status === 'success' && f.url)
  .map((f) => f.url)
```

---

## Props

| 属性 | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| modelValue / v-model | `Array` | `[]` | 文件列表，见下方结构 |
| mode | `String` | `'list'` | `list` 带列表 UI；`headless` 无 UI 纯能力 |
| maxCount | `Number` | `9` | 列表最多文件数 |
| multiple | `Boolean` | `true` | 单次能否多选；`false` 则每次只能选 1 个 |
| maxSize | `Number` | `20` | 单文件上限 MB；`0` 表示不限制 |
| formats | `String \| Array` | `''` | 允许扩展名，空=不限制。如 `'pdf,png'` 或 `['pdf','png']` |
| fileType | `String` | `'all'` | 选文件类型提示：`all` / `image` / `video` / `file` |
| accept | `String` | `''` | H5 / App 原生 input 的 accept，如 `image/*`、`.pdf,.doc` |
| disabled | `Boolean` | `false` | 禁用选择 / 删除 / 上传 |
| readonly | `Boolean` | `false` | 只读展示，不可选、不可删、不可传 |
| showRemove | `Boolean` | `true` | 是否显示删除 |
| showCount | `Boolean` | `true` | 默认按钮上是否显示数量 |
| showThumb | `Boolean` | `true` | 图片是否显示缩略图 |
| addText | `String` | `'添加附件'` | 默认选择按钮文案 |
| showTrigger | `Boolean` | `true` | 是否显示内置选择按钮；`false` 时用外部 `ref.choose()` |
| triggerPosition | `String` | `'bottom'` | 内置按钮位置：`bottom` / `top` |
| addClass | `String \| Array \| Object` | `''` | 默认按钮额外 class |
| addStyle | `Object \| String` | `{}` | 默认按钮内联样式 |
| toast | `Boolean` | `true` | 超限等是否 toast |
| autoUpload | `Boolean` | `false` | 选完是否自动上传 |
| uploadUrl | `String` | `''` | 上传接口（对应 `uni.uploadFile` 的 url） |
| uploadOptions | `Object` | 见下表 | 上传参数 |
| concurrent | `Number` | `3` | 并发上传数；≤0 按 1 处理 |
| responseUrlKey | `String` | `''` | 从响应取远程 url 的路径，如 `data.url`、`data.fileUrl` |
| parseResponse | `Function` | `null` | `(raw, res) => url \| { url, data }`，优先级最高 |
| beforeRemove | `Function` | `null` | `({ index, file }) => boolean \| Promise`，返回 `false` 拦截删除 |

### uploadOptions

对应 `uni.uploadFile`，未传字段自动补默认，**只填 `uploadUrl` 也能上传**：

| 字段 | 默认 | 说明 |
| --- | --- | --- |
| name | `'file'` | 文件字段名 |
| header | `{}` | 请求头（可用 computed 动态带 Token） |
| formData | `{}` | 额外表单字段；**值必须是字符串** |
| method | `'POST'` | App（Android/iOS）XHR 方法；其它端仍走 `uni.uploadFile`（POST） |
| toBase | `false` | App：以 Base64 字符串作为字段值上传 |
| withCredentials | `false` | App XHR 是否跨域携带 cookie（需服务端 CORS 允许 credentials） |

```js
{
  name: 'file',
  header: { Authorization: 'Bearer xxx' },
  formData: { bizId: '1', type: 'attach' },
  method: 'POST',
  // toBase: true, // App 需要 Base64 字段时
  withCredentials: true // iOS 本地测远程且要带 cookie
}
```

另有 props：`debug`（日志）、`distinct`（同名覆盖，默认重命名）。

### 关于跨域（CORS）

跨域需服务端配置 CORS，组件侧可配合 `withCredentials`：

- **Android/iOS App**：webview **XHR** → 需服务端 CORS。  
- **鸿蒙 / H5 / 小程序**：`uni.uploadFile`，同样要服务端允许跨域。  
- 带 cookie：`uploadOptions.withCredentials: true` + 服务端 `Access-Control-Allow-Credentials: true`（Origin 不能是 `*`）。

---

## Events

| 事件 | 触发时机 | 回调参数 |
| --- | --- | --- |
| update:modelValue | 列表变化 | `fileList`（同 v-model） |
| change | 列表任意变化 | `{ fileList, ... }` |
| select | 每次选文件完成 | 见下方 |
| uploadSuccess | 单文件上传成功 | 见下方 |
| uploadFail | 单文件上传失败 | `{ index, file, err, statusCode?, data?, res? }` |
| uploadAbort | 取消上传 | `{ index, file }` |
| remove | 删除（内置删除按钮） | `{ index, file }` |
| oversize | 单文件超体积 | `{ name, path, size, maxSize }` |
| exceed | 超过 maxCount | `{ maxCount }` |
| preview | 点击预览 | `{ file }` |

### `@select`

```js
{
  files,     // 本次新增：与 v-model 单项同结构
  count,     // 本次新增数量
  fileList   // 当前全部
}
```

### `@uploadSuccess`

```js
{
  index,
  file,        // 当前文件
  url,         // 优先为解析出的远程地址
  statusCode,
  data,        // 解析后的服务端 JSON（失败则为原始字符串）
  res,         // 原始回调
  fileList     // 当前全部
}
```

### `@change`

```js
{
  fileList   // 当前全部文件
}
```

---

## 方法（ref）

```vue
<kex-file-uploader ref="fu" v-model="files" upload-url="..." />
```

| 方法 | 说明 |
| --- | --- |
| `choose()` | 打开系统选文件 |
| `chooseFile({ success, fail, count, ... })` | 回调式选文件（可带 count/formats/size 等） |
| `upload(index)` | 上传指定下标 |
| `upload({ url, file, success, fail, onprogress, ... })` | 命令式上传单文件 |
| `upload()` / `uploadAll()` | 上传所有 `ready` / `fail` 项（受 `concurrent` 控制） |
| `abort(index)` | 取消指定上传中的项 |
| `abortAll()` | 取消全部上传中 |
| `clear()` | 清空列表（会先 abort） |
| `getFileList()` | 返回当前列表副本 |
| `openDocument({ file, success, fail })` | 打开文档预览 |
| `getTempFilePath` / `getBase` / `getArrayBuffer` | 读临时路径 / Base64 / ArrayBuffer |

```js
fu.value.choose()
fu.value.uploadAll()
fu.value.abort(0)
fu.value.clear()
console.log(fu.value.getFileList())

// 回调式选文件 / 命令式上传
fu.value.chooseFile({
  count: 3,
  success: (files) => console.log(files)
})
fu.value.upload({
  url: 'https://xxx/upload',
  file: files[0],
  header: { Authorization: 'Bearer xxx' },
  success: (e) => console.log(e.result),
  fail: console.error,
  onprogress: (e) => console.log(e.progress)
})
```

---

## 插槽

| 插槽 | 说明 | 作用域参数 |
| --- | --- | --- |
| `trigger` | **完全自定义**选择按钮（推荐）；达上限或 disabled/readonly 时不渲染 | `choose`、`count`、`maxCount`、`disabled`、`readonly` |
| `add` | 只替换默认虚线框里的内容 | `count`、`maxCount` |
| `item` | 完全自定义每一行列表 | `file`、`index`、`remove`、`retry`、`abort`、`preview` |

点击 `trigger` 里请调用插槽传入的 `choose()`（或外部 `ref.choose()`）。

---

## v-model 数据结构

每一项大致为：

```js
{
  uid: 'kex-fu-...',   // 内部唯一 id
  name: '报告.pdf',
  url: 'https://...',  // 成功后优先为远程地址；App 选中未上传时可能为空
  path: '',            // 本地路径（上传用；App 选任意文件时可能为空）
  size: 102400,        // 字节
  ext: 'pdf',
  status: 'success',   // ready | uploading | success | fail
  progress: 100,       // 0~100
  message: ''          // 失败等原因文案
}
```

| status | 含义 |
| --- | --- |
| ready | 已选中，待上传 |
| uploading | 上传中 |
| success | 成功（回显远程文件也是此状态） |
| fail | 失败或已取消，可重试 |

说明：

- App 选图会额外在内部保留 `thumb`（缩略图 dataURL）用于展示，**不会**把大段 base64 写进 `v-model` 的 `url`
- 提交后端时建议只提交 `status === 'success'` 的 `url` / `name` 等业务字段

---

## 上传与响应解析

底层：非 App 用 `uni.uploadFile`；App 用 renderjs + `XMLHttpRequest`。

成功条件：HTTP 状态码 `2xx`。

远程地址解析优先级：

1. **`parseResponse`**（最高）  
2. **`responseUrlKey`**（如 `data.url`、`data.fileUrl`）  
3. 内置字段：`url` / `path` / `data.url` / `data.path` / `data.fileUrl` / `result.url`

```vue
<!-- 方式 A：路径取值 -->
<kex-file-uploader response-url-key="data.url" ... />

<!-- 方式 B：自定义函数 -->
<kex-file-uploader
  :parse-response="(raw) => {
    const d = typeof raw === 'string' ? JSON.parse(raw) : raw
    return (d && d.data && d.data.fileUrl) || ''
  }"
  ...
/>
```

也可在 `@uploadSuccess` 里用 `e.data` 自行处理，再改 `v-model`。

---

## 多端说明

| 端 | 选文件方式 | 上传 | 多选 |
| --- | --- | --- | --- |
| H5 | `uni.chooseFile` | `uni.uploadFile` | ✅ |
| 微信 / QQ | `chooseMessageFile`（聊天会话） | `uni.uploadFile` | ✅ |
| 支付宝 | 图片相册 / 其它 `chooseFileFromDisk` | `uni.uploadFile` | 本地多为单选 |
| **Android / iOS App** | renderjs `<input type="file">` | renderjs **XHR** | ✅（个别安卓管理器可能单选） |
| **鸿蒙 App** | **`uni.chooseFile`**（**不走 renderjs**） | **`uni.uploadFile`** | 以系统能力为准 |

说明：uni-app 里 **`APP-PLUS` 不含鸿蒙**；鸿蒙走 `#ifndef APP-PLUS` 的选传链路。不要把「App = 必须 renderjs」理解成鸿蒙也要 renderjs。

微信选文件来自聊天记录、支付宝本地单选等，属于**平台能力限制**。

---

## 常见问题

**Q：外部打开时，其它 props 还能用吗？**  
能。`show-trigger="false"` / `headless` 只影响 UI，上传、限制、多选、事件等全部照常。

**Q：组件必须和按钮放在一起吗？**  
不必。组件放页面任意位置（甚至视觉上藏起来），按钮在别处调 `ref.choose()` 即可。注意不要把组件 `v-if` 掉，否则 ref 为空。

**Q：为什么没有多选？**  
确认 `multiple` 为 `true`（默认）且 `maxCount > 1`。支付宝本地文件、部分安卓系统选择器可能仍只能单选，可多次点选凑满。

**Q：App 真机图片只有「图」字、不能预览？**  
App 无本地 path 时用缩略图 + 内置全屏预览。需重新编译运行。

**Q：如何判断都传完了？**  
```js
const allDone = files.value.every(
  (f) => f.status === 'success' || f.status === 'fail'
)
const allOk = files.value.length && files.value.every((f) => f.status === 'success')
```

**Q：控制台一直刷「uni统计 上报超时」？**  
与本组件无关。在项目 `manifest.json` 中设置：

```json
"uniStatistics": { "enable": false }
```

然后重新编译。

---

## 注意事项

1. **仅 Vue3** + `uni_modules` easycom。  
2. **`formData` 的值必须是字符串**（uni 规范），如 `folderId: '123'`。  
3. **自动上传**需同时：`autoUpload === true` 且 `uploadUrl` 非空。  
4. **`mode="headless"`** 不渲染列表/按钮，请自行 `v-model` + `ref` 方法。  
5. **Android/iOS App 用 vue 页**（renderjs），不要用 nvue；**鸿蒙**用 `uni.chooseFile`，无需 renderjs。  
6. 删除：内置按钮走 `beforeRemove` + `@remove`；headless 下自己改数组时不会走 `beforeRemove`，需业务自行确认。  
7. 各端对「任意文件」支持程度不同，以真机为准；`formats` 为空时组件不做类型拦截。

---

## 版本

当前能力见同目录 [changelog.md](./changelog.md)。
