frontend: FE-028 登录页改版,品牌区+分步动效+最近连接+密码可见切换+失败抖动
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user