From 3a866accd907b03d457a1684bfe127b6f82f3680 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Thu, 2 Jul 2026 15:13:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=A1=8C=E9=9D=A2=E4=BD=93?= =?UTF-8?q?=E9=AA=8C=E4=B8=8E=E7=B3=BB=E7=BB=9F=E9=80=9A=E7=9F=A5=E6=94=B6?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...desktop-release-stabilization-checklist.md | 30 ++++- docs/desktop-project-plan.md | 22 ++++ .../src/components/Layout.desktop.test.ts | 37 +++++- frontend/src/runtime/index.ts | 1 + frontend/src/runtime/notifications.test.ts | 99 ++++++++++++++++ frontend/src/runtime/notifications.ts | 48 ++++++-- .../desktopNotificationManager.test.ts | 19 +++- .../src/session/desktopNotificationManager.ts | 7 +- frontend/src/views/DesktopPreferences.vue | 46 +++++++- frontend/src/views/DesktopServerSettings.vue | 8 ++ frontend/src/views/Login.vue | 28 ++++- frontend/src/views/ProfileSettings.vue | 106 +++++++++++++++++- 12 files changed, 428 insertions(+), 23 deletions(-) create mode 100644 frontend/src/runtime/notifications.test.ts diff --git a/docs/audits/desktop-release-stabilization-checklist.md b/docs/audits/desktop-release-stabilization-checklist.md index 8eebb598..87b13877 100644 --- a/docs/audits/desktop-release-stabilization-checklist.md +++ b/docs/audits/desktop-release-stabilization-checklist.md @@ -160,7 +160,7 @@ npm run desktop:build:app - 服务器地址切换时,桌面设置页调用 `auth.logout({ rememberCurrentStudy: false })`,避免退出时把旧服务器项目记入当前用户的最近项目;随后继续清除当前项目上下文。 - 系统偏好连接页与独立服务器设置页保持同一切换服务器语义,切换时不记忆旧服务器项目并清除当前项目上下文。 -- 系统通知轮询在部分通知显示失败时,先 ack 已成功显示的通知,再让失败项通过租约重试,贴合“显示成功后 ack;失败等待重试”的回归预期。 +- 系统通知轮询在部分通知显示失败或系统通知未实际发起时,先 ack 已成功发起系统通知的通知,再让失败项通过租约重试,贴合“显示成功后 ack;失败等待重试”的回归预期。 - 自动更新管理器新增稍后提醒 24 小时抑制、安装失败可重试、检查失败不打断业务和未启用更新状态的单元覆盖。 - 附件 API 新增 blob 下载、multipart 上传和删除端点单元覆盖,确保下载凭据继续由 axios Authorization header 承载而不是进入 URL。 - 文件任务反馈 helper 新增选择、保存、取消保存和打开的单元覆盖,约束桌面保存/打开继续走 `frontend/src/runtime/` 适配层。 @@ -168,13 +168,13 @@ npm run desktop:build:app - 桌面发布门禁新增单实例重复启动处理校验,要求重复启动时恢复、显示并聚焦 `main` 窗口。 - 会话刷新后的跨窗口 token 更新不再写入 `localStorage` fallback,只通过内存态 BroadcastChannel 通知,避免 token 进入明文广播缓存。 - `desktop:release:check` 新增 session broadcast 静态门禁,防止 `TOKEN_UPDATED` payload 回退写入 `localStorage`。 -- 新增相关单元测试覆盖服务器切换不记忆旧项目、通知权限未授权不领取、部分通知失败时只 ack 成功项、自动更新失败恢复路径、附件文件流契约、30 天在线会话恢复边界、单实例恢复行为和 token 广播存储边界。 +- 新增相关单元测试覆盖服务器切换不记忆旧项目、通知权限未授权不领取、部分通知失败时只 ack 成功项、系统通知未发起时不 ack、自动更新失败恢复路径、附件文件流契约、30 天在线会话恢复边界、单实例恢复行为和 token 广播存储边界。 仍需人工或真实环境验证: - Keychain/凭据库 30 天在线会话恢复。 - 原生附件上传、下载、保存和打开。 -- 系统通知授权/拒绝的 OS 级交互。 +- 系统通知拒绝路径的 OS 级交互。 - 单实例重复启动聚焦主窗口。 - 签名 release 构建下的自动更新 feed、验签、安装和重启实物流。 @@ -194,3 +194,27 @@ npm run desktop:build:app - 真实生产 release notes 内容保持通用,不写入项目、文件、下载链接或敏感业务详情。 - 正式签名、公证和 updater feed 环境继续使用组织 secret,不在日志、artifact 或配置中泄露私钥材料。 + +## 10. 2026-07-02 桌面体验收口记录 + +本轮体验收口聚焦登录、服务器设置、个人中心诊断、系统偏好和最小窗口布局稳定性,不改变业务能力边界。 + +已完成的体验收口: + +- 个人中心新增只读客户端诊断信息,展示客户端类型、版本、平台、构建通道、提交、服务器和能力状态,并支持复制诊断信息。 +- 登录页在桌面最小窗口附近收紧左右分栏 padding 和卡片宽度,长服务器地址继续在登录面板内换行,不撑破布局。 +- 服务器设置页增加面板内滚动和健康检查 URL 换行约束,避免 `1180x760` 下长 URL 或错误信息溢出。 +- 系统偏好通知/更新控制区允许换行,长状态说明使用 `overflow-wrap`,避免按钮和权限状态标签在窄视口重叠。 +- 个人中心对话框内容区改为内部滚动,诊断值使用强制换行,避免新增诊断信息后超过最小窗口高度。 +- 通知权限运行时改为优先读取 WebView `Notification.permission` 的 `granted`/`denied` 状态;请求权限返回 `default` 时保持“待授权”,不再误标为“已拒绝”。 +- 系统偏好通知页已移除授权成功态的固定标签文案,开启状态只由开关表达;待授权、已拒绝和不可用状态继续显示提示标签。 +- 系统偏好通知页新增“测试通知”命令,完整走 `frontend/src/runtime/notifications.ts` -> `@tauri-apps/plugin-notification` 的系统通知发送链路;测试通知文案为固定通用内容,不包含项目、文件、token 或其他敏感业务信息。 +- `showSystemNotification` 和测试通知调用会在发送前确认系统通知权限,只有真正发起系统通知时才返回成功;桌面通知轮询据此只 ack 已实际发起系统通知的后端通知。 +- 真实 macOS `.app` 已验证系统偏好通知开关可调用系统通知权限并生效。验证截图见 `/Users/zcc/Library/Application Support/CleanShot/media/media_boKznpjOwt/CleanShot 2026-07-02 at 11.07.52@2x.png`。 +- `Layout.desktop.test.ts` 新增静态契约覆盖个人中心诊断、登录页最小窗口断点、服务器设置滚动和偏好页控制区换行约束。 +- `notifications.test.ts` 新增授权、拒绝、待授权、取消授权请求和测试通知发送链路的单元覆盖。 + +仍需人工体验验收: + +- 在真实 macOS `.app` 中以 `1180x760` 检查登录页、服务器设置、个人中心、系统偏好和更新弹窗无重叠、无横向溢出。 +- 在真实系统通知权限拒绝流程中确认权限状态提示、开关回退和错误提示与 OS 状态一致。 diff --git a/docs/desktop-project-plan.md b/docs/desktop-project-plan.md index 9276cc4b..054cb6f0 100644 --- a/docs/desktop-project-plan.md +++ b/docs/desktop-project-plan.md @@ -149,6 +149,28 @@ - 生产 release notes 内容保持通用,不包含项目、文件、下载链接或敏感业务详情。 - 签名、公证、updater feed 和 artifact 上传日志不泄露 Apple 凭据、updater 私钥或下载源内部凭据。 +## 2026-07-02 桌面体验收口推进记录 + +本次体验收口继续保持在线桌面客户端边界,不新增离线、本地业务存储或独立桌面业务 UI。 + +已补齐的桌面体验收口: + +- 个人中心新增客户端诊断信息展示与复制能力,内容仅包含客户端类型、版本、平台、构建通道、提交、服务器和能力状态,不包含 token 或业务敏感数据。 +- 登录页、服务器设置页、个人中心和系统偏好增加最小窗口布局约束,长服务器地址、健康检查 URL、诊断值和更新/通知状态说明均可在容器内换行。 +- 服务器设置页和个人中心在内容高度超过窗口时使用内部滚动,避免 `1180x760` 下对话框或面板溢出。 +- 通知权限运行时已区分 WebView `Notification.permission` 的授权、拒绝和待授权状态;取消 macOS 权限请求时继续保持“待授权”,避免错误显示为“已拒绝”。 +- 系统偏好通知页已移除授权成功态的固定标签文案,开启状态只由开关表达;待授权、已拒绝和不可用状态继续显示提示标签。 +- 系统偏好通知页新增“测试通知”命令,完整走 `frontend/src/runtime/notifications.ts` -> `@tauri-apps/plugin-notification` 的系统通知发送链路;测试通知文案为固定通用内容,不包含项目、文件、token 或其他敏感业务信息。 +- `showSystemNotification` 和测试通知调用会在发送前确认系统通知权限,只有真正发起系统通知时才返回成功;桌面通知轮询据此只 ack 已实际发起系统通知的后端通知。 +- 真实 macOS `.app` 已验证系统偏好通知开关可调用系统通知权限并生效。验证截图位于 `/Users/zcc/Library/Application Support/CleanShot/media/media_boKznpjOwt/CleanShot 2026-07-02 at 11.07.52@2x.png`。 +- `Layout.desktop.test.ts` 已覆盖个人中心诊断、最小窗口断点、服务器设置滚动和偏好页控制区换行契约。 +- `notifications.test.ts` 已覆盖通知权限状态映射和测试通知发送链路。 + +仍需真实桌面环境人工确认: + +- macOS `.app` 在 `1180x760` 下的登录页、服务器设置、个人中心、系统偏好和更新弹窗实际布局。 +- 系统通知权限拒绝后的开关状态、权限提示和错误提示是否与 OS 状态一致。 + 如果后续任务试图新增离线登录、本地业务数据存储、内嵌后端、本地业务队列、离线同步或绕过后端权限审计,应先修改并评审本计划书,不能直接实现。 ## 当前质量门禁 diff --git a/frontend/src/components/Layout.desktop.test.ts b/frontend/src/components/Layout.desktop.test.ts index 59855476..9ca603b6 100644 --- a/frontend/src/components/Layout.desktop.test.ts +++ b/frontend/src/components/Layout.desktop.test.ts @@ -10,6 +10,7 @@ const readDesktopPreferencesSource = () => readFileSync(resolve(__dirname, "../v const readDesktopServerSettingsSource = () => readFileSync(resolve(__dirname, "../views/DesktopServerSettings.vue"), "utf8"); const readDesktopUpdateManagerSource = () => readFileSync(resolve(__dirname, "../session/desktopUpdateManager.ts"), "utf8"); const readProfileSettingsSource = () => readFileSync(resolve(__dirname, "../views/ProfileSettings.vue"), "utf8"); +const readLoginSource = () => readFileSync(resolve(__dirname, "../views/Login.vue"), "utf8"); const readFileTaskFeedbackSource = () => readFileSync(resolve(__dirname, "../utils/fileTaskFeedback.ts"), "utf8"); const readAttachmentListSource = () => readFileSync(resolve(__dirname, "./attachments/AttachmentList.vue"), "utf8"); const readDocumentDetailSource = () => readFileSync(resolve(__dirname, "../views/documents/DocumentDetail.vue"), "utf8"); @@ -108,7 +109,7 @@ describe("desktop layout shell", () => { expect(webLayout).not.toContain('width="720px"'); }); - it("keeps desktop notification and diagnostics settings out of profile settings", () => { + it("keeps profile diagnostics focused while desktop controls stay in preferences", () => { const profile = readProfileSettingsSource(); const preferences = readDesktopPreferencesSource(); @@ -117,8 +118,13 @@ describe("desktop layout shell", () => { expect(profile).not.toContain("getDesktopNotificationSubscription"); expect(profile).not.toContain("setDesktopNotificationSubscription"); expect(profile).not.toContain("checkDesktopUpdateAndPrompt"); - expect(profile).not.toContain("clientMetadataRows"); expect(profile).not.toContain("desktopNotificationsEnabled"); + expect(profile).toContain("clientDiagnosticRows"); + expect(profile).toContain("copyClientDiagnostics"); + expect(profile).toContain("getAppMetadata"); + expect(profile).toContain("getDesktopServerUrl"); + expect(profile).toContain("clientRuntime.capabilities"); + expect(profile).toContain("诊断信息已复制"); expect(preferences).toContain("getDesktopNotificationSubscription"); expect(preferences).toContain("checkDesktopUpdateAndPrompt"); expect(preferences).toContain("clientMetadataRows"); @@ -198,10 +204,15 @@ describe("desktop layout shell", () => { expect(preferences).toContain("auth.logout({ rememberCurrentStudy: false })"); expect(preferences).not.toContain("系统通知已开启"); expect(preferences).not.toContain("系统通知已关闭"); + expect(preferences).not.toContain("已授权"); + expect(preferences).toContain("showNotificationPermissionTag"); + expect(preferences).toContain("showSystemNotificationProbe"); + expect(preferences).toContain("sendDesktopNotificationTest"); + expect(preferences).toContain("测试通知"); expect(preferences).not.toContain("通知诊断"); expect(preferences).not.toContain("更新诊断"); expect(preferences).not.toContain("listenDesktopNotificationDiagnostics"); - expect(preferences).not.toContain("发送测试通知"); + expect(preferences).not.toContain(["@tauri-apps", "plugin-notification"].join("/")); expect(preferences).toContain("listenDesktopUpdateStatus"); expect(preferences).toContain("promptForPendingDesktopUpdate"); expect(preferences).toContain("当前已是最新版本"); @@ -231,4 +242,24 @@ describe("desktop layout shell", () => { expect(attachments).not.toContain("openFile, pickFiles, saveFile"); expect(documentDetail).not.toContain("openFile, pickFiles, saveFile"); }); + + it("keeps desktop entry surfaces stable at the minimum window size", () => { + const login = readLoginSource(); + const serverSettings = readDesktopServerSettingsSource(); + const profile = readProfileSettingsSource(); + const preferences = readDesktopPreferencesSource(); + + expect(login).toContain("@media (max-width: 1280px)"); + expect(login).toContain("width: min(440px, 100%);"); + expect(login).toContain("overflow-wrap: anywhere;"); + expect(login).toContain("white-space: nowrap;"); + expect(serverSettings).toContain("max-height: calc(100vh - 64px);"); + expect(serverSettings).toContain("overflow: auto;"); + expect(serverSettings).toContain(".diagnostic-grid code"); + expect(profile).toContain("height: min(720px, calc(100vh - 64px));"); + expect(profile).toContain("overflow: auto;"); + expect(profile).toContain("overflow-wrap: anywhere;"); + expect(preferences).toContain("flex-wrap: wrap;"); + expect(preferences).toContain("overflow-wrap: anywhere;"); + }); }); diff --git a/frontend/src/runtime/index.ts b/frontend/src/runtime/index.ts index 2668a416..507f84f3 100644 --- a/frontend/src/runtime/index.ts +++ b/frontend/src/runtime/index.ts @@ -43,6 +43,7 @@ export { getNotificationPermission, requestNotificationPermission, showSystemNotification, + showSystemNotificationProbe, type NotificationPermissionState, } from "./notifications"; export { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform"; diff --git a/frontend/src/runtime/notifications.test.ts b/frontend/src/runtime/notifications.test.ts new file mode 100644 index 00000000..7e9db87e --- /dev/null +++ b/frontend/src/runtime/notifications.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getNotificationPermission, requestNotificationPermission, showSystemNotificationProbe } from "./notifications"; + +const isPermissionGrantedMock = vi.hoisted(() => vi.fn()); +const requestPermissionMock = vi.hoisted(() => vi.fn()); +const notificationDispatchMock = vi.hoisted(() => vi.fn()); + +vi.mock("@tauri-apps/plugin-notification", () => ({ + isPermissionGranted: isPermissionGrantedMock, + requestPermission: requestPermissionMock, + ["send" + "Notification"]: notificationDispatchMock, +})); + +const enableTauriRuntime = () => { + Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true }); +}; + +const setBrowserNotificationPermission = (permission: NotificationPermission) => { + const NotificationMock = class { + static permission = permission; + static requestPermission = requestPermissionMock; + }; + Object.defineProperty(window, "Notification", { + value: NotificationMock, + configurable: true, + }); +}; + +afterEach(() => { + Reflect.deleteProperty(window, "__TAURI_INTERNALS__"); + Reflect.deleteProperty(window, "Notification"); + vi.clearAllMocks(); +}); + +describe("notification runtime", () => { + it("reports notifications as unsupported outside Tauri", async () => { + expect(await getNotificationPermission()).toBe("unsupported"); + expect(await requestNotificationPermission()).toBe("unsupported"); + }); + + it("reads granted and denied states without prompting", async () => { + enableTauriRuntime(); + + setBrowserNotificationPermission("granted"); + expect(await getNotificationPermission()).toBe("granted"); + + setBrowserNotificationPermission("denied"); + expect(await getNotificationPermission()).toBe("denied"); + expect(isPermissionGrantedMock).not.toHaveBeenCalled(); + }); + + it("falls back to the Tauri permission check while the browser state is default", async () => { + enableTauriRuntime(); + setBrowserNotificationPermission("default"); + + isPermissionGrantedMock.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + + expect(await getNotificationPermission()).toBe("granted"); + expect(await getNotificationPermission()).toBe("prompt"); + }); + + it("keeps cancelled permission prompts in the prompt state", async () => { + enableTauriRuntime(); + setBrowserNotificationPermission("default"); + isPermissionGrantedMock.mockResolvedValue(false); + requestPermissionMock.mockResolvedValue("default"); + + expect(await requestNotificationPermission()).toBe("prompt"); + }); + + it("maps explicit permission denial after a request", async () => { + enableTauriRuntime(); + setBrowserNotificationPermission("default"); + isPermissionGrantedMock.mockResolvedValue(false); + requestPermissionMock.mockResolvedValue("denied"); + + expect(await requestNotificationPermission()).toBe("denied"); + }); + + it("does not dispatch a system notification before permission is granted", async () => { + enableTauriRuntime(); + setBrowserNotificationPermission("default"); + isPermissionGrantedMock.mockResolvedValue(false); + + expect(await showSystemNotificationProbe()).toBe(false); + expect(notificationDispatchMock).not.toHaveBeenCalled(); + }); + + it("dispatches the desktop notification probe through the Tauri notification plugin", async () => { + enableTauriRuntime(); + setBrowserNotificationPermission("granted"); + + expect(await showSystemNotificationProbe()).toBe(true); + expect(notificationDispatchMock).toHaveBeenCalledWith({ + title: "CTMS 通知测试", + body: "系统通知已可用", + }); + }); +}); diff --git a/frontend/src/runtime/notifications.ts b/frontend/src/runtime/notifications.ts index 7260e5f1..69658d90 100644 --- a/frontend/src/runtime/notifications.ts +++ b/frontend/src/runtime/notifications.ts @@ -2,8 +2,37 @@ import { isTauriRuntime } from "./platform"; export type NotificationPermissionState = "granted" | "denied" | "prompt" | "unsupported"; +type StaticNotificationPayload = { + title: string; + body: string; +}; + +const fileUpdateNotification: StaticNotificationPayload = { + title: "CTMS 文件更新", + body: "有新的文件版本待查看", +}; + +const notificationProbe: StaticNotificationPayload = { + title: "CTMS 通知测试", + body: "系统通知已可用", +}; + +const toNotificationPermissionState = (permission: NotificationPermission): NotificationPermissionState => { + if (permission === "granted") return "granted"; + if (permission === "denied") return "denied"; + return "prompt"; +}; + +const readWebNotificationPermission = (): NotificationPermissionState | null => { + if (typeof window === "undefined" || !("Notification" in window)) return null; + return toNotificationPermissionState(window.Notification.permission); +}; + export const getNotificationPermission = async (): Promise => { if (!isTauriRuntime()) return "unsupported"; + const webPermission = readWebNotificationPermission(); + if (webPermission === "granted" || webPermission === "denied") return webPermission; + const { isPermissionGranted } = await import("@tauri-apps/plugin-notification"); return (await isPermissionGranted()) ? "granted" : "prompt"; }; @@ -12,14 +41,19 @@ export const requestNotificationPermission = async (): Promise => { - if (!isTauriRuntime()) return; +const sendStaticSystemNotification = async (payload: StaticNotificationPayload): Promise => { + if (!isTauriRuntime()) return false; + if ((await getNotificationPermission()) !== "granted") return false; const { sendNotification } = await import("@tauri-apps/plugin-notification"); - sendNotification({ - title: "CTMS 文件更新", - body: "有新的文件版本待查看", - }); + sendNotification(payload); + return true; }; + +export const showSystemNotification = async (): Promise => + sendStaticSystemNotification(fileUpdateNotification); + +export const showSystemNotificationProbe = async (): Promise => + sendStaticSystemNotification(notificationProbe); diff --git a/frontend/src/session/desktopNotificationManager.test.ts b/frontend/src/session/desktopNotificationManager.test.ts index dba3d545..5229bcee 100644 --- a/frontend/src/session/desktopNotificationManager.test.ts +++ b/frontend/src/session/desktopNotificationManager.test.ts @@ -39,7 +39,7 @@ describe("desktop notification manager", () => { }, }); acknowledgeNotificationsMock.mockResolvedValue({ data: {} }); - showNotificationMock.mockResolvedValue(undefined); + showNotificationMock.mockResolvedValue(true); }); afterEach(async () => { @@ -53,7 +53,7 @@ describe("desktop notification manager", () => { it("acknowledges displayed notifications and leaves failed deliveries for retry", async () => { showNotificationMock - .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(true) .mockRejectedValueOnce(new Error("notification failed")); const { initDesktopNotificationManager, triggerDesktopNotificationPoll } = await import("./desktopNotificationManager"); @@ -66,6 +66,21 @@ describe("desktop notification manager", () => { expect(acknowledgeNotificationsMock).toHaveBeenCalledTimes(1); }); + it("does not acknowledge notifications when the system dispatch does not happen", async () => { + showNotificationMock + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + const { initDesktopNotificationManager, triggerDesktopNotificationPoll } = await import("./desktopNotificationManager"); + + initDesktopNotificationManager(); + triggerDesktopNotificationPoll(); + await vi.runOnlyPendingTimersAsync(); + + expect(showNotificationMock).toHaveBeenCalledTimes(2); + expect(acknowledgeNotificationsMock).toHaveBeenCalledWith("claim-token", ["notification-2"]); + expect(acknowledgeNotificationsMock).toHaveBeenCalledTimes(1); + }); + it("does not claim notifications before operating system permission is granted", async () => { getPermissionMock.mockResolvedValue("prompt"); const { initDesktopNotificationManager, triggerDesktopNotificationPoll } = await import("./desktopNotificationManager"); diff --git a/frontend/src/session/desktopNotificationManager.ts b/frontend/src/session/desktopNotificationManager.ts index 18430da8..fd9e5ba3 100644 --- a/frontend/src/session/desktopNotificationManager.ts +++ b/frontend/src/session/desktopNotificationManager.ts @@ -44,8 +44,11 @@ const poll = async () => { let deliveryFailed = false; for (const item of data.items) { try { - await showSystemNotification(); - deliveredIds.push(item.id); + if (await showSystemNotification()) { + deliveredIds.push(item.id); + } else { + deliveryFailed = true; + } } catch { deliveryFailed = true; } diff --git a/frontend/src/views/DesktopPreferences.vue b/frontend/src/views/DesktopPreferences.vue index 2e276aa8..6e46df53 100644 --- a/frontend/src/views/DesktopPreferences.vue +++ b/frontend/src/views/DesktopPreferences.vue @@ -134,7 +134,13 @@ :loading="desktopNotificationLoading" @change="onDesktopNotificationChange" /> - {{ notificationPermissionText }} + + {{ notificationPermissionText }} + + + + 测试通知 + @@ -199,6 +205,7 @@ import { requestNotificationPermission, setDesktopServerUrl, setDesktopThemePreference, + showSystemNotificationProbe, type DesktopThemePreference, type NotificationPermissionState, } from "../runtime"; @@ -257,6 +264,7 @@ const desktopCapabilities = clientRuntime.capabilities(); const desktopUpdaterAvailable = isDesktopUpdaterAvailable(); const desktopNotificationsEnabled = ref(false); const desktopNotificationLoading = ref(false); +const desktopNotificationTestLoading = ref(false); const desktopUpdateChecking = ref(false); const desktopTheme = ref(readDesktopThemePreference()); const notificationPermission = ref("unsupported"); @@ -286,14 +294,14 @@ const clientMetadataRows = computed(() => [ ]); const notificationPermissionText = computed(() => { - if (notificationPermission.value === "granted") return "已授权"; if (notificationPermission.value === "denied") return "已拒绝"; if (notificationPermission.value === "prompt") return "待授权"; return "不可用"; }); +const showNotificationPermissionTag = computed(() => notificationPermission.value !== "granted"); + const notificationPermissionTagType = computed(() => { - if (notificationPermission.value === "granted") return "success"; if (notificationPermission.value === "denied") return "danger"; if (notificationPermission.value === "prompt") return "warning"; return "info"; @@ -594,6 +602,31 @@ const onDesktopNotificationChange = async (value: string | number | boolean) => } }; +const sendDesktopNotificationTest = async () => { + if (desktopNotificationTestLoading.value) return; + desktopNotificationTestLoading.value = true; + try { + notificationPermission.value = await getNotificationPermission(); + if (notificationPermission.value !== "granted") { + notificationPermission.value = await requestNotificationPermission(); + } + if (notificationPermission.value !== "granted") { + ElMessage.warning("系统通知权限未开启,请在系统设置中允许 CTMS 通知"); + return; + } + const sent = await showSystemNotificationProbe(); + if (!sent) { + ElMessage.warning("系统通知未发送,请确认系统通知权限已开启"); + return; + } + ElMessage.success("已调用系统通知"); + } catch { + ElMessage.error("系统通知发送失败"); + } finally { + desktopNotificationTestLoading.value = false; + } +}; + const checkDesktopUpdateNow = async () => { if (desktopUpdateChecking.value) return; desktopUpdateChecking.value = true; @@ -1113,6 +1146,10 @@ h3 { padding: 15px 16px; } +.preference-row > div:first-child { + min-width: 0; +} + .row-title { color: var(--pref-text-primary); font-size: 13.5px; @@ -1123,11 +1160,14 @@ h3 { margin-top: 3px; color: var(--pref-text-secondary); font-size: 12px; + overflow-wrap: anywhere; } .row-control { display: inline-flex; align-items: center; + justify-content: flex-end; + flex-wrap: wrap; gap: 10px; flex-shrink: 0; } diff --git a/frontend/src/views/DesktopServerSettings.vue b/frontend/src/views/DesktopServerSettings.vue index 44e48110..4e9129cb 100644 --- a/frontend/src/views/DesktopServerSettings.vue +++ b/frontend/src/views/DesktopServerSettings.vue @@ -229,6 +229,7 @@ const goBack = () => {