feat(): 添加更新功能

This commit is contained in:
tsl
2026-03-25 10:06:25 +08:00
parent 92055dd6a0
commit 1b6e12e402
23 changed files with 1045 additions and 285 deletions
+2
View File
@@ -15,6 +15,7 @@ import SettingsPanel from "./components/SettingsPanel.vue";
import LogViewer from "./components/LogViewer.vue";
import AppStatusBar, { type StatusBarItem } from "./components/AppStatusBar.vue";
import CloseDialog from "./components/CloseDialog.vue";
import UpdateDialog from "./components/UpdateDialog.vue";
import { useMonitorStore } from "./stores/monitor";
const activeTab = ref("monitor");
@@ -81,6 +82,7 @@ onUnmounted(() => store.cleanup());
</a-layout>
<CloseDialog />
<UpdateDialog />
</template>
<style>
+99
View File
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { computed } from "vue";
import { useMonitorStore } from "../stores/monitor";
const store = useMonitorStore();
const progressPercent = computed(() => {
if (!store.updateProgress || !store.updateProgress.total) return 0;
return Math.round((store.updateProgress.downloaded / store.updateProgress.total) * 100);
});
const downloadedMB = computed(() => {
if (!store.updateProgress) return "0";
return (store.updateProgress.downloaded / 1024 / 1024).toFixed(1);
});
const totalMB = computed(() => {
if (!store.updateProgress || !store.updateProgress.total) return "0";
return (store.updateProgress.total / 1024 / 1024).toFixed(1);
});
</script>
<template>
<a-modal
v-model:open="store.showUpdateDialog"
title="发现新版本"
:width="400"
centered
:closable="!store.isUpdating"
:maskClosable="!store.isUpdating"
>
<div class="update-body">
<div v-if="!store.isUpdating">
<p class="version-info">
新版本 <span class="version-tag">v{{ store.updateInfo?.version }}</span> 已发布
</p>
<div v-if="store.updateInfo?.notes" class="update-notes">
<p class="notes-title">更新说明:</p>
<p class="notes-content">{{ store.updateInfo.notes }}</p>
</div>
</div>
<div v-else class="update-progress">
<p>正在下载更新...</p>
<a-progress :percent="progressPercent" :status="progressPercent >= 100 ? 'success' : 'active'" />
<p class="progress-detail">{{ downloadedMB }} / {{ totalMB }} MB</p>
</div>
</div>
<template #footer>
<template v-if="!store.isUpdating">
<a-button @click="store.dismissUpdate()">稍后提醒</a-button>
<a-button type="primary" @click="store.confirmUpdate()">立即更新</a-button>
</template>
<template v-else>
<a-button disabled>更新中,请稍候...</a-button>
</template>
</template>
</a-modal>
</template>
<style scoped>
.update-body {
padding: 4px 0 8px;
}
.version-info {
margin: 0 0 12px;
font-size: 15px;
color: #333;
}
.version-tag {
font-weight: 600;
color: #1677ff;
}
.notes-title {
margin: 0 0 4px;
font-size: 13px;
color: #888;
}
.notes-content {
margin: 0;
padding: 8px 12px;
background: #f5f5f5;
border-radius: 6px;
font-size: 13px;
color: #555;
white-space: pre-wrap;
}
.update-progress {
text-align: center;
}
.update-progress p {
margin: 0 0 8px;
}
.progress-detail {
font-size: 12px;
color: #999;
margin-top: 4px !important;
}
</style>
+113 -18
View File
@@ -18,6 +18,8 @@ import {
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { openUrl } from "@tauri-apps/plugin-opener";
import { check, type Update } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
export const APP_NAME = "工单系统监测";
const API_BASE = "https://crm.yunvip123.com/api";
@@ -76,6 +78,10 @@ export const useMonitorStore = defineStore("monitor", () => {
});
const showCloseDialog = ref(false);
const postponeResumeTime = ref<number | null>(null);
const showUpdateDialog = ref(false);
const updateInfo = ref<{ version: string; notes: string; date: string } | null>(null);
const updateProgress = ref<{ total: number; downloaded: number } | null>(null);
const isUpdating = ref(false);
// ===== 内部状态 =====
let sessionId = "";
@@ -85,7 +91,9 @@ export const useMonitorStore = defineStore("monitor", () => {
let postponeTimer: ReturnType<typeof setTimeout> | null = null;
let unlistenNotification: UnlistenFn | null = null;
let unlistenClose: UnlistenFn | null = null;
let pendingUpdate: Update | null = null;
let trayIconInstance: TrayIcon | null = null;
let cachedSvgImg: HTMLImageElement | null = null;
// ===== 工具函数 =====
function formatTime(date: Date): string {
@@ -541,6 +549,60 @@ export const useMonitorStore = defineStore("monitor", () => {
}
// ===== 配置更新 =====
async function checkForUpdates() {
try {
addLog("INFO", "正在检查应用更新...", "UPDATER");
const update = await check();
if (update) {
pendingUpdate = update;
updateInfo.value = {
version: update.version,
notes: update.body || "",
date: update.date || "",
};
showUpdateDialog.value = true;
addLog("INFO", `发现新版本: ${update.version}`, "UPDATER");
} else {
addLog("INFO", "当前已是最新版本", "UPDATER");
}
} catch (e: any) {
addLog("WARN", `检查更新失败: ${e.message || e}`, "UPDATER");
}
}
async function confirmUpdate() {
if (!pendingUpdate) return;
isUpdating.value = true;
updateProgress.value = { total: 0, downloaded: 0 };
try {
addLog("INFO", "开始下载更新...", "UPDATER");
await pendingUpdate.downloadAndInstall((event) => {
if (event.event === "Started" && event.data.contentLength) {
updateProgress.value = { total: event.data.contentLength, downloaded: 0 };
} else if (event.event === "Progress") {
if (updateProgress.value) {
updateProgress.value.downloaded += event.data.chunkLength;
}
} else if (event.event === "Finished") {
addLog("INFO", "更新下载完成,即将重启...", "UPDATER");
}
});
await relaunch();
} catch (e: any) {
addLog("ERROR", `更新安装失败: ${e.message || e}`, "UPDATER");
message.value = "更新安装失败";
isUpdating.value = false;
updateProgress.value = null;
}
}
function dismissUpdate() {
showUpdateDialog.value = false;
pendingUpdate = null;
updateInfo.value = null;
}
async function updateConfig() {
isLoading.value = true;
try {
@@ -563,29 +625,59 @@ export const useMonitorStore = defineStore("monitor", () => {
// ===== 系统托盘 =====
/** 用 Canvas 渲染彩色圆形背景 + 白色"工"字,返回 RGBA 像素数据 */
function makeTrayIconRGBA(r: number, g: number, b: number, size = 32): Uint8Array {
/** 加载 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 数据 */
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")!;
// 圆形背景
ctx.clearRect(0, 0, size, size);
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2 - 1, 0, Math.PI * 2);
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.fill();
try {
const svgImg = await loadSvgImage();
// 白色"工"字
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);
ctx.drawImage(svgImg, 0, 0, size, size);
const imageData = ctx.getImageData(0, 0, size, size);
const data = imageData.data;
const imageData = ctx.getImageData(0, 0, size, size);
return new Uint8Array(imageData.data.buffer);
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);
}
/** 根据当前应用状态更新托盘图标和提示文字 */
@@ -620,7 +712,7 @@ export const useMonitorStore = defineStore("monitor", () => {
tooltip = `${APP_NAME} - 监测中`;
}
const icon = await Image.new(makeTrayIconRGBA(...color), 32, 32);
const icon = await Image.new(await makeTrayIconRGBA(...color), 32, 32);
await trayIconInstance.setIcon(icon);
await trayIconInstance.setTooltip(tooltip);
} catch (e: any) {
@@ -686,7 +778,7 @@ export const useMonitorStore = defineStore("monitor", () => {
const menu = await buildTrayMenu();
// 初始状态:灰色(未开始)
const icon = await Image.new(makeTrayIconRGBA(158, 158, 158), 32, 32);
const icon = await Image.new(await makeTrayIconRGBA(158, 158, 158), 32, 32);
trayIconInstance = await TrayIcon.new({
icon,
@@ -763,6 +855,7 @@ export const useMonitorStore = defineStore("monitor", () => {
}
checkNetworkStatus();
checkForUpdates();
if (config.value.auto_monitor && rememberPassword.value) {
const canAutoStart = loginMode.value === "token"
@@ -795,12 +888,14 @@ export const useMonitorStore = defineStore("monitor", () => {
isLoggedIn, isMonitoring, isPostponed, postponeResumeTime, autoStartEnabled,
logs, networkStatus, config, ticketCounts,
showCloseDialog,
showUpdateDialog, updateInfo, updateProgress, isUpdating,
// 方法
initialize, cleanup,
addLog, clearLogs, clearMessage, saveSettings, exportLogs,
startMonitoring, stopMonitoring, resumeMonitoring, manualCheck,
testNotification, checkNetworkStatus, updateConfig,
minimizeToTray, handleCloseAction,
checkForUpdates, confirmUpdate, dismissUpdate,
};
});