Files
new-school-iot-admin/src/pages/login/index.vue
T
admin 47dd605067 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
2026-10-27 09:13:39 +08:00

451 lines
12 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 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>