feat: 班级小助手 阶段1-3(monorepo + 后端 REST/Socket.IO + Web 管理端)

This commit is contained in:
LoveChengke committed 2026-09-11 21:57:38 +08:00
commit 84ab49de10
102 files changed
+14751

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
# ---------------------------------------------------------------------------
# Web 管理端环境变量(可选)
# 默认直连 http://127.0.0.1:4000,一般无需配置。
# 复制为 .env 后按需修改:cp .env.example .env
# ---------------------------------------------------------------------------
# 后端 API 基地址
# 直连(默认):http://127.0.0.1:4000/api
# 走 Vite 开发代理(vite.config.ts 已配置 /api -> 后端):/api
# VITE_API_BASE_URL=http://127.0.0.1:4000/api
# Socket.IO 服务地址(不含 /socket.io 路径)
# VITE_SOCKET_URL=http://127.0.0.1:4000
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>班级小助手 · 管理端</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@classhelper/web-admin",
"version": "0.1.0",
"private": true,
"description": "班级小助手 Web 管理端(Vue 3 + Vite + Element Plus)",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit -p tsconfig.json && vite build",
"build:only": "vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@classhelper/shared": "workspace:*",
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.20.0",
"echarts": "^6.1.0",
"element-plus": "^2.14.5",
"pinia": "^4.0.3",
"socket.io-client": "^4.8.3",
"vue": "^3.5.42",
"vue-router": "^5.3.1"
},
"devDependencies": {
"@types/node": "^22.20.2",
"@vitejs/plugin-vue": "^6.0.8",
"typescript": "~5.9.3",
"vite": "^8.3.0",
"vue-tsc": "^3.3.11"
}
}
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { onMounted, onUnmounted, watch } from 'vue';
import { useAuthStore } from '@/stores/auth';
import { useRealtimeStore } from '@/stores/realtime';
const auth = useAuthStore();
const realtime = useRealtimeStore();
/** 已登录时建立实时连接,退出登录时断开 */
function syncRealtime(token: string | null): void {
if (token) realtime.connect(token);
else realtime.disconnect();
}
onMounted(() => {
syncRealtime(auth.token);
});
watch(
() => auth.token,
(token) => syncRealtime(token),
);
onUnmounted(() => {
realtime.disconnect();
});
</script>
<template>
<router-view />
</template>
+65
View File
@@ -0,0 +1,65 @@
import axios, { type AxiosError, type AxiosRequestConfig } from 'axios';
import { ElMessage } from 'element-plus';
import { STORAGE_KEYS, type ApiErrorResponse, type ApiResponse } from '@classhelper/shared';
import { API_BASE_URL } from '@/config';
/**
* Axios 封装:
* 1) 自动携带 JWT
* 2) 统一拆包(响应拦截器直接把 data 返回给调用方)
* 3) 统一错误提示;401 自动清理会话并回到登录页
*/
const instance = axios.create({
baseURL: API_BASE_URL,
timeout: 20000,
});
instance.interceptors.request.use((config) => {
const token = localStorage.getItem(STORAGE_KEYS.token);
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
instance.interceptors.response.use(
(response) => {
const body = response.data as ApiResponse<unknown> | undefined;
if (body && typeof body === 'object' && 'success' in body) {
if (!body.success) {
ElMessage.error(body.message || '请求失败');
return Promise.reject(new Error(body.message || '请求失败'));
}
return body.data as never;
}
return response.data as never;
},
(error: AxiosError<ApiErrorResponse>) => {
const status = error.response?.status;
const message = error.response?.data?.message ?? error.message ?? '网络请求失败';
if (status === 401) {
localStorage.removeItem(STORAGE_KEYS.token);
localStorage.removeItem(STORAGE_KEYS.user);
ElMessage.warning('登录状态已失效,请重新登录');
if (!window.location.pathname.endsWith('/login')) {
window.location.assign('/login');
}
} else {
ElMessage.error(message);
}
return Promise.reject(error);
},
);
/** 统一请求入口:返回值已经是响应体里的 data */
async function request<T>(config: AxiosRequestConfig): Promise<T> {
return (await instance.request(config)) as unknown as T;
}
export const api = {
get: <T>(url: string, params?: Record<string, unknown>): Promise<T> =>
request<T>({ method: 'GET', url, params }),
post: <T>(url: string, data?: unknown): Promise<T> => request<T>({ method: 'POST', url, data }),
patch: <T>(url: string, data?: unknown): Promise<T> => request<T>({ method: 'PATCH', url, data }),
put: <T>(url: string, data?: unknown): Promise<T> => request<T>({ method: 'PUT', url, data }),
delete: <T>(url: string): Promise<T> => request<T>({ method: 'DELETE', url }),
};
+182
View File
@@ -0,0 +1,182 @@
import {
API_PATHS,
type ClassDetailDto,
type ClassDto,
type CourseDto,
type CreateClassRequest,
type CreateCourseRequest,
type CreateGradeRequest,
type CreateHomeworkRequest,
type CreateNotificationRequest,
type CreateScheduleRequest,
type DashboardSummary,
type GradeDto,
type GradeStats,
type HomeworkDto,
type LoginRequest,
type LoginResponse,
type NotificationDto,
type ScheduleDto,
type ScheduleWeekView,
type StudentDto,
type UpdateClassRequest,
type UserDto,
} from '@classhelper/shared';
import { api } from './http';
/* ------------------------------------------------------------------ 认证 */
export const authApi = {
login: (payload: LoginRequest): Promise<LoginResponse> => api.post(API_PATHS.auth.login, payload),
logout: (): Promise<{ loggedOut: boolean }> => api.post(API_PATHS.auth.logout, {}),
me: (): Promise<StudentDto> => api.get(API_PATHS.auth.me),
changePassword: (payload: {
currentPassword: string;
newPassword: string;
}): Promise<{ changed: boolean }> => api.patch(API_PATHS.auth.changePassword, payload),
};
/* ------------------------------------------------------------------ 班级 */
export const classApi = {
list: (params?: { keyword?: string }): Promise<ClassDto[]> => api.get(API_PATHS.classes, params),
detail: (id: string): Promise<ClassDetailDto> => api.get(`${API_PATHS.classes}/${id}`),
create: (payload: CreateClassRequest): Promise<ClassDto> => api.post(API_PATHS.classes, payload),
update: (id: string, payload: UpdateClassRequest): Promise<ClassDto> =>
api.patch(`${API_PATHS.classes}/${id}`, payload),
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.classes}/${id}`),
students: (id: string): Promise<StudentDto[]> => api.get(`${API_PATHS.classes}/${id}/students`),
addStudent: (
id: string,
payload: { username: string; name: string; password?: string },
): Promise<StudentDto> => api.post(`${API_PATHS.classes}/${id}/students`, payload),
removeStudent: (id: string, userId: string): Promise<unknown> =>
api.delete(`${API_PATHS.classes}/${id}/students/${userId}`),
assignTeacher: (id: string, teacherId: string): Promise<unknown> =>
api.post(`${API_PATHS.classes}/${id}/teachers`, { teacherId }),
removeTeacher: (id: string, teacherId: string): Promise<unknown> =>
api.delete(`${API_PATHS.classes}/${id}/teachers/${teacherId}`),
};
/* ------------------------------------------------------------------ 课程 */
export const courseApi = {
list: (classId?: string): Promise<CourseDto[]> =>
api.get(API_PATHS.courses, classId ? { classId } : undefined),
create: (payload: CreateCourseRequest): Promise<CourseDto> => api.post(API_PATHS.courses, payload),
update: (id: string, payload: { name: string }): Promise<CourseDto> =>
api.patch(`${API_PATHS.courses}/${id}`, payload),
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.courses}/${id}`),
};
/* ------------------------------------------------------------------ 课表 */
export const scheduleApi = {
list: (params: { classId?: string; week?: number; dayOfWeek?: number }): Promise<ScheduleDto[]> =>
api.get(API_PATHS.schedules, params),
grid: (params: { classId?: string; week?: number }): Promise<ScheduleWeekView> =>
api.get(`${API_PATHS.schedules}/grid`, params),
create: (payload: CreateScheduleRequest): Promise<ScheduleDto> => api.post(API_PATHS.schedules, payload),
update: (id: string, payload: Partial<CreateScheduleRequest>): Promise<ScheduleDto> =>
api.patch(`${API_PATHS.schedules}/${id}`, payload),
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.schedules}/${id}`),
};
/* ------------------------------------------------------------------ 作业 */
export const homeworkApi = {
list: (params: {
classId?: string;
courseId?: string;
pendingOnly?: boolean;
keyword?: string;
}): Promise<HomeworkDto[]> => api.get(API_PATHS.homeworks, params),
detail: (id: string): Promise<HomeworkDto> => api.get(`${API_PATHS.homeworks}/${id}`),
create: (payload: CreateHomeworkRequest): Promise<HomeworkDto> => api.post(API_PATHS.homeworks, payload),
update: (id: string, payload: Partial<CreateHomeworkRequest>): Promise<HomeworkDto> =>
api.patch(`${API_PATHS.homeworks}/${id}`, payload),
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.homeworks}/${id}`),
};
/* ------------------------------------------------------------------ 通知 */
export const notificationApi = {
list: (params: {
classId?: string;
priority?: string;
unreadOnly?: boolean;
keyword?: string;
}): Promise<NotificationDto[]> => api.get(API_PATHS.notifications, params),
create: (payload: CreateNotificationRequest): Promise<NotificationDto> =>
api.post(API_PATHS.notifications, payload),
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.notifications}/${id}`),
unreadCount: (classId?: string): Promise<{ count: number }> =>
api.get(`${API_PATHS.notifications}/unread-count`, classId ? { classId } : undefined),
};
/* ------------------------------------------------------------------ 成绩 */
export const gradeApi = {
list: (params: {
classId?: string;
courseId?: string;
userId?: string;
examName?: string;
}): Promise<GradeDto[]> => api.get(API_PATHS.grades, params),
stats: (params: { classId?: string; courseId?: string; examName?: string }): Promise<GradeStats> =>
api.get(`${API_PATHS.grades}/stats`, params),
create: (payload: CreateGradeRequest): Promise<GradeDto> => api.post(API_PATHS.grades, payload),
bulkCreate: (payload: {
classId: string;
courseId?: string | null;
examName: string;
totalScore?: number;
publishedAt?: string | null;
items: Array<{ userId: string; score: number }>;
}): Promise<{ count: number; items: GradeDto[] }> => api.post(`${API_PATHS.grades}/bulk`, payload),
update: (id: string, payload: Partial<CreateGradeRequest>): Promise<GradeDto> =>
api.patch(`${API_PATHS.grades}/${id}`, payload),
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.grades}/${id}`),
};
/* ------------------------------------------------------------------ 学生 */
export const studentApi = {
list: (params?: { classId?: string; keyword?: string }): Promise<StudentDto[]> =>
api.get(API_PATHS.students, params),
create: (payload: {
username: string;
name: string;
password?: string;
classId?: string | null;
}): Promise<StudentDto> => api.post(API_PATHS.students, payload),
update: (
id: string,
payload: { username?: string; name?: string; classId?: string | null },
): Promise<StudentDto> => api.patch(`${API_PATHS.students}/${id}`, payload),
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.students}/${id}`),
resetPassword: (id: string, newPassword?: string): Promise<unknown> =>
api.post(`${API_PATHS.students}/${id}/reset-password`, newPassword ? { newPassword } : {}),
};
/* ------------------------------------------------------------------ 仪表盘 */
export const dashboardApi = {
summary: (): Promise<DashboardSummary> => api.get(API_PATHS.dashboard),
term: (): Promise<{ currentWeek: number; maxWeek: number }> => api.get('/dashboard/term'),
};
/* ------------------------------------------------------------------ 协作教师 */
export const teacherApi = {
list: (keyword?: string): Promise<UserDto[]> =>
api.get(API_PATHS.teachers, keyword ? { keyword } : undefined),
create: (payload: {
username: string;
name: string;
password: string;
role?: 'TEACHER' | 'ADMIN';
}): Promise<UserDto> => api.post(API_PATHS.teachers, payload),
/** 班级详情中的 teachers 字段即为已分配的协作教师 */
fromClass: (classId: string): Promise<ClassDetailDto> => classApi.detail(classId),
};
+12
View File
@@ -0,0 +1,12 @@
import { DEFAULT_SERVER_URL } from '@classhelper/shared';
/**
* 运行时配置。
* 默认直连后端(开发/生产都可用);如需走 Vite 代理,把 .env 里的
* VITE_API_BASE_URL 设为 /api 即可(vite.config.ts 已配置代理)。
*/
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? `${DEFAULT_SERVER_URL}/api`;
export const SOCKET_URL = import.meta.env.VITE_SOCKET_URL ?? DEFAULT_SERVER_URL;
export const APP_TITLE = '班级小助手 · 管理端';
+12
View File
@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** 后端 API 基地址,默认 http://127.0.0.1:4000/api */
readonly VITE_API_BASE_URL?: string;
/** Socket.IO 服务地址,默认 http://127.0.0.1:4000 */
readonly VITE_SOCKET_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
@@ -0,0 +1,253 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import { authApi } from '@/api';
import { useAuthStore } from '@/stores/auth';
import { useRealtimeStore } from '@/stores/realtime';
import { APP_TITLE } from '@/config';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const realtime = useRealtimeStore();
const menuItems = [
{ name: 'dashboard', title: '仪表盘', icon: 'Odometer' },
{ name: 'classes', title: '班级管理', icon: 'School' },
{ name: 'students', title: '学生管理', icon: 'User' },
{ name: 'schedules', title: '课表管理', icon: 'Calendar' },
{ name: 'homeworks', title: '作业发布', icon: 'Notebook' },
{ name: 'notifications', title: '通知发布', icon: 'Bell' },
{ name: 'grades', title: '成绩录入', icon: 'Trophy' },
];
const activeMenu = computed(() => `/${String(route.name ?? 'dashboard')}`);
const connectionText = computed(() => {
if (realtime.connected) return '实时通道已连接';
if (realtime.connecting) return '实时通道连接中';
return '实时通道已断开';
});
const connectionType = computed(() =>
realtime.connected ? 'success' : realtime.connecting ? 'warning' : 'danger',
);
function go(name: string): void {
void router.push({ name });
}
async function handleLogout(): Promise<void> {
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', { type: 'warning' });
await auth.logout();
ElMessage.success('已退出登录');
void router.push({ name: 'login' });
}
/* ------------------------------------------------------------ 修改密码 */
const passwordVisible = ref(false);
const passwordFormRef = ref<FormInstance>();
const passwordForm = reactive({ currentPassword: '', newPassword: '', confirmPassword: '' });
const passwordRules: FormRules = {
currentPassword: [{ required: true, message: '请输入当前密码', trigger: 'blur' }],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '新密码至少 6 位', trigger: 'blur' },
],
confirmPassword: [
{
validator: (_rule, value: string, callback: (error?: Error) => void) => {
if (!value) callback(new Error('请再次输入新密码'));
else if (value !== passwordForm.newPassword) callback(new Error('两次输入的密码不一致'));
else callback();
},
trigger: 'blur',
},
],
};
async function submitPassword(): Promise<void> {
const valid = await passwordFormRef.value?.validate().catch(() => false);
if (!valid) return;
await authApi.changePassword({
currentPassword: passwordForm.currentPassword,
newPassword: passwordForm.newPassword,
});
passwordVisible.value = false;
passwordForm.currentPassword = '';
passwordForm.newPassword = '';
passwordForm.confirmPassword = '';
ElMessage.success('密码修改成功,请重新登录');
await auth.logout();
void router.push({ name: 'login' });
}
</script>
<template>
<el-container class="layout">
<el-aside width="210px" class="layout-aside">
<div class="brand">
<el-icon :size="22"><School /></el-icon>
<span class="brand-text">班级小助手</span>
</div>
<el-menu :default-active="activeMenu" class="layout-menu" @select="go">
<el-menu-item v-for="item in menuItems" :key="item.name" :index="`/${item.name}`">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="layout-header">
<div class="header-left">
<span class="header-title">{{ APP_TITLE }}</span>
<el-tag :type="connectionType" size="small" effect="light">
<span class="header-conn">{{ connectionText }}</span>
</el-tag>
<el-tag v-if="realtime.eventCount > 0" size="small" type="info" effect="plain">
已接收 {{ realtime.eventCount }} 条实时事件
</el-tag>
</div>
<div class="header-right">
<el-dropdown trigger="click">
<span class="user-chip">
<el-icon><UserFilled /></el-icon>
{{ auth.displayName }}
<el-tag size="small" type="info" effect="plain">{{ auth.roleLabel }}</el-tag>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="passwordVisible = true">
<el-icon><Lock /></el-icon>
修改密码
</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main class="layout-main">
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</el-main>
</el-container>
<el-dialog v-model="passwordVisible" title="修改密码" width="420px">
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-width="90px">
<el-form-item label="当前密码" prop="currentPassword">
<el-input v-model="passwordForm.currentPassword" type="password" show-password />
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input v-model="passwordForm.newPassword" type="password" show-password />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="passwordForm.confirmPassword" type="password" show-password />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="passwordVisible = false">取消</el-button>
<el-button type="primary" @click="submitPassword">确认修改</el-button>
</template>
</el-dialog>
</el-container>
</template>
<style scoped>
.layout {
height: 100vh;
}
.layout-aside {
background: #1f2d3d;
color: #fff;
display: flex;
flex-direction: column;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
height: 56px;
padding: 0 16px;
color: #fff;
font-weight: 600;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.brand-text {
font-size: 16px;
}
.layout-menu {
border-right: none;
background: transparent;
flex: 1;
--el-menu-text-color: #cbd5e1;
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.08);
--el-menu-active-color: #ffffff;
}
.layout-menu :deep(.el-menu-item.is-active) {
background: #409eff;
border-radius: 6px;
margin: 0 8px;
}
.layout-menu :deep(.el-menu-item) {
margin: 2px 8px;
border-radius: 6px;
}
.layout-header {
background: #fff;
border-bottom: 1px solid var(--ch-border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.header-title {
font-weight: 600;
}
.header-conn {
margin-left: 4px;
}
.header-right {
display: flex;
align-items: center;
}
.user-chip {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
outline: none;
}
.layout-main {
background: var(--ch-bg);
padding: 0;
overflow-y: auto;
}
</style>
+23
View File
@@ -0,0 +1,23 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import ElementPlus from 'element-plus';
import zhCn from 'element-plus/es/locale/lang/zh-cn';
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
import 'element-plus/dist/index.css';
import App from './App.vue';
import router from './router';
import '@/styles/index.css';
const app = createApp(App);
// 全量注册 Element Plus 图标,模板里可直接 <el-icon><Bell /></el-icon>
for (const [name, component] of Object.entries(ElementPlusIconsVue)) {
app.component(name, component);
}
app.use(createPinia());
app.use(router);
app.use(ElementPlus, { locale: zhCn });
app.mount('#app');
+90
View File
@@ -0,0 +1,90 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
import { STORAGE_KEYS } from '@classhelper/shared';
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { public: true, title: '登录' },
},
{
path: '/',
component: () => import('@/layouts/AdminLayout.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'dashboard',
component: () => import('@/views/DashboardView.vue'),
meta: { title: '仪表盘', icon: 'Odometer' },
},
{
path: 'classes',
name: 'classes',
component: () => import('@/views/ClassesView.vue'),
meta: { title: '班级管理', icon: 'School' },
},
{
path: 'students',
name: 'students',
component: () => import('@/views/StudentsView.vue'),
meta: { title: '学生管理', icon: 'User' },
},
{
path: 'schedules',
name: 'schedules',
component: () => import('@/views/SchedulesView.vue'),
meta: { title: '课表管理', icon: 'Calendar' },
},
{
path: 'homeworks',
name: 'homeworks',
component: () => import('@/views/HomeworksView.vue'),
meta: { title: '作业发布', icon: 'Notebook' },
},
{
path: 'notifications',
name: 'notifications',
component: () => import('@/views/NotificationsView.vue'),
meta: { title: '通知发布', icon: 'Bell' },
},
{
path: 'grades',
name: 'grades',
component: () => import('@/views/GradesView.vue'),
meta: { title: '成绩录入', icon: 'Trophy' },
},
],
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/views/NotFoundView.vue'),
meta: { public: true, title: '页面不存在' },
},
];
export const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach((to) => {
const token = localStorage.getItem(STORAGE_KEYS.token);
if (to.meta.public) {
if (to.name === 'login' && token) return { name: 'dashboard' };
return true;
}
if (!token) {
return { name: 'login', query: { redirect: to.fullPath } };
}
return true;
});
router.afterEach((to) => {
const title = typeof to.meta.title === 'string' ? to.meta.title : '';
document.title = title ? `${title} · 班级小助手` : '班级小助手 · 管理端';
});
export default router;
+89
View File
@@ -0,0 +1,89 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { STORAGE_KEYS, type LoginRequest, type LoginResponse, type StudentDto } from '@classhelper/shared';
import { authApi } from '@/api';
function readStoredUser(): StudentDto | null {
const raw = localStorage.getItem(STORAGE_KEYS.user);
if (!raw) return null;
try {
return JSON.parse(raw) as StudentDto;
} catch {
return null;
}
}
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem(STORAGE_KEYS.token));
const user = ref<StudentDto | null>(readStoredUser());
const loading = ref(false);
const isAuthenticated = computed(() => Boolean(token.value));
const role = computed(() => user.value?.role ?? null);
const isStaff = computed(() => role.value === 'ADMIN' || role.value === 'TEACHER');
const displayName = computed(() => user.value?.name ?? '未登录');
const roleLabel = computed(() => {
if (role.value === 'ADMIN') return '管理员';
if (role.value === 'TEACHER') return '教师';
return '学生';
});
function persist(): void {
if (token.value) localStorage.setItem(STORAGE_KEYS.token, token.value);
else localStorage.removeItem(STORAGE_KEYS.token);
if (user.value) localStorage.setItem(STORAGE_KEYS.user, JSON.stringify(user.value));
else localStorage.removeItem(STORAGE_KEYS.user);
}
function setSession(payload: LoginResponse): void {
token.value = payload.token;
user.value = { ...payload.user } as StudentDto;
persist();
}
async function login(payload: LoginRequest): Promise<void> {
loading.value = true;
try {
setSession(await authApi.login(payload));
} finally {
loading.value = false;
}
}
async function fetchProfile(): Promise<void> {
if (!token.value) return;
user.value = await authApi.me();
persist();
}
async function logout(): Promise<void> {
try {
if (token.value) await authApi.logout();
} catch {
// 退出登录失败不阻塞本地清理
} finally {
clear();
}
}
function clear(): void {
token.value = null;
user.value = null;
persist();
}
return {
token,
user,
loading,
isAuthenticated,
isStaff,
role,
displayName,
roleLabel,
login,
logout,
fetchProfile,
clear,
};
});
+178
View File
@@ -0,0 +1,178 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
import { ElNotification } from 'element-plus';
import { io, type Socket } from 'socket.io-client';
import {
PRIORITY_LABELS,
SOCKET_EVENTS,
type GradeDto,
type HomeworkDto,
type NotificationDto,
type ScheduleDto,
type ServerToClientEvents,
} from '@classhelper/shared';
import { SOCKET_URL } from '@/config';
type RealtimeEventName = keyof ServerToClientEvents;
type EventHandler = (payload: unknown) => void;
const EVENT_LABELS: Record<string, string> = {
[SOCKET_EVENTS.notificationNew]: '新通知',
[SOCKET_EVENTS.homeworkNew]: '新作业',
[SOCKET_EVENTS.homeworkUpdated]: '作业变更',
[SOCKET_EVENTS.gradeUpdated]: '成绩更新',
[SOCKET_EVENTS.scheduleUpdated]: '课表变更',
[SOCKET_EVENTS.homeworkStatus]: '作业状态',
};
/**
* 实时通道 store:
* - 持有 Socket.IO 连接(自动重连)
* - 收到事件时弹出 Element Plus 通知
* - 通过 on/off 让页面订阅事件后自动刷新列表
*/
export const useRealtimeStore = defineStore('realtime', () => {
const connected = ref(false);
const connecting = ref(false);
const lastEventName = ref<string | null>(null);
const lastEventAt = ref<number | null>(null);
const eventCount = ref(0);
const handlers = new Map<string, Set<EventHandler>>();
let socket: Socket | null = null;
function notify(
event: string,
title: string,
message: string,
type: 'success' | 'info' | 'warning' | 'error' = 'info',
): void {
ElNotification({ title: `${title}`, message, type, duration: 4000, position: 'bottom-right' });
}
function dispatch(event: string, payload: unknown): void {
for (const handler of handlers.get(event) ?? []) {
handler(payload);
}
}
function bindEvents(instance: Socket): void {
instance.on('connect', () => {
connected.value = true;
connecting.value = false;
});
instance.on('disconnect', () => {
connected.value = false;
});
instance.on('connect_error', (error: Error) => {
connected.value = false;
connecting.value = false;
// 重连过程中的失败只提示一次,避免刷屏
if (eventCount.value === 0) notify('error', '实时通道连接失败', error.message);
});
instance.on(SOCKET_EVENTS.notificationNew, (payload: NotificationDto) => {
markEvent(SOCKET_EVENTS.notificationNew);
notify(
'info',
`新通知 · ${PRIORITY_LABELS[payload.priority] ?? payload.priority}`,
`${payload.title}`,
payload.priority === 'URGENT' ? 'error' : payload.priority === 'HIGH' ? 'warning' : 'info',
);
dispatch(SOCKET_EVENTS.notificationNew, payload);
});
instance.on(SOCKET_EVENTS.homeworkNew, (payload: HomeworkDto) => {
markEvent(SOCKET_EVENTS.homeworkNew);
notify('success', '新作业发布', payload.title);
dispatch(SOCKET_EVENTS.homeworkNew, payload);
});
instance.on(SOCKET_EVENTS.homeworkUpdated, (payload: HomeworkDto) => {
markEvent(SOCKET_EVENTS.homeworkUpdated);
dispatch(SOCKET_EVENTS.homeworkUpdated, payload);
});
instance.on(SOCKET_EVENTS.homeworkStatus, (payload) => {
markEvent(SOCKET_EVENTS.homeworkStatus);
dispatch(SOCKET_EVENTS.homeworkStatus, payload);
});
instance.on(SOCKET_EVENTS.gradeUpdated, (payload: GradeDto) => {
markEvent(SOCKET_EVENTS.gradeUpdated);
notify('success', '成绩已更新', `${payload.examName}:${payload.score}/${payload.totalScore}`);
dispatch(SOCKET_EVENTS.gradeUpdated, payload);
});
instance.on(SOCKET_EVENTS.scheduleUpdated, (payload: { schedule?: ScheduleDto; action: string }) => {
markEvent(SOCKET_EVENTS.scheduleUpdated);
if (payload.action !== 'updated') {
notify(
'info',
'课表变更',
`课表已${payload.action === 'created' ? '新增' : '删除'}:${payload.schedule?.course?.name ?? ''}`,
);
}
dispatch(SOCKET_EVENTS.scheduleUpdated, payload);
});
instance.on(SOCKET_EVENTS.connected, (payload: { rooms: string[] }) => {
markEvent(SOCKET_EVENTS.connected);
console.info('[realtime] 已加入房间', payload.rooms);
});
}
function markEvent(event: string): void {
lastEventName.value = event;
lastEventAt.value = Date.now();
eventCount.value += 1;
}
function connect(token: string): void {
if (socket?.connected || connecting.value) return;
connecting.value = true;
socket = io(SOCKET_URL, {
auth: { token, clientType: 'admin' },
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 8000,
});
bindEvents(socket);
}
function disconnect(): void {
socket?.disconnect();
socket?.removeAllListeners();
socket = null;
connected.value = false;
connecting.value = false;
}
function on(event: RealtimeEventName, handler: EventHandler): void {
const set = handlers.get(event) ?? new Set<EventHandler>();
set.add(handler);
handlers.set(event, set);
}
function off(event: RealtimeEventName, handler: EventHandler): void {
handlers.get(event)?.delete(handler);
}
function eventLabel(): string {
return lastEventName.value ? (EVENT_LABELS[lastEventName.value] ?? lastEventName.value) : '';
}
return {
connected,
connecting,
lastEventName,
lastEventAt,
eventCount,
eventLabel,
connect,
disconnect,
on,
off,
};
});
+119
View File
@@ -0,0 +1,119 @@
:root {
--ch-bg: #f5f7fa;
--ch-card-radius: 10px;
--ch-border: #e4e7ed;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
height: 100%;
margin: 0;
padding: 0;
}
body {
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
background: var(--ch-bg);
color: #303133;
}
.page {
padding: 16px 20px 24px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
gap: 12px;
flex-wrap: wrap;
}
.page-title {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.page-subtitle {
color: #909399;
font-size: 13px;
margin: 4px 0 0;
}
.toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
}
.stat-card {
background: #fff;
border-radius: var(--ch-card-radius);
padding: 16px;
border: 1px solid var(--ch-border);
}
.stat-label {
color: #909399;
font-size: 13px;
}
.stat-value {
font-size: 26px;
font-weight: 700;
margin-top: 6px;
}
.stat-hint {
color: #c0c4cc;
font-size: 12px;
margin-top: 4px;
}
.text-muted {
color: #909399;
}
.mt-12 {
margin-top: 12px;
}
.mt-16 {
margin-top: 16px;
}
.timetable-cell {
min-height: 34px;
}
.timetable-course {
font-weight: 600;
}
.timetable-meta {
font-size: 12px;
color: #909399;
}
.unread-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #f56c6c;
margin-right: 6px;
}
@@ -0,0 +1,385 @@
<script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import {
SOCKET_EVENTS,
formatDate,
type ClassDetailDto,
type ClassDto,
type CourseDto,
type StudentDto,
type UserDto,
} from '@classhelper/shared';
import { classApi, courseApi, teacherApi } from '@/api';
import { useRealtimeStore } from '@/stores/realtime';
const realtime = useRealtimeStore();
const loading = ref(false);
const classes = ref<ClassDto[]>([]);
const keyword = ref('');
async function loadClasses(): Promise<void> {
loading.value = true;
try {
classes.value = await classApi.list(keyword.value ? { keyword: keyword.value } : undefined);
} finally {
loading.value = false;
}
}
/* ------------------------------------------------------------ 新建 / 编辑班级 */
const formVisible = ref(false);
const formRef = ref<FormInstance>();
const editingId = ref<string | null>(null);
const form = reactive({ name: '', grade: '' });
const rules: FormRules = {
name: [{ required: true, message: '请输入班级名称', trigger: 'blur' }],
grade: [{ required: true, message: '请输入年级', trigger: 'blur' }],
};
function openCreate(): void {
editingId.value = null;
form.name = '';
form.grade = '';
formVisible.value = true;
}
function openEdit(row: ClassDto): void {
editingId.value = row.id;
form.name = row.name;
form.grade = row.grade;
formVisible.value = true;
}
async function submitForm(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
if (editingId.value) {
await classApi.update(editingId.value, { name: form.name, grade: form.grade });
ElMessage.success('班级已更新');
} else {
await classApi.create({ name: form.name, grade: form.grade });
ElMessage.success('班级创建成功');
}
formVisible.value = false;
await loadClasses();
}
async function removeClass(row: ClassDto): Promise<void> {
await ElMessageBox.confirm(
`删除「${row.name}」将同时删除其课表、作业、通知与成绩,确认继续?`,
'危险操作',
{ type: 'warning', confirmButtonText: '确认删除' },
);
await classApi.remove(row.id);
ElMessage.success('班级已删除');
await loadClasses();
}
/* ------------------------------------------------------------ 班级详情抽屉 */
const detailVisible = ref(false);
const detailLoading = ref(false);
const detail = ref<ClassDetailDto | null>(null);
const activeTab = ref('students');
async function openDetail(row: ClassDto): Promise<void> {
detailVisible.value = true;
detailLoading.value = true;
activeTab.value = 'students';
try {
detail.value = await classApi.detail(row.id);
} finally {
detailLoading.value = false;
}
}
async function refreshDetail(): Promise<void> {
if (!detail.value) return;
detailLoading.value = true;
try {
detail.value = await classApi.detail(detail.value.id);
} finally {
detailLoading.value = false;
}
}
/* ------------------------------------------------------------ 学生 */
const studentFormVisible = ref(false);
const studentForm = reactive({ username: '', name: '', password: '' });
const studentRules: FormRules = {
username: [{ required: true, message: '请输入学号/用户名', trigger: 'blur' }],
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
};
async function addStudent(): Promise<void> {
if (!detail.value) return;
if (!studentForm.username.trim() || !studentForm.name.trim()) {
ElMessage.warning('请填写用户名和姓名');
return;
}
const created = await classApi.addStudent(detail.value.id, {
username: studentForm.username.trim(),
name: studentForm.name.trim(),
...(studentForm.password ? { password: studentForm.password } : {}),
});
ElMessage.success(`已加入学生:${created.name}`);
studentFormVisible.value = false;
studentForm.username = '';
studentForm.name = '';
studentForm.password = '';
await refreshDetail();
await loadClasses();
}
async function removeStudent(row: StudentDto): Promise<void> {
if (!detail.value) return;
await ElMessageBox.confirm(`将「${row.name}」移出本班级?`, '确认', { type: 'warning' });
await classApi.removeStudent(detail.value.id, row.id);
ElMessage.success('已移出班级');
await refreshDetail();
await loadClasses();
}
/* ------------------------------------------------------------ 课程 */
const courseForm = reactive({ name: '' });
async function addCourse(): Promise<void> {
if (!detail.value || !courseForm.name.trim()) {
ElMessage.warning('请输入课程名称');
return;
}
await courseApi.create({ name: courseForm.name.trim(), classId: detail.value.id });
ElMessage.success('课程已添加');
courseForm.name = '';
await refreshDetail();
}
async function removeCourse(row: CourseDto): Promise<void> {
await ElMessageBox.confirm(`删除课程「${row.name}」?相关课表与作业关联会被清除。`, '确认', {
type: 'warning',
});
await courseApi.remove(row.id);
ElMessage.success('课程已删除');
await refreshDetail();
}
/* ------------------------------------------------------------ 协作教师 */
const teachers = ref<UserDto[]>([]);
const selectedTeacherId = ref<string>('');
async function loadTeachers(): Promise<void> {
teachers.value = await teacherApi.list();
}
async function assignTeacher(): Promise<void> {
if (!detail.value || !selectedTeacherId.value) {
ElMessage.warning('请选择教师');
return;
}
await classApi.assignTeacher(detail.value.id, selectedTeacherId.value);
ElMessage.success('教师已分配');
selectedTeacherId.value = '';
await refreshDetail();
}
async function removeTeacher(teacherId: string): Promise<void> {
if (!detail.value) return;
await classApi.removeTeacher(detail.value.id, teacherId);
ElMessage.success('已取消分配');
await refreshDetail();
}
/* ------------------------------------------------------------ 实时刷新 */
function onClassChanged(): void {
void loadClasses();
if (detailVisible.value) void refreshDetail();
}
onMounted(async () => {
await Promise.all([loadClasses(), loadTeachers()]);
realtime.on(SOCKET_EVENTS.classUpdated, onClassChanged);
realtime.on(SOCKET_EVENTS.notificationNew, onClassChanged);
realtime.on(SOCKET_EVENTS.homeworkNew, onClassChanged);
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.classUpdated, onClassChanged);
realtime.off(SOCKET_EVENTS.notificationNew, onClassChanged);
realtime.off(SOCKET_EVENTS.homeworkNew, onClassChanged);
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">班级管理</h2>
<p class="page-subtitle">教师只能看到并管理自己创建或被分配的班级</p>
</div>
<div class="toolbar">
<el-input
v-model="keyword"
placeholder="搜索班级名称 / 年级"
clearable
style="width: 220px"
@keyup.enter="loadClasses"
@clear="loadClasses"
/>
<el-button :icon="'Search'" @click="loadClasses">查询</el-button>
<el-button type="primary" :icon="'Plus'" @click="openCreate">新建班级</el-button>
</div>
</div>
<el-card shadow="never">
<el-table v-loading="loading" :data="classes" empty-text="暂无班级,点击右上角新建">
<el-table-column prop="name" label="班级" min-width="140" />
<el-table-column prop="grade" label="年级" width="100" />
<el-table-column label="班主任" width="140">
<template #default="{ row }">{{ row.teacher?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="学生" width="80">
<template #default="{ row }">{{ row.studentCount ?? 0 }}</template>
</el-table-column>
<el-table-column label="课程" width="80">
<template #default="{ row }">{{ row.courseCount ?? 0 }}</template>
</el-table-column>
<el-table-column label="作业" width="80">
<template #default="{ row }">{{ row.homeworkCount ?? 0 }}</template>
</el-table-column>
<el-table-column label="通知" width="80">
<template #default="{ row }">{{ row.notificationCount ?? 0 }}</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
</el-table-column>
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="removeClass(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 新建 / 编辑 -->
<el-dialog v-model="formVisible" :title="editingId ? '编辑班级' : '新建班级'" width="420px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="班级名称" prop="name">
<el-input v-model="form.name" placeholder="例如 高一(1)班" />
</el-form-item>
<el-form-item label="年级" prop="grade">
<el-input v-model="form.grade" placeholder="例如 高一" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">保存</el-button>
</template>
</el-dialog>
<!-- 班级详情 -->
<el-drawer v-model="detailVisible" size="60%" :title="detail ? `${detail.name} · 详情` : '班级详情'">
<div v-loading="detailLoading">
<el-descriptions v-if="detail" :column="3" border size="small">
<el-descriptions-item label="年级">{{ detail.grade }}</el-descriptions-item>
<el-descriptions-item label="班主任">{{ detail.teacher?.name ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="学生数">{{ detail.students.length }}</el-descriptions-item>
</el-descriptions>
<el-tabs v-model="activeTab" class="mt-12">
<el-tab-pane label="学生名单" name="students">
<div class="toolbar mt-12">
<el-button type="primary" size="small" :icon="'Plus'" @click="studentFormVisible = true">
添加学生
</el-button>
<span class="text-muted">共 {{ detail?.students.length ?? 0 }} 人</span>
</div>
<el-table :data="detail?.students ?? []" size="small" class="mt-12" empty-text="暂无学生">
<el-table-column prop="username" label="用户名" width="140" />
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column label="加入时间" width="180">
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ row }">
<el-button link type="danger" @click="removeStudent(row)">移出</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="课程" name="courses">
<el-table :data="detail?.courses ?? []" size="small" class="mt-12" empty-text="暂无课程">
<el-table-column prop="name" label="课程" min-width="140" />
<el-table-column label="任课教师" width="140">
<template #default="{ row }">{{ row.teacher?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ row }">
<el-button link type="danger" @click="removeCourse(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-input
v-model="courseForm.name"
placeholder="输入课程名称后回车快速添加"
class="mt-12"
@keyup.enter="addCourse"
/>
</el-tab-pane>
<el-tab-pane label="协作教师" name="teachers">
<el-table :data="detail?.teachers ?? []" size="small" class="mt-12" empty-text="暂无协作教师">
<el-table-column prop="name" label="姓名" width="140" />
<el-table-column prop="username" label="用户名" width="140" />
<el-table-column label="操作" width="110">
<template #default="{ row }">
<el-button link type="danger" @click="removeTeacher(row.id)">取消分配</el-button>
</template>
</el-table-column>
</el-table>
<div class="toolbar mt-12">
<el-select v-model="selectedTeacherId" placeholder="选择教师" style="width: 220px" filterable>
<el-option
v-for="item in teachers"
:key="item.id"
:label="`${item.name}(${item.username})`"
:value="item.id"
/>
</el-select>
<el-button type="primary" @click="assignTeacher">分配</el-button>
</div>
</el-tab-pane>
</el-tabs>
</div>
</el-drawer>
<!-- 添加学生 -->
<el-dialog v-model="studentFormVisible" title="添加学生到本班" width="440px">
<el-form :model="studentForm" :rules="studentRules" label-width="90px">
<el-form-item label="用户名" prop="username">
<el-input v-model="studentForm.username" placeholder="学号或登录名,已存在则直接转入本班" />
</el-form-item>
<el-form-item label="姓名" prop="name">
<el-input v-model="studentForm.name" />
</el-form-item>
<el-form-item label="初始密码">
<el-input v-model="studentForm.password" placeholder="留空则使用默认密码" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="studentFormVisible = false">取消</el-button>
<el-button type="primary" @click="addStudent">确认添加</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -0,0 +1,211 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import {
PRIORITY_LABELS,
PRIORITY_TAG_TYPES,
formatDate,
formatDeadline,
relativeTime,
type DashboardSummary,
} from '@classhelper/shared';
import { dashboardApi, notificationApi } from '@/api';
import { useAuthStore } from '@/stores/auth';
import { useRealtimeStore } from '@/stores/realtime';
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
const router = useRouter();
const auth = useAuthStore();
const realtime = useRealtimeStore();
const loading = ref(false);
const summary = ref<DashboardSummary | null>(null);
const unreadCount = ref(0);
const statCards = computed(() => {
const data = summary.value;
return [
{ label: '管理班级', value: data?.classCount ?? 0, hint: '按权限范围统计' },
{ label: '学生人数', value: data?.studentCount ?? 0, hint: '所辖班级学生' },
{ label: '课程数量', value: data?.courseCount ?? 0, hint: '含全部课程' },
{ label: '课表条目', value: data?.scheduleCount ?? 0, hint: '全部周次' },
{
label: '作业总数',
value: data?.homeworkCount ?? 0,
hint: `待截止/待完成 ${data?.pendingHomeworkCount ?? 0}`,
},
{ label: '通知总数', value: data?.notificationCount ?? 0, hint: `未读 ${unreadCount.value}` },
{ label: '成绩记录', value: data?.gradeCount ?? 0, hint: '已发布成绩' },
];
});
async function load(): Promise<void> {
loading.value = true;
try {
summary.value = await dashboardApi.summary();
const result = await notificationApi.unreadCount();
unreadCount.value = result.count;
} finally {
loading.value = false;
}
}
function onRealtimeEvent(): void {
void load();
}
onMounted(async () => {
await load();
realtime.on(SOCKET_EVENTS.notificationNew, onRealtimeEvent);
realtime.on(SOCKET_EVENTS.homeworkNew, onRealtimeEvent);
realtime.on(SOCKET_EVENTS.gradeUpdated, onRealtimeEvent);
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.notificationNew, onRealtimeEvent);
realtime.off(SOCKET_EVENTS.homeworkNew, onRealtimeEvent);
realtime.off(SOCKET_EVENTS.gradeUpdated, onRealtimeEvent);
});
function go(name: string): void {
void router.push({ name });
}
function openNotification(item: NotificationDto): void {
void router.push({ name: 'notifications', query: { highlight: item.id } });
}
</script>
<template>
<div v-loading="loading" class="page">
<div class="page-header">
<div>
<h2 class="page-title">仪表盘</h2>
<p class="page-subtitle">
{{ auth.displayName }}({{ auth.roleLabel }})· 数据范围随账号权限自动收敛
</p>
</div>
<div class="toolbar">
<el-tag :type="realtime.connected ? 'success' : 'danger'" effect="light">
{{ realtime.connected ? '实时推送已连接' : '实时推送未连接' }}
</el-tag>
<el-button :icon="'Refresh'" @click="load">刷新</el-button>
</div>
</div>
<div class="stat-grid">
<div v-for="card in statCards" :key="card.label" class="stat-card">
<div class="stat-label">{{ card.label }}</div>
<div class="stat-value">{{ card.value }}</div>
<div class="stat-hint">{{ card.hint }}</div>
</div>
</div>
<el-row :gutter="12" class="mt-16">
<el-col :xs="24" :md="12">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>最新通知</span>
<el-button link type="primary" @click="go('notifications')">去发布</el-button>
</div>
</template>
<el-empty v-if="(summary?.recentNotifications ?? []).length === 0" description="暂无通知" />
<ul v-else class="list">
<li
v-for="item in summary?.recentNotifications"
:key="item.id"
class="list-item"
@click="openNotification(item)"
>
<el-tag :type="PRIORITY_TAG_TYPES[item.priority]" size="small" effect="light">
{{ PRIORITY_LABELS[item.priority] }}
</el-tag>
<span class="list-title">{{ item.title }}</span>
<span class="text-muted">{{ relativeTime(item.createdAt) }}</span>
</li>
</ul>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>即将截止的作业</span>
<el-button link type="primary" @click="go('homeworks')">去发布</el-button>
</div>
</template>
<el-empty v-if="(summary?.upcomingDeadlines ?? []).length === 0" description="暂无待截止作业" />
<ul v-else class="list">
<li v-for="item in summary?.upcomingDeadlines" :key="item.id" class="list-item">
<el-tag size="small" effect="plain">{{ item.course?.name ?? '未关联课程' }}</el-tag>
<span class="list-title">{{ item.title }}</span>
<span class="text-muted">{{ formatDeadline(item.dueAt) }}</span>
</li>
</ul>
</el-card>
</el-col>
</el-row>
<el-card shadow="never" class="mt-16">
<template #header>
<div class="card-header">
<span>最近作业</span>
<el-button link type="primary" @click="go('homeworks')">查看全部</el-button>
</div>
</template>
<el-table :data="summary?.recentHomeworks ?? []" size="small" empty-text="暂无作业">
<el-table-column prop="title" label="标题" min-width="200" />
<el-table-column label="课程" width="120">
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="完成情况" width="120">
<template #default="{ row }">
<span>{{ row.completedCount ?? 0 }} 人已完成</span>
</template>
</el-table-column>
<el-table-column label="截止时间" width="180">
<template #default="{ row }">{{ formatDate(row.dueAt, true) || '不限' }}</template>
</el-table-column>
<el-table-column label="发布时间" width="180">
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<style scoped>
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.list {
list-style: none;
padding: 0;
margin: 0;
}
.list-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 4px;
border-bottom: 1px dashed var(--ch-border);
cursor: pointer;
}
.list-item:last-child {
border-bottom: none;
}
.list-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+421
View File
@@ -0,0 +1,421 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import * as echarts from 'echarts';
import {
SOCKET_EVENTS,
formatDate,
gradeLevel,
gradePercent,
type ClassDto,
type CourseDto,
type GradeDto,
type GradeStats,
type StudentDto,
} from '@classhelper/shared';
import { classApi, courseApi, gradeApi } from '@/api';
import { useRealtimeStore } from '@/stores/realtime';
const realtime = useRealtimeStore();
const loading = ref(false);
const classes = ref<ClassDto[]>([]);
const courses = ref<CourseDto[]>([]);
const grades = ref<GradeDto[]>([]);
const students = ref<StudentDto[]>([]);
const stats = ref<GradeStats | null>(null);
const filter = reactive({ classId: '', courseId: '', examName: '' });
const levelChartRef = ref<HTMLDivElement>();
const courseChartRef = ref<HTMLDivElement>();
const levelChart = ref<ReturnType<typeof echarts.init> | null>(null);
const courseChart = ref<ReturnType<typeof echarts.init> | null>(null);
const averagePercent = computed(() => {
if (grades.value.length === 0) return 0;
const sum = grades.value.reduce((total, item) => total + gradePercent(item.score, item.totalScore), 0);
return Math.round((sum / grades.value.length) * 10) / 10;
});
const examOptions = computed(() => [...new Set(grades.value.map((item) => item.examName))]);
async function loadClasses(): Promise<void> {
classes.value = await classApi.list();
if (!filter.classId && classes.value.length > 0) filter.classId = classes.value[0]?.id ?? '';
if (filter.classId) {
courses.value = await courseApi.list(filter.classId);
students.value = await classApi.students(filter.classId);
}
}
async function loadGrades(): Promise<void> {
if (!filter.classId) return;
loading.value = true;
try {
const params: { classId?: string; courseId?: string; examName?: string } = { classId: filter.classId };
if (filter.courseId) params.courseId = filter.courseId;
if (filter.examName) params.examName = filter.examName;
const [list, statistic] = await Promise.all([gradeApi.list(params), gradeApi.stats(params)]);
grades.value = list;
stats.value = statistic;
renderCharts();
} finally {
loading.value = false;
}
}
async function onClassChange(): Promise<void> {
filter.courseId = '';
filter.examName = '';
courses.value = await courseApi.list(filter.classId);
students.value = await classApi.students(filter.classId);
await loadGrades();
await loadTeacherSuggestions();
}
/* ------------------------------------------------------------ 图表 */
function renderCharts(): void {
const statistic = stats.value;
if (!statistic) return;
if (levelChartRef.value) {
levelChart.value ??= echarts.init(levelChartRef.value);
levelChart.value.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 16, top: 30, bottom: 28 },
xAxis: { type: 'category', data: statistic.distribution.map((item) => `${item.level} 等`) },
yAxis: { type: 'value', minInterval: 1 },
series: [
{
type: 'bar',
barWidth: '46%',
itemStyle: { color: '#409eff', borderRadius: [4, 4, 0, 0] },
data: statistic.distribution.map((item) => item.count),
},
],
});
}
if (courseChartRef.value) {
courseChart.value ??= echarts.init(courseChartRef.value);
courseChart.value.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 16, top: 30, bottom: 28 },
xAxis: { type: 'category', data: statistic.byCourse.map((item) => item.courseName) },
yAxis: { type: 'value', max: 100, name: '得分率%' },
series: [
{
type: 'line',
smooth: true,
symbolSize: 8,
areaStyle: { opacity: 0.12 },
itemStyle: { color: '#67c23a' },
data: statistic.byCourse.map((item) => item.averagePercent),
},
],
});
}
}
function handleResize(): void {
levelChart.value?.resize();
courseChart.value?.resize();
}
/* ------------------------------------------------------------ 单条录入 */
const singleVisible = ref(false);
const singleForm = reactive({ userId: '', courseId: '', examName: '', score: 90, totalScore: 100 });
function openSingle(): void {
if (!filter.classId) {
ElMessage.warning('请先选择班级');
return;
}
singleForm.userId = students.value[0]?.id ?? '';
singleForm.courseId = filter.courseId || courses.value[0]?.id || '';
singleForm.examName = filter.examName || '期中考试';
singleForm.score = 90;
singleForm.totalScore = 100;
singleVisible.value = true;
}
async function submitSingle(): Promise<void> {
if (!singleForm.userId || !singleForm.examName.trim()) {
ElMessage.warning('请选择学生并填写考试名称');
return;
}
await gradeApi.create({
classId: filter.classId,
courseId: singleForm.courseId || null,
userId: singleForm.userId,
examName: singleForm.examName.trim(),
score: singleForm.score,
totalScore: singleForm.totalScore,
});
ElMessage.success('成绩已录入,已实时推送给该学生');
singleVisible.value = false;
await loadGrades();
}
/* ------------------------------------------------------------ 批量录入 */
const bulkVisible = ref(false);
const bulkForm = reactive({ courseId: '', examName: '期中考试', totalScore: 100 });
const bulkRows = ref<Array<{ userId: string; name: string; username: string; score: number | null }>>([]);
function openBulk(): void {
if (students.value.length === 0) {
ElMessage.warning('该班级暂无学生');
return;
}
bulkForm.courseId = filter.courseId || courses.value[0]?.id || '';
bulkForm.examName = filter.examName || '期中考试';
bulkForm.totalScore = 100;
bulkRows.value = students.value.map((item) => ({
userId: item.id,
name: item.name,
username: item.username,
score: null,
}));
bulkVisible.value = true;
}
async function submitBulk(): Promise<void> {
const items = bulkRows.value
.filter((row) => row.score !== null && row.score !== undefined)
.map((row) => ({ userId: row.userId, score: Number(row.score) }));
if (items.length === 0) {
ElMessage.warning('至少录入一位学生的成绩');
return;
}
if (!bulkForm.examName.trim()) {
ElMessage.warning('请填写考试名称');
return;
}
const result = await gradeApi.bulkCreate({
classId: filter.classId,
courseId: bulkForm.courseId || null,
examName: bulkForm.examName.trim(),
totalScore: bulkForm.totalScore,
items,
});
ElMessage.success(`已录入 ${result.count} 条成绩并广播到班级`);
bulkVisible.value = false;
await loadGrades();
}
async function removeGrade(row: GradeDto): Promise<void> {
await ElMessageBox.confirm(`删除 ${row.student?.name ?? ''} 的「${row.examName}」成绩?`, '确认', {
type: 'warning',
});
await gradeApi.remove(row.id);
ElMessage.success('已删除');
await loadGrades();
}
/* ------------------------------------------------------------ 教师视角建议:按考试分组统计 */
const teacherCourseSuggestions = ref<CourseDto[]>([]);
async function loadTeacherSuggestions(): Promise<void> {
teacherCourseSuggestions.value = courses.value;
}
function onGradeEvent(): void {
void loadGrades();
}
watch(
() => [filter.courseId, filter.examName],
() => {
void loadGrades();
},
);
onMounted(async () => {
await loadClasses();
await loadGrades();
window.addEventListener('resize', handleResize);
realtime.on(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
levelChart.value?.dispose();
courseChart.value?.dispose();
realtime.off(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">成绩录入</h2>
<p class="page-subtitle">同一学生 + 课程 + 考试重复录入会覆盖旧分数,避免产生重复记录</p>
</div>
<div class="toolbar">
<el-select
v-model="filter.classId"
placeholder="选择班级"
style="width: 170px"
@change="onClassChange"
>
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="filter.courseId" placeholder="全部课程" clearable style="width: 150px">
<el-option v-for="item in courses" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="filter.examName" placeholder="全部考试" clearable style="width: 160px">
<el-option v-for="item in examOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-button type="primary" :icon="'Plus'" @click="openSingle">单条录入</el-button>
<el-button type="success" :icon="'Upload'" @click="openBulk">批量录入</el-button>
</div>
</div>
<el-row :gutter="12">
<el-col :xs="24" :md="6">
<div class="stat-card">
<div class="stat-label">成绩记录</div>
<div class="stat-value">{{ grades.length }}</div>
<div class="stat-hint">当前筛选条件</div>
</div>
<div class="stat-card mt-12">
<div class="stat-label">平均得分率</div>
<div class="stat-value">{{ averagePercent }}%</div>
<div class="stat-hint">按总分换算</div>
</div>
</el-col>
<el-col :xs="24" :md="9">
<el-card shadow="never">
<template #header><span>等级分布</span></template>
<div ref="levelChartRef" class="chart"></div>
</el-card>
</el-col>
<el-col :xs="24" :md="9">
<el-card shadow="never">
<template #header><span>各课程平均得分率</span></template>
<div ref="courseChartRef" class="chart"></div>
</el-card>
</el-col>
</el-row>
<el-card shadow="never" class="mt-16">
<el-table v-loading="loading" :data="grades" empty-text="暂无成绩记录" max-height="520">
<el-table-column label="学生" width="140">
<template #default="{ row }">{{ row.student?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="用户名" width="130">
<template #default="{ row }">{{ row.student?.username ?? '-' }}</template>
</el-table-column>
<el-table-column label="课程" width="110">
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
</el-table-column>
<el-table-column prop="examName" label="考试" width="150" />
<el-table-column label="分数" width="130">
<template #default="{ row }">{{ row.score }} / {{ row.totalScore }}</template>
</el-table-column>
<el-table-column label="得分率" width="190">
<template #default="{ row }">
<el-progress
:percentage="gradePercent(row.score, row.totalScore)"
:stroke-width="10"
:status="gradePercent(row.score, row.totalScore) >= 60 ? 'success' : 'exception'"
/>
<span class="text-muted">{{ gradeLevel(gradePercent(row.score, row.totalScore)) }} 等</span>
</template>
</el-table-column>
<el-table-column label="发布时间" width="170">
<template #default="{ row }">{{ formatDate(row.publishedAt, true) }}</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ row }">
<el-button link type="danger" @click="removeGrade(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 单条录入 -->
<el-dialog v-model="singleVisible" title="单条成绩录入" width="480px">
<el-form :model="singleForm" label-width="90px">
<el-form-item label="学生">
<el-select v-model="singleForm.userId" filterable style="width: 100%">
<el-option
v-for="item in students"
:key="item.id"
:label="`${item.name}(${item.username})`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="课程">
<el-select v-model="singleForm.courseId" clearable style="width: 100%">
<el-option v-for="item in courses" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="考试名称">
<el-input v-model="singleForm.examName" placeholder="例如 第一次月考" />
</el-form-item>
<el-form-item label="分数">
<el-input-number v-model="singleForm.score" :min="0" :max="singleForm.totalScore" />
<span style="margin: 0 8px">/</span>
<el-input-number v-model="singleForm.totalScore" :min="1" :max="1000" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="singleVisible = false">取消</el-button>
<el-button type="primary" @click="submitSingle">录入并推送</el-button>
</template>
</el-dialog>
<!-- 批量录入 -->
<el-dialog v-model="bulkVisible" title="批量录入成绩" width="620px">
<el-form :model="bulkForm" label-width="90px" inline>
<el-form-item label="课程">
<el-select v-model="bulkForm.courseId" clearable style="width: 150px">
<el-option
v-for="item in teacherCourseSuggestions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="考试">
<el-input v-model="bulkForm.examName" style="width: 170px" />
</el-form-item>
<el-form-item label="总分">
<el-input-number v-model="bulkForm.totalScore" :min="1" :max="1000" />
</el-form-item>
</el-form>
<el-table :data="bulkRows" height="360" size="small">
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column prop="username" label="用户名" width="140" />
<el-table-column label="分数">
<template #default="{ row }">
<el-input-number v-model="row.score" :min="0" :max="bulkForm.totalScore" size="small" />
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="bulkVisible = false">取消</el-button>
<el-button type="primary" @click="submitBulk">提交并广播</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.chart {
height: 220px;
width: 100%;
}
</style>
@@ -0,0 +1,319 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import {
SOCKET_EVENTS,
formatDate,
formatDeadline,
isOverdue,
truncate,
type ClassDto,
type CourseDto,
type HomeworkDto,
} from '@classhelper/shared';
import { classApi, courseApi, homeworkApi } from '@/api';
import { useRealtimeStore } from '@/stores/realtime';
const realtime = useRealtimeStore();
const loading = ref(false);
const classes = ref<ClassDto[]>([]);
const courses = ref<CourseDto[]>([]);
const homeworks = ref<HomeworkDto[]>([]);
const filter = reactive({ classId: '', courseId: '', keyword: '' });
const detailVisible = ref(false);
const current = ref<HomeworkDto | null>(null);
const classStudents = ref<Array<{ id: string; name: string }>>([]);
const completedMap = computed(() => {
const total = classStudents.value.length;
const done = current.value?.completedCount ?? 0;
return { total, done, rate: total === 0 ? 0 : Math.round((done / total) * 100) };
});
async function loadClasses(): Promise<void> {
classes.value = await classApi.list();
if (!filter.classId && classes.value.length > 0) filter.classId = classes.value[0]?.id ?? '';
await loadCourses();
}
async function loadCourses(): Promise<void> {
courses.value = filter.classId ? await courseApi.list(filter.classId) : [];
}
async function loadHomeworks(): Promise<void> {
loading.value = true;
try {
const params: { classId?: string; courseId?: string; keyword?: string } = {};
if (filter.classId) params.classId = filter.classId;
if (filter.courseId) params.courseId = filter.courseId;
if (filter.keyword.trim()) params.keyword = filter.keyword.trim();
homeworks.value = await homeworkApi.list(params);
} finally {
loading.value = false;
}
}
async function onClassChange(): Promise<void> {
filter.courseId = '';
await loadCourses();
await loadHomeworks();
}
/* ------------------------------------------------------------ 发布 / 编辑 */
const formVisible = ref(false);
const formRef = ref<FormInstance>();
const editingId = ref<string | null>(null);
const form = reactive({
classId: '',
courseId: '',
title: '',
content: '',
attachmentUrl: '',
dueAt: '',
});
const rules: FormRules = {
classId: [{ required: true, message: '请选择目标班级', trigger: 'change' }],
title: [{ required: true, message: '请输入作业标题', trigger: 'blur' }],
content: [{ required: true, message: '请输入作业内容', trigger: 'blur' }],
};
function openCreate(): void {
editingId.value = null;
form.classId = filter.classId || classes.value[0]?.id || '';
form.courseId = '';
form.title = '';
form.content = '';
form.attachmentUrl = '';
form.dueAt = '';
formVisible.value = true;
}
function openEdit(row: HomeworkDto): void {
editingId.value = row.id;
form.classId = row.classId;
form.courseId = row.courseId ?? '';
form.title = row.title;
form.content = row.content;
form.attachmentUrl = row.attachmentUrl ?? '';
form.dueAt = row.dueAt ? row.dueAt.slice(0, 16) : '';
formVisible.value = true;
}
async function submitForm(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
const payload = {
classId: form.classId,
courseId: form.courseId || null,
title: form.title.trim(),
content: form.content,
attachmentUrl: form.attachmentUrl.trim() || null,
dueAt: form.dueAt ? new Date(form.dueAt).toISOString() : null,
};
if (editingId.value) {
await homeworkApi.update(editingId.value, payload);
ElMessage.success('作业已更新');
} else {
await homeworkApi.create(payload);
ElMessage.success('作业已发布,实时推送已发出');
}
formVisible.value = false;
await loadHomeworks();
}
async function removeHomework(row: HomeworkDto): Promise<void> {
await ElMessageBox.confirm(`删除作业「${row.title}」?`, '确认', { type: 'warning' });
await homeworkApi.remove(row.id);
ElMessage.success('已删除');
await loadHomeworks();
}
async function openDetail(row: HomeworkDto): Promise<void> {
current.value = await homeworkApi.detail(row.id);
const students = await classApi.students(row.classId);
classStudents.value = students.map((item) => ({ id: item.id, name: item.name }));
detailVisible.value = true;
}
function onHomeworkEvent(): void {
void loadHomeworks();
}
onMounted(async () => {
await loadClasses();
await loadHomeworks();
realtime.on(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
realtime.on(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
realtime.on(SOCKET_EVENTS.homeworkStatus, onHomeworkEvent);
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
realtime.off(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
realtime.off(SOCKET_EVENTS.homeworkStatus, onHomeworkEvent);
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">作业发布</h2>
<p class="page-subtitle">发布后立即通过 WebSocket 广播到学生客户端(homework:new)</p>
</div>
<div class="toolbar">
<el-select
v-model="filter.classId"
placeholder="选择班级"
style="width: 170px"
@change="onClassChange"
>
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select
v-model="filter.courseId"
placeholder="全部课程"
clearable
style="width: 150px"
@change="loadHomeworks"
>
<el-option v-for="item in courses" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-input
v-model="filter.keyword"
placeholder="搜索标题/内容"
clearable
style="width: 170px"
@keyup.enter="loadHomeworks"
@clear="loadHomeworks"
/>
<el-button type="primary" :icon="'Plus'" @click="openCreate">发布作业</el-button>
</div>
</div>
<el-card shadow="never">
<el-table v-loading="loading" :data="homeworks" empty-text="暂无作业">
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
<el-table-column label="课程" width="110">
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="截止时间" width="180">
<template #default="{ row }">
<span :class="{ 'text-danger': isOverdue(row.dueAt) }">
{{ formatDate(row.dueAt, true) || '不限' }}
</span>
</template>
</el-table-column>
<el-table-column label="剩余" width="130">
<template #default="{ row }">{{ formatDeadline(row.dueAt) }}</template>
</el-table-column>
<el-table-column label="已完成" width="100">
<template #default="{ row }">{{ row.completedCount ?? 0 }} 人</template>
</el-table-column>
<el-table-column label="发布时间" width="170">
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
</el-table-column>
<el-table-column label="操作" width="210" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="removeHomework(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="formVisible" :title="editingId ? '编辑作业' : '发布作业'" width="560px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="目标班级" prop="classId">
<el-select v-model="form.classId" style="width: 100%" :disabled="Boolean(editingId)">
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="所属课程">
<el-select v-model="form.courseId" placeholder="可选" clearable style="width: 100%">
<el-option v-for="item in courses" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" maxlength="120" show-word-limit />
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input v-model="form.content" type="textarea" :rows="5" maxlength="5000" show-word-limit />
</el-form-item>
<el-form-item label="附件链接">
<el-input v-model="form.attachmentUrl" placeholder="https://..." />
</el-form-item>
<el-form-item label="截止时间">
<el-date-picker
v-model="form.dueAt"
type="datetime"
placeholder="选择截止时间"
value-format="YYYY-MM-DDTHH:mm"
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">{{ editingId ? '保存' : '发布' }}</el-button>
</template>
</el-dialog>
<el-drawer v-model="detailVisible" size="46%" :title="current?.title ?? '作业详情'">
<template v-if="current">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="课程">{{ current.course?.name ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="发布人">{{ current.creator?.name ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="截止时间">
{{ formatDate(current.dueAt, true) || '不限' }}
</el-descriptions-item>
<el-descriptions-item label="发布时间">
{{ formatDate(current.createdAt, true) }}
</el-descriptions-item>
<el-descriptions-item label="完成情况" :span="2">
已完成 {{ completedMap.done }} / {{ completedMap.total }} 人({{ completedMap.rate }}%)
<el-progress :percentage="completedMap.rate" :stroke-width="10" class="mt-12" />
</el-descriptions-item>
</el-descriptions>
<div class="mt-16">
<h4>作业内容</h4>
<p class="content-block">{{ current.content }}</p>
</div>
<div v-if="current.attachmentUrl" class="mt-12">
<el-link type="primary" :href="current.attachmentUrl" target="_blank">查看附件</el-link>
</div>
<el-alert
class="mt-16"
type="info"
:closable="false"
title="学生端实时同步"
:description="`学生客户端在收到 homework:new / homework:status 事件后会自动刷新列表,并在离线时使用本地缓存(摘要:${truncate(current.content, 30)})`"
/>
</template>
</el-drawer>
</div>
</template>
<style scoped>
.text-danger {
color: #f56c6c;
}
.content-block {
white-space: pre-wrap;
line-height: 1.7;
background: #fafafa;
border-radius: 8px;
padding: 12px;
margin: 0;
}
</style>
+147
View File
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { useAuthStore } from '@/stores/auth';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const formRef = ref<FormInstance>();
const form = reactive({ username: '', password: '' });
const rules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
};
const demoAccounts = [
{ role: '管理员', username: 'admin', password: 'admin123' },
{ role: '教师', username: 'teacher1', password: 'teacher123' },
{ role: '教师', username: 'teacher2', password: 'teacher123' },
];
function fill(username: string, password: string): void {
form.username = username;
form.password = password;
}
async function submit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
try {
await auth.login({ username: form.username.trim(), password: form.password });
ElMessage.success(`欢迎回来,${auth.displayName}`);
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/dashboard';
await router.replace(redirect);
} catch {
// 错误提示已由 axios 拦截器统一处理
}
}
</script>
<template>
<div class="login-page">
<div class="login-card">
<div class="login-brand">
<el-icon :size="30" color="#409eff"><School /></el-icon>
<div>
<h1 class="login-title">班级小助手</h1>
<p class="login-subtitle">Web 管理端 · 教师 / 管理员登录</p>
</div>
</div>
<el-form ref="formRef" :model="form" :rules="rules" size="large" @submit.prevent="submit">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名" :prefix-icon="'User'" clearable />
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="密码"
:prefix-icon="'Lock'"
show-password
@keyup.enter="submit"
/>
</el-form-item>
<el-button type="primary" size="large" class="login-button" :loading="auth.loading" @click="submit">
登录
</el-button>
</el-form>
<el-divider>演示账号(点击填充)</el-divider>
<div class="demo-list">
<el-tag
v-for="item in demoAccounts"
:key="item.username"
class="demo-tag"
effect="plain"
@click="fill(item.username, item.password)"
>
{{ item.role }}:{{ item.username }} / {{ item.password }}
</el-tag>
</div>
<p class="login-tip">学生请使用班级小助手桌面客户端登录;种子数据见 README。</p>
</div>
</div>
</template>
<style scoped>
.login-page {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1f2d3d 0%, #3a5169 55%, #409eff 100%);
}
.login-card {
width: 400px;
background: #fff;
border-radius: 14px;
padding: 30px 28px 22px;
box-shadow: 0 18px 40px rgba(15, 30, 50, 0.28);
}
.login-brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 22px;
}
.login-title {
font-size: 20px;
margin: 0;
}
.login-subtitle {
margin: 4px 0 0;
font-size: 13px;
color: #909399;
}
.login-button {
width: 100%;
}
.demo-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.demo-tag {
cursor: pointer;
justify-content: flex-start;
}
.login-tip {
margin: 16px 0 0;
font-size: 12px;
color: #c0c4cc;
line-height: 1.6;
}
</style>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { useRouter } from 'vue-router';
const router = useRouter();
</script>
<template>
<div class="not-found">
<el-result icon="warning" title="404" sub-title="页面不存在或已被移除">
<template #extra>
<el-button type="primary" @click="router.push('/dashboard')">返回仪表盘</el-button>
</template>
</el-result>
</div>
</template>
<style scoped>
.not-found {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
</style>
@@ -0,0 +1,226 @@
<script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import {
NOTIFICATION_PRIORITIES,
PRIORITY_LABELS,
PRIORITY_TAG_TYPES,
SOCKET_EVENTS,
formatDate,
relativeTime,
truncate,
type ClassDto,
type NotificationDto,
type NotificationPriority,
} from '@classhelper/shared';
import { classApi, notificationApi } from '@/api';
import { useRealtimeStore } from '@/stores/realtime';
const realtime = useRealtimeStore();
const loading = ref(false);
const classes = ref<ClassDto[]>([]);
const notifications = ref<NotificationDto[]>([]);
const studentCount = ref(0);
const filter = reactive({ classId: '', priority: '', keyword: '' });
async function loadClasses(): Promise<void> {
classes.value = await classApi.list();
if (!filter.classId && classes.value.length > 0) filter.classId = classes.value[0]?.id ?? '';
}
async function loadNotifications(): Promise<void> {
loading.value = true;
try {
const params: { classId?: string; priority?: string; keyword?: string } = {};
if (filter.classId) params.classId = filter.classId;
if (filter.priority) params.priority = filter.priority;
if (filter.keyword.trim()) params.keyword = filter.keyword.trim();
notifications.value = await notificationApi.list(params);
if (filter.classId) {
const students = await classApi.students(filter.classId);
studentCount.value = students.length;
}
} finally {
loading.value = false;
}
}
/* ------------------------------------------------------------ 发布 */
const formVisible = ref(false);
const formRef = ref<FormInstance>();
const form = reactive<{ classId: string; title: string; content: string; priority: NotificationPriority }>({
classId: '',
title: '',
content: '',
priority: 'NORMAL',
});
const rules: FormRules = {
classId: [{ required: true, message: '请选择目标班级', trigger: 'change' }],
title: [{ required: true, message: '请输入通知标题', trigger: 'blur' }],
content: [{ required: true, message: '请输入通知内容', trigger: 'blur' }],
};
function openCreate(): void {
form.classId = filter.classId || classes.value[0]?.id || '';
form.title = '';
form.content = '';
form.priority = 'NORMAL';
formVisible.value = true;
}
async function submitForm(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
const created = await notificationApi.create({
classId: form.classId,
title: form.title.trim(),
content: form.content,
priority: form.priority,
});
ElMessage.success(`通知已发布(${created.title}),已推送给学生客户端`);
formVisible.value = false;
await loadNotifications();
}
async function removeNotification(row: NotificationDto): Promise<void> {
await ElMessageBox.confirm(`删除通知「${row.title}」?`, '确认', { type: 'warning' });
await notificationApi.remove(row.id);
ElMessage.success('已删除');
await loadNotifications();
}
function readRate(row: NotificationDto): number {
if (!studentCount.value) return 0;
return Math.round(((row.readCount ?? 0) / studentCount.value) * 100);
}
function onNotificationEvent(): void {
void loadNotifications();
}
onMounted(async () => {
await loadClasses();
await loadNotifications();
realtime.on(SOCKET_EVENTS.notificationNew, onNotificationEvent);
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.notificationNew, onNotificationEvent);
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">通知发布</h2>
<p class="page-subtitle">发布后按班级房间广播(notification:new),学生端 5 秒内到达</p>
</div>
<div class="toolbar">
<el-select
v-model="filter.classId"
placeholder="选择班级"
style="width: 170px"
@change="loadNotifications"
>
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select
v-model="filter.priority"
placeholder="全部优先级"
clearable
style="width: 150px"
@change="loadNotifications"
>
<el-option
v-for="item in NOTIFICATION_PRIORITIES"
:key="item"
:label="PRIORITY_LABELS[item]"
:value="item"
/>
</el-select>
<el-input
v-model="filter.keyword"
placeholder="搜索标题/内容"
clearable
style="width: 170px"
@keyup.enter="loadNotifications"
@clear="loadNotifications"
/>
<el-button type="primary" :icon="'Plus'" @click="openCreate">发布通知</el-button>
</div>
</div>
<el-card shadow="never">
<el-table v-loading="loading" :data="notifications" empty-text="暂无通知">
<el-table-column label="优先级" width="90">
<template #default="{ row }">
<el-tag
:type="PRIORITY_TAG_TYPES[row.priority as NotificationPriority]"
size="small"
effect="light"
>
{{ PRIORITY_LABELS[row.priority as NotificationPriority] }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
<el-table-column label="内容摘要" min-width="220">
<template #default="{ row }">{{ truncate(row.content, 40) }}</template>
</el-table-column>
<el-table-column label="发布人" width="110">
<template #default="{ row }">{{ row.creator?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="发布时间" width="170">
<template #default="{ row }">
<div>{{ formatDate(row.createdAt, true) }}</div>
<div class="text-muted">{{ relativeTime(row.createdAt) }}</div>
</template>
</el-table-column>
<el-table-column label="已读率" width="150">
<template #default="{ row }">
<el-progress :percentage="readRate(row)" :stroke-width="10" />
<span class="text-muted">{{ row.readCount ?? 0 }}/{{ studentCount }} 人</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ row }">
<el-button link type="danger" @click="removeNotification(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="formVisible" title="发布通知" width="560px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="目标班级" prop="classId">
<el-select v-model="form.classId" style="width: 100%">
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="优先级">
<el-radio-group v-model="form.priority">
<el-radio-button v-for="item in NOTIFICATION_PRIORITIES" :key="item" :value="item">
{{ PRIORITY_LABELS[item] }}
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" maxlength="120" show-word-limit />
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input v-model="form.content" type="textarea" :rows="5" maxlength="5000" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">立即发布</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -0,0 +1,292 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import {
SOCKET_EVENTS,
WEEKDAYS,
WEEKDAY_LABELS,
buildWeekOptions,
formatWeekRange,
type ClassDto,
type CourseDto,
type CreateScheduleRequest,
type ScheduleDto,
type ScheduleWeekView,
} from '@classhelper/shared';
import { classApi, courseApi, dashboardApi, scheduleApi } from '@/api';
import { useRealtimeStore } from '@/stores/realtime';
const realtime = useRealtimeStore();
const loading = ref(false);
const classes = ref<ClassDto[]>([]);
const courses = ref<CourseDto[]>([]);
const grid = ref<ScheduleWeekView | null>(null);
const rawSchedules = ref<ScheduleDto[]>([]);
const query = reactive({ classId: '', week: 1 });
const weekOptions = ref<number[]>(buildWeekOptions(20));
/** 把同一时间段的课表行聚合成"节次"行,形成 7 列课表 */
const periods = computed(() => {
const buckets = new Map<string, ScheduleDto[]>();
for (const item of rawSchedules.value) {
const key = `${item.startTime}-${item.endTime}`;
const bucket = buckets.get(key) ?? [];
bucket.push(item);
buckets.set(key, bucket);
}
return [...buckets.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([key, items]) => {
const [start, end] = key.split('-');
return { start: start ?? '', end: end ?? '', items };
});
});
function cellItems(period: { items: ScheduleDto[] }, dayOfWeek: number): ScheduleDto[] {
return period.items.filter((item) => item.dayOfWeek === dayOfWeek);
}
async function loadBase(): Promise<void> {
const [classList, term] = await Promise.all([classApi.list(), dashboardApi.term()]);
classes.value = classList;
weekOptions.value = buildWeekOptions(term.maxWeek);
query.week = term.currentWeek;
if (!query.classId && classList.length > 0) query.classId = classList[0]?.id ?? '';
if (query.classId) await loadCourses();
}
async function loadCourses(): Promise<void> {
courses.value = query.classId ? await courseApi.list(query.classId) : [];
}
async function loadSchedules(): Promise<void> {
if (!query.classId) {
grid.value = null;
rawSchedules.value = [];
return;
}
loading.value = true;
try {
const [weekGrid, list] = await Promise.all([
scheduleApi.grid({ classId: query.classId, week: query.week }),
scheduleApi.list({ classId: query.classId, week: query.week }),
]);
grid.value = weekGrid;
rawSchedules.value = list;
} finally {
loading.value = false;
}
}
async function onClassChange(): Promise<void> {
await loadCourses();
await loadSchedules();
}
/* ------------------------------------------------------------ 新增 / 编辑 */
const formVisible = ref(false);
const formRef = ref<FormInstance>();
const editingId = ref<string | null>(null);
const form = reactive({
courseId: '',
dayOfWeek: 1,
startTime: '08:00',
endTime: '08:45',
location: '',
weekStart: 1,
weekEnd: 20,
});
const rules: FormRules = {
courseId: [{ required: true, message: '请选择课程', trigger: 'change' }],
startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
endTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }],
};
function openCreate(): void {
if (!query.classId) {
ElMessage.warning('请先选择班级');
return;
}
editingId.value = null;
form.courseId = courses.value[0]?.id ?? '';
form.dayOfWeek = 1;
form.startTime = '08:00';
form.endTime = '08:45';
form.location = '';
form.weekStart = 1;
form.weekEnd = weekOptions.value.at(-1) ?? 20;
formVisible.value = true;
}
function openEdit(item: ScheduleDto): void {
editingId.value = item.id;
form.courseId = item.courseId;
form.dayOfWeek = item.dayOfWeek;
form.startTime = item.startTime;
form.endTime = item.endTime;
form.location = item.location ?? '';
form.weekStart = item.weekStart;
form.weekEnd = item.weekEnd;
formVisible.value = true;
}
async function submitForm(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
if (form.startTime >= form.endTime) {
ElMessage.warning('结束时间必须晚于开始时间');
return;
}
const payload: CreateScheduleRequest = {
classId: query.classId,
courseId: form.courseId,
dayOfWeek: Number(form.dayOfWeek),
startTime: form.startTime,
endTime: form.endTime,
location: form.location || null,
weekStart: Number(form.weekStart),
weekEnd: Number(form.weekEnd),
};
if (editingId.value) {
await scheduleApi.update(editingId.value, payload);
ElMessage.success('课表已更新');
} else {
await scheduleApi.create(payload);
ElMessage.success('课表已新增');
}
formVisible.value = false;
await loadSchedules();
}
async function removeSchedule(item: ScheduleDto): Promise<void> {
await ElMessageBox.confirm(
`删除 ${WEEKDAY_LABELS[item.dayOfWeek]} ${item.startTime} ${item.course?.name ?? ''} 这节课?`,
'确认',
{
type: 'warning',
},
);
await scheduleApi.remove(item.id);
ElMessage.success('已删除');
await loadSchedules();
}
function onScheduleEvent(): void {
void loadSchedules();
}
onMounted(async () => {
await loadBase();
await loadSchedules();
realtime.on(SOCKET_EVENTS.scheduleUpdated, onScheduleEvent);
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.scheduleUpdated, onScheduleEvent);
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">课表管理</h2>
<p class="page-subtitle">按周次维护课程安排,支持单双周范围(起始周 - 结束周)</p>
</div>
<div class="toolbar">
<el-select
v-model="query.classId"
placeholder="选择班级"
style="width: 170px"
@change="onClassChange"
>
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="query.week" placeholder="周次" style="width: 130px" @change="loadSchedules">
<el-option v-for="week in weekOptions" :key="week" :label="`第 ${week} 周`" :value="week" />
</el-select>
<el-button :icon="'Refresh'" @click="loadSchedules">刷新</el-button>
<el-button type="primary" :icon="'Plus'" @click="openCreate">新增课表</el-button>
</div>
</div>
<el-card v-loading="loading" shadow="never">
<template #header>
<div class="toolbar">
<span>
第 {{ query.week }} 周课表
<span class="text-muted">(共 {{ rawSchedules.length }} 节课)</span>
</span>
</div>
</template>
<el-empty v-if="periods.length === 0" description="该周暂无课表,点击右上角新增" />
<el-table v-else :data="periods" border size="small">
<el-table-column label="节次" width="120" fixed>
<template #default="{ row }">
<div>{{ row.start }}</div>
<div class="timetable-meta">{{ row.end }}</div>
</template>
</el-table-column>
<el-table-column v-for="day in WEEKDAYS" :key="day" :label="WEEKDAY_LABELS[day]" min-width="130">
<template #default="{ row }">
<div v-for="item in cellItems(row, day)" :key="item.id" class="timetable-cell">
<div class="timetable-course">{{ item.course?.name ?? '-' }}</div>
<div class="timetable-meta">{{ item.location ?? '未填地点' }}</div>
<div class="timetable-meta">{{ formatWeekRange(item.weekStart, item.weekEnd) }}</div>
<div>
<el-button link type="primary" size="small" @click="openEdit(item)">编辑</el-button>
<el-button link type="danger" size="small" @click="removeSchedule(item)">删除</el-button>
</div>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="formVisible" :title="editingId ? '编辑课表' : '新增课表'" width="480px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="课程" prop="courseId">
<el-select v-model="form.courseId" placeholder="选择课程" style="width: 100%">
<el-option v-for="item in courses" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="星期" prop="dayOfWeek">
<el-select v-model="form.dayOfWeek" style="width: 100%">
<el-option v-for="day in WEEKDAYS" :key="day" :label="WEEKDAY_LABELS[day]" :value="day" />
</el-select>
</el-form-item>
<el-form-item label="开始时间" prop="startTime">
<el-time-select
v-model="form.startTime"
start="07:00"
step="00:05"
end="21:00"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="结束时间" prop="endTime">
<el-time-select v-model="form.endTime" start="07:00" step="00:05" end="22:00" style="width: 100%" />
</el-form-item>
<el-form-item label="地点">
<el-input v-model="form.location" placeholder="例如 教学楼 A301" />
</el-form-item>
<el-form-item label="周次范围">
<el-input-number v-model="form.weekStart" :min="1" :max="30" />
<span style="margin: 0 8px">至</span>
<el-input-number v-model="form.weekEnd" :min="1" :max="30" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">保存</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -0,0 +1,187 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import { ROLE_LABELS, formatDate, type ClassDto, type StudentDto } from '@classhelper/shared';
import { classApi, studentApi } from '@/api';
const loading = ref(false);
const students = ref<StudentDto[]>([]);
const classes = ref<ClassDto[]>([]);
const filter = reactive({ classId: '', keyword: '' });
async function loadClasses(): Promise<void> {
classes.value = await classApi.list();
}
async function loadStudents(): Promise<void> {
loading.value = true;
try {
const params: { classId?: string; keyword?: string } = {};
if (filter.classId) params.classId = filter.classId;
if (filter.keyword.trim()) params.keyword = filter.keyword.trim();
students.value = await studentApi.list(params);
} finally {
loading.value = false;
}
}
/* ------------------------------------------------------------ 新建 / 编辑 */
const formVisible = ref(false);
const formRef = ref<FormInstance>();
const editingId = ref<string | null>(null);
const form = reactive({ username: '', name: '', password: '', classId: '' });
const rules: FormRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, message: '用户名至少 3 位', trigger: 'blur' },
],
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
};
function openCreate(): void {
editingId.value = null;
form.username = '';
form.name = '';
form.password = '';
form.classId = filter.classId;
formVisible.value = true;
}
function openEdit(row: StudentDto): void {
editingId.value = row.id;
form.username = row.username;
form.name = row.name;
form.password = '';
form.classId = row.classId ?? '';
formVisible.value = true;
}
async function submitForm(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
if (editingId.value) {
await studentApi.update(editingId.value, {
username: form.username.trim(),
name: form.name.trim(),
classId: form.classId || null,
});
ElMessage.success('学生信息已更新');
} else {
await studentApi.create({
username: form.username.trim(),
name: form.name.trim(),
classId: form.classId || null,
...(form.password ? { password: form.password } : {}),
});
ElMessage.success('学生账号创建成功');
}
formVisible.value = false;
await loadStudents();
}
async function removeStudent(row: StudentDto): Promise<void> {
await ElMessageBox.confirm(`删除学生「${row.name}」及其成绩、作业记录?`, '危险操作', {
type: 'warning',
confirmButtonText: '确认删除',
});
await studentApi.remove(row.id);
ElMessage.success('学生已删除');
await loadStudents();
}
async function resetPassword(row: StudentDto): Promise<void> {
await ElMessageBox.confirm(`将「${row.name}」的密码重置为默认密码?`, '重置密码', { type: 'warning' });
await studentApi.resetPassword(row.id);
ElMessage.success('密码已重置为默认密码');
}
onMounted(async () => {
await Promise.all([loadClasses(), loadStudents()]);
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">学生管理</h2>
<p class="page-subtitle">学生账号、分班与密码重置(数据范围按班级权限收敛)</p>
</div>
<div class="toolbar">
<el-select
v-model="filter.classId"
placeholder="全部班级"
clearable
style="width: 180px"
@change="loadStudents"
>
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-input
v-model="filter.keyword"
placeholder="姓名 / 用户名"
clearable
style="width: 180px"
@keyup.enter="loadStudents"
@clear="loadStudents"
/>
<el-button :icon="'Search'" @click="loadStudents">查询</el-button>
<el-button type="primary" :icon="'Plus'" @click="openCreate">新建学生</el-button>
</div>
</div>
<el-card shadow="never">
<el-table v-loading="loading" :data="students" empty-text="暂无学生数据">
<el-table-column prop="username" label="用户名" width="140" />
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column label="角色" width="90">
<template #default="{ row }">{{ ROLE_LABELS[row.role as 'STUDENT'] ?? row.role }}</template>
</el-table-column>
<el-table-column label="班级" width="140">
<template #default="{ row }">
<el-tag v-if="row.className" size="small" effect="plain">{{ row.className }}</el-tag>
<span v-else class="text-muted">未分班</span>
</template>
</el-table-column>
<el-table-column label="年级" width="90">
<template #default="{ row }">{{ row.grade ?? '-' }}</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="warning" @click="resetPassword(row)">重置密码</el-button>
<el-button link type="danger" @click="removeStudent(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="formVisible" :title="editingId ? '编辑学生' : '新建学生'" width="460px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" :disabled="Boolean(editingId)" />
</el-form-item>
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item v-if="!editingId" label="初始密码">
<el-input v-model="form.password" placeholder="留空使用默认密码" />
</el-form-item>
<el-form-item label="班级">
<el-select v-model="form.classId" placeholder="暂不分班" clearable style="width: 100%">
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">保存</el-button>
</template>
</el-dialog>
</div>
</template>
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"jsx": "preserve",
"types": ["node"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"declaration": false,
"declarationMap": false
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "vite.config.ts"]
}
+28
View File
@@ -0,0 +1,28 @@
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
const BACKEND_ORIGIN = process.env.CLASSHELPER_BACKEND ?? 'http://127.0.0.1:4000';
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
host: '127.0.0.1',
port: 5173,
// 开发期把 /api 与 /socket.io 代理到后端,避免跨域配置
proxy: {
'/api': { target: BACKEND_ORIGIN, changeOrigin: true },
'/socket.io': { target: BACKEND_ORIGIN, changeOrigin: true, ws: true },
},
},
build: {
outDir: 'dist',
sourcemap: false,
chunkSizeWarningLimit: 2000,
},
});