后端代码

This commit is contained in:
liumengyu
2026-08-24 17:48:55 +08:00
parent e0485f7366
commit aa4145fd33
419 changed files with 3684 additions and 53031 deletions
@@ -1,87 +0,0 @@
<template>
<section class="app-main">
<transition name="fade-transform" mode="out-in">
<keep-alive :include="cachedViews">
<router-view v-if="!$route.meta.link" :key="key" />
</keep-alive>
</transition>
<iframe-toggle />
</section>
</template>
<script>
import iframeToggle from "./IframeToggle/index"
export default {
name: 'AppMain',
components: { iframeToggle },
computed: {
cachedViews() {
return this.$store.state.tagsView.cachedViews
},
key() {
return this.$route.path
}
},
watch: {
$route() {
this.addIframe()
}
},
mounted() {
this.addIframe()
},
methods: {
addIframe() {
const { name } = this.$route
if (name && this.$route.meta.link) {
this.$store.dispatch('tagsView/addIframeView', this.$route)
}
}
}
}
</script>
<style lang="scss" scoped>
.app-main {
/* 顶部 Header 已移出主容器,主内容在 flex 列中占满剩余空间 */
flex: 1;
min-height: 0;
width: 100%;
position: relative;
overflow-y: auto;
scrollbar-gutter: stable;
background-color: #fff;
&:fullscreen,
&:-webkit-full-screen,
&:-moz-full-screen,
&:-ms-fullscreen {
background: #fff;
overflow-y: auto;
}
}
.hasTagsView {
.app-main {
/* tags-view 高度 34px,AppMain 在 flex 列中由父容器约束高度 */
min-height: 0;
}
}
</style>
<style lang="scss">
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background-color: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background-color: #c0c0c0;
border-radius: 3px;
}
</style>
@@ -1,362 +0,0 @@
<template>
<el-drawer title="公告详情" :visible.sync="visible" direction="rtl" size="50%" append-to-body :before-close="handleClose" custom-class="notice-detail-drawer">
<div v-loading="loading" class="notice-detail-drawer__body">
<div v-if="!detail" class="notice-empty">
<i class="el-icon-document"></i>
<span>暂无数据</span>
</div>
<div v-else class="notice-page">
<div class="notice-type-wrap">
<span v-if="detail.noticeType === '1'" class="notice-type-tag type-notify">
<i class="el-icon-bell"></i> 通知
</span>
<span v-else-if="detail.noticeType === '2'" class="notice-type-tag type-announce">
<i class="el-icon-message"></i> 公告
</span>
<span v-else class="notice-type-tag type-notify">
<i class="el-icon-document"></i> 消息
</span>
</div>
<h1 class="notice-title">{{ detail.noticeTitle }}</h1>
<div class="notice-meta">
<span class="meta-item">
<i class="el-icon-user"></i>
<span>{{ detail.createBy || '—' }}</span>
</span>
<span class="meta-item">
<i class="el-icon-time"></i>
<span>{{ detail.createTime || '—' }}</span>
</span>
<span class="meta-item">
<span :class="['status-dot', isStatusNormal ? 'status-ok' : 'status-off']"></span>
<span>{{ isStatusNormal ? '正常' : '已关闭' }}</span>
</span>
</div>
<div class="notice-divider">
<span class="notice-divider-dot"></span>
<span class="notice-divider-dot"></span>
<span class="notice-divider-dot"></span>
</div>
<div class="notice-body">
<div v-if="hasContent" class="notice-content" v-html="detail.noticeContent" />
<div v-else class="notice-empty notice-empty--inner">
<i class="el-icon-document"></i> 暂无内容
</div>
</div>
</div>
</div>
</el-drawer>
</template>
<script>
import { getNotice } from '@/api/system/notice'
export default {
name: 'NoticeDetailView',
data() {
return {
visible: false,
loading: false,
detail: null
}
},
computed: {
isStatusNormal() {
const s = this.detail && this.detail.status
return s === '0' || s === 0
},
hasContent() {
const c = this.detail && this.detail.noticeContent
return c != null && String(c).trim() !== ''
}
},
methods: {
open(payload) {
let id = null
let preset = null
if (payload != null && typeof payload === 'object') {
id = payload.noticeId
if (payload.noticeContent != null) {
preset = payload
}
} else {
id = payload
}
this.visible = true
if (preset) {
this.detail = preset
return
}
if (id == null || id === '') {
this.detail = null
return
}
this.loading = true
this.detail = null
getNotice(id).then(res => {
this.detail = res.data
}).catch(() => {
this.detail = null
}).finally(() => {
this.loading = false
})
},
handleClose() {
this.visible = false
this.detail = null
this.loading = false
}
}
}
</script>
<style lang="scss" scoped>
.notice-page {
max-width: 760px;
margin: 0 auto;
padding: 8px 8px 20px;
animation: notice-fade-up 0.28s ease both;
}
@keyframes notice-fade-up {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.notice-type-tag {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 12px;
border-radius: 2px;
font-size: 11px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 14px;
}
.type-notify {
background: #fff8e6;
color: #b7791f;
border-left: 3px solid #d97706;
}
.type-announce {
background: #e8f5e9;
color: #276749;
border-left: 3px solid #38a169;
}
.notice-title {
font-size: 22px;
font-weight: 700;
color: #1a202c;
line-height: 1.45;
margin: 0 0 16px;
letter-spacing: -0.2px;
}
.notice-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
padding: 12px 0;
border-top: 1px solid #e9ecef;
border-bottom: 1px solid #e9ecef;
margin-bottom: 28px;
}
.meta-item {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: #718096;
}
.meta-item i {
font-size: 12px;
color: #a0aec0;
}
.status-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
margin-right: 4px;
}
.status-ok {
background: #38a169;
}
.status-off {
background: #e53e3e;
}
.notice-divider {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.notice-divider::before,
.notice-divider::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(to right, transparent, #dee2e6, transparent);
}
.notice-divider-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #cbd5e0;
}
.notice-body {
background: #fff;
border-radius: 6px;
padding: 28px 32px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(0, 0, 0, 0.04);
min-height: 120px;
}
.notice-content {
font-size: 14px;
line-height: 1.85;
color: #2d3748;
word-break: break-word;
}
.notice-content ::v-deep p {
margin: 0 0 1em;
}
.notice-content ::v-deep h1,
.notice-content ::v-deep h2,
.notice-content ::v-deep h3 {
font-weight: 700;
color: #1a202c;
margin: 1.4em 0 0.6em;
}
.notice-content ::v-deep h1 {
font-size: 18px;
}
.notice-content ::v-deep h2 {
font-size: 16px;
}
.notice-content ::v-deep h3 {
font-size: 14px;
}
.notice-content ::v-deep a {
color: #3182ce;
text-decoration: underline;
}
.notice-content ::v-deep a:hover {
color: #2b6cb0;
}
.notice-content ::v-deep img {
max-width: 100%;
border-radius: 4px;
margin: 8px 0;
}
.notice-content ::v-deep ul,
.notice-content ::v-deep ol {
padding-left: 20px;
margin: 0 0 1em;
}
.notice-content ::v-deep li {
margin-bottom: 4px;
}
.notice-content ::v-deep blockquote {
border-left: 3px solid #cbd5e0;
margin: 1em 0;
padding: 6px 16px;
color: #718096;
background: #f7fafc;
}
.notice-content ::v-deep table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
font-size: 13px;
}
.notice-content ::v-deep table th,
.notice-content ::v-deep table td {
border: 1px solid #e2e8f0;
padding: 7px 12px;
}
.notice-content ::v-deep table th {
background: #f7fafc;
font-weight: 600;
}
.notice-empty {
text-align: center;
padding: 40px 0;
color: #a0aec0;
font-size: 13px;
}
.notice-empty i {
font-size: 28px;
display: block;
margin-bottom: 10px;
}
.notice-empty--inner {
padding: 32px 0;
}
.notice-empty--inner i {
font-size: 28px;
}
::v-deep .notice-detail-drawer {
.el-drawer__header {
margin-bottom: 0;
padding: 16px 20px;
border-bottom: 1px solid #ebeef5;
font-size: 16px;
font-weight: 600;
color: #303133;
}
.el-drawer__body {
background: #f5f6f8;
}
}
.notice-detail-drawer__body {
height: 100%;
overflow: auto;
padding: 10px 16px 22px;
}
</style>
@@ -1,191 +0,0 @@
<template>
<div>
<el-popover ref="noticePopover" placement="bottom-end" width="320" trigger="manual" :value="noticeVisible" popper-class="notice-popover">
<div class="notice-header">
<span class="notice-title">通知公告</span>
<span class="notice-mark-all" @click="markAllRead">全部已读</span>
</div>
<div v-if="noticeLoading" class="notice-loading"><i class="el-icon-loading"></i> 加载中...</div>
<div v-else-if="noticeList.length === 0" class="notice-empty"><i class="el-icon-inbox"></i><br>暂无公告</div>
<div v-else>
<div v-for="item in noticeList" :key="item.noticeId" class="notice-item" :class="{ 'is-read': item.isRead }" @click="previewNotice(item)">
<el-tag size="mini" :type="item.noticeType === '1' ? 'warning' : 'success'" class="notice-tag">
{{ item.noticeType === '1' ? '通知' : '公告' }}
</el-tag>
<span class="notice-item-title">{{ item.noticeTitle }}</span>
<span class="notice-item-date">{{ item.createTime }}</span>
</div>
</div>
</el-popover>
<div v-popover:noticePopover class="right-menu-item hover-effect notice-trigger" @mouseenter="onNoticeEnter" @mouseleave="onNoticeLeave">
<svg-icon icon-class="bell" />
<span v-if="unreadCount > 0" class="notice-badge">{{ unreadCount }}</span>
</div>
<notice-detail-view ref="noticeViewRef" />
</div>
</template>
<script>
import NoticeDetailView from './DetailView'
import { listNoticeTop, markNoticeRead, markNoticeReadAll } from '@/api/system/notice'
export default {
name: 'HeaderNotice',
components: { NoticeDetailView },
data() {
return {
noticeList: [], // 通知列表
unreadCount: 0, // 未读数量
noticeLoading: false, // 加载状态
noticeVisible: false, // 弹出层显示状态
noticeLeaveTimer: null // 鼠标离开计时器
}
},
mounted() {
this.loadNoticeTop()
},
methods: {
// 鼠标移入铃铛区域
onNoticeEnter() {
clearTimeout(this.noticeLeaveTimer)
this.noticeVisible = true
this.$nextTick(() => {
const popper = this.$refs.noticePopover.$refs.popper
if (popper && !popper._noticeBound) {
popper._noticeBound = true
popper.addEventListener('mouseenter', () => clearTimeout(this.noticeLeaveTimer))
popper.addEventListener('mouseleave', () => {
this.noticeLeaveTimer = setTimeout(() => { this.noticeVisible = false }, 100)
})
}
})
},
// 鼠标离开铃铛区域
onNoticeLeave() {
this.noticeLeaveTimer = setTimeout(() => { this.noticeVisible = false }, 150)
},
// 加载顶部公告列表
loadNoticeTop() {
this.noticeLoading = true
listNoticeTop().then(res => {
this.noticeList = res.data || []
this.unreadCount = res.unreadCount !== undefined ? res.unreadCount : this.noticeList.filter(n => !n.isRead).length
}).finally(() => {
this.noticeLoading = false
})
},
// 预览公告详情
previewNotice(item) {
if (!item.isRead) {
markNoticeRead(item.noticeId).catch(() => {})
item.isRead = true
const idx = this.noticeList.indexOf(item)
if (idx !== -1) this.$set(this.noticeList, idx, { ...item, isRead: true })
this.unreadCount = Math.max(0, this.unreadCount - 1)
}
this.$refs.noticeViewRef.open(item.noticeId)
},
// 全部已读
markAllRead() {
const ids = this.noticeList.map(n => n.noticeId).join(',')
if (!ids) return
markNoticeReadAll(ids).catch(() => {})
this.noticeList = this.noticeList.map(n => ({ ...n, isRead: true }))
this.unreadCount = 0
}
}
}
</script>
<style lang="scss" scoped>
.notice-trigger {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
/* 自身不强行限制尺寸,由父级 .icon-btn 控制圆框尺寸 */
width: 100%;
height: 100%;
.svg-icon { width: 18px; height: 18px; color: #fff; }
.notice-badge {
position: absolute;
top: 2px;
right: 2px;
background: linear-gradient(135deg, #ff5e5e, #f5222d);
color: #fff;
border-radius: 10px;
font-size: 10px;
height: 16px;
line-height: 16px;
padding: 0 4px;
min-width: 16px;
text-align: center;
white-space: nowrap;
pointer-events: none;
font-weight: 600;
box-shadow: 0 2px 6px rgba(245, 34, 45, 0.5);
border: 1.5px solid #fff;
transform: scale(0.92);
transform-origin: top right;
}
}
.notice-popover {
padding: 0 !important;
}
.notice-popover .notice-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: #f7f9fb;
border-bottom: 1px solid #eee;
font-size: 13px;
font-weight: 600;
color: #333;
}
.notice-popover .notice-mark-all {
font-size: 12px;
color: #00875A;
font-weight: normal;
cursor: pointer;
}
.notice-popover .notice-mark-all:hover { color: #2b7cc1; }
.notice-popover .notice-loading,
.notice-popover .notice-empty {
padding: 24px;
text-align: center;
color: #bbb;
font-size: 12px;
line-height: 1.8;
}
.notice-popover .notice-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid #f5f5f5;
cursor: pointer;
transition: background 0.15s;
}
.notice-popover .notice-item:last-child { border-bottom: none; }
.notice-popover .notice-item:hover { background: #f7f9fb; }
.notice-popover .notice-item.is-read .notice-tag,
.notice-popover .notice-item.is-read .notice-item-title,
.notice-popover .notice-item.is-read .notice-item-date { opacity: 0.45; filter: grayscale(1); color: #999; }
.notice-popover .notice-tag { flex-shrink: 0; }
.notice-popover .notice-item-title {
flex: 1;
font-size: 12px;
color: #333;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.notice-popover .notice-item-date {
flex-shrink: 0;
font-size: 11px;
color: #bbb;
}
</style>
@@ -1,33 +0,0 @@
<template>
<transition-group name="fade-transform" mode="out-in">
<inner-link
v-for="(item, index) in iframeViews"
:key="item.path"
:iframeId="'iframe' + index"
v-show="$route.path === item.path"
:src="iframeUrl(item.meta.link, item.query)"
></inner-link>
</transition-group>
</template>
<script>
import InnerLink from "../InnerLink/index"
export default {
components: { InnerLink },
computed: {
iframeViews() {
return this.$store.state.tagsView.iframeViews
}
},
methods: {
iframeUrl(url, query) {
if (Object.keys(query).length > 0) {
let params = Object.keys(query).map((key) => key + "=" + query[key]).join("&")
return url + "?" + params
}
return url
}
}
}
</script>
@@ -1,47 +0,0 @@
<template>
<div :style="'height:' + height" v-loading="loading" element-loading-text="正在加载页面,请稍候!">
<iframe
:id="iframeId"
style="width: 100%; height: 100%"
:src="src"
frameborder="no"
></iframe>
</div>
</template>
<script>
export default {
props: {
src: {
type: String,
default: "/"
},
iframeId: {
type: String
}
},
data() {
return {
loading: false,
height: document.documentElement.clientHeight - 94.5 + "px;"
}
},
mounted() {
var _this = this
const iframeId = ("#" + this.iframeId).replace(/\//g, "\\/")
const iframe = document.querySelector(iframeId)
// iframe页面loading控制
if (iframe.attachEvent) {
this.loading = true
iframe.attachEvent("onload", function () {
_this.loading = false
})
} else {
this.loading = true
iframe.onload = function () {
_this.loading = false
}
}
}
}
</script>
-406
View File
@@ -1,406 +0,0 @@
<template>
<div class="navbar" :class="[{ 'header-mode': headerMode }]">
<hamburger v-if="!headerMode" id="hamburger-container" :is-active="sidebar.opened" class="hamburger-container" @toggleClick="toggleSideBar" />
<breadcrumb v-if="!headerMode" id="breadcrumb-container" class="breadcrumb-container" />
<div class="right-menu">
<template v-if="device!=='mobile'">
<el-tooltip content="菜单搜索" effect="dark" placement="bottom">
<div id="header-search" class="right-menu-item icon-btn" @click="openSearch">
<search ref="searchRef" />
</div>
</el-tooltip>
<el-tooltip content="消息通知" effect="dark" placement="bottom">
<header-notice id="header-notice" class="right-menu-item icon-btn" />
</el-tooltip>
</template>
<el-dropdown class="avatar-container right-menu-item" trigger="click" @command="handleCommand">
<div class="avatar-wrapper">
<div class="avatar-box">
<img :src="avatar" class="user-avatar">
<span class="online-dot" title="在线"></span>
</div>
<div class="user-info">
<span class="user-nickname">{{ nickName }}</span>
<span class="user-role">{{ userRoleLabel }}</span>
</div>
<i class="el-icon-arrow-down user-caret"></i>
</div>
<el-dropdown-menu slot="dropdown" class="user-dropdown-menu">
<router-link to="/user/profile">
<el-dropdown-item icon="el-icon-user">个人中心</el-dropdown-item>
</router-link>
<el-dropdown-item icon="el-icon-lock" @click.native="lockScreen">
<span>锁定屏幕</span>
</el-dropdown-item>
<el-dropdown-item divided icon="el-icon-switch-button" @click.native="logout">
<span>退出登录</span>
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import Breadcrumb from '@/components/Breadcrumb'
import Hamburger from '@/components/Hamburger'
import Screenfull from '@/components/Screenfull'
import Search from '@/components/HeaderSearch'
import HeaderNotice from './HeaderNotice'
export default {
components: {
Breadcrumb,
Hamburger,
Screenfull,
Search,
HeaderNotice
},
props: {
// headerMode=true 时,Navbar 作为顶部全宽 Header 右侧功能区,仅渲染 right-menu
headerMode: {
type: Boolean,
default: false
}
},
data() {
return {}
},
computed: {
...mapGetters([
'sidebar',
'avatar',
'device',
'nickName',
'roles'
]),
// 用户角色标签:根据 roles 数组智能映射为友好文本
userRoleLabel() {
const roleMap = {
admin: '系统管理员',
teacher: '教师用户',
student: '学生用户',
ROLE_DEFAULT: '普通用户'
}
const r = (this.roles && this.roles[0]) || ''
// 直接匹配或去除 ROLE_ 前缀后再匹配
return roleMap[r] || roleMap[String(r).replace(/^ROLE_/, '')] || '教学管理系统'
}
},
methods: {
toggleSideBar() {
this.$store.dispatch('app/toggleSideBar')
},
handleCommand(command) {
// 预留:下拉菜单 command 事件入口
},
openSearch() {
if (this.$refs.searchRef && typeof this.$refs.searchRef.click === 'function') {
this.$refs.searchRef.click()
}
},
lockScreen() {
const currentPath = this.$route.fullPath
this.$store.dispatch('lock/lockScreen', currentPath).then(() => {
this.$router.push('/lock')
})
},
logout() {
this.$confirm('确定注销并退出系统吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.$store.dispatch('LogOut').then(() => {
location.href = '/index'
})
}).catch(() => {})
},
}
}
</script>
<style lang="scss" scoped>
.navbar.nav3 {
.hamburger-container {
display: none !important;
}
}
.navbar {
height: 50px;
overflow: hidden;
position: relative;
background: #fff;
box-shadow: 0 1px 4px rgba(0,21,41,.08);
display: flex;
align-items: center;
// padding: 0 8px;
box-sizing: border-box;
&.header-mode {
height: 100%;
background: transparent;
box-shadow: none;
padding-right: 8px;
/* 深色顶栏下右侧功能区图标与文字改为白色 */
.right-menu-item {
color: rgba(255, 255, 255, 0.95);
&.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
transition: all 0.25s ease;
cursor: pointer;
&:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
&:active {
transform: translateY(0);
}
.svg-icon {
width: 18px;
height: 18px;
}
}
.el-dropdown-link {
color: rgba(255, 255, 255, 0.95);
}
}
/* 通过深选择器让 .icon-btn 样式同时穿透到子组件(如 HeaderNotice)的根元素 */
::v-deep .icon-btn {
display: inline-flex !important;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
transition: all 0.25s ease;
cursor: pointer;
&:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
&:active {
transform: translateY(0);
}
.svg-icon {
width: 18px;
height: 18px;
color: #fff;
}
}
.avatar-container .avatar-wrapper .user-nickname {
color: #fff;
}
}
.hamburger-container {
line-height: 46px;
height: 100%;
cursor: pointer;
transition: background .3s;
-webkit-tap-highlight-color:transparent;
display: flex;
align-items: center;
flex-shrink: 0;
margin-right: 8px;
&:hover {
background: rgba(0, 0, 0, .025)
}
}
.breadcrumb-container {
flex-shrink: 0;
}
.topmenu-container {
position: absolute;
left: 50px;
}
.right-menu {
height: 100%;
line-height: 50px;
display: flex;
align-items: center;
margin-left: auto;
gap: 6px;
&:focus {
outline: none;
}
.right-menu-item {
display: inline-flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 18px;
color: #5a5e66;
vertical-align: text-bottom;
padding: 0;
line-height: normal;
&.icon-btn {
position: relative;
}
.el-dropdown-link {
font-size: 14px;
color: #5a5e66;
cursor: pointer;
}
}
.avatar-container {
margin-right: 0;
padding: 0 6px 0 10px;
height: 100%;
border-radius: 22px;
cursor: pointer;
&.is-active .user-caret {
transform: rotate(180deg);
}
.avatar-wrapper {
display: flex;
align-items: center;
position: relative;
gap: 10px;
.avatar-box {
position: relative;
width: 36px;
height: 36px;
border-radius: 50%;
padding: 2px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.55));
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
flex-shrink: 0;
}
.user-avatar {
cursor: pointer;
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
background: var(--edu-green-light);
display: block;
}
.online-dot {
position: absolute;
right: 0;
bottom: 0;
width: 10px;
height: 10px;
border-radius: 50%;
background: #2ecc71;
border: 2px solid #fff;
box-shadow: 0 0 0 0 rgba(46, 204, 113, 0.6);
animation: onlinePulse 2s infinite;
}
@keyframes onlinePulse {
0% { box-shadow: 0 0 0 0 rgba(46, 204, 113, 0.55); }
70% { box-shadow: 0 0 0 6px rgba(46, 204, 113, 0); }
100% { box-shadow: 0 0 0 0 rgba(46, 204, 113, 0); }
}
.user-info {
display: flex;
flex-direction: column;
justify-content: center;
line-height: 1.15;
min-width: 0;
}
.user-nickname {
font-size: 14px;
font-weight: 600;
color: #fff;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0.3px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
}
.user-role {
font-size: 11px;
color: rgba(255, 255, 255, 0.7);
margin-top: 2px;
letter-spacing: 0.2px;
}
.user-caret {
font-size: 12px;
color: rgba(255, 255, 255, 0.85);
margin-left: 2px;
transition: transform 0.2s ease;
}
}
}
}
}
/* 用户下拉菜单 - 提升视觉品质(非 scoped,因为 el-dropdown-menu 渲染到 body) */
.user-dropdown-menu {
border-radius: 10px !important;
border: 1px solid #ebeef5 !important;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12), 0 2px 6px rgba(0, 0, 0, 0.06) !important;
padding: 6px !important;
overflow: hidden;
.el-dropdown-menu__item {
border-radius: 6px;
font-size: 13px;
color: #303133;
line-height: 36px;
padding: 0 14px;
transition: background 0.2s, color 0.2s;
i {
font-size: 14px;
color: var(--edu-green-primary);
margin-right: 8px;
}
&:not(.is-disabled):hover,
&:not(.is-disabled):focus {
background: var(--edu-green-light);
color: var(--edu-green-primary);
outline: none;
}
}
}
</style>
@@ -1,274 +0,0 @@
<template>
<div class="semester-bar">
<!-- 左:当前学期信息 -->
<div class="semester-bar__item semester-bar__current">
<i class="el-icon-date" />
<span class="label">当前学期</span>
<span class="value">{{ currentSemesterName }}</span>
<span v-if="currentSemesterDate" class="date">{{ currentSemesterDate }}</span>
</div>
<span class="semester-bar__divider" />
<!-- 中:今日日期 -->
<div class="semester-bar__item semester-bar__date">
<i class="el-icon-time" />
<span class="value">{{ todayText }}</span>
<span class="week">{{ weekText }}</span>
</div>
<span class="semester-bar__divider" />
<!-- 右:学期切换下拉框 -->
<div class="semester-bar__item semester-bar__switch">
<span class="label">切换学期</span>
<div class="semester-select-wrap">
<el-select
:value="activeSemester ? activeSemester.nd : ''"
placeholder="请选择学期"
size="small"
class="semester-select"
@change="handleSwitch"
>
<el-option
v-for="item in semesterList"
:key="item.nd"
:label="getSemesterName(item.nd)"
:value="item.nd"
/>
</el-select>
</div>
</div>
</div>
</template>
<script>
import { listSemester, updateSemester } from '@/api/teachBusiness/semester'
export default {
name: 'SemesterBar',
data() {
return {
// 学期列表(接口数据)
semesterList: [],
// 当前选中的学期对象
activeSemester: null,
today: new Date(),
loading: false
}
},
computed: {
currentSemesterName() {
return this.getSemesterName(this.activeSemester ? this.activeSemester.nd : '')
},
currentSemesterDate() {
const item = this.activeSemester
if (!item) return ''
const start = this.formatDate(item.kxrq)
const end = this.formatDate(item.jsrq)
if (!start && !end) return ''
return `${start || '?'} ~ ${end || '?'}`
},
todayText() {
const d = this.today
const pad = n => (n < 10 ? '0' + n : '' + n)
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
},
weekText() {
const weeks = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
return weeks[this.today.getDay()]
}
},
created() {
this.getSemesterList()
},
methods: {
/** 加载学期列表,默认选中当前学期(dqxq=true),无当前学期则选第一条 */
getSemesterList() {
this.loading = true
listSemester({ pageNum: 1, pageSize: 100 }).then(response => {
const data = response.data || {}
const list = data.records || []
this.semesterList = list
const current = list.find(i => i.dqxq)
this.activeSemester = current || (list[0] ? list[0] : null)
this.loading = false
}).catch(() => {
this.semesterList = []
this.loading = false
})
},
/** 格式化后端 LocalDateTime(2026-02-23T00:00:00 -> 2026-02-23) */
formatDate(value) {
if (!value) return ''
return String(value).slice(0, 10)
},
/** 学期代号 -> 学期名称(如 202601 -> 2026年春季学期) */
getSemesterName(nd) {
if (!nd) return ''
const s = String(nd)
const xn = s.slice(0, 4)
const xq = s.slice(4)
const map = { '01': '春季学期', '02': '夏季学期', '03': '秋季学期' }
return xn + '年' + (map[xq] || '第' + xq + '学期')
},
/** 切换学期:将目标学期设为当前学期,原当前学期取消 */
handleSwitch(nd) {
const current = this.activeSemester
if (!nd || (current && nd === current.nd)) return
const target = this.semesterList.find(i => i.nd === nd)
if (!target) return
// 当前已是"当前学期"的其他记录(需取消)
const others = this.semesterList.filter(i => i.dqxq && i.nd !== nd)
updateSemester({ ...target, dqxq: true }).then(() => {
// 若存在原当前学期,一并取消
if (others.length) {
return Promise.all(others.map(o => updateSemester({ ...o, dqxq: false })))
}
return Promise.resolve()
}).then(() => {
this.activeSemester = target
this.$message.success(`已切换至:${this.getSemesterName(nd)}`)
// 重新拉取,同步最新 dqxq 状态
this.getSemesterList()
// 通知学期管理页面自动刷新
this.$root.$emit('semester-current-changed')
}).catch(() => {
this.$message.error('切换学期失败,请稍后重试')
})
}
}
}
</script>
<style scoped lang="scss">
.semester-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
height: 36px;
font-size: 13px;
color: rgba(255, 255, 255, 0.85);
user-select: none;
&__item {
display: flex;
align-items: center;
justify-content: center;
height: 36px;
padding: 0 16px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.22);
backdrop-filter: blur(2px);
box-sizing: border-box;
i {
color: #fff;
margin-right: 7px;
font-size: 15px;
display: flex;
align-items: center;
height: 100%;
}
.label,
.value,
.week,
.date {
display: flex;
align-items: center;
height: 100%;
white-space: nowrap;
}
.label {
color: rgba(255, 255, 255, 0.75);
margin-right: 6px;
}
.value {
font-weight: 600;
color: #fff;
}
.date {
margin-left: 10px;
font-size: 12px;
color: rgba(255, 255, 255, 0.75);
letter-spacing: 0.5px;
padding-left: 10px;
border-left: 1px solid rgba(255, 255, 255, 0.25);
}
.week {
margin-left: 8px;
color: rgba(255, 255, 255, 0.75);
}
}
&__current {
margin-right: 0;
}
&__date {
margin-right: 0;
}
/* 胶囊式布局后不再需要分隔线 */
&__divider {
display: none;
}
&__switch {
background: transparent;
border: none;
padding: 0;
border-radius: 0;
height: 36px;
/* 用一层定高 flex 容器包裹 select,彻底隔离其默认行高外溢 */
.semester-select-wrap {
display: flex;
align-items: center;
height: 36px;
}
.semester-select {
width: 200px;
}
/* 关闭 select/input 外层默认行高,交给 flex 居中 */
::v-deep .el-select,
::v-deep .el-input {
height: 36px;
line-height: normal;
}
::v-deep .el-input__inner {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.35);
color: #fff;
border-radius: 18px;
height: 36px;
padding: 0 12px;
display: flex;
align-items: center;
&::placeholder {
color: rgba(255, 255, 255, 0.7);
}
}
::v-deep .el-input__inner:hover {
border-color: rgba(255, 255, 255, 0.6);
}
::v-deep .el-input__icon {
color: rgba(255, 255, 255, 0.85);
}
}
}
</style>
@@ -1,25 +0,0 @@
export default {
computed: {
device() {
return this.$store.state.app.device
}
},
mounted() {
// In order to fix the click on menu on the ios device will trigger the mouseleave bug
this.fixBugIniOS()
},
methods: {
fixBugIniOS() {
const $subMenu = this.$refs.subMenu
if ($subMenu) {
const handleMouseleave = $subMenu.handleMouseleave
$subMenu.handleMouseleave = (e) => {
if (this.device === 'mobile') {
return
}
handleMouseleave(e)
}
}
}
}
}
@@ -1,33 +0,0 @@
<script>
export default {
name: 'MenuItem',
functional: true,
props: {
icon: {
type: String,
default: ''
},
title: {
type: String,
default: ''
}
},
render(h, context) {
const { icon, title } = context.props
const vnodes = []
if (icon) {
vnodes.push(<svg-icon icon-class={icon}/>)
}
if (title) {
if (title.length > 5) {
vnodes.push(<span slot='title' title={(title)}>{(title)}</span>)
} else {
vnodes.push(<span slot='title'>{(title)}</span>)
}
}
return vnodes
}
}
</script>
@@ -1,43 +0,0 @@
<template>
<component :is="type" v-bind="linkProps(to)">
<slot />
</component>
</template>
<script>
import { isExternal } from '@/utils/validate'
export default {
props: {
to: {
type: [String, Object],
required: true
}
},
computed: {
isExternal() {
return isExternal(this.to)
},
type() {
if (this.isExternal) {
return 'a'
}
return 'router-link'
}
},
methods: {
linkProps(to) {
if (this.isExternal) {
return {
href: to,
target: '_blank',
rel: 'noopener'
}
}
return {
to: to
}
}
}
}
</script>
@@ -1,99 +0,0 @@
<template>
<div v-if="!item.hidden">
<template v-if="hasOneShowingChild(item.children,item) && (!onlyOneChild.children||onlyOneChild.noShowingChildren)&&!item.alwaysShow">
<app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path, onlyOneChild.query)">
<el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{'submenu-title-noDropdown':!isNest}">
<item :icon="onlyOneChild.meta.icon||(item.meta&&item.meta.icon)" :title="onlyOneChild.meta.title" />
</el-menu-item>
</app-link>
</template>
<el-submenu v-else ref="subMenu" :index="resolvePath(item.path)" popper-append-to-body>
<template slot="title">
<item v-if="item.meta" :icon="item.meta && item.meta.icon" :title="item.meta.title" />
</template>
<sidebar-item
v-for="(child, index) in item.children"
:key="child.path + index"
:is-nest="true"
:item="child"
:base-path="resolvePath(child.path)"
class="nest-menu"
/>
</el-submenu>
</div>
</template>
<script>
import path from 'path'
import { isExternal } from '@/utils/validate'
import Item from './Item'
import AppLink from './Link'
import FixiOSBug from './FixiOSBug'
export default {
name: 'SidebarItem',
components: { Item, AppLink },
mixins: [FixiOSBug],
props: {
// route object
item: {
type: Object,
required: true
},
isNest: {
type: Boolean,
default: false
},
basePath: {
type: String,
default: ''
}
},
data() {
this.onlyOneChild = null
return {}
},
methods: {
hasOneShowingChild(children = [], parent) {
if (!children) {
children = []
}
const showingChildren = children.filter(item => {
if (item.hidden) {
return false
}
// Temp set(will be used if only has one showing child)
this.onlyOneChild = item
return true
})
// When there is only one child router, the child router is displayed by default
if (showingChildren.length === 1) {
return true
}
// Show parent if there are no child router to display
if (showingChildren.length === 0) {
this.onlyOneChild = { ... parent, path: '', noShowingChildren: true }
return true
}
return false
},
resolvePath(routePath, routeQuery) {
if (isExternal(routePath)) {
return routePath
}
if (isExternal(this.basePath)) {
return this.basePath
}
if (routeQuery) {
let query = JSON.parse(routeQuery)
return { path: path.resolve(this.basePath, routePath), query: query }
}
return path.resolve(this.basePath, routePath)
}
}
}
</script>
@@ -1,95 +0,0 @@
<template>
<div :class="['sidebar-theme-wrapper', 'theme-dark']" :style="{ backgroundColor: variables.menuBackground }">
<el-scrollbar class="theme-dark" wrap-class="scrollbar-wrapper">
<el-menu
:default-active="activeMenu"
:collapse="isCollapse"
:background-color="variables.menuBackground"
:text-color="variables.menuColor"
:unique-opened="true"
:active-text-color="'#ffffff'"
:collapse-transition="false"
mode="vertical"
>
<sidebar-item
v-for="(route, index) in sidebarRouters"
:key="route.path + index"
:item="route"
:base-path="route.path"
/>
</el-menu>
</el-scrollbar>
<!-- 侧边栏底部折叠按钮 -->
<div class="sidebar-collapse-bar" @click="toggleSideBar" :title="isCollapse ? '展开菜单' : '收起菜单'">
<hamburger :is-active="sidebar.opened" class="collapse-hamburger" />
</div>
</div>
</template>
<script>
import { mapGetters, mapState } from "vuex"
import SidebarItem from "./SidebarItem"
import Hamburger from "@/components/Hamburger"
import variables from "@/assets/styles/variables.scss"
export default {
components: { SidebarItem, Hamburger },
computed: {
...mapState(["settings"]),
...mapGetters(["sidebarRouters", "sidebar"]),
activeMenu() {
const route = this.$route
const { meta, path } = route
// if set path, the sidebar will highlight the path you set
if (meta.activeMenu) {
return meta.activeMenu
}
return path
},
variables() {
return variables
},
isCollapse() {
return !this.sidebar.opened
}
},
methods: {
toggleSideBar() {
this.$store.dispatch('app/toggleSideBar')
}
}
}
</script>
<style scoped lang="scss">
.sidebar-theme-wrapper {
height: 100%;
display: flex;
flex-direction: column;
.el-scrollbar {
flex: 1;
min-height: 0;
}
}
.sidebar-collapse-bar {
flex-shrink: 0;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #bfcbd9;
.collapse-hamburger {
line-height: 48px;
font-size: 18px;
}
}
.sidebar-theme-wrapper.theme-light .sidebar-collapse-bar {
color: #909399;
}
</style>
@@ -1,149 +0,0 @@
<template>
<el-scrollbar ref="scrollContainer" :vertical="false" class="scroll-container" @wheel.native.prevent="handleScroll">
<slot />
</el-scrollbar>
</template>
<script>
const tagAndTagSpacing = 4
export default {
name: 'ScrollPane',
data() {
return {
left: 0
}
},
computed: {
scrollWrapper() {
return this.$refs.scrollContainer.$refs.wrap
}
},
mounted() {
this.scrollWrapper.addEventListener('scroll', this.emitScroll, true)
},
beforeDestroy() {
this.scrollWrapper.removeEventListener('scroll', this.emitScroll)
},
methods: {
smoothScrollTo(target) {
const $scrollWrapper = this.scrollWrapper
const start = $scrollWrapper.scrollLeft
const distance = target - start
const duration = 300
let startTime = null
function ease(t, b, c, d) {
t /= d / 2
if (t < 1) return c / 2 * t * t + b
t--
return -c / 2 * (t * (t - 2) - 1) + b
}
const emit = this.$emit.bind(this)
function step(timestamp) {
if (!startTime) startTime = timestamp
const elapsed = timestamp - startTime
$scrollWrapper.scrollLeft = ease(elapsed, start, distance, duration)
if (elapsed < duration) {
requestAnimationFrame(step)
} else {
$scrollWrapper.scrollLeft = target
emit('updateArrows')
}
}
requestAnimationFrame(step)
},
handleScroll(e) {
const eventDelta = e.wheelDelta || -e.deltaY * 40
const $scrollWrapper = this.scrollWrapper
$scrollWrapper.scrollLeft = $scrollWrapper.scrollLeft + eventDelta / 4
this.$emit('updateArrows')
},
emitScroll() {
this.$emit('scroll')
this.$emit('updateArrows')
},
moveToTarget(currentTag) {
const $container = this.$refs.scrollContainer.$el
const $containerWidth = $container.offsetWidth
const $scrollWrapper = this.scrollWrapper
const tagList = this.$parent.$refs.tag
let firstTag = null
let lastTag = null
if (tagList.length > 0) {
firstTag = tagList[0]
lastTag = tagList[tagList.length - 1]
}
if (firstTag === currentTag) {
this.smoothScrollTo(0)
} else if (lastTag === currentTag) {
this.smoothScrollTo($scrollWrapper.scrollWidth - $containerWidth)
} else {
const currentIndex = tagList.findIndex(item => item === currentTag)
const prevTag = tagList[currentIndex - 1]
const nextTag = tagList[currentIndex + 1]
const afterNextTagOffsetLeft = nextTag.$el.offsetLeft + nextTag.$el.offsetWidth + tagAndTagSpacing
const beforePrevTagOffsetLeft = prevTag.$el.offsetLeft - tagAndTagSpacing
if (afterNextTagOffsetLeft > $scrollWrapper.scrollLeft + $containerWidth) {
this.smoothScrollTo(afterNextTagOffsetLeft - $containerWidth)
} else if (beforePrevTagOffsetLeft < $scrollWrapper.scrollLeft) {
this.smoothScrollTo(beforePrevTagOffsetLeft)
}
}
},
// 向左滚动固定距离
scrollLeft() {
const $scrollWrapper = this.scrollWrapper
this.smoothScrollTo(Math.max(0, $scrollWrapper.scrollLeft - 200))
},
// 向右滚动固定距离
scrollRight() {
const $scrollWrapper = this.scrollWrapper
const maxScroll = $scrollWrapper.scrollWidth - $scrollWrapper.clientWidth
this.smoothScrollTo(Math.min(maxScroll, $scrollWrapper.scrollLeft + 200))
},
// 直接平滑滚动到最左端
scrollToStart() {
this.smoothScrollTo(0)
},
// 直接平滑滚动到最右端
scrollToEnd() {
const $scrollWrapper = this.scrollWrapper
this.smoothScrollTo($scrollWrapper.scrollWidth - $scrollWrapper.clientWidth)
},
// 获取是否可以继续向左/右滚动
getScrollState() {
const $scrollWrapper = this.scrollWrapper
return {
canLeft: $scrollWrapper.scrollLeft > 0,
canRight: $scrollWrapper.scrollLeft < $scrollWrapper.scrollWidth - $scrollWrapper.clientWidth - 1
}
}
}
}
</script>
<style lang="scss" scoped>
.scroll-container {
white-space: nowrap;
position: relative;
overflow: hidden;
width: 100%;
::v-deep {
.el-scrollbar__bar {
bottom: 0px;
}
.el-scrollbar__wrap {
height: 34px;
display: flex;
align-items: center;
}
}
}
</style>
@@ -1,752 +0,0 @@
<template>
<div id="tags-view-container" class="tags-view-container">
<!-- 左切换箭头 -->
<span class="tags-nav-btn tags-nav-btn--left" :class="{ disabled: !canScrollLeft }" @click="scrollLeft">
<i class="el-icon-arrow-left" />
</span>
<!-- 标签滚动区 -->
<scroll-pane ref="scrollPane" class="tags-view-wrapper" @scroll="handleScroll" @updateArrows="updateArrowState">
<router-link
v-for="tag in visitedViews"
ref="tag"
:key="tag.path"
:class="{ 'active': isActive(tag) }"
:to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }"
tag="span"
class="tags-view-item"
:style="tagActiveStyle(tag)"
@click.middle.native="!isAffix(tag) ? closeSelectedTag(tag) : ''"
@contextmenu.prevent.native="openMenu(tag, $event)"
>
{{ tag.title }}
<span v-if="!isAffix(tag)" class="el-icon-close" @click.prevent.stop="closeSelectedTag(tag)" />
</router-link>
</scroll-pane>
<!-- 右切换箭头 -->
<span class="tags-nav-btn tags-nav-btn--right" :class="{ disabled: !canScrollRight }" @click="scrollRight">
<i class="el-icon-arrow-right" />
</span>
<!-- 下拉操作菜单 -->
<el-dropdown class="tags-action-dropdown" trigger="click" placement="bottom-end" @command="handleDropdownCommand">
<span class="tags-action-btn">
<i class="el-icon-arrow-down" />
</span>
<el-dropdown-menu slot="dropdown" class="tags-dropdown-menu">
<el-dropdown-item v-if="!isAffix(selectedDropdownTag)" command="close" icon="el-icon-close">关闭当前</el-dropdown-item>
<el-dropdown-item command="closeOthers" icon="el-icon-circle-close">关闭其他</el-dropdown-item>
<el-dropdown-item command="closeLeft" :disabled="isFirstView()" icon="el-icon-back">关闭左侧</el-dropdown-item>
<el-dropdown-item command="closeRight" :disabled="isLastView()" icon="el-icon-right">关闭右侧</el-dropdown-item>
<el-dropdown-item command="closeAll" icon="el-icon-circle-close">全部关闭</el-dropdown-item>
<el-dropdown-item command="fullscreen" divided>
<template v-if="!isFullscreen"><i class="el-icon-full-screen"></i>全屏显示</template>
<template v-else><i class="el-icon-close"></i>退出全屏</template>
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<!-- 刷新按钮 -->
<span class="tags-action-btn tags-refresh-btn" title="刷新页面" @click="refreshSelectedTag(selectedDropdownTag)">
<i class="el-icon-refresh-right" /> 刷新
</span>
<!-- 右键上下文菜单 -->
<ul v-show="visible" :style="{ left: left + 'px', top: top + 'px' }" class="contextmenu">
<li @click="refreshSelectedTag(selectedTag)"><i class="el-icon-refresh-right"></i> 刷新页面</li>
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)"><i class="el-icon-close"></i> 关闭当前</li>
<li @click="closeOthersTags"><i class="el-icon-circle-close"></i> 关闭其他</li>
<li v-if="!isFirstView()" @click="closeLeftTags"><i class="el-icon-back"></i> 关闭左侧</li>
<li v-if="!isLastView()" @click="closeRightTags"><i class="el-icon-right"></i> 关闭右侧</li>
<li @click="closeAllTags(selectedTag)"><i class="el-icon-circle-close"></i> 全部关闭</li>
</ul>
</div>
</template>
<script>
import ScrollPane from './ScrollPane'
import path from 'path'
export default {
components: { ScrollPane },
data() {
return {
visible: false,
top: 0,
left: 0,
selectedTag: {},
affixTags: [],
canScrollLeft: false,
canScrollRight: false,
isFullscreen: false,
hiddenElements: []
}
},
computed: {
visitedViews() {
return this.$store.state.tagsView.visitedViews
},
routes() {
return this.$store.state.permission.routes
},
selectedDropdownTag() {
return this.visitedViews.find(v => this.isActive(v)) || {}
}
},
watch: {
$route() {
this.addTags()
this.moveToCurrentTag()
},
visible(value) {
if (value) {
document.body.addEventListener('click', this.closeMenu)
} else {
document.body.removeEventListener('click', this.closeMenu)
}
},
visitedViews() {
this.$nextTick(() => {
this.updateArrowState()
})
}
},
mounted() {
this.initTags()
this.addTags()
window.addEventListener('resize', this.updateArrowState)
window.addEventListener('keydown', this.handleKeyDown)
},
beforeDestroy() {
window.removeEventListener('resize', this.updateArrowState)
window.removeEventListener('keydown', this.handleKeyDown)
},
methods: {
handleKeyDown(event) {
// 当按下Esc键且处于全屏状态时,退出全屏
if (event.key === 'Escape' && this.isFullscreen) {
this.toggleFullscreen()
}
},
isActive(route) {
return route.path === this.$route.path
},
tagActiveStyle(tag) {
if (!this.isActive(tag)) return {}
return {
"background-color": "#00875A",
"border-color": "#00875A"
}
},
isAffix(tag) {
return tag && tag.meta && tag.meta.affix
},
isFirstView() {
try {
const tag = this.selectedTag && this.selectedTag.fullPath ? this.selectedTag : this.selectedDropdownTag
return tag.fullPath === '/index' || tag.fullPath === this.visitedViews[1].fullPath
} catch (err) {
return false
}
},
isLastView() {
try {
const tag = this.selectedTag && this.selectedTag.fullPath ? this.selectedTag : this.selectedDropdownTag
return tag.fullPath === this.visitedViews[this.visitedViews.length - 1].fullPath
} catch (err) {
return false
}
},
filterAffixTags(routes, basePath = '/') {
let tags = []
routes.forEach(route => {
if (route.meta && route.meta.affix) {
const tagPath = path.resolve(basePath, route.path)
tags.push({
fullPath: tagPath,
path: tagPath,
name: route.name,
meta: { ...route.meta }
})
}
if (route.children) {
const tempTags = this.filterAffixTags(route.children, route.path)
if (tempTags.length >= 1) {
tags = [...tags, ...tempTags]
}
}
})
return tags
},
initTags() {
const affixTags = this.affixTags = this.filterAffixTags(this.routes)
for (const tag of affixTags) {
if (tag.name) {
this.$store.dispatch('tagsView/addAffixView', tag)
}
}
},
addTags() {
const { name } = this.$route
if (name) {
this.$store.dispatch('tagsView/addView', this.$route)
}
},
moveToCurrentTag() {
const tags = this.$refs.tag
this.$nextTick(() => {
for (const tag of tags) {
if (tag.to.path === this.$route.path) {
this.$refs.scrollPane.moveToTarget(tag)
if (tag.to.fullPath !== this.$route.fullPath) {
this.$store.dispatch('tagsView/updateVisitedView', this.$route)
}
break
}
}
})
},
scrollLeft() {
if (!this.canScrollLeft) return
this.$refs.scrollPane.scrollToStart()
},
scrollRight() {
if (!this.canScrollRight) return
this.$refs.scrollPane.scrollToEnd()
},
updateArrowState() {
this.$nextTick(() => {
if (this.$refs.scrollPane) {
const state = this.$refs.scrollPane.getScrollState()
this.canScrollLeft = state.canLeft
this.canScrollRight = state.canRight
}
})
},
toggleFullscreen() {
const mainContainer = document.querySelector('.main-container')
const navbar = document.querySelector('.navbar')
const sidebar = document.querySelector('.sidebar-container')
if (!mainContainer) return
if (!this.isFullscreen) {
mainContainer.classList.add('fullscreen-mode')
document.body.style.overflow = 'hidden'
const elementsToHide = [
{ el: navbar, originalDisplay: (navbar && navbar.style.display) || '' },
{ el: sidebar, originalDisplay: (sidebar && sidebar.style.display) || '' }
]
elementsToHide.forEach(item => {
if (item.el && item.el.style.display !== 'none') {
item.originalDisplay = item.el.style.display
item.el.style.display = 'none'
this.hiddenElements.push(item)
}
})
this.isFullscreen = true
} else {
mainContainer.classList.remove('fullscreen-mode')
document.body.style.overflow = ''
this.hiddenElements.forEach(item => {
if (item.el) {
item.el.style.display = item.originalDisplay
}
})
this.hiddenElements = []
const btn = document.querySelector('.tags-action-btn')
if (btn) btn.blur()
this.isFullscreen = false
}
},
handleDropdownCommand(command) {
const tag = this.selectedDropdownTag
this.selectedTag = tag
switch (command) {
case 'refresh':
this.refreshSelectedTag(tag)
break
case 'fullscreen':
this.toggleFullscreen()
break
case 'close':
this.closeSelectedTag(tag)
break
case 'closeOthers':
this.closeOthersTags()
break
case 'closeLeft':
this.closeLeftTags()
break
case 'closeRight':
this.closeRightTags()
break
case 'closeAll':
this.closeAllTags(tag)
break
}
},
refreshSelectedTag(view) {
this.$tab.refreshPage(view)
if (this.$route.meta.link) {
this.$store.dispatch('tagsView/delIframeView', this.$route)
}
},
closeSelectedTag(view) {
this.$tab.closePage(view).then(({ visitedViews }) => {
if (this.isActive(view)) {
this.toLastView(visitedViews, view)
}
})
},
closeRightTags() {
this.$tab.closeRightPage(this.selectedTag).then(visitedViews => {
if (!visitedViews.find(i => i.fullPath === this.$route.fullPath)) {
this.toLastView(visitedViews)
}
})
},
closeLeftTags() {
this.$tab.closeLeftPage(this.selectedTag).then(visitedViews => {
if (!visitedViews.find(i => i.fullPath === this.$route.fullPath)) {
this.toLastView(visitedViews)
}
})
},
closeOthersTags() {
this.$router.push(this.selectedTag.fullPath).catch(() => {})
this.$tab.closeOtherPage(this.selectedTag).then(() => {
this.moveToCurrentTag()
})
},
closeAllTags(view) {
this.$tab.closeAllPage().then(({ visitedViews }) => {
if (this.affixTags.some(tag => tag.path === this.$route.path)) {
return
}
this.toLastView(visitedViews, view)
})
},
toLastView(visitedViews, view) {
const latestView = visitedViews.slice(-1)[0]
if (latestView) {
this.$router.push(latestView.fullPath)
} else {
if (view && view.name === 'Dashboard') {
this.$router.replace({ path: '/redirect' + view.fullPath })
} else {
this.$router.push('/')
}
}
},
openMenu(tag, e) {
this.left = e.clientX
this.top = e.clientY
this.visible = true
this.selectedTag = tag
},
closeMenu() {
this.visible = false
},
handleScroll() {
this.closeMenu()
this.updateArrowState()
}
}
}
</script>
<style lang="scss" scoped>
$tags-bar-height: 34px;
.tags-view-container {
height: $tags-bar-height;
width: 100%;
background: #fff;
border-bottom: 1px solid #d8dce5;
display: flex;
align-items: center;
overflow: hidden;
$btn-width: 28px;
$btn-color: #71717a;
$btn-hover-bg: #f0f2f5;
$btn-hover-color: #303133;
$btn-disabled-color: #c0c4cc;
$divider: 1px solid #d8dce5;
.tags-nav-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: $btn-width;
height: $tags-bar-height;
cursor: pointer;
color: $btn-color;
font-size: 13px;
user-select: none;
transition: background 0.15s, color 0.15s;
&:hover:not(.disabled) {
background: $btn-hover-bg;
color: $btn-hover-color;
}
&.disabled {
color: $btn-disabled-color;
cursor: not-allowed;
}
&--left { border-right: $divider; }
&--right { border-left: $divider; }
}
.tags-view-wrapper {
flex: 1;
min-width: 0;
height: 100%;
.tags-view-item {
display: inline-block;
position: relative;
cursor: pointer;
height: 26px;
line-height: 26px;
border: 1px solid #d8dce5;
color: #495060;
background: #fff;
padding: 0 8px;
font-size: 12px;
margin-left: 5px;
border-radius: 3px;
&:first-of-type { margin-left: 6px; }
&:last-of-type { margin-right: 15px; }
}
}
&:not(.tags-view-container--chrome) .tags-view-wrapper .tags-view-item.active {
background-color: #42b983;
color: #fff;
border-color: #42b983;
&::before {
content: '';
background: #fff;
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
position: relative;
margin-right: 2px;
}
}
&:not(.tags-view-container--chrome) .tags-view-wrapper .tags-view-item.active.has-icon::before {
content: none !important;
}
.tags-action-dropdown {
flex-shrink: 0;
display: flex;
align-items: center;
}
.tags-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: $btn-width;
height: $tags-bar-height;
cursor: pointer;
color: $btn-color;
font-size: 13px;
border-left: $divider;
user-select: none;
transition: background 0.15s, color 0.15s;
&:hover {
background: $btn-hover-bg;
color: $btn-hover-color;
}
}
.tags-refresh-btn {
width: 60px;
}
.contextmenu {
margin: 0;
background: #fff;
z-index: 3000;
position: fixed;
list-style-type: none;
padding: 5px 0;
border-radius: 4px;
font-size: 12px;
font-weight: 400;
color: #333;
box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, .3);
li {
margin: 0;
padding: 7px 16px;
cursor: pointer;
&:hover {
background: #eee;
}
}
}
&.tags-view-container--chrome {
--chrome-strip-bg: #ffffff;
--chrome-strip-border: #e4e7ed;
--chrome-tab-text: #606266;
overflow: visible;
background: var(--chrome-strip-bg);
border-bottom: 1px solid var(--chrome-strip-border);
align-items: flex-end;
.tags-nav-btn {
align-self: stretch;
height: auto;
min-height: $tags-bar-height;
border-color: var(--chrome-strip-border);
}
.tags-action-btn {
border-color: var(--chrome-strip-border);
}
.tags-view-wrapper {
.tags-view-item {
display: inline-flex !important;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
height: 30px;
min-height: 30px;
margin: 0 0 -1px;
padding: 0 12px;
font-size: 13px;
font-weight: 400;
line-height: 1.2;
border: none !important;
border-radius: 0;
background: transparent !important;
color: var(--chrome-tab-text) !important;
padding-top: 0 !important;
box-shadow: none !important;
transition: background 0.12s ease, color 0.12s ease, border-radius 0.12s ease;
&::before,
&::after {
content: '' !important;
display: block !important;
position: absolute;
bottom: 0;
width: var(--chrome-wing-r);
height: var(--chrome-wing-r);
margin: 0 !important;
pointer-events: none;
background: transparent !important;
border-radius: 0 !important;
transition: box-shadow 0.12s ease;
}
&::before {
left: calc(-1 * var(--chrome-wing-r));
border-bottom-right-radius: var(--chrome-wing-r) !important;
box-shadow: none;
}
&::after {
right: calc(-1 * var(--chrome-wing-r));
border-bottom-left-radius: var(--chrome-wing-r) !important;
box-shadow: none;
}
&:first-of-type { margin-left: 6px; }
&:last-of-type { margin-right: 10px; }
&:not(.active) + .tags-view-item:not(.active) {
border-left: 1px solid #e4e7ed;
padding-left: 11px;
}
&:hover:not(.active) {
background: #f5f7fa !important;
border-radius: 6px 6px 0 0;
color: #303133 !important;
z-index: 2;
}
&.active {
height: 33px;
min-height: 33px;
padding: 0 14px;
z-index: 3;
color: var(--chrome-tab-text-active) !important;
font-weight: 500;
background: var(--chrome-tab-active-bg) !important;
border: none !important;
border-bottom: 2px solid var(--chrome-tab-active-bg) !important;
border-radius: var(--chrome-wing-r) var(--chrome-wing-r) 0 0;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
&::before {
box-shadow: calc(var(--chrome-wing-r) * 0.5) calc(var(--chrome-wing-r) * 0.5) 0 calc(var(--chrome-wing-r) * 0.5) var(--chrome-tab-active-bg);
}
&::after {
box-shadow: calc(var(--chrome-wing-r) * -0.5) calc(var(--chrome-wing-r) * 0.5) 0 calc(var(--chrome-wing-r) * 0.5) var(--chrome-tab-active-bg);
}
}
.el-icon-close {
margin-left: 3px;
&:before {
vertical-align: -2px;
}
}
}
}
}
}
</style>
<style lang="scss">
.tags-view-wrapper {
.el-scrollbar {
height: 100%;
overflow: hidden;
}
.el-scrollbar__wrap {
height: 34px !important;
display: flex;
align-items: center;
overflow-x: auto;
overflow-y: hidden;
&::-webkit-scrollbar {
width: 0;
height: 0;
}
.tags-view-container:hover & {
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 3px;
transition: background-color 0.2s;
&:hover {
background-color: rgba(0, 0, 0, 0.4);
}
}
}
scrollbar-width: none;
-ms-overflow-style: none;
}
.el-scrollbar__bar {
opacity: 0;
transition: opacity 0.3s;
z-index: 10;
.tags-view-container:hover & {
opacity: 1;
}
}
.tags-view-container--chrome & {
.el-scrollbar {
position: relative;
}
.el-scrollbar__wrap {
&::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
}
scrollbar-width: none;
-ms-overflow-style: none;
}
.el-scrollbar__bar.is-horizontal {
z-index: 20;
height: 6px !important;
}
}
.tags-view-item {
.el-icon-close {
width: 16px;
height: 16px;
vertical-align: 2px;
border-radius: 50%;
text-align: center;
transition: all .3s cubic-bezier(.645, .045, .355, 1);
transform-origin: 100% 50%;
&:before {
transform: scale(.6);
display: inline-block;
vertical-align: -3px;
}
&:hover {
background-color: #b4bccc;
color: #fff;
}
}
}
}
/* 页签全屏模式样式 */
.main-container.fullscreen-mode {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
margin-left: 0 !important;
transition: none !important;
}
.main-container.fullscreen-mode .fixed-header {
display: block !important;
position: fixed;
top: 0;
left: 0;
right: 0;
width: 100% !important;
z-index: 1000;
transition: none !important;
}
.main-container.fullscreen-mode .fixed-header .navbar {
display: none !important;
}
.main-container.fullscreen-mode .app-main {
position: fixed;
top: 34px;
left: 0;
right: 0;
bottom: 0;
margin: 0 !important;
padding: 0 !important;
height: calc(100vh - 34px) !important;
min-height: calc(100vh - 34px) !important;
overflow: auto;
}
</style>
-4
View File
@@ -1,4 +0,0 @@
export { default as AppMain } from './AppMain'
export { default as Navbar } from './Navbar'
export { default as Sidebar } from './Sidebar/index.vue'
export { default as TagsView } from './TagsView/index.vue'
-199
View File
@@ -1,199 +0,0 @@
<template>
<div :class="classObj" class="app-wrapper" :style="{'--current-color': '#00875A', '--current-color-light': '#00875A1a', '--current-color-dark-bg': '#00875A33'}">
<div v-if="device==='mobile'&&sidebar.opened" class="drawer-bg" @click="handleClickOutside"/>
<!-- 顶部全宽 Header -->
<div class="top-header">
<div class="top-header__left">
<hamburger
v-if="device === 'mobile'"
id="mobile-hamburger"
:is-active="sidebar.opened"
class="mobile-hamburger"
@toggleClick="toggleSideBar"
/>
<span class="sys-title" title="关闭所有已打开的页签" @click="handleCloseAllTabs">教学管理信息系统</span>
</div>
<semester-bar class="top-header__center" />
<navbar class="top-header__right" :header-mode="true" />
</div>
<!-- 下方:侧边栏 + 主内容 -->
<div class="lower-container" :class="{sidebarHide:sidebar.hide}">
<sidebar v-if="!sidebar.hide" class="sidebar-container"/>
<div :class="{hasTagsView:true,sidebarHide:sidebar.hide}" class="main-container">
<tags-view/>
<app-main/>
</div>
</div>
</div>
</template>
<script>
import { AppMain, Navbar, Sidebar, TagsView } from './components'
import SemesterBar from './components/SemesterBar'
import Hamburger from '@/components/Hamburger'
import ResizeMixin from './mixin/ResizeHandler'
import { mapState } from 'vuex'
import variables from '@/assets/styles/variables.scss'
export default {
name: 'Layout',
components: {
AppMain,
Navbar,
Sidebar,
TagsView,
SemesterBar,
Hamburger
},
mixins: [ResizeMixin],
computed: {
...mapState({
sidebar: state => state.app.sidebar,
device: state => state.app.device
}),
classObj() {
return {
hideSidebar: !this.sidebar.opened,
mobile: this.device === 'mobile'
}
},
variables() {
return variables
}
},
methods: {
handleClickOutside() {
this.$store.dispatch('app/closeSideBar', { withoutAnimation: false })
},
toggleSideBar() {
this.$store.dispatch('app/toggleSideBar')
},
handleCloseAllTabs() {
this.$store.dispatch('tagsView/delAllViews').then(({ visitedViews }) => {
const last = visitedViews[visitedViews.length - 1]
if (last) {
this.$router.push(last.fullPath).catch(() => {})
} else {
this.$router.push('/').catch(() => {})
}
})
}
}
}
</script>
<style lang="scss" scoped>
@import "~@/assets/styles/mixin.scss";
@import "~@/assets/styles/variables.scss";
.app-wrapper {
@include clearfix;
position: relative;
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
}
/* 顶部全宽 Header */
.top-header {
flex-shrink: 0;
height: 60px;
display: flex;
align-items: center;
background: var(--edu-green-primary);
background: linear-gradient(90deg, var(--edu-green-primary) 0%, var(--edu-green-dark) 100%);
box-shadow: 0 2px 8px rgba(0, 21, 41, 0.16);
padding: 0 20px;
z-index: 10;
&__left {
display: flex;
align-items: center;
flex-shrink: 0;
height: 100%;
gap: 14px;
.mobile-hamburger {
display: flex;
align-items: center;
justify-content: center;
height: 36px;
width: 36px;
border-radius: 8px;
cursor: pointer;
color: #fff;
transition: background 0.25s ease;
&:hover {
background: rgba(255, 255, 255, 0.15);
}
}
.sys-title {
font-size: 19px;
font-weight: 700;
color: #fff;
white-space: nowrap;
letter-spacing: 1.5px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
cursor: pointer;
border-radius: 6px;
padding: 4px 10px;
transition: background 0.25s ease;
&:hover {
background: rgba(255, 255, 255, 0.15);
}
}
}
&__center {
flex: 1;
min-width: 0;
display: flex;
justify-content: center;
height: 100%;
}
&__right {
flex-shrink: 0;
height: 100%;
}
}
/* 下方主体 */
.lower-container {
flex: 1;
min-height: 0;
display: flex;
position: relative;
}
.sidebar-container {
flex-shrink: 0;
}
.main-container {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
height: 100%;
background-color: #f5f7fa;
}
.drawer-bg {
background: #000;
opacity: 0.3;
width: 100%;
top: 0;
height: 100%;
position: absolute;
z-index: 999;
}
</style>
@@ -1,45 +0,0 @@
import store from '@/store'
const { body } = document
const WIDTH = 992 // refer to Bootstrap's responsive design
export default {
watch: {
$route(route) {
if (this.device === 'mobile' && this.sidebar.opened) {
store.dispatch('app/closeSideBar', { withoutAnimation: false })
}
}
},
beforeMount() {
window.addEventListener('resize', this.$_resizeHandler)
},
beforeDestroy() {
window.removeEventListener('resize', this.$_resizeHandler)
},
mounted() {
const isMobile = this.$_isMobile()
if (isMobile) {
store.dispatch('app/toggleDevice', 'mobile')
store.dispatch('app/closeSideBar', { withoutAnimation: true })
}
},
methods: {
// use $_ for mixins properties
// https://vuejs.org/v2/style-guide/index.html#Private-property-names-essential
$_isMobile() {
const rect = body.getBoundingClientRect()
return rect.width - 1 < WIDTH
},
$_resizeHandler() {
if (!document.hidden) {
const isMobile = this.$_isMobile()
store.dispatch('app/toggleDevice', isMobile ? 'mobile' : 'desktop')
if (isMobile) {
store.dispatch('app/closeSideBar', { withoutAnimation: true })
}
}
}
}
}