Profile.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. import { FormEvent, useEffect, useState } from 'react'
  2. import { ArrowLeft, Building2, ChevronRight, Coins, KeyRound, LoaderCircle, Save, UserRound } from 'lucide-react'
  3. import { Link, useSearchParams } from 'react-router-dom'
  4. import { api } from '../api'
  5. import { useAuth } from '../AuthContext'
  6. import type { PointsTransaction } from '../types'
  7. import { useWorkspace } from '../WorkspaceContext'
  8. type ProfileTab = 'profile' | 'security' | 'points'
  9. const tabs: Array<{ id: ProfileTab; label: string; description: string; icon: typeof UserRound }> = [
  10. { id: 'profile', label: '用户信息', description: '姓名与组织资料', icon: UserRound },
  11. { id: 'security', label: '账户安全', description: '修改登录密码', icon: KeyRound },
  12. { id: 'points', label: '积分账单', description: '余额与积分流水', icon: Coins }
  13. ]
  14. export function Profile() {
  15. const { user, setUser } = useAuth()
  16. const { organizations, currentOrganization } = useWorkspace()
  17. const [searchParams, setSearchParams] = useSearchParams()
  18. const requestedTab = searchParams.get('tab')
  19. const activeTab: ProfileTab = user?.mustChangePassword ? 'security' : tabs.some((tab) => tab.id === requestedTab)
  20. ? (requestedTab as ProfileTab)
  21. : 'profile'
  22. const [message, setMessage] = useState('')
  23. const [error, setError] = useState('')
  24. const [transactions, setTransactions] = useState<PointsTransaction[]>([])
  25. const [pointsLoading, setPointsLoading] = useState(false)
  26. const [displayBalance, setDisplayBalance] = useState<number | null>(null)
  27. useEffect(() => {
  28. if (activeTab !== 'points') return
  29. setPointsLoading(true)
  30. setError('');
  31. (currentOrganization ? api.organizations.points(currentOrganization.id) : api.getPointsTransactions())
  32. .then((result) => {
  33. setTransactions(result.transactions)
  34. setDisplayBalance(result.balance)
  35. if (!currentOrganization && user && result.balance !== user.pointsBalance) setUser({ ...user, pointsBalance: result.balance })
  36. })
  37. .catch((reason) => setError((reason as Error).message))
  38. .finally(() => setPointsLoading(false))
  39. }, [activeTab, currentOrganization?.id])
  40. if (!user) return null
  41. const changeTab = (tab: ProfileTab) => {
  42. setMessage('')
  43. setError('')
  44. setSearchParams(tab === 'profile' ? {} : { tab })
  45. }
  46. async function save(event: FormEvent<HTMLFormElement>) {
  47. event.preventDefault()
  48. const form = new FormData(event.currentTarget)
  49. setError('')
  50. setMessage('')
  51. try {
  52. const result = await api.updateMe({
  53. wechat: String(form.get('wechat')),
  54. contactName: String(form.get('contactName')),
  55. bio: String(form.get('bio'))
  56. })
  57. setUser(result.user)
  58. setMessage('个人信息已保存')
  59. } catch (reason) {
  60. setError((reason as Error).message)
  61. }
  62. }
  63. async function password(event: FormEvent<HTMLFormElement>) {
  64. event.preventDefault()
  65. const formElement = event.currentTarget
  66. const form = new FormData(formElement)
  67. setError('')
  68. setMessage('')
  69. if (form.get('newPassword') !== form.get('confirm')) return setError('两次输入的新密码不一致')
  70. try {
  71. await api.changePassword(String(form.get('currentPassword')), String(form.get('newPassword')))
  72. setUser({ ...user!, mustChangePassword: false })
  73. formElement.reset()
  74. setMessage('密码修改成功')
  75. } catch (reason) {
  76. setError((reason as Error).message)
  77. }
  78. }
  79. return (
  80. <div className="page profile-page">
  81. <Link to="/" className="back-link"><ArrowLeft size={16} />返回课程</Link>
  82. <div className="profile-settings">
  83. <aside className="profile-sidebar">
  84. <div className="profile-sidebar-heading">
  85. <div className="profile-avatar"><UserRound size={22} /></div>
  86. <div><strong>{user.contactName || '我的账户'}</strong><span>{user.phone}</span></div>
  87. </div>
  88. <nav aria-label="个人中心菜单">
  89. {tabs.filter((tab) => !user.mustChangePassword || tab.id === 'security').map(({ id, label, description, icon: Icon }) => (
  90. <button key={id} className={activeTab === id ? 'active' : ''} onClick={() => changeTab(id)}>
  91. <Icon size={18} />
  92. <span><strong>{label}</strong><small>{description}</small></span>
  93. <ChevronRight size={15} />
  94. </button>
  95. ))}
  96. </nav>
  97. </aside>
  98. <main className="profile-content">
  99. {activeTab === 'profile' && (
  100. <form className="paper-form profile-panel" onSubmit={save}>
  101. <div className="form-title"><h2>用户信息</h2><p>管理您的基本资料和联系方式</p></div>
  102. <label>注册手机号<input value={user.phone} disabled /></label>
  103. <div className="profile-field-item">
  104. <span className="profile-field-label">隶属组织</span>
  105. {organizations.length > 0 ? (
  106. <div className="profile-org-list">
  107. {organizations.map((org) => (
  108. <div key={org.id} className="profile-org-card">
  109. <div className="profile-org-main">
  110. <div className="profile-org-icon-box">
  111. <Building2 size={16} />
  112. </div>
  113. <div className="profile-org-meta">
  114. <span className="profile-org-name">{org.name}</span>
  115. <span className={`profile-org-badge ${org.role === 'MANAGER' ? 'badge-manager' : 'badge-member'}`}>
  116. {org.role === 'MANAGER' ? '组织管理员' : '组织成员'}
  117. </span>
  118. {!org.isActive && <span className="profile-org-badge badge-inactive">已停用</span>}
  119. </div>
  120. </div>
  121. {org.role === 'MANAGER' && (
  122. <Link to={`/org/${org.slug}/manage`} className="profile-org-action">
  123. 组织管理
  124. </Link>
  125. )}
  126. </div>
  127. ))}
  128. </div>
  129. ) : user.organization ? (
  130. <div className="profile-org-list">
  131. <div className="profile-org-card">
  132. <div className="profile-org-main">
  133. <div className="profile-org-icon-box">
  134. <Building2 size={16} />
  135. </div>
  136. <div className="profile-org-meta">
  137. <span className="profile-org-name">{user.organization}</span>
  138. <span className="profile-org-badge badge-member">成员</span>
  139. </div>
  140. </div>
  141. </div>
  142. </div>
  143. ) : (
  144. <div className="profile-org-empty-card">
  145. <Building2 size={16} />
  146. <span>当前为个人账号,暂未加入任何企业或组织</span>
  147. </div>
  148. )}
  149. </div>
  150. <label>姓名<input name="contactName" defaultValue={user.contactName} placeholder="怎么称呼您" /></label>
  151. <label>联系方式(微信)<input name="wechat" defaultValue={user.wechat} placeholder="请输入微信号" /></label>
  152. <label>补充信息<textarea name="bio" rows={4} defaultValue={user.bio} placeholder="其他需要说明的信息" /></label>
  153. <button className="primary-button"><Save size={16} />保存信息</button>
  154. </form>
  155. )}
  156. {activeTab === 'security' && (
  157. <form className="paper-form profile-panel profile-security" onSubmit={password}>
  158. <div className="form-title"><h2>账户安全</h2><p>定期更新密码有助于保护您的账户</p></div>
  159. {user.mustChangePassword && <div className="notice notice-error">这是组织管理员创建的初始密码。请先设置自己的密码,再进入工作区。</div>}
  160. <label>当前密码<input name="currentPassword" type="password" required /></label>
  161. <label>新密码<input name="newPassword" type="password" minLength={6} required placeholder="至少 6 位" /></label>
  162. <label>确认新密码<input name="confirm" type="password" minLength={6} required /></label>
  163. <button className="secondary-button"><KeyRound size={16} />修改密码</button>
  164. </form>
  165. )}
  166. {activeTab === 'points' && (
  167. <section className="paper-form profile-panel points-history-panel">
  168. <div className="form-title points-form-title">
  169. <div>
  170. <div className="points-heading-wrap">
  171. <h2>{currentOrganization ? `${currentOrganization.name} · 积分账单` : '个人积分账单'}</h2>
  172. {currentOrganization && (
  173. <span className="points-org-pill">
  174. <Building2 size={13} />
  175. {currentOrganization.role === 'MANAGER' ? '组织管理员' : '组织成员'}
  176. </span>
  177. )}
  178. </div>
  179. </div>
  180. <p>{currentOrganization?.role === 'MANAGER' ? '组织完整积分流水' : currentOrganization ? '组织余额与本人课程消耗' : '个人积分余额与流水'}</p>
  181. </div>
  182. <div className="points-wallet">
  183. <div className="points-wallet-icon"><Coins size={25} /></div>
  184. <div>
  185. <span>{currentOrganization ? `${currentOrganization.name} · 组织可用积分` : '个人可用积分'}</span>
  186. <strong>{(displayBalance ?? currentOrganization?.pointsBalance ?? user.pointsBalance).toLocaleString('zh-CN')}</strong>
  187. <small>积分</small>
  188. </div>
  189. </div>
  190. <div className="points-history-heading">
  191. <h3>{currentOrganization ? `${currentOrganization.name} · 积分流水` : '积分流水'}</h3>
  192. <span>共 {transactions.length} 笔</span>
  193. </div>
  194. {pointsLoading ? (
  195. <div className="points-empty"><LoaderCircle className="spin" size={22} />正在加载消费记录…</div>
  196. ) : transactions.length ? (
  197. <div className="points-transaction-list">
  198. {transactions.map((transaction) => (
  199. <div className="points-transaction" key={transaction.id}>
  200. <div className="transaction-icon"><Coins size={17} /></div>
  201. <div className="transaction-main"><strong>{transaction.courseName}</strong><span>{transaction.points < 0 ? currentOrganization ? '组织账户支付课程费用' : '个人账户支付课程费用' : '积分入账'}</span></div>
  202. <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>
  203. </div>
  204. ))}
  205. </div>
  206. ) : (
  207. <div className="points-empty"><Coins size={24} /><strong>暂无积分流水</strong><span>课程制作完成或积分入账后,记录会显示在这里</span></div>
  208. )}
  209. </section>
  210. )}
  211. {(message || error) && <div className={error ? 'notice notice-error' : 'notice notice-success'}>{error || message}</div>}
  212. </main>
  213. </div>
  214. </div>
  215. )
  216. }