Skip to content

Hướng dẫn: dựng trang admin đầu tiên ​

Bài này đi từ một dự án trống tới một trang admin có menu, dashboard, bảng dữ liệu, form có kiểm tra, xác nhận xoá và đổi ngôn ngữ/giao diện. Ví dụ dùng Vue 3 + Vite; cuối mỗi bước có ghi chú cho React và Angular.

Yêu cầu: Node 20+, và một trong các framework: Vue ≥ 3.5.2, React ≥ 18, Angular ≥ 20.

1. Tạo dự án và cài thl-ui ​

sh
npm create vite@latest my-admin -- --template vue-ts
cd my-admin
npm i @thl-ui/vue @thl-ui/styles vue-router

@thl-ui/core, @thl-ui/locales, @thl-ui/tokens, @thl-ui/icons và @thl-ui/charts được cài tự động. Không cần Bootstrap, jQuery hay font từ Google: CSS và font Open Sans đều nằm trong @thl-ui/styles.

React / Angular
sh
npm i @thl-ui/react @thl-ui/styles react-router        # React
npm i @thl-ui/angular @thl-ui/styles                    # Angular

2. Khai báo plugin và CSS ​

src/main.ts:

ts
import { createApp } from 'vue';
import ThlUI from '@thl-ui/vue';
import '@thl-ui/styles/thl-ui.css';
import App from './App.vue';
import { router } from './router';

createApp(App)
  .use(router)
  .use(ThlUI, {
    locale: 'vi',          // ngôn ngữ mặc định: en · ja · vi · zh
    theme: 'default',      // 1 trong 12 skin
    colorMode: 'auto',     // light · dark · auto (theo hệ điều hành)
    persist: true,         // nhớ lựa chọn của người dùng trong localStorage
  })
  .mount('#app');

Thêm class thl-body vào <body> trong index.html để có font, màu nền và cỡ tiêu đề cơ bản:

html
<body class="thl-body">
React / Angular

React: bọc app trong <ThlProvider config={{ locale: 'vi', persist: true }}>. Angular: bootstrapApplication(App, { providers: [provideZonelessChangeDetection(), provideThlUI({ locale: 'vi' })] }) và thêm node_modules/@thl-ui/styles/dist/thl-ui.css vào styles trong angular.json.

3. Khung trang: menu, topbar, sidebar ​

Menu là một mảng MenuItem. label có thể là khoá i18n (tự dịch) hoặc chữ thường.

src/menu.ts:

ts
import type { MenuItem } from '@thl-ui/core';

export const menu: MenuItem[] = [
  { key: 'dash', label: 'menu.dashboard', icon: 'layout-dashboard', href: '/' },
  { key: 'users', label: 'Người dùng', icon: 'users', href: '/users' },
  { key: 'settings', label: 'Cài đặt', icon: 'settings', children: [
    { key: 'profile', label: 'Hồ sơ', href: '/settings/profile' },
  ] },
];

src/App.vue:

vue
<script setup lang="ts">
import { useRoute, RouterLink, RouterView } from 'vue-router';
import { ThlAppLayout } from '@thl-ui/vue';
import { menu } from './menu';

const route = useRoute();
const user = { name: 'An Nguyễn', role: 'Quản trị viên', avatar: '/me.jpg' };
const Nodes = (p: { nodes: unknown }) => p.nodes;
</script>

<template>
  <ThlAppLayout :menu="menu" :current-path="route.path" :user="user" brand="My Admin">
    <!-- để menu dùng RouterLink thay vì <a href> (không tải lại trang) -->
    <template #link="{ item, content, class: cls, current }">
      <RouterLink :to="item.href" :class="cls" :aria-current="current ? 'page' : undefined"><Nodes :nodes="content" /></RouterLink>
    </template>
    <RouterView />
  </ThlAppLayout>
</template>

Bạn có ngay: topbar có tìm kiếm, nút đổi ngôn ngữ và sáng/tối, menu người dùng; sidebar nhiều cấp tự đánh dấu mục đang mở; panel cài đặt (nút ⚙) để người dùng đổi skin, layout, chế độ màu, ngôn ngữ. Trên màn hình < 1170px sidebar tự thu nhỏ, < 768px thành menu trượt.

Các slot có sẵn: brand, topbar-left, topbar-right, link, sidebar-footer, footer.

4. Một trang dashboard ​

Mỗi trang bọc trong ThlPage (tiêu đề + breadcrumb). Bố cục dùng lưới 12 cột thl-row / thl-col-*: dưới 768px mọi cột tự xếp thành một cột.

src/pages/Dashboard.vue:

vue
<script setup lang="ts">
import { ThlPage, ThlInfoBox, ThlCard, ThlLineChart, ThlLabel } from '@thl-ui/vue';
const series = [{ name: 'Doanh thu', data: [12, 19, 14, 25, 22, 30] }];
const labels = ['T1', 'T2', 'T3', 'T4', 'T5', 'T6'];
</script>

<template>
  <ThlPage title="Tổng quan" :breadcrumb="[{ label: 'Trang chủ', href: '/' }, { label: 'Tổng quan' }]">
    <div class="thl-row">
      <div class="thl-col-12 thl-col-sm-6 thl-col-lg-3">
        <ThlInfoBox icon="shopping-cart" value="154" title="Đơn hàng">
          <template #footer>Mục tiêu <ThlLabel color="success" rounded>300</ThlLabel></template>
        </ThlInfoBox>
      </div>
      <!-- …3 ô nữa… -->
    </div>
    <div class="thl-row">
      <div class="thl-col-12 thl-col-lg-8">
        <ThlCard title="Doanh thu 6 tháng"><ThlLineChart :series="series" :labels="labels" smooth area /></ThlCard>
      </div>
    </div>
  </ThlPage>
</template>

Lớp lưới: thl-col-{1..12} (mọi kích thước), thl-col-sm-* (≥ 768px), thl-col-md-* (≥ 1024px), thl-col-lg-* (≥ 1170px).

5. Bảng dữ liệu có tìm kiếm, sắp xếp, phân trang ​

vue
<script setup lang="ts">
import { ref } from 'vue';
import { ThlPage, ThlCard, ThlDataTable, ThlButton, ThlLabel, confirm, useToast } from '@thl-ui/vue';

const toast = useToast();
const users = ref([
  { id: 1, name: 'An', email: '[email protected]', role: 'admin' },
  { id: 2, name: 'Bình', email: '[email protected]', role: 'user' },
]);
const columns = [
  { key: 'name', title: 'Tên', sortable: true },
  { key: 'email', title: 'Email' },
  { key: 'role', title: 'Quyền', sortable: true },
  { key: 'actions', title: '', searchable: false },
];

async function remove(row: { id: number; name: string }) {
  if (!(await confirm({ title: `Xoá ${row.name}?`, icon: 'warning', confirmVariant: 'danger' }))) return;
  users.value = users.value.filter((u) => u.id !== row.id);
  toast.success('Đã xoá');
}
</script>

<template>
  <ThlPage title="Người dùng">
    <ThlCard>
      <ThlDataTable :data="users" :columns="columns" row-key="id" selectable :page-size="10">
        <!-- tuỳ biến ô bằng slot cell-{key} -->
        <template #cell-role="{ value }">
          <ThlLabel :color="value === 'admin' ? 'danger' : 'info'" rounded>{{ value }}</ThlLabel>
        </template>
        <template #cell-actions="{ row }">
          <ThlButton size="sm" variant="danger" outline icon="trash-2" :aria-label="`Xoá ${row.name}`" @click="remove(row)" />
        </template>
      </ThlDataTable>
    </ThlCard>
  </ThlPage>
</template>

Dữ liệu lớn từ server: thêm manual và :total="tongSoBanGhi", rồi tải dữ liệu trong sự kiện @query-change="({ page, pageSize, sort, search }) => …".

6. Form có kiểm tra dữ liệu ​

Thông báo lỗi tự dịch theo ngôn ngữ hiện tại. Lỗi chỉ hiện sau lần bấm Submit đầu tiên, rồi cập nhật ngay khi sửa.

vue
<script setup lang="ts">
import { reactive } from 'vue';
import { ThlForm, ThlField, ThlInput, ThlSelect, ThlButton, ThlFormActions, rules, useToast } from '@thl-ui/vue';

const toast = useToast();
const v = reactive({ name: '', email: '', role: null as string | null });
const roles = [{ label: 'Quản trị', value: 'admin' }, { label: 'Người dùng', value: 'user' }];
const save = (ok: boolean) => ok && toast.success('Đã lưu');
</script>

<template>
  <ThlForm :values="v" @submit="save">
    <ThlField label="Họ tên" name="name" :value="v.name" :rules="[rules.required(), rules.minLength(2)]" required v-slot="{ id }">
      <ThlInput :id="id" v-model="v.name" />
    </ThlField>
    <ThlField label="Email" name="email" :value="v.email" :rules="[rules.required(), rules.email()]" required v-slot="{ id }">
      <ThlInput :id="id" v-model="v.email" type="email" />
    </ThlField>
    <ThlField label="Quyền" name="role" :value="v.role" :rules="[rules.required()]" required>
      <ThlSelect v-model="v.role" :options="roles" />
    </ThlField>
    <ThlFormActions>
      <ThlButton type="submit" variant="primary">Lưu</ThlButton>
    </ThlFormActions>
  </ThlForm>
</template>

Các luật có sẵn: required, email, minLength(n), maxLength(n), min(n), max(n), number, pattern(re), match('tenTruong'). Tự viết luật: hàm trả về null (hợp lệ) hoặc { key: 'khoa.i18n', params }.

7. Thông báo và hộp xác nhận ​

ts
import { useToast, confirm } from '@thl-ui/vue';
const toast = useToast();

toast.success('Đã lưu');
toast.error('Không kết nối được', { title: 'Lỗi', duration: 0 }); // 0 = không tự đóng
if (await confirm({ title: 'Đăng xuất?', icon: 'question' })) logout();

AppLayout đã gắn sẵn chỗ hiển thị toast và hộp xác nhận. Nếu không dùng AppLayout, hãy đặt <ThlToaster /> và <ThlConfirmHost /> một lần ở gốc app.

8. Ngôn ngữ, giao diện, chế độ màu ​

Người dùng tự đổi trong panel ⚙. Từ code:

ts
import { useThlConfig, useI18n } from '@thl-ui/vue';
const { set } = useThlConfig();
const { t, setLocale } = useI18n();

set({ theme: 'purple-dark', colorMode: 'dark', layout: 'horizontal' });
setLocale('en');
t('common.save'); // "Save"

Thêm chuỗi dịch của riêng bạn khi khởi tạo:

ts
app.use(ThlUI, {
  locale: 'vi',
  messages: {
    vi: { app: { welcome: 'Xin chào, {name}!' } },
    en: { app: { welcome: 'Hello, {name}!' } },
  },
});
// trong template: {{ $t('app.welcome', { name: user.name }) }}

Đổi màu thương hiệu: ghi đè biến CSS, ví dụ

css
:root { --thl-theme: #7c3aed; --thl-on-theme: #fff; --thl-theme-text: #6d28d9; }

Xem Theme & dark mode để biết mọi biến và cách đảm bảo độ tương phản.

9. Trang đăng nhập và trang lỗi ​

Các trang không có sidebar dùng layout riêng:

vue
<ThlAuthLayout title="Đăng nhập">
  <ThlForm :values="v" @submit="login">…</ThlForm>
</ThlAuthLayout>

<ThlErrorLayout :code="404" />  <!-- tiêu đề và mô tả tự dịch -->

Trong router, đánh dấu các route này để App.vue không bọc chúng trong ThlAppLayout (xem meta.blank trong apps/playground-vue).

10. Đưa lên production ​

  • npm run build: Vite tự đóng gói CSS và font (chỉ các subset font cần thiết được tải).
  • Chỉ import component bạn dùng (import { ThlButton } from '@thl-ui/vue') và cài plugin không đăng ký toàn cục (app.use(ThlUI) → import { ThlUI } from '@thl-ui/vue') để bundle nhỏ hơn.
  • SSR (Nuxt, Next, Angular SSR) được hỗ trợ: component không đụng window khi render trên server.

Tiếp theo ​