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:
232
apps/web/app/profile/page.tsx
Normal file
232
apps/web/app/profile/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
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 { useHasPermission } from '@/components/auth/Guard';
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
|
|
||||||
const NAV_GROUPS = [
|
const NAV_GROUPS = [
|
||||||
{
|
{
|
||||||
@@ -58,20 +60,46 @@ export function Sidebar() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="border-t border-[var(--line)] p-2">
|
<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)]">
|
<UserBlock />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</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 }: {
|
function NavGroup({ label, items, isActive, onNavigate }: {
|
||||||
label: string;
|
label: string;
|
||||||
items: { label: string; path: string; icon: any; permission: string | null }[];
|
items: { label: string; path: string; icon: any; permission: string | null }[];
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface AuthState {
|
|||||||
login: (phone: string, password: string, remember: boolean) => boolean;
|
login: (phone: string, password: string, remember: boolean) => boolean;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
checkAuth: () => void;
|
checkAuth: () => void;
|
||||||
|
refreshUser: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SESSION_KEY = 'ftb_auth_session';
|
const SESSION_KEY = 'ftb_auth_session';
|
||||||
@@ -88,4 +89,22 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
return;
|
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 {}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
180
docs/superpowers/specs/2026-06-16-profile-center-design.md
Normal file
180
docs/superpowers/specs/2026-06-16-profile-center-design.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# 个人中心管理设计
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
当前用户登录后无法编辑自己的个人信息和密码——这两件事现在只能由超管在"成员管理"里改。Sidebar 底部"Admin / 管理员"硬编码(与真实登录用户脱节)也是历史遗留 bug。
|
||||||
|
|
||||||
|
本期目标:新增 `/profile` 路由,让用户能改自己的姓名 / 手机 / 邮箱、修改密码、退出当前账号。顺手把 Sidebar 用户区改成绑定真实用户。
|
||||||
|
|
||||||
|
## 决策摘要
|
||||||
|
|
||||||
|
| 项 | 决策 |
|
||||||
|
|---|---|
|
||||||
|
| 入口 | Sidebar 底部用户区右侧加 `<Settings />` icon,仅 icon 跳转(整块不响应) |
|
||||||
|
| 可编辑字段 | 姓名 / 手机 / 邮箱(部门/角色只读展示) |
|
||||||
|
| 保存策略 | 复用 `useMemberStore.updateMember`;姓名/手机/邮箱保存后立刻 `useAuthStore.refreshUser` 同步 |
|
||||||
|
| 密码修改 | 原密 + 新密 + 确认 三档;新密 ≥ 8 字符且 ≠ 原密 |
|
||||||
|
| 密码生效时机 | 下次登录生效(不强制退出) |
|
||||||
|
| 退出登录 | 复用 `useAuthStore.logout` → 跳 `/login` |
|
||||||
|
| 路由权限 | 不加 RouteGuard(个人中心是当前用户自己的,无需权限点) |
|
||||||
|
| 默认 Tab | 「个人信息」 |
|
||||||
|
| 退出按钮位置 | Header 右上角,红色边框/红字 |
|
||||||
|
| 密码可见切换 | 末尾眼睛 icon(与 login 页风格一致) |
|
||||||
|
| Sidebar 副名 | 通过 `roleId` 反查 `roles` 拿角色名 |
|
||||||
|
| 部门展示 | 通过 `departmentId` 反查 `departments` 拿层级名(如"产品部 / 前端组") |
|
||||||
|
|
||||||
|
## 1. 入口(Sidebar 改造)
|
||||||
|
|
||||||
|
**`apps/web/components/layout/Sidebar.tsx`**
|
||||||
|
|
||||||
|
底部用户区当前硬编码"A / Admin / 管理员"。改造:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ [头像首字母] 张三 [⚙] │
|
||||||
|
│ 产品经理 │
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- 头像首字母 = `user.name?.[0]`(fallback `'?'`)
|
||||||
|
- 主名 = `user.name`
|
||||||
|
- 副名 = `roles.find(r => r.id === user.roleId)?.name ?? '-'`
|
||||||
|
- 整块按钮**不响应点击**(移除 onClick)
|
||||||
|
- 右侧加 `<Settings className="h-4 w-4" />` 独立按钮 → `router.push('/profile')`
|
||||||
|
- 未登录:保留显示"未登录"占位,icon 不显示
|
||||||
|
|
||||||
|
## 2. 个人中心页
|
||||||
|
|
||||||
|
**新增 `apps/web/app/profile/page.tsx`**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Header (h-14) ──────────────────────────┐
|
||||||
|
│ 个人中心 [退出登录] │
|
||||||
|
├──────────────────────────────────────────┤
|
||||||
|
│ ┌─ 个人信息 ─┐ ┌─ 修改密码 ─┐ │ ← Tab 切换
|
||||||
|
├──────────────────────────────────────────┤
|
||||||
|
│ [Tab 内容区] │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**入口校验**:`useAuthStore.user` 不存在则 `router.push('/login')`,避免直访问空 store。
|
||||||
|
|
||||||
|
**Tab 样式**:沿用版本详情页 `border-b-2 + accent` 色风格。
|
||||||
|
|
||||||
|
**退出登录按钮**:
|
||||||
|
- Header 右上角,红色边框红字白底
|
||||||
|
- `onClick`: `confirm('确认退出当前账号?')` → `useAuthStore.logout()` → `router.push('/login')`
|
||||||
|
|
||||||
|
## 3. 个人信息表单
|
||||||
|
|
||||||
|
```
|
||||||
|
姓名 * [_____________]
|
||||||
|
手机号 * [_____________] FieldError
|
||||||
|
邮箱 * [_____________] FieldError
|
||||||
|
部门 产品部 / 前端组 (只读,灰底)
|
||||||
|
角色 产品经理 (只读,灰底)
|
||||||
|
|
||||||
|
[取消] [保存]
|
||||||
|
```
|
||||||
|
|
||||||
|
**初始值**:从 `useMemberStore.members.find(m => m.id === user.id)` 取最新值(不直接信 `useAuthStore.user`)。
|
||||||
|
|
||||||
|
**校验**:
|
||||||
|
- 姓名:必填,trim 后非空,最长 20
|
||||||
|
- 手机号:必填,正则 `/^1[3-9]\d{9}$/`
|
||||||
|
- 邮箱:必填,正则 `/^[^\s@]+@[^\s@]+\.[^\s@]+$/`
|
||||||
|
- 失败:用 `<FieldError>` 在对应 input 下方显示文案
|
||||||
|
|
||||||
|
**部门展示**:递归查 `departmentId` 父子链,拼为"产品部 / 前端组"格式。
|
||||||
|
|
||||||
|
**角色展示**:`roles.find(r => r.id === user.roleId)?.name`。
|
||||||
|
|
||||||
|
**提交**:
|
||||||
|
1. 校验通过 → `useMemberStore.updateMember(user.id, { name, phone, email })`
|
||||||
|
2. 调 `useAuthStore.refreshUser()` 同步 user state(让 Sidebar 立即显示新名字)
|
||||||
|
3. 内联绿色提示"已保存"(2 秒自动消失)
|
||||||
|
|
||||||
|
**取消**:恢复初始值。
|
||||||
|
|
||||||
|
## 4. 密码修改表单 + 退出
|
||||||
|
|
||||||
|
```
|
||||||
|
当前密码 * [_____________ 👁] FieldError
|
||||||
|
新密码 * [_____________ 👁] FieldError
|
||||||
|
确认密码 * [_____________ 👁] FieldError
|
||||||
|
|
||||||
|
[取消] [保存]
|
||||||
|
```
|
||||||
|
|
||||||
|
**校验**:
|
||||||
|
- 当前密码:必填,等于 `member.password`,否则 `FieldError: '当前密码错误'`
|
||||||
|
- 新密码:必填,长度 ≥ 8(与 `passwordRule.length` 默认值一致),≠ 当前密码
|
||||||
|
- 确认密码:必填,必须等于新密码
|
||||||
|
|
||||||
|
**提交**:
|
||||||
|
1. 三项校验通过 → `useMemberStore.updateMember(user.id, { password: newPassword })`
|
||||||
|
2. 清空三个输入框
|
||||||
|
3. 内联绿色提示"密码已修改,下次登录生效"
|
||||||
|
4. 不强制退出
|
||||||
|
|
||||||
|
**眼睛 icon**:每个 input 末尾切换 `type="password" / "text"`,复用 login 页 Eye/EyeOff 模式。
|
||||||
|
|
||||||
|
**取消**:清空三个输入框。
|
||||||
|
|
||||||
|
## 数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
useAuthStore.user (id, name, roleId, departmentId, phone, email)
|
||||||
|
│
|
||||||
|
├─ Sidebar 用户区 ─→ 头像/姓名 + 角色名 + 设置 icon
|
||||||
|
│
|
||||||
|
└─ /profile 页面入口校验
|
||||||
|
│
|
||||||
|
├─ 个人信息表单 ─→ updateMember() ─→ refreshUser() ─→ Sidebar 立即更新
|
||||||
|
├─ 密码表单 ─→ updateMember() ─→ 提示下次登录生效
|
||||||
|
└─ 退出按钮 ─→ logout() ─→ router.push('/login')
|
||||||
|
```
|
||||||
|
|
||||||
|
## useAuthStore 改造
|
||||||
|
|
||||||
|
新增 `refreshUser()` 方法:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
refreshUser: () => {
|
||||||
|
const user = get().user;
|
||||||
|
if (!user) return;
|
||||||
|
// 从 localStorage 重新读 member(与 login 同源)
|
||||||
|
const membersRaw = localStorage.getItem('ftb_members_v1');
|
||||||
|
if (!membersRaw) return;
|
||||||
|
const parsed = JSON.parse(membersRaw);
|
||||||
|
const m = parsed.members?.find((x: any) => x.id === user.id);
|
||||||
|
if (!m) return;
|
||||||
|
const next = { id: m.id, name: m.name, roleId: m.roleId, departmentId: m.departmentId, phone: m.phone, email: m.email };
|
||||||
|
set({ user: next });
|
||||||
|
sessionStorage.setItem('ftb_auth_session', JSON.stringify(next));
|
||||||
|
if (localStorage.getItem('ftb_auth_persist')) {
|
||||||
|
localStorage.setItem('ftb_auth_persist', JSON.stringify(next));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 实施清单
|
||||||
|
|
||||||
|
新增:
|
||||||
|
- `apps/web/app/profile/page.tsx` — 个人中心页面(含两个 Tab + 退出按钮)
|
||||||
|
|
||||||
|
修改:
|
||||||
|
- `apps/web/components/layout/Sidebar.tsx` — 用户区绑定真实 user + 加 Settings icon
|
||||||
|
- `apps/web/stores/useAuthStore.ts` — 加 `refreshUser` 方法
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
1. `pnpm type-check` 0 错误
|
||||||
|
2. `pnpm build` 14+1 个页面成功(新增 `/profile`)
|
||||||
|
3. 登录张三(产品经理):
|
||||||
|
- Sidebar 底部显示"张 张三 / 产品经理 / ⚙"
|
||||||
|
- 点 ⚙ 跳 `/profile`
|
||||||
|
- 改姓名提交后 Sidebar 立即同步新名字
|
||||||
|
- 改密码后提示下次登录生效,重新登录用新密码可登入旧密码失败
|
||||||
|
- 点退出 → 弹确认 → 跳 /login + session 清空
|
||||||
|
4. 三个表单的校验:手机/邮箱格式错误、新密短于 8、新密=旧密、确认不一致——都能在对应字段下显示红色文案
|
||||||
Reference in New Issue
Block a user