2
0

初始化项目

This commit is contained in:
caiyuchao
2024-11-14 11:06:38 +08:00
parent 988b9e6799
commit 4ffac789e1
320 changed files with 34244 additions and 0 deletions

12
src/store/index.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { App } from 'vue';
import { createPinia } from 'pinia';
import { resetSetupStore } from './plugins';
/** Setup Vue store plugin pinia */
export function setupStore(app: App) {
const store = createPinia();
store.use(resetSetupStore);
app.use(store);
}

View File

@@ -0,0 +1,139 @@
import { effectScope, onScopeDispose, ref, watch } from 'vue';
import { defineStore } from 'pinia';
import { breakpointsTailwind, useBreakpoints, useTitle } from '@vueuse/core';
import { useBoolean } from '@sa/hooks';
import { SetupStoreId } from '@/enum';
import { router } from '@/router';
import { $t, setLocale } from '@/locales';
import { setDayjsLocale } from '@/locales/dayjs';
import { localStg } from '@/utils/storage';
import { useRouteStore } from '../route';
import { useTabStore } from '../tab';
import { useThemeStore } from '../theme';
export const useAppStore = defineStore(SetupStoreId.App, () => {
const themeStore = useThemeStore();
const routeStore = useRouteStore();
const tabStore = useTabStore();
const scope = effectScope();
const breakpoints = useBreakpoints(breakpointsTailwind);
const { bool: themeDrawerVisible, setTrue: openThemeDrawer, setFalse: closeThemeDrawer } = useBoolean();
const { bool: reloadFlag, setBool: setReloadFlag } = useBoolean(true);
const { bool: fullContent, toggle: toggleFullContent } = useBoolean();
const { bool: contentXScrollable, setBool: setContentXScrollable } = useBoolean();
const { bool: siderCollapse, setBool: setSiderCollapse, toggle: toggleSiderCollapse } = useBoolean();
const { bool: mixSiderFixed, setBool: setMixSiderFixed, toggle: toggleMixSiderFixed } = useBoolean();
/** Is mobile layout */
const isMobile = breakpoints.smaller('sm');
/**
* Reload page
*
* @param duration Duration time
*/
async function reloadPage(duration = 300) {
setReloadFlag(false);
if (duration > 0) {
await new Promise(resolve => {
setTimeout(resolve, duration);
});
}
setReloadFlag(true);
}
const locale = ref<App.I18n.LangType>(localStg.get('lang') || 'zh-CN');
const localeOptions: App.I18n.LangOption[] = [
{
label: '中文',
key: 'zh-CN'
},
{
label: 'English',
key: 'en-US'
}
];
function changeLocale(lang: App.I18n.LangType) {
locale.value = lang;
setLocale(lang);
localStg.set('lang', lang);
}
/** Update document title by locale */
function updateDocumentTitleByLocale() {
const { i18nKey, title } = router.currentRoute.value.meta;
const documentTitle = i18nKey ? $t(i18nKey) : title;
useTitle(documentTitle);
}
function init() {
setDayjsLocale(locale.value);
}
// watch store
scope.run(() => {
// watch isMobile, if is mobile, collapse sider
watch(
isMobile,
newValue => {
if (newValue) {
setSiderCollapse(true);
themeStore.setThemeLayout('vertical');
}
},
{ immediate: true }
);
// watch locale
watch(locale, () => {
// update document title by locale
updateDocumentTitleByLocale();
// update global menus by locale
routeStore.updateGlobalMenusByLocale();
// update tabs by locale
tabStore.updateTabsByLocale();
// sey dayjs locale
setDayjsLocale(locale.value);
});
});
/** On scope dispose */
onScopeDispose(() => {
scope.stop();
});
// init
init();
return {
isMobile,
reloadFlag,
reloadPage,
fullContent,
locale,
localeOptions,
changeLocale,
themeDrawerVisible,
openThemeDrawer,
closeThemeDrawer,
toggleFullContent,
contentXScrollable,
setContentXScrollable,
siderCollapse,
setSiderCollapse,
toggleSiderCollapse,
mixSiderFixed,
setMixSiderFixed,
toggleMixSiderFixed
};
});

View File

@@ -0,0 +1,136 @@
import { computed, reactive, ref } from 'vue';
import { defineStore } from 'pinia';
import { useLoading } from '@sa/hooks';
import { SetupStoreId } from '@/enum';
import { useRouterPush } from '@/hooks/common/router';
import { localStg } from '@/utils/storage';
import { $t } from '@/locales';
import { useRouteStore } from '../route';
import { clearAuthStorage, emptyInfo, getToken } from './shared';
export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
const routeStore = useRouteStore();
const { route, toLogin, redirectFromLogin } = useRouterPush(false);
const { loading: loginLoading, startLoading, endLoading } = useLoading();
const token = ref(getToken());
const userInfo: Api.Auth.UserInfo = reactive(emptyInfo);
const permissions = computed(() => userInfo.permissions);
watch(
() => token.value,
async value => {
if (value && !userInfo.user && !route.value.path.includes('login')) {
refreshUserInfo();
}
},
{
immediate: true
}
);
/** is super role in static route */
const isStaticSuper = computed(() => {
const { VITE_AUTH_ROUTE_MODE, VITE_STATIC_SUPER_ROLE } = import.meta.env;
return VITE_AUTH_ROUTE_MODE === 'static' && userInfo.roles?.includes(VITE_STATIC_SUPER_ROLE);
});
/** Is login */
const isLogin = computed(() => Boolean(token.value));
/**
* Reset auth store
*
* @param isMe [isMe=false] Whether to reset the store by yourself. Default is `false`
*/
async function resetStore(isMe = false) {
if (isMe) {
doDeleteLogout();
}
const authStore = useAuthStore();
authStore.$reset();
clearAuthStorage();
if (!route.value.meta.constant) {
await toLogin();
}
routeStore.resetStore();
}
/**
* Login
*
* @param username User name
* @param password Password
* @param [redirect=true] Whether to redirect after login. Default is `true`
*/
async function login(
params: { loginForm: Api.Auth.LoginBody; onError?: () => void; onSuccess?: () => void },
redirect = true
) {
const { loginForm, onError, onSuccess } = params;
startLoading();
const { data: loginToken, error } = await fetchLogin(loginForm);
if (!error) {
const pass = await loginByToken(loginToken);
onSuccess && onSuccess();
if (pass) {
await routeStore.initAuthRoute();
if (redirect) {
await redirectFromLogin();
}
if (routeStore.isInitAuthRoute) {
$notification?.success({
message: $t('page.login.common.loginSuccess'),
description: $t('page.login.common.welcomeBack', { username: userInfo?.user?.nickName })
});
}
}
} else {
onError && onError();
resetStore();
}
endLoading();
}
async function loginByToken(loginToken: Api.Auth.LoginToken) {
// 1. stored in the localStorage, the later requests need it in headers
localStg.set('token', loginToken.access_token);
localStg.set('refreshToken', loginToken.refreshToken);
token.value = loginToken.access_token;
const isSuccess = await refreshUserInfo();
return isSuccess;
}
async function refreshUserInfo() {
const { data: info, error } = await doGetUserInfo();
if (!error) {
Object.assign(userInfo, info);
return true;
}
return false;
}
return {
token,
userInfo,
isStaticSuper,
isLogin,
loginLoading,
resetStore,
permissions,
login,
refreshUserInfo
};
});

View File

@@ -0,0 +1,19 @@
import { localStg } from '@/utils/storage';
/** Get token */
export function getToken() {
return localStg.get('token') || '';
}
export const emptyInfo: Api.Auth.UserInfo = {
roles: [],
buttons: [],
permissions: [],
user: null
};
/** Clear auth storage */
export function clearAuthStorage() {
localStg.remove('token');
localStg.remove('refreshToken');
}

View File

@@ -0,0 +1,349 @@
import { computed, ref, shallowRef } from 'vue';
import type { RouteRecordRaw } from 'vue-router';
import { defineStore } from 'pinia';
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 { ROOT_ROUTE } from '@/router/routes/builtin';
import { getRouteName, getRoutePath } from '@/router/elegant/transform';
import { useAppStore } from '../app';
import { useAuthStore } from '../auth';
import { useTabStore } from '../tab';
import {
filterAuthRoutesByRoles,
getBreadcrumbsByRoute,
getCacheRouteNames,
getGlobalMenusByAuthRoutes,
getSelectedMenuKeyPathByKey,
isRouteExistByRouteName,
sortRoutesByOrder,
transformMenuToSearchMenus,
updateLocaleOfGlobalMenus
} from './shared';
export const useRouteStore = defineStore(SetupStoreId.Route, () => {
const appStore = useAppStore();
const authStore = useAuthStore();
const tabStore = useTabStore();
const { bool: isInitConstantRoute, setBool: setIsInitConstantRoute } = useBoolean();
const { bool: isInitAuthRoute, setBool: setIsInitAuthRoute } = useBoolean();
/**
* Auth route mode
*
* It recommends to use static mode in the development environment, and use dynamic mode in the production
* environment, if use static mode in development environment, the auth routes will be auto generated by plugin
* "@elegant-router/vue"
*/
const authRouteMode = ref(import.meta.env.VITE_AUTH_ROUTE_MODE);
/** Home route key */
const routeHome = ref(import.meta.env.VITE_ROUTE_HOME);
/**
* Set route home
*
* @param routeKey Route key
*/
function setRouteHome(routeKey: LastLevelRouteKey) {
routeHome.value = routeKey;
}
/** auth routes */
const authRoutes = shallowRef<ElegantConstRoute[]>([]);
function addAuthRoutes(routes: ElegantConstRoute[]) {
const authRoutesMap = new Map(authRoutes.value.map(route => [route.name, route]));
routes.forEach(route => {
authRoutesMap.set(route.name, route);
});
authRoutes.value = Array.from(authRoutesMap.values());
}
const removeRouteFns: (() => void)[] = [];
/** Global menus */
const menus = ref<App.Global.Menu[]>([]);
const searchMenus = computed(() => transformMenuToSearchMenus(menus.value));
/** Get global menus */
function getGlobalMenus(routes: ElegantConstRoute[]) {
menus.value = getGlobalMenusByAuthRoutes(routes);
}
/** Update global menus by locale */
function updateGlobalMenusByLocale() {
menus.value = updateLocaleOfGlobalMenus(menus.value);
}
/** Cache routes */
const cacheRoutes = ref<RouteKey[]>([]);
/**
* Get cache routes
*
* @param routes Vue routes
*/
function getCacheRoutes(routes: RouteRecordRaw[]) {
cacheRoutes.value = getCacheRouteNames(routes);
}
/**
* Add cache routes
*
* @param routeKey
*/
function addCacheRoutes(routeKey: RouteKey) {
if (cacheRoutes.value.includes(routeKey)) return;
cacheRoutes.value.push(routeKey);
}
/**
* Remove cache routes
*
* @param routeKey
*/
function removeCacheRoutes(routeKey: RouteKey) {
const index = cacheRoutes.value.findIndex(item => item === routeKey);
if (index === -1) return;
cacheRoutes.value.splice(index, 1);
}
/**
* Re cache routes by route key
*
* @param routeKey
*/
async function reCacheRoutesByKey(routeKey: RouteKey) {
removeCacheRoutes(routeKey);
await appStore.reloadPage();
addCacheRoutes(routeKey);
}
/**
* Re cache routes by route keys
*
* @param routeKeys
*/
async function reCacheRoutesByKeys(routeKeys: RouteKey[]) {
for await (const key of routeKeys) {
await reCacheRoutesByKey(key);
}
}
/** Global breadcrumbs */
const breadcrumbs = computed(() => getBreadcrumbsByRoute(router.currentRoute.value, menus.value));
/** Reset store */
async function resetStore() {
const routeStore = useRouteStore();
routeStore.$reset();
resetVueRoutes();
// after reset store, need to re-init constant route
await initConstantRoute();
}
/** Reset vue routes */
function resetVueRoutes() {
removeRouteFns.forEach(fn => fn());
removeRouteFns.length = 0;
}
/** init constant route */
async function initConstantRoute() {
if (isInitConstantRoute.value) return;
// if (authRouteMode.value === 'static') {
const { constantRoutes } = createStaticRoutes();
addAuthRoutes(constantRoutes);
// } else {
// const { data, error } = await fetchGetConstantRoutes();
// if (!error) {
// addAuthRoutes(data);
// }
// }
handleAuthRoutes();
setIsInitConstantRoute(true);
}
/** Init auth route */
async function initAuthRoute() {
if (authRouteMode.value === 'static') {
await initStaticAuthRoute();
} else {
await initDynamicAuthRoute();
}
tabStore.initHomeTab();
}
/** Init static auth route */
async function initStaticAuthRoute() {
const { authRoutes: staticAuthRoutes } = createStaticRoutes();
if (authStore.isStaticSuper) {
addAuthRoutes(staticAuthRoutes);
} else {
const filteredAuthRoutes = filterAuthRoutesByRoles(staticAuthRoutes, authStore.userInfo.roles ?? []);
addAuthRoutes(filteredAuthRoutes);
}
handleAuthRoutes();
setIsInitAuthRoute(true);
}
/** Init dynamic auth route */
async function initDynamicAuthRoute() {
const { data: routes, error } = await doGetUserRoutes();
if (!error) {
addAuthRoutes(routes);
handleAuthRoutes();
setRouteHome('manage_role');
handleUpdateRootRouteRedirect('manage_role');
setIsInitAuthRoute(true);
} else {
await authStore.resetStore();
}
}
/** handle auth routes */
function handleAuthRoutes() {
const sortRoutes = sortRoutesByOrder(authRoutes.value);
const vueRoutes = getAuthVueRoutes(sortRoutes);
resetVueRoutes();
addRoutesToVueRouter(vueRoutes);
getGlobalMenus(sortRoutes);
getCacheRoutes(vueRoutes);
}
/**
* Add routes to vue router
*
* @param routes Vue routes
*/
function addRoutesToVueRouter(routes: RouteRecordRaw[]) {
routes.forEach(route => {
const removeFn = router.addRoute(route);
addRemoveRouteFn(removeFn);
});
}
/**
* Add remove route fn
*
* @param fn
*/
function addRemoveRouteFn(fn: () => void) {
removeRouteFns.push(fn);
}
/**
* Update root route redirect when auth route mode is dynamic
*
* @param redirectKey Redirect route key
*/
function handleUpdateRootRouteRedirect(redirectKey: LastLevelRouteKey) {
const redirect = getRoutePath(redirectKey);
if (redirect) {
const rootRoute: CustomRoute = { ...ROOT_ROUTE, redirect };
router.removeRoute(rootRoute.name);
const [rootVueRoute] = getAuthVueRoutes([rootRoute]);
router.addRoute(rootVueRoute);
}
}
/**
* Get is auth route exist
*
* @param routePath Route path
*/
async function getIsAuthRouteExist(routePath: RouteMap[RouteKey]) {
const routeName = getRouteName(routePath);
if (!routeName) {
return false;
}
if (authRouteMode.value === 'static') {
const { authRoutes: staticAuthRoutes } = createStaticRoutes();
return isRouteExistByRouteName(routeName, staticAuthRoutes);
}
const { data } = await fetchIsRouteExist(routeName);
return data;
}
/**
* Get selected menu key path
*
* @param selectedKey Selected menu key
*/
function getSelectedMenuKeyPath(selectedKey: string) {
return getSelectedMenuKeyPathByKey(selectedKey, menus.value);
}
/**
* Get selected menu meta by key
*
* @param selectedKey Selected menu key
*/
function getSelectedMenuMetaByKey(selectedKey: string) {
// The routes in router.options.routes are static, you need to use router.getRoutes() to get all the routes.
const allRoutes = router.getRoutes();
return allRoutes.find(route => route.name === selectedKey)?.meta || null;
}
return {
resetStore,
routeHome,
menus,
searchMenus,
updateGlobalMenusByLocale,
cacheRoutes,
reCacheRoutesByKey,
reCacheRoutesByKeys,
breadcrumbs,
initConstantRoute,
isInitConstantRoute,
initAuthRoute,
isInitAuthRoute,
setIsInitAuthRoute,
getIsAuthRouteExist,
getSelectedMenuKeyPath,
getSelectedMenuMetaByKey
};
});

View File

@@ -0,0 +1,304 @@
import type { RouteLocationNormalizedLoaded, RouteRecordRaw, _RouteRecordBase } from 'vue-router';
import type { ElegantConstRoute, LastLevelRouteKey, RouteKey, RouteMap } from '@elegant-router/types';
import { $t } from '@/locales';
import { useSvgIcon } from '@/hooks/common/icon';
/**
* Filter auth routes by roles
*
* @param routes Auth routes
* @param roles Roles
*/
export function filterAuthRoutesByRoles(routes: ElegantConstRoute[], roles: string[]) {
return routes.flatMap(route => filterAuthRouteByRoles(route, roles));
}
/**
* Filter auth route by roles
*
* @param route Auth route
* @param roles Roles
*/
function filterAuthRouteByRoles(route: ElegantConstRoute, roles: string[]) {
const routeRoles = (route.meta && route.meta.roles) || [];
// if the route's "roles" is empty, then it is allowed to access
const isEmptyRoles = !routeRoles.length;
// if the user's role is included in the route's "roles", then it is allowed to access
const hasPermission = routeRoles.some(role => roles.includes(role));
const filterRoute = { ...route };
if (filterRoute.children?.length) {
filterRoute.children = filterRoute.children.flatMap(item => filterAuthRouteByRoles(item, roles));
}
return hasPermission || isEmptyRoles ? [filterRoute] : [];
}
/**
* sort route by order
*
* @param route route
*/
function sortRouteByOrder(route: ElegantConstRoute) {
if (route.children?.length) {
route.children.sort((next, prev) => (Number(next.meta?.order) || 0) - (Number(prev.meta?.order) || 0));
route.children.forEach(sortRouteByOrder);
}
return route;
}
/**
* sort routes by order
*
* @param routes routes
*/
export function sortRoutesByOrder(routes: ElegantConstRoute[]) {
routes.sort((next, prev) => (Number(next.meta?.order) || 0) - (Number(prev.meta?.order) || 0));
routes.forEach(sortRouteByOrder);
return routes;
}
/**
* Get global menus by auth routes
*
* @param routes Auth routes
*/
export function getGlobalMenusByAuthRoutes(routes: ElegantConstRoute[]) {
const menus: App.Global.Menu[] = [];
routes.forEach(route => {
if (!route.meta?.hideInMenu) {
const menu = getGlobalMenuByBaseRoute(route);
if (route.children?.some(child => !child.meta?.hideInMenu)) {
menu.children = getGlobalMenusByAuthRoutes(route.children);
}
menus.push(menu);
}
});
return menus;
}
/**
* Update locale of global menus
*
* @param menus
*/
export function updateLocaleOfGlobalMenus(menus: App.Global.Menu[]) {
const result: App.Global.Menu[] = [];
menus.forEach(menu => {
const { i18nKey, label, children } = menu;
const newLabel = i18nKey ? $t(i18nKey) : label;
const newMenu: App.Global.Menu = {
...menu,
label: newLabel,
title: newLabel
};
if (children?.length) {
newMenu.children = updateLocaleOfGlobalMenus(children);
}
result.push(newMenu);
});
return result;
}
/**
* Get global menu by route
*
* @param route
*/
function getGlobalMenuByBaseRoute(route: RouteLocationNormalizedLoaded | ElegantConstRoute) {
const { SvgIconVNode } = useSvgIcon();
const { name, path } = route;
const { title, i18nKey, icon = import.meta.env.VITE_MENU_ICON, localIcon } = route.meta ?? {};
const label = i18nKey ? $t(i18nKey) : title!;
const menu: App.Global.Menu = {
key: name as string,
label,
i18nKey,
routeKey: name as RouteKey,
routePath: path as RouteMap[RouteKey],
icon: SvgIconVNode({ icon, localIcon, fontSize: 20 }),
title: label
};
return menu;
}
/**
* Get cache route names
*
* @param routes Vue routes (two levels)
*/
export function getCacheRouteNames(routes: RouteRecordRaw[]) {
const cacheNames: LastLevelRouteKey[] = [];
routes.forEach(route => {
// only get last two level route, which has component
route.children?.forEach(child => {
if (child.component && child.meta?.keepAlive) {
cacheNames.push(child.name as LastLevelRouteKey);
}
});
});
return cacheNames;
}
/**
* Is route exist by route name
*
* @param routeName
* @param routes
*/
export function isRouteExistByRouteName(routeName: RouteKey, routes: ElegantConstRoute[]) {
return routes.some(route => recursiveGetIsRouteExistByRouteName(route, routeName));
}
/**
* Recursive get is route exist by route name
*
* @param route
* @param routeName
*/
function recursiveGetIsRouteExistByRouteName(route: ElegantConstRoute, routeName: RouteKey) {
let isExist = route.name === routeName;
if (isExist) {
return true;
}
if (route.children && route.children.length) {
isExist = route.children.some(item => recursiveGetIsRouteExistByRouteName(item, routeName));
}
return isExist;
}
/**
* Get selected menu key path
*
* @param selectedKey
* @param menus
*/
export function getSelectedMenuKeyPathByKey(selectedKey: string, menus: App.Global.Menu[]) {
const keyPath: string[] = [];
menus.some(menu => {
const path = findMenuPath(selectedKey, menu);
const find = Boolean(path?.length);
if (find) {
keyPath.push(...path!);
}
return find;
});
return keyPath;
}
/**
* Find menu path
*
* @param targetKey Target menu key
* @param menu Menu
*/
function findMenuPath(targetKey: string, menu: App.Global.Menu): string[] | null {
const path: string[] = [];
function dfs(item: App.Global.Menu): boolean {
path.push(item.key);
if (item.key === targetKey) {
return true;
}
if (item.children) {
for (const child of item.children) {
if (dfs(child)) {
return true;
}
}
}
path.pop();
return false;
}
if (dfs(menu)) {
return path;
}
return null;
}
/**
* Get breadcrumbs by route
*
* @param route
* @param menus
*/
export function getBreadcrumbsByRoute(
route: RouteLocationNormalizedLoaded,
menus: App.Global.Menu[]
): App.Global.Menu[] {
const key = route.name as string;
const activeKey = route.meta?.activeMenu;
const menuKey = activeKey || key;
for (const menu of menus) {
if (menu.key === menuKey) {
const breadcrumb = menuKey !== activeKey ? menu : getGlobalMenuByBaseRoute(route);
return [breadcrumb];
}
if (menu.children?.length) {
const result = getBreadcrumbsByRoute(route, menu.children);
if (result.length > 0) {
return [menu, ...result];
}
}
}
return [];
}
/**
* Transform menu to searchMenus
*
* @param menus - menus
* @param treeMap
*/
export function transformMenuToSearchMenus(menus: App.Global.Menu[], treeMap: App.Global.Menu[] = []) {
if (menus && menus.length === 0) return [];
return menus.reduce((acc, cur) => {
if (!cur.children) {
acc.push(cur);
}
if (cur.children && cur.children.length > 0) {
transformMenuToSearchMenus(cur.children, treeMap);
}
return acc;
}, treeMap);
}

View File

@@ -0,0 +1,298 @@
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { defineStore } from 'pinia';
import { useEventListener } from '@vueuse/core';
import type { RouteKey } from '@elegant-router/types';
import { SetupStoreId } from '@/enum';
import { useRouterPush } from '@/hooks/common/router';
import { localStg } from '@/utils/storage';
import { useRouteStore } from '@/store/modules/route';
import { useThemeStore } from '../theme';
import {
extractTabsByAllRoutes,
filterTabsById,
filterTabsByIds,
findTabByRouteName,
getAllTabs,
getDefaultHomeTab,
getFixedTabIds,
getTabByRoute,
isTabInTabs,
updateTabByI18nKey,
updateTabsByI18nKey
} from './shared';
export const useTabStore = defineStore(SetupStoreId.Tab, () => {
const router = useRouter();
const routeStore = useRouteStore();
const themeStore = useThemeStore();
const { routerPush } = useRouterPush(false);
/** Tabs */
const tabs = ref<App.Global.Tab[]>([]);
/** Get active tab */
const homeTab = ref<App.Global.Tab>();
/**
* Init home tab
*
* @param router Router instance
*/
function initHomeTab() {
homeTab.value = getDefaultHomeTab(router, routeStore.routeHome);
}
/** Get all tabs */
const allTabs = computed(() => getAllTabs(tabs.value, homeTab.value));
/** Active tab id */
const activeTabId = ref<string>('');
/**
* Set active tab id
*
* @param id Tab id
*/
function setActiveTabId(id: string) {
activeTabId.value = id;
}
/**
* Init tab store
*
* @param currentRoute Current route
*/
function initTabStore(currentRoute: App.Global.TabRoute) {
const storageTabs = localStg.get('globalTabs');
if (themeStore.tab.cache && storageTabs) {
const filteredTabs = extractTabsByAllRoutes(router, storageTabs);
tabs.value = updateTabsByI18nKey(filteredTabs);
}
addTab(currentRoute);
}
/**
* Add tab
*
* @param route Tab route
* @param active Whether to activate the added tab
*/
function addTab(route: App.Global.TabRoute, active = true) {
const tab = getTabByRoute(route);
const isHomeTab = tab.id === homeTab.value?.id;
if (!isHomeTab && !isTabInTabs(tab.id, tabs.value)) {
tabs.value.push(tab);
}
if (active) {
setActiveTabId(tab.id);
}
}
/**
* Remove tab
*
* @param tabId Tab id
*/
async function removeTab(tabId: string) {
const isRemoveActiveTab = activeTabId.value === tabId;
const updatedTabs = filterTabsById(tabId, tabs.value);
function update() {
tabs.value = updatedTabs;
}
if (!isRemoveActiveTab) {
update();
return;
}
const activeTab = updatedTabs.at(-1) || homeTab.value;
if (activeTab) {
await switchRouteByTab(activeTab);
update();
}
}
/** remove active tab */
async function removeActiveTab() {
await removeTab(activeTabId.value);
}
/**
* remove tab by route name
*
* @param routeName route name
*/
async function removeTabByRouteName(routeName: RouteKey) {
const tab = findTabByRouteName(routeName, tabs.value);
if (!tab) return;
await removeTab(tab.id);
}
/**
* Clear tabs
*
* @param excludes Exclude tab ids
*/
async function clearTabs(excludes: string[] = []) {
const remainTabIds = [...getFixedTabIds(tabs.value), ...excludes];
const removedTabsIds = tabs.value.map(tab => tab.id).filter(id => !remainTabIds.includes(id));
const isRemoveActiveTab = removedTabsIds.includes(activeTabId.value);
const updatedTabs = filterTabsByIds(removedTabsIds, tabs.value);
function update() {
tabs.value = updatedTabs;
}
if (!isRemoveActiveTab) {
update();
return;
}
const activeTab = updatedTabs[updatedTabs.length - 1] || homeTab.value;
await switchRouteByTab(activeTab);
update();
}
/**
* Switch route by tab
*
* @param tab
*/
async function switchRouteByTab(tab: App.Global.Tab) {
const fail = await routerPush(tab.fullPath);
if (!fail) {
setActiveTabId(tab.id);
}
}
/**
* Clear left tabs
*
* @param tabId
*/
async function clearLeftTabs(tabId: string) {
const tabIds = tabs.value.map(tab => tab.id);
const index = tabIds.indexOf(tabId);
if (index === -1) return;
const excludes = tabIds.slice(index);
await clearTabs(excludes);
}
/**
* Clear right tabs
*
* @param tabId
*/
async function clearRightTabs(tabId: string) {
const isHomeTab = tabId === homeTab.value?.id;
if (isHomeTab) {
clearTabs();
return;
}
const tabIds = tabs.value.map(tab => tab.id);
const index = tabIds.indexOf(tabId);
if (index === -1) return;
const excludes = tabIds.slice(0, index + 1);
await clearTabs(excludes);
}
/**
* Set new label of tab
*
* @default activeTabId
* @param label New tab label
* @param tabId Tab id
*/
function setTabLabel(label: string, tabId?: string) {
const id = tabId || activeTabId.value;
const tab = tabs.value.find(item => item.id === id);
if (!tab) return;
tab.oldLabel = tab.label;
tab.newLabel = label;
}
/**
* Reset tab label
*
* @default activeTabId
* @param tabId Tab id
*/
function resetTabLabel(tabId?: string) {
const id = tabId || activeTabId.value;
const tab = tabs.value.find(item => item.id === id);
if (!tab) return;
tab.newLabel = undefined;
}
/**
* Is tab retain
*
* @param tabId
*/
function isTabRetain(tabId: string) {
if (tabId === homeTab.value?.id) return true;
const fixedTabIds = getFixedTabIds(tabs.value);
return fixedTabIds.includes(tabId);
}
/** Update tabs by locale */
function updateTabsByLocale() {
tabs.value = updateTabsByI18nKey(tabs.value);
if (homeTab.value) {
homeTab.value = updateTabByI18nKey(homeTab.value);
}
}
/** Cache tabs */
function cacheTabs() {
if (!themeStore.tab.cache) return;
localStg.set('globalTabs', tabs.value);
}
// cache tabs when page is closed or refreshed
useEventListener(window, 'beforeunload', () => {
cacheTabs();
});
return {
/** All tabs */
tabs: allTabs,
activeTabId,
initHomeTab,
initTabStore,
addTab,
removeTab,
removeActiveTab,
removeTabByRouteName,
clearTabs,
clearLeftTabs,
clearRightTabs,
switchRouteByTab,
setTabLabel,
resetTabLabel,
isTabRetain,
updateTabsByLocale
};
});

View File

@@ -0,0 +1,243 @@
import type { Router } from 'vue-router';
import type { LastLevelRouteKey, RouteKey, RouteMap } from '@elegant-router/types';
import { $t } from '@/locales';
import { getRoutePath } from '@/router/elegant/transform';
/**
* Get all tabs
*
* @param tabs Tabs
* @param homeTab Home tab
*/
export function getAllTabs(tabs: App.Global.Tab[], homeTab?: App.Global.Tab) {
if (!homeTab) {
return [];
}
const filterHomeTabs = tabs.filter(tab => tab.id !== homeTab.id);
const fixedTabs = filterHomeTabs
.filter(tab => tab.fixedIndex !== undefined)
.sort((a, b) => a.fixedIndex! - b.fixedIndex!);
const remainTabs = filterHomeTabs.filter(tab => tab.fixedIndex === undefined);
const allTabs = [homeTab, ...fixedTabs, ...remainTabs];
return updateTabsLabel(allTabs);
}
/**
* Get tab id by route
*
* @param route
*/
export function getTabIdByRoute(route: App.Global.TabRoute) {
const { path, query = {}, meta } = route;
let id = path;
if (meta.multiTab) {
const queryKeys = Object.keys(query).sort();
const qs = queryKeys.map(key => `${key}=${query[key]}`).join('&');
id = `${path}?${qs}`;
}
return id;
}
/**
* Get tab by route
*
* @param route
*/
export function getTabByRoute(route: App.Global.TabRoute) {
const { name, path, fullPath = path, meta } = route;
const { title, i18nKey, fixedIndexInTab } = meta;
// Get icon and localIcon from getRouteIcons function
const { icon, localIcon } = getRouteIcons(route);
const label = i18nKey ? $t(i18nKey) : title;
const tab: App.Global.Tab = {
id: getTabIdByRoute(route),
label,
routeKey: name as LastLevelRouteKey,
routePath: path as RouteMap[LastLevelRouteKey],
fullPath,
fixedIndex: fixedIndexInTab,
icon,
localIcon,
i18nKey
};
return tab;
}
/**
* The vue router will automatically merge the meta of all matched items, and the icons here may be affected by other
* matching items, so they need to be processed separately
*
* @param route
*/
export function getRouteIcons(route: App.Global.TabRoute) {
// Set default value for icon at the beginning
let icon: string = route?.meta?.icon || import.meta.env.VITE_MENU_ICON;
let localIcon: string | undefined = route?.meta?.localIcon;
// Route.matched only appears when there are multiple matches,so check if route.matched exists
if (route.matched) {
// Find the meta of the current route from matched
const currentRoute = route.matched.find(r => r.name === route.name);
// If icon exists in currentRoute.meta, it will overwrite the default value
icon = currentRoute?.meta?.icon || icon;
localIcon = currentRoute?.meta?.localIcon;
}
return { icon, localIcon };
}
/**
* Get default home tab
*
* @param router
* @param homeRouteName routeHome in useRouteStore
*/
export function getDefaultHomeTab(router: Router, homeRouteName: LastLevelRouteKey) {
const homeRoutePath = getRoutePath(homeRouteName);
const i18nLabel = $t(`route.${homeRouteName}`);
let homeTab: App.Global.Tab = {
id: getRoutePath(homeRouteName),
label: i18nLabel || homeRouteName,
routeKey: homeRouteName,
routePath: homeRoutePath,
fullPath: homeRoutePath
};
const routes = router.getRoutes();
const homeRoute = routes.find(route => route.name === homeRouteName);
if (homeRoute) {
homeTab = getTabByRoute(homeRoute);
}
return homeTab;
}
/**
* Is tab in tabs
*
* @param tab
* @param tabs
*/
export function isTabInTabs(tabId: string, tabs: App.Global.Tab[]) {
return tabs.some(tab => tab.id === tabId);
}
/**
* Filter tabs by id
*
* @param tabId
* @param tabs
*/
export function filterTabsById(tabId: string, tabs: App.Global.Tab[]) {
return tabs.filter(tab => tab.id !== tabId);
}
/**
* Filter tabs by ids
*
* @param tabIds
* @param tabs
*/
export function filterTabsByIds(tabIds: string[], tabs: App.Global.Tab[]) {
return tabs.filter(tab => !tabIds.includes(tab.id));
}
/**
* extract tabs by all routes
*
* @param router
* @param tabs
*/
export function extractTabsByAllRoutes(router: Router, tabs: App.Global.Tab[]) {
const routes = router.getRoutes();
const routeNames = routes.map(route => route.name);
return tabs.filter(tab => routeNames.includes(tab.routeKey));
}
/**
* Get fixed tabs
*
* @param tabs
*/
export function getFixedTabs(tabs: App.Global.Tab[]) {
return tabs.filter(tab => tab.fixedIndex !== undefined);
}
/**
* Get fixed tab ids
*
* @param tabs
*/
export function getFixedTabIds(tabs: App.Global.Tab[]) {
const fixedTabs = getFixedTabs(tabs);
return fixedTabs.map(tab => tab.id);
}
/**
* Update tabs label
*
* @param tabs
*/
function updateTabsLabel(tabs: App.Global.Tab[]) {
const updated = tabs.map(tab => ({
...tab,
label: tab.newLabel || tab.oldLabel || tab.label
}));
return updated;
}
/**
* Update tab by i18n key
*
* @param tab
*/
export function updateTabByI18nKey(tab: App.Global.Tab) {
const { i18nKey, label } = tab;
return {
...tab,
label: i18nKey ? $t(i18nKey) : label
};
}
/**
* Update tabs by i18n key
*
* @param tabs
*/
export function updateTabsByI18nKey(tabs: App.Global.Tab[]) {
return tabs.map(tab => updateTabByI18nKey(tab));
}
/**
* find tab by route name
*
* @param name
* @param tabs
*/
export function findTabByRouteName(name: RouteKey, tabs: App.Global.Tab[]) {
const routePath = getRoutePath(name);
const tabId = routePath;
const multiTabId = `${routePath}?`;
return tabs.find(tab => tab.id === tabId || tab.id.startsWith(multiTabId));
}

View File

@@ -0,0 +1,158 @@
import { computed, effectScope, onScopeDispose, ref, toRefs, watch } from 'vue';
import type { Ref } from 'vue';
import { defineStore } from 'pinia';
import { useEventListener, usePreferredColorScheme } from '@vueuse/core';
import { SetupStoreId } from '@/enum';
import { localStg } from '@/utils/storage';
import { addThemeVarsToHtml, createThemeToken, getAntdTheme, initThemeSettings, toggleCssDarkMode } from './shared';
/** Theme store */
export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
const scope = effectScope();
const osTheme = usePreferredColorScheme();
/** Theme settings */
const settings: Ref<App.Theme.ThemeSetting> = ref(initThemeSettings());
/** Reset store */
function resetStore() {
const themeStore = useThemeStore();
themeStore.$reset();
}
/** Theme colors */
const themeColors = computed(() => {
const { themeColor, otherColor, isInfoFollowPrimary } = settings.value;
const colors: App.Theme.ThemeColor = {
primary: themeColor,
...otherColor,
info: isInfoFollowPrimary ? themeColor : otherColor.info
};
return colors;
});
/** Dark mode */
const darkMode = computed(() => {
if (settings.value.themeScheme === 'auto') {
return osTheme.value === 'dark';
}
return settings.value.themeScheme === 'dark';
});
/** Antd theme */
const antdTheme = computed(() => getAntdTheme(themeColors.value, darkMode.value));
/**
* Settings json
*
* It is for copy settings
*/
const settingsJson = computed(() => JSON.stringify(settings.value));
/**
* Set theme scheme
*
* @param themeScheme
*/
function setThemeScheme(themeScheme: UnionKey.ThemeScheme) {
settings.value.themeScheme = themeScheme;
}
/** Toggle theme scheme */
function toggleThemeScheme() {
const themeSchemes: UnionKey.ThemeScheme[] = ['light', 'dark', 'auto'];
const index = themeSchemes.findIndex(item => item === settings.value.themeScheme);
const nextIndex = index === themeSchemes.length - 1 ? 0 : index + 1;
const nextThemeScheme = themeSchemes[nextIndex];
setThemeScheme(nextThemeScheme);
}
/**
* Set theme layout
*
* @param mode Theme layout mode
*/
function setThemeLayout(mode: UnionKey.ThemeLayoutMode) {
settings.value.layout.mode = mode;
}
/**
* Update theme colors
*
* @param key Theme color key
* @param color Theme color
*/
function updateThemeColors(key: App.Theme.ThemeColorKey, color: string) {
if (key === 'primary') {
settings.value.themeColor = color;
} else {
settings.value.otherColor[key] = color;
}
}
/** Setup theme vars to html */
function setupThemeVarsToHtml() {
const { themeTokens, darkThemeTokens } = createThemeToken(themeColors.value);
addThemeVarsToHtml(themeTokens, darkThemeTokens);
}
/** Cache theme settings */
function cacheThemeSettings() {
const isProd = import.meta.env.PROD;
if (!isProd) return;
localStg.set('themeSettings', settings.value);
}
// cache theme settings when page is closed or refreshed
useEventListener(window, 'beforeunload', () => {
cacheThemeSettings();
});
// watch store
scope.run(() => {
// watch dark mode
watch(
darkMode,
val => {
toggleCssDarkMode(val);
},
{ immediate: true }
);
// themeColors change, update css vars and storage theme color
watch(
themeColors,
val => {
setupThemeVarsToHtml();
localStg.set('themeColor', val.primary);
},
{ immediate: true }
);
});
/** On scope dispose */
onScopeDispose(() => {
scope.stop();
});
return {
...toRefs(settings.value),
resetStore,
settingsJson,
darkMode,
themeColors,
antdTheme,
toggleThemeScheme,
setThemeScheme,
updateThemeColors,
setThemeLayout
};
});

View File

@@ -0,0 +1,217 @@
import { theme as antdTheme } from 'ant-design-vue';
import type { ConfigProviderProps } from 'ant-design-vue';
import { getColorPalette } from '@sa/color-palette';
import { getRgbOfColor } from '@sa/utils';
import { overrideThemeSettings, themeSettings } from '@/theme/settings';
import { themeVars } from '@/theme/vars';
import { localStg } from '@/utils/storage';
const DARK_CLASS = 'dark';
/** Init theme settings */
export function initThemeSettings() {
const isProd = import.meta.env.PROD;
// if it is development mode, the theme settings will not be cached, by update `themeSettings` in `src/theme/settings.ts` to update theme settings
if (!isProd) return themeSettings;
// if it is production mode, the theme settings will be cached in localStorage
// if want to update theme settings when publish new version, please update `overrideThemeSettings` in `src/theme/settings.ts`
const settings = localStg.get('themeSettings') || themeSettings;
const isOverride = localStg.get('overrideThemeFlag') === BUILD_TIME;
if (!isOverride) {
Object.assign(settings, overrideThemeSettings);
localStg.set('overrideThemeFlag', BUILD_TIME);
}
return settings;
}
/**
* Create theme token
*
* @param colors Theme colors
*/
export function createThemeToken(colors: App.Theme.ThemeColor) {
const paletteColors = createThemePaletteColors(colors);
const themeTokens: App.Theme.ThemeToken = {
colors: {
...paletteColors,
nprogress: paletteColors.primary,
container: 'rgb(255, 255, 255)',
layout: 'rgb(247, 250, 252)',
inverted: 'rgb(0, 20, 40)',
base_text: 'rgb(31, 31, 31)'
},
boxShadow: {
header: '0 1px 2px rgb(0, 21, 41, 0.08)',
sider: '2px 0 8px 0 rgb(29, 35, 41, 0.05)',
tab: '0 1px 2px rgb(0, 21, 41, 0.08)'
}
};
const darkThemeTokens: App.Theme.ThemeToken = {
colors: {
...themeTokens.colors,
container: 'rgb(28, 28, 28)',
layout: 'rgb(18, 18, 18)',
base_text: 'rgb(224, 224, 224)'
},
boxShadow: {
...themeTokens.boxShadow
}
};
return {
themeTokens,
darkThemeTokens
};
}
/**
* Create theme palette colors
*
* @param colors Theme colors
*/
function createThemePaletteColors(colors: App.Theme.ThemeColor) {
const colorKeys = Object.keys(colors) as App.Theme.ThemeColorKey[];
const colorPaletteVar = {} as App.Theme.ThemePaletteColor;
colorKeys.forEach(key => {
const { palettes, main } = getColorPalette(colors[key], key);
colorPaletteVar[key] = main.hexcode;
palettes.forEach(item => {
colorPaletteVar[`${key}-${item.number}`] = item.hexcode;
});
});
return colorPaletteVar;
}
/**
* Get css var by tokens
*
* @param tokens Theme base tokens
*/
function getCssVarByTokens(tokens: App.Theme.BaseToken) {
const styles: string[] = [];
function removeVarPrefix(value: string) {
return value.replace('var(', '').replace(')', '');
}
function removeRgbPrefix(value: string) {
return value.replace('rgb(', '').replace(')', '');
}
for (const [key, tokenValues] of Object.entries(themeVars)) {
for (const [tokenKey, tokenValue] of Object.entries(tokenValues)) {
let cssVarsKey = removeVarPrefix(tokenValue);
let cssValue = tokens[key][tokenKey];
if (key === 'colors') {
cssVarsKey = removeRgbPrefix(cssVarsKey);
const { r, g, b } = getRgbOfColor(cssValue);
cssValue = `${r} ${g} ${b}`;
}
styles.push(`${cssVarsKey}: ${cssValue}`);
}
}
const styleStr = styles.join(';');
return styleStr;
}
/**
* Add theme vars to html
*
* @param tokens
*/
export function addThemeVarsToHtml(tokens: App.Theme.BaseToken, darkTokens: App.Theme.BaseToken) {
const cssVarStr = getCssVarByTokens(tokens);
const darkCssVarStr = getCssVarByTokens(darkTokens);
const css = `
html {
${cssVarStr}
}
`;
const darkCss = `
html.${DARK_CLASS} {
${darkCssVarStr}
}
`;
const styleId = 'theme-vars';
const style = document.querySelector(`#${styleId}`) || document.createElement('style');
style.id = styleId;
style.textContent = css + darkCss;
document.head.appendChild(style);
}
/**
* Toggle css dark mode
*
* @param darkMode Is dark mode
*/
export function toggleCssDarkMode(darkMode = false) {
function addDarkClass() {
document.documentElement.classList.add(DARK_CLASS);
}
function removeDarkClass() {
document.documentElement.classList.remove(DARK_CLASS);
}
if (darkMode) {
addDarkClass();
} else {
removeDarkClass();
}
}
/**
* Get antd theme
*
* @param colors Theme colors
* @param darkMode Is dark mode
*/
export function getAntdTheme(colors: App.Theme.ThemeColor, darkMode: boolean) {
const { defaultAlgorithm, darkAlgorithm } = antdTheme;
const { primary, info, success, warning, error } = colors;
const theme: ConfigProviderProps['theme'] = {
token: {
colorPrimary: primary,
colorInfo: info,
colorSuccess: success,
colorWarning: warning,
colorError: error
},
algorithm: [darkMode ? darkAlgorithm : defaultAlgorithm],
components: {
Button: {
controlHeightSM: 28
},
Menu: {
colorSubItemBg: 'transparent'
}
}
};
return theme;
}

View File

@@ -0,0 +1,22 @@
import type { PiniaPluginContext } from 'pinia';
import { cloneDeep } from 'lodash-es';
import { SetupStoreId } from '@/enum';
/**
* The plugin reset the state of the store which is written by setup syntax
*
* @param context
*/
export function resetSetupStore(context: PiniaPluginContext) {
const setupSyntaxIds = Object.values(SetupStoreId) as string[];
if (setupSyntaxIds.includes(context.store.$id)) {
const { $state } = context.store;
const defaultStore = cloneDeep($state);
context.store.$reset = () => {
context.store.$patch(defaultStore);
};
}
}