/** * useTray.ts — 系统托盘 Composable * * 负责创建、更新系统托盘图标与右键菜单。 * 图标颜色根据监测状态动态变化: * 灰色 — 未开始监测 * 橙色 — 监测已暂停/推迟 * 红色 — 有待处理工单 * 绿色 — 监测中,无异常 */ import { Image } from "@tauri-apps/api/image"; import { Menu, MenuItem } from "@tauri-apps/api/menu"; import { TrayIcon } from "@tauri-apps/api/tray"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { openUrl } from "@tauri-apps/plugin-opener"; import { storeToRefs } from "pinia"; import { watch } from "vue"; import { CRM_URL } from "@/constants/app"; import { useLogStore } from "@/stores/log"; import { useMonitorStore } from "@/stores/monitor"; import { getErrorMessage } from "@/utils/common"; /** 隐藏主窗口(最小化到系统托盘)。 */ async function minimizeToTray() { await getCurrentWindow().hide(); } /** * 状态与动作直接从 monitor store 获取。 */ export function useTray() { const { addLog } = useLogStore(); const monitorStore = useMonitorStore(); const { isMonitoring, isPostponed, ticketCounts } = storeToRefs(monitorStore); const { startMonitoring, stopMonitoring } = monitorStore; const appName = import.meta.env.VITE_APP_NAME; // 当前托盘图标实例,null 表示尚未初始化 let trayIconInstance: TrayIcon | null = null; // SVG 图片缓存,避免重复加载 let cachedSvgImg: HTMLImageElement | null = null; /** * 加载 /gong.svg 并缓存,后续调用直接返回缓存结果。 */ async function loadSvgImage(): Promise { if (cachedSvgImg) return cachedSvgImg; return new Promise((resolve, reject) => { const img = new globalThis.Image(); img.onload = () => { cachedSvgImg = img; resolve(img); }; img.onerror = reject; img.src = "/gong.svg"; }); } /** * 将 SVG 渲染到 Canvas,对每个像素按亮度重新着色后返回 RGBA 原始数据。 * * 着色规则: * - 暗像素(图标笔画,亮度 < 128)→ 白色,确保图标在彩色背景上清晰可见 * - 亮像素(背景) → 传入的 (r, g, b) 状态色 * * SVG 加载失败时回退到纯色背景 + "工" 字文本方案。 * * @param r 状态色红分量 (0-255) * @param g 状态色绿分量 (0-255) * @param b 状态色蓝分量 (0-255) * @param size 图标边长(像素),默认 32 */ async function makeTrayIconRGBA( r: number, g: number, b: number, size = 32, ): Promise { const canvas = document.createElement("canvas"); canvas.width = size; canvas.height = size; const ctx = canvas.getContext("2d")!; try { const svgImg = await loadSvgImage(); ctx.drawImage(svgImg, 0, 0, size, size); const imageData = ctx.getImageData(0, 0, size, size); const data = imageData.data; for (let i = 0; i < data.length; i += 4) { // 根据亮度判断:暗像素(文字) → 白色,亮像素(背景) → 状态色 const brightness = data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114; if (brightness < 128) { data[i] = data[i + 1] = data[i + 2] = 255; } else { data[i] = r; data[i + 1] = g; data[i + 2] = b; } data[i + 3] = 255; } ctx.putImageData(imageData, 0, 0); } catch { // SVG 加载失败,回退到纯 Canvas 绘制 addLog("WARN", "SVG 图标加载失败,使用回退方案", "SYSTEM"); ctx.fillStyle = `rgb(${r},${g},${b})`; ctx.fillRect(0, 0, size, size); ctx.fillStyle = "#ffffff"; ctx.font = `bold ${Math.round(size * 0.62)}px "Microsoft YaHei","SimHei",sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("工", size / 2, size / 2 + 1); } const finalData = ctx.getImageData(0, 0, size, size); return new Uint8Array(finalData.data.buffer); } /** * 根据当前应用状态更新托盘图标颜色与 tooltip 文字。 * * 状态优先级(高 → 低): * 未监测 > 已暂停 > 有工单 > 正常监测中 */ async function updateTrayIcon() { if (!trayIconInstance) return; try { const hasTickets = ticketCounts.value.pending > 0 || ticketCounts.value.stayclose > 0 || ticketCounts.value.confirm > 0 || ticketCounts.value.workOrderCount > 0; let color: [number, number, number]; let tooltip: string; if (!isMonitoring.value) { // 未开始监测 — 灰色 color = [158, 158, 158]; tooltip = `${appName} - 未开始`; } else if (isPostponed.value) { // 已暂停/推迟 — 橙色 color = [255, 152, 0]; tooltip = `${appName} - 已暂停`; } else if (hasTickets) { // 有待处理工单 — 红色 const total = ticketCounts.value.pending + ticketCounts.value.stayclose + ticketCounts.value.confirm + ticketCounts.value.workOrderCount; color = [244, 67, 54]; tooltip = `${appName} - 有 ${total} 条工单待处理!`; } else { // 监测中,无异常 — 绿色 color = [76, 175, 80]; tooltip = `${appName} - 监测中`; } const icon = await Image.new(await makeTrayIconRGBA(...color), 32, 32); await trayIconInstance.setIcon(icon); await trayIconInstance.setTooltip(tooltip); } catch (e: unknown) { addLog("WARN", `更新托盘图标失败: ${getErrorMessage(e)}`, "SYSTEM"); } } /** * 构建托盘右键菜单,包含以下菜单项: * - 显示面板:还原并聚焦主窗口 * - 开始/停止监测:根据当前状态切换 * - 打开工单网站:在默认浏览器中打开 * - 退出:销毁主窗口 */ async function buildTrayMenu() { const mainWindow = getCurrentWindow(); return await Menu.new({ items: [ await MenuItem.new({ id: "show", text: "显示面板", action: async () => { await mainWindow.unminimize(); await mainWindow.show(); await mainWindow.setFocus(); }, }), await MenuItem.new({ id: "toggle_monitor", text: isMonitoring.value ? "停止监测" : "开始监测", action: async () => { if (isMonitoring.value) { stopMonitoring(); } else { // 开始监测前先还原窗口,确保登录流程可见 await mainWindow.unminimize(); await mainWindow.show(); await mainWindow.setFocus(); await startMonitoring(); } }, }), await MenuItem.new({ id: "open_website", text: "打开工单网站", action: async () => { await openUrl(CRM_URL); }, }), await MenuItem.new({ id: "quit", text: "退出", action: async () => { await mainWindow.destroy(); }, }), ], }); } /** * 重新构建并替换托盘右键菜单。 * 在监测状态变化后调用,以同步"开始/停止监测"等动态菜单文字。 */ async function updateTrayMenu() { if (!trayIconInstance) return; try { const menu = await buildTrayMenu(); await trayIconInstance.setMenu(menu); } catch (e: unknown) { addLog("WARN", `更新托盘菜单失败: ${getErrorMessage(e)}`, "SYSTEM"); } } watch( [isMonitoring, isPostponed, ticketCounts], () => { updateTrayIcon(); updateTrayMenu(); }, { deep: true }, ); /** * 初始化系统托盘:创建图标(初始灰色)、注册右键菜单、绑定左键单击事件。 * 应在应用启动时调用一次。 */ async function setupTray() { try { const menu = await buildTrayMenu(); // 初始状态:灰色(未开始) const icon = await Image.new( await makeTrayIconRGBA(158, 158, 158), 32, 32, ); trayIconInstance = await TrayIcon.new({ icon, tooltip: `${appName} - 未开始`, menu, menuOnLeftClick: false, action: async (event) => { // 左键单击:还原并聚焦主窗口 if (event.type === "Click" && event.button === "Left") { const win = getCurrentWindow(); await win.unminimize(); await win.show(); await win.setFocus(); } }, }); addLog("INFO", "系统托盘创建成功", "SYSTEM"); } catch (e: unknown) { addLog("ERROR", `系统托盘创建失败: ${getErrorMessage(e)}`, "SYSTEM"); } } return { setupTray, updateTrayIcon, updateTrayMenu, minimizeToTray }; }