Skip to content

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/uikhô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ệuComponentGhi chú
Chữ ngắnCInput
Chữ dàiCTextarea:rows
SốCInputNumber
TiềnCInputCurrencyTự định dạng phân cách hàng nghìn
Phần trămCInputPercent
Chọn một / nhiềuCSelect + CSelectOptionmode="multiple"
Có/khôngCSwitch, CCheckbox
Ngày, khoảng ngàyCDatePicker, CRangePickerLocale vi_VN áp sẵn
TệpCUploadGử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.

RowCol 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ặc formRef.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.
  • [ ] submitting luôn được tắt trong finally.
  • [ ] 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