Admin 元件

File Upload

參考
多檔上傳。拖曳、驗證、File 與 URL 並存;上傳由父層監聽 upload 事件處理。

安裝

Terminal
pnpm dlx shadcn-vue add @admin/file-upload
import {  AdminFileUpload,  AdminFileUploadClear,  AdminFileUploadDropzone,  AdminFileUploadItem,  AdminFileUploadItemDelete,  AdminFileUploadItemMetadata,  AdminFileUploadItemPreview,  AdminFileUploadItemProgress,  AdminFileUploadList,  AdminFileUploadTrigger,} from '@/components/admin/file-upload'

用法

<script setup lang="ts">import type { AdminFileUploadListItem } from '@/components/admin/file-upload'import { IconRefresh } from '@tabler/icons-vue'import { ref } from 'vue'import { cn } from '@/lib/utils'import {  AdminFileUpload,  AdminFileUploadClear,  AdminFileUploadDropzone,  AdminFileUploadItem,  AdminFileUploadItemDelete,  AdminFileUploadItemMetadata,  AdminFileUploadItemPreview,  AdminFileUploadItemProgress,  AdminFileUploadList,  AdminFileUploadTrigger} from '@/components/admin/file-upload'import { AdminNotification, errorNotification } from '@/components/admin/notification'​const items = ref<AdminFileUploadListItem[]>([])​const MAX_FILES = 5const MAX_SIZE = 5 * 1024 * 1024​function validate(file: File, ctx: { total: number }): true | string {  if (ctx.total >= MAX_FILES) {    return `最多 ${MAX_FILES} 個檔案`  }  if (file.size > MAX_SIZE) {    return `檔案超過大小上限(5 MB)`  }  return true}​function onFileReject({ reason, file }: { reason: string; file?: File }) {  errorNotification('檔案無法加入', file ? `${file.name}:${reason}` : reason)}​async function handleUpload({ id }: { id: string; file: File }) {  const findIndex = () => items.value.findIndex((it) => it.id === id)  const patch = (p: Partial<AdminFileUploadListItem>) => {    const i = findIndex()    if (i === -1) {      return    }    const copy = [...items.value]    copy[i] = { ...copy[i]!, ...p }    items.value = copy  }​  const willFail = Math.random() < 0.4  const failAt = 50​  patch({ status: 'uploading', progress: 0, errorMessage: undefined })​  for (let p = 25; p <= 100; p += 25) {    await new Promise((r) => setTimeout(r, 180))    if (willFail && p >= failAt) {      patch({ status: 'failed', errorMessage: '網路錯誤,請重試' })      return    }    patch({ progress: p })  }  patch({ status: 'success', progress: 100 })}​function retry(item: AdminFileUploadListItem) {  if (item.source instanceof File) {    handleUpload({ id: item.id, file: item.source })  }}</script>​<template>  <admin-notification />  <admin-file-upload    v-model="items"    class="w-full max-w-lg"    :on-file-validate="validate"    @file-reject="onFileReject"    @upload="handleUpload"  >    <admin-file-upload-dropzone>      <p class="typo-title-small text-text-90">拖曳檔案到此或選取檔案</p>      <admin-file-upload-trigger        :class="          cn(`            rounded-lg border border-primary-50 px-3 py-2 typo-caption-small text-primary-50            hover:bg-primary-10          `)        "      >        選擇檔案      </admin-file-upload-trigger>    </admin-file-upload-dropzone>    <admin-file-upload-clear class="self-start" label="清除全部" />    <admin-file-upload-list v-if="items.length">      <admin-file-upload-item v-for="it in items" :key="it.id" :item-id="it.id">        <admin-file-upload-item-preview />        <div class="flex min-w-0 flex-1 flex-col gap-2">          <admin-file-upload-item-metadata>            <p              v-if="it.status === 'failed' && it.errorMessage"              class="typo-caption-small text-error-50"            >              {{ it.errorMessage }}            </p>          </admin-file-upload-item-metadata>          <admin-file-upload-item-progress            :labels="{ uploading: '上傳中', success: '上傳成功', failed: '上傳失敗' }"          />        </div>        <button          v-if="it.status === 'failed'"          aria-label="重試上傳"          type="button"          :class="            cn(`              flex size-9 shrink-0 items-center justify-center rounded-lg text-text-50              hover:bg-bg-70 hover:text-text-90            `)          "          @click="retry(it)"        >          <icon-refresh class="size-5" />        </button>        <admin-file-upload-item-delete label="移除檔案" />      </admin-file-upload-item>    </admin-file-upload-list>  </admin-file-upload></template>

場景

僅按鈕、不要 Dropzone

<script setup lang="ts">import type { AdminFileUploadListItem } from '@/components/admin/file-upload'import { ref } from 'vue'import { cn } from '@/lib/utils'import {  AdminFileUpload,  AdminFileUploadClear,  AdminFileUploadItem,  AdminFileUploadItemDelete,  AdminFileUploadItemMetadata,  AdminFileUploadItemPreview,  AdminFileUploadList,  AdminFileUploadTrigger} from '@/components/admin/file-upload'​const items = ref<AdminFileUploadListItem[]>([])</script>​<template>  <admin-file-upload v-model="items" class="w-full max-w-lg" :multiple="false">    <div class="flex flex-wrap items-center gap-2">      <admin-file-upload-trigger        :class="          cn(`            rounded-lg bg-primary-50 px-3 py-2 typo-caption-small text-text-10            hover:opacity-90          `)        "      >        從電腦上傳      </admin-file-upload-trigger>      <span class="typo-caption-small text-text-50">(此 demo 不含 Dropzone,僅 Trigger)</span>    </div>    <admin-file-upload-clear v-if="items.length" class="self-start" label="清除全部" />    <admin-file-upload-list v-if="items.length" class="mt-2">      <admin-file-upload-item v-for="it in items" :key="it.id" :item-id="it.id">        <admin-file-upload-item-preview />        <admin-file-upload-item-metadata />        <admin-file-upload-item-delete label="移除檔案" />      </admin-file-upload-item>    </admin-file-upload-list>  </admin-file-upload></template>

程式寫入 URL(不經檔案選擇器)

由素材庫、過往上傳紀錄等來源直接寫入 URL 項目,跳過檔案選擇器流程。

<script setup lang="ts">import type { AdminFileUploadListItem } from '@/components/admin/file-upload'import { ref } from 'vue'import { cn } from '@/lib/utils'import {  AdminFileUpload,  AdminFileUploadClear,  AdminFileUploadItem,  AdminFileUploadItemDelete,  AdminFileUploadItemMetadata,  AdminFileUploadItemPreview,  AdminFileUploadList} from '@/components/admin/file-upload'​const items = ref<AdminFileUploadListItem[]>([])​function addRemoteImage() {  items.value = [    ...items.value,    {      id:        globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`,      source: 'https://picsum.photos/id/237/200/200',      status: 'success',      progress: 100    }  ]}</script>​<template>  <admin-file-upload v-model="items" class="w-full max-w-lg">    <p class="typo-caption-small text-text-50">      模擬「素材庫」或其它來源:由外層按鈕直接寫入 URL 項目(不經檔案選擇器)。    </p>    <button      type="button"      :class="        cn(`          rounded-lg border border-primary-50 px-3 py-2 typo-caption-small text-primary-50          hover:bg-primary-10        `)      "      @click="addRemoteImage"    >      新增一筆圖片 URL    </button>    <admin-file-upload-clear class="self-start" label="清除全部" />    <admin-file-upload-list v-if="items.length" class="mt-2">      <admin-file-upload-item v-for="it in items" :key="it.id" :item-id="it.id">        <admin-file-upload-item-preview />        <admin-file-upload-item-metadata>          <p class="typo-caption-small text-text-50">此列為 slot:可放影片時長、上傳日期等</p>        </admin-file-upload-item-metadata>        <admin-file-upload-item-delete label="移除檔案" />      </admin-file-upload-item>    </admin-file-upload-list>  </admin-file-upload></template>

Type-safe 列表項目

AdminFileUpload 已是 generic(TItem extends AdminFileUploadListItem),以子型別的 ref 綁定 v-model 即可讓列表項目攜帶業務欄位(如後端回傳的資產 id),update:modelValue payload 同步收斂為該子型別。

元件自行新增的項目(使用者選檔)僅含 base 欄位(id/source);自訂欄位由呼叫端於 upload 事件後自行補寫。

需開啟 strictVModel

型別檢查需開啟 strictVModel(若已開啟 strictTemplates 則已包含)。設定見 Type-safe。下列同時含合法綁定與刻意錯誤綁定;錯誤處以 @vue-expect-error 標示。

<script setup lang="ts">import type { AdminFileUploadListItem } from '@/components/admin/file-upload'import { ref } from 'vue'import {  AdminFileUpload,  AdminFileUploadDropzone,  AdminFileUploadItem,  AdminFileUploadItemDelete,  AdminFileUploadItemMetadata,  AdminFileUploadItemPreview,  AdminFileUploadList,  AdminFileUploadTrigger} from '@/components/admin/file-upload'​interface AttachmentItem extends AdminFileUploadListItem {  /** 上傳完成後由後端回傳之資產 id,由呼叫端於 upload 事件後補寫 */  assetId?: string}​const items = ref<AttachmentItem[]>([])const wrongItems = ref<{ id: string }[]>([])</script>​<template>  <div class="flex w-full max-w-md flex-col gap-6">    <admin-file-upload v-model="items">      <admin-file-upload-dropzone>        <admin-file-upload-trigger>選擇檔案</admin-file-upload-trigger>      </admin-file-upload-dropzone>      <admin-file-upload-list>        <admin-file-upload-item v-for="it in items" :key="it.id" :item-id="it.id">          <admin-file-upload-item-preview />          <admin-file-upload-item-metadata />          <admin-file-upload-item-delete />        </admin-file-upload-item>      </admin-file-upload-list>    </admin-file-upload>​    <!-- @vue-expect-error 項目缺 source 必要欄位,不符 AdminFileUploadListItem 約束 -->    <admin-file-upload v-model="wrongItems" />  </div></template>

API 參考

組成

<template>  <admin-file-upload v-model="items" :on-file-validate="validate" @upload="handleUpload">    <admin-file-upload-dropzone>      <admin-file-upload-trigger>選擇檔案</admin-file-upload-trigger>    </admin-file-upload-dropzone>    <admin-file-upload-clear />    <admin-file-upload-list>      <admin-file-upload-item v-for="it in items" :key="it.id" :item-id="it.id">        <admin-file-upload-item-preview />        <admin-file-upload-item-metadata />        <admin-file-upload-item-progress />        <admin-file-upload-item-delete />      </admin-file-upload-item>    </admin-file-upload-list>  </admin-file-upload></template>

admin-file-upload

Props

名稱型別預設值說明
classClassValue—根元素之自訂 class
disabledboolean | undefinedfalse設為 `true` 時禁用整個上傳互動
invalidboolean | undefinedfalse設為 `true` 時套用錯誤狀態樣式
multipleboolean | undefinedtrue設為 `false` 時限制單檔上傳
acceptstring | undefined—可接受之檔案類型(同原生 `<input type="file">` accept)
onFileValidate((file: File, ctx: { index: number; accepted: number; total: number; }) => string | true | Promise<string | true>) | undefined—自訂單檔驗證 hook;回傳 `true` 為通過,回傳字串為錯誤訊息
modelValueTItem[] | undefined[]—

Events

名稱Payload說明
fileReject{ reason: string; file?: File | undefined; }檔案被拒絕時觸發(驗證失敗或被 `onFileValidate` 拒收)
upload{ id: string; file: File; }檔案被接受並加入列表後觸發,可用於啟動實際上傳流程
update:modelValueTItem[]—

Slots

名稱Bindings說明
default{}—

admin-file-upload-trigger

Props

名稱型別預設值說明
asChildboolean | undefined—Change the default rendered element for the one passed as a child, merging their props and behavior. Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details.
asglobalThis.Component | AsTag | undefined"button"The element or component this component should render as. Can be overwritten by `asChild`.
classClassValue——

Slots

名稱Bindings說明
default{}—

admin-file-upload-list

Props

名稱型別預設值說明
orientation"horizontal" | "vertical" | undefined"vertical"—
classClassValue——

Slots

名稱Bindings說明
default{}—

admin-file-upload-item

Props

名稱型別預設值說明
itemId*string——
classClassValue——

* 代表必填

Slots

名稱Bindings說明
default{}—

admin-file-upload-clear

Props

名稱型別預設值說明
asChildboolean | undefined—Change the default rendered element for the one passed as a child, merging their props and behavior. Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details.
asglobalThis.Component | AsTag | undefined"button"The element or component this component should render as. Can be overwritten by `asChild`.
classClassValue——
forceMountboolean | undefinedfalse—
labelstring | undefined"Clear all"—

Slots

名稱Bindings說明
default{}—

admin-file-upload-item-delete

Props

名稱型別預設值說明
asChildboolean | undefined—Change the default rendered element for the one passed as a child, merging their props and behavior. Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details.
asglobalThis.Component | AsTag | undefined"button"The element or component this component should render as. Can be overwritten by `asChild`.
classClassValue——
labelstring | undefined"Remove file"—

Slots

名稱Bindings說明
default{}—

admin-file-upload-item-progress

Props

名稱型別預設值說明
classClassValue——
labelsPartial<Record<FileUploadStatus, string>> | undefined——

Slots

名稱Bindings說明
status{ label: string; status: FileUploadStatus; }—
icon{ status: FileUploadStatus; }—

admin-file-upload-dropzone

Props

名稱型別預設值說明
classClassValue——

Slots

名稱Bindings說明
default{}—

admin-file-upload-item-metadata

Props

名稱型別預設值說明
classClassValue——

Slots

名稱Bindings說明
default{}—

admin-file-upload-item-preview

Props

名稱型別預設值說明
classClassValue——

Slots

名稱Bindings說明
default{ item: AdminFileUploadListItem | undefined; }—

注意事項

  • 自訂子元件可從 @/components/admin/file-upload import useFileUploadRoot() / useFileUploadItem() 取得內部狀態。
  • useFileUploadRoot() 的 modelValue 為寬型別 AdminFileUploadListItem[];泛型收斂僅作用於 root 的 v-model 與 update:modelValue。
  • 單張圖片欄位使用 admin-image-upload,多檔、列表進度、拖曳區使用本元件族。

更新紀錄

  • feat: admin-file-upload item 支援泛型 TItem09c65ae
  • feat: 新增 accept 類型驗證並修正拖放與觸發行為27d79cc