generated from ttipo/project-base
feat(frontend): 새 메일 확인·메일 검색·여러 통 선택과 처리 화면
- 화면이 보이는 동안 1분마다 바뀐 것을 묻고 목록·메일함 숫자·탭 제목 "(N)" 을 맞춘다 — 늦게 온 응답은 목록 변경 세대로 버린다 - 상단 검색 칸 → /mail/search (범위 전환, 결과 없음 안내에 엔진 한계) - 표·분할 보기에서 여러 통 고르기와 도구 줄, 이동 메뉴, 휴지통 이동 + 되돌리기, 휴지통에서만 확인 뒤 영구 삭제 - 읽기 화면의 삭제도 DELETE 대신 휴지통 이동 · 확인 뒤 영구 삭제 — 다른 창이 휴지통에 넣은 메일을 확인 없이 지우지 않게 - 되돌리기 알림은 띄운 세션에서만 움직인다 — 메일 id 는 계정마다 겹칠 수 있다 - 메일 스토어를 기능별 setup 모듈로 나눴다 (공개 API 그대로) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
// 메일 모듈 레이아웃 — 전역 셸 + 메일함 패널 + 하위 화면(목록·본문 / 쓰기)
|
||||
// 메일 모듈 레이아웃 — 전역 셸 + 메일함 패널 + 하위 화면(목록·본문 / 검색 결과 / 쓰기)
|
||||
// 패널은 하위 화면이 바뀌어도 다시 그리지 않는다.
|
||||
// 메일 화면에 있는 동안: 상단 검색 칸은 "메일 검색", 1분마다 새 메일 확인, 탭 제목 앞에 받은편지함의 안 읽은 수 (디자인 README "3단계")
|
||||
import AppShell from '@/components/shell/AppShell.vue'
|
||||
import MailSearchBox from '@/components/mail/MailSearchBox.vue'
|
||||
import MailboxPanel from '@/components/mail/MailboxPanel.vue'
|
||||
import { useClock } from '@/composables/useClock'
|
||||
import { useMailStatePolling } from '@/composables/useMailStatePolling'
|
||||
import { useMailTabTitle } from '@/composables/useMailTabTitle'
|
||||
import { useMailSignatureStore } from '@/stores/mail-signature.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
@@ -14,10 +19,23 @@ void mailStore.ensureMailboxes()
|
||||
void mailStore.ensureQuota()
|
||||
// 쓰기 화면이 열 때 넣을 서명 — 미리 받아 두면 편집기가 기다리지 않는다 (못 받으면 서명 없이 연다)
|
||||
void useMailSignatureStore().ensureSignature()
|
||||
|
||||
// 새 메일 확인 — 바뀌었으면 메일함 개수 · 사용량과 보이는 메일함 목록을 맞춘다 (검색 결과는 저절로 바꾸지 않는다 — 스토어가 거른다)
|
||||
useMailStatePolling({
|
||||
clock: useClock(),
|
||||
onChange: () => {
|
||||
mailStore.syncCounts()
|
||||
void mailStore.syncVisibleList()
|
||||
},
|
||||
})
|
||||
useMailTabTitle()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell module-id="mail">
|
||||
<template #search>
|
||||
<MailSearchBox />
|
||||
</template>
|
||||
<MailboxPanel />
|
||||
<RouterView />
|
||||
</AppShell>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { mailViewModeStorageKey } from '@/stores/mail-view.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MailboxPage from './MailboxPage.vue'
|
||||
|
||||
// 검색 결과 화면 (디자인 원본 Search · ListStates · 계약 2절 "검색")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
const Q = encodeURIComponent('회의')
|
||||
|
||||
function mailbox(id: string, role: Mailbox['role'], name: string): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 5, unreadCount: 0 }
|
||||
}
|
||||
|
||||
const MAILBOXES = [mailbox('inbox', 'inbox', '받은편지함'), mailbox('sent', 'sent', '보낸편지함')]
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [{ name: '김아라', email: USER.email }],
|
||||
subject: `주간 회의 ${id}`,
|
||||
preview: '',
|
||||
receivedAt: '2026-10-27T01:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function detail(id: string): MessageDetail {
|
||||
return {
|
||||
...summary(id),
|
||||
cc: [],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
sentAt: null,
|
||||
messageId: null,
|
||||
references: [],
|
||||
htmlBody: null,
|
||||
textBody: '본문',
|
||||
hasBlockedImages: false,
|
||||
attachments: [],
|
||||
}
|
||||
}
|
||||
|
||||
describe('MailboxPage — 검색 결과', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
let results: MessageSummary[]
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
useMailStore().mailboxes = MAILBOXES
|
||||
results = [summary('r1'), summary('r2')]
|
||||
requests = installMockApi(({ url }) => {
|
||||
if (url === '/messages') return ok({ items: results, pageInfo: { hasNext: false, nextCursor: null, total: results.length } })
|
||||
return ok(detail(url.split('/').pop() ?? 'r1'))
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function mountAt(path: string) {
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MailboxPage, { global: { plugins: [pinia, router] } })
|
||||
await flushPromises()
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
const listRequests = () => requests.filter((request) => request.url === '/messages')
|
||||
|
||||
it('주소의 검색어 · 범위로 찾는다 — 머리 "검색 결과" · "‘회의’ · 2통", 범위 줄 [받은편지함 | 전체 메일함]', async () => {
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
|
||||
|
||||
expect(listRequests()[0]?.params).toEqual({ q: '회의', mailboxId: 'inbox', limit: 20 })
|
||||
expect(wrapper.get('#message-list-title').text()).toBe('검색 결과')
|
||||
expect(wrapper.get('.search-header__count').text()).toBe('‘회의’ · 2통')
|
||||
expect(wrapper.get('.search-header__close').attributes('aria-label')).toBe('검색 닫기')
|
||||
const scope = wrapper.findAll('.search-scope .segmented__item')
|
||||
expect(scope.map((button) => [button.text(), button.attributes('aria-pressed')])).toEqual([
|
||||
['받은편지함', 'true'],
|
||||
['전체 메일함', 'false'],
|
||||
])
|
||||
expect(wrapper.find('.search-scope__hint').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.message-row')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('결과가 없으면 한계 안내 한 줄과 [전체 메일함에서 찾기] — 누르면 전체에서 다시 찾는다(기록을 쌓지 않는다)', async () => {
|
||||
results = []
|
||||
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
|
||||
const push = vi.spyOn(router, 'push')
|
||||
|
||||
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
|
||||
expect(wrapper.text()).toContain(
|
||||
'낱말 단위로 찾습니다. 붙여 쓴 말의 일부(‘서버이전’의 ‘서버’)나 제목 맨 앞의 말머리([공지] · Re:)로는 찾지 못합니다.',
|
||||
)
|
||||
await wrapper.get('.search-empty__all').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=all`)
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
expect(listRequests().at(-1)?.params).toEqual({ q: '회의', limit: 20 })
|
||||
// 전체에서 찾았으면 더 넓힐 곳이 없다 — 안내 줄만
|
||||
expect(wrapper.find('.search-empty__all').exists()).toBe(false)
|
||||
expect(wrapper.get('.search-scope__hint').text()).toBe('휴지통 · 스팸메일함은 빼고 찾습니다')
|
||||
})
|
||||
|
||||
it('범위 줄에서 "전체 메일함"을 누르면 기록을 쌓지 않고 전체에서 다시 찾고, "받은편지함"을 누르면 돌아온다', async () => {
|
||||
useMailStore().rememberSearchOrigin({ mailboxId: 'inbox', location: '/mail/inbox' })
|
||||
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
|
||||
const push = vi.spyOn(router, 'push')
|
||||
const scopeButton = (label: string) =>
|
||||
wrapper.findAll('.search-scope .segmented__item').find((button) => button.text() === label)
|
||||
|
||||
await scopeButton('전체 메일함')?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=all`)
|
||||
expect(listRequests().at(-1)?.params).toEqual({ q: '회의', limit: 20 })
|
||||
expect(scopeButton('전체 메일함')?.attributes('aria-pressed')).toBe('true')
|
||||
// 메일함 패널은 전체 메일함에서 찾는 동안 현재 메일함을 표시하지 않는다
|
||||
expect(useMailStore().currentMailboxId).toBeNull()
|
||||
|
||||
await scopeButton('받은편지함')?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
|
||||
expect(listRequests()).toHaveLength(3)
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('결과에서 연 메일은 /mail/search/<id>?… — 다시 찾지 않고, 읽기 화면에 "메일함 받은편지함" 을 더한다', async () => {
|
||||
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
const row = wrapper.findAll('.message-row__main')[1]
|
||||
expect(row?.attributes('href')).toBe(`/mail/search/r2?q=${Q}&in=all`)
|
||||
|
||||
await router.push(row?.attributes('href') ?? '')
|
||||
await flushPromises()
|
||||
|
||||
expect(listRequests()).toHaveLength(1)
|
||||
expect(wrapper.get('#reader-subject').text()).toBe('주간 회의 r2')
|
||||
expect(wrapper.get('.reader__mailbox').text()).toBe('메일함 받은편지함')
|
||||
expect(wrapper.findAll('.message-row')[1]?.classes()).toContain('is-selected')
|
||||
})
|
||||
|
||||
it('검색 닫기(←)는 검색을 시작한 화면으로 돌아간다', async () => {
|
||||
useMailStore().rememberSearchOrigin({ mailboxId: 'sent', location: '/mail/sent' })
|
||||
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=sent`)
|
||||
|
||||
expect(wrapper.get('.search-header__close').attributes('title')).toBe('검색 닫기 — 보낸편지함으로')
|
||||
await wrapper.get('.search-header__close').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
|
||||
expect(useMailStore().search).toBeNull()
|
||||
})
|
||||
|
||||
it('검색어가 없는 주소(/mail/search)는 검색을 닫은 것으로 본다', async () => {
|
||||
const { router } = await mountAt('/mail/search')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(listRequests().some((request) => request.params.q !== undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('목록 보기 — 검색 결과도 표로, 전체 메일함이면 메일함 칸이 생긴다', async () => {
|
||||
window.localStorage.setItem(mailViewModeStorageKey(USER.email), 'list')
|
||||
results = [summary('r1'), summary('r2', { mailboxIds: ['sent'] })]
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
|
||||
expect(wrapper.find('table.mail-table').exists()).toBe(true)
|
||||
expect(wrapper.get('#message-table-title').text()).toBe('검색 결과')
|
||||
expect(wrapper.findAll('tbody .mail-table__mailbox').map((cell) => cell.text())).toEqual([
|
||||
'받은편지함',
|
||||
'보낸편지함',
|
||||
])
|
||||
})
|
||||
|
||||
it('검색하면 고른 것을 푼다', async () => {
|
||||
const store = useMailStore()
|
||||
const { router } = await mountAt('/mail/inbox')
|
||||
store.toggleCheck('r1')
|
||||
|
||||
await router.push(`/mail/search?q=${Q}&in=inbox`)
|
||||
await flushPromises()
|
||||
|
||||
expect(store.checkedCount).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 화면 (/mail · /mail/:mailboxId · /mail/:mailboxId/:messageId)
|
||||
// 화면 상태는 주소가 기준이다: 주소의 메일함·메일이 바뀌면 스토어가 그에 맞춰 불러온다.
|
||||
// 메일함 · 검색 결과 화면 (/mail · /mail/:mailboxId · /mail/:mailboxId/:messageId · /mail/search?q=…&in=… · /mail/search/:messageId?…)
|
||||
// 화면 상태는 주소가 기준이다: 주소의 메일함·검색·메일이 바뀌면 스토어가 그에 맞춰 불러온다.
|
||||
// 보기 방식(사용자별 기억)에 따라 같은 주소를 다르게 그린다:
|
||||
// - 분할: 목록(2줄 행) + 본문 (디자인 원본 Main)
|
||||
// - 분할: 목록(2줄 행) + 본문 (디자인 원본 Main · Search)
|
||||
// - 목록: 메일을 열지 않았으면 게시판형 표(MainList), 열었으면 표 자리에 본문을 넓게(MainListRead)
|
||||
// 검색 결과는 메일함 목록과 같은 목록 · 표 · 본문을 쓴다 — 목록의 출처만 다르다 (mail-list-source.ts)
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
@@ -13,6 +14,7 @@ import MessageList from '@/components/mail/MessageList.vue'
|
||||
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
|
||||
import MessageReader from '@/components/mail/MessageReader.vue'
|
||||
import MessageTable from '@/components/mail/MessageTable.vue'
|
||||
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailViewStore } from '@/stores/mail-view.store'
|
||||
|
||||
@@ -20,14 +22,17 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const viewStore = useMailViewStore()
|
||||
const { isSearching, routeSearch, closeSearch } = useMailSearchNavigation()
|
||||
|
||||
function paramOf(value: string | string[] | undefined): string | null {
|
||||
return typeof value === 'string' && value !== '' ? value : null
|
||||
}
|
||||
|
||||
const mailboxId = computed(() => paramOf(route.params.mailboxId))
|
||||
const mailboxId = computed(() => (isSearching.value ? null : paramOf(route.params.mailboxId)))
|
||||
const messageId = computed(() => paramOf(route.params.messageId))
|
||||
const isListView = computed(() => viewStore.mode === 'list')
|
||||
/** 목록이 있는 화면 — 메일함 또는 검색 결과 */
|
||||
const hasList = computed(() => Boolean(mailboxId.value) || isSearching.value)
|
||||
|
||||
watch(
|
||||
mailboxId,
|
||||
@@ -37,6 +42,21 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 검색어 · 범위가 바뀔 때만 다시 찾는다 — 결과에서 메일을 열고 닫는 것으로는 다시 찾지 않는다.
|
||||
// 검색어가 없는 주소(/mail/search)는 검색을 닫은 것으로 본다
|
||||
watch(
|
||||
[isSearching, () => routeSearch.value?.q ?? null, () => routeSearch.value?.scope ?? null],
|
||||
([searching, q, scope]) => {
|
||||
if (!searching) return
|
||||
if (q === null || scope === null) {
|
||||
void closeSearch()
|
||||
return
|
||||
}
|
||||
void mailStore.openSearch(q, scope)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
messageId,
|
||||
(id) => {
|
||||
@@ -46,20 +66,20 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 쓰기를 마치거나 취소하면 이 화면으로 돌아온다 (보던 메일함·메일)
|
||||
// 쓰기를 마치거나 취소하면 이 화면으로 돌아온다 (보던 메일함·메일·검색 결과)
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
(fullPath) => {
|
||||
if (mailboxId.value) mailStore.rememberBrowseLocation(fullPath)
|
||||
if (hasList.value) mailStore.rememberBrowseLocation(fullPath)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// /mail 로 들어오면 메일함을 불러온 뒤 받은편지함으로 보낸다
|
||||
watch(
|
||||
[mailboxId, () => mailStore.defaultMailbox],
|
||||
([id, fallback]) => {
|
||||
if (!id && fallback) {
|
||||
[hasList, () => mailStore.defaultMailbox],
|
||||
([listed, fallback]) => {
|
||||
if (!listed && fallback) {
|
||||
void router.replace({ name: 'mail-mailbox', params: { mailboxId: fallback.id } })
|
||||
}
|
||||
},
|
||||
@@ -69,26 +89,20 @@ watch(
|
||||
|
||||
<template>
|
||||
<main :class="['mail-view', { 'mail-view--list': isListView }]">
|
||||
<template v-if="mailboxId && isListView">
|
||||
<template v-if="hasList && isListView">
|
||||
<MessageReader
|
||||
v-if="messageId"
|
||||
layout="list"
|
||||
>
|
||||
<template #toolbar-start>
|
||||
<ListReadNav
|
||||
:mailbox-id="mailboxId"
|
||||
:message-id="messageId"
|
||||
/>
|
||||
<ListReadNav :message-id="messageId" />
|
||||
</template>
|
||||
</MessageReader>
|
||||
<MessageTable
|
||||
v-else
|
||||
:mailbox-id="mailboxId"
|
||||
/>
|
||||
<MessageTable v-else />
|
||||
</template>
|
||||
|
||||
<template v-else-if="mailboxId">
|
||||
<MessageList :mailbox-id="mailboxId" />
|
||||
<template v-else-if="hasList">
|
||||
<MessageList />
|
||||
<MessageReader />
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user