unis_crm/frontend/src/pages/Profile.tsx

686 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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";
import { useTheme } from "@/components/ThemeProvider";
import { useIsMobileViewport } from "@/hooks/useIsMobileViewport";
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
import {
clearAuth,
getCurrentUser,
getProfileOverview,
updateCurrentUserProfile,
updateCurrentUserPassword,
type ProfileOverview,
type UpdateCurrentUserProfilePayload,
type UpdateCurrentUserPasswordPayload,
type UserProfile,
} from "@/lib/auth";
type MenuItem = {
key: "personal" | "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: "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 px-0 pb-0 pt-[env(safe-area-inset-top)] sm:p-6"
>
<div className="mx-auto h-[calc(100dvh-env(safe-area-inset-top))] w-full sm:h-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-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}
</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 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4 dark:border-slate-800 sm:px-6 sm:pb-4">{footer}</div> : null}
</div>
</div>
</motion.div>
</>
);
}
export default function Profile() {
const { theme, setTheme } = useTheme();
const navigate = useNavigate();
const isMobileViewport = useIsMobileViewport();
const isWecomBrowser = useIsWecomBrowser();
const disableMobileMotion = isMobileViewport || isWecomBrowser;
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 handleNavigateToMonthlyOpportunity = () => {
navigate("/opportunities");
};
const handleNavigateToMonthlyExpansion = () => {
navigate("/expansion");
};
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 handleOpenHelp = () => {
window.alert("请联系系统管理员!");
};
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);
return (
<div className="crm-page-stack">
<header className="crm-page-header">
<div className="crm-page-heading">
<h1 className="crm-page-title"></h1>
<p className="crm-page-subtitle"></p>
</div>
</header>
<motion.div
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={disableMobileMotion ? { duration: 0 } : undefined}
className="crm-card crm-card-pad-lg relative rounded-2xl transition-shadow transition-colors"
>
<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"
>
<Settings className="crm-icon-lg" />
</button>
<div className="flex flex-col gap-5 pr-12 sm:flex-row sm:items-start">
<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-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">
<p>{orgNames}</p>
<p>{roleNames}</p>
</div>
</div>
</div>
</div>
</motion.div>
<motion.div
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={disableMobileMotion ? { duration: 0 } : { delay: 0.1 }}
className="crm-card overflow-hidden rounded-2xl transition-shadow transition-colors"
>
<ul className="divide-y divide-slate-50 dark:divide-slate-800/50">
<li>
<button
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="flex w-full items-center justify-between p-4 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50 md:hidden"
>
<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="crm-icon-lg text-amber-500" /> : <Moon className="crm-icon-lg text-indigo-500" />}
</div>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
{theme === "dark" ? "切换亮色模式" : "切换暗色模式"}
</span>
</div>
<ChevronRight className="crm-icon-lg text-slate-300 dark:text-slate-600" />
</button>
</li>
{MENU_ITEMS.map((item) => (
<li key={item.key}>
<button
onClick={
item.key === "personal"
? () => void handleOpenProfile()
: item.key === "security"
? handleOpenSecurity
: item.key === "help"
? handleOpenHelp
: 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"
>
<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={`crm-icon-lg ${item.color}`} />
</div>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">{item.label}</span>
</div>
<ChevronRight className="crm-icon-lg text-slate-300 dark:text-slate-600" />
</button>
</li>
))}
</ul>
</motion.div>
<motion.button
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={disableMobileMotion ? { duration: 0 } : { delay: 0.2 }}
onClick={handleLogout}
className="crm-btn crm-btn-danger flex w-full items-center justify-center gap-2 active:scale-100 sm:active:scale-[0.98]"
>
<LogOut className="crm-icon-lg" />
退
</motion.button>
<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="crm-btn crm-btn-secondary"
>
</button>
<button
onClick={() => void handleSave()}
disabled={saving || detailLoading}
className="crm-btn crm-btn-primary disabled:cursor-not-allowed disabled:opacity-60"
>
{saving ? "保存中..." : "保存资料"}
</button>
</div>
)}
>
{error && !currentUser ? (
<div className="crm-alert crm-alert-error">
{error}
</div>
) : (
<div className="crm-modal-stack">
<div className="crm-form-section 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="crm-form-grid">
<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="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"
/>
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<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">
{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="crm-icon-md text-slate-400" />
</span>
<input
value={form.phone || ""}
onChange={(event) => setForm((current) => ({ ...current, phone: event.target.value }))}
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"
/>
</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="crm-icon-md text-slate-400" />
</span>
<input
value={form.email || ""}
onChange={(event) => setForm((current) => ({ ...current, email: event.target.value }))}
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"
/>
</label>
</div>
<div className="crm-form-grid">
<div className="crm-form-section">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
<BriefcaseBusiness className="crm-icon-md 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="crm-form-section">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
<MapPinned className="crm-icon-md 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="crm-icon-md text-slate-400" />
{numericValue(overview?.onboardingDays)}
</p>
</div>
</div>
</div>
<div className="crm-form-grid">
<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="crm-alert crm-alert-info">
...
</div>
) : null}
{error ? (
<div className="crm-alert crm-alert-error">
{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="crm-btn crm-btn-secondary"
>
</button>
<button
onClick={() => void handleUpdatePassword()}
disabled={securitySaving}
className="crm-btn crm-btn-success disabled:cursor-not-allowed disabled:opacity-60"
>
{securitySaving ? "提交中..." : "更新密码"}
</button>
</div>
)}
>
<div className="crm-modal-stack">
<div className="crm-form-section">
<p className="text-sm text-slate-600 dark:text-slate-300">
</p>
</div>
<div className="crm-section-stack">
<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="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"
/>
<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="crm-icon-md" /> : <Eye className="crm-icon-md" />}
</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="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"
/>
<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="crm-icon-md" /> : <Eye className="crm-icon-md" />}
</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="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"
/>
<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="crm-icon-md" /> : <Eye className="crm-icon-md" />}
</button>
</div>
</label>
</div>
{securityError ? (
<div className="crm-alert crm-alert-error">
{securityError}
</div>
) : null}
{securitySuccess ? (
<div className="crm-alert crm-alert-success">
{securitySuccess}
</div>
) : null}
</div>
</PageModal>
) : null}
</AnimatePresence>
</div>
);
}