Add frontend workspace UI and refactor plan

This commit is contained in:
fenghp
2026-06-09 10:07:22 +08:00
parent 307249499c
commit 8692edd2dd
61 changed files with 7877 additions and 70 deletions
+216
View File
@@ -0,0 +1,216 @@
import { getMactfsBridge } from "~/lib/electron/bridge"
import type { ApiResult } from "./types"
const API_BASE_URL = "http://127.0.0.1:38765"
interface ApiRequestOptions extends Omit<RequestInit, "body" | "headers"> {
body?: unknown
headers?: HeadersInit
}
export type ApiRequestLogStatus = "running" | "success" | "error"
export interface ApiRequestLogEntry {
id: string
operation: string
summary: string
startedAt: number
endedAt?: number
durationMs?: number
success?: boolean
errorMessage?: string
status: ApiRequestLogStatus
logs?: string[]
}
type ApiRequestLogListener = (entry: ApiRequestLogEntry) => void
const apiRequestLogListeners = new Set<ApiRequestLogListener>()
let apiRequestLogIndex = 0
/**
* 订阅统一 API client 的请求日志,供底部 Console 展示每次操作状态。
*/
export function subscribeApiRequestLogs(listener: ApiRequestLogListener) {
apiRequestLogListeners.add(listener)
return () => {
apiRequestLogListeners.delete(listener)
}
}
/**
* 向所有日志订阅方发布一条请求状态更新。
*/
function notifyApiRequestLog(entry: ApiRequestLogEntry) {
apiRequestLogListeners.forEach((listener) => listener(entry))
}
/**
* 生成本次请求的前端日志 id,用于 running 和完成态更新同一行。
*/
function nextApiRequestLogId(apiPath: string) {
apiRequestLogIndex += 1
return `${Date.now()}:${apiRequestLogIndex}:${apiPath}`
}
/**
* 从请求体或 query 中提取路径摘要,便于 Console 快速定位操作对象。
*/
function summarizeApiRequest(apiPath: string, body?: unknown) {
if (body && typeof body === "object" && !Array.isArray(body)) {
const bodySummary = summarizeRequestBody(body as Record<string, unknown>)
if (bodySummary) {
return bodySummary
}
}
const query = apiPath.split("?")[1]
return query || apiPath
}
/**
* 按后端常用字段生成请求体摘要。
*/
function summarizeRequestBody(body: Record<string, unknown>) {
return [
"serverPath",
"path",
"localPath",
"paths",
"serverPaths",
"changeset",
"sourceChangeset",
"targetChangeset",
]
.filter((key) => body[key] !== undefined && body[key] !== "")
.map((key) => `${key}=${formatSummaryValue(body[key])}`)
.join(" | ")
}
/**
* 将摘要字段值转换成单行文本。
*/
function formatSummaryValue(value: unknown) {
return Array.isArray(value) ? value.join(", ") : String(value)
}
/**
* 创建前端统一失败响应,供网络或 Electron 桥接不可用时展示明确提示。
*/
function createFailureResult<TData>(
message: string,
startedAt = Date.now()
): ApiResult<TData> {
const endedAt = Date.now()
return {
success: false,
message,
errorMessage: message,
operation: "frontendRequest",
startedAt,
endedAt,
durationMs: endedAt - startedAt,
logs: [],
data: {} as TData,
}
}
/**
* 将统一 API 响应转换为底部 Console 可展示的日志行。
*/
function toApiRequestLogEntry(
id: string,
summary: string,
fallbackStartedAt: number,
result: ApiResult<unknown>
): ApiRequestLogEntry {
return {
id,
operation: result.operation,
summary,
startedAt: result.startedAt || fallbackStartedAt,
endedAt: result.endedAt,
durationMs: result.durationMs,
success: result.success,
errorMessage: result.errorMessage || (result.success ? "" : result.message),
status: result.success ? "success" : "error",
logs: result.logs,
}
}
/**
* 通过统一 API client 调用本地 Java API,并自动附加 Bearer token。
*/
export async function apiRequest<TData>(
apiPath: string,
options: ApiRequestOptions = {}
): Promise<ApiResult<TData>> {
const startedAt = Date.now()
const logId = nextApiRequestLogId(apiPath)
const summary = summarizeApiRequest(apiPath, options.body)
notifyApiRequestLog({
id: logId,
operation: apiPath,
summary,
startedAt,
status: "running",
})
const bridge = getMactfsBridge()
if (!bridge) {
const result = createFailureResult<TData>(
"未检测到 Electron preload,无法读取本地 API token。",
startedAt
)
notifyApiRequestLog(
toApiRequestLogEntry(logId, summary, startedAt, result)
)
return result
}
const token = await bridge.getToken()
if (!token) {
const result = createFailureResult<TData>(
"未找到本地 API token,请确认本地服务已启动。",
startedAt
)
notifyApiRequestLog(
toApiRequestLogEntry(logId, summary, startedAt, result)
)
return result
}
try {
const headers = new Headers(options.headers)
headers.set("Authorization", `Bearer ${token}`)
if (options.body !== undefined) {
headers.set("Content-Type", "application/json")
}
const response = await fetch(`${API_BASE_URL}${apiPath}`, {
...options,
headers,
body:
options.body === undefined ? undefined : JSON.stringify(options.body),
})
const result = (await response.json()) as ApiResult<TData>
notifyApiRequestLog(
toApiRequestLogEntry(logId, summary, startedAt, result)
)
return result
} catch (error) {
const result = createFailureResult<TData>(
error instanceof Error ? error.message : "本地 API 请求失败。",
startedAt
)
notifyApiRequestLog(
toApiRequestLogEntry(logId, summary, startedAt, result)
)
return result
}
}
+237
View File
@@ -0,0 +1,237 @@
import { apiRequest } from "./client"
import type {
AppConfig,
AddMappingData,
AddMappingRequest,
CheckinData,
CheckinRequest,
ChangesetFilesData,
CompareFolderData,
CompareFolderRequest,
CollectionsData,
FileOperationData,
FileOperationRequest,
ConfigData,
ConnectData,
DiffLocalLatestRequest,
DiffRevisionsRequest,
GetLatestData,
GetLatestRequest,
HistoryData,
MappingsData,
MappingsRequest,
OperationLogsData,
PendingChangesData,
PendingChangesRequest,
ServerTreeData,
TextDiffData,
} from "./types"
/**
* 读取后端保存的默认连接配置,用于再次打开时回填表单。
*/
export function getConfig() {
return apiRequest<ConfigData>("/api/config")
}
/**
* 提交连接配置给后端;连接成功时后端会保存为默认配置。
*/
export function connectSession(config: AppConfig) {
return apiRequest<ConnectData>("/api/session/connect", {
method: "POST",
body: config,
})
}
/**
* 加载当前连接可见的 TFS Collection 列表。
*/
export function listCollections() {
return apiRequest<CollectionsData>("/api/collections")
}
/**
* 加载指定 Collection 和服务端路径下的目录节点。
*/
export function listServerTree(path: string, collection?: string) {
const params = new URLSearchParams({ path })
if (collection) {
params.set("collection", collection)
}
return apiRequest<ServerTreeData>(`/api/server-tree?${params.toString()}`)
}
/**
* 加载当前 Workspace 的 Mapping 列表,用于判断服务端项是否已映射到本地。
*/
export function listMappings(request: MappingsRequest = {}) {
const params = new URLSearchParams()
if (request.collection) {
params.set("collection", request.collection)
}
const query = params.toString()
return apiRequest<MappingsData>(
query ? `/api/mappings?${query}` : "/api/mappings"
)
}
/**
* 加载指定目录下一级文件和文件夹,供中间文件列表展示。
*/
export function listServerFolderItems(path: string, collection?: string) {
const params = new URLSearchParams({ path })
if (collection) {
params.set("collection", collection)
}
return apiRequest<ServerTreeData>(
`/api/server-folder/items?${params.toString()}`
)
}
/**
* 创建服务端路径到本地目录的 Mapping,可选择保存后立即 Get Latest。
*/
export function addMapping(request: AddMappingRequest) {
return apiRequest<AddMappingData>("/api/mappings", {
method: "POST",
body: request,
})
}
/**
* 对已映射目录执行元数据级目录对比,返回差异文件状态列表。
*/
export function compareFolder(request: CompareFolderRequest) {
return apiRequest<CompareFolderData>("/api/compare/folder", {
method: "POST",
body: request,
})
}
/**
* 查询当前 Workspace 的挂起更改列表,供右侧 Inspector 分组展示。
*/
export function listPendingChanges(request: PendingChangesRequest = {}) {
const params = new URLSearchParams()
if (request.collection) {
params.set("collection", request.collection)
}
if (request.serverPath) {
params.set("serverPath", request.serverPath)
}
const query = params.toString()
return apiRequest<PendingChangesData>(
query ? `/api/pending-changes?${query}` : "/api/pending-changes"
)
}
/**
* 获取指定服务端路径的最新版本,目录按 recursive 参数递归。
*/
export function getLatest(request: GetLatestRequest) {
return apiRequest<GetLatestData>("/api/files/get-latest", {
method: "POST",
body: request,
})
}
/**
* 对已映射且本地存在的文件或目录执行 checkout。
*/
export function checkoutFiles(request: FileOperationRequest) {
return apiRequest<FileOperationData>("/api/files/checkout", {
method: "POST",
body: request,
})
}
/**
* 将本地新增文件加入 pending add。
*/
export function addFiles(request: FileOperationRequest) {
return apiRequest<FileOperationData>("/api/files/add", {
method: "POST",
body: request,
})
}
/**
* 对已版本控制文件或目录执行 pending delete。
*/
export function deleteFiles(request: FileOperationRequest) {
return apiRequest<FileOperationData>("/api/files/delete", {
method: "POST",
body: request,
})
}
/**
* 撤销指定路径上的 pending changes。
*/
export function undoFiles(request: FileOperationRequest) {
return apiRequest<FileOperationData>("/api/files/undo", {
method: "POST",
body: request,
})
}
/**
* 将 Included Changes 对应的 serverPaths 提交到 TFS。
*/
export function checkin(request: CheckinRequest) {
return apiRequest<CheckinData>("/api/checkin", {
method: "POST",
body: request,
})
}
/**
* 查询文件或目录最近历史记录。
*/
export function queryHistory(path: string, folder: boolean) {
const params = new URLSearchParams({ path, folder: String(folder) })
return apiRequest<HistoryData>(`/api/history?${params.toString()}`)
}
/**
* 查询指定 changeset 影响的文件列表。
*/
export function queryChangesetFiles(changeset: number) {
const params = new URLSearchParams({ changeset: String(changeset) })
return apiRequest<ChangesetFilesData>(
`/api/history/changeset?${params.toString()}`
)
}
/**
* 生成本地文件和服务器 latest 之间的文本 diff。
*/
export function diffLocalLatest(request: DiffLocalLatestRequest) {
return apiRequest<TextDiffData>("/api/diff/local-latest", {
method: "POST",
body: request,
})
}
/**
* 生成同一服务端文件两个历史版本之间的文本 diff。
*/
export function diffRevisions(request: DiffRevisionsRequest) {
return apiRequest<TextDiffData>("/api/diff/revisions", {
method: "POST",
body: request,
})
}
/**
* 读取本地 API 服务最近操作日志,供底部 Console 手动刷新。
*/
export function listOperationLogs() {
return apiRequest<OperationLogsData>("/api/logs")
}
+251
View File
@@ -0,0 +1,251 @@
export interface ApiResult<TData = Record<string, unknown>> {
success: boolean
message: string
errorMessage?: string
operation: string
startedAt: number
endedAt: number
durationMs: number
logs: string[]
data: TData
}
export interface HealthData {
status: string
host: string
port: number
tokenFile: string
configFile: string
connected: boolean
}
export interface MappingConfig {
serverPath?: string
localPath?: string
}
export interface TfsMappingInfo {
serverPath: string
localPath: string
}
export interface AppConfig {
serverUri?: string
authType?: string
domain?: string
username?: string
password?: string
collection?: string
workspace?: string
mappings?: MappingConfig[]
}
export interface ConfigData {
config: AppConfig
}
export interface ConnectData {
serverUri?: string
collectionCount?: number
}
export interface TfsCollectionInfo {
name: string
id: string
}
export interface TfsServerItem {
name: string
path: string
serverPath: string
type: "folder" | "file"
folder: boolean
latestVersion: number
checkinDate?: number | null
}
export interface CollectionsData {
collections: TfsCollectionInfo[]
}
export interface ServerTreeData {
path: string
items: TfsServerItem[]
}
export interface MappingsData {
mappings: TfsMappingInfo[]
}
export interface MappingsRequest {
collection?: string
}
export interface AddMappingRequest {
serverPath: string
localPath: string
getLatest: boolean
}
export interface AddMappingData {
mapping?: TfsMappingInfo
getLatest?: unknown
}
export type FolderDiffStatus =
| "localModified"
| "remoteChanged"
| "bothChanged"
| "localOnly"
| "remoteOnly"
| "notDownloaded"
| "localDeleted"
| "pendingEdit"
| "pendingAdd"
| "pendingDelete"
| "upToDate"
export interface CompareFolderRequest {
serverPath: string
localPath: string
recursive: boolean
}
export interface FolderDiffItem {
serverPath: string
localPath: string
name: string
folder: boolean
status: FolderDiffStatus
localVersion: number
latestVersion: number
}
export interface CompareFolderData {
diffs: FolderDiffItem[]
}
export type PendingChangeStatus =
| "pendingEdit"
| "pendingAdd"
| "pendingDelete"
| "pendingRename"
| "pending"
export interface TfsPendingChangeInfo {
serverPath: string
localPath: string
name: string
folder: boolean
status: PendingChangeStatus
changeType: string
version: number
}
export interface PendingChangesData {
pendingChanges: TfsPendingChangeInfo[]
}
export interface PendingChangesRequest {
collection?: string
serverPath?: string
}
export interface GetLatestRequest {
serverPath: string
recursive: boolean
}
export interface TfsGetLatestResult {
updated: number
operations: number
conflicts: number
failures: number
}
export interface GetLatestData {
result?: TfsGetLatestResult
}
export interface FileOperationRequest {
paths: string[]
recursive?: boolean
}
export interface TfsFileOperationResult {
operation: string
affected: number
failures: string[]
}
export interface FileOperationData {
result?: TfsFileOperationResult
}
export interface CheckinRequest {
serverPaths: string[]
comment: string
}
export interface TfsCheckinResult {
changeset: number
submittedChanges: number
}
export interface CheckinData {
checkin?: TfsCheckinResult
}
export interface TfsHistoryEntry {
serverPath: string
name: string
changeType: string
itemType: string
changeset: number
author: string
date?: number | null
comment: string
}
export interface HistoryData {
history: TfsHistoryEntry[]
}
export interface ChangesetFilesData {
files: TfsHistoryEntry[]
}
export interface DiffLocalLatestRequest {
serverPath: string
localPath: string
}
export interface DiffRevisionsRequest {
serverPath: string
sourceChangeset: number
targetChangeset: number
}
export interface TfsTextDiff {
sourceLabel: string
targetLabel: string
lines: string[]
}
export interface TextDiffData {
diff?: TfsTextDiff
}
export interface TfsOperationLogEntry {
operation: string
summary: string
startedAt: number
endedAt: number
durationMs: number
success: boolean
errorMessage?: string
}
export interface OperationLogsData {
logs: TfsOperationLogEntry[]
}