Template
Align console UI with school-iot-admin and wire login to serverschool
UI / layout - rebuild left mode: full-width blue header (60px) + white sidebar below + card-style tags view - introduce design tokens in variables.css (brand #347df9, sidebar 240px, menu item 49px) and theme overrides for dark / dark-blue themes - restyle navigation bar (logo + platform name / top nav / tools + user), logo, sidebar (selected state: light blue bg + blue bar + bold), tags view, app main - global styles: .app-container white card, .head-title, .btn-blue/.btn-white, el-table / el-dialog look, brand color injected into --el-color-primary - add favorites store (localStorage) wired to sidebar star and header top nav - disable template watermark / footer / promo notifications by default Login (serverschool) - port school-iot-admin login page (background art, right side card, keep-login) - OAuth2 password mode: GET /oauth/token (raw axios) + GET /uc/account/current - port phone lookup (/wx/getAccoutList) and forget-password flow (/sms/sendCode + /sms/verifyCode) as components/login/Forget.vue - user store exposes accountInfo / accountType / teamId / roleId and strips password Infra - move all devServer proxies and env vars from school-iot-admin/vue.config.js (default targets point to production) - fix empty sidebar: guard short-circuit skipped setRoutes when login page already fetched user info
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
esbuild: true
|
||||
-11
@@ -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>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* 内容区放大时,将不需要的组件隐藏 */
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,7 @@
|
||||
/** 常用功能(被收藏的菜单项) */
|
||||
export interface FavoriteMenu {
|
||||
/** 菜单路径 */
|
||||
path: string
|
||||
/** 菜单名称 */
|
||||
title: string
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
// 主容器
|
||||
.main-container {
|
||||
height: 100vh;
|
||||
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;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
min-height: calc(100vh - var(--v3-navigationbar-height));
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fixed-header + .app-main {
|
||||
padding-top: var(--v3-navigationbar-height);
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
height: 100vh;
|
||||
padding-top: var(--v3-navigationbar-height);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tags-view {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
transition: padding-left $transition-time;
|
||||
padding-top: var(--v3-navigationbar-height);
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.has-tags-view {
|
||||
.app-main {
|
||||
padding-top: var(--v3-header-height);
|
||||
}
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Vendored
+1
@@ -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']
|
||||
|
||||
Vendored
+19
@@ -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
@@ -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/, "")
|
||||
}
|
||||
},
|
||||
// 是否允许跨域
|
||||
|
||||
Reference in New Issue
Block a user