Giao diện
Trang form
Trang thêm mới và trang chỉnh sửa dùng chung một mẫu: CPageHeader + CCard + CForm, dữ liệu trong một reactive model, kiểm tra hai tầng (rules ở client, 422 từ backend).
Bộ khung
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)
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' }],
}
// `finish` chỉ phát khi mọi rule phía client đã qua → đây là chỗ gọi API.
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) {
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: 560px" @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">
<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>
<CButton variant="text" @click="navigateTo('/orders')">Huỷ</CButton>
</CFormItem>
</CForm>
</CCard>
</template>CForm mặc định layout dọc (nhãn trên, ô nhập dưới) — dễ đọc và hợp với màn hình hẹp. Đổi bằng cách truyền thẳng thuộc tính của antd Form: <CForm layout="horizontal">.
Mặc định gửi bằng @finish
Trang thêm/sửa một nút Lưu thì dùng @finish: nút đặt html-type="submit", sự kiện chỉ phát khi mọi rule phía client đã qua, và Enter trong ô nhập cũng gửi được form. Cần biết lúc rule không qua thì bắt thêm @finish-failed.
Khi trang phải tự quyết thời điểm validate — validate một bước của wizard, resetFields() sau khi lưu, clearValidate() lúc đổi giữa thêm và sửa — thì lấy API của form qua ref (mục dưới). Hai cách dùng chung được trên cùng một form.
Chủ động validate bằng ref từ 2.1
@finish luôn validate cả form, nên không hợp với wizard hay form chia tab. Những lúc đó giữ CForm qua template ref và gọi thẳng validate():
vue
<script setup lang="ts">
import type { CFormInstance } from '@tasco/ui'
const formRef = ref<CFormInstance>()
const step = ref(0)
async function next() {
try {
await formRef.value?.validate(['customer', 'amount']) // chỉ field của bước này
step.value += 1
} catch {
// Rule chưa qua — antd đã hiện lỗi dưới từng ô, giữ nguyên bước hiện tại.
}
}
</script>
<template>
<CForm ref="formRef" :model="model" :rules="rules">
<!-- … -->
</CForm>
</template>Kiểu CFormInstance lấy từ @tasco/ui — không import FormInstance của ant-design-vue, ESLint chặn mọi import từ đó kể cả import type.
Sau khi lưu thành công mà ở lại trang (form "tạo tiếp") thì gọi formRef.value?.resetFields(): hàm này trả field về giá trị khởi tạo và xoá luôn trạng thái lỗi. Chỉ muốn xoá lỗi mà giữ giá trị (vd chuyển một form dùng chung từ chế độ sửa sang thêm) thì dùng clearValidate().
validate() reject khi có rule chưa qua, nên luôn bọc try/catch. Danh sách đầy đủ các hàm: xem trang CForm.
Một trang cho cả thêm và sửa
vue
<!-- app/pages/orders/[id]/edit.vue -->
<script setup lang="ts">
const route = useRoute()
const id = computed(() => String(route.params.id))
const isEdit = computed(() => id.value !== 'new')
const { fetchOrder, createOrder, updateOrder } = useOrdersApi()
const model = reactive<OrderInput>({ customer: '', amount: 0, note: '' })
if (isEdit.value) {
const order = await fetchOrder(id.value)
Object.assign(model, { customer: order.customer, amount: order.amount, note: order.note ?? '' })
}
async function save() {
return isEdit.value ? updateOrder(id.value, { ...model }) : createOrder({ ...model })
}
</script>Chỉ gán những field thuộc về form, không Object.assign(model, order) nguyên khối — bản ghi từ backend có id, createdAt, status… và chúng sẽ bị gửi ngược lên khi lưu.
Quyền của hai chế độ khác nhau, khai theo chế độ:
ts
definePageMeta({ permissions: [] }) // khai rỗng rồi tự kiểm trong trang
const { can } = usePermission()
if (!can(isEdit.value ? '/orders/update' : '/orders/create')) {
throw createError({ statusCode: 403, statusMessage: 'Bạn không có quyền thực hiện thao tác này.' })
}Ô nhập có sẵn
| Dữ liệu | Component | Ghi chú |
|---|---|---|
| Chữ ngắn | CInput | |
| Chữ dài | CTextarea | :rows |
| Số | CInputNumber | |
| Tiền | CInputCurrency | Tự định dạng phân cách hàng nghìn |
| Phần trăm | CInputPercent | |
| Chọn một / nhiều | CSelect + CSelectOption | mode="multiple" |
| Có/không | CSwitch, CCheckbox | |
| Ngày, khoảng ngày | CDatePicker, CRangePicker | Locale vi_VN áp sẵn |
| Tệp | CUpload | Gửi bằng FormData qua useApi |
Tất cả đều là primitive antdv re-export, nên phải import { … } from '@tasco/ui'. Chỉ 17 component thương hiệu (CForm, CCard, CButton, CTable…) mới đăng ký toàn cục.
Bố cục nhiều cột
vue
<script setup lang="ts">
import { Col as CCol, CFormItem, CInput, Row as CRow } from '@tasco/ui'
</script>
<template>
<CForm :model="model" :rules="rules">
<CRow :gutter="16">
<CCol :xs="24" :md="12">
<CFormItem label="Khách hàng" name="customer">
<CInput v-model:value="model.customer" />
</CFormItem>
</CCol>
<CCol :xs="24" :md="12">
<CFormItem label="Người phụ trách" name="owner">
<CInput v-model:value="model.owner" />
</CFormItem>
</CCol>
</CRow>
</CForm>
</template>Luôn khai điểm ngắt (:xs="24") để form không vỡ trên màn hình hẹp.
Row và Col giữ nguyên tên
Vài primitive bố cục (Row, Col, Space, Flex, Divider, Typography, Layout) được re-export không kèm tiền tố C. Dùng thẳng tên gốc, hoặc đặt bí danh khi import như ví dụ trên.
Cảnh báo khi rời trang
ts
import { useConfirm } from '@tasco/ui'
const confirm = useConfirm()
const dirty = ref(false)
watch(model, () => { dirty.value = true }, { deep: true })
onBeforeRouteLeave(async () => {
if (!dirty.value) return true
return confirm({
title: 'Rời khỏi trang?',
content: 'Thay đổi chưa lưu sẽ bị mất.',
okText: 'Rời khỏi',
danger: true,
})
})useConfirm nằm ở @tasco/ui (phụ thuộc antd) nên không auto-import — phải khai import.
Chống lưu hai lần
Nút lưu bind :loading="submitting" là đủ: antd tự khoá nút khi loading. Đừng thêm disabled riêng rồi quên bật lại trong nhánh lỗi — luôn tắt submitting trong finally.
Danh sách rà soát
- [ ] Dữ liệu được validate trước khi gửi — qua
@finish, hoặcformRef.value.validate()nếu trang tự gọi. - [ ] Lỗi 422 gắn đúng field, xoá sạch trước mỗi lần gửi lại.
- [ ]
submittingluôn được tắt trongfinally. - [ ] Chỉ gửi những field thuộc về form.
- [ ] Sau khi lưu: điều hướng hoặc làm mới dữ liệu, kèm thông báo thành công.
- [ ] Form dài có nút Huỷ rõ ràng và cảnh báo khi rời trang.
Liên quan
- Lỗi 422 và form — chi tiết phần lỗi nghiệp vụ.
- Trang chi tiết — nơi quay về sau khi lưu.
- Component — API của từng ô nhập.