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
+278
View File
@@ -0,0 +1,278 @@
<script lang="ts" setup>
import type { FormInstance, FormRules } from "element-plus"
import type { AccountItem } from "../apis/type"
import { QuestionFilled } from "@element-plus/icons-vue"
import { getAccountListApi, sendSmsCodeApi, verifySmsCodeApi } from "../apis"
interface Emits {
(e: "backToLogin"): void
(e: "passwordResetSuccess", username: string, password: string): void
}
defineOptions({
name: "ForgetPassword"
})
const emit = defineEmits<Emits>()
/** 表单元素的引用 */
const formRef = useTemplateRef<FormInstance>("formRef")
/** 表单数据 */
const form = reactive({
phone: "",
userName: "",
code: "",
newPassword: "",
confirmPassword: ""
})
/** 手机号查出的账号列表 */
const accountList = ref<AccountItem[]>([])
/** 上一次查询过的手机号(避免重复请求) */
const oldPhone = ref("")
/** 验证码倒计时 */
const countdown = ref(0)
let timer: ReturnType<typeof setInterval> | undefined
/** 发送验证码 Loading */
const smsLoading = ref(false)
/** 提交 Loading */
const submitLoading = ref(false)
/** 重置成功提示弹窗 */
const dialogVisible = ref(false)
/** 校验两次密码一致 */
function validateConfirm(_rule: unknown, value: string, callback: (error?: Error) => void) {
if (value !== form.newPassword) callback(new Error("两次输入的密码不一致"))
else callback()
}
/** 表单校验规则(对齐 school-iot-admin 的忘记密码表单) */
const rules: FormRules = {
phone: [
{ required: true, message: "请输入手机号", trigger: "blur" },
{ pattern: /^1[3-9]\d{9}$/, message: "手机号格式错误", trigger: "blur" }
],
userName: [{ required: true, message: "请选择用户名", trigger: "change" }],
code: [
{ required: true, message: "请输入验证码", trigger: "blur" },
{ len: 6, message: "验证码必须是6位数字", trigger: "blur" }
],
newPassword: [
{ required: true, message: "请输入新密码", trigger: "blur" },
{ min: 8, message: "密码长度不能少于8位", trigger: "blur" },
{
validator: (_rule, value, callback) => {
if (/^(?:\d+|[a-z]+)$/i.test(value)) callback(new Error("密码强度太弱,请包含字母、数字"))
else callback()
},
trigger: "blur"
}
],
confirmPassword: [
{ required: true, message: "请再次输入密码", trigger: "blur" },
{ validator: validateConfirm, trigger: "blur" }
]
}
/** 根据手机号查询账号列表 */
async function loadAccountList() {
try {
const res = await getAccountListApi(form.phone)
accountList.value = res.success ? (res.data ?? []) : []
form.userName = accountList.value[0]?.userName ?? ""
} catch {
accountList.value = []
form.userName = ""
}
}
/** 手机号失焦:合法手机号才查询账号列表 */
function handlePhoneBlur() {
const phone = form.phone
if (phone && /^1[3-9]\d{9}$/.test(phone)) {
if (oldPhone.value !== phone) {
form.userName = ""
oldPhone.value = phone
loadAccountList()
}
}
}
/** 倒计时 */
function tick() {
timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
clearInterval(timer)
timer = undefined
}
}, 1000)
}
/** 获取验证码 */
async function getVerifyCode() {
if (!form.phone) {
ElMessage.warning("请先输入手机号")
return
}
smsLoading.value = true
countdown.value = 120
tick()
try {
const res = await sendSmsCodeApi(form.phone)
if (res.success) {
ElMessage.success("验证码已发送")
} else {
throw new Error(res.msg || "发送失败")
}
} catch (error) {
ElMessage.error((error as Error).message || "发送失败")
countdown.value = 0
if (timer) {
clearInterval(timer)
timer = undefined
}
} finally {
smsLoading.value = false
}
}
/** 提交重置密码 */
async function onSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) {
ElMessage.warning("请检查输入内容")
return
}
submitLoading.value = true
try {
const res = await verifySmsCodeApi({
userName: form.userName,
newPassword: form.newPassword,
phone: form.phone,
code: form.code
})
if (res.success) {
ElMessage.success("密码重置成功")
dialogVisible.value = true
} else {
ElMessage.error(res.msg || "提交失败")
}
} catch (error) {
ElMessage.error((error as Error).message || "提交失败")
} finally {
submitLoading.value = false
}
}
/** 重置成功弹窗的确定按钮:回填账号密码并返回登录表单 */
function ok() {
dialogVisible.value = false
emit("passwordResetSuccess", form.userName, form.newPassword)
formRef.value?.resetFields()
}
/** 返回登录 */
function goBackLogin() {
emit("backToLogin")
}
onUnmounted(() => {
if (timer) clearInterval(timer)
})
</script>
<template>
<div class="forget-container">
<el-form ref="formRef" :model="form" :rules="rules" label-width="auto" @submit.prevent>
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" placeholder="请输入手机号" maxlength="11" @blur="handlePhoneBlur" />
</el-form-item>
<el-form-item label="用户名" prop="userName">
<el-select
v-model="form.userName"
placeholder="请选择用户名"
:disabled="accountList.length === 0"
style="width: 100%"
>
<template #prefix>
<el-tooltip effect="dark" content="此处为小程序对应的用户名" placement="top">
<el-icon class="question-icon">
<QuestionFilled />
</el-icon>
</el-tooltip>
</template>
<el-option v-for="item in accountList" :key="item.userName" :label="item.userName" :value="item.userName" />
</el-select>
</el-form-item>
<el-form-item label="验证码" prop="code">
<el-input v-model="form.code" placeholder="请输入验证码" maxlength="6" :disabled="!form.userName">
<template #append>
<el-button :disabled="countdown > 0 || !form.userName" :loading="smsLoading" @click="getVerifyCode">
{{ countdown > 0 ? `${countdown}s 后重试` : "获取验证码" }}
</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input v-model="form.newPassword" placeholder="请输入新密码" type="password" show-password />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="form.confirmPassword" placeholder="请再次输入新密码" type="password" show-password />
</el-form-item>
<el-button type="primary" class="submit-button" :loading="submitLoading" @click="onSubmit">
提交
</el-button>
<el-button class="submit-button" @click="goBackLogin">
返回登录
</el-button>
</el-form>
<el-dialog v-model="dialogVisible" title="提示" width="30%" append-to-body>
<div class="reset-success-tip">
<p>关联的小程序旧密码失效,用修改后的密码进行登入</p>
</div>
<template #footer>
<el-button type="primary" @click="ok">
确 定
</el-button>
</template>
</el-dialog>
</div>
</template>
<style lang="scss" scoped>
.forget-container {
width: 100%;
.question-icon {
margin-right: 4px;
color: var(--el-text-color-placeholder);
cursor: help;
}
.submit-button {
width: 100%;
margin-top: 8px;
& + .submit-button {
margin-left: 0;
}
}
.reset-success-tip {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
}
</style>