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:
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user