Files
ftb-project-management/apps/web/app/profile/page.tsx
2026-07-03 19:10:02 +08:00

319 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { LogOut, User, KeyRound, Eye, EyeOff, Mail, Loader2, Check } from 'lucide-react';
import { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { FieldError } from '@/components/FieldError';
type Tab = 'info' | 'password';
export default function ProfilePage() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const { fetchMembers, roles, departments } = useMemberStore();
const [activeTab, setActiveTab] = useState<Tab>('info');
useEffect(() => { fetchMembers(); }, [fetchMembers]);
useEffect(() => {
if (!user) router.push('/login');
}, [user, router]);
if (!user) return null;
const role = roles.find((r) => r.id === user.roleId);
const deptName = useMemo(() => {
const d = departments.find((x) => x.id === user.departmentId);
if (!d) return '-';
if (d.parentId) {
const parent = departments.find((x) => x.id === d.parentId);
return parent ? `${parent.name} / ${d.name}` : d.name;
}
return d.name;
}, [departments, user.departmentId]);
const handleLogout = () => {
if (!confirm('确认退出当前账号?')) return;
logout();
router.push('/login');
};
return (
<div className="flex h-full flex-col overflow-y-auto bg-[var(--bg)]">
<div className="relative h-40 px-8 py-6 rounded-b-3xl bg-gradient-to-br from-[var(--accent)] to-[var(--accent-hover)] text-white shrink-0">
<button
onClick={handleLogout}
className="absolute top-5 right-5 flex h-8 items-center gap-1.5 rounded-lg bg-white/10 border border-white/20 px-3 text-[12px] font-medium text-white hover:bg-white/20 transition-colors"
>
<LogOut className="h-3.5 w-3.5" strokeWidth={2} />
退
</button>
<div className="flex items-center gap-5 h-full">
<div className="h-20 w-20 rounded-full bg-white/15 ring-4 ring-white/20 flex items-center justify-center text-3xl font-semibold shrink-0">
{user.name?.[0] ?? '?'}
</div>
<div className="min-w-0">
<p className="text-2xl font-semibold leading-tight">{user.name}</p>
<p className="text-sm text-white/80 mt-1">
{role?.name ?? '-'} <span className="mx-1.5 text-white/40">·</span> {deptName}
</p>
<div className="flex items-center gap-3 mt-2 text-xs text-white/70">
<span className="flex items-center gap-1"><User className="h-3 w-3" />{user.username}</span>
{user.email && (
<span className="flex items-center gap-1"><Mail className="h-3 w-3" />{user.email}</span>
)}
</div>
</div>
</div>
</div>
<div className="max-w-3xl w-full mx-auto px-5 mt-6 shrink-0">
<div className="flex items-center gap-0 border-b border-[var(--line)]">
<TabButton active={activeTab === 'info'} onClick={() => setActiveTab('info')} icon={User} label="个人信息" />
<TabButton active={activeTab === 'password'} onClick={() => setActiveTab('password')} icon={KeyRound} label="修改密码" />
</div>
</div>
<div className="max-w-3xl w-full mx-auto px-5 py-6 flex-1">
{activeTab === 'info' ? <InfoForm /> : <PasswordForm />}
</div>
</div>
);
}
function TabButton({ active, onClick, icon: Icon, label }: { active: boolean; onClick: () => void; icon: any; label: string }) {
return (
<button
onClick={onClick}
className={`flex items-center gap-1.5 px-4 py-2.5 text-[13px] font-medium border-b-2 transition-colors ${active ? 'border-[var(--accent)] text-[var(--ink)]' : 'border-transparent text-[var(--ink-muted)] hover:text-[var(--ink-soft)]'}`}
>
<Icon className="h-3.5 w-3.5" strokeWidth={1.75} />
{label}
</button>
);
}
function CardHeader({ title, subtitle }: { title: string; subtitle: string }) {
return (
<div className="border-b border-[var(--line)] pb-4 mb-5">
<h2 className="text-base font-semibold text-[var(--ink)]">{title}</h2>
<p className="text-xs text-[var(--ink-muted)] mt-1">{subtitle}</p>
</div>
);
}
function InfoForm() {
const user = useAuthStore((s) => s.user)!;
const refreshUser = useAuthStore((s) => s.refreshUser);
const { members, departments, roles, updateMember } = useMemberStore();
const member = members.find((m) => m.id === user.id);
const [name, setName] = useState(member?.name ?? user.name);
const [email, setEmail] = useState(member?.email ?? user.email);
const [errors, setErrors] = useState<{ name?: string; email?: string }>({});
const [saveError, setSaveError] = useState('');
const [submitting, setSubmitting] = useState(false);
const [savedAt, setSavedAt] = useState(0);
const deptName = useMemo(() => {
const d = departments.find((x) => x.id === user.departmentId);
if (!d) return '-';
if (d.parentId) {
const parent = departments.find((x) => x.id === d.parentId);
return parent ? `${parent.name} / ${d.name}` : d.name;
}
return d.name;
}, [departments, user.departmentId]);
const roleName = roles.find((r) => r.id === user.roleId)?.name ?? '-';
const handleReset = () => {
setName(member?.name ?? user.name);
setEmail(member?.email ?? user.email);
setErrors({});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaveError('');
setSavedAt(0);
const errs: typeof errors = {};
if (!name.trim()) errs.name = '请输入姓名';
else if (name.trim().length > 20) errs.name = '姓名最长 20 字符';
if (!email.trim()) errs.email = '请输入邮箱';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) errs.email = '邮箱格式错误';
setErrors(errs);
if (Object.keys(errs).length > 0) return;
try {
setSubmitting(true);
await updateMember(user.id, { name: name.trim(), email: email.trim() });
await refreshUser();
setSavedAt(Date.now());
setTimeout(() => setSavedAt(0), 3000);
} catch {
setSaveError('保存失败,请确认网络/API 服务正常后重试');
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] p-6">
<CardHeader title="基本信息" subtitle="修改后立即生效" />
<div className="space-y-4">
<Field label="姓名" required>
<input value={name} onChange={(e) => setName(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
<FieldError message={errors.name} />
</Field>
<Field label="用户名">
<input value={member?.username ?? user.username} disabled className="h-9 w-full rounded-lg border border-[var(--line)] bg-zinc-50 px-3 font-mono text-[13px] text-[var(--ink-soft)] cursor-not-allowed" />
</Field>
<Field label="邮箱" required>
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="name@company.com" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
<FieldError message={errors.email} />
</Field>
<Field label="部门">
<input value={deptName} disabled className="h-9 w-full rounded-lg border border-[var(--line)] bg-zinc-50 px-3 text-[13px] text-[var(--ink-soft)] cursor-not-allowed" />
</Field>
<Field label="角色">
<input value={roleName} disabled className="h-9 w-full rounded-lg border border-[var(--line)] bg-zinc-50 px-3 text-[13px] text-[var(--ink-soft)] cursor-not-allowed" />
</Field>
</div>
<div className="flex items-center justify-end gap-2 pt-5 mt-5 border-t border-[var(--line)]">
{saveError && (
<span className="mr-auto">
<FieldError message={saveError} />
</span>
)}
{savedAt > 0 && (
<span className="flex items-center gap-1 text-[12px] text-emerald-600 mr-auto transition-opacity">
<Check className="h-3.5 w-3.5" />
</span>
)}
<button type="button" onClick={handleReset} disabled={submitting} className="h-8 px-4 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] disabled:opacity-50"></button>
<button type="submit" disabled={submitting} className="flex items-center gap-1.5 h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-60">
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{submitting ? '保存中...' : '保存'}
</button>
</div>
</form>
);
}
function PasswordForm() {
const user = useAuthStore((s) => s.user)!;
const { members, updateMember } = useMemberStore();
const [oldPwd, setOldPwd] = useState('');
const [newPwd, setNewPwd] = useState('');
const [confirmPwd, setConfirmPwd] = useState('');
const [showOld, setShowOld] = useState(false);
const [showNew, setShowNew] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [errors, setErrors] = useState<{ oldPwd?: string; newPwd?: string; confirmPwd?: string }>({});
const [saveError, setSaveError] = useState('');
const [submitting, setSubmitting] = useState(false);
const [savedAt, setSavedAt] = useState(0);
const handleReset = () => {
setOldPwd(''); setNewPwd(''); setConfirmPwd('');
setErrors({});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaveError('');
setSavedAt(0);
const member = members.find((m) => m.id === user.id);
const errs: typeof errors = {};
if (!oldPwd) errs.oldPwd = '请输入当前密码';
else if (member && oldPwd !== member.password) errs.oldPwd = '当前密码错误';
if (!newPwd) errs.newPwd = '请输入新密码';
else if (newPwd.length < 8) errs.newPwd = '新密码长度至少 8 位';
else if (newPwd === oldPwd) errs.newPwd = '新密码不能与当前密码相同';
if (!confirmPwd) errs.confirmPwd = '请再次输入新密码';
else if (confirmPwd !== newPwd) errs.confirmPwd = '两次输入不一致';
setErrors(errs);
if (Object.keys(errs).length > 0) return;
try {
setSubmitting(true);
await updateMember(user.id, { password: newPwd });
handleReset();
setSavedAt(Date.now());
setTimeout(() => setSavedAt(0), 3000);
} catch {
setSaveError('密码保存失败,请确认网络/API 服务正常后重试');
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] p-6">
<CardHeader title="修改密码" subtitle="密码修改后下次登录生效" />
<div className="space-y-4">
<Field label="当前密码" required>
<PwdInput value={oldPwd} onChange={setOldPwd} show={showOld} onToggle={() => setShowOld(!showOld)} />
<FieldError message={errors.oldPwd} />
</Field>
<Field label="新密码" required>
<PwdInput value={newPwd} onChange={setNewPwd} show={showNew} onToggle={() => setShowNew(!showNew)} />
<FieldError message={errors.newPwd} />
</Field>
<Field label="确认新密码" required>
<PwdInput value={confirmPwd} onChange={setConfirmPwd} show={showConfirm} onToggle={() => setShowConfirm(!showConfirm)} />
<FieldError message={errors.confirmPwd} />
</Field>
</div>
<div className="flex items-center justify-end gap-2 pt-5 mt-5 border-t border-[var(--line)]">
{saveError && (
<span className="mr-auto">
<FieldError message={saveError} />
</span>
)}
{savedAt > 0 && (
<span className="flex items-center gap-1 text-[12px] text-emerald-600 mr-auto transition-opacity">
<Check className="h-3.5 w-3.5" />
</span>
)}
<button type="button" onClick={handleReset} disabled={submitting} className="h-8 px-4 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] disabled:opacity-50"></button>
<button type="submit" disabled={submitting} className="flex items-center gap-1.5 h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-60">
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{submitting ? '保存中...' : '保存'}
</button>
</div>
</form>
);
}
function Field({ label, required, children }: { label: string; required?: boolean; children: React.ReactNode }) {
return (
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">
{label}{required && <span className="text-red-500"> *</span>}
</label>
{children}
</div>
);
}
function PwdInput({ value, onChange, show, onToggle }: { value: string; onChange: (v: string) => void; show: boolean; onToggle: () => void }) {
return (
<div className="relative">
<input
type={show ? 'text' : 'password'}
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 pr-9 text-[13px] focus:border-[var(--accent)] focus:outline-none"
/>
<button type="button" onClick={onToggle} className="absolute right-1 top-1/2 -translate-y-1/2 p-1.5 text-[var(--ink-muted)] hover:text-[var(--ink-soft)]">
{show ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
</div>
);
}