diff --git a/frontend/src/pages/account/index.tsx b/frontend/src/pages/account/index.tsx index f0c515aa..cb54ddd4 100644 --- a/frontend/src/pages/account/index.tsx +++ b/frontend/src/pages/account/index.tsx @@ -1,10 +1,11 @@ -import { useEffect, useId, useRef, useState, type FormEvent } from 'react' +import { useEffect, useId, useReducer, useRef, useState, type FormEvent } from 'react' import accountBadgeArtwork from '@/assets/account/illustrations/account-badge.webp' import type { User } from '@/entities' import { useAuthSession } from '@/features/auth-session' import './account.css' +import { createProfileState, initialSecurityState, profileReducer, securityReducer } from './state' const MAX_NICKNAME_LENGTH = 50 @@ -35,16 +36,12 @@ export function AccountPage() { } = session const currentUser = session.state.status === 'authenticated' ? session.state.user : null const profileRequestRef = useRef | null>(null) - const [nickname, setNickname] = useState(currentUser?.nickname ?? '') - const [isProfileLoading, setIsProfileLoading] = useState(true) - const [isProfileFresh, setIsProfileFresh] = useState(false) - const [isSavingNickname, setIsSavingNickname] = useState(false) - const [profileError, setProfileError] = useState(null) - const [profileSuccess, setProfileSuccess] = useState(null) - const [oldPassword, setOldPassword] = useState('') - const [newPassword, setNewPassword] = useState('') - const [isChangingPassword, setIsChangingPassword] = useState(false) - const [passwordError, setPasswordError] = useState(null) + const [profile, dispatchProfile] = useReducer( + profileReducer, + currentUser?.nickname ?? '', + createProfileState, + ) + const [security, dispatchSecurity] = useReducer(securityReducer, initialSecurityState) const [activeSection, setActiveSection] = useState<'profile' | 'security'>('profile') const nicknameId = useId() const oldPasswordId = useId() @@ -56,16 +53,11 @@ export function AccountPage() { void profileRequestRef.current.then( (user) => { if (!active) return - setNickname(user.nickname ?? '') - setIsProfileFresh(true) - setProfileError(null) - setIsProfileLoading(false) + dispatchProfile({ type: 'refreshSucceeded', nickname: user.nickname ?? '' }) }, (error) => { if (!active) return - setIsProfileFresh(false) - setProfileError(errorMessage(error)) - setIsProfileLoading(false) + dispatchProfile({ type: 'refreshFailed', error: errorMessage(error) }) }, ) return () => { @@ -75,53 +67,46 @@ export function AccountPage() { async function saveNickname(event: FormEvent) { event.preventDefault() - if (isSavingNickname) return - const normalizedNickname = nickname.trim() + if (profile.isSaving) return + const normalizedNickname = profile.nickname.trim() if (!normalizedNickname) { - setProfileSuccess(null) - setProfileError('昵称不能为空') + dispatchProfile({ type: 'validationFailed', error: '昵称不能为空' }) return } if (normalizedNickname.length > MAX_NICKNAME_LENGTH) { - setProfileSuccess(null) - setProfileError('昵称不能超过 50 个字符') + dispatchProfile({ type: 'validationFailed', error: '昵称不能超过 50 个字符' }) return } - setProfileError(null) - setProfileSuccess(null) - setIsSavingNickname(true) + dispatchProfile({ type: 'saveStarted' }) try { const user = await updateNickname(normalizedNickname) - setNickname(user.nickname ?? '') - setIsProfileFresh(true) - setProfileSuccess('昵称已更新。') + dispatchProfile({ type: 'saveSucceeded', nickname: user.nickname ?? '' }) } catch (error) { - setProfileError(errorMessage(error)) - } finally { - setIsSavingNickname(false) + dispatchProfile({ type: 'saveFailed', error: errorMessage(error) }) } } async function changePassword(event: FormEvent) { event.preventDefault() - if (isChangingPassword) return - if (!oldPassword) { - setPasswordError('请输入当前密码') + if (security.isChanging) return + if (!security.oldPassword) { + dispatchSecurity({ type: 'validationFailed', error: '请输入当前密码' }) return } - if (newPassword.length < 8 || newPassword.length > 128) { - setPasswordError('新密码需为 8–128 位') + if (security.newPassword.length < 8 || security.newPassword.length > 128) { + dispatchSecurity({ type: 'validationFailed', error: '新密码需为 8–128 位' }) return } - setPasswordError(null) - setIsChangingPassword(true) + dispatchSecurity({ type: 'changeStarted' }) try { - await changeSessionPassword({ oldPassword, newPassword }) + await changeSessionPassword({ + oldPassword: security.oldPassword, + newPassword: security.newPassword, + }) } catch (error) { - setPasswordError(errorMessage(error)) - setIsChangingPassword(false) + dispatchSecurity({ type: 'changeFailed', error: errorMessage(error) }) } } @@ -131,11 +116,8 @@ export function AccountPage() { function selectSection(section: 'profile' | 'security') { setActiveSection(section) - setProfileError(null) - setProfileSuccess(null) - setPasswordError(null) - setOldPassword('') - setNewPassword('') + dispatchProfile({ type: 'sectionChanged' }) + dispatchSecurity({ type: 'sectionChanged' }) } if (!currentUser) return null @@ -254,10 +236,12 @@ export function AccountPage() { id={nicknameId} type="text" autoComplete="nickname" - value={nickname} + value={profile.nickname} maxLength={MAX_NICKNAME_LENGTH + 1} - disabled={isProfileLoading || isSavingNickname} - onChange={(event) => setNickname(event.target.value)} + disabled={profile.isLoading || profile.isSaving} + onChange={(event) => + dispatchProfile({ type: 'nicknameChanged', nickname: event.target.value }) + } className="account-field" aria-describedby={`${nicknameId}-hint`} /> @@ -279,37 +263,37 @@ export function AccountPage() { - {profileError && ( + {profile.error && (

- {profileError} + {profile.error}

)} - {profileSuccess && ( + {profile.success && (

- {profileSuccess} + {profile.success}

)}
- {isProfileLoading + {profile.isLoading ? '正在同步最新资料…' - : isProfileFresh + : profile.isFresh ? '资料已同步' : '资料同步失败'}
@@ -335,9 +319,14 @@ export function AccountPage() { id={oldPasswordId} type="password" autoComplete="current-password" - value={oldPassword} - disabled={isChangingPassword} - onChange={(event) => setOldPassword(event.target.value)} + value={security.oldPassword} + disabled={security.isChanging} + onChange={(event) => + dispatchSecurity({ + type: 'oldPasswordChanged', + password: event.target.value, + }) + } className="account-field" /> @@ -347,9 +336,14 @@ export function AccountPage() { id={newPasswordId} type="password" autoComplete="new-password" - value={newPassword} - disabled={isChangingPassword} - onChange={(event) => setNewPassword(event.target.value)} + value={security.newPassword} + disabled={security.isChanging} + onChange={(event) => + dispatchSecurity({ + type: 'newPasswordChanged', + password: event.target.value, + }) + } className="account-field" aria-describedby={`${newPasswordId}-hint`} /> @@ -360,20 +354,20 @@ export function AccountPage() { 8–128 位 - {passwordError && ( + {security.error && (

- {passwordError} + {security.error}

)} diff --git a/frontend/src/pages/account/state.test.ts b/frontend/src/pages/account/state.test.ts new file mode 100644 index 00000000..7ea6858f --- /dev/null +++ b/frontend/src/pages/account/state.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' + +import { createProfileState, initialSecurityState, profileReducer, securityReducer } from './state' + +describe('account profile state', () => { + it('tracks refresh and save transitions within the profile domain', () => { + const refreshed = profileReducer(createProfileState('Cached Reader'), { + type: 'refreshSucceeded', + nickname: 'Fresh Reader', + }) + const saving = profileReducer(refreshed, { type: 'saveStarted' }) + const saved = profileReducer(saving, { type: 'saveSucceeded', nickname: 'New Reader' }) + + expect(refreshed).toMatchObject({ + nickname: 'Fresh Reader', + isLoading: false, + isFresh: true, + error: null, + }) + expect(saving).toMatchObject({ isSaving: true, error: null, success: null }) + expect(saved).toMatchObject({ + nickname: 'New Reader', + isFresh: true, + isSaving: false, + success: '昵称已更新。', + }) + }) + + it('preserves edited profile data while clearing transient section feedback', () => { + const failed = profileReducer( + { ...createProfileState('Taken Name'), isLoading: false, success: '旧提示' }, + { type: 'saveFailed', error: '昵称已存在' }, + ) + + expect(profileReducer(failed, { type: 'sectionChanged' })).toEqual({ + ...failed, + error: null, + success: null, + }) + }) +}) + +describe('account security state', () => { + it('preserves password fields when a password change fails', () => { + const withOldPassword = securityReducer(initialSecurityState, { + type: 'oldPasswordChanged', + password: 'old-password', + }) + const withPasswords = securityReducer(withOldPassword, { + type: 'newPasswordChanged', + password: 'new-password-123', + }) + const changing = securityReducer(withPasswords, { type: 'changeStarted' }) + const failed = securityReducer(changing, { type: 'changeFailed', error: '当前密码错误' }) + + expect(failed).toEqual({ + oldPassword: 'old-password', + newPassword: 'new-password-123', + isChanging: false, + error: '当前密码错误', + }) + }) + + it('clears sensitive fields and feedback when the active section changes', () => { + const populated = { + oldPassword: 'old-password', + newPassword: 'new-password-123', + isChanging: true, + error: '旧错误', + } + + expect(securityReducer(populated, { type: 'sectionChanged' })).toEqual({ + ...initialSecurityState, + isChanging: true, + }) + }) +}) diff --git a/frontend/src/pages/account/state.ts b/frontend/src/pages/account/state.ts new file mode 100644 index 00000000..8aab68fc --- /dev/null +++ b/frontend/src/pages/account/state.ts @@ -0,0 +1,101 @@ +export type ProfileState = { + nickname: string + isLoading: boolean + isFresh: boolean + isSaving: boolean + error: string | null + success: string | null +} + +export type ProfileAction = + | { type: 'nicknameChanged'; nickname: string } + | { type: 'refreshSucceeded'; nickname: string } + | { type: 'refreshFailed'; error: string } + | { type: 'validationFailed'; error: string } + | { type: 'saveStarted' } + | { type: 'saveSucceeded'; nickname: string } + | { type: 'saveFailed'; error: string } + | { type: 'sectionChanged' } + +export function createProfileState(nickname: string): ProfileState { + return { + nickname, + isLoading: true, + isFresh: false, + isSaving: false, + error: null, + success: null, + } +} + +export function profileReducer(state: ProfileState, action: ProfileAction): ProfileState { + switch (action.type) { + case 'nicknameChanged': + return { ...state, nickname: action.nickname } + case 'refreshSucceeded': + return { + ...state, + nickname: action.nickname, + isLoading: false, + isFresh: true, + error: null, + } + case 'refreshFailed': + return { ...state, isLoading: false, isFresh: false, error: action.error } + case 'validationFailed': + return { ...state, error: action.error, success: null } + case 'saveStarted': + return { ...state, isSaving: true, error: null, success: null } + case 'saveSucceeded': + return { + ...state, + nickname: action.nickname, + isFresh: true, + isSaving: false, + success: '昵称已更新。', + } + case 'saveFailed': + return { ...state, isSaving: false, error: action.error } + case 'sectionChanged': + return { ...state, error: null, success: null } + } +} + +export type SecurityState = { + oldPassword: string + newPassword: string + isChanging: boolean + error: string | null +} + +export type SecurityAction = + | { type: 'oldPasswordChanged'; password: string } + | { type: 'newPasswordChanged'; password: string } + | { type: 'validationFailed'; error: string } + | { type: 'changeStarted' } + | { type: 'changeFailed'; error: string } + | { type: 'sectionChanged' } + +export const initialSecurityState: SecurityState = { + oldPassword: '', + newPassword: '', + isChanging: false, + error: null, +} + +export function securityReducer(state: SecurityState, action: SecurityAction): SecurityState { + switch (action.type) { + case 'oldPasswordChanged': + return { ...state, oldPassword: action.password } + case 'newPasswordChanged': + return { ...state, newPassword: action.password } + case 'validationFailed': + return { ...state, error: action.error } + case 'changeStarted': + return { ...state, isChanging: true, error: null } + case 'changeFailed': + return { ...state, isChanging: false, error: action.error } + case 'sectionChanged': + return { ...initialSecurityState, isChanging: state.isChanging } + } +}