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:
1 parent
d27f9a4b9e
commit
3880eee08e
22 files changed
+2482
-175
No files matched your search
@@ -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
@@ -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,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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user