297 lines
13 KiB
TypeScript
297 lines
13 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useMemo, useState } from 'react';
|
||
import { useRouter } from 'next/navigation';
|
||
import { LogOut, User, KeyRound, Eye, EyeOff, Mail, Loader2, Check } 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, roles, departments } = useMemberStore();
|
||
const [activeTab, setActiveTab] = useState<Tab>('info');
|
||
|
||
useEffect(() => { fetchMembers(); }, [fetchMembers]);
|
||
useEffect(() => {
|
||
if (!user) router.push('/login');
|
||
}, [user, router]);
|
||
|
||
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();
|
||
router.push('/login');
|
||
};
|
||
|
||
return (
|
||
<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>
|
||
<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">
|
||
<span className="flex items-center gap-1"><User className="h-3 w-3" />{user.username}</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="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>
|
||
);
|
||
}
|
||
|
||
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 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);
|
||
const { members, departments, roles, updateMember } = useMemberStore();
|
||
const member = members.find((m) => m.id === user.id);
|
||
|
||
const [name, setName] = useState(member?.name ?? user.name);
|
||
const [email, setEmail] = useState(member?.email ?? user.email);
|
||
const [errors, setErrors] = useState<{ name?: string; email?: string }>({});
|
||
const [submitting, setSubmitting] = useState(false);
|
||
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);
|
||
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 (!email.trim()) errs.email = '请输入邮箱';
|
||
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) errs.email = '邮箱格式错误';
|
||
setErrors(errs);
|
||
if (Object.keys(errs).length > 0) return;
|
||
|
||
setSubmitting(true);
|
||
setTimeout(() => {
|
||
updateMember(user.id, { name: name.trim(), email: email.trim() });
|
||
refreshUser();
|
||
setSubmitting(false);
|
||
setSavedAt(Date.now());
|
||
setTimeout(() => setSavedAt(0), 3000);
|
||
}, 200);
|
||
};
|
||
|
||
return (
|
||
<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="用户名">
|
||
<input value={member?.username ?? user.username} disabled className="h-9 w-full rounded-lg border border-[var(--line)] bg-zinc-50 px-3 font-mono text-[13px] text-[var(--ink-soft)] cursor-not-allowed" />
|
||
</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>
|
||
);
|
||
}
|
||
|
||
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 [submitting, setSubmitting] = useState(false);
|
||
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;
|
||
|
||
setSubmitting(true);
|
||
setTimeout(() => {
|
||
updateMember(user.id, { password: newPwd });
|
||
handleReset();
|
||
setSubmitting(false);
|
||
setSavedAt(Date.now());
|
||
setTimeout(() => setSavedAt(0), 3000);
|
||
}, 200);
|
||
};
|
||
|
||
return (
|
||
<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>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|