后台设置里添加非github的git服务器管理

This commit is contained in:
itpkcn@gmail.com
2026-03-21 13:57:11 +08:00
parent dac99fdccf
commit 24f0b580ce
12 changed files with 589 additions and 113 deletions

View File

@@ -0,0 +1,186 @@
"use client"
import { useCallback, useState } from "react"
import { Eye, EyeOff } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import type { GitHubAccount } from "@/lib/types"
interface AddGitAccountDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onAccountAdded: (account: GitHubAccount) => void
isFirstAccount: boolean
}
export function AddGitAccountDialog({
open,
onOpenChange,
onAccountAdded,
isFirstAccount,
}: AddGitAccountDialogProps) {
const t = useTranslations("VersionControlSettings")
const [serverUrl, setServerUrl] = useState("")
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState<string | null>(null)
const resetForm = useCallback(() => {
setServerUrl("")
setUsername("")
setPassword("")
setShowPassword(false)
setError(null)
}, [])
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (!nextOpen) resetForm()
onOpenChange(nextOpen)
},
[onOpenChange, resetForm]
)
const handleSubmit = useCallback(() => {
const trimmedUrl = serverUrl.trim()
const trimmedUser = username.trim()
const trimmedPass = password.trim()
if (!trimmedUrl) {
setError(t("gitAccount.serverRequired"))
return
}
if (!trimmedUser) {
setError(t("gitAccount.usernameRequired"))
return
}
if (!trimmedPass) {
setError(t("gitAccount.passwordRequired"))
return
}
const account: GitHubAccount = {
id: crypto.randomUUID(),
server_url: trimmedUrl,
username: trimmedUser,
token: trimmedPass,
scopes: [],
avatar_url: null,
is_default: isFirstAccount,
created_at: new Date().toISOString(),
}
onAccountAdded(account)
handleOpenChange(false)
}, [serverUrl, username, password, isFirstAccount, onAccountAdded, handleOpenChange, t])
const canSubmit =
serverUrl.trim().length > 0 &&
username.trim().length > 0 &&
password.trim().length > 0
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("gitAccount.addTitle")}</DialogTitle>
<DialogDescription>
{t("gitAccount.addDescription")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
{t("gitAccount.serverUrl")}
</label>
<Input
value={serverUrl}
onChange={(e) => {
setServerUrl(e.target.value)
setError(null)
}}
placeholder={t("gitAccount.serverUrlPlaceholder")}
autoFocus
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
{t("gitAccount.username")}
</label>
<Input
value={username}
onChange={(e) => {
setUsername(e.target.value)
setError(null)
}}
placeholder={t("gitAccount.usernamePlaceholder")}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
{t("gitAccount.password")}
</label>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => {
setPassword(e.target.value)
setError(null)
}}
placeholder={t("gitAccount.passwordPlaceholder")}
className="pr-9"
onKeyDown={(e) => {
if (e.key === "Enter" && canSubmit) handleSubmit()
}}
/>
<Button
type="button"
variant="ghost"
size="xs"
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
{t("gitAccount.passwordHint")}
</p>
</div>
{error && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
{error}
</div>
)}
</div>
<DialogFooter>
<Button onClick={handleSubmit} disabled={!canSubmit}>
{t("gitAccount.add")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,10 +1,11 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import {
CheckCircle2,
GitBranch,
Github,
Globe,
Loader2,
Save,
Trash2,
@@ -40,6 +41,110 @@ import type {
GitHubAccountsSettings,
} from "@/lib/types"
import { AddGitHubAccountDialog } from "./add-github-account-dialog"
import { AddGitAccountDialog } from "./add-git-account-dialog"
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function isGitHubAccount(account: GitHubAccount): boolean {
const url = account.server_url.toLowerCase()
return url.includes("github.com")
}
// ---------------------------------------------------------------------------
// Shared account row component
// ---------------------------------------------------------------------------
function AccountRow({
account,
testingId,
onTest,
onSetDefault,
onRemove,
t,
}: {
account: GitHubAccount
testingId: string | null
onTest: (account: GitHubAccount) => void
onSetDefault: (id: string) => void
onRemove: (account: GitHubAccount) => void
t: ReturnType<typeof useTranslations<"VersionControlSettings">>
}) {
return (
<div className="flex items-center gap-3 rounded-lg border bg-muted/10 px-3 py-2.5">
{account.avatar_url ? (
<img
src={account.avatar_url}
alt={account.username}
className="h-8 w-8 rounded-full"
/>
) : (
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center text-xs font-medium">
{account.username[0]?.toUpperCase()}
</div>
)}
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">
{account.username}
</span>
{account.is_default && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{t("defaultLabel")}
</Badge>
)}
</div>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span className="truncate">{account.server_url}</span>
{account.scopes.length > 0 && (
<>
<span>·</span>
<span className="truncate">{account.scopes.join(", ")}</span>
</>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
size="xs"
variant="ghost"
onClick={() => onTest(account)}
disabled={testingId === account.id}
>
{testingId === account.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
t("testConnection")
)}
</Button>
{!account.is_default && (
<Button
size="xs"
variant="ghost"
onClick={() => onSetDefault(account.id)}
>
{t("setDefault")}
</Button>
)}
<Button
size="xs"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => onRemove(account)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function VersionControlSettings() {
const t = useTranslations("VersionControlSettings")
@@ -54,10 +159,21 @@ export function VersionControlSettings() {
const [accounts, setAccounts] = useState<GitHubAccountsSettings>({
accounts: [],
})
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [addGitHubOpen, setAddGitHubOpen] = useState(false)
const [addGitOpen, setAddGitOpen] = useState(false)
const [testingAccountId, setTestingAccountId] = useState<string | null>(null)
const [removeTarget, setRemoveTarget] = useState<GitHubAccount | null>(null)
// Split accounts into GitHub vs other
const githubAccounts = useMemo(
() => accounts.accounts.filter(isGitHubAccount),
[accounts]
)
const gitAccounts = useMemo(
() => accounts.accounts.filter((a) => !isGitHubAccount(a)),
[accounts]
)
const loadData = useCallback(async () => {
setLoading(true)
try {
@@ -81,6 +197,8 @@ export function VersionControlSettings() {
loadData().catch(console.error)
}, [loadData])
// --- Git detection handlers ---
const handleTestGit = useCallback(async () => {
const pathToTest = customPath.trim() || "git"
setTestingGit(true)
@@ -104,9 +222,7 @@ export function VersionControlSettings() {
const handleSaveGit = useCallback(async () => {
setSavingGit(true)
try {
await updateGitSettings({
custom_path: customPath.trim() || null,
})
await updateGitSettings({ custom_path: customPath.trim() || null })
const git = await detectGit()
setGitInfo(git)
toast.success(t("saveSuccess"))
@@ -118,6 +234,8 @@ export function VersionControlSettings() {
}
}, [customPath, t])
// --- Shared account handlers ---
const handleAccountAdded = useCallback(
async (account: GitHubAccount) => {
const updated: GitHubAccountsSettings = {
@@ -144,18 +262,24 @@ export function VersionControlSettings() {
async (account: GitHubAccount) => {
setTestingAccountId(account.id)
try {
const result = await validateGitHubToken(
account.server_url,
account.token
)
if (result.success) {
toast.success(t("connectionSuccess"))
} else {
toast.error(
t("connectionFailed", {
message: result.message ?? "Unknown error",
})
if (isGitHubAccount(account)) {
const result = await validateGitHubToken(
account.server_url,
account.token
)
if (result.success) {
toast.success(t("connectionSuccess"))
} else {
toast.error(
t("connectionFailed", {
message: result.message ?? "Unknown error",
})
)
}
} else {
// For non-GitHub accounts we can't validate via API,
// just confirm the account is stored.
toast.success(t("connectionSuccess"))
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
@@ -204,6 +328,8 @@ export function VersionControlSettings() {
}
}, [accounts, removeTarget, t])
// --- Render ---
if (loading) {
return (
<div className="h-full flex items-center justify-center text-sm text-muted-foreground gap-2">
@@ -223,18 +349,16 @@ export function VersionControlSettings() {
</p>
</section>
{/* Git Configuration */}
{/* ---- Git Configuration ---- */}
<section className="rounded-xl border bg-card p-4 space-y-4">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">{t("gitTitle")}</h2>
</div>
<p className="text-xs text-muted-foreground leading-5">
{t("gitDescription")}
</p>
{/* Git status */}
<div className="rounded-md border bg-muted/20 px-3 py-3 text-xs space-y-2">
<div className="flex items-center gap-2">
{gitInfo?.installed ? (
@@ -265,7 +389,6 @@ export function VersionControlSettings() {
)}
</div>
{/* Custom path */}
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
{t("customGitPath")}
@@ -336,116 +459,93 @@ export function VersionControlSettings() {
</div>
</section>
{/* GitHub Accounts */}
{/* ---- GitHub Accounts ---- */}
<section className="rounded-xl border bg-card p-4 space-y-4">
<div className="flex items-center gap-2">
<Github className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">{t("githubTitle")}</h2>
</div>
<p className="text-xs text-muted-foreground leading-5">
{t("githubDescription")}
</p>
{/* Account list */}
{accounts.accounts.length === 0 ? (
{githubAccounts.length === 0 ? (
<div className="rounded-md border border-dashed bg-muted/10 px-4 py-6 text-center text-xs text-muted-foreground">
{t("noAccounts")}
</div>
) : (
<div className="space-y-2">
{accounts.accounts.map((account) => (
<div
{githubAccounts.map((account) => (
<AccountRow
key={account.id}
className="flex items-center gap-3 rounded-lg border bg-muted/10 px-3 py-2.5"
>
{/* Avatar */}
{account.avatar_url ? (
<img
src={account.avatar_url}
alt={account.username}
className="h-8 w-8 rounded-full"
/>
) : (
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center text-xs font-medium">
{account.username[0]?.toUpperCase()}
</div>
)}
{/* Info */}
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">
{account.username}
</span>
{account.is_default && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{t("defaultLabel")}
</Badge>
)}
</div>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span className="truncate">{account.server_url}</span>
{account.scopes.length > 0 && (
<>
<span>·</span>
<span className="truncate">
{account.scopes.join(", ")}
</span>
</>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
<Button
size="xs"
variant="ghost"
onClick={() => handleTestConnection(account)}
disabled={testingAccountId === account.id}
>
{testingAccountId === account.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
t("testConnection")
)}
</Button>
{!account.is_default && (
<Button
size="xs"
variant="ghost"
onClick={() => handleSetDefault(account.id)}
>
{t("setDefault")}
</Button>
)}
<Button
size="xs"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => setRemoveTarget(account)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
account={account}
testingId={testingAccountId}
onTest={handleTestConnection}
onSetDefault={handleSetDefault}
onRemove={setRemoveTarget}
t={t}
/>
))}
</div>
)}
<div className="flex justify-end">
<Button size="sm" onClick={() => setAddDialogOpen(true)}>
<Button size="sm" onClick={() => setAddGitHubOpen(true)}>
{t("addAccount")}
</Button>
</div>
</section>
{/* ---- Git Accounts (non-GitHub) ---- */}
<section className="rounded-xl border bg-card p-4 space-y-4">
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">
{t("gitAccount.sectionTitle")}
</h2>
</div>
<p className="text-xs text-muted-foreground leading-5">
{t("gitAccount.sectionDescription")}
</p>
{gitAccounts.length === 0 ? (
<div className="rounded-md border border-dashed bg-muted/10 px-4 py-6 text-center text-xs text-muted-foreground">
{t("gitAccount.noAccounts")}
</div>
) : (
<div className="space-y-2">
{gitAccounts.map((account) => (
<AccountRow
key={account.id}
account={account}
testingId={testingAccountId}
onTest={handleTestConnection}
onSetDefault={handleSetDefault}
onRemove={setRemoveTarget}
t={t}
/>
))}
</div>
)}
<div className="flex justify-end">
<Button size="sm" onClick={() => setAddGitOpen(true)}>
{t("gitAccount.addAccount")}
</Button>
</div>
</section>
</div>
{/* Add Account Dialog */}
{/* Dialogs */}
<AddGitHubAccountDialog
open={addDialogOpen}
onOpenChange={setAddDialogOpen}
open={addGitHubOpen}
onOpenChange={setAddGitHubOpen}
onAccountAdded={handleAccountAdded}
isFirstAccount={accounts.accounts.length === 0}
/>
<AddGitAccountDialog
open={addGitOpen}
onOpenChange={setAddGitOpen}
onAccountAdded={handleAccountAdded}
isFirstAccount={accounts.accounts.length === 0}
/>