Files
SmartUp/frontend/src/views/Login.vue
T
SmartUp Developer ad16618406 fix: address multiple code audit findings
- CORS: replace wildcard with explicit origin list from CORS_ORIGINS env
- Auth: enforce strong defaults, JWT blacklist (RevokedToken model), login rate limiting
- Auth: validate password length before bcrypt (72-byte limit)
- Scheduler: single-threaded worker to mitigate SQLite write contention
- Scheduler: graceful shutdown (wait=True)
- Snapshots: add prune_snapshots() with configurable retention count
- Storage: isolate localStorage keys via VITE_APP_KEY prefix
- Config: add cors_origins, login_rate_limit, snapshot_retention_count settings
2026-05-17 10:52:18 +08:00

420 lines
9.0 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="login-page">
<div class="login-atmosphere" aria-hidden="true">
<div class="login-grid" />
<div class="login-glow login-glow-primary" />
</div>
<div class="login-shell">
<section class="login-intro surface-card">
<div class="intro-hero">
<div class="intro-mark-wrap">
<img src="/favicon.svg" alt="SmartUp" class="intro-mark" />
</div>
<h1 class="intro-title brand-type">SmartUp</h1>
<p class="intro-tagline">API 上游监控与站点同步管理控制台</p>
</div>
<div class="intro-features">
<span class="feature-pill">
<span class="pill-dot pill-dot--green" />上游健康检测
</span>
<span class="feature-pill">
<span class="pill-dot pill-dot--blue" />站点倍率同步
</span>
<span class="feature-pill">
<span class="pill-dot pill-dot--amber" />Webhook 通知
</span>
</div>
</section>
<section class="login-panel surface-card">
<div class="login-panel-body">
<div class="login-panel-head">
<h2 class="login-panel-title brand-type">登录控制台</h2>
<p class="login-panel-desc">输入管理员邮箱与密码进入 SmartUp 控制台</p>
</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="login-form"
@submit.prevent="handleLogin"
>
<el-form-item label="邮箱" prop="email">
<el-input
v-model="form.email"
placeholder="admin@example.com"
size="large"
:prefix-icon="Message"
autocomplete="email"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="输入当前管理员密码"
size="large"
:prefix-icon="Lock"
show-password
autocomplete="current-password"
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-button type="primary" size="large" class="login-submit" :loading="loading" @click="handleLogin">
进入控制台
<el-icon class="login-submit-icon"><Right /></el-icon>
</el-button>
<p v-if="errorMsg" class="login-error">{{ errorMsg }}</p>
</el-form>
</div>
<div class="panel-footer">
<span class="footer-secure">
<el-icon :size="13"><Lock /></el-icon>
安全加密连接
</span>
<span class="footer-version">SmartUp v1.0</span>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { Lock, Message, Right } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { authApi } from '@/api'
import type { FormInstance } from 'element-plus'
const router = useRouter()
const auth = useAuthStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const errorMsg = ref('')
const form = ref({ email: '', password: '' })
const rules = {
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}
async function handleLogin() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
errorMsg.value = ''
try {
const res = await authApi.login(form.value.email, form.value.password)
auth.setToken(res.data.access_token, form.value.email)
router.push('/upstreams')
} catch (e: any) {
errorMsg.value = e.response?.data?.detail || '登录失败,请检查账号密码'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page {
position: relative;
min-height: 100vh;
overflow: hidden;
display: grid;
place-items: center;
padding: 1rem;
}
.login-atmosphere,
.login-grid,
.login-glow {
position: absolute;
inset: 0;
pointer-events: none;
}
.login-grid {
background-image:
linear-gradient(rgba(255, 244, 232, 0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 244, 232, 0.025) 1px, transparent 1px);
background-size: 2.5rem 2.5rem;
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.45), transparent 85%);
}
.login-glow-primary {
inset: -10% auto auto -8%;
width: 22rem;
height: 22rem;
border-radius: 50%;
background: rgba(217, 139, 66, 0.14);
filter: blur(5.5rem);
}
.login-shell {
position: relative;
z-index: 1;
width: min(100%, 74rem);
display: grid;
gap: 1rem;
}
.login-intro,
.login-panel {
display: grid;
gap: 1.15rem;
padding: 1.2rem;
}
.login-panel {
order: -1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.login-panel-body {
display: grid;
gap: 1.5rem;
flex: 1;
align-content: center;
}
/* ── Left intro hero ── */
.login-intro {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rem;
text-align: center;
}
.intro-hero {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.intro-mark-wrap {
width: 4.5rem;
height: 4.5rem;
display: grid;
place-items: center;
border-radius: 1.25rem;
background: linear-gradient(135deg, rgba(217, 139, 66, 0.22), rgba(217, 139, 66, 0.06));
box-shadow:
inset 0 0 0 1px rgba(239, 175, 99, 0.16),
0 8px 32px rgba(217, 139, 66, 0.08);
}
.intro-mark {
width: 2.4rem;
height: 2.4rem;
}
.intro-title {
font-size: clamp(2.8rem, 2.4rem + 2vw, 4.2rem);
line-height: 0.9;
font-weight: 800;
}
.intro-tagline {
color: var(--text-muted);
font-size: 1.05rem;
line-height: 1.6;
max-width: 22ch;
text-wrap: balance;
}
/* ── Feature pills ── */
.intro-features {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.6rem;
}
.feature-pill {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.45rem 1rem 0.45rem 0.75rem;
border-radius: 2rem;
border: 1px solid rgba(218, 183, 142, 0.12);
background: rgba(255, 244, 232, 0.03);
color: var(--text-secondary, rgba(255, 244, 232, 0.72));
font-size: 0.84rem;
font-weight: 500;
letter-spacing: 0.02em;
transition: border-color 0.2s, background 0.2s;
}
.feature-pill:hover {
border-color: rgba(218, 183, 142, 0.22);
background: rgba(255, 244, 232, 0.06);
}
.pill-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
flex-shrink: 0;
}
.pill-dot--green {
background: #34d399;
box-shadow: 0 0 6px rgba(52, 211, 153, 0.4);
}
.pill-dot--blue {
background: #60a5fa;
box-shadow: 0 0 6px rgba(96, 165, 250, 0.4);
}
.pill-dot--amber {
background: #fbbf24;
box-shadow: 0 0 6px rgba(251, 191, 36, 0.4);
}
/* ── Right login panel ── */
.panel-summary-item span {
color: var(--text-soft);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.login-panel-desc,
.panel-footnote {
color: var(--text-muted);
font-size: 0.95rem;
line-height: 1.75;
text-wrap: pretty;
}
.panel-summary-item strong {
color: var(--text-primary);
font-size: 0.98rem;
}
.login-panel-head {
display: grid;
gap: 0.4rem;
}
.login-panel-title {
font-size: clamp(2rem, 1.7rem + 1vw, 2.8rem);
line-height: 0.95;
font-weight: 800;
}
.panel-summary {
display: grid;
gap: 0.7rem;
padding: 0.95rem 1rem;
border-radius: 1rem;
border: 1px solid rgba(218, 183, 142, 0.1);
background: rgba(255, 244, 232, 0.02);
}
.panel-summary-item {
display: grid;
gap: 0.15rem;
}
.login-form {
display: grid;
gap: 0.6rem;
}
/* ── Panel footer ── */
.panel-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 1rem;
border-top: 1px solid rgba(218, 183, 142, 0.08);
margin-top: 1rem;
}
.footer-secure {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: var(--text-muted);
font-size: 0.78rem;
opacity: 0.7;
}
.footer-version {
color: var(--text-muted);
font-size: 0.75rem;
opacity: 0.5;
letter-spacing: 0.04em;
}
.login-submit {
width: 100%;
margin-top: 0.25rem;
}
.login-submit-icon {
margin-left: 0.3rem;
}
.login-error {
margin-top: 0.25rem;
color: var(--color-danger);
font-size: 0.86rem;
line-height: 1.6;
}
/* ── Responsive ── */
@media (min-width: 768px) {
.login-page {
padding: 1.25rem;
}
.login-intro,
.login-panel {
padding: 1.45rem;
}
}
@media (min-width: 1024px) {
.login-page {
padding: 1.35rem;
}
.login-shell {
grid-template-columns: minmax(0, 1.2fr) minmax(22rem, 24rem);
align-items: stretch;
}
.login-panel {
order: 0;
min-height: 100%;
padding: 1.5rem;
}
.login-intro {
padding: 2rem 1.5rem;
}
}
</style>