Giao diện
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, total | Bind vào CTable |
error | AppError của lần tải gần nhất, null nếu thành công |
query | TableQuery hiện tại (trang, sắp xếp, bộ lọc) — chỉ đọc trong trang |
pagination | Bind :pagination |
filterValues | Bộ lọc form đang áp dụng — bind :filter-values |
onChange | Bind @change: đổi trang, sắp xếp, filter cột |
onFilter | Bind @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ệc | Cách làm | Gửi tới backend qua |
|---|---|---|
| Ô tìm kiếm nhanh | show-search + v-model:search-value + @search | Khoá do trang tự đặt, ví dụ filters.keyword |
| Bộ lọc nhiều trường | filter-fields + :filter-values + @update:filter-values | query.filters |
| Sắp xếp | sorter: true trên cột | query.sortField, query.sortOrder |
| Lọc ngay trên cột | filters + onFilter của antdv | Gộ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 CTable và useTable:
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-keytrỏ đúng field định danh. - [ ]
definePageMetakhai 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.
- [ ]
pageSizehợp lý (20–50); đừng tải hàng nghìn dòng một lúc. - [ ] Ép kiểu bản ghi trong
#bodyCellbằngas Order, không dùngany. - [ ] Trạng thái hiển thị qua hằng số dùng chung, không rải
v-ifmỗi nơi một kiểu.
Liên quan
- CTable — API đầy đủ của bảng.
- useApi và fetcher — nơi quy đổi tham số truy vấn.
- Trang chi tiết — nơi dòng bảng dẫn tới.