fix: 优化日志显示和下载完成反馈

This commit is contained in:
2026-05-22 10:30:52 +08:00
parent c2952d028f
commit 211f87e65e
15 changed files with 259 additions and 94 deletions
+8
View File
@@ -1,4 +1,12 @@
# 项目上下文记录
## 2026-05-22:统一日志框与桌面端下载路径反馈
- 数据处理页菜单从“数据上传”改为“数据处理”,路由标题同步改为“数据处理”。
- 上传/处理日志、历史详情日志和脚本执行日志统一使用分级日志渲染:默认/INFO 跟随主题文字色,SUCCESS 绿色,WARNING 黄色,ERROR 红色;日志框背景和滚动条颜色跟随主题,支持横纵向滚动。
- 数据处理页日志高度改为响应式上限,避免长任务日志把外层页面撑出纵向滚动条。
- 脚本编辑页的手动运行日志移到编辑器下方,编辑器与日志框共享页面高度;脚本运行状态保存在前端全局状态中,切换页面回来可继续轮询或查看上次运行结果,运行结束不自动隐藏日志。
- 桌面端下载完成弹窗会显示保存路径,并提供“打开所在文件夹”;新增 Tauri 命令 `open_path_in_file_manager` 通过系统文件管理器打开下载目录。
## 2026-05-21:桌面端 sidecar 进程改为跨平台管理
- Tauri 桌面端启动/关闭后端 sidecar 不再调用 Windows `netstat`、`tasklist`、`taskkill` 等控制台命令,避免启动和退出时闪过 DOS 窗口。
+1 -1
View File
@@ -178,7 +178,7 @@ type MenuKey = (typeof menuKeys)[number];
let brandClickResetTimer: number | undefined;
const menuOptions: MenuOption[] = [
{ label: '数据上传', key: 'workflow', icon: renderIcon(CloudUploadOutline) },
{ label: '数据处理', key: 'workflow', icon: renderIcon(CloudUploadOutline) },
{ label: '处理历史', key: 'history', icon: renderIcon(FileTrayFullOutline) },
{ label: '数据管理', key: 'database', icon: renderIcon(ServerOutline) },
{ label: '脚本编辑', key: 'script', icon: renderIcon(ConstructOutline) },
+17 -6
View File
@@ -11,6 +11,11 @@ let cachedTauriInvoke: TauriInvoke | null = null;
type TauriInvoke = <T>(command: string, args?: Record<string, unknown>) => Promise<T>;
export interface DownloadResult {
saved: boolean;
path?: string;
}
interface TauriDownloadHeader {
name: string;
value: string;
@@ -149,7 +154,7 @@ export function upload<T>(
});
}
export async function download(url: string, body: unknown, filename: string): Promise<boolean> {
export async function download(url: string, body: unknown, filename: string): Promise<DownloadResult> {
const headers = new Headers({ 'Content-Type': 'application/json' });
const token = getToken();
if (token) {
@@ -180,7 +185,7 @@ export async function download(url: string, body: unknown, filename: string): Pr
return await saveBlobResponse(response, parseFilename(response.headers.get('content-disposition')) || filename);
}
export async function downloadGet(url: string, fallbackFilename: string): Promise<boolean> {
export async function downloadGet(url: string, fallbackFilename: string): Promise<DownloadResult> {
const headers = new Headers();
const token = getToken();
if (token) {
@@ -247,7 +252,7 @@ function parseApiError(data: ApiError, fallback: string): { message: string; det
};
}
async function saveBlobResponse(response: Response, filename: string): Promise<boolean> {
async function saveBlobResponse(response: Response, filename: string): Promise<DownloadResult> {
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
@@ -255,19 +260,25 @@ async function saveBlobResponse(response: Response, filename: string): Promise<b
link.download = filename;
link.click();
URL.revokeObjectURL(objectUrl);
return true;
return { saved: true };
}
async function downloadWithTauri(request: TauriDownloadRequest): Promise<boolean> {
async function downloadWithTauri(request: TauriDownloadRequest): Promise<DownloadResult> {
try {
const invoke = await getTauriInvoke();
const result = await invoke<TauriDownloadResult>('download_to_file', { request });
return result.saved;
return { saved: result.saved, path: result.path };
} catch (error) {
throw parseTauriDownloadError(error);
}
}
export async function openPathInFileManager(path: string): Promise<void> {
if (!isTauriRuntime()) return;
const invoke = await getTauriInvoke();
await invoke('open_path_in_file_manager', { path });
}
async function getTauriInvoke(): Promise<TauriInvoke> {
if (!cachedTauriInvoke) {
const api = await import('@tauri-apps/api/core');
+6 -6
View File
@@ -487,14 +487,14 @@ async function downloadSelectedCsv() {
const filename = `${selectedCsvTable.value}.csv`;
downloadingFormat.value = 'csv';
try {
const completed = await download(
const result = await download(
'/api/download',
{ table_name: selectedCsvTable.value, format: 'csv' },
filename
);
if (completed) {
if (result.saved) {
csvDialogVisible.value = false;
showDownloadCompleteDialog(dialog, filename);
showDownloadCompleteDialog(dialog, filename, result.path);
}
} catch (error) {
message.error(error instanceof Error ? error.message : '导出失败');
@@ -510,14 +510,14 @@ async function downloadSelectedXlsx() {
downloadingFormat.value = 'xlsx';
const filename = selectedXlsxTables.value.length === 1 ? `${selectedXlsxTables.value[0]}.xlsx` : 'tables.xlsx';
try {
const completed = await download(
const result = await download(
'/api/download',
{ table_names: selectedXlsxTables.value, format: 'xlsx' },
filename
);
if (completed) {
if (result.saved) {
xlsxDialogVisible.value = false;
showDownloadCompleteDialog(dialog, filename);
showDownloadCompleteDialog(dialog, filename, result.path);
}
} catch (error) {
message.error(error instanceof Error ? error.message : '导出失败');
+8 -1
View File
@@ -141,7 +141,12 @@
{{ taskStatus.error }}
</n-alert>
<div ref="logContainer" class="log-container">
<pre class="log-content">{{ logText }}</pre>
<pre class="colored-log-content"><span
v-for="(line, index) in logLines"
:key="index"
class="colored-log-line"
:class="`log-level-${line.level}`"
>{{ line.text }}</span></pre>
</div>
</div>
</div>
@@ -189,6 +194,7 @@ import {
import { ApiRequestError, apiGet, apiPost, upload } from '../api/client';
import type { ActiveTask, ApiErrorDetail, LicenseStatus, TaskStatus } from '../types';
import { toColoredLogLines } from '../composables/logLines';
type FileStatus = 'pending' | 'uploading' | 'uploaded' | 'error';
@@ -248,6 +254,7 @@ const stageLabels: Record<string, string> = {
};
const logText = computed(() => taskStatus.value?.logs?.join('\n') || '等待任务开始');
const logLines = computed(() => toColoredLogLines(logText.value, '等待任务开始'));
const processVisible = computed(() => Boolean(taskStatus.value || activeTask.value?.has_active));
const taskInProgress = computed(() => {
if (activeTask.value?.has_active) {
+5 -20
View File
@@ -101,10 +101,9 @@ import { CopyOutline, DownloadOutline, FileTrayOutline, RefreshOutline, TrashOut
import { apiGet, apiPost, download as downloadFile } from '../api/client';
import type { CacheSize, HistoryDetail, HistoryRecord } from '../types';
import { showDownloadCompleteDialog } from '../composables/downloadFeedback';
import { toColoredLogLines } from '../composables/logLines';
import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
type LogLevel = 'default' | 'info' | 'success' | 'warning' | 'error';
const message = useMessage();
const dialog = useDialog();
const records = ref<HistoryRecord[]>([]);
@@ -120,13 +119,7 @@ let recordSizeToken = 0;
const totalSizeText = computed(() => `总占用: ${cacheSize.value?.size_formatted || '计算中...'}`);
const hasRecords = computed(() => records.value.length > 0);
const detailLogText = computed(() => detail.value?.logs?.join('\n') || '');
const detailLogLines = computed(() => {
const lines = detailLogText.value ? detailLogText.value.split(/\r?\n/) : ['暂无日志'];
return lines.map(line => ({
text: line,
level: resolveLogLevel(line)
}));
});
const detailLogLines = computed(() => toColoredLogLines(detailLogText.value));
onMounted(() => {
setPageHeader({
@@ -186,9 +179,9 @@ async function downloadHistory(record: HistoryRecord) {
downloadingRecordId.value = record.id;
try {
const filename = `${record.id}.zip`;
const completed = await downloadFile('/api/history/download', { record_id: record.id }, filename);
if (completed) {
showDownloadCompleteDialog(dialog, filename);
const result = await downloadFile('/api/history/download', { record_id: record.id }, filename);
if (result.saved) {
showDownloadCompleteDialog(dialog, filename, result.path);
}
} catch (error) {
message.error(error instanceof Error ? error.message : '下载历史数据失败');
@@ -256,14 +249,6 @@ async function writeClipboardText(text: string) {
}
}
function resolveLogLevel(line: string): LogLevel {
if (/\b(ERROR|FAILED|FAILURE)\b|错误|失败/i.test(line)) return 'error';
if (/\b(WARN|WARNING)\b|警告/i.test(line)) return 'warning';
if (/\b(SUCCESS|SUCCEEDED|COMPLETED)\b|成功|完成/i.test(line)) return 'success';
if (/\bINFO\b|信息/i.test(line)) return 'info';
return 'default';
}
function confirmDelete(recordId: string) {
dialog.warning({
title: '删除历史记录',
+34 -11
View File
@@ -4,14 +4,8 @@
<div ref="editorHost" class="script-editor" />
</div>
<div class="script-status-bar">
<span>{{ cursorText }}</span>
<span>{{ editorStatus }}</span>
<span>{{ lineCount }} 行</span>
</div>
<div v-if="taskStatus" class="process-section script-process-section">
<div class="upload-process-card">
<div v-if="taskStatus || taskId" class="script-process-section">
<div class="upload-process-card script-process-card">
<div class="card-header">
<div class="card-header-left">
<span class="card-title">脚本执行进度</span>
@@ -22,10 +16,23 @@
</div>
</div>
<div class="card-body">
<n-log :log="logText" language="text" trim class="script-log" />
<div class="colored-log-panel script-log" role="log" aria-label="脚本执行日志">
<pre class="colored-log-content"><span
v-for="(line, index) in logLines"
:key="index"
class="colored-log-line"
:class="`log-level-${line.level}`"
>{{ line.text }}</span></pre>
</div>
</div>
</div>
</div>
<div class="script-status-bar">
<span>{{ cursorText }}</span>
<span>{{ editorStatus }}</span>
<span>{{ lineCount }} 行</span>
</div>
</div>
</template>
@@ -39,12 +46,17 @@ import 'monaco-editor/esm/vs/basic-languages/sql/sql.contribution';
import { apiGet, apiPost } from '../api/client';
import type { ApiMessage, ScriptContent, TaskStatus } from '../types';
import { toColoredLogLines } from '../composables/logLines';
import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
type MonacoGlobal = typeof globalThis & {
MonacoEnvironment?: {
getWorker: (_moduleId: string, _label: string) => Worker;
};
__capacityReportScriptTaskState?: {
taskId: ReturnType<typeof ref<string>>;
taskStatus: ReturnType<typeof ref<TaskStatus | null>>;
};
};
(globalThis as MonacoGlobal).MonacoEnvironment = {
@@ -65,13 +77,21 @@ const isModified = ref(false);
const loading = ref(false);
const saving = ref(false);
const executing = ref(false);
const taskId = ref('');
const taskStatus = ref<TaskStatus | null>(null);
let timer: number | undefined;
let themeObserver: MutationObserver | undefined;
let applyingRemoteContent = false;
const globalWindow = window as MonacoGlobal;
const scriptTaskState = globalWindow.__capacityReportScriptTaskState || {
taskId: ref(''),
taskStatus: ref<TaskStatus | null>(null)
};
globalWindow.__capacityReportScriptTaskState = scriptTaskState;
const taskId = scriptTaskState.taskId;
const taskStatus = scriptTaskState.taskStatus;
const logText = computed(() => taskStatus.value?.logs?.join('\n') || '等待执行日志');
const logLines = computed(() => toColoredLogLines(logText.value, '等待执行日志'));
const statusClass = computed(() => {
if (taskStatus.value?.status === 'completed') return 'completed';
if (taskStatus.value?.status === 'failed') return 'failed';
@@ -123,6 +143,9 @@ onMounted(async () => {
createEditor();
observeTheme();
await loadScript();
if (taskId.value && taskStatus.value && !['completed', 'failed'].includes(taskStatus.value.status)) {
startPolling();
}
});
onBeforeUnmount(() => {
+3 -3
View File
@@ -659,9 +659,9 @@ async function changePassword() {
async function downloadConfig() {
try {
const filename = 'Configure.json';
const completed = await downloadGet('/api/config/download', filename);
if (completed) {
showDownloadCompleteDialog(dialog, filename);
const result = await downloadGet('/api/config/download', filename);
if (result.saved) {
showDownloadCompleteDialog(dialog, filename, result.path);
}
} catch (error) {
message.error(error instanceof Error ? error.message : '下载配置失败');
+24 -13
View File
@@ -1,21 +1,32 @@
interface DownloadDialog {
success(options: {
title: string;
content: string;
positiveText?: string;
closable?: boolean;
maskClosable?: boolean;
}): unknown;
}
import { h, type VNodeChild } from 'vue';
import type { useDialog } from 'naive-ui';
export function showDownloadCompleteDialog(dialog: DownloadDialog, filename?: string): void {
import { openPathInFileManager } from '../api/client';
type DownloadDialog = ReturnType<typeof useDialog>;
export function showDownloadCompleteDialog(dialog: DownloadDialog, filename?: string, path?: string): void {
const safeName = filename?.trim();
const safePath = path?.trim();
const content = safePath
? (): VNodeChild => h('div', { class: 'download-complete-content' }, [
h('p', safeName ? `文件「${safeName}」已下载完成。` : '文件已下载完成。'),
h('button', {
class: 'download-path-link',
type: 'button',
title: safePath,
onClick: () => void openPathInFileManager(safePath)
}, safePath)
])
: safeName ? `文件「${safeName}」已下载完成。` : '文件已下载完成。';
dialog.success({
title: '下载完成',
content: safeName ? `文件「${safeName}」已下载完成。` : '文件已下载完成。',
positiveText: '知道了',
content,
positiveText: safePath ? '打开所在文件夹' : '知道了',
negativeText: safePath ? '关闭' : undefined,
closable: true,
maskClosable: true
maskClosable: true,
onPositiveClick: safePath ? () => openPathInFileManager(safePath) : undefined
});
}
+28
View File
@@ -0,0 +1,28 @@
export type LogLevel = 'default' | 'info' | 'success' | 'warning' | 'error';
export interface ColoredLogLine {
text: string;
level: LogLevel;
}
export function toColoredLogLines(text: string, fallback = '暂无日志'): ColoredLogLine[] {
const lines = text ? text.split(/\r?\n/) : [fallback];
return lines.map(line => ({
text: line,
level: resolveLogLevel(line)
}));
}
export function resolveLogLevel(line: string): LogLevel {
const marker = line.match(/\[(ERROR|WARN|WARNING|SUCCESS|INFO)\]/i)?.[1]?.toUpperCase();
if (marker === 'ERROR') return 'error';
if (marker === 'WARN' || marker === 'WARNING') return 'warning';
if (marker === 'SUCCESS') return 'success';
if (marker === 'INFO') return 'info';
if (/\b(ERROR|FAILED|FAILURE)\b|错误|失败/i.test(line)) return 'error';
if (/\b(WARN|WARNING)\b|警告/i.test(line)) return 'warning';
if (/\b(SUCCESS|SUCCEEDED|COMPLETED)\b|成功|已完成|处理完成|下载完成|执行完成/i.test(line)) {
return 'success';
}
return 'default';
}
+1 -1
View File
@@ -9,7 +9,7 @@ const ScriptPanel = () => import('./components/ScriptPanel.vue');
export const routes: RouteRecordRaw[] = [
{ path: '/', redirect: { name: 'workflow' } },
{ path: '/upload', name: 'workflow', component: FileWorkflow, meta: { title: '数据上传与处理' } },
{ path: '/upload', name: 'workflow', component: FileWorkflow, meta: { title: '数据处理' } },
{ path: '/history', name: 'history', component: HistoryPanel, meta: { title: '处理历史' } },
{ path: '/database', name: 'database', component: DatabasePanel, meta: { title: '数据管理' } },
{ path: '/script', name: 'script', component: ScriptPanel, meta: { title: '脚本编辑' } },
+103 -31
View File
@@ -26,6 +26,7 @@
--td-bg-color-component-disabled: #eeeeee;
--td-border-color: #dcdcdc;
--td-border-color-light: #e7e7e7;
--td-scrollbar-color: rgba(0, 0, 0, 0.2);
--td-mask-color: rgba(0, 0, 0, 0.6);
--td-shadow-1: 0 1px 10px rgba(0, 0, 0, 0.05), 0 4px 5px rgba(0, 0, 0, 0.08), 0 2px 4px -1px rgba(0, 0, 0, 0.12);
--td-shadow-2: 0 3px 14px 2px rgba(0, 0, 0, 0.05), 0 8px 10px 1px rgba(0, 0, 0, 0.06), 0 5px 5px -3px rgba(0, 0, 0, 0.1);
@@ -61,6 +62,7 @@
--td-bg-color-component-disabled: #383838;
--td-border-color: #5e5e5e;
--td-border-color-light: #4c4c4c;
--td-scrollbar-color: rgba(255, 255, 255, 0.28);
--td-shadow-1: 0 1px 10px rgba(0, 0, 0, 0.15), 0 4px 5px rgba(0, 0, 0, 0.22), 0 2px 4px -1px rgba(0, 0, 0, 0.32);
--td-shadow-2: 0 3px 14px 2px rgba(0, 0, 0, 0.15), 0 8px 10px 1px rgba(0, 0, 0, 0.18), 0 5px 5px -3px rgba(0, 0, 0, 0.28);
--td-shadow-3: 0 6px 30px 5px rgba(0, 0, 0, 0.15), 0 16px 24px 2px rgba(0, 0, 0, 0.12), 0 8px 10px -5px rgba(0, 0, 0, 0.24);
@@ -473,6 +475,12 @@ select {
padding: 24px 32px;
}
.upload-page-body {
display: flex;
min-height: 0;
flex-direction: column;
}
.upload-zone {
display: flex;
position: relative;
@@ -797,9 +805,14 @@ select {
}
.process-section {
min-height: 0;
margin-top: 12px;
}
.upload-page-body > .process-section {
flex: 1 1 auto;
}
.upload-process-card {
background: var(--td-bg-color-container);
border: 1px solid var(--td-border-color-light);
@@ -829,6 +842,7 @@ select {
.card-body {
padding: 20px;
min-height: 0;
}
.process-status {
@@ -857,20 +871,40 @@ select {
}
.log-container {
height: calc(100vh - 280px);
min-height: 300px;
height: clamp(200px, calc(100vh - 400px), 520px);
min-height: 180px;
margin-top: 16px;
padding: 16px;
overflow-y: auto;
overflow: auto;
background: var(--td-bg-color-page);
border: 1px solid var(--td-border-color-light);
border-radius: var(--td-radius-default);
scrollbar-color: var(--td-scrollbar-color) transparent;
scrollbar-width: thin;
}
.process-section.finished .log-container {
height: auto;
min-height: 180px;
max-height: clamp(220px, calc(100vh - 620px), 460px);
height: clamp(180px, calc(100vh - 460px), 380px);
min-height: 160px;
}
.upload-page-body > .process-section .upload-process-card {
display: flex;
height: 100%;
max-height: 100%;
min-height: 0;
flex-direction: column;
}
.upload-page-body > .process-section .card-body {
display: flex;
flex: 1 1 auto;
min-height: 0;
flex-direction: column;
}
.upload-page-body > .process-section .log-container {
flex: 1 1 auto;
}
.log-content {
@@ -1063,13 +1097,15 @@ select {
}
.colored-log-panel {
height: 360px;
overflow: scroll;
height: 300px;
overflow: auto;
padding: 12px;
color: #d7dde7;
background: #111820;
color: var(--td-text-color-primary);
background: var(--td-bg-color-page);
border: 1px solid var(--td-border-color-light);
border-radius: var(--td-radius-default);
scrollbar-color: #5f6f83 #0b1118;
scrollbar-color: var(--td-scrollbar-color) transparent;
scrollbar-width: thin;
}
.colored-log-panel::-webkit-scrollbar {
@@ -1078,22 +1114,22 @@ select {
}
.colored-log-panel::-webkit-scrollbar-track {
background: #0b1118;
background: transparent;
border-radius: 5px;
}
.colored-log-panel::-webkit-scrollbar-thumb {
background: #5f6f83;
border: 2px solid #0b1118;
background: var(--td-scrollbar-color);
border: 2px solid var(--td-bg-color-page);
border-radius: 5px;
}
.colored-log-panel::-webkit-scrollbar-thumb:hover {
background: #7b8da3;
background: var(--td-text-color-placeholder);
}
.colored-log-panel::-webkit-scrollbar-corner {
background: #0b1118;
background: var(--td-bg-color-page);
}
.colored-log-content {
@@ -1108,32 +1144,57 @@ select {
.colored-log-line {
display: block;
color: #d7dde7;
color: var(--td-text-color-primary);
white-space: pre;
}
.colored-log-line.log-level-info {
color: #62a8ff;
color: var(--td-text-color-primary);
}
.colored-log-line.log-level-success {
color: #38d996;
color: var(--td-success-color);
}
.colored-log-line.log-level-warning {
color: #f6c453;
color: var(--td-warning-color);
}
.colored-log-line.log-level-error {
color: #ff6b7a;
color: var(--td-error-color);
}
.script-log {
min-height: 320px;
padding: 12px;
color: #d7dde7;
background: #111820;
border-radius: var(--td-radius-default);
height: 100%;
}
.download-complete-content {
display: flex;
min-width: 0;
flex-direction: column;
gap: 8px;
}
.download-complete-content p {
margin: 0;
}
.download-path-link {
overflow: hidden;
padding: 0;
color: var(--td-brand-color);
font: inherit;
font-family: var(--td-font-family-mono);
text-align: left;
white-space: nowrap;
text-overflow: ellipsis;
cursor: pointer;
background: transparent;
border: 0;
}
.download-path-link:hover {
text-decoration: underline;
}
/* ==================== Settings ==================== */
@@ -1550,7 +1611,7 @@ select {
}
.script-editor-container {
flex: 1;
flex: 1 1 auto;
min-height: 0;
margin: 16px 16px 0;
overflow: hidden;
@@ -1588,17 +1649,28 @@ select {
}
.script-process-section {
flex: 0 0 auto;
margin: 0 16px 16px;
flex: 0 1 clamp(168px, 26vh, 280px);
min-height: 148px;
margin: 8px 16px 0;
overflow: hidden;
}
.script-process-card {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
}
.script-process-section .card-body {
flex: 1;
min-height: 0;
padding: 12px 16px 16px;
}
.script-log {
height: 240px;
min-height: 240px;
height: 100%;
min-height: 0;
}
/* ==================== Responsive ==================== */
+1
View File
@@ -237,6 +237,7 @@ dependencies = [
name = "capacity-report-desktop"
version = "3.0.0"
dependencies = [
"open",
"reqwest",
"serde",
"sysinfo",
+1
View File
@@ -9,6 +9,7 @@ edition = "2021"
tauri-build = { version = "2", features = [] }
[dependencies]
open = "5.3.5"
reqwest = { version = "0.13", default-features = false, features = ["stream"] }
serde = { version = "1", features = ["derive"] }
sysinfo = { version = "0.38.4", default-features = false, features = ["system"] }
+19 -1
View File
@@ -267,6 +267,21 @@ async fn download_to_file(
})
}
#[tauri::command]
fn open_path_in_file_manager(path: String) -> Result<(), String> {
let target = PathBuf::from(path);
let directory = if target.is_dir() {
target
} else {
target
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| "Unable to resolve download directory".to_string())?
};
open::that(directory).map_err(|error| format!("Unable to open download directory: {error}"))
}
async fn write_download(request: DownloadFileRequest, file_path: &Path) -> Result<(), String> {
let method = reqwest::Method::from_bytes(request.method.as_bytes())
.map_err(|error| format!("Invalid download method: {error}"))?;
@@ -332,7 +347,10 @@ fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![download_to_file])
.invoke_handler(tauri::generate_handler![
download_to_file,
open_path_in_file_manager
])
.manage(ServerState(Mutex::new(None)))
.setup(|app| {
start_server(app)?;