feat: 完善课表档案、课件归档、提醒与客户端更新并发布 1.4.1

- 重写课表档案工作区并合并时间表、课表与完整档案导入导出
- 修复课前/上下课提醒遮罩与通知收回冲突
- 增加课件文件夹分类、教师归档路径和客户端本机归档
- 打通客户端下载/安装/完成状态上报并在管理端展示更新过程
- 修复侧栏折叠裁切、课时多选与上传控件样式
- 统一版本到 1.4.1
This commit is contained in:
LoveChengke committed 2026-10-18 17:39:54 +08:00
1 parent 2612a912ef
commit 6347d1e5aa
59 files changed
+2251 -584

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/desktop-client",
"version": "1.4.0",
"version": "1.4.1",
"private": true,
"description": "ClassHelper EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
"author": "Class Helper",
@@ -390,7 +390,7 @@ const islandClass = computed(() => ({
mode.value === 'hidden' && !appearance.value.idleSliver && !state.value.lessonReminder,
'ci-lesson-card': !!state.value.lessonReminder,
'message-surface': !!state.value.active && mode.value !== 'hidden' && !state.value.lessonReminder,
sliver: mode.value === 'hidden' && appearance.value.idleSliver,
sliver: mode.value === 'hidden' && appearance.value.idleSliver && !state.value.lessonReminder,
urgent: isUrgent.value,
call: isCall.value,
homework: isHomework.value,
@@ -58,6 +58,7 @@ const time = computed(() =>
);
const regions = ref([0, 0, 0, 0, 0]);
let frame = 0;
let fallback: ReturnType<typeof setTimeout> | undefined;
// ClassIsland SlantedMaskControl:5 个斜切区域、30°偏移、两侧向中央打开,中央向两侧关闭。
const polygons = computed(() => {
const offset = Math.tan(Math.PI / 6) * 40;
@@ -71,12 +72,21 @@ const polygons = computed(() => {
});
function animateMask() {
cancelAnimationFrame(frame);
clearTimeout(fallback);
const target = mask.value ? 1 : 0;
if (!props.motion) {
regions.value = [target, target, target, target, target];
return;
}
const from = [...regions.value];
// 隐藏窗口恢复或系统合成暂停时 rAF 可能停住,遮罩仍须在有限时间内成为可见实体。
fallback = setTimeout(
() => {
cancelAnimationFrame(frame);
regions.value = [target, target, target, target, target];
},
600 / Math.max(0.25, props.appearance.speed),
);
const started = performance.now();
const delays = mask.value ? [0, 90, 120, 90, 0] : [120, 90, 0, 90, 120];
const update = (at: number) => {
@@ -109,6 +119,7 @@ onMounted(() => {
onUnmounted(() => {
observer?.disconnect();
cancelAnimationFrame(frame);
clearTimeout(fallback);
});
</script>
@@ -137,7 +148,7 @@ onUnmounted(() => {
<strong>{{ leftSeconds }}</strong>
秒
</span>
<span v-else class="ci-countdown">课间休息</span>
<span v-else class="ci-countdown">{{ reminder.kind === 'on-class' ? '开始上课' : '课间休息' }}</span>
<span class="ci-lesson-course">
<span>
{{
@@ -146,8 +157,8 @@ onUnmounted(() => {
: '下节课是:'
}}
</span>
<strong>{{ course }}</strong>
<small>{{ time }}</small>
<strong>{{ course || '今日课程已结束' }}</strong>
<small v-if="course">{{ time }}</small>
</span>
</div>
<div
@@ -194,8 +205,8 @@ onUnmounted(() => {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
padding: 0 28px;
gap: 12px;
padding: 0 14px;
white-space: nowrap;
transition: opacity var(--ci-duration) ease-in;
}
@@ -222,6 +233,7 @@ onUnmounted(() => {
text-overflow: ellipsis;
}
.ci-lesson-course small {
flex-shrink: 0;
font-size: 14px;
font-variant-numeric: tabular-nums;
}
+159 -11
View File
@@ -14,6 +14,26 @@ import {
import { getConfig, saveConfig } from './config.js';
import { island } from './island.js';
import { logger } from './logger.js';
import { connectionEvents } from './config-events.js';
let updateEpoch = 0;
let downloadAbort = new AbortController();
connectionEvents.on('changed', () => {
updateEpoch++;
downloadAbort.abort();
downloadAbort = new AbortController();
policy = null;
installationRetryAt = 0;
installResultChecked = false;
updateProgress = {
version: '',
stage: 'idle',
downloadedBytes: 0,
totalBytes: 0,
percent: null,
error: '',
};
});
/**
* 服务端统一下发的自动更新(下载 + 空闲时静默安装)。
@@ -28,15 +48,24 @@ import { logger } from './logger.js';
* 否则会与"安装器正在覆盖自己"撞车 —— Windows 上正在运行的程序文件是删不掉的)。
*/
let policy: Pick<ClientHeartbeatResponse, 'updateTargetVersion' | 'updateMode' | 'updateNotBefore'> | null =
null;
let policy:
| (Pick<ClientHeartbeatResponse, 'updateTargetVersion' | 'updateMode' | 'updateNotBefore'> & {
serverUrl: string;
username: string;
})
| null = null;
function policyPath(): string {
return path.join(pendingUpdateDir(), 'policy.json');
}
function loadPolicy(): typeof policy {
if (policy) return policy;
if (policy)
return policy.serverUrl === getConfig().serverUrl && policy.username === getConfig().username
? policy
: null;
try {
policy = JSON.parse(fs.readFileSync(policyPath(), 'utf8'));
if (policy?.serverUrl !== getConfig().serverUrl || policy?.username !== getConfig().username)
policy = null;
} catch {
/* 尚无下发策略 */
}
@@ -58,7 +87,23 @@ function removeManagedInstaller(file: string): void {
/** 每次成功心跳先同步策略;取消或更换目标后旧包不得自行安装。 */
export function syncUpdatePolicy(next: ClientHeartbeatResponse): void {
if (policy && policy.updateTargetVersion !== next.updateTargetVersion) {
installationRetryAt = 0;
updateEpoch++;
downloadAbort.abort();
downloadAbort = new AbortController();
updateProgress = {
version: next.updateTargetVersion || '',
stage: 'idle',
downloadedBytes: 0,
totalBytes: 0,
percent: null,
error: '',
};
}
policy = {
serverUrl: getConfig().serverUrl,
username: getConfig().username,
updateTargetVersion: next.updateTargetVersion,
updateMode: next.updateMode ?? 'idle',
updateNotBefore: next.updateNotBefore ?? null,
@@ -86,6 +131,8 @@ export function pendingUpdateDir(): string {
let mainWindowHiddenAt: number | null = null;
/** 本进程是否已经发起过安装(防止心跳与 hide 事件重复触发) */
let installing = false;
let installationRetryAt = 0;
let installResultChecked = false;
let updateProgress: ContractReleaseProgressDto = {
version: '',
stage: 'idle',
@@ -95,12 +142,69 @@ let updateProgress: ContractReleaseProgressDto = {
error: '',
};
export function getUpdateProgress() {
if (!installResultChecked) {
installResultChecked = true;
const resultFile = path.join(pendingUpdateDir(), 'install-result.json');
try {
const result = JSON.parse(fs.readFileSync(resultFile, 'utf8').replace(/^\uFEFF/, ''));
if (
result.serverUrl === getConfig().serverUrl &&
result.username === getConfig().username &&
typeof result.version === 'string' &&
result.version === getConfig().pendingUpdateVersion &&
isNewerVersion(app.getVersion(), result.version) &&
typeof result.error === 'string' &&
result.error
) {
updateProgress = {
...updateProgress,
version: result.version,
stage: 'failed',
error: result.error.slice(0, 1000),
};
installationRetryAt = Date.now() + 5 * 60_000;
fs.rmSync(resultFile, { force: true });
}
} catch {
/* 尚未执行安装助手,或上一次已成功。 */
}
}
return { ...updateProgress };
}
let progressPublishedAt = 0;
let reportAt = 0;
let reporting: Promise<boolean> = Promise.resolve(true);
/** 与心跳分离,按顺序上报;安装前等待该队列完成再退出。 */
function reportProgress(): Promise<boolean> {
const config = getConfig(),
snapshot = getUpdateProgress(),
epoch = updateEpoch;
if (!config.token || process.env.ELECTRON_SMOKE_TEST === '1') return Promise.resolve(true);
reporting = reporting
.catch(() => false)
.then(async () => {
if (epoch !== updateEpoch) return false;
try {
const response = await fetch(`${config.serverUrl}/api/clients/update-progress`, {
method: 'POST',
headers: { authorization: `Bearer ${config.token}`, 'content-type': 'application/json' },
body: JSON.stringify({ deviceKey: config.deviceKey, progress: snapshot }),
signal: AbortSignal.timeout(5000),
redirect: 'error',
});
if (!response.ok) logger.warn(`更新进度上报失败:HTTP ${response.status}`);
return response.ok;
} catch (error) {
logger.warn(`更新进度上报失败:${String(error)}`);
return false;
}
});
return reporting;
}
function publishProgress(patch: Partial<typeof updateProgress>) {
const previousStage = updateProgress.stage;
updateProgress = { ...updateProgress, ...patch };
updateProgress.error = updateProgress.error.slice(0, 1000);
updateProgress.percent = updateProgress.totalBytes
? Math.min(100, Math.round((updateProgress.downloadedBytes / updateProgress.totalBytes) * 100))
: null;
@@ -108,6 +212,10 @@ function publishProgress(patch: Partial<typeof updateProgress>) {
progressPublishedAt = Date.now();
for (const win of BrowserWindow.getAllWindows())
if (!win.isDestroyed()) win.webContents.send('client:update-progress', getUpdateProgress());
if (previousStage !== updateProgress.stage || Date.now() - reportAt >= 2000) {
reportAt = Date.now();
void reportProgress();
}
}
/**
* 退出本进程的回调(由 index.ts 注册成既有的 `quitApp()`)。
@@ -175,6 +283,7 @@ export function shouldConsider(version: string): boolean {
* - 同一版本已经下载过(`pendingUpdateVersion` 命中且文件还在):跳过下载,直接尝试安装。
*/
export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: string): Promise<void> {
const epoch = updateEpoch;
const version = targetVersion.trim() || info.version.trim();
if (!shouldConsider(version)) {
logger.info(`忽略服务端下发的更新 ${version || '(未指定版本)'}:自动更新已在设置中关闭`);
@@ -189,6 +298,13 @@ export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: s
if (config.pendingUpdateVersion === version) {
const existing = config.pendingUpdateFile;
if (existing && fs.existsSync(existing)) {
publishProgress({
version,
stage: 'waiting',
downloadedBytes: info.size,
totalBytes: info.size,
error: '',
});
logger.info(`更新 ${version} 早已下载完成,直接尝试安装`);
await maybeInstallPendingUpdate();
return;
@@ -222,7 +338,8 @@ export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: s
logger.info(`开始下载更新 ${version}(${info.fileName},${formatSize(info.size)})`);
const response = await fetch(url, {
headers: { authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
signal: AbortSignal.any([AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), downloadAbort.signal]),
redirect: 'error',
});
if (!response.ok) {
throw new Error(`下载失败:HTTP ${response.status}`);
@@ -235,13 +352,20 @@ export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: s
const handle = await fsp.open(temp, 'w');
try {
for await (const chunk of response.body as unknown as AsyncIterable<Uint8Array>) {
await handle.write(chunk);
let offset = 0;
while (offset < chunk.byteLength) {
const { bytesWritten } = await handle.write(chunk, offset, chunk.byteLength - offset);
if (!bytesWritten) throw new Error('更新包写入失败');
offset += bytesWritten;
}
publishProgress({ downloadedBytes: updateProgress.downloadedBytes + chunk.byteLength });
}
} finally {
await handle.close();
}
if (epoch !== updateEpoch) return;
if (info.size > 0 && updateProgress.downloadedBytes !== info.size) throw new Error('更新包长度不匹配');
publishProgress({ stage: 'verifying' });
const digest = await sha256File(temp);
const expected = info.sha256.trim().toLowerCase();
@@ -264,7 +388,7 @@ export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: s
await fsp.rm(target, { force: true });
await fsp.rename(temp, target);
publishProgress({ stage: 'waiting' });
if (loadPolicy()?.updateTargetVersion !== version) {
if (epoch !== updateEpoch || loadPolicy()?.updateTargetVersion !== version) {
removeManagedInstaller(target);
return;
}
@@ -277,6 +401,7 @@ export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: s
await maybeInstallPendingUpdate();
} catch (error) {
await fsp.rm(temp, { force: true }).catch(() => undefined);
if (epoch !== updateEpoch) return;
publishProgress({ stage: 'failed', error: String(error) });
logger.error(`下载更新 ${version} 失败:${error instanceof Error ? error.message : String(error)}`);
} finally {
@@ -319,6 +444,7 @@ export async function maybeInstallPendingUpdate(): Promise<void> {
const courseware = island.getCoursewareTransfer();
if (courseware && ['downloading', 'verifying'].includes(courseware.stage)) return;
if (installing) return;
if (Date.now() < installationRetryAt) return;
if (process.env.ELECTRON_SMOKE_TEST === '1') return;
const config = getConfig();
@@ -375,15 +501,36 @@ export async function maybeInstallPendingUpdate(): Promise<void> {
}
installing = true;
publishProgress({ stage: 'installing' });
const installEpoch = updateEpoch;
publishProgress({ version, stage: 'installing', error: '' });
const reported = await reporting;
if (!reported) {
installing = false;
publishProgress({ stage: 'waiting', error: '安装前状态尚未送达服务端,等待网络恢复后重试' });
return;
}
if (
installEpoch !== updateEpoch ||
loadPolicy()?.updateTargetVersion !== version ||
getConfig().autoUpdate === false
) {
installing = false;
return;
}
logger.info(`开始静默安装更新 ${version}:${installer}`);
try {
const exe = app.getPath('exe');
const exe = process.env.PORTABLE_EXECUTABLE_FILE || app.getPath('exe');
const quote = (value: string): string => "'" + value.replaceAll("'", "''") + "'";
const config = getConfig();
const resultFile = path.join(pendingUpdateDir(), 'install-result.json');
// 上一次失败的结果不能冒充这一次:安装开始时先清掉,只有本轮失败才重新写入。
fs.rmSync(resultFile, { force: true });
const script =
`$ErrorActionPreference = 'Stop'; Wait-Process -Id ${process.pid} -ErrorAction SilentlyContinue; ` +
`$installer = Start-Process -FilePath ${quote(installer)} -ArgumentList '/S','--updated','--force-run' -Wait -PassThru; ` +
`if ($installer.ExitCode -ne 0 -and (Test-Path -LiteralPath ${quote(exe)})) { Start-Process -FilePath ${quote(exe)} }`;
`$ErrorActionPreference = 'Stop'; try { Wait-Process -Id ${process.pid} -ErrorAction SilentlyContinue; ` +
`$installer = Start-Process -FilePath ${quote(installer)} -ArgumentList '/S','--updated','--force-run' -WindowStyle Hidden -Wait -PassThru; ` +
`if ($installer.ExitCode -ne 0) { throw ('安装器退出码:' + $installer.ExitCode) } ` +
`} catch { @{ version=${quote(version)}; serverUrl=${quote(config.serverUrl)}; username=${quote(config.username)}; error=$_.Exception.Message } | ConvertTo-Json -Compress | Set-Content -LiteralPath ${quote(resultFile)} -Encoding UTF8; ` +
`if (Test-Path -LiteralPath ${quote(exe)}) { Start-Process -FilePath ${quote(exe)} -WindowStyle Hidden } }`;
const encoded = Buffer.from(script, 'utf16le').toString('base64');
const child = spawn(
'powershell.exe',
@@ -408,6 +555,7 @@ export async function maybeInstallPendingUpdate(): Promise<void> {
}
} catch (error) {
installing = false;
publishProgress({ stage: 'failed', error: String(error).slice(0, 1000) });
logger.error(
`启动更新安装失败(将在下次满足安装条件时重试):${error instanceof Error ? error.message : String(error)}`,
);
@@ -15,7 +15,12 @@ import {
type ClientHeartbeatResponse,
type ClientUpdateInfoDto,
} from '@classhelper/shared';
import { maybeInstallPendingUpdate, considerUpdate, syncUpdatePolicy } from './auto-update.js';
import {
maybeInstallPendingUpdate,
considerUpdate,
syncUpdatePolicy,
getUpdateProgress,
} from './auto-update.js';
import { connectionEvents, type ConnectionEvent } from './config-events.js';
import { getConfig } from './config.js';
import { island } from './island.js';
@@ -178,6 +183,7 @@ async function beat(): Promise<void> {
autoUpdate: config.autoUpdate !== false,
pendingVersion: pendingVersionOrNull(),
stateNote: currentStateNote(),
updateProgress: getUpdateProgress(),
};
const response = await fetch(`${config.serverUrl}/api/clients/heartbeat`, {
method: 'POST',
+66 -4
View File
@@ -9,6 +9,8 @@ import { app, BrowserWindow, shell } from 'electron';
import {
COURSEWARE_MAX_BYTES,
COURSEWARE_TRANSFER_TIMEOUT_MS,
coursewareArchivePath,
coursewarePathSegment,
type ApiResponse,
type CoursewareDto,
type CoursewarePushDto,
@@ -74,6 +76,34 @@ async function digest(file: string) {
for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
return hash.digest('hex');
}
interface LocalCourseware {
item: CoursewareDto;
relativePath: string;
}
function localFile(root: string, relative: string) {
const file = path.resolve(root, relative);
if (!file.startsWith(path.resolve(root) + path.sep)) throw new Error('课件路径无效');
return file;
}
async function localRecord(id: string, root = scopeDirectory()): Promise<LocalCourseware | null> {
try {
return JSON.parse(await fsp.readFile(path.join(root, '.index', `${id}.json`), 'utf8'));
} catch {
return null;
}
}
export async function listLocalCourseware(): Promise<CoursewareDto[]> {
if (!getConfig().token) return [];
const root = scopeDirectory();
const startedEpoch = epoch;
const names = await fsp.readdir(path.join(root, '.index')).catch(() => []);
const result: CoursewareDto[] = [];
for (const name of names.filter((name) => /^[a-z0-9-]+\.json$/i.test(name))) {
const record = await localRecord(name.slice(0, -5), root);
if (record && fs.existsSync(localFile(root, record.relativePath))) result.push(record.item);
}
return startedEpoch === epoch ? result.sort((a, b) => b.createdAt.localeCompare(a.createdAt)) : [];
}
export function downloadCourseware(id: string): Promise<void> {
checkId(id);
const startedEpoch = epoch,
@@ -99,10 +129,22 @@ export function downloadCourseware(id: string): Promise<void> {
!/^[a-f0-9]{64}$/.test(item.sha256)
)
throw new Error('课件文件信息无效');
const safeName =
`_${item.name.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_').replace(/[. ]+$/, '')}`.slice(0, 182);
const dir = path.join(scopeDirectory(), id),
file = path.join(dir, safeName);
const root = scopeDirectory();
const stored = await localRecord(id, root);
let relativePath =
stored?.relativePath ||
(item.archivePath || coursewareArchivePath(item.teacher.name, new Date(item.createdAt), item.name))
.split('/')
.map(coursewarePathSegment)
.join(path.sep);
let file = localFile(root, relativePath);
// 同秒、同名文件不得覆盖先前课件;重试通过本地索引始终定位同一文件。
if (!stored && fs.existsSync(file)) {
const ext = path.extname(file);
relativePath = relativePath.slice(0, relativePath.length - ext.length) + ` (${id})` + ext;
file = localFile(root, relativePath);
}
const dir = path.dirname(file);
partial = `${file}.part`;
const base: CoursewareTransfer = {
id,
@@ -164,6 +206,17 @@ export function downloadCourseware(id: string): Promise<void> {
await fsp.rename(partial, file);
}
if (startedEpoch !== epoch) throw new Error('登录会话已切换');
await fsp.mkdir(path.join(root, '.index'), { recursive: true });
const indexFile = path.join(root, '.index', `${id}.json`);
await fsp.writeFile(
`${indexFile}.part`,
JSON.stringify({
item: { ...item, archivePath: relativePath.split(path.sep).join('/') },
relativePath,
}),
'utf8',
);
await fsp.rename(`${indexFile}.part`, indexFile);
publish({ ...base, receivedBytes: item.size, progress: 100, stage: 'completed' });
} catch (error) {
if (partial) await fsp.rm(partial, { force: true }).catch(() => undefined);
@@ -232,6 +285,15 @@ export async function pollCourseware() {
}
export async function revealCourseware(id: string) {
checkId(id);
if (!getConfig().token) throw new Error('请先登录班级账号');
const stored = await localRecord(id);
if (stored) {
const file = localFile(scopeDirectory(), stored.relativePath);
if (fs.existsSync(file)) {
shell.showItemInFolder(file);
return;
}
}
const dir = path.join(scopeDirectory(), id);
const names = await fsp.readdir(dir).catch(() => []);
const file = names.find((name) => !name.endsWith('.part'));
+11 -2
View File
@@ -1,4 +1,10 @@
import { downloadCourseware, getCoursewareTransfer, pollCourseware, revealCourseware } from './courseware.js';
import {
downloadCourseware,
getCoursewareTransfer,
pollCourseware,
revealCourseware,
listLocalCourseware,
} from './courseware.js';
import { island } from './island.js';
import { getUpdateProgress } from './auto-update.js';
import { app, BrowserWindow, ipcMain, shell } from 'electron';
@@ -17,7 +23,10 @@ export { IPC_CHANNELS };
export function registerIpcHandlers(): void {
ipcMain.handle('courseware:download', (_event, id: string) => downloadCourseware(id));
ipcMain.handle('courseware:reveal', (_event, id: string) => revealCourseware(id));
ipcMain.handle('courseware:sync', () => { void pollCourseware(); });
ipcMain.handle('courseware:local-list', () => listLocalCourseware());
ipcMain.handle('courseware:sync', () => {
void pollCourseware();
});
ipcMain.handle('courseware:get-progress', () => getCoursewareTransfer());
ipcMain.handle('client:get-update-progress', () => getUpdateProgress());
ipcMain.handle(IPC_CHANNELS.getConfig, (): DesktopStoredConfig => getConfig());
+12 -1
View File
@@ -195,7 +195,14 @@ class IslandController {
if (JSON.stringify(next) === JSON.stringify(this.lessonCue)) return;
this.lessonCue = next;
// 触摸窗口先放得下新横条,再下发内容;混合宽高变化也只能增大画布。
if (this.lessonShowing) this.syncTouchLayout();
if (this.lessonShowing) {
// 通知收回的延迟回调不能在课表提醒出现后再隐藏同一扇原生窗口。
if (this.closeTimer) clearTimeout(this.closeTimer);
this.closeTimer = null;
this.cancelFade();
this.syncResidentShape(true);
this.syncTouchLayout();
}
this.emit();
this.syncWindow();
}
@@ -1106,6 +1113,10 @@ class IslandController {
* 所以"窗口最终会隐藏"的既有断言(含轮询写法)不受影响。
*/
private finishClose(): void {
if (this.lessonShowing || this.coursewareShowing) {
this.setState({ mode: 'hidden', reason: null });
return;
}
// 动画期间又来了新消息 / 被显式唤起:这次收回归属于旧的一批,放弃收尾
if (this.state.mode !== 'pill' || this.state.reason !== 'closing') return;
if (this.state.active || this.state.queued.length > 0) return;
@@ -1,6 +1,5 @@
import {
dayKeyLocal,
resolveClassStatus,
DEFAULT_LESSON_REMINDERS,
type LessonReminderSettings,
type IslandLessonReminder,
@@ -23,16 +22,18 @@ export class LessonReminderTracker {
const at = now.getTime();
const keyOf = (s: ScheduleDto, kind: IslandLessonReminder['kind']) =>
`${dayKeyLocal(now)}:${s.id}:${s.startTime}:${s.endTime}:${kind}`;
const status = resolveClassStatus(schedules, now);
const timeAt = (time: string) => {
const d = new Date(now);
const [h, m] = time.split(':').map(Number);
d.setHours(h!, m!, 0, 0);
const [h, m, s = 0] = time.split(':').map(Number);
d.setHours(h!, m!, s, 0);
return d.getTime();
};
const today = schedules.filter((s) => s.dayOfWeek === (now.getDay() || 7));
const current = today.find((s) => s.id === status.current?.scheduleId);
const next = today.find((s) => s.id === status.next?.scheduleId);
const today = schedules
.filter((s) => s.dayOfWeek === (now.getDay() || 7))
.sort((a, b) => timeAt(a.startTime) - timeAt(b.startTime));
// 已解析日课表只按准确时间比较,避免带秒的课时提前触发或错过上课遮罩。
const current = today.find((s) => at >= timeAt(s.startTime) && at < timeAt(s.endTime));
const next = today.find((s) => timeAt(s.startTime) > at);
const ended = today
.filter((s) => at >= timeAt(s.endTime) && at - timeAt(s.endTime) < 12_000)
.sort((a, b) => b.endTime.localeCompare(a.endTime))[0];
@@ -74,12 +75,14 @@ export class LessonReminderTracker {
const entered = this.current?.id === id && at >= this.current.startedAt ? this.current.startedAt : at;
const start = timeAt(lesson.startTime);
const end = timeAt(lesson.endTime);
// 导入的 ClassIsland 时间点可能带秒;提醒条只显示到分钟,宽度保持稳定。
const clock = (value: string) => value.slice(0, 5);
return (this.current = {
id,
kind,
courseName: lesson.course?.name || '课程',
startTime: lesson.startTime,
endTime: lesson.endTime,
startTime: clock(lesson.startTime),
endTime: clock(lesson.endTime),
startedAt: entered,
maskUntil:
kind === 'ending' || kind === 'in-class'
@@ -100,8 +103,8 @@ export class LessonReminderTracker {
lessonStartsAt: start,
lessonEndsAt: end,
nextCourseName: kind === 'break' ? next?.course?.name : undefined,
nextStartTime: kind === 'break' ? next?.startTime : undefined,
nextEndTime: kind === 'break' ? next?.endTime : undefined,
nextStartTime: kind === 'break' && next ? clock(next.startTime) : undefined,
nextEndTime: kind === 'break' && next ? clock(next.endTime) : undefined,
});
}
}
@@ -8,7 +8,7 @@ import { app } from 'electron';
import type { CoursewareDto } from '@classhelper/shared';
import { defaultIslandLayouts } from '@classhelper/shared';
import { getConfig, saveConfig } from './config.js';
import { downloadCourseware, getCoursewareTransfer } from './courseware.js';
import { downloadCourseware, getCoursewareTransfer, listLocalCourseware } from './courseware.js';
import { island } from './island.js';
export async function verifyCoursewareSmoke(
check: (name: string, action: () => Promise<boolean | string> | boolean) => Promise<void>,
@@ -93,7 +93,7 @@ export async function verifyCoursewareSmoke(
});
await job;
await check('课件校验完成后保存正式文件并显示完成', async () => {
const files = await localFiles();
const files = (await localFiles()).filter((file) => file.endsWith('.pptx'));
return (
getCoursewareTransfer()?.stage === 'completed' &&
getCoursewareTransfer()?.progress === 100 &&
@@ -101,6 +101,18 @@ export async function verifyCoursewareSmoke(
(await fs.readFile(files[0]!)).equals(bytes)
);
});
await check('本机归档索引支持离线浏览且路径按教师年月日时间分类', async () => {
const saved = await listLocalCourseware();
const files = (await localFiles()).filter((file) => file.endsWith('.pptx'));
return (
saved.some((local) => local.id === item.id) &&
files.some((file) =>
/Teachers[\\/]临时课件教师[\\/]\d{4}[\\/]\d{2}[\\/]\d{2}[\\/]\d{2}-\d{2}-\d{2}[\\/]课件接收验证\.pptx$/.test(
file,
),
)
);
});
await check('已完整接收的相同课件不重复传输', async () => {
await downloadCourseware(item.id);
return downloads === 1;
@@ -558,7 +558,15 @@ export async function verifyResidentSmoke(
await pause(800);
if (island.getState().lessonReminder?.kind !== 'prepare') return false;
const mask = await win.webContents.executeJavaScript(
`(() => {const cue=document.querySelector('${layout === 'custom' ? '.resident-lesson-page' : '.ci-fixed-lesson'} .ci-mask')||document.querySelector('.ci-fixed-lesson .ci-mask');return Boolean(cue&&cue.querySelectorAll('polygon').length===5);})()`,
`(() => {
const cue=document.querySelector('${layout === 'custom' ? '.resident-lesson-page' : '.ci-fixed-lesson'} .ci-mask')||document.querySelector('.ci-fixed-lesson .ci-mask');
if (!cue || cue.querySelectorAll('polygon').length !== 5) return false;
const card=cue.closest('.ci-lesson'), title=card?.querySelector('.ci-mask-title');
const bounds=card?.getBoundingClientRect(), painted=cue.getBBox();
let opacity=1, node=card;
while(node&&node!==document.body){const style=getComputedStyle(node);if(style.visibility==='hidden'||style.display==='none')return false;opacity*=Number(style.opacity);node=node.parentElement;}
return Boolean(bounds&&bounds.width>=290&&bounds.height>=39&&painted.width>=bounds.width*.9&&title&&Number(getComputedStyle(title).opacity)>.8&&opacity>.8);
})()`,
);
if (!mask) return false;
await shot('lesson-prepare-mask-' + layout);
+5 -1
View File
@@ -1,4 +1,7 @@
import type { ReleaseProgressDto as ContractReleaseProgressDto, CoursewareTransfer } from '@classhelper/shared';
import type {
ReleaseProgressDto as ContractReleaseProgressDto,
CoursewareTransfer,
} from '@classhelper/shared';
import { contextBridge, ipcRenderer } from 'electron';
import { IPC_CHANNELS, UPDATE_AVAILABLE_CHANNEL } from '../main/channels.js';
import type { ClientAgentStatus } from '../main/client-agent.js';
@@ -22,6 +25,7 @@ import type { IslandNotification, UpdateInfo } from '@classhelper/shared';
const bridge: DesktopBridge = {
downloadCourseware: (id) => ipcRenderer.invoke('courseware:download', id),
revealCourseware: (id) => ipcRenderer.invoke('courseware:reveal', id),
listLocalCourseware: () => ipcRenderer.invoke('courseware:local-list'),
syncCourseware: () => ipcRenderer.invoke('courseware:sync'),
getCoursewareTransfer: () => ipcRenderer.invoke('courseware:get-progress'),
onCoursewareTransfer: (handler) => {
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import {
COURSEWARE_CATEGORIES,
@@ -12,6 +12,27 @@ import { fetchWithCache } from '../cache/index.js';
import { getCacheScope } from '../cache/scope.js';
import { useRealtimeStore } from '../stores/realtime.js';
const realtime = useRealtimeStore();
const localItems = ref<CoursewareDto[]>([]),
library = ref<'shared' | 'local'>('shared'),
teacher = ref(''),
archive = ref('');
const source = computed(() => (library.value === 'local' ? localItems.value : items.value));
const teachers = computed(() => [...new Set(source.value.map((item) => item.teacher.name))]);
const archiveFolders = computed(() => [
...new Set(
source.value
.filter((item) => !teacher.value || item.teacher.name === teacher.value)
.map((item) => item.archivePath?.split('/').slice(0, -1).join('/') || item.archiveDate),
),
]);
async function refreshLocal() {
localItems.value = (await window.desktop?.listLocalCourseware()) || [];
}
// 两个来源的教师与目录不通用,切换时清空,避免看到空列表却找不到原因。
watch(library, () => {
teacher.value = '';
archive.value = '';
});
const items = ref<CoursewareDto[]>([]),
loading = ref(false),
offline = ref(false),
@@ -22,15 +43,19 @@ const query = ref(''),
busy = ref<string[]>([]),
transfer = ref<CoursewareTransfer | null>(null);
const filtered = computed(() =>
items.value.filter(
source.value.filter(
(item) =>
(!query.value || `${item.name} ${item.teacher.name}`.includes(query.value)) &&
(!category.value || item.category === category.value) &&
(!teacher.value || item.teacher.name === teacher.value) &&
(!archive.value ||
(item.archivePath?.startsWith(archive.value + '/') ?? item.archiveDate === archive.value)) &&
(!date.value || item.archiveDate === date.value),
),
);
let disposed = false;
async function refresh() {
await refreshLocal();
const scope = getCacheScope();
loading.value = true;
try {
@@ -53,6 +78,7 @@ async function download(item: CoursewareDto) {
busy.value.push(item.id);
try {
await window.desktop.downloadCourseware(item.id);
await refreshLocal();
ElMessage.success('课件已下载,可打开所在文件夹');
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : String(e));
@@ -77,6 +103,7 @@ onMounted(() => {
timer = setInterval(() => void refresh(), 30000);
offProgress = window.desktop?.onCoursewareTransfer((value) => {
transfer.value = value;
if (value?.stage === 'completed') void refreshLocal();
});
void window.desktop?.getCoursewareTransfer().then((value) => {
if (!disposed) transfer.value = value;
@@ -122,6 +149,16 @@ onUnmounted(() => {
<p v-if="transfer.error">{{ transfer.error }}</p>
</el-card>
<div class="filters">
<el-radio-group v-model="library">
<el-radio-button value="shared">班级共享</el-radio-button>
<el-radio-button value="local">本机归档</el-radio-button>
</el-radio-group>
<el-select v-model="teacher" clearable placeholder="全部教师" @change="archive = ''">
<el-option v-for="name in teachers" :key="name" :label="name" :value="name" />
</el-select>
<el-select v-model="archive" clearable filterable placeholder="年 / 月 / 日 / 时间">
<el-option v-for="folder in archiveFolders" :key="folder" :label="folder" :value="folder" />
</el-select>
<el-input v-model="query" clearable placeholder="搜索课件或教师" />
<el-select v-model="category" clearable placeholder="全部类型">
<el-option v-for="(label, key) in COURSEWARE_CATEGORIES" :key="key" :label="label" :value="key" />
@@ -129,8 +166,12 @@ onUnmounted(() => {
<el-date-picker v-model="date" value-format="YYYY-MM-DD" placeholder="归档日期" />
</div>
<el-card shadow="never" class="table-card" v-loading="loading">
<el-table :data="filtered" empty-text="老师还没有向本班共享课件">
<el-table
:data="filtered"
:empty-text="library === 'local' ? '本机还没有已归档的课件' : '老师还没有向本班共享课件'"
>
<el-table-column prop="name" label="课件名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="archivePath" label="归档路径" min-width="250" show-overflow-tooltip />
<el-table-column label="类型" min-width="110">
<template #default="{ row }">
{{ COURSEWARE_CATEGORIES[(row as CoursewareDto).category] }} · {{ row.extension || '无扩展名' }}
@@ -143,7 +184,13 @@ onUnmounted(() => {
</el-table-column>
<el-table-column label="操作" min-width="200">
<template #default="{ row }">
<el-button link type="primary" :loading="busy.includes(row.id)" @click="download(row)">
<el-button
v-if="library === 'shared'"
link
type="primary"
:loading="busy.includes(row.id)"
@click="download(row)"
>
下载
</el-button>
<el-button link @click="reveal(row.id)">打开文件夹</el-button>
+1
View File
@@ -119,6 +119,7 @@ export interface IslandClassStatePayload {
export interface DesktopBridge {
downloadCourseware(id: string): Promise<void>;
revealCourseware(id: string): Promise<void>;
listLocalCourseware(): Promise<import('@classhelper/shared').CoursewareDto[]>;
syncCourseware(): Promise<void>;
getCoursewareTransfer(): Promise<import('@classhelper/shared').CoursewareTransfer | null>;
onCoursewareTransfer(