멤버 상세 수정 모드 정리 v1.5.11
This commit is contained in:
@@ -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<void>}
|
||||
*/
|
||||
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<void>}
|
||||
*/
|
||||
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, () => {
|
||||
<circle cx="12" cy="12.001" r="4.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="admin-member-form__save h-11 rounded-md bg-[#15171a] px-5 text-sm font-semibold text-white transition hover:bg-[#2b2f35] disabled:opacity-50" type="button" :disabled="isSaving" @click="saveMember">
|
||||
{{ isSaving ? '저장 중' : '저장' }}
|
||||
<button
|
||||
class="admin-member-form__save h-11 rounded-md bg-[#15171a] px-5 text-sm font-semibold text-white transition hover:bg-[#2b2f35] disabled:cursor-not-allowed disabled:bg-[#c7cdd4] disabled:text-white"
|
||||
type="button"
|
||||
:disabled="isFormEditable && !canSubmitMemberForm"
|
||||
@click="saveMember"
|
||||
>
|
||||
{{ !isFormEditable ? '수정하기' : isSaving ? '저장 중' : '저장' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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"
|
||||
>
|
||||
<img
|
||||
@@ -543,11 +583,11 @@ watch(() => props.member, () => {
|
||||
{{ memberInitial }}
|
||||
</span>
|
||||
<span class="admin-member-form__avatar-caption absolute inset-x-0 bottom-0 flex min-h-8 items-end justify-center bg-gradient-to-t from-black/80 via-black/35 to-transparent px-2 pb-2 text-center text-[11px] font-semibold leading-tight text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
{{ isUploadingAvatar ? '업로드 중' : form.avatarUrl ? '썸네일 변경' : '썸네일 등록' }}
|
||||
{{ !isFormEditable ? '현재 썸네일' : isUploadingAvatar ? '업로드 중' : form.avatarUrl ? '썸네일 변경' : '썸네일 등록' }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="form.avatarUrl"
|
||||
v-if="form.avatarUrl && isFormEditable"
|
||||
class="admin-member-form__avatar-remove absolute right-0 top-0 grid size-6 -translate-y-1 translate-x-1 place-items-center rounded-full bg-black/85 text-white opacity-0 shadow-sm transition hover:bg-[#d21a26] group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
type="button"
|
||||
aria-label="썸네일 제거"
|
||||
@@ -562,7 +602,6 @@ watch(() => props.member, () => {
|
||||
<div class="min-w-0">
|
||||
<h2 class="truncate text-lg font-semibold text-[#15171a]">{{ pageTitle }}</h2>
|
||||
<p class="mt-1 truncate text-sm text-[#657080]">{{ form.email || '이메일 없음' }}</p>
|
||||
<p class="mt-1 text-xs font-semibold uppercase tracking-[0.04em] text-[#9aa4b2]">{{ currentRoleLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -606,11 +645,17 @@ watch(() => props.member, () => {
|
||||
<div class="grid gap-5 md:grid-cols-2">
|
||||
<label class="admin-member-form__field block">
|
||||
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">이름</span>
|
||||
<input v-model="form.username" class="admin-member-form__input h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="text" maxlength="60" required>
|
||||
<span v-if="!isFormEditable" class="admin-member-form__readonly flex min-h-12 items-center text-sm text-[#15171a]">
|
||||
{{ form.username || '-' }}
|
||||
</span>
|
||||
<input v-else v-model="form.username" class="admin-member-form__input h-12 w-full rounded-md border border-[#d7dce0] bg-white px-4 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]" type="text" maxlength="60" required>
|
||||
</label>
|
||||
<label class="admin-member-form__field block">
|
||||
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">이메일</span>
|
||||
<input v-model="form.email" class="admin-member-form__input h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="email" maxlength="254" required>
|
||||
<span v-if="!isFormEditable" class="admin-member-form__readonly flex min-h-12 items-center text-sm text-[#15171a]">
|
||||
{{ form.email || '-' }}
|
||||
</span>
|
||||
<input v-else v-model="form.email" class="admin-member-form__input h-12 w-full rounded-md border border-[#d7dce0] bg-white px-4 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]" type="email" maxlength="254" required>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -618,14 +663,15 @@ watch(() => props.member, () => {
|
||||
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">멤버 등급</span>
|
||||
<span
|
||||
v-if="shouldRenderRoleAsText"
|
||||
class="admin-member-form__role-text flex h-12 w-full items-center rounded-md bg-[#eef1f4] px-4 text-sm font-semibold text-[#15171a]"
|
||||
class="admin-member-form__role-text flex min-h-12 w-full items-center text-sm font-semibold text-[#15171a]"
|
||||
:class="{ 'rounded-md border border-[#d7dce0] bg-white px-4': isFormEditable }"
|
||||
>
|
||||
{{ currentRoleLabel }}
|
||||
</span>
|
||||
<span v-else class="admin-member-form__select-wrap relative block">
|
||||
<select
|
||||
v-model="form.roleCode"
|
||||
class="admin-member-form__select h-12 w-full appearance-none rounded-md border border-transparent bg-[#eef1f4] px-4 pr-10 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8] disabled:opacity-60"
|
||||
class="admin-member-form__select h-12 w-full appearance-none rounded-md border border-[#d7dce0] bg-white px-4 pr-10 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8] disabled:opacity-60"
|
||||
:disabled="!canEditRoleSelect"
|
||||
>
|
||||
<option v-for="option in availableRoleOptions" :key="option.value" :value="option.value">
|
||||
@@ -643,23 +689,24 @@ watch(() => props.member, () => {
|
||||
|
||||
<label class="admin-member-form__field mt-5 block">
|
||||
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">레이블</span>
|
||||
<input v-model="form.labelsText" class="admin-member-form__input h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="text" placeholder="쉼표로 구분">
|
||||
<span v-if="!isFormEditable" class="admin-member-form__readonly flex min-h-12 items-center text-sm text-[#15171a]">
|
||||
{{ form.labelsText || '-' }}
|
||||
</span>
|
||||
<input v-else v-model="form.labelsText" class="admin-member-form__input h-12 w-full rounded-md border border-[#d7dce0] bg-white px-4 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]" type="text" placeholder="쉼표로 구분">
|
||||
</label>
|
||||
|
||||
<label class="admin-member-form__field mt-5 block">
|
||||
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">
|
||||
노트 <span class="font-normal text-[#657080]">(멤버에게 보이지 않음)</span>
|
||||
</span>
|
||||
<textarea v-model="form.note" class="admin-member-form__textarea min-h-32 w-full resize-y rounded-md border border-transparent bg-[#eef1f4] px-4 py-3 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" maxlength="500" />
|
||||
<p v-if="!isFormEditable" class="admin-member-form__readonly min-h-24 whitespace-pre-wrap text-sm leading-6 text-[#15171a]">
|
||||
{{ form.note || '-' }}
|
||||
</p>
|
||||
<textarea v-else v-model="form.note" class="admin-member-form__textarea min-h-32 w-full resize-y rounded-md border border-[#d7dce0] bg-white px-4 py-3 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]" maxlength="500" />
|
||||
<span class="admin-member-form__count mt-2 block text-sm text-[#8a95a5]">
|
||||
최대 500자. 현재 {{ noteLength }}자
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<p v-if="saveMessage" class="admin-member-form__message mt-5 rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">{{ saveMessage }}</p>
|
||||
<p v-if="saveError" class="admin-member-form__error mt-5 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ saveError }}</p>
|
||||
<p v-if="actionMessage" class="admin-member-form__message mt-5 rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">{{ actionMessage }}</p>
|
||||
<p v-if="actionError && !passwordModalOpen && !deleteModalOpen" class="admin-member-form__error mt-5 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</p>
|
||||
</form>
|
||||
|
||||
<section v-if="!isNewMember" class="admin-member-form__activity">
|
||||
@@ -696,6 +743,21 @@ watch(() => props.member, () => {
|
||||
@leave="leaveUnsavedPage"
|
||||
/>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="toast"
|
||||
class="admin-member-form__toast fixed right-5 top-5 z-[100] max-w-[min(24rem,calc(100vw-2.5rem))] rounded border px-4 py-3 text-sm font-semibold shadow-lg"
|
||||
:class="{
|
||||
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
|
||||
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
|
||||
'border-[#e3e6e8] bg-white text-[#15171a]': toast.type === 'info'
|
||||
}"
|
||||
role="status"
|
||||
>
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="passwordModalOpen" class="admin-member-form__modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 pt-10">
|
||||
<section class="admin-member-form__modal-panel w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
|
||||
|
||||
Reference in New Issue
Block a user