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:
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
// 최상위 레이아웃 — 라우터 뷰만 마운트, 도메인 별 레이아웃은 라우트 단위로 분리
|
||||
// 최상위 레이아웃 — 라우터 뷰 + 전역 공통 다이얼로그(알림/확인)
|
||||
import { RouterView } from 'vue-router'
|
||||
import AppDialog from '@/components/AppDialog.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<AppDialog />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -488,6 +488,14 @@ body {
|
||||
background: var(--green-weak);
|
||||
color: var(--green);
|
||||
}
|
||||
.modal-ico.info {
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
}
|
||||
.modal-ico.warning {
|
||||
background: var(--amber-weak);
|
||||
color: var(--amber);
|
||||
}
|
||||
.modal-ico svg {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
@@ -549,13 +557,15 @@ body {
|
||||
.mbtn.approve:hover {
|
||||
background: #188a4b;
|
||||
}
|
||||
.mbtn.request-send {
|
||||
.mbtn.request-send,
|
||||
.mbtn.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 2px rgba(79, 70, 229, 0.4);
|
||||
}
|
||||
.mbtn.request-send:hover {
|
||||
.mbtn.request-send:hover,
|
||||
.mbtn.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.modal-textarea {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -3,6 +3,7 @@ import axios, {
|
||||
type AxiosResponse,
|
||||
type InternalAxiosRequestConfig,
|
||||
} from 'axios'
|
||||
import { useDialogStore } from '@/stores/dialog.store'
|
||||
|
||||
// axios 요청 설정 확장 — 호출 단위 옵션
|
||||
declare module 'axios' {
|
||||
@@ -35,9 +36,10 @@ const ErrorCodeLexicon: Record<string, string> = {
|
||||
BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
|
||||
}
|
||||
|
||||
// 사용자 노출용 알림 — Toast 라이브러리 도입 시 이 함수만 교체하면 됨
|
||||
// 사용자 노출용 에러 알림 — 전역 공통 다이얼로그(danger)로 표시.
|
||||
// (인터셉터 실행 시점엔 pinia 가 활성화돼 있어 store 접근 가능)
|
||||
const showErrorUI = (message: string) => {
|
||||
window.alert(message)
|
||||
void useDialogStore().alert(message, { variant: 'danger' })
|
||||
}
|
||||
|
||||
// 에러 코드 → 메시지 변환 (미정의 코드는 SYS_001 로 폴백, 항상 문자열 보장)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useDialogStore, type DialogOptions } from '@/stores/dialog.store'
|
||||
|
||||
// 전역 알림/확인 다이얼로그 — window.alert/confirm 대체.
|
||||
// 사용 예:
|
||||
// const dialog = useDialog()
|
||||
// await dialog.alert('저장했습니다.', { variant: 'success' })
|
||||
// if (await dialog.confirm('삭제할까요?', { confirmText: '삭제' })) { ... }
|
||||
export function useDialog() {
|
||||
const store = useDialogStore()
|
||||
return {
|
||||
alert: (text: string, opts?: DialogOptions): Promise<void> =>
|
||||
store.alert(text, opts).then(() => undefined),
|
||||
confirm: (text: string, opts?: DialogOptions): Promise<boolean> => store.confirm(text, opts),
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import { ref } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import { useDialog } from '@/composables/useDialog'
|
||||
|
||||
const router = useRouter()
|
||||
const projectStore = useProjectStore()
|
||||
const dialog = useDialog()
|
||||
|
||||
const name = ref('')
|
||||
const description = ref('')
|
||||
@@ -16,7 +18,7 @@ const submitting = ref(false)
|
||||
async function createProject() {
|
||||
if (submitting.value) return
|
||||
if (!name.value.trim()) {
|
||||
window.alert('프로젝트 이름을 입력해 주세요.')
|
||||
void dialog.alert('프로젝트 이름을 입력해 주세요.', { variant: 'warning' })
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
|
||||
@@ -9,6 +9,7 @@ import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import { useMemberStore } from '@/stores/member.store'
|
||||
import { useUser } from '@/composables/useUser'
|
||||
import { useDialog } from '@/composables/useDialog'
|
||||
import type { Project } from '@/mock/relay.mock'
|
||||
import type { ApiMember } from '@/types/repo'
|
||||
import type { MemberRoleType } from '@/types/member'
|
||||
@@ -19,6 +20,7 @@ const route = useRoute()
|
||||
const projectId = computed(() => String(route.params.projectId))
|
||||
const projectStore = useProjectStore()
|
||||
const memberStore = useMemberStore()
|
||||
const dialog = useDialog()
|
||||
|
||||
// 프로젝트 헤더 — API 연동
|
||||
const repo = ref<Project | null>(null)
|
||||
@@ -96,7 +98,11 @@ async function changeRole(userId: string, roleType: MemberRoleType) {
|
||||
|
||||
// --- 멤버 제거 ---
|
||||
async function removeMember(userId: string, name: string) {
|
||||
if (!window.confirm(`${name} 님을 프로젝트에서 제거하시겠습니까?`)) return
|
||||
const ok = await dialog.confirm(`${name} 님을 프로젝트에서 제거할까요?`, {
|
||||
variant: 'danger',
|
||||
confirmText: '제거',
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await memberStore.remove(projectId.value, userId)
|
||||
} catch {
|
||||
|
||||
@@ -6,12 +6,14 @@ import AppShell from '@/layouts/AppShell.vue'
|
||||
import ProjectHeader from '@/components/ProjectHeader.vue'
|
||||
import ProjectTabs from '@/components/ProjectTabs.vue'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import { useDialog } from '@/composables/useDialog'
|
||||
import { type Project } from '@/mock/relay.mock'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const projectId = computed(() => String(route.params.projectId))
|
||||
const projectStore = useProjectStore()
|
||||
const dialog = useDialog()
|
||||
|
||||
const repo = ref<Project | null>(null)
|
||||
|
||||
@@ -56,7 +58,7 @@ async function save() {
|
||||
name: displayTitle.value.trim(),
|
||||
description: description.value.trim(),
|
||||
})
|
||||
window.alert('변경 사항을 저장했습니다.')
|
||||
void dialog.alert('변경 사항을 저장했습니다.', { variant: 'success' })
|
||||
} catch {
|
||||
// 인터셉터 전역 처리
|
||||
} finally {
|
||||
@@ -67,7 +69,15 @@ async function save() {
|
||||
// 프로젝트 삭제 (소유자 전용)
|
||||
async function removeProject() {
|
||||
if (!repo.value || !isOwner.value) return
|
||||
if (!window.confirm('프로젝트와 그 안의 모든 업무·활동 기록이 영구 삭제됩니다. 계속할까요?')) return
|
||||
const ok = await dialog.confirm(
|
||||
'프로젝트와 그 안의 모든 업무·활동 기록이 영구 삭제됩니다. 이 작업은 되돌릴 수 없습니다.',
|
||||
{
|
||||
title: '프로젝트를 삭제할까요?',
|
||||
variant: 'danger',
|
||||
confirmText: '삭제',
|
||||
},
|
||||
)
|
||||
if (!ok) return
|
||||
try {
|
||||
await projectStore.remove(projectId.value)
|
||||
await router.push('/projects')
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useDialog } from '@/composables/useDialog'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const dialog = useDialog()
|
||||
|
||||
// 폼 상태
|
||||
const name = ref('')
|
||||
@@ -30,19 +32,19 @@ async function submit() {
|
||||
|
||||
// 클라이언트 1차 검증 (서버 DTO 가 최종 검증)
|
||||
if (!name.value.trim() || !email.value.trim() || !password.value) {
|
||||
window.alert('이름, 이메일, 비밀번호를 모두 입력해 주세요.')
|
||||
void dialog.alert('이름, 이메일, 비밀번호를 모두 입력해 주세요.', { variant: 'warning' })
|
||||
return
|
||||
}
|
||||
if (password.value.length < 8) {
|
||||
window.alert('비밀번호는 최소 8자 이상이어야 합니다.')
|
||||
void dialog.alert('비밀번호는 최소 8자 이상이어야 합니다.', { variant: 'warning' })
|
||||
return
|
||||
}
|
||||
if (password.value !== passwordConfirm.value) {
|
||||
window.alert('비밀번호가 일치하지 않습니다.')
|
||||
void dialog.alert('비밀번호가 일치하지 않습니다.', { variant: 'warning' })
|
||||
return
|
||||
}
|
||||
if (!agreeTerms.value) {
|
||||
window.alert('이용약관 및 개인정보 처리방침에 동의해 주세요.')
|
||||
void dialog.alert('이용약관 및 개인정보 처리방침에 동의해 주세요.', { variant: 'warning' })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useTaskStore } from '@/stores/task.store'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useTask } from '@/composables/useTask'
|
||||
import { useAi } from '@/composables/useAi'
|
||||
import { useDialog } from '@/composables/useDialog'
|
||||
import { attachmentKindOf, fileBadge, formatBytes } from '@/shared/utils/format'
|
||||
import { CHECKLIST_CATEGORIES } from '@/mock/relay.mock'
|
||||
import type {
|
||||
@@ -36,6 +37,7 @@ const taskStore = useTaskStore()
|
||||
const authStore = useAuthStore()
|
||||
const taskApi = useTask()
|
||||
const ai = useAi()
|
||||
const dialog = useDialog()
|
||||
|
||||
// API 멤버 → 화면용 User(이니셜/색상 파생)
|
||||
function toUserView(m: ApiMember): User {
|
||||
@@ -337,7 +339,9 @@ function onPickFiles(e: Event) {
|
||||
const ok = picked.filter((f) => f.size <= MAX_FILE_SIZE)
|
||||
pendingFiles.value.push(...ok)
|
||||
if (ok.length < picked.length) {
|
||||
window.alert(`25MB를 초과하는 파일 ${picked.length - ok.length}개는 제외했습니다.`)
|
||||
void dialog.alert(`25MB를 초과하는 파일 ${picked.length - ok.length}개는 제외했습니다.`, {
|
||||
variant: 'warning',
|
||||
})
|
||||
}
|
||||
input.value = '' // 같은 파일을 다시 선택할 수 있도록 초기화
|
||||
}
|
||||
@@ -436,8 +440,9 @@ async function submitTask() {
|
||||
// 선택한 파일 업로드(업무가 생성/확정된 뒤에야 가능) — 베스트에포트
|
||||
const failed = await uploadPendingFiles(tid)
|
||||
if (failed > 0) {
|
||||
window.alert(
|
||||
void dialog.alert(
|
||||
`파일 ${failed}개 업로드에 실패했습니다. 업무 상세 화면에서 다시 첨부해 주세요.`,
|
||||
{ variant: 'warning' },
|
||||
)
|
||||
}
|
||||
router.push(`/projects/${projectId.value}/tasks/${tid}`)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
// 전역 다이얼로그(알림/확인) — window.alert/confirm 을 대체하는 공통 모달의 상태 저장소.
|
||||
// 디자인/구조는 업무 삭제 모달과 동일(전역 relay.css 의 .modal-* / .mbtn 클래스 사용).
|
||||
|
||||
export type DialogVariant = 'info' | 'success' | 'warning' | 'danger'
|
||||
export type DialogMode = 'alert' | 'confirm'
|
||||
|
||||
// 호출 시 넘길 수 있는 옵션
|
||||
export interface DialogOptions {
|
||||
/** 제목을 따로 지정하면 본문 텍스트는 설명(desc)으로 내려간다 */
|
||||
title?: string
|
||||
/** 아이콘/버튼 색 계열 */
|
||||
variant?: DialogVariant
|
||||
/** 확인 버튼 문구 */
|
||||
confirmText?: string
|
||||
/** 취소 버튼 문구(confirm 전용) */
|
||||
cancelText?: string
|
||||
}
|
||||
|
||||
// 현재 표시 중인 다이얼로그 상태
|
||||
export interface DialogState {
|
||||
id: number
|
||||
mode: DialogMode
|
||||
title: string
|
||||
desc?: string
|
||||
variant: DialogVariant
|
||||
confirmText: string
|
||||
cancelText: string
|
||||
resolve: (ok: boolean) => void
|
||||
}
|
||||
|
||||
export const useDialogStore = defineStore('dialog', () => {
|
||||
// 현재 떠 있는 다이얼로그(없으면 null)
|
||||
const current = ref<DialogState | null>(null)
|
||||
// 동시에 여러 개가 요청되면 순차 표시(큐)
|
||||
const queue: DialogState[] = []
|
||||
let seq = 0
|
||||
|
||||
function enqueue(
|
||||
mode: DialogMode,
|
||||
text: string,
|
||||
opts: DialogOptions,
|
||||
defaults: { variant: DialogVariant; confirmText: string },
|
||||
): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const item: DialogState = {
|
||||
id: ++seq,
|
||||
mode,
|
||||
// title 을 따로 주면 본문은 설명으로, 아니면 본문이 곧 제목
|
||||
title: opts.title ?? text,
|
||||
desc: opts.title ? text : undefined,
|
||||
variant: opts.variant ?? defaults.variant,
|
||||
confirmText: opts.confirmText ?? defaults.confirmText,
|
||||
cancelText: opts.cancelText ?? '취소',
|
||||
resolve,
|
||||
}
|
||||
if (current.value) queue.push(item)
|
||||
else current.value = item
|
||||
})
|
||||
}
|
||||
|
||||
// 알림(OK 한 개) — 항상 true 로 resolve
|
||||
function alert(text: string, opts: DialogOptions = {}): Promise<boolean> {
|
||||
return enqueue('alert', text, opts, { variant: 'info', confirmText: '확인' })
|
||||
}
|
||||
|
||||
// 확인(취소/확인) — 확인=true, 취소/바깥클릭=false
|
||||
function confirm(text: string, opts: DialogOptions = {}): Promise<boolean> {
|
||||
return enqueue('confirm', text, opts, { variant: 'danger', confirmText: '확인' })
|
||||
}
|
||||
|
||||
// 사용자 응답 처리 후 다음 큐로 전환
|
||||
function settle(ok: boolean) {
|
||||
current.value?.resolve(ok)
|
||||
current.value = queue.shift() ?? null
|
||||
}
|
||||
|
||||
return { current, alert, confirm, settle }
|
||||
})
|
||||
Reference in New Issue
Block a user