| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230 |
- import { FormEvent, useEffect, useState } from 'react'
- import { ArrowLeft, Building2, ChevronRight, Coins, KeyRound, LoaderCircle, Save, UserRound } from 'lucide-react'
- import { Link, useSearchParams } from 'react-router-dom'
- import { api } from '../api'
- import { useAuth } from '../AuthContext'
- import type { PointsTransaction } from '../types'
- import { useWorkspace } from '../WorkspaceContext'
- type ProfileTab = 'profile' | 'security' | 'points'
- const tabs: Array<{ id: ProfileTab; label: string; description: string; icon: typeof UserRound }> = [
- { id: 'profile', label: '用户信息', description: '姓名与组织资料', icon: UserRound },
- { id: 'security', label: '账户安全', description: '修改登录密码', icon: KeyRound },
- { id: 'points', label: '积分账单', description: '余额与积分流水', icon: Coins }
- ]
- export function Profile() {
- const { user, setUser } = useAuth()
- const { organizations, currentOrganization } = useWorkspace()
- const [searchParams, setSearchParams] = useSearchParams()
- const requestedTab = searchParams.get('tab')
- const activeTab: ProfileTab = user?.mustChangePassword ? 'security' : tabs.some((tab) => tab.id === requestedTab)
- ? (requestedTab as ProfileTab)
- : 'profile'
- const [message, setMessage] = useState('')
- const [error, setError] = useState('')
- const [transactions, setTransactions] = useState<PointsTransaction[]>([])
- const [pointsLoading, setPointsLoading] = useState(false)
- const [displayBalance, setDisplayBalance] = useState<number | null>(null)
- useEffect(() => {
- if (activeTab !== 'points') return
- setPointsLoading(true)
- setError('');
- (currentOrganization ? api.organizations.points(currentOrganization.id) : api.getPointsTransactions())
- .then((result) => {
- setTransactions(result.transactions)
- setDisplayBalance(result.balance)
- if (!currentOrganization && user && result.balance !== user.pointsBalance) setUser({ ...user, pointsBalance: result.balance })
- })
- .catch((reason) => setError((reason as Error).message))
- .finally(() => setPointsLoading(false))
- }, [activeTab, currentOrganization?.id])
- if (!user) return null
- const changeTab = (tab: ProfileTab) => {
- setMessage('')
- setError('')
- setSearchParams(tab === 'profile' ? {} : { tab })
- }
- async function save(event: FormEvent<HTMLFormElement>) {
- event.preventDefault()
- const form = new FormData(event.currentTarget)
- setError('')
- setMessage('')
- try {
- const result = await api.updateMe({
- wechat: String(form.get('wechat')),
- contactName: String(form.get('contactName')),
- bio: String(form.get('bio'))
- })
- setUser(result.user)
- setMessage('个人信息已保存')
- } catch (reason) {
- setError((reason as Error).message)
- }
- }
- async function password(event: FormEvent<HTMLFormElement>) {
- event.preventDefault()
- const formElement = event.currentTarget
- const form = new FormData(formElement)
- setError('')
- setMessage('')
- if (form.get('newPassword') !== form.get('confirm')) return setError('两次输入的新密码不一致')
- try {
- await api.changePassword(String(form.get('currentPassword')), String(form.get('newPassword')))
- setUser({ ...user!, mustChangePassword: false })
- formElement.reset()
- setMessage('密码修改成功')
- } catch (reason) {
- setError((reason as Error).message)
- }
- }
- return (
- <div className="page profile-page">
- <Link to="/" className="back-link"><ArrowLeft size={16} />返回课程</Link>
- <div className="profile-settings">
- <aside className="profile-sidebar">
- <div className="profile-sidebar-heading">
- <div className="profile-avatar"><UserRound size={22} /></div>
- <div><strong>{user.contactName || '我的账户'}</strong><span>{user.phone}</span></div>
- </div>
- <nav aria-label="个人中心菜单">
- {tabs.filter((tab) => !user.mustChangePassword || tab.id === 'security').map(({ id, label, description, icon: Icon }) => (
- <button key={id} className={activeTab === id ? 'active' : ''} onClick={() => changeTab(id)}>
- <Icon size={18} />
- <span><strong>{label}</strong><small>{description}</small></span>
- <ChevronRight size={15} />
- </button>
- ))}
- </nav>
- </aside>
- <main className="profile-content">
- {activeTab === 'profile' && (
- <form className="paper-form profile-panel" onSubmit={save}>
- <div className="form-title"><h2>用户信息</h2><p>管理您的基本资料和联系方式</p></div>
- <label>注册手机号<input value={user.phone} disabled /></label>
- <div className="profile-field-item">
- <span className="profile-field-label">隶属组织</span>
- {organizations.length > 0 ? (
- <div className="profile-org-list">
- {organizations.map((org) => (
- <div key={org.id} className="profile-org-card">
- <div className="profile-org-main">
- <div className="profile-org-icon-box">
- <Building2 size={16} />
- </div>
- <div className="profile-org-meta">
- <span className="profile-org-name">{org.name}</span>
- <span className={`profile-org-badge ${org.role === 'MANAGER' ? 'badge-manager' : 'badge-member'}`}>
- {org.role === 'MANAGER' ? '组织管理员' : '组织成员'}
- </span>
- {!org.isActive && <span className="profile-org-badge badge-inactive">已停用</span>}
- </div>
- </div>
- {org.role === 'MANAGER' && (
- <Link to={`/org/${org.slug}/manage`} className="profile-org-action">
- 组织管理
- </Link>
- )}
- </div>
- ))}
- </div>
- ) : user.organization ? (
- <div className="profile-org-list">
- <div className="profile-org-card">
- <div className="profile-org-main">
- <div className="profile-org-icon-box">
- <Building2 size={16} />
- </div>
- <div className="profile-org-meta">
- <span className="profile-org-name">{user.organization}</span>
- <span className="profile-org-badge badge-member">成员</span>
- </div>
- </div>
- </div>
- </div>
- ) : (
- <div className="profile-org-empty-card">
- <Building2 size={16} />
- <span>当前为个人账号,暂未加入任何企业或组织</span>
- </div>
- )}
- </div>
- <label>姓名<input name="contactName" defaultValue={user.contactName} placeholder="怎么称呼您" /></label>
- <label>联系方式(微信)<input name="wechat" defaultValue={user.wechat} placeholder="请输入微信号" /></label>
- <label>补充信息<textarea name="bio" rows={4} defaultValue={user.bio} placeholder="其他需要说明的信息" /></label>
- <button className="primary-button"><Save size={16} />保存信息</button>
- </form>
- )}
- {activeTab === 'security' && (
- <form className="paper-form profile-panel profile-security" onSubmit={password}>
- <div className="form-title"><h2>账户安全</h2><p>定期更新密码有助于保护您的账户</p></div>
- {user.mustChangePassword && <div className="notice notice-error">这是组织管理员创建的初始密码。请先设置自己的密码,再进入工作区。</div>}
- <label>当前密码<input name="currentPassword" type="password" required /></label>
- <label>新密码<input name="newPassword" type="password" minLength={6} required placeholder="至少 6 位" /></label>
- <label>确认新密码<input name="confirm" type="password" minLength={6} required /></label>
- <button className="secondary-button"><KeyRound size={16} />修改密码</button>
- </form>
- )}
- {activeTab === 'points' && (
- <section className="paper-form profile-panel points-history-panel">
- <div className="form-title points-form-title">
- <div>
- <div className="points-heading-wrap">
- <h2>{currentOrganization ? `${currentOrganization.name} · 积分账单` : '个人积分账单'}</h2>
- {currentOrganization && (
- <span className="points-org-pill">
- <Building2 size={13} />
- {currentOrganization.role === 'MANAGER' ? '组织管理员' : '组织成员'}
- </span>
- )}
- </div>
- </div>
- <p>{currentOrganization?.role === 'MANAGER' ? '组织完整积分流水' : currentOrganization ? '组织余额与本人课程消耗' : '个人积分余额与流水'}</p>
- </div>
- <div className="points-wallet">
- <div className="points-wallet-icon"><Coins size={25} /></div>
- <div>
- <span>{currentOrganization ? `${currentOrganization.name} · 组织可用积分` : '个人可用积分'}</span>
- <strong>{(displayBalance ?? currentOrganization?.pointsBalance ?? user.pointsBalance).toLocaleString('zh-CN')}</strong>
- <small>积分</small>
- </div>
- </div>
- <div className="points-history-heading">
- <h3>{currentOrganization ? `${currentOrganization.name} · 积分流水` : '积分流水'}</h3>
- <span>共 {transactions.length} 笔</span>
- </div>
- {pointsLoading ? (
- <div className="points-empty"><LoaderCircle className="spin" size={22} />正在加载消费记录…</div>
- ) : transactions.length ? (
- <div className="points-transaction-list">
- {transactions.map((transaction) => (
- <div className="points-transaction" key={transaction.id}>
- <div className="transaction-icon"><Coins size={17} /></div>
- <div className="transaction-main"><strong>{transaction.courseName}</strong><span>{transaction.points < 0 ? currentOrganization ? '组织账户支付课程费用' : '个人账户支付课程费用' : '积分入账'}</span></div>
- <div className="transaction-value"><strong>{transaction.points.toLocaleString('zh-CN')}</strong><time>{new Date(transaction.createdAt).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}</time></div>
- </div>
- ))}
- </div>
- ) : (
- <div className="points-empty"><Coins size={24} /><strong>暂无积分流水</strong><span>课程制作完成或积分入账后,记录会显示在这里</span></div>
- )}
- </section>
- )}
- {(message || error) && <div className={error ? 'notice notice-error' : 'notice notice-success'}>{error || message}</div>}
- </main>
- </div>
- </div>
- )
- }
|