refactor: ♻️ 重构文件结构

This commit is contained in:
tsl
2026-03-31 17:08:30 +08:00
parent 1b6e12e402
commit 0b2c1ca83f
53 changed files with 10813 additions and 2410 deletions
+95 -24
View File
@@ -1,43 +1,114 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import { SettingOutlined, DashboardOutlined, ToolOutlined, FileTextOutlined } from "@ant-design/icons-vue";
import { getVersion } from "@tauri-apps/api/app";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import {
SettingOutlined,
DashboardOutlined,
ToolOutlined,
FileTextOutlined,
} from "@ant-design/icons-vue";
import { isEnabled as isAutostartEnabled } from "@tauri-apps/plugin-autostart";
import { ref, computed, onMounted, onUnmounted } from "vue";
import StatusHeader from "./components/StatusHeader.vue";
import LoginForm from "./components/LoginForm.vue";
import MonitorControl from "./components/MonitorControl.vue";
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";
import AppStatusBar, { type StatusBarItem } from "@/components/AppStatusBar.vue";
import CloseDialog from "@/components/CloseDialog.vue";
import LogViewer from "@/components/LogViewer.vue";
import LoginForm from "@/components/LoginForm.vue";
import MonitorControl from "@/components/MonitorControl.vue";
import SettingsPanel from "@/components/SettingsPanel.vue";
import StatusHeader from "@/components/StatusHeader.vue";
import UpdateDialog from "@/components/UpdateDialog.vue";
import { useUpdater } from "@/composables/useUpdater";
import { useAppStore } from "@/stores/app";
import { useLogStore } from "@/stores/log";
import { useMonitorStore } from "@/stores/monitor";
import { useNetworkStore } from "@/stores/network";
const activeTab = ref("monitor");
const store = useMonitorStore();
const appStore = useAppStore();
const logStore = useLogStore();
const updater = useUpdater();
const networkStore = useNetworkStore();
/** 通知动作事件的取消监听函数 */
let unlistenNotification: UnlistenFn | null = null;
/** 窗口关闭请求事件的取消监听函数 */
let unlistenClose: UnlistenFn | null = null;
const statusItems = computed<StatusBarItem[]>(() => {
const items: StatusBarItem[] = [];
if (store.isPostponed) {
items.push({ key: "postponed", type: "warning", content: store.message });
} else if (store.message) {
items.push({ key: "message", type: "info", content: store.message });
items.push({ key: "postponed", type: "warning", content: appStore.message });
} else if (appStore.message) {
items.push({ key: "message", type: "info", content: appStore.message });
}
return items;
});
onMounted(() => {
store.initialize();
onMounted(async () => {
appStore.loadConfig();
store.loadCredentials();
await store.setupTray();
try {
const enabled = await isAutostartEnabled();
appStore.autoStartEnabled = enabled;
appStore.config.auto_start = enabled;
} catch {
/* ignore */
}
unlistenNotification = await listen<string>("notification-action", (event) => {
if (event.payload === "postpone_1h") store.postponeMonitoring();
else if (event.payload === "go_handle") store.pauseForHandle();
});
unlistenClose = await listen<void>("close-requested", async () => {
const action = appStore.config.close_action;
if (action === "minimize") {
await getCurrentWindow().hide();
} else if (action === "close") {
await getCurrentWindow().destroy();
} else {
appStore.showCloseDialog = true;
}
});
logStore.addLog("INFO", "应用程序启动完成", "SYSTEM");
if (appStore.config.silent_start) {
try {
await getCurrentWindow().hide();
logStore.addLog("INFO", "静默启动:窗口已隐藏到托盘", "SYSTEM");
} catch {
/* ignore */
}
}
if (import.meta.env.DEV) {
getCurrentWindow().setTitle("工单系统监测【开发版】");
getCurrentWindow().setTitle(import.meta.env.VITE_APP_NAME);
}
getVersion()
.then((v) => {
updater.currentVersion.value = v;
})
.catch(() => {});
networkStore.checkNetworkStatus();
updater.checkForUpdates();
if (appStore.config.auto_monitor && store.rememberPassword) {
const canAutoStart = store.loginMode === "token" ? !!store.tokenSessionId : !!(store.username && store.password);
if (canAutoStart) {
logStore.addLog("INFO", "自动登录并开始监测...", "SYSTEM");
await store.startMonitoring();
}
}
});
onUnmounted(() => store.cleanup());
onUnmounted(() => {
store.stopMonitoring();
unlistenNotification?.();
unlistenClose?.();
});
</script>
<template>
@@ -47,7 +118,7 @@ onUnmounted(() => store.cleanup());
</a-layout-header>
<a-layout-content class="app-content">
<a-tabs v-model:activeKey="activeTab" tab-position="left" class="main-tabs">
<a-tabs v-model:active-key="activeTab" tab-position="left" class="main-tabs">
<a-tab-pane key="monitor">
<template #tab>
<span><DashboardOutlined /> 监测</span>
+67 -67
View File
@@ -1,67 +1,67 @@
<script setup lang="ts">
import {
InfoCircleOutlined,
ExclamationCircleOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
} from "@ant-design/icons-vue";
export interface StatusBarItem {
key: string;
content: string;
type?: "info" | "warning" | "success" | "error";
}
defineProps<{ items?: StatusBarItem[] }>();
const colorMap: Record<string, string> = {
info: "rgba(255,255,255,0.85)",
warning: "#ffe58f",
success: "#95de64",
error: "#ff7875",
};
const iconMap: Record<string, unknown> = {
info: InfoCircleOutlined,
warning: ExclamationCircleOutlined,
success: CheckCircleOutlined,
error: CloseCircleOutlined,
};
</script>
<template>
<div class="app-statusbar">
<template v-for="(item, index) in items" :key="item.key">
<span v-if="index > 0" class="statusbar-sep" />
<span class="statusbar-item" :style="{ color: colorMap[item.type ?? 'info'] }">
<component :is="iconMap[item.type ?? 'info']" />
{{ item.content }}
</span>
</template>
</div>
</template>
<style scoped>
.app-statusbar {
display: flex;
align-items: center;
padding: 0 16px;
height: 28px;
background: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(8px);
flex-shrink: 0;
font-size: 12px;
}
.statusbar-item {
display: flex;
align-items: center;
gap: 5px;
padding: 0 10px;
height: 100%;
}
.statusbar-sep {
width: 1px;
height: 14px;
background: rgba(255, 255, 255, 0.2);
}
</style>
<script setup lang="ts">
import {
InfoCircleOutlined,
ExclamationCircleOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
} from "@ant-design/icons-vue";
export interface StatusBarItem {
key: string;
content: string;
type?: "info" | "warning" | "success" | "error";
}
defineProps<{ items?: StatusBarItem[] }>();
const colorMap: Record<string, string> = {
info: "rgba(255,255,255,0.85)",
warning: "#ffe58f",
success: "#95de64",
error: "#ff7875",
};
const iconMap: Record<string, unknown> = {
info: InfoCircleOutlined,
warning: ExclamationCircleOutlined,
success: CheckCircleOutlined,
error: CloseCircleOutlined,
};
</script>
<template>
<div class="app-statusbar">
<template v-for="(item, index) in items" :key="item.key">
<span v-if="index > 0" class="statusbar-sep" />
<span class="statusbar-item" :style="{ color: colorMap[item.type ?? 'info'] }">
<component :is="iconMap[item.type ?? 'info']" />
{{ item.content }}
</span>
</template>
</div>
</template>
<style scoped>
.app-statusbar {
display: flex;
align-items: center;
padding: 0 16px;
height: 28px;
background: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(8px);
flex-shrink: 0;
font-size: 12px;
}
.statusbar-item {
display: flex;
align-items: center;
gap: 5px;
padding: 0 10px;
height: 100%;
}
.statusbar-sep {
width: 1px;
height: 14px;
background: rgba(255, 255, 255, 0.2);
}
</style>
+36 -41
View File
@@ -1,41 +1,36 @@
<script setup lang="ts">
import { ref } from "vue";
import { useMonitorStore } from "../stores/monitor";
const store = useMonitorStore();
const rememberClose = ref(false);
function handleClose(action: "minimize" | "close") {
store.handleCloseAction(action, rememberClose.value);
rememberClose.value = false;
}
</script>
<template>
<a-modal
v-model:open="store.showCloseDialog"
title="关闭窗口"
:width="340"
centered
>
<div class="dialog-body">
<p class="dialog-tip">请选择关闭窗口时的操作:</p>
<a-checkbox v-model:checked="rememberClose">记住我的选择</a-checkbox>
</div>
<template #footer>
<a-button @click="handleClose('minimize')">最小化到托盘</a-button>
<a-button danger type="primary" @click="handleClose('close')">直接退出程序</a-button>
</template>
</a-modal>
</template>
<style scoped>
.dialog-body {
padding: 4px 0 8px;
}
.dialog-tip {
margin: 0 0 12px;
color: #555;
}
</style>
<script setup lang="ts">
import { ref } from "vue";
import { useAppStore } from "@/stores/app";
const appStore = useAppStore();
const rememberClose = ref(false);
function handleClose(action: "minimize" | "close") {
appStore.handleCloseAction(action, rememberClose.value);
rememberClose.value = false;
}
</script>
<template>
<a-modal v-model:open="appStore.showCloseDialog" title="关闭窗口" :width="340" centered>
<div class="dialog-body">
<p class="dialog-tip">请选择关闭窗口时的操作:</p>
<a-checkbox v-model:checked="rememberClose"> 记住我的选择 </a-checkbox>
</div>
<template #footer>
<a-button @click="handleClose('minimize')"> 最小化到托盘 </a-button>
<a-button danger type="primary" @click="handleClose('close')"> 直接退出程序 </a-button>
</template>
</a-modal>
</template>
<style scoped>
.dialog-body {
padding: 4px 0 8px;
}
.dialog-tip {
margin: 0 0 12px;
color: #555;
}
</style>
+18 -16
View File
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { computed } from "vue";
import { DeleteOutlined, DownloadOutlined } from "@ant-design/icons-vue";
import { useMonitorStore } from "../stores/monitor";
import { computed } from "vue";
import { useLogStore } from "@/stores/log";
const store = useMonitorStore();
const reversedLogs = computed(() => [...store.logs].reverse());
const logStore = useLogStore();
const reversedLogs = computed(() => [...logStore.logs].reverse());
const levelColor: Record<string, string> = {
INFO: "blue",
@@ -17,32 +17,34 @@ const levelColor: Record<string, string> = {
<template>
<div class="log-viewer">
<div class="log-viewer__toolbar">
<span class="log-viewer__count">共 {{ store.logs.length }} 条日志</span>
<span class="log-viewer__count">共 {{ logStore.logs.length }} 条日志</span>
<a-space>
<a-button size="small" :disabled="store.logs.length === 0" @click="store.exportLogs()">
<template #icon><DownloadOutlined /></template>
<a-button size="small" :disabled="logStore.logs.length === 0" @click="logStore.exportLogs()">
<template #icon>
<DownloadOutlined />
</template>
导出
</a-button>
<a-button size="small" danger @click="store.clearLogs()">
<template #icon><DeleteOutlined /></template>
<a-button size="small" danger @click="logStore.clearLogs()">
<template #icon>
<DeleteOutlined />
</template>
清除
</a-button>
</a-space>
</div>
<a-empty v-if="store.logs.length === 0" description="暂无日志记录" />
<a-empty v-if="logStore.logs.length === 0" description="暂无日志记录" />
<div v-else class="log-viewer__list">
<div
v-for="log in reversedLogs"
:key="`${log.timestamp}-${log.message}`"
class="log-item"
>
<div v-for="log in reversedLogs" :key="`${log.timestamp}-${log.message}`" class="log-item">
<span class="log-item__time">{{ log.timestamp }}</span>
<a-tag :color="levelColor[log.level] || 'default'" size="small" class="log-item__tag">
{{ log.level }}
</a-tag>
<a-tag color="geekblue" size="small" class="log-item__tag">{{ log.category }}</a-tag>
<a-tag color="geekblue" size="small" class="log-item__tag">
{{ log.category }}
</a-tag>
<span class="log-item__msg">{{ log.message }}</span>
</div>
</div>
+38 -21
View File
@@ -1,32 +1,45 @@
<script setup lang="ts">
import { useMonitorStore } from "../stores/monitor";
import { ref } from "vue";
import { useMonitorStore } from "@/stores/monitor";
const store = useMonitorStore();
/** 表单操作加载状态 */
const isLoading = ref(false);
async function handleSaveSettings() {
isLoading.value = true;
await store.saveSettings();
isLoading.value = false;
}
async function handleStartMonitoring() {
isLoading.value = true;
await store.startMonitoring();
isLoading.value = false;
}
async function handleStopMonitoring() {
isLoading.value = true;
await store.stopMonitoring();
isLoading.value = false;
}
</script>
<template>
<a-form layout="vertical">
<a-form-item>
<a-radio-group v-model:value="store.loginMode" :disabled="store.isMonitoring">
<a-radio-button value="password">账号密码登录</a-radio-button>
<a-radio-button value="token">Token 登录</a-radio-button>
<a-radio-button value="password"> 账号密码登录 </a-radio-button>
<a-radio-button value="token"> Token 登录 </a-radio-button>
</a-radio-group>
</a-form-item>
<template v-if="store.loginMode === 'password'">
<a-form-item label="用户名">
<a-input
v-model:value="store.username"
placeholder="请输入用户名"
:disabled="store.isLoading"
/>
<a-input v-model:value="store.username" placeholder="请输入用户名" :disabled="isLoading" />
</a-form-item>
<a-form-item label="密码">
<a-input-password
v-model:value="store.password"
placeholder="请输入密码"
:disabled="store.isLoading"
/>
<a-input-password v-model:value="store.password" placeholder="请输入密码" :disabled="isLoading" />
</a-form-item>
</template>
@@ -35,29 +48,33 @@ const store = useMonitorStore();
<a-input
v-model:value="store.tokenSessionId"
placeholder="请输入 ASP.NET_SessionId"
:disabled="store.isLoading"
:disabled="isLoading"
allow-clear
/>
</a-form-item>
<a-typography-text type="secondary" style="font-size: 12px; display: block; margin-bottom: 12px;">
<a-typography-text type="secondary" style="font-size: 12px; display: block; margin-bottom: 12px">
从浏览器开发者工具 (F12) → Application → Cookies 中复制 ASP.NET_SessionId 的值
</a-typography-text>
</template>
<a-form-item>
<a-checkbox v-model:checked="store.rememberPassword">
记住{{ store.loginMode === 'password' ? '密码' : 'Token' }}
记住{{ store.loginMode === "password" ? "密码" : "Token" }}
</a-checkbox>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" :loading="store.isLoading" @click="store.saveSettings()">
保存设置
</a-button>
<a-button type="primary" :loading="store.isLoading" :disabled="store.isMonitoring" style="background: #52c41a; border-color: #52c41a" @click="store.startMonitoring()">
<a-button type="primary" :loading="isLoading" @click="handleSaveSettings()"> 保存设置 </a-button>
<a-button
type="primary"
:loading="isLoading"
:disabled="store.isMonitoring"
style="background: #52c41a; border-color: #52c41a"
@click="handleStartMonitoring()"
>
开始监测
</a-button>
<a-button danger :disabled="store.isLoading || !store.isMonitoring" @click="store.stopMonitoring()">
<a-button danger :disabled="isLoading || !store.isMonitoring" @click="handleStopMonitoring()">
停止监测
</a-button>
</a-space>
+49 -17
View File
@@ -1,7 +1,4 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import {
ReloadOutlined,
MinusCircleOutlined,
@@ -9,11 +6,24 @@ import {
WifiOutlined,
ClockCircleOutlined,
} from "@ant-design/icons-vue";
import { useMonitorStore } from "../stores/monitor";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import { ref, onMounted, onUnmounted } from "vue";
import { useMonitorStore } from "@/stores/monitor";
import { useNetworkStore } from "@/stores/network";
dayjs.extend(duration);
const store = useMonitorStore();
const networkStore = useNetworkStore();
/** 手动检查加载状态 */
const isLoading = ref(false);
async function handleManualCheck() {
isLoading.value = true;
await store.manualCheck();
isLoading.value = false;
}
const countdown = ref("");
let countdownTimer: ReturnType<typeof setInterval> | null = null;
@@ -46,16 +56,32 @@ onUnmounted(() => {
<div class="monitor-control">
<div class="ticket-dashboard">
<div class="ticket-card ticket-card--pending">
<a-statistic title="待处理" :value="store.ticketCounts.pending" :value-style="{ color: store.ticketCounts.pending > 0 ? '#cf1322' : '#3f8600' }" />
<a-statistic
title="待处理"
:value="store.ticketCounts.pending"
:value-style="{ color: store.ticketCounts.pending > 0 ? '#cf1322' : '#3f8600' }"
/>
</div>
<div class="ticket-card ticket-card--stayclose">
<a-statistic title="待关闭" :value="store.ticketCounts.stayclose" :value-style="{ color: store.ticketCounts.stayclose > 0 ? '#d46b08' : '#3f8600' }" />
<a-statistic
title="待关闭"
:value="store.ticketCounts.stayclose"
:value-style="{ color: store.ticketCounts.stayclose > 0 ? '#d46b08' : '#3f8600' }"
/>
</div>
<div class="ticket-card ticket-card--confirm">
<a-statistic title="待确认" :value="store.ticketCounts.confirm" :value-style="{ color: store.ticketCounts.confirm > 0 ? '#1677ff' : '#3f8600' }" />
<a-statistic
title="待确认"
:value="store.ticketCounts.confirm"
:value-style="{ color: store.ticketCounts.confirm > 0 ? '#1677ff' : '#3f8600' }"
/>
</div>
<div class="ticket-card ticket-card--workorder">
<a-statistic title="待审核" :value="store.ticketCounts.workOrderCount" :value-style="{ color: store.ticketCounts.workOrderCount > 0 ? '#722ed1' : '#3f8600' }" />
<a-statistic
title="待审核"
:value="store.ticketCounts.workOrderCount"
:value-style="{ color: store.ticketCounts.workOrderCount > 0 ? '#722ed1' : '#3f8600' }"
/>
</div>
</div>
@@ -65,26 +91,32 @@ onUnmounted(() => {
<div v-if="store.isPostponed && countdown" class="postpone-countdown">
<ClockCircleOutlined /> 监测已暂停,<span class="countdown-time">{{ countdown }}</span> 后自动恢复
<a-button size="small" type="link" @click="store.resumeMonitoring()">
立即恢复
</a-button>
<a-button size="small" type="link" @click="store.resumeMonitoring()"> 立即恢复 </a-button>
</div>
<a-space wrap :size="[12, 12]" style="margin-top: 20px">
<a-button :loading="store.isLoading" :disabled="!store.isLoggedIn" @click="store.manualCheck()">
<template #icon><ReloadOutlined /></template>
<a-button :loading="isLoading" :disabled="!store.isLoggedIn" @click="handleManualCheck()">
<template #icon>
<ReloadOutlined />
</template>
手动检查
</a-button>
<a-button @click="store.checkNetworkStatus()">
<template #icon><WifiOutlined /></template>
<a-button @click="networkStore.checkNetworkStatus()">
<template #icon>
<WifiOutlined />
</template>
网络检测
</a-button>
<a-button @click="store.testNotification()">
<template #icon><BellOutlined /></template>
<template #icon>
<BellOutlined />
</template>
测试通知
</a-button>
<a-button @click="store.minimizeToTray()">
<template #icon><MinusCircleOutlined /></template>
<template #icon>
<MinusCircleOutlined />
</template>
最小化到托盘
</a-button>
</a-space>
+71 -46
View File
@@ -1,101 +1,100 @@
<script setup lang="ts">
import { computed, watch } from "vue";
import { CloudSyncOutlined } from "@ant-design/icons-vue";
import dayjs, { type Dayjs } from "dayjs";
import { useMonitorStore } from "../stores/monitor";
import { debounce } from "lodash-es";
import { computed, watch } from "vue";
import { useUpdater } from "@/composables/useUpdater";
import { useAppStore } from "@/stores/app";
const store = useMonitorStore();
const appStore = useAppStore();
const { currentVersion, isCheckingUpdates, isUpdating, checkForUpdates } = useUpdater();
// ===== 检查间隔:内部用秒,TimePicker 用 Dayjs =====
const intervalTime = computed<Dayjs>({
get() {
const t = store.config.check_interval;
return dayjs().hour(Math.floor(t / 3600)).minute(Math.floor((t % 3600) / 60)).second(t % 60).millisecond(0);
const t = appStore.config.check_interval;
const hours = Math.floor(t / 3600);
const minutes = Math.floor((t % 3600) / 60);
const seconds = t % 60;
return dayjs().hour(hours).minute(minutes).second(seconds).millisecond(0);
},
set(val: Dayjs | null) {
if (!val) return;
store.config.check_interval = Math.max(10, val.hour() * 3600 + val.minute() * 60 + val.second());
const totalSeconds = val.hour() * 3600 + val.minute() * 60 + val.second();
appStore.config.check_interval = Math.max(10, totalSeconds);
},
});
// 自动保存:config 变化后防抖 500ms 触发保存
let saveTimer: ReturnType<typeof setTimeout> | null = null;
watch(
() => store.config,
() => {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => store.updateConfig(), 500);
},
{ deep: true }
);
const saveConfig = debounce(() => appStore.updateConfig(), 500);
watch(() => appStore.config, saveConfig, { deep: true });
</script>
<template>
<a-form layout="vertical">
<a-form-item label="检查间隔">
<a-time-picker
v-model:value="intervalTime"
format="HH:mm:ss"
:allow-clear="false"
style="width: 100%"
/>
<div class="form-hint">最短 10 秒,当前 {{ store.config.check_interval }} 秒</div>
<a-time-picker v-model:value="intervalTime" format="HH:mm:ss" :allow-clear="false" style="width: 100%" />
<div class="form-hint">最短 10 秒,当前 {{ appStore.config.check_interval }} 秒</div>
</a-form-item>
<a-form-item label="网络超时(秒)">
<a-input-number
v-model:value="store.config.network_timeout"
:min="5"
:max="120"
style="width: 100%"
/>
<a-input-number v-model:value="appStore.config.network_timeout" :min="5" :max="120" style="width: 100%" />
</a-form-item>
<a-divider />
<div class="switch-row">
<div class="switch-label">开机自启动</div>
<a-switch v-model:checked="store.config.auto_start" />
<a-switch v-model:checked="appStore.config.auto_start" />
</div>
<div class="switch-row">
<div class="switch-label">
启动后自动监测
<div class="form-hint">需同时开启「记住密码」</div>
</div>
<a-switch v-model:checked="store.config.auto_monitor" />
<a-switch v-model:checked="appStore.config.auto_monitor" />
</div>
<div class="switch-row">
<div class="switch-label">
静默启动
<div class="form-hint">启动时直接最小化到托盘</div>
</div>
<a-switch v-model:checked="store.config.silent_start" />
<a-switch v-model:checked="appStore.config.silent_start" />
</div>
<a-divider />
<div class="switch-row">
<div class="switch-label">显示系统通知</div>
<a-switch v-model:checked="store.config.show_notifications" />
<a-switch v-model:checked="appStore.config.show_notifications" />
</div>
<div class="switch-row">
<div class="switch-label">通知提示音</div>
<a-switch
v-model:checked="store.config.notification_sound"
:disabled="!store.config.show_notifications"
/>
<a-switch v-model:checked="appStore.config.notification_sound" :disabled="!appStore.config.show_notifications" />
</div>
<a-divider />
<a-form-item label="点击「去处理」后暂停时长(分钟)">
<a-input-number
v-model:value="store.config.handle_pause_minutes"
:min="1"
:max="120"
style="width: 100%"
/>
<a-input-number v-model:value="appStore.config.handle_pause_minutes" :min="1" :max="120" style="width: 100%" />
</a-form-item>
<a-divider />
<a-form-item label="关闭窗口行为">
<a-radio-group v-model:value="store.config.close_action">
<a-radio value="ask">每次询问</a-radio>
<a-radio value="minimize">最小化到托盘</a-radio>
<a-radio value="close">直接退出</a-radio>
<a-radio-group v-model:value="appStore.config.close_action">
<a-radio value="ask"> 每次询问 </a-radio>
<a-radio value="minimize"> 最小化到托盘 </a-radio>
<a-radio value="close"> 直接退出 </a-radio>
</a-radio-group>
</a-form-item>
<a-divider />
<div class="about-card">
<div class="about-card__row">
<div>
<div class="about-card__title">当前版本</div>
<div class="about-card__value">v{{ currentVersion || "--" }}</div>
</div>
<a-button type="primary" :loading="isCheckingUpdates" :disabled="isUpdating" @click="checkForUpdates()">
<template #icon>
<CloudSyncOutlined />
</template>
检查更新
</a-button>
</div>
<div class="form-hint">手动检查是否有可安装的新版本。</div>
</div>
</a-form>
</template>
@@ -125,4 +124,30 @@ watch(
flex: 1;
font-size: 14px;
}
.about-card {
padding: 16px;
border: 1px solid #f0f0f0;
border-radius: 10px;
background: #fafafa;
}
.about-card__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.about-card__title {
font-size: 13px;
color: #888;
}
.about-card__value {
margin-top: 4px;
font-size: 18px;
font-weight: 600;
color: #262626;
}
</style>
+37 -34
View File
@@ -1,48 +1,46 @@
<script setup lang="ts">
import {
CheckCircleFilled,
CloseCircleFilled,
SyncOutlined,
PauseCircleFilled,
} from "@ant-design/icons-vue";
import { APP_NAME, useMonitorStore } from "../stores/monitor";
import { CheckCircleFilled, CloseCircleFilled, SyncOutlined, PauseCircleFilled } from "@ant-design/icons-vue";
import { APP_NAME } from "@/constants/app";
import { useMonitorStore } from "@/stores/monitor";
import { useNetworkStore } from "@/stores/network";
const store = useMonitorStore();
const networkStore = useNetworkStore();
function getNetworkStatusText() {
if (!store.networkStatus.is_connected) return "网络断开";
if (!store.networkStatus.api_reachable) return "API不可达";
if (!networkStore.isConnected) return "网络断开";
if (!networkStore.apiReachable) return "API不可达";
return "网络正常";
}
</script>
<template>
<div class="status-header">
<h1 class="status-header__title">{{ APP_NAME }}</h1>
<a-space :size="12">
<a-tag :color="store.isLoggedIn ? 'success' : 'error'">
<template #icon>
<CheckCircleFilled v-if="store.isLoggedIn" />
<CloseCircleFilled v-else />
</template>
{{ store.isLoggedIn ? "已登录" : "未登录" }}
</a-tag>
<a-tag v-if="store.isMonitoring" :color="store.isPostponed ? 'warning' : 'processing'">
<template #icon>
<PauseCircleFilled v-if="store.isPostponed" />
<SyncOutlined v-else :spin="true" />
</template>
{{ store.isPostponed ? "已推迟1小时" : "监测中" }}
</a-tag>
<a-tag
:color="store.networkStatus.api_reachable ? 'success' : store.networkStatus.is_connected ? 'warning' : 'error'"
>
{{ getNetworkStatusText() }}
</a-tag>
<span v-if="store.networkStatus.response_time" class="response-time">
{{ store.networkStatus.response_time }}ms
</span>
</a-space>
<h1 class="status-header__title">
{{ APP_NAME }}
</h1>
<div class="status-header__actions">
<a-space :size="12" wrap>
<a-tag :color="store.isLoggedIn ? 'success' : 'error'">
<template #icon>
<CheckCircleFilled v-if="store.isLoggedIn" />
<CloseCircleFilled v-else />
</template>
{{ store.isLoggedIn ? "已登录" : "未登录" }}
</a-tag>
<a-tag v-if="store.isMonitoring" :color="store.isPostponed ? 'warning' : 'processing'">
<template #icon>
<PauseCircleFilled v-if="store.isPostponed" />
<SyncOutlined v-else :spin="true" />
</template>
{{ store.isPostponed ? "已推迟1小时" : "监测中" }}
</a-tag>
<a-tag :color="networkStore.apiReachable ? 'success' : networkStore.isConnected ? 'warning' : 'error'">
{{ getNetworkStatusText() }}
</a-tag>
<span v-if="networkStore.responseTime" class="response-time"> {{ networkStore.responseTime }}ms </span>
</a-space>
</div>
</div>
</template>
@@ -60,6 +58,11 @@ function getNetworkStatusText() {
font-weight: 600;
color: #fff;
}
.status-header__actions {
display: flex;
align-items: center;
justify-content: flex-end;
}
.response-time {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
+101 -99
View File
@@ -1,99 +1,101 @@
<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>
<script setup lang="ts">
import { computed } from "vue";
import { useUpdater } from "@/composables/useUpdater";
const { showUpdateDialog, updateInfo, updateProgress, isUpdating, confirmUpdate, dismissUpdate } = useUpdater();
const progressPercent = computed(() => {
if (!updateProgress.value || !updateProgress.value.total) return 0;
return Math.round((updateProgress.value.downloaded / updateProgress.value.total) * 100);
});
const downloadedMB = computed(() => {
if (!updateProgress.value) return "0";
return (updateProgress.value.downloaded / 1024 / 1024).toFixed(1);
});
const totalMB = computed(() => {
if (!updateProgress.value || !updateProgress.value.total) return "0";
return (updateProgress.value.total / 1024 / 1024).toFixed(1);
});
</script>
<template>
<a-modal
v-model:open="showUpdateDialog"
title="发现新版本"
:width="400"
centered
:closable="!isUpdating"
:mask-closable="!isUpdating"
>
<div class="update-body">
<div v-if="!isUpdating">
<p class="version-info">
新版本 <span class="version-tag">v{{ updateInfo?.version }}</span> 已发布
</p>
<div v-if="updateInfo?.notes" class="update-notes">
<p class="notes-title">更新说明:</p>
<p class="notes-content">
{{ 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="!isUpdating">
<a-button @click="dismissUpdate()"> 稍后提醒 </a-button>
<a-button type="primary" @click="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>
-582
View File
@@ -1,582 +0,0 @@
import { ref, onMounted, onUnmounted } from "vue";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { TrayIcon } from "@tauri-apps/api/tray";
import { Image } from "@tauri-apps/api/image";
import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { fetch } from "@tauri-apps/plugin-http";
import {
isPermissionGranted,
requestPermission,
} from "@tauri-apps/plugin-notification";
import {
enable as enableAutostart,
disable as disableAutostart,
isEnabled as isAutostartEnabled,
} from "@tauri-apps/plugin-autostart";
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { resolveResource } from "@tauri-apps/api/path";
export const APP_NAME = "工单系统监测";
const API_BASE = "https://crm.yunvip123.com/api";
const LOGIN_URL = `${API_BASE}/SystemUser/Login`;
const CHECK_URL = `${API_BASE}/DemandManage/QueryIndexCount`;
const WORK_ORDER_URL = `${API_BASE}/DemandManage/GetWorkOrderListPage`;
export interface LogEntry {
timestamp: string;
level: string;
message: string;
category: string;
}
export interface TicketCounts {
pending: number;
stayclose: number;
confirm: number;
workOrderCount: number;
lastCheck: string;
}
export function useMonitor() {
// ===== 响应式数据 =====
const username = ref("");
const password = ref("");
const rememberPassword = ref(false);
const message = ref("");
const isLoading = ref(false);
const isLoggedIn = ref(false);
const isMonitoring = ref(false);
const isPostponed = ref(false);
const autoStartEnabled = ref(false);
const logs = ref<LogEntry[]>([]);
const ticketCounts = ref<TicketCounts>({
pending: 0, stayclose: 0, confirm: 0, workOrderCount: 0, lastCheck: "",
});
const networkStatus = ref({
is_connected: false,
api_reachable: false,
response_time: null as number | null,
last_check: "",
});
const config = ref({
check_interval: 60,
auto_start: false,
auto_monitor: false,
show_notifications: true,
notification_sound: true,
network_timeout: 30,
});
// ===== 内部状态 =====
let sessionId = "";
let userGid = "";
let userName = "";
let monitorTimer: ReturnType<typeof setInterval> | null = null;
let postponeTimer: ReturnType<typeof setTimeout> | null = null;
let unlistenNotification: UnlistenFn | null = null;
// ===== 工具函数 =====
function formatTime(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}:${String(date.getSeconds()).padStart(2, "0")}`;
}
function addLog(level: string, msg: string, category: string) {
logs.value.push({ timestamp: formatTime(new Date()), level, message: msg, category });
if (logs.value.length > 1000) logs.value.shift();
}
function clearLogs() {
logs.value = [];
message.value = "日志已清除";
}
function clearMessage() {
message.value = "";
}
// ===== 通知声音 =====
function playNotificationSound() {
if (!config.value.notification_sound) return;
try {
const ctx = new AudioContext();
const oscillator = ctx.createOscillator();
const gain = ctx.createGain();
oscillator.connect(gain);
gain.connect(ctx.destination);
oscillator.frequency.setValueAtTime(880, ctx.currentTime);
oscillator.frequency.setValueAtTime(660, ctx.currentTime + 0.15);
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.4);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.4);
} catch { /* 静默失败 */ }
}
// ===== 通知 =====
async function notify(title: string, body: string) {
if (!config.value.show_notifications) return;
let granted = await isPermissionGranted();
if (!granted) {
const permission = await requestPermission();
granted = permission === "granted";
}
if (granted) {
try {
await invoke("send_clickable_notification", { title, body });
playNotificationSound();
addLog("INFO", `通知: ${title} - ${body}`, "NOTIFICATION");
} catch (e: any) {
addLog("ERROR", `发送通知失败: ${e}`, "NOTIFICATION");
}
}
}
// ===== API =====
async function apiLogin(): Promise<boolean> {
try {
const body = JSON.stringify({ Account: username.value, PassWord: password.value });
const resp = await fetch(LOGIN_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": String(new TextEncoder().encode(body).length),
Host: "crm.yunvip123.com",
"Cache-Control": "no-cache",
Cookie: "",
},
body,
credentials: "omit",
});
const setCookie = resp.headers.get("set-cookie") || "";
const match = setCookie.match(/ASP\.NET_SessionId=([^;]+)/);
if (match) sessionId = match[1];
addLog("DEBUG", `SessionId: ${sessionId || "(未提取)"}`, "LOGIN");
const data = await resp.json();
if (data.success && data.data) {
userGid = data.data.GID;
userName = data.data.SU_UserName || "用户";
if (!sessionId) {
addLog("WARN", "未能从响应头提取SessionId", "LOGIN");
}
isLoggedIn.value = true;
addLog("INFO", `登录成功,用户: ${userName}, GID: ${userGid}`, "LOGIN");
return true;
} else {
addLog("ERROR", `登录失败: ${data.msg}`, "LOGIN");
return false;
}
} catch (e: any) {
addLog("ERROR", `登录请求失败: ${e.message || e}`, "LOGIN");
return false;
}
}
async function apiCheckWorkOrders(): Promise<number> {
try {
const resp = await fetch(WORK_ORDER_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Cookie: `ASP.NET_SessionId=${sessionId}`,
},
body: JSON.stringify({
isSelect: 8,
isShow: 3,
IsExport: 0,
PageIndex: 1,
PageSize: 20,
}),
});
const result = await resp.json();
if (result.success && result.data) {
const count = result.data.DataCount || 0;
addLog("INFO", `待审核检查完成 - 数据条数: ${count}`, "SCHEDULER");
return count;
} else {
addLog("WARN", `待审核 API 返回异常: ${result.msg}`, "SCHEDULER");
return 0;
}
} catch (e: any) {
addLog("ERROR", `待审核 API 检查失败: ${e.message || e}`, "SCHEDULER");
return 0;
}
}
async function apiCheckStatus(isRetry = false): Promise<void> {
if (!userGid) {
addLog("WARN", "会话已失效,尝试重新登录", "SCHEDULER");
if (!isRetry) await tryReLogin();
return;
}
try {
const resp = await fetch(CHECK_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Cookie: `ASP.NET_SessionId=${sessionId}`,
},
body: JSON.stringify({ UserGID: userGid }),
});
const result = await resp.json();
if (result.success && result.data) {
const d = result.data;
const pending = d.PendingCount || 0;
const stayclose = d.StaycloseCount || 0;
const confirm = d.ConfirmCount || 0;
const workOrderCount = await apiCheckWorkOrders();
ticketCounts.value = {
pending, stayclose, confirm, workOrderCount,
lastCheck: formatTime(new Date()),
};
addLog("INFO", `检查完成 - 待处理: ${pending}, 待关闭: ${stayclose}, 待确认: ${confirm}, 待审核: ${workOrderCount}`, "SCHEDULER");
if (pending > 0 || stayclose > 0 || confirm > 0 || workOrderCount > 0) {
const msgs: string[] = [];
if (pending > 0) msgs.push(`待处理工单: ${pending}`);
if (stayclose > 0) msgs.push(`待关闭工单: ${stayclose}`);
if (confirm > 0) msgs.push(`待确认工单: ${confirm}`);
if (workOrderCount > 0) msgs.push(`待审核: ${workOrderCount}`);
await notify("工单提醒", msgs.join(","));
}
} else {
addLog("WARN", `API 返回异常: ${result.msg}`, "SCHEDULER");
if (!isRetry && isSessionExpiredMsg(result.msg)) {
await tryReLogin();
}
}
} catch (e: any) {
addLog("ERROR", `API 检查失败: ${e.message || e}`, "SCHEDULER");
}
}
function isSessionExpiredMsg(msg: string | undefined): boolean {
if (!msg) return false;
const keywords = ["session", "expired", "unauthorized", "登录", "过期", "失效", "超时"];
const lower = msg.toLowerCase();
return keywords.some((k) => lower.includes(k));
}
async function tryReLogin(): Promise<void> {
if (!username.value || !password.value) {
addLog("WARN", "无法自动重登录:缺少凭据", "SESSION");
return;
}
addLog("INFO", "会话可能已过期,正在自动重新登录...", "SESSION");
const ok = await apiLogin();
if (ok) {
addLog("INFO", "自动重新登录成功,继续检查", "SESSION");
await apiCheckStatus(true);
} else {
addLog("ERROR", "自动重新登录失败,请手动重新登录", "SESSION");
message.value = "会话已过期且自动重登录失败,请手动操作";
}
}
// ===== 网络检测 =====
async function checkNetworkStatus() {
isLoading.value = true;
let isConnected = false;
let apiReachable = false;
let responseTime: number | null = null;
try {
const resp = await fetch("https://www.baidu.com", {
method: "GET",
connectTimeout: config.value.network_timeout * 1000,
});
isConnected = resp.ok;
} catch {
/* 网络不通 */
}
if (isConnected) {
try {
const apiStart = Date.now();
const resp = await fetch(LOGIN_URL, {
method: "HEAD",
connectTimeout: config.value.network_timeout * 1000,
});
responseTime = Date.now() - apiStart;
apiReachable = resp.status < 500;
} catch {
/* API 不可达 */
}
}
networkStatus.value = {
is_connected: isConnected,
api_reachable: apiReachable,
response_time: responseTime,
last_check: formatTime(new Date()),
};
addLog("INFO", `网络检查完成 - 连接: ${isConnected}, API可达: ${apiReachable}`, "NETWORK");
message.value = "网络状态检查完成";
isLoading.value = false;
}
// ===== 凭据存储 =====
function loadCredentials() {
try {
const saved = localStorage.getItem("crm_credentials");
if (saved) {
const data = JSON.parse(saved);
username.value = data.username || "";
password.value = data.password || "";
rememberPassword.value = data.remember || false;
addLog("INFO", "已加载保存的凭据", "CONFIG");
}
} catch (e: any) {
addLog("ERROR", `加载凭据失败: ${e.message || e}`, "CONFIG");
}
}
function saveCredentials() {
try {
if (rememberPassword.value) {
localStorage.setItem(
"crm_credentials",
JSON.stringify({ username: username.value, password: password.value, remember: true })
);
addLog("INFO", "凭据已保存到本地", "CONFIG");
} else {
localStorage.removeItem("crm_credentials");
addLog("INFO", "已清除本地凭据", "CONFIG");
}
} catch (e: any) {
addLog("ERROR", `保存凭据失败: ${e.message || e}`, "CONFIG");
}
}
function loadConfig() {
try {
const saved = localStorage.getItem("crm_config");
if (saved) {
const data = JSON.parse(saved);
config.value = { ...config.value, ...data };
addLog("INFO", "已加载保存的配置", "CONFIG");
}
} catch (e: any) {
addLog("ERROR", `加载配置失败: ${e.message || e}`, "CONFIG");
}
}
function saveConfigToStorage() {
try {
localStorage.setItem("crm_config", JSON.stringify(config.value));
} catch { /* ignore */ }
}
function saveSettings() {
if (!username.value.trim() || !password.value.trim()) {
message.value = "请输入用户名和密码";
return;
}
saveCredentials();
message.value = "设置保存成功";
addLog("INFO", "用户凭据已保存", "CONFIG");
}
function exportLogs() {
if (logs.value.length === 0) {
message.value = "暂无日志可导出";
return;
}
const lines = logs.value.map(
(l) => `[${l.timestamp}] [${l.level}] [${l.category}] ${l.message}`
);
const blob = new Blob([lines.join("\n")], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `monitor-logs-${formatTime(new Date()).replaceAll(/[: ]/g, "-")}.txt`;
a.click();
URL.revokeObjectURL(url);
addLog("INFO", `已导出 ${logs.value.length} 条日志`, "SYSTEM");
message.value = "日志导出成功";
}
// ===== 监测控制 =====
async function startMonitoring() {
if (!username.value.trim() || !password.value.trim()) {
message.value = "请先设置用户名和密码";
return;
}
isLoading.value = true;
const ok = await apiLogin();
if (!ok) {
message.value = "登录失败,无法开始监测";
isLoading.value = false;
return;
}
isMonitoring.value = true;
await apiCheckStatus();
monitorTimer = setInterval(() => apiCheckStatus(), config.value.check_interval * 1000);
addLog("INFO", "监测已启动", "MONITOR");
message.value = `监测已开始,欢迎 ${userName}!`;
isLoading.value = false;
}
function stopMonitoring() {
if (monitorTimer) { clearInterval(monitorTimer); monitorTimer = null; }
if (postponeTimer) { clearTimeout(postponeTimer); postponeTimer = null; }
isPostponed.value = false;
isMonitoring.value = false;
isLoggedIn.value = false;
sessionId = "";
userGid = "";
addLog("INFO", "监测已停止", "MONITOR");
message.value = "监测已停止";
}
function postponeMonitoring() {
if (!isMonitoring.value) return;
if (monitorTimer) { clearInterval(monitorTimer); monitorTimer = null; }
if (postponeTimer) { clearTimeout(postponeTimer); }
isPostponed.value = true;
addLog("INFO", "监测已推迟1小时", "MONITOR");
message.value = "监测已推迟1小时,将在1小时后自动恢复";
postponeTimer = setTimeout(() => {
isPostponed.value = false;
postponeTimer = null;
apiCheckStatus();
monitorTimer = setInterval(() => apiCheckStatus(), config.value.check_interval * 1000);
addLog("INFO", "推迟结束,监测已自动恢复", "MONITOR");
message.value = "推迟结束,监测已自动恢复";
}, 60 * 60 * 1000);
}
async function manualCheck() {
if (!isLoggedIn.value) { message.value = "请先开始监测(登录)"; return; }
isLoading.value = true;
await apiCheckStatus();
message.value = "手动检查完成";
isLoading.value = false;
}
async function testNotification() {
await notify("工单提醒(测试)", "待处理工单: 3,待关闭工单: 1,待确认工单: 2");
}
// ===== 配置更新 =====
async function updateConfig() {
isLoading.value = true;
try {
if (config.value.auto_start) await enableAutostart();
else await disableAutostart();
autoStartEnabled.value = config.value.auto_start;
addLog("INFO", `自启动设置: ${config.value.auto_start ? "启用" : "禁用"}`, "CONFIG");
} catch (e: any) {
addLog("ERROR", `自启动设置失败: ${e.message || e}`, "CONFIG");
}
if (isMonitoring.value && monitorTimer) {
clearInterval(monitorTimer);
monitorTimer = setInterval(() => apiCheckStatus(), config.value.check_interval * 1000);
addLog("INFO", `检查间隔已更新为: ${config.value.check_interval}秒`, "CONFIG");
}
saveConfigToStorage();
message.value = "配置更新成功";
isLoading.value = false;
}
// ===== 系统托盘 =====
async function setupTray() {
try {
const mainWindow = getCurrentWindow();
const menu = 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: "quit",
text: "退出",
action: async () => { await mainWindow.destroy(); },
}),
],
});
// 使用 Tauri 资源路径解析图标
const iconPath = await resolveResource("icons/32x32.png");
const icon = await Image.fromPath(iconPath);
await TrayIcon.new({
icon,
tooltip: APP_NAME,
menu,
menuOnLeftClick: false,
action: async (event) => {
if (event.type === "Click" && event.button === "Left") {
await mainWindow.unminimize();
await mainWindow.show();
await mainWindow.setFocus();
}
},
});
addLog("INFO", "系统托盘创建成功", "SYSTEM");
} catch (e: any) {
addLog("ERROR", `系统托盘创建失败: ${e.message || e}`, "SYSTEM");
}
}
async function minimizeToTray() {
try {
await getCurrentWindow().hide();
} catch (e: any) {
message.value = `最小化失败: ${e}`;
}
}
// ===== 网络状态文本 =====
function getNetworkStatusText() {
if (!networkStatus.value.is_connected) return "网络断开";
if (!networkStatus.value.api_reachable) return "API不可达";
return "网络正常";
}
// ===== 生命周期 =====
onMounted(async () => {
loadConfig();
loadCredentials();
await setupTray();
try {
autoStartEnabled.value = await isAutostartEnabled();
config.value.auto_start = autoStartEnabled.value;
} catch { /* ignore */ }
unlistenNotification = await listen<string>("notification-action", (event) => {
if (event.payload === "postpone_1h") postponeMonitoring();
});
addLog("INFO", "应用程序启动完成", "SYSTEM");
checkNetworkStatus();
if (config.value.auto_monitor && rememberPassword.value && username.value && password.value) {
addLog("INFO", "自动登录并开始监测...", "SYSTEM");
await startMonitoring();
}
});
onUnmounted(() => {
if (monitorTimer) clearInterval(monitorTimer);
if (postponeTimer) clearTimeout(postponeTimer);
unlistenNotification?.();
});
return {
// 状态
username, password, rememberPassword, message, isLoading,
isLoggedIn, isMonitoring, isPostponed, autoStartEnabled,
logs, networkStatus, config, ticketCounts,
// 方法
addLog, clearLogs, clearMessage, saveSettings, exportLogs,
startMonitoring, stopMonitoring, manualCheck,
testNotification, checkNetworkStatus, updateConfig,
minimizeToTray, getNetworkStatusText,
};
}
+54
View File
@@ -0,0 +1,54 @@
import { invoke } from "@tauri-apps/api/core";
import {
isPermissionGranted,
requestPermission,
} from "@tauri-apps/plugin-notification";
import { storeToRefs } from "pinia";
import { useAppStore } from "@/stores/app";
import { useLogStore } from "@/stores/log";
/** 通知 Composable */
export function useNotification() {
const { addLog } = useLogStore();
const { config } = storeToRefs(useAppStore());
/** 使用 Web Audio API 播放双音节提示音 */
function playNotificationSound() {
if (!config.value.notification_sound) return;
const ctx = new AudioContext();
const oscillator = ctx.createOscillator();
const gain = ctx.createGain();
oscillator.connect(gain);
gain.connect(ctx.destination);
oscillator.frequency.setValueAtTime(880, ctx.currentTime);
oscillator.frequency.setValueAtTime(660, ctx.currentTime + 0.15);
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.4);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.4);
}
/**
* 发送系统通知并播放提示音
* @param title 通知标题
* @param body 通知正文
*/
async function notify(title: string, body: string) {
if (!config.value.show_notifications) return;
let granted = await isPermissionGranted();
if (!granted) {
const permission = await requestPermission();
granted = permission === "granted";
}
if (granted) {
try {
await invoke("send_clickable_notification", { title, body });
playNotificationSound();
addLog("INFO", `通知: ${title} - ${body}`, "NOTIFICATION");
} catch (e: any) {
addLog("ERROR", `发送通知失败: ${e}`, "NOTIFICATION");
}
}
}
return { playNotificationSound, notify };
}
+278
View File
@@ -0,0 +1,278 @@
/**
* 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 { CRM_URL } from "@/constants/app";
import { useLogStore } from "@/stores/log";
import { type TicketCounts } from "@/stores/monitor";
/**
* @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: any) {
addLog("WARN", `更新托盘图标失败: ${e.message || 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: any) {
addLog("WARN", `更新托盘菜单失败: ${e.message || 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: any) {
addLog("ERROR", `系统托盘创建失败: ${e.message || e}`, "SYSTEM");
}
}
/** 隐藏主窗口(最小化到系统托盘)。 */
async function minimizeToTray() {
await getCurrentWindow().hide();
}
return { setupTray, updateTrayIcon, updateTrayMenu, minimizeToTray };
}
+101
View File
@@ -0,0 +1,101 @@
import { relaunch } from "@tauri-apps/plugin-process";
import { check, type Update } from "@tauri-apps/plugin-updater";
import { ref } from "vue";
import { useLogStore } from "@/stores/log";
// ===== 模块级单例状态 =====
/** 是否显示更新对话框 */
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);
/** 当前应用版本号 */
const currentVersion = ref("");
/** 是否正在检查更新 */
const isCheckingUpdates = ref(false);
/** 待安装的更新对象 */
let pendingUpdate: Update | null = null;
/**
* 版本更新 Composable(单例模式,模块级状态共享)
*/
export function useUpdater() {
const { addLog } = useLogStore();
/** 检查是否有可用的应用更新,发现新版本时展示更新对话框 */
async function checkForUpdates() {
if (isCheckingUpdates.value || isUpdating.value) return;
isCheckingUpdates.value = true;
try {
const update = await check();
if (update?.available) {
pendingUpdate = update;
updateInfo.value = {
version: update.version,
notes: update.body ?? "",
date: update.date ?? "",
};
showUpdateDialog.value = true;
addLog("INFO", `发现新版本: v${update.version}`, "UPDATER");
} else {
addLog("INFO", "当前已是最新版本", "UPDATER");
}
} catch (e: any) {
addLog("WARN", `检查更新失败: ${e.message || e}`, "UPDATER");
} finally {
isCheckingUpdates.value = false;
}
}
/** 确认并执行更新:下载安装后重启应用 */
async function confirmUpdate() {
if (!pendingUpdate) return;
isUpdating.value = true;
updateProgress.value = null;
try {
await pendingUpdate.downloadAndInstall((event) => {
if (event.event === "Started") {
updateProgress.value = {
total: event.data.contentLength ?? 0,
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");
isUpdating.value = false;
}
}
/** 关闭更新对话框(稍后提醒) */
function dismissUpdate() {
showUpdateDialog.value = false;
}
return {
showUpdateDialog,
updateInfo,
updateProgress,
isUpdating,
currentVersion,
isCheckingUpdates,
checkForUpdates,
confirmUpdate,
dismissUpdate,
};
}
+11
View File
@@ -0,0 +1,11 @@
/** CRM API 根地址 */
export const API_BASE = "https://crm.yunvip123.com/api";
/** 用户登录接口 */
export const LOGIN_URL = `${API_BASE}/SystemUser/Login`;
/** 工单状态计数查询接口 */
export const CHECK_URL = `${API_BASE}/DemandManage/QueryIndexCount`;
/** 工单列表分页查询接口 */
export const WORK_ORDER_URL = `${API_BASE}/DemandManage/GetWorkOrderListPage`;
+5
View File
@@ -0,0 +1,5 @@
/** 应用名称,来自环境变量 VITE_APP_NAME */
export const APP_NAME = import.meta.env.VITE_APP_NAME;
/** 工单网站地址 */
export const CRM_URL = "https://crm.yunvip123.com";
+7 -7
View File
@@ -1,7 +1,7 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import Antd from "ant-design-vue";
import "ant-design-vue/dist/reset.css";
import App from "./App.vue";
createApp(App).use(createPinia()).use(Antd).mount("#app");
import Antd from "ant-design-vue";
import { createPinia } from "pinia";
import { createApp } from "vue";
import "ant-design-vue/dist/reset.css";
import App from "@/App.vue";
createApp(App).use(createPinia()).use(Antd).mount("#app");
+121
View File
@@ -0,0 +1,121 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import {
enable as enableAutostart,
disable as disableAutostart,
} from "@tauri-apps/plugin-autostart";
import { defineStore, acceptHMRUpdate } from "pinia";
import { ref } from "vue";
import { useLogStore } from "@/stores/log";
export const useAppStore = defineStore("app", () => {
const message = ref(""); // 界面状态提示消息
const isLoading = ref(false); // 是否处于加载/请求中
const autoStartEnabled = ref(false); // 系统自启动是否已启用
const config = ref({
check_interval: 60, // 轮询间隔(秒)
auto_start: false, // 开机自启
auto_monitor: false, // 启动后自动开始监测
silent_start: false, // 静默启动(最小化到托盘)
show_notifications: true, // 是否显示系统通知
notification_sound: true, // 通知是否播放声音
network_timeout: 30, // 网络请求超时时间(秒)
close_action: "ask" as "ask" | "minimize" | "close", // 点击关闭按钮的行为
handle_pause_minutes: 20, // 点击"去处理"后的暂停时长(分钟)
});
const showCloseDialog = ref(false); // 是否显示关闭确认对话框
const { addLog } = useLogStore();
/** 清空状态提示消息 */
function clearMessage() {
message.value = "";
}
/** 设置状态提示消息 */
function setMessage(msg: string) {
message.value = msg;
}
/** 从 localStorage 加载已保存的运行配置,与默认值合并 */
function loadConfig() {
try {
const saved = localStorage.getItem("crm_config");
if (saved) {
const data = JSON.parse(saved);
config.value = { ...config.value, ...data };
addLog("INFO", "已加载保存的配置", "CONFIG");
}
} catch (e: any) {
addLog("ERROR", `加载配置失败: ${e.message || e}`, "CONFIG");
}
}
/** 将当前配置序列化写入 localStorage */
function saveConfigToStorage() {
try {
localStorage.setItem("crm_config", JSON.stringify(config.value));
} catch {
/* ignore */
}
}
/** 应用当前配置:同步自启动设置并持久化 */
async function updateConfig() {
isLoading.value = true;
try {
if (config.value.auto_start) await enableAutostart();
else await disableAutostart();
autoStartEnabled.value = config.value.auto_start;
addLog(
"INFO",
`自启动设置: ${config.value.auto_start ? "启用" : "禁用"}`,
"CONFIG",
);
} catch (e: any) {
addLog("ERROR", `自启动设置失败: ${e.message || e}`, "CONFIG");
}
saveConfigToStorage();
setMessage("配置更新成功");
isLoading.value = false;
}
/** 处理用户关闭对话框的确认操作,可选记住选择;minimize 隐藏窗口,close 销毁窗口 */
async function handleCloseAction(
action: "minimize" | "close",
remember: boolean,
) {
showCloseDialog.value = false;
if (remember) {
config.value.close_action = action;
saveConfigToStorage();
addLog(
"INFO",
`关闭行为已设为: ${action === "minimize" ? "最小化到托盘" : "直接退出"}`,
"SYSTEM",
);
}
if (action === "minimize") {
await getCurrentWindow().hide();
} else {
await getCurrentWindow().destroy();
}
}
return {
message,
isLoading,
autoStartEnabled,
config,
showCloseDialog,
clearMessage,
setMessage,
loadConfig,
saveConfigToStorage,
updateConfig,
handleCloseAction,
};
});
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useAppStore, import.meta.hot));
}
+59
View File
@@ -0,0 +1,59 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { formatTime } from "@/utils/common";
/** 单条日志记录 */
export interface LogEntry {
/** 日志记录时间,格式:YYYY-MM-DD HH:mm:ss */
timestamp: string;
/** 日志级别,如 INFO / WARN / ERROR / DEBUG */
level: string;
/** 日志正文 */
message: string;
/** 日志来源分类,如 LOGIN / SCHEDULER / SYSTEM */
category: string;
}
export const useLogStore = defineStore("log", () => {
/** 日志列表,最多保留 1000 条 */
const logs = ref<LogEntry[]>([]);
/** 追加一条日志,超过 1000 条时自动丢弃最旧一条 */
function addLog(level: string, msg: string, category: string) {
logs.value.push({
timestamp: formatTime(new Date()),
level,
message: msg,
category,
});
if (logs.value.length > 1000) logs.value.shift();
}
/** 清空所有日志 */
function clearLogs() {
logs.value = [];
}
/** 将当前日志导出为 .txt 文件并触发浏览器下载,返回操作结果消息 */
function exportLogs(): string {
if (logs.value.length === 0) {
return "暂无日志可导出";
}
const lines = logs.value.map(
(l) => `[${l.timestamp}] [${l.level}] [${l.category}] ${l.message}`,
);
const blob = new Blob([lines.join("\n")], {
type: "text/plain;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `monitor-logs-${formatTime(new Date()).replaceAll(/[: ]/g, "-")}.txt`;
a.click();
URL.revokeObjectURL(url);
addLog("INFO", `已导出 ${logs.value.length} 条日志`, "SYSTEM");
return "日志导出成功";
}
return { logs, addLog, clearLogs, exportLogs };
});
+513 -904
View File
File diff suppressed because it is too large Load Diff
+88
View File
@@ -0,0 +1,88 @@
import { fetch } from "@tauri-apps/plugin-http";
import { defineStore, acceptHMRUpdate } from "pinia";
import { ref } from "vue";
import { LOGIN_URL } from "@/constants/api";
import { useAppStore } from "@/stores/app";
import { useLogStore } from "@/stores/log";
import { formatTime } from "@/utils/common";
/**
* 网络状态 store。
* 提供公网连通性检测与 CRM API 可达性检测,结果以响应式状态共享给所有组件。
*/
export const useNetworkStore = defineStore("network", () => {
const appStore = useAppStore();
const { addLog } = useLogStore();
/** 公网是否连通 */
const isConnected = ref(false);
/** CRM API 是否可达 */
const apiReachable = ref(false);
/** API 最近一次响应时间(毫秒),未检测时为 null */
const responseTime = ref<number | null>(null);
/** 最近一次检查的时间字符串 */
const lastCheck = ref("");
/**
* 依次检测公网连通性和 API 可达性,并记录响应时间。
* 步骤:
* 1. GET https://www.baidu.com 判断公网是否可用
* 2. 公网可用时,HEAD LOGIN_URL 判断 API 是否可达并测量响应时间
* 结果更新到 store 状态,同时写入日志。
*/
async function checkNetworkStatus() {
appStore.isLoading = true;
let connected = false;
let reachable = false;
let respTime: number | null = null;
try {
const resp = await fetch("https://www.baidu.com", {
method: "GET",
connectTimeout: appStore.config.network_timeout * 1000,
});
connected = resp.ok;
} catch {
/* 网络不通 */
}
if (connected) {
try {
const apiStart = Date.now();
const resp = await fetch(LOGIN_URL, {
method: "HEAD",
connectTimeout: appStore.config.network_timeout * 1000,
});
respTime = Date.now() - apiStart;
reachable = resp.status < 500;
} catch {
/* API 不可达 */
}
}
isConnected.value = connected;
apiReachable.value = reachable;
responseTime.value = respTime;
lastCheck.value = formatTime(new Date());
addLog(
"INFO",
`网络检查完成 - 连接: ${connected}, API可达: ${reachable}`,
"NETWORK",
);
appStore.message = "网络状态检查完成";
appStore.isLoading = false;
}
return {
isConnected,
apiReachable,
responseTime,
lastCheck,
checkNetworkStatus,
};
});
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useNetworkStore, import.meta.hot));
}
+6
View File
@@ -0,0 +1,6 @@
import dayjs from "dayjs";
/** 将 Date 对象格式化为 'YYYY-MM-DD HH:mm:ss' 字符串 */
export function formatTime(date: Date): string {
return dayjs(date).format("YYYY-MM-DD HH:mm:ss");
}
+55
View File
@@ -0,0 +1,55 @@
import { load, type Store } from "@tauri-apps/plugin-store";
let storeInstance: Store | null = null;
async function getStore(): Promise<Store> {
if (!storeInstance) {
storeInstance = await load("config.json", { defaults: {}, autoSave: true });
}
return storeInstance;
}
export async function getItem<T = unknown>(key: string): Promise<T | null> {
const store = await getStore();
const value = await store.get<T>(key);
return value ?? null;
}
export async function setItem<T = unknown>(
key: string,
value: T,
): Promise<void> {
const store = await getStore();
await store.set(key, value);
}
export async function removeItem(key: string): Promise<void> {
const store = await getStore();
await store.delete(key);
}
/**
* 从 localStorage 迁移数据到文件存储(仅首次升级时执行)
*/
export async function migrateFromLocalStorage(): Promise<boolean> {
const store = await getStore();
const migrated = await store.get<boolean>("_migrated");
if (migrated) return false;
let hasMigrated = false;
for (const key of ["crm_credentials", "crm_config"]) {
const raw = localStorage.getItem(key);
if (raw) {
try {
await store.set(key, JSON.parse(raw));
localStorage.removeItem(key);
hasMigrated = true;
} catch {
/* ignore invalid JSON */
}
}
}
await store.set("_migrated", true);
return hasMigrated;
}
+16 -7
View File
@@ -1,7 +1,16 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_NAME: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}