2025-12-13 12:35:03 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* Account Page
|
|
|
|
|
|
* Cyberpunk Terminal Aesthetic
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2025-11-28 01:06:01 +08:00
|
|
|
|
import { useState, useEffect } from "react";
|
|
|
|
|
|
import { useNavigate } from "react-router-dom";
|
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
|
|
|
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
2025-12-24 16:08:56 +08:00
|
|
|
|
import { Textarea } from "@/components/ui/textarea";
|
2025-11-28 01:06:01 +08:00
|
|
|
|
import {
|
|
|
|
|
|
User,
|
|
|
|
|
|
Mail,
|
|
|
|
|
|
Phone,
|
|
|
|
|
|
Shield,
|
|
|
|
|
|
Calendar,
|
|
|
|
|
|
Save,
|
|
|
|
|
|
KeyRound,
|
|
|
|
|
|
LogOut,
|
|
|
|
|
|
UserPlus,
|
2025-12-13 12:35:03 +08:00
|
|
|
|
GitBranch,
|
2025-12-24 16:08:56 +08:00
|
|
|
|
Terminal,
|
|
|
|
|
|
Key,
|
|
|
|
|
|
Copy,
|
|
|
|
|
|
Trash2,
|
2025-12-26 09:33:55 +08:00
|
|
|
|
CheckCircle2,
|
|
|
|
|
|
ServerCrash
|
2025-11-28 01:06:01 +08:00
|
|
|
|
} from "lucide-react";
|
|
|
|
|
|
import { apiClient } from "@/shared/api/serverClient";
|
|
|
|
|
|
import { toast } from "sonner";
|
|
|
|
|
|
import type { Profile } from "@/shared/types";
|
2025-12-26 09:33:55 +08:00
|
|
|
|
import { generateSSHKey, getSSHKey, deleteSSHKey, testSSHKey, clearKnownHosts } from "@/shared/api/sshKeys";
|
2025-11-28 01:06:01 +08:00
|
|
|
|
|
|
|
|
|
|
export default function Account() {
|
|
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
|
const [profile, setProfile] = useState<Profile | null>(null);
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
|
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
|
|
|
|
|
|
const [form, setForm] = useState({
|
|
|
|
|
|
full_name: "",
|
|
|
|
|
|
phone: "",
|
|
|
|
|
|
github_username: "",
|
|
|
|
|
|
gitlab_username: "",
|
|
|
|
|
|
});
|
|
|
|
|
|
const [passwordForm, setPasswordForm] = useState({
|
|
|
|
|
|
current_password: "",
|
|
|
|
|
|
new_password: "",
|
|
|
|
|
|
confirm_password: "",
|
|
|
|
|
|
});
|
|
|
|
|
|
const [changingPassword, setChangingPassword] = useState(false);
|
|
|
|
|
|
|
2025-12-24 16:08:56 +08:00
|
|
|
|
// SSH Key states
|
|
|
|
|
|
const [sshKey, setSSHKey] = useState<{ has_key: boolean; public_key?: string; fingerprint?: string }>({ has_key: false });
|
|
|
|
|
|
const [generatingKey, setGeneratingKey] = useState(false);
|
|
|
|
|
|
const [deletingKey, setDeletingKey] = useState(false);
|
2025-12-26 09:33:55 +08:00
|
|
|
|
const [clearingKnownHosts, setClearingKnownHosts] = useState(false);
|
2025-12-24 16:08:56 +08:00
|
|
|
|
const [testingKey, setTestingKey] = useState(false);
|
|
|
|
|
|
const [testRepoUrl, setTestRepoUrl] = useState("");
|
|
|
|
|
|
const [showDeleteKeyDialog, setShowDeleteKeyDialog] = useState(false);
|
|
|
|
|
|
|
2025-11-28 01:06:01 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadProfile();
|
2025-12-24 16:08:56 +08:00
|
|
|
|
loadSSHKey();
|
2025-11-28 01:06:01 +08:00
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const loadProfile = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
const res = await apiClient.get('/users/me');
|
|
|
|
|
|
setProfile(res.data);
|
|
|
|
|
|
setForm({
|
|
|
|
|
|
full_name: res.data.full_name || "",
|
|
|
|
|
|
phone: res.data.phone || "",
|
|
|
|
|
|
github_username: res.data.github_username || "",
|
|
|
|
|
|
gitlab_username: res.data.gitlab_username || "",
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to load profile:', error);
|
|
|
|
|
|
toast.error("加载账号信息失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-24 16:08:56 +08:00
|
|
|
|
const loadSSHKey = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await getSSHKey();
|
|
|
|
|
|
setSSHKey(data);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to load SSH key:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleGenerateSSHKey = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setGeneratingKey(true);
|
|
|
|
|
|
const data = await generateSSHKey();
|
|
|
|
|
|
setSSHKey({ has_key: true, public_key: data.public_key, fingerprint: data.fingerprint });
|
|
|
|
|
|
toast.success(data.message);
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
console.error('Failed to generate SSH key:', error);
|
|
|
|
|
|
toast.error(error.response?.data?.detail || "生成SSH密钥失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setGeneratingKey(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleDeleteSSHKey = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setDeletingKey(true);
|
|
|
|
|
|
await deleteSSHKey();
|
|
|
|
|
|
setSSHKey({ has_key: false });
|
|
|
|
|
|
toast.success("SSH密钥已删除");
|
|
|
|
|
|
setShowDeleteKeyDialog(false);
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
console.error('Failed to delete SSH key:', error);
|
|
|
|
|
|
toast.error(error.response?.data?.detail || "删除SSH密钥失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setDeletingKey(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleTestSSHKey = async () => {
|
|
|
|
|
|
if (!testRepoUrl) {
|
|
|
|
|
|
toast.error("请输入仓库URL");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
setTestingKey(true);
|
|
|
|
|
|
const result = await testSSHKey(testRepoUrl);
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
|
toast.success("SSH连接测试成功");
|
|
|
|
|
|
// 在控制台输出详细信息
|
|
|
|
|
|
if (result.output) {
|
|
|
|
|
|
console.log("SSH测试输出:", result.output);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 显示详细的错误信息
|
|
|
|
|
|
toast.error(result.message || "SSH连接测试失败", {
|
|
|
|
|
|
description: result.output ? `详情: ${result.output.substring(0, 100)}...` : undefined,
|
|
|
|
|
|
duration: 5000,
|
|
|
|
|
|
});
|
|
|
|
|
|
// 在控制台输出完整错误信息
|
|
|
|
|
|
if (result.output) {
|
|
|
|
|
|
console.error("SSH测试失败:", result.output);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
console.error('Failed to test SSH key:', error);
|
|
|
|
|
|
toast.error(error.response?.data?.detail || "测试SSH密钥失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setTestingKey(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-26 09:33:55 +08:00
|
|
|
|
const handleClearKnownHosts = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setClearingKnownHosts(true);
|
|
|
|
|
|
const result = await clearKnownHosts();
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
|
toast.success(result.message || "known_hosts已清理");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
toast.error("清理known_hosts失败");
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
console.error('Failed to clear known_hosts:', error);
|
|
|
|
|
|
toast.error(error.response?.data?.detail || "清理known_hosts失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setClearingKnownHosts(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-24 16:08:56 +08:00
|
|
|
|
const handleCopyPublicKey = () => {
|
|
|
|
|
|
if (sshKey.public_key) {
|
|
|
|
|
|
navigator.clipboard.writeText(sshKey.public_key);
|
|
|
|
|
|
toast.success("公钥已复制到剪贴板");
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-28 01:06:01 +08:00
|
|
|
|
const handleSave = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setSaving(true);
|
|
|
|
|
|
const res = await apiClient.put('/users/me', form);
|
|
|
|
|
|
setProfile(res.data);
|
|
|
|
|
|
toast.success("账号信息已更新");
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to update profile:', error);
|
|
|
|
|
|
toast.error("更新失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSaving(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleChangePassword = async () => {
|
|
|
|
|
|
if (!passwordForm.new_password || !passwordForm.confirm_password) {
|
|
|
|
|
|
toast.error("请填写新密码");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (passwordForm.new_password !== passwordForm.confirm_password) {
|
|
|
|
|
|
toast.error("两次输入的密码不一致");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (passwordForm.new_password.length < 6) {
|
|
|
|
|
|
toast.error("密码长度至少6位");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
setChangingPassword(true);
|
|
|
|
|
|
await apiClient.put('/users/me', { password: passwordForm.new_password });
|
|
|
|
|
|
toast.success("密码已更新");
|
|
|
|
|
|
setPasswordForm({ current_password: "", new_password: "", confirm_password: "" });
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to change password:', error);
|
|
|
|
|
|
toast.error("密码更新失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setChangingPassword(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const formatDate = (dateString?: string) => {
|
|
|
|
|
|
if (!dateString) return "-";
|
|
|
|
|
|
return new Date(dateString).toLocaleDateString('zh-CN', {
|
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
|
month: 'long',
|
|
|
|
|
|
day: 'numeric'
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const getInitials = (name?: string, email?: string) => {
|
|
|
|
|
|
if (name) return name.charAt(0).toUpperCase();
|
|
|
|
|
|
if (email) return email.charAt(0).toUpperCase();
|
|
|
|
|
|
return "U";
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleLogout = () => {
|
|
|
|
|
|
localStorage.removeItem('access_token');
|
|
|
|
|
|
toast.success("已退出登录");
|
|
|
|
|
|
navigate('/login');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleSwitchAccount = () => {
|
|
|
|
|
|
localStorage.removeItem('access_token');
|
|
|
|
|
|
navigate('/login');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
|
return (
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<div className="flex items-center justify-center min-h-screen cyber-bg-elevated">
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<div className="text-center space-y-4">
|
|
|
|
|
|
<div className="loading-spinner mx-auto" />
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<p className="text-muted-foreground font-mono text-sm uppercase tracking-wider">加载中...</p>
|
2025-12-13 12:35:03 +08:00
|
|
|
|
</div>
|
2025-11-28 01:06:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<div className="space-y-6 p-6 cyber-bg-elevated min-h-screen font-mono relative">
|
2025-12-13 12:35:03 +08:00
|
|
|
|
{/* Grid background */}
|
|
|
|
|
|
<div className="absolute inset-0 cyber-grid-subtle pointer-events-none" />
|
2025-11-28 01:06:01 +08:00
|
|
|
|
|
|
|
|
|
|
<div className="relative z-10 grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
|
|
|
|
{/* Profile Card */}
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<div className="cyber-card p-0">
|
|
|
|
|
|
<div className="cyber-card-header">
|
|
|
|
|
|
<User className="w-5 h-5 text-primary" />
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<h3 className="text-lg font-bold uppercase tracking-wider text-foreground">用户信息</h3>
|
2025-12-13 12:35:03 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="p-6 text-center">
|
|
|
|
|
|
<div className="relative inline-block mb-4">
|
|
|
|
|
|
<Avatar className="w-24 h-24 border-2 border-primary/30">
|
|
|
|
|
|
<AvatarImage src={profile?.avatar_url} />
|
|
|
|
|
|
<AvatarFallback className="bg-primary/20 text-primary text-2xl font-bold">
|
|
|
|
|
|
{getInitials(profile?.full_name, profile?.email)}
|
|
|
|
|
|
</AvatarFallback>
|
|
|
|
|
|
</Avatar>
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<div className="absolute -bottom-1 -right-1 w-6 h-6 bg-emerald-500 rounded-full border-2 border-background flex items-center justify-center">
|
|
|
|
|
|
<div className="w-2 h-2 bg-foreground rounded-full animate-pulse" />
|
2025-12-13 12:35:03 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<h4 className="text-lg font-bold text-foreground uppercase mb-1">
|
2025-12-13 12:35:03 +08:00
|
|
|
|
{profile?.full_name || "未设置姓名"}
|
|
|
|
|
|
</h4>
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<p className="text-muted-foreground text-sm">{profile?.email}</p>
|
2025-12-13 12:35:03 +08:00
|
|
|
|
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<div className="mt-6 pt-6 border-t border-border space-y-3 text-left">
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<div className="flex items-center gap-3 text-sm">
|
|
|
|
|
|
<Shield className="w-4 h-4 text-violet-400" />
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<span className="text-muted-foreground">角色:</span>
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<span className="text-violet-400 font-bold uppercase">
|
|
|
|
|
|
{profile?.role === 'admin' ? '管理员' : '成员'}
|
|
|
|
|
|
</span>
|
2025-11-28 01:06:01 +08:00
|
|
|
|
</div>
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<div className="flex items-center gap-3 text-sm">
|
|
|
|
|
|
<Calendar className="w-4 h-4 text-sky-400" />
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<span className="text-muted-foreground">注册时间:</span>
|
|
|
|
|
|
<span className="text-foreground font-mono">{formatDate(profile?.created_at)}</span>
|
2025-11-28 01:06:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-12-13 12:35:03 +08:00
|
|
|
|
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<div className="mt-6 pt-6 border-t border-border space-y-2">
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
2025-11-28 16:16:29 +08:00
|
|
|
|
onClick={handleSwitchAccount}
|
2025-12-13 12:35:03 +08:00
|
|
|
|
className="w-full cyber-btn-outline h-10"
|
2025-11-28 16:16:29 +08:00
|
|
|
|
>
|
|
|
|
|
|
<UserPlus className="w-4 h-4 mr-2" />
|
|
|
|
|
|
切换账号
|
|
|
|
|
|
</Button>
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="destructive"
|
2025-11-28 16:16:29 +08:00
|
|
|
|
onClick={() => setShowLogoutDialog(true)}
|
2025-12-13 12:35:03 +08:00
|
|
|
|
className="w-full bg-rose-500/20 hover:bg-rose-500/30 text-rose-400 border border-rose-500/30 h-10"
|
2025-11-28 16:16:29 +08:00
|
|
|
|
>
|
|
|
|
|
|
<LogOut className="w-4 h-4 mr-2" />
|
|
|
|
|
|
退出登录
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2025-12-13 12:35:03 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-11-28 01:06:01 +08:00
|
|
|
|
|
|
|
|
|
|
{/* Edit Form */}
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<div className="lg:col-span-2 cyber-card p-0">
|
|
|
|
|
|
<div className="cyber-card-header">
|
|
|
|
|
|
<Terminal className="w-5 h-5 text-primary" />
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<h3 className="text-lg font-bold uppercase tracking-wider text-foreground">基本信息</h3>
|
2025-12-13 12:35:03 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="p-6 space-y-6">
|
2025-11-28 01:06:01 +08:00
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
|
|
|
|
<div className="space-y-2">
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<Label htmlFor="email" className="text-xs font-bold text-muted-foreground uppercase flex items-center gap-2">
|
2025-11-28 01:06:01 +08:00
|
|
|
|
<Mail className="w-3 h-3" /> 邮箱
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="email"
|
|
|
|
|
|
value={profile?.email || ""}
|
|
|
|
|
|
disabled
|
2025-12-18 19:57:43 +08:00
|
|
|
|
className="cyber-input bg-muted text-muted-foreground cursor-not-allowed"
|
2025-11-28 01:06:01 +08:00
|
|
|
|
/>
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<p className="text-xs text-muted-foreground">邮箱不可修改</p>
|
2025-11-28 01:06:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-2">
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<Label htmlFor="full_name" className="text-xs font-bold text-muted-foreground uppercase flex items-center gap-2">
|
2025-11-28 01:06:01 +08:00
|
|
|
|
<User className="w-3 h-3" /> 姓名
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="full_name"
|
|
|
|
|
|
value={form.full_name}
|
|
|
|
|
|
onChange={(e) => setForm({ ...form, full_name: e.target.value })}
|
|
|
|
|
|
placeholder="请输入姓名"
|
2025-12-13 12:35:03 +08:00
|
|
|
|
className="cyber-input"
|
2025-11-28 01:06:01 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-2">
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<Label htmlFor="phone" className="text-xs font-bold text-muted-foreground uppercase flex items-center gap-2">
|
2025-11-28 01:06:01 +08:00
|
|
|
|
<Phone className="w-3 h-3" /> 手机号
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="phone"
|
|
|
|
|
|
value={form.phone}
|
|
|
|
|
|
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
|
|
|
|
|
placeholder="请输入手机号"
|
2025-12-13 12:35:03 +08:00
|
|
|
|
className="cyber-input"
|
2025-11-28 01:06:01 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<div className="pt-6 border-t border-border">
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<h3 className="section-title text-sm mb-4 flex items-center gap-2">
|
|
|
|
|
|
<GitBranch className="w-4 h-4" />
|
|
|
|
|
|
代码托管账号
|
|
|
|
|
|
</h3>
|
2025-11-28 01:06:01 +08:00
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
|
|
|
|
<div className="space-y-2">
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<Label htmlFor="github" className="text-xs font-bold text-muted-foreground uppercase flex items-center gap-2">
|
2025-11-28 01:06:01 +08:00
|
|
|
|
<GitBranch className="w-3 h-3" /> GitHub 用户名
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="github"
|
|
|
|
|
|
value={form.github_username}
|
|
|
|
|
|
onChange={(e) => setForm({ ...form, github_username: e.target.value })}
|
|
|
|
|
|
placeholder="your-github-username"
|
2025-12-13 12:35:03 +08:00
|
|
|
|
className="cyber-input"
|
2025-11-28 01:06:01 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-2">
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<Label htmlFor="gitlab" className="text-xs font-bold text-muted-foreground uppercase flex items-center gap-2">
|
2025-11-28 01:06:01 +08:00
|
|
|
|
<GitBranch className="w-3 h-3" /> GitLab 用户名
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="gitlab"
|
|
|
|
|
|
value={form.gitlab_username}
|
|
|
|
|
|
onChange={(e) => setForm({ ...form, gitlab_username: e.target.value })}
|
|
|
|
|
|
placeholder="your-gitlab-username"
|
2025-12-13 12:35:03 +08:00
|
|
|
|
className="cyber-input"
|
2025-11-28 01:06:01 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex justify-end pt-4">
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<Button onClick={handleSave} disabled={saving} className="cyber-btn-primary h-10">
|
|
|
|
|
|
{saving ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="loading-spinner w-4 h-4 mr-2" />
|
|
|
|
|
|
保存中...
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Save className="w-4 h-4 mr-2" />
|
|
|
|
|
|
保存修改
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2025-11-28 01:06:01 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2025-12-13 12:35:03 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-11-28 01:06:01 +08:00
|
|
|
|
|
2025-12-24 16:08:56 +08:00
|
|
|
|
{/* SSH Key Management */}
|
|
|
|
|
|
<div className="lg:col-span-3 cyber-card p-0">
|
|
|
|
|
|
<div className="cyber-card-header">
|
|
|
|
|
|
<Key className="w-5 h-5 text-emerald-400" />
|
|
|
|
|
|
<h3 className="text-lg font-bold uppercase tracking-wider text-foreground">SSH 密钥管理</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="p-6 space-y-4">
|
|
|
|
|
|
<div className="flex items-start gap-3 p-4 bg-emerald-500/10 border border-emerald-500/20 rounded-lg">
|
|
|
|
|
|
<div className="flex-shrink-0 mt-0.5">
|
|
|
|
|
|
<div className="w-8 h-8 rounded-lg bg-emerald-500/20 flex items-center justify-center">
|
|
|
|
|
|
<Key className="w-4 h-4 text-emerald-400" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
|
<p className="text-sm text-foreground font-medium mb-1">
|
|
|
|
|
|
使用 SSH 密钥访问 Git 仓库
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
|
|
|
|
生成 SSH 密钥对后,将公钥添加到 GitHub/GitLab,即可使用 SSH URL 访问私有仓库。私钥将被加密存储。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{!sshKey.has_key ? (
|
|
|
|
|
|
<div className="text-center py-8">
|
|
|
|
|
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-muted/50 mb-4">
|
|
|
|
|
|
<Key className="w-8 h-8 text-muted-foreground" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="text-sm text-muted-foreground mb-4">尚未生成 SSH 密钥</p>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
onClick={handleGenerateSSHKey}
|
|
|
|
|
|
disabled={generatingKey}
|
|
|
|
|
|
className="cyber-btn-primary h-10"
|
|
|
|
|
|
>
|
|
|
|
|
|
{generatingKey ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="loading-spinner w-4 h-4 mr-2" />
|
|
|
|
|
|
生成中...
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Key className="w-4 h-4 mr-2" />
|
|
|
|
|
|
生成 SSH 密钥
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
{/* Public Key Display */}
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<Label className="text-xs font-bold text-muted-foreground uppercase flex items-center gap-2">
|
|
|
|
|
|
<CheckCircle2 className="w-3 h-3 text-emerald-400" />
|
|
|
|
|
|
SSH 公钥
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
onClick={handleCopyPublicKey}
|
|
|
|
|
|
className="h-8 text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Copy className="w-3 h-3 mr-1" />
|
|
|
|
|
|
复制
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
value={sshKey.public_key || ""}
|
|
|
|
|
|
readOnly
|
|
|
|
|
|
className="cyber-input font-mono text-xs h-24 resize-none"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 显示指纹 */}
|
|
|
|
|
|
{sshKey.fingerprint && (
|
|
|
|
|
|
<div className="p-3 bg-muted/50 rounded border border-border">
|
|
|
|
|
|
<Label className="text-xs font-bold text-muted-foreground uppercase mb-1 block">
|
|
|
|
|
|
公钥指纹 (SHA256)
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<code className="text-xs text-emerald-400 font-mono break-all">
|
|
|
|
|
|
{sshKey.fingerprint}
|
|
|
|
|
|
</code>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
2025-12-25 16:17:42 +08:00
|
|
|
|
请将此公钥添加到 <a href="https://github.com/settings/keys" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">GitHub</a> 或 <a href="https://gitlab.com/-/profile/keys" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">GitLab</a> 账户
|
2025-12-24 16:08:56 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Test SSH Connection */}
|
|
|
|
|
|
<div className="space-y-2 pt-4 border-t border-border">
|
|
|
|
|
|
<Label className="text-xs font-bold text-muted-foreground uppercase">
|
|
|
|
|
|
测试 SSH 连接
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
placeholder="git@github.com:username/repo.git"
|
|
|
|
|
|
value={testRepoUrl}
|
|
|
|
|
|
onChange={(e) => setTestRepoUrl(e.target.value)}
|
|
|
|
|
|
className="cyber-input font-mono text-xs"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
onClick={handleTestSSHKey}
|
|
|
|
|
|
disabled={testingKey}
|
|
|
|
|
|
className="cyber-btn-outline whitespace-nowrap"
|
|
|
|
|
|
>
|
|
|
|
|
|
{testingKey ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="loading-spinner w-4 h-4 mr-2" />
|
|
|
|
|
|
测试中...
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Terminal className="w-4 h-4 mr-2" />
|
|
|
|
|
|
测试连接
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-12-26 09:33:55 +08:00
|
|
|
|
{/* Delete Key and Clear Known Hosts */}
|
|
|
|
|
|
<div className="flex justify-end gap-2 pt-4 border-t border-border">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
onClick={handleClearKnownHosts}
|
|
|
|
|
|
disabled={clearingKnownHosts}
|
|
|
|
|
|
className="cyber-btn-outline h-10"
|
|
|
|
|
|
>
|
|
|
|
|
|
{clearingKnownHosts ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="loading-spinner w-4 h-4 mr-2" />
|
|
|
|
|
|
清理中...
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<ServerCrash className="w-4 h-4 mr-2" />
|
|
|
|
|
|
清理 known_hosts
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Button>
|
2025-12-24 16:08:56 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="destructive"
|
|
|
|
|
|
onClick={() => setShowDeleteKeyDialog(true)}
|
|
|
|
|
|
className="bg-rose-500/20 hover:bg-rose-500/30 text-rose-400 border border-rose-500/30 h-10"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 className="w-4 h-4 mr-2" />
|
|
|
|
|
|
删除密钥
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-11-28 01:06:01 +08:00
|
|
|
|
{/* Password Change */}
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<div className="lg:col-span-3 cyber-card p-0">
|
|
|
|
|
|
<div className="cyber-card-header">
|
|
|
|
|
|
<KeyRound className="w-5 h-5 text-amber-400" />
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<h3 className="text-lg font-bold uppercase tracking-wider text-foreground">修改密码</h3>
|
2025-12-13 12:35:03 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="p-6">
|
2025-11-28 01:06:01 +08:00
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
|
|
|
|
<div className="space-y-2">
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<Label htmlFor="new_password" className="text-xs font-bold text-muted-foreground uppercase">新密码</Label>
|
2025-11-28 01:06:01 +08:00
|
|
|
|
<Input
|
|
|
|
|
|
id="new_password"
|
|
|
|
|
|
type="password"
|
|
|
|
|
|
value={passwordForm.new_password}
|
|
|
|
|
|
onChange={(e) => setPasswordForm({ ...passwordForm, new_password: e.target.value })}
|
|
|
|
|
|
placeholder="输入新密码"
|
2025-12-13 12:35:03 +08:00
|
|
|
|
className="cyber-input"
|
2025-11-28 01:06:01 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-2">
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<Label htmlFor="confirm_password" className="text-xs font-bold text-muted-foreground uppercase">确认密码</Label>
|
2025-11-28 01:06:01 +08:00
|
|
|
|
<Input
|
|
|
|
|
|
id="confirm_password"
|
|
|
|
|
|
type="password"
|
|
|
|
|
|
value={passwordForm.confirm_password}
|
|
|
|
|
|
onChange={(e) => setPasswordForm({ ...passwordForm, confirm_password: e.target.value })}
|
|
|
|
|
|
placeholder="再次输入新密码"
|
2025-12-13 12:35:03 +08:00
|
|
|
|
className="cyber-input"
|
2025-11-28 01:06:01 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-end">
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
onClick={handleChangePassword}
|
|
|
|
|
|
disabled={changingPassword}
|
|
|
|
|
|
className="cyber-btn-outline h-10"
|
|
|
|
|
|
>
|
|
|
|
|
|
{changingPassword ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="loading-spinner w-4 h-4 mr-2" />
|
|
|
|
|
|
更新中...
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<KeyRound className="w-4 h-4 mr-2" />
|
|
|
|
|
|
更新密码
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2025-11-28 01:06:01 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-12-13 12:35:03 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-11-28 01:06:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Logout Confirmation Dialog */}
|
|
|
|
|
|
<AlertDialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<AlertDialogContent className="cyber-card border-rose-500/30 cyber-dialog">
|
2025-11-28 01:06:01 +08:00
|
|
|
|
<AlertDialogHeader>
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<AlertDialogTitle className="text-lg font-bold uppercase text-foreground flex items-center gap-2">
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<LogOut className="w-5 h-5 text-rose-400" />
|
|
|
|
|
|
确认退出登录?
|
|
|
|
|
|
</AlertDialogTitle>
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<AlertDialogDescription className="text-muted-foreground">
|
2025-11-28 01:06:01 +08:00
|
|
|
|
退出后需要重新登录才能访问系统。
|
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogFooter>
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<AlertDialogCancel className="cyber-btn-outline">
|
2025-11-28 01:06:01 +08:00
|
|
|
|
取消
|
|
|
|
|
|
</AlertDialogCancel>
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<AlertDialogAction
|
2025-11-28 01:06:01 +08:00
|
|
|
|
onClick={handleLogout}
|
2025-12-13 12:35:03 +08:00
|
|
|
|
className="bg-rose-500/20 hover:bg-rose-500/30 text-rose-400 border border-rose-500/30"
|
2025-11-28 01:06:01 +08:00
|
|
|
|
>
|
|
|
|
|
|
确认退出
|
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
|
</AlertDialog>
|
2025-12-24 16:08:56 +08:00
|
|
|
|
|
|
|
|
|
|
{/* Delete SSH Key Confirmation Dialog */}
|
|
|
|
|
|
<AlertDialog open={showDeleteKeyDialog} onOpenChange={setShowDeleteKeyDialog}>
|
|
|
|
|
|
<AlertDialogContent className="cyber-card border-rose-500/30 cyber-dialog">
|
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogTitle className="text-lg font-bold uppercase text-foreground flex items-center gap-2">
|
|
|
|
|
|
<Trash2 className="w-5 h-5 text-rose-400" />
|
|
|
|
|
|
确认删除 SSH 密钥?
|
|
|
|
|
|
</AlertDialogTitle>
|
|
|
|
|
|
<AlertDialogDescription className="text-muted-foreground">
|
|
|
|
|
|
删除后将无法使用 SSH 方式访问 Git 仓库,需要重新生成密钥。此操作不可恢复。
|
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogFooter>
|
|
|
|
|
|
<AlertDialogCancel className="cyber-btn-outline" disabled={deletingKey}>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</AlertDialogCancel>
|
|
|
|
|
|
<AlertDialogAction
|
|
|
|
|
|
onClick={handleDeleteSSHKey}
|
|
|
|
|
|
disabled={deletingKey}
|
|
|
|
|
|
className="bg-rose-500/20 hover:bg-rose-500/30 text-rose-400 border border-rose-500/30"
|
|
|
|
|
|
>
|
|
|
|
|
|
{deletingKey ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="loading-spinner w-4 h-4 mr-2" />
|
|
|
|
|
|
删除中...
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
"确认删除"
|
|
|
|
|
|
)}
|
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
|
</AlertDialog>
|
2025-11-28 01:06:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|