2026-03-20 08:39:07 +00:00
|
|
|
|
import { useEffect, useState, type ReactNode } from "react";
|
|
|
|
|
|
import { AnimatePresence, motion } from "motion/react";
|
|
|
|
|
|
import {
|
|
|
|
|
|
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-30 06:29:32 +00:00
|
|
|
|
import { useIsMobileViewport } from "@/hooks/useIsMobileViewport";
|
|
|
|
|
|
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
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 = {
|
2026-03-26 09:29:55 +00:00
|
|
|
|
key: "personal" | "security" | "help";
|
2026-03-20 08:39:07 +00:00
|
|
|
|
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: "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 }}
|
2026-03-26 09:29:55 +00:00
|
|
|
|
className="fixed inset-0 z-50 px-0 pb-0 pt-[env(safe-area-inset-top)] sm:p-6"
|
2026-03-20 08:39:07 +00:00
|
|
|
|
>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<div className="mx-auto h-[calc(100dvh-env(safe-area-inset-top))] w-full sm:h-full sm:max-w-3xl">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<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>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<h2 className="text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{title}</h2>
|
|
|
|
|
|
{subtitle ? <p className="mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400">{subtitle}</p> : null}
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</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>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
{footer ? <div className="border-t border-slate-100 px-5 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4 dark:border-slate-800 sm:px-6 sm:pb-4">{footer}</div> : null}
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</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();
|
2026-03-30 06:29:32 +00:00
|
|
|
|
const isMobileViewport = useIsMobileViewport();
|
|
|
|
|
|
const isWecomBrowser = useIsWecomBrowser();
|
|
|
|
|
|
const disableMobileMotion = isMobileViewport || isWecomBrowser;
|
2026-03-20 08:39:07 +00:00
|
|
|
|
|
|
|
|
|
|
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 });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-23 01:03:27 +00:00
|
|
|
|
const handleNavigateToMonthlyOpportunity = () => {
|
|
|
|
|
|
navigate("/opportunities");
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleNavigateToMonthlyExpansion = () => {
|
|
|
|
|
|
navigate("/expansion");
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-20 08:39:07 +00:00
|
|
|
|
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("");
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-26 09:29:55 +00:00
|
|
|
|
const handleOpenHelp = () => {
|
|
|
|
|
|
window.alert("请联系系统管理员!");
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-20 08:39:07 +00:00
|
|
|
|
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-26 09:29:55 +00:00
|
|
|
|
<div className="crm-page-stack">
|
2026-03-27 09:05:41 +00:00
|
|
|
|
<header className="crm-page-header">
|
|
|
|
|
|
<div className="crm-page-heading">
|
|
|
|
|
|
<h1 className="crm-page-title">我的</h1>
|
|
|
|
|
|
<p className="crm-page-subtitle">查看个人信息、账号安全设置和本月工作概览。</p>
|
|
|
|
|
|
</div>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<motion.div
|
2026-03-30 06:29:32 +00:00
|
|
|
|
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
2026-03-30 06:29:32 +00:00
|
|
|
|
transition={disableMobileMotion ? { duration: 0 } : undefined}
|
|
|
|
|
|
className="crm-card crm-card-pad-lg relative rounded-2xl transition-shadow transition-colors"
|
2026-03-19 06:23:03 +00:00
|
|
|
|
>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => void handleOpenProfile()}
|
|
|
|
|
|
className="absolute right-4 top-4 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:right-6 sm:top-6"
|
|
|
|
|
|
>
|
2026-03-27 09:05:41 +00:00
|
|
|
|
<Settings className="crm-icon-lg" />
|
2026-03-26 09:29:55 +00:00
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-col gap-5 pr-12 sm:flex-row sm:items-start">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<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">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<h2 className="truncate text-lg font-bold text-slate-900 dark:text-white sm:text-xl">{displayName}</h2>
|
|
|
|
|
|
<div className="mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<p>部门:{orgNames}</p>
|
|
|
|
|
|
<p>岗位:{roleNames}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
|
|
|
|
|
|
<motion.div
|
2026-03-30 06:29:32 +00:00
|
|
|
|
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
2026-03-30 06:29:32 +00:00
|
|
|
|
transition={disableMobileMotion ? { duration: 0 } : { delay: 0.1 }}
|
|
|
|
|
|
className="crm-card overflow-hidden rounded-2xl transition-shadow transition-colors"
|
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">
|
2026-03-27 09:05:41 +00:00
|
|
|
|
{theme === "dark" ? <Sun className="crm-icon-lg text-amber-500" /> : <Moon className="crm-icon-lg text-indigo-500" />}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
|
|
|
|
|
{theme === "dark" ? "切换亮色模式" : "切换暗色模式"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-03-27 09:05:41 +00:00
|
|
|
|
<ChevronRight className="crm-icon-lg text-slate-300 dark:text-slate-600" />
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</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
|
2026-03-26 09:29:55 +00:00
|
|
|
|
: item.key === "help"
|
|
|
|
|
|
? handleOpenHelp
|
2026-03-20 08:39:07 +00:00
|
|
|
|
: 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}`}>
|
2026-03-27 09:05:41 +00:00
|
|
|
|
<item.icon className={`crm-icon-lg ${item.color}`} />
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">{item.label}</span>
|
|
|
|
|
|
</div>
|
2026-03-27 09:05:41 +00:00
|
|
|
|
<ChevronRight className="crm-icon-lg text-slate-300 dark:text-slate-600" />
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</button>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
|
|
|
|
|
|
<motion.button
|
2026-03-30 06:29:32 +00:00
|
|
|
|
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
2026-03-30 06:29:32 +00:00
|
|
|
|
transition={disableMobileMotion ? { duration: 0 } : { delay: 0.2 }}
|
2026-03-20 08:39:07 +00:00
|
|
|
|
onClick={handleLogout}
|
2026-03-30 06:29:32 +00:00
|
|
|
|
className="crm-btn crm-btn-danger flex w-full items-center justify-center gap-2 active:scale-100 sm:active:scale-[0.98]"
|
2026-03-19 06:23:03 +00:00
|
|
|
|
>
|
2026-03-27 09:05:41 +00:00
|
|
|
|
<LogOut className="crm-icon-lg" />
|
2026-03-19 06:23:03 +00:00
|
|
|
|
退出登录
|
|
|
|
|
|
</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}
|
2026-03-27 09:05:41 +00:00
|
|
|
|
className="crm-btn crm-btn-secondary"
|
2026-03-20 08:39:07 +00:00
|
|
|
|
>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => void handleSave()}
|
|
|
|
|
|
disabled={saving || detailLoading}
|
2026-03-27 09:05:41 +00:00
|
|
|
|
className="crm-btn crm-btn-primary disabled:cursor-not-allowed disabled:opacity-60"
|
2026-03-20 08:39:07 +00:00
|
|
|
|
>
|
|
|
|
|
|
{saving ? "保存中..." : "保存资料"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{error && !currentUser ? (
|
2026-03-27 09:05:41 +00:00
|
|
|
|
<div className="crm-alert crm-alert-error">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
{error}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<div className="crm-modal-stack">
|
|
|
|
|
|
<div className="crm-form-section sm:flex-row sm:items-center">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<div className="crm-form-grid">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<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 }))}
|
2026-03-26 09:29:55 +00:00
|
|
|
|
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
2026-03-20 08:39:07 +00:00
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">用户名</span>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<div className="crm-input-box-readonly crm-input-text w-full border border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-800 dark:bg-slate-800/40 dark:text-slate-400">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
{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">
|
2026-03-27 09:05:41 +00:00
|
|
|
|
<Phone className="crm-icon-md text-slate-400" />
|
2026-03-20 08:39:07 +00:00
|
|
|
|
手机号
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
value={form.phone || ""}
|
|
|
|
|
|
onChange={(event) => setForm((current) => ({ ...current, phone: event.target.value }))}
|
2026-03-26 09:29:55 +00:00
|
|
|
|
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
2026-03-20 08:39:07 +00:00
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2">
|
|
|
|
|
|
<span className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300">
|
2026-03-27 09:05:41 +00:00
|
|
|
|
<Mail className="crm-icon-md text-slate-400" />
|
2026-03-20 08:39:07 +00:00
|
|
|
|
邮箱
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
value={form.email || ""}
|
|
|
|
|
|
onChange={(event) => setForm((current) => ({ ...current, email: event.target.value }))}
|
2026-03-26 09:29:55 +00:00
|
|
|
|
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
2026-03-20 08:39:07 +00:00
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<div className="crm-form-grid">
|
|
|
|
|
|
<div className="crm-form-section">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<div className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
|
2026-03-27 09:05:41 +00:00
|
|
|
|
<BriefcaseBusiness className="crm-icon-md text-violet-500" />
|
2026-03-20 08:39:07 +00:00
|
|
|
|
岗位信息
|
|
|
|
|
|
</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>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<div className="crm-form-section">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<div className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
|
2026-03-27 09:05:41 +00:00
|
|
|
|
<MapPinned className="crm-icon-md text-emerald-500" />
|
2026-03-20 08:39:07 +00:00
|
|
|
|
组织信息
|
|
|
|
|
|
</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">
|
2026-03-27 09:05:41 +00:00
|
|
|
|
<CircleUserRound className="crm-icon-md text-slate-400" />
|
2026-03-20 08:39:07 +00:00
|
|
|
|
入职天数:{numericValue(overview?.onboardingDays)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<div className="crm-form-grid">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<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 ? (
|
2026-03-27 09:05:41 +00:00
|
|
|
|
<div className="crm-alert crm-alert-info">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
正在加载个人资料...
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{error ? (
|
2026-03-27 09:05:41 +00:00
|
|
|
|
<div className="crm-alert crm-alert-error">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
{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}
|
2026-03-27 09:05:41 +00:00
|
|
|
|
className="crm-btn crm-btn-secondary"
|
2026-03-20 08:39:07 +00:00
|
|
|
|
>
|
|
|
|
|
|
关闭
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => void handleUpdatePassword()}
|
|
|
|
|
|
disabled={securitySaving}
|
2026-03-27 09:05:41 +00:00
|
|
|
|
className="crm-btn crm-btn-success disabled:cursor-not-allowed disabled:opacity-60"
|
2026-03-20 08:39:07 +00:00
|
|
|
|
>
|
|
|
|
|
|
{securitySaving ? "提交中..." : "更新密码"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<div className="crm-modal-stack">
|
|
|
|
|
|
<div className="crm-form-section">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<p className="text-sm text-slate-600 dark:text-slate-300">
|
|
|
|
|
|
为了账号安全,请先输入当前密码,再设置新密码。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<div className="crm-section-stack">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<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 }))}
|
2026-03-26 09:29:55 +00:00
|
|
|
|
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white pr-12 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 dark:border-slate-800 dark:bg-slate-900/50"
|
2026-03-20 08:39:07 +00:00
|
|
|
|
/>
|
|
|
|
|
|
<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 ? "隐藏当前密码" : "显示当前密码"}
|
|
|
|
|
|
>
|
2026-03-27 09:05:41 +00:00
|
|
|
|
{showOldPassword ? <EyeOff className="crm-icon-md" /> : <Eye className="crm-icon-md" />}
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</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 }))}
|
2026-03-26 09:29:55 +00:00
|
|
|
|
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white pr-12 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 dark:border-slate-800 dark:bg-slate-900/50"
|
2026-03-20 08:39:07 +00:00
|
|
|
|
/>
|
|
|
|
|
|
<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 ? "隐藏新密码" : "显示新密码"}
|
|
|
|
|
|
>
|
2026-03-27 09:05:41 +00:00
|
|
|
|
{showNewPassword ? <EyeOff className="crm-icon-md" /> : <Eye className="crm-icon-md" />}
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</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 }))}
|
2026-03-26 09:29:55 +00:00
|
|
|
|
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white pr-12 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 dark:border-slate-800 dark:bg-slate-900/50"
|
2026-03-20 08:39:07 +00:00
|
|
|
|
/>
|
|
|
|
|
|
<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 ? "隐藏确认密码" : "显示确认密码"}
|
|
|
|
|
|
>
|
2026-03-27 09:05:41 +00:00
|
|
|
|
{showConfirmPassword ? <EyeOff className="crm-icon-md" /> : <Eye className="crm-icon-md" />}
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{securityError ? (
|
2026-03-27 09:05:41 +00:00
|
|
|
|
<div className="crm-alert crm-alert-error">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
{securityError}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{securitySuccess ? (
|
2026-03-27 09:05:41 +00:00
|
|
|
|
<div className="crm-alert crm-alert-success">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
{securitySuccess}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</PageModal>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</AnimatePresence>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|