frontend: add electron service bootstrap, preload and api client

FE-001: detect and launch the local mactfs-server, expose a narrow preload
bridge (token, service status, directory picker), and add a unified API
client that auto-attaches the Bearer token. Home becomes a bootstrap gate
with a retry entry. Update task board, FE-001 record and FRONTEND_SPEC.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
fenghp
2026-06-09 19:46:09 +08:00
co-authored by Cursor
parent 09421930a3
commit 5c633fd024
12 changed files with 700 additions and 23 deletions
+117
View File
@@ -0,0 +1,117 @@
import { getApiBaseUrl, getToken } from "~/lib/electron"
import type { ApiRequestOptions, ApiResponse, ApiResult } from "./types"
/**
* 拼接查询参数到请求路径,自动跳过空值。
*/
function buildUrl(
baseUrl: string,
path: string,
query?: ApiRequestOptions["query"],
): string {
const url = new URL(path, baseUrl)
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null) {
url.searchParams.set(key, String(value))
}
}
}
return url.toString()
}
/**
* 统一提取错误信息,遵循 errorMessage || message 的展示约定。
*/
function resolveErrorMessage(
result: ApiResult<unknown> | null,
fallback: string,
): string {
if (result) {
return result.errorMessage || result.message || fallback
}
return fallback
}
/**
* 发起一次本地 API 请求,自动携带 Bearer Token 并统一封装结果。
*/
async function request<T = Record<string, unknown>>(
method: string,
path: string,
options: ApiRequestOptions = {},
): Promise<ApiResponse<T>> {
const baseUrl = await getApiBaseUrl()
const token = await getToken()
const headers: Record<string, string> = {
Accept: "application/json",
}
if (token) {
headers.Authorization = `Bearer ${token}`
}
const init: RequestInit = { method, headers, signal: options.signal }
if (options.body !== undefined) {
headers["Content-Type"] = "application/json;charset=utf-8"
init.body = JSON.stringify(options.body)
}
let response: Response
try {
response = await fetch(buildUrl(baseUrl, path, options.query), init)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return {
ok: false,
status: 0,
result: null,
data: null,
errorMessage: `无法连接本地服务:${message}`,
}
}
let result: ApiResult<T> | null = null
try {
result = (await response.json()) as ApiResult<T>
} catch {
result = null
}
const ok = response.ok && result?.success === true
return {
ok,
status: response.status,
result,
data: result?.data ?? null,
errorMessage: ok
? null
: resolveErrorMessage(result, `请求失败(HTTP ${response.status})`),
}
}
// 统一 API 客户端,组件只通过本对象访问后端,不直接 fetch、不直接读 token。
export const apiClient = {
/**
* 发起 GET 请求。
*/
get<T = Record<string, unknown>>(path: string, options?: ApiRequestOptions) {
return request<T>("GET", path, options)
},
/**
* 发起 POST 请求。
*/
post<T = Record<string, unknown>>(path: string, options?: ApiRequestOptions) {
return request<T>("POST", path, options)
},
/**
* 发起 PUT 请求。
*/
put<T = Record<string, unknown>>(path: string, options?: ApiRequestOptions) {
return request<T>("PUT", path, options)
},
/**
* 发起 DELETE 请求。
*/
delete<T = Record<string, unknown>>(path: string, options?: ApiRequestOptions) {
return request<T>("DELETE", path, options)
},
}
+2
View File
@@ -0,0 +1,2 @@
export type { ApiRequestOptions, ApiResponse, ApiResult } from "./types"
export { apiClient } from "./client"
+28
View File
@@ -0,0 +1,28 @@
// 服务端统一响应结构,对应 MacTfsServer.ApiResult。
export interface ApiResult<T = Record<string, unknown>> {
success: boolean
message?: string
errorMessage?: string
operation?: string
startedAt?: number
endedAt?: number
durationMs?: number
logs?: string[]
data: T
}
// API 客户端对外返回结构,统一封装网络层与业务层成功/失败。
export interface ApiResponse<T = Record<string, unknown>> {
ok: boolean
status: number
result: ApiResult<T> | null
data: T | null
errorMessage: string | null
}
// API 请求选项。
export interface ApiRequestOptions {
query?: Record<string, string | number | boolean | undefined | null>
body?: unknown
signal?: AbortSignal
}