Skip to content

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[],
  },
})
FieldKiểuÝ nghĩa
labelstringNhãn hiển thị (bắt buộc)
pathstring?Đường dẫn điều hướng; nhóm cha có children thì bỏ trống
iconrender function() => h(IconHome, { size: 18 }) — dùng @tabler/icons-vue
permissionstring?URI quyền; không có quyền thì item bị ẩn
childrenNavItem[]?Menu con một cấp
keystring?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ệcDùng
Nhóm nội dung thành khốiCCard (có title, collapsible, type)
Lưới nhiều cộtRow + 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ệuCTable — đã 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 = true

Chế độ 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