From aa5676365a96dc551097178090cf3cb2bbeb9c3b Mon Sep 17 00:00:00 2001 From: Evander Date: Mon, 27 Apr 2026 17:54:43 +0800 Subject: [PATCH 01/15] Show browser usage percentages --- package-lock.json | 4 ++-- package.json | 2 +- src/renderer/pages/BrowserUsagePage.tsx | 28 ++++++++++++++++--------- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2684575..240d917 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "timetable", - "version": "0.3.3", + "version": "0.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "timetable", - "version": "0.3.3", + "version": "0.3.4", "dependencies": { "chinese-days": "^1.5.7", "clsx": "^2.1.1", diff --git a/package.json b/package.json index ec4e6b2..6b9275e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "timetable", "productName": "Timetable", "private": false, - "version": "0.3.3", + "version": "0.3.4", "description": "A local Windows desktop planner for courses, tasks, goals, memos, countdowns, and desktop widgets.", "author": "Timetable Local Builder", "main": "out/main/index.js", diff --git a/src/renderer/pages/BrowserUsagePage.tsx b/src/renderer/pages/BrowserUsagePage.tsx index 8b57bd2..5d41195 100644 --- a/src/renderer/pages/BrowserUsagePage.tsx +++ b/src/renderer/pages/BrowserUsagePage.tsx @@ -30,10 +30,12 @@ export function BrowserUsagePage() { const webPages = useMemo(() => pages.filter((page) => getUsageEntryType(page) === 'web'), [pages]) const aiApps = useMemo(() => pages.filter((page) => getUsageEntryType(page) === 'ai'), [pages]) const day = data ? getBrowserUsageDay(data, selectedDate) : null + const webTotalSeconds = webPages.reduce((total, page) => total + page.totalSeconds, 0) const aiTotalSeconds = aiApps.reduce((total, page) => total + page.totalSeconds, 0) - const aiServiceCount = new Set(aiApps.map((page) => getUsageDisplayDomain(page))).size - const domainCount = new Set(pages.map((page) => getUsageDisplayDomain(page))).size const topEntry = pages[0] + const webPercent = day ? getUsagePercent(webTotalSeconds, day.totalSeconds) : 0 + const aiPercent = day ? getUsagePercent(aiTotalSeconds, day.totalSeconds) : 0 + const topPercent = day && topEntry ? getUsagePercent(topEntry.totalSeconds, day.totalSeconds) : 0 if (!data || !day) { return @@ -67,8 +69,8 @@ export function BrowserUsagePage() {
{data.appSettings.browserTrackingEnabled ? '正在记录前台网页和 AI 应用' : '不会继续写入新的时间记录'}
} label="总时长" value={formatUsageDuration(day.totalSeconds)} /> - } label="网页域名" value={`${domainCount}`} /> - } label="AI 服务" value={`${aiServiceCount}`} /> + } label="网页占比" value={`${webPercent}%`} /> + } label="AI 占比" value={`${aiPercent}%`} />
@@ -85,9 +87,9 @@ export function BrowserUsagePage() { setSelectedDate(event.target.value)} />
- - - + + +
@@ -95,7 +97,7 @@ export function BrowserUsagePage() {
使用明细
- {formatUsageDuration(day.totalSeconds)} + 按占比排序 · {formatUsageDuration(day.totalSeconds)}
{pages.length ? pages.map((page) => { @@ -116,10 +118,16 @@ export function BrowserUsagePage() {
{displayTitle}
{displayDomain}
- {formatUsageDuration(page.totalSeconds)} +
+
{usagePercent}%
+
{formatUsageDuration(page.totalSeconds)}
+
-
{isWeb ? page.url : '不保存 AI 对话标题或具体链接'}
+
+ {isWeb ? page.url : '不保存 AI 对话标题或具体链接'} + 占比 {usagePercent}% +
) }) : ( From 1aa8fd5b8afb35f793f205946984636e30b18b1a Mon Sep 17 00:00:00 2001 From: Evander Date: Mon, 27 Apr 2026 18:04:21 +0800 Subject: [PATCH 02/15] Add tray icon asset --- package-lock.json | 4 ++-- package.json | 2 +- public/tray-icon.png | Bin 0 -> 2177 bytes 3 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 public/tray-icon.png diff --git a/package-lock.json b/package-lock.json index 240d917..fd822cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "timetable", - "version": "0.3.4", + "version": "0.3.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "timetable", - "version": "0.3.4", + "version": "0.3.5", "dependencies": { "chinese-days": "^1.5.7", "clsx": "^2.1.1", diff --git a/package.json b/package.json index 6b9275e..28185e1 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "timetable", "productName": "Timetable", "private": false, - "version": "0.3.4", + "version": "0.3.5", "description": "A local Windows desktop planner for courses, tasks, goals, memos, countdowns, and desktop widgets.", "author": "Timetable Local Builder", "main": "out/main/index.js", diff --git a/public/tray-icon.png b/public/tray-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4c587c276e249bf00db9751ad7761a5afa502b3c GIT binary patch literal 2177 zcmV-{2!8j8P)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D2o_01K~#8N?VD?G zlvf$YJL3mu{NRiqobiJnv{h`?)~YDgYAvCpDWrr#2uaADgp}OVB!zORkgy>ONm`Ro zT98|S3R2S$E*mNhDVH(BbdwRLRAyRW#0#Uv&2Dzz-S>9>&VRGe`m)Xl=@AEwGJ@?#=yKy(-2_eK6g%DpALOdXZnD$@PgZzI1e98Koq=pdU^C8$2 zAw>DPtHM6?f#^6qV($IT8Pl=v^sU+-(gEt!$9m&GWH@@>v1rh6yctHlgN9>ZNZ&j1 zC$r{m)nXv8MMwVnaTPS{C~VeHXwy(+)3Dg4p?FP`)fuW0Dltzqv${oL_C==)u6Q7?peY(k9)E%DM`r`W%)*L0Wf z5=gO|cxbta`>IWRquRtbt4vIxDh+(A(!l){1|Fy|@a=K~-zhin-7*6Ymg#tCsgCb0 z)$#DNI=)}3;|HZ0ez;^@Q%f}bs6@q&i&acptm2WyDt=N#1@P0t03Iz2;AaH^{5(H^ z$MOUCMP2}p=lPLb=tse_09LlC*t}a)-a4uE_yVFv2re%&kTTl>w+mAeHV zZ8Y)q8WZ={+!^rGbNxul^<~gsx3WbIE_*(C!l%6~@U_~>1OH^U zf|M);`IUZb-l-~A{%qQKKw23i1`iM(c3k#+@{OvpV zbA8ydJ)r&LBV$$6RlvQ%bXT%XBn14^89vO(@L|P^ejM%BejoKfXjm(2F+ zZjo^C^XB`|*yxATsc(&VLj-W|zakO`Fj0>K7{%Zfk8W1o1!nt0o zdS1cXu^7NYMm+G*4m^z9YEJ72CiK9w!e`F)Vs*o97$6Itr+qjB+D0H1J}WI`fFlM2 zSjLhC&r}!Afc6nch0jVG!;AGX7{KBsYk0EYndc)JAQL`&&TSaL0;Vi@UUI@2@DdG> z2)}5~7=Br&U?7?SEM2pDD+``CyhsMfgwJ_;3~ORC;1mroq`~ug8_58f@VTjDSR1PW ztlP_iXQ~Tpz%Zo3=cSIJ@wpfcVADbtJX2jH17yPI&!z!B3?7YQ0NYgT*vNusstaep z%fpZgUohK)rrKByXfdS0Gu2^4wesUv!;lJJILm`|u^PZ$oh*2!y2u8|gD;xpfvv`e z!6Q)(FdS@B%7SOA!*F;5WWq0g$^%=q5AQ@XfNg0SAPb(UE}Q`!!;lJJoZ>-qwKp~c z*rJvN&r}!AfX-n^g)d2QV|}a!us<&go~bUZ0ZvGTU-IO*0jFXzz@|xqXR3>2fJ}I* zrHTdwq8Kn}I5?Rh3!bSioB>@E#CII`fG>LM8+6MpH;QEaFj!|6jY8XyZ^cB+d6Up8|T8!KWpph-(O`0^)4(Hg4( z97Rhs_==zbp6CW}2$6Gmi3VRW!xfVOQxBgs`YM{WgoCe~;evgM2WO%fFy*9E-&x

3thEiIComx$@xbsdoVa4b?qZ#Roe~? zGx8G)zGk`$yViPf>Ac>~8AvPp%@9IlZU3EqIjc61Q1HuAMsaYn;^u@qfX`XEZwB|r zp#j5D*Q_QCeC>1>+KN3G-0vTXIxENz5V#FGcd1%xVPe47C%N$3jXwP4qG{(Ckd^tC zpi8~|#)-Nnb<)E>_c))v8^^nqbKKrm*08~8F-}!(d0lfC)c7Yg{K{D_ywT><{&q>X z#q{)V1TRJJT{PQY*`aEgrT$3?-;ne#>}~Yo{CMj7^t~T0_+8MMclDiJzYb{mW&TM8 z&nHj#p}yeFFZWYV{z$=pMCZ<5Fgvz(t4iHUKjtmE^Wit-yMxO<^yG8-Wt)}lwt}gV zXTpJA&Dr^?ikb#LvU8O?3cexP1$({+`&zug1CM*250B%&R-Vrkp%14rKK#(MzuBh` z?bxNdTiXIyzFY}TVAj Date: Mon, 27 Apr 2026 18:24:47 +0800 Subject: [PATCH 03/15] Increase desktop widget opacity --- package-lock.json | 4 ++-- package.json | 2 +- src/main/windows.ts | 7 ++---- src/renderer/index.css | 10 ++++---- src/shared/data/defaults.ts | 13 ++++++----- src/shared/utils/widgets.test.ts | 40 ++++++++++++++++++++++++++++++++ src/shared/utils/widgets.ts | 27 ++++++++++++++++----- 7 files changed, 78 insertions(+), 25 deletions(-) create mode 100644 src/shared/utils/widgets.test.ts diff --git a/package-lock.json b/package-lock.json index fd822cd..318410e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "timetable", - "version": "0.3.5", + "version": "0.3.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "timetable", - "version": "0.3.5", + "version": "0.3.6", "dependencies": { "chinese-days": "^1.5.7", "clsx": "^2.1.1", diff --git a/package.json b/package.json index 28185e1..c6d8618 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "timetable", "productName": "Timetable", "private": false, - "version": "0.3.5", + "version": "0.3.6", "description": "A local Windows desktop planner for courses, tasks, goals, memos, countdowns, and desktop widgets.", "author": "Timetable Local Builder", "main": "out/main/index.js", diff --git a/src/main/windows.ts b/src/main/windows.ts index bce369c..68ca833 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -3,6 +3,7 @@ import { app, BrowserWindow, Menu, nativeImage, screen, shell, Tray } from 'elec import type { Rectangle } from 'electron/main' import type { AppData, WidgetKey } from '@shared/types/app' import type { OverlayWidgetUpdatePayload, WindowStatePayload } from '@shared/ipc' +import { getEffectiveOverlayOpacity } from '@shared/utils/widgets' type OverlayRuntimeState = { hidden: boolean @@ -361,7 +362,7 @@ export class WindowManager { }) window.setIgnoreMouseEvents(false) window.setAlwaysOnTop(data.desktopSettings.overlayMode === 'floating' && data.desktopSettings.alwaysOnTop, 'screen-saver') - window.setOpacity(clampOpacity(config.opacity * data.desktopSettings.opacity)) + window.setOpacity(getEffectiveOverlayOpacity(config.opacity, data.desktopSettings.opacity)) if (runtime.hidden) { runtime.expandedBounds ??= configuredBounds @@ -537,10 +538,6 @@ export class WindowManager { } } -function clampOpacity(value: number): number { - return Math.max(0.2, Math.min(1, value)) -} - function isOverlayUserResizable(key: WidgetKey): boolean { return key !== 'countdown' } diff --git a/src/renderer/index.css b/src/renderer/index.css index e22491d..4d2e093 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -85,14 +85,14 @@ textarea { } .glass-card { - border: 1px solid rgba(255, 255, 255, 0.62); + border: 1px solid rgba(255, 255, 255, 0.36); border-radius: 18px; background: - linear-gradient(180deg, rgba(248, 252, 255, 0.78), rgba(234, 244, 255, 0.58)); + linear-gradient(180deg, rgba(248, 252, 255, 0.96), rgba(234, 244, 255, 0.9)); box-shadow: - 0 14px 34px rgba(19, 35, 63, 0.18), - inset 0 1px 0 rgba(255, 255, 255, 0.64); - backdrop-filter: blur(22px); + 0 14px 34px rgba(19, 35, 63, 0.14), + inset 0 1px 0 rgba(255, 255, 255, 0.42); + backdrop-filter: blur(18px); } .sidebar-link { diff --git a/src/shared/data/defaults.ts b/src/shared/data/defaults.ts index b97892d..b5785cb 100644 --- a/src/shared/data/defaults.ts +++ b/src/shared/data/defaults.ts @@ -2,11 +2,12 @@ import type { AppData, Course, DailyTask, LongTermGoal, Memo, WidgetConfig } fro import { createId } from '@shared/utils/id' import { formatDateKey, getWeekStart } from '@shared/utils/date' import { defaultTimetableSlots } from '@shared/utils/course' +import { DEFAULT_OVERLAY_OPACITY, OVERLAY_OPACITY_VERSION } from '@shared/utils/widgets' import { addDays, format, subDays } from 'date-fns' const cardBase = { enabled: true, - opacity: 0.94, + opacity: DEFAULT_OVERLAY_OPACITY, } function widget(x: number, y: number, width: number, height: number, config?: Partial): WidgetConfig { @@ -258,7 +259,7 @@ export function createDefaultAppData(dataPath: string): AppData { author: '—— 斯多葛学派', displayMode: 'embedded', position: 'bottom-center', - opacity: 0.94, + opacity: DEFAULT_OVERLAY_OPACITY, autoHide: false, autoRotate: false, rotateIntervalSeconds: 60, @@ -267,11 +268,11 @@ export function createDefaultAppData(dataPath: string): AppData { enabled: true, minimized: true, position: 'bottom-left', - opacity: 0.96, + opacity: DEFAULT_OVERLAY_OPACITY, }, desktopSettings: { overlayEnabled: true, - opacity: 0.96, + opacity: DEFAULT_OVERLAY_OPACITY, scale: 1, alwaysOnTop: true, autoHide: false, @@ -281,7 +282,7 @@ export function createDefaultAppData(dataPath: string): AppData { mainPanel: widget(40, 42, 560, 640), dailyTasks: widget(640, 72, 430, 430, { enabled: false }), memo: widget(640, 520, 420, 380, { enabled: false }), - countdown: widget(40, 710, 390, 54, { minimized: true, opacity: 0.96 }), + countdown: widget(40, 710, 390, 54, { minimized: true }), principle: widget(640, 42, 400, 190, { enabled: false }), }, }, @@ -293,7 +294,7 @@ export function createDefaultAppData(dataPath: string): AppData { browserTrackingEnabled: true, browserTrackingIntervalSeconds: 10, desktopLayoutVersion: 2, - opacityVersion: 2, + opacityVersion: OVERLAY_OPACITY_VERSION, dataPath, termStartDate, termWeekCount: 20, diff --git a/src/shared/utils/widgets.test.ts b/src/shared/utils/widgets.test.ts new file mode 100644 index 0000000..f0f851a --- /dev/null +++ b/src/shared/utils/widgets.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { createDefaultAppData } from '@shared/data/defaults' +import { + DEFAULT_OVERLAY_OPACITY, + getEffectiveOverlayOpacity, + migrateOverlayOpacity, + OVERLAY_OPACITY_VERSION, +} from './widgets' + +describe('overlay widget opacity', () => { + it('keeps card opacity from being compounded by global opacity', () => { + expect(getEffectiveOverlayOpacity(0.98, 0.98)).toBe(0.98) + expect(getEffectiveOverlayOpacity(0.9, 0.98)).toBe(0.9) + }) + + it('raises older desktop card opacity values to the current baseline', () => { + const data = createDefaultAppData('C:/tmp/app-data.json') + const migrated = migrateOverlayOpacity({ + ...data, + desktopSettings: { + ...data.desktopSettings, + opacity: 0.72, + widgets: { + ...data.desktopSettings.widgets, + countdown: { ...data.desktopSettings.widgets.countdown, opacity: 0.68 }, + principle: { ...data.desktopSettings.widgets.principle, opacity: 0.7 }, + }, + }, + appSettings: { + ...data.appSettings, + opacityVersion: 2, + }, + }, 2) + + expect(migrated.desktopSettings.opacity).toBe(DEFAULT_OVERLAY_OPACITY) + expect(migrated.desktopSettings.widgets.countdown.opacity).toBe(DEFAULT_OVERLAY_OPACITY) + expect(migrated.desktopSettings.widgets.principle.opacity).toBe(DEFAULT_OVERLAY_OPACITY) + expect(migrated.appSettings.opacityVersion).toBe(OVERLAY_OPACITY_VERSION) + }) +}) diff --git a/src/shared/utils/widgets.ts b/src/shared/utils/widgets.ts index bf4cb06..82560b0 100644 --- a/src/shared/utils/widgets.ts +++ b/src/shared/utils/widgets.ts @@ -1,5 +1,8 @@ import type { AppData, WidgetConfig, WidgetKey } from '@shared/types/app' +export const OVERLAY_OPACITY_VERSION = 3 +export const DEFAULT_OVERLAY_OPACITY = 0.98 + type WidgetSizeBase = { width: number height: number @@ -88,7 +91,7 @@ export function normalizeCountdownStripWidget(data: AppData): AppData { } export function migrateOverlayOpacity(data: AppData, previousOpacityVersion?: number): AppData { - if (previousOpacityVersion && previousOpacityVersion >= 2) { + if (previousOpacityVersion && previousOpacityVersion >= OVERLAY_OPACITY_VERSION) { return data } @@ -97,7 +100,7 @@ export function migrateOverlayOpacity(data: AppData, previousOpacityVersion?: nu key, { ...config, - opacity: Math.max(config.opacity ?? 0, key === 'countdown' ? 0.96 : 0.94), + opacity: Math.max(config.opacity ?? 0, DEFAULT_OVERLAY_OPACITY), }, ]), ) as Record @@ -106,24 +109,28 @@ export function migrateOverlayOpacity(data: AppData, previousOpacityVersion?: nu ...data, principleCard: { ...data.principleCard, - opacity: Math.max(data.principleCard.opacity ?? 0, 0.94), + opacity: Math.max(data.principleCard.opacity ?? 0, DEFAULT_OVERLAY_OPACITY), }, countdownCard: { ...data.countdownCard, - opacity: Math.max(data.countdownCard.opacity ?? 0, 0.96), + opacity: Math.max(data.countdownCard.opacity ?? 0, DEFAULT_OVERLAY_OPACITY), }, desktopSettings: { ...data.desktopSettings, - opacity: Math.max(data.desktopSettings.opacity ?? 0, 0.96), + opacity: Math.max(data.desktopSettings.opacity ?? 0, DEFAULT_OVERLAY_OPACITY), widgets, }, appSettings: { ...data.appSettings, - opacityVersion: 2, + opacityVersion: OVERLAY_OPACITY_VERSION, }, } } +export function getEffectiveOverlayOpacity(widgetOpacity?: number, desktopOpacity?: number): number { + return clamp(Math.min(normalizeOpacity(widgetOpacity), normalizeOpacity(desktopOpacity)), 0.2, 1) +} + export function migrateDesktopThreePieceLayout(data: AppData, previousLayoutVersion?: number): AppData { if (previousLayoutVersion && previousLayoutVersion >= 2) { return data @@ -169,3 +176,11 @@ export function migrateDesktopThreePieceLayout(data: AppData, previousLayoutVers function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)) } + +function normalizeOpacity(value?: number): number { + if (typeof value !== 'number' || Number.isNaN(value)) { + return 1 + } + + return clamp(value, 0, 1) +} From 7ce1bce228c5af90b611933eccf70acbf57b1dde Mon Sep 17 00:00:00 2001 From: Evander Date: Mon, 27 Apr 2026 19:10:03 +0800 Subject: [PATCH 04/15] Add course reminders and stabilize desktop widgets --- package-lock.json | 4 +- package.json | 2 +- src/main/courseReminder.test.ts | 68 +++++++++++++ src/main/courseReminder.ts | 77 +++++++++++++++ src/main/index.ts | 7 ++ src/main/storage.ts | 14 +-- src/main/windows.ts | 26 +++-- src/renderer/pages/BrowserUsagePage.tsx | 10 +- src/renderer/pages/DesktopPanelPage.tsx | 122 ++++++++++++++++++++---- src/renderer/pages/SchedulePage.tsx | 35 ++++++- src/shared/data/defaults.ts | 44 +++++---- src/shared/data/reducer.ts | 18 +++- src/shared/types/app.ts | 4 + src/shared/utils/course.test.ts | 23 ++++- src/shared/utils/course.ts | 31 ++++++ src/shared/utils/widgets.test.ts | 8 ++ src/shared/utils/widgets.ts | 10 ++ 17 files changed, 441 insertions(+), 62 deletions(-) create mode 100644 src/main/courseReminder.test.ts create mode 100644 src/main/courseReminder.ts diff --git a/package-lock.json b/package-lock.json index 318410e..8793b85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "timetable", - "version": "0.3.6", + "version": "0.3.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "timetable", - "version": "0.3.6", + "version": "0.3.7", "dependencies": { "chinese-days": "^1.5.7", "clsx": "^2.1.1", diff --git a/package.json b/package.json index c6d8618..70def3d 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "timetable", "productName": "Timetable", "private": false, - "version": "0.3.6", + "version": "0.3.7", "description": "A local Windows desktop planner for courses, tasks, goals, memos, countdowns, and desktop widgets.", "author": "Timetable Local Builder", "main": "out/main/index.js", diff --git a/src/main/courseReminder.test.ts b/src/main/courseReminder.test.ts new file mode 100644 index 0000000..b21f6ba --- /dev/null +++ b/src/main/courseReminder.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest' +import { createDefaultAppData } from '@shared/data/defaults' +import type { AppData, Course } from '@shared/types/app' + +vi.mock('electron', () => ({ + Notification: { + isSupported: () => true, + }, +})) + +const course: Course = { + id: 'course-1', + name: '算法分析', + teacher: '张老师', + location: 'A301', + dayOfWeek: 2, + startTime: '08:00', + endTime: '09:40', + repeatType: 'weekly', + weekStart: 1, + weekEnd: 16, +} + +function createReminderData(settings: Partial = {}): AppData { + const data = createDefaultAppData('C:/tmp/app-data.json') + return { + ...data, + courses: [course], + appSettings: { + ...data.appSettings, + termStartDate: '2026-03-02', + termWeekCount: 20, + courseReminderEnabled: true, + courseReminderMinutes: 15, + ...settings, + }, + } +} + +describe('CourseReminderService', () => { + it('notifies each course only once in one run', async () => { + const { CourseReminderService } = await import('./courseReminder') + const notify = vi.fn() + const service = new CourseReminderService({ + getData: () => createReminderData(), + notify, + }) + + service.check(new Date('2026-03-10T07:46:00')) + service.check(new Date('2026-03-10T07:47:00')) + + expect(notify).toHaveBeenCalledTimes(1) + expect(notify.mock.calls[0][0].id).toBe('course-1') + }) + + it('does not notify when course reminders are disabled', async () => { + const { CourseReminderService } = await import('./courseReminder') + const notify = vi.fn() + const service = new CourseReminderService({ + getData: () => createReminderData({ courseReminderEnabled: false }), + notify, + }) + + service.check(new Date('2026-03-10T07:46:00')) + + expect(notify).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/courseReminder.ts b/src/main/courseReminder.ts new file mode 100644 index 0000000..b975e31 --- /dev/null +++ b/src/main/courseReminder.ts @@ -0,0 +1,77 @@ +import { Notification } from 'electron' +import type { AppData } from '@shared/types/app' +import { getCourseReminderCandidates, getCourseReminderKey, normalizeCourseReminderMinutes, type CourseOccurrence } from '@shared/utils/course' + +type CourseReminderServices = { + getData: () => AppData + notify?: (course: CourseOccurrence) => void + intervalMs?: number +} + +export class CourseReminderService { + private readonly getData: () => AppData + private readonly notify: (course: CourseOccurrence) => void + private readonly intervalMs: number + private readonly notifiedReminderKeys = new Set() + private timer?: NodeJS.Timeout + + constructor({ getData, notify = showCourseReminderNotification, intervalMs = 30_000 }: CourseReminderServices) { + this.getData = getData + this.notify = notify + this.intervalMs = intervalMs + } + + start(): void { + this.stop() + this.check() + this.timer = setInterval(() => this.check(), this.intervalMs) + } + + stop(): void { + clearInterval(this.timer) + this.timer = undefined + } + + check(now = new Date()): void { + const data = this.getData() + if (!data.appSettings.courseReminderEnabled) { + return + } + + const candidates = getCourseReminderCandidates( + data.courses, + now, + data.appSettings.termStartDate, + data.appSettings.termWeekCount, + normalizeCourseReminderMinutes(data.appSettings.courseReminderMinutes), + ) + + for (const course of candidates) { + const reminderKey = getCourseReminderKey(course) + if (this.notifiedReminderKeys.has(reminderKey)) { + continue + } + + this.notifiedReminderKeys.add(reminderKey) + try { + this.notify(course) + } catch (error) { + console.error('Failed to show course reminder notification.', error) + } + } + } +} + +function showCourseReminderNotification(course: CourseOccurrence): void { + if (!Notification.isSupported()) { + return + } + + const details = [course.location, course.teacher].map((item) => item.trim()).filter(Boolean).join(' · ') + const body = [`${course.startTime} 开始`, details].filter(Boolean).join(' · ') + const notification = new Notification({ + title: `课程提醒:${course.name || '未命名课程'}`, + body, + }) + notification.show() +} diff --git a/src/main/index.ts b/src/main/index.ts index d7d3d9a..faeffe5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2,6 +2,7 @@ import { join } from 'node:path' import { app } from 'electron' import type { OverlayWidgetUpdatePayload } from '@shared/ipc' import { BrowserUsageTracker } from './browserUsageTracker' +import { CourseReminderService } from './courseReminder' import { promptForGithubUpdate } from './githubUpdate' import { registerIpcHandlers } from './ipc' import { AppStorage } from './storage' @@ -11,6 +12,7 @@ import { WindowManager } from './windows' let storage: AppStorage let windows: WindowManager let browserUsageTracker: BrowserUsageTracker +let courseReminderService: CourseReminderService async function bootstrap(): Promise { const dataPath = join(app.getPath('userData'), 'app-data.json') @@ -32,6 +34,9 @@ async function bootstrap(): Promise { recordUsage: (sample, durationSeconds) => storage.recordBrowserUsage(sample, durationSeconds), onDataChanged: (next) => windows.broadcastData(next), }) + courseReminderService = new CourseReminderService({ + getData: () => storage.getData(), + }) registerIpcHandlers({ storage, @@ -43,6 +48,7 @@ async function bootstrap(): Promise { await windows.syncOverlayWindows(storage.getData()) windows.broadcastData(storage.getData()) browserUsageTracker.start() + courseReminderService.start() void promptForGithubUpdate(storage, windows.getMainWindow()).then(() => windows.broadcastData(storage.getData())) } @@ -59,6 +65,7 @@ app.whenReady().then(async () => { app.on('before-quit', async () => { browserUsageTracker?.stop() + courseReminderService?.stop() await storage?.flush() }) diff --git a/src/main/storage.ts b/src/main/storage.ts index 3a129c5..90e9b4f 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -4,7 +4,7 @@ import { dialog, type BrowserWindow } from 'electron' import type { AppData, BrowserPageSample } from '@shared/types/app' import type { BackupInfo, DataAction, ExportDataResult, OverlayWidgetUpdatePayload, SettingsUpdatePayload } from '@shared/ipc' import { createDefaultAppData } from '@shared/data/defaults' -import { applyDataAction, applyOverlayWidgetUpdate, applySettingsUpdate } from '@shared/data/reducer' +import { applyDataAction, applyOverlayWidgetUpdate, applySettingsUpdate, normalizeAppSettings } from '@shared/data/reducer' import { formatDateKey } from '@shared/utils/date' import { createBrowserUsageDaySnapshot, normalizeBrowserPageSample, recordBrowserUsageSample } from '@shared/utils/browserUsage' import { normalizeCourseTimeSlots, normalizeTermWeekCount } from '@shared/utils/course' @@ -310,11 +310,13 @@ export class AppStorage { }, }, appSettings: { - ...defaults.appSettings, - ...raw.appSettings, - dataPath: this.filePath, - termWeekCount: normalizeTermWeekCount(raw.appSettings?.termWeekCount ?? defaults.appSettings.termWeekCount), - timetableSlots: normalizeCourseTimeSlots(raw.appSettings?.timetableSlots ?? defaults.appSettings.timetableSlots), + ...normalizeAppSettings({ + ...defaults.appSettings, + ...raw.appSettings, + dataPath: this.filePath, + termWeekCount: normalizeTermWeekCount(raw.appSettings?.termWeekCount ?? defaults.appSettings.termWeekCount), + timetableSlots: normalizeCourseTimeSlots(raw.appSettings?.timetableSlots ?? defaults.appSettings.timetableSlots), + }), }, browserUsage: raw.browserUsage ?? defaults.browserUsage, })) diff --git a/src/main/windows.ts b/src/main/windows.ts index 68ca833..8c8f4f4 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -3,7 +3,7 @@ import { app, BrowserWindow, Menu, nativeImage, screen, shell, Tray } from 'elec import type { Rectangle } from 'electron/main' import type { AppData, WidgetKey } from '@shared/types/app' import type { OverlayWidgetUpdatePayload, WindowStatePayload } from '@shared/ipc' -import { getEffectiveOverlayOpacity } from '@shared/utils/widgets' +import { getEffectiveOverlayOpacity, normalizeDesktopAutoHideDelayMs } from '@shared/utils/widgets' type OverlayRuntimeState = { hidden: boolean @@ -20,7 +20,6 @@ const rendererHtml = join(__dirname, '../renderer/index.html') const preloadPath = join(__dirname, '../preload/index.mjs') const AUTO_HIDE_THRESHOLD = 40 const AUTO_HIDE_STRIP = 22 -const AUTO_HIDE_COLLAPSE_DELAY_MS = 420 const AUTO_HIDE_POINTER_MARGIN = 8 const PROGRAMMATIC_BOUNDS_SUPPRESS_MS = 180 const MAIN_WINDOW_DEFAULT_WIDTH = 1180 @@ -292,7 +291,8 @@ export class WindowManager { frame: false, transparent: true, hasShadow: false, - resizable: isOverlayUserResizable(key), + resizable: isOverlayUserResizable(key) && !this.isOverlayLayoutLocked(key, data), + movable: isOverlayUserMovable() && !this.isOverlayLayoutLocked(key, data), skipTaskbar: true, show: false, backgroundColor: '#00000000', @@ -305,7 +305,8 @@ export class WindowManager { window.on('will-move', (event, newBounds) => { const runtime = this.overlayState.get(key) - if (runtime?.hidden) { + const currentData = this.latestData ?? data + if (runtime?.hidden || this.isOverlayLayoutLocked(key, currentData)) { event.preventDefault() return } @@ -328,6 +329,12 @@ export class WindowManager { this.queueOverlayBoundsSync(key, undefined, false, 'move') }) window.on('resized', () => { + const currentData = this.latestData ?? data + if (this.isOverlayLayoutLocked(key, currentData)) { + this.setOverlayBounds(key, this.withConfiguredOverlaySize(key, window.getBounds())) + return + } + const runtime = this.overlayState.get(key) const resizedDuringMove = Boolean(runtime?.suppressResizeUntil && Date.now() < runtime.suppressResizeUntil) this.queueOverlayBoundsSync(key, undefined, resizedDuringMove, resizedDuringMove ? 'move' : 'resize') @@ -387,8 +394,9 @@ export class WindowManager { private restoreOverlayWindowConstraints(window: BrowserWindow, key: WidgetKey, data: AppData): void { window.setMinimumSize(getOverlayMinWidth(key), getOverlayMinHeight(key)) - window.setResizable(isOverlayUserResizable(key)) - window.setMovable(isOverlayUserMovable() && !this.isOverlayDragLocked(key, data)) + const layoutLocked = this.isOverlayLayoutLocked(key, data) + window.setResizable(isOverlayUserResizable(key) && !layoutLocked) + window.setMovable(isOverlayUserMovable() && !layoutLocked) } private scheduleOverlayCollapse(key: WidgetKey, data: AppData): void { @@ -433,7 +441,7 @@ export class WindowManager { window.setResizable(false) window.setMovable(false) this.setOverlayBounds(key, getHiddenOverlayBounds(bounds, display, edge), false) - }, AUTO_HIDE_COLLAPSE_DELAY_MS) + }, normalizeDesktopAutoHideDelayMs(data.appSettings.desktopAutoHideDelayMs)) } private withConfiguredOverlaySize(key: WidgetKey, bounds: Rectangle): Rectangle { @@ -532,9 +540,9 @@ export class WindowManager { } } - private isOverlayDragLocked(key: WidgetKey, data: AppData): boolean { + private isOverlayLayoutLocked(key: WidgetKey, data: AppData): boolean { const config = data.desktopSettings.widgets[key] - return data.desktopSettings.dragLocked || Boolean(config.dragLocked) + return data.appSettings.desktopLayoutLockEnabled || data.desktopSettings.dragLocked || Boolean(config.dragLocked) } } diff --git a/src/renderer/pages/BrowserUsagePage.tsx b/src/renderer/pages/BrowserUsagePage.tsx index 5d41195..77e488b 100644 --- a/src/renderer/pages/BrowserUsagePage.tsx +++ b/src/renderer/pages/BrowserUsagePage.tsx @@ -88,16 +88,16 @@ export function BrowserUsagePage() {

- + - +
使用明细
- 按占比排序 · {formatUsageDuration(day.totalSeconds)} + 按总使用时间占比排序 · 总使用 100%
{pages.length ? pages.map((page) => { @@ -120,13 +120,13 @@ export function BrowserUsagePage() {
{usagePercent}%
-
{formatUsageDuration(page.totalSeconds)}
+
占总使用时间
{isWeb ? page.url : '不保存 AI 对话标题或具体链接'} - 占比 {usagePercent}% + 总使用 {usagePercent}%
) diff --git a/src/renderer/pages/DesktopPanelPage.tsx b/src/renderer/pages/DesktopPanelPage.tsx index de4e869..b82cfd9 100644 --- a/src/renderer/pages/DesktopPanelPage.tsx +++ b/src/renderer/pages/DesktopPanelPage.tsx @@ -1,23 +1,36 @@ import type { ReactNode } from 'react' import { useState } from 'react' -import { ChevronDown, LayoutPanelTop, MonitorSmartphone, PanelBottomOpen } from 'lucide-react' +import { ChevronDown, LayoutPanelTop, MonitorSmartphone, PanelBottomOpen, RotateCcw } from 'lucide-react' import { Button } from '@renderer/components/Button' import { Card } from '@renderer/components/Card' import { LoadingState } from '@renderer/components/LoadingState' import { PageHeader } from '@renderer/components/PageHeader' import { Toggle } from '@renderer/components/Toggle' import { useAppStore } from '@renderer/store/appStore' +import { createDefaultDesktopSettings } from '@shared/data/defaults' import type { WidgetKey } from '@shared/types/app' -import { getWidgetScalePercent, resizeWidgetByScale, WIDGET_SIZE_BASES } from '@shared/utils/widgets' +import { + DEFAULT_DESKTOP_AUTO_HIDE_DELAY_MS, + getWidgetScalePercent, + normalizeDesktopAutoHideDelayMs, + resizeWidgetByScale, + WIDGET_SIZE_BASES, +} from '@shared/utils/widgets' const widgetMeta: Record = { - mainPanel: { title: '主面板', desc: '日期与今日课程' }, - dailyTasks: { title: '每日任务卡片', desc: '显示今日完成进度与任务清单' }, - memo: { title: '进行中备忘', desc: '只展示 active 且允许桌面显示的备忘录' }, - countdown: { title: '倒计时卡片', desc: '显示当天剩余时间与任务统计' }, - principle: { title: '道理卡片', desc: '显示核心提醒文案与作者' }, + mainPanel: { title: '主面板', desc: '日期、课程状态和今日行动中心。' }, + dailyTasks: { title: '每日任务卡片', desc: '显示今日完成进度与任务清单。' }, + memo: { title: '进行中备忘', desc: '展示正在进行且允许桌面显示的备忘。' }, + countdown: { title: '倒计时卡片', desc: '显示当天剩余时间与任务统计。' }, + principle: { title: '道理卡片', desc: '显示核心提醒文案与署名。' }, } +const opacityPresets = [ + { label: '清晰', value: 0.98 }, + { label: '半透明', value: 0.88 }, + { label: '极简', value: 0.72 }, +] + export function DesktopPanelPage() { const data = useAppStore((state) => state.data) const updateSettings = useAppStore((state) => state.updateSettings) @@ -28,6 +41,8 @@ export function DesktopPanelPage() { return } + const appData = data + async function setOverlayEnabled(enabled: boolean) { await updateSettings({ desktopSettings: { overlayEnabled: enabled } }, enabled ? '桌面面板已启用。' : '桌面面板已关闭。') if (enabled) { @@ -37,11 +52,44 @@ export function DesktopPanelPage() { } } + function setOpacityPreset(opacity: number) { + const widgets = (Object.keys(appData.desktopSettings.widgets) as WidgetKey[]).reduce((nextWidgets, key) => ({ + ...nextWidgets, + [key]: { + ...appData.desktopSettings.widgets[key], + opacity, + }, + }), {} as typeof appData.desktopSettings.widgets) + + void updateSettings({ desktopSettings: { opacity, widgets } }, '桌面卡片透明度预设已应用。') + } + + function resetDesktopLayout() { + const defaults = createDefaultDesktopSettings() + void updateSettings({ + desktopSettings: { + ...appData.desktopSettings, + opacity: defaults.opacity, + scale: defaults.scale, + autoHide: defaults.autoHide, + dragLocked: defaults.dragLocked, + widgets: defaults.widgets, + }, + appSettings: { + desktopAutoHideDelayMs: DEFAULT_DESKTOP_AUTO_HIDE_DELAY_MS, + desktopLayoutLockEnabled: false, + }, + }, '桌面卡片布局已重置。') + } + + const autoHideDelayMs = normalizeDesktopAutoHideDelayMs(data.appSettings.desktopAutoHideDelayMs) + const layoutLocked = data.appSettings.desktopLayoutLockEnabled || data.desktopSettings.dragLocked + return (
+ } />
void setOverlayEnabled(checked)} />} /> {[ @@ -108,8 +160,42 @@ export function DesktopPanelPage() { value={Math.round(data.desktopSettings.opacity * 100)} onChange={(value) => void updateSettings({ desktopSettings: { opacity: value / 100 } })} /> - void updateSettings({ desktopSettings: { autoHide: checked } }, '贴边隐藏设置已更新。')} /> - void updateSettings({ desktopSettings: { dragLocked: checked } }, '拖拽锁定状态已更新。')} /> +
+ {opacityPresets.map((preset) => ( + + ))} +
+ void updateSettings({ desktopSettings: { autoHide: checked } }, '贴边隐藏设置已更新。')} + /> + void updateSettings({ appSettings: { desktopAutoHideDelayMs: value } })} + /> + void updateSettings({ + appSettings: { desktopLayoutLockEnabled: checked }, + desktopSettings: { dragLocked: checked }, + }, checked ? '桌面卡片已锁定。' : '桌面卡片已解锁。')} + />
@@ -142,7 +228,7 @@ export function DesktopPanelPage() {
位置 {Math.round(config.x)}, {Math.round(config.y)} - 尺寸 {Math.round(config.width)} × {Math.round(config.height)} + 尺寸 {Math.round(config.width)} x {Math.round(config.height)} 透明度 {Math.round(config.opacity * 100)}%
@@ -223,7 +309,7 @@ function ToggleRow({ onChange: (checked: boolean) => void }) { return ( -
+
{label}
{description}
@@ -239,20 +325,24 @@ function SliderRow({ onChange, min = 30, max = 100, + step = 1, + suffix = '%', }: { label: string value: number onChange: (value: number) => void min?: number max?: number + step?: number + suffix?: string }) { return (
{label}
-
{value}%
+
{value}{suffix}
- onChange(Number(event.target.value))} /> + onChange(Number(event.target.value))} />
) } diff --git a/src/renderer/pages/SchedulePage.tsx b/src/renderer/pages/SchedulePage.tsx index e0ef67b..68bf49c 100644 --- a/src/renderer/pages/SchedulePage.tsx +++ b/src/renderer/pages/SchedulePage.tsx @@ -8,10 +8,11 @@ import { EmptyState } from '@renderer/components/EmptyState' import { LoadingState } from '@renderer/components/LoadingState' import { PageHeader } from '@renderer/components/PageHeader' import { ProgressBar } from '@renderer/components/ProgressBar' +import { Toggle } from '@renderer/components/Toggle' import { useAppStore } from '@renderer/store/appStore' import type { Course, TimetableSlot } from '@shared/types/app' import { createId } from '@shared/utils/id' -import { defaultTimetableSlots, getCoursesForDate, getNextCourse, getWeekCourses, normalizeCourseTimeSlots, normalizeTermWeekCount } from '@shared/utils/course' +import { defaultTimetableSlots, getCoursesForDate, getNextCourse, getWeekCourses, normalizeCourseReminderMinutes, normalizeCourseTimeSlots, normalizeTermWeekCount } from '@shared/utils/course' import { getAcademicWeek, getMonthDayLabel, getWeekDays, parseTimeToMinutes } from '@shared/utils/date' const pixelsPerHour = 68 @@ -52,6 +53,7 @@ export function SchedulePage() { const timetableSlots = normalizeCourseTimeSlots(data.appSettings.timetableSlots) const termWeekCount = normalizeTermWeekCount(data.appSettings.termWeekCount) + const reminderMinutes = normalizeCourseReminderMinutes(data.appSettings.courseReminderMinutes) const startHour = Math.floor(parseTimeToMinutes(timetableSlots[0].startTime) / 60) const endHour = Math.ceil(parseTimeToMinutes(timetableSlots[timetableSlots.length - 1].endTime) / 60) const timeSlots = Array.from({ length: endHour - startHour }, (_, index) => startHour + index) @@ -336,6 +338,37 @@ export function SchedulePage() { onChange={(event) => void updateSettings({ appSettings: { termWeekCount: Number(event.target.value) } }, '学期总周数已更新。')} /> +
+
+
+
课程提醒
+
默认提前 15 分钟,提醒时间可以自行调整。
+
+ void updateSettings({ appSettings: { courseReminderEnabled: checked } }, checked ? '课程提醒已开启。' : '课程提醒已关闭。')} + /> +
+
+ void updateSettings({ appSettings: { courseReminderMinutes: Number(event.target.value) } })} + /> + void updateSettings({ appSettings: { courseReminderMinutes: Number(event.target.value) } })} + /> +
+
提前 {reminderMinutes} 分钟发送系统通知。
+
diff --git a/src/shared/data/defaults.ts b/src/shared/data/defaults.ts index b5785cb..c8df45a 100644 --- a/src/shared/data/defaults.ts +++ b/src/shared/data/defaults.ts @@ -1,8 +1,8 @@ import type { AppData, Course, DailyTask, LongTermGoal, Memo, WidgetConfig } from '@shared/types/app' import { createId } from '@shared/utils/id' import { formatDateKey, getWeekStart } from '@shared/utils/date' -import { defaultTimetableSlots } from '@shared/utils/course' -import { DEFAULT_OVERLAY_OPACITY, OVERLAY_OPACITY_VERSION } from '@shared/utils/widgets' +import { DEFAULT_COURSE_REMINDER_MINUTES, defaultTimetableSlots } from '@shared/utils/course' +import { DEFAULT_DESKTOP_AUTO_HIDE_DELAY_MS, DEFAULT_OVERLAY_OPACITY, OVERLAY_OPACITY_VERSION } from '@shared/utils/widgets' import { addDays, format, subDays } from 'date-fns' const cardBase = { @@ -23,6 +23,25 @@ function widget(x: number, y: number, width: number, height: number, config?: Pa } } +export function createDefaultDesktopSettings(): AppData['desktopSettings'] { + return { + overlayEnabled: true, + opacity: DEFAULT_OVERLAY_OPACITY, + scale: 1, + alwaysOnTop: true, + autoHide: false, + dragLocked: false, + overlayMode: 'floating', + widgets: { + mainPanel: widget(40, 42, 560, 640), + dailyTasks: widget(640, 72, 430, 430, { enabled: false }), + memo: widget(640, 520, 420, 380, { enabled: false }), + countdown: widget(40, 710, 390, 54, { minimized: true }), + principle: widget(640, 42, 400, 190, { enabled: false }), + }, + } +} + function buildDefaultCourses(): Course[] { const common = { weekStart: 1, weekEnd: 20 } return [ @@ -270,22 +289,7 @@ export function createDefaultAppData(dataPath: string): AppData { position: 'bottom-left', opacity: DEFAULT_OVERLAY_OPACITY, }, - desktopSettings: { - overlayEnabled: true, - opacity: DEFAULT_OVERLAY_OPACITY, - scale: 1, - alwaysOnTop: true, - autoHide: false, - dragLocked: false, - overlayMode: 'floating', - widgets: { - mainPanel: widget(40, 42, 560, 640), - dailyTasks: widget(640, 72, 430, 430, { enabled: false }), - memo: widget(640, 520, 420, 380, { enabled: false }), - countdown: widget(40, 710, 390, 54, { minimized: true }), - principle: widget(640, 42, 400, 190, { enabled: false }), - }, - }, + desktopSettings: createDefaultDesktopSettings(), appSettings: { autoSave: true, launchAtStartup: false, @@ -299,6 +303,10 @@ export function createDefaultAppData(dataPath: string): AppData { termStartDate, termWeekCount: 20, timetableSlots: defaultTimetableSlots, + courseReminderEnabled: true, + courseReminderMinutes: DEFAULT_COURSE_REMINDER_MINUTES, + desktopAutoHideDelayMs: DEFAULT_DESKTOP_AUTO_HIDE_DELAY_MS, + desktopLayoutLockEnabled: false, autoBackupEnabled: true, autoCheckForUpdates: true, }, diff --git a/src/shared/data/reducer.ts b/src/shared/data/reducer.ts index 2f25808..c886a74 100644 --- a/src/shared/data/reducer.ts +++ b/src/shared/data/reducer.ts @@ -1,6 +1,8 @@ -import type { AppData, WidgetKey } from '@shared/types/app' +import type { AppData, AppSettings, WidgetKey } from '@shared/types/app' import type { DataAction, OverlayWidgetUpdatePayload, SettingsUpdatePayload } from '@shared/ipc' +import { normalizeCourseReminderMinutes } from '@shared/utils/course' import { advanceGoalStage } from '@shared/utils/goals' +import { normalizeDesktopAutoHideDelayMs } from '@shared/utils/widgets' function upsertById(items: T[], value: T): T[] { const index = items.findIndex((item) => item.id === value.id) @@ -91,10 +93,10 @@ export function applySettingsUpdate(data: AppData, payload: SettingsUpdatePayloa if (payload.appSettings) { nextData = { ...nextData, - appSettings: { + appSettings: normalizeAppSettings({ ...nextData.appSettings, ...payload.appSettings, - }, + }), } } @@ -121,6 +123,16 @@ export function applySettingsUpdate(data: AppData, payload: SettingsUpdatePayloa return nextData } +export function normalizeAppSettings(settings: AppSettings): AppSettings { + return { + ...settings, + courseReminderEnabled: settings.courseReminderEnabled !== false, + courseReminderMinutes: normalizeCourseReminderMinutes(settings.courseReminderMinutes), + desktopAutoHideDelayMs: normalizeDesktopAutoHideDelayMs(settings.desktopAutoHideDelayMs), + desktopLayoutLockEnabled: Boolean(settings.desktopLayoutLockEnabled), + } +} + export function applyOverlayWidgetUpdate(data: AppData, payload: OverlayWidgetUpdatePayload): AppData { const key = payload.key as WidgetKey return { diff --git a/src/shared/types/app.ts b/src/shared/types/app.ts index 776785b..b451360 100644 --- a/src/shared/types/app.ts +++ b/src/shared/types/app.ts @@ -196,6 +196,10 @@ export type AppSettings = { termStartDate: string termWeekCount: number timetableSlots: TimetableSlot[] + courseReminderEnabled: boolean + courseReminderMinutes: number + desktopAutoHideDelayMs: number + desktopLayoutLockEnabled: boolean desktopLayoutVersion?: number opacityVersion?: number autoBackupEnabled: boolean diff --git a/src/shared/utils/course.test.ts b/src/shared/utils/course.test.ts index 17a6a79..4f27d7a 100644 --- a/src/shared/utils/course.test.ts +++ b/src/shared/utils/course.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { Course } from '@shared/types/app' -import { doesCourseAppear, getCoursesForDate, getNextCourse } from './course' +import { doesCourseAppear, getCourseReminderCandidates, getCoursesForDate, getNextCourse, normalizeCourseReminderMinutes } from './course' const course: Course = { id: 'course-1', @@ -36,4 +36,25 @@ describe('course utils', () => { expect(getCoursesForDate(courses, tuesday, termStartDate)).toHaveLength(1) expect(getNextCourse(courses, thursday, termStartDate)?.name).toBe('操作系统') }) + it('normalizes course reminder lead time', () => { + expect(normalizeCourseReminderMinutes(undefined)).toBe(15) + expect(normalizeCourseReminderMinutes(0)).toBe(1) + expect(normalizeCourseReminderMinutes(121)).toBe(120) + expect(normalizeCourseReminderMinutes(5.4)).toBe(5) + }) + + it('finds reminder candidates only before valid current-day courses', () => { + const termStartDate = '2026-03-02' + const now = new Date('2026-03-10T07:46:00') + const courses: Course[] = [ + course, + { ...course, id: 'course-2', startTime: '08:30', endTime: '09:10' }, + { ...course, id: 'course-3', repeatType: 'odd' }, + ] + + const candidates = getCourseReminderCandidates(courses, now, termStartDate, 20, 15) + expect(candidates.map((item) => item.id)).toEqual(['course-1']) + expect(getCourseReminderCandidates(courses, new Date('2026-03-10T08:00:00'), termStartDate, 20, 15)).toHaveLength(0) + expect(getCourseReminderCandidates(courses, now, termStartDate, 1, 15)).toHaveLength(0) + }) }) diff --git a/src/shared/utils/course.ts b/src/shared/utils/course.ts index 28d9fda..b850af0 100644 --- a/src/shared/utils/course.ts +++ b/src/shared/utils/course.ts @@ -20,6 +20,8 @@ export type TodayCourseStatus = { remainingCourses: CourseOccurrence[] } +export const DEFAULT_COURSE_REMINDER_MINUTES = 15 + export const defaultTimetableSlots: TimetableSlot[] = [ { id: 'morning-1', section: '上午', label: '第1节', startTime: '08:30', endTime: '09:10' }, { id: 'morning-2', section: '上午', label: '第2节', startTime: '09:15', endTime: '09:55' }, @@ -42,6 +44,14 @@ export function normalizeTermWeekCount(value: unknown): number { return Math.min(40, Math.max(1, Math.round(parsed))) } +export function normalizeCourseReminderMinutes(value: unknown): number { + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + return DEFAULT_COURSE_REMINDER_MINUTES + } + return Math.min(120, Math.max(1, Math.round(parsed))) +} + export function normalizeCourseTimeSlots(slots: unknown): TimetableSlot[] { if (!Array.isArray(slots)) { return defaultTimetableSlots @@ -141,6 +151,27 @@ export function getTodayCourseStatus(courses: Course[], now: Date, termStartDate } } +export function getCourseReminderCandidates( + courses: Course[], + now: Date, + termStartDate: string, + termWeekCount = 20, + reminderMinutes = DEFAULT_COURSE_REMINDER_MINUTES, +): CourseOccurrence[] { + const reminderWindowMs = normalizeCourseReminderMinutes(reminderMinutes) * 60 * 1000 + const nowTime = now.getTime() + return getCoursesForDate(courses, now, termStartDate, termWeekCount) + .filter((course) => { + const startTime = combineDateAndTime(now, course.startTime).getTime() + const untilStart = startTime - nowTime + return untilStart > 0 && untilStart <= reminderWindowMs + }) +} + +export function getCourseReminderKey(course: CourseOccurrence): string { + return `${course.dateKey}:${course.id}:${course.startTime}` +} + export function getWeekCourses( courses: Course[], anchorDate: Date, diff --git a/src/shared/utils/widgets.test.ts b/src/shared/utils/widgets.test.ts index f0f851a..75f19a0 100644 --- a/src/shared/utils/widgets.test.ts +++ b/src/shared/utils/widgets.test.ts @@ -4,6 +4,7 @@ import { DEFAULT_OVERLAY_OPACITY, getEffectiveOverlayOpacity, migrateOverlayOpacity, + normalizeDesktopAutoHideDelayMs, OVERLAY_OPACITY_VERSION, } from './widgets' @@ -37,4 +38,11 @@ describe('overlay widget opacity', () => { expect(migrated.desktopSettings.widgets.principle.opacity).toBe(DEFAULT_OVERLAY_OPACITY) expect(migrated.appSettings.opacityVersion).toBe(OVERLAY_OPACITY_VERSION) }) + + it('normalizes desktop auto-hide delay', () => { + expect(normalizeDesktopAutoHideDelayMs(undefined)).toBe(800) + expect(normalizeDesktopAutoHideDelayMs(100)).toBe(300) + expect(normalizeDesktopAutoHideDelayMs(3200)).toBe(3000) + expect(normalizeDesktopAutoHideDelayMs(1250.4)).toBe(1250) + }) }) diff --git a/src/shared/utils/widgets.ts b/src/shared/utils/widgets.ts index 82560b0..48c0b1c 100644 --- a/src/shared/utils/widgets.ts +++ b/src/shared/utils/widgets.ts @@ -2,6 +2,7 @@ import type { AppData, WidgetConfig, WidgetKey } from '@shared/types/app' export const OVERLAY_OPACITY_VERSION = 3 export const DEFAULT_OVERLAY_OPACITY = 0.98 +export const DEFAULT_DESKTOP_AUTO_HIDE_DELAY_MS = 800 type WidgetSizeBase = { width: number @@ -131,6 +132,15 @@ export function getEffectiveOverlayOpacity(widgetOpacity?: number, desktopOpacit return clamp(Math.min(normalizeOpacity(widgetOpacity), normalizeOpacity(desktopOpacity)), 0.2, 1) } +export function normalizeDesktopAutoHideDelayMs(value: unknown): number { + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + return DEFAULT_DESKTOP_AUTO_HIDE_DELAY_MS + } + + return clamp(Math.round(parsed), 300, 3000) +} + export function migrateDesktopThreePieceLayout(data: AppData, previousLayoutVersion?: number): AppData { if (previousLayoutVersion && previousLayoutVersion >= 2) { return data From ae2b5fd9d22478cf2fa53f2cf72b0f441c3b7790 Mon Sep 17 00:00:00 2001 From: Evander Date: Mon, 27 Apr 2026 19:19:28 +0800 Subject: [PATCH 05/15] Clarify browser usage share display --- electron.vite.config.ts | 6 ++++++ src/preload/global.d.ts | 2 ++ src/renderer/components/Sidebar.tsx | 2 +- src/renderer/pages/BrowserUsagePage.tsx | 22 +++++++++++----------- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 6ba0f51..6027c32 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -1,8 +1,11 @@ +import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import { defineConfig, externalizeDepsPlugin } from 'electron-vite' +const packageJson = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf-8')) as { version: string } + export default defineConfig({ main: { plugins: [externalizeDepsPlugin()], @@ -26,6 +29,9 @@ export default defineConfig({ root: resolve(__dirname, 'src/renderer'), publicDir: resolve(__dirname, 'public'), plugins: [react(), tailwindcss()], + define: { + __APP_VERSION__: JSON.stringify(packageJson.version), + }, resolve: { alias: { '@renderer': resolve(__dirname, 'src/renderer'), diff --git a/src/preload/global.d.ts b/src/preload/global.d.ts index 1c48d6a..5781d16 100644 --- a/src/preload/global.d.ts +++ b/src/preload/global.d.ts @@ -1,6 +1,8 @@ import type { TimetableApi } from '@shared/ipc' declare global { + const __APP_VERSION__: string + interface Window { timeable: TimetableApi } diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index cb1cf24..562cf51 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -33,7 +33,7 @@ export function Sidebar() {
-
Timetable v0.3.3
+
Timetable v{__APP_VERSION__}
专注当下,持续成长 diff --git a/src/renderer/pages/BrowserUsagePage.tsx b/src/renderer/pages/BrowserUsagePage.tsx index 77e488b..e10fe4a 100644 --- a/src/renderer/pages/BrowserUsagePage.tsx +++ b/src/renderer/pages/BrowserUsagePage.tsx @@ -73,7 +73,7 @@ export function BrowserUsagePage() { } label="AI 占比" value={`${aiPercent}%`} />
-
+
@@ -87,16 +87,16 @@ export function BrowserUsagePage() { setSelectedDate(event.target.value)} />
- +
- +
-
使用明细
+
占比明细
按总使用时间占比排序 · 总使用 100%
@@ -107,25 +107,25 @@ export function BrowserUsagePage() { const displayDomain = getUsageDisplayDomain(page) const usagePercent = getUsagePercent(page.totalSeconds, day.totalSeconds) return ( -
-
+
+
{isWeb ? '网页' : 'AI 应用'} / - {isWeb ? page.browser : '仅统计使用时长'} + {isWeb ? page.browser : '占总使用时间'}
{displayTitle}
{displayDomain}
-
+
{usagePercent}%
-
占总使用时间
+
总占比
- +
- {isWeb ? page.url : '不保存 AI 对话标题或具体链接'} + {isWeb ? page.url : '不保存 AI 对话标题或具体链接'} 总使用 {usagePercent}%
From 38abced93e96454765465ef774d14466aec71da4 Mon Sep 17 00:00:00 2001 From: Evander Date: Mon, 27 Apr 2026 19:24:58 +0800 Subject: [PATCH 06/15] Collapse browser usage detail list --- package-lock.json | 4 +-- package.json | 2 +- src/renderer/pages/BrowserUsagePage.tsx | 45 +++++++++++++++++++++---- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8793b85..682ae92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "timetable", - "version": "0.3.7", + "version": "0.3.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "timetable", - "version": "0.3.7", + "version": "0.3.8", "dependencies": { "chinese-days": "^1.5.7", "clsx": "^2.1.1", diff --git a/package.json b/package.json index 70def3d..211991c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "timetable", "productName": "Timetable", "private": false, - "version": "0.3.7", + "version": "0.3.8", "description": "A local Windows desktop planner for courses, tasks, goals, memos, countdowns, and desktop widgets.", "author": "Timetable Local Builder", "main": "out/main/index.js", diff --git a/src/renderer/pages/BrowserUsagePage.tsx b/src/renderer/pages/BrowserUsagePage.tsx index e10fe4a..81ea02a 100644 --- a/src/renderer/pages/BrowserUsagePage.tsx +++ b/src/renderer/pages/BrowserUsagePage.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react' import { useMemo, useState } from 'react' -import { Bot, CalendarDays, Clock3, Globe2, Save } from 'lucide-react' +import { Bot, CalendarDays, ChevronDown, Clock3, Globe2, Save } from 'lucide-react' import { Button } from '@renderer/components/Button' import { Card } from '@renderer/components/Card' import { EmptyState } from '@renderer/components/EmptyState' @@ -20,11 +20,14 @@ import { } from '@shared/utils/browserUsage' import { formatDateKey } from '@shared/utils/date' +const collapsedDetailCount = 3 + export function BrowserUsagePage() { const data = useAppStore((state) => state.data) const updateSettings = useAppStore((state) => state.updateSettings) const saveBrowserUsageDay = useAppStore((state) => state.saveBrowserUsageDay) const [selectedDate, setSelectedDate] = useState(() => formatDateKey(new Date())) + const [detailsExpanded, setDetailsExpanded] = useState(false) const todayKey = formatDateKey(new Date()) const pages = useMemo(() => data ? getBrowserUsagePages(data, selectedDate) : [], [data, selectedDate]) const webPages = useMemo(() => pages.filter((page) => getUsageEntryType(page) === 'web'), [pages]) @@ -36,11 +39,18 @@ export function BrowserUsagePage() { const webPercent = day ? getUsagePercent(webTotalSeconds, day.totalSeconds) : 0 const aiPercent = day ? getUsagePercent(aiTotalSeconds, day.totalSeconds) : 0 const topPercent = day && topEntry ? getUsagePercent(topEntry.totalSeconds, day.totalSeconds) : 0 + const visiblePages = detailsExpanded ? pages : pages.slice(0, collapsedDetailCount) + const hiddenPageCount = Math.max(0, pages.length - visiblePages.length) if (!data || !day) { return } + function selectDate(date: string) { + setSelectedDate(date) + setDetailsExpanded(false) + } + return (
默认查看今天
- setSelectedDate(event.target.value)} /> + selectDate(event.target.value)} />
@@ -95,12 +105,26 @@ export function BrowserUsagePage() { -
-
占比明细
- 按总使用时间占比排序 · 总使用 100% +
+
+
占比明细
+
+ 按总使用时间占比排序,{detailsExpanded ? `已展开全部 ${pages.length} 项。` : `默认显示前 ${Math.min(collapsedDetailCount, pages.length)} 项。`} +
+
+ {pages.length > collapsedDetailCount ? ( + + ) : null}
- {pages.length ? pages.map((page) => { + {pages.length ? visiblePages.map((page) => { const usageType = getUsageEntryType(page) const isWeb = usageType === 'web' const displayTitle = getUsageDisplayTitle(page) @@ -133,6 +157,15 @@ export function BrowserUsagePage() { }) : ( )} + {hiddenPageCount > 0 ? ( + + ) : null}
From c5517010b9dfe37e4f61734ad51b788cceb310d0 Mon Sep 17 00:00:00 2001 From: Evander Date: Wed, 29 Apr 2026 23:53:22 +0800 Subject: [PATCH 07/15] Restore widget interactions and countdown events --- src/main/index.ts | 2 +- src/main/storage.test.ts | 35 ++++- src/main/storage.ts | 27 +++- src/main/windows.ts | 44 +++++- src/renderer/components/Sidebar.tsx | 5 +- src/renderer/index.css | 73 +++++++++ src/renderer/overlay/CountdownWidget.tsx | 45 ++++-- src/renderer/overlay/OverlayFrame.tsx | 122 ++++++++++++++- src/renderer/pages/BrowserUsagePage.tsx | 15 +- src/renderer/pages/CountdownPage.tsx | 186 ++++++++++++++++++++++- src/renderer/pages/DesktopPanelPage.tsx | 37 ++++- src/renderer/pages/SettingsPage.tsx | 20 ++- src/shared/data/defaults.ts | 1 + src/shared/data/reducer.test.ts | 23 ++- src/shared/data/reducer.ts | 4 + src/shared/ipc.ts | 2 + src/shared/types/app.ts | 11 ++ src/shared/utils/countdownEvents.test.ts | 66 ++++++++ src/shared/utils/countdownEvents.ts | 135 ++++++++++++++++ 19 files changed, 809 insertions(+), 44 deletions(-) create mode 100644 src/shared/utils/countdownEvents.test.ts create mode 100644 src/shared/utils/countdownEvents.ts diff --git a/src/main/index.ts b/src/main/index.ts index faeffe5..15c4f51 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -27,7 +27,7 @@ async function bootstrap(): Promise { windows = new WindowManager(async (payload: OverlayWidgetUpdatePayload) => { const next = await storage.updateWidget(payload) windows.broadcastData(next) - }, () => storage.getData()) + }, () => storage.getData(), () => storage.flush()) browserUsageTracker = new BrowserUsageTracker({ getData: () => storage.getData(), diff --git a/src/main/storage.test.ts b/src/main/storage.test.ts index 57c8420..d30a111 100644 --- a/src/main/storage.test.ts +++ b/src/main/storage.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -44,4 +44,37 @@ describe('AppStorage', () => { expect(saved.memos[0].status).toBe('ended') expect(saved.appSettings.lastSavedAt).toBeTruthy() }) + + it('normalizes old data without countdown events', async () => { + const { AppStorage } = await import('./storage') + const { createDefaultAppData } = await import('@shared/data/defaults') + const filePath = join(tempDir, 'legacy-app-data.json') + const legacyData = createDefaultAppData(filePath) + const legacyWithoutEvents: Partial = { ...legacyData } + delete legacyWithoutEvents.countdownEvents + await writeFile(filePath, JSON.stringify(legacyWithoutEvents), 'utf-8') + + const storage = new AppStorage(filePath) + const initial = await storage.initialize() + + expect(initial.countdownEvents).toEqual([]) + }) + + it('migrates legacy global auto hide into widget settings', async () => { + const { AppStorage } = await import('./storage') + const { createDefaultAppData } = await import('@shared/data/defaults') + const filePath = join(tempDir, 'legacy-auto-hide.json') + const legacyData = createDefaultAppData(filePath) + legacyData.desktopSettings.autoHide = true + Object.values(legacyData.desktopSettings.widgets).forEach((widget) => { + delete widget.autoHide + }) + await writeFile(filePath, JSON.stringify(legacyData), 'utf-8') + + const storage = new AppStorage(filePath) + const initial = await storage.initialize() + + expect(initial.desktopSettings.autoHide).toBe(false) + expect(Object.values(initial.desktopSettings.widgets).every((widget) => widget.autoHide === true)).toBe(true) + }) }) diff --git a/src/main/storage.ts b/src/main/storage.ts index 90e9b4f..a2b2d72 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -286,13 +286,14 @@ export class AppStorage { const defaults = createDefaultAppData(this.filePath) const previousLayoutVersion = raw.appSettings?.desktopLayoutVersion const previousOpacityVersion = raw.appSettings?.opacityVersion - const normalized = normalizeCountdownStripWidget(migrateLegacyDesktopScale({ + const normalized = migrateLegacyWidgetAutoHide(normalizeCountdownStripWidget(migrateLegacyDesktopScale({ ...defaults, ...raw, courses: raw.courses ?? defaults.courses, dailyTasks: raw.dailyTasks ?? defaults.dailyTasks, longTermGoals: raw.longTermGoals ?? defaults.longTermGoals, memos: raw.memos ?? defaults.memos, + countdownEvents: raw.countdownEvents ?? defaults.countdownEvents, principleCard: normalizePrincipleCard({ ...defaults.principleCard, ...raw.principleCard, @@ -319,7 +320,7 @@ export class AppStorage { }), }, browserUsage: raw.browserUsage ?? defaults.browserUsage, - })) + })), raw) return migrateOverlayOpacity(migrateDesktopThreePieceLayout(normalized, previousLayoutVersion), previousOpacityVersion) } @@ -355,6 +356,28 @@ export class AppStorage { } } +function migrateLegacyWidgetAutoHide(data: AppData, raw: Partial): AppData { + if (raw.desktopSettings?.autoHide !== true) { + return data + } + + const rawWidgets = raw.desktopSettings.widgets ?? {} + const widgets = Object.fromEntries(Object.entries(data.desktopSettings.widgets).map(([key, config]) => { + const rawConfig = rawWidgets[key as keyof typeof rawWidgets] + const autoHide = typeof rawConfig?.autoHide === 'boolean' ? rawConfig.autoHide : true + return [key, { ...config, autoHide }] + })) as AppData['desktopSettings']['widgets'] + + return { + ...data, + desktopSettings: { + ...data.desktopSettings, + autoHide: false, + widgets, + }, + } +} + function formatBackupTimestamp(date: Date): string { const pad = (value: number) => value.toString().padStart(2, '0') return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}` diff --git a/src/main/windows.ts b/src/main/windows.ts index 8c8f4f4..b89519f 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -27,6 +27,8 @@ const MAIN_WINDOW_DEFAULT_HEIGHT = 760 const MAIN_WINDOW_MIN_WIDTH = 1024 const MAIN_WINDOW_MIN_HEIGHT = 660 const MAIN_WINDOW_SCREEN_MARGIN = 64 +const EMBEDDED_TRAY_ICON = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAARNSURBVFhHxVf/U1RVFN+f6l/oj9jdt0Fo7ttddhLYVgzbpjVUHEoKJEoRMgMl5YuWEUboMiAWJRaRopmLOI6wghLgl7CaSKaxNPui2KDbWjr5w6c5B95r37vv7Sw2DWfmM3PvPeedc+6958t9FkuSFAwGH5Akt89qd4askhyxSfKE1S7HCDyW5AjxSIZk9d/fN6WleR+yOlzNVskZtdqdSAok63A107d6fUmT3+9/0CbJNTZJvi0YSBL0LekgXXr9CYk8t9nlYb3C+wXpSvo0rCluu83uvKpX8l9BOm2pHpvenoamd56ccWf6Irz5QRde3d4k8MxAuk1Pgu98Fsfe2PU5xu6A8VxppcA3A9kwjAkKFr1wImxt26c6MPjbTSwvWifImIFsaYzz0c8y2qUUD0o2bsXSVS9hWWEpTl+PwpOxRJAzAtnSXAXnuYGgAq/vKeQWrMGKojJkB/PhSHELMlVNbXwt/kAesnKWkRFBRgOHq5mNU9UyKzIZ2UvxyfAY+n+aRMfgGbT3DeHQhYvIfaGU+b4ly7El1I7D33yPruELOHXtFnZ0fobwt5cwdCPG45Q0r6CXITmjXDFnyqsg8PAjXhy/9AvWVtfD7nBpeIuDz6Kt9yQiV2+gtrUDgRWFvOOGjw+htLqeZea7fNh36ize/uigoFsB2bZwbTdg5q0ux+GvJjRr5BSlXv+VSZTV7RB2V1BehT3HBtT5Ao8fpyejwgbiELLMNBE9g1Prw/5hdb7o6XyEx3/AroO9mCdnCfIE12NPYODXKXVOgTp6664gp0KSI5R+EwLD7oQn80mM3LyDtAUZfMSkuKBskyCnx7nY3+qOKXC7z48LMgrItmW6pYpMwnvHB1G3uwMnf/6dlen5FJTtJ4Y0ayQre7N53Dl0HsUVdcJ3/0KOJXSA7pAUmhUZIwd6vvuR07B8WyNnRuJ0lP8wvQLCG+93crT3XLyMzMW5At8IfZevIT0rwA54fQGBH4/pKzAJwkfdj3MAzXNmYnVFDadczjOrBDk9Rqb+ROr8hcK6ISgIzdJwZcl6dH4xpplTIFY0NJumFTlNBSh+zUx2BiHTQtR0IIxzt++hpmWvukaZsXdgFN1fjhvGRV5xOfNpTO2arm5k6i92Xi9L4EJkVIrJazJOne5s7J5a+90ZOVwDKB2pSFFZfrGyTo36ynda8frOPTwuXL9Z7ZZtRyOCcbUUmzUjpd/vPHBUXdtQH+Jyq8ypOe3u6edOSM5Q4Xp3fxgtR05wtaQqeCZ6F/kvbxAdUJpRonbsSE3XzCktqeG4F+Zo1inV1tU28AmUbNqGoteq+bRYh0HnFNoxUbIPko2NrdwZ4xVTulG9oCallzeC8CAhSvZJRrulY/909Gs8/0oV1m55C31XrmPN5u2CrBFMn2REs3mUFlfUoiXch13dvQisLBL4Rkj4KFVoTp/lCs3pj4lCc/prFk9z9nOqp//r9/wfQtP3O42Xv4kAAAAASUVORK5CYII=' function loadRoute(window: BrowserWindow, hash: string): Promise { if (process.env.ELECTRON_RENDERER_URL) { @@ -36,6 +38,23 @@ function loadRoute(window: BrowserWindow, hash: string): Promise { return window.loadFile(rendererHtml, { hash: `/${hash}` }) } +function createAppIcon(size: number): Electron.NativeImage { + const candidates = [ + join(__dirname, '../renderer/tray-icon.png'), + join(__dirname, '../renderer/favicon.png'), + join(__dirname, '../renderer/favicon.ico'), + join(__dirname, '../renderer/favicon.svg'), + ] + const fileImage = candidates.map((path) => nativeImage.createFromPath(path)).find((image) => !image.isEmpty()) + const image = fileImage ?? nativeImage.createFromDataURL(EMBEDDED_TRAY_ICON) + + if (image.isEmpty()) { + return image + } + + return image.resize({ width: size, height: size }) +} + export class WindowManager { private mainWindow: BrowserWindow | null = null private tray: Tray | null = null @@ -47,6 +66,7 @@ export class WindowManager { constructor( private readonly onOverlayBoundsChanged: (payload: OverlayWidgetUpdatePayload) => Promise, private readonly getData?: () => AppData, + private readonly flushBeforeQuit?: () => Promise, ) {} async createMainWindow(): Promise { @@ -62,6 +82,7 @@ export class WindowManager { height: initialSize.height, minWidth: initialSize.minWidth, minHeight: initialSize.minHeight, + icon: createAppIcon(32), frame: false, titleBarStyle: 'hidden', backgroundColor: '#EDF4FF', @@ -98,9 +119,7 @@ export class WindowManager { return } - const trayIcon = nativeImage.createFromPath(join(__dirname, '../renderer/tray-icon.png')) - const fallbackIcon = nativeImage.createFromPath(join(__dirname, '../renderer/favicon.svg')) - const image = trayIcon.isEmpty() ? fallbackIcon : trayIcon.resize({ width: 16, height: 16 }) + const image = createAppIcon(16) this.tray = new Tray(image) this.tray.setToolTip('Timetable') this.tray.on('click', () => void this.showMainWindow()) @@ -123,6 +142,7 @@ export class WindowManager { { label: `退出方式:${trayOnlyQuitEnabled ? '仅托盘退出' : '按关闭按钮设置'}`, enabled: false }, { label: `关闭按钮:${trayOnlyQuitEnabled || closeAction === 'hide' ? '隐藏到托盘' : '退出程序'}`, enabled: false }, { type: 'separator' }, + { label: '重启应用', click: () => void this.restartApplication() }, { label: '彻底退出', click: () => void this.quitApplication() }, ]) this.tray.setContextMenu(contextMenu) @@ -229,6 +249,20 @@ export class WindowManager { async quitApplication(): Promise { this.isQuitting = true + await this.flushBeforeQuit?.() + this.tray?.destroy() + this.tray = null + for (const key of [...this.overlayWindows.keys()]) { + this.destroyOverlayWindow(key) + } + this.mainWindow?.destroy() + app.quit() + } + + async restartApplication(): Promise { + this.isQuitting = true + await this.flushBeforeQuit?.() + app.relaunch() this.tray?.destroy() this.tray = null for (const key of [...this.overlayWindows.keys()]) { @@ -264,7 +298,7 @@ export class WindowManager { } const config = data.desktopSettings.widgets[key] - const shouldAutoHide = config.autoHide || data.desktopSettings.autoHide + const shouldAutoHide = Boolean(config.autoHide) if (!shouldAutoHide) { return } @@ -416,7 +450,7 @@ export class WindowManager { } const config = currentData.desktopSettings.widgets[key] - const shouldAutoHide = config.autoHide || currentData.desktopSettings.autoHide + const shouldAutoHide = Boolean(config.autoHide) if (!shouldAutoHide) { return } diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index 562cf51..2197fa1 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -10,10 +10,7 @@ export function Sidebar() {
-
-
Timetable
-
Timetable
-
+
Timetable