feat: 整合登录

This commit is contained in:
xlsea
2024-08-16 16:33:11 +08:00
parent e6aa25e9f8
commit 243de247f7
49 changed files with 889 additions and 3882 deletions

BIN
src/assets/imgs/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View File

@ -1 +0,0 @@
<svg viewBox="0 0 160 160" xmlns="http://www.w3.org/2000/svg"><path d="M81.28 55.9c-.1-11.67-2.93-22.55-9.37-32.38-1-1.5-2.14-2.86-2.5-4.71a8.1 8.1 0 014-8.61 7.89 7.89 0 019.3 1.23 35.999 35.999 0 015.9 8.83 75.18 75.18 0 018.44 28.58 83.211 83.211 0 01-5.23 36.74 102.983 102.983 0 01-3 7.28 1.2 1.2 0 000 1.41c9.58 13.3 21.76 23 37.85 27.24a54.37 54.37 0 0019.68 1.57 7.72 7.72 0 018.36 6.9 7.903 7.903 0 01-6.7 9 64.744 64.744 0 01-23-1.33 77.68 77.68 0 01-36.93-19.88 93.628 93.628 0 01-11.91-13.71 2.18 2.18 0 00-2.3-1.06 72.744 72.744 0 00-27.38 7.55c-11.6 6-20.67 14.58-26.4 26.45a10.134 10.134 0 01-3.7 4.7 8 8 0 01-9.19-.7 7.86 7.86 0 01-2.36-9.28 60.324 60.324 0 018.72-14.52c12.2-15.43 28.21-24.59 47.32-28.57A85.085 85.085 0 0173.07 87c.524.015 1-.307 1.18-.8a76.06 76.06 0 006.53-22.3c.351-2.652.518-5.325.5-8z" fill="currentColor"/><path d="M136.26 108.34a44.742 44.742 0 01-11.13-2.87 46.108 46.108 0 01-19.66-13.76 8 8 0 015.72-13.22 7.93 7.93 0 016.54 2.93 33.27 33.27 0 0018.87 10.75c1.546.155 3.058.553 4.48 1.18a8.08 8.08 0 013.84 9.21c-.92 3.52-4.13 5.81-8.66 5.78zm-80.6-75.02a7.61 7.61 0 016.64 5 49.139 49.139 0 013.64 17 46.33 46.33 0 01-2.46 17.28c-2 5.77-8.24 7.79-12.89 4.15a8.1 8.1 0 01-2.39-9 31.679 31.679 0 001.68-12.36 35.77 35.77 0 00-2.43-11c-2.1-5.45 1.75-11.07 8.21-11.07zm22.26 93.25a8 8 0 01-6.68 7.86 32.88 32.88 0 00-19.7 12.19 8.13 8.13 0 01-11.21 1.62 8 8 0 01-1.41-11.58A51.043 51.043 0 0154 123.81a45.842 45.842 0 0114-5.1c5.35-1.04 9.91 2.56 9.92 7.86z" fill="currentColor"/></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -3,7 +3,7 @@ defineOptions({ name: 'SystemLogo' });
</script>
<template>
<icon-local-logo />
<img src="@/assets/imgs/logo.png" />
</template>
<style scoped></style>

View File

@ -9,10 +9,10 @@ export function useAuth() {
}
if (typeof codes === 'string') {
return authStore.userInfo.buttons.includes(codes);
return authStore.userInfo.permissions.includes(codes);
}
return codes.some(code => authStore.userInfo.buttons.includes(code));
return codes.some(code => authStore.userInfo.permissions.includes(code));
}
return {

View File

@ -38,7 +38,8 @@ export function useFormRules() {
phone: [createRequiredRule($t('form.phone.required')), patternRules.phone],
pwd: [createRequiredRule($t('form.pwd.required')), patternRules.pwd],
code: [createRequiredRule($t('form.code.required')), patternRules.code],
email: [createRequiredRule($t('form.email.required')), patternRules.email]
email: [createRequiredRule($t('form.email.required')), patternRules.email],
tenantId: [createRequiredRule('请选择/输入公司名称')]
} satisfies Record<string, App.Global.FormRule[]>;
/** the default required rule */

View File

@ -38,7 +38,7 @@ export function useTable<A extends NaiveUI.TableApiFn>(config: NaiveUI.NaiveTabl
apiParams,
columns: config.columns,
transformer: res => {
const { records = [], current = 1, size = 10, total = 0 } = res.data || {};
const { rows: records = [], pageNum: current = 1, pageSize: size = 10, total = 0 } = res.data || {};
// Ensure that the size is greater than 0, If it is less than 0, it will cause paging calculation errors.
const pageSize = size <= 0 ? 10 : size;
@ -225,9 +225,9 @@ export function useTableOperate<T extends TableData = TableData>(data: Ref<T[]>,
/** the editing row data */
const editingData: Ref<T | null> = ref(null);
function handleEdit(id: T['id']) {
function handleEdit(field: keyof T, id: string) {
operateType.value = 'edit';
const findItem = data.value.find(item => item.id === id) || null;
const findItem = data.value.find(item => item[field] === id) || null;
editingData.value = jsonClone(findItem);
openDrawer();

View File

@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, defineAsyncComponent } from 'vue';
import { computed, defineAsyncComponent, onMounted } from 'vue';
import { AdminLayout, LAYOUT_SCROLL_EL_ID } from '@sa/materials';
import type { LayoutMode } from '@sa/materials';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { initWebSocket } from '@/utils/websocket';
import GlobalHeader from '../modules/global-header/index.vue';
import GlobalSider from '../modules/global-sider/index.vue';
import GlobalTab from '../modules/global-tab/index.vue';
@ -100,6 +101,11 @@ function getSiderCollapsedWidth() {
return w;
}
onMounted(() => {
const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
initWebSocket(`${protocol + window.location.host + import.meta.env.VITE_APP_BASE_API}/resource/websocket`);
});
</script>
<template>

View File

@ -73,7 +73,7 @@ function handleDropdown(key: DropdownKey) {
<div>
<ButtonIcon>
<SvgIcon icon="ph:user-circle" class="text-icon-large" />
<span class="text-16px font-medium">{{ authStore.userInfo.userName }}</span>
<span class="text-16px font-medium">{{ authStore.userInfo.user?.userName }}</span>
</ButtonIcon>
</div>
</NDropdown>

View File

@ -17,7 +17,7 @@ withDefaults(defineProps<Props>(), {
<template>
<RouterLink to="/" class="w-full flex-center nowrap-hidden">
<SystemLogo class="text-32px text-primary" />
<SystemLogo class="w-32px text-primary" />
<h2 v-show="showTitle" class="pl-8px text-16px text-primary font-bold transition duration-300 ease-in-out">
{{ $t('system.title') }}
</h2>

View File

@ -1,6 +1,6 @@
const local: App.I18n.Schema = {
system: {
title: 'SoybeanAdmin',
title: 'RuoYi Vue Plus',
updateTitle: 'System Version Update Notification',
updateContent: 'A new version of the system has been detected. Do you want to refresh the page immediately?',
updateConfirm: 'Refresh immediately',

View File

@ -1,6 +1,6 @@
const local: App.I18n.Schema = {
system: {
title: 'Soybean 管理系统',
title: 'RuoYi Vue Plus',
updateTitle: '系统版本更新通知',
updateContent: '检测到系统有新版本发布,是否立即刷新页面?',
updateConfirm: '立即刷新',

View File

@ -2,36 +2,20 @@
import { getRgb } from '@sa/color';
import { $t } from '@/locales';
import { localStg } from '@/utils/storage';
import systemLogo from '@/assets/svg-icon/logo.svg?raw';
import SystemLogo from '@/assets/imgs/logo.png';
export function setupLoading() {
const themeColor = localStg.get('themeColor') || '#646cff';
const themeColor = localStg.get('themeColor') || '#2080f0';
const { r, g, b } = getRgb(themeColor);
const primaryColor = `--primary-color: ${r} ${g} ${b}`;
const loadingClasses = [
'left-0 top-0',
'left-0 bottom-0 animate-delay-500',
'right-0 top-0 animate-delay-1000',
'right-0 bottom-0 animate-delay-1500'
];
const logoWithClass = systemLogo.replace('<svg', `<svg class="size-128px text-primary"`);
const dot = loadingClasses
.map(item => {
return `<div class="absolute w-16px h-16px bg-primary rounded-8px animate-pulse ${item}"></div>`;
})
.join('\n');
const loading = `
<div class="fixed-center flex-col" style="${primaryColor}">
${logoWithClass}
<div class="w-56px h-56px my-36px">
<div class="w-120px h-120px my-36px">
<div class="relative h-full animate-spin">
${dot}
<img src="${SystemLogo}" width="120" />
</div>
</div>
<h2 class="text-28px font-500 text-#646464">${$t('system.title')}</h2>

View File

@ -30,6 +30,99 @@ export function createStaticRoutes() {
};
}
const dynamicConstantRoutes: ElegantRoute[] = [
{
name: 'home',
path: '/home',
component: 'layout.base$view.home',
meta: {
title: 'home',
i18nKey: 'route.home',
icon: 'mdi:monitor-dashboard',
order: 1
}
},
{
name: '403',
path: '/403',
component: 'layout.blank$view.403',
meta: {
title: '403',
i18nKey: 'route.403',
constant: true,
hideInMenu: true
}
},
{
name: '404',
path: '/404',
component: 'layout.blank$view.404',
meta: {
title: '404',
i18nKey: 'route.404',
constant: true,
hideInMenu: true
}
},
{
name: '500',
path: '/500',
component: 'layout.blank$view.500',
meta: {
title: '500',
i18nKey: 'route.500',
constant: true,
hideInMenu: true
}
},
{
name: 'login',
path: '/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?',
component: 'layout.blank$view.login',
props: true,
meta: {
title: 'login',
i18nKey: 'route.login',
constant: true,
hideInMenu: true
}
},
{
name: 'iframe-page',
path: '/iframe-page/:url',
component: 'layout.base$view.iframe-page',
props: true,
meta: {
title: 'iframe-page',
i18nKey: 'route.iframe-page',
constant: true,
hideInMenu: true,
keepAlive: true,
icon: 'material-symbols:iframe-outline'
}
}
];
/** create routes when the auth route mode is static */
export function createDynamicRoutes() {
const constantRoutes: ElegantConstRoute[] = [];
const authRoutes: ElegantConstRoute[] = [];
[...customRoutes, ...dynamicConstantRoutes].forEach(item => {
if (item.meta?.constant) {
constantRoutes.push(item);
} else {
authRoutes.push(item);
}
});
return {
constantRoutes,
authRoutes
};
}
/**
* Get auth vue routes
*

View File

@ -1,48 +1,41 @@
import { request } from '../request';
/** Get tenant list */
export function fetchTenantList() {
return request<Api.Auth.TenantList>({
url: '/auth/tenant/list',
method: 'get'
});
}
/** Get image code */
export function fetchCaptchaCode() {
return request<Api.Auth.CaptchaCode>({
url: '/auth/code',
method: 'get'
});
}
/**
* Login
*
* @param userName User name
* @param username User name
* @param password Password
*/
export function fetchLogin(userName: string, password: string) {
export function fetchLogin(data: Api.Auth.LoginData) {
return request<Api.Auth.LoginToken>({
url: '/auth/login',
method: 'post',
data: {
userName,
password
}
headers: {
isToken: false,
isEncrypt: true,
repeatSubmit: false
},
data
});
}
/** Get user info */
export function fetchGetUserInfo() {
return request<Api.Auth.UserInfo>({ url: '/auth/getUserInfo' });
}
/**
* Refresh token
*
* @param refreshToken Refresh token
*/
export function fetchRefreshToken(refreshToken: string) {
return request<Api.Auth.LoginToken>({
url: '/auth/refreshToken',
method: 'post',
data: {
refreshToken
}
});
}
/**
* return custom backend error
*
* @param code error code
* @param msg error message
*/
export function fetchCustomBackendError(code: string, msg: string) {
return request({ url: '/auth/error', params: { code, msg } });
return request<Api.Auth.UserInfo>({ url: '/system/user/getInfo' });
}

View File

@ -1,20 +1,7 @@
import type { ElegantConstRoute } from '@elegant-router/types';
import { request } from '../request';
/** get constant routes */
export function fetchGetConstantRoutes() {
return request<Api.Route.MenuRoute[]>({ url: '/route/getConstantRoutes' });
}
/** get user routes */
export function fetchGetUserRoutes() {
return request<Api.Route.UserRoute>({ url: '/route/getUserRoutes' });
}
/**
* whether the route is exist
*
* @param routeName route name
*/
export function fetchIsRouteExist(routeName: string) {
return request<boolean>({ url: '/route/isRouteExist', params: { routeName } });
/** get routes */
export function fetchGetRoutes() {
return request<ElegantConstRoute[]>({ url: '/system/menu/getRouters' });
}

View File

@ -0,0 +1,10 @@
import { request } from '@/service/request';
/** get menu list */
export function fetchGetMenuList(params?: Api.System.MenuSearchParams) {
return request<Api.System.MenuList>({
url: '/system/menu/list',
method: 'get',
params
});
}

View File

@ -1,30 +1,50 @@
import type { AxiosResponse } from 'axios';
import { BACKEND_ERROR_CODE, createFlatRequest, createRequest } from '@sa/axios';
import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import { BACKEND_ERROR_CODE, createFlatRequest } from '@sa/axios';
import { useAuthStore } from '@/store/modules/auth';
import { $t } from '@/locales';
import { localStg } from '@/utils/storage';
import { localStg, sessionStg } from '@/utils/storage';
import { getServiceBaseURL } from '@/utils/service';
import { decryptBase64, decryptWithAes, encryptBase64, encryptWithAes, generateAesKey } from '@/utils/crypto';
import { decrypt, encrypt } from '@/utils/jsencrypt';
import { handleRefreshToken, showErrorMsg } from './shared';
import type { RequestInstanceState } from './type';
const encryptHeader = 'encrypt-key';
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
const { baseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
export const request = createFlatRequest<App.Service.Response, RequestInstanceState>(
{
baseURL,
headers: {
apifoxToken: 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2'
'axios-retry': {
retries: 0
}
},
{
async onRequest(config) {
const { headers } = config;
// 对应国际化资源文件后缀
config.headers['Content-Language'] = 'zh_CN';
const isToken = config.headers?.isToken === false;
// set token
const token = localStg.get('token');
const Authorization = token ? `Bearer ${token}` : null;
Object.assign(headers, { Authorization });
if (token && !isToken) {
config.headers.Clientid = import.meta.env.VITE_APP_CLIENT_ID;
const Authorization = token ? `Bearer ${token}` : null;
Object.assign(headers, { Authorization });
}
handleRepeatSubmit(config);
handleEncrypt(config);
// FormData数据去请求头Content-Type
if (config.data instanceof FormData) {
delete config.headers['Content-Type'];
}
return config;
},
@ -98,6 +118,31 @@ export const request = createFlatRequest<App.Service.Response, RequestInstanceSt
return null;
},
transformBackendResponse(response) {
if (import.meta.env.VITE_APP_ENCRYPT === 'true') {
// 加密后的 AES 秘钥
const keyStr = response.headers[encryptHeader];
// 加密
if (keyStr && keyStr !== '') {
const data = String(response.data);
// 请求体 AES 解密
const base64Str = decrypt(keyStr);
// base64 解码 得到请求头的 AES 秘钥
const aesKey = decryptBase64(base64Str.toString());
// aesKey 解码 data
const decryptData = decryptWithAes(data, aesKey);
// 将结果 (得到的是 JSON 字符串) 转为 JSON
response.data = JSON.parse(decryptData);
}
}
// 二进制数据则直接返回
if (response.request.responseType === 'blob' || response.request.responseType === 'arraybuffer') {
return response.data;
}
if (response.data.rows) {
return response.data;
}
return response.data.data;
},
onError(error) {
@ -129,44 +174,48 @@ export const request = createFlatRequest<App.Service.Response, RequestInstanceSt
}
);
export const demoRequest = createRequest<App.Service.DemoResponse>(
{
baseURL: otherBaseURL.demo
},
{
async onRequest(config) {
const { headers } = config;
function handleRepeatSubmit(config: InternalAxiosRequestConfig) {
// 是否需要防止数据重复提交
const isRepeatSubmit = config.headers?.repeatSubmit === false;
// set token
const token = localStg.get('token');
const Authorization = token ? `Bearer ${token}` : null;
Object.assign(headers, { Authorization });
return config;
},
isBackendSuccess(response) {
// when the backend response code is "200", it means the request is success
// you can change this logic by yourself
return response.data.status === '200';
},
async onBackendFail(_response) {
// when the backend response code is not "200", it means the request is fail
// for example: the token is expired, refresh token and retry request
},
transformBackendResponse(response) {
return response.data.result;
},
onError(error) {
// when the request is fail, you can show error message
let message = error.message;
// show backend error message
if (error.code === BACKEND_ERROR_CODE) {
message = error.response?.data?.message || message;
if (!isRepeatSubmit && (config.method === 'post' || config.method === 'put')) {
const requestObj = {
url: config.url!,
data: typeof config.data === 'object' ? JSON.stringify(config.data) : config.data,
time: new Date().getTime()
};
const sessionObj = sessionStg.get('sessionObj');
if (!sessionObj) {
sessionStg.set('sessionObj', requestObj);
} else {
const s_url = sessionObj.url; // 请求地址
const s_data = sessionObj.data; // 请求数据
const s_time = sessionObj.time; // 请求时间
const interval = 500; // 间隔时间(ms),小于此时间视为重复提交
if (s_data === requestObj.data && requestObj.time - s_time < interval && s_url === requestObj.url) {
const message = '数据正在处理,请勿重复提交';
console.warn(`[${s_url}]: ${message}`);
throw new Error(message);
}
window.$message?.error(message);
sessionStg.set('sessionObj', requestObj);
}
}
);
}
function handleEncrypt(config: InternalAxiosRequestConfig) {
// 是否需要加密
const isEncrypt = config.headers?.isEncrypt === 'true';
if (import.meta.env.VITE_APP_ENCRYPT === 'true') {
// 当开启参数加密
if (isEncrypt && (config.method === 'post' || config.method === 'put')) {
// 生成一个 AES 密钥
const aesKey = generateAesKey();
config.headers[encryptHeader] = encrypt(encryptBase64(aesKey));
config.data =
typeof config.data === 'object'
? encryptWithAes(JSON.stringify(config.data), aesKey)
: encryptWithAes(config.data, aesKey);
}
}
}

View File

@ -1,7 +1,6 @@
import type { AxiosRequestConfig } from 'axios';
import { useAuthStore } from '@/store/modules/auth';
import { localStg } from '@/utils/storage';
import { fetchRefreshToken } from '../api';
// import { localStg } from '@/utils/storage';
import type { RequestInstanceState } from './type';
/**
@ -9,22 +8,11 @@ import type { RequestInstanceState } from './type';
*
* @param axiosConfig - request config when the token is expired
*/
export async function handleRefreshToken(axiosConfig: AxiosRequestConfig) {
export async function handleRefreshToken(_: AxiosRequestConfig) {
const { resetStore } = useAuthStore();
const refreshToken = localStg.get('refreshToken') || '';
const { error, data } = await fetchRefreshToken(refreshToken);
if (!error) {
localStg.set('token', data.token);
localStg.set('refreshToken', data.refreshToken);
const config = { ...axiosConfig };
if (config.headers) {
config.headers.Authorization = data.token;
}
return config;
}
// request
// const refreshToken = localStg.get('refreshToken') || '';
resetStore();

View File

@ -6,7 +6,7 @@ import { SetupStoreId } from '@/enum';
import { useRouterPush } from '@/hooks/common/router';
import { fetchGetUserInfo, fetchLogin } from '@/service/api';
import { localStg } from '@/utils/storage';
import { $t } from '@/locales';
// import { $t } from '@/locales';
import { useRouteStore } from '../route';
import { useTabStore } from '../tab';
import { clearAuthStorage, getToken } from './shared';
@ -21,10 +21,9 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
const token = ref(getToken());
const userInfo: Api.Auth.UserInfo = reactive({
userId: '',
userName: '',
user: undefined,
roles: [],
buttons: []
permissions: []
});
/** is super role in static route */
@ -56,14 +55,20 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
/**
* Login
*
* @param userName User name
* @param password Password
* @param [redirect=true] Whether to redirect after login. Default is `true`
*/
async function login(userName: string, password: string, redirect = true) {
async function login(loginForm: Api.Auth.LoginForm, redirect = true) {
startLoading();
const { data: loginToken, error } = await fetchLogin(userName, password);
const { VITE_APP_CLIENT_ID } = import.meta.env;
const loginData: Api.Auth.LoginData = {
...loginForm,
clientId: VITE_APP_CLIENT_ID!,
grantType: 'password'
};
const { data: loginToken, error } = await fetchLogin(loginData);
if (!error) {
const pass = await loginByToken(loginToken);
@ -76,11 +81,11 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
}
if (routeStore.isInitAuthRoute) {
window.$notification?.success({
title: $t('page.login.common.loginSuccess'),
content: $t('page.login.common.welcomeBack', { userName: userInfo.userName }),
duration: 4500
});
// window.$notification?.success({
// title: $t('page.login.common.loginSuccess'),
// content: $t('page.login.common.welcomeBack', { userName: userInfo.userName }),
// duration: 4500
// });
}
}
} else {
@ -88,18 +93,20 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
}
endLoading();
return error ? Promise.reject(error) : Promise.resolve();
}
async function loginByToken(loginToken: Api.Auth.LoginToken) {
// 1. stored in the localStorage, the later requests need it in headers
localStg.set('token', loginToken.token);
localStg.set('refreshToken', loginToken.refreshToken);
localStg.set('token', loginToken.access_token);
localStg.set('refreshToken', loginToken.refresh_token);
// 2. get user info
const pass = await getUserInfo();
if (pass) {
token.value = loginToken.token;
token.value = loginToken.access_token;
return true;
}

View File

@ -0,0 +1,43 @@
import { defineStore } from 'pinia';
import { reactive } from 'vue';
interface NoticeItem {
title?: string;
read: boolean;
message: any;
time: string;
}
export const useNoticeStore = defineStore('notice', () => {
const state = reactive({
notices: [] as NoticeItem[]
});
const addNotice = (notice: NoticeItem) => {
state.notices.push(notice);
};
const removeNotice = (notice: NoticeItem) => {
state.notices.splice(state.notices.indexOf(notice), 1);
};
// 实现全部已读
const readAll = () => {
state.notices.forEach((item: any) => {
item.read = true;
});
};
const clearNotice = () => {
state.notices = [];
};
return {
state,
addNotice,
removeNotice,
readAll,
clearNotice
};
});
export default useNoticeStore;

View File

@ -5,10 +5,10 @@ import { useBoolean } from '@sa/hooks';
import type { CustomRoute, ElegantConstRoute, LastLevelRouteKey, RouteKey, RouteMap } from '@elegant-router/types';
import { SetupStoreId } from '@/enum';
import { router } from '@/router';
import { createStaticRoutes, getAuthVueRoutes } from '@/router/routes';
import { createDynamicRoutes, createStaticRoutes, getAuthVueRoutes } from '@/router/routes';
import { ROOT_ROUTE } from '@/router/routes/builtin';
import { getRouteName, getRoutePath } from '@/router/elegant/transform';
import { fetchGetConstantRoutes, fetchGetUserRoutes, fetchIsRouteExist } from '@/service/api';
import { fetchGetRoutes } from '@/service/api';
import { useAppStore } from '../app';
import { useAuthStore } from '../auth';
import { useTabStore } from '../tab';
@ -75,6 +75,17 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
authRoutesMap.set(route.name, route);
});
const dynamicRoutes = createDynamicRoutes();
dynamicRoutes.authRoutes.forEach(route => {
const parent = authRoutesMap.get(route.name);
if (parent && route.children) {
parent.children?.push(...route.children);
return;
}
authRoutesMap.set(route.name, route);
});
authRoutes.value = Array.from(authRoutesMap.values());
}
@ -201,14 +212,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
if (authRouteMode.value === 'static') {
addConstantRoutes(staticRoute.constantRoutes);
} else {
const { data, error } = await fetchGetConstantRoutes();
if (!error) {
addConstantRoutes(data);
} else {
// if fetch constant routes failed, use static constant routes
addConstantRoutes(staticRoute.constantRoutes);
}
addConstantRoutes(staticRoute.constantRoutes);
}
handleConstantAndAuthRoutes();
@ -246,18 +250,16 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
/** Init dynamic auth route */
async function initDynamicAuthRoute() {
const { data, error } = await fetchGetUserRoutes();
const { data, error } = await fetchGetRoutes();
if (!error) {
const { routes, home } = data;
addAuthRoutes(routes);
addAuthRoutes(data);
handleConstantAndAuthRoutes();
setRouteHome(home);
setRouteHome('home');
handleUpdateRootRouteRedirect(home);
handleUpdateRootRouteRedirect('home');
setIsInitAuthRoute(true);
} else {
@ -335,14 +337,8 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
return false;
}
if (authRouteMode.value === 'static') {
const { authRoutes: staticAuthRoutes } = createStaticRoutes();
return isRouteExistByRouteName(routeName, staticAuthRoutes);
}
const { data } = await fetchIsRouteExist(routeName);
return data;
const { authRoutes: staticAuthRoutes } = createStaticRoutes();
return isRouteExistByRouteName(routeName, staticAuthRoutes);
}
/**

View File

@ -4,7 +4,7 @@ export const themeSettings: App.Theme.ThemeSetting = {
grayscale: false,
colourWeakness: false,
recommendColor: false,
themeColor: '#646cff',
themeColor: '#2080f0',
otherColor: {
info: '#2080f0',
success: '#52c41a',

View File

@ -8,40 +8,38 @@ declare namespace Api {
/** common params of paginating */
interface PaginatingCommonParams {
/** current page number */
current: number;
pageNum: number;
/** page size */
size: number;
pageSize: number;
/** total count */
total: number;
}
/** common params of paginating query list data */
interface PaginatingQueryRecord<T = any> extends PaginatingCommonParams {
records: T[];
rows: T[];
}
/**
* enable status
*
* - "1": enabled
* - "2": disabled
* - "0": enabled
* - "1": disabled
*/
type EnableStatus = '1' | '2';
type EnableStatus = '0' | '1';
/** common record */
type CommonRecord<T = any> = {
/** record id */
id: number;
/** record creator */
createBy: string;
/** record dept */
createDept?: any;
/** record create time */
createTime: string;
/** record updater */
updateBy: string;
/** record update time */
updateTime: string;
/** record status */
status: EnableStatus | null;
} & T;
}
@ -51,16 +49,53 @@ declare namespace Api {
* backend api module: "auth"
*/
namespace Auth {
interface LoginData {
tenantId?: string;
username?: string;
password?: string;
rememberMe?: boolean;
socialCode?: string;
socialState?: string;
source?: string;
code?: string;
uuid?: string;
clientId: string;
grantType: string;
}
type LoginForm = Pick<LoginData, 'tenantId' | 'username' | 'password' | 'rememberMe' | 'code' | 'uuid'>;
interface LoginToken {
token: string;
refreshToken: string;
access_token: string;
client_id: string;
expire_in: number;
openid: string;
refresh_expire_in: number;
refresh_token: string;
scope: string;
}
interface UserInfo {
userId: string;
userName: string;
user?: Api.System.User;
roles: string[];
buttons: string[];
permissions: string[];
}
interface Tenant {
companyName: string;
domain: string;
tenantId: string;
}
interface TenantList {
tenantEnabled: boolean;
voList: Tenant[];
}
interface CaptchaCode {
captchaEnabled: boolean;
uuid?: string;
img?: string;
}
}

123
src/typings/api/system.api.d.ts vendored Normal file
View File

@ -0,0 +1,123 @@
/**
* Namespace Api
*
* All backend api type
*/
declare namespace Api {
/**
* namespace System
*
* backend api module: "system"
*/
namespace System {
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'pageNum' | 'pageSize'>;
/** role */
type Role = Common.CommonRecord<{
roleId: string | number;
roleName: string;
roleKey: string;
roleSort: number;
dataScope: string;
menuCheckStrictly: boolean;
deptCheckStrictly: boolean;
status: string;
delFlag: string;
remark?: any;
flag: boolean;
menuIds?: Array<string | number>;
deptIds?: Array<string | number>;
admin: boolean;
}>;
/** role search params */
type RoleSearchParams = CommonType.RecordNullable<
Pick<Role, 'roleName' | 'roleKey' | 'status'> & CommonSearchParams
>;
/** role list */
type RoleList = Common.PaginatingQueryRecord<Role>;
/** all role */
type AllRole = Pick<Role, 'roleId' | 'roleName' | 'roleKey'>;
/**
* user gender
*
* - "1": "male"
* - "2": "female"
*/
type UserGender = '1' | '2';
/** user */
type User = Common.CommonRecord<{
userId: string | number;
deptId: number;
userName: string;
nickName: string;
userType: string;
email: string;
phonenumber: string;
sex: string;
avatar: string;
status: string;
delFlag: string;
loginIp: string;
loginDate: string;
remark: string;
deptName: string;
roles: Role[];
roleIds: any;
postIds: any;
roleId: any;
admin: boolean;
}>;
/** user search params */
type UserSearchParams = CommonType.RecordNullable<
Pick<User, 'userName' | 'sex' | 'nickName' | 'phonenumber' | 'email' | 'status'> & CommonSearchParams
>;
/** user list */
type UserList = Common.PaginatingQueryRecord<User>;
/**
* menu type
*
* - "M": "目录"
* - "C": "菜单"
* - "F": "按钮"
*/
type MenuType = 'M' | 'C' | 'F';
type Menu = Common.CommonRecord<
{
parentName: string;
parentId: string | number;
children: Menu[];
menuId: string | number;
menuName: string;
orderNum: number;
path: string;
component: string;
queryParam: string;
isFrame: string;
isCache: string;
menuType: MenuType;
visible: string;
status: Common.EnableStatus;
perms: string;
icon: string;
componentInfo: string;
remark: string;
keywords?: string;
} & Pick<import('vue-router').RouteMeta, 'i18nKey'>
>;
/** menu list */
type MenuList = Common.PaginatingQueryRecord<Menu>;
type MenuSearchParams = CommonType.RecordNullable<Pick<Api.System.Menu, 'menuName' | 'status' | 'keywords'>> &
CommonSearchParams;
}
}

View File

@ -532,6 +532,7 @@ declare namespace App {
baseURL: string;
/** The proxy pattern of the backend service base url */
proxyPattern: string;
ws?: boolean;
}
interface OtherServiceConfigItem extends ServiceConfigItem {
@ -556,6 +557,8 @@ declare namespace App {
msg: string;
/** The backend service response data */
data: T;
rows?: any[];
total?: number;
};
/** The demo backend service response data */

View File

@ -76,6 +76,7 @@ declare module 'vue' {
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSelect: typeof import('naive-ui')['NSelect']
NSpace: typeof import('naive-ui')['NSpace']
NSpin: typeof import('naive-ui')['NSpin']
NStatistic: typeof import('naive-ui')['NStatistic']
NSwitch: typeof import('naive-ui')['NSwitch']
NTab: typeof import('naive-ui')['NTab']

View File

@ -103,6 +103,11 @@ declare namespace Env {
readonly VITE_ICONIFY_URL?: string;
/** Used to differentiate storage across different domains */
readonly VITE_STORAGE_PREFIX?: string;
readonly VITE_APP_CLIENT_ID?: string;
readonly VITE_APP_ENCRYPT?: string;
readonly VITE_APP_RSA_PUBLIC_KEY?: string;
readonly VITE_APP_RSA_PRIVATE_KEY?: string;
readonly VITE_APP_WEBSOCKET: string;
}
}

View File

@ -7,6 +7,11 @@ declare namespace StorageType {
// * the theme settings
// */
// themeSettings: App.Theme.ThemeSetting;
sessionObj: {
url: string;
data: any;
time: number;
};
}
interface Local {

70
src/utils/crypto.ts Normal file
View File

@ -0,0 +1,70 @@
import CryptoJS from 'crypto-js';
/**
* 随机生成32位的字符串
*
* @returns {string}
*/
const generateRandomString = () => {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
const charactersLength = characters.length;
// eslint-disable-next-line no-plusplus
for (let i = 0; i < 32; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
};
/**
* 随机生成aes 密钥
*
* @returns {string}
*/
export const generateAesKey = () => {
return CryptoJS.enc.Utf8.parse(generateRandomString());
};
/**
* 加密base64
*
* @returns {string}
*/
export const encryptBase64 = (str: CryptoJS.lib.WordArray) => {
return CryptoJS.enc.Base64.stringify(str);
};
/** 解密base64 */
export const decryptBase64 = (str: string) => {
return CryptoJS.enc.Base64.parse(str);
};
/**
* 使用密钥对数据进行加密
*
* @param message
* @param aesKey
* @returns {string}
*/
export const 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();
};
/**
* 使用密钥对数据进行解密
*
* @param message
* @param aesKey
* @returns {string}
*/
export const 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);
};

21
src/utils/jsencrypt.ts Normal file
View File

@ -0,0 +1,21 @@
import JSEncrypt from 'jsencrypt';
// 密钥对生成 http://web.chacuo.net/netrsakeypair
const publicKey = import.meta.env.VITE_APP_RSA_PUBLIC_KEY;
// 前端不建议存放私钥 不建议解密数据 因为都是透明的意义不大
const privateKey = import.meta.env.VITE_APP_RSA_PRIVATE_KEY;
// 加密
export const encrypt = (txt: string) => {
const encryptor = new JSEncrypt();
encryptor.setPublicKey(publicKey!); // 设置公钥
return encryptor.encrypt(txt); // 对数据进行加密
};
// 解密
export const decrypt = (txt: string) => {
const encryptor = new JSEncrypt();
encryptor.setPrivateKey(privateKey!); // 设置私钥
return encryptor.decrypt(txt); // 对数据进行解密
};

View File

@ -4,11 +4,11 @@
* @param env The current env
*/
export function createServiceConfig(env: Env.ImportMeta) {
const { VITE_SERVICE_BASE_URL, VITE_OTHER_SERVICE_BASE_URL } = env;
const { VITE_SERVICE_BASE_URL, VITE_OTHER_SERVICE_BASE_URL, VITE_APP_BASE_API, VITE_APP_WEBSOCKET } = env;
let other = {} as Record<App.Service.OtherBaseURLKey, string>;
try {
other = JSON.parse(VITE_OTHER_SERVICE_BASE_URL);
other = JSON.parse(VITE_OTHER_SERVICE_BASE_URL || '{}');
} catch (error) {
// eslint-disable-next-line no-console
console.error('VITE_OTHER_SERVICE_BASE_URL is not a valid JSON string');
@ -24,6 +24,7 @@ export function createServiceConfig(env: Env.ImportMeta) {
const otherConfig: App.Service.OtherServiceConfigItem[] = otherHttpKeys.map(key => {
return {
key,
ws: false,
baseURL: httpConfig.other[key],
proxyPattern: createProxyPattern(key)
};
@ -31,7 +32,8 @@ export function createServiceConfig(env: Env.ImportMeta) {
const config: App.Service.ServiceConfig = {
baseURL: httpConfig.baseURL,
proxyPattern: createProxyPattern(),
ws: VITE_APP_WEBSOCKET === 'true',
proxyPattern: VITE_APP_BASE_API,
other: otherConfig
};
@ -45,7 +47,7 @@ export function createServiceConfig(env: Env.ImportMeta) {
* @param isProxy - if use proxy
*/
export function getServiceBaseURL(env: Env.ImportMeta, isProxy: boolean) {
const { baseURL, other } = createServiceConfig(env);
const { baseURL, other, proxyPattern } = createServiceConfig(env);
const otherBaseURL = {} as Record<App.Service.OtherBaseURLKey, string>;
@ -54,7 +56,7 @@ export function getServiceBaseURL(env: Env.ImportMeta, isProxy: boolean) {
});
return {
baseURL: isProxy ? createProxyPattern() : baseURL,
baseURL: isProxy ? proxyPattern : baseURL,
otherBaseURL
};
}
@ -64,10 +66,6 @@ export function getServiceBaseURL(env: Env.ImportMeta, isProxy: boolean) {
*
* @param key If not set, will use the default key
*/
function createProxyPattern(key?: App.Service.OtherBaseURLKey) {
if (!key) {
return '/proxy-default';
}
function createProxyPattern(key: App.Service.OtherBaseURLKey) {
return `/proxy-${key}`;
}

139
src/utils/websocket.ts Normal file
View File

@ -0,0 +1,139 @@
/**
* socket 通信
*
* @module initWebSocket 初始化
* @module websocketonopen 连接成功
* @module websocketonerror 连接失败
* @module websocketclose 断开连接
* @module resetHeart 重置心跳
* @module sendSocketHeart 心跳发送
* @module reconnect 重连
* @module sendMsg 发送数据
* @module websocketonmessage 接收数据
* @module test 测试收到消息传递
* @param {any} url socket地址
* @param {any} websocket websocket 实例
* @param {any} heartTime 心跳定时器实例
* @param {number} socketHeart 心跳次数
* @param {number} HeartTimeOut 心跳超时时间
* @param {number} socketError 错误次数
*/
import useNoticeStore from '@/store/modules/notice';
import { localStg } from './storage';
let socketUrl: any = ''; // socket地址
let websocket: any = null; // websocket 实例
let heartTime: any = null; // 心跳定时器实例
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let socketHeart = 0; // 心跳次数
const HeartTimeOut = 10000; // 心跳超时时间 10000 = 10s
let socketError = 0; // 错误次数
// 初始化socket
export function initWebSocket(url: any) {
if (import.meta.env.VITE_APP_WEBSOCKET === 'false') {
return null;
}
socketUrl = url;
// 初始化 websocket
const token = localStg.get('token');
websocket = new WebSocket(`${url}?Authorization=Bearer ${token}&clientid=${import.meta.env.VITE_APP_CLIENT_ID}`);
websocketonopen();
websocketonmessage();
websocketonerror();
websocketclose();
sendSocketHeart();
return websocket;
}
// socket 连接成功
export function websocketonopen() {
websocket.onopen = () => {
console.log('连接 websocket 成功');
resetHeart();
};
}
// socket 连接失败
export function websocketonerror() {
websocket.onerror = (e: any) => {
console.log('连接 websocket 失败', e);
};
}
// socket 断开链接
export function websocketclose() {
websocket.onclose = (e: any) => {
console.log('断开连接', e);
};
}
// socket 重置心跳
export function resetHeart() {
socketHeart = 0;
socketError = 0;
clearInterval(heartTime);
sendSocketHeart();
}
// socket心跳发送
export function sendSocketHeart() {
heartTime = setInterval(() => {
// 如果连接正常则发送心跳
if (websocket.readyState === 1) {
// if (socketHeart <= 30) {
websocket.send(
JSON.stringify({
type: 'ping'
})
);
socketHeart += 1;
} else {
// 重连
reconnect();
}
}, HeartTimeOut);
}
// socket重连
export function reconnect() {
if (socketError <= 2) {
clearInterval(heartTime);
initWebSocket(socketUrl);
socketError += 1;
console.log('socket重连', socketError);
} else {
console.log('重试次数已用完');
clearInterval(heartTime);
}
}
// socket 发送数据
export function sendMsg(data: any) {
websocket.send(data);
}
// socket 接收数据
export function websocketonmessage() {
websocket.onmessage = (e: any) => {
if (e.data.indexOf('heartbeat') > 0) {
resetHeart();
}
if (e.data.indexOf('ping') > 0) {
return null;
}
useNoticeStore().addNotice({
message: e.data,
read: false,
time: new Date().toLocaleString()
});
window.$notification?.create({
title: '消息',
content: e.data,
type: 'success',
duration: 3000
});
return e.data;
};
}

View File

@ -56,7 +56,7 @@ const bgColor = computed(() => {
<NCard :bordered="false" class="relative z-4 w-auto rd-12px">
<div class="w-400px lt-sm:w-300px">
<header class="flex-y-center justify-between">
<SystemLogo class="text-64px text-primary lt-sm:text-48px" />
<SystemLogo class="w-64px text-primary lt-sm:text-48px" />
<h3 class="text-28px text-primary font-500 lt-sm:text-22px">{{ $t('system.title') }}</h3>
<div class="i-flex-col">
<ThemeSchemaSwitch

View File

@ -1,10 +1,13 @@
<script setup lang="ts">
import { computed, reactive } from 'vue';
import { computed, reactive, ref } from 'vue';
import type { SelectOption } from 'naive-ui';
import { useLoading } from '@sa/hooks';
import { $t } from '@/locales';
import { loginModuleRecord } from '@/constants/app';
import { useRouterPush } from '@/hooks/common/router';
import { useFormRules, useNaiveForm } from '@/hooks/common/form';
import { useAuthStore } from '@/store/modules/auth';
import { fetchCaptchaCode, fetchTenantList } from '@/service/api';
defineOptions({
name: 'PwdLogin'
@ -13,71 +16,83 @@ defineOptions({
const authStore = useAuthStore();
const { toggleLoginModule } = useRouterPush();
const { formRef, validate } = useNaiveForm();
const { loading: codeLoading, startLoading: startCodeLoading, endLoading: endCodeLoading } = useLoading();
interface FormModel {
userName: string;
password: string;
}
const codeUrl = ref<string>();
const captchaEnabled = ref<boolean>(false);
const tenantEnabled = ref<boolean>(false);
const tenantOption = ref<SelectOption[]>([]);
const model: FormModel = reactive({
userName: 'Soybean',
password: '123456'
const model: Api.Auth.LoginForm = reactive({
tenantId: '000000',
username: 'admin',
password: 'admin123',
remberMe: false
});
const rules = computed<Record<keyof FormModel, App.Global.FormRule[]>>(() => {
const rules = computed<Record<keyof Api.Auth.LoginForm, App.Global.FormRule[]>>(() => {
// inside computed to make locale reactive, if not apply i18n, you can define it without computed
const { formRules } = useFormRules();
const { formRules, createRequiredRule } = useFormRules();
return {
userName: formRules.userName,
password: formRules.pwd
const loginRules: Record<keyof Api.Auth.LoginForm, App.Global.FormRule[]> = {
username: formRules.userName,
password: formRules.pwd,
code: captchaEnabled.value ? [createRequiredRule($t('form.code.required'))] : [],
tenantId: tenantEnabled.value ? formRules.tenantId : [],
rememberMe: [],
uuid: []
};
return loginRules;
});
async function handleSubmit() {
await validate();
await authStore.login(model.userName, model.password);
}
type AccountKey = 'super' | 'admin' | 'user';
interface Account {
key: AccountKey;
label: string;
userName: string;
password: string;
}
const accounts = computed<Account[]>(() => [
{
key: 'super',
label: $t('page.login.pwdLogin.superAdmin'),
userName: 'Super',
password: '123456'
},
{
key: 'admin',
label: $t('page.login.pwdLogin.admin'),
userName: 'Admin',
password: '123456'
},
{
key: 'user',
label: $t('page.login.pwdLogin.user'),
userName: 'User',
password: '123456'
try {
await authStore.login(model);
} catch (error) {
handleFetchCaptchaCode();
}
]);
async function handleAccountLogin(account: Account) {
await authStore.login(account.userName, account.password);
}
async function handleFetchTenantList() {
const { data, error } = await fetchTenantList();
if (!error) {
tenantEnabled.value = data.tenantEnabled;
tenantOption.value = data.voList.map(tenant => {
return {
label: tenant.companyName,
value: tenant.tenantId
};
});
}
}
handleFetchTenantList();
async function handleFetchCaptchaCode() {
startCodeLoading();
const { data, error } = await fetchCaptchaCode();
if (!error) {
captchaEnabled.value = data.captchaEnabled;
if (data.captchaEnabled) {
model.uuid = data.uuid;
codeUrl.value = `data:image/gif;base64,${data.img}`;
}
}
endCodeLoading();
}
handleFetchCaptchaCode();
</script>
<template>
<NForm ref="formRef" :model="model" :rules="rules" size="large" :show-label="false">
<NFormItem path="userName">
<NInput v-model:value="model.userName" :placeholder="$t('page.login.common.userNamePlaceholder')" />
<NFormItem v-if="tenantEnabled" path="tenantId">
<NSelect v-model:value="model.tenantId" placeholder="请选择/输入公司名称" :options="tenantOption" />
</NFormItem>
<NFormItem path="username">
<NInput v-model:value="model.username" :placeholder="$t('page.login.common.usernamePlaceholder')" />
</NFormItem>
<NFormItem path="password">
<NInput
@ -87,6 +102,17 @@ async function handleAccountLogin(account: Account) {
:placeholder="$t('page.login.common.passwordPlaceholder')"
/>
</NFormItem>
<NFormItem v-if="captchaEnabled" path="code">
<div class="w-full flex-y-center gap-16px">
<NInput v-model:value="model.code" :placeholder="$t('page.login.common.codePlaceholder')" />
<NSpin :show="codeLoading" :size="28" class="h-42px">
<NButton :focusable="false" class="login-code h-42px w-116px" @click="handleFetchCaptchaCode">
<img v-if="codeUrl" :src="codeUrl" />
<NEmpty v-else :show-icon="false" description="暂无验证码" />
</NButton>
</NSpin>
</div>
</NFormItem>
<NSpace vertical :size="24">
<div class="flex-y-center justify-between">
<NCheckbox>{{ $t('page.login.pwdLogin.rememberMe') }}</NCheckbox>
@ -105,14 +131,19 @@ async function handleAccountLogin(account: Account) {
{{ $t(loginModuleRecord.register) }}
</NButton>
</div>
<NDivider class="text-14px text-#666 !m-0">{{ $t('page.login.pwdLogin.otherAccountLogin') }}</NDivider>
<div class="flex-center gap-12px">
<NButton v-for="item in accounts" :key="item.key" type="primary" @click="handleAccountLogin(item)">
{{ item.label }}
</NButton>
</div>
</NSpace>
</NForm>
</template>
<style scoped></style>
<style scoped>
.login-code {
&.n-button {
--n-padding: 0 8px !important;
background-color: #c0c0c0;
}
img {
height: 40px;
}
}
</style>

View File

@ -48,7 +48,7 @@ const statisticData = computed<StatisticData[]>(() => [
</div>
<div class="pl-12px">
<h3 class="text-18px font-semibold">
{{ $t('page.home.greeting', { userName: authStore.userInfo.userName }) }}
{{ $t('page.home.greeting', { userName: authStore.userInfo.user?.userName }) }}
</h3>
<p class="text-#999 leading-30px">{{ $t('page.home.weatherDesc') }}</p>
</div>