13 changed files with 770 additions and 16 deletions
@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
|
||||
<template> |
||||
<transition> |
||||
<div :class="prefixCls"> |
||||
<MiniLogin sessionTimeout/> |
||||
</div> |
||||
</transition> |
||||
</template> |
||||
<script lang="ts" setup> |
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'; |
||||
import MiniLogin from './MiniLogin.vue'; |
||||
import { useDesign } from '/@/hooks/web/useDesign'; |
||||
import { useUserStore } from '/@/store/modules/user'; |
||||
import { usePermissionStore } from '/@/store/modules/permission'; |
||||
import { useAppStore } from '/@/store/modules/app'; |
||||
|
||||
const { prefixCls } = useDesign('st-login'); |
||||
const userStore = useUserStore(); |
||||
const permissionStore = usePermissionStore(); |
||||
const appStore = useAppStore(); |
||||
const userId = ref<Nullable<number | string>>(0); |
||||
|
||||
onMounted(() => { |
||||
// 记录当前的UserId |
||||
userId.value = userStore.getUserInfo?.id; |
||||
console.log('Mounted', userStore.getUserInfo); |
||||
}); |
||||
|
||||
onBeforeUnmount(() => { |
||||
if (userId.value && userId.value !== userStore.getUserInfo.id) { |
||||
// 登录的不是同一个用户,刷新整个页面以便丢弃之前用户的页面状态 |
||||
document.location.reload(); |
||||
} else if (permissionStore.getLastBuildMenuTime === 0) { |
||||
// 没有成功加载过菜单,就重新加载整个页面。这通常发生在会话过期后按F5刷新整个页面后载入了本模块这种场景 |
||||
document.location.reload(); |
||||
} |
||||
}); |
||||
</script> |
||||
<style lang="less" scoped> |
||||
@prefix-cls: ~'@{namespace}-st-login'; |
||||
|
||||
.@{prefix-cls} { |
||||
position: fixed; |
||||
z-index: 9999999; |
||||
width: 100%; |
||||
height: 100%; |
||||
background: @component-background; |
||||
} |
||||
</style> |
@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
|
||||
<template> |
||||
<PageWrapper |
||||
title="登录过期示例" |
||||
content="用户登录过期示例,不再跳转登录页,直接生成页面覆盖当前页面,方便保持过期前的用户状态!" |
||||
> |
||||
<a-card title="请点击下面的按钮访问测试接口" extra="所访问的接口会返回Token过期响应"> |
||||
<a-card-grid style="width: 50%; text-align: center"> |
||||
<a-button type="primary" @click="test1">HttpStatus == 401</a-button> |
||||
</a-card-grid> |
||||
<a-card-grid style="width: 50%; text-align: center"> |
||||
<span/> |
||||
<a-button class="ml-4" type="primary" @click="test2">Response.code == 401</a-button> |
||||
</a-card-grid> |
||||
</a-card> |
||||
</PageWrapper> |
||||
</template> |
||||
<script lang="ts"> |
||||
import { defineComponent } from 'vue'; |
||||
import { PageWrapper } from '/@/components/Page'; |
||||
import { useUserStore } from '/@/store/modules/user'; |
||||
|
||||
//import { sessionTimeoutApi, tokenExpiredApi } from '/@/api/demo/account'; |
||||
import { Card } from 'ant-design-vue'; |
||||
|
||||
export default defineComponent({ |
||||
name: 'TestSessionTimeout', |
||||
components: { ACardGrid: Card.Grid, ACard: Card, PageWrapper }, |
||||
setup() { |
||||
const userStore = useUserStore(); |
||||
async function test1() { |
||||
userStore.setAccessToken(''); |
||||
userStore.setSessionTimeout(true); |
||||
// 示例网站生产环境用的是mock数据,不能返回Http状态码, |
||||
// 所以在生产环境直接改变状态来达到测试效果 |
||||
// if (import.meta.env.PROD) { |
||||
// userStore.setToken(undefined); |
||||
// userStore.setSessionTimeout(true); |
||||
// } else { |
||||
// // 这个api会返回状态码为401的响应 |
||||
// await sessionTimeoutApi(); |
||||
// } |
||||
} |
||||
|
||||
async function test2() { |
||||
// 这个api会返回code为401的json数据,Http状态码为200 |
||||
try { |
||||
//await tokenExpiredApi(); |
||||
} catch (err) { |
||||
console.log('接口访问错误:', (err as Error).message || '错误'); |
||||
} |
||||
} |
||||
|
||||
return { test1, test2 }; |
||||
}, |
||||
}); |
||||
</script> |
@ -0,0 +1,559 @@
@@ -0,0 +1,559 @@
|
||||
<template> |
||||
<div :class="prefixCls" class="login-background-img"> |
||||
<AppLocalePicker v-if="showLocale" class="absolute top-4 right-4 enter-x xl:text-gray-600" :showText="false"/> |
||||
<AppDarkModeToggle class="absolute top-3 right-10 enter-x"/> |
||||
<div v-if="!getIsMobile" class="aui-logo"> |
||||
<div> |
||||
<h3> |
||||
<img :src="logoImg"> |
||||
</h3> |
||||
</div> |
||||
</div> |
||||
<div v-else class="aui-phone-logo"> |
||||
<img :src="logoPhoneImg"> |
||||
</div> |
||||
<div v-show="type === 'login'"> |
||||
<div class="aui-content"> |
||||
<div class="aui-container"> |
||||
<div class="aui-form"> |
||||
<div class="aui-image"> |
||||
<div class="aui-image-text"/> |
||||
</div> |
||||
<div class="aui-formBox"> |
||||
<div class="aui-formWell"> |
||||
<div class="aui-flex aui-form-nav"> |
||||
<div |
||||
class="aui-flex-box" |
||||
:class="activeIndex === 'accountLogin' ? 'activeNav on' : ''" |
||||
@click="loginClick('accountLogin')" |
||||
>{{ t('sys.login.signInFormTitle') }}</div> |
||||
<div |
||||
class="aui-flex-box" |
||||
:class="activeIndex === 'phoneLogin' ? 'activeNav on' : ''" |
||||
@click="loginClick('phoneLogin')" |
||||
>{{ t('sys.login.mobileSignInFormTitle') }}</div> |
||||
</div> |
||||
<div class="aui-form-box" style="height: 180px"> |
||||
<a-form |
||||
v-if="activeIndex === 'accountLogin'" |
||||
ref="loginRef" |
||||
:model="formData" |
||||
@keypress.enter="loginHandleClick" |
||||
> |
||||
<div class="aui-account"> |
||||
<div class="aui-inputClear"> |
||||
<i class="icon icon-code"/> |
||||
<a-form-item> |
||||
<a-input v-model:value="formData.username" class="fix-auto-fill" :placeholder="t('sys.login.userName')"/> |
||||
</a-form-item> |
||||
</div> |
||||
<div class="aui-inputClear"> |
||||
<i class="icon icon-password"/> |
||||
<a-form-item> |
||||
<a-input |
||||
v-model:value="formData.password" |
||||
class="fix-auto-fill" |
||||
type="password" |
||||
:placeholder="t('sys.login.password')" |
||||
/> |
||||
</a-form-item> |
||||
</div> |
||||
<div class="aui-inputClear"> |
||||
<i class="icon icon-code"/> |
||||
<a-form-item> |
||||
<a-input |
||||
v-model:value="formData.inputCode" |
||||
class="fix-auto-fill" |
||||
type="text" |
||||
:placeholder="t('sys.login.captcha')" |
||||
/> |
||||
</a-form-item> |
||||
<div class="aui-code"> |
||||
<img v-if="randCodeData.requestCodeSuccess" :src="randCodeData.randCodeImage" @click="handleChangeCheckCode"> |
||||
<img |
||||
v-else |
||||
style="margin-top: 2px; max-width: initial" |
||||
:src="defaultCaptchaImg" |
||||
@click="handleChangeCheckCode" |
||||
> |
||||
</div> |
||||
</div> |
||||
<div class="aui-flex"> |
||||
<div class="aui-flex-box"> |
||||
<div class="aui-choice"> |
||||
<a-input v-model:value="rememberMe" class="fix-auto-fill" type="checkbox"/> |
||||
<span style="margin-left: 5px">{{ t('sys.login.rememberMe') }}</span> |
||||
</div> |
||||
</div> |
||||
<div class="aui-forget"> |
||||
<a @click="forgetHandelClick"> {{ t('sys.login.forgetPassword') }}</a> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</a-form> |
||||
<a-form |
||||
v-else |
||||
ref="phoneFormRef" |
||||
:model="phoneFormData" |
||||
@keypress.enter="loginHandleClick" |
||||
> |
||||
<div class="aui-account phone"> |
||||
<div class="aui-inputClear phoneClear"> |
||||
<a-input v-model:value="phoneFormData.mobile" class="fix-auto-fill" :placeholder="t('sys.login.mobile')"/> |
||||
</div> |
||||
<div class="aui-inputClear"> |
||||
<a-input |
||||
v-model:value="phoneFormData.smscode" |
||||
class="fix-auto-fill" |
||||
:maxlength="6" |
||||
:placeholder="t('sys.login.smsCode')" |
||||
/> |
||||
<div v-if="showInterval" class="aui-code" @click="getLoginCode"> |
||||
<a>{{ t('component.countdown.normalText') }}</a> |
||||
</div> |
||||
<div v-else class="aui-code"> |
||||
<span class="aui-get-code code-shape">{{ t('component.countdown.sendText', [unref(timeRuning)]) }}</span> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</a-form> |
||||
</div> |
||||
<div class="aui-formButton"> |
||||
<div class="aui-flex"> |
||||
<a-button |
||||
:loading="loginLoading" |
||||
class="aui-link-login aui-flex-box" |
||||
type="primary" |
||||
@click="loginHandleClick" |
||||
>{{ t('sys.login.loginButton') }}</a-button> |
||||
</div> |
||||
<div class="aui-flex"> |
||||
<a class="aui-linek-code aui-flex-box" @click="codeHandleClick">{{ t('sys.login.qrSignInFormTitle') }}</a> |
||||
</div> |
||||
<!--<div class="aui-flex"> |
||||
<a class="aui-linek-code aui-flex-box" @click="registerHandleClick">{{ t('sys.login.registerButton') }}</a> |
||||
</div>--> |
||||
</div> |
||||
</div> |
||||
<a-form> |
||||
<div class="aui-flex aui-third-text"> |
||||
<div class="aui-flex-box aui-third-border"> |
||||
<span>{{ t('sys.login.otherSignIn') }}</span> |
||||
</div> |
||||
</div> |
||||
<div class="aui-flex" :class="`${prefixCls}-sign-in-way`"> |
||||
<div class="aui-flex-box"> |
||||
<div class="aui-third-login"> |
||||
<a title="github" @click="onThirdLogin('github')"><GithubFilled/></a> |
||||
</div> |
||||
</div> |
||||
<div class="aui-flex-box"> |
||||
<div class="aui-third-login"> |
||||
<a title="支付宝" @click="onThirdLogin('alipay')"><AlipayCircleFilled/></a> |
||||
</div> |
||||
</div> |
||||
<div class="aui-flex-box"> |
||||
<div class="aui-third-login"> |
||||
<a title="钉钉" @click="onThirdLogin('dingtalk')"><DingtalkCircleFilled/></a> |
||||
</div> |
||||
</div> |
||||
<div class="aui-flex-box"> |
||||
<div class="aui-third-login"> |
||||
<a title="微信" @click="onThirdLogin('wechat_open')"><WechatFilled/></a> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</a-form> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div v-show="type === 'forgot'" :class="`${prefixCls}-form`"> |
||||
<MiniForgotpad ref="forgotRef" @go-back="goBack" @success="handleSuccess"/> |
||||
</div> |
||||
<div v-show="type === 'register'" :class="`${prefixCls}-form`"> |
||||
<MiniRegister ref="registerRef" @go-back="goBack" @success="handleSuccess"/> |
||||
</div> |
||||
<div v-show="type === 'codeLogin'" :class="`${prefixCls}-form`"> |
||||
<MiniCodelogin ref="codeRef" @go-back="goBack" @success="handleSuccess"/> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
<script lang="ts" setup> |
||||
import { getCaptcha } from '/@/api/platform/core/controller/user'; |
||||
import { onMounted, reactive, ref, toRaw, unref } from 'vue'; |
||||
import defaultCaptchaImg from '/@/assets/images/captcha.jpg'; |
||||
import { useUserStore } from '/@/store/modules/user'; |
||||
import { useMessage } from '/@/hooks/web/useMessage'; |
||||
import { useI18n } from '/@/hooks/web/useI18n'; |
||||
import MiniForgotpad from './MiniForgotpad.vue'; |
||||
import MiniRegister from './MiniRegister.vue'; |
||||
import MiniCodelogin from './MiniCodelogin.vue'; |
||||
import logoImg from '/@/assets/images/logo-tag.png'; |
||||
import logoPhoneImg from '/@/assets/images/logo.png'; |
||||
import { AppLocalePicker, AppDarkModeToggle } from '/@/components/Application'; |
||||
import { useLocaleStore } from '/@/store/modules/locale'; |
||||
import { useDesign } from '/@/hooks/web/useDesign'; |
||||
import { useAppInject } from '/@/hooks/web/useAppInject'; |
||||
import { GithubFilled, WechatFilled, AlipayCircleFilled, DingtalkCircleFilled } from '@ant-design/icons-vue'; |
||||
|
||||
const { prefixCls } = useDesign('mini-login'); |
||||
const { notification, createMessage } = useMessage(); |
||||
const userStore = useUserStore(); |
||||
const { t } = useI18n(); |
||||
const localeStore = useLocaleStore(); |
||||
const showLocale = localeStore.getShowPicker; |
||||
const randCodeData = reactive<any>({ |
||||
randCodeImage: '', |
||||
requestCodeSuccess: false, |
||||
}); |
||||
const rememberMe = ref<string>('0'); |
||||
// 手机号登录还是账号登录 |
||||
const activeIndex = ref<string>('accountLogin'); |
||||
const type = ref<string>('login'); |
||||
// 账号登录表单字段 |
||||
const formData = reactive<any>({ |
||||
realKey: '', |
||||
inputCode: '', |
||||
username: '', |
||||
password: '', |
||||
}); |
||||
// 手机登录表单字段 |
||||
const phoneFormData = reactive<any>({ |
||||
mobile: '', |
||||
smscode: '', |
||||
}); |
||||
const loginRef = ref(); |
||||
// 扫码登录 |
||||
const codeRef = ref(); |
||||
// 是否显示获取验证码 |
||||
const showInterval = ref<boolean>(true); |
||||
// 60s |
||||
const timeRuning = ref<number>(60); |
||||
// 定时器 |
||||
const timer = ref<any>(null); |
||||
// 忘记密码 |
||||
const forgotRef = ref(); |
||||
// 注册 |
||||
const registerRef = ref(); |
||||
const loginLoading = ref<boolean>(false); |
||||
const { getIsMobile } = useAppInject(); |
||||
|
||||
/** |
||||
* 获取验证码 |
||||
*/ |
||||
async function handleChangeCheckCode() { |
||||
formData.inputCode = ''; |
||||
try { |
||||
const codeModel = await getCaptcha(); |
||||
randCodeData.randCodeImage = codeModel.img; |
||||
randCodeData.requestCodeSuccess = true; |
||||
formData.realKey = codeModel.realKey; |
||||
} catch(error) { |
||||
randCodeData.requestCodeSuccess = false; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* 切换登录方式 |
||||
*/ |
||||
function loginClick(type) { |
||||
activeIndex.value = type; |
||||
} |
||||
|
||||
/** |
||||
* 账号或者手机登录 |
||||
*/ |
||||
async function loginHandleClick() { |
||||
if (unref(activeIndex) === 'accountLogin') { |
||||
await accountLogin(); |
||||
} else { |
||||
await phoneLogin(); |
||||
} |
||||
} |
||||
|
||||
async function accountLogin() { |
||||
if (!formData.username) { |
||||
createMessage.warn(t('sys.login.accountPlaceholder')); |
||||
return; |
||||
} |
||||
if (!formData.password) { |
||||
createMessage.warn(t('sys.login.passwordPlaceholder')); |
||||
return; |
||||
} |
||||
if (!formData.inputCode) { |
||||
createMessage.warn(t('sys.login.smsPlaceholder')); |
||||
return; |
||||
} |
||||
try { |
||||
loginLoading.value = true; |
||||
const userInfo = await userStore.login(toRaw({ |
||||
password: formData.password, |
||||
username: formData.username, |
||||
realKey: formData.realKey, |
||||
code: formData.inputCode, |
||||
})); |
||||
if (userInfo) { |
||||
notification.success({ |
||||
message: t('sys.login.loginSuccessTitle'), |
||||
description: `${t('sys.login.loginSuccessDesc')}: ${userInfo.nickName}`, |
||||
duration: 3, |
||||
}); |
||||
} |
||||
} catch (error){ |
||||
formData.code=''; |
||||
formData.realKey=''; |
||||
await handleChangeCheckCode(); |
||||
} finally { |
||||
loginLoading.value = false; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* 手机号登录 |
||||
*/ |
||||
async function phoneLogin() { |
||||
notification.success({ |
||||
message: '功能待开发中.', |
||||
duration: 3, |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* 获取手机验证码 |
||||
*/ |
||||
async function getLoginCode() { |
||||
/*if (!phoneFormData.mobile) { |
||||
createMessage.warn(t('sys.login.mobilePlaceholder')); |
||||
return; |
||||
}*/ |
||||
const TIME_COUNT = 60; |
||||
if (!unref(timer)) { |
||||
timeRuning.value = TIME_COUNT; |
||||
showInterval.value = false; |
||||
timer.value = setInterval(() => { |
||||
if (unref(timeRuning) > 0 && unref(timeRuning) <= TIME_COUNT) { |
||||
timeRuning.value = timeRuning.value - 1; |
||||
} else { |
||||
showInterval.value = true; |
||||
clearInterval(unref(timer)); |
||||
timer.value = null; |
||||
} |
||||
}, 1000); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* 第三方登录 |
||||
* @param type |
||||
*/ |
||||
function onThirdLogin(type) { |
||||
notification.success({ |
||||
message: '功能待开发中.', |
||||
duration: 3, |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* 忘记密码 |
||||
*/ |
||||
function forgetHandelClick() { |
||||
type.value = 'forgot'; |
||||
setTimeout(() => { |
||||
forgotRef.value.initForm(); |
||||
}, 300); |
||||
} |
||||
|
||||
/** |
||||
* 返回登录页面 |
||||
*/ |
||||
function goBack() { |
||||
activeIndex.value = 'accountLogin'; |
||||
type.value = 'login'; |
||||
} |
||||
|
||||
/** |
||||
* 忘记密码/注册账号回调事件 |
||||
* @param value |
||||
*/ |
||||
function handleSuccess(value) { |
||||
Object.assign(formData, value); |
||||
Object.assign(phoneFormData, { mobile: '', smscode: '' }); |
||||
type.value = 'login'; |
||||
activeIndex.value = 'accountLogin'; |
||||
handleChangeCheckCode(); |
||||
} |
||||
|
||||
/** |
||||
* 注册 |
||||
*/ |
||||
function registerHandleClick() { |
||||
type.value = 'register'; |
||||
setTimeout(() => { |
||||
registerRef.value.initForm(); |
||||
}, 300); |
||||
} |
||||
|
||||
/** |
||||
* 二维码登录 |
||||
*/ |
||||
function codeHandleClick() { |
||||
type.value = 'codeLogin'; |
||||
setTimeout(() => { |
||||
codeRef.value.initFrom(); |
||||
}, 300); |
||||
} |
||||
|
||||
onMounted(() => { |
||||
//加载验证码 |
||||
handleChangeCheckCode(); |
||||
}); |
||||
</script> |
||||
|
||||
<style lang="less" scoped> |
||||
|
||||
.login-background-img { |
||||
background-image: url(../icon/login-bg.png); |
||||
background-size: cover; |
||||
background-position: top center; |
||||
background-repeat: no-repeat; |
||||
} |
||||
|
||||
.aui-logo { |
||||
width: 180px; |
||||
height: 80px; |
||||
position: absolute; |
||||
top: 2%; |
||||
left: 8%; |
||||
z-index: 4; |
||||
} |
||||
|
||||
:deep(.ant-input:focus) { |
||||
box-shadow: none; |
||||
} |
||||
.aui-get-code { |
||||
float: right; |
||||
position: relative; |
||||
z-index: 3; |
||||
background: #ffffff; |
||||
color: #1573e9; |
||||
border-radius: 100px; |
||||
padding: 5px 16px; |
||||
margin: 7px; |
||||
border: 1px solid #1573e9; |
||||
top: 12px; |
||||
} |
||||
|
||||
.aui-get-code:hover { |
||||
color: #1573e9; |
||||
} |
||||
|
||||
.code-shape { |
||||
border-color: #dadada !important; |
||||
color: #aaa !important; |
||||
} |
||||
|
||||
.aui-link-login{ |
||||
height: 42px; |
||||
padding: 10px 15px; |
||||
font-size: 14px; |
||||
border-radius: 8px; |
||||
margin-top: 15px; |
||||
margin-bottom: 8px; |
||||
} |
||||
.aui-phone-logo{ |
||||
position: absolute; |
||||
margin-left: 10px; |
||||
width: 60px; |
||||
top:2px; |
||||
z-index: 4; |
||||
} |
||||
.top-3{ |
||||
top: 0.45rem; |
||||
} |
||||
</style> |
||||
|
||||
<style lang="less"> |
||||
@prefix-cls: ~'@{namespace}-mini-login'; |
||||
@dark-bg: #293146; |
||||
|
||||
html[data-theme='dark'] { |
||||
.@{prefix-cls} { |
||||
background-color: @dark-bg !important; |
||||
background-image: none; |
||||
|
||||
&::before { |
||||
background-image: url(/@/assets/images/login-bg-dark.svg); |
||||
} |
||||
.aui-inputClear{ |
||||
background-color: #232a3b !important; |
||||
} |
||||
.ant-input, |
||||
.ant-input-password { |
||||
background-color: #232a3b !important; |
||||
} |
||||
|
||||
.ant-btn:not(.ant-btn-link):not(.ant-btn-primary) { |
||||
border: 1px solid #4a5569 !important; |
||||
} |
||||
|
||||
&-form { |
||||
background: @dark-bg !important; |
||||
} |
||||
|
||||
.app-iconify { |
||||
color: #fff !important; |
||||
} |
||||
.aui-inputClear input,.aui-input-line input,.aui-choice{ |
||||
color: #c9d1d9 !important; |
||||
} |
||||
|
||||
.aui-formBox{ |
||||
background-color: @dark-bg !important; |
||||
} |
||||
.aui-third-text span{ |
||||
background-color: @dark-bg !important; |
||||
} |
||||
.aui-form-nav .aui-flex-box{ |
||||
color: #c9d1d9 !important; |
||||
} |
||||
|
||||
.aui-formButton .aui-linek-code{ |
||||
background: @dark-bg !important; |
||||
color: white !important; |
||||
} |
||||
.aui-code-line{ |
||||
border-left: none !important; |
||||
} |
||||
.ant-checkbox-inner,.aui-success h3{ |
||||
border-color: #c9d1d9; |
||||
} |
||||
} |
||||
|
||||
input.fix-auto-fill, |
||||
.fix-auto-fill input { |
||||
-webkit-text-fill-color: #c9d1d9 !important; |
||||
box-shadow: inherit !important; |
||||
} |
||||
|
||||
&-sign-in-way { |
||||
.anticon { |
||||
font-size: 22px !important; |
||||
color: #888 !important; |
||||
cursor: pointer !important; |
||||
|
||||
&:hover { |
||||
color: @primary-color !important; |
||||
} |
||||
} |
||||
} |
||||
.ant-divider-inner-text { |
||||
font-size: 12px !important; |
||||
color: @text-color-secondary !important; |
||||
} |
||||
.aui-third-login a{ |
||||
background: transparent; |
||||
} |
||||
} |
||||
</style> |
Loading…
Reference in new issue