Giao diện
Hướng dẫn từng bước: quản lý đơn hàng
Dựng một màn hình nghiệp vụ hoàn chỉnh — danh sách có lọc, trang chi tiết, form thêm mới, phân quyền — trên dự án vừa tạo bằng CLI. Khoảng 45 phút. Mỗi bước là một file hoàn chỉnh: chép vào dự án là chạy được.
Điều kiện
Đã làm xong Tạo app mới, pnpm dev chạy được và .env có NUXT_AUTH_MOCK=true. Backend giả định phục vụ GET /orders, GET /orders/:id, POST /orders sau NUXT_API_PROXY_TARGET; chưa có backend thì xem bước 2b.
Bước 1 — Khai báo menu
Menu, tiêu đề và footer đều nằm trong app/app.config.ts. Layout, sidebar, header đã có sẵn từ layer.
ts
// app/app.config.ts
import { h } from 'vue'
import { IconHome, IconShoppingCart } from '@tabler/icons-vue'
import type { NavItem } from '@tasco/ui'
export default defineAppConfig({
tasco: {
appTitle: 'Quản lý đơn hàng',
nav: [
{ label: 'Trang chủ', path: '/', icon: () => h(IconHome, { size: 18 }) },
{
label: 'Đơn hàng',
path: '/orders',
icon: () => h(IconShoppingCart, { size: 18 }),
// Người không có quyền này sẽ không thấy mục menu.
permission: '/orders/search',
},
] satisfies NavItem[],
},
})Icon là render function nên chế độ thu gọn sidebar vẫn hiển thị đúng. Dùng @tabler/icons-vue — đã có sẵn trong dự án, không cài thêm bộ icon khác.
Bước 2 — Lớp gọi backend
Đặt mọi lời gọi HTTP của một nghiệp vụ vào một composable. Trang không tự gọi useApi, nhờ vậy đổi đường dẫn hay định dạng backend chỉ sửa một chỗ.
ts
// app/composables/useOrdersApi.ts
import type { Order, OrderInput, Paginated, TableQuery } from './orders.types'
export function useOrdersApi() {
const api = useApi()
/** Backend nhận `sort`/`order` và các bộ lọc ở cấp cao nhất — quy đổi tại đây. */
function fetchOrders(query: TableQuery): Promise<Paginated<Order>> {
return api<Paginated<Order>>('/orders', {
query: {
page: query.page,
pageSize: query.pageSize,
sort: query.sortField,
order: query.sortOrder === 'descend' ? 'desc' : query.sortOrder ? 'asc' : undefined,
...query.filters,
},
})
}
const fetchOrder = (id: string) => api<Order>(`/orders/${id}`)
const createOrder = (payload: OrderInput) =>
api<Order>('/orders', { method: 'POST', body: payload })
return { fetchOrders, fetchOrder, createOrder }
}ts
// app/composables/orders.types.ts
export interface Order {
id: number
code: string
customer: string
amount: number
status: 'new' | 'processing' | 'done' | 'cancelled'
createdAt: string
}
export interface OrderInput {
customer: string
amount: number
note?: string
}Paginated và TableQuery là kiểu của framework:
ts
// app/composables/orders.types.ts (tiếp)
export type { Paginated } from '@tasco/utils'
export type { TableQuery } from '@tasco/composables'Cài thêm package kiểu
@tasco/utils và @tasco/composables là phụ thuộc gián tiếp qua layer. Muốn import type trực tiếp thì thêm chúng vào dependencies của dự án — cùng phiên bản với @tasco/nuxt-layer-base.
Đường dẫn /orders đi qua BFF: trình duyệt gọi /api/orders, Nitro gắn Bearer từ cookie phiên rồi chuyển tiếp sang NUXT_API_PROXY_TARGET. Token không bao giờ xuống client. Xem BFF: /api/** đi đâu.
Bước 2b — Chưa có backend
Trả dữ liệu giả ngay trong composable để làm giao diện trước; đổi sang useApi khi backend sẵn sàng.
ts
// app/composables/useOrdersApi.ts — bản tạm
const ALL: Order[] = Array.from({ length: 47 }, (_, i) => ({
id: i + 1,
code: `DH-${String(i + 1).padStart(4, '0')}`,
customer: ['Công ty An Bình', 'Tập đoàn Bắc Sơn', 'Cửa hàng Cường Thịnh'][i % 3] ?? '',
amount: (i + 1) * 1_250_000,
status: (['new', 'processing', 'done', 'cancelled'] as const)[i % 4] ?? 'new',
createdAt: '2026-09-01',
}))
async function fetchOrders(query: TableQuery): Promise<Paginated<Order>> {
await new Promise((resolve) => setTimeout(resolve, 200))
const start = (query.page - 1) * query.pageSize
return { items: ALL.slice(start, start + query.pageSize), total: ALL.length }
}Bước 3 — Trang danh sách
Trạng thái xuất hiện ở cả danh sách lẫn chi tiết, nên map màu và nhãn một chỗ. File trong app/utils/ được Nuxt auto-import, không cần khai báo lại ở từng trang.
ts
// app/utils/order-status.ts
import type { Order } from '~/composables/orders.types'
export const ORDER_STATUS: Record<
Order['status'],
{ color: 'primary' | 'warning' | 'success' | 'error', label: string }
> = {
new: { color: 'primary', label: 'Mới' },
processing: { color: 'warning', label: 'Đang xử lý' },
done: { color: 'success', label: 'Hoàn thành' },
cancelled: { color: 'error', label: 'Đã huỷ' },
}vue
<!-- app/pages/orders/index.vue -->
<script setup lang="ts">
import type { Order } from '~/composables/orders.types'
definePageMeta({ permissions: ['/orders/search'] })
const { fetchOrders } = useOrdersApi()
const { dataSource, loading, pagination, onChange, reload } = 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' },
{ title: 'Giá trị', dataIndex: 'amount', key: 'amount', align: 'right', width: 160, sorter: true },
{ title: 'Trạng thái', dataIndex: 'status', key: 'status', width: 150 },
]
const currency = new Intl.NumberFormat('vi-VN')
</script>
<template>
<CPageHeader title="Đơn hàng" sub-title="Danh sách đơn hàng toàn hệ thống" />
<CTable
row-key="id"
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
show-reload
@change="onChange"
@reload="reload"
>
<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>
</CTable>
</template>Vào http://localhost:3000/orders. useTable tự gọi fetcher lần đầu, giữ trang/sắp xếp/bộ lọc và đưa pagination + onChange sẵn sàng để bind — không cần tự quản lý state phân trang.
Ép kiểu bản ghi trong #bodyCell bằng as Order — a-table khai record là unknown, và quy ước của framework cấm any.
Bước 4 — Tìm kiếm và bộ lọc
CTable có sẵn ô tìm kiếm và drawer lọc. Trang chỉ khai báo các trường lọc, useTable gộp chúng vào query.filters gửi cho fetcher.
vue
<script setup lang="ts">
import type { TableFilterField } from '@tasco/ui'
const { dataSource, loading, pagination, filterValues, onChange, onFilter, reload }
= useTable<Order>(fetchOrders, { pageSize: 20 })
const search = ref('')
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' },
]
function onSearch(keyword: string) {
onFilter({ ...filterValues.value, keyword: keyword || undefined })
}
</script>
<template>
<CTable
v-model:search-value="search"
row-key="id"
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
:filter-fields="filterFields"
:filter-values="filterValues"
show-search
show-filter
show-reload
@change="onChange"
@search="onSearch"
@reload="reload"
@update:filter-values="onFilter"
/>
</template>- Bấm Áp dụng trong drawer →
@update:filter-values→onFiltervề trang 1 và tải lại. - Điều kiện đang áp dụng hiện thành thẻ ngay trên bảng; bỏ một thẻ cũng phát
update:filterValues. - Đổi trang hay sắp xếp không làm mất bộ lọc —
useTablegiữ bộ lọc form tách khỏi filter cột.
Bước 5 — Trang chi tiết
vue
<!-- app/pages/orders/[id].vue -->
<script setup lang="ts">
import { CDescriptions, CDescriptionsItem } from '@tasco/ui'
import type { Order } from '~/composables/orders.types'
definePageMeta({ permissions: ['/orders/search'] })
const route = useRoute()
const { fetchOrder } = useOrdersApi()
const { data: order, status, error } = await useAsyncData<Order>(
() => `order-${route.params.id}`,
() => fetchOrder(String(route.params.id)),
)
</script>
<template>
<CPageHeader
:title="order?.code ?? 'Đơn hàng'"
:breadcrumb="[{ title: 'Đơn hàng', to: '/orders' }, { title: order?.code ?? '' }]"
@navigate="navigateTo"
/>
<CCard v-if="status === 'pending'">
Đang tải…
</CCard>
<CEmpty
v-else-if="error || !order"
description="Không tải được đơn hàng này."
bordered
/>
<CCard v-else>
<CDescriptions :column="2" bordered>
<CDescriptionsItem label="Mã đơn">{{ order.code }}</CDescriptionsItem>
<CDescriptionsItem label="Khách hàng">{{ order.customer }}</CDescriptionsItem>
<CDescriptionsItem label="Ngày tạo">{{ order.createdAt }}</CDescriptionsItem>
<CDescriptionsItem label="Trạng thái">
<CTag :color="ORDER_STATUS[order.status].color" dot>{{ ORDER_STATUS[order.status].label }}</CTag>
</CDescriptionsItem>
</CDescriptions>
</CCard>
</template>CDescriptions là primitive antdv re-export nên phải import từ @tasco/ui; chỉ 17 component thương hiệu (CTable, CTag, CCard…) mới đăng ký toàn cục. Chi tiết: Trang chi tiết.
Bước 6 — Form thêm mới
vue
<!-- app/pages/orders/new.vue -->
<script setup lang="ts">
import { CFormItem, CInput, CTextarea, message } from '@tasco/ui'
import { getFieldErrors, isValidation } from '@tasco/utils'
import type { OrderInput } from '~/composables/orders.types'
definePageMeta({ permissions: ['/orders/create'] })
const { createOrder } = useOrdersApi()
const { $tascoError } = useNuxtApp()
const model = reactive<OrderInput>({ customer: '', amount: 0, note: '' })
const submitting = ref(false)
// Lỗi 422 do backend trả về, theo từng field.
const serverErrors = ref<Record<string, string[]>>({})
const rules = {
customer: [{ required: true, message: 'Nhập tên khách hàng' }],
amount: [{ required: true, message: 'Nhập giá trị đơn hàng' }],
}
// CForm phát `finish` sau khi mọi rule phía client đã qua — đủ cho form một nút Lưu như đây.
// Form phải validate từng phần (wizard) thì gọi `formRef.value.validate([...])` qua ref, xem
// [Trang form](/guide/patterns/form).
async function onFinish() {
submitting.value = true
serverErrors.value = {}
try {
const order = await createOrder({ ...model })
message.success('Đã tạo đơn hàng.')
await navigateTo(`/orders/${order.id}`)
} catch (caught) {
// 422 → hiện lỗi ngay dưới ô nhập, không toast. Lỗi khác để handler chung xử lý.
if (isValidation(caught)) serverErrors.value = getFieldErrors(caught)
else $tascoError(caught)
} finally {
submitting.value = false
}
}
</script>
<template>
<CPageHeader
title="Tạo đơn hàng"
:breadcrumb="[{ title: 'Đơn hàng', to: '/orders' }, { title: 'Tạo mới' }]"
@navigate="navigateTo"
/>
<CCard>
<CForm :model="model" :rules="rules" style="max-width: 520px" @finish="onFinish">
<CFormItem
label="Khách hàng"
name="customer"
:validate-status="serverErrors.customer ? 'error' : undefined"
:help="serverErrors.customer?.[0]"
>
<CInput v-model:value="model.customer" placeholder="Tên khách hàng" />
</CFormItem>
<CFormItem
label="Giá trị"
name="amount"
:validate-status="serverErrors.amount ? 'error' : undefined"
:help="serverErrors.amount?.[0]"
>
<CInputCurrency v-model:value="model.amount" />
</CFormItem>
<CFormItem label="Ghi chú" name="note">
<CTextarea v-model:value="model.note" :rows="3" />
</CFormItem>
<CFormItem>
<CButton
variant="primary"
:loading="submitting"
html-type="submit"
>
Lưu
</CButton>
</CFormItem>
</CForm>
</CCard>
</template>Hai tầng kiểm tra dữ liệu, cả hai đều cần:
| Tầng | Ai kiểm | Hiển thị ở đâu |
|---|---|---|
| Ràng buộc nhập liệu (bắt buộc, độ dài, định dạng) | rules của CForm ở client | Ngay khi rời ô nhập |
| Nghiệp vụ (mã trùng, hạn mức, tồn kho) | Backend, trả 422 | serverErrors gắn vào đúng field |
Xem thêm Lỗi 422 và form.
Nối nút Thêm mới của bảng ở bước 4 vào trang này:
vue
<CTable show-create create-text="Tạo đơn" @create="navigateTo('/orders/new')" />Bước 7 — Phân quyền
Quyền là URI, so khớp chính xác, lấy từ IAM khi đăng nhập. Ba điểm chặn, khai báo độc lập:
ts
// 1. Vào trang — thiếu quyền thì middleware ném 403
definePageMeta({ permissions: ['/orders/search'] })ts
// 2. Mục menu — tự ẩn (app.config.ts, bước 1)
{ label: 'Đơn hàng', path: '/orders', permission: '/orders/search' }vue
<!-- 3. Nút hành động -->
<CButton v-can="'/orders/create'" variant="primary" @click="navigateTo('/orders/new')">
Tạo đơn
</CButton>
<CButton v-can:disable="'/orders/update'" @click="edit">Sửa</CButton>Chế độ mock cấp sẵn /orders/search, /orders/create, /orders/update — thử đổi definePageMeta sang một URI không có trong danh sách để thấy trang 403.
WARNING
Ba lớp trên chỉ làm trải nghiệm gọn gàng. Backend phải tự kiểm tra quyền trên mọi request: người dùng vẫn gọi được API thẳng bằng công cụ khác. Xem Mô hình phân quyền.
Bước 8 — Kiểm tra trước khi mở MR
bash
pnpm check # lint + typecheck
pnpm build # bản productionDanh sách rà soát nhanh:
- [ ] Không
importtrực tiếpant-design-vue, không thẻ<a-*>trong template. - [ ] Không hardcode mã màu — chỉ
var(--tasco-*). - [ ] Không
any: ép kiểu record trong#bodyCellbằngas Order. - [ ] Mọi trang có
definePageMetakhai quyền đúng, hoặcauth: falsenếu công khai. - [ ] Lỗi đi qua
$tascoErrorhoặc để handler chung bắt — khôngconsole.logcòn sót.
Đi tiếp
- Trang danh sách, Trang form, Trang chi tiết — bản đầy đủ của từng mẫu.
- Gọi backend — BFF, fetcher, hợp đồng dữ liệu.
- Xử lý lỗi —
AppErrorvà cách hiển thị. - Component — tra API từng component.