浏览代码

refactor: handling server validation rules on the forms also

Denis 10 月之前
父节点
当前提交
886d5e18a7

+ 6 - 4
.eslintrc.cjs

@@ -21,9 +21,9 @@ module.exports = {
     // Uncomment any of the lines below to choose desired strictness,
     // but leave only one uncommented!
     // See https://eslint.vuejs.org/rules/#available-rules
-    "plugin:vue/vue3-essential", // Priority A: Essential (Error Prevention)
-    "plugin:vue/vue3-strongly-recommended", // Priority B: Strongly Recommended (Improving Readability)
-    "plugin:vue/vue3-recommended", // Priority C: Recommended (Minimizing Arbitrary Choices and Cognitive Overhead)
+    "plugin:vue/essential", // Priority A: Essential (Error Prevention)
+    "plugin:vue/strongly-recommended", // Priority B: Strongly Recommended (Improving Readability)
+    "plugin:vue/recommended", // Priority C: Recommended (Minimizing Arbitrary Choices and Cognitive Overhead)
     "plugin:@intlify/vue-i18n/recommended",
 
     // https://github.com/prettier/eslint-config-prettier#installation
@@ -64,7 +64,9 @@ module.exports = {
   rules: {
     "prefer-promise-reject-errors": "off",
     "vue/require-prop-types": "off",
-
+    "vue/no-v-model-argument": "off",
+    "vue/no-unused-vars": "warn",
+    "vue/no-unused-components": "warn",
     // allow debugger during development only
     "no-debugger": process.env.NODE_ENV === "production" ? "error" : "off",
   },

文件差异内容过多而无法显示
+ 213 - 185
package-lock.json


+ 2 - 2
package.json

@@ -38,11 +38,11 @@
     "@intlify/eslint-plugin-vue-i18n": "^3.2.0",
     "@intlify/unplugin-vue-i18n": "^2.0.0",
     "@intlify/vue-i18n-loader": "^4.2.0",
-    "@quasar/app-vite": "^2.0.1",
+    "@quasar/app-vite": "^2.2.1",
     "autoprefixer": "^10.4.20",
     "eslint": "^8.57.1",
     "eslint-config-prettier": "^9.1.0",
-    "eslint-plugin-vue": "^9.32.0",
+    "eslint-plugin-vue": "^9.33.0",
     "postcss": "^8.4.49",
     "prettier": "^3.4.2",
     "vite-plugin-checker": "^0.6.4"

+ 2 - 2
quasar.config.js

@@ -15,10 +15,10 @@ export default configure((ctx) => {
     // --> boot files are part of "main.js"
     // https://v2.quasar.dev/quasar-cli-vite/boot-files
     boot: [
-      "i18n",
       "axios",
-      "setPermissions",
+      "i18n",
       "defaultPropsComponents",
+      "setPermissions",
       // "socket.io",
     ],
 

+ 67 - 57
src/boot/axios.js

@@ -1,7 +1,8 @@
 import { boot } from "quasar/wrappers";
-import { useAuth } from "src/composables/useAuth";
 import { Cookies, Notify } from "quasar";
 import axios from "axios";
+import { useRouter } from "vue-router";
+import { useAuth } from "src/composables/useAuth";
 
 const api = axios.create({
   baseURL: process.env.API_URL + "/api",
@@ -26,77 +27,83 @@ api.interceptors.request.use(
 );
 
 let isRefreshing = false;
-let validQueue = [];
+let failedQueue = [];
+
+const processQueue = (error, token = null) => {
+  failedQueue.forEach((prom) => {
+    if (error) {
+      prom.reject(error);
+    } else {
+      prom.config.headers["Authorization"] = "Bearer " + token;
+      prom.resolve(api(prom.config));
+    }
+  });
+  failedQueue = [];
+};
 
-const errorInterceptor = async (error) => {
-  if (error.config?.retryCount) {
-    error.config.retryCount = 0;
-  }
+const errorInterceptor = async (
+  error,
+  router,
+  setAuthTokens,
+  eraseAuthTokens,
+) => {
+  const originalRequest = error.config;
 
-  if (error.config.retryCount >= 3) {
+  if (error.response?.status === 422) {
     return Promise.reject(error);
   }
 
-  error.config.retryCount++;
-
-  if (!error.response) {
+  if (
+    error.response?.status !== 401 ||
+    originalRequest.url.includes("/refresh")
+  ) {
     Notify.create({
-      message: error.message,
+      message: error?.response?.data?.message ?? error.message,
       type: "negative",
     });
     return Promise.reject(error);
   }
 
-  if (error.response.status === 401) {
-    if (error?.config?.url === "/login") {
-      Notify.create({
-        message: error.response.data.message,
-        type: "negative",
-      });
-      return Promise.reject(error);
-    }
+  if (error?.config?.url === "/login") {
+    Notify.create({
+      message: error.response.data.message,
+      type: "negative",
+    });
+    return Promise.reject(error);
+  }
 
-    if (isRefreshing) {
-      return new Promise((resolve, reject) => {
-        validQueue.push({ resolve, reject, config: error.config });
-      });
-    }
+  if (isRefreshing) {
+    return new Promise((resolve, reject) => {
+      failedQueue.push({ resolve, reject, config: originalRequest });
+    })
+      .then((res) => res)
+      .catch((err) => Promise.reject(err));
+  }
 
-    isRefreshing = true;
-    try {
-      await useAuth().refreshToken();
-    } catch (error) {
-      validQueue = [];
-      Cookies.remove("access_token");
-      Cookies.remove("refresh_token");
-      if (window.location.pathname !== "/login") {
-        window.location.href = "/login";
-      }
-      return Promise.reject(error);
+  isRefreshing = true;
+  try {
+    const refreshToken = Cookies.get("refresh_token");
+    if (!refreshToken) {
+      router.push("/login");
+      return Promise.reject(new Error("No refresh token available."));
     }
 
-    try {
-      validQueue.forEach((request) => {
-        request.resolve(api.request(request.config));
-      });
-      validQueue = [];
-
-      return await api.request(error.config);
-    } catch (error) {
-      Notify.create({
-        message: error.response.data.message,
-        type: "negative",
-      });
-    } finally {
-      isRefreshing = false;
-    }
+    const response = await api.post("/refresh", {
+      refresh_token: refreshToken,
+    });
+    const newAccessToken = response.data.payload.access_token;
+    setAuthTokens(response.data.payload);
+    originalRequest.headers["Authorization"] = `Bearer ${newAccessToken}`;
+    processQueue(null, newAccessToken);
+    return api(originalRequest);
+  } catch (err) {
+    processQueue(err, null);
+    eraseAuthTokens();
+    router.push("/login");
+    return Promise.reject(err);
+  } finally {
+    isRefreshing = false;
   }
-
-  Notify.create({
-    message: error.response.data.message,
-    type: "negative",
-  });
-  return Promise.reject(error);
 };
 
 const successInterceptor = (response) => {
@@ -110,9 +117,12 @@ const successInterceptor = (response) => {
 };
 
 export default boot(({ app }) => {
+  const router = useRouter();
+  const { setAuthTokens, eraseAuthTokens } = useAuth();
+
   api.interceptors.response.use(
     (response) => successInterceptor(response),
-    (error) => errorInterceptor(error),
+    (error) => errorInterceptor(error, router, setAuthTokens, eraseAuthTokens),
   );
 
   // for use inside Vue files (Options API) through this.$axios and this.$api

+ 10 - 0
src/components/defaults/DefaultCepInput.vue

@@ -11,6 +11,8 @@
       :mask="masks.Brasil.cep"
       :rules="rules"
       :loading="loading"
+      :error
+      :error-message
     />
   </div>
 </template>
@@ -40,6 +42,14 @@ const { disable, readonly, label, rules } = defineProps({
     type: Array,
     default: () => [],
   },
+  error: {
+    type: Boolean,
+    default: false,
+  },
+  errorMessage: {
+    type: String,
+    default: "",
+  },
 });
 
 const emit = defineEmits([

+ 15 - 5
src/components/defaults/DefaultCurrencyInput.vue

@@ -4,7 +4,7 @@
     v-model="formattedValue"
     outlined
     :error-message
-    :error="!!errorMessage"
+    :error="!!errorMessageComp"
     :class="disable ? 'no-pointer-events' : ''"
     :label="newLabel"
     :disable
@@ -19,7 +19,7 @@ import { useI18n } from "vue-i18n";
 
 const model = defineModel();
 
-const { options, disable, readonly, label } = defineProps({
+const { options, disable, readonly, label, error, errorMessage } = defineProps({
   options: {
     type: Object,
     default: () => ({
@@ -46,16 +46,26 @@ const { options, disable, readonly, label } = defineProps({
     type: String,
     default: () => useI18n().t("common.terms.currency"),
   },
+  error: {
+    type: Boolean,
+    default: false,
+  },
+  errorMessage: {
+    type: String,
+    default: "",
+  },
 });
 
 const { inputRef, formattedValue, numberValue, setValue } =
   useCurrencyInput(options);
 
-const errorMessage = computed(() =>
+const errorMessageComp = computed(() => {
   numberValue.value < 0
     ? useI18n().t("validation.rules.value_smaller_than_zero")
-    : undefined,
-);
+    : undefined;
+
+  return errorMessage ? errorMessage : (error ?? void 0);
+});
 
 const newLabel = computed(() => (label ? label : void 0));
 

+ 16 - 1
src/components/defaults/DefaultFilePicker.vue

@@ -1,5 +1,12 @@
 <template>
-  <q-field v-model="model" v-bind="$attrs" borderless :rules="rules">
+  <q-field
+    v-model="model"
+    v-bind="$attrs"
+    borderless
+    :rules="rules"
+    :error
+    :error-message
+  >
     <div class="column flex-center q-mb-sm full-width">
       <span class="text-grey-6">{{ label }}</span>
       <div
@@ -94,6 +101,14 @@ const { label, rules, accept, type, initialImage } = defineProps({
     type: String,
     default: null,
   },
+  error: {
+    type: Boolean,
+    default: false,
+  },
+  errorMessage: {
+    type: String,
+    default: "",
+  },
 });
 
 const model = defineModel();

+ 12 - 2
src/components/defaults/DefaultInputDatePicker.vue

@@ -7,6 +7,8 @@
       :label="label"
       :rules="rules"
       :dense="dense"
+      :error
+      :error-message
       clearable
     >
       <template #append>
@@ -73,9 +75,17 @@ const { label, rules, time, dense } = defineProps({
     type: Boolean,
     default: false,
   },
+  error: {
+    type: Boolean,
+    default: false,
+  },
+  errorMessage: {
+    type: String,
+    default: "",
+  },
 });
 
-const qInputRef = useTemplateRef("inputRef");
+const qInputRef = useTemplateRef('inputRef')
 
 const treatedDate = defineModel();
 const untreatedDate = defineModel("untreatedDate");
@@ -108,7 +118,7 @@ const unformatDate = (value) => {
 };
 
 const inputMask = computed(() => {
-  if (!qInputRef.value) return "";
+  if (!qInputRef.value) return '';
 
   if (time) {
     return masks.Brasil.datetime;

+ 11 - 1
src/components/defaults/DefaultPasswordInput.vue

@@ -4,7 +4,9 @@
     v-bind="$attrs"
     :label="$t('common.terms.password')"
     :type="!seePassword ? 'password' : 'text'"
-    :rules="rules"
+    :rules
+    :error
+    :error-message
   >
     <template #append>
       <q-icon
@@ -21,6 +23,14 @@ const { rules } = defineProps({
     type: Array,
     default: () => [],
   },
+  error: {
+    type: Boolean,
+    default: false,
+  },
+  errorMessage: {
+    type: String,
+    default: "",
+  },
 });
 
 const password = defineModel();

+ 3 - 2
src/components/layout/LeftMenuLayout.vue

@@ -42,10 +42,11 @@
       </div>
 
       <q-list class="column no-wrap">
-        <template v-for="item in navigationItems" :key="item.name">
+        <template v-for="item in navigationItems">
           <template v-if="item.permission">
             <q-item
               v-if="item.type === 'single'"
+              :key="item.name"
               v-ripple
               clickable
               exact-active-class="menu-selected"
@@ -67,7 +68,7 @@
               >
             </q-item>
             <!-- Expansive Menu with children -->
-            <div v-else>
+            <div v-else :key="item.name">
               <template v-if="!miniState">
                 <q-tooltip
                   v-if="miniState"

+ 3 - 1
src/components/layout/LeftMenuLayoutMobile.vue

@@ -10,11 +10,12 @@
   >
     <div class="column full-height q-pa-sm no-wrap">
       <q-list class="column no-wrap">
-        <template v-for="item in navigationItems" :key="item.name">
+        <template v-for="item in navigationItems">
           <template v-if="item.permission">
             <!-- Single Menu -->
             <q-item
               v-if="item.type === 'single'"
+              :key="item.name"
               v-ripple
               clickable
               exact-active-class="menu-selected"
@@ -31,6 +32,7 @@
             <!-- Expansive Menu with children -->
             <q-expansion-item
               v-else
+              :key="item.name"
               v-model="isExpasionItemExpanded"
               header-class="menu-item--spaced"
               :class="{

+ 13 - 3
src/components/regions/CitySelect.vue

@@ -7,10 +7,12 @@
     fill-input
     clearable
     :options="cityOptions"
-    :label="label"
-    :loading="loading"
+    :label
+    :rules
+    :loading
     :placeholder="$t('common.actions.search') + ' ' + $t('ui.navigation.city')"
-    :rules="rules"
+    :error
+    :error-message
     @filter="filterFn"
   >
     <template #no-option>
@@ -60,6 +62,14 @@ const { state, label, rules, initialId } = defineProps({
     required: false,
     default: null,
   },
+  error: {
+    type: Boolean,
+    default: false,
+  },
+  errorMessage: {
+    type: String,
+    default: "",
+  },
 });
 
 const selectedCity = defineModel();

+ 19 - 1
src/components/regions/CountrySelect.vue

@@ -13,6 +13,8 @@
       $t('common.actions.search') + ' ' + $t('ui.navigation.country')
     "
     :rules="rules"
+    :error
+    :error-message
     @filter="filterFn"
   >
     <template #no-option>
@@ -30,7 +32,7 @@ import { getCountries } from "src/api/country";
 import { ref, onMounted } from "vue";
 import { useI18n } from "vue-i18n";
 
-const { label, rules } = defineProps({
+const { label, rules, initialId } = defineProps({
   label: {
     type: String,
     default: () => useI18n().t("ui.navigation.country"),
@@ -39,6 +41,19 @@ const { label, rules } = defineProps({
     type: Array,
     default: () => [],
   },
+  initialId: {
+    type: Number,
+    required: false,
+    default: null,
+  },
+  error: {
+    type: Boolean,
+    default: false,
+  },
+  errorMessage: {
+    type: String,
+    default: "",
+  },
 });
 
 const selectedCountry = defineModel();
@@ -85,6 +100,9 @@ onMounted(async () => {
       label: country.name,
       value: country.id,
     }));
+    if (initialId) {
+      selectCountryById(initialId);
+    }
   } catch (e) {
     console.log(e);
   } finally {

+ 13 - 3
src/components/regions/StateSelect.vue

@@ -7,10 +7,12 @@
     fill-input
     clearable
     :options="stateOptions"
-    :label="label"
-    :loading="loading"
+    :label
+    :loading
     :placeholder="$t('common.actions.search') + ' ' + $t('ui.navigation.state')"
-    :rules="rules"
+    :rules
+    :error
+    :error-message
     @filter="filterFn"
   >
     <template #no-option>
@@ -54,6 +56,14 @@ const { country, label, rules, initialId } = defineProps({
     required: false,
     default: null,
   },
+  error: {
+    type: Boolean,
+    default: false,
+  },
+  errorMessage: {
+    type: String,
+    default: "",
+  },
 });
 
 const selectedState = defineModel();

+ 29 - 30
src/composables/useAuth.js

@@ -4,6 +4,30 @@ import { permissionStore } from "src/stores/permission";
 import { userStore } from "src/stores/user";
 
 export const useAuth = () => {
+  const setAuthTokens = async (tokens) => {
+    const { access_token, refresh_token } = tokens;
+    const accessTokenExpiresIn = new Date(
+      new Date().getTime() + tokens.access_token_expires_in * 1000,
+    );
+    const refreshTokenExpiresIn = new Date(
+      new Date().getTime() + tokens.refresh_token_expires_in * 1000,
+    );
+    Cookies.set("access_token", access_token, {
+      expires: accessTokenExpiresIn,
+    });
+    Cookies.set("refresh_token", refresh_token, {
+      expires: refreshTokenExpiresIn,
+    });
+    userStore().user = tokens.user;
+    await permissionStore().fetchScopes();
+  };
+
+  const eraseAuthTokens = async () => {
+    Cookies.remove("access_token");
+    Cookies.remove("refresh_token");
+    await permissionStore().fetchScopes();
+  };
+
   const login = async (email, password) => {
     try {
       const response = await api.post("/login", {
@@ -13,20 +37,7 @@ export const useAuth = () => {
 
       if (response.status === 200) {
         const payload = response.data.payload;
-        const accessTokenExpiresIn = new Date(
-          new Date().getTime() + payload.access_token_expires_in * 1000,
-        );
-        const refreshTokenExpiresIn = new Date(
-          new Date().getTime() + payload.refresh_token_expires_in * 1000,
-        );
-        Cookies.set("access_token", payload.access_token, {
-          expires: accessTokenExpiresIn,
-        });
-        Cookies.set("refresh_token", payload.refresh_token, {
-          expires: refreshTokenExpiresIn,
-        });
-        userStore().user = payload.user;
-        await permissionStore().fetchScopes();
+        await setAuthTokens(payload);
       }
       return response;
     } catch (error) {
@@ -38,9 +49,7 @@ export const useAuth = () => {
     try {
       const response = await api.post("/logout");
       if (response.status === 200) {
-        Cookies.remove("access_token");
-        Cookies.remove("refresh_token");
-        await permissionStore().fetchScopes();
+        await eraseAuthTokens();
       }
     } catch (error) {
       console.error(error);
@@ -56,19 +65,7 @@ export const useAuth = () => {
 
       if (response.status === 200) {
         const payload = response.data.payload;
-        const accessTokenExpiresIn = new Date(
-          new Date().getTime() + payload.access_token_expires_in * 1000,
-        );
-        const refreshTokenExpiresIn = new Date(
-          new Date().getTime() + payload.refresh_token_expires_in * 1000,
-        );
-        Cookies.set("access_token", payload.access_token, {
-          expires: accessTokenExpiresIn,
-        });
-        Cookies.set("refresh_token", payload.refresh_token, {
-          expires: refreshTokenExpiresIn,
-        });
-        userStore().user = payload.user;
+        await setAuthTokens(payload);
       }
     } catch (error) {
       return Promise.reject(error);
@@ -79,5 +76,7 @@ export const useAuth = () => {
     login,
     logout,
     refreshToken,
+    setAuthTokens,
+    eraseAuthTokens,
   };
 };

+ 40 - 0
src/composables/useSubmitHandler.js

@@ -0,0 +1,40 @@
+import { ref } from "vue";
+
+export function useSubmitHandler(onSuccess) {
+  const loading = ref(false);
+  const serverErrors = ref({});
+
+  const execute = async (apiCallThunk) => {
+    loading.value = true;
+    serverErrors.value = {};
+
+    try {
+      const response = await apiCallThunk();
+
+      if (onSuccess && typeof onSuccess === "function") {
+        onSuccess(response);
+      }
+    } catch (error) {
+      if (error.response?.status === 422) {
+        const errors = error.response.data.errors || {};
+        for (const key in errors) {
+          serverErrors.value[key] = errors[key][0];
+        }
+      } else {
+        console.error(
+          "An unexpected error occurred in useSubmitHandler:",
+          error,
+        );
+        throw error;
+      }
+    } finally {
+      loading.value = false;
+    }
+  };
+
+  return {
+    loading,
+    serverErrors,
+    execute,
+  };
+}

+ 21 - 21
src/pages/city/components/AddEditCityDialog.vue

@@ -8,14 +8,21 @@
             v-model="form.name"
             :label="$t('common.terms.name')"
             :rules="[inputRules.required]"
+            :error="!!serverErrors.name"
+            :error-message="serverErrors.name"
             class="col-md-6 col-12"
+            @update:model-value="serverErrors.name = null"
           />
           <CountrySelect
             ref="countrySelectRef"
             v-model="selectedCountry"
             :label="$t('ui.navigation.country')"
             :rules="[inputRules.required]"
+            :error="!!serverErrors.country_id"
+            :error-message="serverErrors.country_id"
+            :initial-id="city ? city.country_id : null"
             class="col-md-6 col-12"
+            @update:model-value="serverErrors.country_id = null"
           />
           <StateSelect
             v-model="selectedState"
@@ -23,15 +30,21 @@
             :initial-id="form.state_id"
             :label="$t('ui.navigation.state')"
             :rules="[inputRules.required]"
+            :error="!!serverErrors.state_id"
+            :error-message="serverErrors.state_id"
             class="col-md-6 col-12"
             @selected-country-id="countrySelectRef.selectCountryById($event)"
+            @update:model-value="serverErrors.state_id = null"
           />
           <q-select
             v-model="selectedStatus"
             :label="$t('common.terms.status')"
             :options="statusOptions"
             :rules="[inputRules.required]"
+            :error="!!serverErrors.status"
+            :error-message="serverErrors.status"
             class="col-md-6 col-12"
+            @update:model-value="serverErrors.status = null"
           />
         </q-card-section>
         <q-card-actions align="center">
@@ -56,6 +69,7 @@ import { useDialogPluginComponent } from "quasar";
 import { useI18n } from "vue-i18n";
 import { createCity, updateCity } from "src/api/city";
 import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
 
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
 import CountrySelect from "src/components/regions/CountrySelect.vue";
@@ -94,7 +108,11 @@ const { form, getUpdatedFields, hasUpdatedFields } = useFormUpdateTracker({
   status: city ? city?.status : "ACTIVE",
 });
 
-const loading = ref(false);
+const {
+  loading,
+  serverErrors,
+  execute: submitForm,
+} = useSubmitHandler(() => onDialogOK(true));
 
 const selectedCountry = ref(null);
 const selectedState = ref(null);
@@ -113,27 +131,9 @@ const onOKClick = async () => {
   }
 
   if (city) {
-    loading.value = true;
-    try {
-      await updateCity(city.id, getUpdatedFields.value);
-    } catch (error) {
-      console.error(error);
-      return;
-    } finally {
-      loading.value = false;
-    }
-    onDialogOK(true);
+    await submitForm(() => updateCity(getUpdatedFields.value, city.id));
   } else {
-    loading.value = true;
-    try {
-      await createCity({ ...form });
-    } catch (error) {
-      console.error(error);
-      return;
-    } finally {
-      loading.value = false;
-    }
-    onDialogOK(true);
+    await submitForm(() => createCity({ ...form }));
   }
 };
 

+ 17 - 23
src/pages/country/components/AddEditCountryDialog.vue

@@ -8,20 +8,29 @@
             v-model="form.name"
             :label="$t('common.terms.name')"
             :rules="[inputRules.required]"
+            :error="!!serverErrors.name"
+            :error-message="serverErrors.name"
             class="col-md-6 col-12"
+            @update:model-value="serverErrors.name = null"
           />
           <q-input
             v-model="form.code"
             :label="$t('common.terms.code')"
             :rules="[inputRules.required]"
+            :error="!!serverErrors.code"
+            :error-message="serverErrors.code"
             class="col-md-6 col-12"
+            @update:model-value="serverErrors.code = null"
           />
           <q-select
             v-model="selectedStatus"
             :label="$t('common.terms.status')"
             :options="statusOptions"
             :rules="[inputRules.required]"
+            :error="!!serverErrors.status"
+            :error-message="serverErrors.status"
             class="col-md-6 col-12"
+            @update:model-value="serverErrors.status = null"
           />
         </q-card-section>
         <q-card-actions align="center">
@@ -46,6 +55,7 @@ import { useDialogPluginComponent } from "quasar";
 import { useI18n } from "vue-i18n";
 import { createCountry, updateCountry } from "src/api/country";
 import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
 
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
 
@@ -80,7 +90,11 @@ const { form, getUpdatedFields, hasUpdatedFields } = useFormUpdateTracker({
   status: country ? country?.status : "ACTIVE",
 });
 
-const loading = ref(false);
+const {
+  loading,
+  serverErrors,
+  execute: submitForm,
+} = useSubmitHandler(() => onDialogOK(true));
 
 const selectedStatus = ref({
   label: t("common.status.active"),
@@ -97,29 +111,9 @@ const onOKClick = async () => {
   }
 
   if (country) {
-    // When editing, only send changed fields
-    loading.value = true;
-    try {
-      await updateCountry(country.id, getUpdatedFields.value);
-    } catch (error) {
-      console.error(error);
-      return;
-    } finally {
-      loading.value = false;
-    }
-    onDialogOK(true);
+    await submitForm(() => updateCountry(getUpdatedFields.value, country.id));
   } else {
-    // When creating, send all fields
-    loading.value = true;
-    try {
-      await createCountry({ ...form });
-    } catch (error) {
-      console.error(error);
-      return;
-    } finally {
-      loading.value = false;
-    }
-    onDialogOK(true);
+    await submitForm(() => createCountry({ ...form }));
   }
 };
 

+ 20 - 24
src/pages/state/components/AddEditStateDialog.vue

@@ -8,27 +8,39 @@
             v-model="form.name"
             :label="$t('common.terms.name')"
             :rules="[inputRules.required]"
+            :error="!!serverErrors.name"
+            :error-message="serverErrors.name"
             class="col-md-6 col-12"
+            @update:model-value="serverErrors.name = null"
           />
           <q-input
             v-model="form.code"
             :label="$t('common.terms.code')"
             :rules="[inputRules.required, inputRules.max(2)]"
+            :error="!!serverErrors.code"
+            :error-message="serverErrors.code"
             class="col-md-6 col-12"
+            @update:model-value="serverErrors.code = null"
           />
           <CountrySelect
             v-model="selectedCountry"
             :label="$t('ui.navigation.country')"
             :rules="[inputRules.required]"
             :initial-id="form.country_id"
+            :error="!!serverErrors.country_id"
+            :error-message="serverErrors.country_id"
             class="col-md-6 col-12"
+            @update:model-value="serverErrors.country_id = null"
           />
           <q-select
             v-model="selectedStatus"
             :label="$t('common.terms.status')"
             :options="statusOptions"
             :rules="[inputRules.required]"
+            :error="!!serverErrors.status"
+            :error-message="serverErrors.status"
             class="col-md-6 col-12"
+            @update:model-value="serverErrors.status = null"
           />
         </q-card-section>
         <q-card-actions align="center">
@@ -53,6 +65,7 @@ import { useDialogPluginComponent } from "quasar";
 import { useI18n } from "vue-i18n";
 import { createState, updateState } from "src/api/state";
 import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
 
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
 import CountrySelect from "src/components/regions/CountrySelect.vue";
@@ -89,7 +102,11 @@ const { form, getUpdatedFields, hasUpdatedFields } = useFormUpdateTracker({
   status: state ? state?.status : "ACTIVE",
 });
 
-const loading = ref(false);
+const {
+  loading,
+  serverErrors,
+  execute: submitForm,
+} = useSubmitHandler(() => onDialogOK(true));
 
 const selectedCountry = ref(null);
 const selectedStatus = ref({
@@ -105,31 +122,10 @@ const onOKClick = async () => {
   if (!(await formRef.value.validate())) {
     return;
   }
-
   if (state) {
-    // When editing, only send changed fields
-    loading.value = true;
-    try {
-      await updateState(state.id, getUpdatedFields.value);
-    } catch (error) {
-      console.error(error);
-      return;
-    } finally {
-      loading.value = false;
-    }
-    onDialogOK(true);
+    await submitForm(() => updateState(getUpdatedFields.value, state.id));
   } else {
-    // When creating, send all fields
-    loading.value = true;
-    try {
-      await createState({ ...form });
-    } catch (error) {
-      console.error(error);
-      return;
-    } finally {
-      loading.value = false;
-    }
-    onDialogOK(true);
+    await submitForm(() => createState({ ...form }));
   }
 };
 

+ 44 - 33
src/pages/users/components/AddEditUserDialog.vue

@@ -9,23 +9,49 @@
             :label="$t('common.terms.name')"
             :hint="$t('user.profile.name_and_surname')"
             :rules="[inputRules.required]"
+            :error="!!serverErrors.name"
+            :error-message="serverErrors.name"
             class="col-md-6 col-12"
-          />
-          <q-input
-            v-model="form.email"
-            label="Email"
-            :rules="[inputRules.email]"
-            class="col-md-6 col-12"
+            @update:model-value="serverErrors.name = null"
           />
           <UserTypeSelect
             v-model="selectedUserType"
             :rules="[inputRules.required]"
             :type="form.type"
+            :error="!!serverErrors.email"
+            :error-message="serverErrors.email"
             class="col-md-6 col-12"
+            @update:model-value="serverErrors.email = null"
+          />
+          <q-input
+            v-model="form.email"
+            label="Email"
+            :rules="[inputRules.email]"
+            :error="!!serverErrors.type"
+            :error-message="serverErrors.type"
+            class="col-12"
+            @update:model-value="serverErrors.type = null"
           />
           <DefaultPasswordInput
             v-model="form.password"
-            :rules="[inputRules.required, inputRules.min(6)]"
+            :rules="
+              user
+                ? [inputRules.password]
+                : [inputRules.required, inputRules.password]
+            "
+            :error="!!serverErrors.password"
+            :error-message="serverErrors.password"
+            class="col-md-6 col-12"
+            @update:model-value="serverErrors.password = null"
+          />
+          <DefaultPasswordInput
+            v-model="confirmPassword"
+            :label="$t('auth.confirm_password')"
+            :rules="
+              user
+                ? [inputRules.samePassword(form.password)]
+                : [inputRules.required, inputRules.samePassword(form.password)]
+            "
             class="col-md-6 col-12"
           />
         </q-card-section>
@@ -51,6 +77,7 @@ import { useDialogPluginComponent } from "quasar";
 import { useI18n } from "vue-i18n";
 import { createUser, updateUser } from "src/api/user";
 import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
 
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
 import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
@@ -88,42 +115,26 @@ const { form, getUpdatedFields, hasUpdatedFields } = useFormUpdateTracker({
 });
 
 const selectedUserType = ref(null);
-const loading = ref(false);
+const confirmPassword = ref("");
+
+const {
+  loading,
+  serverErrors,
+  execute: submitForm,
+} = useSubmitHandler(() => onDialogOK(true));
 
-// this is part of our example (so not required)
 const onOKClick = async () => {
   if (!(await formRef.value.validate())) {
     return;
   }
-
   if (user) {
-    // When editing, only send changed fields
-    loading.value = true;
-    try {
-      await updateUser(user.id, getUpdatedFields.value);
-    } catch (error) {
-      console.error(error);
-      return;
-    } finally {
-      loading.value = false;
-    }
-    onDialogOK(true);
+    await submitForm(() => updateUser(getUpdatedFields.value, user.id));
   } else {
-    // When creating, send all fields
-    loading.value = true;
-    try {
-      await createUser({ ...form });
-    } catch (error) {
-      console.error(error);
-      return;
-    } finally {
-      loading.value = false;
-    }
-    onDialogOK(true);
+    await submitForm(() => createUser({ ...form }));
   }
 };
 
 watch(selectedUserType, () => {
-  form.type = selectedUserType.value.value
+  form.type = selectedUserType.value.value;
 });
 </script>

+ 73 - 12
src/pages/users/components/UserTypeSelect.vue

@@ -2,9 +2,18 @@
   <q-select
     v-bind="$attrs"
     v-model="selectedUserType"
-    :label="label"
-    :options="userTypeOptions"
-    :rules="rules"
+    use-input
+    hide-selected
+    fill-input
+    :options="filteredOptions"
+    :clearable
+    :loading
+    :readonly
+    :label
+    :rules
+    :error
+    :error-message
+    @filter="filterFn"
   />
 </template>
 <script setup>
@@ -25,21 +34,73 @@ const { label, rules, type } = defineProps({
     type: String,
     default: null,
   },
+  clearable: {
+    type: Boolean,
+    default: true,
+  },
+  readonly: {
+    type: Boolean,
+    default: false,
+  },
+  error: {
+    type: Boolean,
+    default: false,
+  },
+  errorMessage: {
+    type: String,
+    default: "",
+  },
 });
 
 const selectedUserType = defineModel();
 const userTypeOptions = ref([]);
+const filteredOptions = ref([]);
+const isLoading = ref(false);
+const searchQuery = ref("");
 
-onMounted(async () => {
-  const response = await userTypes();
-  const values = Object.entries(response);
-  values.forEach(([key, value]) => {
-    userTypeOptions.value.push({ label: value, value: key });
+const selectUserByValue = (value) => {
+  selectedUserType.value = userTypeOptions.value.find(
+    (option) => option.value === value,
+  );
+};
+
+const filterFn = (val, update) => {
+  searchQuery.value = val;
+  update(() => {
+    if (val === "") {
+      filteredOptions.value = userTypeOptions.value;
+    } else {
+      const needle = val.toLowerCase();
+      filteredOptions.value = userTypeOptions.value.filter((v) =>
+        v.label.toLowerCase().includes(needle),
+      );
+    }
   });
-  if (type) {
-    selectedUserType.value = userTypeOptions.value.find(
-      (option) => option.value === type,
-    );
+};
+
+onMounted(async () => {
+  try {
+    isLoading.value = true;
+    const response = await userTypes();
+    userTypeOptions.value = Object.entries(response).map(([key, value]) => ({
+      label: value,
+      value: key,
+    }));
+    filteredOptions.value = userTypeOptions.value;
+
+    if (type) {
+      selectedUserType.value = userTypeOptions.value.find(
+        (option) => option.value === type,
+      );
+    }
+  } catch (error) {
+    console.error("Failed to load user types:", error);
+  } finally {
+    isLoading.value = false;
   }
 });
+
+defineExpose({
+  selectUserByValue,
+});
 </script>

部分文件因为文件数量过多而无法显示