Add frontend workspace UI and refactor plan
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
Reference in New Issue
Block a user