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 发到每台学生机、且可解包,冒烟凭据一律改由环境变量注入,
产物里出现种子口令即构建失败。
This commit is contained in:
LoveChengke committed 2026-10-02 00:25:10 +08:00
1 parent d27f9a4b9e
commit 3880eee08e
22 files changed
+2482 -175

No files matched your search

+8 -2
View File
@@ -1,7 +1,12 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { startIslandBridge, stopIslandBridge, subscribeIslandMarkRead } from './island/bridge.js';
import {
startIslandBridge,
stopIslandBridge,
subscribeIslandMarkAllRead,
subscribeIslandMarkRead,
} from './island/bridge.js';
import { useAppStore } from './stores/app.js';
import { useAuthStore } from './stores/auth.js';
import { useRealtimeStore } from './stores/realtime.js';
@@ -41,8 +46,9 @@ onMounted(async () => {
// 灵动岛:开始按课表计算上课状态(上课隐藏、下课自动弹出)
if (auth.token) startIslandBridge();
// 灵动岛"标为已读" → 同步通知中心(未读红点)
// 灵动岛"标为已读" → 同步通知中心(未读红点);多条通知时是整批已读
subscribeIslandMarkRead();
subscribeIslandMarkAllRead();
if (auth.isAuthenticated) await router.replace('/schedule');
else await router.replace('/login');
@@ -21,13 +21,22 @@ import { api } from './http.js';
/** 客户端只需用到学生视角的这批接口(与后端同一套 REST 契约) */
export const authApi = {
/** 个人账号登录(教师/管理员/个人学生;客户端界面已不再展示,保留以兼容旧流程与排障) */
/** 账号登录(教师/管理员用;客户端界面走班级登录,学生个人账号已清理不能登录) */
login: (payload: LoginRequest): Promise<LoginResponse> => api.post(API_PATHS.auth.login, payload),
/** 班级账号登录(学生端主入口):班级码 + 班级密码 */
classLogin: (payload: { code: string; password: string }): Promise<LoginResponse> =>
api.post('/auth/class-login', payload),
logout: (): Promise<{ loggedOut: boolean }> => api.post(API_PATHS.auth.logout, {}),
me: (): Promise<StudentDto> => api.get(API_PATHS.auth.me),
/**
* 修改密码(设置页「账号信息」里的入口)。
* 服务端同一个接口按会话类型分流:班级账号改的是**班级密码**,教师/管理员改的是本人登录密码 ——
* 因此这台机器无论以哪种身份登录,改密入口都走这一条。
*/
changePassword: (payload: {
currentPassword: string;
newPassword: string;
}): Promise<{ changed: boolean }> => api.patch(API_PATHS.auth.changePassword, payload),
};
export const classApi = {
@@ -110,8 +119,10 @@ export const notificationApi = {
list: (params?: { classId?: string; unreadOnly?: boolean }): Promise<NotificationDto[]> =>
api.get(API_PATHS.notifications, params),
unreadCount: (): Promise<{ count: number }> => api.get(`${API_PATHS.notifications}/unread-count`),
// id 来自服务端或灵动岛(作业卡是本地合成的 `homework-…`),一律 encodeURIComponent:
// 否则含 `/` 或 `..` 的 id 会改变实际请求的路径
markRead: (id: string): Promise<{ notificationId: string; readAt: string }> =>
api.post(`${API_PATHS.notifications}/${id}/read`, {}),
api.post(`${API_PATHS.notifications}/${encodeURIComponent(id)}/read`, {}),
markAllRead: (): Promise<{ marked: number }> => api.post(`${API_PATHS.notifications}/read-all`, {}),
};
+123 -8
View File
@@ -6,6 +6,31 @@ export interface SmokeCheckResult {
detail: string;
}
/**
* 冒烟用的教师凭据:**由 preload 从环境变量注入,不写死在代码里**。
*
* 本文件会进渲染进程产物(`dist/renderer/assets/*.js`)→ `app.asar` → 每台学生机,
* 而 asar 可以直接解包。因此任何口令字面量都不能留在这里;
* 凭据由 `scripts/smoke.mjs` / `scripts/verify-packaged.mjs` 通过环境变量提供
* (见 `main/smoke.ts` 顶部关于 app.asar 的完整说明)。
*/
let smokeTeacherCredentials: { username: string; password: string } = { username: '', password: '' };
/** 由 main.ts 在注册冒烟钩子时注入(来源:ELECTRON_SMOKE_USER / ELECTRON_SMOKE_PASSWORD) */
export function setSmokeTeacherCredentials(next: { username: string; password: string }): void {
smokeTeacherCredentials = {
username: next?.username ?? '',
password: next?.password ?? '',
};
}
/** 教师登录请求体;未注入凭据时返回 null(调用方据此跳过并说明原因) */
function teacherLoginBody(): { username: string; password: string } | null {
return smokeTeacherCredentials.username && smokeTeacherCredentials.password
? { username: smokeTeacherCredentials.username, password: smokeTeacherCredentials.password }
: null;
}
/**
* 冒烟自检 1:IndexedDB 写入 / 读回 / 统计 / 删除。
* 由主进程通过 webContents.executeJavaScript 调用。
@@ -144,10 +169,18 @@ async function seedSmokeContent(
const stamp = `自检 ${new Date().toLocaleTimeString('zh-CN')}`;
try {
// 凭据由环境变量注入(理由见文件顶部):没给就明确跳过,而不是拿种子账号兜底
const loginBody = teacherLoginBody();
if (!loginBody) {
return {
ok: false,
detail: '未提供冒烟教师凭据(ELECTRON_SMOKE_USER / ELECTRON_SMOKE_PASSWORD),无法自建测试数据',
};
}
const login = await fetch(`${serverUrl}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'teacher1', password: 'teacher123' }),
body: JSON.stringify(loginBody),
}).then((response) => response.json());
const token: string | undefined = login?.data?.token;
if (!token) return { ok: false, detail: `教师登录失败:${JSON.stringify(login).slice(0, 120)}` };
@@ -239,10 +272,18 @@ export async function onlineScenario(credentials?: {
const detail: string[] = [];
try {
// 学生端已改为「班级账号」登录:凭据由 scripts/smoke.mjs 通过管理端接口准备并传入
const code = credentials?.code ?? 'G101';
const password = credentials?.password ?? '123456';
if (!credentials?.code) detail.push('未收到班级凭据,回退到种子班级码 G101');
// 学生端已改为「班级账号」登录:凭据由 scripts/smoke.mjs 通过管理端接口准备并传入。
// 这里**不再回退到种子班级码**(G101/123456 也是真实可用的登录凭据,不能写进安装包):
// 没有凭据就明确跳过,由 scripts/smoke.mjs 打印"未能准备班级账号"。
const code = credentials?.code ?? '';
const password = credentials?.password ?? '';
if (!code) {
return {
ok: false,
detail:
'未收到班级凭据(ELECTRON_SMOKE_CLASS_CODE / ELECTRON_SMOKE_CLASS_PASSWORD),已跳过联网集成自检',
};
}
await auth.login(serverUrl, code, password);
detail.push(`login=${auth.user?.name ?? '-'}`);
@@ -363,10 +404,17 @@ export async function islandRealtimeScenario(): Promise<{
const title = `灵动岛真机链路自检 ${Date.now()}`;
try {
const loginBody = teacherLoginBody();
if (!loginBody) {
return {
ok: false,
detail: '未提供冒烟教师凭据(ELECTRON_SMOKE_USER / ELECTRON_SMOKE_PASSWORD),无法投递通知',
};
}
const login = await fetch(`${serverUrl}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'teacher1', password: 'teacher123' }),
body: JSON.stringify(loginBody),
}).then((response) => response.json());
const teacherToken: string | undefined = login?.data?.token;
if (!teacherToken) return { ok: false, detail: `教师登录失败:${JSON.stringify(login).slice(0, 120)}` };
@@ -378,6 +426,8 @@ export async function islandRealtimeScenario(): Promise<{
classId,
title,
content: '自动化验证:这条通知用于确认「服务端广播 → 客户端实时通道 → 灵动岛」整条链路可用。',
// 普通优先级:冒烟客户端登录的是**临时班级**(无课表 → 判定不在上课),
// 普通通知也会立刻弹出胶囊,链路断言与真实上课时段无关。
priority: 'NORMAL',
}),
}).then((response) => response.json());
@@ -465,10 +515,17 @@ export async function scheduleTimelineSelfTest(): Promise<SmokeCheckResult> {
let teacherToken = '';
try {
const loginBody = teacherLoginBody();
if (!loginBody) {
return {
ok: false,
detail: '未提供冒烟教师凭据(ELECTRON_SMOKE_USER / ELECTRON_SMOKE_PASSWORD),无法构造课表',
};
}
const login = await fetch(`${serverUrl}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'teacher1', password: 'teacher123' }),
body: JSON.stringify(loginBody),
}).then((response) => response.json());
teacherToken = login?.data?.token ?? '';
if (!teacherToken) return { ok: false, detail: '教师登录失败' };
@@ -613,7 +670,9 @@ export async function homeworkBoardSelfTest(): Promise<SmokeCheckResult> {
// 今天可能恰好没有作业:切到「最近一个有作业的日期」保证看板一定有卡片(断言才有意义)。
// 按天查看支持 ?date=YYYY-MM-DD 深链,切天不需要去操作日期选择器。
if (!document.querySelector('.board-host')) {
// 注意判据是「看板里没有卡片」而不是「没有 board-host 容器」——空看板照样渲染容器,
// 拿容器当判据会导致切日期的兜底永远不触发(今天的日期没作业时看板必然为空)。
if (!document.querySelector('.board-card')) {
const [{ homeworkApi }, { useAuthStore }] = await Promise.all([
import('../api/index.js'),
import('../stores/auth.js'),
@@ -704,6 +763,61 @@ export async function homeworkBoardSelfTest(): Promise<SmokeCheckResult> {
}
}
/**
* 冒烟自检(不需要后端):**本机录入的作业不再上灵动岛**(用户要求)。
*
* 场景:教室机器上录完作业,服务端照样广播 `homework:new`,但"自己通知自己"没有意义。
* 判定按**内容指纹 + id**(发起请求前就登记,避免"广播晚于响应"漏判),因此这里两条都要验:
* 1) 登记过的这条:投递后灵动岛状态不变;
* 2) 没登记过的同形作业(别班/别的标题):照常上岛。
*/
export async function islandHomeworkSuppressionCheck(): Promise<SmokeCheckResult> {
const [{ markHomeworkCreatedLocally, isLocalHomework, pushHomeworkToIsland }, { useAuthStore }] =
await Promise.all([import('../island/bridge.js'), import('../stores/auth.js')]);
try {
const classId = useAuthStore().classId ?? 'smoke-class';
const assignDate = '2030-01-01';
const draft = {
id: 'smoke-homework-suppressed',
classId,
title: '本机录入的作业',
content: '这条不应该弹上岛',
assignDate,
};
markHomeworkCreatedLocally(draft);
const recognized = isLocalHomework(draft);
// 同 id、同内容 → 必须认出来;换个标题 → 不算本机录入
const recognizedByFingerprint = isLocalHomework({ ...draft, id: '' });
const foreign = isLocalHomework({ ...draft, id: 'other', title: '别的作业' });
const before = await window.desktop?.islandGetState?.();
pushHomeworkToIsland({
...draft,
courseId: null,
attachmentUrl: null,
createdBy: '',
createdAt: new Date().toISOString(),
} as never);
await new Promise((resolve) => setTimeout(resolve, 400));
const after = await window.desktop?.islandGetState?.();
const untouched =
(before?.active?.id ?? null) === (after?.active?.id ?? null) &&
(before?.queued.length ?? 0) === (after?.queued.length ?? 0);
return {
ok: recognized && recognizedByFingerprint && !foreign && untouched,
detail:
`按 id 认出=${recognized} 按内容指纹认出=${recognizedByFingerprint} 别的作业误判=${foreign} ` +
`投递后灵动岛未变=${untouched}`,
};
} catch (error) {
return {
ok: false,
detail: `本机作业不上岛自检异常:${error instanceof Error ? error.message : String(error)}`,
};
}
}
/** 冒烟收尾:删除自检临时数据 → 断开实时通道 → 退出登录,保证下次冒烟从登录页开始 */
export async function sessionCleanup(): Promise<SmokeCheckResult> {
const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([
@@ -730,6 +844,7 @@ export function registerSmokeHooks(): void {
islandRealtimeScenario,
islandRealtimeCleanup,
islandReadState,
islandHomeworkSuppressionCheck,
scheduleTimelineSelfTest,
homeworkBoardSelfTest,
sessionCleanup,
@@ -92,6 +92,11 @@ export function pushNotificationToIsland(notification: NotificationDto): void {
/** 新作业上岛(homework:new):胶囊提示"新作业",点击展开看作业要求 */
export function pushHomeworkToIsland(homework: HomeworkDto): void {
// 本机刚录入的作业不再上岛:教室机器上录作业的人就是眼前这台设备的主人,
// 再弹一张"新作业"卡片纯属自己通知自己(用户反馈)。
if (isLocalHomework(homework)) {
return;
}
window.desktop?.islandPush({
notification: {
id: `homework-${homework.id}`,
@@ -107,6 +112,62 @@ export function pushHomeworkToIsland(homework: HomeworkDto): void {
});
}
/* ------------------------------------------------------------ 本机录入的作业 */
/**
* 本机刚录入的作业指纹。
*
* 为什么按指纹而不只按 id:`homework:new` 是服务端**先广播、后回响应**的,
* 录入接口返回的 id 很可能比实时事件到得还晚 —— 只记 id 会漏掉"事件先到"的那一半,
* 于是本机录的作业照样弹上岛。因此在发请求**之前**就按内容记一份指纹,
* 回来后补记 id(覆盖事件晚到的情况)。指纹带 TTL,避免长期占用内存。
*/
const localHomeworkKeys = new Set<string>();
let localHomeworkKeysAt = 0;
const LOCAL_HOMEWORK_TTL_MS = 60_000;
/** 作业内容指纹:同一班、同一天、同标题同正文 = 就是本机刚录的那条 */
function homeworkFingerprint(homework: {
classId?: string | null;
title?: string | null;
content?: string | null;
assignDate?: string | null;
}): string {
return [
homework.classId ?? '',
homework.assignDate ?? '',
(homework.title ?? '').trim(),
(homework.content ?? '').trim(),
].join('|');
}
/** 录入前/后登记"这条作业是本机录的"(id 可后补) */
export function markHomeworkCreatedLocally(homework: {
id?: string | null;
classId?: string | null;
title?: string | null;
content?: string | null;
assignDate?: string | null;
}): void {
const now = Date.now();
if (now - localHomeworkKeysAt > LOCAL_HOMEWORK_TTL_MS) localHomeworkKeys.clear();
localHomeworkKeysAt = now;
localHomeworkKeys.add(homeworkFingerprint(homework));
if (homework.id) localHomeworkKeys.add(homework.id);
}
/** 这条作业是不是本机刚录的(供投递前判定与冒烟验证) */
export function isLocalHomework(homework: {
id?: string | null;
classId?: string | null;
title?: string | null;
content?: string | null;
assignDate?: string | null;
}): boolean {
if (homework.id && localHomeworkKeys.has(homework.id)) return true;
return localHomeworkKeys.has(homeworkFingerprint(homework));
}
/**
* "叫人"上岛:紧急叫人(priority=URGENT)无视上课时段立即展开;
* 普通叫人按普通通知处理(课间先显示胶囊、点击展开,上课时段只排队)。
@@ -148,15 +209,40 @@ export function subscribeIslandMarkRead(): void {
});
}
/**
* 灵动岛点了"标为已读"(多条通知的整批)→ 把这一批都在通知中心里标记已读。
* 与单条版本同一套兜底:离线时先在本地记为已读。
*/
export function subscribeIslandMarkAllRead(): void {
window.desktop?.onIslandMarkAllRead?.((ids) => {
void (async () => {
const store = useNotificationStore();
await Promise.all(
(ids ?? []).map(async (id) => {
try {
await store.markRead(id);
} catch {
const target = store.items.find((item) => item.id === id);
if (target) target.read = true;
}
}),
);
})();
});
}
/** 具名的课表变更处理器:匿名函数在停止时无法 off 掉,会随"登出→登录"越挂越多 */
function onScheduleUpdated(): void {
void refreshIslandSchedules().catch(() => undefined);
}
export function startIslandBridge(): void {
if (tickTimer) return;
void refreshIslandSchedules().catch(() => undefined);
// 课表变更(教师改课)立即复算,避免"下课后不弹出"或"上课没隐藏"
useRealtimeStore().on(SOCKET_EVENTS.scheduleUpdated, () => {
void refreshIslandSchedules().catch(() => undefined);
});
useRealtimeStore().on(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated);
tickTimer = setInterval(() => recomputeClassState(), TICK_INTERVAL_MS);
refreshTimer = setInterval(() => {
@@ -164,9 +250,31 @@ export function startIslandBridge(): void {
}, REFRESH_INTERVAL_MS);
}
/**
* 停止桥接:退出登录 / 切换账号时必须调用。
*
* 只清定时器是不够的:
* - 订阅要 off,否则每次"登出 → 登录"都会多挂一份监听;
* - 本地课表缓存要清空,否则登出后仍会按**上一个班**的课表继续给主进程下发"上课/下课"
* (灵动岛按陈旧课表隐藏或弹出),换班登录时第一轮刷新回来之前用的也是旧课表。
*/
export function stopIslandBridge(): void {
if (tickTimer) clearInterval(tickTimer);
if (refreshTimer) clearInterval(refreshTimer);
tickTimer = null;
refreshTimer = null;
useRealtimeStore().off(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated);
schedules = [];
currentPeriod = null;
inClass = false;
// 把"不在上课"同步给主进程:否则主进程还记着上一个会话的"上课中",
// 重新登录后若第一轮课表刷新失败(离线且无缓存),灵动岛会继续按上课态把通知压着不弹,
// 直到下一次刷新成功为止。这里主动对齐一次,代价是登出瞬间可能补弹一条此前被暂存的通知。
window.desktop?.islandSetClassState({
inClass: false,
currentPeriodEnd: null,
week: useAppStore().currentWeek,
});
}
@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
import { startIslandBridge, stopIslandBridge } from '../island/bridge.js';
import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useNotificationStore } from '../stores/notifications.js';
@@ -70,10 +71,14 @@ async function handleLogout(): Promise<void> {
void router.push({ name: 'login' });
}
/** 通知实时推送:更新列表 + 红点 + 缓存 */
/**
* 通知实时推送:更新通知中心列表与红点。
*
* 这里**不再**额外弹一条"收到新通知"的 toast —— `stores/realtime.ts` 收到
* notification:new 时已经弹过带优先级与标题的那条,两条堆在屏幕上纯属噪音。
*/
function onNotification(payload: unknown): void {
void notifications.pushRealtime(payload as NotificationDto);
ElMessage({ message: '收到新通知,点击「通知」查看', type: 'info', duration: 3000 });
}
/** 服务器从不可达恢复为可达:自动同步一次(联网后自动同步) */
@@ -87,11 +92,15 @@ onMounted(async () => {
await notifications.refreshUnreadCount();
realtime.on(SOCKET_EVENTS.notificationNew, onNotification);
appStore.onServerRecovered(onRecovered);
// 灵动岛桥接跟着"已登录布局"的生命周期走:这样退出登录会随布局卸载而停止,
// 重新登录(或换班登录)时又会重新启动,不会拿旧课表算上课状态。
if (auth.token) startIslandBridge();
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.notificationNew, onNotification);
appStore.offServerRecovered(onRecovered);
stopIslandBridge();
});
</script>
+7 -2
View File
@@ -7,7 +7,7 @@ import 'element-plus/dist/index.css';
import App from './App.vue';
import router from './router/index.js';
import { registerSmokeHooks } from './cache/self-test.js';
import { registerSmokeHooks, setSmokeTeacherCredentials } from './cache/self-test.js';
import './styles/index.css';
const app = createApp(App);
@@ -20,8 +20,13 @@ app.use(createPinia());
app.use(router);
app.use(ElementPlus, { locale: zhCn });
// 冒烟验证模式:把缓存自检钩子暴露给主进程调用(正常运行时不存在)
// 冒烟验证模式:把缓存自检钩子暴露给主进程调用(正常运行时不存在)。
// 教师凭据由 preload 从环境变量透传(**不写在代码里**:渲染产物会进 app.asar 发给学生机)。
if (window.desktop?.smokeTest) {
setSmokeTeacherCredentials({
username: window.desktop.smokeCredentials?.username ?? '',
password: window.desktop.smokeCredentials?.password ?? '',
});
registerSmokeHooks();
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import {
@@ -15,6 +15,7 @@ import {
} 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';
@@ -80,6 +81,17 @@ async function selectDate(value: string | null): Promise<void> {
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));
@@ -412,13 +424,19 @@ async function submitCreate(): Promise<void> {
createSaving.value = true;
try {
await homeworkApi.create({
// 先按内容登记"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();
@@ -624,12 +624,18 @@ onMounted(async () => {
{{ auth.user?.username ?? '-' }}
</el-descriptions-item>
<el-descriptions-item label="登录方式">
{{ auth.isClassSession ? '班级账号(本机代表全班)' : '个人学生账号' }}
{{ auth.isClassSession ? '班级账号(本机代表全班)' : '班级账号' }}
</el-descriptions-item>
<el-descriptions-item label="班级">{{ auth.user?.className ?? '未分班' }}</el-descriptions-item>
<el-descriptions-item label="年级">{{ auth.user?.grade ?? '-' }}</el-descriptions-item>
</el-descriptions>
<el-button class="mt-16" type="danger" plain @click="logout">退出登录</el-button>
<div class="toolbar mt-16">
<el-button type="danger" plain @click="logout">退出登录</el-button>
</div>
<div class="text-muted mt-12">
班级密码由管理员在 Web 管理端「班级管理 → 修改班级账号」中设置;本机退出后用班级码 +
班级密码重新登录。
</div>
</el-card>
</el-col>