fix: 修复桌面版启动期接口加载

This commit is contained in:
2026-05-20 11:53:42 +08:00
parent 3995358095
commit 07ae90c1b5
4 changed files with 55 additions and 3 deletions
+1
View File
@@ -150,6 +150,7 @@ dist\desktop\*-setup.exe
``` ```
桌面版使用 Tauri 启动 Python sidecar。sidecar 默认监听 `127.0.0.1:19082`,运行数据写入系统 app data 目录,不写入安装目录。 桌面版使用 Tauri 启动 Python sidecar。sidecar 默认监听 `127.0.0.1:19082`,运行数据写入系统 app data 目录,不写入安装目录。
首次启动会把安装包内置的 `Configure.json` 和 `ReportScript.sql` 复制到运行数据目录;Windows 下通常是 `%APPDATA%\com.nixevol.capacityreport\`。安装目录中的 `_up_` 只是 Tauri 打包资源目录,程序运行时不会直接编辑它。
构建桌面版需要 Rust 和 Tauri CLI。脚本会在缺少 Tauri CLI 时自动执行: 构建桌面版需要 Rust 和 Tauri CLI。脚本会在缺少 Tauri CLI 时自动执行:
+8
View File
@@ -1,4 +1,12 @@
# 项目上下文记录 # 项目上下文记录
## 2026-05-20:修复桌面版启动期配置和脚本加载竞态
- 桌面版运行配置和脚本仍从安装包资源 `Configure.json`、`ReportScript.sql` 首次复制到系统 AppData 后读取;安装目录中的 `_up_` 是 Tauri 对 `../` 资源的打包目录,不是后端实际运行目录。
- `src-tauri/src/main.rs` 在启动 Python sidecar 后会等待 `127.0.0.1:19082` 可连接,最多等待 20 秒;如果端口没有起来,会主动杀掉刚启动的 sidecar 并让启动失败,避免前端先加载导致配置页停在默认空表单、脚本页停在“正在加载”。
- `frontend/src/api/client.ts` 对普通 `fetch` 请求增加短暂重试,处理桌面 sidecar 启动或服务重启瞬间的 `Failed to fetch`;上传 XHR 不做自动重试,避免重复上传。
- 已实测当前安装目录 `D:\Program Files\CapacityReport\_up_` 和运行目录 `%APPDATA%\com.nixevol.capacityreport` 均存在配置与脚本,`http://127.0.0.1:19082/health`、`/api/config/full`、`/api/script/content` 均能读取;本次修复的是前端初始请求早于 sidecar 就绪的竞态。
- 已执行 `.venv\Scripts\python.exe -m compileall app`、`npm run build` 和带临时 sidecar 占位文件的 `cargo check --manifest-path src-tauri\Cargo.toml`,均通过;生成产物随后清理。
## 2026-05-20:修复登录失败误提示会话过期 ## 2026-05-20:修复登录失败误提示会话过期
- `frontend/src/api/client.ts` 不再把 `/api/login` 的 401 响应当作全局会话过期处理,登录失败会按后端真实错误显示“账号或密码错误”。 - `frontend/src/api/client.ts` 不再把 `/api/login` 的 401 响应当作全局会话过期处理,登录失败会按后端真实错误显示“账号或密码错误”。
+26 -3
View File
@@ -2,6 +2,8 @@ import type { ApiError, ApiErrorDetail } from '../types';
const TOKEN_KEY = 'capacity_report_token'; const TOKEN_KEY = 'capacity_report_token';
const API_BASE = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, ''); const API_BASE = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
const API_FETCH_RETRIES = 8;
const API_FETCH_RETRY_DELAY_MS = 500;
let onUnauthorized: (() => void) | null = null; let onUnauthorized: (() => void) | null = null;
export class ApiRequestError extends Error { export class ApiRequestError extends Error {
@@ -55,7 +57,7 @@ export async function request<T>(url: string, init: RequestInit = {}): Promise<T
headers.set('Authorization', `Bearer ${token}`); headers.set('Authorization', `Bearer ${token}`);
} }
const response = await fetch(apiUrl(url), { ...init, headers }); const response = await fetchWithRetry(apiUrl(url), { ...init, headers });
if (response.status === 401) { if (response.status === 401) {
if (isLoginRequest(url)) { if (isLoginRequest(url)) {
const error = await readError(response); const error = await readError(response);
@@ -131,7 +133,7 @@ export async function download(url: string, body: unknown, filename: string): Pr
headers.set('Authorization', `Bearer ${token}`); headers.set('Authorization', `Bearer ${token}`);
} }
const response = await fetch(apiUrl(url), { const response = await fetchWithRetry(apiUrl(url), {
method: 'POST', method: 'POST',
headers, headers,
body: JSON.stringify(body) body: JSON.stringify(body)
@@ -152,7 +154,7 @@ export async function downloadGet(url: string, fallbackFilename: string): Promis
headers.set('Authorization', `Bearer ${token}`); headers.set('Authorization', `Bearer ${token}`);
} }
const response = await fetch(apiUrl(url), { headers }); const response = await fetchWithRetry(apiUrl(url), { headers });
if (response.status === 401) { if (response.status === 401) {
clearToken(); clearToken();
onUnauthorized?.(); onUnauthorized?.();
@@ -213,6 +215,27 @@ async function saveBlobResponse(response: Response, filename: string): Promise<v
URL.revokeObjectURL(objectUrl); URL.revokeObjectURL(objectUrl);
} }
async function fetchWithRetry(url: string, init: RequestInit): Promise<Response> {
let lastError: unknown;
for (let attempt = 0; attempt <= API_FETCH_RETRIES; attempt += 1) {
try {
return await fetch(url, init);
} catch (error) {
lastError = error;
if (attempt === API_FETCH_RETRIES) {
break;
}
await sleep(API_FETCH_RETRY_DELAY_MS);
}
}
throw lastError instanceof Error ? lastError : new Error('网络请求失败');
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => window.setTimeout(resolve, ms));
}
function parseFilename(disposition: string | null): string { function parseFilename(disposition: string | null): string {
if (!disposition) return ''; if (!disposition) return '';
+20
View File
@@ -2,8 +2,11 @@
use std::error::Error; use std::error::Error;
use std::fs; use std::fs;
use std::net::{SocketAddr, TcpStream};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Mutex; use std::sync::Mutex;
use std::thread;
use std::time::{Duration, Instant};
use tauri::{Manager, Runtime}; use tauri::{Manager, Runtime};
use tauri_plugin_shell::process::CommandChild; use tauri_plugin_shell::process::CommandChild;
@@ -77,6 +80,11 @@ fn start_server<R: Runtime>(app: &tauri::App<R>) -> Result<(), Box<dyn Error>> {
.args(["--host", "127.0.0.1", "--port", "19082"]) .args(["--host", "127.0.0.1", "--port", "19082"])
.spawn()?; .spawn()?;
if let Err(error) = wait_for_server("127.0.0.1:19082", Duration::from_secs(20)) {
let _ = child.kill();
return Err(error);
}
tauri::async_runtime::spawn(async move { while rx.recv().await.is_some() {} }); tauri::async_runtime::spawn(async move { while rx.recv().await.is_some() {} });
let pid = child.pid(); let pid = child.pid();
@@ -85,6 +93,18 @@ fn start_server<R: Runtime>(app: &tauri::App<R>) -> Result<(), Box<dyn Error>> {
Ok(()) Ok(())
} }
fn wait_for_server(addr: &str, timeout: Duration) -> Result<(), Box<dyn Error>> {
let socket_addr: SocketAddr = addr.parse()?;
let started_at = Instant::now();
while started_at.elapsed() < timeout {
if TcpStream::connect_timeout(&socket_addr, Duration::from_millis(250)).is_ok() {
return Ok(());
}
thread::sleep(Duration::from_millis(250));
}
Err(format!("Server did not start within {} seconds", timeout.as_secs()).into())
}
fn stop_server<R: Runtime>(app: &tauri::AppHandle<R>) { fn stop_server<R: Runtime>(app: &tauri::AppHandle<R>) {
let state = app.state::<ServerState>(); let state = app.state::<ServerState>();
let child = state.0.lock().expect("server state lock poisoned").take(); let child = state.0.lock().expect("server state lock poisoned").take();