frontend: FE-028 登录页改版,品牌区+分步动效+最近连接+密码可见切换+失败抖动

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
fenghp
2026-06-11 17:18:58 +08:00
co-authored by Cursor
parent ee9e052586
commit ad7c30393c
5 changed files with 415 additions and 134 deletions
+334 -127
View File
@@ -1,10 +1,23 @@
import { useCallback, useEffect, useState, type ChangeEvent } from "react"
import {
Check,
Clock3,
Eye,
EyeOff,
FolderSync,
GitCompareArrows,
History,
Loader2,
ShieldCheck,
} from "lucide-react"
import { Button } from "~/components/ui/button"
import { Input } from "~/components/ui/input"
import { Label } from "~/components/ui/label"
import { api } 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 { cn } from "~/lib/utils"
@@ -26,8 +39,17 @@ const EMPTY_FORM: ConnectFormState = {
password: "",
}
// 品牌区能力点列表,登录页左侧展示。
const BRAND_FEATURES = [
{ icon: FolderSync, label: "映射目录与获取最新" },
{ icon: ShieldCheck, label: "签出、签入与挂起更改" },
{ icon: History, label: "历史记录与变更集回滚" },
{ icon: GitCompareArrows, label: "目录对比与版本 Diff" },
]
/**
* 连接入口:连接 TFS、加载并确认 Collection、确保默认 Workspace,成功后回调固定上下文。
* 布局为左品牌区 + 右表单卡片,带分阶段过渡与最近连接快速填充。
*/
export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession) => void }) {
const [phase, setPhase] = useState<ConnectPhase>("form")
@@ -37,9 +59,15 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
const [busy, setBusy] = useState(false)
const [busyText, setBusyText] = useState("")
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(() => {
let active = true
setRecent(loadRecentServers())
void (async () => {
const result = await api.getConfig()
if (!active) {
@@ -58,6 +86,12 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
}
}
})()
void (async () => {
const status = await getServiceStatus()
if (active) {
setServiceOk(status.running)
}
})()
return () => {
active = false
}
@@ -71,6 +105,27 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
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 列表。
*/
@@ -88,23 +143,30 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
const connectResult = await api.connect(payload)
if (!connectResult.ok) {
setBusy(false)
setError(connectResult.errorMessage ?? "连接失败")
failWith(connectResult.errorMessage ?? "连接失败")
return
}
setBusyText("正在加载 Collection…")
const collectionsResult = await api.listCollections()
setBusy(false)
if (!collectionsResult.ok) {
setError(collectionsResult.errorMessage ?? "加载 Collection 失败")
failWith(collectionsResult.errorMessage ?? "加载 Collection 失败")
return
}
setRecent(
saveRecentServer({
serverUri: form.serverUri.trim(),
domain: form.domain.trim(),
username: form.username.trim(),
}),
)
const list = collectionsResult.data?.collections ?? []
setCollections(list)
setSelectedCollection((prev) =>
list.some((item) => item.name === prev) ? prev : list[0]?.name ?? "",
)
setPhase("collection")
}, [form])
}, [form, failWith])
/**
* 确认 Collection 后确保默认 Workspace,并把固定上下文回调给上层。
@@ -120,7 +182,7 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
setBusy(false)
const workspace = ensureResult.data?.workspace
if (!ensureResult.ok || !workspace) {
setError(ensureResult.errorMessage ?? "确保 Workspace 失败")
failWith(ensureResult.errorMessage ?? "确保 Workspace 失败")
return
}
onReady({
@@ -129,7 +191,7 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
workspace: workspace.name,
mappings: workspace.mappings ?? [],
})
}, [selectedCollection, form.serverUri, onReady])
}, [selectedCollection, form.serverUri, onReady, failWith])
const canConnect =
form.serverUri.trim().length > 0 &&
@@ -137,131 +199,276 @@ export function ConnectView({ onReady }: { onReady: (session: WorkspaceSession)
form.password.length > 0
return (
<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="space-y-1">
<h1 className="text-base font-semibold">MacTFS</h1>
<p className="text-sm text-muted-foreground">
{phase === "form" ? "连接 TFS 服务器" : "选择 Collection"}
</p>
<div className="relative flex min-h-svh">
{/* 隐藏式标题栏下登录页的窗口拖拽区 */}
<div className="app-drag absolute inset-x-0 top-0 z-10 h-10" />
{/* 左侧品牌区 */}
<aside className="relative hidden w-[42%] max-w-md min-w-80 flex-col justify-between overflow-hidden bg-zinc-950 p-10 text-zinc-100 md:flex">
<div aria-hidden className="pointer-events-none absolute inset-0">
<div className="animate-float-slow absolute -top-24 -left-20 size-72 rounded-full bg-blue-500/20 blur-3xl" />
<div className="animate-float-slow absolute -right-24 bottom-0 size-80 rounded-full bg-indigo-500/15 blur-3xl [animation-delay:-4s]" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_left,transparent_55%,rgb(9_9_11/0.7))]" />
</div>
{phase === "form" && (
<form
className="mt-5 space-y-3"
onSubmit={(event) => {
event.preventDefault()
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>
)}
{error && <p className="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 ? busyText || "处理中…" : "进入工作台"}
</Button>
</div>
<div className="relative mt-6 flex items-center gap-3">
<img src="/logo.png" alt="MacTFS" className="size-10 rounded-xl shadow-lg" />
<div>
<p className="text-lg leading-tight font-semibold tracking-tight">MacTFS</p>
<p className="text-xs text-zinc-400">TFS 源码管理 · macOS 桌面工作台</p>
</div>
)}
</div>
</div>
<div className="relative space-y-6">
<p className="text-2xl leading-snug font-semibold tracking-tight">
连接 Team Foundation Server,
<br />
在桌面完成日常源码管理。
</p>
<ul className="space-y-3">
{BRAND_FEATURES.map(({ icon: Icon, label }) => (
<li key={label} className="flex items-center gap-3 text-sm text-zinc-300">
<span className="flex size-7 items-center justify-center rounded-md border border-zinc-700/80 bg-zinc-900/80">
<Icon className="size-3.5 text-blue-400" />
</span>
{label}
</li>
))}
</ul>
</div>
<div className="relative flex items-center gap-2 text-xs text-zinc-500">
<span
className={cn(
"size-1.5 rounded-full",
serviceOk == null ? "bg-zinc-600" : serviceOk ? "bg-emerald-400" : "bg-red-400",
)}
/>
{serviceOk == null
? "正在检测本地服务…"
: serviceOk
? "本地服务运行中 · 127.0.0.1:38765"
: "本地服务未就绪"}
</div>
</aside>
{/* 右侧表单区 */}
<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>
<form
className="mt-6 space-y-4"
onSubmit={(event) => {
event.preventDefault()
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="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-all duration-150 hover:-translate-y-px hover:border-ring/40 hover:shadow-overlay"
>
<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>
)}
{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 bg-card px-3.5 py-3 text-left text-sm transition-all duration-150",
active
? "border-primary shadow-overlay ring-2 ring-primary/25"
: "shadow-card hover:-translate-y-px hover:border-ring/40 hover:shadow-overlay",
)}
>
<span className="font-medium">{collection.name}</span>
<span
className={cn(
"flex size-5 items-center justify-center rounded-full border transition-all duration-150",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border text-transparent",
)}
>
<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>
)
}