diff --git a/.vscode/settings.json b/.vscode/settings.json index 5141a436..b43481f0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -23,7 +23,7 @@ "editor.suggestSelection": "recentlyUsedByPrefix", "editor.acceptSuggestionOnEnter": "smart", "editor.suggest.snippetsPreventQuickSuggestions": false, - "editor.stickyScroll.enabled": true, + "editor.stickyScroll.enabled": false, "editor.hover.sticky": true, "editor.suggest.insertMode": "replace", "editor.bracketPairColorization.enabled": true, @@ -46,7 +46,7 @@ "terminal.integrated.persistentSessionReviveProcess": "never", "terminal.integrated.tabs.enabled": true, "terminal.integrated.scrollback": 10000, - "terminal.integrated.stickyScroll.enabled": true, + "terminal.integrated.stickyScroll.enabled": false, // files "files.eol": "\n", diff --git a/apps/web-antd/.env.development b/apps/web-antd/.env.development index dcf361e7..c27e90c4 100644 --- a/apps/web-antd/.env.development +++ b/apps/web-antd/.env.development @@ -1,16 +1,25 @@ # 端口号 VITE_PORT=5555 - +# base路径 VITE_BASE=/ - -# 接口地址 -VITE_GLOB_API_URL=/api - # 是否开启 Nitro Mock服务,true 为开启,false 为关闭 VITE_NITRO_MOCK=true - # 是否打开 devtools,true 为打开,false 为关闭 VITE_DEVTOOLS=false - # 是否注入全局loading VITE_INJECT_APP_LOADING=true + +# 后台请求路径 具体在vite.config.mts配置代理 +VITE_GLOB_API_URL=/api + +# 全局加密开关(即开启了加解密功能才会生效 不是全部接口加密 需要和后端对应) +VITE_GLOB_ENABLE_ENCRYPT=true +# RSA公钥 请求加密使用 注意这两个是两对RSA公私钥 请求加密-后端解密是一对 响应解密-后端加密是一对 +VITE_GLOB_RSA_PUBLIC_KEY=MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKoR8mX0rGKLqzcWmOzbfj64K8ZIgOdHnzkXSOVOZbFu/TJhZ7rFAN+eaGkl3C4buccQd/EjEsj9ir7ijT7h96MCAwEAAQ== +# RSA私钥 响应解密使用 注意这两个是两对RSA公私钥 请求加密-后端解密是一对 响应解密-后端加密是一对 +VITE_GLOB_RSA_PRIVATE_KEY=MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAmc3CuPiGL/LcIIm7zryCEIbl1SPzBkr75E2VMtxegyZ1lYRD+7TZGAPkvIsBcaMs6Nsy0L78n2qh+lIZMpLH8wIDAQABAkEAk82Mhz0tlv6IVCyIcw/s3f0E+WLmtPFyR9/WtV3Y5aaejUkU60JpX4m5xNR2VaqOLTZAYjW8Wy0aXr3zYIhhQQIhAMfqR9oFdYw1J9SsNc+CrhugAvKTi0+BF6VoL6psWhvbAiEAxPPNTmrkmrXwdm/pQQu3UOQmc2vCZ5tiKpW10CgJi8kCIFGkL6utxw93Ncj4exE/gPLvKcT+1Emnoox+O9kRXss5AiAMtYLJDaLEzPrAWcZeeSgSIzbL+ecokmFKSDDcRske6QIgSMkHedwND1olF8vlKsJUGK3BcdtM8w4Xq7BpSBwsloE= +# 客户端id +VITE_GLOB_APP_CLIENT_ID=e5cd7e4891bf95d1d19206ce24a7b32e + +# 开启WEBSOCKET +VITE_GLOB_WEBSOCKET_ENABLE=false diff --git a/apps/web-antd/.env.production b/apps/web-antd/.env.production index 37947069..0425eada 100644 --- a/apps/web-antd/.env.production +++ b/apps/web-antd/.env.production @@ -1,8 +1,5 @@ VITE_BASE=/ -# 接口地址 -VITE_GLOB_API_URL=https://mock-napi.vben.pro/api - # 是否开启压缩,可以设置为 none, brotli, gzip VITE_COMPRESS=none @@ -10,7 +7,22 @@ VITE_COMPRESS=none VITE_PWA=false # vue-router 的模式 -VITE_ROUTER_HISTORY=hash +VITE_ROUTER_HISTORY=history # 是否注入全局loading VITE_INJECT_APP_LOADING=true + +# 后台请求路径 具体在vite.config.mts配置代理 +VITE_GLOB_API_URL=/prod-api + +# 全局加密开关(即开启了加解密功能才会生效 不是全部接口加密 需要和后端对应) +VITE_GLOB_ENABLE_ENCRYPT=true +# RSA公钥 请求加密使用 注意这两个是两对RSA公私钥 请求加密-后端解密是一对 响应解密-后端加密是一对 +VITE_GLOB_RSA_PUBLIC_KEY=MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKoR8mX0rGKLqzcWmOzbfj64K8ZIgOdHnzkXSOVOZbFu/TJhZ7rFAN+eaGkl3C4buccQd/EjEsj9ir7ijT7h96MCAwEAAQ== +# RSA私钥 响应解密使用 注意这两个是两对RSA公私钥 请求加密-后端解密是一对 响应解密-后端加密是一对 +VITE_GLOB_RSA_PRIVATE_KEY=MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAmc3CuPiGL/LcIIm7zryCEIbl1SPzBkr75E2VMtxegyZ1lYRD+7TZGAPkvIsBcaMs6Nsy0L78n2qh+lIZMpLH8wIDAQABAkEAk82Mhz0tlv6IVCyIcw/s3f0E+WLmtPFyR9/WtV3Y5aaejUkU60JpX4m5xNR2VaqOLTZAYjW8Wy0aXr3zYIhhQQIhAMfqR9oFdYw1J9SsNc+CrhugAvKTi0+BF6VoL6psWhvbAiEAxPPNTmrkmrXwdm/pQQu3UOQmc2vCZ5tiKpW10CgJi8kCIFGkL6utxw93Ncj4exE/gPLvKcT+1Emnoox+O9kRXss5AiAMtYLJDaLEzPrAWcZeeSgSIzbL+ecokmFKSDDcRske6QIgSMkHedwND1olF8vlKsJUGK3BcdtM8w4Xq7BpSBwsloE= +# 客户端id +VITE_GLOB_APP_CLIENT_ID=6afcaa29272b14c1c87264950c726ef4 + +# 开启WEBSOCKET +VITE_GLOB_WEBSOCKET_ENABLE=false diff --git a/apps/web-antd/.vscode/settings.json b/apps/web-antd/.vscode/settings.json new file mode 100644 index 00000000..382d4256 --- /dev/null +++ b/apps/web-antd/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "editor.tabSize": 2, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit", + "source.fixAll.stylelint": "explicit" + } +} diff --git a/apps/web-antd/index.html b/apps/web-antd/index.html index ca532699..33d34a9e 100644 --- a/apps/web-antd/index.html +++ b/apps/web-antd/index.html @@ -14,19 +14,6 @@ <%= VITE_APP_TITLE %> -
diff --git a/apps/web-antd/package.json b/apps/web-antd/package.json index 6e6da999..a102d5b3 100644 --- a/apps/web-antd/package.json +++ b/apps/web-antd/package.json @@ -42,9 +42,17 @@ "@vben/utils": "workspace:*", "@vueuse/core": "^10.11.0", "ant-design-vue": "^4.2.3", + "crypto-js": "^4.2.0", "dayjs": "^1.11.12", + "echarts": "^5.5.1", + "jsencrypt": "^3.3.2", + "lodash-es": "^4.17.21", "pinia": "2.2.0", "vue": "^3.4.35", "vue-router": "^4.4.2" + }, + "devDependencies": { + "@types/crypto-js": "^4.2.2", + "@types/lodash-es": "^4.17.12" } } diff --git a/apps/web-antd/src/api/core/auth.ts b/apps/web-antd/src/api/core/auth.ts index 6950e3bf..f2e76a06 100644 --- a/apps/web-antd/src/api/core/auth.ts +++ b/apps/web-antd/src/api/core/auth.ts @@ -1,20 +1,25 @@ +import { useAppConfig } from '@vben/hooks'; + import { requestClient } from '#/api/request'; +const { clientId } = useAppConfig(import.meta.env, import.meta.env.PROD); + export namespace AuthApi { /** 登录接口参数 */ export interface LoginParams { + code?: string; + grantType: string; password: string; + tenantId: string; username: string; + uuid?: string; } /** 登录接口返回值 */ export interface LoginResult { - accessToken: string; - desc: string; - realName: string; - refreshToken: string; - userId: string; - username: string; + access_token: string; + client_id: string; + expire_in: number; } } @@ -22,12 +27,46 @@ export namespace AuthApi { * 登录 */ export async function login(data: AuthApi.LoginParams) { - return requestClient.post('/auth/login', data); + return requestClient.post( + '/auth/login', + { ...data, clientId }, + { + encrypt: true, + }, + ); } /** - * 获取用户权限码 + * 用户登出 + * @returns void */ -export async function getAccessCodes() { - return requestClient.get('/auth/codes'); +export function doLogout() { + return requestClient.post('/auth/logout'); +} + +/** + * @param companyName 租户/公司名称 + * @param domain 绑定域名(不带http(s)://) 可选 + * @param tenantId 租户id + */ +export interface TenantOption { + companyName: string; + domain?: string; + tenantId: string; +} + +/** + * @param tenantEnabled 是否启用租户 + * @param voList 租户列表 + */ +export interface TenantResp { + tenantEnabled: boolean; + voList: TenantOption[]; +} + +/** + * 获取租户列表 下拉框使用 + */ +export function tenantList() { + return requestClient.get('/auth/tenant/list'); } diff --git a/apps/web-antd/src/api/core/captcha.ts b/apps/web-antd/src/api/core/captcha.ts new file mode 100644 index 00000000..254e11be --- /dev/null +++ b/apps/web-antd/src/api/core/captcha.ts @@ -0,0 +1,42 @@ +import { requestClient } from '#/api/request'; + +/** + * 发送短信验证码 + * @param phonenumber 手机号 + * @returns void + */ +export function sendSmsCode(phonenumber: string) { + return requestClient.get('/resource/sms/code', { + params: { phonenumber }, + }); +} + +/** + * 发送邮件验证码 + * @param email 邮箱 + * @returns void + */ +export function sendEmailCode(email: string) { + return requestClient.get('/resource/email/code', { + params: { email }, + }); +} + +/** + * @param img 图片验证码 需要和base64拼接 + * @param captchaEnabled 是否开启 + * @param uuid 验证码ID + */ +export interface CaptchaResponse { + captchaEnabled: boolean; + img: string; + uuid: string; +} + +/** + * 图片验证码 + * @returns resp + */ +export function captchaImage() { + return requestClient.get('/auth/code'); +} diff --git a/apps/web-antd/src/api/core/menu.ts b/apps/web-antd/src/api/core/menu.ts index 62c40f17..a9e747db 100644 --- a/apps/web-antd/src/api/core/menu.ts +++ b/apps/web-antd/src/api/core/menu.ts @@ -1,10 +1,45 @@ -import type { RouteRecordStringComponent } from '@vben/types'; - import { requestClient } from '#/api/request'; +/** + * @description: 菜单meta + * @param title 菜单名 + * @param icon 菜单图标 + * @param noCache 是否不缓存 + * @param link 外链链接 + */ +export interface MenuMeta { + icon: string; + link?: string; + noCache: boolean; + title: string; +} + +/** + * @description: 菜单 + * @param name 菜单名 + * @param path 菜单路径 + * @param hidden 是否隐藏 + * @param component 组件名称 Laout + * @param alwaysShow 总是显示 + * @param query 路由参数(json形式) + * @param meta 路由信息 + * @param children 子路由信息 + */ +export interface Menu { + alwaysShow?: boolean; + children: Menu[]; + component: string; + hidden: boolean; + meta: MenuMeta; + name: string; + path: string; + query?: string; + redirect?: string; +} + /** * 获取用户所有菜单 */ export async function getAllMenus() { - return requestClient.get('/menu/all'); + return requestClient.get('/system/menu/getRouters'); } diff --git a/apps/web-antd/src/api/core/user.ts b/apps/web-antd/src/api/core/user.ts index 34c14ea9..4d201941 100644 --- a/apps/web-antd/src/api/core/user.ts +++ b/apps/web-antd/src/api/core/user.ts @@ -1,10 +1,45 @@ -import type { UserInfo } from '@vben/types'; - import { requestClient } from '#/api/request'; +export interface Role { + dataScope: string; + flag: boolean; + roleId: number; + roleKey: string; + roleName: string; + roleSort: number; + status: string; + superAdmin: boolean; +} + +export interface User { + avatar: string; + createTime: string; + deptId: number; + deptName: string; + email: string; + loginDate: string; + loginIp: string; + nickName: string; + phonenumber: string; + remark: string; + roles: Role[]; + sex: string; + status: string; + tenantId: string; + userId: number; + userName: string; + userType: string; +} + +export interface UserInfoResp { + permissions: string[]; + roles: string[]; + user: User; +} + /** * 获取用户信息 */ export async function getUserInfo() { - return requestClient.get('/user/info'); + return requestClient.get('/system/user/getInfo'); } diff --git a/apps/web-antd/src/api/helper.ts b/apps/web-antd/src/api/helper.ts new file mode 100644 index 00000000..d742bbbe --- /dev/null +++ b/apps/web-antd/src/api/helper.ts @@ -0,0 +1,69 @@ +import { isObject, isString } from '@vben/utils'; + +const DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'; + +export function joinTimestamp( + join: boolean, + restful: T, +): T extends true ? string : object; + +export function joinTimestamp(join: boolean, restful = false): object | string { + if (!join) { + return restful ? '' : {}; + } + const now = Date.now(); + if (restful) { + return `?_t=${now}`; + } + return { _t: now }; +} + +/** + * @description: Format request parameter time + */ +export function formatRequestDate(params: Record) { + if (Object.prototype.toString.call(params) !== '[object Object]') { + return; + } + + for (const key in params) { + const format = params[key]?.format ?? null; + if (format && typeof format === 'function') { + params[key] = params[key].format(DATE_TIME_FORMAT); + } + if (isString(key)) { + const value = params[key]; + if (value) { + try { + params[key] = isString(value) ? value.trim() : value; + } catch (error: any) { + throw new Error(error); + } + } + } + if (isObject(params[key])) { + formatRequestDate(params[key]); + } + } +} + +/** + * Add the object as a parameter to the URL + * @param baseUrl url + * @param obj + * @returns {string} + * eg: + * let obj = {a: '3', b: '4'} + * setObjToUrlParams('www.baidu.com', obj) + * ==>www.baidu.com?a=3&b=4 + */ +export function setObjToUrlParams(baseUrl: string, obj: any): string { + let parameters = ''; + for (const key in obj) { + parameters += `${key}=${encodeURIComponent(obj[key])}&`; + } + parameters = parameters.replace(/&$/, ''); + return /\?$/.test(baseUrl) + ? baseUrl + parameters + : baseUrl.replace(/\/?$/, '?') + parameters; +} diff --git a/apps/web-antd/src/api/monitor/cache/index.ts b/apps/web-antd/src/api/monitor/cache/index.ts new file mode 100644 index 00000000..1065d340 --- /dev/null +++ b/apps/web-antd/src/api/monitor/cache/index.ts @@ -0,0 +1,24 @@ +import { requestClient } from '#/api/request'; + +export interface CommandStats { + name: string; + value: string; +} + +export interface RedisInfo { + [key: string]: string; +} + +export interface CacheInfo { + commandStats: CommandStats[]; + dbSize: number; + info: RedisInfo; +} + +/** + * + * @returns redis信息 + */ +export function redisCacheInfo() { + return requestClient.get('/monitor/cache'); +} diff --git a/apps/web-antd/src/api/request.ts b/apps/web-antd/src/api/request.ts index 7cd11e27..5d92183e 100644 --- a/apps/web-antd/src/api/request.ts +++ b/apps/web-antd/src/api/request.ts @@ -4,19 +4,51 @@ import type { HttpResponse } from '@vben/request'; import { useAppConfig } from '@vben/hooks'; +import { $t } from '@vben/locales'; import { preferences } from '@vben/preferences'; import { RequestClient } from '@vben/request'; import { useAccessStore } from '@vben/stores'; +import { isString } from '@vben/utils'; -import { message } from 'ant-design-vue'; +import { message, Modal } from 'ant-design-vue'; +import { isEmpty, isNull } from 'lodash-es'; import { useAuthStore } from '#/store'; +import { + decryptBase64, + decryptWithAes, + encryptBase64, + encryptWithAes, + generateAesKey, +} from '#/utils/encryption/crypto'; +import * as encryptUtil from '#/utils/encryption/jsencrypt'; -const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD); +import { formatRequestDate, joinTimestamp, setObjToUrlParams } from './helper'; + +const { apiURL, clientId, enableEncrypt } = useAppConfig( + import.meta.env, + import.meta.env.PROD, +); + +/** 控制是否弹窗 防止登录超时请求多个api会弹窗多次 */ +let showTimeoutToast = true; function createRequestClient(baseURL: string) { const client = new RequestClient({ + // 后端地址 baseURL, + // 消息提示类型 + errorMessageMode: 'message', + // 格式化提交参数时间 + formatDate: true, + // 是否返回原生响应头 比如:需要获取响应头时使用该属性 + isReturnNativeResponse: false, + // 需要对返回数据进行处理 + isTransformResponse: true, + // post请求的时候添加参数到url + joinParamsToUrl: false, + // 是否加入时间戳 + joinTime: false, // 为每个请求携带 Authorization makeAuthorization: () => { return { @@ -43,23 +75,192 @@ function createRequestClient(baseURL: string) { }, }; }, + /** + * http状态码不为200会走到这里 + * 其他会走到addResponseInterceptor + * @param msg + * @returns void + */ makeErrorMessage: (msg) => message.error(msg), makeRequestHeaders: () => { + /** + * locale跟后台不一致 需要转换 + */ + const language = preferences.app.locale.replace('-', '_'); return { // 为每个请求携带 Accept-Language - 'Accept-Language': preferences.app.locale, + 'Accept-Language': language, + clientId, }; }, }); - client.addResponseInterceptor((response) => { - const { data: responseData, status } = response; - const { code, data, message: msg } = responseData; - if (status >= 200 && status < 400 && code === 0) { - return data; + client.addRequestInterceptor((config) => { + const { encrypt, formatDate, joinParamsToUrl, joinTime = true } = config; + const params = config.params || {}; + const data = config.data || false; + formatDate && data && !isString(data) && formatRequestDate(data); + if (config.method?.toUpperCase() === 'GET') { + if (isString(params)) { + // 兼容restful风格 + config.url = `${config.url + params}${joinTimestamp(joinTime, true)}`; + config.params = undefined; + } else { + // 给 get 请求加上时间戳参数,避免从缓存中拿数据。 + config.params = Object.assign( + params || {}, + joinTimestamp(joinTime, false), + ); + } + } else { + if (isString(params)) { + // 兼容restful风格 + config.url = config.url + params; + config.params = undefined; + } else { + formatDate && formatRequestDate(params); + if ( + Reflect.has(config, 'data') && + config.data && + (Object.keys(config.data).length > 0 || + config.data instanceof FormData) + ) { + config.data = data; + config.params = params; + } else { + // 非GET请求如果没有提供data,则将params视为data + config.data = params; + config.params = undefined; + } + if (joinParamsToUrl) { + config.url = setObjToUrlParams( + config.url as string, + Object.assign({}, config.params, config.data), + ); + } + } } - throw new Error(msg); + console.log('请求参数', config); + // 全局开启 && 该请求开启 && 是post/put请求 + if ( + enableEncrypt && + encrypt && + ['POST', 'PUT'].includes(config.method?.toUpperCase() || '') + ) { + const aesKey = generateAesKey(); + config.headers['encrypt-key'] = encryptUtil.encrypt( + encryptBase64(aesKey), + ); + + config.data = + typeof config.data === 'object' + ? encryptWithAes(JSON.stringify(config.data), aesKey) + : encryptWithAes(config.data, aesKey); + } + return config; + }); + + client.addResponseInterceptor((response) => { + const encryptKey = (response.headers || {})['encrypt-key']; + if (encryptKey) { + /** RSA私钥解密 拿到解密秘钥的base64 */ + const base64Str = encryptUtil.decrypt(encryptKey); + /** base64 解码 得到请求头的 AES 秘钥 */ + const aesSecret = decryptBase64(base64Str.toString()); + /** 使用aesKey解密 responseData */ + const decryptData = decryptWithAes( + response.data as unknown as string, + aesSecret, + ); + /** 赋值 需要转为对象 */ + response.data = JSON.parse(decryptData); + } + + const { isReturnNativeResponse, isTransformResponse } = response.config; + // 是否返回原生响应头 比如:需要获取响应头时使用该属性 + if (isReturnNativeResponse) { + return response; + } + // 不进行任何处理,直接返回 + // 用于页面代码可能需要直接获取code,data,message这些信息时开启 + if (!isTransformResponse) { + return response.data; + } + + const axiosResponseData = response.data; + if (!axiosResponseData) { + throw new Error($t('fallback.http.apiRequestFailed')); + } + + // ruoyi-plus没有采用严格的{code, msg, data}模式 + const { code, data, msg, ...other } = axiosResponseData; + + // 这里逻辑可以根据项目进行修改 + const hasSuccess = Reflect.has(axiosResponseData, 'code') && code === 200; + if (hasSuccess) { + let successMsg = msg; + + if (isNull(successMsg) || isEmpty(successMsg)) { + successMsg = $t(`fallback.http.operationSuccess`); + } + + if (response.config.successMessageMode === 'modal') { + Modal.success({ + content: successMsg, + title: $t('fallback.http.successTip'), + }); + } else if (response.config.successMessageMode === 'message') { + message.success(successMsg); + } + // ruoyi-plus没有采用严格的{code, msg, data}模式 + // 如果有data 直接返回data 没有data将剩余参数(...other)封装为data返回 + // 需要考虑data为null的情况(比如查询为空) + if (data !== undefined) { + return data; + } + return other; + } + // 在此处根据自己项目的实际情况对不同的code执行不同的操作 + // 如果不希望中断当前请求,请return数据,否则直接抛出异常即可 + let timeoutMsg = ''; + switch (code) { + case 401: { + const _msg = '登录超时, 请重新登录'; + const userStore = useAuthStore(); + userStore.logout().then(() => { + /** 只弹窗一次 */ + if (showTimeoutToast) { + showTimeoutToast = false; + message.error(_msg); + /** 定时器 3s后再开启弹窗 */ + setTimeout(() => { + showTimeoutToast = true; + }, 3000); + } + }); + // 不再执行下面逻辑 + return; + } + default: { + if (msg) { + timeoutMsg = msg; + } + } + } + + // errorMessageMode='modal'的时候会显示modal错误弹窗,而不是消息提示,用于一些比较重要的错误 + // errorMessageMode='none' 一般是调用时明确表示不希望自动弹出错误提示 + if (response.config.errorMessageMode === 'modal') { + Modal.error({ + content: timeoutMsg, + title: $t('fallback.http.errorTip'), + }); + } else if (response.config.errorMessageMode === 'message') { + message.error(timeoutMsg); + } + + throw new Error(timeoutMsg || $t('fallback.http.apiRequestFailed')); }); return client; } diff --git a/apps/web-antd/src/layouts/basic.vue b/apps/web-antd/src/layouts/basic.vue index 630d8bdd..a5c771b4 100644 --- a/apps/web-antd/src/layouts/basic.vue +++ b/apps/web-antd/src/layouts/basic.vue @@ -1,11 +1,8 @@ @@ -131,10 +89,12 @@ function handleMakeAll() {