Giao diện
Khung giao diện
Khung app — dải logo, sidebar, header, vùng nội dung, chân trang — do layer dựng sẵn. Dự án khai báo menu và tiêu đề, không dựng lại khung.
┌──────────┬─────────────────────────────────────────┐
│ logo │ header: topNav … chuông, sáng/tối, tài khoản │
├──────────┼─────────────────────────────────────────┤
│ │ │
│ sidebar │ nội dung trang (<slot />) │
│ (nav) │ │
│ │ │
├──────────┴─────────────────────────────────────────┤
│ footerText │
└────────────────────────────────────────────────────┘Khai báo menu
ts
// app/app.config.ts
import { h } from 'vue'
import { IconChartBar, IconHome, IconShoppingCart } from '@tabler/icons-vue'
import type { NavItem } from '@tasco/ui'
export default defineAppConfig({
tasco: {
appTitle: 'Quản lý đơn hàng',
footerText: '© Tập đoàn Tasco',
topNav: [
{ label: 'Điều hành', path: '/' },
{ label: 'Báo cáo', path: '/reports' },
] satisfies NavItem[],
nav: [
{ label: 'Trang chủ', path: '/', icon: () => h(IconHome, { size: 18 }) },
{
label: 'Đơn hàng',
path: '/orders',
icon: () => h(IconShoppingCart, { size: 18 }),
permission: '/orders/search',
},
{
label: 'Báo cáo',
icon: () => h(IconChartBar, { size: 18 }),
children: [
{ label: 'Doanh thu', path: '/reports/revenue', permission: '/reports/revenue' },
{ label: 'Tồn kho', path: '/reports/stock' },
],
},
] satisfies NavItem[],
},
})NavItem
| Field | Kiểu | Ý nghĩa |
|---|---|---|
label | string | Nhãn hiển thị (bắt buộc) |
path | string? | Đường dẫn điều hướng; nhóm cha có children thì bỏ trống |
icon | render function | () => h(IconHome, { size: 18 }) — dùng @tabler/icons-vue |
permission | string? | URI quyền; không có quyền thì item bị ẩn |
children | NavItem[]? | Menu con một cấp |
key | string? | Khoá riêng; mặc định suy từ path/label |
Icon là render function, không phải component — viết icon: IconHome sẽ không hiển thị đúng ở chế độ thu gọn. Chỉ dùng bộ icon có sẵn trong dự án, đừng thêm bộ thứ hai.
Mục đang mở được tô sáng theo route.path; nhóm cha tự mở khi một mục con đang hoạt động.
Tiêu đề trang
Mỗi trang tự đặt CPageHeader ở đầu nội dung:
vue
<CPageHeader
title="Đơn hàng"
sub-title="Danh sách đơn hàng toàn hệ thống"
:breadcrumb="[{ title: 'Trang chủ', to: '/' }, { title: 'Đơn hàng' }]"
@navigate="navigateTo"
>
<template #extra>
<CButton variant="outline">Xuất Excel</CButton>
<CButton v-can="'/orders/create'" variant="primary">Tạo đơn</CButton>
</template>
</CPageHeader>CPageHeader không phụ thuộc router: breadcrumb có to sẽ phát sự kiện navigate, trang tự gọi navigateTo. Quên bind @navigate thì bấm breadcrumb không có gì xảy ra.
Breadcrumb đặt theo cấu trúc nghiệp vụ, không nhất thiết theo cây thư mục route. Mục cuối là trang hiện tại, không có to.
Bố cục nội dung
| Việc | Dùng |
|---|---|
| Nhóm nội dung thành khối | CCard (có title, collapsible, type) |
| Lưới nhiều cột | Row + Col từ @tasco/ui, luôn khai điểm ngắt :xs |
| Khoảng cách giữa phần tử | Space, hoặc gap với biến --tasco-* |
| Danh sách dữ liệu | CTable — đã có khung card riêng, không bọc thêm CCard |
CTable tự dựng khung card (tiêu đề + toolbar), nên không lồng nó trong CCard — sẽ thành hai lớp viền.
Thu gọn sidebar
Layout giữ trạng thái ở useState('tasco:layout:collapsed'), dùng chung toàn app:
ts
const collapsed = useState<boolean>('tasco:layout:collapsed')
collapsed.value = trueChế độ sáng / tối
Nút chuyển có sẵn trên header. Trong trang:
ts
const { mode, isDark, setMode, toggle } = useThemeMode()Lựa chọn lưu vào cookie tasco:theme (một năm) nên không nháy theme khi tải lại. Mặc định ban đầu lấy từ app.config.ts (tasco.themeMode).
Mọi CSS của dự án phải dùng biến --tasco-* để đổi theo chế độ — xem Viết CSS trong dự án.
Trang không dùng khung
vue
<script setup lang="ts">
definePageMeta({ layout: false }) // trang đăng nhập, màn hình toàn màn hình
</script>Hoặc khai layout khác nếu dự án có thêm layout riêng trong app/layouts/.
Thay khung hoàn toàn
Tạo app/layouts/default.vue trong dự án là bản của layer không còn được dùng. Khi đó dự án tự lo: lọc menu theo quyền, nút đổi sáng/tối, menu tài khoản và đăng xuất, <NuxtErrorBoundary> quanh nội dung. Nên dựng lại từ CAppLayout + CSideNav + CTopNav thay vì viết từ đầu:
vue
<script setup lang="ts">
const appConfig = useAppConfig()
const route = useRoute()
const { filterByPermission } = usePermission()
const collapsed = useState('tasco:layout:collapsed', () => false)
const nav = computed(() => filterByPermission(appConfig.tasco?.nav ?? []))
</script>
<template>
<CAppLayout v-model:collapsed="collapsed" :app-title="appConfig.tasco?.appTitle">
<template #logo><img src="/logo.svg" alt="" height="28"></template>
<template #nav>
<CSideNav :items="nav" :active-path="route.path" :collapsed="collapsed" @navigate="navigateTo" />
</template>
<slot />
</CAppLayout>
</template>Màn hình hẹp
Sidebar không tự thu gọn theo độ rộng màn hình: người dùng bấm nút cạnh logo để thu lại còn dải icon 64px, và lựa chọn giữ qua các trang. Cần thu gọn sẵn trên màn hình hẹp thì trang đặt tasco:layout:collapsed (mục Thu gọn sidebar). Nội dung trang thì dự án tự chịu trách nhiệm — luôn khai điểm ngắt cho Col, và để bảng nhiều cột cuộn ngang (:scroll="{ x: 1200 }") thay vì ép chữ xuống dòng.
Liên quan
- Theme & design token — theme sáng/tối trong app.
- Bố cục trang chuẩn — cấu trúc một trang và lưới.
- Viết CSS trong dự án — quy tắc dùng token.
- Chặn theo quyền — ẩn mục menu theo quyền.