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:
2026-10-27 09:13:39 +08:00
parent 80b8218b23
commit 47dd605067
38 changed files with 1719 additions and 464 deletions
+399 -158
View File
@@ -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;
}
}
}