feat: 班级小助手 阶段1-3(monorepo + 后端 REST/Socket.IO + Web 管理端)
This commit is contained in:
commit
84ab49de10
102 files changed
+14751
No files matched your search
@@ -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
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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 = '班级小助手 · 管理端';
|
||||
Vendored
+12
@@ -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>
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user