chore: 脚手架

This commit is contained in:
dap
2024-08-07 08:57:56 +08:00
parent 4bd4f7490b
commit c31259598b
83 changed files with 2127 additions and 225 deletions

View File

@@ -1,16 +1,25 @@
# 端口号
VITE_PORT=5555
# base路径
VITE_BASE=/
# 接口地址
VITE_GLOB_API_URL=/api
# 是否开启 Nitro Mock服务true 为开启false 为关闭
VITE_NITRO_MOCK=true
# 是否打开 devtoolstrue 为打开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

View File

@@ -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

9
apps/web-antd/.vscode/settings.json vendored Normal file
View File

@@ -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"
}
}

View File

@@ -14,19 +14,6 @@
<!-- 由 vite 注入 VITE_APP_TITLE 变量,在 .env 文件内配置 -->
<title><%= VITE_APP_TITLE %></title>
<link rel="icon" href="/favicon.ico" />
<script>
// 生产环境下注入百度统计
if (window._VBEN_ADMIN_PRO_APP_CONF_) {
var _hmt = _hmt || [];
(function () {
var hm = document.createElement('script');
hm.src =
'https://hm.baidu.com/hm.js?d20a01273820422b6aa2ee41b6c9414d';
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(hm, s);
})();
}
</script>
</head>
<body>
<div id="app"></div>

View File

@@ -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"
}
}

View File

@@ -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<AuthApi.LoginResult>('/auth/login', data);
return requestClient.post<AuthApi.LoginResult>(
'/auth/login',
{ ...data, clientId },
{
encrypt: true,
},
);
}
/**
* 获取用户权限码
* 用户登出
* @returns void
*/
export async function getAccessCodes() {
return requestClient.get<string[]>('/auth/codes');
export function doLogout() {
return requestClient.post<void>('/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<TenantResp>('/auth/tenant/list');
}

View File

@@ -0,0 +1,42 @@
import { requestClient } from '#/api/request';
/**
* 发送短信验证码
* @param phonenumber 手机号
* @returns void
*/
export function sendSmsCode(phonenumber: string) {
return requestClient.get<void>('/resource/sms/code', {
params: { phonenumber },
});
}
/**
* 发送邮件验证码
* @param email 邮箱
* @returns void
*/
export function sendEmailCode(email: string) {
return requestClient.get<void>('/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<CaptchaResponse>('/auth/code');
}

View File

@@ -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<RouteRecordStringComponent[]>('/menu/all');
return requestClient.get<Menu[]>('/system/menu/getRouters');
}

View File

@@ -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<UserInfo>('/user/info');
return requestClient.get<UserInfoResp>('/system/user/getInfo');
}

View File

@@ -0,0 +1,69 @@
import { isObject, isString } from '@vben/utils';
const DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
export function joinTimestamp<T extends boolean>(
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<string, any>) {
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;
}

View File

@@ -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<CacheInfo>('/monitor/cache');
}

View File

@@ -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<HttpResponse>((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<HttpResponse>((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;
}
// 不进行任何处理,直接返回
// 用于页面代码可能需要直接获取codedatamessage这些信息时开启
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;
}

View File

@@ -1,11 +1,8 @@
<script lang="ts" setup>
import type { NotificationItem } from '@vben/layouts';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { computed, onMounted } from 'vue';
import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
import { LOGIN_PATH, VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
import { BookOpenText, CircleHelp, MdiGithub } from '@vben/icons';
import {
BasicLayout,
@@ -14,55 +11,18 @@ import {
UserDropdown,
} from '@vben/layouts';
import { preferences } from '@vben/preferences';
import {
resetAllStores,
storeToRefs,
useAccessStore,
useUserStore,
} from '@vben/stores';
import { storeToRefs, useAccessStore, useUserStore } from '@vben/stores';
import { openWindow } from '@vben/utils';
import { message } from 'ant-design-vue';
import { $t } from '#/locales';
import { resetRoutes } from '#/router';
import { useAuthStore } from '#/store';
const notifications = ref<NotificationItem[]>([
{
avatar: 'https://avatar.vercel.sh/vercel.svg?text=VB',
date: '3小时前',
isRead: true,
message: '描述信息描述信息描述信息',
title: '收到了 14 份新周报',
},
{
avatar: 'https://avatar.vercel.sh/1',
date: '刚刚',
isRead: false,
message: '描述信息描述信息描述信息',
title: '朱偏右 回复了你',
},
{
avatar: 'https://avatar.vercel.sh/1',
date: '2024-01-01',
isRead: false,
message: '描述信息描述信息描述信息',
title: '曲丽丽 评论了你',
},
{
avatar: 'https://avatar.vercel.sh/satori',
date: '1天前',
isRead: false,
message: '描述信息描述信息描述信息',
title: '代办提醒',
},
]);
import { useAuthStore, useNotifyStore } from '#/store';
const userStore = useUserStore();
const authStore = useAuthStore();
const accessStore = useAccessStore();
const showDot = computed(() =>
notifications.value.some((item) => !item.isRead),
);
const menus = computed(() => [
{
@@ -100,20 +60,18 @@ const avatar = computed(() => {
return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar;
});
const router = useRouter();
async function handleLogout() {
resetAllStores();
// resetAllStores();
resetRoutes();
await router.replace(LOGIN_PATH);
// await router.replace(LOGIN_PATH);
authStore.logout();
}
function handleNoticeClear() {
notifications.value = [];
}
const notifyStore = useNotifyStore();
onMounted(() => notifyStore.startListeningMessage());
function handleMakeAll() {
notifications.value.forEach((item) => (item.isRead = true));
function handleViewAll() {
message.warning('暂未开放');
}
</script>
@@ -131,10 +89,12 @@ function handleMakeAll() {
</template>
<template #notification>
<Notification
:dot="showDot"
:notifications="notifications"
@clear="handleNoticeClear"
@make-all="handleMakeAll"
:dot="notifyStore.showDot"
:notifications="notifyStore.notificationList"
@clear="notifyStore.clearAllMessage"
@make-all="notifyStore.setAllRead"
@read="notifyStore.setRead"
@view-all="handleViewAll"
/>
</template>
<template #extra>

View File

@@ -24,10 +24,10 @@ const localesMap = loadLocalesMap(modules);
*/
async function loadMessages(lang: SupportedLanguagesType) {
const [appLocaleMessages] = await Promise.all([
localesMap[lang]?.(),
localesMap[lang](),
loadThirdPartyMessage(lang),
]);
return appLocaleMessages?.default;
return appLocaleMessages.default;
}
/**

View File

@@ -7,6 +7,18 @@ import { defineOverridesPreferences } from '@vben/preferences';
export const overridesPreferences = defineOverridesPreferences({
// overrides
app: {
/**
* 不要动这里 后端路由模式
*/
accessMode: 'backend',
name: import.meta.env.VITE_APP_TITLE,
},
tabbar: {
/**
* 标签tab 持久化 关闭
*/
persist: false,
styleType: 'card',
},
theme: {},
});

View File

@@ -1,18 +1,88 @@
import type {
ComponentRecordType,
GenerateMenuAndRoutesOptions,
RouteRecordStringComponent,
} from '@vben/types';
import { generateAccessible } from '@vben/access';
import { preferences } from '@vben/preferences';
import { message } from 'ant-design-vue';
import { cloneDeep } from 'lodash-es';
import { getAllMenus } from '#/api';
import { getAllMenus, type Menu } from '#/api';
import { BasicLayout, IFrameView } from '#/layouts';
import { $t } from '#/locales';
const forbiddenComponent = () => import('#/views/_core/fallback/forbidden.vue');
const NotFoundComponent = () => import('#/views/_core/fallback/not-found.vue');
/**
* 这里放本地路由
*/
const localMenuList: RouteRecordStringComponent[] = [
{
component: 'BasicLayout',
meta: {
order: -1,
title: 'page.dashboard.title',
},
name: 'Dashboard',
path: '/',
redirect: '/analytics',
children: [
{
name: 'Analytics',
path: '/analytics',
component: '/dashboard/analytics/index',
meta: {
affixTab: true,
title: 'page.dashboard.analytics',
},
},
{
name: 'Workspace',
path: '/workspace',
component: '/dashboard/workspace/index',
meta: {
title: 'page.dashboard.workspace',
},
},
{
name: 'VbenDocument',
path: '/vben-admin/document',
component: 'IFrameView',
meta: {
icon: 'lucide:book-open-text',
iframeSrc: 'https://dapdap.top',
keepAlive: true,
title: $t('page.vben.document'),
},
},
],
},
{
component: 'BasicLayout',
meta: {
hideChildrenInMenu: true,
icon: 'lucide:copyright',
order: 9999,
title: $t('page.vben.about'),
},
name: 'About',
path: '/about',
children: [
{
component: '/_core/vben/about/index',
meta: {
title: $t('page.vben.about'),
},
name: 'VbenAbout',
path: '/vben-admin/about',
},
],
},
];
async function generateAccess(options: GenerateMenuAndRoutesOptions) {
const pageMap: ComponentRecordType = import.meta.glob('../views/**/*.vue');
@@ -20,16 +90,164 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) {
const layoutMap: ComponentRecordType = {
BasicLayout,
IFrameView,
NotFoundComponent,
};
/**
* 后台路由转vben路由
*
* todo 需要重构
* @param menuList 后台菜单
* @param parentPath 上级目录
* @returns vben路由
*/
function backMenuToVbenMenu(
menuList: Menu[],
parentPath = '',
): RouteRecordStringComponent[] {
const resultList: RouteRecordStringComponent[] = [];
menuList.forEach((menu) => {
// 根目录为菜单形式
// 固定有一个children children为当前菜单
if (menu.path === '/' && menu.children && menu.children.length === 1) {
menu.meta = menu.children[0].meta;
/**
* todo 先写死 后续再优化
*/
menu.path = '/root_menu';
menu.component = 'RootMenu';
}
// 外链: http开头 & 组件为Layout || ParentView
// 正则判断是否为http://或者https://开头
if (
/^http(s)?:\/\//.test(menu.path) &&
(menu.component === 'Layout' || menu.component === 'ParentView')
) {
menu.component = 'Link';
}
// 内嵌iframe 组件为InnerLink
if (menu.meta?.link && menu.component === 'InnerLink') {
menu.component = 'IFrameView';
}
// path
if (parentPath) {
menu.path = `${parentPath}/${menu.path}`;
}
const vbenRoute: RouteRecordStringComponent = {
component: menu.component,
meta: {
// 当前路由不在菜单显示 但是可以通过链接访问
// 不可访问的路由由后端控制隐藏(不返回对应路由)
hideMenu: menu.hidden,
icon: menu.meta?.icon,
keepAlive: !menu.meta?.noCache,
title: menu.meta?.title,
},
name: menu.name,
path: menu.path,
};
/**
* 处理不同组件
*/
switch (menu.component) {
case 'Layout': {
vbenRoute.component = 'BasicLayout';
break;
}
/**
* iframe内嵌
*/
case 'IFrameView': {
vbenRoute.component = 'IFrameView';
if (vbenRoute.meta) {
vbenRoute.meta.iframeSrc = menu.meta.link;
}
/**
* 需要判断特殊情况 比如vue的hash是带#的
* 比如链接 aaa.com/#/bbb path会转换为 aaa/com/#/bbb
* 比如链接 aaa.com/?bbb=xxx
* 需要去除# 否则无法被添加到路由
*/
/**
* todo 不优雅 考虑别的方案
*/
if (vbenRoute.path.includes('/#/')) {
vbenRoute.path = vbenRoute.path.replace('/#/', '');
}
if (vbenRoute.path.includes('#')) {
vbenRoute.path = vbenRoute.path.replace('#', '');
}
if (vbenRoute.path.includes('?') || vbenRoute.path.includes('&')) {
vbenRoute.path = vbenRoute.path.replace('?', '');
vbenRoute.path = vbenRoute.path.replace('&', '');
}
break;
}
/**
* 外链 新窗口打开
*/
case 'Link': {
if (vbenRoute.meta) {
vbenRoute.meta.link = menu.meta.link;
}
vbenRoute.component = 'BasicLayout';
break;
}
/**
* 根目录菜单
*/
case 'RootMenu': {
if (vbenRoute.meta) {
vbenRoute.meta.hideChildrenInMenu = true;
}
vbenRoute.component = 'BasicLayout';
console.log('RootMenu', vbenRoute);
break;
}
/**
* 不能为layout 会套两层BasicLayout
*/
case 'ParentView': {
vbenRoute.component = '';
break;
}
/**
* 其他自定义组件 如system/user/index 拼接/
*/
default: {
vbenRoute.component = `/${menu.component}`;
break;
}
}
// children处理
if (menu.children && menu.children.length > 0) {
vbenRoute.children = backMenuToVbenMenu(menu.children, menu.path);
}
resultList.push(vbenRoute);
});
return resultList;
}
return await generateAccessible(preferences.app.accessMode, {
...options,
fetchMenuListAsync: async () => {
message.loading({
content: `${$t('common.loadingMenu')}...`,
duration: 1.5,
duration: 1,
});
return await getAllMenus();
// 后台返回路由/菜单
const backMenuList = await getAllMenus();
// 转换为vben能用的路由
const vbenMenuList = backMenuToVbenMenu(backMenuList);
// 特别注意 这里要深拷贝
const menuList = [...cloneDeep(localMenuList), ...vbenMenuList];
console.log('menuList', menuList);
return menuList;
},
// 可以指定没有权限跳转403页面
forbiddenComponent,

View File

@@ -62,20 +62,14 @@ function setupAccessGuard(router: Router) {
const userStore = useUserStore();
const authStore = useAuthStore();
// 基本路由,这些路由不需要进入权限拦截
if (coreRouteNames.includes(to.name as string)) {
if (to.path === LOGIN_PATH && accessStore.accessToken) {
return decodeURIComponent(
(to.query?.redirect as string) || DEFAULT_HOME_PATH,
);
}
return true;
}
// accessToken 检查
if (!accessStore.accessToken) {
// 明确声明忽略权限访问权限,则可以访问
if (to.meta.ignoreAccess) {
if (
// 基本路由,这些路由不需要进入权限拦截
coreRouteNames.includes(to.name as string) ||
// 明确声明忽略权限访问权限,则可以访问
to.meta.ignoreAccess
) {
return true;
}
@@ -93,6 +87,15 @@ function setupAccessGuard(router: Router) {
}
const accessRoutes = accessStore.accessRoutes;
/**
* 已经登录 前往登录页 跳转到首页
*/
if (to.path === LOGIN_PATH) {
return {
path: DEFAULT_HOME_PATH,
replace: true,
};
}
// 是否已经生成过动态路由
if (accessRoutes && accessRoutes.length > 0) {

View File

@@ -10,7 +10,7 @@ import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
import { notification } from 'ant-design-vue';
import { defineStore } from 'pinia';
import { getAccessCodes, getUserInfo, login } from '#/api';
import { doLogout, getUserInfo, login } from '#/api';
import { $t } from '#/locales';
export const useAuthStore = defineStore('auth', () => {
@@ -33,40 +33,35 @@ export const useAuthStore = defineStore('auth', () => {
let userInfo: null | UserInfo = null;
try {
loginLoading.value = true;
const { accessToken, refreshToken } = await login(params);
const { access_token } = await login(params);
// 如果成功获取到 accessToken
if (accessToken) {
// 将 accessToken 存储到 accessStore 中
accessStore.setAccessToken(accessToken);
accessStore.setRefreshToken(refreshToken);
// accessToken 存储到 accessStore 中
accessStore.setAccessToken(access_token);
accessStore.setRefreshToken(access_token);
// 获取用户信息并存储到 accessStore 中
const [fetchUserInfoResult, accessCodes] = await Promise.all([
fetchUserInfo(),
getAccessCodes(),
]);
// 获取用户信息并存储到 accessStore 中
userInfo = await fetchUserInfo();
/**
* 设置用户信息
*/
userStore.setUserInfo(userInfo);
/**
* 在这里设置权限
*/
accessStore.setAccessCodes(userInfo.permissions);
userInfo = fetchUserInfoResult;
if (accessStore.loginExpired) {
accessStore.setLoginExpired(false);
} else {
onSuccess ? await onSuccess?.() : await router.push(DEFAULT_HOME_PATH);
}
userStore.setUserInfo(userInfo);
accessStore.setAccessCodes(accessCodes);
if (accessStore.loginExpired) {
accessStore.setLoginExpired(false);
} else {
onSuccess
? await onSuccess?.()
: await router.push(userInfo.homePath || DEFAULT_HOME_PATH);
}
if (userInfo?.realName) {
notification.success({
description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.realName}`,
duration: 3,
message: $t('authentication.loginSuccess'),
});
}
if (userInfo?.realName) {
notification.success({
description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.realName}`,
duration: 3,
message: $t('authentication.loginSuccess'),
});
}
} finally {
loginLoading.value = false;
@@ -78,21 +73,38 @@ export const useAuthStore = defineStore('auth', () => {
}
async function logout() {
resetAllStores();
accessStore.setLoginExpired(false);
try {
await doLogout();
} catch (error) {
console.error(error);
} finally {
resetAllStores();
accessStore.setLoginExpired(false);
// 回登陆页带上当前路由地址
await router.replace({
path: LOGIN_PATH,
query: {
redirect: encodeURIComponent(router.currentRoute.value.fullPath),
},
});
// 回登陆页带上当前路由地址
await router.replace({
path: LOGIN_PATH,
query: {
redirect: encodeURIComponent(router.currentRoute.value.fullPath),
},
});
}
}
async function fetchUserInfo() {
let userInfo: null | UserInfo = null;
userInfo = await getUserInfo();
const { permissions = [], roles = [], user } = await getUserInfo();
/**
* 从后台user -> vben user转换
*/
const userInfo: UserInfo = {
avatar: user.avatar ?? '',
permissions,
realName: user.nickName,
roles,
userId: user.userId,
username: user.userName,
};
userStore.setUserInfo(userInfo);
return userInfo;
}

View File

@@ -1 +1,2 @@
export * from './auth';
export * from './notify';

View File

@@ -0,0 +1,119 @@
import type { NotificationItem } from '@vben/layouts';
import { computed, ref, watch } from 'vue';
import { useAppConfig } from '@vben/hooks';
import { useAccessStore } from '@vben/stores';
import { useEventSource } from '@vueuse/core';
import { notification } from 'ant-design-vue';
import dayjs from 'dayjs';
import { random } from 'lodash-es';
import { defineStore } from 'pinia';
const { apiURL, clientId } = useAppConfig(
import.meta.env,
import.meta.env.PROD,
);
export const useNotifyStore = defineStore(
'app-notify',
() => {
const notificationList = ref<NotificationItem[]>([]);
/**
* 开始监听sse消息
*/
function startListeningMessage() {
const accessStore = useAccessStore();
const token = accessStore.accessToken;
const sseAddr = `${apiURL}/resource/sse?clientid=${clientId}&Authorization=Bearer ${token}`;
const { data } = useEventSource(sseAddr, [], {
autoReconnect: {
delay: 1000,
onFailed() {
console.log('sse重连失败.');
},
retries: 3,
},
});
watch(data, (message) => {
if (!message) return;
console.log(`接收到消息: ${message}`);
notification.success({
description: message,
duration: 3,
message: '收到新消息',
});
notificationList.value.push({
// 随机头像
avatar: `https://api.multiavatar.com/${random(0, 10_000)}.png`,
date: dayjs().format('YYYY-MM-DD HH:mm:ss'),
isRead: false,
message,
title: '消息',
});
data.value = null;
});
}
/**
* 设置全部已读
*/
function setAllRead() {
notificationList.value.forEach((item) => {
item.isRead = true;
});
}
/**
* 设置单条消息已读
* @param item 通知
*/
function setRead(item: NotificationItem) {
!item.isRead && (item.isRead = true);
}
/**
* 清空全部消息
*/
function clearAllMessage() {
notificationList.value = [];
}
/**
* 只需要空实现即可
* 否则会在退出登录清空所有
*/
function $reset() {
// notificationList.value = [];
}
/**
* 显示小圆点
*/
const showDot = computed(() =>
notificationList.value.some((item) => !item.isRead),
);
return {
$reset,
clearAllMessage,
notificationList,
setAllRead,
setRead,
showDot,
startListeningMessage,
};
},
{
persist: {
paths: ['notificationList'],
},
},
);

View File

@@ -0,0 +1,80 @@
import CryptoJS from 'crypto-js';
function randomUUID() {
const chars = [
...'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
];
const uuid = Array.from({ length: 36 });
let rnd = 0;
let r: number;
for (let i = 0; i < 36; i++) {
if (i === 8 || i === 13 || i === 18 || i === 23) {
uuid[i] = '-';
} else if (i === 14) {
uuid[i] = '4';
} else {
if (rnd <= 0x02)
rnd = Math.trunc(0x2_00_00_00 + Math.random() * 0x1_00_00_00);
r = rnd & 16;
rnd = rnd >> 4;
uuid[i] = chars[i === 19 ? (r & 0x3) | 0x8 : r];
}
}
return uuid.join('').replaceAll('-', '').toLowerCase();
}
/**
* 随机生成aes 密钥
*
* @returns aes 密钥
*/
export function generateAesKey() {
return CryptoJS.enc.Utf8.parse(randomUUID());
}
/**
* base64编码
* @param str
* @returns base64编码
*/
export function encryptBase64(str: CryptoJS.lib.WordArray) {
return CryptoJS.enc.Base64.stringify(str);
}
/**
* 使用公钥加密
* @param message 加密内容
* @param aesKey aesKey
* @returns 使用公钥加密
*/
export function encryptWithAes(
message: string,
aesKey: CryptoJS.lib.WordArray,
) {
const encrypted = CryptoJS.AES.encrypt(message, aesKey, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7,
});
return encrypted.toString();
}
/**
* 解密base64
*/
export function decryptBase64(str: string) {
return CryptoJS.enc.Base64.parse(str);
}
/**
* 使用密钥对数据进行解密
*/
export function decryptWithAes(
message: string,
aesKey: CryptoJS.lib.WordArray,
) {
const decrypted = CryptoJS.AES.decrypt(message, aesKey, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7,
});
return decrypted.toString(CryptoJS.enc.Utf8);
}

View File

@@ -0,0 +1,31 @@
// 密钥对生成 http://web.chacuo.net/netrsakeypair
import { useAppConfig } from '@vben/hooks';
import JSEncrypt from 'jsencrypt';
const { rsaPrivateKey, rsaPublicKey } = useAppConfig(
import.meta.env,
import.meta.env.PROD,
);
/**
* 加密
* @param txt 需要加密的数据
* @returns 加密后的数据
*/
export function encrypt(txt: string) {
const instance = new JSEncrypt();
instance.setPublicKey(rsaPublicKey);
return instance.encrypt(txt);
}
/**
* 解密
* @param txt 需要解密的数据
* @returns 解密后的数据
*/
export function decrypt(txt: string) {
const instance = new JSEncrypt();
instance.setPrivateKey(rsaPrivateKey);
return instance.decrypt(txt);
}

View File

@@ -16,7 +16,6 @@ const loading = ref(false);
* @param values 登录表单数据
*/
async function handleLogin(values: LoginCodeParams) {
// eslint-disable-next-line no-console
console.log(values);
}
</script>

View File

@@ -9,7 +9,6 @@ defineOptions({ name: 'ForgetPassword' });
const loading = ref(false);
function handleSubmit(value: string) {
// eslint-disable-next-line no-console
console.log('reset email:', value);
}
</script>

View File

@@ -1,18 +1,91 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { AuthenticationLogin } from '@vben/common-ui';
import { omit } from 'lodash-es';
import { tenantList, type TenantResp } from '#/api';
import { captchaImage, type CaptchaResponse } from '#/api/core/captcha';
import { useAuthStore } from '#/store';
defineOptions({ name: 'Login' });
const authStore = useAuthStore();
const captchaInfo = ref<CaptchaResponse>({
captchaEnabled: false,
img: '',
uuid: '',
});
async function loadCaptcha() {
const resp = await captchaImage();
if (resp.captchaEnabled) {
resp.img = `data:image/png;base64,${resp.img}`;
}
captchaInfo.value = resp;
}
const tenantInfo = ref<TenantResp>({
tenantEnabled: false,
voList: [],
});
async function loadTenant() {
const resp = await tenantList();
tenantInfo.value = resp;
}
onMounted(() => {
loadCaptcha();
loadTenant();
});
interface LoginForm {
code?: string;
grantType: string;
password: string;
tenantId: string;
username: string;
}
const loginRef = ref<InstanceType<typeof AuthenticationLogin>>();
async function handleAccountLogin(values: LoginForm) {
try {
const requestParam: any = omit(values, ['code']);
// 验证码
if (captchaInfo.value.captchaEnabled) {
requestParam.code = values.code;
requestParam.uuid = captchaInfo.value.uuid;
}
// 登录
await authStore.authLogin(requestParam);
} catch (error) {
console.error(error);
// 处理验证码错误
if (error instanceof Error) {
const message = error.message;
if (message.includes('captcha') || message.includes('验证码')) {
// 刷新验证码
loginRef.value?.resetCaptcha();
}
}
}
}
</script>
<template>
<AuthenticationLogin
ref="loginRef"
:captcha-base64="captchaInfo.img"
:loading="authStore.loginLoading"
password-placeholder="123456"
username-placeholder="vben"
@submit="authStore.authLogin"
:tenant-options="tenantInfo.voList"
:use-captcha="captchaInfo.captchaEnabled"
:use-tenant="tenantInfo.tenantEnabled"
password-placeholder="密码"
username-placeholder="用户名"
@captcha-click="loadCaptcha"
@submit="handleAccountLogin"
/>
</template>

View File

@@ -11,7 +11,6 @@ defineOptions({ name: 'Register' });
const loading = ref(false);
function handleSubmit(value: LoginAndRegisterParams) {
// eslint-disable-next-line no-console
console.log('register submit:', value);
}
</script>

View File

@@ -0,0 +1,18 @@
import { defineComponent } from 'vue';
import { Fallback } from '@vben/common-ui';
export default defineComponent({
name: 'CommonSkeleton',
setup() {
return () => (
<div class="flex h-[600px] w-full items-center justify-center">
<Fallback
description="等待官方组件中"
status="coming-soon"
title="Coming Soon"
/>
</div>
);
},
});

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -41,11 +41,9 @@ async function changeAccount(role: string) {
const account = accounts[role];
resetAllStores();
if (account) {
await authStore.authLogin(account, async () => {
router.go(0);
});
}
await authStore.authLogin(account, async () => {
router.go(0);
});
}
</script>

View File

@@ -41,17 +41,12 @@ async function changeAccount(role: string) {
const account = accounts[role];
resetAllStores();
if (account) {
await accessStore.authLogin(account, async () => {
router.go(0);
});
}
await accessStore.authLogin(account, async () => {
router.go(0);
});
}
async function handleToggleAccessMode() {
if (!accounts.super) {
return;
}
await toggleAccessMode();
resetAllStores();

View File

@@ -0,0 +1,6 @@
<template>
<iframe
class="size-full"
src="http://localhost:9090/admin/applications"
></iframe>
</template>

View File

@@ -0,0 +1,83 @@
<script lang="ts">
import type { EChartsOption } from 'echarts';
import { defineComponent, onMounted, ref, shallowRef, watch } from 'vue';
import { preferences } from '@vben/preferences';
import * as echarts from 'echarts';
export default defineComponent({
name: 'CommandChart',
props: {
data: {
default: () => [],
type: Array,
},
},
setup(props, { expose }) {
expose({});
const commandHtmlRef = ref<HTMLDivElement>();
const echartsInstance = shallowRef<echarts.ECharts | null>(null);
watch(
() => props.data,
() => {
if (!commandHtmlRef.value) return;
setEchartsOption(props.data);
},
{ immediate: true },
);
onMounted(() => {
echartsInstance.value = echarts.init(
commandHtmlRef.value,
preferences.theme.mode,
);
setEchartsOption(props.data);
});
watch(
() => preferences.theme.mode,
(mode) => {
echartsInstance.value?.dispose();
echartsInstance.value = echarts.init(commandHtmlRef.value, mode);
setEchartsOption(props.data);
},
);
function setEchartsOption(data: any[]) {
const option: EChartsOption = {
series: [
{
animationDuration: 1000,
animationEasing: 'cubicInOut',
center: ['50%', '38%'],
data,
name: '命令',
radius: [15, 95],
roseType: 'radius',
type: 'pie',
},
],
tooltip: {
formatter: '{a} <br/>{b} : {c} ({d}%)',
trigger: 'item',
},
};
echartsInstance.value?.setOption(option);
}
return {
commandHtmlRef,
};
},
});
</script>
<template>
<div ref="commandHtmlRef" class="h-[400px] w-full"></div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,102 @@
<script lang="ts">
import type { EChartsOption } from 'echarts';
import { defineComponent, onMounted, ref, shallowRef, watch } from 'vue';
import { preferences } from '@vben/preferences';
import * as echarts from 'echarts';
export default defineComponent({
name: 'MemoryChart',
props: {
data: {
default: '0',
type: String,
},
},
setup(props, { expose }) {
expose({});
const memoryHtmlRef = ref<HTMLDivElement>();
const echartsInstance = shallowRef<echarts.ECharts | null>(null);
watch(
() => props.data,
() => {
if (!memoryHtmlRef.value) return;
setEchartsOption(props.data);
},
{ immediate: true },
);
onMounted(() => {
echartsInstance.value = echarts.init(
memoryHtmlRef.value,
preferences.theme.mode,
);
setEchartsOption(props.data);
});
watch(
() => preferences.theme.mode,
(mode) => {
echartsInstance.value?.dispose();
echartsInstance.value = echarts.init(memoryHtmlRef.value, mode);
setEchartsOption(props.data);
},
);
function getNearestPowerOfTen(num: number) {
let power = 10;
while (power <= num) {
power *= 10;
}
return power;
}
function setEchartsOption(value: string) {
// x10
const formattedValue = Math.floor(Number.parseFloat(value));
// 最大值 10以内取10 100以内取100 以此类推
const max = getNearestPowerOfTen(formattedValue);
const options: EChartsOption = {
series: [
{
animation: true,
animationDuration: 1000,
data: [
{
name: '内存消耗',
value: Number.parseFloat(value),
},
],
detail: {
formatter: `${value}M`,
valueAnimation: true,
},
max,
min: 0,
name: '峰值',
type: 'gauge',
},
],
tooltip: {
formatter: `{b} <br/>{a} : ${value}M`,
},
};
echartsInstance.value?.setOption(options);
}
return {
memoryHtmlRef,
};
},
});
</script>
<template>
<div ref="memoryHtmlRef" class="h-[400px] w-full"></div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import type { RedisInfo } from '#/api/monitor/cache';
import type { PropType } from 'vue';
import { Descriptions, DescriptionsItem } from 'ant-design-vue';
interface IRedisInfo extends RedisInfo {
dbSize: string;
}
defineProps({
data: {
required: true,
type: Object as PropType<IRedisInfo>,
},
});
</script>
<template>
<Descriptions
:column="{ xs: 1, sm: 1, md: 3, lg: 4, xl: 4 }"
bordered
size="small"
>
<DescriptionsItem label="redis版本">
{{ data.redis_version }}
</DescriptionsItem>
<DescriptionsItem label="redis模式">
{{ data.redis_mode === 'standalone' ? '单机模式' : '集群模式' }}
</DescriptionsItem>
<DescriptionsItem label="tcp端口">
{{ data.tcp_port }}
</DescriptionsItem>
<DescriptionsItem label="客户端数">
{{ data.connected_clients }}
</DescriptionsItem>
<DescriptionsItem label="运行时间">
{{ `${data.uptime_in_days}` }}
</DescriptionsItem>
<DescriptionsItem label="使用内存">
{{ data.used_memory_human }}
</DescriptionsItem>
<DescriptionsItem label="使用CPU">
{{ parseFloat(data.used_cpu_user_children!).toFixed(2) }}
</DescriptionsItem>
<DescriptionsItem label="内存配置">
{{ data.maxmemory_human }}
</DescriptionsItem>
<DescriptionsItem label="AOF是否开启">
{{ data.aof_enabled === '0' ? '否' : '是' }}
</DescriptionsItem>
<DescriptionsItem label="RDB是否成功">
{{ data.rdb_last_bgsave_status }}
</DescriptionsItem>
<DescriptionsItem label="Key数量">
{{ data.dbSize }}
</DescriptionsItem>
<DescriptionsItem label="网络入口/出口">
{{
`${data.instantaneous_input_kbps}kps/${data.instantaneous_output_kbps}kps`
}}
</DescriptionsItem>
</Descriptions>
</template>

View File

@@ -0,0 +1,94 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { Button, Card, Col, Row } from 'ant-design-vue';
import { redisCacheInfo, type RedisInfo } from '#/api/monitor/cache';
import CommandChart from './components/CommandChart.vue';
import MemoryChart from './components/MemoryChart.vue';
import RedisDescription from './components/RedisDescription.vue';
const baseSpan = { lg: 12, md: 24, sm: 24, xl: 12, xs: 24 };
const chartData = reactive<{ command: any[]; memory: string }>({
command: [],
memory: '0',
});
interface IRedisInfo extends RedisInfo {
dbSize: string;
}
const redisInfo = ref<IRedisInfo>();
onMounted(async () => {
await loadInfo();
});
async function loadInfo() {
try {
const ret = await redisCacheInfo();
// 单位MB 保留两位小数
const usedMemory = (
Number.parseInt(ret.info.used_memory!) /
1024 /
1024
).toFixed(2);
chartData.memory = usedMemory;
// 命令统计
chartData.command = ret.commandStats;
// redis信息
redisInfo.value = { ...ret.info, dbSize: String(ret.dbSize) };
} catch (error) {
console.warn(error);
}
}
</script>
<template>
<div class="m-[16px]">
<Row :gutter="[15, 15]">
<Col :span="24">
<Card size="small">
<template #title>
<div class="flex items-center justify-start gap-[6px]">
<span class="icon-[logos--redis]"></span>
<span>redis信息</span>
</div>
</template>
<template #extra>
<Button size="small" @click="loadInfo">
<div class="flex">
<span class="icon-[charm--refresh]"></span>
</div>
</Button>
</template>
<RedisDescription v-if="redisInfo" :data="redisInfo" />
</Card>
</Col>
<Col v-bind="baseSpan">
<Card size="small">
<template #title>
<div class="flex items-center gap-[6px]">
<span class="icon-[flat-color-icons--command-line]"></span>
<span>命令统计</span>
</div>
</template>
<CommandChart :data="chartData.command" />
</Card>
</Col>
<Col v-bind="baseSpan">
<Card size="small">
<template #title>
<div class="flex items-center justify-start gap-[6px]">
<span class="icon-[la--memory]"></span>
<span>内存占用</span>
</div>
</template>
<MemoryChart :data="chartData.memory" />
</Card>
</Col>
</Row>
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,3 @@
<template>
<iframe class="size-full" src="http://localhost:8800/snail-job"></iframe>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { Button } from 'ant-design-vue';
onMounted(() => {
console.log('keepAlive测试 -> 挂载了');
});
</script>
<template>
<div class="m-[8px]">
<Button type="primary" v-access:code="['system:user:list']">
测试按钮可见
</Button>
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
</script>
<template>
<div>
<CommonSkeleton />
</div>
</template>

View File

@@ -10,7 +10,7 @@ export default defineConfig(async () => {
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
// mock代理目标地址
target: 'http://localhost:5320/api',
target: 'http://localhost:8080',
ws: true,
},
},