2026-03-20 08:39:07 +00:00
|
|
|
|
import { useEffect, useState, type ReactNode } from "react";
|
|
|
|
|
|
import { AnimatePresence, motion } from "motion/react";
|
|
|
|
|
|
import {
|
|
|
|
|
|
Bell,
|
|
|
|
|
|
BriefcaseBusiness,
|
|
|
|
|
|
ChevronRight,
|
|
|
|
|
|
CircleUserRound,
|
|
|
|
|
|
HelpCircle,
|
|
|
|
|
|
LogOut,
|
|
|
|
|
|
Mail,
|
|
|
|
|
|
MapPinned,
|
|
|
|
|
|
Moon,
|
|
|
|
|
|
Eye,
|
|
|
|
|
|
EyeOff,
|
|
|
|
|
|
Phone,
|
|
|
|
|
|
Settings,
|
|
|
|
|
|
Shield,
|
|
|
|
|
|
Sun,
|
|
|
|
|
|
User,
|
|
|
|
|
|
X,
|
|
|
|
|
|
} from "lucide-react";
|
|
|
|
|
|
import { useNavigate } from "react-router-dom";
|
2026-03-19 06:23:03 +00:00
|
|
|
|
import { useTheme } from "@/components/ThemeProvider";
|
2026-03-20 08:39:07 +00:00
|
|
|
|
import {
|
|
|
|
|
|
clearAuth,
|
|
|
|
|
|
getCurrentUser,
|
|
|
|
|
|
getProfileOverview,
|
|
|
|
|
|
updateCurrentUserProfile,
|
|
|
|
|
|
updateCurrentUserPassword,
|
|
|
|
|
|
type ProfileOverview,
|
|
|
|
|
|
type UpdateCurrentUserProfilePayload,
|
|
|
|
|
|
type UpdateCurrentUserPasswordPayload,
|
|
|
|
|
|
type UserProfile,
|
|
|
|
|
|
} from "@/lib/auth";
|
|
|
|
|
|
|
|
|
|
|
|
type MenuItem = {
|
|
|
|
|
|
key: "personal" | "notice" | "security" | "help";
|
|
|
|
|
|
icon: typeof User;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
color: string;
|
|
|
|
|
|
bg: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
type EditableProfileForm = UpdateCurrentUserProfilePayload;
|
|
|
|
|
|
|
|
|
|
|
|
const MENU_ITEMS: MenuItem[] = [
|
|
|
|
|
|
{ key: "personal", icon: User, label: "个人资料", color: "text-blue-500 dark:text-blue-400", bg: "bg-blue-50 dark:bg-blue-500/10" },
|
|
|
|
|
|
{ key: "notice", icon: Bell, label: "消息通知", color: "text-amber-500 dark:text-amber-400", bg: "bg-amber-50 dark:bg-amber-500/10" },
|
|
|
|
|
|
{ key: "security", icon: Shield, label: "账号安全", color: "text-emerald-500 dark:text-emerald-400", bg: "bg-emerald-50 dark:bg-emerald-500/10" },
|
|
|
|
|
|
{ key: "help", icon: HelpCircle, label: "帮助中心", color: "text-violet-500 dark:text-violet-400", bg: "bg-violet-50 dark:bg-violet-500/10" },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const EMPTY_FORM: EditableProfileForm = {
|
|
|
|
|
|
displayName: "",
|
|
|
|
|
|
email: "",
|
|
|
|
|
|
phone: "",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const EMPTY_PASSWORD_FORM: SecurityForm = {
|
|
|
|
|
|
oldPassword: "",
|
|
|
|
|
|
newPassword: "",
|
|
|
|
|
|
confirmPassword: "",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
type SecurityForm = UpdateCurrentUserPasswordPayload & {
|
|
|
|
|
|
confirmPassword: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function displayText(value?: string | null) {
|
|
|
|
|
|
return value && value.trim() ? value : "无";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function numericValue(value?: number | null) {
|
|
|
|
|
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function PageModal({
|
|
|
|
|
|
title,
|
|
|
|
|
|
subtitle,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
children,
|
|
|
|
|
|
footer,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
subtitle?: string;
|
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
|
footer?: ReactNode;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<motion.div
|
|
|
|
|
|
initial={{ opacity: 0 }}
|
|
|
|
|
|
animate={{ opacity: 1 }}
|
|
|
|
|
|
exit={{ opacity: 0 }}
|
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
|
className="fixed inset-0 z-40 bg-slate-900/25 backdrop-blur-sm dark:bg-slate-900/70"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<motion.div
|
|
|
|
|
|
initial={{ opacity: 0, y: 20 }}
|
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
|
exit={{ opacity: 0, y: 20 }}
|
|
|
|
|
|
className="fixed inset-0 z-50 p-0 sm:p-6"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="mx-auto h-full w-full sm:max-w-3xl">
|
|
|
|
|
|
<div className="flex h-full flex-col overflow-hidden border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900 sm:rounded-3xl">
|
|
|
|
|
|
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800 sm:px-6">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">{title}</h2>
|
|
|
|
|
|
{subtitle ? <p className="mt-1 text-xs text-slate-500 dark:text-slate-400">{subtitle}</p> : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
|
className="rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800"
|
|
|
|
|
|
>
|
|
|
|
|
|
<X className="h-5 w-5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex-1 overflow-y-auto px-5 py-5 sm:px-6">{children}</div>
|
|
|
|
|
|
{footer ? <div className="border-t border-slate-100 px-5 py-4 dark:border-slate-800 sm:px-6">{footer}</div> : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
|
|
|
|
|
|
export default function Profile() {
|
|
|
|
|
|
const { theme, setTheme } = useTheme();
|
2026-03-20 08:39:07 +00:00
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
|
|
|
|
|
|
|
const [overview, setOverview] = useState<ProfileOverview | null>(null);
|
|
|
|
|
|
const [currentUser, setCurrentUser] = useState<UserProfile | null>(null);
|
|
|
|
|
|
const [profileDrawerOpen, setProfileDrawerOpen] = useState(false);
|
|
|
|
|
|
const [securityDrawerOpen, setSecurityDrawerOpen] = useState(false);
|
|
|
|
|
|
const [form, setForm] = useState<EditableProfileForm>(EMPTY_FORM);
|
|
|
|
|
|
const [detailLoading, setDetailLoading] = useState(false);
|
|
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
|
const [error, setError] = useState("");
|
|
|
|
|
|
const [securityForm, setSecurityForm] = useState<SecurityForm>(EMPTY_PASSWORD_FORM);
|
|
|
|
|
|
const [securitySaving, setSecuritySaving] = useState(false);
|
|
|
|
|
|
const [securityError, setSecurityError] = useState("");
|
|
|
|
|
|
const [securitySuccess, setSecuritySuccess] = useState("");
|
|
|
|
|
|
const [showOldPassword, setShowOldPassword] = useState(false);
|
|
|
|
|
|
const [showNewPassword, setShowNewPassword] = useState(false);
|
|
|
|
|
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let ignore = false;
|
|
|
|
|
|
|
|
|
|
|
|
async function loadProfileData() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const me = await getCurrentUser();
|
|
|
|
|
|
const overviewData = await getProfileOverview().catch(() => null);
|
|
|
|
|
|
|
|
|
|
|
|
if (ignore) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setCurrentUser(me);
|
|
|
|
|
|
setOverview(overviewData);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
if (ignore) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setOverview(null);
|
|
|
|
|
|
setCurrentUser(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void loadProfileData();
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
ignore = true;
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const handleLogout = () => {
|
|
|
|
|
|
clearAuth();
|
|
|
|
|
|
navigate("/login", { replace: true });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleOpenProfile = async () => {
|
|
|
|
|
|
setDetailLoading(true);
|
|
|
|
|
|
setError("");
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const me = currentUser ?? await getCurrentUser();
|
|
|
|
|
|
const latestOverview = overview ?? await getProfileOverview().catch(() => null);
|
|
|
|
|
|
|
|
|
|
|
|
setCurrentUser(me);
|
|
|
|
|
|
setOverview(latestOverview);
|
|
|
|
|
|
setForm({
|
|
|
|
|
|
userId: me.userId,
|
|
|
|
|
|
username: me.username,
|
|
|
|
|
|
displayName: me.displayName || "",
|
|
|
|
|
|
email: me.email || "",
|
|
|
|
|
|
phone: me.phone || "",
|
|
|
|
|
|
pwdResetRequired: me.pwdResetRequired,
|
|
|
|
|
|
isPlatformAdmin: me.isPlatformAdmin,
|
|
|
|
|
|
orgId: undefined,
|
|
|
|
|
|
});
|
|
|
|
|
|
setProfileDrawerOpen(true);
|
|
|
|
|
|
} catch (loadError) {
|
|
|
|
|
|
setError(loadError instanceof Error ? loadError.message : "获取个人资料失败");
|
|
|
|
|
|
setProfileDrawerOpen(true);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setDetailLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleCloseProfile = () => {
|
|
|
|
|
|
if (saving) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setProfileDrawerOpen(false);
|
|
|
|
|
|
setError("");
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleSave = async () => {
|
|
|
|
|
|
if (saving) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setSaving(true);
|
|
|
|
|
|
setError("");
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await updateCurrentUserProfile({
|
|
|
|
|
|
...form,
|
|
|
|
|
|
email: form.email || undefined,
|
|
|
|
|
|
phone: form.phone || undefined,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const me = await getCurrentUser();
|
|
|
|
|
|
const overviewData = await getProfileOverview().catch(() => overview);
|
|
|
|
|
|
|
|
|
|
|
|
setCurrentUser(me);
|
|
|
|
|
|
setOverview(overviewData);
|
|
|
|
|
|
setForm({
|
|
|
|
|
|
userId: me.userId,
|
|
|
|
|
|
username: me.username,
|
|
|
|
|
|
displayName: me.displayName || "",
|
|
|
|
|
|
email: me.email || "",
|
|
|
|
|
|
phone: me.phone || "",
|
|
|
|
|
|
pwdResetRequired: me.pwdResetRequired,
|
|
|
|
|
|
isPlatformAdmin: me.isPlatformAdmin,
|
|
|
|
|
|
orgId: undefined,
|
|
|
|
|
|
});
|
|
|
|
|
|
sessionStorage.setItem("userProfile", JSON.stringify(me));
|
|
|
|
|
|
setProfileDrawerOpen(false);
|
|
|
|
|
|
} catch (saveError) {
|
|
|
|
|
|
setError(saveError instanceof Error ? saveError.message : "保存失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSaving(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleOpenSecurity = () => {
|
|
|
|
|
|
setSecurityForm(EMPTY_PASSWORD_FORM);
|
|
|
|
|
|
setSecurityError("");
|
|
|
|
|
|
setSecuritySuccess("");
|
|
|
|
|
|
setShowOldPassword(false);
|
|
|
|
|
|
setShowNewPassword(false);
|
|
|
|
|
|
setShowConfirmPassword(false);
|
|
|
|
|
|
setSecurityDrawerOpen(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleCloseSecurity = () => {
|
|
|
|
|
|
if (securitySaving) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setSecurityDrawerOpen(false);
|
|
|
|
|
|
setSecurityError("");
|
|
|
|
|
|
setSecuritySuccess("");
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleUpdatePassword = async () => {
|
|
|
|
|
|
if (securitySaving) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!securityForm.oldPassword || !securityForm.newPassword || !securityForm.confirmPassword) {
|
|
|
|
|
|
setSecurityError("请完整填写旧密码、新密码和确认密码");
|
|
|
|
|
|
setSecuritySuccess("");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (securityForm.newPassword !== securityForm.confirmPassword) {
|
|
|
|
|
|
setSecurityError("两次输入的新密码不一致");
|
|
|
|
|
|
setSecuritySuccess("");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (securityForm.newPassword.length < 6) {
|
|
|
|
|
|
setSecurityError("新密码长度不能少于6位");
|
|
|
|
|
|
setSecuritySuccess("");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setSecuritySaving(true);
|
|
|
|
|
|
setSecurityError("");
|
|
|
|
|
|
setSecuritySuccess("");
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await updateCurrentUserPassword({
|
|
|
|
|
|
oldPassword: securityForm.oldPassword,
|
|
|
|
|
|
newPassword: securityForm.newPassword,
|
|
|
|
|
|
});
|
|
|
|
|
|
clearAuth();
|
|
|
|
|
|
navigate("/login", { replace: true });
|
|
|
|
|
|
} catch (saveError) {
|
|
|
|
|
|
setSecurityError(saveError instanceof Error ? saveError.message : "密码修改失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSecuritySaving(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const roleNames = displayText(overview?.jobTitle);
|
|
|
|
|
|
const orgNames = displayText(overview?.deptName);
|
|
|
|
|
|
const displayName = displayText(currentUser?.displayName || overview?.realName);
|
|
|
|
|
|
const avatarText = displayName === "无" ? "无" : displayName.slice(0, 1);
|
|
|
|
|
|
const currentEmail = displayText(currentUser?.email);
|
|
|
|
|
|
const currentPhone = displayText(currentUser?.phone);
|
2026-03-19 06:23:03 +00:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<div className="space-y-5 sm:space-y-6">
|
2026-03-19 06:23:03 +00:00
|
|
|
|
<header className="flex items-center justify-between">
|
|
|
|
|
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900 dark:text-white">我的</h1>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<motion.div
|
|
|
|
|
|
initial={{ opacity: 0, y: 10 }}
|
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
2026-03-20 08:39:07 +00:00
|
|
|
|
className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm backdrop-blur-sm transition-all dark:border-slate-800 dark:bg-slate-900/50 sm:p-6"
|
2026-03-19 06:23:03 +00:00
|
|
|
|
>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
|
|
|
|
|
|
<div className="flex items-center gap-4">
|
|
|
|
|
|
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-violet-100 text-2xl font-bold text-violet-600 dark:bg-violet-500/20 dark:text-violet-400">
|
|
|
|
|
|
{avatarText}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
|
<h2 className="truncate text-xl font-bold text-slate-900 dark:text-white">{displayName}</h2>
|
|
|
|
|
|
<div className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
|
|
|
|
|
<p>部门:{orgNames}</p>
|
|
|
|
|
|
<p>岗位:{roleNames}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => void handleOpenProfile()}
|
|
|
|
|
|
className="self-end rounded-full bg-slate-50 p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 dark:bg-slate-800 dark:text-slate-500 dark:hover:bg-slate-700 dark:hover:text-slate-300 sm:self-start"
|
|
|
|
|
|
>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
<Settings className="h-5 w-5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<div className="mt-6 grid grid-cols-1 gap-3 border-t border-slate-50 pt-6 dark:border-slate-800/50 sm:grid-cols-3 sm:gap-0 sm:divide-x sm:divide-slate-100 dark:sm:divide-slate-800">
|
|
|
|
|
|
<div className="rounded-2xl bg-slate-50 px-4 py-4 text-center dark:bg-slate-800/40 sm:rounded-none sm:bg-transparent sm:px-2">
|
|
|
|
|
|
<p className="text-2xl font-bold text-slate-900 dark:text-white">{numericValue(overview?.monthlyOpportunityCount)}</p>
|
|
|
|
|
|
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">本月商机</p>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<div className="rounded-2xl bg-slate-50 px-4 py-4 text-center dark:bg-slate-800/40 sm:rounded-none sm:bg-transparent sm:px-2">
|
|
|
|
|
|
<p className="text-2xl font-bold text-slate-900 dark:text-white">{numericValue(overview?.monthlyExpansionCount)}</p>
|
|
|
|
|
|
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">本月拓展</p>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<div className="rounded-2xl bg-slate-50 px-4 py-4 text-center dark:bg-slate-800/40 sm:rounded-none sm:bg-transparent sm:px-2">
|
|
|
|
|
|
<p className="text-2xl font-bold text-slate-900 dark:text-white">{numericValue(overview?.averageScore)}</p>
|
|
|
|
|
|
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">平均分</p>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
|
|
|
|
|
|
<motion.div
|
|
|
|
|
|
initial={{ opacity: 0, y: 10 }}
|
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
|
transition={{ delay: 0.1 }}
|
2026-03-20 08:39:07 +00:00
|
|
|
|
className="overflow-hidden rounded-2xl border border-slate-100 bg-white shadow-sm backdrop-blur-sm transition-all dark:border-slate-800 dark:bg-slate-900/50"
|
2026-03-19 06:23:03 +00:00
|
|
|
|
>
|
|
|
|
|
|
<ul className="divide-y divide-slate-50 dark:divide-slate-800/50">
|
|
|
|
|
|
<li>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
2026-03-20 08:39:07 +00:00
|
|
|
|
className="flex w-full items-center justify-between p-4 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50 md:hidden"
|
2026-03-19 06:23:03 +00:00
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 dark:bg-slate-800">
|
|
|
|
|
|
{theme === "dark" ? <Sun className="h-5 w-5 text-amber-500" /> : <Moon className="h-5 w-5 text-indigo-500" />}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
|
|
|
|
|
{theme === "dark" ? "切换亮色模式" : "切换暗色模式"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ChevronRight className="h-5 w-5 text-slate-300 dark:text-slate-600" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</li>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
{MENU_ITEMS.map((item) => (
|
|
|
|
|
|
<li key={item.key}>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={
|
|
|
|
|
|
item.key === "personal"
|
|
|
|
|
|
? () => void handleOpenProfile()
|
|
|
|
|
|
: item.key === "security"
|
|
|
|
|
|
? handleOpenSecurity
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
className="flex w-full items-center justify-between p-4 text-left transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50"
|
|
|
|
|
|
>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<div className={`flex h-10 w-10 items-center justify-center rounded-xl ${item.bg}`}>
|
|
|
|
|
|
<item.icon className={`h-5 w-5 ${item.color}`} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">{item.label}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ChevronRight className="h-5 w-5 text-slate-300 dark:text-slate-600" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
|
|
|
|
|
|
<motion.button
|
|
|
|
|
|
initial={{ opacity: 0, y: 10 }}
|
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
|
transition={{ delay: 0.2 }}
|
2026-03-20 08:39:07 +00:00
|
|
|
|
onClick={handleLogout}
|
|
|
|
|
|
className="flex w-full items-center justify-center gap-2 rounded-2xl border border-rose-100 bg-rose-50 p-4 text-sm font-semibold text-rose-600 transition-all hover:bg-rose-100 active:scale-[0.98] dark:border-rose-900/50 dark:bg-rose-500/10 dark:text-rose-400 dark:hover:bg-rose-500/20"
|
2026-03-19 06:23:03 +00:00
|
|
|
|
>
|
|
|
|
|
|
<LogOut className="h-5 w-5" />
|
|
|
|
|
|
退出登录
|
|
|
|
|
|
</motion.button>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
|
|
|
|
|
|
<AnimatePresence>
|
|
|
|
|
|
{profileDrawerOpen ? (
|
|
|
|
|
|
<PageModal
|
|
|
|
|
|
title="个人资料"
|
|
|
|
|
|
onClose={handleCloseProfile}
|
|
|
|
|
|
footer={(
|
|
|
|
|
|
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={handleCloseProfile}
|
|
|
|
|
|
className="rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
|
|
|
|
|
|
>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => void handleSave()}
|
|
|
|
|
|
disabled={saving || detailLoading}
|
|
|
|
|
|
className="rounded-xl bg-violet-600 px-4 py-3 text-sm font-medium text-white shadow-sm transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-60"
|
|
|
|
|
|
>
|
|
|
|
|
|
{saving ? "保存中..." : "保存资料"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{error && !currentUser ? (
|
|
|
|
|
|
<div className="rounded-xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-600 dark:border-rose-900/50 dark:bg-rose-500/10 dark:text-rose-300">
|
|
|
|
|
|
{error}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
|
<div className="flex flex-col gap-4 rounded-2xl border border-slate-100 bg-slate-50/70 p-4 dark:border-slate-800 dark:bg-slate-800/20 sm:flex-row sm:items-center">
|
|
|
|
|
|
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-violet-100 text-2xl font-bold text-violet-600 dark:bg-violet-500/20 dark:text-violet-400">
|
|
|
|
|
|
{(form.displayName || currentUser?.displayName || "无").slice(0, 1)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
|
<div className="text-lg font-semibold text-slate-900 dark:text-white">{displayText(currentUser?.displayName)}</div>
|
|
|
|
|
|
<div className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
|
|
|
|
|
<p>部门:{orgNames}</p>
|
|
|
|
|
|
<p>岗位:{roleNames}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-2 text-xs text-slate-400 dark:text-slate-500">账号:{displayText(currentUser?.username)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
|
|
|
|
<label className="space-y-2">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">显示名称</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
value={form.displayName || ""}
|
|
|
|
|
|
onChange={(event) => setForm((current) => ({ ...current, displayName: event.target.value }))}
|
|
|
|
|
|
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">用户名</span>
|
|
|
|
|
|
<div className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-500 dark:border-slate-800 dark:bg-slate-800/40 dark:text-slate-400">
|
|
|
|
|
|
{displayText(currentUser?.username)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2">
|
|
|
|
|
|
<span className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300">
|
|
|
|
|
|
<Phone className="h-4 w-4 text-slate-400" />
|
|
|
|
|
|
手机号
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
value={form.phone || ""}
|
|
|
|
|
|
onChange={(event) => setForm((current) => ({ ...current, phone: event.target.value }))}
|
|
|
|
|
|
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2">
|
|
|
|
|
|
<span className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300">
|
|
|
|
|
|
<Mail className="h-4 w-4 text-slate-400" />
|
|
|
|
|
|
邮箱
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
value={form.email || ""}
|
|
|
|
|
|
onChange={(event) => setForm((current) => ({ ...current, email: event.target.value }))}
|
|
|
|
|
|
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
|
|
|
|
<div className="rounded-2xl border border-slate-100 bg-slate-50/70 p-4 dark:border-slate-800 dark:bg-slate-800/20">
|
|
|
|
|
|
<div className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
|
|
|
|
|
|
<BriefcaseBusiness className="h-4 w-4 text-violet-500" />
|
|
|
|
|
|
岗位信息
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-3 space-y-2 text-sm text-slate-500 dark:text-slate-400">
|
|
|
|
|
|
<p>岗位:{roleNames}</p>
|
|
|
|
|
|
<p>状态:{displayText(overview?.accountStatus)}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="rounded-2xl border border-slate-100 bg-slate-50/70 p-4 dark:border-slate-800 dark:bg-slate-800/20">
|
|
|
|
|
|
<div className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
|
|
|
|
|
|
<MapPinned className="h-4 w-4 text-emerald-500" />
|
|
|
|
|
|
组织信息
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-3 space-y-2 text-sm text-slate-500 dark:text-slate-400">
|
|
|
|
|
|
<p>部门:{orgNames}</p>
|
|
|
|
|
|
<p className="flex items-center gap-2">
|
|
|
|
|
|
<CircleUserRound className="h-4 w-4 text-slate-400" />
|
|
|
|
|
|
入职天数:{numericValue(overview?.onboardingDays)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
|
|
|
|
<div className="rounded-xl border border-slate-100 bg-slate-50/70 px-4 py-3 text-sm text-slate-500 dark:border-slate-800 dark:bg-slate-800/20 dark:text-slate-400">
|
|
|
|
|
|
当前手机号:{currentPhone}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="rounded-xl border border-slate-100 bg-slate-50/70 px-4 py-3 text-sm text-slate-500 dark:border-slate-800 dark:bg-slate-800/20 dark:text-slate-400">
|
|
|
|
|
|
当前邮箱:{currentEmail}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{detailLoading ? (
|
|
|
|
|
|
<div className="rounded-xl border border-slate-100 bg-slate-50 px-4 py-3 text-sm text-slate-500 dark:border-slate-800 dark:bg-slate-800/40 dark:text-slate-400">
|
|
|
|
|
|
正在加载个人资料...
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{error ? (
|
|
|
|
|
|
<div className="rounded-xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-600 dark:border-rose-900/50 dark:bg-rose-500/10 dark:text-rose-300">
|
|
|
|
|
|
{error}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</PageModal>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{securityDrawerOpen ? (
|
|
|
|
|
|
<PageModal
|
|
|
|
|
|
title="账号安全"
|
|
|
|
|
|
onClose={handleCloseSecurity}
|
|
|
|
|
|
footer={(
|
|
|
|
|
|
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={handleCloseSecurity}
|
|
|
|
|
|
className="rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
|
|
|
|
|
|
>
|
|
|
|
|
|
关闭
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => void handleUpdatePassword()}
|
|
|
|
|
|
disabled={securitySaving}
|
|
|
|
|
|
className="rounded-xl bg-emerald-600 px-4 py-3 text-sm font-medium text-white shadow-sm transition-colors hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-60"
|
|
|
|
|
|
>
|
|
|
|
|
|
{securitySaving ? "提交中..." : "更新密码"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
|
<div className="rounded-2xl border border-slate-100 bg-slate-50/70 p-4 dark:border-slate-800 dark:bg-slate-800/20">
|
|
|
|
|
|
<p className="text-sm text-slate-600 dark:text-slate-300">
|
|
|
|
|
|
为了账号安全,请先输入当前密码,再设置新密码。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-1 gap-4">
|
|
|
|
|
|
<label className="space-y-2">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">当前密码</span>
|
|
|
|
|
|
<div className="relative">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type={showOldPassword ? "text" : "password"}
|
|
|
|
|
|
autoComplete="current-password"
|
|
|
|
|
|
value={securityForm.oldPassword}
|
|
|
|
|
|
onChange={(event) => setSecurityForm((current) => ({ ...current, oldPassword: event.target.value }))}
|
|
|
|
|
|
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 pr-12 text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 dark:border-slate-800 dark:bg-slate-900/50"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setShowOldPassword((current) => !current)}
|
|
|
|
|
|
className="absolute inset-y-0 right-0 flex w-12 items-center justify-center text-slate-400 transition-colors hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300"
|
|
|
|
|
|
aria-label={showOldPassword ? "隐藏当前密码" : "显示当前密码"}
|
|
|
|
|
|
>
|
|
|
|
|
|
{showOldPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
|
|
<label className="space-y-2">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">新密码</span>
|
|
|
|
|
|
<div className="relative">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type={showNewPassword ? "text" : "password"}
|
|
|
|
|
|
autoComplete="new-password"
|
|
|
|
|
|
value={securityForm.newPassword}
|
|
|
|
|
|
onChange={(event) => setSecurityForm((current) => ({ ...current, newPassword: event.target.value }))}
|
|
|
|
|
|
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 pr-12 text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 dark:border-slate-800 dark:bg-slate-900/50"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setShowNewPassword((current) => !current)}
|
|
|
|
|
|
className="absolute inset-y-0 right-0 flex w-12 items-center justify-center text-slate-400 transition-colors hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300"
|
|
|
|
|
|
aria-label={showNewPassword ? "隐藏新密码" : "显示新密码"}
|
|
|
|
|
|
>
|
|
|
|
|
|
{showNewPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
|
|
<label className="space-y-2">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">确认新密码</span>
|
|
|
|
|
|
<div className="relative">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type={showConfirmPassword ? "text" : "password"}
|
|
|
|
|
|
autoComplete="new-password"
|
|
|
|
|
|
value={securityForm.confirmPassword}
|
|
|
|
|
|
onChange={(event) => setSecurityForm((current) => ({ ...current, confirmPassword: event.target.value }))}
|
|
|
|
|
|
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 pr-12 text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 dark:border-slate-800 dark:bg-slate-900/50"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setShowConfirmPassword((current) => !current)}
|
|
|
|
|
|
className="absolute inset-y-0 right-0 flex w-12 items-center justify-center text-slate-400 transition-colors hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300"
|
|
|
|
|
|
aria-label={showConfirmPassword ? "隐藏确认密码" : "显示确认密码"}
|
|
|
|
|
|
>
|
|
|
|
|
|
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{securityError ? (
|
|
|
|
|
|
<div className="rounded-xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-600 dark:border-rose-900/50 dark:bg-rose-500/10 dark:text-rose-300">
|
|
|
|
|
|
{securityError}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{securitySuccess ? (
|
|
|
|
|
|
<div className="rounded-xl border border-emerald-100 bg-emerald-50 px-4 py-3 text-sm text-emerald-600 dark:border-emerald-900/50 dark:bg-emerald-500/10 dark:text-emerald-300">
|
|
|
|
|
|
{securitySuccess}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</PageModal>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</AnimatePresence>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|