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
+18 -1
View File
@@ -1,7 +1,24 @@
# 所有环境的环境变量(命名必须以 VITE_ 开头)
## 项目标题
VITE_APP_TITLE = V3 Admin Vite
VITE_APP_TITLE = 校园物联网管理平台
## 路由模式 hash 或 html5
VITE_ROUTER_HISTORY = hash
## serverschool 后端接口前缀(登录 / 用户信息等真实接口,与 apifoxmock 的 VITE_BASE_URL 区分)
## 开发环境由 vite.config.ts 的 /hxyd-api 代理转发;生产环境需由 nginx 反代同名路径
VITE_SCHOOL_BASE_URL = /hxyd-api
## kkFileView 文档预览服务(对齐 school-iot-admin 的 VUE_APP_KKFILEVIEW_URL)
VITE_KKFILEVIEW_URL = https://net2.hxzhxy.cn:8012
## 跨系统链接(对齐 school-iot-admin,默认生产地址;开发内网地址见 .env.development 注释)
# 问卷系统
VITE_QUESTION_URL = http://complaints.hxzhxy.cn
# 可视化数据大屏
VITE_DATA_SCREEN = http://139.159.182.121:1689
# 新生填报 H5
VITE_APPLY_URL = http://apply.hxzhxy.cn
# 数据填报表单
VITE_API_FORM = https://form.hxzhxy.cn
+18 -1
View File
@@ -1,7 +1,24 @@
# 开发环境的环境变量(命名必须以 VITE_ 开头)
## 后端接口地址(如果解决跨域问题采用反向代理就只需写相对路径)
## 后端接口地址(apifoxmock,demo / FastCrud 演示页用)
VITE_BASE_URL = /api/v1
## 开发环境域名和静态资源公共路径(一般 / 或 ./ 都可以)
VITE_PUBLIC_PATH = /
# ===== serverschool 后端代理(默认连生产,与 school-iot-admin 的 dev 一致) =====
## /hxyd-api 代理目标;连测试库改成 http://192.168.0.10:8100
VITE_SCHOOL_TARGET = https://band.hxzhxy.cn
## WebSocket(/ws-api);老平台 dev 内网值 ws://192.168.0.244:58089
VITE_WS_TARGET = ws://139.159.182.121:8100
## 视频流 wvp(/hxyd-video);老平台 dev 内网值 http://10.19.64.250:8000
VITE_VIDEO_TARGET = http://11.1.20.107:8000
## yolo 抓拍图片 minio(/hxyd-video/yolo);老平台 dev 内网值 http://10.19.64.250:9000
VITE_VIDEO_MINIO = http://11.1.20.107:9000
## 智能体对话(/agent-dialogue-api,本机 score_agent / LangGraph)
VITE_AGENT_DIALOGUE_TARGET = http://127.0.0.1:3030
+3
View File
@@ -0,0 +1,3 @@
allowBuilds:
'@parcel/watcher': true
esbuild: true
-11
View File
@@ -1,6 +1,5 @@
<script lang="ts" setup>
import { useGreyAndColorWeakness } from "@@/composables/useGreyAndColorWeakness"
import { usePany } from "@@/composables/usePany"
import { useTheme } from "@@/composables/useTheme"
import zhCn from "element-plus/es/locale/lang/zh-cn" // Element Plus 中文包
@@ -8,21 +7,11 @@ const { initTheme } = useTheme()
const { initGreyAndColorWeakness } = useGreyAndColorWeakness()
const { initStarNotification, initStoreNotification, initMobileNotification } = usePany()
// 初始化主题
initTheme()
// 初始化灰色模式和色弱模式
initGreyAndColorWeakness()
// #region 初始化通知
initStarNotification()
initStoreNotification()
initMobileNotification()
// #endregion
</script>
<template>
+8 -3
View File
@@ -1,10 +1,15 @@
import type * as Users from "./type"
import { request } from "@/http/axios"
/** 获取当前登录用户详情 */
/**
* 获取当前登录用户详情(serverschool /uc/account/current)
* 返回的 data 中包含 pwd 等敏感字段,调用方只取必要字段,不要整体入库/展示
*/
export function getCurrentUserApi() {
return request<Users.CurrentUserResponseData>({
url: "users/me",
method: "get"
baseURL: import.meta.env.VITE_SCHOOL_BASE_URL,
url: "uc/account/current",
method: "get",
timeout: 10000
})
}
+24 -5
View File
@@ -1,5 +1,24 @@
export type CurrentUserResponseData = ApiResponseData<{
username: string
roles: string[]
permissions?: string[]
}>
/** serverschool 账号信息(/uc/account/current 返回的 AccountDetails,已裁剪无用字段) */
export interface AccountDetails {
/** 账号 ID */
id: number
/** 用户名 */
userName: string
/** 账号类型(3 表示无权限登录后台) */
type?: number
/** 所属学校/分组 ID */
teamId?: number
/** 角色 */
role?: {
id: number
name?: string
[key: string]: unknown
}
/** 是否锁定 */
locked?: boolean
/** 是否启用 */
enabled?: boolean
[key: string]: unknown
}
export type CurrentUserResponseData = ApiResponseData<AccountDetails>
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

@@ -1,4 +1,16 @@
// 自定义 Element Plus 样式
// 视觉规范对齐 school-iot-admin(品牌色 #347df9)
// 品牌主色(对齐 school-iot-admin 的 #347df9,让按钮/链接/开关等统一品牌色)
:root {
--el-color-primary: #347df9;
--el-color-primary-light-3: #71a4fb;
--el-color-primary-light-5: #9abefc;
--el-color-primary-light-7: #c2d8fd;
--el-color-primary-light-8: #d6e5fe;
--el-color-primary-light-9: #ebf2fe;
--el-color-primary-dark-2: #2e68cb;
}
// 分页
.el-pagination {
@@ -13,3 +25,61 @@
}
}
}
// 表格
.el-table {
--el-table-header-bg-color: #eaf1fe;
--el-table-header-text-color: #3d3e40;
--el-table-text-color: #373535;
--el-table-border-color: #ebeef5;
--el-table-row-hover-bg-color: #f2f6fc;
th.el-table__cell {
font-size: 15px;
font-weight: 500;
}
td.el-table__cell {
font-size: 14px;
}
// 斑马纹
&.el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
background-color: #f7faff;
}
}
// 弹窗(标题栏为品牌蓝底白字)
.el-dialog {
border-radius: 10px;
overflow: hidden;
.el-dialog__header {
margin: 0;
padding: 0 20px;
height: 48px;
display: flex;
align-items: center;
background-color: #347df9;
.el-dialog__title {
color: #ffffff;
font-size: 16px;
font-weight: 500;
}
.el-dialog__headerbtn {
top: 50%;
right: 12px;
transform: translateY(-50%);
.el-dialog__close {
color: #ffffff;
}
}
}
.el-dialog__body {
padding: 20px;
}
}
// 表单
.el-form-item__label {
font-weight: 500;
}
+51 -1
View File
@@ -16,8 +16,58 @@
@import "./view-transition.scss";
// 业务页面几乎都应该在根元素上挂载 class="app-container",以保持页面美观
// 视觉规范对齐 school-iot-admin:白卡片 + 10px 圆角 + 淡蓝阴影
.app-container {
padding: 20px;
padding: 16px 20px 20px;
background-color: var(--el-bg-color);
border-radius: 10px;
box-shadow: 0 0 10px rgba(32, 76, 131, 0.12);
}
// 页面标题(对齐 school-iot-admin 的 .head-title:左侧 6px 蓝色竖条)
.head-title {
position: relative;
display: flex;
align-items: center;
margin: 4px 0 16px;
padding-left: 14px;
font-size: 20px;
font-weight: 500;
color: var(--el-text-color-primary);
&::before {
content: "";
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
width: 6px;
height: 17px;
border-radius: 10px;
background-color: #347df9;
}
.tle {
margin: 0 5px;
}
}
// 主按钮(对齐 school-iot-admin 的 .btn-blue)
.btn-blue {
min-width: 68px;
border-radius: 2px;
font-weight: 500;
color: #ffffff;
background-color: #347df9;
border-color: #347df9;
}
// 次按钮(对齐 school-iot-admin 的 .btn-white)
.btn-white {
min-width: 68px;
border-radius: 2px;
font-weight: 500;
color: #347df9;
background-color: #ffffff;
border: 1px solid #347df9;
}
html {
@@ -4,3 +4,30 @@
$theme-name: "dark-blue";
// 主题背景颜色
$theme-bg-color: #001b44;
// dark-blue 主题下的布局变量覆盖
html.#{ $theme-name } {
// Element Plus 品牌主色(与亮色主题保持一致)
--el-color-primary: #347df9;
--el-color-primary-light-3: #2460c3;
--el-color-primary-light-5: #1a4c9e;
--el-color-primary-light-7: #10387a;
--el-color-primary-light-8: #0a2f68;
--el-color-primary-light-9: #052556;
--el-color-primary-dark-2: #5d97fa;
// Body
--v3-body-bg-color: #{$theme-bg-color};
// Sidebar 组件(深色背景下需要浅色文字)
--v3-sidebar-menu-bg-color: var(--el-bg-color);
--v3-sidebar-menu-hover-bg-color: var(--el-fill-color);
--v3-sidebar-menu-active-bg-color: var(--el-fill-color);
--v3-sidebar-menu-text-color: #cfd3dc;
--v3-sidebar-menu-active-text-color: #ffffff;
// TagsView 组件
--v3-tagsview-text-color: var(--el-text-color-regular);
--v3-tagsview-tag-bg-color: var(--el-bg-color);
--v3-tagsview-tag-bg: var(--el-bg-color);
--v3-tagsview-tag-active-bg: var(--el-color-primary);
--v3-tagsview-tag-border-color: var(--el-border-color-lighter);
}
@@ -4,3 +4,30 @@
$theme-name: "dark";
// 主题背景颜色
$theme-bg-color: #141414;
// dark 主题下的布局变量覆盖
html.#{ $theme-name } {
// Element Plus 品牌主色(与亮色主题保持一致)
--el-color-primary: #347df9;
--el-color-primary-light-3: #2a5eb4;
--el-color-primary-light-5: #244886;
--el-color-primary-light-7: #1e3459;
--el-color-primary-light-8: #1a2942;
--el-color-primary-light-9: #171e2b;
--el-color-primary-dark-2: #5d97fa;
// Body
--v3-body-bg-color: #{$theme-bg-color};
// Sidebar 组件(深色背景下需要浅色文字)
--v3-sidebar-menu-bg-color: var(--el-bg-color);
--v3-sidebar-menu-hover-bg-color: var(--el-fill-color);
--v3-sidebar-menu-active-bg-color: var(--el-fill-color);
--v3-sidebar-menu-text-color: #cfd3dc;
--v3-sidebar-menu-active-text-color: #ffffff;
// TagsView 组件
--v3-tagsview-text-color: var(--el-text-color-regular);
--v3-tagsview-tag-bg-color: var(--el-bg-color);
--v3-tagsview-tag-bg: var(--el-bg-color);
--v3-tagsview-tag-active-bg: var(--el-color-primary);
--v3-tagsview-tag-border-color: var(--el-border-color-lighter);
}
+29 -28
View File
@@ -3,37 +3,38 @@
:root {
/* Body */
--v3-body-text-color: var(--el-text-color-primary);
--v3-body-bg-color: var(--el-bg-color-page);
--v3-body-bg-color: #f5f7fa;
/* Header 区域 = NavigationBar 组件 + TagsView 组件 */
--v3-header-height: calc(
var(--v3-navigationbar-height) + var(--v3-tagsview-height) + var(--v3-header-border-bottom-width)
);
--v3-header-bg-color: var(--el-bg-color);
--v3-header-box-shadow: var(--el-box-shadow-lighter);
--v3-header-border-bottom-width: 1px;
--v3-header-border-bottom: var(--v3-header-border-bottom-width) solid var(--el-fill-color);
--v3-header-height: calc(var(--v3-navigationbar-height) + var(--v3-tagsview-height));
--v3-header-bg-color: #347df9;
--v3-header-box-shadow: none;
--v3-header-border-bottom-width: 0px;
--v3-header-border-bottom: none;
/* NavigationBar 组件 */
--v3-navigationbar-height: 50px;
--v3-navigationbar-text-color: var(--el-text-color-regular);
--v3-navigationbar-height: 60px;
--v3-navigationbar-text-color: #ffffff;
/* Sidebar 组件(左侧模式全部生效、顶部模式全部不生效、混合模式非颜色部分生效) */
--v3-sidebar-width: 220px;
--v3-sidebar-hide-width: 58px;
--v3-sidebar-border-right: 1px solid var(--el-fill-color);
--v3-sidebar-menu-item-height: 60px;
--v3-sidebar-menu-tip-line-bg-color: var(--el-color-primary);
--v3-sidebar-menu-bg-color: #001428;
--v3-sidebar-menu-hover-bg-color: #409eff10;
--v3-sidebar-menu-text-color: #cfd3dc;
--v3-sidebar-menu-active-text-color: #ffffff;
--v3-sidebar-width: 240px;
--v3-sidebar-hide-width: 64px;
--v3-sidebar-border-right: none;
--v3-sidebar-menu-item-height: 49px;
--v3-sidebar-menu-tip-line-bg-color: #347df9;
--v3-sidebar-menu-bg-color: #ffffff;
--v3-sidebar-menu-hover-bg-color: #f2f6fc;
--v3-sidebar-menu-active-bg-color: #ebf5ff;
--v3-sidebar-menu-text-color: #303133;
--v3-sidebar-menu-active-text-color: #347df9;
/* TagsView 组件 */
--v3-tagsview-height: 34px;
--v3-tagsview-text-color: var(--el-text-color-regular);
--v3-tagsview-height: 36px;
--v3-tagsview-text-color: #303133;
--v3-tagsview-tag-active-text-color: #ffffff;
--v3-tagsview-tag-bg-color: var(--el-bg-color);
--v3-tagsview-tag-active-bg-color: var(--el-color-primary);
--v3-tagsview-tag-border-radius: 2px;
--v3-tagsview-tag-border-color: var(--el-border-color-lighter);
--v3-tagsview-tag-active-border-color: var(--el-color-primary);
--v3-tagsview-tag-bg-color: #ffffff;
--v3-tagsview-tag-bg: linear-gradient(to bottom, #ffffff 20%, #90c8f7);
--v3-tagsview-tag-active-bg-color: #347df9;
--v3-tagsview-tag-active-bg: linear-gradient(to bottom, #56bafb, #0577fa);
--v3-tagsview-tag-border-radius: 4px 4px 0 0;
--v3-tagsview-tag-border-color: #d3d4d6;
--v3-tagsview-tag-active-border-color: #347df9;
--v3-tagsview-tag-icon-hover-bg-color: #00000030;
--v3-tagsview-tag-icon-hover-color: #ffffff;
--v3-tagsview-contextmenu-text-color: var(--el-text-color-regular);
@@ -42,9 +43,9 @@
--v3-tagsview-contextmenu-hover-bg-color: var(--el-fill-color);
--v3-tagsview-contextmenu-box-shadow: var(--el-box-shadow);
/* Hamburger 组件 */
--v3-hamburger-text-color: var(--el-text-color-primary);
--v3-hamburger-text-color: #ffffff;
/* RightPanel 组件 */
--v3-rightpanel-button-bg-color: #001428;
--v3-rightpanel-button-bg-color: #347df9;
}
/* 内容区放大时,将不需要的组件隐藏 */
+2
View File
@@ -8,4 +8,6 @@ export class CacheKey {
static readonly ACTIVE_THEME_NAME = `${SYSTEM_NAME}-active-theme-name-key`
static readonly VISITED_VIEWS = `${SYSTEM_NAME}-visited-views-key`
static readonly CACHED_VIEWS = `${SYSTEM_NAME}-cached-views-key`
static readonly FAVORITE_MENUS = `${SYSTEM_NAME}-favorite-menus-key`
static readonly KEEP_LOGIN = `${SYSTEM_NAME}-keep-login-key`
}
+11
View File
@@ -0,0 +1,11 @@
/** 平台名称(中文) */
export const PLATFORM_NAME = "校园物联网管理平台"
/** 平台名称(英文) */
export const PLATFORM_NAME_EN = "Campus IoT Management Platform"
/** 登录页标题(与 school-iot-admin 的登录页保持一致) */
export const LOGIN_TITLE = "综合信息化管理平台"
/** 品牌主色(与 school-iot-admin 保持一致) */
export const BRAND_COLOR = "#347df9"
+7
View File
@@ -0,0 +1,7 @@
/** 常用功能(被收藏的菜单项) */
export interface FavoriteMenu {
/** 菜单路径 */
path: string
/** 菜单名称 */
title: string
}
+27
View File
@@ -2,6 +2,7 @@
import type { ThemeName } from "@@/composables/useTheme"
import type { SidebarClosed, SidebarOpened } from "@@/constants/app-key"
import type { FavoriteMenu } from "@@/types/menu"
import type { LayoutsConfig } from "@/layouts/config"
import type { TagView } from "@/pinia/stores/tags-view"
import { CacheKey } from "@@/constants/cache-key"
@@ -79,3 +80,29 @@ export function setCachedViews(views: string[]) {
localStorage.setItem(CacheKey.CACHED_VIEWS, JSON.stringify(views))
}
// #endregion
// #region 常用功能
export function getFavoriteMenus() {
const json = localStorage.getItem(CacheKey.FAVORITE_MENUS)
return json ? (JSON.parse(json) as FavoriteMenu[]) : []
}
export function setFavoriteMenus(menus: FavoriteMenu[]) {
localStorage.setItem(CacheKey.FAVORITE_MENUS, JSON.stringify(menus))
}
// #endregion
// #region 保持登录(记住账号密码,对齐 school-iot-admin 的 localStorage.key)
export function getKeepLogin() {
const json = localStorage.getItem(CacheKey.KEEP_LOGIN)
return json ? (JSON.parse(json) as { username: string, password: string }) : null
}
export function setKeepLogin(data: { username: string, password: string }) {
localStorage.setItem(CacheKey.KEEP_LOGIN, JSON.stringify(data))
}
export function removeKeepLogin() {
localStorage.removeItem(CacheKey.KEEP_LOGIN)
}
// #endregion
+2 -2
View File
@@ -38,8 +38,8 @@ function createInstance() {
// Token 过期时
return useUserStore().logout()
default:
// 不是正确的 code
ElMessage.error(apiData.message || "Error")
// 不是正确的 code(serverschool 信封的错误信息字段是 msg)
ElMessage.error(apiData.message || apiData.msg || "Error")
return Promise.reject(apiData)
}
},
+2
View File
@@ -40,6 +40,8 @@ const settingsStore = useSettingsStore()
.app-scrollbar {
flex-grow: 1;
overflow: auto;
// 内容区留白(对齐 school-iot-admin 的内容区内边距)
padding: 16px;
@extend %scrollbar;
display: flex;
flex-direction: column;
+46 -24
View File
@@ -1,47 +1,74 @@
<script lang="ts" setup>
import logoText1 from "@@/assets/images/layouts/logo-text-1.png?url"
import logoText2 from "@@/assets/images/layouts/logo-text-2.png?url"
import logo from "@@/assets/images/layouts/logo.png?url"
import platformLogo from "@@/assets/images/layouts/platform-logo.png?url"
import { useLayoutMode } from "@@/composables/useLayoutMode"
import { PLATFORM_NAME, PLATFORM_NAME_EN } from "@@/constants/platform"
interface Props {
/** 是否折叠(折叠时只显示 Logo 图标) */
collapse?: boolean
}
const { collapse = true } = defineProps<Props>()
const { collapse = false } = defineProps<Props>()
const { isLeft, isTop } = useLayoutMode()
const { isTop } = useLayoutMode()
</script>
<template>
<div class="layout-logo-container" :class="{ 'collapse': collapse, 'layout-mode-top': isTop }">
<transition name="layout-logo-fade">
<router-link v-if="collapse" key="collapse" to="/">
<img :src="logo" class="layout-logo">
<router-link to="/">
<img :src="platformLogo" class="layout-logo" alt="logo">
<div v-if="!collapse" class="platform-name">
<span class="platform-name-cn">{{ PLATFORM_NAME }}</span>
<span class="platform-name-en">{{ PLATFORM_NAME_EN }}</span>
</div>
</router-link>
<router-link v-else key="expand" to="/">
<img :src="!isLeft ? logoText2 : logoText1" class="layout-logo-text">
</router-link>
</transition>
</div>
</template>
<style lang="scss" scoped>
.layout-logo-container {
position: relative;
width: 100%;
height: var(--v3-header-height);
width: auto;
max-width: 100%;
height: var(--v3-navigationbar-height);
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
a {
display: flex;
align-items: center;
min-width: 0;
color: #ffffff;
.layout-logo {
display: none;
flex: none;
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #ffffff;
object-fit: cover;
}
.platform-name {
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
margin-left: 12px;
.platform-name-cn {
font-size: 18px;
font-weight: 700;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.platform-name-en {
font-size: 12px;
line-height: 1.3;
opacity: 0.85;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.layout-logo-text {
height: 100%;
}
}
}
@@ -52,12 +79,7 @@ const { isLeft, isTop } = useLayoutMode()
.collapse {
a {
.layout-logo {
width: 32px;
height: 32px;
display: inline-block;
}
.layout-logo-text {
.platform-name {
display: none;
}
}
+102 -18
View File
@@ -5,24 +5,39 @@ import SearchMenu from "@@/components/SearchMenu/index.vue"
import ThemeSwitch from "@@/components/ThemeSwitch/index.vue"
import { useDevice } from "@@/composables/useDevice"
import { useLayoutMode } from "@@/composables/useLayoutMode"
import { UserFilled } from "@element-plus/icons-vue"
import { ArrowDown, UserFilled } from "@element-plus/icons-vue"
import { useAppStore } from "@/pinia/stores/app"
import { useFavoriteStore } from "@/pinia/stores/favorite"
import { useSettingsStore } from "@/pinia/stores/settings"
import { useUserStore } from "@/pinia/stores/user"
import { DASHBOARD_PATH } from "@/router/config"
import { Breadcrumb, Hamburger, Sidebar } from "../index"
const { isMobile } = useDevice()
const { isTop } = useLayoutMode()
const route = useRoute()
const appStore = useAppStore()
const userStore = useUserStore()
const settingsStore = useSettingsStore()
const favoriteStore = useFavoriteStore()
const { showNotify, showThemeSwitch, showScreenfull, showSearchMenu } = storeToRefs(settingsStore)
/** 顶部导航 = 首页 + 常用功能 */
const topNavs = computed(() => [
{ path: DASHBOARD_PATH, title: "首页" },
...favoriteStore.favorites
])
/** 用户名 */
const username = computed(() => userStore.username || "未登录")
/** 切换侧边栏 */
function toggleSidebar() {
appStore.toggleSidebar(false)
@@ -31,6 +46,7 @@ function toggleSidebar() {
<template>
<div class="navigation-bar">
<div class="navigation-bar__left">
<Hamburger
v-if="!isTop || isMobile"
:is-active="appStore.sidebar.opened"
@@ -39,27 +55,33 @@ function toggleSidebar() {
/>
<Breadcrumb v-if="!isTop || isMobile" class="breadcrumb" />
<Sidebar v-if="isTop && !isMobile" class="sidebar" />
</div>
<div v-if="!isTop" class="navigation-bar__center">
<router-link
v-for="nav in topNavs"
:key="nav.path"
:to="nav.path"
class="top-nav-item"
:class="{ active: route.path === nav.path }"
>
{{ nav.title }}
</router-link>
</div>
<div class="right-menu">
<SearchMenu v-if="showSearchMenu" class="right-menu-item" />
<Screenfull v-if="showScreenfull" class="right-menu-item" />
<ThemeSwitch v-if="showThemeSwitch" class="right-menu-item" />
<Notify v-if="showNotify" class="right-menu-item" />
<el-dropdown>
<el-dropdown trigger="click">
<div class="right-menu-item user">
<el-avatar :icon="UserFilled" :size="30" />
<span>{{ userStore.username }}</span>
<el-avatar :icon="UserFilled" :size="32" />
<span class="user-name">{{ username }}</span>
<el-icon :size="12">
<ArrowDown />
</el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<a target="_blank" href="https://github.com/un-pany/v3-admin-vite">
<el-dropdown-item>GitHub</el-dropdown-item>
</a>
<a target="_blank" href="https://gitee.com/un-pany/v3-admin-vite">
<el-dropdown-item>Gitee</el-dropdown-item>
</a>
<a target="_blank" href="https://atomgit.com/un-pany/v3-admin-vite">
<el-dropdown-item>AtomGit</el-dropdown-item>
</a>
<el-dropdown-item divided @click="userStore.logout">
退出登录
</el-dropdown-item>
@@ -76,7 +98,14 @@ function toggleSidebar() {
overflow: hidden;
color: var(--v3-navigationbar-text-color);
display: flex;
align-items: center;
justify-content: space-between;
.navigation-bar__left {
display: flex;
align-items: center;
height: 100%;
min-width: 0;
}
.hamburger {
display: flex;
align-items: center;
@@ -90,6 +119,21 @@ function toggleSidebar() {
@media screen and (max-width: 576px) {
display: none;
}
// 面包屑在蓝色顶栏上的配色
:deep(.el-breadcrumb__inner),
:deep(.el-breadcrumb__inner a) {
color: rgba(255, 255, 255, 0.75);
font-weight: 400;
&:hover {
color: #ffffff;
}
}
:deep(.el-breadcrumb__separator) {
color: rgba(255, 255, 255, 0.5);
}
:deep(.no-redirect) {
color: #ffffff;
}
}
.sidebar {
flex: 1;
@@ -97,24 +141,60 @@ function toggleSidebar() {
min-width: 0px;
:deep(.el-menu) {
background-color: transparent;
--el-menu-text-color: rgba(255, 255, 255, 0.75);
--el-menu-active-color: #ffffff;
--el-menu-hover-bg-color: transparent;
}
:deep(.el-sub-menu) {
&.is-active {
.el-sub-menu__title {
color: var(--el-color-primary);
color: #ffffff;
}
}
}
}
// 顶部导航(对齐 school-iot-admin 的头部中间导航)
.navigation-bar__center {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
flex: 1;
min-width: 0;
overflow: hidden;
}
.top-nav-item {
display: flex;
align-items: center;
height: 100%;
margin: 0 22px;
padding: 0 2px;
font-size: 18px;
font-weight: 500;
color: #ffffff;
opacity: 0.72;
border-bottom: 3px solid transparent;
white-space: nowrap;
transition: opacity 0.2s ease;
&:hover {
opacity: 1;
}
&.active {
opacity: 1;
border-bottom-color: #ffffff;
}
}
.right-menu {
margin-right: 10px;
margin-right: 16px;
height: 100%;
display: flex;
align-items: center;
&-item {
height: 20px;
margin: 0 10px;
margin: 0 12px;
cursor: pointer;
display: flex;
align-items: center;
&:last-child {
margin-left: 20px;
}
@@ -123,10 +203,14 @@ function toggleSidebar() {
display: flex;
align-items: center;
.el-avatar {
margin-right: 10px;
margin-right: 8px;
}
span {
.user-name {
font-size: 16px;
max-width: 140px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
+53 -2
View File
@@ -1,7 +1,9 @@
<script lang="ts" setup>
import type { RouteRecordRaw } from "vue-router"
import { isExternal } from "@@/utils/validate"
import { Star, StarFilled } from "@element-plus/icons-vue"
import path from "path-browserify"
import { useFavoriteStore } from "@/pinia/stores/favorite"
import Link from "./Link.vue"
interface Props {
@@ -11,6 +13,8 @@ interface Props {
const { item, basePath = "" } = defineProps<Props>()
const favoriteStore = useFavoriteStore()
/** 是否始终显示根菜单 */
const alwaysShowRootMenu = computed(() => item.meta?.alwaysShow)
@@ -44,6 +48,24 @@ function resolvePath(routePath: string) {
return path.resolve(basePath, routePath)
}
}
/** 当前菜单项的路径 */
const menuPath = computed(() => resolvePath(theOnlyOneChild.value?.path ?? item.path))
/** 当前菜单项的名称 */
const menuTitle = computed(() => theOnlyOneChild.value?.meta?.title ?? item.meta?.title ?? "")
/** 是否已添加到常用功能 */
const isFavorite = computed(() => favoriteStore.isFavorite(menuPath.value))
/** 是否可以收藏(外部链接无法收藏) */
const canFavorite = computed(() => !!menuTitle.value && !isExternal(menuPath.value))
/** 收藏/取消收藏 */
function handleToggleFavorite() {
if (!canFavorite.value) return
favoriteStore.toggleFavorite({ path: menuPath.value, title: menuTitle.value })
}
</script>
<template>
@@ -54,6 +76,16 @@ function resolvePath(routePath: string) {
<component v-else-if="theOnlyOneChild.meta.elIcon" :is="theOnlyOneChild.meta.elIcon" class="el-icon" />
<template v-if="theOnlyOneChild.meta.title" #title>
<span class="title">{{ theOnlyOneChild.meta.title }}</span>
<el-icon
v-if="canFavorite"
class="favorite-icon"
:class="{ active: isFavorite }"
:title="isFavorite ? '取消常用功能' : '添加到常用功能'"
@click.stop="handleToggleFavorite"
>
<StarFilled v-if="isFavorite" />
<Star v-else />
</el-icon>
</template>
</el-menu-item>
</Link>
@@ -80,17 +112,36 @@ function resolvePath(routePath: string) {
.svg-icon {
min-width: 1em;
margin-right: 12px;
margin-right: 10px;
font-size: 18px;
}
.el-icon {
width: 1em !important;
margin-right: 12px !important;
margin-right: 10px !important;
font-size: 18px;
}
.title {
@extend %ellipsis;
flex: 1;
min-width: 0;
}
// 常用功能(收藏)按钮
.el-menu-item .favorite-icon {
flex: none;
width: auto !important;
margin-left: auto;
margin-right: 4px !important;
font-size: 15px;
color: #c0c4cc;
cursor: pointer;
&:hover {
color: #f5a623;
}
&.active {
color: #f5a623;
}
}
</style>
+32 -37
View File
@@ -1,22 +1,13 @@
<script lang="ts" setup>
import { useDevice } from "@@/composables/useDevice"
import { useLayoutMode } from "@@/composables/useLayoutMode"
import { getCssVar } from "@@/utils/css"
import { useAppStore } from "@/pinia/stores/app"
import { usePermissionStore } from "@/pinia/stores/permission"
import { useSettingsStore } from "@/pinia/stores/settings"
import { Logo } from "../index"
import Item from "./Item.vue"
const v3SidebarMenuBgColor = getCssVar("--v3-sidebar-menu-bg-color")
const v3SidebarMenuTextColor = getCssVar("--v3-sidebar-menu-text-color")
const v3SidebarMenuActiveTextColor = getCssVar("--v3-sidebar-menu-active-text-color")
const { isMobile } = useDevice()
const { isLeft, isTop } = useLayoutMode()
const { isTop } = useLayoutMode()
const route = useRoute()
@@ -24,40 +15,29 @@ const appStore = useAppStore()
const permissionStore = usePermissionStore()
const settingsStore = useSettingsStore()
const activeMenu = computed(() => route.meta.activeMenu || route.path)
const noHiddenRoutes = computed(() => permissionStore.routes.filter(item => !item.meta?.hidden))
const isCollapse = computed(() => !appStore.sidebar.opened)
const isLogo = computed(() => isLeft.value && settingsStore.showLogo)
const backgroundColor = computed(() => (isLeft.value ? v3SidebarMenuBgColor : undefined))
const textColor = computed(() => (isLeft.value ? v3SidebarMenuTextColor : undefined))
const activeTextColor = computed(() => (isLeft.value ? v3SidebarMenuActiveTextColor : undefined))
const sidebarMenuItemHeight = computed(() => !isTop.value ? "var(--v3-sidebar-menu-item-height)" : "var(--v3-navigationbar-height)")
const sidebarMenuHoverBgColor = computed(() => !isTop.value ? "var(--v3-sidebar-menu-hover-bg-color)" : "transparent")
const tipLineWidth = computed(() => !isTop.value ? "2px" : "0px")
const sidebarMenuActiveBgColor = computed(() => !isTop.value ? "var(--v3-sidebar-menu-active-bg-color)" : "transparent")
const tipLineWidth = computed(() => !isTop.value ? "4px" : "0px")
</script>
<template>
<div :class="{ 'has-logo': isLogo }">
<Logo v-if="isLogo" :collapse="isCollapse" />
<div class="sidebar-root">
<el-scrollbar wrap-class="scrollbar-wrapper">
<el-menu
:default-active="activeMenu"
:collapse="isCollapse && !isTop"
:background-color="backgroundColor"
:text-color="textColor"
:active-text-color="activeTextColor"
:collapse-transition="false"
:unique-opened="true"
:mode="isTop && !isMobile ? 'horizontal' : 'vertical'"
>
<Item
@@ -84,10 +64,8 @@ const tipLineWidth = computed(() => !isTop.value ? "2px" : "0px")
}
}
.has-logo {
.el-scrollbar {
height: calc(100% - var(--v3-header-height));
}
.sidebar-root {
height: 100%;
}
.el-scrollbar {
@@ -109,6 +87,11 @@ const tipLineWidth = computed(() => !isTop.value ? "2px" : "0px")
user-select: none;
border: none;
width: 100%;
// 菜单配色统一由 CSS 变量控制,方便多主题覆盖
--el-menu-bg-color: var(--v3-sidebar-menu-bg-color);
--el-menu-text-color: var(--v3-sidebar-menu-text-color);
--el-menu-active-color: var(--v3-sidebar-menu-active-text-color);
--el-menu-hover-bg-color: var(--v3-sidebar-menu-hover-bg-color);
}
.el-menu--horizontal {
@@ -121,22 +104,30 @@ const tipLineWidth = computed(() => !isTop.value ? "2px" : "0px")
:deep(.el-menu--horizontal .el-menu-item) {
height: v-bind(sidebarMenuItemHeight);
line-height: v-bind(sidebarMenuItemHeight);
font-size: 16px;
&.is-active,
&:hover {
background-color: v-bind(sidebarMenuHoverBgColor);
}
}
:deep(.el-sub-menu) {
&.is-active {
> .el-sub-menu__title {
color: v-bind(activeTextColor);
}
// 选中的菜单项(对齐 school-iot-admin:浅蓝底 + 左侧蓝色竖条 + 加粗)
:deep(.el-menu-item.is-active) {
@extend %tip-line;
background-color: v-bind(sidebarMenuActiveBgColor);
color: var(--el-menu-active-color);
font-weight: 700;
&:hover {
background-color: v-bind(sidebarMenuActiveBgColor);
}
}
:deep(.el-menu-item.is-active) {
@extend %tip-line;
:deep(.el-sub-menu) {
&.is-active {
> .el-sub-menu__title {
color: var(--el-menu-active-color);
}
}
}
.el-menu--collapse {
@@ -146,5 +137,9 @@ const tipLineWidth = computed(() => !isTop.value ? "2px" : "0px")
background-color: v-bind(sidebarMenuHoverBgColor);
}
}
// 折叠状态下隐藏常用功能入口(只保留图标)
:deep(.favorite-icon) {
display: none;
}
}
</style>
+7 -5
View File
@@ -217,12 +217,14 @@ listenerRouteChange((route) => {
justify-content: center;
position: relative;
cursor: pointer;
height: 26px;
height: 28px;
padding: 0 10px;
font-size: 13px;
border: 1px solid var(--v3-tagsview-tag-border-color);
border-bottom: none;
border-radius: var(--v3-tagsview-tag-border-radius);
background-color: var(--v3-tagsview-tag-bg-color);
padding: 0 8px;
font-size: 12px;
background: var(--v3-tagsview-tag-bg);
color: #3a9bff;
margin-left: 5px;
&:first-of-type {
margin-left: 5px;
@@ -231,7 +233,7 @@ listenerRouteChange((route) => {
margin-right: 5px;
}
&.active {
background-color: var(--v3-tagsview-tag-active-bg-color);
background: var(--v3-tagsview-tag-active-bg);
color: var(--v3-tagsview-tag-active-text-color);
border-color: var(--v3-tagsview-tag-active-border-color);
}
+2 -2
View File
@@ -39,14 +39,14 @@ const DEFAULT_CONFIG: LayoutsConfig = {
layoutMode: LayoutModeEnum.Left,
showTagsView: true,
fixedHeader: true,
showFooter: true,
showFooter: false,
showLogo: true,
showNotify: true,
showThemeSwitch: true,
showScreenfull: true,
showSearchMenu: true,
cacheTagsView: false,
showWatermark: true,
showWatermark: false,
showGreyMode: false,
showColorWeakness: false
}
+63 -63
View File
@@ -3,7 +3,7 @@ import { useDevice } from "@@/composables/useDevice"
import { useLayoutMode } from "@@/composables/useLayoutMode"
import { useAppStore } from "@/pinia/stores/app"
import { useSettingsStore } from "@/pinia/stores/settings"
import { AppMain, NavigationBar, Sidebar, TagsView } from "../components"
import { AppMain, Logo, NavigationBar, Sidebar, TagsView } from "../components"
const { isMobile } = useDevice()
@@ -13,7 +13,7 @@ const appStore = useAppStore()
const settingsStore = useSettingsStore()
const { showTagsView, fixedHeader } = storeToRefs(settingsStore)
const { showTagsView, fixedHeader, showLogo } = storeToRefs(settingsStore)
/** 定义计算属性 layoutClasses,用于控制布局的类名 */
const layoutClasses = computed(() => {
@@ -36,15 +36,17 @@ function handleClickOutside() {
<div :class="layoutClasses" class="app-wrapper">
<!-- mobile 端侧边栏遮罩层 -->
<div v-if="layoutClasses.mobile && layoutClasses['open-sidebar']" class="drawer-bg" @click="handleClickOutside" />
<!-- 头部导航栏(通栏,蓝色品牌色) -->
<div :class="{ 'fixed-header': fixedHeader }" class="layout-header">
<Logo v-if="showLogo" :collapse="false" class="logo" />
<NavigationBar class="navigation-bar" />
</div>
<!-- 左侧边栏 -->
<Sidebar class="sidebar-container" />
<!-- 主容器 -->
<div :class="{ 'has-tags-view': showTagsView }" class="main-container">
<!-- 头部导航栏和标签栏 -->
<div :class="{ 'fixed-header': fixedHeader }" class="layout-header">
<NavigationBar />
<TagsView v-show="showTagsView" />
</div>
<div :class="{ 'has-tags-view': showTagsView, 'has-fixed-header': fixedHeader }" class="main-container">
<!-- 标签栏 -->
<TagsView v-show="showTagsView" class="tags-view" />
<!-- 页面主体内容 -->
<AppMain class="app-main" />
</div>
@@ -59,6 +61,7 @@ $transition-time: 0.35s;
@extend %clearfix;
position: relative;
width: 100%;
height: 100%;
}
.drawer-bg {
@@ -70,65 +73,73 @@ $transition-time: 0.35s;
z-index: 2001;
}
.sidebar-container {
background-color: var(--v3-sidebar-menu-bg-color);
transition: width $transition-time;
width: var(--v3-sidebar-width);
height: 100%;
position: fixed;
top: 0;
bottom: 0;
left: 0;
z-index: 2002;
overflow: hidden;
border-right: var(--v3-sidebar-border-right);
}
.main-container {
min-height: 100%;
transition: margin-left $transition-time;
margin-left: var(--v3-sidebar-width);
// 头部导航栏
.layout-header {
position: relative;
z-index: 2003;
display: flex;
align-items: center;
height: var(--v3-navigationbar-height);
background-color: var(--v3-header-bg-color);
color: var(--v3-navigationbar-text-color);
box-shadow: var(--v3-header-box-shadow);
border-bottom: var(--v3-header-border-bottom);
.logo {
flex: none;
padding-left: 20px;
}
.navigation-bar {
flex: 1;
min-width: 0;
}
}
.fixed-header {
position: fixed !important;
top: 0;
left: 0;
right: 0;
z-index: 9;
width: calc(100% - var(--v3-sidebar-width));
}
// 左侧边栏
.sidebar-container {
position: fixed;
top: var(--v3-navigationbar-height);
left: 0;
bottom: 0;
width: var(--v3-sidebar-width);
z-index: 2002;
overflow: hidden;
background-color: var(--v3-sidebar-menu-bg-color);
border-top-right-radius: 10px;
box-shadow: 0 0 10px rgba(32, 76, 131, 0.12);
transition: width $transition-time;
}
.layout-header {
position: relative;
z-index: 9;
background-color: var(--v3-header-bg-color);
box-shadow: var(--v3-header-box-shadow);
border-bottom: var(--v3-header-border-bottom);
}
.app-main {
min-height: calc(100vh - var(--v3-navigationbar-height));
position: relative;
overflow: hidden;
}
.fixed-header + .app-main {
padding-top: var(--v3-navigationbar-height);
// 主容器
.main-container {
height: 100vh;
overflow: auto;
margin-left: var(--v3-sidebar-width);
display: flex;
flex-direction: column;
overflow: hidden;
transition: margin-left $transition-time;
}
.has-fixed-header {
padding-top: var(--v3-navigationbar-height);
}
.tags-view {
flex: none;
}
.has-tags-view {
.app-main {
min-height: calc(100vh - var(--v3-header-height));
}
.fixed-header + .app-main {
padding-top: var(--v3-header-height);
}
flex: 1;
min-height: 0;
}
// 侧边栏折叠
.hide-sidebar {
.sidebar-container {
width: var(--v3-sidebar-hide-width);
@@ -136,9 +147,6 @@ $transition-time: 0.35s;
.main-container {
margin-left: var(--v3-sidebar-hide-width);
}
.fixed-header {
width: calc(100% - var(--v3-sidebar-hide-width));
}
}
// 适配 mobile 端
@@ -150,12 +158,10 @@ $transition-time: 0.35s;
.main-container {
margin-left: 0px;
}
.fixed-header {
width: 100%;
}
&.open-sidebar {
position: fixed;
top: 0;
width: 100%;
}
&.hide-sidebar {
.sidebar-container {
@@ -164,12 +170,6 @@ $transition-time: 0.35s;
transform: translate3d(calc(0px - var(--v3-sidebar-width)), 0, 0);
}
}
// 既是 mobile 又是顶部或混合布局模式
&.no-left {
.sidebar-container {
background-color: var(--el-bg-color);
}
}
}
.without-animation {
+39 -35
View File
@@ -19,18 +19,17 @@ const layoutClasses = computed(() => {
<template>
<div :class="layoutClasses" class="app-wrapper">
<!-- 头部导航栏和标签栏 -->
<!-- 头部导航栏 -->
<div class="fixed-header layout-header">
<Logo v-if="showLogo" :collapse="false" class="logo" />
<div class="content">
<NavigationBar />
<TagsView v-show="showTagsView" />
<NavigationBar class="navigation-bar" />
</div>
</div>
<!-- 主容器 -->
<div :class="{ 'has-tags-view': showTagsView }" class="main-container">
<!-- 左侧边栏 -->
<Sidebar class="sidebar-container" />
<!-- 主容器 -->
<div class="main-container">
<!-- 标签栏 -->
<TagsView v-show="showTagsView" class="tags-view" />
<!-- 页面主体内容 -->
<AppMain class="app-main" />
</div>
@@ -44,70 +43,75 @@ $transition-time: 0.35s;
.app-wrapper {
@extend %clearfix;
width: 100%;
height: 100%;
}
.fixed-header {
position: fixed;
top: 0;
z-index: 1002;
width: 100%;
left: 0;
right: 0;
z-index: 2003;
display: flex;
align-items: center;
height: var(--v3-navigationbar-height);
.logo {
flex: none;
width: var(--v3-sidebar-width);
padding-left: 20px;
}
.content {
.navigation-bar {
flex: 1;
overflow: hidden;
min-width: 0;
}
}
.layout-header {
background-color: var(--v3-header-bg-color);
color: var(--v3-navigationbar-text-color);
box-shadow: var(--v3-header-box-shadow);
border-bottom: var(--v3-header-border-bottom);
}
.main-container {
min-height: 100%;
height: 100vh;
margin-left: var(--v3-sidebar-width);
padding-top: var(--v3-navigationbar-height);
display: flex;
flex-direction: column;
overflow: hidden;
transition: margin-left $transition-time;
}
.sidebar-container {
background-color: var(--el-menu-bg-color);
transition: width $transition-time;
width: var(--v3-sidebar-width);
height: 100%;
position: fixed;
top: var(--v3-navigationbar-height);
left: 0;
z-index: 1001;
bottom: 0;
width: var(--v3-sidebar-width);
z-index: 2002;
overflow: hidden;
border-right: var(--v3-sidebar-border-right);
padding-top: var(--v3-navigationbar-height);
background-color: var(--v3-sidebar-menu-bg-color);
border-top-right-radius: 10px;
box-shadow: 0 0 10px rgba(32, 76, 131, 0.12);
transition: width $transition-time;
}
.tags-view {
flex: none;
}
.app-main {
transition: padding-left $transition-time;
padding-top: var(--v3-navigationbar-height);
padding-left: var(--v3-sidebar-width);
height: 100vh;
overflow: auto;
flex: 1;
min-height: 0;
}
.hide-sidebar {
.sidebar-container {
width: var(--v3-sidebar-hide-width);
}
.app-main {
padding-left: var(--v3-sidebar-hide-width);
}
}
.has-tags-view {
.sidebar-container {
padding-top: var(--v3-header-height);
}
.app-main {
padding-top: var(--v3-header-height);
.main-container {
margin-left: var(--v3-sidebar-hide-width);
}
}
</style>
+25 -21
View File
@@ -9,16 +9,15 @@ const { showTagsView, showLogo } = storeToRefs(settingsStore)
<template>
<div class="app-wrapper">
<!-- 头部导航栏和标签栏 -->
<!-- 头部导航栏 -->
<div class="fixed-header layout-header">
<div class="content">
<Logo v-if="showLogo" :collapse="false" class="logo" />
<NavigationBar class="navigation-bar" />
</div>
<TagsView v-show="showTagsView" />
</div>
<!-- 主容器 -->
<div :class="{ 'has-tags-view': showTagsView }" class="main-container">
<!-- 标签栏 -->
<TagsView v-show="showTagsView" class="tags-view" />
<!-- 页面主体内容 -->
<AppMain class="app-main" />
</div>
@@ -32,44 +31,49 @@ $transition-time: 0.35s;
.app-wrapper {
@extend %clearfix;
width: 100%;
height: 100%;
}
.fixed-header {
position: fixed;
top: 0;
z-index: 1002;
width: 100%;
.logo {
width: var(--v3-sidebar-width);
}
.content {
left: 0;
right: 0;
z-index: 2003;
display: flex;
align-items: center;
height: var(--v3-navigationbar-height);
.logo {
flex: none;
padding-left: 20px;
}
.navigation-bar {
flex: 1;
}
min-width: 0;
}
}
.layout-header {
background-color: var(--v3-header-bg-color);
color: var(--v3-navigationbar-text-color);
box-shadow: var(--v3-header-box-shadow);
border-bottom: var(--v3-header-border-bottom);
}
.main-container {
min-height: 100%;
}
.app-main {
transition: padding-left $transition-time;
padding-top: var(--v3-navigationbar-height);
height: 100vh;
overflow: auto;
padding-top: var(--v3-navigationbar-height);
display: flex;
flex-direction: column;
overflow: hidden;
}
.has-tags-view {
.app-main {
padding-top: var(--v3-header-height);
.tags-view {
flex: none;
}
.app-main {
flex: 1;
min-height: 0;
}
</style>
+70 -14
View File
@@ -1,19 +1,75 @@
import type * as Auth from "./type"
import { request } from "@/http/axios"
import type * as Login from "./type"
import axios from "axios"
/** 获取登录验证码 */
export function getCaptchaApi() {
return request<Auth.CaptchaResponseData>({
url: "auth/captcha",
method: "get"
})
/** serverschool OAuth2 客户端信息(与 school-iot-admin 保持一致,公开客户端) */
const OAUTH_CLIENT_ID = "client"
const OAUTH_CLIENT_SECRET = "123456"
const OAUTH_GRANT_TYPE = "password"
/** serverschool 的 Result 信封(/wx、/sms 等老接口同样是这个结构) */
export interface SchoolResult<T = unknown> {
success: boolean
msg?: string
code?: number
data?: T
}
/** 登录并返回 Token */
export function loginApi(data: Auth.LoginRequestData) {
return request<Auth.LoginResponseData>({
url: "auth/login",
method: "post",
data
const baseURL = import.meta.env.VITE_SCHOOL_BASE_URL
/**
* 登录并返回 Token
* /oauth/token 返回的是 OAuth2 令牌结构(非 Result 信封格式),所以单独用 axios 请求
* 账号或密码错误时后端返回 400,axios 会直接 reject
*/
export function loginApi(data: Login.LoginRequestData) {
return axios
.get<Login.OAuthToken>(`${baseURL}/oauth/token`, {
params: {
username: data.username,
password: data.password,
client_id: OAUTH_CLIENT_ID,
client_secret: OAUTH_CLIENT_SECRET,
grant_type: OAUTH_GRANT_TYPE
},
timeout: 10000
})
.then(res => res.data)
}
/**
* 根据手机号查询账号列表(对齐 school-iot-admin 的 /wx/getAccoutList)
* 返回 [{ userName, teamName, ... }],查不到时 success=false
*/
export function getAccountListApi(phone: string) {
return axios
.get<SchoolResult<Login.AccountItem[]>>(`${baseURL}/wx/getAccoutList`, {
params: { phone },
timeout: 10000
})
.then(res => res.data)
}
/** 发送短信验证码(POST /sms/sendCode?phone=) */
export function sendSmsCodeApi(phone: string) {
return axios
.post<SchoolResult<number>>(`${baseURL}/sms/sendCode`, null, {
params: { phone },
timeout: 15000
})
.then(res => res.data)
}
/** 校验验证码并重置密码(POST /sms/verifyCode?userName=&newPwd=&phone=&code=) */
export function verifySmsCodeApi(data: Login.ResetPasswordData) {
return axios
.post<SchoolResult>(`${baseURL}/sms/verifyCode`, null, {
params: {
userName: data.userName,
newPwd: data.newPassword,
phone: data.phone,
code: data.code
},
timeout: 15000
})
.then(res => res.data)
}
+30 -6
View File
@@ -1,12 +1,36 @@
/** 登录请求数据 */
export interface LoginRequestData {
/** admin 或 editor */
username: "admin" | "editor"
/** 用户名(输入 11 位手机号可查询对应账号) */
username: string
/** 密码 */
password: string
/** 验证码 */
code: string
}
export type CaptchaResponseData = ApiResponseData<string>
/** OAuth2 令牌响应(/oauth/token) */
export interface OAuthToken {
access_token: string
token_type: string
refresh_token?: string
expires_in: number
scope?: string
}
export type LoginResponseData = ApiResponseData<{ token: string }>
/** 手机号查询出的账号(/wx/getAccoutList) */
export interface AccountItem {
userName: string
/** 所属学校/分组名称 */
teamName?: string
[key: string]: unknown
}
/** 短信验证码重置密码(/sms/verifyCode) */
export interface ResetPasswordData {
/** 用户名 */
userName: string
/** 新密码 */
newPassword: string
/** 手机号 */
phone: string
/** 短信验证码 */
code: string
}
+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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

+378 -137
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()
saveKeepLogin()
const redirect = route.query.redirect
await router.push(redirect ? decodeURIComponent(redirect as string) : "/")
} catch {
userStore.resetToken()
errorText.value = "账户或密码错误"
loginFormData.password = ""
}).finally(() => {
} 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">
{{ LOGIN_TITLE }}
</div>
<div class="content">
<el-form ref="loginFormRef" :model="loginFormData" :rules="loginFormRules" @keyup.enter="handleLogin">
<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-input
v-model.trim="loginFormData.username"
placeholder="用户名"
type="text"
<el-select
v-model="loginFormData.username"
placeholder="请输入用户名或手机号"
filterable
allow-create
clearable
default-first-option
:filter-method="filterOption"
tabindex="1"
:prefix-icon="User"
size="large"
/>
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="密码"
placeholder="请输入密码"
type="password"
tabindex="2"
:prefix-icon="Lock"
size="large"
show-password
@blur="handleBlur"
@focus="handleFocus"
autocomplete="current-password"
/>
</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 />
</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>
<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;
}
.login-card {
width: 480px;
max-width: 90%;
border-radius: 20px;
box-shadow: 0 0 10px #dcdfe6;
background-color: var(--el-bg-color);
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 {
display: flex;
justify-content: center;
align-items: center;
height: 150px;
img {
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;
}
.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;
.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 {
}
.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%;
margin-top: 10px;
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;
}
}
}
+38
View File
@@ -0,0 +1,38 @@
import type { FavoriteMenu } from "@@/types/menu"
import { getFavoriteMenus, setFavoriteMenus } from "@@/utils/local-storage"
import { pinia } from "@/pinia"
/** 常用功能 Store */
export const useFavoriteStore = defineStore("favorite", () => {
// 常用功能(被收藏的菜单项)
const favorites = ref<FavoriteMenu[]>(getFavoriteMenus())
// 缓存常用功能数据
watchEffect(() => {
setFavoriteMenus(favorites.value)
})
/** 判断某个菜单是否已被收藏 */
function isFavorite(path: string) {
return favorites.value.some(item => item.path === path)
}
/** 收藏/取消收藏某个菜单 */
function toggleFavorite(menu: FavoriteMenu) {
if (isFavorite(menu.path)) {
favorites.value = favorites.value.filter(item => item.path !== menu.path)
} else {
favorites.value = [...favorites.value, menu]
}
}
return { favorites, isFavorite, toggleFavorite }
})
/**
* @description 在 SPA 应用中可用于在 pinia 实例被激活前使用 store
* @description 在 SSR 应用中可用于在 setup 外使用 store
*/
export function useFavoriteStoreOutside() {
return useFavoriteStore(pinia)
}
+45 -4
View File
@@ -1,3 +1,4 @@
import type { AccountDetails } from "@@/apis/users/type"
import { getCurrentUserApi } from "@@/apis/users"
import { setToken as _setToken, getToken, removeToken } from "@@/utils/local-storage"
import { pinia } from "@/pinia"
@@ -5,6 +6,9 @@ import { resetRouter, router } from "@/router"
import { useSettingsStore } from "./settings"
import { useTagsViewStore } from "./tags-view"
/** 账号类型:3 表示无权限登录管理后台(与 school-iot-admin 约定一致) */
export const ACCOUNT_TYPE_FORBIDDEN = 3
export const useUserStore = defineStore("user", () => {
const token = ref<string>(getToken() || "")
@@ -16,6 +20,21 @@ export const useUserStore = defineStore("user", () => {
const isGotUserInfo = ref<boolean>(false)
/** 当前账号完整信息(serverschool AccountDetails) */
const accountInfo = ref<AccountDetails | null>(null)
/** 账号 ID */
const accountId = computed(() => accountInfo.value?.id)
/** 账号类型 */
const accountType = computed(() => accountInfo.value?.type)
/** 所属学校/分组 ID(多租户) */
const teamId = computed(() => accountInfo.value?.teamId)
/** 角色 ID */
const roleId = computed(() => accountInfo.value?.role?.id)
const tagsViewStore = useTagsViewStore()
const settingsStore = useSettingsStore()
@@ -29,9 +48,13 @@ export const useUserStore = defineStore("user", () => {
// 获取用户详情
const getInfo = async () => {
const { data } = await getCurrentUserApi()
username.value = data.username
roles.value = data.roles ?? []
permissions.value = data.permissions ?? []
// 后端 AccountDetails 会把密码一起序列化回来,这里剔除后再保存
const { pwd: _pwd, password: _password, ...safeAccount } = data as AccountDetails & { pwd?: string, password?: string }
accountInfo.value = safeAccount as AccountDetails
username.value = data.userName
// serverschool 的角色/权限体系与模板不同,暂不映射,只放行常驻路由
roles.value = []
permissions.value = []
// 防止路由守卫逻辑进入无限循环
isGotUserInfo.value = true
}
@@ -60,6 +83,7 @@ export const useUserStore = defineStore("user", () => {
token.value = ""
roles.value = []
permissions.value = []
accountInfo.value = null
isGotUserInfo.value = false
}
@@ -71,7 +95,24 @@ export const useUserStore = defineStore("user", () => {
}
}
return { token, roles, permissions, username, isGotUserInfo, setToken, getInfo, changeUser, logout, resetToken }
return {
token,
roles,
permissions,
username,
isGotUserInfo,
accountInfo,
accountId,
accountType,
teamId,
roleId,
setToken,
getInfo,
changeUser,
logout,
resetToken,
resetTagsView
}
})
/**
+4 -3
View File
@@ -29,11 +29,12 @@ export function registerNavigationGuard(router: Router) {
}
// 如果已经登录,并准备进入 Login 页面,则重定向到主页
if (to.path === LOGIN_PATH) return "/"
// 如果用户已经获得其权限信息
if (userStore.isGotUserInfo) return true
// 用户信息和路由都已就绪,直接放行
if (userStore.isGotUserInfo && permissionStore.routes.length > 0) return true
// 否则要重新获取权限信息
try {
await userStore.getInfo()
// 已获取过用户信息(如登录页里已经调过 getInfo)就不再重复请求
userStore.isGotUserInfo || (await userStore.getInfo())
// 注意:角色和权限必须是数组! 例如: ["admin"] 或 ["permission:page-level"]
const { roles, permissions } = userStore
// 生成可访问的 Routes
+1
View File
@@ -21,6 +21,7 @@ declare module 'vue' {
ElButton: typeof import('element-plus/es')['ElButton']
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDialog: typeof import('element-plus/es')['ElDialog']
+19
View File
@@ -4,6 +4,25 @@ interface ImportMetaEnv {
readonly VITE_BASE_URL: string
readonly VITE_ROUTER_HISTORY: "hash" | "html5"
readonly VITE_PUBLIC_PATH: string
/** serverschool 后端接口前缀(如 /hxyd-api) */
readonly VITE_SCHOOL_BASE_URL: string
/** 开发环境 /hxyd-api 代理目标(仅 vite.config.ts 使用) */
readonly VITE_SCHOOL_TARGET: string
/** kkFileView 文档预览服务 */
readonly VITE_KKFILEVIEW_URL?: string
/** 问卷系统 */
readonly VITE_QUESTION_URL?: string
/** 可视化数据大屏 */
readonly VITE_DATA_SCREEN?: string
/** 新生填报 H5 */
readonly VITE_APPLY_URL?: string
/** 数据填报表单 */
readonly VITE_API_FORM?: string
/** 以下为 vite.config.ts 代理目标(仅开发环境使用) */
readonly VITE_WS_TARGET?: string
readonly VITE_VIDEO_TARGET?: string
readonly VITE_VIDEO_MINIO?: string
readonly VITE_AGENT_DIALOGUE_TARGET?: string
}
interface ImportMeta {
+124 -4
View File
@@ -13,7 +13,14 @@ import svgLoader from "vite-svg-loader"
// Configuring Vite: https://cn.vite.dev/config
export default defineConfig(({ mode }) => {
const { VITE_PUBLIC_PATH } = loadEnv(mode, process.cwd(), "") as ImportMetaEnv
const {
VITE_PUBLIC_PATH,
VITE_SCHOOL_TARGET,
VITE_WS_TARGET,
VITE_VIDEO_TARGET,
VITE_VIDEO_MINIO,
VITE_AGENT_DIALOGUE_TARGET
} = loadEnv(mode, process.cwd(), "") as ImportMetaEnv
return {
// 开发或打包构建时用到的公共基础路径
base: VITE_PUBLIC_PATH,
@@ -35,14 +42,127 @@ export default defineConfig(({ mode }) => {
strictPort: false,
// 是否自动打开浏览器
open: true,
// 反向代理
// 反向代理(从 school-iot-admin/vue.config.js 的 devServer.proxy 搬运,默认连生产环境)
// 目标地址优先读 .env.development 里的 VITE_*_TARGET,未配置时用下面的默认值
proxy: {
// apifoxmock(demo / FastCrud 演示页)
"/api/v1": {
target: "https://apifoxmock.com/m1/2930465-2145633-default",
// 是否为 WebSocket
ws: false,
// 是否允许跨域
changeOrigin: true
},
// serverschool 后端(登录 / 业务主接口)
"/hxyd-api": {
target: VITE_SCHOOL_TARGET || "https://band.hxzhxy.cn",
ws: false,
changeOrigin: true,
secure: false,
// 去掉前缀后转发,例如 /hxyd-api/oauth/token -> /oauth/token
rewrite: path => path.replace(/^\/hxyd-api/, "")
},
// 测试后端(老平台 203 环境)
"/hxyd-test": {
target: "http://203.25.213.15:8100",
ws: false,
changeOrigin: true,
secure: false,
rewrite: path => path.replace(/^\/hxyd-test/, "")
},
// 智能体对话(score_agent / LangGraph,本机起服务)
"/agent-dialogue-api": {
target: VITE_AGENT_DIALOGUE_TARGET || "http://127.0.0.1:3030",
ws: false,
changeOrigin: true,
secure: false,
rewrite: path => path.replace(/^\/agent-dialogue-api/, "")
},
// LangGraph 智能体服务
"/hxyd-langgraph": {
target: "http://192.168.0.111:2027",
ws: false,
changeOrigin: true,
secure: false,
rewrite: path => path.replace(/^\/hxyd-langgraph/, "")
},
// Dify
"/hxyd-dify": {
target: "https://net2.hxzhxy.cn:5093",
ws: false,
changeOrigin: true,
secure: false,
rewrite: path => path.replace(/^\/hxyd-dify/, "")
},
// AI 服务(yolo 识别等)
"/hxyd-ai": {
target: "http://100.64.0.25:8080",
ws: false,
changeOrigin: true,
secure: false,
rewrite: path => path.replace(/^\/hxyd-ai/, "")
},
// 成绩智能体后端(205)
"/school-api": {
target: "http://192.168.0.205:8100",
ws: false,
changeOrigin: true,
secure: false,
rewrite: path => path.replace(/^\/school-api/, "")
},
// yolo 抓拍图片(minio),必须放在 /hxyd-video 前面(更前缀优先匹配)
"/hxyd-video/yolo": {
target: VITE_VIDEO_MINIO || "http://11.1.20.107:9000",
ws: false,
changeOrigin: true,
secure: false,
// 与老平台一致:只去掉 /hxyd-video,保留 /yolo
rewrite: path => path.replace(/^\/hxyd-video/, "")
},
// 视频流(wvp / jessibuca 拉流)
"/hxyd-video": {
target: VITE_VIDEO_TARGET || "http://11.1.20.107:8000",
ws: true,
changeOrigin: true,
secure: false,
rewrite: path => path.replace(/^\/hxyd-video/, "")
},
// WebSocket
"/ws-api": {
target: VITE_WS_TARGET || "ws://139.159.182.121:8100",
ws: true,
changeOrigin: true,
rewrite: path => path.replace(/^\/ws-api/, "")
},
// WebRTC 流媒体
"/webrtc": {
target: "http://192.168.0.244:8082",
ws: false,
changeOrigin: true,
rewrite: path => path.replace(/^\/webrtc/, "")
},
// 个人测试后端
"/xtl": {
target: "http://192.168.0.10:8100",
ws: false,
changeOrigin: true,
rewrite: path => path.replace(/^\/xtl/, "")
},
"/tangchao": {
target: "http://192.168.0.209:8100",
ws: false,
changeOrigin: true,
rewrite: path => path.replace(/^\/tangchao/, "")
},
"/huanghui": {
target: "http://192.168.0.205:8100",
ws: false,
changeOrigin: true,
rewrite: path => path.replace(/^\/huanghui/, "")
},
"/xiatianle": {
target: "http://3.3.3.3:8100",
ws: false,
changeOrigin: true,
rewrite: path => path.replace(/^\/xiatianle/, "")
}
},
// 是否允许跨域