完善桌面体验与系统通知收口
This commit is contained in:
@@ -160,7 +160,7 @@ npm run desktop:build:app
|
|||||||
|
|
||||||
- 服务器地址切换时,桌面设置页调用 `auth.logout({ rememberCurrentStudy: false })`,避免退出时把旧服务器项目记入当前用户的最近项目;随后继续清除当前项目上下文。
|
- 服务器地址切换时,桌面设置页调用 `auth.logout({ rememberCurrentStudy: false })`,避免退出时把旧服务器项目记入当前用户的最近项目;随后继续清除当前项目上下文。
|
||||||
- 系统偏好连接页与独立服务器设置页保持同一切换服务器语义,切换时不记忆旧服务器项目并清除当前项目上下文。
|
- 系统偏好连接页与独立服务器设置页保持同一切换服务器语义,切换时不记忆旧服务器项目并清除当前项目上下文。
|
||||||
- 系统通知轮询在部分通知显示失败时,先 ack 已成功显示的通知,再让失败项通过租约重试,贴合“显示成功后 ack;失败等待重试”的回归预期。
|
- 系统通知轮询在部分通知显示失败或系统通知未实际发起时,先 ack 已成功发起系统通知的通知,再让失败项通过租约重试,贴合“显示成功后 ack;失败等待重试”的回归预期。
|
||||||
- 自动更新管理器新增稍后提醒 24 小时抑制、安装失败可重试、检查失败不打断业务和未启用更新状态的单元覆盖。
|
- 自动更新管理器新增稍后提醒 24 小时抑制、安装失败可重试、检查失败不打断业务和未启用更新状态的单元覆盖。
|
||||||
- 附件 API 新增 blob 下载、multipart 上传和删除端点单元覆盖,确保下载凭据继续由 axios Authorization header 承载而不是进入 URL。
|
- 附件 API 新增 blob 下载、multipart 上传和删除端点单元覆盖,确保下载凭据继续由 axios Authorization header 承载而不是进入 URL。
|
||||||
- 文件任务反馈 helper 新增选择、保存、取消保存和打开的单元覆盖,约束桌面保存/打开继续走 `frontend/src/runtime/` 适配层。
|
- 文件任务反馈 helper 新增选择、保存、取消保存和打开的单元覆盖,约束桌面保存/打开继续走 `frontend/src/runtime/` 适配层。
|
||||||
@@ -168,13 +168,13 @@ npm run desktop:build:app
|
|||||||
- 桌面发布门禁新增单实例重复启动处理校验,要求重复启动时恢复、显示并聚焦 `main` 窗口。
|
- 桌面发布门禁新增单实例重复启动处理校验,要求重复启动时恢复、显示并聚焦 `main` 窗口。
|
||||||
- 会话刷新后的跨窗口 token 更新不再写入 `localStorage` fallback,只通过内存态 BroadcastChannel 通知,避免 token 进入明文广播缓存。
|
- 会话刷新后的跨窗口 token 更新不再写入 `localStorage` fallback,只通过内存态 BroadcastChannel 通知,避免 token 进入明文广播缓存。
|
||||||
- `desktop:release:check` 新增 session broadcast 静态门禁,防止 `TOKEN_UPDATED` payload 回退写入 `localStorage`。
|
- `desktop:release:check` 新增 session broadcast 静态门禁,防止 `TOKEN_UPDATED` payload 回退写入 `localStorage`。
|
||||||
- 新增相关单元测试覆盖服务器切换不记忆旧项目、通知权限未授权不领取、部分通知失败时只 ack 成功项、自动更新失败恢复路径、附件文件流契约、30 天在线会话恢复边界、单实例恢复行为和 token 广播存储边界。
|
- 新增相关单元测试覆盖服务器切换不记忆旧项目、通知权限未授权不领取、部分通知失败时只 ack 成功项、系统通知未发起时不 ack、自动更新失败恢复路径、附件文件流契约、30 天在线会话恢复边界、单实例恢复行为和 token 广播存储边界。
|
||||||
|
|
||||||
仍需人工或真实环境验证:
|
仍需人工或真实环境验证:
|
||||||
|
|
||||||
- Keychain/凭据库 30 天在线会话恢复。
|
- Keychain/凭据库 30 天在线会话恢复。
|
||||||
- 原生附件上传、下载、保存和打开。
|
- 原生附件上传、下载、保存和打开。
|
||||||
- 系统通知授权/拒绝的 OS 级交互。
|
- 系统通知拒绝路径的 OS 级交互。
|
||||||
- 单实例重复启动聚焦主窗口。
|
- 单实例重复启动聚焦主窗口。
|
||||||
- 签名 release 构建下的自动更新 feed、验签、安装和重启实物流。
|
- 签名 release 构建下的自动更新 feed、验签、安装和重启实物流。
|
||||||
|
|
||||||
@@ -194,3 +194,27 @@ npm run desktop:build:app
|
|||||||
|
|
||||||
- 真实生产 release notes 内容保持通用,不写入项目、文件、下载链接或敏感业务详情。
|
- 真实生产 release notes 内容保持通用,不写入项目、文件、下载链接或敏感业务详情。
|
||||||
- 正式签名、公证和 updater feed 环境继续使用组织 secret,不在日志、artifact 或配置中泄露私钥材料。
|
- 正式签名、公证和 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 状态一致。
|
||||||
|
|||||||
@@ -149,6 +149,28 @@
|
|||||||
- 生产 release notes 内容保持通用,不包含项目、文件、下载链接或敏感业务详情。
|
- 生产 release notes 内容保持通用,不包含项目、文件、下载链接或敏感业务详情。
|
||||||
- 签名、公证、updater feed 和 artifact 上传日志不泄露 Apple 凭据、updater 私钥或下载源内部凭据。
|
- 签名、公证、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 状态一致。
|
||||||
|
|
||||||
如果后续任务试图新增离线登录、本地业务数据存储、内嵌后端、本地业务队列、离线同步或绕过后端权限审计,应先修改并评审本计划书,不能直接实现。
|
如果后续任务试图新增离线登录、本地业务数据存储、内嵌后端、本地业务队列、离线同步或绕过后端权限审计,应先修改并评审本计划书,不能直接实现。
|
||||||
|
|
||||||
## 当前质量门禁
|
## 当前质量门禁
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const readDesktopPreferencesSource = () => readFileSync(resolve(__dirname, "../v
|
|||||||
const readDesktopServerSettingsSource = () => readFileSync(resolve(__dirname, "../views/DesktopServerSettings.vue"), "utf8");
|
const readDesktopServerSettingsSource = () => readFileSync(resolve(__dirname, "../views/DesktopServerSettings.vue"), "utf8");
|
||||||
const readDesktopUpdateManagerSource = () => readFileSync(resolve(__dirname, "../session/desktopUpdateManager.ts"), "utf8");
|
const readDesktopUpdateManagerSource = () => readFileSync(resolve(__dirname, "../session/desktopUpdateManager.ts"), "utf8");
|
||||||
const readProfileSettingsSource = () => readFileSync(resolve(__dirname, "../views/ProfileSettings.vue"), "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 readFileTaskFeedbackSource = () => readFileSync(resolve(__dirname, "../utils/fileTaskFeedback.ts"), "utf8");
|
||||||
const readAttachmentListSource = () => readFileSync(resolve(__dirname, "./attachments/AttachmentList.vue"), "utf8");
|
const readAttachmentListSource = () => readFileSync(resolve(__dirname, "./attachments/AttachmentList.vue"), "utf8");
|
||||||
const readDocumentDetailSource = () => readFileSync(resolve(__dirname, "../views/documents/DocumentDetail.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"');
|
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 profile = readProfileSettingsSource();
|
||||||
const preferences = readDesktopPreferencesSource();
|
const preferences = readDesktopPreferencesSource();
|
||||||
|
|
||||||
@@ -117,8 +118,13 @@ describe("desktop layout shell", () => {
|
|||||||
expect(profile).not.toContain("getDesktopNotificationSubscription");
|
expect(profile).not.toContain("getDesktopNotificationSubscription");
|
||||||
expect(profile).not.toContain("setDesktopNotificationSubscription");
|
expect(profile).not.toContain("setDesktopNotificationSubscription");
|
||||||
expect(profile).not.toContain("checkDesktopUpdateAndPrompt");
|
expect(profile).not.toContain("checkDesktopUpdateAndPrompt");
|
||||||
expect(profile).not.toContain("clientMetadataRows");
|
|
||||||
expect(profile).not.toContain("desktopNotificationsEnabled");
|
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("getDesktopNotificationSubscription");
|
||||||
expect(preferences).toContain("checkDesktopUpdateAndPrompt");
|
expect(preferences).toContain("checkDesktopUpdateAndPrompt");
|
||||||
expect(preferences).toContain("clientMetadataRows");
|
expect(preferences).toContain("clientMetadataRows");
|
||||||
@@ -198,10 +204,15 @@ describe("desktop layout shell", () => {
|
|||||||
expect(preferences).toContain("auth.logout({ rememberCurrentStudy: false })");
|
expect(preferences).toContain("auth.logout({ rememberCurrentStudy: false })");
|
||||||
expect(preferences).not.toContain("系统通知已开启");
|
expect(preferences).not.toContain("系统通知已开启");
|
||||||
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("更新诊断");
|
expect(preferences).not.toContain("更新诊断");
|
||||||
expect(preferences).not.toContain("listenDesktopNotificationDiagnostics");
|
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("listenDesktopUpdateStatus");
|
||||||
expect(preferences).toContain("promptForPendingDesktopUpdate");
|
expect(preferences).toContain("promptForPendingDesktopUpdate");
|
||||||
expect(preferences).toContain("当前已是最新版本");
|
expect(preferences).toContain("当前已是最新版本");
|
||||||
@@ -231,4 +242,24 @@ describe("desktop layout shell", () => {
|
|||||||
expect(attachments).not.toContain("openFile, pickFiles, saveFile");
|
expect(attachments).not.toContain("openFile, pickFiles, saveFile");
|
||||||
expect(documentDetail).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;");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export {
|
|||||||
getNotificationPermission,
|
getNotificationPermission,
|
||||||
requestNotificationPermission,
|
requestNotificationPermission,
|
||||||
showSystemNotification,
|
showSystemNotification,
|
||||||
|
showSystemNotificationProbe,
|
||||||
type NotificationPermissionState,
|
type NotificationPermissionState,
|
||||||
} from "./notifications";
|
} from "./notifications";
|
||||||
export { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform";
|
export { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform";
|
||||||
|
|||||||
@@ -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: "系统通知已可用",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,8 +2,37 @@ import { isTauriRuntime } from "./platform";
|
|||||||
|
|
||||||
export type NotificationPermissionState = "granted" | "denied" | "prompt" | "unsupported";
|
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<NotificationPermissionState> => {
|
export const getNotificationPermission = async (): Promise<NotificationPermissionState> => {
|
||||||
if (!isTauriRuntime()) return "unsupported";
|
if (!isTauriRuntime()) return "unsupported";
|
||||||
|
const webPermission = readWebNotificationPermission();
|
||||||
|
if (webPermission === "granted" || webPermission === "denied") return webPermission;
|
||||||
|
|
||||||
const { isPermissionGranted } = await import("@tauri-apps/plugin-notification");
|
const { isPermissionGranted } = await import("@tauri-apps/plugin-notification");
|
||||||
return (await isPermissionGranted()) ? "granted" : "prompt";
|
return (await isPermissionGranted()) ? "granted" : "prompt";
|
||||||
};
|
};
|
||||||
@@ -12,14 +41,19 @@ export const requestNotificationPermission = async (): Promise<NotificationPermi
|
|||||||
if (!isTauriRuntime()) return "unsupported";
|
if (!isTauriRuntime()) return "unsupported";
|
||||||
const { isPermissionGranted, requestPermission } = await import("@tauri-apps/plugin-notification");
|
const { isPermissionGranted, requestPermission } = await import("@tauri-apps/plugin-notification");
|
||||||
if (await isPermissionGranted()) return "granted";
|
if (await isPermissionGranted()) return "granted";
|
||||||
return (await requestPermission()) === "granted" ? "granted" : "denied";
|
return toNotificationPermissionState(await requestPermission());
|
||||||
};
|
};
|
||||||
|
|
||||||
export const showSystemNotification = async (): Promise<void> => {
|
const sendStaticSystemNotification = async (payload: StaticNotificationPayload): Promise<boolean> => {
|
||||||
if (!isTauriRuntime()) return;
|
if (!isTauriRuntime()) return false;
|
||||||
|
if ((await getNotificationPermission()) !== "granted") return false;
|
||||||
const { sendNotification } = await import("@tauri-apps/plugin-notification");
|
const { sendNotification } = await import("@tauri-apps/plugin-notification");
|
||||||
sendNotification({
|
sendNotification(payload);
|
||||||
title: "CTMS 文件更新",
|
return true;
|
||||||
body: "有新的文件版本待查看",
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const showSystemNotification = async (): Promise<boolean> =>
|
||||||
|
sendStaticSystemNotification(fileUpdateNotification);
|
||||||
|
|
||||||
|
export const showSystemNotificationProbe = async (): Promise<boolean> =>
|
||||||
|
sendStaticSystemNotification(notificationProbe);
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe("desktop notification manager", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
acknowledgeNotificationsMock.mockResolvedValue({ data: {} });
|
acknowledgeNotificationsMock.mockResolvedValue({ data: {} });
|
||||||
showNotificationMock.mockResolvedValue(undefined);
|
showNotificationMock.mockResolvedValue(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -53,7 +53,7 @@ describe("desktop notification manager", () => {
|
|||||||
|
|
||||||
it("acknowledges displayed notifications and leaves failed deliveries for retry", async () => {
|
it("acknowledges displayed notifications and leaves failed deliveries for retry", async () => {
|
||||||
showNotificationMock
|
showNotificationMock
|
||||||
.mockResolvedValueOnce(undefined)
|
.mockResolvedValueOnce(true)
|
||||||
.mockRejectedValueOnce(new Error("notification failed"));
|
.mockRejectedValueOnce(new Error("notification failed"));
|
||||||
const { initDesktopNotificationManager, triggerDesktopNotificationPoll } = await import("./desktopNotificationManager");
|
const { initDesktopNotificationManager, triggerDesktopNotificationPoll } = await import("./desktopNotificationManager");
|
||||||
|
|
||||||
@@ -66,6 +66,21 @@ describe("desktop notification manager", () => {
|
|||||||
expect(acknowledgeNotificationsMock).toHaveBeenCalledTimes(1);
|
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 () => {
|
it("does not claim notifications before operating system permission is granted", async () => {
|
||||||
getPermissionMock.mockResolvedValue("prompt");
|
getPermissionMock.mockResolvedValue("prompt");
|
||||||
const { initDesktopNotificationManager, triggerDesktopNotificationPoll } = await import("./desktopNotificationManager");
|
const { initDesktopNotificationManager, triggerDesktopNotificationPoll } = await import("./desktopNotificationManager");
|
||||||
|
|||||||
@@ -44,8 +44,11 @@ const poll = async () => {
|
|||||||
let deliveryFailed = false;
|
let deliveryFailed = false;
|
||||||
for (const item of data.items) {
|
for (const item of data.items) {
|
||||||
try {
|
try {
|
||||||
await showSystemNotification();
|
if (await showSystemNotification()) {
|
||||||
deliveredIds.push(item.id);
|
deliveredIds.push(item.id);
|
||||||
|
} else {
|
||||||
|
deliveryFailed = true;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
deliveryFailed = true;
|
deliveryFailed = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,7 +134,13 @@
|
|||||||
:loading="desktopNotificationLoading"
|
:loading="desktopNotificationLoading"
|
||||||
@change="onDesktopNotificationChange"
|
@change="onDesktopNotificationChange"
|
||||||
/>
|
/>
|
||||||
<el-tag size="small" :type="notificationPermissionTagType">{{ notificationPermissionText }}</el-tag>
|
<el-tag v-if="showNotificationPermissionTag" size="small" :type="notificationPermissionTagType">
|
||||||
|
{{ notificationPermissionText }}
|
||||||
|
</el-tag>
|
||||||
|
<el-button size="small" :loading="desktopNotificationTestLoading" @click="sendDesktopNotificationTest">
|
||||||
|
<el-icon><Bell /></el-icon>
|
||||||
|
<span>测试通知</span>
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -199,6 +205,7 @@ import {
|
|||||||
requestNotificationPermission,
|
requestNotificationPermission,
|
||||||
setDesktopServerUrl,
|
setDesktopServerUrl,
|
||||||
setDesktopThemePreference,
|
setDesktopThemePreference,
|
||||||
|
showSystemNotificationProbe,
|
||||||
type DesktopThemePreference,
|
type DesktopThemePreference,
|
||||||
type NotificationPermissionState,
|
type NotificationPermissionState,
|
||||||
} from "../runtime";
|
} from "../runtime";
|
||||||
@@ -257,6 +264,7 @@ const desktopCapabilities = clientRuntime.capabilities();
|
|||||||
const desktopUpdaterAvailable = isDesktopUpdaterAvailable();
|
const desktopUpdaterAvailable = isDesktopUpdaterAvailable();
|
||||||
const desktopNotificationsEnabled = ref(false);
|
const desktopNotificationsEnabled = ref(false);
|
||||||
const desktopNotificationLoading = ref(false);
|
const desktopNotificationLoading = ref(false);
|
||||||
|
const desktopNotificationTestLoading = ref(false);
|
||||||
const desktopUpdateChecking = ref(false);
|
const desktopUpdateChecking = ref(false);
|
||||||
const desktopTheme = ref<DesktopThemePreference>(readDesktopThemePreference());
|
const desktopTheme = ref<DesktopThemePreference>(readDesktopThemePreference());
|
||||||
const notificationPermission = ref<NotificationPermissionState>("unsupported");
|
const notificationPermission = ref<NotificationPermissionState>("unsupported");
|
||||||
@@ -286,14 +294,14 @@ const clientMetadataRows = computed(() => [
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const notificationPermissionText = computed(() => {
|
const notificationPermissionText = computed(() => {
|
||||||
if (notificationPermission.value === "granted") return "已授权";
|
|
||||||
if (notificationPermission.value === "denied") return "已拒绝";
|
if (notificationPermission.value === "denied") return "已拒绝";
|
||||||
if (notificationPermission.value === "prompt") return "待授权";
|
if (notificationPermission.value === "prompt") return "待授权";
|
||||||
return "不可用";
|
return "不可用";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const showNotificationPermissionTag = computed(() => notificationPermission.value !== "granted");
|
||||||
|
|
||||||
const notificationPermissionTagType = computed(() => {
|
const notificationPermissionTagType = computed(() => {
|
||||||
if (notificationPermission.value === "granted") return "success";
|
|
||||||
if (notificationPermission.value === "denied") return "danger";
|
if (notificationPermission.value === "denied") return "danger";
|
||||||
if (notificationPermission.value === "prompt") return "warning";
|
if (notificationPermission.value === "prompt") return "warning";
|
||||||
return "info";
|
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 () => {
|
const checkDesktopUpdateNow = async () => {
|
||||||
if (desktopUpdateChecking.value) return;
|
if (desktopUpdateChecking.value) return;
|
||||||
desktopUpdateChecking.value = true;
|
desktopUpdateChecking.value = true;
|
||||||
@@ -1113,6 +1146,10 @@ h3 {
|
|||||||
padding: 15px 16px;
|
padding: 15px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preference-row > div:first-child {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.row-title {
|
.row-title {
|
||||||
color: var(--pref-text-primary);
|
color: var(--pref-text-primary);
|
||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
@@ -1123,11 +1160,14 @@ h3 {
|
|||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
color: var(--pref-text-secondary);
|
color: var(--pref-text-secondary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-control {
|
.row-control {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ const goBack = () => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.desktop-settings-page {
|
.desktop-settings-page {
|
||||||
|
box-sizing: border-box;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -239,7 +240,9 @@ const goBack = () => {
|
|||||||
|
|
||||||
.settings-panel {
|
.settings-panel {
|
||||||
width: min(100%, 520px);
|
width: min(100%, 520px);
|
||||||
|
max-height: calc(100vh - 64px);
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
|
overflow: auto;
|
||||||
border: 1px solid #d9e2ef;
|
border: 1px solid #d9e2ef;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
@@ -324,6 +327,11 @@ h1 {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.diagnostic-grid code {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|||||||
@@ -458,6 +458,7 @@ const onSubmit = async () => {
|
|||||||
|
|
||||||
.login-split-container {
|
.login-split-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
@@ -467,6 +468,7 @@ const onSubmit = async () => {
|
|||||||
═══════════════════════ */
|
═══════════════════════ */
|
||||||
.login-left-brand {
|
.login-left-brand {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
background: linear-gradient(135deg, #e0f2fe 0%, #e0e7ff 50%, #f3e8ff 100%);
|
background: linear-gradient(135deg, #e0f2fe 0%, #e0e7ff 50%, #f3e8ff 100%);
|
||||||
padding: 60px 80px;
|
padding: 60px 80px;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -715,6 +717,7 @@ const onSubmit = async () => {
|
|||||||
═══════════════════════ */
|
═══════════════════════ */
|
||||||
.login-right-form {
|
.login-right-form {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
padding: 60px 80px;
|
padding: 60px 80px;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -848,6 +851,7 @@ const onSubmit = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.desktop-server-action {
|
.desktop-server-action {
|
||||||
|
white-space: nowrap;
|
||||||
color: #2563eb;
|
color: #2563eb;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -884,7 +888,7 @@ const onSubmit = async () => {
|
|||||||
|
|
||||||
.ln-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 1px; }
|
.ln-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 1px; }
|
||||||
.ln-icon svg { width: 100%; height: 100%; }
|
.ln-icon svg { width: 100%; height: 100%; }
|
||||||
.ln-body { display: flex; flex-direction: column; gap: 2px; }
|
.ln-body { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||||
.ln-body strong { font-size: 12px; font-weight: 700; }
|
.ln-body strong { font-size: 12px; font-weight: 700; }
|
||||||
.ln-body span { font-size: 11px; opacity: 0.9; }
|
.ln-body span { font-size: 11px; opacity: 0.9; }
|
||||||
|
|
||||||
@@ -1326,6 +1330,28 @@ const onSubmit = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.login-left-brand {
|
||||||
|
padding: 48px 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-right-form {
|
||||||
|
padding: 48px 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-main-title {
|
||||||
|
font-size: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card-container {
|
||||||
|
width: min(440px, 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.login-brand-header {
|
.login-brand-header {
|
||||||
margin-bottom: 28px;
|
margin-bottom: 28px;
|
||||||
|
|||||||
@@ -64,6 +64,22 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="form-section form-section--diagnostics">
|
||||||
|
<div class="section-heading">
|
||||||
|
<span class="section-kicker">Diagnostics</span>
|
||||||
|
<h4>客户端诊断</h4>
|
||||||
|
</div>
|
||||||
|
<div class="diagnostics-panel">
|
||||||
|
<dl>
|
||||||
|
<template v-for="row in clientDiagnosticRows" :key="row.label">
|
||||||
|
<dt>{{ row.label }}</dt>
|
||||||
|
<dd>{{ row.value }}</dd>
|
||||||
|
</template>
|
||||||
|
</dl>
|
||||||
|
<el-button size="small" :icon="DocumentCopy" @click="copyClientDiagnostics">复制诊断信息</el-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
|
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,11 +93,12 @@
|
|||||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||||
import type { FormInstance, FormRules } from "element-plus";
|
import type { FormInstance, FormRules } from "element-plus";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { Close, Upload } from "@element-plus/icons-vue";
|
import { Close, DocumentCopy, Upload } from "@element-plus/icons-vue";
|
||||||
import { updateProfile, fetchMe, uploadAvatar } from "../api/auth";
|
import { updateProfile, fetchMe, uploadAvatar } from "../api/auth";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import { TEXT, requiredMessage } from "../locales";
|
import { TEXT, requiredMessage } from "../locales";
|
||||||
import { pickFilesWithFeedback } from "../utils/fileTaskFeedback";
|
import { pickFilesWithFeedback } from "../utils/fileTaskFeedback";
|
||||||
|
import { clientRuntime, getAppMetadata, getDesktopServerUrl } from "../runtime";
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
"close-request": [];
|
"close-request": [];
|
||||||
@@ -106,12 +123,36 @@ const savedProfile = ref({
|
|||||||
const avatarPreview = ref<string | undefined>();
|
const avatarPreview = ref<string | undefined>();
|
||||||
const profileInitial = computed(() => (form.full_name?.charAt(0) || form.email?.charAt(0) || "?").toUpperCase());
|
const profileInitial = computed(() => (form.full_name?.charAt(0) || form.email?.charAt(0) || "?").toUpperCase());
|
||||||
const avatarAcceptedTypes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
const avatarAcceptedTypes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
||||||
|
const clientMetadata = getAppMetadata();
|
||||||
const hasUnsavedChanges = computed(
|
const hasUnsavedChanges = computed(
|
||||||
() =>
|
() =>
|
||||||
form.full_name !== savedProfile.value.full_name ||
|
form.full_name !== savedProfile.value.full_name ||
|
||||||
form.clinical_department !== savedProfile.value.clinical_department ||
|
form.clinical_department !== savedProfile.value.clinical_department ||
|
||||||
Boolean(form.current_password || form.password || form.confirmPassword)
|
Boolean(form.current_password || form.password || form.confirmPassword)
|
||||||
);
|
);
|
||||||
|
const clientDiagnosticRows = computed(() => {
|
||||||
|
const capabilities = clientRuntime.capabilities();
|
||||||
|
return [
|
||||||
|
{ label: "客户端", value: `${clientMetadata.clientType} ${clientMetadata.version}` },
|
||||||
|
{ label: "平台", value: clientMetadata.platform },
|
||||||
|
{ label: "构建通道", value: clientMetadata.channel },
|
||||||
|
{ label: "提交", value: clientMetadata.commit },
|
||||||
|
{
|
||||||
|
label: "服务器",
|
||||||
|
value: clientMetadata.clientType === "desktop" ? getDesktopServerUrl() || "未配置" : clientRuntime.apiBaseUrl(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "能力",
|
||||||
|
value: [
|
||||||
|
capabilities.serverConfiguration ? "服务器配置" : "固定入口",
|
||||||
|
capabilities.secureSessionStorage ? "安全会话" : "浏览器会话",
|
||||||
|
capabilities.nativeFiles ? "原生文件" : "浏览器文件",
|
||||||
|
capabilities.systemNotifications ? "系统通知" : "无系统通知",
|
||||||
|
capabilities.automaticUpdates ? "自动更新" : "无自动更新",
|
||||||
|
].join(" / "),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
const rules: FormRules<typeof form> = {
|
const rules: FormRules<typeof form> = {
|
||||||
full_name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), trigger: "blur" }],
|
full_name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), trigger: "blur" }],
|
||||||
@@ -220,6 +261,16 @@ const selectAndUploadAvatar = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const copyClientDiagnostics = async () => {
|
||||||
|
const text = clientDiagnosticRows.value.map((row) => `${row.label}: ${row.value}`).join("\n");
|
||||||
|
if (!navigator.clipboard?.writeText) {
|
||||||
|
ElMessage.warning("当前环境无法访问剪贴板");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
ElMessage.success("诊断信息已复制");
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadProfile();
|
loadProfile();
|
||||||
});
|
});
|
||||||
@@ -235,11 +286,14 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
|
|||||||
.profile-layout {
|
.profile-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 260px minmax(0, 1fr);
|
grid-template-columns: 260px minmax(0, 1fr);
|
||||||
min-height: 620px;
|
height: min(720px, calc(100vh - 64px));
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-aside {
|
.profile-aside {
|
||||||
|
min-height: 0;
|
||||||
padding: 48px 32px;
|
padding: 48px 32px;
|
||||||
|
overflow: hidden;
|
||||||
border-right: 1px solid #e5ebf2;
|
border-right: 1px solid #e5ebf2;
|
||||||
background: linear-gradient(180deg, #f8fbfd 0%, #f1f5f9 100%);
|
background: linear-gradient(180deg, #f8fbfd 0%, #f1f5f9 100%);
|
||||||
}
|
}
|
||||||
@@ -295,7 +349,9 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
|
|||||||
}
|
}
|
||||||
|
|
||||||
.profile-main {
|
.profile-main {
|
||||||
|
min-height: 0;
|
||||||
padding: 42px 48px 36px;
|
padding: 42px 48px 36px;
|
||||||
|
overflow: auto;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,6 +392,12 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
|
|||||||
border-top: 1px solid #e5ebf2;
|
border-top: 1px solid #e5ebf2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-section--diagnostics {
|
||||||
|
margin-top: 26px;
|
||||||
|
padding-top: 28px;
|
||||||
|
border-top: 1px solid #e5ebf2;
|
||||||
|
}
|
||||||
|
|
||||||
.section-heading {
|
.section-heading {
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
padding-left: 112px;
|
padding-left: 112px;
|
||||||
@@ -378,6 +440,42 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
|
|||||||
box-shadow: 0 0 0 1px #3f8f6b inset, 0 0 0 3px rgba(63, 143, 107, 0.12);
|
box-shadow: 0 0 0 1px #3f8f6b inset, 0 0 0 3px rgba(63, 143, 107, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.diagnostics-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
margin-left: 112px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #dfe6ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-panel dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 76px minmax(0, 1fr);
|
||||||
|
gap: 8px 12px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-panel dt {
|
||||||
|
color: #7f92ad;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-panel dd {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
color: #1f2a3d;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-panel :deep(.el-button) {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
margin-top: 28px;
|
margin-top: 28px;
|
||||||
padding-left: 112px;
|
padding-left: 112px;
|
||||||
@@ -420,6 +518,10 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
|
|||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.diagnostics-panel {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user