Repository: Tencent/tdesign-mobile-vue
Pull request #318 (2022-08-27 13:36:06)

test(upload): add unit test for upload
<!--
首先，感谢你的贡献！😄
请阅读并遵循 [TDesign 贡献指南](https://github.com/Tencent/tdesign/blob/main/docs/contributing.md)，填写以下 pull request 的信息。
PR 在维护者审核通过后会合并，谢谢！
-->

### 🤔 这个 PR 的性质是？

- [x] 日常 bug 修复
- [ ] 新特性提交
- [ ] 文档改进
- [ ] 演示代码改进
- [ ] 组件样式/交互改进
- [ ] CI/CD 改进
- [ ] 重构
- [ ] 代码风格优化
- [x] 测试用例
- [ ] 分支合并
- [ ] 其他

### 🔗 相关 Issue

<!--
1. 描述相关需求的来源，如相关的 issue 讨论链接。
-->

[tdesign-mobile-vue#271](https://github.com/Tencent/tdesign-mobile-vue/issues/271)

### 📝 更新日志

<!--
从用户角度描述具体变化，以及可能的 breaking change 和其他风险。
-->

- feat(upload): 新增单元测试
- fix(upload): 修复上传失败后，再次上传会产生报错的问题

### ☑️ 请求合并前的自查清单

⚠️ 请自检并全部**勾选全部选项**。⚠️

- [x] 文档已补充或无须补充
- [x] 代码演示已提供或无须提供
- [x] TypeScript 定义已补充或无须补充
- [x] Changelog 已提供或无须提供


github-actions[bot]: <a href="https://preview-pr318-tdesign-mobile-vue.surge.sh"><img height="96" alt="完成" src="https://user-images.githubusercontent.com/15634204/150816437-9f5bb788-cd67-4cbc-9897-b82d74e9aa65.png" /></a>
<!-- [工作流地址](https://github.com/Tencent/tdesign-mobile-vue/actions/runs/2945299491) -->
<!-- AUTO_PREVIEW_HOOK -->
anlyyao: @palmcivet 辛苦合一下 dev 分支，重跑一下 ci～
anlyyao: @palmcivet  branches覆盖率小于80，可以在补充一下requestMethod的内容～
LeeJim: 单测没过

Patch:
diff --git a/.eslintignore b/.eslintignore
index a3546408..ee8db377 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -2,7 +2,6 @@
 **/*_common*
 **/*types*
 !.*.js
-
 # build files
 es
 esm
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 317284e4..e78aff15 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,27 @@ toc: false
 spline: explain
 ---
 
+## 🌈 0.10.6 `2022-09-20` 
+### 🚀 Features
+- `Drawer`: 新增单元测试 @anlyyao ([#311](https://github.com/Tencent/tdesign-mobile-vue/pull/311))
+- `Popup`: 新增单元测试 @anlyyao ([#310](https://github.com/Tencent/tdesign-mobile-vue/pull/310))
+- `Stepper`: 新增单元测试 @anlyyao ([#304](https://github.com/Tencent/tdesign-mobile-vue/pull/304))
+- `Overlay`: 新增单元测试 @HelKyle ([#336](https://github.com/Tencent/tdesign-mobile-vue/pull/336))
+- `BackTop`: 新增单元测试 @HelKyle ([#335](https://github.com/Tencent/tdesign-mobile-vue/pull/335))
+- `Toast`: 新增单元测试 @HelKyle ([#334](https://github.com/Tencent/tdesign-mobile-vue/pull/334))
+- `ActionSheet`: 新增单元测试 @HelKyle ([#319](https://github.com/Tencent/tdesign-mobile-vue/pull/319))
+- `Progress`: 新增单元测试 @HelKyle ([#337](https://github.com/Tencent/tdesign-mobile-vue/pull/337))
+- `ImageViewer`: 新增单元测试 @palmcivet ([#332](https://github.com/Tencent/tdesign-mobile-vue/pull/332))
+- `TabBar`: 新增单元测试 @HelKyle ([#314](https://github.com/Tencent/tdesign-mobile-vue/pull/314))
+- `Dialog`: 新增单元测试 @anlyyao ([#361](https://github.com/Tencent/tdesign-mobile-vue/pull/361))
+### 🐞 Bug Fixes
+- `Stepper`: 修复输入非 `number` 字符时，出现 `NaN` 问题 @anlyyao ([#304](https://github.com/Tencent/tdesign-mobile-vue/pull/304))
+- `Stepper`: 修复输入值能超出 `max` 的问题 @anlyyao ([#304](https://github.com/Tencent/tdesign-mobile-vue/pull/304))
+- `Stepper`: 修复 `value` 值小于 `min` 或超出 `max` 时，不触发 `overlimit` 的问题 @anlyyao ([#304](https://github.com/Tencent/tdesign-mobile-vue/pull/304))
+- `Collapse`: 修复 `headerRightContent` 插槽与文档描述不一致的问题 @isanxia ([#330](https://github.com/Tencent/tdesign-mobile-vue/pull/330))
+### 🚧 Others
+- `官网`: 更新浏览器兼容性说明文档 @anlyyao ([#349](https://github.com/Tencent/tdesign-mobile-vue/pull/349))
+
 ## 🌈 0.10.5 `2022-09-06` 
 ### 🚀 Features
 - `Popup`: 新增 `customStyle` 和 `overlayProps` 属性 @anlyyao ([#296](https://github.com/Tencent/tdesign-mobile-vue/pull/296))
diff --git a/package.json b/package.json
index de497356..23e00d3c 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "tdesign-mobile-vue",
   "purename": "tdesign",
-  "version": "0.10.5",
+  "version": "0.10.6",
   "description": "tdesign-mobile-vue",
   "author": "TDesign Group",
   "homepage": "https://tdesign.tencent.com/mobile-vue/",
diff --git a/site/docs.config.js b/site/docs.config.js
index 6a136ffd..9650ca70 100644
--- a/site/docs.config.js
+++ b/site/docs.config.js
@@ -70,7 +70,7 @@ export default {
           path: '/mobile-vue/components/grid',
           component: () => import('@/grid/grid.md'),
         },
-      ]
+      ],
     },
     {
       title: '导航',
@@ -124,6 +124,12 @@ export default {
       title: '输入',
       type: 'component',
       children: [
+        {
+          title: 'Calendar 日历',
+          name: 'calendar',
+          path: '/mobile-vue/components/calendar',
+          component: () => import('@/calendar/calendar.md'),
+        },
         {
           title: 'CheckBox 复选框',
           name: 'checkbox',
diff --git a/site/vite.config.js b/site/vite.config.js
index f45a56ef..7d611c45 100644
--- a/site/vite.config.js
+++ b/site/vite.config.js
@@ -79,7 +79,7 @@ export default ({ mode }) => {
         },
       }),
       vueJsx({
-        isCustomElement
+        isCustomElement,
       }),
       createTDesignPlugin(),
     ],
diff --git a/src/_common b/src/_common
index d9ad437c..8a306a10 160000
--- a/src/_common
+++ b/src/_common
@@ -1,1 +1,1 @@
-Subproject commit d9ad437c0ded605ac3699f2fa19b98337a4edb4a
+Subproject commit 8a306a10fca05f9f66e28c5932fef4049d68cb48
diff --git a/src/calendar/calendar.en-US.md b/src/calendar/calendar.en-US.md
new file mode 100644
index 00000000..b9b8e0e7
--- /dev/null
+++ b/src/calendar/calendar.en-US.md
@@ -0,0 +1,25 @@
+:: BASE_DOC ::
+
+## API
+### Calendar Props
+
+name | type | default | description | required
+-- | -- | -- | -- | --
+confirmBtn | String / Object / Slot / Function | '' | Typescript：`string | TNode | ButtonProps | null`，[Button API Documents](./button?tab=api)。[see more ts definition](https://github.com/Tencent/tdesign-mobile-vue/blob/develop/src/common.ts)。[see more ts definition](https://github.com/Tencent/tdesign-mobile-vue/tree/develop/src/calendar/type.ts) | N
+firstDayOfWeek | Number | 0 | \- | N
+format | Function | - | Typescript：`(day: TDate) => TDate` `type TDateType = 'selected' | 'disabled' | 'start' | 'centre' | 'end' | ''; ` `interface TDate { date: Date; day: number; type: TDateType; className?: string; prefix?: string; suffix?: string; }`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-vue/tree/develop/src/calendar/type.ts) | N
+maxDate | Number / Date | - | Typescript：`number | Date` | N
+minDate | Number / Date | - | Typescript：`number | Date` | N
+title | String / Slot / Function | - | Typescript：`string | TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-vue/blob/develop/src/common.ts) | N
+type | String | 'single' | options：single/multiple/range | N
+value | Number / Array / Date | - | Typescript：`number | Date | TCalendarValue[] ` `type TCalendarValue = number | Date`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-vue/tree/develop/src/calendar/type.ts) | N
+visible | Boolean | false | \- | N
+onConfirm | Function |  | Typescript：`(value: Date) => void`<br/> | N
+onSelect | Function |  | Typescript：`(value: Date) => void`<br/> | N
+
+### Calendar Events
+
+name | params | description
+-- | -- | --
+confirm | `(value: Date)` | \-
+select | `(value: Date)` | \-
diff --git a/src/calendar/calendar.md b/src/calendar/calendar.md
new file mode 100644
index 00000000..2b44bc46
--- /dev/null
+++ b/src/calendar/calendar.md
@@ -0,0 +1,25 @@
+:: BASE_DOC ::
+
+## API
+### Calendar Props
+
+名称 | 类型 | 默认值 | 说明 | 必传
+-- | -- | -- | -- | --
+confirmBtn | String / Object / Slot / Function | '' | 确认按钮。值为 null 则不显示确认按钮。值类型为字符串，则表示自定义按钮文本，值类型为 Object 则表示透传 Button 组件属性。。TS 类型：`string | TNode | ButtonProps | null`，[Button API Documents](./button?tab=api)。[通用类型定义](https://github.com/Tencent/tdesign-mobile-vue/blob/develop/src/common.ts)。[详细类型定义](https://github.com/Tencent/tdesign-mobile-vue/tree/develop/src/calendar/type.ts) | N
+firstDayOfWeek | Number | 0 | 第一天从星期几开始，默认 0 = 周日 | N
+format | Function | - | 用于格式化日期的函数。TS 类型：`(day: TDate) => TDate` `type TDateType = 'selected' | 'disabled' | 'start' | 'centre' | 'end' | ''; ` `interface TDate { date: Date; day: number; type: TDateType; className?: string; prefix?: string; suffix?: string; }`。[详细类型定义](https://github.com/Tencent/tdesign-mobile-vue/tree/develop/src/calendar/type.ts) | N
+maxDate | Number / Date | - | 最大可选的日期，不传则默认半年后。TS 类型：`number | Date` | N
+minDate | Number / Date | - | 最小可选的日期，不传则默认今天。TS 类型：`number | Date` | N
+title | String / Slot / Function | - | 标题，不传默认为“请选择日期”。TS 类型：`string | TNode`。[通用类型定义](https://github.com/Tencent/tdesign-mobile-vue/blob/develop/src/common.ts) | N
+type | String | 'single' | 日历的选择类型，single = 单选；multiple = 多选; range = 区间选择。可选项：single/multiple/range | N
+value | Number / Array / Date | - | 当前选择的日期，不传则默认今天，当 type = multiple 或 range 时传入数组。TS 类型：`number | Date | TCalendarValue[] ` `type TCalendarValue = number | Date`。[详细类型定义](https://github.com/Tencent/tdesign-mobile-vue/tree/develop/src/calendar/type.ts) | N
+visible | Boolean | false | 是否显示日历 | N
+onConfirm | Function |  | TS 类型：`(value: Date) => void`<br/>点击确认按钮时触发 | N
+onSelect | Function |  | TS 类型：`(value: Date) => void`<br/>选择日期时触发 | N
+
+### Calendar Events
+
+名称 | 参数 | 描述
+-- | -- | --
+confirm | `(value: Date)` | 点击确认按钮时触发
+select | `(value: Date)` | 选择日期时触发
diff --git a/src/calendar/calendar.vue b/src/calendar/calendar.vue
new file mode 100644
index 00000000..d48502cf
--- /dev/null
+++ b/src/calendar/calendar.vue
@@ -0,0 +1,260 @@
+<template>
+  <div>
+    <t-popup v-model="popup" placement="bottom">
+      <div :class="name">
+        <div :class="`${name}__title`">
+          <slot name="title">{{ title || '请选择日期' }}</slot>
+        </div>
+        <CloseIcon :class="`${name}__close-btn`" size="24" @click="popup = false" />
+        <div :class="`${name}__days`">
+          <div v-for="(item, index) in days" :key="index" :class="`${name}__days-item`">{{ item }}</div>
+        </div>
+        <div :class="`${name}__months`">
+          <template v-for="(item, index) in months" :key="index">
+            <div :class="`${name}__month`">{{ item.year }} 年 {{ item.month + 1 }} 月</div>
+            <div :class="`${name}__dates`">
+              <template v-for="(dateItem, dateIndex) in item.months" :key="dateIndex">
+                <div
+                  :class="{
+                    [`${name}__dates-item`]: true,
+                    [`${name}__dates-item--${dateItem.type}`]: !!dateItem.type,
+                    [`${dateItem.className ?? ''}`]: true,
+                  }"
+                  :style="{
+                    marginLeft: dateIndex === 0 ? `${49 * ((item.weekdayOfFirstDay - firstDayOfWeek + 7) % 7)}px` : 0,
+                  }"
+                  @click="handleSelect(item.year, item.month, dateItem.day, dateItem)"
+                >
+                  <slot name="cell" :item="dateItem">
+                    <div v-if="dateItem.prefix" :class="`${name}__dates-item-prefix`">{{ dateItem.prefix }}</div>
+                    {{ dateItem.day }}
+                    <div
+                      v-if="dateItem.suffix"
+                      :class="{
+                        [`${name}__dates-item-suffix`]: true,
+                        [`${name}__dates-item-suffix--${dateItem.type}`]: !!dateItem.type,
+                      }"
+                    >
+                      {{ dateItem.suffix }}
+                    </div>
+                  </slot>
+                </div>
+              </template>
+            </div>
+          </template>
+        </div>
+        <div class="t-calendar__footer">
+          <slot name="confirmBtn">
+            <template v-if="confirmBtn">
+              <t-button block theme="primary" v-bind="confirmBtn" @click="handleConfirm" />
+            </template>
+          </slot>
+        </div>
+      </div>
+    </t-popup>
+  </div>
+</template>
+
+<script lang="ts">
+import { defineEmits, defineProps, computed, watch, ref, toRaw } from 'vue';
+import { CloseIcon } from 'tdesign-icons-vue-next';
+import pull from 'lodash/pull';
+
+import config from '../config';
+import calendarProps from './props';
+import { TDate, TDateType } from './type';
+
+const { prefix } = config;
+const name = `${prefix}-calendar`;
+
+export default {
+  name,
+};
+</script>
+
+<script setup lang="ts">
+const props = defineProps(calendarProps);
+const emit = defineEmits(['select', 'confirm', 'update:modelValue', 'update:value', 'update:visible']);
+
+// 获取时间年月日起
+const getYearMonthDay = (date: Date) => {
+  return {
+    year: date.getFullYear(),
+    month: date.getMonth(),
+    date: date.getDate(),
+  };
+};
+
+const popup = ref<boolean>(props.visible);
+const valueRef = ref(props.value);
+const selectedDate = ref();
+const firstDayOfWeek = computed(() => props.firstDayOfWeek);
+const type = computed(() => props.type);
+const days = computed(() => {
+  const raw = '日一二三四五六';
+  const ans = [];
+  let i = firstDayOfWeek.value % 7;
+
+  while (ans.length < 7) {
+    ans.push(raw[i]);
+    i = (i + 1) % 7;
+  }
+
+  return ans;
+});
+const today = new Date();
+const minDate = props.minDate ? new Date(props.minDate) : today;
+const maxDate = props.maxDate
+  ? new Date(props.maxDate)
+  : new Date(today.getFullYear(), today.getMonth() + 6, today.getDate());
+
+// 获取日期
+const getDate = (year: number, month: number, day: number) => new Date(year, month, day);
+
+// 选择日期
+const handleSelect = (year: number, month: number, date: number, dateItem: TDate) => {
+  if (dateItem.type === 'disabled') return;
+  const selected = new Date(year, month, date);
+
+  if (type.value === 'range' && Array.isArray(selectedDate.value)) {
+    if (selectedDate.value.length === 1) {
+      if (selectedDate.value[0] > selected) {
+        selectedDate.value = [selected];
+      } else {
+        selectedDate.value = [selectedDate.value[0], selected];
+      }
+    } else {
+      selectedDate.value = [selected];
+    }
+  } else if (props.type === 'multiple') {
+    const newVal = [...selectedDate.value];
+    const index = selectedDate.value.findIndex((item: Date) => isSameDate(item, selected));
+    if (index > -1) {
+      newVal.splice(index, 1);
+    } else {
+      newVal.push(selected);
+    }
+    selectedDate.value = newVal;
+  } else {
+    selectedDate.value = selected;
+  }
+  props.onSelect?.(toRaw(selectedDate.value));
+};
+// 确认
+const handleConfirm = () => {
+  popup.value = false;
+  props.onConfirm?.(toRaw(selectedDate.value));
+};
+const getMonthDates = (date: Date) => {
+  const { year, month } = getYearMonthDay(date);
+  const firstDay = getDate(year, month, 1);
+  const weekdayOfFirstDay = firstDay.getDay();
+  const lastDate = new Date(+getDate(year, month + 1, 1) - 24 * 3600 * 1000).getDate();
+
+  return {
+    year,
+    month,
+    weekdayOfFirstDay,
+    lastDate,
+  };
+};
+type CompareDate = Date | number | { year: number; month: number; date: number };
+const isSameDate = (date1: CompareDate, date2: CompareDate) => {
+  if (date1 instanceof Date) date1 = getYearMonthDay(date1);
+  if (date2 instanceof Date) date2 = getYearMonthDay(date2);
+  const keys = ['year', 'month', 'date'];
+  return keys.every((key) => date1[key] === date2[key]);
+};
+
+const months = computed(() => {
+  const ans = [];
+  let { year: minYear, month: minMonth } = getYearMonthDay(minDate);
+  const { year: maxYear, month: maxMonth } = getYearMonthDay(maxDate);
+  const calcType = (year: number, month: number, date: number): TDateType => {
+    const curDate = new Date(year, month, date, 23, 59, 59);
+
+    if (type.value === 'single') {
+      if (isSameDate({ year, month, date }, selectedDate.value)) return 'selected';
+    }
+    if (type.value === 'multiple') {
+      const hit = selectedDate.value.some((item: Date) => isSameDate({ year, month, date }, item));
+      if (hit) {
+        return 'selected';
+      }
+    }
+    if (type.value === 'range') {
+      if (Array.isArray(selectedDate.value)) {
+        const [startDate, endDate] = selectedDate.value;
+
+        if (startDate && isSameDate({ year, month, date }, startDate)) return 'start';
+        if (endDate && isSameDate({ year, month, date }, endDate)) return 'end';
+        if (startDate && endDate && curDate.getTime() > startDate.getTime() && curDate.getTime() < endDate.getTime())
+          return 'centre';
+      }
+    }
+
+    const minCurDate = new Date(year, month, date, 0, 0, 0);
+    if (curDate.getTime() < minDate.getTime() || minCurDate.getTime() > maxDate.getTime()) {
+      return 'disabled';
+    }
+    return '';
+  };
+
+  while (minYear < maxYear || (minYear === maxYear && minMonth <= maxMonth)) {
+    const target = getMonthDates(getDate(minYear, minMonth, 1));
+    const months: TDate[] = [];
+    for (let i = 1; i <= 31; i++) {
+      if (i > target.lastDate) break;
+      const dateObj: TDate = {
+        date: getDate(minYear, minMonth, i),
+        day: i,
+        type: calcType(minYear, minMonth, i),
+      };
+      months.push(props.format ? props.format(dateObj) : dateObj);
+    }
+    ans.push({
+      year: minYear,
+      month: minMonth,
+      months,
+      weekdayOfFirstDay: target.weekdayOfFirstDay,
+    });
+    const curDate = getYearMonthDay(getDate(minYear, minMonth + 1, 1));
+    minYear = curDate.year;
+    minMonth = curDate.month;
+  }
+
+  return ans;
+});
+
+const confirmBtn = computed(() => {
+  if (props.confirmBtn === 'string' || props.confirmBtn === '') return { content: props.confirmBtn || '确认' };
+  return props.confirmBtn;
+});
+
+watch(
+  () => props.visible,
+  (val) => {
+    popup.value = val;
+  },
+);
+watch(
+  () => popup.value,
+  (val) => {
+    emit('update:visible', val);
+  },
+);
+
+watch(
+  valueRef,
+  () => {
+    if (Array.isArray(valueRef.value)) {
+      selectedDate.value = valueRef.value.map((item) => new Date(item));
+    } else if (valueRef.value) {
+      selectedDate.value = new Date(valueRef.value);
+    } else {
+      selectedDate.value = props.type === 'multiple' ? [new Date()] : new Date();
+    }
+  },
+  { immediate: true },
+);
+</script>
diff --git a/src/calendar/demos/base.vue b/src/calendar/demos/base.vue
new file mode 100644
index 00000000..76583613
--- /dev/null
+++ b/src/calendar/demos/base.vue
@@ -0,0 +1,18 @@
+<template>
+  <div>
+    <t-calendar v-model:visible="visible" @confirm="handleConfirm" @select="handleSelect"></t-calendar>
+    <t-cell title="单个选择日期" arrow @click="visible = true"></t-cell>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+
+const visible = ref(false);
+const handleConfirm = (val: string) => {
+  console.log(val);
+};
+const handleSelect = (val: any) => {
+  console.log(val);
+};
+</script>
diff --git a/src/calendar/demos/custom-button.vue b/src/calendar/demos/custom-button.vue
new file mode 100644
index 00000000..0a38047c
--- /dev/null
+++ b/src/calendar/demos/custom-button.vue
@@ -0,0 +1,15 @@
+<template>
+  <t-calendar v-model:visible="visible" :confirm-btn="confirmBtn" />
+  <t-cell title="自定义按钮" arrow @click="visible = true"></t-cell>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+
+const visible = ref(false);
+const confirmBtn = {
+  content: 'ok',
+  theme: 'danger',
+  shape: 'round',
+};
+</script>
diff --git a/src/calendar/demos/custom-range.vue b/src/calendar/demos/custom-range.vue
new file mode 100644
index 00000000..5a7645af
--- /dev/null
+++ b/src/calendar/demos/custom-range.vue
@@ -0,0 +1,14 @@
+<template>
+  <t-calendar v-model:visible="visible" :value="value" :min-date="minDate" :max-date="maxDate" />
+  <t-cell title="自定义日期区间" arrow @click="visible = true"></t-cell>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+
+const minDate = new Date(2022, 0, 1);
+const maxDate = new Date(2022, 0, 31);
+
+const visible = ref(false);
+const value = new Date(2022, 0, 15);
+</script>
diff --git a/src/calendar/demos/custom-text.vue b/src/calendar/demos/custom-text.vue
new file mode 100644
index 00000000..c302540b
--- /dev/null
+++ b/src/calendar/demos/custom-text.vue
@@ -0,0 +1,47 @@
+<template>
+  <t-calendar v-model:visible="visible" :value="value" :format="format" :min-date="minDate" :max-date="maxDate" />
+  <t-cell title="自定义文案" arrow @click="visible = true"></t-cell>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+
+const minDate = new Date(2022, 1, 1);
+const maxDate = new Date(2022, 2, 15);
+const format = (day) => {
+  const { date } = day;
+  const year = date.getFullYear();
+  const month = date.getMonth() + 1;
+  const curDate = date.getDate();
+
+  day.suffix = '¥60';
+
+  if (year === 2022) {
+    if (month === 2) {
+      const map = {
+        1: '初一',
+        2: '初二',
+        3: '初三',
+        14: '情人节',
+        15: '元宵节',
+      };
+      if (curDate in map) {
+        day.prefix = map[curDate];
+        day.suffix = '¥100';
+        day.className = 'is-holiday';
+      }
+    }
+  }
+
+  return day;
+};
+
+const visible = ref(false);
+const value = new Date(2022, 1, 15);
+</script>
+
+<style lang="less">
+.is-holiday:not(.t-calendar__dates-item--selected) {
+  color: #e34d59;
+}
+</style>
diff --git a/src/calendar/demos/mobile.vue b/src/calendar/demos/mobile.vue
new file mode 100644
index 00000000..d714263d
--- /dev/null
+++ b/src/calendar/demos/mobile.vue
@@ -0,0 +1,46 @@
+<template>
+  <div class="tdesign-mobile-demo">
+    <h1 class="title">Calendar 日历</h1>
+    <p class="summary">按钮用于选择日期。</p>
+
+    <tdesign-demo-block title="01 基础" summary="基础用法">
+      <div class="Calendar-demo">
+        <BaseCalendarDemo />
+        <multipleVue />
+        <rangeDemo />
+      </div>
+    </tdesign-demo-block>
+
+    <tdesign-demo-block title="02 自定义" summary=" ">
+      <customTextDemo />
+      <customButtonVue />
+      <customRangeVue />
+    </tdesign-demo-block>
+
+    <!-- <tdesign-demo-block title="03 设置标题" summary="自定义标题">
+      <div class="Calendar-demo">
+        <HeadCalendarDemo />
+      </div>
+    </tdesign-demo-block>
+
+    <tdesign-demo-block title="04 设置单元格" summary="自定义单元格">
+      <div class="Calendar-demo">
+        <CellCalendarDemo />
+      </div>
+    </tdesign-demo-block>
+
+    <tdesign-demo-block title="04 设置背景颜色" summary="自定义单元格和按钮颜色">
+      <div class="Calendar-demo">
+        <ColorCalendarDemo />
+      </div>
+    </tdesign-demo-block> -->
+  </div>
+</template>
+<script lang="ts" setup>
+import BaseCalendarDemo from './base.vue';
+import multipleVue from './multiple.vue';
+import customTextDemo from './custom-text.vue';
+import rangeDemo from './range.vue';
+import customButtonVue from './custom-button.vue';
+import customRangeVue from './custom-range.vue';
+</script>
diff --git a/src/calendar/demos/multiple.vue b/src/calendar/demos/multiple.vue
new file mode 100644
index 00000000..381c0911
--- /dev/null
+++ b/src/calendar/demos/multiple.vue
@@ -0,0 +1,18 @@
+<template>
+  <div>
+    <t-calendar v-model:visible="visible" type="multiple" @confirm="handleConfirm" @select="handleSelect"></t-calendar>
+    <t-cell title="多个选择日期" arrow @click="visible = true"></t-cell>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+
+const visible = ref(false);
+const handleConfirm = (val: string) => {
+  console.log(val);
+};
+const handleSelect = (val: any) => {
+  console.log(val);
+};
+</script>
diff --git a/src/calendar/demos/range.vue b/src/calendar/demos/range.vue
new file mode 100644
index 00000000..4b6ee0e8
--- /dev/null
+++ b/src/calendar/demos/range.vue
@@ -0,0 +1,18 @@
+<template>
+  <div>
+    <t-calendar v-model:visible="visible" :value="value" type="range" @confirm="handleConfirm"></t-calendar>
+    <t-cell title="区间选择日期" arrow @click="visible = true"></t-cell>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+
+const visible = ref(false);
+const handleConfirm = (val: string) => {
+  console.log(val);
+};
+const today = new Date();
+const tomorrow = new Date(today.getTime() + 5 * 24 * 3600 * 1000);
+const value = ref([today, tomorrow]);
+</script>
diff --git a/src/calendar/index.ts b/src/calendar/index.ts
new file mode 100644
index 00000000..c35637d7
--- /dev/null
+++ b/src/calendar/index.ts
@@ -0,0 +1,11 @@
+import Calendar from './calendar.vue';
+import { withInstall, WithInstallType } from '../shared';
+
+import './style';
+import { TdCalendarProps } from './type';
+
+export * from './type';
+export type CalendarProps = TdCalendarProps;
+
+const _Calendar: WithInstallType<typeof Calendar> = withInstall(Calendar);
+export default _Calendar;
diff --git a/src/calendar/props.ts b/src/calendar/props.ts
new file mode 100644
index 00000000..6fe48b28
--- /dev/null
+++ b/src/calendar/props.ts
@@ -0,0 +1,56 @@
+/* eslint-disable */
+
+/**
+ * 该文件为脚本自动生成文件，请勿随意修改。如需修改请联系 PMC
+ * */
+
+import { TdCalendarProps } from './type';
+import { PropType } from 'vue';
+
+export default {
+  /** 确认按钮。值为 null 则不显示确认按钮。值类型为字符串，则表示自定义按钮文本，值类型为 Object 则表示透传 Button 组件属性。 */
+  confirmBtn: {
+    type: [String, Object, Function] as PropType<TdCalendarProps['confirmBtn']>,
+    default: '',
+  },
+  /** 第一天从星期几开始，默认 0 = 周日 */
+  firstDayOfWeek: {
+    type: Number,
+    default: 0,
+  },
+  /** 用于格式化日期的函数 */
+  format: {
+    type: Function as PropType<TdCalendarProps['format']>,
+  },
+  /** 最大可选的日期，不传则默认半年后 */
+  maxDate: {
+    type: [Number, Date] as PropType<TdCalendarProps['maxDate']>,
+  },
+  /** 最小可选的日期，不传则默认今天 */
+  minDate: {
+    type: [Number, Date] as PropType<TdCalendarProps['minDate']>,
+  },
+  /** 标题，不传默认为“请选择日期” */
+  title: {
+    type: [String, Function] as PropType<TdCalendarProps['title']>,
+  },
+  /** 日历的选择类型，single = 单选；multiple = 多选; range = 区间选择 */
+  type: {
+    type: String as PropType<TdCalendarProps['type']>,
+    default: 'single' as TdCalendarProps['type'],
+    validator(val: TdCalendarProps['type']): boolean {
+      if (!val) return true;
+      return ['single', 'multiple', 'range'].includes(val);
+    },
+  },
+  /** 当前选择的日期，不传则默认今天，当 type = multiple 或 range 时传入数组 */
+  value: {
+    type: [Number, Array, Date] as PropType<TdCalendarProps['value']>,
+  },
+  /** 是否显示日历 */
+  visible: Boolean,
+  /** 点击确认按钮时触发 */
+  onConfirm: Function as PropType<TdCalendarProps['onConfirm']>,
+  /** 选择日期时触发 */
+  onSelect: Function as PropType<TdCalendarProps['onSelect']>,
+};
diff --git a/src/calendar/style/css.js b/src/calendar/style/css.js
new file mode 100644
index 00000000..6a9a4b13
--- /dev/null
+++ b/src/calendar/style/css.js
@@ -0,0 +1,1 @@
+import './index.css';
diff --git a/src/calendar/style/index.js b/src/calendar/style/index.js
new file mode 100644
index 00000000..ccfc1c7f
--- /dev/null
+++ b/src/calendar/style/index.js
@@ -0,0 +1,1 @@
+import '../../_common/style/mobile/components/calendar/_index.less';
diff --git a/src/calendar/type.ts b/src/calendar/type.ts
new file mode 100644
index 00000000..54a9f03d
--- /dev/null
+++ b/src/calendar/type.ts
@@ -0,0 +1,72 @@
+/* eslint-disable */
+
+/**
+ * 该文件为脚本自动生成文件，请勿随意修改。如需修改请联系 PMC
+ * */
+
+import { ButtonProps } from '../button';
+import { TNode } from '../common';
+
+export interface TdCalendarProps {
+  /**
+   * 确认按钮。值为 null 则不显示确认按钮。值类型为字符串，则表示自定义按钮文本，值类型为 Object 则表示透传 Button 组件属性。
+   * @default ''
+   */
+  confirmBtn?: string | TNode | ButtonProps | null;
+  /**
+   * 第一天从星期几开始，默认 0 = 周日
+   * @default 0
+   */
+  firstDayOfWeek?: number;
+  /**
+   * 用于格式化日期的函数
+   */
+  format?: (day: TDate) => TDate;
+  /**
+   * 最大可选的日期，不传则默认半年后
+   */
+  maxDate?: number | Date;
+  /**
+   * 最小可选的日期，不传则默认今天
+   */
+  minDate?: number | Date;
+  /**
+   * 标题，不传默认为“请选择日期”
+   */
+  title?: string | TNode;
+  /**
+   * 日历的选择类型，single = 单选；multiple = 多选; range = 区间选择
+   * @default 'single'
+   */
+  type?: 'single' | 'multiple' | 'range';
+  /**
+   * 当前选择的日期，不传则默认今天，当 type = multiple 或 range 时传入数组
+   */
+  value?: number | Date | TCalendarValue[];
+  /**
+   * 是否显示日历
+   * @default false
+   */
+  visible?: boolean;
+  /**
+   * 点击确认按钮时触发
+   */
+  onConfirm?: (value: Date) => void;
+  /**
+   * 选择日期时触发
+   */
+  onSelect?: (value: Date) => void;
+}
+
+export type TDateType = 'selected' | 'disabled' | 'start' | 'centre' | 'end' | '';
+
+export interface TDate {
+  date: Date;
+  day: number;
+  type: TDateType;
+  className?: string;
+  prefix?: string;
+  suffix?: string;
+}
+
+export type TCalendarValue = number | Date;
diff --git a/src/collapse/demos/action.vue b/src/collapse/demos/action.vue
index ce13799f..3920c839 100644
--- a/src/collapse/demos/action.vue
+++ b/src/collapse/demos/action.vue
@@ -11,14 +11,12 @@
 </template>
 
 <script setup lang="ts">
-import { ref, h } from 'vue';
+import { ref } from 'vue';
 
 const values = ref([0]);
-const handleChange = (val) => {
+const handleChange = (val: number[]) => {
   values.value = val;
 };
-
-// const header = () => h('div', '123');
 </script>
 
 <style scoped lang="less">
diff --git a/src/components.ts b/src/components.ts
index 8d41ce71..89a39f39 100644
--- a/src/components.ts
+++ b/src/components.ts
@@ -1,6 +1,7 @@
 export { default as Button } from './button';
 export { default as ButtonGroup } from './button-group';
 export { default as Divider } from './divider';
+export { default as Calendar } from './calendar';
 export { default as Cell } from './cell';
 export { default as CellGroup } from './cell-group';
 export { default as Checkbox, CheckboxGroup } from './checkbox';
diff --git a/src/count-down/count-down.vue b/src/count-down/count-down.vue
index 1891565b..7bab8bb9 100644
--- a/src/count-down/count-down.vue
+++ b/src/count-down/count-down.vue
@@ -29,11 +29,9 @@ export default defineComponent({
     ...CountDownProps,
   },
   setup(props) {
-    const { content, size = 'small', theme = 'default', ...other } = props || {};
-    //
-    const { time, showTimes } = useCountDown(other);
+    const { time, showTimes } = useCountDown(props);
     const internalInstance = getCurrentInstance();
-    const hasChinese = /.*[\u4e00-\u9fa5]+.*$/?.test?.(other?.format);
+    const hasChinese = /.*[\u4e00-\u9fa5]+.*$/?.test?.(props?.format);
     const contentLayout = computed(() => renderTNode(internalInstance, 'content'));
     // return
     return {
diff --git a/src/pull-down-refresh/demos/mobile.vue b/src/pull-down-refresh/demos/mobile.vue
index db849ce2..7eb7d8ed 100644
--- a/src/pull-down-refresh/demos/mobile.vue
+++ b/src/pull-down-refresh/demos/mobile.vue
@@ -4,7 +4,7 @@
     <p class="summary">用于快速刷新页面信息，刷新可以是整页刷新也可以是页面的局部刷新。</p>
     <p class="blank"></p> -->
     <tdesign-demo-block title="" summary="">
-      <t-tabs default-value="first" @on-change="onChange">
+      <t-tabs default-value="first">
         <t-tab-panel value="first" label="基础用法">
           <div class="refresh-content">
             <t-pull-down-refresh
@@ -48,7 +48,7 @@
 </template>
 
 <script lang="ts">
-import { ref, defineComponent, reactive } from 'vue';
+import { ref, defineComponent } from 'vue';
 import { Toast } from '@/components';
 
 export default defineComponent({
diff --git a/src/pull-down-refresh/pull-down-refresh.vue b/src/pull-down-refresh/pull-down-refresh.vue
index 28fe817a..c41a6ee7 100644
--- a/src/pull-down-refresh/pull-down-refresh.vue
+++ b/src/pull-down-refresh/pull-down-refresh.vue
@@ -39,8 +39,8 @@ export default defineComponent({
   name,
   components: { TLoading },
   props: PullDownRefreshProps,
-  emits: ['refresh', 'timeout'],
-  setup(props: any, context: SetupContext) {
+  emits: ['refresh', 'timeout', 'update:value', 'update:modelValue'],
+  setup(props, context: SetupContext) {
     const emitEvent = useEmitEvent(props, context.emit);
 
     // 动态生成style
diff --git a/src/shared/useCountDown/index.ts b/src/shared/useCountDown/index.ts
index 5aea3542..ef4d2472 100644
--- a/src/shared/useCountDown/index.ts
+++ b/src/shared/useCountDown/index.ts
@@ -8,7 +8,7 @@ export function useCountDown(props: TdUseCountDownProps): TdUseCountDown {
   // state
   const fps = ref();
   const count = ref(Number(time));
-  const showTimes = reactive(getShowTimes(getRemainTimes(time), format));
+  const showTimes = reactive(getShowTimes(getRemainTimes(time), format, !!millisecond));
 
   // raf
   const { pause, resume } = useRafFn(
diff --git a/src/shared/useCountDown/utils.ts b/src/shared/useCountDown/utils.ts
index 5361bf8a..a2084aef 100644
--- a/src/shared/useCountDown/utils.ts
+++ b/src/shared/useCountDown/utils.ts
@@ -30,7 +30,17 @@ export const getRemainTimes = (time?: number): TimeData => {
  * @param num
  * @returns
  */
-export const fillZero = (num: number): string | number => (num >= 10 ? num : `0${num}`);
+export const fillZero = (num: number, isMillieconds = false): string | number => {
+  if (isMillieconds) {
+    if (num >= 100) {
+      return num;
+    }
+
+    return num >= 10 ? `0${num}` : `00${num}`;
+  }
+
+  return num >= 10 ? num : `0${num}`;
+};
 
 /**
  * getMark
@@ -45,8 +55,9 @@ export const getMark = (format: string, type: string): string => format?.split?.
  * @param time
  * @returns
  */
-export const getShowTimes = (times: TimeData, format: string): TdUseCountDownShowTimes => {
+export const getShowTimes = (times: TimeData, format: string, milliseconds = false): TdUseCountDownShowTimes => {
   format = (format || 'DD:HH:mm:ss')?.toUpperCase?.();
+  milliseconds && !format.includes(':SSS') && (format = format.concat(':SSS'));
   const showTimes: TdUseCountDownShowTimes = [];
   if (format?.indexOf('DD') > -1) {
     showTimes?.push({
@@ -75,7 +86,7 @@ export const getShowTimes = (times: TimeData, format: string): TdUseCountDownSho
   if (format?.indexOf('SSS') > -1) {
     showTimes?.push({
       mark: getMark(format, 'SSS'),
-      value: fillZero(times?.milliseconds),
+      value: fillZero(times?.milliseconds, true),
     });
   }
 
diff --git a/src/tab-bar/tab-bar-item.vue b/src/tab-bar/tab-bar-item.vue
index 837bdc04..d95ab673 100644
--- a/src/tab-bar/tab-bar-item.vue
+++ b/src/tab-bar/tab-bar-item.vue
@@ -91,6 +91,7 @@ export default defineComponent({
         isSpread.value = !isSpread.value;
         if (!isToggleCurrent.value) {
           updateChild([currentName]);
+          return;
         }
       }
       updateChild(currentName);
diff --git a/src/tab-bar/tab-bar.vue b/src/tab-bar/tab-bar.vue
index e26b570c..01d900c3 100644
--- a/src/tab-bar/tab-bar.vue
+++ b/src/tab-bar/tab-bar.vue
@@ -5,7 +5,7 @@
 </template>
 
 <script lang="ts">
-import { defineComponent, ref, provide, watch, Ref, computed, SetupContext } from 'vue';
+import { defineComponent, ref, provide, Ref, computed, SetupContext } from 'vue';
 import TabBarProps from './props';
 import config from '../config';
 import { useDefault } from '../shared';
diff --git a/src/upload/demos/mobile.vue b/src/upload/demos/mobile.vue
index 8cc6cc88..df94ec96 100644
--- a/src/upload/demos/mobile.vue
+++ b/src/upload/demos/mobile.vue
@@ -45,7 +45,7 @@ export default defineComponent({
   components: {},
   setup() {
     const addIcon = () => h(AddIcon);
-    const onFail = ({ e, file }) => {
+    const onFail = ({ file, e }: { file: UploadFile; e: ProgressEvent }) => {
       console.log('---onFail', file, e);
       return null;
     };
@@ -58,7 +58,7 @@ export default defineComponent({
     const onChange = (files: Array<UploadFile>, { e, response, trigger, index, file }: UploadChangeContext) => {
       console.log('====onChange', files, e, response, trigger, index, file);
     };
-    const onPreview = ({ file, e }) => {
+    const onPreview = ({ file, e }: { file: UploadFile; e: MouseEvent }) => {
       console.log('====onPreview', file, e);
     };
     const onSuccess = ({ file, fileList, response, e }: SuccessContext) => {
@@ -71,7 +71,6 @@ export default defineComponent({
       console.log('====onSelectChange', files);
     };
     const action = 'https://service-bv448zsw-1257786608.gz.apigw.tencentcs.com/api/upload-demo';
-    // const action = 'www';
     const max = 10;
     return {
       action,
diff --git a/src/upload/upload.vue b/src/upload/upload.vue
index 6d4dbf48..a7af0687 100644
--- a/src/upload/upload.vue
+++ b/src/upload/upload.vue
@@ -57,9 +57,10 @@ import xhr from '../_common/js/upload/xhr';
 import { useDefault, useEmitEvent, isFunction, isArray, isObject, renderTNode, TNode } from '../shared';
 import { TdUploadProps, UploadFile, RequestMethodResponse, SizeLimitObj } from './type';
 import { SuccessContext, InnerProgressContext } from './interface';
-import props from './props';
+import UploadProps from './props';
 import config from '../config';
 import { isOverSizeLimit } from './util';
+import TImageViewer from '../image-viewer';
 
 const { prefix } = config;
 const name = `${prefix}-upload`;
@@ -70,8 +71,9 @@ export default defineComponent({
     TNode,
     CloseIcon,
     RefreshIcon,
+    TImageViewer,
   },
-  props,
+  props: UploadProps,
   emits: [
     'update:files',
     'update:modelValue',
@@ -83,7 +85,7 @@ export default defineComponent({
     'success',
     'select-change',
   ],
-  setup(props: any, context: SetupContext) {
+  setup(props, context: SetupContext) {
     const emitEvent = useEmitEvent(props, context.emit);
     const [innerFiles, setInnerFiles] = useDefault<TdUploadProps['files'], TdUploadProps>(
       props,
@@ -150,13 +152,13 @@ export default defineComponent({
       });
     };
 
-    const handleReload = (file: any) => {
-      uploadFiles([file]);
+    const handleReload = (file: UploadFile) => {
+      uploadFiles([file.fileRaw]);
     };
 
-    const handleChange = (event: Event) => {
-      const input = <HTMLInputElement>inputRef.value;
-      if (props.disabled || !input.files) return;
+    const handleChange = () => {
+      const input = inputRef.value;
+      if (props.disabled || !input || !input.files) return;
       const formatFiles = formatFileToUploadFile(input.files);
       emitEvent('select-change', [...formatFiles]);
       uploadFiles(formatFiles);
@@ -164,7 +166,8 @@ export default defineComponent({
     };
 
     const formatFileToUploadFile = (files: FileList): File[] => {
-      if (!props.format || !isFunction(props.format)) {
+      const { format } = props;
+      if (!format || !isFunction(format)) {
         const res = [];
         for (let i = 0; i < files.length; i++) {
           res.push(files[i]);
@@ -174,7 +177,7 @@ export default defineComponent({
 
       const NewFiles = [...files];
       NewFiles.forEach((item) => {
-        item = props.format?.(item);
+        item = format(item) as any;
       });
       return NewFiles;
     };
@@ -232,7 +235,7 @@ export default defineComponent({
           status: 'waiting',
         };
         const reader = new FileReader();
-        reader.readAsDataURL(fileRaw as any);
+        reader.readAsDataURL(fileRaw);
         reader.onload = (event: ProgressEvent<FileReader>) => {
           uploadFile.url = event.target?.result as string;
         };
@@ -311,6 +314,7 @@ export default defineComponent({
             file: file.fileRaw,
             ...props.data,
           },
+          file,
           method: props.method,
           headers: props.headers || {},
           withCredentials: props.withCredentials,
@@ -379,7 +383,7 @@ export default defineComponent({
       }
       file.url = res?.url || file.url;
       // 从待上传文件队列中移除上传成功的文件
-      const index = findIndex(toUploadFiles.value, (o: any) => o.name === file.name);
+      const index = findIndex(toUploadFiles.value, (o: UploadFile) => o.name === file.name);
       toUploadFiles.value.splice(index, 1);
       // 上传成功的文件发送到 files
       const newFile = { ...file, response: res };
@@ -399,7 +403,7 @@ export default defineComponent({
       file: UploadFile;
       response?: any;
       resFormatted?: boolean;
-    }): any => {
+    }) => {
       const { event, file, response, resFormatted } = options;
       file.status = 'fail';
       let res = response;


Test patch:
diff --git a/site/web/test-coverage.js b/site/web/test-coverage.js
index d35a1fbb..ef34f280 100644
--- a/site/web/test-coverage.js
+++ b/site/web/test-coverage.js
@@ -1,1 +1,11 @@
-module.exports = {"button":{"statements":"98.57%","branches":"58.82%","functions":"100%","lines":"98.57%"},"checkbox":{"statements":"93.47%","branches":"75.8%","functions":"80%","lines":"93.47%"},"drawer":{"statements":"100%","branches":"93.75%","functions":"100%","lines":"100%"},"fab":{"statements":"100%","branches":"100%","functions":"100%","lines":"100%"},"overlay":{"statements":"92.55%","branches":"100%","functions":"20%","lines":"92.55%"},"popup":{"statements":"95%","branches":"64.7%","functions":"55.55%","lines":"95%"},"shared":{"statements":"70.58%","branches":"63.15%","functions":"25%","lines":"70.58%"},"switch":{"statements":"98.05%","branches":"92.85%","functions":"100%","lines":"98.05%"},"toast":{"statements":"100%","branches":"80%","functions":"100%","lines":"100%"}}
+module.exports = {
+  button: { statements: '98.57%', branches: '58.82%', functions: '100%', lines: '98.57%' },
+  checkbox: { statements: '93.47%', branches: '75.8%', functions: '80%', lines: '93.47%' },
+  drawer: { statements: '100%', branches: '93.75%', functions: '100%', lines: '100%' },
+  fab: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' },
+  overlay: { statements: '92.55%', branches: '100%', functions: '20%', lines: '92.55%' },
+  popup: { statements: '95%', branches: '64.7%', functions: '55.55%', lines: '95%' },
+  shared: { statements: '70.58%', branches: '63.15%', functions: '25%', lines: '70.58%' },
+  switch: { statements: '98.05%', branches: '92.85%', functions: '100%', lines: '98.05%' },
+  toast: { statements: '100%', branches: '80%', functions: '100%', lines: '100%' },
+};
diff --git a/src/collapse/__test__/index.test.jsx b/src/collapse/__test__/index.test.jsx
new file mode 100644
index 00000000..bb792e58
--- /dev/null
+++ b/src/collapse/__test__/index.test.jsx
@@ -0,0 +1,225 @@
+import { ref, reactive } from 'vue';
+import { mount } from '@vue/test-utils';
+import { Collapse, CollapsePanel } from '../index';
+
+describe('Collapse', () => {
+  describe('collapse props', () => {
+    test('defaultExpandAll', async () => {
+      const defaultExpandAll = ref(false);
+      const wrapper = mount({
+        setup() {
+          return () => (
+            <Collapse defaultExpandAll={defaultExpandAll}>
+              <CollapsePanel ref="1" value="1" header="标题1" content="内容1" />
+              <CollapsePanel ref="2" value="2" header="标题2" content="内容2" />
+            </Collapse>
+          );
+        },
+      });
+
+      const panel1 = wrapper.findComponent({ ref: '1' });
+      const panel2 = wrapper.findComponent({ ref: '2' });
+
+      expect(panel1.classes()).not.toContain('t-collapse-panel--active');
+      expect(panel2.classes()).not.toContain('t-collapse-panel--active');
+
+      defaultExpandAll.value = true;
+      await wrapper.vm.$nextTick();
+
+      expect(panel1.classes()).toContain('t-collapse-panel--active');
+      expect(panel2.classes()).toContain('t-collapse-panel--active');
+    });
+
+    test('default-value', async () => {
+      const wrapper = mount({
+        setup() {
+          return () => (
+            <Collapse default-value={['1']}>
+              <CollapsePanel ref="1" value="1" header="标题1" content="内容1" />
+              <CollapsePanel ref="2" value="2" header="标题2" content="内容2" />
+            </Collapse>
+          );
+        },
+      });
+
+      const panel1 = wrapper.findComponent({ ref: '1' });
+      const panel2 = wrapper.findComponent({ ref: '2' });
+
+      expect(panel1.classes()).toContain('t-collapse-panel--active');
+      expect(panel2.classes()).not.toContain('t-collapse-panel--active');
+    });
+
+    test('value', async () => {
+      const val = ref([]);
+      const wrapper = mount({
+        setup() {
+          return () => (
+            <Collapse v-model={val.value}>
+              <CollapsePanel ref="1" value="1" header="标题1" content="内容1" />
+              <CollapsePanel ref="2" value="2" header="标题2" content="内容2" />
+            </Collapse>
+          );
+        },
+      });
+
+      const panel1 = wrapper.findComponent({ ref: '1' });
+      const panel2 = wrapper.findComponent({ ref: '2' });
+      await panel1.find('.t-collapse-panel__header').trigger('click');
+
+      expect(val.value).toHaveLength(1);
+      expect(val.value).toContain('1');
+
+      await panel2.find('.t-collapse-panel__header').trigger('click');
+
+      expect(val.value).toHaveLength(2);
+      expect(val.value).toContain('2');
+    });
+
+    test('expandMutex', async () => {
+      const val = ref([]);
+      const wrapper = mount({
+        setup() {
+          return () => (
+            <Collapse expandMutex v-model={val.value}>
+              <CollapsePanel ref="1" value="1" header="标题1" content="内容1" />
+              <CollapsePanel ref="2" value="2" header="标题2" content="内容2" />
+            </Collapse>
+          );
+        },
+      });
+
+      const panel1 = wrapper.findComponent({ ref: '1' });
+      const panel2 = wrapper.findComponent({ ref: '2' });
+      await panel1.find('.t-collapse-panel__header').trigger('click');
+
+      expect(val.value).toHaveLength(1);
+      expect(val.value).toContain('1');
+
+      await panel2.find('.t-collapse-panel__header').trigger('click');
+
+      expect(val.value).toHaveLength(1);
+      expect(val.value).toContain('2');
+    });
+
+    test('onChange', async () => {
+      const handleChange = vi.fn();
+      const wrapper = mount({
+        setup() {
+          return () => (
+            <Collapse onChange={handleChange}>
+              <CollapsePanel ref="1" value="1" header="标题" content="内容" />
+            </Collapse>
+          );
+        },
+      });
+
+      const panel = wrapper.findComponent({ ref: '1' });
+      await panel.find('.t-collapse-panel__header').trigger('click')
+
+      expect(handleChange).toHaveBeenCalled();
+    });
+
+    test('disabled', async () => {
+      const disabled = ref(false);
+      const handleChange = vi.fn();
+      const wrapper = mount({
+        setup() {
+          return () => (
+            <Collapse disabled={disabled.value} onChange={handleChange}>
+              <CollapsePanel ref="1" value="1" header="标题" content="内容" />
+            </Collapse>
+          );
+        },
+      });
+
+      const panel = wrapper.findComponent({ ref: '1' });
+      await panel.find('.t-collapse-panel__header').trigger('click');
+
+      expect(handleChange).toHaveBeenCalled();
+
+      disabled.value = true;
+      await wrapper.vm.$nextTick();
+      await panel.find('.t-collapse-panel__header').trigger('click');
+
+      expect(handleChange).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('collapse-panel props', () => {
+    test('header and content', async () => {
+      const wrapper = mount({
+        setup() {
+          return () => (
+            <Collapse>
+              <CollapsePanel ref="1" value="1" header="标题" content="内容" />
+            </Collapse>
+          );
+        },
+      });
+
+      const panel = wrapper.findComponent({ ref: '1' });
+
+      expect(panel.find('.t-collapse-panel__title').text()).toBe('标题');
+      expect(panel.find('.t-collapse-panel__body').text()).toBe('内容');
+    });
+
+    test('disabled', async () => {
+      const handleChange = vi.fn();
+      const wrapper = mount({
+        setup() {
+          return () => (
+            <Collapse onChange={handleChange}>
+              <CollapsePanel ref="1" value="1" disabled header="标题" content="内容" />
+            </Collapse>
+          );
+        },
+      });
+
+      const panel = wrapper.findComponent({ ref: '1' });
+
+      await panel.find('.t-collapse-panel__header').trigger('click')
+
+      expect(handleChange).not.toHaveBeenCalled();
+    });
+
+    test('headerRightContent', async () => {
+      const wrapper = mount({
+        setup() {
+          return () => (
+            <Collapse>
+              <CollapsePanel ref="1" value="1" headerRightContent="操作" header="标题" content="内容" />
+            </Collapse>
+          );
+        },
+      });
+
+      const panel = wrapper.findComponent({ ref: '1' });
+
+      expect(panel.find('.t-collapse-panel__header-right').text()).toBe('操作');
+    });
+
+    test('slot', async () => {
+      const wrapper = mount({
+        setup() {
+          return () => (
+            <Collapse>
+              <CollapsePanel ref="1" value="1">
+                {{
+                  header: () => <h4>标题</h4>,
+                  headerRightContent: () => <span>操作</span>,
+                  default: () => <div>内容</div>,
+                }}
+              </CollapsePanel>
+            </Collapse>
+          );
+        },
+      });
+
+      const panel = wrapper.findComponent({ ref: '1' });
+
+      expect(panel.find('.t-collapse-panel__title > h4').html()).toBe('<h4>标题</h4>');
+      expect(panel.find('.t-collapse-panel__header-extra > span').html()).toBe('<span>操作</span>');
+      expect(panel.find('.t-collapse-panel__body > div').html()).toBe('<div>内容</div>');
+    });
+  });
+});
diff --git a/src/count-down/__test__/index.test.jsx b/src/count-down/__test__/index.test.jsx
new file mode 100644
index 00000000..4be3bebc
--- /dev/null
+++ b/src/count-down/__test__/index.test.jsx
@@ -0,0 +1,103 @@
+import { nextTick } from 'vue';
+import { describe, it, expect, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+import CountDown from '../count-down.vue';
+
+const sleep = (duration) =>
+  new Promise((resolve) =>
+    setTimeout(() => {
+      resolve();
+    }, duration),
+  );
+
+describe('countdown.vue', async () => {
+  describe('props', async () => {
+    it('time', async () => {
+      const wrapper = mount(<CountDown time={2000}></CountDown>);
+      expect(wrapper.text()).toContain('00:00:02');
+      await sleep(1000);
+      expect(wrapper.text()).toContain('00:00:01');
+      await sleep(500);
+      expect(wrapper.text()).toContain('00:00:00');
+      await sleep(500);
+      await nextTick();
+      expect(wrapper.text()).toContain('default');
+    });
+
+    it('content', async () => {
+      const wrapper = mount(<CountDown time={1000} content="ok"></CountDown>);
+      await sleep(1500);
+      await nextTick();
+      expect(wrapper.text()).toContain('ok');
+
+      await wrapper.setProps({ content: 'change' });
+      expect(wrapper.text()).toContain('change');
+    });
+
+    it('format', async () => {
+      const wrapper = mount(<CountDown time={100000} format="mm:ss"></CountDown>);
+      expect(wrapper.text()).not.toContain('00:01:40');
+      expect(wrapper.text()).toContain('01:40');
+
+      const wrapper2 = mount(<CountDown time={100000} format="ss"></CountDown>);
+      expect(wrapper2.text()).not.toContain(':40');
+      expect(wrapper2.text()).toContain('40');
+
+      const wrapper3 = mount(<CountDown time={100000} format="中文"></CountDown>);
+      expect(wrapper3.find('.t-countdown---split-with-unit').exists()).toBe(false);
+
+      const wrapper4 = mount(<CountDown></CountDown>);
+      expect(wrapper4).toBeTruthy();
+    });
+
+    it('millisecond', async () => {
+      const wrapper = mount(<CountDown time={100000} millisecond={true}></CountDown>);
+      expect(wrapper.text()).toContain('00:01:40:000');
+    });
+
+    it('autoStart', async () => {
+      const wrapper = mount(<CountDown time={100000} autoStart={false}></CountDown>);
+      await sleep(1000);
+      expect(wrapper.text()).toContain('00:01:40');
+    });
+
+    it('size', async () => {
+      const wrapper = mount(<CountDown time={100000}></CountDown>);
+      expect(wrapper.find('.t-countdown--small').exists()).toBe(true);
+    });
+
+    it('theme', async () => {
+      const wrapper = mount(<CountDown time={100000}></CountDown>);
+      expect(wrapper.find('.t-countdown--default').exists()).toBe(true);
+    });
+  });
+
+  describe('events', async () => {
+    it('change', async () => {
+      const onChange = vi.fn();
+      const wrapper = mount(<CountDown time={2000} onChange={onChange}></CountDown>);
+      await sleep(1000);
+      expect(onChange).toHaveBeenCalled();
+    });
+
+    it('finish', async () => {
+      const onFinish = vi.fn();
+      const wrapper = mount(<CountDown time={1000} onFinish={onFinish}></CountDown>);
+      await sleep(1500);
+      expect(onFinish).toHaveBeenCalled();
+    });
+  });
+
+  describe('slots', async () => {
+    it('content', async () => {
+      const temp = <div>测试</div>;
+      const wrapper = mount(<CountDown time={1000}></CountDown>, {
+        slots: {
+          content: temp,
+        },
+      });
+      await sleep(1500);
+      expect(wrapper.text()).toContain('测试');
+    });
+  });
+});
diff --git a/src/dialog/__test__/index.test.jsx b/src/dialog/__test__/index.test.jsx
new file mode 100644
index 00000000..5e83cecb
--- /dev/null
+++ b/src/dialog/__test__/index.test.jsx
@@ -0,0 +1,183 @@
+import { mount } from '@vue/test-utils';
+import { describe, it, expect, vi } from 'vitest';
+import { nextTick } from 'vue';
+import Dialog from '../dialog.vue';
+import Button from '../../button/button.vue';
+
+describe('dialog', () => {
+  describe('props', () => {
+    it(': visible	', async () => {
+      const visible = true;
+      const title = 'this is a title';
+      const wrapper = mount(Dialog, {
+        props: {
+          visible,
+          title,
+        },
+      });
+
+      expect(wrapper.find('.t-overlay').isVisible()).toBe(true)
+      expect(wrapper.find('.t-dialog__header').text()).toBe(title);
+      await wrapper.setProps({
+        visible: false,
+      })
+      expect(wrapper.find('.t-overlay').isVisible()).toBe(false)
+
+    })
+
+    it(': title', async () => {
+      const visible = true;
+      const title = 'old title';
+      const wrapper = mount(Dialog, {
+        props: {
+          visible,
+          title,
+        },
+      });
+      expect(wrapper.find('.t-dialog__header').text()).toBe(title);
+      const newTitle = 'new title'
+      await wrapper.setProps({
+        title: newTitle
+      })
+      expect(wrapper.find('.t-dialog__header').text()).toBe(newTitle);
+    })
+
+    it(': content', async () => {
+      const visible = true;
+      const title = 'this is title';
+      const content = 'content is string';
+
+      const wrapper = mount(Dialog, {
+        props: {
+          visible,
+          title,
+          content,
+        }
+      });
+
+      expect(wrapper.find('.t-dialog__body').text()).toBe(content);
+      const newContent = 'new content';
+      await wrapper.setProps({
+        content: newContent
+      })
+      expect(wrapper.find('.t-dialog__body').text()).toBe(newContent);
+    });
+
+    it(': buttonLayout', async () => {
+      const visible = true;
+      const selector = 't-dialog__vertical-footer';
+      const wrapper = mount(Dialog, {
+        props: {
+          visible,
+        },
+      });
+      expect(wrapper.find('.t-dialog__footer').classes().includes(selector)).toBeFalsy();
+      await wrapper.setProps({
+        buttonLayout: 'vertical'
+      })
+      expect(wrapper.find('.t-dialog__footer').classes().includes(selector)).toBeTruthy();
+    })
+
+    it(': width', async () => {
+      const visible = true;
+      // width 为 string 类型
+      const width = '80%';
+      const wrapper = mount(Dialog, {
+        props: {
+          visible,
+          width,
+        },
+      });
+      expect(wrapper.find('.t-dialog').attributes('style').includes(`width: ${width}`)).toBeTruthy();
+      // width 为 number 类型
+      const newWidth = 320;
+      await wrapper.setProps({
+        width: newWidth
+      })
+      expect(wrapper.find('.t-dialog').attributes('style').includes(`width: ${newWidth}px`)).toBeTruthy();
+    })
+
+    it(': zIndex', async () => {
+      const visible = true;
+      const zIndex = 11500;
+      const wrapper = mount(Dialog, {
+        props: {
+          visible,
+          zIndex: zIndex,
+        },
+      });
+      // expect(wrapper).toMatchSnapshot()
+      expect(wrapper.find('.t-dialog').attributes('style').includes(`z-index: ${zIndex}`)).toBeTruthy();
+      const newZIndex = 12000;
+      await wrapper.setProps({
+        zIndex: newZIndex
+      })
+      expect(wrapper.find('.t-dialog').attributes('style').includes(`z-index: ${newZIndex}`)).toBeTruthy();
+    })
+
+    it(': closeOnOverlayClick', async () => {
+      let triggerOrigin = {};
+      const onClose = vi.fn((trigger) => {
+        triggerOrigin = trigger;
+      });
+      const onOverlayClick = vi.fn();
+      const closeOverlayClick = true;
+      const visible = true;
+      const wrapper = mount(Dialog, {
+        props: {
+          visible,
+          closeOverlayClick,
+          onClose,
+          onOverlayClick
+        },
+      });
+
+      const $overlay = wrapper.findComponent({ name: 't-overlay' })
+      expect($overlay.exists()).toBeTruthy();
+      $overlay.vm.$emit('click');
+      await nextTick();
+      expect(onClose).toBeCalledTimes(1)
+      expect(onOverlayClick).toBeCalledTimes(1)
+      expect(triggerOrigin).toBe('overlay')
+    })
+
+  });
+
+  describe('event', () => {
+    it(': cancel && confirm', async () => {
+      let triggerOrigin = {};
+      const visible = true;
+      const cancelBtn = 'cancel';
+      const confirmBtn = 'confirm';
+      const title = 'this is a title';
+      const onClose = vi.fn((trigger) => {
+        triggerOrigin = trigger;
+      });
+      const onCancel = vi.fn();
+      const onConfirm = vi.fn();
+      const wrapper = mount(Dialog, {
+        props: {
+          visible,
+          title,
+          cancelBtn,
+          confirmBtn,
+          onCancel,
+          onConfirm,
+          onClose
+        },
+      });
+      // expect(wrapper).toMatchSnapshot()
+      // footer
+      const $buttons = wrapper.findAllComponents(Button);
+      expect($buttons).toHaveLength(2);
+      expect($buttons.at(0).text()).toBe(cancelBtn);
+      expect($buttons.at(1).text()).toBe(confirmBtn);
+      $buttons.at(0).trigger('click');
+      expect(onClose).toBeCalledTimes(1)
+      expect(triggerOrigin).toBe('cancel')
+      expect(onCancel).toBeCalledTimes(1)
+      $buttons.at(1).trigger('click');
+      expect(onConfirm).toBeCalledTimes(1)
+    });
+  });
+})
diff --git a/src/loading/__test__/__snapshots__/index.test.jsx.snap b/src/loading/__test__/__snapshots__/index.test.jsx.snap
new file mode 100644
index 00000000..6bc891a2
--- /dev/null
+++ b/src/loading/__test__/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,114 @@
+// Vitest Snapshot v1
+
+exports[`Loading.vue > props > :pause 1`] = `
+<div
+  class="t-loading"
+  style=""
+>
+  
+  
+  <svg
+    class="t-loading__gradient t-icon-loading"
+    height="1em"
+    style="animation-play-state: paused; animation-duration: 800ms;"
+    version="1.1"
+    viewBox="0 0 14 14"
+    width="1em"
+    xmlns="http://www.w3.org/2000/svg"
+  >
+    <foreignobject
+      height="12"
+      width="12"
+      x="1"
+      y="1"
+    >
+      <div
+        class="t-loading__gradient-conic"
+      />
+    </foreignobject>
+  </svg>
+  
+  <!--v-if-->
+  <!---->
+  
+</div>
+`;
+
+exports[`Loading.vue > props > :reverse 1`] = `
+<div
+  class="t-loading"
+  style=""
+>
+  
+  
+  <svg
+    class="t-loading__gradient t-icon-loading"
+    height="1em"
+    style="animation-direction: reverse; animation-duration: 800ms;"
+    version="1.1"
+    viewBox="0 0 14 14"
+    width="1em"
+    xmlns="http://www.w3.org/2000/svg"
+  >
+    <foreignobject
+      height="12"
+      width="12"
+      x="1"
+      y="1"
+    >
+      <div
+        class="t-loading__gradient-conic"
+      />
+    </foreignobject>
+  </svg>
+  
+  <!--v-if-->
+  <!---->
+  
+</div>
+`;
+
+exports[`spinner.vue > render > :text 1`] = `
+<span
+  class="t-loading__spinner"
+>
+  
+  <i
+    class="t-loading__spinner--line t-loading__spinner--line-1"
+  />
+  <i
+    class="t-loading__spinner--line t-loading__spinner--line-2"
+  />
+  <i
+    class="t-loading__spinner--line t-loading__spinner--line-3"
+  />
+  <i
+    class="t-loading__spinner--line t-loading__spinner--line-4"
+  />
+  <i
+    class="t-loading__spinner--line t-loading__spinner--line-5"
+  />
+  <i
+    class="t-loading__spinner--line t-loading__spinner--line-6"
+  />
+  <i
+    class="t-loading__spinner--line t-loading__spinner--line-7"
+  />
+  <i
+    class="t-loading__spinner--line t-loading__spinner--line-8"
+  />
+  <i
+    class="t-loading__spinner--line t-loading__spinner--line-9"
+  />
+  <i
+    class="t-loading__spinner--line t-loading__spinner--line-10"
+  />
+  <i
+    class="t-loading__spinner--line t-loading__spinner--line-11"
+  />
+  <i
+    class="t-loading__spinner--line t-loading__spinner--line-12"
+  />
+  
+</span>
+`;
diff --git a/src/loading/__test__/index.test.jsx b/src/loading/__test__/index.test.jsx
new file mode 100644
index 00000000..c3550cd7
--- /dev/null
+++ b/src/loading/__test__/index.test.jsx
@@ -0,0 +1,98 @@
+import { mount } from '@vue/test-utils';
+import { describe, it, expect } from 'vitest';
+import Loading from '../loading.vue';
+import Spinner from '../icon/spinner.vue';
+import { nextTick } from "vue";
+
+describe('Loading.vue', () => {
+  describe('props', () => {
+    beforeEach(() => {
+      vi.useFakeTimers()
+    })
+
+    afterEach(() => {
+      vi.restoreAllMocks()
+    })
+
+    it(':progress', () => {
+      const wrapper = mount(() => (
+          <Loading theme="bar" progress={0.5} />
+      ));
+
+      const shadow = wrapper.find('.t-loading__shadow');
+      expect(shadow.exists()).toBeTruthy();
+    });
+
+    it(':delay', async () => {
+      const wrapper = mount(() => (
+          <Loading delay={1000} />
+      ));
+      expect(wrapper.find('.t-icon-loading').exists()).not.toBeTruthy();
+      vi.runAllTimers();
+      await nextTick();
+      expect(wrapper.find('.t-icon-loading').exists()).toBeTruthy();
+    });
+
+    it(':error', () => {
+      const wrapper = mount(() => (
+          <Loading theme="error"/>
+      ));
+      const errorBlock = wrapper.find('.t-loading__text');
+      expect(errorBlock.element.innerHTML).toBe('加载失败');
+    });
+
+    it(':inheritColor', () => {
+      const wrapper = mount(Loading, {
+        props: {
+          inheritColor: true
+        },
+      });
+      expect(wrapper.vm.rootStyle).toBe('color: inherit')
+    });
+
+    it(':pause', () => {
+      const wrapper = mount(Loading, {
+        props: {
+          pause: true
+        },
+      });
+      expect(wrapper.element).toMatchSnapshot();
+      const iconWrapper = wrapper.find('.t-loading__gradient')
+      const iconWrapperStyle = iconWrapper.attributes().style;
+      expect(iconWrapperStyle).toContain('animation-play-state: paused')
+    });
+
+    it(':reverse', () => {
+      const wrapper = mount(() => (
+          <Loading reverse={true}/>
+      ));
+      expect(wrapper.element).toMatchSnapshot();
+      const iconWrapper = wrapper.find('.t-loading__gradient')
+      const iconWrapperStyle = iconWrapper.attributes().style;
+      expect(iconWrapperStyle).toContain('animation-direction: reverse')
+    });
+  })
+
+  describe('slots', () => {
+    it(':text', () => {
+      const customLoading = 'custom-loading-text'
+      const wrapper = mount(() => (
+          <Loading>{{ text: () => <p className={customLoading}>{customLoading}</p> }}</Loading>
+      ));
+      const text = wrapper.find('.t-loading__text');
+      expect(text.exists()).toBeTruthy();
+      expect(text.element.innerHTML).toBe(`<p class="${customLoading}">${customLoading}</p>`);
+    });
+  })
+})
+
+describe('spinner.vue', () => {
+  describe('render', () => {
+    it(':text', () => {
+      const wrapper = mount(() => (
+          <Spinner/>
+      ));
+      expect(wrapper.element).toMatchSnapshot();
+    });
+  })
+})
diff --git a/src/message/__test__/index.test.jsx b/src/message/__test__/index.test.jsx
new file mode 100644
index 00000000..9f8c35be
--- /dev/null
+++ b/src/message/__test__/index.test.jsx
@@ -0,0 +1,54 @@
+import { mount } from '@vue/test-utils';
+import { describe, it, expect } from 'vitest';
+import { ref, nextTick } from "vue";
+import Message from '../message.vue';
+
+describe('Message.vue', () => {
+  describe('event', () => {
+    it('open', async () => {
+      const fn = vi.fn();
+      const visible = ref(false);
+      const wrapper = mount(() => (
+        <Message v-model={visible.value} content="这是一条消息通知" onOpen={fn}/>
+      ));
+      visible.value = true;
+      await nextTick();
+      expect(fn).toBeCalledTimes(1);
+      visible.value = false;
+      await nextTick();
+      expect(fn).toBeCalledTimes(1);
+    });
+
+    it('close', () => {
+      const fn = vi.fn();
+      const wrapper = mount(() => (
+        <Message visible={true} content="这是一条带关闭的消息通知，常驻可关闭" closeBtn={true} onClose={fn}/>
+      ));
+      const closeBtn = wrapper.find('.t-icon-close');
+      closeBtn.trigger('click');
+      expect(fn).toBeCalledTimes(1);
+    });
+
+    it('after-enter-leave', async () => {
+      const fn = vi.fn();
+      const wrapper = mount(() => (
+        <Message visible={true} onOpened={fn} onClosed={fn}/>
+      ));
+      expect(fn).not.toBeCalled();
+      wrapper.findComponent(Message).vm.afterEnter();
+      expect(fn).toBeCalledTimes(1);
+      wrapper.findComponent(Message).vm.afterLeave();
+      expect(fn).toBeCalledTimes(2);
+    });
+  })
+
+  describe('slots', () => {
+    it('icon:success', () => {
+      const wrapper = mount(() => (
+        <Message visible={true} content="这是一条消息通知" theme={'success'}/>
+      ));
+      const successIcon = wrapper.find('.t-icon-check-circle-filled');
+      expect(successIcon).toBeTruthy();
+    });
+  })
+});
\ No newline at end of file
diff --git a/src/pull-down-refresh/__test__/__snapshots__/index.test.jsx.snap b/src/pull-down-refresh/__test__/__snapshots__/index.test.jsx.snap
new file mode 100644
index 00000000..dba2a2c9
--- /dev/null
+++ b/src/pull-down-refresh/__test__/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,26 @@
+// Vitest Snapshot v1
+
+exports[`PullDownRefresh > : props > : others 1`] = `
+<div
+  class="t-pull-down-refresh"
+>
+  <div
+    class="t-pull-down-refresh__track"
+    style="transition-duration: 300ms; transform: translate3d(0, 0px, 0);"
+  >
+    <div
+      class="t-pull-down-refresh__max"
+      style="height: 80px;"
+    >
+      <div
+        class="t-pull-down-refresh__loading"
+        style="height: 50px;"
+      >
+        <div />
+      </div>
+    </div>
+    
+    
+  </div>
+</div>
+`;
diff --git a/src/pull-down-refresh/__test__/index.test.jsx b/src/pull-down-refresh/__test__/index.test.jsx
new file mode 100644
index 00000000..5fa3eed9
--- /dev/null
+++ b/src/pull-down-refresh/__test__/index.test.jsx
@@ -0,0 +1,129 @@
+import { nextTick, ref } from 'vue';
+import { mount } from '@vue/test-utils';
+import { beforeAll, vi } from 'vitest';
+import PullDownRefresh from '../index';
+import { MockResizeObserver, trigger } from './utils';
+
+describe('PullDownRefresh', () => {
+  beforeAll(() => {
+    global.ResizeObserver = MockResizeObserver;
+  });
+
+  describe(': props', () => {
+    it(': loadingBarHeight', async () => {
+      const wrapper = mount(<PullDownRefresh value={true} loadingBarHeight={30} />);
+      const loadingBar = wrapper.find('.t-pull-down-refresh__loading');
+      expect(window.getComputedStyle(loadingBar.element).height).toBe(`${30}px`);
+
+      await wrapper.setProps({ loadingBarHeight: '90px' });
+      expect(window.getComputedStyle(loadingBar.element).height).toBe(`${90}px`);
+    });
+
+    it(': others', async () => {
+      const status = ref(false);
+      const loadingProps = {
+        theme: 'circular',
+      };
+      const loadingTexts = ['a', 'b', 'c', 'd'];
+      const maxBarHeight = ref(80);
+      const onChange = vi.fn((value) => {
+        status.value = value;
+      });
+      const onRefresh = vi.fn(() => {
+        status.value = true;
+        setTimeout(() => {
+          status.value = false;
+        }, 2000);
+      });
+
+      const wrapper = mount(
+        <PullDownRefresh
+          value={status.value}
+          loadingProps={loadingProps}
+          loadingTexts={loadingTexts}
+          maxBarHeight={maxBarHeight.value}
+          onChange={onChange}
+          onRefresh={onRefresh}
+        />,
+      );
+      const track = wrapper.find('.t-pull-down-refresh__track');
+      const maxBar = wrapper.find('.t-pull-down-refresh__max');
+
+      await nextTick();
+      maxBar.trigger('resize', {
+        height: 80,
+      });
+
+      trigger(track, 'touchstart', 50, 0);
+      await wrapper.setProps({ value: true, maxBarHeight: '80px' });
+      trigger(track, 'touchmove', 50, 20);
+      trigger(track, 'touchmove', 50, 70);
+      trigger(track, 'touchmove', 50, 120);
+      trigger(track, 'touchmove', 50, 170);
+      expect(wrapper.vm.loadingText).toBe('b'); // loosing
+      expect(window.getComputedStyle(maxBar.element).height).toBe(`${80}px`);
+      trigger(track, 'touchend', 50, 170);
+      expect(wrapper.element).toMatchSnapshot();
+
+      await new Promise((resolve, reject) => {
+        setTimeout(() => resolve(), 350); // 大于 300ms
+      });
+    });
+  });
+
+  describe(': events', () => {
+    it('functions and refreshTimeout', async () => {
+      const status = ref(false);
+      const refreshTimeout = 500;
+
+      const onChange = vi.fn((value) => {
+        status.value = value;
+      });
+      const onRefresh = vi.fn(() => {
+        status.value = true;
+        setTimeout(() => {
+          status.value = false;
+        }, 1000); // 大于设定值
+      });
+      const onTimeout = vi.fn();
+
+      const wrapper = mount(
+        <PullDownRefresh
+          value={status.value}
+          refreshTimeout={refreshTimeout}
+          onChange={onChange}
+          onRefresh={onRefresh}
+          onTimeout={onTimeout}
+        />,
+      );
+
+      const target = wrapper.find('.t-pull-down-refresh__track');
+      const maxBar = wrapper.find('.t-pull-down-refresh__max');
+
+      await nextTick();
+      maxBar.trigger('resize', {
+        height: 80,
+      });
+
+      trigger(target, 'touchstart', 50, 0);
+      await wrapper.setProps({ value: true });
+      trigger(target, 'touchmove', 50, 20);
+      trigger(target, 'touchmove', 50, 70);
+      trigger(target, 'touchmove', 50, 120);
+      trigger(target, 'touchmove', 50, 170);
+      trigger(target, 'touchend', 50, 180);
+
+      await new Promise((resolve, reject) => {
+        setTimeout(() => resolve(), 1000); // 超时 1000ms
+      });
+
+      expect(wrapper.emitted()).toHaveProperty('update:value'); // useModel 受控模式
+
+      expect(onChange).toHaveBeenCalled();
+      expect(onRefresh).toHaveBeenCalled();
+      expect(onTimeout).toHaveBeenCalled();
+
+      wrapper.unmount();
+    });
+  });
+});
diff --git a/src/pull-down-refresh/__test__/utils.ts b/src/pull-down-refresh/__test__/utils.ts
new file mode 100644
index 00000000..3f73b99c
--- /dev/null
+++ b/src/pull-down-refresh/__test__/utils.ts
@@ -0,0 +1,97 @@
+import { ComponentPublicInstance, nextTick } from 'vue';
+import { VueWrapper, DOMWrapper } from '@vue/test-utils';
+
+function getTouch(el: Element | Window, x: number, y: number) {
+  return {
+    identifier: Date.now(),
+    target: el,
+    pageX: x,
+    pageY: y,
+    clientX: x,
+    clientY: y,
+    radiusX: 2.5,
+    radiusY: 2.5,
+    rotationAngle: 10,
+    force: 0.5,
+  };
+}
+
+// 模拟 touch 事件
+export function trigger(
+  wrapper: VueWrapper<ComponentPublicInstance<any, any, any>> | DOMWrapper<Element> | Element | Window,
+  eventName: string,
+  x = 0,
+  y = 0,
+  options: any = {},
+) {
+  const el = 'element' in wrapper ? wrapper.element : wrapper;
+  const touchList = options.touchList || [getTouch(el, x, y)];
+
+  if (options.x || options.y) {
+    touchList.push(getTouch(el, options.x, options.y));
+  }
+
+  const event = document.createEvent('CustomEvent');
+  event.initCustomEvent(eventName, true, true, {});
+
+  Object.assign(event, {
+    clientX: x,
+    clientY: y,
+    touches: touchList,
+    targetTouches: touchList,
+    changedTouches: touchList,
+  });
+
+  el.dispatchEvent(event);
+
+  return nextTick();
+}
+
+/**
+ * PullDownRefresh 使用了 [`useElementSize()`](https://github.com/vueuse/vueuse/blob/658374fd12/packages/core/useElementSize/index.ts)，
+ * 该函数调用了 [`useResizeObserver()`](https://github.com/vueuse/vueuse/blob/658374fd12fbce2ac6127a9fb9bca52fb2137505/packages/core/useResizeObserver/index.ts)。
+ *
+ * 而 jsdom 中 ResizeObserver 无效，见： https://github.com/jsdom/jsdom/issues/3368
+ *
+ * @description 模拟实现 ResizeObserver
+ * @params callback `new ResizeObserver(callback)` 中的回调，签名如下：
+ *
+ * ```ts
+ * ResizeObserverCallback = (entries: ReadonlyArray<ResizeObserverEntry>, observer: ResizeObserver) => void
+ * ```
+ */
+export class MockResizeObserver {
+  _callback: Function;
+
+  _element!: HTMLElement;
+
+  constructor(callback: Function) {
+    this._callback = callback;
+  }
+
+  observe(element: HTMLElement) {
+    this._element = element;
+    this._element.addEventListener('resize', this.trigger);
+  }
+
+  unobserve() {
+    this._element.removeEventListener('resize', this.trigger);
+  }
+
+  disconnect() {
+    this._element.removeEventListener('resize', this.trigger);
+  }
+
+  /**
+   * @description 手动触发 resize，提供参数。本例 `maxBarHeight` 始终为 `useElementSize()` 的默认初始值 `initialSize`（`{ width: 0, height: 0 }`），模拟返回有效值即可
+   * @param event 手动触发
+   */
+  trigger = (event: UIEvent) => {
+    const { width = 0, height = 0 } = event as { width?: number; height?: number };
+    this._callback([
+      {
+        contentRect: { width, height },
+      },
+    ]);
+  };
+}
diff --git a/src/tab-bar/__test__/__snapshots__/index.test.jsx.snap b/src/tab-bar/__test__/__snapshots__/index.test.jsx.snap
new file mode 100644
index 00000000..63a6bfcd
--- /dev/null
+++ b/src/tab-bar/__test__/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,152 @@
+// Vitest Snapshot v1
+
+exports[`TabBar > props > snapshot 1`] = `
+<div
+  class="t-tab-bar t-tab-bar--bordered t-tab-bar--fixed"
+  role="tablist"
+>
+  
+  
+  <div
+    aria-label="TabBar"
+    aria-selected="false"
+    class="t-tab-bar-item t-no-border"
+    name="label_1"
+    role="tab"
+    text="标签一"
+  >
+    <div
+      class="t-tab-bar-item__content"
+    >
+      <div
+        class="t-tab-bar-item__icon"
+      >
+        <div
+          class="t-badge"
+        >
+          <div
+            class="t-badge__inner t-badge--has-children t-badge--medium t-badge--circle"
+            style="right: 0px; top: 5px;"
+          >
+            1
+          </div>
+          
+          <svg
+            class="t-icon t-icon-app"
+            fill="none"
+            height="1em"
+            viewBox="0 0 16 16"
+            width="1em"
+          >
+            <path
+              d="M11.25 1.75a3 3 0 100 6 3 3 0 000-6zm-2 3a2 2 0 114 0 2 2 0 01-4 0zM2 3a1 1 0 011-1h3.5a1 1 0 011 1v3.5a1 1 0 01-1 1H3a1 1 0 01-1-1V3zm1 0v3.5h3.5V3H3zM2 9.5a1 1 0 011-1h3.5a1 1 0 011 1V13a1 1 0 01-1 1H3a1 1 0 01-1-1V9.5zm1 0V13h3.5V9.5H3zM8.5 9.5a1 1 0 011-1H13a1 1 0 011 1V13a1 1 0 01-1 1H9.5a1 1 0 01-1-1V9.5zm1 3.5H13V9.5H9.5V13z"
+              fill="currentColor"
+              fill-opacity="0.9"
+            />
+          </svg>
+          
+        </div>
+      </div>
+      <!--v-if-->
+    </div>
+    <transition-stub>
+      <!--v-if-->
+    </transition-stub>
+  </div>
+  <div
+    aria-label="TabBar"
+    aria-selected="false"
+    class="t-tab-bar-item t-no-border"
+    name="label_2"
+    role="tab"
+    text="标签二"
+  >
+    <div
+      class="t-tab-bar-item__content"
+    >
+      <div
+        class="t-tab-bar-item__icon"
+      >
+        <div
+          class="t-badge"
+        >
+          <div
+            class="t-badge__inner t-badge--has-children t-badge--medium t-badge--circle"
+            style="right: 0px; top: 5px;"
+          >
+            1
+          </div>
+          
+          <svg
+            class="t-icon t-icon-app"
+            fill="none"
+            height="1em"
+            viewBox="0 0 16 16"
+            width="1em"
+          >
+            <path
+              d="M11.25 1.75a3 3 0 100 6 3 3 0 000-6zm-2 3a2 2 0 114 0 2 2 0 01-4 0zM2 3a1 1 0 011-1h3.5a1 1 0 011 1v3.5a1 1 0 01-1 1H3a1 1 0 01-1-1V3zm1 0v3.5h3.5V3H3zM2 9.5a1 1 0 011-1h3.5a1 1 0 011 1V13a1 1 0 01-1 1H3a1 1 0 01-1-1V9.5zm1 0V13h3.5V9.5H3zM8.5 9.5a1 1 0 011-1H13a1 1 0 011 1V13a1 1 0 01-1 1H9.5a1 1 0 01-1-1V9.5zm1 3.5H13V9.5H9.5V13z"
+              fill="currentColor"
+              fill-opacity="0.9"
+            />
+          </svg>
+          
+        </div>
+      </div>
+      <!--v-if-->
+    </div>
+    <transition-stub>
+      <!--v-if-->
+    </transition-stub>
+  </div>
+  <div
+    aria-haspopup="true"
+    aria-label="TabBar"
+    aria-selected="false"
+    class="t-tab-bar-item t-no-border"
+    name="label_3"
+    role="tab"
+    text="此处展开"
+  >
+    <div
+      class="t-tab-bar-item__content"
+    >
+      <div
+        class="t-tab-bar-item__icon"
+      >
+        <div
+          class="t-badge"
+        >
+          <div
+            class="t-badge__inner t-badge--has-children t-badge--medium t-badge--circle"
+            style="right: 0px; top: 5px;"
+          >
+            1
+          </div>
+          
+          <svg
+            class="t-icon t-icon-app"
+            fill="none"
+            height="1em"
+            viewBox="0 0 16 16"
+            width="1em"
+          >
+            <path
+              d="M11.25 1.75a3 3 0 100 6 3 3 0 000-6zm-2 3a2 2 0 114 0 2 2 0 01-4 0zM2 3a1 1 0 011-1h3.5a1 1 0 011 1v3.5a1 1 0 01-1 1H3a1 1 0 01-1-1V3zm1 0v3.5h3.5V3H3zM2 9.5a1 1 0 011-1h3.5a1 1 0 011 1V13a1 1 0 01-1 1H3a1 1 0 01-1-1V9.5zm1 0V13h3.5V9.5H3zM8.5 9.5a1 1 0 011-1H13a1 1 0 011 1V13a1 1 0 01-1 1H9.5a1 1 0 01-1-1V9.5zm1 3.5H13V9.5H9.5V13z"
+              fill="currentColor"
+              fill-opacity="0.9"
+            />
+          </svg>
+          
+        </div>
+      </div>
+      <!--v-if-->
+    </div>
+    <transition-stub>
+      <!--v-if-->
+    </transition-stub>
+  </div>
+  
+  
+</div>
+`;
diff --git a/src/tab-bar/__test__/index.test.jsx b/src/tab-bar/__test__/index.test.jsx
new file mode 100644
index 00000000..ed5468cb
--- /dev/null
+++ b/src/tab-bar/__test__/index.test.jsx
@@ -0,0 +1,149 @@
+import { describe, it, expect, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+import TabBar from '../tab-bar.vue'
+import TabBarItem from '../tab-bar-item.vue'
+import { nextTick, ref } from 'vue';
+import { AppIcon as TIconApp } from 'tdesign-icons-vue-next';
+
+const list = [
+  {
+    name: 'label_1',
+    text: '标签一',
+    icon: undefined,
+    value: '1'
+  },
+  {
+    name: 'label_2',
+    text: '标签二',
+    icon: undefined,
+    value: '2'
+  },
+  {
+    name: 'label_3',
+    text: '此处展开',
+    icon: undefined,
+    value: '3',
+    subTabBar: [
+      {
+        name: 'spread_1',
+        text: '展开项一',
+        icon: undefined,
+        value: '3-1',
+      },
+      {
+        name: 'spread_2',
+        text: '展开项二',
+        icon: undefined,
+        value: '3-2',
+      },
+      {
+        name: 'spread_3',
+        text: '展开项三',
+        icon: undefined,
+        value: '3-3',
+      },
+    ],
+  },
+];
+
+describe('TabBar', () => {
+  describe('props', () => {
+    it('bordered', async () => {
+      const wrapper = mount(TabBar, {
+        shallow: true
+      })
+      expect(wrapper.classes())
+        .toContain('t-tab-bar--bordered')
+
+      await wrapper.setProps({ 'bordered': false})
+      expect(wrapper.classes())
+        .not.toContain('t-tab-bar--bordered')
+    })
+
+    it('fixed', async () => {
+      const wrapper = mount(TabBar, {
+        shallow: true
+      })
+      expect(wrapper.classes())
+        .toContain('t-tab-bar--fixed')
+
+      await wrapper.setProps({ 'fixed': false})
+      expect(wrapper.classes())
+        .not.toContain('t-tab-bar--fixed')
+    })
+
+    it('defaultValue', async () => {
+      const wrapper = mount({
+        render: () => (
+          <TabBar defaultValue={"2"}>
+            {
+              list.map((item) => {
+                return <TabBarItem {...item}>{item.text}</TabBarItem>
+              })
+            }
+          </TabBar>
+        )
+      })
+
+      expect(wrapper.findAll('.t-tab-bar-item')).toHaveLength(list.length)
+      expect(wrapper.find('[aria-selected="true"]').attributes('text')).toEqual('标签二')
+    })
+
+    it ('snapshot', () => {
+      const icon = () => <TIconApp />
+      const wrapper = mount({
+        render: () => (
+          <TabBar>
+            {
+              list.map((item) => {
+                return <TabBarItem {...item} icon={icon} badgeProps={{count: 1}} />
+              })
+            }
+          </TabBar>
+        )
+      })
+      expect(wrapper.element).toMatchSnapshot();
+    })
+  })
+
+  describe('events', () => {
+    it ('click to change', async () => {
+      const onChange = vi.fn()
+      const value = ref('1')
+      const wrapper = mount({
+        render: () => (
+          <TabBar
+            v-model={value.value}
+            onChange={onChange}
+          >
+            {
+              list.map((item) => {
+                return <TabBarItem {...item} />
+              })
+            }
+          </TabBar>
+        )
+      })
+
+      // 从 bar1 切到 bar2
+      const bar2 = wrapper.find('[name="label_2"] > .t-tab-bar-item__content')
+      expect(onChange).toHaveBeenCalledTimes(0)
+      await bar2.trigger('click')
+      expect(onChange).toHaveBeenCalledTimes(1)
+      expect(onChange).toHaveBeenLastCalledWith('2')
+
+      // 从 bar2 切到 bar3
+      const bar3 = wrapper.find('[name="label_3"] > .t-tab-bar-item__content')
+      await bar3.trigger('click')
+      expect(onChange).toHaveBeenCalledTimes(2)
+      expect(onChange).toHaveBeenLastCalledWith(['3'])
+
+      // 点击 spread
+      const bar3_1 = wrapper.find('[name="label_3"] .t-tab-bar-item__spread-item')
+      await bar3_1.trigger('click')
+      expect(onChange).toHaveBeenCalledTimes(3)
+      expect(onChange).toHaveBeenLastCalledWith(['3', '3-1'])
+      expect(wrapper.find('[name="label_3"]').find('.t-tab-bar-item__spread-item').exists()).toBe(false)
+    })
+  })
+})
diff --git a/src/upload/__test__/index.test.jsx b/src/upload/__test__/index.test.jsx
new file mode 100644
index 00000000..7a8a1ce1
--- /dev/null
+++ b/src/upload/__test__/index.test.jsx
@@ -0,0 +1,451 @@
+import { ref, nextTick } from 'vue';
+import { mount } from '@vue/test-utils';
+import { describe, it, expect, vi } from 'vitest';
+import { AddIcon, CloseIcon, RefreshIcon, UploadIcon } from 'tdesign-icons-vue-next';
+import Upload from '../upload.vue';
+
+const mockFileFoo = new File([new ArrayBuffer(3)], 'foo.png', {
+  type: 'image/png',
+});
+
+const mockFileBar = new File([new ArrayBuffer(3)], 'bar.png', {
+  type: 'image/png',
+});
+
+const action = 'http://service-bv448zsw-1257786608.gz.apigw.tencentcs.com/api/upload-demo';
+
+const requestMethod = (file) =>
+  new Promise((resolve, reject) => {
+    resolve({
+      status: 'success',
+      response: { url: 'https://tdesign.gtimg.com/site/source/figma-pc.png' },
+    });
+  });
+
+const sleep = (timeout) => {
+  return new Promise((resolve, reject) => {
+    setTimeout(() => {
+      resolve();
+    }, timeout);
+  });
+};
+
+const triggerUploadFile = (node, fileList) => {
+  const target = node.find('input');
+  const { element } = target;
+
+  // 判断 HTMLInputElement 对象是否被处理过
+  if (target.element._hijack) {
+    element.files = [...element.files, ...fileList];
+  } else {
+    target.element._hijack = true;
+    Object.defineProperty(target.element, 'files', {
+      value: fileList,
+      writable: true,
+    });
+    // handleChange() 之后会执行 input.value = ''，因此需要清空 files，否则会持续积攒
+    Object.defineProperty(target.element, 'value', {
+      set(value) {
+        if (value === '') {
+          target.element.files = [];
+        }
+      },
+    });
+  }
+
+  target.trigger('change');
+};
+
+describe('Upload', () => {
+  describe('props', () => {
+    it(': action', async () => {
+      const onProgress = vi.fn();
+
+      const wrapper = mount(Upload, {
+        props: {
+          action,
+          onProgress,
+        },
+      });
+
+      triggerUploadFile(wrapper, [mockFileFoo]);
+
+      await sleep(1000);
+      expect(onProgress).toHaveBeenCalled();
+    });
+
+    it(': allowUploadDuplicateFile', async () => {
+      const props = {
+        autoUpload: false,
+        allowUploadDuplicateFile: true,
+      };
+
+      const wrapper = mount(Upload, {
+        props,
+      });
+
+      triggerUploadFile(wrapper, [mockFileFoo]);
+      triggerUploadFile(wrapper, [mockFileFoo]);
+
+      await nextTick();
+      expect(wrapper.vm.toUploadFiles.length).toBe(2);
+
+      wrapper.setProps({
+        allowUploadDuplicateFile: false,
+      });
+
+      triggerUploadFile(wrapper, [mockFileFoo]);
+
+      await nextTick();
+      expect(wrapper.vm.toUploadFiles.length).toBe(2);
+    });
+
+    it(': disabled', async () => {
+      const onSelectChange = vi.fn();
+
+      const props = {
+        autoUpload: false,
+        disabled: true,
+        onSelectChange,
+      };
+
+      const wrapper = mount(Upload, {
+        props,
+      });
+
+      await wrapper.findComponent(Upload).trigger('click');
+      expect(onSelectChange).not.toHaveBeenCalled();
+    });
+
+    it(': files', async () => {
+      const files = ref([]);
+
+      const wrapper = mount(<Upload requestMethod={requestMethod} v-model={files.value} />);
+
+      await wrapper.findComponent(Upload).setValue([mockFileFoo]);
+      expect(files.value.length).toBe(1);
+    });
+
+    it(': defaultFiles', async () => {
+      const defaultFiles = ref([]);
+
+      const wrapper = mount({
+        setup() {
+          return () => <Upload requestMethod={requestMethod} defaultFiles={defaultFiles.value} />;
+        },
+      });
+
+      triggerUploadFile(wrapper, [mockFileFoo]);
+      expect(defaultFiles.value.length).toBe(0);
+    });
+
+    it(': gridConfig', async () => {
+      const gridConfig = ref({
+        column: 2,
+      });
+
+      const props = {
+        requestMethod,
+        gridConfig: gridConfig.value,
+        multiple: true,
+        allowUploadDuplicateFile: true,
+      };
+
+      const wrapper = mount(Upload, {
+        props,
+      });
+
+      await wrapper.findComponent(Upload).setValue([mockFileFoo, mockFileBar, mockFileFoo, mockFileBar]);
+
+      expect(wrapper.vm.itemStyle).toStrictEqual({ flexBasis: `${100 / 2}%` });
+
+      gridConfig.value.column = 4;
+      expect(wrapper.vm.itemStyle).toStrictEqual({ flexBasis: `${100 / 4}%` });
+    });
+
+    it(': max', async () => {
+      const props = {
+        requestMethod,
+        allowUploadDuplicateFile: true,
+        max: 3,
+      };
+
+      const wrapper = mount(Upload, {
+        props,
+      });
+
+      triggerUploadFile(wrapper, [mockFileFoo, mockFileBar, mockFileFoo, mockFileBar]);
+      await nextTick();
+      expect(wrapper.vm.toUploadFiles.length).toBe(3);
+    });
+
+    it(': multiple', async () => {
+      const props = {
+        autoUpload: false,
+        multiple: true,
+      };
+
+      const wrapper = mount(Upload, {
+        props,
+      });
+
+      triggerUploadFile(wrapper, [mockFileFoo, mockFileBar]);
+      await nextTick();
+      expect(wrapper.vm.toUploadFiles.length).toBe(2);
+    });
+
+    it(': requestMethod', async () => {
+      const onSuccess = vi.fn();
+      const onFail = vi.fn();
+      const response = ref({
+        status: 'success',
+        response: { url: 'https://tdesign.gtimg.com/site/source/figma-pc.png' },
+      });
+
+      const props = {
+        allowUploadDuplicateFile: true,
+        onSuccess,
+        onFail,
+        requestMethod: (file) => new Promise((resolve, reject) => resolve(response.value)),
+      };
+
+      const wrapper = mount(Upload, {
+        props,
+      });
+
+      triggerUploadFile(wrapper, [mockFileFoo]);
+      await sleep(0);
+      expect(onSuccess).toHaveBeenCalledTimes(1);
+
+      response.value = {
+        status: 'success',
+        error: 'bar',
+        response: {
+          error: 'foo',
+        },
+      };
+      triggerUploadFile(wrapper, [mockFileFoo]);
+      await sleep(0);
+      expect(onSuccess).toHaveBeenCalledTimes(1);
+
+      response.value = {
+        status: 'fail',
+        error: 'bar',
+      };
+      triggerUploadFile(wrapper, [mockFileBar]);
+      await sleep(0);
+      expect(onFail).toHaveBeenCalledTimes(2);
+
+      response.value = undefined;
+      triggerUploadFile(wrapper, [mockFileBar]);
+      await sleep(0);
+      expect(onFail).toHaveBeenCalledTimes(2);
+
+      response.value = {
+        error: 'bar',
+      };
+      triggerUploadFile(wrapper, [mockFileBar]);
+      await sleep(0);
+      expect(onFail).toHaveBeenCalledTimes(2);
+
+      response.value = {
+        status: 'yes',
+      };
+      triggerUploadFile(wrapper, [mockFileBar]);
+      await sleep(0);
+      expect(onFail).toHaveBeenCalledTimes(2);
+    });
+
+    it(': sizeLimit', () => {
+      const props = {
+        requestMethod,
+        sizeLimit: {
+          size: 1,
+          unit: 'KB',
+          message: '图片大小不超过 {sizeLimit} KB',
+        },
+      };
+
+      const wrapper = mount(Upload, {
+        props,
+      });
+
+      const target = wrapper.findComponent(Upload);
+      triggerUploadFile(wrapper, [
+        new File([new ArrayBuffer(1023)], 'foo.png', {
+          type: 'image/png',
+        }),
+      ]);
+      expect(wrapper.vm.uploadFiles.length).toBe(1);
+
+      target.vm.uploadFiles = [];
+      triggerUploadFile(wrapper, [
+        new File([new ArrayBuffer(1025)], 'foo.png', {
+          type: 'image/png',
+        }),
+      ]);
+      expect(wrapper.vm.uploadFiles.length).toBe(0);
+    });
+  });
+
+  describe('slots', () => {
+    it(': deleteBtn', async () => {
+      const props = {
+        requestMethod,
+        deleteBtn: () => <AddIcon />,
+      };
+
+      const wrapper = mount(Upload, {
+        props,
+      });
+
+      triggerUploadFile(wrapper, [mockFileFoo]);
+      await nextTick();
+      expect(wrapper.findComponent(AddIcon).exists()).toBe(true);
+    });
+
+    it(': trigger', async () => {
+      const wrapper = mount(
+        <Upload>
+          <UploadIcon />
+        </Upload>,
+      );
+
+      await nextTick();
+
+      const triggerTarget = wrapper.findComponent(UploadIcon);
+      expect(triggerTarget.exists()).toBe(true);
+
+      const spy = vi.spyOn(wrapper.vm, 'triggerUpload');
+      await triggerTarget.trigger('click');
+      expect(spy).toHaveBeenCalled();
+    });
+  });
+
+  describe('event', () => {
+    it(': events', async () => {
+      const props = {
+        requestMethod,
+      };
+
+      const wrapper = mount(Upload, {
+        props,
+      });
+
+      triggerUploadFile(wrapper, [mockFileFoo]);
+
+      await sleep(0);
+      expect(wrapper.emitted()).toHaveProperty('select-change');
+      expect(wrapper.emitted()).toHaveProperty('success');
+
+      await wrapper.find('.t-upload__card-image').trigger('click');
+      expect(wrapper.emitted()).toHaveProperty('preview');
+
+      await wrapper.findComponent(CloseIcon).trigger('click');
+      expect(wrapper.emitted()).toHaveProperty('remove');
+    });
+  });
+
+  describe('function', () => {
+    it(': beforeUpload', async () => {
+      const canUpload = ref(false);
+      const beforeUpload = vi.fn(() => canUpload.value);
+
+      const props = {
+        requestMethod,
+        beforeUpload,
+      };
+
+      const wrapper = mount(Upload, {
+        props,
+      });
+
+      triggerUploadFile(wrapper, [mockFileFoo]);
+      await nextTick();
+      expect(beforeUpload).toHaveBeenCalled();
+      expect(beforeUpload).toHaveReturnedWith(false);
+
+      canUpload.value = true;
+      triggerUploadFile(wrapper, [mockFileBar]);
+      await nextTick();
+      expect(beforeUpload).toHaveReturnedWith(true);
+    });
+
+    it(': format', async () => {
+      const format = vi.fn((file) => ({ ...file, name: 'bar.png', raw: file }));
+
+      const props = {
+        requestMethod,
+        format,
+      };
+
+      const wrapper = mount(Upload, {
+        props,
+      });
+
+      triggerUploadFile(wrapper, [mockFileBar]);
+      await nextTick();
+      expect(format).toHaveBeenCalled();
+      expect(wrapper.vm.toUploadFiles[0].name).toBe('bar.png');
+    });
+
+    it(': onFail', async () => {
+      const onFail = vi.fn();
+
+      const props = {
+        requestMethod: () =>
+          new Promise((resolve, reject) =>
+            resolve({
+              status: 'fail',
+              error: 'bar',
+            }),
+          ),
+        onFail,
+      };
+
+      const wrapper = mount(Upload, {
+        props,
+      });
+
+      triggerUploadFile(wrapper, [mockFileFoo]);
+
+      await sleep(0);
+      expect(onFail).toHaveBeenCalled();
+
+      const spy = vi.spyOn(wrapper.vm, 'handleReload');
+      await wrapper.findComponent(RefreshIcon).trigger('click');
+      expect(spy).toHaveBeenCalled();
+    });
+
+    it(': on-functions', async () => {
+      const onSelectChange = vi.fn();
+      const onSuccess = vi.fn();
+      const onPreview = vi.fn();
+      const onRemove = vi.fn();
+
+      const props = {
+        requestMethod,
+        onSelectChange,
+        onSuccess,
+        onPreview,
+        onRemove,
+      };
+
+      const wrapper = mount(Upload, {
+        props,
+      });
+
+      triggerUploadFile(wrapper, [mockFileFoo]);
+
+      await sleep(0);
+      expect(onSelectChange).toHaveBeenCalled();
+      expect(onSuccess).toHaveBeenCalled();
+
+      await wrapper.find('.t-upload__card-image').trigger('click');
+      expect(onPreview).toHaveBeenCalled();
+
+      await wrapper.findComponent(CloseIcon).trigger('click');
+      expect(onRemove).toHaveBeenCalled();
+    });
+  });
+});