generated from ttipo/project-base
chore(docker): 메일 엔진·네트워크 분리·같은 주소 배포와 Node 24 고정 #1
@@ -1,19 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
// 모달 대화상자 틀 — 브라우저 <dialog>(showModal)로 띄운다. 모양은 디자인 원본 States "세션 만료".
|
||||
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명) · MailPurgeDialog(영구 삭제 확인)
|
||||
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명 · 부재중 자동 응답) ·
|
||||
// MailPurgeDialog(영구 삭제 확인) · 메일함 관리(MailboxNameDialog — 이름 칸 · MailboxDeleteDialog · MailboxEmptyDialog)
|
||||
// - 모달이라 뒤의 화면을 조작할 수 없다. 처음 초점은 autofocus 를 붙인 단추 (없으면 브라우저가 첫 단추로)
|
||||
// - Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 무엇을 할지는 쓰는 쪽이 정한다
|
||||
// - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게
|
||||
// 단추는 actions 슬롯에 둔다: class="modal-dialog__action" (보조 단추는 modal-dialog__action--secondary, 되돌릴 수 없는 일은 --danger 를 더한다)
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
// 글(message) 대신 · 글 아래에 입력 칸을 둘 때는 기본 슬롯에 넣는다 — 글이 없으면 describedBy 로 칸의 안내를 가리킨다
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
/** 제목 아래 글 — 없으면 그리지 않는다 (입력 칸만 있는 대화상자) */
|
||||
message?: string
|
||||
/** 제목 · 글 문단의 id — aria-labelledby · aria-describedby 가 가리킨다 (화면에 하나뿐인 값) */
|
||||
titleId: string
|
||||
messageId: string
|
||||
messageId?: string
|
||||
/** 글이 없을 때 aria-describedby 가 가리킬 요소의 id (입력 칸의 안내 등) */
|
||||
describedBy?: string
|
||||
/** 사용자가 답해야 하는 확인이면 'alertdialog' — 주지 않으면 dialog 그대로 */
|
||||
role?: 'alertdialog'
|
||||
}
|
||||
@@ -23,9 +28,16 @@ interface Emits {
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
message: '',
|
||||
messageId: undefined,
|
||||
describedBy: undefined,
|
||||
role: undefined,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const descriptionId = computed(() => (props.message ? props.messageId : props.describedBy))
|
||||
|
||||
const dialogRef = ref<HTMLDialogElement | null>(null)
|
||||
/** 열기 전에 초점이 있던 곳 — 닫으면 돌려준다 (브라우저가 돌려주지 않는 환경에서도) */
|
||||
let returnFocus: HTMLElement | null = null
|
||||
@@ -88,7 +100,7 @@ onMounted(() => {
|
||||
class="modal-dialog"
|
||||
:role="role"
|
||||
:aria-labelledby="titleId"
|
||||
:aria-describedby="messageId"
|
||||
:aria-describedby="descriptionId"
|
||||
@cancel="onCancel"
|
||||
@close="onClose"
|
||||
>
|
||||
@@ -99,11 +111,13 @@ onMounted(() => {
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="message"
|
||||
:id="messageId"
|
||||
class="modal-dialog__message"
|
||||
>
|
||||
{{ message }}
|
||||
</p>
|
||||
<slot />
|
||||
<div class="modal-dialog__actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
@@ -164,16 +178,25 @@ onMounted(() => {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:slotted(.modal-dialog__action:hover) {
|
||||
:slotted(.modal-dialog__action:not(:disabled):hover) {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/*
|
||||
* 아직 누를 수 없는 단추 (메일함 이름을 비웠다 · 보내는 중 · 함께 돌 수 없는 처리가 도는 중) — 마우스를 올려도 그대로.
|
||||
* 마우스를 올린 모양은 모두 :not(:disabled) 로만 그린다 — 꺼진 빨강 · 보조 단추가 브랜드색으로 바뀌지 않게
|
||||
*/
|
||||
:slotted(.modal-dialog__action:disabled) {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 되돌릴 수 없는 일 — 빨강 채움 (영구 삭제, 디자인 원본 BulkSelect) */
|
||||
:slotted(.modal-dialog__action--danger) {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
:slotted(.modal-dialog__action--danger:hover) {
|
||||
:slotted(.modal-dialog__action--danger:not(:disabled):hover) {
|
||||
background: var(--color-danger-strong);
|
||||
}
|
||||
|
||||
@@ -184,7 +207,7 @@ onMounted(() => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:slotted(.modal-dialog__action--secondary:hover) {
|
||||
:slotted(.modal-dialog__action--secondary:not(:disabled):hover) {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
// 화면 오른쪽 아래 알림 (디자인 원본 States "오류 알림" · "보내기 완료")
|
||||
// 화면 오른쪽 아래 알림 (디자인 원본 States "오류 알림" · "보내기 완료" · MailActions4 "오래 걸리는 처리")
|
||||
// 오류: 흰 카드 + 문의 코드(traceId 앞 8자리) + 닫기 / 성공: 브랜드 바탕 + 이동 링크 또는 그 자리의 단추(되돌리기 등)
|
||||
// 진행: 성공과 같은 바탕 + 돌아가는 아이콘 — 끝날 때까지 남았다가 그 자리에서 결과로 바뀐다 (toast.store 의 update)
|
||||
// 마우스가 올라가 있거나 초점이 알림 안에 있는 동안은 닫히지 않는다 (toast.store 의 hold · release — WCAG 2.2.1)
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
import { formatTraceCode } from '@/shared/utils/format'
|
||||
import { useToastStore, type Toast } from '@/stores/toast.store'
|
||||
import { useToastStore, type Toast, type ToastKind } from '@/stores/toast.store'
|
||||
|
||||
const toastStore = useToastStore()
|
||||
|
||||
/** 종류별 아이콘 — 오류 18 · 성공 ✓ 16 · 진행 원의 3/4 (돌아간다) */
|
||||
const TOAST_ICONS: Readonly<Record<ToastKind, { name: IconName; size: number; strokeWidth: number }>> = {
|
||||
error: { name: 'alert-circle', size: 18, strokeWidth: 1.75 },
|
||||
success: { name: 'check', size: 16, strokeWidth: 2.2 },
|
||||
progress: { name: 'loader', size: 16, strokeWidth: 2 },
|
||||
}
|
||||
|
||||
/** 초점이 알림 밖으로 나갔을 때만 놓는다 — 알림 안의 다른 단추로 옮긴 것은 그대로 붙잡는다 */
|
||||
function onFocusOut(event: FocusEvent, id: number) {
|
||||
const next = event.relatedTarget
|
||||
@@ -44,9 +53,9 @@ function runAction(toast: Toast) {
|
||||
@focusout="onFocusOut($event, toast.id)"
|
||||
>
|
||||
<AppIcon
|
||||
:name="toast.kind === 'error' ? 'alert-circle' : 'check'"
|
||||
:size="toast.kind === 'error' ? 18 : 16"
|
||||
:stroke-width="toast.kind === 'error' ? 1.75 : 2.2"
|
||||
:name="TOAST_ICONS[toast.kind].name"
|
||||
:size="TOAST_ICONS[toast.kind].size"
|
||||
:stroke-width="TOAST_ICONS[toast.kind].strokeWidth"
|
||||
class="toast__icon"
|
||||
/>
|
||||
<div class="toast__body">
|
||||
@@ -121,7 +130,8 @@ function runAction(toast: Toast) {
|
||||
box-shadow: var(--shadow-toast);
|
||||
}
|
||||
|
||||
.toast--success {
|
||||
.toast--success,
|
||||
.toast--progress {
|
||||
align-items: center;
|
||||
padding: var(--space-8) var(--space-6) var(--space-8) var(--space-14);
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -143,6 +153,28 @@ function runAction(toast: Toast) {
|
||||
color: var(--color-on-brand-success);
|
||||
}
|
||||
|
||||
/* 진행 — 돌아가는 아이콘 · 숫자 폭을 고정해 "600 / 1,234통"이 바뀔 때 글자가 흔들리지 않게 */
|
||||
.toast--progress {
|
||||
min-height: var(--size-touch);
|
||||
padding-right: var(--space-14);
|
||||
}
|
||||
|
||||
.toast--progress .toast__icon {
|
||||
margin-top: 0;
|
||||
color: var(--color-on-brand-link);
|
||||
animation: toast-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.toast--progress .toast__message {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@keyframes toast-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.toast__body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
@@ -13,8 +13,8 @@ interface Props {
|
||||
/** 툴팁 — 주지 않으면 이름 */
|
||||
title?: string
|
||||
labelMode?: 'always' | 'wide' | 'never'
|
||||
/** danger: 삭제 · 영구 삭제 (빨강) */
|
||||
tone?: 'default' | 'danger'
|
||||
/** danger: 삭제 · 영구 삭제 (빨강) · accent: 스팸 아님 (강조 글자 굵게) */
|
||||
tone?: 'default' | 'danger' | 'accent'
|
||||
/** 메뉴를 여는 단추 — 이름 뒤 ▾ (아이콘만일 때는 빼고) */
|
||||
menu?: boolean
|
||||
disabled?: boolean
|
||||
@@ -108,6 +108,16 @@ defineExpose({
|
||||
color: var(--color-danger-strong);
|
||||
}
|
||||
|
||||
/* 스팸 아님 — 읽기 화면과 같은 강조 글자 (디자인 원본 MailActions4) */
|
||||
.bulk-button--accent {
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bulk-button--accent:hover:not(:disabled) {
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/* 아이콘만 40×44 (분할 보기의 목록 칸) */
|
||||
.bulk-button--label-never {
|
||||
width: var(--size-icon-button);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 머리 — 메일함 이름 · 개수("전체 N · 안 읽음 M") · 보기 전환 · 새로고침
|
||||
// 목록 머리 — 메일함 이름 · 개수("전체 N · 안 읽음 M") · [휴지통 비우기 · 스팸메일함 비우기] · 보기 전환 · 새로고침
|
||||
// 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다. 안쪽 여백만 보기마다 다르다 (디자인 원본).
|
||||
// 비우기는 휴지통 · 스팸메일함에 메일이 있을 때만 (디자인 원본 MailboxManage — MailboxEmptyButton). 좁으면 이름 · 개수가 먼저 줄어든다.
|
||||
// 스크롤해도 위에 남는 것은 목록 틀(MailListFrame)의 머리 묶음이 맡는다 — 메일을 고르면 이 자리가 도구 줄로 바뀐다.
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailboxEmptyButton from '@/components/mail/MailboxEmptyButton.vue'
|
||||
import ViewModeSwitch from '@/components/mail/ViewModeSwitch.vue'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { isEmptiableMailbox } from '@/shared/utils/mailbox'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
@@ -29,6 +32,11 @@ const countText = computed(() => {
|
||||
return box.unreadCount > 0 ? `${total} · 안 읽음 ${formatCount(box.unreadCount)}` : total
|
||||
})
|
||||
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
|
||||
/** 비울 수 있는 메일함 — 휴지통 · 스팸메일함에 메일이 있을 때 */
|
||||
const emptiable = computed(() => {
|
||||
const box = mailStore.currentMailbox
|
||||
return box && isEmptiableMailbox(box) && box.totalCount > 0 ? box : null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -40,16 +48,23 @@ const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRe
|
||||
{{ title }}
|
||||
</h1>
|
||||
<span class="list-header__count">{{ countText }}</span>
|
||||
<ViewModeSwitch class="list-header__switch" />
|
||||
<button
|
||||
type="button"
|
||||
class="list-header__refresh"
|
||||
aria-label="새로고침"
|
||||
:disabled="busy"
|
||||
@click="mailStore.refresh()"
|
||||
>
|
||||
<AppIcon name="refresh" />
|
||||
</button>
|
||||
<div class="list-header__end">
|
||||
<MailboxEmptyButton
|
||||
v-if="emptiable"
|
||||
:mailbox="emptiable"
|
||||
:variant="variant"
|
||||
/>
|
||||
<ViewModeSwitch />
|
||||
<button
|
||||
type="button"
|
||||
class="list-header__refresh"
|
||||
aria-label="새로고침"
|
||||
:disabled="busy"
|
||||
@click="mailStore.refresh()"
|
||||
>
|
||||
<AppIcon name="refresh" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -70,19 +85,33 @@ const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRe
|
||||
}
|
||||
|
||||
.list-header__title {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
font-size: var(--font-size-title-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 좁으면 이름보다 먼저 줄어든다 */
|
||||
.list-header__count {
|
||||
flex: 0 100 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.list-header__switch {
|
||||
.list-header__end {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ describe('여러 통 처리 도구 줄', () => {
|
||||
const bulkPatches = () =>
|
||||
requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)
|
||||
|
||||
it('도구 줄 — "N통 선택됨" · 읽음 · 안 읽음 · 중요 표시 | 이동 · 삭제 … 선택 해제 (목록 보기는 이름을 보인다)', async () => {
|
||||
it('도구 줄 — "N통 선택됨" · 읽음 · 안 읽음 · 중요 표시 | 이동 · 스팸 신고 · 삭제 … 선택 해제 (목록 보기는 이름을 보인다)', async () => {
|
||||
const { wrapper, store } = await mountView()
|
||||
store.toggleCheck('m1')
|
||||
store.toggleCheck('m2')
|
||||
@@ -117,7 +117,7 @@ describe('여러 통 처리 도구 줄', () => {
|
||||
|
||||
expect(wrapper.get('.bulk-bar__count').text()).toBe('2통 선택됨')
|
||||
const labels = wrapper.findAll('[role="toolbar"] button').map((button) => button.attributes('aria-label'))
|
||||
expect(labels).toEqual(['읽음 표시', '안 읽음 표시', '중요 표시', '이동', '삭제', '선택 해제'])
|
||||
expect(labels).toEqual(['읽음 표시', '안 읽음 표시', '중요 표시', '이동', '스팸 신고', '삭제', '선택 해제'])
|
||||
expect(wrapper.get('[data-tool="clear"]').attributes('title')).toBe('선택 해제 (Esc)')
|
||||
expect(wrapper.get('.bulk-bar').classes()).toContain('bulk-bar--list')
|
||||
expect(wrapper.get('[data-tool="markRead"]').text()).toBe('읽음 표시')
|
||||
@@ -376,4 +376,60 @@ describe('여러 통 처리 도구 줄', () => {
|
||||
release(ok({ updatedIds: ['m1'], failedIds: [] }))
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
describe('스팸 신고 · 스팸 아님 (4단계 — 디자인 원본 MailActions4)', () => {
|
||||
it('스팸 신고는 이동 ▾ 과 삭제 사이 — PATCH { ids, mailboxId: 스팸메일함, isJunk: true }, 알림 + [되돌리기](원래 메일함 · isJunk: false)', async () => {
|
||||
const { wrapper, store } = await mountView()
|
||||
store.toggleCheck('m1')
|
||||
store.toggleCheck('m2')
|
||||
await flushPromises()
|
||||
const tools = wrapper.findAll('[role="toolbar"] [data-tool]').map((tool) => tool.attributes('data-tool'))
|
||||
expect(tools.slice(3, 6)).toEqual(['move', 'spam', 'delete'])
|
||||
|
||||
await wrapper.get('[data-tool="spam"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(bulkPatches()).toEqual([{ ids: ['m1', 'm2'], mailboxId: 'junk', isJunk: true }])
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m3'])
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(toast?.message).toBe('메일 2통을 스팸메일함으로 옮겼습니다.')
|
||||
expect(toast?.action?.label).toBe('되돌리기')
|
||||
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await flushPromises()
|
||||
expect(bulkPatches()[1]).toEqual({ ids: ['m1', 'm2'], mailboxId: 'inbox', isJunk: false })
|
||||
expect(toastTexts()).toContain('메일 2통을 되돌렸습니다.')
|
||||
})
|
||||
|
||||
it('스팸메일함에서는 「스팸 아님」(강조) — 받은편지함으로 · isJunk: false, "(스팸 아님)" 알림 · 되돌리면 스팸메일함으로 · isJunk: true', async () => {
|
||||
const junked = [summary('j1', { mailboxIds: ['junk'] }), summary('j2', { mailboxIds: ['junk'] })]
|
||||
const { wrapper, store } = await mountView({ boxId: 'junk', messages: junked })
|
||||
store.toggleCheck('j1')
|
||||
await flushPromises()
|
||||
const notSpam = wrapper.get('[data-tool="spam"]')
|
||||
expect(notSpam.attributes('aria-label')).toBe('스팸 아님')
|
||||
expect(notSpam.classes()).toContain('bulk-button--accent')
|
||||
|
||||
await notSpam.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(bulkPatches()).toEqual([{ ids: ['j1'], mailboxId: 'inbox', isJunk: false }])
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(toast?.message).toBe('메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님).')
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await flushPromises()
|
||||
expect(bulkPatches()[1]).toEqual({ ids: ['j1'], mailboxId: 'junk', isJunk: true })
|
||||
})
|
||||
|
||||
it('스팸메일함을 모르면 스팸 단추를 그리지 않는다 — 로빙 tabindex 도 건너뛴다', async () => {
|
||||
const { wrapper, store } = await mountView()
|
||||
store.mailboxes = MAILBOXES.filter((mailbox) => mailbox.role !== 'junk')
|
||||
store.toggleCheck('m1')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-tool="spam"]').exists()).toBe(false)
|
||||
await wrapper.get('[data-tool="move"]').trigger('keydown', { key: 'ArrowRight' })
|
||||
expect(document.activeElement).toBe(wrapper.get('[data-tool="delete"]').element)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
// 여러 통 처리 도구 줄 — 하나라도 고르면 목록 머리(52) 자리에 (디자인 원본 BulkSelect · ListStates, 바탕 #F3F8F8)
|
||||
// "N통 선택됨"(14px 굵게) · 읽음 표시 · 안 읽음 표시 · 중요 표시(모두 중요면 "중요 해제") | 이동 ▾ · 삭제(휴지통에서는 "영구 삭제", 빨강)
|
||||
// … 오른쪽 끝 선택 해제(✕). 하는 일은 useBulkMailActions 가 맡는다.
|
||||
// "N통 선택됨"(14px 굵게) · 읽음 표시 · 안 읽음 표시 · 중요 표시(모두 중요면 "중요 해제") | 이동 ▾ · 스팸 신고(스팸메일함에서는 "스팸 아님") ·
|
||||
// 삭제(휴지통에서는 "영구 삭제", 빨강) … 오른쪽 끝 선택 해제(✕). 하는 일은 useBulkMailActions 가 맡는다.
|
||||
// 스팸 단추는 옮길 메일함(스팸메일함 · 받은편지함)을 알 때만 그린다 (디자인 README "4단계 › 스팸 · 원문")
|
||||
// - 단추(MailBulkButton)는 높이 44 · 아이콘 + 이름. 폭 1100 미만이거나 분할 보기의 목록 칸에서는 아이콘만(40×44 — 이름은 툴팁 · aria-label).
|
||||
// "선택 해제"는 목록 보기에서 이름을 늘 보인다 (캔버스 BulkSelect)
|
||||
// - role="toolbar": Tab 한 번에 들어와 화살표 · Home · End 로 옮긴다(로빙 tabindex). 이동 메뉴는 MailBulkMoveButton.
|
||||
@@ -14,7 +15,7 @@ import MailBulkMoveButton from '@/components/mail/MailBulkMoveButton.vue'
|
||||
import MailPurgeDialog from '@/components/mail/MailPurgeDialog.vue'
|
||||
import { useBulkMailActions } from '@/composables/useBulkMailActions'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
|
||||
import { MAIL_BULK_TEXT, MAIL_SPAM_TEXT } from '@/shared/constants/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
@@ -29,8 +30,8 @@ const props = defineProps<Props>()
|
||||
const mailStore = useMailStore()
|
||||
const bulk = useBulkMailActions()
|
||||
|
||||
type ToolId = 'markRead' | 'markUnread' | 'flag' | 'move' | 'delete' | 'clear'
|
||||
const TOOL_ORDER: readonly ToolId[] = ['markRead', 'markUnread', 'flag', 'move', 'delete', 'clear']
|
||||
type ToolId = 'markRead' | 'markUnread' | 'flag' | 'move' | 'spam' | 'delete' | 'clear'
|
||||
const ALL_TOOLS: readonly ToolId[] = ['markRead', 'markUnread', 'flag', 'move', 'spam', 'delete', 'clear']
|
||||
|
||||
interface LeadingTool {
|
||||
id: Extract<ToolId, 'markRead' | 'markUnread' | 'flag'>
|
||||
@@ -46,17 +47,23 @@ const purgeOpen = ref<boolean>(false)
|
||||
/** 초점이 도구 줄 안의 어느 단추에 있었는지 — 그 단추가 눌리지 않게 되어 초점을 잃었을 때 되돌려 줄 곳을 찾는다 */
|
||||
let lastFocusedTool: ToolId | null = null
|
||||
|
||||
/** 그리는 단추 — 스팸 단추는 옮길 메일함을 알 때만 */
|
||||
const toolOrder = computed<ToolId[]>(() => ALL_TOOLS.filter((id) => id !== 'spam' || bulk.spamTarget.value !== null))
|
||||
/** 지금 누를 수 없는 단추 — 옮기기 · 지우기를 하는 동안 */
|
||||
const disabledTools = computed<ReadonlySet<ToolId>>(() => new Set<ToolId>(bulk.busy.value ? ['move', 'delete'] : []))
|
||||
const disabledTools = computed<ReadonlySet<ToolId>>(
|
||||
() => new Set<ToolId>(bulk.busy.value ? ['move', 'spam', 'delete'] : []),
|
||||
)
|
||||
/** 화살표 · Home · End 로 옮겨 다닐 단추 — 누를 수 없는 단추는 건너뛴다 */
|
||||
const enabledTools = computed<ToolId[]>(() => TOOL_ORDER.filter((id) => !disabledTools.value.has(id)))
|
||||
/** Tab 으로 들어올 단추 — 마지막 단추를 누를 수 없게 되면 그다음(끝이면 처음부터) 누를 수 있는 단추 */
|
||||
const enabledTools = computed<ToolId[]>(() => toolOrder.value.filter((id) => !disabledTools.value.has(id)))
|
||||
/** Tab 으로 들어올 단추 — 마지막 단추를 누를 수 없게 되면(또는 사라지면) 그다음(끝이면 처음부터) 누를 수 있는 단추 */
|
||||
const rovingTool = computed<ToolId>(() => {
|
||||
if (!disabledTools.value.has(activeTool.value)) return activeTool.value
|
||||
const start = TOOL_ORDER.indexOf(activeTool.value)
|
||||
for (let step = 1; step < TOOL_ORDER.length; step += 1) {
|
||||
const candidate = TOOL_ORDER[(start + step) % TOOL_ORDER.length]
|
||||
if (candidate && !disabledTools.value.has(candidate)) return candidate
|
||||
const order = toolOrder.value
|
||||
const usable = (id: ToolId | undefined): id is ToolId => id !== undefined && order.includes(id) && !disabledTools.value.has(id)
|
||||
if (usable(activeTool.value)) return activeTool.value
|
||||
const start = ALL_TOOLS.indexOf(activeTool.value)
|
||||
for (let step = 1; step < ALL_TOOLS.length; step += 1) {
|
||||
const candidate = ALL_TOOLS[(start + step) % ALL_TOOLS.length]
|
||||
if (usable(candidate)) return candidate
|
||||
}
|
||||
return 'clear'
|
||||
})
|
||||
@@ -65,6 +72,7 @@ const rovingTool = computed<ToolId>(() => {
|
||||
const labelMode = computed(() => (props.variant === 'split' ? 'never' : 'wide'))
|
||||
const countText = computed(() => MAIL_BULK_TEXT.selected(formatCount(mailStore.checkedCount)))
|
||||
const deleteLabel = computed(() => (bulk.inTrash.value ? MAIL_BULK_TEXT.purge : MAIL_BULK_TEXT.delete))
|
||||
const spamLabel = computed(() => (bulk.inJunk.value ? MAIL_SPAM_TEXT.notSpam : MAIL_SPAM_TEXT.report))
|
||||
const leadingTools = computed<LeadingTool[]>(() => [
|
||||
{ id: 'markRead', icon: 'mail-open', label: MAIL_BULK_TEXT.markRead, run: () => void bulk.markRead(true) },
|
||||
{ id: 'markUnread', icon: 'mail', label: MAIL_BULK_TEXT.markUnread, run: () => void bulk.markRead(false) },
|
||||
@@ -120,7 +128,7 @@ function onKeydown(event: KeyboardEvent) {
|
||||
|
||||
function onFocusIn(event: FocusEvent) {
|
||||
const element = event.target instanceof HTMLElement ? event.target : null
|
||||
const tool = TOOL_ORDER.find((id) => id === element?.dataset.tool)
|
||||
const tool = ALL_TOOLS.find((id) => id === element?.dataset.tool)
|
||||
if (!tool) return
|
||||
activeTool.value = tool
|
||||
lastFocusedTool = tool
|
||||
@@ -188,6 +196,17 @@ watch(disabledTools, (disabled) => {
|
||||
:tabindex="tabindexOf('move')"
|
||||
@move="bulk.moveTo"
|
||||
/>
|
||||
<MailBulkButton
|
||||
v-if="bulk.spamTarget.value"
|
||||
:icon="bulk.inJunk.value ? 'inbox' : 'alert-octagon'"
|
||||
:label="spamLabel"
|
||||
:label-mode="labelMode"
|
||||
:tone="bulk.inJunk.value ? 'accent' : 'default'"
|
||||
:disabled="bulk.busy.value"
|
||||
data-tool="spam"
|
||||
:tabindex="tabindexOf('spam')"
|
||||
@click="bulk.moveSpam()"
|
||||
/>
|
||||
<MailBulkButton
|
||||
icon="trash"
|
||||
:label="deleteLabel"
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import type { MailVacation } from '@/shared/types/mail'
|
||||
import { endOfDayIso, startOfDayIso } from '@/shared/utils/mail-vacation'
|
||||
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MailVacationNotice from './MailVacationNotice.vue'
|
||||
|
||||
// 메일함 패널 아래 부재중 안내 — 시각에 따라 바뀌므로 1분마다 다시 계산한다 (손으로 돌리는 시계로 시간을 넘긴다)
|
||||
// 날짜는 브라우저 시간대 기준이라 기대값도 로컬 Date 로 만든다
|
||||
|
||||
const ONE_MINUTE_MS = 60_000
|
||||
|
||||
function vacation(partial: Partial<MailVacation>): MailVacation {
|
||||
return { isEnabled: true, fromDate: null, toDate: null, subject: '부재중입니다', textBody: '자리를 비웁니다.', ...partial }
|
||||
}
|
||||
|
||||
describe('MailVacationNotice — 1분마다 다시 계산', () => {
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
})
|
||||
|
||||
async function mountNotice(value: MailVacation, clock: ManualClock) {
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail')
|
||||
await router.isReady()
|
||||
wrapper = mount(MailVacationNotice, {
|
||||
props: { vacation: value },
|
||||
global: { plugins: [router], provide: { [CLOCK as symbol]: clock } },
|
||||
})
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const detail = (view: VueWrapper) => view.find('[data-vacation-notice] .vacation-notice__detail')
|
||||
|
||||
it('끝나는 시각이 지나면 1분 안에 안내가 사라진다 — 새로고침을 기다리지 않는다', async () => {
|
||||
const clock = createManualClock(new Date(2026, 10, 3, 23, 59, 30).getTime())
|
||||
const view = await mountNotice(vacation({ toDate: endOfDayIso('2026-11-03') }), clock)
|
||||
expect(detail(view).text()).toBe('11월 3일까지 · 설정')
|
||||
|
||||
clock.advance(ONE_MINUTE_MS)
|
||||
await nextTick()
|
||||
expect(view.find('[data-vacation-notice]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('시작 전에는 기간을 보이다가, 시작하면 끝나는 날 문구로 바뀐다', async () => {
|
||||
const clock = createManualClock(new Date(2026, 9, 29, 23, 59, 30).getTime())
|
||||
const view = await mountNotice(
|
||||
vacation({ fromDate: startOfDayIso('2026-10-30'), toDate: endOfDayIso('2026-11-03') }),
|
||||
clock,
|
||||
)
|
||||
expect(detail(view).text()).toBe('10월 30일 ~ 11월 3일 · 설정')
|
||||
|
||||
clock.advance(ONE_MINUTE_MS)
|
||||
await nextTick()
|
||||
expect(detail(view).text()).toBe('11월 3일까지 · 설정')
|
||||
})
|
||||
|
||||
it('타이머는 하나만 걸어 두고 이어 걸며, 화면을 떠나면 거둔다', async () => {
|
||||
const clock = createManualClock(new Date(2026, 9, 29, 9, 0).getTime())
|
||||
const view = await mountNotice(vacation({}), clock)
|
||||
expect(detail(view).text()).toBe('끌 때까지 · 설정')
|
||||
expect(clock.pendingCount()).toBe(1)
|
||||
|
||||
clock.advance(ONE_MINUTE_MS * 3)
|
||||
expect(clock.pendingCount()).toBe(1)
|
||||
|
||||
view.unmount()
|
||||
wrapper = null
|
||||
expect(clock.pendingCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
// 부재중 자동 응답 안내 — 메일함 패널 아래(사용량 위) (디자인 원본 MailboxManage · README "4단계 › 부재중 자동 응답")
|
||||
// 바탕 #FFF7E6 · #7A5200, 높이 44 이상: "부재중 자동 응답 켜짐" / "11월 3일까지 · 설정" — 누르면 설정 › 메일 › 부재중 자동 응답으로.
|
||||
// 켜져 있는 동안만 보인다 — 꺼져 있거나 끝나는 날이 지났으면 그리지 않는다 (아직 시작하지 않았으면 시작일을 보인다, mail-vacation.ts).
|
||||
// 시각에 따라 바뀌므로 1분마다 다시 계산한다 (useClock — 테스트는 손으로 돌리는 시계)
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { useClock, type ClockTimer } from '@/composables/useClock'
|
||||
import { VACATION_NOTICE_REFRESH_MS, VACATION_TEXT } from '@/shared/constants/mail-vacation'
|
||||
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
|
||||
import type { MailVacation } from '@/shared/types/mail'
|
||||
import { vacationNoticeText } from '@/shared/utils/mail-vacation'
|
||||
|
||||
interface Props {
|
||||
vacation: MailVacation | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const clock = useClock()
|
||||
|
||||
/** 안내를 계산한 시각 — 1분마다 바뀐다 */
|
||||
const now = ref<number>(clock.now())
|
||||
let timer: ClockTimer | null = null
|
||||
|
||||
const detail = computed(() => vacationNoticeText(props.vacation, new Date(now.value)))
|
||||
|
||||
function tick() {
|
||||
now.value = clock.now()
|
||||
timer = clock.setTimeout(tick, VACATION_NOTICE_REFRESH_MS)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
timer = clock.setTimeout(tick, VACATION_NOTICE_REFRESH_MS)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer !== null) clock.clearTimeout(timer)
|
||||
timer = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink
|
||||
v-if="detail"
|
||||
:to="{ name: SETTINGS_ROUTES.vacation }"
|
||||
class="vacation-notice"
|
||||
data-vacation-notice
|
||||
>
|
||||
<AppIcon
|
||||
name="calendar"
|
||||
:size="15"
|
||||
class="vacation-notice__icon"
|
||||
/>
|
||||
<span class="vacation-notice__text">
|
||||
<span class="vacation-notice__title">{{ VACATION_TEXT.noticeTitle }}</span>
|
||||
<span class="vacation-notice__detail">{{ detail }}</span>
|
||||
</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vacation-notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-8);
|
||||
min-height: var(--size-touch);
|
||||
padding: var(--space-8) var(--space-10);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-warning-surface);
|
||||
color: var(--color-warning-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.vacation-notice:hover {
|
||||
color: var(--color-warning-text);
|
||||
}
|
||||
|
||||
.vacation-notice:hover .vacation-notice__detail {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.vacation-notice__icon {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.vacation-notice__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: var(--line-height-heading);
|
||||
}
|
||||
|
||||
.vacation-notice__title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vacation-notice__detail {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 지우기 확인 (디자인 원본 MailboxManage, 공용 모양 ModalDialog — role=alertdialog)
|
||||
// - 하위 메일함이 있으면 지울 수 없다: "‘프로젝트’ 메일함을 지울 수 없습니다" · "하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요." · [확인]
|
||||
// - 아니면: "‘거래처’ 메일함을 지울까요?" · "안의 메일 12통은 휴지통으로 옮깁니다. 지운 메일함은 되돌릴 수 없습니다."
|
||||
// (빈 메일함은 "빈 메일함입니다. …") · [지우기](빨강 채움) [취소] — 열리면 「취소」에 초점 (실수로 Enter 를 눌러도 지워지지 않게)
|
||||
// - 휴지통을 비우는 동안은 [지우기]를 누를 수 없다 (blocked — 비우기가 옮겨 간 메일까지 영구 삭제하지 않게)
|
||||
// 고정 id — #mailbox-delete-dialog · #mailbox-delete-title · #mailbox-delete-message · #mailbox-delete-confirm · #mailbox-delete-cancel
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import { MAILBOX_DIALOG_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
mailboxName: string
|
||||
/** 안의 메일 수 */
|
||||
messageCount: number
|
||||
/** 하위 메일함이 있다 — 지울 수 없다고 알린다 */
|
||||
hasChildren: boolean
|
||||
/** 지금은 시작할 수 없다 (휴지통을 비우는 중) — [지우기]를 끈다 */
|
||||
blocked?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'confirm'): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
blocked: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const title = computed(() =>
|
||||
props.hasChildren ? MAILBOX_DIALOG_TEXT.blockedTitle(props.mailboxName) : MAILBOX_DIALOG_TEXT.deleteTitle(props.mailboxName),
|
||||
)
|
||||
const message = computed(() => {
|
||||
if (props.hasChildren) return MAILBOX_DIALOG_TEXT.blockedMessage
|
||||
return props.messageCount > 0
|
||||
? MAILBOX_DIALOG_TEXT.deleteMessage(formatCount(props.messageCount))
|
||||
: MAILBOX_DIALOG_TEXT.deleteEmptyMessage
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
id="mailbox-delete-dialog"
|
||||
:open="open"
|
||||
role="alertdialog"
|
||||
:title="title"
|
||||
:message="message"
|
||||
title-id="mailbox-delete-title"
|
||||
message-id="mailbox-delete-message"
|
||||
@cancel="emit('cancel')"
|
||||
>
|
||||
<template #actions>
|
||||
<button
|
||||
v-if="hasChildren"
|
||||
id="mailbox-delete-cancel"
|
||||
type="button"
|
||||
class="modal-dialog__action"
|
||||
autofocus
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ MAILBOX_DIALOG_TEXT.blockedConfirm }}
|
||||
</button>
|
||||
<template v-else>
|
||||
<button
|
||||
id="mailbox-delete-confirm"
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--danger"
|
||||
:disabled="blocked"
|
||||
@click="emit('confirm')"
|
||||
>
|
||||
{{ MAILBOX_DIALOG_TEXT.deleteConfirm }}
|
||||
</button>
|
||||
<button
|
||||
id="mailbox-delete-cancel"
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
autofocus
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ MAILBOX_DIALOG_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 패널의 대화상자 — ⋯ 메뉴 · [+ 새 메일함]이 연다 (디자인 원본 MailboxManage · useMailboxActions)
|
||||
// - 새 메일함 · 하위 메일함 · 이름 바꾸기: 이름 대화상자. 같은 이름(MAIL_009) · 형식(VAL_001)은 칸 아래에 보이고 대화상자를 둔다.
|
||||
// 메일함이 그사이 사라졌으면(RES_001 — 알림은 인터셉터) 닫고 메일함 목록을 다시 맞춘다. 그 밖의 실패는 알림을 보고 다시 누를 수 있게 둔다.
|
||||
// 이름을 바꾸지 않았으면 보내지 않고 닫는다. 기다리는 사이 화면이 사라졌으면 칸 아래에 보일 실패를 알림으로 알린다 (notifyApiError)
|
||||
// - 지우기: 하위 메일함이 있으면 지울 수 없다고 알린다. 확인하면 닫고 지우기를 시작한다 (진행은 알림으로)
|
||||
// - 비우기: 확인하면 닫고 비우기를 시작한다
|
||||
// - 휴지통 비우기 · 메일함 지우기는 서로 도는 동안 시작할 수 없다 — 확인 단추를 끄고, 누르는 순간에도 다시 본다 (useMailboxActions 의 isBlocked)
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import MailboxDeleteDialog from '@/components/mail/MailboxDeleteDialog.vue'
|
||||
import MailboxEmptyDialog from '@/components/mail/MailboxEmptyDialog.vue'
|
||||
import MailboxNameDialog from '@/components/mail/MailboxNameDialog.vue'
|
||||
import type { MailboxDialogRequest } from '@/components/mail/mailbox-dialog'
|
||||
import { notifyApiError } from '@/composables/useApi'
|
||||
import { useMailboxActions } from '@/composables/useMailboxActions'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { hasChildMailbox } from '@/shared/utils/mailbox'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
request: MailboxDialogRequest | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const actions = useMailboxActions()
|
||||
|
||||
const pending = ref<boolean>(false)
|
||||
const nameError = ref<string | null>(null)
|
||||
/** 화면(메일 모듈)이 사라졌다 — 칸 아래에 보일 곳이 없다 */
|
||||
let unmounted = false
|
||||
|
||||
const nameMode = computed<'create' | 'rename' | null>(() => {
|
||||
const kind = props.request?.kind
|
||||
return kind === 'create' || kind === 'rename' ? kind : null
|
||||
})
|
||||
/** 대상 메일함 — 연 뒤에 바뀐 개수 · 이름을 쓰도록 지금 목록에서 다시 찾는다 */
|
||||
const target = computed<Mailbox | null>(() => {
|
||||
const request = props.request
|
||||
if (!request || request.kind === 'create') return null
|
||||
return mailStore.mailboxes.find((mailbox) => mailbox.id === request.mailbox.id) ?? request.mailbox
|
||||
})
|
||||
const parentName = computed(() => (props.request?.kind === 'create' ? (props.request.parent?.name ?? null) : null))
|
||||
const hasChildren = computed(() => (target.value ? hasChildMailbox(mailStore.mailboxes, target.value.id) : false))
|
||||
/** 지금은 시작할 수 없다 — 휴지통 비우기 ↔ 메일함 지우기 */
|
||||
const deleteBlocked = computed(() => (target.value ? actions.isBlocked('delete', target.value) : false))
|
||||
const emptyBlocked = computed(() => (target.value ? actions.isBlocked('empty', target.value) : false))
|
||||
|
||||
function close() {
|
||||
pending.value = false
|
||||
nameError.value = null
|
||||
emit('close')
|
||||
}
|
||||
|
||||
async function onSubmitName(name: string) {
|
||||
const request = props.request
|
||||
if (!request || pending.value) return
|
||||
if (request.kind === 'rename' && name === target.value?.name) {
|
||||
close()
|
||||
return
|
||||
}
|
||||
pending.value = true
|
||||
const result =
|
||||
request.kind === 'rename'
|
||||
? await actions.rename(target.value ?? request.mailbox, name)
|
||||
: request.kind === 'create'
|
||||
? await actions.create(name, request.parent?.id ?? null)
|
||||
: null
|
||||
if (unmounted) {
|
||||
// 칸 아래에 보일 곳이 사라졌다 — 끈 알림을 띄운다 (성공 알림은 이미 떴다)
|
||||
if (result?.status === 'failed' && result.inline) notifyApiError(result.error)
|
||||
return
|
||||
}
|
||||
// 기다리는 사이 닫았거나 다른 대화상자를 열었으면 그 대화상자를 건드리지 않는다 (결과 알림은 이미 떴다)
|
||||
if (props.request !== request) return
|
||||
pending.value = false
|
||||
if (!result || result.status !== 'failed') {
|
||||
close()
|
||||
return
|
||||
}
|
||||
if (result.inline) {
|
||||
nameError.value = result.error.message
|
||||
return
|
||||
}
|
||||
if (result.error.code === 'RES_001') {
|
||||
void mailStore.loadMailboxes({ background: true })
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
function onConfirmDelete() {
|
||||
const mailbox = target.value
|
||||
// 누르는 순간 다시 본다 — 그사이 휴지통 비우기가 시작됐으면 대화상자를 그대로 둔다
|
||||
if (!mailbox || deleteBlocked.value) return
|
||||
close()
|
||||
void actions.remove(mailbox)
|
||||
}
|
||||
|
||||
function onConfirmEmpty() {
|
||||
const mailbox = target.value
|
||||
if (!mailbox || emptyBlocked.value) return
|
||||
close()
|
||||
void actions.empty(mailbox)
|
||||
}
|
||||
|
||||
// 다른 대화상자로 바뀌면 지난 오류를 지운다
|
||||
watch(
|
||||
() => props.request,
|
||||
() => {
|
||||
nameError.value = null
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MailboxNameDialog
|
||||
:open="nameMode !== null"
|
||||
:mode="nameMode ?? 'create'"
|
||||
:parent-name="parentName"
|
||||
:initial-name="nameMode === 'rename' ? (target?.name ?? '') : ''"
|
||||
:pending="pending"
|
||||
:error="nameError"
|
||||
@submit="onSubmitName"
|
||||
@cancel="close"
|
||||
@clear-error="nameError = null"
|
||||
/>
|
||||
<MailboxDeleteDialog
|
||||
:open="request?.kind === 'delete'"
|
||||
:mailbox-name="target?.name ?? ''"
|
||||
:message-count="target?.totalCount ?? 0"
|
||||
:has-children="hasChildren"
|
||||
:blocked="deleteBlocked"
|
||||
@confirm="onConfirmDelete"
|
||||
@cancel="close"
|
||||
/>
|
||||
<MailboxEmptyDialog
|
||||
:open="request?.kind === 'empty'"
|
||||
:mailbox-name="target?.name ?? ''"
|
||||
:message-count="target?.totalCount ?? 0"
|
||||
:blocked="emptyBlocked"
|
||||
id-prefix="mailbox-empty"
|
||||
@confirm="onConfirmEmpty"
|
||||
@cancel="close"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,189 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import {
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MessageList from './MessageList.vue'
|
||||
import MessageTable from './MessageTable.vue'
|
||||
|
||||
// 휴지통 · 스팸메일함 목록 머리의 「비우기」 (디자인 원본 MailboxManage · 계약 2절 "메일함 관리")
|
||||
|
||||
function box(id: string, role: Mailbox['role'], name: string, totalCount: number): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount, unreadCount: 0 }
|
||||
}
|
||||
|
||||
function summary(id: string, mailboxId: string): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: [mailboxId],
|
||||
from: { name: '호스팅 안내', email: 'ad@example.test' },
|
||||
to: [],
|
||||
subject: `제목 ${id}`,
|
||||
preview: '',
|
||||
receivedAt: '2026-10-21T01:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
}
|
||||
}
|
||||
|
||||
describe('목록 머리의 휴지통 · 스팸메일함 비우기', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
let listed: MessageSummary[]
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountList(
|
||||
mailbox: Mailbox,
|
||||
view: 'split' | 'list' = 'list',
|
||||
handler?: (request: MockRequest) => MockResult | Promise<MockResult> | undefined,
|
||||
) {
|
||||
listed = [summary('t1', mailbox.id), summary('t2', mailbox.id)]
|
||||
requests = installMockApi((request) => {
|
||||
const custom = handler?.(request)
|
||||
if (custom) return custom
|
||||
if (request.method === 'DELETE') {
|
||||
listed = []
|
||||
return ok({ processed: 2, remaining: 0 })
|
||||
}
|
||||
if (request.url === '/messages') return ok({ items: listed, pageInfo: { hasNext: false, nextCursor: null, total: listed.length } })
|
||||
if (request.url === '/mailboxes') return ok({ items: useMailStore().mailboxes, pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
|
||||
return ok(null)
|
||||
})
|
||||
const store = useMailStore()
|
||||
store.mailboxes = [box('inbox', 'inbox', '받은편지함', 3), mailbox]
|
||||
store.currentMailboxId = mailbox.id
|
||||
store.messages = listed
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: listed.length }
|
||||
store.listStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push(`/mail/${mailbox.id}`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(view === 'split' ? MessageList : MessageTable, {
|
||||
global: { plugins: [pinia, router] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
return { wrapper, store }
|
||||
}
|
||||
|
||||
it('휴지통 — 빨간 글자 「휴지통 비우기」, 확인(처음 초점 「취소」) 뒤 비우고 목록을 다시 받는다 · 되돌리기 없는 알림', async () => {
|
||||
const { wrapper, store } = await mountList(box('trash', 'trash', '휴지통', 2))
|
||||
const empty = wrapper.get('[data-mailbox-empty]')
|
||||
expect(empty.text()).toBe('휴지통 비우기')
|
||||
|
||||
await empty.trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.getElementById('list-empty-dialog')?.hasAttribute('open')).toBe(true)
|
||||
expect(wrapper.get('#list-empty-title').text()).toBe('휴지통의 메일 2통을 모두 영구 삭제할까요?')
|
||||
expect(document.activeElement?.id).toBe('list-empty-cancel')
|
||||
|
||||
await wrapper.get('#list-empty-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([
|
||||
'/mailboxes/trash/messages',
|
||||
])
|
||||
expect(store.messages).toEqual([])
|
||||
expect(useToastStore().toasts.map((toast) => [toast.message, toast.action])).toEqual([
|
||||
['휴지통을 비웠습니다 — 메일 2통.', null],
|
||||
])
|
||||
// 비었으니 단추도 사라진다
|
||||
expect(wrapper.find('[data-mailbox-empty]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('스팸메일함 · 분할 보기 — 「스팸메일함 비우기」는 아이콘만(이름은 aria-label · 툴팁)', async () => {
|
||||
const { wrapper } = await mountList(box('junk', 'junk', '스팸메일함', 2), 'split')
|
||||
const empty = wrapper.get('[data-mailbox-empty]')
|
||||
|
||||
expect(empty.classes()).toContain('empty-button--split')
|
||||
expect(empty.attributes('aria-label')).toBe('스팸메일함 비우기')
|
||||
expect(empty.attributes('title')).toBe('스팸메일함 비우기')
|
||||
})
|
||||
|
||||
it('빈 휴지통 · 다른 메일함에는 없다', async () => {
|
||||
const emptyTrash = await mountList(box('trash', 'trash', '휴지통', 0))
|
||||
expect(emptyTrash.wrapper.find('[data-mailbox-empty]').exists()).toBe(false)
|
||||
emptyTrash.wrapper.unmount()
|
||||
|
||||
const inbox = await mountList(box('archive', 'archive', '보관함', 2))
|
||||
expect(inbox.wrapper.find('[data-mailbox-empty]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
const dialogOpen = () => document.getElementById('list-empty-dialog')?.hasAttribute('open') ?? false
|
||||
const deletes = () => requests.filter((request) => request.method === 'DELETE').map((request) => request.url)
|
||||
|
||||
it('확인창은 연 때의 메일함을 붙잡는다 — 연 채 보는 메일함이 바뀌면(뒤로 가기 등) 닫고, 연 적 없는 메일함을 비우지 않는다', async () => {
|
||||
const { wrapper, store } = await mountList(box('trash', 'trash', '휴지통', 2))
|
||||
store.mailboxes = [...store.mailboxes, box('junk', 'junk', '스팸메일함', 5)]
|
||||
await wrapper.get('[data-mailbox-empty]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialogOpen()).toBe(true)
|
||||
expect(wrapper.get('#list-empty-title').text()).toBe('휴지통의 메일 2통을 모두 영구 삭제할까요?')
|
||||
|
||||
// 확인창을 연 채 뒤로 가기로 스팸메일함을 보게 되었다
|
||||
store.currentMailboxId = 'junk'
|
||||
await flushPromises()
|
||||
expect(dialogOpen()).toBe(false)
|
||||
expect(deletes()).toEqual([])
|
||||
|
||||
// 다시 열면 지금 메일함의 창 — 확인하면 그 메일함만 비운다
|
||||
await wrapper.get('[data-mailbox-empty]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('#list-empty-title').text()).toBe('스팸메일함의 메일 5통을 모두 영구 삭제할까요?')
|
||||
await wrapper.get('#list-empty-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(deletes()).toEqual(['/mailboxes/junk/messages'])
|
||||
})
|
||||
|
||||
it('메일함을 지우는 동안 「휴지통 비우기」는 누를 수 없다 — 꺼도 초점을 잃지 않게 aria-disabled', async () => {
|
||||
let finish: (result: MockResult) => void = () => undefined
|
||||
const deleting = new Promise<MockResult>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
const { wrapper, store } = await mountList(box('trash', 'trash', '휴지통', 2), 'list', (request) =>
|
||||
request.url === '/mailboxes/c' ? deleting : undefined,
|
||||
)
|
||||
const custom = box('c', null, '거래처', 12)
|
||||
store.mailboxes = [...store.mailboxes, custom]
|
||||
const running = store.runMailboxOperation('delete', custom, () => undefined)
|
||||
await flushPromises()
|
||||
|
||||
const empty = wrapper.get('[data-mailbox-empty]')
|
||||
expect(empty.attributes('aria-disabled')).toBe('true')
|
||||
expect(empty.attributes('disabled')).toBeUndefined()
|
||||
await empty.trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialogOpen()).toBe(false)
|
||||
|
||||
finish(ok({ deleted: true, processed: 12, movedToTrash: 12, remaining: 0 }))
|
||||
await running
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[data-mailbox-empty]').attributes('aria-disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
// 휴지통 · 스팸메일함 목록 머리의 「휴지통 비우기」 · 「스팸메일함 비우기」 (디자인 원본 MailboxManage — 빨강 글자 · 44)
|
||||
// 확인 대화상자(MailboxEmptyDialog — 처음 초점 「취소」)를 거쳐 비운다 (useMailboxActions — 진행은 알림으로).
|
||||
// - 확인창은 연 때의 메일함을 붙잡는다: 열어 둔 채 뒤로 가기 등으로 보는 메일함이 바뀌면 창을 닫는다 — 연 적 없는 메일함을 비우지 않게
|
||||
// - 비우는 중 · 메일함을 지우는 중(휴지통 — 함께 돌 수 없다)에는 누를 수 없다. 꺼도 초점은 잃지 않게 aria-disabled 로 둔다
|
||||
// (끝나서 단추가 사라지면 useMailboxActions 가 메일함 패널의 지금 메일함으로 초점을 옮긴다)
|
||||
// 분할 보기의 목록 칸(384)은 머리가 좁아 아이콘만(40×44 — 이름은 툴팁 · aria-label), 목록 보기는 아이콘 + 이름
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailboxEmptyDialog from '@/components/mail/MailboxEmptyDialog.vue'
|
||||
import { useMailboxActions } from '@/composables/useMailboxActions'
|
||||
import { MAILBOX_MENU_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
mailbox: Mailbox
|
||||
variant: 'split' | 'list'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const actions = useMailboxActions()
|
||||
const authStore = useAuthStore()
|
||||
const mailStore = useMailStore()
|
||||
|
||||
/** 확인창을 연 메일함 — 열려 있는 동안만 */
|
||||
const target = shallowRef<Mailbox | null>(null)
|
||||
|
||||
const label = computed(() => (props.mailbox.role === 'junk' ? MAILBOX_MENU_TEXT.emptyJunk : MAILBOX_MENU_TEXT.emptyTrash))
|
||||
/** 지금은 누를 수 없다 — 비우는 중 · 함께 돌 수 없는 처리가 도는 중 */
|
||||
const unavailable = computed(
|
||||
() => actions.isRunning('empty', props.mailbox.id) || actions.isBlocked('empty', props.mailbox),
|
||||
)
|
||||
/** 확인창에 보일 메일함 — 연 뒤에 바뀐 개수를 쓰도록 지금 목록에서 다시 찾는다 */
|
||||
const shown = computed<Mailbox | null>(() => {
|
||||
const opened = target.value
|
||||
if (!opened) return null
|
||||
return mailStore.mailboxes.find((mailbox) => mailbox.id === opened.id) ?? opened
|
||||
})
|
||||
const blocked = computed(() => (shown.value ? actions.isBlocked('empty', shown.value) : false))
|
||||
|
||||
function open() {
|
||||
if (unavailable.value) return
|
||||
target.value = props.mailbox
|
||||
}
|
||||
|
||||
function close() {
|
||||
target.value = null
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
const mailbox = shown.value
|
||||
// 누르는 순간 다시 본다 — 그사이 메일함 지우기가 시작됐으면 창을 그대로 둔다
|
||||
if (!mailbox || blocked.value) return
|
||||
close()
|
||||
void actions.empty(mailbox)
|
||||
}
|
||||
|
||||
// 보는 메일함이 바뀌면(뒤로 가기 등) 확인창을 닫는다 — 잡아 둔 메일함과 다른 메일함의 단추가 되었다
|
||||
watch(
|
||||
() => props.mailbox.id,
|
||||
(id) => {
|
||||
if (target.value && target.value.id !== id) close()
|
||||
},
|
||||
)
|
||||
|
||||
// 세션이 바뀌면(로그아웃 · 세션 만료) 확인을 닫는다 — 세션 만료 대화상자와 겹치지 않게
|
||||
watch(() => authStore.generation, close)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:class="['empty-button', `empty-button--${variant}`]"
|
||||
:aria-label="label"
|
||||
:title="label"
|
||||
:aria-disabled="unavailable ? 'true' : undefined"
|
||||
data-mailbox-empty
|
||||
@click="open"
|
||||
>
|
||||
<AppIcon
|
||||
name="trash"
|
||||
:size="15"
|
||||
/>
|
||||
<span class="empty-button__label">{{ label }}</span>
|
||||
</button>
|
||||
<MailboxEmptyDialog
|
||||
:open="shown !== null"
|
||||
:mailbox-name="shown?.name ?? ''"
|
||||
:message-count="shown?.totalCount ?? 0"
|
||||
:blocked="blocked"
|
||||
id-prefix="list-empty"
|
||||
@confirm="onConfirm"
|
||||
@cancel="close"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty-button {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-6);
|
||||
min-width: var(--size-icon-button);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-12);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-danger);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty-button:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-danger-surface);
|
||||
color: var(--color-danger-strong);
|
||||
}
|
||||
|
||||
.empty-button[aria-disabled='true'] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 분할 보기의 목록 칸 — 아이콘만 40×44 */
|
||||
.empty-button--split {
|
||||
width: var(--size-icon-button);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.empty-button--split .empty-button__label {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
// 휴지통 · 스팸메일함 비우기 확인 (디자인 원본 MailboxManage, 공용 모양 ModalDialog — role=alertdialog)
|
||||
// "휴지통의 메일 21통을 모두 영구 삭제할까요?" · "지운 메일은 되돌릴 수 없습니다." · [영구 삭제](빨강 채움) [취소]
|
||||
// 열리면 「취소」에 초점이 간다 — 실수로 Enter · Space 를 눌러도 지워지지 않게. Esc 는 취소와 같다.
|
||||
// 쓰는 곳: 메일함 패널의 ⋯ 메뉴 · 휴지통 / 스팸메일함 목록 머리의 「비우기」 — 둘이 함께 보일 수 있어 id 앞부분(idPrefix)을 따로 받는다
|
||||
// 메일함을 지우는 동안의 휴지통 비우기는 [영구 삭제]를 누를 수 없다 (blocked — 지우는 메일함의 메일이 휴지통에 들어가자마자 지워지지 않게)
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import { MAILBOX_DIALOG_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
mailboxName: string
|
||||
/** 안의 메일 수 */
|
||||
messageCount: number
|
||||
/** 제목 · 글 · 단추의 id 앞부분 — 화면에 하나뿐인 값 */
|
||||
idPrefix: string
|
||||
/** 지금은 시작할 수 없다 (메일함을 지우는 중의 휴지통) — [영구 삭제]를 끈다 */
|
||||
blocked?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'confirm'): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
blocked: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
:id="`${idPrefix}-dialog`"
|
||||
:open="open"
|
||||
role="alertdialog"
|
||||
:title="MAILBOX_DIALOG_TEXT.emptyTitle(mailboxName, formatCount(messageCount))"
|
||||
:message="MAILBOX_DIALOG_TEXT.emptyMessage"
|
||||
:title-id="`${idPrefix}-title`"
|
||||
:message-id="`${idPrefix}-message`"
|
||||
@cancel="emit('cancel')"
|
||||
>
|
||||
<template #actions>
|
||||
<button
|
||||
:id="`${idPrefix}-confirm`"
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--danger"
|
||||
:disabled="blocked"
|
||||
@click="emit('confirm')"
|
||||
>
|
||||
{{ MAILBOX_DIALOG_TEXT.emptyConfirm }}
|
||||
</button>
|
||||
<button
|
||||
:id="`${idPrefix}-cancel`"
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
autofocus
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ MAILBOX_DIALOG_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 행의 ⋯ 단추와 메뉴 (디자인 원본 MailboxManage · README "4단계 › 메일함 관리")
|
||||
// - 단추 32×40 — 목록 표의 별 칸과 같은 밀도 예외(--size-mailbox-more). 행에 마우스를 올리거나 초점이 가면 보이고, 메뉴가 열린 동안은 계속 보인다
|
||||
// (보임 · 숨김은 행 MailboxPanelRow 가 정한다 — 열림을 open-change 로 알린다)
|
||||
// - 메뉴는 편집기 메뉴(EditorMenu — 항목 40)를 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에, 마우스로 열면 메뉴 자체에 초점.
|
||||
// Esc 는 메뉴만 닫고 이 단추로 돌아간다. 항목을 고르면 메뉴를 닫고 이 단추에 초점을 둔 뒤 알린다 —
|
||||
// 대화상자가 열리면 닫힐 때 초점이 이 단추로 돌아온다
|
||||
// - 메뉴는 아래로 열되, 메일함 패널(스크롤되는 칸)의 보이는 영역 아래가 모자라면 위로 연다 — 아래쪽 줄의 메뉴가 잘리지 않게 (popup-placement.ts)
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
|
||||
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
|
||||
import { MAILBOX_PANEL_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import { opensUpward } from '@/shared/utils/popup-placement'
|
||||
|
||||
interface Props {
|
||||
/** 메일함 이름 — 단추 · 메뉴의 이름 */
|
||||
mailboxName: string
|
||||
items: readonly EditorMenuItem[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'select', id: string): void
|
||||
(e: 'open-change', open: boolean): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 잘라 내는 overflow — 이 조상 밖은 보이지 않는다 */
|
||||
const CLIPPING_OVERFLOW = new Set(['auto', 'scroll', 'hidden', 'clip', 'overlay'])
|
||||
|
||||
const buttonRef = ref<HTMLButtonElement | null>(null)
|
||||
const menuRef = ref<InstanceType<typeof EditorMenu> | null>(null)
|
||||
const open = ref<boolean>(false)
|
||||
/** 키보드로 열었다 — 첫 항목에 초점 */
|
||||
const byKeyboard = ref<boolean>(false)
|
||||
/** 위로 연다 — 아래가 모자랄 때 */
|
||||
const upward = ref<boolean>(false)
|
||||
|
||||
const trigger = computed<HTMLElement | null>(() => buttonRef.value)
|
||||
|
||||
function setOpen(next: boolean) {
|
||||
if (open.value === next) return
|
||||
open.value = next
|
||||
emit('open-change', next)
|
||||
}
|
||||
|
||||
/** 단추가 보일 수 있는 세로 영역 — 화면과 잘라 내는 조상(메일함 패널 등)들이 겹치는 부분 */
|
||||
function visibleArea(element: Element): { top: number; bottom: number } {
|
||||
let top = 0
|
||||
let bottom = window.innerHeight
|
||||
for (let node = element.parentElement; node; node = node.parentElement) {
|
||||
if (!CLIPPING_OVERFLOW.has(getComputedStyle(node).overflowY)) continue
|
||||
const rect = node.getBoundingClientRect()
|
||||
top = Math.max(top, rect.top)
|
||||
bottom = Math.min(bottom, rect.bottom)
|
||||
}
|
||||
return { top, bottom }
|
||||
}
|
||||
|
||||
/** 아래로 그린 메뉴를 재어 위로 열지 정한다 */
|
||||
function place() {
|
||||
const button = buttonRef.value
|
||||
const menu = menuRef.value?.$el
|
||||
if (!button || !(menu instanceof HTMLElement)) return
|
||||
const buttonRect = button.getBoundingClientRect()
|
||||
const menuRect = menu.getBoundingClientRect()
|
||||
const area = visibleArea(button)
|
||||
upward.value = opensUpward({
|
||||
triggerTop: buttonRect.top,
|
||||
triggerBottom: buttonRect.bottom,
|
||||
popupHeight: menuRect.height,
|
||||
areaTop: area.top,
|
||||
areaBottom: area.bottom,
|
||||
gap: Math.max(0, menuRect.top - buttonRect.bottom),
|
||||
})
|
||||
}
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
if (open.value) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
// detail 0 — Enter · Space 로 눌렀다
|
||||
byKeyboard.value = event.detail === 0
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'ArrowDown' || open.value) return
|
||||
event.preventDefault()
|
||||
byKeyboard.value = true
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
function onClose(restoreFocus: boolean) {
|
||||
setOpen(false)
|
||||
if (restoreFocus) buttonRef.value?.focus()
|
||||
}
|
||||
|
||||
function onSelect(id: string) {
|
||||
setOpen(false)
|
||||
buttonRef.value?.focus()
|
||||
emit('select', id)
|
||||
}
|
||||
|
||||
// 메뉴를 그린 직후(초점을 주기 전) 잰다 — 닫으면 다음에 열 때 다시 아래로 재기 시작한다
|
||||
watch(
|
||||
open,
|
||||
(isOpen) => {
|
||||
upward.value = false
|
||||
if (isOpen) place()
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mailbox-more">
|
||||
<button
|
||||
ref="buttonRef"
|
||||
type="button"
|
||||
class="mailbox-more__button"
|
||||
:aria-label="MAILBOX_PANEL_TEXT.more(props.mailboxName)"
|
||||
:title="MAILBOX_PANEL_TEXT.more(props.mailboxName)"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="open"
|
||||
data-mailbox-more
|
||||
@click="onClick"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<AppIcon
|
||||
name="more-horizontal"
|
||||
:size="15"
|
||||
/>
|
||||
</button>
|
||||
<EditorMenu
|
||||
v-if="open"
|
||||
ref="menuRef"
|
||||
:class="['mailbox-more__menu', { 'mailbox-more__menu--up': upward }]"
|
||||
:label="MAILBOX_PANEL_TEXT.menu(props.mailboxName)"
|
||||
:items="items"
|
||||
:trigger="trigger"
|
||||
align="end"
|
||||
:focus-content="!byKeyboard"
|
||||
@select="onSelect"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mailbox-more {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* 32×40 — 밀도 예외 (tokens.css --size-mailbox-more · 행 높이 --size-row-min) */
|
||||
.mailbox-more__button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-mailbox-more);
|
||||
height: var(--size-row-min);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.mailbox-more__button:hover,
|
||||
.mailbox-more__button[aria-expanded='true'] {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
/* 메뉴 폭 196 — 행 오른쪽 끝에 맞춘다 (디자인 원본 MailboxManage) */
|
||||
.mailbox-more__menu {
|
||||
min-width: 12.25rem;
|
||||
}
|
||||
|
||||
/* 아래가 모자라면 위로 — 단추 위쪽에 같은 간격을 두고 연다 (편집기 메뉴의 아래 열림보다 앞선다) */
|
||||
.mailbox-more .mailbox-more__menu--up {
|
||||
top: auto;
|
||||
bottom: calc(100% + var(--space-4));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 이름 대화상자 — 새 메일함 · "‘프로젝트’ 안에 새 메일함" · 메일함 이름 바꾸기 (디자인 원본 MailboxManage, 공용 모양 ModalDialog)
|
||||
// 이름 칸(44 · 64자) + 안내 "64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다." — 오류는 그 자리에 빨강으로
|
||||
// (같은 이름 MAIL_009 · 형식 VAL_001 — 문구는 에러 코드 사전). 열리면 이름 칸에 초점(이름 바꾸기는 이름 전체를 고른다).
|
||||
// Enter 는 [만들기 · 바꾸기]와 같다 (한글 조합 중의 Enter 는 글자 확정이라 무시한다). Esc 는 취소와 같다.
|
||||
// 고정 id — #mailbox-name-dialog · #mailbox-name-title · #mailbox-name-input · #mailbox-name-help · #mailbox-name-confirm · #mailbox-name-cancel
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { getErrorMessage } from '@/composables/useApi'
|
||||
import { MAILBOX_DIALOG_TEXT, MAILBOX_NAME_MAX_LENGTH } from '@/shared/constants/mailbox-manage'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
import { mailboxNameProblem } from '@/shared/utils/mailbox'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
mode: 'create' | 'rename'
|
||||
/** 하위 메일함을 만들 곳의 이름 — 맨 위면 null */
|
||||
parentName?: string | null
|
||||
/** 처음 이름 — 이름 바꾸기는 지금 이름 */
|
||||
initialName?: string
|
||||
/** 보내는 중 — 단추를 다시 누를 수 없다 */
|
||||
pending?: boolean
|
||||
/** 서버가 거절한 까닭(사전 문구) — 이름을 고치면 부모가 지운다(clear-error) */
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', name: string): void
|
||||
(e: 'cancel'): void
|
||||
(e: 'clear-error'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
parentName: null,
|
||||
initialName: '',
|
||||
pending: false,
|
||||
error: null,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const name = ref<string>(props.initialName)
|
||||
/** 보내기 전에 막은 형식 오류 ('/' · 길이) */
|
||||
const formatError = ref<string | null>(null)
|
||||
|
||||
const title = computed(() => {
|
||||
if (props.mode === 'rename') return MAILBOX_DIALOG_TEXT.renameTitle
|
||||
return props.parentName ? MAILBOX_DIALOG_TEXT.createChildTitle(props.parentName) : MAILBOX_DIALOG_TEXT.createTitle
|
||||
})
|
||||
const confirmLabel = computed(() => (props.mode === 'rename' ? MAILBOX_DIALOG_TEXT.rename : MAILBOX_DIALOG_TEXT.create))
|
||||
const shownError = computed(() => formatError.value ?? props.error)
|
||||
/** 이름을 비웠으면 누를 수 없다 */
|
||||
const canSubmit = computed(() => mailboxNameProblem(name.value) !== 'empty' && !props.pending)
|
||||
|
||||
function onInput(event: Event) {
|
||||
name.value = (event.target as HTMLInputElement).value
|
||||
formatError.value = null
|
||||
if (props.error) emit('clear-error')
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!canSubmit.value) return
|
||||
if (mailboxNameProblem(name.value) === 'invalid') {
|
||||
formatError.value = getErrorMessage('VAL_001')
|
||||
inputRef.value?.focus()
|
||||
return
|
||||
}
|
||||
emit('submit', name.value.trim())
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter' || isImeComposing(event)) return
|
||||
event.preventDefault()
|
||||
submit()
|
||||
}
|
||||
|
||||
// 열 때마다 처음 이름으로 — 이름 바꾸기는 이름 전체를 골라 바로 고쳐 쓸 수 있게
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
name.value = props.initialName
|
||||
formatError.value = null
|
||||
void nextTick(() => {
|
||||
if (props.mode === 'rename') inputRef.value?.select()
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
// 서버가 거절하면 칸에 초점을 돌려준다 — 고쳐 쓰기 쉽게
|
||||
watch(
|
||||
() => props.error,
|
||||
(error) => {
|
||||
if (error) void nextTick(() => inputRef.value?.focus())
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
id="mailbox-name-dialog"
|
||||
:open="open"
|
||||
:title="title"
|
||||
title-id="mailbox-name-title"
|
||||
described-by="mailbox-name-help"
|
||||
@cancel="emit('cancel')"
|
||||
>
|
||||
<div class="mailbox-name">
|
||||
<label
|
||||
for="mailbox-name-input"
|
||||
class="mailbox-name__label"
|
||||
>{{ MAILBOX_DIALOG_TEXT.nameLabel }}</label>
|
||||
<input
|
||||
id="mailbox-name-input"
|
||||
ref="inputRef"
|
||||
class="form-input mailbox-name__input"
|
||||
type="text"
|
||||
:value="name"
|
||||
:maxlength="MAILBOX_NAME_MAX_LENGTH"
|
||||
autocomplete="off"
|
||||
:aria-invalid="shownError ? 'true' : 'false'"
|
||||
aria-describedby="mailbox-name-help"
|
||||
autofocus
|
||||
@input="onInput"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<p
|
||||
id="mailbox-name-help"
|
||||
:class="['form-message', { 'form-message--error': shownError }]"
|
||||
>
|
||||
<AppIcon
|
||||
v-if="shownError"
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ shownError ?? MAILBOX_DIALOG_TEXT.nameHelp }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<template #actions>
|
||||
<button
|
||||
id="mailbox-name-confirm"
|
||||
type="button"
|
||||
class="modal-dialog__action"
|
||||
:disabled="!canSubmit"
|
||||
:aria-busy="pending"
|
||||
@click="submit"
|
||||
>
|
||||
{{ confirmLabel }}
|
||||
</button>
|
||||
<button
|
||||
id="mailbox-name-cancel"
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ MAILBOX_DIALOG_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mailbox-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.mailbox-name__label {
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.mailbox-name__input {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,777 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type DOMWrapper, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { MailVacation, Mailbox } from '@/shared/types/mail'
|
||||
import { endOfDayIso } from '@/shared/utils/mail-vacation'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailVacationStore } from '@/stores/mail-vacation.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MailboxPanel from './MailboxPanel.vue'
|
||||
|
||||
// 메일함 패널의 메일함 관리 (디자인 원본 MailboxManage · MailActions4 · 계약 2절 "메일함 관리" · "부재중 자동 응답")
|
||||
|
||||
function box(id: string, role: Mailbox['role'], name: string, partial: Partial<Mailbox> = {}): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 0, unreadCount: 0, ...partial }
|
||||
}
|
||||
|
||||
const MAILBOXES: Mailbox[] = [
|
||||
box('inbox', 'inbox', '받은편지함', { unreadCount: 3, totalCount: 148 }),
|
||||
box('sent', 'sent', '보낸편지함', { totalCount: 64 }),
|
||||
box('drafts', 'drafts', '임시보관함', { totalCount: 2 }),
|
||||
box('junk', 'junk', '스팸메일함', { unreadCount: 1, totalCount: 5 }),
|
||||
box('trash', 'trash', '휴지통', { totalCount: 21 }),
|
||||
box('p', null, '프로젝트', { unreadCount: 2, totalCount: 18 }),
|
||||
box('pq', null, '견적', { parentId: 'p', totalCount: 6 }),
|
||||
box('c', null, '거래처', { unreadCount: 1, totalCount: 12 }),
|
||||
]
|
||||
|
||||
/** 응답을 직접 풀어 주는 요청 — 진행 알림을 그 사이에 본다 */
|
||||
interface Deferred {
|
||||
promise: Promise<MockResult>
|
||||
resolve: (result: MockResult) => void
|
||||
}
|
||||
|
||||
function deferred(): Deferred {
|
||||
let resolve: (result: MockResult) => void = () => undefined
|
||||
const promise = new Promise<MockResult>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
type Reply = MockResult | Deferred
|
||||
|
||||
describe('MailboxPanel — 메일함 관리', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
/** "METHOD url" 마다 차례로 줄 응답 — 다 쓰면 마지막 것을 되풀이한다 */
|
||||
let replies: Map<string, Reply[]>
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
replies = new Map()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function reply(key: string, ...results: Reply[]) {
|
||||
replies.set(key, results)
|
||||
}
|
||||
|
||||
async function mountPanel(path = '/mail/inbox') {
|
||||
requests = installMockApi((request) => {
|
||||
const queue = replies.get(`${request.method} ${request.url}`)
|
||||
if (queue && queue.length > 0) {
|
||||
const next = queue.length > 1 ? queue.shift() : queue[0]
|
||||
if (next) return typeof next === 'object' && 'promise' in next ? next.promise : next
|
||||
}
|
||||
if (request.url === '/mailboxes') {
|
||||
return ok({ items: [...useMailStore().mailboxes], pageInfo: { hasNext: false, nextCursor: null, total: 8 } })
|
||||
}
|
||||
if (request.url === '/messages') return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
return ok(null)
|
||||
})
|
||||
const store = useMailStore()
|
||||
store.mailboxes = MAILBOXES.map((mailbox) => ({ ...mailbox }))
|
||||
store.mailboxesStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const match = /^\/mail\/([^/]+)/.exec(path)
|
||||
if (match?.[1]) store.currentMailboxId = match[1]
|
||||
const wrapper = mount(MailboxPanel, { attachTo: document.body, global: { plugins: [pinia, router] } })
|
||||
await flushPromises()
|
||||
return { wrapper, router, store }
|
||||
}
|
||||
|
||||
const row = (wrapper: VueWrapper, name: string) => {
|
||||
const found = wrapper.findAll('.mailbox-row').find((item) => item.get('.mailbox-row__name').text() === name)
|
||||
if (!found) throw new Error(`${name} 줄이 없다`)
|
||||
return found
|
||||
}
|
||||
|
||||
async function openMenu(wrapper: VueWrapper, name: string) {
|
||||
await row(wrapper, name).get('[data-mailbox-more]').trigger('click')
|
||||
await flushPromises()
|
||||
return wrapper.findAll('[role="menu"] [role^="menuitem"]')
|
||||
}
|
||||
|
||||
async function choose(wrapper: VueWrapper, name: string, label: string) {
|
||||
const items = await openMenu(wrapper, name)
|
||||
const item = items.find((candidate) => candidate.text() === label)
|
||||
if (!item) throw new Error(`${name} 메뉴에 ${label} 이 없다`)
|
||||
await item.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
const dialogOpen = (id: string) => document.getElementById(id)?.hasAttribute('open') ?? false
|
||||
const toastTexts = () => useToastStore().toasts.map((toast) => toast.message)
|
||||
const sent = (method: string, url: string) => requests.filter((request) => request.method === method && request.url === url)
|
||||
|
||||
it('기본 메일함 아래 "내 메일함" 묶음 — 하위 메일함은 들여 쓰고, 끝에 [+ 새 메일함]', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
const lists = wrapper.findAll('.mailbox-tree__list')
|
||||
expect(lists[0]?.findAll('.mailbox-row__name').map((name) => name.text())).toEqual([
|
||||
'받은편지함',
|
||||
'보낸편지함',
|
||||
'임시보관함',
|
||||
'스팸메일함',
|
||||
'휴지통',
|
||||
])
|
||||
expect(wrapper.get('.mailbox-tree__group').text()).toBe('내 메일함')
|
||||
expect(lists[1]?.attributes('aria-labelledby')).toBe(wrapper.get('.mailbox-tree__group').attributes('id'))
|
||||
expect(lists[1]?.findAll('.mailbox-row__name').map((name) => name.text())).toEqual(['거래처', '프로젝트', '견적'])
|
||||
expect(row(wrapper, '견적').get('a').classes()).toContain('mailbox-row__link--depth-1')
|
||||
expect(wrapper.get('[data-mailbox-new]').text()).toBe('새 메일함')
|
||||
// 안 읽은 수는 평범한 숫자 — ⋯ 앞에 그대로
|
||||
expect(row(wrapper, '받은편지함').get('.mailbox-row__count').text()).toContain('3')
|
||||
})
|
||||
|
||||
it('⋯ 메뉴 — 메일함마다 할 수 있는 일만 · 할 일이 없는 메일함에는 ⋯ 가 없다', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
const labels = async (name: string) => {
|
||||
const items = await openMenu(wrapper, name)
|
||||
const result = items.map((item) => [item.text(), item.classes().includes('menu__item--danger')])
|
||||
await row(wrapper, name).get('[data-mailbox-more]').trigger('click')
|
||||
await flushPromises()
|
||||
return result
|
||||
}
|
||||
|
||||
expect(await labels('받은편지함')).toEqual([
|
||||
['모두 읽음으로 표시', false],
|
||||
['하위 메일함 만들기', false],
|
||||
])
|
||||
expect(await labels('스팸메일함')).toEqual([
|
||||
['모두 읽음으로 표시', false],
|
||||
['스팸메일함 비우기', true],
|
||||
])
|
||||
expect(await labels('휴지통')).toEqual([['휴지통 비우기', true]])
|
||||
expect(await labels('프로젝트')).toEqual([
|
||||
['모두 읽음으로 표시', false],
|
||||
['하위 메일함 만들기', false],
|
||||
['이름 바꾸기', false],
|
||||
['지우기', true],
|
||||
])
|
||||
expect(await labels('견적')).toEqual([
|
||||
['하위 메일함 만들기', false],
|
||||
['이름 바꾸기', false],
|
||||
['지우기', true],
|
||||
])
|
||||
expect(row(wrapper, '보낸편지함').find('[data-mailbox-more]').exists()).toBe(false)
|
||||
expect(row(wrapper, '임시보관함').find('[data-mailbox-more]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('⋯ 단추 — 이름 · aria-expanded, 메뉴가 열린 동안 줄이 is-open(⋯ 가 보인다), Esc 는 메뉴만 닫고 단추로', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
const more = row(wrapper, '거래처').get('[data-mailbox-more]')
|
||||
expect(more.attributes('aria-label')).toBe('거래처 메일함 메뉴')
|
||||
expect(more.attributes('aria-haspopup')).toBe('menu')
|
||||
|
||||
await more.trigger('keydown', { key: 'ArrowDown' })
|
||||
await flushPromises()
|
||||
const menu = wrapper.get('[role="menu"]')
|
||||
expect(menu.attributes('aria-label')).toBe('거래처 메일함')
|
||||
expect(more.attributes('aria-expanded')).toBe('true')
|
||||
expect(row(wrapper, '거래처').classes()).toContain('is-open')
|
||||
expect(document.activeElement?.textContent?.trim()).toBe('모두 읽음으로 표시')
|
||||
|
||||
await menu.trigger('keydown', { key: 'Escape' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[role="menu"]').exists()).toBe(false)
|
||||
expect(row(wrapper, '거래처').classes()).not.toContain('is-open')
|
||||
expect(document.activeElement).toBe(more.element)
|
||||
})
|
||||
|
||||
describe('새 메일함 · 하위 메일함 · 이름 바꾸기', () => {
|
||||
it('[+ 새 메일함] — 이름 칸(64자)에 초점 · 안내, 만들면 POST /mailboxes { name } → 패널에 바로 나타나고 짧은 알림', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
reply('POST /mailboxes', ok(box('n1', null, '협력사'), 201))
|
||||
|
||||
await wrapper.get('[data-mailbox-new]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialogOpen('mailbox-name-dialog')).toBe(true)
|
||||
expect(wrapper.get('#mailbox-name-title').text()).toBe('새 메일함')
|
||||
const input = wrapper.get('#mailbox-name-input')
|
||||
expect(input.attributes('maxlength')).toBe('64')
|
||||
expect(document.activeElement).toBe(input.element)
|
||||
expect(wrapper.get('#mailbox-name-help').text()).toBe('64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다.')
|
||||
// 이름을 비웠으면 누를 수 없다
|
||||
expect(wrapper.get('#mailbox-name-confirm').attributes('disabled')).toBeDefined()
|
||||
|
||||
await input.setValue(' 협력사 ')
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(sent('POST', '/mailboxes').map((request) => request.data)).toEqual([{ name: '협력사' }])
|
||||
expect(dialogOpen('mailbox-name-dialog')).toBe(false)
|
||||
expect(wrapper.findAll('.mailbox-row__name').map((name) => name.text())).toContain('협력사')
|
||||
expect(toastTexts()).toEqual(['‘협력사’ 메일함을 만들었습니다.'])
|
||||
})
|
||||
|
||||
it('하위 메일함 만들기 — "‘프로젝트’ 안에 새 메일함", parentId 를 함께 보낸다', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
reply('POST /mailboxes', ok(box('n2', null, '2027', { parentId: 'p' }), 201))
|
||||
|
||||
await choose(wrapper, '프로젝트', '하위 메일함 만들기')
|
||||
expect(wrapper.get('#mailbox-name-title').text()).toBe('‘프로젝트’ 안에 새 메일함')
|
||||
await wrapper.get('#mailbox-name-input').setValue('2027')
|
||||
await wrapper.get('#mailbox-name-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(sent('POST', '/mailboxes').map((request) => request.data)).toEqual([{ name: '2027', parentId: 'p' }])
|
||||
})
|
||||
|
||||
it('같은 이름(409 MAIL_009) — 알림 없이 칸 아래에 빨간 사전 문구 · 대화상자는 그대로, 고치면 안내로 돌아간다', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
reply('POST /mailboxes', fail(409, 'MAIL_009'))
|
||||
|
||||
await wrapper.get('[data-mailbox-new]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('#mailbox-name-input').setValue('거래처')
|
||||
await wrapper.get('#mailbox-name-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const help = wrapper.get('#mailbox-name-help')
|
||||
expect(help.text()).toBe(ERROR_CODE_MESSAGES.MAIL_009)
|
||||
expect(ERROR_CODE_MESSAGES.MAIL_009).toBe('같은 이름의 메일함이 이미 있습니다.')
|
||||
expect(help.classes()).toContain('form-message--error')
|
||||
expect(wrapper.get('#mailbox-name-input').attributes('aria-invalid')).toBe('true')
|
||||
expect(dialogOpen('mailbox-name-dialog')).toBe(true)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
await wrapper.get('#mailbox-name-input').setValue('거래처 2')
|
||||
expect(wrapper.get('#mailbox-name-help').text()).toBe('64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다.')
|
||||
})
|
||||
|
||||
it('/ 가 든 이름은 보내지 않고 칸 아래에 사전 문구(VAL_001)', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
await wrapper.get('[data-mailbox-new]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('#mailbox-name-input').setValue('2026/견적')
|
||||
await wrapper.get('#mailbox-name-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(sent('POST', '/mailboxes')).toEqual([])
|
||||
expect(wrapper.get('#mailbox-name-help').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
})
|
||||
|
||||
it('이름 바꾸기 — 지금 이름으로 채운다. 그대로면 보내지 않고 닫고, 바꾸면 PATCH { name } + "메일함 이름을 바꿨습니다."', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
reply('PATCH /mailboxes/c', ok(box('c', null, '거래처 (2026)', { totalCount: 12 })))
|
||||
|
||||
await choose(wrapper, '거래처', '이름 바꾸기')
|
||||
expect(wrapper.get('#mailbox-name-title').text()).toBe('메일함 이름 바꾸기')
|
||||
const input = wrapper.get<HTMLInputElement>('#mailbox-name-input')
|
||||
expect(input.element.value).toBe('거래처')
|
||||
await wrapper.get('#mailbox-name-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialogOpen('mailbox-name-dialog')).toBe(false)
|
||||
expect(sent('PATCH', '/mailboxes/c')).toEqual([])
|
||||
|
||||
await choose(wrapper, '거래처', '이름 바꾸기')
|
||||
await wrapper.get('#mailbox-name-input').setValue('거래처 (2026)')
|
||||
await wrapper.get('#mailbox-name-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(sent('PATCH', '/mailboxes/c').map((request) => request.data)).toEqual([{ name: '거래처 (2026)' }])
|
||||
expect(row(wrapper, '거래처 (2026)').exists()).toBe(true)
|
||||
expect(toastTexts()).toEqual(['메일함 이름을 바꿨습니다.'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('지우기 — 안의 메일은 휴지통으로', () => {
|
||||
it('하위 메일함이 있으면 지울 수 없다 — [확인]만, 보내지 않는다', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
await choose(wrapper, '프로젝트', '지우기')
|
||||
|
||||
expect(dialogOpen('mailbox-delete-dialog')).toBe(true)
|
||||
expect(wrapper.get('#mailbox-delete-dialog').attributes('role')).toBe('alertdialog')
|
||||
expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘프로젝트’ 메일함을 지울 수 없습니다')
|
||||
expect(wrapper.get('#mailbox-delete-message').text()).toBe('하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요.')
|
||||
expect(wrapper.find('#mailbox-delete-confirm').exists()).toBe(false)
|
||||
expect(wrapper.get('#mailbox-delete-cancel').text()).toBe('확인')
|
||||
|
||||
await wrapper.get('#mailbox-delete-cancel').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialogOpen('mailbox-delete-dialog')).toBe(false)
|
||||
expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
|
||||
})
|
||||
|
||||
it('확인(처음 초점 「취소」) 뒤 DELETE 를 deleted 가 될 때까지 다시 보낸다 — 진행 알림이 결과로 바뀌고, 보던 메일함이면 받은편지함으로', async () => {
|
||||
const second = deferred()
|
||||
reply(
|
||||
'DELETE /mailboxes/c',
|
||||
ok({ deleted: false, processed: 500, movedToTrash: 500, remaining: 734 }),
|
||||
second,
|
||||
ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }),
|
||||
)
|
||||
const { wrapper, router, store } = await mountPanel('/mail/c')
|
||||
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘거래처’ 메일함을 지울까요?')
|
||||
expect(wrapper.get('#mailbox-delete-message').text()).toBe(
|
||||
'안의 메일 12통은 휴지통으로 옮깁니다. 지운 메일함은 되돌릴 수 없습니다.',
|
||||
)
|
||||
expect(document.activeElement?.id).toBe('mailbox-delete-cancel')
|
||||
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialogOpen('mailbox-delete-dialog')).toBe(false)
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['progress', '‘거래처’ 메일함을 지우는 중 · 500 / 1,234통'],
|
||||
])
|
||||
// 지우는 동안 그 메일함의 메뉴 항목은 누를 수 없다
|
||||
const items = await openMenu(wrapper, '거래처')
|
||||
expect(items.every((item) => item.attributes('disabled') !== undefined)).toBe(true)
|
||||
|
||||
second.resolve(ok({ deleted: false, processed: 734, movedToTrash: 734, remaining: 0 }))
|
||||
await flushPromises()
|
||||
|
||||
expect(sent('DELETE', '/mailboxes/c')).toHaveLength(3)
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['success', '‘거래처’ 메일함을 지웠습니다. 안의 메일 1,234통은 휴지통에 있습니다.'],
|
||||
])
|
||||
expect(store.mailboxes.some((mailbox) => mailbox.id === 'c')).toBe(false)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('빈 메일함은 "빈 메일함입니다." — 지운 뒤 "‘…’ 메일함을 지웠습니다." · 다른 메일함을 보고 있었으면 그대로', async () => {
|
||||
const { wrapper, router, store } = await mountPanel('/mail/inbox')
|
||||
store.mailboxes = store.mailboxes.map((mailbox) => (mailbox.id === 'c' ? { ...mailbox, totalCount: 0 } : mailbox))
|
||||
reply('DELETE /mailboxes/c', ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }))
|
||||
await flushPromises()
|
||||
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
expect(wrapper.get('#mailbox-delete-message').text()).toBe('빈 메일함입니다. 지운 메일함은 되돌릴 수 없습니다.')
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(toastTexts()).toEqual(['‘거래처’ 메일함을 지웠습니다.'])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
})
|
||||
|
||||
describe('오래 걸리는 처리 — 모두 읽음 · 비우기', () => {
|
||||
it('모두 읽음 — remaining 이 0 이 될 때까지 PATCH /mailboxes/{id}/messages { isRead: true } · 안 읽은 수가 바로 줄고 결과 알림', async () => {
|
||||
const second = deferred()
|
||||
reply(
|
||||
'PATCH /mailboxes/inbox/messages',
|
||||
ok({ processed: 500, remaining: 734 }),
|
||||
second,
|
||||
)
|
||||
const { wrapper, store } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '받은편지함', '모두 읽음으로 표시')
|
||||
expect(sent('PATCH', '/mailboxes/inbox/messages')[0]?.data).toEqual({ isRead: true })
|
||||
expect(toastTexts()).toEqual(['받은편지함의 메일을 읽음으로 표시하는 중 · 500 / 1,234통'])
|
||||
expect(store.mailboxes.find((mailbox) => mailbox.id === 'inbox')?.unreadCount).toBe(734)
|
||||
// 처리하는 동안 다른 일을 해도 된다 — 같은 처리만 다시 누를 수 없다
|
||||
const items = await openMenu(wrapper, '받은편지함')
|
||||
expect(items.map((item) => [item.text(), item.attributes('disabled') !== undefined])).toEqual([
|
||||
['모두 읽음으로 표시', true],
|
||||
['하위 메일함 만들기', false],
|
||||
])
|
||||
await row(wrapper, '받은편지함').get('[data-mailbox-more]').trigger('click')
|
||||
|
||||
second.resolve(ok({ processed: 734, remaining: 0 }))
|
||||
await flushPromises()
|
||||
expect(sent('PATCH', '/mailboxes/inbox/messages')).toHaveLength(2)
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['success', '받은편지함의 메일 1,234통을 읽음으로 표시했습니다.'],
|
||||
])
|
||||
expect(row(wrapper, '받은편지함').find('.mailbox-row__count').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('휴지통 비우기 — 확인(처음 초점 「취소」) 뒤 DELETE /mailboxes/{id}/messages 를 되풀이 · "휴지통을 비웠습니다 — 메일 21통."', async () => {
|
||||
reply('DELETE /mailboxes/trash/messages', ok({ processed: 20, remaining: 1 }), ok({ processed: 1, remaining: 0 }))
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '휴지통', '휴지통 비우기')
|
||||
expect(dialogOpen('mailbox-empty-dialog')).toBe(true)
|
||||
expect(wrapper.get('#mailbox-empty-title').text()).toBe('휴지통의 메일 21통을 모두 영구 삭제할까요?')
|
||||
expect(wrapper.get('#mailbox-empty-message').text()).toBe('지운 메일은 되돌릴 수 없습니다.')
|
||||
expect(document.activeElement?.id).toBe('mailbox-empty-cancel')
|
||||
|
||||
await wrapper.get('#mailbox-empty-cancel').trigger('click')
|
||||
await flushPromises()
|
||||
expect(sent('DELETE', '/mailboxes/trash/messages')).toEqual([])
|
||||
|
||||
await choose(wrapper, '휴지통', '휴지통 비우기')
|
||||
await wrapper.get('#mailbox-empty-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(sent('DELETE', '/mailboxes/trash/messages')).toHaveLength(2)
|
||||
expect(toastTexts()).toEqual(['휴지통을 비웠습니다 — 메일 21통.'])
|
||||
// 되돌릴 수 없어 [되돌리기]가 없다
|
||||
expect(useToastStore().toasts[0]?.action).toBeNull()
|
||||
})
|
||||
|
||||
it('도중에 실패하면 거기까지 처리한 수를 알린다 (까닭은 인터셉터 알림) · 처음부터 실패하면 진행 알림만 닫는다', async () => {
|
||||
reply('PATCH /mailboxes/inbox/messages', ok({ processed: 500, remaining: 734 }), fail(503, 'MAIL_001'))
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '받은편지함', '모두 읽음으로 표시')
|
||||
// 진행 알림은 그 자리에서 결과로 바뀐다 — 까닭(사전 문구 · 문의 코드)은 인터셉터의 알림
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['error', '1,234통 가운데 500통까지 처리했습니다 — 다시 시도해 주세요.'],
|
||||
['error', ERROR_CODE_MESSAGES.MAIL_001],
|
||||
])
|
||||
|
||||
useToastStore().clear()
|
||||
reply('DELETE /mailboxes/junk/messages', fail(503, 'SYS_001'))
|
||||
await choose(wrapper, '스팸메일함', '스팸메일함 비우기')
|
||||
await wrapper.get('#mailbox-empty-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(useToastStore().toasts.map((toast) => toast.kind)).toEqual(['error'])
|
||||
expect(toastTexts()).toEqual([ERROR_CODE_MESSAGES.SYS_001])
|
||||
})
|
||||
|
||||
it('서버가 나아가지 못하면(남은 수만 돌려줌) 되풀이를 멈추고 사전 문구로 알린다', async () => {
|
||||
reply('PATCH /mailboxes/c/messages', ok({ processed: 0, remaining: 1 }))
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '거래처', '모두 읽음으로 표시')
|
||||
|
||||
expect(sent('PATCH', '/mailboxes/c/messages')).toHaveLength(3)
|
||||
expect(toastTexts()).toEqual([ERROR_CODE_MESSAGES.BIZ_001])
|
||||
})
|
||||
})
|
||||
|
||||
it('부재중 자동 응답이 켜져 있으면 사용량 위에 안내 — 누르면 설정으로, 꺼지면 사라진다', async () => {
|
||||
const vacation: MailVacation = {
|
||||
isEnabled: true,
|
||||
fromDate: null,
|
||||
toDate: endOfDayIso('2099-11-03'),
|
||||
subject: '부재중 안내',
|
||||
textBody: '휴가입니다.',
|
||||
}
|
||||
const { wrapper } = await mountPanel()
|
||||
useMailVacationStore().vacation = vacation
|
||||
useMailStore().quota = { usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 }
|
||||
await flushPromises()
|
||||
|
||||
const notice = wrapper.get('[data-vacation-notice]')
|
||||
expect(notice.text()).toContain('부재중 자동 응답 켜짐')
|
||||
expect(notice.text()).toContain('2099년 11월 3일까지 · 설정')
|
||||
expect(notice.attributes('href')).toBe('/settings/mail/vacation')
|
||||
const footer = wrapper.get('.mailbox-panel__footer').element
|
||||
expect(footer.firstElementChild).toBe(notice.element)
|
||||
expect(footer.lastElementChild?.classList.contains('quota')).toBe(true)
|
||||
|
||||
useMailVacationStore().vacation = { ...vacation, isEnabled: false }
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-vacation-notice]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
describe('지우기 · 비우기 — 리뷰 보완', () => {
|
||||
it('다른 메일함에도 든 메일은 이 메일함에서만 빠진다 — 진행 · 정체는 processed 로, 끝 알림의 휴지통 수는 movedToTrash 합계로', async () => {
|
||||
reply(
|
||||
'DELETE /mailboxes/c',
|
||||
ok({ deleted: false, processed: 500, movedToTrash: 300, remaining: 300 }),
|
||||
ok({ deleted: false, processed: 100, movedToTrash: 0, remaining: 200 }),
|
||||
ok({ deleted: false, processed: 100, movedToTrash: 0, remaining: 100 }),
|
||||
ok({ deleted: false, processed: 100, movedToTrash: 0, remaining: 0 }),
|
||||
ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }),
|
||||
)
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// 휴지통으로 옮긴 것이 없는 차례가 이어져도 나아가고 있다 — 멈추지 않는다
|
||||
expect(sent('DELETE', '/mailboxes/c')).toHaveLength(5)
|
||||
expect(toastTexts()).toEqual(['‘거래처’ 메일함을 지웠습니다. 안의 메일 300통은 휴지통에 있습니다.'])
|
||||
})
|
||||
|
||||
it('이미 지워진 메일함(404 RES_001)은 끝난 것으로 — 오류 알림 없이 "‘거래처’ 메일함을 지웠습니다." · 메일함 목록을 다시 맞춘다', async () => {
|
||||
reply('DELETE /mailboxes/c', fail(404, 'RES_001'))
|
||||
const { wrapper, store } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['success', '‘거래처’ 메일함을 지웠습니다.'],
|
||||
])
|
||||
expect(store.mailboxes.some((mailbox) => mailbox.id === 'c')).toBe(false)
|
||||
expect(sent('GET', '/mailboxes').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('응답이 끊긴 뒤 다시 보내 404 를 받아도 지운 것으로 — 처음 끊김은 인터셉터 알림만', async () => {
|
||||
reply('DELETE /mailboxes/c', 'network', fail(404, 'RES_001'))
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(toastTexts()).toEqual([ERROR_CODE_MESSAGES.SYS_001])
|
||||
|
||||
useToastStore().clear()
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(toastTexts()).toEqual(['‘거래처’ 메일함을 지웠습니다.'])
|
||||
})
|
||||
|
||||
it('다른 탭이 먼저 했으면 — "거래처에 안 읽은 메일이 없습니다." · "휴지통이 이미 비어 있습니다."', async () => {
|
||||
reply('PATCH /mailboxes/c/messages', ok({ processed: 0, remaining: 0 }))
|
||||
reply('DELETE /mailboxes/trash/messages', ok({ processed: 0, remaining: 0 }))
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '거래처', '모두 읽음으로 표시')
|
||||
expect(toastTexts()).toEqual(['거래처에 안 읽은 메일이 없습니다.'])
|
||||
|
||||
useToastStore().clear()
|
||||
await choose(wrapper, '휴지통', '휴지통 비우기')
|
||||
await wrapper.get('#mailbox-empty-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(toastTexts()).toEqual(['휴지통이 이미 비어 있습니다.'])
|
||||
})
|
||||
|
||||
it('다른 탭이 같은 메일함을 처리하는 중이면(429 REQ_001) 인터셉터 알림만 — 진행 알림은 닫는다', async () => {
|
||||
reply('PATCH /mailboxes/inbox/messages', fail(429, 'REQ_001'))
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '받은편지함', '모두 읽음으로 표시')
|
||||
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['error', ERROR_CODE_MESSAGES.REQ_001],
|
||||
])
|
||||
})
|
||||
|
||||
it('비운 뒤 누른 ⋯ 가 사라지면 초점을 메일함 패널의 지금 메일함으로', async () => {
|
||||
reply('DELETE /mailboxes/trash/messages', ok({ processed: 21, remaining: 0 }))
|
||||
const { wrapper } = await mountPanel('/mail/inbox')
|
||||
|
||||
await choose(wrapper, '휴지통', '휴지통 비우기')
|
||||
await wrapper.get('#mailbox-empty-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(row(wrapper, '휴지통').find('[data-mailbox-more]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(row(wrapper, '받은편지함').get('a').element)
|
||||
})
|
||||
|
||||
it('지운 메일함의 ⋯ 가 사라져도 초점을 메일함 패널의 지금 메일함으로', async () => {
|
||||
reply('DELETE /mailboxes/c', ok({ deleted: true, processed: 12, movedToTrash: 12, remaining: 0 }))
|
||||
const { wrapper } = await mountPanel('/mail/inbox')
|
||||
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(document.activeElement).toBe(row(wrapper, '받은편지함').get('a').element)
|
||||
})
|
||||
})
|
||||
|
||||
describe('휴지통 비우기 ↔ 메일함 지우기 — 함께 돌지 않는다', () => {
|
||||
const itemState = (items: DOMWrapper<Element>[]) =>
|
||||
items.map((item) => [item.text(), item.attributes('disabled') !== undefined])
|
||||
|
||||
it('휴지통을 비우는 동안은 「지우기」가 꺼진다 — 끝나면 다시 켜진다', async () => {
|
||||
const second = deferred()
|
||||
reply('DELETE /mailboxes/trash/messages', ok({ processed: 20, remaining: 1 }), second)
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '휴지통', '휴지통 비우기')
|
||||
await wrapper.get('#mailbox-empty-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(itemState(await openMenu(wrapper, '거래처'))).toEqual([
|
||||
['모두 읽음으로 표시', false],
|
||||
['하위 메일함 만들기', false],
|
||||
['이름 바꾸기', false],
|
||||
['지우기', true],
|
||||
])
|
||||
await row(wrapper, '거래처').get('[data-mailbox-more]').trigger('click')
|
||||
|
||||
second.resolve(ok({ processed: 1, remaining: 0 }))
|
||||
await flushPromises()
|
||||
expect(itemState(await openMenu(wrapper, '거래처')).at(-1)).toEqual(['지우기', false])
|
||||
})
|
||||
|
||||
it('지우기 대화상자를 연 뒤 휴지통 비우기가 시작되면 [지우기]를 누를 수 없다 — 누르는 순간에도 다시 본다', async () => {
|
||||
const emptying = deferred()
|
||||
reply('DELETE /mailboxes/trash/messages', emptying)
|
||||
const { wrapper, store } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
const trash = store.mailboxes.find((mailbox) => mailbox.id === 'trash')
|
||||
const target = store.mailboxes.find((mailbox) => mailbox.id === 'c')
|
||||
if (!trash || !target) throw new Error('메일함이 없다')
|
||||
const running = store.runMailboxOperation('empty', trash, () => undefined)
|
||||
await flushPromises()
|
||||
|
||||
const confirm = wrapper.get('#mailbox-delete-confirm')
|
||||
expect(confirm.attributes('disabled')).toBeDefined()
|
||||
await confirm.trigger('click')
|
||||
await flushPromises()
|
||||
expect(sent('DELETE', '/mailboxes/c')).toEqual([])
|
||||
expect(dialogOpen('mailbox-delete-dialog')).toBe(true)
|
||||
// 스토어도 시작하지 않는다
|
||||
expect((await store.runMailboxOperation('delete', target, () => undefined)).status).toBe('canceled')
|
||||
|
||||
emptying.resolve(ok({ processed: 21, remaining: 0 }))
|
||||
await running
|
||||
await flushPromises()
|
||||
expect(wrapper.get('#mailbox-delete-confirm').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('메일함을 지우는 동안은 「휴지통 비우기」가 꺼진다 — 「스팸메일함 비우기」는 그대로', async () => {
|
||||
const second = deferred()
|
||||
reply('DELETE /mailboxes/c', ok({ deleted: false, processed: 6, movedToTrash: 6, remaining: 6 }), second)
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(itemState(await openMenu(wrapper, '휴지통'))).toEqual([['휴지통 비우기', true]])
|
||||
await row(wrapper, '휴지통').get('[data-mailbox-more]').trigger('click')
|
||||
expect(itemState(await openMenu(wrapper, '스팸메일함'))).toEqual([
|
||||
['모두 읽음으로 표시', false],
|
||||
['스팸메일함 비우기', false],
|
||||
])
|
||||
await row(wrapper, '스팸메일함').get('[data-mailbox-more]').trigger('click')
|
||||
|
||||
second.resolve(ok({ deleted: true, processed: 6, movedToTrash: 6, remaining: 0 }))
|
||||
await flushPromises()
|
||||
expect(itemState(await openMenu(wrapper, '휴지통'))).toEqual([['휴지통 비우기', false]])
|
||||
})
|
||||
})
|
||||
|
||||
describe('세션이 바뀌면 (stores/mail/mailbox-admin.ts)', () => {
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const inbox = () => MAILBOXES[0] as Mailbox
|
||||
|
||||
it('처리 중 로그아웃하면 다음 요청을 보내지 않고 진행 알림이 닫힌다', async () => {
|
||||
const first = deferred()
|
||||
reply('PATCH /mailboxes/inbox/messages', first, ok({ processed: 734, remaining: 0 }))
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '받은편지함', '모두 읽음으로 표시')
|
||||
expect(useToastStore().toasts.map((toast) => toast.kind)).toEqual(['progress'])
|
||||
useAuthStore().clearAuth()
|
||||
await nextTick()
|
||||
first.resolve(ok({ processed: 500, remaining: 734 }))
|
||||
await flushPromises()
|
||||
|
||||
expect(sent('PATCH', '/mailboxes/inbox/messages')).toHaveLength(1)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('「모두 읽음」을 두 번 눌러도 요청은 한 줄 — 도는 동안 항목이 꺼지고 스토어도 다시 시작하지 않는다', async () => {
|
||||
const first = deferred()
|
||||
reply('PATCH /mailboxes/inbox/messages', first)
|
||||
const { wrapper, store } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '받은편지함', '모두 읽음으로 표시')
|
||||
const items = await openMenu(wrapper, '받은편지함')
|
||||
await items[0]?.trigger('click')
|
||||
await flushPromises()
|
||||
expect((await store.runMailboxOperation('read', inbox(), () => undefined)).status).toBe('canceled')
|
||||
|
||||
expect(sent('PATCH', '/mailboxes/inbox/messages')).toHaveLength(1)
|
||||
first.resolve(ok({ processed: 3, remaining: 0 }))
|
||||
await flushPromises()
|
||||
expect(toastTexts()).toEqual(['받은편지함의 메일 3통을 읽음으로 표시했습니다.'])
|
||||
})
|
||||
|
||||
it('새 세션에서 같은 처리를 다시 시작할 수 있다 — 옛 처리가 끝나도 새 처리의 표시를 지우지 않는다', async () => {
|
||||
const first = deferred()
|
||||
const second = deferred()
|
||||
reply('PATCH /mailboxes/inbox/messages', first, second)
|
||||
const { store } = await mountPanel()
|
||||
|
||||
const old = store.runMailboxOperation('read', inbox(), () => undefined)
|
||||
await flushPromises()
|
||||
// 로그아웃 → 다시 로그인 (사이에 로그인 화면이 있다)
|
||||
useAuthStore().clearAuth()
|
||||
await nextTick()
|
||||
useAuthStore().startSession(USER)
|
||||
await nextTick()
|
||||
store.mailboxes = MAILBOXES.map((mailbox) => ({ ...mailbox }))
|
||||
|
||||
const fresh = store.runMailboxOperation('read', inbox(), () => undefined)
|
||||
await flushPromises()
|
||||
expect(store.isMailboxOperationRunning('read', 'inbox')).toBe(true)
|
||||
|
||||
first.resolve(ok({ processed: 1, remaining: 2 }))
|
||||
expect((await old).status).toBe('canceled')
|
||||
expect(store.isMailboxOperationRunning('read', 'inbox')).toBe(true)
|
||||
expect(sent('PATCH', '/mailboxes/inbox/messages')).toHaveLength(2)
|
||||
|
||||
second.resolve(ok({ processed: 3, remaining: 0 }))
|
||||
expect((await fresh).status).toBe('done')
|
||||
expect(store.isMailboxOperationRunning('read', 'inbox')).toBe(false)
|
||||
})
|
||||
|
||||
it('만들기 · 이름 바꾸기의 늦은 응답은 쓰지 않는다 — 다음 사용자의 메일함을 덮지 않고 알리지도 않는다', async () => {
|
||||
const created = deferred()
|
||||
const renamed = deferred()
|
||||
reply('POST /mailboxes', created)
|
||||
reply('PATCH /mailboxes/c', renamed)
|
||||
const { store } = await mountPanel()
|
||||
|
||||
const creating = store.createMailbox('협력사', null)
|
||||
const renaming = store.renameMailbox('c', '거래처 (옛 사용자)')
|
||||
await flushPromises()
|
||||
useAuthStore().clearAuth()
|
||||
await nextTick()
|
||||
useAuthStore().startSession({ email: 'doyun.lee@arasystem.it.kr', name: '이도윤' })
|
||||
await nextTick()
|
||||
// 다음 사용자의 메일함 — id 가 겹칠 수 있다
|
||||
store.mailboxes = [box('c', null, '도윤의 거래처')]
|
||||
|
||||
created.resolve(ok(box('n1', null, '협력사'), 201))
|
||||
renamed.resolve(ok(box('c', null, '거래처 (옛 사용자)')))
|
||||
expect(await creating).toBeNull()
|
||||
expect(await renaming).toBeNull()
|
||||
expect(store.mailboxes.map((mailbox) => mailbox.name)).toEqual(['도윤의 거래처'])
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it('⋯ 메뉴는 메일함 패널의 보이는 영역 아래가 모자라면 위로 연다', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
const panel = wrapper.get('[data-mailbox-panel]').element as HTMLElement
|
||||
panel.style.overflowY = 'auto'
|
||||
let buttonTop = 540
|
||||
const rectOf = (top: number, bottom: number) =>
|
||||
({ top, bottom, height: bottom - top, left: 0, right: 0, width: 0, x: 0, y: top, toJSON: () => ({}) }) as DOMRect
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
||||
if (this === panel) return rectOf(0, 600)
|
||||
if (this.matches('[data-mailbox-more]')) return rectOf(buttonTop, buttonTop + 40)
|
||||
if (this.matches('[role="menu"]')) return rectOf(buttonTop + 44, buttonTop + 44 + 254)
|
||||
return rectOf(0, 0)
|
||||
})
|
||||
|
||||
await openMenu(wrapper, '거래처')
|
||||
expect(wrapper.get('[role="menu"]').classes()).toContain('mailbox-more__menu--up')
|
||||
await row(wrapper, '거래처').get('[data-mailbox-more]').trigger('click')
|
||||
|
||||
buttonTop = 100
|
||||
await openMenu(wrapper, '거래처')
|
||||
expect(wrapper.get('[role="menu"]').classes()).not.toContain('mailbox-more__menu--up')
|
||||
})
|
||||
})
|
||||
@@ -1,33 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 패널 — 메일 쓰기 + 메일함 목록 + 하단 사용량 (디자인 원본 Main · Compose 왼쪽)
|
||||
// 메일함 패널 — 메일 쓰기 + 메일함 목록 + 아래 안내 · 사용량 (디자인 원본 Main · Compose 왼쪽 · MailboxManage)
|
||||
// - 메일함 목록은 두 묶음(기본 메일함 · "내 메일함")과 [+ 새 메일함] — MailboxTree
|
||||
// - 메일함마다 ⋯ 메뉴(MailboxPanelRow) — 모두 읽음 · 하위 메일함 · 이름 바꾸기 · 지우기 · 비우기. 대화상자는 MailboxDialogs, 하는 일은 useMailboxActions
|
||||
// - 아래: 부재중 자동 응답이 켜져 있으면 안내(MailVacationNotice) → 사용량
|
||||
// 안 읽은 개수는 뱃지가 아니라 오른쪽의 평범한 숫자로 보인다.
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailQuotaMeter from '@/components/mail/MailQuotaMeter.vue'
|
||||
import { mailboxIcon } from '@/shared/utils/mailbox'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import MailVacationNotice from '@/components/mail/MailVacationNotice.vue'
|
||||
import MailboxDialogs from '@/components/mail/MailboxDialogs.vue'
|
||||
import MailboxTree from '@/components/mail/MailboxTree.vue'
|
||||
import type { MailboxDialogRequest } from '@/components/mail/mailbox-dialog'
|
||||
import { useMailboxActions } from '@/composables/useMailboxActions'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import type { MailboxAction } from '@/shared/utils/mailbox'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
/** 하위 메일함 들여쓰기는 3단계까지만 (그 아래는 같은 깊이로) */
|
||||
const MAX_INDENT_DEPTH = 3
|
||||
import { useMailVacationStore } from '@/stores/mail-vacation.store'
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const mailStore = useMailStore()
|
||||
const vacationStore = useMailVacationStore()
|
||||
const actions = useMailboxActions()
|
||||
|
||||
const dialog = ref<MailboxDialogRequest | null>(null)
|
||||
|
||||
const isComposing = computed(() => route.name === 'mail-compose')
|
||||
const activeMailboxId = computed(() => (isComposing.value ? null : mailStore.currentMailboxId))
|
||||
|
||||
function depthClass(depth: number): string {
|
||||
return `mailbox-link--depth-${Math.min(depth, MAX_INDENT_DEPTH)}`
|
||||
/** ⋯ 메뉴에서 고른 일 — 모두 읽음은 바로, 그 밖은 대화상자를 거친다 */
|
||||
function onAction(action: MailboxAction, mailbox: Mailbox) {
|
||||
if (action === 'markRead') {
|
||||
void actions.markAllRead(mailbox)
|
||||
return
|
||||
}
|
||||
if (action === 'createChild') dialog.value = { kind: 'create', parent: mailbox }
|
||||
else if (action === 'rename') dialog.value = { kind: 'rename', mailbox }
|
||||
else if (action === 'delete') dialog.value = { kind: 'delete', mailbox }
|
||||
else dialog.value = { kind: 'empty', mailbox }
|
||||
}
|
||||
|
||||
// 세션이 바뀌면(로그아웃 · 세션 만료) 대화상자를 닫는다 — 세션 만료 대화상자와 겹치지 않게
|
||||
watch(
|
||||
() => authStore.generation,
|
||||
() => {
|
||||
dialog.value = null
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- data-mailbox-panel — 비우기 · 지우기 뒤 초점을 잃으면 여기의 지금 메일함으로 (useMailboxActions) -->
|
||||
<nav
|
||||
class="mailbox-panel"
|
||||
aria-label="메일함"
|
||||
data-mailbox-panel
|
||||
>
|
||||
<span
|
||||
v-if="isComposing"
|
||||
@@ -54,39 +84,17 @@ function depthClass(depth: number): string {
|
||||
<span>메일 쓰기</span>
|
||||
</RouterLink>
|
||||
|
||||
<ul
|
||||
<MailboxTree
|
||||
v-if="mailStore.orderedMailboxes.length > 0"
|
||||
class="mailbox-panel__list"
|
||||
>
|
||||
<li
|
||||
v-for="node in mailStore.orderedMailboxes"
|
||||
:key="node.mailbox.id"
|
||||
>
|
||||
<RouterLink
|
||||
:to="{ name: 'mail-mailbox', params: { mailboxId: node.mailbox.id } }"
|
||||
:class="[
|
||||
'mailbox-link',
|
||||
depthClass(node.depth),
|
||||
{ 'is-active': node.mailbox.id === activeMailboxId },
|
||||
]"
|
||||
:aria-current="node.mailbox.id === activeMailboxId ? 'page' : undefined"
|
||||
>
|
||||
<AppIcon :name="mailboxIcon(node.mailbox)" />
|
||||
<span class="mailbox-link__name">{{ node.mailbox.name }}</span>
|
||||
<span
|
||||
v-if="node.mailbox.unreadCount > 0"
|
||||
class="mailbox-link__count"
|
||||
>
|
||||
<span class="visually-hidden">안 읽음</span>
|
||||
{{ formatCount(node.mailbox.unreadCount) }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
:nodes="mailStore.orderedMailboxes"
|
||||
:active-id="activeMailboxId"
|
||||
@action="onAction"
|
||||
@create="dialog = { kind: 'create', parent: null }"
|
||||
/>
|
||||
|
||||
<ul
|
||||
v-else-if="mailStore.mailboxesStatus === 'loading' || mailStore.mailboxesStatus === 'idle'"
|
||||
class="mailbox-panel__list"
|
||||
class="mailbox-panel__placeholders"
|
||||
aria-label="메일함 불러오는 중"
|
||||
aria-busy="true"
|
||||
>
|
||||
@@ -114,12 +122,20 @@ function depthClass(depth: number): string {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 사용량은 보조 정보 — 받지 못했으면 영역을 그리지 않는다 -->
|
||||
<MailQuotaMeter
|
||||
v-if="mailStore.quota"
|
||||
:quota="mailStore.quota"
|
||||
/>
|
||||
<!-- 아래 묶음 — 부재중 안내(켜져 있을 때) → 사용량(받지 못했으면 그리지 않는다 — 보조 정보) -->
|
||||
<div class="mailbox-panel__footer">
|
||||
<MailVacationNotice :vacation="vacationStore.vacation" />
|
||||
<MailQuotaMeter
|
||||
v-if="mailStore.quota"
|
||||
:quota="mailStore.quota"
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
<!-- 대화상자는 메일함 목록(nav) 밖에 둔다 — 열리면 맨 위 층에 뜬다 -->
|
||||
<MailboxDialogs
|
||||
:request="dialog"
|
||||
@close="dialog = null"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -156,7 +172,7 @@ a.mailbox-panel__primary:hover {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.mailbox-panel__list {
|
||||
.mailbox-panel__placeholders {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
@@ -165,60 +181,6 @@ a.mailbox-panel__primary:hover {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.mailbox-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-row-min);
|
||||
padding: 0 var(--space-10);
|
||||
border-radius: var(--radius-item);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mailbox-link:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mailbox-link.is-active {
|
||||
background: var(--color-brand-subtle);
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mailbox-link--depth-1 {
|
||||
padding-left: var(--space-28);
|
||||
}
|
||||
|
||||
.mailbox-link--depth-2 {
|
||||
padding-left: var(--space-44);
|
||||
}
|
||||
|
||||
.mailbox-link--depth-3 {
|
||||
padding-left: var(--space-56);
|
||||
}
|
||||
|
||||
.mailbox-link__name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mailbox-link__count {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.mailbox-link.is-active .mailbox-link__count {
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.mailbox-panel__placeholder {
|
||||
height: var(--size-row-min);
|
||||
margin: var(--space-2) 0;
|
||||
@@ -249,6 +211,14 @@ a.mailbox-panel__primary:hover {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 아래에 붙는다 — 안내와 사용량 사이 10 */
|
||||
.mailbox-panel__footer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-10);
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 패널만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
|
||||
@media (min-width: 70rem) {
|
||||
.mailbox-panel {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 패널의 한 줄 — 아이콘 · 이름 · 안 읽은 수(평범한 숫자, 뱃지 아님) · ⋯ 메뉴 (디자인 원본 Main · MailboxManage)
|
||||
// - 하위 메일함은 16px 씩 들여 쓴다 (3단계까지 — 그 아래는 같은 깊이로)
|
||||
// - ⋯ 는 마우스를 올리거나 초점이 가면 보이고, 메뉴가 열린 동안은 계속 보인다 (터치 화면은 늘 보인다). 할 일이 없는 메일함은 ⋯ 를 그리지 않는다
|
||||
// - 메뉴 항목은 메일함마다 다르다 (shared/utils/mailbox.ts 의 mailboxActions). 같은 처리가 도는 동안은 그 항목을 끈다 —
|
||||
// 휴지통을 비우는 동안의 「지우기」, 메일함을 지우는 동안의 「휴지통 비우기」도 끈다 (함께 돌 수 없다 — useMailboxActions 의 isBlocked)
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailboxMoreButton from '@/components/mail/MailboxMoreButton.vue'
|
||||
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
|
||||
import { MAILBOX_MENU_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { mailboxActions, mailboxIcon, type MailboxAction } from '@/shared/utils/mailbox'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
mailbox: Mailbox
|
||||
depth: number
|
||||
active: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'action', action: MailboxAction, mailbox: Mailbox): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
|
||||
/** 하위 메일함 들여쓰기는 3단계까지만 (그 아래는 같은 깊이로) */
|
||||
const MAX_INDENT_DEPTH = 3
|
||||
|
||||
const menuOpen = ref<boolean>(false)
|
||||
|
||||
/** 그 메일함에서 돌고 있는 처리 — 지우는 중이면 모든 항목을 끈다. 함께 돌 수 없는 처리가 도는 동안의 지우기 · 비우기도 끈다 */
|
||||
function isDisabled(action: MailboxAction): boolean {
|
||||
const mailbox = props.mailbox
|
||||
if (mailStore.isMailboxOperationRunning('delete', mailbox.id)) return true
|
||||
if (action === 'markRead') return mailStore.isMailboxOperationRunning('read', mailbox.id)
|
||||
if (action === 'empty') {
|
||||
return mailStore.isMailboxOperationRunning('empty', mailbox.id) || mailStore.isMailboxOperationBlocked('empty', mailbox)
|
||||
}
|
||||
if (action === 'delete') return mailStore.isMailboxOperationBlocked('delete', mailbox)
|
||||
return false
|
||||
}
|
||||
|
||||
function labelOf(action: MailboxAction): string {
|
||||
if (action === 'empty') return props.mailbox.role === 'junk' ? MAILBOX_MENU_TEXT.emptyJunk : MAILBOX_MENU_TEXT.emptyTrash
|
||||
return MAILBOX_MENU_TEXT[action]
|
||||
}
|
||||
|
||||
const items = computed<EditorMenuItem[]>(() =>
|
||||
mailboxActions(props.mailbox).map((action) => ({
|
||||
id: action,
|
||||
label: labelOf(action),
|
||||
disabled: isDisabled(action),
|
||||
tone: action === 'delete' || action === 'empty' ? 'danger' : undefined,
|
||||
})),
|
||||
)
|
||||
|
||||
const depthClass = computed(() => `mailbox-row__link--depth-${Math.min(props.depth, MAX_INDENT_DEPTH)}`)
|
||||
|
||||
function onSelect(id: string) {
|
||||
const action = mailboxActions(props.mailbox).find((candidate) => candidate === id)
|
||||
if (action) emit('action', action, props.mailbox)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li :class="['mailbox-row', { 'is-active': active, 'is-open': menuOpen }]">
|
||||
<RouterLink
|
||||
:to="{ name: 'mail-mailbox', params: { mailboxId: mailbox.id } }"
|
||||
:class="['mailbox-row__link', depthClass]"
|
||||
:aria-current="active ? 'page' : undefined"
|
||||
>
|
||||
<AppIcon :name="mailboxIcon(mailbox)" />
|
||||
<span class="mailbox-row__name">{{ mailbox.name }}</span>
|
||||
<span
|
||||
v-if="mailbox.unreadCount > 0"
|
||||
class="mailbox-row__count"
|
||||
>
|
||||
<span class="visually-hidden">안 읽음</span>
|
||||
{{ formatCount(mailbox.unreadCount) }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
<MailboxMoreButton
|
||||
v-if="items.length > 0"
|
||||
class="mailbox-row__more"
|
||||
:mailbox-name="mailbox.name"
|
||||
:items="items"
|
||||
@select="onSelect"
|
||||
@open-change="menuOpen = $event"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mailbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-item);
|
||||
}
|
||||
|
||||
.mailbox-row:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.mailbox-row.is-active {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.mailbox-row__link {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-row-min);
|
||||
padding: 0 var(--space-4) 0 var(--space-10);
|
||||
border-radius: var(--radius-item);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mailbox-row:hover .mailbox-row__link {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mailbox-row.is-active .mailbox-row__link {
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mailbox-row__link--depth-1 {
|
||||
padding-left: var(--space-28);
|
||||
}
|
||||
|
||||
.mailbox-row__link--depth-2 {
|
||||
padding-left: var(--space-44);
|
||||
}
|
||||
|
||||
.mailbox-row__link--depth-3 {
|
||||
padding-left: var(--space-56);
|
||||
}
|
||||
|
||||
.mailbox-row__name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mailbox-row__count {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.mailbox-row.is-active .mailbox-row__count {
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/* ⋯ — 마우스를 올리거나 초점이 가면, 메뉴가 열린 동안은 계속 보인다 */
|
||||
.mailbox-row__more {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mailbox-row:hover .mailbox-row__more,
|
||||
.mailbox-row:focus-within .mailbox-row__more,
|
||||
.mailbox-row.is-open .mailbox-row__more {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 마우스를 올릴 수 없는 화면(터치)에서는 늘 보인다 */
|
||||
@media (hover: none) {
|
||||
.mailbox-row__more {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 목록 두 묶음 — 기본 메일함 · "내 메일함"(12px 굵게) + 끝의 [+ 새 메일함](44) (디자인 원본 MailboxManage)
|
||||
// 하위 메일함은 뿌리의 묶음을 따라 16px 씩 들여 쓴다 (mailbox.ts 의 splitMailboxGroups). 줄 하나는 MailboxPanelRow.
|
||||
// "내 메일함" 이 비어도 묶음 이름과 [+ 새 메일함]은 둔다 — 첫 메일함을 만들 수 있게
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailboxPanelRow from '@/components/mail/MailboxPanelRow.vue'
|
||||
import { MAILBOX_PANEL_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { splitMailboxGroups, type MailboxAction, type MailboxNode } from '@/shared/utils/mailbox'
|
||||
|
||||
interface Props {
|
||||
nodes: readonly MailboxNode[]
|
||||
/** 지금 보는 메일함 — 쓰기 화면에서는 null */
|
||||
activeId: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'action', action: MailboxAction, mailbox: Mailbox): void
|
||||
(e: 'create'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const MY_GROUP_ID = 'mailbox-panel-mine'
|
||||
|
||||
const groups = computed(() => splitMailboxGroups(props.nodes))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mailbox-tree">
|
||||
<ul class="mailbox-tree__list">
|
||||
<MailboxPanelRow
|
||||
v-for="node in groups.system"
|
||||
:key="node.mailbox.id"
|
||||
:mailbox="node.mailbox"
|
||||
:depth="node.depth"
|
||||
:active="node.mailbox.id === activeId"
|
||||
@action="(action, mailbox) => emit('action', action, mailbox)"
|
||||
/>
|
||||
</ul>
|
||||
<p
|
||||
:id="MY_GROUP_ID"
|
||||
class="mailbox-tree__group"
|
||||
>
|
||||
{{ MAILBOX_PANEL_TEXT.myMailboxes }}
|
||||
</p>
|
||||
<ul
|
||||
v-if="groups.mine.length > 0"
|
||||
class="mailbox-tree__list"
|
||||
:aria-labelledby="MY_GROUP_ID"
|
||||
>
|
||||
<MailboxPanelRow
|
||||
v-for="node in groups.mine"
|
||||
:key="node.mailbox.id"
|
||||
:mailbox="node.mailbox"
|
||||
:depth="node.depth"
|
||||
:active="node.mailbox.id === activeId"
|
||||
@action="(action, mailbox) => emit('action', action, mailbox)"
|
||||
/>
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
class="mailbox-tree__new"
|
||||
data-mailbox-new
|
||||
@click="emit('create')"
|
||||
>
|
||||
<AppIcon name="plus" />
|
||||
<span>{{ MAILBOX_PANEL_TEXT.newMailbox }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mailbox-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.mailbox-tree__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* "내 메일함" — 12px 굵게 (디자인 원본 MailboxManage) */
|
||||
.mailbox-tree__group {
|
||||
margin: var(--space-12) var(--space-10) var(--space-4);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* [+ 새 메일함] — 44 */
|
||||
.mailbox-tree__new {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-touch);
|
||||
margin-top: var(--space-2);
|
||||
padding: 0 var(--space-10);
|
||||
border: 0;
|
||||
border-radius: var(--radius-item);
|
||||
background: transparent;
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mailbox-tree__new:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
@@ -3,19 +3,16 @@
|
||||
// - 「영구 삭제」인지는 그 메일의 메일함으로 정한다 — 휴지통 목록에서는 휴지통에 든 메일, 그 밖에서는 휴지통에만 든 메일 (useMessageRemoval)
|
||||
// · 「삭제」: 휴지통으로 옮긴다 — "메일 1통을 휴지통으로 옮겼습니다." + [되돌리기]
|
||||
// · 「영구 삭제」: 확인 대화상자(처음 초점은 「취소」)를 거쳐 영구 삭제 — "메일 1통을 영구 삭제했습니다."
|
||||
// - 지운 뒤: 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (기록을 쌓지 않는다).
|
||||
// - 지운 뒤: 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (useMessageRemoval 의 leaveRemoved).
|
||||
// 그사이 다른 목록으로 옮겨 갔거나 서버가 그대로 두었으면 움직이지 않는다
|
||||
// - DELETE /api/messages/{id} 는 쓰지 않는다 (그사이 다른 창이 휴지통으로 옮긴 메일을 확인 없이 영구 삭제하게 된다)
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import MailPurgeDialog from '@/components/mail/MailPurgeDialog.vue'
|
||||
import MailToolButton from '@/components/mail/MailToolButton.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { useMessageRemoval } from '@/composables/useMessageRemoval'
|
||||
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { listLocationOf, messageLocationOf } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
@@ -26,10 +23,8 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const removal = useMessageRemoval()
|
||||
const { backToSourceList } = useMailNavigation()
|
||||
|
||||
const working = ref<boolean>(false)
|
||||
const confirmOpen = ref<boolean>(false)
|
||||
@@ -43,13 +38,7 @@ async function run(kind: 'trash' | 'purge') {
|
||||
working.value = true
|
||||
try {
|
||||
const outcome = kind === 'trash' ? await removal.moveToTrash(target) : await removal.purge(target)
|
||||
if (!outcome.removed || !source) return
|
||||
if (props.layout === 'list') {
|
||||
// 목록 보기에서는 지우면 목록으로 돌아간다 (디자인 원본 MainListRead)
|
||||
await backToSourceList(source)
|
||||
return
|
||||
}
|
||||
await router.replace(outcome.nextId ? messageLocationOf(source, outcome.nextId) : listLocationOf(source))
|
||||
await removal.leaveRemoved(outcome, source, props.layout)
|
||||
} finally {
|
||||
working.value = false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
// 읽기 화면 도구 줄 끝의 ⋯ (더 보기 40×44) — 메뉴에 "원문 내려받기 (.eml)" (디자인 원본 MailActions4 · 계약 2절 "스팸 · 원문")
|
||||
// 메뉴는 이동 ▾ 과 같은 편집기 메뉴(EditorMenu — 항목 40)를 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에,
|
||||
// 마우스로 열면 메뉴 자체에 초점. Esc 는 메뉴만 닫고 이 단추로 돌아간다.
|
||||
// 원문 내려받기는 첨부 내려받기처럼 이동하기 전에 세션을 확인한다 (useMail 의 downloadMessageSource — confirmSession)
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
|
||||
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
|
||||
import { useMail } from '@/composables/useMail'
|
||||
import { MAIL_READER_MORE_TEXT } from '@/shared/constants/mail'
|
||||
|
||||
interface Props {
|
||||
messageId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const { downloadMessageSource } = useMail()
|
||||
|
||||
const buttonRef = ref<HTMLButtonElement | null>(null)
|
||||
const open = ref<boolean>(false)
|
||||
/** 키보드로 열었다 — 첫 항목에 초점 */
|
||||
const byKeyboard = ref<boolean>(false)
|
||||
|
||||
const ITEMS: readonly EditorMenuItem[] = [
|
||||
{ id: 'source', label: MAIL_READER_MORE_TEXT.downloadSource, icon: 'download' },
|
||||
]
|
||||
const trigger = computed<HTMLElement | null>(() => buttonRef.value)
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
if (open.value) {
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
// detail 0 — Enter · Space 로 눌렀다
|
||||
byKeyboard.value = event.detail === 0
|
||||
open.value = true
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'ArrowDown' || open.value) return
|
||||
event.preventDefault()
|
||||
byKeyboard.value = true
|
||||
open.value = true
|
||||
}
|
||||
|
||||
function onClose(restoreFocus: boolean) {
|
||||
open.value = false
|
||||
if (restoreFocus) buttonRef.value?.focus()
|
||||
}
|
||||
|
||||
function onSelect(id: string) {
|
||||
open.value = false
|
||||
buttonRef.value?.focus()
|
||||
if (id === 'source') void downloadMessageSource(props.messageId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="reader-more">
|
||||
<button
|
||||
ref="buttonRef"
|
||||
type="button"
|
||||
class="reader-more__button"
|
||||
:aria-label="MAIL_READER_MORE_TEXT.button"
|
||||
:title="MAIL_READER_MORE_TEXT.button"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="open"
|
||||
data-reader-more
|
||||
@click="onClick"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<AppIcon name="more-horizontal" />
|
||||
</button>
|
||||
<EditorMenu
|
||||
v-if="open"
|
||||
class="reader-more__menu"
|
||||
:label="MAIL_READER_MORE_TEXT.button"
|
||||
:items="ITEMS"
|
||||
:trigger="trigger"
|
||||
align="end"
|
||||
:focus-content="!byKeyboard"
|
||||
@select="onSelect"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 메뉴는 이 단추 바로 아래 — 도구 줄 아래 선 밑에서 연다 (EditorPopup) */
|
||||
.reader-more {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 40×44 — 새로고침 · 이전/다음과 같은 크기 */
|
||||
.reader-more__button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-icon-button);
|
||||
height: var(--size-touch);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.reader-more__button:hover,
|
||||
.reader-more__button[aria-expanded='true'] {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 메뉴 폭 200 (디자인 원본 MailActions4) */
|
||||
.reader-more__menu {
|
||||
min-width: 12.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -11,6 +11,7 @@ import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
@@ -457,3 +458,149 @@ describe('MessageReader 삭제 · 영구 삭제 — DELETE /messages/{id} 를
|
||||
expect(deleteButton(inTrashList.wrapper).text()).toBe('영구 삭제')
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자인 원본 MailActions4)', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
/** 내려받기를 시작하는 임시 <a download> 의 클릭 — jsdom 은 이동하지 못하므로 가로채 기록만 한다 */
|
||||
let downloads: (string | null)[]
|
||||
|
||||
const INBOX: Mailbox = { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 2, unreadCount: 0 }
|
||||
const JUNK: Mailbox = { ...INBOX, id: 'junk', name: '스팸메일함', role: 'junk' }
|
||||
const TRASH: Mailbox = { ...INBOX, id: 'trash', name: '휴지통', role: 'trash' }
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().startSession({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
downloads = []
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
|
||||
downloads.push(this.getAttribute('href'))
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountWith(
|
||||
options: { box?: string; mailboxes?: Mailbox[]; me?: MockResult; refresh?: MockResult } = {},
|
||||
) {
|
||||
const box = options.box ?? 'inbox'
|
||||
const mailboxes = options.mailboxes ?? [INBOX, JUNK, TRASH]
|
||||
requests = installMockApi((request) => {
|
||||
if (request.url === '/auth/me') return options.me ?? ok({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
if (request.url === '/auth/refresh') return options.refresh ?? ok({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
if (request.url === '/mailboxes') return ok({ items: mailboxes, pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
|
||||
if (request.url === '/messages' && request.method === 'GET') {
|
||||
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
}
|
||||
if (request.url === '/messages') return ok({ updatedIds: (request.data as MessagesPatch).ids, failedIds: [] })
|
||||
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
|
||||
})
|
||||
const store = useMailStore()
|
||||
store.mailboxes = mailboxes
|
||||
store.listSource = { kind: 'mailbox', mailboxId: box }
|
||||
const open = { ...DETAIL, mailboxIds: [box] }
|
||||
store.messages = [open, { ...DETAIL, id: 'm2', mailboxIds: [box], subject: '다음 메일' }]
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
|
||||
store.listStatus = 'ready'
|
||||
store.selected = open
|
||||
store.selectedId = open.id
|
||||
store.selectedStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push(`/mail/${box}/m1`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MessageReader, { global: { plugins: [pinia, router] }, attachTo: document.body })
|
||||
return { wrapper, router, store }
|
||||
}
|
||||
|
||||
const patches = () => requests.filter((request) => request.method === 'PATCH').map((request) => request.data)
|
||||
|
||||
it('도구 줄 — 답장 · 전체 답장 · 전달 | 스팸 신고 · 삭제 · ⋯(더 보기)', async () => {
|
||||
const { wrapper } = await mountWith()
|
||||
const toolbar = wrapper.get('.reader__toolbar')
|
||||
|
||||
expect(toolbar.findAll('.tool-button').map((button) => button.text())).toEqual([
|
||||
'답장',
|
||||
'전체 답장',
|
||||
'전달',
|
||||
'스팸 신고',
|
||||
'삭제',
|
||||
])
|
||||
const more = toolbar.get('[data-reader-more]')
|
||||
expect(more.attributes('aria-label')).toBe('더 보기')
|
||||
expect(more.attributes('aria-haspopup')).toBe('menu')
|
||||
expect(toolbar.element.lastElementChild?.contains(more.element)).toBe(true)
|
||||
})
|
||||
|
||||
it('스팸 신고 — 스팸메일함으로 옮기며 isJunk: true, 다음 메일을 연다 · [되돌리기]는 원래 메일함 · isJunk: false', async () => {
|
||||
const { wrapper, router, store } = await mountWith()
|
||||
|
||||
await wrapper.get('[data-reader-spam]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'junk', isJunk: true }])
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(toast?.message).toBe('메일 1통을 스팸메일함으로 옮겼습니다.')
|
||||
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await flushPromises()
|
||||
expect(patches().at(-1)).toEqual({ ids: ['m1'], mailboxId: 'inbox', isJunk: false })
|
||||
})
|
||||
|
||||
it('스팸메일함의 메일은 「스팸 아님」(강조) — 받은편지함으로 · isJunk: false, "(스팸 아님)" 알림', async () => {
|
||||
const { wrapper } = await mountWith({ box: 'junk' })
|
||||
const notSpam = wrapper.get('[data-reader-spam]')
|
||||
expect(notSpam.text()).toBe('스팸 아님')
|
||||
expect(notSpam.classes()).toContain('tool-button--accent')
|
||||
|
||||
await notSpam.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'inbox', isJunk: false }])
|
||||
expect(useToastStore().toasts[0]?.message).toBe('메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님).')
|
||||
})
|
||||
|
||||
it('스팸메일함을 모르면 「스팸 신고」를 그리지 않는다', async () => {
|
||||
const { wrapper } = await mountWith({ mailboxes: [INBOX, TRASH] })
|
||||
|
||||
expect(wrapper.find('[data-reader-spam]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('⋯ → "원문 내려받기 (.eml)" — 이동하기 전에 세션을 확인하고(confirmSession) 원문 주소로 내려받는다', async () => {
|
||||
const { wrapper } = await mountWith()
|
||||
const more = wrapper.get('[data-reader-more]')
|
||||
|
||||
await more.trigger('keydown', { key: 'ArrowDown' })
|
||||
await flushPromises()
|
||||
const items = wrapper.findAll('[role="menu"] [role="menuitem"]')
|
||||
expect(items.map((item) => item.text())).toEqual(['원문 내려받기 (.eml)'])
|
||||
expect(document.activeElement).toBe(items[0]?.element)
|
||||
|
||||
await items[0]?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /auth/me'])
|
||||
expect(downloads).toEqual(['/api/messages/m1/source'])
|
||||
expect(wrapper.find('[role="menu"]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(more.element)
|
||||
})
|
||||
|
||||
it('세션을 확인하지 못하면(갱신도 401) 내려받지 않고 세션 만료로 다룬다', async () => {
|
||||
const { wrapper } = await mountWith({ me: fail(401, 'AUTH_001'), refresh: fail(401, 'AUTH_001') })
|
||||
|
||||
await wrapper.get('[data-reader-more]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[role="menuitem"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(downloads).toEqual([])
|
||||
expect(useAuthStore().sessionExpired).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
// 본문 HTML 은 MailBodyFrame(격리된 iframe)에만 그린다.
|
||||
// 툴바와 본문은 콘텐츠 열(tokens.css --content-*)을 따른다 — 분할 보기 읽기 · 목록 보기 읽기 · 쓰기 공통.
|
||||
// 검색 결과에서 연 메일은 받는 사람 아래에 "메일함 받은편지함" 한 줄을 더한다 (디자인 README "3단계 › 검색").
|
||||
// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다.
|
||||
// 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 원문 내려받기) (디자인 README "4단계 › 스팸 · 원문").
|
||||
// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다. 「스팸 신고」는 MessageSpamButton, ⋯ 는 MessageMoreMenu.
|
||||
// 삭제 뒤 · 없는 메일에서 돌아가는 곳은 지금 목록(메일함 또는 검색 결과)이다.
|
||||
import { computed } from 'vue'
|
||||
|
||||
@@ -15,6 +16,8 @@ import MailToolButton from '@/components/mail/MailToolButton.vue'
|
||||
import MailToolbarDivider from '@/components/mail/MailToolbarDivider.vue'
|
||||
import MessageAttachments from '@/components/mail/MessageAttachments.vue'
|
||||
import MessageDeleteButton from '@/components/mail/MessageDeleteButton.vue'
|
||||
import MessageMoreMenu from '@/components/mail/MessageMoreMenu.vue'
|
||||
import MessageSpamButton from '@/components/mail/MessageSpamButton.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { formatMailDateTime, initialOf } from '@/shared/utils/format'
|
||||
@@ -99,10 +102,15 @@ function leaveToList() {
|
||||
:to="{ name: 'mail-compose', query: { forward: message.id } }"
|
||||
/>
|
||||
<MailToolbarDivider v-if="layout === 'split'" />
|
||||
<MessageSpamButton
|
||||
:message="message"
|
||||
:layout="layout"
|
||||
/>
|
||||
<MessageDeleteButton
|
||||
:message="message"
|
||||
:layout="layout"
|
||||
/>
|
||||
<MessageMoreMenu :message-id="message.id" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
// 읽기 화면의 「스팸 신고」 · 「스팸 아님」 — 열린 메일 한 통 (디자인 README "4단계 › 스팸 · 원문 · 오래 걸리는 처리" · useMessageRemoval)
|
||||
// - 스팸메일함에 든 메일이면 「스팸 아님」(받은편지함 아이콘 · 강조 글자 #0A5F67 굵게) — 받은편지함으로 옮기고 "스팸 아님" 표시
|
||||
// - 그 밖은 「스팸 신고」 — 스팸메일함으로 옮기고 스팸 표시. 알림에 [되돌리기](원래 메일함 · 원래 표시로)
|
||||
// - 옮길 메일함(스팸메일함 · 받은편지함)을 모르면(메일함 목록을 아직 못 받음) 그리지 않는다
|
||||
// - 옮긴 뒤: 삭제와 같다 — 분할 보기는 다음 메일, 목록 보기는 목록으로 (leaveRemoved)
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import MailToolButton from '@/components/mail/MailToolButton.vue'
|
||||
import { useMessageRemoval } from '@/composables/useMessageRemoval'
|
||||
import { MAIL_SPAM_TEXT } from '@/shared/constants/mail'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
message: MessageSummary
|
||||
/** split: 옮기면 다음 메일을 연다 · list: 옮기면 목록으로 */
|
||||
layout: 'split' | 'list'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const removal = useMessageRemoval()
|
||||
|
||||
const working = ref<boolean>(false)
|
||||
|
||||
const inJunk = computed(() => removal.isInJunk(props.message))
|
||||
const available = computed(() => mailStore.mailboxByRole(inJunk.value ? 'inbox' : 'junk') !== null)
|
||||
|
||||
async function onClick() {
|
||||
if (working.value) return
|
||||
const source = mailStore.listSource
|
||||
working.value = true
|
||||
try {
|
||||
const outcome = await removal.moveSpam(props.message)
|
||||
await removal.leaveRemoved(outcome, source, props.layout)
|
||||
} finally {
|
||||
working.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MailToolButton
|
||||
v-if="available"
|
||||
:icon="inJunk ? 'inbox' : 'alert-octagon'"
|
||||
:label="inJunk ? MAIL_SPAM_TEXT.notSpam : MAIL_SPAM_TEXT.report"
|
||||
:tone="inJunk ? 'accent' : 'default'"
|
||||
:disabled="working"
|
||||
data-reader-spam
|
||||
@click="onClick"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 툴바 메뉴 — 글꼴 · 글자 크기 · 더보기 (디자인 원본 ComposeEditor "더보기 메뉴" · "글꼴 · 글자 크기")
|
||||
// 항목 높이 40 · 아이콘 16 · 이름 · 단축키(흐린 글자) · 켜진 것은 오른쪽 ✓ · 한 단계 더 여는 것은 ›
|
||||
// 같은 모양을 쓰는 곳: 여러 통 처리의 이동 ▾ · 메일함 패널의 ⋯ (되돌릴 수 없는 항목은 tone 'danger' — 빨간 글자) · 읽기 화면의 ⋯
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import EditorColorGlyph from '@/components/mail/editor/EditorColorGlyph.vue'
|
||||
import EditorPopup from '@/components/mail/editor/EditorPopup.vue'
|
||||
@@ -52,7 +53,7 @@ function isToggle(role: string | undefined): boolean {
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="menu__item"
|
||||
:class="['menu__item', { 'menu__item--danger': item.tone === 'danger' }]"
|
||||
:role="item.role ?? 'menuitem'"
|
||||
:aria-checked="isToggle(item.role) ? Boolean(item.checked) : undefined"
|
||||
:aria-haspopup="item.opens"
|
||||
@@ -125,6 +126,19 @@ function isToggle(role: string | undefined): boolean {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 되돌릴 수 없는 일 (디자인 원본 MailboxManage "지우기 · 비우기") */
|
||||
.menu__item--danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.menu__item--danger:hover:not(:disabled) {
|
||||
background: var(--color-danger-surface);
|
||||
}
|
||||
|
||||
.menu__item--danger .menu__icon {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.menu__icon {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface EditorMenuEntry {
|
||||
hint?: string
|
||||
checked?: boolean
|
||||
disabled?: boolean
|
||||
/** danger: 되돌릴 수 없는 일 — 빨간 글자 (메일함 ⋯ 메뉴의 지우기 · 비우기) */
|
||||
tone?: 'danger'
|
||||
/** 한 단계 더 여는 항목 (›) — 메뉴인지 대화상자인지 */
|
||||
opens?: 'menu' | 'dialog'
|
||||
/** 미리보기 모양 (글꼴 · 크기) — EditorMenu 의 class 이름 */
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// 메일함 패널의 대화상자 요청 — 패널(MailboxPanel)이 열고 대화상자 묶음(MailboxDialogs)이 그린다
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
|
||||
export type MailboxDialogRequest =
|
||||
/** 새 메일함 — parent 가 없으면 맨 위 ("내 메일함"), 있으면 그 안 */
|
||||
| { kind: 'create'; parent: Mailbox | null }
|
||||
| { kind: 'rename'; mailbox: Mailbox }
|
||||
| { kind: 'delete'; mailbox: Mailbox }
|
||||
| { kind: 'empty'; mailbox: Mailbox }
|
||||
@@ -0,0 +1,258 @@
|
||||
<script setup lang="ts">
|
||||
// 비밀번호 바꾸기 (디자인 원본 SettingsAccount · 계약 1절 "PUT /api/auth/password")
|
||||
// 칸 셋(최대 폭 420): 지금 비밀번호 · 새 비밀번호 · 새 비밀번호 확인 — 앞의 두 칸 끝에 보기/숨기기 (확인 칸은 새 비밀번호를 따른다)
|
||||
// - 보내기 전에 서버와 같은 규칙을 본다: 8자 이상 · 지금과 다름(어기면 새 비밀번호 안내 글이 빨간 오류가 된다) · 확인 칸 일치("새 비밀번호가 서로 다릅니다.")
|
||||
// · 지금 비밀번호를 비움 · 128자 초과(사전 문구 VAL_001). 처음 걸린 칸으로 초점을 옮긴다
|
||||
// - 서버: 지금 비밀번호가 틀림(AUTH_005)은 지금 비밀번호 칸 아래, 새 비밀번호가 엔진 규칙에 어긋남(VAL_001)은 새 비밀번호 칸 아래에
|
||||
// 사전 문구로 (알림은 끈다). 그 밖(요청 과다 · 메일 서버 장애)은 인터셉터의 알림. 기다리는 사이 화면을 떠났으면 끈 거절도 알림으로 (notifyApiError).
|
||||
// 요청 한도는 30초다 (useAuth — 서버가 엔진을 두 번 부르고 다른 세션을 끊는다)
|
||||
// - 바꾸면 "비밀번호를 바꿨습니다. 다른 기기 · 브라우저의 로그인은 끊었습니다." — 세 칸을 비우고 보기를 끈다. 무엇이든 다시 치면 내린다
|
||||
// 고정 id — #password-current · #password-next · #password-confirm · #password-submit · #password-status
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import PasswordField from '@/components/settings/PasswordField.vue'
|
||||
import { getErrorMessage, notifyApiError, toApiError } from '@/composables/useApi'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { PASSWORD_TEXT } from '@/shared/constants/account'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import {
|
||||
hasPasswordChangeProblem,
|
||||
passwordChangeProblems,
|
||||
type PasswordChangeProblems,
|
||||
} from '@/shared/utils/password-change'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
/** 칸 아래에 보이는 서버 거절 — 알림은 끈다 */
|
||||
const INLINE_CODES: readonly ErrorCode[] = ['AUTH_005', 'VAL_001']
|
||||
const NO_PROBLEMS: PasswordChangeProblems = { current: null, next: null, confirm: null }
|
||||
|
||||
const { changePassword } = useAuth()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const currentRef = ref<InstanceType<typeof PasswordField> | null>(null)
|
||||
const nextRef = ref<InstanceType<typeof PasswordField> | null>(null)
|
||||
const confirmRef = ref<InstanceType<typeof PasswordField> | null>(null)
|
||||
|
||||
const current = ref<string>('')
|
||||
const next = ref<string>('')
|
||||
const confirm = ref<string>('')
|
||||
const showCurrent = ref<boolean>(false)
|
||||
const showNext = ref<boolean>(false)
|
||||
const problems = ref<PasswordChangeProblems>(NO_PROBLEMS)
|
||||
/** 서버가 거절한 까닭 — 그 칸 아래에 (사전 문구) */
|
||||
const serverError = ref<{ field: 'current' | 'next'; message: string } | null>(null)
|
||||
const submitting = ref<boolean>(false)
|
||||
const done = ref<boolean>(false)
|
||||
/** 화면이 사라졌다 — 칸 아래 글을 볼 곳이 없다 */
|
||||
let unmounted = false
|
||||
|
||||
const currentError = computed(() => {
|
||||
if (problems.value.current === 'missing') return getErrorMessage('VAL_001')
|
||||
return serverError.value?.field === 'current' ? serverError.value.message : null
|
||||
})
|
||||
const nextError = computed(() => {
|
||||
if (problems.value.next === 'tooLong') return getErrorMessage('VAL_001')
|
||||
return serverError.value?.field === 'next' ? serverError.value.message : null
|
||||
})
|
||||
const confirmError = computed(() => (problems.value.confirm === 'mismatch' ? PASSWORD_TEXT.mismatch : null))
|
||||
|
||||
// 칸을 고치면 그 칸에 걸린 오류와 "바꿨습니다"를 내린다 (지금 비밀번호를 고치면 "지금과 같음"도 다시 본다)
|
||||
function setCurrent(value: string) {
|
||||
current.value = value
|
||||
problems.value = { ...problems.value, current: null, next: problems.value.next === 'rule' ? null : problems.value.next }
|
||||
if (serverError.value?.field === 'current') serverError.value = null
|
||||
done.value = false
|
||||
}
|
||||
|
||||
function setNext(value: string) {
|
||||
next.value = value
|
||||
problems.value = { ...problems.value, next: null, confirm: null }
|
||||
if (serverError.value?.field === 'next') serverError.value = null
|
||||
done.value = false
|
||||
}
|
||||
|
||||
function setConfirm(value: string) {
|
||||
confirm.value = value
|
||||
problems.value = { ...problems.value, confirm: null }
|
||||
done.value = false
|
||||
}
|
||||
|
||||
function focusFirstProblem(found: PasswordChangeProblems) {
|
||||
if (found.current) currentRef.value?.focus()
|
||||
else if (found.next) nextRef.value?.focus()
|
||||
else if (found.confirm) confirmRef.value?.focus()
|
||||
}
|
||||
|
||||
function clearFields() {
|
||||
current.value = ''
|
||||
next.value = ''
|
||||
confirm.value = ''
|
||||
showCurrent.value = false
|
||||
showNext.value = false
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (submitting.value) return
|
||||
done.value = false
|
||||
serverError.value = null
|
||||
const found = passwordChangeProblems({ current: current.value, next: next.value, confirm: confirm.value })
|
||||
problems.value = found
|
||||
if (hasPasswordChangeProblem(found)) {
|
||||
focusFirstProblem(found)
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await changePassword({ currentPassword: current.value, newPassword: next.value }, { silent: INLINE_CODES })
|
||||
clearFields()
|
||||
problems.value = NO_PROBLEMS
|
||||
done.value = true
|
||||
} catch (error) {
|
||||
const apiError = toApiError(error)
|
||||
if (unmounted) {
|
||||
if (INLINE_CODES.includes(apiError.code)) notifyApiError(apiError)
|
||||
return
|
||||
}
|
||||
if (apiError.code === 'AUTH_005') {
|
||||
serverError.value = { field: 'current', message: apiError.message }
|
||||
currentRef.value?.focus()
|
||||
} else if (apiError.code === 'VAL_001') {
|
||||
serverError.value = { field: 'next', message: apiError.message }
|
||||
nextRef.value?.focus()
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="password-form"
|
||||
novalidate
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<!-- 비밀번호 관리자가 어느 계정의 비밀번호인지 알게 한다 (보이지 않는다) -->
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
:value="authStore.user?.email ?? ''"
|
||||
hidden
|
||||
readonly
|
||||
>
|
||||
<PasswordField
|
||||
id="password-current"
|
||||
ref="currentRef"
|
||||
:label="PASSWORD_TEXT.current"
|
||||
:model-value="current"
|
||||
autocomplete="current-password"
|
||||
:shown="showCurrent"
|
||||
:toggle-label="PASSWORD_TEXT.showCurrent"
|
||||
:error="currentError"
|
||||
@update:model-value="setCurrent"
|
||||
@toggle="showCurrent = !showCurrent"
|
||||
/>
|
||||
<PasswordField
|
||||
id="password-next"
|
||||
ref="nextRef"
|
||||
:label="PASSWORD_TEXT.next"
|
||||
:model-value="next"
|
||||
autocomplete="new-password"
|
||||
:shown="showNext"
|
||||
:toggle-label="PASSWORD_TEXT.showNext"
|
||||
:hint="PASSWORD_TEXT.hint"
|
||||
:hint-is-error="problems.next === 'rule'"
|
||||
:error="nextError"
|
||||
@update:model-value="setNext"
|
||||
@toggle="showNext = !showNext"
|
||||
/>
|
||||
<PasswordField
|
||||
id="password-confirm"
|
||||
ref="confirmRef"
|
||||
:label="PASSWORD_TEXT.confirm"
|
||||
:model-value="confirm"
|
||||
autocomplete="new-password"
|
||||
:shown="showNext"
|
||||
:error="confirmError"
|
||||
@update:model-value="setConfirm"
|
||||
/>
|
||||
<div class="password-form__actions">
|
||||
<button
|
||||
id="password-submit"
|
||||
type="submit"
|
||||
class="password-form__submit"
|
||||
:disabled="submitting"
|
||||
:aria-busy="submitting"
|
||||
>
|
||||
{{ PASSWORD_TEXT.submit }}
|
||||
</button>
|
||||
<span
|
||||
id="password-status"
|
||||
class="password-form__status"
|
||||
role="status"
|
||||
>
|
||||
<template v-if="done">
|
||||
<AppIcon
|
||||
name="check"
|
||||
:size="15"
|
||||
:stroke-width="2.2"
|
||||
/>
|
||||
<span>{{ PASSWORD_TEXT.done }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.password-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-18);
|
||||
max-width: var(--form-max-width);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.password-form__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-12);
|
||||
}
|
||||
|
||||
.password-form__submit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-22);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
font-size: var(--font-size-emphasis);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.password-form__submit:hover:not(:disabled) {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.password-form__submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.password-form__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
color: var(--color-success-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
// 비밀번호 칸 하나 — 이름 · 칸(44) · [보기/숨기기](44 · 눈 아이콘 · aria-pressed) · 안내 · 오류 (디자인 원본 SettingsAccount)
|
||||
// - 오류는 칸 아래 빨간 글(alert-circle) · 칸 테두리도 빨강. hintIsError 면 안내 글 자체가 오류가 된다 (새 비밀번호의 규칙)
|
||||
// - 보기 단추의 이름은 바뀌지 않는다 — 켜짐은 aria-pressed 로 알린다 (아이콘만 눈 ↔ 빗금 눈)
|
||||
// - 칸의 설명(aria-describedby)은 안내 → 오류 순서
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
label: string
|
||||
modelValue: string
|
||||
autocomplete: 'current-password' | 'new-password'
|
||||
/** 글자를 보이는 중 */
|
||||
shown: boolean
|
||||
/** 보기 단추의 이름 — 없으면 단추를 두지 않는다 (확인 칸은 새 비밀번호의 단추를 따른다) */
|
||||
toggleLabel?: string
|
||||
hint?: string
|
||||
/** 안내 글이 그대로 오류다 */
|
||||
hintIsError?: boolean
|
||||
/** 오류 문구 — 사전 문구 또는 디자인 문구 */
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'toggle'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
toggleLabel: '',
|
||||
hint: '',
|
||||
hintIsError: false,
|
||||
error: null,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const hintId = computed(() => `${props.id}-hint`)
|
||||
const errorId = computed(() => `${props.id}-error`)
|
||||
const invalid = computed(() => Boolean(props.error) || props.hintIsError)
|
||||
const describedBy = computed(() => {
|
||||
const ids = [props.hint ? hintId.value : '', props.error ? errorId.value : ''].filter(Boolean)
|
||||
return ids.length > 0 ? ids.join(' ') : undefined
|
||||
})
|
||||
|
||||
function onInput(event: Event) {
|
||||
emit('update:modelValue', (event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focus: () => inputRef.value?.focus(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="password-field">
|
||||
<label
|
||||
:for="id"
|
||||
class="password-field__label"
|
||||
>{{ label }}</label>
|
||||
<div :class="['form-input', 'password-field__box', { 'is-invalid': invalid }]">
|
||||
<input
|
||||
:id="id"
|
||||
ref="inputRef"
|
||||
class="form-input__control"
|
||||
:type="shown ? 'text' : 'password'"
|
||||
:autocomplete="autocomplete"
|
||||
:value="modelValue"
|
||||
spellcheck="false"
|
||||
autocapitalize="off"
|
||||
:aria-invalid="invalid ? 'true' : 'false'"
|
||||
:aria-describedby="describedBy"
|
||||
@input="onInput"
|
||||
>
|
||||
<button
|
||||
v-if="toggleLabel"
|
||||
type="button"
|
||||
class="password-field__toggle"
|
||||
:aria-label="toggleLabel"
|
||||
:title="toggleLabel"
|
||||
:aria-pressed="shown"
|
||||
:aria-controls="id"
|
||||
@click="emit('toggle')"
|
||||
>
|
||||
<AppIcon :name="shown ? 'eye-off' : 'eye'" />
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-if="hint"
|
||||
:id="hintId"
|
||||
:class="['form-message', { 'form-message--error': hintIsError }]"
|
||||
>
|
||||
<AppIcon
|
||||
v-if="hintIsError"
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ hint }}</span>
|
||||
</p>
|
||||
<p
|
||||
v-if="error"
|
||||
:id="errorId"
|
||||
class="form-message form-message--error"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ error }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.password-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.password-field__label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 상자 안에 칸과 단추 — 상자가 테두리 · 초점을 그린다 (base.css .form-input). 칸 · 단추가 상자 높이(44)를 채운다 */
|
||||
.password-field__box {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 44 */
|
||||
.password-field__toggle {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-touch);
|
||||
border: 0;
|
||||
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.password-field__toggle:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.password-field__toggle[aria-pressed='true'] {
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 화면의 틀 — 제목(18px) · 설명 + 내용 (디자인 원본 SettingsAccount · SettingsVacation)
|
||||
// 콘텐츠 열은 읽기 · 쓰기 화면과 같은 값이다 — 최대 800 · 좌우 32 · 위 20 · 아래 36 (tokens.css --content-*), 묶음 사이 22.
|
||||
// 사용 예: <SettingsPageFrame title-id="password-page-title" :title="…" :description="…">…</SettingsPageFrame>
|
||||
interface Props {
|
||||
/** 제목(h1)의 id — main 이 aria-labelledby 로 가리킨다 */
|
||||
titleId: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
class="settings-page"
|
||||
:aria-labelledby="titleId"
|
||||
>
|
||||
<div class="settings-page__column">
|
||||
<div class="settings-page__intro">
|
||||
<h1
|
||||
:id="titleId"
|
||||
class="settings-page__title"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p class="settings-page__description">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-page {
|
||||
flex: 999 1 35rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-page__column {
|
||||
max-width: var(--content-max-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-22);
|
||||
padding: var(--content-block-start) var(--content-inline) var(--content-block-end);
|
||||
}
|
||||
|
||||
.settings-page__intro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.settings-page__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-title-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
}
|
||||
|
||||
.settings-page__description {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 이 칸만 따로 스크롤한다 */
|
||||
@media (min-width: 70rem) {
|
||||
.settings-page {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,296 @@
|
||||
<script setup lang="ts">
|
||||
// 부재중 자동 응답 설정 (디자인 원본 SettingsVacation · 계약 2절 "부재중 자동 응답")
|
||||
// 위에서부터 [x] 사용(44) → 기간(VacationPeriodFields) → 제목 · 내용(VacationMessageFields) → [저장] + 상태 글 → 미리 보기(VacationPreview).
|
||||
// - 끄면 아래 칸을 흐리게(쓴 글은 남긴다). 켤 때만 제목 · 내용이 필요하다 — 꺼 두면 빈 칸도 저장된다
|
||||
// - 저장하기 전에 막는 것(켜 둘 때): 기간(날짜가 없음 · 시작이 끝보다 늦음) · 빈 제목 · 빈 내용 — 그 칸 아래에 사전 문구(VAL_001), 첫 칸으로 초점.
|
||||
// 서버의 거절(VAL_001)은 저장 단추 옆에 사전 문구로 (알림은 끈다) — 저장하는 사이 화면을 떠났으면 알림으로 (notifyApiError)
|
||||
// - 저장하면 "저장했습니다. 10월 30일(금)부터 11월 3일(화)까지 자동으로 답합니다." 등 — 무엇이든 다시 바꾸면 내린다
|
||||
// - 나가기 확인: 불러온(저장한 뒤에는 저장한) 값과 다르면 앱 안 이동은 대화상자, 탭 닫기 · 새로고침은 브라우저 확인창 (서명 설정과 같다).
|
||||
// 저장하는 중 · 세션이 끝났을 때는 앱 안 이동을 묻지 않는다
|
||||
// 고정 id — #vacation-enabled · #vacation-save · #vacation-save-status ·
|
||||
// 나가기 확인 #vacation-leave-dialog · #vacation-leave-stay · #vacation-leave-confirm (칸의 id 는 각 칸 컴포넌트에)
|
||||
import { computed, onBeforeUnmount, reactive, ref, shallowRef } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import LeaveConfirmDialog from '@/components/feedback/LeaveConfirmDialog.vue'
|
||||
import VacationMessageFields from '@/components/settings/VacationMessageFields.vue'
|
||||
import VacationPeriodFields from '@/components/settings/VacationPeriodFields.vue'
|
||||
import VacationPreview from '@/components/settings/VacationPreview.vue'
|
||||
import { getErrorMessage, notifyApiError, toApiError } from '@/composables/useApi'
|
||||
import { useLeaveConfirm } from '@/composables/useLeaveConfirm'
|
||||
import { VACATION_LEAVE_DIALOG, VACATION_TEXT } from '@/shared/constants/mail-vacation'
|
||||
import type { MailVacation } from '@/shared/types/mail'
|
||||
import { formatDate } from '@/shared/utils/format'
|
||||
import {
|
||||
sameVacationForm,
|
||||
vacationFormOf,
|
||||
vacationPayloadOf,
|
||||
vacationProblems,
|
||||
vacationSavedText,
|
||||
type VacationForm,
|
||||
type VacationPeriod,
|
||||
type VacationProblem,
|
||||
} from '@/shared/utils/mail-vacation'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailVacationStore } from '@/stores/mail-vacation.store'
|
||||
|
||||
interface Props {
|
||||
/** 서버의 설정 — 이 값으로 칸을 채운다 */
|
||||
vacation: MailVacation
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const vacationStore = useMailVacationStore()
|
||||
|
||||
const periodRef = ref<InstanceType<typeof VacationPeriodFields> | null>(null)
|
||||
const messageRef = ref<InstanceType<typeof VacationMessageFields> | null>(null)
|
||||
|
||||
const form = reactive<VacationForm>(vacationFormOf(props.vacation))
|
||||
/** 불러온(저장한 뒤에는 저장한) 값 — 이것과 다르면 바꾼 것이다 */
|
||||
const saved = shallowRef<VacationForm>(vacationFormOf(props.vacation))
|
||||
const saveState = ref<'idle' | 'saving' | 'saved'>('idle')
|
||||
const savedText = ref<string>('')
|
||||
const problems = ref<VacationProblem[]>([])
|
||||
/** 서버가 거절한 까닭 (사전 문구) */
|
||||
const saveError = ref<string | null>(null)
|
||||
/** 화면이 사라졌다 — 저장 단추 옆 글을 볼 곳이 없다 */
|
||||
let unmounted = false
|
||||
|
||||
const changed = computed(() => !sameVacationForm(form, saved.value))
|
||||
const problemText = getErrorMessage('VAL_001')
|
||||
|
||||
/** 그 칸에 걸린 문제의 문구 — 없으면 null */
|
||||
function problemOf(problem: VacationProblem): string | null {
|
||||
return problems.value.includes(problem) ? problemText : null
|
||||
}
|
||||
|
||||
const { confirming: leaveConfirming, stay: stayOnForm, leave: leaveForm } = useLeaveConfirm({
|
||||
active: () => changed.value && saveState.value !== 'saving' && authStore.isLoggedIn,
|
||||
unloadActive: () => changed.value && authStore.isLoggedIn,
|
||||
})
|
||||
|
||||
/** 칸을 고친다 — 그 칸의 문제와 "저장했습니다" · 서버 거절을 내린다 */
|
||||
function edit(patch: Partial<VacationForm>, clears: readonly VacationProblem[]) {
|
||||
Object.assign(form, patch)
|
||||
problems.value = problems.value.filter((problem) => !clears.includes(problem))
|
||||
saveError.value = null
|
||||
if (saveState.value === 'saved') saveState.value = 'idle'
|
||||
}
|
||||
|
||||
/** 켜고 끄기 — 끄면 칸이 흐려지므로 걸려 있던 문제도 내린다 */
|
||||
function setEnabled(event: Event) {
|
||||
edit({ isEnabled: (event.target as HTMLInputElement).checked }, ['period', 'subject', 'textBody'])
|
||||
}
|
||||
|
||||
/** 기간 정하기로 바꾸는데 날짜가 비었으면 시작을 오늘로 채운다 (끝은 사용자가 고른다) */
|
||||
function setPeriod(period: VacationPeriod) {
|
||||
const prefill = period === 'range' && !form.fromDay && !form.toDay ? { fromDay: formatDate(new Date()) } : {}
|
||||
edit({ period, ...prefill }, ['period'])
|
||||
}
|
||||
|
||||
function focusFirst(found: readonly VacationProblem[]) {
|
||||
if (found.includes('period')) periodRef.value?.focus()
|
||||
else if (found.includes('subject')) messageRef.value?.focusSubject()
|
||||
else if (found.includes('textBody')) messageRef.value?.focusBody()
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (saveState.value === 'saving') return
|
||||
saveError.value = null
|
||||
const found = vacationProblems(form)
|
||||
problems.value = found
|
||||
if (found.length > 0) {
|
||||
focusFirst(found)
|
||||
return
|
||||
}
|
||||
const sent: VacationForm = { ...form }
|
||||
saveState.value = 'saving'
|
||||
try {
|
||||
const result = await vacationStore.save(vacationPayloadOf(sent), { silent: ['VAL_001'] })
|
||||
const savedForm = vacationFormOf(result)
|
||||
// 저장하는 동안 더 고치지 않았으면 서버가 저장한 값으로 맞춘다 (흐린 칸의 맞지 않는 기간은 비워 저장했다)
|
||||
if (sameVacationForm(form, sent)) Object.assign(form, savedForm)
|
||||
saved.value = savedForm
|
||||
savedText.value = vacationSavedText(result)
|
||||
saveState.value = 'saved'
|
||||
} catch (error) {
|
||||
saveState.value = 'idle'
|
||||
const apiError = toApiError(error)
|
||||
if (apiError.code !== 'VAL_001') return
|
||||
// 알림을 끈 거절 — 화면을 떠났으면 알림으로 알린다 (보내는 사이 떠나도 묻지 않는다)
|
||||
if (unmounted) notifyApiError(apiError)
|
||||
else saveError.value = apiError.message
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vacation-form">
|
||||
<label class="vacation-form__toggle">
|
||||
<input
|
||||
id="vacation-enabled"
|
||||
type="checkbox"
|
||||
class="vacation-form__checkbox"
|
||||
:checked="form.isEnabled"
|
||||
@change="setEnabled"
|
||||
>
|
||||
<span>{{ VACATION_TEXT.enable }}</span>
|
||||
</label>
|
||||
|
||||
<VacationPeriodFields
|
||||
ref="periodRef"
|
||||
:period="form.period"
|
||||
:from-day="form.fromDay"
|
||||
:to-day="form.toDay"
|
||||
:disabled="!form.isEnabled"
|
||||
:error="problemOf('period')"
|
||||
@update:period="setPeriod"
|
||||
@update:from-day="edit({ fromDay: $event }, ['period'])"
|
||||
@update:to-day="edit({ toDay: $event }, ['period'])"
|
||||
/>
|
||||
|
||||
<VacationMessageFields
|
||||
ref="messageRef"
|
||||
:subject="form.subject"
|
||||
:text-body="form.textBody"
|
||||
:disabled="!form.isEnabled"
|
||||
:subject-error="problemOf('subject')"
|
||||
:body-error="problemOf('textBody')"
|
||||
@update:subject="edit({ subject: $event }, ['subject'])"
|
||||
@update:text-body="edit({ textBody: $event }, ['textBody'])"
|
||||
/>
|
||||
|
||||
<div class="vacation-form__actions">
|
||||
<button
|
||||
id="vacation-save"
|
||||
type="button"
|
||||
class="vacation-form__save"
|
||||
:disabled="saveState === 'saving'"
|
||||
:aria-busy="saveState === 'saving'"
|
||||
@click="save"
|
||||
>
|
||||
{{ saveState === 'saving' ? VACATION_TEXT.saving : VACATION_TEXT.save }}
|
||||
</button>
|
||||
<span
|
||||
id="vacation-save-status"
|
||||
:class="['vacation-form__status', { 'is-error': saveError }]"
|
||||
role="status"
|
||||
>
|
||||
<template v-if="saveError">
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="15"
|
||||
/>
|
||||
<span>{{ saveError }}</span>
|
||||
</template>
|
||||
<template v-else-if="saveState === 'saved'">
|
||||
<AppIcon
|
||||
name="check"
|
||||
:size="15"
|
||||
:stroke-width="2.2"
|
||||
/>
|
||||
<span>{{ savedText }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<hr class="vacation-form__rule">
|
||||
|
||||
<VacationPreview
|
||||
:enabled="form.isEnabled"
|
||||
:sender-name="authStore.user?.name ?? ''"
|
||||
:sender-email="authStore.user?.email ?? ''"
|
||||
:subject="form.subject"
|
||||
:text-body="form.textBody"
|
||||
/>
|
||||
|
||||
<!-- 저장하지 않은 채 떠나려 할 때 — 답할 때까지 이동이 기다린다 (useLeaveConfirm) -->
|
||||
<LeaveConfirmDialog
|
||||
:open="leaveConfirming"
|
||||
id-prefix="vacation-leave"
|
||||
:copy="VACATION_LEAVE_DIALOG"
|
||||
@stay="stayOnForm"
|
||||
@leave="leaveForm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vacation-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-22);
|
||||
}
|
||||
|
||||
.vacation-form__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-touch);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vacation-form__checkbox {
|
||||
width: var(--size-option-input);
|
||||
height: var(--size-option-input);
|
||||
margin: 0;
|
||||
accent-color: var(--color-brand);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vacation-form__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-12);
|
||||
}
|
||||
|
||||
.vacation-form__save {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-22);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
font-size: var(--font-size-emphasis);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vacation-form__save:hover:not(:disabled) {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.vacation-form__save:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.vacation-form__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
color: var(--color-success-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vacation-form__status.is-error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.vacation-form__rule {
|
||||
width: 100%;
|
||||
height: var(--border-width);
|
||||
margin: 0;
|
||||
border: 0;
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
// 부재중 자동 응답의 제목(44 · 최대 폭 520 · 200자) · 내용(여러 줄 · 4,000자 · 글자만) (디자인 원본 SettingsVacation)
|
||||
// 끈 동안은 흐리게(쓴 글은 남긴다). 문제가 있으면 그 칸 아래에 사전 문구 — 검사는 부모(VacationForm)가 한다.
|
||||
// 고정 id — #vacation-subject · #vacation-subject-error · #vacation-body · #vacation-body-hint · #vacation-body-error
|
||||
import { ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { VACATION_BODY_MAX_LENGTH, VACATION_SUBJECT_MAX_LENGTH, VACATION_TEXT } from '@/shared/constants/mail-vacation'
|
||||
|
||||
interface Props {
|
||||
subject: string
|
||||
textBody: string
|
||||
disabled: boolean
|
||||
/** 칸마다의 문제 문구 — 없으면 null */
|
||||
subjectError?: string | null
|
||||
bodyError?: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:subject', value: string): void
|
||||
(e: 'update:textBody', value: string): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
subjectError: null,
|
||||
bodyError: null,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const subjectRef = ref<HTMLInputElement | null>(null)
|
||||
const bodyRef = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
function valueOf(event: Event): string {
|
||||
return (event.target as HTMLInputElement | HTMLTextAreaElement).value
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focusSubject: () => subjectRef.value?.focus(),
|
||||
focusBody: () => bodyRef.value?.focus(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vacation-message">
|
||||
<div class="vacation-message__field">
|
||||
<label
|
||||
for="vacation-subject"
|
||||
class="vacation-message__label"
|
||||
>{{ VACATION_TEXT.subject }}</label>
|
||||
<input
|
||||
id="vacation-subject"
|
||||
ref="subjectRef"
|
||||
class="form-input vacation-message__subject"
|
||||
type="text"
|
||||
:value="subject"
|
||||
:maxlength="VACATION_SUBJECT_MAX_LENGTH"
|
||||
:disabled="disabled"
|
||||
:aria-invalid="subjectError ? 'true' : 'false'"
|
||||
:aria-describedby="subjectError ? 'vacation-subject-error' : undefined"
|
||||
@input="emit('update:subject', valueOf($event))"
|
||||
>
|
||||
<p
|
||||
v-if="subjectError"
|
||||
id="vacation-subject-error"
|
||||
class="form-message form-message--error"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ subjectError }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="vacation-message__field">
|
||||
<label
|
||||
for="vacation-body"
|
||||
class="vacation-message__label"
|
||||
>{{ VACATION_TEXT.body }}</label>
|
||||
<textarea
|
||||
id="vacation-body"
|
||||
ref="bodyRef"
|
||||
class="form-input vacation-message__body"
|
||||
rows="6"
|
||||
:value="textBody"
|
||||
:maxlength="VACATION_BODY_MAX_LENGTH"
|
||||
:disabled="disabled"
|
||||
:aria-invalid="bodyError ? 'true' : 'false'"
|
||||
:aria-describedby="bodyError ? 'vacation-body-hint vacation-body-error' : 'vacation-body-hint'"
|
||||
@input="emit('update:textBody', valueOf($event))"
|
||||
/>
|
||||
<p
|
||||
v-if="bodyError"
|
||||
id="vacation-body-error"
|
||||
class="form-message form-message--error"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ bodyError }}</span>
|
||||
</p>
|
||||
<p
|
||||
id="vacation-body-hint"
|
||||
class="form-message"
|
||||
>
|
||||
{{ VACATION_TEXT.bodyHint }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vacation-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-22);
|
||||
}
|
||||
|
||||
.vacation-message__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.vacation-message__label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vacation-message__subject {
|
||||
width: 100%;
|
||||
max-width: var(--form-wide-max-width);
|
||||
}
|
||||
|
||||
.vacation-message__body {
|
||||
width: 100%;
|
||||
padding: var(--space-10) var(--space-12);
|
||||
line-height: var(--line-height-body);
|
||||
resize: vertical;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,196 @@
|
||||
<script setup lang="ts">
|
||||
// 부재중 자동 응답의 기간 (디자인 원본 SettingsVacation) — (•) 끌 때까지 계속 / ( ) 기간 정하기 → 시작 ~ 끝 날짜 칸(44)
|
||||
// "시작일 0시부터 끝나는 날 자정까지 답합니다 (이 브라우저의 시간대 기준)." 끈 동안은 묶음째 흐리게(쓴 값은 남긴다).
|
||||
// 날짜는 <input type="date"> 값(YYYY-MM-DD) 그대로 주고받는다 — 시각으로 바꾸는 일은 mail-vacation.ts
|
||||
// 고정 id — #vacation-period-until-off · #vacation-period-range · #vacation-from · #vacation-to · #vacation-period-error
|
||||
import { ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { VACATION_TEXT } from '@/shared/constants/mail-vacation'
|
||||
import type { VacationPeriod } from '@/shared/utils/mail-vacation'
|
||||
|
||||
interface Props {
|
||||
period: VacationPeriod
|
||||
fromDay: string
|
||||
toDay: string
|
||||
disabled: boolean
|
||||
/** 기간이 맞지 않는다 — 사전 문구 */
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:period', period: VacationPeriod): void
|
||||
(e: 'update:fromDay', day: string): void
|
||||
(e: 'update:toDay', day: string): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
error: null,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const fromRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function valueOf(event: Event): string {
|
||||
return (event.target as HTMLInputElement).value
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
/** 기간이 맞지 않을 때 — 시작 날짜 칸으로 */
|
||||
focus: () => fromRef.value?.focus(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<fieldset
|
||||
class="vacation-period"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<legend class="vacation-period__legend">
|
||||
{{ VACATION_TEXT.periodLegend }}
|
||||
</legend>
|
||||
<label class="vacation-period__option">
|
||||
<input
|
||||
id="vacation-period-until-off"
|
||||
type="radio"
|
||||
name="vacation-period"
|
||||
class="vacation-period__radio"
|
||||
:checked="period === 'untilOff'"
|
||||
@change="emit('update:period', 'untilOff')"
|
||||
>
|
||||
<span>{{ VACATION_TEXT.untilOff }}</span>
|
||||
</label>
|
||||
<label class="vacation-period__option">
|
||||
<input
|
||||
id="vacation-period-range"
|
||||
type="radio"
|
||||
name="vacation-period"
|
||||
class="vacation-period__radio"
|
||||
:checked="period === 'range'"
|
||||
@change="emit('update:period', 'range')"
|
||||
>
|
||||
<span>{{ VACATION_TEXT.range }}</span>
|
||||
</label>
|
||||
<template v-if="period === 'range'">
|
||||
<div class="vacation-period__dates">
|
||||
<label
|
||||
for="vacation-from"
|
||||
class="vacation-period__date-label"
|
||||
>{{ VACATION_TEXT.from }}</label>
|
||||
<input
|
||||
id="vacation-from"
|
||||
ref="fromRef"
|
||||
class="form-input vacation-period__date"
|
||||
type="date"
|
||||
:value="fromDay"
|
||||
:max="toDay || undefined"
|
||||
:aria-invalid="error ? 'true' : 'false'"
|
||||
:aria-describedby="error ? 'vacation-period-hint vacation-period-error' : 'vacation-period-hint'"
|
||||
@input="emit('update:fromDay', valueOf($event))"
|
||||
>
|
||||
<span
|
||||
class="vacation-period__tilde"
|
||||
aria-hidden="true"
|
||||
>~</span>
|
||||
<label
|
||||
for="vacation-to"
|
||||
class="vacation-period__date-label"
|
||||
>{{ VACATION_TEXT.to }}</label>
|
||||
<input
|
||||
id="vacation-to"
|
||||
class="form-input vacation-period__date"
|
||||
type="date"
|
||||
:value="toDay"
|
||||
:min="fromDay || undefined"
|
||||
:aria-invalid="error ? 'true' : 'false'"
|
||||
:aria-describedby="error ? 'vacation-period-hint vacation-period-error' : 'vacation-period-hint'"
|
||||
@input="emit('update:toDay', valueOf($event))"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
id="vacation-period-hint"
|
||||
class="form-message vacation-period__note"
|
||||
>
|
||||
{{ VACATION_TEXT.rangeHint }}
|
||||
</p>
|
||||
<p
|
||||
v-if="error"
|
||||
id="vacation-period-error"
|
||||
class="form-message form-message--error vacation-period__note"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ error }}</span>
|
||||
</p>
|
||||
</template>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vacation-period {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.vacation-period:disabled {
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.vacation-period__legend {
|
||||
margin: 0 0 var(--space-4);
|
||||
padding: 0;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.vacation-period__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-touch);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vacation-period:disabled .vacation-period__option {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.vacation-period__radio {
|
||||
width: var(--size-option-input);
|
||||
height: var(--size-option-input);
|
||||
margin: 0;
|
||||
accent-color: var(--color-brand);
|
||||
}
|
||||
|
||||
/* 날짜 줄 — 라디오 글자 선(28)에 맞춰 들여 쓴다 */
|
||||
.vacation-period__dates {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-8) var(--space-10);
|
||||
padding: var(--space-4) 0 0 var(--space-28);
|
||||
}
|
||||
|
||||
.vacation-period__date-label {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.vacation-period__date {
|
||||
padding: 0 var(--space-10);
|
||||
}
|
||||
|
||||
.vacation-period__tilde {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.vacation-period__note {
|
||||
margin: var(--space-6) 0 0 var(--space-28);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
// 부재중 자동 응답의 미리 보기 — "보낸 사람이 받는 답장" (디자인 원본 SettingsVacation)
|
||||
// 보낸 사람(나) · 제목 · 내용(글자 그대로 — 메일 본문 글꼴 14px · 줄 간격 1.75) + 아래 한 줄
|
||||
// (켜짐: "같은 사람에게 매번 보내지는 않습니다(메일 엔진 규칙)." · 꺼짐: "꺼져 있습니다 — 자동으로 답하지 않습니다.")
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import { VACATION_TEXT } from '@/shared/constants/mail-vacation'
|
||||
|
||||
interface Props {
|
||||
enabled: boolean
|
||||
senderName: string
|
||||
senderEmail: string
|
||||
subject: string
|
||||
textBody: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const sender = computed(() =>
|
||||
props.senderName ? `${props.senderName} <${props.senderEmail}>` : props.senderEmail,
|
||||
)
|
||||
const shownSubject = computed(() => props.subject.trim() || MAIL_NO_SUBJECT)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="vacation-preview"
|
||||
aria-labelledby="vacation-preview-title"
|
||||
data-vacation-preview
|
||||
>
|
||||
<h2
|
||||
id="vacation-preview-title"
|
||||
class="vacation-preview__title"
|
||||
>
|
||||
{{ VACATION_TEXT.previewTitle }}
|
||||
</h2>
|
||||
<div class="vacation-preview__card">
|
||||
<dl class="vacation-preview__meta">
|
||||
<dt class="vacation-preview__label">
|
||||
{{ VACATION_TEXT.previewFrom }}
|
||||
</dt>
|
||||
<dd class="vacation-preview__value">
|
||||
{{ sender }}
|
||||
</dd>
|
||||
<dt class="vacation-preview__label">
|
||||
{{ VACATION_TEXT.previewSubject }}
|
||||
</dt>
|
||||
<dd
|
||||
class="vacation-preview__value"
|
||||
data-vacation-preview-subject
|
||||
>
|
||||
{{ shownSubject }}
|
||||
</dd>
|
||||
</dl>
|
||||
<!-- 글자 그대로 — 템플릿의 들여쓰기 공백이 끼지 않게 v-text 로 넣는다 -->
|
||||
<div
|
||||
class="vacation-preview__body"
|
||||
data-vacation-preview-body
|
||||
v-text="textBody"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
class="vacation-preview__note"
|
||||
data-vacation-preview-note
|
||||
>
|
||||
{{ enabled ? VACATION_TEXT.previewNoteOn : VACATION_TEXT.previewNoteOff }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vacation-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-12);
|
||||
}
|
||||
|
||||
.vacation-preview__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.vacation-preview__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-10);
|
||||
padding: var(--space-16) var(--space-20) var(--space-20);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 이름 칸 72 · 값 (12.5px) */
|
||||
.vacation-preview__meta {
|
||||
display: grid;
|
||||
grid-template-columns: var(--space-72) minmax(0, 1fr);
|
||||
row-gap: var(--space-2);
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
}
|
||||
|
||||
.vacation-preview__label {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.vacation-preview__value {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 받는 쪽이 보는 모양 — 글자 그대로(줄바꿈 · 공백), 메일 본문 글꼴 */
|
||||
.vacation-preview__body {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: var(--font-family-mail-body);
|
||||
font-size: var(--font-size-body);
|
||||
line-height: var(--line-height-mail-body);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.vacation-preview__note {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -27,7 +27,7 @@ describe('AppRail', () => {
|
||||
|
||||
expect(current.text()).toBe('메일')
|
||||
expect(current.attributes('href')).toBe('/mail')
|
||||
// 설정은 첫 메뉴(메일 › 서명)로 바꿔 끼운다
|
||||
// 설정은 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다
|
||||
expect(links).toEqual([
|
||||
['메일', '/mail'],
|
||||
['설정', '/settings'],
|
||||
|
||||
@@ -66,6 +66,9 @@ describe('에러 코드 사전', () => {
|
||||
'MAIL_003', // 메일함 용량 — 임시 저장이 조금 뒤 저절로 다시 해 본다
|
||||
'FILE_001', // 첨부 형식 사전 검사
|
||||
'FILE_002', // 첨부 크기 사전 검사 · 413 역매핑
|
||||
// 웹메일 4단계
|
||||
'AUTH_005', // 지금 비밀번호가 틀림 — 비밀번호 화면이 지금 비밀번호 칸 아래에 보인다
|
||||
'MAIL_009', // 같은 자리에 같은 이름의 메일함 — 메일함 이름 대화상자가 칸 아래에 보인다
|
||||
]
|
||||
|
||||
for (const code of usedByClient) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { isApiError, refreshSession, settleRefresh, useApi } from './useApi'
|
||||
import { announceLogout } from './useLogoutBroadcast'
|
||||
import type { AuthUser, LoginPayload } from '@/shared/types/auth'
|
||||
import { PASSWORD_CHANGE_TIMEOUT_MS } from '@/shared/constants/account'
|
||||
import type { AuthUser, ChangePasswordPayload, LoginPayload } from '@/shared/types/auth'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
export type { LoginPayload } from '@/shared/types/auth'
|
||||
@@ -83,5 +85,18 @@ export function useAuth() {
|
||||
return authStore.restore(fetchSessionUser)
|
||||
}
|
||||
|
||||
return { loading, login, logout, me, refresh: refreshSession, restoreSession }
|
||||
/**
|
||||
* 비밀번호 바꾸기 (계약 1절 "PUT /api/auth/password" — 4단계). 실패하면 ApiError 를 그대로 던진다 — 호출부가 code 로 분기한다.
|
||||
* 지금 비밀번호가 틀리면 400 AUTH_005(세션은 그대로). 401 이 아니므로 토큰이 만료됐을 때만 갱신 뒤 같은 요청을 다시 보낸다.
|
||||
* 바뀌면 서버가 이 세션만 남기고 그 직원의 다른 로그인 세션을 모두 끊는다 — 이 탭의 세션은 이어진다.
|
||||
* 시간 한도는 30초다 (PASSWORD_CHANGE_TIMEOUT_MS — 서버가 엔진을 두 번 부르고 세션을 끊는 시간까지 기다린다)
|
||||
*/
|
||||
async function changePassword(
|
||||
payload: ChangePasswordPayload,
|
||||
options?: Pick<AxiosRequestConfig, 'silent'>,
|
||||
): Promise<void> {
|
||||
await api.put('/auth/password', payload, { timeout: PASSWORD_CHANGE_TIMEOUT_MS, ...options })
|
||||
}
|
||||
|
||||
return { loading, login, logout, me, refresh: refreshSession, restoreSession, changePassword }
|
||||
}
|
||||
|
||||
@@ -5,11 +5,12 @@ import { notifyApiError } from './useApi'
|
||||
import { useMailMoveFeedback } from './useMailMoveFeedback'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { listLocationOf } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore, type MailBulkResult } from '@/stores/mail.store'
|
||||
import { useMailStore, type MailBulkResult, type MoveOptions } from '@/stores/mail.store'
|
||||
|
||||
// 여러 통 처리 — 도구 줄의 단추가 하는 일 (계약 2절 "여러 통 한꺼번에" · 디자인 README "3단계 › 여러 통 처리")
|
||||
// - 읽음 · 안 읽음 · 중요 표시: 목록이 바로 바뀌므로 알리지 않는다 (서버가 바꾸지 못한 메일만 되돌린다)
|
||||
// - 이동 · 삭제(휴지통 밖 — 휴지통으로 옮긴다) · 영구 삭제(휴지통에서만, 확인 대화상자를 거친 뒤): 알림은 useMailMoveFeedback
|
||||
// - 스팸 신고(스팸메일함으로 + 스팸 표시) · 스팸 아님(스팸메일함에서 — 받은편지함으로 + "스팸 아님" 표시): 옮기기와 같은 길에 isJunk 를 함께 보낸다
|
||||
// - 열어 둔 메일을 옮기거나 지워 목록에서 빠졌으면 목록 주소로 바꿔 끼운다 (본문 칸이 없는 메일을 붙들지 않게).
|
||||
// 끝났을 때 다른 목록으로 옮겨 가 있으면(stale) 그 목록에 기대어 움직이지 않는다
|
||||
|
||||
@@ -27,6 +28,10 @@ export function useBulkMailActions() {
|
||||
)
|
||||
/** 휴지통을 보는 중 — "삭제"가 "영구 삭제"가 된다 (휴지통 안의 검색 포함) */
|
||||
const inTrash = computed<boolean>(() => mailStore.currentMailbox?.role === 'trash')
|
||||
/** 스팸메일함을 보는 중 — 「스팸 신고」가 「스팸 아님」이 된다 (스팸메일함 안의 검색 포함) */
|
||||
const inJunk = computed<boolean>(() => mailStore.currentMailbox?.role === 'junk')
|
||||
/** 스팸 단추가 옮길 곳 — 스팸 신고는 스팸메일함, 스팸 아님은 받은편지함. 그 메일함을 모르면 단추를 그리지 않는다 */
|
||||
const spamTarget = computed<Mailbox | null>(() => mailStore.mailboxByRole(inJunk.value ? 'inbox' : 'junk'))
|
||||
/** 이동 메뉴 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함 (메일함 패널 순서) */
|
||||
const moveTargets = computed<Mailbox[]>(() =>
|
||||
mailStore.orderedMailboxes
|
||||
@@ -55,11 +60,11 @@ export function useBulkMailActions() {
|
||||
await router.replace(listLocationOf(source))
|
||||
}
|
||||
|
||||
async function moveTo(mailbox: Mailbox): Promise<void> {
|
||||
async function moveTo(mailbox: Mailbox, options: MoveOptions = {}): Promise<void> {
|
||||
if (busy.value || mailStore.checkedCount === 0) return
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await mailStore.moveChecked(mailbox.id)
|
||||
const result = await mailStore.moveChecked(mailbox.id, options)
|
||||
await leaveRemovedMessage(result)
|
||||
feedback.announceMove(result, mailbox.name)
|
||||
} finally {
|
||||
@@ -78,6 +83,18 @@ export function useBulkMailActions() {
|
||||
await moveTo(trash.mailbox)
|
||||
}
|
||||
|
||||
/** 스팸 신고 · 스팸 아님 — 옮기며 스팸 표시를 함께 바꾼다 (되돌리기는 원래 메일함 · 원래 표시로) */
|
||||
async function moveSpam(): Promise<void> {
|
||||
if (busy.value || mailStore.checkedCount === 0) return
|
||||
const reportsSpam = !inJunk.value
|
||||
const target = await mailStore.resolveMailboxByRole(reportsSpam ? 'junk' : 'inbox')
|
||||
if ('error' in target) {
|
||||
notifyApiError(target.error)
|
||||
return
|
||||
}
|
||||
await moveTo(target.mailbox, { isJunk: reportsSpam })
|
||||
}
|
||||
|
||||
/** 영구 삭제 — 확인 대화상자에서 「영구 삭제」를 누른 뒤에만 부른다 */
|
||||
async function purge(): Promise<void> {
|
||||
if (busy.value || mailStore.checkedCount === 0) return
|
||||
@@ -91,5 +108,18 @@ export function useBulkMailActions() {
|
||||
}
|
||||
}
|
||||
|
||||
return { busy, allFlagged, inTrash, moveTargets, markRead, toggleFlagged, moveTo, moveToTrash, purge }
|
||||
return {
|
||||
busy,
|
||||
allFlagged,
|
||||
inTrash,
|
||||
inJunk,
|
||||
spamTarget,
|
||||
moveTargets,
|
||||
markRead,
|
||||
toggleFlagged,
|
||||
moveTo,
|
||||
moveToTrash,
|
||||
moveSpam,
|
||||
purge,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AxiosRequestConfig } from 'axios'
|
||||
import { buildApiUrl, confirmSession, useApi } from './useApi'
|
||||
import {
|
||||
MAIL_IDEMPOTENCY_HEADER,
|
||||
MAIL_MAILBOX_OPERATION_TIMEOUT_MS,
|
||||
MAIL_SEND_TIMEOUT_MS,
|
||||
MAIL_UPLOAD_TIMEOUT_MS,
|
||||
} from '@/shared/constants/mail'
|
||||
@@ -14,7 +15,12 @@ import type {
|
||||
MailQuota,
|
||||
MailSignature,
|
||||
MailState,
|
||||
MailVacation,
|
||||
Mailbox,
|
||||
MailboxBulkResult,
|
||||
MailboxCreatePayload,
|
||||
MailboxDeletion,
|
||||
MailboxRenamePayload,
|
||||
MessageDeletionRequest,
|
||||
MessageDeletionResult,
|
||||
MessageDetail,
|
||||
@@ -83,6 +89,59 @@ export function useMail() {
|
||||
return api.get<MailQuota, MailQuota>('/mail-quota', options)
|
||||
}
|
||||
|
||||
// ── 메일함 관리 (계약 2절 "메일함 관리" — 4단계) ─────────────
|
||||
|
||||
/** 새 메일함 — 같은 자리에 같은 이름이면 409 MAIL_009, 없는 parentId 는 404 RES_001. parentId 가 없으면 맨 위 */
|
||||
function createMailbox(payload: MailboxCreatePayload, options?: MailRequestOptions): Promise<Mailbox> {
|
||||
const body: MailboxCreatePayload = payload.parentId ? payload : { name: payload.name }
|
||||
return api.post<Mailbox, Mailbox>('/mailboxes', body, options)
|
||||
}
|
||||
|
||||
/** 이름 바꾸기 — 기본 메일함은 400 VAL_001 (화면은 메뉴에 두지 않는다) */
|
||||
function renameMailbox(id: string, payload: MailboxRenamePayload, options?: MailRequestOptions): Promise<Mailbox> {
|
||||
return api.patch<Mailbox, Mailbox>(`/mailboxes/${segment(id)}`, payload, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일함 지우기 — 안의 메일을 휴지통으로 옮긴 뒤 지운다. 한 요청에 다 옮기지 못하면 deleted: false 다 (같은 요청을 다시 보낸다).
|
||||
* 하위 메일함이 있으면 400 VAL_001 (화면이 먼저 막는다)
|
||||
*/
|
||||
function deleteMailbox(id: string, options?: MailRequestOptions): Promise<MailboxDeletion> {
|
||||
return api.delete<MailboxDeletion, MailboxDeletion>(`/mailboxes/${segment(id)}`, {
|
||||
timeout: MAIL_MAILBOX_OPERATION_TIMEOUT_MS,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/** 그 메일함의 안 읽은 메일을 읽음으로 — 한 요청에 500통씩, remaining 이 0 이 될 때까지 다시 보낸다 */
|
||||
function markMailboxRead(id: string, options?: MailRequestOptions): Promise<MailboxBulkResult> {
|
||||
return api.patch<MailboxBulkResult, MailboxBulkResult>(
|
||||
`/mailboxes/${segment(id)}/messages`,
|
||||
{ isRead: true },
|
||||
{ timeout: MAIL_MAILBOX_OPERATION_TIMEOUT_MS, ...options },
|
||||
)
|
||||
}
|
||||
|
||||
/** 휴지통 · 스팸메일함 비우기(영구 삭제) — 한 요청에 500통씩, remaining 이 0 이 될 때까지 다시 보낸다 */
|
||||
function emptyMailbox(id: string, options?: MailRequestOptions): Promise<MailboxBulkResult> {
|
||||
return api.delete<MailboxBulkResult, MailboxBulkResult>(`/mailboxes/${segment(id)}/messages`, {
|
||||
timeout: MAIL_MAILBOX_OPERATION_TIMEOUT_MS,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
// ── 부재중 자동 응답 (계약 2절 "부재중 자동 응답" — 4단계) ──────────
|
||||
|
||||
/** 설정한 적 없으면 꺼짐 · 빈 제목 · 빈 내용이 온다 */
|
||||
function getVacation(options?: MailRequestOptions): Promise<MailVacation> {
|
||||
return api.get<MailVacation, MailVacation>('/mail-vacation', options)
|
||||
}
|
||||
|
||||
/** 전체 교체 — 꺼도 제목 · 내용은 남긴다 */
|
||||
function saveVacation(payload: MailVacation, options?: MailRequestOptions): Promise<MailVacation> {
|
||||
return api.put<MailVacation, MailVacation>('/mail-vacation', payload, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 메일 확인 — 메일함이나 메일이 하나라도 바뀌면 state 가 달라진다 (계약 2절 "새 메일 확인").
|
||||
* 메일을 읽지 않는 가벼운 조회라 화면이 보이는 동안 1분마다 부른다 (useMailStatePolling)
|
||||
@@ -248,9 +307,33 @@ export function useMail() {
|
||||
return true
|
||||
}
|
||||
|
||||
/** 원문(.eml) 주소 — 받은 그대로의 메일 message/rfc822 (표준 래퍼 없음, 4단계 "스팸 · 원문") */
|
||||
function messageSourceUrl(messageId: string): string {
|
||||
return buildApiUrl(`/messages/${segment(messageId)}/source`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 원문을 .eml 로 내려받는다 — 첨부 내려받기와 같이 이동하기 전에 세션을 확인한다 (confirmSession).
|
||||
* @returns 내려받기를 시작했으면 true. false 면 안내(세션 만료 대화상자·토스트)는 이미 끝났다.
|
||||
*/
|
||||
async function downloadMessageSource(messageId: string): Promise<boolean> {
|
||||
if (!(await confirmSession())) return false
|
||||
startBrowserDownload(messageSourceUrl(messageId))
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
listMailboxes,
|
||||
getQuota,
|
||||
createMailbox,
|
||||
renameMailbox,
|
||||
deleteMailbox,
|
||||
markMailboxRead,
|
||||
emptyMailbox,
|
||||
getVacation,
|
||||
saveVacation,
|
||||
messageSourceUrl,
|
||||
downloadMessageSource,
|
||||
getMailState,
|
||||
listMessages,
|
||||
getMessage,
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { useToast } from './useToast'
|
||||
import { DRAFT_UNDO_LABEL } from '@/shared/constants/mail'
|
||||
import { movedMessageText, purgedMessageText, restoredMessageText, type BulkOrigin } from '@/shared/utils/mail-bulk'
|
||||
import { useMailStore, type BulkMoveResult, type MailBulkResult } from '@/stores/mail.store'
|
||||
import {
|
||||
movedMessageText,
|
||||
notSpamMovedText,
|
||||
purgedMessageText,
|
||||
restoredMessageText,
|
||||
type BulkOrigin,
|
||||
} from '@/shared/utils/mail-bulk'
|
||||
import { useMailStore, type BulkMoveResult, type MailBulkResult, type MoveOptions } from '@/stores/mail.store'
|
||||
|
||||
/**
|
||||
* 옮기기 · 영구 삭제의 결과 알림 (디자인 README "3단계 › 여러 통 처리") — 도구 줄과 읽기 화면의 「삭제」가 함께 쓴다.
|
||||
* 옮기기 · 영구 삭제의 결과 알림 (디자인 README "3단계 › 여러 통 처리" · "4단계 › 스팸 · 원문") — 도구 줄과 읽기 화면의 「삭제」 · 「스팸 신고」가 함께 쓴다.
|
||||
* - "메일 N통을 X으로 옮겼습니다." + [되돌리기](10초 — 원래 메일함별로 다시 옮기고 "메일 N통을 되돌렸습니다.")
|
||||
* 스팸 신고는 같은 문구("…스팸메일함으로 옮겼습니다."), 스팸 아님은 "메일 N통을 받은편지함으로 옮겼습니다 (스팸 아님)."
|
||||
* — 스팸 신고 · 스팸 아님의 되돌리기는 원래 메일함으로 옮기며 표시도 되돌린다(보낸 값의 반대)
|
||||
* - "메일 N통을 영구 삭제했습니다."
|
||||
* - 일부만 처리했으면 뒤에 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다." (하나도 못 했으면 그 문장만)
|
||||
* - 하나도 보내지 못했으면(요청 실패 — 인터셉터가 알렸다) 알리지 않는다
|
||||
@@ -15,8 +23,8 @@ export function useMailMoveFeedback() {
|
||||
const mailStore = useMailStore()
|
||||
const toast = useToast()
|
||||
|
||||
async function undo(origins: readonly BulkOrigin[]): Promise<void> {
|
||||
const result = await mailStore.restoreMoved(origins)
|
||||
async function undo(origins: readonly BulkOrigin[], options: MoveOptions): Promise<void> {
|
||||
const result = await mailStore.restoreMoved(origins, options)
|
||||
const done = result.doneIds.length
|
||||
const skipped = result.skippedIds.length
|
||||
if (done > 0 || skipped > 0) toast.success(restoredMessageText(done, skipped))
|
||||
@@ -27,9 +35,12 @@ export function useMailMoveFeedback() {
|
||||
const skipped = result.skippedIds.length
|
||||
if (done === 0 && skipped === 0) return
|
||||
const origins = result.origins
|
||||
const undoOptions: MoveOptions = result.isJunk === undefined ? {} : { isJunk: !result.isJunk }
|
||||
const text =
|
||||
result.isJunk === false ? notSpamMovedText(done, mailboxName, skipped) : movedMessageText(done, mailboxName, skipped)
|
||||
toast.success(
|
||||
movedMessageText(done, mailboxName, skipped),
|
||||
origins.length > 0 ? { label: DRAFT_UNDO_LABEL, run: () => void undo(origins) } : undefined,
|
||||
text,
|
||||
origins.length > 0 ? { label: DRAFT_UNDO_LABEL, run: () => void undo(origins, undoOptions) } : undefined,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { notifyApiError, toApiError, type ApiError } from './useApi'
|
||||
import { useMailNavigation } from './useMailNavigation'
|
||||
import { useToast } from './useToast'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import { MAILBOX_RESULT_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { isSearchRouteName, listLocationOf, scopeMailboxIdOf } from '@/shared/utils/mail-list-source'
|
||||
import { initialOperationTotal, mailboxOperationCopy, type MailboxOperationKind } from '@/shared/utils/mailbox-operation'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
// 메일함 관리 — 메일함 패널의 ⋯ 메뉴 · 대화상자와 휴지통 / 스팸메일함 목록 머리의 「비우기」가 하는 일 (계약 2절 "메일함 관리" · 디자인 README "4단계")
|
||||
// - 만들기 · 이름 바꾸기: 대화상자가 칸 안에 보일 실패(같은 이름 MAIL_009 · 형식 VAL_001)는 알리지 않고 돌려준다. 그 밖은 인터셉터가 알린다.
|
||||
// 성공하면 짧은 알림 ("‘협력사’ 메일함을 만들었습니다." · "메일함 이름을 바꿨습니다.") — 메일함 패널에 바로 나타난다.
|
||||
// 기다리는 사이 세션이 바뀌었으면 아무것도 하지 않는다 (canceled)
|
||||
// - 오래 걸리는 처리(모두 읽음 · 비우기 · 메일함 지우기): 알림이 "…하는 중 · 600 / 1,234통"(돌아가는 아이콘)으로 남았다가 끝나면 결과로 바뀐다.
|
||||
// 처리하는 동안 다른 일을 해도 된다. 도중에 실패하면 거기까지 처리한 수를 알린다(실패 까닭은 인터셉터가 알렸다 — 하나도 못 했으면 그것만).
|
||||
// 다른 탭이 먼저 해서 처리할 것이 없었으면 그렇게 알린다("…에 안 읽은 메일이 없습니다." · "…이 이미 비어 있습니다.").
|
||||
// 비우기 · 지우기는 되돌릴 수 없어 [되돌리기]가 없다. 확인 대화상자는 부르는 쪽이 먼저 띄운다.
|
||||
// 휴지통 비우기와 메일함 지우기는 함께 돌지 않는다 (isBlocked — 단추 · 메뉴 항목을 끄고, 확인하는 순간에도 다시 본다)
|
||||
// - 끝난 뒤: 보고 있던 메일함을 지웠으면 받은편지함으로, 비운 메일함의 메일을 열어 두었으면 그 목록으로 (기록을 쌓지 않는다).
|
||||
// 비우기 · 지우기를 누른 단추(목록 머리 「비우기」 · 지운 메일함의 ⋯)가 사라져 초점을 잃었으면 메일함 패널의 지금 메일함으로 옮긴다
|
||||
|
||||
/** 이름 대화상자가 칸 안에 보이는 실패 — 같은 이름 · 형식 */
|
||||
const NAME_INLINE_CODES: readonly ErrorCode[] = ['MAIL_009', 'VAL_001']
|
||||
/** 메일함 패널의 지금 메일함 항목 (MailboxPanel 의 data-mailbox-panel) */
|
||||
const CURRENT_MAILBOX_LINK = '[data-mailbox-panel] a[aria-current="page"]'
|
||||
|
||||
export type MailboxNameResult =
|
||||
| { status: 'saved'; mailbox: Mailbox }
|
||||
/** inline: 대화상자가 칸 아래에 보일 실패 (알림을 끈 코드) */
|
||||
| { status: 'failed'; error: ApiError; inline: boolean }
|
||||
/** 기다리는 사이 세션이 바뀌었다 */
|
||||
| { status: 'canceled' }
|
||||
|
||||
/** 초점이 갈 곳을 잃었는가 — 누른 단추가 사라졌거나 꺼졌다 */
|
||||
function focusIsLost(): boolean {
|
||||
const active = document.activeElement
|
||||
return active === null || active === document.body || !active.isConnected
|
||||
}
|
||||
|
||||
export function useMailboxActions() {
|
||||
const mailStore = useMailStore()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { replaceWithMailbox } = useMailNavigation()
|
||||
|
||||
function isRunning(kind: MailboxOperationKind, mailboxId: string): boolean {
|
||||
return mailStore.isMailboxOperationRunning(kind, mailboxId)
|
||||
}
|
||||
|
||||
/** 지금은 시작할 수 없는가 — 휴지통 비우기 ↔ 메일함 지우기 (스토어의 isMailboxOperationBlocked) */
|
||||
function isBlocked(kind: MailboxOperationKind, mailbox: Pick<Mailbox, 'role'>): boolean {
|
||||
return mailStore.isMailboxOperationBlocked(kind, mailbox)
|
||||
}
|
||||
|
||||
async function saveName(
|
||||
run: () => Promise<Mailbox | null>,
|
||||
done: (mailbox: Mailbox) => string,
|
||||
): Promise<MailboxNameResult> {
|
||||
try {
|
||||
const mailbox = await run()
|
||||
if (!mailbox) return { status: 'canceled' }
|
||||
toast.success(done(mailbox))
|
||||
return { status: 'saved', mailbox }
|
||||
} catch (error) {
|
||||
const apiError = toApiError(error)
|
||||
return { status: 'failed', error: apiError, inline: NAME_INLINE_CODES.includes(apiError.code) }
|
||||
}
|
||||
}
|
||||
|
||||
/** 새 메일함 — parentId 가 없으면 맨 위 */
|
||||
function create(name: string, parentId: string | null): Promise<MailboxNameResult> {
|
||||
return saveName(
|
||||
() => mailStore.createMailbox(name, parentId, { silent: NAME_INLINE_CODES }),
|
||||
(mailbox) => MAILBOX_RESULT_TEXT.created(mailbox.name),
|
||||
)
|
||||
}
|
||||
|
||||
function rename(mailbox: Mailbox, name: string): Promise<MailboxNameResult> {
|
||||
return saveName(
|
||||
() => mailStore.renameMailbox(mailbox.id, name, { silent: NAME_INLINE_CODES }),
|
||||
() => MAILBOX_RESULT_TEXT.renamed,
|
||||
)
|
||||
}
|
||||
|
||||
/** 지금 화면이 그 메일함(의 목록 · 메일 · 그 안의 검색)을 보고 있는가 */
|
||||
function isViewing(mailboxId: string): boolean {
|
||||
const route = router.currentRoute.value
|
||||
if (route.name === 'mail-mailbox' || route.name === 'mail-message') return route.params.mailboxId === mailboxId
|
||||
return isSearchRouteName(route.name) && scopeMailboxIdOf(mailStore.listSource) === mailboxId
|
||||
}
|
||||
|
||||
/** 끝난 뒤의 이동 (위 규칙) */
|
||||
async function moveAway(kind: MailboxOperationKind, mailboxId: string): Promise<void> {
|
||||
if (!isViewing(mailboxId)) return
|
||||
if (kind === 'delete') {
|
||||
await replaceWithMailbox(mailStore.defaultMailbox?.id ?? null)
|
||||
return
|
||||
}
|
||||
const source = mailStore.listSource
|
||||
if (kind === 'empty' && source && router.currentRoute.value.params.messageId) {
|
||||
await router.replace(listLocationOf(source))
|
||||
}
|
||||
}
|
||||
|
||||
/** 누른 단추가 사라져 초점을 잃었으면 메일함 패널의 지금 메일함으로 — 다른 곳으로 옮긴 초점은 빼앗지 않는다 */
|
||||
async function recoverFocus(): Promise<void> {
|
||||
await nextTick()
|
||||
if (!focusIsLost()) return
|
||||
document.querySelector<HTMLElement>(CURRENT_MAILBOX_LINK)?.focus()
|
||||
}
|
||||
|
||||
async function runOperation(kind: MailboxOperationKind, mailbox: Mailbox): Promise<void> {
|
||||
if (isRunning(kind, mailbox.id) || isBlocked(kind, mailbox)) return
|
||||
const copy = mailboxOperationCopy(kind, mailbox.name)
|
||||
const progress = toast.progress(copy.progress(0, initialOperationTotal(kind, mailbox)))
|
||||
const outcome = await mailStore.runMailboxOperation(kind, mailbox, (step) =>
|
||||
progress.update(copy.progress(step.processed, step.total)),
|
||||
)
|
||||
if (outcome.status === 'canceled') {
|
||||
progress.dismiss()
|
||||
return
|
||||
}
|
||||
if (outcome.status === 'failed') {
|
||||
if (!outcome.notified) notifyApiError(outcome.error)
|
||||
if (outcome.processed > 0) progress.fail(copy.failed(outcome.processed, outcome.total))
|
||||
else progress.dismiss()
|
||||
return
|
||||
}
|
||||
// 지우기는 "안의 메일 N통은 휴지통에 있습니다" — 다른 메일함에도 있어 이 메일함에서만 뺀 메일은 세지 않는다
|
||||
progress.succeed(copy.done(kind === 'delete' ? outcome.movedToTrash : outcome.processed))
|
||||
await moveAway(kind, mailbox.id)
|
||||
if (kind !== 'read') await recoverFocus()
|
||||
}
|
||||
|
||||
/** 모두 읽음으로 표시 */
|
||||
function markAllRead(mailbox: Mailbox): Promise<void> {
|
||||
return runOperation('read', mailbox)
|
||||
}
|
||||
|
||||
/** 휴지통 · 스팸메일함 비우기(영구 삭제) — 확인 대화상자를 거친 뒤에만 부른다 */
|
||||
function empty(mailbox: Mailbox): Promise<void> {
|
||||
return runOperation('empty', mailbox)
|
||||
}
|
||||
|
||||
/** 메일함 지우기(안의 메일은 휴지통으로) — 확인 대화상자를 거친 뒤에만 부른다. 하위 메일함이 있으면 대화상자가 먼저 막는다 */
|
||||
function remove(mailbox: Mailbox): Promise<void> {
|
||||
return runOperation('delete', mailbox)
|
||||
}
|
||||
|
||||
return { isRunning, isBlocked, create, rename, markAllRead, empty, remove }
|
||||
}
|
||||
@@ -1,18 +1,27 @@
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { notifyApiError } from './useApi'
|
||||
import { useMailMoveFeedback } from './useMailMoveFeedback'
|
||||
import { useMailNavigation } from './useMailNavigation'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { listLocationOf, messageLocationOf, type MailListSource } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
// 읽기 화면의 「삭제」 · 「영구 삭제」 — 열린 메일 한 통 (디자인 README "3단계 › 여러 통 처리" · 계약 2절 "여러 통 한꺼번에")
|
||||
// 읽기 화면의 「삭제」 · 「영구 삭제」 · 「스팸 신고」 · 「스팸 아님」 — 열린 메일 한 통
|
||||
// (디자인 README "3단계 › 여러 통 처리" · "4단계 › 스팸 · 원문" · 계약 2절 "여러 통 한꺼번에" · "스팸 · 원문")
|
||||
// - 「영구 삭제」인지는 그 메일의 메일함(mailboxIds)으로 정한다 — 검색 결과 · 다른 창에서 옮긴 메일도 맞게 (deletesPermanently)
|
||||
// · 휴지통 목록(휴지통 안의 검색 포함)에서는 휴지통에 든 메일이면 「영구 삭제」 (지금까지와 같다)
|
||||
// · 그 밖의 목록에서는 휴지통에만 든 메일만 「영구 삭제」 — 휴지통과 다른 메일함에 함께 든 메일(다른 메일 프로그램이 복사)은
|
||||
// 영구 삭제가 휴지통에서만 빼고 끝나 목록 · 알림이 사실과 달라지므로 「삭제」(휴지통으로 옮기기)다
|
||||
// - 「삭제」: 휴지통으로 옮긴다(PATCH /messages { mailboxId: 휴지통 }) — "메일 1통을 휴지통으로 옮겼습니다." + [되돌리기]
|
||||
// - 「영구 삭제」: 확인 대화상자를 거친 뒤 POST /message-deletions — "메일 1통을 영구 삭제했습니다."
|
||||
// - 「스팸 신고」: 스팸메일함으로 옮기며 스팸 표시(isJunk: true) — 스팸메일함에 든 메일이면 「스팸 아님」: 받은편지함으로 · isJunk: false.
|
||||
// 알림에 [되돌리기] (원래 메일함 · 원래 표시로)
|
||||
// - DELETE /messages/{id} 는 쓰지 않는다 — 메일함 정보가 낡았으면(그사이 다른 창이 휴지통으로 옮겼으면) 확인 없이 영구 삭제하기 때문이다.
|
||||
// 이 길에서는 낡은 정보가 해롭지 않다: 이미 휴지통에 있는 메일을 휴지통으로 옮기면 그대로이고, 휴지통을 떠난 메일의 영구 삭제는
|
||||
// 서버가 그대로 둔다(skippedIds — 목록을 다시 맞춘다)
|
||||
// - 뺀 뒤(leaveRemoved): 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (기록을 쌓지 않는다).
|
||||
// 그사이 다른 목록으로 옮겨 갔거나 서버가 그대로 두었으면 움직이지 않는다
|
||||
|
||||
export interface MessageRemovalOutcome {
|
||||
/** 서버가 처리했고 지금 목록에서도 빠졌다 — 다음 메일(또는 목록)로 넘어간다 */
|
||||
@@ -25,7 +34,9 @@ const NOTHING: MessageRemovalOutcome = { removed: false, nextId: null }
|
||||
|
||||
export function useMessageRemoval() {
|
||||
const mailStore = useMailStore()
|
||||
const router = useRouter()
|
||||
const feedback = useMailMoveFeedback()
|
||||
const { backToSourceList } = useMailNavigation()
|
||||
|
||||
/**
|
||||
* 「영구 삭제」할 메일인가 (위 규칙) — 휴지통 메일함을 아직 모르면 아니라고 본다 (옮기기 쪽이 해롭지 않다)
|
||||
@@ -37,6 +48,12 @@ export function useMessageRemoval() {
|
||||
return message.mailboxIds.every((id) => id === trashId)
|
||||
}
|
||||
|
||||
/** 스팸메일함에 든 메일 — 「스팸 신고」 대신 「스팸 아님」 */
|
||||
function isInJunk(message: Pick<MessageSummary, 'mailboxIds'>): boolean {
|
||||
const junkId = mailStore.mailboxByRole('junk')?.id
|
||||
return Boolean(junkId && message.mailboxIds.includes(junkId))
|
||||
}
|
||||
|
||||
function outcomeOf(id: string, nextId: string | null, done: boolean, stale: boolean): MessageRemovalOutcome {
|
||||
const removed = done && !stale && !mailStore.messages.some((message) => message.id === id)
|
||||
return { removed, nextId: removed ? nextId : null }
|
||||
@@ -63,5 +80,34 @@ export function useMessageRemoval() {
|
||||
return outcomeOf(message.id, nextId, result.doneIds.includes(message.id), result.stale)
|
||||
}
|
||||
|
||||
return { deletesPermanently, moveToTrash, purge }
|
||||
/** 스팸 신고(스팸메일함으로 + 스팸 표시) · 스팸 아님(받은편지함으로 + 스팸 아님 표시) — 그 메일의 메일함으로 정한다 */
|
||||
async function moveSpam(message: MessageSummary): Promise<MessageRemovalOutcome> {
|
||||
const reportsSpam = !isInJunk(message)
|
||||
const target = await mailStore.resolveMailboxByRole(reportsSpam ? 'junk' : 'inbox')
|
||||
if ('error' in target) {
|
||||
notifyApiError(target.error)
|
||||
return NOTHING
|
||||
}
|
||||
const nextId = mailStore.neighborAfterRemoval(message.id)
|
||||
const result = await mailStore.moveMessages([message], target.mailbox.id, { isJunk: reportsSpam })
|
||||
feedback.announceMove(result, target.mailbox.name)
|
||||
return outcomeOf(message.id, nextId, result.doneIds.includes(message.id), result.stale)
|
||||
}
|
||||
|
||||
/** 뺀 뒤의 이동 (위 규칙) — source 는 일을 시작할 때의 목록 */
|
||||
async function leaveRemoved(
|
||||
outcome: MessageRemovalOutcome,
|
||||
source: MailListSource | null,
|
||||
layout: 'split' | 'list',
|
||||
): Promise<void> {
|
||||
if (!outcome.removed || !source) return
|
||||
if (layout === 'list') {
|
||||
// 목록 보기에서는 빼면 목록으로 돌아간다 (디자인 원본 MainListRead)
|
||||
await backToSourceList(source)
|
||||
return
|
||||
}
|
||||
await router.replace(outcome.nextId ? messageLocationOf(source, outcome.nextId) : listLocationOf(source))
|
||||
}
|
||||
|
||||
return { deletesPermanently, isInJunk, moveToTrash, purge, moveSpam, leaveRemoved }
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore, type ToastAction } from '@/stores/toast.store'
|
||||
import { useToastStore, type TimedToastKind, type ToastAction } from '@/stores/toast.store'
|
||||
|
||||
/** 오래 걸리는 처리의 진행 알림 — 하는 중에는 남아 있다가 끝나면 그 자리에서 결과로 바뀐다 */
|
||||
export interface ProgressToast {
|
||||
/** 진행 문구를 바꾼다 ("…하는 중 · 600 / 1,234통") */
|
||||
update: (message: string) => void
|
||||
/** 끝났다 — 성공 알림으로 바꾼다 (그사이 닫혔으면 새로 띄운다) */
|
||||
succeed: (message: string) => void
|
||||
/** 도중에 멈췄다 — 오류 알림으로 바꾼다 (그사이 닫혔으면 새로 띄운다) */
|
||||
fail: (message: string) => void
|
||||
/** 알리지 않고 닫는다 */
|
||||
dismiss: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 알림(토스트) — 성공 안내처럼 화면이 직접 띄우는 알림에 쓴다.
|
||||
* API 오류 알림은 useApi 인터셉터가 이미 띄우므로 호출부에서 다시 띄우지 않는다.
|
||||
* 그 자리의 단추(되돌리기 등 — run)는 알림을 띄운 세션에서만 움직인다: 그사이 로그아웃 · 세션 만료 · 다른 계정 로그인이 있었으면
|
||||
* 눌러도 아무것도 하지 않는다 (세션이 바뀌면 알림도 닫힌다 — toast.store)
|
||||
* 눌러도 아무것도 하지 않는다 (세션이 바뀌면 알림도 닫힌다 — toast.store). 진행 알림도 띄운 세션에서만 바뀐다
|
||||
*/
|
||||
export function useToast() {
|
||||
const store = useToastStore()
|
||||
@@ -27,5 +39,26 @@ export function useToast() {
|
||||
return store.show({ kind: 'success', message, action: action ? sessionBound(action) : undefined })
|
||||
}
|
||||
|
||||
return { success, dismiss: store.dismiss }
|
||||
/** 진행 알림을 띄운다 — 저절로 닫히지 않는다. 끝나면 succeed · fail · dismiss 중 하나를 부른다 */
|
||||
function progress(message: string): ProgressToast {
|
||||
const session = authStore.generation
|
||||
let id = store.show({ kind: 'progress', message })
|
||||
const sameSession = () => authStore.generation === session
|
||||
|
||||
function settle(kind: TimedToastKind, text: string) {
|
||||
if (!sameSession()) return
|
||||
if (!store.update(id, { kind, message: text })) id = store.show({ kind, message: text })
|
||||
}
|
||||
|
||||
return {
|
||||
update: (text) => {
|
||||
if (sameSession()) store.update(id, { message: text })
|
||||
},
|
||||
succeed: (text) => settle('success', text),
|
||||
fail: (text) => settle('error', text),
|
||||
dismiss: () => store.dismiss(id),
|
||||
}
|
||||
}
|
||||
|
||||
return { success, progress, dismiss: store.dismiss }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// 메일 모듈 레이아웃 — 전역 셸 + 메일함 패널 + 하위 화면(목록·본문 / 검색 결과 / 쓰기)
|
||||
// 패널은 하위 화면이 바뀌어도 다시 그리지 않는다.
|
||||
// 메일 화면에 있는 동안: 상단 검색 칸은 "메일 검색", 1분마다 새 메일 확인, 탭 제목 앞에 받은편지함의 안 읽은 수 (디자인 README "3단계")
|
||||
// 들어올 때 부재중 자동 응답을 한 번 받는다 — 켜져 있으면 메일함 패널 아래에 안내 (계약 2절 "부재중 자동 응답")
|
||||
import AppShell from '@/components/shell/AppShell.vue'
|
||||
import MailSearchBox from '@/components/mail/MailSearchBox.vue'
|
||||
import MailboxPanel from '@/components/mail/MailboxPanel.vue'
|
||||
@@ -9,6 +10,7 @@ import { useClock } from '@/composables/useClock'
|
||||
import { useMailStatePolling } from '@/composables/useMailStatePolling'
|
||||
import { useMailTabTitle } from '@/composables/useMailTabTitle'
|
||||
import { useMailSignatureStore } from '@/stores/mail-signature.store'
|
||||
import { useMailVacationStore } from '@/stores/mail-vacation.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
const mailStore = useMailStore()
|
||||
@@ -19,6 +21,8 @@ void mailStore.ensureMailboxes()
|
||||
void mailStore.ensureQuota()
|
||||
// 쓰기 화면이 열 때 넣을 서명 — 미리 받아 두면 편집기가 기다리지 않는다 (못 받으면 서명 없이 연다)
|
||||
void useMailSignatureStore().ensureSignature()
|
||||
// 메일함 패널 아래의 부재중 안내 — 못 받으면 알리지 않고 그리지 않는다 (지난 값은 둔다)
|
||||
void useMailVacationStore().load()
|
||||
|
||||
// 새 메일 확인 — 바뀌었으면 메일함 개수 · 사용량과 보이는 메일함 목록을 맞춘다 (검색 결과는 저절로 바꾸지 않는다 — 스토어가 거른다)
|
||||
useMailStatePolling({
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { PASSWORD_CHANGE_TIMEOUT_MS } from '@/shared/constants/account'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import AccountPasswordPage from './AccountPasswordPage.vue'
|
||||
|
||||
// 설정 › 계정 › 비밀번호 (디자인 원본 SettingsAccount · 계약 1절 "PUT /api/auth/password")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const CURRENT = 'old-pass-1234'
|
||||
const NEXT = 'Ara!2026-mail'
|
||||
|
||||
describe('AccountPasswordPage', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountPage(reply: (request: MockRequest) => MockResult | Promise<MockResult> = () => ok(null)) {
|
||||
requests = installMockApi((request) => (request.url === '/auth/password' ? reply(request) : ok(null)))
|
||||
const router = createTestRouter()
|
||||
await router.push('/settings/account/password')
|
||||
await router.isReady()
|
||||
const wrapper = mount(AccountPasswordPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
async function fill(wrapper: VueWrapper, values: { current?: string; next?: string; confirm?: string }) {
|
||||
if (values.current !== undefined) await wrapper.get('#password-current').setValue(values.current)
|
||||
if (values.next !== undefined) await wrapper.get('#password-next').setValue(values.next)
|
||||
if (values.confirm !== undefined) await wrapper.get('#password-confirm').setValue(values.confirm)
|
||||
}
|
||||
|
||||
async function submit(wrapper: VueWrapper) {
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
const passwordRequests = () => requests.filter((request) => request.url === '/auth/password')
|
||||
const errorOf = (wrapper: VueWrapper, id: string) => wrapper.find(`#${id}-error`)
|
||||
|
||||
it('칸 셋 · 안내 · 안내 상자 — 앞의 두 칸에 보기 단추(aria-pressed), 확인 칸은 새 비밀번호를 따른다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
|
||||
expect(wrapper.get('h1').text()).toBe('비밀번호')
|
||||
expect(wrapper.findAll('label').map((label) => label.text())).toEqual(['지금 비밀번호', '새 비밀번호', '새 비밀번호 확인'])
|
||||
expect(wrapper.get('#password-current').attributes('autocomplete')).toBe('current-password')
|
||||
expect(wrapper.get('#password-next').attributes('autocomplete')).toBe('new-password')
|
||||
expect(wrapper.get('#password-next-hint').text()).toBe(
|
||||
'8자 이상 · 지금 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다.',
|
||||
)
|
||||
expect(wrapper.get('[data-password-note]').text()).toContain('메일 프로그램(IMAP)에는 새 비밀번호를 다시 넣어 주세요.')
|
||||
|
||||
const toggles = wrapper.findAll('.password-field__toggle')
|
||||
expect(toggles.map((toggle) => toggle.attributes('aria-label'))).toEqual(['지금 비밀번호 보기', '새 비밀번호 보기'])
|
||||
await toggles[1]?.trigger('click')
|
||||
expect(toggles[1]?.attributes('aria-pressed')).toBe('true')
|
||||
// 이름은 그대로다 — 켜짐은 aria-pressed 로만
|
||||
expect(toggles[1]?.attributes('aria-label')).toBe('새 비밀번호 보기')
|
||||
expect(wrapper.get('#password-next').attributes('type')).toBe('text')
|
||||
expect(wrapper.get('#password-confirm').attributes('type')).toBe('text')
|
||||
expect(wrapper.get('#password-current').attributes('type')).toBe('password')
|
||||
})
|
||||
|
||||
it('보내기 전에 규칙을 본다 — 8자 미만이면 안내 글이 빨간 오류, 확인 칸이 다르면 "새 비밀번호가 서로 다릅니다." · 보내지 않는다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await fill(wrapper, { current: CURRENT, next: 'short', confirm: 'shorter' })
|
||||
await submit(wrapper)
|
||||
|
||||
expect(passwordRequests()).toEqual([])
|
||||
expect(wrapper.get('#password-next').attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.get('#password-next-hint').classes()).toContain('form-message--error')
|
||||
expect(errorOf(wrapper, 'password-confirm').text()).toBe('새 비밀번호가 서로 다릅니다.')
|
||||
expect(document.activeElement?.id).toBe('password-next')
|
||||
|
||||
// 고치면 그 칸의 오류를 내린다
|
||||
await fill(wrapper, { next: NEXT })
|
||||
expect(wrapper.get('#password-next').attributes('aria-invalid')).toBe('false')
|
||||
expect(errorOf(wrapper, 'password-confirm').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('지금 비밀번호와 같으면 보내지 않는다 · 지금 비밀번호를 비우면 그 칸에 사전 문구', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await fill(wrapper, { current: NEXT, next: NEXT, confirm: NEXT })
|
||||
await submit(wrapper)
|
||||
expect(passwordRequests()).toEqual([])
|
||||
expect(wrapper.get('#password-next-hint').classes()).toContain('form-message--error')
|
||||
|
||||
await fill(wrapper, { current: '' })
|
||||
await submit(wrapper)
|
||||
expect(passwordRequests()).toEqual([])
|
||||
expect(errorOf(wrapper, 'password-current').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(document.activeElement?.id).toBe('password-current')
|
||||
})
|
||||
|
||||
it('바꾸면 PUT /auth/password { currentPassword, newPassword } — "비밀번호를 바꿨습니다…" 를 보이고 세 칸을 비운다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await wrapper.findAll('.password-field__toggle')[0]?.trigger('click')
|
||||
await fill(wrapper, { current: CURRENT, next: NEXT, confirm: NEXT })
|
||||
await submit(wrapper)
|
||||
|
||||
expect(passwordRequests().map((request) => [request.method, request.data])).toEqual([
|
||||
['PUT', { currentPassword: CURRENT, newPassword: NEXT }],
|
||||
])
|
||||
// 서버가 엔진을 두 번 부르고 다른 세션을 끊는다 — 요청 한도는 보통(API_TIMEOUT_MS)보다 긴 30초
|
||||
expect(PASSWORD_CHANGE_TIMEOUT_MS).toBe(30_000)
|
||||
expect(passwordRequests()[0]?.config.timeout).toBe(PASSWORD_CHANGE_TIMEOUT_MS)
|
||||
expect(wrapper.get('#password-status').text()).toBe('비밀번호를 바꿨습니다. 다른 기기 · 브라우저의 로그인은 끊었습니다.')
|
||||
for (const id of ['password-current', 'password-next', 'password-confirm']) {
|
||||
expect((wrapper.get(`#${id}`).element as HTMLInputElement).value).toBe('')
|
||||
}
|
||||
// 보기도 끈다
|
||||
expect(wrapper.get('#password-current').attributes('type')).toBe('password')
|
||||
expect(useAuthStore().isLoggedIn).toBe(true)
|
||||
|
||||
// 다시 치면 내린다
|
||||
await fill(wrapper, { current: 'x' })
|
||||
expect(wrapper.get('#password-status').text()).toBe('')
|
||||
})
|
||||
|
||||
it('지금 비밀번호가 틀리면(400 AUTH_005) 알림 없이 지금 비밀번호 칸 아래에 사전 문구 — 세션은 그대로', async () => {
|
||||
const wrapper = await mountPage(() => fail(400, 'AUTH_005'))
|
||||
await fill(wrapper, { current: 'wrong-pass', next: NEXT, confirm: NEXT })
|
||||
await submit(wrapper)
|
||||
|
||||
expect(errorOf(wrapper, 'password-current').text()).toBe(ERROR_CODE_MESSAGES.AUTH_005)
|
||||
expect(ERROR_CODE_MESSAGES.AUTH_005).toBe('지금 비밀번호가 올바르지 않습니다.')
|
||||
expect(wrapper.get('#password-current').attributes('aria-invalid')).toBe('true')
|
||||
expect(document.activeElement?.id).toBe('password-current')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
expect(useAuthStore().isLoggedIn).toBe(true)
|
||||
// 쓴 것은 남긴다 — 지금 비밀번호만 고쳐 다시 보낼 수 있게
|
||||
expect((wrapper.get('#password-next').element as HTMLInputElement).value).toBe(NEXT)
|
||||
|
||||
await fill(wrapper, { current: CURRENT })
|
||||
expect(errorOf(wrapper, 'password-current').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('엔진 규칙에 어긋나면(400 VAL_001) 새 비밀번호 칸 아래에 사전 문구 · 그 밖의 실패(MAIL_001)는 인터셉터 알림', async () => {
|
||||
let reply: MockResult = fail(400, 'VAL_001')
|
||||
const wrapper = await mountPage(() => reply)
|
||||
await fill(wrapper, { current: CURRENT, next: NEXT, confirm: NEXT })
|
||||
await submit(wrapper)
|
||||
expect(errorOf(wrapper, 'password-next').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
reply = fail(503, 'MAIL_001')
|
||||
await submit(wrapper)
|
||||
expect(errorOf(wrapper, 'password-next').exists()).toBe(false)
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.MAIL_001])
|
||||
})
|
||||
|
||||
it('기다리는 사이 화면을 떠났는데 지금 비밀번호가 틀렸으면(AUTH_005) 볼 칸이 없으니 알림으로', async () => {
|
||||
let finish: (result: MockResult) => void = () => undefined
|
||||
const wrapper = await mountPage(
|
||||
() =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
finish = resolve
|
||||
}),
|
||||
)
|
||||
await fill(wrapper, { current: 'wrong-pass', next: NEXT, confirm: NEXT })
|
||||
await submit(wrapper)
|
||||
expect(passwordRequests()).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
|
||||
finish(fail(400, 'AUTH_005'))
|
||||
await flushPromises()
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['error', ERROR_CODE_MESSAGES.AUTH_005],
|
||||
])
|
||||
expect(useAuthStore().isLoggedIn).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 계정 › 비밀번호 (디자인 원본 SettingsAccount · README "4단계 › 비밀번호") — /settings/account/password · 설정의 첫 화면
|
||||
// 바꾸는 폼(PasswordChangeForm) 아래에 안내 상자(바탕 #F3F8F8): 다른 기기의 웹메일 로그인이 끊기는 것 · 메일 프로그램(IMAP)에 새 비밀번호를 다시 넣는 것
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import PasswordChangeForm from '@/components/settings/PasswordChangeForm.vue'
|
||||
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
|
||||
import { PASSWORD_TEXT } from '@/shared/constants/account'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsPageFrame
|
||||
title-id="password-page-title"
|
||||
:title="PASSWORD_TEXT.title"
|
||||
:description="PASSWORD_TEXT.description"
|
||||
>
|
||||
<PasswordChangeForm />
|
||||
<div
|
||||
class="password-note"
|
||||
data-password-note
|
||||
>
|
||||
<AppIcon
|
||||
name="info"
|
||||
class="password-note__icon"
|
||||
/>
|
||||
<div class="password-note__text">
|
||||
<span>{{ PASSWORD_TEXT.noteSessions }}</span>
|
||||
<span>{{ PASSWORD_TEXT.noteImap }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsPageFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.password-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-10);
|
||||
max-width: var(--note-max-width);
|
||||
padding: var(--space-12) var(--space-14);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.password-note__icon {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.password-note__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,288 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { MailVacation } from '@/shared/types/mail'
|
||||
import { formatDate } from '@/shared/utils/format'
|
||||
import { endOfDayIso, startOfDayIso } from '@/shared/utils/mail-vacation'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailVacationStore } from '@/stores/mail-vacation.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
|
||||
import MailVacationPage from './MailVacationPage.vue'
|
||||
|
||||
// 설정 › 메일 › 부재중 자동 응답 (디자인 원본 SettingsVacation · 계약 2절 "부재중 자동 응답")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
/** 설정한 적 없는 계정 — 꺼짐 · 빈 값 */
|
||||
const NEVER: MailVacation = { isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '' }
|
||||
const SAVED: MailVacation = {
|
||||
isEnabled: true,
|
||||
fromDate: startOfDayIso('2026-10-30'),
|
||||
toDate: endOfDayIso('2026-11-03'),
|
||||
subject: '부재중 안내 — 11월 4일 복귀',
|
||||
textBody: '안녕하세요, 김아라입니다.\n급한 일은 인프라팀으로 연락해 주세요.',
|
||||
}
|
||||
|
||||
interface Server {
|
||||
vacation?: MailVacation | MockResult
|
||||
save?: (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
}
|
||||
|
||||
describe('MailVacationPage', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) {
|
||||
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
|
||||
}
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountPage(server: Server = {}) {
|
||||
requests = installMockApi((request) => {
|
||||
if (request.method === 'GET' && request.url === '/mail-vacation') {
|
||||
const vacation = server.vacation ?? NEVER
|
||||
return typeof vacation === 'string' || 'status' in vacation ? vacation : ok(vacation)
|
||||
}
|
||||
if (request.method === 'PUT' && request.url === '/mail-vacation') {
|
||||
return server.save ? server.save(request) : ok(request.data)
|
||||
}
|
||||
return ok(null)
|
||||
})
|
||||
const router = createTestRouter()
|
||||
await router.push('/settings/mail/vacation')
|
||||
await router.isReady()
|
||||
const wrapper = mount(MailVacationPage, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, router], provide: routeViewProvide(router) },
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
const puts = () => requests.filter((request) => request.method === 'PUT').map((request) => request.data)
|
||||
const value = (wrapper: VueWrapper, selector: string) => (wrapper.get(selector).element as HTMLInputElement).value
|
||||
|
||||
async function save(wrapper: VueWrapper) {
|
||||
await wrapper.get('#vacation-save').trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('설정한 적 없는 계정 — 꺼짐 · 끌 때까지 · 빈 칸, 끈 동안은 아래 칸이 흐리다', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
|
||||
expect(wrapper.get('h1').text()).toBe('부재중 자동 응답')
|
||||
expect((wrapper.get('#vacation-enabled').element as HTMLInputElement).checked).toBe(false)
|
||||
expect((wrapper.get('#vacation-period-until-off').element as HTMLInputElement).checked).toBe(true)
|
||||
expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('#vacation-subject').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('#vacation-body').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('#vacation-subject').attributes('maxlength')).toBe('200')
|
||||
expect(wrapper.get('#vacation-body').attributes('maxlength')).toBe('4000')
|
||||
expect(wrapper.get('[data-vacation-preview-note]').text()).toBe('꺼져 있습니다 — 자동으로 답하지 않습니다.')
|
||||
})
|
||||
|
||||
it('저장한 값으로 채운다 — 날짜는 브라우저 시간대의 날짜로, 미리 보기는 보낸 사람 · 제목 · 내용', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: SAVED })
|
||||
|
||||
expect((wrapper.get('#vacation-period-range').element as HTMLInputElement).checked).toBe(true)
|
||||
expect(value(wrapper, '#vacation-from')).toBe('2026-10-30')
|
||||
expect(value(wrapper, '#vacation-to')).toBe('2026-11-03')
|
||||
expect(wrapper.get('#vacation-period-hint').text()).toBe(
|
||||
'시작일 0시부터 끝나는 날 자정까지 답합니다 (이 브라우저의 시간대 기준).',
|
||||
)
|
||||
expect(value(wrapper, '#vacation-subject')).toBe(SAVED.subject)
|
||||
const preview = wrapper.get('[data-vacation-preview]')
|
||||
expect(preview.text()).toContain('김아라 <ara.kim@arasystem.it.kr>')
|
||||
expect(preview.get('[data-vacation-preview-subject]').text()).toBe(SAVED.subject)
|
||||
expect(preview.get('[data-vacation-preview-body]').element.textContent).toBe(SAVED.textBody)
|
||||
expect(wrapper.get('[data-vacation-preview-note]').text()).toBe(
|
||||
'받은 메일의 보낸 사람에게 위 글로 답합니다. 같은 사람에게 매번 보내지는 않습니다(메일 엔진 규칙).',
|
||||
)
|
||||
})
|
||||
|
||||
it('받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도"', async () => {
|
||||
let reply: MailVacation | MockResult = fail(503, 'MAIL_001')
|
||||
const { wrapper } = await mountPage({
|
||||
get vacation() {
|
||||
return reply
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
reply = SAVED
|
||||
await wrapper.get('.mail-load-error__action').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('#vacation-enabled').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('켜고 기간을 정해 저장 — 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보내고 "…부터 …까지 자동으로 답합니다."', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await wrapper.get('#vacation-enabled').setValue(true)
|
||||
await wrapper.get('#vacation-period-range').setValue(true)
|
||||
// 날짜가 비었으면 시작을 오늘로 채운다
|
||||
expect(value(wrapper, '#vacation-from')).toBe(formatDate(new Date()))
|
||||
await wrapper.get('#vacation-from').setValue('2026-10-30')
|
||||
await wrapper.get('#vacation-to').setValue('2026-11-03')
|
||||
await wrapper.get('#vacation-subject').setValue('부재중 안내')
|
||||
await wrapper.get('#vacation-body').setValue('휴가입니다.')
|
||||
await save(wrapper)
|
||||
|
||||
expect(puts()).toEqual([
|
||||
{
|
||||
isEnabled: true,
|
||||
fromDate: new Date(2026, 9, 30).toISOString(),
|
||||
toDate: new Date(2026, 10, 4).toISOString(),
|
||||
subject: '부재중 안내',
|
||||
textBody: '휴가입니다.',
|
||||
},
|
||||
])
|
||||
expect(wrapper.get('#vacation-save-status').text()).toBe(
|
||||
'저장했습니다. 10월 30일(금)부터 11월 3일(화)까지 자동으로 답합니다.',
|
||||
)
|
||||
expect(useMailVacationStore().vacation?.isEnabled).toBe(true)
|
||||
|
||||
// 다시 바꾸면 내린다
|
||||
await wrapper.get('#vacation-subject').setValue('부재중 안내 (수정)')
|
||||
expect(wrapper.get('#vacation-save-status').text()).toBe('')
|
||||
})
|
||||
|
||||
it('켜 둘 때는 제목 · 내용 · 기간을 먼저 본다 — 칸 아래에 사전 문구(VAL_001), 첫 칸으로 초점 · 보내지 않는다', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await wrapper.get('#vacation-enabled').setValue(true)
|
||||
await wrapper.get('#vacation-period-range').setValue(true)
|
||||
await wrapper.get('#vacation-from').setValue('2026-11-05')
|
||||
await wrapper.get('#vacation-to').setValue('2026-11-03')
|
||||
await save(wrapper)
|
||||
|
||||
expect(puts()).toEqual([])
|
||||
expect(wrapper.get('#vacation-period-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(wrapper.get('#vacation-subject-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(wrapper.get('#vacation-body-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(wrapper.get('#vacation-subject').attributes('aria-invalid')).toBe('true')
|
||||
expect(document.activeElement?.id).toBe('vacation-from')
|
||||
|
||||
// 고친 칸의 문구만 내린다
|
||||
await wrapper.get('#vacation-subject').setValue('부재중 안내')
|
||||
expect(wrapper.find('#vacation-subject-error').exists()).toBe(false)
|
||||
expect(wrapper.find('#vacation-body-error').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('끄면 빈 제목 · 내용도 저장된다 — 쓴 글은 남기고 "자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다."', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: SAVED })
|
||||
await wrapper.get('#vacation-enabled').setValue(false)
|
||||
expect(value(wrapper, '#vacation-subject')).toBe(SAVED.subject)
|
||||
await save(wrapper)
|
||||
|
||||
expect(puts()).toEqual([{ ...SAVED, isEnabled: false }])
|
||||
expect(wrapper.get('#vacation-save-status').text()).toBe('저장했습니다. 자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다.')
|
||||
|
||||
// 설정한 적 없는 계정을 그대로 꺼 둔 채 저장해도 된다
|
||||
const { wrapper: fresh } = await mountPage()
|
||||
await save(fresh)
|
||||
expect(puts().at(-1)).toEqual(NEVER)
|
||||
})
|
||||
|
||||
it('끌 때까지 계속 — 두 날짜 모두 null · "끌 때까지 자동으로 답합니다."', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: SAVED })
|
||||
await wrapper.get('#vacation-period-until-off').setValue(true)
|
||||
expect(wrapper.find('#vacation-from').exists()).toBe(false)
|
||||
await save(wrapper)
|
||||
|
||||
expect(puts()).toEqual([{ ...SAVED, fromDate: null, toDate: null }])
|
||||
expect(wrapper.get('#vacation-save-status').text()).toBe('저장했습니다. 끌 때까지 자동으로 답합니다.')
|
||||
})
|
||||
|
||||
it('서버가 거절하면(VAL_001) 알림 없이 저장 단추 옆에 사전 문구', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: SAVED, save: () => fail(400, 'VAL_001') })
|
||||
await save(wrapper)
|
||||
|
||||
expect(wrapper.get('#vacation-save-status').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('저장하는 사이 화면을 떠났는데 서버가 거절하면(VAL_001) 볼 곳이 없으니 알림으로 — 떠날 때는 묻지 않는다', async () => {
|
||||
let finish: (result: MockResult) => void = () => undefined
|
||||
const { wrapper, router } = await mountPage({
|
||||
vacation: SAVED,
|
||||
save: () =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
finish = resolve
|
||||
}),
|
||||
})
|
||||
await wrapper.get('#vacation-subject').setValue('바꾼 제목')
|
||||
await save(wrapper)
|
||||
expect(wrapper.get('#vacation-save').attributes('aria-busy')).toBe('true')
|
||||
|
||||
await router.push('/mail/inbox')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
wrapper.unmount()
|
||||
|
||||
finish(fail(400, 'VAL_001'))
|
||||
await flushPromises()
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['error', ERROR_CODE_MESSAGES.VAL_001],
|
||||
])
|
||||
})
|
||||
|
||||
describe('나가기 확인 (서명 설정과 같다)', () => {
|
||||
const dialogOpen = () => document.getElementById('vacation-leave-dialog')?.hasAttribute('open') ?? false
|
||||
|
||||
it('바꾸고 떠나면 묻는다 — [계속 고치기]는 머물고 [나가기]는 떠난다', async () => {
|
||||
const { wrapper, router } = await mountPage({ vacation: SAVED })
|
||||
await wrapper.get('#vacation-subject').setValue('바꾼 제목')
|
||||
|
||||
const stay = router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
expect(dialogOpen()).toBe(true)
|
||||
expect(wrapper.get('#vacation-leave-title').text()).toBe('저장하지 않은 자동 응답이 있습니다')
|
||||
expect(wrapper.get('#vacation-leave-message').text()).toBe('이 화면을 나가면 바꾼 내용이 사라집니다.')
|
||||
expect(document.activeElement?.id).toBe('vacation-leave-stay')
|
||||
await wrapper.get('#vacation-leave-stay').trigger('click')
|
||||
await stay
|
||||
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/vacation')
|
||||
|
||||
const leave = router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
await wrapper.get('#vacation-leave-confirm').trigger('click')
|
||||
await leave
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('되돌렸거나 저장했으면 묻지 않는다', async () => {
|
||||
const { wrapper, router } = await mountPage({ vacation: SAVED })
|
||||
await wrapper.get('#vacation-subject').setValue('바꾼 제목')
|
||||
await wrapper.get('#vacation-subject').setValue(SAVED.subject)
|
||||
await wrapper.get('#vacation-body').setValue('바꾼 내용')
|
||||
await save(wrapper)
|
||||
|
||||
await router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
expect(dialogOpen()).toBe(false)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 메일 › 부재중 자동 응답 (디자인 원본 SettingsVacation · README "4단계 › 부재중 자동 응답") — /settings/mail/vacation
|
||||
// 들어올 때마다 새로 받는다(다른 창 · 메일 프로그램에서 바꿨을 수 있다). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도".
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
|
||||
import VacationForm from '@/components/settings/VacationForm.vue'
|
||||
import { VACATION_TEXT } from '@/shared/constants/mail-vacation'
|
||||
import { useMailVacationStore } from '@/stores/mail-vacation.store'
|
||||
|
||||
const vacationStore = useMailVacationStore()
|
||||
|
||||
function reload() {
|
||||
void vacationStore.load()
|
||||
}
|
||||
|
||||
// 처음 그리기 전에 — 지난번에 받아 둔 값으로 칸을 잠깐 그렸다 지우지 않게
|
||||
reload()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsPageFrame
|
||||
title-id="vacation-page-title"
|
||||
:title="VACATION_TEXT.title"
|
||||
:description="VACATION_TEXT.description"
|
||||
>
|
||||
<MailLoadError
|
||||
v-if="vacationStore.status === 'error' && vacationStore.error"
|
||||
:error="vacationStore.error"
|
||||
leave-label=""
|
||||
@retry="reload"
|
||||
/>
|
||||
<VacationForm
|
||||
v-else-if="vacationStore.status === 'ready' && vacationStore.vacation"
|
||||
:vacation="vacationStore.vacation"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="vacation-page__placeholder"
|
||||
aria-busy="true"
|
||||
/>
|
||||
</SettingsPageFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 받는 동안의 자리 — 사용 · 기간 · 제목 · 내용 칸만큼 */
|
||||
.vacation-page__placeholder {
|
||||
min-height: 22rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { h } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
|
||||
import { routes } from '@/router/routes'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import SettingsLayout from './SettingsLayout.vue'
|
||||
|
||||
// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 (디자인 README "4단계 › 설정 메뉴")
|
||||
|
||||
describe('SettingsLayout', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountAt(path: string) {
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(SettingsLayout, {
|
||||
global: { plugins: [pinia, router], stubs: { RouterView: { render: () => h('div') } } },
|
||||
})
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
it('묶음 둘 — 계정(비밀번호) · 메일(서명 · 부재중 자동 응답), 묶음 이름이 목록의 이름이다', async () => {
|
||||
const { wrapper } = await mountAt('/settings/account/password')
|
||||
const menu = wrapper.get('nav[aria-label="설정 메뉴"]')
|
||||
|
||||
expect(menu.findAll('.settings-menu__group').map((group) => group.text())).toEqual(['계정', '메일'])
|
||||
const lists = menu.findAll('ul')
|
||||
expect(lists.map((list) => list.attributes('aria-labelledby'))).toEqual(['settings-menu-account', 'settings-menu-mail'])
|
||||
expect(lists.map((list) => list.findAll('a').map((link) => [link.text(), link.attributes('href')]))).toEqual([
|
||||
[['비밀번호', '/settings/account/password']],
|
||||
[
|
||||
['서명', '/settings/mail/signature'],
|
||||
['부재중 자동 응답', '/settings/mail/vacation'],
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
it('지금 화면의 항목만 현재로 표시한다', async () => {
|
||||
const { wrapper } = await mountAt('/settings/mail/vacation')
|
||||
const current = wrapper.get('nav[aria-label="설정 메뉴"]').findAll('a[aria-current="page"]')
|
||||
|
||||
expect(current.map((link) => link.text())).toEqual(['부재중 자동 응답'])
|
||||
expect(current[0]?.classes()).toContain('is-active')
|
||||
})
|
||||
|
||||
it('/settings 는 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다 — 앱의 라우트 표 그대로', async () => {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes })
|
||||
await router.push('/settings')
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('settings-account-password')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/settings/account/password')
|
||||
expect(router.currentRoute.value.meta.title).toBe('비밀번호')
|
||||
expect(router.resolve({ name: 'settings-mail-vacation' }).fullPath).toBe('/settings/mail/vacation')
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 모듈 레이아웃 (디자인 원본 Settings) — 전역 셸 + 왼쪽 설정 메뉴(208 · #F8F9FB) + 하위 화면
|
||||
// 메뉴는 묶음("메일") 아래 항목("서명")으로 늘어난다. 지금은 메일 서명만 있다.
|
||||
// 설정 모듈 레이아웃 (디자인 원본 Settings · SettingsAccount · SettingsVacation) — 전역 셸 + 왼쪽 설정 메뉴(208 · #F8F9FB) + 하위 화면
|
||||
// 메뉴는 묶음 둘: 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 (디자인 README "4단계 › 설정 메뉴", 목록은 settings-menu.ts).
|
||||
// 묶음 이름은 12px #667085 굵게, 항목은 40 · 아이콘 16. 지금 화면의 항목은 aria-current="page" (RouterLink).
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import AppShell from '@/components/shell/AppShell.vue'
|
||||
import { SETTINGS_MENU } from '@/shared/constants/settings-menu'
|
||||
|
||||
function groupLabelId(groupId: string): string {
|
||||
return `settings-menu-${groupId}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -11,30 +17,38 @@ import AppShell from '@/components/shell/AppShell.vue'
|
||||
class="settings-menu"
|
||||
aria-label="설정 메뉴"
|
||||
>
|
||||
<p
|
||||
id="settings-menu-mail"
|
||||
class="settings-menu__group"
|
||||
<template
|
||||
v-for="group in SETTINGS_MENU"
|
||||
:key="group.id"
|
||||
>
|
||||
메일
|
||||
</p>
|
||||
<ul
|
||||
class="settings-menu__list"
|
||||
aria-labelledby="settings-menu-mail"
|
||||
>
|
||||
<li>
|
||||
<RouterLink
|
||||
:to="{ name: 'settings-mail-signature' }"
|
||||
class="settings-menu__link"
|
||||
active-class="is-active"
|
||||
<p
|
||||
:id="groupLabelId(group.id)"
|
||||
class="settings-menu__group"
|
||||
>
|
||||
{{ group.label }}
|
||||
</p>
|
||||
<ul
|
||||
class="settings-menu__list"
|
||||
:aria-labelledby="groupLabelId(group.id)"
|
||||
>
|
||||
<li
|
||||
v-for="item in group.items"
|
||||
:key="item.routeName"
|
||||
>
|
||||
<AppIcon
|
||||
name="edit"
|
||||
:size="16"
|
||||
/>
|
||||
<span>서명</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
<RouterLink
|
||||
:to="{ name: item.routeName }"
|
||||
class="settings-menu__link"
|
||||
active-class="is-active"
|
||||
>
|
||||
<AppIcon
|
||||
:name="item.icon"
|
||||
:size="16"
|
||||
/>
|
||||
<span>{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</nav>
|
||||
<RouterView />
|
||||
</AppShell>
|
||||
@@ -59,6 +73,11 @@ import AppShell from '@/components/shell/AppShell.vue'
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 둘째 묶음부터 위를 띄운다 (디자인 원본 SettingsAccount — 묶음 사이 14) */
|
||||
.settings-menu__list + .settings-menu__group {
|
||||
margin-top: var(--space-14);
|
||||
}
|
||||
|
||||
.settings-menu__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { documentTitle } from '@/shared/utils/document-title'
|
||||
import { MAIL_SEARCH_MESSAGE_ROUTE, MAIL_SEARCH_ROUTE } from '@/shared/utils/mail-list-source'
|
||||
import { toSafeRedirect } from '@/shared/utils/redirect'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { routes } from './routes'
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
@@ -17,70 +17,6 @@ declare module 'vue-router' {
|
||||
}
|
||||
}
|
||||
|
||||
// 목록·본문 라우트가 같은 컴포넌트를 쓰도록 한 번만 정의한다 (메일을 바꿔도 화면을 다시 만들지 않는다)
|
||||
const MailboxPage = () => import('@/pages/mail/MailboxPage.vue')
|
||||
|
||||
// 코드 스플리팅 — 모든 페이지 컴포넌트는 lazy import 처리
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: { name: 'mail' },
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/pages/LoginPage.vue'),
|
||||
meta: { guestOnly: true, title: '로그인' },
|
||||
},
|
||||
{
|
||||
path: '/mail',
|
||||
component: () => import('@/pages/mail/MailLayout.vue'),
|
||||
meta: { requiresAuth: true, title: '메일' },
|
||||
children: [
|
||||
// /mail → 메일함을 불러온 뒤 받은편지함으로 (MailboxPage 가 이동시킨다)
|
||||
{ path: '', name: 'mail', component: MailboxPage },
|
||||
{
|
||||
path: 'compose',
|
||||
name: 'mail-compose',
|
||||
component: () => import('@/pages/mail/ComposePage.vue'),
|
||||
meta: { title: '메일 쓰기' },
|
||||
},
|
||||
// 검색 결과 /mail/search?q=…&in=<메일함 id | all> · 결과에서 연 메일 /mail/search/<메일 id>?q=…&in=… (계약 2절 "검색")
|
||||
// 고정 경로라 :mailboxId 보다 앞에 둔다 — 같은 화면(MailboxPage)이 목록 출처만 바꿔 그린다
|
||||
{ path: 'search', name: MAIL_SEARCH_ROUTE, component: MailboxPage, meta: { title: '메일 검색' } },
|
||||
{
|
||||
path: 'search/:messageId',
|
||||
name: MAIL_SEARCH_MESSAGE_ROUTE,
|
||||
component: MailboxPage,
|
||||
meta: { title: '메일 검색' },
|
||||
},
|
||||
{ path: ':mailboxId', name: 'mail-mailbox', component: MailboxPage },
|
||||
{ path: ':mailboxId/:messageId', name: 'mail-message', component: MailboxPage },
|
||||
],
|
||||
},
|
||||
{
|
||||
// 설정 — 왼쪽 메뉴(메일 › 서명) + 하위 화면. /settings 는 첫 메뉴로 바꿔 끼운다 (앱 레일의 "설정")
|
||||
path: '/settings',
|
||||
component: () => import('@/pages/settings/SettingsLayout.vue'),
|
||||
meta: { requiresAuth: true, title: '설정' },
|
||||
children: [
|
||||
{ path: '', name: 'settings', redirect: { name: 'settings-mail-signature' } },
|
||||
{
|
||||
path: 'mail/signature',
|
||||
name: 'settings-mail-signature',
|
||||
component: () => import('@/pages/settings/MailSignaturePage.vue'),
|
||||
meta: { title: '메일 서명' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
component: () => import('@/pages/NotFoundPage.vue'),
|
||||
meta: { title: '페이지를 찾을 수 없음' },
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
|
||||
import { MAIL_SEARCH_MESSAGE_ROUTE, MAIL_SEARCH_ROUTE } from '@/shared/utils/mail-list-source'
|
||||
|
||||
// 앱의 라우트 표 — 라우터(index.ts)와 라우트 테스트가 함께 쓴다 (이 파일은 라우터를 만들지 않는다)
|
||||
|
||||
// 목록·본문 라우트가 같은 컴포넌트를 쓰도록 한 번만 정의한다 (메일을 바꿔도 화면을 다시 만들지 않는다)
|
||||
const MailboxPage = () => import('@/pages/mail/MailboxPage.vue')
|
||||
|
||||
// 코드 스플리팅 — 모든 페이지 컴포넌트는 lazy import 처리
|
||||
export const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: { name: 'mail' },
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/pages/LoginPage.vue'),
|
||||
meta: { guestOnly: true, title: '로그인' },
|
||||
},
|
||||
{
|
||||
path: '/mail',
|
||||
component: () => import('@/pages/mail/MailLayout.vue'),
|
||||
meta: { requiresAuth: true, title: '메일' },
|
||||
children: [
|
||||
// /mail → 메일함을 불러온 뒤 받은편지함으로 (MailboxPage 가 이동시킨다)
|
||||
{ path: '', name: 'mail', component: MailboxPage },
|
||||
{
|
||||
path: 'compose',
|
||||
name: 'mail-compose',
|
||||
component: () => import('@/pages/mail/ComposePage.vue'),
|
||||
meta: { title: '메일 쓰기' },
|
||||
},
|
||||
// 검색 결과 /mail/search?q=…&in=<메일함 id | all> · 결과에서 연 메일 /mail/search/<메일 id>?q=…&in=… (계약 2절 "검색")
|
||||
// 고정 경로라 :mailboxId 보다 앞에 둔다 — 같은 화면(MailboxPage)이 목록 출처만 바꿔 그린다
|
||||
{ path: 'search', name: MAIL_SEARCH_ROUTE, component: MailboxPage, meta: { title: '메일 검색' } },
|
||||
{
|
||||
path: 'search/:messageId',
|
||||
name: MAIL_SEARCH_MESSAGE_ROUTE,
|
||||
component: MailboxPage,
|
||||
meta: { title: '메일 검색' },
|
||||
},
|
||||
{ path: ':mailboxId', name: 'mail-mailbox', component: MailboxPage },
|
||||
{ path: ':mailboxId/:messageId', name: 'mail-message', component: MailboxPage },
|
||||
],
|
||||
},
|
||||
{
|
||||
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답) + 하위 화면.
|
||||
// /settings 는 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다 (앱 레일의 "설정")
|
||||
path: '/settings',
|
||||
component: () => import('@/pages/settings/SettingsLayout.vue'),
|
||||
meta: { requiresAuth: true, title: '설정' },
|
||||
children: [
|
||||
{ path: '', name: 'settings', redirect: { name: SETTINGS_ROUTES.password } },
|
||||
{
|
||||
path: 'account/password',
|
||||
name: SETTINGS_ROUTES.password,
|
||||
component: () => import('@/pages/settings/AccountPasswordPage.vue'),
|
||||
meta: { title: '비밀번호' },
|
||||
},
|
||||
{
|
||||
path: 'mail/signature',
|
||||
name: SETTINGS_ROUTES.signature,
|
||||
component: () => import('@/pages/settings/MailSignaturePage.vue'),
|
||||
meta: { title: '메일 서명' },
|
||||
},
|
||||
{
|
||||
path: 'mail/vacation',
|
||||
name: SETTINGS_ROUTES.vacation,
|
||||
component: () => import('@/pages/settings/MailVacationPage.vue'),
|
||||
meta: { title: '부재중 자동 응답' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
component: () => import('@/pages/NotFoundPage.vue'),
|
||||
meta: { title: '페이지를 찾을 수 없음' },
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
// 설정 › 계정 › 비밀번호 상수 — 계약 1절 "PUT /api/auth/password" · 디자인 README "4단계 › 비밀번호" (디자인 원본 SettingsAccount)
|
||||
// 한도는 서버와 같은 값이다 — 서버가 최종 검증한다 (넘으면 400 VAL_001).
|
||||
|
||||
/** 새 비밀번호 최소 길이 — 메일 엔진도 8자 미만을 거절한다 */
|
||||
export const PASSWORD_MIN_LENGTH = 8
|
||||
/** 새 비밀번호 최대 길이 (JS 문자열 길이) */
|
||||
export const PASSWORD_MAX_LENGTH = 128
|
||||
/**
|
||||
* 비밀번호 바꾸기 요청 하나의 시간 한도 — 서버는 메일 엔진을 두 번(각 10초까지) 부른 뒤 다른 세션을 끊어 최악 약 21초가 걸린다.
|
||||
* 기본 10초로 끊으면 화면은 실패로 보는데 서버는 이미 바꿨을 수 있고, 다시 누르면 옛 비밀번호라 AUTH_005 가
|
||||
* 로그인 실패 한도에 쌓인다 (shared/constants/mail.ts 의 긴 요청 한도와 같은 방식)
|
||||
*/
|
||||
export const PASSWORD_CHANGE_TIMEOUT_MS = 30 * 1000
|
||||
|
||||
export const PASSWORD_TEXT = {
|
||||
title: '비밀번호',
|
||||
description: '웹메일과 휴대폰 메일 앱(IMAP)이 함께 쓰는 메일 계정의 비밀번호입니다.',
|
||||
current: '지금 비밀번호',
|
||||
next: '새 비밀번호',
|
||||
confirm: '새 비밀번호 확인',
|
||||
/** 보기/숨기기 단추의 이름 — 눌림 상태는 aria-pressed 로 알리므로 이름은 바꾸지 않는다 */
|
||||
showCurrent: '지금 비밀번호 보기',
|
||||
showNext: '새 비밀번호 보기',
|
||||
/** 새 비밀번호 아래 안내 — 규칙에 맞지 않으면 이 글이 빨간 오류로 바뀐다 */
|
||||
hint: '8자 이상 · 지금 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다.',
|
||||
mismatch: '새 비밀번호가 서로 다릅니다.',
|
||||
submit: '비밀번호 바꾸기',
|
||||
done: '비밀번호를 바꿨습니다. 다른 기기 · 브라우저의 로그인은 끊었습니다.',
|
||||
noteSessions: '바꾸면 이 브라우저를 뺀 다른 기기 · 브라우저의 웹메일 로그인이 끊깁니다.',
|
||||
noteImap: '휴대폰이나 PC 메일 프로그램(IMAP)에는 새 비밀번호를 다시 넣어 주세요.',
|
||||
} as const
|
||||
@@ -224,6 +224,30 @@ export const ICONS = {
|
||||
],
|
||||
/** 배경 색 "없음" 견본의 빗금 */
|
||||
slash: [line(5, 19, 19, 5)],
|
||||
|
||||
// ── 4단계: 계정 · 메일 설정 (디자인 원본 SettingsAccount · SettingsVacation · MailboxManage) ──
|
||||
/** 설정 메뉴의 비밀번호 */
|
||||
lock: [rect(3, 11, 18, 11, 2), path('M7 11V7a5 5 0 0 1 10 0v4')],
|
||||
/** 설정 메뉴의 부재중 자동 응답 (달력 + ✕) */
|
||||
'calendar-x': [
|
||||
rect(3, 4, 18, 18, 2),
|
||||
line(16, 2, 16, 6),
|
||||
line(8, 2, 8, 6),
|
||||
line(3, 10, 21, 10),
|
||||
line(10, 14, 14, 18),
|
||||
line(14, 14, 10, 18),
|
||||
],
|
||||
/** 비밀번호 보기 — 숨긴 상태 */
|
||||
eye: [path('M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z'), circle(12, 12, 3)],
|
||||
/** 비밀번호 보기 — 보이는 상태 */
|
||||
'eye-off': [
|
||||
path(
|
||||
'M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24',
|
||||
),
|
||||
line(1, 1, 23, 23),
|
||||
],
|
||||
/** 메일함 패널의 "새 메일함" */
|
||||
plus: [line(12, 5, 12, 19), line(5, 12, 19, 12)],
|
||||
} as const satisfies Record<string, readonly IconShape[]>
|
||||
|
||||
export type IconName = keyof typeof ICONS
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// 설정 › 메일 › 부재중 자동 응답 상수 — 계약 2절 "부재중 자동 응답" · 디자인 README "4단계 › 부재중 자동 응답" (디자인 원본 SettingsVacation)
|
||||
// 한도는 서버와 같은 값이다 — 켜 둘 때만 제목 · 내용이 필요하다 (서버가 최종 검증한다, 넘으면 400 VAL_001).
|
||||
|
||||
/** 제목 — 켤 때 1~200자 */
|
||||
export const VACATION_SUBJECT_MAX_LENGTH = 200
|
||||
/** 내용 — 켤 때 1~4,000자 (글자만) */
|
||||
export const VACATION_BODY_MAX_LENGTH = 4_000
|
||||
/**
|
||||
* 메일함 패널 안내를 다시 계산하는 간격 — 끝나는 시각이 지나면 사라지고, 시작 전 문구("10월 30일 ~ …")가 시작한 뒤의 문구로 바뀐다
|
||||
* (안내의 날짜는 하루 단위라 1분이면 충분하다)
|
||||
*/
|
||||
export const VACATION_NOTICE_REFRESH_MS = 60_000
|
||||
|
||||
export const VACATION_TEXT = {
|
||||
title: '부재중 자동 응답',
|
||||
description: '자리를 비운 동안 받은 메일에 정해 둔 글로 자동으로 답합니다. 켜 두면 메일 화면의 메일함 패널 아래에 알립니다.',
|
||||
enable: '부재중 자동 응답 사용',
|
||||
periodLegend: '기간',
|
||||
untilOff: '끌 때까지 계속',
|
||||
range: '기간 정하기',
|
||||
from: '시작',
|
||||
to: '끝',
|
||||
rangeHint: '시작일 0시부터 끝나는 날 자정까지 답합니다 (이 브라우저의 시간대 기준).',
|
||||
subject: '제목',
|
||||
body: '내용',
|
||||
bodyHint: '글자만 보냅니다 (서식 · 그림 없음). 서명은 붙지 않습니다.',
|
||||
save: '저장',
|
||||
saving: '저장하는 중…',
|
||||
previewTitle: '보낸 사람이 받는 답장',
|
||||
previewFrom: '보낸 사람',
|
||||
previewSubject: '제목',
|
||||
previewNoteOn: '받은 메일의 보낸 사람에게 위 글로 답합니다. 같은 사람에게 매번 보내지는 않습니다(메일 엔진 규칙).',
|
||||
previewNoteOff: '꺼져 있습니다 — 자동으로 답하지 않습니다.',
|
||||
// 저장한 뒤의 상태 글 — 날짜는 "10월 30일(금)" (올해가 아니면 앞에 연도)
|
||||
savedOff: '저장했습니다. 자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다.',
|
||||
savedUntilOff: '저장했습니다. 끌 때까지 자동으로 답합니다.',
|
||||
savedRange: (from: string, to: string) => `저장했습니다. ${from}부터 ${to}까지 자동으로 답합니다.`,
|
||||
savedFrom: (from: string) => `저장했습니다. ${from}부터 자동으로 답합니다.`,
|
||||
savedTo: (to: string) => `저장했습니다. ${to}까지 자동으로 답합니다.`,
|
||||
// 메일 화면의 메일함 패널 아래(사용량 위) 안내 — 날짜는 "11월 3일"
|
||||
noticeTitle: '부재중 자동 응답 켜짐',
|
||||
noticeUntil: (to: string) => `${to}까지 · 설정`,
|
||||
noticeUntilOff: '끌 때까지 · 설정',
|
||||
/** 아직 시작하지 않았다 */
|
||||
noticeRange: (from: string, to: string) => `${from} ~ ${to} · 설정`,
|
||||
noticeFrom: (from: string) => `${from}부터 · 설정`,
|
||||
} as const
|
||||
|
||||
/** 바꾸고 저장하지 않은 채 떠날 때 (앱 안 이동은 대화상자, 탭 닫기 · 새로고침은 브라우저 확인창) — 서명 설정과 같은 모양 */
|
||||
export const VACATION_LEAVE_DIALOG = {
|
||||
title: '저장하지 않은 자동 응답이 있습니다',
|
||||
message: '이 화면을 나가면 바꾼 내용이 사라집니다.',
|
||||
stay: '계속 고치기',
|
||||
leave: '나가기',
|
||||
} as const
|
||||
@@ -105,6 +105,11 @@ export const MAIL_QUOTA_WARNING_RATIO = 0.9
|
||||
export const MAIL_UPLOAD_TIMEOUT_MS = 5 * 60 * 1000
|
||||
/** 발송 — 첨부를 붙여 메일 엔진에 넘기는 시간까지 기다린다 */
|
||||
export const MAIL_SEND_TIMEOUT_MS = 60 * 1000
|
||||
/**
|
||||
* 오래 걸리는 메일함 처리(전체 읽음 · 비우기 · 메일함 지우기)의 요청 하나 — 서버가 한 요청을 25초 안에 끝내고 남은 수를 돌려준다
|
||||
* (계약 2절 "메일함 관리"). 기본 10초로는 끊기므로 발송과 같은 값을 쓴다
|
||||
*/
|
||||
export const MAIL_MAILBOX_OPERATION_TIMEOUT_MS = 60 * 1000
|
||||
|
||||
// ── 중복 발송 방지 (계약 2절 "발송 본문") ─────────────────────
|
||||
/** 쓰기 화면마다 만든 키(UUID)를 이 헤더로 보낸다 — 서버는 같은 키의 두 번째 발송을 내보내지 않는다 */
|
||||
@@ -269,6 +274,22 @@ export const MAIL_BULK_TEXT = {
|
||||
purgeCancel: '취소',
|
||||
} as const
|
||||
|
||||
// ── 스팸 · 원문 (계약 2절 "스팸 · 원문" · 디자인 README "4단계 › 스팸 · 원문 · 오래 걸리는 처리") ─────────────
|
||||
export const MAIL_SPAM_TEXT = {
|
||||
/** 스팸메일함 밖 — 스팸메일함으로 옮기고 스팸 표시를 붙인다 */
|
||||
report: '스팸 신고',
|
||||
/** 스팸메일함 — 받은편지함으로 옮기고 표시를 "스팸 아님"으로 바꾼다 */
|
||||
notSpam: '스팸 아님',
|
||||
/** place 에는 메일함 이름과 조사를 붙여 넘긴다 ("받은편지함으로") */
|
||||
notSpamMoved: (count: string, place: string) => `메일 ${count}통을 ${place} 옮겼습니다 (스팸 아님).`,
|
||||
} as const
|
||||
|
||||
/** 읽기 화면 도구 줄의 ⋯ (더 보기 40×44) 메뉴 */
|
||||
export const MAIL_READER_MORE_TEXT = {
|
||||
button: '더 보기',
|
||||
downloadSource: '원문 내려받기 (.eml)',
|
||||
} as const
|
||||
|
||||
// ── 읽기 화면의 본문 안내 (디자인 README "읽기 화면의 본문 안내" · 계약 2절 "읽기 한도") ─────────
|
||||
/**
|
||||
* 서버가 본문을 다 주지 못했을 때 본문 위에 두는 안내 — "외부 이미지를 차단했습니다"와 같은 모양.
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// 메일함 관리 · 오래 걸리는 처리 상수 — 계약 2절 "메일함 관리" · 디자인 README "4단계 › 메일함 관리" (디자인 원본 MailboxManage · MailActions4)
|
||||
// 한도는 서버와 같은 값이다 — 서버가 최종 검증한다.
|
||||
|
||||
/** 메일함 이름 — 앞뒤 공백을 뺀 1~64자 (넘으면 400 VAL_001) */
|
||||
export const MAILBOX_NAME_MAX_LENGTH = 64
|
||||
/** 메일함 이름에 쓸 수 없는 글자 — 경로 구분자로 읽는 메일 프로그램이 있다 */
|
||||
export const MAILBOX_NAME_FORBIDDEN_CHAR = '/'
|
||||
|
||||
/**
|
||||
* 오래 걸리는 처리가 한 번의 요청에서 하나도 처리하지 못한 채 남은 수만 돌려준 횟수 — 이만큼 이어지면 멈추고 실패로 알린다.
|
||||
* 서버는 한 요청에 500통씩 처리하므로 정상이라면 일어나지 않는다 (같은 요청을 끝없이 다시 보내지 않게 하는 안전장치)
|
||||
*/
|
||||
export const MAILBOX_OPERATION_MAX_STALLS = 3
|
||||
|
||||
export const MAILBOX_PANEL_TEXT = {
|
||||
/** 기본 메일함 아래 묶음 이름 (12px 굵게) */
|
||||
myMailboxes: '내 메일함',
|
||||
newMailbox: '새 메일함',
|
||||
/** ⋯ 단추의 이름 */
|
||||
more: (name: string) => `${name} 메일함 메뉴`,
|
||||
/** ⋯ 메뉴의 이름 */
|
||||
menu: (name: string) => `${name} 메일함`,
|
||||
} as const
|
||||
|
||||
/** ⋯ 메뉴 항목 (항목 높이 40 — 메뉴 항목 밀도 예외) */
|
||||
export const MAILBOX_MENU_TEXT = {
|
||||
markRead: '모두 읽음으로 표시',
|
||||
createChild: '하위 메일함 만들기',
|
||||
rename: '이름 바꾸기',
|
||||
delete: '지우기',
|
||||
emptyTrash: '휴지통 비우기',
|
||||
emptyJunk: '스팸메일함 비우기',
|
||||
} as const
|
||||
|
||||
/** 대화상자 (공용 모양 — ModalDialog) */
|
||||
export const MAILBOX_DIALOG_TEXT = {
|
||||
createTitle: '새 메일함',
|
||||
createChildTitle: (parent: string) => `‘${parent}’ 안에 새 메일함`,
|
||||
renameTitle: '메일함 이름 바꾸기',
|
||||
nameLabel: '이름',
|
||||
nameHelp: '64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다.',
|
||||
create: '만들기',
|
||||
rename: '바꾸기',
|
||||
cancel: '취소',
|
||||
deleteTitle: (name: string) => `‘${name}’ 메일함을 지울까요?`,
|
||||
deleteMessage: (count: string) => `안의 메일 ${count}통은 휴지통으로 옮깁니다. 지운 메일함은 되돌릴 수 없습니다.`,
|
||||
deleteEmptyMessage: '빈 메일함입니다. 지운 메일함은 되돌릴 수 없습니다.',
|
||||
deleteConfirm: '지우기',
|
||||
blockedTitle: (name: string) => `‘${name}’ 메일함을 지울 수 없습니다`,
|
||||
blockedMessage: '하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요.',
|
||||
blockedConfirm: '확인',
|
||||
/** "휴지통의 메일 21통을 모두 영구 삭제할까요?" */
|
||||
emptyTitle: (mailbox: string, count: string) => `${mailbox}의 메일 ${count}통을 모두 영구 삭제할까요?`,
|
||||
emptyMessage: '지운 메일은 되돌릴 수 없습니다.',
|
||||
emptyConfirm: '영구 삭제',
|
||||
} as const
|
||||
|
||||
/** 만들기 · 이름 바꾸기 알림 — 메일함 패널에 바로 나타나므로 짧게 (디자인 원본 MailActions4 "알림 문구") */
|
||||
export const MAILBOX_RESULT_TEXT = {
|
||||
created: (name: string) => `‘${name}’ 메일함을 만들었습니다.`,
|
||||
renamed: '메일함 이름을 바꿨습니다.',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 오래 걸리는 처리(모두 읽음 · 비우기 · 메일함 지우기)의 알림 — 하는 중에는 남아 있다가 끝나면 결과로 바뀐다.
|
||||
* 하는 중의 글은 "…하는 중" + 진행 수(" · 600 / 1,234통" — 셀 메일이 없으면 뺀다).
|
||||
* done · total · count 는 formatCount 로 만든 글자. 비우기의 object 는 메일함 이름에 '을/를'까지 붙여 넘긴다 ("휴지통을")
|
||||
*/
|
||||
export const MAILBOX_OPERATION_TEXT = {
|
||||
readProgress: (mailbox: string) => `${mailbox}의 메일을 읽음으로 표시하는 중`,
|
||||
readDone: (mailbox: string, count: string) => `${mailbox}의 메일 ${count}통을 읽음으로 표시했습니다.`,
|
||||
/** 처리할 것이 없었다 — 다른 탭 · 메일 프로그램이 먼저 했다 */
|
||||
readNone: (mailbox: string) => `${mailbox}에 안 읽은 메일이 없습니다.`,
|
||||
emptyProgress: (object: string) => `${object} 비우는 중`,
|
||||
emptyDone: (object: string, count: string) => `${object} 비웠습니다 — 메일 ${count}통.`,
|
||||
/** subject 에는 메일함 이름에 '이/가'까지 붙여 넘긴다 ("휴지통이") */
|
||||
emptyNone: (subject: string) => `${subject} 이미 비어 있습니다.`,
|
||||
deleteProgress: (mailbox: string) => `‘${mailbox}’ 메일함을 지우는 중`,
|
||||
progressCount: (done: string, total: string) => ` · ${done} / ${total}통`,
|
||||
deleteDone: (mailbox: string) => `‘${mailbox}’ 메일함을 지웠습니다.`,
|
||||
deleteDoneMoved: (mailbox: string, count: string) =>
|
||||
`‘${mailbox}’ 메일함을 지웠습니다. 안의 메일 ${count}통은 휴지통에 있습니다.`,
|
||||
/** 도중에 실패 — 거기까지 처리한 수. 다시 하면 남은 것만 처리한다 */
|
||||
failed: (done: string, total: string) => `${total}통 가운데 ${done}통까지 처리했습니다 — 다시 시도해 주세요.`,
|
||||
} as const
|
||||
@@ -0,0 +1,40 @@
|
||||
// 설정 모듈의 왼쪽 메뉴 — 묶음(계정 · 메일) 아래 항목 (디자인 README "4단계 › 설정 메뉴")
|
||||
// 라우트(router/routes.ts) · 메뉴(SettingsLayout.vue) · 다른 화면의 링크(메일함 패널의 부재중 안내)가 이 이름을 함께 쓴다.
|
||||
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
|
||||
export const SETTINGS_ROUTES = {
|
||||
/** 설정의 첫 화면 — /settings 는 이 화면으로 바꿔 끼운다 (앱 레일의 "설정") */
|
||||
password: 'settings-account-password',
|
||||
signature: 'settings-mail-signature',
|
||||
vacation: 'settings-mail-vacation',
|
||||
} as const
|
||||
|
||||
export interface SettingsMenuItem {
|
||||
routeName: string
|
||||
label: string
|
||||
icon: IconName
|
||||
}
|
||||
|
||||
export interface SettingsMenuGroup {
|
||||
id: string
|
||||
/** 묶음 이름 — 12px 굵게 */
|
||||
label: string
|
||||
items: readonly SettingsMenuItem[]
|
||||
}
|
||||
|
||||
export const SETTINGS_MENU: readonly SettingsMenuGroup[] = [
|
||||
{
|
||||
id: 'account',
|
||||
label: '계정',
|
||||
items: [{ routeName: SETTINGS_ROUTES.password, label: '비밀번호', icon: 'lock' }],
|
||||
},
|
||||
{
|
||||
id: 'mail',
|
||||
label: '메일',
|
||||
items: [
|
||||
{ routeName: SETTINGS_ROUTES.signature, label: '서명', icon: 'edit' },
|
||||
{ routeName: SETTINGS_ROUTES.vacation, label: '부재중 자동 응답', icon: 'calendar-x' },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -12,3 +12,13 @@ export interface LoginPayload {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /auth/password 요청 본문 (4단계) — 성공은 200 data: null, 지금 비밀번호가 틀리면 400 AUTH_005.
|
||||
* 바뀌면 서버가 이 세션만 남기고 그 직원의 다른 로그인 세션을 모두 끊는다
|
||||
*/
|
||||
export interface ChangePasswordPayload {
|
||||
currentPassword: string
|
||||
/** 8~128자 · 지금 비밀번호와 달라야 한다 (넘으면 400 VAL_001) */
|
||||
newPassword: string
|
||||
}
|
||||
|
||||
@@ -110,6 +110,11 @@ export interface UpdateMessagePayload {
|
||||
isRead?: boolean
|
||||
isFlagged?: boolean
|
||||
mailboxId?: string
|
||||
/**
|
||||
* 스팸 표시 (4단계 "스팸 · 원문") — true 면 $junk 를 붙이고 $notjunk 를 뗀다, false 면 반대.
|
||||
* 화면은 이동과 함께 보낸다 — 스팸 신고 { mailboxId: 스팸메일함, isJunk: true } · 스팸 아님 { mailboxId: 받은편지함, isJunk: false }
|
||||
*/
|
||||
isJunk?: boolean
|
||||
}
|
||||
|
||||
// ── 새 메일 확인 (계약 2절 "새 메일 확인" — 3단계) ──────────────────
|
||||
@@ -203,6 +208,58 @@ export interface DraftSaved {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// ── 메일함 관리 (계약 2절 "메일함 관리" — 4단계) ──────────────────
|
||||
|
||||
/** POST /mailboxes 본문 — parentId 가 없으면 맨 위 (받은편지함 · 보관함 · 내 메일함 아래만 만들 수 있다) */
|
||||
export interface MailboxCreatePayload {
|
||||
/** 앞뒤 공백을 뺀 1~64자, '/' 없음 — 같은 자리에 같은 이름이면 409 MAIL_009 */
|
||||
name: string
|
||||
parentId?: string | null
|
||||
}
|
||||
|
||||
/** PATCH /mailboxes/{id} 본문 — 기본 메일함(역할이 있는 메일함)은 바꿀 수 없다 */
|
||||
export interface MailboxRenamePayload {
|
||||
name: string
|
||||
}
|
||||
|
||||
/** DELETE /mailboxes/{id} 응답 — deleted 가 false 면 아직 옮길 메일이 남았다(같은 요청을 다시 보낸다) */
|
||||
export interface MailboxDeletion {
|
||||
deleted: boolean
|
||||
/**
|
||||
* 이번 요청에서 이 메일함에서 빠진 메일 수 — 휴지통으로 옮긴 것 + 다른 메일함에도 있어 이 메일함에서만 뺀 것.
|
||||
* 진행 · 정체 판정은 이 값으로 한다 (다른 메일함에도 든 메일만 남아도 나아가고 있다). 이 값을 아직 주지 않는 서버면 movedToTrash 로 본다
|
||||
*/
|
||||
processed: number
|
||||
/** 이번 요청에서 휴지통으로 옮긴 메일 수 — 끝 알림의 "안의 메일 N통은 휴지통에 있습니다" */
|
||||
movedToTrash: number
|
||||
/** 아직 남은 메일 수 */
|
||||
remaining: number
|
||||
}
|
||||
|
||||
/** PATCH · DELETE /mailboxes/{id}/messages 응답 — 전체 읽음 · 비우기. remaining 이 0 이 될 때까지 같은 요청을 다시 보낸다 */
|
||||
export interface MailboxBulkResult {
|
||||
/** 이번 요청에서 읽음으로 바꾼 · 지운 메일 수 */
|
||||
processed: number
|
||||
remaining: number
|
||||
}
|
||||
|
||||
// ── 부재중 자동 응답 (계약 2절 "부재중 자동 응답" — 4단계) ──────────────
|
||||
|
||||
/**
|
||||
* GET · PUT /mail-vacation — 저장은 전체 교체. 꺼도 쓴 제목 · 내용은 남긴다.
|
||||
* 제목(1~200자) · 내용(1~4,000자)은 켤 때만 필요하다 — 꺼져 있으면 빈 값도 된다. 설정한 적 없으면 꺼짐 · 빈 값이 온다
|
||||
*/
|
||||
export interface MailVacation {
|
||||
isEnabled: boolean
|
||||
/** ISO 8601 UTC — null 이면 켠 때부터. 화면은 시작일 0시(브라우저 시간대)를 보낸다 */
|
||||
fromDate: string | null
|
||||
/** ISO 8601 UTC — null 이면 끌 때까지. 화면은 끝나는 날 다음 날 0시(브라우저 시간대)를 보낸다 */
|
||||
toDate: string | null
|
||||
subject: string
|
||||
/** 글자만(서식 없음) — 서명은 붙지 않는다 */
|
||||
textBody: string
|
||||
}
|
||||
|
||||
// ── 서명 (계약 2절 "서명") ─────────────────────────────
|
||||
|
||||
export type SignatureImageType = 'image/png' | 'image/jpeg' | 'image/gif'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { directionParticle, subjectParticle } from './korean'
|
||||
import { directionParticle, objectParticle, subjectParticle } from './korean'
|
||||
|
||||
describe('subjectParticle (주격 조사 이/가)', () => {
|
||||
it.each([
|
||||
@@ -35,3 +35,17 @@ describe('directionParticle (방향 조사 으로/로)', () => {
|
||||
expect(directionParticle(word)).toBe(particle)
|
||||
})
|
||||
})
|
||||
|
||||
describe('objectParticle (목적격 조사 을/를 — "휴지통을 비웠습니다")', () => {
|
||||
it.each([
|
||||
['휴지통', '을'],
|
||||
['스팸메일함', '을'],
|
||||
['보관함', '을'],
|
||||
['프로젝트', '를'],
|
||||
['메일 서버', '를'],
|
||||
['Work', '을(를)'],
|
||||
['', '을(를)'],
|
||||
])('%s → %s', (word, particle) => {
|
||||
expect(objectParticle(word)).toBe(particle)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,6 +28,16 @@ export function subjectParticle(word: string): '이' | '가' | '이(가)' {
|
||||
return jongseong === NO_JONGSEONG ? '가' : '이'
|
||||
}
|
||||
|
||||
/**
|
||||
* 목적격 조사 "을/를" — 받침이 있으면 "을", 없으면 "를" (휴지통을 · 스팸메일함을 · 보관함을 · 메일 서버를).
|
||||
* 한글로 끝나지 않는 이름(영문·숫자)은 발음을 알 수 없어 "을(를)" 을 쓴다.
|
||||
*/
|
||||
export function objectParticle(word: string): '을' | '를' | '을(를)' {
|
||||
const jongseong = lastJongseong(word)
|
||||
if (jongseong === null) return '을(를)'
|
||||
return jongseong === NO_JONGSEONG ? '를' : '을'
|
||||
}
|
||||
|
||||
/**
|
||||
* 방향 조사 "으로/로" — 받침이 있으면 "으로", 받침이 없거나 ㄹ 받침이면 "로" (보낸편지함으로 · 휴지통으로 · 보관함으로 · 서울로).
|
||||
* 한글로 끝나지 않는 이름(영문·숫자)은 발음을 알 수 없어 "(으)로" 를 쓴다.
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
chunk,
|
||||
groupByOrigin,
|
||||
movedMessageText,
|
||||
notSpamMovedText,
|
||||
purgedMessageText,
|
||||
restoredMessageText,
|
||||
sendInChunks,
|
||||
@@ -64,4 +65,11 @@ describe('mail-bulk (여러 통 처리 보조)', () => {
|
||||
expect(purgedMessageText(0, 2)).toBe('2통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.')
|
||||
expect(restoredMessageText(1_234)).toBe('메일 1,234통을 되돌렸습니다.')
|
||||
})
|
||||
|
||||
it('스팸 아님 — "메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님)." · 일부만 처리했으면 그대로 둔 수를 덧붙인다', () => {
|
||||
expect(notSpamMovedText(1, '받은편지함')).toBe('메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님).')
|
||||
expect(notSpamMovedText(2, '받은편지함', 1)).toBe(
|
||||
'메일 2통을 받은편지함으로 옮겼습니다 (스팸 아님). 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 여러 통 처리 보조 — 나눠 보내기 · 되돌리기 묶음 · 알림 문구 (계약 2절 "여러 통 한꺼번에" — 순수 함수만 둔다)
|
||||
|
||||
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
|
||||
import { MAIL_BULK_TEXT, MAIL_SPAM_TEXT } from '@/shared/constants/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { directionParticle } from '@/shared/utils/korean'
|
||||
|
||||
@@ -81,6 +81,12 @@ export function movedMessageText(done: number, mailboxName: string, skipped = 0)
|
||||
return withSkipped(MAIL_BULK_TEXT.moved(formatCount(done), place), done, skipped)
|
||||
}
|
||||
|
||||
/** 스팸 아님 — "메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님)." (스팸 신고는 옮기기와 같은 문구 — movedMessageText) */
|
||||
export function notSpamMovedText(done: number, mailboxName: string, skipped = 0): string {
|
||||
const place = `${mailboxName}${directionParticle(mailboxName)}`
|
||||
return withSkipped(MAIL_SPAM_TEXT.notSpamMoved(formatCount(done), place), done, skipped)
|
||||
}
|
||||
|
||||
export function purgedMessageText(done: number, skipped = 0): string {
|
||||
return withSkipped(MAIL_BULK_TEXT.purged(formatCount(done)), done, skipped)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { MailVacation } from '@/shared/types/mail'
|
||||
import {
|
||||
endOfDayIso,
|
||||
fromDayOf,
|
||||
sameVacationForm,
|
||||
startOfDayIso,
|
||||
toDayOf,
|
||||
vacationFormOf,
|
||||
vacationNoticeText,
|
||||
vacationPayloadOf,
|
||||
vacationProblems,
|
||||
vacationSavedText,
|
||||
type VacationForm,
|
||||
} from './mail-vacation'
|
||||
|
||||
// 부재중 자동 응답 — 날짜 ↔ 시각 (계약 2절: 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로)
|
||||
// 시간대마다 결과가 달라지므로 기대값도 같은 브라우저 시간대(로컬 Date)로 만든다
|
||||
|
||||
const OFF: MailVacation = { isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '' }
|
||||
|
||||
function form(partial: Partial<VacationForm> = {}): VacationForm {
|
||||
return {
|
||||
isEnabled: true,
|
||||
period: 'untilOff',
|
||||
fromDay: '',
|
||||
toDay: '',
|
||||
subject: '부재중 안내',
|
||||
textBody: '복귀 후 답장 드리겠습니다.',
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
describe('날짜 ↔ 시각', () => {
|
||||
it('시작일은 그날 0시, 끝나는 날은 다음 날 0시 — 브라우저 시간대 기준을 UTC ISO 로', () => {
|
||||
expect(startOfDayIso('2026-10-30')).toBe(new Date(2026, 9, 30).toISOString())
|
||||
expect(endOfDayIso('2026-11-03')).toBe(new Date(2026, 10, 4).toISOString())
|
||||
// 달 · 해가 넘어가도 다음 날 0시
|
||||
expect(endOfDayIso('2026-12-31')).toBe(new Date(2027, 0, 1).toISOString())
|
||||
})
|
||||
|
||||
it('틀린 날짜 · 빈 값은 null', () => {
|
||||
expect(startOfDayIso('')).toBeNull()
|
||||
expect(startOfDayIso('2026-02-30')).toBeNull()
|
||||
expect(endOfDayIso('2026/11/03')).toBeNull()
|
||||
})
|
||||
|
||||
it('서버 값 → 화면 날짜 — 끝 시각은 그 직전 날짜가 끝나는 날', () => {
|
||||
expect(fromDayOf(startOfDayIso('2026-10-30'))).toBe('2026-10-30')
|
||||
expect(toDayOf(endOfDayIso('2026-11-03'))).toBe('2026-11-03')
|
||||
expect(fromDayOf(null)).toBe('')
|
||||
expect(toDayOf('잘못된 값')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('칸 ↔ 서버 값', () => {
|
||||
it('설정한 적 없는 계정(꺼짐 · 빈 값)은 끌 때까지 · 빈 칸', () => {
|
||||
expect(vacationFormOf(OFF)).toEqual({
|
||||
isEnabled: false,
|
||||
period: 'untilOff',
|
||||
fromDay: '',
|
||||
toDay: '',
|
||||
subject: '',
|
||||
textBody: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('날짜가 하나라도 있으면 기간 정하기 — 저장하면 같은 시각이 돌아간다', () => {
|
||||
const vacation: MailVacation = {
|
||||
isEnabled: true,
|
||||
fromDate: startOfDayIso('2026-10-30'),
|
||||
toDate: endOfDayIso('2026-11-03'),
|
||||
subject: '부재중 안내',
|
||||
textBody: '휴가입니다.',
|
||||
}
|
||||
const filled = vacationFormOf(vacation)
|
||||
|
||||
expect(filled).toMatchObject({ period: 'range', fromDay: '2026-10-30', toDay: '2026-11-03' })
|
||||
expect(vacationPayloadOf(filled)).toEqual(vacation)
|
||||
})
|
||||
|
||||
it('끌 때까지 계속이면 숨은 날짜 칸이 남아 있어도 두 날짜 모두 null', () => {
|
||||
expect(vacationPayloadOf(form({ fromDay: '2026-10-30', toDay: '2026-11-03' }))).toMatchObject({
|
||||
fromDate: null,
|
||||
toDate: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('꺼 둔 채 저장하면 맞지 않는 기간은 비운다 — 끄기는 늘 저장된다 (빈 제목 · 내용도)', () => {
|
||||
const payload = vacationPayloadOf(
|
||||
form({ isEnabled: false, period: 'range', fromDay: '2026-11-05', toDay: '2026-11-03', subject: '', textBody: '' }),
|
||||
)
|
||||
|
||||
expect(payload).toEqual({ isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '' })
|
||||
})
|
||||
|
||||
it('저장하지 않은 것이 있는지 — 끌 때까지 계속이면 숨은 날짜는 보지 않는다', () => {
|
||||
expect(sameVacationForm(form(), form({ fromDay: '2026-10-30' }))).toBe(true)
|
||||
expect(sameVacationForm(form({ period: 'range' }), form({ period: 'range', fromDay: '2026-10-30' }))).toBe(false)
|
||||
expect(sameVacationForm(form(), form({ subject: '다른 제목' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('저장하기 전 검사 — 켜 둘 때만', () => {
|
||||
it('꺼 두면 아무것도 막지 않는다', () => {
|
||||
expect(vacationProblems(form({ isEnabled: false, subject: '', textBody: '', period: 'range' }))).toEqual([])
|
||||
})
|
||||
|
||||
it('켜 두면 제목 · 내용이 필요하다 (공백뿐이면 빈 것)', () => {
|
||||
expect(vacationProblems(form({ subject: ' ', textBody: '\n' }))).toEqual(['subject', 'textBody'])
|
||||
expect(vacationProblems(form())).toEqual([])
|
||||
})
|
||||
|
||||
it('기간 정하기 — 날짜가 하나도 없거나 시작이 끝보다 늦으면 막는다 (같은 날은 하루)', () => {
|
||||
expect(vacationProblems(form({ period: 'range' }))).toEqual(['period'])
|
||||
expect(vacationProblems(form({ period: 'range', fromDay: '2026-11-04', toDay: '2026-11-03' }))).toEqual(['period'])
|
||||
expect(vacationProblems(form({ period: 'range', fromDay: '2026-11-03', toDay: '2026-11-03' }))).toEqual([])
|
||||
expect(vacationProblems(form({ period: 'range', toDay: '2026-11-03' }))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('알리는 글', () => {
|
||||
const now = new Date(2026, 9, 28, 9, 0)
|
||||
const ranged = (from: string | null, to: string | null): MailVacation => ({
|
||||
isEnabled: true,
|
||||
fromDate: from ? startOfDayIso(from) : null,
|
||||
toDate: to ? endOfDayIso(to) : null,
|
||||
subject: '부재중 안내',
|
||||
textBody: '휴가입니다.',
|
||||
})
|
||||
|
||||
it('저장한 뒤 — 기간 · 끌 때까지 · 껐음 (디자인 원본 SettingsVacation)', () => {
|
||||
expect(vacationSavedText(ranged('2026-10-30', '2026-11-03'), now)).toBe(
|
||||
'저장했습니다. 10월 30일(금)부터 11월 3일(화)까지 자동으로 답합니다.',
|
||||
)
|
||||
expect(vacationSavedText(ranged(null, null), now)).toBe('저장했습니다. 끌 때까지 자동으로 답합니다.')
|
||||
expect(vacationSavedText(ranged(null, '2026-11-03'), now)).toBe('저장했습니다. 11월 3일(화)까지 자동으로 답합니다.')
|
||||
expect(vacationSavedText(ranged('2026-10-30', null), now)).toBe('저장했습니다. 10월 30일(금)부터 자동으로 답합니다.')
|
||||
expect(vacationSavedText(OFF, now)).toBe('저장했습니다. 자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다.')
|
||||
})
|
||||
|
||||
it('올해가 아니면 연도를 붙인다', () => {
|
||||
expect(vacationSavedText(ranged('2026-12-28', '2027-01-03'), now)).toBe(
|
||||
'저장했습니다. 12월 28일(월)부터 2027년 1월 3일(일)까지 자동으로 답합니다.',
|
||||
)
|
||||
})
|
||||
|
||||
it('메일함 패널 안내 — 켜져 있는 동안만 · 끝나는 날 · 아직 시작 전이면 시작일', () => {
|
||||
expect(vacationNoticeText(ranged('2026-10-27', '2026-11-03'), now)).toBe('11월 3일까지 · 설정')
|
||||
expect(vacationNoticeText(ranged(null, null), now)).toBe('끌 때까지 · 설정')
|
||||
expect(vacationNoticeText(ranged('2026-10-30', '2026-11-03'), now)).toBe('10월 30일 ~ 11월 3일 · 설정')
|
||||
expect(vacationNoticeText(ranged('2026-10-30', null), now)).toBe('10월 30일부터 · 설정')
|
||||
// 끝났거나 꺼져 있으면 보이지 않는다
|
||||
expect(vacationNoticeText(ranged('2026-10-20', '2026-10-27'), now)).toBeNull()
|
||||
expect(vacationNoticeText({ ...ranged(null, null), isEnabled: false }, now)).toBeNull()
|
||||
expect(vacationNoticeText(null, now)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
// 부재중 자동 응답 — 화면의 날짜 ↔ 서버의 시각, 폼 검사, 안내 문구 (계약 2절 "부재중 자동 응답" — 순수 함수만 둔다)
|
||||
// 화면은 날짜만 고른다. 서버에는 브라우저 시간대의 "시작일 0시"와 "끝나는 날 다음 날 0시"를 UTC(ISO 8601)로 보낸다 —
|
||||
// 끝 시각은 그 시각 직전까지 답한다는 뜻이라, 읽을 때는 1ms 앞의 날짜가 끝나는 날이다.
|
||||
|
||||
import { VACATION_BODY_MAX_LENGTH, VACATION_SUBJECT_MAX_LENGTH, VACATION_TEXT } from '@/shared/constants/mail-vacation'
|
||||
import type { MailVacation } from '@/shared/types/mail'
|
||||
import { formatDate } from '@/shared/utils/format'
|
||||
|
||||
/** 화면의 기간 고르기 — 끌 때까지 계속 · 기간 정하기 */
|
||||
export type VacationPeriod = 'untilOff' | 'range'
|
||||
|
||||
/** 화면의 칸 — 날짜는 <input type="date"> 값(YYYY-MM-DD, 비면 '') */
|
||||
export interface VacationForm {
|
||||
isEnabled: boolean
|
||||
period: VacationPeriod
|
||||
fromDay: string
|
||||
toDay: string
|
||||
subject: string
|
||||
textBody: string
|
||||
}
|
||||
|
||||
/** 저장하기 전에 막는 문제 — 칸마다 하나 (켜 둘 때만 본다) */
|
||||
export type VacationProblem = 'period' | 'subject' | 'textBody'
|
||||
|
||||
const DAY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/
|
||||
/** 끝 시각 바로 앞 — 끝나는 날을 구한다 */
|
||||
const END_OFFSET_MS = 1
|
||||
|
||||
/** YYYY-MM-DD → 브라우저 시간대의 그날 0시 (없는 날짜 · 형식 오류면 null) */
|
||||
function localMidnight(day: string, addDays = 0): Date | null {
|
||||
const match = DAY_PATTERN.exec(day)
|
||||
if (!match) return null
|
||||
const [year, month, date] = [Number(match[1]), Number(match[2]), Number(match[3])]
|
||||
const check = new Date(year, month - 1, date)
|
||||
// 2월 30일처럼 넘어간 날짜는 받지 않는다
|
||||
if (check.getFullYear() !== year || check.getMonth() !== month - 1 || check.getDate() !== date) return null
|
||||
return new Date(year, month - 1, date + addDays)
|
||||
}
|
||||
|
||||
function parseInstant(value: string | null): Date | null {
|
||||
if (!value) return null
|
||||
const time = Date.parse(value)
|
||||
return Number.isNaN(time) ? null : new Date(time)
|
||||
}
|
||||
|
||||
/** 시작일 → 그날 0시(브라우저 시간대)의 UTC ISO — 비었거나 틀린 날짜면 null */
|
||||
export function startOfDayIso(day: string): string | null {
|
||||
return localMidnight(day)?.toISOString() ?? null
|
||||
}
|
||||
|
||||
/** 끝나는 날 → 다음 날 0시(브라우저 시간대)의 UTC ISO — 그 직전(끝나는 날 자정)까지 답한다 */
|
||||
export function endOfDayIso(day: string): string | null {
|
||||
return localMidnight(day, 1)?.toISOString() ?? null
|
||||
}
|
||||
|
||||
/** 서버의 시작 시각 → 화면의 시작일 (브라우저 시간대) */
|
||||
export function fromDayOf(fromDate: string | null): string {
|
||||
const instant = parseInstant(fromDate)
|
||||
return instant ? formatDate(instant) : ''
|
||||
}
|
||||
|
||||
/** 서버의 끝 시각(그 직전까지 답한다) → 화면의 끝나는 날 — 1ms 앞의 날짜 */
|
||||
export function toDayOf(toDate: string | null): string {
|
||||
const instant = parseInstant(toDate)
|
||||
return instant ? formatDate(new Date(instant.getTime() - END_OFFSET_MS)) : ''
|
||||
}
|
||||
|
||||
/** 서버 값 → 화면의 칸. 날짜가 하나라도 있으면 "기간 정하기" */
|
||||
export function vacationFormOf(vacation: MailVacation): VacationForm {
|
||||
const fromDay = fromDayOf(vacation.fromDate)
|
||||
const toDay = toDayOf(vacation.toDate)
|
||||
return {
|
||||
isEnabled: vacation.isEnabled,
|
||||
period: fromDay || toDay ? 'range' : 'untilOff',
|
||||
fromDay,
|
||||
toDay,
|
||||
subject: vacation.subject ?? '',
|
||||
textBody: vacation.textBody ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
/** 고른 기간 — 날짜가 없거나 틀렸거나 시작이 끝보다 늦으면 null */
|
||||
function rangeOf(form: Pick<VacationForm, 'fromDay' | 'toDay'>): { fromDate: string | null; toDate: string | null } | null {
|
||||
const fromDate = form.fromDay ? startOfDayIso(form.fromDay) : null
|
||||
const toDate = form.toDay ? endOfDayIso(form.toDay) : null
|
||||
if ((form.fromDay && !fromDate) || (form.toDay && !toDate)) return null
|
||||
if (!fromDate && !toDate) return null
|
||||
if (fromDate && toDate && Date.parse(fromDate) >= Date.parse(toDate)) return null
|
||||
return { fromDate, toDate }
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장하기 전에 막는 문제 — 켜 둘 때만 본다 (꺼 두면 빈 제목 · 내용도 저장된다 — 계약 2절).
|
||||
* 기간 정하기: 날짜가 하나도 없거나, 틀렸거나, 시작이 끝보다 늦다 · 제목 1~200자 · 내용 1~4,000자 (공백뿐이면 빈 것)
|
||||
*/
|
||||
export function vacationProblems(form: VacationForm): VacationProblem[] {
|
||||
if (!form.isEnabled) return []
|
||||
const problems: VacationProblem[] = []
|
||||
if (form.period === 'range' && !rangeOf(form)) problems.push('period')
|
||||
if (form.subject.trim() === '' || form.subject.length > VACATION_SUBJECT_MAX_LENGTH) problems.push('subject')
|
||||
if (form.textBody.trim() === '' || form.textBody.length > VACATION_BODY_MAX_LENGTH) problems.push('textBody')
|
||||
return problems
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면의 칸 → PUT /mail-vacation 본문 (전체 교체). 끌 때까지 계속이면 두 날짜 모두 null.
|
||||
* 꺼 둔 채 저장할 때 기간이 맞지 않으면(흐린 칸이라 고칠 수 없다) 날짜를 비운다 — 끄기는 늘 저장되게
|
||||
*/
|
||||
export function vacationPayloadOf(form: VacationForm): MailVacation {
|
||||
const range = form.period === 'range' ? rangeOf(form) : null
|
||||
return {
|
||||
isEnabled: form.isEnabled,
|
||||
fromDate: range?.fromDate ?? null,
|
||||
toDate: range?.toDate ?? null,
|
||||
subject: form.subject,
|
||||
textBody: form.textBody,
|
||||
}
|
||||
}
|
||||
|
||||
/** 두 폼이 같은 내용인가 — 저장하지 않은 것이 있는지 본다. 끌 때까지 계속이면 숨은 날짜 칸은 보지 않는다 */
|
||||
export function sameVacationForm(a: VacationForm, b: VacationForm): boolean {
|
||||
const sameRange = a.period === 'untilOff' || (a.fromDay === b.fromDay && a.toDay === b.toDay)
|
||||
return (
|
||||
a.isEnabled === b.isEnabled &&
|
||||
a.period === b.period &&
|
||||
sameRange &&
|
||||
a.subject === b.subject &&
|
||||
a.textBody === b.textBody
|
||||
)
|
||||
}
|
||||
|
||||
const WEEKDAY = new Intl.DateTimeFormat('ko-KR', { weekday: 'short' })
|
||||
|
||||
/** "11월 3일" — 올해가 아니면 앞에 연도 ("2027년 1월 3일") */
|
||||
function dayText(date: Date, now: Date): string {
|
||||
const monthDay = `${date.getMonth() + 1}월 ${date.getDate()}일`
|
||||
return date.getFullYear() === now.getFullYear() ? monthDay : `${date.getFullYear()}년 ${monthDay}`
|
||||
}
|
||||
|
||||
/** "10월 30일(금)" — 저장한 뒤의 상태 글 */
|
||||
function dayWithWeekday(date: Date, now: Date): string {
|
||||
return `${dayText(date, now)}(${WEEKDAY.format(date)})`
|
||||
}
|
||||
|
||||
/** 서버의 끝 시각 → 끝나는 날(그 직전 날짜) */
|
||||
function endDayDate(toDate: Date): Date {
|
||||
return new Date(toDate.getTime() - END_OFFSET_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장한 뒤의 상태 글 (디자인 원본 SettingsVacation) —
|
||||
* "저장했습니다. 10월 30일(금)부터 11월 3일(화)까지 자동으로 답합니다." · "저장했습니다. 끌 때까지 자동으로 답합니다." · 끄면 "…자동 응답을 껐습니다 — …"
|
||||
*/
|
||||
export function vacationSavedText(vacation: MailVacation, now: Date = new Date()): string {
|
||||
if (!vacation.isEnabled) return VACATION_TEXT.savedOff
|
||||
const from = parseInstant(vacation.fromDate)
|
||||
const to = parseInstant(vacation.toDate)
|
||||
const fromText = from ? dayWithWeekday(from, now) : null
|
||||
const toText = to ? dayWithWeekday(endDayDate(to), now) : null
|
||||
if (fromText && toText) return VACATION_TEXT.savedRange(fromText, toText)
|
||||
if (toText) return VACATION_TEXT.savedTo(toText)
|
||||
if (fromText) return VACATION_TEXT.savedFrom(fromText)
|
||||
return VACATION_TEXT.savedUntilOff
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일함 패널 아래 안내의 둘째 줄 (디자인 원본 MailboxManage "11월 3일까지 · 설정") — 보이지 않을 때는 null:
|
||||
* 꺼져 있거나, 끝나는 시각이 지났다. 아직 시작하지 않았으면 시작일을 보인다
|
||||
*/
|
||||
export function vacationNoticeText(vacation: MailVacation | null, now: Date = new Date()): string | null {
|
||||
if (!vacation?.isEnabled) return null
|
||||
const from = parseInstant(vacation.fromDate)
|
||||
const to = parseInstant(vacation.toDate)
|
||||
if (to && to.getTime() <= now.getTime()) return null
|
||||
const toText = to ? dayText(endDayDate(to), now) : null
|
||||
if (from && from.getTime() > now.getTime()) {
|
||||
const fromText = dayText(from, now)
|
||||
return toText ? VACATION_TEXT.noticeRange(fromText, toText) : VACATION_TEXT.noticeFrom(fromText)
|
||||
}
|
||||
return toText ? VACATION_TEXT.noticeUntil(toText) : VACATION_TEXT.noticeUntilOff
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { initialOperationTotal, mailboxOperationCopy } from './mailbox-operation'
|
||||
|
||||
// 오래 걸리는 메일함 처리의 알림 문구 (디자인 원본 MailActions4 "오래 걸리는 처리" · "알림 문구")
|
||||
|
||||
describe('mailboxOperationCopy', () => {
|
||||
it('모두 읽음 — "…하는 중 · 600 / 1,234통" → "받은편지함의 메일 1,234통을 읽음으로 표시했습니다."', () => {
|
||||
const copy = mailboxOperationCopy('read', '받은편지함')
|
||||
|
||||
expect(copy.progress(600, 1234)).toBe('받은편지함의 메일을 읽음으로 표시하는 중 · 600 / 1,234통')
|
||||
expect(copy.done(1234)).toBe('받은편지함의 메일 1,234통을 읽음으로 표시했습니다.')
|
||||
expect(copy.failed(600, 1234)).toBe('1,234통 가운데 600통까지 처리했습니다 — 다시 시도해 주세요.')
|
||||
// 다른 탭이 먼저 했다
|
||||
expect(copy.done(0)).toBe('받은편지함에 안 읽은 메일이 없습니다.')
|
||||
})
|
||||
|
||||
it('비우기 — 메일함 이름의 받침에 맞춰 을/를', () => {
|
||||
expect(mailboxOperationCopy('empty', '휴지통').progress(20, 21)).toBe('휴지통을 비우는 중 · 20 / 21통')
|
||||
expect(mailboxOperationCopy('empty', '휴지통').done(21)).toBe('휴지통을 비웠습니다 — 메일 21통.')
|
||||
expect(mailboxOperationCopy('empty', '스팸메일함').done(5)).toBe('스팸메일함을 비웠습니다 — 메일 5통.')
|
||||
// 다른 탭이 먼저 비웠다 — 받침에 맞춰 이/가
|
||||
expect(mailboxOperationCopy('empty', '휴지통').done(0)).toBe('휴지통이 이미 비어 있습니다.')
|
||||
expect(mailboxOperationCopy('empty', 'Spam').done(0)).toBe('Spam이(가) 이미 비어 있습니다.')
|
||||
})
|
||||
|
||||
it('메일함 지우기 — 옮긴 메일이 있으면 휴지통에 있다고 알리고, 빈 메일함은 진행 수 없이', () => {
|
||||
const copy = mailboxOperationCopy('delete', '거래처')
|
||||
|
||||
expect(copy.progress(500, 1234)).toBe('‘거래처’ 메일함을 지우는 중 · 500 / 1,234통')
|
||||
expect(copy.progress(0, 0)).toBe('‘거래처’ 메일함을 지우는 중')
|
||||
expect(copy.done(12)).toBe('‘거래처’ 메일함을 지웠습니다. 안의 메일 12통은 휴지통에 있습니다.')
|
||||
expect(copy.done(0)).toBe('‘거래처’ 메일함을 지웠습니다.')
|
||||
})
|
||||
|
||||
it('시작할 때의 전체 수 — 모두 읽음은 안 읽은 수, 비우기 · 지우기는 전체 수', () => {
|
||||
const mailbox = { unreadCount: 3, totalCount: 148 }
|
||||
|
||||
expect(initialOperationTotal('read', mailbox)).toBe(3)
|
||||
expect(initialOperationTotal('empty', mailbox)).toBe(148)
|
||||
expect(initialOperationTotal('delete', mailbox)).toBe(148)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
// 오래 걸리는 메일함 처리 — 모두 읽음 · 비우기 · 메일함 지우기의 알림 문구 (계약 2절 "메일함 관리" · 디자인 원본 MailActions4 — 순수 함수만 둔다)
|
||||
// 서버는 한 요청에 500통씩(25초 안) 처리하고 남은 수를 돌려준다 — 화면은 0 이 될 때까지 다시 보내며 "…하는 중 · 600 / 1,234통"을 보인다.
|
||||
|
||||
import { MAILBOX_OPERATION_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { objectParticle, subjectParticle } from '@/shared/utils/korean'
|
||||
|
||||
/** read: 모두 읽음으로 표시 · empty: 휴지통 / 스팸메일함 비우기 · delete: 메일함 지우기(안의 메일은 휴지통으로) */
|
||||
export type MailboxOperationKind = 'read' | 'empty' | 'delete'
|
||||
|
||||
export interface MailboxOperationCopy {
|
||||
/** 하는 중 — 지금까지 처리한 수 / 처리한 수 + 남은 수 */
|
||||
progress: (done: number, total: number) => string
|
||||
/**
|
||||
* 끝남 — 처리한 수 (지우기는 휴지통으로 옮긴 수). 0 이면 이미 끝나 있던 것이다(다른 탭이 먼저 했다):
|
||||
* "…에 안 읽은 메일이 없습니다." · "…이 이미 비어 있습니다." · "‘…’ 메일함을 지웠습니다."
|
||||
*/
|
||||
done: (count: number) => string
|
||||
/** 도중에 실패 — 거기까지 처리한 수 */
|
||||
failed: (done: number, total: number) => string
|
||||
}
|
||||
|
||||
/** 시작할 때 아는 전체 수 — 첫 응답 전에 알림에 보인다 (그 뒤로는 서버가 돌려준 남은 수로 맞춘다) */
|
||||
export function initialOperationTotal(kind: MailboxOperationKind, mailbox: Pick<Mailbox, 'unreadCount' | 'totalCount'>): number {
|
||||
return Math.max(0, kind === 'read' ? mailbox.unreadCount : mailbox.totalCount)
|
||||
}
|
||||
|
||||
export function mailboxOperationCopy(kind: MailboxOperationKind, mailboxName: string): MailboxOperationCopy {
|
||||
const failed = (done: number, total: number) => MAILBOX_OPERATION_TEXT.failed(formatCount(done), formatCount(total))
|
||||
/** "…하는 중 · 600 / 1,234통" — 셀 메일이 없으면(빈 메일함 지우기) 진행 수를 뺀다 */
|
||||
const withCount = (label: string) => (done: number, total: number) =>
|
||||
total > 0 ? `${label}${MAILBOX_OPERATION_TEXT.progressCount(formatCount(done), formatCount(total))}` : label
|
||||
if (kind === 'read') {
|
||||
return {
|
||||
progress: withCount(MAILBOX_OPERATION_TEXT.readProgress(mailboxName)),
|
||||
done: (count) =>
|
||||
count > 0
|
||||
? MAILBOX_OPERATION_TEXT.readDone(mailboxName, formatCount(count))
|
||||
: MAILBOX_OPERATION_TEXT.readNone(mailboxName),
|
||||
failed,
|
||||
}
|
||||
}
|
||||
if (kind === 'empty') {
|
||||
const object = `${mailboxName}${objectParticle(mailboxName)}`
|
||||
return {
|
||||
progress: withCount(MAILBOX_OPERATION_TEXT.emptyProgress(object)),
|
||||
done: (count) =>
|
||||
count > 0
|
||||
? MAILBOX_OPERATION_TEXT.emptyDone(object, formatCount(count))
|
||||
: MAILBOX_OPERATION_TEXT.emptyNone(`${mailboxName}${subjectParticle(mailboxName)}`),
|
||||
failed,
|
||||
}
|
||||
}
|
||||
return {
|
||||
progress: withCount(MAILBOX_OPERATION_TEXT.deleteProgress(mailboxName)),
|
||||
done: (count) =>
|
||||
count > 0
|
||||
? MAILBOX_OPERATION_TEXT.deleteDoneMoved(mailboxName, formatCount(count))
|
||||
: MAILBOX_OPERATION_TEXT.deleteDone(mailboxName),
|
||||
failed,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { emptyMailboxText, mailboxIcon, orderMailboxes } from './mailbox'
|
||||
import {
|
||||
emptyMailboxText,
|
||||
hasChildMailbox,
|
||||
mailboxActions,
|
||||
mailboxIcon,
|
||||
mailboxNameProblem,
|
||||
orderMailboxes,
|
||||
splitMailboxGroups,
|
||||
} from './mailbox'
|
||||
|
||||
function mailbox(partial: Partial<Mailbox> & Pick<Mailbox, 'id' | 'name'>): Mailbox {
|
||||
return {
|
||||
@@ -88,3 +96,56 @@ describe('mailboxIcon', () => {
|
||||
expect(mailboxIcon({ role: null })).toBe('folder')
|
||||
})
|
||||
})
|
||||
|
||||
describe('메일함 관리 (4단계 — 디자인 README "메일함 관리")', () => {
|
||||
const inbox = mailbox({ id: 'i', name: '받은편지함', role: 'inbox' })
|
||||
const sent = mailbox({ id: 's', name: '보낸편지함', role: 'sent' })
|
||||
const trash = mailbox({ id: 't', name: '휴지통', role: 'trash' })
|
||||
const project = mailbox({ id: 'p', name: '프로젝트' })
|
||||
const quote = mailbox({ id: 'q', name: '견적', parentId: 'p' })
|
||||
const inboxChild = mailbox({ id: 'ic', name: '협력사', parentId: 'i' })
|
||||
|
||||
it('두 묶음 — 역할이 있는 뿌리(와 그 아래)는 기본 메일함, 역할 없는 뿌리(와 그 아래)는 내 메일함', () => {
|
||||
const groups = splitMailboxGroups(orderMailboxes([project, quote, sent, inboxChild, trash, inbox]))
|
||||
|
||||
expect(groups.system.map((node) => [node.mailbox.id, node.depth])).toEqual([
|
||||
['i', 0],
|
||||
['ic', 1],
|
||||
['s', 0],
|
||||
['t', 0],
|
||||
])
|
||||
expect(groups.mine.map((node) => [node.mailbox.id, node.depth])).toEqual([
|
||||
['p', 0],
|
||||
['q', 1],
|
||||
])
|
||||
})
|
||||
|
||||
it('⋯ 메뉴 — 안 읽은 메일이 있으면 모두 읽음 · 받은편지함과 내 메일함은 하위 메일함 · 내 메일함만 이름 바꾸기 · 지우기 · 휴지통 / 스팸메일함은 비우기', () => {
|
||||
expect(mailboxActions({ ...inbox, unreadCount: 3 })).toEqual(['markRead', 'createChild'])
|
||||
expect(mailboxActions(inbox)).toEqual(['createChild'])
|
||||
expect(mailboxActions(sent)).toEqual([])
|
||||
expect(mailboxActions({ ...project, unreadCount: 1 })).toEqual(['markRead', 'createChild', 'rename', 'delete'])
|
||||
expect(mailboxActions({ ...trash, totalCount: 21 })).toEqual(['empty'])
|
||||
expect(mailboxActions(mailbox({ id: 'j', name: '스팸메일함', role: 'junk', totalCount: 5, unreadCount: 1 }))).toEqual([
|
||||
'markRead',
|
||||
'empty',
|
||||
])
|
||||
// 빈 휴지통은 비울 것이 없다
|
||||
expect(mailboxActions(trash)).toEqual([])
|
||||
})
|
||||
|
||||
it('하위 메일함이 있는가 — 지우기를 막는다', () => {
|
||||
expect(hasChildMailbox([project, quote], 'p')).toBe(true)
|
||||
expect(hasChildMailbox([project, quote], 'q')).toBe(false)
|
||||
})
|
||||
|
||||
it('이름 — 앞뒤 공백을 뺀 1~64자, / · 제어 문자(탭 등) 없음', () => {
|
||||
expect(mailboxNameProblem(' ')).toBe('empty')
|
||||
expect(mailboxNameProblem(' 협력사 ')).toBeNull()
|
||||
expect(mailboxNameProblem('a'.repeat(64))).toBeNull()
|
||||
expect(mailboxNameProblem('a'.repeat(65))).toBe('invalid')
|
||||
expect(mailboxNameProblem('2026/견적')).toBe('invalid')
|
||||
expect(mailboxNameProblem('견적\t2026')).toBe('invalid')
|
||||
expect(mailboxNameProblem('견적\u00852026')).toBe('invalid')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
MAILBOX_ROLE_ORDER,
|
||||
type MailboxKind,
|
||||
} from '@/shared/constants/mail'
|
||||
import { MAILBOX_NAME_FORBIDDEN_CHAR, MAILBOX_NAME_MAX_LENGTH } from '@/shared/constants/mailbox-manage'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
import type { Mailbox, MailboxRole } from '@/shared/types/mail'
|
||||
import { subjectParticle } from '@/shared/utils/korean'
|
||||
@@ -73,6 +74,76 @@ export function findMailboxByRole(list: readonly Mailbox[], role: MailboxRole):
|
||||
return list.find((mailbox) => mailbox.role === role) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일함 패널의 두 묶음 (디자인 README "4단계 › 메일함 관리") — 기본 메일함(역할이 있는 뿌리)과 "내 메일함"(역할 없는 뿌리).
|
||||
* 하위 메일함은 뿌리의 묶음을 따라간다 — 받은편지함 아래에 만든 메일함은 받은편지함 밑에 들여 쓴다 (나무 모양을 지킨다)
|
||||
*/
|
||||
export function splitMailboxGroups(nodes: readonly MailboxNode[]): { system: MailboxNode[]; mine: MailboxNode[] } {
|
||||
const system: MailboxNode[] = []
|
||||
const mine: MailboxNode[] = []
|
||||
let rootHasRole = false
|
||||
for (const node of nodes) {
|
||||
if (node.depth === 0) rootHasRole = node.mailbox.role !== null
|
||||
;(rootHasRole ? system : mine).push(node)
|
||||
}
|
||||
return { system, mine }
|
||||
}
|
||||
|
||||
/** 하위 메일함이 있는가 — 있으면 지울 수 없다 (서버도 400 VAL_001, 화면이 먼저 막는다) */
|
||||
export function hasChildMailbox(list: readonly Mailbox[], id: string): boolean {
|
||||
return list.some((mailbox) => mailbox.parentId === id)
|
||||
}
|
||||
|
||||
/** 비울 수 있는 메일함 — 휴지통 · 스팸메일함 (DELETE /mailboxes/{id}/messages) */
|
||||
export function isEmptiableMailbox(mailbox: Pick<Mailbox, 'role'>): boolean {
|
||||
return mailbox.role === 'trash' || mailbox.role === 'junk'
|
||||
}
|
||||
|
||||
/** ⋯ 메뉴가 할 수 있는 일 */
|
||||
export type MailboxAction = 'markRead' | 'createChild' | 'rename' | 'delete' | 'empty'
|
||||
|
||||
/**
|
||||
* 메일함마다 ⋯ 메뉴의 항목 (디자인 README "4단계 › 메일함 관리"):
|
||||
* 모두 읽음으로 표시(안 읽은 메일이 있을 때) · 하위 메일함 만들기(받은편지함 · 내 메일함) · 이름 바꾸기 · 지우기(내 메일함만) ·
|
||||
* 휴지통 / 스팸메일함 비우기(그 메일함에서만 — 빈 메일함이면 뺀다). 할 일이 없으면 빈 목록 — ⋯ 단추를 그리지 않는다
|
||||
*/
|
||||
export function mailboxActions(mailbox: Mailbox): MailboxAction[] {
|
||||
const actions: MailboxAction[] = []
|
||||
if (mailbox.unreadCount > 0) actions.push('markRead')
|
||||
if (mailbox.role === 'inbox' || mailbox.role === null) actions.push('createChild')
|
||||
if (mailbox.role === null) actions.push('rename', 'delete')
|
||||
if (isEmptiableMailbox(mailbox) && mailbox.totalCount > 0) actions.push('empty')
|
||||
return actions
|
||||
}
|
||||
|
||||
/** 제어 문자 — C0(줄바꿈 · 탭 등) · DEL · C1. 메일함 이름에 쓸 수 없다 (서버와 같은 범위) */
|
||||
const CONTROL_CHAR_RANGES: readonly (readonly [number, number])[] = [
|
||||
[0x00, 0x1f],
|
||||
[0x7f, 0x9f],
|
||||
]
|
||||
|
||||
function hasControlChar(text: string): boolean {
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const code = text.charCodeAt(index)
|
||||
if (CONTROL_CHAR_RANGES.some(([start, end]) => code >= start && code <= end)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 메일함 이름의 문제 — 앞뒤 공백을 뺀 1~64자, '/' · 제어 문자 없음 (계약 2절 "메일함 관리") */
|
||||
export function mailboxNameProblem(name: string): 'empty' | 'invalid' | null {
|
||||
const trimmed = name.trim()
|
||||
if (trimmed === '') return 'empty'
|
||||
if (
|
||||
trimmed.length > MAILBOX_NAME_MAX_LENGTH ||
|
||||
trimmed.includes(MAILBOX_NAME_FORBIDDEN_CHAR) ||
|
||||
hasControlChar(trimmed)
|
||||
) {
|
||||
return 'invalid'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function kindOf(mailbox: Pick<Mailbox, 'role'>): MailboxKind {
|
||||
return mailbox.role ?? CUSTOM_MAILBOX_KEY
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { hasPasswordChangeProblem, passwordChangeProblems } from './password-change'
|
||||
|
||||
// 비밀번호 바꾸기 — 보내기 전에 서버와 같은 규칙 (계약 1절: 8자 이상 · 지금과 다름 · 확인 칸 일치, 128자 이하)
|
||||
|
||||
describe('passwordChangeProblems', () => {
|
||||
it('규칙에 맞으면 문제가 없다', () => {
|
||||
const problems = passwordChangeProblems({ current: 'old-pass-1234', next: 'Ara!2026-mail', confirm: 'Ara!2026-mail' })
|
||||
|
||||
expect(problems).toEqual({ current: null, next: null, confirm: null })
|
||||
expect(hasPasswordChangeProblem(problems)).toBe(false)
|
||||
})
|
||||
|
||||
it('지금 비밀번호를 비웠다', () => {
|
||||
expect(passwordChangeProblems({ current: '', next: 'Ara!2026-mail', confirm: 'Ara!2026-mail' }).current).toBe('missing')
|
||||
})
|
||||
|
||||
it('새 비밀번호 — 8자 미만이거나 지금과 같으면 규칙 위반, 128자를 넘으면 너무 김', () => {
|
||||
expect(passwordChangeProblems({ current: 'old-pass', next: 'short1!', confirm: 'short1!' }).next).toBe('rule')
|
||||
expect(passwordChangeProblems({ current: 'same-pass-1', next: 'same-pass-1', confirm: 'same-pass-1' }).next).toBe('rule')
|
||||
const long = 'a'.repeat(129)
|
||||
expect(passwordChangeProblems({ current: 'old-pass', next: long, confirm: long }).next).toBe('tooLong')
|
||||
// 공백도 글자다 — 다듬지 않는다
|
||||
expect(passwordChangeProblems({ current: 'old-pass', next: ' ', confirm: ' ' }).next).toBeNull()
|
||||
})
|
||||
|
||||
it('확인 칸이 다르다', () => {
|
||||
const problems = passwordChangeProblems({ current: 'old-pass-1234', next: 'Ara!2026-mail', confirm: 'Ara!2026-mai' })
|
||||
|
||||
expect(problems.confirm).toBe('mismatch')
|
||||
expect(hasPasswordChangeProblem(problems)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
// 비밀번호 바꾸기 — 보내기 전에 서버와 같은 규칙을 먼저 본다 (계약 1절 "PUT /api/auth/password" — 순수 함수만 둔다)
|
||||
// 8자 이상 · 지금 비밀번호와 다름 · 확인 칸 일치. 128자를 넘으면 서버가 VAL_001 로 거절하므로 그것도 먼저 막는다.
|
||||
// 공백도 비밀번호의 글자다 — 다듬지 않는다.
|
||||
|
||||
import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH } from '@/shared/constants/account'
|
||||
|
||||
export interface PasswordChangeInput {
|
||||
current: string
|
||||
next: string
|
||||
confirm: string
|
||||
}
|
||||
|
||||
/** 칸마다 하나 — 없으면 null */
|
||||
export interface PasswordChangeProblems {
|
||||
/** 지금 비밀번호를 비웠다 */
|
||||
current: 'missing' | null
|
||||
/** rule: 8자 미만이거나 지금 비밀번호와 같다(안내 글이 그대로 오류가 된다) · tooLong: 128자 초과 */
|
||||
next: 'rule' | 'tooLong' | null
|
||||
/** 확인 칸이 새 비밀번호와 다르다 */
|
||||
confirm: 'mismatch' | null
|
||||
}
|
||||
|
||||
export function passwordChangeProblems(input: PasswordChangeInput): PasswordChangeProblems {
|
||||
const { current, next, confirm } = input
|
||||
let nextProblem: PasswordChangeProblems['next'] = null
|
||||
if (next.length > PASSWORD_MAX_LENGTH) nextProblem = 'tooLong'
|
||||
else if (next.length < PASSWORD_MIN_LENGTH || next === current) nextProblem = 'rule'
|
||||
return {
|
||||
current: current === '' ? 'missing' : null,
|
||||
next: nextProblem,
|
||||
confirm: confirm !== next ? 'mismatch' : null,
|
||||
}
|
||||
}
|
||||
|
||||
export function hasPasswordChangeProblem(problems: PasswordChangeProblems): boolean {
|
||||
return problems.current !== null || problems.next !== null || problems.confirm !== null
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { opensUpward } from './popup-placement'
|
||||
|
||||
// 메일함 패널의 ⋯ 메뉴 — 아래로 열면 잘릴 때 위로 연다
|
||||
|
||||
describe('opensUpward', () => {
|
||||
const base = { triggerTop: 400, triggerBottom: 440, popupHeight: 254, gap: 4 }
|
||||
|
||||
it('아래에 다 들어가면 아래로', () => {
|
||||
expect(opensUpward({ ...base, areaTop: 0, areaBottom: 900 })).toBe(false)
|
||||
})
|
||||
|
||||
it('아래가 모자라고 위가 넉넉하면 위로', () => {
|
||||
expect(opensUpward({ ...base, areaTop: 0, areaBottom: 600 })).toBe(true)
|
||||
})
|
||||
|
||||
it('둘 다 모자라면 넓은 쪽으로 — 아래가 더 넓으면 아래로', () => {
|
||||
expect(opensUpward({ ...base, triggerTop: 100, triggerBottom: 140, areaTop: 0, areaBottom: 320 })).toBe(false)
|
||||
expect(opensUpward({ ...base, triggerTop: 220, triggerBottom: 260, areaTop: 0, areaBottom: 320 })).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
// 펼침 메뉴의 자리 — 아래로 열면 보이는 영역을 넘을 때 위로 연다 (메일함 패널처럼 스크롤되는 칸 안의 ⋯ 메뉴, 순수 함수만 둔다)
|
||||
// 보이는 영역(화면과 스크롤 · 잘림 조상이 겹치는 부분)을 재는 일은 부르는 쪽이 한다.
|
||||
|
||||
export interface PopupFitInput {
|
||||
/** 연 단추의 위 · 아래 (화면 좌표) */
|
||||
triggerTop: number
|
||||
triggerBottom: number
|
||||
/** 메뉴 높이 */
|
||||
popupHeight: number
|
||||
/** 보이는 영역의 위 · 아래 (화면 좌표) */
|
||||
areaTop: number
|
||||
areaBottom: number
|
||||
/** 단추와 메뉴 사이 */
|
||||
gap: number
|
||||
}
|
||||
|
||||
/** 위로 열어야 하는가 — 아래로는 다 보이지 않고 위가 더 넓을 때 (둘 다 모자라면 넓은 쪽으로 — 메뉴는 안에서 스크롤된다) */
|
||||
export function opensUpward(input: PopupFitInput): boolean {
|
||||
const below = input.areaBottom - input.triggerBottom - input.gap
|
||||
const above = input.triggerTop - input.areaTop - input.gap
|
||||
return input.popupHeight > below && above > below
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import type { MailVacation } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
|
||||
import { useMailVacationStore } from './mail-vacation.store'
|
||||
|
||||
// 부재중 자동 응답 스토어 — 세션이 바뀐 뒤 늦게 온 응답은 쓰지 않는다 (계약 2절 "부재중 자동 응답")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const OTHER = { email: 'minsu.park@arasystem.it.kr', name: '박민수' }
|
||||
|
||||
const ON: MailVacation = {
|
||||
isEnabled: true,
|
||||
fromDate: null,
|
||||
toDate: '2026-11-03T15:00:00.000Z',
|
||||
subject: '부재중입니다',
|
||||
textBody: '11월 3일까지 자리를 비웁니다.',
|
||||
}
|
||||
|
||||
/** 응답을 손으로 끝내는 요청 — 그 사이에 세션을 바꿔 본다 */
|
||||
function deferred() {
|
||||
let finish: (result: MockResult) => void = () => undefined
|
||||
const promise = new Promise<MockResult>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
return { promise, finish }
|
||||
}
|
||||
|
||||
describe('mail-vacation.store — 늦게 온 응답', () => {
|
||||
let requests: MockRequest[]
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().startSession(USER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
it('불러오는 사이 로그아웃하고 다른 계정으로 들어오면, 늦게 온 응답을 새 사용자의 안내로 쓰지 않는다', async () => {
|
||||
const reply = deferred()
|
||||
requests = installMockApi(() => reply.promise)
|
||||
const store = useMailVacationStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loading = store.load()
|
||||
authStore.clearAuth()
|
||||
await nextTick()
|
||||
authStore.startSession(OTHER)
|
||||
await nextTick()
|
||||
|
||||
reply.finish(ok(ON))
|
||||
await loading
|
||||
expect(requests.map((request) => request.method)).toEqual(['GET'])
|
||||
expect(store.vacation).toBeNull()
|
||||
expect(store.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('저장하는 사이 세션이 바뀌면(같은 사람이 다시 들어와도) 결과를 돌려주기만 하고 스토어에는 쓰지 않는다', async () => {
|
||||
const reply = deferred()
|
||||
requests = installMockApi((request) => (request.method === 'PUT' ? reply.promise : ok(ON)))
|
||||
const store = useMailVacationStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const saving = store.save(ON)
|
||||
authStore.clearAuth()
|
||||
await nextTick()
|
||||
authStore.startSession(USER)
|
||||
await nextTick()
|
||||
|
||||
reply.finish(ok(ON))
|
||||
await expect(saving).resolves.toEqual(ON)
|
||||
expect(store.vacation).toBeNull()
|
||||
expect(store.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('같은 세션이면 저장한 값이 곧 안내 값이다 — 그 전에 시작한 불러오기가 늦게 와도 덮지 않는다', async () => {
|
||||
const stale = deferred()
|
||||
const saved: MailVacation = { ...ON, subject: '휴가 중입니다' }
|
||||
requests = installMockApi((request) => (request.method === 'PUT' ? ok(saved) : stale.promise))
|
||||
const store = useMailVacationStore()
|
||||
|
||||
const loading = store.load()
|
||||
await store.save(saved)
|
||||
stale.finish(ok({ ...ON, isEnabled: false }))
|
||||
await loading
|
||||
|
||||
expect(requests.map((request) => request.method)).toEqual(['GET', 'PUT'])
|
||||
expect(store.vacation).toEqual(saved)
|
||||
expect(store.status).toBe('ready')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { toApiError, type ApiError } from '@/composables/useApi'
|
||||
import { useMail, type MailRequestOptions } from '@/composables/useMail'
|
||||
import type { MailVacation } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { LoadStatus } from '@/stores/mail.store'
|
||||
|
||||
// 부재중 자동 응답 스토어 — 계약 2절 "부재중 자동 응답" (사용자마다 하나, 엔진의 VacationResponse)
|
||||
// - 메일 화면은 들어올 때 한 번 받는다(load) — 켜져 있으면 메일함 패널 아래(사용량 위)에 안내를 보인다. 못 받으면 알리지 않고 지난 값을 둔다
|
||||
// - 설정 화면은 들어올 때마다 새로 받는다(load) — 다른 창 · 메일 프로그램에서 바꿨을 수 있다. 오류는 화면이 직접 그린다
|
||||
// - 저장(save)한 값이 곧 패널 안내의 값이다
|
||||
// - 세션이 바뀐 뒤(로그아웃 · 다른 계정 — reset) 늦게 온 응답은 쓰지 않는다: 불러오기는 순번(seq — reset 이 올린다),
|
||||
// 저장은 세션 세대(authStore.generation — 그사이 불러오기가 있어도 저장한 값이 더 새롭다)로 가린다
|
||||
export const useMailVacationStore = defineStore('mail-vacation', () => {
|
||||
const mailApi = useMail()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const vacation = ref<MailVacation | null>(null)
|
||||
const status = ref<LoadStatus>('idle')
|
||||
const error = ref<ApiError | null>(null)
|
||||
|
||||
let seq = 0
|
||||
|
||||
/** 새로 받는다 — 실패하면 null (알림 없이 상태에 담는다 — 지난 값은 그대로 둔다) */
|
||||
async function load(): Promise<MailVacation | null> {
|
||||
seq += 1
|
||||
const current = seq
|
||||
status.value = 'loading'
|
||||
error.value = null
|
||||
try {
|
||||
const result = await mailApi.getVacation({ silent: true })
|
||||
if (current !== seq) return vacation.value
|
||||
vacation.value = result
|
||||
status.value = 'ready'
|
||||
return result
|
||||
} catch (caught) {
|
||||
if (current !== seq) return vacation.value
|
||||
status.value = 'error'
|
||||
error.value = toApiError(caught)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 — 실패하면 예외가 그대로 올라간다 (칸 안 안내는 화면이 한다).
|
||||
* 기다리는 사이 세션이 바뀌었으면 결과를 돌려주기만 하고 스토어에는 쓰지 않는다 (다음 사용자의 안내가 되지 않게)
|
||||
*/
|
||||
async function save(payload: MailVacation, options?: MailRequestOptions): Promise<MailVacation> {
|
||||
const session = authStore.generation
|
||||
const result = await mailApi.saveVacation(payload, options)
|
||||
if (authStore.generation !== session) return result
|
||||
seq += 1
|
||||
vacation.value = result
|
||||
status.value = 'ready'
|
||||
error.value = null
|
||||
return result
|
||||
}
|
||||
|
||||
function reset() {
|
||||
seq += 1
|
||||
vacation.value = null
|
||||
status.value = 'idle'
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// 로그아웃 · 다른 계정 로그인 — 이전 사용자의 설정이 남지 않게
|
||||
watch(
|
||||
() => authStore.user?.email ?? null,
|
||||
() => reset(),
|
||||
)
|
||||
|
||||
return { vacation, status, error, load, save, reset }
|
||||
})
|
||||
@@ -5,17 +5,20 @@ import { useMail } from '@/composables/useMail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { createBulkActions } from './mail/bulk'
|
||||
import { createListState } from './mail/list'
|
||||
import { createMailboxAdmin } from './mail/mailbox-admin'
|
||||
import { createMailboxesState } from './mail/mailboxes'
|
||||
import { createMessageActions, createOpenedMessage } from './mail/message'
|
||||
import { createSelection } from './mail/selection'
|
||||
|
||||
export type { BulkResult } from '@/shared/utils/mail-bulk'
|
||||
export type { BulkMoveResult, FlagChanges, LoadStatus, MailBulkResult, SearchOrigin } from './mail/types'
|
||||
export type { BulkMoveResult, FlagChanges, LoadStatus, MailBulkResult, MoveOptions, SearchOrigin } from './mail/types'
|
||||
export type { MailboxOperationOutcome, MailboxOperationProgress } from './mail/mailbox-admin'
|
||||
|
||||
// 메일 도메인 스토어 — 메일함 · 사용량 · 보이는 목록(메일함 또는 검색 결과) · 고른 메일 · 열린 메일
|
||||
// 한 스토어를 기능별 모듈(stores/mail/*)로 나눠 조립한다 — 화면은 이 스토어 하나만 본다.
|
||||
// mailboxes.ts 메일함 · 사용량 | list.ts 보이는 목록 · 이 탭이 바꾼 것 | selection.ts 고른 메일
|
||||
// message.ts 열린 메일 · 읽음 · 중요 표시 | bulk.ts 여러 통 처리 (읽기 화면의 한 통 삭제 포함)
|
||||
// message.ts 열린 메일 · 읽음 · 중요 표시 | bulk.ts 여러 통 처리 (읽기 화면의 한 통 삭제 · 스팸 신고 포함)
|
||||
// mailbox-admin.ts 메일함 만들기 · 이름 바꾸기 · 오래 걸리는 처리(모두 읽음 · 비우기 · 메일함 지우기)
|
||||
//
|
||||
// 오류 처리 원칙:
|
||||
// - 화면 영역이 오류 상태를 직접 그리는 첫 조회(메일함·목록·본문)는 silent 로 부르고 상태에 담는다
|
||||
@@ -35,6 +38,7 @@ export const useMailStore = defineStore('mail', () => {
|
||||
const selection = createSelection(list)
|
||||
const message = createMessageActions({ mailApi, mailboxes: mailboxState, list, opened })
|
||||
const bulk = createBulkActions({ mailApi, mailboxes: mailboxState, list, selection })
|
||||
const admin = createMailboxAdmin({ mailApi, mailboxes: mailboxState, list })
|
||||
|
||||
/** 쓰기를 마치거나 취소하면 돌아갈 화면 (보던 메일함·메일·검색 결과) */
|
||||
const lastBrowseLocation = ref<string | null>(null)
|
||||
@@ -68,6 +72,7 @@ export const useMailStore = defineStore('mail', () => {
|
||||
}
|
||||
|
||||
function reset() {
|
||||
admin.reset()
|
||||
mailboxState.reset()
|
||||
list.reset()
|
||||
selection.clearChecked()
|
||||
@@ -147,6 +152,13 @@ export const useMailStore = defineStore('mail', () => {
|
||||
purgeMessages: bulk.purgeMessages,
|
||||
purgeChecked: bulk.purgeChecked,
|
||||
restoreMoved: bulk.restoreMoved,
|
||||
// 메일함 관리 (4단계)
|
||||
runningMailboxOperations: admin.runningOperations,
|
||||
isMailboxOperationRunning: admin.isOperationRunning,
|
||||
isMailboxOperationBlocked: admin.isOperationBlocked,
|
||||
createMailbox: admin.createMailbox,
|
||||
renameMailbox: admin.renameMailbox,
|
||||
runMailboxOperation: admin.runMailboxOperation,
|
||||
// 쓰기 화면
|
||||
lastBrowseLocation,
|
||||
rememberBrowseLocation,
|
||||
|
||||
@@ -17,7 +17,7 @@ import { isAllMailboxSearch, scopeMailboxIdOf } from '@/shared/utils/mail-list-s
|
||||
import type { ListState } from './list'
|
||||
import type { MailboxesState } from './mailboxes'
|
||||
import type { SelectionState } from './selection'
|
||||
import type { BulkMoveResult, FlagChanges, MailApi, MailBulkResult } from './types'
|
||||
import type { BulkMoveResult, FlagChanges, MailApi, MailBulkResult, MoveOptions } from './types'
|
||||
|
||||
interface BulkDeps {
|
||||
mailApi: MailApi
|
||||
@@ -122,11 +122,17 @@ export function createBulkActions({ mailApi, mailboxes, list, selection }: BulkD
|
||||
|
||||
/**
|
||||
* 메일을 옮긴다 (PATCH /messages { mailboxId }) — 휴지통 밖의 "삭제"도 휴지통으로 옮기는 것이다.
|
||||
* 스팸 신고 · 스팸 아님은 스팸 표시(isJunk)를 같은 요청에 함께 보낸다 (계약 2절 "스팸 · 원문").
|
||||
* 응답을 받은 뒤 옮긴 메일(updatedIds)만 목록에서 빼고 개수를 다시 맞춘다. 그대로 둔 메일 · 보내지 못한 메일이 있으면
|
||||
* (503 은 일부만 바뀌었을 수 있다) 보이는 목록을 서버 기준으로 다시 맞춘다. targets 는 고른 메일이거나 열린 메일 한 통이다
|
||||
*/
|
||||
async function moveMessages(targets: readonly MessageSummary[], targetId: string): Promise<BulkMoveResult> {
|
||||
if (targets.length === 0) return { ...emptyBulkResult(), origins: [], stale: false }
|
||||
async function moveMessages(
|
||||
targets: readonly MessageSummary[],
|
||||
targetId: string,
|
||||
options: MoveOptions = {},
|
||||
): Promise<BulkMoveResult> {
|
||||
const junk = options.isJunk === undefined ? {} : { isJunk: options.isJunk }
|
||||
if (targets.length === 0) return { ...emptyBulkResult(), origins: [], stale: false, ...junk }
|
||||
const isStale = captureList()
|
||||
const origins = new Map<string, string>()
|
||||
for (const message of targets) {
|
||||
@@ -137,7 +143,7 @@ export function createBulkActions({ mailApi, mailboxes, list, selection }: BulkD
|
||||
const result = await sendBulk(
|
||||
targets.map((message) => message.id),
|
||||
async (chunkIds) => {
|
||||
const patched = await mailApi.updateMessages({ ids: chunkIds, mailboxId: targetId })
|
||||
const patched = await mailApi.updateMessages({ ids: chunkIds, mailboxId: targetId, ...junk })
|
||||
return { done: patched.updatedIds, skipped: patched.failedIds }
|
||||
},
|
||||
)
|
||||
@@ -149,12 +155,12 @@ export function createBulkActions({ mailApi, mailboxes, list, selection }: BulkD
|
||||
if (hasLeftovers(result)) void list.syncVisibleList({ includeSearch: true })
|
||||
mailboxes.syncCounts()
|
||||
}
|
||||
return { ...result, origins: groupByOrigin(result.doneIds, origins), stale }
|
||||
return { ...result, origins: groupByOrigin(result.doneIds, origins), stale, ...junk }
|
||||
}
|
||||
|
||||
/** 고른 메일을 옮긴다 — 하나라도 옮겼으면 고른 것을 푼다 (요청이 통째로 실패했으면 다시 해 볼 수 있게 둔다) */
|
||||
async function moveChecked(targetId: string): Promise<BulkMoveResult> {
|
||||
const result = await moveMessages(selection.checkedMessages.value, targetId)
|
||||
async function moveChecked(targetId: string, options: MoveOptions = {}): Promise<BulkMoveResult> {
|
||||
const result = await moveMessages(selection.checkedMessages.value, targetId, options)
|
||||
if (!result.stale && result.doneIds.length > 0) selection.clearChecked()
|
||||
return result
|
||||
}
|
||||
@@ -190,13 +196,15 @@ export function createBulkActions({ mailApi, mailboxes, list, selection }: BulkD
|
||||
}
|
||||
|
||||
/**
|
||||
* 되돌리기 — 옮긴 메일을 원래 메일함별로 묶어 다시 옮긴 뒤, 개수와 보이는 목록(검색 결과 포함)을 서버 기준으로 맞춘다
|
||||
* 되돌리기 — 옮긴 메일을 원래 메일함별로 묶어 다시 옮긴 뒤, 개수와 보이는 목록(검색 결과 포함)을 서버 기준으로 맞춘다.
|
||||
* 스팸 신고 · 스팸 아님의 되돌리기는 표시도 되돌린다 — 옮길 때 보낸 값의 반대(isJunk)를 함께 보낸다
|
||||
*/
|
||||
async function restoreMoved(origins: readonly BulkOrigin[]): Promise<BulkResult> {
|
||||
async function restoreMoved(origins: readonly BulkOrigin[], options: MoveOptions = {}): Promise<BulkResult> {
|
||||
const junk = options.isJunk === undefined ? {} : { isJunk: options.isJunk }
|
||||
const total = emptyBulkResult()
|
||||
for (const [index, group] of origins.entries()) {
|
||||
const result = await sendBulk(group.ids, async (chunkIds) => {
|
||||
const patched = await mailApi.updateMessages({ ids: chunkIds, mailboxId: group.mailboxId })
|
||||
const patched = await mailApi.updateMessages({ ids: chunkIds, mailboxId: group.mailboxId, ...junk })
|
||||
return { done: patched.updatedIds, skipped: patched.failedIds }
|
||||
})
|
||||
total.doneIds.push(...result.doneIds)
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
// 메일 스토어 모듈 — 메일함 관리 (계약 2절 "메일함 관리" — 4단계, mail.store.ts 가 조립한다)
|
||||
// - 만들기 · 이름 바꾸기: 서버가 돌려준 메일함을 바로 보이고, 뒤이어 메일함 목록을 서버 기준으로 다시 맞춘다
|
||||
// - 오래 걸리는 처리(모두 읽음 · 비우기 · 메일함 지우기): 서버가 한 요청을 25초 안에 끝내고 남은 수를 돌려준다 —
|
||||
// 0(지우기는 deleted: true)이 될 때까지 같은 요청을 다시 보내며 진행을 알린다. 도중에 실패하면 거기까지 처리한 수를 돌려준다
|
||||
// (다시 하면 남은 것만 처리한다). 요청 실패는 인터셉터가 알린다. 그동안 다른 일을 해도 된다 — 같은 메일함의 같은 처리만 한 번에 하나
|
||||
// - 휴지통 비우기와 메일함 지우기는 함께 돌지 않는다: 비우기는 차례마다 그때의 휴지통을 다시 읽어 지우므로, 그동안 지운 메일함의 메일이
|
||||
// 휴지통에 들어가자마자 영구 삭제된다 ("안의 메일은 휴지통에 있습니다"가 거짓이 된다). 스팸메일함 비우기는 상관없다 (isOperationBlocked)
|
||||
// - 이미 지워진 메일함(지우기의 404 RES_001 — 다른 탭이 먼저 지웠거나, 응답이 끊긴 뒤 다시 보냈다)은 실패가 아니라 끝난 것으로 다룬다
|
||||
// - 세션이 바뀌면(로그아웃 · 다른 계정 — reset) 하던 처리는 더 보내지 않고 멈춘다 (canceled — 알리지 않는다).
|
||||
// 만들기 · 이름 바꾸기의 늦은 응답도 쓰지 않는다 — 메일함 id 는 계정마다 겹칠 수 있어 다음 사용자의 메일함을 덮게 된다
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
import { getErrorMessage, toApiError, type ApiError } from '@/composables/useApi'
|
||||
import type { MailRequestOptions } from '@/composables/useMail'
|
||||
import { MAILBOX_OPERATION_MAX_STALLS } from '@/shared/constants/mailbox-manage'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { initialOperationTotal, type MailboxOperationKind } from '@/shared/utils/mailbox-operation'
|
||||
import { scopeMailboxIdOf } from '@/shared/utils/mail-list-source'
|
||||
import type { ListState } from './list'
|
||||
import type { MailboxesState } from './mailboxes'
|
||||
import type { MailApi } from './types'
|
||||
|
||||
interface MailboxAdminDeps {
|
||||
mailApi: MailApi
|
||||
mailboxes: MailboxesState
|
||||
list: ListState
|
||||
}
|
||||
|
||||
/** 진행 — 지금까지 처리한 수 / 처리한 수 + 남은 수 */
|
||||
export interface MailboxOperationProgress {
|
||||
processed: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type MailboxOperationOutcome =
|
||||
/** 끝났다 — 처리한 수 (0 이면 이미 끝나 있었다) · 휴지통으로 옮긴 수 (지우기에서만) */
|
||||
| { status: 'done'; processed: number; movedToTrash: number }
|
||||
/** 도중에 멈췄다 — notified 가 false 면 아직 알리지 않은 실패다(서버가 나아가지 못함 — 부르는 쪽이 알린다) */
|
||||
| { status: 'failed'; processed: number; total: number; error: ApiError; notified: boolean }
|
||||
/** 세션이 바뀌었거나 같은 처리가 이미 돌고 있다 · 함께 돌 수 없는 처리가 돌고 있다 — 아무것도 알리지 않는다 */
|
||||
| { status: 'canceled' }
|
||||
|
||||
/** 돌고 있는 처리 */
|
||||
interface RunningOperation {
|
||||
kind: MailboxOperationKind
|
||||
mailboxId: string
|
||||
}
|
||||
|
||||
/** 한 번 보낸 결과 — 이 메일함에서 처리한 수 · 휴지통으로 옮긴 수 · 남은 수 · 끝났는지 */
|
||||
interface OperationStep {
|
||||
processed: number
|
||||
movedToTrash: number
|
||||
remaining: number
|
||||
done: boolean
|
||||
}
|
||||
|
||||
function operationKey(kind: MailboxOperationKind, mailboxId: string): string {
|
||||
return `${kind}:${mailboxId}`
|
||||
}
|
||||
|
||||
export function createMailboxAdmin({ mailApi, mailboxes, list }: MailboxAdminDeps) {
|
||||
/** 돌고 있는 처리 — "종류:메일함 id" (메뉴 항목 · 비우기 단추를 그동안 끈다) */
|
||||
const runningOperations = shallowRef<ReadonlyMap<string, RunningOperation>>(new Map())
|
||||
/** 세션이 바뀔 때마다 오른다 — 하던 처리 · 늦게 온 응답은 쓰지 않고 멈춘다 */
|
||||
let epoch = 0
|
||||
|
||||
function isOperationRunning(kind: MailboxOperationKind, mailboxId: string): boolean {
|
||||
return runningOperations.value.has(operationKey(kind, mailboxId))
|
||||
}
|
||||
|
||||
/**
|
||||
* 지금은 시작할 수 없는 처리인가 — 휴지통을 비우는 동안의 메일함 지우기, 메일함을 지우는 동안의 휴지통 비우기 (위 설명).
|
||||
* 스팸메일함 비우기 · 모두 읽음은 막지 않는다
|
||||
*/
|
||||
function isOperationBlocked(kind: MailboxOperationKind, mailbox: Pick<Mailbox, 'role'>): boolean {
|
||||
if (kind === 'delete') {
|
||||
const trash = mailboxes.mailboxByRole('trash')
|
||||
return trash !== null && isOperationRunning('empty', trash.id)
|
||||
}
|
||||
if (kind === 'empty' && mailbox.role === 'trash') {
|
||||
return [...runningOperations.value.values()].some((operation) => operation.kind === 'delete')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function markRunning(operation: RunningOperation, running: boolean) {
|
||||
const next = new Map(runningOperations.value)
|
||||
const key = operationKey(operation.kind, operation.mailboxId)
|
||||
if (running) next.set(key, operation)
|
||||
else next.delete(key)
|
||||
runningOperations.value = next
|
||||
}
|
||||
|
||||
/**
|
||||
* 만들고 바로 보인다 — 같은 자리에 같은 이름이면 409 MAIL_009 (예외가 그대로 올라간다).
|
||||
* 기다리는 사이 세션이 바뀌었으면 null — 보이지도 알리지도 않는다
|
||||
*/
|
||||
async function createMailbox(
|
||||
name: string,
|
||||
parentId: string | null,
|
||||
options?: MailRequestOptions,
|
||||
): Promise<Mailbox | null> {
|
||||
const started = epoch
|
||||
const created = await mailApi.createMailbox({ name, parentId }, options)
|
||||
if (started !== epoch) return null
|
||||
mailboxes.upsertMailbox(created)
|
||||
void mailboxes.loadMailboxes({ background: true })
|
||||
return created
|
||||
}
|
||||
|
||||
/** 이름 바꾸기 — 기다리는 사이 세션이 바뀌었으면 null (다음 사용자의 같은 id 메일함을 덮지 않게) */
|
||||
async function renameMailbox(id: string, name: string, options?: MailRequestOptions): Promise<Mailbox | null> {
|
||||
const started = epoch
|
||||
const renamed = await mailApi.renameMailbox(id, { name }, options)
|
||||
if (started !== epoch) return null
|
||||
mailboxes.upsertMailbox(renamed)
|
||||
void mailboxes.loadMailboxes({ background: true })
|
||||
return renamed
|
||||
}
|
||||
|
||||
async function sendStep(kind: MailboxOperationKind, mailboxId: string): Promise<OperationStep> {
|
||||
if (kind === 'delete') {
|
||||
// 이미 지워진 메일함(RES_001)은 끝난 것으로 다룬다 — 알림을 끄고 아래 runMailboxOperation 이 받는다
|
||||
const result = await mailApi.deleteMailbox(mailboxId, { silent: ['RES_001'] })
|
||||
const processed = typeof result.processed === 'number' ? result.processed : result.movedToTrash
|
||||
return { processed, movedToTrash: result.movedToTrash, remaining: result.remaining, done: result.deleted }
|
||||
}
|
||||
const result = kind === 'read' ? await mailApi.markMailboxRead(mailboxId) : await mailApi.emptyMailbox(mailboxId)
|
||||
return { processed: result.processed, movedToTrash: 0, remaining: result.remaining, done: result.remaining <= 0 }
|
||||
}
|
||||
|
||||
/** 한 번 보낸 뒤 — 남은 수를 패널에 바로 보인다 (끝나면 서버 기준으로 다시 맞춘다) */
|
||||
function applyStep(kind: MailboxOperationKind, mailboxId: string, step: OperationStep) {
|
||||
if (kind === 'read') mailboxes.setCounts(mailboxId, { unreadCount: Math.max(0, step.remaining) })
|
||||
else if (kind === 'delete' && step.done) mailboxes.removeMailbox(mailboxId)
|
||||
else mailboxes.setCounts(mailboxId, { totalCount: Math.max(0, step.remaining) })
|
||||
}
|
||||
|
||||
/**
|
||||
* 끝난 뒤 — 개수 · 사용량(메일함 목록 포함)을 다시 맞추고, 그 메일함의 목록을 보고 있으면 다시 받는다
|
||||
* (비우기는 메일이 빠졌고, 모두 읽음은 굵은 글자가 풀린다). 다른 목록은 건드리지 않는다
|
||||
*/
|
||||
function settle(kind: MailboxOperationKind, mailboxId: string) {
|
||||
mailboxes.syncCounts()
|
||||
const source = list.listSource.value
|
||||
if (kind === 'delete' || !source || scopeMailboxIdOf(source) !== mailboxId) return
|
||||
if (kind === 'read') void list.syncVisibleList({ includeSearch: true })
|
||||
else void list.openList(source)
|
||||
}
|
||||
|
||||
/**
|
||||
* 오래 걸리는 처리 — 끝날 때까지(남은 수 0 · 지우기는 deleted: true) 같은 요청을 다시 보낸다. 다시 보낼 때마다 onProgress.
|
||||
* 실패는 요청마다 인터셉터가 알린다(notified). 한 번도 나아가지 못한 응답이 이어지면 멈추고 BIZ_001 로 돌려준다(아직 알리지 않았다).
|
||||
* 같은 처리가 이미 돌고 있거나 함께 돌 수 없는 처리가 돌고 있으면 시작하지 않는다 (canceled)
|
||||
*/
|
||||
async function runMailboxOperation(
|
||||
kind: MailboxOperationKind,
|
||||
mailbox: Mailbox,
|
||||
onProgress: (progress: MailboxOperationProgress) => void,
|
||||
): Promise<MailboxOperationOutcome> {
|
||||
if (isOperationRunning(kind, mailbox.id) || isOperationBlocked(kind, mailbox)) return { status: 'canceled' }
|
||||
const operation: RunningOperation = { kind, mailboxId: mailbox.id }
|
||||
const started = epoch
|
||||
markRunning(operation, true)
|
||||
let processed = 0
|
||||
let movedToTrash = 0
|
||||
let total = initialOperationTotal(kind, mailbox)
|
||||
let stalls = 0
|
||||
try {
|
||||
for (;;) {
|
||||
let step: OperationStep
|
||||
try {
|
||||
step = await sendStep(kind, mailbox.id)
|
||||
} catch (error) {
|
||||
if (started !== epoch) return { status: 'canceled' }
|
||||
const apiError = toApiError(error)
|
||||
if (kind === 'delete' && apiError.code === 'RES_001') {
|
||||
// 이미 지워진 메일함 — 다른 탭이 먼저 지웠거나 끊긴 응답 뒤에 다시 보냈다
|
||||
mailboxes.removeMailbox(mailbox.id)
|
||||
settle(kind, mailbox.id)
|
||||
return { status: 'done', processed, movedToTrash }
|
||||
}
|
||||
mailboxes.syncCounts()
|
||||
return { status: 'failed', processed, total, error: apiError, notified: true }
|
||||
}
|
||||
if (started !== epoch) return { status: 'canceled' }
|
||||
processed += Math.max(0, step.processed)
|
||||
movedToTrash += Math.max(0, step.movedToTrash)
|
||||
total = processed + Math.max(0, step.remaining)
|
||||
applyStep(kind, mailbox.id, step)
|
||||
if (step.done) break
|
||||
stalls = step.processed > 0 ? 0 : stalls + 1
|
||||
if (stalls >= MAILBOX_OPERATION_MAX_STALLS) {
|
||||
mailboxes.syncCounts()
|
||||
const error: ApiError = { code: 'BIZ_001', message: getErrorMessage('BIZ_001') }
|
||||
return { status: 'failed', processed, total, error, notified: false }
|
||||
}
|
||||
onProgress({ processed, total })
|
||||
}
|
||||
settle(kind, mailbox.id)
|
||||
return { status: 'done', processed, movedToTrash }
|
||||
} finally {
|
||||
// 세션이 바뀐 뒤 끝난 옛 처리는 새 세션의 표시를 건드리지 않는다 (reset 이 이미 비웠다)
|
||||
if (started === epoch) markRunning(operation, false)
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
epoch += 1
|
||||
runningOperations.value = new Map()
|
||||
}
|
||||
|
||||
return {
|
||||
runningOperations,
|
||||
isOperationRunning,
|
||||
isOperationBlocked,
|
||||
createMailbox,
|
||||
renameMailbox,
|
||||
runMailboxOperation,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
export type MailboxAdminState = ReturnType<typeof createMailboxAdmin>
|
||||
@@ -143,6 +143,24 @@ export function createMailboxesState(mailApi: MailApi) {
|
||||
)
|
||||
}
|
||||
|
||||
/** 서버가 돌려준 메일함을 바로 보인다 — 만들기 · 이름 바꾸기 (서버 기준은 뒤이어 받는 목록이 맞춘다) */
|
||||
function upsertMailbox(next: Mailbox) {
|
||||
const known = mailboxes.value.some((mailbox) => mailbox.id === next.id)
|
||||
mailboxes.value = known
|
||||
? mailboxes.value.map((mailbox) => (mailbox.id === next.id ? next : mailbox))
|
||||
: [...mailboxes.value, next]
|
||||
}
|
||||
|
||||
/** 지운 메일함을 뺀다 */
|
||||
function removeMailbox(id: string) {
|
||||
mailboxes.value = mailboxes.value.filter((mailbox) => mailbox.id !== id)
|
||||
}
|
||||
|
||||
/** 오래 걸리는 처리의 남은 수를 바로 보인다 — 전체 읽음은 안 읽은 수, 비우기 · 지우기는 전체 수 */
|
||||
function setCounts(id: string, counts: Partial<Pick<Mailbox, 'unreadCount' | 'totalCount'>>) {
|
||||
mailboxes.value = mailboxes.value.map((mailbox) => (mailbox.id === id ? { ...mailbox, ...counts } : mailbox))
|
||||
}
|
||||
|
||||
function reset() {
|
||||
mailboxSeq += 1
|
||||
quotaSeq += 1
|
||||
@@ -171,6 +189,9 @@ export function createMailboxesState(mailApi: MailApi) {
|
||||
ensureQuota,
|
||||
syncCounts,
|
||||
adjustUnread,
|
||||
upsertMailbox,
|
||||
removeMailbox,
|
||||
setCounts,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,13 @@ export interface MailBulkResult extends BulkResult {
|
||||
export interface BulkMoveResult extends MailBulkResult {
|
||||
/** 되돌리기 — 원래 메일함별로 묶은 메일 */
|
||||
origins: BulkOrigin[]
|
||||
/** 옮기며 함께 보낸 스팸 표시 (스팸 신고 true · 스팸 아님 false) — 되돌리기는 반대 값을 보낸다. 그냥 옮기기는 undefined */
|
||||
isJunk?: boolean
|
||||
}
|
||||
|
||||
/** 옮기기에 함께 보낼 것 — 스팸 신고 · 스팸 아님은 이동과 스팸 표시를 한 요청에 보낸다 (계약 2절 "스팸 · 원문") */
|
||||
export interface MoveOptions {
|
||||
isJunk?: boolean
|
||||
}
|
||||
|
||||
/** 검색을 시작한 곳 — 범위 줄의 "그 메일함"과 검색을 닫을 때 돌아갈 화면 */
|
||||
|
||||
@@ -7,7 +7,7 @@ import ToastHost from '@/components/feedback/ToastHost.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import { useAuthStore } from './auth.store'
|
||||
import { TOAST_ACTION_DURATION_MS, TOAST_DURATION_MS, useToastStore } from './toast.store'
|
||||
import { MAX_VISIBLE_TOASTS, TOAST_ACTION_DURATION_MS, TOAST_DURATION_MS, useToastStore } from './toast.store'
|
||||
|
||||
// 알림이 닫히는 시간 — 단추가 있는 알림은 10초, 마우스 · 초점이 안에 있는 동안은 닫지 않는다 (WCAG 2.2.1)
|
||||
|
||||
@@ -121,4 +121,73 @@ describe('toast.store', () => {
|
||||
expect(run).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('진행 알림 — 오래 걸리는 메일함 처리 (디자인 원본 MailActions4)', () => {
|
||||
it('끝날 때까지 남고, 그 자리에서 문구를 바꾸다가 결과로 바뀌면 그때부터 시간을 센다', () => {
|
||||
const store = useToastStore()
|
||||
const id = store.show({ kind: 'progress', message: '받은편지함의 메일을 읽음으로 표시하는 중 · 0 / 1,234통' })
|
||||
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(store.update(id, { message: '받은편지함의 메일을 읽음으로 표시하는 중 · 600 / 1,234통' })).toBe(true)
|
||||
expect(messages()).toEqual(['받은편지함의 메일을 읽음으로 표시하는 중 · 600 / 1,234통'])
|
||||
|
||||
store.update(id, { kind: 'success', message: '받은편지함의 메일 1,234통을 읽음으로 표시했습니다.' })
|
||||
expect(store.toasts.map((toast) => [toast.id, toast.kind])).toEqual([[id, 'success']])
|
||||
vi.advanceTimersByTime(TOAST_DURATION_MS.success - 1)
|
||||
expect(messages()).toHaveLength(1)
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(messages()).toEqual([])
|
||||
expect(store.update(id, { message: '이미 닫힌 알림' })).toBe(false)
|
||||
})
|
||||
|
||||
it('알림이 넘치면 진행 알림이 아닌 오래된 알림부터 닫는다', () => {
|
||||
const store = useToastStore()
|
||||
store.show({ kind: 'progress', message: '휴지통을 비우는 중' })
|
||||
store.show({ kind: 'success', message: '하나' })
|
||||
store.show({ kind: 'success', message: '둘' })
|
||||
store.show({ kind: 'success', message: '셋' })
|
||||
|
||||
expect(messages()).toHaveLength(MAX_VISIBLE_TOASTS)
|
||||
expect(messages()).toEqual(['휴지통을 비우는 중', '둘', '셋'])
|
||||
})
|
||||
|
||||
it('붙잡은 채 결과로 바뀌면 놓을 때부터 시간을 센다', () => {
|
||||
const store = useToastStore()
|
||||
const id = store.show({ kind: 'progress', message: '비우는 중' })
|
||||
store.hold(id, 'pointer')
|
||||
store.update(id, { kind: 'success', message: '비웠습니다' })
|
||||
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(messages()).toEqual(['비웠습니다'])
|
||||
store.release(id, 'pointer')
|
||||
vi.advanceTimersByTime(TOAST_DURATION_MS.success)
|
||||
expect(messages()).toEqual([])
|
||||
})
|
||||
|
||||
it('세션이 바뀌면 진행 알림도 닫는다 — useToast 의 진행 알림은 그 뒤로 바뀌지 않는다', async () => {
|
||||
useAuthStore().startSession({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
const progress = useToast().progress('메일함을 지우는 중')
|
||||
expect(messages()).toEqual(['메일함을 지우는 중'])
|
||||
|
||||
useAuthStore().clearAuth()
|
||||
await nextTick()
|
||||
expect(messages()).toEqual([])
|
||||
progress.succeed('지웠습니다')
|
||||
expect(messages()).toEqual([])
|
||||
})
|
||||
|
||||
it('useToast 의 진행 알림 — 끝나면 성공 · 실패로 바꾸고, 그사이 닫혔으면 결과를 새로 띄운다', () => {
|
||||
const toast = useToast()
|
||||
const store = useToastStore()
|
||||
const done = toast.progress('읽음으로 표시하는 중')
|
||||
done.update('읽음으로 표시하는 중 · 500 / 600통')
|
||||
done.succeed('600통을 읽음으로 표시했습니다.')
|
||||
expect(store.toasts.map((item) => [item.kind, item.message])).toEqual([['success', '600통을 읽음으로 표시했습니다.']])
|
||||
|
||||
const failed = toast.progress('비우는 중')
|
||||
store.dismiss(store.toasts[1]?.id ?? -1)
|
||||
failed.fail('21통 가운데 10통까지 처리했습니다 — 다시 시도해 주세요.')
|
||||
expect(store.toasts.at(-1)).toMatchObject({ kind: 'error', message: '21통 가운데 10통까지 처리했습니다 — 다시 시도해 주세요.' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,10 +8,14 @@ import { useAuthStore } from '@/stores/auth.store'
|
||||
// 오류 알림은 useApi 의 showErrorUI() 만 띄운다 (같은 코드 3초 중복 억제는 그쪽 책임).
|
||||
// 마우스가 올라가 있거나 초점이 알림 안에 있는 동안은 닫지 않는다(hold · release) — 다 떠나면 그 알림의 시간을 처음부터 다시 센다
|
||||
// (WCAG 2.2.1 시간 조절 — 단추를 누르려는 사람이 시간에 쫓기지 않게).
|
||||
// 세션이 바뀌면(로그아웃 · 세션 만료 · 다른 계정 로그인) 단추가 있는 알림은 닫는다 — 이전 세션의 되돌리기 등을 누를 수 없게
|
||||
// 진행 알림(progress — 오래 걸리는 메일함 처리 "…하는 중 · 600 / 1,234통")은 끝날 때까지 남는다: 저절로 닫히지 않고,
|
||||
// 알림이 넘쳐도 다른 알림부터 닫는다. 끝나면 같은 알림을 결과(성공 · 오류)로 바꾼다(update) — 그때부터 시간을 센다.
|
||||
// 세션이 바뀌면(로그아웃 · 세션 만료 · 다른 계정 로그인) 단추가 있는 알림과 진행 알림은 닫는다 — 이전 세션의 되돌리기 등을 누를 수 없게
|
||||
// (단추 자체도 만든 세션에서만 움직인다 — useToast).
|
||||
|
||||
export type ToastKind = 'error' | 'success'
|
||||
export type ToastKind = 'error' | 'success' | 'progress'
|
||||
/** 정해진 시간 뒤에 닫히는 알림 */
|
||||
export type TimedToastKind = Exclude<ToastKind, 'progress'>
|
||||
|
||||
/** 알림의 단추 — 다른 화면으로 가는 링크(to) 또는 그 자리에서 하는 일(run, 예: 되돌리기). 누르면 알림을 닫는다 */
|
||||
export type ToastAction = { label: string; to: RouteLocationRaw } | { label: string; run: () => void }
|
||||
@@ -33,11 +37,11 @@ export interface ToastInput {
|
||||
action?: ToastAction | null
|
||||
}
|
||||
|
||||
/** 동시에 보이는 최대 개수 — 넘치면 오래된 것부터 닫는다 */
|
||||
/** 동시에 보이는 최대 개수 — 넘치면 오래된 것부터 닫는다 (진행 알림은 뒤로 미룬다) */
|
||||
export const MAX_VISIBLE_TOASTS = 3
|
||||
|
||||
/** 자동으로 닫히기까지의 시간 — 오류는 문의 코드를 옮겨 적을 수 있게 더 오래 둔다 */
|
||||
export const TOAST_DURATION_MS: Readonly<Record<ToastKind, number>> = {
|
||||
/** 자동으로 닫히기까지의 시간 — 오류는 문의 코드를 옮겨 적을 수 있게 더 오래 둔다. 진행 알림은 끝날 때까지 남는다 */
|
||||
export const TOAST_DURATION_MS: Readonly<Record<TimedToastKind, number>> = {
|
||||
error: 8000,
|
||||
success: 5000,
|
||||
}
|
||||
@@ -48,10 +52,32 @@ export const TOAST_ACTION_DURATION_MS = 10_000
|
||||
/** 알림을 붙잡아 두는 까닭 — 마우스가 올라가 있다 · 초점이 안에 있다 */
|
||||
export type ToastHoldReason = 'pointer' | 'focus'
|
||||
|
||||
/** 닫히기까지의 시간 — 진행 알림은 없다(null) */
|
||||
function durationOf(toast: Pick<Toast, 'kind' | 'action'>): number | null {
|
||||
if (toast.kind === 'progress') return null
|
||||
return toast.action ? TOAST_ACTION_DURATION_MS : TOAST_DURATION_MS[toast.kind]
|
||||
}
|
||||
|
||||
/** 넘친 만큼 닫을 알림 — 오래된 것부터, 진행 중인 알림은 다른 알림이 모자랄 때만. 방금 띄운 것은 닫지 않는다 */
|
||||
function overflowOf(all: readonly Toast[], keepId: number): Set<number> {
|
||||
let excess = all.length - MAX_VISIBLE_TOASTS
|
||||
const evicted = new Set<number>()
|
||||
for (const preferProgress of [false, true]) {
|
||||
for (const toast of all) {
|
||||
if (excess <= 0) return evicted
|
||||
if (toast.id === keepId || evicted.has(toast.id)) continue
|
||||
if (!preferProgress && toast.kind === 'progress') continue
|
||||
evicted.add(toast.id)
|
||||
excess -= 1
|
||||
}
|
||||
}
|
||||
return evicted
|
||||
}
|
||||
|
||||
export const useToastStore = defineStore('toast', () => {
|
||||
const toasts = ref<Toast[]>([])
|
||||
const timers = new Map<number, ReturnType<typeof setTimeout>>()
|
||||
/** 알림마다 닫히기까지의 시간 — 붙잡았다 놓으면 이 시간을 다시 센다 */
|
||||
/** 알림마다 닫히기까지의 시간 — 붙잡았다 놓으면 이 시간을 다시 센다 (진행 알림은 없다) */
|
||||
const durations = new Map<number, number>()
|
||||
const holds = new Map<number, Set<ToastHoldReason>>()
|
||||
let nextId = 1
|
||||
@@ -84,9 +110,24 @@ export const useToastStore = defineStore('toast', () => {
|
||||
toasts.value = toasts.value.filter((toast) => toast.id !== id)
|
||||
}
|
||||
|
||||
/** 붙잡는다 — 마우스가 올라왔다 · 초점이 들어왔다. 그동안은 닫지 않는다 */
|
||||
/** 닫히기까지의 시간을 정한다 — 붙잡혀 있지 않으면 바로 센다 (진행 알림은 시간이 없다) */
|
||||
function setDuration(id: number, toast: Pick<Toast, 'kind' | 'action'>) {
|
||||
const duration = durationOf(toast)
|
||||
stopTimer(id)
|
||||
if (duration === null) {
|
||||
durations.delete(id)
|
||||
return
|
||||
}
|
||||
durations.set(id, duration)
|
||||
if (!holds.has(id)) startTimer(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 붙잡는다 — 마우스가 올라왔다 · 초점이 들어왔다. 그동안은 닫지 않는다.
|
||||
* 진행 알림도 기억해 둔다 — 붙잡은 채 결과로 바뀌면 놓을 때부터 시간을 센다
|
||||
*/
|
||||
function hold(id: number, reason: ToastHoldReason) {
|
||||
if (!durations.has(id)) return
|
||||
if (!toasts.value.some((toast) => toast.id === id)) return
|
||||
const reasons = holds.get(id) ?? new Set<ToastHoldReason>()
|
||||
reasons.add(reason)
|
||||
holds.set(id, reasons)
|
||||
@@ -113,15 +154,34 @@ export const useToastStore = defineStore('toast', () => {
|
||||
action: input.action ?? null,
|
||||
}
|
||||
const all = [...toasts.value, next]
|
||||
const overflow = all.slice(0, Math.max(0, all.length - MAX_VISIBLE_TOASTS))
|
||||
overflow.forEach((toast) => forget(toast.id))
|
||||
toasts.value = all.slice(-MAX_VISIBLE_TOASTS)
|
||||
const evicted = overflowOf(all, id)
|
||||
evicted.forEach((toastId) => forget(toastId))
|
||||
toasts.value = all.filter((toast) => !evicted.has(toast.id))
|
||||
|
||||
durations.set(id, next.action ? TOAST_ACTION_DURATION_MS : TOAST_DURATION_MS[input.kind])
|
||||
startTimer(id)
|
||||
setDuration(id, next)
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* 띄운 알림을 그 자리에서 바꾼다 — 진행 문구를 고치거나, 끝난 진행 알림을 결과(성공 · 오류)로 바꾼다.
|
||||
* 종류 · 단추가 바뀌면 그에 맞는 시간을 처음부터 센다 (진행 알림이 되면 저절로 닫히지 않는다).
|
||||
* 이미 닫힌 알림이면 false — 부르는 쪽이 새로 띄울지 정한다
|
||||
*/
|
||||
function update(id: number, patch: Partial<Omit<ToastInput, 'kind'>> & { kind?: ToastKind }): boolean {
|
||||
const current = toasts.value.find((toast) => toast.id === id)
|
||||
if (!current) return false
|
||||
const next: Toast = {
|
||||
...current,
|
||||
kind: patch.kind ?? current.kind,
|
||||
message: patch.message ?? current.message,
|
||||
traceId: patch.traceId === undefined ? current.traceId : patch.traceId,
|
||||
action: patch.action === undefined ? current.action : patch.action,
|
||||
}
|
||||
toasts.value = toasts.value.map((toast) => (toast.id === id ? next : toast))
|
||||
if (next.kind !== current.kind || next.action !== current.action) setDuration(id, next)
|
||||
return true
|
||||
}
|
||||
|
||||
function clear() {
|
||||
timers.forEach((timer) => clearTimeout(timer))
|
||||
timers.clear()
|
||||
@@ -130,13 +190,15 @@ export const useToastStore = defineStore('toast', () => {
|
||||
toasts.value = []
|
||||
}
|
||||
|
||||
/** 단추가 있는 알림을 닫는다 — 세션이 바뀌었을 때 */
|
||||
function dismissActions() {
|
||||
toasts.value.filter((toast) => toast.action !== null).forEach((toast) => dismiss(toast.id))
|
||||
/** 세션에 묶인 알림을 닫는다 — 단추가 있는 알림 · 진행 알림 (세션이 바뀌었을 때) */
|
||||
function dismissSessionBound() {
|
||||
toasts.value
|
||||
.filter((toast) => toast.action !== null || toast.kind === 'progress')
|
||||
.forEach((toast) => dismiss(toast.id))
|
||||
}
|
||||
|
||||
const authStore = useAuthStore()
|
||||
watch(() => authStore.generation, dismissActions)
|
||||
watch(() => authStore.generation, dismissSessionBound)
|
||||
|
||||
return { toasts, show, dismiss, hold, release, clear }
|
||||
return { toasts, show, update, dismiss, hold, release, clear }
|
||||
})
|
||||
|
||||
@@ -92,6 +92,78 @@ a:hover {
|
||||
box-shadow: var(--shadow-segment);
|
||||
}
|
||||
|
||||
/*
|
||||
* 테두리 있는 입력칸 — 설정 화면 · 메일함 이름 대화상자 (디자인 원본 SettingsAccount · SettingsVacation · MailboxManage 의 .field · .pw-box)
|
||||
* 높이 44 · 테두리 #D0D5DD. 초점은 강조색 1px 테두리 + 1px 그림자(상단 검색 칸과 같다), 오류는 빨간 테두리, 꺼진 칸은 흐리게.
|
||||
* 칸 안에 단추가 있는 상자(비밀번호 보기)는 상자에 .form-input 을, 안의 입력 요소에 .form-input__control 을 붙인다 —
|
||||
* 입력 요소에 초점이 있을 때만 상자에 초점을 보인다 (안의 단추에 초점이 가면 단추 고리만 보인다).
|
||||
* 오류 상태는 입력 요소의 aria-invalid="true" (상자는 .is-invalid) 로 그린다.
|
||||
*/
|
||||
.form-input {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-12);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-input:has(.form-input__control:focus) {
|
||||
outline: none;
|
||||
border-color: var(--color-brand);
|
||||
box-shadow: 0 0 0 var(--border-width) var(--color-brand);
|
||||
}
|
||||
|
||||
.form-input[aria-invalid='true'],
|
||||
.form-input.is-invalid {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.form-input[aria-invalid='true']:focus,
|
||||
.form-input.is-invalid:has(.form-input__control:focus) {
|
||||
border-color: var(--color-danger);
|
||||
box-shadow: 0 0 0 var(--border-width) var(--color-danger);
|
||||
}
|
||||
|
||||
.form-input:disabled {
|
||||
background: var(--color-surface-panel);
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.form-input__control {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0 var(--space-12);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.form-input__control:focus,
|
||||
.form-input__control:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 칸 아래 안내 · 오류 (12.5px) — 오류는 빨간 글 + alert-circle 아이콘 (디자인 원본 SettingsAccount) */
|
||||
.form-message {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-6);
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.form-message--error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* 아이콘을 첫 줄 가운데에 맞춘다 */
|
||||
.form-message > .app-icon {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* 화면에는 숨기고 스크린리더에는 읽힌다 (안 읽음·준비 중 같은 상태 전달용) */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
|
||||
@@ -148,6 +148,15 @@
|
||||
--size-search-clear-height: 2.375rem; /* 38px — 검색 칸(40) 안쪽 높이를 채운다 · 밀도 예외 */
|
||||
--size-notice-bar: 2.5rem; /* 40px — "새 메일 N통 · 맨 위로" 줄 높이 · 밀도 예외 */
|
||||
--size-checkbox: 1rem; /* 16px — 고르기 상자 */
|
||||
/*
|
||||
* 4단계(계정 · 메일 설정) 밀도 예외 — 클릭 영역 44px 기준의 의도된 예외 (디자인 README "4단계 › 메일함 관리", 사용자 확정 캔버스).
|
||||
* 메일함 행의 ⋯ 단추 32×40(행 높이 --size-row-min — 목록 표의 별 칸과 같은 폭) · ⋯ 메뉴 항목 40(--size-editor-menu-item)
|
||||
*/
|
||||
--size-mailbox-more: 2rem; /* 32px — 메일함 행의 ⋯ 단추 폭 · 밀도 예외 */
|
||||
--size-option-input: 1.125rem; /* 18px — 설정 화면의 고르기 상자 · 라디오 (줄 전체가 44px 로 눌린다) */
|
||||
--form-max-width: 26.25rem; /* 420px — 비밀번호 칸 (디자인 원본 SettingsAccount) */
|
||||
--form-wide-max-width: 32.5rem; /* 520px — 자동 응답 제목 칸 (디자인 원본 SettingsVacation) */
|
||||
--note-max-width: 38.75rem; /* 620px — 설정 화면의 안내 상자 */
|
||||
--size-rail: 4.5rem; /* 72px — 앱 레일 폭 */
|
||||
--size-rail-item: 3.75rem; /* 60px */
|
||||
--size-row-min: 2.5rem; /* 40px — 메일함 항목 */
|
||||
|
||||
@@ -35,13 +35,25 @@ export function createTestRouter(): Router {
|
||||
component: Empty,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{ path: '/settings', name: 'settings', redirect: { name: 'settings-mail-signature' } },
|
||||
{ path: '/settings', name: 'settings', redirect: { name: 'settings-account-password' } },
|
||||
{
|
||||
path: '/settings/account/password',
|
||||
name: 'settings-account-password',
|
||||
component: Empty,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/settings/mail/signature',
|
||||
name: 'settings-mail-signature',
|
||||
component: Empty,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/settings/mail/vacation',
|
||||
name: 'settings-mail-vacation',
|
||||
component: Empty,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user