280 lines
8.9 KiB
TypeScript
280 lines
8.9 KiB
TypeScript
/**
|
||
* 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 { type Ref } from "vue";
|
||
import { type TicketCounts } from "@/api/types";
|
||
import { CRM_URL } from "@/constants/app";
|
||
import { useLogStore } from "@/stores/log";
|
||
import { getErrorMessage } from "@/utils/common";
|
||
|
||
/**
|
||
* @param isMonitoring 是否正在监测
|
||
* @param isPostponed 是否已推迟/暂停
|
||
* @param ticketCounts 当前工单计数
|
||
* @param startMonitoring 开始监测(托盘菜单调用)
|
||
* @param stopMonitoring 停止监测(托盘菜单调用)
|
||
*/
|
||
export function useTray(
|
||
isMonitoring: Ref<boolean>,
|
||
isPostponed: Ref<boolean>,
|
||
ticketCounts: Ref<TicketCounts>,
|
||
startMonitoring: () => Promise<void>,
|
||
stopMonitoring: () => void,
|
||
) {
|
||
const { addLog } = useLogStore();
|
||
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) => {
|
||
const img = new window.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<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);
|
||
} 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");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 初始化系统托盘:创建图标(初始灰色)、注册右键菜单、绑定左键单击事件。
|
||
* 应在应用启动时调用一次。
|
||
*/
|
||
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");
|
||
}
|
||
}
|
||
|
||
/** 隐藏主窗口(最小化到系统托盘)。 */
|
||
async function minimizeToTray() {
|
||
await getCurrentWindow().hide();
|
||
}
|
||
|
||
return { setupTray, updateTrayIcon, updateTrayMenu, minimizeToTray };
|
||
}
|