admin_new/src/modules/auth/LoginView.vue

453 lines
14 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.

<script setup lang="ts">
/**
* F006 登录页
* W3.7:按 http://localhost:8770/theme-switcher.html?theme=tech-blue 设计稿 1:1 还原
* 设计稿关键数值:
* - 舞台 1324×600 圆角 16px
* - 左 65% (flex 1.857) / 右 35% (flex 1)
* - input 380×38 圆角 8px 背景 #F8FAFC 边框 #E2E8F0 padding-left 34px(给 icon)
* - 验证码 110×38 圆角 8px 背景 #DBEAFE 字 17px 600 蓝
* - 按钮 380×40 圆角 8px 字 14px 600 白 阴影 glow
* - 复选框 + 忘记密码 + divider + SSO 2 按钮
*/
import { reactive, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import qs from 'qs'
import axios from 'axios'
import { useUserStore } from '@/stores/user'
import { useThemeStore } from '@/stores/theme'
import { computed } from 'vue'
import logoUrl from '@/assets/logo-jubian.png'
import logoLightUrl from '@/assets/logo-jubian-light.png'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const themeStore = useThemeStore()
const logoSrc = computed(() => themeStore.current === 'dark' ? logoLightUrl : logoUrl)
const form = reactive({
username: 'admin',
password: 'admin',
captcha: '',
uuid: ''
})
const captchaUrl = ref('')
const loading = ref(false)
const themePalette = [
{ value: 'tech-blue', name: '科技蓝', color: '#2563EB' },
{ value: 'red', name: '中国红', color: '#DC2626' },
{ value: 'purple', name: '深空紫', color: '#5645D4' },
{ value: 'dark', name: '暗夜黑', color: '#0F172A' }
]
function refreshCaptcha() {
form.uuid = Math.random().toString(36).slice(2, 12)
captchaUrl.value = `/auth/captcha?uuid=${form.uuid}&_t=${Date.now()}`
}
function onCaptchaInput(e: Event) {
const t = e.target as HTMLInputElement
form.captcha = t.value
}
refreshCaptcha()
function pickTheme(value: string) {
themeStore.pick(value)
}
async function onLogin() {
if (!form.username || !form.password) {
ElMessage.warning('请输入账号和密码')
return
}
loading.value = true
try {
const res = await axios.post(
'/auth/oauth/token',
qs.stringify({ ...form, grant_type: 'password' }),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: 'Basic cmVucmVuaW86cmVucmVuaW8='
}
}
)
if (res.data?.code === 0 && res.data?.access_token) {
userStore.setAuth({
token: res.data.access_token,
username: form.username,
permissions: res.data.permissions || ['*:*:*']
})
ElMessage.success('登录成功')
const redirect = (route.query.redirect as string) || '/'
router.push(redirect)
} else {
ElMessage.error(res.data?.msg || '登录失败')
refreshCaptcha()
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.msg || e?.message || '登录失败')
refreshCaptcha()
} finally {
loading.value = false
}
}
</script>
<template>
<div class="rr-login">
<div class="rr-login-stage">
<!-- 左 65%: 品牌区 -->
<div class="rr-login-left">
<div class="rr-login-brand">
<img class="rr-login-logo" :src="logoSrc" alt="巨变" />
<div class="rr-login-product-name">产业互联网平台运营后台</div>
</div>
<div class="rr-login-display">
<h1 class="rr-login-headline">
The operating<br>system for <span class="rr-login-accent">industry.</span>
</h1>
<p class="rr-login-sub">
询价 · 订单 · 供应商 · 履约一体化协同平台,为 B2B 团队而生。
</p>
</div>
<div class="rr-login-features">
<div class="rr-login-feature">
<span class="rr-login-ck"><el-icon><Check /></el-icon></span>
ISO 27001 + SOC 2 Type II
</div>
<div class="rr-login-feature">
<span class="rr-login-ck"><el-icon><Check /></el-icon></span>
等保三级 · 99.99% SLA
</div>
<div class="rr-login-feature">
<span class="rr-login-ck"><el-icon><Check /></el-icon></span>
10 万+ 企业运营团队在用
</div>
</div>
</div>
<!-- 右 35%: 登录卡 -->
<div class="rr-login-right">
<div class="rr-login-card">
<div class="rr-login-card-title">登录工作台</div>
<div class="rr-login-card-sub">输入账号继续</div>
<form class="rr-login-fields" @submit.prevent="onLogin">
<div class="rr-field">
<label class="rr-field-label">账号</label>
<div class="rr-input-wrap">
<el-icon class="rr-input-icon"><User /></el-icon>
<input v-model="form.username" class="rr-input" placeholder="请输入账号" autocomplete="username" />
</div>
</div>
<div class="rr-field">
<label class="rr-field-label">密码</label>
<div class="rr-input-wrap">
<el-icon class="rr-input-icon"><Lock /></el-icon>
<input v-model="form.password" type="password" class="rr-input" placeholder="请输入密码" autocomplete="current-password" />
</div>
</div>
<div class="rr-field">
<label class="rr-field-label">验证码</label>
<div class="rr-captcha-row">
<div class="rr-input-wrap">
<el-icon class="rr-input-icon"><Picture /></el-icon>
<input v-model="form.captcha" class="rr-input" placeholder="请输入验证码" maxlength="20" @input="onCaptchaInput($event)" />
</div>
<div class="rr-captcha-box" @click="refreshCaptcha" title="点击刷新">
<img v-if="captchaUrl" :src="captchaUrl" class="rr-captcha-img" alt="验证码" />
<span v-else class="rr-captcha-loading">加载中</span>
</div>
</div>
</div>
<button type="submit" class="rr-btn-primary" :disabled="loading">
{{ loading ? '登录中…' : '登 录' }}
</button>
</form>
<div class="rr-login-theme-section">
<div class="rr-login-theme-label">选择主题</div>
<div class="rr-login-theme-grid">
<div
v-for="t in themePalette"
:key="t.value"
class="rr-login-theme-dot"
:class="{ active: themeStore.current === t.value }"
:style="{ background: t.color }"
:title="t.name"
@click="pickTheme(t.value)"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
/**
* 设计稿精确数值(从 design.html Playwright 读出来):
* 舞台 1324×600 圆角 16px / 左 65% 右 35% / input 380×38 圆角 8px / 按钮 380×40 圆角 8px
* 输入背景 #F8FAFC 边框 #E2E8F0 圆角 8px / 验证码背景 #DBEAFE / 按钮阴影 0 1px 2px rgba(37,99,235,0.15)
*/
.rr-login {
width: 100vw;
height: 100vh;
background: linear-gradient(145deg, var(--login-grad-1) 0%, var(--login-grad-2) 50%, var(--login-grad-3) 100%);
display: flex;
align-items: center;
justify-content: center;
margin: 0;
overflow: hidden;
padding: 24px;
}
/* 舞台(套设计稿 .login-stage) */
.rr-login-stage {
width: 1324px;
height: 600px;
max-width: 100%;
max-height: 100%;
border-radius: 16px;
overflow: hidden;
display: flex;
position: relative;
background: linear-gradient(145deg, var(--login-grad-1) 0%, var(--login-grad-2) 50%, var(--login-grad-3) 100%);
border: 1px solid var(--border);
box-shadow: 0 20px 50px -12px rgba(15, 23, 42, 0.1);
&::after {
content: '';
position: absolute;
inset: 0;
background-image:
linear-gradient(var(--brand) 1px, transparent 1px),
linear-gradient(90deg, var(--brand) 1px, transparent 1px);
background-size: 32px 32px;
opacity: 0.05;
pointer-events: none;
z-index: 0;
}
& > * { position: relative; z-index: 1; }
}
/* 左 65% 品牌区 */
.rr-login-left {
flex: 1.857;
padding: 48px;
display: flex;
flex-direction: column;
justify-content: space-between;
position: relative;
background: linear-gradient(135deg, var(--login-grad-1) 0%, var(--login-grad-2) 50%, var(--login-grad-3) 100%);
color: var(--ink);
overflow: hidden;
&::before {
content: '';
position: absolute;
inset: 0;
background-image:
radial-gradient(ellipse 800px 500px at 25% 30%, var(--login-glow-1) 0%, transparent 50%),
radial-gradient(ellipse 700px 400px at 75% 70%, var(--login-glow-2) 0%, transparent 50%);
pointer-events: none;
}
&::after {
content: '';
position: absolute;
inset: 0;
background-image:
linear-gradient(var(--brand) 1px, transparent 1px),
linear-gradient(90deg, var(--brand) 1px, transparent 1px);
background-size: 32px 32px;
opacity: 0.04;
mask-image: radial-gradient(ellipse at center, black 20%, transparent 70%);
-webkit-mask-image: radial-gradient(ellipse at center, black 20%, transparent 70%);
pointer-events: none;
}
& > * { position: relative; z-index: 1; }
}
.rr-login-brand { display: flex; align-items: center; gap: 10px; }
.rr-login-logo {
width: 36px; height: 36px;
border-radius: 9px;
object-fit: contain;
padding: 2px;
box-shadow: 0 6px 16px var(--brand-glow);
background: var(--surface);
}
.rr-login-product-name { font-size: 16px; font-weight: 600; color: var(--ink); }
.rr-login-display { max-width: 460px; }
.rr-login-headline {
font-size: 42px; font-weight: 700; line-height: 1.1;
letter-spacing: -0.03em; margin-bottom: 16px; color: var(--ink);
}
.rr-login-accent { color: var(--brand); }
.rr-login-sub { color: var(--ink-2); font-size: 15px; line-height: 1.6; }
.rr-login-features { display: flex; flex-direction: column; gap: 10px; }
.rr-login-feature {
display: flex; align-items: center; gap: 10px;
font-size: 13.5px; color: var(--ink-2);
}
.rr-login-ck {
width: 20px; height: 20px;
background: var(--brand-soft);
color: var(--brand);
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 12px;
}
/* 右 35% 登录卡(半透明白让 stage 渐变+网格透过来) */
.rr-login-right {
flex: 1;
background: rgba(255, 255, 255, 0.85);
// W5.x.x 暗主题适配:登录卡深色半透明,让 var(--ink) 系文字清晰可见
[data-theme="dark"] & {
background: rgba(30, 41, 59, 0.85);
}
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
padding: 48px 40px;
display: flex; align-items: center; justify-content: center;
border-left: 1px solid var(--border);
}
.rr-login-card { width: 100%; max-width: 380px; }
/* 标题(套设计稿 .login-card-title 20px 700 + .login-card-sub 13px 400 灰) */
.rr-login-card-title {
font-size: 20px; font-weight: 700; color: var(--ink);
letter-spacing: -0.01em;
margin-bottom: 4px;
}
.rr-login-card-sub {
font-size: 13px; font-weight: 400; color: var(--ink-3);
margin-bottom: 24px;
}
/* 字段(套设计稿 .field + label 12px 500 + .input-wrap) */
.rr-login-fields { display: flex; flex-direction: column; gap: 14px; }
.rr-field { display: flex; flex-direction: column; gap: 6px; }
.rr-field-label {
font-size: 12px; font-weight: 500; color: var(--ink-2);
}
.rr-input-wrap {
position: relative;
width: 100%; height: 38px;
}
.rr-input-icon {
position: absolute;
left: 12px; top: 50%;
transform: translateY(-50%);
font-size: 14px;
color: var(--ink-3);
pointer-events: none;
}
.rr-input {
width: 100%; height: 38px;
padding: 0 12px 0 34px;
background: var(--bg-soft);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 14px;
color: var(--ink);
outline: none;
transition: all 0.15s;
&::placeholder { color: var(--ink-3); }
&:focus {
border-color: var(--brand);
background: var(--surface);
box-shadow: 0 0 0 3px var(--brand-soft);
}
}
/* 验证码行 */
.rr-captcha-row {
display: flex; gap: 8px; width: 100%;
.rr-input-wrap { flex: 1; }
}
.rr-captcha-box {
width: 130px; height: 40px;
background: var(--brand-soft);
border: 1px solid var(--border);
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
user-select: none;
overflow: hidden;
flex-shrink: 0;
}
.rr-captcha-img {
width: 100%; height: 100%;
object-fit: contain;
display: block;
}
.rr-captcha-loading {
font-size: 12px; font-weight: 600;
color: var(--brand);
}
/* 主按钮(套设计稿 .btn-primary 380×40 圆角 8px 阴影) */
.rr-btn-primary {
width: 100%; height: 40px;
background: var(--brand);
color: white;
border: 0;
border-radius: 8px;
font-size: 14px; font-weight: 600;
letter-spacing: 0.04em;
cursor: pointer;
box-shadow: 0 1px 2px rgba(37, 99, 235, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.15);
transition: all 0.15s;
&:hover { background: var(--brand-hover); }
&:active { transform: translateY(1px); }
&:disabled { opacity: 0.7; cursor: not-allowed; }
}
/* 主题色板 */
.rr-login-theme-section {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--border);
}
.rr-login-theme-label {
font-size: 11px; color: var(--ink-3);
text-transform: uppercase; letter-spacing: 0.08em;
margin-bottom: 10px;
text-align: center;
}
.rr-login-theme-grid { display: flex; gap: 10px; justify-content: center; }
.rr-login-theme-dot {
width: 24px; height: 24px;
border-radius: 50%;
cursor: pointer;
border: 2px solid transparent;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1);
transition: all 0.15s;
&:hover { transform: scale(1.1); }
&.active {
border-color: var(--ink);
box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px currentColor;
}
}
@media (max-width: 992px) {
.rr-login-left { display: none; }
.rr-login-stage { width: 460px; height: auto; }
.rr-login-right { width: 100%; }
}
</style>