diff --git a/docs/history.md b/docs/history.md index 717d8c6..b54a8aa 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-04-02 v1.3.51 +- 관리자 리팩터링은 본문 분리 다음 단계에서 `회원 관리`처럼 모달과 부수 액션이 많은 영역을 composable로 떼어내는 편이 효과가 크다고 판단했다. +- 이 단계에서는 UI 문구나 사용자가 이미 손본 CSS를 다시 건드리기보다, 현재 동작을 유지한 채 책임 경계만 옮기는 쪽이 더 안전하다고 정리했다. + ## 2026-04-02 v1.3.50 - 템플릿 요청 카드는 게임 이름/ID만 남기기보다 대표 썸네일까지 유지하는 편이 운영자가 요청 대상을 훨씬 빨리 구분할 수 있다고 정리했다. - 템플릿 요청의 `확인하기`는 단순히 해당 게임을 선택하는 동작이 아니라, 게임 관리 화면에서 요청 아이템 후보가 실제 작업 상태로 복원되어야 한다고 판단했다. diff --git a/docs/todo.md b/docs/todo.md index ae017c3..fde5e43 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -2,8 +2,8 @@ ## 중기 개선 - 관리자 URL 분리는 시작했으므로, 다음 단계에서는 `AdminView.vue` 단일 대형 파일을 섹션별 뷰/컴포저블로 쪼개고 직접 진입 시 선택 게임/요청 작업 상태 복원 범위도 함께 정리한다. -- 관리자 본문 컴포넌트 분리는 시작했으므로, 다음 단계에서는 `AdminView.vue`에 남아 있는 상태/액션도 `useAdmin*` composable 단위로 분리해 실제 로직 결합도를 줄인다. -- 관리자 게임/템플릿 요청 composable 분리는 시작했으므로, 다음 단계에서는 회원/아이템/목록 관리도 같은 기준으로 정리하고 공통 모달 상태를 어느 계층에서 소유할지 정리한다. +- 관리자 본문 컴포넌트 분리와 `게임/템플릿 요청/회원 관리` composable 분리는 시작했으므로, 다음 단계에서는 `아이템 관리`와 `목록 관리`도 같은 기준으로 옮기고 공통 모달 상태를 어느 계층에서 소유할지 정리한다. +- 관리자 화면은 섹션 경로 분리까지 끝났으므로, 다음 단계에서는 `AdminView.vue`를 실제 레이아웃 뷰와 섹션별 라우트 컴포넌트로 더 쪼갤지 결정한다. - 관리자 게임 아이템 순서 저장은 추가됐으므로, 다음 단계에서는 새 아이템 추가 직후 `자동 맨 앞 배치`와 `관리자 수동 고정 순서`의 우선순위를 실제 운영 흐름 기준으로 한 번 더 QA한다. - 관리자 템플릿 요청 미리보기는 실제 완성본 iframe 방식과의 체감 차이를 마지막으로 한 번 더 QA한다. - 라이트모드/다크모드 2차 보정까지 반영했으므로, 남은 작업은 전체 화면을 실제 사용 흐름으로 돌려 보며 대비·명도·아이콘 가독성을 미세하게 QA하는 최종 테마 점검 단계로 가져간다. diff --git a/docs/update.md b/docs/update.md index f5dd007..11d8258 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-04-02 v1.3.51 +- 관리자 리팩터링 3차로 회원 관리 액션을 `useAdminUsers` composable로 분리해, 아바타 변경, 회원 정보 수정, 비밀번호 초기화, 권한 변경, 삭제 모달 흐름을 `AdminView.vue` 밖으로 옮김. +- 따라서 관리자 메인 뷰는 섹션 연결과 공통 상태에 더 집중하고, 회원 관리 로직은 다른 관리자 영역과 같은 composable 분리 기준으로 맞추기 시작함. +- 이번 정리에서도 관리자 화면에 직접 반영돼 있던 텍스트와 게임 관리 CSS 수정분은 유지한 채 구조만 옮기도록 정리함. + ## 2026-04-02 v1.3.50 - 관리자 `템플릿 요청 관리` 카드에서는 대표 썸네일을 다시 복구해 게임 이름/ID와 함께 요청 대상을 더 빠르게 식별할 수 있게 정리함. - `확인하기` 후 게임을 불러오면서 요청 아이템 임시 목록이 비워지던 흐름을 수정하고, 신규 게임 생성 직후에도 요청 아이템이 기본 아이템 추가 미리보기에 유지되도록 보강함. diff --git a/frontend/src/composables/useAdminUsers.js b/frontend/src/composables/useAdminUsers.js new file mode 100644 index 0000000..b25c8a0 --- /dev/null +++ b/frontend/src/composables/useAdminUsers.js @@ -0,0 +1,265 @@ +import { computed } from 'vue' + +export function useAdminUsers({ + api, + auth, + users, + userQuery, + userSort, + userSortDirection, + userAvatarInputs, + modalTargetUser, + modalPasswordDraft, + modalRoleNextAdmin, + modalUserDraftEmail, + modalUserDraftNickname, + modalUserDraftIsAdmin, + userEditModalOpen, + userPasswordModalOpen, + userDeleteModalOpen, + userRoleModalOpen, + resetMessages, + refreshUsers, + success, + error, +}) { + function setUserAvatarInput(userId, el) { + if (!userId) return + if (!el) { + delete userAvatarInputs.value[userId] + return + } + userAvatarInputs.value[userId] = el + } + + const canManageModalRole = computed(() => { + if (!auth.user?.isPrimaryAdmin) return false + if (!modalTargetUser.value) return false + return !modalTargetUser.value.isPrimaryAdmin + }) + + const isUserEditDirty = computed(() => { + if (!modalTargetUser.value) return false + return ( + modalUserDraftEmail.value.trim() !== (modalTargetUser.value.email || '') || + modalUserDraftNickname.value.trim() !== (modalTargetUser.value.nickname || '') || + !!modalUserDraftIsAdmin.value !== !!modalTargetUser.value.isAdmin + ) + }) + + function roleLabelOf(user) { + if (user?.isPrimaryAdmin) return '최고 관리자' + if (user?.isAdmin) return '운영자' + return '일반 회원' + } + + function openUserAvatarPicker(user) { + userAvatarInputs.value[user?.id]?.click() + } + + async function uploadUserAvatar(user, file, { remove = false } = {}) { + resetMessages() + if (!user?.id) return + + try { + user.isAvatarBusy = true + const data = await api.updateAdminUserAvatar(user.id, { file, removeAvatar: remove }) + const updated = data.user + users.value = users.value.map((entry) => + entry.id === updated.id + ? { + ...entry, + ...updated, + isAvatarBusy: false, + } + : entry + ) + if (modalTargetUser.value?.id === updated.id) { + modalTargetUser.value = { ...modalTargetUser.value, ...updated } + } + if (updated.id === auth.user?.id) await auth.refresh() + await refreshUsers() + success.value = remove ? '회원 썸네일을 삭제했어요.' : '회원 썸네일을 업데이트했어요.' + } catch (e) { + error.value = remove ? '회원 썸네일 삭제에 실패했어요.' : '회원 썸네일 변경에 실패했어요.' + } finally { + const target = users.value.find((entry) => entry.id === user.id) + if (target) target.isAvatarBusy = false + } + } + + async function onUserAvatarChange(user, event) { + const file = event.target.files && event.target.files[0] ? event.target.files[0] : null + event.target.value = '' + if (!file) return + await uploadUserAvatar(user, file) + } + + async function removeUserAvatar(user) { + if (!user?.avatarSrc) return + await uploadUserAvatar(user, null, { remove: true }) + } + + function openUserEditModal(user) { + resetMessages() + modalTargetUser.value = user ? { ...user } : null + modalUserDraftEmail.value = user?.email || '' + modalUserDraftNickname.value = user?.nickname || '' + modalUserDraftIsAdmin.value = !!user?.isAdmin + userEditModalOpen.value = true + } + + function closeUserEditModal() { + userEditModalOpen.value = false + modalTargetUser.value = null + modalUserDraftEmail.value = '' + modalUserDraftNickname.value = '' + modalUserDraftIsAdmin.value = false + } + + async function saveUserEdit() { + resetMessages() + if (!modalTargetUser.value?.id) return + + try { + const data = await api.updateAdminUser(modalTargetUser.value.id, { + email: modalUserDraftEmail.value.trim(), + nickname: modalUserDraftNickname.value.trim(), + isAdmin: !!modalUserDraftIsAdmin.value, + }) + const updated = data.user + users.value = users.value.map((entry) => + entry.id === updated.id + ? { + ...entry, + ...updated, + isAvatarBusy: entry.isAvatarBusy || false, + } + : entry + ) + if (updated.id === auth.user?.id) await auth.refresh() + closeUserEditModal() + await refreshUsers() + success.value = '회원 정보를 저장했어요.' + } catch (e) { + error.value = '회원 정보 저장에 실패했어요.' + } + } + + function openUserPasswordModal(user) { + resetMessages() + modalTargetUser.value = user ? { ...user } : null + modalPasswordDraft.value = '' + userPasswordModalOpen.value = true + } + + function closeUserPasswordModal() { + userPasswordModalOpen.value = false + modalTargetUser.value = null + modalPasswordDraft.value = '' + } + + function userDisplayName(user) { + return user?.nickname || user?.email?.split('@')[0] || '알 수 없음' + } + + async function confirmUserPasswordReset() { + resetMessages() + if (!modalTargetUser.value?.id) return + + const password = modalPasswordDraft.value.trim() + if (!password) { + error.value = '초기화할 비밀번호를 입력해주세요.' + return + } + + try { + await api.updateAdminUserPassword(modalTargetUser.value.id, { password }) + success.value = `${userDisplayName(modalTargetUser.value)} 계정 비밀번호를 초기화했어요.` + closeUserPasswordModal() + } catch (e) { + error.value = '비밀번호 초기화에 실패했어요.' + } + } + + function openUserDeleteModal(user) { + resetMessages() + modalTargetUser.value = user ? { ...user } : null + userDeleteModalOpen.value = true + } + + function closeUserDeleteModal() { + userDeleteModalOpen.value = false + modalTargetUser.value = null + } + + async function confirmUserDelete() { + resetMessages() + if (!modalTargetUser.value?.id) return + + try { + const deletingSelf = modalTargetUser.value.id === auth.user?.id + const deletedName = userDisplayName(modalTargetUser.value) + await api.deleteAdminUser(modalTargetUser.value.id) + users.value = users.value.filter((entry) => entry.id !== modalTargetUser.value.id) + closeUserDeleteModal() + success.value = `${deletedName} 계정을 삭제했어요.` + if (deletingSelf) await auth.refresh() + } catch (e) { + error.value = '회원 삭제에 실패했어요.' + } + } + + function openUserRoleModal(user, nextIsAdmin = !modalUserDraftIsAdmin.value) { + resetMessages() + modalTargetUser.value = user ? { ...user } : null + modalRoleNextAdmin.value = !!nextIsAdmin + userRoleModalOpen.value = true + } + + function closeUserRoleModal() { + userRoleModalOpen.value = false + if (!userEditModalOpen.value) modalTargetUser.value = null + modalRoleNextAdmin.value = false + } + + function confirmUserRoleDraft() { + if (!modalTargetUser.value?.id) return + modalUserDraftIsAdmin.value = modalRoleNextAdmin.value + const targetLabel = modalRoleNextAdmin.value ? '운영자 권한을 저장 대기 상태로 반영했어요.' : '운영자 권한 해제를 저장 대기 상태로 반영했어요.' + closeUserRoleModal() + success.value = targetLabel + } + + function submitUserFilters() { + refreshUsers({ + q: userQuery.value, + sort: userSort.value, + direction: userSortDirection.value, + }) + } + + return { + setUserAvatarInput, + canManageModalRole, + isUserEditDirty, + roleLabelOf, + openUserAvatarPicker, + onUserAvatarChange, + removeUserAvatar, + openUserEditModal, + closeUserEditModal, + saveUserEdit, + openUserPasswordModal, + closeUserPasswordModal, + confirmUserPasswordReset, + openUserDeleteModal, + closeUserDeleteModal, + confirmUserDelete, + openUserRoleModal, + closeUserRoleModal, + confirmUserRoleDraft, + submitUserFilters, + userDisplayName, + } +} diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 298ec47..07648f0 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -14,6 +14,7 @@ import AdminTierlistsSection from '../components/admin/AdminTierlistsSection.vue import AdminUsersSection from '../components/admin/AdminUsersSection.vue' import { useAdminGameManager } from '../composables/useAdminGameManager' import { useAdminTemplateRequests } from '../composables/useAdminTemplateRequests' +import { useAdminUsers } from '../composables/useAdminUsers' import { useAuthStore } from '../stores/auth' import { useToast } from '../composables/useToast' @@ -646,83 +647,6 @@ async function refreshGames() { } } -function setUserAvatarInput(userId, el) { - if (!userId) return - if (!el) { - delete userAvatarInputs.value[userId] - return - } - userAvatarInputs.value[userId] = el -} - -const canManageModalRole = computed(() => { - if (!auth.user?.isPrimaryAdmin) return false - if (!modalTargetUser.value) return false - return !modalTargetUser.value.isPrimaryAdmin -}) - -const isUserEditDirty = computed(() => { - if (!modalTargetUser.value) return false - return ( - modalUserDraftEmail.value.trim() !== (modalTargetUser.value.email || '') || - modalUserDraftNickname.value.trim() !== (modalTargetUser.value.nickname || '') || - !!modalUserDraftIsAdmin.value !== !!modalTargetUser.value.isAdmin - ) -}) - -function roleLabelOf(user) { - if (user?.isPrimaryAdmin) return '최고 관리자' - if (user?.isAdmin) return '운영자' - return '일반 회원' -} - -function openUserAvatarPicker(user) { - userAvatarInputs.value[user?.id]?.click() -} - -async function uploadUserAvatar(user, file, { remove = false } = {}) { - resetMessages() - if (!user?.id) return - - try { - user.isAvatarBusy = true - const data = await api.updateAdminUserAvatar(user.id, { file, removeAvatar: remove }) - const updated = data.user - users.value = users.value.map((entry) => - entry.id === updated.id - ? { - ...entry, - ...updated, - isAvatarBusy: false, - } - : entry - ) - if (modalTargetUser.value?.id === updated.id) { - modalTargetUser.value = { ...modalTargetUser.value, ...updated } - } - if (updated.id === auth.user?.id) await auth.refresh() - await refreshUsers() - success.value = remove ? '회원 썸네일을 삭제했어요.' : '회원 썸네일을 업데이트했어요.' - } catch (e) { - error.value = remove ? '회원 썸네일 삭제에 실패했어요.' : '회원 썸네일 변경에 실패했어요.' - } finally { - const target = users.value.find((entry) => entry.id === user.id) - if (target) target.isAvatarBusy = false - } -} - -async function onUserAvatarChange(user, event) { - const file = event.target.files && event.target.files[0] ? event.target.files[0] : null - event.target.value = '' - if (!file) return - await uploadUserAvatar(user, file) -} - -async function removeUserAvatar(user) { - if (!user?.avatarSrc) return - await uploadUserAvatar(user, null, { remove: true }) -} - function destroyFeaturedSortable() { if (featuredSortable.value) { featuredSortable.value.destroy() @@ -892,6 +816,52 @@ const { error, }) +const { + setUserAvatarInput, + canManageModalRole, + isUserEditDirty, + roleLabelOf, + openUserAvatarPicker, + onUserAvatarChange, + removeUserAvatar, + openUserEditModal, + closeUserEditModal, + saveUserEdit, + openUserPasswordModal, + closeUserPasswordModal, + confirmUserPasswordReset, + openUserDeleteModal, + closeUserDeleteModal, + confirmUserDelete, + openUserRoleModal, + closeUserRoleModal, + confirmUserRoleDraft, + submitUserFilters, + userDisplayName, +} = useAdminUsers({ + api, + auth, + users, + userQuery, + userSort, + userSortDirection, + userAvatarInputs, + modalTargetUser, + modalPasswordDraft, + modalRoleNextAdmin, + modalUserDraftEmail, + modalUserDraftNickname, + modalUserDraftIsAdmin, + userEditModalOpen, + userPasswordModalOpen, + userDeleteModalOpen, + userRoleModalOpen, + resetMessages, + refreshUsers, + success, + error, +}) + function handleThumbFile(file) { const nextFile = file && (file.type || '').startsWith('image/') ? file : null @@ -1043,138 +1013,6 @@ async function removeGame() { } } -function openUserEditModal(user) { - resetMessages() - modalTargetUser.value = user ? { ...user } : null - modalUserDraftEmail.value = user?.email || '' - modalUserDraftNickname.value = user?.nickname || '' - modalUserDraftIsAdmin.value = !!user?.isAdmin - userEditModalOpen.value = true -} - -function closeUserEditModal() { - userEditModalOpen.value = false - modalTargetUser.value = null - modalUserDraftEmail.value = '' - modalUserDraftNickname.value = '' - modalUserDraftIsAdmin.value = false -} - -async function saveUserEdit() { - resetMessages() - if (!modalTargetUser.value?.id) return - - try { - const data = await api.updateAdminUser(modalTargetUser.value.id, { - email: modalUserDraftEmail.value.trim(), - nickname: modalUserDraftNickname.value.trim(), - isAdmin: !!modalUserDraftIsAdmin.value, - }) - const updated = data.user - users.value = users.value.map((entry) => - entry.id === updated.id - ? { - ...entry, - ...updated, - isAvatarBusy: entry.isAvatarBusy || false, - } - : entry - ) - if (updated.id === auth.user?.id) await auth.refresh() - closeUserEditModal() - await refreshUsers() - success.value = '회원 정보를 저장했어요.' - } catch (e) { - error.value = '회원 정보 저장에 실패했어요.' - } -} - -function openUserPasswordModal(user) { - resetMessages() - modalTargetUser.value = user ? { ...user } : null - modalPasswordDraft.value = '' - userPasswordModalOpen.value = true -} - -function closeUserPasswordModal() { - userPasswordModalOpen.value = false - modalTargetUser.value = null - modalPasswordDraft.value = '' -} - -async function confirmUserPasswordReset() { - resetMessages() - if (!modalTargetUser.value?.id) return - - const password = modalPasswordDraft.value.trim() - if (!password) { - error.value = '초기화할 비밀번호를 입력해주세요.' - return - } - - try { - await api.updateAdminUserPassword(modalTargetUser.value.id, { password }) - success.value = `${userDisplayName(modalTargetUser.value)} 계정 비밀번호를 초기화했어요.` - closeUserPasswordModal() - } catch (e) { - error.value = '비밀번호 초기화에 실패했어요.' - } -} - -function openUserDeleteModal(user) { - resetMessages() - modalTargetUser.value = user ? { ...user } : null - userDeleteModalOpen.value = true -} - -function closeUserDeleteModal() { - userDeleteModalOpen.value = false - modalTargetUser.value = null -} - -async function confirmUserDelete() { - resetMessages() - if (!modalTargetUser.value?.id) return - - try { - const deletingSelf = modalTargetUser.value.id === auth.user?.id - const deletedName = userDisplayName(modalTargetUser.value) - await api.deleteAdminUser(modalTargetUser.value.id) - users.value = users.value.filter((entry) => entry.id !== modalTargetUser.value.id) - closeUserDeleteModal() - success.value = `${deletedName} 계정을 삭제했어요.` - if (deletingSelf) await auth.refresh() - } catch (e) { - error.value = '회원 삭제에 실패했어요.' - } -} - - -function openUserRoleModal(user, nextIsAdmin = !modalUserDraftIsAdmin.value) { - resetMessages() - modalTargetUser.value = user ? { ...user } : null - modalRoleNextAdmin.value = !!nextIsAdmin - userRoleModalOpen.value = true -} - -function closeUserRoleModal() { - userRoleModalOpen.value = false - if (!userEditModalOpen.value) modalTargetUser.value = null - modalRoleNextAdmin.value = false -} - -function confirmUserRoleDraft() { - if (!modalTargetUser.value?.id) return - modalUserDraftIsAdmin.value = modalRoleNextAdmin.value - const targetLabel = modalRoleNextAdmin.value ? '운영자 권한을 저장 대기 상태로 반영했어요.' : '운영자 권한 해제를 저장 대기 상태로 반영했어요.' - closeUserRoleModal() - success.value = targetLabel -} - -function submitUserFilters() { - refreshUsers() -} - function submitCustomItemSearch() { customItemPage.value = 1 refreshCustomItems() @@ -1572,10 +1410,6 @@ function userAvatarUrl(user) { return user?.avatarSrc ? toApiUrl(user.avatarSrc) : '' } -function userDisplayName(user) { - return user?.nickname || user?.email?.split('@')[0] || '알 수 없음' -} - function userAvatarFallback(user) { return (user?.email?.trim()?.[0] || '?').toUpperCase() }