Merge feature/ui-macos-restyle into main

合并 macOS 原生风 UI 改版分支,冲突以 feature 分支为准
(feature 为当前维护主线,取代 main 上的并行前端尝试)。

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	task/frontend/FE-001-实现Electron启动服务与API客户端.md
#	task/frontend/FE-002-实现登录配置页.md
#	task/frontend/FE-003-实现VS风格主布局.md
#	task/frontend/FE-004-实现Collection服务端目录树.md
#	task/frontend/FE-005-实现中间目录文件列表.md
#	task/frontend/FE-006-实现Mapping创建流程.md
#	task/frontend/FE-007-实现目录对比页面.md
#	task/frontend/FE-008-实现右侧挂起更改面板.md
#	task/frontend/FE-009-实现文件操作交互.md
#	task/frontend/FE-010-实现签入流程.md
#	task/frontend/FE-011-实现历史记录界面.md
#	task/frontend/FE-012-实现文件Diff界面.md
#	task/frontend/FE-013-实现操作日志面板.md
This commit is contained in:
fenghp
2026-06-16 14:33:47 +08:00
158 changed files with 17212 additions and 4654 deletions
@@ -0,0 +1,259 @@
import { useCallback, useMemo, useState } from "react"
import { FileText, FolderClosed, ListChecks, Loader2, RefreshCw } from "lucide-react"
import { FileTargetMenu } from "~/components/app/file-target-menu"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import { Checkbox } from "~/components/ui/checkbox"
import { ScrollArea } from "~/components/ui/scroll-area"
import { Textarea } from "~/components/ui/textarea"
import type { MappingInfo, PendingChange } from "~/lib/api"
import type { FileActionId, FileTarget } from "~/lib/tfs"
import { makeFileTarget, statusBadgeClass, statusLabel } from "~/lib/tfs"
import { cn } from "~/lib/utils"
/**
* 右侧 Changes 面板:Pending Changes 的 Included / Excluded 分组维护、
* 项级右键菜单,以及作为签入唯一入口的 comment + Checkin 区域。
*/
export function ChangesPanel({
mappings,
pendingChanges,
excludedKeys,
loading,
error,
checkinBusy,
onToggleExcluded,
onCheckin,
onFileAction,
onRefresh,
}: {
mappings: MappingInfo[]
pendingChanges: PendingChange[]
excludedKeys: Set<string>
loading: boolean
error: string | null
checkinBusy: boolean
onToggleExcluded: (serverPath: string) => void
onCheckin: (paths: string[], comment: string) => Promise<boolean>
onFileAction: (target: FileTarget, action: FileActionId) => void
onRefresh: () => void
}) {
const [comment, setComment] = useState("")
const included = useMemo(
() => pendingChanges.filter((change) => !excludedKeys.has(change.serverPath)),
[pendingChanges, excludedKeys],
)
const excluded = useMemo(
() => pendingChanges.filter((change) => excludedKeys.has(change.serverPath)),
[pendingChanges, excludedKeys],
)
const canCheckin = included.length > 0 && comment.trim().length > 0 && !checkinBusy
/**
* 提交签入:只提交 Included 项,成功后清空注释。
*/
const handleCheckin = useCallback(async () => {
const success = await onCheckin(
included.map((change) => change.serverPath),
comment.trim(),
)
if (success) {
setComment("")
}
}, [onCheckin, included, comment])
return (
<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 bg-muted/40 px-3">
<span className="text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
挂起更改
</span>
{pendingChanges.length > 0 && (
<span className="ml-auto text-xs text-muted-foreground">{pendingChanges.length} 项</span>
)}
<Button
variant="ghost"
size="icon-xs"
className={pendingChanges.length > 0 ? "" : "ml-auto"}
onClick={onRefresh}
disabled={loading}
aria-label="刷新挂起更改"
title="刷新挂起更改"
>
<RefreshCw className={cn(loading && "animate-spin")} />
</Button>
</div>
{loading ? (
<div className="flex flex-1 items-center justify-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
正在加载挂起更改…
</div>
) : error ? (
<div className="flex flex-1 items-center justify-center p-4 text-xs text-destructive">
{error}
</div>
) : pendingChanges.length === 0 ? (
<div className="flex flex-1 items-center justify-center p-4">
<div className="flex flex-col items-center gap-2 text-center">
<ListChecks className="size-6 text-muted-foreground/60" />
<p className="text-xs leading-5 text-muted-foreground">当前没有挂起更改</p>
</div>
</div>
) : (
<ScrollArea className="min-h-0 flex-1">
<div className="p-2">
<GroupHeader label="Included" count={included.length} />
{included.length === 0 && (
<p className="px-2 py-1.5 text-xs text-muted-foreground">
没有要签入的更改,勾选下方 Excluded 项可移回
</p>
)}
{included.map((change) => (
<PendingRow
key={change.serverPath}
change={change}
included
mappings={mappings}
onToggleExcluded={onToggleExcluded}
onFileAction={onFileAction}
/>
))}
{excluded.length > 0 && (
<>
<GroupHeader label="Excluded" count={excluded.length} className="mt-3" />
{excluded.map((change) => (
<PendingRow
key={change.serverPath}
change={change}
included={false}
mappings={mappings}
onToggleExcluded={onToggleExcluded}
onFileAction={onFileAction}
/>
))}
</>
)}
</div>
</ScrollArea>
)}
<div className="shrink-0 space-y-2 border-t p-3">
<Textarea
value={comment}
onChange={(event) => setComment(event.target.value)}
placeholder="签入注释(必填)"
rows={3}
disabled={checkinBusy || pendingChanges.length === 0}
className="resize-none text-sm"
/>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
将签入 {included.length} 项
</span>
<Button size="sm" disabled={!canCheckin} onClick={() => void handleCheckin()}>
{checkinBusy ? (
<>
<Loader2 data-icon="inline-start" className="animate-spin" />
正在签入…
</>
) : (
"签入"
)}
</Button>
</div>
{comment.trim().length === 0 && pendingChanges.length > 0 && (
<p className="text-xs text-muted-foreground">填写注释后才能签入</p>
)}
</div>
</aside>
)
}
/**
* 分组标题行。
*/
function GroupHeader({
label,
count,
className,
}: {
label: string
count: number
className?: string
}) {
return (
<div className={cn("flex items-center gap-1.5 px-2 py-1", className)}>
<span className="text-xs font-medium text-muted-foreground">{label}</span>
<span className="text-xs text-muted-foreground/70">{count}</span>
</div>
)
}
/**
* 单条挂起更改:勾选维护 Included / Excluded,整行支持统一右键菜单。
*/
function PendingRow({
change,
included,
mappings,
onToggleExcluded,
onFileAction,
}: {
change: PendingChange
included: boolean
mappings: MappingInfo[]
onToggleExcluded: (serverPath: string) => void
onFileAction: (target: FileTarget, action: FileActionId) => void
}) {
const target = makeFileTarget({
source: "pending",
folder: change.folder,
serverPath: change.serverPath,
mappings,
pendingStatus: change.status,
})
return (
<FileTargetMenu target={target} onAction={onFileAction}>
<div
className={cn(
"flex h-9 cursor-default items-center gap-2 rounded-md px-2 select-none hover:bg-muted",
!included && "opacity-60",
)}
title={change.serverPath}
>
<Checkbox
checked={included}
onCheckedChange={() => onToggleExcluded(change.serverPath)}
aria-label={included ? "移到 Excluded" : "移回 Included"}
/>
{change.folder ? (
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">
{change.status === "pendingRename" && change.sourceServerPath
? `${change.sourceServerPath.slice(change.sourceServerPath.lastIndexOf("/") + 1)} → ${change.name}`
: change.name}
</span>
<span className="block truncate font-mono text-[10px] leading-3 text-muted-foreground">
{change.serverPath}
</span>
</span>
<Badge
variant="secondary"
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)}
</Badge>
</div>
</FileTargetMenu>
)
}