Author SHA1 Message Date
fenghpandCursor fe8b23225a 体验: 工作台改画布卡片式布局,toast提示移到右上角加彩色强调条更显眼
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 21:06:56 +08:00
fenghpandCursor 2768eafd93 frontend: FE-034 macOS风UI改版回归验收,真实TFS走查13项通过并归档关键截图
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 20:37:44 +08:00
fenghpandCursor 380280bace frontend: FE-033 明暗主题三态切换并跟随系统,防闪白+全局过渡+Monaco同步
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 20:31:25 +08:00
fenghpandCursor 4703a03f21 frontend: FE-032 轻量toast接管动作通知+日志滑入+按钮按压与勾选动画
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 20:24:57 +08:00
fenghpandCursor 261778732d frontend: FE-031 弹窗统一macOS Sheet风格,顶部锚定下滑出场+分层阴影
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 20:21:49 +08:00
fenghpandCursor 581cfb559c frontend: FE-030 目录树与文件列表精致化,图标体系+骨架屏+空状态+行级联动效+表头固定与滚动条修正
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 20:18:35 +08:00
fenghpandCursor c0f613fcb6 frontend: FE-029 Finder式工作台外壳,毛玻璃侧栏+透明工具栏+折叠过渡动画+动作图标化
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 20:14:46 +08:00
fenghpandCursor eec2cc85a3 frontend: FE-028 macOS风登录页,毛玻璃品牌区大logo+分步动效+最近连接+密码切换+失败抖动
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 20:11:08 +08:00
fenghpandCursor 3d1ffd0e81 frontend: FE-027 隐藏式标题栏+窗口vibrancy毛玻璃+防白闪+启动最大化+顶栏拖拽
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 20:07:57 +08:00
fenghpandCursor b19c6895a3 frontend: FE-026 macOS设计基建,系统字体+macOS蓝主色+半透明材质变量+圆角阴影动效令牌
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 20:04:38 +08:00
fenghpandCursor fbd16013e6 需求: macOS原生风UI改版需求文档与任务拆分(FE-026~034),与现代工具风分支并行对比
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 16:53:46 +08:00
fenghpandCursor b1a5b2f16b 体验: 目录对比改为先确认选项再执行,避免进入弹窗即自动对比与切换选项重复对比
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 15:59:22 +08:00
fenghpandCursor bba6e89a16 体验: 目录对比改为忽略仅本地存在的项(如 node_modules),勾选后跳过本地全量扫描提速
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 15:53:40 +08:00
fenghpandCursor b247a18155 体验: 目录对比执行签出等动作后自动重新对比,新增忽略仅服务器存在项的过滤开关
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 15:31:12 +08:00
fenghpandCursor b156f505eb 修复: 目录对比漏报未签出的纯本地修改,文件变为可写即按本地已修改处理(对齐 VS writable 判定)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 15:22:18 +08:00
fenghpandCursor b3454eb157 品牌: 项目更名为 MacTFS,Electron 窗口与程序坞使用根目录 logo 图标
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 15:04:49 +08:00
51 changed files with 2194 additions and 315 deletions
+127
View File
@@ -0,0 +1,127 @@
# macTFS UI 风格改版需求(macOS 现代化 / 精致化 / 动效)
## 一、背景
macTFS 功能层面已完成 FE-001 ~ FE-025,三栏工作台、弹窗体系、挂起更改与签入链路均可用。但当前 UI 是「功能优先」的默认 shadcn 风格:
- 全局使用 Roboto Slab 衬线字体,与 macOS 原生观感不符
- 登录页只有一张简单表单卡片,元素少、缺少品牌感
- 窗口使用系统默认标题栏,没有 macOS 原生质感(毛玻璃、隐藏式标题栏)
- 几乎没有过渡动效:弹窗、面板折叠、列表加载都是瞬时切换
- 明暗主题虽有 token 定义,但没有切换入口、也不跟随系统
本批需求目标:在**不改任何业务逻辑与接口**的前提下,把整体 UI 升级为「精致、现代、macOS 原生感、富动效」的风格。
> 说明:本分支(`feature/ui-macos-restyle`)做 macOS 原生风方案;姊妹分支 `feature/ui-modern-restyle` 做 Codex/CC Switch 现代工具风方案,两套并行对比。
## 二、用户诉求原文与解读
> 「我希望的 ui 风格是更精致,有 macos 风格,动效更多,登录页元素多点,反正就是希望比较现代一些。」
解读为四个方向:
| 诉求 | 落地方向 |
|---|---|
| 更精致 | 统一设计令牌(颜色/圆角/阴影/间距/图标),细化悬停、选中、禁用等状态 |
| macOS 风格 | 系统字体栈、隐藏式标题栏 + 毛玻璃、Finder 式侧栏、Sheet 式弹窗、跟随系统明暗 |
| 动效更多 | 建立统一动效规范:弹窗出入场、面板折叠、列表级联、骨架屏、微交互 |
| 登录页元素多点 | 品牌区 + 分步表单 + 背景氛围 + 最近连接 + 连接过程动效 |
## 三、现状盘点
| 现状 | 文件 | 问题 |
|---|---|---|
| 主题 token | `mactfsui/app/app.css` | 衬线字体、紫色主色、无 macOS 质感变量,dark 主题无入口 |
| Electron 窗口 | `mactfsui/electron/main.cjs` | 默认标题栏,无 vibrancy / hiddenInset |
| 登录页 | `app/components/app/connect-view.tsx` | 单卡片表单,无品牌区、无动效、无最近连接 |
| 工作台 | `app/components/app/workspace-shell.tsx`、`top-bar.tsx` | 布局可用但视觉平淡,折叠无动画 |
| 目录树 / 文件列表 | `app/components/explorer/source-tree-panel.tsx`、`folder-items-panel.tsx` | 行样式简单,加载无骨架屏,空状态简陋 |
| 弹窗(13 个) | `app/components/explorer/*-dialog.tsx`、`app/components/app/*` | 风格不一,出入场无动效 |
| 反馈 | `changes-panel.tsx`、`console-panel.tsx` | 无 toast,操作反馈靠日志面板 |
技术栈现状:React 19 + React Router 7 + Tailwind CSS 4 + radix-ui/shadcn + tw-animate-css + lucide-react + Electron 42。动效优先用 CSS / tw-animate-css,复杂编排可引入 `motion`(原 framer-motion)。
## 四、需求定义
### U1 macOS 设计基建(FE-026,P0)
- 字体切换为系统字体栈(`-apple-system, BlinkMacSystemFont, "SF Pro", "PingFang SC"…`),仅代码/Diff 保留等宽字体
- 重定义颜色令牌:macOS 蓝为主色(accentColor 风格),中性灰分级、分隔线、半透明材质变量
- 统一圆角(macOS 常用 6/10/14px 档位)、阴影(弹窗/浮层分层阴影)、间距档位
- 建立动效令牌:统一时长(150/220/320ms)、缓动曲线(ease-out-quart、spring 近似)
- 完整 light/dark 两套 token,预留 vibrancy 透明背景变量
### U2 Electron 窗口 macOS 化(FE-027,P0)
- `titleBarStyle: "hiddenInset"`,红绿灯融入顶栏,顶栏设置可拖拽区域
- 开启窗口 vibrancy(`under-window` 或 `sidebar` 材质),侧栏呈毛玻璃
- 窗口最小尺寸、默认尺寸与圆角阴影核对
- 兼容退化:非 macOS 或 vibrancy 不可用时回退为纯色背景
### U3 登录页改版(FE-028,P0)
- 左右分栏:左侧品牌区(logo、产品名、标语、版本号、氛围背景动效),右侧连接表单
- 表单分步过渡动效:连接表单 → Collection 选择,左右滑动 + 淡入淡出
- 新增元素:密码可见性切换、记住最近服务器(本地存储多条,可一键填充)、连接状态指示灯、服务健康状态展示
- 连接中动效:按钮内 spinner + 步骤文案渐变;失败时表单抖动 + 错误条
- Collection 选择改为卡片式列表,悬停浮起、选中高亮动画
### U4 工作台外壳与顶栏改版(FE-029,P0)
- 顶栏融合红绿灯区域,按 macOS 工具栏风格重排(上下文信息居左、动作按钮居右、图标化)
- 左侧目录树面板改 Finder 式侧栏:毛玻璃背景、分组标题、圆角选中条
- 三栏折叠/展开增加宽度过渡动画,底部操作台滑入滑出
- 面板间分隔线与留白统一
### U5 目录树与文件列表精致化(FE-030,P0)
- 树节点与文件行:统一行高、圆角悬停/选中态、展开箭头旋转动画、子级展开渐入
- 文件图标体系:按扩展名区分图标与颜色,文件夹/分支/映射目录差异化
- 加载骨架屏替代文字 loading;空状态配插图式占位(图标 + 引导文案)
- 状态徽标(pendingEdit/Add/Delete、未下载等)配色与形状统一
### U6 弹窗体系统一(FE-031,P1)
- 13 个弹窗统一为 macOS Sheet 风格:顶部标题区、内容区、底部按钮区结构一致
- 出入场动效统一:缩放 + 淡入(spring 曲线),遮罩淡入
- 弹窗内表格、表单控件按新 token 对齐;大弹窗(History/Compare/Diff)支持更合理的留白与分区
### U7 操作反馈与微交互(FE-032,P1)
- 引入轻量 toast(成功/失败/进行中),与日志面板互补
- 挂起更改面板、日志面板按新风格改版;新日志条目滑入动画
- 按钮、复选框、输入框等控件微交互:按压缩放、聚焦光环、hover 过渡
- 长操作(GetLatest、Checkin、目录对比)显示进度反馈动效
### U8 明暗主题跟随系统(FE-033,P1)
- 跟随 macOS 系统外观自动切换 light/dark,顶栏提供手动三态开关(跟随/亮/暗)
- 切换瞬间全局颜色过渡动画
- 校验全部面板与弹窗在 dark 下的对比度与可读性
### U9 改版回归验收(FE-034,P0)
- 改版完成后按 `TFS-AI测试流程.md` 大纲做 UI 链路回归:登录、浏览、Mapping、GetLatest、签出签入、历史、Diff、目录对比
- 确认零业务逻辑变化:`app/lib` 与接口契约不动
- typecheck 通过、关键页面截图存档
## 五、优先级与排期建议
| 顺序 | 任务 | 优先级 | 依赖 |
|---|---|---|---|
| 1 | FE-026 设计基建 | P0 | 无 |
| 2 | FE-027 窗口 macOS 化 | P0 | FE-026 |
| 3 | FE-028 登录页改版 | P0 | FE-026 |
| 4 | FE-029 工作台外壳改版 | P0 | FE-026、FE-027 |
| 5 | FE-030 目录树与文件列表 | P0 | FE-029 |
| 6 | FE-031 弹窗体系统一 | P1 | FE-026 |
| 7 | FE-032 反馈与微交互 | P1 | FE-026 |
| 8 | FE-033 明暗主题 | P1 | FE-026 ~ FE-032 |
| 9 | FE-034 回归验收 | P0 | 全部 |
## 六、不在本批范围
- 任何业务逻辑、接口契约、服务端代码改动
- 新功能(本批只做视觉与交互层)
- Windows / Linux 适配(仅保证不崩溃的回退样式)
- Monaco Diff 内部主题深度定制(仅跟随明暗主题切换)
Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

@@ -214,7 +214,7 @@ public class MacTfsCli {
private static String readServerToken() throws Exception { private static String readServerToken() throws Exception {
File tokenFile = serverTokenFile(); File tokenFile = serverTokenFile();
if (!tokenFile.exists()) { if (!tokenFile.exists()) {
throw new IllegalArgumentException("Token file not found: " + tokenFile.getAbsolutePath() + ". Start macTFS API server first."); throw new IllegalArgumentException("Token file not found: " + tokenFile.getAbsolutePath() + ". Start MacTFS API server first.");
} }
String token = new String(java.nio.file.Files.readAllBytes(tokenFile.toPath()), Charset.forName("UTF-8")).trim(); String token = new String(java.nio.file.Files.readAllBytes(tokenFile.toPath()), Charset.forName("UTF-8")).trim();
if (token.length() == 0) { if (token.length() == 0) {
@@ -235,7 +235,8 @@ public class TfsPhaseOneService {
required(merged, "workspace"), required(merged, "workspace"),
required(merged, "server-path"), required(merged, "server-path"),
merged.getProperty("local-path"), merged.getProperty("local-path"),
!"false".equalsIgnoreCase(merged.getProperty("recursive")) !"false".equalsIgnoreCase(merged.getProperty("recursive")),
!"false".equalsIgnoreCase(merged.getProperty("include-local-only"))
); );
Map<String, Object> data = new LinkedHashMap<String, Object>(); Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("diffs", folderDiffsToMaps(result.getData())); data.put("diffs", folderDiffsToMaps(result.getData()));
@@ -64,7 +64,7 @@ import java.util.Map;
import java.util.Set; import java.util.Set;
/** /**
* macTFS 核心服务层,封装 TFS 连接、目录、Workspace、文件操作、历史和 Diff 能力。 * MacTFS 核心服务层,封装 TFS 连接、目录、Workspace、文件操作、历史和 Diff 能力。
*/ */
public class MacTfsCoreService { public class MacTfsCoreService {
@@ -139,7 +139,7 @@ public class MacTfsCoreService {
} }
/** /**
* 查询或创建 Collection 默认 Workspace,未传名称时按 macTFS 规则生成。 * 查询或创建 Collection 默认 Workspace,未传名称时按 MacTFS 规则生成。
*/ */
public CoreOperationResult<TfsWorkspaceInfo> ensureWorkspace(final TfsConnectionConfig config, public CoreOperationResult<TfsWorkspaceInfo> ensureWorkspace(final TfsConnectionConfig config,
final String collectionName, final String collectionName,
@@ -579,7 +579,8 @@ public class MacTfsCoreService {
final String workspaceName, final String workspaceName,
final String serverPath, final String serverPath,
final String localPath, final String localPath,
final boolean recursive) { final boolean recursive,
final boolean includeLocalOnly) {
return execute("compareFolder", new CoreCallable<List<TfsFolderDiffItem>>() { return execute("compareFolder", new CoreCallable<List<TfsFolderDiffItem>>() {
@Override @Override
public List<TfsFolderDiffItem> call(List<String> logs) throws Exception { public List<TfsFolderDiffItem> call(List<String> logs) throws Exception {
@@ -593,7 +594,10 @@ public class MacTfsCoreService {
} }
Map<String, ExtendedItem> serverItems = queryExtendedItems(workspace, normalizedServerPath, recursive); Map<String, ExtendedItem> serverItems = queryExtendedItems(workspace, normalizedServerPath, recursive);
Map<String, PendingChange> pendingChanges = mapPendingChanges(queryPendingChanges(workspace, Arrays.asList(normalizedServerPath), recursive)); Map<String, PendingChange> pendingChanges = mapPendingChanges(queryPendingChanges(workspace, Arrays.asList(normalizedServerPath), recursive));
Set<String> localFiles = scanLocalFiles(new File(normalizedLocalPath), recursive); // 不统计仅本地存在的项时直接跳过本地全量扫描(如 node_modules 大目录),只对比两端都有的文件。
Set<String> localFiles = includeLocalOnly
? scanLocalFiles(new File(normalizedLocalPath), recursive)
: new LinkedHashSet<String>();
Map<String, TfsFolderDiffItem> result = new LinkedHashMap<String, TfsFolderDiffItem>(); Map<String, TfsFolderDiffItem> result = new LinkedHashMap<String, TfsFolderDiffItem>();
for (Map.Entry<String, ExtendedItem> entry : serverItems.entrySet()) { for (Map.Entry<String, ExtendedItem> entry : serverItems.entrySet()) {
@@ -1237,8 +1241,12 @@ public class MacTfsCoreService {
} }
boolean exists = localFile != null && localFile.exists(); boolean exists = localFile != null && localFile.exists();
boolean remoteChanged = item.getLocalVersion() > 0 && item.getLatestVersion() > item.getLocalVersion(); boolean remoteChanged = item.getLocalVersion() > 0 && item.getLatestVersion() > item.getLocalVersion();
// mtime 只能作为远端版本变化时的辅助信号,避免 Get Latest 后因本地时间晚于签入时间误报本地修改。 // 服务器工作区下文件取下来默认只读,变为可写说明被本地解锁修改过(对齐 VS 的 writable 判定),
boolean localModified = remoteChanged && exists && item.getCheckinDate() != null && localFile.lastModified() > item.getCheckinDate().getTimeInMillis(); // 这样未签出的纯本地修改也能在目录对比中暴露出来。
boolean writableFile = exists && !isFolder(item.getItemType()) && localFile.canWrite();
// mtime 仅作为远端版本变化时的辅助信号,避免 Get Latest 后因本地时间晚于签入时间误报本地修改。
boolean mtimeNewer = exists && item.getCheckinDate() != null && localFile.lastModified() > item.getCheckinDate().getTimeInMillis();
boolean localModified = writableFile || (remoteChanged && mtimeNewer);
if (!exists && item.getLocalVersion() > 0) { if (!exists && item.getLocalVersion() > 0) {
return "localDeleted"; return "localDeleted";
} }
@@ -52,7 +52,7 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
/** /**
* macTFS 第二阶段本地 API 服务入口,负责 HTTP 路由、Token 校验、配置、会话、日志和超时控制。 * MacTFS 第二阶段本地 API 服务入口,负责 HTTP 路由、Token 校验、配置、会话、日志和超时控制。
*/ */
public class MacTfsServer { public class MacTfsServer {
@@ -93,8 +93,8 @@ public class MacTfsServer {
registerFilters(); registerFilters();
registerRoutes(); registerRoutes();
Spark.awaitInitialization(); Spark.awaitInitialization();
System.out.println("macTFS API server listening on http://" + HOST + ":" + PORT); System.out.println("MacTFS API server listening on http://" + HOST + ":" + PORT);
System.out.println("macTFS token file: " + tokenStore.getTokenFile().getAbsolutePath()); System.out.println("MacTFS token file: " + tokenStore.getTokenFile().getAbsolutePath());
} }
/** /**
@@ -597,7 +597,7 @@ public class MacTfsServer {
public ApiResult call(Request request) throws Exception { public ApiResult call(Request request) throws Exception {
Map<String, Object> body = readBody(request); Map<String, Object> body = readBody(request);
AppConfig config = requestConfig(body); AppConfig config = requestConfig(body);
CoreOperationResult<List<TfsFolderDiffItem>> result = coreService.compareFolder(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), require(stringValue(body, "serverPath"), "serverPath"), stringValue(body, "localPath"), booleanValue(body, "recursive", true)); CoreOperationResult<List<TfsFolderDiffItem>> result = coreService.compareFolder(toTfsConfig(config), require(config.collection, "collection"), require(config.workspace, "workspace"), require(stringValue(body, "serverPath"), "serverPath"), stringValue(body, "localPath"), booleanValue(body, "recursive", true), booleanValue(body, "includeLocalOnly", true));
Map<String, Object> data = new LinkedHashMap<String, Object>(); Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("diffs", result.getData() == null ? Collections.emptyList() : result.getData()); data.put("diffs", result.getData() == null ? Collections.emptyList() : result.getData());
return fromCore(result, data); return fromCore(result, data);
+115 -63
View File
@@ -1,13 +1,18 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@import "shadcn/tailwind.css"; @import "shadcn/tailwind.css";
@import "@fontsource-variable/roboto-slab";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
/*
* macOS 原生风设计基建(FE-026)
* 基调:系统字体 + macOS 蓝主色 + 中性灰分级 + 半透明材质(vibrancy)变量。
* 动效约定:快 150ms / 标准 220ms / 慢 320ms,缓动 ease-out-quart / spring 近似。
*/
@theme inline { @theme inline {
--font-heading: var(--font-serif); --font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
--font-serif: 'Roboto Slab Variable', serif; --font-heading: var(--font-sans);
--font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -39,6 +44,7 @@
--color-card: var(--card); --color-card: var(--card);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-background: var(--background); --color-background: var(--background);
/* 圆角档位:macOS 常用 6/8/10/14px(--radius = 10px) */
--radius-sm: calc(var(--radius) * 0.6); --radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8); --radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius); --radius-lg: var(--radius);
@@ -46,75 +52,97 @@
--radius-2xl: calc(var(--radius) * 1.8); --radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2); --radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6); --radius-4xl: calc(var(--radius) * 2.6);
/* 阴影分层:卡片极轻 / 浮层轻 / 弹窗(Sheet)明显 */
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.05);
--shadow-overlay: 0 4px 16px rgb(0 0 0 / 0.1), 0 1px 3px rgb(0 0 0 / 0.06);
--shadow-dialog: 0 18px 50px rgb(0 0 0 / 0.22), 0 4px 12px rgb(0 0 0 / 0.1);
/* 缓动曲线 */
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
--ease-spring: cubic-bezier(0.34, 1.3, 0.64, 1);
/* 反馈动效:表单错误抖动 */
--animate-shake: shake 0.4s var(--ease-out-quart);
@keyframes shake {
10%, 90% { transform: translateX(-1px); }
20%, 80% { transform: translateX(2px); }
30%, 50%, 70% { transform: translateX(-3px); }
40%, 60% { transform: translateX(3px); }
}
} }
:root { :root {
--background: oklch(1 0 0); --background: oklch(0.98 0.002 286);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.22 0.006 286);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.22 0.006 286);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.22 0.006 286);
--primary: oklch(0.488 0.243 264.376); /* macOS 系统蓝(≈ #007AFF),仅用于主按钮、选中态、状态点 */
--primary-foreground: oklch(0.97 0.014 254.604); --primary: oklch(0.585 0.205 256.5);
--secondary: oklch(0.967 0.001 286.375); --primary-foreground: oklch(0.99 0.002 286);
--secondary-foreground: oklch(0.21 0.006 285.885); --secondary: oklch(0.957 0.004 286);
--muted: oklch(0.97 0 0); --secondary-foreground: oklch(0.22 0.006 286);
--muted-foreground: oklch(0.556 0 0); --muted: oklch(0.957 0.004 286);
--accent: oklch(0.488 0.243 264.376); --muted-foreground: oklch(0.55 0.012 285);
--accent-foreground: oklch(0.97 0.014 254.604); /* accent 为中性悬停底色 */
--destructive: oklch(0.577 0.245 27.325); --accent: oklch(0.94 0.004 286);
--border: oklch(0.922 0 0); --accent-foreground: oklch(0.22 0.006 286);
--input: oklch(0.922 0 0); --destructive: oklch(0.59 0.24 27.5);
--ring: oklch(0.708 0 0); --border: oklch(0.905 0.004 286);
--input: oklch(0.905 0.004 286);
--ring: oklch(0.62 0.19 256.5);
--chart-1: oklch(0.809 0.105 251.813); --chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815); --chart-2: oklch(0.7 0.165 254);
--chart-3: oklch(0.546 0.245 262.881); --chart-3: oklch(0.585 0.205 256.5);
--chart-4: oklch(0.488 0.243 264.376); --chart-4: oklch(0.5 0.21 259);
--chart-5: oklch(0.424 0.199 265.638); --chart-5: oklch(0.43 0.19 262);
--radius: 0.875rem; --radius: 0.625rem;
--sidebar: oklch(0.985 0 0); /* 侧栏半透明材质:vibrancy 开启时透出窗口毛玻璃,关闭时回退近实色 */
--sidebar-foreground: oklch(0.145 0 0); --sidebar: oklch(0.962 0.003 286 / 72%);
--sidebar-primary: oklch(0.546 0.245 262.881); --sidebar-foreground: oklch(0.22 0.006 286);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604); --sidebar-primary: oklch(0.585 0.205 256.5);
--sidebar-accent: oklch(0.97 0 0); --sidebar-primary-foreground: oklch(0.99 0.002 286);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent: oklch(0.88 0.005 286 / 80%);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.89 0.004 286);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.62 0.19 256.5);
/* 动效时长:快 / 标准 / 慢 */
--motion-fast: 150ms;
--motion-base: 220ms;
--motion-slow: 320ms;
} }
.dark { .dark {
--background: oklch(0.145 0 0); --background: oklch(0.21 0.005 285);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.96 0.002 286);
--card: oklch(0.205 0 0); --card: oklch(0.245 0.006 285);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.96 0.002 286);
--popover: oklch(0.205 0 0); --popover: oklch(0.245 0.006 285);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.96 0.002 286);
--primary: oklch(0.424 0.199 265.638); /* macOS 暗色系统蓝(≈ #0A84FF) */
--primary-foreground: oklch(0.97 0.014 254.604); --primary: oklch(0.64 0.19 253);
--secondary: oklch(0.274 0.006 286.033); --primary-foreground: oklch(0.99 0.002 286);
--secondary-foreground: oklch(0.985 0 0); --secondary: oklch(0.29 0.006 286);
--muted: oklch(0.269 0 0); --secondary-foreground: oklch(0.96 0.002 286);
--muted-foreground: oklch(0.708 0 0); --muted: oklch(0.28 0.006 286);
--accent: oklch(0.424 0.199 265.638); --muted-foreground: oklch(0.71 0.01 286);
--accent-foreground: oklch(0.97 0.014 254.604); --accent: oklch(0.3 0.006 286);
--accent-foreground: oklch(0.96 0.002 286);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 9%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 13%);
--ring: oklch(0.556 0 0); --ring: oklch(0.64 0.19 253);
--chart-1: oklch(0.809 0.105 251.813); --chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815); --chart-2: oklch(0.7 0.165 254);
--chart-3: oklch(0.546 0.245 262.881); --chart-3: oklch(0.64 0.19 253);
--chart-4: oklch(0.488 0.243 264.376); --chart-4: oklch(0.5 0.21 259);
--chart-5: oklch(0.424 0.199 265.638); --chart-5: oklch(0.43 0.19 262);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.22 0.005 285 / 62%);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.96 0.002 286);
--sidebar-primary: oklch(0.623 0.214 259.815); --sidebar-primary: oklch(0.64 0.19 253);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604); --sidebar-primary-foreground: oklch(0.99 0.002 286);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(0.36 0.006 285 / 80%);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 9%);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.64 0.19 253);
--sidebar-ring: oklch(0.556 0 0);
} }
@layer base { @layer base {
@@ -125,6 +153,30 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
html { html {
@apply font-serif; @apply font-sans;
} }
} }
/* 主题切换瞬间的全局颜色过渡,由 use-theme 短暂挂载 theme-transition class。 */
.theme-transition,
.theme-transition * {
transition:
background-color 250ms var(--ease-out-quart),
border-color 250ms var(--ease-out-quart),
color 250ms var(--ease-out-quart) !important;
}
/* vibrancy 模式(FE-027 在 macOS Electron 下挂载):页面底透明,毛玻璃从半透明侧栏透出 */
html.vibrancy,
html.vibrancy body {
background: transparent;
}
/* 隐藏式标题栏下的窗口拖拽区域:顶栏整体可拖动,交互控件单独豁免。 */
@utility app-drag {
-webkit-app-region: drag;
}
@utility app-no-drag {
-webkit-app-region: no-drag;
}
+282 -122
View File
@@ -1,10 +1,13 @@
import { useCallback, useEffect, useState, type ChangeEvent } from "react" import { useCallback, useEffect, useState, type ChangeEvent } from "react"
import { Check, Clock3, Eye, EyeOff, Loader2 } from "lucide-react"
import { Button } from "~/components/ui/button" import { Button } from "~/components/ui/button"
import { Input } from "~/components/ui/input" import { Input } from "~/components/ui/input"
import { Label } from "~/components/ui/label" import { Label } from "~/components/ui/label"
import { api } from "~/lib/api" import { api } from "~/lib/api"
import type { AppConfig, Collection } from "~/lib/api" import type { AppConfig, Collection } from "~/lib/api"
import { getServiceStatus } from "~/lib/electron"
import { loadRecentServers, saveRecentServer, type RecentServer } from "~/lib/recent-servers"
import type { WorkspaceSession } from "~/lib/tfs/session" import type { WorkspaceSession } from "~/lib/tfs/session"
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils"
@@ -28,6 +31,7 @@ const EMPTY_FORM: ConnectFormState = {
/** /**
* 连接入口:连接 TFS、加载并确认 Collection、确保默认 Workspace,成功后回调固定上下文。 * 连接入口:连接 TFS、加载并确认 Collection、确保默认 Workspace,成功后回调固定上下文。
* macOS 风:左侧毛玻璃品牌区(大 logo + 一句话),右侧表单,带分阶段过渡与最近连接。
*/ */
export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession) => void }) { export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession) => void }) {
const [phase, setPhase] = useState<ConnectPhase>("form") const [phase, setPhase] = useState<ConnectPhase>("form")
@@ -37,9 +41,15 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [busyText, setBusyText] = useState("") const [busyText, setBusyText] = useState("")
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [showPassword, setShowPassword] = useState(false)
const [recent, setRecent] = useState<RecentServer[]>([])
const [serviceOk, setServiceOk] = useState<boolean | null>(null)
// 每次失败自增,作为 key 触发表单抖动动画重放。
const [shakeCount, setShakeCount] = useState(0)
useEffect(() => { useEffect(() => {
let active = true let active = true
setRecent(loadRecentServers())
void (async () => { void (async () => {
const result = await api.getConfig() const result = await api.getConfig()
if (!active) { if (!active) {
@@ -58,6 +68,12 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
} }
} }
})() })()
void (async () => {
const status = await getServiceStatus()
if (active) {
setServiceOk(status.running)
}
})()
return () => { return () => {
active = false active = false
} }
@@ -71,6 +87,27 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
setForm((prev) => ({ ...prev, [key]: value })) setForm((prev) => ({ ...prev, [key]: value }))
} }
/**
* 失败统一处理:展示错误条并触发表单抖动。
*/
const failWith = useCallback((message: string) => {
setError(message)
setShakeCount((count) => count + 1)
}, [])
/**
* 用一条最近连接记录填充表单(不含密码)。
*/
const applyRecent = useCallback((item: RecentServer) => {
setError(null)
setForm((prev) => ({
...prev,
serverUri: item.serverUri,
domain: item.domain,
username: item.username,
}))
}, [])
/** /**
* 连接 TFS 并加载当前账号可见的 Collection 列表。 * 连接 TFS 并加载当前账号可见的 Collection 列表。
*/ */
@@ -88,23 +125,30 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
const connectResult = await api.connect(payload) const connectResult = await api.connect(payload)
if (!connectResult.ok) { if (!connectResult.ok) {
setBusy(false) setBusy(false)
setError(connectResult.errorMessage ?? "连接失败") failWith(connectResult.errorMessage ?? "连接失败")
return return
} }
setBusyText("正在加载 Collection…") setBusyText("正在加载 Collection…")
const collectionsResult = await api.listCollections() const collectionsResult = await api.listCollections()
setBusy(false) setBusy(false)
if (!collectionsResult.ok) { if (!collectionsResult.ok) {
setError(collectionsResult.errorMessage ?? "加载 Collection 失败") failWith(collectionsResult.errorMessage ?? "加载 Collection 失败")
return return
} }
setRecent(
saveRecentServer({
serverUri: form.serverUri.trim(),
domain: form.domain.trim(),
username: form.username.trim(),
}),
)
const list = collectionsResult.data?.collections ?? [] const list = collectionsResult.data?.collections ?? []
setCollections(list) setCollections(list)
setSelectedCollection((prev) => setSelectedCollection((prev) =>
list.some((item) => item.name === prev) ? prev : list[0]?.name ?? "", list.some((item) => item.name === prev) ? prev : list[0]?.name ?? "",
) )
setPhase("collection") setPhase("collection")
}, [form]) }, [form, failWith])
/** /**
* 确认 Collection 后确保默认 Workspace,并把固定上下文回调给上层。 * 确认 Collection 后确保默认 Workspace,并把固定上下文回调给上层。
@@ -120,7 +164,7 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
setBusy(false) setBusy(false)
const workspace = ensureResult.data?.workspace const workspace = ensureResult.data?.workspace
if (!ensureResult.ok || !workspace) { if (!ensureResult.ok || !workspace) {
setError(ensureResult.errorMessage ?? "确保 Workspace 失败") failWith(ensureResult.errorMessage ?? "确保 Workspace 失败")
return return
} }
onReady({ onReady({
@@ -129,7 +173,7 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
workspace: workspace.name, workspace: workspace.name,
mappings: workspace.mappings ?? [], mappings: workspace.mappings ?? [],
}) })
}, [selectedCollection, form.serverUri, onReady]) }, [selectedCollection, form.serverUri, onReady, failWith])
const canConnect = const canConnect =
form.serverUri.trim().length > 0 && form.serverUri.trim().length > 0 &&
@@ -137,131 +181,247 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
form.password.length > 0 form.password.length > 0
return ( return (
<div className="flex min-h-svh items-center justify-center bg-muted/40 p-6"> <div className="relative flex min-h-svh">
<div className="w-full max-w-md rounded-lg border bg-background p-6 shadow-sm"> {/* 隐藏式标题栏下登录页的窗口拖拽区 */}
<div className="space-y-1"> <div className="app-drag absolute inset-x-0 top-0 z-10 h-10" />
<h1 className="text-base font-semibold">macTFS</h1>
<p className="text-sm text-muted-foreground"> {/* 左侧品牌区:半透明材质(vibrancy 下呈毛玻璃),大 logo + 一句话 */}
{phase === "form" ? "连接 TFS 服务器" : "选择 Collection"} <aside className="relative hidden w-[42%] max-w-md min-w-80 flex-col border-r bg-sidebar md:flex">
</p> <div className="flex flex-1 flex-col items-center justify-center gap-5 p-10">
<img src="/logo.png" alt="MacTFS" className="size-24 drop-shadow-2xl" />
<div className="text-center">
<p className="text-2xl font-semibold tracking-tight">MacTFS</p>
<p className="mt-1.5 text-sm text-muted-foreground">macOS 上的 TFS 源码管理工作台</p>
</div>
</div> </div>
{phase === "form" && ( <div className="flex items-center gap-2 p-6 text-xs text-muted-foreground">
<form <span
className="mt-5 space-y-3" className={cn(
onSubmit={(event) => { "size-1.5 rounded-full",
event.preventDefault() serviceOk == null ? "bg-muted-foreground/50" : serviceOk ? "bg-emerald-500" : "bg-red-500",
if (canConnect && !busy) {
void handleConnect()
}
}}
>
<div className="space-y-1.5">
<Label htmlFor="serverUri">服务器地址</Label>
<Input
id="serverUri"
placeholder="http://tfs.example.com:8080/tfs"
value={form.serverUri}
onChange={updateField("serverUri")}
disabled={busy}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="domain">域(可选)</Label>
<Input
id="domain"
placeholder="DOMAIN"
value={form.domain}
onChange={updateField("domain")}
disabled={busy}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="username">用户名</Label>
<Input
id="username"
autoComplete="username"
value={form.username}
onChange={updateField("username")}
disabled={busy}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">密码</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={form.password}
onChange={updateField("password")}
disabled={busy}
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={!canConnect || busy}>
{busy ? busyText || "处理中…" : "连接 TFS"}
</Button>
</form>
)}
{phase === "collection" && (
<div className="mt-5 space-y-4">
{collections.length === 0 ? (
<p className="text-sm text-muted-foreground">当前账号没有可见的 Collection。</p>
) : (
<ul className="max-h-64 space-y-1 overflow-y-auto">
{collections.map((collection) => {
const active = collection.name === selectedCollection
return (
<li key={collection.id || collection.name}>
<button
type="button"
onClick={() => setSelectedCollection(collection.name)}
disabled={busy}
className={cn(
"flex w-full items-center justify-between rounded-md border px-3 py-2 text-left text-sm transition-colors",
active
? "border-primary bg-primary/10 text-foreground"
: "border-border bg-background hover:bg-muted",
)}
>
<span className="font-medium">{collection.name}</span>
{active && <span className="text-xs text-primary">已选择</span>}
</button>
</li>
)
})}
</ul>
)} )}
/>
{serviceOk == null
? "正在检测本地服务…"
: serviceOk
? "本地服务运行中 · 127.0.0.1:38765"
: "本地服务未就绪"}
</div>
</aside>
{error && <p className="text-sm text-destructive">{error}</p>} {/* 右侧表单区 */}
<main className="flex flex-1 items-center justify-center bg-background p-6">
<div className="w-full max-w-sm">
{phase === "form" && (
<div
key={`form-${shakeCount}`}
className={cn(
"animate-in fade-in slide-in-from-left-2 duration-200",
error != null && "animate-shake",
)}
>
<div className="space-y-1">
<h1 className="text-xl font-semibold tracking-tight">连接 TFS</h1>
<p className="text-sm text-muted-foreground">
使用域账号连接 Team Foundation Server
</p>
</div>
<div className="flex items-center justify-between gap-2"> <form
<Button className="mt-6 space-y-4"
type="button" onSubmit={(event) => {
variant="ghost" event.preventDefault()
size="sm" if (canConnect && !busy) {
onClick={() => { void handleConnect()
setError(null) }
setPhase("form")
}} }}
disabled={busy}
> >
返回 <div className="space-y-1.5">
</Button> <Label htmlFor="serverUri">服务器地址</Label>
<Button <Input
type="button" id="serverUri"
onClick={() => void handleEnter()} placeholder="http://tfs.example.com:8080/tfs"
disabled={busy || !selectedCollection} value={form.serverUri}
> onChange={updateField("serverUri")}
{busy ? busyText || "处理中…" : "进入工作台"} disabled={busy}
</Button> />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="domain">域(可选)</Label>
<Input
id="domain"
placeholder="DOMAIN"
value={form.domain}
onChange={updateField("domain")}
disabled={busy}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="username">用户名</Label>
<Input
id="username"
autoComplete="username"
value={form.username}
onChange={updateField("username")}
disabled={busy}
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">密码</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
className="pr-9"
value={form.password}
onChange={updateField("password")}
disabled={busy}
/>
<button
type="button"
tabIndex={-1}
onClick={() => setShowPassword((prev) => !prev)}
className="absolute top-1/2 right-2 -translate-y-1/2 rounded-sm p-1 text-muted-foreground transition-colors duration-150 hover:text-foreground"
aria-label={showPassword ? "隐藏密码" : "显示密码"}
>
{showPassword ? <Eye className="size-4" /> : <EyeOff className="size-4" />}
</button>
</div>
</div>
{error && (
<p className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
<Button type="submit" className="w-full" disabled={!canConnect || busy}>
{busy && <Loader2 className="size-4 animate-spin" />}
{busy ? busyText || "处理中…" : "连接 TFS"}
</Button>
</form>
{recent.length > 0 && (
<div className="mt-6 space-y-2">
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock3 className="size-3.5" />
最近连接
</p>
<ul className="space-y-1.5">
{recent.map((item) => (
<li key={`${item.serverUri}-${item.username}`}>
<button
type="button"
onClick={() => applyRecent(item)}
disabled={busy}
className="flex w-full items-center justify-between gap-2 rounded-md border bg-card px-3 py-2 text-left shadow-card transition-colors duration-150 hover:bg-muted/60"
>
<span className="min-w-0">
<span className="block truncate font-mono text-xs">
{item.serverUri}
</span>
<span className="block text-xs text-muted-foreground">
{item.domain ? `${item.domain} \\ ` : ""}
{item.username}
</span>
</span>
<span className="shrink-0 text-xs text-primary">填充</span>
</button>
</li>
))}
</ul>
</div>
)}
</div> </div>
</div> )}
)}
</div> {phase === "collection" && (
<div
key={`collection-${shakeCount}`}
className={cn(
"animate-in fade-in slide-in-from-right-4 duration-200",
error != null && "animate-shake",
)}
>
<div className="space-y-1">
<h1 className="text-xl font-semibold tracking-tight">选择 Collection</h1>
<p className="text-sm text-muted-foreground">
已连接 <span className="font-mono text-xs">{form.serverUri.trim()}</span>
</p>
</div>
<div className="mt-6 space-y-4">
{collections.length === 0 ? (
<p className="rounded-md border bg-card px-3 py-6 text-center text-sm text-muted-foreground">
当前账号没有可见的 Collection。
</p>
) : (
<ul className="max-h-72 space-y-2 overflow-y-auto">
{collections.map((collection) => {
const active = collection.name === selectedCollection
return (
<li key={collection.id || collection.name}>
<button
type="button"
onClick={() => setSelectedCollection(collection.name)}
disabled={busy}
className={cn(
"flex w-full items-center justify-between rounded-lg border px-3.5 py-3 text-left text-sm transition-colors duration-150",
active
? "border-primary/60 bg-primary/5"
: "bg-card shadow-card hover:bg-muted/60",
)}
>
<span className={cn(active && "font-medium")}>{collection.name}</span>
{active && (
<span className="animate-in zoom-in-75 fade-in flex size-4.5 items-center justify-center rounded-full bg-primary text-primary-foreground duration-150">
<Check className="size-3" />
</span>
)}
</button>
</li>
)
})}
</ul>
)}
{error && (
<p className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
<div className="flex items-center justify-between gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setError(null)
setPhase("form")
}}
disabled={busy}
>
返回
</Button>
<Button
type="button"
onClick={() => void handleEnter()}
disabled={busy || !selectedCollection}
>
{busy && <Loader2 className="size-4 animate-spin" />}
{busy ? busyText || "处理中…" : "进入工作台"}
</Button>
</div>
</div>
</div>
)}
</div>
</main>
</div> </div>
) )
} }
+47 -10
View File
@@ -1,11 +1,20 @@
import { PanelBottom, PanelLeft, PanelRight } from "lucide-react" import { FolderCog, Monitor, Moon, PanelBottom, PanelLeft, PanelRight, Sun, Unplug } from "lucide-react"
import { Button } from "~/components/ui/button" import { Button } from "~/components/ui/button"
import { Separator } from "~/components/ui/separator" import { Separator } from "~/components/ui/separator"
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip"
import { useTheme, type ThemePreference } from "~/hooks/use-theme"
import { isMacElectron } from "~/lib/platform"
import type { WorkspaceSession } from "~/lib/tfs/session" import type { WorkspaceSession } from "~/lib/tfs/session"
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils"
// 主题三态对应的图标与说明文案。
const THEME_VISUALS: Record<ThemePreference, { icon: typeof Monitor; label: string }> = {
system: { icon: Monitor, label: "主题:跟随系统" },
light: { icon: Sun, label: "主题:亮色" },
dark: { icon: Moon, label: "主题:暗色" },
}
// 三个可折叠面板的显隐状态,由 WorkspaceShell 持有。 // 三个可折叠面板的显隐状态,由 WorkspaceShell 持有。
export interface PanelVisibility { export interface PanelVisibility {
tree: boolean tree: boolean
@@ -30,10 +39,20 @@ export function TopBar({
onReconnect: () => void onReconnect: () => void
onManageWorkspace: () => void onManageWorkspace: () => void
}) { }) {
// 隐藏式标题栏下顶栏整体作为拖拽区,并为左上角红绿灯预留空间。
const macInset = isMacElectron()
const theme = useTheme()
const themeVisual = THEME_VISUALS[theme.preference]
const ThemeIcon = themeVisual.icon
return ( return (
<header className="flex h-12 shrink-0 items-center justify-between gap-3 border-b bg-background px-3"> <header
className={cn(
"app-drag flex h-12 shrink-0 items-center justify-between gap-3 px-3",
macInset && "pl-20",
)}
>
<div className="flex min-w-0 items-center gap-2 text-sm"> <div className="flex min-w-0 items-center gap-2 text-sm">
<span className="shrink-0 font-semibold">macTFS</span> <span className="shrink-0 font-semibold">MacTFS</span>
<Separator orientation="vertical" className="h-4!" /> <Separator orientation="vertical" className="h-4!" />
<span className="hidden shrink-0 text-xs text-muted-foreground lg:inline"> <span className="hidden shrink-0 text-xs text-muted-foreground lg:inline">
服务器 服务器
@@ -49,7 +68,7 @@ export function TopBar({
<span className="max-w-44 truncate font-mono text-xs">{session.workspace}</span> <span className="max-w-44 truncate font-mono text-xs">{session.workspace}</span>
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="app-no-drag flex shrink-0 items-center gap-1">
<PanelToggle <PanelToggle
label="源码目录面板" label="源码目录面板"
active={panels.tree} active={panels.tree}
@@ -72,12 +91,30 @@ export function TopBar({
<PanelBottom /> <PanelBottom />
</PanelToggle> </PanelToggle>
<Separator orientation="vertical" className="mx-1 h-4!" /> <Separator orientation="vertical" className="mx-1 h-4!" />
<Button size="sm" variant="outline" onClick={onManageWorkspace}> <Tooltip>
工作区 <TooltipTrigger asChild>
</Button> <Button size="icon-sm" variant="ghost" onClick={theme.cycle} aria-label={themeVisual.label}>
<Button size="sm" variant="outline" onClick={onReconnect}> <ThemeIcon />
重新连接 </Button>
</Button> </TooltipTrigger>
<TooltipContent side="bottom">{themeVisual.label}(点击切换)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon-sm" variant="ghost" onClick={onManageWorkspace} aria-label="工作区与映射管理">
<FolderCog />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">工作区与映射管理</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon-sm" variant="ghost" onClick={onReconnect} aria-label="重新连接">
<Unplug />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">重新连接</TooltipContent>
</Tooltip>
</div> </div>
</header> </header>
) )
+39 -21
View File
@@ -1,4 +1,4 @@
import { useCallback, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { TopBar, type PanelVisibility } from "~/components/app/top-bar" import { TopBar, type PanelVisibility } from "~/components/app/top-bar"
import { WorkspaceDialogs } from "~/components/app/workspace-dialogs" import { WorkspaceDialogs } from "~/components/app/workspace-dialogs"
@@ -7,6 +7,7 @@ import { SourceTreePanel } from "~/components/explorer/source-tree-panel"
import { WorkspaceManageDialog } from "~/components/explorer/workspace-manage-dialog" import { WorkspaceManageDialog } from "~/components/explorer/workspace-manage-dialog"
import { ChangesPanel } from "~/components/inspector/changes-panel" import { ChangesPanel } from "~/components/inspector/changes-panel"
import { ConsolePanel } from "~/components/logs/console-panel" import { ConsolePanel } from "~/components/logs/console-panel"
import { Toaster, showToast } from "~/components/ui/toaster"
import { TooltipProvider } from "~/components/ui/tooltip" import { TooltipProvider } from "~/components/ui/tooltip"
import { useFileActions } from "~/hooks/use-file-actions" import { useFileActions } from "~/hooks/use-file-actions"
import { usePendingChanges } from "~/hooks/use-pending-changes" import { usePendingChanges } from "~/hooks/use-pending-changes"
@@ -71,9 +72,23 @@ export function WorkspaceShell({
refreshLogs, refreshLogs,
}) })
// 把动作编排的 notice 转成右上角 toast:固定 id 让「正在…」与结果复用同一条目。
useEffect(() => {
if (!actions.notice) {
return
}
const { kind, text } = actions.notice
showToast({
id: "action",
kind: kind === "error" ? "error" : text.startsWith("正在") ? "loading" : "success",
text,
})
}, [actions.notice])
return ( return (
<TooltipProvider> <TooltipProvider>
<div className="flex h-svh flex-col overflow-hidden bg-background"> {/* 毛玻璃画布 + 卡片化面板:半透明画布透出 vibrancy,四个面板为圆角卡片留缝隙 */}
<div className="flex h-svh flex-col overflow-hidden bg-sidebar">
<TopBar <TopBar
session={session} session={session}
panels={panels} panels={panels}
@@ -82,24 +97,14 @@ export function WorkspaceShell({
onManageWorkspace={() => setManageOpen(true)} onManageWorkspace={() => setManageOpen(true)}
/> />
{actions.notice && ( <div className="flex min-h-0 flex-1 px-2 pb-2">
{/* 左右面板保持挂载,通过宽度 + 间距过渡实现折叠 / 展开动画 */}
<div <div
className={cn( className={cn(
"flex shrink-0 items-center gap-2 border-b px-3 py-1 text-xs", "flex min-h-0 shrink-0 overflow-hidden transition-[width,margin] duration-250 ease-out-quart",
actions.notice.kind === "error" panels.tree ? "mr-2 w-[280px]" : "mr-0 w-0",
? "bg-destructive/10 text-destructive"
: "bg-primary/5 text-foreground",
)} )}
> >
{actions.actionBusy && (
<span className="size-2 animate-pulse rounded-full bg-primary" />
)}
{actions.notice.text}
</div>
)}
<div className="flex min-h-0 flex-1">
{panels.tree && (
<SourceTreePanel <SourceTreePanel
collection={session.collection} collection={session.collection}
mappings={session.mappings} mappings={session.mappings}
@@ -107,7 +112,7 @@ export function WorkspaceShell({
onNavigate={onNavigate} onNavigate={onNavigate}
onFileAction={actions.handleFileAction} onFileAction={actions.handleFileAction}
/> />
)} </div>
<FolderItemsPanel <FolderItemsPanel
session={session} session={session}
selectedServerPath={selectedServerPath} selectedServerPath={selectedServerPath}
@@ -117,7 +122,12 @@ export function WorkspaceShell({
onFileAction={actions.handleFileAction} onFileAction={actions.handleFileAction}
onRefresh={refreshItems} onRefresh={refreshItems}
/> />
{panels.changes && ( <div
className={cn(
"flex min-h-0 shrink-0 justify-end overflow-hidden transition-[width,margin] duration-250 ease-out-quart",
panels.changes ? "ml-2 w-[340px]" : "ml-0 w-0",
)}
>
<ChangesPanel <ChangesPanel
mappings={session.mappings} mappings={session.mappings}
pendingChanges={pending.pendingChanges} pendingChanges={pending.pendingChanges}
@@ -130,16 +140,22 @@ export function WorkspaceShell({
onFileAction={actions.handleFileAction} onFileAction={actions.handleFileAction}
onRefresh={() => void pending.refresh()} onRefresh={() => void pending.refresh()}
/> />
)} </div>
</div> </div>
{panels.console && ( {/* 底部操作台通过高度过渡实现滑入滑出 */}
<div
className={cn(
"box-border shrink-0 overflow-hidden px-2 transition-[height] duration-250 ease-out-quart",
panels.console ? "h-[188px] pb-2" : "h-0",
)}
>
<ConsolePanel <ConsolePanel
refreshToken={logsRefreshToken} refreshToken={logsRefreshToken}
busy={actions.actionBusy || actions.checkinBusy} busy={actions.actionBusy || actions.checkinBusy}
busyText={actions.notice?.kind === "info" ? actions.notice.text : null} busyText={actions.notice?.kind === "info" ? actions.notice.text : null}
/> />
)} </div>
<WorkspaceDialogs <WorkspaceDialogs
dialog={actions.dialog} dialog={actions.dialog}
@@ -169,6 +185,8 @@ export function WorkspaceShell({
}} }}
/> />
)} )}
<Toaster />
</div> </div>
</TooltipProvider> </TooltipProvider>
) )
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useMemo, useState } from "react"
import { FileText, FolderClosed, Loader2, RefreshCw } from "lucide-react" import { FileText, FolderClosed, Loader2, RefreshCw } from "lucide-react"
import { FileTargetMenu } from "~/components/app/file-target-menu" import { FileTargetMenu } from "~/components/app/file-target-menu"
@@ -9,6 +9,7 @@ import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "~/components/ui/dialog" } from "~/components/ui/dialog"
@@ -30,6 +31,9 @@ import { cn } from "~/lib/utils"
* 目录对比弹窗:对已映射目录执行本地与服务端对比, * 目录对比弹窗:对已映射目录执行本地与服务端对比,
* 默认隐藏 upToDate,支持状态筛选、刷新重比,结果项走统一右键菜单。 * 默认隐藏 upToDate,支持状态筛选、刷新重比,结果项走统一右键菜单。
*/ */
// 在对比弹窗内直接执行、完成后需要刷新对比结果的动作。
const ACTIONS_NEED_RECOMPARE: FileActionId[] = ["checkout", "getLatest", "undo", "delete", "add"]
export function CompareDialog({ export function CompareDialog({
serverPath, serverPath,
mappings, mappings,
@@ -39,33 +43,51 @@ export function CompareDialog({
serverPath: string serverPath: string
mappings: MappingInfo[] mappings: MappingInfo[]
onClose: () => void onClose: () => void
onFileAction: (target: FileTarget, action: FileActionId) => void onFileAction: (target: FileTarget, action: FileActionId) => void | Promise<void>
}) { }) {
// 先选项后对比:setup 阶段确认选项,result 阶段展示对比结果。
const [phase, setPhase] = useState<"setup" | "result">("setup")
const [diffs, setDiffs] = useState<FolderDiffItem[]>([]) const [diffs, setDiffs] = useState<FolderDiffItem[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [showUpToDate, setShowUpToDate] = useState(false) const [showUpToDate, setShowUpToDate] = useState(false)
// 忽略仅本地存在的项(如 node_modules):勾选后只对比两端都有的文件,并跳过本地全量扫描。
const [ignoreLocalOnly, setIgnoreLocalOnly] = useState(false)
// 状态筛选:空集合表示不过滤(除 upToDate 开关外)。 // 状态筛选:空集合表示不过滤(除 upToDate 开关外)。
const [statusFilter, setStatusFilter] = useState<Set<string>>(new Set()) const [statusFilter, setStatusFilter] = useState<Set<string>>(new Set())
/** /**
* 执行(或重新执行)目录对比。 * 执行(或重新执行)目录对比;忽略仅本地存在的项时由后端直接跳过本地扫描。
*/ */
const runCompare = useCallback(async () => { const runCompare = useCallback(async () => {
setLoading(true) setLoading(true)
setError(null) setError(null)
const result = await api.compareFolder({ serverPath, recursive: true }) const result = await api.compareFolder({
serverPath,
recursive: true,
includeLocalOnly: !ignoreLocalOnly,
})
setLoading(false) setLoading(false)
if (!result.ok) { if (!result.ok) {
setError(result.errorMessage ?? "目录对比失败") setError(result.errorMessage ?? "目录对比失败")
return return
} }
setDiffs(result.data?.diffs ?? []) setDiffs(result.data?.diffs ?? [])
}, [serverPath]) }, [serverPath, ignoreLocalOnly])
useEffect(() => { /**
void runCompare() * 转发右键动作:签出 / 获取 / 撤销等就地执行的动作完成后自动重新对比,
}, [runCompare]) * 让结果状态(如 签出编辑)立即反映最新事实。
*/
const handleAction = useCallback(
async (target: FileTarget, action: FileActionId) => {
await onFileAction(target, action)
if (ACTIONS_NEED_RECOMPARE.includes(action)) {
await runCompare()
}
},
[onFileAction, runCompare],
)
// 结果中出现过的状态(不含 upToDate),用于生成筛选 chips。 // 结果中出现过的状态(不含 upToDate),用于生成筛选 chips。
const presentStatuses = useMemo(() => { const presentStatuses = useMemo(() => {
@@ -104,11 +126,56 @@ export function CompareDialog({
}) })
}, []) }, [])
// 选项确认阶段:先确定对比方式再执行,避免反复触发全量对比。
if (phase === "setup") {
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>目录对比</DialogTitle>
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
</DialogHeader>
<div className="space-y-3 rounded-md border bg-muted/30 p-4">
<label className="flex items-start gap-2 text-sm">
<Checkbox
checked={ignoreLocalOnly}
onCheckedChange={(value) => setIgnoreLocalOnly(value === true)}
className="mt-0.5"
/>
<span>
<span className="font-medium">忽略仅本地存在的项</span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
只对比两端都有的文件;仅本地存在的项(如 node_modules、构建产物)不计入差异,
并跳过本地全量扫描,大目录速度更快。
</span>
</span>
</label>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
取消
</Button>
<Button
onClick={() => {
setPhase("result")
void runCompare()
}}
>
开始对比
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
return ( return (
<Dialog open onOpenChange={(open) => !open && onClose()}> <Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="flex h-[88svh] max-h-[88svh] flex-col sm:max-w-[90vw]"> <DialogContent className="flex h-[88svh] max-h-[88svh] flex-col sm:max-w-[90vw]">
<DialogHeader> <DialogHeader>
<DialogTitle>目录对比</DialogTitle> <DialogTitle>目录对比{ignoreLocalOnly ? "(已忽略仅本地存在的项)" : ""}</DialogTitle>
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription> <DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
</DialogHeader> </DialogHeader>
@@ -134,6 +201,14 @@ export function CompareDialog({
/> />
显示最新项 显示最新项
</label> </label>
<Button
variant="outline"
size="sm"
onClick={() => setPhase("setup")}
disabled={loading}
>
调整选项
</Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -181,7 +256,11 @@ export function CompareDialog({
compareStatus: diff.status, compareStatus: diff.status,
}) })
return ( return (
<FileTargetMenu key={diff.serverPath} target={target} onAction={onFileAction}> <FileTargetMenu
key={diff.serverPath}
target={target}
onAction={(menuTarget, action) => void handleAction(menuTarget, action)}
>
<TableRow className="cursor-default select-none"> <TableRow className="cursor-default select-none">
<TableCell className="py-1.5"> <TableCell className="py-1.5">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
@@ -126,6 +126,7 @@ export function DiffDialog({
let diffEditor: MonacoEditor.IStandaloneDiffEditor | null = null let diffEditor: MonacoEditor.IStandaloneDiffEditor | null = null
let originalModel: MonacoEditor.ITextModel | null = null let originalModel: MonacoEditor.ITextModel | null = null
let modifiedModel: MonacoEditor.ITextModel | null = null let modifiedModel: MonacoEditor.ITextModel | null = null
let themeObserver: MutationObserver | null = null
void (async () => { void (async () => {
const { monaco, detectLanguage } = await import("~/lib/monaco") const { monaco, detectLanguage } = await import("~/lib/monaco")
if (disposed || !containerRef.current) { if (disposed || !containerRef.current) {
@@ -167,10 +168,16 @@ export function DiffDialog({
setBlockCount(diffEditor?.getLineChanges()?.length ?? 0) setBlockCount(diffEditor?.getLineChanges()?.length ?? 0)
}) })
editorRef.current = diffEditor editorRef.current = diffEditor
// 弹窗打开期间应用切换明暗主题时,同步更新 Monaco 主题。
themeObserver = new MutationObserver(() => {
monaco.editor.setTheme(isDarkTheme() ? "mactfs-dark" : "mactfs-light")
})
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] })
})() })()
return () => { return () => {
disposed = true disposed = true
editorRef.current = null editorRef.current = null
themeObserver?.disconnect()
diffEditor?.dispose() diffEditor?.dispose()
originalModel?.dispose() originalModel?.dispose()
modifiedModel?.dispose() modifiedModel?.dispose()
@@ -0,0 +1,111 @@
import {
Database,
File,
FileArchive,
FileCode,
FileImage,
FileJson,
FileSpreadsheet,
FileTerminal,
FileText,
FolderClosed,
FolderOpen,
FolderSymlink,
type LucideIcon,
} from "lucide-react"
import { cn } from "~/lib/utils"
// 扩展名 -> 图标与配色的映射规则,未命中走默认文件图标。
const FILE_ICON_RULES: { exts: string[]; icon: LucideIcon; className: string }[] = [
{
exts: ["ts", "tsx", "js", "jsx", "java", "cs", "py", "go", "rs", "c", "cpp", "h", "vue", "css", "scss", "html"],
icon: FileCode,
className: "text-blue-600 dark:text-blue-400",
},
{
exts: ["json", "xml", "yml", "yaml", "toml", "config", "properties", "gradle"],
icon: FileJson,
className: "text-amber-600 dark:text-amber-400",
},
{
exts: ["md", "txt", "doc", "docx", "pdf", "rtf"],
icon: FileText,
className: "text-zinc-500 dark:text-zinc-400",
},
{
exts: ["sql", "db"],
icon: Database,
className: "text-violet-600 dark:text-violet-400",
},
{
exts: ["png", "jpg", "jpeg", "gif", "svg", "webp", "ico", "icns"],
icon: FileImage,
className: "text-emerald-600 dark:text-emerald-400",
},
{
exts: ["zip", "rar", "7z", "tar", "gz", "jar"],
icon: FileArchive,
className: "text-orange-600 dark:text-orange-400",
},
{
exts: ["xls", "xlsx", "csv"],
icon: FileSpreadsheet,
className: "text-green-600 dark:text-green-400",
},
{
exts: ["sh", "bat", "cmd", "ps1", "zsh"],
icon: FileTerminal,
className: "text-zinc-600 dark:text-zinc-300",
},
]
/**
* 取文件名扩展名(小写,无点);无扩展名返回空串。
*/
function extOf(name: string): string {
const index = name.lastIndexOf(".")
return index > 0 ? name.slice(index + 1).toLowerCase() : ""
}
/**
* 文件 / 文件夹图标:文件按扩展名区分图标与配色,
* 文件夹区分普通(蓝色)与已映射(FolderSymlink 主色),展开态用 FolderOpen。
*/
export function FileIcon({
name,
folder,
mapped = false,
expanded = false,
className,
}: {
name: string
folder: boolean
mapped?: boolean
expanded?: boolean
className?: string
}) {
if (folder) {
const Icon = mapped ? FolderSymlink : expanded ? FolderOpen : FolderClosed
return (
<Icon
className={cn(
"size-3.5 shrink-0",
mapped ? "text-primary" : "text-sky-600 dark:text-sky-400",
className,
)}
/>
)
}
const rule = FILE_ICON_RULES.find((item) => item.exts.includes(extOf(name)))
const Icon = rule?.icon ?? File
return (
<Icon
className={cn(
"size-3.5 shrink-0",
rule?.className ?? "text-muted-foreground",
className,
)}
/>
)
}
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { FileText, FolderClosed, Loader2, RefreshCw } from "lucide-react" import { AlertCircle, FolderOpen, RefreshCw } from "lucide-react"
import { Button } from "~/components/ui/button" import { Button } from "~/components/ui/button"
import { FileTargetMenu } from "~/components/app/file-target-menu" import { FileTargetMenu } from "~/components/app/file-target-menu"
import { FileIcon } from "~/components/explorer/file-icon"
import { Badge } from "~/components/ui/badge" import { Badge } from "~/components/ui/badge"
import { import {
Table, Table,
@@ -127,7 +128,7 @@ export function FolderItemsPanel({
}) })
return ( return (
<section className="flex h-full min-w-0 flex-1 flex-col bg-background"> <section className="flex h-full min-w-0 flex-1 flex-col overflow-hidden rounded-xl border bg-card shadow-card">
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3"> <div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
<span className="shrink-0 text-xs text-muted-foreground">当前路径</span> <span className="shrink-0 text-xs text-muted-foreground">当前路径</span>
<span className="min-w-0 truncate font-mono text-xs">{selectedServerPath}</span> <span className="min-w-0 truncate font-mono text-xs">{selectedServerPath}</span>
@@ -152,23 +153,26 @@ export function FolderItemsPanel({
</div> </div>
<FileTargetMenu target={currentFolderTarget} onAction={onFileAction}> <FileTargetMenu target={currentFolderTarget} onAction={onFileAction}>
<div className="min-h-0 flex-1 overflow-auto"> {/* 由 table-container 自身承担纵横滚动:表头 sticky 不随内容滚走,横向滚动条始终贴可视区底部 */}
<div className="min-h-0 flex-1 overflow-hidden [&_[data-slot=table-container]]:h-full [&_[data-slot=table-container]]:overflow-auto">
{loading ? ( {loading ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground"> <ItemsSkeleton />
<Loader2 className="size-4 animate-spin" />
正在加载目录…
</div>
) : error ? ( ) : error ? (
<div className="flex h-full items-center justify-center p-6 text-sm text-destructive"> <div className="flex h-full flex-col items-center justify-center gap-2 p-6">
{error} <AlertCircle className="size-8 text-destructive/60" />
<p className="text-sm text-destructive">{error}</p>
</div> </div>
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground"> <div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center">
当前目录为空 <FolderOpen className="size-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">当前目录为空</p>
<p className="text-xs text-muted-foreground/70">
右键空白处可对当前目录执行获取、映射或添加本地文件
</p>
</div> </div>
) : ( ) : (
<Table> <Table>
<TableHeader className="sticky top-0 z-10 bg-background"> <TableHeader className="sticky top-0 z-10 bg-card">
<TableRow className="hover:bg-transparent"> <TableRow className="hover:bg-transparent">
<TableHead className="h-8 text-xs text-muted-foreground">名称</TableHead> <TableHead className="h-8 text-xs text-muted-foreground">名称</TableHead>
<TableHead className="h-8 w-24 text-xs text-muted-foreground">状态</TableHead> <TableHead className="h-8 w-24 text-xs text-muted-foreground">状态</TableHead>
@@ -182,7 +186,7 @@ export function FolderItemsPanel({
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{rows.map((row) => { {rows.map((row, index) => {
const selected = row.item.serverPath === selectedItemPath const selected = row.item.serverPath === selectedItemPath
const target = makeFileTarget({ const target = makeFileTarget({
source: "list", source: "list",
@@ -199,7 +203,8 @@ export function FolderItemsPanel({
> >
<TableRow <TableRow
data-state={selected ? "selected" : undefined} data-state={selected ? "selected" : undefined}
className="cursor-default select-none" className="animate-in fade-in cursor-default select-none fill-mode-backwards duration-200"
style={{ animationDelay: `${Math.min(index, 12) * 18}ms` }}
onClick={() => setSelectedItemPath(row.item.serverPath)} onClick={() => setSelectedItemPath(row.item.serverPath)}
onContextMenu={(event) => { onContextMenu={(event) => {
// 阻止冒泡到外层「当前目录」菜单,避免双菜单。 // 阻止冒泡到外层「当前目录」菜单,避免双菜单。
@@ -214,19 +219,20 @@ export function FolderItemsPanel({
> >
<TableCell className="py-1.5"> <TableCell className="py-1.5">
<span className="flex items-center gap-1.5" title={row.item.serverPath}> <span className="flex items-center gap-1.5" title={row.item.serverPath}>
{row.item.folder ? ( <FileIcon
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" /> name={row.item.name}
) : ( folder={row.item.folder}
<FileText className="size-3.5 shrink-0 text-muted-foreground" /> mapped={row.item.folder && row.localPath != null}
)} />
<span className="truncate text-sm">{row.item.name}</span> <span className="truncate text-sm">{row.item.name}</span>
</span> </span>
</TableCell> </TableCell>
<TableCell className="py-1.5"> <TableCell className="py-1.5">
<Badge <Badge
variant="secondary" variant="secondary"
className={cn("rounded-md", statusBadgeClass(row.localState))} className={cn("gap-1.5 rounded-md", statusBadgeClass(row.localState))}
> >
<span className="size-1.5 rounded-full bg-current opacity-60" />
{statusLabel(row.localState)} {statusLabel(row.localState)}
</Badge> </Badge>
</TableCell> </TableCell>
@@ -269,3 +275,25 @@ export function FolderItemsPanel({
</section> </section>
) )
} }
/**
* 目录加载骨架屏:模拟表格行的占位条,替代纯文字 loading。
*/
function ItemsSkeleton() {
return (
<div className="space-y-2 p-3">
{Array.from({ length: 9 }).map((_, index) => (
<div
key={index}
className="flex animate-pulse items-center gap-3"
style={{ animationDelay: `${index * 80}ms` }}
>
<span className="size-4 rounded bg-muted" />
<span className="h-3.5 rounded bg-muted" style={{ width: `${160 + ((index * 53) % 120)}px` }} />
<span className="h-3.5 w-14 rounded-full bg-muted" />
<span className="ml-auto h-3 w-28 rounded bg-muted" />
</div>
))}
</div>
)
}
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useRef, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import { ChevronRight, FolderClosed, FolderOpen, Loader2, RefreshCw } from "lucide-react" import { ChevronRight, Loader2, RefreshCw } from "lucide-react"
import { FileTargetMenu } from "~/components/app/file-target-menu" import { FileTargetMenu } from "~/components/app/file-target-menu"
import { FileIcon } from "~/components/explorer/file-icon"
import { Button } from "~/components/ui/button" import { Button } from "~/components/ui/button"
import { ScrollArea } from "~/components/ui/scroll-area" import { ScrollArea } from "~/components/ui/scroll-area"
import { api } from "~/lib/api" import { api } from "~/lib/api"
@@ -12,6 +13,7 @@ import {
getAncestorPaths, getAncestorPaths,
makeFileTarget, makeFileTarget,
normalizeServerPath, normalizeServerPath,
resolveLocalPath,
} from "~/lib/tfs" } from "~/lib/tfs"
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils"
@@ -127,9 +129,11 @@ export function SourceTreePanel({
) )
return ( return (
<aside className="flex h-full w-[280px] shrink-0 flex-col border-r bg-sidebar"> <aside className="flex h-full w-[280px] shrink-0 flex-col overflow-hidden rounded-xl border bg-card shadow-card">
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3"> <div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
<span className="text-xs font-medium text-muted-foreground">源码目录</span> <span className="text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
源码目录
</span>
<span className="ml-auto max-w-28 truncate text-xs text-muted-foreground">{collection}</span> <span className="ml-auto max-w-28 truncate text-xs text-muted-foreground">{collection}</span>
<Button <Button
variant="ghost" variant="ghost"
@@ -200,6 +204,7 @@ function TreeNode({
const isSelected = selectedServerPath === path const isSelected = selectedServerPath === path
// 未加载前默认认为可能有子目录;加载后子目录为空则视为叶子。 // 未加载前默认认为可能有子目录;加载后子目录为空则视为叶子。
const isLeaf = children != null && children.length === 0 const isLeaf = children != null && children.length === 0
const isMapped = resolveLocalPath(mappings, path) != null
const target = makeFileTarget({ source: "tree", folder: true, serverPath: path, mappings }) const target = makeFileTarget({ source: "tree", folder: true, serverPath: path, mappings })
return ( return (
@@ -207,8 +212,8 @@ function TreeNode({
<FileTargetMenu target={target} onAction={onFileAction}> <FileTargetMenu target={target} onAction={onFileAction}>
<div <div
className={cn( className={cn(
"group flex h-7 items-center rounded-md pr-1", "group flex h-7 items-center rounded-md pr-1 transition-colors duration-150",
isSelected ? "bg-primary/10" : "hover:bg-muted", isSelected ? "bg-sidebar-accent" : "hover:bg-sidebar-accent/60",
)} )}
style={{ paddingLeft: `${depth * 14 + 2}px` }} style={{ paddingLeft: `${depth * 14 + 2}px` }}
> >
@@ -220,10 +225,13 @@ function TreeNode({
type="button" type="button"
aria-label={isExpanded ? "收起目录" : "展开目录"} aria-label={isExpanded ? "收起目录" : "展开目录"}
onClick={() => onToggle(path)} onClick={() => onToggle(path)}
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground" className="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors duration-150 hover:text-foreground"
> >
<ChevronRight <ChevronRight
className={cn("size-3.5 transition-transform", isExpanded && "rotate-90")} className={cn(
"size-3.5 transition-transform duration-200 ease-out-quart",
isExpanded && "rotate-90",
)}
/> />
</button> </button>
)} )}
@@ -233,11 +241,7 @@ function TreeNode({
onClick={() => onSelect(path)} onClick={() => onSelect(path)}
className="flex min-w-0 flex-1 items-center gap-1.5 text-left" className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
> >
{isExpanded && !isLeaf ? ( <FileIcon name={name} folder mapped={isMapped} expanded={isExpanded && !isLeaf} />
<FolderOpen className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span <span
className={cn( className={cn(
"truncate text-sm", "truncate text-sm",
@@ -249,23 +253,26 @@ function TreeNode({
</button> </button>
</div> </div>
</FileTargetMenu> </FileTargetMenu>
{isExpanded && {isExpanded && children != null && children.length > 0 && (
children?.map((child) => ( <div className="animate-in fade-in slide-in-from-top-1 duration-200">
<TreeNode {children.map((child) => (
key={child.serverPath} <TreeNode
path={child.serverPath} key={child.serverPath}
name={child.name} path={child.serverPath}
depth={depth + 1} name={child.name}
mappings={mappings} depth={depth + 1}
childrenByPath={childrenByPath} mappings={mappings}
expanded={expanded} childrenByPath={childrenByPath}
loadingPaths={loadingPaths} expanded={expanded}
selectedServerPath={selectedServerPath} loadingPaths={loadingPaths}
onToggle={onToggle} selectedServerPath={selectedServerPath}
onSelect={onSelect} onToggle={onToggle}
onFileAction={onFileAction} onSelect={onSelect}
/> onFileAction={onFileAction}
))} />
))}
</div>
)}
</div> </div>
) )
} }
@@ -66,9 +66,11 @@ export function ChangesPanel({
}, [onCheckin, included, comment]) }, [onCheckin, included, comment])
return ( return (
<aside className="flex h-full w-[340px] shrink-0 flex-col border-l bg-sidebar"> <aside className="flex h-full w-[340px] shrink-0 flex-col overflow-hidden rounded-xl border bg-card shadow-card">
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3"> <div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
<span className="text-xs font-medium text-muted-foreground">挂起更改</span> <span className="text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
挂起更改
</span>
{pendingChanges.length > 0 && ( {pendingChanges.length > 0 && (
<span className="ml-auto text-xs text-muted-foreground">{pendingChanges.length} 项</span> <span className="ml-auto text-xs text-muted-foreground">{pendingChanges.length} 项</span>
)} )}
@@ -246,8 +248,9 @@ function PendingRow({
</span> </span>
<Badge <Badge
variant="secondary" variant="secondary"
className={cn("shrink-0 rounded-md", statusBadgeClass(change.status))} className={cn("shrink-0 gap-1.5 rounded-md", statusBadgeClass(change.status))}
> >
<span className="size-1.5 rounded-full bg-current opacity-60" />
{statusLabel(change.status)} {statusLabel(change.status)}
</Badge> </Badge>
</div> </div>
@@ -60,9 +60,11 @@ export function ConsolePanel({
) )
return ( return (
<footer className="flex h-[180px] shrink-0 flex-col border-t bg-background"> <footer className="flex h-full flex-col overflow-hidden rounded-xl border bg-card shadow-card">
<div className="flex h-8 shrink-0 items-center gap-2 border-b px-3"> <div className="flex h-8 shrink-0 items-center gap-2 border-b px-3">
<span className="text-xs font-medium text-muted-foreground">操作日志</span> <span className="text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
操作日志
</span>
{busy && ( {busy && (
<span className="flex items-center gap-1.5 text-xs text-primary"> <span className="flex items-center gap-1.5 text-xs text-primary">
<Loader2 className="size-3 animate-spin" /> <Loader2 className="size-3 animate-spin" />
@@ -95,7 +97,8 @@ export function ConsolePanel({
{ordered.map((entry, index) => ( {ordered.map((entry, index) => (
<li <li
key={`${entry.startedAt}-${entry.operation}-${index}`} key={`${entry.startedAt}-${entry.operation}-${index}`}
className="flex h-7 items-center gap-2 rounded px-1.5 text-xs hover:bg-muted" className="animate-in fade-in slide-in-from-top-1 flex h-7 items-center gap-2 rounded fill-mode-backwards px-1.5 text-xs duration-200 transition-colors hover:bg-muted"
style={{ animationDelay: `${Math.min(index, 6) * 25}ms` }}
title={entry.errorMessage ?? entry.summary} title={entry.errorMessage ?? entry.summary}
> >
{entry.success ? ( {entry.success ? (
+1 -1
View File
@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-150 outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px active:not-aria-[haspopup]:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
variant: { variant: {
+1 -1
View File
@@ -19,7 +19,7 @@ function Checkbox({
> >
<CheckboxPrimitive.Indicator <CheckboxPrimitive.Indicator
data-slot="checkbox-indicator" data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5" className="animate-in zoom-in-50 fade-in grid place-content-center text-current duration-150 [&>svg]:size-3.5"
> >
<CheckIcon <CheckIcon
/> />
+3 -3
View File
@@ -37,7 +37,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0", "fixed inset-0 isolate z-50 bg-black/20 duration-200 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 data-closed:duration-150",
className className
)} )}
{...props} {...props}
@@ -59,7 +59,7 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", "ease-spring fixed top-12 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 gap-4 rounded-xl border bg-popover p-4 text-sm text-popover-foreground shadow-dialog duration-200 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:slide-in-from-top-6 data-closed:animate-out data-closed:fade-out-0 data-closed:slide-out-to-top-4 data-closed:duration-150 data-closed:ease-out",
className className
)} )}
{...props} {...props}
@@ -128,7 +128,7 @@ function DialogTitle({
<DialogPrimitive.Title <DialogPrimitive.Title
data-slot="dialog-title" data-slot="dialog-title"
className={cn( className={cn(
"font-heading text-base leading-none font-medium", "font-heading text-base leading-none font-semibold tracking-tight",
className className
)} )}
{...props} {...props}
+1 -1
View File
@@ -44,7 +44,7 @@ function ScrollBar({
> >
<ScrollAreaPrimitive.ScrollAreaThumb <ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb" data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border" className="relative flex-1 rounded-full bg-foreground/25 transition-colors hover:bg-foreground/40"
/> />
</ScrollAreaPrimitive.ScrollAreaScrollbar> </ScrollAreaPrimitive.ScrollAreaScrollbar>
) )
+141
View File
@@ -0,0 +1,141 @@
import { useEffect, useState } from "react"
import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react"
import { cn } from "~/lib/utils"
// 轻量 toast:success / error 自动消失,loading 常驻直到被同 id 更新或手动关闭。
export type ToastKind = "success" | "error" | "loading"
export interface ToastItem {
id: string
kind: ToastKind
text: string
}
type Listener = (toasts: ToastItem[]) => void
// 模块级单例状态,让任意调用方无需 context 即可弹出 toast。
let currentToasts: ToastItem[] = []
const listeners = new Set<Listener>()
const dismissTimers = new Map<string, ReturnType<typeof setTimeout>>()
const AUTO_DISMISS_MS = 3200
const MAX_VISIBLE = 4
/**
* 通知所有挂载中的 Toaster 重新渲染。
*/
function emit() {
for (const listener of listeners) {
listener(currentToasts)
}
}
/**
* 关闭指定 toast。
*/
export function dismissToast(id: string) {
const timer = dismissTimers.get(id)
if (timer) {
clearTimeout(timer)
dismissTimers.delete(id)
}
currentToasts = currentToasts.filter((item) => item.id !== id)
emit()
}
/**
* 展示或更新一条 toast:同 id 复用同一条目并重置自动消失计时;
* loading 类型不自动消失,等待后续同 id 的 success / error 替换。
*/
export function showToast(input: { id?: string; kind: ToastKind; text: string }): string {
const id = input.id ?? `toast-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const existing = currentToasts.find((item) => item.id === id)
const next: ToastItem = { id, kind: input.kind, text: input.text }
currentToasts = existing
? currentToasts.map((item) => (item.id === id ? next : item))
: [...currentToasts.slice(-(MAX_VISIBLE - 1)), next]
const timer = dismissTimers.get(id)
if (timer) {
clearTimeout(timer)
dismissTimers.delete(id)
}
if (input.kind !== "loading") {
dismissTimers.set(
id,
setTimeout(() => dismissToast(id), AUTO_DISMISS_MS),
)
}
emit()
return id
}
// toast 类型对应的图标、强调条与边框配色:彩色左侧条 + 着色边框提高辨识度。
const KIND_VISUALS: Record<
ToastKind,
{ icon: typeof CheckCircle2; iconClass: string; barClass: string; borderClass: string }
> = {
success: {
icon: CheckCircle2,
iconClass: "text-emerald-600 dark:text-emerald-400",
barClass: "bg-emerald-500",
borderClass: "border-emerald-500/35",
},
error: {
icon: AlertCircle,
iconClass: "text-destructive",
barClass: "bg-destructive",
borderClass: "border-destructive/40",
},
loading: {
icon: Loader2,
iconClass: "animate-spin text-primary",
barClass: "bg-primary",
borderClass: "border-primary/35",
},
}
/**
* toast 挂载点:固定在窗口右上角(顶栏下方),新条目自上滑入,点击可关闭。
* 彩色强调条 + 着色边框 + 毛玻璃底,保证进度与结果提示足够显眼。
*/
export function Toaster() {
const [items, setItems] = useState<ToastItem[]>([])
useEffect(() => {
const listener: Listener = (toasts) => setItems([...toasts])
listeners.add(listener)
setItems([...currentToasts])
return () => {
listeners.delete(listener)
}
}, [])
if (items.length === 0) {
return null
}
return (
<div className="pointer-events-none fixed top-14 right-3 z-[60] flex w-[380px] flex-col gap-2">
{items.map((item) => {
const visual = KIND_VISUALS[item.kind]
const Icon = visual.icon
return (
<button
key={item.id}
type="button"
onClick={() => dismissToast(item.id)}
className={cn(
"animate-in fade-in slide-in-from-top-3 ease-out-quart pointer-events-auto relative flex items-start gap-2.5 overflow-hidden rounded-xl border bg-popover/95 py-3 pr-3.5 pl-4 text-left text-sm font-medium shadow-overlay backdrop-blur-md duration-250",
visual.borderClass,
)}
>
<span className={cn("absolute inset-y-0 left-0 w-1", visual.barClass)} />
<Icon className={cn("mt-0.5 size-4.5 shrink-0", visual.iconClass)} />
<span className="min-w-0 break-words">{item.text}</span>
</button>
)
})}
</div>
)
}
+83
View File
@@ -0,0 +1,83 @@
import { useCallback, useEffect, useState } from "react"
// 主题偏好:跟随系统 / 强制亮色 / 强制暗色。
export type ThemePreference = "system" | "light" | "dark"
const STORAGE_KEY = "mactfs.theme"
const CYCLE_ORDER: ThemePreference[] = ["system", "light", "dark"]
/**
* 当前系统外观是否为暗色。
*/
function systemPrefersDark(): boolean {
return typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches
}
/**
* 由偏好推导是否应启用暗色。
*/
function resolveIsDark(preference: ThemePreference): boolean {
return preference === "dark" || (preference === "system" && systemPrefersDark())
}
/**
* 读取持久化的主题偏好,无记录或不可用时默认跟随系统。
*/
function loadPreference(): ThemePreference {
if (typeof window === "undefined") {
return "system"
}
const raw = window.localStorage.getItem(STORAGE_KEY)
return raw === "light" || raw === "dark" ? raw : "system"
}
/**
* 应用主题到根节点:切换 .dark class,并短暂启用全局颜色过渡动画。
*/
function applyTheme(preference: ThemePreference) {
const root = document.documentElement
root.classList.add("theme-transition")
root.classList.toggle("dark", resolveIsDark(preference))
window.setTimeout(() => root.classList.remove("theme-transition"), 320)
}
/**
* 明暗主题 hook:维护「跟随系统 / 亮 / 暗」三态偏好,持久化到 localStorage,
* 跟随系统时监听系统外观变化自动切换。
*/
export function useTheme() {
const [preference, setPreferenceState] = useState<ThemePreference>(loadPreference)
useEffect(() => {
applyTheme(preference)
if (preference !== "system") {
return
}
const media = window.matchMedia("(prefers-color-scheme: dark)")
const onChange = () => applyTheme("system")
media.addEventListener("change", onChange)
return () => media.removeEventListener("change", onChange)
}, [preference])
/**
* 设置主题偏好并持久化。
*/
const setPreference = useCallback((next: ThemePreference) => {
setPreferenceState(next)
try {
window.localStorage.setItem(STORAGE_KEY, next)
} catch {
// 本地存储不可用时仅在当前会话生效
}
}, [])
/**
* 按 跟随系统 → 亮色 → 暗色 顺序循环切换。
*/
const cycle = useCallback(() => {
const index = CYCLE_ORDER.indexOf(preference)
setPreference(CYCLE_ORDER[(index + 1) % CYCLE_ORDER.length])
}, [preference, setPreference])
return { preference, setPreference, cycle }
}
+7 -1
View File
@@ -225,8 +225,14 @@ export function getFileContent(params: { serverPath?: string; localPath?: string
/** /**
* 对已映射目录执行目录对比。 * 对已映射目录执行目录对比。
* includeLocalOnly=false 时跳过本地全量扫描,仅本地存在的项(如 node_modules)不计入差异。
*/ */
export function compareFolder(body: { serverPath: string; localPath?: string; recursive?: boolean }) { export function compareFolder(body: {
serverPath: string
localPath?: string
recursive?: boolean
includeLocalOnly?: boolean
}) {
return apiClient.post<{ diffs: FolderDiffItem[] }>("/api/compare/folder", { body }) return apiClient.post<{ diffs: FolderDiffItem[] }>("/api/compare/folder", { body })
} }
+11
View File
@@ -0,0 +1,11 @@
/**
* 是否运行在 macOS 的 Electron 窗口内。
* 用于判断隐藏式标题栏与 vibrancy 场景:需要为红绿灯预留空间并启用顶栏拖拽。
*/
export function isMacElectron(): boolean {
if (typeof navigator === "undefined") {
return false
}
const ua = navigator.userAgent
return ua.includes("Electron") && ua.includes("Macintosh")
}
+47
View File
@@ -0,0 +1,47 @@
// 登录页「最近连接」本地记录,仅存非敏感字段(不存密码)。
export interface RecentServer {
serverUri: string
domain: string
username: string
lastUsedAt: number
}
const STORAGE_KEY = "mactfs.recent-servers"
const MAX_ENTRIES = 5
/**
* 读取最近连接记录,本地存储不可用或数据损坏时返回空列表。
*/
export function loadRecentServers(): RecentServer[] {
if (typeof window === "undefined") {
return []
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) {
return []
}
const parsed = JSON.parse(raw) as RecentServer[]
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
/**
* 写入一条最近连接记录:按 serverUri + username 去重置顶,最多保留 5 条。
*/
export function saveRecentServer(entry: Omit<RecentServer, "lastUsedAt">): RecentServer[] {
const next: RecentServer[] = [
{ ...entry, lastUsedAt: Date.now() },
...loadRecentServers().filter(
(item) => item.serverUri !== entry.serverUri || item.username !== entry.username,
),
].slice(0, MAX_ENTRIES)
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
} catch {
// 本地存储写入失败不影响连接流程
}
return next
}
+6
View File
@@ -16,6 +16,12 @@ export function Layout({ children }: { children: React.ReactNode }) {
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
{/* 首屏前标记 vibrancy 环境并应用主题偏好,避免暗色闪白 */}
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var ua=navigator.userAgent;if(ua.includes("Electron")&&ua.includes("Macintosh"))document.documentElement.classList.add("vibrancy");var t=localStorage.getItem("mactfs.theme");var d=t==="dark"||(t!=="light"&&matchMedia("(prefers-color-scheme: dark)").matches);if(d)document.documentElement.classList.add("dark")}catch(e){}})()`,
}}
/>
<Meta /> <Meta />
<Links /> <Links />
</head> </head>
+8 -1
View File
@@ -11,6 +11,13 @@ import type { WorkspaceSession } from "~/lib/tfs/session"
// 顶层视图状态:检测服务、服务未就绪、连接入口、已进入工作台。 // 顶层视图状态:检测服务、服务未就绪、连接入口、已进入工作台。
type AppPhase = "checking" | "blocked" | "connect" | "workspace" type AppPhase = "checking" | "blocked" | "connect" | "workspace"
/**
* 页面标题(同时作为 Electron 窗口标题)。
*/
export function meta() {
return [{ title: "MacTFS" }]
}
/** /**
* 第一版前端入口:先确保本地服务就绪,再走连接 / Collection / Workspace 流程, * 第一版前端入口:先确保本地服务就绪,再走连接 / Collection / Workspace 流程,
* 进入工作台后持有固定上下文,并统一维护左树与中间列表共享的当前路径(FE-004)。 * 进入工作台后持有固定上下文,并统一维护左树与中间列表共享的当前路径(FE-004)。
@@ -88,7 +95,7 @@ export default function Home() {
return ( return (
<div className="flex min-h-svh items-center justify-center bg-muted/40 p-6"> <div className="flex min-h-svh items-center justify-center bg-muted/40 p-6">
<div className="w-full max-w-md rounded-lg border bg-background p-6 shadow-sm"> <div className="w-full max-w-md rounded-lg border bg-background p-6 shadow-sm">
<h1 className="text-base font-semibold">macTFS</h1> <h1 className="text-base font-semibold">MacTFS</h1>
<p className="mt-1 text-sm text-muted-foreground">本地源码管理工作台</p> <p className="mt-1 text-sm text-muted-foreground">本地源码管理工作台</p>
{phase === "checking" && ( {phase === "checking" && (
+52 -1
View File
@@ -1,4 +1,4 @@
const { app, BrowserWindow, ipcMain, dialog, shell } = require("electron") const { app, BrowserWindow, ipcMain, dialog, shell, nativeImage, nativeTheme } = require("electron")
const path = require("node:path") const path = require("node:path")
const fs = require("node:fs") const fs = require("node:fs")
const os = require("node:os") const os = require("node:os")
@@ -260,13 +260,51 @@ function registerIpcHandlers() {
}) })
} }
/**
* 解析应用图标路径:开发态用 public/logo.png,打包态用构建产物中的 logo.png。
*/
function resolveAppIcon() {
const candidates = [
path.join(__dirname, "..", "public", "logo.png"),
path.join(__dirname, "..", "build", "client", "logo.png"),
]
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate
}
}
return null
}
/** /**
* 创建 Electron 主窗口,开发环境加载本地服务,生产环境加载构建后的静态页面。 * 创建 Electron 主窗口,开发环境加载本地服务,生产环境加载构建后的静态页面。
*/ */
function createWindow() { function createWindow() {
const iconPath = resolveAppIcon()
const isMac = process.platform === "darwin"
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
width: 1280, width: 1280,
height: 840, height: 840,
minWidth: 980,
minHeight: 640,
title: "MacTFS",
// 预设背景并延迟显示,避免启动白闪;macOS 下背景透明以透出 vibrancy 毛玻璃。
show: false,
backgroundColor: isMac
? "#00000000"
: nativeTheme.shouldUseDarkColors
? "#222226"
: "#f9f9fa",
// macOS 隐藏系统标题栏 + 窗口级毛玻璃材质;其它平台回退系统标题栏与实色背景。
...(isMac
? {
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 16, y: 16 },
vibrancy: "sidebar",
visualEffectState: "followWindow",
}
: {}),
...(iconPath ? { icon: nativeImage.createFromPath(iconPath) } : {}),
webPreferences: { webPreferences: {
preload: path.join(__dirname, "preload.cjs"), preload: path.join(__dirname, "preload.cjs"),
contextIsolation: true, contextIsolation: true,
@@ -274,6 +312,12 @@ function createWindow() {
}, },
}) })
mainWindow.once("ready-to-show", () => {
// 启动即铺满当前屏幕可视区域(保留程序坞与菜单栏,非独立全屏 Space)。
mainWindow.maximize()
mainWindow.show()
})
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
mainWindow.loadURL("http://localhost:5173") mainWindow.loadURL("http://localhost:5173")
return return
@@ -282,7 +326,14 @@ function createWindow() {
mainWindow.loadFile(path.join(__dirname, "../build/client/index.html")) mainWindow.loadFile(path.join(__dirname, "../build/client/index.html"))
} }
app.setName("MacTFS")
app.whenReady().then(() => { app.whenReady().then(() => {
// macOS 程序坞图标在开发态需要显式设置(BrowserWindow icon 仅对 Windows / Linux 生效)。
const iconPath = resolveAppIcon()
if (process.platform === "darwin" && iconPath && app.dock) {
app.dock.setIcon(nativeImage.createFromPath(iconPath))
}
registerIpcHandlers() registerIpcHandlers()
createWindow() createWindow()
-1
View File
@@ -12,7 +12,6 @@
"format": "prettier --write \"**/*.{ts,tsx}\"" "format": "prettier --write \"**/*.{ts,tsx}\""
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/roboto-slab": "^5.2.8",
"@react-router/node": "7.13.1", "@react-router/node": "7.13.1",
"@react-router/serve": "7.13.1", "@react-router/serve": "7.13.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
-8
View File
@@ -8,9 +8,6 @@ importers:
.: .:
dependencies: dependencies:
'@fontsource-variable/roboto-slab':
specifier: ^5.2.8
version: 5.2.8
'@react-router/node': '@react-router/node':
specifier: 7.13.1 specifier: 7.13.1
version: 7.13.1(react-router@7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3) version: 7.13.1(react-router@7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)
@@ -416,9 +413,6 @@ packages:
'@floating-ui/utils@0.2.11': '@floating-ui/utils@0.2.11':
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
'@fontsource-variable/roboto-slab@5.2.8':
resolution: {integrity: sha512-cdvOSwocP50xS01gXqnGxw7uFcCZQgn1IDlqiAmNuZP4uEVScYAKMDC+aWsbP1grn9QdMJ2frhcuVBNXniBVGA==}
'@hapi/address@5.1.1': '@hapi/address@5.1.1':
resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==} resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
@@ -3742,8 +3736,6 @@ snapshots:
'@floating-ui/utils@0.2.11': {} '@floating-ui/utils@0.2.11': {}
'@fontsource-variable/roboto-slab@5.2.8': {}
'@hapi/address@5.1.1': '@hapi/address@5.1.1':
dependencies: dependencies:
'@hapi/hoek': 11.0.7 '@hapi/hoek': 11.0.7
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+127
View File
@@ -0,0 +1,127 @@
// FE-034 补充回归:目录已被既有 Workspace 映射(含用户真实挂起更改),
// 因此跳过创建/取消映射,只验证 目录对比、签出→Diff→撤销(仅 ai-smoke 文件)、操作日志。
import { _electron as electron } from "playwright-core"
import { mkdirSync } from "node:fs"
import path from "node:path"
const ROOT = process.cwd()
const SHOT_DIR = path.join(ROOT, ".e2e-shots")
mkdirSync(SHOT_DIR, { recursive: true })
const TEST_TREE_CHAIN = ["北京排水集团人力系统", "5SRC", "MicroFront", "subapp_pm", "mactfs"]
const SMOKE_FILE = "ai-smoke-20260606051038.txt"
const results = []
/** 记录一步结果并打印。 */
function record(step, ok, detail = "") {
results.push({ step, ok, detail })
console.log(`${ok ? "PASS" : "FAIL"} | ${step}${detail ? " | " + detail : ""}`)
}
/** 截图保存。 */
async function shot(win, name) {
await win.screenshot({ path: path.join(SHOT_DIR, `${name}.png`) })
}
const app = await electron.launch({
args: ["electron/main.cjs"],
cwd: ROOT,
env: { ...process.env, NODE_ENV: "development" },
})
try {
const win = await app.firstWindow()
win.setDefaultTimeout(90000)
// 进入工作台并导航到测试目录
await win.getByRole("button", { name: "连接 TFS" }).waitFor({ timeout: 90000 })
await win.getByRole("button", { name: "连接 TFS" }).click()
await win.getByText("选择 Collection").waitFor({ timeout: 120000 })
await win.locator("ul button", { hasText: "PKUSEHR" }).first().click()
await win.getByRole("button", { name: "进入工作台" }).click()
await win.getByText("源码目录").waitFor({ timeout: 120000 })
const tree = win.locator("aside").first()
for (const name of TEST_TREE_CHAIN) {
const node = tree.getByText(name, { exact: true }).first()
await node.waitFor({ timeout: 120000 })
await node.click()
await win.waitForTimeout(2500)
}
record("0 进入工作台并导航到测试目录", true)
// A. 目录对比(目录已映射 → 菜单应可用;忽略仅本地/仅服务器项保持默认)
await tree.getByText("mactfs", { exact: true }).first().click({ button: "right" })
const compareItem = win.getByRole("menuitem", { name: "目录对比", exact: true })
await compareItem.waitFor({ timeout: 10000 })
await compareItem.click()
await win.locator("[data-slot=dialog-title]", { hasText: "目录对比" }).waitFor()
await shot(win, "12-compare-options")
// 新交互:先确认选项再执行
const startButton = win.getByRole("button", { name: /开始对比|执行对比|对比/ }).first()
if ((await startButton.count()) > 0) {
await startButton.click()
}
await win.waitForTimeout(10000)
const compareText = await win.locator("[data-slot=dialog-content]").innerText()
record("A 目录对比弹窗", !compareText.includes("目录对比失败"), compareText.slice(0, 100).replace(/\n/g, " "))
await shot(win, "12-compare")
await win.keyboard.press("Escape")
await win.locator("[data-slot=dialog-content]").waitFor({ state: "detached", timeout: 10000 }).catch(() => {})
await win.waitForTimeout(800)
// B. 签出 ai-smoke 文件 → 挂起更改出现 → 与最新版本比较 → 撤销(只动这一个文件)
const fileRow = win.locator("section table tbody tr").filter({ hasText: SMOKE_FILE }).first()
await fileRow.click({ button: "right" })
const checkoutItem = win.getByRole("menuitem", { name: "签出编辑", exact: true })
await checkoutItem.waitFor({ timeout: 10000 })
await checkoutItem.click()
await win.waitForTimeout(15000)
const pendingPanel = await win.locator("aside").nth(1).innerText()
const hasPending = pendingPanel.includes(SMOKE_FILE)
record("B1 签出文件产生挂起更改", hasPending, "")
await shot(win, "13-pending")
if (hasPending) {
const row = win.locator("aside").nth(1).getByText(SMOKE_FILE).first()
await row.click({ button: "right" })
const diffItem = win.getByRole("menuitem", { name: "与最新版本比较", exact: true })
if ((await diffItem.count()) > 0) {
await diffItem.click()
await win.waitForTimeout(8000)
const diffText = await win.locator("[data-slot=dialog-content]").innerText()
record("B2 本地 vs latest Diff(Monaco)", diffText.includes("差异") || diffText.includes("一致"), diffText.slice(0, 80).replace(/\n/g, " "))
await shot(win, "14-diff")
await win.keyboard.press("Escape")
await win.locator("[data-slot=dialog-content]").waitFor({ state: "detached", timeout: 10000 }).catch(() => {})
await win.waitForTimeout(800)
}
// 仅撤销 ai-smoke 这一个文件,避免影响用户已有挂起更改
await row.click({ button: "right" })
await win.getByRole("menuitem", { name: "撤销更改", exact: true }).click()
await win.waitForTimeout(12000)
const afterUndo = await win.locator("aside").nth(1).innerText()
record("B3 撤销该文件挂起更改", !afterUndo.includes(SMOKE_FILE), "")
await shot(win, "15-undone")
}
// C. 操作日志面板有真实记录
const consoleText = await win.locator("footer").innerText()
record("C 操作日志记录", /checkout|undo|connect|serverTree|folderDiff|compare/i.test(consoleText), consoleText.split("\n").slice(0, 5).join(" | "))
await shot(win, "16-console")
} catch (error) {
record("EXCEPTION", false, String(error).slice(0, 300))
try {
const win = await app.firstWindow()
await shot(win, "98-exception")
} catch {}
} finally {
await app.close().catch(() => {})
}
console.log("\n==== RESULT SUMMARY ====")
for (const item of results) {
console.log(`${item.ok ? "✅" : "❌"} ${item.step}${item.detail ? " — " + item.detail : ""}`)
}
const failed = results.filter((item) => !item.ok).length
console.log(`\n${results.length - failed}/${results.length} passed`)
process.exit(failed > 0 ? 1 : 0)
+12
View File
@@ -57,6 +57,18 @@ try {
record("3 进入工作台(固定上下文)", topbar.includes("PKUSEHR"), topbar.replace(/\n/g, " ")) record("3 进入工作台(固定上下文)", topbar.includes("PKUSEHR"), topbar.replace(/\n/g, " "))
await shot(win, "03-workspace") await shot(win, "03-workspace")
// 3.5 明暗主题三态切换(FE-033 回归):跟随系统 → 亮 → 暗 → 跟随系统
const themeButton = win.getByRole("button", { name: /主题:/ })
await themeButton.click()
await win.waitForTimeout(400)
await themeButton.click()
await win.waitForTimeout(600)
const darkApplied = await win.evaluate(() => document.documentElement.classList.contains("dark"))
record("3.5 主题切换到暗色", darkApplied, "")
await shot(win, "03b-dark-workspace")
await themeButton.click()
await win.waitForTimeout(600)
// 4. 逐级展开目录树到指定测试目录(懒加载真实数据) // 4. 逐级展开目录树到指定测试目录(懒加载真实数据)
const tree = win.locator("aside").first() const tree = win.locator("aside").first()
for (const name of TEST_TREE_CHAIN) { for (const name of TEST_TREE_CHAIN) {
+49
View File
@@ -0,0 +1,49 @@
// 快速视觉确认:进入工作台后截图亮色 / 暗色画布卡片布局,不做任何写操作。
import { _electron as electron } from "playwright-core"
import { mkdirSync } from "node:fs"
import path from "node:path"
const ROOT = process.cwd()
const SHOT_DIR = path.join(ROOT, ".e2e-shots")
mkdirSync(SHOT_DIR, { recursive: true })
const app = await electron.launch({
args: ["electron/main.cjs"],
cwd: ROOT,
env: { ...process.env, NODE_ENV: "development" },
})
try {
const win = await app.firstWindow()
win.setDefaultTimeout(90000)
await win.getByRole("button", { name: "连接 TFS" }).waitFor({ timeout: 90000 })
await win.screenshot({ path: path.join(SHOT_DIR, "20-connect-v2.png") })
await win.getByRole("button", { name: "连接 TFS" }).click()
await win.getByText("选择 Collection").waitFor({ timeout: 120000 })
await win.screenshot({ path: path.join(SHOT_DIR, "21-collections-v2.png") })
await win.locator("ul button", { hasText: "PKUSEHR" }).first().click()
await win.getByRole("button", { name: "进入工作台" }).click()
await win.getByText("源码目录").waitFor({ timeout: 120000 })
await win.waitForTimeout(6000)
await win.screenshot({ path: path.join(SHOT_DIR, "22-workspace-cards.png") })
// 验证表头 sticky:滚动文件列表后表头应仍可见
await win.evaluate(() => {
const container = document.querySelector('[data-slot="table-container"]')
if (container) {
container.scrollTop = 400
}
})
await win.waitForTimeout(600)
await win.screenshot({ path: path.join(SHOT_DIR, "24-sticky-header.png") })
const themeButton = win.getByRole("button", { name: /主题:/ })
await themeButton.click()
await win.waitForTimeout(400)
await themeButton.click()
await win.waitForTimeout(800)
await win.screenshot({ path: path.join(SHOT_DIR, "23-workspace-cards-dark.png") })
await themeButton.click()
await win.waitForTimeout(400)
console.log("screenshots done")
} finally {
await app.close().catch(() => {})
}
+14 -3
View File
@@ -31,14 +31,14 @@ blocked
| Core | 12 | 12 | 100% | | Core | 12 | 12 | 100% |
| Server API | 10 | 10 | 100% | | Server API | 10 | 10 | 100% |
| CLI | 2 | 2 | 100% | | CLI | 2 | 2 | 100% |
| Frontend | 25 | 25 | 100% | | Frontend | 34 | 34 | 100% |
| Feature E2E | 0 | 5 | 0% | | Feature E2E | 0 | 5 | 0% |
| Release | 0 | 4 | 0% | | Release | 0 | 4 | 0% |
| Overall | 49 | 58 | 84% | | Overall | 58 | 67 | 87% |
## 当前阶段 ## 当前阶段
Feature E2E(前端含两批增强 FE-001 ~ FE-025 已全部完成,需要真实 TFS 环境) UI 风格改版·macOS 原生风方案完成(分支 `feature/ui-macos-restyle`,FE-026 ~ FE-034 全部 done;姊妹分支 `feature/ui-modern-restyle` 的现代工具风方案也已完成,两套可并行对比体验,截图见 [docs/ui-restyle-screenshots](/Users/fenghp/Desktop/DEV/project/mydev/docs/ui-restyle-screenshots))
## 前端规范 ## 前端规范
@@ -46,7 +46,9 @@ Feature E2E(前端含两批增强 FE-001 ~ FE-025 已全部完成,需要真
## 下一步任务 ## 下一步任务
- 两个 UI 分支对比体验后选定方案
- [FEATURE-001-连接与目录浏览端到端验收.md](/Users/fenghp/Desktop/DEV/project/mydev/task/feature/FEATURE-001-连接与目录浏览端到端验收.md)(需要真实 TFS 环境) - [FEATURE-001-连接与目录浏览端到端验收.md](/Users/fenghp/Desktop/DEV/project/mydev/task/feature/FEATURE-001-连接与目录浏览端到端验收.md)(需要真实 TFS 环境)
- UI 改版完成后再回到 [FEATURE-001-连接与目录浏览端到端验收.md](/Users/fenghp/Desktop/DEV/project/mydev/task/feature/FEATURE-001-连接与目录浏览端到端验收.md)(需要真实 TFS 环境)
## Core ## Core
@@ -116,6 +118,15 @@ Feature E2E(前端含两批增强 FE-001 ~ FE-025 已全部完成,需要真
| [FE-023-实现分支.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-023-实现分支.md) | done | P1 | | [FE-023-实现分支.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-023-实现分支.md) | done | P1 |
| [FE-024-实现合并.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-024-实现合并.md) | done | P1 | | [FE-024-实现合并.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-024-实现合并.md) | done | P1 |
| [FE-025-实现访达打开与添加本地文件.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-025-实现访达打开与添加本地文件.md) | done | P0 | | [FE-025-实现访达打开与添加本地文件.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-025-实现访达打开与添加本地文件.md) | done | P0 |
| [FE-026-建立macOS设计基建.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-026-建立macOS设计基建.md) | done | P0 |
| [FE-027-Electron窗口macOS化.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-027-Electron窗口macOS化.md) | done | P0 |
| [FE-028-重设计登录页.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-028-重设计登录页.md) | done | P0 |
| [FE-029-工作台外壳与顶栏改版.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-029-工作台外壳与顶栏改版.md) | done | P0 |
| [FE-030-目录树与文件列表精致化.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-030-目录树与文件列表精致化.md) | done | P0 |
| [FE-031-弹窗体系统一macOS风格.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-031-弹窗体系统一macOS风格.md) | done | P1 |
| [FE-032-操作反馈与微交互.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-032-操作反馈与微交互.md) | done | P1 |
| [FE-033-明暗主题跟随系统.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-033-明暗主题跟随系统.md) | done | P1 |
| [FE-034-UI改版回归验收.md](/Users/fenghp/Desktop/DEV/project/mydev/task/frontend/FE-034-UI改版回归验收.md) | done | P0 |
## Feature E2E ## Feature E2E
@@ -0,0 +1,74 @@
# FE-026 建立macOS设计基建
## 状态
done
## 优先级
P0
## 所属阶段
frontend
## 依赖任务
无
## 需求来源
- [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md) U1
## 目标
把全局设计令牌升级为 macOS 现代风格,作为后续所有 UI 改版任务的统一基础。
## 实现范围
- 字体切换为系统字体栈(`-apple-system, BlinkMacSystemFont, "PingFang SC"` 等),移除 Roboto Slab 作为全局字体;代码/Diff 场景保留等宽字体
- 重定义颜色令牌:macOS 蓝主色、中性灰分级、分隔线、半透明材质变量,light/dark 两套完整定义
- 统一圆角档位(约 6/10/14px)、分层阴影(卡片/浮层/弹窗)、间距档位
- 建立动效令牌:统一时长(150/220/320ms)与缓动曲线变量
- 预留 vibrancy 透明背景变量(供 FE-027 使用)
## 不在范围
- 不改任何组件结构与业务逻辑
- 不实现主题切换入口(FE-033 做)
- 不改 Electron 窗口配置(FE-027 做)
## 涉及文件
- `mactfsui/app/app.css`
- `mactfsui/package.json`(如需移除字体依赖或引入 motion)
## 验收标准
- 全局字体为系统字体栈,界面文字观感与 macOS 原生应用一致
- light/dark 两套 token 完整、可用
- 圆角、阴影、动效时长全部来自统一变量,无散落魔法值
- `pnpm typecheck` 通过,现有页面无功能回归
## 测试方式
```bash
cd mactfsui && pnpm typecheck && pnpm electron:dev
```
## 完成记录
- 实际修改文件:
- `mactfsui/app/app.css`
- `mactfsui/package.json`、`mactfsui/pnpm-lock.yaml`(移除 `@fontsource-variable/roboto-slab`)
- 实际实现内容:
- 全局字体切换为系统字体栈(SF Pro / PingFang SC 优先),`html` 改 `font-sans`;新增 `--font-mono` 等宽令牌
- 主色改为 macOS 系统蓝(light ≈ #007AFF、dark ≈ #0A84FF),`--accent` 与主色解耦改中性悬停色
- 中性灰分级 light/dark 两套完整定义(dark 深灰非纯黑)
- 半透明材质变量:`--sidebar` / `--sidebar-accent` 带透明度,配套 `html.vibrancy` 时页面底透明(供 FE-027 毛玻璃透出),vibrancy 未开启时半透明叠在实色上近似实色、可安全回退
- 圆角基准 0.875rem → 0.625rem(6/8/10/14px 档位);阴影分层 card/overlay/dialog;动效令牌 150/220/320ms + ease-out-quart / spring
- 测试结果:
- `pnpm typecheck` 通过、`pnpm build` 通过
- 视觉走查随 FE-027 ~ FE-034 进行
- 遗留问题:
- 无
@@ -0,0 +1,77 @@
# FE-027 Electron窗口macOS化
## 状态
done
## 优先级
P0
## 所属阶段
frontend
## 依赖任务
- FE-026
## 需求来源
- [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md) U2
## 目标
让应用窗口具备 macOS 原生质感:隐藏式标题栏、红绿灯融入顶栏、毛玻璃材质。
## 实现范围
- BrowserWindow 配置 `titleBarStyle: "hiddenInset"`,校准 trafficLightPosition
- 顶栏对应区域设置 `-webkit-app-region: drag`,交互控件设置 no-drag
- 开启窗口 vibrancy(`sidebar` 或 `under-window` 材质),配合 FE-026 的透明背景变量让侧栏呈毛玻璃
- 核对窗口默认尺寸与最小尺寸
- 非 macOS 或 vibrancy 不可用时回退纯色背景,不影响功能
## 不在范围
- 顶栏内容与布局重排(FE-029 做)
- 打包签名相关配置(RELEASE 阶段做)
## 涉及文件
- `mactfsui/electron/main.cjs`
- `mactfsui/app/components/app/top-bar.tsx`(仅拖拽区域标记)
- `mactfsui/app/app.css`(透明背景接线)
## 验收标准
- 窗口无系统标题栏,红绿灯悬浮于顶栏内且不遮挡控件
- 顶栏空白区可拖动窗口,按钮等控件可正常点击
- 侧栏可见毛玻璃效果;关闭 vibrancy 时回退正常
- 全部既有功能无回归
## 测试方式
```bash
cd mactfsui && pnpm electron:dev
```
## 完成记录
- 实际修改文件:
- `mactfsui/electron/main.cjs`
- `mactfsui/app/components/app/top-bar.tsx`(拖拽区域 + 红绿灯留白)
- `mactfsui/app/app.css`(app-drag / app-no-drag 工具类)
- `mactfsui/app/root.tsx`(首屏前挂载 html.vibrancy 标记)
- 新增 `mactfsui/app/lib/platform.ts`
- 实际实现内容:
- macOS 下 `titleBarStyle: "hiddenInset"` + `trafficLightPosition {16,16}`,非 macOS 回退系统标题栏
- 开启窗口 vibrancy(`sidebar` 材质,followWindow);macOS 背景设全透明配合 `html.vibrancy body { background: transparent }` 让毛玻璃可透出;非 macOS 用实色背景
- `show:false + ready-to-show` 显示并默认最大化铺满屏幕(带上现代风分支已确认的体验决策)
- `minWidth 980 / minHeight 640`
- 顶栏 `app-drag` 拖拽 + 控件 `app-no-drag`,macOS Electron 下 `pl-20` 为红绿灯留白
- 测试结果:
- `pnpm typecheck` 通过、`pnpm build` 通过,产物确认 app-drag / vibrancy 已编译
- 毛玻璃透出效果在 FE-029 侧栏完成后整体截图验证
- 遗留问题:
- 登录页(无顶栏)拖拽区在 FE-028 补充
@@ -0,0 +1,77 @@
# FE-028 重设计登录页
## 状态
done
## 优先级
P0
## 所属阶段
frontend
## 依赖任务
- FE-026
## 需求来源
- [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md) U3
## 目标
把登录页从单张表单卡片升级为有品牌感、有动效、元素丰富的现代登录页。
## 实现范围
- 左右分栏布局:左侧品牌区(logo、产品名、标语、版本号、氛围背景动效),右侧连接表单卡片
- 表单 → Collection 选择两阶段切换增加滑动 + 淡入淡出过渡
- 新增:密码可见性切换按钮、最近连接记录(localStorage 多条、一键填充)、本地服务健康状态指示灯
- 连接中按钮内 spinner、阶段文案过渡;连接失败表单抖动 + 错误提示条
- Collection 列表改卡片式:悬停浮起、选中高亮过渡
- 保持现有 `api.connect / listCollections / ensureWorkspace` 调用链路不变
## 不在范围
- 不改连接协议与认证方式
- 不改 `app/lib/api` 接口契约(最近连接仅存前端本地)
## 涉及文件
- `mactfsui/app/components/app/connect-view.tsx`
- 新增 `mactfsui/app/lib/recent-servers.ts`(或等价的本地存储工具)
## 验收标准
- 登录页具备品牌区与氛围背景,观感现代精致
- 两阶段切换有流畅过渡动效
- 最近连接可保存、可一键填充;密码可见性切换可用
- 连接成功进入工作台的链路与改版前完全一致
## 测试方式
```bash
cd mactfsui && pnpm typecheck && pnpm electron:dev
# 手工验证:连接真实 TFS → 选择 Collection → 进入工作台
```
## 完成记录
- 实际修改文件:
- `mactfsui/app/components/app/connect-view.tsx`(整体重写)
- 新增 `mactfsui/app/lib/recent-servers.ts`
- `mactfsui/app/app.css`(shake 抖动动画令牌)
- 实际实现内容:
- 左右分栏:左侧 `bg-sidebar` 半透明品牌区(vibrancy 下呈毛玻璃),居中大 logo(drop-shadow 跟随透明轮廓)+「MacTFS」+ 一句话描述 + 底部服务健康指示灯(吸收现代风分支用户反馈:品牌区只放大 logo + 一句话)
- 表单 → Collection 两阶段滑动淡入过渡;密码可见性切换;最近连接(localStorage 5 条去重置顶、一键填充、不存密码)
- 连接中按钮 spinner + 阶段文案;失败错误条 + 表单抖动
- Collection 卡片式选择:选中 = 主色细边框 + 浅主色底 + 蓝色对勾(zoom 入场),未选中 hover 仅底色微变(直接采用对比分支已确认的选中样式)
- 登录页顶部 `app-drag` 拖拽条(补 FE-027 遗留)
- `api.connect / listCollections / ensureWorkspace` 链路不变
- 测试结果:
- `pnpm typecheck` 通过、`pnpm build` 通过,shake 动画已编译
- 真实连接流程随 FE-034 回归验证
- 遗留问题:
- 无
@@ -0,0 +1,78 @@
# FE-029 工作台外壳与顶栏改版
## 状态
done
## 优先级
P0
## 所属阶段
frontend
## 依赖任务
- FE-026
- FE-027
## 需求来源
- [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md) U4
## 目标
把三栏工作台外壳与顶栏升级为 macOS 工具栏 / Finder 侧栏风格,并补齐布局动效。
## 实现范围
- 顶栏按 macOS 工具栏风格重排:为红绿灯预留左侧空间,上下文信息居左、动作按钮图标化居右
- 左侧目录树面板改 Finder 式侧栏:毛玻璃背景(接 FE-027 vibrancy)、圆角选中条、分组标题样式
- 左右面板折叠/展开增加宽度过渡动画,底部操作台增加滑入滑出动画
- 统一三栏分隔线、内边距与留白
## 不在范围
- 目录树节点与文件列表行内样式(FE-030 做)
- 弹窗样式(FE-031 做)
- 不改动作编排与状态模型
## 涉及文件
- `mactfsui/app/components/app/workspace-shell.tsx`
- `mactfsui/app/components/app/top-bar.tsx`
## 验收标准
- 顶栏与红绿灯融合自然,动作按钮可用且有 tooltip
- 侧栏呈毛玻璃 Finder 风格
- 折叠/展开、底部操作台均有流畅动画且不卡顿
- 同步导航、刷新等既有行为无回归
## 测试方式
```bash
cd mactfsui && pnpm typecheck && pnpm electron:dev
# 手工验证:折叠/展开左右面板与底部操作台,检查动画与功能
```
## 完成记录
- 实际修改文件:
- `mactfsui/app/components/app/workspace-shell.tsx`
- `mactfsui/app/components/app/top-bar.tsx`
- `mactfsui/app/components/explorer/source-tree-panel.tsx`(面板标题样式)
- `mactfsui/app/components/inspector/changes-panel.tsx`(改为不透明底 + 标题样式)
- `mactfsui/app/components/logs/console-panel.tsx`(高度交由外层控制 + 标题样式)
- 实际实现内容:
- Finder 式布局:根容器不铺底色,vibrancy 下顶栏整条与左侧目录树呈窗口级毛玻璃;中间列表 / 右侧挂起 / 底部日志保持不透明内容底
- 顶栏透明融入材质,红绿灯左侧留白,动作按钮图标化(工作区→FolderCog、重新连接→Unplug)配 tooltip
- 左右面板折叠改保持挂载 + 宽度过渡(250ms ease-out-quart),底部操作台高度过渡滑入滑出
- 三个面板标题统一小字号大写风格
- 非 macOS / 非 Electron 环境 body 实色回退,布局不受影响
- 测试结果:
- `pnpm typecheck` 通过、`pnpm build` 通过
- 毛玻璃整体效果在 FE-034 回归截图确认
- 遗留问题:
- 顶部通知条仍为旧样式,FE-032 用 toast 替换
@@ -0,0 +1,75 @@
# FE-030 目录树与文件列表精致化
## 状态
done
## 优先级
P0
## 所属阶段
frontend
## 依赖任务
- FE-029
## 需求来源
- [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md) U5
## 目标
把左侧目录树与中间文件列表的行样式、图标、加载与空状态升级到精致水平。
## 实现范围
- 树节点与文件行统一行高、圆角悬停/选中态;展开箭头旋转动画、子级展开渐入
- 按扩展名区分文件图标与颜色,文件夹/分支/已映射目录差异化图标
- 列表加载改骨架屏;空目录、未连接等空状态配图标 + 引导文案
- pendingEdit / pendingAdd / pendingDelete / 未下载等状态徽标统一配色与形状
- 文件列表行级联渐入动效(首次加载时)
## 不在范围
- 不改目录加载、右键菜单与动作编排逻辑
- 不改服务端接口与数据结构
## 涉及文件
- `mactfsui/app/components/explorer/source-tree-panel.tsx`
- `mactfsui/app/components/explorer/folder-items-panel.tsx`
## 验收标准
- 树与列表行有清晰的悬停/选中反馈,展开收起有动画
- 不同类型文件图标可区分,状态徽标风格统一
- 加载呈骨架屏,空状态有引导
- 导航、右键菜单、双击打开等既有行为无回归
## 测试方式
```bash
cd mactfsui && pnpm typecheck && pnpm electron:dev
# 手工验证:浏览多层目录、含挂起更改的目录,检查图标与徽标
```
## 完成记录
- 实际修改文件:
- 新增 `mactfsui/app/components/explorer/file-icon.tsx`(扩展名 → 图标/配色映射)
- `mactfsui/app/components/explorer/source-tree-panel.tsx`
- `mactfsui/app/components/explorer/folder-items-panel.tsx`
- `mactfsui/app/components/ui/scroll-area.tsx`(滚动条加深,与选中行区分)
- 实际实现内容:
- 文件图标体系:代码/配置/文档/SQL/图片/压缩包/表格/脚本 8 类图标与配色;文件夹普通蓝色、已映射 FolderSymlink 主色、展开态 FolderOpen
- 树节点:选中态 `bg-sidebar-accent` 半透明圆角条(毛玻璃上呈 Finder 选中观感)、悬停过渡、展开箭头旋转、子级渐入
- 文件列表:行级联渐入;状态徽标统一「小圆点 + 浅底色」;9 行骨架屏;空目录/错误空状态
- 修正滚动体验(吸收对比分支用户反馈):表头 sticky 改由表格容器承担纵横滚动,横向滚动条贴可视区底部;滚动条滑块加深
- 测试结果:
- `pnpm typecheck` 通过、`pnpm build` 通过
- 真实目录浏览随 FE-034 回归
- 遗留问题:
- 无
@@ -0,0 +1,75 @@
# FE-031 弹窗体系统一macOS风格
## 状态
done
## 优先级
P1
## 所属阶段
frontend
## 依赖任务
- FE-026
## 需求来源
- [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md) U6
## 目标
把全部弹窗统一为 macOS Sheet 风格,结构一致、动效一致。
## 实现范围
- 统一 Dialog 基础组件:顶部标题区、内容区、底部按钮区结构与间距规范
- 出入场动效统一为缩放 + 淡入(spring 近似曲线),遮罩淡入淡出
- 覆盖弹窗:mapping / history / compare / diff / file-view / conflict / properties / rename / get-version / branch / merge / workspace-manage / add-files / confirm / workspace-dialogs
- 大弹窗(History、Compare、Diff)优化分区留白与表格样式
- 弹窗内表单控件按 FE-026 token 对齐
## 不在范围
- 不改各弹窗的业务流程与接口调用
- Monaco Diff 编辑器内部主题深度定制
## 涉及文件
- `mactfsui/app/components/ui/dialog.tsx`
- `mactfsui/app/components/explorer/*-dialog.tsx`(13 个)
- `mactfsui/app/components/app/confirm-dialog.tsx`
- `mactfsui/app/components/app/workspace-dialogs.tsx`
## 验收标准
- 所有弹窗结构、间距、按钮排布一致,出入场动效统一流畅
- 大弹窗内表格与分区样式精致清晰
- 各弹窗功能(确认、提交、二次弹窗)无回归
## 测试方式
```bash
cd mactfsui && pnpm typecheck && pnpm electron:dev
# 手工验证:逐一打开全部弹窗检查样式与动效
```
## 完成记录
- 实际修改文件:
- `mactfsui/app/components/ui/dialog.tsx`
- 实际实现内容:
- 全部 15 个弹窗共用同一 Dialog 基础组件,在基础组件统一升级为 macOS Sheet 风格,等效全量统一:
- 定位改为顶部锚定(top-12 水平居中),打开时自顶部下滑 + 淡入(spring 近似曲线 200ms),关闭时上滑淡出 150ms——贴近 macOS sheet 从标题栏垂下的动效
- 容器 `border + shadow-dialog` 取代原 ring,rounded-xl(14px)
- 标题统一 `font-semibold tracking-tight`
- 遮罩 `bg-black/20 + backdrop-blur` 淡入淡出
- 底部按钮区沿用 DialogFooter 的 `bg-muted/50 + border-t` 分区带;查看器形态弹窗(History/Diff/FileView/Properties)无底栏,结构保持
- 测试结果:
- `pnpm typecheck` 通过、`pnpm build` 通过,产物确认 slide-in-from-top-6 / ease-spring 已编译
- 顶部锚定与最小窗口(980×640)下 92svh 高弹窗的边界在 FE-034 回归确认
- 遗留问题:
- 无
@@ -0,0 +1,77 @@
# FE-032 操作反馈与微交互
## 状态
done
## 优先级
P1
## 所属阶段
frontend
## 依赖任务
- FE-026
## 需求来源
- [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md) U7
## 目标
补齐全局操作反馈(toast、进度)与控件微交互,让操作结果即时可感知。
## 实现范围
- 引入轻量 toast 通知(成功/失败/进行中),接入既有动作编排的结果回调,与日志面板互补
- 挂起更改面板、日志面板按新 token 改版;新日志条目滑入动画
- 控件微交互:按钮按压缩放、输入框聚焦光环、复选框勾选动画、hover 过渡
- 长操作(GetLatest、Checkin、目录对比)期间展示进度反馈动效
## 不在范围
- 不改动作编排逻辑与日志数据结构
- 不做系统级通知(macOS 通知中心)
## 涉及文件
- `mactfsui/app/components/inspector/changes-panel.tsx`
- `mactfsui/app/components/logs/console-panel.tsx`
- `mactfsui/app/components/ui/button.tsx`、`input.tsx`、`checkbox.tsx`
- 新增 toast 组件与挂载点(`app/root.tsx` 或 workspace-shell)
## 验收标准
- 关键操作(签出、签入、获取、撤销等)成功/失败均有 toast 反馈
- 面板样式与全局风格统一,日志新条目有动画
- 控件具备统一微交互,无性能问题
- 既有操作链路无回归
## 测试方式
```bash
cd mactfsui && pnpm typecheck && pnpm electron:dev
# 手工验证:执行签出/撤销/获取最新,观察 toast 与面板反馈
```
## 完成记录
- 实际修改文件:
- 新增 `mactfsui/app/components/ui/toaster.tsx`(无依赖轻量 toast)
- `mactfsui/app/components/app/workspace-shell.tsx`(notice → toast 接线,移除顶部通知条,挂载 Toaster)
- `mactfsui/app/components/logs/console-panel.tsx`(日志条目滑入动画)
- `mactfsui/app/components/inspector/changes-panel.tsx`(状态徽标加圆点)
- `mactfsui/app/components/ui/button.tsx`(按压缩放 + 150ms 过渡)
- `mactfsui/app/components/ui/checkbox.tsx`(勾选 zoom+fade 动画)
- 实际实现内容:
- toast 体系:success / error 3.2 秒自动消失,loading(「正在…」)常驻被同 id 结果替换;右下角滑入、点击关闭、同屏最多 4 条
- 动作编排零改动:监听 `actions.notice` 映射 toast,长操作(GetLatest、Checkin、目录对比)天然获得进行中→结果反馈
- 日志面板新条目 fade+slide 滑入;控件微交互(按钮按压、复选框勾选、输入框聚焦光环沿用既有 ring)
- 测试结果:
- `pnpm typecheck` 通过、`pnpm build` 通过
- toast 实际触发随 FE-034 回归验证
- 遗留问题:
- 无
@@ -0,0 +1,82 @@
# FE-033 明暗主题跟随系统
## 状态
done
## 优先级
P1
## 所属阶段
frontend
## 依赖任务
- FE-026
- FE-027
- FE-028
- FE-029
- FE-030
- FE-031
- FE-032
## 需求来源
- [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md) U8
## 目标
实现明暗主题自动跟随 macOS 系统外观,并提供手动切换入口。
## 实现范围
- 监听系统外观(`prefers-color-scheme` / Electron nativeTheme),自动切换 `.dark`
- 顶栏增加三态切换:跟随系统 / 亮色 / 暗色,选择持久化到 localStorage
- 主题切换时全局颜色过渡动画
- 校验全部页面、面板、弹窗、Monaco Diff 在 dark 下的对比度与可读性,修正不达标 token
## 不在范围
- 自定义主题色 / 多主题方案
- Monaco 编辑器自定义配色(仅在明暗两套内置主题间切换)
## 涉及文件
- `mactfsui/app/app.css`
- `mactfsui/app/components/app/top-bar.tsx`
- 新增 `mactfsui/app/hooks/use-theme.ts`(或等价实现)
- `mactfsui/app/components/explorer/diff-dialog.tsx`、`file-view-dialog.tsx`(Monaco 主题接线)
## 验收标准
- 系统切换外观时应用自动跟随;手动选择优先于系统并可持久化
- 切换有平滑过渡,无闪烁
- dark 模式下所有界面可读性达标,无「白块」遗漏
## 测试方式
```bash
cd mactfsui && pnpm typecheck && pnpm electron:dev
# 手工验证:切换 macOS 系统外观 + 顶栏手动三态切换,全页面走查
```
## 完成记录
- 实际修改文件:
- 新增 `mactfsui/app/hooks/use-theme.ts`
- `mactfsui/app/components/app/top-bar.tsx`(三态主题切换按钮)
- `mactfsui/app/root.tsx`(首屏前同步主题,防暗色闪白)
- `mactfsui/app/app.css`(theme-transition 全局颜色过渡)
- `mactfsui/app/components/explorer/diff-dialog.tsx`(MutationObserver 同步 Monaco 主题)
- 实际实现内容:
- `useTheme`:「跟随系统 / 亮 / 暗」三态持久化;跟随系统时监听 `prefers-color-scheme`
- 顶栏循环切换按钮(Monitor/Sun/Moon + tooltip)
- 切换瞬间 250ms 全局颜色过渡;root 内联脚本首屏前置 dark class
- Diff 弹窗打开期间切主题即时跟随(mactfs-light / mactfs-dark)
- 测试结果:
- `pnpm typecheck` 通过、`pnpm build` 通过
- dark + vibrancy 组合观感随 FE-034 截图确认
- 遗留问题:
- 手动强制亮/暗与系统外观不一致时,窗口 vibrancy 毛玻璃底色仍跟随系统(Electron nativeTheme 未随手动开关切换),如需完全一致可后续通过 preload 增加 themeSource 同步
@@ -0,0 +1,81 @@
# FE-034 UI改版回归验收
## 状态
done
## 优先级
P0
## 所属阶段
frontend
## 依赖任务
- FE-026 ~ FE-033 全部完成
## 需求来源
- [docs/mactfs-ui-restyle-requirements.md](/Users/fenghp/Desktop/DEV/project/mydev/docs/mactfs-ui-restyle-requirements.md) U9
- [TFS-AI测试流程.md](/Users/fenghp/Desktop/DEV/project/mydev/TFS-AI测试流程.md)
## 目标
确认 UI 改版后所有业务链路零回归,作为本批改版的收口验收。
## 实现范围
- 按测试大纲走查 UI 链路:登录连接、Collection 选择、目录浏览、Mapping、GetLatest、签出/新增/删除/撤销、签入、历史、Diff、目录对比、分支合并、工作区管理
- 明暗两种主题下分别走查关键页面
- 确认 `app/lib` 与接口契约零改动(diff 检查)
- 关键页面截图存档到 `docs/` 或任务完成记录
- `pnpm typecheck` 通过
## 不在范围
- 真实 TFS 写操作验收(属 FEATURE-001 ~ 005,按测试大纲限定目录执行)
- 性能压测
## 涉及文件
- 无新增改动(验收任务,发现问题回各任务修复)
## 验收标准
- 全部业务链路在新 UI 下可用,无功能回归
- 明暗主题下均无样式破损
- typecheck 通过
- 验收记录与截图归档
## 测试方式
```bash
cd mactfsui && pnpm typecheck && pnpm electron:dev
# 按 TFS-AI测试流程.md 第 6 节分组走查
```
## 完成记录
- 实际修改文件:
- 移植 `mactfsui/scripts/e2e-electron.mjs`、`e2e-electron-part2.mjs`、`shot-workspace.mjs`(与现代风分支共用同一套回归脚本)
- 新增 `docs/ui-restyle-screenshots/`(macOS 风 6 张关键页面截图)
- 实际实现内容(验收执行):
- playwright-core 驱动真实 Electron 应用连真实 TFS,两轮共 13 项检查通过:
1. 本地服务自动拉起 → macOS 风登录页(毛玻璃品牌区/最近连接/服务指示灯)
2. 连接真实 TFS → Collection 卡片选择 → 进入工作台(上下文正确)
3. 主题切换 跟随系统→亮→暗 生效(暗色工作台截图存档)
4. 目录树懒加载导航至指定测试目录
5. 目录历史弹窗(Sheet 式,真实 Changeset 678739)
6. 文件查看弹窗(服务器 latest)
7. 目录对比(先确认选项再执行流程正常)
8. 签出文件 → 挂起更改出现 → 本地 vs latest Monaco Diff → 撤销恢复现场(仅动 ai-smoke 测试文件)
9. 操作日志面板记录真实 API 调用(undo /api/files/undo 等)
- 接口契约零改动:`app/lib/api` 未触碰(新增 platform.ts / recent-servers.ts 为纯前端工具)
- `pnpm typecheck` / `pnpm build` 每任务提交前均通过
- 测试结果:
- 13/14 PASS;1 项 FAIL 为环境因素:「创建/取消映射」跳过(测试目录已被既有 Workspace 映射且含用户真实挂起更改,避免破坏现场)
- 遗留问题:
- 「映射到本地 / 取消映射」建议在干净环境人工补验一次
- vibrancy 毛玻璃实际透出效果(截图合成不含窗后模糊)建议真机肉眼确认一次