generated from ttipo/project-base
feat(frontend): 검색에 조건 · 강조 · 준비 중 안내를 더한다 (14단계 화면 1차)
- [조건] 패널(보낸 사람 · 받는 사람 · 제목 · 내용 · 기간 · 첨부)과 결과 머리의 조건 줄 — 조건도 화면 주소에 싣는다(since · until 은 브라우저 시간대 날짜 · 양 끝 포함) - 결과 줄 세 줄(분할 보기)과 맞은 말 노랑 강조 — HighlightedText 가 글 조각으로 그린다(v-html 없음), 읽기 화면은 서버가 넣은 <mark> · 제목 · 첨부 이름 강조. 강조가 든 응답은 보이기만 한다 (답장 · 전달 · 인쇄 · 예약은 강조 없이 받는다) - "검색 준비 중" 줄 · 한 글자 안내 · 빈 쪽이면 이어 찾기(한 번에 5번까지) + [더 찾기], total 이 null 이면 통 수를 숨긴다 - search-match.ts — backend search-query · search-text · subject-base 와 짝(같은 표본 표) 리뷰 반영: 이어 받는 동안 [다음] · [메일 더 보기] 기다림, 새로 고치는 동안 이어 받지 않음, 손으로 고친 주소도 패널과 같은 검사(8낱말 · 날짜 범위 · 거꾸로 된 기간), 패널 컴포넌트 나눔, 시험 정리(unmount · 보이지 않는 글자 이스케이프). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
// 검색 강조가 든 글 (디자인 원본 Search14 · README 「14단계 › 검색 · 조건 검색 · 강조」) — 서버가 준 맞은 곳을 <mark>(노랑 · 글자색 그대로)로 그린다.
|
||||
// v-html 없이 조각(search-highlight.ts)으로 그린다. 맞은 곳이 없으면 글 그대로다(엔진 방식 검색 · 메일함 목록).
|
||||
// 뿌리 요소가 없다 — 부르는 쪽의 글 자리(span · h2 · a) 안에 넣는다.
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SearchRange } from '@/shared/types/mail'
|
||||
import { highlightSegments } from '@/shared/utils/search-highlight'
|
||||
|
||||
interface Props {
|
||||
text: string
|
||||
/** 맞은 곳 [시작, 끝) — UTF-16, 서버가 NFC 로 맞춘 글 기준 */
|
||||
ranges?: readonly SearchRange[] | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
ranges: null,
|
||||
})
|
||||
|
||||
const segments = computed(() => highlightSegments(props.text, props.ranges))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template
|
||||
v-for="(segment, index) in segments"
|
||||
:key="index"
|
||||
>
|
||||
<mark
|
||||
v-if="segment.mark"
|
||||
class="search-mark"
|
||||
data-search-mark
|
||||
>{{ segment.text }}</mark>
|
||||
<template v-else>
|
||||
{{ segment.text }}
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 맞은 말 — 노랑 · 글자색 그대로 · 모서리 2 · 좌우 1 (디자인 원본 Search14) */
|
||||
.search-mark {
|
||||
padding: 0 var(--space-1);
|
||||
border-radius: calc(var(--radius-xs) / 2);
|
||||
background: var(--color-search-mark);
|
||||
color: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -158,3 +158,114 @@ describe('ListReadNav (목록 보기에서 연 메일의 이동)', () => {
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?${query}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ListReadNav — 14단계 검색의 빈 쪽', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
it('[다음]은 빈 쪽에 멈추지 않는다 — 다음 메일이 나올 때까지 커서로 이어 묻고 그 메일로 간다', async () => {
|
||||
const requests = installMockApi(({ params }) => {
|
||||
if (params.cursor === 'c2') return ok({ items: [], pageInfo: { hasNext: true, nextCursor: 'c3', total: null } })
|
||||
return ok({ items: [summary('m4')], pageInfo: { hasNext: false, nextCursor: null, total: null } })
|
||||
})
|
||||
const store = useMailStore()
|
||||
store.listSource = { kind: 'search', q: '회의', scope: 'all' }
|
||||
store.messages = [summary('m1'), summary('m2'), summary('m3')]
|
||||
store.pageInfo = { hasNext: true, nextCursor: 'c2', total: null }
|
||||
store.listStatus = 'ready'
|
||||
const query = `q=${encodeURIComponent('회의')}&in=all`
|
||||
const router = createTestRouter()
|
||||
await router.push(`/mail/search?${query}`)
|
||||
await router.push(`/mail/search/m3?${query}`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(ListReadNav, { props: { messageId: 'm3' }, global: { plugins: [pinia, router] } })
|
||||
|
||||
// 전체 수를 모르면 "n / 전체" 를 보이지 않는다
|
||||
expect(wrapper.find('.nav-position').exists()).toBe(false)
|
||||
|
||||
await wrapper.get('button[aria-label="다음 메일"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.filter((request) => request.url === '/messages').map((request) => request.params.cursor)).toEqual([
|
||||
'c2',
|
||||
'c3',
|
||||
])
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search/m4?${query}`)
|
||||
})
|
||||
|
||||
it('뒤에서 결과를 이어 받는 중에 누른 [다음]은 그 일을 기다렸다가 넘어간다 — 기다리는 동안 꺼지고 aria-busy, 새로 묻지 않는다', async () => {
|
||||
let resolveSecond: (value: ReturnType<typeof ok>) => void = () => undefined
|
||||
const second = new Promise<ReturnType<typeof ok>>((done) => {
|
||||
resolveSecond = done
|
||||
})
|
||||
const requests = installMockApi(({ url, params }) => {
|
||||
if (url === '/search-index') return ok({ status: 'ready', done: 1, total: 1 })
|
||||
if (!params.cursor) {
|
||||
return ok({ items: [summary('m1'), summary('m2'), summary('m3')], pageInfo: { hasNext: true, nextCursor: 'c2', total: null } })
|
||||
}
|
||||
return second
|
||||
})
|
||||
const store = useMailStore()
|
||||
const opening = store.openSearch('회의', 'all')
|
||||
await flushPromises()
|
||||
expect(store.isLoadingMore).toBe(true)
|
||||
const query = `q=${encodeURIComponent('회의')}&in=all`
|
||||
const router = createTestRouter()
|
||||
await router.push(`/mail/search?${query}`)
|
||||
await router.push(`/mail/search/m3?${query}`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(ListReadNav, { props: { messageId: 'm3' }, global: { plugins: [pinia, router] } })
|
||||
const next = wrapper.get('button[aria-label="다음 메일"]')
|
||||
// 이어 받는 중이면 끝에 있어도 [다음]을 누를 수 있다
|
||||
expect(next.attributes('disabled')).toBeUndefined()
|
||||
|
||||
await next.trigger('click')
|
||||
await flushPromises()
|
||||
expect(next.attributes('disabled')).toBeDefined()
|
||||
expect(next.attributes('aria-busy')).toBe('true')
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search/m3?${query}`)
|
||||
|
||||
resolveSecond(ok({ items: [summary('m4')], pageInfo: { hasNext: false, nextCursor: null, total: null } }))
|
||||
await opening
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search/m4?${query}`)
|
||||
expect(requests.filter((request) => request.url === '/messages').map((request) => request.params.cursor)).toEqual([
|
||||
undefined,
|
||||
'c2',
|
||||
])
|
||||
})
|
||||
|
||||
it('목록을 보이는 채로 새로 고치는 동안 받아야 할 [다음]은 꺼지고 aria-busy — 끝나면 다시 누를 수 있다', async () => {
|
||||
const store = useMailStore()
|
||||
store.currentMailboxId = 'inbox'
|
||||
store.messages = [summary('m1'), summary('m2'), summary('m3')]
|
||||
store.pageInfo = { hasNext: true, nextCursor: 'c2', total: 5 }
|
||||
store.listStatus = 'ready'
|
||||
store.isRefreshing = true
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail/inbox')
|
||||
await router.push('/mail/inbox/m3')
|
||||
await router.isReady()
|
||||
const wrapper = mount(ListReadNav, { props: { messageId: 'm3' }, global: { plugins: [pinia, router] } })
|
||||
const next = wrapper.get('button[aria-label="다음 메일"]')
|
||||
|
||||
expect(next.attributes('disabled')).toBeDefined()
|
||||
expect(next.attributes('aria-busy')).toBe('true')
|
||||
|
||||
store.isRefreshing = false
|
||||
await flushPromises()
|
||||
expect(next.attributes('disabled')).toBeUndefined()
|
||||
expect(next.attributes('aria-busy')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,21 +34,29 @@ const listHref = computed(() => {
|
||||
})
|
||||
/** 불러온 목록에서의 위치 — 목록 밖의 메일(주소로 바로 연 경우 등)이면 -1 */
|
||||
const index = computed(() => mailStore.messages.findIndex((message) => message.id === props.messageId))
|
||||
/** 전체 — 서버가 준 total, 없으면 메일함 개수(메일함 목록일 때만), 그것도 없으면 불러온 수 */
|
||||
const total = computed(
|
||||
() =>
|
||||
mailStore.pageInfo?.total ??
|
||||
(mailStore.search ? null : mailStore.currentMailbox?.totalCount) ??
|
||||
mailStore.messages.length,
|
||||
)
|
||||
/**
|
||||
* 전체 — 서버가 준 total, 없으면 메일함 개수(메일함 목록일 때만), 그것도 없으면 불러온 수.
|
||||
* 검색 결과의 total 이 null 이면(1,000통 넘음 · 다 세지 못함 — 14단계) 모른다 — 위치 "n / 전체"를 보이지 않는다
|
||||
*/
|
||||
const total = computed<number | null>(() => {
|
||||
const known = mailStore.pageInfo?.total ?? null
|
||||
if (known !== null) return known
|
||||
if (mailStore.search) return null
|
||||
return mailStore.currentMailbox?.totalCount ?? mailStore.messages.length
|
||||
})
|
||||
const position = computed(() =>
|
||||
index.value === -1 ? '' : `${formatCount(index.value + 1)} / ${formatCount(total.value)}`,
|
||||
index.value === -1 || total.value === null ? '' : `${formatCount(index.value + 1)} / ${formatCount(total.value)}`,
|
||||
)
|
||||
const hasPrev = computed(() => index.value > 0)
|
||||
/** 불러온 범위의 끝이어도 서버에 더 있으면(hasNext) 다음으로 갈 수 있다 */
|
||||
/** 불러온 범위의 끝이어도 서버에 더 있거나(hasNext) 뒤에서 이어 받는 중이면 다음으로 갈 수 있다 */
|
||||
const hasNext = computed(
|
||||
() =>
|
||||
index.value !== -1 && (index.value < mailStore.messages.length - 1 || mailStore.hasMore),
|
||||
index.value !== -1 &&
|
||||
(index.value < mailStore.messages.length - 1 || mailStore.hasMore || mailStore.isLoadingMore),
|
||||
)
|
||||
/** 다음 메일을 받아야 하는데 목록을 보이는 채로 새로 고치는 중 — 끝날 때까지 [다음]을 막는다 (옛 커서로 붙이지 않게) */
|
||||
const nextWaiting = computed(
|
||||
() => index.value !== -1 && !mailStore.messages[index.value + 1] && mailStore.isRefreshing,
|
||||
)
|
||||
|
||||
async function goPrev() {
|
||||
@@ -57,13 +65,19 @@ async function goPrev() {
|
||||
if (previous && source) await showSiblingIn(source, previous.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 메일 — 불러온 범위의 끝이면 더 받는다. 검색 결과를 뒤에서 이어 받는 중(14단계)이면 그 일이 끝나기를 기다리고,
|
||||
* 그 안에 다음 메일이 없었으면 이번 누름의 차례로 한 번 더 받는다. 기다리는 동안 단추는 꺼지고 aria-busy
|
||||
*/
|
||||
async function goNext() {
|
||||
if (loadingNext.value || index.value === -1) return
|
||||
if (loadingNext.value || nextWaiting.value || index.value === -1) return
|
||||
const current = index.value
|
||||
if (!mailStore.messages[current + 1] && mailStore.hasMore) {
|
||||
if (!mailStore.messages[current + 1] && (mailStore.hasMore || mailStore.isLoadingMore)) {
|
||||
loadingNext.value = true
|
||||
try {
|
||||
const waited = mailStore.isLoadingMore
|
||||
await mailStore.loadMore()
|
||||
if (waited && !mailStore.messages[current + 1] && mailStore.hasMore) await mailStore.loadMore()
|
||||
} finally {
|
||||
loadingNext.value = false
|
||||
}
|
||||
@@ -115,8 +129,8 @@ function onBackToList(event: MouseEvent) {
|
||||
type="button"
|
||||
class="nav-button"
|
||||
aria-label="다음 메일"
|
||||
:disabled="!hasNext || loadingNext"
|
||||
:aria-busy="loadingNext"
|
||||
:disabled="!hasNext || loadingNext || nextWaiting"
|
||||
:aria-busy="loadingNext || nextWaiting"
|
||||
@click="goNext"
|
||||
>
|
||||
<AppIcon
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
// (<출처>/api/mail-images/ — 'self' 전체가 아니다)를 더하고 정화가 중계 주소의 그림을 남긴다 — sandbox 는 그대로다 (mail-html.ts).
|
||||
// 내용 높이를 잴 때마다 measured 를 알린다 — 인쇄 화면이 다 잰 뒤(그림까지 받은 뒤 — iframe 의 load) 인쇄 창을 띄운다
|
||||
// 대용량 첨부 덩이(11단계)는 foldLargeFiles 면 접어 그린다 — 본문 틀 안의 details 를 열고 닫으면(toggle) 높이를 다시 잰다
|
||||
// 검색 결과에서 연 메일(14단계)은 맞은 말이 노랑으로 보인다 — 서식 본문은 서버가 넣은 <mark>, 글 본문은 textHighlights 의 위치로 (mail-html.ts)
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { SearchRange } from '@/shared/types/mail'
|
||||
import { buildMailDocument } from '@/shared/utils/mail-html'
|
||||
|
||||
interface Props {
|
||||
@@ -29,6 +31,8 @@ interface Props {
|
||||
title?: string
|
||||
/** 대용량 첨부 덩이를 접는다 — 읽기 화면이 첨부 칸에 그 파일을 보일 때 (인쇄 화면은 펼친 채) */
|
||||
foldLargeFiles?: boolean
|
||||
/** 글 본문의 검색에서 맞은 곳 — 서버의 searchHighlight.textBody (14단계 — 글 본문을 보일 때만 쓴다) */
|
||||
textHighlights?: readonly SearchRange[] | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -47,6 +51,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
imagesShown: false,
|
||||
title: '메일 본문',
|
||||
foldLargeFiles: false,
|
||||
textHighlights: null,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
@@ -63,6 +68,7 @@ const srcdoc = computed(() =>
|
||||
textBody: props.textBody,
|
||||
imagesShown: props.imagesShown,
|
||||
foldLargeFiles: props.foldLargeFiles,
|
||||
textHighlights: props.textHighlights,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
// 그 아래 휴지통 · 스팸메일함의 30일 줄(13단계 — 메일이 있을 때만, 없으면 빈 상태 글에 붙인다 · 검색 결과 · 다른 메일함에는 없다),
|
||||
// 바탕화면 알림 권하기(메일함 목록에서만 · 한 번 — MailNotifyOffer, 5단계), "새 메일 N통 · 맨 위로"(useListArrivalNotice)
|
||||
// - 상태: 불러오는 중(뼈대) · 못 불러옴 · 행(slot) + 메일 더 보기 · 빈 메일함 · 검색 결과 없음 · 예약한 메일 없음(9단계 — 예약됨에는 알림 권하기 줄이 없다)
|
||||
// - 검색 결과(14단계 — 디자인 원본 Search14): 찾을 곳 줄 아래 조건 줄(조건이 걸렸을 때) · 안내 줄(검색 준비 중 · 한 글자 안내).
|
||||
// 결과 없음은 더 찾을 것이 없을 때만 — 이어 찾을 것이 남았는데 아직 하나도 못 찾았으면 [더 찾기](MailSearchMore)
|
||||
// - Esc 는 고른 것을 푼다 — 이 칸 안의 키만 받는다 (전역 단축키가 아니다 · 메뉴 · 대화상자 안의 Esc 는 그쪽 것이다)
|
||||
// - 목록 보기는 메일을 열면 이 칸이 사라진다 — 스크롤을 기억해 "목록으로" 돌아오면 되살리고 보던 메일로 초점을 돌려준다
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
@@ -14,8 +16,11 @@ import MailListHeader from '@/components/mail/MailListHeader.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import MailNewNotice from '@/components/mail/MailNewNotice.vue'
|
||||
import MailNotifyOffer from '@/components/mail/MailNotifyOffer.vue'
|
||||
import MailSearchConditionLine from '@/components/mail/MailSearchConditionLine.vue'
|
||||
import MailSearchEmpty from '@/components/mail/MailSearchEmpty.vue'
|
||||
import MailSearchHeader from '@/components/mail/MailSearchHeader.vue'
|
||||
import MailSearchMore from '@/components/mail/MailSearchMore.vue'
|
||||
import MailSearchNotices from '@/components/mail/MailSearchNotices.vue'
|
||||
import MailSearchScope from '@/components/mail/MailSearchScope.vue'
|
||||
import MailSelectionToolbar from '@/components/mail/MailSelectionToolbar.vue'
|
||||
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
|
||||
@@ -26,7 +31,8 @@ import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
|
||||
import { MAIL_SEARCH_TEXT, MAILBOX_RETENTION_NOTICES, SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
import { isScheduledSource } from '@/shared/utils/mail-list-source'
|
||||
import { isScheduledSource, isSearchTooLong } from '@/shared/utils/mail-list-source'
|
||||
import { hasSearchConditions, skipsBodySearch } from '@/shared/utils/mail-search'
|
||||
import { emptyMailboxText, mailboxIcon } from '@/shared/utils/mailbox'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
@@ -71,12 +77,34 @@ const retentionNotice = computed(() => {
|
||||
if (text === null) return null
|
||||
return mailStore.listStatus === 'ready' && mailStore.messages.length === 0 ? null : text
|
||||
})
|
||||
/** 검색 조건 — 조건 줄은 걸렸을 때만 (14단계) */
|
||||
const searchConditions = computed(() =>
|
||||
search.value && hasSearchConditions(search.value.conditions) ? (search.value.conditions ?? null) : null,
|
||||
)
|
||||
/** 검색어와 조건의 낱말이 한도를 넘는 주소 — 찾지 않았다, 결과 없음 자리에 그 까닭을 보인다 (14단계) */
|
||||
const searchTooLong = computed(() => isSearchTooLong(search.value))
|
||||
/** 지난 메일의 색인을 만드는 중 — 결과 머리 아래 "검색 준비 중" 줄 · 결과 없음의 한계 안내 (14단계) */
|
||||
const indexing = computed(() => mailStore.searchIndexStatus === 'building')
|
||||
/** 한 글자 낱말뿐이라 본문을 찾지 않았다 — 색인 방식(준비가 끝남)일 때만 알린다 (14단계) */
|
||||
const singleChar = computed(
|
||||
() =>
|
||||
Boolean(search.value) &&
|
||||
!searchTooLong.value &&
|
||||
mailStore.searchIndexStatus === 'ready' &&
|
||||
skipsBodySearch(search.value?.q ?? '', search.value?.conditions),
|
||||
)
|
||||
/** 더 받는 중 · 보이는 채로 새로 고치는 중 — [메일 더 보기] · [더 찾기]를 막는다 (새로 고치는 동안 옛 커서로 붙이지 않게) */
|
||||
const loadingMore = computed(() => mailStore.isLoadingMore || mailStore.isRefreshing)
|
||||
/** 결과가 아직 없지만 이어 찾을 것이 남았다 — 결과 없음 대신 [더 찾기] */
|
||||
const searchPending = computed(
|
||||
() => Boolean(search.value) && mailStore.listStatus === 'ready' && mailStore.messages.length === 0 && mailStore.hasMore,
|
||||
)
|
||||
/** 없는 주소(RES_001)일 때 — 메일함은 받은편지함으로, 없는 메일함 안의 검색은 전체 메일함에서 */
|
||||
const leaveLabel = computed(() => (search.value ? MAIL_SEARCH_TEXT.searchAll : '받은편지함으로'))
|
||||
|
||||
function retry() {
|
||||
const current = search.value
|
||||
if (current) void mailStore.openSearch(current.q, current.scope, { force: true })
|
||||
if (current) void mailStore.openSearch(current.q, current.scope, { force: true, conditions: current.conditions })
|
||||
else if (scheduled.value) void mailStore.openScheduled()
|
||||
else if (mailStore.currentMailboxId) void mailStore.openMailbox(mailStore.currentMailboxId)
|
||||
}
|
||||
@@ -107,6 +135,18 @@ function onKeydown(event: KeyboardEvent) {
|
||||
mailStore.clearChecked()
|
||||
}
|
||||
|
||||
// 조건 줄의 [조건 지우기] · 결과를 기다리던 [더 찾기]는 누르면 그 자리가 사라진다 — 초점을 잃었으면 목록 칸으로 (14단계)
|
||||
watch(
|
||||
[() => searchConditions.value !== null, searchPending],
|
||||
([hasConditions, pending], [hadConditions, wasPending]) => {
|
||||
if ((hadConditions && !hasConditions) || (wasPending && !pending)) {
|
||||
const active = document.activeElement
|
||||
if (active === null || active === document.body) focusSection()
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
// 고른 것을 다 풀면 도구 줄이 사라진다 — 초점이 그 안에 있었으면(갈 곳을 잃으면) 목록 칸으로
|
||||
watch(
|
||||
() => mailStore.checkedCount,
|
||||
@@ -159,7 +199,17 @@ watch(
|
||||
/>
|
||||
<span>{{ retentionNotice }}</span>
|
||||
</p>
|
||||
<MailSearchScope v-if="search" />
|
||||
<template v-if="search">
|
||||
<MailSearchScope />
|
||||
<MailSearchConditionLine
|
||||
v-if="searchConditions"
|
||||
:conditions="searchConditions"
|
||||
/>
|
||||
<MailSearchNotices
|
||||
:indexing="indexing"
|
||||
:single-char="singleChar"
|
||||
/>
|
||||
</template>
|
||||
<MailNotifyOffer
|
||||
v-else-if="!scheduled"
|
||||
:variant="variant"
|
||||
@@ -199,18 +249,26 @@ watch(
|
||||
<button
|
||||
type="button"
|
||||
class="mail-list-frame__more-button"
|
||||
:disabled="mailStore.isLoadingMore"
|
||||
:disabled="loadingMore"
|
||||
@click="mailStore.loadMore()"
|
||||
>
|
||||
{{ mailStore.isLoadingMore ? '불러오는 중…' : '메일 더 보기' }}
|
||||
{{ loadingMore ? '불러오는 중…' : '메일 더 보기' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<MailSearchMore
|
||||
v-else-if="searchPending"
|
||||
:busy="loadingMore"
|
||||
@more="mailStore.loadMore()"
|
||||
/>
|
||||
|
||||
<MailSearchEmpty
|
||||
v-else-if="mailStore.listStatus === 'ready' && search"
|
||||
:query="search.q"
|
||||
:offer-all="search.scope !== SEARCH_SCOPE_ALL"
|
||||
:offer-all="search.scope !== SEARCH_SCOPE_ALL && !searchTooLong"
|
||||
:word-limit="indexing && !searchTooLong"
|
||||
:too-many-words="searchTooLong"
|
||||
@search-all="setScope(SEARCH_SCOPE_ALL)"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { createManualClock } from '@/test-utils/manual-clock'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MailSearchBox from './MailSearchBox.vue'
|
||||
@@ -51,8 +53,10 @@ describe('MailSearchBox (메일 검색)', () => {
|
||||
expect(input.attributes('aria-label')).toBe('메일 검색')
|
||||
expect(input.attributes('placeholder')).toBe('메일 검색')
|
||||
expect(wrapper.get('form').attributes('role')).toBe('search')
|
||||
expect(wrapper.find('button').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-search-clear]').exists()).toBe(false)
|
||||
expect(wrapper.get('form').classes()).not.toContain('is-active')
|
||||
// [조건]은 늘 있다 (14단계)
|
||||
expect(wrapper.get('[data-search-conditions-toggle]').text()).toBe('조건')
|
||||
})
|
||||
|
||||
it('치는 동안에는 찾지 않고, Enter 로 보던 메일함에서 찾는다 (기록을 쌓는다 — 뒤로 가기로 메일함)', async () => {
|
||||
@@ -123,7 +127,7 @@ describe('MailSearchBox (메일 검색)', () => {
|
||||
|
||||
expect((input.element as HTMLInputElement).value).toBe('회의')
|
||||
expect(wrapper.get('form').classes()).toContain('is-active')
|
||||
const clear = wrapper.get('button')
|
||||
const clear = wrapper.get('[data-search-clear]')
|
||||
expect(clear.attributes('aria-label')).toBe('검색어 지우기')
|
||||
expect(clear.attributes('title')).toBe('검색어 지우기 (Esc)')
|
||||
|
||||
@@ -165,3 +169,311 @@ describe('MailSearchBox (메일 검색)', () => {
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MailSearchBox — [조건] · 「조건으로 찾기」 (14단계 디자인 원본 Search14)', () => {
|
||||
let pinia: Pinia
|
||||
/** 2026년 11월 6일 오전 9시(브라우저 시간대) — 최근 1달은 10월 6일부터 */
|
||||
const clock = createManualClock(new Date(2026, 10, 6, 9).getTime())
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
useMailStore().mailboxes = MAILBOXES
|
||||
installMockApi(() => ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }))
|
||||
})
|
||||
|
||||
/** 문서에 붙여 그린 칸 — 테스트마다 떼어 낸다 (열린 패널의 문서 pointerdown 처리가 다음 테스트에 남지 않게) */
|
||||
let mounted: VueWrapper[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted) wrapper.unmount()
|
||||
mounted = []
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountAt(path: string) {
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MailSearchBox, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: clock } },
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
return { wrapper, router, input: wrapper.get<HTMLInputElement>('input[type="search"]') }
|
||||
}
|
||||
|
||||
async function openPanel(wrapper: VueWrapper) {
|
||||
await wrapper.get('[data-search-conditions-toggle]').trigger('click')
|
||||
await flushPromises()
|
||||
return wrapper.get('[data-search-conditions]')
|
||||
}
|
||||
|
||||
const field = (wrapper: VueWrapper, name: string) => wrapper.get(`[data-search-condition="${name}"]`)
|
||||
|
||||
it('[조건]은 칸 안(✕ 앞)에 있고 누르면 칸 아래에 패널이 열린다 — aria-expanded · 청록 바탕, 첫 칸(보낸 사람)으로 초점', async () => {
|
||||
const { wrapper } = await mountAt('/mail/inbox')
|
||||
const toggle = wrapper.get('[data-search-conditions-toggle]')
|
||||
expect(toggle.attributes('aria-expanded')).toBe('false')
|
||||
expect(toggle.classes()).not.toContain('is-on')
|
||||
|
||||
const panel = await openPanel(wrapper)
|
||||
|
||||
expect(toggle.attributes('aria-expanded')).toBe('true')
|
||||
expect(toggle.attributes('aria-controls')).toBe(panel.attributes('id'))
|
||||
expect(toggle.classes()).toContain('is-on')
|
||||
expect(panel.attributes('role')).toBe('group')
|
||||
expect(panel.get('h2').text()).toBe('조건으로 찾기')
|
||||
expect(panel.findAll('label').map((label) => label.text())).toEqual([
|
||||
'보낸 사람',
|
||||
'받는 사람',
|
||||
'제목',
|
||||
'내용',
|
||||
'기간',
|
||||
'첨부 파일이 있는 메일만',
|
||||
])
|
||||
expect(panel.findAll('option').map((option) => option.text())).toEqual([
|
||||
'전체 기간',
|
||||
'최근 1주',
|
||||
'최근 1달',
|
||||
'최근 3달',
|
||||
'최근 1년',
|
||||
'날짜 고르기',
|
||||
])
|
||||
expect(panel.text()).toContain(
|
||||
'검색 칸의 말과 이 조건에 모두 맞는 메일을 찾습니다. 받는 사람에는 참조로 받은 사람도 들어갑니다.',
|
||||
)
|
||||
expect(document.activeElement).toBe(field(wrapper, 'from').element)
|
||||
})
|
||||
|
||||
it('검색 칸이 비어도 조건만으로 찾는다 — 최근 1달은 그 날짜로 바꿔 주소에 싣는다, 패널은 닫히고 검색 칸으로', async () => {
|
||||
const { wrapper, router, input } = await mountAt('/mail/inbox')
|
||||
await openPanel(wrapper)
|
||||
await field(wrapper, 'from').setValue(' 이도윤 ')
|
||||
await field(wrapper, 'period').setValue('month')
|
||||
await field(wrapper, 'attachment').setValue(true)
|
||||
|
||||
await wrapper.get('[data-search-conditions-apply]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(
|
||||
`/mail/search?in=inbox&from=${encodeURIComponent('이도윤')}&since=2026-10-06&until=2026-11-06&attachment=1`,
|
||||
)
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(input.element)
|
||||
})
|
||||
|
||||
it('패널이 열린 동안 검색 칸의 Enter 는 [찾기]와 같다 — 검색어와 조건을 함께', async () => {
|
||||
const { wrapper, router, input } = await mountAt('/mail/inbox')
|
||||
await openPanel(wrapper)
|
||||
await field(wrapper, 'subject').setValue('견적')
|
||||
await input.setValue('계약')
|
||||
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(
|
||||
`/mail/search?q=${encodeURIComponent('계약')}&in=inbox&subject=${encodeURIComponent('견적')}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('검색어와 조건을 합친 낱말이 8개를 넘으면 찾지 않고 VAL_001 사전 문구 — 패널 · 친 글자는 그대로', async () => {
|
||||
// 같은 코드의 알림은 3초 안에 다시 띄우지 않는다(useApi) — 앞 테스트의 알림과 떨어뜨린다
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(Date.now() + 60_000)
|
||||
const { wrapper, router, input } = await mountAt('/mail/inbox')
|
||||
await input.setValue('하나 둘 셋 넷 다섯')
|
||||
await openPanel(wrapper)
|
||||
await field(wrapper, 'body').setValue('여섯 일곱 여덟 아홉')
|
||||
|
||||
await wrapper.get('[data-search-conditions-apply]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.VAL_001])
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
|
||||
expect((field(wrapper, 'body').element as HTMLInputElement).value).toBe('여섯 일곱 여덟 아홉')
|
||||
})
|
||||
|
||||
it('날짜 고르기의 시작이 끝보다 늦으면 찾지 않고 칸 아래에 알린다 — 끝 날짜 칸으로 초점', async () => {
|
||||
const { wrapper, router } = await mountAt('/mail/inbox')
|
||||
await openPanel(wrapper)
|
||||
await field(wrapper, 'period').setValue('custom')
|
||||
await field(wrapper, 'since').setValue('2026-11-05')
|
||||
await field(wrapper, 'until').setValue('2026-10-01')
|
||||
|
||||
await wrapper.get('[data-search-conditions-apply]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
const error = wrapper.get('[role="alert"]')
|
||||
expect(error.text()).toBe('끝 날짜를 시작 날짜와 같거나 그 뒤로 골라 주세요.')
|
||||
expect(field(wrapper, 'until').attributes('aria-invalid')).toBe('true')
|
||||
expect(field(wrapper, 'until').attributes('aria-describedby')).toBe(error.attributes('id'))
|
||||
expect(document.activeElement).toBe(field(wrapper, 'until').element)
|
||||
|
||||
// 고쳐서 찾으면 그 날짜로
|
||||
await field(wrapper, 'until').setValue('2026-11-05')
|
||||
await wrapper.get('[data-search-conditions-apply]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.query).toMatchObject({ since: '2026-11-05', until: '2026-11-05' })
|
||||
})
|
||||
|
||||
it('날짜는 1970년 1월 1일 ~ 9999년 12월 30일만 고른다 — 그 밖의 날짜는 찾지 않고 칸 아래에 알린다 (그 칸으로 초점)', async () => {
|
||||
const { wrapper, router } = await mountAt('/mail/inbox')
|
||||
await openPanel(wrapper)
|
||||
await field(wrapper, 'period').setValue('custom')
|
||||
expect(field(wrapper, 'since').attributes()).toMatchObject({ min: '1970-01-01', max: '9999-12-30' })
|
||||
expect(field(wrapper, 'until').attributes()).toMatchObject({ min: '1970-01-01', max: '9999-12-30' })
|
||||
|
||||
await field(wrapper, 'since').setValue('1969-12-31')
|
||||
await field(wrapper, 'until').setValue('2026-11-05')
|
||||
// 고른 끝 날짜가 시작 날짜 칸의 끝이 된다
|
||||
expect(field(wrapper, 'since').attributes('max')).toBe('2026-11-05')
|
||||
await wrapper.get('[data-search-conditions-apply]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
const error = wrapper.get('[role="alert"]')
|
||||
expect(error.text()).toBe('날짜는 1970년 1월 1일부터 9999년 12월 30일 사이에서 골라 주세요.')
|
||||
expect(field(wrapper, 'since').attributes('aria-invalid')).toBe('true')
|
||||
expect(field(wrapper, 'since').attributes('aria-describedby')).toBe(error.attributes('id'))
|
||||
expect(field(wrapper, 'until').attributes('aria-invalid')).toBe('false')
|
||||
expect(document.activeElement).toBe(field(wrapper, 'since').element)
|
||||
|
||||
// 날짜를 고치면 알림을 지우고, 고른 시작 날짜가 끝 날짜 칸의 처음이 된다
|
||||
await field(wrapper, 'since').setValue('2026-11-01')
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
expect(field(wrapper, 'until').attributes('min')).toBe('2026-11-01')
|
||||
await wrapper.get('[data-search-conditions-apply]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.query).toMatchObject({ since: '2026-11-01', until: '2026-11-05' })
|
||||
})
|
||||
|
||||
it('미리 정한 기간에서 날짜 고르기로 바꾸면 그 기간의 날짜로 칸을 채워 둔다', async () => {
|
||||
const { wrapper } = await mountAt('/mail/inbox')
|
||||
await openPanel(wrapper)
|
||||
|
||||
await field(wrapper, 'period').setValue('week')
|
||||
await field(wrapper, 'period').setValue('custom')
|
||||
|
||||
expect((field(wrapper, 'since').element as HTMLInputElement).value).toBe('2026-10-30')
|
||||
expect((field(wrapper, 'until').element as HTMLInputElement).value).toBe('2026-11-06')
|
||||
})
|
||||
|
||||
it('Esc 는 패널만 닫고 [조건]으로 초점을 돌린다 — 바깥을 누르면 닫는다', async () => {
|
||||
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
|
||||
await openPanel(wrapper)
|
||||
|
||||
await field(wrapper, 'to').trigger('keydown', { key: 'Escape' })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(wrapper.get('[data-search-conditions-toggle]').element)
|
||||
// 검색은 그대로
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
|
||||
|
||||
await openPanel(wrapper)
|
||||
document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('패널이 열린 채 검색 칸에서 Esc 를 누르면 패널만 닫고 초점은 검색 칸에 남는다 — 검색은 그대로', async () => {
|
||||
const { wrapper, router, input } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
|
||||
await openPanel(wrapper)
|
||||
input.element.focus()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
|
||||
|
||||
await input.trigger('keydown', { key: 'Escape' })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(input.element)
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
|
||||
})
|
||||
|
||||
it('키보드로 검색 칸 밖으로 나가면(focusout) 패널을 닫는다 — 검색 칸 안으로 옮기는 초점은 그대로', async () => {
|
||||
const { wrapper, input } = await mountAt('/mail/inbox')
|
||||
const outside = document.createElement('button')
|
||||
document.body.append(outside)
|
||||
await openPanel(wrapper)
|
||||
const from = field(wrapper, 'from').element
|
||||
|
||||
from.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: input.element }))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
|
||||
|
||||
from.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: outside }))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
|
||||
// 나간 곳의 초점을 [조건]으로 끌어오지 않는다
|
||||
expect(document.activeElement).not.toBe(wrapper.get('[data-search-conditions-toggle]').element)
|
||||
})
|
||||
|
||||
it('다른 화면으로 가면(뒤로 가기 등) 열어 둔 패널을 닫는다', async () => {
|
||||
const { wrapper, router } = await mountAt('/mail/inbox')
|
||||
await openPanel(wrapper)
|
||||
|
||||
await router.push('/mail/trash')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-search-conditions-toggle]').attributes('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('조건이 걸린 검색 — [조건]이 청록이고, 열면 걸린 조건으로 칸을 채운다(오늘 기준 최근 1달)', async () => {
|
||||
const { wrapper } = await mountAt(
|
||||
`/mail/search?q=${Q}&in=inbox&from=${encodeURIComponent('이도윤')}&since=2026-10-06&until=2026-11-06&attachment=1`,
|
||||
)
|
||||
const toggle = wrapper.get('[data-search-conditions-toggle]')
|
||||
expect(toggle.classes()).toContain('is-on')
|
||||
expect(toggle.attributes('aria-expanded')).toBe('false')
|
||||
expect(toggle.text()).toContain('조건이 걸려 있음')
|
||||
|
||||
await openPanel(wrapper)
|
||||
|
||||
expect((field(wrapper, 'from').element as HTMLInputElement).value).toBe('이도윤')
|
||||
expect((field(wrapper, 'period').element as HTMLSelectElement).value).toBe('month')
|
||||
expect((field(wrapper, 'attachment').element as HTMLInputElement).checked).toBe(true)
|
||||
})
|
||||
|
||||
it('패널을 닫은 채 누른 Enter 는 걸린 조건을 그대로 두고 검색어만 바꾼다 (기록을 쌓지 않는다)', async () => {
|
||||
const { router, input } = await mountAt(`/mail/search?q=${Q}&in=all&from=${encodeURIComponent('이도윤')}`)
|
||||
const push = vi.spyOn(router, 'push')
|
||||
|
||||
await input.setValue('견적')
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(
|
||||
`/mail/search?q=${encodeURIComponent('견적')}&in=all&from=${encodeURIComponent('이도윤')}`,
|
||||
)
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('패널의 [조건 지우기] — 걸린 조건을 빼고 다시 찾는다. 검색어도 없으면 검색을 닫는다', async () => {
|
||||
useMailStore().rememberSearchOrigin({ mailboxId: 'inbox', location: '/mail/inbox' })
|
||||
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox&subject=${encodeURIComponent('견적')}`)
|
||||
await openPanel(wrapper)
|
||||
|
||||
await wrapper.get('[data-search-conditions-clear]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
|
||||
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
|
||||
|
||||
await router.replace('/mail/search?in=inbox&attachment=1')
|
||||
await flushPromises()
|
||||
await openPanel(wrapper)
|
||||
await wrapper.get('[data-search-conditions-clear]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,28 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
// 상단 검색 칸 — 메일 화면에서는 "메일 검색" (디자인 원본 Search · BulkSelect 머리 · README "3단계 › 검색")
|
||||
// 상단 검색 칸 — 메일 화면에서는 "메일 검색" (디자인 원본 Search · Search14 · BulkSelect 머리 · README "3단계 › 검색" · "14단계 › 검색 · 조건 검색 · 강조")
|
||||
// - Enter 로 찾는다. 치는 동안에는 찾지 않는다 — 한글 조합 중의 Enter 는 글자 확정이다(isImeComposing, Safari · macOS 의 keyCode 229 포함)
|
||||
// - 검색 중(검색 화면)에는 테두리가 강조색(1px 테두리 + 1px 그림자)이고 끝에 ✕(40×38 · "검색어 지우기" — 밀도 예외).
|
||||
// ✕ · Esc 는 검색을 닫고 검색 전 메일함으로 돌아간다. 검색 화면이 아니면 Esc 는 친 글자만 지운다
|
||||
// - 검색 화면에서는 칸에 지금 검색어를 보인다 (뒤로 가기로 결과에 돌아와도)
|
||||
import { ref, watch } from 'vue'
|
||||
// - 칸 안 오른쪽(✕ 앞)의 [조건](MailSearchConditionsToggle)이 칸 바로 아래에 「조건으로 찾기」 패널을 연다 (14단계 — 열고 닫기는 useSearchConditionsPopup).
|
||||
// 패널이 열린 동안 검색 칸의 Enter 는 [찾기]와 같고, 검색 칸의 Esc 는 패널만 닫는다(초점은 검색 칸에 남는다).
|
||||
// 검색 칸이 비어도 조건만으로 찾는다. 패널이 닫힌 채 누른 Enter 는 걸려 있는 조건을 그대로 둔다
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailSearchConditions from '@/components/mail/MailSearchConditions.vue'
|
||||
import MailSearchConditionsToggle from '@/components/mail/MailSearchConditionsToggle.vue'
|
||||
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
|
||||
import { useSearchConditionsPopup } from '@/composables/useSearchConditionsPopup'
|
||||
import { MAIL_SEARCH_MAX_LENGTH, MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
import { hasSearchConditions, type SearchConditions } from '@/shared/utils/mail-search'
|
||||
|
||||
const { isSearching, routeSearch, startSearch, closeSearch } = useMailSearchNavigation()
|
||||
const PANEL_ID = 'mail-search-conditions'
|
||||
|
||||
const { isSearching, routeSearch, startSearch, clearConditions, closeSearch } = useMailSearchNavigation()
|
||||
|
||||
const text = ref<string>('')
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const formRef = ref<HTMLFormElement | null>(null)
|
||||
const toggleRef = ref<InstanceType<typeof MailSearchConditionsToggle> | null>(null)
|
||||
const panelRef = ref<InstanceType<typeof MailSearchConditions> | null>(null)
|
||||
|
||||
const {
|
||||
isOpen: conditionsOpen,
|
||||
close: closeConditions,
|
||||
toggle: toggleConditions,
|
||||
onFocusOut,
|
||||
} = useSearchConditionsPopup({
|
||||
container: formRef,
|
||||
toggle: toggleRef,
|
||||
onOpened: () => panelRef.value?.focusFirst(),
|
||||
})
|
||||
|
||||
/** 지금 걸린 조건 — 검색 화면의 주소에서 */
|
||||
const appliedConditions = computed<SearchConditions | null>(() => routeSearch.value?.conditions ?? null)
|
||||
const conditionsApplied = computed(() => hasSearchConditions(appliedConditions.value))
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (isImeComposing(event)) return
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void startSearch(text.value)
|
||||
// 패널이 열린 동안은 [찾기]와 같다 — 패널의 조건과 함께
|
||||
if (conditionsOpen.value) void panelRef.value?.apply()
|
||||
else void startSearch(text.value)
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Escape') return
|
||||
if (conditionsOpen.value) {
|
||||
// 패널만 닫는다 — 치던 검색 칸에 초점을 둔다
|
||||
event.preventDefault()
|
||||
closeConditions({ restoreFocus: false })
|
||||
return
|
||||
}
|
||||
if (isSearching.value) {
|
||||
event.preventDefault()
|
||||
void closeSearch()
|
||||
@@ -32,12 +68,26 @@ function onKeydown(event: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
/** [찾기] — 검색 칸의 말과 패널의 조건으로. 찾았으면 패널을 닫고 검색 칸으로 (낱말이 너무 많으면 알림만 — 패널은 그대로) */
|
||||
async function onApply(conditions: SearchConditions) {
|
||||
if (!(await startSearch(text.value, conditions))) return
|
||||
closeConditions({ restoreFocus: false })
|
||||
await nextTick()
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
|
||||
/** [조건 지우기] — 걸린 조건이 있으면 빼고 다시 찾는다(검색어도 없으면 검색을 닫는다). 패널을 닫고 [조건]으로 */
|
||||
function onClear() {
|
||||
closeConditions()
|
||||
if (conditionsApplied.value) void clearConditions()
|
||||
}
|
||||
|
||||
// 검색 화면에서는 주소의 검색어를 보이고, 검색을 닫으면(검색 화면을 떠나면) 비운다
|
||||
watch(
|
||||
() => routeSearch.value?.q ?? null,
|
||||
() => (routeSearch.value ? routeSearch.value.q : null),
|
||||
(query, previous) => {
|
||||
if (query !== null) text.value = query
|
||||
else if (previous) text.value = ''
|
||||
else if (previous !== null && previous !== undefined) text.value = ''
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -45,15 +95,18 @@ watch(
|
||||
|
||||
<template>
|
||||
<form
|
||||
ref="formRef"
|
||||
role="search"
|
||||
:class="['mail-search', { 'is-active': isSearching }]"
|
||||
@submit.prevent
|
||||
@focusout="onFocusOut"
|
||||
>
|
||||
<AppIcon
|
||||
name="search"
|
||||
class="mail-search__icon"
|
||||
/>
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="text"
|
||||
type="search"
|
||||
class="mail-search__input"
|
||||
@@ -64,12 +117,20 @@ watch(
|
||||
enterkeyhint="search"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<MailSearchConditionsToggle
|
||||
ref="toggleRef"
|
||||
:expanded="conditionsOpen"
|
||||
:applied="conditionsApplied"
|
||||
:panel-id="PANEL_ID"
|
||||
@toggle="toggleConditions"
|
||||
/>
|
||||
<button
|
||||
v-if="isSearching"
|
||||
type="button"
|
||||
class="mail-search__clear"
|
||||
:aria-label="MAIL_SEARCH_TEXT.clear"
|
||||
:title="MAIL_SEARCH_TEXT.clearTitle"
|
||||
data-search-clear
|
||||
@click="closeSearch"
|
||||
>
|
||||
<AppIcon
|
||||
@@ -78,11 +139,21 @@ watch(
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
</button>
|
||||
<MailSearchConditions
|
||||
v-if="conditionsOpen"
|
||||
ref="panelRef"
|
||||
:panel-id="PANEL_ID"
|
||||
:applied="appliedConditions"
|
||||
@apply="onApply"
|
||||
@clear="onClear"
|
||||
@close="closeConditions()"
|
||||
/>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-search {
|
||||
position: relative;
|
||||
flex: 1 1 17.5rem;
|
||||
max-width: 32.5rem;
|
||||
display: flex;
|
||||
@@ -90,7 +161,7 @@ watch(
|
||||
gap: var(--space-8);
|
||||
height: var(--size-search-box);
|
||||
margin: 0;
|
||||
padding: 0 var(--space-12);
|
||||
padding: 0 var(--space-4) 0 var(--space-12);
|
||||
border: var(--border-width) solid var(--color-border-input);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
// 「조건으로 찾기」 패널의 칸 묶음 (14단계 — 디자인 원본 Search14): 보낸 사람 · 받는 사람(참조도) · 제목 · 내용 · 기간(MailSearchPeriodField) · 첨부.
|
||||
// 이름 76 · 칸, 칸 높이 40 — 필터 조건 칸의 밀도 예외(--size-filter-control)를 다시 쓴다.
|
||||
// 칸 값은 useSearchConditionsDraft — 처음 그릴 때 걸린 조건(applied)으로 채우고, 부르는 쪽이 take() 로 넘길 조건을 받는다
|
||||
// (기간이 잘못됐으면 null — 칸 아래에 알리고 그 날짜 칸으로 초점). 글 칸의 Enter 는 submit 으로 알린다 (조합 중 Enter 는 글자 확정 — isImeComposing)
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
|
||||
import MailSearchPeriodField from '@/components/mail/MailSearchPeriodField.vue'
|
||||
import { useClock } from '@/composables/useClock'
|
||||
import { useSearchConditionsDraft } from '@/composables/useSearchConditionsDraft'
|
||||
import { MAIL_SEARCH_MAX_LENGTH } from '@/shared/constants/mail'
|
||||
import { SEARCH_CONDITIONS_TEXT } from '@/shared/constants/mail-search'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
import type { SearchConditions, SearchConditionTextField } from '@/shared/utils/mail-search'
|
||||
|
||||
interface Props {
|
||||
/** 칸 id 의 앞 — 패널의 id */
|
||||
idPrefix: string
|
||||
/** 지금 걸린 조건 — 처음 그릴 때 칸을 채운다 (없으면 빈 칸) */
|
||||
applied: SearchConditions | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { draft, periodProblem, fill, changePeriod, changeDay, take } = useSearchConditionsDraft(useClock())
|
||||
|
||||
const TEXT_FIELDS: readonly { field: SearchConditionTextField; label: string; placeholder: string }[] = [
|
||||
{ field: 'from', label: SEARCH_CONDITIONS_TEXT.from, placeholder: SEARCH_CONDITIONS_TEXT.fromPlaceholder },
|
||||
{ field: 'to', label: SEARCH_CONDITIONS_TEXT.to, placeholder: SEARCH_CONDITIONS_TEXT.toPlaceholder },
|
||||
{ field: 'subject', label: SEARCH_CONDITIONS_TEXT.subject, placeholder: SEARCH_CONDITIONS_TEXT.subjectPlaceholder },
|
||||
{ field: 'body', label: SEARCH_CONDITIONS_TEXT.body, placeholder: SEARCH_CONDITIONS_TEXT.bodyPlaceholder },
|
||||
]
|
||||
|
||||
const fieldId = (name: string) => `${props.idPrefix}-${name}`
|
||||
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const periodRef = ref<InstanceType<typeof MailSearchPeriodField> | null>(null)
|
||||
|
||||
function onTextKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter' || isImeComposing(event)) return
|
||||
event.preventDefault()
|
||||
emit('submit')
|
||||
}
|
||||
|
||||
/** 넘길 조건 — 기간이 잘못됐으면 null 이고, 칸 아래에 알린 뒤 그 날짜 칸으로 */
|
||||
async function takeConditions(): Promise<SearchConditions | null> {
|
||||
const conditions = take()
|
||||
if (conditions) return conditions
|
||||
await nextTick()
|
||||
periodRef.value?.focusInvalid()
|
||||
return null
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
take: takeConditions,
|
||||
/** 첫 칸(보낸 사람)으로 */
|
||||
focusFirst: () => rootRef.value?.querySelector<HTMLInputElement>('input')?.focus(),
|
||||
})
|
||||
|
||||
onMounted(() => fill(props.applied))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="rootRef"
|
||||
class="condition-fields"
|
||||
>
|
||||
<template
|
||||
v-for="item in TEXT_FIELDS"
|
||||
:key="item.field"
|
||||
>
|
||||
<label
|
||||
:for="fieldId(item.field)"
|
||||
class="condition-fields__label"
|
||||
>{{ item.label }}</label>
|
||||
<input
|
||||
:id="fieldId(item.field)"
|
||||
v-model="draft[item.field]"
|
||||
class="form-input condition-fields__control"
|
||||
type="text"
|
||||
:maxlength="MAIL_SEARCH_MAX_LENGTH"
|
||||
:placeholder="item.placeholder"
|
||||
autocomplete="off"
|
||||
:data-search-condition="item.field"
|
||||
@keydown="onTextKeydown"
|
||||
>
|
||||
</template>
|
||||
|
||||
<label
|
||||
:for="fieldId('period')"
|
||||
class="condition-fields__label condition-fields__label--top"
|
||||
>{{ SEARCH_CONDITIONS_TEXT.period }}</label>
|
||||
<MailSearchPeriodField
|
||||
ref="periodRef"
|
||||
:select-id="fieldId('period')"
|
||||
:period="draft.period"
|
||||
:since="draft.since"
|
||||
:until="draft.until"
|
||||
:problem="periodProblem"
|
||||
@period="changePeriod"
|
||||
@day="changeDay"
|
||||
/>
|
||||
|
||||
<span class="condition-fields__label">{{ SEARCH_CONDITIONS_TEXT.attachment }}</span>
|
||||
<label class="condition-fields__check">
|
||||
<input
|
||||
v-model="draft.hasAttachment"
|
||||
type="checkbox"
|
||||
data-search-condition="attachment"
|
||||
>
|
||||
<span>{{ SEARCH_CONDITIONS_TEXT.attachmentOnly }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 이름 76 · 칸 (디자인 원본 Search14) */
|
||||
.condition-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 4.75rem minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--space-10) var(--space-12);
|
||||
}
|
||||
|
||||
.condition-fields__label {
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 기간 — 칸 아래에 날짜 · 안내가 더해져도 이름은 첫 줄(기간 고르기)에 맞춘다 */
|
||||
.condition-fields__label--top {
|
||||
align-self: start;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: var(--size-filter-control);
|
||||
}
|
||||
|
||||
/* 칸 높이 40 — 밀도 예외 (필터 조건 칸과 같다) */
|
||||
.condition-fields__control {
|
||||
min-width: 0;
|
||||
min-height: var(--size-filter-control);
|
||||
height: var(--size-filter-control);
|
||||
padding: 0 var(--space-10);
|
||||
}
|
||||
|
||||
.condition-fields__check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
min-height: var(--size-filter-control);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.condition-fields__check input {
|
||||
width: var(--size-option-input);
|
||||
height: var(--size-option-input);
|
||||
margin: 0;
|
||||
accent-color: var(--color-brand);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
// 조건이 걸린 검색 결과의 조건 줄 (디자인 원본 Search14 "조건으로 찾음") — 찾을 곳 줄 아래 한 줄(최소 40)
|
||||
// "조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" + [조건 지우기](36 — 안내 줄 단추의 밀도 예외 --size-notice-action).
|
||||
// 조건은 쉼표로 잇는다 — 꼬리표 · 알약을 쓰지 않는다. 기간은 오늘 기준의 미리 정한 기간과 같으면 그 이름, 아니면 날짜(mail-search.ts)
|
||||
// [조건 지우기]는 조건만 빼고 다시 찾는다 — 검색어도 없으면 검색을 닫는다
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useClock } from '@/composables/useClock'
|
||||
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
|
||||
import { SEARCH_CONDITION_LINE_TEXT } from '@/shared/constants/mail-search'
|
||||
import { searchConditionsSummary, type SearchConditions } from '@/shared/utils/mail-search'
|
||||
|
||||
interface Props {
|
||||
conditions: SearchConditions
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const clock = useClock()
|
||||
const { clearConditions } = useMailSearchNavigation()
|
||||
|
||||
const summary = computed(() => searchConditionsSummary(props.conditions, new Date(clock.now())))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="search-condition-line"
|
||||
data-search-condition-line
|
||||
>
|
||||
<span class="search-condition-line__text">{{ summary }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="search-condition-line__clear"
|
||||
data-search-condition-line-clear
|
||||
@click="clearConditions"
|
||||
>
|
||||
{{ SEARCH_CONDITION_LINE_TEXT.clear }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-condition-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2) var(--space-8);
|
||||
min-height: var(--size-row-min);
|
||||
padding: var(--space-2) var(--space-8) var(--space-2) var(--space-16);
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.search-condition-line__text {
|
||||
min-width: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-strong);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 36 — 안내 줄 단추의 밀도 예외 (tokens.css) */
|
||||
.search-condition-line__clear {
|
||||
flex: 0 0 auto;
|
||||
min-height: var(--size-notice-action);
|
||||
padding: 0 var(--space-8);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-condition-line__clear:hover {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
// 「조건으로 찾기」 패널 (디자인 원본 Search14 "조건 열림" · README 「14단계 › 검색 · 조건 검색 · 강조」) — 검색 칸 바로 아래, 검색 칸 폭.
|
||||
// 칸 묶음(칸 값 · 기간 검사)은 MailSearchConditionFields. 단추 높이 40 — 칸과 같은 밀도 예외(--size-filter-control)
|
||||
// - 열 때 지금 걸린 조건(applied)으로 칸을 채운다
|
||||
// - [찾기] · 글 칸의 Enter: 조건을 apply 로 넘긴다. 날짜 고르기가 잘못됐으면(거꾸로 · 고를 수 없는 날짜) 넘기지 않는다 — 칸 아래에 알리고 그 칸으로
|
||||
// - [조건 지우기]: clear 로 알린다 (부르는 쪽이 걸린 조건을 빼고 패널을 닫는다). Esc: close (부르는 쪽이 [조건]으로 초점을 돌린다 — 조합 중 Esc 는 글자 확정)
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import MailSearchConditionFields from '@/components/mail/MailSearchConditionFields.vue'
|
||||
import { SEARCH_CONDITIONS_TEXT } from '@/shared/constants/mail-search'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
import type { SearchConditions } from '@/shared/utils/mail-search'
|
||||
|
||||
interface Props {
|
||||
/** 패널의 id — [조건] 단추가 aria-controls 로 가리킨다 */
|
||||
panelId: string
|
||||
/** 지금 걸린 조건 — 열 때 칸을 채운다 (없으면 빈 칸) */
|
||||
applied: SearchConditions | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'apply', conditions: SearchConditions): void
|
||||
(e: 'clear'): void
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const titleId = computed(() => `${props.panelId}-title`)
|
||||
const fieldsRef = ref<InstanceType<typeof MailSearchConditionFields> | null>(null)
|
||||
|
||||
/** [찾기] — 기간이 잘못됐으면 넘기지 않는다 (칸이 알린다) */
|
||||
async function apply() {
|
||||
const conditions = await fieldsRef.value?.take()
|
||||
if (conditions) emit('apply', conditions)
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape' || isImeComposing(event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
apply,
|
||||
/** 연 뒤 첫 칸(보낸 사람)으로 */
|
||||
focusFirst: () => fieldsRef.value?.focusFirst(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:id="panelId"
|
||||
class="search-conditions"
|
||||
role="group"
|
||||
:aria-labelledby="titleId"
|
||||
data-search-conditions
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<h2
|
||||
:id="titleId"
|
||||
class="search-conditions__title"
|
||||
>
|
||||
{{ SEARCH_CONDITIONS_TEXT.title }}
|
||||
</h2>
|
||||
<MailSearchConditionFields
|
||||
ref="fieldsRef"
|
||||
:id-prefix="panelId"
|
||||
:applied="applied"
|
||||
@submit="apply"
|
||||
/>
|
||||
<p class="search-conditions__hint">
|
||||
{{ SEARCH_CONDITIONS_TEXT.hint }}
|
||||
</p>
|
||||
<div class="search-conditions__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="search-conditions__button"
|
||||
data-search-conditions-clear
|
||||
@click="emit('clear')"
|
||||
>
|
||||
{{ SEARCH_CONDITIONS_TEXT.clear }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="search-conditions__button search-conditions__button--primary"
|
||||
data-search-conditions-apply
|
||||
@click="apply"
|
||||
>
|
||||
{{ SEARCH_CONDITIONS_TEXT.apply }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 검색 칸 바로 아래 · 검색 칸 폭 — 위 16 · 좌우 18 · 아래 14, 칸 사이 14 (디자인 원본 Search14) */
|
||||
.search-conditions {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--space-6));
|
||||
left: calc(var(--border-width) * -2);
|
||||
right: calc(var(--border-width) * -2);
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-14);
|
||||
padding: var(--space-16) var(--space-18) var(--space-14);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.search-conditions__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-emphasis);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.search-conditions__hint {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.search-conditions__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
/* 단추 높이 40 — 칸과 같은 밀도 예외 */
|
||||
.search-conditions__button {
|
||||
min-height: var(--size-filter-control);
|
||||
padding: 0 var(--space-14);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-conditions__button:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.search-conditions__button--primary {
|
||||
padding: 0 var(--space-18);
|
||||
border-color: var(--color-brand);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
}
|
||||
|
||||
.search-conditions__button--primary:hover {
|
||||
border-color: var(--color-brand-strong);
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
// 검색 칸 안 오른쪽(✕ 앞)의 [조건] (14단계 — 디자인 원본 Search14): 높이 32 · 밀도 예외(--size-compact-control — 새 토큰 없이 다시 쓴다).
|
||||
// 「조건으로 찾기」 패널을 열고 닫는다(aria-expanded · 열린 동안만 aria-controls). 패널이 열렸거나 조건이 걸려 있으면 청록 바탕이고,
|
||||
// 걸려 있으면 화면 낭독기에 ", 조건이 걸려 있음"을 더한다
|
||||
import { ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { SEARCH_CONDITIONS_TEXT } from '@/shared/constants/mail-search'
|
||||
|
||||
interface Props {
|
||||
/** 패널이 열렸다 */
|
||||
expanded: boolean
|
||||
/** 조건이 걸려 있다 */
|
||||
applied: boolean
|
||||
/** 패널의 id */
|
||||
panelId: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'toggle'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const buttonRef = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
defineExpose({
|
||||
focus: () => buttonRef.value?.focus(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
ref="buttonRef"
|
||||
type="button"
|
||||
:class="['conditions-toggle', { 'is-on': expanded || applied }]"
|
||||
:title="SEARCH_CONDITIONS_TEXT.toggleTitle"
|
||||
:aria-expanded="expanded"
|
||||
:aria-controls="expanded ? panelId : undefined"
|
||||
data-search-conditions-toggle
|
||||
@click="emit('toggle')"
|
||||
>
|
||||
<AppIcon
|
||||
name="sliders"
|
||||
:size="14"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
<span>{{ SEARCH_CONDITIONS_TEXT.toggle }}</span>
|
||||
<span
|
||||
v-if="applied"
|
||||
class="visually-hidden"
|
||||
>, {{ SEARCH_CONDITIONS_TEXT.toggleApplied }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 높이 32 · 밀도 예외(--size-compact-control), 패널이 열렸거나 조건이 걸리면 청록 바탕 (디자인 원본 Search14) */
|
||||
.conditions-toggle {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
height: var(--size-compact-control);
|
||||
padding: 0 var(--space-9);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.conditions-toggle:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.conditions-toggle.is-on {
|
||||
background: var(--color-brand-subtle);
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
</style>
|
||||
@@ -1,30 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
// 검색 결과 없음 (디자인 원본 Search "결과 없음") — 검색 아이콘 · "‘…’에 맞는 메일이 없습니다" · 찾는 방법의 한계 한 줄
|
||||
// 검색 결과 없음 (디자인 원본 Search · Search14 "결과 없음") — 더 찾을 것이 없을 때(hasNext false)만 보인다(이어 찾을 것이 남았으면 MailSearchMore)
|
||||
// 검색 아이콘 · "‘계약’에 맞는 메일이 없습니다"(조건만으로 찾았으면 "조건에 맞는 메일이 없습니다") · "다른 말로 찾거나 조건을 줄여 보세요."
|
||||
// 지난 메일을 검색할 수 있게 준비하는 동안(엔진 방식 — 낱말 단위)은 그 한계를 한 줄 더한다 — 준비가 끝나면 뺀다 (14단계)
|
||||
// 한 메일함에서 찾았으면 [전체 메일함에서 찾기]
|
||||
// 검색어와 조건의 낱말이 한도를 넘는 주소(손으로 고친 주소 — 14단계)면 찾지 않았다 — "찾을 말이 너무 많습니다"와 줄이는 방법 (tooManyWords)
|
||||
import { computed } from 'vue'
|
||||
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { MAIL_SEARCH_MAX_WORDS, MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { SEARCH_NOTICE_TEXT } from '@/shared/constants/mail-search'
|
||||
|
||||
interface Props {
|
||||
/** 검색어 — 조건만으로 찾았으면 빈 글 */
|
||||
query: string
|
||||
/** 한 메일함에서 찾았다 — 전체 메일함에서 찾기를 권한다 */
|
||||
offerAll: boolean
|
||||
/** 낱말 단위로 찾는 동안(지난 메일 색인을 준비하는 중)이다 — 그 한계를 알린다 */
|
||||
wordLimit?: boolean
|
||||
/** 검색어와 조건의 낱말이 한도를 넘어 찾지 않았다 */
|
||||
tooManyWords?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'search-all'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
wordLimit: false,
|
||||
tooManyWords: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const title = computed(() =>
|
||||
props.tooManyWords ? SEARCH_NOTICE_TEXT.tooManyWordsTitle : MAIL_SEARCH_TEXT.emptyTitle(props.query),
|
||||
)
|
||||
const hint = computed(() => {
|
||||
if (props.tooManyWords) return SEARCH_NOTICE_TEXT.tooManyWordsHint(MAIL_SEARCH_MAX_WORDS)
|
||||
return props.query ? MAIL_SEARCH_TEXT.emptyHint : MAIL_SEARCH_TEXT.emptyConditionsHint
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StateMessage
|
||||
class="search-empty"
|
||||
icon="search"
|
||||
:title="MAIL_SEARCH_TEXT.emptyTitle(query)"
|
||||
:hint="MAIL_SEARCH_TEXT.emptyHint"
|
||||
:icon="tooManyWords ? 'alert-circle' : 'search'"
|
||||
:title="title"
|
||||
:hint="hint"
|
||||
:data-search-too-many-words="tooManyWords ? '' : undefined"
|
||||
>
|
||||
<p
|
||||
v-if="wordLimit"
|
||||
class="search-empty__limit"
|
||||
data-search-word-limit
|
||||
>
|
||||
{{ MAIL_SEARCH_TEXT.wordLimitHint }}
|
||||
</p>
|
||||
<button
|
||||
v-if="offerAll"
|
||||
type="button"
|
||||
@@ -41,9 +71,18 @@ const emit = defineEmits<Emits>()
|
||||
padding: var(--space-72) var(--space-28);
|
||||
}
|
||||
|
||||
/* 한계 안내는 좁게 두 줄로 (디자인 원본 300px) */
|
||||
.search-empty :deep(.state-message__hint) {
|
||||
/* 도움말은 좁게 두 줄로 (디자인 원본 300px) — 한국어는 낱말 가운데에서 줄을 바꾸지 않는다(keep-all · 긴 영문은 anywhere) */
|
||||
.search-empty :deep(.state-message__hint),
|
||||
.search-empty__limit {
|
||||
max-width: 18.75rem;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.search-empty__limit {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.search-empty__all {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// 검색 결과 머리(52) — [←] 검색 닫기(40×44) · "검색 결과"(15px 굵게) · "‘회의’ · 6통"(12px 흐린 글자) · 보기 전환
|
||||
// 디자인 원본 Search(분할 보기) · ListStates(목록 보기 표). 안쪽 여백만 보기마다 다르다.
|
||||
// 검색 닫기는 검색 전 메일함으로 돌아간다 — 툴팁에 돌아갈 메일함 이름("검색 닫기 — 받은편지함으로")
|
||||
// 통 수는 서버의 total 이다 — null 이면(1,000통 넘음 · 다 세지 못함 — 14단계) 수를 보이지 않는다. 조건만으로 찾았으면 검색어 없이 수만
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
@@ -26,12 +27,12 @@ const mailStore = useMailStore()
|
||||
const { originMailbox, closeSearch } = useMailSearchNavigation()
|
||||
|
||||
const countText = computed(() => {
|
||||
const query = mailStore.search?.q
|
||||
if (!query) return ''
|
||||
const search = mailStore.search
|
||||
if (!search) return ''
|
||||
const query = search.q
|
||||
if (mailStore.listStatus === 'loading') return MAIL_SEARCH_TEXT.searching(query)
|
||||
if (mailStore.listStatus !== 'ready') return MAIL_SEARCH_TEXT.quoted(query)
|
||||
const total = mailStore.pageInfo?.total ?? mailStore.messages.length
|
||||
return MAIL_SEARCH_TEXT.count(query, formatCount(total))
|
||||
const total = mailStore.listStatus === 'ready' ? (mailStore.pageInfo?.total ?? null) : null
|
||||
return total === null ? MAIL_SEARCH_TEXT.quoted(query) : MAIL_SEARCH_TEXT.count(query, formatCount(total))
|
||||
})
|
||||
|
||||
const closeTitle = computed(() => {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
// 이어 찾을 것이 남았는데 아직 하나도 찾지 못한 검색 (14단계 — 계약 2절 "검색 — 14단계" › hasNext 인데 빈 쪽)
|
||||
// 서버는 한 요청에 확인하는 메일 수에 상한이 있다 — 목록 스토어가 정한 횟수(MAIL_SEARCH_FILL_MAX_REQUESTS)까지 이어 묻고도 못 찾았으면
|
||||
// 결과 없음 대신 이 안내와 [더 찾기]를 보인다. 누르면 같은 횟수만큼 더 이어 묻는다(목록 스토어의 loadMore). 찾는 동안 단추는 aria-busy
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import { SEARCH_NOTICE_TEXT } from '@/shared/constants/mail-search'
|
||||
|
||||
interface Props {
|
||||
busy: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'more'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
function onMore() {
|
||||
if (!props.busy) emit('more')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StateMessage
|
||||
class="search-more"
|
||||
icon="search"
|
||||
:title="SEARCH_NOTICE_TEXT.partialTitle"
|
||||
:hint="SEARCH_NOTICE_TEXT.partialHint"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="search-more__button"
|
||||
:aria-busy="busy"
|
||||
:aria-disabled="busy ? 'true' : undefined"
|
||||
data-search-more
|
||||
@click="onMore"
|
||||
>
|
||||
{{ busy ? SEARCH_NOTICE_TEXT.moreBusy : SEARCH_NOTICE_TEXT.more }}
|
||||
</button>
|
||||
</StateMessage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-more {
|
||||
padding: var(--space-72) var(--space-28);
|
||||
}
|
||||
|
||||
/* 한국어는 낱말 가운데에서 줄을 바꾸지 않는다(keep-all · 긴 영문은 anywhere) */
|
||||
.search-more :deep(.state-message__hint) {
|
||||
max-width: 18.75rem;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.search-more__button {
|
||||
margin-top: var(--space-8);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-16);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-more__button:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.search-more__button[aria-disabled='true'] {
|
||||
color: var(--color-text-muted);
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
// 검색 결과 머리 아래 안내 줄 (디자인 원본 Search14 "검색 준비 중" — 옅은 청록 바탕 · 12.5px · info 아이콘) — 14단계
|
||||
// - 검색 준비 중: 지난 메일의 색인을 만드는 동안(GET /search-index 의 building) — 그동안의 검색은 낱말이 통째로 같을 때만 맞는다
|
||||
// - 한 글자 안내: 낱말이 모두 한 글자라 본문은 찾지 않았을 때(색인 방식 — 서버와 같은 낱말 나누기, search-match.ts 의 isSingleCharSearch)
|
||||
// 둘을 감싼 칸이 role="status" 라 줄이 생기면 화면 낭독기가 읽는다 (감싼 칸은 검색 화면이면 늘 있다)
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { SEARCH_NOTICE_TEXT } from '@/shared/constants/mail-search'
|
||||
|
||||
interface Props {
|
||||
indexing: boolean
|
||||
singleChar: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="search-notices"
|
||||
role="status"
|
||||
>
|
||||
<p
|
||||
v-if="indexing"
|
||||
class="search-notices__line"
|
||||
data-search-notice="indexing"
|
||||
>
|
||||
<AppIcon
|
||||
name="info"
|
||||
:size="15"
|
||||
:stroke-width="1.9"
|
||||
class="search-notices__icon"
|
||||
/>
|
||||
<span>{{ SEARCH_NOTICE_TEXT.indexing }}</span>
|
||||
</p>
|
||||
<p
|
||||
v-if="singleChar"
|
||||
class="search-notices__line"
|
||||
data-search-notice="single-char"
|
||||
>
|
||||
<AppIcon
|
||||
name="info"
|
||||
:size="15"
|
||||
:stroke-width="1.9"
|
||||
class="search-notices__icon"
|
||||
/>
|
||||
<span>{{ SEARCH_NOTICE_TEXT.singleChar }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 옅은 청록 바탕 · 아래 선 #E3EEEF · 위아래 10 · 좌우 16 (디자인 원본 Search14) */
|
||||
.search-notices__line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-8);
|
||||
margin: 0;
|
||||
padding: var(--space-10) var(--space-16);
|
||||
border-bottom: var(--border-width) solid var(--color-brand-tint-border);
|
||||
background: var(--color-brand-tint);
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-strong);
|
||||
/* 한국어는 낱말 가운데에서 줄을 바꾸지 않는다 */
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.search-notices__icon {
|
||||
flex: 0 0 auto;
|
||||
margin-top: var(--space-2);
|
||||
color: var(--color-brand);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,190 @@
|
||||
<script setup lang="ts">
|
||||
// 「조건으로 찾기」 패널의 기간 칸 (14단계 — 디자인 원본 Search14): 기간 고르기(전체 기간 · 최근 1주 · 1달 · 3달 · 1년 · 날짜 고르기)와
|
||||
// 날짜 고르기의 시작 ~ 끝. 날짜 칸은 고를 수 있는 날짜(SEARCH_DAY_MIN ~ SEARCH_DAY_MAX)와 서로의 날짜로 범위를 둔다 —
|
||||
// 손으로 친 날짜는 [찾기] 때 다시 본다(useSearchConditionsDraft 의 take).
|
||||
// 잘못(problem)은 칸 아래에 알리고(role=alert) 그 날짜 칸을 aria-invalid 로 — 넘기지 못했으면 부르는 쪽이 focusInvalid() 로 그 칸에 초점을 둔다.
|
||||
// 칸 높이 40 — 필터 조건 칸의 밀도 예외(--size-filter-control)를 다시 쓴다
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import type { SearchPeriodProblem } from '@/composables/useSearchConditionsDraft'
|
||||
import {
|
||||
SEARCH_CONDITIONS_TEXT,
|
||||
SEARCH_DAY_MAX,
|
||||
SEARCH_DAY_MIN,
|
||||
SEARCH_PERIOD_LABELS,
|
||||
SEARCH_PERIOD_ORDER,
|
||||
type SearchPeriod,
|
||||
} from '@/shared/constants/mail-search'
|
||||
import { isSearchDay } from '@/shared/utils/mail-search'
|
||||
|
||||
interface Props {
|
||||
/** 기간 고르기의 id — 부르는 쪽의 이름(label for)이 가리킨다 */
|
||||
selectId: string
|
||||
period: SearchPeriod
|
||||
/** 날짜 고르기의 시작 · 끝 (YYYY-MM-DD, 비면 '') */
|
||||
since: string
|
||||
until: string
|
||||
/** 알릴 잘못 — 없으면 null */
|
||||
problem: SearchPeriodProblem | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'period', period: SearchPeriod): void
|
||||
(e: 'day', edge: 'since' | 'until', value: string): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const sinceRef = ref<HTMLInputElement | null>(null)
|
||||
const untilRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const periodModel = computed<SearchPeriod>({
|
||||
get: () => props.period,
|
||||
set: (value) => emit('period', value),
|
||||
})
|
||||
const sinceModel = computed<string>({
|
||||
get: () => props.since,
|
||||
set: (value) => emit('day', 'since', value),
|
||||
})
|
||||
const untilModel = computed<string>({
|
||||
get: () => props.until,
|
||||
set: (value) => emit('day', 'until', value),
|
||||
})
|
||||
|
||||
const errorId = computed(() => `${props.selectId}-error`)
|
||||
const errorText = computed(() =>
|
||||
props.problem === 'range' ? SEARCH_CONDITIONS_TEXT.periodRange : SEARCH_CONDITIONS_TEXT.periodOrder,
|
||||
)
|
||||
|
||||
/** 고를 수 없는 날짜 — 비었으면 아니다 */
|
||||
const outOfRange = (value: string) => value !== '' && !isSearchDay(value)
|
||||
/** 순서가 거꾸로면 두 칸 모두, 고를 수 없는 날짜면 그 칸만 */
|
||||
const sinceInvalid = computed(
|
||||
() => props.problem === 'order' || (props.problem === 'range' && outOfRange(props.since)),
|
||||
)
|
||||
const untilInvalid = computed(
|
||||
() => props.problem === 'order' || (props.problem === 'range' && outOfRange(props.until)),
|
||||
)
|
||||
/** 날짜 칸의 범위 — 고를 수 있는 날짜 안에서 시작은 끝을, 끝은 시작을 넘지 않게 */
|
||||
const sinceMax = computed(() => (isSearchDay(props.until) ? props.until : SEARCH_DAY_MAX))
|
||||
const untilMin = computed(() => (isSearchDay(props.since) ? props.since : SEARCH_DAY_MIN))
|
||||
|
||||
defineExpose({
|
||||
/** 넘기지 못한 까닭의 칸으로 — 고를 수 없는 시작 날짜면 시작, 그 밖은 끝 날짜 */
|
||||
focusInvalid: () => {
|
||||
if (props.problem === 'range' && outOfRange(props.since)) sinceRef.value?.focus()
|
||||
else untilRef.value?.focus()
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="period-field">
|
||||
<div class="period-field__row">
|
||||
<select
|
||||
:id="selectId"
|
||||
v-model="periodModel"
|
||||
class="form-input period-field__control period-field__select"
|
||||
data-search-condition="period"
|
||||
>
|
||||
<option
|
||||
v-for="option in SEARCH_PERIOD_ORDER"
|
||||
:key="option"
|
||||
:value="option"
|
||||
>
|
||||
{{ SEARCH_PERIOD_LABELS[option] }}
|
||||
</option>
|
||||
</select>
|
||||
<span
|
||||
v-if="period === 'custom'"
|
||||
class="period-field__dates"
|
||||
>
|
||||
<input
|
||||
ref="sinceRef"
|
||||
v-model="sinceModel"
|
||||
class="form-input period-field__control"
|
||||
type="date"
|
||||
:min="SEARCH_DAY_MIN"
|
||||
:max="sinceMax"
|
||||
:aria-label="SEARCH_CONDITIONS_TEXT.since"
|
||||
:aria-invalid="sinceInvalid ? 'true' : 'false'"
|
||||
:aria-describedby="sinceInvalid ? errorId : undefined"
|
||||
data-search-condition="since"
|
||||
>
|
||||
<span
|
||||
class="period-field__tilde"
|
||||
aria-hidden="true"
|
||||
>~</span>
|
||||
<input
|
||||
ref="untilRef"
|
||||
v-model="untilModel"
|
||||
class="form-input period-field__control"
|
||||
type="date"
|
||||
:min="untilMin"
|
||||
:max="SEARCH_DAY_MAX"
|
||||
:aria-label="SEARCH_CONDITIONS_TEXT.until"
|
||||
:aria-invalid="untilInvalid ? 'true' : 'false'"
|
||||
:aria-describedby="untilInvalid ? errorId : undefined"
|
||||
data-search-condition="until"
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="problem"
|
||||
:id="errorId"
|
||||
class="form-message form-message--error"
|
||||
role="alert"
|
||||
:data-search-period-problem="problem"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ errorText }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.period-field {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.period-field__row {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
/* 칸 높이 40 — 밀도 예외 (필터 조건 칸과 같다) */
|
||||
.period-field__control {
|
||||
min-width: 0;
|
||||
min-height: var(--size-filter-control);
|
||||
height: var(--size-filter-control);
|
||||
padding: 0 var(--space-10);
|
||||
}
|
||||
|
||||
.period-field__select {
|
||||
min-width: 8.75rem;
|
||||
}
|
||||
|
||||
.period-field__dates {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.period-field__tilde {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -6,16 +6,18 @@
|
||||
// 대용량 첨부(11단계 — MessageDetail.largeFiles)도 같은 칸에 첨부처럼 — 「첨부 파일 N개」는 둘을 합한 수 (MessageLargeFileItem).
|
||||
// 보낸 사람 자신(링크 주인)에게는 칸 아래에 링크 줄 — 받은 수 · [링크 복사] · [링크 끊기] (MessageLargeFileLinkBar — 끝난 링크는 없다)
|
||||
// 보안 검사 중인 대용량 첨부가 있는 동안은 상태를 가끔 다시 묻고, 막힌 [다운로드]를 누르면 한 번 바로 묻는다 (useLargeFileScanRefresh)
|
||||
// 검색 결과에서 연 메일(14단계)은 첨부 이름의 맞은 말을 <mark> 로 (nameHighlights — 첨부 id → 맞은 곳, 맞은 첨부만)
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import HighlightedText from '@/components/mail/HighlightedText.vue'
|
||||
import MessageLargeFileItem from '@/components/mail/MessageLargeFileItem.vue'
|
||||
import MessageLargeFileLinkBar from '@/components/mail/MessageLargeFileLinkBar.vue'
|
||||
import { useClock } from '@/composables/useClock'
|
||||
import { useLargeFileScanRefresh } from '@/composables/useLargeFileScanRefresh'
|
||||
import { useMail } from '@/composables/useMail'
|
||||
import type { MessageLargeFile } from '@/shared/types/large-file'
|
||||
import type { Attachment } from '@/shared/types/mail'
|
||||
import type { Attachment, SearchRange } from '@/shared/types/mail'
|
||||
import { formatFileSize } from '@/shared/utils/format'
|
||||
import { isEndedLinkStatus } from '@/shared/utils/large-file-status'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
@@ -25,10 +27,13 @@ interface Props {
|
||||
attachments: Attachment[]
|
||||
/** 대용량 첨부 — 링크 주인이 이 메일의 보낸 사람일 때만 온다 */
|
||||
largeFiles?: MessageLargeFile[]
|
||||
/** 첨부 이름의 검색에서 맞은 곳 — 서버의 searchHighlight.attachments (14단계) */
|
||||
nameHighlights?: Readonly<Record<string, readonly SearchRange[]>> | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
largeFiles: () => [],
|
||||
nameHighlights: null,
|
||||
})
|
||||
|
||||
const { attachmentDownloadUrl, downloadAttachment } = useMail()
|
||||
@@ -126,7 +131,12 @@ function onDownload(event: MouseEvent, attachmentId: string) {
|
||||
<AppIcon name="file" />
|
||||
</span>
|
||||
<div class="attachments__meta">
|
||||
<span class="attachments__name">{{ attachment.name }}</span>
|
||||
<span class="attachments__name">
|
||||
<HighlightedText
|
||||
:text="attachment.name"
|
||||
:ranges="nameHighlights?.[attachment.id] ?? null"
|
||||
/>
|
||||
</span>
|
||||
<span class="attachments__size">{{ formatFileSize(attachment.size) }}</span>
|
||||
</div>
|
||||
<a
|
||||
|
||||
@@ -35,6 +35,7 @@ function onCheck(id: string, range: boolean) {
|
||||
:selected="message.id === mailStore.selectedId"
|
||||
:checked="mailStore.checkedIds.has(message.id)"
|
||||
:checking="mailStore.checkedCount > 0"
|
||||
:search="Boolean(mailStore.search)"
|
||||
@toggle-flag="mailStore.toggleFlag"
|
||||
@check="onCheck"
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// 보낸 사람 자리 · 읽음 표시 · 누르면 갈 곳은 메일함 규칙을 따른다 (mail-list-row.ts — 임시보관함은 받는 사람 · 쓰기 화면에서 이어 쓰기)
|
||||
// 전체 메일함 검색 결과면 첫 줄 시각 앞에 "보낸편지함 ·" (디자인 원본 Search)
|
||||
// 아직 보내지 않은 내 메일(9단계)은 시각 자리에 "예약 · 10월 30일 오전 9:00" · "보내는 중"(청록 — 디자인 원본 MailSendLater)
|
||||
// 검색 결과(14단계 — 디자인 원본 Search14)는 세 줄(최소 64): 보낸 사람 · 시각 / 제목(+ 첨부) / 미리 보기 한 줄.
|
||||
// 서버가 준 searchHighlight 의 위치로 제목 · 미리 보기의 맞은 말을 <mark>(HighlightedText — v-html 없음)로 그린다. 강조가 없으면(엔진 방식) 글 그대로
|
||||
// 여러 통 고르기 (디자인 원본 ListStates "분할 보기에서 여러 통 고르기"):
|
||||
// - 마우스를 올리거나 초점이 가면 왼쪽 36px 에 고르기 상자가 나타나고, 하나라도 고르면 모든 행에 보인다 (숨긴 동안에도 Tab 으로 닿는다)
|
||||
// - 행을 누르면 지금처럼 메일을 연다. Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위로 고른다 (새 탭 · 새 창으로 열지 않는다)
|
||||
@@ -12,16 +14,17 @@ import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import HighlightedText from '@/components/mail/HighlightedText.vue'
|
||||
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
|
||||
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
|
||||
import type { MailboxRole, MessageSummary } from '@/shared/types/mail'
|
||||
import type { MailboxRole, MessageListItem } from '@/shared/types/mail'
|
||||
import { formatMailListTime } from '@/shared/utils/format'
|
||||
import { isDraftRole, isRowUnread, pendingSendLabel, rowWho } from '@/shared/utils/mail-list-row'
|
||||
import { isHeldMessage } from '@/shared/utils/pending-send'
|
||||
|
||||
interface Props {
|
||||
message: MessageSummary
|
||||
message: MessageListItem
|
||||
/** 누르면 갈 곳 — 임시 저장본은 쓰기 화면, 그 밖은 읽기 화면 */
|
||||
to: RouteLocationRaw
|
||||
/** 이 행의 메일함 역할 — 보낸 사람 자리 · 읽음 표시 규칙 */
|
||||
@@ -36,6 +39,8 @@ interface Props {
|
||||
checked?: boolean
|
||||
/** 하나라도 골랐다 — 모든 행에 고르기 상자를 보인다 */
|
||||
checking?: boolean
|
||||
/** 검색 결과의 줄 — 미리 보기 줄을 더하고 맞은 말을 강조한다 (14단계) */
|
||||
search?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -50,6 +55,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
selected: false,
|
||||
checked: false,
|
||||
checking: false,
|
||||
search: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
@@ -63,6 +69,11 @@ const unread = computed(() => isRowUnread(props.message, props.role))
|
||||
const draft = computed(() => isDraftRole(props.role))
|
||||
/** 아직 보내지 않은 메일 — 고를 수 없다 */
|
||||
const held = computed(() => isHeldMessage(props.message))
|
||||
/** 검색 강조 (14단계) — 제목이 비었으면 "(제목 없음)"에는 강조가 없다 */
|
||||
const subjectRanges = computed(() => (props.message.subject ? (props.message.searchHighlight?.subject ?? null) : null))
|
||||
/** 검색 결과의 미리 보기 — 본문에서 맞았으면 맞은 말 둘레, 아니면 미리 보기 */
|
||||
const preview = computed(() => props.message.searchHighlight?.preview.text ?? props.message.preview)
|
||||
const previewRanges = computed(() => props.message.searchHighlight?.preview.ranges ?? null)
|
||||
|
||||
/**
|
||||
* Ctrl(⌘) · Shift + 클릭은 메일을 열지 않고 고른다 (RouterLink 는 수정 글쇠가 있으면 이동하지 않는다 — 브라우저의 새 탭도 막는다).
|
||||
@@ -84,7 +95,13 @@ function onMouseDown(event: MouseEvent) {
|
||||
<li
|
||||
:class="[
|
||||
'message-row',
|
||||
{ 'is-selected': selected, 'is-unread': unread, 'is-checked': checked, 'is-checking': checking },
|
||||
{
|
||||
'is-selected': selected,
|
||||
'is-unread': unread,
|
||||
'is-checked': checked,
|
||||
'is-checking': checking,
|
||||
'message-row--search': search,
|
||||
},
|
||||
]"
|
||||
:data-row-id="message.id"
|
||||
@mousedown="onMouseDown"
|
||||
@@ -119,9 +136,14 @@ function onMouseDown(event: MouseEvent) {
|
||||
:datetime="message.pendingSend?.sendAt ?? message.receivedAt"
|
||||
>{{ time }}</time>
|
||||
</span>
|
||||
<!-- 본문 미리보기는 넣지 않는다 (디자인 원칙) — 제목 뒤에 첨부 클립만 -->
|
||||
<!-- 본문 미리보기는 넣지 않는다 (디자인 원칙) — 제목 뒤에 첨부 클립만. 검색 결과만 미리 보기 한 줄 (14단계) -->
|
||||
<span class="message-row__bottom">
|
||||
<span :class="['message-row__subject', { 'is-placeholder': draft && !message.subject }]">{{ subject }}</span>
|
||||
<span :class="['message-row__subject', { 'is-placeholder': draft && !message.subject }]">
|
||||
<HighlightedText
|
||||
:text="subject"
|
||||
:ranges="search ? subjectRanges : null"
|
||||
/>
|
||||
</span>
|
||||
<AppIcon
|
||||
v-if="message.hasAttachment"
|
||||
name="paperclip"
|
||||
@@ -131,6 +153,16 @@ function onMouseDown(event: MouseEvent) {
|
||||
class="message-row__clip"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
v-if="search"
|
||||
class="message-row__preview"
|
||||
data-row-preview
|
||||
>
|
||||
<HighlightedText
|
||||
:text="preview"
|
||||
:ranges="previewRanges"
|
||||
/>
|
||||
</span>
|
||||
</RouterLink>
|
||||
<button
|
||||
type="button"
|
||||
@@ -226,6 +258,11 @@ function onMouseDown(event: MouseEvent) {
|
||||
padding-left: var(--space-10);
|
||||
}
|
||||
|
||||
/* 검색 결과 — 세 줄 최소 64 (디자인 원본 Search14 — 분할 보기 2줄 행 --size-list-row 의 검색판) */
|
||||
.message-row--search .message-row__main {
|
||||
min-height: 4rem;
|
||||
}
|
||||
|
||||
.message-row__main:hover {
|
||||
color: inherit;
|
||||
}
|
||||
@@ -290,6 +327,16 @@ function onMouseDown(event: MouseEvent) {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 검색 결과의 미리 보기 한 줄 — 12.5px 흐린 글자 (디자인 원본 Search14) */
|
||||
.message-row__preview {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 비어 있는 값 — "(받는 사람 없음)" · "(제목 없음)" (디자인 원본 ComposeAssist #667085) */
|
||||
.message-row__who.is-placeholder,
|
||||
.message-row__subject.is-placeholder {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createMailContactDialog, MAIL_CONTACT_DIALOG } from '@/composables/useM
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
|
||||
import type { Contact } from '@/shared/types/contacts'
|
||||
import type { Mailbox, MailFilterRule, MailFilterSet, MessageDetail, MessagesPatch } from '@/shared/types/mail'
|
||||
import type { Mailbox, MailFilterRule, MailFilterSet, MessageDetail, MessagesPatch, MessageSummary } from '@/shared/types/mail'
|
||||
import type { PaginatedResult } from '@/shared/types/pagination'
|
||||
import type { MailListSource } from '@/shared/utils/mail-list-source'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
@@ -338,7 +338,7 @@ describe('MessageReader 삭제 · 영구 삭제 — DELETE /messages/{id} 를
|
||||
store.mailboxes = [INBOX, TRASH]
|
||||
store.listSource = options.source ?? { kind: 'mailbox', mailboxId: 'inbox' }
|
||||
const open = { ...DETAIL, mailboxIds: options.mailboxIds ?? ['inbox'] }
|
||||
store.messages = [open, { ...DETAIL, id: 'm2', subject: '다음 메일' }]
|
||||
store.messages = [open, { ...DETAIL, id: 'm2', subject: '다음 메일' }] as MessageSummary[]
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
|
||||
store.listStatus = 'ready'
|
||||
store.selected = open
|
||||
@@ -537,7 +537,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
store.mailboxes = mailboxes
|
||||
store.listSource = { kind: 'mailbox', mailboxId: box }
|
||||
const open = { ...DETAIL, mailboxIds: [box], from: options.from === undefined ? DETAIL.from : options.from }
|
||||
store.messages = [open, { ...DETAIL, id: 'm2', mailboxIds: [box], subject: '다음 메일' }]
|
||||
store.messages = [open, { ...DETAIL, id: 'm2', mailboxIds: [box], subject: '다음 메일' }] as MessageSummary[]
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
|
||||
store.listStatus = 'ready'
|
||||
store.selected = open
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import type { BlockedSender, Mailbox, MessageDetail, MessagesPatch } from '@/shared/types/mail'
|
||||
import type { BlockedSender, Mailbox, MessageDetail, MessagesPatch, MessageSummary } from '@/shared/types/mail'
|
||||
import { formatScheduleTime } from '@/shared/utils/format'
|
||||
import type { MailListSource } from '@/shared/utils/mail-list-source'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
@@ -144,7 +144,7 @@ describe('MessageReader — 9단계', () => {
|
||||
const store = useMailStore()
|
||||
store.mailboxes = MAILBOXES
|
||||
store.listSource = source
|
||||
store.messages = [message, { ...message, id: 'm2', subject: '다음 메일', pendingSend: message.pendingSend }]
|
||||
store.messages = [message, { ...message, id: 'm2', subject: '다음 메일', pendingSend: message.pendingSend }] as MessageSummary[]
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
|
||||
store.listStatus = 'ready'
|
||||
store.selected = message
|
||||
@@ -426,7 +426,7 @@ describe('MessageReader — 9단계', () => {
|
||||
await wrapper.get('[data-scheduled-cancel]').trigger('click')
|
||||
// 기다리는 사이 받은편지함의 다른 메일을 연다
|
||||
store.listSource = { kind: 'mailbox', mailboxId: 'inbox' }
|
||||
store.messages = [{ ...NEWSLETTER, id: 'i1' }]
|
||||
store.messages = [{ ...NEWSLETTER, id: 'i1' }] as MessageSummary[]
|
||||
store.selectedId = 'i1'
|
||||
await router.push('/mail/inbox/i1')
|
||||
cancel.resolve(ok({ draftId: 's1' }))
|
||||
@@ -445,7 +445,7 @@ describe('MessageReader — 9단계', () => {
|
||||
extra: (request) => (request.method === 'DELETE' ? (cancel.promise as unknown as MockResult) : undefined),
|
||||
})
|
||||
|
||||
store.messages = [...store.messages, { ...NEWSLETTER, id: 'm3', mailboxIds: ['sent'] }]
|
||||
store.messages = [...store.messages, { ...NEWSLETTER, id: 'm3', mailboxIds: ['sent'] }] as MessageSummary[]
|
||||
await wrapper.get('[data-reader-delete]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-scheduled-delete-confirm]').trigger('click')
|
||||
@@ -629,7 +629,7 @@ describe('MessageReader — 9단계', () => {
|
||||
await wrapper.get('[data-block-confirm]').trigger('click')
|
||||
// 기다리는 사이 보낸편지함의 메일을 연다
|
||||
store.listSource = { kind: 'mailbox', mailboxId: 'sent' }
|
||||
store.messages = [{ ...SCHEDULED, id: 'x1', pendingSend: null }]
|
||||
store.messages = [{ ...SCHEDULED, id: 'x1', pendingSend: null }] as MessageSummary[]
|
||||
store.selectedId = 'x1'
|
||||
await router.push('/mail/sent/x1')
|
||||
blocking.resolve(ok({ address: 'news@cloudbrief.example', blockedAt: '2026-10-29T06:00:00.000Z' }, 201))
|
||||
|
||||
@@ -13,10 +13,13 @@
|
||||
// Ctrl+P(맥 ⌘P)는 브라우저 인쇄 대신 인쇄 화면을 새 탭에 연다(⋯ › 인쇄와 같다 — 본문을 눌러 초점이 본문 안에 있어도).
|
||||
// 삭제 뒤 · 없는 메일에서 돌아가는 곳은 지금 목록(메일함 · 검색 결과 · 예약됨)이다.
|
||||
// 대용량 첨부(11단계 — largeFiles)는 첨부 칸에 첨부처럼 보이고, 본문의 덩이는 접는다(본문 틀 안 — 스크립트 없이 열고 닫는다).
|
||||
// 검색 결과에서 연 메일(14단계)은 제목 · 본문 · 첨부 이름의 맞은 말이 노랑이다 — 서버의 searchHighlight(제목 · 첨부 이름 · 글 본문의 위치)와
|
||||
// 서식 본문 안의 <mark>. 강조가 든 응답은 여기서 보이기만 한다 — [답장] · [전달] · 인쇄는 그 메일을 검색 쿼리 없이 따로 받는다 (계약 2절 "검색 — 14단계")
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import HighlightedText from '@/components/mail/HighlightedText.vue'
|
||||
import MailBodyFrame from '@/components/mail/MailBodyFrame.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import MailToolButton from '@/components/mail/MailToolButton.vue'
|
||||
@@ -68,14 +71,17 @@ const fromName = computed(() => displayName(message.value?.from) || '(보낸 사
|
||||
const fromEmail = computed(() => message.value?.from?.email ?? '')
|
||||
const initial = computed(() => initialOf(message.value?.from?.name, fromEmail.value))
|
||||
const subject = computed(() => message.value?.subject || '(제목 없음)')
|
||||
/** 검색 강조 (14단계) — 검색 결과에서 연 메일만 온다. "(제목 없음)"에는 없다 */
|
||||
const subjectRanges = computed(() => (message.value?.subject ? (message.value.searchHighlight?.subject ?? null) : null))
|
||||
const receivedAt = computed(() => (message.value ? formatMailDateTime(message.value.receivedAt) : ''))
|
||||
/** 첨부 목록 — 본문 안에 그림으로 보인 것(서명 그림 · 본문 그림, isInline)은 뺀다 */
|
||||
const attachments = computed(() => (message.value ? listedAttachments(message.value.attachments) : []))
|
||||
/** 대용량 첨부 — 링크 주인이 이 메일의 보낸 사람일 때만 서버가 준다 (필드가 없는 서버면 없다) */
|
||||
const largeFiles = computed(() => message.value?.largeFiles ?? [])
|
||||
|
||||
/** 다시 시도 — 검색 결과에서 연 메일이면 같은 강조 쿼리로 */
|
||||
function retry() {
|
||||
if (mailStore.selectedId) void mailStore.openMessage(mailStore.selectedId)
|
||||
if (mailStore.selectedId) void mailStore.openMessage(mailStore.selectedId, mailStore.selectedHighlight)
|
||||
}
|
||||
|
||||
/** 없는 메일(RES_001 — 지워졌거나 주소가 틀림) — 목록으로 돌아간다 (뒤로 가기 또는 바꿔 끼우기) */
|
||||
@@ -194,7 +200,10 @@ onBeforeUnmount(() => {
|
||||
id="reader-subject"
|
||||
class="reader__subject"
|
||||
>
|
||||
{{ subject }}
|
||||
<HighlightedText
|
||||
:text="subject"
|
||||
:ranges="subjectRanges"
|
||||
/>
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
@@ -265,6 +274,7 @@ onBeforeUnmount(() => {
|
||||
:text-body="message.textBody"
|
||||
:images-shown="message.imagesShown === true"
|
||||
:fold-large-files="largeFiles.length > 0"
|
||||
:text-highlights="message.searchHighlight?.textBody ?? null"
|
||||
:title="`메일 본문: ${subject}`"
|
||||
@frame-keydown="onPrintKey"
|
||||
/>
|
||||
@@ -274,6 +284,7 @@ onBeforeUnmount(() => {
|
||||
:message-id="message.id"
|
||||
:attachments="attachments"
|
||||
:large-files="largeFiles"
|
||||
:name-highlights="message.searchHighlight?.attachments ?? null"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -5,21 +5,23 @@
|
||||
// - 보낸 사람 자리 · 읽음 표시 · 누르면 갈 곳은 메일함 규칙을 따른다 (mail-list-row.ts)
|
||||
// - 아직 보내지 않은 내 메일(9단계)은 날짜 칸이 "예약 · 10월 30일 오전 9:00" · "보내는 중"(청록) — 칸(84)보다 길면 줄이고 전체는 title 로.
|
||||
// 고를 수 없다 — 고르기 상자는 꺼진 모양(aria-disabled · 까닭은 title), Ctrl · Shift + 클릭도 고르지 않는다 (옮기거나 지워도 엔진은 그 시각에 보낸다)
|
||||
// - 검색 결과(14단계)는 제목의 맞은 말을 <mark>(HighlightedText — searchHighlight 의 위치)로 그린다. 행 높이(38)는 그대로 — 미리 보기 줄은 분할 보기만
|
||||
// 칸 폭 · 칸 공통 여백은 표(MessageTable)가 정한다.
|
||||
import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import HighlightedText from '@/components/mail/HighlightedText.vue'
|
||||
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
|
||||
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
|
||||
import type { MailboxRole, MessageSummary } from '@/shared/types/mail'
|
||||
import type { MailboxRole, MessageListItem } from '@/shared/types/mail'
|
||||
import { formatFileSize, formatMailTableDate } from '@/shared/utils/format'
|
||||
import { isDraftRole, isRowUnread, pendingSendLabel, rowWho } from '@/shared/utils/mail-list-row'
|
||||
import { isHeldMessage } from '@/shared/utils/pending-send'
|
||||
|
||||
interface Props {
|
||||
message: MessageSummary
|
||||
message: MessageListItem
|
||||
to: RouteLocationRaw
|
||||
role: MailboxRole | null
|
||||
/** 전체 메일함 검색 결과 — 메일함 칸이 생기고, 보낸편지함 · 임시보관함의 메일은 "받는 사람: 이름" */
|
||||
@@ -51,6 +53,8 @@ const draft = computed(() => isDraftRole(props.role))
|
||||
const pendingLabel = computed(() => pendingSendLabel(props.message))
|
||||
/** 아직 보내지 않은 메일 — 고를 수 없다 */
|
||||
const held = computed(() => isHeldMessage(props.message))
|
||||
/** 검색 강조 (14단계) — "(제목 없음)"에는 없다 */
|
||||
const subjectRanges = computed(() => (props.message.subject ? (props.message.searchHighlight?.subject ?? null) : null))
|
||||
|
||||
/** 행 어디를 눌러도 연다 — 버튼을 누른 경우 · 제목 링크(스스로 연다) · 글자를 고르는 중이면 그대로 둔다 */
|
||||
function onRowClick(event: MouseEvent) {
|
||||
@@ -125,7 +129,10 @@ function onMouseDown(event: MouseEvent) {
|
||||
:to="to"
|
||||
:data-message-id="message.id"
|
||||
>
|
||||
{{ subject }}
|
||||
<HighlightedText
|
||||
:text="subject"
|
||||
:ranges="subjectRanges"
|
||||
/>
|
||||
</RouterLink>
|
||||
<AppIcon
|
||||
v-if="message.hasAttachment"
|
||||
|
||||
@@ -32,6 +32,8 @@ import type {
|
||||
MessageDeletionRequest,
|
||||
MessageDeletionResult,
|
||||
MessageDetail,
|
||||
MessageHighlightQuery,
|
||||
MessageListItem,
|
||||
MessageListQuery,
|
||||
MessageSummary,
|
||||
MessagesPatch,
|
||||
@@ -40,6 +42,7 @@ import type {
|
||||
RecipientSuggestionQuery,
|
||||
SaveSignaturePayload,
|
||||
ScheduledMessageCancel,
|
||||
SearchIndexState,
|
||||
SendMessagePayload,
|
||||
SentMessage,
|
||||
SignatureImage,
|
||||
@@ -54,6 +57,11 @@ export type MailRequestOptions = Pick<AxiosRequestConfig, 'silent' | 'signal'>
|
||||
/** 메일 한 통 읽기의 옵션 — showImages 면 외부 이미지를 보이는 본문(`images=show` — 9단계 "외부 이미지 보기") */
|
||||
export interface GetMessageOptions extends MailRequestOptions {
|
||||
showImages?: boolean
|
||||
/**
|
||||
* 검색 결과에서 연 메일의 강조 쿼리 (14단계 — q · from · to · subject · body 만, mail-search.ts 의 messageHighlightQueryOf).
|
||||
* ⚠️ 읽기 화면(스토어의 열린 메일)만 넘긴다 — 답장 · 전달의 원문 · 인쇄는 넘기지 않는다(강조가 든 응답은 보이기만 한다 — 계약 2절)
|
||||
*/
|
||||
highlight?: MessageHighlightQuery | null
|
||||
}
|
||||
|
||||
export interface SendMessageOptions extends MailRequestOptions {
|
||||
@@ -225,28 +233,43 @@ export function useMail() {
|
||||
|
||||
/**
|
||||
* 받은 시각 내림차순. cursor 는 pageInfo.nextCursor 를 해석하지 말고 그대로 넘긴다.
|
||||
* 검색(q)은 mailboxId 가 있으면 그 메일함, 없으면 휴지통 · 스팸메일함을 뺀 모든 메일함에서 찾는다
|
||||
* 검색(q · 조건 — 14단계)은 mailboxId 가 있으면 그 메일함, 없으면 휴지통 · 스팸메일함을 뺀 모든 메일함에서 찾는다.
|
||||
* 색인으로 찾은 검색은 hasNext 인데 항목이 적거나 0개일 수 있다 — 이어 묻는 것은 부르는 쪽(목록 스토어)이다
|
||||
*/
|
||||
function listMessages(
|
||||
query: MessageListQuery,
|
||||
options?: MailRequestOptions,
|
||||
): Promise<PaginatedResult<MessageSummary>> {
|
||||
return api.get<PaginatedResult<MessageSummary>, PaginatedResult<MessageSummary>>('/messages', {
|
||||
): Promise<PaginatedResult<MessageListItem>> {
|
||||
return api.get<PaginatedResult<MessageListItem>, PaginatedResult<MessageListItem>>('/messages', {
|
||||
...options,
|
||||
params: query,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 지난 메일 색인의 상태 (14단계 — 계약 2절 "검색 색인") — 조회지만 상태 줄을 만들고 멈춤을 푼다(멱등).
|
||||
* 검색을 시작할 때 조용히 묻는다 — building 이면 결과 머리 아래 "검색 준비 중" 줄
|
||||
*/
|
||||
function getSearchIndex(options?: MailRequestOptions): Promise<SearchIndexState> {
|
||||
return api.get<SearchIndexState, SearchIndexState>('/search-index', options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 조회만으로는 읽음 처리되지 않는다 — 읽음은 updateMessage 로 따로 보낸다.
|
||||
* showImages 면 `?images=show` — 외부 이미지를 중계 주소로 바꿔 넣은 본문(imagesShown: true). 주지 않으면 서버가 설정을 따른다
|
||||
* (늘 묻기 · 주소록과 사내 직원은 늘 보기 · 늘 보기 — 스팸메일함의 메일은 늘 지운다, 계약 2절 "외부 이미지 보기")
|
||||
* (늘 묻기 · 주소록과 사내 직원은 늘 보기 · 늘 보기 — 스팸메일함의 메일은 늘 지운다, 계약 2절 "외부 이미지 보기").
|
||||
* highlight 면 그 검색의 맞은 곳을 강조한 본문(searchHighlight · htmlBody 의 <mark> — 14단계)
|
||||
*/
|
||||
function getMessage(id: string, options: GetMessageOptions = {}): Promise<MessageDetail> {
|
||||
const { showImages, ...requestOptions } = options
|
||||
const { showImages, highlight, ...requestOptions } = options
|
||||
const params: Record<string, string> = {}
|
||||
if (showImages) params[MAIL_IMAGES_PARAM] = MAIL_IMAGES_SHOW
|
||||
for (const [name, value] of Object.entries(highlight ?? {})) {
|
||||
if (typeof value === 'string' && value !== '') params[name] = value
|
||||
}
|
||||
return api.get<MessageDetail, MessageDetail>(`/messages/${segment(id)}`, {
|
||||
...requestOptions,
|
||||
...(showImages ? { params: { [MAIL_IMAGES_PARAM]: MAIL_IMAGES_SHOW } } : {}),
|
||||
...(Object.keys(params).length > 0 ? { params } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -453,6 +476,7 @@ export function useMail() {
|
||||
downloadMessageSource,
|
||||
getMailState,
|
||||
listMessages,
|
||||
getSearchIndex,
|
||||
getMessage,
|
||||
updateMessage,
|
||||
updateMessages,
|
||||
|
||||
@@ -6,14 +6,21 @@ import { useMailNavigation } from './useMailNavigation'
|
||||
import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import {
|
||||
exceedsSearchWords,
|
||||
isSearchRouteName,
|
||||
listLocationOf,
|
||||
normalizeSearchText,
|
||||
sameListSource,
|
||||
scopeMailboxIdOf,
|
||||
searchSourceFromQuery,
|
||||
searchSourceOf,
|
||||
type SearchListSource,
|
||||
} from '@/shared/utils/mail-list-source'
|
||||
import {
|
||||
exceedsCombinedSearchWords,
|
||||
hasSearchConditions,
|
||||
normalizeSearchConditions,
|
||||
type SearchConditions,
|
||||
} from '@/shared/utils/mail-search'
|
||||
import { useMailStore, type SearchOrigin } from '@/stores/mail.store'
|
||||
|
||||
/** 메일함 화면(목록 · 연 메일)의 라우트 */
|
||||
@@ -24,9 +31,11 @@ function paramOf(value: unknown): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 화면 오가기 (계약 2절 "검색" · 디자인 README "3단계 › 검색")
|
||||
* - 상단 검색 칸의 Enter: 메일함에서 시작하면 그 메일함에서 찾고(휴지통 · 스팸메일함에서 시작해도 그 안에서), 검색 화면에서는 지금 범위 그대로
|
||||
* - 결과 머리의 범위 전환: "그 메일함 | 전체 메일함" — 기록을 쌓지 않고 바꿔 끼운다
|
||||
* 검색 화면 오가기 (계약 2절 "검색" · "검색 — 14단계" · 디자인 README "3단계 › 검색" · "14단계 › 검색 · 조건 검색 · 강조")
|
||||
* - 상단 검색 칸의 Enter · 「조건으로 찾기」의 [찾기]: 메일함에서 시작하면 그 메일함에서 찾고(휴지통 · 스팸메일함에서 시작해도 그 안에서),
|
||||
* 검색 화면에서는 지금 범위 그대로. 검색 칸이 비어도 조건만으로 찾는다. Enter 는 걸려 있는 조건을 그대로 둔다
|
||||
* - 결과 머리의 범위 전환: "그 메일함 | 전체 메일함" — 기록을 쌓지 않고 바꿔 끼운다 (조건은 그대로)
|
||||
* - 조건 줄의 [조건 지우기]: 조건만 빼고 다시 찾는다 — 검색어도 없으면 검색을 닫는다
|
||||
* - ✕ · Esc · "검색 닫기": 검색 전 메일함(화면)으로 — 바로 앞 기록이 그곳이면 뒤로 가기 (뒤로 가기: 메일 → 결과 → 검색 전 메일함)
|
||||
*/
|
||||
export function useMailSearchNavigation() {
|
||||
@@ -70,28 +79,44 @@ export function useMailSearchNavigation() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter — 빈 검색어는 아무것도 하지 않는다. 같은 검색어로 다시 누르면 보이는 채로 다시 찾는다.
|
||||
* 낱말이 서버 한도(8개)를 넘으면 찾지 않고 서버와 같은 코드(VAL_001)의 사전 문구로 알린다 — 칸에 친 글자는 그대로 둔다
|
||||
* Enter · [찾기] — 검색어도 조건도 없으면 아무것도 하지 않는다. 같은 검색으로 다시 누르면 보이는 채로 다시 찾는다.
|
||||
* conditions 를 주면(「조건으로 찾기」) 그 조건으로, 주지 않으면(검색 칸의 Enter) 걸려 있는 조건 그대로 찾는다.
|
||||
* 검색어와 조건 넷을 합친 낱말이 서버 한도(8개)를 넘으면 찾지 않고 서버와 같은 코드(VAL_001)의 사전 문구로 알린다 — 친 글자는 그대로 둔다.
|
||||
* 찾았으면 true (패널이 닫힌다)
|
||||
*/
|
||||
async function startSearch(text: string): Promise<void> {
|
||||
async function startSearch(text: string, conditions?: Partial<SearchConditions> | null): Promise<boolean> {
|
||||
const q = normalizeSearchText(text)
|
||||
if (!q) return
|
||||
if (exceedsSearchWords(q)) {
|
||||
notifyApiError({ code: 'VAL_001', message: getErrorMessage('VAL_001') })
|
||||
return
|
||||
}
|
||||
const current = routeSearch.value
|
||||
const nextConditions = conditions === undefined ? (current?.conditions ?? null) : normalizeSearchConditions(conditions)
|
||||
if (!q && !hasSearchConditions(nextConditions)) return false
|
||||
if (exceedsCombinedSearchWords(q, nextConditions)) {
|
||||
notifyApiError({ code: 'VAL_001', message: getErrorMessage('VAL_001') })
|
||||
return false
|
||||
}
|
||||
if (current) {
|
||||
if (current.q === q) {
|
||||
await mailStore.openSearch(q, current.scope, { force: true })
|
||||
return
|
||||
const next = searchSourceOf(q, current.scope, nextConditions)
|
||||
if (sameListSource(current, next)) {
|
||||
await mailStore.openSearch(q, current.scope, { force: true, conditions: next.conditions })
|
||||
return true
|
||||
}
|
||||
await router.replace(listLocationOf({ ...current, q }))
|
||||
return
|
||||
await router.replace(listLocationOf(next))
|
||||
return true
|
||||
}
|
||||
const origin = originOfCurrentScreen()
|
||||
mailStore.rememberSearchOrigin(origin)
|
||||
await router.push(listLocationOf({ kind: 'search', q, scope: origin.mailboxId ?? SEARCH_SCOPE_ALL }))
|
||||
await router.push(listLocationOf(searchSourceOf(q, origin.mailboxId ?? SEARCH_SCOPE_ALL, nextConditions)))
|
||||
return true
|
||||
}
|
||||
|
||||
/** 조건 줄의 [조건 지우기] — 조건만 빼고 같은 검색어 · 범위로 다시 찾는다. 검색어가 없으면 검색을 닫는다 */
|
||||
async function clearConditions(): Promise<void> {
|
||||
const current = routeSearch.value
|
||||
if (!current || !hasSearchConditions(current.conditions)) return
|
||||
if (!current.q) {
|
||||
await closeSearch()
|
||||
return
|
||||
}
|
||||
await router.replace(listLocationOf(searchSourceOf(current.q, current.scope)))
|
||||
}
|
||||
|
||||
/** 범위 전환 — 결과에서 연 메일이 있었으면 결과로 돌아간다 */
|
||||
@@ -106,5 +131,5 @@ export function useMailSearchNavigation() {
|
||||
await backOrReplace(returnLocation())
|
||||
}
|
||||
|
||||
return { isSearching, routeSearch, originMailbox, startSearch, setScope, closeSearch }
|
||||
return { isSearching, routeSearch, originMailbox, startSearch, clearConditions, setScope, closeSearch }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
import type { Clock } from '@/composables/useClock'
|
||||
import type { SearchPeriod } from '@/shared/constants/mail-search'
|
||||
import {
|
||||
isReversedPeriod,
|
||||
isSearchDay,
|
||||
normalizeSearchConditions,
|
||||
periodOf,
|
||||
periodRange,
|
||||
type SearchConditions,
|
||||
} from '@/shared/utils/mail-search'
|
||||
|
||||
/** 기간 칸의 잘못 — 시작이 끝보다 늦음(order) · 고를 수 없는 날짜(range — 1970-01-01 ~ 9999-12-30 밖) */
|
||||
export type SearchPeriodProblem = 'order' | 'range'
|
||||
|
||||
/** 「조건으로 찾기」 패널의 칸 값 — 날짜는 <input type="date"> 값(YYYY-MM-DD, 비면 '') */
|
||||
export interface SearchConditionsDraft {
|
||||
from: string
|
||||
to: string
|
||||
subject: string
|
||||
body: string
|
||||
period: SearchPeriod
|
||||
since: string
|
||||
until: string
|
||||
hasAttachment: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 「조건으로 찾기」 패널의 칸 값 (14단계 — 디자인 원본 Search14). 걸린 조건으로 채우고(fill), 넘길 조건을 만든다(take).
|
||||
* - 기간은 오늘 기준의 미리 정한 기간과 같으면 그 고르기, 아니면 날짜 고르기로 채운다. 미리 정한 기간은 넘길 때 그 날짜(그만큼 앞의 같은 날 ~ 오늘)로 바꾼다
|
||||
* - 미리 정한 기간에서 날짜 고르기로 바꾸면 빈 날짜 칸을 그 기간의 날짜로 채워 둔다 — 조금만 고치면 되게
|
||||
* - 날짜 고르기의 시작이 끝보다 늦거나 고를 수 없는 날짜면 넘기지 않고 periodProblem 을 둔다 (칸 아래에 알린다)
|
||||
* 오늘은 주입한 시계로 센다 (테스트는 손으로 돌리는 시계)
|
||||
*/
|
||||
export function useSearchConditionsDraft(clock: Clock) {
|
||||
const draft = reactive<SearchConditionsDraft>({
|
||||
from: '',
|
||||
to: '',
|
||||
subject: '',
|
||||
body: '',
|
||||
period: 'all',
|
||||
since: '',
|
||||
until: '',
|
||||
hasAttachment: false,
|
||||
})
|
||||
const periodProblem = ref<SearchPeriodProblem | null>(null)
|
||||
/** 바로 전에 고른 기간 — 날짜 고르기로 바꿀 때 그 날짜로 칸을 채운다 */
|
||||
let previousPeriod: SearchPeriod = 'all'
|
||||
|
||||
function today(): Date {
|
||||
return new Date(clock.now())
|
||||
}
|
||||
|
||||
/** 걸린 조건으로 칸을 채운다 (열 때) */
|
||||
function fill(conditions: SearchConditions | null) {
|
||||
const value = normalizeSearchConditions(conditions)
|
||||
draft.from = value.from
|
||||
draft.to = value.to
|
||||
draft.subject = value.subject
|
||||
draft.body = value.body
|
||||
draft.period = periodOf(value.since, value.until, today())
|
||||
draft.since = value.since ?? ''
|
||||
draft.until = value.until ?? ''
|
||||
draft.hasAttachment = value.hasAttachment
|
||||
previousPeriod = draft.period
|
||||
periodProblem.value = null
|
||||
}
|
||||
|
||||
/** 기간 고르기를 바꾼다 */
|
||||
function changePeriod(period: SearchPeriod) {
|
||||
draft.period = period
|
||||
periodProblem.value = null
|
||||
if (period === 'custom' && !draft.since && !draft.until) {
|
||||
const range = periodRange(previousPeriod, today())
|
||||
draft.since = range.since ?? ''
|
||||
draft.until = range.until ?? ''
|
||||
}
|
||||
previousPeriod = period
|
||||
}
|
||||
|
||||
/** 날짜 칸을 바꾼다 — 알리던 잘못을 지운다 */
|
||||
function changeDay(edge: 'since' | 'until', value: string) {
|
||||
draft[edge] = value
|
||||
periodProblem.value = null
|
||||
}
|
||||
|
||||
/** 넘길 조건 — 기간이 잘못됐으면 null (periodProblem 에 까닭) */
|
||||
function take(): SearchConditions | null {
|
||||
let since: string | null
|
||||
let until: string | null
|
||||
if (draft.period === 'custom') {
|
||||
since = draft.since || null
|
||||
until = draft.until || null
|
||||
if ((since !== null && !isSearchDay(since)) || (until !== null && !isSearchDay(until))) {
|
||||
periodProblem.value = 'range'
|
||||
return null
|
||||
}
|
||||
if (isReversedPeriod(since, until)) {
|
||||
periodProblem.value = 'order'
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
;({ since, until } = periodRange(draft.period, today()))
|
||||
}
|
||||
periodProblem.value = null
|
||||
return normalizeSearchConditions({
|
||||
from: draft.from,
|
||||
to: draft.to,
|
||||
subject: draft.subject,
|
||||
body: draft.body,
|
||||
since,
|
||||
until,
|
||||
hasAttachment: draft.hasAttachment,
|
||||
})
|
||||
}
|
||||
|
||||
return { draft, periodProblem, fill, changePeriod, changeDay, take }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { nextTick, onBeforeUnmount, ref, watch, type Ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
/** 닫을 때 초점을 [조건]으로 돌릴지 — 기본은 돌린다 */
|
||||
export interface SearchConditionsCloseOptions {
|
||||
restoreFocus?: boolean
|
||||
}
|
||||
|
||||
interface Options {
|
||||
/** 검색 칸(form) — 이 안을 누르거나 이 안으로 옮기는 초점은 바깥이 아니다 */
|
||||
container: Ref<HTMLElement | null>
|
||||
/** [조건] — 닫을 때 초점을 돌려줄 곳 */
|
||||
toggle: Ref<{ focus: () => void } | null>
|
||||
/** 연 뒤(패널이 그려진 뒤) — 첫 칸으로 초점 */
|
||||
onOpened: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 칸의 「조건으로 찾기」 패널 열고 닫기 (14단계 — 디자인 원본 Search14).
|
||||
* 열린 동안 바깥을 누르거나(pointerdown — 초점은 누른 곳으로 간다) 키보드로 검색 칸 밖으로 나가거나(focusout)
|
||||
* 다른 화면으로 가면(뒤로 가기 등) 닫는다 — 이때는 초점을 옮기지 않는다. 그 밖에 닫을 때는 [조건]으로 초점을 돌린다(restoreFocus 로 끈다).
|
||||
* 패널의 칸은 열 때마다 걸린 조건으로 다시 채운다(패널이 v-if 로 새로 그려진다)
|
||||
*/
|
||||
export function useSearchConditionsPopup(options: Options) {
|
||||
const route = useRoute()
|
||||
const isOpen = ref<boolean>(false)
|
||||
|
||||
function onDocumentPointerDown(event: PointerEvent) {
|
||||
const target = event.target instanceof Node ? event.target : null
|
||||
if (target && options.container.value?.contains(target)) return
|
||||
close({ restoreFocus: false })
|
||||
}
|
||||
|
||||
function listenOutside(on: boolean) {
|
||||
if (on) document.addEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
else document.removeEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
}
|
||||
|
||||
/** 키보드로 패널 · 검색 칸 밖으로 나가면 닫는다 (마우스로 누른 곳은 pointerdown 이 본다 — relatedTarget 이 없다) */
|
||||
function onFocusOut(event: FocusEvent) {
|
||||
const next = event.relatedTarget instanceof Node ? event.relatedTarget : null
|
||||
if (!isOpen.value || !next || options.container.value?.contains(next)) return
|
||||
close({ restoreFocus: false })
|
||||
}
|
||||
|
||||
async function open() {
|
||||
if (isOpen.value) return
|
||||
isOpen.value = true
|
||||
listenOutside(true)
|
||||
await nextTick()
|
||||
if (isOpen.value) options.onOpened()
|
||||
}
|
||||
|
||||
function close(closeOptions: SearchConditionsCloseOptions = {}) {
|
||||
if (!isOpen.value) return
|
||||
isOpen.value = false
|
||||
listenOutside(false)
|
||||
if (closeOptions.restoreFocus !== false) void nextTick(() => options.toggle.value?.focus())
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (isOpen.value) close()
|
||||
else void open()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => close({ restoreFocus: false }),
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => listenOutside(false))
|
||||
|
||||
return { isOpen, open, close, toggle, onFocusOut }
|
||||
}
|
||||
@@ -181,6 +181,24 @@ describe('ComposePage', () => {
|
||||
expect(quote.text()).not.toContain('받는 사람')
|
||||
})
|
||||
|
||||
it('검색 결과에서 강조해 연 메일이 스토어에 있어도 답장의 원문은 검색 쿼리 없이 다시 받는다 — 인용에 <mark> 가 없다 (14단계)', async () => {
|
||||
const store = useMailStore()
|
||||
store.selectedId = 'm1'
|
||||
store.selected = {
|
||||
...ORIGINAL,
|
||||
htmlBody: '<p><mark>계약</mark>서를 보냅니다</p>',
|
||||
searchHighlight: { subject: [[0, 2]], attachments: {}, textBody: [] },
|
||||
}
|
||||
store.selectedHighlight = { q: '계약' }
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose?replyTo=m1', replyApi)
|
||||
|
||||
const originals = requests.filter((request) => request.method === 'GET' && request.url === '/messages/m1')
|
||||
expect(originals).toHaveLength(1)
|
||||
expect(originals[0]?.params).toEqual({})
|
||||
expect(wrapper.get('#compose-body').html()).not.toContain('<mark')
|
||||
expect(wrapper.get('#compose-body').text()).toContain('김아라님, 안녕하세요.')
|
||||
})
|
||||
|
||||
it('답장은 커서를 본문 맨 위 첫 줄(인용 위)에 둔다', async () => {
|
||||
const { wrapper } = await mountCompose('/mail/compose?replyTo=m1', replyApi, true)
|
||||
await vi.waitFor(
|
||||
|
||||
@@ -8,6 +8,7 @@ import { routes } from '@/router/routes'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.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'
|
||||
@@ -168,6 +169,16 @@ describe('MailPrintPage', () => {
|
||||
expect(wrapper.get('iframe').attributes('srcdoc')).toContain('src="/api/mail-images/t0k"')
|
||||
})
|
||||
|
||||
it('검색 결과에서 강조해 연 메일도 인쇄는 검색 쿼리 없이 다시 받는다 — 인쇄 주소에도 싣지 않는다 (14단계)', async () => {
|
||||
const store = useMailStore()
|
||||
store.selected = { ...MESSAGE, htmlBody: '<p><mark>계약</mark></p>', searchHighlight: { subject: [], attachments: {}, textBody: [] } }
|
||||
store.selectedHighlight = { q: '계약' }
|
||||
const { wrapper, requests } = await mountAt('/mail/print/m1')
|
||||
|
||||
expect(requests[0]?.params).toEqual({})
|
||||
expect(wrapper.get('iframe').attributes('srcdoc')).not.toContain('<mark>')
|
||||
})
|
||||
|
||||
it('메일을 찾을 수 없으면 "메일을 찾을 수 없습니다" + [닫기] — 인쇄 창은 띄우지 않는다', async () => {
|
||||
const close = vi.spyOn(window, 'close').mockImplementation(() => undefined)
|
||||
const { wrapper, router } = await mountAt('/mail/print/m1', () => fail(404, 'RES_001'))
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import type { Mailbox, MessageDetail, MessageListItem, 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 { createManualClock } from '@/test-utils/manual-clock'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MailboxPage from './MailboxPage.vue'
|
||||
|
||||
@@ -60,6 +70,8 @@ describe('MailboxPage — 검색 결과', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
let results: MessageSummary[]
|
||||
/** 지난 메일 색인의 상태 (GET /search-index — 14단계) */
|
||||
let indexStatus: 'building' | 'ready'
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
@@ -68,8 +80,10 @@ describe('MailboxPage — 검색 결과', () => {
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
useMailStore().mailboxes = MAILBOXES
|
||||
results = [summary('r1'), summary('r2')]
|
||||
indexStatus = 'ready'
|
||||
requests = installMockApi(({ url }) => {
|
||||
if (url === '/messages') return ok({ items: results, pageInfo: { hasNext: false, nextCursor: null, total: results.length } })
|
||||
if (url === '/search-index') return ok({ status: indexStatus, done: 10, total: indexStatus === 'ready' ? 10 : 40 })
|
||||
return ok(detail(url.split('/').pop() ?? 'r1'))
|
||||
})
|
||||
})
|
||||
@@ -107,15 +121,16 @@ describe('MailboxPage — 검색 결과', () => {
|
||||
expect(wrapper.findAll('.message-row')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('결과가 없으면 한계 안내 한 줄과 [전체 메일함에서 찾기] — 누르면 전체에서 다시 찾는다(기록을 쌓지 않는다)', async () => {
|
||||
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: 같은 말머리로는 찾지 못합니다.',
|
||||
)
|
||||
expect(wrapper.text()).toContain('다른 말로 찾거나 조건을 줄여 보세요.')
|
||||
// 색인이 준비됐으면 낱말 단위의 한계 안내 · 검색 준비 중 줄은 없다 (14단계)
|
||||
expect(wrapper.find('[data-search-word-limit]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-search-notice="indexing"]').exists()).toBe(false)
|
||||
await wrapper.get('.search-empty__all').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
@@ -206,3 +221,395 @@ describe('MailboxPage — 검색 결과', () => {
|
||||
expect(store.checkedCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 · 검색 준비 중)', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
let handler: (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
/** 2026년 11월 6일 오전 9시(브라우저 시간대) */
|
||||
const clock = createManualClock(new Date(2026, 10, 6, 9).getTime())
|
||||
|
||||
const HIGHLIGHTED: MessageListItem = {
|
||||
...summary('r1', { subject: 'Re: 유지보수 계약 갱신 건', hasAttachment: true }),
|
||||
preview: '지난번 말씀하신 계약서 초안을 첨부했습니다.',
|
||||
searchHighlight: {
|
||||
subject: [[9, 11]],
|
||||
preview: { text: '…말씀하신 계약서 초안을 첨부했습니다.', ranges: [[6, 8]] },
|
||||
},
|
||||
}
|
||||
|
||||
function indexState(status: 'building' | 'ready'): MockResult {
|
||||
return ok({ status, done: 10, total: 40 })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
useMailStore().mailboxes = MAILBOXES
|
||||
handler = ({ url }) => {
|
||||
if (url === '/search-index') return indexState('ready')
|
||||
if (url === '/messages') return ok({ items: [HIGHLIGHTED], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
|
||||
return ok(detail(url.split('/').pop() ?? 'r1'))
|
||||
}
|
||||
requests = installMockApi((request) => handler(request))
|
||||
})
|
||||
|
||||
/** 그린 화면 — 테스트마다 떼어 낸다 (초점을 보는 테스트는 문서에 붙여 그린다) */
|
||||
let mounted: VueWrapper[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted) wrapper.unmount()
|
||||
mounted = []
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountAt(path: string, options: { attach?: boolean } = {}) {
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MailboxPage, {
|
||||
attachTo: options.attach ? document.body : undefined,
|
||||
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: clock } },
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
function heldResult() {
|
||||
let resolve: (value: MockResult) => void = () => undefined
|
||||
const promise = new Promise<MockResult>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
const listRequests = () => requests.filter((request) => request.url === '/messages')
|
||||
const CONDITIONS_QUERY = `from=${encodeURIComponent('이도윤')}&since=2026-10-06&until=2026-11-06&attachment=1`
|
||||
|
||||
it('주소의 조건으로 찾고 찾을 곳 줄 아래에 "조건: …" 줄 — [조건 지우기]는 조건만 빼고 다시 찾는다', async () => {
|
||||
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox&${CONDITIONS_QUERY}`)
|
||||
|
||||
expect(listRequests()[0]?.params).toEqual({
|
||||
q: '회의',
|
||||
mailboxId: 'inbox',
|
||||
from: '이도윤',
|
||||
after: new Date(2026, 9, 6).toISOString(),
|
||||
before: new Date(2026, 10, 7).toISOString(),
|
||||
hasAttachment: 'true',
|
||||
limit: 20,
|
||||
})
|
||||
expect(wrapper.get('[data-search-condition-line]').text()).toContain(
|
||||
'조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음',
|
||||
)
|
||||
|
||||
await wrapper.get('[data-search-condition-line-clear]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
|
||||
expect(wrapper.find('[data-search-condition-line]').exists()).toBe(false)
|
||||
expect(listRequests().at(-1)?.params).toEqual({ q: '회의', mailboxId: 'inbox', limit: 20 })
|
||||
})
|
||||
|
||||
it('조건만으로 찾으면 머리는 검색어 없이 수만 — 결과 없음은 "조건에 맞는 메일이 없습니다"', async () => {
|
||||
handler = ({ url }) =>
|
||||
url === '/search-index' ? indexState('ready') : ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
const { wrapper } = await mountAt(`/mail/search?in=inbox&${CONDITIONS_QUERY}`)
|
||||
|
||||
expect(wrapper.get('.search-header__count').text()).toBe('0통')
|
||||
expect(wrapper.text()).toContain('조건에 맞는 메일이 없습니다')
|
||||
expect(wrapper.text()).toContain('조건을 바꾸거나 줄여 보세요.')
|
||||
})
|
||||
|
||||
it('total 이 null 이면 통 수를 보이지 않는다', async () => {
|
||||
handler = ({ url }) =>
|
||||
url === '/search-index'
|
||||
? indexState('ready')
|
||||
: ok({ items: [HIGHLIGHTED], pageInfo: { hasNext: false, nextCursor: null, total: null } })
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
|
||||
expect(wrapper.get('.search-header__count').text()).toBe('‘회의’')
|
||||
})
|
||||
|
||||
it('결과 줄은 세 줄 — 보낸 사람 · 시각 / 제목(+ 첨부) / 미리 보기, 맞은 말은 <mark> (v-html 없이)', async () => {
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
|
||||
const row = wrapper.get('.message-row')
|
||||
expect(row.classes()).toContain('message-row--search')
|
||||
expect(row.get('.message-row__subject').text()).toBe('Re: 유지보수 계약 갱신 건')
|
||||
expect(row.get('.message-row__subject').findAll('mark').map((mark) => mark.text())).toEqual(['계약'])
|
||||
const preview = row.get('[data-row-preview]')
|
||||
expect(preview.text()).toBe('…말씀하신 계약서 초안을 첨부했습니다.')
|
||||
expect(preview.findAll('mark').map((mark) => mark.text())).toEqual(['계약'])
|
||||
expect(row.find('[aria-label="첨부 있음"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('강조가 없으면(엔진 방식 — 색인을 준비하는 중) 글 그대로 — 미리 보기는 메일의 미리 보기, 결과 머리 아래 "검색 준비 중" 줄', async () => {
|
||||
handler = ({ url }) => {
|
||||
if (url === '/search-index') return indexState('building')
|
||||
return ok({
|
||||
items: [{ ...HIGHLIGHTED, searchHighlight: null }],
|
||||
pageInfo: { hasNext: false, nextCursor: null, total: 1 },
|
||||
})
|
||||
}
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
|
||||
const row = wrapper.get('.message-row')
|
||||
expect(row.findAll('mark')).toHaveLength(0)
|
||||
expect(row.get('[data-row-preview]').text()).toBe('지난번 말씀하신 계약서 초안을 첨부했습니다.')
|
||||
const notice = wrapper.get('[data-search-notice="indexing"]')
|
||||
expect(notice.text()).toBe(
|
||||
'지난 메일을 검색할 수 있게 준비하고 있습니다. 준비가 끝날 때까지는 낱말이 통째로 같은 메일만 찾습니다.',
|
||||
)
|
||||
expect(notice.element.closest('[role="status"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('색인을 준비하는 중의 결과 없음에는 낱말 단위의 한계 안내를 더한다', async () => {
|
||||
handler = ({ url }) =>
|
||||
url === '/search-index' ? indexState('building') : ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
|
||||
expect(wrapper.get('[data-search-word-limit]').text()).toBe(
|
||||
'낱말 단위로 찾습니다. ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.',
|
||||
)
|
||||
})
|
||||
|
||||
it('색인 상태를 못 받으면 안내 줄 · 한계 안내 없이 지금처럼 — 알리지 않는다', async () => {
|
||||
handler = ({ url }) =>
|
||||
url === '/search-index' ? fail(503, 'SYS_001') : ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
|
||||
expect(wrapper.find('[data-search-notice]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-search-word-limit]').exists()).toBe(false)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('한 글자 낱말뿐이면(색인 방식) 결과 머리에 한 줄 — 본문은 찾지 않았다', async () => {
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${encodeURIComponent('김 a')}&in=all`)
|
||||
|
||||
expect(wrapper.get('[data-search-notice="single-char"]').text()).toBe(
|
||||
'한 글자로만 찾을 때는 본문은 빼고 제목, 보낸 사람과 받는 사람, 첨부 파일 이름에서 찾습니다. 본문까지 찾으려면 두 글자 이상으로 찾아 주세요.',
|
||||
)
|
||||
|
||||
const longer = await mountAt(`/mail/search?q=${encodeURIComponent('김 계약')}&in=all`)
|
||||
expect(longer.wrapper.find('[data-search-notice="single-char"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('색인을 준비하는 중에는 한 글자 안내를 보이지 않는다 — 낱말 단위로 찾는 동안은 "검색 준비 중" 줄만', async () => {
|
||||
handler = ({ url }) =>
|
||||
url === '/search-index'
|
||||
? indexState('building')
|
||||
: ok({ items: [HIGHLIGHTED], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${encodeURIComponent('김 a')}&in=all`)
|
||||
|
||||
expect(wrapper.find('[data-search-notice="single-char"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-search-notice="indexing"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('[더 찾기]는 찾는 동안 aria-busy · "찾는 중…"(다시 눌러도 묻지 않는다) — 찾아서 사라지면 초점을 목록 칸으로', async () => {
|
||||
let next = 0
|
||||
let hold: ReturnType<typeof heldResult> | null = null
|
||||
handler = ({ url }) => {
|
||||
if (url === '/search-index') return indexState('ready')
|
||||
if (url !== '/messages') return ok(detail('r1'))
|
||||
next += 1
|
||||
if (hold) return hold.promise
|
||||
return ok({ items: [], pageInfo: { hasNext: true, nextCursor: `c${next}`, total: null } })
|
||||
}
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`, { attach: true })
|
||||
const more = wrapper.get('[data-search-more]')
|
||||
;(more.element as HTMLButtonElement).focus()
|
||||
|
||||
hold = heldResult()
|
||||
await more.trigger('click')
|
||||
await flushPromises()
|
||||
expect(more.attributes('aria-busy')).toBe('true')
|
||||
expect(more.attributes('aria-disabled')).toBe('true')
|
||||
expect(more.text()).toBe('찾는 중…')
|
||||
const sent = listRequests().length
|
||||
await more.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listRequests().length).toBe(sent)
|
||||
|
||||
hold.resolve(ok({ items: [HIGHLIGHTED], pageInfo: { hasNext: false, nextCursor: null, total: null } }))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-search-more]').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.message-row')).toHaveLength(1)
|
||||
expect(document.activeElement).toBe(wrapper.get('section.mail-list-frame').element)
|
||||
})
|
||||
|
||||
it('조건 줄의 [조건 지우기]를 누르면 줄이 사라진다 — 초점은 목록 칸으로', async () => {
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=inbox&${CONDITIONS_QUERY}`, { attach: true })
|
||||
const clear = wrapper.get('[data-search-condition-line-clear]')
|
||||
;(clear.element as HTMLButtonElement).focus()
|
||||
|
||||
await clear.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-search-condition-line]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(wrapper.get('section.mail-list-frame').element)
|
||||
})
|
||||
|
||||
it('검색어와 조건의 낱말이 8개를 넘는 주소(손으로 고친 주소)는 찾지 않고 "찾을 말이 너무 많습니다" — 연 메일은 강조 없이 받는다', async () => {
|
||||
const words = encodeURIComponent('하나 둘 셋 넷 다섯')
|
||||
const body = encodeURIComponent('여섯 일곱 여덟 아홉')
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${words}&in=inbox&body=${body}`)
|
||||
|
||||
expect(listRequests()).toEqual([])
|
||||
const empty = wrapper.get('[data-search-too-many-words]')
|
||||
expect(empty.text()).toContain('찾을 말이 너무 많습니다')
|
||||
expect(empty.text()).toContain(
|
||||
'검색어와 조건에 넣은 낱말은 모두 합쳐 8개까지 찾을 수 있습니다. 검색 칸이나 [조건]에서 낱말을 줄여 주세요.',
|
||||
)
|
||||
// 다른 메일함에서 찾아도 같다 — [전체 메일함에서 찾기]를 권하지 않는다. 조건은 조건 줄에서 지울 수 있다
|
||||
expect(wrapper.find('.search-empty__all').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-search-condition-line-clear]').exists()).toBe(true)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
wrapper.unmount()
|
||||
mounted = []
|
||||
|
||||
// 그 주소로 연 메일은 강조 쿼리 없이 받는다 — 서버가 400 으로 거절해 메일을 열지 못하는 일이 없게
|
||||
const opened = await mountAt(`/mail/search/r1?q=${words}&in=inbox&body=${body}`)
|
||||
expect(requests.filter((request) => request.url === '/messages/r1').map((request) => request.params)).toEqual([{}])
|
||||
expect(opened.wrapper.find('#reader-subject').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('주소의 날짜가 거꾸로면 기간을 버리고, 고를 수 없는 날짜(1970-01-01 ~ 9999-12-30 밖)는 그 날짜만 버린다 — 나머지 조건으로 찾는다', async () => {
|
||||
const reversed = await mountAt(`/mail/search?q=${Q}&in=all&since=2026-11-06&until=2026-10-01&attachment=1`)
|
||||
expect(listRequests()[0]?.params).toEqual({ q: '회의', hasAttachment: 'true', limit: 20 })
|
||||
expect(reversed.wrapper.get('[data-search-condition-line]').text()).toContain('조건: 첨부 있음')
|
||||
reversed.wrapper.unmount()
|
||||
mounted = []
|
||||
requests.length = 0
|
||||
|
||||
await mountAt(`/mail/search?q=${Q}&in=all&since=1969-12-31&until=2026-11-01`)
|
||||
expect(listRequests()[0]?.params).toEqual({ q: '회의', before: new Date(2026, 10, 2).toISOString(), limit: 20 })
|
||||
for (const wrapper of mounted) wrapper.unmount()
|
||||
mounted = []
|
||||
requests.length = 0
|
||||
|
||||
const outside = await mountAt(`/mail/search?q=${Q}&in=all&since=2026-11-01&until=9999-12-31`)
|
||||
expect(listRequests()[0]?.params).toEqual({ q: '회의', after: new Date(2026, 10, 1).toISOString(), limit: 20 })
|
||||
expect(outside.wrapper.get('[data-search-condition-line]').text()).toContain('조건: 2026. 11. 1.부터')
|
||||
})
|
||||
|
||||
it('빈 쪽이 이어져 아직 못 찾았으면 결과 없음 대신 [더 찾기] — 누르면 이어 찾고, 더 없으면 결과 없음', async () => {
|
||||
let next = 0
|
||||
let exhausted = false
|
||||
handler = ({ url, params }) => {
|
||||
if (url === '/search-index') return indexState('ready')
|
||||
if (url !== '/messages') return ok(detail('r1'))
|
||||
next += 1
|
||||
const done = exhausted && params.cursor !== undefined
|
||||
return ok({ items: [], pageInfo: { hasNext: !done, nextCursor: done ? null : `c${next}`, total: null } })
|
||||
}
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
|
||||
expect(wrapper.text()).toContain('아직 맞는 메일을 찾지 못했습니다')
|
||||
expect(wrapper.text()).toContain('메일이 많아 한 번에 다 찾지 못했습니다. [더 찾기]를 누르면 이어서 찾습니다.')
|
||||
expect(wrapper.text()).not.toContain('에 맞는 메일이 없습니다')
|
||||
const sent = listRequests().length
|
||||
|
||||
exhausted = true
|
||||
await wrapper.get('[data-search-more]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(listRequests().length).toBe(sent + 1)
|
||||
expect(wrapper.find('[data-search-more]').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
|
||||
})
|
||||
|
||||
it('결과에서 연 메일은 검색어와 글 조건 넷만 넘겨 강조를 받는다 — 제목 · 첨부 이름 · 글 본문의 맞은 말', async () => {
|
||||
handler = ({ url, params }) => {
|
||||
if (url === '/search-index') return indexState('ready')
|
||||
if (url === '/messages') return ok({ items: [HIGHLIGHTED], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
|
||||
const highlighted = params.q !== undefined
|
||||
return ok({
|
||||
...detail('r1'),
|
||||
subject: 'Re: 유지보수 계약 갱신 건',
|
||||
htmlBody: null,
|
||||
textBody: '계약서 초안을 첨부했습니다.',
|
||||
attachments: [{ id: 'a1', name: '유지보수_계약서.docx', type: 'application/msword', size: 100, isInline: false }],
|
||||
searchHighlight: highlighted ? { subject: [[9, 11]], attachments: { a1: [[5, 7]] }, textBody: [[0, 2]] } : null,
|
||||
})
|
||||
}
|
||||
const { wrapper, router } = await mountAt(`/mail/search/r1?q=${Q}&in=inbox&${CONDITIONS_QUERY}&subject=${encodeURIComponent('갱신')}`)
|
||||
|
||||
const detailRequest = requests.find((request) => request.url === '/messages/r1')
|
||||
expect(detailRequest?.params).toEqual({ q: '회의', from: '이도윤', subject: '갱신' })
|
||||
expect(wrapper.get('#reader-subject').findAll('mark').map((mark) => mark.text())).toEqual(['계약'])
|
||||
expect(wrapper.get('.attachments__name').findAll('mark').map((mark) => mark.text())).toEqual(['계약'])
|
||||
expect(wrapper.get('iframe').attributes('srcdoc')).toContain('<mark>계약</mark>서 초안을 첨부했습니다.')
|
||||
|
||||
// 같은 메일을 메일함에서 열면 강조가 든 본문을 쓰지 않고 쿼리 없이 다시 받는다
|
||||
await router.push('/mail/inbox/r1')
|
||||
await flushPromises()
|
||||
const plain = requests.filter((request) => request.url === '/messages/r1').at(-1)
|
||||
expect(plain?.params).toEqual({})
|
||||
expect(wrapper.get('#reader-subject').findAll('mark')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MailboxPage — 14단계 검색 · 목록 보기', () => {
|
||||
let pinia: Pinia
|
||||
let total: number | null
|
||||
|
||||
const HIGHLIGHTED: MessageListItem = {
|
||||
...summary('r1', { subject: '견적서와 계약 조건 정리' }),
|
||||
searchHighlight: { subject: [[5, 7]], preview: { text: '', ranges: [] } },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
useMailStore().mailboxes = MAILBOXES
|
||||
window.localStorage.setItem(mailViewModeStorageKey(USER.email), 'list')
|
||||
total = 1
|
||||
installMockApi(({ url }) => {
|
||||
if (url === '/search-index') return ok({ status: 'ready', done: 1, total: 1 })
|
||||
if (url === '/messages') {
|
||||
return ok({ items: [HIGHLIGHTED, summary('r2')], pageInfo: { hasNext: false, nextCursor: null, total } })
|
||||
}
|
||||
return ok(detail(url.split('/').pop() ?? 'r1'))
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
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 }
|
||||
}
|
||||
|
||||
it('표의 제목에도 맞은 말을 <mark> 로 — 행 높이는 그대로(미리 보기 줄은 분할 보기만)', async () => {
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
|
||||
const link = wrapper.get('tbody .mail-table__link')
|
||||
expect(link.text()).toBe('견적서와 계약 조건 정리')
|
||||
expect(link.findAll('mark').map((mark) => mark.text())).toEqual(['계약'])
|
||||
expect(wrapper.find('[data-row-preview]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('total 이 null 이면 연 메일의 위치 "n / 전체"를 보이지 않는다 — 이전 · 다음은 그대로', async () => {
|
||||
total = null
|
||||
const { wrapper } = await mountAt(`/mail/search/r1?q=${Q}&in=all`)
|
||||
|
||||
expect(wrapper.find('.nav-position').exists()).toBe(false)
|
||||
expect(wrapper.get('button[aria-label="다음 메일"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,7 +18,8 @@ import MessageReader from '@/components/mail/MessageReader.vue'
|
||||
import MessageTable from '@/components/mail/MessageTable.vue'
|
||||
import ScheduledMessageList from '@/components/mail/ScheduledMessageList.vue'
|
||||
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
|
||||
import { isScheduledRouteName } from '@/shared/utils/mail-list-source'
|
||||
import { isScheduledRouteName, isSearchTooLong, listKeyOf } from '@/shared/utils/mail-list-source'
|
||||
import { messageHighlightKey, messageHighlightQueryOf } from '@/shared/utils/mail-search'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailViewStore } from '@/stores/mail-view.store'
|
||||
|
||||
@@ -57,25 +58,37 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 검색어 · 범위가 바뀔 때만 다시 찾는다 — 결과에서 메일을 열고 닫는 것으로는 다시 찾지 않는다.
|
||||
// 검색어가 없는 주소(/mail/search)는 검색을 닫은 것으로 본다
|
||||
// 검색어 · 범위 · 조건(14단계)이 바뀔 때만 다시 찾는다 — 결과에서 메일을 열고 닫는 것으로는 다시 찾지 않는다.
|
||||
// 검색어도 조건도 없는 주소(/mail/search)는 검색을 닫은 것으로 본다
|
||||
watch(
|
||||
[isSearching, () => routeSearch.value?.q ?? null, () => routeSearch.value?.scope ?? null],
|
||||
([searching, q, scope]) => {
|
||||
[isSearching, () => (routeSearch.value ? listKeyOf(routeSearch.value) : null)],
|
||||
([searching, key]) => {
|
||||
if (!searching) return
|
||||
if (q === null || scope === null) {
|
||||
const search = routeSearch.value
|
||||
if (key === null || !search) {
|
||||
void closeSearch()
|
||||
return
|
||||
}
|
||||
void mailStore.openSearch(q, scope)
|
||||
void mailStore.openSearch(search.q, search.scope, { conditions: search.conditions })
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/**
|
||||
* 검색 결과에서 연 메일의 강조 쿼리 (14단계 — 검색어와 글 조건 넷만). 메일함에서 연 메일은 null —
|
||||
* 쿼리가 바뀌면 같은 메일이어도 다시 받는다(검색 결과의 강조 본문이 메일함 화면에 남지 않게).
|
||||
* 낱말이 한도를 넘는 주소(손으로 고친 주소)면 싣지 않는다 — 서버가 400 으로 거절해 메일을 열지 못한다
|
||||
*/
|
||||
const highlightQuery = computed(() =>
|
||||
isSearching.value && routeSearch.value && !isSearchTooLong(routeSearch.value)
|
||||
? messageHighlightQueryOf(routeSearch.value.q, routeSearch.value.conditions)
|
||||
: null,
|
||||
)
|
||||
|
||||
watch(
|
||||
messageId,
|
||||
(id) => {
|
||||
if (id) void mailStore.openMessage(id)
|
||||
[messageId, () => messageHighlightKey(highlightQuery.value)],
|
||||
([id]) => {
|
||||
if (id) void mailStore.openMessage(id, highlightQuery.value)
|
||||
else mailStore.closeMessage()
|
||||
},
|
||||
{ immediate: true },
|
||||
|
||||
@@ -330,6 +330,20 @@ export const ICONS = {
|
||||
path('M7 19h5'),
|
||||
rect(16, 12, 6, 10, 2),
|
||||
],
|
||||
|
||||
// ── 14단계: 검색 (디자인 원본 Search14) ──
|
||||
/** 검색 칸의 [조건] (조절 막대 셋) */
|
||||
sliders: [
|
||||
line(4, 21, 4, 14),
|
||||
line(4, 10, 4, 3),
|
||||
line(12, 21, 12, 12),
|
||||
line(12, 8, 12, 3),
|
||||
line(20, 21, 20, 16),
|
||||
line(20, 12, 20, 3),
|
||||
line(1, 14, 7, 14),
|
||||
line(9, 8, 15, 8),
|
||||
line(17, 16, 23, 16),
|
||||
],
|
||||
} as const satisfies Record<string, readonly IconShape[]>
|
||||
|
||||
export type IconName = keyof typeof ICONS
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// 메일 검색 14단계 — 조건 검색 · 이어 찾기 · 검색 준비 중 · 한 글자 안내 (계약 2절 "검색 — 14단계" · 디자인 README 「14단계 — 검색 · 대화 묶기」)
|
||||
// 3단계의 검색 칸 · 결과 머리 글(MAIL_SEARCH_TEXT)과 검색어 한도는 shared/constants/mail.ts 에 있다.
|
||||
// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」을 따른다 — 이 파일이 원본이다.
|
||||
|
||||
/**
|
||||
* 화면 주소(/mail/search?…)에 싣는 조건의 이름 — 검색어처럼 앞단 nginx 의 접근 로그에 쿼리가 남지 않는다 (계약 2절 "검색 — 14단계").
|
||||
* 글 칸 넷은 API 와 같은 이름이고, 기간은 브라우저 시간대의 날짜(YYYY-MM-DD — 시작 날짜 · 끝 날짜 모두 든다),
|
||||
* 첨부는 켰을 때만 `attachment=1`. API 의 after · before 는 보낼 때 이 날짜로 만든다(mail-search.ts)
|
||||
*/
|
||||
export const SEARCH_ROUTE_PARAMS = {
|
||||
from: 'from',
|
||||
to: 'to',
|
||||
subject: 'subject',
|
||||
body: 'body',
|
||||
since: 'since',
|
||||
until: 'until',
|
||||
attachment: 'attachment',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 고를 수 있는 날짜의 범위 (YYYY-MM-DD — 두 날짜 모두 든다). 끝은 9999-12-30 — 9999-12-31 은 UTC 의 다음 날 0시가 10000년이라
|
||||
* 서버가 받지 않는다(어느 시간대에서도 보낼 수 있게). 주소의 날짜도 이 밖이면 버린다 (mail-search.ts 의 isSearchDay)
|
||||
*/
|
||||
export const SEARCH_DAY_MIN = '1970-01-01'
|
||||
export const SEARCH_DAY_MAX = '9999-12-30'
|
||||
|
||||
/** 주소의 attachment 값 — 첨부 파일이 있는 메일만 */
|
||||
export const SEARCH_ATTACHMENT_ROUTE_VALUE = '1'
|
||||
/** API 의 hasAttachment 값 — 이것만 받는다 (다른 값은 400 VAL_001) */
|
||||
export const SEARCH_HAS_ATTACHMENT_VALUE = 'true'
|
||||
|
||||
/**
|
||||
* 결과가 모자라면 이어 묻는 횟수 — 한 번 찾을 때(검색을 열 때 · [메일 더 보기] · [더 찾기] · 목록 보기의 [다음]) 요청을 이만큼까지 보낸다.
|
||||
* 서버(색인 방식)는 한 요청에 메일 3,000통까지만 확인해 hasNext 인데 항목이 적거나 0개일 수 있다 — 한 쪽(20통)을 채우거나 더 없으면 멈춘다.
|
||||
* 5번이면 1만 5천 통을 훑는다(한 요청 1초 안 — 계약). 넘으면 [더 찾기]로 사용자가 잇는다
|
||||
*/
|
||||
export const MAIL_SEARCH_FILL_MAX_REQUESTS = 5
|
||||
|
||||
/** 기간 고르기 — 최근 N주 · 달 · 해는 그만큼 앞의 같은 날부터 오늘까지(두 날짜 모두 든다) */
|
||||
export type SearchPeriod = 'all' | 'week' | 'month' | 'quarter' | 'year' | 'custom'
|
||||
|
||||
/** 미리 정한 기간 — 오늘에서 뺄 날 · 달 수 */
|
||||
export interface SearchPeriodPreset {
|
||||
id: Exclude<SearchPeriod, 'all' | 'custom'>
|
||||
days?: number
|
||||
months?: number
|
||||
}
|
||||
|
||||
export const SEARCH_PERIOD_PRESETS: readonly SearchPeriodPreset[] = [
|
||||
{ id: 'week', days: 7 },
|
||||
{ id: 'month', months: 1 },
|
||||
{ id: 'quarter', months: 3 },
|
||||
{ id: 'year', months: 12 },
|
||||
]
|
||||
|
||||
/** 기간 고르기의 차례 · 이름 (디자인 원본 Search14) */
|
||||
export const SEARCH_PERIOD_LABELS: Readonly<Record<SearchPeriod, string>> = {
|
||||
all: '전체 기간',
|
||||
week: '최근 1주',
|
||||
month: '최근 1달',
|
||||
quarter: '최근 3달',
|
||||
year: '최근 1년',
|
||||
custom: '날짜 고르기',
|
||||
}
|
||||
|
||||
export const SEARCH_PERIOD_ORDER: readonly SearchPeriod[] = ['all', 'week', 'month', 'quarter', 'year', 'custom']
|
||||
|
||||
/** 검색 칸의 [조건] 단추와 「조건으로 찾기」 패널 (디자인 원본 Search14) */
|
||||
export const SEARCH_CONDITIONS_TEXT = {
|
||||
toggle: '조건',
|
||||
toggleTitle: '조건으로 찾기',
|
||||
/** 조건이 걸려 있을 때 [조건] 단추에 더해 읽어 주는 글 */
|
||||
toggleApplied: '조건이 걸려 있음',
|
||||
title: '조건으로 찾기',
|
||||
from: '보낸 사람',
|
||||
to: '받는 사람',
|
||||
subject: '제목',
|
||||
body: '내용',
|
||||
period: '기간',
|
||||
attachment: '첨부',
|
||||
fromPlaceholder: '이름이나 메일 주소',
|
||||
toPlaceholder: '이름이나 메일 주소',
|
||||
subjectPlaceholder: '제목에 든 말',
|
||||
bodyPlaceholder: '본문에 든 말',
|
||||
since: '시작 날짜',
|
||||
until: '끝 날짜',
|
||||
attachmentOnly: '첨부 파일이 있는 메일만',
|
||||
hint: '검색 칸의 말과 이 조건에 모두 맞는 메일을 찾습니다. 받는 사람에는 참조로 받은 사람도 들어갑니다.',
|
||||
periodOrder: '끝 날짜를 시작 날짜와 같거나 그 뒤로 골라 주세요.',
|
||||
periodRange: '날짜는 1970년 1월 1일부터 9999년 12월 30일 사이에서 골라 주세요.',
|
||||
clear: '조건 지우기',
|
||||
apply: '찾기',
|
||||
} as const
|
||||
|
||||
/** 결과 머리 아래 "조건: …" 줄 (디자인 원본 Search14 "조건으로 찾음") — 조건은 쉼표로 잇는다 */
|
||||
export const SEARCH_CONDITION_LINE_TEXT = {
|
||||
prefix: '조건: ',
|
||||
separator: ', ',
|
||||
from: (who: string) => `${who} 님이 보낸 메일`,
|
||||
to: (who: string) => `${who} 님이 받은 메일`,
|
||||
/** 조사(이 · 가)는 부르는 쪽이 붙인다 */
|
||||
subject: (text: string, particle: string) => `제목에 ‘${text}’${particle} 든 메일`,
|
||||
body: (text: string, particle: string) => `내용에 ‘${text}’${particle} 든 메일`,
|
||||
since: (day: string) => `${day}부터`,
|
||||
until: (day: string) => `${day}까지`,
|
||||
range: (since: string, until: string) => `${since}부터 ${until}까지`,
|
||||
attachment: '첨부 있음',
|
||||
clear: '조건 지우기',
|
||||
} as const
|
||||
|
||||
/** 결과 머리 아래 안내 줄 · 결과가 모자랄 때 (디자인 원본 Search14 "검색 준비 중") */
|
||||
export const SEARCH_NOTICE_TEXT = {
|
||||
/** 지난 메일의 색인을 만드는 동안 (GET /search-index 의 building) */
|
||||
indexing: '지난 메일을 검색할 수 있게 준비하고 있습니다. 준비가 끝날 때까지는 낱말이 통째로 같은 메일만 찾습니다.',
|
||||
/** 한 글자 낱말뿐이라 본문은 찾지 않았을 때 (서버와 같은 낱말 나누기 — search-match.ts) */
|
||||
singleChar:
|
||||
'한 글자로만 찾을 때는 본문은 빼고 제목, 보낸 사람과 받는 사람, 첨부 파일 이름에서 찾습니다. 본문까지 찾으려면 두 글자 이상으로 찾아 주세요.',
|
||||
/** 이어 찾을 것이 남았는데 아직 하나도 찾지 못했을 때 */
|
||||
partialTitle: '아직 맞는 메일을 찾지 못했습니다',
|
||||
partialHint: '메일이 많아 한 번에 다 찾지 못했습니다. [더 찾기]를 누르면 이어서 찾습니다.',
|
||||
more: '더 찾기',
|
||||
moreBusy: '찾는 중…',
|
||||
/** 검색어와 조건을 합친 낱말이 한도를 넘는 주소로 들어왔을 때 — 찾지 않고 이 안내만 (조건이 있으면 조건 줄의 [조건 지우기]) */
|
||||
tooManyWordsTitle: '찾을 말이 너무 많습니다',
|
||||
tooManyWordsHint: (max: number) =>
|
||||
`검색어와 조건에 넣은 낱말은 모두 합쳐 ${max}개까지 찾을 수 있습니다. 검색 칸이나 [조건]에서 낱말을 줄여 주세요.`,
|
||||
} as const
|
||||
@@ -268,14 +268,19 @@ export const MAIL_SEARCH_TEXT = {
|
||||
/** "검색 닫기 — 받은편지함으로" — 돌아갈 메일함 이름과 조사는 부르는 쪽이 붙인다 */
|
||||
closeTitle: (place: string) => `검색을 닫고 ${place} 돌아가기`,
|
||||
title: '검색 결과',
|
||||
quoted: (query: string) => `‘${query}’`,
|
||||
count: (query: string, count: string) => `‘${query}’ · ${count}통`,
|
||||
searching: (query: string) => `‘${query}’ · 찾는 중`,
|
||||
/** 결과 머리의 검색어 — 조건만으로 찾았으면(14단계) 검색어 없이 수만 · 전체 수를 모르면(total null) 수를 뺀다 */
|
||||
quoted: (query: string) => (query ? `‘${query}’` : ''),
|
||||
count: (query: string, count: string) => (query ? `‘${query}’ · ${count}통` : `${count}통`),
|
||||
searching: (query: string) => (query ? `‘${query}’ · 찾는 중` : '찾는 중'),
|
||||
scopeLabel: '찾을 곳',
|
||||
scopeAll: '전체 메일함',
|
||||
scopeAllHint: '휴지통과 스팸메일함은 빼고 찾습니다',
|
||||
emptyTitle: (query: string) => `‘${query}’에 맞는 메일이 없습니다`,
|
||||
emptyHint:
|
||||
emptyTitle: (query: string) => (query ? `‘${query}’에 맞는 메일이 없습니다` : '조건에 맞는 메일이 없습니다'),
|
||||
/** 결과 없음의 도움말 (14단계 디자인 원본 Search14) — 조건만으로 찾았으면 emptyConditionsHint */
|
||||
emptyHint: '다른 말로 찾거나 조건을 줄여 보세요.',
|
||||
emptyConditionsHint: '조건을 바꾸거나 줄여 보세요.',
|
||||
/** 지난 메일을 검색할 수 있게 준비하는 동안(엔진 방식 — 14단계 GET /search-index 의 building)에만 결과 없음에 더하는 한계 안내 */
|
||||
wordLimitHint:
|
||||
'낱말 단위로 찾습니다. ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.',
|
||||
searchAll: '전체 메일함에서 찾기',
|
||||
/** 결과 목록의 보이지 않는 이름 (불러오는 동안) */
|
||||
|
||||
@@ -57,6 +57,33 @@ export interface MessageSummary {
|
||||
pendingSend?: PendingSend | null
|
||||
}
|
||||
|
||||
/** 검색에서 맞은 곳 — [시작, 끝) UTF-16 위치 (끝은 들지 않는다 — 계약 2절 "검색 — 14단계") */
|
||||
export type SearchRange = readonly [start: number, end: number]
|
||||
|
||||
/**
|
||||
* 목록 항목의 검색 강조 (14단계) — 색인으로 찾은 검색 결과에만 온다. 강조가 있으면 subject 는 NFC 로 맞춘 글이고 위치는 그 글 기준이다.
|
||||
* preview.text 는 본문에서 맞았으면 맞은 말 둘레 한 줄(잘린 쪽에 "…"), 아니면 미리 보기다
|
||||
*/
|
||||
export interface MessageListHighlight {
|
||||
subject: SearchRange[]
|
||||
preview: { text: string; ranges: SearchRange[] }
|
||||
}
|
||||
|
||||
/** GET /messages 의 항목 — 요약 + 검색 강조. 메일함 목록 · 엔진 방식 검색은 null, 필드가 없으면(14단계 앞 서버) 없는 것으로 본다 */
|
||||
export interface MessageListItem extends MessageSummary {
|
||||
searchHighlight?: MessageListHighlight | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 읽기 화면의 검색 강조 (14단계 — 검색 쿼리를 함께 보냈을 때만). 강조가 있으면 subject · 첨부 이름 · textBody 는 NFC 로 맞춘 글이고
|
||||
* htmlBody 는 맞은 글 조각에 속성 없는 <mark> 가 들어 있다. attachments 는 맞은 첨부만 (첨부 id → 맞은 곳)
|
||||
*/
|
||||
export interface MessageDetailHighlight {
|
||||
subject: SearchRange[]
|
||||
attachments: Record<string, SearchRange[]>
|
||||
textBody: SearchRange[]
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: string
|
||||
name: string
|
||||
@@ -108,6 +135,11 @@ export interface MessageDetail extends MessageSummary {
|
||||
* 그때 htmlBody 의 덩이 바깥 요소에 data-ara-large-files 가 붙는다(읽기 화면이 접는다). 필드가 없으면(11단계 앞 서버) 없는 것으로 본다
|
||||
*/
|
||||
largeFiles?: MessageLargeFile[]
|
||||
/**
|
||||
* 검색 강조 (14단계) — 검색 결과에서 연 메일을 검색 쿼리(q · from · to · subject · body)와 함께 받았을 때만, 없으면 null.
|
||||
* ⚠️ 강조가 든 응답은 읽기 화면에 보이기만 한다 — 답장 · 전달의 원문 인용 · 인쇄는 검색 쿼리 없이 다시 받는다 (계약 2절 "검색 — 14단계")
|
||||
*/
|
||||
searchHighlight?: MessageDetailHighlight | null
|
||||
}
|
||||
|
||||
/** GET /mail-quota — 메일함 패널 하단 사용량 표시와 첨부 사전 검사에 쓴다 */
|
||||
@@ -125,13 +157,49 @@ export interface MailQuota {
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /messages 쿼리 — mailboxId 와 q 중 하나는 있어야 한다 (계약 2절 "검색").
|
||||
* q 만 있으면 휴지통 · 스팸메일함을 뺀 모든 메일함에서 찾는다
|
||||
* GET /messages 쿼리 — mailboxId 와 검색(q 나 조건 하나) 가운데 하나는 있어야 한다 (계약 2절 "검색" · "검색 — 14단계").
|
||||
* mailboxId 가 없으면 휴지통 · 스팸메일함을 뺀 모든 메일함에서 찾는다. q 와 조건 넷(from · to · subject · body)을 합친 낱말은 8개까지
|
||||
*/
|
||||
export interface MessageListQuery extends CursorPaginationQuery {
|
||||
mailboxId?: string
|
||||
/** 검색어 — 앞뒤 공백을 뺀 1~200자, 낱말 8개까지 */
|
||||
q?: string
|
||||
/** 보낸 사람(이름 · 주소) — 검사는 q 와 같다 */
|
||||
from?: string
|
||||
/** 받는 사람과 참조 */
|
||||
to?: string
|
||||
subject?: string
|
||||
/** 본문 */
|
||||
body?: string
|
||||
/** 받은 시각이 이 시각 이상 — ISO 8601 UTC (화면은 시작 날짜의 0시를 보낸다) */
|
||||
after?: string
|
||||
/** 받은 시각이 이 시각 미만 — ISO 8601 UTC (화면은 끝 날짜의 다음 날 0시를 보낸다) */
|
||||
before?: string
|
||||
/** 'true' 면 첨부 파일이 있는 메일만 (다른 값은 400 VAL_001) */
|
||||
hasAttachment?: 'true'
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /messages/{id} 의 강조 쿼리 (14단계) — 검색 주소의 조건 가운데 이 다섯만 보낸다(after · before · hasAttachment · cursor · limit 은 400 VAL_001).
|
||||
* from · to 는 서버가 받기만 하고 강조하지 않는다
|
||||
*/
|
||||
export interface MessageHighlightQuery {
|
||||
q?: string
|
||||
from?: string
|
||||
to?: string
|
||||
subject?: string
|
||||
body?: string
|
||||
}
|
||||
|
||||
/** 지난 메일 색인의 상태 (GET /search-index — 14단계) — building 이면 그 직원의 검색은 엔진 방식(낱말 통째 일치)이다 */
|
||||
export type SearchIndexStatus = 'building' | 'ready'
|
||||
|
||||
export interface SearchIndexState {
|
||||
status: SearchIndexStatus
|
||||
/** 채운 메일 수 */
|
||||
done: number
|
||||
/** 채우기를 시작할 때의 메일 수 — 시작 전이면 null */
|
||||
total: number | null
|
||||
}
|
||||
|
||||
/** PATCH /messages/{id} 본문 — 하나 이상 지정한다 */
|
||||
|
||||
@@ -278,3 +278,39 @@ describe('대용량 첨부 덩이 접기 (11단계 — 디자인 원본 MailRead
|
||||
expect(doc).toContain('details[open] .ara-large-files__hint,details[open] .ara-large-files__open,details:not([open]) .ara-large-files__close{display:none}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('검색 강조 (14단계 — 계약 2절 "검색 — 14단계" › 읽기 화면)', () => {
|
||||
it('서버가 서식 본문의 글 조각에 넣은 <mark> 는 한 번 더 정화해도 남는다 (속성은 지운다)', () => {
|
||||
const html = sanitizeMailHtml('<p>지난번 <mark>계약</mark>서 초안을 <mark onclick="x()" class="a">첨부</mark>했습니다</p>')
|
||||
|
||||
expect(html).toBe('<p>지난번 <mark>계약</mark>서 초안을 <mark class="a">첨부</mark>했습니다</p>')
|
||||
expect(buildMailDocument({ htmlBody: '<p><mark>계약</mark></p>', textBody: null })).toContain('<mark>계약</mark>')
|
||||
})
|
||||
|
||||
it('글 본문은 맞은 곳(textHighlights)을 <mark> 로 — 나머지는 지금처럼 이스케이프한다', () => {
|
||||
const doc = buildMailDocument({
|
||||
htmlBody: null,
|
||||
textBody: '<b>계약</b>서를 보냅니다\n계약 갱신',
|
||||
textHighlights: [
|
||||
[3, 5],
|
||||
[17, 19],
|
||||
],
|
||||
})
|
||||
|
||||
expect(doc).toContain('<div class="mail-text"><b><mark>계약</mark></b>서를 보냅니다\n<mark>계약</mark> 갱신</div>')
|
||||
})
|
||||
|
||||
it('서식 본문이 있으면 글 본문의 맞은 곳은 쓰지 않는다 · 맞은 곳이 없으면 지금과 같다', () => {
|
||||
const withHtml = buildMailDocument({ htmlBody: '<p>본문</p>', textBody: '계약', textHighlights: [[0, 2]] })
|
||||
expect(withHtml).not.toContain('<mark>')
|
||||
expect(buildMailDocument({ htmlBody: null, textBody: 'a < b', textHighlights: null })).toContain(
|
||||
'<div class="mail-text">a < b</div>',
|
||||
)
|
||||
})
|
||||
|
||||
it('맞은 말의 모양은 문서 안 style 이 정한다 — 노랑 · 글자색 그대로 (디자인 원본 Search14)', () => {
|
||||
expect(buildMailDocument({ htmlBody: null, textBody: '' })).toContain(
|
||||
'mark{background:#FCE7A4;color:inherit;border-radius:0.125rem;padding:0 0.0625rem}',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,8 @@ import DOMPurify, { type Config, type DOMPurify as Purifier } from 'dompurify'
|
||||
|
||||
import { MESSAGE_LARGE_FILE_TEXT } from '@/shared/constants/large-file'
|
||||
import { MAIL_IMAGE_PROXY_PATH } from '@/shared/constants/mail'
|
||||
import type { SearchRange } from '@/shared/types/mail'
|
||||
import { highlightSegments } from '@/shared/utils/search-highlight'
|
||||
|
||||
/**
|
||||
* iframe 문서 안에만 적용하는 CSP.
|
||||
@@ -166,6 +168,9 @@ const MAIL_FRAME_STYLE = [
|
||||
'pre{white-space:pre-wrap}',
|
||||
'blockquote{margin:0 0 0 0.25rem;padding-left:0.75rem;border-left:0.125rem solid #98A2B3;color:#4A5363}',
|
||||
'.mail-text{white-space:pre-wrap}',
|
||||
// 검색에서 맞은 말 (14단계 — 서버가 정화한 HTML 의 글 조각에 넣은 <mark> · 글 본문은 아래 textHighlights).
|
||||
// 짝: styles/tokens.css 의 --color-search-mark — 본문 틀은 화면의 변수를 읽지 못해 같은 값을 적는다, 한쪽을 고치면 둘 다
|
||||
'mark{background:#FCE7A4;color:inherit;border-radius:0.125rem;padding:0 0.0625rem}',
|
||||
// 대용량 첨부 덩이를 접는 줄 (11단계 — 디자인 원본 MailReadLargeFiles: 높이 40 · 12.5px · 바탕 #F6F7F9 · 펼치기 #0A5F67).
|
||||
// 스크립트 없이 details · summary 로 열고 닫는다 — 접힌 동안은 "(접어 둠) — …" 와 「펼치기」, 펼치면 「접기」
|
||||
'details.ara-large-files{margin:0.875rem 0}',
|
||||
@@ -234,6 +239,18 @@ export interface MailBodySource {
|
||||
* 대용량 첨부 덩이를 접는다 (11단계 — 읽기 화면이 첨부 칸에 그 파일을 보일 때). 인쇄 화면은 접지 않는다(종이에는 주소가 있어야 한다)
|
||||
*/
|
||||
foldLargeFiles?: boolean
|
||||
/**
|
||||
* 글 본문(textBody)의 검색에서 맞은 곳 (14단계 — 검색 결과에서 연 메일의 searchHighlight.textBody). 글 본문을 보일 때만 <mark> 로 그린다 —
|
||||
* 서식 본문은 서버가 이미 맞은 글 조각에 <mark> 를 넣었다
|
||||
*/
|
||||
textHighlights?: readonly SearchRange[] | null
|
||||
}
|
||||
|
||||
/** 글 본문 → 이스케이프한 HTML — 맞은 곳은 <mark> 로 */
|
||||
function textBodyHtml(textBody: string, highlights: readonly SearchRange[] | null | undefined): string {
|
||||
return highlightSegments(textBody, highlights)
|
||||
.map((segment) => (segment.mark ? `<mark>${escapeHtml(segment.text)}</mark>` : escapeHtml(segment.text)))
|
||||
.join('')
|
||||
}
|
||||
|
||||
/** 이 화면의 출처 — 브라우저가 아니면 빈 글 (CSP 는 'self' 로 연다 — mailFrameCspImagesShown) */
|
||||
@@ -265,7 +282,7 @@ export function buildMailDocument(source: MailBodySource): string {
|
||||
const content =
|
||||
html !== null
|
||||
? sanitizeMailHtml(html.trim(), { imagesShown })
|
||||
: `<div class="mail-text">${escapeHtml(source.textBody ?? '')}</div>`
|
||||
: `<div class="mail-text">${textBodyHtml(source.textBody ?? '', source.textHighlights)}</div>`
|
||||
const csp = imagesShown ? mailFrameCspImagesShown(source.origin ?? currentOrigin()) : MAIL_FRAME_CSP
|
||||
|
||||
return [
|
||||
|
||||
@@ -93,3 +93,73 @@ describe('mail-list-source (보이는 목록의 출처)', () => {
|
||||
expect(exceedsSearchWords(' "회의를 잡겠습니다" ')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mail-list-source — 조건 검색 (14단계)', () => {
|
||||
const CONDITIONS = {
|
||||
from: '이도윤',
|
||||
to: '',
|
||||
subject: '',
|
||||
body: '',
|
||||
since: '2026-10-06',
|
||||
until: '2026-11-06',
|
||||
hasAttachment: true,
|
||||
}
|
||||
const WITH_CONDITIONS: MailListSource = { kind: 'search', q: '계약', scope: 'inbox', conditions: CONDITIONS }
|
||||
const CONDITIONS_ONLY: MailListSource = { kind: 'search', q: '', scope: SEARCH_SCOPE_ALL, conditions: CONDITIONS }
|
||||
|
||||
it('주소에 조건을 싣는다 — 검색어가 없으면 q 를 뺀다. 결과에서 연 메일의 주소도 같은 쿼리', () => {
|
||||
expect(listLocationOf(WITH_CONDITIONS)).toEqual({
|
||||
name: 'mail-search',
|
||||
query: {
|
||||
q: '계약',
|
||||
in: 'inbox',
|
||||
from: '이도윤',
|
||||
since: '2026-10-06',
|
||||
until: '2026-11-06',
|
||||
attachment: '1',
|
||||
},
|
||||
})
|
||||
expect(listLocationOf(CONDITIONS_ONLY)).toEqual({
|
||||
name: 'mail-search',
|
||||
query: { in: 'all', from: '이도윤', since: '2026-10-06', until: '2026-11-06', attachment: '1' },
|
||||
})
|
||||
expect(messageLocationOf(CONDITIONS_ONLY, 'm1')).toEqual({
|
||||
name: 'mail-search-message',
|
||||
params: { messageId: 'm1' },
|
||||
query: { in: 'all', from: '이도윤', since: '2026-10-06', until: '2026-11-06', attachment: '1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('주소 → 출처 — 검색어가 없어도 조건이 있으면 검색이다. 조건이 없으면 conditions 를 두지 않는다', () => {
|
||||
expect(
|
||||
searchSourceFromQuery({ in: 'all', from: '이도윤', since: '2026-10-06', until: '2026-11-06', attachment: '1' }),
|
||||
).toEqual(CONDITIONS_ONLY)
|
||||
expect(searchSourceFromQuery({ q: '회의', in: 'inbox', since: 'oops' })).toEqual({
|
||||
kind: 'search',
|
||||
q: '회의',
|
||||
scope: 'inbox',
|
||||
})
|
||||
expect(searchSourceFromQuery({ in: 'all', attachment: '0' })).toBeNull()
|
||||
})
|
||||
|
||||
it('API 쿼리 — 검색어 · 메일함 · 조건(날짜는 지역 0시의 UTC · 끝 날짜는 다음 날 0시)', () => {
|
||||
expect(listQueryOf(WITH_CONDITIONS)).toEqual({
|
||||
q: '계약',
|
||||
mailboxId: 'inbox',
|
||||
from: '이도윤',
|
||||
after: new Date(2026, 9, 6).toISOString(),
|
||||
before: new Date(2026, 10, 7).toISOString(),
|
||||
hasAttachment: 'true',
|
||||
})
|
||||
expect(listQueryOf(CONDITIONS_ONLY)).not.toHaveProperty('q')
|
||||
})
|
||||
|
||||
it('같은 목록인지 · 목록 키 — 조건까지 같아야 같다', () => {
|
||||
const plain: MailListSource = { kind: 'search', q: '계약', scope: 'inbox' }
|
||||
expect(sameListSource(WITH_CONDITIONS, plain)).toBe(false)
|
||||
expect(sameListSource(WITH_CONDITIONS, { ...WITH_CONDITIONS, conditions: { ...CONDITIONS } })).toBe(true)
|
||||
expect(sameListSource(plain, { ...plain, conditions: { ...CONDITIONS, from: '', since: null, until: null, hasAttachment: false } })).toBe(true)
|
||||
expect(listKeyOf(WITH_CONDITIONS)).not.toBe(listKeyOf(plain))
|
||||
expect(listKeyOf(plain)).toBe('search:inbox:계약')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
// 보이는 메일 목록의 출처 — 메일함 하나 · 검색 결과 · 예약됨(9단계) (순수 함수만 둔다)
|
||||
// 목록 · 표 · 읽기 화면 · 여러 통 처리는 출처를 몰라도 되게 이 함수들로 쿼리 · 주소를 만든다 (계약 2절 "검색" · "보내기 취소 · 예약 발송").
|
||||
|
||||
import type { LocationQuery, LocationQueryValue, RouteLocationRaw } from 'vue-router'
|
||||
import type { LocationQuery, RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import { MAIL_SEARCH_MAX_LENGTH, MAIL_SEARCH_MAX_WORDS, SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import { MAIL_SEARCH_MAX_WORDS, SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import type { MessageListQuery } from '@/shared/types/mail'
|
||||
import {
|
||||
countSearchWords,
|
||||
exceedsCombinedSearchWords,
|
||||
firstQueryValue,
|
||||
hasSearchConditions,
|
||||
normalizeSearchText,
|
||||
sameSearchConditions,
|
||||
searchConditionsFromRoute,
|
||||
searchConditionsKey,
|
||||
searchConditionsParams,
|
||||
searchConditionsRouteQuery,
|
||||
type SearchConditions,
|
||||
} from '@/shared/utils/mail-search'
|
||||
|
||||
export { normalizeSearchText } from '@/shared/utils/mail-search'
|
||||
|
||||
export interface MailboxListSource {
|
||||
kind: 'mailbox'
|
||||
@@ -13,10 +28,12 @@ export interface MailboxListSource {
|
||||
|
||||
export interface SearchListSource {
|
||||
kind: 'search'
|
||||
/** 앞뒤 공백을 뺀 검색어 */
|
||||
/** 앞뒤 공백을 뺀 검색어 — 조건만으로 찾으면 빈 글 (14단계) */
|
||||
q: string
|
||||
/** 찾을 곳 — 메일함 id 또는 전체(SEARCH_SCOPE_ALL) */
|
||||
scope: string
|
||||
/** 조건 (14단계 — 보낸 사람 · 받는 사람 · 제목 · 내용 · 기간 · 첨부). 없으면 검색어로만 찾는다 (조건이 없으면 두지 않는다 — searchSourceOf) */
|
||||
conditions?: SearchConditions
|
||||
}
|
||||
|
||||
/** 예약한 메일 — GET /scheduled-messages (보낼 시각 차례). 고르기 · 여러 통 처리는 없다 (디자인 README "9단계") */
|
||||
@@ -50,14 +67,20 @@ export function isScheduledRouteName(name: unknown): boolean {
|
||||
export function sameListSource(a: MailListSource | null, b: MailListSource | null): boolean {
|
||||
if (!a || !b) return a === b
|
||||
if (a.kind === 'mailbox' && b.kind === 'mailbox') return a.mailboxId === b.mailboxId
|
||||
if (a.kind === 'search' && b.kind === 'search') return a.q === b.q && a.scope === b.scope
|
||||
if (a.kind === 'search' && b.kind === 'search') {
|
||||
return a.q === b.q && a.scope === b.scope && sameSearchConditions(a.conditions, b.conditions)
|
||||
}
|
||||
return a.kind === 'scheduled' && b.kind === 'scheduled'
|
||||
}
|
||||
|
||||
/** GET /messages 쿼리 — 메일함은 mailboxId, 검색은 q (+ 한 메일함에서 찾으면 mailboxId) */
|
||||
export function listQueryOf(source: MessageListSource): Pick<MessageListQuery, 'mailboxId' | 'q'> {
|
||||
/** GET /messages 쿼리 — 메일함은 mailboxId, 검색은 q (+ 한 메일함에서 찾으면 mailboxId) + 조건(14단계 — 빈 칸은 넣지 않는다) */
|
||||
export function listQueryOf(source: MessageListSource): Omit<MessageListQuery, 'cursor' | 'limit'> {
|
||||
if (source.kind === 'mailbox') return { mailboxId: source.mailboxId }
|
||||
return source.scope === SEARCH_SCOPE_ALL ? { q: source.q } : { q: source.q, mailboxId: source.scope }
|
||||
return {
|
||||
...(source.q ? { q: source.q } : {}),
|
||||
...(source.scope === SEARCH_SCOPE_ALL ? {} : { mailboxId: source.scope }),
|
||||
...searchConditionsParams(source.conditions),
|
||||
}
|
||||
}
|
||||
|
||||
/** 출처가 가리키는 메일함 하나 — 메일함 목록이거나 한 메일함 안의 검색. 전체 메일함 검색 · 예약됨이면 null */
|
||||
@@ -80,12 +103,28 @@ export function isScheduledSource(source: MailListSource | null): boolean {
|
||||
/** 목록마다 다른 키 — 표 위치 기억 등 */
|
||||
export function listKeyOf(source: MailListSource): string {
|
||||
if (source.kind === 'scheduled') return 'scheduled'
|
||||
return source.kind === 'mailbox' ? `mailbox:${source.mailboxId}` : `search:${source.scope}:${source.q}`
|
||||
if (source.kind === 'mailbox') return `mailbox:${source.mailboxId}`
|
||||
const conditions = searchConditionsKey(source.conditions)
|
||||
return conditions ? `search:${source.scope}:${source.q}:${conditions}` : `search:${source.scope}:${source.q}`
|
||||
}
|
||||
|
||||
/** 검색 주소의 쿼리 — /mail/search?q=…&in=<메일함 id | all> */
|
||||
/**
|
||||
* 검색 주소의 쿼리 — /mail/search?q=…&in=<메일함 id | all> + 조건(14단계 — mail-search.ts 의 searchConditionsRouteQuery).
|
||||
* 검색어 없이 조건만으로 찾으면 q 를 뺀다
|
||||
*/
|
||||
export function searchRouteQuery(source: SearchListSource): Record<string, string> {
|
||||
return { q: source.q, in: source.scope }
|
||||
return {
|
||||
...(source.q ? { q: source.q } : {}),
|
||||
in: source.scope,
|
||||
...searchConditionsRouteQuery(source.conditions),
|
||||
}
|
||||
}
|
||||
|
||||
/** 검색 출처 — 조건이 없으면 conditions 를 두지 않는다 (같은 검색은 늘 같은 모양) */
|
||||
export function searchSourceOf(q: string, scope: string, conditions?: SearchConditions | null): SearchListSource {
|
||||
return conditions && hasSearchConditions(conditions)
|
||||
? { kind: 'search', q, scope, conditions }
|
||||
: { kind: 'search', q, scope }
|
||||
}
|
||||
|
||||
/** 목록 화면 주소 */
|
||||
@@ -104,32 +143,24 @@ export function messageLocationOf(source: MailListSource, messageId: string): Ro
|
||||
return { name: MAIL_SEARCH_MESSAGE_ROUTE, params: { messageId }, query: searchRouteQuery(source) }
|
||||
}
|
||||
|
||||
function firstValue(value: LocationQueryValue | LocationQueryValue[] | undefined): string {
|
||||
const first = Array.isArray(value) ? value[0] : value
|
||||
return typeof first === 'string' ? first : ''
|
||||
}
|
||||
|
||||
/** 앞 반쪽만 남은 서로게이트(이모지를 가운데서 자름) — 주소에 넣을 수 없다 (encodeURIComponent 가 실패한다) */
|
||||
const TRAILING_HIGH_SURROGATE = /[\uD800-\uDBFF]$/
|
||||
|
||||
/**
|
||||
* 검색어 다듬기 — 앞뒤 공백을 빼고 서버 한도(200자 — JS 문자열 길이, 입력칸 maxlength 와 같다)에서 자른다.
|
||||
* 이모지를 가운데서 자르게 되면 그 글자를 뺀다
|
||||
* 검색어와 조건 넷을 합친 낱말이 서버 한도(8개)를 넘는 검색인가 (14단계) — 검색 칸 · [조건]은 보내기 전에 막지만 손으로 고친 주소로 들어올 수 있다.
|
||||
* 목록 스토어는 묻지 않고(400 이 될 것이라), 목록 틀은 까닭을 보이고, 읽기 화면은 강조 쿼리를 싣지 않는다
|
||||
*/
|
||||
export function normalizeSearchText(text: string): string {
|
||||
const cut = text.trim().slice(0, MAIL_SEARCH_MAX_LENGTH)
|
||||
return (TRAILING_HIGH_SURROGATE.test(cut) ? cut.slice(0, -1) : cut).trim()
|
||||
export function isSearchTooLong(source: MailListSource | null | undefined): boolean {
|
||||
return source?.kind === 'search' && exceedsCombinedSearchWords(source.q, source.conditions)
|
||||
}
|
||||
|
||||
/** 서버가 받는 낱말 수를 넘는가 — 띄어쓰기로 나눈 조각을 센다 (따옴표와 상관없이, 서버와 같다) */
|
||||
export function exceedsSearchWords(text: string): boolean {
|
||||
return text.split(/\s+/u).filter((word) => word !== '').length > MAIL_SEARCH_MAX_WORDS
|
||||
return countSearchWords(text) > MAIL_SEARCH_MAX_WORDS
|
||||
}
|
||||
|
||||
/** 주소의 검색 쿼리 → 출처. 검색어가 없으면 null (범위가 비었으면 전체) */
|
||||
/** 주소의 검색 쿼리 → 출처. 검색어도 조건도 없으면 null (범위가 비었으면 전체) */
|
||||
export function searchSourceFromQuery(query: LocationQuery): SearchListSource | null {
|
||||
const q = normalizeSearchText(firstValue(query.q))
|
||||
if (!q) return null
|
||||
const scope = firstValue(query.in).trim()
|
||||
return { kind: 'search', q, scope: scope || SEARCH_SCOPE_ALL }
|
||||
const q = normalizeSearchText(firstQueryValue(query.q))
|
||||
const conditions = searchConditionsFromRoute(query)
|
||||
if (!q && !hasSearchConditions(conditions)) return null
|
||||
const scope = firstQueryValue(query.in).trim()
|
||||
return searchSourceOf(q, scope || SEARCH_SCOPE_ALL, conditions)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
combinedSearchWords,
|
||||
exceedsCombinedSearchWords,
|
||||
hasSearchConditions,
|
||||
isReversedPeriod,
|
||||
isSearchDay,
|
||||
messageHighlightKey,
|
||||
messageHighlightQueryOf,
|
||||
NO_SEARCH_CONDITIONS,
|
||||
normalizeSearchConditions,
|
||||
periodOf,
|
||||
periodRange,
|
||||
sameSearchConditions,
|
||||
searchConditionsFromRoute,
|
||||
searchConditionsKey,
|
||||
searchConditionsParams,
|
||||
searchConditionsRouteQuery,
|
||||
searchConditionsSummary,
|
||||
skipsBodySearch,
|
||||
type SearchConditions,
|
||||
} from './mail-search'
|
||||
|
||||
// 조건 검색 (14단계 — 계약 2절 "검색 — 14단계" · 디자인 README 「14단계 › 검색 · 조건 검색 · 강조」)
|
||||
// 날짜는 브라우저 시간대의 날짜다 — 기대값도 지역 시각(new Date(년, 월, 일))으로 만든다 (TZ=UTC · KST 모두 같은 답)
|
||||
|
||||
function conditions(partial: Partial<SearchConditions> = {}): SearchConditions {
|
||||
return { ...NO_SEARCH_CONDITIONS, ...partial }
|
||||
}
|
||||
|
||||
/** 지역 날짜 0시의 UTC ISO — 화면이 보내는 after · before 와 같은 방법 */
|
||||
const localMidnightIso = (year: number, month: number, day: number) => new Date(year, month - 1, day).toISOString()
|
||||
|
||||
describe('조건 다듬기 · 같은 조건', () => {
|
||||
it('글 칸은 검색어처럼(앞뒤 공백 · 200자), 날짜는 있는 날짜만, 첨부는 true 만', () => {
|
||||
expect(
|
||||
normalizeSearchConditions({
|
||||
from: ' 이도윤 ',
|
||||
subject: '가'.repeat(250),
|
||||
since: '2026-02-30',
|
||||
until: '2026-11-05',
|
||||
hasAttachment: true,
|
||||
}),
|
||||
).toEqual({
|
||||
from: '이도윤',
|
||||
to: '',
|
||||
subject: '가'.repeat(200),
|
||||
body: '',
|
||||
since: null,
|
||||
until: '2026-11-05',
|
||||
hasAttachment: true,
|
||||
})
|
||||
expect(normalizeSearchConditions(null)).toEqual(NO_SEARCH_CONDITIONS)
|
||||
expect(normalizeSearchConditions({ since: '2026-1-5' }).since).toBeNull()
|
||||
})
|
||||
|
||||
it('날짜는 1970-01-01 ~ 9999-12-30 안의 있는 날짜만 — 밖의 날짜는 그 날짜만 버린다', () => {
|
||||
expect(isSearchDay('1970-01-01')).toBe(true)
|
||||
expect(isSearchDay('9999-12-30')).toBe(true)
|
||||
expect(isSearchDay('1969-12-31')).toBe(false)
|
||||
// 9999-12-31 의 다음 날 0시는 10000년 — 서버가 받지 않는다
|
||||
expect(isSearchDay('9999-12-31')).toBe(false)
|
||||
expect(isSearchDay('10000-01-01')).toBe(false)
|
||||
expect(isSearchDay('2024-02-29')).toBe(true)
|
||||
expect(isSearchDay('2026-02-29')).toBe(false)
|
||||
expect(isSearchDay('')).toBe(false)
|
||||
expect(normalizeSearchConditions({ since: '1969-12-31', until: '2026-11-01' })).toEqual(
|
||||
conditions({ until: '2026-11-01' }),
|
||||
)
|
||||
expect(normalizeSearchConditions({ since: '2026-11-01', until: '9999-12-31' })).toEqual(
|
||||
conditions({ since: '2026-11-01' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('시작이 끝보다 늦은 기간은 통째로 버린다 — 같은 날은 그 하루', () => {
|
||||
expect(normalizeSearchConditions({ since: '2026-11-06', until: '2026-10-01', hasAttachment: true })).toEqual(
|
||||
conditions({ hasAttachment: true }),
|
||||
)
|
||||
expect(normalizeSearchConditions({ since: '2026-11-06', until: '2026-11-06' })).toEqual(
|
||||
conditions({ since: '2026-11-06', until: '2026-11-06' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('조건이 하나라도 있는가 · 같은 조건인가 — 없는 조건과 빈 조건은 같다', () => {
|
||||
expect(hasSearchConditions(null)).toBe(false)
|
||||
expect(hasSearchConditions(NO_SEARCH_CONDITIONS)).toBe(false)
|
||||
expect(hasSearchConditions(conditions({ hasAttachment: true }))).toBe(true)
|
||||
expect(hasSearchConditions(conditions({ until: '2026-11-05' }))).toBe(true)
|
||||
expect(sameSearchConditions(undefined, NO_SEARCH_CONDITIONS)).toBe(true)
|
||||
expect(sameSearchConditions(conditions({ from: '이도윤' }), conditions({ from: '이도윤' }))).toBe(true)
|
||||
expect(sameSearchConditions(conditions({ from: '이도윤' }), conditions({ to: '이도윤' }))).toBe(false)
|
||||
expect(searchConditionsKey(NO_SEARCH_CONDITIONS)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('화면 주소 ↔ 조건', () => {
|
||||
it('주소에는 걸린 칸만 싣는다 — from · to · subject · body · since · until · attachment=1', () => {
|
||||
const value = conditions({
|
||||
from: '이도윤',
|
||||
body: '계약 갱신',
|
||||
since: '2026-10-06',
|
||||
until: '2026-11-06',
|
||||
hasAttachment: true,
|
||||
})
|
||||
expect(searchConditionsRouteQuery(value)).toEqual({
|
||||
from: '이도윤',
|
||||
body: '계약 갱신',
|
||||
since: '2026-10-06',
|
||||
until: '2026-11-06',
|
||||
attachment: '1',
|
||||
})
|
||||
expect(searchConditionsRouteQuery(NO_SEARCH_CONDITIONS)).toEqual({})
|
||||
expect(searchConditionsFromRoute(searchConditionsRouteQuery(value))).toEqual(value)
|
||||
})
|
||||
|
||||
it('주소의 모르는 값 · 틀린 날짜는 없는 것으로 본다 — 같은 이름이 여럿이면 첫 것', () => {
|
||||
expect(
|
||||
searchConditionsFromRoute({
|
||||
from: [' 이도윤 ', '무시'],
|
||||
since: 'yesterday',
|
||||
until: '2026-13-01',
|
||||
attachment: 'yes',
|
||||
}),
|
||||
).toEqual(conditions({ from: '이도윤' }))
|
||||
expect(searchConditionsFromRoute({ attachment: '1' }).hasAttachment).toBe(true)
|
||||
})
|
||||
|
||||
it('손으로 고친 주소도 패널과 같은 검사를 거친다 — 거꾸로 된 기간 · 고를 수 없는 날짜는 버리고, 글 칸은 200자에서 자른다', () => {
|
||||
expect(searchConditionsFromRoute({ since: '2026-11-06', until: '2026-10-01', subject: '견적' })).toEqual(
|
||||
conditions({ subject: '견적' }),
|
||||
)
|
||||
expect(searchConditionsFromRoute({ since: '0999-01-01', until: '2026-11-01' })).toEqual(
|
||||
conditions({ until: '2026-11-01' }),
|
||||
)
|
||||
expect(searchConditionsFromRoute({ body: ` ${'가'.repeat(201)}` }).body).toBe('가'.repeat(200))
|
||||
// 낱말 수는 자르지 않는다 — 넘으면 목록이 찾지 않고 까닭을 보인다(exceedsCombinedSearchWords)
|
||||
const many = searchConditionsFromRoute({ body: '하나 둘 셋 넷 다섯 여섯 일곱 여덟 아홉' })
|
||||
expect(many.body).toBe('하나 둘 셋 넷 다섯 여섯 일곱 여덟 아홉')
|
||||
expect(exceedsCombinedSearchWords('', many)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('API 쿼리 · 읽기 화면의 강조 쿼리', () => {
|
||||
it('기간은 시작 날짜 0시 · 끝 날짜 다음 날 0시를 브라우저 시간대에서 UTC 로 — 첨부는 hasAttachment=true', () => {
|
||||
expect(
|
||||
searchConditionsParams(
|
||||
conditions({ to: 'ara.kim', subject: '견적', since: '2026-10-06', until: '2026-11-06', hasAttachment: true }),
|
||||
),
|
||||
).toEqual({
|
||||
to: 'ara.kim',
|
||||
subject: '견적',
|
||||
after: localMidnightIso(2026, 10, 6),
|
||||
before: localMidnightIso(2026, 11, 7),
|
||||
hasAttachment: 'true',
|
||||
})
|
||||
// 시작만 · 끝만도 된다
|
||||
expect(searchConditionsParams(conditions({ until: '2026-12-31' }))).toEqual({
|
||||
before: localMidnightIso(2027, 1, 1),
|
||||
})
|
||||
expect(searchConditionsParams(undefined)).toEqual({})
|
||||
})
|
||||
|
||||
it('읽기 화면에는 검색어와 글 칸 넷만 넘긴다 — 기간 · 첨부는 서버가 받지 않는다', () => {
|
||||
const value = conditions({ from: '이도윤', body: '계약', since: '2026-10-06', hasAttachment: true })
|
||||
expect(messageHighlightQueryOf('갱신', value)).toEqual({ q: '갱신', from: '이도윤', body: '계약' })
|
||||
expect(messageHighlightQueryOf('', conditions({ subject: '견적' }))).toEqual({ subject: '견적' })
|
||||
expect(messageHighlightQueryOf('', conditions({ since: '2026-10-06', hasAttachment: true }))).toBeNull()
|
||||
expect(messageHighlightKey(null)).toBe('')
|
||||
expect(messageHighlightKey({ q: '계약' })).not.toBe(messageHighlightKey({ subject: '계약' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('합친 낱말 수 — 검색어와 조건 넷을 합쳐 8개까지 (서버와 같은 기준)', () => {
|
||||
it('띄어쓰기로 나눈 조각을 칸마다 세어 더한다', () => {
|
||||
const value = conditions({ from: '이도윤', to: '김아라 박지훈', subject: '"계약 갱신"', body: '단가' })
|
||||
expect(combinedSearchWords('견적 회의', value)).toBe(8)
|
||||
expect(exceedsCombinedSearchWords('견적 회의', value)).toBe(false)
|
||||
expect(exceedsCombinedSearchWords('견적 회의 일정', value)).toBe(true)
|
||||
// 날짜 · 첨부는 낱말이 아니다
|
||||
expect(combinedSearchWords('', conditions({ since: '2026-10-06', hasAttachment: true }))).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('기간 고르기', () => {
|
||||
const today = new Date(2026, 10, 6, 15, 30)
|
||||
|
||||
it('최근 1주 · 1달 · 3달 · 1년은 그만큼 앞의 같은 날부터 오늘까지', () => {
|
||||
expect(periodRange('week', today)).toEqual({ since: '2026-10-30', until: '2026-11-06' })
|
||||
expect(periodRange('month', today)).toEqual({ since: '2026-10-06', until: '2026-11-06' })
|
||||
expect(periodRange('quarter', today)).toEqual({ since: '2026-08-06', until: '2026-11-06' })
|
||||
expect(periodRange('year', today)).toEqual({ since: '2025-11-06', until: '2026-11-06' })
|
||||
expect(periodRange('all', today)).toEqual({ since: null, until: null })
|
||||
expect(periodRange('custom', today)).toEqual({ since: null, until: null })
|
||||
})
|
||||
|
||||
it('달은 그 달의 마지막 날을 넘지 않는다 — 3월 31일의 1달 앞은 2월 28일(윤년이면 29일)', () => {
|
||||
expect(periodRange('month', new Date(2026, 2, 31)).since).toBe('2026-02-28')
|
||||
expect(periodRange('month', new Date(2028, 2, 31)).since).toBe('2028-02-29')
|
||||
expect(periodRange('quarter', new Date(2026, 4, 31)).since).toBe('2026-02-28')
|
||||
expect(periodRange('week', new Date(2026, 0, 3)).since).toBe('2025-12-27')
|
||||
})
|
||||
|
||||
it('두 날짜가 오늘 기준의 미리 정한 기간과 같으면 그 고르기, 아니면 날짜 고르기', () => {
|
||||
expect(periodOf(null, null, today)).toBe('all')
|
||||
expect(periodOf('2026-10-06', '2026-11-06', today)).toBe('month')
|
||||
expect(periodOf('2026-10-30', '2026-11-06', today)).toBe('week')
|
||||
// 다음 날 보면 같은 날짜도 날짜 고르기다
|
||||
expect(periodOf('2026-10-06', '2026-11-06', new Date(2026, 10, 7))).toBe('custom')
|
||||
expect(periodOf('2026-10-01', null, today)).toBe('custom')
|
||||
})
|
||||
|
||||
it('시작이 끝보다 늦으면 거꾸로다 — 한쪽만 있으면 아니다', () => {
|
||||
expect(isReversedPeriod('2026-11-05', '2026-10-01')).toBe(true)
|
||||
expect(isReversedPeriod('2026-10-01', '2026-10-01')).toBe(false)
|
||||
expect(isReversedPeriod('2026-11-05', null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('조건 줄 글', () => {
|
||||
const today = new Date(2026, 10, 6)
|
||||
|
||||
it('"조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" — 쉼표로 잇는다', () => {
|
||||
expect(
|
||||
searchConditionsSummary(
|
||||
conditions({ from: '이도윤', since: '2026-10-06', until: '2026-11-06', hasAttachment: true }),
|
||||
today,
|
||||
),
|
||||
).toBe('조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음')
|
||||
})
|
||||
|
||||
it('받는 사람 · 제목 · 내용은 조사를 붙인다 — 날짜 고르기는 날짜로(같은 해면 끝 날짜의 해를 뺀다)', () => {
|
||||
expect(
|
||||
searchConditionsSummary(
|
||||
conditions({ to: '김아라', subject: '견적', body: '계약서', since: '2026-10-01', until: '2026-11-05' }),
|
||||
today,
|
||||
),
|
||||
).toBe('조건: 김아라 님이 받은 메일, 제목에 ‘견적’이 든 메일, 내용에 ‘계약서’가 든 메일, 2026. 10. 1.부터 11. 5.까지')
|
||||
expect(searchConditionsSummary(conditions({ since: '2025-12-01', until: '2026-01-05' }), today)).toBe(
|
||||
'조건: 2025. 12. 1.부터 2026. 1. 5.까지',
|
||||
)
|
||||
expect(searchConditionsSummary(conditions({ since: '2026-10-01' }), today)).toBe('조건: 2026. 10. 1.부터')
|
||||
expect(searchConditionsSummary(conditions({ until: '2026-10-31' }), today)).toBe('조건: 2026. 10. 31.까지')
|
||||
expect(searchConditionsSummary(NO_SEARCH_CONDITIONS, today)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('한 글자 안내 — 본문을 찾을 말이 한 글자뿐일 때만', () => {
|
||||
it('검색어나 내용 조건이 모두 한 글자면 그렇다', () => {
|
||||
expect(skipsBodySearch('김', NO_SEARCH_CONDITIONS)).toBe(true)
|
||||
expect(skipsBodySearch('', conditions({ body: '가' }))).toBe(true)
|
||||
expect(skipsBodySearch('김', conditions({ from: '이' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('두 글자 이상 낱말이 어느 칸에든 있거나, 본문을 찾을 말이 없으면 아니다', () => {
|
||||
expect(skipsBodySearch('김 계약', NO_SEARCH_CONDITIONS)).toBe(false)
|
||||
expect(skipsBodySearch('김', conditions({ subject: '견적' }))).toBe(false)
|
||||
// 보낸 사람 · 제목 조건만이면 본문은 원래 찾지 않는다
|
||||
expect(skipsBodySearch('', conditions({ from: '김' }))).toBe(false)
|
||||
expect(skipsBodySearch('', conditions({ subject: '가', since: '2026-10-01' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,306 @@
|
||||
// 조건 검색 (14단계 — 계약 2절 "검색 — 14단계" · 디자인 README 「14단계 › 검색 · 조건 검색 · 강조」) — 순수 함수만 둔다.
|
||||
// 조건 다듬기 · 화면 주소 ↔ 조건 · API 쿼리 · 읽기 화면의 강조 쿼리 · 합친 낱말 수 · 기간 고르기 · 조건 줄 글.
|
||||
// 검색 결과의 출처(검색어 · 범위 · 조건)는 mail-list-source.ts 가 이것으로 만든다.
|
||||
|
||||
import type { LocationQuery, LocationQueryValue } from 'vue-router'
|
||||
|
||||
import { MAIL_SEARCH_MAX_LENGTH, MAIL_SEARCH_MAX_WORDS } from '@/shared/constants/mail'
|
||||
import {
|
||||
SEARCH_ATTACHMENT_ROUTE_VALUE,
|
||||
SEARCH_CONDITION_LINE_TEXT,
|
||||
SEARCH_DAY_MAX,
|
||||
SEARCH_DAY_MIN,
|
||||
SEARCH_HAS_ATTACHMENT_VALUE,
|
||||
SEARCH_PERIOD_LABELS,
|
||||
SEARCH_PERIOD_PRESETS,
|
||||
SEARCH_ROUTE_PARAMS,
|
||||
type SearchPeriod,
|
||||
type SearchPeriodPreset,
|
||||
} from '@/shared/constants/mail-search'
|
||||
import type { MessageHighlightQuery, MessageListQuery } from '@/shared/types/mail'
|
||||
import { formatDate } from '@/shared/utils/format'
|
||||
import { subjectParticle } from '@/shared/utils/korean'
|
||||
import { endOfDayIso, startOfDayIso } from '@/shared/utils/mail-vacation'
|
||||
import { isSingleCharSearch, parseSearchTerms, SEARCH_WORD_SEPARATOR } from '@/shared/utils/search-match'
|
||||
|
||||
/** 검색 칸 밖의 조건 — 글 칸은 비면 없는 것, 기간은 브라우저 시간대의 날짜(YYYY-MM-DD — 두 날짜 모두 든다) */
|
||||
export interface SearchConditions {
|
||||
/** 보낸 사람(이름 · 주소) */
|
||||
from: string
|
||||
/** 받는 사람과 참조 */
|
||||
to: string
|
||||
subject: string
|
||||
/** 본문 */
|
||||
body: string
|
||||
/** 시작 날짜 — 그날 0시부터. 없으면 null */
|
||||
since: string | null
|
||||
/** 끝 날짜 — 그날이 끝날 때까지(다음 날 0시 앞). 없으면 null */
|
||||
until: string | null
|
||||
hasAttachment: boolean
|
||||
}
|
||||
|
||||
/** 글을 넣는 조건 칸 — q 와 같은 검사(200자 · 낱말 수)를 받는다 */
|
||||
export const SEARCH_CONDITION_TEXT_FIELDS = ['from', 'to', 'subject', 'body'] as const
|
||||
export type SearchConditionTextField = (typeof SEARCH_CONDITION_TEXT_FIELDS)[number]
|
||||
|
||||
export const NO_SEARCH_CONDITIONS: Readonly<SearchConditions> = Object.freeze({
|
||||
from: '',
|
||||
to: '',
|
||||
subject: '',
|
||||
body: '',
|
||||
since: null,
|
||||
until: null,
|
||||
hasAttachment: false,
|
||||
})
|
||||
|
||||
/** 앞 반쪽만 남은 서로게이트(이모지를 가운데서 자름) — 주소에 넣을 수 없다 (encodeURIComponent 가 실패한다) */
|
||||
const TRAILING_HIGH_SURROGATE = /[\uD800-\uDBFF]$/
|
||||
/** 날짜 칸의 값 — <input type="date"> 와 같은 모양 */
|
||||
const DAY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/
|
||||
|
||||
/**
|
||||
* 검색 글 다듬기 — 앞뒤 공백을 빼고 서버 한도(200자 — JS 문자열 길이, 입력칸 maxlength 와 같다)에서 자른다.
|
||||
* 이모지를 가운데서 자르게 되면 그 글자를 뺀다. 검색어 · 조건 칸이 함께 쓴다
|
||||
*/
|
||||
export function normalizeSearchText(text: string): string {
|
||||
const cut = text.trim().slice(0, MAIL_SEARCH_MAX_LENGTH)
|
||||
return (TRAILING_HIGH_SURROGATE.test(cut) ? cut.slice(0, -1) : cut).trim()
|
||||
}
|
||||
|
||||
/** 낱말 수 — 띄어쓰기로 나눈 조각 (따옴표와 상관없이, 서버의 countSearchWords 와 같다) */
|
||||
export function countSearchWords(text: string): number {
|
||||
return text.split(SEARCH_WORD_SEPARATOR).filter((word) => word !== '').length
|
||||
}
|
||||
|
||||
/**
|
||||
* 찾을 수 있는 날짜(YYYY-MM-DD)인가 — 2월 30일처럼 넘어간 날짜 · 다른 모양 · SEARCH_DAY_MIN ~ SEARCH_DAY_MAX 밖은 아니다
|
||||
* (9999-12-31 은 UTC 의 다음 날 0시가 10000년이라 서버가 받지 않는다 — 어느 시간대에서도 보낼 수 있는 범위만)
|
||||
*/
|
||||
export function isSearchDay(value: string): boolean {
|
||||
return DAY_PATTERN.test(value) && value >= SEARCH_DAY_MIN && value <= SEARCH_DAY_MAX && startOfDayIso(value) !== null
|
||||
}
|
||||
|
||||
function dayOrNull(value: string | null | undefined): string | null {
|
||||
return typeof value === 'string' && isSearchDay(value) ? value : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건 다듬기 — 글 칸은 검색어처럼, 날짜는 찾을 수 있는 날짜만(isSearchDay), 첨부는 true 만.
|
||||
* 시작이 끝보다 늦은 기간(손으로 고친 주소)은 버린다 — 아무것도 맞지 않는 검색이 되지 않게 (패널은 보내기 전에 칸 아래에 알린다)
|
||||
*/
|
||||
export function normalizeSearchConditions(input: Partial<SearchConditions> | null | undefined): SearchConditions {
|
||||
const since = dayOrNull(input?.since)
|
||||
const until = dayOrNull(input?.until)
|
||||
const reversed = since !== null && until !== null && since > until
|
||||
return {
|
||||
from: normalizeSearchText(input?.from ?? ''),
|
||||
to: normalizeSearchText(input?.to ?? ''),
|
||||
subject: normalizeSearchText(input?.subject ?? ''),
|
||||
body: normalizeSearchText(input?.body ?? ''),
|
||||
since: reversed ? null : since,
|
||||
until: reversed ? null : until,
|
||||
hasAttachment: input?.hasAttachment === true,
|
||||
}
|
||||
}
|
||||
|
||||
/** 조건이 하나라도 있는가 */
|
||||
export function hasSearchConditions(conditions: SearchConditions | null | undefined): boolean {
|
||||
if (!conditions) return false
|
||||
return (
|
||||
SEARCH_CONDITION_TEXT_FIELDS.some((field) => conditions[field] !== '') ||
|
||||
conditions.since !== null ||
|
||||
conditions.until !== null ||
|
||||
conditions.hasAttachment
|
||||
)
|
||||
}
|
||||
|
||||
/** 조건의 열쇠 — 같은 조건이면 같다. 조건이 없으면 빈 글 (목록 키 · 같은 검색인지 · 읽기 화면을 다시 받을지) */
|
||||
export function searchConditionsKey(conditions: SearchConditions | null | undefined): string {
|
||||
if (!hasSearchConditions(conditions) || !conditions) return ''
|
||||
return JSON.stringify([
|
||||
conditions.from,
|
||||
conditions.to,
|
||||
conditions.subject,
|
||||
conditions.body,
|
||||
conditions.since,
|
||||
conditions.until,
|
||||
conditions.hasAttachment,
|
||||
])
|
||||
}
|
||||
|
||||
export function sameSearchConditions(
|
||||
a: SearchConditions | null | undefined,
|
||||
b: SearchConditions | null | undefined,
|
||||
): boolean {
|
||||
return searchConditionsKey(a) === searchConditionsKey(b)
|
||||
}
|
||||
|
||||
/** 화면 주소의 조건 쿼리 — 빈 칸은 넣지 않는다 (/mail/search?q=…&in=…&from=…&since=2026-10-06&attachment=1) */
|
||||
export function searchConditionsRouteQuery(conditions: SearchConditions | null | undefined): Record<string, string> {
|
||||
const query: Record<string, string> = {}
|
||||
if (!conditions) return query
|
||||
for (const field of SEARCH_CONDITION_TEXT_FIELDS) {
|
||||
if (conditions[field]) query[SEARCH_ROUTE_PARAMS[field]] = conditions[field]
|
||||
}
|
||||
if (conditions.since) query[SEARCH_ROUTE_PARAMS.since] = conditions.since
|
||||
if (conditions.until) query[SEARCH_ROUTE_PARAMS.until] = conditions.until
|
||||
if (conditions.hasAttachment) query[SEARCH_ROUTE_PARAMS.attachment] = SEARCH_ATTACHMENT_ROUTE_VALUE
|
||||
return query
|
||||
}
|
||||
|
||||
/** 주소 쿼리의 첫 값 — 같은 이름이 여럿이면 첫 것, 글이 아니면 빈 글 */
|
||||
export function firstQueryValue(value: LocationQueryValue | LocationQueryValue[] | undefined): string {
|
||||
const first = Array.isArray(value) ? value[0] : value
|
||||
return typeof first === 'string' ? first : ''
|
||||
}
|
||||
|
||||
/** 주소 쿼리 → 조건 (모르는 값 · 틀린 날짜는 없는 것으로) */
|
||||
export function searchConditionsFromRoute(query: LocationQuery): SearchConditions {
|
||||
return normalizeSearchConditions({
|
||||
from: firstQueryValue(query[SEARCH_ROUTE_PARAMS.from]),
|
||||
to: firstQueryValue(query[SEARCH_ROUTE_PARAMS.to]),
|
||||
subject: firstQueryValue(query[SEARCH_ROUTE_PARAMS.subject]),
|
||||
body: firstQueryValue(query[SEARCH_ROUTE_PARAMS.body]),
|
||||
since: firstQueryValue(query[SEARCH_ROUTE_PARAMS.since]) || null,
|
||||
until: firstQueryValue(query[SEARCH_ROUTE_PARAMS.until]) || null,
|
||||
hasAttachment: firstQueryValue(query[SEARCH_ROUTE_PARAMS.attachment]) === SEARCH_ATTACHMENT_ROUTE_VALUE,
|
||||
})
|
||||
}
|
||||
|
||||
/** API 쿼리의 조건 — 날짜는 브라우저 시간대의 시작 날짜 0시 · 끝 날짜 다음 날 0시를 UTC 로 (부재중과 같은 방식 — mail-vacation.ts) */
|
||||
export type SearchConditionParams = Pick<
|
||||
MessageListQuery,
|
||||
'from' | 'to' | 'subject' | 'body' | 'after' | 'before' | 'hasAttachment'
|
||||
>
|
||||
|
||||
export function searchConditionsParams(conditions: SearchConditions | null | undefined): SearchConditionParams {
|
||||
const params: SearchConditionParams = {}
|
||||
if (!conditions) return params
|
||||
for (const field of SEARCH_CONDITION_TEXT_FIELDS) {
|
||||
if (conditions[field]) params[field] = conditions[field]
|
||||
}
|
||||
const after = conditions.since ? startOfDayIso(conditions.since) : null
|
||||
const before = conditions.until ? endOfDayIso(conditions.until) : null
|
||||
if (after) params.after = after
|
||||
if (before) params.before = before
|
||||
if (conditions.hasAttachment) params.hasAttachment = SEARCH_HAS_ATTACHMENT_VALUE
|
||||
return params
|
||||
}
|
||||
|
||||
/**
|
||||
* 읽기 화면에 넘기는 강조 쿼리 — 검색어와 글 조건 넷만(기간 · 첨부는 서버가 받지 않는다). 빈 칸은 빼고, 하나도 없으면 null
|
||||
*/
|
||||
export function messageHighlightQueryOf(
|
||||
q: string,
|
||||
conditions: SearchConditions | null | undefined,
|
||||
): MessageHighlightQuery | null {
|
||||
const query: MessageHighlightQuery = {}
|
||||
if (q) query.q = q
|
||||
for (const field of SEARCH_CONDITION_TEXT_FIELDS) {
|
||||
const value = conditions?.[field] ?? ''
|
||||
if (value) query[field] = value
|
||||
}
|
||||
return Object.keys(query).length > 0 ? query : null
|
||||
}
|
||||
|
||||
/** 강조 쿼리의 열쇠 — 같은 쿼리로 받은 본문인지 본다 (없으면 빈 글) */
|
||||
export function messageHighlightKey(query: MessageHighlightQuery | null | undefined): string {
|
||||
if (!query) return ''
|
||||
return JSON.stringify([query.q ?? '', query.from ?? '', query.to ?? '', query.subject ?? '', query.body ?? ''])
|
||||
}
|
||||
|
||||
/** 검색어와 조건 넷을 합친 낱말 수 — 서버 한도(8개)와 같은 기준 */
|
||||
export function combinedSearchWords(q: string, conditions: SearchConditions | null | undefined): number {
|
||||
let total = countSearchWords(q)
|
||||
for (const field of SEARCH_CONDITION_TEXT_FIELDS) total += countSearchWords(conditions?.[field] ?? '')
|
||||
return total
|
||||
}
|
||||
|
||||
export function exceedsCombinedSearchWords(q: string, conditions: SearchConditions | null | undefined): boolean {
|
||||
return combinedSearchWords(q, conditions) > MAIL_SEARCH_MAX_WORDS
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 글자 낱말뿐이라 본문을 찾지 않은 검색인가 (색인 방식 — 서버와 같은 판정, search-match.ts 의 isSingleCharSearch).
|
||||
* 본문을 찾을 말(검색어나 내용 조건)이 있을 때만 그렇다 — 보낸 사람 · 받는 사람 · 제목 조건만이면 본문은 원래 찾지 않는다
|
||||
*/
|
||||
export function skipsBodySearch(q: string, conditions: SearchConditions | null | undefined): boolean {
|
||||
const body = conditions?.body ?? ''
|
||||
if (parseSearchTerms(q).length === 0 && parseSearchTerms(body).length === 0) return false
|
||||
return isSingleCharSearch([q, conditions?.from ?? '', conditions?.to ?? '', conditions?.subject ?? '', body])
|
||||
}
|
||||
|
||||
// ── 기간 고르기 ─────────────────────────────────────────────
|
||||
|
||||
/** 오늘에서 그만큼 앞의 같은 날 — 달은 그 달의 마지막 날을 넘지 않는다(3월 31일의 1달 앞은 2월 28 · 29일) */
|
||||
function shiftBack(today: Date, preset: SearchPeriodPreset): Date {
|
||||
const year = today.getFullYear()
|
||||
const month = today.getMonth() - (preset.months ?? 0)
|
||||
const lastDay = new Date(year, month + 1, 0).getDate()
|
||||
const day = Math.min(today.getDate(), lastDay) - (preset.days ?? 0)
|
||||
return new Date(year, month, day)
|
||||
}
|
||||
|
||||
/** 미리 정한 기간의 두 날짜 — 그만큼 앞의 같은 날부터 오늘까지. 전체 기간 · 날짜 고르기는 날짜가 없다 */
|
||||
export function periodRange(period: SearchPeriod, today: Date): { since: string | null; until: string | null } {
|
||||
const preset = SEARCH_PERIOD_PRESETS.find((candidate) => candidate.id === period)
|
||||
if (!preset) return { since: null, until: null }
|
||||
return { since: formatDate(shiftBack(today, preset)), until: formatDate(today) }
|
||||
}
|
||||
|
||||
/** 두 날짜가 어떤 기간 고르기인가 — 둘 다 없으면 전체 기간, 오늘 기준의 미리 정한 기간과 같으면 그것, 아니면 날짜 고르기 */
|
||||
export function periodOf(since: string | null, until: string | null, today: Date): SearchPeriod {
|
||||
if (since === null && until === null) return 'all'
|
||||
const preset = SEARCH_PERIOD_PRESETS.find((candidate) => {
|
||||
const range = periodRange(candidate.id, today)
|
||||
return range.since === since && range.until === until
|
||||
})
|
||||
return preset?.id ?? 'custom'
|
||||
}
|
||||
|
||||
/** 시작 날짜가 끝 날짜보다 늦은가 — 그러면 아무것도 맞지 않는다 (찾기 전에 막는다) */
|
||||
export function isReversedPeriod(since: string | null, until: string | null): boolean {
|
||||
return since !== null && until !== null && since > until
|
||||
}
|
||||
|
||||
// ── 조건 줄 글 ───────────────────────────────────────────────
|
||||
|
||||
/** "2026. 10. 1." — withYear 가 아니면 "10. 1." */
|
||||
function periodDayText(day: string, withYear: boolean): string {
|
||||
const match = DAY_PATTERN.exec(day)
|
||||
if (!match) return day
|
||||
const [year, month, date] = [Number(match[1]), Number(match[2]), Number(match[3])]
|
||||
return withYear ? `${year}. ${month}. ${date}.` : `${month}. ${date}.`
|
||||
}
|
||||
|
||||
function periodText(conditions: SearchConditions, today: Date): string | null {
|
||||
const { since, until } = conditions
|
||||
const period = periodOf(since, until, today)
|
||||
if (period === 'all') return null
|
||||
if (period !== 'custom') return SEARCH_PERIOD_LABELS[period]
|
||||
if (since !== null && until !== null) {
|
||||
const sameYear = since.slice(0, 4) === until.slice(0, 4)
|
||||
return SEARCH_CONDITION_LINE_TEXT.range(periodDayText(since, true), periodDayText(until, !sameYear))
|
||||
}
|
||||
if (since !== null) return SEARCH_CONDITION_LINE_TEXT.since(periodDayText(since, true))
|
||||
return until !== null ? SEARCH_CONDITION_LINE_TEXT.until(periodDayText(until, true)) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 결과 머리 아래 조건 줄 — "조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" (쉼표로 잇는다 — 꼬리표 · 알약을 쓰지 않는다).
|
||||
* 기간은 오늘 기준의 미리 정한 기간과 같으면 그 이름, 아니면 날짜. 조건이 없으면 빈 글
|
||||
*/
|
||||
export function searchConditionsSummary(conditions: SearchConditions | null | undefined, today: Date): string {
|
||||
if (!conditions || !hasSearchConditions(conditions)) return ''
|
||||
const parts: string[] = []
|
||||
if (conditions.from) parts.push(SEARCH_CONDITION_LINE_TEXT.from(conditions.from))
|
||||
if (conditions.to) parts.push(SEARCH_CONDITION_LINE_TEXT.to(conditions.to))
|
||||
if (conditions.subject) {
|
||||
parts.push(SEARCH_CONDITION_LINE_TEXT.subject(conditions.subject, subjectParticle(conditions.subject)))
|
||||
}
|
||||
if (conditions.body) parts.push(SEARCH_CONDITION_LINE_TEXT.body(conditions.body, subjectParticle(conditions.body)))
|
||||
const period = periodText(conditions, today)
|
||||
if (period) parts.push(period)
|
||||
if (conditions.hasAttachment) parts.push(SEARCH_CONDITION_LINE_TEXT.attachment)
|
||||
return `${SEARCH_CONDITION_LINE_TEXT.prefix}${parts.join(SEARCH_CONDITION_LINE_TEXT.separator)}`
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { highlightSegments } from './search-highlight'
|
||||
|
||||
// 검색 강조 조각 (14단계 — 서버가 준 [시작, 끝) UTF-16 위치, NFC 글 기준)
|
||||
|
||||
const marked = (text: string, ranges: readonly (readonly [number, number])[] | null) =>
|
||||
highlightSegments(text, ranges)
|
||||
.map((segment) => (segment.mark ? `[${segment.text}]` : segment.text))
|
||||
.join('')
|
||||
|
||||
describe('highlightSegments', () => {
|
||||
it('맞은 곳이 없으면 글 한 조각 — 빈 글이면 조각이 없다', () => {
|
||||
expect(highlightSegments('계약서 초안', null)).toEqual([{ text: '계약서 초안', mark: false }])
|
||||
expect(highlightSegments('계약서 초안', [])).toEqual([{ text: '계약서 초안', mark: false }])
|
||||
expect(highlightSegments('', null)).toEqual([])
|
||||
})
|
||||
|
||||
it('여러 범위를 차례로 자른다 — 앞 · 끝 · 맞닿은 곳도', () => {
|
||||
expect(marked('Re: 유지보수 계약 갱신 건', [[12, 14], [4, 8]])).toBe('Re: [유지보수] 계약 [갱신] 건')
|
||||
expect(marked('계약서', [[0, 2]])).toBe('[계약]서')
|
||||
expect(marked('견적서와 계약', [[5, 7]])).toBe('견적서와 [계약]')
|
||||
expect(marked('계약서', [[0, 3]])).toBe('[계약서]')
|
||||
})
|
||||
|
||||
it('겹치거나 맞닿은 범위는 합친다', () => {
|
||||
expect(marked('aaaa', [[0, 2], [1, 3]])).toBe('[aaa]a')
|
||||
expect(marked('계약서', [[0, 2], [2, 3]])).toBe('[계약서]')
|
||||
})
|
||||
|
||||
it('글 밖 · 거꾸로 · 정수가 아닌 위치는 버리고, 끝이 글을 넘으면 글 끝에서 자른다', () => {
|
||||
expect(marked('계약', [[5, 7]])).toBe('계약')
|
||||
expect(marked('계약', [[1, 1], [2, 1], [-1, 1], [0.5, 1]])).toBe('계약')
|
||||
expect(marked('계약', [[1, 10]])).toBe('계[약]')
|
||||
})
|
||||
|
||||
it('맞은 곳이 있으면 NFC 로 맞춘 글을 자른다 — 다른 정규형(NFD)의 같은 글이어도 위치가 맞다', () => {
|
||||
const nfd = '계약서'.normalize('NFD')
|
||||
expect(nfd.length).toBeGreaterThan(3)
|
||||
expect(marked(nfd, [[0, 2]])).toBe('[계약]서')
|
||||
// 맞은 곳이 없으면 글 그대로
|
||||
expect(highlightSegments(nfd, null)[0]?.text).toBe(nfd)
|
||||
})
|
||||
|
||||
it('BMP 밖 글자 뒤의 위치도 UTF-16 그대로 쓴다', () => {
|
||||
expect(marked('😀계약', [[2, 4]])).toBe('😀[계약]')
|
||||
})
|
||||
|
||||
it('위치가 서로게이트 쌍(이모지)의 가운데를 가르면 그 글자를 통째로 넣는다 — 반쪽 글자를 만들지 않는다', () => {
|
||||
// 😀 는 UTF-16 두 칸(0 · 1)
|
||||
expect(marked('😀계약', [[1, 4]])).toBe('[😀계약]')
|
||||
expect(marked('😀계약', [[0, 1]])).toBe('[😀]계약')
|
||||
expect(marked('계😀약', [[2, 3]])).toBe('계[😀]약')
|
||||
expect(marked('계😀약', [[0, 2]])).toBe('[계😀]약')
|
||||
// 가른 자리를 넓힌 뒤 맞닿으면 합친다
|
||||
expect(marked('😀😀', [[0, 1], [3, 4]])).toBe('[😀😀]')
|
||||
// 반쪽 글자(짝 없는 서로게이트)가 있으면 encodeURIComponent 가 URIError 를 던진다
|
||||
const segments = highlightSegments('a😀b😀c', [[2, 5]])
|
||||
expect(segments.map((segment) => segment.text)).toEqual(['a', '😀b😀', 'c'])
|
||||
for (const segment of segments) expect(() => encodeURIComponent(segment.text)).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
// 검색 강조 — 서버가 준 맞은 곳(위치)으로 글을 조각낸다 (순수 함수만 둔다 — 계약 2절 "검색 — 14단계").
|
||||
// 화면은 조각을 v-html 없이 <mark> 로 그린다(HighlightedText.vue). 본문 틀(iframe)의 글 본문도 같은 조각을 이스케이프해 넣는다(mail-html.ts).
|
||||
|
||||
import type { SearchRange } from '@/shared/types/mail'
|
||||
|
||||
/** 그릴 조각 — mark 면 맞은 말 */
|
||||
export interface HighlightSegment {
|
||||
text: string
|
||||
mark: boolean
|
||||
}
|
||||
|
||||
/** 그 자리가 서로게이트 쌍(이모지 등 BMP 밖 글자)의 가운데다 */
|
||||
function splitsPair(text: string, at: number): boolean {
|
||||
if (at <= 0 || at >= text.length) return false
|
||||
const before = text.charCodeAt(at - 1)
|
||||
const after = text.charCodeAt(at)
|
||||
return before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff
|
||||
}
|
||||
|
||||
/**
|
||||
* 쓸 수 있는 위치만 — 정수 · 0 이상 · 시작 < 끝, 끝은 글 길이에서 자른다. 시작 차례로 놓고 겹치거나 맞닿은 것은 합친다.
|
||||
* 위치가 서로게이트 쌍을 가르면 그 글자를 통째로 넣는다 — 반쪽 글자가 깨져 보이지 않게
|
||||
*/
|
||||
function usableRanges(ranges: readonly SearchRange[], text: string): [number, number][] {
|
||||
const length = text.length
|
||||
const valid: [number, number][] = []
|
||||
for (const [start, end] of ranges) {
|
||||
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || start >= length || end <= start) continue
|
||||
const from = splitsPair(text, start) ? start - 1 : start
|
||||
const to = Math.min(end, length)
|
||||
valid.push([from, splitsPair(text, to) ? to + 1 : to])
|
||||
}
|
||||
valid.sort((a, b) => a[0] - b[0] || a[1] - b[1])
|
||||
const merged: [number, number][] = []
|
||||
for (const range of valid) {
|
||||
const last = merged[merged.length - 1]
|
||||
if (last && range[0] <= last[1]) last[1] = Math.max(last[1], range[1])
|
||||
else merged.push(range)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* 글 → 조각 — 맞은 곳이 없으면 글 한 조각(빈 글이면 조각 없음).
|
||||
* 서버는 NFC 로 맞춘 글의 위치를 준다 — 맞은 곳이 있으면 글을 NFC 로 맞춘 뒤 자른다(목록 맞추기가 다른 정규형의 같은 글로 덮어도 어긋나지 않게).
|
||||
* 글 밖 · 거꾸로 된 위치는 버린다
|
||||
*/
|
||||
export function highlightSegments(text: string, ranges: readonly SearchRange[] | null | undefined): HighlightSegment[] {
|
||||
if (!ranges || ranges.length === 0) return text ? [{ text, mark: false }] : []
|
||||
const source = text.normalize('NFC')
|
||||
const segments: HighlightSegment[] = []
|
||||
let cursor = 0
|
||||
for (const [start, end] of usableRanges(ranges, source)) {
|
||||
if (start > cursor) segments.push({ text: source.slice(cursor, start), mark: false })
|
||||
segments.push({ text: source.slice(start, end), mark: true })
|
||||
cursor = end
|
||||
}
|
||||
if (cursor < source.length) segments.push({ text: source.slice(cursor), mark: false })
|
||||
return segments
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { FOLD_SAMPLES, GRAM_SAMPLES, MATCH_SAMPLES, SUBJECT_BASE_SAMPLES } from '@/test-utils/search-text-samples'
|
||||
import {
|
||||
MAX_MATCH_RANGES,
|
||||
foldText,
|
||||
gramsOf,
|
||||
isSingleCharSearch,
|
||||
isSpaceCode,
|
||||
matchRanges,
|
||||
parseSearchTerms,
|
||||
subjectBase,
|
||||
} from './search-match'
|
||||
|
||||
// 서버와 같은 표본 표(backend test/search-text-samples.ts — 행과 순서까지 그대로)로 본다 — 계약 2절 "검색 — 14단계"
|
||||
|
||||
describe('foldText (검색의 접기 — 짝: backend search-text.ts)', () => {
|
||||
it.each(FOLD_SAMPLES)('%j → %j', (input, folded) => {
|
||||
expect(foldText(input)).toBe(folded)
|
||||
})
|
||||
|
||||
it('접은 글은 NFC 원문과 UTF-16 길이가 늘 같다 — 서버가 준 위치를 원문에 그대로 쓴다', () => {
|
||||
for (const [input] of FOLD_SAMPLES) {
|
||||
expect(foldText(input).length).toBe(input.normalize('NFC').length)
|
||||
}
|
||||
const changedLength: number[] = []
|
||||
for (let code = 0; code <= 0xffff; code += 1) {
|
||||
if (code >= 0xd800 && code <= 0xdfff) continue
|
||||
const char = String.fromCharCode(code).normalize('NFC')
|
||||
if (foldText(char).length !== char.length) changedLength.push(code)
|
||||
}
|
||||
expect(changedLength).toEqual([])
|
||||
})
|
||||
|
||||
it('두 번 접어도 같다 · 짝이 없는 서로게이트는 그대로 둔다', () => {
|
||||
for (const [input, folded] of FOLD_SAMPLES) {
|
||||
expect(foldText(folded)).toBe(folded)
|
||||
expect(foldText(foldText(input))).toBe(folded)
|
||||
}
|
||||
expect(foldText('a\ud800B')).toBe('a\ud800b')
|
||||
expect(foldText('\udc00A')).toBe('\udc00a')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gramsOf (글자 두 개 묶음)', () => {
|
||||
it.each(GRAM_SAMPLES)('%j → %j', (fold, grams) => {
|
||||
expect([...gramsOf(fold)]).toEqual(grams)
|
||||
})
|
||||
|
||||
it('공백은 정규식 \\s 와 같은 글자다 (낱말 나누기와 같다)', () => {
|
||||
const differ: number[] = []
|
||||
for (let code = 0; code <= 0xffff; code += 1) {
|
||||
if (isSpaceCode(code) !== /\s/u.test(String.fromCharCode(code))) differ.push(code)
|
||||
}
|
||||
expect(differ).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchRanges (맞은 곳)', () => {
|
||||
it.each(MATCH_SAMPLES)('%j 에서 %j → %j', (original, terms, ranges) => {
|
||||
expect(matchRanges(original, terms)).toEqual(ranges)
|
||||
})
|
||||
|
||||
it('맞은 곳은 limit 개까지만 모은다', () => {
|
||||
expect(matchRanges('가'.repeat(5_000), ['가'])).toEqual([[0, MAX_MATCH_RANGES]])
|
||||
expect(matchRanges('가 '.repeat(10), ['가'], 3)).toEqual([
|
||||
[0, 1],
|
||||
[2, 3],
|
||||
[4, 5],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('subjectBase (말머리를 뗀 제목 — 짝: backend subject-base.ts)', () => {
|
||||
it.each(SUBJECT_BASE_SAMPLES)('%j → %j', (subject, base) => {
|
||||
expect(subjectBase(subject)).toBe(base)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseSearchTerms (검색어 나누기 — 짝: backend search-query.ts)', () => {
|
||||
it('띄어쓰기로 낱말을 나눈다 — 여러 칸 · 전각 공백 · 줄바꿈도 한 번의 띄어쓰기', () => {
|
||||
expect(parseSearchTerms(` 서버 이전\u3000일정\n공유 `)).toEqual([
|
||||
{ kind: 'word', text: '서버' },
|
||||
{ kind: 'word', text: '이전' },
|
||||
{ kind: 'word', text: '일정' },
|
||||
{ kind: 'word', text: '공유' },
|
||||
])
|
||||
})
|
||||
|
||||
it('큰따옴표 안은 구절 하나다 — 짝이 없는 마지막 따옴표는 없는 것으로 본다', () => {
|
||||
expect(parseSearchTerms('회의 "일정 공유" 서버')).toEqual([
|
||||
{ kind: 'word', text: '회의' },
|
||||
{ kind: 'phrase', text: '일정 공유' },
|
||||
{ kind: 'word', text: '서버' },
|
||||
])
|
||||
expect(parseSearchTerms('회의 "일정 공유').map((term) => term.text)).toEqual(['회의', '일정', '공유'])
|
||||
expect(parseSearchTerms('"a b" "c')).toEqual([
|
||||
{ kind: 'phrase', text: 'a b' },
|
||||
{ kind: 'word', text: 'c' },
|
||||
])
|
||||
})
|
||||
|
||||
it('문자 · 숫자가 없는 낱말 · 구절은 버리고, 낱말 앞뒤의 문장부호는 뗀다 — 같은 낱말은 한 번', () => {
|
||||
for (const query of ['-', '…', '- … !!', '"- …"', '😀', '" "', '""']) {
|
||||
expect(parseSearchTerms(query)).toEqual([])
|
||||
}
|
||||
expect(parseSearchTerms('회의, (회의) 회의. “회의” [공지] 2026. 회의!').map((term) => term.text)).toEqual([
|
||||
'회의',
|
||||
'공지',
|
||||
'2026',
|
||||
])
|
||||
expect(parseSearchTerms('"회의, 일정."')).toEqual([{ kind: 'phrase', text: '회의, 일정.' }])
|
||||
expect(parseSearchTerms('회의 회의 "일정" "일정" 일정')).toEqual([
|
||||
{ kind: 'word', text: '회의' },
|
||||
{ kind: 'phrase', text: '일정' },
|
||||
{ kind: 'word', text: '일정' },
|
||||
])
|
||||
})
|
||||
|
||||
it('NFC 로 모으고 NUL 은 U+FFFD 로 바꾼다 (서버의 색인과 같다)', () => {
|
||||
expect(parseSearchTerms('\u1112\u1161\u11ab\u1100\u1173\u11af')).toEqual([{ kind: 'word', text: '한글' }])
|
||||
expect(parseSearchTerms(`계\u0000약`)).toEqual([{ kind: 'word', text: '계\ufffd약' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSingleCharSearch (한 글자 낱말뿐인 검색 — 서버가 본문을 찾지 않는다)', () => {
|
||||
it('낱말이 모두 한 글자면 그렇다 — 검색어 · 조건 칸을 모두 합쳐 본다', () => {
|
||||
expect(isSingleCharSearch(['김'])).toBe(true)
|
||||
expect(isSingleCharSearch(['김 a 3'])).toBe(true)
|
||||
expect(isSingleCharSearch(['김', '', '', '', '박'])).toBe(true)
|
||||
// 한 글자 낱말로만 된 구절도 묶음이 없다
|
||||
expect(isSingleCharSearch(['"가 나"'])).toBe(true)
|
||||
})
|
||||
|
||||
it('두 글자 이상 낱말이 하나라도 있으면 아니다 — 다른 칸에 있어도', () => {
|
||||
expect(isSingleCharSearch(['계약'])).toBe(false)
|
||||
expect(isSingleCharSearch(['김 계약'])).toBe(false)
|
||||
expect(isSingleCharSearch(['김', '', '', '견적', ''])).toBe(false)
|
||||
expect(isSingleCharSearch(['"가나"'])).toBe(false)
|
||||
})
|
||||
|
||||
it('찾을 낱말이 없으면(빈 글 · 문장부호뿐) 아니다', () => {
|
||||
expect(isSingleCharSearch([])).toBe(false)
|
||||
expect(isSingleCharSearch(['', ' '])).toBe(false)
|
||||
expect(isSingleCharSearch(['- …'])).toBe(false)
|
||||
// 앞뒤 문장부호를 떼면 한 글자다 — 서버와 같게 뗀 뒤 센다
|
||||
expect(isSingleCharSearch(['(김)'])).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,328 @@
|
||||
// 메일 검색의 낱말 나누기 · 접기(정규화) · 글자 두 개 묶음 · 맞은 곳 · 말머리 떼기 (순수 함수만 둔다 — 계약 2절 "검색 — 14단계")
|
||||
//
|
||||
// ⚠️ backend 와 **짝**이다 — `src/shared/utils/search-query.ts`(낱말 나누기) · `search-text.ts`(접기 · 묶음 · 맞은 곳) ·
|
||||
// `subject-base.ts`(말머리 떼기). 같은 표본 표(frontend `src/test-utils/search-text-samples.ts` · backend `test/search-text-samples.ts` —
|
||||
// 행과 순서까지 그대로)를 두 쪽 테스트에 둔다. 한쪽을 고치면 둘 다 고친다.
|
||||
// 화면은 이것으로 "한 글자 낱말뿐이라 본문을 찾지 않은 검색"을 알아본다(isSingleCharSearch — 결과 머리의 한 줄).
|
||||
// 서버는 그 사실을 응답에 싣지 않는다 — 서버와 같은 낱말 나누기 · 묶음으로 같은 답을 낸다. 강조할 곳은 서버가 준다(searchHighlight).
|
||||
//
|
||||
// 접기(foldText)는 먼저 NFC 로 맞춘 뒤 **코드 포인트 하나씩** 바꾼다 — 전각 ASCII(! ~ ~) → 반각, 소문자(결과가 코드 포인트 하나이고
|
||||
// UTF-16 길이가 같을 때만 — 'İ' 처럼 늘어나면 그대로), 공백류(줄바꿈 · 탭 · NBSP · U+3000 등 정규식 `\s` 의 글자) → 띄어쓰기(U+0020) 하나.
|
||||
// 그래서 NFC 원문과 접은 글은 코드 포인트 · UTF-16 위치가 같다. NFKC 는 쓰지 않는다(㈜ → (주) 처럼 길이를 바꿔 위치를 잃는다).
|
||||
// 모든 함수는 입력을 한 번(맞은 곳은 낱말마다 한 번) 훑는다 — 길이에 비례한다.
|
||||
|
||||
// ── 낱말 나누기 (짝: backend search-query.ts) ─────────────────────────────
|
||||
|
||||
/** 검색어의 낱말을 나누는 기준 — 공백 문자(전각 공백 · 줄바꿈 포함). 낱말 수 · 서버의 엔진 조건 · 색인 찾기가 같은 기준이다 */
|
||||
export const SEARCH_WORD_SEPARATOR = /\s+/u
|
||||
|
||||
/** 큰따옴표 — 감싼 부분은 구절로 찾는다 */
|
||||
const QUOTE = '"'
|
||||
|
||||
/** NUL(U+0000) — 서버는 색인과 같게 U+FFFD 로 1:1 바꿔 찾는다 */
|
||||
const NUL = String.fromCharCode(0)
|
||||
const REPLACEMENT_CHARACTER = String.fromCharCode(0xfffd)
|
||||
|
||||
/** 찾을 수 있는 글자 — 문자(\p{L})나 숫자(\p{N}). 하나도 없는 낱말 · 구절(`-` · `…` · 그림 문자뿐)은 버린다 */
|
||||
const SEARCHABLE_CHARACTER = /[\p{L}\p{N}]/u
|
||||
|
||||
/**
|
||||
* 낱말 앞뒤에 붙은, 문자 · 숫자가 아닌 글자 — 떼고 가운데 낱말로 찾는다('회의,' · '(회의)' → '회의').
|
||||
* 뒤쪽은 "문자 · 숫자 · 결합 문자가 아닌 글자 + 그 뒤의 결합 문자"를 뗀다 — 글자에 붙은 결합 문자는 남기고, 그림 문자와 그 이체 선택자는 함께 뗀다
|
||||
*/
|
||||
const WORD_EDGE_PUNCTUATION = /^[^\p{L}\p{N}]+|(?:[^\p{L}\p{N}\p{M}]\p{M}*)+$/gu
|
||||
|
||||
/** 검색어를 나눈 조각 — 낱말 · 구절(큰따옴표 안) */
|
||||
export interface SearchTerm {
|
||||
readonly kind: 'word' | 'phrase'
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
function coreWord(word: string): string {
|
||||
return word.replace(WORD_EDGE_PUNCTUATION, '')
|
||||
}
|
||||
|
||||
function splitWords(text: string): string[] {
|
||||
return text.split(SEARCH_WORD_SEPARATOR).filter((word) => word !== '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색어를 낱말 · 구절로 나눈다 — 나온 순서대로, 같은 낱말 · 같은 구절은 한 번만 (서버와 같다).
|
||||
* 큰따옴표 한 쌍 안은 구절(안의 여러 칸 공백은 한 칸으로), 짝이 없는 마지막 큰따옴표는 없는 것으로 본다.
|
||||
* 낱말 앞뒤의 문장부호는 떼고, 문자 · 숫자가 하나도 없는 낱말 · 구절은 버린다. NFC 로 맞추고 NUL 은 U+FFFD 로 바꾼다
|
||||
*/
|
||||
export function parseSearchTerms(query: string): SearchTerm[] {
|
||||
const segments = query.normalize('NFC').replaceAll(NUL, REPLACEMENT_CHARACTER).split(QUOTE)
|
||||
const terms: SearchTerm[] = []
|
||||
const seen = new Set<string>()
|
||||
const add = (kind: SearchTerm['kind'], text: string) => {
|
||||
const key = `${kind}\n${text}`
|
||||
if (!SEARCHABLE_CHARACTER.test(text) || seen.has(key)) return
|
||||
seen.add(key)
|
||||
terms.push({ kind, text })
|
||||
}
|
||||
|
||||
segments.forEach((segment, index) => {
|
||||
// 홀수 번째 조각은 따옴표 안이다 — 뒤에 닫는 따옴표가 있을 때만 (마지막 조각이면 짝이 없다)
|
||||
const isPhrase = index % 2 === 1 && index < segments.length - 1
|
||||
if (isPhrase) {
|
||||
add('phrase', splitWords(segment).join(' '))
|
||||
return
|
||||
}
|
||||
for (const word of splitWords(segment)) add('word', coreWord(word))
|
||||
})
|
||||
return terms
|
||||
}
|
||||
|
||||
// ── 접기 · 묶음 · 맞은 곳 (짝: backend search-text.ts) ────────────────────
|
||||
|
||||
/** 전각 ASCII 의 범위와 반각과의 차이 (U+FF01 ! ~ U+FF5E ~ → U+0021 ~ U+007E) */
|
||||
const FULLWIDTH_FIRST = 0xff01
|
||||
const FULLWIDTH_LAST = 0xff5e
|
||||
const FULLWIDTH_OFFSET = 0xfee0
|
||||
|
||||
const ASCII_UPPER_A = 0x41
|
||||
const ASCII_UPPER_Z = 0x5a
|
||||
const ASCII_CASE_OFFSET = 0x20
|
||||
const ASCII_LIMIT = 0x80
|
||||
|
||||
/** 소문자가 따로 없는 큰 묶음 — 한글 음절 · 자모, CJK 한자 · 기호 (toLowerCase 를 부르지 않고 건너뛴다) */
|
||||
const HANGUL_JAMO_FIRST = 0x1100
|
||||
const HANGUL_JAMO_LAST = 0x11ff
|
||||
const CJK_FIRST = 0x2e80
|
||||
const CJK_LAST = 0x9fff
|
||||
const HANGUL_SYLLABLE_FIRST = 0xac00
|
||||
const HANGUL_SYLLABLE_LAST = 0xd7a3
|
||||
|
||||
const HIGH_SURROGATE_FIRST = 0xd800
|
||||
const HIGH_SURROGATE_LAST = 0xdbff
|
||||
const LOW_SURROGATE_FIRST = 0xdc00
|
||||
const LOW_SURROGATE_LAST = 0xdfff
|
||||
const BMP_LAST = 0xffff
|
||||
|
||||
/** 맞은 곳을 이만큼까지만 모은다 — 글이 길고 낱말이 흔하면(한 글자) 끝없이 늘지 않게 */
|
||||
export const MAX_MATCH_RANGES = 500
|
||||
|
||||
/** 맞은 곳 — [시작, 끝) UTF-16 위치 */
|
||||
export type MatchRange = readonly [start: number, end: number]
|
||||
|
||||
/** 공백류를 바꾼 글자 — 띄어쓰기 하나 */
|
||||
const SPACE = ' '
|
||||
const SPACE_CODE = 0x20
|
||||
|
||||
function isHighSurrogate(code: number): boolean {
|
||||
return code >= HIGH_SURROGATE_FIRST && code <= HIGH_SURROGATE_LAST
|
||||
}
|
||||
|
||||
function isLowSurrogate(code: number): boolean {
|
||||
return code >= LOW_SURROGATE_FIRST && code <= LOW_SURROGATE_LAST
|
||||
}
|
||||
|
||||
/** 소문자가 따로 없는 글자인가 (빠른 길 — 한글 · 한자) */
|
||||
function hasNoCase(code: number): boolean {
|
||||
return (
|
||||
(code >= HANGUL_SYLLABLE_FIRST && code <= HANGUL_SYLLABLE_LAST) ||
|
||||
(code >= CJK_FIRST && code <= CJK_LAST) ||
|
||||
(code >= HANGUL_JAMO_FIRST && code <= HANGUL_JAMO_LAST)
|
||||
)
|
||||
}
|
||||
|
||||
/** ASCII 한 글자(코드)의 접기 — 대문자만 소문자로 */
|
||||
function foldAsciiCode(code: number): number {
|
||||
return code >= ASCII_UPPER_A && code <= ASCII_UPPER_Z ? code + ASCII_CASE_OFFSET : code
|
||||
}
|
||||
|
||||
/** ASCII 밖 한 코드 포인트의 접기 — 바뀌지 않으면 null. `char` 는 그 코드 포인트의 글(UTF-16 한두 단위) */
|
||||
function foldCodePoint(code: number, char: string): string | null {
|
||||
if (code >= FULLWIDTH_FIRST && code <= FULLWIDTH_LAST) {
|
||||
return String.fromCharCode(foldAsciiCode(code - FULLWIDTH_OFFSET))
|
||||
}
|
||||
if (hasNoCase(code)) return null
|
||||
const lower = char.toLowerCase()
|
||||
if (lower === char || lower.length !== char.length) return null
|
||||
// 같은 UTF-16 길이여도 코드 포인트가 둘이 될 수 있다(BMP 두 글자) — 하나일 때만
|
||||
const first = lower.codePointAt(0) ?? 0
|
||||
const firstWidth = first > BMP_LAST ? 2 : 1
|
||||
return firstWidth === lower.length ? lower : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 공백 글자인가 — 낱말 나누기 · 묶음의 경계 (JS 정규식 `\s` 와 같은 글자들: 띄어쓰기 · 탭 · 줄바꿈 · NBSP · 전각 공백 · U+2000~U+200A 등)
|
||||
*/
|
||||
export function isSpaceCode(code: number): boolean {
|
||||
if (code <= 0x20) {
|
||||
return code === 0x20 || (code >= 0x09 && code <= 0x0d)
|
||||
}
|
||||
if (code < 0xa0) return false
|
||||
return (
|
||||
code === 0xa0 ||
|
||||
code === 0x1680 ||
|
||||
(code >= 0x2000 && code <= 0x200a) ||
|
||||
code === 0x2028 ||
|
||||
code === 0x2029 ||
|
||||
code === 0x202f ||
|
||||
code === 0x205f ||
|
||||
code === 0x3000 ||
|
||||
code === 0xfeff
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 찾기 · 색인의 접기 글 — NFC 뒤 코드 포인트 하나씩: 전각 ASCII → 반각, 소문자, 공백류 → 띄어쓰기.
|
||||
* 결과는 `text.normalize('NFC')` 와 UTF-16 길이 · 위치가 같다
|
||||
*/
|
||||
export function foldText(text: string): string {
|
||||
const source = text.normalize('NFC')
|
||||
let folded = ''
|
||||
let runStart = 0
|
||||
for (let index = 0; index < source.length; ) {
|
||||
const code = source.charCodeAt(index)
|
||||
let width = 1
|
||||
let point = code
|
||||
if (isHighSurrogate(code) && index + 1 < source.length && isLowSurrogate(source.charCodeAt(index + 1))) {
|
||||
width = 2
|
||||
point = source.codePointAt(index) ?? code
|
||||
}
|
||||
let replacement: string | null
|
||||
if (isSpaceCode(code)) {
|
||||
// 공백류는 모두 BMP 의 한 단위다 — 띄어쓰기 하나로 1:1
|
||||
replacement = code === SPACE_CODE ? null : SPACE
|
||||
} else if (code < ASCII_LIMIT) {
|
||||
const lowered = foldAsciiCode(code)
|
||||
replacement = lowered === code ? null : String.fromCharCode(lowered)
|
||||
} else {
|
||||
replacement = foldCodePoint(point, source.slice(index, index + width))
|
||||
}
|
||||
if (replacement !== null) {
|
||||
folded += source.slice(runStart, index) + replacement
|
||||
runStart = index + width
|
||||
}
|
||||
index += width
|
||||
}
|
||||
return runStart === 0 ? source : folded + source.slice(runStart)
|
||||
}
|
||||
|
||||
/**
|
||||
* 글자 두 개 묶음 — 공백이 아닌 이웃한 코드 포인트 둘을 한 묶음으로, 나온 차례대로 겹치지 않게(같은 묶음은 한 번).
|
||||
* 접은 글(foldText)에 쓴다. 공백을 넘지 않는다 — "회의 일정" → 회의 · 일정. 한 글자 낱말은 묶음이 없다
|
||||
*/
|
||||
export function gramsOf(fold: string, into: Set<string> = new Set()): Set<string> {
|
||||
let previous: string | null = null
|
||||
for (let index = 0; index < fold.length; ) {
|
||||
const code = fold.charCodeAt(index)
|
||||
const width = isHighSurrogate(code) && index + 1 < fold.length && isLowSurrogate(fold.charCodeAt(index + 1)) ? 2 : 1
|
||||
if (isSpaceCode(code)) {
|
||||
previous = null
|
||||
} else {
|
||||
const char = width === 1 ? fold.charAt(index) : fold.slice(index, index + 2)
|
||||
if (previous !== null) into.add(previous + char)
|
||||
previous = char
|
||||
}
|
||||
index += width
|
||||
}
|
||||
return into
|
||||
}
|
||||
|
||||
/** 맞은 곳들을 시작 차례로 놓고 겹치거나 맞닿은 것을 합친다 */
|
||||
function mergeRanges(ranges: MatchRange[]): MatchRange[] {
|
||||
ranges.sort((a, b) => a[0] - b[0] || a[1] - b[1])
|
||||
const merged: [number, number][] = []
|
||||
for (const [start, end] of ranges) {
|
||||
const last = merged[merged.length - 1]
|
||||
if (last && start <= last[1]) {
|
||||
if (end > last[1]) last[1] = end
|
||||
} else {
|
||||
merged.push([start, end])
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/** 찾는 말들을 접는다 — 같은 말은 한 번, 빈 말은 뺀다 */
|
||||
export function foldTerms(terms: readonly string[]): string[] {
|
||||
return [...new Set(terms.map(foldText))].filter((term) => term !== '')
|
||||
}
|
||||
|
||||
/** matchRanges 의 접은 글 판 — `haystack` 은 foldText 한 글, `terms` 는 foldTerms 한 말 */
|
||||
export function matchFoldedRanges(
|
||||
haystack: string,
|
||||
terms: readonly string[],
|
||||
limit: number = MAX_MATCH_RANGES,
|
||||
): MatchRange[] {
|
||||
const found: MatchRange[] = []
|
||||
for (const term of terms) {
|
||||
if (term === '') continue
|
||||
let count = 0
|
||||
for (let at = haystack.indexOf(term); at >= 0 && count < limit; at = haystack.indexOf(term, at + 1)) {
|
||||
found.push([at, at + term.length])
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return mergeRanges(found).slice(0, limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* 맞은 곳 — 원문(`original` — NFC 여야 위치가 맞는다)에서 찾는 말들(`terms` — 접기 전 글)이 나오는 자리를 모두 찾아
|
||||
* 겹치거나 맞닿은 것을 합친 [시작, 끝) 목록(UTF-16, 시작 차례). 같은 접기 · 부분 일치다('aa' 는 'aaa' 의 [0, 3))
|
||||
*/
|
||||
export function matchRanges(original: string, terms: readonly string[], limit: number = MAX_MATCH_RANGES): MatchRange[] {
|
||||
return matchFoldedRanges(foldText(original), foldTerms(terms), limit)
|
||||
}
|
||||
|
||||
// ── 말머리 떼기 (짝: backend subject-base.ts) ────────────────────────────
|
||||
|
||||
/** 앞의 말머리 하나 — 답장 · 전달 말머리 또는 대괄호 꼬리표, 뒤의 공백까지 (접은 글 — 소문자 · 반각) */
|
||||
const LEADING_PREFIX = /(?:(?:re|fwd|fw|aw|wg|회신|답장|전달) ?(?:\[\d{1,4}\]|\(\d{1,4}\))? ?:|\[[^\]]*\]) ?/uy
|
||||
|
||||
/** 공백 글자의 이어짐을 띄어쓰기 하나로 · 앞뒤 공백 빼기 */
|
||||
function collapseSpaces(text: string): string {
|
||||
let collapsed = ''
|
||||
let pendingSpace = false
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
if (isSpaceCode(text.charCodeAt(index))) {
|
||||
pendingSpace = collapsed !== ''
|
||||
continue
|
||||
}
|
||||
if (pendingSpace) collapsed += ' '
|
||||
pendingSpace = false
|
||||
collapsed += text.charAt(index)
|
||||
}
|
||||
return collapsed
|
||||
}
|
||||
|
||||
/**
|
||||
* 말머리를 뗀 제목의 접기 글 — 같은 대화인지 볼 때 제목을 견주는 글이다(대화 묶기 — 서버가 묶는다).
|
||||
* 접기 → 공백을 하나로 · 앞뒤 공백 빼기 → 앞의 말머리(Re · Fw · Fwd · AW · WG · 회신 · 답장 · 전달 · 대괄호 꼬리표)를 여러 겹 뗀다
|
||||
*/
|
||||
export function subjectBase(subject: string): string {
|
||||
const text = collapseSpaces(foldText(subject))
|
||||
// 붙은 자리(sticky)에서만 맞춘다 — 뗄 때마다 글을 자르지 않고 자리만 옮긴다
|
||||
let position = 0
|
||||
for (;;) {
|
||||
LEADING_PREFIX.lastIndex = position
|
||||
const prefix = LEADING_PREFIX.exec(text)
|
||||
if (!prefix || prefix[0] === '') break
|
||||
position += prefix[0].length
|
||||
}
|
||||
return text.slice(position).trim()
|
||||
}
|
||||
|
||||
// ── 화면이 쓰는 판정 ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 한 글자 낱말뿐인 검색인가 — 검색어 · 조건 칸의 낱말 · 구절을 모두 접어도 글자 두 개 묶음이 하나도 없으면 그렇다(서버의 search-plan 과 같은 판정).
|
||||
* 그때 서버(색인 방식)는 본문을 찾지 않고 제목 · 사람 · 첨부 이름에서만 찾는다 (계약 2절 "검색 — 14단계" › 한 글자 낱말).
|
||||
* `texts` 는 검색어 q 와 조건 넷(보낸 사람 · 받는 사람 · 제목 · 내용)의 글 — 빈 글은 없는 것이다
|
||||
*/
|
||||
export function isSingleCharSearch(texts: readonly string[]): boolean {
|
||||
let terms = 0
|
||||
const grams = new Set<string>()
|
||||
for (const text of texts) {
|
||||
for (const term of parseSearchTerms(text)) {
|
||||
terms += 1
|
||||
gramsOf(foldText(term.text), grams)
|
||||
if (grams.size > 0) return false
|
||||
}
|
||||
}
|
||||
return terms > 0
|
||||
}
|
||||
@@ -55,6 +55,8 @@ function page(items: MessageSummary[], nextCursor: string | null = null, total:
|
||||
}
|
||||
|
||||
const ids = (list: readonly { id: string }[]) => list.map((item) => item.id)
|
||||
/** 목록 요청만 — 검색을 시작할 때 묻는 색인 상태(GET /search-index — 14단계)는 뺀다 */
|
||||
const listRequests = (requests: MockRequest[]) => requests.filter((request) => request.url === '/messages')
|
||||
const patches = (requests: MockRequest[]) =>
|
||||
requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data as MessagesPatch)
|
||||
|
||||
@@ -94,12 +96,12 @@ describe('mail.store — 3단계 목록', () => {
|
||||
const store = useMailStore()
|
||||
|
||||
await store.openSearch('회의', 'inbox')
|
||||
expect(requests[0]?.params).toEqual({ q: '회의', mailboxId: 'inbox', limit: 20 })
|
||||
expect(listRequests(requests)[0]?.params).toEqual({ q: '회의', mailboxId: 'inbox', limit: 20 })
|
||||
expect(store.search).toEqual({ kind: 'search', q: '회의', scope: 'inbox' })
|
||||
expect(store.currentMailboxId).toBe('inbox')
|
||||
|
||||
await store.openSearch('회의', SEARCH_SCOPE_ALL)
|
||||
expect(requests[1]?.params).toEqual({ q: '회의', limit: 20 })
|
||||
expect(listRequests(requests)[1]?.params).toEqual({ q: '회의', limit: 20 })
|
||||
expect(store.currentMailboxId).toBeNull()
|
||||
expect(ids(store.messages)).toEqual(['r1'])
|
||||
})
|
||||
@@ -110,24 +112,28 @@ describe('mail.store — 3단계 목록', () => {
|
||||
|
||||
await store.openSearch('회의', SEARCH_SCOPE_ALL)
|
||||
await store.openSearch('회의', SEARCH_SCOPE_ALL)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(listRequests(requests)).toHaveLength(1)
|
||||
|
||||
await store.openSearch('회의', SEARCH_SCOPE_ALL, { force: true })
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(listRequests(requests)).toHaveLength(2)
|
||||
expect(store.listStatus).toBe('ready')
|
||||
})
|
||||
|
||||
it('더 보기는 검색어 · 범위와 커서를 함께 보낸다', async () => {
|
||||
const first = Array.from({ length: 20 }, (_, index) => summary(`r${index + 1}`))
|
||||
const requests = installMockApi(({ params }) =>
|
||||
params.cursor ? page([summary('r2')]) : page([summary('r1')], 'c2', 2),
|
||||
params.cursor ? page([summary('r21')]) : page(first, 'c2', 21),
|
||||
)
|
||||
const store = useMailStore()
|
||||
await store.openSearch('회의', 'sent')
|
||||
// 첫 쪽이 다 찼으면 이어 묻지 않는다
|
||||
expect(listRequests(requests)).toHaveLength(1)
|
||||
|
||||
await store.loadMore()
|
||||
|
||||
expect(requests[1]?.params).toEqual({ q: '회의', mailboxId: 'sent', cursor: 'c2', limit: 20 })
|
||||
expect(ids(store.messages)).toEqual(['r1', 'r2'])
|
||||
expect(listRequests(requests)[1]?.params).toEqual({ q: '회의', mailboxId: 'sent', cursor: 'c2', limit: 20 })
|
||||
expect(store.messages).toHaveLength(21)
|
||||
expect(store.messages.at(-1)?.id).toBe('r21')
|
||||
})
|
||||
|
||||
it('메일함으로 돌아가면 보이던 결과를 버리고 그 메일함을 새로 연다', async () => {
|
||||
@@ -186,11 +192,11 @@ describe('mail.store — 3단계 목록', () => {
|
||||
await store.openSearch('회의', SEARCH_SCOPE_ALL)
|
||||
|
||||
await store.syncVisibleList()
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(listRequests(requests)).toHaveLength(1)
|
||||
|
||||
await store.syncVisibleList({ includeSearch: true })
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[1]?.params).toEqual({ q: '회의', limit: 20 })
|
||||
expect(listRequests(requests)).toHaveLength(2)
|
||||
expect(listRequests(requests)[1]?.params).toEqual({ q: '회의', limit: 20 })
|
||||
})
|
||||
|
||||
it('실패해도 알리지 않고 목록을 그대로 둔다', async () => {
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import { MAIL_SEARCH_FILL_MAX_REQUESTS } from '@/shared/constants/mail-search'
|
||||
import type { MessageDetail, MessageListItem, MessageSummary } from '@/shared/types/mail'
|
||||
import { NO_SEARCH_CONDITIONS } from '@/shared/utils/mail-search'
|
||||
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 { useMailStore } from './mail.store'
|
||||
|
||||
// 14단계 검색 — 이어 찾기 · 지난 메일 색인 상태 · 읽기 화면의 강조 쿼리 (계약 2절 "검색 — 14단계")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [],
|
||||
subject: `계약 ${id}`,
|
||||
preview: '',
|
||||
receivedAt: '2026-11-05T01:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function item(id: string, partial: Partial<MessageListItem> = {}): MessageListItem {
|
||||
return { ...summary(id), ...partial }
|
||||
}
|
||||
|
||||
function detail(id: string, partial: Partial<MessageDetail> = {}): MessageDetail {
|
||||
return {
|
||||
...summary(id),
|
||||
cc: [],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
sentAt: null,
|
||||
messageId: null,
|
||||
references: [],
|
||||
htmlBody: '<p>본문</p>',
|
||||
textBody: '본문',
|
||||
hasBlockedImages: false,
|
||||
attachments: [],
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function page(items: MessageListItem[], nextCursor: string | null, total: number | null = null): MockResult {
|
||||
return ok({ items, pageInfo: { hasNext: nextCursor !== null, nextCursor, total } })
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve: (value: MockResult) => void = () => undefined
|
||||
const promise = new Promise<MockResult>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
const listRequests = (requests: MockRequest[]) => requests.filter((request) => request.url === '/messages')
|
||||
const ids = (list: readonly { id: string }[]) => list.map((entry) => entry.id)
|
||||
|
||||
describe('mail.store — 14단계 검색', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
describe('이어 찾기 — hasNext 인데 항목이 적거나 0개인 쪽 (색인 방식)', () => {
|
||||
it('빈 쪽이면 커서로 이어 묻는다 — 찾을 때까지 "찾는 중", 한 쪽이 차면 멈춘다', async () => {
|
||||
const full = Array.from({ length: 20 }, (_, index) => item(`r${index + 1}`))
|
||||
const second = deferred()
|
||||
const requests = installMockApi(({ url, params }) => {
|
||||
if (url === '/search-index') return ok({ status: 'ready', done: 10, total: 10 })
|
||||
if (!params.cursor) return page([], 'c2', null)
|
||||
if (params.cursor === 'c2') return second.promise
|
||||
return page([], null)
|
||||
})
|
||||
const store = useMailStore()
|
||||
|
||||
const opening = store.openSearch('계약', SEARCH_SCOPE_ALL)
|
||||
await flushPromises()
|
||||
expect(store.listStatus).toBe('loading')
|
||||
expect(listRequests(requests).map((request) => request.params.cursor)).toEqual([undefined, 'c2'])
|
||||
|
||||
second.resolve(page(full, 'c3', null))
|
||||
await opening
|
||||
|
||||
expect(store.listStatus).toBe('ready')
|
||||
expect(store.messages).toHaveLength(20)
|
||||
// 한 쪽이 찼다 — 더 묻지 않는다 (남은 것은 [메일 더 보기])
|
||||
expect(listRequests(requests)).toHaveLength(2)
|
||||
expect(store.hasMore).toBe(true)
|
||||
expect(store.isLoadingMore).toBe(false)
|
||||
expect(listRequests(requests)[1]?.params).toEqual({ q: '계약', cursor: 'c2', limit: 20 })
|
||||
})
|
||||
|
||||
it(`빈 쪽이 이어지면 ${MAIL_SEARCH_FILL_MAX_REQUESTS}번까지만 묻고 멈춘다 — 결과 없음이 아니라 더 찾을 것이 남은 상태(hasMore)`, async () => {
|
||||
let cursor = 0
|
||||
const requests = installMockApi(({ url }) => {
|
||||
if (url === '/search-index') return ok({ status: 'ready', done: 10, total: 10 })
|
||||
cursor += 1
|
||||
return page([], `c${cursor + 1}`, null)
|
||||
})
|
||||
const store = useMailStore()
|
||||
|
||||
await store.openSearch('계약', 'inbox')
|
||||
|
||||
expect(listRequests(requests)).toHaveLength(MAIL_SEARCH_FILL_MAX_REQUESTS)
|
||||
expect(store.listStatus).toBe('ready')
|
||||
expect(store.messages).toEqual([])
|
||||
expect(store.hasMore).toBe(true)
|
||||
|
||||
// [더 찾기] — 같은 횟수만큼 더 묻는다
|
||||
await store.loadMore()
|
||||
expect(listRequests(requests)).toHaveLength(MAIL_SEARCH_FILL_MAX_REQUESTS * 2)
|
||||
})
|
||||
|
||||
it('하나라도 찾으면 먼저 보이고 뒤에서 잇는다(isLoadingMore) — 더 없으면(hasNext false) 멈춘다', async () => {
|
||||
const second = deferred()
|
||||
installMockApi(({ url, params }) => {
|
||||
if (url === '/search-index') return ok({ status: 'ready', done: 10, total: 10 })
|
||||
return params.cursor ? second.promise : page([item('r1'), item('r2')], 'c2', 7)
|
||||
})
|
||||
const store = useMailStore()
|
||||
|
||||
const opening = store.openSearch('계약', SEARCH_SCOPE_ALL)
|
||||
await flushPromises()
|
||||
expect(store.listStatus).toBe('ready')
|
||||
expect(ids(store.messages)).toEqual(['r1', 'r2'])
|
||||
expect(store.isLoadingMore).toBe(true)
|
||||
|
||||
second.resolve(page([item('r3')], null, null))
|
||||
await opening
|
||||
|
||||
expect(ids(store.messages)).toEqual(['r1', 'r2', 'r3'])
|
||||
expect(store.isLoadingMore).toBe(false)
|
||||
expect(store.hasMore).toBe(false)
|
||||
// 다음 쪽의 total 은 null 이다 — 첫 쪽의 수를 남긴다
|
||||
expect(store.pageInfo?.total).toBe(7)
|
||||
})
|
||||
|
||||
it('더 보기도 한 쪽을 더 찾을 때까지 잇는다 — 메일함 목록은 지금처럼 한 번만 묻는다', async () => {
|
||||
const full = Array.from({ length: 20 }, (_, index) => item(`r${index + 1}`))
|
||||
const requests = installMockApi(({ url, params }) => {
|
||||
if (url === '/search-index') return ok({ status: 'ready', done: 10, total: 10 })
|
||||
if (!params.cursor) return page(full, 'c2', null)
|
||||
if (params.cursor === 'c2') return page([], 'c3', null)
|
||||
if (params.cursor === 'c3') return page([item('r21')], 'c4', null)
|
||||
return page([item('r22')], null, null)
|
||||
})
|
||||
const store = useMailStore()
|
||||
await store.openSearch('계약', SEARCH_SCOPE_ALL)
|
||||
|
||||
await store.loadMore()
|
||||
|
||||
expect(listRequests(requests).map((request) => request.params.cursor)).toEqual([undefined, 'c2', 'c3', 'c4'])
|
||||
expect(store.messages).toHaveLength(22)
|
||||
|
||||
// 메일함 목록 — 빈 쪽이어도 이어 묻지 않는다
|
||||
requests.length = 0
|
||||
installMockApi(({ params }) => (params.cursor ? page([], 'm3') : page([], 'm2')))
|
||||
await store.openMailbox('inbox')
|
||||
expect(store.messages).toEqual([])
|
||||
expect(store.hasMore).toBe(true)
|
||||
})
|
||||
|
||||
it('이어 묻다 실패하면 받은 데까지 보인다 — 알림은 한 번(인터셉터), [더 찾기]로 다시 잇는다', async () => {
|
||||
installMockApi(({ url, params }) => {
|
||||
if (url === '/search-index') return ok({ status: 'ready', done: 10, total: 10 })
|
||||
return params.cursor ? fail(503, 'MAIL_001') : page([], 'c2', null)
|
||||
})
|
||||
const store = useMailStore()
|
||||
|
||||
await store.openSearch('계약', SEARCH_SCOPE_ALL)
|
||||
|
||||
expect(store.listStatus).toBe('ready')
|
||||
expect(store.listError).toBeNull()
|
||||
expect(store.hasMore).toBe(true)
|
||||
expect(store.isLoadingMore).toBe(false)
|
||||
expect(useToastStore().toasts).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('보이는 채로 다시 찾을 때 첫 쪽이 비고 더 있으면 "찾는 중"(loading)으로 잇는다 — 옛 결과를 남기지 않는다', async () => {
|
||||
let round = 1
|
||||
const second = deferred()
|
||||
installMockApi(({ url, params }) => {
|
||||
if (url === '/search-index') return ok({ status: 'ready', done: 10, total: 10 })
|
||||
if (round === 1) return page([item('old')], null, 1)
|
||||
return params.cursor ? second.promise : page([], 'c2', null)
|
||||
})
|
||||
const store = useMailStore()
|
||||
await store.openSearch('계약', SEARCH_SCOPE_ALL)
|
||||
expect(ids(store.messages)).toEqual(['old'])
|
||||
|
||||
round = 2
|
||||
const again = store.openSearch('계약', SEARCH_SCOPE_ALL, { force: true })
|
||||
await flushPromises()
|
||||
expect(store.listStatus).toBe('loading')
|
||||
expect(store.messages).toEqual([])
|
||||
expect(store.isRefreshing).toBe(false)
|
||||
|
||||
second.resolve(page([item('new')], null, null))
|
||||
await again
|
||||
expect(store.listStatus).toBe('ready')
|
||||
expect(ids(store.messages)).toEqual(['new'])
|
||||
})
|
||||
|
||||
it('뒤에서 이어 받는 중에 더 보기([다음] · [더 찾기])를 부르면 새로 묻지 않고 그 일이 끝나기를 기다린다', async () => {
|
||||
const second = deferred()
|
||||
const requests = installMockApi(({ url, params }) => {
|
||||
if (url === '/search-index') return ok({ status: 'ready', done: 10, total: 10 })
|
||||
if (!params.cursor) return page([item('r1')], 'c2', null)
|
||||
return second.promise
|
||||
})
|
||||
const store = useMailStore()
|
||||
const opening = store.openSearch('계약', SEARCH_SCOPE_ALL)
|
||||
await flushPromises()
|
||||
expect(store.isLoadingMore).toBe(true)
|
||||
|
||||
let done = false
|
||||
const more = store.loadMore().then(() => {
|
||||
done = true
|
||||
})
|
||||
await flushPromises()
|
||||
expect(done).toBe(false)
|
||||
expect(listRequests(requests)).toHaveLength(2)
|
||||
|
||||
second.resolve(page([item('r2')], null, null))
|
||||
await more
|
||||
await opening
|
||||
expect(ids(store.messages)).toEqual(['r1', 'r2'])
|
||||
expect(listRequests(requests)).toHaveLength(2)
|
||||
expect(store.isLoadingMore).toBe(false)
|
||||
})
|
||||
|
||||
it('보이는 채로 새로 고치는 중에는 더 보기가 묻지 않는다 — 옛 커서로 붙이지 않고, 끝나면 새 커서로', async () => {
|
||||
const first = Array.from({ length: 20 }, (_, index) => item(`m${index + 1}`))
|
||||
let round = 1
|
||||
const reload = deferred()
|
||||
const requests = installMockApi(({ params }) => {
|
||||
if (round === 1) return page(first, 'c2', 40)
|
||||
return params.cursor ? page([item('m21')], null, 40) : reload.promise
|
||||
})
|
||||
const store = useMailStore()
|
||||
await store.openMailbox('inbox')
|
||||
|
||||
round = 2
|
||||
const reopening = store.openMailbox('inbox')
|
||||
await flushPromises()
|
||||
expect(store.isRefreshing).toBe(true)
|
||||
await store.loadMore()
|
||||
expect(listRequests(requests).filter((request) => request.params.cursor)).toEqual([])
|
||||
|
||||
reload.resolve(page(first, 'c2b', 40))
|
||||
await reopening
|
||||
await store.loadMore()
|
||||
expect(listRequests(requests).map((request) => request.params.cursor).filter(Boolean)).toEqual(['c2b'])
|
||||
expect(store.messages).toHaveLength(21)
|
||||
})
|
||||
|
||||
it('이어 묻는 사이 다른 검색을 열면 앞 검색의 응답은 버린다', async () => {
|
||||
const late = deferred()
|
||||
installMockApi(({ url, params }) => {
|
||||
if (url === '/search-index') return ok({ status: 'ready', done: 10, total: 10 })
|
||||
if (params.q === '계약') return params.cursor ? late.promise : page([], 'c2', null)
|
||||
return page([item('n1')], null, 1)
|
||||
})
|
||||
const store = useMailStore()
|
||||
|
||||
const first = store.openSearch('계약', SEARCH_SCOPE_ALL)
|
||||
await flushPromises()
|
||||
await store.openSearch('견적', SEARCH_SCOPE_ALL)
|
||||
late.resolve(page([item('old')], null, null))
|
||||
await first
|
||||
|
||||
expect(store.search?.q).toBe('견적')
|
||||
expect(ids(store.messages)).toEqual(['n1'])
|
||||
expect(store.isLoadingMore).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('조건 · 목록 맞추기', () => {
|
||||
it('검색어와 조건을 합친 낱말이 8개를 넘는 검색(손으로 고친 주소)은 묻지 않는다 — 결과 없이 ready, 알림 없음 · 맞추기도 묻지 않는다', async () => {
|
||||
const requests = installMockApi(({ url }) =>
|
||||
url === '/search-index' ? ok({ status: 'ready', done: 10, total: 10 }) : page([item('x')], null, 1),
|
||||
)
|
||||
const store = useMailStore()
|
||||
const conditions = { ...NO_SEARCH_CONDITIONS, body: '여섯 일곱 여덟 아홉' }
|
||||
|
||||
await store.openSearch('하나 둘 셋 넷 다섯', SEARCH_SCOPE_ALL, { conditions })
|
||||
await store.syncVisibleList({ includeSearch: true })
|
||||
|
||||
expect(listRequests(requests)).toEqual([])
|
||||
expect(store.listStatus).toBe('ready')
|
||||
expect(store.messages).toEqual([])
|
||||
expect(store.pageInfo).toBeNull()
|
||||
expect(store.listError).toBeNull()
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('조건과 함께 찾는다 — 커서 요청에도 같은 조건을 싣는다', async () => {
|
||||
const conditions = { ...NO_SEARCH_CONDITIONS, from: '이도윤', hasAttachment: true }
|
||||
const requests = installMockApi(({ url, params }) => {
|
||||
if (url === '/search-index') return ok({ status: 'ready', done: 10, total: 10 })
|
||||
return params.cursor ? page([], null) : page([], 'c2', null)
|
||||
})
|
||||
const store = useMailStore()
|
||||
|
||||
await store.openSearch('', 'inbox', { conditions })
|
||||
|
||||
expect(listRequests(requests).map((request) => request.params)).toEqual([
|
||||
{ mailboxId: 'inbox', from: '이도윤', hasAttachment: 'true', limit: 20 },
|
||||
{ mailboxId: 'inbox', from: '이도윤', hasAttachment: 'true', cursor: 'c2', limit: 20 },
|
||||
])
|
||||
expect(store.search).toEqual({ kind: 'search', q: '', scope: 'inbox', conditions })
|
||||
})
|
||||
|
||||
it('읽음 표시의 응답(요약 — 강조 없음)으로 맞춰도 목록 줄의 강조는 남는다', async () => {
|
||||
const highlight = { subject: [[0, 2] as const], preview: { text: '…계약서 초안', ranges: [[1, 3] as const] } }
|
||||
installMockApi(({ url, method }) => {
|
||||
if (url === '/search-index') return ok({ status: 'ready', done: 10, total: 10 })
|
||||
if (method === 'PATCH') return ok(item('r1', { isRead: true }))
|
||||
if (url === '/messages') return page([item('r1', { isRead: false, searchHighlight: highlight })], null, 1)
|
||||
return ok(detail('r1', { isRead: false }))
|
||||
})
|
||||
const store = useMailStore()
|
||||
await store.openSearch('계약', SEARCH_SCOPE_ALL)
|
||||
|
||||
await store.openMessage('r1', { q: '계약' })
|
||||
await flushPromises()
|
||||
|
||||
expect(store.messages[0]?.isRead).toBe(true)
|
||||
expect(store.messages[0]?.searchHighlight).toEqual(highlight)
|
||||
})
|
||||
})
|
||||
|
||||
describe('지난 메일 색인의 상태 (GET /search-index)', () => {
|
||||
it('검색을 시작할 때 조용히 묻는다 — building · ready 를 기억하고, 같은 검색이 보이면 다시 묻지 않는다', async () => {
|
||||
let status = 'building'
|
||||
const requests = installMockApi(({ url }) =>
|
||||
url === '/search-index' ? ok({ status, done: 3, total: 40 }) : page([item('r1')], null, 1),
|
||||
)
|
||||
const store = useMailStore()
|
||||
|
||||
await store.openSearch('계약', SEARCH_SCOPE_ALL)
|
||||
await flushPromises()
|
||||
expect(store.searchIndexStatus).toBe('building')
|
||||
const indexRequests = () => requests.filter((request) => request.url === '/search-index')
|
||||
expect(indexRequests()).toHaveLength(1)
|
||||
expect(indexRequests()[0]?.config.silent).toBe(true)
|
||||
|
||||
await store.openSearch('계약', SEARCH_SCOPE_ALL)
|
||||
expect(indexRequests()).toHaveLength(1)
|
||||
|
||||
status = 'ready'
|
||||
await store.openSearch('계약', SEARCH_SCOPE_ALL, { force: true })
|
||||
await flushPromises()
|
||||
expect(store.searchIndexStatus).toBe('ready')
|
||||
})
|
||||
|
||||
it('못 받거나 모르는 값이면 모르는 것(null)으로 — 알리지 않는다', async () => {
|
||||
let broken = true
|
||||
installMockApi(({ url }) => {
|
||||
if (url === '/search-index') return broken ? fail(503, 'SYS_001') : ok({ status: 'paused', done: 0, total: null })
|
||||
return page([], null, 0)
|
||||
})
|
||||
const store = useMailStore()
|
||||
|
||||
await store.openSearch('계약', SEARCH_SCOPE_ALL)
|
||||
await flushPromises()
|
||||
expect(store.searchIndexStatus).toBeNull()
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
broken = false
|
||||
await store.openSearch('견적', SEARCH_SCOPE_ALL)
|
||||
await flushPromises()
|
||||
expect(store.searchIndexStatus).toBeNull()
|
||||
})
|
||||
|
||||
it('늦게 온 앞 응답은 새 응답을 덮지 않는다 · 로그아웃하면 지운다', async () => {
|
||||
const slow = deferred()
|
||||
let calls = 0
|
||||
installMockApi(({ url }) => {
|
||||
if (url !== '/search-index') return page([], null, 0)
|
||||
calls += 1
|
||||
return calls === 1 ? slow.promise : ok({ status: 'ready', done: 1, total: 1 })
|
||||
})
|
||||
const store = useMailStore()
|
||||
|
||||
void store.openSearch('계약', SEARCH_SCOPE_ALL)
|
||||
await store.openSearch('견적', SEARCH_SCOPE_ALL)
|
||||
await flushPromises()
|
||||
slow.resolve(ok({ status: 'building', done: 0, total: 10 }))
|
||||
await flushPromises()
|
||||
expect(store.searchIndexStatus).toBe('ready')
|
||||
|
||||
store.reset()
|
||||
expect(store.searchIndexStatus).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('읽기 화면의 강조 쿼리', () => {
|
||||
it('검색 결과에서 연 메일은 강조 쿼리와 함께 받는다 — 같은 쿼리면 다시 받지 않고, 쿼리가 바뀌면(메일함에서 열면) 다시 받는다', async () => {
|
||||
const requests = installMockApi(({ url }) => ok(detail(url.split('/').pop() ?? 'm1')))
|
||||
const detailRequests = () => requests.filter((request) => request.url === '/messages/m1')
|
||||
const store = useMailStore()
|
||||
|
||||
await store.openMessage('m1', { q: '계약', subject: '견적' })
|
||||
expect(detailRequests()[0]?.params).toEqual({ q: '계약', subject: '견적' })
|
||||
expect(store.selectedHighlight).toEqual({ q: '계약', subject: '견적' })
|
||||
|
||||
await store.openMessage('m1', { q: '계약', subject: '견적' })
|
||||
expect(detailRequests()).toHaveLength(1)
|
||||
|
||||
// 같은 메일을 메일함에서 다시 열었다 — 강조가 든 본문을 쓰지 않고 쿼리 없이 다시 받는다
|
||||
await store.openMessage('m1')
|
||||
expect(detailRequests()).toHaveLength(2)
|
||||
expect(detailRequests()[1]?.params).toEqual({})
|
||||
expect(store.selectedHighlight).toBeNull()
|
||||
})
|
||||
|
||||
it('[이미지 보기]는 같은 강조 쿼리와 함께 받는다 — 강조 · 제목도 새 응답으로', async () => {
|
||||
const highlight = { subject: [[0, 2] as const], attachments: {}, textBody: [] }
|
||||
const requests = installMockApi(({ params }) =>
|
||||
ok(
|
||||
detail('m1', {
|
||||
hasBlockedImages: params.images !== 'show',
|
||||
imagesShown: params.images === 'show',
|
||||
htmlBody: params.images === 'show' ? '<p><mark>계약</mark> <img src="/api/mail-images/abc"></p>' : '<p><mark>계약</mark></p>',
|
||||
searchHighlight: highlight,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const store = useMailStore()
|
||||
await store.openMessage('m1', { q: '계약' })
|
||||
|
||||
expect(await store.showImages()).toBe(true)
|
||||
|
||||
expect(requests.at(-1)?.params).toEqual({ images: 'show', q: '계약' })
|
||||
expect(store.selected?.imagesShown).toBe(true)
|
||||
expect(store.selected?.searchHighlight).toEqual(highlight)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -122,6 +122,7 @@ export const useMailStore = defineStore('mail', () => {
|
||||
currentMailbox: list.currentMailbox,
|
||||
search: list.search,
|
||||
searchOrigin: list.searchOrigin,
|
||||
searchIndexStatus: list.searchIndexStatus,
|
||||
messages: list.messages,
|
||||
pageInfo: list.pageInfo,
|
||||
listStatus: list.listStatus,
|
||||
@@ -152,6 +153,7 @@ export const useMailStore = defineStore('mail', () => {
|
||||
selected: opened.selected,
|
||||
selectedStatus: opened.selectedStatus,
|
||||
selectedError: opened.selectedError,
|
||||
selectedHighlight: opened.selectedHighlight,
|
||||
imagesLoadingId: opened.imagesLoadingId,
|
||||
openMessage: message.openMessage,
|
||||
closeMessage: opened.closeMessage,
|
||||
|
||||
@@ -6,22 +6,33 @@
|
||||
// 아직 같은 목록인지 본다(bulk.ts — 다른 목록이면 그 목록을 건드리지 않는다)
|
||||
// - localGeneration: 이 탭이 목록 · 열린 메일을 바꿀 때마다(읽음 · 중요 표시 · 빼기 · 옮기기 · 보내던 표시가 끝남) —
|
||||
// 그 전에 떠난 새 메일 확인의 응답은 옛 상태라 버리고 한 번 더 맞춘다 (옮긴 메일이 되살아나거나 표시가 되돌아가지 않게)
|
||||
//
|
||||
// 검색 결과 이어 받기 (14단계 — 계약 2절 "검색 — 14단계"): 서버(색인 방식)는 한 요청에 확인하는 메일 수에 상한이 있어 hasNext 인데 항목이
|
||||
// 적거나 0개인 쪽을 준다. 검색을 열 때 · 더 볼 때 한 쪽(20통)이 찰 때까지 커서로 이어 묻는다(MAIL_SEARCH_FILL_MAX_REQUESTS 번까지 — 넘으면
|
||||
// 사용자가 [더 찾기] · [메일 더 보기]로 잇는다). 하나도 찾기 전에는 "찾는 중"(loading), 하나라도 찾으면 보이며 뒤에서 잇는다(isLoadingMore).
|
||||
// 결과 없음은 더 찾을 것이 없을 때(hasNext false)만이다. 다음 쪽의 total 은 늘 null 이라 첫 쪽의 수를 남긴다.
|
||||
// 이어 받는 동안 누른 [다음] · [더 찾기]는 그 일이 끝나기를 기다리고(loadMore), 보이는 채로 새로 고치는 동안은 더 받지 않는다.
|
||||
// 검색을 시작할 때 지난 메일 색인의 상태(GET /search-index)를 조용히 묻는다 — 결과 머리의 "검색 준비 중" 줄 · 한 글자 안내 · 결과 없음의 한계 안내
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { toApiError, type ApiError } from '@/composables/useApi'
|
||||
import type { MailRequestOptions } from '@/composables/useMail'
|
||||
import { MAIL_LIST_SYNC_MAX } from '@/shared/constants/mail'
|
||||
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
|
||||
import { MAIL_SEARCH_FILL_MAX_REQUESTS } from '@/shared/constants/mail-search'
|
||||
import type { Mailbox, MessageListItem, MessageSummary, SearchIndexStatus } from '@/shared/types/mail'
|
||||
import { DEFAULT_PAGE_SIZE, type PageInfo, type PaginatedResult } from '@/shared/types/pagination'
|
||||
import { mergeRefreshedList } from '@/shared/utils/mail-list-merge'
|
||||
import {
|
||||
isSearchTooLong,
|
||||
listQueryOf,
|
||||
sameListSource,
|
||||
scopeMailboxIdOf,
|
||||
SCHEDULED_LIST_SOURCE,
|
||||
searchSourceOf,
|
||||
type MailListSource,
|
||||
type SearchListSource,
|
||||
} from '@/shared/utils/mail-list-source'
|
||||
import type { SearchConditions } from '@/shared/utils/mail-search'
|
||||
import { messagePendingSend, samePendingSend } from '@/shared/utils/pending-send'
|
||||
import type { MailboxesState } from './mailboxes'
|
||||
import type { OpenedMessage } from './message'
|
||||
@@ -59,7 +70,11 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
|
||||
listSource.value?.kind === 'search' ? listSource.value : null,
|
||||
)
|
||||
const searchOrigin = ref<SearchOrigin | null>(null)
|
||||
const messages = ref<MessageSummary[]>([])
|
||||
/**
|
||||
* 지난 메일 색인의 상태 (14단계) — 검색을 시작할 때 묻는다. 모르면(아직 · 못 받음) null — 그때는 안내 줄 · 한계 안내를 보이지 않는다
|
||||
*/
|
||||
const searchIndexStatus = ref<SearchIndexStatus | null>(null)
|
||||
const messages = ref<MessageListItem[]>([])
|
||||
const pageInfo = ref<PageInfo | null>(null)
|
||||
const listStatus = ref<LoadStatus>('idle')
|
||||
const listError = ref<ApiError | null>(null)
|
||||
@@ -75,6 +90,10 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
|
||||
let listSeq = 0
|
||||
let listSyncSeq = 0
|
||||
let localGeneration = 0
|
||||
/** 색인 상태 요청의 순번 — 늦게 온 응답이 새 응답을 덮어쓰지 않게 */
|
||||
let searchIndexSeq = 0
|
||||
/** 지금 이어 받는 일 (runLoading) — 그사이 누른 [다음] · [더 찾기]가 끝나기를 기다린다 */
|
||||
let loading: Promise<void> | null = null
|
||||
/**
|
||||
* 보내는 중인 읽음 · 중요 표시 — 그사이 도착한 목록(새 메일 확인)이 옛 값으로 되돌려 보이지 않게 덮어쓴다.
|
||||
* 화면에 그리지 않으므로 반응형이 아니다
|
||||
@@ -110,9 +129,14 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
|
||||
bumpLocalChange()
|
||||
}
|
||||
|
||||
/** 서버가 돌려준 요약으로 목록 · 열린 메일을 맞춘다 */
|
||||
/**
|
||||
* 서버가 돌려준 요약으로 목록 · 열린 메일을 맞춘다 — 요약(PATCH 응답)에는 검색 강조가 없으므로 목록 줄의 강조는 그대로 둔다 (14단계)
|
||||
*/
|
||||
function applySummary(summary: MessageSummary) {
|
||||
messages.value = messages.value.map((message) => (message.id === summary.id ? summary : message))
|
||||
messages.value = messages.value.map((message) => {
|
||||
if (message.id !== summary.id) return message
|
||||
return message.searchHighlight === undefined ? summary : { ...summary, searchHighlight: message.searchHighlight }
|
||||
})
|
||||
if (opened.selected.value?.id === summary.id) opened.selected.value = { ...opened.selected.value, ...summary }
|
||||
bumpLocalChange()
|
||||
}
|
||||
@@ -157,7 +181,7 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
|
||||
}
|
||||
|
||||
/** 보내는 중인 읽음 · 중요 표시를 덮어쓴다 */
|
||||
function withPendingFlags(items: readonly MessageSummary[]): MessageSummary[] {
|
||||
function withPendingFlags<T extends MessageSummary>(items: readonly T[]): T[] {
|
||||
if (pendingFlags.size === 0) return [...items]
|
||||
return items.map((item) => {
|
||||
const pending = pendingFlags.get(item.id)
|
||||
@@ -208,7 +232,7 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
|
||||
source: MailListSource,
|
||||
page: { cursor?: string; limit: number },
|
||||
options?: MailRequestOptions,
|
||||
): Promise<PaginatedResult<MessageSummary>> {
|
||||
): Promise<PaginatedResult<MessageListItem>> {
|
||||
if (source.kind === 'scheduled') return mailApi.listScheduledMessages(page, options)
|
||||
return mailApi.listMessages({ ...listQueryOf(source), ...page }, options)
|
||||
}
|
||||
@@ -224,15 +248,82 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
|
||||
if (source.kind === 'scheduled') onScheduledTotal?.(info.total)
|
||||
}
|
||||
|
||||
/**
|
||||
* 받은 쪽을 목록 뒤에 붙인다 — 이미 있는 메일은 건너뛴다(그사이 새 메일이 와서 경계가 밀렸을 수 있다). 붙인 수를 돌려준다.
|
||||
* 다음 쪽의 total 이 없으면(색인 검색은 첫 쪽에서만 센다 — 14단계) 아는 수를 남긴다
|
||||
*/
|
||||
function appendPage(page: PaginatedResult<MessageListItem>): number {
|
||||
const known = new Set(messages.value.map((message) => message.id))
|
||||
const fresh = page.items.filter((item) => !known.has(item.id))
|
||||
messages.value = [...messages.value, ...fresh]
|
||||
pageInfo.value = { ...page.pageInfo, total: page.pageInfo.total ?? pageInfo.value?.total ?? null }
|
||||
return fresh.length
|
||||
}
|
||||
|
||||
/** 검색 결과를 더 이어 받을까 — 서버에 더 있고(hasNext · 커서) 이번에 찾은 것이 한 쪽에 못 미친다 */
|
||||
function wantsMoreResults(source: MailListSource, found: number): boolean {
|
||||
return (
|
||||
source.kind === 'search' &&
|
||||
found < DEFAULT_PAGE_SIZE &&
|
||||
pageInfo.value?.hasNext === true &&
|
||||
Boolean(pageInfo.value.nextCursor)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 결과 이어 받기 (머리말) — 한 쪽이 찰 때까지 · 더 없을 때까지 · 요청이 MAIL_SEARCH_FILL_MAX_REQUESTS 번이 될 때까지 커서로 묻는다.
|
||||
* found · requests 는 이번에 이미 찾은 수 · 보낸 요청 수. 하나라도 찾으면 목록을 보인다. 그사이 목록을 새로 열었으면 멈춘다.
|
||||
* 실패는 그대로 던진다 — 부르는 쪽이 받은 데까지 보인다 (알림은 인터셉터가 띄운다)
|
||||
*/
|
||||
async function continueSearch(source: SearchListSource, seq: number, found: number, requests: number): Promise<void> {
|
||||
let total = found
|
||||
let sent = requests
|
||||
while (seq === listSeq && sent < MAIL_SEARCH_FILL_MAX_REQUESTS && wantsMoreResults(source, total)) {
|
||||
const cursor = pageInfo.value?.nextCursor
|
||||
if (!cursor) return
|
||||
const page = await fetchPage(source, { cursor, limit: DEFAULT_PAGE_SIZE })
|
||||
sent += 1
|
||||
if (seq !== listSeq) return
|
||||
total += appendPage(page)
|
||||
if (messages.value.length > 0 && listStatus.value === 'loading') listStatus.value = 'ready'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이어 받는 일 하나를 돌린다 — 도는 동안 isLoadingMore. [다음] · [더 찾기]가 그사이 다시 누르면 새로 묻지 않고 이 일이 끝나기를 기다린다(loadMore).
|
||||
* 실패는 삼킨다 — 알림은 인터셉터가 띄웠고 받은 데까지 보인다. 그사이 다른 목록을 열었으면 그 목록이 표시를 맡는다(openList)
|
||||
*/
|
||||
async function runLoading(seq: number, work: () => Promise<void>): Promise<void> {
|
||||
isLoadingMore.value = true
|
||||
const run = work().catch(() => undefined)
|
||||
loading = run
|
||||
await run
|
||||
if (loading === run) loading = null
|
||||
if (seq === listSeq) isLoadingMore.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록 열기 — 첫 페이지를 불러온다. 다른 목록이면 고른 것이 풀린다 (메일함을 바꾸거나 검색하면 선택을 푼다).
|
||||
* 같은 목록으로 돌아오면(쓰기 화면에서 복귀 등) 보이던 목록을 둔 채 새로 고친다.
|
||||
* 검색 결과가 한 쪽에 못 미치고 서버에 더 있으면 이어 받는다 (머리말 "검색 결과 이어 받기") — 첫 쪽이 비었으면 보이던 채로 다시 찾을 때도 "찾는 중"(뼈대).
|
||||
* 검색어와 조건을 합친 낱말이 서버 한도를 넘는 검색(손으로 고친 주소)은 묻지 않는다 — 목록 틀이 그 까닭을 보인다 (isSearchTooLong)
|
||||
*/
|
||||
async function openList(next: MailListSource): Promise<void> {
|
||||
const keepVisible = sameListSource(listSource.value, next) && listStatus.value === 'ready'
|
||||
listSource.value = next
|
||||
listSeq += 1
|
||||
const seq = listSeq
|
||||
// 앞 목록을 이어 받던 일은 이제 쓰지 않는다 (그 응답은 순번으로 버린다)
|
||||
isLoadingMore.value = false
|
||||
|
||||
if (isSearchTooLong(next)) {
|
||||
messages.value = []
|
||||
pageInfo.value = null
|
||||
listError.value = null
|
||||
isRefreshing.value = false
|
||||
listStatus.value = 'ready'
|
||||
return
|
||||
}
|
||||
|
||||
if (keepVisible) {
|
||||
isRefreshing.value = true
|
||||
@@ -243,26 +334,36 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
|
||||
listError.value = null
|
||||
}
|
||||
|
||||
let page: PaginatedResult<MessageListItem>
|
||||
try {
|
||||
const page = await fetchPage(
|
||||
page = await fetchPage(
|
||||
next,
|
||||
{ limit: DEFAULT_PAGE_SIZE },
|
||||
// 처음 여는 목록은 목록 영역이 오류 상태("다시 시도")를 직접 그린다
|
||||
{ silent: keepVisible ? undefined : true },
|
||||
)
|
||||
if (seq !== listSeq) return
|
||||
messages.value = page.items
|
||||
pageInfo.value = page.pageInfo
|
||||
listStatus.value = 'ready'
|
||||
listError.value = null
|
||||
reportTotal(next, page.pageInfo)
|
||||
} catch (error) {
|
||||
if (seq !== listSeq || keepVisible) return
|
||||
if (seq !== listSeq) return
|
||||
isRefreshing.value = false
|
||||
if (keepVisible) return
|
||||
listStatus.value = 'error'
|
||||
listError.value = toApiError(error)
|
||||
} finally {
|
||||
if (seq === listSeq) isRefreshing.value = false
|
||||
return
|
||||
}
|
||||
if (seq !== listSeq) return
|
||||
messages.value = page.items
|
||||
pageInfo.value = page.pageInfo
|
||||
listError.value = null
|
||||
isRefreshing.value = false
|
||||
reportTotal(next, page.pageInfo)
|
||||
|
||||
if (next.kind === 'search' && wantsMoreResults(next, page.items.length)) {
|
||||
// 하나라도 찾았으면 보이며 뒤에서 잇는다 — 하나도 없으면 "찾는 중"(뼈대)으로 잇는다
|
||||
listStatus.value = page.items.length > 0 ? 'ready' : 'loading'
|
||||
await runLoading(seq, () => continueSearch(next, seq, page.items.length, 1))
|
||||
if (seq !== listSeq) return
|
||||
}
|
||||
listStatus.value = 'ready'
|
||||
}
|
||||
|
||||
/** 메일함 열기 — 같은 메일함으로 돌아오면 보이던 목록을 둔 채 새로 고친다 */
|
||||
@@ -276,15 +377,38 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 결과 열기 (계약 2절 "검색") — scope 는 메일함 id 또는 전체(SEARCH_SCOPE_ALL).
|
||||
* 같은 검색 결과가 이미 보이면(결과에서 연 메일에서 돌아오는 등) 다시 찾지 않는다 — 보던 결과가 저절로 바뀌지 않게.
|
||||
* force 면(같은 검색어로 다시 Enter) 보이는 채로 다시 찾는다 — 다시 찾는 것도 검색이라 고른 것을 푼다
|
||||
* 지난 메일 색인의 상태를 조용히 묻는다 (14단계 — 검색을 시작할 때). 늦게 온 응답은 버리고, 실패하면 모르는 것(null)으로 둔다 —
|
||||
* 안내 줄 없이 지금처럼 보인다
|
||||
*/
|
||||
async function openSearch(q: string, scope: string, options: { force?: boolean } = {}): Promise<void> {
|
||||
const next: SearchListSource = { kind: 'search', q, scope }
|
||||
async function refreshSearchIndex(): Promise<void> {
|
||||
searchIndexSeq += 1
|
||||
const seq = searchIndexSeq
|
||||
try {
|
||||
const state = await mailApi.getSearchIndex({ silent: true })
|
||||
if (seq !== searchIndexSeq) return
|
||||
const status: unknown = state?.status
|
||||
searchIndexStatus.value = status === 'building' || status === 'ready' ? status : null
|
||||
} catch {
|
||||
if (seq === searchIndexSeq) searchIndexStatus.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 결과 열기 (계약 2절 "검색" · "검색 — 14단계") — scope 는 메일함 id 또는 전체(SEARCH_SCOPE_ALL), conditions 는 조건(없으면 검색어로만).
|
||||
* 같은 검색 결과가 이미 보이면(결과에서 연 메일에서 돌아오는 등) 다시 찾지 않는다 — 보던 결과가 저절로 바뀌지 않게.
|
||||
* force 면(같은 검색어로 다시 Enter) 보이는 채로 다시 찾는다 — 다시 찾는 것도 검색이라 고른 것을 푼다.
|
||||
* 찾을 때마다 지난 메일 색인의 상태도 묻는다
|
||||
*/
|
||||
async function openSearch(
|
||||
q: string,
|
||||
scope: string,
|
||||
options: { force?: boolean; conditions?: SearchConditions | null } = {},
|
||||
): Promise<void> {
|
||||
const next = searchSourceOf(q, scope, options.conditions)
|
||||
const shown = sameListSource(listSource.value, next)
|
||||
if (shown && !options.force && (listStatus.value === 'ready' || listStatus.value === 'loading')) return
|
||||
if (shown && options.force) renewSource()
|
||||
void refreshSearchIndex()
|
||||
await openList(next)
|
||||
}
|
||||
|
||||
@@ -299,27 +423,29 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
|
||||
if (listSource.value) await openList(listSource.value)
|
||||
}
|
||||
|
||||
/** 다음 페이지 — pageInfo.nextCursor 를 해석하지 않고 그대로 보낸다 */
|
||||
/**
|
||||
* 다음 페이지 — pageInfo.nextCursor 를 해석하지 않고 그대로 보낸다.
|
||||
* 검색 결과는 한 쪽(20통)을 더 찾을 때까지 이어 묻는다 (머리말 — [메일 더 보기] · [더 찾기] · 목록 보기의 [다음]).
|
||||
* 이미 이어 받는 중이면 새로 묻지 않고 그 일이 끝나기를 기다린다 — [다음]이 그 뒤에 온 메일로 넘어가게.
|
||||
* 보이는 채로 새로 고치는 중이면 묻지 않는다 — 옛 커서로 붙이면 새 첫 쪽과 사이가 빈다
|
||||
*/
|
||||
async function loadMore(): Promise<void> {
|
||||
if (isLoadingMore.value) {
|
||||
await loading
|
||||
return
|
||||
}
|
||||
const cursor = pageInfo.value?.nextCursor
|
||||
const source = listSource.value
|
||||
if (!cursor || !source || isLoadingMore.value) return
|
||||
if (!cursor || !source || isRefreshing.value) return
|
||||
|
||||
const seq = listSeq
|
||||
isLoadingMore.value = true
|
||||
try {
|
||||
await runLoading(seq, async () => {
|
||||
const page = await fetchPage(source, { cursor, limit: DEFAULT_PAGE_SIZE })
|
||||
if (seq !== listSeq) return
|
||||
// 그사이 새 메일이 와서 경계가 밀렸을 수 있다 — 이미 있는 메일은 건너뛴다
|
||||
const known = new Set(messages.value.map((message) => message.id))
|
||||
messages.value = [...messages.value, ...page.items.filter((item) => !known.has(item.id))]
|
||||
pageInfo.value = page.pageInfo
|
||||
const added = appendPage(page)
|
||||
reportTotal(source, page.pageInfo)
|
||||
} catch {
|
||||
// 알림은 인터셉터가 띄웠다 — 버튼을 다시 누를 수 있게 그대로 둔다
|
||||
} finally {
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
if (source.kind === 'search') await continueSearch(source, seq, added, 1)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -332,7 +458,7 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
|
||||
async function syncVisibleList(options: { includeSearch?: boolean } = {}, attempt = 0): Promise<void> {
|
||||
const source = listSource.value
|
||||
if (!source || listStatus.value !== 'ready') return
|
||||
if (source.kind === 'search' && !options.includeSearch) return
|
||||
if (source.kind === 'search' && (!options.includeSearch || isSearchTooLong(source))) return
|
||||
|
||||
const seq = listSeq
|
||||
const generation = localGeneration
|
||||
@@ -363,8 +489,10 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
|
||||
|
||||
function reset() {
|
||||
listSeq += 1
|
||||
searchIndexSeq += 1
|
||||
listSource.value = null
|
||||
searchOrigin.value = null
|
||||
searchIndexStatus.value = null
|
||||
messages.value = []
|
||||
pageInfo.value = null
|
||||
listStatus.value = 'idle'
|
||||
@@ -382,6 +510,7 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
|
||||
currentMailbox,
|
||||
search,
|
||||
searchOrigin,
|
||||
searchIndexStatus,
|
||||
messages,
|
||||
pageInfo,
|
||||
listStatus,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
// 메일 스토어 모듈 — 열린 메일(읽기 화면)과 그 동작: 열기 · 읽음 처리 · 중요 표시 · 다른 메일함으로 옮기기 (mail.store.ts 가 조립한다)
|
||||
// "열린 메일"(selectedId)은 여러 통 처리의 "고른 메일"(checkedIds — selection.ts)과 다르다.
|
||||
// 검색 결과에서 연 메일(14단계)은 검색 쿼리(q · from · to · subject · body)와 함께 받아 맞은 곳이 강조된 본문이다 — 강조 쿼리가 다르면
|
||||
// (같은 메일을 메일함에서 다시 열었을 때 등) 같은 메일이어도 다시 받는다. 강조가 든 본문은 읽기 화면에 보이기만 한다 —
|
||||
// 답장 · 전달의 원문 · 인쇄는 쿼리 없이 따로 받는다 (계약 2절 "검색 — 14단계", ComposePage · MailPrintPage)
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { toApiError, type ApiError } from '@/composables/useApi'
|
||||
import type { MessageLargeFile, ShareLink } from '@/shared/types/large-file'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import type { MessageDetail, MessageHighlightQuery } from '@/shared/types/mail'
|
||||
import { messageHighlightKey } from '@/shared/utils/mail-search'
|
||||
import type { ListState } from './list'
|
||||
import type { MailboxesState } from './mailboxes'
|
||||
import type { FlagChanges, LoadStatus, MailApi } from './types'
|
||||
@@ -17,6 +21,8 @@ export function createOpenedMessage() {
|
||||
const selectedError = ref<ApiError | null>(null)
|
||||
/** 외부 이미지를 보이는 본문을 받는 중인 메일 — 읽기 화면의 [이미지 보기]가 기다린다 (9단계) */
|
||||
const imagesLoadingId = ref<string | null>(null)
|
||||
/** 열린 메일을 받은 강조 쿼리 (14단계 — 검색 결과에서 연 메일만, 그 밖에는 null). [이미지 보기] · [다시 시도]가 같은 쿼리로 받는다 */
|
||||
const selectedHighlight = ref<MessageHighlightQuery | null>(null)
|
||||
/** 메일을 빠르게 바꿀 때 늦게 온 본문 응답이 덮어쓰지 않게 */
|
||||
let detailSeq = 0
|
||||
|
||||
@@ -41,6 +47,7 @@ export function createOpenedMessage() {
|
||||
selectedStatus.value = 'idle'
|
||||
selectedError.value = null
|
||||
imagesLoadingId.value = null
|
||||
selectedHighlight.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -49,6 +56,7 @@ export function createOpenedMessage() {
|
||||
selectedStatus,
|
||||
selectedError,
|
||||
imagesLoadingId,
|
||||
selectedHighlight,
|
||||
nextDetailSeq,
|
||||
currentDetailSeq,
|
||||
isCurrentDetail,
|
||||
@@ -88,18 +96,23 @@ export function createMessageActions({ mailApi, mailboxes, list, opened }: Messa
|
||||
}
|
||||
}
|
||||
|
||||
/** 메일 열기 — 조회만으로는 읽음 처리되지 않으므로 열린 뒤 PATCH {isRead:true} 를 보낸다 */
|
||||
async function openMessage(id: string): Promise<void> {
|
||||
/**
|
||||
* 메일 열기 — 조회만으로는 읽음 처리되지 않으므로 열린 뒤 PATCH {isRead:true} 를 보낸다.
|
||||
* highlight 는 검색 결과에서 연 메일의 강조 쿼리(14단계 — 그 밖에는 null). 같은 메일을 같은 쿼리로 이미 열었으면 다시 받지 않는다
|
||||
*/
|
||||
async function openMessage(id: string, highlight: MessageHighlightQuery | null = null): Promise<void> {
|
||||
opened.selectedId.value = id
|
||||
if (opened.selected.value?.id === id && opened.selectedStatus.value === 'ready') return
|
||||
const sameHighlight = messageHighlightKey(opened.selectedHighlight.value) === messageHighlightKey(highlight)
|
||||
if (opened.selected.value?.id === id && opened.selectedStatus.value === 'ready' && sameHighlight) return
|
||||
|
||||
const seq = opened.nextDetailSeq()
|
||||
opened.selected.value = null
|
||||
opened.selectedStatus.value = 'loading'
|
||||
opened.selectedError.value = null
|
||||
opened.imagesLoadingId.value = null
|
||||
opened.selectedHighlight.value = highlight
|
||||
try {
|
||||
const detail = await mailApi.getMessage(id, { silent: true })
|
||||
const detail = await mailApi.getMessage(id, { silent: true, highlight })
|
||||
if (!opened.isCurrentDetail(seq)) return
|
||||
opened.selected.value = detail
|
||||
opened.selectedStatus.value = 'ready'
|
||||
@@ -113,6 +126,7 @@ export function createMessageActions({ mailApi, mailboxes, list, opened }: Messa
|
||||
|
||||
/**
|
||||
* [이미지 보기] — 열린 메일을 `images=show` 로 다시 받아 본문만 바꾼다 (9단계 "외부 이미지 보기" — 그 메일만, 다시 열면 설정대로).
|
||||
* 검색 결과에서 연 메일은 같은 강조 쿼리와 함께 받는다(14단계 — 강조가 그대로 남게).
|
||||
* 그사이 다른 메일을 열었거나 닫았으면(순번이 바뀜) 늦은 응답을 버린다. 읽음 · 중요 · 메일함 표시는 지금 값 그대로 둔다.
|
||||
* 실패는 인터셉터가 알리고 안내 줄은 그대로 남는다. 바꿨으면 true
|
||||
*/
|
||||
@@ -122,12 +136,15 @@ export function createMessageActions({ mailApi, mailboxes, list, opened }: Messa
|
||||
const id = current.id
|
||||
const seq = opened.currentDetailSeq()
|
||||
opened.imagesLoadingId.value = id
|
||||
const highlight = opened.selectedHighlight.value
|
||||
try {
|
||||
const detail = await mailApi.getMessage(id, { showImages: true })
|
||||
const detail = await mailApi.getMessage(id, { showImages: true, highlight })
|
||||
const latest = opened.selected.value
|
||||
if (!opened.isCurrentDetail(seq) || latest?.id !== id) return false
|
||||
opened.selected.value = {
|
||||
...latest,
|
||||
// 강조가 든 본문이면 제목 · 첨부 이름 · 글 본문은 NFC 로 맞춘 글이고 위치는 새 본문 기준이다 — 함께 바꾼다 (14단계)
|
||||
...(highlight ? { subject: detail.subject, searchHighlight: detail.searchHighlight ?? null } : {}),
|
||||
htmlBody: detail.htmlBody,
|
||||
textBody: detail.textBody,
|
||||
hasBlockedImages: detail.hasBlockedImages,
|
||||
|
||||
@@ -87,6 +87,9 @@
|
||||
--color-warning-border: #f0d9a8; /* 노란 줄 안의 흰 단추 테두리 — 읽기 화면 [이미지 보기] (디자인 원본 MailReadTools) */
|
||||
--color-success-text: #067647; /* 서명 "저장했습니다" (디자인 원본 Settings) */
|
||||
--color-status-live: #12b76a; /* 실시간 연결 "연결되어 있습니다" 점 (디자인 원본 SettingsNotify) */
|
||||
/* 검색에서 맞은 말 — 글자색은 그대로 (14단계 디자인 원본 Search14). 짝: shared/utils/mail-html.ts 의 본문 틀 mark 색 —
|
||||
본문 틀(iframe)은 이 변수를 읽지 못해 같은 값을 적어 둔다, 한쪽을 고치면 둘 다 */
|
||||
--color-search-mark: #fce7a4;
|
||||
--color-star: #b7791f;
|
||||
|
||||
/* ── 간격 (이름의 숫자 = px) ─────────────── */
|
||||
@@ -139,7 +142,8 @@
|
||||
/*
|
||||
* 밀도를 위한 작은 조작 영역 — 클릭 영역 44px 기준의 의도된 예외 (디자인 README "디자인 원칙", 사용자 확정).
|
||||
* 목록 표의 별 버튼(폭 32px · 행 높이를 채움), "분할 | 목록" 전환(바깥 36px),
|
||||
* 설정 화면의 전환 단추 · 서명 그림의 크기 줄(작게 · 보통 · 원본 · 그림 빼기 — 36px, 캔버스 확정값)에만 쓴다.
|
||||
* 설정 화면의 전환 단추 · 서명 그림의 크기 줄(작게 · 보통 · 원본 · 그림 빼기 — 36px, 캔버스 확정값),
|
||||
* 14단계 검색 칸 안의 [조건](32px — 아래 14단계 주석)에만 쓴다.
|
||||
*/
|
||||
--size-compact-control: 2rem; /* 32px */
|
||||
--size-segment-item: 1.875rem; /* 30px — 전환 버튼 한 칸 높이 (테두리·안쪽 여백 포함 36px) */
|
||||
@@ -181,6 +185,12 @@
|
||||
* 연락처 창의 이메일 · 전화번호 줄(종류 · 값) · [이메일 · 전화번호 더하기] · 회사 · 부서 · 직책 칸 높이 40(--size-filter-control) · 줄 ✕ 36×40(--size-filter-remove) ·
|
||||
* 구성원 넣기 창의 「내 연락처 | 사내 직원」 36(--size-segment-item-lg — .segmented) · 찾기 칸 40(--size-filter-control)
|
||||
*/
|
||||
/*
|
||||
* 14단계(검색 · 조건 검색) 밀도 예외 — 새 토큰 없이 위의 것을 다시 쓴다 (디자인 README "14단계 — 검색 · 대화 묶기", 사용자 확정 캔버스 Search14).
|
||||
* 검색 칸 안의 [조건] 높이 32(--size-compact-control) ·
|
||||
* 「조건으로 찾기」 패널의 칸(보낸 사람 · 받는 사람 · 제목 · 내용 · 기간 · 날짜) · [조건 지우기] · [찾기] 높이 40(--size-filter-control) ·
|
||||
* 결과 머리 아래 조건 줄 높이 40(--size-row-min) · 줄 안의 [조건 지우기] 36(--size-notice-action)
|
||||
*/
|
||||
--size-status-dot: 0.5rem; /* 8px — 설정 › 알림의 실시간 연결 점 */
|
||||
--size-option-input: 1.125rem; /* 18px — 설정 화면의 고르기 상자 · 라디오 (줄 전체가 44px 로 눌린다) */
|
||||
--form-max-width: 26.25rem; /* 420px — 비밀번호 칸 (디자인 원본 SettingsAccount) */
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// 메일 검색의 접기 · 글자 두 개 묶음 · 맞은 곳 · 말머리 떼기 표본 — 단위 테스트 전용 (앱 코드에서 import 하지 않는다)
|
||||
// 화면의 미리 확인(shared/utils/search-match.ts — 한 글자 안내)과 서버의 색인 · 찾기 · 강조(backend `src/shared/utils/search-text.ts` · `subject-base.ts`)를 같은 표로 본다.
|
||||
|
||||
/**
|
||||
* ⚠️ backend `test/search-text-samples.ts` 와 **같은 표**다(행과 순서까지 그대로).
|
||||
* 한쪽을 고치면 둘 다 고친다 — 접기 · 위치 규칙을 바꾸면 두 쪽 함수와 두 표를 함께 (서버가 준 맞은 곳의 위치를 화면이 그대로 그린다).
|
||||
* 답은 손으로 적었고 두 쪽 테스트가 함수의 결과와 견준다.
|
||||
*/
|
||||
|
||||
/** 접기 — [넣은 글, 접은 글]. 접은 글은 NFC 원문과 UTF-16 길이가 늘 같다 */
|
||||
export const FOLD_SAMPLES: readonly (readonly [input: string, folded: string])[] = [
|
||||
['계약서', '계약서'],
|
||||
['Project PLAN', 'project plan'],
|
||||
// 전각 ASCII → 반각 (대문자는 소문자로까지)
|
||||
['ABC123!', 'abc123!'],
|
||||
['RE:회의', 're:회의'],
|
||||
['[공지]', '[공지]'],
|
||||
// 공백류(전각 공백 · 줄바꿈 · 탭 · NBSP · CR)는 띄어쓰기 하나로 1:1 (색인 판 2) — 이어진 공백을 줄이지는 않는다(위치를 지킨다)
|
||||
['회의\u3000일정', '회의 일정'],
|
||||
['계약\n갱신\t안내\u00a0끝', '계약 갱신 안내 끝'],
|
||||
['a\r\n\nb', 'a b'],
|
||||
// 소문자가 코드 포인트 하나 · 같은 길이일 때만 — 'İ'(U+0130)는 'i̇' 두 글자가 되므로 그대로
|
||||
['İstanbul', 'İstanbul'],
|
||||
['ÀÉÎÕÜ', 'àéîõü'],
|
||||
['ΣΑΣ', 'σασ'],
|
||||
// 풀어 쓴 한글(NFD)은 NFC 로 모은다
|
||||
['\u1112\u1161\u11ab\u1100\u1173\u11af', '한글'],
|
||||
// ㈜ 는 그대로 (NFKC 가 아니다 — 길이를 바꾸지 않는다)
|
||||
['㈜아라', '㈜아라'],
|
||||
// BMP 밖 글자 — Deseret 대문자는 소문자로(둘 다 두 단위), 이모지는 그대로
|
||||
['\u{10400}😀', '\u{10428}😀'],
|
||||
['', ''],
|
||||
]
|
||||
|
||||
/** 글자 두 개 묶음 — [접은 글, 묶음(나온 차례, 겹치지 않게)] */
|
||||
export const GRAM_SAMPLES: readonly (readonly [fold: string, grams: readonly string[]])[] = [
|
||||
['계약서', ['계약', '약서']],
|
||||
['회의 일정', ['회의', '일정']],
|
||||
['계약', ['계약']],
|
||||
['계', []],
|
||||
['a b c', []],
|
||||
['aaaa', ['aa']],
|
||||
['ab\nab\tab', ['ab']],
|
||||
// 전각 공백 · NBSP 도 공백이다
|
||||
['회의\u3000일정\u00a0안내', ['회의', '일정', '안내']],
|
||||
// 문장부호는 글자다 — 're:' 의 'e:' 도 묶음
|
||||
['re:회의', ['re', 'e:', ':회', '회의']],
|
||||
// BMP 밖 글자는 코드 포인트 하나
|
||||
['😀😁', ['😀😁']],
|
||||
['', []],
|
||||
]
|
||||
|
||||
/** 맞은 곳 — [원문(NFC), 찾는 말들, 맞은 곳들 [시작, 끝) UTF-16] — 겹치거나 맞닿으면 합친다 */
|
||||
export const MATCH_SAMPLES: readonly (readonly [
|
||||
original: string,
|
||||
terms: readonly string[],
|
||||
ranges: readonly (readonly [number, number])[],
|
||||
])[] = [
|
||||
['계약서를 보냈습니다', ['계약'], [[0, 2]]],
|
||||
['Project plan', ['PROJ'], [[0, 4]]],
|
||||
['ABC 주문', ['abc'], [[0, 3]]],
|
||||
[
|
||||
'회의 회의록 회의실',
|
||||
['회의'],
|
||||
[
|
||||
[0, 2],
|
||||
[3, 5],
|
||||
[7, 9],
|
||||
],
|
||||
],
|
||||
// 겹치는 자리도 찾아 합친다
|
||||
['aaa', ['aa'], [[0, 3]]],
|
||||
// 맞닿은 두 말은 하나로
|
||||
['계약서', ['계약', '서'], [[0, 3]]],
|
||||
[
|
||||
'회의 일정 안내',
|
||||
['일정', '회의'],
|
||||
[
|
||||
[0, 2],
|
||||
[3, 5],
|
||||
],
|
||||
],
|
||||
// 구절은 줄바꿈 · NBSP 로 갈린 글에도 맞는다 (공백류 → 띄어쓰기 1:1) — 위치는 원문 그대로
|
||||
['계약\n갱신 안내', ['계약 갱신'], [[0, 5]]],
|
||||
['계약\u00a0갱신', ['계약 갱신'], [[0, 5]]],
|
||||
// 구절은 띄어쓰기까지 그대로
|
||||
['회의 일정 안내', ['회의 일정'], [[0, 5]]],
|
||||
['회의 일정', ['회의 일정'], []],
|
||||
[
|
||||
'[공지] Re: 회의',
|
||||
['공지', 're'],
|
||||
[
|
||||
[1, 3],
|
||||
[5, 7],
|
||||
],
|
||||
],
|
||||
// 빈 말은 건너뛴다 · 없는 말은 맞은 곳 없음
|
||||
['계약', ['', '없음'], []],
|
||||
// BMP 밖 글자 앞뒤에서도 UTF-16 위치
|
||||
['😀계약', ['계약'], [[2, 4]]],
|
||||
['', ['계약'], []],
|
||||
]
|
||||
|
||||
/** 말머리 떼기 — [제목, 말머리를 뗀 접기 글] */
|
||||
export const SUBJECT_BASE_SAMPLES: readonly (readonly [subject: string, base: string])[] = [
|
||||
['회의 일정 안내', '회의 일정 안내'],
|
||||
['Re: 회의 일정 안내', '회의 일정 안내'],
|
||||
['RE: Re: FW: 회의 일정 안내', '회의 일정 안내'],
|
||||
['Re:회의', '회의'],
|
||||
['Fwd: 견적서', '견적서'],
|
||||
['AW: WG: Angebot', 'angebot'],
|
||||
// 한국어 Outlook — 엔진은 떼지 않는다
|
||||
['회신: 회의 일정 안내', '회의 일정 안내'],
|
||||
['답장: 회신: 전달: 회의', '회의'],
|
||||
['회신 : 회의', '회의'],
|
||||
// 수 · 전각 쌍점
|
||||
['Re[2]: 회의', '회의'],
|
||||
['RE(3): 회의', '회의'],
|
||||
['Re:회의', '회의'],
|
||||
['RE: 회의', '회의'],
|
||||
// 대괄호 꼬리표 (여러 겹 · 말머리와 섞여도)
|
||||
['[공지] 회의 일정', '회의 일정'],
|
||||
['Re: [공지] [중요] 회의', '회의'],
|
||||
['[외부] RE: 견적', '견적'],
|
||||
// 공백은 하나로 · 대소문자 없이
|
||||
[' Re: 회의 일정 ', '회의 일정'],
|
||||
['Project PLAN', 'project plan'],
|
||||
// 쌍점이 없으면 말머리가 아니다
|
||||
['Re 회의', 're 회의'],
|
||||
['Reply to 회의', 'reply to 회의'],
|
||||
['Regarding: 회의', 'regarding: 회의'],
|
||||
['회신드립니다', '회신드립니다'],
|
||||
// 다 떼면 빈 글
|
||||
['Re:', ''],
|
||||
['[공지]', ''],
|
||||
['', ''],
|
||||
]
|
||||
Reference in New Issue
Block a user