feat: 完善课表档案、课件归档、提醒与客户端更新并发布 1.4.1
- 重写课表档案工作区并合并时间表、课表与完整档案导入导出 - 修复课前/上下课提醒遮罩与通知收回冲突 - 增加课件文件夹分类、教师归档路径和客户端本机归档 - 打通客户端下载/安装/完成状态上报并在管理端展示更新过程 - 修复侧栏折叠裁切、课时多选与上传控件样式 - 统一版本到 1.4.1
This commit is contained in:
1 parent
2612a912ef
commit
6347d1e5aa
59 files changed
+2251
-584
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user