From 7f017a03a561348a2834eac66b759ca4a0bba697 Mon Sep 17 00:00:00 2001 From: zenn Date: Wed, 27 May 2026 11:00:05 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A9=A4=EB=B2=84=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=AA=A8=EB=93=9C=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?v1.5.11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/admin/AdminMemberForm.vue | 136 +++++++++++++++++++-------- docs/changelog.md | 7 ++ docs/history.md | 4 + docs/map.md | 6 +- docs/spec.md | 4 +- docs/update.md | 7 ++ package-lock.json | 4 +- package.json | 2 +- pages/admin/members/index.vue | 11 ++- 9 files changed, 131 insertions(+), 50 deletions(-) diff --git a/components/admin/AdminMemberForm.vue b/components/admin/AdminMemberForm.vue index dbe6ed3..c4f52bc 100644 --- a/components/admin/AdminMemberForm.vue +++ b/components/admin/AdminMemberForm.vue @@ -12,6 +12,8 @@ const props = defineProps({ const emit = defineEmits(['saved', 'deleted']) +const { toast, showToast } = useAdminToast() + const { data: adminSession } = await useFetch('/admin/api/auth/me', { default: () => ({ userId: '', @@ -20,18 +22,16 @@ const { data: adminSession } = await useFetch('/admin/api/auth/me', { }) const isNewMember = computed(() => props.mode === 'new') -const saveMessage = ref('') -const saveError = ref('') const isSaving = ref(false) const savedMemberSnapshot = ref('') const avatarInputRef = ref(null) const isUploadingAvatar = ref(false) +const isEditingMember = ref(props.mode === 'new') const actionMenuOpen = ref(false) const passwordModalOpen = ref(false) const deleteModalOpen = ref(false) const isUpdatingPassword = ref(false) const isDeletingMember = ref(false) -const actionMessage = ref('') const actionError = ref('') const roleOptions = [ @@ -93,15 +93,16 @@ const normalizedLabels = computed(() => [...new Set( .filter(Boolean) )]) +const isFormEditable = computed(() => isNewMember.value || isEditingMember.value) const currentRoleLabel = computed(() => roleOptions.find((option) => option.value === form.roleCode)?.label || '멤버') const currentAdminRoleCode = computed(() => adminSession.value?.roleCode || '') const isCurrentAdminPrivileged = computed(() => ['owner', 'admin'].includes(currentAdminRoleCode.value)) const isEditingSelf = computed(() => Boolean(props.member?.id && adminSession.value?.userId) && String(props.member.id) === String(adminSession.value.userId)) const isTargetPrivilegedRole = computed(() => ['owner', 'admin'].includes(props.member?.roleCode || form.roleCode)) -const shouldRenderRoleAsText = computed(() => isNewMember.value || !isCurrentAdminPrivileged.value) +const shouldRenderRoleAsText = computed(() => !isFormEditable.value || isNewMember.value || !isCurrentAdminPrivileged.value) const canEditRoleSelect = computed(() => { - if (shouldRenderRoleAsText.value || isSaving.value) { + if (!isFormEditable.value || shouldRenderRoleAsText.value || isSaving.value) { return false } @@ -127,6 +128,10 @@ const availableRoleOptions = computed(() => { return roleOptions }) const roleHelpText = computed(() => { + if (!isFormEditable.value) { + return '수정하기를 누르면 변경 가능한 항목을 편집할 수 있습니다.' + } + if (shouldRenderRoleAsText.value) { return '멤버와 VIP는 관리자 권한이 없어 등급을 변경할 수 없습니다.' } @@ -229,18 +234,32 @@ const getMemberPayload = () => ({ }) const hasUnsavedMemberChanges = computed(() => serializeMemberPayload() !== savedMemberSnapshot.value) +const shouldBlockUnsavedMemberChanges = computed(() => isFormEditable.value && hasUnsavedMemberChanges.value) +const canSubmitMemberForm = computed(() => isFormEditable.value && hasUnsavedMemberChanges.value && !isSaving.value) const { isUnsavedModalOpen, stayOnUnsavedPage, leaveUnsavedPage -} = useAdminUnsavedChangesGuard(hasUnsavedMemberChanges) +} = useAdminUnsavedChangesGuard(shouldBlockUnsavedMemberChanges) + +/** + * 회원 상세를 수정 모드로 전환한다. + * @returns {void} + */ +const enterEditMode = () => { + isEditingMember.value = true +} /** * 썸네일 파일 선택창을 연다. * @returns {void} */ const openAvatarFilePicker = () => { + if (!isFormEditable.value) { + return + } + avatarInputRef.value?.click() } @@ -257,9 +276,11 @@ const uploadAvatar = async (event) => { return } + if (!isFormEditable.value) { + return + } + isUploadingAvatar.value = true - saveError.value = '' - saveMessage.value = '' try { const formData = new FormData() @@ -278,10 +299,10 @@ const uploadAvatar = async (event) => { if (!isNewMember.value) { emit('saved', result) savedMemberSnapshot.value = serializeMemberPayload() - saveMessage.value = '썸네일이 변경되었습니다.' + showToast('success', '썸네일이 변경되었습니다.') } } catch (error) { - saveError.value = error?.data?.message || '썸네일 업로드에 실패했습니다.' + showToast('error', error?.data?.message || '썸네일 업로드에 실패했습니다.') } finally { isUploadingAvatar.value = false if (target) { @@ -295,6 +316,10 @@ const uploadAvatar = async (event) => { * @returns {void} */ const removeAvatar = () => { + if (!isFormEditable.value) { + return + } + form.avatarUrl = '' } @@ -321,7 +346,6 @@ const closeActionMenu = () => { const openPasswordModal = () => { passwordForm.password = '' passwordForm.passwordConfirm = '' - actionMessage.value = '' actionError.value = '' passwordModalOpen.value = true closeActionMenu() @@ -333,7 +357,6 @@ const openPasswordModal = () => { */ const openDeleteModal = () => { deleteForm.confirmText = '' - actionMessage.value = '' actionError.value = '' deleteModalOpen.value = true closeActionMenu() @@ -368,7 +391,6 @@ const closeDeleteModal = () => { * @returns {Promise} */ const updateMemberPassword = async () => { - actionMessage.value = '' actionError.value = '' if (!passwordForm.password || passwordForm.password.length < 8 || passwordForm.password.length > 32) { @@ -392,9 +414,11 @@ const updateMemberPassword = async () => { passwordModalOpen.value = false passwordForm.password = '' passwordForm.passwordConfirm = '' - actionMessage.value = '비밀번호가 변경되었습니다.' + showToast('success', '비밀번호가 변경되었습니다.') } catch (error) { - actionError.value = error?.data?.message || '비밀번호 변경에 실패했습니다.' + const message = error?.data?.message || '비밀번호 변경에 실패했습니다.' + actionError.value = message + showToast('error', message) } finally { isUpdatingPassword.value = false } @@ -405,7 +429,6 @@ const updateMemberPassword = async () => { * @returns {Promise} */ const deleteMember = async () => { - actionMessage.value = '' actionError.value = '' if (deleteForm.confirmText !== form.email) { @@ -420,7 +443,9 @@ const deleteMember = async () => { }) emit('deleted') } catch (error) { - actionError.value = error?.data?.message || '회원 삭제에 실패했습니다.' + const message = error?.data?.message || '회원 삭제에 실패했습니다.' + actionError.value = message + showToast('error', message) } finally { isDeletingMember.value = false } @@ -435,8 +460,15 @@ const saveMember = async () => { return } - saveMessage.value = '' - saveError.value = '' + if (!isFormEditable.value) { + enterEditMode() + return + } + + if (!hasUnsavedMemberChanges.value) { + return + } + isSaving.value = true try { @@ -462,9 +494,10 @@ const saveMember = async () => { savedMemberSnapshot.value = serializeMemberPayload() emit('saved', saved) - saveMessage.value = '저장되었습니다.' + isEditingMember.value = isNewMember.value + showToast('success', '저장되었습니다.') } catch (error) { - saveError.value = error?.data?.message || '저장에 실패했습니다.' + showToast('error', error?.data?.message || '저장에 실패했습니다.') } finally { isSaving.value = false } @@ -516,8 +549,13 @@ watch(() => props.member, () => { - @@ -531,6 +569,8 @@ watch(() => props.member, () => { class="admin-member-form__avatar-button relative h-20 w-20 overflow-hidden rounded-full bg-[#15171a] text-white" type="button" :aria-label="form.avatarUrl ? '썸네일 변경' : '썸네일 등록'" + :disabled="!isFormEditable" + :class="{ 'cursor-default': !isFormEditable }" @click="openAvatarFilePicker" > props.member, () => { {{ memberInitial }} - {{ isUploadingAvatar ? '업로드 중' : form.avatarUrl ? '썸네일 변경' : '썸네일 등록' }} + {{ !isFormEditable ? '현재 썸네일' : isUploadingAvatar ? '업로드 중' : form.avatarUrl ? '썸네일 변경' : '썸네일 등록' }}