refactor: ♻️ 优化存储

This commit is contained in:
tsl 2026-04-17 08:58:20 +08:00
parent 8137d414da
commit 13f92f79b6
17 changed files with 443 additions and 182 deletions

View File

@ -3,7 +3,8 @@ fetch("https://crm.yunvip123.com/api/DemandManage/GetWorkOrderListPage", {
accept: "application/json, text/javascript, */*; q=0.01", accept: "application/json, text/javascript, */*; q=0.01",
"accept-language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", "accept-language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7",
"content-type": "application/x-www-form-urlencoded; charset=UTF-8", "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"sec-ch-ua": '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"', "sec-ch-ua":
'"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"',
"sec-ch-ua-mobile": "?0", "sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"', "sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "empty", "sec-fetch-dest": "empty",
@ -11,7 +12,8 @@ fetch("https://crm.yunvip123.com/api/DemandManage/GetWorkOrderListPage", {
"sec-fetch-site": "same-origin", "sec-fetch-site": "same-origin",
"x-requested-with": "XMLHttpRequest", "x-requested-with": "XMLHttpRequest",
}, },
referrer: "https://crm.yunvip123.com/WebUI/DemandManagement/DemandListNew.html?v=2.0.4.12", referrer:
"https://crm.yunvip123.com/WebUI/DemandManagement/DemandListNew.html?v=2.0.4.12",
body: "DL_ProductID=&DL_ID=&isSelect=8&Solution=&OrderType=&DL_TaskName=&CreatGID=&CreatTimeStart=&CreatTimeEnd=&SloveGID=&SloveTimeStart=&SloveTimeEnd=&ShowTable=DemandList&isShow=3&IsExport=0&PageIndex=1&PageSize=20", body: "DL_ProductID=&DL_ID=&isSelect=8&Solution=&OrderType=&DL_TaskName=&CreatGID=&CreatTimeStart=&CreatTimeEnd=&SloveGID=&SloveTimeStart=&SloveTimeEnd=&ShowTable=DemandList&isShow=3&IsExport=0&PageIndex=1&PageSize=20",
method: "POST", method: "POST",
mode: "cors", mode: "cors",

View File

@ -6,7 +6,8 @@
* 所有接口都要带 cookie: ASP.NET_SessionId=td0mirxws4klqpm0bydhvgjj * 所有接口都要带 cookie: ASP.NET_SessionId=td0mirxws4klqpm0bydhvgjj
*/ */
const requestData = "UserAcount=&PassWord=123456&VerifyCode=&Account=18688886666_777&ValidateCode="; const requestData =
"UserAcount=&PassWord=123456&VerifyCode=&Account=18688886666_777&ValidateCode=";
const responseBody = { const responseBody = {
success: true, success: true,

View File

@ -14,11 +14,13 @@ import MonitorControl from "@/components/MonitorControl.vue";
import SettingsPanel from "@/components/SettingsPanel.vue"; import SettingsPanel from "@/components/SettingsPanel.vue";
import StatusHeader from "@/components/StatusHeader.vue"; import StatusHeader from "@/components/StatusHeader.vue";
import UpdateDialog from "@/components/UpdateDialog.vue"; import UpdateDialog from "@/components/UpdateDialog.vue";
import { useTray } from "@/composables/useTray";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import { useLogStore } from "@/stores/log"; import { useLogStore } from "@/stores/log";
import { useMonitorStore } from "@/stores/monitor"; import { useMonitorStore } from "@/stores/monitor";
import { useNetworkStore } from "@/stores/network"; import { useNetworkStore } from "@/stores/network";
import { useUpdaterStore } from "@/stores/updater"; import { useUpdaterStore } from "@/stores/updater";
import { migrateFromLocalStorage } from "@/utils/storage";
const activeTab = ref("monitor"); const activeTab = ref("monitor");
const store = useMonitorStore(); const store = useMonitorStore();
@ -26,6 +28,8 @@ const appStore = useAppStore();
const logStore = useLogStore(); const logStore = useLogStore();
const updater = useUpdaterStore(); const updater = useUpdaterStore();
const networkStore = useNetworkStore(); const networkStore = useNetworkStore();
const { setupTray } = useTray();
const closeDialogRef = ref<InstanceType<typeof CloseDialog> | null>(null);
/** 通知动作事件的取消监听函数 */ /** 通知动作事件的取消监听函数 */
let unlistenNotification: UnlistenFn | null = null; let unlistenNotification: UnlistenFn | null = null;
@ -43,17 +47,18 @@ const statusItems = computed<StatusBarItem[]>(() => {
}); });
onMounted(async () => { onMounted(async () => {
appStore.loadConfig(); const migrated = await migrateFromLocalStorage();
store.loadCredentials(); if (migrated) {
await store.setupTray(); logStore.addLog("INFO", "已将本地 localStorage 配置迁移到文件存储", "CONFIG");
}
await appStore.loadConfig();
await store.loadCredentials();
await setupTray();
try {
const enabled = await isAutostartEnabled(); const enabled = await isAutostartEnabled();
appStore.autoStartEnabled = enabled; appStore.autoStartEnabled = enabled;
appStore.config.auto_start = enabled; appStore.config.auto_start = enabled;
} catch {
/* ignore */
}
unlistenNotification = await listen<string>("notification-action", (event) => { unlistenNotification = await listen<string>("notification-action", (event) => {
if (event.payload === "postpone_1h") store.postponeMonitoring(); if (event.payload === "postpone_1h") store.postponeMonitoring();
@ -67,30 +72,24 @@ onMounted(async () => {
} else if (action === "close") { } else if (action === "close") {
await getCurrentWindow().destroy(); await getCurrentWindow().destroy();
} else { } else {
appStore.showCloseDialog = true; closeDialogRef.value?.openDialog();
} }
}); });
logStore.addLog("INFO", "应用程序启动完成", "SYSTEM"); logStore.addLog("INFO", "应用程序启动完成", "SYSTEM");
if (appStore.config.silent_start) { if (appStore.config.silent_start) {
try {
await getCurrentWindow().hide(); await getCurrentWindow().hide();
logStore.addLog("INFO", "静默启动:窗口已隐藏到托盘", "SYSTEM"); logStore.addLog("INFO", "静默启动:窗口已隐藏到托盘", "SYSTEM");
} catch {
/* ignore */
}
} }
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
getCurrentWindow().setTitle(import.meta.env.VITE_APP_NAME); getCurrentWindow().setTitle(import.meta.env.VITE_APP_NAME);
} }
getVersion() getVersion().then((v) => {
.then((v) => {
updater.currentVersion = v; updater.currentVersion = v;
}) });
.catch(() => {});
networkStore.checkNetworkStatus(); networkStore.checkNetworkStatus();
updater.checkForUpdates(); updater.checkForUpdates();
@ -152,7 +151,7 @@ onUnmounted(() => {
<AppStatusBar :items="statusItems" /> <AppStatusBar :items="statusItems" />
</a-layout> </a-layout>
<CloseDialog /> <CloseDialog ref="closeDialogRef" />
<UpdateDialog /> <UpdateDialog />
</template> </template>

173
src/api/example-usage.ts Normal file
View File

@ -0,0 +1,173 @@
// 使用示例:展示新的泛型 request 函数和 HTTP 方法辅助函数如何使用
import {
request,
get,
post,
put,
del,
patch,
type RequestOptions,
} from "@/api/client";
import type { ApiResponse, LoginResponseData } from "@/api/types";
// 示例1GET 请求
interface UserProfile {
id: number;
name: string;
email: string;
}
async function getUserProfile(userId: number) {
// 只需要指定响应类型
const response = await request<UserProfile>(`/users/${userId}`, {
method: "GET",
});
return response;
}
// 示例2POST 请求,带请求数据和响应类型
interface LoginRequest {
username: string;
password: string;
}
async function login(username: string, password: string) {
// 指定响应类型和请求类型
const response = await request<ApiResponse<LoginResponseData>, LoginRequest>(
"/login",
{
method: "POST",
data: { username, password },
},
);
return response;
}
// 示例3PUT 请求
interface UpdateUserRequest {
name: string;
email: string;
}
interface UpdateUserResponse {
success: boolean;
message: string;
}
async function updateUser(userId: number, name: string, email: string) {
const response = await request<UpdateUserResponse, UpdateUserRequest>(
`/users/${userId}`,
{
method: "PUT",
data: { name, email },
},
);
return response;
}
// 示例4不指定类型使用默认 any
async function genericRequest() {
const response = await request("/some-endpoint");
return response;
}
// 示例5使用 RequestOptions 类型定义
interface SearchParams {
keyword: string;
page: number;
limit: number;
}
interface SearchResult {
items: any[];
total: number;
}
function createSearchOptions(
keyword: string,
page: number,
limit: number,
): RequestOptions<SearchParams> {
return {
method: "POST",
data: { keyword, page, limit },
headers: {
"Custom-Header": "search-request",
},
};
}
async function searchUsers(keyword: string, page: number, limit: number) {
const options: RequestOptions<SearchParams> = createSearchOptions(
keyword,
page,
limit,
);
const response = await request<SearchResult, SearchParams>(
"/search",
options,
);
return response;
}
// 示例6使用 HTTP 方法辅助函数
async function getUserById(userId: number) {
// 使用 get 辅助函数
const user = await get<UserProfile>(`/users/${userId}`);
return user;
}
async function createUser(name: string, email: string) {
// 使用 post 辅助函数
const newUser = await post<UserProfile, { name: string; email: string }>(
"/users",
{ name, email },
);
return newUser;
}
async function updateUserProfile(
userId: number,
updates: Partial<UserProfile>,
) {
// 使用 put 辅助函数
const updatedUser = await put<UserProfile, Partial<UserProfile>>(
`/users/${userId}`,
updates,
);
return updatedUser;
}
async function deleteUser(userId: number) {
// 使用 del 辅助函数
const result = await del<{ success: boolean }>(`/users/${userId}`);
return result;
}
async function partialUpdateUser(
userId: number,
updates: Partial<UserProfile>,
) {
// 使用 patch 辅助函数
const updatedUser = await patch<UserProfile, Partial<UserProfile>>(
`/users/${userId}`,
updates,
);
return updatedUser;
}
// 导出函数以避免未使用变量的警告
export {
getUserProfile,
login,
updateUser,
genericRequest,
searchUsers,
getUserById,
createUser,
updateUserProfile,
deleteUser,
partialUpdateUser,
};

View File

@ -1,18 +1,49 @@
<script setup lang="ts"> <script setup lang="ts">
import { getCurrentWindow } from "@tauri-apps/api/window";
import { ref } from "vue"; import { ref } from "vue";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import { useLogStore } from "@/stores/log";
const appStore = useAppStore(); const appStore = useAppStore();
const { addLog } = useLogStore();
const isOpen = ref(false);
const rememberClose = ref(false); const rememberClose = ref(false);
function handleClose(action: "minimize" | "close") { function openDialog() {
appStore.handleCloseAction(action, rememberClose.value); isOpen.value = true;
}
function closeDialog() {
isOpen.value = false;
rememberClose.value = false; rememberClose.value = false;
} }
defineExpose({
openDialog,
closeDialog,
});
async function handleClose(action: "minimize" | "close") {
isOpen.value = false;
try {
if (rememberClose.value) {
appStore.config.close_action = action;
await appStore.saveConfigToStorage();
addLog("INFO", `关闭行为已设为: ${action === "minimize" ? "最小化到托盘" : "直接退出"}`, "SYSTEM");
}
if (action === "minimize") {
await getCurrentWindow().hide();
} else {
await getCurrentWindow().destroy();
}
} finally {
rememberClose.value = false;
}
}
</script> </script>
<template> <template>
<a-modal v-model:open="appStore.showCloseDialog" title="关闭窗口" :width="340" centered> <a-modal v-model:open="isOpen" title="关闭窗口" :width="340" centered @cancel="closeDialog">
<div class="dialog-body"> <div class="dialog-body">
<p class="dialog-tip">请选择关闭窗口时的操作</p> <p class="dialog-tip">请选择关闭窗口时的操作</p>
<a-checkbox v-model:checked="rememberClose"> 记住我的选择 </a-checkbox> <a-checkbox v-model:checked="rememberClose"> 记住我的选择 </a-checkbox>

View File

@ -1,8 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs } from "pinia";
import { ref } from "vue"; import { ref } from "vue";
import { useMonitorStore } from "@/stores/monitor"; import { useMonitorStore } from "@/stores/monitor";
const store = useMonitorStore(); const store = useMonitorStore();
const { loginMode, isMonitoring, username, password, tokenSessionId, rememberPassword } = storeToRefs(store);
/** 表单操作加载状态 */ /** 表单操作加载状态 */
const isLoading = ref(false); const isLoading = ref(false);
@ -20,7 +22,7 @@ async function handleStartMonitoring() {
async function handleStopMonitoring() { async function handleStopMonitoring() {
isLoading.value = true; isLoading.value = true;
await store.stopMonitoring(); store.stopMonitoring();
isLoading.value = false; isLoading.value = false;
} }
</script> </script>
@ -28,25 +30,25 @@ async function handleStopMonitoring() {
<template> <template>
<a-form layout="vertical"> <a-form layout="vertical">
<a-form-item> <a-form-item>
<a-radio-group v-model:value="store.loginMode" :disabled="store.isMonitoring"> <a-radio-group v-model:value="loginMode" :disabled="isMonitoring">
<a-radio-button value="password"> 账号密码登录 </a-radio-button> <a-radio-button value="password"> 账号密码登录 </a-radio-button>
<a-radio-button value="token"> Token 登录 </a-radio-button> <a-radio-button value="token"> Token 登录 </a-radio-button>
</a-radio-group> </a-radio-group>
</a-form-item> </a-form-item>
<template v-if="store.loginMode === 'password'"> <template v-if="loginMode === 'password'">
<a-form-item label="用户名"> <a-form-item label="用户名">
<a-input v-model:value="store.username" placeholder="请输入用户名" :disabled="isLoading" /> <a-input v-model:value="username" placeholder="请输入用户名" :disabled="isLoading" />
</a-form-item> </a-form-item>
<a-form-item label="密码"> <a-form-item label="密码">
<a-input-password v-model:value="store.password" placeholder="请输入密码" :disabled="isLoading" /> <a-input-password v-model:value="password" placeholder="请输入密码" :disabled="isLoading" />
</a-form-item> </a-form-item>
</template> </template>
<template v-else> <template v-else>
<a-form-item label="ASP.NET_SessionId"> <a-form-item label="ASP.NET_SessionId">
<a-input <a-input
v-model:value="store.tokenSessionId" v-model:value="tokenSessionId"
placeholder="请输入 ASP.NET_SessionId" placeholder="请输入 ASP.NET_SessionId"
:disabled="isLoading" :disabled="isLoading"
allow-clear allow-clear
@ -58,8 +60,8 @@ async function handleStopMonitoring() {
</template> </template>
<a-form-item> <a-form-item>
<a-checkbox v-model:checked="store.rememberPassword"> <a-checkbox v-model:checked="rememberPassword">
记住{{ store.loginMode === "password" ? "密码" : "Token" }} 记住{{ loginMode === "password" ? "密码" : "Token" }}
</a-checkbox> </a-checkbox>
</a-form-item> </a-form-item>
<a-form-item> <a-form-item>
@ -68,15 +70,13 @@ async function handleStopMonitoring() {
<a-button <a-button
type="primary" type="primary"
:loading="isLoading" :loading="isLoading"
:disabled="store.isMonitoring" :disabled="isMonitoring"
style="background: #52c41a; border-color: #52c41a" style="background: #52c41a; border-color: #52c41a"
@click="handleStartMonitoring()" @click="handleStartMonitoring()"
> >
开始监测 开始监测
</a-button> </a-button>
<a-button danger :disabled="isLoading || !store.isMonitoring" @click="handleStopMonitoring()"> <a-button danger :disabled="isLoading || !isMonitoring" @click="handleStopMonitoring()"> 停止监测 </a-button>
停止监测
</a-button>
</a-space> </a-space>
</a-form-item> </a-form-item>
</a-form> </a-form>

View File

@ -9,6 +9,7 @@ import {
import dayjs from "dayjs"; import dayjs from "dayjs";
import duration from "dayjs/plugin/duration"; import duration from "dayjs/plugin/duration";
import { ref, onMounted, onUnmounted } from "vue"; import { ref, onMounted, onUnmounted } from "vue";
import { useTray } from "@/composables/useTray";
import { useMonitorStore } from "@/stores/monitor"; import { useMonitorStore } from "@/stores/monitor";
import { useNetworkStore } from "@/stores/network"; import { useNetworkStore } from "@/stores/network";
@ -16,6 +17,7 @@ dayjs.extend(duration);
const store = useMonitorStore(); const store = useMonitorStore();
const networkStore = useNetworkStore(); const networkStore = useNetworkStore();
const { minimizeToTray } = useTray();
/** 手动检查加载状态 */ /** 手动检查加载状态 */
const isLoading = ref(false); const isLoading = ref(false);
@ -25,6 +27,10 @@ async function handleManualCheck() {
isLoading.value = false; isLoading.value = false;
} }
async function handleMinimizeToTray() {
await minimizeToTray();
}
const countdown = ref(""); const countdown = ref("");
let countdownTimer: ReturnType<typeof setInterval> | null = null; let countdownTimer: ReturnType<typeof setInterval> | null = null;
@ -113,7 +119,7 @@ onUnmounted(() => {
</template> </template>
测试通知 测试通知
</a-button> </a-button>
<a-button @click="store.minimizeToTray()"> <a-button @click="handleMinimizeToTray()">
<template #icon> <template #icon>
<MinusCircleOutlined /> <MinusCircleOutlined />
</template> </template>

View File

@ -2,17 +2,19 @@
import { CloudSyncOutlined } from "@ant-design/icons-vue"; import { CloudSyncOutlined } from "@ant-design/icons-vue";
import dayjs, { type Dayjs } from "dayjs"; import dayjs, { type Dayjs } from "dayjs";
import { debounce } from "lodash-es"; import { debounce } from "lodash-es";
import { computed, watch } from "vue"; import { storeToRefs } from "pinia";
import { computed, onBeforeUnmount, watch } from "vue";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import { useUpdaterStore } from "@/stores/updater"; import { useUpdaterStore } from "@/stores/updater";
const appStore = useAppStore(); const appStore = useAppStore();
const { config } = storeToRefs(appStore);
const { currentVersion, isCheckingUpdates, isUpdating, checkForUpdates } = useUpdaterStore(); const { currentVersion, isCheckingUpdates, isUpdating, checkForUpdates } = useUpdaterStore();
// ===== TimePicker Dayjs ===== // ===== TimePicker Dayjs =====
const intervalTime = computed<Dayjs>({ const intervalTime = computed<Dayjs>({
get() { get() {
const t = appStore.config.check_interval; const t = config.value.check_interval;
const hours = Math.floor(t / 3600); const hours = Math.floor(t / 3600);
const minutes = Math.floor((t % 3600) / 60); const minutes = Math.floor((t % 3600) / 60);
const seconds = t % 60; const seconds = t % 60;
@ -21,59 +23,64 @@ const intervalTime = computed<Dayjs>({
set(val: Dayjs | null) { set(val: Dayjs | null) {
if (!val) return; if (!val) return;
const totalSeconds = 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.value.check_interval = Math.max(10, totalSeconds);
}, },
}); });
// config 500ms // config 500ms
const saveConfig = debounce(() => appStore.updateConfig(), 500); const saveConfig = debounce(() => appStore.updateConfig(), 500);
watch(() => appStore.config, saveConfig, { deep: true }); watch(config, saveConfig, { deep: true });
//
onBeforeUnmount(() => {
saveConfig.flush();
});
</script> </script>
<template> <template>
<a-form layout="vertical"> <a-form layout="vertical">
<a-form-item label="检查间隔"> <a-form-item label="检查间隔">
<a-time-picker v-model:value="intervalTime" format="HH:mm:ss" :allow-clear="false" style="width: 100%" /> <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> <div class="form-hint">最短 10 当前 {{ config.check_interval }} </div>
</a-form-item> </a-form-item>
<a-form-item label="网络超时(秒)"> <a-form-item label="网络超时(秒)">
<a-input-number v-model:value="appStore.config.network_timeout" :min="5" :max="120" style="width: 100%" /> <a-input-number v-model:value="config.network_timeout" :min="5" :max="120" style="width: 100%" />
</a-form-item> </a-form-item>
<a-divider /> <a-divider />
<div class="switch-row"> <div class="switch-row">
<div class="switch-label">开机自启动</div> <div class="switch-label">开机自启动</div>
<a-switch v-model:checked="appStore.config.auto_start" /> <a-switch v-model:checked="config.auto_start" />
</div> </div>
<div class="switch-row"> <div class="switch-row">
<div class="switch-label"> <div class="switch-label">
启动后自动监测 启动后自动监测
<div class="form-hint">需同时开启记住密码</div> <div class="form-hint">需同时开启记住密码</div>
</div> </div>
<a-switch v-model:checked="appStore.config.auto_monitor" /> <a-switch v-model:checked="config.auto_monitor" />
</div> </div>
<div class="switch-row"> <div class="switch-row">
<div class="switch-label"> <div class="switch-label">
静默启动 静默启动
<div class="form-hint">启动时直接最小化到托盘</div> <div class="form-hint">启动时直接最小化到托盘</div>
</div> </div>
<a-switch v-model:checked="appStore.config.silent_start" /> <a-switch v-model:checked="config.silent_start" />
</div> </div>
<a-divider /> <a-divider />
<div class="switch-row"> <div class="switch-row">
<div class="switch-label">显示系统通知</div> <div class="switch-label">显示系统通知</div>
<a-switch v-model:checked="appStore.config.show_notifications" /> <a-switch v-model:checked="config.show_notifications" />
</div> </div>
<div class="switch-row"> <div class="switch-row">
<div class="switch-label">通知提示音</div> <div class="switch-label">通知提示音</div>
<a-switch v-model:checked="appStore.config.notification_sound" :disabled="!appStore.config.show_notifications" /> <a-switch v-model:checked="config.notification_sound" :disabled="!config.show_notifications" />
</div> </div>
<a-divider /> <a-divider />
<a-form-item label="点击「去处理」后暂停时长(分钟)"> <a-form-item label="点击「去处理」后暂停时长(分钟)">
<a-input-number v-model:value="appStore.config.handle_pause_minutes" :min="1" :max="120" style="width: 100%" /> <a-input-number v-model:value="config.handle_pause_minutes" :min="1" :max="120" style="width: 100%" />
</a-form-item> </a-form-item>
<a-divider /> <a-divider />
<a-form-item label="关闭窗口行为"> <a-form-item label="关闭窗口行为">
<a-radio-group v-model:value="appStore.config.close_action"> <a-radio-group v-model:value="config.close_action">
<a-radio value="ask"> 每次询问 </a-radio> <a-radio value="ask"> 每次询问 </a-radio>
<a-radio value="minimize"> 最小化到托盘 </a-radio> <a-radio value="minimize"> 最小化到托盘 </a-radio>
<a-radio value="close"> 直接退出 </a-radio> <a-radio value="close"> 直接退出 </a-radio>

View File

@ -13,27 +13,26 @@ import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { TrayIcon } from "@tauri-apps/api/tray"; import { TrayIcon } from "@tauri-apps/api/tray";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { openUrl } from "@tauri-apps/plugin-opener"; import { openUrl } from "@tauri-apps/plugin-opener";
import { type Ref } from "vue"; import { storeToRefs } from "pinia";
import { type TicketCounts } from "@/api/types"; import { watch } from "vue";
import { CRM_URL } from "@/constants/app"; import { CRM_URL } from "@/constants/app";
import { useLogStore } from "@/stores/log"; import { useLogStore } from "@/stores/log";
import { useMonitorStore } from "@/stores/monitor";
import { getErrorMessage } from "@/utils/common"; import { getErrorMessage } from "@/utils/common";
/** 隐藏主窗口(最小化到系统托盘)。 */
async function minimizeToTray() {
await getCurrentWindow().hide();
}
/** /**
* @param isMonitoring * monitor store
* @param isPostponed /
* @param ticketCounts
* @param startMonitoring
* @param stopMonitoring
*/ */
export function useTray( export function useTray() {
isMonitoring: Ref<boolean>,
isPostponed: Ref<boolean>,
ticketCounts: Ref<TicketCounts>,
startMonitoring: () => Promise<void>,
stopMonitoring: () => void,
) {
const { addLog } = useLogStore(); const { addLog } = useLogStore();
const monitorStore = useMonitorStore();
const { isMonitoring, isPostponed, ticketCounts } = storeToRefs(monitorStore);
const { startMonitoring, stopMonitoring } = monitorStore;
const appName = import.meta.env.VITE_APP_NAME; const appName = import.meta.env.VITE_APP_NAME;
// 当前托盘图标实例null 表示尚未初始化 // 当前托盘图标实例null 表示尚未初始化
let trayIconInstance: TrayIcon | null = null; let trayIconInstance: TrayIcon | null = null;
@ -46,7 +45,7 @@ export function useTray(
async function loadSvgImage(): Promise<HTMLImageElement> { async function loadSvgImage(): Promise<HTMLImageElement> {
if (cachedSvgImg) return cachedSvgImg; if (cachedSvgImg) return cachedSvgImg;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new window.Image(); const img = new globalThis.Image();
img.onload = () => { img.onload = () => {
cachedSvgImg = img; cachedSvgImg = img;
resolve(img); resolve(img);
@ -234,6 +233,15 @@ export function useTray(
} }
} }
watch(
[isMonitoring, isPostponed, ticketCounts],
() => {
updateTrayIcon();
updateTrayMenu();
},
{ deep: true },
);
/** /**
* *
* *
@ -270,10 +278,5 @@ export function useTray(
} }
} }
/** 隐藏主窗口(最小化到系统托盘)。 */
async function minimizeToTray() {
await getCurrentWindow().hide();
}
return { setupTray, updateTrayIcon, updateTrayMenu, minimizeToTray }; return { setupTray, updateTrayIcon, updateTrayMenu, minimizeToTray };
} }

4
src/constants/store.ts Normal file
View File

@ -0,0 +1,4 @@
export const STORE_FILE_NAME = "config.json";
export const APP_CONFIG_STORAGE_KEY = "crm_config";
export const CREDENTIALS_STORAGE_KEY = "crm_credentials";

View File

@ -1,29 +1,43 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { import {
enable as enableAutostart, enable as enableAutostart,
disable as disableAutostart, disable as disableAutostart,
} from "@tauri-apps/plugin-autostart"; } from "@tauri-apps/plugin-autostart";
import { defineStore, acceptHMRUpdate } from "pinia"; import { defineStore, acceptHMRUpdate } from "pinia";
import { ref } from "vue"; import { ref } from "vue";
import { APP_CONFIG_STORAGE_KEY } from "@/constants/store";
import { useLogStore } from "@/stores/log"; import { useLogStore } from "@/stores/log";
import { getErrorMessage } from "@/utils/common"; import { getErrorMessage } from "@/utils/common";
import { getItem, setItem } from "@/utils/storage";
type CloseAction = "ask" | "minimize" | "close";
interface AppConfig {
check_interval: number; // 轮询间隔(秒)
auto_start: boolean; // 开机自启
auto_monitor: boolean; // 启动后自动开始监测
silent_start: boolean; // 静默启动(最小化到托盘)
show_notifications: boolean; // 是否显示系统通知
notification_sound: boolean; // 通知是否播放声音
network_timeout: number; // 网络请求超时时间(秒)
close_action: CloseAction; // 点击关闭按钮的行为
handle_pause_minutes: number; // 点击"去处理"后的暂停时长(分钟)
}
export const useAppStore = defineStore("app", () => { export const useAppStore = defineStore("app", () => {
const message = ref(""); // 界面状态提示消息 const message = ref(""); // 界面状态提示消息
const isLoading = ref(false); // 是否处于加载/请求中 const isLoading = ref(false); // 是否处于加载/请求中
const autoStartEnabled = ref(false); // 系统自启动是否已启用 const autoStartEnabled = ref(false); // 系统自启动是否已启用
const config = ref({ const config = ref<AppConfig>({
check_interval: 60, // 轮询间隔(秒) check_interval: 60 * 10,
auto_start: false, // 开机自启 auto_start: false,
auto_monitor: false, // 启动后自动开始监测 auto_monitor: false,
silent_start: false, // 静默启动(最小化到托盘) silent_start: false,
show_notifications: true, // 是否显示系统通知 show_notifications: true,
notification_sound: true, // 通知是否播放声音 notification_sound: true,
network_timeout: 30, // 网络请求超时时间(秒) network_timeout: 30,
close_action: "ask" as "ask" | "minimize" | "close", // 点击关闭按钮的行为 close_action: "ask",
handle_pause_minutes: 20, // 点击"去处理"后的暂停时长(分钟) handle_pause_minutes: 20,
}); });
const showCloseDialog = ref(false); // 是否显示关闭确认对话框
const { addLog } = useLogStore(); const { addLog } = useLogStore();
@ -37,26 +51,27 @@ export const useAppStore = defineStore("app", () => {
message.value = msg; message.value = msg;
} }
/** 从 localStorage 加载已保存的运行配置,与默认值合并 */ /** 从文件存储加载配置localStorage 迁移在应用启动阶段统一执行) */
function loadConfig() { async function loadConfig() {
try { try {
const saved = localStorage.getItem("crm_config"); const savedFromFile = await getItem<Partial<AppConfig>>(
if (saved) { APP_CONFIG_STORAGE_KEY,
const data = JSON.parse(saved); );
config.value = { ...config.value, ...data }; if (savedFromFile && typeof savedFromFile === "object") {
addLog("INFO", "已加载保存的配置", "CONFIG"); config.value = { ...config.value, ...savedFromFile };
addLog("INFO", "已从文件加载配置", "CONFIG");
} }
} catch (e: unknown) { } catch (e: unknown) {
addLog("ERROR", `加载配置失败: ${getErrorMessage(e)}`, "CONFIG"); addLog("ERROR", `加载配置失败: ${getErrorMessage(e)}`, "CONFIG");
} }
} }
/** 将当前配置序列化写入 localStorage */ /** 将当前配置写入文件存储 */
function saveConfigToStorage() { async function saveConfigToStorage() {
try { try {
localStorage.setItem("crm_config", JSON.stringify(config.value)); await setItem(APP_CONFIG_STORAGE_KEY, config.value);
} catch { } catch (e: unknown) {
/* ignore */ addLog("ERROR", `保存配置失败: ${getErrorMessage(e)}`, "CONFIG");
} }
} }
@ -75,45 +90,21 @@ export const useAppStore = defineStore("app", () => {
} catch (e: unknown) { } catch (e: unknown) {
addLog("ERROR", `自启动设置失败: ${getErrorMessage(e)}`, "CONFIG"); addLog("ERROR", `自启动设置失败: ${getErrorMessage(e)}`, "CONFIG");
} }
saveConfigToStorage(); await saveConfigToStorage();
setMessage("配置更新成功"); setMessage("配置更新成功");
isLoading.value = false; 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 { return {
message, message,
isLoading, isLoading,
autoStartEnabled, autoStartEnabled,
config, config,
showCloseDialog,
clearMessage, clearMessage,
setMessage, setMessage,
loadConfig, loadConfig,
saveConfigToStorage, saveConfigToStorage,
updateConfig, updateConfig,
handleCloseAction,
}; };
}); });

View File

@ -2,12 +2,21 @@ import { defineStore } from "pinia";
import { ref } from "vue"; import { ref } from "vue";
import { formatTime } from "@/utils/common"; import { formatTime } from "@/utils/common";
export const LOG_LEVELS = {
INFO: "INFO",
WARN: "WARN",
ERROR: "ERROR",
DEBUG: "DEBUG",
} as const;
export type LogLevel = (typeof LOG_LEVELS)[keyof typeof LOG_LEVELS];
/** 单条日志记录 */ /** 单条日志记录 */
export interface LogEntry { export interface LogEntry {
/** 日志记录时间格式YYYY-MM-DD HH:mm:ss */ /** 日志记录时间格式YYYY-MM-DD HH:mm:ss */
timestamp: string; timestamp: string;
/** 日志级别,如 INFO / WARN / ERROR / DEBUG */ /** 日志级别,如 INFO / WARN / ERROR / DEBUG */
level: string; level: LogLevel;
/** 日志正文 */ /** 日志正文 */
message: string; message: string;
/** 日志来源分类,如 LOGIN / SCHEDULER / SYSTEM */ /** 日志来源分类,如 LOGIN / SCHEDULER / SYSTEM */
@ -19,7 +28,7 @@ export const useLogStore = defineStore("log", () => {
const logs = ref<LogEntry[]>([]); const logs = ref<LogEntry[]>([]);
/** 追加一条日志,超过 1000 条时自动丢弃最旧一条 */ /** 追加一条日志,超过 1000 条时自动丢弃最旧一条 */
function addLog(level: string, msg: string, category: string) { function addLog(level: LogLevel, msg: string, category: string) {
logs.value.push({ logs.value.push({
timestamp: formatTime(new Date()), timestamp: formatTime(new Date()),
level, level,
@ -29,6 +38,22 @@ export const useLogStore = defineStore("log", () => {
if (logs.value.length > 1000) logs.value.shift(); if (logs.value.length > 1000) logs.value.shift();
} }
function addInfoLog(msg: string, category: string) {
addLog(LOG_LEVELS.INFO, msg, category);
}
function addWarnLog(msg: string, category: string) {
addLog(LOG_LEVELS.WARN, msg, category);
}
function addErrorLog(msg: string, category: string) {
addLog(LOG_LEVELS.ERROR, msg, category);
}
function addDebugLog(msg: string, category: string) {
addLog(LOG_LEVELS.DEBUG, msg, category);
}
/** 清空所有日志 */ /** 清空所有日志 */
function clearLogs() { function clearLogs() {
logs.value = []; logs.value = [];
@ -51,9 +76,18 @@ export const useLogStore = defineStore("log", () => {
a.download = `monitor-logs-${formatTime(new Date()).replaceAll(/[: ]/g, "-")}.txt`; a.download = `monitor-logs-${formatTime(new Date()).replaceAll(/[: ]/g, "-")}.txt`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
addLog("INFO", `已导出 ${logs.value.length} 条日志`, "SYSTEM"); addInfoLog(`已导出 ${logs.value.length} 条日志`, "SYSTEM");
return "日志导出成功"; return "日志导出成功";
} }
return { logs, addLog, clearLogs, exportLogs }; return {
logs,
addLog,
addInfoLog,
addWarnLog,
addErrorLog,
addDebugLog,
clearLogs,
exportLogs,
};
}); });

View File

@ -2,12 +2,26 @@ import { defineStore, acceptHMRUpdate, storeToRefs } from "pinia";
import { ref, watch } from "vue"; import { ref, watch } from "vue";
import { login, checkWorkOrders, checkStatus } from "@/api/monitor"; import { login, checkWorkOrders, checkStatus } from "@/api/monitor";
import { useNotification } from "@/composables/useNotification"; import { useNotification } from "@/composables/useNotification";
import { useTray } from "@/composables/useTray"; import { CREDENTIALS_STORAGE_KEY } from "@/constants/store";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import { useLogStore } from "@/stores/log"; import { useLogStore } from "@/stores/log";
import { formatTime, getErrorMessage } from "@/utils/common"; import { formatTime, getErrorMessage } from "@/utils/common";
import { getItem, removeItem, setItem } from "@/utils/storage";
import type { TicketCounts, CheckStatusResponse } from "@/api/types"; import type { TicketCounts, CheckStatusResponse } from "@/api/types";
/** 判断 API 返回 code 是否属于登录过期 */
function isLoginTimeoutCode(code: string | null | undefined): boolean {
return code === "LoginTimeout";
}
interface StoredCredentials {
username?: string;
password?: string;
remember?: boolean;
loginMode?: "password" | "token";
tokenSessionId?: string;
}
export const useMonitorStore = defineStore("monitor", () => { export const useMonitorStore = defineStore("monitor", () => {
// ===== 响应式数据 ===== // ===== 响应式数据 =====
const username = ref(""); // 登录用户名 const username = ref(""); // 登录用户名
@ -159,7 +173,7 @@ export const useMonitorStore = defineStore("monitor", () => {
} else { } else {
// 3. 处理业务失败 // 3. 处理业务失败
addLog("WARN", `API 返回异常: ${result.msg}`, "SCHEDULER"); addLog("WARN", `API 返回异常: ${result.msg}`, "SCHEDULER");
if (!isRetry && isSessionExpiredMsg(result.msg)) { if (!isRetry && isSessionExpired(result.code, result.msg)) {
await tryReLogin(); await tryReLogin();
} }
} }
@ -169,7 +183,16 @@ export const useMonitorStore = defineStore("monitor", () => {
} }
} }
/** 判断 API 返回消息是否属于会话过期类错误 */ /** 判断 API 返回是否属于会话过期类错误(优先 codemsg 兜底) */
function isSessionExpired(
code: string | null | undefined,
msg: string | undefined,
): boolean {
if (isLoginTimeoutCode(code)) return true;
return isSessionExpiredMsg(msg);
}
/** 判断 API 返回消息是否属于会话过期类错误(兼容历史逻辑) */
function isSessionExpiredMsg(msg: string | undefined): boolean { function isSessionExpiredMsg(msg: string | undefined): boolean {
if (!msg) return false; if (!msg) return false;
const keywords = [ const keywords = [
@ -213,12 +236,11 @@ export const useMonitorStore = defineStore("monitor", () => {
} }
// ===== 认证与凭据 ===== // ===== 认证与凭据 =====
/** 从 localStorage 加载已保存的登录凭据 */ /** 从文件存储加载已保存的登录凭据 */
function loadCredentials() { async function loadCredentials() {
try { try {
const saved = localStorage.getItem("crm_credentials"); const data = await getItem<StoredCredentials>(CREDENTIALS_STORAGE_KEY);
if (saved) { if (data && typeof data === "object") {
const data = JSON.parse(saved);
username.value = data.username || ""; username.value = data.username || "";
password.value = data.password || ""; password.value = data.password || "";
rememberPassword.value = data.remember || false; rememberPassword.value = data.remember || false;
@ -231,23 +253,20 @@ export const useMonitorStore = defineStore("monitor", () => {
} }
} }
/** 根据 rememberPassword 决定写入或清除 localStorage 中的凭据 */ /** 根据 rememberPassword 决定写入或清除文件存储中的凭据 */
function saveCredentials() { async function saveCredentials() {
try { try {
if (rememberPassword.value) { if (rememberPassword.value) {
localStorage.setItem( await setItem(CREDENTIALS_STORAGE_KEY, {
"crm_credentials",
JSON.stringify({
username: username.value, username: username.value,
password: password.value, password: password.value,
remember: true, remember: true,
loginMode: loginMode.value, loginMode: loginMode.value,
tokenSessionId: tokenSessionId.value, tokenSessionId: tokenSessionId.value,
}), });
);
addLog("INFO", "凭据已保存到本地", "CONFIG"); addLog("INFO", "凭据已保存到本地", "CONFIG");
} else { } else {
localStorage.removeItem("crm_credentials"); await removeItem(CREDENTIALS_STORAGE_KEY);
addLog("INFO", "已清除本地凭据", "CONFIG"); addLog("INFO", "已清除本地凭据", "CONFIG");
} }
} catch (e: unknown) { } catch (e: unknown) {
@ -256,7 +275,7 @@ export const useMonitorStore = defineStore("monitor", () => {
} }
/** 校验凭据不为空后保存,同时记录日志并更新状态消息 */ /** 校验凭据不为空后保存,同时记录日志并更新状态消息 */
function saveSettings() { async function saveSettings() {
if (loginMode.value === "password") { if (loginMode.value === "password") {
if (!username.value.trim() || !password.value.trim()) { if (!username.value.trim() || !password.value.trim()) {
appStore.setMessage("请输入用户名和密码"); appStore.setMessage("请输入用户名和密码");
@ -268,7 +287,7 @@ export const useMonitorStore = defineStore("monitor", () => {
return; return;
} }
} }
saveCredentials(); await saveCredentials();
appStore.setMessage("设置保存成功"); appStore.setMessage("设置保存成功");
addLog("INFO", "用户凭据已保存", "CONFIG"); addLog("INFO", "用户凭据已保存", "CONFIG");
} }
@ -434,15 +453,6 @@ export const useMonitorStore = defineStore("monitor", () => {
); );
} }
// ===== 系统托盘 =====
const { setupTray, updateTrayIcon, updateTrayMenu, minimizeToTray } = useTray(
isMonitoring,
isPostponed,
ticketCounts,
startMonitoring,
stopMonitoring,
);
// 检查间隔变化时重启定时器 // 检查间隔变化时重启定时器
watch( watch(
() => config.value.check_interval, () => config.value.check_interval,
@ -455,16 +465,6 @@ export const useMonitorStore = defineStore("monitor", () => {
}, },
); );
// 监听状态变化,自动更新托盘图标和菜单
watch(
[isMonitoring, isPostponed, ticketCounts],
() => {
updateTrayIcon();
updateTrayMenu();
},
{ deep: true },
);
return { return {
// 状态 // 状态
username, username,
@ -480,7 +480,6 @@ export const useMonitorStore = defineStore("monitor", () => {
// 方法 // 方法
saveSettings, saveSettings,
loadCredentials, loadCredentials,
setupTray,
startMonitoring, startMonitoring,
stopMonitoring, stopMonitoring,
postponeMonitoring, postponeMonitoring,
@ -488,7 +487,6 @@ export const useMonitorStore = defineStore("monitor", () => {
resumeMonitoring, resumeMonitoring,
manualCheck, manualCheck,
testNotification, testNotification,
minimizeToTray,
}; };
}); });

View File

@ -4,7 +4,7 @@ import { ref } from "vue";
import { API_BASE } from "@/api/client"; import { API_BASE } from "@/api/client";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import { useLogStore } from "@/stores/log"; import { useLogStore } from "@/stores/log";
import { formatTime } from "@/utils/common"; import { formatTime, getErrorMessage } from "@/utils/common";
/** /**
* store * store
@ -42,8 +42,8 @@ export const useNetworkStore = defineStore("network", () => {
connectTimeout: appStore.config.network_timeout * 1000, connectTimeout: appStore.config.network_timeout * 1000,
}); });
connected = resp.ok; connected = resp.ok;
} catch { } catch (e: unknown) {
/* 网络不通 */ addLog("ERROR", `公网连通性检测失败: ${getErrorMessage(e)}`, "NETWORK");
} }
if (connected) { if (connected) {
@ -55,8 +55,8 @@ export const useNetworkStore = defineStore("network", () => {
}); });
respTime = Date.now() - apiStart; respTime = Date.now() - apiStart;
reachable = resp.status < 500; reachable = resp.status < 500;
} catch { } catch (e: unknown) {
/* API 不可达 */ addLog("ERROR", `API 可达性检测失败: ${getErrorMessage(e)}`, "NETWORK");
} }
} }

View File

@ -1,11 +1,17 @@
import { load, type Store } from "@tauri-apps/plugin-store"; import { load, type Store } from "@tauri-apps/plugin-store";
import {
APP_CONFIG_STORAGE_KEY,
CREDENTIALS_STORAGE_KEY,
STORE_FILE_NAME,
} from "@/constants/store";
let storeInstance: Store | null = null; let storeInstance: Store | null = null;
async function getStore(): Promise<Store> { async function getStore(): Promise<Store> {
if (!storeInstance) { storeInstance ??= await load(STORE_FILE_NAME, {
storeInstance = await load("config.json", { defaults: {}, autoSave: true }); defaults: {},
} autoSave: true,
});
return storeInstance; return storeInstance;
} }
@ -37,7 +43,7 @@ export async function migrateFromLocalStorage(): Promise<boolean> {
if (migrated) return false; if (migrated) return false;
let hasMigrated = false; let hasMigrated = false;
for (const key of ["crm_credentials", "crm_config"]) { for (const key of [CREDENTIALS_STORAGE_KEY, APP_CONFIG_STORAGE_KEY]) {
const raw = localStorage.getItem(key); const raw = localStorage.getItem(key);
if (raw) { if (raw) {
try { try {
@ -46,6 +52,7 @@ export async function migrateFromLocalStorage(): Promise<boolean> {
hasMigrated = true; hasMigrated = true;
} catch { } catch {
/* ignore invalid JSON */ /* ignore invalid JSON */
// TODO 换成r-utils解析JSON
} }
} }
} }

View File

@ -0,0 +1,5 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
export async function hideCurrentWindow() {
await getCurrentWindow().hide();
}