This commit is contained in:
李志鹏
2026-04-21 15:57:59 +08:00
parent 483b78ada2
commit 9cd63c90c9
28 changed files with 1568 additions and 6 deletions

View File

@@ -1,13 +1,15 @@
<template>
<main-header />
<div class="view"><RouteCache /></div>
<login-dialog />
<div id="loading" v-if="loading" v-loading="true"></div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import RouteCache from '@/components/RouteCache.vue'
import MainHeader from '@/views/main-header.vue'
import { computed } from 'vue'
import LoginDialog from '@/views/login/login-dialog.vue'
import { useGlobalStore } from '@/stores'
const globalStore = useGlobalStore()
const loading = computed(() => globalStore.state.loading)

View File

@@ -0,0 +1,3 @@
<svg width="10" height="17" viewBox="0 0 10 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.56121 8.28356L8.95682 0.887943C9.16023 0.684537 9.16023 0.355959 8.95682 0.152554C8.75342 -0.0508513 8.42484 -0.0508513 8.22143 0.152554L0.0852156 8.28877C-0.0295258 8.40351 -0.0295258 8.59127 0.0852156 8.71123L8.22143 16.8474C8.42484 17.0509 8.75342 17.0509 8.95682 16.8474C9.16023 16.644 9.16023 16.3155 8.95682 16.1121L1.56121 8.71644C1.44647 8.6017 1.44647 8.41394 1.56121 8.29399V8.28356Z" fill="#232323"/>
</svg>

After

Width:  |  Height:  |  Size: 527 B

View File

@@ -0,0 +1,3 @@
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.28289 16.25L0 14.9671L6.8421 8.125L0 1.28289L1.28289 0L8.125 6.8421L14.9671 0L16.25 1.28289L9.40789 8.125L16.25 14.9671L14.9671 16.25L8.125 9.40789L1.28289 16.25Z" fill="#232323"/>
</svg>

After

Width:  |  Height:  |  Size: 296 B

View File

@@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.6233 6.32175L15.7098 9.40833C15.7338 9.43232 15.7418 9.4683 15.7298 9.50029C15.7178 9.53227 15.6858 9.55226 15.6539 9.55226L8.14532 9.55226C7.89743 9.55226 7.69752 9.75217 7.69752 10.0001C7.69752 10.2479 7.89743 10.4478 8.14532 10.4478L15.6539 10.4478C15.6898 10.4478 15.7178 10.4678 15.7298 10.4998C15.7418 10.5318 15.7378 10.5678 15.7098 10.5918L12.6233 13.6784C12.4553 13.8503 12.4553 14.1461 12.6233 14.3141C12.7072 14.398 12.8232 14.446 12.9431 14.446C13.063 14.446 13.175 14.398 13.263 14.3141L17.449 10.128C17.517 10.06 17.517 9.93609 17.449 9.86812L13.263 5.68205C13.179 5.59809 13.063 5.55011 12.9431 5.55011C12.8232 5.55011 12.7112 5.59809 12.6233 5.68205C12.4553 5.84997 12.4553 6.14983 12.6233 6.31776L12.6233 6.32175Z" fill="#232323"/>
<path d="M7.39453 17.1967C7.39453 16.9488 7.19462 16.7489 6.94674 16.7489L3.74821 16.7489C3.5563 16.7489 3.39638 16.593 3.39638 16.397L3.39638 3.60295C3.39638 3.41104 3.5523 3.25112 3.74821 3.25112L6.94674 3.25112C7.19462 3.25112 7.39453 3.05121 7.39453 2.80332C7.39453 2.55544 7.19462 2.35553 6.94674 2.35553L3.74821 2.35553C3.06053 2.35553 2.50079 2.91527 2.50079 3.60295L2.50079 16.397C2.50079 17.0847 3.06053 17.6445 3.74821 17.6445L6.94674 17.6445C7.19462 17.6445 7.39453 17.4446 7.39453 17.1967Z" fill="#232323"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,3 @@
<svg width="19" height="12" viewBox="0 0 19 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.88916 2.181C7.79027 1.48222 8.35808 0.400024 9.37247 0.400024C11.245 0.400024 11.5927 2.86742 9.86691 3.49818C9.51283 3.62804 9.26402 3.93724 9.26402 4.30209V4.96687L0.593832 9.8089C0.246132 10.0099 0.392868 10.8664 0.794797 10.8664H17.6407C18.0395 10.8664 18.1894 10.0192 17.8513 9.81199L14.017 7.65843L12.0998 6.58165" stroke="#585858" stroke-width="0.8" stroke-miterlimit="10" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 520 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,9 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.0759 3.39874H16.3041C16.5037 3.39874 16.6673 3.56227 16.6673 3.76194V17.9701C16.6673 18.1698 16.5037 18.3333 16.3041 18.3333H4.30851C4.10884 18.3333 3.94531 18.1698 3.94531 17.9701V3.76285C3.94531 3.56318 4.10884 3.39965 4.30851 3.39965H7.39753" stroke="#585858" stroke-width="0.666667" stroke-miterlimit="10"/>
<path d="M14.0938 5.15686H15.1337C15.2069 5.15686 15.2675 5.21739 15.2675 5.29058V16.3736C15.2675 16.4468 15.2069 16.5073 15.1337 16.5073H5.28215C5.20897 16.5073 5.14844 16.4468 5.14844 16.3736V5.29058C5.14844 5.21739 5.20897 5.15686 5.28215 5.15686H6.29948" stroke="#585858" stroke-width="0.666667" stroke-miterlimit="10"/>
<path d="M9.59802 2.08328L6.48732 3.94084C6.42047 3.9806 6.37891 4.05378 6.37891 4.13148V5.33582C6.37891 5.4587 6.47829 5.55808 6.60116 5.55808H13.867C13.9899 5.55808 14.0892 5.4587 14.0892 5.33582V4.13148C14.0892 4.05287 14.0486 3.9806 13.9808 3.94084L10.8701 2.08328C10.478 1.84928 9.98923 1.84928 9.59712 2.08328H9.59802Z" stroke="#585858" stroke-width="0.666667" stroke-miterlimit="10"/>
<path d="M6.89062 7.74353H13.3032" stroke="#585858" stroke-width="0.666667" stroke-miterlimit="10" stroke-linecap="round"/>
<path d="M6.89062 9.59393H13.3032" stroke="#585858" stroke-width="0.666667" stroke-miterlimit="10" stroke-linecap="round"/>
<path d="M6.89062 11.4443H13.3032" stroke="#585858" stroke-width="0.666667" stroke-miterlimit="10" stroke-linecap="round"/>
<path d="M6.89062 13.385H13.3032" stroke="#585858" stroke-width="0.666667" stroke-miterlimit="10" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,5 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.41667 5.83333C6.41667 5.51117 6.1555 5.25 5.83333 5.25C5.51117 5.25 5.25 5.51117 5.25 5.83333V8.16667C5.25 8.48883 5.51117 8.75 5.83333 8.75C6.1555 8.75 6.41667 8.48883 6.41667 8.16667V5.83333Z" fill="#9F9F9F"/>
<path d="M5.83333 0C2.61167 0 0 2.61167 0 5.83333C0 9.05499 2.61167 11.6667 5.83333 11.6667C9.05499 11.6667 11.6667 9.05499 11.6667 5.83333C11.6667 2.61167 9.05499 0 5.83333 0ZM1.16667 5.83333C1.16667 3.256 3.256 1.16667 5.83333 1.16667C8.41066 1.16667 10.5 3.256 10.5 5.83333C10.5 8.41066 8.41066 10.5 5.83333 10.5C3.256 10.5 1.16667 8.41066 1.16667 5.83333Z" fill="#9F9F9F"/>
<path d="M5.83333 4.25836C6.20382 4.25836 6.50417 3.95802 6.50417 3.58753C6.50417 3.21704 6.20382 2.91669 5.83333 2.91669C5.46284 2.91669 5.1625 3.21704 5.1625 3.58753C5.1625 3.95802 5.46284 4.25836 5.83333 4.25836Z" fill="#9F9F9F"/>
</svg>

After

Width:  |  Height:  |  Size: 939 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -0,0 +1,109 @@
<template>
<div class="input-code">
<input
ref="inputRef"
type="tel"
maxlength="1"
v-for="(v, i) in props.length"
:key="i"
v-model="code[i]"
@input="handleInput(i)"
@keydown.delete="handleDelete(i)"
@paste="handlePaste"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, computed, watch, nextTick } from 'vue'
const emit = defineEmits(['submit', 'update:modelValue'])
const props = defineProps({
length: {
type: Number,
default: 6
}
})
const inputRef = ref('')
const code = ref([])
const codeStr = computed(() => code.value.join(''))
watch(codeStr, (newVal) => {
emit('update:modelValue', newVal)
})
const resetCode = (size = props.length) => {
code.value = []
for (let i = 0; i < size; i++) {
code.value.push('')
}
}
resetCode(props.length)
const handleInput = (index: number) => {
const value = code.value[index]
if (value) {
if (/[0-9]/.test(value)) {
code.value[index] = value
focusLast()
} else {
code.value[index] = ''
}
}
submit()
}
const handleDelete = (index: number) => {
if (code.value[index].length == 0) {
focusLast(-1)
}
}
const handlePaste = (e: ClipboardEvent) => {
const text = e.clipboardData?.getData('text')
if (text) {
const nums = text.match(/[0-9]/g) || []
if (nums.length === code.value.length) {
code.value = [...nums]
focusLast()
nextTick(submit)
}
}
}
// 聚焦最后一个没有输入的
const focusLast = (step = 0) => {
let index = code.value.findIndex((item) => !item) + step
index < 0 && (index = 0)
if (index >= 0 && index < props.length) {
inputRef.value[index]?.focus?.()
}
if (code.value.every((item) => item.length)) {
inputRef.value?.forEach((item) => item.blur?.())
}
}
const submit = () => {
if (codeStr.value.length === props.length) {
emit('submit', codeStr.value)
}
}
onMounted(() => {
focusLast()
})
defineExpose({
resetCode
})
</script>
<style lang="less" scoped>
.input-code {
width: 100%;
display: flex;
align-items: center;
justify-content: var(--input-code-justify-content, space-between);
gap: var(--input-code-input-gap, 0);
> input {
width: var(--input-code-input-width, 4.8rem);
height: var(--input-code-input-height, 4.8rem);
border-radius: var(--input-code-input-radius, 0);
border-width: var(--input-code-input-border-width, 0.15rem);
border-color: var(--input-code-input-border-color, #c4c4c4);
border-style: var(--input-code-input-border-style, solid);
background-color: var(--input-code-input-bg-color, #fff);
text-align: center;
}
}
</style>

View File

@@ -2,5 +2,40 @@ export default {
Login: {
login: 'Log in',
register: 'Register',
}
loginTo: 'Log on to <span>FiDA</span>',
loginTitle: 'A multi-agent canvas for rapid, trend driven design iteration.',
name: 'Name',
email: 'Email',
password: 'Password',
enterName: 'Enter your name',
enterEmail: 'Enter your email',
enterPassword: 'Enter your password',
forgotPassword: 'Forget password?',
pleaseInputName: 'Please input the name',
nameLengthError: 'Name length must be between {min} and {max} characters',
passwordSpecial: 'Must contain special characters',
passwordCase: 'Mix of uppercase, lowercase and numbers',
pleaseInputEmail: 'Please input the email',
emailFormatError: 'Please input the email again',
pleaseInputPassword: 'Please input the password',
passwordLengthError: 'Password length must be between {min} and {max} characters',
pleaseTermsPolicy: 'Please agree to the Terms, Policy and Fees',
agreeTermsPolicy:
'I agree to the <span onclick="onClickPrivacy()">Terms, Policy</span> and Fees.',
noAccountToSignUp: `Don't have an account? <span onclick="onClickRegister()">Sign up</span>`,
signUpFor: 'Sign up for <span>FiDA</span>',
registerTip: 'A multi-agent canvas for rapid, trend driven design iteration.',
havenAccountToLogin: `Already have an account? <span onclick="onClickLogin()">Log in</span>`,
verifyEmail: 'Verify your email address',
verifyCodeHasSent: 'A verification code has been sent to<br><span>{email}</span>',
verify: 'Verify',
resendCode: 'Resend Code',
resendCodeIn: 'Resend Code in {time}',
orContinueWith: 'or continue with',
googleLogin: 'Sign in with Google',
wechatLogin: 'Sign in with Wechat',
indexTip: 'A multi-agent canvas for rapid, trend driven design iteration.',
sendCodeError: 'Send code error',
retrievePassword: 'Retrieve password'
},
}

View File

@@ -2,5 +2,41 @@ export default {
Login: {
login: '登录',
register: '注册',
}
signUp: '注册',
loginTo: '登录到 <span>FiDA</span',
loginTitle: '一个多智能体画布,用于快速、趋势驱动的设计迭代。',
name: '姓名',
email: '邮箱',
password: '密码',
enterName: '请输入姓名',
enterEmail: '请输入邮箱',
enterPassword: '请输入密码',
forgotPassword: '忘记密码?',
pleaseInputName: '请输入姓名',
nameLengthError: '姓名长度必须在 {min} 到 {max} 个字符之间',
passwordSpecial: '必须包含特殊符号',
passwordCase: '大小写字母与数字混合组合',
pleaseInputEmail: '请输入邮箱',
emailFormatError: '请输入正确的邮箱',
pleaseInputPassword: '请输入密码',
passwordLengthError: '密码长度必须在 {min} 到 {max} 个字符之间',
pleaseTermsPolicy: '请同意条款、政策和费用',
agreeTermsPolicy: '我同意 <span onclick="onClickPrivacy()">条款、政策</span> 和费用。',
noAccountToSignUp: `还没有账号? <span onclick="onClickRegister()">注册</span>`,
signUpFor: '注册账号',
registerTip: '一个多智能体画布,用于快速、趋势驱动的设计迭代。',
havenAccountToLogin: `已经有账号? <span onclick="onClickLogin()">登录</span>`,
verifyEmail: '验证您的邮箱地址',
verifyCodeHasSent: '已发送验证码到<br><span>{email}</span>',
verifyCode: '请输入验证码',
verify: '验证',
resendCode: '重新发送验证码',
resendCodeIn: '重新发送验证码倒计时 {time}',
orContinueWith: '或者使用',
googleLogin: '使用 Google 登录',
wechatLogin: '使用微信登录',
indexTip: '一个多智能体画布,用于快速、趋势驱动的设计迭代。',
sendCodeError: '发送验证码失败',
retrievePassword: '找回密码'
},
}

View File

@@ -183,4 +183,15 @@ export async function shareImageToWhatsapp (url: string){
const whatsappLink = `https://api.whatsapp.com/send/?text=${encodeURIComponent(message)}`
window.open(whatsappLink, '_blank')
}
}
}
/**
* 倒计时
* @param time 倒计时时间,单位秒
* @returns 倒计时字符串,格式为 mm:ss
*/
export function CountDown(time: number) {
const mm = String(Math.floor(time / 60)).padStart(2, '0');
const ss = String(time % 60).padStart(2, '0');
return `${mm}:${ss}`;
}

View File

@@ -0,0 +1,130 @@
.retrieve-password,
.register,
.login {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
margin-top: 2rem;
}
.retrieve-password:deep(.el-form),
.register:deep(.el-form),
.login:deep(.el-form) {
width: 100%;
}
.retrieve-password:deep(.el-form) .el-form-item,
.register:deep(.el-form) .el-form-item,
.login:deep(.el-form) .el-form-item {
position: relative;
margin-bottom: 1.6rem;
}
.retrieve-password:deep(.el-form) .el-form-item__content,
.register:deep(.el-form) .el-form-item__content,
.login:deep(.el-form) .el-form-item__content {
position: relative;
}
.retrieve-password:deep(.el-form) .el-form-item__label,
.register:deep(.el-form) .el-form-item__label,
.login:deep(.el-form) .el-form-item__label {
color: #232323;
font-size: 1.2rem;
margin-bottom: 0.8rem;
line-height: 1.5rem;
}
.retrieve-password:deep(.el-form) .el-input,
.register:deep(.el-form) .el-input,
.login:deep(.el-form) .el-input {
--el-input-height: 3.4rem;
--el-input-border-radius: 0;
--el-input-text-color: #232323;
--el-border-color: #C4C4C4;
font-size: 1.4rem;
}
.retrieve-password:deep(.el-form) .el-input::placeholder,
.register:deep(.el-form) .el-input::placeholder,
.login:deep(.el-form) .el-input::placeholder {
color: #9F9F9F;
}
.retrieve-password:deep(.el-form) .password-tip,
.register:deep(.el-form) .password-tip,
.login:deep(.el-form) .password-tip {
position: absolute;
z-index: 10;
top: -1rem;
right: 0;
transform: translateY(-100%);
}
.retrieve-password:deep(.el-form) .password-warning,
.register:deep(.el-form) .password-warning,
.login:deep(.el-form) .password-warning {
--el-checkbox-height: auto;
margin-top: -0.6rem;
margin-bottom: 1.6rem;
display: flex;
align-items: center;
}
.retrieve-password:deep(.el-form) .password-warning > .icon,
.register:deep(.el-form) .password-warning > .icon,
.login:deep(.el-form) .password-warning > .icon {
width: 1.4rem;
height: 1.4rem;
margin-right: 0.8rem;
}
.retrieve-password:deep(.el-form) .password-warning > .label,
.register:deep(.el-form) .password-warning > .label,
.login:deep(.el-form) .password-warning > .label {
font-family: KaiseiOpti-Regular;
font-size: 1rem;
color: #9F9F9F;
}
.retrieve-password:deep(.el-form) .forgotPassword,
.register:deep(.el-form) .forgotPassword,
.login:deep(.el-form) .forgotPassword {
margin-top: -0.8rem;
margin-bottom: 5rem;
font-size: 1.1rem;
text-align: right;
color: #666666;
cursor: pointer;
text-decoration: underline;
font-family: KaiseiOpti-Regular;
}
.retrieve-password:deep(.el-form) .el-form-item__error,
.register:deep(.el-form) .el-form-item__error,
.login:deep(.el-form) .el-form-item__error {
padding-top: 1px;
font-size: 1rem;
}
.retrieve-password:deep(.el-form) .submit,
.register:deep(.el-form) .submit,
.login:deep(.el-form) .submit {
width: 100%;
height: 4rem;
font-size: 1.36rem;
}
.retrieve-password:deep(.el-form) .privacy,
.register:deep(.el-form) .privacy,
.login:deep(.el-form) .privacy {
margin-top: -0.6rem;
--el-checkbox-height: auto;
}
.retrieve-password:deep(.el-form) .privacy .el-checkbox__label,
.register:deep(.el-form) .privacy .el-checkbox__label,
.login:deep(.el-form) .privacy .el-checkbox__label {
font-size: 1.1rem;
color: #666666;
font-family: KaiseiOpti-Regular;
}
.retrieve-password:deep(.el-form) .privacy .el-checkbox__label > div > span,
.register:deep(.el-form) .privacy .el-checkbox__label > div > span,
.login:deep(.el-form) .privacy .el-checkbox__label > div > span {
font-family: KaiseiOpti-Bold;
text-decoration: underline;
cursor: pointer;
color: #232323;
}
.retrieve-password > .other-login,
.register > .other-login,
.login > .other-login {
margin-top: 4rem;
}

View File

@@ -0,0 +1,143 @@
<template>
<div class="visible-code">
<div class="tip" v-html="$t('Login.verifyCodeHasSent', { email: props.email })"></div>
<input-code @submit="onVerify" v-model="code" ref="inputCodeRef" />
<p class="time" v-if="time > 0">{{ $t('Login.resendCodeIn', { time: timeStr }) }}</p>
<p class="time" v-if="time === 0">
<span @click="onResend">{{ $t('Login.resendCode') }}</span>
</p>
<button class="verify" custom="black" @click="onVerify">{{ $t('Login.verify') }}</button>
<other-login />
</div>
</template>
<script setup lang="ts">
import md5 from 'md5'
import { ElMessage } from 'element-plus'
import OtherLogin from './other-login.vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { CountDown } from '@/utils/tools'
import InputCode from '@/components/input-code.vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const router = useRouter()
const emit = defineEmits(['submit-email-code'])
const props = defineProps({
email: { type: String, required: true },
type: {
type: String as () => 'LOGIN' | 'REGISTER' | 'FORGOT_PWD',
required: true
},
password: { type: String, default: '' }
})
const code = ref('')
const time = ref(60)
const timeStr = computed(() => CountDown(time.value))
const timeout = ref(null)
const setTime = (s = 120) => {
clearTime()
time.value = s
timeout.value = setInterval(() => {
time.value--
if (time.value <= 0) {
clearTime()
time.value = 0
}
}, 1000)
}
const clearTime = () => {
time.value = -1
clearTimeout(timeout.value)
}
onBeforeUnmount(() => {
clearTime()
})
onMounted(() => {
onSendCode()
})
const inputCodeRef = ref(null)
const resetCode = () => {
inputCodeRef.value?.resetCode?.()
}
const onSendCode = async () => {
resetCode()
const email = props.email
if (!email) {
console.warn('请输入邮箱')
return Promise.reject('请输入邮箱')
}
// const data = {
// email,
// type: props.type
// }
// if (props.type === 'LOGIN') {
// data['password'] = md5(props.password)
// }
// const res = await SendVerificationCode(data)
// if (!res) {
// ElMessage.error(t('Login.sendCodeError'))
// return Promise.reject('发送验证码失败')
// }
setTime()
return Promise.resolve()
}
const onResend = () => {
if (time.value > 0) return
onSendCode()
}
const onVerify = () => {
if (code.value.length !== 6) return
emit('submit-email-code', code.value)
}
defineExpose({
onSendCode
})
</script>
<style lang="less" scoped>
.visible-code {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
> .tip {
text-align: center;
margin-top: 4.6rem;
line-height: 2.4rem;
font-family: KaiseiOpti-Regular;
font-size: 1.6rem;
color: #666;
&:deep(span) {
color: #252727;
font-family: KaiseiOpti-Medium;
}
}
> .input-code {
margin-top: 6rem;
}
> .verify {
margin-top: 5rem;
width: 100%;
height: 4rem;
--button-font-size: 1.4rem;
}
> .time {
user-select: none;
margin-top: 2.4rem;
font-size: 1.6rem;
color: #666;
font-family: Regular;
> span {
color: #ff7a50;
text-decoration: underline;
cursor: pointer;
font-weight: 500;
font-family: Medium;
}
}
> .other-login {
margin-top: 6rem;
}
}
</style>

117
src/views/login/index.vue Normal file
View File

@@ -0,0 +1,117 @@
<template>
<div class="index">
<div class="header">
<span class="tip">{{ $t('AlphaVersion') }}</span>
<img src="@/assets/images/logo-1.png" class="logo" />
<p class="split"></p>
<button class="login" @click="onLogin">{{ $t('Login.login') }}</button>
<button class="register" @click="onRegister">{{ $t('Login.register') }}</button>
</div>
<img src="@/assets/images/login/index-title.png" class="title" draggable="false" />
<img src="@/assets/images/login/index-zhuangshi.png" class="zhuangshi" draggable="false" />
<div class="tip">{{ $t('Login.indexTip') }}</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const onLogin = () => {
router.push({ name: 'login' })
}
const onRegister = () => {
router.push({ name: 'register' })
}
</script>
<style lang="less" scoped>
.index {
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
background-image: url('@/assets/images/login/index-bg.png');
background-size: cover;
background-position: center;
> .header {
position: absolute;
top: 3rem;
left: 0;
width: 100%;
display: flex;
justify-content: center;
> * {
position: relative;
z-index: 1;
}
> .tip {
position: absolute;
width: 100%;
top: 0;
left: 0;
font-size: 3rem;
text-align: center;
font-family: Regular;
color: #fff;
z-index: 0;
}
> .logo {
width: auto;
height: 2.5rem;
margin-left: 3.8rem;
}
> .split {
margin: 0 auto;
}
> button {
margin-right: 3rem;
width: 20rem;
height: 5.2rem;
border-radius: 5rem;
border: none;
outline: none;
font-size: 2.2rem;
font-weight: 600;
font-family: SemiBold;
border: 0.2rem solid #fff;
&:active {
opacity: 0.8;
}
}
> .login {
background-color: #fff;
color: #713e1f;
}
> .register {
background-color: transparent;
color: #ffffff;
backdrop-filter: blur(10px);
}
}
> .zhuangshi,
> .title,
> .tip {
position: absolute;
left: 50%;
transform: translateX(-50%);
}
> .title {
// width: 55%;
width: 105.6rem;
height: auto;
top: 20.5rem;
}
> .zhuangshi {
width: 21.5rem;
height: auto;
bottom: 13.4rem;
}
> .tip {
font-size: 2.8rem;
font-family: Regular;
color: #fff;
bottom: 8rem;
}
}
</style>

View File

@@ -0,0 +1,120 @@
.retrieve-password,
.register,
.login {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
margin-top: 2rem;
&:deep(.el-form) {
width: 100%;
.el-form-item {
position: relative;
margin-bottom: 1.6rem;
}
.el-form-item__content {
position: relative;
}
.el-form-item__label {
color: #232323;
font-size: 1.2rem;
margin-bottom: 0.8rem;
line-height: 1.5rem;
}
.el-input {
--el-input-height: 3.4rem;
--el-input-border-radius: 0;
--el-input-text-color: #232323;
--el-border-color: #C4C4C4;
font-size: 1.4rem;
&::placeholder {
color: #9F9F9F;
}
}
.password-tip {
position: absolute;
z-index: 10;
top: -1rem;
right: 0;
transform: translateY(-100%);
}
.password-warning {
--el-checkbox-height: auto;
margin-top: -0.6rem;
margin-bottom: 1.6rem;
display: flex;
align-items: center;
>.icon {
width: 1.4rem;
height: 1.4rem;
margin-right: 0.8rem;
}
>.label {
font-family: KaiseiOpti-Regular;
font-size: 1rem;
color: #9F9F9F;
}
}
.forgotPassword {
margin-top: -0.8rem;
margin-bottom: 5rem;
font-size: 1.1rem;
text-align: right;
color: #666666;
cursor: pointer;
text-decoration: underline;
font-family: KaiseiOpti-Regular;
}
.el-form-item__error {
padding-top: 1px;
font-size: 1rem;
}
.submit {
width: 100%;
height: 4rem;
font-size: 1.36rem;
}
.privacy {
margin-top: -0.6rem;
--el-checkbox-height: auto;
.el-checkbox__label {
font-size: 1.1rem;
color: #666666;
font-family: KaiseiOpti-Regular;
>div {
>span {
font-family: KaiseiOpti-Bold;
text-decoration: underline;
cursor: pointer;
color: #232323;
}
}
}
}
}
>.other-login {
margin-top: 4rem;
}
}

View File

@@ -0,0 +1,224 @@
<template>
<el-dialog
class="login-dialog"
v-model="show"
header-class="login-dialog-header"
align-center
destroy-on-close
:show-close="false"
:close-on-press-escape="false"
:close-on-click-modal="false"
>
<div class="login-dialog-content">
<img class="bg" src="@/assets/images/login/bg.jpg" />
<img class="logo" src="@/assets/images/logo.png" />
<div class="close" @click="show = false"><svg-icon name="close" /></div>
<div class="content" v-if="curentTabInfo">
<div class="header">
<div class="title" v-show="curentTabInfo.title">
<div class="icon" @click="onBack"><svg-icon name="back" size="17" /></div>
<div class="label">{{ curentTabInfo.title }}</div>
</div>
<div class="nav" v-show="!curentTabInfo.title">
<div
class="item"
:class="{ active: currentTab === TabNames.sign_up }"
@click="currentTab = TabNames.sign_up"
>
SIGN UP
</div>
<div
class="item"
:class="{ active: currentTab === TabNames.login }"
@click="currentTab = TabNames.login"
>
LOG IN
</div>
</div>
</div>
<component
:is="curentTabInfo.component"
@retrieve-password="currentTab = TabNames.retrieve_password"
@login="onLogin"
@register="onRegister"
@submit-email-code="onSubmitEmailCode"
:name="data.name"
:email="data.email"
:password="data.password"
type="FORGOT_PWD"
/>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, ref, markRaw, watch, onBeforeUnmount } from 'vue'
import md5 from 'md5'
import login from './login.vue'
import register from './register.vue'
import emailVerify from './email-verify.vue'
import myEvent from '@/utils/myEvent'
const data = ref({
name: '',
email: '',
password: '',
type: ''
})
const show = ref(false)
const TabNames = {
login: 'login',
sign_up: 'sign_up',
email_verify: 'email_verify',
retrieve_password: 'retrieve_password'
}
const tabList = markRaw([
{
name: TabNames.login,
component: login
},
{
name: TabNames.sign_up,
component: register
},
{
name: TabNames.email_verify,
title: 'EMAIL VERIFICATION',
component: emailVerify
},
{
name: TabNames.retrieve_password,
title: 'RETRIEVE PASSWORD',
component: login
}
])
const currentTab = ref(TabNames.login)
const curentTabInfo = computed(() => tabList.find((v) => v.name === currentTab.value))
const lastTab = ref('')
watch(currentTab, (v, o) => (lastTab.value = o))
const onBack = () => {
if (lastTab.value) currentTab.value = lastTab.value
}
const open = (type?: string) => {
currentTab.value = TabNames[type] || TabNames.login
data.value.name = ''
data.value.email = ''
data.value.password = ''
show.value = true
}
myEvent.add('openLoginDialog', open)
onBeforeUnmount(() => {
myEvent.remove('openLoginDialog', open)
})
const onLogin = (res: any) => {
data.value = res
data.value.type = TabNames.login
currentTab.value = TabNames.email_verify
}
const onRegister = (res: any) => {
data.value = res
data.value.type = TabNames.sign_up
currentTab.value = TabNames.email_verify
}
const onSubmitEmailCode = (code: string) => {
// data.value.code = code
console.log(code)
show.value = false
}
</script>
<style lang="less">
.login-dialog-header {
display: none;
}
.el-dialog.login-dialog {
--el-dialog-border-radius: 0;
--el-dialog-padding-primary: 0;
}
.login-dialog-content {
width: 90rem;
height: 62rem;
position: relative;
> .bg {
width: 100%;
height: 100%;
display: block;
}
> * {
position: absolute;
}
> .logo {
width: auto;
height: 4rem;
top: 4rem;
left: 3rem;
}
> .close {
width: 3rem;
height: 3rem;
top: 2rem;
right: 2rem;
cursor: pointer;
}
> .content {
width: 34rem;
top: 5rem;
right: 6rem;
height: calc(100% - 10rem);
> .header {
--padding-bottom: 1.2rem;
padding-bottom: var(--padding-bottom);
border-bottom: 0.1rem solid #9f9f9f;
> div {
display: flex;
height: 3rem;
}
> .nav {
> .item {
user-select: none;
cursor: pointer;
flex: 1;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
font-size: 2rem;
font-family: KaiseiOpti-Regular;
color: #9f9f9f;
&.active {
font-family: KaiseiOpti-Bold;
color: #232323;
&::after {
content: '';
position: absolute;
bottom: calc(0rem - var(--padding-bottom) - 0.2rem);
left: 0;
width: 100%;
height: 0;
border-bottom: 0.4rem solid #232323;
}
}
}
}
> .title {
align-items: center;
justify-content: center;
> .icon {
width: 2.4rem;
height: 2.4rem;
cursor: pointer;
margin-right: 1rem;
}
> .label {
font-family: KaiseiOpti-Bold;
font-size: 2rem;
color: #232323;
}
}
}
}
}
</style>

75
src/views/login/login.vue Normal file
View File

@@ -0,0 +1,75 @@
<template>
<div class="login">
<el-form :model="formData" :rules="ruleForm" label-position="top" ref="formRef">
<el-form-item :label="$t('Login.email')" prop="email">
<el-input v-model="formData.email" :placeholder="$t('Login.enterEmail')" name="email" />
</el-form-item>
<el-form-item :label="$t('Login.password')" prop="password">
<el-input
v-model="formData.password"
:placeholder="$t('Login.enterPassword')"
type="password"
show-password
name="password"
/>
</el-form-item>
<div class="forgotPassword">
<span @click="onForgotPassword">{{ $t('Login.forgotPassword') }}</span>
</div>
<el-form-item>
<button type="submit" class="submit" custom="black" @click.prevent="onSubmit">
{{ $t('Login.login') }}
</button>
</el-form-item>
<el-form-item prop="privacy" class="privacy">
<el-checkbox v-model="formData.privacy">
<div v-html="$t('Login.agreeTermsPolicy')"></div>
</el-checkbox>
</el-form-item>
</el-form>
<other-login />
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { validateEmail, validatePassLength, validatePrivacy } from './tools'
import OtherLogin from './other-login.vue'
const emit = defineEmits(['retrieve-password', 'login'])
const props = defineProps({
name: { type: String, default: '' },
email: { type: String, default: '' },
password: { type: String, default: '' }
})
const ruleForm = reactive({
email: [{ validator: validateEmail, trigger: 'change' }],
password: [{ validator: validatePassLength, trigger: 'change' }],
privacy: [{ validator: validatePrivacy, trigger: 'change' }]
})
const formData = reactive({
email: props.email,
password: props.password,
privacy: false
})
const formRef = ref(null)
const onForgotPassword = () => {
emit('retrieve-password')
}
const onSubmit = () => {
formRef.value?.validate?.((valid) => {
if (valid) {
emit('login', {
email: formData.email,
password: formData.password
})
} else {
console.warn('error submit!')
}
})
}
</script>
<style lang="less" scoped>
@import './less/style.less';
</style>

View File

@@ -0,0 +1,83 @@
<template>
<div class="other-login">
<div class="title">{{ $t('Login.orContinueWith') }}</div>
<div class="btns">
<button class="submit" custom @click="onGoogle">
<img src="@/assets/images/login/google.png" />
{{ $t('Login.googleLogin') }}
</button>
<button class="submit" custom @click="onWechat">
<img src="@/assets/images/login/wechat.png" />
{{ $t('Login.wechatLogin') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
const onGoogle = () => {}
const onWechat = () => {}
</script>
<style lang="less" scoped>
.other-login {
width: 100%;
> .title {
width: 100%;
color: #232323;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
font-family: KaiseiOpti-Regular;
line-height: 2rem;
&::before,
&::after {
content: '';
flex: 1;
border-bottom: 0.05rem solid #232323;
}
&::before {
margin-right: 2rem;
}
&::after {
margin-left: 2rem;
}
}
> .btns {
margin-top: 2.4rem;
display: flex;
align-items: center;
justify-content: center;
gap: 2rem;
width: 100%;
overflow: hidden;
> button {
min-width: 0;
flex: 1;
height: 3.4rem;
display: flex;
align-items: center;
justify-content: space-around;
--button-bgcolor: transparent;
--button-color: #666666;
--button-font-size: 1rem;
border: 0.1rem solid #c4c4c4;
&:first-child {
margin-left: 0;
}
img {
width: auto;
height: 2rem;
// margin-right: 2.5rem;
}
}
}
}
</style>

View File

@@ -0,0 +1,60 @@
<template>
<div class="password-tip">
<div>
<el-icon>
<CloseBold v-if="validateLength(value)" />
<Select v-else />
</el-icon>
<span>{{ $t('Login.passwordLengthError', { min: 6, max: 20 }) }}</span>
</div>
<div>
<el-icon>
<CloseBold v-if="validateSpecial(value)" />
<Select v-else />
</el-icon>
<span>{{ $t('Login.passwordSpecial') }}</span>
</div>
<div>
<el-icon>
<CloseBold v-if="validateCase(value)" />
<Select v-else />
</el-icon>
<span>{{ $t('Login.passwordCase') }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { Select, CloseBold } from '@element-plus/icons-vue'
import { validateLength, validateSpecial, validateCase } from './tools'
const props = defineProps({
value: {
type: String,
default: ''
}
})
</script>
<style lang="less" scoped>
.password-tip {
background: #404040;
color: #fff;
font-size: 1.4rem;
padding: 2rem;
border-radius: 2rem;
line-height: normal;
> div {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
&:last-child {
margin-bottom: 0;
}
> .el-icon {
margin-right: 1rem;
}
}
}
</style>

View File

@@ -0,0 +1,85 @@
<template>
<div class="register">
<el-form :model="formData" :rules="ruleForm" label-position="top" ref="formRef">
<el-form-item :label="$t('Login.name')" prop="name">
<el-input name="name" v-model="formData.name" :placeholder="$t('Login.enterName')" />
</el-form-item>
<el-form-item :label="$t('Login.password')" prop="password">
<password-tip :value="formData.password" v-show="showPasswordTip" />
<el-input
name="password"
v-model="formData.password"
:placeholder="$t('Login.enterPassword')"
type="password"
show-password
@blur="showPasswordTip = false"
@focus="showPasswordTip = true"
/>
</el-form-item>
<div class="password-warning">
<span class="icon"><svg-icon name="warning" size="12" /></span>
<span class="label">You must satisfy ALL password conditions to register.</span>
</div>
<el-form-item :label="$t('Login.email')" prop="email">
<el-input name="email" v-model="formData.email" :placeholder="$t('Login.enterEmail')" />
</el-form-item>
<el-form-item>
<button type="submit" class="submit" custom="black" @click.prevent="onSubmit">
{{ $t('Login.register') }}
</button>
</el-form-item>
<el-form-item prop="privacy" class="privacy">
<el-checkbox v-model="formData.privacy">
<div v-html="$t('Login.agreeTermsPolicy')"></div>
</el-checkbox>
</el-form-item>
</el-form>
<other-login />
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { validateName, validateEmail, validatePass, validatePrivacy } from './tools'
import OtherLogin from './other-login.vue'
import PasswordTip from './password-tip.vue'
const emit = defineEmits(['register'])
const props = defineProps({
name: { type: String, default: '' },
email: { type: String, default: '' },
password: { type: String, default: '' }
})
const ruleForm = reactive({
name: [{ validator: validateName, trigger: 'change' }],
email: [{ validator: validateEmail, trigger: 'change' }],
password: [{ validator: validatePass, trigger: 'change' }],
privacy: [{ validator: validatePrivacy, trigger: 'change' }]
})
const showPasswordTip = ref(false)
const formData = reactive({
name: props.name,
email: props.email,
password: props.password,
privacy: false
})
const formRef = ref(null)
const onSubmit = () => {
formRef.value?.validate?.((valid) => {
if (valid) {
// console.log('submit!')
emit('register', {
name: formData.name,
email: formData.email,
password: formData.password
})
} else {
console.warn('error submit!')
}
})
}
</script>
<style lang="less" scoped>
@import './less/style.less';
</style>

View File

@@ -0,0 +1,120 @@
<template>
<div class="retrieve-password">
<div class="left">
<img class="bg" src="@/assets/images/login/left-bg.png" />
<img class="logo" src="@/assets/images/logo-1.png" />
</div>
<div class="right">
<div class="top">
<button class="back" @click="onBack">
<svg-icon name="arrow-left" size="37" />
</button>
</div>
<div class="box">
<img src="@/assets/images/login/elephant.png" />
<template v-if="!isVisible">
<div class="title">{{ $t('Login.retrievePassword') }}</div>
<el-form :model="formData" :rules="ruleForm" label-position="top" ref="formRef">
<el-form-item :label="$t('Login.email')" prop="email">
<el-input
v-model="formData.email"
:placeholder="$t('Login.enterEmail')"
name="email"
/>
</el-form-item>
<el-form-item :label="$t('Login.password')" prop="password">
<password-tip :value="formData.password" v-show="showPasswordTip" />
<el-input
v-model="formData.password"
:placeholder="$t('Login.enterPassword')"
type="password"
show-password
name="password"
@blur="showPasswordTip = false"
@focus="showPasswordTip = true"
/>
</el-form-item>
<br />
<br />
<el-form-item>
<el-button class="submit" type="primary" @click="onSubmit">{{
$t('submit')
}}</el-button>
</el-form-item>
</el-form>
</template>
<!-- <visible-code
v-show="isVisible"
type="FORGOT_PWD"
ref="visibleCodeRef"
:email="formData.email"
@submit="onVerifyCode"
/> -->
<other-login />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import md5 from 'md5'
import { ForgotPassword } from '@/api/user'
import { computed, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { validateEmail, validatePass } from './tools'
import OtherLogin from './other-login.vue'
import emailVerify from './email-verify.vue'
import PasswordTip from './password-tip.vue'
import { useUserInfoStore } from '@/stores'
const userInfoStore = useUserInfoStore()
const router = useRouter()
const ruleForm = reactive({
email: [{ validator: validateEmail, trigger: 'change' }],
password: [{ validator: validatePass, trigger: 'change' }]
})
const isVisible = ref(false)
const showPasswordTip = ref(false)
const formData = reactive({
email: '',
password: ''
})
const formRef = ref(null)
const onBack = () => {
if (isVisible.value) {
isVisible.value = false
} else {
router.back()
}
}
const visibleCodeRef = ref(null)
const onSubmit = () => {
formRef.value?.validate?.((valid) => {
if (valid) {
// console.log('submit!')
visibleCodeRef.value?.onSendCode().then(() => {
isVisible.value = true
})
} else {
console.warn('error submit!')
}
})
}
const onVerifyCode = (code: string) => {
// console.log(code)
ForgotPassword({
email: formData.email,
newPassword: md5(formData.password),
verificationCode: code
})
.then((res) => {
if (res) router.push({ name: 'login' })
})
.catch((error) => {
console.warn(error)
})
}
</script>
<style lang="less" scoped>
@import './less/style.less';
</style>

54
src/views/login/tools.js Normal file
View File

@@ -0,0 +1,54 @@
import i18n from '@/lang'
const t = i18n.global.t
export const validateName = (rule, value, callback) => {
var str = ""
if (!value) {
str = t('Login.pleaseInputName')
} else if (value.length < 2 || value.length > 20) {
str = t('Login.nameLengthError', { min: 2, max: 20 })
}
callback(str ? new Error(str) : undefined)
}
export const validateEmail = (rule, value, callback) => {
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(\.[a-zA-Z]{2,})?$/
var str = ''
if (!value) {
str = t('Login.pleaseInputEmail')
} else if (!emailRegex.test(value)) {
str = t('Login.emailFormatError')
}
callback(str ? new Error(str) : undefined)
}
// 检查长度
export const validateLength = (v, min = 6, max = 20) => (v.length < 6 || v.length > 20);
//检查特殊字符
export const validateSpecial = (v) => (!/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(v));
//检查大小写字母和数字
export const validateCase = (v) => (!/[a-z]/.test(v) || !/[A-Z]/.test(v) || !/\d/.test(v));
// 检查密码
export const validatePass = (rule, value, callback) => {
var str = ''
if (validateLength(value)) {
str = t('Login.passwordLengthError', { min: 6, max: 20 })
} else if (validateSpecial(value)) {
str = t('Login.passwordSpecial')
} else if (validateCase(value)) {
str = t('Login.passwordCase')
}
callback(str ? new Error(str) : undefined)
}
// 检查密码长度
export const validatePassLength = (rule, value, callback) => {
var str = ''
if (validateLength(value)) {
str = t('Login.passwordLengthError', { min: 6, max: 20 })
}
callback(str ? new Error(str) : undefined)
}
export const validatePrivacy = (rule, value, callback) => {
if (!value) {
callback(new Error(t('Login.pleaseTermsPolicy')))
} else {
callback()
}
}

View File

@@ -24,8 +24,44 @@
>
<svg-icon :name="activePath === v.path ? v.active_icon : v.icon" size="22" />
</div>
<div class="login">Login</div>
<div class="profile"></div>
<div class="login" @click="onLogin">Login</div>
<el-popover
ref="profilePopover"
placement="bottom-end"
trigger="click"
:show-arrow="false"
popper-style="width: 24rem; padding: 0; border-radius: 0; right: 2rem; top: 10rem;"
>
<template #reference><div class="profile"></div></template>
<template #default>
<div class="profile-content">
<div class="info">
<img src="@/assets/images/profile-content-bg.jpg" alt="" />
<div class="content">
<div class="profile"></div>
<div class="name">Hi, Alexandra_chen</div>
</div>
</div>
<div class="nav-item" @click="onMyWardrobe">
<div class="icon"><svg-icon name="my_wardrobe" size="18" /></div>
<div class="label">My Wardrobe</div>
</div>
<div class="nav-item" @click="onNotifications">
<div class="icon"><svg-icon name="notifications" size="14" /></div>
<div class="label">Notifications</div>
</div>
<div class="nav-item" @click="onSettings">
<div class="icon"><svg-icon name="settings" size="16" /></div>
<div class="label">Settings</div>
</div>
<div class="hr"></div>
<div class="nav-item logout" @click="onLogout">
<div class="icon"><svg-icon name="logout" size="20" /></div>
<div class="label">Log off</div>
</div>
</div>
</template>
</el-popover>
</div>
</div>
</template>
@@ -33,6 +69,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import myEvent from '@/utils/myEvent'
const router = useRouter()
const route = useRoute()
const activePath = computed(() => route.path)
@@ -70,6 +107,29 @@
if (path === activePath.value) return
router.push(path)
}
const onLogin = () => {
myEvent.emit('openLoginDialog')
}
const profilePopover = ref(null)
const hideProfilePopover = () => {
profilePopover.value?.hide()
}
const onMyWardrobe = () => {
hideProfilePopover()
console.log('my wardrobe')
}
const onNotifications = () => {
hideProfilePopover()
console.log('notifications')
}
const onSettings = () => {
hideProfilePopover()
console.log('settings')
}
const onLogout = () => {
hideProfilePopover()
console.log('logout')
}
</script>
<style lang="less">
@@ -145,4 +205,66 @@
}
}
}
.profile-content {
width: 100%;
> .info {
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 2rem;
> img {
width: 100%;
height: auto;
}
> .content {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 1rem;
> .profile {
width: 5rem;
height: 5rem;
border-radius: 50%;
background: #cfcfcf;
}
> .name {
font-size: 1.4rem;
color: #232323;
}
}
}
> .hr {
margin: 1.2rem 1rem;
border-top: 0.1rem solid #c4c4c4;
}
> .nav-item {
margin-left: 2rem;
margin-bottom: 1.2rem;
display: flex;
align-items: center;
height: 2rem;
user-select: none;
cursor: pointer;
> .icon {
width: 2rem;
height: 2rem;
margin-right: 1rem;
}
> .label {
font-family: KaiseiOpti-Regular;
font-size: 1.2rem;
color: #585858;
}
}
> .logout {
> .label {
font-family: KaiseiOpti-Medium;
font-size: 1.4rem;
color: #232323;
}
}
}
</style>