Files
ftb-project-management/apps/web/app/profile/page.tsx
Script Generator d1b4d81a75 feat: 个人中心 UI 改版(Banner + 大头像 + 卡片化)
从孤立白卡改为 GitHub/Notion 风格个人页:
- 顶部 h-40 渐变 Banner(主色到 hover 色),左下 80px 圆头像 + ring 描边
- Banner 内显示姓名/角色·部门/手机·邮箱,右上白底透明退出按钮
- max-w-3xl 卡片居中:标题 + 副标题(如「修改后立即生效」)+ 字段单栏堆叠
- 保存按钮加 spinner(提交中)+ 已保存绿色文字带 Check icon 3 秒淡出
- refreshUser 后 Banner 立即同步新名字/邮箱
- 不考虑移动端

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

304 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, Phone, 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">
{user.phone && (
<span className="flex items-center gap-1"><Phone className="h-3 w-3" />{user.phone}</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 [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 [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);
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;
setSubmitting(true);
setTimeout(() => {
updateMember(user.id, { name: name.trim(), phone: phone.trim(), email: email.trim() });
refreshUser();
setSubmitting(false);
setSavedAt(Date.now());
setTimeout(() => setSavedAt(0), 3000);
}, 200);
};
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="手机号" 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>
<div className="flex items-center justify-end gap-2 pt-5 mt-5 border-t border-[var(--line)]">
{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 [submitting, setSubmitting] = useState(false);
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;
setSubmitting(true);
setTimeout(() => {
updateMember(user.id, { password: newPwd });
handleReset();
setSubmitting(false);
setSavedAt(Date.now());
setTimeout(() => setSavedAt(0), 3000);
}, 200);
};
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)]">
{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>
);
}