Template
UI / layout - rebuild left mode: full-width blue header (60px) + white sidebar below + card-style tags view - introduce design tokens in variables.css (brand #347df9, sidebar 240px, menu item 49px) and theme overrides for dark / dark-blue themes - restyle navigation bar (logo + platform name / top nav / tools + user), logo, sidebar (selected state: light blue bg + blue bar + bold), tags view, app main - global styles: .app-container white card, .head-title, .btn-blue/.btn-white, el-table / el-dialog look, brand color injected into --el-color-primary - add favorites store (localStorage) wired to sidebar star and header top nav - disable template watermark / footer / promo notifications by default Login (serverschool) - port school-iot-admin login page (background art, right side card, keep-login) - OAuth2 password mode: GET /oauth/token (raw axios) + GET /uc/account/current - port phone lookup (/wx/getAccoutList) and forget-password flow (/sms/sendCode + /sms/verifyCode) as components/login/Forget.vue - user store exposes accountInfo / accountType / teamId / roleId and strips password Infra - move all devServer proxies and env vars from school-iot-admin/vue.config.js (default targets point to production) - fix empty sidebar: guard short-circuit skipped setRoutes when login page already fetched user info
451 lines
12 KiB
Vue
451 lines
12 KiB
Vue
<script lang="ts" setup>
|
||
import type { FormInstance, FormRules } from "element-plus"
|
||
import type { AccountItem, LoginRequestData } from "./apis/type"
|
||
import { LOGIN_TITLE } from "@@/constants/platform"
|
||
import { getKeepLogin, removeKeepLogin, setKeepLogin } from "@@/utils/local-storage"
|
||
import { CircleCloseFilled, Iphone, Lock } from "@element-plus/icons-vue"
|
||
import { ACCOUNT_TYPE_FORBIDDEN, useUserStore } from "@/pinia/stores/user"
|
||
import { getAccountListApi, loginApi } from "./apis"
|
||
import Forget from "./components/Forget.vue"
|
||
|
||
const route = useRoute()
|
||
|
||
const router = useRouter()
|
||
|
||
const userStore = useUserStore()
|
||
|
||
/** 登录表单元素的引用 */
|
||
const loginFormRef = useTemplateRef<FormInstance>("loginFormRef")
|
||
|
||
/** 登录按钮 Loading */
|
||
const loading = ref(false)
|
||
|
||
/** 错误提示信息 */
|
||
const errorText = ref<string | null>(null)
|
||
|
||
/** 保持登录 */
|
||
const keepLoginIn = ref(false)
|
||
|
||
/** 是否显示忘记密码 */
|
||
const showForgetPassword = ref(false)
|
||
|
||
/** 手机号查出的账号列表 */
|
||
const accountList = ref<AccountItem[]>([])
|
||
|
||
/** 上一次查询过的手机号(避免重复请求) */
|
||
const oldPhone = ref("")
|
||
|
||
/** 登录表单数据 */
|
||
const loginFormData: LoginRequestData = reactive({
|
||
username: "",
|
||
password: ""
|
||
})
|
||
|
||
/** 登录表单校验规则 */
|
||
const loginFormRules: FormRules = {
|
||
username: [{ required: true, message: "请输入用户名", trigger: "blur" }],
|
||
password: [{ required: true, message: "请输入密码", trigger: "blur" }]
|
||
}
|
||
|
||
/** 读取"保持登录"缓存的账号密码 */
|
||
function readKeepLogin() {
|
||
const data = getKeepLogin()
|
||
if (!data) return
|
||
loginFormData.username = data.username || ""
|
||
loginFormData.password = data.password || ""
|
||
keepLoginIn.value = !!(loginFormData.username && loginFormData.password)
|
||
}
|
||
|
||
/** 勾选了保持登录就缓存账号密码(对齐 school-iot-admin 的行为),否则清除缓存 */
|
||
function saveKeepLogin() {
|
||
if (keepLoginIn.value) {
|
||
setKeepLogin({ username: loginFormData.username, password: loginFormData.password })
|
||
} else {
|
||
removeKeepLogin()
|
||
}
|
||
}
|
||
|
||
/** 用户名筛选:输入 11 位手机号时查询对应账号(对齐 school-iot-admin 的 filterOption) */
|
||
function filterOption(val: string) {
|
||
loginFormData.username = val
|
||
if (val.length === 11) {
|
||
const phone = val.slice(0, 11)
|
||
if (phone !== oldPhone.value) {
|
||
oldPhone.value = phone
|
||
getAccountList(phone)
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 手机号查询账号列表 */
|
||
async function getAccountList(phone: string) {
|
||
try {
|
||
const res = await getAccountListApi(phone)
|
||
accountList.value = res.success ? (res.data ?? []) : []
|
||
if (accountList.value.length > 0) {
|
||
loginFormData.username = accountList.value[0].userName
|
||
}
|
||
} catch {
|
||
accountList.value = []
|
||
}
|
||
}
|
||
|
||
/** 登录 */
|
||
async function handleLogin() {
|
||
if (loading.value) return
|
||
const valid = await loginFormRef.value?.validate().catch(() => false)
|
||
if (!valid) return
|
||
errorText.value = null
|
||
loading.value = true
|
||
try {
|
||
// 第一次请求:OAuth2 密码模式获取 token
|
||
const { access_token } = await loginApi(loginFormData)
|
||
userStore.setToken(access_token)
|
||
// 第二次请求:获取当前用户信息
|
||
await userStore.getInfo()
|
||
if (userStore.accountType === ACCOUNT_TYPE_FORBIDDEN) {
|
||
userStore.resetToken()
|
||
errorText.value = "该用户无权限登录"
|
||
ElMessage.warning("该用户无权限登录")
|
||
return
|
||
}
|
||
saveKeepLogin()
|
||
const redirect = route.query.redirect
|
||
await router.push(redirect ? decodeURIComponent(redirect as string) : "/")
|
||
} catch {
|
||
userStore.resetToken()
|
||
errorText.value = "账户或密码错误"
|
||
loginFormData.password = ""
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
/** 从忘记密码返回登录 */
|
||
function handleBackToLogin() {
|
||
showForgetPassword.value = false
|
||
}
|
||
|
||
/** 密码重置成功:回填账号密码 */
|
||
function handlePasswordResetSuccess(username: string, password: string) {
|
||
showForgetPassword.value = false
|
||
loginFormData.username = username
|
||
loginFormData.password = password
|
||
ElMessage.success("密码重置成功,请登录")
|
||
}
|
||
|
||
// 初始化"保持登录"
|
||
readKeepLogin()
|
||
</script>
|
||
|
||
<template>
|
||
<div class="login-container">
|
||
<div class="title">
|
||
{{ LOGIN_TITLE }}
|
||
</div>
|
||
<div class="login-wrap">
|
||
<div class="form-section">
|
||
<template v-if="!showForgetPassword">
|
||
<h3 class="form-title">
|
||
用户登录
|
||
</h3>
|
||
<transition name="el-zoom-in-top">
|
||
<div v-if="errorText" class="error-wrap">
|
||
<div class="error-tip">
|
||
<el-icon class="error-icon">
|
||
<CircleCloseFilled />
|
||
</el-icon>
|
||
<span class="error-text">{{ errorText }}</span>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
<el-form
|
||
ref="loginFormRef"
|
||
:model="loginFormData"
|
||
:rules="loginFormRules"
|
||
class="login-form"
|
||
@submit.prevent
|
||
@keyup.enter="handleLogin"
|
||
>
|
||
<el-form-item prop="username">
|
||
<el-select
|
||
v-model="loginFormData.username"
|
||
placeholder="请输入用户名或手机号"
|
||
filterable
|
||
allow-create
|
||
clearable
|
||
default-first-option
|
||
:filter-method="filterOption"
|
||
tabindex="1"
|
||
autocomplete="username"
|
||
>
|
||
<template #prefix>
|
||
<el-tooltip effect="dark" content="输入手机号可获取对应的用户名" placement="top">
|
||
<el-icon class="phone-icon">
|
||
<Iphone />
|
||
</el-icon>
|
||
</el-tooltip>
|
||
</template>
|
||
<el-option
|
||
v-for="item in accountList"
|
||
:key="item.userName"
|
||
:label="item.userName"
|
||
:value="item.userName"
|
||
>
|
||
<div class="account-option">
|
||
<span>{{ item.userName }}</span>
|
||
<span class="account-team">{{ item.teamName }}</span>
|
||
</div>
|
||
</el-option>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item prop="password">
|
||
<el-input
|
||
v-model.trim="loginFormData.password"
|
||
placeholder="请输入密码"
|
||
type="password"
|
||
tabindex="2"
|
||
:prefix-icon="Lock"
|
||
show-password
|
||
autocomplete="current-password"
|
||
/>
|
||
</el-form-item>
|
||
</el-form>
|
||
<div class="form-link">
|
||
<el-checkbox v-model="keepLoginIn" :disabled="loading">
|
||
保持登录
|
||
</el-checkbox>
|
||
<span class="forget-link" @click="showForgetPassword = true">
|
||
忘记密码
|
||
</span>
|
||
</div>
|
||
<div class="form-submit">
|
||
<el-button class="login-button" type="primary" :loading="loading" @click="handleLogin">
|
||
<span class="login-button-text">登 录</span>
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<h3 class="form-title">
|
||
忘记密码
|
||
</h3>
|
||
<Forget @back-to-login="handleBackToLogin" @password-reset-success="handlePasswordResetSuccess" />
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<!-- 页面底部公司信息 -->
|
||
<div class="copy-right">
|
||
<p>成都鸿兴运达科技公司 <a href="http://beian.miit.gov.cn" target="_blank" rel="noopener">蜀ICP备19023702号-4</a></p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style lang="scss" scoped>
|
||
// 视觉对齐 school-iot-admin 的登录页(src/views/User/Login.vue)
|
||
.login-container {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
background-color: rgba(52, 125, 249, 0.3);
|
||
// 背景图(复制自 school-iot-admin 的 newLoginBackgtound2.png)
|
||
&::after {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 0;
|
||
background: url("./images/login-background.png") no-repeat center;
|
||
background-size: cover;
|
||
}
|
||
|
||
@media screen and (max-width: 768px) {
|
||
&::after {
|
||
background-size: 100% auto;
|
||
}
|
||
}
|
||
|
||
.title {
|
||
position: absolute;
|
||
top: 20%;
|
||
right: 14%;
|
||
z-index: 1;
|
||
width: max-content;
|
||
max-width: 90%;
|
||
font-size: 38px;
|
||
font-weight: 500;
|
||
color: #ffffff;
|
||
text-shadow: 0 4px 4px rgba(0, 0, 0, 0.5);
|
||
white-space: nowrap;
|
||
|
||
@media screen and (max-height: 900px) {
|
||
top: 14%;
|
||
font-size: 32px;
|
||
}
|
||
|
||
// 移动端隐藏标题(对齐老平台 screen-xs 行为,避免与登录卡片重叠)
|
||
@media screen and (max-width: 768px) {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
.login-wrap {
|
||
position: relative;
|
||
z-index: 1;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
// 白色登录卡片
|
||
.form-section {
|
||
position: absolute;
|
||
top: 54%;
|
||
left: 77.5%;
|
||
transform: translate(-50%, -50%);
|
||
width: 420px;
|
||
max-width: 92vw;
|
||
padding: 28px 32px 32px;
|
||
background-color: var(--el-bg-color);
|
||
border-radius: 5px;
|
||
box-shadow: 0 4px 10px rgba(15, 72, 200, 0.6);
|
||
box-sizing: border-box;
|
||
|
||
@media screen and (max-width: 768px) {
|
||
position: relative;
|
||
top: 0;
|
||
left: 0;
|
||
transform: none;
|
||
margin: 120px auto 0;
|
||
}
|
||
|
||
.form-title {
|
||
margin: 16px 0 24px;
|
||
color: #347df9;
|
||
font-size: 24px;
|
||
font-weight: 500;
|
||
text-align: center;
|
||
letter-spacing: 0;
|
||
line-height: 24px;
|
||
}
|
||
|
||
.error-wrap {
|
||
padding: 0 0 16px;
|
||
.error-tip {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 6px 10px;
|
||
border-radius: 3px;
|
||
background-color: rgba(240, 70, 48, 0.1);
|
||
.error-icon {
|
||
color: #f04631;
|
||
font-size: 20px;
|
||
flex: none;
|
||
}
|
||
.error-text {
|
||
margin-left: 8px;
|
||
font-size: 15px;
|
||
color: rgba(31, 56, 88, 0.8);
|
||
}
|
||
}
|
||
}
|
||
|
||
.login-form {
|
||
:deep(.el-form-item) {
|
||
margin-bottom: 24px;
|
||
}
|
||
:deep(.el-input__wrapper) {
|
||
height: 48px;
|
||
border-radius: 3px;
|
||
box-shadow: 0 0 0 1px #bcbdc1 inset;
|
||
}
|
||
:deep(.el-select__wrapper) {
|
||
min-height: 48px;
|
||
border-radius: 3px;
|
||
box-shadow: 0 0 0 1px #bcbdc1 inset;
|
||
}
|
||
:deep(.el-input__inner) {
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
}
|
||
:deep(.el-select__placeholder) {
|
||
font-size: 16px;
|
||
}
|
||
:deep(.el-input__wrapper.is-focus),
|
||
:deep(.el-select__wrapper.is-focused) {
|
||
box-shadow: 0 0 0 1px #347df9 inset;
|
||
}
|
||
.phone-icon {
|
||
cursor: help;
|
||
}
|
||
}
|
||
|
||
.account-option {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
.account-team {
|
||
color: var(--el-text-color-secondary);
|
||
font-size: 13px;
|
||
}
|
||
}
|
||
|
||
.form-link {
|
||
height: 32px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
user-select: none;
|
||
:deep(.el-checkbox__label) {
|
||
font-size: 16px;
|
||
color: var(--el-text-color-primary);
|
||
}
|
||
.forget-link {
|
||
font-size: 14px;
|
||
color: #347df9;
|
||
text-decoration: underline;
|
||
cursor: pointer;
|
||
}
|
||
}
|
||
|
||
.form-submit {
|
||
margin-top: 32px;
|
||
.login-button {
|
||
width: 100%;
|
||
height: 50px;
|
||
font-size: 20px;
|
||
border-radius: 5px;
|
||
}
|
||
.login-button-text {
|
||
letter-spacing: 5px;
|
||
margin-left: 5px;
|
||
}
|
||
}
|
||
}
|
||
|
||
.copy-right {
|
||
position: absolute;
|
||
bottom: 16px;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 1;
|
||
text-align: center;
|
||
font-size: 12px;
|
||
color: #a8a8a8;
|
||
p {
|
||
margin: 0;
|
||
line-height: 1.5em;
|
||
}
|
||
a {
|
||
color: #ffffff;
|
||
text-decoration: none;
|
||
&:hover {
|
||
text-decoration: underline;
|
||
}
|
||
}
|
||
@media screen and (max-width: 768px) {
|
||
display: none;
|
||
}
|
||
}
|
||
}
|
||
</style>
|