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:
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { ApiRequestOptions, ApiResponse, ApiResult } from "./types"
|
||||
export { apiClient } from "./client"
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user