feat(web,desktop,server): 灵动岛收起后可再次打开 · 下线作业截止时间 · Web 端圆角设计
灵动岛"收起后部分情况无法再次打开"(用户反馈): - 收起不再等于消失:只要有未处理通知(当前展示的或队列里的),超时后保持胶囊常驻, 只有"知道了 / 标为已读"、队列清空或进入上课时段才彻底隐藏。 - 收起态点击兜底:active 为空但队列还有未看通知时,点击胶囊会把它取出并展开; 上课时段仍严格不显示(该兜底放在上课守卫之后,不影响"上课不打扰")。 - 命中测试自愈:渲染进程记住最近指针位置,状态/外观变化与形变每一帧都按新几何重算命中; 岛可见时主进程也会重新下发可交互,避免"窗口还处于穿透 → 点了没反应"。 - 新增 3 条回归断言:收起后胶囊常驻、收起态点击可再次展开(命中恢复)、空闲细缝也可点开。 作业"截止时间"功能下线(用户要求): - 接口/类型:HomeworkDto 与创建请求去掉 dueAt,homeworks schema/service 不再读写; 仪表盘去掉"即将截止的作业"卡片与待截止统计口径(改为"未完成/待完成")。 - Web:作业发布表单移除截止时间选择、列表与详情移除截止时间列; 学生端作业列表/详情移除截止时间与"已逾期"状态(只保留完成状态)。 - 灵动岛:作业卡不再显示"截止时间:…"副标题,改为只展示作业要求。 - DB 中的 Homework.dueAt 列**保留**(已在 schema 注明为遗留字段),避免对已安装库做破坏性迁移; 种子数据不再写入该列。 Web 管理端圆角设计(用户要求): - 新增设计令牌 --ch-radius-xl/lg/md/sm/pill 与两级柔和投影,并让 Element Plus 的 --el-border-radius-* 对齐同一套令牌;卡片、弹窗、抽屉、按钮、输入、标签、分页、 侧栏菜单项、表格表头统一改造,卡片去硬边框改投影 + 悬停微浮起,顶栏/侧栏做圆角收口。 - 未改组件源码,全部为 CSS 变量 + 类选择器覆盖;手机端媒体查询保持生效(截图已更新)。 验证:verify:desktop 62/62(含 3 条收起态回归)与离线复跑 56/56、打包便携版 62/62(packaged=true)、 verify:e2e 136/136(新增"作业接口不再返回 dueAt")、verify:web 20/20、typecheck/lint/format 全绿; 服务端与 Web 已重新打包并部署到本机安装目录。
This commit is contained in:
1 parent
0690546307
commit
b69f708aa2
23 files changed
+534
-222
No files matched your search
@@ -186,12 +186,15 @@ function syncTargets(snap = false): void {
|
||||
springs.radius.snap(radius);
|
||||
morph.value = { width: size.width, height: size.height, radius };
|
||||
progress.value = mode.value === 'expanded' ? 1 : 0;
|
||||
refreshInteractive();
|
||||
return;
|
||||
}
|
||||
springs.width.setTarget(size.width);
|
||||
springs.height.setTarget(size.height);
|
||||
springs.radius.setTarget(radius);
|
||||
startFrameLoop();
|
||||
// 目标几何变了:立刻按新尺寸重算一次命中(形变过程中每帧也会重算)
|
||||
refreshInteractive();
|
||||
}
|
||||
|
||||
function startFrameLoop(): void {
|
||||
@@ -218,6 +221,8 @@ function startFrameLoop(): void {
|
||||
span < 1 ? (mode.value === 'expanded' ? 1 : 0) : Math.abs(morph.value.height - pill) / span;
|
||||
|
||||
if (moving || !springs.width.settled || !springs.height.settled || !springs.radius.settled) {
|
||||
// 形变过程中岛的可点区域在变:每帧按缓存指针位置重算命中(值不变时不发 IPC)
|
||||
refreshInteractive();
|
||||
rafHandle = requestAnimationFrame(frame);
|
||||
return;
|
||||
}
|
||||
@@ -228,6 +233,7 @@ function startFrameLoop(): void {
|
||||
springs.radius.snap(targetRadius(size));
|
||||
morph.value = { width: size.width, height: size.height, radius: springs.radius.value };
|
||||
progress.value = mode.value === 'expanded' ? 1 : 0;
|
||||
refreshInteractive();
|
||||
rafHandle = null;
|
||||
};
|
||||
rafHandle = requestAnimationFrame(frame);
|
||||
@@ -293,7 +299,11 @@ const islandClass = computed(() => ({
|
||||
* 指针在岛体上(或可交互控件上)时才让窗口接收鼠标,否则穿透,保证不吞桌面点击。
|
||||
*/
|
||||
let lastInteractive: boolean | null = null;
|
||||
/** 最近一次已知的指针位置:岛收起/变大后用它重算命中,避免"鼠标没动就点不到" */
|
||||
let lastPointer: { x: number; y: number } | null = null;
|
||||
|
||||
function updateInteractive(clientX: number, clientY: number): void {
|
||||
lastPointer = { x: clientX, y: clientY };
|
||||
const node = document.querySelector('.island-card') as HTMLElement | null;
|
||||
let inside = false;
|
||||
if (node) {
|
||||
@@ -310,6 +320,29 @@ function updateInteractive(clientX: number, clientY: number): void {
|
||||
bridge?.setInteractive?.(inside);
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态 / 外观变化后主动重算一次命中测试。
|
||||
*
|
||||
* 为什么必须主动算:窗口默认 `setIgnoreMouseEvents(true, {forward:true})`,
|
||||
* 只有在收到 mousemove 时才会打开命中。岛"收起 / 展开 / 换形态"时几何变了,
|
||||
* 但指针可能一动没动(没有新的 mousemove)—— 此时窗口会一直保持"穿透",
|
||||
* 表现就是**收起后怎么点都打不开**(用户反馈)。所以每次状态变化都用缓存的指针位置重算一次。
|
||||
*/
|
||||
function refreshInteractive(): void {
|
||||
const fullyHidden = state.value?.mode === 'hidden' && !appearance.value.idleSliver;
|
||||
if (fullyHidden) {
|
||||
if (lastInteractive !== false) {
|
||||
lastInteractive = false;
|
||||
bridge?.setInteractive?.(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!lastPointer) return;
|
||||
const pointer = lastPointer;
|
||||
lastInteractive = null; // 强制按新几何再算一次
|
||||
updateInteractive(pointer.x, pointer.y);
|
||||
}
|
||||
|
||||
function applyAppearance(next: IslandAppearance | null | undefined): void {
|
||||
if (!next) return;
|
||||
appearance.value = next;
|
||||
|
||||
@@ -371,10 +371,22 @@ class IslandController {
|
||||
handleAction(payload: IslandActionPayload): void {
|
||||
switch (payload.action) {
|
||||
case 'expand': {
|
||||
const active = this.state.active;
|
||||
// 上课时段:普通通知(含普通叫人)一律不显示;但紧急通知 / 紧急叫人本来就是必须
|
||||
// 立刻看到的,收起(回缩为胶囊)之后必须能再次点开——否则学生会误以为消息消失了。
|
||||
if (this.state.inClass && !(active && this.isImmediate(active))) break;
|
||||
const showing = this.state.active;
|
||||
if (this.state.inClass && !(showing && this.isImmediate(showing))) break;
|
||||
// 收起态(胶囊 / 空闲细缝)再次点击必须能打开:
|
||||
// active 为空但队列里还有未看通知时,先把它取出来当"当前通知"再展开,
|
||||
// 否则会出现"岛明明在屏幕上,点了没反应"(部分情况下的收起态无法再次打开)。
|
||||
if (!this.state.active) {
|
||||
const pending = this.state.queued[this.state.queued.length - 1];
|
||||
if (pending) {
|
||||
this.state.queued = this.state.queued.filter((item) => item.id !== pending.id);
|
||||
this.state.active = pending;
|
||||
logger.info(`灵动岛:收起态点击,重新打开队列中的通知 ${pending.id}`);
|
||||
}
|
||||
}
|
||||
const active = this.state.active;
|
||||
if (active) {
|
||||
this.setState({ mode: 'expanded' });
|
||||
this.scheduleCollapse(
|
||||
@@ -515,14 +527,19 @@ class IslandController {
|
||||
if (this.state.mode === 'expanded') this.setState({ mode: 'pill' });
|
||||
return;
|
||||
}
|
||||
if (this.state.mode === 'expanded') {
|
||||
const hasMore = this.state.queued.length > 1;
|
||||
if (hasMore) {
|
||||
this.setState({ mode: 'pill' });
|
||||
this.scheduleCollapse(TIMEOUTS.afterClassPill);
|
||||
} else {
|
||||
this.hide();
|
||||
}
|
||||
// 关键行为:只要还有"没处理完"的通知(当前展示的或队列里的),收起后**保持胶囊**,
|
||||
// 不再直接消失 —— 否则用户看到岛收起了却点不到("收起状态无法再次打开")。
|
||||
// 彻底隐藏只发生在:用户点了"知道了 / 标为已读"、队列清空、或进入上课时段。
|
||||
const pending = this.state.active !== null || this.state.queued.length > 0;
|
||||
if (this.state.mode === 'expanded' && this.state.queued.length > 1) {
|
||||
// 还有多条未看:先回缩为"还有 N 条"胶囊
|
||||
this.setState({ mode: 'pill' });
|
||||
if (pending) return;
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
if (pending) {
|
||||
if (this.state.mode !== 'pill') this.setState({ mode: 'pill' });
|
||||
return;
|
||||
}
|
||||
this.hide();
|
||||
@@ -959,4 +976,9 @@ export function registerIslandIpc(): void {
|
||||
});
|
||||
}
|
||||
|
||||
export { SIZES as ISLAND_SIZES, URGENT_SIZE as ISLAND_URGENT_SIZE, CALL_SIZE as ISLAND_CALL_SIZE };
|
||||
export {
|
||||
SIZES as ISLAND_SIZES,
|
||||
URGENT_SIZE as ISLAND_URGENT_SIZE,
|
||||
CALL_SIZE as ISLAND_CALL_SIZE,
|
||||
TIMEOUTS as ISLAND_TIMEOUTS,
|
||||
};
|
||||
@@ -5,7 +5,14 @@ import type { BrowserWindow, NativeImage } from 'electron';
|
||||
import type { IslandAppearance, IslandNotification } from '@classhelper/shared';
|
||||
import { getConfig, saveConfig } from './config.js';
|
||||
import { getDiagnostics } from './ipc.js';
|
||||
import { getSliverSize, island, ISLAND_CALL_SIZE, ISLAND_SIZES, ISLAND_URGENT_SIZE } from './island.js';
|
||||
import {
|
||||
getSliverSize,
|
||||
island,
|
||||
ISLAND_CALL_SIZE,
|
||||
ISLAND_SIZES,
|
||||
ISLAND_TIMEOUTS,
|
||||
ISLAND_URGENT_SIZE,
|
||||
} from './island.js';
|
||||
import { destroyTray, getTrayState, isTrayReady } from './tray.js';
|
||||
|
||||
interface SmokeResult {
|
||||
@@ -839,14 +846,14 @@ async function runIslandChecks(
|
||||
`展开时 focusable=${focusable} 失焦后 mode=${afterBlur.mode} focusable=${islandWindow?.isFocusable() ?? '-'}`,
|
||||
);
|
||||
|
||||
// 4.6) 新作业上岛(kind=homework):胶囊 + 展开显示截止时间
|
||||
// 4.6) 新作业上岛(kind=homework):胶囊 + 展开显示作业要求
|
||||
//(截止时间功能已下线:这里同时守住"作业卡上不再出现截止时间")
|
||||
island.handleAction({ action: 'dismiss' });
|
||||
await sleep(400);
|
||||
island.pushNotification(
|
||||
{
|
||||
...makeNotification('smoke-homework', 'NORMAL', '第 3 章课后练习'),
|
||||
kind: 'homework',
|
||||
subtitle: '截止时间:2026-09-15 22:00',
|
||||
teacherName: '张老师',
|
||||
},
|
||||
{ inClass: false },
|
||||
@@ -868,13 +875,13 @@ async function runIslandChecks(
|
||||
);
|
||||
island.handleAction({ action: 'expand' });
|
||||
await sleep(700);
|
||||
const homeworkHint = await islandWindow?.webContents.executeJavaScript(
|
||||
`document.querySelector('.island-card.expanded .call-hint')?.textContent?.trim() ?? ''`,
|
||||
const homeworkCardText = await islandWindow?.webContents.executeJavaScript(
|
||||
`document.querySelector('.island-card.expanded')?.innerText?.replace(/\\s+/g, ' ').trim() ?? ''`,
|
||||
);
|
||||
record(
|
||||
'作业详情显示截止时间',
|
||||
(homeworkHint ?? '').includes('截止时间'),
|
||||
`附加说明="${homeworkHint ?? '-'}"`,
|
||||
'作业详情只显示作业要求(截止时间已下线)',
|
||||
(homeworkCardText ?? '').includes('第 3 章课后练习') && !(homeworkCardText ?? '').includes('截止时间'),
|
||||
`作业卡文案="${homeworkCardText ?? '-'}"`,
|
||||
);
|
||||
await captureIsland('island-7-homework', ISLAND_SIZES.expanded);
|
||||
|
||||
@@ -957,6 +964,105 @@ async function runIslandChecks(
|
||||
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
|
||||
await sleep(400);
|
||||
|
||||
// 4.9) 收起态回归(用户反馈"收起后部分情况无法再次打开"):
|
||||
// a) 胶囊不再因超时消失(有未处理通知时常驻);
|
||||
// b) 收起后胶囊仍然可命中(窗口保持可交互)→ 点击可再次展开;
|
||||
// c) 空闲细缝态同样是"可见即可点"(细缝 6x22 也能命中)。
|
||||
// 注:此处不使用后面的 drainIsland(它在本函数更靠下的位置定义),就地排空队列。
|
||||
const drainForCollapseCheck = async (): Promise<void> => {
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
const current = island.getState();
|
||||
if (!current.active && current.queued.length === 0) return;
|
||||
island.handleAction({ action: 'dismiss' });
|
||||
await sleep(120);
|
||||
}
|
||||
};
|
||||
/** 合成"指针移到卡片上":命中测试由渲染进程按指针位置决定(真实场景由系统转发 mousemove) */
|
||||
const hoverCard = async (): Promise<boolean> =>
|
||||
Boolean(
|
||||
await islandWindow?.webContents
|
||||
.executeJavaScript(
|
||||
`(() => {
|
||||
const card = document.querySelector('.island-card');
|
||||
if (!card) return false;
|
||||
const r = card.getBoundingClientRect();
|
||||
window.dispatchEvent(
|
||||
new MouseEvent('mousemove', {
|
||||
clientX: r.left + r.width / 2,
|
||||
clientY: r.top + r.height / 2,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
})()`,
|
||||
)
|
||||
.catch(() => false),
|
||||
);
|
||||
await drainForCollapseCheck();
|
||||
await sleep(300);
|
||||
island.pushNotification(makeNotification('smoke-collapse-reopen', 'NORMAL', '收起态回归校验'), {
|
||||
inClass: false,
|
||||
});
|
||||
await sleep(500);
|
||||
const collapseBefore = island.getState();
|
||||
await hoverCard();
|
||||
await sleep(200);
|
||||
await sleep(ISLAND_TIMEOUTS.pill + 700);
|
||||
const lingered = island.getState();
|
||||
const lingeredVisible = islandWindow?.isVisible() ?? false;
|
||||
record(
|
||||
'收起后胶囊常驻(超时不再消失,随时可再次点开)',
|
||||
collapseBefore.mode === 'pill' && lingered.mode === 'pill' && lingeredVisible,
|
||||
`收起前=${collapseBefore.mode} 超时后=${lingered.mode} 可见=${lingeredVisible}`,
|
||||
);
|
||||
// 命中:把窗口显式置为"穿透"(等价于收起瞬间窗口被重置),再做一次状态变化 ——
|
||||
// 主进程会按当前形态重新下发命中,渲染进程也会按缓存指针重算,两者都必须把命中恢复。
|
||||
island.setInteractive(false);
|
||||
await sleep(60);
|
||||
const forcedThrough = island.getInteractive();
|
||||
island.handleAction({ action: 'collapse' });
|
||||
await sleep(300);
|
||||
const interactiveWhilePill = island.getInteractive();
|
||||
await islandWindow?.webContents
|
||||
.executeJavaScript(
|
||||
`(() => {
|
||||
document.querySelector('.island-card')?.click();
|
||||
return true;
|
||||
})()`,
|
||||
)
|
||||
.catch(() => undefined);
|
||||
await sleep(750);
|
||||
const reopened = island.getState();
|
||||
record(
|
||||
'收起态点击可再次展开(胶囊命中恢复 + 点击后展开)',
|
||||
forcedThrough === false &&
|
||||
interactiveWhilePill === true &&
|
||||
reopened.mode === 'expanded' &&
|
||||
reopened.active?.id === 'smoke-collapse-reopen',
|
||||
`置穿透后=${forcedThrough} 状态变化后=${interactiveWhilePill} 点击后 mode=${reopened.mode} ` +
|
||||
`active=${reopened.active?.id ?? '-'}`,
|
||||
);
|
||||
|
||||
const sliverForInteractive = getSliverSize();
|
||||
island.setAppearance({ ...island.getAppearance(), idleSliver: true });
|
||||
await sleep(200);
|
||||
await drainForCollapseCheck();
|
||||
await sleep(500);
|
||||
const sliverState = island.getState();
|
||||
await hoverCard();
|
||||
await sleep(250);
|
||||
const sliverInteractive = island.getInteractive();
|
||||
const sliverVisible = islandWindow?.isVisible() ?? false;
|
||||
record(
|
||||
'空闲细缝态保持可交互(细缝很窄,指针停在岛上仍能点开)',
|
||||
sliverState.mode === 'hidden' && sliverVisible && sliverInteractive === true,
|
||||
`mode=${sliverState.mode} 可见=${sliverVisible} interactive=${sliverInteractive} ` +
|
||||
`细缝=${sliverForInteractive.width}x${sliverForInteractive.height}`,
|
||||
);
|
||||
island.setAppearance({ ...island.getAppearance(), idleSliver: false });
|
||||
await drainForCollapseCheck();
|
||||
await sleep(300);
|
||||
|
||||
// 5) 截图留档的像素级断言:每张图必须有实际绘制内容、颜色丰富,且宽高比与
|
||||
// 状态机配置的窗口尺寸一致(DPI 无关),紧急形态还必须出现红色描边/内部光晕像素。
|
||||
const shotDetails = islandShots.map((shot) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
SOCKET_EVENTS,
|
||||
formatDate,
|
||||
resolveClassStatus,
|
||||
type ClassPeriod,
|
||||
type HomeworkDto,
|
||||
@@ -91,9 +90,8 @@ export function pushNotificationToIsland(notification: NotificationDto): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** 新作业上岛(homework:new):胶囊提示"新作业",展开看截止时间 */
|
||||
/** 新作业上岛(homework:new):胶囊提示"新作业",点击展开看作业要求 */
|
||||
export function pushHomeworkToIsland(homework: HomeworkDto): void {
|
||||
const due = homework.dueAt ? formatDate(homework.dueAt, true) : '未设置截止时间';
|
||||
window.desktop?.islandPush({
|
||||
notification: {
|
||||
id: `homework-${homework.id}`,
|
||||
@@ -104,7 +102,6 @@ export function pushHomeworkToIsland(homework: HomeworkDto): void {
|
||||
courseName: homework.course?.name ?? null,
|
||||
teacherName: homework.creator?.name ?? null,
|
||||
kind: 'homework',
|
||||
subtitle: `截止时间:${due}`,
|
||||
},
|
||||
context: getIslandClassContext(),
|
||||
});
|
||||
|
||||
@@ -79,7 +79,7 @@ export const useRealtimeStore = defineStore('realtime', () => {
|
||||
instance.on(SOCKET_EVENTS.homeworkNew, (payload: HomeworkDto) => {
|
||||
mark(SOCKET_EVENTS.homeworkNew);
|
||||
ElMessage.success(`新作业:${payload.title}`);
|
||||
// 作业发布也上岛:胶囊提示"新作业",点击展开看截止时间与要求
|
||||
// 作业发布也上岛:胶囊提示"新作业",点击展开看作业要求
|
||||
pushHomeworkToIsland(payload);
|
||||
dispatch(SOCKET_EVENTS.homeworkNew, payload);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { SOCKET_EVENTS, formatDate, formatDeadline, isOverdue, type HomeworkDto } from '@classhelper/shared';
|
||||
import { SOCKET_EVENTS, formatDate, type HomeworkDto } from '@classhelper/shared';
|
||||
import { homeworkApi } from '../api/index.js';
|
||||
import { fetchWithCache } from '../cache/index.js';
|
||||
import { useAppStore } from '../stores/app.js';
|
||||
@@ -147,12 +147,8 @@ onUnmounted(() => {
|
||||
<el-table v-else :data="filtered" @row-click="openDetail">
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="row.completed ? 'success' : isOverdue(row.dueAt) ? 'danger' : 'info'"
|
||||
size="small"
|
||||
effect="light"
|
||||
>
|
||||
{{ row.completed ? '已完成' : isOverdue(row.dueAt) ? '已逾期' : '待完成' }}
|
||||
<el-tag :type="row.completed ? 'success' : 'info'" size="small" effect="light">
|
||||
{{ row.completed ? '已完成' : '待完成' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -160,16 +156,6 @@ onUnmounted(() => {
|
||||
<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 }">{{ formatDate(row.dueAt, true) || '不限' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剩余" width="120">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ 'text-danger': isOverdue(row.dueAt) && !row.completed }">
|
||||
{{ row.completed ? '—' : formatDeadline(row.dueAt) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="130" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
@@ -198,9 +184,6 @@ onUnmounted(() => {
|
||||
<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>
|
||||
|
||||
@@ -155,6 +155,8 @@ model Homework {
|
||||
title String
|
||||
content String
|
||||
attachmentUrl String?
|
||||
/// 遗留字段:作业"截止时间"功能已下线(UI/接口均不再读写)。
|
||||
/// 保留列以免对已安装的库做破坏性迁移;新库该列恒为 null。
|
||||
dueAt DateTime?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -186,37 +186,33 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 作业
|
||||
// 注:作业"截止时间"功能已下线,种子数据不再写入 dueAt(库字段保留以兼容已装库)
|
||||
const now = Date.now();
|
||||
const day = 86_400_000;
|
||||
const homeworkSeeds = [
|
||||
{
|
||||
title: '《劝学》全文背诵',
|
||||
content: '背诵《劝学》并录制音频上传到班级群,注意断句。',
|
||||
dueInDays: 3,
|
||||
courseIndex: 0,
|
||||
},
|
||||
{
|
||||
title: '数学必修一 3.2 习题',
|
||||
content: '完成课本 P78 习题 3.2 全部题目,第 12 题选做。',
|
||||
dueInDays: 1,
|
||||
courseIndex: 1,
|
||||
},
|
||||
{
|
||||
title: '英语周记一篇',
|
||||
content: '以"My School Life"为题写一篇 150 词左右的英语周记。',
|
||||
dueInDays: 5,
|
||||
courseIndex: 2,
|
||||
},
|
||||
{
|
||||
title: '物理实验报告',
|
||||
content: '整理"探究匀变速直线运动"实验数据,撰写实验报告。',
|
||||
dueInDays: -2,
|
||||
courseIndex: 3,
|
||||
},
|
||||
{
|
||||
title: '化学方程式默写',
|
||||
content: '默写第一至第三章全部化学方程式,家长签字。',
|
||||
dueInDays: -1,
|
||||
courseIndex: 4,
|
||||
},
|
||||
];
|
||||
@@ -234,7 +230,6 @@ async function main(): Promise<void> {
|
||||
content: seed.content,
|
||||
attachmentUrl:
|
||||
seed.courseIndex % 2 === 0 ? 'https://example.com/classhelper/homework-sample.pdf' : null,
|
||||
dueAt: new Date(now + seed.dueInDays * day),
|
||||
createdBy: targetClass.teacherId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -198,11 +198,16 @@ async function main() {
|
||||
courseId,
|
||||
title: `联调验证作业 ${new Date().toISOString()}`,
|
||||
content: '请完成验证脚本创建的作业。',
|
||||
dueAt: new Date(Date.now() + 86_400_000).toISOString(),
|
||||
},
|
||||
});
|
||||
const homeworkId = createdHomework.payload?.data?.id;
|
||||
record('教师发布作业', createdHomework.status === 201, `status=${createdHomework.status}`);
|
||||
// 截止时间功能已下线:接口不再返回该字段(历史数据保留在库里但不读写)
|
||||
record(
|
||||
'作业接口不再返回截止时间(dueAt 已下线)',
|
||||
createdHomework.payload?.data?.dueAt === undefined,
|
||||
`dueAt=${String(createdHomework.payload?.data?.dueAt)}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const { payload, elapsed } = await homeworkWait;
|
||||
|
||||
@@ -190,7 +190,6 @@ export interface HomeworkLike {
|
||||
title: string;
|
||||
content: string;
|
||||
attachmentUrl: string | null;
|
||||
dueAt: Date | null;
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
course?: CourseBriefLike | null;
|
||||
@@ -228,7 +227,6 @@ export function toHomeworkDto(
|
||||
title: item.title,
|
||||
content: item.content,
|
||||
attachmentUrl: item.attachmentUrl,
|
||||
dueAt: toIsoOrNull(item.dueAt),
|
||||
createdBy: item.createdBy,
|
||||
createdAt: toIso(item.createdAt),
|
||||
course: item.course ? toCourseBrief(item.course) : null,
|
||||
|
||||
@@ -25,7 +25,6 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
|
||||
// 子表按 classId 过滤;Class 表本身按主键 id 过滤
|
||||
const where = classScopeWhere(scope);
|
||||
const classWhere = classScopeIdFilter(scope);
|
||||
const now = new Date();
|
||||
// 班级账号(班级设备)以全班学生为范围;普通学生即自己
|
||||
const personalIds = await resolvePersonalIds(user);
|
||||
const personalWhere = personalIdWhere(personalIds);
|
||||
@@ -42,7 +41,6 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
|
||||
pendingHomeworkCount,
|
||||
recentNotifications,
|
||||
recentHomeworks,
|
||||
upcoming,
|
||||
] = await Promise.all([
|
||||
prisma.class.count({ where: classWhere }),
|
||||
prisma.user.count({ where: { ...where, role: 'STUDENT' } }),
|
||||
@@ -56,7 +54,7 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
|
||||
? prisma.homework.count({
|
||||
where: { ...where, statuses: { none: { ...personalWhere, completed: true } } },
|
||||
})
|
||||
: prisma.homework.count({ where: { ...where, dueAt: { gte: now } } }),
|
||||
: prisma.homework.count({ where }),
|
||||
prisma.notification.findMany({
|
||||
where,
|
||||
include: { creator: creatorSelect, reads: { where: personalWhere } },
|
||||
@@ -73,16 +71,6 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 5,
|
||||
}),
|
||||
prisma.homework.findMany({
|
||||
where: { ...where, dueAt: { gte: now } },
|
||||
include: {
|
||||
course: courseSelect,
|
||||
creator: creatorSelect,
|
||||
statuses: student ? { where: personalWhere } : true,
|
||||
},
|
||||
orderBy: { dueAt: 'asc' },
|
||||
take: 5,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -101,8 +89,5 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
|
||||
recentHomeworks: recentHomeworks.map((item): HomeworkDto =>
|
||||
toHomeworkDto(item, { userIds: student ? personalIds : [], withStatus: !student }),
|
||||
),
|
||||
upcomingDeadlines: upcoming.map((item): HomeworkDto =>
|
||||
toHomeworkDto(item, { userIds: student ? personalIds : [], withStatus: !student }),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { booleanFlagSchema, optionalDateTimeSchema } from '../../lib/schemas.js';
|
||||
import { booleanFlagSchema } from '../../lib/schemas.js';
|
||||
|
||||
export const listHomeworksQuerySchema = z.object({
|
||||
classId: z.string().min(1).optional(),
|
||||
@@ -15,7 +15,6 @@ export const createHomeworkSchema = z.object({
|
||||
title: z.string().trim().min(1, '请输入作业标题').max(120),
|
||||
content: z.string().min(1, '请输入作业内容').max(5000),
|
||||
attachmentUrl: z.string().trim().max(500).nullish(),
|
||||
dueAt: optionalDateTimeSchema,
|
||||
});
|
||||
|
||||
export const updateHomeworkSchema = z.object({
|
||||
@@ -23,7 +22,6 @@ export const updateHomeworkSchema = z.object({
|
||||
title: z.string().trim().min(1).max(120).optional(),
|
||||
content: z.string().min(1).max(5000).optional(),
|
||||
attachmentUrl: z.string().trim().max(500).nullish(),
|
||||
dueAt: optionalDateTimeSchema,
|
||||
});
|
||||
|
||||
export const updateHomeworkStatusSchema = z.object({
|
||||
|
||||
@@ -10,7 +10,6 @@ import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { toHomeworkDto, toHomeworkStatusDto } from '../../lib/mappers.js';
|
||||
import { parseOptionalDate } from '../../lib/schemas.js';
|
||||
import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
|
||||
import { emitToClass } from '../../realtime/bus.js';
|
||||
import type {
|
||||
@@ -101,7 +100,6 @@ export async function createHomework(user: TokenPayload, input: CreateHomeworkIn
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
attachmentUrl: input.attachmentUrl ?? null,
|
||||
dueAt: parseOptionalDate(input.dueAt),
|
||||
createdBy: user.sub,
|
||||
},
|
||||
include: { course: courseSelect, creator: creatorSelect, statuses: true },
|
||||
@@ -130,7 +128,6 @@ export async function updateHomework(
|
||||
...(input.content ? { content: input.content } : {}),
|
||||
...(input.courseId !== undefined ? { courseId: input.courseId ?? null } : {}),
|
||||
...(input.attachmentUrl !== undefined ? { attachmentUrl: input.attachmentUrl ?? null } : {}),
|
||||
...(input.dueAt !== undefined ? { dueAt: parseOptionalDate(input.dueAt) } : {}),
|
||||
},
|
||||
include: { course: courseSelect, creator: creatorSelect, statuses: true },
|
||||
});
|
||||
|
||||
@@ -215,7 +215,6 @@ export interface HomeworkDto {
|
||||
title: string;
|
||||
content: string;
|
||||
attachmentUrl: string | null;
|
||||
dueAt: string | null;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
course?: CourseBrief | null;
|
||||
@@ -241,7 +240,6 @@ export interface CreateHomeworkRequest {
|
||||
title: string;
|
||||
content: string;
|
||||
attachmentUrl?: string | null;
|
||||
dueAt?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateHomeworkStatusRequest {
|
||||
@@ -491,7 +489,6 @@ export interface DashboardSummary {
|
||||
pendingHomeworkCount: number;
|
||||
recentNotifications: NotificationDto[];
|
||||
recentHomeworks: HomeworkDto[];
|
||||
upcomingDeadlines: HomeworkDto[];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ WebSocket */
|
||||
|
||||
@@ -102,28 +102,6 @@ export function durationMinutes(startTime: string, endTime: string): number {
|
||||
return (eh as number) * 60 + (em as number) - ((sh as number) * 60 + (sm as number));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 作业 */
|
||||
|
||||
/** 是否已过截止时间 */
|
||||
export function isOverdue(dueAt: string | null | undefined, now: Date = new Date()): boolean {
|
||||
if (!dueAt) return false;
|
||||
const due = toDate(dueAt);
|
||||
return !Number.isNaN(due.getTime()) && due.getTime() < now.getTime();
|
||||
}
|
||||
|
||||
/** 距离截止时间的可读描述,例如"还剩 2 天"、"已逾期 3 小时" */
|
||||
export function formatDeadline(dueAt: string | null | undefined, now: Date = new Date()): string {
|
||||
if (!dueAt) return '无截止时间';
|
||||
const due = toDate(dueAt);
|
||||
if (Number.isNaN(due.getTime())) return '无截止时间';
|
||||
const diffMinutes = Math.round((due.getTime() - now.getTime()) / 60000);
|
||||
const prefix = diffMinutes < 0 ? '已逾期' : '还剩';
|
||||
const abs = Math.abs(diffMinutes);
|
||||
if (abs < 60) return `${prefix} ${abs} 分钟`;
|
||||
if (abs < 60 * 24) return `${prefix} ${Math.floor(abs / 60)} 小时`;
|
||||
return `${prefix} ${Math.floor(abs / (60 * 24))} 天`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 成绩 */
|
||||
|
||||
/** 得分率(0-100),总分非法时返回 0 */
|
||||
|
||||
@@ -299,16 +299,19 @@ async function submitPassword(): Promise<void> {
|
||||
|
||||
.layout-menu :deep(.el-menu-item.is-active) {
|
||||
background: #409eff;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--ch-radius-sm);
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.layout-menu :deep(.el-menu-item) {
|
||||
margin: 2px 8px;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--ch-radius-sm);
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
/* 顶栏跟随圆角设计:底部圆角 + 轻投影,与内容区一体化 */
|
||||
border-radius: 0 0 var(--ch-radius-lg) var(--ch-radius-lg);
|
||||
box-shadow: var(--ch-shadow-sm);
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--ch-border);
|
||||
display: flex;
|
||||
@@ -326,7 +329,7 @@ async function submitPassword(): Promise<void> {
|
||||
height: 32px;
|
||||
margin-right: 2px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--ch-radius-sm);
|
||||
background: #f2f3f5;
|
||||
color: #303133;
|
||||
cursor: pointer;
|
||||
@@ -392,6 +395,9 @@ async function submitPassword(): Promise<void> {
|
||||
<style>
|
||||
/* 侧边栏配色放在全局:桌面 asider 与移动端抽屉复用同一套样式(抽屉内容在 teleport 中,scoped 样式不生效) */
|
||||
.ch-sidebar {
|
||||
/* 圆角设计:深色侧栏右侧做圆角,与内容区形成"卡片浮起"观感 */
|
||||
border-radius: 0 var(--ch-radius-xl) var(--ch-radius-xl) 0;
|
||||
overflow: hidden;
|
||||
background: #1f2d3d;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,239 @@
|
||||
:root {
|
||||
--ch-bg: #f5f7fa;
|
||||
--ch-card-radius: 10px;
|
||||
--ch-border: #e4e7ed;
|
||||
--ch-bg: #f2f4f8;
|
||||
/* 圆角设计令牌:卡片 > 面板 > 控件 > 小元素,统一走这四个档位 */
|
||||
--ch-radius-xl: 22px;
|
||||
--ch-radius-lg: 18px;
|
||||
--ch-radius-md: 14px;
|
||||
--ch-radius-sm: 10px;
|
||||
--ch-radius-pill: 999px;
|
||||
--ch-card-radius: var(--ch-radius-lg);
|
||||
--ch-border: #e6e8ef;
|
||||
--ch-shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04), 0 4px 12px rgba(15, 23, 42, 0.04);
|
||||
--ch-shadow-md: 0 6px 24px rgba(15, 23, 42, 0.08);
|
||||
/* 1Panel 风格:固定顶栏 + 深色侧栏 */
|
||||
--ch-header-height: 56px;
|
||||
--ch-aside-width: 210px;
|
||||
|
||||
/* Element Plus 圆角令牌对齐(组件库内部都引用这些变量,改这里即可全局生效) */
|
||||
--el-border-radius-base: var(--ch-radius-sm);
|
||||
--el-border-radius-small: 8px;
|
||||
--el-border-radius-round: var(--ch-radius-pill);
|
||||
--el-box-shadow-light: var(--ch-shadow-sm);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 圆角设计:
|
||||
|
||||
统一"大圆角 + 轻阴影 + 无硬边框"的观感:
|
||||
- 卡片 / 面板 / 弹窗 / 抽屉:18~22px 圆角 + 柔和投影(去掉 1px 灰边)
|
||||
- 按钮 / 输入框 / 下拉 / 标签:10px 圆角,胶囊类(标签页、分页、单选组)走 999px
|
||||
- 表格:外层由卡片包裹,表头首末单元格跟随卡片圆角
|
||||
|
||||
全部通过 Element Plus 的 CSS 变量 + 少量类选择器覆盖,不改组件源码。 */
|
||||
|
||||
.el-card {
|
||||
border: none;
|
||||
border-radius: var(--ch-radius-lg);
|
||||
box-shadow: var(--ch-shadow-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-card__header {
|
||||
border-bottom: 1px solid var(--ch-border);
|
||||
padding: 14px 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-card__body {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
border-radius: var(--ch-radius-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.el-button.is-round,
|
||||
.el-button--circle {
|
||||
border-radius: var(--ch-radius-pill);
|
||||
}
|
||||
|
||||
.el-input__wrapper,
|
||||
.el-textarea__inner,
|
||||
.el-select__wrapper,
|
||||
.el-input-number,
|
||||
.el-date-editor.el-input__wrapper {
|
||||
border-radius: var(--ch-radius-sm);
|
||||
}
|
||||
|
||||
.el-input__wrapper,
|
||||
.el-select__wrapper {
|
||||
box-shadow: 0 0 0 1px var(--ch-border) inset;
|
||||
}
|
||||
|
||||
.el-input__wrapper.is-focus,
|
||||
.el-select__wrapper.is-focused {
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
|
||||
}
|
||||
|
||||
.el-textarea__inner {
|
||||
box-shadow: 0 0 0 1px var(--ch-border) inset;
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
border-radius: var(--ch-radius-xl);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--ch-shadow-md);
|
||||
}
|
||||
|
||||
.el-dialog__header {
|
||||
margin-right: 0;
|
||||
padding: 18px 22px 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 10px 22px 6px;
|
||||
}
|
||||
|
||||
.el-dialog__footer {
|
||||
padding: 8px 22px 18px;
|
||||
}
|
||||
|
||||
.el-drawer {
|
||||
border-radius: var(--ch-radius-xl) 0 0 var(--ch-radius-xl);
|
||||
}
|
||||
|
||||
.el-drawer.rtl {
|
||||
border-radius: var(--ch-radius-xl) 0 0 var(--ch-radius-xl);
|
||||
}
|
||||
|
||||
.el-drawer.ltr {
|
||||
border-radius: 0 var(--ch-radius-xl) var(--ch-radius-xl) 0;
|
||||
}
|
||||
|
||||
.el-drawer.ttb,
|
||||
.el-drawer.btt {
|
||||
border-radius: var(--ch-radius-xl);
|
||||
}
|
||||
|
||||
.el-message,
|
||||
.el-message-box,
|
||||
.el-notification,
|
||||
.el-popper.is-light,
|
||||
.el-dropdown-menu,
|
||||
.el-select-dropdown,
|
||||
.el-picker-panel {
|
||||
border-radius: var(--ch-radius-md);
|
||||
}
|
||||
|
||||
.el-message-box {
|
||||
border-radius: var(--ch-radius-lg);
|
||||
padding: 20px 22px;
|
||||
}
|
||||
|
||||
.el-popper.is-light {
|
||||
border: 1px solid var(--ch-border);
|
||||
box-shadow: var(--ch-shadow-sm);
|
||||
}
|
||||
|
||||
.el-dropdown-menu__item,
|
||||
.el-select-dropdown__item {
|
||||
border-radius: var(--ch-radius-sm);
|
||||
margin: 2px 6px;
|
||||
}
|
||||
|
||||
.el-tag {
|
||||
border-radius: var(--ch-radius-pill);
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.el-alert {
|
||||
border-radius: var(--ch-radius-md);
|
||||
}
|
||||
|
||||
.el-descriptions__body {
|
||||
border-radius: var(--ch-radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-descriptions__label.el-descriptions__cell.is-bordered-label {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.el-radio-group,
|
||||
.el-radio-button:first-child .el-radio-button__inner {
|
||||
border-radius: var(--ch-radius-sm) 0 0 var(--ch-radius-sm);
|
||||
}
|
||||
|
||||
.el-radio-button:last-child .el-radio-button__inner {
|
||||
border-radius: 0 var(--ch-radius-sm) var(--ch-radius-sm) 0;
|
||||
}
|
||||
|
||||
.el-radio-button__inner {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.el-checkbox__inner,
|
||||
.el-switch__core,
|
||||
.el-progress-bar__outer,
|
||||
.el-progress-bar__inner {
|
||||
border-radius: var(--ch-radius-pill);
|
||||
}
|
||||
|
||||
.el-tabs__item {
|
||||
border-radius: var(--ch-radius-sm) var(--ch-radius-sm) 0 0;
|
||||
}
|
||||
|
||||
.el-pagination button,
|
||||
.el-pager li,
|
||||
.el-pagination .el-input__wrapper {
|
||||
border-radius: var(--ch-radius-sm);
|
||||
}
|
||||
|
||||
.el-pagination.is-background .el-pager li {
|
||||
border-radius: var(--ch-radius-sm);
|
||||
}
|
||||
|
||||
.el-table {
|
||||
border-radius: var(--ch-radius-md);
|
||||
--el-table-border-color: var(--ch-border);
|
||||
}
|
||||
|
||||
.el-table th.el-table__cell {
|
||||
background: #f7f9fc;
|
||||
}
|
||||
|
||||
.el-table__inner-wrapper::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.el-table--border .el-table__inner-wrapper,
|
||||
.el-table--border {
|
||||
border-radius: var(--ch-radius-md);
|
||||
}
|
||||
|
||||
.el-empty__image {
|
||||
border-radius: var(--ch-radius-md);
|
||||
}
|
||||
|
||||
.el-form-item__label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.el-upload-dragger,
|
||||
.el-upload-list__item {
|
||||
border-radius: var(--ch-radius-md);
|
||||
}
|
||||
|
||||
.el-step__icon,
|
||||
.el-collapse,
|
||||
.el-collapse-item__header {
|
||||
border-radius: var(--ch-radius-sm);
|
||||
}
|
||||
|
||||
.el-menu-item,
|
||||
.el-sub-menu__title {
|
||||
border-radius: var(--ch-radius-sm);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -28,7 +257,7 @@ body {
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 16px 20px 24px;
|
||||
padding: 18px 22px 28px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
@@ -67,9 +296,18 @@ body {
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: var(--ch-card-radius);
|
||||
padding: 16px;
|
||||
border: 1px solid var(--ch-border);
|
||||
border-radius: var(--ch-radius-lg);
|
||||
padding: 18px;
|
||||
border: none;
|
||||
box-shadow: var(--ch-shadow-sm);
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--ch-shadow-md);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
<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';
|
||||
@@ -32,7 +31,7 @@ const statCards = computed(() => {
|
||||
{
|
||||
label: '作业总数',
|
||||
value: data?.homeworkCount ?? 0,
|
||||
hint: `待截止/待完成 ${data?.pendingHomeworkCount ?? 0}`,
|
||||
hint: `未完成/待完成 ${data?.pendingHomeworkCount ?? 0}`,
|
||||
},
|
||||
{ label: '通知总数', value: data?.notificationCount ?? 0, hint: `未读 ${unreadCount.value}` },
|
||||
{ label: '成绩记录', value: data?.gradeCount ?? 0, hint: '已发布成绩' },
|
||||
@@ -102,7 +101,7 @@ function openNotification(item: NotificationDto): void {
|
||||
</div>
|
||||
|
||||
<el-row :gutter="12" class="mt-16">
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-col :xs="24" :md="24">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
@@ -127,25 +126,6 @@ function openNotification(item: NotificationDto): void {
|
||||
</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 table-card">
|
||||
@@ -165,9 +145,6 @@ function openNotification(item: NotificationDto): void {
|
||||
<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>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
<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,
|
||||
@@ -76,7 +74,6 @@ const form = reactive({
|
||||
title: '',
|
||||
content: '',
|
||||
attachmentUrl: '',
|
||||
dueAt: '',
|
||||
});
|
||||
|
||||
const rules: FormRules = {
|
||||
@@ -92,7 +89,6 @@ function openCreate(): void {
|
||||
form.title = '';
|
||||
form.content = '';
|
||||
form.attachmentUrl = '';
|
||||
form.dueAt = '';
|
||||
formVisible.value = true;
|
||||
}
|
||||
|
||||
@@ -103,7 +99,6 @@ function openEdit(row: HomeworkDto): void {
|
||||
form.title = row.title;
|
||||
form.content = row.content;
|
||||
form.attachmentUrl = row.attachmentUrl ?? '';
|
||||
form.dueAt = row.dueAt ? row.dueAt.slice(0, 16) : '';
|
||||
formVisible.value = true;
|
||||
}
|
||||
|
||||
@@ -117,7 +112,6 @@ async function submitForm(): Promise<void> {
|
||||
title: form.title.trim(),
|
||||
content: form.content,
|
||||
attachmentUrl: form.attachmentUrl.trim() || null,
|
||||
dueAt: form.dueAt ? new Date(form.dueAt).toISOString() : null,
|
||||
};
|
||||
|
||||
if (editingId.value) {
|
||||
@@ -207,16 +201,6 @@ onUnmounted(() => {
|
||||
<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>
|
||||
@@ -254,15 +238,6 @@ onUnmounted(() => {
|
||||
<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>
|
||||
@@ -275,9 +250,6 @@ onUnmounted(() => {
|
||||
<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.dueAt, true) || '不限' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="发布时间">
|
||||
{{ formatDate(current.createdAt, true) }}
|
||||
</el-descriptions-item>
|
||||
|
||||
Reference in new issue
Block a user