iT邦幫忙

2024 iThome 鐵人賽

DAY 5
3
Modern Web

為你自己寫 Vue Component系列 第 5

[為你自己寫 Vue Component] AtomicPagination

  • 分享至 

  • xImage
  •  

[為你自己寫 Vue Component] AtomicPagination

在 ERP 系統的列表頁面或部落格文章的總覽頁面,如果我們想要一次顯示大量資料,不僅會耗費大量的網路傳輸流量,也會使瀏覽器渲染畫面變得緩慢費時。為了解決這個問題,我們可能會選擇使用分頁的方式載入資料。這樣可以讓使用者不必一次取得所有資料,而是透過點擊指定分頁來查看特定區間的內容。

本篇的實作大量參考 React 的 Material-UIPagination 元件。

元件分析

元件架構

AtomicPagination 元件架構

  1. Button:按鈕(第一頁、前一頁、下一頁、最後一頁)。
  2. Pages:頁碼按鈕。
  3. Ellipsis:省略符號。
  4. Sibling:當前頁面前後固定顯示的頁碼。
  5. Boundary:開頭與結尾固定顯示的頁碼。

功能設計

在開始實作前,我們先研究各個 UI Library 的 Pagination 元件是如何設計的。

Element Plus

Element Plus Pagination

<template>
  <ElPagination
    v-model:current-page="page"
    background
    layout="prev, pager, next"
    :total="1000"
  />
</template>

Element Plus 的 <ElPagination> 用法非常簡單,最特別的是其排版可依照需求調整,例如範例中的 layout 設定先顯示上一頁的按鈕,再來是頁碼,最後是下一頁的按鈕。

如果我們的 layout 設定為 prev, next, pager,排版就會變成「上一頁」、「下一頁」、「頁碼」。

Element Plus Pagination Layout 設定

Vuetify

Vuetify Pagination

<template>
  <VPagination v-model="page" :length="15" :total-visible="8" />
</template>

Vuetify 的 <VPagination> 也非常簡單使用。比較特別的設定是 totalVisible,可以設定頁碼按鈕顯示的總數量。不過目前看起來,這項設定只有在頁碼處於最前面或最後面的情況下,才會正確顯示。

Vuetify Pagination totalVisible 設定

另外 <VPagination> 的按鈕外觀設定繼承了 <VBtn> 的設定。如果需要定制按鈕樣式,可以透過 variant 這個 prop,接受的值與 <VBtn> 一致,開發人員能夠輕易地調整和統一整個網站的風格。

Nuxt UI

Nuxt UI Pagination

<template>
  <UPagination v-model="page" size="xl" :total="100" show-last show-first />
</template>

Nuxt UI 的 <UPagination> 也很簡單使用,特別的是 Nuxt UI 可以設定 showLastshowFirst,來控制是否顯示「第一頁」與「最後一頁」的按鈕。

在研究 <AtomicPagination> 的 props 應該如何命名時,我們需要一個 page 來表示當前頁面,並且要能夠雙向綁定。另外,關於總頁數的顯示,我們有兩個選擇,一個是用 count 來表示總頁數,另一個是用 perPagetotal 來表示總資料數。

根據我的開發經驗,我們比較常接收到以下的資料格式:

const result = {
  page: 1,      // 當前頁碼
  perPage: 10,  // 每頁顯示資料數量
  total: 100,   // 總資料數
}

如果我們選擇使用 count,那麼我們就需要自己計算總頁數,在每個用到的地方都要進行計算。因此,我偏好使用 perPagetotal 來計算總頁數,這樣我們可以在元件內部計算總頁數,而不需要在每個使用的地方自行計算。

綜合以上並結合自身經驗,我們統整出 <AtomicPagination> 的功能:

  • 透過 page 來控制當前頁碼。
  • 透過 perPagetotal 來計算總頁數。
  • 可以透過 boundaryCount 來控制開頭與結尾固定顯示的頁碼數量。
  • 可以透過 siblingCount 來控制當前頁面前後固定顯示的頁碼數量。
  • 可以透過 hidePrevButtonhideNextButton 來控制是否隱藏「前一頁」、「下一頁」的按鈕。
  • 可以透過 showFirstButtonshowLastButton 來控制是否顯示「第一頁」、「最後一頁」的按鈕。
  • 可以透過 disabled 來控制是否禁用整個元件。

在這裡,我們希望「前一頁」、「下一頁」的預設值為 false,因此在 props 的命名上會選用 hide 開頭來表示是否隱藏;而「第一頁」、「最後一頁」的預設值為 false,所以在 props 的命名上會選用 show 開頭來表示是否顯示。

使用結構如下:

<template>
  <AtomicPagination
    v-model:page="page"
    :perPage="10"
    :total="100"
    boundaryCount="1"
    siblingCount="1"
    hidePrevButton
    hideNextButton
    showFirstButton
    showLastButton
    disabled
  />
</template>

在實際元件中,perPage 也會設計為可以雙向綁定的屬性,不過這裡為了完全聚焦在核心功能上,所以省略了這部分。

元件實作

首先,我們將需求中提到的功能整理成 props 的介面,我們會需要下列屬性:

屬性 型別 預設值 說明
page number - 當前頁碼
perPage number - 每頁顯示資料數量
total number - 總資料數
boundaryCount number 1 開頭結尾各固定顯示幾頁面數量
siblingCount number 1 當前頁面前後固定顯示的頁碼數量
hidePrevButton boolean false 是否隱藏「前一頁」按鈕
hideNextButton boolean false 是否隱藏「下一頁」按鈕
showFirstButton boolean false 是否顯示「第一頁」按鈕
showLastButton boolean false 是否顯示「最後一頁」按鈕
disabled boolean false 是否禁用

接著我們就可以開始實作 <AtomicPagination> 元件。

簡單的 <AtomicPagination>

interface AtomicPaginationProps {
  page: number;
  perPage: number;
  total: number;
  boundaryCount?: number;
  siblingCount?: number;
  hideNextButton?: boolean;
  hidePrevButton?: boolean;
  showFirstButton?: boolean;
  showLastButton?: boolean;
  disabled?: boolean;
}

interface AtomicPaginationEmits {
  (event: 'update:page', value: number): void;
}

const props = withDefaults(defineProps<AtomicPaginationProps>(), {
  perPage: 10,
  boundaryCount: 1,
  siblingCount: 1,
});

const emit = defineEmits<AtomicPaginationEmits>();

接著我們需要計算總頁數:

const count = computed(() => Math.ceil(props.total / props.perPage));

在計算總頁數時,我們需要無條件進位,即使最後一頁資料不足 perPage 筆,我們仍需算作一頁。

接著我們來處理畫面的部分。頁碼按鈕部分,我們使用之前做的 <AtomicButton>

<template>
  <nav
    aria-label="pagination navigation"
    class="atomic-pagination"
  >
    <ul class="atomic-pagination__container">
      <li
        v-for="(item, index) in count"
        :key="index"
      >
        <AtomicButton
          :aria-current="item === page ? 'true' : undefined"
          :aria-disabled="disabled"
          :aria-label="item === page ? undefined : `前往第 ${item} 頁`"
          class="atomic-pagination__button"
          :disabled="disabled"
          :variant="item === page ? 'contained' : 'outlined'"
          @click="emit('update:page', item)"
        >
          {{ item }}
        </AtomicButton>
      </li>
    </ul>
  </nav>
</template>

接著處理 SCSS 部分。因為使用了前面已經做好的 <AtomicButton>,所以在這裡我們需要處理的樣式並不複雜。

.pagination {
  &__container {
    display: flex;
    column-gap: 6px;
  }

  &__button {
    padding-right: 0;
    padding-left: 0;
    min-width: 36px;
    font-size: 0.75rem;
  }
}

接著依照 props 的設定來決定是否顯示「前一頁」、「下一頁」、「第一頁」、「最後一頁」的按鈕。

我們先處理「前一頁」、「下一頁」的按鈕。

<ul class="atomic-pagination__container">
  <li v-if="!hidePrevButton">
    <AtomicButton
      class="atomic-pagination__button atomic-pagination__button--previous"
      :disabled="disabled || page <= 1"
      variant="outlined"
      @click="emit('update:page', page - 1)"
    >
      <ArrowSvg
        fill="currentColor"
        height="16"
        width="16"
      />
    </AtomicButton>
  </li>
  <!-- 略 -->
  <li v-if="!hideNextButton">
    <AtomicButton
      class="atomic-pagination__button atomic-pagination__button--next"
      :disabled="disabled || page >= count"
      variant="outlined"
      @click="emit('update:page', page + 1)"
    >
      <ArrowSvg
        fill="currentColor"
        height="16"
        width="16"
      />
    </AtomicButton>
  </li>
</ul>

接著加上「第一頁」、「最後一頁」的按鈕。

<ul class="atomic-pagination__container">
  <li v-if="showFirstButton">
    <AtomicButton
      class="atomic-pagination__button atomic-pagination__button--first"
      :disabled="disabled || page <= 1"
      variant="outlined"
      @click="emit('update:page', 1)"
    >
      <DoubleArrowSvg
        fill="currentColor"
        height="16"
        width="16"
      />
    </AtomicButton>
  </li>
  <!-- 略 -->
  <li v-if="showLastButton">
    <AtomicButton
      class="atomic-pagination__button atomic-pagination__button--last"
      :disabled="disabled || page >= count"
      variant="outlined"
      @click="emit('update:page', count)"
    >
      <DoubleArrowSvg
        fill="currentColor"
        height="16"
        width="16"
      />
    </AtomicButton>
  </li>
</ul>

這樣我們就完成了最簡單的 <AtomicPagination> 元件。

最簡單的 AtomicPagination

頁數過多的處理

以最簡單的 <AtomicPagination> 為例,除了原有的 10 頁按鈕外,我們還有「前一頁」、「下一頁」、「第一頁」、「最後一頁」的按鈕,共 14 個按鈕。如果資料量很大,按鈕數量就會變得非常多,導致頁面變得非常雜亂。

因此,我們需要對按鈕數量進行控制,當按鈕數量過多時,可以隱藏部分按鈕。

省略按鈕後的結果

但要如何隱藏呢?在前面我們列出了 <AtomicPagination> 的 props,其中 boundaryCountsiblingCount 兩個 props,可以幫助我們控制按鈕數量。

如上圖所示,我們將 boundaryCount 設定為 1,siblingCount 設定為 1,這樣可以只顯示當前頁面前後各 1 頁,以及開頭和結尾各 1 頁,從而控制按鈕數量。

但如何計算呢?我們需要計算以下資訊:

  • 開頭固定顯示的頁碼。
  • 結尾固定顯示的頁碼。
  • 當前頁面前後顯示的頁碼。
  • 開頭固定顯示的頁碼到當前頁面前顯示的頁碼之間是否需要省略?
  • 當前頁面後顯示的頁碼到結尾固定顯示的頁碼之間是否需要省略?

在開始前,我們需要一個 util function 幫助我們生成指定範圍的數字陣列。

// https://dev.to/namirsab/comment/2050
const range = (start: number, end: number) => {
  const length = end - start + 1;
  return Array.from({ length }, (_, i) => start + i);
}

range 的功能是產生一個從 startend 的數字陣列。

例如:

range(3, 5); // [3, 4, 5]

開頭固定顯示的頁碼

const startPages = computed(() => range(1, props.boundaryCount));

這樣假設 boundaryCount 為 3,startPages 就會是 [1, 2, 3]

但在上面的假設下並且 count 為 2 時,算出的結果就不會是我們要的,因此當 boundaryCount 大於 count 時,我們需要將他的最大值限制為 count

const startPages = computed(() => range(1, Math.min(props.boundaryCount, count.value)));

這樣當 count 為 2,boundaryCount 為 3 時,startPages 算出來就會是 [1, 2]

結尾固定顯示的頁碼

const endPages = computed(() => range(count.value - props.boundaryCount + 1, count.value));

這樣當 count 為 20,boundaryCount 為 3 時,endPages 就會是 [18, 19, 20]。但如果 count 只有 2 時,endPages 就會是 [0, 1, 2]

不僅出現了不合理的頁碼,還與 startPages 重複了。因此我們要將 endPages 的計算方式改為:

const endPages = computed(() => {
  return range(Math.max(count.value - props.boundaryCount + 1, props.boundaryCount + 1), count.value)
});

延續上面的範例,此時算出的 endPages 就會是空陣列 []

當前頁面前與當前頁面前後顯示的頁碼

大多數情況下,這些頁碼可以很容易計算出來。例如當 siblingCount 為 1,page 為 5 時,siblingPages 就會是 [4, 5, 6]

// 當前頁面前顯示的頁碼
const siblingsStart = computed(() => props.page - props.siblingCount);
// 當前頁面後顯示的頁碼
const siblingsEnd = computed(() => props.page + props.siblingCount);

我們很輕易就可以算出 siblingsStartsiblingsEnd 了對吧!對吧?

情境 1:

  • page 為 1。
  • count 為 20。
  • boundaryCount 為 1。
  • siblingCount 為 1。

此時算出的結果為:

  • startPages[1]
  • endPages[20]
  • siblingsStart0
  • siblingsEnd2

顯然這種算法不夠全面,我們需要避免當前頁面減去或加上 siblingCount 會超出頁碼範圍的問題。

const siblingsStart = computed(() => {
  return Math.max(props.page - props.siblingCount, props.boundaryCount + 1)
});
const siblingsEnd = computed(() => {
  return Math.max(
    props.page + props.siblingCount,
    // 原公式:props.boundaryCount + props.siblingCount + 1 + props.siblingCount
    props.boundaryCount + props.siblingCount * 2 + 1
  )
});

修正後的結果會是:

  • startPages[1]
  • endPages[20]
  • siblingsStart2
  • siblingsEnd4

這樣很好,我們來看看另一個情境。

情境 2:

  • page 為 20。
  • count 為 20。
  • boundaryCount 為 1。
  • siblingCount 為 1。

此時算出的結果為:

  • startPages[1]
  • endPages[20]
  • siblingsStart19
  • siblingsEnd21

siblingsEnd 超出了頁碼範圍!我們需要避免這個問題。

const siblingsEnd = computed(() => {
  return Math.min(
    Math.max(
      props.page + props.siblingCount,
      props.boundaryCount + props.siblingCount * 2 + 1
    ),
    count.value - props.boundaryCount
  )
});
const siblingsStart = computed(() => {
  return Math.max(
    Math.min(
      props.page - props.siblingCount,
      count.value - props.boundaryCount - props.siblingCount * 2
    ),
    props.boundaryCount + 1
  )
});

修正後得出的結果會是:

  • startPages[1]
  • endPages[20]
  • siblingsStart17
  • siblingsEnd19

到目前為止一切順利,我們已經可以推算出我們需要顯示的頁碼了,接下來只要判斷是否需要加上省略符號即可。

開頭固定顯示的頁碼到當前頁面前顯示的頁碼之間是否需要省略?

const needStartEllipsis = computed(() => {
  return siblingsStart.value > props.boundaryCount + 1;
});

const needEndEllipsis = computed(() => {
  return siblingsEnd.value < count.value - endPages.value.length;
});

這樣我們就可以知道是否需要加上省略符號了。

重新整合

看起來很棒,我們已經算出了所有需要的資訊,接下來可以重新整合我們的按鈕了。

<ul class="pagination__container">
  <!-- 上略 -->
  <li
    v-for="item in startPages"
    :key="item"
  >
    <!-- 按鈕 -->
  </li>
  <li v-if="needStartEllipsis">
    <span>
      … 
    </span>
  </li>
  <li
    v-for="item in range(siblingsStart, siblingsEnd)"
    :key="item"
  >
    <!-- 按鈕 -->
  </li>
  <li v-if="needEndEllipsis">
    <span>
      … 
    </span>
  </li>
  <li
    v-for="item in endPages"
    :key="item"
  >
    <!-- 按鈕 -->
  </li>
  <!-- 下略 -->
</ul>

加上 SCSS 即可完成:

.atomic-pagination {
  &__button {
    &--ellipsis {
      display: flex;
      height: 100%;
      width: 100%;
      align-items: center;
      justify-content: center;
    }
  }
}

進階功能

現在的 <AtomicPagination> 已經可以應付大部分的情況了,但有沒有什麼部分可以做得更好的呢?

最顯而易見的是,「<template> 裡面重複的地方太多了」。另一個問題是當頁面不斷向下切換時,元素(按鈕與省略符號)的數量不總是固定的。如果能固定元素的數量,效果會更好。

解決重複的問題

這個問題容易解決,我們可以將所有要顯示的按鈕與省略符號整合成一個陣列,這樣就可以使用一個迴圈渲染出我們需要的按鈕。

首先,我們把所有計算屬性統整到一個 items 計算屬性裡面。

const items = computed(() => {
  const { 
    boundaryCount,
    siblingCount,
    showFirstButton,
    showLastButton,
    hidePrevButton,
    hideNextButton,
  } = props;

  const count = Math.ceil(props.total / props.perPage);

  const startPages = range(1, Math.min(boundaryCount, count))

  const endPages = range(
    Math.max(count - boundaryCount + 1, boundaryCount + 1),
    count
  )

  const siblingsEnd = Math.min(
    Math.max(
      page + siblingCount,
      boundaryCount + siblingCount * 2 + 1
    ),
    count - boundaryCount
  )

  const siblingsStart = Math.max(
    Math.min(
      page - siblingCount,
      count - boundaryCount - siblingCount * 2
    ),
    boundaryCount + 1
  )

  // 先省略 `needStartEllipsis` 與 `needEndEllipsis`
})

接著,我們組合出 _items,用來標示每個按鈕的意義。

const items = computed(() => {
  // 上略

  const _items = [
    ...(showFirstButton ? ['first'] : []),
    ...(hidePrevButton ? [] : ['previous']),
    ...startPages,

    // Start ellipsis
    ...(siblingsStart > boundaryCount + 1
      ? ['start-ellipsis']
      : []),

    // Sibling pages
    ...range(siblingsStart, siblingsEnd),

    // End ellipsis
    ...(siblingsEnd < count - boundaryCount
      ? ['end-ellipsis']
      : []),

    ...endPages,
    ...(hideNextButton ? [] : ['next']),
    ...(showLastButton ? ['last'] : []),
  ]
})

到這一步 _items 會像是這樣:

['first', 'previous', 1, 'start-ellipsis', 4, 5, 6, 'end-ellipsis', 10, 'next', 'last']

最後我們把 _items 轉換成按鈕上的各種屬性與事件。

const items = computed(() => {
  // 上略

  const buttonPage = (type: string) => {
    switch (type) {
      case 'first':
        return 1;
      case 'previous':
        return page - 1;
      case 'next':
        return page + 1;
      case 'last':
        return count;
      default:
        return null;
    }
  };

  return _items.map(item => {
    return isNumber(item)
      ? {
          onClick(): void {
            onChange?.(item);
          },
          type: 'page',
          page: item,
          selected: item === page,
          disabled,
          ariaCurrent: item === page ? 'true' : undefined,
        }
      : {
          onClick(): void {
            const page = buttonPage(item);
            page && onChange?.(page);
          },
          type: item,
          page: buttonPage(item),
          selected: false,
          disabled: 
            disabled ||
            (!item.includes('ellipsis') &&
              (item === 'next' || item === 'last'
                ? page >= count
                : page <= 1)),
        };
  })
})

我們有了 items 這個計算屬性,就可以在 <template> 裡使用 v-forv-if 來渲染按鈕了。

<ul class="atomic-pagination__container">
  <li
    v-for="(item, index) in pagination"
    :key="index"
  >
    <template v-if="item.type === 'start-ellipsis' || item.type === 'end-ellipsis'">
      <span class="atomic-pagination__button atomic-pagination__button--ellipsis">
        …
      </span>
    </template>
    <template v-else>
      <AtomicButton
        :aria-current="item.ariaCurrent"
        :aria-disabled="item.disabled"
        class="atomic-pagination__button"
        :class="{
          'atomic-pagination__button--selected': item.selected,
          [`atomic-pagination__button--${item.type}`]: true,
        }"
        :disabled="item.disabled || undefined"
        :variant="item.selected ? 'contained' : 'outlined'"
        @click="item.onClick"
      >
        <template v-if="item.type === 'page'">
          {{ item.page }}
        </template>
        <template v-else>
          <component
            :is="ARROW_MAP[item.type]" 
            fill="currentColor"
            height="16"
            width="16"
          />
        </template>
      </AtomicButton>
    </template>
  </li>
</ul>

在這裡我們使用了 ARROW_MAP 這個物件,這個物件是用來對應按鈕的類型與對應的 SVG 圖示。

import ArrowSvg from '~/assets/svg/arrow.svg?component';
import DoubleArrowSvg from '~/assets/svg/double-arrow.svg?component';

const ARROW_MAP = {
  next: ArrowSvg,
  previous: ArrowSvg,
  first: DoubleArrowSvg,
  last: DoubleArrowSvg,
};

這裡使用了極端方式減少 <template> 重複的部分,不過這樣的寫法可能會使 <template> 變得較難閱讀,使用時需要注意。

固定元素數量

我們先來看看元素數量如何變化。

按鈕數量變化 1
按鈕數量變化 2

我們發現在第 4 頁(總頁數 10)時,不含頭尾元素的數量為 7;在第 1 頁時,元素數量為 6。另外我們發現一個問題,在第 4 頁時頁碼為:

[previous, 1, start-ellipsis, 3, 4, 5, end-ellipsis, 10, next]

其中 start-ellipsis 裡面只有第二頁而已,因此可以將 start-ellipsis 裡面的按鈕直接顯示出來。

數量的部分以上圖為例,我們知道最多的按鈕數量為 7,因此可以將按鈕數量固定為 7。當遇到按鈕數量不足的情況時,我們可以補上一個按鈕。

但如果多補上一個按鈕,表示 siblingsStartsiblingsEnd 需要進行相應調整。

const siblingsStart = Math.max(
  Math.min(
    page - siblingCount,
    count - boundaryCount - siblingCount * 2 - 1
  ),
  boundaryCount + 2
)

const siblingsEnd = Math.min(
  Math.max(
    page + siblingCount,
    boundaryCount + siblingCount * 2 + 2
  ),
  count - boundaryCount - 1
)

最後 ellipsis 部分也需要做相應調整。

const items = [
  // 上略

  // Start ellipsis
  ...(siblingsStart > boundaryCount + 2
    ? ['start-ellipsis']
    : boundaryCount + 1 < count - boundaryCount
    ? [boundaryCount + 1]
    : []),

  // Sibling pages
  ...range(siblingsStart, siblingsEnd),

  // End ellipsis
  ...(siblingsEnd < count - boundaryCount - 1
    ? ['end-ellipsis']
    : count - boundaryCount > boundaryCount
    ? [count - boundaryCount]
    : []),

  // 下略
]

看起來不錯,我們已經可以固定按鈕數量了!

最終版本的 AtomicPagination

Composable

當初看到這種設計方式時真的是驚為天人。很多時候我們無法好好使用一套 UI Library,主要原因之一就是其樣式幾乎被寫死了!當然,我們可以自己用 CSS 壓過原本的設定,但這樣對長期的維護來說真的是一個災難(回顧那些被 UI Library 卡死、完全無法升級的專案)。

如果我們可以把元件中的邏輯與樣式結構分開,那麼我們就可以更靈活地使用這些「邏輯」。

在剛剛的實作過程中,我們已經把所有的邏輯、所有需要的資料整合到一個 items 的計算屬性裡面,這部分可以抽取成獨立的 Composition Function。

const range = (start: number, end: number) => {
  const length = end - start + 1;
  return Array.from({ length }, (_, i) => start + i);
};

function usePagination (props: MaybeRefOrGetter<UsePaginationProps>) {
  return computed(() => {
    const {
      boundaryCount = 1,
      count = 1,
      disabled = false,
      hideNextButton = false,
      hidePrevButton = false,
      page,
      showFirstButton = false,
      showLastButton = false,
      siblingCount = 1,
      onChange = noop,
    } = toValue(props);
  })

  // 這裡放剛剛所有的計算邏輯

  const items = _items.map(() => {
    // 略
  })

  return items
}

這樣我們的元件就會更加簡潔了!

總結

今天我們一起從最簡單的 <AtomicPagination> 開始,逐步推進到更完善的元件。在增進使用者體驗的過程中,我們花了大量篇幅推導最合理的算法。如果一開始沒看懂,沒關係,我強烈建議從進階需求部分開始,隨機帶入幾種組合進行計算,這對理解會有幫助。

在樣式方面,我們使用了之前寫的 <AtomicButton> 並做了一些調整。如果可以的話,<AtomicPagination> 也可以接收類似 variant 這樣的 props 並傳給 <AtomicButton> 來調整樣式,這部分就留給大家去嘗試了。

最後,受到 Material-UI 的啟發,我們將元件的邏輯與樣式分開,這樣我們就可以更靈活地使用這個元件了。這是一個很酷的概念,在不同的專案中,我通常只需要調整樣式和修改預設值就可以使用了。必須佩服想到這種設計的人,真的是太厲害了!

參考資料


上一篇
[為你自己寫 Vue Component] AtomicBreadcrumb
下一篇
[為你自己寫 Vue Component] AtomicTabs
系列文
為你自己寫 Vue Component30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言