Skip to content

Lỗi 422 và form

Hai tầng kiểm tra dữ liệu, cả hai đều cần:

TầngAi kiểmVí dụHiện ở đâu
Ràng buộc nhập liệurules của CForm, chạy ở trình duyệtBắt buộc, độ dài, định dạng emailNgay khi rời ô nhập
Nghiệp vụBackend, trả 422Mã trùng, vượt hạn mức, khách hàng ngừng hoạt độngDưới đúng ô nhập, sau khi gửi

Framework cố tình không hiện toast cho 422: lỗi phải nằm cạnh ô nhập gây ra nó, không phải ở góc màn hình.

Thân lỗi 422

json
{
  "message": "Dữ liệu không hợp lệ",
  "errors": {
    "customer": ["Khách hàng không tồn tại"],
    "amount": ["Giá trị phải lớn hơn 0", "Vượt hạn mức tín dụng"]
  }
}

errors (hoặc fields) phải ở gốc body. Backend lồng vào chỗ khác thì quy đổi trong fetcher trước khi ném ra.

Đọc lỗi ra

ts
import { getFieldErrors, isValidation } from '@tasco/utils'

const serverErrors = ref<Record<string, string[]>>({})

try {
  await createOrder(model)
} catch (caught) {
  if (isValidation(caught)) {
    serverErrors.value = getFieldErrors(caught)   // { customer: [...], amount: [...] }
  } else {
    $tascoError(caught)
  }
}

getFieldErrors luôn trả object (rỗng nếu không có gì đọc được) và luôn chuẩn hoá giá trị thành mảng chuỗi, kể cả khi backend trả một chuỗi đơn.

Gắn vào ô nhập

vue
<CFormItem
  label="Khách hàng"
  name="customer"
  :validate-status="serverErrors.customer ? 'error' : undefined"
  :help="serverErrors.customer?.[0]"
>
  <CInput v-model:value="model.customer" />
</CFormItem>

Bấm nút trong ví dụ dưới để xem lỗi 422 hiện lên đúng ô nhập, và biến mất khi người dùng sửa lại:

Nhiều lỗi trên một field thì hiện tất cả:

vue
:help="serverErrors.amount?.join('. ')"

Ví dụ đầy đủ

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'

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' }],
}

/** Người dùng sửa ô nào thì xoá lỗi server của ô đó — tránh lỗi cũ nằm lại. */
function clearServerError(field: keyof OrderInput) {
  if (serverErrors.value[field]) {
    const { [field]: _removed, ...rest } = serverErrors.value
    serverErrors.value = rest
  }
}

// `finish` chỉ phát khi rules phía client đã qua nên đây là chỗ gọi API. Form cần tự quyết
// thời điểm validate thì dùng ref: xem [Trang form](/guide/patterns/form).
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>
  <CForm :model="model" :rules="rules" style="max-width: 520px" @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" @change="clearServerError('customer')" />
    </CFormItem>

    <CFormItem
      label="Giá trị"
      name="amount"
      :validate-status="serverErrors.amount ? 'error' : undefined"
      :help="serverErrors.amount?.[0]"
    >
      <CInputCurrency v-model:value="model.amount" @change="clearServerError('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>
    </CFormItem>
  </CForm>
</template>

Lỗi không thuộc về field nào

Backend có thể trả 422 với message chung mà không có errors ("Đơn hàng đã bị khoá bởi người khác"). Hiện nó ở đầu form:

vue
<CAlert
  v-if="formError"
  type="error"
  :message="formError"
  show-icon
  style="margin-bottom: 16px"
/>
ts
catch (caught) {
  if (isValidation(caught)) {
    const error = toAppError(caught)
    serverErrors.value = getFieldErrors(error)
    formError.value = Object.keys(serverErrors.value).length ? '' : error.message
  }
}

CAlert là primitive antdv re-export — import { CAlert } from '@tasco/ui'.

Bẫy thường gặp

  • Không xoá lỗi cũ trước khi gửi lại — người dùng sửa xong vẫn thấy lỗi cũ nằm đó.
  • Tên field không khớp giữa backend và model: order_amount ở backend sẽ không gắn vào ô amount. Thống nhất tên, hoặc map lại trong fetcher.
  • Dùng createError({ data }) trong route Nitro của dự án — h3 lồng thêm một tầng data khiến getFieldErrors không thấy gì. Dùng setResponseStatus(event, 422) rồi trả body thẳng, xem Hợp đồng dữ liệu.
  • Backend trả 400 thay vì 422 cho lỗi nhập liệu — người dùng sẽ thấy toast thay vì lỗi dưới ô nhập. Thống nhất mã trạng thái từ đầu.

Liên quan