Files
ClassHelper/packages/web-admin/src/views/HomeworksView.vue
T
LoveChengke 11008928a1 fix(island,homework,schedule,web): 16 项反馈修复(联合通知点不开 / 设置不生效 / 红点位置 / 看板当天且铺满 / 单双周与周数可调 / Web 手机端与批量通知等)
一、灵动岛
- ①「紧急通知后来了新作业,收起再点开不了新作业」根因有二,都已修:
  (a) active 仍是紧急那条,点击只会反复打开它 —— 现在点击胶囊优先打开**最新**那条未处理消息(队列里更晚的);
  (b) 上课时段 syncWindow 会把普通通知立刻隐藏(表现为点了没反应)—— 新增 explicitShowId,
      "用户主动点开"的那条守卫放行(岛完全隐藏时仍严格不打扰)。
  冒烟新增「紧急通知后来了新作业:收起再点击打开的是新作业(联合通知回归)」。
- ② 设置实时预览/保存:补「设置页真实链路」断言(window.desktop.islandSetAppearance → IPC → 主进程 →
  渲染进程 CSS 变量 + 窗口尺寸),并断言"岛显示中改尺寸立即生效"(实测均通过)。
- ③ 未读红点移到「通知」图标右上角(此前在文字后面),并加 DOM 几何断言(dx>0 且 dy<0,
  没有未读时自动造一条未读通知再断言、断言后删除)。
- 冒烟稳定性:新增 island.setBlurCollapseEnabled()(默认开),测试期间关掉"失焦收起",
  避免系统抢焦点导致展开态被收回的随机失败;专门验证失焦的用例仍临时打开。

二、作业看板(学生端)
- ⑥ 只看当天:新增「只看今天」开关(默认开),按 createdAt 的本地日期过滤,昨天的过期作业不再混入。
- ⑧ 完整显示:卡片同时渲染标题 + 作业要求全文(列表模式也加「作业要求」列)。
- ⑨ 铺满不滚动:看板外层高度固定 + overflow hidden,按内容/可用高度算缩放系数用 transform: scale 适配
  (工具栏显示当前缩放比例),页面本身不出现滚动条。
- ⑦ 全屏看板标题栏用 #header 插槽:标题左对齐、当天日期时间绝对居中(受「显示时间」开关控制,每分钟刷新)。

三、课表与周数
- ⑤ 学期周数可调:新增 Class.termWeeks(默认 20,迁移 + 维护脚本),班主任在班级管理里改;
  GET /api/dashboard/term?classId= 返回该班 maxWeek,课表周次下拉与新建课表的默认 weekEnd 都按它来
  (不再固定 30 周)。
- ⑩ 课表管理只保留「导入 ClassIsland 课程表」入口(时间表由课程表 JSON 的 TimeLayouts 一并带入),
  「导入时间配置」按钮下线;ui-smoke 第 7.7 段改为断言新弹窗(解析 4 行 + 单双周标签 + 旧入口已下线)。

四、以班级为单位(⑬)与 Web 端
- 个人学生账号登录停用:POST /api/auth/login 对 role=STUDENT 返回 403(改用班级码 + 班级密码);
  学生行仍作为个人数据记录键由班级会话代全班读写;e2e 全面切换到班级会话并新增该断言。
- ④ 作业科目过少:新增共享 SUBJECT_CATALOG(16 个常见科目)+ 种子课程扩到全科目;
  Web 作业发布弹窗可选「其他科目(新建)」,提交前自动建课(同名复用)。
- ⑪ 通知支持多班批量发布(多选 + 逐班发布 + 汇总结果 + 二次确认覆盖所有选中班);
  ⑫ 单班只显示已读情况、跨班才显示已读率;⑭ 手机端课表可横向滚动看全 7 天;
  ⑮ 手机端已连接不再显示孤立绿点(仅未连接时显示文字标签);⑯ 手机端「叫人/发布通知」按钮同宽左对齐;
  ⑬ Web 学生管理去掉「用户名」列(新建自动生成学号式用户名,导入仍支持显式指定)。

验证:verify:e2e 149/149、verify:web 20/20、verify:desktop 75/75(复跑 68/68,打包便携版 75/75 packaged=true)、
typecheck / lint / format 全绿;服务端与 Web 已重新打包并部署到本机安装目录。
2026-09-13 08:39:11 +08:00

560 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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,
SUBJECT_CATALOG,
formatDate,
truncate,
type ClassDto,
type CourseDto,
type HomeworkDto,
type HomeworkSubmissionsDto,
} from '@classhelper/shared';
import { classApi, courseApi, homeworkApi } from '@/api';
import { useRealtimeStore } from '@/stores/realtime';
import { useResponsive } from '@/composables/useResponsive';
const realtime = useRealtimeStore();
const { isMobile } = useResponsive();
/** 小屏下详情描述改为单列,避免文字被压成竖排 */
const detailColumns = computed(() => (isMobile.value ? 1 : 2));
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) };
});
/* ------------------------------------------------------------ 未交名单(勾选谁没交) */
const submissionsVisible = ref(false);
const submissionsLoading = ref(false);
const submissionsSaving = ref(false);
const submissionStudents = ref<HomeworkSubmissionsDto['students']>([]);
/** 勾选 = 未交(保存后其余学生一律标记为已交) */
const notSubmittedIds = ref<string[]>([]);
const notSubmittedNames = computed(() =>
submissionStudents.value.filter((item) => notSubmittedIds.value.includes(item.userId)),
);
/** 打开详情时顺带读一次未交名单(教师/管理员都有权限) */
async function loadSubmissions(homeworkId: string): Promise<void> {
submissionsLoading.value = true;
try {
const result = await homeworkApi.submissions(homeworkId);
submissionStudents.value = result.students;
notSubmittedIds.value = result.notSubmitted.map((item) => item.userId);
} catch {
submissionStudents.value = [];
notSubmittedIds.value = [];
} finally {
submissionsLoading.value = false;
}
}
async function openSubmissions(): Promise<void> {
if (!current.value) return;
if (submissionStudents.value.length === 0) await loadSubmissions(current.value.id);
submissionsVisible.value = true;
}
async function saveSubmissions(): Promise<void> {
if (!current.value) return;
submissionsSaving.value = true;
try {
const result = await homeworkApi.saveSubmissions(current.value.id, notSubmittedIds.value);
submissionStudents.value = result.students;
notSubmittedIds.value = result.notSubmitted.map((item) => item.userId);
if (current.value) current.value.completedCount = result.completedCount;
ElMessage.success(`未交名单已保存(未交 ${result.notSubmitted.length} / ${result.total} 人)`);
submissionsVisible.value = false;
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存未交名单失败');
} finally {
submissionsSaving.value = false;
}
}
function markAllSubmitted(): void {
notSubmittedIds.value = [];
}
function markAllNotSubmitted(): void {
notSubmittedIds.value = submissionStudents.value.map((item) => item.userId);
}
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: '',
});
/**
* 发布弹窗里的「其他科目(新建)」选项前缀。
* 下拉只有课程 id 一个 v-model,新建科目用 `subject:语文` 这种带前缀的伪值表达,
* 提交时再解析成「复用同名课程 / 新建课程」。
*/
const NEW_SUBJECT_PREFIX = 'subject:';
/** 弹窗里所选班级的课程(与页面筛选用的 courses 分开,避免互相污染) */
const formCourses = ref<CourseDto[]>([]);
/** 课程列表加载中:加载完成前不展示「其他科目(新建)」,避免把该班已有科目误当成新科目 */
const formCoursesLoading = ref(false);
/** 该班还没有的科目(来自 shared 的 SUBJECT_CATALOG) */
const newSubjectOptions = computed(() => {
const existing = new Set(formCourses.value.map((item) => item.name));
return SUBJECT_CATALOG.filter((name) => !existing.has(name));
});
async function loadFormCourses(): Promise<void> {
if (!form.classId) {
formCourses.value = [];
return;
}
formCoursesLoading.value = true;
try {
formCourses.value = await courseApi.list(form.classId);
} catch {
formCourses.value = [];
} finally {
formCoursesLoading.value = false;
}
}
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 = '';
formVisible.value = true;
void loadFormCourses();
}
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 ?? '';
formVisible.value = true;
void loadFormCourses();
}
/** 弹窗里改班级:课程下拉要跟着换成该班的课程 */
async function onFormClassChange(): Promise<void> {
form.courseId = '';
await loadFormCourses();
}
/**
* 解析课程下拉的取值:
* - 已有课程 id → 直接用;
* - `subject:语文`(其他科目新建)→ 该班已有同名课程则复用,否则先 `courseApi.create` 建课再返回新 id;
* - 空 → 不关联课程。
*/
async function resolveCourseId(): Promise<string | null> {
const value = form.courseId;
if (!value) return null;
if (!value.startsWith(NEW_SUBJECT_PREFIX)) return value;
const name = value.slice(NEW_SUBJECT_PREFIX.length);
const reuse =
formCourses.value.find((item) => item.name === name) ??
(await courseApi.list(form.classId).catch(() => [])).find((item) => item.name === name);
if (reuse) return reuse.id;
try {
const created = await courseApi.create({ classId: form.classId, name });
formCourses.value = [...formCourses.value, created];
return created.id;
} catch (error) {
// 新建课程需要"管理员或本班班主任"权限(与课表管理一致),科任老师会被 403 拦截
ElMessage.error(
`新建科目「${name}」失败:${
error instanceof Error ? error.message : '仅班主任/管理员可以在班级下新建课程'
},可改用已有课程或直接留空发布`,
);
throw error;
}
}
async function submitForm(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
let courseId: string | null;
try {
courseId = await resolveCourseId();
} catch {
// 建课失败的提示已在 resolveCourseId 里给出,这里保持弹窗打开让老师改选
return;
}
const payload = {
classId: form.classId,
courseId,
title: form.title.trim(),
content: form.content,
attachmentUrl: form.attachmentUrl.trim() || 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 }));
void loadSubmissions(row.id);
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" class="table-card">
<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="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)"
@change="onFormClassChange"
>
<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
filterable
:loading="formCoursesLoading"
style="width: 100%"
>
<el-option-group v-if="formCourses.length" label="已有课程">
<el-option v-for="item in formCourses" :key="item.id" :label="item.name" :value="item.id" />
</el-option-group>
<el-option-group v-if="!formCoursesLoading && newSubjectOptions.length" label="其他科目(新建)">
<el-option
v-for="name in newSubjectOptions"
:key="`new-${name}`"
:label="name"
:value="`${NEW_SUBJECT_PREFIX}${name}`"
/>
</el-option-group>
</el-select>
<div class="form-hint">
{{
formCoursesLoading
? '正在加载该班课程…'
: '该班还没有的科目在「其他科目(新建)」里,选中后会自动建课再发布(需班主任/管理员)'
}}
</div>
</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>
<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="detailColumns" 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.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-item label="未交名单" :span="2">
<div v-loading="submissionsLoading" class="submission-summary">
<template v-if="submissionStudents.length === 0">该班还没有学生账号</template>
<template v-else-if="notSubmittedNames.length === 0">
<el-tag type="success" size="small" effect="light">全部已交</el-tag>
</template>
<template v-else>
<el-tag
v-for="student in notSubmittedNames"
:key="student.userId"
type="warning"
size="small"
effect="light"
class="submission-tag"
>
{{ student.name }}
</el-tag>
</template>
<el-button size="small" type="primary" plain @click="openSubmissions">勾选未交</el-button>
</div>
</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>
<!-- 未交名单:勾选谁没交,其余学生自动标记为已交 -->
<el-dialog v-model="submissionsVisible" title="未交名单" width="520px">
<p class="text-muted" style="margin-top: 0">
勾选
<strong>没交作业</strong>
的同学,保存后其余同学会自动标记为已完成。
</p>
<div class="submission-toolbar">
<el-button size="small" @click="markAllSubmitted">全部已交</el-button>
<el-button size="small" @click="markAllNotSubmitted">全部未交</el-button>
<span class="text-muted">未交 {{ notSubmittedIds.length }} / {{ submissionStudents.length }} 人</span>
</div>
<div v-loading="submissionsLoading" class="submission-list">
<el-empty
v-if="submissionStudents.length === 0 && !submissionsLoading"
description="该班还没有学生账号"
/>
<el-checkbox-group v-model="notSubmittedIds">
<el-checkbox v-for="student in submissionStudents" :key="student.userId" :value="student.userId">
{{ student.name }}({{ student.username }})
</el-checkbox>
</el-checkbox-group>
</div>
<template #footer>
<el-button @click="submissionsVisible = false">取消</el-button>
<el-button type="primary" :loading="submissionsSaving" @click="saveSubmissions">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.text-danger {
color: #f56c6c;
}
.form-hint {
width: 100%;
font-size: 12px;
line-height: 1.5;
color: #909399;
margin-top: 4px;
}
.content-block {
white-space: pre-wrap;
line-height: 1.7;
background: #fafafa;
border-radius: 12px;
padding: 12px;
margin: 0;
}
/* ---------------------------------------------------------------- 未交名单 */
.submission-summary {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.submission-tag {
margin: 0;
}
.submission-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.submission-list {
max-height: 46vh;
overflow-y: auto;
border: 1px solid #eef0f5;
border-radius: 12px;
padding: 12px;
}
.submission-list :deep(.el-checkbox-group) {
display: flex;
flex-direction: column;
gap: 4px;
}
</style>