From d1b4d81a752796cf3f260a8af8cd0506f927a088 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Tue, 16 Jun 2026 20:26:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=AA=E4=BA=BA=E4=B8=AD=E5=BF=83=20?= =?UTF-8?q?UI=20=E6=94=B9=E7=89=88=EF=BC=88Banner=20+=20=E5=A4=A7=E5=A4=B4?= =?UTF-8?q?=E5=83=8F=20+=20=E5=8D=A1=E7=89=87=E5=8C=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从孤立白卡改为 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) --- apps/web/app/profile/page.tsx | 195 ++++++++++++------ .../specs/2026-06-16-profile-ui-redesign.md | 154 ++++++++++++++ 2 files changed, 287 insertions(+), 62 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-16-profile-ui-redesign.md diff --git a/apps/web/app/profile/page.tsx b/apps/web/app/profile/page.tsx index 9091c57..c9b4129 100644 --- a/apps/web/app/profile/page.tsx +++ b/apps/web/app/profile/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { LogOut, User, KeyRound, Eye, EyeOff } from 'lucide-react'; +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'; @@ -13,7 +13,7 @@ export default function ProfilePage() { const router = useRouter(); const user = useAuthStore((s) => s.user); const logout = useAuthStore((s) => s.logout); - const { fetchMembers } = useMemberStore(); + const { fetchMembers, roles, departments } = useMemberStore(); const [activeTab, setActiveTab] = useState('info'); useEffect(() => { fetchMembers(); }, [fetchMembers]); @@ -23,6 +23,17 @@ export default function ProfilePage() { 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(); @@ -30,25 +41,46 @@ export default function ProfilePage() { }; return ( -
-
-

个人中心

- -
- -
- setActiveTab('info')} icon={User} label="个人信息" /> - setActiveTab('password')} icon={KeyRound} label="修改密码" /> +
+
+ {user.name?.[0] ?? '?'} +
+
+

{user.name}

+

+ {role?.name ?? '-'} · {deptName} +

+
+ {user.phone && ( + {user.phone} + )} + {user.email && ( + {user.email} + )} +
+
+
-
-
- {activeTab === 'info' ? : } +
+
+ setActiveTab('info')} icon={User} label="个人信息" /> + setActiveTab('password')} icon={KeyRound} label="修改密码" />
+ +
+ {activeTab === 'info' ? : } +
); } @@ -65,6 +97,15 @@ function TabButton({ active, onClick, icon: Icon, label }: { active: boolean; on ); } +function CardHeader({ title, subtitle }: { title: string; subtitle: string }) { + return ( +
+

{title}

+

{subtitle}

+
+ ); +} + function InfoForm() { const user = useAuthStore((s) => s.user)!; const refreshUser = useAuthStore((s) => s.refreshUser); @@ -75,6 +116,7 @@ function InfoForm() { 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(() => { @@ -108,36 +150,50 @@ function InfoForm() { 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); + 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 ( -
- - 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 && 已保存} - - + + +
+ + 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 && ( + + 已保存 + + )} + +
); @@ -154,6 +210,7 @@ function PasswordForm() { 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 = () => { @@ -175,30 +232,44 @@ function PasswordForm() { setErrors(errs); if (Object.keys(errs).length > 0) return; - updateMember(user.id, { password: newPwd }); - handleReset(); - setSavedAt(Date.now()); - setTimeout(() => setSavedAt(0), 3000); + setSubmitting(true); + setTimeout(() => { + updateMember(user.id, { password: newPwd }); + handleReset(); + setSubmitting(false); + setSavedAt(Date.now()); + setTimeout(() => setSavedAt(0), 3000); + }, 200); }; return ( -
- - setShowOld(!showOld)} /> - - - - setShowNew(!showNew)} /> - - - - setShowConfirm(!showConfirm)} /> - - -
- {savedAt > 0 && 密码已修改,下次登录生效} - - + + +
+ + setShowOld(!showOld)} /> + + + + setShowNew(!showNew)} /> + + + + setShowConfirm(!showConfirm)} /> + + +
+
+ {savedAt > 0 && ( + + 密码已修改,下次登录生效 + + )} + +
); diff --git a/docs/superpowers/specs/2026-06-16-profile-ui-redesign.md b/docs/superpowers/specs/2026-06-16-profile-ui-redesign.md new file mode 100644 index 0000000..e9be455 --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-profile-ui-redesign.md @@ -0,0 +1,154 @@ +# 个人中心 UI 改版设计 + +## Context + +当前 `/profile` 页面(commit `7c49868`)的视觉太单调——内容用 `max-w-xl` 限宽,单张白卡片孤立漂浮在空白页上,没有头部装饰,整体像一个被推平的弹窗。改为 GitHub/Notion 风格的"个人页":渐变 Banner + 大头像 + Tab 切换的卡片。 + +## 决策摘要 + +| 项 | 决策 | +|---|---| +| 整体风格 | 顶部渐变 Banner + 头像 + 下方 Tab 卡片(方案 A) | +| Banner 高度 | h-40(160px) | +| Banner 背景 | `bg-gradient-to-br from-[var(--accent)] to-[var(--accent-hover)]` | +| 头像 | 80px 圆形,主色背景 + 白字首字母,`ring-4 ring-white/20` | +| 退出按钮位置 | Banner 右上角 absolute(白底红描边红字) | +| Tab 风格 | 沿用版本详情页风格(active 主色下划线) | +| 字段布局 | 单栏堆叠(用户决定,避免两栏挤迫) | +| 卡片宽度 | `max-w-3xl mx-auto` 居中 | +| 保存反馈 | 提交中 spinner + 已保存绿色文字 3 秒淡出 | +| 移动端 | 不考虑(项目仅面向桌面端) | + +## 1. Banner 视觉规格 + +**结构**: + +``` +┌─────────────────────────────────────────────────────┐ +│ [渐变背景 h-40] │ +│ [退出登录] │ +│ │ +│ [80px 圆头像] 张三 │ +│ "张" 产品经理 · 产品部 / 前端组 │ +│ 📞 138... · ✉ zhang@... │ +└─────────────────────────────────────────────────────┘ +``` + +**实现要点**: +- 容器 `relative h-40 px-8 py-6 rounded-b-3xl bg-gradient-to-br from-[var(--accent)] to-[var(--accent-hover)] text-white` +- 内部 flex 布局:左侧头像 + 文字组,右上 absolute 退出按钮 +- 头像:`h-20 w-20 rounded-full bg-white/15 ring-4 ring-white/20 flex items-center justify-center text-3xl font-semibold`,内容是 `user.name?.[0]` +- 主名 `text-2xl font-semibold` +- 副名 `text-sm text-white/80`,格式:"角色名 · 部门层级" +- 联系方式 `text-xs text-white/60`,Phone/Mail icon + 文本 +- 退出按钮:`absolute top-5 right-5 h-8 px-3 rounded-lg bg-white/10 border border-white/20 hover:bg-white/20 text-white`(白色透明) + +## 2. Tab 内容布局 + +**结构**: + +``` +[Banner] + + ┌─ 个人信息 ─┐ ┌─ 修改密码 ─┐ + ════════════ + + ┌──────────────────────────────────────────┐ + │ 基本信息 │ + │ 修改后立即生效 │ + │ ───────────────────────────────────── │ + │ │ + │ 姓名 * │ + │ [_____________________________________] │ + │ │ + │ 手机号 * │ + │ [_____________________________________] │ + │ │ + │ 邮箱 * │ + │ [_____________________________________] │ + │ │ + │ 部门 │ + │ [产品部 / 前端组] │ + │ │ + │ 角色 │ + │ [产品经理] │ + │ │ + │ [取消] [保存] │ + └──────────────────────────────────────────┘ +``` + +**实现要点**: +- Banner 与 Tab 之间留 `mt-6` 间距 +- Tab 容器 `max-w-3xl mx-auto px-5`,无外边框,仅 Tab 按钮带下划线 +- 卡片 `max-w-3xl mx-auto rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] p-6` +- 卡片头:标题 `text-base font-semibold` + 副标题 `text-xs text-[var(--ink-muted)] mt-1` +- 标题区下细分割线 `border-t border-[var(--line)] pt-5 mt-4` +- 字段单栏堆叠 `space-y-4` +- 必填星号 `text-red-500` +- 部门、角色字段 `disabled bg-zinc-50` + +**密码 Tab**: +- 卡片标题"修改密码"+ 副标题"密码修改后下次登录生效" +- 三个字段单栏堆叠(与第 1 节澄清一致) +- 眼睛 icon 切换可见性保留 + +## 3. 保存反馈微调 + +**保存按钮三态**: + +``` +普通: [取消] [保存] +提交中: [取消] [● 保存中...] ← spinner 禁用 +成功: 已保存 ✓ [取消] [保存] ← 绿色文字 3 秒淡出 +``` + +- 提交瞬间设 `submitting=true`,按钮 disabled + 左侧 Loader2 icon spinning +- 成功后 `submitting=false` + `savedAt=Date.now()`,3 秒后 `setTimeout` 清掉 savedAt +- 已保存提示:`text-emerald-600` + Check icon + +**取消按钮**: +- 个人信息 Tab:`handleReset` 重读 `useMemberStore.members.find(m => m.id === user.id)` 的字段 +- 密码 Tab:清空三个输入 + 清空 errors + +**Banner 与表单同步**: +- 字段改了未保存:Banner 仍显老值(不联动) +- 保存成功 → `refreshUser()` → Banner 立即更新 + +## 数据流 + +``` +useAuthStore.user + │ + └─ 触发渲染 + │ + ├─ Banner(user.name / role.name / dept name 链 / phone / email) + └─ 表单字段(从 useMemberStore.members.find 取最新值) + +提交流程: + form submit → updateMember() → refreshUser() → Banner 自动重渲 +``` + +## 实施清单 + +修改: +- `apps/web/app/profile/page.tsx` — 整页重写:Banner + Tab + 卡片布局 + 保存 spinner + 已保存淡出 + +不变: +- `apps/web/components/layout/Sidebar.tsx`(已正确) +- `apps/web/stores/useAuthStore.ts`(已加 refreshUser) +- `apps/web/components/FieldError.tsx`(复用) + +## 验证 + +1. `pnpm type-check` 0 错误 +2. `pnpm build` 14+1 个页面成功 +3. 进入 `/profile`: + - 顶部渐变 Banner,左下圆头像 + 姓名/角色/部门/联系方式 + - 右上角白底红字退出按钮 + - Banner 下 Tab 切换条 + 卡片 +4. 改姓名后保存: + - 按钮 spinner 一闪 + - 显示绿色"已保存 ✓"3 秒后淡出 + - Banner 上的姓名立即更新(refreshUser 生效) +5. 修改密码:spinner + "密码已修改,下次登录生效" 3 秒淡出 +6. 取消按钮:恢复初始值