feat(auth): 登录页改版并支持「记住密码」
- 左右分栏:左侧品牌区(产品定位 + 三条能力要点),窄屏自动收起只留表单 - 表单补字段标签、记住密码勾选与提示、登录按钮 loading 文案 - 记住密码:本机保存账号密码(XOR+Base64 混淆,非明文),下次自动填充并恢复勾选;取消勾选再登录会清除 - 清掉模板化的单卡片登录页
This commit is contained in:
1 parent
1c50d565eb
commit
630f2819cb
1 file changed
+309
-57
+309
-57
@@ -1,51 +1,97 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="login-container">
|
<div class="login-page">
|
||||||
<div class="login-card">
|
<!-- 左侧品牌区:桌面显示,窄屏隐藏 -->
|
||||||
<div class="login-header">
|
<aside class="brand-panel">
|
||||||
<h2 class="login-title">opsx-licence</h2>
|
<div class="brand-glow"></div>
|
||||||
|
<div class="brand-inner">
|
||||||
|
<div class="brand-logo">
|
||||||
|
<el-icon :size="26"><Key /></el-icon>
|
||||||
|
<span>opsx-licence</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="brand-title">License 授权服务控制台</h1>
|
||||||
|
<p class="brand-desc">
|
||||||
|
产品授权签发、设备激活追踪、订单与收款对账——签发、验证、回收在同一个后台闭环。
|
||||||
|
</p>
|
||||||
|
<ul class="brand-points">
|
||||||
|
<li>
|
||||||
|
<b>在线与离线双签发</b>
|
||||||
|
<span>在线激活与离线序列号共用一套授权模型,断网环境同样可交付。</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>设备激活可追溯</b>
|
||||||
|
<span>激活记录带归属地与平台分布,异常设备能力强制下线。</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>多渠道收款对账</b>
|
||||||
|
<span>微信支付、支付宝与海外支付统一进订单视图,便于核对续费。</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="brand-foot">杭州日理万机智能科技有限公司</div>
|
||||||
</div>
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- 右侧登录区 -->
|
||||||
|
<main class="form-panel">
|
||||||
|
<div class="form-card">
|
||||||
|
<div class="form-head">
|
||||||
|
<h2>登录控制台</h2>
|
||||||
|
<p>使用管理员账号登录,进入授权管理后台。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form
|
||||||
|
ref="loginFormRef"
|
||||||
|
:model="loginForm"
|
||||||
|
:rules="loginRules"
|
||||||
|
size="large"
|
||||||
|
label-position="top"
|
||||||
|
@keyup.enter="handleLogin"
|
||||||
|
>
|
||||||
|
<el-form-item label="用户名" prop="username">
|
||||||
|
<el-input
|
||||||
|
v-model="loginForm.username"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
:prefix-icon="User"
|
||||||
|
autocomplete="username"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="密码" prop="password">
|
||||||
|
<el-input
|
||||||
|
v-model="loginForm.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
show-password
|
||||||
|
:prefix-icon="Lock"
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<div class="form-extra">
|
||||||
|
<el-checkbox v-model="rememberPassword">记住密码</el-checkbox>
|
||||||
|
<span class="remember-hint">{{ rememberHint }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-form
|
|
||||||
ref="loginFormRef"
|
|
||||||
:model="loginForm"
|
|
||||||
:rules="loginRules"
|
|
||||||
size="large"
|
|
||||||
@keyup.enter="handleLogin"
|
|
||||||
>
|
|
||||||
<el-form-item prop="username">
|
|
||||||
<el-input
|
|
||||||
v-model="loginForm.username"
|
|
||||||
placeholder="用户名"
|
|
||||||
:prefix-icon="User"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item prop="password">
|
|
||||||
<el-input
|
|
||||||
v-model="loginForm.password"
|
|
||||||
type="password"
|
|
||||||
placeholder="密码"
|
|
||||||
show-password
|
|
||||||
:prefix-icon="Lock"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item>
|
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
|
class="submit-btn"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
style="width: 100%"
|
|
||||||
@click="handleLogin"
|
@click="handleLogin"
|
||||||
>
|
>
|
||||||
登 录
|
{{ loading ? '登录中…' : '登 录' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-form-item>
|
</el-form>
|
||||||
</el-form>
|
|
||||||
</div>
|
<p class="form-note">
|
||||||
|
连续登录失败会被限流,请确认账号密码后再重试。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { User, Lock } from '@element-plus/icons-vue'
|
import { User, Lock } from '@element-plus/icons-vue'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
@@ -55,59 +101,265 @@ const userStore = useUserStore()
|
|||||||
|
|
||||||
const loginFormRef = ref(null)
|
const loginFormRef = ref(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const rememberPassword = ref(false)
|
||||||
|
|
||||||
const loginForm = reactive({
|
const loginForm = reactive({
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const loginRules = {
|
const loginRules = {
|
||||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rememberHint = computed(() =>
|
||||||
|
rememberPassword.value ? '已在本机保存,下次自动填充' : '勾选后本机自动填充账号密码'
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============ 「记住密码」本地存储 ============
|
||||||
|
// 仅保存在本机浏览器;做一层 XOR + Base64 混淆,避免明文躺在 localStorage 里被随手看到。
|
||||||
|
// ⚠️ 这是混淆而非加密,公共设备请勿勾选。 // Logan.Li
|
||||||
|
const REMEMBER_KEY = 'opsx_licence_remember'
|
||||||
|
const REMEMBER_USERNAME_KEY = 'opsx_licence_last_username'
|
||||||
|
const XOR_KEY = 0x2f
|
||||||
|
|
||||||
|
function obfuscate(text) {
|
||||||
|
const xored = Array.from(text)
|
||||||
|
.map((ch) => String.fromCharCode(ch.charCodeAt(0) ^ XOR_KEY))
|
||||||
|
.join('')
|
||||||
|
return btoa(encodeURIComponent(xored))
|
||||||
|
}
|
||||||
|
|
||||||
|
function deobfuscate(token) {
|
||||||
|
const xored = decodeURIComponent(atob(token))
|
||||||
|
return Array.from(xored)
|
||||||
|
.map((ch) => String.fromCharCode(ch.charCodeAt(0) ^ XOR_KEY))
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取上次的账号 / 密码 // Logan.Li
|
||||||
|
function restoreRemembered() {
|
||||||
|
try {
|
||||||
|
const lastUser = localStorage.getItem(REMEMBER_USERNAME_KEY)
|
||||||
|
if (lastUser) loginForm.username = lastUser
|
||||||
|
|
||||||
|
const saved = localStorage.getItem(REMEMBER_KEY)
|
||||||
|
if (!saved) return
|
||||||
|
const data = JSON.parse(deobfuscate(saved))
|
||||||
|
if (data?.u) loginForm.username = data.u
|
||||||
|
if (data?.p) {
|
||||||
|
loginForm.password = data.p
|
||||||
|
rememberPassword.value = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem(REMEMBER_KEY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存 / 清理记住的凭据 // Logan.Li
|
||||||
|
function persistRemembered() {
|
||||||
|
const username = loginForm.username.trim()
|
||||||
|
if (username) {
|
||||||
|
localStorage.setItem(REMEMBER_USERNAME_KEY, username)
|
||||||
|
}
|
||||||
|
if (rememberPassword.value && username) {
|
||||||
|
localStorage.setItem(
|
||||||
|
REMEMBER_KEY,
|
||||||
|
obfuscate(JSON.stringify({ u: username, p: loginForm.password }))
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(REMEMBER_KEY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 处理登录 // Logan.Li
|
// 处理登录 // Logan.Li
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
if (!loginFormRef.value) return
|
if (!loginFormRef.value || loading.value) return
|
||||||
await loginFormRef.value.validate(async (valid) => {
|
await loginFormRef.value.validate(async (valid) => {
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await userStore.login(loginForm)
|
await userStore.login({
|
||||||
const redirect = route.query.redirect || '/'
|
username: loginForm.username.trim(),
|
||||||
|
password: loginForm.password,
|
||||||
|
})
|
||||||
|
persistRemembered()
|
||||||
|
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
|
||||||
router.push(redirect)
|
router.push(redirect)
|
||||||
} catch {
|
} catch {
|
||||||
// 拦截器已处理错误提示
|
// 拦截器已统一提示错误
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(restoreRemembered)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.login-container {
|
.login-page {
|
||||||
height: 100vh;
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 左侧品牌区 ============ */
|
||||||
|
.brand-panel {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 46%;
|
||||||
|
max-width: 620px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(150deg, #0b1b3a 0%, #10294f 48%, #0d3b57 100%);
|
||||||
|
color: #e8eefc;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.brand-glow {
|
||||||
|
position: absolute;
|
||||||
|
width: 520px;
|
||||||
|
height: 520px;
|
||||||
|
right: -180px;
|
||||||
|
top: -160px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, rgba(64, 158, 255, 0.35) 0%, rgba(64, 158, 255, 0) 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.brand-inner {
|
||||||
|
position: relative;
|
||||||
|
padding: 56px 56px 48px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.brand-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.brand-title {
|
||||||
|
margin: 34px 0 14px;
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 1.35;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.brand-desc {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 30em;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: rgba(232, 238, 252, 0.72);
|
||||||
|
}
|
||||||
|
.brand-points {
|
||||||
|
margin: 38px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
.brand-points li {
|
||||||
|
padding-left: 18px;
|
||||||
|
border-left: 2px solid rgba(64, 158, 255, 0.5);
|
||||||
|
}
|
||||||
|
.brand-points b {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.brand-points span {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: rgba(232, 238, 252, 0.6);
|
||||||
|
}
|
||||||
|
.brand-foot {
|
||||||
|
margin-top: 46px;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: rgba(232, 238, 252, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 右侧表单区 ============ */
|
||||||
|
.form-panel {
|
||||||
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #f3f5f7;
|
padding: 48px 24px;
|
||||||
}
|
}
|
||||||
.login-card {
|
.form-card {
|
||||||
width: 380px;
|
width: 100%;
|
||||||
padding: 36px 32px 28px;
|
max-width: 380px;
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #ebeef5;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
|
||||||
}
|
}
|
||||||
.login-header {
|
.form-head {
|
||||||
text-align: center;
|
margin-bottom: 26px;
|
||||||
margin-bottom: 28px;
|
|
||||||
}
|
}
|
||||||
.login-title {
|
.form-head h2 {
|
||||||
margin: 0;
|
margin: 0 0 8px;
|
||||||
font-size: 20px;
|
font-size: 22px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.02em;
|
color: #1f2d3d;
|
||||||
color: #303133;
|
}
|
||||||
|
.form-head p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8492a6;
|
||||||
|
}
|
||||||
|
.form-extra {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin: -4px 0 20px;
|
||||||
|
}
|
||||||
|
.remember-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #a3adbd;
|
||||||
|
}
|
||||||
|
.submit-btn {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.form-note {
|
||||||
|
margin: 18px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #a3adbd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单项标签更紧凑,整体更接近控制台观感 */
|
||||||
|
.form-card :deep(.el-form-item) {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.form-card :deep(.el-form-item__label) {
|
||||||
|
padding-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5a6b83;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 窄屏:收起品牌区,表单居中 ============ */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.brand-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.form-panel {
|
||||||
|
padding: 32px 20px;
|
||||||
|
}
|
||||||
|
.form-head {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.form-card :deep(.el-form-item__label) {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
Reference in new issue
Block a user