Files
ClassHelper/packages/desktop-client/src/renderer/views/HomeworkView.vue
T
LoveChengke 3880eee08e feat(desktop,island,shared): 灵动岛支持多条通知竖排列表 + 触摸模式 + 收回动画,产物加凭据门禁
灵动岛列表:多条通知时展开为竖排列表,默认显示 3 条,点「展开更多」铺到屏幕容纳
上限,放不下的收进「更多请前往应用内操作」。卡片高度由主进程与渲染进程各算一次
同一个数 —— 度量表 ISLAND_LIST_METRICS 与排序权重 PRIORITY_RANK 放在 shared,
窗口包围盒随之长高(不够高会把底部按钮裁掉)。

触摸模式:触摸屏上手指既不产生 mousemove、也不移动系统光标,原先两条命中链路都会
让窗口永远停在穿透态、手指点不开岛。现按 navigator.maxTouchPoints 上报后,主进程把
窗口贴合岛体并始终接收输入;非触摸屏机器完全不走这条路。

收回动画:main 先发 island:closing,渲染进程形变收敛后回 closing-done 才淡出,
不再靠猜时长(rAF 被节流时会把卡片缩到一半窗口就没了)。

安全门禁:新增 scripts/check-bundle-secrets.mjs 并接进 build:desktop ——
dist 产物会被打进 app.asar 发到每台学生机、且可解包,冒烟凭据一律改由环境变量注入,
产物里出现种子口令即构建失败。
2026-10-02 00:25:10 +08:00

1190 lines
40 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, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import {
HOMEWORK_PHRASE_DEFAULTS,
SOCKET_EVENTS,
dayKeyLocal,
formatDate,
isDayKey,
shiftDayKey,
type CourseDto,
type HomeworkDto,
type HomeworkSubmissionDto,
} from '@classhelper/shared';
import { courseApi, homeworkApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js';
import { markHomeworkCreatedLocally } from '../island/bridge.js';
import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useRealtimeStore } from '../stores/realtime.js';
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const auth = useAuthStore();
const realtime = useRealtimeStore();
/** 看板/列表模式与看板外观(持久化到客户端配置,重启仍生效) */
const DEFAULT_BOARD = {
mode: 'board' as 'board' | 'list',
showTime: false,
fontSize: 15,
};
const loading = ref(false);
const homeworks = ref<HomeworkDto[]>([]);
const fromCache = ref(false);
const updatedAt = ref<number | null>(null);
const detailVisible = ref(false);
const current = ref<HomeworkDto | null>(null);
const viewMode = ref<'board' | 'list'>(DEFAULT_BOARD.mode);
const boardShowTime = ref(DEFAULT_BOARD.showTime);
const boardFontSize = ref(DEFAULT_BOARD.fontSize);
const savingBoard = ref(false);
/** 全屏放大的科目(null = 未打开) */
const fullscreenCourse = ref<string | null>(null);
const fullscreenBoard = ref(false);
/* ------------------------------------------------------------ 按天查看 */
/**
* 当前查看的日期(YYYY-MM-DD,本地):默认今天,可用日期选择器或前后一天切换。
* 支持 `?date=YYYY-MM-DD` 深链(冒烟测试与"直接打开某天的作业"都靠它)。
*/
const selectedDate = ref(
typeof route.query.date === 'string' && isDayKey(route.query.date)
? route.query.date
: dayKeyLocal(new Date()),
);
/** 有作业的日期(日期选择器高亮):来自 GET /homeworks/days */
const dayMarks = ref<Map<string, number>>(new Map());
/** 录入作业的科目下拉 */
const courses = ref<CourseDto[]>([]);
/** 选中的是否就是今天(标题与按钮文案用) */
const isToday = computed(() => selectedDate.value === dayKeyLocal(now.value));
/** 日期选择器:给有作业的日期加高亮类 */
function dayCellClass(date: Date): string {
return dayMarks.value.has(dayKeyLocal(date)) ? 'day-has-homework' : '';
}
async function selectDate(value: string | null): Promise<void> {
if (!value || !isDayKey(value)) return;
selectedDate.value = value;
// 同步到地址栏:刷新/分享/冒烟测试都能落在同一天
await router.replace({ path: '/homeworks', query: { date: value } });
await loadHomework();
}
// 深链 ?date= 不止在首次挂载生效:已在作业页时(看板/列表之间切换、冒烟直接推 query),
// 路由 query 变了也要跟着切换日期 —— 组件被复用,没有这条监听就停在原日期。
watch(
() => route.query.date,
(value) => {
if (typeof value !== 'string' || !isDayKey(value) || value === selectedDate.value) return;
selectedDate.value = value;
void loadHomework();
},
);
/** 前后一天(教室电脑上不用打开日历也能翻) */
async function shiftDate(delta: number): Promise<void> {
await selectDate(shiftDayKey(selectedDate.value, delta));
}
/** 每分钟刷新一次"现在",用于标题栏时钟与"今天"的判定 */
const now = ref(new Date());
let clockTimer: ReturnType<typeof setInterval> | null = null;
const todayText = computed(
() =>
`${now.value.getFullYear()} 年 ${now.value.getMonth() + 1} 月 ${now.value.getDate()} 日 ` +
`${['周日', '周一', '周二', '周三', '周四', '周五', '周六'][now.value.getDay()]} ` +
`${String(now.value.getHours()).padStart(2, '0')}:${String(now.value.getMinutes()).padStart(2, '0')}`,
);
/**
* 列表内容 = 服务端按天返回的结果(`?date=`),因此这里不再做二次过滤。
*
* 注意:"已完成"是老师端/通知中心的概念,客户端作业模块只做**展示 + 录入**,
* 因此这里没有任何完成状态相关的筛选与统计。
*/
const filtered = computed(() => homeworks.value);
const selectedDayText = computed(() => {
if (isToday.value) return '今天';
const [year, month, day] = selectedDate.value.split('-');
return `${Number(year)} 年 ${Number(month)} 月 ${Number(day)} 日`;
});
/**
* 看板分组:按科目聚合成卡片(未关联课程的归到「其他」),
* 科目内部按发布时间倒序,卡片顺序按学科常见顺序排列。
*/
const COURSE_ORDER = [
'语文',
'数学',
'英语',
'物理',
'化学',
'生物',
'政治',
'历史',
'地理',
'体育',
'音乐',
'美术',
'信息技术',
'通用技术',
];
interface BoardColumn {
course: string;
items: HomeworkDto[];
}
const boardColumns = computed<BoardColumn[]>(() => {
const groups = new Map<string, HomeworkDto[]>();
for (const item of filtered.value) {
const key = item.course?.name ?? '其他';
const list = groups.get(key) ?? [];
list.push(item);
groups.set(key, list);
}
const columns: BoardColumn[] = [...groups.entries()].map(([course, items]) => ({
course,
items: [...items].sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)),
}));
const rank = (name: string): number => {
const index = COURSE_ORDER.findIndex((item) => name.includes(item));
return index < 0 ? COURSE_ORDER.length : index;
};
return columns.sort((a, b) => rank(a.course) - rank(b.course) || a.course.localeCompare(b.course));
});
/** 看板字号:主看板用设定值,全屏放大 1.7 倍 */
const boardStyle = computed(() => ({ '--board-font': `${boardFontSize.value}px` }));
const fullscreenStyle = computed(() => ({ '--board-font': `${Math.round(boardFontSize.value * 1.7)}px` }));
const fullscreenItems = computed(() =>
fullscreenCourse.value
? (boardColumns.value.find((column) => column.course === fullscreenCourse.value)?.items ?? [])
: [],
);
/* ------------------------------------------------------------ 看板自适应缩放
*
* 需求:作业必须**全部完整显示**(标题 + 作业要求),且**不需要上下滚动**。
* 做法:先把卡片按视口宽度铺成网格,再按"内容高度 / 可用高度"算一个缩放系数,
* 用 transform: scale() 整体缩到刚好铺满(字号仍由用户滑块决定,缩放只是兜底适配)。
*/
const boardHostRef = ref<HTMLElement | null>(null);
const boardInnerRef = ref<HTMLElement | null>(null);
const boardScale = ref(1);
const fullscreenHostRef = ref<HTMLElement | null>(null);
const fullscreenInnerRef = ref<HTMLElement | null>(null);
const fullscreenScale = ref(1);
/** 全屏单个科目视图(列表型):与整块看板分开持有 ref,避免两个全屏弹窗互相覆盖 */
const courseHostRef = ref<HTMLElement | null>(null);
const courseInnerRef = ref<HTMLElement | null>(null);
const fullscreenCourseScale = ref(1);
/** 全屏看板的列数(由"铺满屏幕"算法动态算出;见 computeColumns) */
const fullscreenColumns = ref(0);
function computeScale(host: HTMLElement | null, inner: HTMLElement | null, minScale = 0.45): number {
if (!host || !inner) return 1;
const available = host.clientHeight;
// 先按 1 倍量一次内容高度(transform 不影响 offsetHeight)
const content = inner.offsetHeight;
if (available <= 0 || content <= 0) return 1;
const scale = Math.min(1, available / content);
return Math.max(minScale, Math.round(scale * 1000) / 1000);
}
/**
* 全屏看板列数:在"可用宽 × 可用高"里枚举 1..count 列,
* 选出"单卡面积最大"的方案(面积越大越铺满屏幕),并用宽高比惩罚避免出现
* 又扁又长的卡片。这样无论 2 个科目还是 12 个科目,全屏都是一屏铺满。
*/
function computeColumns(count: number, host: HTMLElement | null): number {
if (count <= 1) return 1;
if (!host) return count;
const width = host.clientWidth || 0;
const height = host.clientHeight || 0;
if (width <= 0 || height <= 0) return Math.min(3, count);
const gap = Math.max(8, boardFontSize.value * 1.53);
const wantRatio = 1.15;
let bestCols = 1;
let bestScore = -Infinity;
for (let cols = 1; cols <= count; cols += 1) {
const rows = Math.ceil(count / cols);
const cardWidth = (width - gap * (cols - 1)) / cols;
const cardHeight = (height - gap * (rows - 1)) / rows;
if (cardWidth <= 0 || cardHeight <= 0) continue;
const ratio = cardWidth / cardHeight;
const ratioPenalty = Math.max(ratio / wantRatio, wantRatio / ratio);
const score = (cardWidth * cardHeight) / ratioPenalty;
if (score > bestScore) {
bestScore = score;
bestCols = cols;
}
}
return bestCols;
}
function fitBoards(): void {
void nextTick(() => {
boardScale.value = computeScale(boardHostRef.value, boardInnerRef.value);
fullscreenScale.value = computeScale(fullscreenHostRef.value, fullscreenInnerRef.value);
fullscreenCourseScale.value = computeScale(courseHostRef.value, courseInnerRef.value);
});
}
/**
* 全屏看板适配:弹窗是异步挂载的(有过渡),刚打开时容器尺寸可能是 0,
* 因此这里轮询等待布局就绪,再算列数与缩放,保证"一屏铺满、不用滚动"。
* 列数改变会重排,所以排完再量一次缩放系数。
*/
async function fitFullscreen(kind: 'board' | 'course' = 'board'): Promise<void> {
for (let attempt = 0; attempt < 20; attempt += 1) {
const host = kind === 'board' ? fullscreenHostRef.value : courseHostRef.value;
const inner = kind === 'board' ? fullscreenInnerRef.value : courseInnerRef.value;
if (host && inner && host.clientHeight > 60 && host.clientWidth > 60) {
if (kind === 'board') {
const count = inner.children.length || boardColumns.value.length || 1;
fullscreenColumns.value = computeColumns(count, host);
await nextTick();
fullscreenScale.value = computeScale(host, fullscreenInnerRef.value, 0.3);
} else {
fullscreenCourseScale.value = computeScale(host, inner, 0.3);
}
return;
}
await new Promise((resolve) => setTimeout(resolve, 60));
}
}
/** 视口/窗口尺寸变化:全屏看板重新自适应 */
function onResize(): void {
fitBoards();
if (fullscreenBoard.value) void fitFullscreen('board');
if (fullscreenCourse.value) void fitFullscreen('course');
}
/* ------------------------------------------------------------ 数据加载 */
async function loadHomework(): Promise<void> {
loading.value = true;
try {
const classId = auth.classId ?? undefined;
const day = selectedDate.value;
// 缓存按"班级 + 日期"分键:翻到别的天不会串数据,离线也能看回看过的那些天
const result = await fetchWithCache<HomeworkDto[]>(
'homeworks',
`${classId ?? 'self'}@${day}`,
() => homeworkApi.list(classId ? { classId, date: day } : { date: day }),
[],
);
homeworks.value = result.data;
fromCache.value = result.fromCache;
updatedAt.value = result.updatedAt;
if (!result.fromCache) appStore.markSynced();
void loadDayMarks(classId);
if (current.value) {
current.value = homeworks.value.find((item) => item.id === current.value?.id) ?? current.value;
}
fitBoards();
} finally {
loading.value = false;
}
}
/**
* 拉取"哪些天有作业"(日期选择器高亮)。
*
* 范围取当前查看日期前后各 45 天:教室电脑上翻月份也能看到有作业的日子。
*/
async function loadDayMarks(classId: string | undefined): Promise<void> {
try {
const from = shiftDayKey(selectedDate.value, -45);
const to = shiftDayKey(selectedDate.value, 45);
const result = await homeworkApi.days({ classId, from, to });
dayMarks.value = new Map(result.days.map((item) => [item.date, item.count]));
} catch {
// 离线/失败时保留上一次的高亮,不打断展示
}
}
/** 科目下拉(录入作业时可选) */
async function loadCourses(): Promise<void> {
if (!auth.classId) return;
courses.value = await courseApi.list(auth.classId).catch(() => []);
}
/** 读取看板偏好(主进程配置为单一事实来源) */
async function loadBoardSettings(): Promise<void> {
const config = await window.desktop?.getConfig?.();
const saved = config?.homeworkBoard;
if (!saved) return;
if (saved.mode === 'board' || saved.mode === 'list') viewMode.value = saved.mode;
if (typeof saved.showTime === 'boolean') boardShowTime.value = saved.showTime;
if (typeof saved.fontSize === 'number' && Number.isFinite(saved.fontSize)) {
boardFontSize.value = Math.min(28, Math.max(11, Math.round(saved.fontSize)));
}
}
async function saveBoardSettings(): Promise<void> {
if (!window.desktop?.saveConfig) return;
savingBoard.value = true;
try {
await window.desktop.saveConfig({
homeworkBoard: {
mode: viewMode.value,
showTime: boardShowTime.value,
fontSize: boardFontSize.value,
},
});
fitBoards();
} finally {
savingBoard.value = false;
}
}
function openDetail(item: HomeworkDto): void {
current.value = item;
detailVisible.value = true;
}
function openCourseFullscreen(column: BoardColumn): void {
fullscreenCourse.value = column.course;
fitBoards();
}
/** 覆盖全屏:整块看板放大(点击标题栏「全屏」按钮) */
function openBoardFullscreen(): void {
fullscreenCourse.value = null;
fullscreenBoard.value = true;
fitBoards();
}
/* ------------------------------------------------------------ 录入作业(教室机器) */
const createVisible = ref(false);
const createSaving = ref(false);
/** 快捷短语:来自客户端本地配置(设置 → 作业录入可增删) */
const phrases = ref<string[]>([...HOMEWORK_PHRASE_DEFAULTS]);
/** 短语插入到哪个输入框:跟着最后一次聚焦的字段走 */
const lastFocused = ref<'title' | 'content'>('content');
const createForm = ref({ courseId: '', title: '', content: '' });
async function loadPhrases(): Promise<void> {
const config = await window.desktop?.getConfig?.();
if (config?.homeworkPhrases) phrases.value = [...config.homeworkPhrases];
}
function openCreate(): void {
createForm.value = { courseId: '', title: '', content: '' };
lastFocused.value = 'content';
createVisible.value = true;
}
/** 点快捷短语:把词追加到当前字段末尾(空格分隔,避免连成一坨) */
function insertPhrase(phrase: string): void {
const field = lastFocused.value;
const current = createForm.value[field];
const next = current.trim() ? `${current.trimEnd()} ${phrase}` : phrase;
createForm.value = { ...createForm.value, [field]: next };
}
async function submitCreate(): Promise<void> {
const classId = auth.classId;
if (!classId) {
ElMessage.error('当前会话没有班级信息,无法录入作业');
return;
}
if (appStore.offline) {
ElMessage.warning('离线状态下无法录入作业,请恢复网络后重试');
return;
}
const title = createForm.value.title.trim();
const content = createForm.value.content.trim();
if (!title) {
ElMessage.warning('请填写作业标题');
return;
}
if (!content) {
ElMessage.warning('请填写作业要求');
return;
}
createSaving.value = true;
try {
// 先按内容登记"This 是本机录的":服务端是**先广播 homework:new、后回响应**的,
// 等响应拿到 id 再登记就晚了 —— 实时事件可能已经到了,岛已经弹出来了。
const draft = { classId, title, content, assignDate: selectedDate.value };
markHomeworkCreatedLocally(draft);
const created = await homeworkApi.create({
classId,
courseId: createForm.value.courseId || null,
title,
content,
assignDate: selectedDate.value,
});
// 回执里带上 id 再记一次(覆盖"广播晚于响应"的另一半)
if (created?.id) markHomeworkCreatedLocally({ ...draft, id: created.id });
ElMessage.success(`已录入到 ${selectedDate.value}`);
createVisible.value = false;
await loadHomework();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '录入失败');
} finally {
createSaving.value = false;
}
}
/* ------------------------------------------------------------ 未交名单(班级设备) */
const submissionsVisible = ref(false);
const submissionsLoading = ref(false);
const submissionsSaving = ref(false);
const submissionsTarget = ref<HomeworkDto | null>(null);
const submissions = ref<HomeworkSubmissionDto[]>([]);
/** 勾选 = 未交(保存后其余学生一律标记为已交) */
const notSubmittedIds = ref<string[]>([]);
const notSubmittedCount = computed(() => notSubmittedIds.value.length);
async function openSubmissions(item: HomeworkDto): Promise<void> {
submissionsTarget.value = item;
submissionsVisible.value = true;
submissionsLoading.value = true;
try {
const result = await homeworkApi.submissions(item.id);
submissions.value = result.students;
// 默认沿用服务端已有的未交数据(老师只需改动的部分)
notSubmittedIds.value = result.notSubmitted.map((student) => student.userId);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '读取未交名单失败');
submissions.value = [];
notSubmittedIds.value = [];
} finally {
submissionsLoading.value = false;
}
}
function markAllSubmitted(): void {
notSubmittedIds.value = [];
}
function markAllNotSubmitted(): void {
notSubmittedIds.value = submissions.value.map((student) => student.userId);
}
async function saveSubmissions(): Promise<void> {
const target = submissionsTarget.value;
if (!target) return;
if (appStore.offline) {
ElMessage.warning('离线状态下无法提交,请恢复网络后重试');
return;
}
submissionsSaving.value = true;
try {
const result = await homeworkApi.saveSubmissions(target.id, notSubmittedIds.value);
submissions.value = result.students;
notSubmittedIds.value = result.notSubmitted.map((student) => student.userId);
target.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 onHomeworkEvent(): void {
void loadHomework();
}
/** 附件链接交给系统浏览器打开(模板里无法直接访问 window) */
function openAttachment(url: string | null | undefined): void {
if (!url) return;
if (window.desktop) void window.desktop.openExternal(url);
else window.open(url, '_blank');
}
function onRecovered(): void {
void loadHomework();
}
let resizeObserver: ResizeObserver | null = null;
onMounted(async () => {
await loadBoardSettings();
await loadPhrases();
await loadCourses();
await loadHomework();
clockTimer = setInterval(() => {
now.value = new Date();
}, 30_000);
// 视口变化后重新适配(保证"全部显示在屏幕上"始终成立)
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => fitBoards());
if (boardHostRef.value) resizeObserver.observe(boardHostRef.value);
}
window.addEventListener('resize', onResize);
fitBoards();
realtime.on(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
realtime.on(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
appStore.onServerRecovered(onRecovered);
});
onUnmounted(() => {
if (clockTimer) clearInterval(clockTimer);
resizeObserver?.disconnect();
window.removeEventListener('resize', onResize);
realtime.off(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
realtime.off(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
appStore.offServerRecovered(onRecovered);
void saveBoardSettings();
});
</script>
<template>
<div class="page homework-page">
<div class="page-header">
<div>
<h2 class="page-title">我的作业</h2>
<p class="page-subtitle">
{{ selectedDayText }} · {{ filtered.length }} 份
<el-tag v-if="fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
</p>
</div>
<div class="toolbar">
<el-button-group>
<el-button :icon="'ArrowLeft'" @click="shiftDate(-1)">前一天</el-button>
<el-button :disabled="isToday" @click="selectDate(dayKeyLocal(now))">今天</el-button>
<el-button @click="shiftDate(1)">后一天<el-icon class="ml-4"><ArrowRight /></el-icon></el-button>
</el-button-group>
<el-date-picker
:model-value="selectedDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
class="day-picker"
:cell-class-name="dayCellClass"
@update:model-value="(value: string | null) => selectDate(value)"
/>
<el-radio-group v-model="viewMode" @change="saveBoardSettings">
<el-radio-button value="board">看板</el-radio-button>
<el-radio-button value="list">列表</el-radio-button>
</el-radio-group>
<el-button type="primary" :icon="'Plus'" @click="openCreate">录入作业</el-button>
<el-button :icon="'Refresh'" @click="loadHomework">刷新</el-button>
</div>
</div>
<!-- 看板外观:显示时间 / 字号(即时生效并持久化) -->
<el-card v-if="viewMode === 'board'" shadow="never" class="board-config">
<div class="board-config-row">
<span class="board-config-label">看板设置</span>
<el-switch
v-model="boardShowTime"
active-text="显示时间"
inactive-text="隐藏时间"
@change="saveBoardSettings"
/>
<span class="board-config-label">字号</span>
<el-slider
v-model="boardFontSize"
:min="11"
:max="28"
:step="1"
style="width: 180px"
@change="saveBoardSettings"
/>
<span class="text-muted">{{ boardFontSize }}px</span>
<el-button :icon="'FullScreen'" @click="openBoardFullscreen">全屏看板</el-button>
<span class="text-muted">自适应缩放 {{ Math.round(boardScale * 100) }}%</span>
<span v-if="savingBoard" class="text-muted">保存中…</span>
</div>
</el-card>
<el-card v-loading="loading" shadow="never" class="homework-body">
<el-empty v-if="filtered.length === 0" :description="`${selectedDayText}没有作业`" />
<!-- 看板模式:按科目分卡片,点击卡片放大全屏、点击条目看详情 -->
<div v-else-if="viewMode === 'board'" ref="boardHostRef" class="board-host">
<div ref="boardInnerRef" class="board" :style="{ ...boardStyle, '--board-scale': boardScale }">
<section
v-for="column in boardColumns"
:key="column.course"
class="board-card"
@click="openCourseFullscreen(column)"
>
<header class="board-card-head">
<span class="board-course">{{ column.course }}</span>
<span class="board-badge">{{ column.items.length }}</span>
</header>
<ol class="board-list">
<li
v-for="(item, index) in column.items"
:key="item.id"
class="board-item"
@click.stop="openDetail(item)"
>
<span class="board-index">{{ index + 1 }}.</span>
<span class="board-text">
<span class="board-title">{{ item.title }}</span>
<!-- 需求:作业要完整显示(标题 + 作业要求),不能只显示标题 -->
<span v-if="item.content.trim()" class="board-content">{{ item.content }}</span>
</span>
<span v-if="boardShowTime" class="board-time">{{ formatDate(item.createdAt, true) }}</span>
</li>
</ol>
<footer class="board-card-foot">
<span class="text-muted">共 {{ column.items.length }} 条</span>
<span class="board-zoom">点击放大</span>
</footer>
</section>
</div>
</div>
<!-- 列表模式(默认表格视图) -->
<el-table v-else :data="filtered" @row-click="openDetail">
<el-table-column prop="title" label="作业标题" min-width="200" show-overflow-tooltip />
<el-table-column prop="content" label="作业要求" min-width="240" show-overflow-tooltip />
<el-table-column label="课程" width="110">
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="所属日期" width="120">
<template #default="{ row }">{{ row.assignDate }}</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="130" fixed="right">
<template #default="{ row }">
<el-button v-if="auth.isClassSession" link type="primary" @click.stop="openSubmissions(row)">
未交名单
</el-button>
<el-button v-else link type="primary" @click.stop="openDetail(row)">查看</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 全屏:单个科目放大 -->
<el-dialog
v-model="fullscreenCourse"
fullscreen
:title="`${fullscreenCourse ?? ''} · 作业`"
class="board-fullscreen"
@opened="fitFullscreen('course')"
>
<template #header="{ titleId, titleClass }">
<div class="board-dialog-head">
<span :id="titleId" :class="titleClass">{{ fullscreenCourse ?? '' }} · 作业</span>
<!-- 需求:当天时间显示在标题栏正中间(受"显示时间"开关控制) -->
<span v-if="boardShowTime" class="board-dialog-clock">{{ todayText }}</span>
</div>
</template>
<div ref="courseHostRef" class="board-host board-host-lg">
<ol
ref="courseInnerRef"
class="board-list board-list-lg"
:style="{ ...fullscreenStyle, '--board-scale': fullscreenCourseScale }"
>
<li
v-for="(item, index) in fullscreenItems"
:key="item.id"
class="board-item"
@click="openDetail(item)"
>
<span class="board-index">{{ index + 1 }}.</span>
<span class="board-text">
<span class="board-title">{{ item.title }}</span>
<span v-if="item.content.trim()" class="board-content">{{ item.content }}</span>
</span>
<span v-if="boardShowTime" class="board-time">{{ formatDate(item.createdAt, true) }}</span>
</li>
</ol>
</div>
<el-empty v-if="fullscreenItems.length === 0" :description="`该科目 ${selectedDayText}没有作业`" />
</el-dialog>
<!-- 全屏:整块看板放大 -->
<el-dialog v-model="fullscreenBoard" fullscreen class="board-fullscreen" @opened="fitFullscreen('board')">
<template #header="{ titleId, titleClass }">
<div class="board-dialog-head">
<span :id="titleId" :class="titleClass">今日作业看板</span>
<span v-if="boardShowTime" class="board-dialog-clock">{{ todayText }}</span>
</div>
</template>
<div ref="fullscreenHostRef" class="board-host board-host-lg">
<div
ref="fullscreenInnerRef"
class="board board-lg"
:style="{
...fullscreenStyle,
'--board-scale': fullscreenScale,
'--board-cols': String(fullscreenColumns || 1),
}"
>
<section v-for="column in boardColumns" :key="column.course" class="board-card">
<header class="board-card-head">
<span class="board-course">{{ column.course }}</span>
<span class="board-badge">{{ column.items.length }}</span>
</header>
<ol class="board-list">
<li
v-for="(item, index) in column.items"
:key="item.id"
class="board-item"
@click="openDetail(item)"
>
<span class="board-index">{{ index + 1 }}.</span>
<span class="board-text">
<span class="board-title">{{ item.title }}</span>
<span v-if="item.content.trim()" class="board-content">{{ item.content }}</span>
</span>
<span v-if="boardShowTime" class="board-time">{{ formatDate(item.createdAt, true) }}</span>
</li>
</ol>
</section>
</div>
</div>
</el-dialog>
<!-- 录入作业(教室机器):快捷短语点一下就追加到标题/内容 -->
<el-dialog v-model="createVisible" title="录入作业" width="620px">
<el-form label-width="90px">
<el-form-item label="所属日期">
<el-date-picker
:model-value="selectedDate"
type="date"
value-format="YYYY-MM-DD"
:cell-class-name="dayCellClass"
@update:model-value="(value: string | null) => selectDate(value)"
/>
<span class="text-muted ml-8">默认就是当前正在查看的这一天</span>
</el-form-item>
<el-form-item label="科目">
<el-select v-model="createForm.courseId" clearable placeholder="可不选" style="width: 220px">
<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="createForm.title"
maxlength="120"
show-word-limit
placeholder="例如:第 3 课生字"
@focus="lastFocused = 'title'"
/>
</el-form-item>
<el-form-item label="作业要求">
<el-input
v-model="createForm.content"
type="textarea"
:rows="3"
maxlength="5000"
placeholder="例如:P 12 大本,背诵第 3 段"
@focus="lastFocused = 'content'"
/>
</el-form-item>
<el-form-item v-if="phrases.length > 0" label="快捷短语">
<div class="phrase-row">
<el-tag
v-for="item in phrases"
:key="item"
class="phrase-chip"
type="info"
effect="plain"
@click="insertPhrase(item)"
>
{{ item }}
</el-tag>
<span class="text-muted">点一下追加到「{{ lastFocused === 'title' ? '标题' : '作业要求' }}」</span>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="createSaving" @click="submitCreate">保存作业</el-button>
</template>
</el-dialog>
<!-- 未交名单:勾选谁没交(其余学生一律视为已交) -->
<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">未交 {{ notSubmittedCount }} / {{ submissions.length }} 人</span>
</div>
<div v-loading="submissionsLoading" class="submission-list">
<el-empty v-if="submissions.length === 0 && !submissionsLoading" description="该班还没有学生账号" />
<el-checkbox-group v-model="notSubmittedIds">
<el-checkbox v-for="student in submissions" :key="student.userId" :value="student.userId">
{{ student.name }}
</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>
<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="所属日期">{{ current.assignDate }}</el-descriptions-item>
<el-descriptions-item label="录入时间">
{{ formatDate(current.createdAt, true) }}
</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-button link type="primary" @click="openAttachment(current.attachmentUrl)">
打开附件链接
</el-button>
</div>
<div v-if="auth.isClassSession" class="mt-16">
<el-button type="primary" :disabled="appStore.offline" @click="openSubmissions(current)">
维护未交名单
</el-button>
<span v-if="appStore.offline" class="text-muted" style="margin-left: 10px">离线状态下不可提交</span>
</div>
</template>
</el-drawer>
</div>
</template>
<style scoped>
/* 日期选择器:有作业的日子加一个圆点,翻月份时一眼看出哪天有作业 */
:deep(.day-has-homework .el-date-table-cell__text)::after {
content: '';
position: absolute;
left: 50%;
bottom: 2px;
width: 4px;
height: 4px;
margin-left: -2px;
border-radius: 50%;
background: currentColor;
}
.day-picker {
width: 168px;
}
.phrase-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.phrase-chip {
cursor: pointer;
user-select: none;
}
.text-danger {
color: #f56c6c;
}
.content-block {
white-space: pre-wrap;
line-height: 1.75;
background: #fafafa;
border-radius: 12px;
padding: 12px;
margin: 0;
}
/* 作业页整体不滚动:看板靠自适应缩放铺满,避免"要上下翻页才能看全" */
.homework-page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
box-sizing: border-box;
}
.homework-body {
flex: 1 1 auto;
min-height: 0;
}
.homework-body :deep(.el-card__body) {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ---------------------------------------------------------------- 看板 */
.board-config {
margin-bottom: 12px;
}
.board-config-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.board-config-label {
font-weight: 600;
}
/* 承载区:高度固定,里面靠 scale 适配,绝不出现滚动条 */
.board-host {
flex: 1 1 auto;
min-height: 240px;
overflow: hidden;
display: flex;
align-items: flex-start;
justify-content: center;
}
.board-host-lg {
height: calc(100vh - 150px);
}
.board {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(calc(var(--board-font, 15px) * 15), 1fr));
gap: calc(var(--board-font, 15px) * 0.8);
width: 100%;
transform: scale(var(--board-scale, 1));
transform-origin: top center;
}
/* 全屏看板:列数由脚本按"铺满屏幕"算好后注入(--board-cols),不再靠 auto-fit 猜;
行高保持内容自然高度,交给 transform: scale() 适配,避免卡片被压扁截断内容。 */
.board-lg {
grid-template-columns: repeat(var(--board-cols, 2), minmax(0, 1fr));
gap: calc(var(--board-font, 15px) * 0.9);
align-content: start;
}
.board-card {
display: flex;
flex-direction: column;
background: #1c1c1e;
color: #f5f5f7;
border-radius: 18px;
padding: calc(var(--board-font, 15px) * 0.8) calc(var(--board-font, 15px) * 0.95);
cursor: zoom-in;
transition:
transform 0.16s ease,
box-shadow 0.16s ease;
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.12);
}
.board-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.22);
}
.board-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: calc(var(--board-font, 15px) * 0.45);
}
.board-course {
font-size: calc(var(--board-font, 15px) * 0.95);
font-weight: 600;
color: #ffffff;
}
.board-badge {
min-width: calc(var(--board-font, 15px) * 1.4);
height: calc(var(--board-font, 15px) * 1.4);
border-radius: 999px;
background: #ff9f0a;
color: #1c1c1e;
font-size: calc(var(--board-font, 15px) * 0.72);
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 6px;
}
.board-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: calc(var(--board-font, 15px) * 0.4);
flex: 1;
}
.board-item {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: var(--board-font, 15px);
line-height: 1.5;
border-radius: 10px;
padding: 2px 4px;
cursor: pointer;
}
.board-item:hover {
background: rgba(255, 255, 255, 0.08);
}
.board-index {
color: #8e8e93;
flex: 0 0 auto;
}
.board-text {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.board-title {
font-weight: 600;
word-break: break-word;
}
/* 作业要求:完整展示(不截断),字号略小以保持层级 */
.board-content {
font-size: calc(var(--board-font, 15px) * 0.82);
color: #c7c7cc;
white-space: pre-wrap;
word-break: break-word;
}
.board-item.done .board-title,
.board-item.done .board-content {
text-decoration: line-through;
color: #8e8e93;
}
.board-time {
flex: 0 0 auto;
font-size: calc(var(--board-font, 15px) * 0.72);
color: #8e8e93;
}
.board-done-tag-unused {
flex: 0 0 auto;
font-size: calc(var(--board-font, 15px) * 0.66);
color: #32d74b;
}
.board-card-foot {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: calc(var(--board-font, 15px) * 0.45);
font-size: calc(var(--board-font, 15px) * 0.7);
}
.board-card-foot .text-muted {
color: #8e8e93;
}
.board-zoom {
color: #0a84ff;
}
.board-list-lg {
width: 100%;
transform: scale(var(--board-scale, 1));
transform-origin: top center;
}
.board-list-lg .board-item {
padding: 6px 8px;
}
/* 全屏弹窗标题栏:标题 + 当天时间(时间绝对居中对齐) */
.board-dialog-head {
position: relative;
display: flex;
align-items: center;
justify-content: flex-start;
padding-right: 48px;
}
.board-dialog-clock {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 14px;
font-weight: 500;
color: #6b7280;
white-space: nowrap;
}
.board-fullscreen :deep(.el-dialog__body) {
padding-top: 6px;
height: calc(100vh - 84px);
overflow: hidden;
}
/* 兜底:`class` 由 el-dialog 透传时可能不携带 scoped 属性,这里用全局选择器保证
全屏弹窗内容区一定铺满整屏(否则看板会缩在左上角)。 */
:global(.board-fullscreen .el-dialog__body) {
padding-top: 6px;
height: calc(100vh - 84px);
overflow: hidden;
}
.submission-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.submission-list {
max-height: 46vh;
overflow-y: auto;
border: 1px solid #ebeef5;
border-radius: 12px;
padding: 12px;
}
.submission-list :deep(.el-checkbox-group) {
display: flex;
flex-direction: column;
gap: 4px;
}
</style>