diff --git a/.gitignore b/.gitignore index 594e684ec4ba37215d6510ae2339e102c2282945..41488fcc3c7fd9cd8143ca5f55f233339ee353ce 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ node_modules dist .vs +docs/* +.vscode diff --git a/components.d.ts b/components.d.ts index 93fddf8c107e9d0be7d6feec12478b24c252bd54..5135cc66cba7d945284cbe20cf7fcd21764d990e 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/administratorapi.js b/src/api/administratorapi.js index c64ebd5cfbbab18c1af9e84631b4db6617c69ce2..9e802a652a2b3ce9321f9df5f4e78f1edf7fa944 100644 --- a/src/api/administratorapi.js +++ b/src/api/administratorapi.js @@ -1,9 +1,25 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 获取管理员列表 +const buildUserNumberPayload = (userNumber) => { + if (userNumber && typeof userNumber === "object") { + return { + UserNumber: userNumber.UserNumber ?? userNumber.userNumber ?? "", + }; + } + + return { UserNumber: userNumber }; +}; + export const fetchAdmins = async (params) => { try { - const response = await api.get("/Admin/GetAllAdminList", { params }); + const response = await api.get( + API_ENDPOINTS.routes.ADMIN_GET_ALL_ADMIN_LIST, + { + params, + }, + ); return response.data.Data; } catch (error) { throw error; @@ -13,7 +29,7 @@ export const fetchAdmins = async (params) => { // 添加管理员 export const addAdmin = async (data) => { try { - const response = await api.post("/Admin/AddAdmin", data); + const response = await api.post(API_ENDPOINTS.routes.ADMIN_ADD_ADMIN, data); return response.data; } catch (error) { throw error; @@ -23,7 +39,7 @@ export const addAdmin = async (data) => { // 更新管理员 export const updateAdmin = async (data) => { try { - const response = await api.post(`/Admin/UpdAdmin`, data); + const response = await api.post(API_ENDPOINTS.routes.ADMIN_UPD_ADMIN, data); return response.data; } catch (error) { throw error; @@ -33,7 +49,7 @@ export const updateAdmin = async (data) => { // 删除管理员 export const deleteAdmin = async (data) => { try { - const response = await api.post(`/Admin/DelAdmin`, data); + const response = await api.post(API_ENDPOINTS.routes.ADMIN_DEL_ADMIN, data); return response.data; } catch (error) { throw error; @@ -43,7 +59,10 @@ export const deleteAdmin = async (data) => { // 为用户分配角色(全量覆盖) export const assignUserRoles = async (data) => { try { - const response = await api.post("/Admin/AssignUserRoles", data); + const response = await api.post( + API_ENDPOINTS.routes.ADMIN_ASSIGN_USER_ROLES, + data, + ); return response.data; } catch (error) { throw error; @@ -53,9 +72,10 @@ export const assignUserRoles = async (data) => { // 读取指定用户已分配角色编码集合 export const readUserRoles = async (userNumber) => { try { - const response = await api.get("/Admin/ReadUserRoles", { - params: { userNumber }, - }); + const response = await api.post( + API_ENDPOINTS.routes.ADMIN_READ_USER_ROLES, + buildUserNumberPayload(userNumber), + ); return response.data?.Data?.Items || []; } catch (error) { throw error; @@ -65,9 +85,10 @@ export const readUserRoles = async (userNumber) => { // 读取指定用户的角色-权限明细(来自 RolePermission + Permission) export const readUserRolePermissions = async (userNumber) => { try { - const response = await api.get("/Admin/ReadUserRolePermissions", { - params: { userNumber }, - }); + const response = await api.post( + API_ENDPOINTS.routes.ADMIN_READ_USER_ROLE_PERMISSIONS, + buildUserNumberPayload(userNumber), + ); return response.data?.Data?.Items || []; } catch (error) { throw error; @@ -77,7 +98,10 @@ export const readUserRolePermissions = async (userNumber) => { // 为用户分配“直接权限”(全量覆盖) export const assignUserPermissions = async (data) => { try { - const response = await api.post("/Admin/AssignUserPermissions", data); + const response = await api.post( + API_ENDPOINTS.routes.ADMIN_ASSIGN_USER_PERMISSIONS, + data, + ); return response.data; } catch (error) { throw error; @@ -87,9 +111,10 @@ export const assignUserPermissions = async (data) => { // 读取指定用户的“直接权限”权限编码集合 export const readUserDirectPermissions = async (userNumber) => { try { - const response = await api.get("/Admin/ReadUserDirectPermissions", { - params: { userNumber }, - }); + const response = await api.post( + API_ENDPOINTS.routes.ADMIN_READ_USER_DIRECT_PERMISSIONS, + buildUserNumberPayload(userNumber), + ); return response.data?.Data?.Items || []; } catch (error) { throw error; diff --git a/src/api/administratortypeapi.js b/src/api/administratortypeapi.js index ab9bec9969359fa5fba8a960c86569765b99d239..1edd1627d16447668721b249b6f77b61bb0f0a27 100644 --- a/src/api/administratortypeapi.js +++ b/src/api/administratortypeapi.js @@ -1,9 +1,13 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 获取管理员类型列表 export const fetchAdminTypes = async (params) => { try { - const response = await api.get("/Admin/GetAllAdminTypes", { params }); + const response = await api.get( + API_ENDPOINTS.routes.ADMIN_GET_ALL_ADMIN_TYPES, + { params }, + ); return response.data.Data; } catch (error) { throw error; @@ -13,7 +17,10 @@ export const fetchAdminTypes = async (params) => { // 添加管理员类型 export const addAdminType = async (data) => { try { - const response = await api.post("/Admin/AddAdminType", data); + const response = await api.post( + API_ENDPOINTS.routes.ADMIN_ADD_ADMIN_TYPE, + data, + ); return response.data; } catch (error) { throw error; @@ -23,7 +30,10 @@ export const addAdminType = async (data) => { // 更新管理员类型 export const updateAdminType = async (data) => { try { - const response = await api.post(`/Admin/UpdAdminType`, data); + const response = await api.post( + API_ENDPOINTS.routes.ADMIN_UPD_ADMIN_TYPE, + data, + ); return response.data; } catch (error) { throw error; @@ -33,7 +43,10 @@ export const updateAdminType = async (data) => { // 删除管理员类型 export const deleteAdminType = async (data) => { try { - const response = await api.post(`/Admin/DelAdminType`, data); + const response = await api.post( + API_ENDPOINTS.routes.ADMIN_DEL_ADMIN_TYPE, + data, + ); return response.data; } catch (error) { throw error; diff --git a/src/api/baseapi.js b/src/api/baseapi.js new file mode 100644 index 0000000000000000000000000000000000000000..8744511a166bbb542c09530aae00bd2e601c66a1 --- /dev/null +++ b/src/api/baseapi.js @@ -0,0 +1,901 @@ +import axios from "axios"; +import { csrfTokenManager } from "@/utils/csrf"; +import { handleApiError, handleHttpError } from "@/common/errorHandler"; +import { ERROR_CODES } from "@/common/errorCodes"; +import { getStoredToken } from "@/utils/tokenStorage"; +import i18n from "@/i18n"; + +const ACTION_PREFIX_REGEX = + /^(Select|GetAll|Get|Build|Read|Insert|Add|Create|Update|Upd|Delete|Del)/i; +const SUFFIX_CLEANUP_REGEX = /(AllCanUse|CanUseAll|All|List)$/i; +const ROW_VERSION_CACHE_TTL_MS = 10 * 60 * 1000; +const ROW_VERSION_CACHE_MAX_ENTITIES = 200; +let ROW_VERSION_CONFLICT_MESSAGE; +let ROW_VERSION_CONFLICT_HINT; + +const IDEMPOTENCY_KEY_HEADER = "Idempotency-Key"; +const CSRF_TOKEN_HEADER = "X-CSRF-TOKEN-HEADER"; +const NON_IDEMPOTENT_METHODS = new Set(["post", "put", "patch"]); +const IDEMPOTENT_METHODS = new Set(["get", "head", "options", "delete"]); +const MAX_HEADER_VALUE_LENGTH = 512; +const TOKEN_VALIDATION_MAX_RETRY_ATTEMPTS = 1; +const TOKEN_VALIDATION_RETRY_BASE_DELAY_MS = 300; +const TOKEN_VALIDATION_RETRY_MAX_JITTER_MS = 100; +const DEFAULT_TRUSTED_API_ORIGINS = Object.freeze([ + "https://tshotel.oscode.top", + "http://localhost:63001", +]); + +// entityKey -> { rows: Map, touchedAt: number } +const rowVersionCache = new Map(); + +const normalizeOrigin = (urlLike) => { + const trimmedUrl = String(urlLike || "").trim(); + if (!trimmedUrl) { + return ""; + } + + try { + const parsedUrl = new URL(trimmedUrl); + if (!["http:", "https:"].includes(parsedUrl.protocol)) { + return ""; + } + return parsedUrl.origin; + } catch { + return ""; + } +}; + +const TRUSTED_API_ORIGINS = (() => { + const configuredTrustedOrigins = String( + import.meta.env.VITE_TRUSTED_API_ORIGINS || "", + ) + .split(",") + .map((origin) => normalizeOrigin(origin)) + .filter(Boolean); + + const defaultTrustedOrigins = DEFAULT_TRUSTED_API_ORIGINS.map((origin) => + normalizeOrigin(origin), + ).filter(Boolean); + + return new Set([...defaultTrustedOrigins, ...configuredTrustedOrigins]); +})(); + +const resolveTrustedApiBaseUrl = () => { + const configuredApiBaseUrl = String( + import.meta.env.VITE_API_URL || "", + ).trim(); + if (!configuredApiBaseUrl) { + return ""; + } + + try { + const fallbackOrigin = + typeof window !== "undefined" && window.location?.origin + ? window.location.origin + : "http://localhost"; + const parsedUrl = new URL(configuredApiBaseUrl, fallbackOrigin); + if (!["http:", "https:"].includes(parsedUrl.protocol)) { + return ""; + } + if (parsedUrl.username || parsedUrl.password) { + return ""; + } + if (!TRUSTED_API_ORIGINS.has(parsedUrl.origin)) { + return ""; + } + + return parsedUrl.toString().replace(/\/+$/, ""); + } catch { + return ""; + } +}; + +const TRUSTED_API_BASE_URL = resolveTrustedApiBaseUrl(); +const TRUSTED_API_ORIGIN = normalizeOrigin(TRUSTED_API_BASE_URL); + +const getNormalizedApiPath = (url = "") => { + if (typeof url !== "string") { + return ""; + } + + const trimmedUrl = url.trim(); + if (!trimmedUrl || !TRUSTED_API_ORIGIN) { + return ""; + } + + try { + const parsedUrl = new URL(trimmedUrl, TRUSTED_API_ORIGIN); + if (parsedUrl.origin !== TRUSTED_API_ORIGIN) { + return ""; + } + return parsedUrl.pathname || ""; + } catch { + return ""; + } +}; + +const api = axios.create({ + baseURL: TRUSTED_API_BASE_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"; + +export const clearRowVersionCache = (entityKey) => { + if (entityKey) { + rowVersionCache.delete(entityKey); + return; + } + + rowVersionCache.clear(); +}; + +const touchRowVersionCacheEntity = (entityKey) => { + if (!entityKey) { + return; + } + + const existingEntry = rowVersionCache.get(entityKey); + if (!existingEntry) { + return; + } + + existingEntry.touchedAt = Date.now(); + // Move to tail so Map order reflects LRU (tail = most recently used). + rowVersionCache.delete(entityKey); + rowVersionCache.set(entityKey, existingEntry); +}; + +const pruneRowVersionCache = () => { + const now = Date.now(); + + for (const [entityKey, cacheEntry] of rowVersionCache.entries()) { + const lastTouchedAt = Number(cacheEntry?.touchedAt || 0); + if (!lastTouchedAt || now - lastTouchedAt > ROW_VERSION_CACHE_TTL_MS) { + clearRowVersionCache(entityKey); + } + } + + // Evict from head while overflow exists (head = least recently used). + while (rowVersionCache.size > ROW_VERSION_CACHE_MAX_ENTITIES) { + const lruEntityKey = rowVersionCache.keys().next().value; + if (!lruEntityKey) { + break; + } + clearRowVersionCache(lruEntityKey); + } +}; + +const getEntityKeyFromUrl = (url = "") => { + const normalizedPath = getNormalizedApiPath(url); + if (!normalizedPath) { + return ""; + } + + const segments = normalizedPath.split("/").filter(Boolean); + const controller = segments[0]?.toLowerCase() || ""; + const action = (segments[1] || "").trim(); + + if (!controller) { + return ""; + } + if (!action) { + return controller; + } + + let resource = action + .replace(ACTION_PREFIX_REGEX, "") + .replace(SUFFIX_CLEANUP_REGEX, "") + .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 + .trim() + .replace(/^[^a-z0-9]+|[^a-z0-9]+$/gi, "") + .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 getDeleteItemsFromPayload = (payload) => { + if (!payload || typeof payload !== "object") { + return []; + } + + const candidateList = [payload.DelIds, payload.delIds]; + + for (const candidate of candidateList) { + if (!Array.isArray(candidate)) { + continue; + } + + return candidate + .map((item) => { + if (!item || typeof item !== "object" || Array.isArray(item)) { + if ( + item === undefined || + item === null || + String(item).trim() === "" + ) { + return null; + } + return { Id: item, RowVersion: null }; + } + + const itemId = getRecordId(item); + if (itemId === null) { + return null; + } + + return { + Id: itemId, + RowVersion: getRecordRowVersion(item), + }; + }) + .filter(Boolean); + } + + const scalarCandidateList = [payload.DelId, payload.delId]; + for (const candidate of scalarCandidateList) { + if ( + candidate !== undefined && + candidate !== null && + String(candidate).trim() !== "" + ) { + return [{ Id: candidate, RowVersion: null }]; + } + } + + return []; +}; + +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 getCachedRowVersion = (entityKey, id) => { + if (!entityKey || id === null || id === undefined) { + return null; + } + + const cacheEntry = rowVersionCache.get(entityKey); + const rowVersion = cacheEntry?.rows?.get(String(id)); + if (rowVersion === undefined || rowVersion === null) { + return null; + } + + touchRowVersionCacheEntity(entityKey); + return rowVersion; +}; + +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, { + rows: new Map(), + touchedAt: Date.now(), + }); + } + + rowVersionCache.get(entityKey).rows.set(String(id), rowVersion); + touchRowVersionCacheEntity(entityKey); +}; + +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)); + pruneRowVersionCache(); + return; + } + + if (Array.isArray(data)) { + data.forEach((item) => saveRowVersionToCache(entityKey, item)); + pruneRowVersionCache(); + return; + } + + if (typeof data === "object") { + saveRowVersionToCache(entityKey, data); + pruneRowVersionCache(); + } +}; + +const shouldAttachRowVersion = (config) => { + const method = (config?.method || "get").toLowerCase(); + if (!["post", "put", "patch"].includes(method)) { + return false; + } + + return true; +}; + +const shouldClearRowVersionCacheAfterMutation = (config) => { + const method = (config?.method || "get").toLowerCase(); + if (["put", "patch", "delete"].includes(method)) { + return true; + } + + if (method !== "post") { + return false; + } + + const normalizedPath = getNormalizedApiPath(config?.url); + if (!normalizedPath) { + return false; + } + + const segments = normalizedPath.split("/").filter(Boolean); + const action = (segments[1] || "").trim(); + + return /^(Insert|Add|Create|Update|Upd|Delete|Del)/i.test(action); +}; + +const clearRowVersionCacheAfterMutation = (response) => { + if (!shouldClearRowVersionCacheAfterMutation(response?.config)) { + return; + } + + const entityKey = getEntityKeyFromUrl(response?.config?.url); + if (!entityKey) { + return; + } + + clearRowVersionCache(entityKey); +}; + +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) { + return config; + } + + const entityKey = getEntityKeyFromUrl(config.url); + if (!entityKey) { + return config; + } + + const deleteItems = getDeleteItemsFromPayload(data); + if (deleteItems.length > 0) { + const unresolvedIds = []; + const resolvedDeleteItems = deleteItems + .map((item) => { + let rowVersion = item.RowVersion; + if (!isEffectiveRowVersion(rowVersion)) { + rowVersion = getCachedRowVersion(entityKey, item.Id); + } + + if (!isEffectiveRowVersion(rowVersion)) { + unresolvedIds.push(item.Id); + return null; + } + + return { + Id: item.Id, + RowVersion: rowVersion, + }; + }) + .filter(Boolean); + + if (unresolvedIds.length > 0) { + const conflictError = new Error( + ROW_VERSION_CONFLICT_HINT || ROW_VERSION_CONFLICT_MESSAGE, + ); + conflictError.code = ERROR_CODES.CONCURRENCY_CONFLICT; + conflictError.data = { unresolvedIds }; + conflictError.isBusinessError = true; + throw conflictError; + } + + data.DelIds = resolvedDeleteItems; + delete data.DelId; + delete data.delId; + delete data.DelRows; + delete data.RowVersions; + touchRowVersionCacheEntity(entityKey); + return config; + } + + const id = getRecordId(data); + if (id === null) { + return config; + } + if (hasEffectiveRowVersion(data)) { + return config; + } + + const rowVersion = getCachedRowVersion(entityKey, id); + if (rowVersion === null) { + return config; + } + + touchRowVersionCacheEntity(entityKey); + data.RowVersion = rowVersion; + return config; +}; + +const getNormalizedMethod = (method) => + String(method || "get") + .trim() + .toLowerCase(); + +const isNonIdempotentRequest = (config) => + NON_IDEMPOTENT_METHODS.has(getNormalizedMethod(config?.method)); + +const cloneRequestData = (data) => { + if (data === null || data === undefined) { + return data; + } + + if (typeof FormData !== "undefined" && data instanceof FormData) { + const cloned = new FormData(); + data.forEach((value, key) => { + cloned.append(key, value); + }); + return cloned; + } + + if ( + typeof URLSearchParams !== "undefined" && + data instanceof URLSearchParams + ) { + return new URLSearchParams(data.toString()); + } + + if (typeof structuredClone === "function") { + try { + return structuredClone(data); + } catch { + // Fallback below. + } + } + + if (typeof data === "object") { + try { + return JSON.parse(JSON.stringify(data)); + } catch { + // Keep original when cloning is not possible. + } + } + + return data; +}; + +const cacheRawRequestData = (config) => { + if ( + !config || + Object.prototype.hasOwnProperty.call(config, "__rawRequestData") + ) { + return config; + } + + config.__rawRequestData = cloneRequestData(config.data); + return config; +}; + +const restoreRawRequestData = (config) => { + if ( + !config || + !Object.prototype.hasOwnProperty.call(config, "__rawRequestData") + ) { + return config; + } + + config.data = cloneRequestData(config.__rawRequestData); + return config; +}; + +const ensureHeaders = (config) => { + config.headers ||= {}; + return config.headers; +}; + +const sanitizeHeaderValue = (headerValue) => { + const valueType = typeof headerValue; + if ( + valueType !== "string" && + valueType !== "number" && + valueType !== "boolean" && + valueType !== "bigint" + ) { + return ""; + } + + return String(headerValue) + .replace(/[\r\n\0]/g, "") + .slice(0, MAX_HEADER_VALUE_LENGTH); +}; + +const setHeaderValue = (config, headerName, headerValue) => { + const headers = ensureHeaders(config); + const sanitizedHeaderValue = sanitizeHeaderValue(headerValue); + if (typeof headers.set === "function") { + headers.set(headerName, sanitizedHeaderValue); + return; + } + + headers[headerName] = sanitizedHeaderValue; +}; + +const getHeaderValue = (config, headerName) => { + const headers = config?.headers; + if (!headers) { + return ""; + } + + if (typeof headers.get === "function") { + return ( + headers.get(headerName) || headers.get(headerName.toLowerCase()) || "" + ); + } + + const direct = + headers[headerName] || + headers[headerName.toLowerCase()] || + headers[headerName.toUpperCase()]; + if (direct) { + return direct; + } + + const matchedKey = Object.keys(headers).find( + (key) => key.toLowerCase() === headerName.toLowerCase(), + ); + return matchedKey ? headers[matchedKey] : ""; +}; + +const generateIdempotencyKey = () => { + if ( + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function" + ) { + return crypto.randomUUID(); + } + + return `idem-${Date.now()}-${Math.random().toString(16).slice(2)}`; +}; + +const attachIdempotencyKey = (config) => { + if (!isNonIdempotentRequest(config)) { + return config; + } + + const existingKey = String( + config.__idempotencyKey || + getHeaderValue(config, IDEMPOTENCY_KEY_HEADER) || + "", + ).trim(); + const idempotencyKey = existingKey || generateIdempotencyKey(); + + config.__idempotencyKey = idempotencyKey; + setHeaderValue(config, IDEMPOTENCY_KEY_HEADER, idempotencyKey); + return config; +}; + +const canRetryRequest = (config) => { + const method = getNormalizedMethod(config?.method); + + if (IDEMPOTENT_METHODS.has(method)) { + return true; + } + if (!NON_IDEMPOTENT_METHODS.has(method)) { + return false; + } + + const idempotencyKey = String( + config?.__idempotencyKey || + getHeaderValue(config, IDEMPOTENCY_KEY_HEADER) || + "", + ).trim(); + + // Non-idempotent requests are retried only when a stable idempotency key exists. + return idempotencyKey.length > 0; +}; + +const refreshRetrySensitiveFields = async (config, error) => { + restoreRawRequestData(config); + + const statusCode = Number(error?.response?.status || 0); + const shouldRefreshCsrf = + isNonIdempotentRequest(config) || statusCode === 403; + + if (shouldRefreshCsrf) { + try { + await csrfTokenManager.refreshToken({ silent: true }); + } catch { + // Keep fallback path below with existing token/cookie value. + } + } + + const csrfToken = csrfTokenManager.getToken(); + if (csrfToken) { + setHeaderValue(config, CSRF_TOKEN_HEADER, csrfToken); + } +}; + +const setAuthorizationHeader = (config) => { + const token = getStoredToken(); + if (token) { + setHeaderValue(config, "Authorization", `Bearer ${token}`); + } + return config; +}; + +const sleep = (delayMs) => + new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); + +const getTokenValidationRetryDelayMs = (attempt) => { + const normalizedAttempt = Math.max(1, Number(attempt) || 1); + const backoffDelayMs = + TOKEN_VALIDATION_RETRY_BASE_DELAY_MS * + 2 ** Math.max(0, normalizedAttempt - 1); + const jitterMs = Math.floor( + Math.random() * + Math.min(TOKEN_VALIDATION_RETRY_MAX_JITTER_MS, backoffDelayMs), + ); + + return backoffDelayMs + jitterMs; +}; + +const retryRequest = async (error) => { + const failedRequest = error?.config; + if (!failedRequest) { + return Promise.reject(error); + } + + if (!canRetryRequest(failedRequest)) { + return Promise.reject(error); + } + + const attempt = Number(failedRequest.__tokenValidationRetryAttempt || 0) + 1; + if (attempt > TOKEN_VALIDATION_MAX_RETRY_ATTEMPTS) { + return Promise.reject(error); + } + + failedRequest.__tokenValidationRetryAttempt = attempt; + failedRequest._retry = attempt >= TOKEN_VALIDATION_MAX_RETRY_ATTEMPTS; + await refreshRetrySensitiveFields(failedRequest, error); + + const token = getStoredToken(); + if (token) { + await sleep(getTokenValidationRetryDelayMs(attempt)); + setHeaderValue(failedRequest, "Authorization", `Bearer ${token}`); + return api(failedRequest); + } + + return Promise.reject(error); +}; + +const toBusinessErrorPayload = (error) => { + const code = Number(error?.code || error?.Code); + if (!Number.isFinite(code)) { + return null; + } + + return { + Success: false, + Code: code, + Message: error?.message || "", + Data: error?.data, + }; +}; + +api.interceptors.request.use( + async (config) => { + ROW_VERSION_CONFLICT_MESSAGE = i18n.global.t("concurrencyConflict"); + ROW_VERSION_CONFLICT_HINT = i18n.global.t("concurrencyConflict"); + const token = getStoredToken(); + if (!token && rowVersionCache.size > 0) { + clearRowVersionCache(); + } else { + pruneRowVersionCache(); + } + + config = cacheRawRequestData(config); + config = setAuthorizationHeader(config); + config = attachIdempotencyKey(config); + config = attachRowVersionFromCache(config); + + const csrfToken = csrfTokenManager.getToken(); + if (csrfToken) { + setHeaderValue(config, CSRF_TOKEN_HEADER, csrfToken); + } + + return config; + }, + async (error) => { + if (error.isTokenError) { + return retryRequest(error); + } + + const localBusinessError = toBusinessErrorPayload(error); + if (localBusinessError) { + return handleApiError(localBusinessError, { + config: error?.config || {}, + }); + } + + return Promise.reject(error); + }, +); + +api.interceptors.response.use( + (response) => { + saveRowVersionsFromResponse(response); + const resData = response.data; + const hasBusinessFlag = + typeof resData?.Success === "boolean" || + typeof resData?.Code === "number"; + + if (response?.config?.skipBusinessErrorHandling) { + if (!hasBusinessFlag || isApiSuccess(resData)) { + clearRowVersionCacheAfterMutation(response); + } + return response; + } + + if (typeof resData === "string" && resData.includes("Version")) { + return response; + } + + if (hasBusinessFlag && !isApiSuccess(resData)) { + return handleApiError(resData, response); + } + + clearRowVersionCacheAfterMutation(response); + return response; + }, + (error) => { + if (error?.config?.skipHttpErrorHandling) { + return Promise.reject(error); + } + + return handleHttpError(error); + }, +); + +const handleBeforeUnload = () => { + clearRowVersionCache(); +}; + +const handleStorageChange = (event) => { + if (event.key === "token" && !event.newValue) { + clearRowVersionCache(); + } +}; + +let hasBoundWindowListeners = false; + +const bindWindowListeners = () => { + if (typeof window === "undefined" || hasBoundWindowListeners) { + return; + } + + window.addEventListener("beforeunload", handleBeforeUnload); + window.addEventListener("storage", handleStorageChange); + hasBoundWindowListeners = true; +}; + +export const unbindWindowListeners = () => { + if (typeof window === "undefined" || !hasBoundWindowListeners) { + return; + } + + window.removeEventListener("beforeunload", handleBeforeUnload); + window.removeEventListener("storage", handleStorageChange); + hasBoundWindowListeners = false; +}; + +bindWindowListeners(); + +if (import.meta.hot) { + import.meta.hot.dispose(() => { + unbindWindowListeners(); + clearRowVersionCache(); + }); +} + +export default api; diff --git a/src/api/basicapi.js b/src/api/basicapi.js index 09f771d90653fe493496bb15cca6452574edec30..ca3e277c536adf26a5ef9c332e15ff6cb6bad71f 100644 --- a/src/api/basicapi.js +++ b/src/api/basicapi.js @@ -1,26 +1,68 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; +import { isTokenInvalidBusinessCode } from "@/common/errorCodes"; + +const LOGIN_ENDPOINTS = { + admin: API_ENDPOINTS.login.admin, + employee: API_ENDPOINTS.login.employee, +}; + +const TWO_FACTOR_CONTROLLERS = { + admin: API_ENDPOINTS.twoFactor.admin, + employee: API_ENDPOINTS.twoFactor.employee, +}; + +const LOGIN_TYPES = { + admin: API_ENDPOINTS.loginTypes.admin, + employee: API_ENDPOINTS.loginTypes.employee, +}; + +const resolveLoginEndpoint = (loginType = LOGIN_TYPES.admin) => + LOGIN_ENDPOINTS[loginType] || LOGIN_ENDPOINTS.admin; + +const resolveTwoFactorController = (loginType = LOGIN_TYPES.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) => + isTokenInvalidBusinessCode(payload?.Code) && + payload?.Data?.RequiresTwoFactor === true; // 登录 -export const signIn = async (data) => { +export const signIn = async (data, loginType = LOGIN_TYPES.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); } }; // 登出 export const signOut = async (data) => { try { - const response = await api.post("/Admin/Logout", data); + const response = await api.post(API_ENDPOINTS.routes.LOGIN_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}`); @@ -32,7 +74,100 @@ export const signOut = async (data) => { // 获取服务器版本号 export const getServerVersion = async () => { - const response = await api.get("/version"); + const response = await api.get(API_ENDPOINTS.routes.VERSION); const resData = response.data; return resData; }; + +export const fetchTwoFactorStatus = async (loginType = LOGIN_TYPES.admin) => { + try { + const controller = resolveTwoFactorController(loginType); + const response = await api.get( + API_ENDPOINTS.dynamic.getTwoFactorStatus(controller), + { + skipBusinessErrorHandling: true, + skipHttpErrorHandling: true, + }, + ); + return response.data; + } catch (error) { + throw toErrorPayload(error); + } +}; + +export const generateTwoFactorSetup = async (loginType = LOGIN_TYPES.admin) => { + try { + const controller = resolveTwoFactorController(loginType); + const response = await api.post( + API_ENDPOINTS.dynamic.generateTwoFactorSetup(controller), + {}, + { + skipBusinessErrorHandling: true, + skipHttpErrorHandling: true, + }, + ); + return response.data; + } catch (error) { + throw toErrorPayload(error); + } +}; + +export const enableTwoFactor = async ( + loginType = LOGIN_TYPES.admin, + verificationCode = "", +) => { + try { + const controller = resolveTwoFactorController(loginType); + const response = await api.post( + API_ENDPOINTS.dynamic.enableTwoFactor(controller), + { VerificationCode: verificationCode }, + { + skipBusinessErrorHandling: true, + skipHttpErrorHandling: true, + }, + ); + return response.data; + } catch (error) { + throw toErrorPayload(error); + } +}; + +export const disableTwoFactor = async ( + loginType = LOGIN_TYPES.admin, + verificationCode = "", +) => { + try { + const controller = resolveTwoFactorController(loginType); + const response = await api.post( + API_ENDPOINTS.dynamic.disableTwoFactor(controller), + { VerificationCode: verificationCode }, + { + skipBusinessErrorHandling: true, + skipHttpErrorHandling: true, + }, + ); + return response.data; + } catch (error) { + throw toErrorPayload(error); + } +}; + +export const regenerateTwoFactorRecoveryCodes = async ( + loginType = LOGIN_TYPES.admin, + verificationCode = "", +) => { + try { + const controller = resolveTwoFactorController(loginType); + const response = await api.post( + API_ENDPOINTS.dynamic.regenerateTwoFactorRecoveryCodes(controller), + { VerificationCode: verificationCode }, + { + skipBusinessErrorHandling: true, + skipHttpErrorHandling: true, + }, + ); + return response.data; + } catch (error) { + throw toErrorPayload(error); + } +}; diff --git a/src/api/csrfapi.js b/src/api/csrfapi.js index 8e327bdb64185b37f02ee7172b894e1ba67f0387..bfcc637aa224d32a2cd34a62ecc233e716f4d618 100644 --- a/src/api/csrfapi.js +++ b/src/api/csrfapi.js @@ -1,8 +1,9 @@ import api from "./index"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; export const getCsrfToken = async () => { try { - const response = await api.get("/Login/GetCSRFToken"); + const response = await api.get(API_ENDPOINTS.routes.LOGIN_GET_CSRF_TOKEN); if (!response.data.Success) { throw new Error(response.data.Message); } @@ -14,7 +15,9 @@ export const getCsrfToken = async () => { export const refreshCsrfToken = async () => { try { - const response = await api.get("/Login/RefreshCSRFToken"); + const response = await api.get( + API_ENDPOINTS.routes.LOGIN_REFRESH_CSRF_TOKEN, + ); if (!response.data.Success) { throw new Error(response.data.Message); } diff --git a/src/api/customerapi.js b/src/api/customerapi.js index e45e1fcb61be7182d2317103972e76feb7aa344f..5cf821d401baa0a878233d6891077e731a30c803 100644 --- a/src/api/customerapi.js +++ b/src/api/customerapi.js @@ -1,9 +1,15 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 获取客户列表 export const fetchCustomers = async (params) => { try { - const response = await api.get("/Customer/SelectCustomers", { params }); + const response = await api.get( + API_ENDPOINTS.routes.CUSTOMER_SELECT_CUSTOMERS, + { + params, + }, + ); return response.data.Data; } catch (error) { throw error; @@ -13,7 +19,10 @@ export const fetchCustomers = async (params) => { // 添加客户 export const addCustomer = async (data) => { try { - const response = await api.post("/Customer/InsertCustomerInfo", data); + const response = await api.post( + API_ENDPOINTS.routes.CUSTOMER_INSERT_CUSTOMER_INFO, + data, + ); return response.data; } catch (error) { throw error; @@ -23,7 +32,10 @@ export const addCustomer = async (data) => { // 更新客户 export const updateCustomer = async (data) => { try { - const response = await api.post(`/Customer/UpdCustomerInfo`, data); + const response = await api.post( + API_ENDPOINTS.routes.CUSTOMER_UPD_CUSTOMER_INFO, + data, + ); return response.data; } catch (error) { throw error; @@ -33,7 +45,10 @@ export const updateCustomer = async (data) => { // 删除客户 export const deleteCustomer = async (data) => { try { - const response = await api.post(`/Customer/DelCustomerInfo`, data); + const response = await api.post( + API_ENDPOINTS.routes.CUSTOMER_DEL_CUSTOMER_INFO, + data, + ); return response.data; } catch (error) { throw error; diff --git a/src/api/customerpermissionapi.js b/src/api/customerpermissionapi.js index 2bfdfa84f988e3acc31f312999ac09ba320c1d6e..23b207e926740847e122ba365f625881714d17a8 100644 --- a/src/api/customerpermissionapi.js +++ b/src/api/customerpermissionapi.js @@ -1,11 +1,23 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 读取指定客户已分配的角色(返回角色编码集合) +const buildUserNumberPayload = (customerNumber) => { + if (customerNumber && typeof customerNumber === "object") { + return { + UserNumber: customerNumber.UserNumber ?? customerNumber.userNumber ?? "", + }; + } + + return { UserNumber: customerNumber }; +}; + export const readUserRoles = async (customerNumber) => { try { - const response = await api.get("/CustomerPermission/ReadUserRoles", { - params: { userNumber: customerNumber }, - }); + const response = await api.post( + API_ENDPOINTS.routes.CUSTOMER_PERMISSION_READ_USER_ROLES, + buildUserNumberPayload(customerNumber), + ); return response.data?.Data?.Items || []; } catch (error) { throw error; @@ -16,7 +28,7 @@ export const readUserRoles = async (customerNumber) => { export const assignUserRoles = async (data) => { try { const response = await api.post( - "/CustomerPermission/AssignUserRoles", + API_ENDPOINTS.routes.CUSTOMER_PERMISSION_ASSIGN_USER_ROLES, data, ); return response.data; @@ -28,11 +40,9 @@ export const assignUserRoles = async (data) => { // 读取客户(通过角色)所拥有的权限明细(列表) export const readUserRolePermissions = async (customerNumber) => { try { - const response = await api.get( - "/CustomerPermission/ReadUserRolePermissions", - { - params: { userNumber: customerNumber }, - }, + const response = await api.post( + API_ENDPOINTS.routes.CUSTOMER_PERMISSION_READ_USER_ROLE_PERMISSIONS, + buildUserNumberPayload(customerNumber), ); return response.data?.Data?.Items || []; } catch (error) { @@ -43,11 +53,9 @@ export const readUserRolePermissions = async (customerNumber) => { // 读取客户“直接权限”(非角色继承)权限编码集合 export const readUserDirectPermissions = async (customerNumber) => { try { - const response = await api.get( - "/CustomerPermission/ReadUserDirectPermissions", - { - params: { userNumber: customerNumber }, - }, + const response = await api.post( + API_ENDPOINTS.routes.CUSTOMER_PERMISSION_READ_USER_DIRECT_PERMISSIONS, + buildUserNumberPayload(customerNumber), ); return response.data?.Data?.Items || []; } catch (error) { @@ -59,7 +67,7 @@ export const readUserDirectPermissions = async (customerNumber) => { export const assignUserPermissions = async (data) => { try { const response = await api.post( - "/CustomerPermission/AssignUserPermissions", + API_ENDPOINTS.routes.CUSTOMER_PERMISSION_ASSIGN_USER_PERMISSIONS, data, ); return response.data; diff --git a/src/api/customertypeapi.js b/src/api/customertypeapi.js index 5ccd0f68b59269fb453f5e4b776c3dff0f4469ef..9e184d1e0edb56e42410fece797aed58fd940524 100644 --- a/src/api/customertypeapi.js +++ b/src/api/customertypeapi.js @@ -1,9 +1,15 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 获取客户类型列表 export const fetchCustomerTypes = async (params) => { try { - const response = await api.get("/Base/SelectCustoTypeAll", { params }); + const response = await api.get( + API_ENDPOINTS.routes.BASE_SELECT_CUSTO_TYPE_ALL, + { + params, + }, + ); return response.data.Data; } catch (error) { throw error; @@ -13,7 +19,10 @@ export const fetchCustomerTypes = async (params) => { // 添加客户类型 export const addCustomerType = async (data) => { try { - const response = await api.post("/Base/InsertCustoType", data); + const response = await api.post( + API_ENDPOINTS.routes.BASE_INSERT_CUSTO_TYPE, + data, + ); return response.data; } catch (error) { throw error; @@ -23,7 +32,10 @@ export const addCustomerType = async (data) => { // 更新客户类型 export const updateCustomerType = async (data) => { try { - const response = await api.post(`/Base/UpdateCustoType`, data); + const response = await api.post( + API_ENDPOINTS.routes.BASE_UPDATE_CUSTO_TYPE, + data, + ); return response.data; } catch (error) { throw error; @@ -33,7 +45,10 @@ export const updateCustomerType = async (data) => { // 删除客户类型 export const deleteCustomerType = async (data) => { try { - const response = await api.post(`/Base/DeleteCustoType`, data); + const response = await api.post( + API_ENDPOINTS.routes.BASE_DELETE_CUSTO_TYPE, + data, + ); return response.data; } catch (error) { throw error; diff --git a/src/api/custotypeapi.js b/src/api/custotypeapi.js index 7e9445f8c4cabbcbb5579138ceb0f5f52773ac6b..5a28df8ae1f2757d1df567cb39d79b235eef5472 100644 --- a/src/api/custotypeapi.js +++ b/src/api/custotypeapi.js @@ -1,9 +1,15 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 获取客户类型列表 export const fetchCustomerTypes = async (params) => { try { - const response = await api.get("/Base/SelectCustoTypeAll", { params }); + const response = await api.get( + API_ENDPOINTS.routes.BASE_SELECT_CUSTO_TYPE_ALL, + { + params, + }, + ); return response.data.Data; } catch (error) { throw error; @@ -13,9 +19,10 @@ export const fetchCustomerTypes = async (params) => { // 获取可用客户类型列表 export const fetchCanUseCustomerTypes = async (params) => { try { - const response = await api.get("/Base/SelectCustoTypeAllCanUse", { - params, - }); + const response = await api.get( + API_ENDPOINTS.routes.BASE_SELECT_CUSTO_TYPE_ALL_CAN_USE, + { params }, + ); return response.data.Data; } catch (error) { throw error; @@ -25,7 +32,10 @@ export const fetchCanUseCustomerTypes = async (params) => { // 添加客户类型 export const addCustomerType = async (data) => { try { - const response = await api.post("/Base/InsertCustoType", data); + const response = await api.post( + API_ENDPOINTS.routes.BASE_INSERT_CUSTO_TYPE, + data, + ); return response.data; } catch (error) { throw error; @@ -35,7 +45,10 @@ export const addCustomerType = async (data) => { // 更新客户类型 export const updateCustomerType = async (data) => { try { - const response = await api.post(`/Base/UpdateCustoType`, data); + const response = await api.post( + API_ENDPOINTS.routes.BASE_UPDATE_CUSTO_TYPE, + data, + ); return response.data; } catch (error) { throw error; @@ -45,7 +58,10 @@ export const updateCustomerType = async (data) => { // 删除客户类型 export const deleteCustomerType = async (data) => { try { - const response = await api.post(`/Base/DeleteCustoType`, data); + const response = await api.post( + API_ENDPOINTS.routes.BASE_DELETE_CUSTO_TYPE, + data, + ); return response.data; } catch (error) { throw error; diff --git a/src/api/dashboardapi.js b/src/api/dashboardapi.js index 74910e16db319c10f758933633269a4cc7098b54..85bb05aac6c4f0e137679b91118db8b117770743 100644 --- a/src/api/dashboardapi.js +++ b/src/api/dashboardapi.js @@ -1,9 +1,15 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 获取房间统计信息 export const fetchRoomStatistics = async (params) => { try { - const response = await api.get("/Dashboard/RoomStatistics", { params }); + const response = await api.get( + API_ENDPOINTS.routes.DASHBOARD_ROOM_STATISTICS, + { + params, + }, + ); return response.data; } catch (error) { throw error; @@ -13,7 +19,10 @@ export const fetchRoomStatistics = async (params) => { // 获取业务统计信息 export const fetchBusinessStatistics = async (params) => { try { - const response = await api.get("/Dashboard/BusinessStatistics", { params }); + const response = await api.get( + API_ENDPOINTS.routes.DASHBOARD_BUSINESS_STATISTICS, + { params }, + ); return response.data; } catch (error) { throw error; @@ -23,9 +32,10 @@ export const fetchBusinessStatistics = async (params) => { // 获取后勤统计信息 export const fetchLogisticsStatistics = async (params) => { try { - const response = await api.get("/Dashboard/LogisticsStatistics", { - params, - }); + const response = await api.get( + API_ENDPOINTS.routes.DASHBOARD_LOGISTICS_STATISTICS, + { params }, + ); return response.data; } catch (error) { throw error; @@ -35,9 +45,10 @@ export const fetchLogisticsStatistics = async (params) => { // 获取人事统计信息 export const fetchHumanResourcesStatistics = async (params) => { try { - const response = await api.get("/Dashboard/HumanResourcesStatistics", { - params, - }); + const response = await api.get( + API_ENDPOINTS.routes.DASHBOARD_HUMAN_RESOURCES_STATISTICS, + { params }, + ); return response.data; } catch (error) { throw error; diff --git a/src/api/departmentapi.js b/src/api/departmentapi.js index 060545a433fe638a4aca844e6c510695a27dc894..902710f7e93ded7351b6d8912b5749414c801d7c 100644 --- a/src/api/departmentapi.js +++ b/src/api/departmentapi.js @@ -1,9 +1,12 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 获取部门列表 export const fetchDepartments = async (params) => { try { - const response = await api.get("/Base/SelectDeptAll", { params }); + const response = await api.get(API_ENDPOINTS.routes.BASE_SELECT_DEPT_ALL, { + params, + }); return response.data.Data; } catch (error) { throw error; @@ -13,7 +16,7 @@ export const fetchDepartments = async (params) => { // 添加部门 export const addDepartment = async (data) => { try { - const response = await api.post("/Base/AddDept", data); + const response = await api.post(API_ENDPOINTS.routes.BASE_ADD_DEPT, data); return response.data; } catch (error) { throw error; @@ -23,7 +26,7 @@ export const addDepartment = async (data) => { // 更新部门 export const updateDepartment = async (data) => { try { - const response = await api.post(`/Base/UpdDept`, data); + const response = await api.post(API_ENDPOINTS.routes.BASE_UPD_DEPT, data); return response.data; } catch (error) { throw error; @@ -33,7 +36,7 @@ export const updateDepartment = async (data) => { // 删除部门 export const deleteDepartment = async (data) => { try { - const response = await api.post(`/Base/DelDept`, { data }); + const response = await api.post(API_ENDPOINTS.routes.BASE_DEL_DEPT, data); return response.data; } catch (error) { throw error; diff --git a/src/api/employeeapi.js b/src/api/employeeapi.js index 6de5c87b11ff225c376e5788a6344f85a4ea12d2..d34d977cf53d89597343fee0a116a7b174c8d093 100644 --- a/src/api/employeeapi.js +++ b/src/api/employeeapi.js @@ -1,9 +1,15 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 获取员工列表 export const fetchEmployees = async (params) => { try { - const response = await api.get("/Employee/SelectEmployeeAll", { params }); + const response = await api.get( + API_ENDPOINTS.routes.EMPLOYEE_SELECT_EMPLOYEE_ALL, + { + params, + }, + ); return response.data.Data; } catch (error) { throw error; @@ -13,9 +19,10 @@ export const fetchEmployees = async (params) => { //获取员工详情 export const fetchEmployeeDetail = async (params) => { try { - const response = await api.get("/Employee/SelectEmployeeInfoByEmployeeId", { - params, - }); + const response = await api.get( + API_ENDPOINTS.routes.EMPLOYEE_SELECT_EMPLOYEE_INFO_BY_EMPLOYEE_ID, + { params }, + ); return response.data; } catch (error) { throw error; @@ -26,7 +33,7 @@ export const fetchEmployeeDetail = async (params) => { export const fetchEmployeeResume = async (params) => { try { const response = await api.get( - "/EmployeeHistory/SelectHistoryByEmployeeId", + API_ENDPOINTS.routes.EMPLOYEE_HISTORY_SELECT_HISTORY_BY_EMPLOYEE_ID, { params }, ); return response.data.Data; @@ -38,7 +45,10 @@ export const fetchEmployeeResume = async (params) => { // 添加员工 export const addEmployee = async (data) => { try { - const response = await api.post("/Employee/AddEmployee", data); + const response = await api.post( + API_ENDPOINTS.routes.EMPLOYEE_ADD_EMPLOYEE, + data, + ); return response.data; } catch (error) { throw error; @@ -48,7 +58,10 @@ export const addEmployee = async (data) => { // 更新员工 export const updateEmployee = async (data) => { try { - const response = await api.post(`/Employee/UpdateEmployee`, data); + const response = await api.post( + API_ENDPOINTS.routes.EMPLOYEE_UPDATE_EMPLOYEE, + data, + ); return response.data; } catch (error) { throw error; @@ -58,7 +71,10 @@ export const updateEmployee = async (data) => { // 禁用/启用员工账号 export const managerEmployeeAccount = async (data) => { try { - const response = await api.post(`/Employee/ManagerEmployeeAccount`, data); + const response = await api.post( + API_ENDPOINTS.routes.EMPLOYEE_MANAGER_EMPLOYEE_ACCOUNT, + data, + ); return response.data; } catch (error) { throw error; @@ -69,7 +85,8 @@ export const managerEmployeeAccount = async (data) => { export const fetchEmployeeRewardPunishment = async (params) => { try { const response = await api.get( - "/RewardPunishment/SelectAllRewardPunishmentByEmployeeId", + API_ENDPOINTS.routes + .REWARD_PUNISHMENT_SELECT_ALL_REWARD_PUNISHMENT_BY_EMPLOYEE_ID, { params }, ); return response.data.Data; @@ -82,7 +99,7 @@ export const fetchEmployeeRewardPunishment = async (params) => { export const fetchEmployeeAttendance = async (params) => { try { const response = await api.get( - "/EmployeeCheck/SelectCheckInfoByEmployeeId", + API_ENDPOINTS.routes.EMPLOYEE_CHECK_SELECT_CHECK_INFO_BY_EMPLOYEE_ID, { params }, ); return response.data.Data; @@ -95,7 +112,7 @@ export const fetchEmployeeAttendance = async (params) => { export const resetEmployeePassword = async (data) => { try { const response = await api.post( - `/Employee/ResetEmployeeAccountPassword`, + API_ENDPOINTS.routes.EMPLOYEE_RESET_EMPLOYEE_ACCOUNT_PASSWORD, data, ); return response.data; @@ -107,7 +124,10 @@ export const resetEmployeePassword = async (data) => { // 上传员工头像 export const uploadEmployeeAvatar = async (data) => { try { - const response = await api.post(`/EmployeePhoto/InsertWorkerPhoto`, data); + const response = await api.post( + API_ENDPOINTS.routes.EMPLOYEE_PHOTO_INSERT_WORKER_PHOTO, + data, + ); return response.data; } catch (error) { throw error; diff --git a/src/api/employeepermissionapi.js b/src/api/employeepermissionapi.js index 3aaa475d91d3804297cd732fb11d8e272f181cfc..09456d9e147f4a09091801327f4f59bfbb88127f 100644 --- a/src/api/employeepermissionapi.js +++ b/src/api/employeepermissionapi.js @@ -1,11 +1,23 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 读取指定员工已分配的角色(返回角色编码集合) +const buildUserNumberPayload = (employeeId) => { + if (employeeId && typeof employeeId === "object") { + return { + UserNumber: employeeId.UserNumber ?? employeeId.userNumber ?? "", + }; + } + + return { UserNumber: employeeId }; +}; + export const readUserRoles = async (employeeId) => { try { - const response = await api.get("/EmployeePermission/ReadUserRoles", { - params: { userNumber: employeeId }, - }); + const response = await api.post( + API_ENDPOINTS.routes.EMPLOYEE_PERMISSION_READ_USER_ROLES, + buildUserNumberPayload(employeeId), + ); return response.data?.Data?.Items || []; } catch (error) { throw error; @@ -16,7 +28,7 @@ export const readUserRoles = async (employeeId) => { export const assignUserRoles = async (data) => { try { const response = await api.post( - "/EmployeePermission/AssignUserRoles", + API_ENDPOINTS.routes.EMPLOYEE_PERMISSION_ASSIGN_USER_ROLES, data, ); return response.data; @@ -28,11 +40,9 @@ export const assignUserRoles = async (data) => { // 读取员工(通过角色)所拥有的权限明细(列表) export const readUserRolePermissions = async (employeeId) => { try { - const response = await api.get( - "/EmployeePermission/ReadUserRolePermissions", - { - params: { userNumber: employeeId }, - }, + const response = await api.post( + API_ENDPOINTS.routes.EMPLOYEE_PERMISSION_READ_USER_ROLE_PERMISSIONS, + buildUserNumberPayload(employeeId), ); return response.data?.Data?.Items || []; } catch (error) { @@ -43,11 +53,9 @@ export const readUserRolePermissions = async (employeeId) => { // 读取员工“直接权限”(非角色继承)权限编码集合 export const readUserDirectPermissions = async (employeeId) => { try { - const response = await api.get( - "/EmployeePermission/ReadUserDirectPermissions", - { - params: { userNumber: employeeId }, - }, + const response = await api.post( + API_ENDPOINTS.routes.EMPLOYEE_PERMISSION_READ_USER_DIRECT_PERMISSIONS, + buildUserNumberPayload(employeeId), ); return response.data?.Data?.Items || []; } catch (error) { @@ -59,7 +67,7 @@ export const readUserDirectPermissions = async (employeeId) => { export const assignUserPermissions = async (data) => { try { const response = await api.post( - "/EmployeePermission/AssignUserPermissions", + API_ENDPOINTS.routes.EMPLOYEE_PERMISSION_ASSIGN_USER_PERMISSIONS, data, ); return response.data; diff --git a/src/api/goodsapi.js b/src/api/goodsapi.js index 3d568c492651ae980ac66670faf13047375f6341..a55e940c0e68d3756815a84963fe715930c84d43 100644 --- a/src/api/goodsapi.js +++ b/src/api/goodsapi.js @@ -1,9 +1,13 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 获取商品列表 export const fetchGoodss = async (params) => { try { - const response = await api.get("/Sellthing/SelectSellthingAll", { params }); + const response = await api.get( + API_ENDPOINTS.routes.SELLTHING_SELECT_SELLTHING_ALL, + { params }, + ); return response.data.Data; } catch (error) { throw error; @@ -13,7 +17,10 @@ export const fetchGoodss = async (params) => { // 添加商品 export const addGoods = async (data) => { try { - const response = await api.post("/Sellthing/InsertSellthing", data); + const response = await api.post( + API_ENDPOINTS.routes.SELLTHING_INSERT_SELLTHING, + data, + ); return response.data; } catch (error) { throw error; @@ -23,7 +30,10 @@ export const addGoods = async (data) => { // 更新商品 export const updateGoods = async (data) => { try { - const response = await api.post(`/Sellthing/UpdateSellthing`, data); + const response = await api.post( + API_ENDPOINTS.routes.SELLTHING_UPDATE_SELLTHING, + data, + ); return response.data; } catch (error) { throw error; @@ -33,7 +43,10 @@ export const updateGoods = async (data) => { // 删除商品 export const deleteGoods = async (data) => { try { - const response = await api.post(`/Sellthing/DeleteSellthing`, data); + const response = await api.post( + API_ENDPOINTS.routes.SELLTHING_DELETE_SELLTHING, + data, + ); return response.data; } catch (error) { throw error; diff --git a/src/api/hydroelectricityapi.js b/src/api/hydroelectricityapi.js index f7228aa57afed6fdbee274cc60d59cb3f3ca106e..ec1db7df7e28d616be4e29a9f90342178e8db09b 100644 --- a/src/api/hydroelectricityapi.js +++ b/src/api/hydroelectricityapi.js @@ -1,10 +1,11 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 根据条件查询水电费信息 房间号(可选)使用开始时间(可选)使用结束时间(可选) export const fetchHydroelectricitys = async (params) => { try { const response = await api.get( - "/EnergyManagement/SelectEnergyManagementInfo", + API_ENDPOINTS.routes.ENERGY_MANAGEMENT_SELECT_ENERGY_MANAGEMENT_INFO, { params }, ); return response.data.Data; @@ -17,7 +18,7 @@ export const fetchHydroelectricitys = async (params) => { export const addHydroelectricity = async (data) => { try { const response = await api.post( - "/EnergyManagement/InsertEnergyManagementInfo", + API_ENDPOINTS.routes.ENERGY_MANAGEMENT_INSERT_ENERGY_MANAGEMENT_INFO, data, ); return response.data; @@ -30,7 +31,7 @@ export const addHydroelectricity = async (data) => { export const updateHydroelectricity = async (data) => { try { const response = await api.post( - `/EnergyManagement/UpdateEnergyManagementInfo`, + API_ENDPOINTS.routes.ENERGY_MANAGEMENT_UPDATE_ENERGY_MANAGEMENT_INFO, data, ); return response.data; @@ -43,7 +44,7 @@ export const updateHydroelectricity = async (data) => { export const deleteHydroelectricity = async (data) => { try { const response = await api.post( - `/EnergyManagement/DeleteEnergyManagementInfo`, + API_ENDPOINTS.routes.ENERGY_MANAGEMENT_DELETE_ENERGY_MANAGEMENT_INFO, data, ); return response.data; diff --git a/src/api/index.js b/src/api/index.js index 23922dd61aa71509376cc02de255c490cb7f1879..a7e79100ac6a85e75de8533742311361296de6ae 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/api/internalfinanceapi.js b/src/api/internalfinanceapi.js index 79051291669c26c2b56de63d3f0aaa5541c17e53..afa796ef1bceb68e94ee7e77c4fce5970b7c6b5a 100644 --- a/src/api/internalfinanceapi.js +++ b/src/api/internalfinanceapi.js @@ -1,9 +1,15 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 获取资产列表 export const fetchInternalFinances = async (params) => { try { - const response = await api.get("/Asset/SelectAssetInfoAll", { params }); + const response = await api.get( + API_ENDPOINTS.routes.ASSET_SELECT_ASSET_INFO_ALL, + { + params, + }, + ); return response.data.Data; } catch (error) { throw error; @@ -13,7 +19,10 @@ export const fetchInternalFinances = async (params) => { // 添加资产 export const addInternalFinance = async (data) => { try { - const response = await api.post("/Asset/AddAssetInfo", data); + const response = await api.post( + API_ENDPOINTS.routes.ASSET_ADD_ASSET_INFO, + data, + ); return response.data; } catch (error) { throw error; @@ -23,7 +32,10 @@ export const addInternalFinance = async (data) => { // 更新资产 export const updateInternalFinance = async (data) => { try { - const response = await api.post(`/Asset/UpdAssetInfo`, data); + const response = await api.post( + API_ENDPOINTS.routes.ASSET_UPD_ASSET_INFO, + data, + ); return response.data; } catch (error) { throw error; @@ -33,7 +45,10 @@ export const updateInternalFinance = async (data) => { // 删除资产 export const deleteInternalFinance = async (data) => { try { - const response = await api.post(`/Asset/DelAssetInfo`, data); + const response = await api.post( + API_ENDPOINTS.routes.ASSET_DEL_ASSET_INFO, + data, + ); return response.data; } catch (error) { throw error; diff --git a/src/api/menuapi.js b/src/api/menuapi.js index 14926a281f6c7488fde8dfc162305a4624287f87..92a4ef7a05e8b658ea06358d623786db6743623d 100644 --- a/src/api/menuapi.js +++ b/src/api/menuapi.js @@ -1,54 +1,88 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; -// 获取菜单树 +// Fetch menu tree. export const fetchMenusTree = async (menu) => { try { - const response = await api.post("/Menu/BuildMenuAll", menu); + const response = await api.post( + API_ENDPOINTS.routes.MENU_BUILD_MENU_ALL, + menu, + { + // BuildMenuAll business failures are handled by caller. + skipBusinessErrorHandling: true, + }, + ); - // 后端 BaseResponse 使用 PascalCase 字段(Success/Code/Message/Data) - if (!response.data.Success) { - throw new Error(response.data.Message || "获取菜单失败"); + const payload = response?.data || {}; + if (!payload.Success) { + const error = new Error( + payload.Message || payload.message || "Failed to load menu data", + ); + error.code = payload.Code || payload.code || 0; + throw error; } - return response.data.Data; + + return payload.Data; } catch (error) { - throw error; + const message = + error?.Message || + error?.message || + error?.response?.data?.Message || + error?.response?.data?.message || + "Failed to load menu data"; + + const wrappedError = new Error(String(message)); + wrappedError.code = + error?.code || error?.Code || error?.response?.data?.Code || 0; + throw wrappedError; } }; -// 获取菜单列表 +// Fetch menu list. export const fetchMenus = async (params) => { try { - const response = await api.get("/Menu/SelectMenuAll", { params }); + const response = await api.get(API_ENDPOINTS.routes.MENU_SELECT_MENU_ALL, { + params, + }); return response.data.Data; } catch (error) { throw error; } }; -// 创建新菜单项 +// Create menu. export const addMenu = async (menu) => { try { - const response = await api.post("/Menu/InsertMenu", menu); + const response = await api.post( + API_ENDPOINTS.routes.MENU_INSERT_MENU, + menu, + ); return response.data; } catch (error) { throw error; } }; -// 更新菜单项 +// Update menu. export const updateMenu = async (menu) => { try { - const response = await api.post(`/Menu/UpdateMenu`, menu); + const response = await api.post( + API_ENDPOINTS.routes.MENU_UPDATE_MENU, + menu, + ); return response.data; } catch (error) { throw error; } }; -// 删除菜单项 +// Delete menu. export const deleteMenu = async (menu) => { try { - const response = await api.post(`/Menu/DeleteMenu`, menu); + const response = await api.post( + API_ENDPOINTS.routes.MENU_DELETE_MENU, + menu, + ); return response.data; } catch (error) { throw error; diff --git a/src/api/nationapi.js b/src/api/nationapi.js index f2f05db24afc7014a878f605fc135574564ca9fa..300db812979012439690f789a4ad5cd484d88d9f 100644 --- a/src/api/nationapi.js +++ b/src/api/nationapi.js @@ -1,9 +1,15 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 获取民族列表 export const fetchNations = async (params) => { try { - const response = await api.get("/Base/SelectNationAll", { params }); + const response = await api.get( + API_ENDPOINTS.routes.BASE_SELECT_NATION_ALL, + { + params, + }, + ); return response.data.Data; } catch (error) { throw error; @@ -13,7 +19,7 @@ export const fetchNations = async (params) => { // 添加民族 export const addNation = async (data) => { try { - const response = await api.post("/Base/AddNation", data); + const response = await api.post(API_ENDPOINTS.routes.BASE_ADD_NATION, data); return response.data; } catch (error) { throw error; @@ -23,7 +29,7 @@ export const addNation = async (data) => { // 更新民族 export const updateNation = async (data) => { try { - const response = await api.post(`/Base/UpdNation`, data); + const response = await api.post(API_ENDPOINTS.routes.BASE_UPD_NATION, data); return response.data; } catch (error) { throw error; @@ -33,7 +39,7 @@ export const updateNation = async (data) => { // 删除民族 export const deleteNation = async (data) => { try { - const response = await api.post(`/Base/DelNation`, data); + const response = await api.post(API_ENDPOINTS.routes.BASE_DEL_NATION, data); return response.data; } catch (error) { throw error; diff --git a/src/api/noticetypeapi.js b/src/api/noticetypeapi.js index 712c7c79c959706b4138d7ac4896f3087af79a95..fbe53442c528f0d0f41dbc173479d4a7d5400c54 100644 --- a/src/api/noticetypeapi.js +++ b/src/api/noticetypeapi.js @@ -1,11 +1,13 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 获取公告类型列表 export const fetchNoticeTypes = async (params) => { try { - const response = await api.get("/Base/SelectAppointmentNoticeTypeAll", { - params, - }); + const response = await api.get( + API_ENDPOINTS.routes.BASE_SELECT_APPOINTMENT_NOTICE_TYPE_ALL, + { params }, + ); return response.data.Data; } catch (error) { throw error; @@ -15,7 +17,10 @@ export const fetchNoticeTypes = async (params) => { // 添加公告类型 export const addNoticeType = async (data) => { try { - const response = await api.post("/Base/CreateAppointmentNoticeType", data); + const response = await api.post( + API_ENDPOINTS.routes.BASE_CREATE_APPOINTMENT_NOTICE_TYPE, + data, + ); return response.data; } catch (error) { throw error; @@ -25,7 +30,10 @@ export const addNoticeType = async (data) => { // 更新公告类型 export const updateNoticeType = async (data) => { try { - const response = await api.post(`/Base/UpdateAppointmentNoticeType`, data); + const response = await api.post( + API_ENDPOINTS.routes.BASE_UPDATE_APPOINTMENT_NOTICE_TYPE, + data, + ); return response.data; } catch (error) { throw error; @@ -35,7 +43,10 @@ export const updateNoticeType = async (data) => { // 删除公告类型 export const deleteNoticeType = async (data) => { try { - const response = await api.post(`/Base/DeleteAppointmentNoticeType`, data); + const response = await api.post( + API_ENDPOINTS.routes.BASE_DELETE_APPOINTMENT_NOTICE_TYPE, + data, + ); return response.data; } catch (error) { throw error; diff --git a/src/api/passportapi.js b/src/api/passportapi.js index e32787aceab8cae7430cdcfd10ecb25e6bb39c05..01fd8a42e4247cfa995afd7de425d43c47c9f529 100644 --- a/src/api/passportapi.js +++ b/src/api/passportapi.js @@ -1,9 +1,13 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 获取证件类型列表 export const fetchPassports = async (params) => { try { - const response = await api.get("/Base/SelectPassPortTypeAll", { params }); + const response = await api.get( + API_ENDPOINTS.routes.BASE_SELECT_PASS_PORT_TYPE_ALL, + { params }, + ); return response.data.Data; } catch (error) { throw error; @@ -13,9 +17,10 @@ export const fetchPassports = async (params) => { // 获取可用证件类型列表 export const fetchCanUsePassports = async (params) => { try { - const response = await api.get("/Base/SelectPassPortTypeAllCanUse", { - params, - }); + const response = await api.get( + API_ENDPOINTS.routes.BASE_SELECT_PASS_PORT_TYPE_ALL_CAN_USE, + { params }, + ); return response.data.Data; } catch (error) { throw error; @@ -25,7 +30,10 @@ export const fetchCanUsePassports = async (params) => { // 添加证件类型 export const addPassport = async (data) => { try { - const response = await api.post("/Base/InsertPassPortType", data); + const response = await api.post( + API_ENDPOINTS.routes.BASE_INSERT_PASS_PORT_TYPE, + data, + ); return response.data; } catch (error) { throw error; @@ -35,7 +43,10 @@ export const addPassport = async (data) => { // 更新证件类型 export const updatePassport = async (data) => { try { - const response = await api.post(`/Base/UpdatePassPortType`, data); + const response = await api.post( + API_ENDPOINTS.routes.BASE_UPDATE_PASS_PORT_TYPE, + data, + ); return response.data; } catch (error) { throw error; @@ -45,7 +56,10 @@ export const updatePassport = async (data) => { // 删除证件类型 export const deletePassport = async (data) => { try { - const response = await api.post(`/Base/DeletePassPortType`, data); + const response = await api.post( + API_ENDPOINTS.routes.BASE_DELETE_PASS_PORT_TYPE, + data, + ); return response.data; } catch (error) { throw error; diff --git a/src/api/permissionapi.js b/src/api/permissionapi.js index 66c88d60245cb58a0981398667e057da166ace60..f4bbecaf4ccbada17e5f51f5425cea2dc279f42f 100644 --- a/src/api/permissionapi.js +++ b/src/api/permissionapi.js @@ -1,10 +1,22 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; + +const buildReadPermissionPayload = (params = {}) => ({ + PermissionNumber: params.PermissionNumber ?? params.permissionNumber ?? "", + PermissionName: params.PermissionName ?? params.permissionName ?? "", + MenuKey: params.MenuKey ?? params.menuKey ?? "", + Module: params.Module ?? params.module ?? "", + Page: params.Page ?? params.page ?? 1, + PageSize: params.PageSize ?? params.pageSize ?? 15, + IgnorePaging: params.IgnorePaging ?? params.ignorePaging ?? false, +}); export const selectPermissionList = async (params) => { try { - const response = await api.get("/Permission/SelectPermissionList", { - params, - }); + const response = await api.post( + API_ENDPOINTS.routes.PERMISSION_SELECT_PERMISSION_LIST, + buildReadPermissionPayload(params), + ); // 返回标准结构 { Items, TotalCount } return response.data?.Data || { Items: [], TotalCount: 0 }; } catch (error) { diff --git a/src/api/positionapi.js b/src/api/positionapi.js index e4f0501773cef2f3f47da979be62fefdc819d295..0a6992128c2669843875d5f394902ebc2785c1d1 100644 --- a/src/api/positionapi.js +++ b/src/api/positionapi.js @@ -1,9 +1,15 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 获取职位列表 export const fetchPositions = async (params) => { try { - const response = await api.get("/Base/SelectPositionAll", { params }); + const response = await api.get( + API_ENDPOINTS.routes.BASE_SELECT_POSITION_ALL, + { + params, + }, + ); return response.data.Data; } catch (error) { throw error; @@ -13,7 +19,10 @@ export const fetchPositions = async (params) => { // 添加职位 export const addPosition = async (data) => { try { - const response = await api.post("/Base/AddPosition", data); + const response = await api.post( + API_ENDPOINTS.routes.BASE_ADD_POSITION, + data, + ); return response.data; } catch (error) { throw error; @@ -23,7 +32,10 @@ export const addPosition = async (data) => { // 更新职位 export const updatePosition = async (data) => { try { - const response = await api.post(`/Base/UpdPosition`, data); + const response = await api.post( + API_ENDPOINTS.routes.BASE_UPD_POSITION, + data, + ); return response.data; } catch (error) { throw error; @@ -33,7 +45,10 @@ export const updatePosition = async (data) => { // 删除职位 export const deletePosition = async (data) => { try { - const response = await api.post(`/Base/DelPosition`, data); + const response = await api.post( + API_ENDPOINTS.routes.BASE_DEL_POSITION, + data, + ); return response.data; } catch (error) { throw error; diff --git a/src/api/promotioncontentapi.js b/src/api/promotioncontentapi.js index 224502c94a5531e4b94995cf5398602f731ab761..845a6fe031542bee48827201127c9c034b599a0b 100644 --- a/src/api/promotioncontentapi.js +++ b/src/api/promotioncontentapi.js @@ -1,10 +1,11 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 获取宣传联动内容列表 export const fetchPromotionContents = async (params) => { try { const response = await api.get( - "/PromotionContent/SelectPromotionContentAll", + API_ENDPOINTS.routes.PROMOTION_CONTENT_SELECT_PROMOTION_CONTENT_ALL, { params }, ); return response.data.Data; @@ -17,7 +18,7 @@ export const fetchPromotionContents = async (params) => { export const addPromotionContent = async (data) => { try { const response = await api.post( - "/PromotionContent/AddPromotionContent", + API_ENDPOINTS.routes.PROMOTION_CONTENT_ADD_PROMOTION_CONTENT, data, ); return response.data; @@ -30,7 +31,7 @@ export const addPromotionContent = async (data) => { export const updatePromotionContent = async (data) => { try { const response = await api.post( - `/PromotionContent/UpdatePromotionContent`, + API_ENDPOINTS.routes.PROMOTION_CONTENT_UPDATE_PROMOTION_CONTENT, data, ); return response.data; @@ -43,7 +44,7 @@ export const updatePromotionContent = async (data) => { export const deletePromotionContent = async (data) => { try { const response = await api.post( - `/PromotionContent/DeletePromotionContent`, + API_ENDPOINTS.routes.PROMOTION_CONTENT_DELETE_PROMOTION_CONTENT, data, ); return response.data; diff --git a/src/api/qualificationapi.js b/src/api/qualificationapi.js index 9276f06c4a3010ce33cfb914de3f7293fa062475..cfa5093c267b3de1d5fe4e920194e2ff5bba9f2f 100644 --- a/src/api/qualificationapi.js +++ b/src/api/qualificationapi.js @@ -1,9 +1,15 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 获取学历列表 export const fetchQualifications = async (params) => { try { - const response = await api.get("/Base/SelectEducationAll", { params }); + const response = await api.get( + API_ENDPOINTS.routes.BASE_SELECT_EDUCATION_ALL, + { + params, + }, + ); return response.data.Data; } catch (error) { throw error; @@ -13,7 +19,10 @@ export const fetchQualifications = async (params) => { // 添加学历 export const addQualification = async (data) => { try { - const response = await api.post("/Base/AddEducation", data); + const response = await api.post( + API_ENDPOINTS.routes.BASE_ADD_EDUCATION, + data, + ); return response.data; } catch (error) { throw error; @@ -23,7 +32,10 @@ export const addQualification = async (data) => { // 更新学历 export const updateQualification = async (data) => { try { - const response = await api.post(`/Base/UpdEducation`, data); + const response = await api.post( + API_ENDPOINTS.routes.BASE_UPD_EDUCATION, + data, + ); return response.data; } catch (error) { throw error; @@ -33,7 +45,10 @@ export const updateQualification = async (data) => { // 删除学历 export const deleteQualification = async (data) => { try { - const response = await api.post(`/Base/DelEducation`, data); + const response = await api.post( + API_ENDPOINTS.routes.BASE_DEL_EDUCATION, + data, + ); return response.data; } catch (error) { throw error; diff --git a/src/api/reserapi.js b/src/api/reserapi.js index 30e2b60138fc1f989a6523f01c0938d6bfe4c1f9..945322a9a6769cde07b469e0f19dd96f3f61a84e 100644 --- a/src/api/reserapi.js +++ b/src/api/reserapi.js @@ -1,9 +1,15 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 获取预约列表 export const fetchResers = async (params) => { try { - const response = await api.get("/Reser/SelectReserAll", { params }); + const response = await api.get( + API_ENDPOINTS.routes.RESER_SELECT_RESER_ALL, + { + params, + }, + ); return response.data.Data; } catch (error) { throw error; @@ -13,7 +19,10 @@ export const fetchResers = async (params) => { // 添加预约 export const addReser = async (data) => { try { - const response = await api.post("/Reser/InserReserInfo", data); + const response = await api.post( + API_ENDPOINTS.routes.RESER_INSER_RESER_INFO, + data, + ); return response.data; } catch (error) { throw error; @@ -23,7 +32,10 @@ export const addReser = async (data) => { // 更新预约 export const updateReser = async (data) => { try { - const response = await api.post(`/Reser/UpdateReserInfo`, data); + const response = await api.post( + API_ENDPOINTS.routes.RESER_UPDATE_RESER_INFO, + data, + ); return response.data; } catch (error) { throw error; @@ -33,7 +45,10 @@ export const updateReser = async (data) => { // 删除预约 export const deleteReser = async (data) => { try { - const response = await api.post(`/Reser/DeleteReserInfo`, data); + const response = await api.post( + API_ENDPOINTS.routes.RESER_DELETE_RESER_INFO, + data, + ); return response.data; } catch (error) { throw error; @@ -43,7 +58,10 @@ export const deleteReser = async (data) => { // 获取预约类型 export const fetchReserTypes = async (params) => { try { - const response = await api.get("/Reser/SelectReserTypeAll", { params }); + const response = await api.get( + API_ENDPOINTS.routes.RESER_SELECT_RESER_TYPE_ALL, + { params }, + ); return response.data.Data; } catch (error) { throw error; diff --git a/src/api/roleapi.js b/src/api/roleapi.js index 4bf02c285b3b31baa9413ebeaa1f357b8cd8f829..f1821a56bb4b8b6d023204d76e996cd65fab7149 100644 --- a/src/api/roleapi.js +++ b/src/api/roleapi.js @@ -1,9 +1,26 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 获取角色列表 +const buildRoleNumberPayload = (roleNumber) => { + if (roleNumber && typeof roleNumber === "object") { + return { + RoleNumber: + roleNumber.RoleNumber ?? + roleNumber.roleNumber ?? + roleNumber.roleNo ?? + "", + }; + } + + return { RoleNumber: roleNumber }; +}; + export const fetchRoles = async (params) => { try { - const response = await api.get("/Role/SelectRoleList", { params }); + const response = await api.get(API_ENDPOINTS.routes.ROLE_SELECT_ROLE_LIST, { + params, + }); return response.data.Data; } catch (error) { throw error; @@ -13,7 +30,10 @@ export const fetchRoles = async (params) => { // 添加角色 export const addRole = async (data) => { try { - const response = await api.post("/Role/InsertRole", data); + const response = await api.post( + API_ENDPOINTS.routes.ROLE_INSERT_ROLE, + data, + ); return response.data; } catch (error) { throw error; @@ -23,7 +43,10 @@ export const addRole = async (data) => { // 更新角色 export const updateRole = async (data) => { try { - const response = await api.post(`/Role/UpdateRole`, data); + const response = await api.post( + API_ENDPOINTS.routes.ROLE_UPDATE_ROLE, + data, + ); return response.data; } catch (error) { throw error; @@ -33,7 +56,10 @@ export const updateRole = async (data) => { // 删除角色 export const deleteRole = async (data) => { try { - const response = await api.post(`/Role/DeleteRole`, data); + const response = await api.post( + API_ENDPOINTS.routes.ROLE_DELETE_ROLE, + data, + ); return response.data; } catch (error) { throw error; @@ -43,7 +69,10 @@ export const deleteRole = async (data) => { // 角色-权限:全量覆盖式授予 export const grantRolePermissions = async (data) => { try { - const response = await api.post("/Role/GrantRolePermissions", data); + const response = await api.post( + API_ENDPOINTS.routes.ROLE_GRANT_ROLE_PERMISSIONS, + data, + ); return response.data; } catch (error) { throw error; @@ -53,9 +82,10 @@ export const grantRolePermissions = async (data) => { // 读取指定角色已授予的权限(返回后端 Data,页面逻辑做进一步兼容处理) export const readRolePermissions = async (roleNumber) => { try { - const response = await api.get("/Role/ReadRolePermissions", { - params: { roleNumber }, - }); + const response = await api.post( + API_ENDPOINTS.routes.ROLE_READ_ROLE_PERMISSIONS, + buildRoleNumberPayload(roleNumber), + ); // 后端返回标准结构 { Code, Message, Data: { Items, TotalCount } } // 这里直接返回 Items,便于上层直接按照数组处理 return response.data?.Data?.Items || []; @@ -67,9 +97,10 @@ export const readRolePermissions = async (roleNumber) => { // 角色-用户:读取指定角色下的管理员编码集合 export const readRoleUsers = async (roleNumber) => { try { - const response = await api.get("/Role/ReadRoleUsers", { - params: { roleNumber }, - }); + const response = await api.post( + API_ENDPOINTS.routes.ROLE_READ_ROLE_USERS, + buildRoleNumberPayload(roleNumber), + ); // 标准结构 { Code, Message, Data: { Items, TotalCount } } return response.data?.Data?.Items || []; } catch (error) { @@ -80,7 +111,10 @@ export const readRoleUsers = async (roleNumber) => { // 角色-用户:为角色分配管理员(全量覆盖) export const assignRoleUsers = async (data) => { try { - const response = await api.post("/Role/AssignRoleUsers", data); + const response = await api.post( + API_ENDPOINTS.routes.ROLE_ASSIGN_ROLE_USERS, + data, + ); return response.data; } catch (error) { throw error; diff --git a/src/api/roomapi.js b/src/api/roomapi.js index b3e8aef21c676d03dc3cf3d70620fa0a0db26c03..4818ea6ce498715b8365f2acb7f74f98f7ffe170 100644 --- a/src/api/roomapi.js +++ b/src/api/roomapi.js @@ -1,9 +1,12 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 获取房间列表 export const fetchRooms = async (params) => { try { - const response = await api.get("/Room/SelectRoomAll", { params }); + const response = await api.get(API_ENDPOINTS.routes.ROOM_SELECT_ROOM_ALL, { + params, + }); return response.data.Data; } catch (error) { throw error; @@ -13,7 +16,12 @@ export const fetchRooms = async (params) => { // 获取可使用房间列表 export const fetchAvailableRooms = async (params) => { try { - const response = await api.get("/Room/SelectCanUseRoomAll", { params }); + const response = await api.get( + API_ENDPOINTS.routes.ROOM_SELECT_CAN_USE_ROOM_ALL, + { + params, + }, + ); return response.data.Data; } catch (error) { throw error; @@ -23,7 +31,10 @@ export const fetchAvailableRooms = async (params) => { // 添加房间 export const addRoom = async (data) => { try { - const response = await api.post("/Room/InsertRoom", data); + const response = await api.post( + API_ENDPOINTS.routes.ROOM_INSERT_ROOM, + data, + ); return response.data; } catch (error) { throw error; @@ -33,7 +44,10 @@ export const addRoom = async (data) => { // 更新房间 export const updateRoom = async (data) => { try { - const response = await api.post(`/Room/UpdateRoom`, data); + const response = await api.post( + API_ENDPOINTS.routes.ROOM_UPDATE_ROOM, + data, + ); return response.data; } catch (error) { throw error; @@ -43,7 +57,10 @@ export const updateRoom = async (data) => { // 删除房间 export const deleteRoom = async (data) => { try { - const response = await api.post(`/Room/DeleteRoom`, data); + const response = await api.post( + API_ENDPOINTS.routes.ROOM_DELETE_ROOM, + data, + ); return response.data; } catch (error) { throw error; diff --git a/src/api/roomstateapi.js b/src/api/roomstateapi.js index 0bddc3dbe422b2e9eb08c0d6d4ad794fd239cc85..82a300c7bd6e0db863071b24d5b83314d2ca3b62 100644 --- a/src/api/roomstateapi.js +++ b/src/api/roomstateapi.js @@ -1,9 +1,12 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 获取房间状态列表 export const fetchRoomStates = async () => { try { - const response = await api.get("/Base/SelectRoomStateAll"); + const response = await api.get( + API_ENDPOINTS.routes.BASE_SELECT_ROOM_STATE_ALL, + ); return response.data.Data; } catch (error) { throw error; diff --git a/src/api/roomtypeapi.js b/src/api/roomtypeapi.js index 78a435676205ae625589a0defaad9f21cb164df8..68fae87966738caee5d34bc1c6bcca98570efe97 100644 --- a/src/api/roomtypeapi.js +++ b/src/api/roomtypeapi.js @@ -1,9 +1,15 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 获取房间类型列表 export const fetchRoomTypes = async (params) => { try { - const response = await api.get("/RoomType/SelectRoomTypesAll", { params }); + const response = await api.get( + API_ENDPOINTS.routes.ROOM_TYPE_SELECT_ROOM_TYPES_ALL, + { + params, + }, + ); return response.data.Data; } catch (error) { throw error; @@ -13,7 +19,10 @@ export const fetchRoomTypes = async (params) => { // 添加房间类型 export const addRoomType = async (data) => { try { - const response = await api.post("/RoomType/InsertRoomType", data); + const response = await api.post( + API_ENDPOINTS.routes.ROOM_TYPE_INSERT_ROOM_TYPE, + data, + ); return response.data; } catch (error) { throw error; @@ -23,7 +32,10 @@ export const addRoomType = async (data) => { // 更新房间类型 export const updateRoomType = async (data) => { try { - const response = await api.post(`/RoomType/UpdateRoomType`, data); + const response = await api.post( + API_ENDPOINTS.routes.ROOM_TYPE_UPDATE_ROOM_TYPE, + data, + ); return response.data; } catch (error) { throw error; @@ -33,7 +45,10 @@ export const updateRoomType = async (data) => { // 删除房间类型 export const deleteRoomType = async (data) => { try { - const response = await api.post(`/RoomType/DeleteRoomType`, data); + const response = await api.post( + API_ENDPOINTS.routes.ROOM_TYPE_DELETE_ROOM_TYPE, + data, + ); return response.data; } catch (error) { throw error; diff --git a/src/api/spendinfoapi.js b/src/api/spendinfoapi.js index f2424d5e6cf266122d3ce0cb9320bf3aecebc40d..f70d57aa6714d80cb7ad4ba7c63a59670d952572 100644 --- a/src/api/spendinfoapi.js +++ b/src/api/spendinfoapi.js @@ -1,9 +1,15 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 获取消费信息列表 export const fetchSpendInfos = async (params) => { try { - const response = await api.get("/Spend/SelectSpendInfoAll", { params }); + const response = await api.get( + API_ENDPOINTS.routes.SPEND_SELECT_SPEND_INFO_ALL, + { + params, + }, + ); return response.data.Data; } catch (error) { throw error; @@ -13,7 +19,10 @@ export const fetchSpendInfos = async (params) => { // 添加消费信息 export const addSpendInfo = async (data) => { try { - const response = await api.post("/Spend/InsertSpendInfo", data); + const response = await api.post( + API_ENDPOINTS.routes.SPEND_INSERT_SPEND_INFO, + data, + ); return response.data; } catch (error) { throw error; @@ -23,7 +32,10 @@ export const addSpendInfo = async (data) => { // 更新消费信息 export const updateSpendInfo = async (data) => { try { - const response = await api.post(`/Spend/UpdSpendInfo`, data); + const response = await api.post( + API_ENDPOINTS.routes.SPEND_UPD_SPEND_INFO, + data, + ); return response.data; } catch (error) { throw error; diff --git a/src/api/supervisioninfoapi.js b/src/api/supervisioninfoapi.js index b5794b2ab7789ff7a75697cb726ec10c85d0084b..a40412b818840e24898d9baef8e331c5956741e5 100644 --- a/src/api/supervisioninfoapi.js +++ b/src/api/supervisioninfoapi.js @@ -1,10 +1,12 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 获取监管统计列表 export const fetchSupervisionInfos = async (params) => { try { const response = await api.get( - "/SupervisionStatistics/SelectSupervisionStatisticsAll", + API_ENDPOINTS.routes + .SUPERVISION_STATISTICS_SELECT_SUPERVISION_STATISTICS_ALL, { params }, ); return response.data.Data; @@ -17,7 +19,7 @@ export const fetchSupervisionInfos = async (params) => { export const addSupervisionInfo = async (data) => { try { const response = await api.post( - "/SupervisionStatistics/InsertSupervisionStatistics", + API_ENDPOINTS.routes.SUPERVISION_STATISTICS_INSERT_SUPERVISION_STATISTICS, data, ); return response.data; @@ -30,7 +32,7 @@ export const addSupervisionInfo = async (data) => { export const updateSupervisionInfo = async (data) => { try { const response = await api.post( - `/SupervisionStatistics/UpdateSupervisionStatistics`, + API_ENDPOINTS.routes.SUPERVISION_STATISTICS_UPDATE_SUPERVISION_STATISTICS, data, ); return response.data; @@ -43,7 +45,7 @@ export const updateSupervisionInfo = async (data) => { export const deleteSupervisionInfo = async (data) => { try { const response = await api.post( - `/SupervisionStatistics/DeleteSupervisionStatistics`, + API_ENDPOINTS.routes.SUPERVISION_STATISTICS_DELETE_SUPERVISION_STATISTICS, data, ); return response.data; diff --git a/src/api/utilityapi.js b/src/api/utilityapi.js index 526143818c86a380740993536a67c8b354bba6bb..3f3df4f2669dec8bc13aa72f6ff9db30551eb7f5 100644 --- a/src/api/utilityapi.js +++ b/src/api/utilityapi.js @@ -1,11 +1,13 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 获取操作日志列表 export const fetchOperationlogs = async (params) => { try { - const response = await api.get("/Utility/SelectOperationlogAll", { - params, - }); + const response = await api.get( + API_ENDPOINTS.routes.UTILITY_SELECT_OPERATIONLOG_ALL, + { params }, + ); return response.data.Data; } catch (error) { throw error; @@ -15,7 +17,10 @@ export const fetchOperationlogs = async (params) => { // 获取请求日志列表 export const fetchRequestlogs = async (params) => { try { - const response = await api.get("/Utility/SelectRequestlogAll", { params }); + const response = await api.get( + API_ENDPOINTS.routes.UTILITY_SELECT_REQUESTLOG_ALL, + { params }, + ); return response.data.Data; } catch (error) { throw error; @@ -25,7 +30,10 @@ export const fetchRequestlogs = async (params) => { // 获取身份证地区代码 export const fetchCardCode = async (cardcode) => { try { - const response = await api.post("/Utility/SelectCardCode", cardcode); + const response = await api.post( + API_ENDPOINTS.routes.UTILITY_SELECT_CARD_CODE, + cardcode, + ); return response.data; } catch (error) { throw error; @@ -36,7 +44,7 @@ export const fetchCardCode = async (cardcode) => { export const deleteOperationlogByRange = async (operationlog) => { try { const response = await api.post( - "/Utility/DeleteOperationlogByRange", + API_ENDPOINTS.routes.UTILITY_DELETE_OPERATIONLOG_BY_RANGE, operationlog, ); return response.data; @@ -49,7 +57,7 @@ export const deleteOperationlogByRange = async (operationlog) => { export const deleteOperationlog = async (operationlog) => { try { const response = await api.post( - "/Utility/DeleteOperationlog", + API_ENDPOINTS.routes.UTILITY_DELETE_OPERATIONLOG, operationlog, ); return response.data; diff --git a/src/api/vipruleapi.js b/src/api/vipruleapi.js index d96e262590d543d915a2da7d5e39df324b3243d4..8a072aebeca5447de53161d629d56fe4f734eee7 100644 --- a/src/api/vipruleapi.js +++ b/src/api/vipruleapi.js @@ -1,9 +1,15 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 获取会员规则列表 export const fetchVipRules = async (params) => { try { - const response = await api.get("/VipRule/SelectVipRuleList", { params }); + const response = await api.get( + API_ENDPOINTS.routes.VIP_RULE_SELECT_VIP_RULE_LIST, + { + params, + }, + ); return response.data.Data; } catch (error) { throw error; @@ -13,7 +19,10 @@ export const fetchVipRules = async (params) => { // 添加会员规则 export const addVipRule = async (data) => { try { - const response = await api.post("/VipRule/AddVipRule", data); + const response = await api.post( + API_ENDPOINTS.routes.VIP_RULE_ADD_VIP_RULE, + data, + ); return response.data; } catch (error) { throw error; @@ -23,7 +32,10 @@ export const addVipRule = async (data) => { // 更新会员规则 export const updateVipRule = async (data) => { try { - const response = await api.post(`/VipRule/UpdVipRule`, data); + const response = await api.post( + API_ENDPOINTS.routes.VIP_RULE_UPD_VIP_RULE, + data, + ); return response.data; } catch (error) { throw error; @@ -33,7 +45,10 @@ export const updateVipRule = async (data) => { // 删除会员规则 export const deleteVipRule = async (data) => { try { - const response = await api.post(`/VipRule/DelVipRule`, data); + const response = await api.post( + API_ENDPOINTS.routes.VIP_RULE_DEL_VIP_RULE, + data, + ); return response.data; } catch (error) { throw error; diff --git a/src/api/workerfeatureapi.js b/src/api/workerfeatureapi.js index dd2ecece4f57ee5fdc8329e6b48a64dd2a266aaa..d75993cd2ab531c2e16d3fe36bb30901944fbcfd 100644 --- a/src/api/workerfeatureapi.js +++ b/src/api/workerfeatureapi.js @@ -1,9 +1,13 @@ import api from "../api"; +import { API_ENDPOINTS } from "../config/apiEndpoints"; // 获取面貌列表 export const fetchWorkerFeatures = async (params) => { try { - const response = await api.get("/Base/SelectWorkerFeatureAll", { params }); + const response = await api.get( + API_ENDPOINTS.routes.BASE_SELECT_WORKER_FEATURE_ALL, + { params }, + ); return response.data.Data; } catch (error) { throw error; diff --git a/src/common/errorCodes.js b/src/common/errorCodes.js new file mode 100644 index 0000000000000000000000000000000000000000..64863ba3f387126c36eb480975589c1e770a61bf --- /dev/null +++ b/src/common/errorCodes.js @@ -0,0 +1,39 @@ +export const API_SUCCESS_CODE = 0; + +export const HTTP_STATUS = Object.freeze({ + UNAUTHORIZED: 401, + FORBIDDEN: 403, +}); + +export const ERROR_CODES = Object.freeze({ + AUTH_TOKEN_EXPIRED: 1401, + AUTH_INVALID_CREDENTIAL: 1402, + AUTH_PERMISSION_DENIED: 1403, + RESOURCE_NOT_FOUND: 1404, + CONCURRENCY_CONFLICT: 1409, + VALIDATION_FAILED: 2001, + DUPLICATE_DATA: 3001, + SERVER_ERROR: 1500, + NETWORK_ERROR: 9001, + NETWORK_TIMEOUT: 9002, +}); + +export const normalizeCode = (value) => { + const code = Number(value); + return Number.isFinite(code) ? code : 0; +}; + +export const isTokenInvalidBusinessCode = (code) => + normalizeCode(code) === ERROR_CODES.AUTH_TOKEN_EXPIRED; + +export const isPermissionDeniedBusinessCode = (code) => { + const normalized = normalizeCode(code); + return ( + normalized === ERROR_CODES.AUTH_INVALID_CREDENTIAL || + normalized === ERROR_CODES.AUTH_PERMISSION_DENIED + ); +}; + +export const isPermissionDeniedErrorCode = (code) => + isPermissionDeniedBusinessCode(code) || + normalizeCode(code) === HTTP_STATUS.FORBIDDEN; diff --git a/src/common/errorHandler.js b/src/common/errorHandler.js index 8b43ae2076131879af914aaada8877ab2fc17390..019ec5ba82d71d94a4436dc0acb9b7f0cbdca591 100644 --- a/src/common/errorHandler.js +++ b/src/common/errorHandler.js @@ -1,53 +1,89 @@ import i18n from "@/i18n"; import { showErrorNotification, showWarningNotification } from "@/utils"; import { redirectToLogin, handleLogout } from "@/utils/auth"; +import { getStoredToken } from "@/utils/tokenStorage"; +import { + ERROR_CODES, + HTTP_STATUS, + isTokenInvalidBusinessCode, +} from "@/common/errorCodes"; // 可扩展的错误码映射表 +const DEFAULT_API_ERROR_MESSAGE = i18n.global.t("message.requestFailed"); + +const readApiMessage = (payload) => { + if (!payload || typeof payload !== "object") { + return ""; + } + return payload.Message || payload.message || ""; +}; + +export const resolveErrorMessage = (payload, fallbackMessage) => { + const directMessage = readApiMessage(payload); + if (directMessage && directMessage !== DEFAULT_API_ERROR_MESSAGE) { + return directMessage; + } + + const nestedMessage = readApiMessage(payload?.response?.data); + if (nestedMessage && nestedMessage !== DEFAULT_API_ERROR_MESSAGE) { + return nestedMessage; + } + + const localizedFallback = + fallbackMessage || i18n.global.t("message.requestFailed"); + return localizedFallback || DEFAULT_API_ERROR_MESSAGE; +}; + export const errorCodeMap = { // 认证/授权相关 - 1401: { + [ERROR_CODES.AUTH_TOKEN_EXPIRED]: { // token 失效:通常需要用户重新登录 message: i18n.global.t("message.loginExpired") || "登录已过期,请重新登录", action: "redirect", clearStorage: true, }, - 1402: { + [ERROR_CODES.AUTH_INVALID_CREDENTIAL]: { // 凭证无效(示例) - message: - i18n.global.t("message.invalidCredentials") || "凭证无效,请重新登录", + message: i18n.global.t("message.noPermission") || "无权限执行该操作", action: "notify", }, - 1403: { + [ERROR_CODES.AUTH_PERMISSION_DENIED]: { // 权限不足 message: i18n.global.t("message.noPermission") || "无权限执行该操作", action: "notify", }, // 资源/数据相关 - 1404: { + [ERROR_CODES.RESOURCE_NOT_FOUND]: { // 资源未找到 message: i18n.global.t("message.notFound") || "未找到相关资源", action: "notify", }, - 2001: { + [ERROR_CODES.CONCURRENCY_CONFLICT]: { + message: + i18n.global.t("message.concurrencyConflict") || + "数据已被其他用户修改,请刷新后重试", + action: "notify", + }, + [ERROR_CODES.VALIDATION_FAILED]: { // 参数/校验错误 message: i18n.global.t("message.validationFailed") || "参数校验失败,请检查输入", action: "notify", }, - 3001: { + [ERROR_CODES.DUPLICATE_DATA]: { // 数据已存在/重复 message: i18n.global.t("message.duplicate") || "数据已存在,无法重复创建", action: "notify", }, // 服务端/网络 - 1500: { + [ERROR_CODES.SERVER_ERROR]: { message: i18n.global.t("message.serverError") || "服务器内部错误,请稍后重试", action: "notify", }, - 9001: { + [ERROR_CODES.NETWORK_ERROR]: { // 网络/连接异常(客户端定义) message: i18n.global.t("message.networkError") || "网络连接异常,请检查网络", @@ -68,16 +104,16 @@ export function handleApiError(resData, response) { // Token 相关优先处理,保留重试逻辑:当后端返回 1401 且本地还有 token 时,触发重试机制 if ( - code === 1401 || + isTokenInvalidBusinessCode(code) || (typeof message === "string" && message.includes("Token")) ) { - const token = localStorage.getItem("token"); + const token = getStoredToken(); if (!token) { // 没有 token,直接跳转登录并显示后端消息(如果有) - redirectToLogin(backendMessage || i18n.global.t("message.loginExpired") ); + redirectToLogin(backendMessage || i18n.global.t("message.loginExpired")); return Promise.reject({ success: false, - code: 1401, + code: ERROR_CODES.AUTH_TOKEN_EXPIRED, message: backendMessage || i18n.global.t("message.loginExpired"), }); } @@ -151,20 +187,20 @@ export function handleHttpError(error) { // 忽略解析错误,继续执行后续的 HTTP 层处理 } - if (status === 401) { + if (status === HTTP_STATUS.UNAUTHORIZED) { redirectToLogin(i18n.global.t("message.loginExpired"), true); return Promise.reject({ success: false, - code: 401, + code: HTTP_STATUS.UNAUTHORIZED, message: i18n.global.t("message.loginExpired"), }); } - if (status === 403) { + if (status === HTTP_STATUS.FORBIDDEN) { showErrorNotification(i18n.global.t("message.noPermission") || "无权限"); return Promise.reject({ success: false, - code: 403, + code: HTTP_STATUS.FORBIDDEN, message: i18n.global.t("message.noPermission"), }); } @@ -200,7 +236,7 @@ export function handleHttpError(error) { showErrorNotification(displayMessage); return Promise.reject({ success: false, - code: isTimeout ? 9002 : 9001, + code: isTimeout ? ERROR_CODES.NETWORK_TIMEOUT : ERROR_CODES.NETWORK_ERROR, message: displayMessage, }); } diff --git a/src/common/permissioncode.js b/src/common/permissioncode.js index 205e26527c4c84249e68869e12f43a9da70d3e80..1190e12e2d60cc4724d54f34e66b0adc3f727cac 100644 --- a/src/common/permissioncode.js +++ b/src/common/permissioncode.js @@ -49,57 +49,66 @@ export const NoticeTypePermissions = { // 推广内容 (Promotion Content Management) export const PromotionContentPermissions = { - CREATE: "promotioncontent.create", - UPDATE: "promotioncontent.update", - DELETE: "promotioncontent.delete", + CREATE: "promotioncontent.apc", + UPDATE: "promotioncontent.upc", + DELETE: "promotioncontent.dpc", }; // ==================== 系统管理模块 ==================== // 管理员类型 (Administrator Type Management) export const AdminTypeManagementPermissions = { - CREATE: "admintypemanagement.create", - UPDATE: "admintypemanagement.update", - DELETE: "admintypemanagement.delete", + CREATE: "system:admintype:aat", + UPDATE: "system:admintype:uat", + DELETE: "system:admintype:dat", }; // 管理员管理 (Administrator Management) export const AdministratorManagementPermissions = { - CREATE: "administratormanagement.create", - UPDATE: "administratormanagement.update", - DELETE: "administratormanagement.delete", - ASSIGN_VIEW: "system:user:assign.view", + CREATE: "system:admin:addadmin", + UPDATE: "system:admin:updadmin", + DELETE: "system:admin:deladmin", + ASSIGN_VIEW: "system:user:assign.selectpermissionlist", + GET_TWO_FACTOR: "system:admin:gtfs", + GENERATE_TWO_FACTOR: "system:admin:gtfsu", + ENABLE_TWO_FACTOR: "system:admin:etf", + DISABLE_TWO_FACTOR: "system:admin:dtf", + RECOVERY_TWO_FACTOR: "system:admin:rtfrc", }; // 角色管理 (Role Management) export const RoleManagementPermissions = { - CREATE: "rolemanagement.create", - UPDATE: "rolemanagement.update", - DELETE: "rolemanagement.delete", - GRANT: "rolemanagement.grant", - SYSTEM_ROLE_GRANT: "system:role:grant", + CREATE: "system:role:insertrole", + UPDATE: "system:role:updaterole", + DELETE: "system:role:deleterole", + GRANT: "system:role:grp", + SYSTEM_ROLE_GRANT: "system:role:aru", }; // 菜单管理 (Menu Management) export const MenuManagementPermissions = { - CREATE: "menumanagement.create", - UPDATE: "menumanagement.update", - DELETE: "menumanagement.delete", + CREATE: "menumanagement.insertmenu", + UPDATE: "menumanagement.updatemenu", + DELETE: "menumanagement.deletemenu", }; // 账户权限管理 (Account Permission Management) export const AccountPermissionPermissions = { - ASSIGN: "system:user:assign", + ASSIGN: [ + "system:user:admin:aup", + "system:user:employee:aup", + "system:user:customer:aup", + ], }; // ==================== 客户管理模块 ==================== // 客户管理 (Customer Management) export const CustomerPermissions = { - CREATE: "customer.create", - UPDATE: "customer.update", - DELETE: "customer.delete", - ASSIGN_VIEW: "system:user:assign.view", + CREATE: "customer.ici", + UPDATE: "customer.uci", + DELETE: "customer.dci", + ASSIGN_VIEW: "system:user:assign.selectpermissionlist", }; // 客户类型 (Customer Type Management) @@ -111,85 +120,90 @@ export const CustomerTypePermissions = { // 客户消费 (Customer Spend Management) export const CustomerSpendPermissions = { - UPDATE: "customerspend.update", + UPDATE: "customerspend.usi", }; // VIP等级 (VIP Level Management) export const VipLevelPermissions = { - CREATE: "viplevel.create", - UPDATE: "viplevel.update", - DELETE: "viplevel.delete", + CREATE: "viplevel.addviprule", + UPDATE: "viplevel.updviprule", + DELETE: "viplevel.delviprule", }; // ==================== 人力资源管理模块 ==================== // 员工管理 (Staff Management) export const StaffManagementPermissions = { - CREATE: "staffmanagement.create", - UPDATE: "staffmanagement.update", - DELETE: "staffmanagement.delete", - VIEW: "staffmanagement.view", - STATUS: "staffmanagement.status", - RESET: "staffmanagement.reset", - ASSIGN_VIEW: "system:user:assign.view", + CREATE: "staffmanagement.ae", + UPDATE: "staffmanagement.ue", + DELETE: "staffmanagement.dwp", + VIEW: "staffmanagement.seibei", + STATUS: "staffmanagement.mea", + RESET: "staffmanagement.reap", + ASSIGN_VIEW: "system:user:assign.selectpermissionlist", + GET_TWO_FACTOR: "staffmanagement.gtfse", + GENERATE_TWO_FACTOR: "staffmanagement.gtfs", + ENABLE_TWO_FACTOR: "staffmanagement.etf", + DISABLE_TWO_FACTOR: "staffmanagement.dtf", + RECOVERY_TWO_FACTOR: "staffmanagement.rtfrc", }; // ==================== 房间信息管理模块 ==================== // 房间管理 (Room Management) export const RoomManagementPermissions = { - CREATE: "roommanagement.create", - UPDATE: "roommanagement.update", - DELETE: "roommanagement.delete", + CREATE: "roommanagement.insertroom", + UPDATE: "roommanagement.updateroom", + DELETE: "roommanagement.deleteroom", }; // 房间配置 (Room Config Management) export const RoomConfigPermissions = { - CREATE: "roomconfig.create", - UPDATE: "roomconfig.update", - DELETE: "roomconfig.delete", + CREATE: "roomconfig.irt", + UPDATE: "roomconfig.urt", + DELETE: "roomconfig.drt", }; // 预约管理 (Reservation Management) export const ReservationManagementPermissions = { - CREATE: "resermanagement.create", - UPDATE: "resermanagement.update", - DELETE: "resermanagement.delete", + CREATE: "resermanagement.iri", + UPDATE: "resermanagement.uri", + DELETE: "resermanagement.dri", }; // ==================== 物料管理模块 ==================== // 商品管理 (Goods Management) export const GoodsManagementPermissions = { - CREATE: "goodsmanagement.create", - UPDATE: "goodsmanagement.update", - DELETE: "goodsmanagement.delete", + CREATE: "goodsmanagement.ist", + UPDATE: "goodsmanagement.ust", + DELETE: "goodsmanagement.dst", }; // ==================== 财务管理模块 ==================== // 内部财务 (Internal Finance Management) export const InternalFinancePermissions = { - CREATE: "internalfinance.create", - UPDATE: "internalfinance.update", - DELETE: "internalfinance.delete", + CREATE: "internalfinance.aai", + UPDATE: "internalfinance.uai", + DELETE: "internalfinance.dai", }; // ==================== 监督管理模块 ==================== // 监督信息 (Supervision Information) export const SupervisionPermissions = { - CREATE: "supervisioninfo.create", - UPDATE: "supervisioninfo.update", - DELETE: "supervisioninfo.delete", + CREATE: "supervisioninfo.iss", + UPDATE: "supervisioninfo.uss", + DELETE: "supervisioninfo.dss", }; // ==================== 水电管理模块 ==================== // 水电信息 (Hydroelectricity Information) export const HydroelectricityPermissions = { - UPDATE: "hydroelectricinformation.update", - DELETE: "hydroelectricinformation.delete", + UPDATE: "hydroelectricinformation.uemi", + DELETE: "hydroelectricinformation.demi", }; // ==================== 系统行为管理模块 ==================== diff --git a/src/config/apiEndpoints.js b/src/config/apiEndpoints.js new file mode 100644 index 0000000000000000000000000000000000000000..3f6677e8a3b8f0bacd9b3bf362965ef82bd3824a --- /dev/null +++ b/src/config/apiEndpoints.js @@ -0,0 +1,117 @@ +import { FLAT_API_ROUTES } from "./apiRoutes"; + +const ROOT_ROUTE_OVERRIDES = { + VERSION: { group: "SYSTEM", name: "VERSION" }, +}; + +const toSnakeUpper = (value) => + String(value || "") + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/[^A-Za-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .toUpperCase(); + +const getRouteMeta = (flatKey, path) => { + const override = ROOT_ROUTE_OVERRIDES[flatKey]; + if (override) { + return override; + } + + const segments = String(path || "") + .split("/") + .filter(Boolean); + const controller = segments[0]; + const group = controller ? toSnakeUpper(controller) : "ROOT"; + const groupPrefix = `${group}_`; + + if (flatKey.startsWith(groupPrefix)) { + return { group, name: flatKey.slice(groupPrefix.length) }; + } + + const action = segments[1]; + return { + group, + name: action ? toSnakeUpper(action) : flatKey, + }; +}; + +const validateFlatRoutes = (flatRoutes) => { + const routeByPath = new Map(); + + for (const [flatKey, path] of Object.entries(flatRoutes)) { + if (typeof path !== "string" || !path.startsWith("/")) { + throw new Error(`Invalid API route path for ${flatKey}: ${path}`); + } + + const existingKey = routeByPath.get(path); + if (existingKey && existingKey !== flatKey) { + throw new Error( + `Duplicate API route path detected: ${path} (${existingKey}, ${flatKey})`, + ); + } + + routeByPath.set(path, flatKey); + } +}; + +const buildGroupedRoutes = (flatRoutes) => { + const grouped = {}; + + for (const [flatKey, path] of Object.entries(flatRoutes)) { + const { group, name } = getRouteMeta(flatKey, path); + grouped[group] ||= {}; + + const existingPath = grouped[group][name]; + if (existingPath && existingPath !== path) { + throw new Error( + `Duplicate API route key detected: ${group}.${name} (${existingPath}, ${path})`, + ); + } + + grouped[group][name] = path; + } + + return grouped; +}; + +validateFlatRoutes(FLAT_API_ROUTES); + +const groupedRoutes = buildGroupedRoutes(FLAT_API_ROUTES); + +export const ROUTES = Object.freeze( + Object.fromEntries( + Object.entries(groupedRoutes).map(([group, routes]) => [ + group, + Object.freeze({ ...routes }), + ]), + ), +); + +export const LEGACY_ROUTES = Object.freeze({ ...FLAT_API_ROUTES }); + +export const API_ROUTE_KEYS = Object.freeze(Object.keys(LEGACY_ROUTES)); + +export const API_ENDPOINTS = { + routes: Object.freeze({ ...ROUTES, ...LEGACY_ROUTES }), + login: { + admin: ROUTES.ADMIN.LOGIN, + employee: ROUTES.EMPLOYEE.EMPLOYEE_LOGIN, + }, + twoFactor: { + admin: "Admin", + employee: "Employee", + }, + loginTypes: { + admin: "admin", + employee: "employee", + }, + dynamic: { + getTwoFactorStatus: (controller) => `/${controller}/GetTwoFactorStatus`, + generateTwoFactorSetup: (controller) => + `/${controller}/GenerateTwoFactorSetup`, + enableTwoFactor: (controller) => `/${controller}/EnableTwoFactor`, + disableTwoFactor: (controller) => `/${controller}/DisableTwoFactor`, + regenerateTwoFactorRecoveryCodes: (controller) => + `/${controller}/RegenerateTwoFactorRecoveryCodes`, + }, +}; diff --git a/src/config/apiRoutes.js b/src/config/apiRoutes.js new file mode 100644 index 0000000000000000000000000000000000000000..15dbe5ca62bfc230452faca29f6e556375aae08a --- /dev/null +++ b/src/config/apiRoutes.js @@ -0,0 +1,168 @@ +// Security baseline: +// - Keep only endpoints actually used by the current frontend. +// - Do not expose a full backend endpoint catalog in client bundles. +export const FLAT_API_ROUTES = Object.freeze({ + ADMIN_ADD_ADMIN: "/Admin/AddAdmin", + ADMIN_ADD_ADMIN_TYPE: "/Admin/AddAdminType", + ADMIN_ASSIGN_USER_PERMISSIONS: "/Admin/AssignUserPermissions", + ADMIN_ASSIGN_USER_ROLES: "/Admin/AssignUserRoles", + ADMIN_DEL_ADMIN: "/Admin/DelAdmin", + ADMIN_DEL_ADMIN_TYPE: "/Admin/DelAdminType", + ADMIN_GET_ALL_ADMIN_LIST: "/Admin/GetAllAdminList", + ADMIN_GET_ALL_ADMIN_TYPES: "/Admin/GetAllAdminTypes", + ADMIN_LOGIN: "/Admin/Login", + LOGIN_LOGOUT: "/Login/Logout", + ADMIN_READ_USER_DIRECT_PERMISSIONS: "/Admin/ReadUserDirectPermissions", + ADMIN_READ_USER_ROLE_PERMISSIONS: "/Admin/ReadUserRolePermissions", + ADMIN_READ_USER_ROLES: "/Admin/ReadUserRoles", + ADMIN_UPD_ADMIN: "/Admin/UpdAdmin", + ADMIN_UPD_ADMIN_TYPE: "/Admin/UpdAdminType", + ASSET_ADD_ASSET_INFO: "/Asset/AddAssetInfo", + ASSET_DEL_ASSET_INFO: "/Asset/DelAssetInfo", + ASSET_SELECT_ASSET_INFO_ALL: "/Asset/SelectAssetInfoAll", + ASSET_UPD_ASSET_INFO: "/Asset/UpdAssetInfo", + BASE_ADD_DEPT: "/Base/AddDept", + BASE_ADD_EDUCATION: "/Base/AddEducation", + BASE_ADD_NATION: "/Base/AddNation", + BASE_ADD_POSITION: "/Base/AddPosition", + BASE_CREATE_APPOINTMENT_NOTICE_TYPE: "/Base/CreateAppointmentNoticeType", + BASE_DEL_DEPT: "/Base/DelDept", + BASE_DEL_EDUCATION: "/Base/DelEducation", + BASE_DEL_NATION: "/Base/DelNation", + BASE_DEL_POSITION: "/Base/DelPosition", + BASE_DELETE_APPOINTMENT_NOTICE_TYPE: "/Base/DeleteAppointmentNoticeType", + BASE_DELETE_CUSTO_TYPE: "/Base/DeleteCustoType", + BASE_DELETE_PASS_PORT_TYPE: "/Base/DeletePassPortType", + BASE_INSERT_CUSTO_TYPE: "/Base/InsertCustoType", + BASE_INSERT_PASS_PORT_TYPE: "/Base/InsertPassPortType", + BASE_SELECT_APPOINTMENT_NOTICE_TYPE_ALL: + "/Base/SelectAppointmentNoticeTypeAll", + BASE_SELECT_CUSTO_TYPE_ALL: "/Base/SelectCustoTypeAll", + BASE_SELECT_CUSTO_TYPE_ALL_CAN_USE: "/Base/SelectCustoTypeAllCanUse", + BASE_SELECT_DEPT_ALL: "/Base/SelectDeptAll", + BASE_SELECT_EDUCATION_ALL: "/Base/SelectEducationAll", + BASE_SELECT_NATION_ALL: "/Base/SelectNationAll", + BASE_SELECT_PASS_PORT_TYPE_ALL: "/Base/SelectPassPortTypeAll", + BASE_SELECT_PASS_PORT_TYPE_ALL_CAN_USE: "/Base/SelectPassPortTypeAllCanUse", + BASE_SELECT_POSITION_ALL: "/Base/SelectPositionAll", + BASE_SELECT_ROOM_STATE_ALL: "/Base/SelectRoomStateAll", + BASE_SELECT_WORKER_FEATURE_ALL: "/Base/SelectWorkerFeatureAll", + BASE_UPD_DEPT: "/Base/UpdDept", + BASE_UPD_EDUCATION: "/Base/UpdEducation", + BASE_UPD_NATION: "/Base/UpdNation", + BASE_UPD_POSITION: "/Base/UpdPosition", + BASE_UPDATE_APPOINTMENT_NOTICE_TYPE: "/Base/UpdateAppointmentNoticeType", + BASE_UPDATE_CUSTO_TYPE: "/Base/UpdateCustoType", + BASE_UPDATE_PASS_PORT_TYPE: "/Base/UpdatePassPortType", + CUSTOMER_DEL_CUSTOMER_INFO: "/Customer/DelCustomerInfo", + CUSTOMER_INSERT_CUSTOMER_INFO: "/Customer/InsertCustomerInfo", + CUSTOMER_PERMISSION_ASSIGN_USER_PERMISSIONS: + "/CustomerPermission/AssignUserPermissions", + CUSTOMER_PERMISSION_ASSIGN_USER_ROLES: "/CustomerPermission/AssignUserRoles", + CUSTOMER_PERMISSION_READ_USER_DIRECT_PERMISSIONS: + "/CustomerPermission/ReadUserDirectPermissions", + CUSTOMER_PERMISSION_READ_USER_ROLE_PERMISSIONS: + "/CustomerPermission/ReadUserRolePermissions", + CUSTOMER_PERMISSION_READ_USER_ROLES: "/CustomerPermission/ReadUserRoles", + CUSTOMER_SELECT_CUSTOMERS: "/Customer/SelectCustomers", + CUSTOMER_UPD_CUSTOMER_INFO: "/Customer/UpdCustomerInfo", + DASHBOARD_BUSINESS_STATISTICS: "/Dashboard/BusinessStatistics", + DASHBOARD_HUMAN_RESOURCES_STATISTICS: "/Dashboard/HumanResourcesStatistics", + DASHBOARD_LOGISTICS_STATISTICS: "/Dashboard/LogisticsStatistics", + DASHBOARD_ROOM_STATISTICS: "/Dashboard/RoomStatistics", + EMPLOYEE_ADD_EMPLOYEE: "/Employee/AddEmployee", + EMPLOYEE_CHECK_SELECT_CHECK_INFO_BY_EMPLOYEE_ID: + "/EmployeeCheck/SelectCheckInfoByEmployeeId", + EMPLOYEE_EMPLOYEE_LOGIN: "/Employee/EmployeeLogin", + EMPLOYEE_HISTORY_SELECT_HISTORY_BY_EMPLOYEE_ID: + "/EmployeeHistory/SelectHistoryByEmployeeId", + EMPLOYEE_MANAGER_EMPLOYEE_ACCOUNT: "/Employee/ManagerEmployeeAccount", + EMPLOYEE_PERMISSION_ASSIGN_USER_PERMISSIONS: + "/EmployeePermission/AssignUserPermissions", + EMPLOYEE_PERMISSION_ASSIGN_USER_ROLES: "/EmployeePermission/AssignUserRoles", + EMPLOYEE_PERMISSION_READ_USER_DIRECT_PERMISSIONS: + "/EmployeePermission/ReadUserDirectPermissions", + EMPLOYEE_PERMISSION_READ_USER_ROLE_PERMISSIONS: + "/EmployeePermission/ReadUserRolePermissions", + EMPLOYEE_PERMISSION_READ_USER_ROLES: "/EmployeePermission/ReadUserRoles", + EMPLOYEE_PHOTO_INSERT_WORKER_PHOTO: "/EmployeePhoto/InsertWorkerPhoto", + EMPLOYEE_RESET_EMPLOYEE_ACCOUNT_PASSWORD: + "/Employee/ResetEmployeeAccountPassword", + EMPLOYEE_SELECT_EMPLOYEE_ALL: "/Employee/SelectEmployeeAll", + EMPLOYEE_SELECT_EMPLOYEE_INFO_BY_EMPLOYEE_ID: + "/Employee/SelectEmployeeInfoByEmployeeId", + EMPLOYEE_UPDATE_EMPLOYEE: "/Employee/UpdateEmployee", + ENERGY_MANAGEMENT_DELETE_ENERGY_MANAGEMENT_INFO: + "/EnergyManagement/DeleteEnergyManagementInfo", + ENERGY_MANAGEMENT_INSERT_ENERGY_MANAGEMENT_INFO: + "/EnergyManagement/InsertEnergyManagementInfo", + ENERGY_MANAGEMENT_SELECT_ENERGY_MANAGEMENT_INFO: + "/EnergyManagement/SelectEnergyManagementInfo", + ENERGY_MANAGEMENT_UPDATE_ENERGY_MANAGEMENT_INFO: + "/EnergyManagement/UpdateEnergyManagementInfo", + LOGIN_GET_CSRF_TOKEN: "/Login/GetCSRFToken", + LOGIN_REFRESH_CSRF_TOKEN: "/Login/RefreshCSRFToken", + MENU_BUILD_MENU_ALL: "/Menu/BuildMenuAll", + MENU_DELETE_MENU: "/Menu/DeleteMenu", + MENU_INSERT_MENU: "/Menu/InsertMenu", + MENU_SELECT_MENU_ALL: "/Menu/SelectMenuAll", + MENU_UPDATE_MENU: "/Menu/UpdateMenu", + PERMISSION_SELECT_PERMISSION_LIST: "/Permission/SelectPermissionList", + PROMOTION_CONTENT_ADD_PROMOTION_CONTENT: + "/PromotionContent/AddPromotionContent", + PROMOTION_CONTENT_DELETE_PROMOTION_CONTENT: + "/PromotionContent/DeletePromotionContent", + PROMOTION_CONTENT_SELECT_PROMOTION_CONTENT_ALL: + "/PromotionContent/SelectPromotionContentAll", + PROMOTION_CONTENT_UPDATE_PROMOTION_CONTENT: + "/PromotionContent/UpdatePromotionContent", + RESER_DELETE_RESER_INFO: "/Reser/DeleteReserInfo", + RESER_INSER_RESER_INFO: "/Reser/InserReserInfo", + RESER_SELECT_RESER_ALL: "/Reser/SelectReserAll", + RESER_SELECT_RESER_TYPE_ALL: "/Reser/SelectReserTypeAll", + RESER_UPDATE_RESER_INFO: "/Reser/UpdateReserInfo", + REWARD_PUNISHMENT_SELECT_ALL_REWARD_PUNISHMENT_BY_EMPLOYEE_ID: + "/RewardPunishment/SelectAllRewardPunishmentByEmployeeId", + ROLE_ASSIGN_ROLE_USERS: "/Role/AssignRoleUsers", + ROLE_DELETE_ROLE: "/Role/DeleteRole", + ROLE_GRANT_ROLE_PERMISSIONS: "/Role/GrantRolePermissions", + ROLE_INSERT_ROLE: "/Role/InsertRole", + ROLE_READ_ROLE_PERMISSIONS: "/Role/ReadRolePermissions", + ROLE_READ_ROLE_USERS: "/Role/ReadRoleUsers", + ROLE_SELECT_ROLE_LIST: "/Role/SelectRoleList", + ROLE_UPDATE_ROLE: "/Role/UpdateRole", + ROOM_DELETE_ROOM: "/Room/DeleteRoom", + ROOM_INSERT_ROOM: "/Room/InsertRoom", + ROOM_SELECT_CAN_USE_ROOM_ALL: "/Room/SelectCanUseRoomAll", + ROOM_SELECT_ROOM_ALL: "/Room/SelectRoomAll", + ROOM_TYPE_DELETE_ROOM_TYPE: "/RoomType/DeleteRoomType", + ROOM_TYPE_INSERT_ROOM_TYPE: "/RoomType/InsertRoomType", + ROOM_TYPE_SELECT_ROOM_TYPES_ALL: "/RoomType/SelectRoomTypesAll", + ROOM_TYPE_UPDATE_ROOM_TYPE: "/RoomType/UpdateRoomType", + ROOM_UPDATE_ROOM: "/Room/UpdateRoom", + SELLTHING_DELETE_SELLTHING: "/Sellthing/DeleteSellthing", + SELLTHING_INSERT_SELLTHING: "/Sellthing/InsertSellthing", + SELLTHING_SELECT_SELLTHING_ALL: "/Sellthing/SelectSellthingAll", + SELLTHING_UPDATE_SELLTHING: "/Sellthing/UpdateSellthing", + SPEND_INSERT_SPEND_INFO: "/Spend/InsertSpendInfo", + SPEND_SELECT_SPEND_INFO_ALL: "/Spend/SelectSpendInfoAll", + SPEND_UPD_SPEND_INFO: "/Spend/UpdSpendInfo", + SUPERVISION_STATISTICS_DELETE_SUPERVISION_STATISTICS: + "/SupervisionStatistics/DeleteSupervisionStatistics", + SUPERVISION_STATISTICS_INSERT_SUPERVISION_STATISTICS: + "/SupervisionStatistics/InsertSupervisionStatistics", + SUPERVISION_STATISTICS_SELECT_SUPERVISION_STATISTICS_ALL: + "/SupervisionStatistics/SelectSupervisionStatisticsAll", + SUPERVISION_STATISTICS_UPDATE_SUPERVISION_STATISTICS: + "/SupervisionStatistics/UpdateSupervisionStatistics", + UTILITY_DELETE_OPERATIONLOG: "/Utility/DeleteOperationlog", + UTILITY_DELETE_OPERATIONLOG_BY_RANGE: "/Utility/DeleteOperationlogByRange", + UTILITY_SELECT_CARD_CODE: "/Utility/SelectCardCode", + UTILITY_SELECT_OPERATIONLOG_ALL: "/Utility/SelectOperationlogAll", + UTILITY_SELECT_REQUESTLOG_ALL: "/Utility/SelectRequestlogAll", + VERSION: "/version", + VIP_RULE_ADD_VIP_RULE: "/VipRule/AddVipRule", + VIP_RULE_DEL_VIP_RULE: "/VipRule/DelVipRule", + VIP_RULE_SELECT_VIP_RULE_LIST: "/VipRule/SelectVipRuleList", + VIP_RULE_UPD_VIP_RULE: "/VipRule/UpdVipRule", +}); diff --git a/src/directives/permission.js b/src/directives/permission.js index d05c1cc2a2cda6c6eef122d5005f6c2c77657994..aadbf60c92f88736ab8ea00413415af626752437 100644 --- a/src/directives/permission.js +++ b/src/directives/permission.js @@ -1,40 +1,93 @@ -import { getAllowedMenuKeys, getAllowedPerms } from "@/utils/permission"; +import { + hasPermission, + isPermissionCacheInitialized, +} from "@/utils/permission"; + +const permStateMap = new WeakMap(); /** - * v-perm 指令:支持菜单键或按钮权限码两种来源 - * - 传入 string 或 string[],例如:v-perm="'system:role:grant'" 或 v-perm="['department.create','department.update']" - * - .all 修饰符表示需要全部命中(默认任意命中其一即可) + * v-perm directive: supports both menu keys and button permission codes. + * - accepts string or string[], e.g. v-perm="'system:role:grp'" + * - .all modifier means all required keys must match. */ function isPermitted(required, needAll = false) { - // 未配置要求时默认放行(方便逐步接入) - if (!required || (Array.isArray(required) && required.length === 0)) + if (!required || (Array.isArray(required) && required.length === 0)) { return true; + } - // 合并“菜单键集合 + 按钮权限码集合” - const allowedSet = new Set([...getAllowedMenuKeys(), ...getAllowedPerms()]); + if (!isPermissionCacheInitialized()) { + return null; + } - // 首屏或尚未拉取权限列表时(两个集合都为空)默认放行,避免按钮被永久隐藏 - if (allowedSet.size === 0) { - return true; + return hasPermission(required, needAll); +} + +function hideElement(el) { + el.style.display = "none"; + el.setAttribute("data-permission-hidden", "true"); +} + +function restoreElement(el) { + const state = permStateMap.get(el); + const originalDisplay = + state && typeof state.originalDisplay === "string" + ? state.originalDisplay + : ""; + el.style.display = originalDisplay; + el.removeAttribute("data-permission-hidden"); +} + +function applyPermission(el, required, needAll) { + const permitted = isPermitted(required, needAll); + if (permitted === null) { + hideElement(el); + el.setAttribute("data-permission-pending", "true"); + return; } - const reqList = Array.isArray(required) ? required : [required]; + el.removeAttribute("data-permission-pending"); + if (!permitted) { + hideElement(el); + return; + } - if (needAll) { - return reqList.every((k) => allowedSet.has(k)); + if (el.getAttribute("data-permission-hidden") === "true") { + restoreElement(el); } - return reqList.some((k) => allowedSet.has(k)); } export default { beforeMount(el, binding) { - const required = binding.value; - const needAll = !!binding.modifiers.all; + const state = { + required: binding.value, + needAll: !!binding.modifiers.all, + originalDisplay: el.style.display || "", + handler: null, + }; + + state.handler = () => { + applyPermission(el, state.required, state.needAll); + }; - const permitted = isPermitted(required, needAll); - if (!permitted) { - el.style.display = "none"; - el.setAttribute("data-permission-hidden", "true"); + permStateMap.set(el, state); + window.addEventListener("permissions-updated", state.handler); + state.handler(); + }, + updated(el, binding) { + const state = permStateMap.get(el); + if (!state) { + return; + } + + state.required = binding.value; + state.needAll = !!binding.modifiers.all; + applyPermission(el, state.required, state.needAll); + }, + unmounted(el) { + const state = permStateMap.get(el); + if (state?.handler) { + window.removeEventListener("permissions-updated", state.handler); } + permStateMap.delete(el); }, }; diff --git a/src/i18n.js b/src/i18n.js index cb0915ace2e56d273a034d473dddc1a273e8c53a..bba2efcf423d5eeffbdc4a98b88adcc7ff89aea3 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -4,7 +4,7 @@ const messages = { "en-US": { message: { hello: "hello", - welcome: "Welcome to our application!", + welcome: "Welcome to our application", username: "Username", password: "Password", pleaseInputUserAccount: "Please input user account", @@ -28,6 +28,7 @@ const messages = { success: "Success", error: "Error", undefined: "Undefined", + my: "My", passwordEditTip: "Do not change this field if you do not want to change your password.", required: "Please enter {field}", @@ -46,6 +47,52 @@ 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.", + permissionCheckError: + "Permission check failed, Please retry or contact administrator.", refreshData: "Refresh Data", addSuccess: "Add Success", updateSuccess: "Update Success", @@ -84,10 +131,38 @@ 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", serverVersion: "Server Version", + homeAccountLabel: "Account", + homeUpdatedAtLabel: "Updated At", + homeAdminDetailPermissionFallback: + "Current account lacks permission to load admin profile details. Showing local account information.", + homeSystemEnvironmentTitle: "System Environment", + homeApiBaseUrl: "API Base URL", + homeRuntimeMode: "Runtime Mode", + homeTimezone: "Timezone", + homeBrowserLanguage: "Browser Language", + homeAppLanguage: "App Language", + homeTokenIssuedAt: "Token Issued At", + homeTokenExpiresAt: "Token Expires At", + homeTokenRemaining: "Token Remaining", + homeAccountTypeAdmin: "Admin Account", + homeAccountTypeEmployee: "Employee Account", + homeCurrentAdminInfo: "Current Admin Info", + homeCurrentEmployeeInfo: "Current Employee Info", + homeExpired: "Expired", + homeEmployeeIdentityNotFound: + "Current employee identity is missing. Unable to load employee details.", + homeEmployeeDetailEmpty: "Employee detail API returned empty data.", + homeEmployeeProfileLoadFailed: "Failed to load employee profile.", + homeAdminDetailNotFound: + "Admin detail API did not return data for current account.", + homeAdminProfileLoadFailed: "Failed to load admin profile.", + homeEnvironmentLoadFailed: "Failed to load environment configuration.", basic: "Basic Information", finance: "Finance Management", supervisionmanagement: "Supervision Management", @@ -685,7 +760,7 @@ const messages = { "zh-CN": { message: { hello: "你好", - welcome: "欢迎使用我们的应用程序!", + welcome: "欢迎使用我们的应用程序", username: "用户账号", password: "用户密码", pleaseInputUserAccount: "请输入用户账号", @@ -708,6 +783,7 @@ const messages = { success: "成功", error: "错误", id: "编号", + my: "我的", passwordEditTip: "留空表示不修改密码", required: "请输入 {field}", description: "描述", @@ -725,6 +801,45 @@ 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: + "本次登录使用了恢复备用码,请立即重新绑定验证器并重置备用码。", + permissionCheckError: "权限检查发生错误,请重试或联系管理员", refreshData: "刷新数据", addSuccess: "添加成功", updateSuccess: "更新成功", @@ -763,6 +878,7 @@ const messages = { duplicate: "重复数据", serverError: "服务器错误", networkError: "网络错误", + concurrencyConflict: "数据已被其他用户修改,请刷新后重试", requestFailed: "请求失败", loginExpired: "登录已过期,请重新登录", unexpectedError: "发生了意外错误", @@ -1396,6 +1512,30 @@ const messages = { present: "正常", late: "迟到", absent: "旷工", + homeAccountLabel: "账号", + homeUpdatedAtLabel: "更新时间", + homeAdminDetailPermissionFallback: + "当前账号缺少管理员详情权限,已降级展示本地账号信息。", + homeSystemEnvironmentTitle: "系统环境配置信息", + homeApiBaseUrl: "API 基址", + homeRuntimeMode: "运行模式", + homeTimezone: "时区", + homeBrowserLanguage: "浏览器语言", + homeAppLanguage: "系统语言", + homeTokenIssuedAt: "Token 生效时间", + homeTokenExpiresAt: "Token 过期时间", + homeTokenRemaining: "Token 剩余有效期", + homeAccountTypeAdmin: "管理员账号", + homeAccountTypeEmployee: "员工账号", + homeCurrentAdminInfo: "当前管理员信息", + homeCurrentEmployeeInfo: "当前员工信息", + homeExpired: "已过期", + homeEmployeeIdentityNotFound: "未找到当前员工标识,无法加载员工详情。", + homeEmployeeDetailEmpty: "员工详情接口未返回有效数据。", + homeEmployeeProfileLoadFailed: "员工信息加载失败", + homeAdminDetailNotFound: "管理员详情接口未返回当前账号数据。", + homeAdminProfileLoadFailed: "管理员信息加载失败", + homeEnvironmentLoadFailed: "系统环境配置加载失败", }, }, }; diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index 5b8ad2034c594cb51020fc5f16f067646faccfe5..8f850bace44d061e8c78c7bedf681ad70986dd30 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,58 @@ import { } from "vue"; import { useStorage } from "@vueuse/core"; import { useRoute, useRouter } from "vue-router"; -import { showErrorNotification } from "@/utils/index"; +import { + formatDateTime, + showErrorNotification, + showSuccessNotification, + showWarningNotification, +} from "@/utils/index"; +import { resolveErrorMessage } from "@/common/errorHandler"; import { fetchMenusTree } from "../api/menuapi"; import { BaseFields } from "@/entities/common.entity"; +import { + readUserDirectPermissions as readAdminUserDirectPermissions, + readUserRolePermissions as readAdminUserRolePermissions, +} from "@/api/administratorapi"; +import { + readUserDirectPermissions as readEmployeeUserDirectPermissions, + readUserRolePermissions as readEmployeeUserRolePermissions, +} from "@/api/employeepermissionapi"; +import { + readUserDirectPermissions as readCustomerUserDirectPermissions, + readUserRolePermissions as readCustomerUserRolePermissions, +} from "@/api/customerpermissionapi"; +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"; +import { clearRowVersionCache } from "@/api/baseapi"; +import { isPermissionDeniedErrorCode } from "@/common/errorCodes"; +import { decodeJwtPayload, getUserIdentity, isTokenExpired } from "@/utils/jwt"; +import { + clearStoredAllowedPathsToken, + clearStoredToken, + getStoredAllowedPathsToken, + getStoredToken, + getStoredTokenCacheKey, + setStoredAllowedPathsToken, +} from "@/utils/tokenStorage"; const serverVersion = ref(""); @@ -212,6 +402,217 @@ 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 unique = (arr) => Array.from(new Set((arr || []).filter(Boolean))); + +const extractPermissionCodes = (items) => + unique( + (Array.isArray(items) ? items : []) + .map((item) => + typeof item === "string" + ? item + : item?.PermissionNumber || item?.PermissionCode || "", + ) + .filter(Boolean), + ); + +const clearAllowedPathsCache = () => { + localStorage.removeItem("allowedPaths"); + localStorage.removeItem("allowedMenuKeys"); + localStorage.removeItem("allowedPerms"); + clearStoredAllowedPathsToken(); + localStorage.removeItem("allowedPathsExpire"); + localStorage.removeItem("allowedPathsIssuedAt"); +}; + +const normalizePath = (path) => (path || "").replace(/\/$/, ""); + +const persistAllowedAccessCache = ( + allowedMenuKeys = [], + allowedPaths = [], + allowedPerms = [], +) => { + localStorage.setItem("allowedMenuKeys", JSON.stringify(allowedMenuKeys)); + localStorage.setItem("allowedPaths", JSON.stringify(allowedPaths)); + localStorage.setItem("allowedPerms", JSON.stringify(allowedPerms)); + + const currentToken = getStoredToken(); + const currentTokenCacheKey = getStoredTokenCacheKey(); + setStoredAllowedPathsToken(currentTokenCacheKey); + + const tokenPayload = decodeJwtPayload(currentToken); + const tokenIssuedAt = Number(tokenPayload?.iat); + const tokenExpireAt = Number(tokenPayload?.exp); + + if (Number.isFinite(tokenIssuedAt)) { + localStorage.setItem("allowedPathsIssuedAt", String(tokenIssuedAt)); + } else { + localStorage.removeItem("allowedPathsIssuedAt"); + } + + if (Number.isFinite(tokenExpireAt)) { + localStorage.setItem("allowedPathsExpire", String(tokenExpireAt)); + } else { + localStorage.removeItem("allowedPathsExpire"); + } + + allowedPermissionCodes.value = allowedPerms; + window.dispatchEvent(new Event("permissions-updated")); +}; + +const isPermissionDeniedError = (error) => { + const code = Number( + error?.code || error?.Code || error?.response?.data?.Code || 0, + ); + if (isPermissionDeniedErrorCode(code)) { + return true; + } + + const message = resolveErrorMessage(error, ""); + return /no\s*permission|forbidden|denied|无权限|缺少权限/i.test(message); +}; + +const getPermissionErrorMessage = () => { + const translated = t("message.permissionCheckError"); + return translated === "message.permissionCheckError" + ? "Permission check failed. Please retry or contact administrator." + : translated; +}; + +const getLocalIdentityFallback = () => { + const candidates = [ + localStorage.getItem("account"), + localStorage.getItem("username"), + ]; + + for (const candidate of candidates) { + const normalized = String(candidate || "").trim(); + if (normalized) { + return normalized; + } + } + + return ""; +}; + +const fetchCurrentUserPermissionCodes = async () => { + const permissionReadersByLoginType = { + admin: { + readRolePermissions: readAdminUserRolePermissions, + readDirectPermissions: readAdminUserDirectPermissions, + }, + employee: { + readRolePermissions: readEmployeeUserRolePermissions, + readDirectPermissions: readEmployeeUserDirectPermissions, + }, + customer: { + readRolePermissions: readCustomerUserRolePermissions, + readDirectPermissions: readCustomerUserDirectPermissions, + }, + }; + + const readers = + permissionReadersByLoginType[loginType.value] || + permissionReadersByLoginType.admin; + + const identity = + getUserIdentity(loginType.value, getStoredToken()) || + getLocalIdentityFallback(); + + if (!identity) { + return { ok: false, reason: "identity_missing", codes: [] }; + } + + try { + const [rolePermissions, directPermissions] = await Promise.all([ + readers.readRolePermissions(identity), + readers.readDirectPermissions(identity), + ]); + + const mergedCodes = unique([ + ...extractPermissionCodes(rolePermissions), + ...extractPermissionCodes(directPermissions), + ]); + + return { ok: true, codes: mergedCodes }; + } catch (error) { + const reason = isPermissionDeniedError(error) + ? "permission_denied" + : "request_failed"; + + if (import.meta.env.DEV && reason === "request_failed") { + // eslint-disable-next-line no-console + console.warn("[Permissions] Failed to sync permission codes:", error); + } + + return { ok: false, reason, codes: [] }; + } +}; + +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); @@ -246,7 +647,7 @@ const selectedKeys = computed(() => { const refreshMenu = async () => { try { const menuItems = await fetchMenusTree({ - [BaseFields.USER_TOKEN]: localStorage.getItem("token"), + [BaseFields.USER_TOKEN]: getStoredToken(), }); const currentOpenKeys = [...openKeys.value]; @@ -281,19 +682,47 @@ const refreshMenu = async () => { }; const { keys, paths, perms } = collect(menuData.value, [], [], []); - 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 allowedPermListFromMenu = unique(perms); + const syncedPermissionCodes = await fetchCurrentUserPermissionCodes(); + const allowedPermList = syncedPermissionCodes.ok + ? syncedPermissionCodes.codes + : allowedPermListFromMenu; - localStorage.setItem("allowedMenuKeys", JSON.stringify(allowedMenuKeys)); - localStorage.setItem("allowedPaths", JSON.stringify(allowedPaths)); - localStorage.setItem("allowedPerms", JSON.stringify(allowedPerms)); + const shouldShowPermissionWarning = + !syncedPermissionCodes.ok && + syncedPermissionCodes.reason === "request_failed" && + allowedPermList.length === 0; + + if (shouldShowPermissionWarning) { + showWarningNotification(getPermissionErrorMessage()); + } + + persistAllowedAccessCache(allowedMenuKeys, allowedPaths, allowedPermList); + const currentPath = normalizePath(router.currentRoute.value.path); + const hasDashboardPath = allowedPaths.some( + (path) => normalizePath(path) === "/dashboard", + ); + if (currentPath === "/dashboard" && !hasDashboardPath) { + await router.replace("/home"); + } // 3) 还原展开状态 openKeys.value = currentOpenKeys; } catch (error) { - showErrorNotification(error.message); + const message = resolveErrorMessage(error, t("message.fetchMenuError")); + showErrorNotification(message); + + if (isPermissionDeniedError(error)) { + menuData.value = []; + persistAllowedAccessCache([], [], []); + + const currentPath = normalizePath(router.currentRoute.value.path); + if (currentPath !== "/home") { + await router.replace("/home"); + } + } } }; @@ -318,36 +747,302 @@ 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 copyTextWithClipboard = async (text) => { + if (navigator?.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.style.position = "fixed"; + textArea.style.opacity = "0"; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + return document.execCommand("copy"); + } catch (error) { + return false; + } finally { + document.body.removeChild(textArea); + } +}; + +const copyAllRecoveryCodes = async () => { + const content = recoveryCodesText.value; + if (!content) { + return; + } + + try { + const copied = await copyTextWithClipboard(content); + if (!copied) { + 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) { + const storedToken = getStoredToken(); + const storedTokenCacheKey = getStoredTokenCacheKey(); + + if (storedToken && !isTokenExpired(storedToken)) { isLoggedIn.value = true; - const storedUsername = localStorage.getItem("username"); - if (storedUsername) { - username.value = storedUsername; + loginType.value = localStorage.getItem("loginType") || "admin"; + + const tokenPayload = decodeJwtPayload(storedToken); + const tokenExpireAt = Number(tokenPayload?.exp); + const cachedAllowedToken = getStoredAllowedPathsToken(); + const cachedAllowedExpireAt = Number( + localStorage.getItem("allowedPathsExpire"), + ); + const now = Math.floor(Date.now() / 1000); + + const isAllowedTokenMismatch = + cachedAllowedToken.length > 0 && + cachedAllowedToken !== storedTokenCacheKey; + const isAllowedCacheExpired = + Number.isFinite(cachedAllowedExpireAt) && cachedAllowedExpireAt <= now; + const isAllowedExpireMismatch = + Number.isFinite(tokenExpireAt) && + Number.isFinite(cachedAllowedExpireAt) && + cachedAllowedExpireAt !== tokenExpireAt; + + if ( + isAllowedTokenMismatch || + isAllowedCacheExpired || + isAllowedExpireMismatch + ) { + clearAllowedPathsCache(); + allowedPermissionCodes.value = []; } else { - username.value = "User"; + syncAllowedPermissions(); } - } else { - isLoggedIn.value = false; - username.value = ""; + + const storedUsername = localStorage.getItem("username"); + username.value = storedUsername || "User"; + return; } + + isLoggedIn.value = false; + loginType.value = "admin"; + allowedPermissionCodes.value = []; + username.value = ""; + clearStoredToken(); + localStorage.removeItem("username"); + localStorage.removeItem("account"); + localStorage.removeItem("loginType"); + clearAllowedPathsCache(); + clearRowVersionCache(); }; const logout = async () => { var response = await signOut(); - if (!response.Success) { + if (!isApiSuccess(response)) { showErrorNotification(response.Message || t("message.logoutFailed")); return; } - localStorage.removeItem("token"); + clearStoredToken(); localStorage.removeItem("username"); localStorage.removeItem("account"); - localStorage.removeItem("allowedPaths"); - localStorage.removeItem("allowedMenuKeys"); - localStorage.removeItem("allowedPerms"); + localStorage.removeItem("loginType"); + clearAllowedPathsCache(); + clearRowVersionCache(); isLoggedIn.value = false; + allowedPermissionCodes.value = []; username.value = ""; + closeTwoFactorModal(); router.push("/signin"); }; diff --git a/src/router/index.js b/src/router/index.js index 9d66eee8fb9bfc5a2335a035daa8f9436be55251..eba4c0d058e0240650a6c1cfb6138adcfb7fa472 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -3,6 +3,49 @@ import { checkTokenValidity } from "../utils/auth"; import { getPageTitle } from "@/utils/pageTitle"; import i18n from "@/i18n"; import { hasPermission } from "@/utils/permission"; +import { + getStoredAllowedPathsToken, + getStoredTokenCacheKey, +} from "@/utils/tokenStorage"; + +const normalizePath = (path) => (path || "").replace(/\/$/, ""); + +const canAccessDashboardFromCache = () => { + if (typeof window === "undefined") { + return false; + } + + const currentTokenCacheKey = getStoredTokenCacheKey(); + if (!currentTokenCacheKey) { + return false; + } + + const rawAllowedPaths = localStorage.getItem("allowedPaths"); + const allowedPathsToken = getStoredAllowedPathsToken(); + const allowedPathsExpire = Number(localStorage.getItem("allowedPathsExpire")); + const now = Math.floor(Date.now() / 1000); + const hasAllowedPathsCache = + rawAllowedPaths !== null && + allowedPathsToken.length > 0 && + allowedPathsToken === currentTokenCacheKey && + Number.isFinite(allowedPathsExpire) && + allowedPathsExpire > now; + + if (!hasAllowedPathsCache) { + return false; + } + + try { + const parsed = rawAllowedPaths ? JSON.parse(rawAllowedPaths) : []; + const allowedPaths = Array.isArray(parsed) ? parsed : []; + return allowedPaths.some((path) => normalizePath(path) === "/dashboard"); + } catch (error) { + return false; + } +}; + +const resolveRootRedirectPath = () => + canAccessDashboardFromCache() ? "/dashboard" : "/home"; // 路由组件改为懒加载,减少首屏体积 const NotFound = () => import("../views/responsepage/NotFound.vue"); @@ -68,6 +111,7 @@ const CustomerAccountPermission = () => const EmployeeAccountPermission = () => import("../views/systemmanagement/EmployeeAccountPermissionView.vue"); +const Home = () => import("../views/home/HomeView.vue"); const Dashboard = () => import("../views/dashboard/DashboardView.vue"); const routes = [ @@ -77,7 +121,7 @@ const routes = [ children: [ { path: "/", - redirect: "/dashboard", + redirect: () => resolveRootRedirectPath(), meta: { requiresAuth: true }, }, { @@ -246,19 +290,34 @@ const routes = [ path: "/accountpermission", name: "accountpermission", component: AccountPermission, - meta: { requiresAuth: true, requiredPerm: "system:user:assign.view" }, + meta: { + requiresAuth: true, + requiredPerm: "system:user:assign.selectpermissionlist", + }, }, { path: "/customeraccountpermission", name: "customeraccountpermission", component: CustomerAccountPermission, - meta: { requiresAuth: true, requiredPerm: "system:user:assign.view" }, + meta: { + requiresAuth: true, + requiredPerm: "system:user:assign.selectpermissionlist", + }, }, { path: "/employeeaccountpermission", name: "employeeaccountpermission", component: EmployeeAccountPermission, - meta: { requiresAuth: true, requiredPerm: "system:user:assign.view" }, + meta: { + requiresAuth: true, + requiredPerm: "system:user:assign.selectpermissionlist", + }, + }, + { + path: "/home", + name: "home", + component: Home, + meta: { requiresAuth: true, ignoreAllowedPaths: true }, }, { path: "/dashboard", @@ -286,13 +345,13 @@ const router = createRouter({ routes, }); -router.beforeEach((to, from, next) => { +router.beforeEach(async (to, from, next) => { if (to.meta.public) { return next(); } if (to.matched.some((record) => record.meta.requiresAuth)) { - if (!checkTokenValidity()) { + if (!(await checkTokenValidity())) { return next({ name: "signin" }); } @@ -303,6 +362,10 @@ router.beforeEach((to, from, next) => { } // 支持基于"权限码或菜单键"直达的路由控制(未出现在菜单里的独立页面) + if (to.meta && to.meta.ignoreAllowedPaths) { + return next(); + } + const requiredPerm = to.meta && to.meta.requiredPerm; if (requiredPerm && hasPermission(requiredPerm)) { return next(); @@ -310,11 +373,31 @@ router.beforeEach((to, from, next) => { // 基于后端返回的菜单(权限)结果做前端路由访问控制(路径白名单) const raw = localStorage.getItem("allowedPaths"); + const currentTokenCacheKey = getStoredTokenCacheKey(); + const allowedPathsToken = getStoredAllowedPathsToken(); + const allowedPathsExpire = Number( + localStorage.getItem("allowedPathsExpire"), + ); + const now = Math.floor(Date.now() / 1000); + const hasAllowedPathsCache = + raw !== null && + allowedPathsToken.length > 0 && + allowedPathsToken === currentTokenCacheKey && + Number.isFinite(allowedPathsExpire) && + allowedPathsExpire > now; const parsed = raw ? JSON.parse(raw) : []; const allowedPaths = Array.isArray(parsed) ? parsed : []; - // 尚未拉取菜单(例如刚进入系统时),先放行,由主布局内拉取并写入 - if (allowedPaths.length > 0) { + // 尚未初始化权限缓存时(例如刚进入系统),先放行。 + // 已初始化但为空数组时,说明当前账号没有可访问路径,应拦截。 + if (hasAllowedPathsCache) { + if (allowedPaths.length === 0) { + if ((to.path || "").replace(/\/$/, "") === "/dashboard") { + return next({ path: "/home" }); + } + return next({ name: "NotFound" }); + } + const normalize = (p) => (p || "").replace(/\/$/, ""); const current = normalize(to.path); @@ -327,6 +410,9 @@ router.beforeEach((to, from, next) => { .some((p) => matchPath(p, current)); if (!permitted) { + if (current === "/dashboard") { + return next({ path: "/home" }); + } return next({ name: "NotFound" }); } } diff --git a/src/utils/auth.js b/src/utils/auth.js index 5803819903b36609b808a6456c27217a54a44e45..1a55d38aa10bedaa579cd3635f9749a3095372bd 100644 --- a/src/utils/auth.js +++ b/src/utils/auth.js @@ -1,17 +1,178 @@ -import { jwtDecode } from "jwt-decode"; import router from "@/router"; +import api from "@/api"; import { showErrorNotification } from "@/utils/index"; import i18n from "@/i18n"; import { signOut } from "@/api/basicapi"; +import { clearRowVersionCache } from "@/api/baseapi"; +import { API_ENDPOINTS } from "@/config/apiEndpoints"; +import { + API_SUCCESS_CODE, + HTTP_STATUS, + isPermissionDeniedBusinessCode, + isTokenInvalidBusinessCode, + normalizeCode, +} from "@/common/errorCodes"; +import { isTokenExpired } from "@/utils/jwt"; +import { + clearStoredAllowedPathsToken, + clearStoredToken, + getStoredToken, +} from "@/utils/tokenStorage"; + +const TOKEN_VALIDATION_CACHE_TTL_MS = 60 * 1000; +const TOKEN_VALIDATION_MAX_ATTEMPTS = 3; +const TOKEN_VALIDATION_RETRY_BASE_DELAY_MS = 300; + +const tokenValidationCache = { + token: "", + valid: false, + validatedAt: 0, + pendingToken: "", + pendingPromise: null, +}; + +const clearTokenValidationCache = () => { + tokenValidationCache.token = ""; + tokenValidationCache.valid = false; + tokenValidationCache.validatedAt = 0; + tokenValidationCache.pendingToken = ""; + tokenValidationCache.pendingPromise = null; +}; + +const saveTokenValidationResult = (token, valid) => { + tokenValidationCache.token = token; + tokenValidationCache.valid = valid; + tokenValidationCache.validatedAt = Date.now(); + return valid; +}; + +const isTokenValidationCacheFresh = (token) => { + if (!token || tokenValidationCache.token !== token) { + return false; + } + + return ( + Date.now() - tokenValidationCache.validatedAt <= + TOKEN_VALIDATION_CACHE_TTL_MS + ); +}; + +const isExplicitInvalidTokenError = (error) => { + const businessCode = normalizeCode( + error?.Code || error?.code || error?.response?.data?.Code, + ); + const httpStatus = normalizeCode(error?.response?.status); + return ( + isTokenInvalidBusinessCode(businessCode) || + httpStatus === HTTP_STATUS.UNAUTHORIZED + ); +}; + +const isRetryableTokenValidationError = (error) => { + if (isExplicitInvalidTokenError(error)) { + return false; + } + + const httpStatus = normalizeCode(error?.response?.status); + if (!httpStatus) { + return true; + } + + return httpStatus >= 500; +}; + +const getTokenValidationRetryDelayMs = (attempt) => + TOKEN_VALIDATION_RETRY_BASE_DELAY_MS * 2 ** Math.max(0, attempt - 1); + +const sleep = (delayMs) => + new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); + +const validateTokenWithServer = async (token) => { + if (!token) { + return false; + } + + if (isTokenValidationCacheFresh(token)) { + return tokenValidationCache.valid; + } + + if ( + tokenValidationCache.pendingPromise && + tokenValidationCache.pendingToken === token + ) { + return tokenValidationCache.pendingPromise; + } + + const pendingPromise = (async () => { + try { + for ( + let attempt = 1; + attempt <= TOKEN_VALIDATION_MAX_ATTEMPTS; + attempt += 1 + ) { + try { + const response = await api.get( + API_ENDPOINTS.routes.LOGIN_GET_CSRF_TOKEN, + { + skipBusinessErrorHandling: true, + skipHttpErrorHandling: true, + }, + ); + + const payload = response?.data || {}; + const code = normalizeCode(payload?.Code); + const valid = + payload?.Success === true || + code === API_SUCCESS_CODE || + isPermissionDeniedBusinessCode(code); + + return saveTokenValidationResult(token, valid); + } catch (error) { + const retryable = isRetryableTokenValidationError(error); + const hasRemainingAttempts = attempt < TOKEN_VALIDATION_MAX_ATTEMPTS; + + if (retryable && hasRemainingAttempts) { + await sleep(getTokenValidationRetryDelayMs(attempt)); + continue; + } + + // Permission-related failures still indicate a valid login session. + const valid = retryable ? false : !isExplicitInvalidTokenError(error); + return saveTokenValidationResult(token, valid); + } + } + + return saveTokenValidationResult(token, false); + } finally { + tokenValidationCache.pendingToken = ""; + tokenValidationCache.pendingPromise = null; + } + })(); + + tokenValidationCache.pendingToken = token; + tokenValidationCache.pendingPromise = pendingPromise; + + return pendingPromise; +}; export function redirectToLogin( message = i18n.global.t("message.loginExpired"), clearStorage = false, ) { - // 只有在明确指定需要清除存储时才清除 if (clearStorage) { - localStorage.removeItem("token"); + clearStoredToken(); localStorage.removeItem("username"); + localStorage.removeItem("account"); + localStorage.removeItem("allowedPerms"); + localStorage.removeItem("allowedPaths"); + localStorage.removeItem("allowedMenuKeys"); + clearStoredAllowedPathsToken(); + localStorage.removeItem("allowedPathsExpire"); + localStorage.removeItem("allowedPathsIssuedAt"); + clearTokenValidationCache(); + clearRowVersionCache(); } if (router.currentRoute.value.path !== "/signin") { @@ -20,21 +181,22 @@ export function redirectToLogin( } } -// 处理登出 export async function handleLogout() { try { - const token = localStorage.getItem("token"); + const token = getStoredToken(); if (token) { - // 调用登出接口 await signOut(); - // 清理本地存储 - localStorage.removeItem("token"); + clearStoredToken(); localStorage.removeItem("username"); localStorage.removeItem("account"); localStorage.removeItem("allowedPerms"); localStorage.removeItem("allowedPaths"); localStorage.removeItem("allowedMenuKeys"); - // 重定向到登录页 + clearStoredAllowedPathsToken(); + localStorage.removeItem("allowedPathsExpire"); + localStorage.removeItem("allowedPathsIssuedAt"); + clearTokenValidationCache(); + clearRowVersionCache(); router.push("/signin"); } } catch (error) { @@ -42,29 +204,19 @@ export async function handleLogout() { } } -export function checkTokenValidity(redirect = false) { - const token = localStorage.getItem("token"); +export async function checkTokenValidity(redirect = false) { + void redirect; + const token = getStoredToken(); if (!token) { + clearTokenValidationCache(); return false; } - try { - const decodedToken = jwtDecode(token); - const currentTime = Math.floor(Date.now() / 1000); - - // 检查 token 是否快要过期(比如还有5分钟过期) - const expiresIn = decodedToken.exp - currentTime; - const isNearExpiration = expiresIn < 300; // 5分钟 = 300秒 - - if (decodedToken.exp < currentTime || isNearExpiration) { - // token 已过期或即将过期 - return false; - } - - return true; - } catch (error) { - // token 解析失败 + if (isTokenExpired(token, 300)) { + clearTokenValidationCache(); return false; } + + return validateTokenWithServer(token); } diff --git a/src/utils/csrf.js b/src/utils/csrf.js index 3d47b405e719694d0a14500bd7b2f92f48a363e7..a4a8cf93637893885330d8fbc0bb300689ca1cf2 100644 --- a/src/utils/csrf.js +++ b/src/utils/csrf.js @@ -76,7 +76,8 @@ class CsrfTokenManager { } } - async refreshToken() { + async refreshToken(options = {}) { + const { silent = false } = options || {}; if (this.isRefreshing) { return new Promise((resolve) => { this.subscribers.push((token) => { @@ -118,10 +119,12 @@ class CsrfTokenManager { return this.token; } catch (error) { - showErrorNotification( - i18n.global.t("message.verifyCsrfTokenFailed"), - error, - ); + if (!silent) { + showErrorNotification( + i18n.global.t("message.verifyCsrfTokenFailed"), + error, + ); + } throw error; } finally { this.isRefreshing = false; diff --git a/src/utils/initialize.js b/src/utils/initialize.js index 8fa4dbf745ccdab06043036e26a2f229cf408075..82cb93c125086a6c34cb9ee6700e030684800e59 100644 --- a/src/utils/initialize.js +++ b/src/utils/initialize.js @@ -1,6 +1,7 @@ import { checkTokenValidity, redirectToLogin } from "./auth"; import { csrfTokenManager } from "./csrf"; import router from "@/router"; +import { clearStoredToken, getStoredToken } from "@/utils/tokenStorage"; export const initializeApplication = async () => { try { @@ -20,7 +21,7 @@ export const initializeApplication = async () => { } // 4. 获取并验证 JWT token - const token = localStorage.getItem("token"); + const token = getStoredToken(); if (!token) { redirectToLogin("请先登录", false); // 不清除存储,因为本来就没有token return; @@ -28,9 +29,9 @@ export const initializeApplication = async () => { try { // 5. 验证 token 有效性 - if (!checkTokenValidity(false)) { + if (!(await checkTokenValidity(false))) { // 不自动重定向,我们要自己控制 - localStorage.removeItem("token"); + clearStoredToken(); localStorage.removeItem("username"); redirectToLogin("登录已过期,请重新登录", true); // 清除无效的token return; diff --git a/src/utils/jwt.js b/src/utils/jwt.js new file mode 100644 index 0000000000000000000000000000000000000000..7d184442a067cff7b16ea1c722cf490f7f93ac97 --- /dev/null +++ b/src/utils/jwt.js @@ -0,0 +1,92 @@ +import { jwtDecode } from "jwt-decode"; + +const IDENTITY_FIELDS_BY_LOGIN_TYPE = { + admin: ["sub", "nameid", "UserNumber", "userNumber", "Account", "account"], + employee: [ + "sub", + "nameid", + "EmployeeId", + "employeeId", + "UserNumber", + "userNumber", + "Account", + "account", + ], + customer: ["sub", "nameid", "CustomerId", "customerId", "Account", "account"], +}; + +const COMMON_IDENTITY_FIELDS = [ + "sub", + "nameid", + "UserNumber", + "userNumber", + "EmployeeId", + "employeeId", + "Account", + "account", +]; + +const normalizeIdentity = (value) => { + if (value === null || value === undefined) { + return ""; + } + + const text = String(value).trim(); + return text.length > 0 ? text : ""; +}; + +const pickIdentity = (payload, keys) => { + for (const key of keys) { + const identity = normalizeIdentity(payload?.[key]); + if (identity) { + return identity; + } + } + return ""; +}; + +export const decodeJwtPayload = (token) => { + const normalizedToken = typeof token === "string" ? token.trim() : ""; + if (!normalizedToken) { + return null; + } + + try { + // SECURITY NOTICE: + // jwt-decode only parses payload and does NOT verify signature. + // Never trust decoded content for authentication/authorization decisions. + return jwtDecode(normalizedToken); + } catch (error) { + return null; + } +}; + +export const getTokenExpiration = (token) => { + const payload = decodeJwtPayload(token); + const exp = Number(payload?.exp); + return Number.isFinite(exp) ? exp : null; +}; + +export const isTokenExpired = (token, minRemainingSeconds = 0) => { + const expiration = getTokenExpiration(token); + if (!Number.isFinite(expiration)) { + return true; + } + + const now = Math.floor(Date.now() / 1000); + return expiration - now <= Number(minRemainingSeconds || 0); +}; + +export const getUserIdentity = (loginType, token) => { + const payload = decodeJwtPayload(token); + if (!payload) { + return null; + } + + const preferredFields = IDENTITY_FIELDS_BY_LOGIN_TYPE[loginType] || []; + const identity = + pickIdentity(payload, preferredFields) || + pickIdentity(payload, COMMON_IDENTITY_FIELDS); + + return identity || null; +}; diff --git a/src/utils/permission.js b/src/utils/permission.js index fc1bff84a5ebe0d69d6847c54979dcdc6f352904..9ad6e5702f9707e2089a08b16973fad1da07ed7f 100644 --- a/src/utils/permission.js +++ b/src/utils/permission.js @@ -1,21 +1,43 @@ -export function getAllowedMenuKeys() { +const PERMISSION_CACHE_KEYS = Object.freeze({ + perms: "allowedPerms", + menuKeys: "allowedMenuKeys", +}); + +const readArrayCache = (key) => { + const raw = localStorage.getItem(key); + if (raw === null) { + return { exists: false, valid: false, value: [] }; + } + try { - const raw = localStorage.getItem("allowedMenuKeys"); - const parsed = raw ? JSON.parse(raw) : []; - return Array.isArray(parsed) ? parsed : []; + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return { exists: true, valid: true, value: parsed }; + } + return { exists: true, valid: false, value: [] }; } catch { - return []; + return { exists: true, valid: false, value: [] }; } +}; + +export function getAllowedMenuKeys() { + return readArrayCache(PERMISSION_CACHE_KEYS.menuKeys).value; } export function getAllowedPerms() { - try { - const raw = localStorage.getItem("allowedPerms"); - const parsed = raw ? JSON.parse(raw) : []; - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; + return readArrayCache(PERMISSION_CACHE_KEYS.perms).value; +} + +export function isPermissionCacheInitialized() { + const perms = readArrayCache(PERMISSION_CACHE_KEYS.perms); + const menuKeys = readArrayCache(PERMISSION_CACHE_KEYS.menuKeys); + + if (!perms.exists && !menuKeys.exists) { + return false; } + + // Valid empty arrays still mean "initialized" (user may legitimately have no permissions). + return perms.valid || menuKeys.valid; } /** @@ -33,7 +55,7 @@ export function hasPermission(required, needAll = false) { const allowedMenuKeys = getAllowedMenuKeys(); // 两者都还未初始化(首次进入系统、尚未拉取菜单)时默认放行,避免首屏闪烁/误隐藏 - if (allowedPerms.length === 0 && allowedMenuKeys.length === 0) { + if (!isPermissionCacheInitialized()) { return true; } diff --git a/src/utils/tokenStorage.js b/src/utils/tokenStorage.js new file mode 100644 index 0000000000000000000000000000000000000000..eade2f330606668bd0b816b8ee2f7c12b9e9e2d4 --- /dev/null +++ b/src/utils/tokenStorage.js @@ -0,0 +1,306 @@ +const TOKEN_STORAGE_KEY = "token"; +const ALLOWED_PATHS_TOKEN_STORAGE_KEY = "allowedPathsToken"; +const TOKEN_STORAGE_PREFIX = "obf:v1:"; + +let cachedToken = ""; +let cachedTokenCacheKey = ""; +let hasHydratedTokenCache = false; + +const getSessionStorage = () => { + if (typeof window === "undefined") { + return null; + } + + try { + return window.sessionStorage; + } catch { + return null; + } +}; + +const getLegacyLocalStorage = () => { + if (typeof window === "undefined") { + return null; + } + + try { + return window.localStorage; + } catch { + return null; + } +}; + +const normalizeToken = (value) => + typeof value === "string" ? value.trim() : ""; + +const reverseText = (value) => value.split("").reverse().join(""); + +const encodeBase64 = (value) => btoa(value); +const decodeBase64 = (value) => atob(value); + +export const obscureToken = (token) => { + const normalized = normalizeToken(token); + if (!normalized) { + return ""; + } + + return `${TOKEN_STORAGE_PREFIX}${encodeBase64(reverseText(normalized))}`; +}; + +export const unobscureToken = (storedValue) => { + const normalized = normalizeToken(storedValue); + if (!normalized) { + return ""; + } + + if (!normalized.startsWith(TOKEN_STORAGE_PREFIX)) { + return normalized; + } + + const payload = normalized.slice(TOKEN_STORAGE_PREFIX.length); + if (!payload) { + return ""; + } + + try { + return reverseText(decodeBase64(payload)); + } catch { + return ""; + } +}; + +const resetTokenCache = () => { + cachedToken = ""; + cachedTokenCacheKey = ""; +}; + +const clearLegacyTokenStorage = () => { + const legacyStorage = getLegacyLocalStorage(); + if (!legacyStorage) { + return; + } + + legacyStorage.removeItem(TOKEN_STORAGE_KEY); +}; + +const clearLegacyAllowedPathsTokenStorage = () => { + const legacyStorage = getLegacyLocalStorage(); + if (!legacyStorage) { + return; + } + + legacyStorage.removeItem(ALLOWED_PATHS_TOKEN_STORAGE_KEY); +}; + +const hydrateCacheFromRawToken = (storage, rawToken) => { + if (!storage || !rawToken) { + resetTokenCache(); + return; + } + + const token = unobscureToken(rawToken); + if (!token) { + storage.removeItem(TOKEN_STORAGE_KEY); + resetTokenCache(); + return; + } + + const cacheKey = rawToken.startsWith(TOKEN_STORAGE_PREFIX) + ? rawToken + : obscureToken(token); + + if (!cacheKey) { + storage.removeItem(TOKEN_STORAGE_KEY); + resetTokenCache(); + return; + } + + if (cacheKey !== rawToken) { + storage.setItem(TOKEN_STORAGE_KEY, cacheKey); + } + + cachedToken = token; + cachedTokenCacheKey = cacheKey; +}; + +const migrateLegacyLocalTokenToSessionIfNeeded = (sessionStorage) => { + if (!sessionStorage) { + return ""; + } + + const legacyStorage = getLegacyLocalStorage(); + if (!legacyStorage) { + return ""; + } + + const legacyRawToken = normalizeToken( + legacyStorage.getItem(TOKEN_STORAGE_KEY), + ); + if (!legacyRawToken) { + return ""; + } + + const token = unobscureToken(legacyRawToken); + legacyStorage.removeItem(TOKEN_STORAGE_KEY); + if (!token) { + return ""; + } + + const migratedRawToken = obscureToken(token); + if (!migratedRawToken) { + return ""; + } + + sessionStorage.setItem(TOKEN_STORAGE_KEY, migratedRawToken); + return migratedRawToken; +}; + +const syncTokenCache = () => { + const storage = getSessionStorage(); + if (!storage) { + hasHydratedTokenCache = true; + resetTokenCache(); + return; + } + + let rawToken = normalizeToken(storage.getItem(TOKEN_STORAGE_KEY)); + if (!rawToken && !hasHydratedTokenCache) { + rawToken = migrateLegacyLocalTokenToSessionIfNeeded(storage); + } else { + clearLegacyTokenStorage(); + } + + hasHydratedTokenCache = true; + if (!rawToken) { + resetTokenCache(); + return; + } + + if (rawToken === cachedTokenCacheKey && cachedToken) { + return; + } + + hydrateCacheFromRawToken(storage, rawToken); +}; + +export const getStoredToken = () => { + syncTokenCache(); + return cachedToken; +}; + +export const getStoredTokenCacheKey = () => { + syncTokenCache(); + return cachedTokenCacheKey; +}; + +export const setStoredToken = (token) => { + hasHydratedTokenCache = true; + const storage = getSessionStorage(); + if (!storage) { + resetTokenCache(); + return; + } + + const normalized = normalizeToken(token); + if (!normalized) { + storage.removeItem(TOKEN_STORAGE_KEY); + clearLegacyTokenStorage(); + resetTokenCache(); + return; + } + + const cacheKey = obscureToken(normalized); + if (!cacheKey) { + storage.removeItem(TOKEN_STORAGE_KEY); + clearLegacyTokenStorage(); + resetTokenCache(); + return; + } + + storage.setItem(TOKEN_STORAGE_KEY, cacheKey); + clearLegacyTokenStorage(); + cachedToken = normalized; + cachedTokenCacheKey = cacheKey; +}; + +export const clearStoredToken = () => { + hasHydratedTokenCache = true; + const storage = getSessionStorage(); + if (storage) { + storage.removeItem(TOKEN_STORAGE_KEY); + } + + clearLegacyTokenStorage(); + resetTokenCache(); +}; + +const migrateLegacyAllowedPathsTokenToSessionIfNeeded = (sessionStorage) => { + if (!sessionStorage) { + return ""; + } + + const legacyStorage = getLegacyLocalStorage(); + if (!legacyStorage) { + return ""; + } + + const legacyAllowedPathsToken = normalizeToken( + legacyStorage.getItem(ALLOWED_PATHS_TOKEN_STORAGE_KEY), + ); + if (!legacyAllowedPathsToken) { + return ""; + } + + sessionStorage.setItem( + ALLOWED_PATHS_TOKEN_STORAGE_KEY, + legacyAllowedPathsToken, + ); + legacyStorage.removeItem(ALLOWED_PATHS_TOKEN_STORAGE_KEY); + return legacyAllowedPathsToken; +}; + +export const getStoredAllowedPathsToken = () => { + const storage = getSessionStorage(); + if (!storage) { + return ""; + } + + let allowedPathsToken = normalizeToken( + storage.getItem(ALLOWED_PATHS_TOKEN_STORAGE_KEY), + ); + if (!allowedPathsToken) { + allowedPathsToken = + migrateLegacyAllowedPathsTokenToSessionIfNeeded(storage); + } else { + clearLegacyAllowedPathsTokenStorage(); + } + + return allowedPathsToken; +}; + +export const setStoredAllowedPathsToken = (allowedPathsToken) => { + const storage = getSessionStorage(); + const normalizedAllowedPathsToken = normalizeToken(allowedPathsToken); + if (!storage) { + clearLegacyAllowedPathsTokenStorage(); + return; + } + + if (!normalizedAllowedPathsToken) { + storage.removeItem(ALLOWED_PATHS_TOKEN_STORAGE_KEY); + clearLegacyAllowedPathsTokenStorage(); + return; + } + + storage.setItem(ALLOWED_PATHS_TOKEN_STORAGE_KEY, normalizedAllowedPathsToken); + clearLegacyAllowedPathsTokenStorage(); +}; + +export const clearStoredAllowedPathsToken = () => { + const storage = getSessionStorage(); + if (storage) { + storage.removeItem(ALLOWED_PATHS_TOKEN_STORAGE_KEY); + } + + clearLegacyAllowedPathsTokenStorage(); +}; diff --git a/src/views/SignInView.vue b/src/views/SignInView.vue index 642a9260b158ada23200e67f9c3c81053d11d7e9..b2565f5c72c287570fcc82c49e14534874388521 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,9 +114,24 @@ 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 { clearRowVersionCache } from "@/api/baseapi"; import { useI18n } from "vue-i18n"; +import { + clearStoredAllowedPathsToken, + setStoredToken, +} from "@/utils/tokenStorage"; const { t, locale } = useI18n(); const currentLocale = ref(locale.value); @@ -87,39 +147,169 @@ 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 clearPermissionCache = () => { + localStorage.removeItem("allowedPaths"); + localStorage.removeItem("allowedMenuKeys"); + localStorage.removeItem("allowedPerms"); + clearStoredAllowedPathsToken(); + localStorage.removeItem("allowedPathsExpire"); + localStorage.removeItem("allowedPathsIssuedAt"); +}; + +const persistLoginData = async (userData, loginType) => { + clearPermissionCache(); + clearRowVersionCache(); + setStoredToken(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/src/views/base/DepartmentView.vue b/src/views/base/DepartmentView.vue index 6748cdb5bb88a882f1e9ece78b9eac3648466c74..ec097915e7a06327bcfcd61f948a1fa6317539c4 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 d20089a78537df1b76049deefb00933163b4db55..db7009eb44e623c35644876d2ecfded92e47a5a6 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 c32b149ef8d87a5d97c4e067249d84a6ddf7fd85..28bef0aeccfc88e979227672d34f070abebfb3e0 100644 --- a/src/views/base/NoticeTypeView.vue +++ b/src/views/base/NoticeTypeView.vue @@ -1,4 +1,4 @@ -