feat(server,web,shared,desktop): 上传超限正确报错 + 两个登录入口都限流 + 作业按天深链,文档与验证脚本同步
服务端加固: - express.json 上限 2mb → 12mb:表格导入把文件以 base64 放进 JSON(约 4/3 倍), 8MB 的声明上限原先到 1.5MB 就被拦掉;超限现在由 error 中间件映射成 413 IMPORT_TOO_LARGE(中文提示"请拆分后分批导入"),不再落进 500 兜底分支抛英文原文; - loginRateLimiter 同时挂 /auth/login 与 /auth/class-login(班级密码默认 123456, 班级登录曾是唯一没有暴力破解防护的入口),两个路径共享同一计数器; - CORS_ORIGIN=* 时启动告警(不拦截启动,避免挡住教室机 file:// 的合法部署); - 删除已无用的 DEFAULT_STUDENT_PASSWORD;班级 termWeeks 上限 40 → 30, 与 MAX_TERM_WEEK / weekNumberSchema 对齐(原先对外承诺了系统兑现不了的学期长度)。 Web 端: - 作业页 ?date= 深链在同路由再推 query 时也生效(watch route.query.date); - 表格导入对话框适配 8MB 上限与 413 提示。 冒烟与文档: - 新增 scripts/lib/smoke-class.mjs:验证用临时班级,避免探针通知弹到用户自己的客户端与灵动岛; - README 与 AGENTS.md 同步本轮全部行为变更(含 schedule source、学生账号清理、凭据门禁)。
This commit is contained in:
1 parent
3880eee08e
commit
20fcca0388
28 files changed
+1649
-915
No files matched your search
@@ -189,22 +189,17 @@ export const gradeApi = {
|
||||
|
||||
/* ------------------------------------------------------------------ 学生 */
|
||||
|
||||
// 学生是"名单"不是"账号":没有密码相关接口(登录统一走班级码 + 班级密码)
|
||||
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),
|
||||
create: (payload: { username: string; name: 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 } : {}),
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ 仪表盘 */
|
||||
|
||||
@@ -105,33 +105,52 @@ function close(): void {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
/** 模板下载:带上 JWT 直接取二进制,避免暴露匿名下载入口 */
|
||||
/**
|
||||
* 模板下载。
|
||||
*
|
||||
* 两种格式在服务端的返回形态**不同**,必须分开处理:
|
||||
* - xlsx:直接 `res.send(Buffer)` 返回二进制,因此这里用 fetch 取流;
|
||||
* - csv:走统一 JSON 响应体(`{ data: { content } }`),必须解析出 content 再自己生成文件。
|
||||
* 之前两种格式共用同一段 `response.blob()`,于是"下载 CSV 模板"得到的是一个
|
||||
* 名为 template-grades.csv、内容却是一整行 JSON 的文件,老师照它填完再导入必然失败。
|
||||
*/
|
||||
async function downloadTemplate(format: 'csv' | 'xlsx'): Promise<void> {
|
||||
try {
|
||||
if (format === 'csv') {
|
||||
const result = await importApi.template(props.kind, 'csv');
|
||||
saveBlob(new Blob([result.content], { type: 'text/csv;charset=utf-8' }), result.fileName);
|
||||
ElMessage.success('模板已开始下载');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem(STORAGE_KEYS.token) ?? '';
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}${API_PATHS.imports}/template?kind=${props.kind}&format=${format}`,
|
||||
`${API_BASE_URL}${API_PATHS.imports}/template?kind=${props.kind}&format=xlsx`,
|
||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
||||
);
|
||||
if (!response.ok) {
|
||||
ElMessage.error(`模板下载失败(${response.status})`);
|
||||
return;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `template-${props.kind}.${format}`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
saveBlob(await response.blob(), `template-${props.kind}.xlsx`);
|
||||
ElMessage.success('模板已开始下载');
|
||||
} catch (error) {
|
||||
ElMessage.error(`模板下载失败:${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 触发浏览器保存一个 Blob */
|
||||
function saveBlob(blob: Blob, fileName: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function pickFile(): void {
|
||||
fileInputRef.value?.click();
|
||||
}
|
||||
@@ -180,6 +199,10 @@ async function onFileChange(event: Event): Promise<void> {
|
||||
} catch {
|
||||
preview.value = null;
|
||||
contentBase64.value = '';
|
||||
// 必须把 input 的值也清掉:否则修好表格后重选**同一个文件名**时 @change 不会触发
|
||||
// (input 的 value 没变),界面会停在失败状态、"重新选择文件"按钮点了没反应。
|
||||
if (fileInputRef.value) fileInputRef.value.value = '';
|
||||
fileName.value = '';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,19 @@ app.use(createPinia());
|
||||
app.use(router);
|
||||
app.use(ElementPlus, { locale: zhCn });
|
||||
|
||||
/**
|
||||
* 全局错误处理。
|
||||
*
|
||||
* Element Plus 的 ElMessageBox 用 **rejection** 表达"用户取消"(reason 为 'cancel' / 'close'),
|
||||
* 而调用点普遍写成 `await ElMessageBox.confirm(...)`,于是每次点「取消」都会以
|
||||
* `Uncaught (in promise) cancel` 冒到控制台。这类噪音会淹没真正的异常(排查线上问题时最要命),
|
||||
* 因此在统一入口吞掉它;其余错误照常记录,行为不变。
|
||||
*/
|
||||
app.config.errorHandler = (error, _instance, info) => {
|
||||
if (error === 'cancel' || error === 'close') return;
|
||||
console.error(`[web-admin] 未处理的异常(${info})`, error);
|
||||
};
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
/**
|
||||
|
||||
@@ -63,8 +63,12 @@ async function loadClasses(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 请求序号:快速切班级时先发的慢响应不能覆盖新数据(否则出现"显示 A 班成绩、按钮按 B 班权限") */
|
||||
let gradesRequestId = 0;
|
||||
|
||||
async function loadGrades(): Promise<void> {
|
||||
if (!filter.classId) return;
|
||||
const requestId = ++gradesRequestId;
|
||||
loading.value = true;
|
||||
try {
|
||||
const params: { classId?: string; courseId?: string; examName?: string } = { classId: filter.classId };
|
||||
@@ -72,11 +76,12 @@ async function loadGrades(): Promise<void> {
|
||||
if (filter.examName) params.examName = filter.examName;
|
||||
|
||||
const [list, statistic] = await Promise.all([gradeApi.list(params), gradeApi.stats(params)]);
|
||||
if (requestId !== gradesRequestId) return;
|
||||
grades.value = list;
|
||||
stats.value = statistic;
|
||||
renderCharts();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (requestId === gradesRequestId) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,18 @@ const dayMarks = ref<Map<string, number>>(new Map());
|
||||
const detailVisible = ref(false);
|
||||
const current = ref<HomeworkDto | null>(null);
|
||||
|
||||
/**
|
||||
* 详情里的附件链接。
|
||||
*
|
||||
* 服务端已限制为 http(s) / 站内相对路径,这里在渲染端再兜一层:
|
||||
* 该值最终会进 `<a href>`,一旦放行 `javascript:` 就是一条存储型 XSS(老师写、管理员点)。
|
||||
*/
|
||||
const attachmentHref = computed(() => {
|
||||
const value = current.value?.attachmentUrl;
|
||||
if (!value) return '';
|
||||
return value.startsWith('/') || /^https?:\/\//i.test(value) ? value : '';
|
||||
});
|
||||
|
||||
const classStudents = ref<Array<{ id: string; name: string }>>([]);
|
||||
const completedMap = computed(() => {
|
||||
const total = classStudents.value.length;
|
||||
@@ -48,34 +60,57 @@ const submissionsSaving = ref(false);
|
||||
const submissionStudents = ref<HomeworkSubmissionsDto['students']>([]);
|
||||
/** 勾选 = 未交(保存后其余学生一律标记为已交) */
|
||||
const notSubmittedIds = ref<string[]>([]);
|
||||
/**
|
||||
* 当前这名单属于**哪一份作业**。
|
||||
*
|
||||
* 上面两个 ref 是页面级共享状态,而详情抽屉可以连续打开不同作业(loadSubmissions 是
|
||||
* 并发发出的),因此必须记住数据归属并给请求编号:否则慢响应会把 A 的名单显示成 B 的,
|
||||
* 老师点"保存"就把 A 的未交名单整份写到 B 上(服务端会对全班逐个 upsert,且没有二次确认)。
|
||||
*/
|
||||
const submissionsHomeworkId = ref('');
|
||||
let submissionsRequestId = 0;
|
||||
|
||||
const notSubmittedNames = computed(() =>
|
||||
submissionStudents.value.filter((item) => notSubmittedIds.value.includes(item.userId)),
|
||||
);
|
||||
|
||||
/** 打开详情时顺带读一次未交名单(教师/管理员都有权限) */
|
||||
async function loadSubmissions(homeworkId: string): Promise<void> {
|
||||
async function loadSubmissions(homeworkId: string): Promise<boolean> {
|
||||
const requestId = ++submissionsRequestId;
|
||||
submissionsLoading.value = true;
|
||||
try {
|
||||
const result = await homeworkApi.submissions(homeworkId);
|
||||
// 迟到的响应:用户已经切到别的作业了,直接丢弃
|
||||
if (requestId !== submissionsRequestId) return false;
|
||||
submissionStudents.value = result.students;
|
||||
notSubmittedIds.value = result.notSubmitted.map((item) => item.userId);
|
||||
submissionsHomeworkId.value = homeworkId;
|
||||
return true;
|
||||
} catch {
|
||||
if (requestId !== submissionsRequestId) return false;
|
||||
submissionStudents.value = [];
|
||||
notSubmittedIds.value = [];
|
||||
submissionsHomeworkId.value = homeworkId;
|
||||
return false;
|
||||
} finally {
|
||||
submissionsLoading.value = false;
|
||||
if (requestId === submissionsRequestId) submissionsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openSubmissions(): Promise<void> {
|
||||
if (!current.value) return;
|
||||
if (submissionStudents.value.length === 0) await loadSubmissions(current.value.id);
|
||||
// 每次都以"当前这份作业"为准重新拉一次:不能因为共享状态里还有数据就跳过 ——
|
||||
// 那些数据可能属于上一次打开的作业,保存时会写到错误的作业上。
|
||||
await loadSubmissions(current.value.id);
|
||||
submissionsVisible.value = true;
|
||||
}
|
||||
|
||||
async function saveSubmissions(): Promise<void> {
|
||||
if (!current.value) return;
|
||||
if (submissionsHomeworkId.value !== current.value.id) {
|
||||
ElMessage.warning('未交名单尚未加载完成,请稍候再保存');
|
||||
return;
|
||||
}
|
||||
submissionsSaving.value = true;
|
||||
try {
|
||||
const result = await homeworkApi.saveSubmissions(current.value.id, notSubmittedIds.value);
|
||||
@@ -320,6 +355,8 @@ function onHomeworkEvent(): void {
|
||||
|
||||
onMounted(async () => {
|
||||
await loadClasses();
|
||||
// 首屏也要拉一次"哪些天有作业",否则日期选择器上没有任何高亮,直到手动切一次班级才出现
|
||||
await loadDayMarks();
|
||||
await loadHomeworks();
|
||||
realtime.on(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
|
||||
realtime.on(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
|
||||
@@ -509,8 +546,8 @@ onUnmounted(() => {
|
||||
<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 v-if="attachmentHref" class="mt-12">
|
||||
<el-link type="primary" :href="attachmentHref" target="_blank">查看附件</el-link>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
|
||||
@@ -304,7 +304,13 @@ async function submitNotify(): Promise<void> {
|
||||
priority: notifyForm.priority,
|
||||
});
|
||||
|
||||
if (result.targetCount === 0) {
|
||||
// 先看服务端给的"为什么没送":教室选了「只在 ClassHelper 客户端显示」时也是
|
||||
// targetCount=0,但原因与"没有接入设备"完全不同,提示错了会把老师引去查设备。
|
||||
if (result.skipped === 'channel-client') {
|
||||
ElMessage.warning(
|
||||
'提醒已保存到通知中心,但该班教室已设置为「只在 ClassHelper 客户端显示」,本次未推送到 ClassIsland',
|
||||
);
|
||||
} else if (result.targetCount === 0) {
|
||||
ElMessage.warning('提醒已保存,但该班级还没有已接入的 ClassIsland 设备');
|
||||
} else {
|
||||
ElMessage.success(`已下发到 ${result.delivered}/${result.targetCount} 台 ClassIsland 设备`);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
|
||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||
import {
|
||||
CALL_QUICK_PHRASES,
|
||||
@@ -21,6 +22,7 @@ import { useRealtimeStore } from '@/stores/realtime';
|
||||
import UrgentClassWarning from '@/components/UrgentClassWarning.vue';
|
||||
|
||||
const realtime = useRealtimeStore();
|
||||
const route = useRoute();
|
||||
|
||||
const loading = ref(false);
|
||||
const classes = ref<ClassDto[]>([]);
|
||||
@@ -290,8 +292,10 @@ async function doPublish(classIds: string[], confirmDuringClass: boolean): Promi
|
||||
reportPublishResult(successCount, reported);
|
||||
|
||||
if (successCount > 0) {
|
||||
// 有班级发布成功:关掉弹窗并刷新列表(仍需二次确认时保留弹窗内的数据)
|
||||
formVisible.value = !pendingConfirm;
|
||||
// 有班级发布成功:关掉弹窗并刷新列表;只有"还有班级等着二次确认"时才保留弹窗内的数据。
|
||||
// 注意这里是 pendingConfirm 而不是 !pendingConfirm —— 写成取反会让弹窗在**发布成功后仍然开着**,
|
||||
// 老师以为没发出去再点一次「立即发布」,同一批班级就重复收到通知(还会重复广播与推 ClassIsland)。
|
||||
formVisible.value = pendingConfirm;
|
||||
await loadNotifications();
|
||||
}
|
||||
}
|
||||
@@ -343,10 +347,29 @@ function onNotificationEvent(): void {
|
||||
void loadNotifications();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从仪表盘点「最新通知」跳过来时带的 `?highlight=<通知 id>`:
|
||||
* 高亮那一行并滚动过去。之前这个参数没有任何人消费,
|
||||
* 用户点完只是跳到通知列表,看不出去的是哪一条。
|
||||
*/
|
||||
const highlightedId = computed(() => {
|
||||
const value = route.query.highlight;
|
||||
return typeof value === 'string' ? value : '';
|
||||
});
|
||||
|
||||
function rowClassName({ row }: { row: NotificationDto }): string {
|
||||
return row.id === highlightedId.value ? 'row-highlight' : '';
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadClasses();
|
||||
await loadNotifications();
|
||||
realtime.on(SOCKET_EVENTS.notificationNew, onNotificationEvent);
|
||||
// 列表渲染完再滚动,否则表格行还没生成
|
||||
if (highlightedId.value) {
|
||||
await nextTick();
|
||||
document.querySelector('.row-highlight')?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -399,7 +422,12 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="table-card">
|
||||
<el-table v-loading="loading" :data="notifications" empty-text="暂无通知">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="notifications"
|
||||
empty-text="暂无通知"
|
||||
:row-class-name="rowClassName"
|
||||
>
|
||||
<el-table-column label="优先级" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
@@ -560,3 +588,10 @@ onUnmounted(() => {
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 从仪表盘点「最新通知」跳转过来的定位高亮(?highlight=<通知 id>) */
|
||||
:deep(.row-highlight) td {
|
||||
background: var(--el-color-primary-light-9) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -127,7 +127,15 @@ async function resolveCourseId(): Promise<string | null> {
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表/周视图请求的序号:快速的"切班级/切周次"会让多个请求同时在途,
|
||||
* 先发出的慢响应若后返回就会覆盖掉新数据(表现为"显示 A 班的课表、按钮却按 B 班权限")。
|
||||
* 因此每次请求领一个号,回来时不是最新一次就直接丢弃。
|
||||
*/
|
||||
let schedulesRequestId = 0;
|
||||
|
||||
async function loadSchedules(): Promise<void> {
|
||||
const requestId = ++schedulesRequestId;
|
||||
if (!query.classId) {
|
||||
grid.value = null;
|
||||
rawSchedules.value = [];
|
||||
@@ -139,10 +147,11 @@ async function loadSchedules(): Promise<void> {
|
||||
scheduleApi.grid({ classId: query.classId, week: query.week }),
|
||||
scheduleApi.list({ classId: query.classId, week: query.week }),
|
||||
]);
|
||||
if (requestId !== schedulesRequestId) return;
|
||||
grid.value = weekGrid;
|
||||
rawSchedules.value = list;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (requestId === schedulesRequestId) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user