This commit is contained in:
2026-07-24 12:42:51 +08:00 Unverified
commit 67905dfa16
56 changed files with 7630 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
<template>
<RouterView />
</template>
+35
View File
@@ -0,0 +1,35 @@
import axios from 'axios'
const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL ?? '/api',
timeout: 15000,
})
http.interceptors.request.use((config) => {
const token = localStorage.getItem('jiaowu_token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
http.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401 && !error.config?.url?.endsWith('/auth/login')) {
localStorage.removeItem('jiaowu_token')
localStorage.removeItem('jiaowu_user')
window.location.assign(`/login?redirect=${encodeURIComponent(location.pathname)}`)
}
return Promise.reject(error)
},
)
export function apiErrorMessage(error: unknown) {
if (!axios.isAxiosError(error)) return '操作失败,请稍后重试。'
const data = error.response?.data
if (data?.errors) {
return Object.values(data.errors).flat().join('')
}
return data?.detail ?? data?.title ?? '操作失败,请检查网络连接。'
}
export default http
+11
View File
@@ -0,0 +1,11 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const ElMessage: typeof import('element-plus/es').ElMessage
const ElMessageBox: typeof import('element-plus/es').ElMessageBox
}
+38
View File
@@ -0,0 +1,38 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ElButton: typeof import('element-plus/es')['ElButton']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}
+103
View File
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { DataAnalysis, OfficeBuilding, Operation, User } from '@element-plus/icons-vue'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const collapsed = ref(false)
const mobileMenu = ref(false)
const pageTitle = computed(() => {
const titles: Record<string, string> = {
dashboard: '教务总览',
'base-data': '基础数据',
users: '用户与权限',
}
return titles[String(route.name)] ?? '教务管理'
})
function signOut() {
auth.logout()
router.push('/login')
}
onMounted(() => auth.refresh().catch(() => undefined))
</script>
<template>
<div class="shell" :class="{ 'is-collapsed': collapsed }">
<aside class="sidebar" :class="{ 'is-mobile-open': mobileMenu }">
<div class="brand">
<div class="brand-mark" aria-hidden="true">
<span v-for="index in 9" :key="index" />
</div>
<div v-if="!collapsed">
<strong>明序教务</strong>
<small>ACADEMIC OFFICE</small>
</div>
</div>
<div v-if="!collapsed" class="term-stamp">
<span>当前工作区</span>
<b>校级教务管理</b>
</div>
<el-menu
router
:default-active="route.path"
:collapse="collapsed"
class="nav-menu"
@select="mobileMenu = false"
>
<el-menu-item index="/dashboard">
<el-icon><DataAnalysis /></el-icon>
<template #title>教务总览</template>
</el-menu-item>
<el-menu-item index="/base-data">
<el-icon><OfficeBuilding /></el-icon>
<template #title>基础数据</template>
</el-menu-item>
<el-menu-item v-if="auth.isSuperAdmin" index="/users">
<el-icon><User /></el-icon>
<template #title>用户与权限</template>
</el-menu-item>
</el-menu>
<div v-if="!collapsed" class="phase-note">
<span>第一阶段 · 基础底座</span>
<p>组织权限和教学资源正在运行</p>
</div>
</aside>
<div v-if="mobileMenu" class="mobile-mask" @click="mobileMenu = false" />
<main class="main-area">
<header class="topbar">
<button class="menu-toggle desktop-only" type="button" @click="collapsed = !collapsed">
<el-icon><Operation /></el-icon>
</button>
<button class="menu-toggle mobile-only" type="button" @click="mobileMenu = true">
<el-icon><Operation /></el-icon>
</button>
<div class="page-heading">
<span>教务工作台</span>
<h1>{{ pageTitle }}</h1>
</div>
<div class="user-block">
<div class="avatar">{{ auth.user?.displayName?.slice(0, 1) ?? '管' }}</div>
<div class="user-copy">
<b>{{ auth.user?.displayName ?? '系统管理员' }}</b>
<span>{{ auth.user?.roles?.[0] ?? '教务人员' }}</span>
</div>
<el-button text @click="signOut">退出</el-button>
</div>
</header>
<section class="content">
<RouterView />
</section>
</main>
</div>
</template>
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
import router from './router'
createApp(App)
.use(createPinia())
.use(router)
.mount('#app')
+53
View File
@@ -0,0 +1,53 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import AdminLayout from '../layouts/AdminLayout.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'login',
component: () => import('../views/LoginView.vue'),
meta: { public: true },
},
{
path: '/',
component: AdminLayout,
children: [
{ path: '', redirect: '/dashboard' },
{
path: 'dashboard',
name: 'dashboard',
component: () => import('../views/DashboardView.vue'),
},
{
path: 'base-data',
name: 'base-data',
component: () => import('../views/BaseDataView.vue'),
},
{
path: 'users',
name: 'users',
component: () => import('../views/UsersView.vue'),
meta: { roles: ['SuperAdmin'] },
},
],
},
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
],
})
router.beforeEach((to) => {
const auth = useAuthStore()
if (!to.meta.public && !auth.isLoggedIn) {
return { name: 'login', query: { redirect: to.fullPath } }
}
if (to.name === 'login' && auth.isLoggedIn) return { name: 'dashboard' }
const roles = to.meta.roles as string[] | undefined
if (roles && !roles.some((role) => auth.user?.roles.includes(role))) {
return { name: 'dashboard' }
}
})
export default router
+43
View File
@@ -0,0 +1,43 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import http from '../api/http'
export interface CurrentUser {
id: string
userName: string
displayName: string
roles: string[]
collegeId?: string
}
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('jiaowu_token') ?? '')
const saved = localStorage.getItem('jiaowu_user')
const user = ref<CurrentUser | null>(saved ? JSON.parse(saved) : null)
const isLoggedIn = computed(() => Boolean(token.value))
const isSuperAdmin = computed(() => user.value?.roles.includes('SuperAdmin') ?? false)
async function login(userName: string, password: string) {
const { data } = await http.post('/auth/login', { userName, password })
token.value = data.token
user.value = data.user
localStorage.setItem('jiaowu_token', data.token)
localStorage.setItem('jiaowu_user', JSON.stringify(data.user))
}
async function refresh() {
if (!token.value) return
const { data } = await http.get('/auth/me')
user.value = data
localStorage.setItem('jiaowu_user', JSON.stringify(data))
}
function logout() {
token.value = ''
user.value = null
localStorage.removeItem('jiaowu_token')
localStorage.removeItem('jiaowu_user')
}
return { token, user, isLoggedIn, isSuperAdmin, login, refresh, logout }
})
+198
View File
@@ -0,0 +1,198 @@
:root {
font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
color: #182033;
background: #f3f5f8;
font-synthesis: none;
text-rendering: optimizeLegibility;
--ink: #182033;
--muted: #6d7689;
--indigo: #233876;
--indigo-deep: #152550;
--teal: #087f73;
--amber: #c78724;
--paper: #ffffff;
--line: #dfe4eb;
--soft: #f3f5f8;
--el-color-primary: #233876;
--el-color-success: #087f73;
--el-border-radius-base: 7px;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button, input, textarea, select { font: inherit; }
button { cursor: pointer; }
#app { min-height: 100vh; }
.shell { min-height: 100vh; display: grid; grid-template-columns: 256px 1fr; transition: grid-template-columns .2s ease; }
.shell.is-collapsed { grid-template-columns: 72px 1fr; }
.sidebar {
position: fixed; inset: 0 auto 0 0; width: 256px; z-index: 20; overflow: hidden;
display: flex; flex-direction: column; color: #eef2ff;
background:
linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px),
var(--indigo-deep);
background-size: 26px 26px;
transition: width .2s ease, transform .25s ease;
}
.is-collapsed .sidebar { width: 72px; }
.brand { height: 86px; padding: 0 24px; display: flex; align-items: center; gap: 13px; border-bottom: 1px solid rgba(255,255,255,.1); white-space: nowrap; }
.is-collapsed .brand { padding: 0 17px; }
.brand strong { display: block; font-family: "STZhongsong", "Songti SC", serif; font-size: 21px; letter-spacing: .12em; color: white; }
.brand small { display: block; margin-top: 3px; font: 9px/1.2 Consolas, monospace; letter-spacing: .16em; color: #9faad1; }
.brand-mark { flex: 0 0 auto; width: 31px; height: 31px; display: grid; grid-template-columns: repeat(3,1fr); gap: 3px; }
.brand-mark span { border: 1px solid #9eabd8; }
.brand-mark span:nth-child(2), .brand-mark span:nth-child(5), .brand-mark span:nth-child(8) { background: #3ec1ae; border-color: #3ec1ae; }
.brand-mark.large { width: 42px; height: 42px; gap: 4px; }
.term-stamp { margin: 24px 20px 11px; padding: 13px 15px; border-left: 2px solid #d89b42; background: rgba(255,255,255,.06); }
.term-stamp span { display: block; font-size: 11px; color: #aab3d4; }
.term-stamp b { display: block; margin-top: 5px; font-size: 13px; font-weight: 500; color: white; }
.nav-menu.el-menu { border: none; background: transparent; padding: 8px 10px; }
.nav-menu .el-menu-item { height: 48px; margin: 4px 0; border-radius: 7px; color: #bbc4e2; }
.nav-menu .el-menu-item:hover { color: white; background: rgba(255,255,255,.07); }
.nav-menu .el-menu-item.is-active { color: white; background: #2d478d; box-shadow: inset 3px 0 #46c6b5; }
.nav-menu .el-icon { font-size: 18px; }
.phase-note { margin: auto 20px 24px; padding-top: 17px; border-top: 1px solid rgba(255,255,255,.12); }
.phase-note span { color: #45c6b5; font-size: 11px; font-weight: 700; letter-spacing: .06em; }
.phase-note p { margin: 7px 0 0; color: #9faad0; font-size: 12px; line-height: 1.6; }
.main-area { grid-column: 2; min-width: 0; }
.topbar { height: 86px; padding: 0 32px; display: flex; align-items: center; gap: 20px; background: rgba(255,255,255,.96); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 10; backdrop-filter: blur(10px); }
.menu-toggle { width: 38px; height: 38px; border: 1px solid var(--line); border-radius: 7px; color: var(--indigo); background: white; }
.page-heading span { display: block; color: #9aa1af; font-size: 10px; letter-spacing: .14em; text-transform: uppercase; }
.page-heading h1 { margin: 3px 0 0; font-size: 20px; font-weight: 650; letter-spacing: .04em; }
.user-block { margin-left: auto; display: flex; align-items: center; gap: 11px; }
.avatar { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 50%; color: white; background: var(--teal); font-weight: 700; }
.user-copy b, .user-copy span { display: block; }
.user-copy b { font-size: 13px; }
.user-copy span { margin-top: 3px; color: var(--muted); font-size: 11px; }
.content { padding: 28px 32px 48px; max-width: 1540px; margin: 0 auto; }
.mobile-only { display: none; }
.section-kicker { color: var(--teal); font: 700 10px/1.2 Consolas, monospace; letter-spacing: .16em; }
.term-hero { min-height: 178px; padding: 32px 36px; display: flex; align-items: end; color: white; background: linear-gradient(118deg, #233876, #192d67 65%, #116d70); position: relative; overflow: hidden; }
.term-hero::after { content: ""; position: absolute; right: -40px; top: -110px; width: 360px; height: 360px; border: 54px solid rgba(255,255,255,.055); border-radius: 50%; }
.term-hero h2 { margin: 12px 0 7px; font-family: "STZhongsong", "Songti SC", serif; font-size: clamp(25px, 3vw, 38px); letter-spacing: .05em; }
.term-hero p { margin: 0; color: #c8d1ed; font-size: 13px; }
.term-hero button { position: relative; z-index: 1; margin-left: auto; width: 226px; padding: 15px 17px; display: flex; justify-content: space-between; border: 1px solid rgba(255,255,255,.35); color: white; background: rgba(255,255,255,.08); }
.term-hero button:hover { background: rgba(255,255,255,.15); }
.metric-grid { margin-top: 18px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
.metric-card { min-height: 108px; padding: 22px; display: flex; align-items: center; gap: 17px; border: 1px solid var(--line); background: white; }
.metric-card .el-icon { width: 41px; height: 41px; border-radius: 50%; color: var(--indigo); background: #edf0f8; font-size: 19px; }
.metric-card span { display: block; color: var(--muted); font-size: 12px; }
.metric-card strong { display: block; margin-top: 5px; font: 700 27px/1 Consolas, monospace; color: var(--ink); }
.metric-card small { margin-left: 4px; color: var(--muted); font: 400 11px/1 sans-serif; }
.dashboard-grid { margin-top: 18px; display: grid; grid-template-columns: 1.35fr 1fr; gap: 18px; }
.work-card { min-height: 270px; padding: 28px; border: 1px solid var(--line); background: white; }
.card-heading { display: flex; justify-content: space-between; align-items: flex-start; }
.work-card h3 { margin: 8px 0 0; font-size: 18px; }
.status-chip { padding: 5px 9px; border-radius: 20px; color: var(--teal); background: #e8f5f2; font-size: 11px; }
.foundation-list { margin-top: 24px; }
.foundation-list > div { display: grid; grid-template-columns: 82px 1fr auto; gap: 14px; align-items: center; padding: 14px 0; border-top: 1px solid #eaedf1; }
.foundation-list span { color: var(--muted); font-size: 12px; }
.foundation-list b { font-size: 13px; font-weight: 550; }
.foundation-list i { font-size: 11px; font-style: normal; color: var(--teal); }
.phase-card { background: #fbfaf6; border-color: #e9e2d2; }
.phase-card p { max-width: 420px; margin: 16px 0 31px; color: var(--muted); font-size: 13px; line-height: 1.8; }
.phase-line { display: grid; grid-template-columns: repeat(4, 1fr); border-top: 2px solid #ded8c9; }
.phase-line span { position: relative; padding-top: 14px; color: #9b9588; font-size: 11px; }
.phase-line span::before { content: ""; position: absolute; top: -5px; left: 0; width: 8px; height: 8px; border-radius: 50%; background: #c7c0b0; }
.phase-line span.active { color: var(--amber); font-weight: 700; }
.phase-line span.active::before { background: var(--amber); box-shadow: 0 0 0 4px #f5ead6; }
.page-stack { display: grid; gap: 18px; }
.page-intro { min-height: 106px; padding: 8px 4px; display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.page-intro h2 { margin: 7px 0 7px; font-family: "STZhongsong", "Songti SC", serif; font-size: 27px; }
.page-intro p { margin: 0; color: var(--muted); font-size: 13px; }
.data-card { border: 1px solid var(--line); background: white; overflow: hidden; }
.data-tabs { display: grid; grid-template-columns: repeat(7, minmax(105px, 1fr)); border-bottom: 1px solid var(--line); overflow-x: auto; }
.data-tabs button { min-width: 110px; padding: 16px 15px 14px; text-align: left; border: none; border-right: 1px solid #eaedf1; border-bottom: 3px solid transparent; color: var(--ink); background: #fafbfc; }
.data-tabs button:hover { background: white; }
.data-tabs button.active { border-bottom-color: var(--teal); background: white; }
.data-tabs b, .data-tabs span { display: block; }
.data-tabs b { font-size: 13px; }
.data-tabs span { margin-top: 4px; color: #9299a7; font-size: 10px; white-space: nowrap; }
.table-toolbar { min-height: 69px; padding: 14px 18px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid var(--line); }
.table-toolbar .el-input { width: min(340px, 60vw); }
.table-toolbar > span { margin-left: auto; color: var(--muted); font-size: 11px; }
.data-table { min-height: 360px; }
.el-table th.el-table__cell { color: #596274; background: #fafbfc; font-size: 12px; font-weight: 650; }
.el-table .cell { font-size: 12px; }
.table-status { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: var(--teal); }
.table-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.table-status.off { color: #a0a6b1; }
.role-tag { margin: 2px 4px 2px 0; }
.entity-form .el-select, .entity-form .el-date-editor { width: 100%; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.form-grid.compact { align-items: center; }
.login-page { min-height: 100vh; display: grid; grid-template-columns: minmax(440px, 1.2fr) minmax(420px, .8fr); background: white; }
.login-story { min-height: 100vh; padding: 54px clamp(45px, 6vw, 90px); display: flex; flex-direction: column; color: white; background: linear-gradient(142deg, #13224d, #243a77 62%, #176b71); overflow: hidden; position: relative; }
.login-story::before { content: ""; position: absolute; inset: 0; opacity: .28; background-image: linear-gradient(rgba(255,255,255,.06) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.06) 1px, transparent 1px); background-size: 46px 46px; }
.story-brand, .story-copy, .schedule-signature, .story-foot { position: relative; z-index: 1; }
.story-brand { display: flex; align-items: center; gap: 16px; }
.story-brand strong { display: block; font-family: "STZhongsong", "Songti SC", serif; font-size: 25px; letter-spacing: .15em; }
.story-brand small { display: block; margin-top: 5px; color: #b7c1e2; font: 9px Consolas, monospace; letter-spacing: .17em; }
.story-copy { margin: auto 0 35px; }
.eyebrow { color: #5bd2c1; font-size: 11px; font-weight: 700; letter-spacing: .18em; }
.story-copy h1 { margin: 17px 0 20px; font-family: "STZhongsong", "Songti SC", serif; font-size: clamp(34px, 4vw, 55px); line-height: 1.34; font-weight: 500; letter-spacing: .03em; }
.story-copy p { max-width: 600px; margin: 0; color: #c4cce5; font-size: 14px; line-height: 1.8; }
.schedule-signature { width: min(600px, 90%); display: grid; grid-template-columns: repeat(5, 1fr); border-top: 1px solid rgba(255,255,255,.18); border-left: 1px solid rgba(255,255,255,.18); }
.schedule-signature div { aspect-ratio: 2.05; border-right: 1px solid rgba(255,255,255,.18); border-bottom: 1px solid rgba(255,255,255,.18); }
.schedule-signature div.active { display: grid; place-items: center; color: white; background: rgba(50,193,173,.52); font-size: 10px; }
.story-foot { margin: 20px 0 0; color: #9da9cc; font-size: 11px; letter-spacing: .08em; }
.login-panel { display: grid; place-items: center; padding: 45px; }
.login-form { width: min(390px, 100%); }
.form-intro { margin-bottom: 36px; }
.form-intro span { color: var(--teal); font-size: 12px; font-weight: 700; }
.form-intro h2 { margin: 8px 0 8px; font-family: "STZhongsong", "Songti SC", serif; font-size: 29px; }
.form-intro p { margin: 0; color: var(--muted); font-size: 13px; }
.login-form label { display: block; margin-bottom: 20px; }
.login-form label > span { display: block; margin-bottom: 8px; color: #525b6d; font-size: 12px; font-weight: 650; }
.login-submit { width: 100%; margin-top: 6px; height: 46px; }
.dev-hint { margin-top: 24px; padding: 13px 15px; display: flex; justify-content: space-between; color: #767e8d; background: #f5f7fa; font-size: 11px; }
@media (max-width: 1100px) {
.metric-grid { grid-template-columns: repeat(2, 1fr); }
.dashboard-grid { grid-template-columns: 1fr; }
.login-page { grid-template-columns: 1fr 430px; }
.login-story { padding-inline: 42px; }
}
@media (max-width: 980px) {
.shell, .shell.is-collapsed { display: block; }
.sidebar, .is-collapsed .sidebar { width: 256px; transform: translateX(-100%); }
.sidebar.is-mobile-open { transform: translateX(0); }
.main-area { width: 100%; }
.mobile-mask { position: fixed; inset: 0; z-index: 15; background: rgba(13,24,48,.45); }
.desktop-only { display: none; }
.mobile-only { display: inline-grid; place-items: center; }
.topbar { height: 72px; padding: 0 16px; }
.user-copy { display: none; }
.content { padding: 18px 14px 35px; }
.term-hero { min-height: 220px; padding: 25px; flex-direction: column; align-items: flex-start; justify-content: flex-end; }
.term-hero button { margin: 22px 0 0; width: 100%; }
.metric-grid { grid-template-columns: 1fr 1fr; gap: 10px; }
.metric-card { padding: 15px; min-height: 90px; }
.metric-card .el-icon { display: none; }
.dashboard-grid { gap: 10px; }
.work-card { padding: 21px; }
.foundation-list > div { grid-template-columns: 72px 1fr; }
.foundation-list i { display: none; }
.page-intro { align-items: flex-end; }
.page-intro p { display: none; }
.data-card { overflow: visible; }
.form-grid { grid-template-columns: 1fr; gap: 0; }
.el-dialog { width: calc(100vw - 24px) !important; }
.login-page { display: block; min-height: 100vh; background: #f4f6f9; }
.login-story { min-height: 310px; padding: 30px 25px; }
.story-copy { margin: auto 0 0; }
.story-copy h1 { font-size: 30px; margin-bottom: 0; }
.story-copy p, .schedule-signature, .story-foot { display: none; }
.login-panel { padding: 32px 22px; background: white; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; }
}
+264
View File
@@ -0,0 +1,264 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
type Kind = 'campuses' | 'colleges' | 'majors' | 'classes' | 'terms' | 'buildings' | 'classrooms'
interface Row { id: string; code: string; name: string; isEnabled: boolean; [key: string]: unknown }
const tabs: { key: Kind; label: string; hint: string }[] = [
{ key: 'campuses', label: '校区', hint: '学校的物理校区' },
{ key: 'colleges', label: '学院', hint: '教学组织单位' },
{ key: 'majors', label: '专业', hint: '专业与授予学位' },
{ key: 'classes', label: '行政班', hint: '按年级组织的班级' },
{ key: 'terms', label: '学期', hint: '教学运行时间轴' },
{ key: 'buildings', label: '教学楼', hint: '校区内教学建筑' },
{ key: 'classrooms', label: '教室', hint: '可排课教学空间' },
]
const auth = useAuthStore()
const active = ref<Kind>('campuses')
const rows = ref<Row[]>([])
const loading = ref(false)
const dialogVisible = ref(false)
const editingId = ref('')
const keyword = ref('')
const references = reactive<Record<string, Row[]>>({
campuses: [], colleges: [], majors: [], buildings: [],
})
const form = reactive<Record<string, any>>({})
const title = computed(() => tabs.find((x) => x.key === active.value)?.label ?? '')
const canManage = computed(() =>
auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false,
)
const filteredRows = computed(() => {
const q = keyword.value.trim().toLowerCase()
if (!q) return rows.value
return rows.value.filter((row) =>
[row.code, row.name, row.collegeName, row.campusName, row.majorName, row.buildingName]
.filter(Boolean).some((value) => String(value).toLowerCase().includes(q)),
)
})
function resetForm(row?: Row) {
Object.keys(form).forEach((key) => delete form[key])
Object.assign(form, {
code: '', name: '', sortOrder: 0, isEnabled: true,
campusId: undefined, collegeId: undefined, majorId: undefined, buildingId: undefined,
shortName: '', degreeType: '学士', schoolingYears: 4,
grade: new Date().getFullYear(), counselorName: '',
academicYear: `${new Date().getFullYear()}-${new Date().getFullYear() + 1}`,
season: 'Autumn', startDate: '', endDate: '', isCurrent: false,
capacity: 60, roomType: '普通教室', equipment: '',
}, row ?? {})
}
async function load() {
loading.value = true
try {
rows.value = (await http.get(`/base-data/${active.value}`)).data
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function loadReferences() {
const kinds = ['campuses', 'colleges', 'majors', 'buildings'] as const
await Promise.all(kinds.map(async (kind) => {
references[kind] = (await http.get(`/base-data/${kind}`)).data
}))
}
async function changeTab() {
keyword.value = ''
await load()
}
function openCreate() {
editingId.value = ''
resetForm()
dialogVisible.value = true
}
function openEdit(row: any) {
editingId.value = row.id
resetForm(row)
dialogVisible.value = true
}
async function save() {
if (!form.code?.trim() || !form.name?.trim()) {
ElMessage.warning('请填写编码和名称。')
return
}
try {
if (editingId.value) {
await http.put(`/base-data/${active.value}/${editingId.value}`, form)
} else {
await http.post(`/base-data/${active.value}`, form)
}
ElMessage.success(editingId.value ? '已保存修改' : `已新增${title.value}`)
dialogVisible.value = false
await Promise.all([load(), loadReferences()])
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function remove(row: any) {
try {
await ElMessageBox.confirm(
`确定删除“${row.name}”吗?已被业务引用的数据将无法删除。`,
'删除确认',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
)
await http.delete(`/base-data/${active.value}/${row.id}`)
ElMessage.success('已删除')
await load()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
onMounted(async () => {
await Promise.all([load(), loadReferences()])
})
</script>
<template>
<div class="page-stack">
<section class="page-intro">
<div>
<span class="section-kicker">MASTER DATA</span>
<h2>基础数据</h2>
<p>先建立稳定的组织与教学资源编码后续业务均从这里引用</p>
</div>
<el-button v-if="canManage" type="primary" :icon="Plus" @click="openCreate">新增{{ title }}</el-button>
</section>
<section class="data-card">
<nav class="data-tabs" aria-label="基础数据分类">
<button
v-for="tab in tabs"
:key="tab.key"
type="button"
:class="{ active: active === tab.key }"
@click="active = tab.key; changeTab()"
>
<b>{{ tab.label }}</b>
<span>{{ tab.hint }}</span>
</button>
</nav>
<div class="table-toolbar">
<el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="搜索编码、名称或所属单位" />
<el-button :icon="Refresh" @click="load">刷新</el-button>
<span> {{ filteredRows.length }} </span>
</div>
<el-table v-loading="loading" :data="filteredRows" class="data-table">
<el-table-column prop="code" label="编码" min-width="130" />
<el-table-column prop="name" label="名称" min-width="180" />
<el-table-column v-if="active === 'colleges'" prop="campusName" label="所属校区" min-width="140" />
<el-table-column v-if="active === 'majors'" prop="collegeName" label="所属学院" min-width="150" />
<el-table-column v-if="active === 'majors'" prop="degreeType" label="学位类型" min-width="120" />
<el-table-column v-if="active === 'classes'" prop="majorName" label="所属专业" min-width="170" />
<el-table-column v-if="active === 'classes'" prop="grade" label="年级" width="90" />
<el-table-column v-if="active === 'terms'" prop="academicYear" label="学年" width="120" />
<el-table-column v-if="active === 'terms'" label="当前学期" width="100">
<template #default="{ row }"><el-tag v-if="row.isCurrent" type="success">当前</el-tag><span v-else></span></template>
</el-table-column>
<el-table-column v-if="active === 'buildings'" prop="campusName" label="所属校区" min-width="140" />
<el-table-column v-if="active === 'classrooms'" prop="buildingName" label="教学楼" min-width="130" />
<el-table-column v-if="active === 'classrooms'" prop="capacity" label="容量" width="90" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<span class="table-status" :class="{ off: !row.isEnabled }">{{ row.isEnabled ? '启用' : '停用' }}</span>
</template>
</el-table-column>
<el-table-column v-if="canManage" label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无数据,点击右上角开始新增" /></template>
</el-table>
</section>
<el-dialog v-model="dialogVisible" :title="`${editingId ? '编辑' : '新增'}${title}`" width="560px">
<el-form label-position="top" class="entity-form">
<div class="form-grid">
<el-form-item label="编码" required><el-input v-model="form.code" placeholder="用于唯一识别" /></el-form-item>
<el-form-item label="名称" required><el-input v-model="form.name" /></el-form-item>
</div>
<el-form-item v-if="active === 'campuses'" label="地址">
<el-input v-model="form.description" />
</el-form-item>
<el-form-item v-if="active === 'colleges' || active === 'buildings'" label="所属校区">
<el-select v-model="form.campusId" clearable>
<el-option v-for="item in references.campuses" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item v-if="active === 'colleges'" label="学院简称"><el-input v-model="form.shortName" /></el-form-item>
<el-form-item v-if="active === 'majors'" label="所属学院" required>
<el-select v-model="form.collegeId">
<el-option v-for="item in references.colleges" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<div v-if="active === 'majors'" class="form-grid">
<el-form-item label="学位类型"><el-input v-model="form.degreeType" /></el-form-item>
<el-form-item label="学制"><el-input-number v-model="form.schoolingYears" :min="1" :max="8" /></el-form-item>
</div>
<el-form-item v-if="active === 'classes'" label="所属专业" required>
<el-select v-model="form.majorId" filterable>
<el-option v-for="item in references.majors" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<div v-if="active === 'classes'" class="form-grid">
<el-form-item label="年级"><el-input-number v-model="form.grade" :min="2000" :max="2200" /></el-form-item>
<el-form-item label="辅导员"><el-input v-model="form.counselorName" /></el-form-item>
</div>
<template v-if="active === 'terms'">
<div class="form-grid">
<el-form-item label="学年"><el-input v-model="form.academicYear" /></el-form-item>
<el-form-item label="学期季">
<el-select v-model="form.season">
<el-option label="秋季学期" value="Autumn" />
<el-option label="春季学期" value="Spring" />
<el-option label="夏季学期" value="Summer" />
</el-select>
</el-form-item>
</div>
<div class="form-grid">
<el-form-item label="开始日期"><el-date-picker v-model="form.startDate" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="结束日期"><el-date-picker v-model="form.endDate" value-format="YYYY-MM-DD" /></el-form-item>
</div>
<el-form-item><el-checkbox v-model="form.isCurrent">设为当前学期</el-checkbox></el-form-item>
</template>
<el-form-item v-if="active === 'classrooms'" label="所属教学楼" required>
<el-select v-model="form.buildingId">
<el-option v-for="item in references.buildings" :key="item.id" :label="`${item.campusName} · ${item.name}`" :value="item.id" />
</el-select>
</el-form-item>
<div v-if="active === 'classrooms'" class="form-grid">
<el-form-item label="容量"><el-input-number v-model="form.capacity" :min="1" :max="1000" /></el-form-item>
<el-form-item label="教室类型"><el-input v-model="form.roomType" /></el-form-item>
</div>
<el-form-item v-if="active === 'classrooms'" label="设备"><el-input v-model="form.equipment" /></el-form-item>
<div class="form-grid compact">
<el-form-item label="排序"><el-input-number v-model="form.sortOrder" :min="0" /></el-form-item>
<el-form-item label="状态"><el-switch v-model="form.isEnabled" active-text="启用" inactive-text="停用" /></el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
+100
View File
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Calendar, Connection, OfficeBuilding, School } from '@element-plus/icons-vue'
import http from '../api/http'
interface DashboardData {
currentTerm?: { name: string; startDate: string; endDate: string }
counts: Record<string, number>
}
const router = useRouter()
const loading = ref(true)
const data = ref<DashboardData>({ counts: {} })
const stats = computed(() => [
{ label: '校区', value: data.value.counts.campuses ?? 0, unit: '个', icon: OfficeBuilding },
{ label: '学院', value: data.value.counts.colleges ?? 0, unit: '个', icon: School },
{ label: '专业', value: data.value.counts.majors ?? 0, unit: '个', icon: Connection },
{ label: '行政班', value: data.value.counts.classes ?? 0, unit: '个', icon: Calendar },
])
onMounted(async () => {
try {
data.value = (await http.get('/dashboard')).data
} finally {
loading.value = false
}
})
</script>
<template>
<div v-loading="loading" class="dashboard">
<section class="term-hero">
<div>
<span class="section-kicker">CURRENT TERM</span>
<h2>{{ data.currentTerm?.name ?? '尚未设置当前学期' }}</h2>
<p v-if="data.currentTerm">
{{ data.currentTerm.startDate }} {{ data.currentTerm.endDate }}
</p>
<p v-else>请先在基础数据中建立学期档案</p>
</div>
<button type="button" @click="router.push('/base-data')">
<span>维护学期与组织数据</span>
<b></b>
</button>
</section>
<section class="metric-grid">
<article v-for="stat in stats" :key="stat.label" class="metric-card">
<el-icon><component :is="stat.icon" /></el-icon>
<div>
<span>{{ stat.label }}</span>
<strong>{{ stat.value }}<small>{{ stat.unit }}</small></strong>
</div>
</article>
</section>
<section class="dashboard-grid">
<article class="work-card foundation-card">
<div class="card-heading">
<div>
<span class="section-kicker">FOUNDATION</span>
<h3>基础数据完整度</h3>
</div>
<span class="status-chip">运行中</span>
</div>
<div class="foundation-list">
<div>
<span>组织体系</span>
<b>校区 学院 专业 行政班</b>
<i class="done">已建立</i>
</div>
<div>
<span>教学空间</span>
<b>校区 教学楼 教室</b>
<i class="done">已建立</i>
</div>
<div>
<span>权限体系</span>
<b>角色权限 + 学院数据范围</b>
<i class="done">已建立</i>
</div>
</div>
</article>
<article class="work-card phase-card">
<span class="section-kicker">NEXT MILESTONE</span>
<h3>下一段业务链</h3>
<p>基础数据确认后将进入学生教师课程库和培养方案</p>
<div class="phase-line">
<span class="active">基础底座</span>
<span>人员档案</span>
<span>培养方案</span>
<span>教学运行</span>
</div>
</article>
</section>
</div>
</template>
+89
View File
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const loading = ref(false)
const form = reactive({ userName: 'admin', password: 'Admin@123456' })
async function submit() {
loading.value = true
try {
await auth.login(form.userName, form.password)
await router.replace(String(route.query.redirect ?? '/dashboard'))
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
</script>
<template>
<main class="login-page">
<section class="login-story">
<div class="story-brand">
<div class="brand-mark large" aria-hidden="true">
<span v-for="index in 9" :key="index" />
</div>
<div>
<strong>明序教务</strong>
<small>MINGXU ACADEMIC SYSTEM</small>
</div>
</div>
<div class="story-copy">
<span class="eyebrow">大学教务管理平台</span>
<h1>让每一项教学安排<br />都有清晰的来处与去向</h1>
<p>从组织档案开始逐步连接培养方案教学任务排课选课与成绩</p>
</div>
<div class="schedule-signature" aria-hidden="true">
<div v-for="item in 20" :key="item" :class="{ active: [3, 8, 9, 14, 18].includes(item) }">
<span v-if="[3, 8, 9, 14, 18].includes(item)">教学</span>
</div>
</div>
<p class="story-foot">第一阶段 · 基础管理工作台</p>
</section>
<section class="login-panel">
<form class="login-form" @submit.prevent="submit">
<div class="form-intro">
<span>欢迎回来</span>
<h2>登录教务工作台</h2>
<p>使用学校分配的管理账号继续</p>
</div>
<label>
<span>账号</span>
<el-input v-model="form.userName" size="large" autocomplete="username" />
</label>
<label>
<span>密码</span>
<el-input
v-model="form.password"
size="large"
type="password"
show-password
autocomplete="current-password"
@keyup.enter="submit"
/>
</label>
<el-button
class="login-submit"
type="primary"
size="large"
native-type="submit"
:loading="loading"
>
进入工作台
</el-button>
<div class="dev-hint">
<b>本地开发账号</b>
<span>admin / Admin@123456</span>
</div>
</form>
</section>
</main>
</template>
+142
View File
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { Plus, Search } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
interface UserRow {
id: string; userName: string; displayName: string; staffNumber?: string
roles: string[]; isEnabled: boolean; lastLoginAt?: string
}
interface Role { name: string; description: string }
const users = ref<UserRow[]>([])
const roles = ref<Role[]>([])
const colleges = ref<any[]>([])
const loading = ref(false)
const dialogVisible = ref(false)
const keyword = ref('')
const form = reactive({
userName: '', displayName: '', password: '', staffNumber: '',
collegeId: undefined as string | undefined, roles: [] as string[],
})
async function load() {
loading.value = true
try {
const [userRes, roleRes, collegeRes] = await Promise.all([
http.get('/users'), http.get('/users/roles'), http.get('/base-data/colleges'),
])
users.value = userRes.data
roles.value = roleRes.data
colleges.value = collegeRes.data
} finally {
loading.value = false
}
}
function openCreate() {
Object.assign(form, {
userName: '', displayName: '', password: '', staffNumber: '',
collegeId: undefined, roles: [],
})
dialogVisible.value = true
}
async function create() {
try {
await http.post('/users', form)
ElMessage.success('账号已创建')
dialogVisible.value = false
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function setStatus(row: any) {
try {
await http.put(`/users/${row.id}/status`, { isEnabled: !row.isEnabled })
ElMessage.success(row.isEnabled ? '账号已停用' : '账号已启用')
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
onMounted(load)
</script>
<template>
<div class="page-stack">
<section class="page-intro">
<div>
<span class="section-kicker">IDENTITY & ACCESS</span>
<h2>用户与权限</h2>
<p>账号决定谁能进入系统角色与学院范围决定能看到哪些数据</p>
</div>
<el-button type="primary" :icon="Plus" @click="openCreate">创建账号</el-button>
</section>
<section class="data-card">
<div class="table-toolbar">
<el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="搜索账号、姓名或工号" />
<span> {{ users.length }} 个账号</span>
</div>
<el-table
v-loading="loading"
:data="users.filter((x) => !keyword || `${x.userName}${x.displayName}${x.staffNumber}`.includes(keyword))"
>
<el-table-column prop="userName" label="账号" min-width="130" />
<el-table-column prop="displayName" label="姓名" min-width="120" />
<el-table-column prop="staffNumber" label="工号/学号" min-width="130">
<template #default="{ row }">{{ row.staffNumber || '—' }}</template>
</el-table-column>
<el-table-column label="角色" min-width="220">
<template #default="{ row }">
<el-tag v-for="role in row.roles" :key="role" class="role-tag" effect="plain">{{ role }}</el-tag>
</template>
</el-table-column>
<el-table-column label="最后登录" min-width="170">
<template #default="{ row }">{{ row.lastLoginAt ? new Date(row.lastLoginAt).toLocaleString() : '尚未登录' }}</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }"><span class="table-status" :class="{ off: !row.isEnabled }">{{ row.isEnabled ? '启用' : '停用' }}</span></template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button link :type="row.isEnabled ? 'danger' : 'primary'" @click="setStatus(row)">
{{ row.isEnabled ? '停用' : '启用' }}
</el-button>
</template>
</el-table-column>
</el-table>
</section>
<el-dialog v-model="dialogVisible" title="创建账号" width="540px">
<el-form label-position="top" class="entity-form">
<div class="form-grid">
<el-form-item label="登录账号" required><el-input v-model="form.userName" /></el-form-item>
<el-form-item label="姓名" required><el-input v-model="form.displayName" /></el-form-item>
</div>
<div class="form-grid">
<el-form-item label="初始密码" required><el-input v-model="form.password" type="password" show-password /></el-form-item>
<el-form-item label="工号/学号"><el-input v-model="form.staffNumber" /></el-form-item>
</div>
<el-form-item label="所属学院">
<el-select v-model="form.collegeId" clearable>
<el-option v-for="item in colleges" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="角色" required>
<el-select v-model="form.roles" multiple>
<el-option v-for="role in roles" :key="role.name" :label="`${role.name} · ${role.description}`" :value="role.name" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="create">创建账号</el-button>
</template>
</el-dialog>
</div>
</template>