fix: 修复桌面版启动期接口加载
This commit is contained in:
@@ -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 时自动执行:
|
||||||
|
|
||||||
|
|||||||
@@ -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 响应当作全局会话过期处理,登录失败会按后端真实错误显示“账号或密码错误”。
|
||||||
|
|||||||
@@ -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 '';
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user