generated from ttipo/project-base
feat(frontend): 목록을 대화로 묶어 보게 한다 (14단계 화면 3차)
메일함 목록을 대화 한 줄로 묶어 보는 [대화로 묶기]와 설정 › 보내기 · 읽기의 한 줄을 더한다. 켜면 목록 출처가 GET /api/conversations 이고, 줄에 하는 일 (고르기 · 읽음 · 중요 · 이동 · 삭제)은 그 줄의 messageIds 에서 붙잡힌 메일을 뺀 메일을 지금의 여러 통 API 로 보낸다 — 붙잡힌 메일은 취소로만 보낸편지함을 떠난다(9단계). - 대화 목록에서는 messages 가 비므로 읽기 화면 · 삭제 · 예약 취소가 줄 기준 도우미(rowCount · rowIndexOfMessage · isListedMessage 등)로 같은 규칙을 탄다 - 첫 진입은 이 브라우저가 그 직원에게서 마지막으로 안 값으로 열고, 설정을 받지 못해도 [대화로 묶기]를 쓸 수 있다 - 리뷰 M1: 로그아웃 · 세션 만료 직후 설정 reset 이 watcher 를 깨워 떠나는 화면의 목록을 다시 부르던 회귀 — canUseApp 이 false 면 부르지 않고 reset 은 보이는 값을 뒤집지 않는다 - 리뷰 L7: 버린 목록 요청은 AbortController 로 끊는다(엔진 방식은 한 요청이 15초까지) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import { useMailStore, type MailBulkResult, type MoveOptions } from '@/stores/ma
|
||||
// - 스팸 신고(스팸메일함으로 + 스팸 표시) · 스팸 아님(스팸메일함에서 — 받은편지함으로 + "스팸 아님" 표시): 옮기기와 같은 길에 isJunk 를 함께 보낸다
|
||||
// - 열어 둔 메일을 옮기거나 지워 목록에서 빠졌으면 목록 주소로 바꿔 끼운다 (본문 칸이 없는 메일을 붙들지 않게).
|
||||
// 끝났을 때 다른 목록으로 옮겨 가 있으면(stale) 그 목록에 기대어 움직이지 않는다
|
||||
// - 대화로 묶은 메일함(14단계)은 고른 줄의 메일 모두에 한다(붙잡힌 메일은 빼고 — 스토어). "중요 해제"는 고른 줄이 모두 중요 표시가 있을 때
|
||||
|
||||
export function useBulkMailActions() {
|
||||
const mailStore = useMailStore()
|
||||
@@ -22,10 +23,11 @@ export function useBulkMailActions() {
|
||||
/** 옮기기 · 지우기를 하는 중 — 끝날 때까지 다시 누르지 못한다 (읽음 · 중요 표시는 막지 않는다) */
|
||||
const busy = ref<boolean>(false)
|
||||
|
||||
/** 고른 메일이 모두 중요 표시 — 단추가 "중요 해제"가 된다 */
|
||||
const allFlagged = computed<boolean>(
|
||||
() => mailStore.checkedMessages.length > 0 && mailStore.checkedMessages.every((message) => message.isFlagged),
|
||||
)
|
||||
/** 고른 메일이 모두 중요 표시 — 단추가 "중요 해제"가 된다 (대화 목록은 고른 줄이 모두 중요 표시가 있을 때) */
|
||||
const allFlagged = computed<boolean>(() => {
|
||||
const rows = mailStore.isConversationList ? mailStore.checkedConversations : mailStore.checkedMessages
|
||||
return rows.length > 0 && rows.every((row) => row.isFlagged)
|
||||
})
|
||||
/** 휴지통을 보는 중 — "삭제"가 "영구 삭제"가 된다 (휴지통 안의 검색 포함) */
|
||||
const inTrash = computed<boolean>(() => mailStore.currentMailbox?.role === 'trash')
|
||||
/** 스팸메일함을 보는 중 — 「스팸 신고」가 「스팸 아님」이 된다 (스팸메일함 안의 검색 포함) */
|
||||
@@ -56,7 +58,7 @@ export function useBulkMailActions() {
|
||||
const openId = mailStore.selectedId
|
||||
const source = mailStore.listSource
|
||||
if (result.stale || !openId || !source || !result.doneIds.includes(openId)) return
|
||||
if (mailStore.messages.some((message) => message.id === openId)) return
|
||||
if (mailStore.isListedMessage(openId)) return
|
||||
await router.replace(listLocationOf(source))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import { useToast } from './useToast'
|
||||
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
|
||||
import type { ConversationSummary, MailboxRole } from '@/shared/types/mail'
|
||||
import { conversationWho, isConversationHeld } from '@/shared/utils/mail-conversation-list'
|
||||
import { isDraftRole, isRowUnread, type RowWho } from '@/shared/utils/mail-list-row'
|
||||
import { messageLocationOf } from '@/shared/utils/mail-list-source'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
/**
|
||||
* 대화로 묶은 메일함의 줄마다 필요한 값 (14단계 — 디자인 원본 ListThreads14) — 분할 보기 줄(ConversationListItem)과 목록 보기 표(ConversationTableRow)가 함께 쓴다.
|
||||
* - 이름 자리: 참여자(내 주소는 "나" — 끝에), 보낸편지함 · 임시보관함에서 나뿐이면 "받는 사람: …" (mail-conversation-list.ts 의 conversationWho)
|
||||
* - 안 읽음: 하나라도 안 읽었으면 굵게 — 임시보관함은 메일 목록처럼 굵게 하지 않는다
|
||||
* - 누르면 갈 곳: 그 메일함의 마지막 메일 — 임시보관함이면 쓰기 화면에서 이어 쓴다(메일 목록과 같다)
|
||||
* - 열린 메일이 든 줄: 마지막 메일이거나 줄에 든 메일([이 메일만 열기]로 연 더 오래된 메일)
|
||||
* - 별: 줄의 메일 모두(붙잡힌 메일은 빼고 — 스토어). 줄의 중요 표시가 붙잡힌 메일에만 있어 풀리지 않았으면 그 까닭을 알린다
|
||||
*/
|
||||
export function useConversationRows() {
|
||||
const mailStore = useMailStore()
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
/** 목록의 메일함 역할 — 묶은 목록은 늘 메일함 하나다 */
|
||||
const role = computed<MailboxRole | null>(() => mailStore.currentMailbox?.role ?? null)
|
||||
/** 임시보관함 — 제목 없는 저장본을 흐린 글자로 */
|
||||
const isDraft = computed<boolean>(() => isDraftRole(role.value))
|
||||
|
||||
function whoOf(row: ConversationSummary, view: 'split' | 'table'): RowWho {
|
||||
return conversationWho(row, { myEmail: authStore.user?.email, role: role.value, view })
|
||||
}
|
||||
|
||||
function isUnread(row: ConversationSummary): boolean {
|
||||
return isRowUnread({ isRead: row.isRead }, role.value)
|
||||
}
|
||||
|
||||
/** 줄의 메일이 모두 붙잡힌 메일 — 고르기 · 별을 쓸 수 없다 */
|
||||
function isHeld(row: ConversationSummary): boolean {
|
||||
return isConversationHeld(row)
|
||||
}
|
||||
|
||||
function locationOf(row: ConversationSummary): RouteLocationRaw {
|
||||
if (isDraftRole(role.value)) return { name: 'mail-compose', query: { draft: row.latest.id } }
|
||||
const source = mailStore.listSource
|
||||
return source ? messageLocationOf(source, row.latest.id) : { name: 'mail' }
|
||||
}
|
||||
|
||||
function isSelected(row: ConversationSummary): boolean {
|
||||
const id = mailStore.selectedId
|
||||
return id !== null && (row.latest.id === id || row.messageIds.includes(id))
|
||||
}
|
||||
|
||||
async function toggleFlag(rowId: string): Promise<void> {
|
||||
const { heldFlagged } = await mailStore.toggleConversationFlag(rowId)
|
||||
if (heldFlagged) toast.error(MAIL_CONVERSATION_LIST_TEXT.heldFlagNote)
|
||||
}
|
||||
|
||||
return { role, isDraft, whoOf, isUnread, isHeld, locationOf, isSelected, toggleFlag }
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
import { MAIL_CONVERSATION_TIMEOUT_MS } from '@/shared/constants/mail-conversation'
|
||||
import type {
|
||||
Attachment,
|
||||
ConversationListPage,
|
||||
ConversationListQuery,
|
||||
ConversationPage,
|
||||
Draft,
|
||||
DraftPayload,
|
||||
@@ -52,6 +54,7 @@ import type {
|
||||
} from '@/shared/types/mail'
|
||||
import type { CursorPaginationQuery, PaginatedResult } from '@/shared/types/pagination'
|
||||
import { conversationPageOf } from '@/shared/utils/mail-conversation'
|
||||
import { conversationListPageOf } from '@/shared/utils/mail-conversation-list'
|
||||
import { pendingSendOf } from '@/shared/utils/pending-send'
|
||||
|
||||
/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
|
||||
@@ -294,6 +297,23 @@ export function useMail() {
|
||||
return conversationPageOf(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* 그 메일함을 대화로 묶은 목록 — 한 줄이 대화 하나, 그 메일함 안 마지막 받은 시각 최신부터 (14단계 — 계약 2절 "대화로 묶어 보기").
|
||||
* 커서는 그 메일함 · 그 기준에서만 통한다. 엔진 방식은 한 요청이 15초까지 걸릴 수 있어 같은 대화와 같은 시간 한도를 쓴다.
|
||||
* 응답은 모양을 맞춰 돌려준다 — 모양이 틀린 줄은 빼고, 마지막 메일은 늘 messageIds 에 든다
|
||||
*/
|
||||
async function listConversations(
|
||||
query: ConversationListQuery,
|
||||
options?: MailRequestOptions,
|
||||
): Promise<ConversationListPage> {
|
||||
const page = await api.get<unknown, unknown>('/conversations', {
|
||||
timeout: MAIL_CONVERSATION_TIMEOUT_MS,
|
||||
...options,
|
||||
params: query,
|
||||
})
|
||||
return conversationListPageOf(page)
|
||||
}
|
||||
|
||||
function updateMessage(
|
||||
id: string,
|
||||
changes: UpdateMessagePayload,
|
||||
@@ -500,6 +520,7 @@ export function useMail() {
|
||||
getSearchIndex,
|
||||
getMessage,
|
||||
getConversation,
|
||||
listConversations,
|
||||
updateMessage,
|
||||
updateMessages,
|
||||
deleteMessages,
|
||||
|
||||
@@ -9,20 +9,35 @@ export type MailSettingsRequestOptions = Pick<AxiosRequestConfig, 'silent' | 'si
|
||||
/** 경로에 넣는 값은 항상 인코딩한다 — 도메인 차단 "@spam.example" 은 "%40spam.example" */
|
||||
const segment = (value: string) => encodeURIComponent(value)
|
||||
|
||||
/** GET · PATCH /mail-settings 가 실제로 주는 모양 — 14단계 앞의 서버는 groupByConversation 이 없다 */
|
||||
type MailSettingsReply = Omit<MailSettings, 'groupByConversation'> & { groupByConversation?: unknown }
|
||||
|
||||
/** 설정 응답 → 화면의 값. groupByConversation 이 불리언 true 가 아니면 묶지 않는 것으로 본다 (처음 값 — 계약 2절 "보내기 · 읽기 설정") */
|
||||
function mailSettingsOf(reply: MailSettingsReply): MailSettings {
|
||||
// 응답을 바꾼 게이트웨이 등으로 객체가 아닐 수도 있다 — 그때도 던지지 않고 묶지 않는 것으로 본다
|
||||
const value: unknown = reply
|
||||
const grouped =
|
||||
typeof value === 'object' && value !== null && (value as { groupByConversation?: unknown }).groupByConversation === true
|
||||
return { ...reply, groupByConversation: grouped }
|
||||
}
|
||||
|
||||
// 보내기 · 읽기 설정 · 보낸 사람 차단 API Composable — docs/mail/api-contract.md 2절 "보내기 · 읽기 설정" · "보낸 사람 차단" (9단계, 모두 로그인 필요)
|
||||
// 인터셉터가 { success, data } 를 언래핑하므로 각 호출의 응답 타입(R)을 data 타입으로 지정한다.
|
||||
// 컴포넌트는 이것을 직접 부르지 않고 스토어(mail-settings.store · mail-block.store)를 거친다
|
||||
export function useMailSettings() {
|
||||
const api = useApi()
|
||||
|
||||
/** 직원의 설정 — 저장한 적 없으면 처음 값(10초 · 늘 묻기) */
|
||||
function getSettings(options?: MailSettingsRequestOptions): Promise<MailSettings> {
|
||||
return api.get<MailSettings, MailSettings>('/mail-settings', options)
|
||||
/** 직원의 설정 — 저장한 적 없으면 처음 값(10초 · 늘 묻기 · 묶지 않음) */
|
||||
async function getSettings(options?: MailSettingsRequestOptions): Promise<MailSettings> {
|
||||
return mailSettingsOf(await api.get<MailSettingsReply, MailSettingsReply>('/mail-settings', options))
|
||||
}
|
||||
|
||||
/** 고른 칸만 보낸다(하나 이상) — 응답은 저장한 뒤의 설정 전체 */
|
||||
function updateSettings(patch: Partial<MailSettings>, options?: MailSettingsRequestOptions): Promise<MailSettings> {
|
||||
return api.patch<MailSettings, MailSettings>('/mail-settings', patch, options)
|
||||
/** 고른 칸만 보낸다(하나 이상) — 응답은 저장한 뒤의 설정 전체. groupByConversation 은 불리언만 받는다(그 밖은 400 VAL_001) */
|
||||
async function updateSettings(
|
||||
patch: Partial<MailSettings>,
|
||||
options?: MailSettingsRequestOptions,
|
||||
): Promise<MailSettings> {
|
||||
return mailSettingsOf(await api.patch<MailSettingsReply, MailSettingsReply>('/mail-settings', patch, options))
|
||||
}
|
||||
|
||||
/** 차단한 주소 · 도메인 — 최근에 차단한 것부터 500개까지 */
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useMailStore } from '@/stores/mail.store'
|
||||
// 이 길에서는 낡은 정보가 해롭지 않다: 이미 휴지통에 있는 메일을 휴지통으로 옮기면 그대로이고, 휴지통을 떠난 메일의 영구 삭제는
|
||||
// 서버가 그대로 둔다(skippedIds — 목록을 다시 맞춘다)
|
||||
// - 뺀 뒤(leaveRemoved): 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (기록을 쌓지 않는다).
|
||||
// 대화로 묶은 메일함(14단계)은 같은 결로 그 줄의 다음 칸(더 오래된 메일), 없으면 앞 칸, 줄이 비면 다음 줄의 마지막 메일 (스토어의 neighborAfterRemoval).
|
||||
// 그사이 다른 목록으로 옮겨 갔거나 서버가 그대로 두었으면 움직이지 않는다
|
||||
|
||||
export interface MessageRemovalOutcome {
|
||||
@@ -55,7 +56,7 @@ export function useMessageRemoval() {
|
||||
}
|
||||
|
||||
function outcomeOf(id: string, nextId: string | null, done: boolean, stale: boolean): MessageRemovalOutcome {
|
||||
const removed = done && !stale && !mailStore.messages.some((message) => message.id === id)
|
||||
const removed = done && !stale && !mailStore.isListedMessage(id)
|
||||
return { removed, nextId: removed ? nextId : null }
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,8 @@ export function usePendingSendRefresh(options: PendingSendRefreshOptions = {}) {
|
||||
|
||||
const watches = computed<PendingWatch[]>(() =>
|
||||
pendingWatchesOf([
|
||||
...mailStore.messages,
|
||||
// 보이는 목록의 줄 — 대화로 묶은 메일함은 줄마다 마지막 메일 (14단계 — 줄 안의 다른 붙잡힌 메일은 시각을 몰라 다음 맞추기를 기다린다)
|
||||
...mailStore.listedSummaries,
|
||||
mailStore.selected,
|
||||
mailStore.nextScheduled,
|
||||
...mailStore.conversationItems,
|
||||
|
||||
@@ -74,7 +74,7 @@ export function useScheduledMessageActions() {
|
||||
|
||||
/** 목록에서 빠졌는가 — 빠졌으면 삭제처럼 다음 메일(분할 보기) · 목록(목록 보기)으로 */
|
||||
function removedFromList(messageId: string): boolean {
|
||||
return !mailStore.messages.some((message) => message.id === messageId)
|
||||
return !mailStore.isListedMessage(messageId)
|
||||
}
|
||||
|
||||
/** [예약 취소] — 보내지 않고 임시보관함으로 */
|
||||
|
||||
Reference in New Issue
Block a user