Giao diện
Trang tổng quan
Màn hình mở đầu của một module: vài chỉ số chính, một hai bảng ngắn, đôi khi biểu đồ. Khác ba mẫu kia ở chỗ dữ liệu đến từ nhiều nguồn cùng lúc, nên điểm khó là tải sao cho nhanh và hỏng một phần không làm hỏng cả trang.
Bộ khung
vue
<script setup lang="ts">
import { Col as CCol, Row as CRow } from '@tasco/ui'
import { IconCash, IconClock, IconShoppingCart } from '@tabler/icons-vue'
definePageMeta({ permissions: ['/orders/search'] })
const { fetchSummary } = useOrdersApi()
const { data: summary, status, error, refresh } = await useAsyncData('orders-summary', fetchSummary)
const currency = new Intl.NumberFormat('vi-VN', { notation: 'compact' })
</script>
<template>
<CPageHeader title="Tổng quan đơn hàng">
<template #extra>
<CButton variant="outline" :loading="status === 'pending'" @click="refresh">Tải lại</CButton>
</template>
</CPageHeader>
<CEmpty v-if="error" :description="error.message" bordered>
<CButton variant="primary" @click="refresh">Thử lại</CButton>
</CEmpty>
<CRow v-else :gutter="16">
<CCol :xs="24" :sm="12" :lg="8">
<CStatistic label="Đơn hôm nay" :value="summary?.todayCount ?? 0" :trend="summary?.todayTrend">
<template #icon><IconShoppingCart :size="20" /></template>
</CStatistic>
</CCol>
<CCol :xs="24" :sm="12" :lg="8">
<CStatistic
label="Doanh thu tháng"
:value="currency.format(summary?.revenue ?? 0)"
accent="success"
>
<template #icon><IconCash :size="20" /></template>
<template #suffix>₫</template>
</CStatistic>
</CCol>
<CCol :xs="24" :sm="12" :lg="8">
<CStatistic label="Chờ xử lý" :value="summary?.pendingCount ?? 0" accent="warning">
<template #icon><IconClock :size="20" /></template>
</CStatistic>
</CCol>
</CRow>
</template>CStatistic nhận giá trị đã định dạng — trang tự quyết định cách viết số, đơn vị, rút gọn. trend là phần trăm biến động: dương hiện mũi tên lên màu xanh, âm hiện mũi tên xuống màu đỏ.
Tải nhiều nguồn
Ba cách, chọn theo số lượng lời gọi:
Một route gộp ở BFF — tốt nhất khi có từ ba nguồn trở lên. Một lần đi mạng, một trạng thái tải, một chỗ xử lý lỗi:
ts
// server/api/orders/summary.get.ts — xem hướng dẫn BFF
const [count, revenue, pending] = await Promise.all([...])
return { todayCount: count.total, revenue: revenue.amount, pendingCount: pending.total }Song song ở client khi các phần độc lập và backend không gộp được:
ts
const { data } = await useAsyncData('dashboard', async () => {
const [orders, revenue] = await Promise.all([fetchOrderStats(), fetchRevenue()])
return { orders, revenue }
})Mỗi ô một lời gọi riêng khi muốn ô nào xong hiện ô đó — đổi lại là nhiều trạng thái tải phải quản lý. Chỉ làm khi thật sự cần.
Đừng gọi tuần tự (await liên tiếp) những thứ không phụ thuộc nhau: trang sẽ chậm bằng tổng thời gian của tất cả các lời gọi.
Hỏng một phần
Dashboard không nên trắng trang chỉ vì một chỉ số lỗi. Với cách gọi song song, tách lỗi từng phần:
ts
const { data } = await useAsyncData('dashboard', async () => {
const [orders, revenue] = await Promise.allSettled([fetchOrderStats(), fetchRevenue()])
return {
orders: orders.status === 'fulfilled' ? orders.value : null,
revenue: revenue.status === 'fulfilled' ? revenue.value : null,
}
})Ô nào không có dữ liệu thì hiện dấu — kèm nút thử lại, thay vì hiện số 0 — số 0 là một con số, dễ bị đọc nhầm thành sự thật.
Lọc theo khoảng thời gian
ts
import { CRangePicker } from '@tasco/ui'
const range = ref<[string, string]>(['2026-09-01', '2026-09-30'])
const { data: summary, refresh } = await useAsyncData(
() => `summary-${range.value[0]}-${range.value[1]}`,
() => fetchSummary({ from: range.value[0], to: range.value[1] }),
{ watch: [range] },
)Khoá đổi theo khoảng ngày và watch khiến dữ liệu tự tải lại khi người dùng chọn lại.
Ẩn khối theo quyền
vue
<Can permission="/reports/revenue">
<CCol :xs="24" :lg="8">
<CStatistic label="Doanh thu tháng" :value="…" />
</CCol>
</Can>Người không có quyền xem doanh thu thì ô biến mất hẳn, không hiện ô trống.
Biểu đồ
Core chưa có component biểu đồ. Dự án cần thì tự chọn thư viện (ECharts, Chart.js…) và:
- Bọc trong một component của dự án (
BieuDoDoanhThu.vue), không rải cấu hình thư viện khắp trang. - Lấy màu từ biến
--tasco-*để biểu đồ đổi theo chế độ sáng/tối, không hardcode mã màu. - Tải động (
defineAsyncComponent) vì thư viện biểu đồ thường nặng.
Nhiều team cùng cần một loại biểu đồ thì mở RFC để đưa vào @tasco/ui, đừng mỗi nơi tự làm một kiểu.
Danh sách rà soát
- [ ] Các lời gọi độc lập chạy song song, không tuần tự.
- [ ] Hỏng một nguồn không làm trắng cả trang.
- [ ] Ô chưa có dữ liệu hiện
—, không hiện0. - [ ] Số liệu có đơn vị và mốc thời gian rõ ràng ("tháng này", "7 ngày qua").
- [ ] Khối nhạy cảm (doanh thu, chi phí) có gating quyền.
- [ ] Màu trong biểu đồ lấy từ token, không hardcode.
Liên quan
- BFF — viết route gộp dữ liệu.
- Chặn theo quyền — ẩn khối theo quyền.
- Theme & design token — lấy màu token cho biểu đồ.