From f087edab5d1d3dfff2f3e22cb15aefde7059f4b2 Mon Sep 17 00:00:00 2001 From: ck_yeun9 Date: Sun, 15 Feb 2026 21:03:10 +0800 Subject: [PATCH 01/10] feat: implement two-factor authentication features and enhance API structure - Added support for two-factor authentication in the sign-in process, including UI components for entering verification codes and managing two-factor settings. - Updated API calls to handle different login types (admin and employee) and added endpoints for two-factor operations. - Enhanced error handling and success notifications for API responses. - Refactored base API setup to improve code organization and maintainability. - Added new permission codes related to two-factor authentication for both admin and staff management. - Updated i18n messages to support new two-factor authentication features and UI elements. --- components.d.ts | 2 + src/api/baseapi.js | 112 +++++++++ src/api/basicapi.js | 144 ++++++++++- src/api/index.js | 85 +------ src/common/permissioncode.js | 10 + src/i18n.js | 82 ++++++ src/layouts/MainLayout.vue | 465 ++++++++++++++++++++++++++++++++++- src/views/SignInView.vue | 212 ++++++++++++++-- vite.config.js | 2 +- 9 files changed, 996 insertions(+), 118 deletions(-) create mode 100644 src/api/baseapi.js diff --git a/components.d.ts b/components.d.ts index 93fddf8..5135cc6 100644 --- a/components.d.ts +++ b/components.d.ts @@ -41,6 +41,7 @@ declare module 'vue' { APageHeader: typeof import('ant-design-vue/es')['PageHeader'] APopconfirm: typeof import('ant-design-vue/es')['Popconfirm'] AProgress: typeof import('ant-design-vue/es')['Progress'] + AQrcode: typeof import('ant-design-vue/es')['QRCode'] ARadio: typeof import('ant-design-vue/es')['Radio'] ARadioButton: typeof import('ant-design-vue/es')['RadioButton'] ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup'] @@ -63,6 +64,7 @@ declare module 'vue' { ATimeline: typeof import('ant-design-vue/es')['Timeline'] ATimelineItem: typeof import('ant-design-vue/es')['TimelineItem'] ATooltip: typeof import('ant-design-vue/es')['Tooltip'] + ATypographyParagraph: typeof import('ant-design-vue/es')['TypographyParagraph'] AUpload: typeof import('ant-design-vue/es')['Upload'] CaretRightOutlined: typeof import('@ant-design/icons-vue')['CaretRightOutlined'] CarryOutOutlined: typeof import('@ant-design/icons-vue')['CarryOutOutlined'] diff --git a/src/api/baseapi.js b/src/api/baseapi.js new file mode 100644 index 0000000..a503590 --- /dev/null +++ b/src/api/baseapi.js @@ -0,0 +1,112 @@ +import axios from "axios"; +import { csrfTokenManager } from "@/utils/csrf"; +import { handleApiError, handleHttpError } from "@/common/errorHandler"; + +const api = axios.create({ + baseURL: import.meta.env.VITE_API_URL, + timeout: 30000, + withCredentials: true, +}); + +export const isApiSuccess = (payload) => { + if (!payload || typeof payload !== "object") { + return false; + } + if (typeof payload.Success === "boolean") { + return payload.Success; + } + if (typeof payload.Code === "number") { + return payload.Code === 0; + } + return false; +}; + +export const getApiMessage = (payload) => + payload?.Message || payload?.message || "Request failed"; + +// 创建一个函数来设置认证头 +const setAuthorizationHeader = (config) => { + const token = localStorage.getItem("token"); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}; + +// 创建一个重试机制 +const retryRequest = async (error) => { + const failedRequest = error.config; + + // 如果已经重试过,则不再重试 + if (failedRequest._retry) { + return Promise.reject(error); + } + + // 标记这个请求已经重试过 + failedRequest._retry = true; + + // 重新从 localStorage 获取 token + const token = localStorage.getItem("token"); + if (token) { + failedRequest.headers.Authorization = `Bearer ${token}`; + return api(failedRequest); + } + + return Promise.reject(error); +}; + +api.interceptors.request.use( + async (config) => { + // 设置认证头 + config = setAuthorizationHeader(config); + + // 设置 CSRF Token + const csrfToken = csrfTokenManager.getToken(); + if (csrfToken) { + config.headers["X-CSRF-TOKEN-HEADER"] = csrfToken; + } + + return config; + }, + async (error) => { + // 检查是否是 token 相关错误 + if (error.isTokenError) { + return retryRequest(error); + } + + // 其他错误直接拒绝 + return Promise.reject(error); + }, +); + +api.interceptors.response.use( + (response) => { + if (response?.config?.skipBusinessErrorHandling) { + return response; + } + + const resData = response.data; + if (typeof resData === "string" && resData.includes("Version")) { + return response; + } + + const hasBusinessFlag = + typeof resData?.Success === "boolean" || + typeof resData?.Code === "number"; + + if (hasBusinessFlag && !isApiSuccess(resData)) { + return handleApiError(resData, response); + } + + return response; + }, + (error) => { + if (error?.config?.skipHttpErrorHandling) { + return Promise.reject(error); + } + + return handleHttpError(error); + }, +); + +export default api; diff --git a/src/api/basicapi.js b/src/api/basicapi.js index 09f771d..9389e98 100644 --- a/src/api/basicapi.js +++ b/src/api/basicapi.js @@ -1,17 +1,51 @@ import api from "../api"; +const LOGIN_ENDPOINTS = { + admin: "/Admin/Login", + employee: "/Employee/EmployeeLogin", +}; + +const TWO_FACTOR_CONTROLLERS = { + admin: "Admin", + employee: "Employee", +}; + +const resolveLoginEndpoint = (loginType = "admin") => + LOGIN_ENDPOINTS[loginType] || LOGIN_ENDPOINTS.admin; + +const resolveTwoFactorController = (loginType = "admin") => + TWO_FACTOR_CONTROLLERS[loginType] || TWO_FACTOR_CONTROLLERS.admin; + +const toErrorPayload = (error) => + error?.response?.data || { + Code: -1, + Message: error?.message || "Request failed", + Data: null, + }; + +export const isApiSuccess = (payload) => { + if (!payload || typeof payload !== "object") return false; + if (typeof payload.Success === "boolean") return payload.Success; + if (typeof payload.Code === "number") return payload.Code === 0; + return false; +}; + +export const getApiMessage = (payload) => + payload?.Message || payload?.message || "Request failed"; + +export const isTwoFactorChallenge = (payload) => + payload?.Code === 1401 && payload?.Data?.RequiresTwoFactor === true; + // 登录 -export const signIn = async (data) => { +export const signIn = async (data, loginType = "admin") => { try { - const response = await api.post("/Admin/Login", data); - const resData = response.data; - if (resData.Success) { - return resData; - } else { - throw new Error(`Login failed with status code: ${response.status}`); - } + const response = await api.post(resolveLoginEndpoint(loginType), data, { + skipBusinessErrorHandling: true, + skipHttpErrorHandling: true, + }); + return response.data; } catch (error) { - throw error; + throw toErrorPayload(error); } }; @@ -20,7 +54,7 @@ export const signOut = async (data) => { try { const response = await api.post("/Admin/Logout", data); const resData = response.data; - if (resData.Success) { + if (isApiSuccess(resData)) { return resData; } else { throw new Error(`Logout failed with status code: ${response.status}`); @@ -36,3 +70,93 @@ export const getServerVersion = async () => { const resData = response.data; return resData; }; + +export const fetchTwoFactorStatus = async (loginType = "admin") => { + try { + const controller = resolveTwoFactorController(loginType); + const response = await api.get(`/${controller}/GetTwoFactorStatus`, { + skipBusinessErrorHandling: true, + skipHttpErrorHandling: true, + }); + return response.data; + } catch (error) { + throw toErrorPayload(error); + } +}; + +export const generateTwoFactorSetup = async (loginType = "admin") => { + try { + const controller = resolveTwoFactorController(loginType); + const response = await api.post( + `/${controller}/GenerateTwoFactorSetup`, + {}, + { + skipBusinessErrorHandling: true, + skipHttpErrorHandling: true, + }, + ); + return response.data; + } catch (error) { + throw toErrorPayload(error); + } +}; + +export const enableTwoFactor = async ( + loginType = "admin", + verificationCode = "", +) => { + try { + const controller = resolveTwoFactorController(loginType); + const response = await api.post( + `/${controller}/EnableTwoFactor`, + { VerificationCode: verificationCode }, + { + skipBusinessErrorHandling: true, + skipHttpErrorHandling: true, + }, + ); + return response.data; + } catch (error) { + throw toErrorPayload(error); + } +}; + +export const disableTwoFactor = async ( + loginType = "admin", + verificationCode = "", +) => { + try { + const controller = resolveTwoFactorController(loginType); + const response = await api.post( + `/${controller}/DisableTwoFactor`, + { VerificationCode: verificationCode }, + { + skipBusinessErrorHandling: true, + skipHttpErrorHandling: true, + }, + ); + return response.data; + } catch (error) { + throw toErrorPayload(error); + } +}; + +export const regenerateTwoFactorRecoveryCodes = async ( + loginType = "admin", + verificationCode = "", +) => { + try { + const controller = resolveTwoFactorController(loginType); + const response = await api.post( + `/${controller}/RegenerateTwoFactorRecoveryCodes`, + { VerificationCode: verificationCode }, + { + skipBusinessErrorHandling: true, + skipHttpErrorHandling: true, + }, + ); + return response.data; + } catch (error) { + throw toErrorPayload(error); + } +}; diff --git a/src/api/index.js b/src/api/index.js index 23922dd..a7e7910 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -1,85 +1,4 @@ -import axios from "axios"; -import router from "../router"; -import { csrfTokenManager } from "@/utils/csrf"; -import { handleApiError, handleHttpError } from "@/common/errorHandler"; - -const api = axios.create({ - baseURL: import.meta.env.VITE_API_URL, - timeout: 30000, - withCredentials: true, -}); - -// 创建一个函数来设置认证头 -const setAuthorizationHeader = (config) => { - const token = localStorage.getItem("token"); - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; -}; - -// 创建一个重试机制 -const retryRequest = async (error) => { - const failedRequest = error.config; - - // 如果已经重试过,则不再重试 - if (failedRequest._retry) { - return Promise.reject(error); - } - - // 标记这个请求已经重试过 - failedRequest._retry = true; - - // 重新从 localStorage 获取 token - const token = localStorage.getItem("token"); - if (token) { - failedRequest.headers.Authorization = `Bearer ${token}`; - return api(failedRequest); - } - - return Promise.reject(error); -}; - -api.interceptors.request.use( - async (config) => { - // 设置认证头 - config = setAuthorizationHeader(config); - - // 设置 CSRF Token - const csrfToken = csrfTokenManager.getToken(); - if (csrfToken) { - config.headers["X-CSRF-TOKEN-HEADER"] = csrfToken; - } - - return config; - }, - async (error) => { - // 检查是否是 token 相关错误 - if (error.isTokenError) { - return retryRequest(error); - } - - // 其他错误直接拒绝 - return Promise.reject(error); - }, -); - -api.interceptors.response.use( - (response) => { - const resData = response.data; - if (typeof resData === "string" && resData.includes("Version")) { - return response; - } - - if (!resData.Success) { - return handleApiError(resData, response); - } - - return response; - }, - (error) => { - return handleHttpError(error); - }, -); +import api from "./baseapi"; +export * from "./baseapi"; export default api; diff --git a/src/common/permissioncode.js b/src/common/permissioncode.js index 205e265..e0cbe85 100644 --- a/src/common/permissioncode.js +++ b/src/common/permissioncode.js @@ -69,6 +69,11 @@ export const AdministratorManagementPermissions = { UPDATE: "administratormanagement.update", DELETE: "administratormanagement.delete", ASSIGN_VIEW: "system:user:assign.view", + GET_TWO_FACTOR: "system:admin:get2fa", + GENERATE_TWO_FACTOR: "system:admin:generate2fa", + ENABLE_TWO_FACTOR: "system:admin:enable2fa", + DISABLE_TWO_FACTOR: "system:admin:disable2fa", + RECOVERY_TWO_FACTOR: "system:admin:recovery2fa", }; // 角色管理 (Role Management) @@ -132,6 +137,11 @@ export const StaffManagementPermissions = { STATUS: "staffmanagement.status", RESET: "staffmanagement.reset", ASSIGN_VIEW: "system:user:assign.view", + GET_TWO_FACTOR: "staffmanagement.get2fa", + GENERATE_TWO_FACTOR: "staffmanagement.generate2fa", + ENABLE_TWO_FACTOR: "staffmanagement.enable2fa", + DISABLE_TWO_FACTOR: "staffmanagement.disable2fa", + RECOVERY_TWO_FACTOR: "staffmanagement.recovery2fa", }; // ==================== 房间信息管理模块 ==================== diff --git a/src/i18n.js b/src/i18n.js index cb0915a..6324814 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -46,6 +46,50 @@ const messages = { batchDeleteSuccess: "Batch Delete Success", resetPassword: "Reset Password", selectYourLang: "Please choose your language", + accountType: "Account Type", + pleaseSelectAccountType: "Please select account type", + accountTypeAdmin: "Admin", + accountTypeEmployee: "Employee", + twoFactor: "2FA", + twoFactorVerification: "Two-Factor Verification", + twoFactorAuthentication: "Two-Factor Authentication", + twoFactorPrompt: + "Please enter a 6-digit authenticator code or a recovery code.", + twoFactorCode: "Verification / Recovery Code", + verify: "Verify", + enabledAt: "Enabled At", + lastVerifiedAt: "Last Verified At", + remainingRecoveryCodes: "Remaining Recovery Codes", + generateTwoFactorSetup: "Generate Setup", + twoFactorSetupSecurityTip: + "Do not share the setup key. Save it securely.", + enterSixDigitCode: "Enter 6-digit code", + enterSixDigitCodeToDisable: "Enter 6-digit code to disable", + enterTwoFactorOrRecoveryCode: "Enter 6-digit code or recovery code", + enterCodeOrRecoveryCode: "Enter 6-digit code or recovery code", + enterCodeOrRecoveryCodeToDisable: + "Enter 6-digit code or recovery code to disable", + enableTwoFactor: "Enable 2FA", + disableTwoFactor: "Disable 2FA", + invalidTwoFactorCode: "Please input a valid 6-digit verification code.", + invalidCodeOrRecoveryCode: + "Please input a valid 6-digit code or recovery code.", + twoFactorSetupGenerated: "Two-factor setup generated.", + twoFactorEnabledSuccess: "2FA enabled successfully.", + twoFactorDisabledSuccess: "2FA disabled successfully.", + regenerateRecoveryCodes: "Regenerate Recovery Codes", + recoveryCodesOneTimeHint: "Recovery codes are shown once. Save them now.", + copyAllRecoveryCodes: "Copy All Recovery Codes", + recoveryCodesCopiedSuccess: "Recovery codes copied.", + recoveryCodesCopyFailed: "Failed to copy recovery codes.", + recoveryCodesRegeneratedSuccess: + "Recovery codes regenerated successfully.", + lowRecoveryCodesWarning: + "Only {count} recovery code(s) left. Regenerate soon.", + twoFactorRecoveryCodeSetupTip: + "2FA enabled. Save your recovery codes now (shown only once).", + recoveryCodeLoginTip: + "Recovery code was used for this login. Rebind your authenticator and regenerate recovery codes immediately.", refreshData: "Refresh Data", addSuccess: "Add Success", updateSuccess: "Update Success", @@ -725,6 +769,44 @@ const messages = { batchDeleteSuccess: "批量删除成功", resetPassword: "重置密码", selectYourLang: "请选择您的语言", + accountType: "账号类型", + pleaseSelectAccountType: "请选择账号类型", + accountTypeAdmin: "管理员", + accountTypeEmployee: "员工", + twoFactor: "两步验证入口", + twoFactorVerification: "两步验证", + twoFactorAuthentication: "两步验证设置", + twoFactorPrompt: "请输入 6 位验证码或恢复备用码", + twoFactorCode: "验证码/备用码", + verify: "验证", + enabledAt: "启用时间", + lastVerifiedAt: "最近验证时间", + remainingRecoveryCodes: "剩余备用码数量", + generateTwoFactorSetup: "生成绑定信息", + twoFactorSetupSecurityTip: "请勿泄露绑定密钥,并妥善保管。", + enterSixDigitCode: "请输入 6 位验证码", + enterSixDigitCodeToDisable: "请输入 6 位验证码以关闭", + enterTwoFactorOrRecoveryCode: "请输入 6 位验证码或恢复备用码", + enterCodeOrRecoveryCode: "请输入 6 位验证码或恢复备用码", + enterCodeOrRecoveryCodeToDisable: "请输入 6 位验证码或恢复备用码以关闭", + enableTwoFactor: "启用 2FA", + disableTwoFactor: "关闭 2FA", + invalidTwoFactorCode: "请输入有效的 6 位验证码", + invalidCodeOrRecoveryCode: "请输入有效的 6 位验证码或恢复备用码", + twoFactorSetupGenerated: "已生成 2FA 绑定信息", + twoFactorEnabledSuccess: "2FA 启用成功", + twoFactorDisabledSuccess: "2FA 关闭成功", + regenerateRecoveryCodes: "重置恢复备用码", + recoveryCodesOneTimeHint: "备用码明文仅展示一次,请立即保存。", + copyAllRecoveryCodes: "复制全部备用码", + recoveryCodesCopiedSuccess: "备用码已复制", + recoveryCodesCopyFailed: "备用码复制失败,请手动复制", + recoveryCodesRegeneratedSuccess: "恢复备用码已重置", + lowRecoveryCodesWarning: "剩余备用码仅 {count} 个,建议立即重置。", + twoFactorRecoveryCodeSetupTip: + "2FA 已启用,请立即保存恢复备用码(仅展示一次)。", + recoveryCodeLoginTip: + "本次登录使用了恢复备用码,请立即重新绑定验证器并重置备用码。", refreshData: "刷新数据", addSuccess: "添加成功", updateSuccess: "更新成功", diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index 5b8ad20..a3bd4aa 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -24,6 +24,14 @@ {{ username }} + + {{ $t("message.twoFactor") }} + {{ $t("message.logout") }} @@ -72,6 +80,145 @@ + + + + + + {{ + twoFactorStatus?.IsEnabled + ? $t("message.enabled") + : $t("message.disabled") + }} + + + {{ + twoFactorStatus?.EnabledAt + ? formatDateTime(twoFactorStatus.EnabledAt) + : "-" + }} + + + {{ + twoFactorStatus?.LastVerifiedAt + ? formatDateTime(twoFactorStatus.LastVerifiedAt) + : "-" + }} + + + {{ twoFactorStatus?.RemainingRecoveryCodes ?? "-" }} + + + + + +
+ + {{ $t("message.generateTwoFactorSetup") }} + +
+ +
+ +
+ +
+ + {{ twoFactorSetupData.ManualEntryKey }} + + + + {{ $t("message.enableTwoFactor") }} + +
+ +
+ + + + {{ $t("message.regenerateRecoveryCodes") }} + + +
+ + + {{ recoveryCodesText }} + + + {{ $t("message.copyAllRecoveryCodes") }} + +
+ + + + {{ $t("message.disableTwoFactor") }} + +
+
+
+ © {{ "2024-" + new Date().getFullYear() }} {{ $t("message.org") }} {{ $t("message.systemName") }} | {{ $t("message.serverVersion") }}: @@ -93,15 +240,33 @@ import { } from "vue"; import { useStorage } from "@vueuse/core"; import { useRoute, useRouter } from "vue-router"; -import { showErrorNotification } from "@/utils/index"; +import { + formatDateTime, + showErrorNotification, + showSuccessNotification, +} from "@/utils/index"; import { fetchMenusTree } from "../api/menuapi"; import { BaseFields } from "@/entities/common.entity"; +import { + AdministratorManagementPermissions, + StaffManagementPermissions, +} from "@/common/permissioncode"; import { useI18n } from "vue-i18n"; import RecursiveMenu from "./Menu/RecursiveMenu.vue"; import { emitter } from "@/utils/eventBus"; -import { getServerVersion, signOut } from "../api/basicapi"; +import { + disableTwoFactor, + enableTwoFactor, + fetchTwoFactorStatus, + generateTwoFactorSetup, + getApiMessage, + isApiSuccess, + regenerateTwoFactorRecoveryCodes, + signOut, + getServerVersion, +} from "../api/basicapi"; const serverVersion = ref(""); @@ -212,6 +377,71 @@ const { locale, t } = useI18n(); const currentLocale = ref(locale.value); const openKeys = useStorage("menu-open-keys", []); const currentRouteKey = ref(""); +const loginType = ref(localStorage.getItem("loginType") || "admin"); +const allowedPermissionCodes = ref([]); +const twoFactorModalOpen = ref(false); +const twoFactorStatusLoading = ref(false); +const twoFactorActionLoading = ref(false); +const twoFactorStatus = ref(null); +const twoFactorSetupData = ref(null); +const twoFactorEnableCode = ref(""); +const twoFactorDisableCode = ref(""); +const twoFactorRecoveryCode = ref(""); +const latestRecoveryCodes = ref([]); +const showRecoveryCodesGuide = ref(false); +const LOW_RECOVERY_CODES_THRESHOLD = 2; +const showLowRecoveryCodesWarning = computed(() => { + if (!twoFactorStatus.value?.IsEnabled) return false; + const remaining = Number(twoFactorStatus.value?.RemainingRecoveryCodes); + return ( + Number.isFinite(remaining) && remaining <= LOW_RECOVERY_CODES_THRESHOLD + ); +}); +const recoveryCodesText = computed(() => latestRecoveryCodes.value.join("\n")); + +const TWO_FACTOR_PERMISSION_BY_LOGIN_TYPE = { + admin: [ + AdministratorManagementPermissions.GET_TWO_FACTOR, + AdministratorManagementPermissions.GENERATE_TWO_FACTOR, + AdministratorManagementPermissions.ENABLE_TWO_FACTOR, + AdministratorManagementPermissions.DISABLE_TWO_FACTOR, + AdministratorManagementPermissions.RECOVERY_TWO_FACTOR, + ], + employee: [ + StaffManagementPermissions.GET_TWO_FACTOR, + StaffManagementPermissions.GENERATE_TWO_FACTOR, + StaffManagementPermissions.ENABLE_TWO_FACTOR, + StaffManagementPermissions.DISABLE_TWO_FACTOR, + StaffManagementPermissions.RECOVERY_TWO_FACTOR, + ], +}; + +const parseStoredAllowedPermissions = () => { + try { + const rawPerms = localStorage.getItem("allowedPerms"); + const parsedPerms = rawPerms ? JSON.parse(rawPerms) : []; + return Array.isArray(parsedPerms) ? parsedPerms : []; + } catch (error) { + return []; + } +}; + +const syncAllowedPermissions = () => { + allowedPermissionCodes.value = parseStoredAllowedPermissions(); +}; + +const showTwoFactorEntry = computed(() => { + if (!isLoggedIn.value) return false; + const requiredPermissions = + TWO_FACTOR_PERMISSION_BY_LOGIN_TYPE[loginType.value] || []; + if (!Array.isArray(requiredPermissions) || requiredPermissions.length === 0) { + return false; + } + return requiredPermissions.some((code) => + allowedPermissionCodes.value.includes(code), + ); +}); + const selectedKeys = computed(() => { const normalize = (p) => (p || "").replace(/\/$/, ""); const target = normalize(route.path); @@ -284,11 +514,12 @@ const refreshMenu = async () => { const unique = (arr) => Array.from(new Set(arr.filter(Boolean))); const allowedMenuKeys = unique(keys); const allowedPaths = unique(paths).map((p) => (p || "").replace(/\/$/, "")); - const allowedPerms = unique(perms); + const allowedPermList = unique(perms); localStorage.setItem("allowedMenuKeys", JSON.stringify(allowedMenuKeys)); localStorage.setItem("allowedPaths", JSON.stringify(allowedPaths)); - localStorage.setItem("allowedPerms", JSON.stringify(allowedPerms)); + localStorage.setItem("allowedPerms", JSON.stringify(allowedPermList)); + allowedPermissionCodes.value = allowedPermList; // 3) 还原展开状态 openKeys.value = currentOpenKeys; @@ -318,10 +549,229 @@ const handleLanguageChange = (value) => { currentLocale.value = value; }; +const normalizeSixDigits = (value) => + String(value || "") + .replace(/\D/g, "") + .slice(0, 6); + +const normalizeCodeOrRecoveryCode = (value) => + String(value || "") + .trim() + .toUpperCase() + .replace(/\s/g, "") + .slice(0, 32); + +const isSixDigitCode = (value) => /^\d{6}$/.test(String(value || "")); + +const isRecoveryCode = (value) => + /^[A-Z0-9-]{6,32}$/.test(String(value || "")) && !isSixDigitCode(value); + +const isValidCodeOrRecoveryCode = (value) => + isSixDigitCode(value) || isRecoveryCode(value); + +const updateTwoFactorEnableCode = (value) => { + twoFactorEnableCode.value = normalizeSixDigits(value); +}; + +const updateTwoFactorDisableCode = (value) => { + twoFactorDisableCode.value = normalizeCodeOrRecoveryCode(value); +}; + +const updateTwoFactorRecoveryCode = (value) => { + twoFactorRecoveryCode.value = normalizeCodeOrRecoveryCode(value); +}; + +const copyTextWithExecCommand = (text) => { + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.style.position = "fixed"; + textArea.style.opacity = "0"; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + const copied = document.execCommand("copy"); + document.body.removeChild(textArea); + return copied; +}; + +const copyAllRecoveryCodes = async () => { + const content = recoveryCodesText.value; + if (!content) { + return; + } + + try { + if (navigator?.clipboard?.writeText) { + await navigator.clipboard.writeText(content); + } else if (!copyTextWithExecCommand(content)) { + throw new Error("Copy failed"); + } + showSuccessNotification(t("message.recoveryCodesCopiedSuccess")); + } catch (error) { + showErrorNotification(t("message.recoveryCodesCopyFailed")); + } +}; + +const loadTwoFactorStatus = async () => { + twoFactorStatusLoading.value = true; + try { + const response = await fetchTwoFactorStatus(loginType.value); + if (isApiSuccess(response)) { + twoFactorStatus.value = response.Data || null; + if (twoFactorStatus.value?.IsEnabled) { + twoFactorSetupData.value = null; + twoFactorEnableCode.value = ""; + } else { + twoFactorDisableCode.value = ""; + twoFactorRecoveryCode.value = ""; + latestRecoveryCodes.value = []; + showRecoveryCodesGuide.value = false; + } + } else { + showErrorNotification(getApiMessage(response)); + } + } catch (error) { + showErrorNotification(getApiMessage(error)); + } finally { + twoFactorStatusLoading.value = false; + } +}; + +const openTwoFactorModal = async () => { + if (!showTwoFactorEntry.value) { + showErrorNotification(t("message.noPermission")); + return; + } + twoFactorModalOpen.value = true; + await loadTwoFactorStatus(); +}; + +const closeTwoFactorModal = () => { + twoFactorModalOpen.value = false; + twoFactorSetupData.value = null; + twoFactorEnableCode.value = ""; + twoFactorDisableCode.value = ""; + twoFactorRecoveryCode.value = ""; + latestRecoveryCodes.value = []; + showRecoveryCodesGuide.value = false; +}; + +const handleGenerateTwoFactorSetup = async () => { + twoFactorActionLoading.value = true; + try { + const response = await generateTwoFactorSetup(loginType.value); + if (isApiSuccess(response)) { + twoFactorSetupData.value = response.Data || null; + showSuccessNotification(t("message.twoFactorSetupGenerated")); + } else { + showErrorNotification(getApiMessage(response)); + } + } catch (error) { + showErrorNotification(getApiMessage(error)); + } finally { + twoFactorActionLoading.value = false; + } +}; + +const handleEnableTwoFactor = async () => { + if (twoFactorEnableCode.value.length !== 6) { + showErrorNotification(t("message.invalidTwoFactorCode")); + return; + } + + twoFactorActionLoading.value = true; + try { + const response = await enableTwoFactor( + loginType.value, + twoFactorEnableCode.value, + ); + if (isApiSuccess(response)) { + const recoveryCodes = Array.isArray(response?.Data?.RecoveryCodes) + ? response.Data.RecoveryCodes + : []; + showSuccessNotification(t("message.twoFactorEnabledSuccess")); + twoFactorSetupData.value = null; + twoFactorEnableCode.value = ""; + twoFactorRecoveryCode.value = ""; + latestRecoveryCodes.value = recoveryCodes; + showRecoveryCodesGuide.value = recoveryCodes.length > 0; + await loadTwoFactorStatus(); + } else { + showErrorNotification(getApiMessage(response)); + } + } catch (error) { + showErrorNotification(getApiMessage(error)); + } finally { + twoFactorActionLoading.value = false; + } +}; + +const handleRegenerateRecoveryCodes = async () => { + if (!isValidCodeOrRecoveryCode(twoFactorRecoveryCode.value)) { + showErrorNotification(t("message.invalidCodeOrRecoveryCode")); + return; + } + + twoFactorActionLoading.value = true; + try { + const response = await regenerateTwoFactorRecoveryCodes( + loginType.value, + twoFactorRecoveryCode.value, + ); + if (isApiSuccess(response)) { + const recoveryCodes = Array.isArray(response?.Data?.RecoveryCodes) + ? response.Data.RecoveryCodes + : []; + latestRecoveryCodes.value = recoveryCodes; + twoFactorRecoveryCode.value = ""; + showRecoveryCodesGuide.value = recoveryCodes.length > 0; + showSuccessNotification(t("message.recoveryCodesRegeneratedSuccess")); + await loadTwoFactorStatus(); + } else { + showErrorNotification(getApiMessage(response)); + } + } catch (error) { + showErrorNotification(getApiMessage(error)); + } finally { + twoFactorActionLoading.value = false; + } +}; + +const handleDisableTwoFactor = async () => { + if (!isValidCodeOrRecoveryCode(twoFactorDisableCode.value)) { + showErrorNotification(t("message.invalidCodeOrRecoveryCode")); + return; + } + + twoFactorActionLoading.value = true; + try { + const response = await disableTwoFactor( + loginType.value, + twoFactorDisableCode.value, + ); + if (isApiSuccess(response)) { + showSuccessNotification(t("message.twoFactorDisabledSuccess")); + twoFactorDisableCode.value = ""; + twoFactorRecoveryCode.value = ""; + latestRecoveryCodes.value = []; + showRecoveryCodesGuide.value = false; + await loadTwoFactorStatus(); + } else { + showErrorNotification(getApiMessage(response)); + } + } catch (error) { + showErrorNotification(getApiMessage(error)); + } finally { + twoFactorActionLoading.value = false; + } +}; + const checkLoginStatus = () => { const storedToken = localStorage.getItem("token"); if (storedToken) { isLoggedIn.value = true; + loginType.value = localStorage.getItem("loginType") || "admin"; + syncAllowedPermissions(); const storedUsername = localStorage.getItem("username"); if (storedUsername) { username.value = storedUsername; @@ -330,24 +780,29 @@ const checkLoginStatus = () => { } } else { isLoggedIn.value = false; + loginType.value = "admin"; + allowedPermissionCodes.value = []; username.value = ""; } }; const logout = async () => { var response = await signOut(); - if (!response.Success) { + if (!isApiSuccess(response)) { showErrorNotification(response.Message || t("message.logoutFailed")); return; } localStorage.removeItem("token"); localStorage.removeItem("username"); localStorage.removeItem("account"); + localStorage.removeItem("loginType"); localStorage.removeItem("allowedPaths"); localStorage.removeItem("allowedMenuKeys"); localStorage.removeItem("allowedPerms"); isLoggedIn.value = false; + allowedPermissionCodes.value = []; username.value = ""; + closeTwoFactorModal(); router.push("/signin"); }; diff --git a/src/views/SignInView.vue b/src/views/SignInView.vue index 642a926..60e780f 100644 --- a/src/views/SignInView.vue +++ b/src/views/SignInView.vue @@ -25,6 +25,22 @@ style="width: 400px; z-index: 2; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1)" > + + + {{ + $t("message.accountTypeAdmin") + }} + {{ + $t("message.accountTypeEmployee") + }} + + + + + + + + + + + @@ -69,8 +114,18 @@ import bgImage from "@/assets/login_bg.png"; import { ref, reactive, onBeforeMount, computed } from "vue"; import { useRouter } from "vue-router"; -import { showErrorNotification, showSuccessNotification } from "@/utils/index"; -import { signIn } from "../api/basicapi"; +import { + showErrorNotification, + showInfoNotification, + showSuccessNotification, + showWarningNotification, +} from "@/utils/index"; +import { + getApiMessage, + isApiSuccess, + isTwoFactorChallenge, + signIn, +} from "../api/basicapi"; import { useI18n } from "vue-i18n"; const { t, locale } = useI18n(); @@ -87,39 +142,158 @@ const passwordLabel = computed(() => t("message.password")); const router = useRouter(); const loading = ref(false); +const twoFactorModalOpen = ref(false); +const twoFactorLoading = ref(false); +const twoFactorCode = ref(""); +const pendingLoginContext = ref(null); + const form = reactive({ + LoginType: "admin", Account: "", Password: "", }); -const onFinish = async () => { - loading.value = true; - try { - const response = await signIn(form); - if (response.Success) { - const userData = response.Data; +const normalizeTwoFactorCode = (value) => + String(value || "") + .trim() + .toUpperCase() + .replace(/\s/g, "") + .slice(0, 32); - localStorage.setItem("token", userData.UserToken); - localStorage.setItem("username", userData.Name); - localStorage.setItem("account", userData.Number); +const isSixDigitCode = (value) => /^\d{6}$/.test(String(value || "")); - router.push("/"); - showSuccessNotification(t("message.welcomeBack")); - } else { +const isRecoveryCode = (value) => + /^[A-Z0-9-]{6,32}$/.test(String(value || "")) && !isSixDigitCode(value); + +const isValidTwoFactorCode = (value) => + isSixDigitCode(value) || isRecoveryCode(value); + +const updateTwoFactorCode = (value) => { + twoFactorCode.value = normalizeTwoFactorCode(value); +}; + +const buildLoginPayload = (loginType, twoFactorValue = "") => { + const account = form.Account.trim(); + const password = form.Password; + + if (loginType === "employee") { + const isEmailAddress = account.includes("@"); + return { + EmployeeId: isEmailAddress ? "" : account, + EmailAddress: isEmailAddress ? account : "", + Password: password, + TwoFactorCode: twoFactorValue, + }; + } + + return { + Account: account, + Password: password, + TwoFactorCode: twoFactorValue, + }; +}; + +const clearTwoFactorChallenge = () => { + twoFactorModalOpen.value = false; + twoFactorCode.value = ""; + pendingLoginContext.value = null; +}; + +const persistLoginData = async (userData, loginType) => { + localStorage.setItem("token", userData.UserToken); + localStorage.setItem( + "username", + userData.Name || userData.UserName || userData.Username || form.Account, + ); + localStorage.setItem( + "account", + userData.Number || userData.Account || userData.EmployeeId || form.Account, + ); + localStorage.setItem("loginType", loginType); + + clearTwoFactorChallenge(); + await router.push("/"); + showSuccessNotification(t("message.welcomeBack")); +}; + +const handleLoginResponse = async (result, context, fromTwoFactor = false) => { + if (isApiSuccess(result) && result?.Data?.UserToken) { + const usedRecoveryCodeLogin = result?.Data?.UsedRecoveryCodeLogin === true; + await persistLoginData(result.Data, context.loginType); + if (usedRecoveryCodeLogin) { + showWarningNotification(t("message.recoveryCodeLoginTip")); + } + return true; + } + + if (isTwoFactorChallenge(result)) { + pendingLoginContext.value = context; + twoFactorModalOpen.value = true; + if (fromTwoFactor) { showErrorNotification( - response.message || t("message.checkUsernameAndPassword"), + getApiMessage(result) || t("message.invalidCodeOrRecoveryCode"), ); + } else { + showInfoNotification(t("message.twoFactorPrompt")); } + return false; + } + + showErrorNotification( + getApiMessage(result) || t("message.checkUsernameAndPassword"), + ); + return false; +}; + +const onFinish = async () => { + loading.value = true; + const context = { + loginType: form.LoginType, + payload: buildLoginPayload(form.LoginType), + }; + + try { + const response = await signIn(context.payload, context.loginType); + await handleLoginResponse(response, context, false); } catch (error) { - showErrorNotification( - error.response?.data?.message || t("message.pleaseTryAgainLater"), - ); + await handleLoginResponse(error, context, false); } finally { loading.value = false; } }; -const onFinishFailed = (errorInfo) => {}; +const submitTwoFactorCode = async () => { + if (!pendingLoginContext.value) { + twoFactorModalOpen.value = false; + return; + } + + const verificationCode = normalizeTwoFactorCode(twoFactorCode.value); + if (!isValidTwoFactorCode(verificationCode)) { + showErrorNotification(t("message.invalidCodeOrRecoveryCode")); + return; + } + + twoFactorLoading.value = true; + try { + const { loginType, payload } = pendingLoginContext.value; + const response = await signIn( + { ...payload, TwoFactorCode: verificationCode }, + loginType, + ); + await handleLoginResponse(response, pendingLoginContext.value, true); + } catch (error) { + await handleLoginResponse(error, pendingLoginContext.value, true); + } finally { + twoFactorLoading.value = false; + } +}; + +const cancelTwoFactorChallenge = () => { + clearTwoFactorChallenge(); +}; + +const onFinishFailed = () => {}; onBeforeMount(() => { currentLocale.value = locale.value; diff --git a/vite.config.js b/vite.config.js index 1208d8d..66dfa60 100644 --- a/vite.config.js +++ b/vite.config.js @@ -113,4 +113,4 @@ export default defineConfig(({ mode }) => { }, }, }; -}); \ No newline at end of file +}); -- Gitee From 9a99b6cbe1ed78cd8fb15f3016bf61df5c37b800 Mon Sep 17 00:00:00 2001 From: ck_yeun9 Date: Mon, 16 Feb 2026 16:16:05 +0800 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=A1=8C?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E6=8E=A7=E5=88=B6=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=9B=B8=E5=85=B3=E8=A7=86=E5=9B=BE=E4=BB=A5?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E8=A1=8C=E7=89=88=E6=9C=AC=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + src/api/baseapi.js | 196 +++++++++++++++++- src/common/errorHandler.js | 8 +- src/i18n.js | 3 + src/views/base/DepartmentView.vue | 1 + src/views/base/NationView.vue | 1 + src/views/base/NoticeTypeView.vue | 6 +- src/views/base/PassportView.vue | 3 +- src/views/base/PositionView.vue | 1 + src/views/base/PromotionContentView.vue | 4 +- src/views/base/QualificationView.vue | 3 +- .../customermanagement/CustomerSpendView.vue | 3 +- .../customermanagement/CustomerTypeView.vue | 3 +- src/views/customermanagement/CustomerView.vue | 3 +- src/views/customermanagement/VipLevelView.vue | 3 +- src/views/finance/InternalFinanceView.vue | 4 +- .../StaffManagementView.vue | 3 +- .../HydroelectricityInfoView.vue | 4 +- .../GoodsmanagementView.vue | 3 +- .../roominformation/ReserManagementView.vue | 3 +- src/views/roominformation/RoomConfigView.vue | 3 +- .../roominformation/RoomManagementView.vue | 3 +- src/views/roominformation/RoomMapView.vue | 3 +- src/views/supervision/SupervisionView.vue | 6 +- .../AdminTypeManagementView.vue | 4 +- .../AdministratorManagementView.vue | 3 +- .../systemmanagement/MenuManagementView.vue | 3 +- .../systemmanagement/RoleManagementView.vue | 3 +- 28 files changed, 251 insertions(+), 33 deletions(-) diff --git a/.gitignore b/.gitignore index 594e684..ba296ca 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ node_modules dist .vs +docs/* diff --git a/src/api/baseapi.js b/src/api/baseapi.js index a503590..64019dd 100644 --- a/src/api/baseapi.js +++ b/src/api/baseapi.js @@ -2,6 +2,8 @@ import axios from "axios"; import { csrfTokenManager } from "@/utils/csrf"; import { handleApiError, handleHttpError } from "@/common/errorHandler"; +const rowVersionCache = new Map(); + const api = axios.create({ baseURL: import.meta.env.VITE_API_URL, timeout: 30000, @@ -24,7 +26,188 @@ export const isApiSuccess = (payload) => { export const getApiMessage = (payload) => payload?.Message || payload?.message || "Request failed"; -// 创建一个函数来设置认证头 +const getEntityKeyFromUrl = (url = "") => { + if (typeof url !== "string" || url.length === 0) { + return ""; + } + + const normalizedUrl = url.split("?")[0].replace(/^https?:\/\/[^/]+/i, ""); + const segments = normalizedUrl.split("/").filter(Boolean); + const controller = segments[0]?.toLowerCase() || ""; + const action = segments[1] || ""; + + if (!controller) { + return ""; + } + if (!action) { + return controller; + } + + let resource = action + .replace( + /^(Select|GetAll|Get|Build|Read|Insert|Add|Create|Update|Upd|Delete|Del)/i, + "", + ) + .replace(/(AllCanUse|CanUseAll|All|List)$/i, "") + .replace(/InfoBy[A-Za-z0-9]+$/i, "") + .replace(/Info$/i, "") + .replace(/Types$/i, "Type"); + + if (/[^s]s$/i.test(resource)) { + resource = resource.slice(0, -1); + } + + resource = resource.toLowerCase(); + return `${controller}:${resource || controller}`; +}; + +const getRecordId = (record) => { + if (!record || typeof record !== "object") { + return null; + } + return record.Id ?? record.id ?? null; +}; + +const getRecordRowVersion = (record) => { + if (!record || typeof record !== "object") { + return null; + } + return ( + record.RowVersion ?? + record.rowVersion ?? + record.rowversion ?? + record.row_version ?? + null + ); +}; + +const isEffectiveRowVersion = (value) => { + if (value === undefined || value === null) { + return false; + } + + if (typeof value === "number") { + return Number.isFinite(value) && value > 0; + } + + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + const asNumber = Number(trimmed); + if (!Number.isNaN(asNumber)) { + return asNumber > 0; + } + return true; + } + + return true; +}; + +const hasEffectiveRowVersion = (record) => { + if (!record || typeof record !== "object") { + return false; + } + + return ( + isEffectiveRowVersion(record.RowVersion) || + isEffectiveRowVersion(record.rowVersion) || + isEffectiveRowVersion(record.rowversion) || + isEffectiveRowVersion(record.row_version) + ); +}; + +const saveRowVersionToCache = (entityKey, record) => { + if (!entityKey || !record || typeof record !== "object") { + return; + } + + const id = getRecordId(record); + const rowVersion = getRecordRowVersion(record); + if (id === null || rowVersion === null) { + return; + } + + if (!rowVersionCache.has(entityKey)) { + rowVersionCache.set(entityKey, new Map()); + } + + rowVersionCache.get(entityKey).set(String(id), rowVersion); +}; + +const saveRowVersionsFromResponse = (response) => { + const entityKey = getEntityKeyFromUrl(response?.config?.url); + if (!entityKey) { + return; + } + + const payload = response?.data; + const data = payload?.Data ?? payload?.data; + if (!data) { + return; + } + + const items = data?.Items ?? data?.items; + if (Array.isArray(items)) { + items.forEach((item) => saveRowVersionToCache(entityKey, item)); + return; + } + + if (Array.isArray(data)) { + data.forEach((item) => saveRowVersionToCache(entityKey, item)); + return; + } + + if (typeof data === "object") { + saveRowVersionToCache(entityKey, data); + } +}; + +const shouldAttachRowVersion = (config) => { + const method = (config?.method || "get").toLowerCase(); + if (!["post", "put", "patch"].includes(method)) { + return false; + } + + const url = config?.url || ""; + return /\/[^/]+\/(?:Update|Upd)[^/?#]*/i.test(url); +}; + +const attachRowVersionFromCache = (config) => { + if (!shouldAttachRowVersion(config)) { + return config; + } + + const data = config?.data; + if (!data || typeof data !== "object" || Array.isArray(data)) { + return config; + } + + if (data instanceof FormData || hasEffectiveRowVersion(data)) { + return config; + } + + const id = getRecordId(data); + if (id === null) { + return config; + } + + const entityKey = getEntityKeyFromUrl(config.url); + if (!entityKey) { + return config; + } + + const entityCache = rowVersionCache.get(entityKey); + const rowVersion = entityCache?.get(String(id)); + if (rowVersion === undefined || rowVersion === null) { + return config; + } + + data.RowVersion = rowVersion; + return config; +}; + const setAuthorizationHeader = (config) => { const token = localStorage.getItem("token"); if (token) { @@ -33,19 +216,15 @@ const setAuthorizationHeader = (config) => { return config; }; -// 创建一个重试机制 const retryRequest = async (error) => { const failedRequest = error.config; - // 如果已经重试过,则不再重试 if (failedRequest._retry) { return Promise.reject(error); } - // 标记这个请求已经重试过 failedRequest._retry = true; - // 重新从 localStorage 获取 token const token = localStorage.getItem("token"); if (token) { failedRequest.headers.Authorization = `Bearer ${token}`; @@ -57,10 +236,9 @@ const retryRequest = async (error) => { api.interceptors.request.use( async (config) => { - // 设置认证头 config = setAuthorizationHeader(config); + config = attachRowVersionFromCache(config); - // 设置 CSRF Token const csrfToken = csrfTokenManager.getToken(); if (csrfToken) { config.headers["X-CSRF-TOKEN-HEADER"] = csrfToken; @@ -69,18 +247,18 @@ api.interceptors.request.use( return config; }, async (error) => { - // 检查是否是 token 相关错误 if (error.isTokenError) { return retryRequest(error); } - // 其他错误直接拒绝 return Promise.reject(error); }, ); api.interceptors.response.use( (response) => { + saveRowVersionsFromResponse(response); + if (response?.config?.skipBusinessErrorHandling) { return response; } diff --git a/src/common/errorHandler.js b/src/common/errorHandler.js index 8b43ae2..7310478 100644 --- a/src/common/errorHandler.js +++ b/src/common/errorHandler.js @@ -29,6 +29,12 @@ export const errorCodeMap = { message: i18n.global.t("message.notFound") || "未找到相关资源", action: "notify", }, + 1409: { + message: + i18n.global.t("message.concurrencyConflict") || + "数据已被其他用户修改,请刷新后重试", + action: "notify", + }, 2001: { // 参数/校验错误 message: @@ -74,7 +80,7 @@ export function handleApiError(resData, response) { const token = localStorage.getItem("token"); if (!token) { // 没有 token,直接跳转登录并显示后端消息(如果有) - redirectToLogin(backendMessage || i18n.global.t("message.loginExpired") ); + redirectToLogin(backendMessage || i18n.global.t("message.loginExpired")); return Promise.reject({ success: false, code: 1401, diff --git a/src/i18n.js b/src/i18n.js index 6324814..d32a238 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -128,6 +128,8 @@ const messages = { duplicate: "Duplicate Data", serverError: "Server Error", networkError: "Network Error", + concurrencyConflict: + "Data was modified by another user. Please refresh and retry.", requestFailed: "Request Failed", loginExpired: "Login expired, please log in again", unexpectedError: "An unexpected error occurred", @@ -845,6 +847,7 @@ const messages = { duplicate: "重复数据", serverError: "服务器错误", networkError: "网络错误", + concurrencyConflict: "数据已被其他用户修改,请刷新后重试", requestFailed: "请求失败", loginExpired: "登录已过期,请重新登录", unexpectedError: "发生了意外错误", diff --git a/src/views/base/DepartmentView.vue b/src/views/base/DepartmentView.vue index 6748cdb..ec09791 100644 --- a/src/views/base/DepartmentView.vue +++ b/src/views/base/DepartmentView.vue @@ -487,6 +487,7 @@ const fetchDepartmentData = async () => { if (result && Array.isArray(result.Items)) { departments.value = result.Items.map((item) => ({ [DepartmentFields.ID]: item[DepartmentFields.ID], + [DepartmentFields.ROWVERSION]: item[DepartmentFields.ROWVERSION], [DepartmentFields.NUMBER]: item[DepartmentFields.NUMBER], [DepartmentFields.NAME]: item[DepartmentFields.NAME], [DepartmentFields.DESCRIPTION]: item[DepartmentFields.DESCRIPTION], diff --git a/src/views/base/NationView.vue b/src/views/base/NationView.vue index d20089a..db7009e 100644 --- a/src/views/base/NationView.vue +++ b/src/views/base/NationView.vue @@ -372,6 +372,7 @@ const fetchNationData = async () => { if (result?.Items) { nations.value = result.Items.map((item) => ({ [NationFields.ID]: item[NationFields.ID], + [NationFields.ROWVERSION]: item[NationFields.ROWVERSION], [NationFields.NUMBER]: item[NationFields.NUMBER], [NationFields.NAME]: item[NationFields.NAME], [NationFields.IS_DELETED]: item[NationFields.IS_DELETED], diff --git a/src/views/base/NoticeTypeView.vue b/src/views/base/NoticeTypeView.vue index c32b149..28bef0a 100644 --- a/src/views/base/NoticeTypeView.vue +++ b/src/views/base/NoticeTypeView.vue @@ -1,4 +1,4 @@ -