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>
This commit is contained in:
Script Generator
2026-06-16 20:26:22 +08:00
parent 7c49868c4f
commit d1b4d81a75
2 changed files with 287 additions and 62 deletions

View File

@@ -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<Tab>('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 (
<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">
<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>
</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 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="flex-1 overflow-y-auto bg-[var(--bg)] px-5 py-5">
<div className="max-w-xl">
{activeTab === 'info' ? <InfoForm /> : <PasswordForm />}
<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>
);
}
@@ -65,6 +97,15 @@ function TabButton({ active, onClick, icon: Icon, label }: { active: boolean; on
);
}
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);
@@ -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 (
<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>
<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>
);
@@ -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 (
<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>
<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>
);

View File

@@ -0,0 +1,154 @@
# 个人中心 UI 改版设计
## Context
当前 `/profile` 页面commit `7c49868`)的视觉太单调——内容用 `max-w-xl` 限宽,单张白卡片孤立漂浮在空白页上,没有头部装饰,整体像一个被推平的弹窗。改为 GitHub/Notion 风格的"个人页":渐变 Banner + 大头像 + Tab 切换的卡片。
## 决策摘要
| 项 | 决策 |
|---|---|
| 整体风格 | 顶部渐变 Banner + 头像 + 下方 Tab 卡片(方案 A |
| Banner 高度 | h-40160px |
| 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
└─ 触发渲染
├─ Banneruser.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. 取消按钮:恢复初始值