Skip to content

Trang danh sách

Mẫu phổ biến nhất trong app quản trị: bảng có phân trang, tìm kiếm, lọc, sắp xếp, và vài nút hành động. Framework gộp sẵn ba mảnh: CPageHeader (tiêu đề), useTable (state), CTable (khung bảng + toolbar + phân trang).

Bộ khung tối thiểu

vue
<script setup lang="ts">
import type { Order } from '~/composables/orders.types'

definePageMeta({ permissions: ['/orders/search'] })

const { fetchOrders } = useOrdersApi()
const { dataSource, loading, pagination, onChange } = useTable<Order>(fetchOrders, { pageSize: 20 })

const columns = [
  { title: 'Mã đơn', dataIndex: 'code', key: 'code', width: 140 },
  { title: 'Khách hàng', dataIndex: 'customer', key: 'customer' },
]
</script>

<template>
  <CPageHeader title="Đơn hàng" />

  <CTable
    row-key="id"
    :columns="columns"
    :data-source="dataSource"
    :loading="loading"
    :pagination="pagination"
    @change="onChange"
  />
</template>

row-key là bắt buộc và phải là field định danh thật (id), không phải chỉ số dòng — thiếu nó thì chọn dòng, mở rộng dòng và cập nhật cục bộ đều sai.

useTable trả về gì

Dùng để
dataSource, loading, totalBind vào CTable
errorAppError của lần tải gần nhất, null nếu thành công
queryTableQuery hiện tại (trang, sắp xếp, bộ lọc) — chỉ đọc trong trang
paginationBind :pagination
filterValuesBộ lọc form đang áp dụng — bind :filter-values
onChangeBind @change: đổi trang, sắp xếp, filter cột
onFilterBind @update:filter-values: đổi bộ lọc form, về trang 1
load() / reload()Tải lại giữ nguyên trang / quay về trang 1 rồi tải

Fetcher nhận TableQuery và trả Paginated<T>; phần quy đổi sang tham số của backend đặt trong fetcher.

Bản đầy đủ

Toolbar, drawer lọc, thanh điều kiện và thiết lập cột đều do CTable dựng sẵn — trang chỉ khai báo cột và trường lọc:

vue
<script setup lang="ts">
import type { TableFilterField } from '@tasco/ui'
import type { Order } from '~/composables/orders.types'

definePageMeta({ permissions: ['/orders/search'] })

const TABLE_KEY = 'orders'

const { fetchOrders } = useOrdersApi()
const { can } = usePermission()
const {
  dataSource, loading, pagination, filterValues, onChange, onFilter, reload,
} = useTable<Order>(fetchOrders, { pageSize: 20, settingsKey: TABLE_KEY })

const search = ref('')

const columns = [
  { title: 'Mã đơn', dataIndex: 'code', key: 'code', width: 140 },
  { title: 'Khách hàng', dataIndex: 'customer', key: 'customer' },
  { title: 'Giá trị', dataIndex: 'amount', key: 'amount', align: 'right', width: 160, sorter: true },
  { title: 'Ngày tạo', dataIndex: 'createdAt', key: 'createdAt', width: 140, sorter: true },
  { title: 'Trạng thái', dataIndex: 'status', key: 'status', width: 150 },
  { title: '', key: 'actions', width: 88, align: 'center' },
]

const filterFields: TableFilterField[] = [
  { key: 'customer', label: 'Khách hàng', type: 'input', placeholder: 'Tên khách hàng' },
  {
    key: 'status',
    label: 'Trạng thái',
    type: 'select',
    multiple: true,
    options: Object.entries(ORDER_STATUS).map(([value, { label }]) => ({ label, value })),
  },
  { key: 'createdAt', label: 'Ngày tạo', type: 'dateRange' },
]

const currency = new Intl.NumberFormat('vi-VN')

function onSearch(keyword: string) {
  onFilter({ ...filterValues.value, keyword: keyword || undefined })
}
</script>

<template>
  <CPageHeader title="Đơn hàng" sub-title="Danh sách đơn hàng toàn hệ thống" />

  <CTable
    v-model:search-value="search"
    row-key="id"
    :columns="columns"
    :data-source="dataSource"
    :loading="loading"
    :pagination="pagination"
    :filter-fields="filterFields"
    :filter-values="filterValues"
    :settings-key="TABLE_KEY"
    :show-create="can('/orders/create')"
    create-text="Tạo đơn"
    show-search
    show-filter
    show-reload
    show-column-setting
    @change="onChange"
    @search="onSearch"
    @reload="reload"
    @create="navigateTo('/orders/new')"
    @update:filter-values="onFilter"
  >
    <template #bodyCell="{ column, record }">
      <template v-if="column.key === 'amount'">
        {{ currency.format((record as Order).amount) }} ₫
      </template>

      <template v-else-if="column.key === 'status'">
        <CTag :color="ORDER_STATUS[(record as Order).status].color" dot>
          {{ ORDER_STATUS[(record as Order).status].label }}
        </CTag>
      </template>

      <template v-else-if="column.key === 'actions'">
        <CButton
          v-can="'/orders/update'"
          variant="text"
          size="sm"
          @click="navigateTo(`/orders/${(record as Order).id}`)"
        >
          Xem
        </CButton>
      </template>
    </template>
  </CTable>
</template>

Tìm kiếm, lọc, sắp xếp

ViệcCách làmGửi tới backend qua
Ô tìm kiếm nhanhshow-search + v-model:search-value + @searchKhoá do trang tự đặt, ví dụ filters.keyword
Bộ lọc nhiều trườngfilter-fields + :filter-values + @update:filter-valuesquery.filters
Sắp xếpsorter: true trên cộtquery.sortField, query.sortOrder
Lọc ngay trên cộtfilters + onFilter của antdvGộp vào query.filters

Bộ lọc form và filter cột được giữ tách nhau rồi mới gộp, nên đổi trang hay đổi sắp xếp không làm mất bộ lọc đang áp dụng.

Sắp xếp và lọc luôn chạy ở backend. Sắp xếp tại chỗ chỉ đúng trong trang hiện tại, sai ngay khi sang trang thứ hai.

Ghi nhớ thiết lập bảng

Truyền cùng một settingsKey cho CTableuseTable:

ts
const TABLE_KEY = 'orders'
useTable(fetchOrders, { settingsKey: TABLE_KEY })
vue
<CTable :settings-key="TABLE_KEY" show-column-setting />

Người dùng đổi thứ tự cột, ẩn cột, đặt sắp xếp mặc định → lưu vào localStorage theo khoá tasco:table:<settingsKey>, và lần tải đầu của những phiên sau đã đúng thứ tự đó. Mỗi bảng một khoá riêng (orders, orders:items), không dùng lại khoá giữa hai màn hình khác nhau.

Trạng thái rỗng và lỗi

CTable tự hiện phần rỗng của antdv khi không có dòng nào. Khi muốn nói rõ hơn — phân biệt "chưa có dữ liệu" với "lọc không ra kết quả" — dùng slot #emptyText hoặc CEmpty bên ngoài bảng.

Lỗi ở lần tải đầu

Promise không ai bắt

useTable tự gọi fetcher ngay khi khởi tạo. Nếu lời gọi đó hỏng, error có giá trị nhưng promise không có ai bắt (hiện lên console dưới dạng unhandled rejection). Cần xử lý tường minh thì tắt tự tải:

ts
const { dataSource, loading, error, load } = useTable<Order>(fetchOrders, { immediate: false })
const { $tascoError } = useNuxtApp()

onMounted(() => {
  load().catch($tascoError)
})

Danh sách rà soát

  • [ ] row-key trỏ đúng field định danh.
  • [ ] definePageMeta khai quyền xem; nút hành động có v-can.
  • [ ] Sắp xếp và lọc chạy ở backend, không lọc tại chỗ trên một trang dữ liệu.
  • [ ] pageSize hợp lý (20–50); đừng tải hàng nghìn dòng một lúc.
  • [ ] Ép kiểu bản ghi trong #bodyCell bằng as Order, không dùng any.
  • [ ] Trạng thái hiển thị qua hằng số dùng chung, không rải v-if mỗi nơi một kiểu.

Liên quan