diff --git a/mayfly_go_web/.env.development b/mayfly_go_web/.env.development index a51df0e5..11c5a2c1 100644 --- a/mayfly_go_web/.env.development +++ b/mayfly_go_web/.env.development @@ -1,6 +1,8 @@ # 本地环境 ENV = 'development' +VITE_OPEN = true + # 本地环境接口地址 VITE_API_URL = '/api' diff --git a/mayfly_go_web/src/common/syssocket.ts b/mayfly_go_web/src/common/syssocket.ts index 936051cf..f6c1d66c 100644 --- a/mayfly_go_web/src/common/syssocket.ts +++ b/mayfly_go_web/src/common/syssocket.ts @@ -73,9 +73,9 @@ class SysSocket { } destory() { - this.socket.close(); + this.socket?.close(); this.socket = null; - this.categoryHandlers.clear(); + this.categoryHandlers?.clear(); } /** diff --git a/mayfly_go_web/src/common/useRequest.ts b/mayfly_go_web/src/common/useRequest.ts index aa34b55d..78101fe6 100644 --- a/mayfly_go_web/src/common/useRequest.ts +++ b/mayfly_go_web/src/common/useRequest.ts @@ -1,5 +1,5 @@ import router from '../router'; -import { clearUser, getClientId, getToken } from './utils/storage'; +import { getClientId, getToken } from './utils/storage'; import { templateResolve } from './utils/string'; import { ElMessage } from 'element-plus'; import { createFetch } from '@vueuse/core'; @@ -7,6 +7,7 @@ import Api from './Api'; import { Result, ResultEnum } from './request'; import config from './config'; import { unref } from 'vue'; +import { URL_401 } from '../router/staticRouter'; const baseUrl: string = config.baseApiUrl; @@ -125,11 +126,10 @@ export function useApiFetch(api: Api, params: any = null, reqOptions: Request // 如果提示没有权限,则跳转至无权限页面 if (result.code === ResultEnum.NO_PERMISSION) { - clearUser(); router.push({ - path: '/401', + path: URL_401, }); - return; + return Promise.reject(result); } // 如果返回的code不为成功,则会返回对应的错误msg,则直接统一通知即可。忽略登录超时或没有权限的提示(直接跳转至401页面) diff --git a/mayfly_go_web/src/hooks/usePageTable.ts b/mayfly_go_web/src/hooks/usePageTable.ts index 7d874f34..e18ecdb9 100644 --- a/mayfly_go_web/src/hooks/usePageTable.ts +++ b/mayfly_go_web/src/hooks/usePageTable.ts @@ -1,5 +1,4 @@ import Api from '@/common/Api'; -import { ElMessage } from 'element-plus'; import { reactive, toRefs, toValue } from 'vue'; /** @@ -54,8 +53,6 @@ export const usePageTable = ( } else { state.tableData = res; } - } catch (error: any) { - ElMessage.error(error?.message); } finally { state.loading = false; } diff --git a/mayfly_go_web/src/router/dynamicRouter.ts b/mayfly_go_web/src/router/dynamicRouter.ts new file mode 100644 index 00000000..8543dae2 --- /dev/null +++ b/mayfly_go_web/src/router/dynamicRouter.ts @@ -0,0 +1,157 @@ +import 'nprogress/nprogress.css'; +import { clearSession, getToken } from '@/common/utils/storage'; +import openApi from '@/common/openApi'; +import { useUserInfo } from '@/store/userInfo'; +import { useRoutesList } from '@/store/routesList'; +import { useKeepALiveNames } from '@/store/keepAliveNames'; +import router from '.'; +import { RouteRecordRaw } from 'vue-router'; +import { LAYOUT_ROUTE_NAME } from './staticRouter'; + +/** + * 获取目录下的 .vue、.tsx 全部文件 + * @method import.meta.glob + * @link 参考:https://cn.vitejs.dev/guide/features.html#json + */ +const viewsModules: Record = import.meta.glob(['../views/**/*.{vue,tsx}']); +const dynamicViewsModules: Record = Object.assign({}, { ...viewsModules }); + +// 后端控制路由:执行路由数据初始化 +export async function initBackendRoutes() { + const token = getToken(); // 获取浏览器缓存 token 值 + if (!token) { + // 无 token 停止执行下一步 + return false; + } + useUserInfo().setUserInfo({}); + // 获取路由 + let menuRoute = await getBackEndControlRoutes(); + + const cacheList: Array = []; + // 处理路由(component) + const routes = backEndRouterConverter(menuRoute, (router: any) => { + // 可能为false时不存在isKeepAlive属性 + if (!router.meta.isKeepAlive) { + router.meta.isKeepAlive = false; + } + if (router.meta.isKeepAlive) { + cacheList.push(router.name); + } + }); + + routes.forEach((item: any) => { + if (item.meta.isFull) { + // 菜单为全屏展示 (示例:数据大屏页面等) + router.addRoute(item as RouteRecordRaw); + } else { + // 要将嵌套路由添加到现有的路由中,可以将路由的 name 作为第一个参数传递给 router.addRoute(),这将有效地添加路由,就像通过 children 添加的一样 + router.addRoute(LAYOUT_ROUTE_NAME, item as RouteRecordRaw); + } + }); + + useKeepALiveNames().setCacheKeepAlive(cacheList); + useRoutesList().setRoutesList(routes); +} + +// 后端控制路由,isRequestRoutes 为 true,则开启后端控制路由 +export async function getBackEndControlRoutes() { + try { + const menuAndPermission = await openApi.getPermissions(); + // 赋值权限码,用于控制按钮等 + useUserInfo().userInfo.permissions = menuAndPermission.permissions; + return menuAndPermission.menus; + } catch (e: any) { + console.error('获取菜单权限信息失败', e); + clearSession(); + throw e; + } +} + +type RouterConvCallbackFunc = (router: any) => void; + +/** + * 后端控制路由,后端返回路由 转换为vue route + * + * @description routes参数配置简介 + * @param code(path) ==> route.path -> 路由菜单访问路径 + * @param name ==> title,路由标题 相当于route.meta.title + * + * @param meta ==> 路由菜单元信息 + * @param meta.routeName ==> route.name -> 路由 name (对应页面组件 name, 可用作 KeepAlive 缓存标识 && 按钮权限筛选) + * @param meta.redirect ==> route.redirect -> 路由重定向地址 + * @param meta.component ==> 文件路径 + * @param meta.icon ==> 菜单和面包屑对应的图标 + * @param meta.isHide ==> 是否在菜单中隐藏 (通常列表详情页需要隐藏) + * @param meta.isFull ==> 菜单是否全屏 (示例:数据大屏页面) + * @param meta.isAffix ==> 菜单是否固定在标签页中 (首页通常是固定项) + * @param meta.isKeepAlive ==> 当前路由是否缓存 + * @param meta.linkType ==> 外链类型, 内嵌: 以iframe展示、外链: 新标签打开 + * @param meta.link ==> 外链地址 + * */ +export function backEndRouterConverter(routes: any, callbackFunc: RouterConvCallbackFunc = null as any, parentPath: string = '/') { + if (!routes) return []; + return routes.map((item: any) => { + if (!item.meta) { + return item; + } + // 将json字符串的meta转为对象 + item.meta = JSON.parse(item.meta); + // 将meta.comoponet 解析为route.component + if (item.meta.component) { + item.component = dynamicImport(dynamicViewsModules, item.meta.component); + delete item.meta['component']; + } + + let path = item.code; + // 如果不是以 / 开头,则路径需要拼接父路径 + if (!path.startsWith('/')) { + path = parentPath + '/' + path; + } + item.path = path; + delete item['code']; + + // route.meta.title == resource.name + item.meta.title = item.name; + delete item['name']; + + // route.name == resource.meta.routeName + item.name = item.meta.routeName; + delete item.meta['routeName']; + + // route.redirect == resource.meta.redirect + if (item.meta.redirect) { + item.redirect = item.meta.redirect; + delete item.meta['redirect']; + } + // 存在回调,则执行回调 + callbackFunc && callbackFunc(item); + item.children && backEndRouterConverter(item.children, callbackFunc, item.path); + return item; + }); +} + +/** + * 后端路由 component 转换函数 + * @param dynamicViewsModules 获取目录下的 .vue、.tsx 全部文件 + * @param component 当前要处理项 component + * @returns 返回处理成函数后的 component + */ +export function dynamicImport(dynamicViewsModules: Record, component: string) { + const keys = Object.keys(dynamicViewsModules); + const matchKeys = keys.filter((key) => { + const k = key.replace(/..\/views|../, ''); + return k.startsWith(`${component}`) || k.startsWith(`/${component}`); + }); + + if (matchKeys?.length === 1) { + return dynamicViewsModules[matchKeys[0]]; + } + + if (matchKeys?.length > 1) { + console.error('匹配到多个相似组件路径, 可添加后缀.vue或.tsx进行区分或者重命名组件名, 请调整...', matchKeys); + return null; + } + + console.error(`未匹配到[${component}]组件名对应的组件文件`); + return null; +} diff --git a/mayfly_go_web/src/router/index.ts b/mayfly_go_web/src/router/index.ts index 72fd88e0..1c3a2e98 100644 --- a/mayfly_go_web/src/router/index.ts +++ b/mayfly_go_web/src/router/index.ts @@ -1,30 +1,21 @@ -import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'; +import { createRouter, createWebHashHistory } from 'vue-router'; import NProgress from 'nprogress'; import 'nprogress/nprogress.css'; -import { clearSession, getToken } from '@/common/utils/storage'; +import { getToken } from '@/common/utils/storage'; import { templateResolve } from '@/common/utils/string'; import { NextLoading } from '@/common/utils/loading'; -import { dynamicRoutes, staticRoutes, pathMatch } from './route'; -import openApi from '@/common/openApi'; +import { staticRoutes, URL_LOGIN, URL_401, ROUTER_WHITE_LIST, errorRoutes } from './staticRouter'; import syssocket from '@/common/syssocket'; import pinia from '@/store/index'; import { useThemeConfig } from '@/store/themeConfig'; import { useUserInfo } from '@/store/userInfo'; import { useRoutesList } from '@/store/routesList'; -import { useKeepALiveNames } from '@/store/keepAliveNames'; - -/** - * 获取目录下的 .vue、.tsx 全部文件 - * @method import.meta.glob - * @link 参考:https://cn.vitejs.dev/guide/features.html#json - */ -const viewsModules: Record = import.meta.glob(['../views/**/*.{vue,tsx}']); -const dynamicViewsModules: Record = Object.assign({}, { ...viewsModules }); +import { initBackendRoutes } from './dynamicRouter'; // 添加静态路由 const router = createRouter({ history: createWebHashHistory(), - routes: staticRoutes, + routes: [...staticRoutes, ...errorRoutes], }); // 前端控制路由:初始化方法,防止刷新时丢失 @@ -35,133 +26,15 @@ export function initAllFun() { return false; } useUserInfo().setUserInfo({}); - router.addRoute(pathMatch); // 添加404界面 resetRoute(); // 删除/重置路由 - router.addRoute(dynamicRoutes[0]); - - // 过滤权限菜单 - useRoutesList().setRoutesList(dynamicRoutes[0].children); -} - -// 后端控制路由:执行路由数据初始化 -export async function initBackEndControlRoutesFun() { - const token = getToken(); // 获取浏览器缓存 token 值 - if (!token) { - // 无 token 停止执行下一步 - return false; - } - useUserInfo().setUserInfo({}); - // 获取路由 - let menuRoute = await getBackEndControlRoutes(); - - const cacheList: Array = []; - // 处理路由(component) - dynamicRoutes[0].children = backEndRouterConverter(menuRoute, (router: any) => { - // 可能为false时不存在isKeepAlive属性 - if (!router.meta.isKeepAlive) { - router.meta.isKeepAlive = false; - } - if (router.meta.isKeepAlive) { - cacheList.push(router.name); - } - }); - useKeepALiveNames().setCacheKeepAlive(cacheList); - - // 添加404界面 - router.addRoute(pathMatch); - resetRoute(); // 删除/重置路由 - router.addRoute(dynamicRoutes[0] as unknown as RouteRecordRaw); - - useRoutesList().setRoutesList(dynamicRoutes[0].children); -} - -// 后端控制路由,isRequestRoutes 为 true,则开启后端控制路由 -export async function getBackEndControlRoutes() { - try { - const menuAndPermission = await openApi.getPermissions(); - // 赋值权限码,用于控制按钮等 - useUserInfo().userInfo.permissions = menuAndPermission.permissions; - return menuAndPermission.menus; - } catch (e: any) { - console.error(e); - return []; - } -} - -type RouterConvCallbackFunc = (router: any) => void; - -// 后端控制路由,后端返回路由 转换为vue route -export function backEndRouterConverter(routes: any, callbackFunc: RouterConvCallbackFunc = null as any, parentPath: string = '/') { - if (!routes) return; - return routes.map((item: any) => { - if (!item.meta) { - return item; - } - // 将json字符串的meta转为对象 - item.meta = JSON.parse(item.meta); - // 将meta.comoponet 解析为route.component - if (item.meta.component) { - item.component = dynamicImport(dynamicViewsModules, item.meta.component); - delete item.meta['component']; - } - - let path = item.code; - // 如果不是以 / 开头,则路径需要拼接父路径 - if (!path.startsWith('/')) { - path = parentPath + '/' + path; - } - item.path = path; - delete item['code']; - - // route.meta.title == resource.name - item.meta.title = item.name; - delete item['name']; - - // route.name == resource.meta.routeName - item.name = item.meta.routeName; - delete item.meta['routeName']; - - // route.redirect == resource.meta.redirect - if (item.meta.redirect) { - item.redirect = item.meta.redirect; - delete item.meta['redirect']; - } - // 存在回调,则执行回调 - callbackFunc && callbackFunc(item); - item.children && backEndRouterConverter(item.children, callbackFunc, item.path); - return item; - }); -} - -/** - * 后端路由 component 转换函数 - * @param dynamicViewsModules 获取目录下的 .vue、.tsx 全部文件 - * @param component 当前要处理项 component - * @returns 返回处理成函数后的 component - */ -export function dynamicImport(dynamicViewsModules: Record, component: string) { - const keys = Object.keys(dynamicViewsModules); - const matchKeys = keys.filter((key) => { - const k = key.replace(/..\/views|../, ''); - return k.startsWith(`${component}`) || k.startsWith(`/${component}`); - }); - - if (matchKeys?.length === 1) { - return dynamicViewsModules[matchKeys[0]]; - } - - if (matchKeys?.length > 1) { - console.error('匹配到多个相似组件路径, 可添加后缀.vue或.tsx进行区分或者重命名组件名, 请调整...', matchKeys); - return null; - } - - console.error(`未匹配到[${component}]组件名对应的组件文件`); - return null; + // router.addRoute(dynamicRoutes[0]); + // // 过滤权限菜单 + // useRoutesList().setRoutesList(dynamicRoutes[0].children); } // 删除/重置路由 export function resetRoute() { - useRoutesList().routesList.forEach((route: any) => { + useRoutesList().routesList?.forEach((route: any) => { const { name } = route; router.hasRoute(name) && router.removeRoute(name); }); @@ -177,19 +50,17 @@ export async function initRouter() { initAllFun(); } else if (isRequestRoutes) { // 后端控制路由,isRequestRoutes 为 true,则开启后端控制路由 - await initBackEndControlRoutesFun(); + await initBackendRoutes(); } } finally { NextLoading.done(); } } -let loadRouter = false; - // 路由加载前 router.beforeEach(async (to, from, next) => { NProgress.configure({ showSpinner: false }); - if (to.meta.title) NProgress.start(); + NProgress.start(); // 如果有标题参数,则再原标题后加上参数来区别 if (to.meta.titleRename && to.meta.title) { @@ -197,24 +68,27 @@ router.beforeEach(async (to, from, next) => { } const token = getToken(); - if ((to.path === '/login' || to.path == '/oauth2/callback') && !token) { - next(); - NProgress.done(); - return; - } - if (!token) { - next(`/login?redirect=${to.path}`); - clearSession(); - resetRoute(); - NProgress.done(); + const toPath = to.path; + // 判断是访问登陆页,有token就在当前页面,没有token重置路由与用户信息到登陆页 + if (toPath === URL_LOGIN) { + if (token) { + return next(from.fullPath); + } + + resetRoute(); syssocket.destory(); - return; + return next(); } - if (token && to.path === '/login') { - next('/'); - NProgress.done(); - return; + + // 判断访问页面是否在路由白名单地址(静态路由)中,如果存在直接放行 + if (ROUTER_WHITE_LIST.includes(toPath)) { + return next(); + } + + // 判断是否有token,没有重定向到 login 页面 + if (!token) { + return next(`${URL_LOGIN}?redirect=${toPath}`); } // 终端不需要连接系统websocket消息 @@ -222,14 +96,18 @@ router.beforeEach(async (to, from, next) => { syssocket.init(); } - // 不存在路由(避免刷新页面找不到路由)并且未加载过(避免token过期,导致获取权限接口报权限不足,无限获取),则重新初始化路由 - if (useRoutesList().routesList?.length == 0 && !loadRouter) { - await initRouter(); - loadRouter = true; - next({ path: to.path, query: to.query }); - } else { - next(); + // 不存在路由(避免刷新页面找不到路由),则重新初始化路由 + if (useRoutesList().routesList?.length == 0) { + try { + // 可能token过期无法获取菜单权限信息等 + await initRouter(); + } catch (e) { + return next(`${URL_401}?redirect=${toPath}`); + } + return next({ path: toPath, query: to.query }); } + + next(); }); // 路由加载后 @@ -237,5 +115,13 @@ router.afterEach(() => { NProgress.done(); }); +/** + * @description 路由跳转错误 + * */ +router.onError((error) => { + NProgress.done(); + console.warn('路由错误', error.message); +}); + // 导出路由 export default router; diff --git a/mayfly_go_web/src/router/route.ts b/mayfly_go_web/src/router/route.ts deleted file mode 100644 index f81bd359..00000000 --- a/mayfly_go_web/src/router/route.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { RouteRecordRaw } from 'vue-router'; -import Layout from '@/layout/index.vue'; - -// 定义动态路由 -export const dynamicRoutes = [ - { - path: '/', - name: '/', - component: Layout, - redirect: '/home', - meta: { - isKeepAlive: true, - }, - children: [], - // children: [ - // { - // path: '/home', - // name: 'home', - // component: () => import('@/views/home/index.vue'), - // meta: { - // title: '首页', - // // iframe链接 - // link: '', - // // 是否在菜单栏显示,默认显示 - // isHide: false, - // isKeepAlive: true, - // // tag标签是否不可删除 - // isAffix: true, - // // 是否为iframe - // isIframe: false, - // icon: 'el-icon-s-home', - // }, - // }, - // { - // path: '/sys', - // name: 'Resource', - // redirect: '/sys/resources', - // meta: { - // title: '系统管理', - // // 资源code,用于校验用户是否拥有该资源权限 - // code: 'sys', - // // isKeepAlive: true, - // icon: 'el-icon-monitor', - // }, - // children: [ - // { - // path: 'sys/resources', - // name: 'ResourceList', - // component: () => import('@/views/system/resource'), - // meta: { - // title: '资源管理', - // code: 'resource:list', - // isKeepAlive: true, - // icon: 'el-icon-menu', - // }, - // }, - // { - // path: 'sys/roles', - // name: 'RoleList', - // component: () => import('@/views/system/role'), - // meta: { - // title: '角色管理', - // code: 'role:list', - // isKeepAlive: true, - // icon: 'el-icon-menu', - // }, - // }, - // { - // path: 'sys/accounts', - // name: 'ResourceList', - // component: () => import('@/views/system/account'), - // meta: { - // title: '账号管理', - // code: 'account:list', - // isKeepAlive: true, - // icon: 'el-icon-menu', - // }, - // }, - // ], - // }, - // { - // path: '/machine', - // name: 'Machine', - // redirect: '/machine/list', - // meta: { - // title: '机器管理', - // // 资源code,用于校验用户是否拥有该资源权限 - // code: 'machine', - // // isKeepAlive: true, - // icon: 'el-icon-monitor', - // }, - // children: [ - // { - // path: '/list', - // name: 'MachineList', - // component: () => import('@/views/ops/machine'), - // meta: { - // title: '机器列表', - // code: 'machine:list', - // isKeepAlive: true, - // icon: 'el-icon-menu', - // }, - // }, - // ], - // }, - // { - // path: '/personal', - // name: 'personal', - // component: () => import('@/views/personal/index.vue'), - // meta: { - // title: '个人中心', - // isKeepAlive: true, - // icon: 'el-icon-user', - // }, - // }, - // ], - }, -]; - -// 定义静态路由 -export const staticRoutes: Array = [ - { - path: '/login', - name: 'login', - component: () => import('@/views/login/index.vue'), - meta: { - title: '登录', - }, - }, - { - path: '/404', - name: 'notFound', - component: () => import('@/views/error/404.vue'), - meta: { - title: '找不到此页面', - }, - }, - { - path: '/401', - name: 'noPower', - component: () => import('@/views/error/401.vue'), - meta: { - title: '没有权限', - }, - }, - { - path: '/oauth2/callback', - name: 'oauth2Callback', - component: () => import('@/views/oauth/Oauth2Callback.vue'), - meta: { - title: 'oauth2回调', - }, - }, - { - path: '/machine/terminal', - name: 'machineTerminal', - component: () => import('@/views/ops/machine/SshTerminalPage.vue'), - meta: { - // 将路径 'xxx?name=名字' 里的name字段值替换到title里 - title: '终端 | {name}', - // 是否根据query对标题名进行参数替换,即最终显示为‘终端_机器名’ - titleRename: true, - }, - }, -]; - -// 定义404界面 -export const pathMatch = { - path: '/:path(.*)*', - redirect: '/404', -}; diff --git a/mayfly_go_web/src/router/staticRouter.ts b/mayfly_go_web/src/router/staticRouter.ts new file mode 100644 index 00000000..4aa23433 --- /dev/null +++ b/mayfly_go_web/src/router/staticRouter.ts @@ -0,0 +1,82 @@ +import { RouteRecordRaw } from 'vue-router'; + +export const URL_HOME: string = '/home'; + +// 登录页地址(默认) +export const URL_LOGIN: string = '/login'; + +export const URL_401: string = '/401'; + +export const URL_404: string = '/404'; + +export const LAYOUT_ROUTE_NAME: string = 'layout'; + +// 路由白名单地址(本地存在的路由 staticRouter.ts 中) +export const ROUTER_WHITE_LIST: string[] = [URL_404, URL_401, '/oauth2/callback']; + +// 静态路由 +export const staticRoutes: Array = [ + { + path: '/', + redirect: URL_HOME, + }, + { + path: URL_LOGIN, + name: 'login', + component: () => import('@/views/login/index.vue'), + meta: { + title: '登录', + }, + }, + { + path: '/layout', + name: LAYOUT_ROUTE_NAME, + component: () => import('@/layout/index.vue'), + redirect: URL_HOME, + children: [], + }, + { + path: '/oauth2/callback', + name: 'oauth2Callback', + component: () => import('@/views/oauth/Oauth2Callback.vue'), + meta: { + title: 'oauth2回调', + }, + }, + { + path: '/machine/terminal', + name: 'machineTerminal', + component: () => import('@/views/ops/machine/SshTerminalPage.vue'), + meta: { + // 将路径 'xxx?name=名字' 里的name字段值替换到title里 + title: '终端 | {name}', + // 是否根据query对标题名进行参数替换,即最终显示为‘终端_机器名’ + titleRename: true, + }, + }, +]; + +// 错误页面路由 +export const errorRoutes: Array = [ + { + path: URL_404, + name: 'notFound', + component: () => import('@/views/error/404.vue'), + meta: { + title: '找不到此页面', + }, + }, + { + path: URL_401, + name: 'noPower', + component: () => import('@/views/error/401.vue'), + meta: { + title: '没有权限', + }, + }, + // Resolve refresh page, route warnings + { + path: '/:pathMatch(.*)*', + component: () => import('@/views/error/404.vue'), + }, +]; diff --git a/mayfly_go_web/src/store/routesList.ts b/mayfly_go_web/src/store/routesList.ts index c4fcc396..5e1dc250 100644 --- a/mayfly_go_web/src/store/routesList.ts +++ b/mayfly_go_web/src/store/routesList.ts @@ -11,7 +11,7 @@ export const useRoutesList = defineStore('routesList', { routesList: [], }), actions: { - async setRoutesList(data: Array) { + async setRoutesList(data: Array) { this.routesList = data; }, }, diff --git a/mayfly_go_web/src/views/error/401.vue b/mayfly_go_web/src/views/error/401.vue index 9cefc555..910026f9 100644 --- a/mayfly_go_web/src/views/error/401.vue +++ b/mayfly_go_web/src/views/error/401.vue @@ -19,16 +19,21 @@