Files
Kg.SeaTime/Web/src/pages/auth/login.vue
2026-04-25 17:12:55 +08:00

572 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-login">
<div class="login-orb login-orb-a"></div>
<div class="login-orb login-orb-b"></div>
<div class="login-shell">
<section class="login-hero">
<p class="hero-kicker">SEA TIME</p>
<h1>欢迎回到航海时代</h1>
<p class="hero-copy">
这是一套移动端登录页示例保留 Vant 的按钮与标签组件表单主体改为 SSR 稳定写法
</p>
<div class="hero-pills">
<span>移动端优先</span>
<span>SSR 安全</span>
<span>模拟登录</span>
</div>
</section>
<section class="login-card">
<div class="card-top">
<div>
<p class="card-eyebrow">账号登录</p>
<h2>继续你的航程</h2>
</div>
<van-tag round plain type="primary">Sample</van-tag>
</div>
<div class="card-banner">
<span class="banner-dot"></span>
默认可直接使用演示账号也可以手动输入任意符合规则的内容
</div>
<div class="demo-account">
<div>
<strong>演示账号</strong>
<span>captain</span>
</div>
<div>
<strong>演示密码</strong>
<span>Voyage123</span>
</div>
</div>
<form class="login-form" novalidate @submit.prevent="handleSubmit">
<div class="field-group">
<label class="field-label" for="login-username">账号</label>
<div class="field-box" :class="{ 'is-error': !!errors.username }">
<span class="field-marker">A</span>
<input id="login-username" v-model.trim="form.username" class="field-input" type="text" inputmode="text"
autocomplete="username" placeholder="请输入账号" @input="clearFieldError('username')" />
</div>
<p v-if="errors.username" class="field-error">{{ errors.username }}</p>
</div>
<div class="field-group">
<label class="field-label" for="login-password">密码</label>
<div class="field-box" :class="{ 'is-error': !!errors.password }">
<span class="field-marker">P</span>
<input id="login-password" v-model.trim="form.password" class="field-input" type="password"
autocomplete="current-password" placeholder="请输入密码" @input="clearFieldError('password')" />
</div>
<p v-if="errors.password" class="field-error">{{ errors.password }}</p>
</div>
<div class="login-options">
<label class="check-row">
<input v-model="form.remember" class="check-input" type="checkbox" />
<span class="check-box"></span>
<span class="check-text">记住演示账号</span>
</label>
<button class="ghost-link" type="button" @click="fillDemoAccount">一键填充</button>
<van-button type="primary" to="/home" size="mini" :loading="false">路由跳转</van-button>
</div>
<div class="login-agreement">
<label class="check-row">
<input v-model="form.agreement" class="check-input" type="checkbox"
@change="clearFieldError('agreement')" />
<span class="check-box"></span>
<span class="check-text">我已阅读并同意演示使用说明</span>
</label>
<p v-if="errors.agreement" class="field-error agreement-error">{{ errors.agreement }}</p>
</div>
<div class="login-actions">
<van-button block round type="primary" native-type="submit" :loading="isSubmitting"
:disabled="submitDisabled">
模拟登录
</van-button>
<van-button block round plain type="primary" native-type="button" @click="clearForm">
清空输入
</van-button>
</div>
</form>
<div class="login-tips">
<div class="tip-item">
<span>01</span>
去掉 ClientOnly刷新时不再有表单占位替换
</div>
<div class="tip-item">
<span>02</span>
提交后延迟模拟请求并跳转到主页
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: layout.empty
})
type ErrorField = 'username' | 'password' | 'agreement'
const isSubmitting = ref(false)
const form = reactive({
username: '',
password: '',
remember: true,
agreement: true
})
const errors = reactive<Record<ErrorField, string>>({
username: '',
password: '',
agreement: ''
})
const submitDisabled = computed(() => {
return !form.username.trim() || !form.password.trim() || !form.agreement
})
const clearFieldError = (field: ErrorField) => {
errors[field] = ''
}
const validateForm = () => {
errors.username = ''
errors.password = ''
errors.agreement = ''
if (!form.username.trim()) {
errors.username = '请输入账号'
} else if (form.username.trim().length < 3) {
errors.username = '账号至少 3 位'
}
if (!form.password.trim()) {
errors.password = '请输入密码'
} else if (form.password.trim().length < 6) {
errors.password = '密码至少 6 位'
}
if (!form.agreement) {
errors.agreement = '请先勾选使用说明'
}
return !errors.username && !errors.password && !errors.agreement
}
const fillDemoAccount = () => {
form.username = 'captain'
form.password = 'Voyage123'
errors.username = ''
errors.password = ''
showToast('已填充演示账号')
}
const clearForm = () => {
form.username = ''
form.password = ''
form.agreement = true
errors.username = ''
errors.password = ''
errors.agreement = ''
showToast('已清空输入')
}
const handleSubmit = async () => {
if (!validateForm()) {
showFailToast(Object.values(errors).find(Boolean) || '请检查表单内容')
return
}
try {
isSubmitting.value = true
await new Promise((resolve) => setTimeout(resolve, 900))
showSuccessToast('登录成功')
await navigateTo('/home')
} finally {
isSubmitting.value = false
}
}
</script>
<style scoped>
.page-login {
position: relative;
overflow: hidden;
min-height: 100vh;
padding: 28px 16px 40px;
background:
linear-gradient(180deg, #0b3558 0%, #0f2943 46%, #f4f8fc 46%, #f4f8fc 100%);
}
.login-orb {
position: absolute;
border-radius: 999px;
filter: blur(4px);
pointer-events: none;
}
.login-orb-a {
top: -36px;
right: -22px;
width: 148px;
height: 148px;
background: radial-gradient(circle, rgba(103, 232, 249, 0.56), rgba(103, 232, 249, 0));
}
.login-orb-b {
top: 168px;
left: -52px;
width: 180px;
height: 180px;
background: radial-gradient(circle, rgba(96, 165, 250, 0.24), rgba(96, 165, 250, 0));
}
.login-shell {
position: relative;
z-index: 1;
max-width: 420px;
margin: 0 auto;
}
.login-hero {
padding: 8px 6px 24px;
color: #f8fbff;
}
.hero-kicker {
margin: 0 0 10px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.28em;
color: rgba(186, 230, 253, 0.92);
}
.login-hero h1 {
margin: 0;
font-size: 34px;
line-height: 1.08;
letter-spacing: 0.01em;
}
.hero-copy {
margin: 14px 0 0;
font-size: 14px;
line-height: 1.7;
color: rgba(226, 232, 240, 0.92);
}
.hero-pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 18px;
}
.hero-pills span {
padding: 6px 10px;
border: 1px solid rgba(186, 230, 253, 0.18);
border-radius: 999px;
font-size: 12px;
color: #dbeafe;
background: rgba(255, 255, 255, 0.1);
}
.login-card {
margin-top: 8px;
padding: 22px 16px 18px;
border-radius: 28px;
background: #ffffff;
box-shadow: 0 28px 60px rgba(15, 23, 42, 0.14);
}
.card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.card-eyebrow {
margin: 0 0 6px;
font-size: 12px;
color: #64748b;
}
.card-top h2 {
margin: 0;
font-size: 24px;
color: #0f172a;
}
.card-banner {
display: flex;
align-items: center;
gap: 10px;
margin-top: 16px;
padding: 12px 14px;
border-radius: 16px;
font-size: 13px;
line-height: 1.6;
color: #0f3d62;
background: linear-gradient(135deg, #e0f2fe, #eff6ff);
}
.banner-dot {
flex: 0 0 auto;
width: 10px;
height: 10px;
border-radius: 50%;
background: linear-gradient(135deg, #0ea5e9, #2563eb);
box-shadow: 0 0 0 4px rgba(14, 165, 233, 0.12);
}
.demo-account {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
.demo-account div {
padding: 12px 14px;
border-radius: 18px;
background: #f8fafc;
}
.demo-account strong,
.demo-account span {
display: block;
}
.demo-account strong {
margin-bottom: 6px;
font-size: 12px;
color: #64748b;
}
.demo-account span {
font-size: 15px;
font-weight: 700;
color: #0f172a;
}
.login-form {
margin-top: 18px;
}
.field-group+.field-group {
margin-top: 14px;
}
.field-label {
display: block;
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: #334155;
}
.field-box {
display: flex;
align-items: center;
gap: 12px;
min-height: 52px;
padding: 0 14px;
border: 1px solid #dbe4f0;
border-radius: 18px;
background: #f8fafc;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
}
.field-box:focus-within {
border-color: #3b82f6;
background: #ffffff;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
}
.field-box.is-error {
border-color: #f87171;
}
.field-marker {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 8px;
font-size: 12px;
font-weight: 700;
color: #2563eb;
background: #dbeafe;
}
.field-input {
flex: 1;
width: 100%;
min-width: 0;
border: 0;
background: transparent;
color: #0f172a;
}
.field-input::placeholder {
color: #94a3b8;
}
.field-input:-webkit-autofill,
.field-input:-webkit-autofill:hover,
.field-input:-webkit-autofill:focus {
-webkit-text-fill-color: #0f172a;
box-shadow: 0 0 0 1000px #f8fafc inset;
}
.field-error {
margin: 6px 0 0;
font-size: 12px;
color: #dc2626;
}
.login-options,
.login-agreement {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 2px 0;
}
.login-agreement {
display: block;
padding-top: 12px;
}
.agreement-error {
margin-left: 32px;
}
.check-row {
display: inline-flex;
align-items: center;
gap: 10px;
color: #475569;
}
.check-input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.check-box {
position: relative;
flex: 0 0 auto;
width: 18px;
height: 18px;
border: 1.5px solid #cbd5e1;
border-radius: 6px;
background: #ffffff;
transition: all 0.2s ease;
}
.check-box::after {
content: '';
position: absolute;
top: 1px;
left: 5px;
width: 4px;
height: 9px;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
opacity: 0;
transform: rotate(45deg);
}
.check-input:checked+.check-box {
border-color: #2563eb;
background: #2563eb;
}
.check-input:checked+.check-box::after {
opacity: 1;
}
.check-text {
font-size: 13px;
line-height: 1.5;
}
.ghost-link {
padding: 0;
border: 0;
color: #2563eb;
background: transparent;
appearance: none;
-webkit-appearance: none;
}
.login-actions {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 18px;
}
.login-tips {
display: grid;
gap: 10px;
margin-top: 18px;
}
.tip-item {
display: flex;
gap: 12px;
padding: 12px 14px;
border-radius: 16px;
font-size: 13px;
line-height: 1.6;
color: #334155;
background: #f8fafc;
}
.tip-item span {
flex: 0 0 auto;
width: 26px;
height: 26px;
border-radius: 10px;
font-size: 12px;
font-weight: 700;
line-height: 26px;
text-align: center;
color: #2563eb;
background: #dbeafe;
}
@media (min-width: 768px) {
.page-login {
display: flex;
align-items: center;
justify-content: center;
padding: 36px 20px;
}
.login-shell {
max-width: 460px;
}
}
</style>