generated from ttipo/project-base
feat(frontend): 전역 셸·로그인·세션 처리와 웹메일 화면
확정된 디자인(docs/design/webmail)을 기준으로 만든 웹메일 화면이다. - 앞으로 붙일 사내 모듈(캘린더·파일·ERP 등)을 위한 전역 앱 레일과 셸, Pretendard 자체 호스팅, 바다 청록 브랜드 토큰과 콘텐츠 열 토큰 - 로그인과 세션 처리: 401 이면 한 번 갱신한 뒤 다시 보내고, 실패하면 세션 만료 대화상자를 띄운다. 로그아웃은 다른 탭에도 알리고, 로그아웃 뒤에 늦게 온 갱신 결과는 버린다 - 메일함(분할·목록 보기), 읽기(외부 HTML 은 sandbox iframe 으로만 그린다), 쓰기(발송 키 규칙, 첨부 형식·크기·합계·개수 사전 검사, 답장·전달 초안 길이 맞춤) - 오류는 코드로만 분기하고, 문구는 생성된 에러 사전에서 가져온다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
// 선 아이콘 — 도형 목록은 shared/constants/icons.ts 에 있다.
|
||||
// 장식용이면 스크린리더에서 숨기고, 의미가 있으면 label 을 넘겨 이름을 읽게 한다.
|
||||
// 사용 예: <AppIcon name="paperclip" :size="13" label="첨부 있음" />
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { ICONS, REM_BASE_PX, type IconName } from '@/shared/constants/icons'
|
||||
|
||||
interface Props {
|
||||
name: IconName
|
||||
/** 한 변의 크기 (디자인 원본의 px 값) — rem 으로 바꿔 그린다 */
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
/** 안을 채운다 (중요 표시 별) */
|
||||
filled?: boolean
|
||||
/** 의미가 있는 아이콘이면 읽어 줄 이름. 비우면 장식으로 숨긴다 */
|
||||
label?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 16,
|
||||
strokeWidth: 1.75,
|
||||
filled: false,
|
||||
label: '',
|
||||
})
|
||||
|
||||
const dimension = computed(() => `${props.size / REM_BASE_PX}rem`)
|
||||
const shapes = computed(() => ICONS[props.name])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
class="app-icon"
|
||||
:width="dimension"
|
||||
:height="dimension"
|
||||
viewBox="0 0 24 24"
|
||||
:fill="filled ? 'currentColor' : 'none'"
|
||||
stroke="currentColor"
|
||||
:stroke-width="strokeWidth"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
:role="label ? 'img' : undefined"
|
||||
:aria-label="label || undefined"
|
||||
:aria-hidden="label ? undefined : 'true'"
|
||||
>
|
||||
<component
|
||||
:is="shape.tag"
|
||||
v-for="(shape, index) in shapes"
|
||||
:key="index"
|
||||
v-bind="shape.attrs"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-icon {
|
||||
flex: 0 0 auto;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -50,12 +50,14 @@ function handleClick(event: MouseEvent) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 모든 치수는 rem 기준 (16px = 1rem) */
|
||||
/* 색·간격·글자 크기는 styles/tokens.css 의 변수만 쓴다. 다른 치수는 rem 기준 (16px = 1rem) */
|
||||
.base-button {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 1rem;
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-16);
|
||||
font-size: var(--font-size-emphasis);
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -65,12 +67,16 @@ function handleClick(event: MouseEvent) {
|
||||
}
|
||||
|
||||
.base-button--primary {
|
||||
background-color: #4f46e5;
|
||||
color: #ffffff;
|
||||
background-color: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
}
|
||||
|
||||
.base-button--primary:hover:not(:disabled) {
|
||||
background-color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.base-button--secondary {
|
||||
background-color: #e5e7eb;
|
||||
color: #111827;
|
||||
background-color: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { flushPromises } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import SessionExpiredDialog from './SessionExpiredDialog.vue'
|
||||
|
||||
describe('SessionExpiredDialog', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
})
|
||||
|
||||
it('세션이 만료되면 사전 문구(AUTH_002)로 열리고, 확인하면 로그인 화면으로 보낸다 (돌아올 주소 포함)', async () => {
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail/inbox/m1')
|
||||
await router.isReady()
|
||||
const wrapper = mount(SessionExpiredDialog, { global: { plugins: [pinia, router] } })
|
||||
const authStore = useAuthStore()
|
||||
authStore.markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
|
||||
authStore.expireSession()
|
||||
await flushPromises()
|
||||
|
||||
const dialog = wrapper.get('dialog')
|
||||
expect(dialog.attributes('open')).toBeDefined()
|
||||
expect(dialog.text()).toContain(ERROR_CODE_MESSAGES.AUTH_002)
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(authStore.sessionExpired).toBe(false)
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/mail/inbox/m1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
// 세션 만료(AUTH_002) 안내 — 디자인 원본 States "세션 만료"
|
||||
// useApi 가 로그인 상태의 401(갱신 실패)을 받으면 authStore.expireSession() 을 부르고, 이 대화상자가 뜬다.
|
||||
// 모달이라 뒤의 화면(이미 정리된 세션)을 더 조작할 수 없다. Esc 로 닫아도 로그인 화면으로 보낸다.
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { getErrorMessage } from '@/composables/useApi'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const dialogRef = ref<HTMLDialogElement | null>(null)
|
||||
/** 문구는 에러 코드 사전에서만 온다 */
|
||||
const message = getErrorMessage('AUTH_002')
|
||||
|
||||
function open() {
|
||||
const dialog = dialogRef.value
|
||||
if (!dialog || dialog.open) return
|
||||
// showModal 이 없는 환경(구형 브라우저·테스트)에서는 열린 상태만 표시한다
|
||||
if (typeof dialog.showModal === 'function') dialog.showModal()
|
||||
else dialog.setAttribute('open', '')
|
||||
}
|
||||
|
||||
function close() {
|
||||
const dialog = dialogRef.value
|
||||
if (!dialog?.open) return
|
||||
if (typeof dialog.close === 'function') dialog.close()
|
||||
else dialog.removeAttribute('open')
|
||||
}
|
||||
|
||||
async function goToLogin() {
|
||||
// 로그인 후 보던 화면으로 돌아오게 한다 (로그인이 필요한 화면일 때만)
|
||||
const redirect = route.meta.requiresAuth ? route.fullPath : undefined
|
||||
authStore.acknowledgeSessionExpired()
|
||||
await router.push({ name: 'login', query: redirect ? { redirect } : {} })
|
||||
}
|
||||
|
||||
function onCancel(event: Event) {
|
||||
event.preventDefault()
|
||||
void goToLogin()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => authStore.sessionExpired,
|
||||
(expired) => (expired ? open() : close()),
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (authStore.sessionExpired) open()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog
|
||||
ref="dialogRef"
|
||||
class="session-dialog"
|
||||
aria-labelledby="session-expired-title"
|
||||
aria-describedby="session-expired-message"
|
||||
@cancel="onCancel"
|
||||
>
|
||||
<h2
|
||||
id="session-expired-title"
|
||||
class="session-dialog__title"
|
||||
>
|
||||
다시 로그인해 주세요
|
||||
</h2>
|
||||
<p
|
||||
id="session-expired-message"
|
||||
class="session-dialog__message"
|
||||
>
|
||||
{{ message }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="session-dialog__action"
|
||||
@click="goToLogin"
|
||||
>
|
||||
로그인 화면으로
|
||||
</button>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.session-dialog {
|
||||
width: min(22.5rem, calc(100vw - var(--space-48)));
|
||||
padding: var(--space-18) var(--space-18) var(--space-14);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
}
|
||||
|
||||
.session-dialog[open] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.session-dialog::backdrop {
|
||||
background: var(--color-backdrop);
|
||||
}
|
||||
|
||||
.session-dialog__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-title-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.session-dialog__message {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.session-dialog__action {
|
||||
align-self: flex-end;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--size-touch);
|
||||
margin-top: var(--space-4);
|
||||
padding: 0 var(--space-16);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.session-dialog__action:hover {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
// 영역 안의 상태 안내 — 빈 메일함 · 메일 미선택 · 불러오기 실패 (디자인 원본 States)
|
||||
// 오류 문구는 호출부가 ApiError.message(에러 코드 사전)를 그대로 넘긴다. 여기서 문구를 만들지 않는다.
|
||||
// 사용 예: <StateMessage tone="danger" icon="alert-circle" :title="error.message" :trace-id="error.traceId">
|
||||
// <button …>다시 시도</button>
|
||||
// </StateMessage>
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
import { formatTraceCode } from '@/shared/utils/format'
|
||||
|
||||
interface Props {
|
||||
icon: IconName
|
||||
title: string
|
||||
hint?: string
|
||||
/** danger 면 빨간 아이콘 + 스크린리더에 바로 알린다 */
|
||||
tone?: 'neutral' | 'danger'
|
||||
/** 서버 추적 ID — 앞 8자리를 "문의 코드"로 보인다 */
|
||||
traceId?: string | null
|
||||
/** 영역을 세로로 가득 채워 가운데 둔다 (본문 영역의 "메일을 선택하세요") */
|
||||
fill?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
hint: '',
|
||||
tone: 'neutral',
|
||||
traceId: null,
|
||||
fill: false,
|
||||
})
|
||||
|
||||
const traceCode = computed(() => formatTraceCode(props.traceId))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="['state-message', `state-message--${tone}`, { 'state-message--fill': fill }]"
|
||||
:role="tone === 'danger' ? 'alert' : undefined"
|
||||
>
|
||||
<AppIcon
|
||||
:name="icon"
|
||||
:size="36"
|
||||
:stroke-width="1.5"
|
||||
class="state-message__icon"
|
||||
/>
|
||||
<p class="state-message__title">
|
||||
{{ title }}
|
||||
</p>
|
||||
<p
|
||||
v-if="hint"
|
||||
class="state-message__hint"
|
||||
>
|
||||
{{ hint }}
|
||||
</p>
|
||||
<slot />
|
||||
<span
|
||||
v-if="traceCode"
|
||||
class="state-message__trace"
|
||||
>문의 코드 {{ traceCode }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.state-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
padding: var(--space-80) var(--space-24);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.state-message--fill {
|
||||
flex: 1 1 auto;
|
||||
justify-content: center;
|
||||
min-height: 32.5rem;
|
||||
padding: var(--space-24);
|
||||
}
|
||||
|
||||
.state-message__icon {
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.state-message--danger .state-message__icon {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.state-message__title {
|
||||
margin: var(--space-6) 0 0;
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.state-message__hint {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.state-message__trace {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
// 화면 오른쪽 아래 알림 (디자인 원본 States "오류 알림" · "보내기 완료")
|
||||
// 오류: 흰 카드 + 문의 코드(traceId 앞 8자리) + 닫기 / 성공: 브랜드 바탕 + 이동 링크
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { formatTraceCode } from '@/shared/utils/format'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
|
||||
const toastStore = useToastStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="toast-host"
|
||||
aria-label="알림"
|
||||
>
|
||||
<TransitionGroup
|
||||
name="toast"
|
||||
tag="div"
|
||||
class="toast-host__stack"
|
||||
>
|
||||
<div
|
||||
v-for="toast in toastStore.toasts"
|
||||
:key="toast.id"
|
||||
:class="['toast', `toast--${toast.kind}`]"
|
||||
:role="toast.kind === 'error' ? 'alert' : 'status'"
|
||||
>
|
||||
<AppIcon
|
||||
:name="toast.kind === 'error' ? 'alert-circle' : 'check'"
|
||||
:size="toast.kind === 'error' ? 18 : 16"
|
||||
:stroke-width="toast.kind === 'error' ? 1.75 : 2.2"
|
||||
class="toast__icon"
|
||||
/>
|
||||
<div class="toast__body">
|
||||
<span class="toast__message">{{ toast.message }}</span>
|
||||
<span
|
||||
v-if="formatTraceCode(toast.traceId)"
|
||||
class="toast__trace"
|
||||
>문의 코드 {{ formatTraceCode(toast.traceId) }}</span>
|
||||
</div>
|
||||
<RouterLink
|
||||
v-if="toast.action"
|
||||
:to="toast.action.to"
|
||||
class="toast__action"
|
||||
@click="toastStore.dismiss(toast.id)"
|
||||
>
|
||||
{{ toast.action.label }}
|
||||
</RouterLink>
|
||||
<button
|
||||
v-if="toast.kind === 'error'"
|
||||
type="button"
|
||||
class="toast__close"
|
||||
aria-label="알림 닫기"
|
||||
@click="toastStore.dismiss(toast.id)"
|
||||
>
|
||||
<AppIcon
|
||||
name="x"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toast-host {
|
||||
position: fixed;
|
||||
right: var(--space-20);
|
||||
bottom: var(--space-20);
|
||||
z-index: 20;
|
||||
width: min(25rem, calc(100vw - var(--space-40)));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast-host__stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-10);
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-10);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.toast--error {
|
||||
padding: var(--space-12) var(--space-6) var(--space-12) var(--space-14);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-toast);
|
||||
}
|
||||
|
||||
.toast--success {
|
||||
align-items: center;
|
||||
padding: var(--space-8) var(--space-6) var(--space-8) var(--space-14);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-brand-surface);
|
||||
color: var(--color-on-brand);
|
||||
box-shadow: var(--shadow-toast-strong);
|
||||
}
|
||||
|
||||
.toast__icon {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.toast--error .toast__icon {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.toast--success .toast__icon {
|
||||
margin-top: 0;
|
||||
color: var(--color-on-brand-success);
|
||||
}
|
||||
|
||||
.toast__body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.toast__message {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toast__trace {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.toast__action {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-10);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-on-brand-link);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.toast__action:hover {
|
||||
color: var(--color-on-brand);
|
||||
}
|
||||
|
||||
.toast__action:focus-visible {
|
||||
outline-color: var(--color-on-brand-link);
|
||||
}
|
||||
|
||||
.toast__close {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--space-36);
|
||||
height: var(--space-36);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.toast__close:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.toast-enter-from,
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(var(--space-8));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,190 @@
|
||||
<script setup lang="ts">
|
||||
// 쓰기 화면의 첨부 목록 — 파일마다 진행률, 실패한 파일은 사유와 함께 빨갛게 (디자인 원본 States "첨부 업로드")
|
||||
// 어느 첨부든 할 수 있는 일은 "빼기"(✕) 하나다 — 올린 파일은 빼고 다시 고르면 되고, 전달 원본은 다시 올릴 수 없다.
|
||||
import { ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import type { UploadItem } from '@/composables/useAttachmentUploads'
|
||||
import { formatFileSize } from '@/shared/utils/format'
|
||||
|
||||
interface Props {
|
||||
items: UploadItem[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'remove', key: number): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const listRef = ref<HTMLUListElement | null>(null)
|
||||
|
||||
/** 그 첨부의 빼기 버튼으로 초점을 옮긴다 — 발송이 찾지 못한 전달 원본을 빼게 할 때 쓴다 */
|
||||
function focusRemove(key: number) {
|
||||
listRef.value
|
||||
?.querySelector<HTMLButtonElement>(`[data-key="${key}"] .upload-item__remove`)
|
||||
?.focus()
|
||||
}
|
||||
|
||||
defineExpose({ focusRemove })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul
|
||||
ref="listRef"
|
||||
class="upload-list"
|
||||
>
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
:class="['upload-item', `upload-item--${item.status}`]"
|
||||
:data-key="item.key"
|
||||
>
|
||||
<div class="upload-item__row">
|
||||
<AppIcon
|
||||
name="file"
|
||||
class="upload-item__icon"
|
||||
/>
|
||||
<span class="upload-item__name">{{ item.name }}</span>
|
||||
<span class="upload-item__meta">
|
||||
<template v-if="item.status === 'uploading'">올리는 중 {{ item.progress }}%</template>
|
||||
<template v-else>{{ formatFileSize(item.size) }}</template>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="upload-item__remove"
|
||||
:aria-label="`${item.name} 첨부 취소`"
|
||||
@click="emit('remove', item.key)"
|
||||
>
|
||||
<AppIcon
|
||||
name="x"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<progress
|
||||
v-if="item.status === 'uploading'"
|
||||
class="upload-item__progress"
|
||||
max="100"
|
||||
:value="item.progress"
|
||||
:aria-label="`${item.name} 올리는 중`"
|
||||
/>
|
||||
<p
|
||||
v-if="item.status === 'failed' && item.errorMessage"
|
||||
class="upload-item__error"
|
||||
role="alert"
|
||||
>
|
||||
{{ item.errorMessage }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.upload-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(17.5rem, 100%), 1fr));
|
||||
gap: var(--space-6);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.upload-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-10);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.upload-item__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
}
|
||||
|
||||
.upload-item__icon {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.upload-item__name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.upload-item__meta {
|
||||
flex: 0 0 auto;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.upload-item__remove {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--space-40);
|
||||
height: var(--space-40);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.upload-item__remove:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
/* 진행률 — 3px 막대 (인라인 스타일 대신 <progress> 를 꾸민다) */
|
||||
.upload-item__progress {
|
||||
appearance: none;
|
||||
width: calc(100% - var(--space-8));
|
||||
height: var(--space-3);
|
||||
margin: 0 0 var(--space-6);
|
||||
border: 0;
|
||||
border-radius: var(--space-2);
|
||||
background: var(--color-skeleton);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-item__progress::-webkit-progress-bar {
|
||||
background: var(--color-skeleton);
|
||||
border-radius: var(--space-2);
|
||||
}
|
||||
|
||||
.upload-item__progress::-webkit-progress-value {
|
||||
background: var(--color-brand);
|
||||
border-radius: var(--space-2);
|
||||
}
|
||||
|
||||
.upload-item__progress::-moz-progress-bar {
|
||||
background: var(--color-brand);
|
||||
border-radius: var(--space-2);
|
||||
}
|
||||
|
||||
.upload-item--failed {
|
||||
border-color: var(--color-danger-border);
|
||||
background: var(--color-danger-surface);
|
||||
}
|
||||
|
||||
.upload-item--failed .upload-item__name,
|
||||
.upload-item--failed .upload-item__meta,
|
||||
.upload-item--failed .upload-item__icon {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.upload-item__error {
|
||||
margin: 0 0 var(--space-6);
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,137 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailViewStore } from '@/stores/mail-view.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import ListReadNav from './ListReadNav.vue'
|
||||
|
||||
function summary(id: string): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [],
|
||||
subject: `제목 ${id}`,
|
||||
preview: '',
|
||||
receivedAt: '2026-10-26T01:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
}
|
||||
}
|
||||
|
||||
describe('ListReadNav (목록 보기에서 연 메일의 이동)', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
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'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function mountNav(messageId: string) {
|
||||
const router = createTestRouter()
|
||||
// 목록에서 메일을 연 것과 같은 기록: /mail/inbox → /mail/inbox/<id>
|
||||
await router.push('/mail/inbox')
|
||||
await router.push(`/mail/inbox/${messageId}`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(ListReadNav, {
|
||||
props: { mailboxId: 'inbox', messageId },
|
||||
global: { plugins: [pinia, router] },
|
||||
})
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
it('위치를 "n / 전체" 로 보인다 (전체는 서버가 준 total)', async () => {
|
||||
const { wrapper } = await mountNav('m2')
|
||||
|
||||
expect(wrapper.get('.nav-position').text()).toBe('2 / 5')
|
||||
})
|
||||
|
||||
it('첫 메일이면 이전 버튼이 꺼진다', async () => {
|
||||
const { wrapper } = await mountNav('m1')
|
||||
|
||||
expect(wrapper.get('button[aria-label="이전 메일"]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('button[aria-label="다음 메일"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('이전 메일로 넘어가도 기록을 늘리지 않는다 — 뒤로 가기는 목록으로 간다', async () => {
|
||||
const { wrapper, router } = await mountNav('m2')
|
||||
|
||||
await wrapper.get('button[aria-label="이전 메일"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
|
||||
|
||||
router.back()
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('불러온 범위의 끝에서 다음을 누르면 다음 페이지를 불러온 뒤 넘어간다', async () => {
|
||||
const requests = installMockApi(() =>
|
||||
ok({ items: [summary('m4'), summary('m5')], pageInfo: { hasNext: false, nextCursor: null, total: 5 } }),
|
||||
)
|
||||
const { wrapper, router } = await mountNav('m3')
|
||||
|
||||
await wrapper.get('button[aria-label="다음 메일"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests[0]?.params).toMatchObject({ mailboxId: 'inbox', cursor: 'c2' })
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m4')
|
||||
})
|
||||
|
||||
it('마지막 메일이고 서버에도 더 없으면 다음 버튼이 꺼진다', async () => {
|
||||
useMailStore().pageInfo = { hasNext: false, nextCursor: null, total: 3 }
|
||||
const { wrapper } = await mountNav('m3')
|
||||
|
||||
expect(wrapper.get('.nav-position').text()).toBe('3 / 3')
|
||||
expect(wrapper.get('button[aria-label="다음 메일"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('목록으로 — 바로 앞 기록이 그 목록이면 뒤로 가기를 쓴다 (브라우저 기록을 쌓지 않는다)', async () => {
|
||||
// 메모리 history 는 history.state 를 쓰지 않으므로 이 경우만 실제 브라우저 history 로 확인한다
|
||||
const router = createRouter({ history: createWebHistory(), routes: createTestRouter().options.routes })
|
||||
await router.push('/mail/inbox')
|
||||
await router.push('/mail/inbox/m2')
|
||||
await router.isReady()
|
||||
const wrapper = mount(ListReadNav, {
|
||||
props: { mailboxId: 'inbox', messageId: 'm2' },
|
||||
global: { plugins: [pinia, router] },
|
||||
})
|
||||
|
||||
await wrapper.get('a.tool-button').trigger('click', { button: 0 })
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.fullPath).toBe('/mail/inbox'))
|
||||
|
||||
// 뒤로 간 것이라 앞으로 갈 기록(보던 메일)이 남아 있다 — 바꿔 끼웠다면 없다
|
||||
expect(router.options.history.state.forward).toBe('/mail/inbox/m2')
|
||||
})
|
||||
|
||||
it('목록으로 — 목록으로 돌아가고, 보던 메일로 초점을 돌려주게 한다', async () => {
|
||||
const { wrapper, router } = await mountNav('m2')
|
||||
|
||||
await wrapper.get('a.tool-button').trigger('click', { button: 0 })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(useMailViewStore().takeReturnFocus()).toBe('m2')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 보기에서 연 메일의 이동 도구 — 목록으로 · 이전/다음 · 위치 "n / 전체" (디자인 원본 MainListRead)
|
||||
// 본문 툴바의 앞쪽에 들어간다 (MessageReader 의 toolbar-start 슬롯).
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailToolButton from '@/components/mail/MailToolButton.vue'
|
||||
import MailToolbarDivider from '@/components/mail/MailToolbarDivider.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailViewStore } from '@/stores/mail-view.store'
|
||||
|
||||
interface Props {
|
||||
mailboxId: string
|
||||
messageId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const viewStore = useMailViewStore()
|
||||
const { mailboxLocation, backToList, showSibling } = useMailNavigation()
|
||||
|
||||
/** 다음 페이지를 불러오는 중 — 같은 버튼을 두 번 눌러도 한 번만 넘어간다 */
|
||||
const loadingNext = ref<boolean>(false)
|
||||
|
||||
const listHref = computed(() => router.resolve(mailboxLocation(props.mailboxId)).href)
|
||||
/** 불러온 목록에서의 위치 — 목록 밖의 메일(주소로 바로 연 경우 등)이면 -1 */
|
||||
const index = computed(() => mailStore.messages.findIndex((message) => message.id === props.messageId))
|
||||
const total = computed(
|
||||
() =>
|
||||
mailStore.pageInfo?.total ?? mailStore.currentMailbox?.totalCount ?? mailStore.messages.length,
|
||||
)
|
||||
const position = computed(() =>
|
||||
index.value === -1 ? '' : `${formatCount(index.value + 1)} / ${formatCount(total.value)}`,
|
||||
)
|
||||
const hasPrev = computed(() => index.value > 0)
|
||||
/** 불러온 범위의 끝이어도 서버에 더 있으면(hasNext) 다음으로 갈 수 있다 */
|
||||
const hasNext = computed(
|
||||
() =>
|
||||
index.value !== -1 && (index.value < mailStore.messages.length - 1 || mailStore.hasMore),
|
||||
)
|
||||
|
||||
async function goPrev() {
|
||||
const previous = mailStore.messages[index.value - 1]
|
||||
if (previous) await showSibling(props.mailboxId, previous.id)
|
||||
}
|
||||
|
||||
async function goNext() {
|
||||
if (loadingNext.value || index.value === -1) return
|
||||
const current = index.value
|
||||
if (!mailStore.messages[current + 1] && mailStore.hasMore) {
|
||||
loadingNext.value = true
|
||||
try {
|
||||
await mailStore.loadMore()
|
||||
} finally {
|
||||
loadingNext.value = false
|
||||
}
|
||||
}
|
||||
const next = mailStore.messages[current + 1]
|
||||
if (next) await showSibling(props.mailboxId, next.id)
|
||||
}
|
||||
|
||||
/** 목록으로 — 불러온 페이지·스크롤 위치는 그대로, 초점은 보던 메일의 행으로 돌려준다 */
|
||||
function onBackToList(event: MouseEvent) {
|
||||
// 새 탭·새 창으로 여는 동작(Ctrl·⌘·Shift·가운데 버튼)은 브라우저에 맡긴다
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || event.button !== 0) return
|
||||
event.preventDefault()
|
||||
viewStore.setReturnFocus(props.messageId)
|
||||
void backToList(props.mailboxId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MailToolButton
|
||||
icon="arrow-left"
|
||||
label="목록으로"
|
||||
tone="accent"
|
||||
:href="listHref"
|
||||
:icon-stroke-width="1.9"
|
||||
@click="onBackToList"
|
||||
/>
|
||||
<MailToolbarDivider />
|
||||
<button
|
||||
type="button"
|
||||
class="nav-button"
|
||||
aria-label="이전 메일"
|
||||
:disabled="!hasPrev"
|
||||
@click="goPrev"
|
||||
>
|
||||
<AppIcon
|
||||
name="chevron-left"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
</button>
|
||||
<span
|
||||
v-if="position"
|
||||
class="nav-position"
|
||||
>{{ position }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="nav-button"
|
||||
aria-label="다음 메일"
|
||||
:disabled="!hasNext || loadingNext"
|
||||
:aria-busy="loadingNext"
|
||||
@click="goNext"
|
||||
>
|
||||
<AppIcon
|
||||
name="chevron-right"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
</button>
|
||||
<MailToolbarDivider />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nav-button {
|
||||
width: var(--size-icon-button);
|
||||
height: var(--size-touch);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.nav-button:hover:not(:disabled) {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.nav-button:disabled {
|
||||
color: var(--color-icon-muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.nav-position {
|
||||
min-width: var(--space-48);
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import { MAIL_FRAME_CSP } from '@/shared/utils/mail-html'
|
||||
import MailBodyFrame from './MailBodyFrame.vue'
|
||||
|
||||
// 보안 핵심 — 본문 iframe 의 격리 조건이 바뀌면 이 테스트가 깨져야 한다.
|
||||
describe('MailBodyFrame', () => {
|
||||
function frameOf(props: { htmlBody?: string | null; textBody?: string | null }) {
|
||||
const wrapper = mount(MailBodyFrame, { props })
|
||||
return wrapper.get('iframe').element as HTMLIFrameElement
|
||||
}
|
||||
|
||||
it('sandbox 는 allow-same-origin · allow-popups · allow-popups-to-escape-sandbox 뿐이다', () => {
|
||||
const frame = frameOf({ htmlBody: '<p>안녕하세요</p>' })
|
||||
const tokens = (frame.getAttribute('sandbox') ?? '').split(/\s+/).filter(Boolean).sort()
|
||||
|
||||
expect(tokens).toEqual(['allow-popups', 'allow-popups-to-escape-sandbox', 'allow-same-origin'])
|
||||
// 스크립트·폼은 절대 허용하지 않는다 (allow-same-origin 과 allow-scripts 를 같이 쓰면 격리가 무너진다)
|
||||
expect(tokens).not.toContain('allow-scripts')
|
||||
expect(tokens).not.toContain('allow-forms')
|
||||
expect(tokens).not.toContain('allow-top-navigation')
|
||||
})
|
||||
|
||||
it('본문은 srcdoc 으로만 넣고 외부 주소(src)를 쓰지 않는다', () => {
|
||||
const frame = frameOf({ htmlBody: '<p>안녕하세요</p>' })
|
||||
|
||||
expect(frame.hasAttribute('src')).toBe(false)
|
||||
expect(frame.getAttribute('srcdoc')).toContain('<p>안녕하세요</p>')
|
||||
})
|
||||
|
||||
it('srcdoc 문서에 내부 CSP 와 <base target="_blank"> 가 들어간다', () => {
|
||||
const srcdoc = frameOf({ htmlBody: '<p>본문</p>' }).getAttribute('srcdoc') ?? ''
|
||||
|
||||
expect(srcdoc).toContain(`content="${MAIL_FRAME_CSP}"`)
|
||||
expect(srcdoc).toContain('<base target="_blank">')
|
||||
})
|
||||
|
||||
it('서버가 놓친 스크립트도 그리기 전에 지운다', () => {
|
||||
const srcdoc =
|
||||
frameOf({ htmlBody: '<p>본문</p><script>alert(1)</script><img src=x onerror=alert(2)>' }).getAttribute(
|
||||
'srcdoc',
|
||||
) ?? ''
|
||||
|
||||
expect(srcdoc).not.toContain('alert(')
|
||||
expect(srcdoc).not.toContain('<script')
|
||||
})
|
||||
|
||||
it('HTML 이 없으면 텍스트를 이스케이프해 보여 준다', () => {
|
||||
const srcdoc = frameOf({ htmlBody: null, textBody: '<b>굵게</b> 아님' }).getAttribute('srcdoc') ?? ''
|
||||
|
||||
expect(srcdoc).toContain('<b>굵게</b> 아님')
|
||||
expect(srcdoc).not.toContain('<b>')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
// 메일 본문을 격리된 iframe(srcdoc)에만 그린다 — 보안 핵심 (shared/utils/mail-html.ts 머리말 참고)
|
||||
//
|
||||
// sandbox 에 넣는 것과 넣지 않는 것:
|
||||
// - allow-scripts · allow-forms 는 절대 넣지 않는다. 본문 안 스크립트·폼 제출이 모두 막힌다.
|
||||
// - allow-same-origin: 부모(우리 앱)가 contentDocument 에 접근해 내용 높이를 재기 위해서만 넣는다.
|
||||
// 이 iframe 은 스크립트가 꺼져 있어(allow-scripts 없음) 같은 출처 권한을 쓸 코드가 실행될 수 없다.
|
||||
// ⚠️ allow-scripts 와 함께 넣으면 iframe 안 스크립트가 sandbox 속성을 지우고 빠져나올 수 있으므로
|
||||
// 둘을 같이 넣는 변경은 금지한다.
|
||||
// - allow-popups · allow-popups-to-escape-sandbox: 링크를 새 창으로 열고, 새 창은 sandbox 없이 정상 동작하게 한다.
|
||||
// 링크는 정화 단계에서 http·https·mailto 만 남기고 rel="noopener noreferrer" 를 붙인다.
|
||||
// 문서 안에는 CSP(default-src 'none'; img-src data:; style-src 'unsafe-inline')와 <base target="_blank"> 가 들어간다.
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { buildMailDocument } from '@/shared/utils/mail-html'
|
||||
|
||||
interface Props {
|
||||
/** 서버가 정화한 HTML — 여기서 한 번 더 정화한다 */
|
||||
htmlBody?: string | null
|
||||
/** HTML 이 없을 때 이스케이프해서 보여 줄 텍스트 */
|
||||
textBody?: string | null
|
||||
/** iframe 이름 (스크린리더) */
|
||||
title?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
htmlBody: null,
|
||||
textBody: null,
|
||||
title: '메일 본문',
|
||||
})
|
||||
|
||||
/** sandbox 값 — 위 설명 외의 권한을 추가하지 않는다 */
|
||||
const SANDBOX = 'allow-same-origin allow-popups allow-popups-to-escape-sandbox'
|
||||
|
||||
const frameRef = ref<HTMLIFrameElement | null>(null)
|
||||
/** 내용 높이(px) — iframe 의 height 속성으로 준다 (인라인 스타일을 쓰지 않는다) */
|
||||
const contentHeight = ref<number | null>(null)
|
||||
|
||||
const srcdoc = computed(() =>
|
||||
buildMailDocument({ htmlBody: props.htmlBody, textBody: props.textBody }),
|
||||
)
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let lastWidth = 0
|
||||
let measureFrame = 0
|
||||
/** 지금 srcdoc 문서가 다 열렸는지 — 열리기 전(빈 about:blank)에 재면 높이가 0 이 된다 */
|
||||
let documentLoaded = false
|
||||
|
||||
/** 문서 높이를 재서 iframe 높이로 맞춘다 (같은 출처라 접근할 수 있다 — 위 sandbox 설명) */
|
||||
function measure() {
|
||||
const frame = frameRef.value
|
||||
const doc = frame?.contentDocument
|
||||
if (!documentLoaded || !frame || !doc?.body) return
|
||||
|
||||
// 넓은 표 때문에 가로 스크롤 막대가 생기면 그 두께만큼 더한다
|
||||
const viewportHeight = frame.contentWindow?.innerHeight ?? 0
|
||||
const scrollbar = Math.max(0, viewportHeight - doc.documentElement.clientHeight)
|
||||
contentHeight.value = Math.ceil(doc.body.scrollHeight) + scrollbar
|
||||
}
|
||||
|
||||
function scheduleMeasure() {
|
||||
cancelAnimationFrame(measureFrame)
|
||||
measureFrame = requestAnimationFrame(measure)
|
||||
}
|
||||
|
||||
function onLoad() {
|
||||
documentLoaded = true
|
||||
measure()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof ResizeObserver === 'undefined' || !frameRef.value) return
|
||||
// 폭이 바뀌면 줄바꿈이 달라져 높이가 바뀐다 — 높이 변화(우리가 바꾼 것)에는 반응하지 않는다
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
const width = entries[0]?.contentRect.width ?? 0
|
||||
if (width === lastWidth) return
|
||||
lastWidth = width
|
||||
scheduleMeasure()
|
||||
})
|
||||
resizeObserver.observe(frameRef.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
cancelAnimationFrame(measureFrame)
|
||||
})
|
||||
|
||||
// 다른 메일로 바뀌면 새 문서가 load 될 때 다시 잰다 — 그 전까지 이전 높이가 남지 않게 비운다
|
||||
watch(srcdoc, () => {
|
||||
documentLoaded = false
|
||||
contentHeight.value = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- sandbox 를 srcdoc 보다 먼저 둔다 — 문서가 sandbox 없이 먼저 열리는 순서를 만들지 않기 위해서다 -->
|
||||
<iframe
|
||||
ref="frameRef"
|
||||
:sandbox="SANDBOX"
|
||||
class="mail-body-frame"
|
||||
:class="{ 'is-measured': contentHeight !== null }"
|
||||
:title="title"
|
||||
referrerpolicy="no-referrer"
|
||||
:height="contentHeight ?? undefined"
|
||||
:srcdoc="srcdoc"
|
||||
@load="onLoad"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-body-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
/* 본문 글줄 최대 폭 (콘텐츠 열 공통값) */
|
||||
max-width: var(--content-measure);
|
||||
border: 0;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 높이를 재기 전(또는 잴 수 없을 때)에도 본문이 보이게 최소 높이를 둔다 */
|
||||
.mail-body-frame:not(.is-measured) {
|
||||
min-height: 12rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 머리 — 메일함 이름 · 개수("전체 N · 안 읽음 M") · 보기 전환 · 새로고침
|
||||
// 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다. 안쪽 여백만 보기마다 다르다 (디자인 원본).
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ViewModeSwitch from '@/components/mail/ViewModeSwitch.vue'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
/** 제목(h1)의 id — 목록 영역이 aria-labelledby 로 가리킨다 */
|
||||
titleId: string
|
||||
variant?: 'split' | 'list'
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
variant: 'split',
|
||||
})
|
||||
|
||||
const mailStore = useMailStore()
|
||||
|
||||
const title = computed(() => mailStore.currentMailbox?.name ?? '메일함')
|
||||
const countText = computed(() => {
|
||||
const box = mailStore.currentMailbox
|
||||
if (!box) return ''
|
||||
const total = `전체 ${formatCount(box.totalCount)}`
|
||||
return box.unreadCount > 0 ? `${total} · 안 읽음 ${formatCount(box.unreadCount)}` : total
|
||||
})
|
||||
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['list-header', `list-header--${variant}`]">
|
||||
<h1
|
||||
:id="titleId"
|
||||
class="list-header__title"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<span class="list-header__count">{{ countText }}</span>
|
||||
<ViewModeSwitch class="list-header__switch" />
|
||||
<button
|
||||
type="button"
|
||||
class="list-header__refresh"
|
||||
aria-label="새로고침"
|
||||
:disabled="busy"
|
||||
@click="mailStore.refresh()"
|
||||
>
|
||||
<AppIcon name="refresh" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
min-height: var(--size-header-min);
|
||||
padding: var(--space-4) var(--space-4) var(--space-4) var(--space-16);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 목록 보기 — 표의 첫 칸과 같은 선(20px)에서 시작한다 */
|
||||
.list-header--list {
|
||||
padding: var(--space-4) var(--space-8) var(--space-4) var(--space-20);
|
||||
}
|
||||
|
||||
.list-header__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-title-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
}
|
||||
|
||||
.list-header__count {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.list-header__switch {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.list-header__refresh {
|
||||
width: var(--size-icon-button);
|
||||
height: var(--size-touch);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.list-header__refresh:hover:not(:disabled) {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.list-header__refresh:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
// 메일 목록·본문을 못 불러왔을 때의 상태 (디자인 원본 States "목록 불러오기 실패")
|
||||
// - 다시 하면 나아질 수 있는 오류(메일 서버 장애·네트워크 등): "다시 시도"
|
||||
// - 없는 주소(404 RES_001 — 주소창에 남은 옛 메일함·메일 id 등): 다시 해도 같으므로 빠져나갈 버튼을 준다
|
||||
// (목록은 "받은편지함으로", 본문은 "목록으로" — 이동은 부모가 useMailNavigation 규칙으로 바꿔 끼운다)
|
||||
// 문구는 에러 코드 사전에서만 온다 (error.message).
|
||||
import { computed } from 'vue'
|
||||
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import type { ApiError } from '@/composables/useApi'
|
||||
|
||||
interface Props {
|
||||
error: ApiError
|
||||
/** 없는 주소일 때 버튼 이름 */
|
||||
leaveLabel: string
|
||||
/** 영역을 세로로 가득 채운다 (본문 영역) */
|
||||
fill?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'retry'): void
|
||||
(e: 'leave'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
fill: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 없는 주소 — 다시 시도해도 같은 결과다 */
|
||||
const notFound = computed(() => props.error.code === 'RES_001')
|
||||
|
||||
function onAction() {
|
||||
if (notFound.value) emit('leave')
|
||||
else emit('retry')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StateMessage
|
||||
tone="danger"
|
||||
icon="alert-circle"
|
||||
:title="error.message"
|
||||
:trace-id="error.traceId"
|
||||
:fill="fill"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="mail-load-error__action"
|
||||
@click="onAction"
|
||||
>
|
||||
{{ notFound ? leaveLabel : '다시 시도' }}
|
||||
</button>
|
||||
</StateMessage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-load-error__action {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-14);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mail-load-error__action:hover {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import MailQuotaMeter from './MailQuotaMeter.vue'
|
||||
|
||||
const GB = 1024 ** 3
|
||||
|
||||
describe('MailQuotaMeter', () => {
|
||||
it('디자인 원본처럼 "메일함 사용량 0.9 / 5 GB" 와 가는 막대를 보인다 (뱃지 없이)', () => {
|
||||
const wrapper = mount(MailQuotaMeter, {
|
||||
props: { quota: { usedBytes: 0.9 * GB, limitBytes: 5 * GB, attachmentMaxBytes: 20 * 1024 ** 2, attachmentTotalMaxBytes: 25 * 1024 ** 2 } },
|
||||
})
|
||||
const meter = wrapper.get('meter')
|
||||
|
||||
expect(wrapper.text()).toContain('메일함 사용량')
|
||||
expect(wrapper.get('.quota__value').text()).toBe('0.9 / 5 GB')
|
||||
expect(meter.attributes('max')).toBe(String(5 * GB))
|
||||
expect(meter.attributes('aria-valuetext')).toBe('18%')
|
||||
})
|
||||
|
||||
it('한도가 없으면(null) 사용량만 보이고 막대는 그리지 않는다', () => {
|
||||
const wrapper = mount(MailQuotaMeter, {
|
||||
props: { quota: { usedBytes: 0.9 * GB, limitBytes: null, attachmentMaxBytes: 20 * 1024 ** 2, attachmentTotalMaxBytes: 25 * 1024 ** 2 } },
|
||||
})
|
||||
|
||||
expect(wrapper.get('.quota__value').text()).toBe('0.9 GB')
|
||||
expect(wrapper.find('meter').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('한도를 넘은 사용량은 막대를 꽉 채운 값으로 그린다', () => {
|
||||
const wrapper = mount(MailQuotaMeter, {
|
||||
props: { quota: { usedBytes: 6 * GB, limitBytes: 5 * GB, attachmentMaxBytes: 20 * 1024 ** 2, attachmentTotalMaxBytes: 25 * 1024 ** 2 } },
|
||||
})
|
||||
|
||||
expect(wrapper.get('meter').attributes('value')).toBe(String(5 * GB))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 사용량 — 메일함 패널 하단 (디자인 원본 Main: "메일함 사용량 0.9 / 5 GB" + 가는 막대)
|
||||
// 뱃지 없이 숫자와 막대만. 한도가 없으면(limitBytes null) 사용량만 보이고 막대는 그리지 않는다.
|
||||
// 막대는 인라인 스타일 대신 <meter> 를 꾸민다 — 값·범위가 스크린리더에도 그대로 전달된다.
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { MAIL_QUOTA_WARNING_RATIO } from '@/shared/constants/mail'
|
||||
import type { MailQuota } from '@/shared/types/mail'
|
||||
import { formatQuotaUsage } from '@/shared/utils/format'
|
||||
|
||||
interface Props {
|
||||
quota: MailQuota
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const PERCENT = 100
|
||||
|
||||
const usage = computed(() => formatQuotaUsage(props.quota.usedBytes, props.quota.limitBytes))
|
||||
/** 한도가 있을 때만 막대를 그린다 */
|
||||
const limit = computed(() => {
|
||||
const value = props.quota.limitBytes
|
||||
return value !== null && value > 0 ? value : null
|
||||
})
|
||||
const used = computed(() => Math.min(Math.max(props.quota.usedBytes, 0), limit.value ?? 0))
|
||||
/** 이 값을 넘으면 경고색 (meter 의 low=high 구간 경계) */
|
||||
const warnAt = computed(() => (limit.value ?? 0) * MAIL_QUOTA_WARNING_RATIO)
|
||||
const percentText = computed(() =>
|
||||
limit.value ? `${Math.round((used.value / limit.value) * PERCENT)}%` : '',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="quota">
|
||||
<div class="quota__row">
|
||||
<span id="mail-quota-label">메일함 사용량</span>
|
||||
<span class="quota__value">{{ usage }}</span>
|
||||
</div>
|
||||
<meter
|
||||
v-if="limit"
|
||||
class="quota__meter"
|
||||
min="0"
|
||||
:max="limit"
|
||||
:low="warnAt"
|
||||
:high="warnAt"
|
||||
optimum="0"
|
||||
:value="used"
|
||||
aria-labelledby="mail-quota-label"
|
||||
:aria-valuetext="percentText"
|
||||
:title="usage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quota {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
padding: var(--space-12) var(--space-10) var(--space-2);
|
||||
border-top: var(--border-width) solid var(--color-border);
|
||||
}
|
||||
|
||||
.quota__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-8);
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.quota__value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 4px 막대 — 브라우저 기본 모양을 지우고 토큰 색으로 그린다 */
|
||||
.quota__meter {
|
||||
appearance: none;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: var(--space-4);
|
||||
border: 0;
|
||||
border-radius: var(--space-2);
|
||||
background: var(--color-skeleton);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quota__meter::-webkit-meter-bar {
|
||||
height: var(--space-4);
|
||||
border: 0;
|
||||
border-radius: var(--space-2);
|
||||
background: var(--color-skeleton);
|
||||
}
|
||||
|
||||
.quota__meter::-webkit-meter-optimum-value {
|
||||
border-radius: var(--space-2);
|
||||
background: var(--color-brand);
|
||||
}
|
||||
|
||||
/* 한도의 90% 를 넘으면 경고색 — 용량 부족(MAIL_003)으로 발송이 막히기 전에 알아차리게 한다 */
|
||||
.quota__meter::-webkit-meter-suboptimum-value,
|
||||
.quota__meter::-webkit-meter-even-less-good-value {
|
||||
border-radius: var(--space-2);
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.quota__meter::-moz-meter-bar {
|
||||
border-radius: var(--space-2);
|
||||
background: var(--color-brand);
|
||||
}
|
||||
|
||||
.quota__meter:-moz-meter-sub-optimum::-moz-meter-bar,
|
||||
.quota__meter:-moz-meter-sub-sub-optimum::-moz-meter-bar {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
// 본문 툴바 버튼 — 분할 보기 읽기 · 목록 보기 읽기가 같은 모양을 쓴다 (디자인 원본 Main · MainListRead)
|
||||
// 안쪽 좌우 여백은 --toolbar-button-inline(10px) — 툴바 안쪽 여백(22px)과 더해 첫 아이콘이 콘텐츠 열 32px 선에 온다.
|
||||
// 사용 예: <MailToolButton icon="reply" label="답장" :to="{ name: 'mail-compose', query: { replyTo: id } }" />
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
|
||||
interface Props {
|
||||
icon: IconName
|
||||
label: string
|
||||
/** 앱 안 이동 — 있으면 RouterLink */
|
||||
to?: RouteLocationRaw | null
|
||||
/** 직접 처리하는 링크(예: 목록으로) — 있으면 <a href>, 누르면 click 을 내보낸다 */
|
||||
href?: string | null
|
||||
/** accent: 강조 글자(목록으로) · danger: 삭제 */
|
||||
tone?: 'default' | 'accent' | 'danger'
|
||||
disabled?: boolean
|
||||
iconStrokeWidth?: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'click', event: MouseEvent): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
to: null,
|
||||
href: null,
|
||||
tone: 'default',
|
||||
disabled: false,
|
||||
iconStrokeWidth: 1.75,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink
|
||||
v-if="to"
|
||||
:to="to"
|
||||
:class="['tool-button', `tool-button--${tone}`]"
|
||||
>
|
||||
<AppIcon
|
||||
:name="icon"
|
||||
:size="15"
|
||||
:stroke-width="iconStrokeWidth"
|
||||
/>
|
||||
<span>{{ label }}</span>
|
||||
</RouterLink>
|
||||
<a
|
||||
v-else-if="href"
|
||||
:href="href"
|
||||
:class="['tool-button', `tool-button--${tone}`]"
|
||||
@click="emit('click', $event)"
|
||||
>
|
||||
<AppIcon
|
||||
:name="icon"
|
||||
:size="15"
|
||||
:stroke-width="iconStrokeWidth"
|
||||
/>
|
||||
<span>{{ label }}</span>
|
||||
</a>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
:class="['tool-button', `tool-button--${tone}`]"
|
||||
:disabled="disabled"
|
||||
@click="emit('click', $event)"
|
||||
>
|
||||
<AppIcon
|
||||
:name="icon"
|
||||
:size="15"
|
||||
:stroke-width="iconStrokeWidth"
|
||||
/>
|
||||
<span>{{ label }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tool-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--toolbar-button-inline);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tool-button:hover:not(:disabled) {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.tool-button:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tool-button--accent {
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tool-button--accent:hover:not(:disabled) {
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.tool-button--danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.tool-button--danger:hover:not(:disabled) {
|
||||
background: var(--color-danger-surface);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
// 본문 툴바의 묶음 구분선 (디자인 원본: 1px × 20px, 좌우 6px)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="toolbar-divider"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar-divider {
|
||||
width: var(--space-1);
|
||||
height: var(--space-20);
|
||||
margin: 0 var(--space-6);
|
||||
background: var(--color-border);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,258 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 패널 — 메일 쓰기 + 메일함 목록 + 하단 사용량 (디자인 원본 Main · Compose 왼쪽)
|
||||
// 안 읽은 개수는 뱃지가 아니라 오른쪽의 평범한 숫자로 보인다.
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailQuotaMeter from '@/components/mail/MailQuotaMeter.vue'
|
||||
import { mailboxIcon } from '@/shared/utils/mailbox'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
/** 하위 메일함 들여쓰기는 3단계까지만 (그 아래는 같은 깊이로) */
|
||||
const MAX_INDENT_DEPTH = 3
|
||||
|
||||
const route = useRoute()
|
||||
const mailStore = useMailStore()
|
||||
|
||||
const isComposing = computed(() => route.name === 'mail-compose')
|
||||
const activeMailboxId = computed(() => (isComposing.value ? null : mailStore.currentMailboxId))
|
||||
|
||||
function depthClass(depth: number): string {
|
||||
return `mailbox-link--depth-${Math.min(depth, MAX_INDENT_DEPTH)}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="mailbox-panel"
|
||||
aria-label="메일함"
|
||||
>
|
||||
<span
|
||||
v-if="isComposing"
|
||||
class="mailbox-panel__primary mailbox-panel__primary--current"
|
||||
aria-current="page"
|
||||
>
|
||||
<AppIcon
|
||||
name="edit"
|
||||
:size="15"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>메일 쓰는 중</span>
|
||||
</span>
|
||||
<RouterLink
|
||||
v-else
|
||||
:to="{ name: 'mail-compose' }"
|
||||
class="mailbox-panel__primary"
|
||||
>
|
||||
<AppIcon
|
||||
name="edit"
|
||||
:size="15"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>메일 쓰기</span>
|
||||
</RouterLink>
|
||||
|
||||
<ul
|
||||
v-if="mailStore.orderedMailboxes.length > 0"
|
||||
class="mailbox-panel__list"
|
||||
>
|
||||
<li
|
||||
v-for="node in mailStore.orderedMailboxes"
|
||||
:key="node.mailbox.id"
|
||||
>
|
||||
<RouterLink
|
||||
:to="{ name: 'mail-mailbox', params: { mailboxId: node.mailbox.id } }"
|
||||
:class="[
|
||||
'mailbox-link',
|
||||
depthClass(node.depth),
|
||||
{ 'is-active': node.mailbox.id === activeMailboxId },
|
||||
]"
|
||||
:aria-current="node.mailbox.id === activeMailboxId ? 'page' : undefined"
|
||||
>
|
||||
<AppIcon :name="mailboxIcon(node.mailbox)" />
|
||||
<span class="mailbox-link__name">{{ node.mailbox.name }}</span>
|
||||
<span
|
||||
v-if="node.mailbox.unreadCount > 0"
|
||||
class="mailbox-link__count"
|
||||
>
|
||||
<span class="visually-hidden">안 읽음</span>
|
||||
{{ formatCount(node.mailbox.unreadCount) }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ul
|
||||
v-else-if="mailStore.mailboxesStatus === 'loading' || mailStore.mailboxesStatus === 'idle'"
|
||||
class="mailbox-panel__list"
|
||||
aria-label="메일함 불러오는 중"
|
||||
aria-busy="true"
|
||||
>
|
||||
<li
|
||||
v-for="row in 5"
|
||||
:key="row"
|
||||
class="mailbox-panel__placeholder"
|
||||
/>
|
||||
</ul>
|
||||
|
||||
<div
|
||||
v-else-if="mailStore.mailboxesStatus === 'error' && mailStore.mailboxesError"
|
||||
class="mailbox-panel__error"
|
||||
role="alert"
|
||||
>
|
||||
<p class="mailbox-panel__error-text">
|
||||
{{ mailStore.mailboxesError.message }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mailbox-panel__retry"
|
||||
@click="mailStore.loadMailboxes()"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 사용량은 보조 정보 — 받지 못했으면 영역을 그리지 않는다 -->
|
||||
<MailQuotaMeter
|
||||
v-if="mailStore.quota"
|
||||
:quota="mailStore.quota"
|
||||
/>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mailbox-panel {
|
||||
flex: 1 1 13rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-14);
|
||||
padding: var(--space-14) var(--space-10);
|
||||
border-right: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface-panel);
|
||||
}
|
||||
|
||||
.mailbox-panel__primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-8);
|
||||
min-height: var(--size-touch);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
font-size: var(--font-size-emphasis);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.mailbox-panel__primary:hover {
|
||||
background: var(--color-brand-strong);
|
||||
color: var(--color-on-brand);
|
||||
}
|
||||
|
||||
.mailbox-panel__primary--current {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.mailbox-panel__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.mailbox-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-row-min);
|
||||
padding: 0 var(--space-10);
|
||||
border-radius: var(--radius-item);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mailbox-link:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mailbox-link.is-active {
|
||||
background: var(--color-brand-subtle);
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mailbox-link--depth-1 {
|
||||
padding-left: var(--space-28);
|
||||
}
|
||||
|
||||
.mailbox-link--depth-2 {
|
||||
padding-left: var(--space-44);
|
||||
}
|
||||
|
||||
.mailbox-link--depth-3 {
|
||||
padding-left: var(--space-56);
|
||||
}
|
||||
|
||||
.mailbox-link__name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mailbox-link__count {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.mailbox-link.is-active .mailbox-link__count {
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.mailbox-panel__placeholder {
|
||||
height: var(--size-row-min);
|
||||
margin: var(--space-2) 0;
|
||||
border-radius: var(--radius-item);
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.mailbox-panel__error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
padding: 0 var(--space-10);
|
||||
}
|
||||
|
||||
.mailbox-panel__error-text {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.mailbox-panel__retry {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 패널만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
|
||||
@media (min-width: 70rem) {
|
||||
.mailbox-panel {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { Attachment } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockHandler,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import MessageAttachments from './MessageAttachments.vue'
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const CHECKLIST: Attachment = {
|
||||
id: 'att 1',
|
||||
name: '체크리스트.xlsx',
|
||||
type: 'application/vnd.ms-excel',
|
||||
size: 49152,
|
||||
}
|
||||
/** 경로의 id 는 인코딩된다 (공백 → %20) */
|
||||
const DOWNLOAD_URL = '/api/messages/m1/attachments/att%201'
|
||||
let clock = Date.UTC(2026, 9, 26, 7, 0, 0)
|
||||
|
||||
interface StartedDownload {
|
||||
href: string | null
|
||||
download: string | null
|
||||
}
|
||||
|
||||
describe('MessageAttachments 다운로드 — 이동 전 세션 확인 (계약 2절)', () => {
|
||||
let pinia: Pinia
|
||||
/** 실제 다운로드를 시작하는 임시 <a download> 의 클릭 — jsdom 은 이동하지 못하므로 가로채 기록만 한다 */
|
||||
let downloads: StartedDownload[]
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
clock += 60_000
|
||||
vi.setSystemTime(clock)
|
||||
downloads = []
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
|
||||
this: HTMLAnchorElement,
|
||||
) {
|
||||
downloads.push({ href: this.getAttribute('href'), download: this.getAttribute('download') })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function mountAttachments(handler: MockHandler) {
|
||||
const requests = installMockApi(handler)
|
||||
const wrapper = mount(MessageAttachments, {
|
||||
props: { messageId: 'm1', attachments: [CHECKLIST] },
|
||||
global: { plugins: [pinia] },
|
||||
})
|
||||
const link = wrapper.get<HTMLAnchorElement>('a.attachments__download').element
|
||||
return { wrapper, link, requests }
|
||||
}
|
||||
|
||||
function press(link: HTMLAnchorElement, type: 'click' | 'auxclick', init: MouseEventInit = {}) {
|
||||
const event = new MouseEvent(type, { bubbles: true, cancelable: true, ...init })
|
||||
link.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
function requestLines(requests: { method: string; url: string }[]): string[] {
|
||||
return requests.map((request) => `${request.method} ${request.url}`)
|
||||
}
|
||||
|
||||
it('링크 주소는 그대로 두고, 누르면 세션을 확인한 뒤 같은 주소로 내려받는다 — 확인 중에 다시 눌러도 한 번만', async () => {
|
||||
let releaseMe: (result: MockResult) => void = () => undefined
|
||||
const meGate = new Promise<MockResult>((resolve) => {
|
||||
releaseMe = resolve
|
||||
})
|
||||
const { link, requests } = mountAttachments(() => meGate)
|
||||
expect(link.getAttribute('href')).toBe(DOWNLOAD_URL)
|
||||
|
||||
const first = press(link, 'click')
|
||||
press(link, 'click')
|
||||
await flushPromises()
|
||||
|
||||
// 원래 링크의 이동은 막고, 확인이 끝나기 전에는 받지 않는다
|
||||
expect(first.defaultPrevented).toBe(true)
|
||||
expect(downloads).toEqual([])
|
||||
|
||||
releaseMe(ok(USER))
|
||||
await flushPromises()
|
||||
|
||||
expect(requestLines(requests)).toEqual(['GET /auth/me'])
|
||||
expect(downloads).toEqual([{ href: DOWNLOAD_URL, download: '' }])
|
||||
})
|
||||
|
||||
it('액세스 토큰이 만료됐으면(AUTH_001) 알림 없이 갱신한 뒤 내려받는다', async () => {
|
||||
const { link, requests } = mountAttachments(({ url }) =>
|
||||
url === '/auth/refresh' ? ok(USER) : fail(401, 'AUTH_001'),
|
||||
)
|
||||
|
||||
press(link, 'click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requestLines(requests)).toEqual(['GET /auth/me', 'POST /auth/refresh'])
|
||||
expect(downloads).toEqual([{ href: DOWNLOAD_URL, download: '' }])
|
||||
expect(useAuthStore().isLoggedIn).toBe(true)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('갱신도 실패하면 내려받지 않고 세션 만료로 정리한다 (토스트 대신 대화상자)', async () => {
|
||||
const { link } = mountAttachments(() => fail(401, 'AUTH_001'))
|
||||
|
||||
press(link, 'click')
|
||||
await flushPromises()
|
||||
|
||||
expect(downloads).toEqual([])
|
||||
expect(useAuthStore().sessionExpired).toBe(true)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('확인이 장애로 실패하면 내려받지 않고 사전 문구로 알린다 — 다시 누르면 다시 확인한다', async () => {
|
||||
let healthy = false
|
||||
const { link, requests } = mountAttachments(() => (healthy ? ok(USER) : fail(500, 'SYS_001')))
|
||||
|
||||
press(link, 'click')
|
||||
await flushPromises()
|
||||
|
||||
expect(downloads).toEqual([])
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([
|
||||
ERROR_CODE_MESSAGES.SYS_001,
|
||||
])
|
||||
expect(useAuthStore().sessionExpired).toBe(false)
|
||||
|
||||
healthy = true
|
||||
press(link, 'click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requestLines(requests)).toEqual(['GET /auth/me', 'GET /auth/me'])
|
||||
expect(downloads).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('보조 클릭(Ctrl·가운데 버튼)도 같은 흐름으로 받고, 오른쪽 버튼은 브라우저 메뉴에 맡긴다', async () => {
|
||||
const { link, requests } = mountAttachments(() => ok(USER))
|
||||
|
||||
const right = press(link, 'auxclick', { button: 2 })
|
||||
await flushPromises()
|
||||
expect(right.defaultPrevented).toBe(false)
|
||||
expect(requests).toEqual([])
|
||||
|
||||
const middle = press(link, 'auxclick', { button: 1 })
|
||||
await flushPromises()
|
||||
const withCtrl = press(link, 'click', { ctrlKey: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(middle.defaultPrevented).toBe(true)
|
||||
expect(withCtrl.defaultPrevented).toBe(true)
|
||||
expect(downloads).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
// 받은 메일의 첨부 목록 — 다운로드는 <a href> 이동으로 받는다 (계약 2절)
|
||||
// 주소는 axios baseURL 과 같은 규칙으로 만들어야 같은 출처·쿠키 경로로 요청이 간다 (useMail.attachmentDownloadUrl)
|
||||
// 이동은 axios 의 401 갱신 규칙을 타지 않으므로, 누르면 바로 이동하지 않고 세션부터 확인한 뒤 받는다.
|
||||
// href 는 그대로 둔다 — "링크 주소 복사" 같은 브라우저 기본 기능을 지키기 위해서다.
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { useMail } from '@/composables/useMail'
|
||||
import type { Attachment } from '@/shared/types/mail'
|
||||
import { formatFileSize } from '@/shared/utils/format'
|
||||
|
||||
interface Props {
|
||||
messageId: string
|
||||
attachments: Attachment[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const { attachmentDownloadUrl, downloadAttachment } = useMail()
|
||||
|
||||
/** 가운데 버튼(새 탭으로 열기) — 열어도 결국 파일을 받으므로 같은 흐름으로 받는다 */
|
||||
const MIDDLE_BUTTON = 1
|
||||
|
||||
/** 세션을 확인하는 중인 첨부 — 기다리는 사이 다시 눌러도 한 번만 받는다 */
|
||||
const pending = new Set<string>()
|
||||
|
||||
function onDownload(event: MouseEvent, attachmentId: string) {
|
||||
// 오른쪽 버튼(메뉴)은 브라우저에 맡긴다
|
||||
if (event.type === 'auxclick' && event.button !== MIDDLE_BUTTON) return
|
||||
event.preventDefault()
|
||||
|
||||
const key = `${props.messageId}/${attachmentId}`
|
||||
if (pending.has(key)) return
|
||||
pending.add(key)
|
||||
void downloadAttachment(props.messageId, attachmentId).finally(() => {
|
||||
pending.delete(key)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="attachments"
|
||||
aria-labelledby="reader-attachments-title"
|
||||
>
|
||||
<h3
|
||||
id="reader-attachments-title"
|
||||
class="attachments__title"
|
||||
>
|
||||
첨부 파일 {{ attachments.length }}개
|
||||
</h3>
|
||||
<ul class="attachments__list">
|
||||
<li
|
||||
v-for="attachment in attachments"
|
||||
:key="attachment.id"
|
||||
class="attachments__item"
|
||||
>
|
||||
<span
|
||||
class="attachments__icon"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<AppIcon name="file" />
|
||||
</span>
|
||||
<div class="attachments__meta">
|
||||
<span class="attachments__name">{{ attachment.name }}</span>
|
||||
<span class="attachments__size">{{ formatFileSize(attachment.size) }}</span>
|
||||
</div>
|
||||
<a
|
||||
class="attachments__download"
|
||||
:href="attachmentDownloadUrl(messageId, attachment.id)"
|
||||
:aria-label="`${attachment.name} 다운로드`"
|
||||
download
|
||||
@click="onDownload($event, attachment.id)"
|
||||
@auxclick="onDownload($event, attachment.id)"
|
||||
>
|
||||
<AppIcon name="download" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.attachments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
padding-top: var(--space-16);
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.attachments__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.attachments__list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(16.25rem, 100%), 1fr));
|
||||
gap: var(--space-8);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.attachments__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
padding: var(--space-4) var(--space-2) var(--space-4) var(--space-8);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.attachments__icon {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.attachments__meta {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.attachments__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.attachments__size {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.attachments__download {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-touch);
|
||||
height: var(--size-touch);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.attachments__download:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
// 분할 보기의 목록 영역 — 머리(메일함 이름·개수·보기 전환·새로고침) + 2줄 행 + 메일 더 보기 (디자인 원본 Main 가운데)
|
||||
// 상태: 불러오는 중 · 메일 서버 장애(다시 시도) · 없는 메일함(받은편지함으로) · 빈 메일함 (디자인 원본 States)
|
||||
import { computed } from 'vue'
|
||||
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import MailListHeader from '@/components/mail/MailListHeader.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import MessageListItem from '@/components/mail/MessageListItem.vue'
|
||||
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { OUTGOING_MAILBOX_ROLES } from '@/shared/constants/mail'
|
||||
import { emptyMailboxText, mailboxIcon } from '@/shared/utils/mailbox'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
mailboxId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const { replaceWithMailbox } = useMailNavigation()
|
||||
|
||||
const mailbox = computed(() => mailStore.currentMailbox)
|
||||
const showRecipients = computed(() => {
|
||||
const role = mailbox.value?.role
|
||||
return role ? OUTGOING_MAILBOX_ROLES.includes(role) : false
|
||||
})
|
||||
const empty = computed(() =>
|
||||
emptyMailboxText({ name: mailbox.value?.name ?? '메일함', role: mailbox.value?.role ?? null }),
|
||||
)
|
||||
const emptyIcon = computed(() => mailboxIcon({ role: mailbox.value?.role ?? null }))
|
||||
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
|
||||
|
||||
function retry() {
|
||||
void mailStore.openMailbox(props.mailboxId)
|
||||
}
|
||||
|
||||
/** 없는 메일함(RES_001) — 받은편지함으로 바꿔 끼운다 */
|
||||
function goToInbox() {
|
||||
void replaceWithMailbox(mailStore.defaultMailbox?.id ?? null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="message-list"
|
||||
aria-labelledby="message-list-title"
|
||||
:aria-busy="busy"
|
||||
>
|
||||
<MailListHeader title-id="message-list-title" />
|
||||
|
||||
<MessageListSkeleton v-if="mailStore.listStatus === 'loading'" />
|
||||
|
||||
<MailLoadError
|
||||
v-else-if="mailStore.listStatus === 'error' && mailStore.listError"
|
||||
:error="mailStore.listError"
|
||||
leave-label="받은편지함으로"
|
||||
@retry="retry"
|
||||
@leave="goToInbox"
|
||||
/>
|
||||
|
||||
<template v-else-if="mailStore.messages.length > 0">
|
||||
<ul class="message-list__items">
|
||||
<MessageListItem
|
||||
v-for="message in mailStore.messages"
|
||||
:key="message.id"
|
||||
:message="message"
|
||||
:mailbox-id="mailboxId"
|
||||
:selected="message.id === mailStore.selectedId"
|
||||
:show-recipients="showRecipients"
|
||||
@toggle-flag="mailStore.toggleFlag"
|
||||
/>
|
||||
</ul>
|
||||
<div
|
||||
v-if="mailStore.hasMore"
|
||||
class="message-list__more"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="message-list__text-button message-list__more-button"
|
||||
:disabled="mailStore.isLoadingMore"
|
||||
@click="mailStore.loadMore()"
|
||||
>
|
||||
{{ mailStore.isLoadingMore ? '불러오는 중…' : '메일 더 보기' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<StateMessage
|
||||
v-else-if="mailStore.listStatus === 'ready'"
|
||||
:icon="emptyIcon"
|
||||
:title="empty.title"
|
||||
:hint="empty.hint"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-list {
|
||||
flex: 1 1 24rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: var(--border-width) solid var(--color-border);
|
||||
}
|
||||
|
||||
.message-list__items {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.message-list__more {
|
||||
padding: var(--space-8) var(--space-12) var(--space-16);
|
||||
}
|
||||
|
||||
.message-list__text-button {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-14);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message-list__text-button:hover:not(:disabled) {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.message-list__more-button {
|
||||
width: 100%;
|
||||
min-height: var(--size-row-min);
|
||||
}
|
||||
|
||||
.message-list__more-button:disabled {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 목록 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
|
||||
@media (min-width: 70rem) {
|
||||
.message-list {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<script setup lang="ts">
|
||||
// 분할 보기 목록의 2줄 행 (디자인 원본 Main) — 안 읽음은 뱃지 없이 굵은 글자로만 구분한다
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { formatMailListTime } from '@/shared/utils/format'
|
||||
import { displayName, summarizeRecipients } from '@/shared/utils/mail-address'
|
||||
|
||||
interface Props {
|
||||
message: MessageSummary
|
||||
mailboxId: string
|
||||
/** 지금 열려 있는 메일 */
|
||||
selected?: boolean
|
||||
/** 보낸편지함처럼 받는 사람을 보여 줄 메일함인지 */
|
||||
showRecipients?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'toggle-flag', id: string): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
selected: false,
|
||||
showRecipients: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const who = computed(() => {
|
||||
if (props.showRecipients) {
|
||||
const recipients = summarizeRecipients(props.message.to)
|
||||
return recipients ? `받는 사람: ${recipients}` : '받는 사람 없음'
|
||||
}
|
||||
return displayName(props.message.from) || '(보낸 사람 없음)'
|
||||
})
|
||||
const subject = computed(() => props.message.subject || '(제목 없음)')
|
||||
const time = computed(() => formatMailListTime(props.message.receivedAt))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li :class="['message-row', { 'is-selected': selected, 'is-unread': !message.isRead }]">
|
||||
<RouterLink
|
||||
class="message-row__main"
|
||||
:to="{ name: 'mail-message', params: { mailboxId, messageId: message.id } }"
|
||||
>
|
||||
<span class="message-row__top">
|
||||
<span class="message-row__who">
|
||||
<span
|
||||
v-if="!message.isRead"
|
||||
class="visually-hidden"
|
||||
>안 읽음, </span>{{ who }}
|
||||
</span>
|
||||
<time
|
||||
class="message-row__time"
|
||||
:datetime="message.receivedAt"
|
||||
>{{ time }}</time>
|
||||
</span>
|
||||
<!-- 본문 미리보기는 넣지 않는다 (디자인 원칙) — 제목 뒤에 첨부 클립만 -->
|
||||
<span class="message-row__bottom">
|
||||
<span class="message-row__subject">{{ subject }}</span>
|
||||
<AppIcon
|
||||
v-if="message.hasAttachment"
|
||||
name="paperclip"
|
||||
:size="13"
|
||||
:stroke-width="2"
|
||||
label="첨부 있음"
|
||||
class="message-row__clip"
|
||||
/>
|
||||
</span>
|
||||
</RouterLink>
|
||||
<button
|
||||
type="button"
|
||||
:class="['message-row__star', { 'is-flagged': message.isFlagged }]"
|
||||
aria-label="중요 표시"
|
||||
:aria-pressed="message.isFlagged"
|
||||
@click="emit('toggle-flag', message.id)"
|
||||
>
|
||||
<AppIcon
|
||||
name="star"
|
||||
:size="15"
|
||||
:filled="message.isFlagged"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.message-row:hover {
|
||||
background: var(--color-surface-panel);
|
||||
}
|
||||
|
||||
.message-row.is-selected {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
/* 2줄 행 약 50px — 보낸 사람·시간 / 제목 + 첨부 클립 (디자인 원본 Main) */
|
||||
.message-row__main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: var(--size-list-row);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-6) var(--space-2) var(--space-6) var(--space-16);
|
||||
line-height: var(--line-height-heading);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.message-row__main:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.message-row__main:focus-visible {
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
}
|
||||
|
||||
.message-row__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.message-row__who {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 450;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.message-row__clip {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.message-row__time {
|
||||
flex: 0 0 auto;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.message-row__bottom {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.message-row__subject {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 */
|
||||
.message-row.is-unread .message-row__who {
|
||||
font-weight: 650;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.message-row.is-unread .message-row__subject,
|
||||
.message-row.is-unread .message-row__time {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.message-row__star {
|
||||
flex: 0 0 auto;
|
||||
width: var(--size-touch);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.message-row__star.is-flagged {
|
||||
color: var(--color-star);
|
||||
}
|
||||
|
||||
.message-row__star:focus-visible {
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 불러오는 중 — 행 자리를 미리 잡아 화면이 튀지 않게 한다 (디자인 원본 States "불러오는 중")
|
||||
interface Props {
|
||||
rows?: number
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
rows: 7,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul
|
||||
class="list-skeleton"
|
||||
aria-label="메일 목록 불러오는 중"
|
||||
aria-busy="true"
|
||||
>
|
||||
<li
|
||||
v-for="row in rows"
|
||||
:key="row"
|
||||
class="list-skeleton__row"
|
||||
>
|
||||
<span class="list-skeleton__bar list-skeleton__bar--who" />
|
||||
<span class="list-skeleton__bar list-skeleton__bar--subject" />
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.list-skeleton__row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
padding: var(--space-12) var(--space-16);
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.list-skeleton__bar {
|
||||
height: var(--space-10);
|
||||
border-radius: var(--space-6);
|
||||
}
|
||||
|
||||
.list-skeleton__bar--who {
|
||||
width: 36%;
|
||||
background: var(--color-skeleton);
|
||||
}
|
||||
|
||||
.list-skeleton__bar--subject {
|
||||
width: 82%;
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
/* 행마다 길이를 조금씩 달리한다 (인라인 스타일 대신 순서로) */
|
||||
.list-skeleton__row:nth-child(3n + 2) .list-skeleton__bar--who {
|
||||
width: 28%;
|
||||
}
|
||||
|
||||
.list-skeleton__row:nth-child(3n + 2) .list-skeleton__bar--subject {
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
.list-skeleton__row:nth-child(3n) .list-skeleton__bar--who {
|
||||
width: 44%;
|
||||
}
|
||||
|
||||
.list-skeleton__row:nth-child(3n) .list-skeleton__bar--subject {
|
||||
width: 76%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,163 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { h } from 'vue'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MessageReader from './MessageReader.vue'
|
||||
|
||||
const DETAIL: MessageDetail = {
|
||||
id: 'm1',
|
||||
threadId: 't1',
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.it.kr' },
|
||||
to: [{ name: '김아라', email: 'ara.kim@arasystem.it.kr' }],
|
||||
subject: '메일 서버 이전 일정 공유',
|
||||
preview: '',
|
||||
receivedAt: '2026-10-26T01:42:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
cc: [],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
sentAt: null,
|
||||
messageId: null,
|
||||
references: [],
|
||||
htmlBody: null,
|
||||
textBody: '본문',
|
||||
hasBlockedImages: false,
|
||||
attachments: [],
|
||||
}
|
||||
|
||||
describe('MessageReader 도구 모음', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
})
|
||||
|
||||
async function mountReader() {
|
||||
const store = useMailStore()
|
||||
store.selected = DETAIL
|
||||
store.selectedId = DETAIL.id
|
||||
store.selectedStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail/inbox/m1')
|
||||
await router.isReady()
|
||||
return mount(MessageReader, { global: { plugins: [pinia, router] } })
|
||||
}
|
||||
|
||||
function linkHref(wrapper: Awaited<ReturnType<typeof mountReader>>, label: string): string | undefined {
|
||||
return wrapper
|
||||
.findAll('.tool-button')
|
||||
.find((button) => button.text() === label)
|
||||
?.attributes('href')
|
||||
}
|
||||
|
||||
it('답장 · 전체 답장 · 전달이 쓰기 화면으로 원본 메일 id 를 넘긴다', async () => {
|
||||
const wrapper = await mountReader()
|
||||
|
||||
expect(linkHref(wrapper, '답장')).toBe('/mail/compose?replyTo=m1')
|
||||
expect(linkHref(wrapper, '전체 답장')).toBe('/mail/compose?replyTo=m1&all=1')
|
||||
expect(linkHref(wrapper, '전달')).toBe('/mail/compose?forward=m1')
|
||||
})
|
||||
|
||||
it('목록 보기에서는 메일을 못 불러와도 앞쪽 이동 도구(목록으로 등)가 남고, 메일 동작 버튼은 숨긴다', async () => {
|
||||
const store = useMailStore()
|
||||
store.selected = null
|
||||
store.selectedId = 'm1'
|
||||
store.selectedStatus = 'error'
|
||||
store.selectedError = { code: 'RES_001', message: '요청하신 정보나 페이지를 찾을 수 없습니다.' }
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail/inbox/m1')
|
||||
await router.isReady()
|
||||
const wrapper = mount(MessageReader, {
|
||||
props: { layout: 'list' },
|
||||
slots: { 'toolbar-start': () => h('a', { class: 'test-nav', href: '/mail/inbox' }, '목록으로') },
|
||||
global: { plugins: [pinia, router] },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.test-nav').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('요청하신 정보나 페이지를 찾을 수 없습니다.')
|
||||
expect(wrapper.findAll('.tool-button')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('전달은 더 이상 비활성("준비 중")이 아니다', async () => {
|
||||
const wrapper = await mountReader()
|
||||
|
||||
expect(wrapper.find('[aria-disabled="true"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('준비 중')
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessageReader 불러오기 실패', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function mountFailed(code: 'RES_001' | 'MAIL_001', layout: 'split' | 'list') {
|
||||
const store = useMailStore()
|
||||
store.currentMailboxId = 'inbox'
|
||||
store.selected = null
|
||||
store.selectedId = 'missing'
|
||||
store.selectedStatus = 'error'
|
||||
store.selectedError = { code, message: ERROR_CODE_MESSAGES[code] }
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail/inbox/missing')
|
||||
await router.isReady()
|
||||
const push = vi.spyOn(router, 'push')
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
const wrapper = mount(MessageReader, { props: { layout }, global: { plugins: [pinia, router] } })
|
||||
const action = wrapper.get('.mail-load-error__action')
|
||||
return { wrapper, router, push, replace, action }
|
||||
}
|
||||
|
||||
it.each(['split', 'list'] as const)(
|
||||
'%s 보기 — 없는 메일(404 RES_001)이면 "다시 시도" 대신 "목록으로" — 기록을 쌓지 않고 그 메일함 목록으로 간다',
|
||||
async (layout) => {
|
||||
const requests = installMockApi(() => ok({}))
|
||||
const { wrapper, router, push, replace, action } = await mountFailed('RES_001', layout)
|
||||
|
||||
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.RES_001)
|
||||
expect(action.text()).toBe('목록으로')
|
||||
|
||||
await action.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(replace).toHaveBeenCalled()
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
// 다시 불러오지 않는다 — 없는 메일이다
|
||||
expect(requests).toEqual([])
|
||||
},
|
||||
)
|
||||
|
||||
it('메일 서버 장애 같은 일시적 오류는 "다시 시도" 그대로 — 누르면 다시 불러온다', async () => {
|
||||
const requests = installMockApi(() => ok(DETAIL))
|
||||
const { router, action } = await mountFailed('MAIL_001', 'split')
|
||||
|
||||
expect(action.text()).toBe('다시 시도')
|
||||
await action.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.map((request) => `${request.method} ${request.url}`)).toContain('GET /messages/missing')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/missing')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,440 @@
|
||||
<script setup lang="ts">
|
||||
// 본문 영역 — 선택한 메일 (디자인 원본 Main 오른쪽 · MainListRead)
|
||||
// 본문 HTML 은 MailBodyFrame(격리된 iframe)에만 그린다.
|
||||
// 툴바와 본문은 콘텐츠 열(tokens.css --content-*)을 따른다 — 분할 보기 읽기 · 목록 보기 읽기 · 쓰기 공통.
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import MailBodyFrame from '@/components/mail/MailBodyFrame.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import MailToolButton from '@/components/mail/MailToolButton.vue'
|
||||
import MailToolbarDivider from '@/components/mail/MailToolbarDivider.vue'
|
||||
import MessageAttachments from '@/components/mail/MessageAttachments.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { formatMailDateTime, initialOf } from '@/shared/utils/format'
|
||||
import { displayName, formatAddressList } from '@/shared/utils/mail-address'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* split: 분할 보기 — 삭제하면 다음 메일(없으면 이전 메일)을 연다
|
||||
* list: 목록 보기에서 연 메일 — 툴바 앞에 "목록으로 · 이전/다음"(toolbar-start 슬롯), 삭제하면 목록으로
|
||||
*/
|
||||
layout?: 'split' | 'list'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
layout: 'split',
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const { mailboxLocation, messageLocation, backToList, replaceWithMailbox } = useMailNavigation()
|
||||
|
||||
const deleting = ref<boolean>(false)
|
||||
|
||||
const message = computed(() => mailStore.selected)
|
||||
const isTrash = computed(() => mailStore.currentMailbox?.role === 'trash')
|
||||
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 || '(제목 없음)')
|
||||
const receivedAt = computed(() => (message.value ? formatMailDateTime(message.value.receivedAt) : ''))
|
||||
|
||||
async function onDelete() {
|
||||
const current = message.value
|
||||
const mailboxId = mailStore.currentMailboxId
|
||||
if (!current || !mailboxId || deleting.value) return
|
||||
|
||||
deleting.value = true
|
||||
try {
|
||||
const nextId = await mailStore.removeMessage(current.id)
|
||||
if (props.layout === 'list') {
|
||||
// 목록 보기에서는 삭제하면 목록으로 돌아간다 (디자인 원본 MainListRead)
|
||||
await backToList(mailboxId)
|
||||
return
|
||||
}
|
||||
await router.replace(nextId ? messageLocation(mailboxId, nextId) : mailboxLocation(mailboxId))
|
||||
} catch {
|
||||
// 알림은 인터셉터가 띄웠다 — 메일은 그대로 둔다
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function retry() {
|
||||
if (mailStore.selectedId) void mailStore.openMessage(mailStore.selectedId)
|
||||
}
|
||||
|
||||
/** 없는 메일(RES_001 — 지워졌거나 주소가 틀림) — 목록으로 돌아간다 (뒤로 가기 또는 바꿔 끼우기) */
|
||||
function leaveToList() {
|
||||
const mailboxId = mailStore.currentMailboxId
|
||||
void (mailboxId ? backToList(mailboxId) : replaceWithMailbox(null))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="reader">
|
||||
<!--
|
||||
툴바 띠는 칸 전체 폭, 버튼 줄은 콘텐츠 열 안 — 첫 아이콘이 본문과 같은 32px 선에서 시작한다.
|
||||
목록 보기에서는 메일을 불러오는 중이거나 실패해도 "목록으로 · 이전/다음"이 남아 있어야 하므로 항상 그린다.
|
||||
-->
|
||||
<div
|
||||
v-if="message || layout === 'list'"
|
||||
class="reader__toolbar-bar"
|
||||
>
|
||||
<div
|
||||
class="reader__toolbar"
|
||||
role="group"
|
||||
aria-label="메일 동작"
|
||||
>
|
||||
<slot name="toolbar-start" />
|
||||
<template v-if="message">
|
||||
<MailToolButton
|
||||
icon="reply"
|
||||
label="답장"
|
||||
:to="{ name: 'mail-compose', query: { replyTo: message.id } }"
|
||||
/>
|
||||
<MailToolButton
|
||||
icon="reply-all"
|
||||
label="전체 답장"
|
||||
:to="{ name: 'mail-compose', query: { replyTo: message.id, all: '1' } }"
|
||||
/>
|
||||
<MailToolButton
|
||||
icon="forward"
|
||||
label="전달"
|
||||
:to="{ name: 'mail-compose', query: { forward: message.id } }"
|
||||
/>
|
||||
<MailToolbarDivider v-if="layout === 'split'" />
|
||||
<MailToolButton
|
||||
icon="trash"
|
||||
:label="isTrash ? '영구 삭제' : '삭제'"
|
||||
tone="danger"
|
||||
:disabled="deleting"
|
||||
@click="onDelete"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="mailStore.selectedStatus === 'loading'"
|
||||
:class="['reader__loading', { 'reader__loading--below-toolbar': layout === 'list' }]"
|
||||
aria-busy="true"
|
||||
aria-label="메일 불러오는 중"
|
||||
>
|
||||
<span class="reader__bar reader__bar--title" />
|
||||
<span class="reader__bar reader__bar--meta" />
|
||||
<span class="reader__bar" />
|
||||
<span class="reader__bar" />
|
||||
<span class="reader__bar reader__bar--short" />
|
||||
</div>
|
||||
|
||||
<MailLoadError
|
||||
v-else-if="mailStore.selectedStatus === 'error' && mailStore.selectedError"
|
||||
:error="mailStore.selectedError"
|
||||
leave-label="목록으로"
|
||||
fill
|
||||
@retry="retry"
|
||||
@leave="leaveToList"
|
||||
/>
|
||||
|
||||
<article
|
||||
v-else-if="message"
|
||||
class="reader__article"
|
||||
aria-labelledby="reader-subject"
|
||||
>
|
||||
<div class="reader__content">
|
||||
<div class="reader__subject-row">
|
||||
<h2
|
||||
id="reader-subject"
|
||||
class="reader__subject"
|
||||
>
|
||||
{{ subject }}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
:class="['reader__star', { 'is-flagged': message.isFlagged }]"
|
||||
aria-label="중요 표시"
|
||||
:aria-pressed="message.isFlagged"
|
||||
@click="mailStore.toggleFlag(message.id)"
|
||||
>
|
||||
<AppIcon
|
||||
name="star"
|
||||
:size="18"
|
||||
:filled="message.isFlagged"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="reader__sender">
|
||||
<span
|
||||
class="reader__avatar"
|
||||
aria-hidden="true"
|
||||
>{{ initial }}</span>
|
||||
<div class="reader__sender-info">
|
||||
<div class="reader__from">
|
||||
<span class="reader__from-name">{{ fromName }}</span>
|
||||
<span
|
||||
v-if="fromEmail && fromEmail !== fromName"
|
||||
class="reader__from-email"
|
||||
>{{ fromEmail }}</span>
|
||||
<time
|
||||
class="reader__date"
|
||||
:datetime="message.receivedAt"
|
||||
>{{ receivedAt }}</time>
|
||||
</div>
|
||||
<p
|
||||
v-if="message.to.length > 0"
|
||||
class="reader__recipients"
|
||||
>
|
||||
<span class="reader__recipients-label">받는 사람</span>
|
||||
{{ formatAddressList(message.to) }}
|
||||
</p>
|
||||
<p
|
||||
v-if="message.cc.length > 0"
|
||||
class="reader__recipients"
|
||||
>
|
||||
<span class="reader__recipients-label">참조</span>
|
||||
{{ formatAddressList(message.cc) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="message.hasBlockedImages"
|
||||
class="reader__notice"
|
||||
>
|
||||
<AppIcon
|
||||
name="shield"
|
||||
:size="15"
|
||||
class="reader__notice-icon"
|
||||
/>
|
||||
<span>외부 이미지를 차단했습니다. 개인정보 보호를 위해 보낸 사람 서버의 이미지는 불러오지 않습니다.</span>
|
||||
</p>
|
||||
|
||||
<MailBodyFrame
|
||||
:html-body="message.htmlBody"
|
||||
:text-body="message.textBody"
|
||||
:title="`메일 본문: ${subject}`"
|
||||
/>
|
||||
|
||||
<MessageAttachments
|
||||
v-if="message.attachments.length > 0"
|
||||
:message-id="message.id"
|
||||
:attachments="message.attachments"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<StateMessage
|
||||
v-else
|
||||
icon="mail"
|
||||
title="메일을 선택하세요"
|
||||
hint="목록에서 메일을 고르면 내용이 여기에 표시됩니다."
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.reader {
|
||||
flex: 999 1 30rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.reader__article {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 툴바 띠 — 칸 전체 폭으로 아래 선을 긋고, 스크롤해도 위에 남는다 */
|
||||
.reader__toolbar-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 버튼 줄 — 콘텐츠 열 안. 안쪽 여백 22px + 버튼 안쪽 10px = 첫 아이콘이 32px 선 */
|
||||
.reader__toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
max-width: var(--content-max-width);
|
||||
min-height: var(--size-header-min);
|
||||
padding: var(--space-4) var(--content-inline) var(--space-4) var(--content-toolbar-start);
|
||||
}
|
||||
|
||||
/* 콘텐츠 열 — 최대 800px · 좌우 32px · 위 20px · 아래 36px, 영역 안 왼쪽 정렬 */
|
||||
.reader__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-18);
|
||||
max-width: var(--content-max-width);
|
||||
padding: var(--content-block-start) var(--content-inline) var(--content-block-end);
|
||||
}
|
||||
|
||||
.reader__subject-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.reader__subject {
|
||||
flex: 1 1 auto;
|
||||
margin: var(--space-4) 0 0;
|
||||
font-size: var(--font-size-heading);
|
||||
line-height: var(--line-height-heading);
|
||||
font-weight: 650;
|
||||
letter-spacing: var(--letter-spacing-tighter);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.reader__star {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-touch);
|
||||
height: var(--size-touch);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.reader__star.is-flagged {
|
||||
color: var(--color-star);
|
||||
}
|
||||
|
||||
.reader__sender {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-12);
|
||||
}
|
||||
|
||||
.reader__avatar {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--space-36);
|
||||
height: var(--space-36);
|
||||
border-radius: var(--radius-round);
|
||||
background: var(--color-brand-subtle);
|
||||
color: var(--color-brand-strong);
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reader__sender-info {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.reader__from {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2) var(--space-8);
|
||||
}
|
||||
|
||||
.reader__from-name {
|
||||
font-size: var(--font-size-emphasis);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reader__from-email {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.reader__date {
|
||||
margin-left: auto;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.reader__recipients {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.reader__recipients-label {
|
||||
margin-right: var(--space-6);
|
||||
}
|
||||
|
||||
.reader__notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-8);
|
||||
margin: 0;
|
||||
padding: var(--space-10) var(--space-12);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-warning-surface);
|
||||
color: var(--color-warning-text);
|
||||
font-size: var(--font-size-label);
|
||||
}
|
||||
|
||||
.reader__notice-icon {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* 툴바가 없을 때(분할 보기)는 툴바 높이만큼 비워 두어 불러온 뒤 화면이 튀지 않게 한다 */
|
||||
.reader__loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-12);
|
||||
max-width: var(--content-max-width);
|
||||
padding: calc(var(--size-header-min) + var(--content-block-start)) var(--content-inline)
|
||||
var(--content-block-end);
|
||||
}
|
||||
|
||||
.reader__loading--below-toolbar {
|
||||
padding-top: var(--content-block-start);
|
||||
}
|
||||
|
||||
.reader__bar {
|
||||
width: 80%;
|
||||
height: var(--space-10);
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.reader__bar--title {
|
||||
width: 55%;
|
||||
height: var(--space-18);
|
||||
background: var(--color-skeleton);
|
||||
}
|
||||
|
||||
.reader__bar--meta {
|
||||
width: 35%;
|
||||
margin-bottom: var(--space-12);
|
||||
background: var(--color-skeleton);
|
||||
}
|
||||
|
||||
.reader__bar--short {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 본문 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
|
||||
@media (min-width: 70rem) {
|
||||
.reader {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,176 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailViewStore } from '@/stores/mail-view.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MessageTable from './MessageTable.vue'
|
||||
|
||||
const INBOX: Mailbox = {
|
||||
id: 'inbox',
|
||||
name: '받은편지함',
|
||||
role: 'inbox',
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
totalCount: 15,
|
||||
unreadCount: 2,
|
||||
}
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [{ name: '박준호', email: 'junho.park@arasystem.test' }],
|
||||
subject: `제목 ${id}`,
|
||||
preview: '',
|
||||
receivedAt: '2025-10-24T01:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 62 * 1024,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
describe('MessageTable (목록 보기)', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountTable(
|
||||
options: { mailbox?: Mailbox; messages?: MessageSummary[]; hasNext?: boolean } = {},
|
||||
) {
|
||||
const store = useMailStore()
|
||||
store.mailboxes = [options.mailbox ?? INBOX]
|
||||
store.currentMailboxId = (options.mailbox ?? INBOX).id
|
||||
store.messages = options.messages ?? [summary('m1', { isRead: false, hasAttachment: true }), summary('m2')]
|
||||
store.pageInfo = { hasNext: options.hasNext ?? false, nextCursor: options.hasNext ? 'c2' : null, total: 15 }
|
||||
store.listStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push(`/mail/${store.currentMailboxId}`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MessageTable, {
|
||||
props: { mailboxId: store.currentMailboxId ?? 'inbox' },
|
||||
global: { plugins: [pinia, router] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
return { wrapper, router, store }
|
||||
}
|
||||
|
||||
it('진짜 <table> 이다 — 캡션 · 다섯 칸 colgroup · 머리 칸', async () => {
|
||||
const { wrapper } = await mountTable()
|
||||
|
||||
expect(wrapper.get('table caption').text()).toBe('받은편지함 메일 목록')
|
||||
expect(wrapper.findAll('colgroup col')).toHaveLength(5)
|
||||
expect(wrapper.findAll('thead th').map((th) => th.text())).toEqual([
|
||||
'중요 표시',
|
||||
'보낸 사람',
|
||||
'제목',
|
||||
'날짜',
|
||||
'크기',
|
||||
])
|
||||
expect(wrapper.findAll('thead th').every((th) => th.attributes('scope') === 'col')).toBe(true)
|
||||
})
|
||||
|
||||
it('머리에 "전체 N · 안 읽음 M" 과 보기 전환이 있다', async () => {
|
||||
const { wrapper } = await mountTable()
|
||||
|
||||
expect(wrapper.get('.list-header__count').text()).toBe('전체 15 · 안 읽음 2')
|
||||
expect(wrapper.find('[role="group"][aria-label="보기 방식"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('행 어디를 눌러도 메일을 연다', async () => {
|
||||
const { wrapper, router } = await mountTable()
|
||||
|
||||
await wrapper.findAll('tbody tr')[1]?.get('.mail-table__who').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
|
||||
})
|
||||
|
||||
it('별 버튼은 메일을 열지 않고 중요 표시만 바꾼다', async () => {
|
||||
const requests = installMockApi(() => ok(summary('m2', { isFlagged: true })))
|
||||
const { wrapper, router, store } = await mountTable()
|
||||
|
||||
await wrapper.findAll('.mail-table__star-button')[1]?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(requests[0]).toMatchObject({ method: 'PATCH', url: '/messages/m2', data: { isFlagged: true } })
|
||||
expect(store.messages[1]?.isFlagged).toBe(true)
|
||||
})
|
||||
|
||||
it('제목 칸은 키보드로 갈 수 있는 링크다', async () => {
|
||||
const { wrapper } = await mountTable()
|
||||
const link = wrapper.findAll('a.mail-table__link')[0]
|
||||
|
||||
expect(link?.attributes('href')).toBe('/mail/inbox/m1')
|
||||
expect(link?.text()).toBe('제목 m1')
|
||||
})
|
||||
|
||||
it('안 읽은 메일은 굵게(행 표시) 하고 스크린리더에는 "안 읽음" 을 읽힌다 · 첨부는 제목 뒤 클립', async () => {
|
||||
const { wrapper } = await mountTable()
|
||||
const [unread, read] = wrapper.findAll('tbody tr')
|
||||
|
||||
expect(unread?.classes()).toContain('is-unread')
|
||||
expect(read?.classes()).not.toContain('is-unread')
|
||||
expect(unread?.get('.mail-table__who').text()).toContain('안 읽음,')
|
||||
expect(unread?.find('.mail-table__subject [aria-label="첨부 있음"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('날짜 · 크기 칸 — 크기는 사람이 읽는 단위', async () => {
|
||||
const { wrapper } = await mountTable()
|
||||
const firstRow = wrapper.findAll('tbody tr')[0]
|
||||
|
||||
expect(firstRow?.get('.mail-table__date').text()).toBe('2025.10.24')
|
||||
expect(firstRow?.get('td.mail-table__size').text()).toBe('62 KB')
|
||||
})
|
||||
|
||||
it('보낸편지함에서는 받는 사람 칸이 된다', async () => {
|
||||
const sent: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent', unreadCount: 0 }
|
||||
const { wrapper } = await mountTable({ mailbox: sent, messages: [summary('s1')] })
|
||||
|
||||
expect(wrapper.findAll('thead th')[1]?.text()).toBe('받는 사람')
|
||||
expect(wrapper.findAll('tbody .mail-table__who')[0]?.text()).toBe('박준호')
|
||||
})
|
||||
|
||||
it('서버에 더 있으면 "메일 더 보기" 로 다음 페이지를 불러온다', async () => {
|
||||
const requests = installMockApi(() =>
|
||||
ok({ items: [summary('m3')], pageInfo: { hasNext: false, nextCursor: null, total: 15 } }),
|
||||
)
|
||||
const { wrapper } = await mountTable({ hasNext: true })
|
||||
|
||||
await wrapper.get('.message-table__more-button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests[0]?.params).toMatchObject({ mailboxId: 'inbox', cursor: 'c2' })
|
||||
expect(wrapper.findAll('tbody tr')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('목록으로 돌아오면 보던 메일의 제목 링크로 초점을 돌려준다', async () => {
|
||||
useMailViewStore().setReturnFocus('m2')
|
||||
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined)
|
||||
const { wrapper } = await mountTable()
|
||||
await flushPromises()
|
||||
|
||||
expect(document.activeElement).toBe(wrapper.findAll('a.mail-table__link')[1]?.element)
|
||||
scrollTo.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,486 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 보기 — 게시판형 표 (디자인 원본 MainList). 캔버스는 grid 로 그렸지만 구현은 진짜 <table> 이다.
|
||||
// 칸: 별 32 · 보낸 사람 168 · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12) — tokens.css 의 --table-* 값.
|
||||
// 행 어디를 눌러도 메일이 열리고, 제목은 키보드로 갈 수 있는 링크다. 별 버튼은 따로 동작한다.
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import MailListHeader from '@/components/mail/MailListHeader.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { OUTGOING_MAILBOX_ROLES } from '@/shared/constants/mail'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { formatFileSize, formatMailTableDate } from '@/shared/utils/format'
|
||||
import { displayName, summarizeRecipients } from '@/shared/utils/mail-address'
|
||||
import { emptyMailboxText, mailboxIcon } from '@/shared/utils/mailbox'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailViewStore } from '@/stores/mail-view.store'
|
||||
|
||||
interface Props {
|
||||
mailboxId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const viewStore = useMailViewStore()
|
||||
const { messageLocation, replaceWithMailbox } = useMailNavigation()
|
||||
|
||||
/** 넓은 화면에서 표 칸 자체가 스크롤된다 — 메일을 열었다 돌아올 때 위치를 되살린다 */
|
||||
const sectionRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const mailbox = computed(() => mailStore.currentMailbox)
|
||||
const isOutgoing = computed(() => {
|
||||
const role = mailbox.value?.role
|
||||
return role ? OUTGOING_MAILBOX_ROLES.includes(role) : false
|
||||
})
|
||||
const whoHeader = computed(() => (isOutgoing.value ? '받는 사람' : '보낸 사람'))
|
||||
const caption = computed(() => `${mailbox.value?.name ?? '메일함'} 메일 목록`)
|
||||
const empty = computed(() =>
|
||||
emptyMailboxText({ name: mailbox.value?.name ?? '메일함', role: mailbox.value?.role ?? null }),
|
||||
)
|
||||
const emptyIcon = computed(() => mailboxIcon({ role: mailbox.value?.role ?? null }))
|
||||
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
|
||||
|
||||
function whoOf(message: MessageSummary): string {
|
||||
if (isOutgoing.value) return summarizeRecipients(message.to) || '받는 사람 없음'
|
||||
return displayName(message.from) || '(보낸 사람 없음)'
|
||||
}
|
||||
|
||||
/** 행 어디를 눌러도 연다 — 링크·별 버튼을 누른 경우와 글자를 고르는 중이면 그대로 둔다 */
|
||||
function onRowClick(event: MouseEvent, message: MessageSummary) {
|
||||
const target = event.target instanceof Element ? event.target : null
|
||||
if (target?.closest('a, button')) return
|
||||
if (window.getSelection()?.toString()) return
|
||||
void router.push(messageLocation(props.mailboxId, message.id))
|
||||
}
|
||||
|
||||
function retry() {
|
||||
void mailStore.openMailbox(props.mailboxId)
|
||||
}
|
||||
|
||||
/** 없는 메일함(RES_001) — 받은편지함으로 바꿔 끼운다 */
|
||||
function goToInbox() {
|
||||
void replaceWithMailbox(mailStore.defaultMailbox?.id ?? null)
|
||||
}
|
||||
|
||||
// 메일을 열면 이 표가 사라진다 — 스크롤을 기억해 "목록으로" 돌아오면 되살린다 (불러온 페이지는 스토어에 그대로 있다)
|
||||
onBeforeUnmount(() => {
|
||||
viewStore.rememberListPosition({
|
||||
mailboxId: props.mailboxId,
|
||||
scrollTop: sectionRef.value?.scrollTop ?? 0,
|
||||
windowY: window.scrollY,
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const saved = viewStore.takeListPosition(props.mailboxId)
|
||||
const focusId = viewStore.takeReturnFocus()
|
||||
if (!saved && !focusId) return
|
||||
await nextTick()
|
||||
if (saved) {
|
||||
if (sectionRef.value) sectionRef.value.scrollTop = saved.scrollTop
|
||||
if (saved.windowY > 0) window.scrollTo({ top: saved.windowY })
|
||||
}
|
||||
if (focusId) {
|
||||
// 보던 메일의 제목 링크로 초점을 돌려준다 (키보드 사용자가 자리를 잃지 않게)
|
||||
const links = sectionRef.value?.querySelectorAll<HTMLElement>('[data-message-id]') ?? []
|
||||
const link = Array.from(links).find((element) => element.dataset.messageId === focusId)
|
||||
link?.focus({ preventScroll: Boolean(saved) })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
ref="sectionRef"
|
||||
class="message-table"
|
||||
aria-labelledby="message-table-title"
|
||||
:aria-busy="busy"
|
||||
>
|
||||
<MailListHeader
|
||||
title-id="message-table-title"
|
||||
variant="list"
|
||||
/>
|
||||
|
||||
<MessageListSkeleton v-if="mailStore.listStatus === 'loading'" />
|
||||
|
||||
<MailLoadError
|
||||
v-else-if="mailStore.listStatus === 'error' && mailStore.listError"
|
||||
:error="mailStore.listError"
|
||||
leave-label="받은편지함으로"
|
||||
@retry="retry"
|
||||
@leave="goToInbox"
|
||||
/>
|
||||
|
||||
<template v-else-if="mailStore.messages.length > 0">
|
||||
<!-- 표 최소 폭(400px)보다 좁으면 가로로 스크롤한다 -->
|
||||
<div class="message-table__scroll">
|
||||
<table class="mail-table">
|
||||
<caption class="visually-hidden">
|
||||
{{ caption }}
|
||||
</caption>
|
||||
<colgroup>
|
||||
<col class="mail-table__col-star">
|
||||
<col class="mail-table__col-who">
|
||||
<col class="mail-table__col-subject">
|
||||
<col class="mail-table__col-date">
|
||||
<col class="mail-table__col-size">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="mail-table__star"
|
||||
>
|
||||
<AppIcon
|
||||
name="star"
|
||||
:size="14"
|
||||
/>
|
||||
<span class="visually-hidden">중요 표시</span>
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ whoHeader }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
제목
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="mail-table__date"
|
||||
>
|
||||
날짜
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="mail-table__size"
|
||||
>
|
||||
크기
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="message in mailStore.messages"
|
||||
:key="message.id"
|
||||
:class="['mail-table__row', { 'is-unread': !message.isRead }]"
|
||||
@click="onRowClick($event, message)"
|
||||
>
|
||||
<td class="mail-table__star">
|
||||
<!-- 폭 32px · 행 높이를 채우는 별 — 44px 기준의 의도된 예외 (디자인 README, 사용자 확정) -->
|
||||
<button
|
||||
type="button"
|
||||
:class="['mail-table__star-button', { 'is-flagged': message.isFlagged }]"
|
||||
aria-label="중요 표시"
|
||||
:aria-pressed="message.isFlagged"
|
||||
@click="mailStore.toggleFlag(message.id)"
|
||||
>
|
||||
<AppIcon
|
||||
name="star"
|
||||
:size="14"
|
||||
:filled="message.isFlagged"
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
<td class="mail-table__who">
|
||||
<span
|
||||
v-if="!message.isRead"
|
||||
class="visually-hidden"
|
||||
>안 읽음, </span>{{ whoOf(message) }}
|
||||
</td>
|
||||
<td class="mail-table__subject">
|
||||
<div class="mail-table__subject-line">
|
||||
<RouterLink
|
||||
class="mail-table__link"
|
||||
:to="messageLocation(mailboxId, message.id)"
|
||||
:data-message-id="message.id"
|
||||
>
|
||||
{{ message.subject || '(제목 없음)' }}
|
||||
</RouterLink>
|
||||
<AppIcon
|
||||
v-if="message.hasAttachment"
|
||||
name="paperclip"
|
||||
:size="13"
|
||||
:stroke-width="2"
|
||||
label="첨부 있음"
|
||||
class="mail-table__clip"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mail-table__date">
|
||||
<time :datetime="message.receivedAt">{{ formatMailTableDate(message.receivedAt) }}</time>
|
||||
</td>
|
||||
<td class="mail-table__size">
|
||||
{{ formatFileSize(message.size) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
v-if="mailStore.hasMore"
|
||||
class="message-table__more"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="message-table__text-button message-table__more-button"
|
||||
:disabled="mailStore.isLoadingMore"
|
||||
@click="mailStore.loadMore()"
|
||||
>
|
||||
{{ mailStore.isLoadingMore ? '불러오는 중…' : '메일 더 보기' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<StateMessage
|
||||
v-else-if="mailStore.listStatus === 'ready'"
|
||||
class="message-table__empty"
|
||||
:icon="emptyIcon"
|
||||
:title="empty.title"
|
||||
:hint="empty.hint"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-table {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/*
|
||||
* 가로 스크롤만 맡는다. overflow-x 를 주면 세로도 스크롤 상자가 되어 세로 flex 안에서 줄어들고
|
||||
* 이 상자가 세로 스크롤을 가로챈다 — 줄어들지 않게 해 칸(section) 전체가 스크롤되게 한다
|
||||
* (분할 보기와 같이 "메일 더 보기"가 표 바로 뒤에 오고, 돌아올 때 칸의 스크롤 위치를 되살린다).
|
||||
*/
|
||||
.message-table__scroll {
|
||||
flex: 0 0 auto;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.mail-table {
|
||||
width: 100%;
|
||||
min-width: var(--table-min-width);
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
/*
|
||||
* 칸 폭 = 디자인 칸 폭 + 칸 사이(12px). 첫 칸에는 행 왼쪽 여백(20px), 마지막 칸에는 행 오른쪽 여백(8px)을 더한다.
|
||||
* 칸 사이는 각 칸의 오른쪽 안쪽 여백으로 만든다.
|
||||
*/
|
||||
.mail-table__col-star {
|
||||
width: calc(var(--space-20) + var(--table-col-star) + var(--table-gap));
|
||||
}
|
||||
|
||||
.mail-table__col-who {
|
||||
width: calc(var(--table-col-who) + var(--table-gap));
|
||||
}
|
||||
|
||||
.mail-table__col-date {
|
||||
width: calc(var(--table-col-date) + var(--table-gap));
|
||||
}
|
||||
|
||||
.mail-table__col-size {
|
||||
width: calc(var(--table-col-size) + var(--space-8));
|
||||
}
|
||||
|
||||
.mail-table th,
|
||||
.mail-table td {
|
||||
padding: 0 var(--table-gap) 0 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.mail-table th:first-child,
|
||||
.mail-table td:first-child {
|
||||
padding-left: var(--space-20);
|
||||
}
|
||||
|
||||
.mail-table th:last-child,
|
||||
.mail-table td:last-child {
|
||||
padding-right: var(--space-8);
|
||||
}
|
||||
|
||||
.mail-table thead th {
|
||||
height: var(--size-table-head);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface-sunken);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mail-table thead .mail-table__star {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.mail-table__row {
|
||||
height: var(--size-table-row);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mail-table__row td {
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.mail-table__row:hover {
|
||||
background: var(--color-row-hover);
|
||||
}
|
||||
|
||||
.mail-table__row:focus-within {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.mail-table__star-button {
|
||||
width: var(--size-compact-control);
|
||||
height: calc(var(--size-table-row) - var(--border-width));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-xs);
|
||||
background: transparent;
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.mail-table__star-button.is-flagged {
|
||||
color: var(--color-star);
|
||||
}
|
||||
|
||||
.mail-table__who {
|
||||
font-weight: 450;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.mail-table__subject-line {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.mail-table__link {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mail-table__link:hover {
|
||||
color: var(--color-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.mail-table__clip {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 날짜·크기는 머리와 행 모두 오른쪽 정렬 — 위의 `.mail-table th/td { text-align: left }` 보다
|
||||
우선하도록 th·td 를 함께 적는다 (클래스만 쓰면 기본 규칙에 져서 행만 왼쪽으로 어긋난다) */
|
||||
.mail-table th.mail-table__date,
|
||||
.mail-table th.mail-table__size,
|
||||
.mail-table td.mail-table__date,
|
||||
.mail-table td.mail-table__size {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mail-table__date,
|
||||
.mail-table__size {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 */
|
||||
.mail-table__row.is-unread .mail-table__who,
|
||||
.mail-table__row.is-unread .mail-table__link {
|
||||
font-weight: 650;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mail-table__row.is-unread .mail-table__date {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.message-table__more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--space-10) var(--space-20) var(--space-20);
|
||||
}
|
||||
|
||||
.message-table__text-button {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-14);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message-table__text-button:hover:not(:disabled) {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.message-table__more-button {
|
||||
min-height: var(--size-row-min);
|
||||
padding: 0 var(--space-24);
|
||||
}
|
||||
|
||||
.message-table__more-button:disabled {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.message-table__empty {
|
||||
padding-block: calc(var(--space-80) + var(--space-16));
|
||||
}
|
||||
|
||||
/* 1100px 미만 — 크기 칸을 숨기고 날짜 칸이 마지막 칸이 된다 (tokens.css 화면 폭 기준) */
|
||||
@media (max-width: 68.6875rem) {
|
||||
.mail-table__col-size,
|
||||
.mail-table__size {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mail-table__col-date {
|
||||
width: calc(var(--table-col-date) + var(--space-8));
|
||||
}
|
||||
|
||||
.mail-table th.mail-table__date,
|
||||
.mail-table td.mail-table__date {
|
||||
padding-right: var(--space-8);
|
||||
}
|
||||
}
|
||||
|
||||
/* 600px 미만 — 보낸 사람 · 날짜 칸을 줄인다. 표 최소 폭(400px)보다 좁으면 가로 스크롤 */
|
||||
@media (max-width: 37.4375rem) {
|
||||
.mail-table__col-who {
|
||||
width: calc(var(--table-col-who-narrow) + var(--table-gap));
|
||||
}
|
||||
|
||||
.mail-table__col-date {
|
||||
width: calc(var(--table-col-date-narrow) + var(--space-8));
|
||||
}
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 표 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
|
||||
@media (min-width: 70rem) {
|
||||
.message-table {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
|
||||
import type { RecipientToken } from '@/shared/utils/mail-address'
|
||||
import RecipientField from './RecipientField.vue'
|
||||
|
||||
/** v-model 처럼 갱신 이벤트를 받아 다시 props 로 넣는다 — 마지막 값은 models 에서 읽는다 */
|
||||
const models = new WeakMap<VueWrapper, RecipientToken[]>()
|
||||
|
||||
function mountField(initial: RecipientToken[] = []): VueWrapper {
|
||||
const wrapper: VueWrapper = mount(RecipientField, {
|
||||
props: {
|
||||
modelValue: initial,
|
||||
label: '받는 사람',
|
||||
inputId: 'compose-to',
|
||||
errorId: 'compose-recipient-error',
|
||||
'onUpdate:modelValue': (value: RecipientToken[]) => {
|
||||
models.set(wrapper, value)
|
||||
return wrapper.setProps({ modelValue: value })
|
||||
},
|
||||
},
|
||||
})
|
||||
models.set(wrapper, initial)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function tokensOf(wrapper: VueWrapper): RecipientToken[] {
|
||||
return models.get(wrapper) ?? []
|
||||
}
|
||||
|
||||
describe('RecipientField', () => {
|
||||
it('Enter 로 토큰을 확정하고, Enter 가 폼을 제출하지 않게 막는다', async () => {
|
||||
const wrapper = mountField()
|
||||
const input = wrapper.get('input')
|
||||
await input.setValue('doyun.lee@arasystem.it.kr')
|
||||
|
||||
const event = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true })
|
||||
input.element.dispatchEvent(event)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(tokensOf(wrapper).map((token) => token.address.email)).toEqual(['doyun.lee@arasystem.it.kr'])
|
||||
expect((input.element as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('쉼표와 입력칸을 벗어날 때(blur)도 확정한다', async () => {
|
||||
const wrapper = mountField()
|
||||
const input = wrapper.get('input')
|
||||
|
||||
await input.setValue('a@arasystem.it.kr')
|
||||
await input.trigger('keydown', { key: ',' })
|
||||
await input.setValue('b@arasystem.it.kr')
|
||||
await input.trigger('blur')
|
||||
|
||||
expect(tokensOf(wrapper).map((token) => token.address.email)).toEqual([
|
||||
'a@arasystem.it.kr',
|
||||
'b@arasystem.it.kr',
|
||||
])
|
||||
})
|
||||
|
||||
it('한글 조합 중의 Enter 는 토큰으로 만들지 않는다', async () => {
|
||||
const wrapper = mountField()
|
||||
const input = wrapper.get('input')
|
||||
await input.setValue('이도')
|
||||
|
||||
await input.trigger('keydown', { key: 'Enter', isComposing: true })
|
||||
|
||||
expect(tokensOf(wrapper)).toEqual([])
|
||||
})
|
||||
|
||||
it('형식이 틀린 주소는 빨간 물결 밑줄 토큰이 되고 입력칸이 오류 안내를 가리킨다', async () => {
|
||||
const wrapper = mountField()
|
||||
const input = wrapper.get('input')
|
||||
await input.setValue('kim@arasystem')
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
|
||||
expect(wrapper.get('.recipient-chip').classes()).toContain('is-invalid')
|
||||
expect(input.attributes('aria-invalid')).toBe('true')
|
||||
expect(input.attributes('aria-describedby')).toBe('compose-recipient-error')
|
||||
})
|
||||
|
||||
it('여러 주소를 붙여 넣으면 한 번에 나눠 토큰으로 만든다', async () => {
|
||||
const wrapper = mountField()
|
||||
const input = wrapper.get('input')
|
||||
|
||||
const paste = new Event('paste', { cancelable: true }) as ClipboardEvent
|
||||
Object.defineProperty(paste, 'clipboardData', {
|
||||
value: { getData: () => 'a@b.kr, 박준호 <junho.park@arasystem.it.kr>' },
|
||||
})
|
||||
input.element.dispatchEvent(paste)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(paste.defaultPrevented).toBe(true)
|
||||
expect(tokensOf(wrapper).map((token) => token.label)).toEqual([
|
||||
'a@b.kr',
|
||||
'박준호 <junho.park@arasystem.it.kr>',
|
||||
])
|
||||
})
|
||||
|
||||
it('빈 입력칸에서 Backspace 는 마지막 토큰을 지우고, 삭제 버튼은 그 토큰만 지운다', async () => {
|
||||
const wrapper = mountField()
|
||||
const input = wrapper.get('input')
|
||||
await input.setValue('a@b.kr, c@d.kr, e@f.kr')
|
||||
await input.trigger('blur')
|
||||
|
||||
await input.trigger('keydown', { key: 'Backspace' })
|
||||
expect(tokensOf(wrapper).map((token) => token.label)).toEqual(['a@b.kr', 'c@d.kr'])
|
||||
|
||||
await wrapper.get('button[aria-label="a@b.kr 삭제"]').trigger('click')
|
||||
expect(tokensOf(wrapper).map((token) => token.label)).toEqual(['c@d.kr'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,243 @@
|
||||
<script setup lang="ts">
|
||||
// 받는 사람 · 참조 · 숨은참조 입력 — Enter · 쉼표 · 입력칸을 벗어날 때 토큰으로 확정한다 (디자인 원본 Compose)
|
||||
// 형식이 틀린 주소는 빨간 물결 밑줄로 바로 표시한다. 오류 문구(MAIL_002)는 쓰기 화면이 사전에서 가져와 보인다.
|
||||
// 사용 예: <RecipientField v-model="to" label="받는 사람" input-id="compose-to" error-id="compose-recipient-error" />
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import {
|
||||
hasOpenRecipientGroup,
|
||||
mergeRecipientTokens,
|
||||
parseRecipient,
|
||||
recipientTokenKey,
|
||||
splitRecipientInput,
|
||||
type RecipientToken,
|
||||
} from '@/shared/utils/mail-address'
|
||||
|
||||
interface Props {
|
||||
modelValue: RecipientToken[]
|
||||
label: string
|
||||
inputId: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
/** 오류 안내 문단 id — 형식이 틀린 주소가 있으면 입력칸과 연결한다 */
|
||||
errorId?: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: RecipientToken[]): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: '',
|
||||
disabled: false,
|
||||
errorId: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 아직 토큰이 되지 않은 입력 */
|
||||
const draft = ref<string>('')
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const hasInvalid = computed(() => props.modelValue.some((token) => !token.valid))
|
||||
|
||||
/** 입력한 글을 토큰으로 확정한다 (여러 주소를 붙여 넣었으면 나눠서) */
|
||||
function commit(text: string = draft.value) {
|
||||
const tokens = splitRecipientInput(text)
|
||||
.map(parseRecipient)
|
||||
.filter((token): token is RecipientToken => token !== null)
|
||||
draft.value = ''
|
||||
if (tokens.length === 0) return
|
||||
emit('update:modelValue', mergeRecipientTokens(props.modelValue, tokens))
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
// 한글 조합 중의 Enter 는 글자 확정이다 — 토큰으로 만들지 않는다
|
||||
if (event.isComposing) return
|
||||
|
||||
const isSeparator =
|
||||
(event.key === ',' || event.key === ';') && !hasOpenRecipientGroup(draft.value)
|
||||
if (event.key === 'Enter' || isSeparator) {
|
||||
// Enter 가 폼을 제출(메일 발송)하지 않게 막는다
|
||||
event.preventDefault()
|
||||
commit()
|
||||
return
|
||||
}
|
||||
if (event.key === 'Backspace' && draft.value === '' && props.modelValue.length > 0) {
|
||||
emit('update:modelValue', props.modelValue.slice(0, -1))
|
||||
}
|
||||
}
|
||||
|
||||
function onPaste(event: ClipboardEvent) {
|
||||
const text = event.clipboardData?.getData('text') ?? ''
|
||||
// 주소 하나면 평소처럼 붙여 넣고, 여러 개면 바로 토큰으로 나눈다
|
||||
if (!/[,;\n]/.test(text)) return
|
||||
event.preventDefault()
|
||||
commit(`${draft.value}${text}`)
|
||||
}
|
||||
|
||||
function remove(token: RecipientToken) {
|
||||
const key = recipientTokenKey(token)
|
||||
emit(
|
||||
'update:modelValue',
|
||||
props.modelValue.filter((item) => recipientTokenKey(item) !== key),
|
||||
)
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
|
||||
function focus() {
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
|
||||
defineExpose({ commit, focus })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="recipient-field">
|
||||
<label
|
||||
:for="inputId"
|
||||
class="recipient-field__label"
|
||||
>{{ label }}</label>
|
||||
<div class="recipient-field__control">
|
||||
<span
|
||||
v-for="token in modelValue"
|
||||
:key="recipientTokenKey(token)"
|
||||
:class="['recipient-chip', { 'is-invalid': !token.valid }]"
|
||||
>
|
||||
<span class="recipient-chip__label">{{ token.label }}</span>
|
||||
<span
|
||||
v-if="!token.valid"
|
||||
class="visually-hidden"
|
||||
>(주소 형식 오류)</span>
|
||||
<button
|
||||
type="button"
|
||||
class="recipient-chip__remove"
|
||||
:aria-label="`${token.label} 삭제`"
|
||||
:disabled="disabled"
|
||||
@click="remove(token)"
|
||||
>
|
||||
<AppIcon
|
||||
name="x"
|
||||
:size="12"
|
||||
:stroke-width="2.25"
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
<input
|
||||
:id="inputId"
|
||||
ref="inputRef"
|
||||
v-model="draft"
|
||||
type="text"
|
||||
inputmode="email"
|
||||
autocomplete="off"
|
||||
class="recipient-field__input"
|
||||
:placeholder="modelValue.length === 0 ? placeholder : ''"
|
||||
:disabled="disabled"
|
||||
:aria-invalid="hasInvalid"
|
||||
:aria-describedby="hasInvalid && errorId ? errorId : undefined"
|
||||
@keydown="onKeydown"
|
||||
@blur="commit()"
|
||||
@paste="onPaste"
|
||||
>
|
||||
</div>
|
||||
<slot name="trailing" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recipient-field {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-6) var(--space-12);
|
||||
min-height: var(--size-touch);
|
||||
/* 쓰기 화면의 다른 줄과 같은 콘텐츠 열 32px 선 */
|
||||
padding: var(--space-4) var(--content-toolbar-start) var(--space-4) var(--content-inline);
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.recipient-field__label {
|
||||
flex: 0 0 4rem;
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.recipient-field__control {
|
||||
flex: 1 1 20rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-4) var(--space-6);
|
||||
}
|
||||
|
||||
.recipient-field__input {
|
||||
flex: 1 1 12.5rem;
|
||||
min-width: 7.5rem;
|
||||
min-height: 2.125rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.recipient-field__input:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 테두리 없는 넓은 입력 영역이라 쓰기 화면의 제목·본문 칸과 같이 1px 로 가볍게 표시한다 */
|
||||
.recipient-field__control:focus-within {
|
||||
outline: var(--border-width) solid var(--color-brand);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
|
||||
.recipient-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: var(--space-28);
|
||||
max-width: 100%;
|
||||
padding: 0 var(--space-2) 0 var(--space-8);
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.recipient-chip__label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 형식 오류 — 디자인 원본의 빨간 물결 밑줄 */
|
||||
.recipient-chip.is-invalid {
|
||||
background: var(--color-danger-surface);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.recipient-chip.is-invalid .recipient-chip__label {
|
||||
text-decoration: underline wavy var(--color-danger);
|
||||
text-underline-offset: var(--space-3);
|
||||
}
|
||||
|
||||
.recipient-chip__remove {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.625rem;
|
||||
height: 1.625rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-xs);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.recipient-chip__remove:hover {
|
||||
background: var(--color-border);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
// "분할 | 목록" 보기 전환 (디자인 원본 Main · MainList 목록 머리)
|
||||
// 선택은 사용자별로 기억한다 (stores/mail-view.store.ts).
|
||||
//
|
||||
// 크기: 한 칸 높이 30px, 테두리·안쪽 여백을 더해 바깥 36px — 클릭 영역 44px 기준의 의도된 예외다.
|
||||
// 목록 머리에 정보를 촘촘히 두기 위해 사용자가 확정했다 (디자인 README "디자인 원칙").
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { useMailViewStore, type MailViewMode } from '@/stores/mail-view.store'
|
||||
|
||||
const viewStore = useMailViewStore()
|
||||
|
||||
const OPTIONS: readonly { mode: MailViewMode; label: string; icon: 'view-split' | 'view-list' }[] = [
|
||||
{ mode: 'split', label: '분할', icon: 'view-split' },
|
||||
{ mode: 'list', label: '목록', icon: 'view-list' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="view-switch"
|
||||
role="group"
|
||||
aria-label="보기 방식"
|
||||
>
|
||||
<button
|
||||
v-for="option in OPTIONS"
|
||||
:key="option.mode"
|
||||
type="button"
|
||||
:class="['view-switch__item', { 'is-active': viewStore.mode === option.mode }]"
|
||||
:aria-pressed="viewStore.mode === option.mode"
|
||||
@click="viewStore.setMode(option.mode)"
|
||||
>
|
||||
<AppIcon
|
||||
:name="option.icon"
|
||||
:size="14"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
<span>{{ option.label }}<span class="visually-hidden"> 보기</span></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.view-switch {
|
||||
display: inline-flex;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.view-switch__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
min-height: var(--size-segment-item);
|
||||
padding: 0 var(--space-8);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-strong);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.view-switch__item:hover:not(.is-active) {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.view-switch__item.is-active {
|
||||
background: var(--color-brand-subtle);
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import { APP_MODULES } from '@/shared/constants/app-modules'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import AppRail from './AppRail.vue'
|
||||
|
||||
describe('AppRail', () => {
|
||||
async function mountRail() {
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail/inbox')
|
||||
await router.isReady()
|
||||
return mount(AppRail, { props: { activeModule: 'mail' }, global: { plugins: [router] } })
|
||||
}
|
||||
|
||||
it('모듈 목록 하나로 그린다 — 메일 · 캘린더 · 파일 · ERP · 주소록 · 설정', async () => {
|
||||
const wrapper = await mountRail()
|
||||
const labels = wrapper.findAll('.rail-item').map((item) => item.text().replace('(준비 중)', '').trim())
|
||||
|
||||
expect(labels).toEqual(APP_MODULES.map((module) => module.label))
|
||||
})
|
||||
|
||||
it('지금은 메일만 링크이고 현재 모듈로 표시된다', async () => {
|
||||
const wrapper = await mountRail()
|
||||
const current = wrapper.get('a[aria-current="page"]')
|
||||
|
||||
expect(current.text()).toBe('메일')
|
||||
expect(current.attributes('href')).toBe('/mail')
|
||||
})
|
||||
|
||||
it('나머지는 "준비 중" 비활성 칸이다 (뱃지 없이 툴팁·스크린리더 문구로)', async () => {
|
||||
const wrapper = await mountRail()
|
||||
const disabled = wrapper.findAll('button[aria-disabled="true"]')
|
||||
|
||||
expect(disabled).toHaveLength(APP_MODULES.length - 1)
|
||||
for (const item of disabled) {
|
||||
expect(item.attributes('title')).toContain('준비 중')
|
||||
expect(item.get('.visually-hidden').text()).toBe('(준비 중)')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
// 전역 앱 레일 — 모듈 목록(APP_MODULES) 하나로 그린다. 새 모듈은 목록에만 추가하면 된다.
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppRailItem from './AppRailItem.vue'
|
||||
import logoMark from '@/assets/brand/logo-ara-white.png'
|
||||
import { APP_MODULES, type AppModuleId } from '@/shared/constants/app-modules'
|
||||
import { APP_NAME } from '@/shared/constants/brand'
|
||||
|
||||
interface Props {
|
||||
/** 지금 보고 있는 모듈 */
|
||||
activeModule: AppModuleId
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const mainModules = computed(() => APP_MODULES.filter((module) => module.placement === 'main'))
|
||||
const endModules = computed(() => APP_MODULES.filter((module) => module.placement === 'end'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="app-rail"
|
||||
aria-label="사내 시스템"
|
||||
>
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="app-rail__logo"
|
||||
>
|
||||
<!-- 흰 로고 — 어두운 브랜드 바탕(#0B3640) 위에만 둔다. 원본 320×111 -->
|
||||
<img
|
||||
:src="logoMark"
|
||||
:alt="APP_NAME"
|
||||
width="320"
|
||||
height="111"
|
||||
class="app-rail__logo-image"
|
||||
>
|
||||
</RouterLink>
|
||||
<ul class="app-rail__list">
|
||||
<li
|
||||
v-for="module in mainModules"
|
||||
:key="module.id"
|
||||
>
|
||||
<AppRailItem
|
||||
:module="module"
|
||||
:active="module.id === activeModule"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="app-rail__list app-rail__list--end">
|
||||
<li
|
||||
v-for="module in endModules"
|
||||
:key="module.id"
|
||||
>
|
||||
<AppRailItem
|
||||
:module="module"
|
||||
:active="module.id === activeModule"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-rail {
|
||||
flex: 0 0 var(--size-rail);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-14);
|
||||
padding: var(--space-10) 0 var(--space-12);
|
||||
background: var(--color-brand-surface);
|
||||
}
|
||||
|
||||
.app-rail__logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-rail-item);
|
||||
min-height: var(--size-touch);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.app-rail__logo:focus-visible {
|
||||
outline-color: var(--color-on-brand-link);
|
||||
}
|
||||
|
||||
.app-rail__logo-image {
|
||||
display: block;
|
||||
width: 3rem;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.app-rail__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.app-rail__list--end {
|
||||
margin-top: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
// 앱 레일의 모듈 한 칸 — 라우트가 있으면 링크, 없으면 "준비 중" 비활성 칸 (뱃지 없이)
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { MODULE_NOT_READY_LABEL, type AppModule } from '@/shared/constants/app-modules'
|
||||
|
||||
interface Props {
|
||||
module: AppModule
|
||||
/** 지금 보고 있는 모듈인지 */
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
active: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink
|
||||
v-if="module.routeName"
|
||||
:to="{ name: module.routeName }"
|
||||
:class="['rail-item', { 'is-active': active }]"
|
||||
:aria-current="active ? 'page' : undefined"
|
||||
>
|
||||
<AppIcon
|
||||
:name="module.icon"
|
||||
:size="20"
|
||||
/>
|
||||
<span>{{ module.label }}</span>
|
||||
</RouterLink>
|
||||
<!-- 준비 중인 모듈 — 초점은 받아 존재를 알리되 눌러도 아무 일도 하지 않는다 -->
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="rail-item is-disabled"
|
||||
aria-disabled="true"
|
||||
:title="`${module.label} (${MODULE_NOT_READY_LABEL})`"
|
||||
>
|
||||
<AppIcon
|
||||
:name="module.icon"
|
||||
:size="20"
|
||||
/>
|
||||
<span>{{ module.label }}</span>
|
||||
<span class="visually-hidden">({{ MODULE_NOT_READY_LABEL }})</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
width: var(--size-rail-item);
|
||||
min-height: var(--space-56);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
background: transparent;
|
||||
color: var(--color-on-brand-muted);
|
||||
font-size: var(--font-size-caption);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.rail-item:hover {
|
||||
color: var(--color-on-brand);
|
||||
background: var(--color-brand-surface-active);
|
||||
}
|
||||
|
||||
.rail-item.is-active {
|
||||
background: var(--color-brand-surface-active);
|
||||
color: var(--color-on-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rail-item.is-disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 어두운 바탕에서는 강조색 테두리가 잘 보이지 않아 밝은 색으로 바꾼다 */
|
||||
.rail-item:focus-visible {
|
||||
outline-color: var(--color-on-brand-link);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
// 전역 셸 — 왼쪽 앱 레일 + 상단 바 + 모듈 영역(slot).
|
||||
// 모든 모듈 레이아웃은 이 셸로 감싼다: <AppShell module-id="mail"> … </AppShell>
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppRail from './AppRail.vue'
|
||||
import AppTopBar from './AppTopBar.vue'
|
||||
import { findAppModule, type AppModuleId } from '@/shared/constants/app-modules'
|
||||
|
||||
interface Props {
|
||||
moduleId: AppModuleId
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const moduleLabel = computed(() => findAppModule(props.moduleId).label)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<AppRail :active-module="moduleId" />
|
||||
<div class="app-shell__main">
|
||||
<AppTopBar :title="moduleLabel" />
|
||||
<div class="app-shell__module">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.app-shell__main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 모듈 영역 — 좁은 화면에서는 칸이 아래로 접힌다 (디자인 원본의 flex-wrap) */
|
||||
.app-shell__module {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/*
|
||||
* 넓은 화면(tokens.css 화면 폭 기준)에서는 화면 높이에 맞추고 칸마다 따로 스크롤한다.
|
||||
* 줄바꿈(wrap)을 끄는 이유: 여러 줄 flex 는 줄 높이를 내용에 맞춰 늘리므로 칸이 화면 높이에 묶이지 않는다.
|
||||
* 이 폭에서는 칸이 원래 한 줄에 다 들어간다.
|
||||
*/
|
||||
@media (min-width: 70rem) {
|
||||
.app-shell {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-shell__main,
|
||||
.app-shell__module {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-shell__module {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
// 상단 바 — 모듈 이름 · 통합 검색 · 로그인한 직원 · 로그아웃
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { MODULE_NOT_READY_LABEL } from '@/shared/constants/app-modules'
|
||||
import { initialOf } from '@/shared/utils/format'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
interface Props {
|
||||
/** 지금 모듈 이름 (예: 메일) */
|
||||
title: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const { logout } = useAuth()
|
||||
|
||||
const loggingOut = ref<boolean>(false)
|
||||
const initial = computed(() => initialOf(authStore.user?.name, authStore.user?.email ?? ''))
|
||||
|
||||
async function onLogout() {
|
||||
if (loggingOut.value) return
|
||||
loggingOut.value = true
|
||||
try {
|
||||
await logout()
|
||||
} finally {
|
||||
loggingOut.value = false
|
||||
}
|
||||
await router.push({ name: 'login' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="top-bar">
|
||||
<span class="top-bar__title">{{ title }}</span>
|
||||
|
||||
<!-- 통합 검색은 1단계 범위 밖이다 (docs/mail/api-contract.md "이 단계에서 하지 않는 것") -->
|
||||
<div
|
||||
class="top-bar__search"
|
||||
:title="MODULE_NOT_READY_LABEL"
|
||||
>
|
||||
<AppIcon
|
||||
name="search"
|
||||
class="top-bar__search-icon"
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
class="top-bar__search-input"
|
||||
:aria-label="`통합 검색 (${MODULE_NOT_READY_LABEL})`"
|
||||
placeholder="통합 검색"
|
||||
disabled
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="authStore.user"
|
||||
class="top-bar__account"
|
||||
>
|
||||
<span
|
||||
class="top-bar__avatar"
|
||||
aria-hidden="true"
|
||||
>{{ initial }}</span>
|
||||
<div class="top-bar__who">
|
||||
<span class="top-bar__name">{{ authStore.user.name }}</span>
|
||||
<span class="top-bar__email">{{ authStore.user.email }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="top-bar__logout"
|
||||
:disabled="loggingOut"
|
||||
@click="onLogout"
|
||||
>
|
||||
<AppIcon
|
||||
name="log-out"
|
||||
:size="15"
|
||||
/>
|
||||
<span>로그아웃</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.top-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-8) var(--space-16);
|
||||
min-height: var(--size-topbar-min);
|
||||
padding: var(--space-6) var(--space-12) var(--space-6) var(--space-20);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.top-bar__title {
|
||||
font-size: var(--font-size-title);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
}
|
||||
|
||||
.top-bar__search {
|
||||
flex: 1 1 17.5rem;
|
||||
max-width: 32.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
height: 2.5rem;
|
||||
padding: 0 var(--space-12);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-panel);
|
||||
color: var(--color-text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.top-bar__search-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.top-bar__search-input:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.top-bar__account {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
}
|
||||
|
||||
.top-bar__avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: var(--radius-round);
|
||||
background: var(--color-brand-subtle);
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.top-bar__who {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
.top-bar__name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.top-bar__email {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.top-bar__logout {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-10);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.top-bar__logout:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user