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_ 开头)
|
||||||
|
|
||||||
## 项目标题
|
## 项目标题
|
||||||
VITE_APP_TITLE = V3 Admin Vite
|
VITE_APP_TITLE = 校园物联网管理平台
|
||||||
|
|
||||||
## 路由模式 hash 或 html5
|
## 路由模式 hash 或 html5
|
||||||
VITE_ROUTER_HISTORY = hash
|
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_ 开头)
|
# 开发环境的环境变量(命名必须以 VITE_ 开头)
|
||||||
|
|
||||||
## 后端接口地址(如果解决跨域问题采用反向代理就只需写相对路径)
|
## 后端接口地址(apifoxmock,demo / FastCrud 演示页用)
|
||||||
VITE_BASE_URL = /api/v1
|
VITE_BASE_URL = /api/v1
|
||||||
|
|
||||||
## 开发环境域名和静态资源公共路径(一般 / 或 ./ 都可以)
|
## 开发环境域名和静态资源公共路径(一般 / 或 ./ 都可以)
|
||||||
VITE_PUBLIC_PATH = /
|
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>
|
<script lang="ts" setup>
|
||||||
import { useGreyAndColorWeakness } from "@@/composables/useGreyAndColorWeakness"
|
import { useGreyAndColorWeakness } from "@@/composables/useGreyAndColorWeakness"
|
||||||
import { usePany } from "@@/composables/usePany"
|
|
||||||
import { useTheme } from "@@/composables/useTheme"
|
import { useTheme } from "@@/composables/useTheme"
|
||||||
import zhCn from "element-plus/es/locale/lang/zh-cn" // Element Plus 中文包
|
import zhCn from "element-plus/es/locale/lang/zh-cn" // Element Plus 中文包
|
||||||
|
|
||||||
@@ -8,21 +7,11 @@ const { initTheme } = useTheme()
|
|||||||
|
|
||||||
const { initGreyAndColorWeakness } = useGreyAndColorWeakness()
|
const { initGreyAndColorWeakness } = useGreyAndColorWeakness()
|
||||||
|
|
||||||
const { initStarNotification, initStoreNotification, initMobileNotification } = usePany()
|
|
||||||
|
|
||||||
// 初始化主题
|
// 初始化主题
|
||||||
initTheme()
|
initTheme()
|
||||||
|
|
||||||
// 初始化灰色模式和色弱模式
|
// 初始化灰色模式和色弱模式
|
||||||
initGreyAndColorWeakness()
|
initGreyAndColorWeakness()
|
||||||
|
|
||||||
// #region 初始化通知
|
|
||||||
initStarNotification()
|
|
||||||
|
|
||||||
initStoreNotification()
|
|
||||||
|
|
||||||
initMobileNotification()
|
|
||||||
// #endregion
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import type * as Users from "./type"
|
import type * as Users from "./type"
|
||||||
import { request } from "@/http/axios"
|
import { request } from "@/http/axios"
|
||||||
|
|
||||||
/** 获取当前登录用户详情 */
|
/**
|
||||||
|
* 获取当前登录用户详情(serverschool /uc/account/current)
|
||||||
|
* 返回的 data 中包含 pwd 等敏感字段,调用方只取必要字段,不要整体入库/展示
|
||||||
|
*/
|
||||||
export function getCurrentUserApi() {
|
export function getCurrentUserApi() {
|
||||||
return request<Users.CurrentUserResponseData>({
|
return request<Users.CurrentUserResponseData>({
|
||||||
url: "users/me",
|
baseURL: import.meta.env.VITE_SCHOOL_BASE_URL,
|
||||||
method: "get"
|
url: "uc/account/current",
|
||||||
|
method: "get",
|
||||||
|
timeout: 10000
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
export type CurrentUserResponseData = ApiResponseData<{
|
/** serverschool 账号信息(/uc/account/current 返回的 AccountDetails,已裁剪无用字段) */
|
||||||
username: string
|
export interface AccountDetails {
|
||||||
roles: string[]
|
/** 账号 ID */
|
||||||
permissions?: string[]
|
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 样式
|
// 自定义 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 {
|
.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";
|
@import "./view-transition.scss";
|
||||||
|
|
||||||
// 业务页面几乎都应该在根元素上挂载 class="app-container",以保持页面美观
|
// 业务页面几乎都应该在根元素上挂载 class="app-container",以保持页面美观
|
||||||
|
// 视觉规范对齐 school-iot-admin:白卡片 + 10px 圆角 + 淡蓝阴影
|
||||||
.app-container {
|
.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 {
|
html {
|
||||||
|
|||||||
@@ -4,3 +4,30 @@
|
|||||||
$theme-name: "dark-blue";
|
$theme-name: "dark-blue";
|
||||||
// 主题背景颜色
|
// 主题背景颜色
|
||||||
$theme-bg-color: #001b44;
|
$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-name: "dark";
|
||||||
// 主题背景颜色
|
// 主题背景颜色
|
||||||
$theme-bg-color: #141414;
|
$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 {
|
:root {
|
||||||
/* Body */
|
/* Body */
|
||||||
--v3-body-text-color: var(--el-text-color-primary);
|
--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 组件 */
|
/* Header 区域 = NavigationBar 组件 + TagsView 组件 */
|
||||||
--v3-header-height: calc(
|
--v3-header-height: calc(var(--v3-navigationbar-height) + var(--v3-tagsview-height));
|
||||||
var(--v3-navigationbar-height) + var(--v3-tagsview-height) + var(--v3-header-border-bottom-width)
|
--v3-header-bg-color: #347df9;
|
||||||
);
|
--v3-header-box-shadow: none;
|
||||||
--v3-header-bg-color: var(--el-bg-color);
|
--v3-header-border-bottom-width: 0px;
|
||||||
--v3-header-box-shadow: var(--el-box-shadow-lighter);
|
--v3-header-border-bottom: none;
|
||||||
--v3-header-border-bottom-width: 1px;
|
|
||||||
--v3-header-border-bottom: var(--v3-header-border-bottom-width) solid var(--el-fill-color);
|
|
||||||
/* NavigationBar 组件 */
|
/* NavigationBar 组件 */
|
||||||
--v3-navigationbar-height: 50px;
|
--v3-navigationbar-height: 60px;
|
||||||
--v3-navigationbar-text-color: var(--el-text-color-regular);
|
--v3-navigationbar-text-color: #ffffff;
|
||||||
/* Sidebar 组件(左侧模式全部生效、顶部模式全部不生效、混合模式非颜色部分生效) */
|
/* Sidebar 组件(左侧模式全部生效、顶部模式全部不生效、混合模式非颜色部分生效) */
|
||||||
--v3-sidebar-width: 220px;
|
--v3-sidebar-width: 240px;
|
||||||
--v3-sidebar-hide-width: 58px;
|
--v3-sidebar-hide-width: 64px;
|
||||||
--v3-sidebar-border-right: 1px solid var(--el-fill-color);
|
--v3-sidebar-border-right: none;
|
||||||
--v3-sidebar-menu-item-height: 60px;
|
--v3-sidebar-menu-item-height: 49px;
|
||||||
--v3-sidebar-menu-tip-line-bg-color: var(--el-color-primary);
|
--v3-sidebar-menu-tip-line-bg-color: #347df9;
|
||||||
--v3-sidebar-menu-bg-color: #001428;
|
--v3-sidebar-menu-bg-color: #ffffff;
|
||||||
--v3-sidebar-menu-hover-bg-color: #409eff10;
|
--v3-sidebar-menu-hover-bg-color: #f2f6fc;
|
||||||
--v3-sidebar-menu-text-color: #cfd3dc;
|
--v3-sidebar-menu-active-bg-color: #ebf5ff;
|
||||||
--v3-sidebar-menu-active-text-color: #ffffff;
|
--v3-sidebar-menu-text-color: #303133;
|
||||||
|
--v3-sidebar-menu-active-text-color: #347df9;
|
||||||
/* TagsView 组件 */
|
/* TagsView 组件 */
|
||||||
--v3-tagsview-height: 34px;
|
--v3-tagsview-height: 36px;
|
||||||
--v3-tagsview-text-color: var(--el-text-color-regular);
|
--v3-tagsview-text-color: #303133;
|
||||||
--v3-tagsview-tag-active-text-color: #ffffff;
|
--v3-tagsview-tag-active-text-color: #ffffff;
|
||||||
--v3-tagsview-tag-bg-color: var(--el-bg-color);
|
--v3-tagsview-tag-bg-color: #ffffff;
|
||||||
--v3-tagsview-tag-active-bg-color: var(--el-color-primary);
|
--v3-tagsview-tag-bg: linear-gradient(to bottom, #ffffff 20%, #90c8f7);
|
||||||
--v3-tagsview-tag-border-radius: 2px;
|
--v3-tagsview-tag-active-bg-color: #347df9;
|
||||||
--v3-tagsview-tag-border-color: var(--el-border-color-lighter);
|
--v3-tagsview-tag-active-bg: linear-gradient(to bottom, #56bafb, #0577fa);
|
||||||
--v3-tagsview-tag-active-border-color: var(--el-color-primary);
|
--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-bg-color: #00000030;
|
||||||
--v3-tagsview-tag-icon-hover-color: #ffffff;
|
--v3-tagsview-tag-icon-hover-color: #ffffff;
|
||||||
--v3-tagsview-contextmenu-text-color: var(--el-text-color-regular);
|
--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-hover-bg-color: var(--el-fill-color);
|
||||||
--v3-tagsview-contextmenu-box-shadow: var(--el-box-shadow);
|
--v3-tagsview-contextmenu-box-shadow: var(--el-box-shadow);
|
||||||
/* Hamburger 组件 */
|
/* Hamburger 组件 */
|
||||||
--v3-hamburger-text-color: var(--el-text-color-primary);
|
--v3-hamburger-text-color: #ffffff;
|
||||||
/* RightPanel 组件 */
|
/* 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 ACTIVE_THEME_NAME = `${SYSTEM_NAME}-active-theme-name-key`
|
||||||
static readonly VISITED_VIEWS = `${SYSTEM_NAME}-visited-views-key`
|
static readonly VISITED_VIEWS = `${SYSTEM_NAME}-visited-views-key`
|
||||||
static readonly CACHED_VIEWS = `${SYSTEM_NAME}-cached-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 { ThemeName } from "@@/composables/useTheme"
|
||||||
import type { SidebarClosed, SidebarOpened } from "@@/constants/app-key"
|
import type { SidebarClosed, SidebarOpened } from "@@/constants/app-key"
|
||||||
|
import type { FavoriteMenu } from "@@/types/menu"
|
||||||
import type { LayoutsConfig } from "@/layouts/config"
|
import type { LayoutsConfig } from "@/layouts/config"
|
||||||
import type { TagView } from "@/pinia/stores/tags-view"
|
import type { TagView } from "@/pinia/stores/tags-view"
|
||||||
import { CacheKey } from "@@/constants/cache-key"
|
import { CacheKey } from "@@/constants/cache-key"
|
||||||
@@ -79,3 +80,29 @@ export function setCachedViews(views: string[]) {
|
|||||||
localStorage.setItem(CacheKey.CACHED_VIEWS, JSON.stringify(views))
|
localStorage.setItem(CacheKey.CACHED_VIEWS, JSON.stringify(views))
|
||||||
}
|
}
|
||||||
// #endregion
|
// #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 过期时
|
// Token 过期时
|
||||||
return useUserStore().logout()
|
return useUserStore().logout()
|
||||||
default:
|
default:
|
||||||
// 不是正确的 code
|
// 不是正确的 code(serverschool 信封的错误信息字段是 msg)
|
||||||
ElMessage.error(apiData.message || "Error")
|
ElMessage.error(apiData.message || apiData.msg || "Error")
|
||||||
return Promise.reject(apiData)
|
return Promise.reject(apiData)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ const settingsStore = useSettingsStore()
|
|||||||
.app-scrollbar {
|
.app-scrollbar {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
// 内容区留白(对齐 school-iot-admin 的内容区内边距)
|
||||||
|
padding: 16px;
|
||||||
@extend %scrollbar;
|
@extend %scrollbar;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,47 +1,74 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import logoText1 from "@@/assets/images/layouts/logo-text-1.png?url"
|
import platformLogo from "@@/assets/images/layouts/platform-logo.png?url"
|
||||||
import logoText2 from "@@/assets/images/layouts/logo-text-2.png?url"
|
|
||||||
import logo from "@@/assets/images/layouts/logo.png?url"
|
|
||||||
import { useLayoutMode } from "@@/composables/useLayoutMode"
|
import { useLayoutMode } from "@@/composables/useLayoutMode"
|
||||||
|
import { PLATFORM_NAME, PLATFORM_NAME_EN } from "@@/constants/platform"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
/** 是否折叠(折叠时只显示 Logo 图标) */
|
||||||
collapse?: boolean
|
collapse?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const { collapse = true } = defineProps<Props>()
|
const { collapse = false } = defineProps<Props>()
|
||||||
|
|
||||||
const { isLeft, isTop } = useLayoutMode()
|
const { isTop } = useLayoutMode()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="layout-logo-container" :class="{ 'collapse': collapse, 'layout-mode-top': isTop }">
|
<div class="layout-logo-container" :class="{ 'collapse': collapse, 'layout-mode-top': isTop }">
|
||||||
<transition name="layout-logo-fade">
|
<router-link to="/">
|
||||||
<router-link v-if="collapse" key="collapse" to="/">
|
<img :src="platformLogo" class="layout-logo" alt="logo">
|
||||||
<img :src="logo" class="layout-logo">
|
<div v-if="!collapse" class="platform-name">
|
||||||
</router-link>
|
<span class="platform-name-cn">{{ PLATFORM_NAME }}</span>
|
||||||
<router-link v-else key="expand" to="/">
|
<span class="platform-name-en">{{ PLATFORM_NAME_EN }}</span>
|
||||||
<img :src="!isLeft ? logoText2 : logoText1" class="layout-logo-text">
|
</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
</transition>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.layout-logo-container {
|
.layout-logo-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: auto;
|
||||||
height: var(--v3-header-height);
|
max-width: 100%;
|
||||||
|
height: var(--v3-navigationbar-height);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
align-items: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
a {
|
a {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
color: #ffffff;
|
||||||
.layout-logo {
|
.layout-logo {
|
||||||
display: none;
|
flex: none;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #ffffff;
|
||||||
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
.layout-logo-text {
|
.platform-name {
|
||||||
height: 100%;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,12 +79,7 @@ const { isLeft, isTop } = useLayoutMode()
|
|||||||
|
|
||||||
.collapse {
|
.collapse {
|
||||||
a {
|
a {
|
||||||
.layout-logo {
|
.platform-name {
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
.layout-logo-text {
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,24 +5,39 @@ import SearchMenu from "@@/components/SearchMenu/index.vue"
|
|||||||
import ThemeSwitch from "@@/components/ThemeSwitch/index.vue"
|
import ThemeSwitch from "@@/components/ThemeSwitch/index.vue"
|
||||||
import { useDevice } from "@@/composables/useDevice"
|
import { useDevice } from "@@/composables/useDevice"
|
||||||
import { useLayoutMode } from "@@/composables/useLayoutMode"
|
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 { useAppStore } from "@/pinia/stores/app"
|
||||||
|
import { useFavoriteStore } from "@/pinia/stores/favorite"
|
||||||
import { useSettingsStore } from "@/pinia/stores/settings"
|
import { useSettingsStore } from "@/pinia/stores/settings"
|
||||||
import { useUserStore } from "@/pinia/stores/user"
|
import { useUserStore } from "@/pinia/stores/user"
|
||||||
|
import { DASHBOARD_PATH } from "@/router/config"
|
||||||
import { Breadcrumb, Hamburger, Sidebar } from "../index"
|
import { Breadcrumb, Hamburger, Sidebar } from "../index"
|
||||||
|
|
||||||
const { isMobile } = useDevice()
|
const { isMobile } = useDevice()
|
||||||
|
|
||||||
const { isTop } = useLayoutMode()
|
const { isTop } = useLayoutMode()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
|
const favoriteStore = useFavoriteStore()
|
||||||
|
|
||||||
const { showNotify, showThemeSwitch, showScreenfull, showSearchMenu } = storeToRefs(settingsStore)
|
const { showNotify, showThemeSwitch, showScreenfull, showSearchMenu } = storeToRefs(settingsStore)
|
||||||
|
|
||||||
|
/** 顶部导航 = 首页 + 常用功能 */
|
||||||
|
const topNavs = computed(() => [
|
||||||
|
{ path: DASHBOARD_PATH, title: "首页" },
|
||||||
|
...favoriteStore.favorites
|
||||||
|
])
|
||||||
|
|
||||||
|
/** 用户名 */
|
||||||
|
const username = computed(() => userStore.username || "未登录")
|
||||||
|
|
||||||
/** 切换侧边栏 */
|
/** 切换侧边栏 */
|
||||||
function toggleSidebar() {
|
function toggleSidebar() {
|
||||||
appStore.toggleSidebar(false)
|
appStore.toggleSidebar(false)
|
||||||
@@ -31,35 +46,42 @@ function toggleSidebar() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="navigation-bar">
|
<div class="navigation-bar">
|
||||||
<Hamburger
|
<div class="navigation-bar__left">
|
||||||
v-if="!isTop || isMobile"
|
<Hamburger
|
||||||
:is-active="appStore.sidebar.opened"
|
v-if="!isTop || isMobile"
|
||||||
class="hamburger"
|
:is-active="appStore.sidebar.opened"
|
||||||
@toggle-click="toggleSidebar"
|
class="hamburger"
|
||||||
/>
|
@toggle-click="toggleSidebar"
|
||||||
<Breadcrumb v-if="!isTop || isMobile" class="breadcrumb" />
|
/>
|
||||||
<Sidebar v-if="isTop && !isMobile" class="sidebar" />
|
<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">
|
<div class="right-menu">
|
||||||
<SearchMenu v-if="showSearchMenu" class="right-menu-item" />
|
<SearchMenu v-if="showSearchMenu" class="right-menu-item" />
|
||||||
<Screenfull v-if="showScreenfull" class="right-menu-item" />
|
<Screenfull v-if="showScreenfull" class="right-menu-item" />
|
||||||
<ThemeSwitch v-if="showThemeSwitch" class="right-menu-item" />
|
<ThemeSwitch v-if="showThemeSwitch" class="right-menu-item" />
|
||||||
<Notify v-if="showNotify" class="right-menu-item" />
|
<Notify v-if="showNotify" class="right-menu-item" />
|
||||||
<el-dropdown>
|
<el-dropdown trigger="click">
|
||||||
<div class="right-menu-item user">
|
<div class="right-menu-item user">
|
||||||
<el-avatar :icon="UserFilled" :size="30" />
|
<el-avatar :icon="UserFilled" :size="32" />
|
||||||
<span>{{ userStore.username }}</span>
|
<span class="user-name">{{ username }}</span>
|
||||||
|
<el-icon :size="12">
|
||||||
|
<ArrowDown />
|
||||||
|
</el-icon>
|
||||||
</div>
|
</div>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<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 divided @click="userStore.logout">
|
||||||
退出登录
|
退出登录
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
@@ -76,7 +98,14 @@ function toggleSidebar() {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--v3-navigationbar-text-color);
|
color: var(--v3-navigationbar-text-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
.navigation-bar__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
.hamburger {
|
.hamburger {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -90,6 +119,21 @@ function toggleSidebar() {
|
|||||||
@media screen and (max-width: 576px) {
|
@media screen and (max-width: 576px) {
|
||||||
display: none;
|
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 {
|
.sidebar {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -97,24 +141,60 @@ function toggleSidebar() {
|
|||||||
min-width: 0px;
|
min-width: 0px;
|
||||||
:deep(.el-menu) {
|
:deep(.el-menu) {
|
||||||
background-color: transparent;
|
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) {
|
:deep(.el-sub-menu) {
|
||||||
&.is-active {
|
&.is-active {
|
||||||
.el-sub-menu__title {
|
.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 {
|
.right-menu {
|
||||||
margin-right: 10px;
|
margin-right: 16px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
&-item {
|
&-item {
|
||||||
height: 20px;
|
height: 20px;
|
||||||
margin: 0 10px;
|
margin: 0 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
&:last-child {
|
&:last-child {
|
||||||
margin-left: 20px;
|
margin-left: 20px;
|
||||||
}
|
}
|
||||||
@@ -123,10 +203,14 @@ function toggleSidebar() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
.el-avatar {
|
.el-avatar {
|
||||||
margin-right: 10px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
span {
|
.user-name {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
max-width: 140px;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { RouteRecordRaw } from "vue-router"
|
import type { RouteRecordRaw } from "vue-router"
|
||||||
import { isExternal } from "@@/utils/validate"
|
import { isExternal } from "@@/utils/validate"
|
||||||
|
import { Star, StarFilled } from "@element-plus/icons-vue"
|
||||||
import path from "path-browserify"
|
import path from "path-browserify"
|
||||||
|
import { useFavoriteStore } from "@/pinia/stores/favorite"
|
||||||
import Link from "./Link.vue"
|
import Link from "./Link.vue"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -11,6 +13,8 @@ interface Props {
|
|||||||
|
|
||||||
const { item, basePath = "" } = defineProps<Props>()
|
const { item, basePath = "" } = defineProps<Props>()
|
||||||
|
|
||||||
|
const favoriteStore = useFavoriteStore()
|
||||||
|
|
||||||
/** 是否始终显示根菜单 */
|
/** 是否始终显示根菜单 */
|
||||||
const alwaysShowRootMenu = computed(() => item.meta?.alwaysShow)
|
const alwaysShowRootMenu = computed(() => item.meta?.alwaysShow)
|
||||||
|
|
||||||
@@ -44,6 +48,24 @@ function resolvePath(routePath: string) {
|
|||||||
return path.resolve(basePath, routePath)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -54,6 +76,16 @@ function resolvePath(routePath: string) {
|
|||||||
<component v-else-if="theOnlyOneChild.meta.elIcon" :is="theOnlyOneChild.meta.elIcon" class="el-icon" />
|
<component v-else-if="theOnlyOneChild.meta.elIcon" :is="theOnlyOneChild.meta.elIcon" class="el-icon" />
|
||||||
<template v-if="theOnlyOneChild.meta.title" #title>
|
<template v-if="theOnlyOneChild.meta.title" #title>
|
||||||
<span class="title">{{ theOnlyOneChild.meta.title }}</span>
|
<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>
|
</template>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -80,17 +112,36 @@ function resolvePath(routePath: string) {
|
|||||||
|
|
||||||
.svg-icon {
|
.svg-icon {
|
||||||
min-width: 1em;
|
min-width: 1em;
|
||||||
margin-right: 12px;
|
margin-right: 10px;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-icon {
|
.el-icon {
|
||||||
width: 1em !important;
|
width: 1em !important;
|
||||||
margin-right: 12px !important;
|
margin-right: 10px !important;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
@extend %ellipsis;
|
@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>
|
</style>
|
||||||
|
|||||||
@@ -1,22 +1,13 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useDevice } from "@@/composables/useDevice"
|
import { useDevice } from "@@/composables/useDevice"
|
||||||
import { useLayoutMode } from "@@/composables/useLayoutMode"
|
import { useLayoutMode } from "@@/composables/useLayoutMode"
|
||||||
import { getCssVar } from "@@/utils/css"
|
|
||||||
import { useAppStore } from "@/pinia/stores/app"
|
import { useAppStore } from "@/pinia/stores/app"
|
||||||
import { usePermissionStore } from "@/pinia/stores/permission"
|
import { usePermissionStore } from "@/pinia/stores/permission"
|
||||||
import { useSettingsStore } from "@/pinia/stores/settings"
|
|
||||||
import { Logo } from "../index"
|
|
||||||
import Item from "./Item.vue"
|
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 { isMobile } = useDevice()
|
||||||
|
|
||||||
const { isLeft, isTop } = useLayoutMode()
|
const { isTop } = useLayoutMode()
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
@@ -24,40 +15,29 @@ const appStore = useAppStore()
|
|||||||
|
|
||||||
const permissionStore = usePermissionStore()
|
const permissionStore = usePermissionStore()
|
||||||
|
|
||||||
const settingsStore = useSettingsStore()
|
|
||||||
|
|
||||||
const activeMenu = computed(() => route.meta.activeMenu || route.path)
|
const activeMenu = computed(() => route.meta.activeMenu || route.path)
|
||||||
|
|
||||||
const noHiddenRoutes = computed(() => permissionStore.routes.filter(item => !item.meta?.hidden))
|
const noHiddenRoutes = computed(() => permissionStore.routes.filter(item => !item.meta?.hidden))
|
||||||
|
|
||||||
const isCollapse = computed(() => !appStore.sidebar.opened)
|
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 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 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="{ 'has-logo': isLogo }">
|
<div class="sidebar-root">
|
||||||
<Logo v-if="isLogo" :collapse="isCollapse" />
|
|
||||||
<el-scrollbar wrap-class="scrollbar-wrapper">
|
<el-scrollbar wrap-class="scrollbar-wrapper">
|
||||||
<el-menu
|
<el-menu
|
||||||
:default-active="activeMenu"
|
:default-active="activeMenu"
|
||||||
:collapse="isCollapse && !isTop"
|
:collapse="isCollapse && !isTop"
|
||||||
:background-color="backgroundColor"
|
|
||||||
:text-color="textColor"
|
|
||||||
:active-text-color="activeTextColor"
|
|
||||||
:collapse-transition="false"
|
:collapse-transition="false"
|
||||||
|
:unique-opened="true"
|
||||||
:mode="isTop && !isMobile ? 'horizontal' : 'vertical'"
|
:mode="isTop && !isMobile ? 'horizontal' : 'vertical'"
|
||||||
>
|
>
|
||||||
<Item
|
<Item
|
||||||
@@ -84,10 +64,8 @@ const tipLineWidth = computed(() => !isTop.value ? "2px" : "0px")
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.has-logo {
|
.sidebar-root {
|
||||||
.el-scrollbar {
|
height: 100%;
|
||||||
height: calc(100% - var(--v3-header-height));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-scrollbar {
|
.el-scrollbar {
|
||||||
@@ -109,6 +87,11 @@ const tipLineWidth = computed(() => !isTop.value ? "2px" : "0px")
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
border: none;
|
border: none;
|
||||||
width: 100%;
|
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 {
|
.el-menu--horizontal {
|
||||||
@@ -121,22 +104,30 @@ const tipLineWidth = computed(() => !isTop.value ? "2px" : "0px")
|
|||||||
:deep(.el-menu--horizontal .el-menu-item) {
|
:deep(.el-menu--horizontal .el-menu-item) {
|
||||||
height: v-bind(sidebarMenuItemHeight);
|
height: v-bind(sidebarMenuItemHeight);
|
||||||
line-height: v-bind(sidebarMenuItemHeight);
|
line-height: v-bind(sidebarMenuItemHeight);
|
||||||
|
font-size: 16px;
|
||||||
&.is-active,
|
&.is-active,
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: v-bind(sidebarMenuHoverBgColor);
|
background-color: v-bind(sidebarMenuHoverBgColor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-sub-menu) {
|
// 选中的菜单项(对齐 school-iot-admin:浅蓝底 + 左侧蓝色竖条 + 加粗)
|
||||||
&.is-active {
|
:deep(.el-menu-item.is-active) {
|
||||||
> .el-sub-menu__title {
|
@extend %tip-line;
|
||||||
color: v-bind(activeTextColor);
|
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) {
|
:deep(.el-sub-menu) {
|
||||||
@extend %tip-line;
|
&.is-active {
|
||||||
|
> .el-sub-menu__title {
|
||||||
|
color: var(--el-menu-active-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-menu--collapse {
|
.el-menu--collapse {
|
||||||
@@ -146,5 +137,9 @@ const tipLineWidth = computed(() => !isTop.value ? "2px" : "0px")
|
|||||||
background-color: v-bind(sidebarMenuHoverBgColor);
|
background-color: v-bind(sidebarMenuHoverBgColor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 折叠状态下隐藏常用功能入口(只保留图标)
|
||||||
|
:deep(.favorite-icon) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -217,12 +217,14 @@ listenerRouteChange((route) => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
height: 26px;
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
border: 1px solid var(--v3-tagsview-tag-border-color);
|
border: 1px solid var(--v3-tagsview-tag-border-color);
|
||||||
|
border-bottom: none;
|
||||||
border-radius: var(--v3-tagsview-tag-border-radius);
|
border-radius: var(--v3-tagsview-tag-border-radius);
|
||||||
background-color: var(--v3-tagsview-tag-bg-color);
|
background: var(--v3-tagsview-tag-bg);
|
||||||
padding: 0 8px;
|
color: #3a9bff;
|
||||||
font-size: 12px;
|
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
&:first-of-type {
|
&:first-of-type {
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
@@ -231,7 +233,7 @@ listenerRouteChange((route) => {
|
|||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
&.active {
|
&.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);
|
color: var(--v3-tagsview-tag-active-text-color);
|
||||||
border-color: var(--v3-tagsview-tag-active-border-color);
|
border-color: var(--v3-tagsview-tag-active-border-color);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,14 +39,14 @@ const DEFAULT_CONFIG: LayoutsConfig = {
|
|||||||
layoutMode: LayoutModeEnum.Left,
|
layoutMode: LayoutModeEnum.Left,
|
||||||
showTagsView: true,
|
showTagsView: true,
|
||||||
fixedHeader: true,
|
fixedHeader: true,
|
||||||
showFooter: true,
|
showFooter: false,
|
||||||
showLogo: true,
|
showLogo: true,
|
||||||
showNotify: true,
|
showNotify: true,
|
||||||
showThemeSwitch: true,
|
showThemeSwitch: true,
|
||||||
showScreenfull: true,
|
showScreenfull: true,
|
||||||
showSearchMenu: true,
|
showSearchMenu: true,
|
||||||
cacheTagsView: false,
|
cacheTagsView: false,
|
||||||
showWatermark: true,
|
showWatermark: false,
|
||||||
showGreyMode: false,
|
showGreyMode: false,
|
||||||
showColorWeakness: false
|
showColorWeakness: false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useDevice } from "@@/composables/useDevice"
|
|||||||
import { useLayoutMode } from "@@/composables/useLayoutMode"
|
import { useLayoutMode } from "@@/composables/useLayoutMode"
|
||||||
import { useAppStore } from "@/pinia/stores/app"
|
import { useAppStore } from "@/pinia/stores/app"
|
||||||
import { useSettingsStore } from "@/pinia/stores/settings"
|
import { useSettingsStore } from "@/pinia/stores/settings"
|
||||||
import { AppMain, NavigationBar, Sidebar, TagsView } from "../components"
|
import { AppMain, Logo, NavigationBar, Sidebar, TagsView } from "../components"
|
||||||
|
|
||||||
const { isMobile } = useDevice()
|
const { isMobile } = useDevice()
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ const appStore = useAppStore()
|
|||||||
|
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
const { showTagsView, fixedHeader } = storeToRefs(settingsStore)
|
const { showTagsView, fixedHeader, showLogo } = storeToRefs(settingsStore)
|
||||||
|
|
||||||
/** 定义计算属性 layoutClasses,用于控制布局的类名 */
|
/** 定义计算属性 layoutClasses,用于控制布局的类名 */
|
||||||
const layoutClasses = computed(() => {
|
const layoutClasses = computed(() => {
|
||||||
@@ -36,15 +36,17 @@ function handleClickOutside() {
|
|||||||
<div :class="layoutClasses" class="app-wrapper">
|
<div :class="layoutClasses" class="app-wrapper">
|
||||||
<!-- mobile 端侧边栏遮罩层 -->
|
<!-- mobile 端侧边栏遮罩层 -->
|
||||||
<div v-if="layoutClasses.mobile && layoutClasses['open-sidebar']" class="drawer-bg" @click="handleClickOutside" />
|
<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" />
|
<Sidebar class="sidebar-container" />
|
||||||
<!-- 主容器 -->
|
<!-- 主容器 -->
|
||||||
<div :class="{ 'has-tags-view': showTagsView }" class="main-container">
|
<div :class="{ 'has-tags-view': showTagsView, 'has-fixed-header': fixedHeader }" class="main-container">
|
||||||
<!-- 头部导航栏和标签栏 -->
|
<!-- 标签栏 -->
|
||||||
<div :class="{ 'fixed-header': fixedHeader }" class="layout-header">
|
<TagsView v-show="showTagsView" class="tags-view" />
|
||||||
<NavigationBar />
|
|
||||||
<TagsView v-show="showTagsView" />
|
|
||||||
</div>
|
|
||||||
<!-- 页面主体内容 -->
|
<!-- 页面主体内容 -->
|
||||||
<AppMain class="app-main" />
|
<AppMain class="app-main" />
|
||||||
</div>
|
</div>
|
||||||
@@ -59,6 +61,7 @@ $transition-time: 0.35s;
|
|||||||
@extend %clearfix;
|
@extend %clearfix;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-bg {
|
.drawer-bg {
|
||||||
@@ -70,65 +73,73 @@ $transition-time: 0.35s;
|
|||||||
z-index: 2001;
|
z-index: 2001;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-container {
|
// 头部导航栏
|
||||||
background-color: var(--v3-sidebar-menu-bg-color);
|
.layout-header {
|
||||||
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);
|
|
||||||
position: relative;
|
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 {
|
.fixed-header {
|
||||||
position: fixed !important;
|
position: fixed !important;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
left: 0;
|
||||||
right: 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;
|
transition: width $transition-time;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-header {
|
// 主容器
|
||||||
position: relative;
|
.main-container {
|
||||||
z-index: 9;
|
height: 100vh;
|
||||||
background-color: var(--v3-header-bg-color);
|
margin-left: var(--v3-sidebar-width);
|
||||||
box-shadow: var(--v3-header-box-shadow);
|
display: flex;
|
||||||
border-bottom: var(--v3-header-border-bottom);
|
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 {
|
.app-main {
|
||||||
min-height: calc(100vh - var(--v3-navigationbar-height));
|
flex: 1;
|
||||||
position: relative;
|
min-height: 0;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 侧边栏折叠
|
||||||
.hide-sidebar {
|
.hide-sidebar {
|
||||||
.sidebar-container {
|
.sidebar-container {
|
||||||
width: var(--v3-sidebar-hide-width);
|
width: var(--v3-sidebar-hide-width);
|
||||||
@@ -136,9 +147,6 @@ $transition-time: 0.35s;
|
|||||||
.main-container {
|
.main-container {
|
||||||
margin-left: var(--v3-sidebar-hide-width);
|
margin-left: var(--v3-sidebar-hide-width);
|
||||||
}
|
}
|
||||||
.fixed-header {
|
|
||||||
width: calc(100% - var(--v3-sidebar-hide-width));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 适配 mobile 端
|
// 适配 mobile 端
|
||||||
@@ -150,12 +158,10 @@ $transition-time: 0.35s;
|
|||||||
.main-container {
|
.main-container {
|
||||||
margin-left: 0px;
|
margin-left: 0px;
|
||||||
}
|
}
|
||||||
.fixed-header {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
&.open-sidebar {
|
&.open-sidebar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
&.hide-sidebar {
|
&.hide-sidebar {
|
||||||
.sidebar-container {
|
.sidebar-container {
|
||||||
@@ -164,12 +170,6 @@ $transition-time: 0.35s;
|
|||||||
transform: translate3d(calc(0px - var(--v3-sidebar-width)), 0, 0);
|
transform: translate3d(calc(0px - var(--v3-sidebar-width)), 0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 既是 mobile 又是顶部或混合布局模式
|
|
||||||
&.no-left {
|
|
||||||
.sidebar-container {
|
|
||||||
background-color: var(--el-bg-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.without-animation {
|
.without-animation {
|
||||||
|
|||||||
@@ -19,18 +19,17 @@ const layoutClasses = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="layoutClasses" class="app-wrapper">
|
<div :class="layoutClasses" class="app-wrapper">
|
||||||
<!-- 头部导航栏和标签栏 -->
|
<!-- 头部导航栏 -->
|
||||||
<div class="fixed-header layout-header">
|
<div class="fixed-header layout-header">
|
||||||
<Logo v-if="showLogo" :collapse="false" class="logo" />
|
<Logo v-if="showLogo" :collapse="false" class="logo" />
|
||||||
<div class="content">
|
<NavigationBar class="navigation-bar" />
|
||||||
<NavigationBar />
|
|
||||||
<TagsView v-show="showTagsView" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 左侧边栏 -->
|
||||||
|
<Sidebar class="sidebar-container" />
|
||||||
<!-- 主容器 -->
|
<!-- 主容器 -->
|
||||||
<div :class="{ 'has-tags-view': showTagsView }" class="main-container">
|
<div class="main-container">
|
||||||
<!-- 左侧边栏 -->
|
<!-- 标签栏 -->
|
||||||
<Sidebar class="sidebar-container" />
|
<TagsView v-show="showTagsView" class="tags-view" />
|
||||||
<!-- 页面主体内容 -->
|
<!-- 页面主体内容 -->
|
||||||
<AppMain class="app-main" />
|
<AppMain class="app-main" />
|
||||||
</div>
|
</div>
|
||||||
@@ -44,70 +43,75 @@ $transition-time: 0.35s;
|
|||||||
.app-wrapper {
|
.app-wrapper {
|
||||||
@extend %clearfix;
|
@extend %clearfix;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fixed-header {
|
.fixed-header {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 1002;
|
left: 0;
|
||||||
width: 100%;
|
right: 0;
|
||||||
|
z-index: 2003;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: var(--v3-navigationbar-height);
|
||||||
.logo {
|
.logo {
|
||||||
flex: none;
|
flex: none;
|
||||||
width: var(--v3-sidebar-width);
|
width: var(--v3-sidebar-width);
|
||||||
|
padding-left: 20px;
|
||||||
}
|
}
|
||||||
.content {
|
.navigation-bar {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-header {
|
.layout-header {
|
||||||
background-color: var(--v3-header-bg-color);
|
background-color: var(--v3-header-bg-color);
|
||||||
|
color: var(--v3-navigationbar-text-color);
|
||||||
box-shadow: var(--v3-header-box-shadow);
|
box-shadow: var(--v3-header-box-shadow);
|
||||||
border-bottom: var(--v3-header-border-bottom);
|
border-bottom: var(--v3-header-border-bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-container {
|
.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 {
|
.sidebar-container {
|
||||||
background-color: var(--el-menu-bg-color);
|
|
||||||
transition: width $transition-time;
|
|
||||||
width: var(--v3-sidebar-width);
|
|
||||||
height: 100%;
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
top: var(--v3-navigationbar-height);
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 1001;
|
bottom: 0;
|
||||||
|
width: var(--v3-sidebar-width);
|
||||||
|
z-index: 2002;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-right: var(--v3-sidebar-border-right);
|
background-color: var(--v3-sidebar-menu-bg-color);
|
||||||
padding-top: var(--v3-navigationbar-height);
|
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 {
|
.app-main {
|
||||||
transition: padding-left $transition-time;
|
flex: 1;
|
||||||
padding-top: var(--v3-navigationbar-height);
|
min-height: 0;
|
||||||
padding-left: var(--v3-sidebar-width);
|
|
||||||
height: 100vh;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hide-sidebar {
|
.hide-sidebar {
|
||||||
.sidebar-container {
|
.sidebar-container {
|
||||||
width: var(--v3-sidebar-hide-width);
|
width: var(--v3-sidebar-hide-width);
|
||||||
}
|
}
|
||||||
.app-main {
|
.main-container {
|
||||||
padding-left: var(--v3-sidebar-hide-width);
|
margin-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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -9,16 +9,15 @@ const { showTagsView, showLogo } = storeToRefs(settingsStore)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="app-wrapper">
|
<div class="app-wrapper">
|
||||||
<!-- 头部导航栏和标签栏 -->
|
<!-- 头部导航栏 -->
|
||||||
<div class="fixed-header layout-header">
|
<div class="fixed-header layout-header">
|
||||||
<div class="content">
|
<Logo v-if="showLogo" :collapse="false" class="logo" />
|
||||||
<Logo v-if="showLogo" :collapse="false" class="logo" />
|
<NavigationBar class="navigation-bar" />
|
||||||
<NavigationBar class="navigation-bar" />
|
|
||||||
</div>
|
|
||||||
<TagsView v-show="showTagsView" />
|
|
||||||
</div>
|
</div>
|
||||||
<!-- 主容器 -->
|
<!-- 主容器 -->
|
||||||
<div :class="{ 'has-tags-view': showTagsView }" class="main-container">
|
<div :class="{ 'has-tags-view': showTagsView }" class="main-container">
|
||||||
|
<!-- 标签栏 -->
|
||||||
|
<TagsView v-show="showTagsView" class="tags-view" />
|
||||||
<!-- 页面主体内容 -->
|
<!-- 页面主体内容 -->
|
||||||
<AppMain class="app-main" />
|
<AppMain class="app-main" />
|
||||||
</div>
|
</div>
|
||||||
@@ -32,44 +31,49 @@ $transition-time: 0.35s;
|
|||||||
.app-wrapper {
|
.app-wrapper {
|
||||||
@extend %clearfix;
|
@extend %clearfix;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fixed-header {
|
.fixed-header {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 1002;
|
left: 0;
|
||||||
width: 100%;
|
right: 0;
|
||||||
|
z-index: 2003;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: var(--v3-navigationbar-height);
|
||||||
.logo {
|
.logo {
|
||||||
width: var(--v3-sidebar-width);
|
flex: none;
|
||||||
|
padding-left: 20px;
|
||||||
}
|
}
|
||||||
.content {
|
.navigation-bar {
|
||||||
display: flex;
|
flex: 1;
|
||||||
.navigation-bar {
|
min-width: 0;
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-header {
|
.layout-header {
|
||||||
background-color: var(--v3-header-bg-color);
|
background-color: var(--v3-header-bg-color);
|
||||||
|
color: var(--v3-navigationbar-text-color);
|
||||||
box-shadow: var(--v3-header-box-shadow);
|
box-shadow: var(--v3-header-box-shadow);
|
||||||
border-bottom: var(--v3-header-border-bottom);
|
border-bottom: var(--v3-header-border-bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-container {
|
.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 {
|
.app-main {
|
||||||
transition: padding-left $transition-time;
|
flex: 1;
|
||||||
padding-top: var(--v3-navigationbar-height);
|
min-height: 0;
|
||||||
height: 100vh;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.has-tags-view {
|
|
||||||
.app-main {
|
|
||||||
padding-top: var(--v3-header-height);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,19 +1,75 @@
|
|||||||
import type * as Auth from "./type"
|
import type * as Login from "./type"
|
||||||
import { request } from "@/http/axios"
|
import axios from "axios"
|
||||||
|
|
||||||
/** 获取登录验证码 */
|
/** serverschool OAuth2 客户端信息(与 school-iot-admin 保持一致,公开客户端) */
|
||||||
export function getCaptchaApi() {
|
const OAUTH_CLIENT_ID = "client"
|
||||||
return request<Auth.CaptchaResponseData>({
|
const OAUTH_CLIENT_SECRET = "123456"
|
||||||
url: "auth/captcha",
|
const OAUTH_GRANT_TYPE = "password"
|
||||||
method: "get"
|
|
||||||
})
|
/** serverschool 的 Result 信封(/wx、/sms 等老接口同样是这个结构) */
|
||||||
|
export interface SchoolResult<T = unknown> {
|
||||||
|
success: boolean
|
||||||
|
msg?: string
|
||||||
|
code?: number
|
||||||
|
data?: T
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 登录并返回 Token */
|
const baseURL = import.meta.env.VITE_SCHOOL_BASE_URL
|
||||||
export function loginApi(data: Auth.LoginRequestData) {
|
|
||||||
return request<Auth.LoginResponseData>({
|
/**
|
||||||
url: "auth/login",
|
* 登录并返回 Token
|
||||||
method: "post",
|
* /oauth/token 返回的是 OAuth2 令牌结构(非 Result 信封格式),所以单独用 axios 请求
|
||||||
data
|
* 账号或密码错误时后端返回 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 {
|
export interface LoginRequestData {
|
||||||
/** admin 或 editor */
|
/** 用户名(输入 11 位手机号可查询对应账号) */
|
||||||
username: "admin" | "editor"
|
username: string
|
||||||
/** 密码 */
|
/** 密码 */
|
||||||
password: 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 |
+399
-158
@@ -1,13 +1,12 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { FormRules } from "element-plus"
|
import type { FormInstance, FormRules } from "element-plus"
|
||||||
import type { LoginRequestData } from "./apis/type"
|
import type { AccountItem, LoginRequestData } from "./apis/type"
|
||||||
import ThemeSwitch from "@@/components/ThemeSwitch/index.vue"
|
import { LOGIN_TITLE } from "@@/constants/platform"
|
||||||
import { Key, Loading, Lock, Picture, User } from "@element-plus/icons-vue"
|
import { getKeepLogin, removeKeepLogin, setKeepLogin } from "@@/utils/local-storage"
|
||||||
import { useSettingsStore } from "@/pinia/stores/settings"
|
import { CircleCloseFilled, Iphone, Lock } from "@element-plus/icons-vue"
|
||||||
import { useUserStore } from "@/pinia/stores/user"
|
import { ACCOUNT_TYPE_FORBIDDEN, useUserStore } from "@/pinia/stores/user"
|
||||||
import { getCaptchaApi, loginApi } from "./apis"
|
import { getAccountListApi, loginApi } from "./apis"
|
||||||
import Owl from "./components/Owl.vue"
|
import Forget from "./components/Forget.vue"
|
||||||
import { useFocus } from "./composables/useFocus"
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
@@ -15,194 +14,436 @@ const router = useRouter()
|
|||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const settingsStore = useSettingsStore()
|
|
||||||
|
|
||||||
const { isFocus, handleBlur, handleFocus } = useFocus()
|
|
||||||
|
|
||||||
/** 登录表单元素的引用 */
|
/** 登录表单元素的引用 */
|
||||||
const loginFormRef = useTemplateRef("loginFormRef")
|
const loginFormRef = useTemplateRef<FormInstance>("loginFormRef")
|
||||||
|
|
||||||
/** 登录按钮 Loading */
|
/** 登录按钮 Loading */
|
||||||
const loading = ref(false)
|
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({
|
const loginFormData: LoginRequestData = reactive({
|
||||||
username: "admin",
|
username: "",
|
||||||
password: "12345678",
|
password: ""
|
||||||
code: ""
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 登录表单校验规则 */
|
/** 登录表单校验规则 */
|
||||||
const loginFormRules: FormRules = {
|
const loginFormRules: FormRules = {
|
||||||
username: [
|
username: [{ required: true, message: "请输入用户名", trigger: "blur" }],
|
||||||
{ required: true, message: "请输入用户名", trigger: "blur" }
|
password: [{ required: true, message: "请输入密码", trigger: "blur" }]
|
||||||
],
|
}
|
||||||
password: [
|
|
||||||
{ required: true, message: "请输入密码", trigger: "blur" },
|
/** 读取"保持登录"缓存的账号密码 */
|
||||||
{ min: 8, max: 16, message: "长度在 8 到 16 个字符", trigger: "blur" }
|
function readKeepLogin() {
|
||||||
],
|
const data = getKeepLogin()
|
||||||
code: [
|
if (!data) return
|
||||||
{ required: true, message: "请输入验证码", trigger: "blur" }
|
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() {
|
async function handleLogin() {
|
||||||
loginFormRef.value?.validate((valid) => {
|
if (loading.value) return
|
||||||
if (!valid) {
|
const valid = await loginFormRef.value?.validate().catch(() => false)
|
||||||
ElMessage.error("表单校验不通过")
|
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
|
return
|
||||||
}
|
}
|
||||||
loading.value = true
|
saveKeepLogin()
|
||||||
loginApi(loginFormData).then(({ data }) => {
|
const redirect = route.query.redirect
|
||||||
userStore.setToken(data.token)
|
await router.push(redirect ? decodeURIComponent(redirect as string) : "/")
|
||||||
router.push(route.query.redirect ? decodeURIComponent(route.query.redirect as string) : "/")
|
} catch {
|
||||||
}).catch(() => {
|
userStore.resetToken()
|
||||||
createCode()
|
errorText.value = "账户或密码错误"
|
||||||
loginFormData.password = ""
|
loginFormData.password = ""
|
||||||
}).finally(() => {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
})
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 创建验证码 */
|
/** 从忘记密码返回登录 */
|
||||||
function createCode() {
|
function handleBackToLogin() {
|
||||||
// 清空已输入的验证码
|
showForgetPassword.value = false
|
||||||
loginFormData.code = ""
|
|
||||||
// 清空验证图片
|
|
||||||
codeUrl.value = ""
|
|
||||||
// 获取验证码图片
|
|
||||||
getCaptchaApi().then((res) => {
|
|
||||||
codeUrl.value = res.data
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化验证码
|
/** 密码重置成功:回填账号密码 */
|
||||||
createCode()
|
function handlePasswordResetSuccess(username: string, password: string) {
|
||||||
|
showForgetPassword.value = false
|
||||||
|
loginFormData.username = username
|
||||||
|
loginFormData.password = password
|
||||||
|
ElMessage.success("密码重置成功,请登录")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化"保持登录"
|
||||||
|
readKeepLogin()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="login-container">
|
<div class="login-container">
|
||||||
<ThemeSwitch v-if="settingsStore.showThemeSwitch" class="theme-switch" />
|
<div class="title">
|
||||||
<Owl :close-eyes="isFocus" />
|
{{ LOGIN_TITLE }}
|
||||||
<div class="login-card">
|
</div>
|
||||||
<div class="title">
|
<div class="login-wrap">
|
||||||
<img src="@@/assets/images/layouts/logo-text-2.png">
|
<div class="form-section">
|
||||||
</div>
|
<template v-if="!showForgetPassword">
|
||||||
<div class="content">
|
<h3 class="form-title">
|
||||||
<el-form ref="loginFormRef" :model="loginFormData" :rules="loginFormRules" @keyup.enter="handleLogin">
|
用户登录
|
||||||
<el-form-item prop="username">
|
</h3>
|
||||||
<el-input
|
<transition name="el-zoom-in-top">
|
||||||
v-model.trim="loginFormData.username"
|
<div v-if="errorText" class="error-wrap">
|
||||||
placeholder="用户名"
|
<div class="error-tip">
|
||||||
type="text"
|
<el-icon class="error-icon">
|
||||||
tabindex="1"
|
<CircleCloseFilled />
|
||||||
:prefix-icon="User"
|
</el-icon>
|
||||||
size="large"
|
<span class="error-text">{{ errorText }}</span>
|
||||||
/>
|
</div>
|
||||||
</el-form-item>
|
</div>
|
||||||
<el-form-item prop="password">
|
</transition>
|
||||||
<el-input
|
<el-form
|
||||||
v-model.trim="loginFormData.password"
|
ref="loginFormRef"
|
||||||
placeholder="密码"
|
:model="loginFormData"
|
||||||
type="password"
|
:rules="loginFormRules"
|
||||||
tabindex="2"
|
class="login-form"
|
||||||
:prefix-icon="Lock"
|
@submit.prevent
|
||||||
size="large"
|
@keyup.enter="handleLogin"
|
||||||
show-password
|
>
|
||||||
@blur="handleBlur"
|
<el-form-item prop="username">
|
||||||
@focus="handleFocus"
|
<el-select
|
||||||
/>
|
v-model="loginFormData.username"
|
||||||
</el-form-item>
|
placeholder="请输入用户名或手机号"
|
||||||
<el-form-item prop="code">
|
filterable
|
||||||
<el-input
|
allow-create
|
||||||
v-model.trim="loginFormData.code"
|
clearable
|
||||||
placeholder="验证码"
|
default-first-option
|
||||||
type="text"
|
:filter-method="filterOption"
|
||||||
tabindex="3"
|
tabindex="1"
|
||||||
:prefix-icon="Key"
|
autocomplete="username"
|
||||||
maxlength="7"
|
>
|
||||||
size="large"
|
<template #prefix>
|
||||||
@blur="handleBlur"
|
<el-tooltip effect="dark" content="输入手机号可获取对应的用户名" placement="top">
|
||||||
@focus="handleFocus"
|
<el-icon class="phone-icon">
|
||||||
>
|
<Iphone />
|
||||||
<template #append>
|
|
||||||
<el-image :src="codeUrl" draggable="false" @click="createCode">
|
|
||||||
<template #placeholder>
|
|
||||||
<el-icon>
|
|
||||||
<Picture />
|
|
||||||
</el-icon>
|
</el-icon>
|
||||||
</template>
|
</el-tooltip>
|
||||||
<template #error>
|
</template>
|
||||||
<el-icon>
|
<el-option
|
||||||
<Loading />
|
v-for="item in accountList"
|
||||||
</el-icon>
|
:key="item.userName"
|
||||||
</template>
|
:label="item.userName"
|
||||||
</el-image>
|
:value="item.userName"
|
||||||
</template>
|
>
|
||||||
</el-input>
|
<div class="account-option">
|
||||||
</el-form-item>
|
<span>{{ item.userName }}</span>
|
||||||
<el-button :loading="loading" type="primary" size="large" @click.prevent="handleLogin">
|
<span class="account-team">{{ item.teamName }}</span>
|
||||||
登 录
|
</div>
|
||||||
</el-button>
|
</el-option>
|
||||||
</el-form>
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item prop="password">
|
||||||
|
<el-input
|
||||||
|
v-model.trim="loginFormData.password"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
type="password"
|
||||||
|
tabindex="2"
|
||||||
|
:prefix-icon="Lock"
|
||||||
|
show-password
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div class="form-link">
|
||||||
|
<el-checkbox v-model="keepLoginIn" :disabled="loading">
|
||||||
|
保持登录
|
||||||
|
</el-checkbox>
|
||||||
|
<span class="forget-link" @click="showForgetPassword = true">
|
||||||
|
忘记密码
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-submit">
|
||||||
|
<el-button class="login-button" type="primary" :loading="loading" @click="handleLogin">
|
||||||
|
<span class="login-button-text">登 录</span>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<h3 class="form-title">
|
||||||
|
忘记密码
|
||||||
|
</h3>
|
||||||
|
<Forget @back-to-login="handleBackToLogin" @password-reset-success="handlePasswordResetSuccess" />
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 页面底部公司信息 -->
|
||||||
|
<div class="copy-right">
|
||||||
|
<p>成都鸿兴运达科技公司 <a href="http://beian.miit.gov.cn" target="_blank" rel="noopener">蜀ICP备19023702号-4</a></p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
// 视觉对齐 school-iot-admin 的登录页(src/views/User/Login.vue)
|
||||||
.login-container {
|
.login-container {
|
||||||
display: flex;
|
position: relative;
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 100%;
|
height: 100%;
|
||||||
.theme-switch {
|
overflow: hidden;
|
||||||
position: fixed;
|
background-color: rgba(52, 125, 249, 0.3);
|
||||||
top: 5%;
|
// 背景图(复制自 school-iot-admin 的 newLoginBackgtound2.png)
|
||||||
right: 5%;
|
&::after {
|
||||||
cursor: pointer;
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
background: url("./images/login-background.png") no-repeat center;
|
||||||
|
background-size: cover;
|
||||||
}
|
}
|
||||||
.login-card {
|
|
||||||
width: 480px;
|
@media screen and (max-width: 768px) {
|
||||||
max-width: 90%;
|
&::after {
|
||||||
border-radius: 20px;
|
background-size: 100% auto;
|
||||||
box-shadow: 0 0 10px #dcdfe6;
|
|
||||||
background-color: var(--el-bg-color);
|
|
||||||
overflow: hidden;
|
|
||||||
.title {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
height: 150px;
|
|
||||||
img {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.content {
|
}
|
||||||
padding: 20px 50px 50px 50px;
|
|
||||||
:deep(.el-input-group__append) {
|
.title {
|
||||||
padding: 0;
|
position: absolute;
|
||||||
overflow: hidden;
|
top: 20%;
|
||||||
.el-image {
|
right: 14%;
|
||||||
width: 100px;
|
z-index: 1;
|
||||||
height: 40px;
|
width: max-content;
|
||||||
border-left: 0px;
|
max-width: 90%;
|
||||||
user-select: none;
|
font-size: 38px;
|
||||||
cursor: pointer;
|
font-weight: 500;
|
||||||
text-align: center;
|
color: #ffffff;
|
||||||
|
text-shadow: 0 4px 4px rgba(0, 0, 0, 0.5);
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
@media screen and (max-height: 900px) {
|
||||||
|
top: 14%;
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端隐藏标题(对齐老平台 screen-xs 行为,避免与登录卡片重叠)
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-wrap {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 白色登录卡片
|
||||||
|
.form-section {
|
||||||
|
position: absolute;
|
||||||
|
top: 54%;
|
||||||
|
left: 77.5%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 420px;
|
||||||
|
max-width: 92vw;
|
||||||
|
padding: 28px 32px 32px;
|
||||||
|
background-color: var(--el-bg-color);
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 0 4px 10px rgba(15, 72, 200, 0.6);
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
position: relative;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
transform: none;
|
||||||
|
margin: 120px auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-title {
|
||||||
|
margin: 16px 0 24px;
|
||||||
|
color: #347df9;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 0;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-wrap {
|
||||||
|
padding: 0 0 16px;
|
||||||
|
.error-tip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: rgba(240, 70, 48, 0.1);
|
||||||
|
.error-icon {
|
||||||
|
color: #f04631;
|
||||||
|
font-size: 20px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.error-text {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: rgba(31, 56, 88, 0.8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.el-button {
|
}
|
||||||
width: 100%;
|
|
||||||
margin-top: 10px;
|
.login-form {
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
:deep(.el-input__wrapper) {
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: 0 0 0 1px #bcbdc1 inset;
|
||||||
|
}
|
||||||
|
:deep(.el-select__wrapper) {
|
||||||
|
min-height: 48px;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: 0 0 0 1px #bcbdc1 inset;
|
||||||
|
}
|
||||||
|
:deep(.el-input__inner) {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
:deep(.el-select__placeholder) {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
:deep(.el-input__wrapper.is-focus),
|
||||||
|
:deep(.el-select__wrapper.is-focused) {
|
||||||
|
box-shadow: 0 0 0 1px #347df9 inset;
|
||||||
|
}
|
||||||
|
.phone-icon {
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
.account-team {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-link {
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
user-select: none;
|
||||||
|
:deep(.el-checkbox__label) {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
.forget-link {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #347df9;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-submit {
|
||||||
|
margin-top: 32px;
|
||||||
|
.login-button {
|
||||||
|
width: 100%;
|
||||||
|
height: 50px;
|
||||||
|
font-size: 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.login-button-text {
|
||||||
|
letter-spacing: 5px;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-right {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 16px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #a8a8a8;
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5em;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #ffffff;
|
||||||
|
text-decoration: none;
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { getCurrentUserApi } from "@@/apis/users"
|
||||||
import { setToken as _setToken, getToken, removeToken } from "@@/utils/local-storage"
|
import { setToken as _setToken, getToken, removeToken } from "@@/utils/local-storage"
|
||||||
import { pinia } from "@/pinia"
|
import { pinia } from "@/pinia"
|
||||||
@@ -5,6 +6,9 @@ import { resetRouter, router } from "@/router"
|
|||||||
import { useSettingsStore } from "./settings"
|
import { useSettingsStore } from "./settings"
|
||||||
import { useTagsViewStore } from "./tags-view"
|
import { useTagsViewStore } from "./tags-view"
|
||||||
|
|
||||||
|
/** 账号类型:3 表示无权限登录管理后台(与 school-iot-admin 约定一致) */
|
||||||
|
export const ACCOUNT_TYPE_FORBIDDEN = 3
|
||||||
|
|
||||||
export const useUserStore = defineStore("user", () => {
|
export const useUserStore = defineStore("user", () => {
|
||||||
const token = ref<string>(getToken() || "")
|
const token = ref<string>(getToken() || "")
|
||||||
|
|
||||||
@@ -16,6 +20,21 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
|
|
||||||
const isGotUserInfo = ref<boolean>(false)
|
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 tagsViewStore = useTagsViewStore()
|
||||||
|
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
@@ -29,9 +48,13 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
// 获取用户详情
|
// 获取用户详情
|
||||||
const getInfo = async () => {
|
const getInfo = async () => {
|
||||||
const { data } = await getCurrentUserApi()
|
const { data } = await getCurrentUserApi()
|
||||||
username.value = data.username
|
// 后端 AccountDetails 会把密码一起序列化回来,这里剔除后再保存
|
||||||
roles.value = data.roles ?? []
|
const { pwd: _pwd, password: _password, ...safeAccount } = data as AccountDetails & { pwd?: string, password?: string }
|
||||||
permissions.value = data.permissions ?? []
|
accountInfo.value = safeAccount as AccountDetails
|
||||||
|
username.value = data.userName
|
||||||
|
// serverschool 的角色/权限体系与模板不同,暂不映射,只放行常驻路由
|
||||||
|
roles.value = []
|
||||||
|
permissions.value = []
|
||||||
// 防止路由守卫逻辑进入无限循环
|
// 防止路由守卫逻辑进入无限循环
|
||||||
isGotUserInfo.value = true
|
isGotUserInfo.value = true
|
||||||
}
|
}
|
||||||
@@ -60,6 +83,7 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
token.value = ""
|
token.value = ""
|
||||||
roles.value = []
|
roles.value = []
|
||||||
permissions.value = []
|
permissions.value = []
|
||||||
|
accountInfo.value = null
|
||||||
isGotUserInfo.value = false
|
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 页面,则重定向到主页
|
// 如果已经登录,并准备进入 Login 页面,则重定向到主页
|
||||||
if (to.path === LOGIN_PATH) return "/"
|
if (to.path === LOGIN_PATH) return "/"
|
||||||
// 如果用户已经获得其权限信息
|
// 用户信息和路由都已就绪,直接放行
|
||||||
if (userStore.isGotUserInfo) return true
|
if (userStore.isGotUserInfo && permissionStore.routes.length > 0) return true
|
||||||
// 否则要重新获取权限信息
|
// 否则要重新获取权限信息
|
||||||
try {
|
try {
|
||||||
await userStore.getInfo()
|
// 已获取过用户信息(如登录页里已经调过 getInfo)就不再重复请求
|
||||||
|
userStore.isGotUserInfo || (await userStore.getInfo())
|
||||||
// 注意:角色和权限必须是数组! 例如: ["admin"] 或 ["permission:page-level"]
|
// 注意:角色和权限必须是数组! 例如: ["admin"] 或 ["permission:page-level"]
|
||||||
const { roles, permissions } = userStore
|
const { roles, permissions } = userStore
|
||||||
// 生成可访问的 Routes
|
// 生成可访问的 Routes
|
||||||
|
|||||||
Vendored
+1
@@ -21,6 +21,7 @@ declare module 'vue' {
|
|||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
|
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
|
||||||
ElCard: typeof import('element-plus/es')['ElCard']
|
ElCard: typeof import('element-plus/es')['ElCard']
|
||||||
|
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
|
|||||||
Vendored
+19
@@ -4,6 +4,25 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_BASE_URL: string
|
readonly VITE_BASE_URL: string
|
||||||
readonly VITE_ROUTER_HISTORY: "hash" | "html5"
|
readonly VITE_ROUTER_HISTORY: "hash" | "html5"
|
||||||
readonly VITE_PUBLIC_PATH: string
|
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 {
|
interface ImportMeta {
|
||||||
|
|||||||
+124
-4
@@ -13,7 +13,14 @@ import svgLoader from "vite-svg-loader"
|
|||||||
|
|
||||||
// Configuring Vite: https://cn.vite.dev/config
|
// Configuring Vite: https://cn.vite.dev/config
|
||||||
export default defineConfig(({ mode }) => {
|
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 {
|
return {
|
||||||
// 开发或打包构建时用到的公共基础路径
|
// 开发或打包构建时用到的公共基础路径
|
||||||
base: VITE_PUBLIC_PATH,
|
base: VITE_PUBLIC_PATH,
|
||||||
@@ -35,14 +42,127 @@ export default defineConfig(({ mode }) => {
|
|||||||
strictPort: false,
|
strictPort: false,
|
||||||
// 是否自动打开浏览器
|
// 是否自动打开浏览器
|
||||||
open: true,
|
open: true,
|
||||||
// 反向代理
|
// 反向代理(从 school-iot-admin/vue.config.js 的 devServer.proxy 搬运,默认连生产环境)
|
||||||
|
// 目标地址优先读 .env.development 里的 VITE_*_TARGET,未配置时用下面的默认值
|
||||||
proxy: {
|
proxy: {
|
||||||
|
// apifoxmock(demo / FastCrud 演示页)
|
||||||
"/api/v1": {
|
"/api/v1": {
|
||||||
target: "https://apifoxmock.com/m1/2930465-2145633-default",
|
target: "https://apifoxmock.com/m1/2930465-2145633-default",
|
||||||
// 是否为 WebSocket
|
|
||||||
ws: false,
|
ws: false,
|
||||||
// 是否允许跨域
|
|
||||||
changeOrigin: true
|
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