Template
Align console UI with school-iot-admin and wire login to serverschool
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
This commit is contained in:
+399
-158
@@ -1,13 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
import type { FormRules } from "element-plus"
|
||||
import type { LoginRequestData } from "./apis/type"
|
||||
import ThemeSwitch from "@@/components/ThemeSwitch/index.vue"
|
||||
import { Key, Loading, Lock, Picture, User } from "@element-plus/icons-vue"
|
||||
import { useSettingsStore } from "@/pinia/stores/settings"
|
||||
import { useUserStore } from "@/pinia/stores/user"
|
||||
import { getCaptchaApi, loginApi } from "./apis"
|
||||
import Owl from "./components/Owl.vue"
|
||||
import { useFocus } from "./composables/useFocus"
|
||||
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()
|
||||
|
||||
@@ -15,194 +14,436 @@ const router = useRouter()
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
const { isFocus, handleBlur, handleFocus } = useFocus()
|
||||
|
||||
/** 登录表单元素的引用 */
|
||||
const loginFormRef = useTemplateRef("loginFormRef")
|
||||
const loginFormRef = useTemplateRef<FormInstance>("loginFormRef")
|
||||
|
||||
/** 登录按钮 Loading */
|
||||
const loading = ref(false)
|
||||
|
||||
/** 验证码图片 URL */
|
||||
const codeUrl = ref("")
|
||||
/** 错误提示信息 */
|
||||
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: "admin",
|
||||
password: "12345678",
|
||||
code: ""
|
||||
username: "",
|
||||
password: ""
|
||||
})
|
||||
|
||||
/** 登录表单校验规则 */
|
||||
const loginFormRules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: "请输入用户名", trigger: "blur" }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: "请输入密码", trigger: "blur" },
|
||||
{ min: 8, max: 16, message: "长度在 8 到 16 个字符", trigger: "blur" }
|
||||
],
|
||||
code: [
|
||||
{ required: true, message: "请输入验证码", trigger: "blur" }
|
||||
]
|
||||
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 = []
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录 */
|
||||
function handleLogin() {
|
||||
loginFormRef.value?.validate((valid) => {
|
||||
if (!valid) {
|
||||
ElMessage.error("表单校验不通过")
|
||||
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
|
||||
}
|
||||
loading.value = true
|
||||
loginApi(loginFormData).then(({ data }) => {
|
||||
userStore.setToken(data.token)
|
||||
router.push(route.query.redirect ? decodeURIComponent(route.query.redirect as string) : "/")
|
||||
}).catch(() => {
|
||||
createCode()
|
||||
loginFormData.password = ""
|
||||
}).finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
})
|
||||
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 createCode() {
|
||||
// 清空已输入的验证码
|
||||
loginFormData.code = ""
|
||||
// 清空验证图片
|
||||
codeUrl.value = ""
|
||||
// 获取验证码图片
|
||||
getCaptchaApi().then((res) => {
|
||||
codeUrl.value = res.data
|
||||
})
|
||||
/** 从忘记密码返回登录 */
|
||||
function handleBackToLogin() {
|
||||
showForgetPassword.value = false
|
||||
}
|
||||
|
||||
// 初始化验证码
|
||||
createCode()
|
||||
/** 密码重置成功:回填账号密码 */
|
||||
function handlePasswordResetSuccess(username: string, password: string) {
|
||||
showForgetPassword.value = false
|
||||
loginFormData.username = username
|
||||
loginFormData.password = password
|
||||
ElMessage.success("密码重置成功,请登录")
|
||||
}
|
||||
|
||||
// 初始化"保持登录"
|
||||
readKeepLogin()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<ThemeSwitch v-if="settingsStore.showThemeSwitch" class="theme-switch" />
|
||||
<Owl :close-eyes="isFocus" />
|
||||
<div class="login-card">
|
||||
<div class="title">
|
||||
<img src="@@/assets/images/layouts/logo-text-2.png">
|
||||
</div>
|
||||
<div class="content">
|
||||
<el-form ref="loginFormRef" :model="loginFormData" :rules="loginFormRules" @keyup.enter="handleLogin">
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model.trim="loginFormData.username"
|
||||
placeholder="用户名"
|
||||
type="text"
|
||||
tabindex="1"
|
||||
:prefix-icon="User"
|
||||
size="large"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model.trim="loginFormData.password"
|
||||
placeholder="密码"
|
||||
type="password"
|
||||
tabindex="2"
|
||||
:prefix-icon="Lock"
|
||||
size="large"
|
||||
show-password
|
||||
@blur="handleBlur"
|
||||
@focus="handleFocus"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="code">
|
||||
<el-input
|
||||
v-model.trim="loginFormData.code"
|
||||
placeholder="验证码"
|
||||
type="text"
|
||||
tabindex="3"
|
||||
:prefix-icon="Key"
|
||||
maxlength="7"
|
||||
size="large"
|
||||
@blur="handleBlur"
|
||||
@focus="handleFocus"
|
||||
>
|
||||
<template #append>
|
||||
<el-image :src="codeUrl" draggable="false" @click="createCode">
|
||||
<template #placeholder>
|
||||
<el-icon>
|
||||
<Picture />
|
||||
<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>
|
||||
</template>
|
||||
<template #error>
|
||||
<el-icon>
|
||||
<Loading />
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-image>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-button :loading="loading" type="primary" size="large" @click.prevent="handleLogin">
|
||||
登 录
|
||||
</el-button>
|
||||
</el-form>
|
||||
</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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
.theme-switch {
|
||||
position: fixed;
|
||||
top: 5%;
|
||||
right: 5%;
|
||||
cursor: pointer;
|
||||
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;
|
||||
}
|
||||
.login-card {
|
||||
width: 480px;
|
||||
max-width: 90%;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 0 10px #dcdfe6;
|
||||
background-color: var(--el-bg-color);
|
||||
overflow: hidden;
|
||||
.title {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 150px;
|
||||
img {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
&::after {
|
||||
background-size: 100% auto;
|
||||
}
|
||||
.content {
|
||||
padding: 20px 50px 50px 50px;
|
||||
:deep(.el-input-group__append) {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
.el-image {
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
border-left: 0px;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
.el-button {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user