1 Commits
Author SHA1 Message Date
fenghp 8692edd2dd Add frontend workspace UI and refactor plan 2026-06-09 10:07:22 +08:00
61 changed files with 7877 additions and 70 deletions
@@ -4,7 +4,11 @@ import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 保存最近一次成功连接与映射结果,供第一阶段 CLI 复用输入。
@@ -12,6 +16,7 @@ import java.util.Properties;
public class LocalConfigStore {
private final File configFile;
private final File serverConfigFile;
public LocalConfigStore() {
File configDirectory = new File(System.getProperty("user.home"), ".mactfs");
@@ -19,20 +24,26 @@ public class LocalConfigStore {
configDirectory.mkdirs();
}
this.configFile = new File(configDirectory, "phase-one.properties");
this.serverConfigFile = new File(configDirectory, "config.json");
}
/**
* 读取第一阶段配置;本地 API 配置存在时优先使用 config.json 的当前连接上下文。
*/
public Properties load() throws IOException {
Properties properties = new Properties();
if (!configFile.exists()) {
return properties;
}
if (configFile.exists()) {
FileInputStream inputStream = new FileInputStream(configFile);
try {
properties.load(inputStream);
} finally {
inputStream.close();
}
}
if (serverConfigFile.exists()) {
mergeServerConfig(properties);
}
return properties;
}
@@ -48,4 +59,46 @@ public class LocalConfigStore {
public File getConfigFile() {
return configFile;
}
/**
* 将第二阶段本地 API 配置转换为旧 CLI 可复用的属性名。
*/
private void mergeServerConfig(Properties properties) throws IOException {
String json = new String(Files.readAllBytes(serverConfigFile.toPath()), StandardCharsets.UTF_8);
copyJsonString(json, properties, "serverUri", "server-uri");
copyJsonString(json, properties, "authType", "auth-type");
copyJsonString(json, properties, "domain", "domain");
copyJsonString(json, properties, "username", "username");
copyJsonString(json, properties, "password", "password");
copyJsonString(json, properties, "collection", "collection");
copyJsonString(json, properties, "workspace", "workspace");
copyJsonString(json, properties, "serverPath", "server-path");
copyJsonString(json, properties, "localPath", "local-path");
}
/**
* 复制 JSON 字符串字段,保持旧 CLI 配置读取逻辑不依赖服务端模块。
*/
private void copyJsonString(String json, Properties properties, String jsonKey, String propertyKey) {
String value = jsonString(json, jsonKey);
if (!isBlank(value)) {
properties.setProperty(propertyKey, value);
}
}
/**
* 从简单配置 JSON 中提取字符串字段。
*/
private String jsonString(String json, String key) {
Pattern pattern = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*\"((?:\\\\.|[^\"])*)\"");
Matcher matcher = pattern.matcher(json);
return matcher.find() ? matcher.group(1).replace("\\\"", "\"") : null;
}
/**
* 判断字符串是否为空。
*/
private boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
}
@@ -283,7 +283,7 @@ public class MacTfsServer {
Spark.get("/api/mappings", (request, response) -> handle(request, response, "listMappings", TIMEOUT_DIRECTORY_MS, new ApiCallable() {
@Override
public ApiResult call(Request request) throws Exception {
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
AppConfig config = requestConfig(queryConfig(request));
CoreOperationResult<List<TfsMappingInfo>> result = coreService.listMappings(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"));
if (result.isSuccess()) {
config.mappings = mappingConfigs(result.getData());
@@ -387,7 +387,8 @@ public class MacTfsServer {
Spark.get("/api/pending-changes", (request, response) -> handle(request, response, "pendingChanges", TIMEOUT_DEFAULT_MS, new ApiCallable() {
@Override
public ApiResult call(Request request) throws Exception {
AppConfig config = requestConfig(new LinkedHashMap<String, Object>());
Map<String, Object> query = queryConfig(request);
AppConfig config = requestConfig(query);
CoreOperationResult<List<TfsPendingChangeInfo>> result = coreService.listPendingChanges(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), splitPaths(request.queryParams("serverPath")));
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("pendingChanges", result.getData() == null ? Collections.emptyList() : result.getData());
@@ -570,6 +571,26 @@ public class MacTfsServer {
return normalizeConfig(config);
}
/**
* 提取 GET 请求中的配置覆盖字段,避免 UI 切换 Collection 时读接口使用旧配置。
*/
private Map<String, Object> queryConfig(Request request) {
Map<String, Object> query = new LinkedHashMap<String, Object>();
copyQuery(request, query, "collection");
copyQuery(request, query, "workspace");
return query;
}
/**
* 将非空 query 参数复制到请求配置覆盖对象。
*/
private void copyQuery(Request request, Map<String, Object> query, String key) {
String value = request.queryParams(key);
if (!isBlank(value)) {
query.put(key, value);
}
}
/**
* 把服务端配置转换为 core 层连接配置。
*/
+160
View File
@@ -0,0 +1,160 @@
# mactfsui 前端 AI 规则
本文件适用于 `mactfsui` 目录下所有前端、Electron、样式和文档改动。后续 AI 在实现前端任务时必须优先遵守本规则。
## 一、必读上下文
开始任何前端改动前,必须先读取:
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/AGENTS.md`
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/FRONTEND_SPEC.md`
- `/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-api-product-prd.md`
- `/Users/fenghp/Desktop/DEV/project/mydev/task/README.md`
- `/Users/fenghp/Desktop/DEV/project/mydev/task/AI-RULES.md`
- 当前任务文件
如果旧任务或旧文档提到 “VS 风格” 或 “Visual Studio Source Control Explorer”,前端视觉实现必须按本文件的 macOS 风格规则解释,不再照搬 Visual Studio 视觉。
## 二、强制 UI 风格
前端默认风格是:
```text
shadcn/ui + macOS Source Workspace
```
具体含义:
- 组件基础使用 shadcn/ui。
- 整体布局参考 macOS Finder、Xcode Source Control、Fork / SourceTree。
- 保留 TFS 工具所需的信息密度。
- 视觉上要像 macOS 原生源码管理工作台,不像 Windows Visual Studio,也不像 Web SaaS 后台。
默认关键词:
- macOS native
- lightweight
- quiet
- source-control focused
- Finder-like table
- Xcode-like inspector
- Fork-like diff
## 三、布局规则
主界面必须采用 macOS 工作台布局:
```text
顶部:macOS 风格工具栏
左侧:Source List,展示 Collection / Server Tree
中间:文件列表、目录对比、历史记录、Diff 主内容
右侧:Inspector,展示 Pending Changes、当前文件操作、签入
底部:可折叠 Console,展示操作日志
```
建议尺寸:
- 顶部工具栏:48-56px
- 左侧 Source List:260-320px
- 右侧 Inspector:320-380px
- 底部 Console:默认 150-220px,可折叠
- 列表行高:28-34px
- 按钮高度:28-32px
- 圆角:6-8px
第一屏必须是连接配置或可操作工作台,不做 landing page、hero、产品介绍页。
## 四、shadcn/ui 使用规则
允许并优先使用:
- `Button`
- `Input`
- `Select`
- `Dialog`
- `Sheet`
- `Tooltip`
- `DropdownMenu`
- `Tabs`
- `Table`
- `Badge`
- `Checkbox`
- `Textarea`
- `Separator`
- `ScrollArea`
规则:
- shadcn 基础组件只能放在 `app/components/ui`。
- 业务组件放到 `app/components/*` 对应业务目录。
- 图标使用 `lucide-react`。
- 文件操作按钮优先使用图标 + tooltip。
- 状态必须用文字或 badge 辅助,不能只靠颜色。
- 路径和版本号可用等宽字体。
## 五、禁止的视觉方向
禁止把 macTFS 做成以下风格:
- Visual Studio / Windows 工具窗口风格
- 满屏 Card 的 SaaS dashboard
- 后台管理系统首页
- 营销 landing page
- 大 Hero
- 大面积渐变背景
- 装饰性 orb / blob
- 过重深蓝、灰黑企业后台色
- 大圆角、过大留白、低信息密度
- 页面区域卡片套卡片
Card 只允许用于重复实体、弹窗内容或确实需要边界的局部工具,不允许把主布局区域都做成 card。
## 六、色彩与主题
默认使用浅色主题,整体背景应接近 macOS 工具软件:
- 主背景:浅灰或 shadcn muted background
- 内容区:白色或轻微分层背景
- 分隔:细边框、浅分割线
- 主色:macOS system blue 方向
状态色只表达业务状态:
- `pendingEdit`:蓝色
- `pendingAdd` / 成功:绿色
- `pendingDelete` / 失败:红色
- `notDownloaded` / 等待:琥珀色
- `remoteChanged`:蓝紫色
- `bothChanged`:红色强调
- `upToDate`:中性灰
不要在组件里硬编码大段颜色,优先使用 Tailwind token 和 `app.css` 变量。
## 七、实现约束
- 继续使用现有 React 19、React Router 7、Electron、Tailwind CSS 4、shadcn/ui 技术栈。
- 不新增 Vue、MicroFront、Redux、Zustand、TanStack Query,除非用户明确要求。
- Electron 本机能力放在 main / preload,React 组件不直接读文件、不启动 Java 进程。
- API 请求必须走统一 API client,不在组件里散落 `fetch`。
- Bearer token 由 Electron 主进程读取后通过 preload 暴露给渲染层。
- 新增函数必须按项目规则添加函数级注释。
- 改任何文件前必须先执行 `chmod u+w <file>` 或 `chmod 644 <file>`。
## 八、验收要求
前端任务完成后至少验证:
```bash
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
pnpm typecheck
```
涉及 UI 的改动还必须实际打开页面或 Electron 看一遍,确认:
- 主区域不空白。
- 文案不重叠。
- 工具栏、Source List、Inspector、Console 区域关系清晰。
- 控件尺寸符合 macOS 工作台风格。
- 没有出现 VS 风格或 SaaS dashboard 风格。
最终输出必须说明是否遵守本规则。
+677
View File
@@ -0,0 +1,677 @@
# macTFS 前端项目规范
## 一、项目定位
`mactfsui` 是 macTFS 的 Electron + React 桌面 UI。它只负责本机桌面交互、状态展示和本地 API 调用,不直接实现 TFS 协议能力。
项目整体链路:
```text
Electron Renderer
-> Electron Preload / Main
-> http://127.0.0.1:38765/api/*
-> mactfs-server
-> mactfs-core
-> TFS SDK
```
第一版前端目标是实现 macOS Source Workspace 风格的日常 TFS 操作界面,视觉参考 Finder、Xcode Source Control、Fork / SourceTree:
- 连接配置
- Collection 服务端目录树
- 当前目录文件列表
- Workspace / Mapping 管理
- Get Latest
- 目录对比
- checkout / add / delete / undo
- Pending Changes
- Checkin
- History
- Diff
- 操作日志
不在第一版前端范围:
- 多 Profile
- Work Item
- Check-in Policy UI
- 三方 merge
- 冲突解决器
- 实时 watcher
- 自定义布局
- 自动更新
## 二、关键设计决策
### 2.1 前端技术栈
推荐答案:继续使用现有技术栈,不新增 Vue、MicroFront 或大型状态库。
当前项目已经确定:
- React 19
- React Router 7 framework mode
- TypeScript strict
- Vite
- Electron
- Tailwind CSS 4
- shadcn/ui `radix-nova`
- Radix UI
- lucide-react
- class-variance-authority
- clsx + tailwind-merge
后续新增 UI 组件优先通过 shadcn/ui 生成到 `app/components/ui`,业务组件放到业务目录,不修改 shadcn 组件的职责边界。
### 2.2 架构复杂度
推荐答案:使用轻量分层,不引入 Redux、Zustand、TanStack Query。
原因:
- 第一版 API 是本机同步接口,页面规模可控。
- PRD 明确第一版优先可用和清晰,不做复杂自动化。
- 当前任务要求最小实现,过早引入状态库会增加维护成本。
状态先放在主界面路由和功能组件内,通过自定义 hook 拆分 API 调用。只有跨多个面板共用的状态,才上提到 `AppShell`。
### 2.3 Electron 与本机能力
推荐答案:Electron 主进程负责服务启动、token 读取、目录选择,渲染进程只通过 preload 暴露的窄接口访问。
约束:
- 不开启 renderer 的 Node.js 能力。
- 不在 React 组件里读取本机文件。
- 不在 React 组件里启动 Java 进程。
- Bearer token 由主进程读取 `~/.mactfs/server-token` 后通过 preload 提供。
- 选择本地目录通过 Electron dialog 完成。
### 2.4 页面组织
推荐答案:第一版以一个主路由承载工作台,登录配置是主路由内的连接态视图。
原因:
- 这是桌面工具,不需要营销页或多页面站点结构。
- 登录成功后进入同一个 macOS Source Workspace 工作台。
- Diff 后续如果需要独立沉浸视图,再新增 `routes/diff.tsx`。
## 三、推荐文件层级
后续按以下结构演进:
```text
mactfsui/
app/
app.css
root.tsx
routes.ts
routes/
home.tsx
diff.tsx # FE-012 需要独立页面时再新增
components/
app/
app-shell.tsx # 主工作台布局
connection-gate.tsx # health/config/connect 入口
connection-bar.tsx # 顶部连接、Collection、Workspace 信息
explorer/
server-tree-panel.tsx # 左侧服务端目录树
folder-items-panel.tsx # 中间目录文件列表
mapping-dialog.tsx # Mapping 创建流程
status-badge.tsx # TFS 状态展示
pending/
pending-changes-panel.tsx
checkin-form.tsx
compare/
folder-compare-panel.tsx
compare-actions.tsx
history/
history-panel.tsx
changeset-files-panel.tsx
diff/
diff-view.tsx
logs/
operation-log-panel.tsx
ui/
button.tsx # shadcn/ui 生成组件
hooks/
use-api-operation.ts
use-folder-selection.ts
use-pending-selection.ts
lib/
api/
client.ts # fetch、token、统一响应处理
endpoints.ts # API 方法
types.ts # 后端响应类型
electron/
bridge.ts # window.mactfs 类型封装
tfs/
status.ts # 状态文案、颜色、可用操作
utils.ts
electron/
main.cjs
preload.cjs # FE-001 新增
```
目录规则:
- `routes` 只做页面编排,不堆业务细节。
- `components/ui` 只放 shadcn/ui 基础组件。
- `components/*` 按业务域组织,避免一个 `components/common` 装所有东西。
- `lib/api` 只关心 HTTP 和类型,不关心界面状态。
- `lib/tfs` 放 TFS 状态到 UI 表现的纯映射逻辑。
- `hooks` 只抽真实复用的交互状态,不为未来扩展提前抽象。
## 四、前端分层规范
### 4.1 Electron Main
职责:
- 创建主窗口。
- 开发环境加载 `http://localhost:5173`。
- 生产环境加载 `build/client/index.html`。
- 检查 `http://127.0.0.1:38765/api/health`。
- 服务未启动时拉起本地 Java API 服务。
- 读取 `~/.mactfs/server-token`。
- 打开本地目录选择器。
开发期建议:
- 优先检测已有服务。
- 服务不存在时可以通过 `cd ../mactfs && ../tfsIntegration/gradlew runServer` 拉起。
- 发行期启动命令由 release 任务统一确定,前端不要长期硬编码 Gradle 路径。
### 4.2 Electron Preload
preload 只暴露必要能力:
```ts
window.mactfs = {
getToken(): Promise<string>
selectDirectory(): Promise<string | null>
getServiceStatus(): Promise<ServiceStatus>
}
```
不要把 `fs`、`child_process`、任意 shell 执行能力直接暴露给渲染进程。
### 4.3 React Route
`routes/home.tsx` 是第一版主入口:
- 初始化 health。
- 读取配置。
- 判断是否展示连接配置页或主工作台。
- 持有跨面板共享状态:
- 当前 Collection
- 当前 Workspace
- mappings
- 当前选中的 serverPath
- 当前选中的文件 / diff 项
- pending included / excluded
路由组件不直接写复杂 JSX,具体区域拆到 `components/app` 和业务组件。
### 4.4 API 层
API 层固定返回后端统一响应,不在组件里重复拼 `fetch`。
统一响应:
```ts
export interface ApiResult<TData> {
success: boolean
message: string
errorMessage?: string
operation: string
startedAt: number
endedAt: number
durationMs: number
logs: string[]
data: TData
}
```
API client 职责:
- 从 preload 获取 Bearer token。
- 请求 `http://127.0.0.1:38765`。
- 自动设置 `Authorization: Bearer <token>`。
- 自动设置 JSON header。
- 返回 `ApiResult<T>`。
- 网络失败时返回统一的前端失败结果,供 UI 展示。
组件规则:
- 组件不直接读 token。
- 组件不直接写 `fetch`。
- 组件不猜测后端错误类型,只展示 `errorMessage || message`。
## 五、后端 API 对接规范
基础配置:
```text
Base URL: http://127.0.0.1:38765
Auth: Authorization: Bearer <token>
```
当前后端路由以 `MacTfsServer` 为准:
| 功能 | 方法 | 路径 | 前端 data 字段 |
|---|---|---|---|
| 健康检查 | GET | `/api/health` | `status`, `connected`, `tokenFile`, `configFile` |
| 读取配置 | GET | `/api/config` | `config` |
| 保存配置 | PUT | `/api/config` | `config` |
| 连接 TFS | POST | `/api/session/connect` | `serverUri`, `collectionCount` |
| Collection | GET | `/api/collections` | `collections` |
| 目录树 | GET | `/api/server-tree?path=...` | `path`, `items` |
| 目录文件 | GET | `/api/server-folder/items?path=...` | `path`, `items` |
| Workspace | GET | `/api/workspace` | `workspace`, `mappings` |
| 创建 Workspace | POST | `/api/workspace/ensure` | `workspace` |
| Mapping 列表 | GET | `/api/mappings` | `mappings` |
| 创建 Mapping | POST | `/api/mappings` | `mapping`, `getLatest` |
| 删除 Mapping | DELETE | `/api/mappings` | `mappings` |
| Get Latest | POST | `/api/files/get-latest` | `result` |
| Checkout | POST | `/api/files/checkout` | `result` |
| Add | POST | `/api/files/add` | `result` |
| Delete | POST | `/api/files/delete` | `result` |
| Undo | POST | `/api/files/undo` | `result` |
| Pending Changes | GET | `/api/pending-changes` | `pendingChanges` |
| Checkin | POST | `/api/checkin` | `checkin` |
| 目录对比 | POST | `/api/compare/folder` | `diffs` |
| 历史 | GET | `/api/history?path=...&folder=true|false` | `history` |
| Changeset 文件 | GET | `/api/history/changeset?changeset=...` | `files` |
| 本地 vs latest diff | POST | `/api/diff/local-latest` | `diff` |
| 历史版本 diff | POST | `/api/diff/revisions` | `diff` |
| 操作日志 | GET | `/api/logs` | `logs` |
常用类型字段:
```ts
export interface AppConfig {
serverUri?: string
authType: "ntlm-explicit"
domain?: string
username?: string
password?: string
collection?: string
workspace?: string
mappings: MappingConfig[]
}
export interface ServerItem {
name: string
path: string
serverPath: string
type: "folder" | "file"
folder: boolean
latestVersion: number
checkinDate?: number
}
export interface MappingInfo {
serverPath: string
localPath: string
}
export interface PendingChangeInfo {
serverPath: string
localPath: string
name: string
folder: boolean
status: TfsStatus
changeType: string
version: number
}
export interface FolderDiffItem {
serverPath: string
localPath: string
name: string
folder: boolean
status: TfsStatus
localVersion: number
latestVersion: number
}
```
## 六、状态模型
### 6.1 App 状态
主工作台维护:
- `serviceStatus`
- `config`
- `collections`
- `selectedCollection`
- `workspace`
- `mappings`
- `selectedServerPath`
这些状态来源于 `/api/health`、`/api/config`、`/api/collections`、`/api/workspace`。
### 6.2 Explorer 状态
目录树:
- 以 `serverPath` 作为节点 key。
- 懒加载子节点。
- 未映射目录也允许浏览。
- 展开目录时调用 `/api/server-tree`。
文件列表:
- 选中左侧目录后调用 `/api/server-folder/items`。
- 结合 mappings 计算是否已映射和本地路径。
- 已映射但本地不存在时展示 `notDownloaded / 未下载`。
### 6.3 Mapping 状态
Mapping 创建流程:
```text
选中服务端目录
点击“映射到本地”
调用 preload selectDirectory
POST /api/mappings
询问是否立即 Get Latest
刷新 mappings、当前文件列表、操作日志
```
不做复杂 Mapping 编辑。删除 Mapping 属于后续按任务需要实现的操作入口。
### 6.4 Compare 状态
目录对比只允许已映射目录触发。
前端状态:
- `diffs`
- `selectedDiffKeys`
- `hideUpToDate = true`
- `compareLoading`
默认隐藏 `upToDate`,只展示差异。操作完成后提示用户重新对比或自动刷新当前对比列表,具体按任务实现范围决定。
### 6.5 Pending Changes 状态
右侧面板维护:
- `includedKeys`
- `excludedKeys`
- `pendingChanges`
规则:
- 后端返回的 pending changes 默认进入 Included。
- 用户移动到 Excluded 后只保存在当前 UI 会话。
- Checkin 只提交 Included。
- Checkin 成功后清空 Included,Excluded 继续保留到当前 UI 会话结束。
### 6.6 Operation 状态
每个主要区域只保留当前操作:
- `idle`
- `loading`
- `success`
- `error`
长耗时操作必须显示明确文案:
- 正在连接 TFS
- 正在查询目录
- 正在执行目录对比
- 正在获取最新
- 正在签入
第一版不做取消按钮。
## 七、TFS 状态与操作映射
状态文案统一放在 `app/lib/tfs/status.ts`。
```text
localModified -> 本地修改
remoteChanged -> 服务端有更新
bothChanged -> 双边变更
localOnly -> 本地新增
remoteOnly -> 服务端新增
notDownloaded -> 未下载
localDeleted -> 本地删除
pendingEdit -> 待签入编辑
pendingAdd -> 待签入新增
pendingDelete -> 待签入删除
pendingRename -> 待签入重命名
pending -> 待签入
upToDate -> 最新
```
操作入口:
```text
localModified -> checkout / diff
localOnly -> add / delete local
localDeleted -> delete / restore from server
remoteChanged -> get latest / diff
notDownloaded -> get latest
pendingEdit -> checkin / undo / diff
pendingAdd -> checkin / undo
pendingDelete -> checkin / undo
bothChanged -> diff / 手工处理
upToDate -> 无主要操作
```
UI 不直接散落状态字符串判断。新增状态必须先补 `status.ts`,再接组件展示。
## 八、UI 风格规范
### 8.1 总体风格
参考 Finder、Xcode Source Control、Fork / SourceTree,优先 macOS 工作台效率:
- 信息密度高
- 面板边界清晰
- 控件安静克制
- shadcn/ui 作为组件基础
- macOS 原生工具软件气质
- 不做营销页
- 不做大 Hero
- 不做装饰性渐变和卡片堆叠
- 不使用大面积单一色调
第一屏就是可操作的 TFS 工作台或连接配置,不展示产品介绍页。
### 8.2 布局尺寸
推荐桌面布局:
```text
顶部工具栏:48-56px
左侧 Source List:260-320px
中间文件列表 / 对比 / 历史:自适应
右侧 Inspector / Pending:320-380px
底部 Console / 操作日志:150-220px,可折叠
```
窗口小于可用宽度时:
- 保持顶部和底部可读。
- 左右面板允许压缩到最小宽度。
- 中间列表横向滚动。
- 不把核心操作隐藏到不可发现的位置。
### 8.3 组件使用
- 按钮使用 shadcn `Button`。
- 图标使用 lucide-react。
- 文件操作按钮优先使用图标 + tooltip。
- 关键破坏性操作使用确认弹窗。
- 目录 checkout 必须二次确认影响数量。
- 状态用 badge,不用只靠颜色表达。
- 路径使用等宽字体并允许横向滚动。
- 列表行高保持 28-34px。
- 按钮高度保持 28-32px。
- 圆角控制在 6-8px。
- 表格列宽固定或可伸缩,不因加载文案导致布局跳动。
### 8.4 色彩语义
颜色只表达业务状态:
- `pendingEdit`:蓝色
- `pendingAdd` / 成功:绿色
- `pendingDelete` / 失败:红色
- `notDownloaded` / 等待:琥珀色
- `remoteChanged`:紫色或蓝紫色
- `bothChanged`:红色强调
- `upToDate`:中性灰
颜色通过 Tailwind token 或 CSS 变量实现,不在组件里硬编码大段自定义颜色。
### 8.5 文案
界面文案使用中文,接口字段和路径保持原始英文。
推荐文案:
- 连接 TFS
- 选择 Collection
- 当前 Workspace
- 映射到本地
- 获取最新
- 目录对比
- 挂起更改
- Included Changes
- Excluded Changes
- 签入注释
- 操作日志
错误信息优先展示后端返回的 TFS 原始错误,外层只补充当前操作上下文。
## 九、开发实现规范
### 9.1 TypeScript
- 保持 `strict: true`。
- API 响应类型放到 `app/lib/api/types.ts`。
- 业务组件 props 使用 interface。
- 不使用 `any` 承接后端响应,除非当前接口确实没有稳定结构。
- 不在组件里重复定义相同类型。
### 9.2 注释
遵守项目根目录规则:
- 新增函数必须有简短函数级注释。
- 修改核心逻辑时补充必要注释。
- 注释说明业务作用和约束,不重复代码表面意思。
### 9.3 样式
- 优先 Tailwind class。
- 复用 `cn` 合并 className。
- 通用 variant 用 `class-variance-authority`。
- 不新增全局样式,除非是全局 token、滚动条或布局基础样式。
- shadcn 组件保持生成风格,不混入业务逻辑。
### 9.4 数据刷新
操作后的最小刷新规则:
- 连接成功:刷新 config、collections。
- 选择 Collection:ensure workspace 后刷新 workspace、mappings、目录树根节点。
- 创建 Mapping:刷新 mappings 和当前目录文件列表。
- Get Latest:刷新当前目录文件列表和操作日志。
- Checkout/Add/Delete/Undo:刷新 pending changes;目录对比页提示重新对比或刷新 diffs。
- Checkin 成功:刷新 pending changes 和操作日志。
- History/Diff:只刷新当前面板数据。
### 9.5 错误处理
- API 失败统一展示 `errorMessage || message`。
- 组件内不要堆多层 try/catch。
- 不做 retry 机制,除非任务明确要求。
- 服务未启动时展示明确提示和重新检测入口。
- 长操作超时按后端返回展示,不在前端重复实现超时体系。
### 9.6 文件编辑流程
每次改代码前必须先执行:
```bash
chmod u+w <file>
```
或:
```bash
chmod 644 <file>
```
任务开发前必须读取:
- `docs/mactfs-api-product-prd.md`
- `task/README.md`
- `task/AI-RULES.md`
- 当前 task 文件
- 相关前端源码
前端任务默认验证:
```bash
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
pnpm typecheck
```
涉及明显 UI 改动时,还要用浏览器或 Electron 实际看一遍布局,确认文字不重叠、主区域不空白、操作入口可见。
## 十、FE 任务落地顺序
严格按任务依赖推进,不跳过前置任务。
1. FE-001:Electron 启动服务、preload、API client。
2. FE-002:连接配置视图,成功后进入工作台。
3. FE-003:macOS Source Workspace 主布局。
4. FE-004:Collection 服务端目录树。
5. FE-005:当前目录文件列表。
6. FE-006:Mapping 创建流程。
7. FE-007:目录对比页面。
8. FE-008:Pending Changes 面板。
9. FE-009:文件操作交互。
10. FE-010:签入流程。
11. FE-011:历史记录界面。
12. FE-012:文件 Diff 界面。
13. FE-013:操作日志面板。
每个任务只做当前任务实现范围,不顺手扩展后续任务。
## 十一、验收基线
前端任一 P0 功能完成时,至少满足:
- `pnpm typecheck` 通过。
- 当前任务验收标准逐条可说明。
- API 调用带 Bearer token。
- 服务慢时有明确等待状态。
- 失败时显示可读错误。
- 操作后刷新对应区域或明确提示用户刷新。
- 不破坏现有 Electron 启动。
- 不引入与现有栈不一致的新框架。
## 十二、当前项目现状
当前 `mactfsui` 仍处于模板状态:
- `app/routes/home.tsx` 只有默认欢迎内容。
- `app/components/ui/button.tsx` 已有 shadcn Button。
- `electron/main.cjs` 只创建窗口,尚未启动 Java API 服务。
- 尚未实现 preload。
- 尚未实现 API client。
- 尚未实现业务布局和页面。
因此后续前端开发应从 FE-001 开始,先补齐 Electron 与 API 基础能力,再进入业务页面。
+3 -4
View File
@@ -1,13 +1,12 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/roboto-slab";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-serif);
--font-serif: 'Roboto Slab Variable', serif;
--font-heading: var(--font-sans);
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -125,6 +124,6 @@
@apply bg-background text-foreground;
}
html {
@apply font-serif;
@apply font-sans;
}
}
+210
View File
@@ -0,0 +1,210 @@
import type { ReactNode } from "react"
import {
CheckCircle2,
Cloud,
Download,
FolderTree,
GitPullRequest,
History,
RefreshCw,
TriangleAlert,
} from "lucide-react"
import { OperationLogPanel } from "~/components/app/operation-log-panel"
import { Button } from "~/components/ui/button"
import type { ApiRequestLogEntry } from "~/lib/api/client"
interface AppShellProps {
connected: boolean
serviceReady: boolean
serviceLoading: boolean
serverUri: string
username: string
collectionCount?: number
serviceBaseUrl?: string
tokenFile?: string
serviceMessage: string
requestStatus: string
operationLogs: ApiRequestLogEntry[]
operationLogsLoading: boolean
workspaceGetLatestEnabled: boolean
workspaceHistoryEnabled: boolean
sourceList?: ReactNode
inspector?: ReactNode
onRefreshService(): void
onRefreshOperationLogs(): void
onWorkspaceGetLatest(): void
onWorkspaceHistory(): void
children: ReactNode
}
/**
* 渲染 macOS Source Workspace 主框架,承载顶部工具栏、Source List、主内容、Inspector 和 Console。
*/
export function AppShell({
connected,
serviceReady,
serviceLoading,
serverUri,
username,
collectionCount,
serviceBaseUrl,
tokenFile,
serviceMessage,
requestStatus,
operationLogs,
operationLogsLoading,
workspaceGetLatestEnabled,
workspaceHistoryEnabled,
sourceList,
inspector,
onRefreshService,
onRefreshOperationLogs,
onWorkspaceGetLatest,
onWorkspaceHistory,
children,
}: AppShellProps) {
return (
<div className="flex h-[calc(100svh-32px)] min-h-[640px] flex-col overflow-hidden rounded-[8px] border bg-background text-sm shadow-sm">
<header className="flex h-13 shrink-0 items-center justify-between gap-3 border-b bg-muted/30 px-3">
<div className="flex min-w-0 items-center gap-3">
<div className="flex size-7 items-center justify-center rounded-[7px] border bg-background text-primary">
<Cloud className="size-4" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">macTFS</span>
<span className="rounded-[6px] border bg-background px-1.5 py-0.5 text-xs text-muted-foreground">
{connected ? "已连接" : "未连接"}
</span>
</div>
<div className="truncate font-mono text-xs text-muted-foreground">
{serverUri || "http://127.0.0.1:38765"}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={!workspaceGetLatestEnabled}
onClick={onWorkspaceGetLatest}
>
<Download />
Get Latest
</Button>
<Button
variant="outline"
size="sm"
disabled={!workspaceHistoryEnabled}
onClick={onWorkspaceHistory}
>
<History />
History
</Button>
<Button
variant="ghost"
size="sm"
disabled={serviceLoading}
onClick={onRefreshService}
>
<RefreshCw className={serviceLoading ? "animate-spin" : ""} />
刷新
</Button>
</div>
</header>
<div className="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[280px_minmax(0,1fr)] lg:grid-cols-[280px_minmax(0,1fr)_340px]">
<aside className="flex min-h-[220px] min-w-0 flex-col border-b bg-muted/25 md:min-h-0 md:border-r md:border-b-0">
<div className="flex h-9 items-center gap-2 border-b px-3 text-xs font-medium text-muted-foreground">
<FolderTree className="size-4" />
Source List
</div>
<div className="min-h-0 flex-1 overflow-auto p-2">
{sourceList || (
<>
<div className="rounded-[6px] bg-background px-2 py-1.5 font-mono text-xs">
$/
</div>
<div className="mt-2 px-2 text-xs text-muted-foreground">
Collection:{collectionCount ?? "-"}
</div>
</>
)}
</div>
</aside>
<main className="flex min-h-[320px] min-w-0 flex-col">
<div className="flex h-9 items-center justify-between gap-3 border-b px-3">
<div className="min-w-0 truncate font-medium">Source Workspace</div>
<div className="flex shrink-0 items-center gap-1">
<Button variant="ghost" size="sm" disabled>
Compare
</Button>
<Button variant="ghost" size="sm" disabled>
Mapping
</Button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-hidden">{children}</div>
</main>
<aside className="flex min-h-[260px] min-w-0 flex-col border-t bg-background md:col-span-2 lg:col-span-1 lg:border-t-0 lg:border-l">
<div className="flex h-9 items-center gap-2 border-b px-3 text-xs font-medium text-muted-foreground">
<GitPullRequest className="size-4" />
Inspector
</div>
<div className="min-h-0 flex-1 overflow-auto p-3">
<div className="grid gap-4">
{inspector || (
<section className="grid gap-2">
<div className="flex items-center justify-between">
<span className="font-medium">Pending Changes</span>
<span className="rounded-[6px] border px-1.5 py-0.5 text-xs text-muted-foreground">
0 项
</span>
</div>
<div className="rounded-[6px] border bg-muted/20 px-3 py-2 text-muted-foreground">
暂无挂起更改
</div>
</section>
)}
<section className="grid gap-2">
<div className="font-medium">本地服务</div>
<div className="flex items-center gap-2 rounded-[6px] border bg-muted/30 px-3 py-2">
{serviceReady ? (
<CheckCircle2 className="size-4 text-green-600" />
) : (
<TriangleAlert className="size-4 text-amber-600" />
)}
<span>{serviceReady ? "服务可用" : "服务未就绪"}</span>
</div>
<div className="grid gap-1 text-xs text-muted-foreground">
<div className="truncate font-mono">
{serviceBaseUrl || "http://127.0.0.1:38765"}
</div>
<div className="truncate font-mono">
{tokenFile || "~/.mactfs/server-token"}
</div>
</div>
</section>
</div>
</div>
</aside>
</div>
<footer className="h-40 shrink-0 border-t bg-muted/20">
<OperationLogPanel
logs={operationLogs}
loading={operationLogsLoading}
serviceMessage={serviceMessage}
requestStatus={requestStatus}
username={username}
onRefresh={onRefreshOperationLogs}
/>
</footer>
</div>
)
}
@@ -0,0 +1,169 @@
import {
CheckCircle2,
Loader2,
RefreshCw,
Terminal,
TriangleAlert,
} from "lucide-react"
import { Button } from "~/components/ui/button"
import type { ApiRequestLogEntry } from "~/lib/api/client"
interface OperationLogPanelProps {
logs: ApiRequestLogEntry[]
loading: boolean
serviceMessage: string
requestStatus: string
username: string
onRefresh(): void
}
/**
* 渲染底部操作日志面板,展示 API 操作状态、耗时、路径摘要和错误信息。
*/
export function OperationLogPanel({
logs,
loading,
serviceMessage,
requestStatus,
username,
onRefresh,
}: OperationLogPanelProps) {
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex h-9 shrink-0 items-center justify-between gap-3 border-b px-3 text-xs font-medium text-muted-foreground">
<div className="flex min-w-0 items-center gap-2">
<Terminal className="size-4" />
<span>Console</span>
<span className="rounded-[6px] border bg-background px-1.5 py-0.5">
{logs.length} 条
</span>
</div>
<Button
size="icon-xs"
variant="ghost"
title="刷新服务端操作日志"
disabled={loading}
onClick={onRefresh}
>
{loading ? <Loader2 className="animate-spin" /> : <RefreshCw />}
</Button>
</div>
{logs.length === 0 ? (
<div className="grid gap-1 p-3 font-mono text-xs text-muted-foreground">
<div>[service] {serviceMessage}</div>
<div>[request] {requestStatus}</div>
<div>[session] {username || "anonymous"}</div>
<div>[logs] 暂无 API 操作日志</div>
</div>
) : (
<div className="min-h-0 flex-1 overflow-auto">
<div className="min-w-[920px]">
<div className="sticky top-0 z-10 grid h-7 grid-cols-[140px_76px_142px_142px_86px_minmax(180px,1fr)_minmax(190px,1fr)] border-b bg-muted px-3 text-xs font-medium text-muted-foreground">
<div className="flex items-center">操作</div>
<div className="flex items-center">状态</div>
<div className="flex items-center">开始</div>
<div className="flex items-center">结束</div>
<div className="flex items-center">耗时</div>
<div className="flex items-center">路径摘要</div>
<div className="flex items-center">错误信息</div>
</div>
{logs.map((log) => (
<div
key={log.id}
className="grid min-h-7 grid-cols-[140px_76px_142px_142px_86px_minmax(180px,1fr)_minmax(190px,1fr)] items-center border-b px-3 font-mono text-xs"
>
<div className="truncate">{log.operation}</div>
<div>
<span
className={`inline-flex max-w-full items-center gap-1 rounded-[6px] border px-1.5 py-0.5 ${statusClassName(log)}`}
>
{log.status === "success" ? (
<CheckCircle2 className="size-3" />
) : log.status === "running" ? (
<Loader2 className="size-3 animate-spin" />
) : (
<TriangleAlert className="size-3" />
)}
{statusLabel(log)}
</span>
</div>
<div className="truncate text-muted-foreground">
{formatLogTime(log.startedAt)}
</div>
<div className="truncate text-muted-foreground">
{log.status === "running"
? "执行中"
: formatLogTime(log.endedAt)}
</div>
<div className="text-muted-foreground">
{formatDuration(log)}
</div>
<div className="truncate text-muted-foreground">
{log.summary || "-"}
</div>
<div
className={`truncate ${
log.errorMessage
? "text-destructive"
: "text-muted-foreground"
}`}
>
{log.status === "running"
? "请求执行中"
: log.errorMessage || "-"}
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
/**
* 按日志状态返回中文状态文案。
*/
function statusLabel(log: ApiRequestLogEntry) {
if (log.status === "running") {
return "执行中"
}
return log.success ? "成功" : "失败"
}
/**
* 按日志状态返回 badge 样式。
*/
function statusClassName(log: ApiRequestLogEntry) {
if (log.status === "running") {
return "border-amber-200 bg-amber-50 text-amber-700"
}
return log.success
? "border-green-200 bg-green-50 text-green-700"
: "border-red-200 bg-red-50 text-red-700"
}
/**
* 格式化操作日志时间戳。
*/
function formatLogTime(value?: number) {
return value ? new Date(value).toLocaleString() : "-"
}
/**
* 格式化操作耗时,执行中的请求保持明确状态。
*/
function formatDuration(log: ApiRequestLogEntry) {
if (log.status === "running") {
return "执行中"
}
if (log.durationMs === undefined) {
return "-"
}
return log.durationMs < 1000
? `${log.durationMs} ms`
: `${(log.durationMs / 1000).toFixed(1)} s`
}
@@ -0,0 +1,185 @@
import { useEffect, useState } from "react"
import { GitCompareArrows, Loader2, RefreshCw, X } from "lucide-react"
import { Button } from "~/components/ui/button"
import { diffLocalLatest, diffRevisions } from "~/lib/api/endpoints"
import type { TfsTextDiff } from "~/lib/api/types"
export type DiffPanelRequest =
| {
type: "localLatest"
serverPath: string
localPath: string
label: string
}
| {
type: "revisions"
serverPath: string
sourceChangeset: number
targetChangeset: number
label: string
}
interface DiffPanelProps {
request: DiffPanelRequest
onClose(): void
}
interface DiffRow {
source?: string
target?: string
kind: "same" | "removed" | "added"
}
/**
* 展示文本 Diff 面板,支持本地 vs latest 和两个历史版本对比。
*/
export function DiffPanel({ request, onClose }: DiffPanelProps) {
const [diff, setDiff] = useState<TfsTextDiff>()
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState("")
useEffect(() => {
loadDiff()
}, [request])
const rows = diff ? toDiffRows(diff.lines) : []
/**
* 根据当前请求类型加载文本 diff。
*/
async function loadDiff() {
setLoading(true)
setMessage("")
const result =
request.type === "localLatest"
? await diffLocalLatest({
serverPath: request.serverPath,
localPath: request.localPath,
})
: await diffRevisions({
serverPath: request.serverPath,
sourceChangeset: request.sourceChangeset,
targetChangeset: request.targetChangeset,
})
setLoading(false)
if (!result.success || !result.data.diff) {
setDiff(undefined)
setMessage(
result.errorMessage ||
result.message ||
"大文件、二进制或不支持类型无法展示文本 diff。"
)
return
}
setDiff(result.data.diff)
}
/**
* 将后端统一 diff lines 转为左右两栏展示行。
*/
function toDiffRows(lines: string[]): DiffRow[] {
return lines.map((line) => {
const prefix = line.charAt(0)
const content = line.slice(1)
if (prefix === "-") {
return { source: content, kind: "removed" }
}
if (prefix === "+") {
return { target: content, kind: "added" }
}
return { source: content, target: content, kind: "same" }
})
}
return (
<div className="border-b bg-background">
<div className="flex min-h-9 items-center justify-between gap-3 px-3 py-1.5 text-xs">
<div className="min-w-0">
<div className="flex items-center gap-1.5 font-medium">
<GitCompareArrows className="size-3.5" />
Diff
</div>
<div className="truncate font-mono text-muted-foreground">
{request.label}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
size="icon-xs"
variant="ghost"
disabled={loading}
title="刷新 Diff"
onClick={loadDiff}
>
{loading ? <Loader2 className="animate-spin" /> : <RefreshCw />}
</Button>
<Button size="icon-xs" variant="ghost" title="关闭" onClick={onClose}>
<X />
</Button>
</div>
</div>
{message && (
<div className="border-t border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{message}
</div>
)}
{diff && (
<div className="border-t">
<div className="grid h-8 grid-cols-2 border-b bg-muted/20 px-3 text-xs font-medium text-muted-foreground">
<div className="flex min-w-0 items-center truncate font-mono">
{diff.sourceLabel}
</div>
<div className="flex min-w-0 items-center truncate border-l pl-3 font-mono">
{diff.targetLabel}
</div>
</div>
<div className="max-h-80 overflow-auto font-mono text-xs">
{rows.length === 0 ? (
<div className="px-3 py-4 text-muted-foreground">
无可展示 diff 内容。
</div>
) : (
rows.map((row, index) => (
<div
key={`${index}:${row.kind}`}
className="grid min-h-6 grid-cols-[48px_minmax(0,1fr)_48px_minmax(0,1fr)] border-b"
>
<div className="bg-muted/20 px-2 py-1 text-right text-muted-foreground">
{index + 1}
</div>
<div
className={`whitespace-pre-wrap px-2 py-1 ${
row.kind === "removed"
? "bg-red-50 text-red-700"
: ""
}`}
>
{row.source ?? ""}
</div>
<div className="border-l bg-muted/20 px-2 py-1 text-right text-muted-foreground">
{index + 1}
</div>
<div
className={`whitespace-pre-wrap px-2 py-1 ${
row.kind === "added"
? "bg-green-50 text-green-700"
: ""
}`}
>
{row.target ?? ""}
</div>
</div>
))
)}
</div>
</div>
)}
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,322 @@
import { useEffect, useState } from "react"
import {
FileText,
Folder,
GitCompareArrows,
Loader2,
RefreshCw,
X,
} from "lucide-react"
import { Button } from "~/components/ui/button"
import type { DiffPanelRequest } from "~/components/explorer/diff-panel"
import { queryChangesetFiles, queryHistory } from "~/lib/api/endpoints"
import type { TfsHistoryEntry } from "~/lib/api/types"
interface HistoryPanelProps {
path: string
folder: boolean
label: string
onOpenDiff(request: DiffPanelRequest): void
onClose(): void
}
/**
* 展示文件或目录历史,并支持目录 changeset 文件列表和历史 diff 入口。
*/
export function HistoryPanel({
path,
folder,
label,
onOpenDiff,
onClose,
}: HistoryPanelProps) {
const [history, setHistory] = useState<TfsHistoryEntry[]>([])
const [changesetFiles, setChangesetFiles] = useState<TfsHistoryEntry[]>([])
const [selectedChangeset, setSelectedChangeset] = useState<number>()
const [selectedRevisionKeys, setSelectedRevisionKeys] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [filesLoading, setFilesLoading] = useState(false)
const [message, setMessage] = useState("")
const [diffMessage, setDiffMessage] = useState("")
useEffect(() => {
loadHistory()
}, [path, folder])
const selectedRevisions = history.filter((entry) =>
selectedRevisionKeys.includes(toHistoryKey(entry))
)
/**
* 查询当前文件或目录最近 100 条历史记录。
*/
async function loadHistory() {
setLoading(true)
setMessage("")
setDiffMessage("")
setChangesetFiles([])
setSelectedChangeset(undefined)
setSelectedRevisionKeys([])
const result = await queryHistory(path, folder)
setLoading(false)
if (!result.success) {
setHistory([])
setMessage(result.errorMessage || result.message)
return
}
setHistory(result.data.history)
}
/**
* 查询目录历史中某个 changeset 影响的文件列表。
*/
async function loadChangesetFiles(changeset: number) {
setFilesLoading(true)
setMessage("")
setDiffMessage("")
setSelectedChangeset(changeset)
const result = await queryChangesetFiles(changeset)
setFilesLoading(false)
if (!result.success) {
setChangesetFiles([])
setMessage(result.errorMessage || result.message)
return
}
setChangesetFiles(result.data.files)
}
/**
* 使用 changeset 作为历史版本选择 key。
*/
function toHistoryKey(entry: TfsHistoryEntry) {
return String(entry.changeset)
}
/**
* 选择最多两个文件历史版本,供后续 Diff 面板使用。
*/
function toggleRevision(entry: TfsHistoryEntry, checked: boolean) {
const key = toHistoryKey(entry)
setSelectedRevisionKeys((currentKeys) => {
if (!checked) {
return currentKeys.filter((currentKey) => currentKey !== key)
}
if (currentKeys.includes(key)) {
return currentKeys
}
return [...currentKeys, key].slice(-2)
})
}
/**
* 格式化后端毫秒时间戳。
*/
function formatHistoryDate(date?: number | null) {
return date ? new Date(date).toLocaleString() : "-"
}
/**
* 打开用户选择的两个历史版本 diff。
*/
function enterHistoryDiff() {
if (selectedRevisions.length !== 2) {
return
}
onOpenDiff({
type: "revisions",
serverPath: path,
sourceChangeset: selectedRevisions[0].changeset,
targetChangeset: selectedRevisions[1].changeset,
label: `${path};C${selectedRevisions[0].changeset} ↔ C${selectedRevisions[1].changeset}`,
})
}
/**
* 记录 changeset 文件列表中的单文件 diff 入口。
*/
function enterChangesetFileDiff(file: TfsHistoryEntry) {
setDiffMessage(
`已选择 ${file.serverPath} @${file.changeset}。请打开该文件历史并选择两个版本进入 Diff。`
)
}
return (
<div className="border-b bg-background">
<div className="flex min-h-9 items-center justify-between gap-3 px-3 py-1.5 text-xs">
<div className="min-w-0">
<div className="font-medium">History</div>
<div className="truncate font-mono text-muted-foreground">
{label}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
size="icon-xs"
variant="ghost"
disabled={loading}
title="刷新历史"
onClick={loadHistory}
>
{loading ? <Loader2 className="animate-spin" /> : <RefreshCw />}
</Button>
<Button size="icon-xs" variant="ghost" title="关闭" onClick={onClose}>
<X />
</Button>
</div>
</div>
{message && (
<div className="border-t border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{message}
</div>
)}
{diffMessage && (
<div className="border-t bg-muted/10 px-3 py-2 text-xs text-muted-foreground">
{diffMessage}
</div>
)}
<div className="grid max-h-80 border-t md:grid-cols-[minmax(0,1fr)_300px]">
<div className="min-h-0 overflow-auto">
<div className="min-w-[760px]">
<div className="grid h-7 grid-cols-[36px_86px_110px_130px_150px_minmax(180px,1fr)] border-b bg-muted/20 px-3 text-xs font-medium text-muted-foreground">
<div className="flex items-center">Diff</div>
<div className="flex items-center">Changeset</div>
<div className="flex items-center">变更</div>
<div className="flex items-center">作者</div>
<div className="flex items-center">时间</div>
<div className="flex items-center">Comment</div>
</div>
{history.length === 0 && !loading ? (
<div className="px-3 py-4 text-xs text-muted-foreground">
暂无历史记录。
</div>
) : (
history.map((entry) => (
<div
key={toHistoryKey(entry)}
className={`grid min-h-8 grid-cols-[36px_86px_110px_130px_150px_minmax(180px,1fr)] items-center border-b px-3 text-xs ${
selectedChangeset === entry.changeset
? "bg-primary/10"
: ""
}`}
>
<label className="flex items-center justify-center">
<input
type="checkbox"
disabled={folder}
checked={selectedRevisionKeys.includes(
toHistoryKey(entry)
)}
aria-label={`选择 changeset ${entry.changeset}`}
onChange={(event) =>
toggleRevision(entry, event.target.checked)
}
/>
</label>
<div className="font-mono">{entry.changeset}</div>
<div className="truncate text-muted-foreground">
{entry.changeType || "-"}
</div>
<div className="truncate">{entry.author || "-"}</div>
<div className="truncate text-muted-foreground">
{formatHistoryDate(entry.date)}
</div>
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="truncate">{entry.comment || "-"}</span>
{folder && (
<Button
size="xs"
variant="ghost"
onClick={() => loadChangesetFiles(entry.changeset)}
>
文件
</Button>
)}
</div>
</div>
))
)}
</div>
</div>
<div className="min-h-0 border-t bg-muted/10 p-2 text-xs md:border-t-0 md:border-l">
{folder ? (
<div className="grid gap-2">
<div className="flex items-center justify-between gap-2">
<span className="font-medium">
Changeset {selectedChangeset ?? "-"}
</span>
{filesLoading && <Loader2 className="size-3 animate-spin" />}
</div>
{changesetFiles.length === 0 ? (
<div className="text-muted-foreground">
点击目录历史 changeset 查看影响文件。
</div>
) : (
<div className="grid max-h-64 gap-2 overflow-auto">
{changesetFiles.map((file) => (
<div
key={`${file.changeset}:${file.serverPath}`}
className="grid gap-1 border-b pb-2 last:border-b-0 last:pb-0"
>
<div className="flex min-w-0 items-center gap-1.5">
{file.itemType.toLowerCase().includes("folder") ? (
<Folder className="size-3.5 shrink-0 text-amber-600" />
) : (
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate font-mono">
{file.serverPath}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">
{file.changeType || "-"}
</span>
<Button
size="xs"
variant="outline"
onClick={() => enterChangesetFileDiff(file)}
>
<GitCompareArrows />
Diff
</Button>
</div>
</div>
))}
</div>
)}
</div>
) : (
<div className="grid gap-2">
<div className="font-medium">历史 Diff</div>
<div className="text-muted-foreground">
选择两个历史版本后进入 Diff。
</div>
<Button
size="xs"
variant="outline"
disabled={selectedRevisions.length !== 2}
onClick={enterHistoryDiff}
>
<GitCompareArrows />
Diff
</Button>
</div>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,241 @@
import { useEffect, useState } from "react"
import {
ChevronDown,
ChevronRight,
Database,
FileText,
Folder,
Loader2,
} from "lucide-react"
import { listCollections, listServerTree } from "~/lib/api/endpoints"
import type { TfsCollectionInfo, TfsServerItem } from "~/lib/api/types"
interface ServerTreePanelProps {
connected: boolean
preferredCollection: string
onCollectionSelect(collection: string): void
onPathSelect(path: string): void
}
/**
* 渲染 Collection 与服务端目录树,并在展开目录时按需请求子节点。
*/
export function ServerTreePanel({
connected,
preferredCollection,
onCollectionSelect,
onPathSelect,
}: ServerTreePanelProps) {
const [collections, setCollections] = useState<TfsCollectionInfo[]>([])
const [selectedCollection, setSelectedCollection] = useState("")
const [selectedPath, setSelectedPath] = useState("")
const [expandedPaths, setExpandedPaths] = useState<string[]>([])
const [itemsByPath, setItemsByPath] = useState<
Record<string, TfsServerItem[]>
>({})
const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState("")
useEffect(() => {
if (!connected) {
setCollections([])
setSelectedCollection("")
setSelectedPath("")
setExpandedPaths([])
setItemsByPath({})
return
}
loadCollections()
}, [connected, preferredCollection])
/**
* 加载 Collection 列表,并优先展开当前配置保存的 Collection。
*/
async function loadCollections() {
setLoading(true)
setErrorMessage("")
const result = await listCollections()
setLoading(false)
if (!result.success) {
setErrorMessage(result.errorMessage || result.message)
return
}
setCollections(result.data.collections)
const collection =
result.data.collections.find(
(item) => item.name === preferredCollection
) || result.data.collections[0]
if (collection) {
selectCollection(collection)
}
}
/**
* 切换当前 Collection,并加载该 Collection 的服务端根目录。
*/
async function selectCollection(collection: TfsCollectionInfo) {
setSelectedCollection(collection.name)
setSelectedPath("$/")
setExpandedPaths(["$/"])
setItemsByPath({})
onCollectionSelect(collection.name)
onPathSelect("$/")
await loadServerItems(collection.name, "$/")
}
/**
* 加载指定目录的一层子节点,未映射目录也直接按服务端路径浏览。
*/
async function loadServerItems(collection: string, path: string) {
setLoading(true)
setErrorMessage("")
const result = await listServerTree(path, collection)
setLoading(false)
if (!result.success) {
setErrorMessage(result.errorMessage || result.message)
return
}
setItemsByPath((currentItems) => ({
...currentItems,
[path]: result.data.items,
}))
}
/**
* 选中节点并通知中间区域当前服务端路径已变化。
*/
function selectPath(path: string) {
setSelectedPath(path)
onPathSelect(path)
}
/**
* 展开或收起目录;首次展开时才加载子节点。
*/
async function toggleFolder(item: TfsServerItem) {
selectPath(item.serverPath)
if (!item.folder || !selectedCollection) {
return
}
if (expandedPaths.includes(item.serverPath)) {
setExpandedPaths((currentPaths) =>
currentPaths.filter((path) => path !== item.serverPath)
)
return
}
setExpandedPaths((currentPaths) => [...currentPaths, item.serverPath])
if (!itemsByPath[item.serverPath]) {
await loadServerItems(selectedCollection, item.serverPath)
}
}
/**
* 按层级渲染目录树节点。
*/
function renderItems(path: string, level = 0) {
const items = itemsByPath[path] || []
return items.map((item) => {
const expanded = expandedPaths.includes(item.serverPath)
const selected = selectedPath === item.serverPath
return (
<div key={item.serverPath}>
<button
className={`flex h-7 w-full items-center gap-1.5 rounded-[6px] px-2 text-left text-xs ${
selected ? "bg-primary/10 text-primary" : "hover:bg-muted"
}`}
style={{ paddingLeft: 8 + level * 14 }}
onClick={() => toggleFolder(item)}
>
{item.folder ? (
expanded ? (
<ChevronDown className="size-3.5 shrink-0" />
) : (
<ChevronRight className="size-3.5 shrink-0" />
)
) : (
<span className="w-3.5 shrink-0" />
)}
{item.folder ? (
<Folder className="size-3.5 shrink-0 text-amber-600" />
) : (
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate">{item.name}</span>
</button>
{item.folder && expanded && renderItems(item.serverPath, level + 1)}
</div>
)
})
}
if (!connected) {
return <div className="px-2 py-1 text-xs text-muted-foreground">未连接</div>
}
return (
<div className="grid gap-2">
{loading && (
<div className="flex items-center gap-2 px-2 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
加载中
</div>
)}
{errorMessage && (
<div className="rounded-[6px] border border-destructive/30 bg-destructive/10 px-2 py-1.5 text-xs text-destructive">
{errorMessage}
</div>
)}
<div className="grid gap-1">
{collections.map((collection) => (
<button
key={collection.id || collection.name}
className={`flex h-7 items-center gap-1.5 rounded-[6px] px-2 text-left text-xs ${
selectedCollection === collection.name
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-muted"
}`}
onClick={() => selectCollection(collection)}
>
<Database className="size-3.5 shrink-0" />
<span className="truncate">{collection.name}</span>
</button>
))}
</div>
<div className="grid gap-0.5 border-t pt-2">
<button
className={`flex h-7 items-center gap-1.5 rounded-[6px] px-2 text-left font-mono text-xs ${
selectedPath === "$/"
? "bg-primary/10 text-primary"
: "hover:bg-muted"
}`}
onClick={() => {
selectPath("$/")
if (selectedCollection && !itemsByPath["$/"]) {
loadServerItems(selectedCollection, "$/")
}
}}
>
<Folder className="size-3.5 shrink-0 text-amber-600" />
$/
</button>
{renderItems("$/")}
</div>
</div>
)
}
@@ -0,0 +1,441 @@
import { useEffect, useState } from "react"
import {
ArrowDown,
ArrowUp,
FileText,
Folder,
Loader2,
RefreshCw,
RotateCcw,
Upload,
} from "lucide-react"
import { Button } from "~/components/ui/button"
import { checkin, listPendingChanges, undoFiles } from "~/lib/api/endpoints"
import type {
PendingChangeStatus,
TfsPendingChangeInfo,
} from "~/lib/api/types"
interface PendingChangesPanelProps {
connected: boolean
collection: string
refreshKey: number
}
const PENDING_STATUS_LABELS: Record<PendingChangeStatus, string> = {
pendingEdit: "编辑",
pendingAdd: "新增",
pendingDelete: "删除",
pendingRename: "重命名",
pending: "挂起",
}
const PENDING_STATUS_CLASSES: Record<PendingChangeStatus, string> = {
pendingEdit: "border-blue-200 bg-blue-50 text-blue-700",
pendingAdd: "border-green-200 bg-green-50 text-green-700",
pendingDelete: "border-red-200 bg-red-50 text-red-700",
pendingRename: "border-violet-200 bg-violet-50 text-violet-700",
pending: "border-border bg-muted/30 text-muted-foreground",
}
/**
* 在 Inspector 中展示当前 Workspace 的 Included / Excluded Pending Changes。
*/
export function PendingChangesPanel({
connected,
collection,
refreshKey,
}: PendingChangesPanelProps) {
const [pendingChanges, setPendingChanges] = useState<
TfsPendingChangeInfo[]
>([])
const [includedKeys, setIncludedKeys] = useState<string[]>([])
const [excludedKeys, setExcludedKeys] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [operationKey, setOperationKey] = useState("")
const [checkinComment, setCheckinComment] = useState("")
const [checkinLoading, setCheckinLoading] = useState(false)
const [messageType, setMessageType] = useState<"success" | "error">(
"success"
)
const [message, setMessage] = useState("")
useEffect(() => {
if (!connected || !collection) {
setPendingChanges([])
setIncludedKeys([])
setExcludedKeys([])
setCheckinComment("")
setCheckinLoading(false)
setMessageType("success")
setMessage("")
return
}
loadPendingChanges()
}, [connected, collection, refreshKey])
const includedChanges = pendingChanges.filter((change) =>
includedKeys.includes(toPendingChangeKey(change))
)
const excludedChanges = pendingChanges.filter((change) =>
excludedKeys.includes(toPendingChangeKey(change))
)
const collectionReady = connected && Boolean(collection)
const canCheckin =
includedChanges.length > 0 &&
checkinComment.trim().length > 0 &&
!checkinLoading
/**
* 查询后端 pending changes,并保留当前会话内用户移动到 Excluded 的选择。
*/
async function loadPendingChanges() {
setLoading(true)
setMessage("")
const result = await listPendingChanges({ collection })
setLoading(false)
if (!result.success) {
setPendingChanges([])
setIncludedKeys([])
setExcludedKeys([])
setMessageType("error")
setMessage(result.errorMessage || result.message)
return
}
const nextChanges = result.data.pendingChanges
const nextKeys = nextChanges.map(toPendingChangeKey)
setPendingChanges(nextChanges)
setExcludedKeys((currentKeys) =>
currentKeys.filter((currentKey) => nextKeys.includes(currentKey))
)
setIncludedKeys((currentKeys) => {
const knownKeys = new Set([...currentKeys, ...excludedKeys])
const excludedKeySet = new Set(excludedKeys)
const includedKeySet = new Set(currentKeys)
return nextKeys.filter((key) => {
if (excludedKeySet.has(key)) {
return false
}
return knownKeys.has(key) ? includedKeySet.has(key) : true
})
})
}
/**
* 使用服务端路径作为 Pending Change 的会话 key。
*/
function toPendingChangeKey(change: TfsPendingChangeInfo) {
return change.serverPath
}
/**
* 将单个挂起更改移动到 Excluded Changes。
*/
function moveToExcluded(change: TfsPendingChangeInfo) {
const key = toPendingChangeKey(change)
setIncludedKeys((currentKeys) =>
currentKeys.filter((currentKey) => currentKey !== key)
)
setExcludedKeys((currentKeys) =>
currentKeys.includes(key) ? currentKeys : [...currentKeys, key]
)
}
/**
* 将单个挂起更改移回 Included Changes,后续签入只读取 Included。
*/
function moveToIncluded(change: TfsPendingChangeInfo) {
const key = toPendingChangeKey(change)
setExcludedKeys((currentKeys) =>
currentKeys.filter((currentKey) => currentKey !== key)
)
setIncludedKeys((currentKeys) =>
currentKeys.includes(key) ? currentKeys : [...currentKeys, key]
)
}
/**
* 撤销指定 pending change,完成后刷新右侧挂起更改列表。
*/
async function undoPendingChange(change: TfsPendingChangeInfo) {
const key = toPendingChangeKey(change)
setOperationKey(key)
setMessage("")
const result = await undoFiles({
paths: [change.serverPath],
recursive: change.folder,
})
setOperationKey("")
if (!result.success) {
setMessage(result.errorMessage || result.message)
setMessageType("error")
return
}
setMessageType("success")
setMessage(`Undo 完成,影响 ${result.data.result?.affected ?? 0} 项。`)
await loadPendingChanges()
}
/**
* 提交 Included Changes,成功后清空 Included 并保留 Excluded 会话状态。
*/
async function submitIncludedChanges() {
const comment = checkinComment.trim()
if (!comment) {
setMessageType("error")
setMessage("Comment 必填。")
return
}
if (includedChanges.length === 0) {
setMessageType("error")
setMessage("没有可签入的 Included Changes。")
return
}
setCheckinLoading(true)
setMessage("")
const result = await checkin({
serverPaths: includedChanges.map((change) => change.serverPath),
comment,
})
setCheckinLoading(false)
if (!result.success) {
setMessageType("error")
setMessage(result.errorMessage || result.message)
return
}
setCheckinComment("")
await loadPendingChanges()
setIncludedKeys([])
setMessageType("success")
setMessage(
`Checkin 成功,changeset ${result.data.checkin?.changeset ?? "-"},提交 ${result.data.checkin?.submittedChanges ?? 0} 项。`
)
}
return (
<section className="grid gap-3">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="font-medium">Pending Changes</div>
<div className="text-xs text-muted-foreground">
签入范围:Included Changes
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<span className="rounded-[6px] border px-1.5 py-0.5 text-xs text-muted-foreground">
{pendingChanges.length} 项
</span>
<Button
size="icon-xs"
variant="ghost"
disabled={!collectionReady || loading || checkinLoading}
title="刷新挂起更改"
onClick={loadPendingChanges}
>
{loading ? (
<Loader2 className="animate-spin" />
) : (
<RefreshCw />
)}
</Button>
</div>
</div>
{!collectionReady ? (
<div className="rounded-[6px] border bg-muted/20 px-3 py-2 text-sm text-muted-foreground">
{connected ? "请选择 Collection" : "未连接"}
</div>
) : (
<>
{message && (
<div
className={`rounded-[6px] border px-3 py-2 text-xs ${
messageType === "error"
? "border-destructive/30 bg-destructive/10 text-destructive"
: "bg-muted/20 text-muted-foreground"
}`}
>
{message}
</div>
)}
<PendingChangeGroup
title="Included Changes"
changes={includedChanges}
emptyText={loading ? "正在查询挂起更改" : "暂无 Included Changes"}
actionTitle="移到 Excluded"
actionIcon="down"
operationKey={operationKey}
disabled={checkinLoading}
onUndo={undoPendingChange}
onMove={moveToExcluded}
/>
<PendingChangeGroup
title="Excluded Changes"
changes={excludedChanges}
emptyText="暂无 Excluded Changes"
actionTitle="移回 Included"
actionIcon="up"
operationKey={operationKey}
disabled={checkinLoading}
onUndo={undoPendingChange}
onMove={moveToIncluded}
/>
<section className="grid gap-2">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="font-medium text-muted-foreground">Checkin</span>
<span className="rounded-[6px] border px-1.5 py-0.5 text-muted-foreground">
Included {includedChanges.length}
</span>
</div>
<textarea
className="min-h-20 rounded-[6px] border bg-background px-2 py-1.5 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
value={checkinComment}
placeholder="Comment"
disabled={checkinLoading}
onChange={(event) => setCheckinComment(event.target.value)}
/>
<Button
size="sm"
disabled={!canCheckin}
onClick={submitIncludedChanges}
>
{checkinLoading ? (
<Loader2 className="animate-spin" />
) : (
<Upload />
)}
Checkin
</Button>
<div className="text-xs text-muted-foreground">
只提交 Included Changes,Excluded 保留在当前 UI 会话。
</div>
</section>
</>
)}
</section>
)
}
interface PendingChangeGroupProps {
title: string
changes: TfsPendingChangeInfo[]
emptyText: string
actionTitle: string
actionIcon: "up" | "down"
operationKey: string
disabled: boolean
onUndo(change: TfsPendingChangeInfo): void
onMove(change: TfsPendingChangeInfo): void
}
/**
* 渲染 Pending Changes 单个分组,并提供组间移动入口。
*/
function PendingChangeGroup({
title,
changes,
emptyText,
actionTitle,
actionIcon,
operationKey,
disabled,
onUndo,
onMove,
}: PendingChangeGroupProps) {
return (
<section className="grid gap-2">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="font-medium text-muted-foreground">{title}</span>
<span className="rounded-[6px] border px-1.5 py-0.5 text-muted-foreground">
{changes.length}
</span>
</div>
{changes.length === 0 ? (
<div className="rounded-[6px] border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{emptyText}
</div>
) : (
<div className="grid max-h-56 gap-2 overflow-auto">
{changes.map((change) => (
<div
key={change.serverPath}
className="grid gap-1 rounded-[6px] border bg-muted/20 p-2 text-xs"
>
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5">
{change.folder ? (
<Folder className="size-3.5 shrink-0 text-amber-600" />
) : (
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate font-medium">{change.name}</span>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
size="icon-xs"
variant="ghost"
title="Undo"
disabled={disabled || operationKey === change.serverPath}
onClick={() => onUndo(change)}
>
{operationKey === change.serverPath ? (
<Loader2 className="animate-spin" />
) : (
<RotateCcw />
)}
</Button>
<Button
size="icon-xs"
variant="ghost"
title={actionTitle}
disabled={disabled || operationKey === change.serverPath}
onClick={() => onMove(change)}
>
{actionIcon === "down" ? <ArrowDown /> : <ArrowUp />}
</Button>
</div>
</div>
<div className="truncate font-mono text-[11px] text-muted-foreground">
{change.serverPath}
</div>
<div className="flex min-w-0 flex-wrap items-center gap-1">
<span
className={`rounded-[6px] border px-1.5 py-0.5 ${PENDING_STATUS_CLASSES[change.status]}`}
>
{change.status} / {PENDING_STATUS_LABELS[change.status]}
</span>
{change.changeType && (
<span className="rounded-[6px] border bg-background px-1.5 py-0.5 text-muted-foreground">
{change.changeType}
</span>
)}
<span className="rounded-[6px] border bg-background px-1.5 py-0.5 font-mono text-muted-foreground">
v{change.version}
</span>
</div>
</div>
))}
</div>
)}
</section>
)
}
+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[]
}
+28
View File
@@ -0,0 +1,28 @@
export interface ServiceStatus {
baseUrl: string
running: boolean
connected: boolean
message: string
tokenFile: string
configFile?: string
started?: boolean
}
export interface MacTfsBridge {
getToken(): Promise<string>
getServiceStatus(): Promise<ServiceStatus>
selectDirectory(): Promise<string | null>
}
declare global {
interface Window {
mactfs?: MacTfsBridge
}
}
/**
* 返回 preload 暴露的 macTFS 桥接对象,避免组件直接依赖 Electron 细节。
*/
export function getMactfsBridge() {
return window.mactfs
}
+399 -7
View File
@@ -1,16 +1,408 @@
import { useEffect, useState } from "react"
import { Loader2 } from "lucide-react"
import { AppShell } from "~/components/app/app-shell"
import { Button } from "~/components/ui/button"
import { FolderItemsPanel } from "~/components/explorer/folder-items-panel"
import { PendingChangesPanel } from "~/components/inspector/pending-changes-panel"
import { ServerTreePanel } from "~/components/explorer/server-tree-panel"
import { apiRequest, subscribeApiRequestLogs } from "~/lib/api/client"
import type { ApiRequestLogEntry } from "~/lib/api/client"
import {
connectSession,
getConfig,
listOperationLogs,
} from "~/lib/api/endpoints"
import type {
ApiResult,
AppConfig,
ConnectData,
HealthData,
TfsOperationLogEntry,
} from "~/lib/api/types"
import type { ServiceStatus } from "~/lib/electron/bridge"
import { getMactfsBridge } from "~/lib/electron/bridge"
interface ServiceViewState {
loading: boolean
serviceStatus?: ServiceStatus
healthResult?: ApiResult<HealthData>
errorMessage?: string
}
interface ConnectionForm {
serverUri: string
authType: string
domain: string
username: string
password: string
}
const EMPTY_CONNECTION_FORM: ConnectionForm = {
serverUri: "",
authType: "ntlm-explicit",
domain: "",
username: "",
password: "",
}
/**
* 将后端配置转换为表单可直接使用的字符串结构,避免受缺省字段影响。
*/
function toConnectionForm(config?: AppConfig): ConnectionForm {
return {
serverUri: config?.serverUri || "",
authType: config?.authType || "ntlm-explicit",
domain: config?.domain || "",
username: config?.username || "",
password: config?.password || "",
}
}
export default function Home() {
const [state, setState] = useState<ServiceViewState>({
loading: true,
})
const [form, setForm] = useState<ConnectionForm>(EMPTY_CONNECTION_FORM)
const [connecting, setConnecting] = useState(false)
const [connected, setConnected] = useState(false)
const [connectResult, setConnectResult] = useState<ConnectData>()
const [preferredCollection, setPreferredCollection] = useState("")
const [selectedCollection, setSelectedCollection] = useState("")
const [selectedServerPath, setSelectedServerPath] = useState("")
const [pendingRefreshKey, setPendingRefreshKey] = useState(0)
const [workspaceActionKey, setWorkspaceActionKey] = useState("")
const [selectedMappedItem, setSelectedMappedItem] = useState(false)
const [operationLogs, setOperationLogs] = useState<ApiRequestLogEntry[]>([])
const [operationLogsLoading, setOperationLogsLoading] = useState(false)
/**
* 先确认服务状态,再读取默认配置并验证自动 token 请求。
*/
async function loadServiceStatus() {
setState((currentState) => ({
...currentState,
loading: true,
errorMessage: undefined,
}))
const bridge = getMactfsBridge()
if (!bridge) {
setState({
loading: false,
errorMessage:
"未检测到 Electron preload,请通过 Electron 启动 macTFS。",
})
return
}
const serviceStatus = await bridge.getServiceStatus()
const healthResult = serviceStatus.running
? await apiRequest<HealthData>("/api/health")
: undefined
const configResult = serviceStatus.running ? await getConfig() : undefined
if (configResult?.success) {
setForm(toConnectionForm(configResult.data.config))
setPreferredCollection(configResult.data.config.collection || "")
}
setState({
loading: false,
serviceStatus,
healthResult,
errorMessage: configResult?.success
? undefined
: configResult?.errorMessage || configResult?.message,
})
setConnected(Boolean(healthResult?.data.connected))
}
useEffect(() => {
return subscribeApiRequestLogs(upsertOperationLog)
}, [])
useEffect(() => {
loadServiceStatus()
}, [])
useEffect(() => {
if (connected) {
loadOperationLogs()
}
}, [connected])
const serviceReady =
state.serviceStatus?.running && state.healthResult?.success
const canConnect = serviceReady && !state.loading && !connecting
const serviceMessage = serviceReady
? "本地 API 已连接。"
: state.serviceStatus?.message ||
state.errorMessage ||
"正在检查本地 API 服务。"
const requestStatus = state.healthResult?.success
? state.healthResult.message
: state.healthResult?.errorMessage || state.errorMessage || "等待检查"
/**
* 更新连接表单字段,保持输入状态与受控表单一致。
*/
function updateFormField(field: keyof ConnectionForm, value: string) {
setForm((currentForm) => ({
...currentForm,
[field]: value,
}))
}
/**
* 调用连接 API,成功后进入已连接工作台状态。
*/
async function handleConnect(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setConnecting(true)
setConnectResult(undefined)
setState((currentState) => ({
...currentState,
errorMessage: undefined,
}))
const result = await connectSession(form)
setConnecting(false)
if (!result.success) {
setState((currentState) => ({
...currentState,
errorMessage: result.errorMessage || result.message,
}))
return
}
setConnected(true)
setConnectResult(result.data)
}
/**
* 触发右侧 Pending Changes 面板重新查询当前 Workspace 状态。
*/
function refreshPendingChanges() {
setPendingRefreshKey((currentKey) => currentKey + 1)
}
/**
* 切换主工作区当前路径,并清理上一目录的选中项状态。
*/
function selectServerPath(path: string) {
setSelectedMappedItem(false)
setSelectedServerPath(path)
}
/**
* 触发中间工作区文件操作,顶部工具栏和底部操作区复用同一套执行逻辑。
*/
function runWorkspaceAction(action: string) {
setWorkspaceActionKey(`${action}:${Date.now()}`)
}
/**
* 合并统一 API client 推送的请求日志,保留最近 80 条。
*/
function upsertOperationLog(entry: ApiRequestLogEntry) {
setOperationLogs((currentLogs) =>
[entry, ...currentLogs.filter((log) => log.id !== entry.id)].slice(0, 80)
)
}
/**
* 从服务端读取最近操作日志,补齐当前 UI 会话之前的 API 操作记录。
*/
async function loadOperationLogs() {
setOperationLogsLoading(true)
const result = await listOperationLogs()
setOperationLogsLoading(false)
if (!result.success) {
return
}
const serverLogs = result.data.logs.map(toOperationLog).reverse()
setOperationLogs((currentLogs) => [
...currentLogs.filter((log) => log.status === "running"),
...serverLogs,
].slice(0, 80))
}
/**
* 将后端操作日志转换成底部 Console 的展示结构。
*/
function toOperationLog(
entry: TfsOperationLogEntry,
index: number
): ApiRequestLogEntry {
return {
id: `server:${entry.startedAt}:${entry.operation}:${index}`,
operation: entry.operation,
summary: entry.summary || "-",
startedAt: entry.startedAt,
endedAt: entry.endedAt,
durationMs: entry.durationMs,
success: entry.success,
errorMessage: entry.errorMessage,
status: entry.success ? "success" : "error",
}
}
return (
<div className="flex min-h-svh p-6">
<div className="flex max-w-md min-w-0 flex-col gap-4 text-sm leading-loose">
<div>
<h1 className="font-medium">Project ready!</h1>
<p>You may now add components and start building.</p>
<p>We&apos;ve already added the button component for you.</p>
<Button className="mt-2">Button</Button>
<div className="min-h-svh bg-muted/40 p-4 text-sm">
<AppShell
connected={connected}
serviceReady={Boolean(serviceReady)}
serviceLoading={state.loading}
serverUri={connectResult?.serverUri || form.serverUri}
username={form.username}
collectionCount={connectResult?.collectionCount}
serviceBaseUrl={state.serviceStatus?.baseUrl}
tokenFile={state.serviceStatus?.tokenFile}
serviceMessage={serviceMessage}
requestStatus={requestStatus}
operationLogs={operationLogs}
operationLogsLoading={operationLogsLoading}
workspaceGetLatestEnabled={connected && selectedMappedItem}
workspaceHistoryEnabled={connected && Boolean(selectedServerPath)}
sourceList={
<ServerTreePanel
connected={connected}
preferredCollection={preferredCollection}
onCollectionSelect={setSelectedCollection}
onPathSelect={selectServerPath}
/>
}
inspector={
<PendingChangesPanel
connected={connected}
collection={selectedCollection}
refreshKey={pendingRefreshKey}
/>
}
onRefreshService={loadServiceStatus}
onRefreshOperationLogs={loadOperationLogs}
onWorkspaceGetLatest={() => runWorkspaceAction("getLatest")}
onWorkspaceHistory={() => runWorkspaceAction("history")}
>
{connected ? (
<FolderItemsPanel
connected={connected}
collection={selectedCollection}
serverPath={selectedServerPath}
actionKey={workspaceActionKey}
onPendingChangesRefresh={refreshPendingChanges}
onPathEnter={selectServerPath}
onSelectedMappedItemChange={setSelectedMappedItem}
/>
) : (
<div className="mx-auto grid w-full max-w-2xl gap-5 p-5">
<div className="min-w-0">
<h1 className="text-base font-medium">连接 TFS</h1>
<p className="mt-1 text-muted-foreground">
{serviceReady ? "本地 API 已就绪。" : serviceMessage}
</p>
</div>
<form className="grid gap-4" onSubmit={handleConnect}>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">
TFS 地址
</span>
<input
className="h-8 rounded-[6px] border bg-background px-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
value={form.serverUri}
required
placeholder="http://tfs.example.com:8080/tfs/"
onChange={(event) =>
updateFormField("serverUri", event.target.value)
}
/>
</label>
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">
认证方式
</span>
<select
className="h-8 rounded-[6px] border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
value={form.authType}
onChange={(event) =>
updateFormField("authType", event.target.value)
}
>
<option value="ntlm-explicit">NTLM 显式账号密码</option>
</select>
</label>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">
域
</span>
<input
className="h-8 rounded-[6px] border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
value={form.domain}
placeholder="domain"
onChange={(event) =>
updateFormField("domain", event.target.value)
}
/>
</label>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">
用户名
</span>
<input
className="h-8 rounded-[6px] border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
value={form.username}
required
autoComplete="username"
onChange={(event) =>
updateFormField("username", event.target.value)
}
/>
</label>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">
密码
</span>
<input
className="h-8 rounded-[6px] border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
type="password"
value={form.password}
required
autoComplete="current-password"
onChange={(event) =>
updateFormField("password", event.target.value)
}
/>
</label>
</div>
{state.errorMessage && (
<div className="rounded-[6px] border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive">
{state.errorMessage}
</div>
)}
<div className="flex justify-end">
<Button type="submit" disabled={!canConnect}>
{connecting && <Loader2 className="animate-spin" />}
连接
</Button>
</div>
</form>
</div>
)}
</AppShell>
</div>
)
}
+169 -1
View File
@@ -1,6 +1,17 @@
const { app, BrowserWindow } = require("electron")
const { app, BrowserWindow, dialog, ipcMain } = require("electron")
const { spawn } = require("node:child_process")
const fs = require("node:fs")
const os = require("node:os")
const path = require("node:path")
const API_BASE_URL = "http://127.0.0.1:38765"
const HEALTH_URL = `${API_BASE_URL}/api/health`
const TOKEN_FILE = path.join(os.homedir(), ".mactfs", "server-token")
const MACTFS_DIRECTORY = path.resolve(__dirname, "../../mactfs")
const GRADLE_WRAPPER = path.resolve(__dirname, "../../tfsIntegration/gradlew")
let serviceStartAttempted = false
let serviceStartError = ""
/**
* 创建 Electron 主窗口,开发环境加载本地服务,生产环境加载构建后的静态页面。
*/
@@ -8,6 +19,11 @@ function createWindow() {
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
preload: path.join(__dirname, "preload.cjs"),
},
})
if (process.env.NODE_ENV === "development") {
@@ -18,7 +34,159 @@ function createWindow() {
mainWindow.loadFile(path.join(__dirname, "../build/client/index.html"))
}
/**
* 读取 Java API 服务生成的本地 Bearer token,供 preload 和 health 检查复用。
*/
function readServerToken() {
if (!fs.existsSync(TOKEN_FILE)) {
return ""
}
return fs.readFileSync(TOKEN_FILE, "utf8").trim()
}
/**
* 调用本地 API health 接口,确认服务、token 和连接状态是否可用。
*/
async function checkServiceHealth() {
const token = readServerToken()
if (!token) {
return {
baseUrl: API_BASE_URL,
running: false,
connected: false,
message: "未找到本地 API token,正在尝试启动服务。",
tokenFile: TOKEN_FILE,
}
}
try {
const response = await fetch(HEALTH_URL, {
headers: {
Authorization: `Bearer ${token}`,
},
})
const result = await response.json()
if (!response.ok || !result.success) {
return {
baseUrl: API_BASE_URL,
running: false,
connected: false,
message:
result.errorMessage || result.message || "本地 API health 检查失败。",
tokenFile: TOKEN_FILE,
}
}
return {
baseUrl: API_BASE_URL,
running: true,
connected: Boolean(result.data.connected),
message: result.message || "ok",
tokenFile: result.data.tokenFile || TOKEN_FILE,
configFile: result.data.configFile,
}
} catch (error) {
return {
baseUrl: API_BASE_URL,
running: false,
connected: false,
message: error instanceof Error ? error.message : "本地 API 服务未响应。",
tokenFile: TOKEN_FILE,
}
}
}
/**
* 在开发环境下通过已有 Gradle runServer 任务拉起本地 Java API 服务。
*/
function startLocalService() {
if (serviceStartAttempted) {
return
}
serviceStartAttempted = true
if (!fs.existsSync(GRADLE_WRAPPER)) {
serviceStartError = `未找到 Gradle 启动脚本:${GRADLE_WRAPPER}`
return
}
const child = spawn(GRADLE_WRAPPER, ["runServer"], {
cwd: MACTFS_DIRECTORY,
detached: true,
stdio: "ignore",
})
child.on("error", (error) => {
serviceStartError = error.message
})
child.unref()
}
/**
* 等待一小段时间后重试 health,用于覆盖 Java 服务启动过程。
*/
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
/**
* 确保本地 API 服务可用;未运行时先启动,再轮询 health 返回最终状态。
*/
async function ensureServiceStatus() {
let status = await checkServiceHealth()
if (status.running) {
return status
}
startLocalService()
for (let index = 0; index < 30; index += 1) {
await delay(1000)
status = await checkServiceHealth()
if (status.running) {
return {
...status,
started: true,
}
}
}
return {
...status,
started: serviceStartAttempted,
message:
serviceStartError ||
"本地 API 服务未就绪,请确认 Java 8、Gradle 和 TFS 依赖可用。",
}
}
/**
* 打开系统目录选择器,返回用户选择的本地目录。
*/
async function selectDirectory() {
const window = BrowserWindow.getFocusedWindow()
const result = await dialog.showOpenDialog(window || undefined, {
properties: ["openDirectory", "createDirectory"],
})
return result.canceled ? null : result.filePaths[0]
}
/**
* 注册 renderer 可调用的最小 Electron 桥接接口。
*/
function registerIpcHandlers() {
ipcMain.handle("mactfs:get-token", () => readServerToken())
ipcMain.handle("mactfs:get-service-status", () => ensureServiceStatus())
ipcMain.handle("mactfs:select-directory", () => selectDirectory())
}
app.whenReady().then(() => {
registerIpcHandlers()
ensureServiceStatus()
createWindow()
app.on("activate", () => {
+28
View File
@@ -0,0 +1,28 @@
const { contextBridge, ipcRenderer } = require("electron")
/**
* 请求主进程返回本地 API Bearer token。
*/
function getToken() {
return ipcRenderer.invoke("mactfs:get-token")
}
/**
* 请求主进程确认本地 Java API 服务状态。
*/
function getServiceStatus() {
return ipcRenderer.invoke("mactfs:get-service-status")
}
/**
* 请求主进程打开本地目录选择器。
*/
function selectDirectory() {
return ipcRenderer.invoke("mactfs:select-directory")
}
contextBridge.exposeInMainWorld("mactfs", {
getToken,
getServiceStatus,
selectDirectory,
})
+1 -1
View File
@@ -6,7 +6,7 @@
"build": "react-router build",
"dev": "react-router dev",
"electron": "electron electron/main.cjs",
"electron:dev": "concurrently -k \"pnpm dev\" \"wait-on http://localhost:5173 && NODE_ENV=development electron electron/main.cjs\"",
"electron:dev": "concurrently -k \"pnpm dev\" \"wait-on tcp:5173 && NODE_ENV=development electron electron/main.cjs\"",
"start": "pnpm build && pnpm electron",
"typecheck": "react-router typegen && tsc",
"format": "prettier --write \"**/*.{ts,tsx}\""
Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

@@ -0,0 +1,42 @@
async page => {
const result = {
pendingBefore: false,
commentFilled: false,
checkinClicked: false,
successMessage: "",
pendingAfterZero: false,
bodySample: "",
}
let bodyText = await page.locator("body").innerText()
result.pendingBefore = bodyText.includes("Included 1") && bodyText.includes("pendingEdit / 编辑")
const comment = page.getByPlaceholder("Comment")
await comment.fill("AI UI retest checkin 2026-06-08 17:42")
result.commentFilled = true
await page.waitForTimeout(500)
const checkinButton = page.getByRole("button", { name: "Checkin" }).last()
if (await checkinButton.isEnabled().catch(() => false)) {
await checkinButton.click()
await page.waitForTimeout(3500)
result.checkinClicked = true
}
bodyText = await page.locator("body").innerText()
result.successMessage = bodyText
.split("\n")
.find((line) => line.includes("Checkin 成功")) || ""
result.pendingAfterZero = bodyText.includes("0 项") && bodyText.includes("暂无 Included Changes")
result.bodySample = bodyText
.split("\n")
.filter((line) => /Pending Changes|Included|stage4-e2e|pendingEdit|编辑|Checkin|changeset|暂无/.test(line))
.slice(0, 80)
.join("\n")
await page.screenshot({ path: "output/playwright/mactfs-retest/checkin-flow-final.png", fullPage: true })
await page.evaluate((data) => {
document.body.setAttribute("data-mactfs-checkin-flow", JSON.stringify(data))
}, result)
console.log(JSON.stringify(result, null, 2))
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

@@ -0,0 +1,57 @@
async page => {
const target = "stage4-e2e-20260608124119.txt"
const result = {
selected: false,
getLatestClicked: false,
getLatestMessage: "",
checkoutClicked: false,
checkoutMessage: "",
pendingVisible: false,
checkinVisible: false,
bodySample: "",
}
const targetRow = page.getByRole("button", { name: new RegExp(target) }).first()
await targetRow.click()
await page.waitForTimeout(800)
result.selected = (await page.locator("body").innerText()).includes(`已选中:$/北京排水集团人力系统/5SRC/MicroFront/subapp_pm/mactfs/${target}`)
const bottomGetLatest = page.getByRole("button", { name: "Get Latest" }).last()
if (await bottomGetLatest.isEnabled().catch(() => false)) {
await bottomGetLatest.click()
await page.waitForTimeout(2500)
result.getLatestClicked = true
}
let bodyText = await page.locator("body").innerText()
result.getLatestMessage = bodyText
.split("\n")
.find((line) => line.includes("Get Latest 完成")) || ""
await targetRow.click()
await page.waitForTimeout(500)
const checkout = page.getByRole("button", { name: "Checkout" }).last()
if (await checkout.isEnabled().catch(() => false)) {
await checkout.click()
await page.waitForTimeout(2500)
result.checkoutClicked = true
}
bodyText = await page.locator("body").innerText()
result.checkoutMessage = bodyText
.split("\n")
.find((line) => line.includes("Checkout 完成")) || ""
result.pendingVisible = bodyText.includes("stage4-e2e-20260608124119.txt") && bodyText.includes("编辑")
result.checkinVisible = bodyText.includes("Checkin") && bodyText.includes("Included")
result.bodySample = bodyText
.split("\n")
.filter((line) => /Get Latest|Checkout|Pending Changes|Included|stage4-e2e|编辑|Checkin/.test(line))
.slice(0, 60)
.join("\n")
await page.screenshot({ path: "output/playwright/mactfs-retest/file-flow-after-checkout.png", fullPage: true })
await page.evaluate((data) => {
document.body.setAttribute("data-mactfs-file-flow", JSON.stringify(data))
}, result)
console.log(JSON.stringify(result, null, 2))
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

@@ -0,0 +1,89 @@
async page => {
const token = '5P5VxCVaR_EXK5s6WdPW69Gny6ccq28e7fN5Gu8nuX4'
const serverPath = "$/北京排水集团人力系统/5SRC/MicroFront/subapp_pm/mactfs"
const result = {
url: "",
connected: false,
hasMissingCollection: false,
pkusehrVisible: false,
targetVisible: false,
mappedVisible: false,
getLatestVisible: false,
checkoutVisible: false,
pendingText: "",
errors: [],
steps: [],
}
await page.addInitScript((initToken) => {
window.mactfs = {
getToken: async () => initToken,
getServiceStatus: async () => ({
running: true,
message: "服务可用",
url: "http://127.0.0.1:38765",
tokenFile: "/Users/fenghp/.mactfs/server-token",
}),
}
}, token)
await page.goto("http://localhost:5173")
await page.waitForLoadState("networkidle")
result.url = page.url()
result.steps.push("loaded")
const connect = page.getByRole("button", { name: "连接" })
if (await connect.isVisible().catch(() => false)) {
await connect.click()
await page.waitForTimeout(2500)
result.steps.push("connected-click")
}
result.connected = await page.getByText("macTFS 已连接").isVisible().catch(() => false)
result.pkusehrVisible = await page.getByRole("button", { name: "PKUSEHR" }).isVisible().catch(() => false)
result.hasMissingCollection = await page.getByText("Missing required field: collection").isVisible().catch(() => false)
const pkusehr = page.getByRole("button", { name: "PKUSEHR" })
if (await pkusehr.isVisible().catch(() => false)) {
await pkusehr.click()
await page.waitForTimeout(2500)
result.steps.push("pkusehr-click")
}
const pathSegments = [
"北京排水集团人力系统",
"5SRC",
"MicroFront",
"subapp_pm",
"mactfs",
]
for (const segment of pathSegments) {
const locator = page.getByRole("button", { name: new RegExp(`^${segment}(\\s|$)`) }).first()
if (await locator.isVisible().catch(() => false)) {
await locator.dblclick().catch(async () => locator.click())
await page.waitForTimeout(1800)
result.steps.push(`click:${segment}`)
}
}
const bodyText = await page.locator("body").innerText()
result.targetVisible = bodyText.includes(serverPath)
result.mappedVisible =
bodyText.includes("/Users/fenghp/Desktop/DEV/mactfs-ai-workspace") &&
bodyText.includes("已映射")
result.hasMissingCollection = bodyText.includes("Missing required field: collection")
result.getLatestVisible = bodyText.includes("Get Latest")
result.checkoutVisible = bodyText.includes("Checkout")
result.pendingText = bodyText
.split("\n")
.filter((line) => /Pending Changes|Included Changes|Excluded Changes|pendingEdit|Missing required/.test(line))
.slice(0, 20)
.join("\n")
await page.screenshot({ path: "output/playwright/mactfs-retest/ui-retest-final.png", fullPage: true })
await page.evaluate((data) => {
document.body.setAttribute("data-mactfs-retest", JSON.stringify(data))
}, result)
console.log(JSON.stringify(result, null, 2))
}
+18 -2
View File
@@ -49,7 +49,23 @@
- 底部操作日志
- Mapping、目录对比、文件操作、历史、Diff、签入
## 阶段 5:Feature E2E
## 阶段 5:Frontend Refactor
目标:根据前端重构与 UI 功能调整方案,补齐后端接口能力并重构桌面工作台交互。
输出:
- 登录页固定 Collection,上次 Collection 默认选中
- Workspace 自动使用或创建
- 左右栏和底部日志收起
- 左侧树与中间文件列表同步导航
- 对象右键菜单
- Mapping、History、目录对比弹窗
- 文件查看与 Diff 编辑器
- Get Latest / Checkout 冲突选择弹窗
- Pending Changes 右键菜单与 Checkin 体验重构
## 阶段 6:Feature E2E
目标:按真实业务链路做端到端验收。
@@ -61,7 +77,7 @@
- Pending Changes 与 Checkin 验收
- History 与 Diff 验收
## 阶段 6:Release
## 阶段 7:Release
目标:完成个人可用的 macOS 桌面应用交付。
+33 -17
View File
@@ -25,18 +25,19 @@ blocked
| Core | 12 | 12 | 100% |
| Server API | 10 | 10 | 100% |
| CLI | 2 | 2 | 100% |
| Frontend | 0 | 13 | 0% |
| Frontend | 13 | 13 | 100% |
| Frontend Refactor | 0 | 10 | 0% |
| Feature E2E | 0 | 5 | 0% |
| Release | 0 | 4 | 0% |
| Overall | 24 | 46 | 52% |
| Overall | 37 | 56 | 66% |
## 当前阶段
Frontend
Frontend Refactor
## 下一步任务
- [FE-001-实现Electron启动服务与API客户端.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-001-实现Electron启动服务与API客户端.md)
- [UIR-001-补齐后端接口能力.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-001-补齐后端接口能力.md)
## Core
@@ -81,19 +82,34 @@ Frontend
| 任务 | 状态 | 优先级 |
|---|---|---|
| [FE-001-实现Electron启动服务与API客户端.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-001-实现Electron启动服务与API客户端.md) | todo | P0 |
| [FE-002-实现登录配置页.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-002-实现登录配置页.md) | todo | P0 |
| [FE-003-实现VS风格主布局.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-003-实现VS风格主布局.md) | todo | P0 |
| [FE-004-实现Collection服务端目录树.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-004-实现Collection服务端目录树.md) | todo | P0 |
| [FE-005-实现中间目录文件列表.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-005-实现中间目录文件列表.md) | todo | P0 |
| [FE-006-实现Mapping创建流程.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-006-实现Mapping创建流程.md) | todo | P0 |
| [FE-007-实现目录对比页面.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-007-实现目录对比页面.md) | todo | P0 |
| [FE-008-实现右侧挂起更改面板.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-008-实现右侧挂起更改面板.md) | todo | P0 |
| [FE-009-实现文件操作交互.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-009-实现文件操作交互.md) | todo | P0 |
| [FE-010-实现签入流程.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-010-实现签入流程.md) | todo | P0 |
| [FE-011-实现历史记录界面.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-011-实现历史记录界面.md) | todo | P0 |
| [FE-012-实现文件Diff界面.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-012-实现文件Diff界面.md) | todo | P0 |
| [FE-013-实现操作日志面板.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-013-实现操作日志面板.md) | todo | P1 |
| [FE-001-实现Electron启动服务与API客户端.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-001-实现Electron启动服务与API客户端.md) | done | P0 |
| [FE-002-实现登录配置页.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-002-实现登录配置页.md) | done | P0 |
| [FE-003-实现VS风格主布局.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-003-实现VS风格主布局.md) | done | P0 |
| [FE-004-实现Collection服务端目录树.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-004-实现Collection服务端目录树.md) | done | P0 |
| [FE-005-实现中间目录文件列表.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-005-实现中间目录文件列表.md) | done | P0 |
| [FE-006-实现Mapping创建流程.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-006-实现Mapping创建流程.md) | done | P0 |
| [FE-007-实现目录对比页面.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-007-实现目录对比页面.md) | done | P0 |
| [FE-008-实现右侧挂起更改面板.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-008-实现右侧挂起更改面板.md) | done | P0 |
| [FE-009-实现文件操作交互.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-009-实现文件操作交互.md) | done | P0 |
| [FE-010-实现签入流程.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-010-实现签入流程.md) | done | P0 |
| [FE-011-实现历史记录界面.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-011-实现历史记录界面.md) | done | P0 |
| [FE-012-实现文件Diff界面.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-012-实现文件Diff界面.md) | done | P0 |
| [FE-013-实现操作日志面板.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-013-实现操作日志面板.md) | done | P1 |
## Frontend Refactor
| 任务 | 状态 | 优先级 |
|---|---|---|
| [UIR-001-补齐后端接口能力.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-001-补齐后端接口能力.md) | todo | P0 |
| [UIR-002-重构登录Collection与Workspace上下文.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-002-重构登录Collection与Workspace上下文.md) | todo | P0 |
| [UIR-003-重构工作台布局与同步导航.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-003-重构工作台布局与同步导航.md) | todo | P0 |
| [UIR-004-实现对象右键菜单.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-004-实现对象右键菜单.md) | todo | P0 |
| [UIR-005-实现MappingHistoryCompare弹窗.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-005-实现MappingHistoryCompare弹窗.md) | todo | P0 |
| [UIR-006-实现文件查看与Diff编辑器.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-006-实现文件查看与Diff编辑器.md) | todo | P0 |
| [UIR-007-实现GetLatestCheckout冲突流程.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-007-实现GetLatestCheckout冲突流程.md) | todo | P0 |
| [UIR-008-重构PendingChanges与Checkin体验.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-008-重构PendingChanges与Checkin体验.md) | todo | P0 |
| [UIR-009-拆分FolderItemsPanel与动作编排.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-009-拆分FolderItemsPanel与动作编排.md) | todo | P1 |
| [UIR-010-前端重构端到端验收.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-010-前端重构端到端验收.md) | todo | P0 |
## Feature E2E
@@ -0,0 +1,713 @@
# macTFS 前端重构与 UI 功能调整方案
## 一、背景与问题
当前 `mactfsui` 前端已经实现连接、Collection 目录树、文件列表、Mapping、目录对比、文件操作、Pending Changes、Checkin、History、Diff 和操作日志,但现有页面把过多功能压在一个工作台中,导致主操作区职责不清。
主要问题:
- Collection 在左侧树里可切换,但 TFS 的日常工作通常围绕一个已选 Collection 和 Workspace 展开,运行时频繁切换 Collection 会让目录树、Mapping、Pending Changes、History、Diff 的上下文变复杂。
- `FolderItemsPanel` 同时承载目录文件列表、Mapping 表单、目录对比结果、批量差异操作、History 面板、Diff 面板和选中项操作区,中间区域被多类临时功能挤占。
- `AppShell` 顶部有全局 `Get Latest`、`History` 入口,但这些操作实际应绑定到文件、文件夹或当前目录上下文,不适合作为全局按钮。
- 右侧 Inspector 同时展示 Pending Changes、Checkin 和本地服务信息,页面宽度不足时 Pending Changes 可用空间被压缩。
- 文件/目录相关操作目前主要依赖按钮,缺少符合桌面工具习惯的右键菜单。
## 二、重构目标
本次 UI 重构应围绕“连接后固定上下文,主区专注浏览,操作绑定对象,详情进入弹窗”来调整。
目标:
- 登录阶段完成 Collection 选择,进入工作台后不再提供 Collection 切换。
- 主工作台保持三栏结构,但左右区域可收缩,保证中间文件列表有足够空间。
- 中间区域只展示当前目录文件列表和必要的路径/状态信息,不再内嵌 History、Diff、目录对比详情。
- 文件、文件夹、目录树节点上的操作统一放入右键菜单。
- History、Diff、目录对比、Mapping 使用弹窗承载;Checkin 保留在右侧 Changes 面板。
- 底部操作日志保留为可收缩区域,不长期占用大量主工作区高度。
## 三、推荐信息架构
### 3.1 登录与连接页
推荐把登录页拆成连接信息和 Collection 选择两个步骤,但仍保留在 `routes/home.tsx` 的未连接视图内,避免现在就引入复杂路由。
流程:
1. 用户填写 TFS 地址、认证方式、域、用户名、密码。
2. 点击连接后调用 `/api/session/connect`。
3. 连接成功后加载 `/api/collections`。
4. 用户选择一个 Collection。
5. 系统自动使用本机已有 Workspace;如果没有,则创建默认 Workspace。
6. 将选中的 Collection 和当前 Workspace 保存到配置或当前会话状态。
7. 进入主工作台。
推荐规则:
- 登录页必须选择 Collection;如果只有一个 Collection,默认选中但仍展示名称。
- 如果配置中有上次使用的 Collection,登录页默认选中该 Collection。
- 工作台内不提供 Collection 切换按钮。
- 不允许用户手动选择 Workspace;本机已有 Workspace 就自动使用,没有就创建默认 Workspace。
- 默认 Workspace 命名规则由后端程序固定,不在前端暴露配置。
- 需要更换 Collection 时,走“断开/重新连接”或“连接设置”流程。
原因:
- Collection 是后续目录树、Mapping、Pending Changes、History、Diff 的共同上游上下文。
- 进入工作台后固定 Collection,可以减少跨面板状态重置和误操作。
- 当前 `ServerTreePanel` 内部维护 Collection 列表和切换逻辑,后续应下沉为只接收固定 `collection` 的目录树。
### 3.2 主工作台布局
推荐布局:
```text
┌──────────────────────────────────────────────────────────────┐
│ Top Bar: macTFS | Server | Collection | Workspace | Actions │
├──────────────┬────────────────────────────────┬──────────────┤
│ Source Tree │ Current Folder Items │ Changes │
│ collapsible │ main workspace │ collapsible │
├──────────────┴────────────────────────────────┴──────────────┤
│ Operation Console collapsible │
└──────────────────────────────────────────────────────────────┘
```
区域职责:
- Top Bar:展示连接状态、服务器、Collection、Workspace、刷新服务、打开设置;不放文件操作主按钮。
- Source Tree:只展示固定 Collection 下的服务端目录树,支持右键目录操作。
- Current Folder Items:展示当前目录下文件/文件夹列表,是主工作区。
- Changes:展示 Pending Changes 摘要、Included/Excluded、Checkin 入口,可收缩。
- Operation Console:展示操作日志,可收缩,默认高度更低。
左右收缩建议:
- 左侧 Source Tree 默认宽度 280px,支持收起到 40px。
- 右侧 Changes 默认宽度 340px,支持收起到 40px。
- 中间使用 `minmax(480px, 1fr)`,优先保证文件列表可读。
- 左右栏收起状态都只保留展开按钮,不显示路径、列表或 Pending Changes 数量徽标。
不建议第一版做任意拖拽宽度:
- 当前项目没有可复用的 resizable 组件。
- 第一版使用固定展开/收起宽度更符合最小改动原则。
- 后续如果确实需要拖拽,再基于 Radix/shadcn 增补 `ResizablePanel`。
### 3.3 中间文件列表
中间区域应只保留高频浏览信息:
- 当前路径面包屑或路径栏。
- 当前目录 Mapping 状态。
- 文件/文件夹表格。
- 选中项状态摘要。
- 必要的刷新入口。
表格列建议:
- 名称
- 状态
- 最新版本
- 本地路径
- 修改时间
应从中间区域移出的内容:
- History 面板。
- Diff 面板。
- 目录对比详情列表。
- 大块 Mapping 创建表单。
- 批量差异操作面板。
- 选中项底部操作区。
中间文件列表的目录进入操作必须和左侧目录树保持同步。用户在中间双击或通过右键菜单“打开”进入目录时,左侧树应自动展开到该目录并选中对应节点;用户在左侧树选择目录时,中间文件列表也应切换到同一路径。
中间文件列表的单击只负责选中行,不进入目录;进入目录只通过双击或右键菜单“打开”触发。
## 四、右键菜单设计
### 4.1 右键菜单使用原则
推荐将“绑定到文件或文件夹”的操作全部放到右键菜单中。
适用对象:
- 左侧目录树节点。
- 中间文件列表行。
- Pending Changes 列表项。
- 目录对比结果项。
不建议只依赖右键菜单:
- `Checkin` 仍应在右侧 Changes 面板有明确入口,因为它是跨文件集合操作。
- `刷新`、`连接设置`、`打开日志` 这类全局操作保留在顶部或面板标题区。
### 4.2 左侧目录树右键菜单
目录树节点菜单:
- 获取最新版本
- 映射到本地
- 查看历史
- 与服务器文件对比
- 刷新子目录
启用条件:
- 获取最新版本:目录已映射。
- 映射到本地:目录未映射。
- 查看历史:已连接且有服务端路径。
- 与服务器文件对比:目录已映射。
- 刷新子目录:目录节点可用。
左侧目录树必须支持右键菜单。目录树右键菜单和中间文件列表右键菜单应复用同一套文件操作判断,避免同一路径在两个区域出现不同可用操作。
### 4.3 中间文件列表右键菜单
文件夹菜单:
- 打开
- 获取最新版本
- 签出
- 新增本地文件到服务器
- 删除
- 撤销挂起更改
- 查看历史
- 与服务器目录对比
- 映射到本地
文件菜单:
- 查看
- 获取最新版本
- 签出
- 删除
- 撤销挂起更改
- 查看历史
- 比较本地与服务器 Latest
启用条件:
- 获取最新版本:已映射且没有 pending edit;文件夹递归执行;发现本地与服务器不一致时,必须让用户选择使用服务器版本或保留本地版本。
- 签出:已映射且为版本控制项;执行时自动先获取最新版本再签出;文件夹允许递归签出,但执行前必须确认。
- 新增本地文件到服务器:仅文件夹或当前目录已映射时展示,先对比本地映射目录和服务器目录,再把选中的本地独有项加入 pending add。
- 删除:仅服务器已有的版本控制项可用,执行 pending delete;不删除本地未入库的 `localOnly` 文件。
- 撤销挂起更改:该项存在 pending change。
- 查看历史:服务端路径存在。
- 查看:文件可用;已映射且存在本地路径时查看本地文件,未映射时查看服务器文件。
- 与服务器目录对比:文件夹、已映射,用于找出本地和服务器不同的文件,方便后续签出、新增、获取最新等操作。
- 比较本地与服务器 Latest:文件、已映射、有本地路径,用于查看本地修改和服务器代码的异同。
- 映射到本地:仅未映射目录展示。
### 4.4 Pending Changes 右键菜单
Pending Change 菜单:
- 移到 Included
- 移到 Excluded
- 查看
- 撤销
- 查看历史
- 比较本地与服务器 Latest
Checkin 不放到单个 pending item 的右键菜单中,避免用户误以为只签入单项。签入仍通过右侧 Changes 面板提交 Included Changes。
Pending Changes 中的查看入口用于打开本地文件只读内容;比较入口用于查看待签入文件的本地内容与服务器 latest 的差异;撤销用于取消该文件的 pending change;历史记录用于查看该文件或目录的提交历史。
Pending Changes 的查看和比较入口只对文件显示;文件夹保留撤销、查看历史、移到 Included 和移到 Excluded。
pending add 文件没有服务器版本,只显示查看和撤销,不显示比较本地与服务器 Latest。
## 五、弹窗与独立视图设计
### 5.1 History 弹窗
History 应改为弹窗或大尺寸 Dialog,不再嵌入中间文件列表。
触发入口:
- 文件/文件夹右键菜单“查看历史”。
- 目录树节点右键菜单“查看历史”。
弹窗内容:
- 顶部展示目标路径。
- 左侧或主表格展示 changeset 列表。
- 右侧展示选中 changeset 的文件列表。
- 文件历史支持选择两个版本后打开 Diff。
推荐尺寸:
- 桌面端宽度使用 `min(1100px, calc(100vw - 48px))`。
- 高度使用 `min(760px, calc(100vh - 48px))`。
### 5.2 Diff 弹窗
Diff 应改为独立 Dialog 或专用工作视图,不再占用文件列表高度。
触发入口:
- 文件右键菜单“Diff 本地与服务器 Latest”。
- History 弹窗选择两个版本后打开。
- 目录对比弹窗中对单个文件打开。
推荐方式:
- 第一版使用 Dialog。
- Diff 视觉效果需要重做,不沿用当前紧凑表格视觉。
- 使用更清晰的左右分栏:左侧本地或源版本,右侧服务器 latest 或目标版本。
- 必须支持代码高亮、搜索、行号、差异高亮、文件路径、版本标签和刷新入口。
- 长文本区域使用独立滚动,不让内容撑开弹窗。
- 单个文件超过 5MB 时不直接渲染 Diff,只提示文件过大。
- 第一版需要引入代码查看/Diff 编辑器能力,优先评估 Monaco Editor:只读查看使用 Monaco Editor,Diff 使用 Monaco Diff Editor。
- 如果 Monaco 包体或集成成本不可接受,再评估 CodeMirror 6 + MergeView。
### 5.3 文件查看弹窗
文件右键菜单需要提供“查看”入口。已映射且存在本地路径时查看本地文件;未映射时查看服务器文件。
触发入口:
- 文件列表文件右键菜单“查看”。
- Pending Changes 文件右键菜单“查看”。
弹窗内容:
- 顶部展示文件名、本地路径和服务器路径。
- 主体展示只读文件内容。
- 明确标识当前查看来源:本地文件或服务器文件。
- 文本文件必须支持代码高亮、搜索、行号和滚动区域。
- 单个文件超过 5MB 时不直接渲染内容,只提示文件过大。
- 二进制文件或无法读取的文件展示明确提示,不尝试渲染内容。
实现建议:
- 第一版先实现只读查看,不提供编辑能力。
- 第一版需要选择编辑器组件承载查看能力,优先评估 Monaco Editor;如果 Monaco 不适合,再评估 CodeMirror 6。
- 本地文件读取必须限制在当前 Mapping 目录内,禁止通过 UI 查看任意本机路径。
### 5.4 目录对比弹窗
目录对比结果应从 `FolderItemsPanel` 移到单独弹窗。
触发入口:
- 目录树节点右键菜单“与服务器文件对比”。
- 文件列表目录行右键菜单“与服务器文件对比”。
- 当前目录路径栏上的对比图标按钮。
弹窗内容:
- 顶部展示服务端路径、本地路径、重新对比按钮、隐藏已同步开关。
- 支持按差异状态筛选,例如本地修改、服务器更新、本地新增、待删除。
- 主表格展示差异项。
- 差异项对象操作通过右键菜单触发,顶部或底部只保留刷新、筛选和已选数量等状态入口。
差异项操作:
- 获取最新版本
- 签出
- 新增本地文件到服务器
- 删除
- 撤销
- Diff
说明:
- 目录对比是一次独立任务,不应长期占据主文件列表区域。
- 获取最新版本遇到本地与服务器不一致时,必须展示冲突选择,用户明确选择使用服务器版本或保留本地版本后再继续。
- 新增本地文件到服务器只处理目录对比中的 `localOnly` 项,用户选中后加入 pending add。
- 操作完成后可以提示“重新对比以刷新结果”。
### 5.5 Mapping 弹窗
Mapping 创建应从中间内联表单改为 Dialog。
触发入口:
- 未映射目录右键菜单“映射到本地”。
- 当前目录未映射时路径栏显示一个小型“映射”按钮。
弹窗字段:
- 服务端路径,只读。
- 本地目录。
- 是否立即 Get Latest。
- 创建按钮。
Mapping 创建时,本地目录必须由用户选择或输入;Workspace 自动处理不代表 Mapping 本地路径自动决定。
是否立即 Get Latest 由用户选择,可以勾选也可以不勾选。
### 5.6 Checkin 面板
Checkin 保留在右侧 Changes 面板,不使用弹窗。
- 右侧面板展示 Included/Excluded 列表和 comment。
- 点击 Checkin 直接提交 Included。
### 5.7 Get Latest 冲突选择弹窗
Get Latest 遇到本地与服务器不一致时,使用集中弹窗处理,不逐个文件连续弹窗。
弹窗内容:
- 顶部展示当前操作路径、冲突数量、批量操作入口。
- 主表格展示冲突文件列表。
- 每个文件必须选择处理方式:使用服务器版本、保留本地版本、自动合并。
- 每个冲突文件提供 Diff 入口,用于对比本地版本和服务器版本。
- 支持批量选择“全部使用服务器版本”和“全部保留本地版本”。
- 批量操作只作为快速填充选择,用户仍可对单个文件改选处理方式。
- 自动合并只对文本文件开放,二进制文件不展示自动合并。
- 二进制文件或无法生成文本 Diff 的文件,只允许选择使用服务器版本或保留本地版本。
- 选择使用服务器版本时,覆盖本地文件并保持无 pending change。
- 选择保留本地版本时,该文件需要自动进入 pending edit。
自动合并约束:
- 自动合并必须基于三份内容:共同基线版本、本地版本、服务器版本。
- 如果后端只能提供本地和服务器两份内容,不能可靠自动合并,只能让用户选择服务器或本地。
- 自动合并成功后,结果仍应在弹窗中标记为“已自动合并”,用户确认后再写入本地文件。
- 自动合并结果确认应用后,作为本地修改进入 pending edit。
- 自动合并失败或存在冲突块时,回退为手动选择服务器或本地;第一版不提供手动编辑冲突块。
候选实现:
- 自动合并算法优先评估 `node-diff3`,它提供文本三方 diff 和 merge 能力,适合在有基线版本时做行级自动合并。
- 第一版不引入 Monaco 或 CodeMirror 作为冲突解决 UI,冲突弹窗先使用项目现有 React + Radix/shadcn 风格实现;后续如果需要代码编辑器级体验,再评估 CodeMirror MergeView 或 Monaco Diff Editor。
## 六、组件重构建议
### 6.1 路由层
`routes/home.tsx` 继续作为主入口,但职责应减少。
保留职责:
- 服务状态检查。
- 连接表单状态。
- 登录后固定 Collection。
- 当前选中路径。
- 左侧目录树展开路径。
- 打开/关闭 Dialog 的全局状态。
移出职责:
- 具体文件操作实现。
- History/Diff/Compare 详情 UI。
- Pending Changes 细节状态。
### 6.2 AppShell
`AppShell` 应调整为纯布局组件。
建议变更:
- 删除顶部全局 `Get Latest` 和 `History` 按钮。
- 新增左侧/右侧/底部收起按钮。
- 顶部只展示连接状态和上下文信息。
- `sourceList`、`inspector`、`console` 保持插槽式传入。
### 6.3 ServerTreePanel
`ServerTreePanel` 应改为固定 Collection 的目录树。
建议 props:
```ts
interface ServerTreePanelProps {
connected: boolean
collection: string
selectedPath: string
onPathSelect(path: string): void
onOpenContextMenu(target: TfsServerItem): void
}
```
需要移除:
- 内部 `collections` 状态。
- Collection 列表渲染。
- `onCollectionSelect`。
- `preferredCollection`。
- 完全内部维护的当前选中路径和展开路径。
### 6.4 FolderItemsPanel
`FolderItemsPanel` 应拆分。
保留:
- 加载当前目录 items。
- 加载 mappings。
- 计算 `FolderItemView`。
- 表格选择和进入目录。
移出:
- `HistoryPanel`。
- `DiffPanel`。
- 目录对比状态和差异操作。
- Mapping 创建表单。
- 选中项底部大操作区。
新增回调:
```ts
interface FolderItemsPanelProps {
connected: boolean
collection: string
serverPath: string
onPathEnter(path: string): void
onOpenHistory(target: FileTarget): void
onOpenDiff(target: FileTarget): void
onOpenCompare(target: FileTarget): void
onOpenMapping(target: FileTarget): void
onRunFileOperation(action: FileAction, target: FileTarget): void
}
```
### 6.5 新增组件
建议新增:
- `components/app/collapsible-sidebar.tsx`
- `components/app/connection-gate.tsx`
- `components/explorer/file-context-menu.tsx`
- `components/explorer/mapping-dialog.tsx`
- `components/compare/folder-compare-dialog.tsx`
- `components/history/history-dialog.tsx`
- `components/diff/diff-dialog.tsx`
说明:
- 如果项目后续继续使用 shadcn/Radix,右键菜单和 Dialog 优先基于 Radix primitives。
- 当前 `package.json` 已有 `radix-ui`,不需要为第一版右键菜单引入额外大型依赖。
## 七、推荐实施顺序
### 阶段一:固定 Collection 上下文
改动:
- 登录成功后加载 Collection。
- 登录页选择 Collection 后进入工作台。
- `ServerTreePanel` 不再渲染 Collection 列表。
- 工作台顶部展示固定 Collection。
收益:
- 先解决后续所有面板共享上下文不稳定的问题。
### 阶段二:布局收缩
改动:
- `AppShell` 增加左侧、右侧、底部收起状态。
- 删除顶部文件操作按钮。
- 底部操作日志默认缩小或可收起。
收益:
- 立即改善中间空间不足问题。
### 阶段三:右键菜单
改动:
- 文件列表行接入右键菜单。
- 目录树节点接入右键菜单。
- Pending Changes 项接入右键菜单。
- 将现有选中项按钮迁移到菜单动作。
收益:
- 操作与对象绑定,减少全局按钮造成的上下文歧义。
### 阶段四:弹窗化详情功能
改动:
- History 改为 Dialog。
- Diff 改为 Dialog。
- 目录对比改为 Dialog。
- Mapping 创建改为 Dialog。
收益:
- 中间区域回归文件浏览。
- 复杂功能获得更大展示空间。
### 阶段五:拆分 FolderItemsPanel
改动:
- 将 `FolderItemsPanel` 中的 History、Diff、Compare、Mapping 逻辑拆出。
- 保留文件列表主职责。
- 文件操作统一由 `home.tsx` 或轻量 action hook 编排。
收益:
- 降低单组件复杂度,后续维护更清晰。
## 八、风险与影响
可能影响:
- `routes/home.tsx`:连接、Collection、弹窗状态和工作台编排会调整。
- `components/app/app-shell.tsx`:布局结构和顶部操作按钮会调整。
- `components/explorer/server-tree-panel.tsx`:Collection 切换逻辑会移除。
- `components/explorer/folder-items-panel.tsx`:会拆出大量功能。
- `components/inspector/pending-changes-panel.tsx`:右键菜单和 Checkin 入口可能调整。
- `components/explorer/history-panel.tsx`:会从内嵌面板改为 Dialog 内容组件。
- `components/explorer/diff-panel.tsx`:会从内嵌面板改为 Dialog 内容组件。
接口影响:
- 现有 API 可支持部分 UI 调整,但文件查看、服务器文件内容、冲突处理和默认 Workspace 等能力需要后端补充。
- 如果登录页要把 Collection 持久保存到后端配置,可能需要确认 `/api/session/connect` 或 `/api/config` 是否保存 `collection` 字段。
- 如果要精确控制右键菜单启用条件,可能需要 pending changes 和映射状态在文件列表中更稳定地合并。
业务影响:
- 进入工作台后不再直接切换 Collection。
- History、Diff、Compare 的打开方式从中间内嵌变为弹窗。
- 文件操作入口从显式按钮为主,调整为右键菜单为主。
## 九、后端接口能力清单
后续重构前需要确认或补充以下后端能力。
连接与 Workspace:
- 登录页选择 Collection 后保存上次 Collection。
- 根据 Collection 自动查找本机已有 Workspace。
- 没有 Workspace 时按程序固定规则创建默认 Workspace。
- 返回当前实际使用的 Workspace 名称和基础信息。
文件查看:
- 读取 Mapping 目录内本地文件内容,必须校验路径位于 Mapping 目录内。
- 读取未映射文件的服务器 latest 内容。
- 返回文件大小、是否二进制、可否文本渲染、文件编码和内容。
- 超过 5MB 时返回大小信息,前端不直接渲染内容。
Diff 与历史:
- 获取本地文件与服务器 latest 的文本 Diff。
- 获取两个历史 changeset 的文本 Diff。
- 支持 Diff 前返回文件大小和二进制判断。
- 支持文件历史和目录历史继续按弹窗使用。
目录对比:
- 返回目录差异状态,至少覆盖本地修改、服务器更新、本地新增、服务器新增、待删除、已同步。
- 返回每个差异项是否可执行获取最新、签出、新增本地文件到服务器、删除、撤销、比较。
- 返回 pending edit 子项,用于递归 Get Latest 时跳过并提示。
Get Latest 与 Checkout 冲突处理:
- Get Latest 文件夹递归执行。
- pending edit 文件禁止直接 Get Latest;目录递归时跳过 pending edit 子文件并返回跳过列表。
- Checkout 自动先 Get Latest,再签出。
- Checkout 自动 Get Latest 遇到冲突时返回同一套冲突明细。
- 冲突明细需要包含服务端路径、本地路径、文件大小、是否二进制、是否可自动合并。
- 冲突文件需要支持读取本地版本、服务器 latest 版本;自动合并需要共同基线版本。
- 支持应用用户选择:使用服务器版本、保留本地版本、自动合并结果。
- 使用服务器版本时覆盖本地且不产生 pending change。
- 保留本地版本时自动进入 pending edit。
- 自动合并结果确认应用后写入本地并进入 pending edit。
Pending Changes:
- 返回 pending change 类型,用于区分 pending add、pending edit、pending delete。
- pending add 文件不提供服务器比较。
- 支持单项撤销、Included/Excluded 前端会话状态和 Checkin 提交 Included。
## 十、待确认问题
第一优先级问题:
- 更换 Collection 是否必须支持“不断开连接直接切换”?
推荐答案:
- 不支持。Collection 在登录页选定后固定,切换 Collection 需要回到连接设置重新选择。
原因:
- 这符合当前重构目标,也能最大限度减少 Mapping、Pending Changes、History、Diff 的上下文混乱。
第二优先级问题:
- Checkin 是否也要右键触发?
推荐答案:
- 不作为主要入口。右键可以提供“移到 Included/Excluded”和“撤销”,Checkin 仍放在 Changes 面板中。
原因:
- Checkin 是批量提交行为,依赖 comment 和 Included 集合,不是单文件即时动作。
第三优先级问题:
- 左右栏是否需要拖拽调整宽度?
推荐答案:
- 第一版只做展开/收起,不做拖拽。
原因:
- 当前问题的核心是空间分配和职责混杂,固定收缩已经能解决大部分问题,且改动更小。
## 十一、已确认决策
- 工作台内不支持切换 Collection。用户需要更换 Collection 时,必须回到连接设置重新选择并重新进入工作台。
- 登录页默认选中上次使用的 Collection,但进入工作台后仍不允许切换 Collection。
- Workspace 不允许用户手动选择;本机已有 Workspace 就自动使用,没有就创建默认 Workspace。
- 默认 Workspace 命名规则由后端程序固定,前端只展示最终使用的 Workspace。
- Mapping 创建时本地目录由用户选择或输入。
- Mapping 创建后是否立即 Get Latest 由用户选择,不强制。
- Checkin 保留在右侧 Changes 面板作为主入口,不放到单个文件或文件夹右键菜单中。
- Checkin 不使用弹窗,右侧 Changes 面板直接展示 Included/Excluded、comment 和提交按钮。
- 左右栏第一版只支持展开/收起,不做拖拽调整宽度。
- History、Diff、目录对比和 Mapping 第一版都使用弹窗,不做新路由,也不继续内嵌在中间文件列表中。
- 底部操作日志保留并支持收起,避免长期占用主工作区高度。
- 右侧 Changes 面板收起后不显示 Pending Changes 数量徽标。
- 左侧 Source Tree 收起后只显示展开按钮,不显示路径或图标列表。
- 顶部工具栏移除文件和目录操作,只保留连接状态、服务器、Collection、Workspace、刷新和设置等全局入口。
- 文件列表取消选中项底部操作区,文件和目录对象操作全部进入右键菜单。
- 左侧目录树必须支持右键菜单,且左侧树展开/选中状态必须与中间文件列表目录导航保持一致。
- 中间文件列表单击只选中,双击或右键“打开”才进入目录。
- 第一版不支持键盘快捷键。
- 当前路径和左侧目录树展开路径统一提升到 `home.tsx` 管理,再传给左侧树和中间文件列表。
- 目录对比结果项的文件操作也使用右键菜单,不在弹窗底部或右侧额外放操作按钮区。
- 目录对比弹窗需要支持按差异状态筛选,例如本地修改、服务器更新、本地新增、待删除。
- 新增本地文件到服务器是文件夹级操作,只在文件夹或当前目录上下文出现,通过本地映射目录和服务器目录对比找出本地独有项,用户选中后加入 pending add。
- 删除只对服务器已有的版本控制项执行 pending delete,不删除本地未加入服务器的 `localOnly` 文件。
- 文件夹“与服务器目录对比”用于找出本地与服务器不同的文件,方便后续签出、新增、获取最新等操作;文件“比较本地与服务器 Latest”用于查看本地修改和服务器代码异同。
- 右侧 Pending Changes 项必须支持右键菜单,至少包含比较本地与服务器 Latest、撤销、查看历史、移到 Included、移到 Excluded。
- 右侧 Pending Changes 的比较入口只对文件显示,文件夹不展示内容比较。
- pending add 文件没有服务器版本,Pending Changes 右键菜单只显示查看和撤销,不显示比较本地与服务器 Latest。
- 文件右键菜单需要提供“查看”入口,打开只读文件查看弹窗;Pending Changes 的文件项也需要提供查看入口。
- 文件“查看”已映射时查看本地文件,未映射时查看服务器文件。
- 文件查看和 Diff 单个文件超过 5MB 时不直接渲染,只提示文件过大。
- 本地文件查看必须限制在 Mapping 目录内,不能读取任意本机路径。
- Diff 视觉效果需要升级为更清晰的弹窗左右分栏,不沿用当前紧凑表格视觉。
- 文件查看和 Diff 第一版必须支持代码高亮、搜索和行号;优先评估 Monaco Editor,备选 CodeMirror 6。
- 文件夹允许递归签出,但执行前必须让用户确认影响范围。
- 获取最新版本对文件夹递归执行;如果发现本地与服务器不一致,必须由用户选择使用服务器版本或保留本地版本。
- Get Latest 冲突选择使用集中弹窗,用户在弹窗内逐文件选择使用服务器版本、保留本地版本或自动合并。
- Get Latest 冲突弹窗允许批量选择处理方式,但必须保留单个文件独立改选能力。
- Get Latest 冲突弹窗中的每个冲突文件都需要提供 Diff 入口,辅助判断使用服务器、本地或自动合并。
- Get Latest 冲突中选择使用服务器版本时,覆盖本地文件并保持无 pending change。
- Get Latest 冲突中选择保留本地版本时,该文件需要自动签出为 pending edit。
- 自动合并只有在后端能提供共同基线版本、本地版本和服务器版本三份内容时才启用;无基线或二进制文件不做自动合并。
- 二进制文件或无法生成文本 Diff 的文件不展示自动合并,只允许使用服务器版本或保留本地版本。
- 自动合并成功后不能直接写入本地,必须等用户在冲突弹窗中确认后再应用结果。
- 自动合并结果确认应用后,需要作为本地修改进入 pending edit。
- 自动合并失败或存在冲突块时,只回退到使用服务器版本或保留本地版本二选一;第一版不做手动冲突块编辑。
- 已经处于 pending edit 的文件禁止直接获取最新版本;checkout 流程应先更新到最新再签出。
- 文件夹递归获取最新版本时,如果子文件已处于 pending edit,则跳过该子文件并在结果中提示。
- Checkout 执行时自动先获取最新版本再签出,不要求用户额外手动执行 Get Latest。
- Checkout 自动获取最新版本时如果遇到本地与服务器不一致,复用 Get Latest 冲突选择弹窗处理。
- Checkin 成功后刷新当前目录文件列表和 Pending Changes;只有涉及新增或删除目录时,才刷新左侧树对应节点。
@@ -0,0 +1,91 @@
# UIR-001 补齐后端接口能力
## 状态
todo
## 优先级
P0
## 所属阶段
frontend-refactor
## 依赖任务
- FE-013
- SERVER-006
- SERVER-007
- SERVER-008
- SERVER-009
## 需求来源
- [UIR-000-前端重构与UI功能调整方案.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-000-前端重构与UI功能调整方案.md)
## 目标
补齐前端重构所需的后端接口能力,确保文件查看、服务器内容读取、默认 Workspace、目录对比、Get Latest / Checkout 冲突处理可以被 UI 调用。
## 实现范围
- 登录后保存上次使用的 Collection。
- 根据 Collection 自动查找本机已有 Workspace。
- 本机没有 Workspace 时按程序固定规则创建默认 Workspace。
- 返回当前实际使用的 Workspace 名称和基础信息。
- 提供 Mapping 目录内本地文件内容读取能力,并校验路径不能越过 Mapping 目录。
- 提供未映射文件的服务器 latest 内容读取能力。
- 文件内容接口返回文件大小、是否二进制、是否可文本渲染、文件编码和内容。
- 文件超过 5MB 时返回大小信息,前端不直接渲染内容。
- Diff 接口支持本地文件与服务器 latest、两个历史 changeset。
- 目录对比接口返回差异状态、可执行动作、pending edit 子项。
- Get Latest 支持递归、跳过 pending edit 子文件并返回跳过列表。
- Checkout 自动先 Get Latest,再签出。
- Get Latest / Checkout 遇到冲突时返回统一冲突明细。
- 冲突明细包含服务端路径、本地路径、文件大小、是否二进制、是否可自动合并。
- 支持应用冲突选择:使用服务器版本、保留本地版本、自动合并结果。
- Pending Changes 返回 pending add / edit / delete 类型,支持菜单判断。
## 不在范围
- 不实现前端页面。
- 不实现 Monaco 或 CodeMirror。
- 不做手动冲突块编辑。
- 不改变现有 TFS 协议封装以外的业务规则。
## 涉及文件
- [mactfs/src/main/java/com/mydev/mactfs](/Users/fenghp/Desktop/DEV/project/mydev/mactfs/src/main/java/com/mydev/mactfs)
- [mactfs/src/main/java/com/mydev/mactfs/server](/Users/fenghp/Desktop/DEV/project/mydev/mactfs/src/main/java/com/mydev/mactfs/server)
- [mactfsui/app/lib/api](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api)
## 验收标准
- 登录连接后能返回固定 Collection 和当前 Workspace。
- 没有 Workspace 时能创建默认 Workspace。
- 已映射文件能读取本地内容,未映射文件能读取服务器内容。
- 非 Mapping 目录路径读取被拒绝。
- 大于 5MB 文件不返回正文渲染内容。
- pending add 文件不会提供服务器比较能力。
- Get Latest / Checkout 冲突能返回前端弹窗所需明细。
- 应用“使用服务器版本”后无 pending change。
- 应用“保留本地版本”后进入 pending edit。
- 自动合并结果确认应用后进入 pending edit。
## 测试方式
```bash
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfs
../tfsIntegration/gradlew test
../tfsIntegration/gradlew runServer
```
## 完成记录
待完成后填写:
- 实际修改文件:
- 实际实现内容:
- 测试结果:
- 遗留问题:
@@ -0,0 +1,79 @@
# UIR-002 重构登录 Collection 与 Workspace 上下文
## 状态
todo
## 优先级
P0
## 所属阶段
frontend-refactor
## 依赖任务
- UIR-001
- FE-002
- FE-004
## 需求来源
- [UIR-000-前端重构与UI功能调整方案.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-000-前端重构与UI功能调整方案.md)
## 目标
将 Collection 选择前置到登录页,进入工作台后固定 Collection;Workspace 由后端自动选择或创建,前端不提供 Workspace 选择。
## 实现范围
- 登录成功后加载 Collection 列表。
- 如果配置中有上次使用的 Collection,登录页默认选中。
- 只有一个 Collection 时默认选中但仍展示名称。
- 用户必须在登录页确认 Collection 后进入工作台。
- 工作台内移除 Collection 切换入口。
- 工作台顶部展示固定 Collection 和当前 Workspace。
- Workspace 不允许用户手动选择。
- 本机已有 Workspace 时自动使用,没有则由后端创建默认 Workspace。
- `ServerTreePanel` 改为只接收固定 Collection。
- 移除 `ServerTreePanel` 内部 Collection 列表和 `onCollectionSelect`。
## 不在范围
- 不做多 Profile。
- 不支持工作台内切换 Collection。
- 不允许用户选择 Workspace。
- 不实现 Mapping 弹窗重构。
## 涉及文件
- [mactfsui/app/routes/home.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/routes/home.tsx)
- [mactfsui/app/components/explorer/server-tree-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/server-tree-panel.tsx)
- [mactfsui/app/components/app/app-shell.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app/app-shell.tsx)
- [mactfsui/app/lib/api](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api)
## 验收标准
- 登录页可以选择 Collection。
- 登录页默认选中上次使用的 Collection。
- 进入工作台后不能切换 Collection。
- 左侧树只展示当前固定 Collection 的目录。
- 顶部展示服务器、Collection、Workspace。
- 前端没有 Workspace 选择控件。
## 测试方式
```bash
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
pnpm typecheck
```
## 完成记录
待完成后填写:
- 实际修改文件:
- 实际实现内容:
- 测试结果:
- 遗留问题:
@@ -0,0 +1,85 @@
# UIR-003 重构工作台布局与同步导航
## 状态
todo
## 优先级
P0
## 所属阶段
frontend-refactor
## 依赖任务
- UIR-002
- FE-003
- FE-005
- FE-013
## 需求来源
- [UIR-000-前端重构与UI功能调整方案.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-000-前端重构与UI功能调整方案.md)
## 目标
重构主工作台布局,让左侧 Source Tree、右侧 Changes、底部操作日志都支持收起,并保证左侧树和中间文件列表导航状态一致。
## 实现范围
- `AppShell` 删除顶部文件/目录操作按钮。
- 顶部只保留连接状态、服务器、Collection、Workspace、刷新和设置等全局入口。
- 左侧 Source Tree 支持展开/收起。
- 左侧收起后只显示展开按钮,不显示路径或图标列表。
- 右侧 Changes 支持展开/收起。
- 右侧收起后只显示展开按钮,不显示 Pending Changes 数量徽标。
- 底部操作日志支持展开/收起。
- 不实现拖拽调整宽度。
- 当前路径和左侧目录树展开路径统一提升到 `home.tsx`。
- 用户在中间双击或右键“打开”进入目录时,左侧树自动展开并选中对应节点。
- 用户在左侧树选择目录时,中间文件列表切换到同一路径。
- 中间文件列表单击只选中,不进入目录。
- 第一版不支持键盘快捷键。
## 不在范围
- 不做拖拽调整宽度。
- 不做自定义布局保存。
- 不做键盘快捷键。
- 不做文件查看、Diff、History 弹窗。
## 涉及文件
- [mactfsui/app/components/app/app-shell.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app/app-shell.tsx)
- [mactfsui/app/routes/home.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/routes/home.tsx)
- [mactfsui/app/components/explorer/server-tree-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/server-tree-panel.tsx)
- [mactfsui/app/components/explorer/folder-items-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/folder-items-panel.tsx)
- [mactfsui/app/components/app/operation-log-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app/operation-log-panel.tsx)
## 验收标准
- 顶部不再出现 Get Latest、History 等文件操作按钮。
- 左侧、右侧、底部可以收起和展开。
- 收起状态只显示展开按钮。
- 中间文件列表空间明显增大。
- 左侧选择目录会刷新中间列表。
- 中间进入目录会展开并选中左侧对应目录。
- 单击文件列表行只选中,双击文件夹才进入。
## 测试方式
```bash
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
pnpm typecheck
```
## 完成记录
待完成后填写:
- 实际修改文件:
- 实际实现内容:
- 测试结果:
- 遗留问题:
@@ -0,0 +1,83 @@
# UIR-004 实现对象右键菜单
## 状态
todo
## 优先级
P0
## 所属阶段
frontend-refactor
## 依赖任务
- UIR-003
- FE-008
- FE-009
## 需求来源
- [UIR-000-前端重构与UI功能调整方案.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-000-前端重构与UI功能调整方案.md)
## 目标
将文件、文件夹、目录树节点、目录对比结果项、Pending Changes 项的对象操作统一迁移到右键菜单。
## 实现范围
- 左侧目录树节点接入右键菜单。
- 中间文件列表文件夹接入右键菜单。
- 中间文件列表文件接入右键菜单。
- Pending Changes Included / Excluded 项接入右键菜单。
- 目录对比结果项接入右键菜单。
- 取消中间文件列表选中项底部操作区。
- 文件夹菜单包含:打开、获取最新版本、签出、新增本地文件到服务器、删除、撤销挂起更改、查看历史、与服务器目录对比、映射到本地。
- 文件菜单包含:查看、获取最新版本、签出、删除、撤销挂起更改、查看历史、比较本地与服务器 Latest。
- Pending Changes 文件项包含:查看、比较本地与服务器 Latest、撤销、查看历史、移到 Included、移到 Excluded。
- Pending Changes 文件夹项不展示内容比较。
- pending add 文件只显示查看和撤销,不显示比较本地与服务器 Latest。
- Checkin 不放到单个文件或文件夹右键菜单。
- 根据 mapped、pending edit、pending add、folder、localPath 等状态控制菜单可用性。
## 不在范围
- 不实现具体弹窗内容。
- 不实现键盘快捷键。
- 不实现 Checkin 弹窗。
- 不实现手动冲突块编辑。
## 涉及文件
- [mactfsui/app/components/explorer/server-tree-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/server-tree-panel.tsx)
- [mactfsui/app/components/explorer/folder-items-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/folder-items-panel.tsx)
- [mactfsui/app/components/inspector/pending-changes-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/inspector/pending-changes-panel.tsx)
- [mactfsui/app/components](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components)
## 验收标准
- 左侧树、文件列表、Pending Changes 均可打开右键菜单。
- 顶部和底部不再承担文件对象操作。
- pending edit 文件禁止直接 Get Latest。
- pending add 文件不显示服务器比较。
- 文件夹与服务器目录对比入口可用。
- 文件比较本地与服务器 Latest 入口可用。
- Checkin 仍只在右侧 Changes 面板提交 Included Changes。
## 测试方式
```bash
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
pnpm typecheck
```
## 完成记录
待完成后填写:
- 实际修改文件:
- 实际实现内容:
- 测试结果:
- 遗留问题:
@@ -0,0 +1,81 @@
# UIR-005 实现 Mapping、History、目录对比弹窗
## 状态
todo
## 优先级
P0
## 所属阶段
frontend-refactor
## 依赖任务
- UIR-004
- FE-006
- FE-007
- FE-011
## 需求来源
- [UIR-000-前端重构与UI功能调整方案.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-000-前端重构与UI功能调整方案.md)
## 目标
将 Mapping、History、目录对比从中间文件列表内嵌区域迁移到弹窗,释放主工作区空间。
## 实现范围
- Mapping 创建改为弹窗。
- Mapping 弹窗展示服务端路径、本地目录、是否立即 Get Latest、创建按钮。
- Mapping 本地目录由用户选择或输入。
- 是否立即 Get Latest 由用户选择,不强制。
- History 改为大尺寸弹窗。
- History 弹窗展示目标路径、changeset 列表、目录 changeset 文件列表。
- History 文件版本支持进入 Diff。
- 目录对比改为弹窗。
- 目录对比弹窗展示服务端路径、本地路径、重新对比、隐藏已同步。
- 目录对比弹窗支持按状态筛选,例如本地修改、服务器更新、本地新增、待删除。
- 目录对比结果项操作使用右键菜单。
- 新增本地文件到服务器只处理 `localOnly` 项,用户选中后加入 pending add。
## 不在范围
- 不实现文件查看弹窗。
- 不实现 Diff 编辑器升级。
- 不实现 Get Latest 冲突弹窗。
- 不做新路由。
## 涉及文件
- [mactfsui/app/components/explorer/folder-items-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/folder-items-panel.tsx)
- [mactfsui/app/components/explorer/history-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/history-panel.tsx)
- [mactfsui/app/routes/home.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/routes/home.tsx)
- [mactfsui/app/components](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components)
## 验收标准
- 中间文件列表不再内嵌 Mapping 表单、History 面板、目录对比结果。
- 未映射目录可以通过弹窗创建 Mapping。
- History 弹窗可以展示文件或目录历史。
- 目录对比弹窗可以筛选差异状态。
- 目录对比结果项通过右键菜单执行对象操作。
## 测试方式
```bash
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
pnpm typecheck
```
## 完成记录
待完成后填写:
- 实际修改文件:
- 实际实现内容:
- 测试结果:
- 遗留问题:
@@ -0,0 +1,81 @@
# UIR-006 实现文件查看与 Diff 编辑器
## 状态
todo
## 优先级
P0
## 所属阶段
frontend-refactor
## 依赖任务
- UIR-001
- UIR-004
- FE-012
## 需求来源
- [UIR-000-前端重构与UI功能调整方案.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-000-前端重构与UI功能调整方案.md)
## 目标
实现文件只读查看弹窗和视觉升级后的 Diff 弹窗,支持代码高亮、搜索、行号和清晰的左右分栏对比。
## 实现范围
- 文件右键“查看”打开只读文件查看弹窗。
- Pending Changes 文件项右键“查看”打开只读文件查看弹窗。
- 已映射文件查看本地文件。
- 未映射文件查看服务器 latest 文件。
- 文件查看弹窗标识当前查看来源:本地文件或服务器文件。
- 文件查看支持代码高亮、搜索、行号和滚动区域。
- 单个文件超过 5MB 时不直接渲染,只提示文件过大。
- 本地文件查看必须限制在 Mapping 目录内。
- 二进制文件或无法读取文件展示明确提示。
- Diff 弹窗改为清晰左右分栏。
- Diff 支持代码高亮、搜索、行号、差异高亮、文件路径、版本标签、刷新入口。
- 优先评估 Monaco Editor;如果 Monaco 不适合,再评估 CodeMirror 6。
## 不在范围
- 不提供文件编辑能力。
- 不做手动冲突块编辑。
- 不做多文件 Diff 标签页。
- 不做大于 5MB 文件渲染。
## 涉及文件
- [mactfsui/package.json](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/package.json)
- [mactfsui/app/components/explorer/diff-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/diff-panel.tsx)
- [mactfsui/app/components](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components)
- [mactfsui/app/lib/api](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api)
## 验收标准
- 文件查看弹窗可以查看本地映射文件。
- 未映射文件可以查看服务器内容。
- 查看弹窗有代码高亮、搜索和行号。
- Diff 弹窗有左右分栏、代码高亮、搜索、行号和差异高亮。
- 大于 5MB 文件不渲染内容。
- 本地非 Mapping 路径不能被查看。
## 测试方式
```bash
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
pnpm typecheck
```
## 完成记录
待完成后填写:
- 实际修改文件:
- 实际实现内容:
- 测试结果:
- 遗留问题:
@@ -0,0 +1,89 @@
# UIR-007 实现 Get Latest 与 Checkout 冲突流程
## 状态
todo
## 优先级
P0
## 所属阶段
frontend-refactor
## 依赖任务
- UIR-001
- UIR-006
- FE-009
## 需求来源
- [UIR-000-前端重构与UI功能调整方案.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-000-前端重构与UI功能调整方案.md)
## 目标
实现 Get Latest / Checkout 的统一冲突选择弹窗,支持逐文件选择服务器版本、本地版本或自动合并。
## 实现范围
- Get Latest 对文件夹递归执行。
- pending edit 文件禁止直接 Get Latest。
- 文件夹递归 Get Latest 遇到 pending edit 子文件时跳过并提示。
- Checkout 自动先 Get Latest,再签出。
- Checkout 自动 Get Latest 遇到冲突时复用同一个冲突弹窗。
- 冲突弹窗集中展示冲突文件列表。
- 每个冲突文件可选择使用服务器版本、保留本地版本、自动合并。
- 支持批量全部使用服务器版本。
- 支持批量全部保留本地版本。
- 批量操作后仍允许单文件改选。
- 每个冲突文件提供 Diff 入口。
- 二进制或无法文本 Diff 的文件不展示自动合并。
- 使用服务器版本时覆盖本地且保持无 pending change。
- 保留本地版本时自动签出为 pending edit。
- 自动合并需要共同基线版本、本地版本、服务器版本。
- 自动合并成功后用户确认再应用。
- 自动合并结果应用后进入 pending edit。
- 自动合并失败或存在冲突块时,只回退到服务器/本地二选一。
## 不在范围
- 不实现手动冲突块编辑。
- 不对二进制文件做自动合并。
- 不支持 pending edit 文件直接 Get Latest。
## 涉及文件
- [mactfsui/app/routes/home.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/routes/home.tsx)
- [mactfsui/app/components](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components)
- [mactfsui/app/lib/api](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api)
## 验收标准
- 文件夹 Get Latest 可以递归执行。
- pending edit 文件右键不允许直接 Get Latest。
- 递归 Get Latest 跳过 pending edit 子文件并提示。
- Checkout 自动先 Get Latest。
- Checkout 冲突复用 Get Latest 冲突弹窗。
- 冲突弹窗支持批量和单项选择。
- 冲突文件可以打开 Diff。
- 使用服务器版本后无 pending change。
- 保留本地版本后进入 pending edit。
- 自动合并结果确认后进入 pending edit。
## 测试方式
```bash
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
pnpm typecheck
```
## 完成记录
待完成后填写:
- 实际修改文件:
- 实际实现内容:
- 测试结果:
- 遗留问题:
@@ -0,0 +1,79 @@
# UIR-008 重构 Pending Changes 与 Checkin 体验
## 状态
todo
## 优先级
P0
## 所属阶段
frontend-refactor
## 依赖任务
- UIR-004
- UIR-006
- FE-008
- FE-010
## 需求来源
- [UIR-000-前端重构与UI功能调整方案.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-000-前端重构与UI功能调整方案.md)
## 目标
保留右侧 Changes 面板作为 Checkin 主入口,并为 Pending Changes 项完善右键菜单和刷新策略。
## 实现范围
- Checkin 保留在右侧 Changes 面板。
- 不实现 Checkin 弹窗。
- 右侧面板直接展示 Included / Excluded、comment 和提交按钮。
- Pending Changes 项支持右键菜单。
- 文件项支持查看、比较本地与服务器 Latest、撤销、查看历史、移到 Included、移到 Excluded。
- 文件夹项不展示内容比较。
- pending add 文件只显示查看和撤销,不显示服务器比较。
- 撤销指定 pending change 后刷新 Pending Changes。
- Checkin 成功后刷新当前目录文件列表和 Pending Changes。
- 只有涉及新增或删除目录时,刷新左侧树对应节点。
## 不在范围
- 不实现 Checkin Dialog。
- 不做 Work Item。
- 不做 Check-in Policy UI。
- 不做右侧收起数量徽标。
## 涉及文件
- [mactfsui/app/components/inspector/pending-changes-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/inspector/pending-changes-panel.tsx)
- [mactfsui/app/routes/home.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/routes/home.tsx)
- [mactfsui/app/lib/api](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api)
## 验收标准
- Checkin 不使用弹窗。
- Included / Excluded 仍可维护。
- Pending Changes 项右键菜单可用。
- pending add 文件没有比较入口。
- Checkin 成功后 Pending Changes 清空或更新。
- 当前目录状态刷新。
## 测试方式
```bash
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
pnpm typecheck
```
## 完成记录
待完成后填写:
- 实际修改文件:
- 实际实现内容:
- 测试结果:
- 遗留问题:
@@ -0,0 +1,79 @@
# UIR-009 拆分 FolderItemsPanel 与动作编排
## 状态
todo
## 优先级
P1
## 所属阶段
frontend-refactor
## 依赖任务
- UIR-005
- UIR-006
- UIR-007
- UIR-008
## 需求来源
- [UIR-000-前端重构与UI功能调整方案.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-000-前端重构与UI功能调整方案.md)
## 目标
拆分当前过重的 `FolderItemsPanel`,让中间文件列表只负责目录项展示、选择和进入目录,复杂功能由弹窗和动作编排承担。
## 实现范围
- `FolderItemsPanel` 保留加载当前目录 items。
- 保留加载 mappings 和计算 `FolderItemView`。
- 保留表格选择和双击进入目录。
- 移除内嵌 History。
- 移除内嵌 Diff。
- 移除目录对比状态和差异操作。
- 移除 Mapping 创建表单。
- 移除选中项底部操作区。
- 在 `home.tsx` 或轻量 action hook 中编排弹窗打开和文件操作。
- 统一 FileTarget / FileAction 类型,供右键菜单、弹窗和操作函数复用。
- 保持项目现有风格,不引入大型状态库。
## 不在范围
- 不引入 Redux、Zustand、TanStack Query。
- 不重构无关 API client。
- 不改变已确认业务规则。
## 涉及文件
- [mactfsui/app/components/explorer/folder-items-panel.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/folder-items-panel.tsx)
- [mactfsui/app/routes/home.tsx](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/routes/home.tsx)
- [mactfsui/app/components](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components)
- [mactfsui/app/lib](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib)
## 验收标准
- `FolderItemsPanel` 不再包含 History、Diff、Compare、Mapping 大块 JSX。
- 中间文件列表只展示目录项和必要状态。
- 所有对象操作仍可通过右键菜单触发。
- 弹窗打开和关闭状态清晰可维护。
- `pnpm typecheck` 通过。
## 测试方式
```bash
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
pnpm typecheck
```
## 完成记录
待完成后填写:
- 实际修改文件:
- 实际实现内容:
- 测试结果:
- 遗留问题:
@@ -0,0 +1,90 @@
# UIR-010 前端重构端到端验收
## 状态
todo
## 优先级
P0
## 所属阶段
frontend-refactor
## 依赖任务
- UIR-001
- UIR-002
- UIR-003
- UIR-004
- UIR-005
- UIR-006
- UIR-007
- UIR-008
- UIR-009
## 需求来源
- [UIR-000-前端重构与UI功能调整方案.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor/UIR-000-前端重构与UI功能调整方案.md)
## 目标
对前端重构后的核心流程做端到端验收,确认工作台布局、右键菜单、弹窗、文件查看、Diff、冲突处理、Checkin 都符合设计方案。
## 实现范围
- 验证登录页 Collection 默认选中和固定上下文。
- 验证 Workspace 自动使用或创建。
- 验证工作台左右和底部收起。
- 验证左侧树和中间文件列表同步导航。
- 验证左侧树、文件列表、Pending Changes 右键菜单。
- 验证 Mapping 弹窗。
- 验证 History 弹窗。
- 验证目录对比弹窗和状态筛选。
- 验证文件查看弹窗代码高亮、搜索、行号。
- 验证 Diff 弹窗左右分栏、搜索、行号、差异高亮。
- 验证 Get Latest 递归、pending edit 跳过、冲突弹窗。
- 验证 Checkout 自动 Get Latest 和冲突复用。
- 验证 Pending Changes 右键撤销、查看、比较、Included/Excluded。
- 验证 Checkin 后刷新当前目录和 Pending Changes。
## 不在范围
- 不做 Release 打包。
- 不做 Work Item。
- 不做 Check-in Policy UI。
- 不做手动冲突块编辑。
## 涉及文件
- [mactfsui](/Users/fenghp/Desktop/DEV/project/mydev/mactfsui)
- [testing](/Users/fenghp/Desktop/DEV/project/mydev/testing)
- [task/frontend-refactor](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend-refactor)
## 验收标准
- 所有 UIR 阶段任务通过 `pnpm typecheck`。
- Electron 环境下可以完成登录、浏览、Mapping、目录对比、文件查看、Diff、Get Latest、Checkout、Pending Changes、Checkin 基本流程。
- 未映射文件查看服务器内容。
- 已映射文件查看本地内容。
- 大文件不直接渲染。
- 本地非 Mapping 路径不能查看。
- 冲突处理三种选择结果符合文档。
## 测试方式
```bash
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
pnpm typecheck
pnpm electron:dev
```
## 完成记录
待完成后填写:
- 实际修改文件:
- 实际实现内容:
- 测试结果:
- 遗留问题:
@@ -2,7 +2,7 @@
## 状态
todo
done
## 优先级
@@ -59,4 +59,37 @@ pnpm typecheck
## 完成记录
待完成后填写。
- 完成日期:2026-06-08
- 实际修改文件:
- `mactfsui/electron/main.cjs`
- `mactfsui/electron/preload.cjs`
- `mactfsui/app/lib/electron/bridge.ts`
- `mactfsui/app/lib/api/client.ts`
- `mactfsui/app/lib/api/types.ts`
- `mactfsui/app/routes/home.tsx`
- `mactfsui/package.json`
- `task/README.md`
- `task/frontend/FE-001-实现Electron启动服务与API客户端.md`
- 实际实现内容:
- Electron 主进程增加 preload 配置、contextIsolation、nodeIntegration 关闭。
- Electron 主进程增加本地 token 读取、`/api/health` 检查、未运行时通过 `../tfsIntegration/gradlew runServer` 拉起本地 Java API 服务。
- 新增 preload 窄接口:`getToken`、`getServiceStatus`。
- 新增 renderer 侧 Electron bridge 类型封装。
- 新增统一 API client,所有请求自动读取 token 并附加 `Authorization: Bearer <token>`。
- 首页替换脚手架内容,展示本地 API 服务状态、token 路径和请求状态;服务未就绪时给出明确提示。
- 调整 `electron:dev` 等待条件为 `wait-on tcp:5173`,避免 React Router dev 的 HTTP 探测差异阻塞 Electron 启动。
- 已执行测试:
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && node -c electron/main.cjs`:通过。
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && node -c electron/preload.cjs`:通过。
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfs && ../tfsIntegration/gradlew runServer` 后请求 `/api/health`:通过,返回 `success=true`。
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm electron:dev`:通过,Electron 启动后拉起 Java API 服务,`curl /api/health` 返回 `success=true`。
- 内置浏览器打开 `http://127.0.0.1:5173/`:通过,非 Electron 环境显示明确的 preload 缺失提示,不是空白页。
- 未执行测试及原因:
- 未执行真实 TFS 连接、目录浏览或文件操作;本任务只要求本地服务、health 和 API client,不涉及真实 TFS 业务操作。
- 验收标准确认:
- Electron 启动后可连接本地 API:满足,`electron:dev` 启动后 38765 端口监听,health 返回 `success=true`。
- API 请求能自动带 token:满足,`apiRequest` 统一从 preload 获取 token 并设置 Bearer header,首页通过该 client 请求 health。
- 服务未启动时 UI 有明确提示:满足,首页根据 bridge/service 状态展示未就绪或 preload 缺失提示。
- 遗留问题:
- 生产环境打包启动命令仍待 Release 阶段确定,当前按前端规范使用开发期 Gradle `runServer`。
+31 -2
View File
@@ -2,7 +2,7 @@
## 状态
todo
done
## 优先级
@@ -64,4 +64,33 @@ pnpm typecheck
## 完成记录
待完成后填写。
- 完成日期:2026-06-08
- 实际修改文件:
- `mactfsui/app/lib/api/types.ts`
- `mactfsui/app/lib/api/endpoints.ts`
- `mactfsui/app/routes/home.tsx`
- `task/README.md`
- `task/frontend/FE-002-实现登录配置页.md`
- 实际实现内容:
- 新增 `AppConfig`、`ConfigData`、`ConnectData` 等前端 API 类型。
- 新增 `getConfig`、`connectSession` API endpoint 封装,复用 FE-001 的统一 `apiRequest` 自动 Bearer token 能力。
- 首页增加连接配置表单,支持 `serverUri`、`authType`、`domain`、`username`、`password`。
- 页面初始化时在本地服务可用后读取 `/api/config`,将默认配置回填到表单。
- 点击连接后调用 `/api/session/connect`,展示连接中状态;失败展示后端错误,成功进入已连接工作台状态。
- 保留右侧本地服务状态面板,服务未启动或 preload 缺失时继续显示明确提示。
- 已执行测试:
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && node -c electron/main.cjs && node -c electron/preload.cjs`:通过。
- `git diff --check`:通过。
- 内置浏览器打开 `http://127.0.0.1:5173/`:通过,连接表单与服务状态面板可见,非 Electron 环境显示 preload 缺失提示。
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm electron:dev`:通过,Electron 启动后本地 API health 与 config 接口可访问。
- 使用无效 TFS 地址请求 `/api/session/connect`:通过,后端返回 `success=false` 和错误信息,配置未被保存。
- 未执行测试及原因:
- 未执行真实 TFS 账号的连接成功验证;当前环境没有可用于验收的真实 TFS 凭据,且任务规则要求谨慎处理真实 TFS 操作。
- 验收标准确认:
- 可填写配置并调用连接 API:满足,表单字段完整,连接提交走 `connectSession`。
- 连接成功进入主界面:满足,`connectSession` 返回成功后进入已连接工作台状态;真实 TFS 成功连接未在当前环境执行。
- 连接失败显示错误:满足,无效连接返回的错误会写入页面错误提示。
- 再次打开能回填配置:满足,页面初始化读取 `/api/config` 并回填表单。
- 遗留问题:
- 真实 TFS 成功连接需要在具备有效 TFS 地址和账号后做端到端验收。
@@ -2,7 +2,7 @@
## 状态
todo
done
## 优先级
@@ -58,4 +58,31 @@ pnpm typecheck
## 完成记录
待完成后填写。
- 完成日期:2026-06-08
- 实际修改文件:
- `mactfsui/app/components/app/app-shell.tsx`
- `mactfsui/app/routes/home.tsx`
- `mactfsui/app/app.css`
- `task/README.md`
- `task/frontend/FE-003-实现VS风格主布局.md`
- 实际实现内容:
- 新增 `AppShell` 主工作台组件,包含顶部工具栏、左侧 Source List、中间 Source Workspace、右侧 Inspector、底部 Console。
- 首页改为通过 `AppShell` 承载连接配置页和已连接后的文件列表占位,保留 FE-002 的连接表单逻辑。
- 右侧 Inspector 预留 Pending Changes 区域,并展示本地 API 服务状态。
- 底部 Console 展示 service、request、session 三类基础日志状态。
- 增加桌面与窄屏响应式网格:桌面三栏,较窄宽度下 Inspector 下移。
- 将全局字体从脚手架 slab serif 调整为 macOS/system sans,符合 `mactfsui/AGENTS.md` 的 macOS Source Workspace 风格要求。
- 已执行测试:
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
- `git diff --check`:通过。
- `find /Users/fenghp/Desktop/DEV/project/mydev/task -type f -name "*.md" | wc -l`:通过,结果为 `50`。
- 内置浏览器打开 `http://127.0.0.1:5173/`:通过,顶部工具栏、Source List、Source Workspace、Inspector、Console 均可见;截图检查未发现空白或重叠。
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm electron:dev`:通过,Electron 启动后本地 API health 返回 `success=true`。
- 未执行测试及原因:
- 未执行真实 Collection、目录树、Pending Changes 数据加载;本任务只实现布局区域,不实现具体业务数据。
- 验收标准确认:
- UI 不再是默认模板页:满足,首页已替换为 macOS Source Workspace 工作台。
- 主界面布局与 PRD 区域一致:满足,包含顶部连接/Workspace 信息、左侧目录树区、中间文件区、右侧挂起更改区、底部操作日志区。
- 桌面窗口下可正常使用:满足,浏览器与 Electron dev 验证通过。
- 遗留问题:
- 具体 Collection 目录树、文件列表、Pending Changes 数据加载留给后续 FE-004、FE-005、FE-008。
@@ -2,7 +2,7 @@
## 状态
todo
done
## 优先级
@@ -58,4 +58,32 @@ pnpm typecheck
## 完成记录
待完成后填写。
- 完成日期:2026-06-08
- 实际修改文件:
- `mactfsui/app/lib/api/types.ts`
- `mactfsui/app/lib/api/endpoints.ts`
- `mactfsui/app/components/app/app-shell.tsx`
- `mactfsui/app/components/explorer/server-tree-panel.tsx`
- `mactfsui/app/routes/home.tsx`
- `task/README.md`
- `task/frontend/FE-004-实现Collection服务端目录树.md`
- 实际实现内容:
- 新增 Collection、服务端目录节点相关前端类型。
- 新增 `listCollections`、`listServerTree` API endpoint 封装,继续复用统一 Bearer token client。
- `AppShell` 左侧 Source List 支持传入业务树组件。
- 新增 `ServerTreePanel`,连接成功后加载 Collection,默认加载第一个 Collection 的 `$/` 根目录。
- 展开目录时按服务端路径调用 `/api/server-tree` 懒加载子节点,不要求目录已映射到本地。
- 选中 Collection 或目录节点后通过回调更新首页当前 Collection 和当前服务端路径,中间区域展示当前路径。
- 已执行测试:
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
- `git diff --check`:通过。
- `find /Users/fenghp/Desktop/DEV/project/mydev/task -type f -name "*.md" | wc -l`:通过,结果为 `50`。
- 内置浏览器打开 `http://127.0.0.1:5173/`:通过,未连接状态下 Source List、主内容、Inspector、Console 均稳定显示。
- 未执行测试及原因:
- 未执行真实 TFS Collection 与目录树成功浏览;当前环境没有可用于验收的真实 TFS 连接配置,且任务规则要求谨慎处理真实 TFS 操作。
- 验收标准确认:
- 可浏览 Collection 下目录树:代码路径满足,真实 TFS 数据未在当前环境执行。
- 展开节点不要求本地已映射:满足,展开时只使用服务端 `collection + path` 调用 `/api/server-tree`。
- 选中目录能更新当前路径:满足,`ServerTreePanel` 通过 `onPathSelect` 通知首页并更新中间区域当前路径。
- 遗留问题:
- 需要在具备真实 TFS 连接后补充 Collection / 目录树端到端验收。
@@ -2,7 +2,7 @@
## 状态
todo
done
## 优先级
@@ -58,4 +58,33 @@ pnpm typecheck
## 完成记录
待完成后填写。
- 完成日期:2026-06-08
- 实际修改文件:
- `mactfsui/app/lib/api/types.ts`
- `mactfsui/app/lib/api/endpoints.ts`
- `mactfsui/app/components/explorer/folder-items-panel.tsx`
- `mactfsui/app/routes/home.tsx`
- `task/README.md`
- `task/frontend/FE-005-实现中间目录文件列表.md`
- 实际实现内容:
- 新增 Mapping 与目录文件列表相关前端类型。
- 新增 `listMappings`、`listServerFolderItems` API endpoint 封装。
- 新增 `FolderItemsPanel`,根据当前 Collection 和服务端路径加载下一级文件 / 文件夹。
- 中间列表展示名称、类型、服务端路径、本地路径、映射状态、状态。
- 根据 Mapping 计算本地路径;已映射项展示 `notDownloaded / 未下载` 状态,未映射项展示 `未映射`。
- 支持文件 / 目录行选中,并在底部展示已选中路径。
- 基础操作入口按映射状态展示:未映射项只保留 History,已映射项显示 Get Latest / Checkout 入口。
- 已执行测试:
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
- `git diff --check`:通过。
- `find /Users/fenghp/Desktop/DEV/project/mydev/task -type f -name "*.md" | wc -l`:通过,结果为 `50`。
- 内置浏览器打开 `http://127.0.0.1:5173/`:通过,未连接状态下主布局和中间区域稳定显示。
- 未执行测试及原因:
- 未执行真实 TFS 目录点击后加载文件列表;当前环境没有可用于验收的真实 TFS 连接配置。
- 未验证真实本地文件是否存在;当前后端目录接口未返回本地存在状态,前端只能根据 Mapping 计算本地路径并展示 `notDownloaded / 未下载` 的基础状态。
- 验收标准确认:
- 目录点击后中间列表更新:代码路径满足,左侧选中路径会传给 `FolderItemsPanel` 并触发 `/api/server-folder/items`。
- 未映射目录不展示本地操作:满足,未映射项不显示 Get Latest / Checkout 本地操作入口。
- 已映射未下载项状态清晰:满足,已映射项展示 `notDownloaded / 未下载` 状态;真实本地存在性需后续后端状态字段或目录对比补强。
- 遗留问题:
- 真实 TFS 目录文件列表、真实本地存在状态需要后续端到端环境补验。
@@ -2,7 +2,7 @@
## 状态
todo
done
## 优先级
@@ -59,4 +59,36 @@ pnpm typecheck
## 完成记录
待完成后填写。
- 完成日期:2026-06-08
- 实际修改文件:
- `mactfsui/electron/main.cjs`
- `mactfsui/electron/preload.cjs`
- `mactfsui/app/lib/electron/bridge.ts`
- `mactfsui/app/lib/api/types.ts`
- `mactfsui/app/lib/api/endpoints.ts`
- `mactfsui/app/components/explorer/folder-items-panel.tsx`
- `task/README.md`
- `task/frontend/FE-006-实现Mapping创建流程.md`
- 实际实现内容:
- Electron 主进程新增本地目录选择器 IPC:`mactfs:select-directory`。
- preload 和 renderer bridge 新增 `selectDirectory()`。
- 新增 `AddMappingRequest`、`AddMappingData` 类型和 `addMapping` endpoint 封装。
- 中间目录面板在当前服务端目录未映射时展示“映射到本地”。
- Mapping 表单支持选择本地目录、手动输入本地路径、选择是否立即 `Get Latest`。
- 调用 `/api/mappings` 创建 Mapping,成功后刷新当前目录列表和 Mapping 状态。
- 已执行测试:
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && node -c electron/main.cjs && node -c electron/preload.cjs`:通过。
- `git diff --check`:通过。
- `find /Users/fenghp/Desktop/DEV/project/mydev/task -type f -name "*.md" | wc -l`:通过,结果为 `50`。
- 内置浏览器打开 `http://127.0.0.1:5173/`:通过,未连接状态下主布局稳定显示。
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm electron:dev`:通过,Electron 启动后本地 API health 返回 `success=true`。
- 未执行测试及原因:
- 未执行真实 Mapping 创建;当前环境没有可用于验收的真实 TFS 连接、Workspace 和本地目录选择上下文。
- 未实际点击系统目录选择弹窗;已完成 main/preload/bridge 代码接入和 CJS 语法校验。
- 验收标准确认:
- 可创建 Mapping:代码路径满足,提交 `/api/mappings`。
- 可选择是否立即 Get Latest:满足,表单提供 `立即 Get Latest` 选项并传递 `getLatest`。
- 不立即下载时后续仍可对下级文件 Get Latest:满足,不勾选时仅保存 Mapping;下级文件列表会根据刷新后的 Mapping 状态展示 Get Latest 入口。
- 遗留问题:
- 真实 Mapping 创建和目录选择弹窗需要在具备有效 TFS 连接后做端到端验收。
@@ -2,7 +2,7 @@
## 状态
todo
done
## 优先级
@@ -59,4 +59,31 @@ pnpm typecheck
## 完成记录
待完成后填写。
- 实际修改文件:
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/types.ts`
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/endpoints.ts`
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/folder-items-panel.tsx`
- `/Users/fenghp/Desktop/DEV/project/mydev/task/README.md`
- `/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-007-实现目录对比页面.md`
- 实际实现内容:
- 新增目录对比请求、响应和差异状态类型。
- 新增 `compareFolder` 统一 API 封装,对接 `POST /api/compare/folder` 的 `diffs` 返回结构。
- 在中间目录文件面板增加“目录对比”区域,已映射目录可执行 Compare,未映射目录仅展示不可对比状态。
- 目录对比请求期间展示“正在执行目录对比,TFS 网络请求可能需要一段时间。”。
- 对比结果默认隐藏 `upToDate`,支持切换显示状态。
- 差异列表支持逐项勾选和当前筛选结果批量勾选。
- 按状态展示可用操作入口:`checkout`、`diff`、`add`、`delete local`、`delete`、`restore from server`、`get latest`、`checkin`、`undo`、`手工处理`。
- 已执行测试:
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
- `git diff --check`:通过。
- Browser 打开 `http://127.0.0.1:5173/`:页面工作台结构可渲染,连接页、Source Workspace、Inspector、Console 可见。
- 未执行测试及原因:
- 未执行真实 TFS 目录对比请求;当前环境没有真实 TFS 凭据、已连接会话和真实 Mapping,且该操作依赖外部 TFS 网络状态。
- Browser 截图调用超时,但 DOM 快照已确认本地页面基础结构渲染正常。
- 是否满足验收标准:
- 可展示 `localModified`、`localOnly`、`notDownloaded`、`pendingEdit`、`pendingAdd`、`pendingDelete` 等状态文案和操作入口。
- 可勾选指定差异文件,并可批量勾选当前筛选结果。
- 未映射目录不显示 Compare 执行按钮,并展示不能执行对比的状态。
- 遗留问题:
- 当前任务只展示可用操作入口,不执行真实文件操作;具体操作交互属于后续 FE-009。
- 真实 TFS 成功路径需要在具备测试 Collection、Workspace 和 Mapping 后做端到端验收。
@@ -2,7 +2,7 @@
## 状态
todo
done
## 优先级
@@ -59,4 +59,34 @@ pnpm typecheck
## 完成记录
待完成后填写。
- 实际修改文件:
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/types.ts`
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/endpoints.ts`
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app/app-shell.tsx`
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/inspector/pending-changes-panel.tsx`
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/routes/home.tsx`
- `/Users/fenghp/Desktop/DEV/project/mydev/task/README.md`
- `/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-008-实现右侧挂起更改面板.md`
- 实际实现内容:
- 新增 `TfsPendingChangeInfo`、`PendingChangeStatus`、`PendingChangesData` 类型。
- 新增 `listPendingChanges` API 封装,对接 `GET /api/pending-changes` 的 `pendingChanges` 返回结构。
- 新增右侧 `PendingChangesPanel`,连接后查询当前 Workspace 的 pending changes。
- 后端返回的 pending changes 默认进入 Included Changes。
- 支持单个文件在 Included Changes 和 Excluded Changes 之间移动。
- Included / Excluded 状态只维护在 React 当前 UI 会话内,不写入后端或本地存储。
- AppShell 支持传入自定义 Inspector 内容,Home 中接入 PendingChangesPanel。
- 面板展示“后续签入只读取 Included Changes”及 Included 数量,供后续签入流程读取 Included 语义。
- 已执行测试:
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
- `git diff --check`:通过。
- Browser 打开 `http://127.0.0.1:5173/`:右侧 Inspector 可渲染 Pending Changes 面板;当前非 Electron 环境显示未连接状态。
- 未执行测试及原因:
- 未执行真实 pending changes 查询成功路径;当前环境没有真实 TFS 凭据、已连接会话和真实 Workspace pending changes。
- 未执行真实签入;签入流程属于后续 FE-010,本任务只维护 Included / Excluded 会话状态。
- 是否满足验收标准:
- pending changes 查询入口已实现,连接后会在右侧面板展示。
- 后端返回的 pending changes 默认进入 Included Changes。
- 用户可通过每项右侧按钮在 Included / Excluded 之间移动。
- 面板明确保留 Included Changes 作为后续签入读取范围。
- 遗留问题:
- 真实 TFS pending changes 展示和移动后的签入行为,需要在 FE-010 或后续端到端验收任务中验证。
@@ -2,7 +2,7 @@
## 状态
todo
done
## 优先级
@@ -62,4 +62,39 @@ pnpm typecheck
## 完成记录
待完成后填写。
- 实际修改文件:
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/types.ts`
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/endpoints.ts`
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/routes/home.tsx`
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/folder-items-panel.tsx`
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/inspector/pending-changes-panel.tsx`
- `/Users/fenghp/Desktop/DEV/project/mydev/task/README.md`
- `/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-009-实现文件操作交互.md`
- 实际实现内容:
- 新增 Get Latest、checkout、add、delete、undo 的前端 API 类型和 endpoint 封装。
- 目录对比结果中按状态展示可执行文件操作按钮:
- `notDownloaded`、`remoteChanged`、`remoteOnly` 可 Get Latest。
- `localModified` 可 checkout。
- `localOnly` 可 add。
- `localDeleted` 可 pending delete。
- `pendingEdit`、`pendingAdd`、`pendingDelete` 可 undo。
- 目录 checkout 前使用当前选择项数量做二次确认。
- 操作成功后展示影响数量,并提示重新 Compare 刷新差异。
- Get Latest 成功后刷新当前目录列表。
- checkout、add、delete、undo 成功后触发右侧 Pending Changes 刷新。
- 文件列表底部选中项接入 Get Latest 和 Checkout 操作入口。
- 右侧 Pending Changes 面板新增每项 Undo 操作入口,并支持外部刷新信号。
- 已执行测试:
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
- `git diff --check`:通过。
- Browser 打开 `http://127.0.0.1:5173/`:工作台、Inspector、Pending Changes 基础结构可渲染;当前非 Electron 环境显示未连接状态。
- 未执行测试及原因:
- 未执行真实 checkout、add、delete、undo、get latest;这些操作会修改真实 TFS Workspace 或本地工作区,当前环境没有真实 TFS 凭据和测试 Workspace。
- 目录 checkout 的真实影响数量由当前选择项数量确认,不做递归文件数预扫描;后续端到端验收可结合真实 TFS 数据校验。
- 是否满足验收标准:
- 目录 checkout 前会显示二次确认并包含当前影响项数量。
- 不同 diff 状态只展示对应文件操作入口。
- 操作后会刷新当前目录列表或右侧 Pending Changes,并提示重新 Compare。
- 遗留问题:
- 未实现本地未版本控制文件的直接删除;当前任务范围只要求 `localOnly` 可 add。
- 真实操作成功路径需要在后续端到端验收中验证。
+29 -2
View File
@@ -2,7 +2,7 @@
## 状态
todo
done
## 优先级
@@ -61,4 +61,31 @@ pnpm typecheck
## 完成记录
待完成后填写。
- 实际修改文件:
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/types.ts`
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/endpoints.ts`
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/inspector/pending-changes-panel.tsx`
- `/Users/fenghp/Desktop/DEV/project/mydev/task/README.md`
- `/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-010-实现签入流程.md`
- 实际实现内容:
- 新增 `CheckinRequest`、`TfsCheckinResult`、`CheckinData` 类型。
- 新增 `checkin` API 封装,对接 `POST /api/checkin`。
- 在右侧 Pending Changes 面板新增 Checkin 区域。
- 新增 comment 输入框,comment 为空时 Checkin 按钮不可用,并在执行函数内保留必填校验。
- Checkin 只提交当前 Included Changes 的 `serverPath`,Excluded Changes 不参与提交。
- 签入中禁用刷新、移动和提交按钮,并显示 loading 状态。
- 签入成功后显示 changeset 和提交数量,清空 comment 与 Included Changes,保留 Excluded 会话状态。
- 签入失败时展示 `errorMessage || message`,用于直接显示 TFS policy、权限、冲突等错误。
- 已执行测试:
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
- `git diff --check`:通过。
- Browser 打开 `http://127.0.0.1:5173/`:工作台、Inspector、Pending Changes 基础结构可渲染;当前非 Electron 环境显示未连接状态。
- 未执行测试及原因:
- 未执行真实 checkin;该操作会提交真实 TFS Workspace 的 pending changes,当前环境没有真实测试凭据、Workspace 和可提交变更。
- 是否满足验收标准:
- comment 为空不能签入。
- Excluded Changes 不会进入 checkin 请求。
- 成功后 Included 清空。
- Excluded 保留在当前 UI 会话内。
- 遗留问题:
- 真实签入成功、policy 失败、权限失败、冲突失败路径需要在后续端到端验收中验证。
@@ -2,7 +2,7 @@
## 状态
todo
done
## 优先级
@@ -59,4 +59,34 @@ pnpm typecheck
## 完成记录
待完成后填写。
- 实际修改文件:
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/types.ts`
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/endpoints.ts`
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/folder-items-panel.tsx`
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/history-panel.tsx`
- `/Users/fenghp/Desktop/DEV/project/mydev/task/README.md`
- `/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-011-实现历史记录界面.md`
- 实际实现内容:
- 新增 `TfsHistoryEntry`、`HistoryData`、`ChangesetFilesData` 类型。
- 新增 `queryHistory` 和 `queryChangesetFiles` API 封装。
- 新增 `HistoryPanel`,展示文件或目录最近历史记录。
- 历史列表展示 changeset、变更类型、作者、时间、comment。
- 当前目录和选中文件 / 目录均可打开 History。
- 目录历史可点击 changeset 查看影响文件列表。
- 文件历史支持选择两个历史版本并进入 Diff 入口。
- changeset 文件列表支持单文件 Diff 入口提示;真实 Diff 渲染由后续 FE-012 接入。
- 已执行测试:
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
- `git diff --check`:通过。
- Browser 打开 `http://127.0.0.1:5173/`:工作台、Inspector、Source Workspace 基础结构可渲染;当前非 Electron 环境显示未连接状态。
- 未执行测试及原因:
- 未执行真实历史查询成功路径;当前环境没有真实 TFS 凭据、连接会话和可查询的 TFS 路径。
- 未执行真实 Diff 渲染;完整 Diff 界面属于后续 FE-012。
- 是否满足验收标准:
- 文件可打开历史入口。
- 目录可打开历史入口。
- 目录历史 changeset 可查询并展示影响文件列表。
- 可选择历史版本或 changeset 文件进入 Diff 入口。
- 遗留问题:
- 历史 Diff 当前只记录入口选择,不渲染实际 diff 内容,待 FE-012 完成。
- 真实 TFS 历史成功路径需要在后续端到端验收中验证。
+32 -2
View File
@@ -2,7 +2,7 @@
## 状态
todo
done
## 优先级
@@ -58,4 +58,34 @@ pnpm typecheck
## 完成记录
待完成后填写。
- 实际修改文件:
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/types.ts`
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/endpoints.ts`
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/diff-panel.tsx`
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/history-panel.tsx`
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/explorer/folder-items-panel.tsx`
- `/Users/fenghp/Desktop/DEV/project/mydev/task/README.md`
- `/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-012-实现文件Diff界面.md`
- 实际实现内容:
- 新增 `DiffLocalLatestRequest`、`DiffRevisionsRequest`、`TfsTextDiff`、`TextDiffData` 类型。
- 新增 `diffLocalLatest` 和 `diffRevisions` API 封装,对接 `POST /api/diff/local-latest` 与 `POST /api/diff/revisions`。
- 新增 `DiffPanel`,支持本地文件 vs 服务器 latest、两个历史 changeset 之间的文本 diff 展示。
- `DiffPanel` 将后端统一 diff lines 转成左右两栏文本展示,并在接口失败或无 diff 内容时提示大文件、二进制或不支持类型无法展示文本 diff。
- `HistoryPanel` 支持选择两个文件历史版本后打开真实 Diff 面板。
- `FolderItemsPanel` 新增 Diff 打开状态,目录对比结果中选择单个可对比文件后可打开本地 vs latest Diff。
- 切换连接、Collection 或服务端目录时同步关闭已打开的 History 和 Diff 面板,避免旧路径内容残留。
- 已执行测试:
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
- `git diff --check`:通过。
- Browser 打开 `http://127.0.0.1:5173/`:工作台、连接页、Inspector、Console 基础结构可渲染;当前非 Electron 环境显示未检测到 preload。
- 未执行测试及原因:
- 未执行真实本地 vs latest Diff;当前环境没有真实 TFS 凭据、连接会话和可对比的映射工作区。
- 未执行真实历史版本 A/B Diff;当前环境没有可查询的 TFS 文件历史。
- 未验证真实大文件或二进制文件响应;该提示由 Diff API 失败或空 diff 响应统一展示。
- 是否满足验收标准:
- 目录对比结果已接入单文件 Diff 入口。
- 文件历史已支持选择两个版本并打开 Diff 面板。
- 大文件、二进制或不支持类型在 Diff 面板中有明确提示。
- 遗留问题:
- 真实 TFS Diff 成功路径、权限失败、二进制文件和大文件路径需要在后续端到端验收中验证。
- Browser 非 Electron 预览中控制台存在 lucide/react `Invalid hook call` 报错;当前页面 DOM 仍可渲染,且未连接状态不会挂载本次 FE-012 的 Diff 组件,建议后续作为前端整体问题单独排查。
@@ -2,7 +2,7 @@
## 状态
todo
done
## 优先级
@@ -60,4 +60,33 @@ pnpm typecheck
## 完成记录
待完成后填写。
- 实际修改文件:
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/client.ts`
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/types.ts`
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/lib/api/endpoints.ts`
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app/operation-log-panel.tsx`
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/components/app/app-shell.tsx`
- `/Users/fenghp/Desktop/DEV/project/mydev/mactfsui/app/routes/home.tsx`
- `/Users/fenghp/Desktop/DEV/project/mydev/task/README.md`
- `/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-013-实现操作日志面板.md`
- 实际实现内容:
- 在统一 `apiRequest` 中新增请求日志订阅机制,请求发出时记录 `running`,请求返回或失败时更新为成功 / 失败。
- 请求日志记录操作名称、开始时间、结束时间、耗时、路径摘要、成功 / 失败、错误信息和后端 logs。
- 新增 `TfsOperationLogEntry`、`OperationLogsData` 类型。
- 新增 `listOperationLogs` API 封装,对接 `GET /api/logs`。
- 新增 `OperationLogPanel`,替换底部静态 Console,按表格展示操作、状态、开始、结束、耗时、路径摘要和错误信息。
- `Home` 订阅统一 API 日志事件,并支持从服务端刷新最近操作日志。
- 已执行测试:
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
- `git diff --check`:通过。
- Browser 打开 `http://127.0.0.1:5173/`:底部 Console 初始显示 0 条日志和 service/request/session 兜底信息。
- Browser 点击“刷新服务端操作日志”:当前非 Electron 环境下生成 1 条 `frontendRequest` 失败日志,状态、开始时间、结束时间、耗时、路径摘要 `/api/logs` 和错误信息均可读。
- 未执行测试及原因:
- 未执行真实后端 `/api/logs` 成功路径;当前 Browser 预览没有 Electron preload,无法读取本地 API token。
- 未执行真实慢 TFS 请求期间的长时间 running 状态;当前环境没有可用真实 TFS 连接和慢请求场景。
- 是否满足验收标准:
- 通过统一 API client 发出的主要操作都会产生底部日志记录。
- 请求发出时会先显示执行中状态,TFS 慢请求期间 UI 可明确看到正在执行。
- 失败日志展示操作名称、路径摘要和 `errorMessage || message`,信息可读。
- 遗留问题:
- 真实 `/api/logs` 成功路径和慢请求 running 状态需要在后续端到端验收中验证。
@@ -198,6 +198,20 @@ env JAVA_HOME=/Users/fenghp/Desktop/DEV/project/mydev/zulu8.94.0.17-ca-jdk8.0.49
- 改用不依赖旧 Workspace 的 `--action test-connection --reuse-config true --output json` 验证旧入口兼容性,结果通过。
- 未修改 `~/.mactfs/phase-one.properties`。
修复状态:已修复。
修复时间:2026-06-08
修复内容:
- `LocalConfigStore.load()` 在读取旧 `phase-one.properties` 后,会优先合并本地 API 使用的 `~/.mactfs/config.json`。
- 旧 `--action ... --reuse-config true` 入口可复用第二阶段 API 配置中的 `serverUri`、认证字段、`collection`、`workspace` 和首条 Mapping,避免继续使用本机历史 Workspace。
修复后验证:
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfs && ../tfsIntegration/gradlew build`:通过。
- 隔离 `user.home` 只放置 `~/.mactfs/config.json` 后,执行旧入口 `--action test-connection --reuse-config true --output json`,返回 `Connection refused`,说明 CLI 已读取 `config.json` 中的 `serverUri`,不再因缺少 `--server-uri` 或旧 Workspace 失败。
## 八、未覆盖项
- 未通过 CLI API 执行 `add`、`checkout`、`delete`、`undo`、`checkin` 的真实写入链路;这些已在阶段二 API 中覆盖,本轮避免重复写入 TFS。
@@ -0,0 +1,224 @@
# macTFS 阶段四 Feature E2E 测试报告
## 一、测试依据
- AI 测试流程:`TFS-AI测试流程.md`
- 前端规范:`mactfsui/FRONTEND_SPEC.md`
- 前端 AI 规则:`mactfsui/AGENTS.md`
- Feature E2E 任务:`task/feature/FEATURE-001` 至 `task/feature/FEATURE-005`
- PRD:`docs/mactfs-api-product-prd.md`
- 执行时间:2026-06-08 12:37-12:48
- 执行入口:Electron UI、mactfs 本地 Java API、真实 TFS SDK
阶段四在当前任务体系中定义为 `Feature E2E`,测试目标是验证 UI、API、Core/TFS SDK 组成的日常 TFS 操作链路。
## 二、测试环境
- 服务地址:`http://127.0.0.1:38765`
- 前端地址:`http://localhost:5173/`
- Java:项目内 x86_64 JDK 8
- Electron:`mactfsui` 本地 Electron 应用
- 本地 API 服务启动命令:
```bash
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfs
env JAVA_HOME=/Users/fenghp/Desktop/DEV/project/mydev/zulu8.94.0.17-ca-jdk8.0.492-macosx_x64/Contents/Home \
arch -x86_64 ../tfsIntegration/gradlew -q runServer
```
- 前端启动命令:
```bash
cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui
pnpm dev
NODE_ENV=development pnpm electron
```
## 三、测试边界
写操作严格限制在测试流程指定目录:
- Collection:`PKUSEHR`
- Workspace:`mactfs-ai-subapp-pm-mactfs`
- Server Path:`$/北京排水集团人力系统/5SRC/MicroFront/subapp_pm/mactfs`
- Local Path:`/Users/fenghp/Desktop/DEV/mactfs-ai-workspace`
本轮不测试:
- 删除 Workspace
- Work Item
- Check-in Policy UI
- 三方 merge / 冲突解决器
- 二进制可视化 diff
- UI 侧再次执行真实 checkin 写操作
说明:
- API 层完成了真实 TFS 全流程写入验证。
- Electron UI 侧只做连接、服务状态、目录浏览、Pending、Console 和工作台可见性验证,避免通过手动 UI 再次产生额外 TFS 写操作。
## 四、构建与静态验证
| 验证项 | 命令 | 结果 |
|---|---|---|
| 后端 / Core 构建 | `env JAVA_HOME=... arch -x86_64 ../tfsIntegration/gradlew build` | 通过,`BUILD SUCCESSFUL` |
| 前端类型检查 | `pnpm typecheck` | 通过,`react-router typegen && tsc` 无错误 |
## 五、Feature E2E 测试结果
| Feature | 测试项 | 入口 | 结果 | 关键结果 |
|---|---|---|---|---|
| FEATURE-001 | TFS 连接 | API / Electron UI | 通过 | `collectionCount=2`,UI 显示 `macTFS 已连接` |
| FEATURE-001 | Collection 查询 | API / Electron UI | 通过 | 返回 `PE`、`PKUSEHR`,UI Source List 可切换 Collection |
| FEATURE-001 | 服务端目录浏览 | API / Electron UI | 通过 | 指定测试目录可浏览;UI 可展示 `PKUSEHR` 根目录和中间文件列表 |
| FEATURE-002 | Workspace 复用 | API | 通过 | 复用 `mactfs-ai-subapp-pm-mactfs`,`created=false` |
| FEATURE-002 | Mapping 删除 / 创建 / 查询 | API | 通过 | 测试 Mapping 删除后恢复,最终 mapping 数量为 1 |
| FEATURE-002 | 不立即 Get Latest 的 Mapping | API | 通过 | `getLatest=false` 创建 Mapping 成功,后续单独执行 Get Latest |
| FEATURE-002 | Get Latest | API | 通过 | `updated=2`,`operations=3`,`conflicts=0`,`failures=0` |
| FEATURE-003 | 干净目录对比 | API | 通过 | Get Latest 后 `diffs=0` |
| FEATURE-003 | localOnly 差异识别 | API | 通过 | 本地新增文件识别为 `localOnly` |
| FEATURE-003 | localOnly 执行 add | API | 通过 | `affected=1`,pending 状态为 `pendingAdd` |
| FEATURE-003 | pending delete 执行 undo | API | 通过 | delete 后 `pendingDelete`,undo 后 pending 清空 |
| FEATURE-004 | Pending Changes 查询 | API / Electron UI | 通过 | API 最终 pending 为 0;UI Inspector 显示 Pending Changes、Included / Excluded 分组 |
| FEATURE-004 | comment 必填 | API / UI 状态 | 通过 | 空 comment API 返回 `Missing required field: comment`;UI Checkin 按钮在 Included 为 0 或 comment 空时禁用 |
| FEATURE-004 | Checkin 新增文件 | API | 通过 | changeset `678131`,提交 1 项 |
| FEATURE-004 | Checkin 修改文件 | API | 通过 | changeset `678132`,提交 1 项 |
| FEATURE-005 | 文件历史 | API | 通过 | 测试文件 history 数量 2,最新 changeset `678132` |
| FEATURE-005 | 目录历史 | API | 通过 | 测试目录 history 数量 6,最新 changeset `678132` |
| FEATURE-005 | changeset 文件列表 | API | 通过 | changeset `678132` 返回 1 个文件,包含本轮测试文件 |
| FEATURE-005 | 本地 vs latest diff | API | 通过 | diff 行数 4,包含 `stage4 e2e edit` |
| FEATURE-005 | 两个历史版本 diff | API | 通过 | `678131` vs `678132` diff 行数 4,包含修改行 |
| Frontend | Electron preload / token / service | Electron UI | 通过 | UI 显示服务可用、token 文件 `/Users/fenghp/.mactfs/server-token` |
| Frontend | 操作日志 Console | Electron UI | 通过 | Console 显示 50+ 条 API 操作日志,成功 / 执行中状态可见 |
## 六、关键执行结果
本轮 API 全流程关键输出:
```text
health success=true, connected=false
connect collectionCount=2
collections PE,PKUSEHR
server-folder items=2
ensure-workspace workspace=mactfs-ai-subapp-pm-mactfs, created=false
create-mapping serverPath=$/北京排水集团人力系统/5SRC/MicroFront/subapp_pm/mactfs, localPath=/Users/fenghp/Desktop/DEV/mactfs-ai-workspace
get-latest updated=2, operations=3, conflicts=0, failures=0
pending-initial count=0
compare-clean diffs=0
compare-local-only status=localOnly, name=stage4-e2e-20260608124119.txt
add-file affected=1
pending-add status=pendingAdd, changeType=add, lock
checkin-empty-comment success=false, message=Missing required field: comment
checkin-add changeset=678131, submitted=1
pending-after-add-checkin count=0
checkout-file affected=1
pending-edit status=pendingEdit, changeType=lock, edit
diff-local-latest lines=4, hasEdit=true
checkin-edit changeset=678132, submitted=1
history-file count=2, latest=678132
history-folder count=6, latest=678132
changeset-files count=1, containsTest=true
diff-revisions lines=4, hasEdit=true
delete-file affected=1
pending-delete status=pendingDelete, changeType=delete, lock
undo-delete affected=1
pending-final count=0
operation-logs count=34
```
## 七、测试产生的数据
本轮新增并提交了 1 个测试文件,随后又提交了 1 次修改:
- Server Path:`$/北京排水集团人力系统/5SRC/MicroFront/subapp_pm/mactfs/stage4-e2e-20260608124119.txt`
- Local Path:`/Users/fenghp/Desktop/DEV/mactfs-ai-workspace/stage4-e2e-20260608124119.txt`
- 新增 changeset:`678131`
- 修改 changeset:`678132`
最终状态:
- 测试 Workspace 保留。
- 测试 Mapping 保留 1 条。
- 测试文件保留在服务端测试目录中。
- 删除操作仅验证到 `pendingDelete`,随后已 `undo`,未提交删除。
- 最终 pending changes 为 0。
## 八、UI 验证记录
Electron UI 实际可见状态:
- 顶部显示 `macTFS 已连接`。
- 顶部显示 TFS 地址 `http://100.113.212.90:20094/tfs/`。
- Source List 显示 `PE`、`PKUSEHR`。
- 切换 `PKUSEHR` 后,根目录可加载并展示大量服务端目录。
- 中间 `Source Workspace` 文件列表可展示名称、类型、服务端路径、本地路径、映射、状态列。
- Inspector 显示 Pending Changes、Included Changes、Excluded Changes、Checkin 输入区。
- 空 pending 状态下 Checkin 按钮禁用。
- 本地服务显示 `服务可用`,服务地址为 `http://127.0.0.1:38765`。
- Console 显示 API 操作日志,包括 `serverTree`、`serverFolderItems`、`listMappings` 等成功记录。
普通浏览器访问 `http://localhost:5173/` 时,因没有 Electron preload,页面显示:
```text
未检测到 Electron preload,请通过 Electron 启动 macTFS。
```
该行为符合前端规范:React 组件不直接读取本机 token,必须通过 Electron preload 获取本机能力。
## 九、发现问题与风险
### P2-1:顶部工具栏全局 Get Latest / History / Compare / Mapping 按钮仍为禁用展示
现象:
- Electron UI 顶部工具栏中的 `Get Latest`、`History` 按钮为 disabled。
- 主内容标题栏中的全局 `Compare`、`Mapping` 按钮也为 disabled。
- 当前可操作入口主要在中间目录面板内:目录区域的 `History`、映射提示、已映射目录下的 `Compare`、选中文件后的 `Get Latest / Checkout`。
影响:
- 不影响 API / Core 能力。
- 不影响当前已实现的面板内操作链路。
- 可能影响用户对顶部工具栏的预期,后续可以决定是接入当前选中目录,还是隐藏未完成的全局按钮。
建议:
- 后续前端任务中补齐顶部工具栏与当前选中路径、Mapping 状态、History/Diff 面板的联动。
- 或在未接入前移除禁用按钮,避免误导。
### P2-2:深层目录 UI 手动验证效率较低
现象:
- `PKUSEHR` 根目录目录项较多,手动通过 Source List 展开到 `$/北京排水集团人力系统/5SRC/MicroFront/subapp_pm/mactfs` 较慢。
- 通过 Computer Use 手动点击时,长列表滚动和元素编号变化容易误点。
影响:
- 不影响真实 API 能力。
- 会影响人工验收指定深层路径时的效率。
建议:
- 前端后续可增加服务端路径输入 / 快速跳转。
- E2E 自动化可在 Electron 启动调试端口后使用稳定的 DOM 自动化工具继续补充深层 UI 点击脚本。
## 十、未覆盖项
- 未通过 UI 再次执行真实 add/checkin/delete 写操作,原因是 API 层已完成真实写入验证,本轮 UI 只做可见性和只读链路确认,避免重复污染 TFS 测试目录。
- 未验证 Excluded 不参与签入的真实 UI 行为;当前 API checkin 支持 `serverPaths` 精确提交,UI 代码按 Included Changes 传入路径,本轮未制造多个 pending 文件做 UI 分组签入。
- 未验证目录对比面板中每个差异操作按钮的 UI 点击路径;API 已覆盖 `localOnly -> add`、`pendingDelete -> undo`,UI 面板结构已存在。
- 未验证二进制文件 diff、三方 merge 和冲突解决,这些不属于第一版范围。
## 十一、结论
阶段四 Feature E2E 的核心链路通过:
- 真实 TFS 连接、Collection、目录浏览可用。
- Workspace / Mapping / Get Latest 可用。
- 目录对比可识别干净目录和本地新增差异。
- Pending Changes、comment 必填、add、checkout、delete、undo、checkin 可用。
- 文件历史、目录历史、changeset 文件列表、本地 vs latest diff、历史版本 diff 可用。
- Electron UI 能通过 preload 读取 token 和服务状态,进入已连接工作台,并展示 Source List、文件列表、Inspector、Console。
当前主要遗留是前端顶部全局工具栏按钮仍为禁用展示,以及深层目录人工导航效率较低。除这些前端体验项外,本轮阶段四 Feature E2E 主链路验收通过。
@@ -0,0 +1,138 @@
# macTFS UI 全流程复测报告
## 一、测试范围
- 测试时间:2026-06-08 17:25-17:43
- 测试入口:Electron UI + Playwright 辅助操作同一前端页面
- 服务端:`http://127.0.0.1:38765`
- 前端:`http://localhost:5173`
- TFS 地址:`http://100.113.212.90:20094/tfs/`
- Collection:`PKUSEHR`
- Workspace:`mactfs-ai-subapp-pm-mactfs`
- Server Path:`$/北京排水集团人力系统/5SRC/MicroFront/subapp_pm/mactfs`
- Local Path:`/Users/fenghp/Desktop/DEV/mactfs-ai-workspace`
本轮复测基于上一轮问题修复后的代码执行。写操作仍严格限制在指定测试目录内。
## 二、测试结论
本轮复测主流程通过:连接、切换 `PKUSEHR`、进入指定项目、识别 Mapping、文件级 Get Latest、Checkout、修改本地文件、Pending Changes 展示、UI Checkin 均成功。
本轮签入生成 changeset `678201`,最终 pending changes 为 0。
上一轮的关键问题中,以下已修复或明显改善:
- `Missing required field: collection` 未再出现在 `PKUSEHR` 流程中。
- `listMappings` / `pendingChanges` 切到 `PKUSEHR` 后均带 `collection=PKUSEHR` 并返回成功。
- 中间文件列表支持双击进入目录。
- 文件级 Get Latest 后状态可变为 `upToDate / 已同步`。
- Checkout 后右侧 Pending 能显示 `pendingEdit / 编辑`。
- Checkin 表单能提交 Included Changes 并展示成功 changeset。
## 三、流程执行结果
| 步骤 | 结果 | 证据 |
| --- | --- | --- |
| 启动服务端 | 通过 | `GET /api/health` 返回 `success=true` |
| 启动前端/Electron | 通过 | Electron 显示本地服务可用 |
| 连接 TFS | 通过 | `/api/session/connect` 返回 200,UI 进入已连接工作台 |
| 切换到 PKUSEHR | 通过 | `/api/server-tree?collection=PKUSEHR`、`/api/server-folder/items?collection=PKUSEHR` 返回 200 |
| 定位目标目录 | 通过 | 页面进入 `$/北京排水集团人力系统/5SRC/MicroFront/subapp_pm/mactfs` |
| 识别 Mapping | 通过 | 页面显示当前目录已映射到 `/Users/fenghp/Desktop/DEV/mactfs-ai-workspace` |
| 文件级 Get Latest | 通过 | UI 显示 `Get Latest 完成,操作 1 项。`,接口日志 `getLatest success=true` |
| Checkout | 通过 | UI 显示 `Checkout 完成,影响 1 项。`,接口日志 `checkout success=true` |
| 修改本地文件 | 通过 | 修改前执行 `chmod u+w`,追加 `ui retest edit 20260608174200` |
| Pending 展示 | 通过 | UI Included Changes 显示 `stage4-e2e-20260608124119.txt` 和 `pendingEdit / 编辑` |
| Checkin | 通过 | UI 显示 `Checkin 成功,changeset 678201,提交 1 项。` |
| 最终状态 | 通过 | `GET /api/pending-changes` 返回 0 项 |
## 四、仍需关注的问题
### P1:登录后默认进入 PE 会触发 Workspace 不匹配错误
现象:
- 登录后 UI 首次加载默认 Collection 为 `PE`。
- 当前 workspace 是 `mactfs-ai-subapp-pm-mactfs`,属于 `PKUSEHR` 测试链路。
- 因此 UI 初始请求出现:
- `/api/pending-changes?collection=PE` 500
- `/api/mappings?collection=PE` 500
- 错误信息:`Workspace not found: mactfs-ai-subapp-pm-mactfs`
影响:
- 虽然切换到 `PKUSEHR` 后可以恢复,但用户会先看到失败日志。
- 当前工作区和默认 Collection 的匹配关系仍不稳定。
建议:
- 登录后优先选中配置中的 `collection=PKUSEHR`,不要默认选第一个 Collection。
- 或在切换 Collection 时同步校验 workspace 是否属于该 Collection,不匹配时不要请求 Pending / Mapping。
### P2:Electron Computer Use 插件本轮只能读窗口,点击动作被拒绝
现象:
- `get_app_state` 能读取 Electron 窗口。
- `click` 多次返回 `Computer Use is not active ... first must call get_app_state`。
- 已按同一 app name、bundle id、完整 app path 重试,仍无法点击。
影响:
- 本轮真实 UI 操作改由 Playwright 辅助执行同一前端页面,并注入等价 `window.mactfs` preload 桥接。
- Electron 可视状态仍已确认,但点击动作不是通过 Computer Use 完成。
建议:
- 后续如必须验证 macOS 原生 Electron 点击,可在 Computer Use 插件恢复后补跑一次。
## 五、修复项回归结果
| 上轮问题 | 本轮结果 |
| --- | --- |
| `Missing required field: collection` 残留 | PKUSEHR 流程未复现 |
| 中间文件列表不能进入目录 | 已支持双击进入,成功进入 `mactfs` |
| Get Latest 后状态未刷新 | 文件级 Get Latest 后可显示 `upToDate / 已同步` |
| Pending 面板不显示真实变更 | Checkout 后显示 `pendingEdit / 编辑` |
| Checkin 无法走 UI | UI Checkin 成功,changeset `678201` |
| 顶部/底部操作状态不一致 | 文件选中后底部操作可用;顶部按钮是否完全一致未单独覆盖 |
## 六、测试数据
本次修改文件:
- `/Users/fenghp/Desktop/DEV/mactfs-ai-workspace/stage4-e2e-20260608124119.txt`
追加内容:
```text
ui retest edit 20260608174200
```
签入信息:
- Comment:`AI UI retest checkin 2026-06-08 17:42`
- Changeset:`678201`
- 最终 pending changes:`0`
## 七、辅助产物
- `/Users/fenghp/Desktop/DEV/project/mydev/output/playwright/mactfs-retest/ui-retest-final.png`
- `/Users/fenghp/Desktop/DEV/project/mydev/output/playwright/mactfs-retest/file-flow-after-checkout.png`
- `/Users/fenghp/Desktop/DEV/project/mydev/output/playwright/mactfs-retest/checkin-flow-final.png`
## 八、修复记录
修复时间:2026-06-08
修复内容:
- `ServerTreePanel` 增加配置 Collection 优先选择逻辑,登录后优先展开 `~/.mactfs/config.json` 中保存的 Collection。
- `Home` 将后端配置中的 `collection` 传入 Source List,避免连接后先默认进入第一个 Collection。
- 保留无配置 Collection 时回退第一个 Collection 的行为,避免空配置首次使用无法自动浏览。
修复后验证:
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm build`:通过。
- 通过 `python3 -m http.server 4174 --bind 127.0.0.1` 打开构建产物:工作台主结构可渲染,Source List / Inspector / Console 均可见。
@@ -0,0 +1,174 @@
# macTFS UI 手工全流程测试报告
## 一、测试范围
- 测试时间:2026-06-08 16:27-16:40
- 测试入口:Electron UI(Computer Use)
- 服务端:`http://127.0.0.1:38765`
- 前端:`http://localhost:5173`
- TFS 地址:`http://100.113.212.90:20094/tfs/`
- Collection:`PKUSEHR`
- Workspace:`mactfs-ai-subapp-pm-mactfs`
- Server Path:`$/北京排水集团人力系统/5SRC/MicroFront/subapp_pm/mactfs`
- Local Path:`/Users/fenghp/Desktop/DEV/mactfs-ai-workspace`
本次测试按 `TFS-AI测试流程.md` 约束执行,写操作只发生在指定测试目录内。
## 二、测试结论
UI 能完成连接、Collection 切换、服务端目录浏览、识别既有 Mapping、触发 Get Latest;但 UI 在 Pending、目录进入、状态刷新和顶部工具栏联动上存在明显问题,导致“签出文件、修改代码、签入”无法完全通过 UI 连续完成。
为验证后端链路,本次在 UI 阻断后使用本地 API 补完 checkout、修改、pending changes、checkin。后端链路成功,最终生成 changeset `678184`,最终 pending changes 为 0。
## 三、流程执行结果
| 步骤 | 结果 | 证据 |
| --- | --- | --- |
| 启动服务端 | 通过 | `GET /api/health` 返回 `success=true`,端口 `38765` 可用 |
| 启动前端/Electron | 通过 | Electron 显示 `macTFS 已连接` |
| 登录/连接 TFS | 通过 | UI 显示 TFS 地址,`/api/collections` 返回 `PE`、`PKUSEHR` |
| 切换到 PKUSEHR | 通过 | 左侧 Source List 显示 `PKUSEHR` 根目录 |
| 定位目标路径 | 部分通过 | 可通过左侧树进入 `北京排水集团人力系统/5SRC/MicroFront/subapp_pm` |
| 映射指定目录 | 通过 | `mactfs` 行显示已映射到 `/Users/fenghp/Desktop/DEV/mactfs-ai-workspace` |
| Get Latest | 部分通过 | UI 显示 `Get Latest 完成,操作 4 项`,接口日志 `getLatest success=true` |
| 签出文件 | UI 阻断,接口通过 | UI 无法进入已映射目录选择文件;接口 checkout 成功,affected=1 |
| 修改文件 | 通过 | 本地文件追加 `ui manual edit 20260608164000` |
| Pending Changes | UI 异常,接口通过 | 接口返回 1 条 `pendingEdit`,UI 右侧仍残留旧错误/未显示变更 |
| Checkin | UI 阻断,接口通过 | 接口 checkin 成功,changeset=`678184` |
| 最终状态 | 通过 | `GET /api/pending-changes` 返回空列表 |
## 四、发现的问题
### P1:Pending 面板长期显示 `Missing required field: collection`
现象:
- UI 连接成功并已选中 `PKUSEHR` 后,右侧 Pending Changes 仍显示 `Missing required field: collection`。
- 后端 `/api/pending-changes` 在配置完整后可正常返回结果,但 UI 旧错误没有清除。
影响:
- 用户无法判断真实 pending 状态。
- checkin 区域无法根据真实 pending changes 启用。
### P1:中间文件列表不能进入目录
现象:
- 单击或双击 `subapp_pm`、`mactfs` 等目录行,只会选中行,不会进入目录。
- 只能依赖左侧 Source List 继续展开深层目录。
影响:
- 已映射目录 `mactfs` 下的具体文件无法通过中间列表选择。
- 后续 checkout/checkin 的 UI 文件级流程被阻断。
### P1:Get Latest 后目录状态没有刷新
现象:
- UI 显示 `Get Latest 完成,操作 4 项`。
- `mactfs` 行仍显示 `notDownloaded / 未下载`。
- 同一行已显示本地路径和已映射,状态与操作结果不一致。
影响:
- 用户无法确认当前目录是否已同步。
- 后续按钮启用状态和用户判断容易被误导。
### P2:顶部工具栏按钮状态不同步
现象:
- 选中已映射的 `mactfs` 行后,底部操作区出现可用 `Get Latest`、`Checkout`。
- 顶部工具栏 `Get Latest` 仍为禁用状态。
影响:
- 同一动作在不同位置状态不一致。
- 用户容易认为当前不能执行 Get Latest。
### P2:Source List 和主列表状态存在短暂串台/不同步
现象:
- 在目录切换过程中,标题显示 `$/北京排水集团人力系统/5SRC`,列表项曾短暂显示其他路径下的 `5SRC`。
- `MicroFront` 点击后,左侧树已展开,但中间列表一度仍显示上一层内容。
影响:
- 深层目录导航时容易误点其他项目路径。
- 对 TFS 写操作场景风险较高,需要确保路径展示严格一致。
### P2:Mapping/Pending 初始请求对配置状态依赖不稳定
现象:
- 早期 UI 日志中多次出现 `/api/mappings`、`/api/pending-changes` 因缺少 `collection` 失败。
- 后续 `/api/config` 已包含 `collection=PKUSEHR`、`workspace=mactfs-ai-subapp-pm-mactfs`,接口直连成功,但 UI 错误状态没有完整恢复。
影响:
- 首次进入工作台时容易出现错误状态残留。
- 用户需要刷新或重新选择目录才能看到部分恢复结果。
## 五、接口补充验证
UI 阻断后,为确认后端能力是否可用,执行了以下补充验证:
- `GET /api/mappings`:成功,返回 1 条目标 Mapping。
- `POST /api/files/get-latest`:成功,UI 触发,耗时约 1.4s。
- `POST /api/files/checkout`:成功,文件 `stage4-e2e-20260608124119.txt`,affected=1。
- 修改本地文件前执行:`chmod u+w /Users/fenghp/Desktop/DEV/mactfs-ai-workspace/stage4-e2e-20260608124119.txt`。
- `GET /api/pending-changes`:成功,返回 1 条 `pendingEdit`。
- `POST /api/checkin`:成功,changeset=`678184`,submittedChanges=1。
- 最终 `GET /api/pending-changes`:成功,返回空列表。
## 六、测试数据
本次修改文件:
- `/Users/fenghp/Desktop/DEV/mactfs-ai-workspace/stage4-e2e-20260608124119.txt`
追加内容:
```text
ui manual edit 20260608164000
```
签入信息:
- Comment:`AI UI manual flow test checkin 2026-06-08 16:40`
- Changeset:`678184`
## 七、建议修复优先级
1. 优先修复 Pending 面板请求和错误状态清理,确保配置完整后能显示真实 pending changes。
2. 修复中间文件列表目录进入能力,至少支持双击目录进入。
3. Get Latest 成功后刷新当前目录状态,避免继续显示 `notDownloaded / 未下载`。
4. 统一顶部工具栏和底部操作区的按钮启用逻辑。
5. 目录切换时清理旧列表数据或加 loading 遮罩,避免路径串台。
## 八、修复记录
修复时间:2026-06-08
修复内容:
- `PendingChangesPanel` 增加 Collection 输入,未选中 Collection 时不再请求 `/api/pending-changes`,避免初始缺 `collection` 错误残留。
- `GET /api/pending-changes` 支持从 query 中读取 `collection` / `workspace` 覆盖当前配置,UI 切换 Collection 后可使用当前选择发起查询。
- `GET /api/mappings` 支持从 query 中读取 `collection` / `workspace` 覆盖当前配置,中间列表加载 Mapping 时携带当前 Collection,降低初始配置状态依赖。
- `FolderItemsPanel` 在目录切换时清空旧列表和选中状态,接口返回前显示加载态,避免旧路径数据覆盖新路径。
- 中间文件列表目录行支持双击进入,并同步主工作区当前路径。
- 文件列表选中项映射状态上提到 `Home`,顶部工具栏 `Get Latest` 复用中间底部操作区的选中项状态,`History` 支持当前目录并优先使用选中项。
- `Get Latest` 成功后清空旧目录对比结果并刷新当前文件列表,避免继续展示过期 `notDownloaded` 状态。
- 未选中 Collection 时禁用 Pending Changes 手动刷新入口,并直接提示选择 Collection,避免再次触发缺 `collection` 请求。
- `Get Latest` 成功后在当前 UI 会话内记录已同步路径,列表刷新后显示 `upToDate / 已同步`,避免同一路径继续显示 `notDownloaded / 未下载`。
修复后验证:
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfs && ../tfsIntegration/gradlew build`:通过。
- `git diff --check`:通过。
- Browser 打开 `http://127.0.0.1:5173/`:非 Electron 环境下工作台、Source List、Inspector、Console 可正常渲染;未连接状态下顶部 `Get Latest` / `History` 禁用。
- 未重新执行真实 Electron + TFS 手工全流程,原因是当前修复验证阶段未再次提供真实 UI 写操作确认窗口;相关真实 checkout/checkin 写入仍需后续 E2E 复测。
@@ -0,0 +1,136 @@
# macTFS 前端 UI 布局专项复测报告
## 一、测试范围
本轮复测只覆盖前端 UI 布局与可用性问题,不重新执行 TFS 签出、修改、签入的完整业务闭环。
重点检查项:
- 长列表场景下,表头、底部操作栏、面板标题是否保持可见。
- Source Workspace 目录列表中“双击进入”提示是否影响可读性。
- Source List、Source Workspace、Inspector、Console 四个区域的视觉层级和滚动边界。
- Electron 桌面端实际运行状态与本地 API 服务状态。
## 二、测试环境
- 测试时间:2026-06-08
- 前端地址:`http://localhost:5173/`
- Electron:`mactfsui/node_modules/.pnpm/electron@42.2.0/.../Electron.app`
- 本地 API:`http://127.0.0.1:38765`
- Collection:`PKUSEHR`
- 测试路径:`$/北京排水集团人力系统/5SRC/MicroFront/subapp_pm/src/views`
## 三、测试方法
- 使用 Computer Use 读取 Electron 窗口状态和可访问性树。
- 通过 Electron 当前界面进入 `subapp_pm/src/views` 长目录场景。
- 通过本地 API 复核列表规模:
- `/api/server-folder/items` 返回 `views` 目录下 36 项。
- `/api/logs` 返回 116 条操作日志。
- `/api/pending-changes?collection=PKUSEHR` 当前返回 0 项。
- 结合前端源码核对滚动容器边界:
- `app-shell.tsx`
- `folder-items-panel.tsx`
- `operation-log-panel.tsx`
- `pending-changes-panel.tsx`
限制说明:
- Computer Use 本轮可以读取 Electron 截图和可访问性树。
- Computer Use 的点击/滚动动作间歇返回 `noWindowsAvailable`,因此滚动动作以读屏结果、接口数据规模和源码结构联合判断。
- 普通 Chrome 打开 `localhost:5173` 会进入“未检测到 Electron preload”的降级态,不能代表真实桌面端操作状态。
## 四、测试结果
### 1. Source Workspace 主目录列表
结论:主要结构通过,但存在可读性问题。
- `views` 目录下 36 项,已覆盖长列表场景。
- 源码中主列表表头位于列表滚动容器外,底部“已选中”操作栏也位于滚动容器外,理论上滚动列表时不会随内容滚走。
- 目录行每一行都展示“双击进入”,在 36 项目录列表中重复出现,压缩了名称列宽。
- 部分长目录名如 `assessmentContactPerson`、`importAppraisalResults`、`performanceProgress` 等在名称列中容易被截断;再叠加“双击进入”徽标后,可读性变差。
建议:
- 删除目录行内的“双击进入”徽标,保留双击行为即可。
- 如需提示,可放到表格空状态、工具提示或首次使用引导,不应在每个目录行重复展示。
### 2. Console 操作日志面板
结论:发现需要修复的问题。
- 当前服务端日志 116 条,Console 是明确的长列表场景。
- `operation-log-panel.tsx` 中日志表头位于 `overflow-auto` 容器内部。
- 当日志列表纵向滚动时,列头“操作 / 状态 / 开始 / 结束 / 耗时 / 路径摘要 / 错误信息”会跟随日志内容滚动,不会固定在 Console 可视区域顶部。
建议:
- 将 Console 列头移出日志滚动容器,或为列头增加 `sticky top-0` 和背景色。
- 保持 Console 顶部标题栏“Console / 条数 / 刷新”固定。
### 3. Source List 左侧树
结论:结构基本通过,仍有轻微体验问题。
- Source List 标题栏在滚动容器外,树内容在独立 `overflow-auto` 容器内。
- 当前左侧树可显示 `subapp_pm` 下的多级项目,标题“Source List”不会随树内容滚动。
- 长路径层级下列表密度较高,目录与文件混排时视觉区分主要依赖小图标,仍可用但扫描成本偏高。
建议:
- 保持标题固定。
- 后续可考虑为当前选中路径增加更明显的路径面包屑或层级聚焦,但本轮不作为阻塞问题。
### 4. Inspector / Pending Changes
结论:当前数据下通过,长 pending 场景未完全覆盖。
- 当前 pending changes 为 0 项。
- Inspector 标题栏在外层滚动容器外,面板内内容独立滚动。
- `PendingChangeGroup` 在有数据时使用 `max-h-56 overflow-auto`,分组标题在分组滚动容器外,因此分组标题不会随组内变更列表滚走。
- 因当前没有大量 pending changes,未覆盖“多条 Included / Excluded 同时存在”时 Checkin 区域是否需要固定的问题。
建议:
- 后续制造 10 条以上 pending changes 后,再专项验证 Included / Excluded 分组滚动和 Checkin 按钮可达性。
## 五、问题清单
| 编号 | 严重级别 | 位置 | 问题 | 建议 |
| --- | --- | --- | --- | --- |
| UI-001 | 中 | Console | 日志列头在滚动容器内部,长日志滚动时会跟随内容滚走 | 固定列头或移出滚动容器 |
| UI-002 | 中 | Source Workspace | 每个目录行重复展示“双击进入”,长目录列表下占用名称列宽 | 删除行内徽标,保留双击交互 |
| UI-003 | 低 | Source Workspace | 长目录名与长服务端路径同时截断,信息定位成本较高 | 保留截断但增加 title/tooltip 或更清晰的路径查看入口 |
| UI-004 | 低 | Inspector | 当前 pending 为 0,未验证多 pending 下 Checkin 区域是否始终可达 | 后续构造多 pending 数据再复测 |
## 六、复测结论
本轮 UI 复测发现 2 个需要优先处理的问题:
- Console 日志列表列头需要固定。
- Source Workspace 目录行内“双击进入”提示建议移除。
Source Workspace 主表头、底部“已选中”栏、Source List 标题栏从当前布局结构看已在滚动容器外,未发现会随列表内容滚走的明确问题。
本轮未执行代码修复,只记录 UI 走查结果并整理测试文档目录。
## 七、修复记录
修复时间:2026-06-08
修复内容:
- `OperationLogPanel` 将日志列头设置为 `sticky top-0`,长日志滚动时保留列头可见。
- `FolderItemsPanel` 删除目录行内重复的“双击进入”徽标,保留双击进入目录交互。
- `FolderItemsPanel` 为名称、服务端路径和本地路径补充 `title`,长文本截断时仍可查看完整内容。
- `AppShell` 将 Source Workspace 内容区外层从 `overflow-auto` 改为 `overflow-hidden`,避免整个中间面板随列表滚动。
- `FolderItemsPanel` 外层改为 `h-full min-h-0 flex-col`,保持目录列表表头和底部“已选中”操作栏在滚动容器外,只让中间列表数据滚动。
修复后验证:
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm typecheck`:通过。
- `cd /Users/fenghp/Desktop/DEV/project/mydev/mactfsui && pnpm build`:通过。
- 构建产物静态服务打开后,工作台主结构可渲染,页面文本中不再出现行内“双击进入”徽标。
- 构建产物静态服务打开后,Source Workspace 主内容容器为 `min-h-0 flex-1 overflow-hidden`,中间列表数据滚动不会带动表头和底部“已选中”操作栏。
- `pnpm dev` / `vite preview` 本轮因本机 watcher 数量限制报 `EMFILE: too many open files, watch`,已改用生产构建和静态服务验证。
+22
View File
@@ -0,0 +1,22 @@
# macTFS 测试文档索引
## 目录说明
- `00-core/`:核心能力验证。
- `01-flow/`:AI 测试流程文档对应的主流程验证。
- `02-server-api/`:本地 Java API 服务阶段测试。
- `03-cli-api/`:CLI / API 联动阶段测试。
- `04-e2e/`:端到端功能流程测试。
- `05-ui-manual/`:前端手工全流程测试与复测。
- `06-ui-layout/`:前端 UI 布局专项走查。
## 文档清单
- `00-core/core-validation-report.md`
- `01-flow/tfs-ai-flow-test-report.md`
- `02-server-api/mactfs-stage2-server-api-test-report.md`
- `03-cli-api/mactfs-stage3-cli-api-test-report.md`
- `04-e2e/mactfs-stage4-feature-e2e-test-report.md`
- `05-ui-manual/mactfs-ui-manual-flow-test-report.md`
- `05-ui-manual/mactfs-ui-manual-flow-retest-report.md`
- `06-ui-layout/mactfs-ui-layout-retest-report.md`