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:
2026-11-06 07:53:56 +09:00
parent 59375f1347
commit ab16379836
52 changed files with 4746 additions and 194 deletions
+30 -6
View File
@@ -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 }
}