Skip to content

Trang chi tiết

Xem một bản ghi: thông tin chính, vài hành động, và thường có bảng con hoặc tab. Khác trang danh sách ở chỗ dữ liệu tải một lần theo id trên URL.

Bộ khung

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, refresh } = await useAsyncData<Order>(
  () => `order-${route.params.id}`,
  () => fetchOrder(String(route.params.id)),
)

const currency = new Intl.NumberFormat('vi-VN')
</script>

<template>
  <CPageHeader
    :title="order?.code ?? 'Đơn hàng'"
    :sub-title="order?.customer"
    :breadcrumb="[{ title: 'Đơn hàng', to: '/orders' }, { title: order?.code ?? '' }]"
    @navigate="navigateTo"
  >
    <template #extra>
      <CButton v-can="'/orders/update'" variant="outline" @click="navigateTo(`/orders/${order?.id}/edit`)">
        Sửa
      </CButton>
      <CButton v-can="'/orders/delete'" variant="primary" danger @click="remove">
        Xoá
      </CButton>
    </template>
  </CPageHeader>

  <CCard v-if="status === 'pending'">Đang tải…</CCard>

  <CEmpty
    v-else-if="error"
    :description="error.message"
    bordered
  >
    <CButton variant="primary" @click="refresh">Thử lại</CButton>
  </CEmpty>

  <CEmpty v-else-if="!order" description="Không tìm thấy đơn hàng." bordered />

  <CCard v-else title="Thông tin chung">
    <CDescriptions :column="2" bordered>
      <CDescriptionsItem label="Mã đơn">{{ order.code }}</CDescriptionsItem>
      <CDescriptionsItem label="Khách hàng">{{ order.customer }}</CDescriptionsItem>
      <CDescriptionsItem label="Giá trị">{{ currency.format(order.amount) }} ₫</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>

Ba trạng thái phải có

Trạng tháiHiển thị
Đang tảiKhung chờ hoặc dòng "Đang tải…" — không để trang trắng
LỗiThông báo kèm nút thử lại (refresh)
Không có dữ liệuCEmpty với lời giải thích; backend trả null chứ không phải 404

Bỏ qua một trong ba là nguồn gốc của "trang trắng không biết vì sao".

Khoá của useAsyncData

ts
// Sai — đổi sang đơn khác vẫn thấy dữ liệu đơn cũ
useAsyncData('order', () => fetchOrder(String(route.params.id)))

// Đúng — khoá đổi theo tham số
useAsyncData(() => `order-${route.params.id}`, () => fetchOrder(String(route.params.id)))

Khoá phải chứa mọi tham số ảnh hưởng tới kết quả.

Hành động trên bản ghi

ts
import { message, useConfirm } from '@tasco/ui'

const confirm = useConfirm()
const { removeOrder } = useOrdersApi()
const { $tascoError } = useNuxtApp()

async function remove() {
  const ok = await confirm({
    title: 'Xoá đơn hàng?',
    content: `Đơn ${order.value?.code} sẽ bị xoá vĩnh viễn.`,
    okText: 'Xoá',
    danger: true,
  })
  if (!ok) return

  try {
    await removeOrder(String(route.params.id))
    message.success('Đã xoá đơn hàng.')
    await navigateTo('/orders')
  } catch (caught) {
    $tascoError(caught)
  }
}

Hành động không thể hoàn tác (xoá, huỷ, duyệt) luôn hỏi lại. Nêu rõ đối tượng bị tác động trong câu hỏi, đừng chỉ viết "Bạn có chắc không?".

Sau hành động làm đổi dữ liệu đang xem, gọi refresh() thay vì tự sửa state tại chỗ — tránh lệch với backend.

Bảng con

Danh sách con (dòng hàng của đơn, lịch sử thao tác) dùng lại useTable với fetcher nhận id:

ts
const { fetchOrderItems } = useOrdersApi()

// Đặt bí danh khi trang có nhiều bảng — tránh trùng tên với bảng chính.
const {
  dataSource: itemRows,
  loading: itemsLoading,
  pagination: itemsPagination,
  onChange: onItemsChange,
} = useTable<OrderItem>(
  (query) => fetchOrderItems(String(route.params.id), query),
  { pageSize: 10, settingsKey: 'orders:items' },
)
vue
<CTable
  title="Mặt hàng"
  row-key="id"
  :columns="itemColumns"
  :data-source="itemRows"
  :loading="itemsLoading"
  :pagination="itemsPagination"
  @change="onItemsChange"
/>

settingsKey của bảng con phải khác bảng chính, nếu không hai bảng ghi đè thiết lập của nhau.

Tab

vue
<script setup lang="ts">
import { CTabPane, CTabs } from '@tasco/ui'

const tab = ref('info')
</script>

<template>
  <CTabs v-model:active-key="tab">
    <CTabPane key="info" tab="Thông tin">…</CTabPane>
    <CTabPane key="items" tab="Mặt hàng">…</CTabPane>
    <CTabPane key="history" tab="Lịch sử">…</CTabPane>
  </CTabs>
</template>

Tab nặng (bảng con, biểu đồ) chỉ nên tải khi được mở lần đầu: đặt immediate: false cho useTable rồi gọi load() trong watch của tab.

Muốn giữ tab qua lần tải lại trang thì đồng bộ với query string (?tab=items) thay vì lưu vào state cục bộ.

Danh sách rà soát

  • [ ] Khoá useAsyncData chứa tham số route.
  • [ ] Đủ ba trạng thái: đang tải, lỗi, không có dữ liệu.
  • [ ] Breadcrumb dẫn được về danh sách (@navigate="navigateTo").
  • [ ] Hành động phá huỷ có xác nhận và có v-can.
  • [ ] Sau hành động thì refresh(), không tự sửa dữ liệu tại chỗ.
  • [ ] Bảng con dùng settingsKey riêng.

Liên quan