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>
This commit is contained in:
Script Generator
2026-06-16 19:51:22 +08:00
parent c2f2fed82c
commit 7c49868c4f
4 changed files with 469 additions and 10 deletions

View File

@@ -0,0 +1,232 @@
'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>
);
}

View File

@@ -1,8 +1,10 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search, Lightbulb, Clock, Shield } from 'lucide-react';
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search, Lightbulb, Clock, Shield, Settings } from 'lucide-react';
import { useHasPermission } from '@/components/auth/Guard';
import { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore';
const NAV_GROUPS = [
{
@@ -58,20 +60,46 @@ export function Sidebar() {
</nav>
<div className="border-t border-[var(--line)] p-2">
<button className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-[var(--bg-subtle)]">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent)] text-[11px] font-semibold text-white">
A
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-medium text-[var(--ink)]">Admin</p>
<p className="truncate text-[11px] text-[var(--ink-muted)]"></p>
</div>
</button>
<UserBlock />
</div>
</aside>
);
}
function UserBlock() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const role = useMemberStore((s) => s.roles.find((r) => r.id === user?.roleId));
if (!user) {
return (
<div className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--bg-subtle)] text-[11px] font-semibold text-[var(--ink-muted)]">?</div>
<p className="truncate text-[13px] text-[var(--ink-muted)]"></p>
</div>
);
}
return (
<div className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent)] text-[11px] font-semibold text-white">
{user.name?.[0] ?? '?'}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-medium text-[var(--ink)]">{user.name}</p>
<p className="truncate text-[11px] text-[var(--ink-muted)]">{role?.name ?? '-'}</p>
</div>
<button
onClick={() => router.push('/profile')}
className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)] hover:text-[var(--accent)]"
title="个人中心"
>
<Settings className="h-4 w-4" strokeWidth={1.75} />
</button>
</div>
);
}
function NavGroup({ label, items, isActive, onNavigate }: {
label: string;
items: { label: string; path: string; icon: any; permission: string | null }[];

View File

@@ -17,6 +17,7 @@ interface AuthState {
login: (phone: string, password: string, remember: boolean) => boolean;
logout: () => void;
checkAuth: () => void;
refreshUser: () => void;
}
const SESSION_KEY = 'ftb_auth_session';
@@ -88,4 +89,22 @@ export const useAuthStore = create<AuthState>((set) => ({
return;
}
},
refreshUser: () => {
const cur = useAuthStore.getState().user;
if (!cur) return;
try {
const raw = localStorage.getItem('ftb_members_v1');
if (!raw) return;
const parsed = JSON.parse(raw);
const m = parsed.members?.find((x: any) => x.id === cur.id);
if (!m) return;
const next: AuthUser = { id: m.id, name: m.name, roleId: m.roleId, departmentId: m.departmentId, phone: m.phone, email: m.email };
useAuthStore.setState({ user: next });
sessionStorage.setItem(SESSION_KEY, JSON.stringify(next));
if (localStorage.getItem(PERSIST_KEY)) {
localStorage.setItem(PERSIST_KEY, JSON.stringify(next));
}
} catch {}
},
}));