diff --git a/apps/web/app/profile/page.tsx b/apps/web/app/profile/page.tsx new file mode 100644 index 0000000..9091c57 --- /dev/null +++ b/apps/web/app/profile/page.tsx @@ -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('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 ( +
+
+

个人中心

+ +
+ +
+ setActiveTab('info')} icon={User} label="个人信息" /> + setActiveTab('password')} icon={KeyRound} label="修改密码" /> +
+ +
+
+ {activeTab === 'info' ? : } +
+
+
+ ); +} + +function TabButton({ active, onClick, icon: Icon, label }: { active: boolean; onClick: () => void; icon: any; label: string }) { + return ( + + ); +} + +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 ( +
+ + 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" /> + + + + 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" /> + + + + 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" /> + + + + + + + + +
+ {savedAt > 0 && 已保存} + + +
+
+ ); +} + +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 ( +
+ + setShowOld(!showOld)} /> + + + + setShowNew(!showNew)} /> + + + + setShowConfirm(!showConfirm)} /> + + +
+ {savedAt > 0 && 密码已修改,下次登录生效} + + +
+
+ ); +} + +function Field({ label, required, children }: { label: string; required?: boolean; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +function PwdInput({ value, onChange, show, onToggle }: { value: string; onChange: (v: string) => void; show: boolean; onToggle: () => void }) { + return ( +
+ 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" + /> + +
+ ); +} diff --git a/apps/web/components/layout/Sidebar.tsx b/apps/web/components/layout/Sidebar.tsx index 92f3328..841f4b0 100644 --- a/apps/web/components/layout/Sidebar.tsx +++ b/apps/web/components/layout/Sidebar.tsx @@ -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() {
- +
); } +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 ( +
+
?
+

未登录

+
+ ); + } + + return ( +
+
+ {user.name?.[0] ?? '?'} +
+
+

{user.name}

+

{role?.name ?? '-'}

+
+ +
+ ); +} + function NavGroup({ label, items, isActive, onNavigate }: { label: string; items: { label: string; path: string; icon: any; permission: string | null }[]; diff --git a/apps/web/stores/useAuthStore.ts b/apps/web/stores/useAuthStore.ts index 3820fb8..a45d9d3 100644 --- a/apps/web/stores/useAuthStore.ts +++ b/apps/web/stores/useAuthStore.ts @@ -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((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 {} + }, })); diff --git a/docs/superpowers/specs/2026-06-16-profile-center-design.md b/docs/superpowers/specs/2026-06-16-profile-center-design.md new file mode 100644 index 0000000..14e00e9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-profile-center-design.md @@ -0,0 +1,180 @@ +# 个人中心管理设计 + +## Context + +当前用户登录后无法编辑自己的个人信息和密码——这两件事现在只能由超管在"成员管理"里改。Sidebar 底部"Admin / 管理员"硬编码(与真实登录用户脱节)也是历史遗留 bug。 + +本期目标:新增 `/profile` 路由,让用户能改自己的姓名 / 手机 / 邮箱、修改密码、退出当前账号。顺手把 Sidebar 用户区改成绑定真实用户。 + +## 决策摘要 + +| 项 | 决策 | +|---|---| +| 入口 | Sidebar 底部用户区右侧加 `` 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) +- 右侧加 `` 独立按钮 → `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@]+$/` +- 失败:用 `` 在对应 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、新密=旧密、确认不一致——都能在对应字段下显示红色文案