미저장 변경 이탈 확인 추가

This commit is contained in:
2026-05-13 11:29:11 +09:00
parent fb0dadb7b9
commit 79d0a30475
12 changed files with 258 additions and 9 deletions

View File

@@ -16,6 +16,7 @@ const isNewMember = computed(() => props.mode === 'new')
const saveMessage = ref('')
const saveError = ref('')
const isSaving = ref(false)
const savedMemberSnapshot = ref('')
const form = reactive({
username: '',
@@ -58,6 +59,12 @@ const normalizedLabels = computed(() => [...new Set(
.filter(Boolean)
)])
/**
* 회원 저장 요청 본문을 문자열로 직렬화한다.
* @returns {string} 직렬화된 회원 입력값
*/
const serializeMemberPayload = () => JSON.stringify(getMemberPayload())
/**
* 날짜 표시 형식 변환
* @param {string | null} value - ISO 날짜 문자열
@@ -131,6 +138,14 @@ const getMemberPayload = () => ({
note: form.note
})
const hasUnsavedMemberChanges = computed(() => serializeMemberPayload() !== savedMemberSnapshot.value)
const {
isUnsavedModalOpen,
stayOnUnsavedPage,
leaveUnsavedPage
} = useAdminUnsavedChangesGuard(hasUnsavedMemberChanges)
/**
* 회원 기본 정보를 저장한다.
* @returns {Promise<void>}
@@ -156,6 +171,7 @@ const saveMember = async () => {
body: payload
})
savedMemberSnapshot.value = serializeMemberPayload()
emit('saved', saved)
saveMessage.value = '저장되었습니다.'
} catch (error) {
@@ -164,6 +180,10 @@ const saveMember = async () => {
isSaving.value = false
}
}
watch(() => props.member, () => {
savedMemberSnapshot.value = serializeMemberPayload()
}, { immediate: true, flush: 'post' })
</script>
<template>
@@ -198,7 +218,7 @@ const saveMember = async () => {
</div>
</div>
<div class="admin-member-form__body grid gap-8 py-8 xl:grid-cols-[280px_minmax(0,1fr)]">
<div class="admin-member-form__body grid gap-8 py-8 xl:grid-cols-3">
<aside class="admin-member-form__summary">
<div class="admin-member-form__identity flex items-center gap-4">
<img
@@ -251,7 +271,7 @@ const saveMember = async () => {
</div>
</aside>
<div class="admin-member-form__content space-y-8">
<div class="admin-member-form__content space-y-8 xl:col-span-2">
<form class="admin-member-form__card rounded-xl border border-line bg-white p-5 md:p-6" @submit.prevent="saveMember">
<div class="grid gap-5 md:grid-cols-2">
<label class="admin-member-form__field block">
@@ -315,5 +335,11 @@ const saveMember = async () => {
</section>
</div>
</div>
<AdminUnsavedChangesModal
:open="isUnsavedModalOpen"
@stay="stayOnUnsavedPage"
@leave="leaveUnsavedPage"
/>
</section>
</template>

View File

@@ -49,6 +49,7 @@ const tagInput = ref('')
const isTagInputComposing = ref(false)
const activeMediaPickerTab = ref('upload')
const selectedMediaPickerUrl = ref('')
const savedPostSnapshot = ref('')
/**
* ISO 날짜를 datetime-local 입력값으로 변환
@@ -260,6 +261,14 @@ const createPostPayload = () => {
}
}
/**
* 현재 게시물 입력값을 문자열로 직렬화한다.
* @returns {string} 직렬화된 게시물 입력값
*/
const serializePostPayload = () => JSON.stringify(createPostPayload())
const hasUnsavedPostChanges = computed(() => serializePostPayload() !== savedPostSnapshot.value)
/**
* 자동 저장 데이터 생성
* @returns {Object} 자동 저장 데이터
@@ -374,6 +383,15 @@ const discardAutosave = () => {
autosaveStatus.value = ''
}
const {
isUnsavedModalOpen,
stayOnUnsavedPage,
leaveUnsavedPage,
allowNextRouteLeave
} = useAdminUnsavedChangesGuard(hasUnsavedPostChanges, {
onLeaveConfirmed: discardAutosave
})
/**
* 미디어 라이브러리 목록 조회
* @returns {Promise<void>}
@@ -582,9 +600,19 @@ const toggleSettingsPanel = () => {
isSettingsOpen.value = !isSettingsOpen.value
}
/**
* 현재 입력값을 저장 완료 기준점으로 표시한다.
* @returns {void}
*/
const markSaved = () => {
savedPostSnapshot.value = serializePostPayload()
}
watch(form, scheduleAutosave, { deep: true })
onMounted(() => {
markSaved()
const savedRaw = localStorage.getItem(autosaveKey.value)
if (!savedRaw) {
@@ -610,7 +638,9 @@ onBeforeUnmount(() => {
})
defineExpose({
clearAutosave: discardAutosave
clearAutosave: discardAutosave,
markSaved,
allowNextRouteLeave
})
</script>
@@ -952,5 +982,10 @@ defineExpose({
</div>
</section>
</div>
<AdminUnsavedChangesModal
:open="isUnsavedModalOpen"
@stay="stayOnUnsavedPage"
@leave="leaveUnsavedPage"
/>
</form>
</template>

View File

@@ -0,0 +1,61 @@
<script setup>
defineProps({
open: {
type: Boolean,
default: false
}
})
defineEmits(['stay', 'leave'])
</script>
<template>
<Teleport to="body">
<div
v-if="open"
class="admin-unsaved-modal fixed inset-0 z-[100] flex items-center justify-center bg-black/40 px-5 py-8"
role="dialog"
aria-modal="true"
aria-labelledby="admin-unsaved-modal-title"
>
<div class="admin-unsaved-modal__content relative w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-2xl">
<header class="admin-unsaved-modal__header border-b border-[#e3e6e8] px-8 py-6">
<h1 id="admin-unsaved-modal-title" class="admin-unsaved-modal__title text-xl font-semibold tracking-[-0.01em]">
페이지를 떠날까요?
</h1>
</header>
<button
class="admin-unsaved-modal__close absolute right-5 top-5 grid size-8 place-items-center rounded-md text-[#4d5663] transition hover:bg-[#eff1f2] hover:text-black"
type="button"
title="닫기"
aria-label="닫기"
@click="$emit('stay')"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
</svg>
</button>
<div class="admin-unsaved-modal__body space-y-3 px-8 py-7 text-sm leading-6 text-[#4d5663]">
<p>저장하지 않은 변경사항이 있습니다.</p>
<p>떠나기 전에 저장해 주세요.</p>
</div>
<footer class="admin-unsaved-modal__footer flex justify-end gap-3 border-t border-[#e3e6e8] px-8 py-5">
<button
class="admin-unsaved-modal__stay h-10 rounded-md border border-[#d7dce0] bg-white px-4 text-sm font-semibold text-[#394047] transition hover:bg-[#eff1f2]"
type="button"
@click="$emit('stay')"
>
머무르기
</button>
<button
class="admin-unsaved-modal__leave h-10 rounded-md bg-[#e5484d] px-4 text-sm font-semibold text-white transition hover:bg-[#d21a26]"
type="button"
@click="$emit('leave')"
>
나가기
</button>
</footer>
</div>
</div>
</Teleport>
</template>