feat: alert/confirm을 공통 모달 다이얼로그로 통합

모든 window.alert/confirm 을 업무 삭제 모달과 동일한 구조/디자인의
공통 모달로 대체하고, 한 곳에서 관리하도록 다이얼로그 시스템을 도입한다.

- dialog.store: 알림/확인 큐 + Promise 기반 상태 관리
- useDialog(): alert/confirm 컴포저블 API
- AppDialog: App 루트 1회 마운트, 전역 .modal-*/.mbtn 클래스로 렌더
  (variant: info/success/warning/danger → 아이콘·버튼 색)
- relay.css: modal-ico.info/.warning, mbtn.primary 변형 추가
- 교체: API 에러 알림(useApi), 검증/성공 알림(Signup/ProjectCreate/
  ProjectSettings/TaskCreate), 삭제·제거 확인(ProjectSettings/
  ProjectMembers/AgentChatPanel)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 22:46:28 +09:00
parent 118d12f829
commit b1afe5480c
12 changed files with 284 additions and 16 deletions
+7 -1
View File
@@ -4,6 +4,7 @@
import { nextTick, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useAiStore } from '@/stores/ai.store'
import { useDialog } from '@/composables/useDialog'
import { relativeTimeKo } from '@/shared/utils/format'
import { renderMarkdown } from '@/shared/utils/markdown'
@@ -18,6 +19,7 @@ interface Emits {
const emit = defineEmits<Emits>()
const aiStore = useAiStore()
const dialog = useDialog()
const { conversations, listLoading, messages, loading, error } =
storeToRefs(aiStore)
@@ -66,7 +68,11 @@ async function openConv(id: string) {
}
async function removeConv(id: string) {
if (!window.confirm('이 대화를 삭제할까요?')) return
const ok = await dialog.confirm('이 대화를 삭제할까요?', {
variant: 'danger',
confirmText: '삭제',
})
if (!ok) return
await aiStore.removeConversation(id)
}
</script>
+127
View File
@@ -0,0 +1,127 @@
<script setup lang="ts">
// 전역 공통 다이얼로그 — App 루트에 1회 마운트. dialog.store 상태를 받아
// 업무 삭제 모달과 동일한 구조/디자인(.modal-* / .mbtn)으로 렌더한다.
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useDialogStore } from '@/stores/dialog.store'
const store = useDialogStore()
const { current } = storeToRefs(store)
// variant → 아이콘 원형 색 클래스(danger 는 기본 빨강)
const iconClass = computed(() => {
switch (current.value?.variant) {
case 'success':
return 'approve'
case 'warning':
return 'warning'
case 'info':
return 'info'
default:
return ''
}
})
// 확인 버튼 색 — 알림(OK)은 항상 중립 강조(primary), 확인은 variant 따름
const confirmClass = computed(() => {
if (current.value?.mode === 'alert') return 'primary'
switch (current.value?.variant) {
case 'success':
return 'approve'
case 'danger':
return 'danger'
default:
return 'primary'
}
})
function onConfirm() {
store.settle(true)
}
function onCancel() {
store.settle(false)
}
</script>
<template>
<Teleport to="body">
<div
class="modal-backdrop"
:class="{ open: !!current }"
@click.self="onCancel"
>
<div
v-if="current"
class="modal"
role="dialog"
aria-modal="true"
>
<div class="modal-body">
<div
class="modal-ico"
:class="iconClass"
>
<svg
v-if="current.variant === 'success'"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M20 6 9 17l-5-5" /></svg>
<svg
v-else-if="current.variant === 'info'"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><circle
cx="12"
cy="12"
r="10"
/><path d="M12 16v-4M12 8h.01" /></svg>
<svg
v-else
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /><path d="M12 9v4M12 17h.01" /></svg>
</div>
<h2 class="modal-title">
{{ current.title }}
</h2>
<p
v-if="current.desc"
class="modal-desc"
>
{{ current.desc }}
</p>
</div>
<div class="modal-foot">
<button
v-if="current.mode === 'confirm'"
class="mbtn"
type="button"
@click="onCancel"
>
{{ current.cancelText }}
</button>
<button
class="mbtn"
:class="confirmClass"
type="button"
@click="onConfirm"
>
{{ current.confirmText }}
</button>
</div>
</div>
</div>
</Teleport>
</template>