Files
ftb-project-management/apps/web/app/profile/page.tsx
Script Generator 7c49868c4f feat: 个人中心管理(编辑信息 + 修改密码 + 退出登录)
- 新增 /profile 路由:两个 Tab(个人信息 / 修改密码)+ 退出按钮
- Sidebar 底部用户区改造:绑定真实登录用户(头像/姓名/角色)+ 加 Settings icon 跳 /profile
- useAuthStore 加 refreshUser:保存信息后立即同步 Sidebar 显示
- 个人信息表单:姓名/手机/邮箱可改,部门/角色只读,复用 updateMember
- 密码修改:原密 + 新密(≥8位且≠原密) + 确认 三档校验,眼睛 icon 切换可见,下次登录生效
- 退出登录:confirm → useAuthStore.logout → /login

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-16 19:51:22 +08:00

233 lines
11 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 } 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 } = useMemberStore();
const [activeTab, setActiveTab] = useState<Tab>('info');
useEffect(() => { fetchMembers(); }, [fetchMembers]);
useEffect(() => {
if (!user) router.push('/login');
}, [user, router]);
if (!user) return null;
const handleLogout = () => {
if (!confirm('确认退出当前账号?')) return;
logout();
router.push('/login');
};
return (
<div className="flex h-full flex-col">
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]"></h1>
<button onClick={handleLogout} className="flex h-8 items-center gap-1.5 rounded-lg border border-red-200 bg-white px-3 text-[13px] font-medium text-red-600 hover:bg-red-50 transition-colors">
<LogOut className="h-3.5 w-3.5" strokeWidth={2} />
退
</button>
</header>
<div className="flex items-center gap-0 border-b border-[var(--line)] bg-[var(--bg-card)] px-5 shrink-0">
<TabButton active={activeTab === 'info'} onClick={() => setActiveTab('info')} icon={User} label="个人信息" />
<TabButton active={activeTab === 'password'} onClick={() => setActiveTab('password')} icon={KeyRound} label="修改密码" />
</div>
<div className="flex-1 overflow-y-auto bg-[var(--bg)] px-5 py-5">
<div className="max-w-xl">
{activeTab === 'info' ? <InfoForm /> : <PasswordForm />}
</div>
</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 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 [phone, setPhone] = useState(member?.phone ?? user.phone);
const [email, setEmail] = useState(member?.email ?? user.email);
const [errors, setErrors] = useState<{ name?: string; phone?: string; email?: string }>({});
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);
setPhone(member?.phone ?? user.phone);
setEmail(member?.email ?? user.email);
setErrors({});
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const errs: typeof errors = {};
if (!name.trim()) errs.name = '请输入姓名';
else if (name.trim().length > 20) errs.name = '姓名最长 20 字符';
if (!phone.trim()) errs.phone = '请输入手机号';
else if (!/^1[3-9]\d{9}$/.test(phone.trim())) errs.phone = '手机号格式错误';
if (!email.trim()) errs.email = '请输入邮箱';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) errs.email = '邮箱格式错误';
setErrors(errs);
if (Object.keys(errs).length > 0) return;
updateMember(user.id, { name: name.trim(), phone: phone.trim(), email: email.trim() });
refreshUser();
setSavedAt(Date.now());
setTimeout(() => setSavedAt(0), 2000);
};
return (
<form onSubmit={handleSubmit} className="space-y-4 rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] p-5">
<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="手机号" required>
<input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="11位手机号" 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.phone} />
</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 className="flex items-center justify-end gap-2 pt-2">
{savedAt > 0 && <span className="text-[12px] text-emerald-600 mr-auto"></span>}
<button type="button" onClick={handleReset} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"></button>
<button type="submit" className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]"></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 [savedAt, setSavedAt] = useState(0);
const handleReset = () => {
setOldPwd(''); setNewPwd(''); setConfirmPwd('');
setErrors({});
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
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;
updateMember(user.id, { password: newPwd });
handleReset();
setSavedAt(Date.now());
setTimeout(() => setSavedAt(0), 3000);
};
return (
<form onSubmit={handleSubmit} className="space-y-4 rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] p-5">
<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 className="flex items-center justify-end gap-2 pt-2">
{savedAt > 0 && <span className="text-[12px] text-emerald-600 mr-auto"></span>}
<button type="button" onClick={handleReset} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"></button>
<button type="submit" className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]"></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>
);
}