2026-03-31 17:08:30 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 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";
|
2026-04-17 08:58:20 +08:00
|
|
|
|
import { storeToRefs } from "pinia";
|
|
|
|
|
|
import { watch } from "vue";
|
2026-03-31 17:08:30 +08:00
|
|
|
|
import { CRM_URL } from "@/constants/app";
|
|
|
|
|
|
import { useLogStore } from "@/stores/log";
|
2026-04-17 08:58:20 +08:00
|
|
|
|
import { useMonitorStore } from "@/stores/monitor";
|
2026-04-01 10:05:53 +08:00
|
|
|
|
import { getErrorMessage } from "@/utils/common";
|
2026-03-31 17:08:30 +08:00
|
|
|
|
|
2026-04-17 08:58:20 +08:00
|
|
|
|
/** 隐藏主窗口(最小化到系统托盘)。 */
|
|
|
|
|
|
async function minimizeToTray() {
|
|
|
|
|
|
await getCurrentWindow().hide();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-31 17:08:30 +08:00
|
|
|
|
/**
|
2026-04-17 08:58:20 +08:00
|
|
|
|
* 状态与动作直接从 monitor store 获取。
|
2026-03-31 17:08:30 +08:00
|
|
|
|
*/
|
2026-04-17 08:58:20 +08:00
|
|
|
|
export function useTray() {
|
2026-03-31 17:08:30 +08:00
|
|
|
|
const { addLog } = useLogStore();
|
2026-04-17 08:58:20 +08:00
|
|
|
|
const monitorStore = useMonitorStore();
|
|
|
|
|
|
const { isMonitoring, isPostponed, ticketCounts } = storeToRefs(monitorStore);
|
|
|
|
|
|
const { startMonitoring, stopMonitoring } = monitorStore;
|
2026-03-31 17:08:30 +08:00
|
|
|
|
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<HTMLImageElement> {
|
|
|
|
|
|
if (cachedSvgImg) return cachedSvgImg;
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
2026-04-17 08:58:20 +08:00
|
|
|
|
const img = new globalThis.Image();
|
2026-03-31 17:08:30 +08:00
|
|
|
|
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<Uint8Array> {
|
|
|
|
|
|
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);
|
2026-04-01 10:05:53 +08:00
|
|
|
|
} catch (e: unknown) {
|
|
|
|
|
|
addLog("WARN", `更新托盘图标失败: ${getErrorMessage(e)}`, "SYSTEM");
|
2026-03-31 17:08:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 构建托盘右键菜单,包含以下菜单项:
|
|
|
|
|
|
* - 显示面板:还原并聚焦主窗口
|
|
|
|
|
|
* - 开始/停止监测:根据当前状态切换
|
|
|
|
|
|
* - 打开工单网站:在默认浏览器中打开
|
|
|
|
|
|
* - 退出:销毁主窗口
|
|
|
|
|
|
*/
|
|
|
|
|
|
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);
|
2026-04-01 10:05:53 +08:00
|
|
|
|
} catch (e: unknown) {
|
|
|
|
|
|
addLog("WARN", `更新托盘菜单失败: ${getErrorMessage(e)}`, "SYSTEM");
|
2026-03-31 17:08:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 08:58:20 +08:00
|
|
|
|
watch(
|
|
|
|
|
|
[isMonitoring, isPostponed, ticketCounts],
|
|
|
|
|
|
() => {
|
|
|
|
|
|
updateTrayIcon();
|
|
|
|
|
|
updateTrayMenu();
|
|
|
|
|
|
},
|
|
|
|
|
|
{ deep: true },
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-03-31 17:08:30 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 初始化系统托盘:创建图标(初始灰色)、注册右键菜单、绑定左键单击事件。
|
|
|
|
|
|
* 应在应用启动时调用一次。
|
|
|
|
|
|
*/
|
|
|
|
|
|
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");
|
2026-04-01 10:05:53 +08:00
|
|
|
|
} catch (e: unknown) {
|
|
|
|
|
|
addLog("ERROR", `系统托盘创建失败: ${getErrorMessage(e)}`, "SYSTEM");
|
2026-03-31 17:08:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return { setupTray, updateTrayIcon, updateTrayMenu, minimizeToTray };
|
|
|
|
|
|
}
|