generated from ttipo/project-base
fix: 화면 · 오류 문구를 사람이 말하듯 다듬고 엔진 검색의 기간 시작 경계를 고친다 #19
@@ -18,7 +18,7 @@ interface Props {
|
||||
tone?: 'neutral' | 'danger'
|
||||
/** 서버 추적 ID — 앞 8자리를 "문의 코드"로 보인다 */
|
||||
traceId?: string | null
|
||||
/** 영역을 세로로 가득 채워 가운데 둔다 (본문 영역의 "메일을 선택해 주세요") */
|
||||
/** 영역을 세로로 가득 채워 가운데 둔다 (본문 영역의 "고른 메일이 없습니다") */
|
||||
fill?: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// "같은 대화" 칸에서 펼친 줄의 안 (14단계 — 디자인 원본 Thread14 "하나 펼침")
|
||||
// 받는 사람 · 제목 한 줄씩, 본문(MailBodyFrame — 검색 쿼리 없이 받은 본문이라 강조가 없다), [답장] · [이 메일만 열기](36 — 밀도 예외 --size-notice-action 재사용).
|
||||
// - 본문이 다 보이지 않으면(서버의 isHtmlBodyOmitted · isBodyTruncated) 읽기 화면과 같은 안내 줄(MAIL_BODY_NOTICES)을 본문 위에 — 읽기 화면도 같은 본문이다.
|
||||
// 앞 문장만 보이고 원문 내려받기(할 일)는 붙이지 않는다 — 이 화면의 ⋯ 는 위에 연 메일의 것이라, 원문은 [이 메일만 열기]로 연 읽기 화면에서 받는다.
|
||||
// 외부 이미지 안내 · 첨부 목록은 두지 않는다 — [이 메일만 열기]로 연 읽기 화면에 있다. 첨부가 있으면 본문 아래 · [답장] 위에
|
||||
// "첨부 파일 N개는 [이 메일만 열기]를 누르면 볼 수 있습니다." (수는 읽기 화면의 "첨부 파일 N개"와 같다 — 본문 그림 빼고 · 대용량 첨부 더해)
|
||||
// - 본문을 받는 동안은 뼈대(aria-busy), 못 받으면 사전 문구 + [다시 시도]. 그사이 지워진 메일(RES_001)이면 [다시 시도] · [답장] · [이 메일만 열기] 없이 —
|
||||
@@ -49,7 +50,7 @@ const ready = computed(() => (props.detail?.status === 'ready' ? props.detail.de
|
||||
const failure = computed(() => (props.detail?.status === 'error' ? props.detail.error : null))
|
||||
/** 그사이 지워진 메일 — 다시 해도 같고 그 메일로 갈 곳도 없다 */
|
||||
const missing = computed(() => failure.value?.code === 'RES_001')
|
||||
const bodyNotices = computed(() => (ready.value ? mailBodyNotices(ready.value) : []))
|
||||
const bodyNotices = computed(() => (ready.value ? mailBodyNotices(ready.value, { withAction: false }) : []))
|
||||
/** 읽기 화면에서 볼 수 있는 첨부 수 — 본문 그림(isInline)은 빼고 대용량 첨부는 더한다 (MessageReader 의 첨부 칸과 같은 수) */
|
||||
const attachmentCount = computed(() =>
|
||||
ready.value ? listedAttachments(ready.value.attachments).length + (ready.value.largeFiles?.length ?? 0) : 0,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { MAILBOX_EMPTY_HINTS } from '@/shared/constants/mail'
|
||||
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
@@ -239,7 +240,9 @@ describe('MailListFrame — 휴지통 · 스팸메일함의 30일 줄 (13단계)
|
||||
|
||||
expect(wrapper.find('[data-retention-notice]').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('휴지통이 비어 있습니다')
|
||||
expect(wrapper.text()).toContain('삭제한 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.')
|
||||
expect(wrapper.text()).toContain(MAILBOX_EMPTY_HINTS.trash)
|
||||
// 줄 대신 이 글이 30일을 알린다
|
||||
expect(MAILBOX_EMPTY_HINTS.trash).toContain('30일')
|
||||
})
|
||||
|
||||
it('다른 메일함 · 검색 결과에는 없다', async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { SEARCH_CONDITIONS_TEXT } from '@/shared/constants/mail-search'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
@@ -245,9 +246,7 @@ describe('MailSearchBox — [조건] · 「조건으로 찾기」 (14단계 디
|
||||
'최근 1년',
|
||||
'날짜 고르기',
|
||||
])
|
||||
expect(panel.text()).toContain(
|
||||
'검색 칸의 말과 이 조건에 모두 맞는 메일을 찾습니다. 받는 사람에는 참조로 받은 사람도 들어갑니다.',
|
||||
)
|
||||
expect(panel.text()).toContain(SEARCH_CONDITIONS_TEXT.hint)
|
||||
// 칸 안내 — 내용 칸은 칸 이름과 같은 말로 (2026-11-06 실사용 시험)
|
||||
expect(['from', 'to', 'subject', 'body'].map((name) => field(wrapper, name).attributes('placeholder'))).toEqual([
|
||||
'이름이나 메일 주소',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
// 검색 결과 없음 (디자인 원본 Search · Search14 "결과 없음") — 더 찾을 것이 없을 때(hasNext false)만 보인다(이어 찾을 것이 남았으면 MailSearchMore)
|
||||
// 검색 아이콘 · "‘계약’에 맞는 메일이 없습니다"(조건만으로 찾았으면 "조건에 맞는 메일이 없습니다") · 도움말 —
|
||||
// 검색 아이콘 · "‘계약’이 들어간 메일이 없습니다"(조건만으로 찾았으면 "조건에 맞는 메일이 없습니다") · 도움말 —
|
||||
// 검색어와 조건이 함께면 "다른 말로 찾거나 조건을 줄여 보세요.", 조건만이면 "조건을 바꾸거나 줄여 보세요.", 걸린 조건이 없으면 "다른 말로 찾아 보세요."
|
||||
// 지난 메일을 검색할 수 있게 준비하는 동안(엔진 방식 — 낱말 단위)은 그 한계를 한 줄 더한다 — 준비가 끝나면 뺀다 (14단계)
|
||||
// 한 메일함에서 찾았으면 [전체 메일함에서 찾기]
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { MAILBOX_OPERATION_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import type { MailVacationState, Mailbox } from '@/shared/types/mail'
|
||||
import { endOfDayIso } from '@/shared/utils/mail-vacation'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
@@ -433,7 +434,7 @@ describe('MailboxPanel — 메일함 관리', () => {
|
||||
await choose(wrapper, '받은편지함', '모두 읽음으로 표시')
|
||||
// 진행 알림은 그 자리에서 결과로 바뀐다 — 까닭(사전 문구 · 문의 코드)은 인터셉터의 알림
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['error', '1,234통 가운데 500통까지 처리하고 멈췄습니다. 다시 시도해 주세요.'],
|
||||
['error', MAILBOX_OPERATION_TEXT.failed('500', '1,234')],
|
||||
['error', ERROR_CODE_MESSAGES.MAIL_001],
|
||||
])
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { CLOCK, type Clock } from '@/composables/useClock'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
|
||||
import { MAIL_BODY_NOTICE_ACTIONS, MAIL_BODY_NOTICES } from '@/shared/constants/mail'
|
||||
import { MAIL_CONVERSATION_PAGE_SIZE, MAIL_CONVERSATION_REFRESH_GAP_MS } from '@/shared/constants/mail-conversation'
|
||||
import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
|
||||
import type { MailListSource } from '@/shared/utils/mail-list-source'
|
||||
@@ -444,7 +444,7 @@ describe('MessageConversation — 읽기 화면의 같은 대화', () => {
|
||||
await rowOf(wrapper, 'm2').get('button').trigger('click')
|
||||
await flushPromises()
|
||||
const error = rowOf(wrapper, 'm2').get('[data-conversation-detail-error]')
|
||||
expect(error.text()).toContain('메일 서비스에 연결할 수 없습니다.')
|
||||
expect(error.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
// 읽지 못했으니 읽음으로 바꾸지 않는다
|
||||
expect(requests.some((request) => request.method === 'PATCH')).toBe(false)
|
||||
@@ -857,7 +857,7 @@ describe('MessageConversation — 읽기 화면의 같은 대화', () => {
|
||||
})
|
||||
|
||||
describe('펼친 줄의 본문 안내 · 그사이 지워진 메일', () => {
|
||||
it('서식을 뺐거나 잘린 본문이면 읽기 화면과 같은 안내 줄을 본문 위에 둔다', async () => {
|
||||
it('서식을 뺐거나 잘린 본문이면 읽기 화면과 같은 안내 줄을 본문 위에 둔다 — 앞 문장만(원문은 [이 메일만 열기]로 연 읽기 화면에서)', async () => {
|
||||
serve({ detail: () => ok(detail(REPLY, { isHtmlBodyOmitted: true, isBodyTruncated: true, textBody: '글자 본문' })) })
|
||||
const { wrapper } = await mountReader()
|
||||
|
||||
@@ -869,6 +869,8 @@ describe('MessageConversation — 읽기 화면의 같은 대화', () => {
|
||||
['htmlOmitted', MAIL_BODY_NOTICES.htmlOmitted],
|
||||
['truncated', MAIL_BODY_NOTICES.truncated],
|
||||
])
|
||||
// 이 화면의 ⋯ 는 위에 연 메일의 것이라 할 일(⋯ → 원문 내려받기)은 붙이지 않는다
|
||||
expect(row.text()).not.toContain(MAIL_BODY_NOTICE_ACTIONS.truncated)
|
||||
expect(row.find('iframe').exists()).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import MailContactDialogHost from '@/components/mail/MailContactDialogHost.vue'
|
||||
import { createMailContactDialog, MAIL_CONTACT_DIALOG } from '@/composables/useMailContactDialog'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
|
||||
import { MAIL_BODY_NOTICE_ACTIONS, MAIL_BODY_NOTICES } from '@/shared/constants/mail'
|
||||
import type { Contact } from '@/shared/types/contacts'
|
||||
import type { Mailbox, MailFilterRule, MailFilterSet, MessageDetail, MessagesPatch, MessageSummary } from '@/shared/types/mail'
|
||||
import type { PaginatedResult } from '@/shared/types/pagination'
|
||||
@@ -94,12 +94,12 @@ describe('MessageReader 도구 모음', () => {
|
||||
expect(linkHref(wrapper, '전달')).toBe('/mail/compose?forward=m1')
|
||||
})
|
||||
|
||||
it('목록 보기에서는 메일을 못 불러와도 앞쪽 이동 도구(목록으로 등)가 남고, 메일 동작 버튼은 숨긴다', async () => {
|
||||
it('목록 보기에서는 메일을 못 불러와도 앞쪽 이동 도구(목록으로 등)가 남고, 이 메일로 할 일 단추는 숨긴다', async () => {
|
||||
const store = useMailStore()
|
||||
store.selected = null
|
||||
store.selectedId = 'm1'
|
||||
store.selectedStatus = 'error'
|
||||
store.selectedError = { code: 'RES_001', message: '요청하신 정보나 페이지를 찾을 수 없습니다.' }
|
||||
store.selectedError = { code: 'RES_001', message: ERROR_CODE_MESSAGES.RES_001 }
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail/inbox/m1')
|
||||
await router.isReady()
|
||||
@@ -110,7 +110,7 @@ describe('MessageReader 도구 모음', () => {
|
||||
})
|
||||
|
||||
expect(wrapper.find('.test-nav').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('요청하신 정보나 페이지를 찾을 수 없습니다.')
|
||||
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.RES_001)
|
||||
expect(wrapper.findAll('.tool-button')).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -159,7 +159,10 @@ describe('MessageReader 본문 안내 (계약 2절 "읽기 한도")', () => {
|
||||
expect(notices(withText)).toEqual([{ kind: 'htmlOmitted', text: MAIL_BODY_NOTICES.htmlOmitted }])
|
||||
|
||||
const withoutText = await mountWith({ ...DETAIL, isHtmlBodyOmitted: true, textBody: null })
|
||||
expect(notices(withoutText)).toEqual([{ kind: 'unavailable', text: MAIL_BODY_NOTICES.unavailable }])
|
||||
// 읽기 화면은 앞 문장 뒤에 할 일(⋯ → 원문 내려받기)까지
|
||||
expect(notices(withoutText)).toEqual([
|
||||
{ kind: 'unavailable', text: `${MAIL_BODY_NOTICES.unavailable} ${MAIL_BODY_NOTICE_ACTIONS.unavailable}` },
|
||||
])
|
||||
})
|
||||
|
||||
it('생략 → 잘림 순서로, 외부 이미지 차단 안내 앞에 같은 모양으로 둔다', async () => {
|
||||
@@ -172,7 +175,7 @@ describe('MessageReader 본문 안내 (계약 2절 "읽기 한도")', () => {
|
||||
})
|
||||
|
||||
expect(notices(wrapper).map((notice) => notice.kind)).toEqual(['truncated', 'blockedImages'])
|
||||
expect(notices(wrapper)[0]?.text).toBe(MAIL_BODY_NOTICES.truncated)
|
||||
expect(notices(wrapper)[0]?.text).toBe(`${MAIL_BODY_NOTICES.truncated} ${MAIL_BODY_NOTICE_ACTIONS.truncated}`)
|
||||
|
||||
const both = await mountWith({ ...DETAIL, isHtmlBodyOmitted: true, isBodyTruncated: true, textBody: '긴 글' })
|
||||
expect(notices(both).map((notice) => notice.kind)).toEqual(['htmlOmitted', 'truncated'])
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { MAIL_IMAGES_TEXT } from '@/shared/constants/mail'
|
||||
import type { BlockedSender, Mailbox, MessageDetail, MessagesPatch, MessageSummary } from '@/shared/types/mail'
|
||||
import { formatScheduleTime } from '@/shared/utils/format'
|
||||
import type { MailListSource } from '@/shared/utils/mail-list-source'
|
||||
@@ -179,7 +180,7 @@ describe('MessageReader — 9단계', () => {
|
||||
extra: (request) => (request.url === '/messages/m1' ? ok(SHOWN) : undefined),
|
||||
})
|
||||
const notice = wrapper.get('[data-notice="blockedImages"]')
|
||||
expect(notice.text()).toContain('외부 이미지를 차단했습니다.')
|
||||
expect(notice.text()).toContain(MAIL_IMAGES_TEXT.blocked)
|
||||
const button = notice.get('[data-show-images]')
|
||||
expect(button.text()).toBe('이미지 보기')
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ onBeforeUnmount(() => {
|
||||
<div
|
||||
class="reader__toolbar"
|
||||
role="group"
|
||||
aria-label="메일 동작"
|
||||
aria-label="이 메일로 할 일"
|
||||
>
|
||||
<slot name="toolbar-start" />
|
||||
<template v-if="message">
|
||||
@@ -301,11 +301,11 @@ onBeforeUnmount(() => {
|
||||
<StateMessage
|
||||
v-else
|
||||
icon="mail"
|
||||
title="메일을 선택해 주세요"
|
||||
title="고른 메일이 없습니다"
|
||||
:hint="
|
||||
mailStore.search
|
||||
? '검색 결과에서 메일을 고르면 내용이 여기에 표시됩니다.'
|
||||
: '목록에서 메일을 고르면 내용이 여기에 표시됩니다.'
|
||||
? '읽을 메일을 검색 결과에서 골라 주세요.'
|
||||
: '읽을 메일을 목록에서 골라 주세요.'
|
||||
"
|
||||
fill
|
||||
/>
|
||||
|
||||
@@ -54,7 +54,8 @@ const senderBlockEntry = computed(() => {
|
||||
if (!junkId || !props.message.mailboxIds.includes(junkId)) return null
|
||||
return blockStore.blockOf(props.message.from?.email)
|
||||
})
|
||||
const bodyNotices = computed(() => mailBodyNotices(props.message))
|
||||
/** 읽기 화면은 할 일까지 — "… [⋯] → [원문 내려받기 (.eml)]로 받아 볼 수 있습니다." (이 화면의 ⋯ 는 이 메일의 것이다) */
|
||||
const bodyNotices = computed(() => mailBodyNotices(props.message, { withAction: true }))
|
||||
const imagesLoading = computed(() => mailStore.imagesLoadingId === props.message.id)
|
||||
|
||||
async function showImages() {
|
||||
|
||||
@@ -286,7 +286,7 @@ describe('RecipientField', () => {
|
||||
expect(options(wrapper)).toEqual(['이도윤 doyun.lee@arasystem.it.kr'])
|
||||
expect(activeOption(wrapper)).toBeNull()
|
||||
expect(input.attributes('aria-activedescendant')).toBeUndefined()
|
||||
expect(wrapper.get('#compose-to-suggestions-hint').text()).toBe('Enter 는 친 주소를 그대로 넣습니다')
|
||||
expect(wrapper.get('#compose-to-suggestions-hint').text()).toBe('Enter를 누르면 친 주소를 그대로 넣습니다')
|
||||
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
expect(tokensOf(wrapper).map((token) => token.label)).toEqual(['doyun.lee@partner.example'])
|
||||
|
||||
@@ -72,7 +72,7 @@ const emit = defineEmits<Emits>()
|
||||
const suggestions = inject(RECIPIENT_SUGGESTIONS, null)
|
||||
|
||||
const HINT_DEFAULT = '↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기'
|
||||
const HINT_TYPED_ADDRESS = 'Enter 는 친 주소를 그대로 넣습니다'
|
||||
const HINT_TYPED_ADDRESS = 'Enter를 누르면 친 주소를 그대로 넣습니다'
|
||||
|
||||
/** 아직 토큰이 되지 않은 입력 — v-model (한글 조합이 끝나야 바뀐다) */
|
||||
const draft = ref<string>('')
|
||||
@@ -354,7 +354,7 @@ defineExpose({ commit, focus, clearDraft, hasDraft })
|
||||
<span
|
||||
v-if="!token.valid"
|
||||
class="visually-hidden"
|
||||
>(주소 형식 오류)</span>
|
||||
>(주소가 올바르지 않음)</span>
|
||||
<button
|
||||
type="button"
|
||||
class="recipient-chip__remove"
|
||||
|
||||
@@ -117,7 +117,7 @@ function onAddressInput() {
|
||||
v-model="text"
|
||||
type="text"
|
||||
class="link-form__input"
|
||||
placeholder="비우면 주소를 그대로 보여 줍니다"
|
||||
placeholder="비워 두면 주소를 그대로 씁니다"
|
||||
autocomplete="off"
|
||||
@keydown.enter="onEnter"
|
||||
>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 앱 비밀번호를 만든 결과 창 — 모달 620(wide) · 처음 초점 [복사] (디자인 원본 SettingsAppPasswords "만든 결과 창" · 계약 5절 "앱 비밀번호")
|
||||
// "앱 비밀번호를 만들었습니다" / "‘회사 PC 아웃룩’ — 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요."
|
||||
// - 값은 고정폭으로 4자씩 떨어뜨려 보이지만 띄어쓰기는 없다(조각 사이를 여백으로 — 골라 복사해도 값 그대로) — [복사]는 값 그대로 복사한다.
|
||||
// "40자입니다. 보기 좋게 4자씩 띄웠지만 띄어쓰기는 없습니다." — 글자 수는 받은 값의 길이로 쓴다.
|
||||
// "40자입니다. 보기 편하게 4자씩 띄어 놓았지만, 실제로는 띄어쓰기 없이 넣으면 됩니다." — 글자 수는 받은 값의 길이로 쓴다.
|
||||
// 묶음은 줄바꿈 없는 inline-block 덩어리 — 넓으면 한 줄, 좁으면 묶음 사이에서만 줄이 바뀐다(묶음이 둘로 갈리지 않는다 — 2026-10-31 브라우저 점검)
|
||||
// - 경고 "이 창을 닫으면 다시 볼 수 없습니다. …" + 설정값 요약 + [닫기]
|
||||
// - 복사 「앱 비밀번호를 복사했습니다.」 / 못 하면 「앱 비밀번호를 복사하지 못했습니다. 값을 직접 골라 복사해 주세요.」
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// 「로그인된 브라우저」 + "웹메일 3곳" · 오른쪽 [다른 곳 모두 로그아웃](44 — 다른 곳이 없으면 그리지 않는다)
|
||||
// 표(구현은 실제 table): PC · 휴대폰 아이콘(16) · 브라우저 · 운영체제 / IP / 마지막 활동 / [로그아웃](36 — 밀도 예외).
|
||||
// 맨 위는 지금 이 브라우저 — 단추 대신 「지금 이 브라우저」 글자만. 모르는 브라우저는 "알 수 없는 브라우저", 모르는 IP 는 「—」.
|
||||
// 아래 "웹메일 로그인만 보입니다. 메일 프로그램은 앱 비밀번호를 지워 끊습니다."(링크 — 다른 곳이 없으면 앞에 "다른 곳에 로그인된 브라우저가 없습니다.")
|
||||
// 아래 "메일 프로그램은 이 목록에 없습니다. 연결을 끊으려면 앱 비밀번호를 지워 주세요."(앱 비밀번호는 링크 — 다른 곳이 없으면 앞에 "다른 곳에 로그인된 브라우저가 없습니다.")
|
||||
// - 줄의 [로그아웃]은 묻지 않고 바로 끊는다 → 「‘Edge · Windows’의 로그인을 끊었습니다.」(되돌리기 없음). 누른 줄이 사라지면 초점은 다음 줄(없으면 앞 줄 · 묶음 제목)
|
||||
// - [다른 곳 모두 로그아웃] → 확인 창(처음 초점 「취소」) → 「다른 곳의 로그인 2곳을 끊었습니다.」 — 지금 이 브라우저만 남고 초점은 묶음 제목으로
|
||||
// 지금 이 브라우저 줄이 없으면(서버가 이 세션을 알아보지 못함 — 서버는 모두 끊는다) 확인 창이 이 브라우저도 로그아웃된다고 알리고,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
// 고치기 창의 「받은편지함에 이미 있는 메일에도 적용」 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates) — 할 일 아래, 6단계의 안내 띠 자리
|
||||
// 고치기 창의 「받은편지함에 이미 있는 메일도 정리하기」 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates) — 할 일 아래, 6단계의 안내 띠 자리
|
||||
// 회색 상자(바탕 #F8F9FB) 하나: 체크 칸(이름 굵게) + 칸 아래 설명(12.5 — 칸의 aria-describedby) → 상자 아래 한 줄 "스팸으로 판정되어 …"
|
||||
// - 켤지는 부모가 정한다(창을 열 때마다 꺼짐 — 필터의 설정이 아니라 이번 저장에 한 번 쓴다)
|
||||
// - 고를 수 없으면(block — mail-filter-run.ts 의 filterApplyBlock) 칸을 disabled · 이름을 흐리게, 설명이 까닭을 말한다:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 필터 고치기 창 (디자인 원본 SettingsFilter "새 필터" · "필터 고치기" — 모달 620, 공용 모양 ModalDialog 의 넓은 창)
|
||||
// 이름(선택, 50자) → "어떤 메일에"(MailFilterConditionFields — 모두 · 하나라도 · 모든 메일 + 조건 줄 5개까지) →
|
||||
// 할 일(MailFilterActionFields) → 「받은편지함에 이미 있는 메일에도 적용」(MailFilterApplyField — 7단계, 6단계 안내 띠 자리) →
|
||||
// 할 일(MailFilterActionFields) → 「받은편지함에 이미 있는 메일도 정리하기」(MailFilterApplyField — 7단계, 6단계 안내 띠 자리) →
|
||||
// [이 필터 지우기](고칠 때만, 왼쪽) · [저장] [취소].
|
||||
// - 잘못은 [저장]을 누를 때 칸 아래 빨간 글로만 — 빈 조건 · `${` · 할 일 없음 · 전달 주소(빈 칸 · 형식 · 사내 주소가 아님 · 내 주소) ·
|
||||
// 이름 · 찾을 글의 길이 · 제어 문자(사전의 VAL_001).
|
||||
@@ -11,7 +11,7 @@
|
||||
// - 열 때마다 칸을 새로 채운다 — 새 필터는 빈 조건 한 줄(읽기 화면에서 왔으면 "보낸 사람 · 포함 · 그 주소"), 고치기는 그 규칙.
|
||||
// 처음 초점은 이름 칸, 읽기 화면에서 만든 새 필터는 할 일의 첫 칸 (디자인 원본 MailFilterStates)
|
||||
// - 보내는 · 다시 받는 동안(pending)과 다른 저장을 기다리는 동안(busy)은 [저장] · [이 필터 지우기]를 aria-disabled 로 막는다 — 누른 단추가 초점을 잃지 않게.
|
||||
// - 「받은편지함에 이미 있는 메일에도 적용」은 열 때마다 꺼져 있다(필터에 저장하지 않는다). 이 탭에서 다른 적용이 진행 중(applyBusy)이거나
|
||||
// - 「받은편지함에 이미 있는 메일도 정리하기」는 열 때마다 꺼져 있다(필터에 저장하지 않는다). 이 탭에서 다른 적용이 진행 중(applyBusy)이거나
|
||||
// 이미 받은 메일에 할 일이 없으면(옮길 메일함이 지워짐 · 받은편지함으로 옮기기만 · 전달만 — filterApplyBlock) 고를 수 없다.
|
||||
// 고른 채 [저장]하면 save 의 apply 가 true — 저장한 뒤의 적용 · 알림은 부모가 한다(useMailFilterActions)
|
||||
// - 나가기 확인(7단계 — 6단계 한계를 고친다, useDialogLeaveConfirm): 창을 연 때의 값(새 필터는 빈 값 · 읽기 화면에서 채운 값)과 다르면
|
||||
@@ -84,7 +84,7 @@ interface Props {
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
/** apply — 「받은편지함에 이미 있는 메일에도 적용」을 골랐다 (저장한 뒤 이번에 한 번) */
|
||||
/** apply — 「받은편지함에 이미 있는 메일도 정리하기」를 골랐다 (저장한 뒤 이번에 한 번) */
|
||||
(e: 'save', rule: MailFilterRule, apply: boolean): void
|
||||
(e: 'remove'): void
|
||||
(e: 'cancel'): void
|
||||
@@ -124,7 +124,7 @@ const problems = ref<MailFilterProblems>(noFilterProblems(draft.conditions.lengt
|
||||
let base = baseOf()
|
||||
/** 창을 연 때의 칸 값 — 이것과 다르면 바꾼 것이다 (나가기 확인) */
|
||||
const opened = shallowRef<MailFilterDraft>(copyFilterDraft(draft))
|
||||
/** 「받은편지함에 이미 있는 메일에도 적용」을 골랐다 — 열 때마다 꺼진다 */
|
||||
/** 「받은편지함에 이미 있는 메일도 정리하기」를 골랐다 — 열 때마다 꺼진다 */
|
||||
const applyChosen = ref<boolean>(false)
|
||||
|
||||
const applyBlock = computed(() => filterApplyBlock(draft, props.mailboxes, props.applyBusy))
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// 바탕화면 알림 켜기 · 알림에 보일 것 · 시험 알림 (디자인 원본 SettingsNotify · MailRealtime "막혔을 때 · 지원하지 않을 때")
|
||||
// 바탕화면 알림 켜기 · 알림 내용 · 시험 알림 (디자인 원본 SettingsNotify · MailRealtime "막혔을 때 · 지원하지 않을 때")
|
||||
// - [x] 새 메일이 오면 바탕화면 알림 보내기(44) — 켜면 브라우저가 허락을 묻는다(누른 뒤에만). 칸 아래 안내(12.5px):
|
||||
// 묻기 전(회색) · 허용됨(초록 + 체크) · 막힘(칸을 끄고 빨강 + 경고) · 지원하지 않음(칸을 끄고 회색) — notifyEnableState
|
||||
// 허락하지 않았으면(막음 · 그냥 닫음) 칸은 비운 채 돌아간다. 묻는 동안은 칸을 끄지 않고 aria-disabled 로만 막는다(초점이 그대로 남게).
|
||||
// 막혀서(또는 지원하지 않아서) 칸이 꺼지면 초점을 안내 글로 옮긴다 — 왜 꺼졌는지 바로 읽게
|
||||
// - 알림에 보일 것: [x] 메일 제목도 보이기 — 알림을 끄면 흐리게(누를 수 없다)
|
||||
// - 알림 내용: [x] 메일 제목도 보이기 — 알림을 끄면 흐리게(누를 수 없다)
|
||||
// - [시험 알림 보내기](보조 단추 44 · 종 아이콘) — 지금 탭에서 바로 "ARA 웹메일" / "알림이 잘 옵니다. …" (알림을 끄면 누를 수 없다).
|
||||
// 못 띄우면 조용히 넘어가지 않는다 — 만들 수 없는 브라우저(휴대폰 Chrome 등)면 "지원하지 않음", 허락이 사라졌으면 그 안내로 바꾸고 초점을 옮긴다
|
||||
// 고정 id — #notify-enabled · #notify-enabled-note · #notify-show-subject · #notify-test
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 알림의 "실시간 연결" (디자인 원본 SettingsNotify) — 이 탭이 보는 연결 상태 (stores/mail-realtime.store.ts)
|
||||
// ● 초록 "연결되어 있습니다 — 새 메일이 오면 바로 목록에 나타납니다." / 끊겼을 때 "연결할 수 없어 1분마다 확인합니다." (저절로 다시 잇는다)
|
||||
// ● 초록 "연결되어 있어 새 메일이 오면 바로 알 수 있습니다." / 끊겼을 때 "연결할 수 없어 1분마다 확인합니다." (저절로 다시 잇는다)
|
||||
// 끊긴 동안은 상태 줄 아래에 "연결될 때까지 바탕화면 알림은 오지 않습니다." (2026-10-28 화면 점검 반영)
|
||||
// 메일 화면에서는 끊겨도 알리지 않는다 — 여기서만 볼 수 있다 (디자인 README "5단계 › 실시간 반영")
|
||||
import { computed } from 'vue'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
// 부재중 자동 응답의 기간 (디자인 원본 SettingsVacation) — (•) 끌 때까지 계속 / ( ) 기간 정하기 → 시작 ~ 끝 날짜 칸(44)
|
||||
// "시작일 0시부터 끝나는 날 자정까지 답합니다 (이 브라우저의 시간대 기준)." 끈 동안은 묶음째 흐리게(쓴 값은 남긴다).
|
||||
// "시작하는 날 0시부터 끝나는 날 밤 12시까지 답합니다. 시각은 이 PC의 시간으로 셉니다." 끈 동안은 묶음째 흐리게(쓴 값은 남긴다).
|
||||
// 날짜는 <input type="date"> 값(YYYY-MM-DD) 그대로 주고받는다 — 시각으로 바꾸는 일은 mail-vacation.ts
|
||||
// 고정 id — #vacation-period-until-off · #vacation-period-range · #vacation-from · #vacation-to · #vacation-period-error
|
||||
import { ref } from 'vue'
|
||||
|
||||
@@ -26,8 +26,10 @@ import api, {
|
||||
|
||||
describe('에러 코드 사전', () => {
|
||||
it('등록된 코드는 해당 한국어 메시지를 반환한다', () => {
|
||||
expect(getErrorMessage('AUTH_001')).toBe(
|
||||
'로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
|
||||
// 문구는 사전에서만 온다 — 글을 적지 않고 사전을 본다 (문구를 고칠 때마다 이 시험을 고치지 않게)
|
||||
expect(getErrorMessage('AUTH_001')).toBe(ERROR_CODE_MESSAGES.AUTH_001)
|
||||
expect(getErrorMessage('AUTH_001')).not.toBe(
|
||||
ERROR_CODE_MESSAGES[FALLBACK_ERROR_CODE],
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -202,7 +202,7 @@ function handle(
|
||||
const code = toAuthCode(isErrorCode(rawCode) ? rawCode : FALLBACK_ERROR_CODE, sentWithSession)
|
||||
const message = getErrorMessage(code)
|
||||
// 로그아웃한 뒤에 돌아온, 로그인 중에 보낸 요청의 401 · 403 AUTH_010 — 사용자가 끝낸 세션이라 알리지 않는다
|
||||
// (로그인 화면에 "로그인이 필요한 서비스입니다" 가 뜨지 않게)
|
||||
// (로그인 화면에 "로그인이 필요합니다" 가 뜨지 않게)
|
||||
const endedBySignOut = (code === 'AUTH_001' || code === RESTRICTED_CODE) && sentWithSession && authStore.signedOut
|
||||
|
||||
if (notify && !endedBySignOut && !isSilenced(code, config?.silent)) showErrorUI(code, message, traceId)
|
||||
|
||||
@@ -339,7 +339,7 @@ describe('useLargeFileUploads', () => {
|
||||
['too-big.zip', 'rejected', '파일 하나는 0MB까지 보낼 수 있습니다.'],
|
||||
['first.zip', 'ready', null],
|
||||
['second.zip', 'rejected', '링크로 보내는 파일은 메일 한 통에 1개까지입니다.'],
|
||||
['setup.exe', 'rejected', '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.'],
|
||||
['setup.exe', 'rejected', ERROR_CODE_MESSAGES.FILE_001],
|
||||
])
|
||||
expect(lines().filter((line) => line === 'POST /file-uploads')).toHaveLength(1)
|
||||
expect(uploads.uploadIds.value).toEqual(['u1'])
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '@/stores/mail-filter.store'
|
||||
|
||||
// 메일 필터 설정 화면의 저장 · 지우기 알림 (디자인 README "6단계 › 상태 · 들어오는 길" · 디자인 원본 MailFilterStates "알림 문구")
|
||||
// - 고치기 창의 [저장]이 끝나면 "필터를 저장했습니다." — 「받은편지함에 이미 있는 메일에도 적용」을 골랐으면(7단계) 그 대신
|
||||
// - 고치기 창의 [저장]이 끝나면 "필터를 저장했습니다." — 「받은편지함에 이미 있는 메일도 정리하기」를 골랐으면(7단계) 그 대신
|
||||
// 저장한 그 규칙(응답 목록의 같은 id — 서버가 앞뒤 공백을 지운 값)으로 받은편지함에 적용하는 진행 알림 (useMailFilterRun)
|
||||
// - 지우기는 확인 없이 하고 "‘거래처 메일’ 필터를 지웠습니다." + [되돌리기](10초 — 단추가 있는 알림, 띄운 세션에서만 움직인다 — useToast).
|
||||
// 되돌리기는 지운 규칙을 지운 자리(앞 · 뒤 규칙 기준)에 다시 저장한다 → "필터를 되돌렸습니다." 알림 여럿의 [되돌리기]는 누른 차례대로 보낸다(스토어).
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
// - 켜기 · 끄기 · 순서 바꾸기는 알리지 않는다 — 목록이 바로 바뀐다(실패하면 스토어가 되돌리고 인터셉터가 알린다)
|
||||
|
||||
export interface MailFilterSaveOptions {
|
||||
/** 「받은편지함에 이미 있는 메일에도 적용」 — 저장한 뒤 이번에 한 번 (필터의 설정이 아니다) */
|
||||
/** 「받은편지함에 이미 있는 메일도 정리하기」 — 저장한 뒤 이번에 한 번 (필터의 설정이 아니다) */
|
||||
apply?: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useMailFilterStore } from '@/stores/mail-filter.store'
|
||||
// - 그사이 바뀜이면 창 안에 [다시 불러오기]를 둔다(conflict — 창이 모달이라 목록 위 줄을 누를 수 없다). 다시 받으면 창 · 쓴 내용은 그대로 두고 안내를 내린다 —
|
||||
// 다음 [저장]은 다시 받은 목록 · version 위에 넣는다(스토어의 saveRule). 칸을 고쳐도 이 안내는 남는다(다시 받아야 풀린다)
|
||||
// - 끝나면 초점을 줄 규칙을 돌려준다 — 저장은 그 규칙, 지우기는 이웃 행(다음, 마지막이었으면 앞). 초점은 화면이 옮긴다
|
||||
// - [저장]이 「받은편지함에 이미 있는 메일에도 적용」을 골랐으면(7단계 — options.apply) 저장한 뒤 적용한다(useMailFilterActions) —
|
||||
// - [저장]이 「받은편지함에 이미 있는 메일도 정리하기」를 골랐으면(7단계 — options.apply) 저장한 뒤 적용한다(useMailFilterActions) —
|
||||
// 창을 그사이 닫았어도 저장했으면 적용한다(고른 사람이 저장을 보냈다)
|
||||
|
||||
export type MailFilterDialogPending = 'save' | 'remove' | 'reload' | null
|
||||
|
||||
@@ -4,6 +4,7 @@ import { nextTick } from 'vue'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import { MAIL_FILTER_RUN_TEXT } from '@/shared/constants/mail-filter'
|
||||
import type { Mailbox, MailFilterRule, MailFilterRun, MailFilterRunRequest } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailFilterRunStore } from '@/stores/mail-filter-run.store'
|
||||
@@ -149,7 +150,7 @@ describe('useMailFilterRun', () => {
|
||||
['읽음 · 중요만 하는 필터', rule({ name: '결재 알림', actions: { ...NO_ACTIONS, markAsRead: true, flag: true } }), 12, '‘결재 알림’ 필터로 받은편지함의 메일 12통을 정리했습니다.'],
|
||||
['받은편지함으로 옮기며 읽음 표시하는 필터', rule({ actions: { ...NO_ACTIONS, moveToMailboxId: 'inbox', markAsRead: true } }), 3, '‘거래처 메일’ 필터로 받은편지함의 메일 3통을 정리했습니다.'],
|
||||
['지금 메일함 목록에 없는 메일함으로 옮기며 읽음 표시하는 필터', rule({ actions: { ...NO_ACTIONS, moveToMailboxId: 'gone', markAsRead: true } }), 3, '‘거래처 메일’ 필터로 받은편지함의 메일 3통을 정리했습니다.'],
|
||||
['맞는 메일이 없는 옮기는 필터', rule(), 0, '받은편지함에 ‘거래처 메일’ 필터에 맞는 메일이 없습니다.'],
|
||||
['걸리는 메일이 없는 옮기는 필터', rule(), 0, MAIL_FILTER_RUN_TEXT.none('‘거래처 메일’')],
|
||||
])('%s — 단추 없이 끝난다', async (_label, target, matched, message) => {
|
||||
replies(step(8412, matched, 0, null))
|
||||
|
||||
|
||||
@@ -15,11 +15,11 @@ import { useMailStore } from '@/stores/mail.store'
|
||||
import type { ToastAction } from '@/stores/toast.store'
|
||||
|
||||
// 이미 받은 메일에 필터 적용 — 진행 · 결과 알림 (디자인 README "7단계 › 저장한 뒤 — 진행 · 결과 알림" · 디자인 원본 MailFilterApply · MailFilterApplyStates)
|
||||
// - 고치기 창의 [저장]이 성공했고 「받은편지함에 이미 있는 메일에도 적용」을 골랐으면 "필터를 저장했습니다." 대신 진행 알림
|
||||
// - 고치기 창의 [저장]이 성공했고 「받은편지함에 이미 있는 메일도 정리하기」를 골랐으면 "필터를 저장했습니다." 대신 진행 알림
|
||||
// "‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인"(첫 응답 전의 전체는 받은편지함의 메일 수 — 모르면 수를 뺀다)이
|
||||
// 남았다가 끝나면 그 자리에서 결과로 바뀐다 (4단계 오래 걸리는 처리 — useMailboxActions 의 runOperation 과 같은 결)
|
||||
// - 끝: "‘…’ 필터를 받은편지함의 메일 N통에 적용했습니다." + 옮기는 필터이고 1통 이상이면 [{메일함} 보기](filterRunTarget — 받은편지함 ·
|
||||
// 지금 메일함 목록에 없는 메일함이면 단추 없음). 맞는 메일이 없으면 "받은편지함에 ‘…’ 필터에 맞는 메일이 없습니다."
|
||||
// 지금 메일함 목록에 없는 메일함이면 단추 없음). 걸리는 메일이 없으면 "받은편지함에는 ‘…’ 필터에 걸리는 메일이 없습니다."
|
||||
// - 멈춤(이어 할 자리가 있다): "‘…’ 필터 — {전체}통 가운데 {확인}통까지 확인하고 멈췄습니다(N통에 적용). 다시 시도해 주세요." +
|
||||
// [다시 시도](그 cursor 로 이어서 — 수는 이어 센다)
|
||||
// - 처음 요청부터 실패: "필터는 저장했지만 받은편지함에 적용하지 못했습니다. 다시 시도해 주세요." + [다시 시도](처음부터)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import { PASSWORD_CHANGE_TIMEOUT_MS } from '@/shared/constants/account'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { RESTRICTED_SECRET_WINDOW_MS } from '@/shared/constants/session-restriction'
|
||||
import { RESTRICTED_SECRET_WINDOW_MS, SUPPORT_CONTACT_TEXT } from '@/shared/constants/session-restriction'
|
||||
import type { TwoFactorEnrollment, TwoFactorStatus } from '@/shared/types/account-security'
|
||||
import type { SessionRestriction } from '@/shared/types/auth'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
@@ -602,7 +602,7 @@ describe('LoginPage — 제한된 세션 (13단계)', () => {
|
||||
it('정하지 않았거나 받지 못하면 지금 글 그대로 — 알리지 않는다', async () => {
|
||||
const { wrapper } = await mountLogin({ supportContact: () => fail(503, 'SYS_001') })
|
||||
|
||||
expect(wrapper.get('[data-support-line]').text()).toBe('비밀번호를 잊으셨다면 시스템 관리자에게 문의해 주세요.')
|
||||
expect(wrapper.get('[data-support-line]').text()).toBe(SUPPORT_CONTACT_TEXT.passwordFallback)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -376,7 +376,7 @@ void supportContactStore.ensureLoaded()
|
||||
사내 업무를<br>한 계정으로
|
||||
</p>
|
||||
<p class="login__lead">
|
||||
메일부터 시작해 캘린더와 파일 저장소까지 같은 계정으로 이어집니다.
|
||||
메일부터 시작해 캘린더와 파일 저장소까지 같은 계정으로 쓸 수 있게 됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
role="alert"
|
||||
>
|
||||
<h1>404</h1>
|
||||
<p>요청하신 정보나 페이지를 찾을 수 없습니다.</p>
|
||||
<p>찾는 페이지가 없습니다. 주소가 맞는지 확인해 주세요.</p>
|
||||
<RouterLink to="/">
|
||||
홈으로 돌아가기
|
||||
</RouterLink>
|
||||
|
||||
@@ -5,7 +5,12 @@ import { h, type FunctionalComponent } from 'vue'
|
||||
import { createMemoryHistory, createRouter, RouterView, type Router } from 'vue-router'
|
||||
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import { CONTACTS_SEARCH_DELAY_MS } from '@/shared/constants/contacts'
|
||||
import {
|
||||
CONTACT_DETAIL_TEXT,
|
||||
CONTACTS_EMPTY_TEXT,
|
||||
CONTACTS_LIST_TEXT,
|
||||
CONTACTS_SEARCH_DELAY_MS,
|
||||
} from '@/shared/constants/contacts'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type {
|
||||
Contact,
|
||||
@@ -387,7 +392,7 @@ describe('ContactsPage (주소록 모듈)', () => {
|
||||
const row = app.get('[data-contacts-row="c2"]')
|
||||
expect(row.get('.contacts-row__meta').text()).toBe('(주)한빛상사 · 영업팀')
|
||||
expect(row.get('.contacts-row__email').text()).toBe('jihoon.park@hanbit.example')
|
||||
expect(app.get('[data-contact-none]').text()).toContain('연락처를 골라 주세요')
|
||||
expect(app.get('[data-contact-none]').text()).toContain(CONTACT_DETAIL_TEXT.noneTitle)
|
||||
|
||||
await click(row.get('a'))
|
||||
expect(router.currentRoute.value.fullPath).toBe('/contacts/all/c2')
|
||||
@@ -624,11 +629,11 @@ describe('ContactsPage (주소록 모듈)', () => {
|
||||
const { app } = await mountApp('/contacts/directory/people/s5')
|
||||
|
||||
expect(app.get('#contacts-list-title').text()).toBe('직원')
|
||||
expect(app.get('.contacts-list__note').text()).toBe('회사 메일을 쓰는 직원이라 이름과 메일 주소만 보입니다.')
|
||||
expect(app.get('.contacts-list__note').text()).toBe(CONTACTS_LIST_TEXT.directoryNote.person)
|
||||
const detail = app.get('[data-directory-detail="s5"]')
|
||||
expect(detail.get('.contact-detail__subtitle').text()).toBe('사내 직원')
|
||||
expect(detail.findAll('.tool-button').map((button) => button.text())).toEqual(['메일 쓰기', '내 주소록에 추가'])
|
||||
expect(detail.get('.contact-detail__hint').text()).toContain('사내 목록은 이름과 주소만 보이고 여기서 고칠 수 없습니다')
|
||||
expect(detail.get('.contact-detail__hint').text()).toContain(CONTACT_DETAIL_TEXT.directoryHint)
|
||||
|
||||
await click(detail.get('[data-contact-add-mine]'))
|
||||
expect(isOpen(app, '#contact-dialog')).toBe(true)
|
||||
@@ -720,7 +725,7 @@ describe('ContactsPage (주소록 모듈)', () => {
|
||||
clock.advance(CONTACTS_SEARCH_DELAY_MS)
|
||||
await flushPromises()
|
||||
const empty = app.get('[data-contacts-empty="search"]')
|
||||
expect(empty.text()).toContain('‘없는사람’에 맞는 연락처가 없습니다')
|
||||
expect(empty.text()).toContain(CONTACTS_EMPTY_TEXT.searchTitle('없는사람', false))
|
||||
expect(empty.text()).toContain('이름, 주소, 회사, 전화번호의 일부나 이름의 초성으로 찾을 수 있습니다.')
|
||||
|
||||
// Esc 는 찾을 글을 지운다 — 목록이 다시 그룹 전체로
|
||||
|
||||
@@ -1195,7 +1195,7 @@ watch(hasMissingLarge, (missing) => {
|
||||
v-model="to"
|
||||
label="받는 사람"
|
||||
input-id="compose-to"
|
||||
placeholder="메일 주소 입력 후 Enter"
|
||||
placeholder="메일 주소를 쓰고 Enter를 눌러 주세요"
|
||||
:error-id="RECIPIENT_ERROR_ID"
|
||||
:rejected="rejectedByServer !== null"
|
||||
:disabled="preparing"
|
||||
|
||||
@@ -3,6 +3,8 @@ import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { SEARCH_NOTICE_TEXT } from '@/shared/constants/mail-search'
|
||||
import type { Mailbox, MessageDetail, MessageListItem, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
@@ -128,7 +130,7 @@ describe('MailboxPage — 검색 결과', () => {
|
||||
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
|
||||
const push = vi.spyOn(router, 'push')
|
||||
|
||||
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
|
||||
expect(wrapper.text()).toContain(MAIL_SEARCH_TEXT.emptyTitle('회의'))
|
||||
// 걸린 조건이 없으면 조건을 줄이라고 하지 않는다 (2026-11-06 실사용 시험)
|
||||
expect(wrapper.text()).toContain('다른 말로 찾아 보세요.')
|
||||
expect(wrapper.text()).not.toContain('조건을 줄여')
|
||||
@@ -335,7 +337,7 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
|
||||
url === '/search-index' ? indexState('ready') : ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=inbox&${CONDITIONS_QUERY}`)
|
||||
|
||||
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
|
||||
expect(wrapper.text()).toContain(MAIL_SEARCH_TEXT.emptyTitle('회의'))
|
||||
expect(wrapper.text()).toContain('다른 말로 찾거나 조건을 줄여 보세요.')
|
||||
})
|
||||
|
||||
@@ -402,9 +404,7 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
|
||||
expect(row.get('[data-row-preview]').text()).toBe('지난번 말씀하신 계약서 초안을 첨부했습니다.')
|
||||
const notice = wrapper.get('[data-search-notice="indexing"]')
|
||||
// 색인은 밤에만 채운다 — 낮 내내 같은 글이어도 멈춘 줄 알지 않게 (2026-11-06 실사용 시험)
|
||||
expect(notice.text()).toBe(
|
||||
'지난 메일을 검색할 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말이 통째로 같은 메일만 찾습니다.',
|
||||
)
|
||||
expect(notice.text()).toBe(SEARCH_NOTICE_TEXT.indexing)
|
||||
expect(notice.element.closest('[role="status"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
@@ -416,7 +416,7 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
|
||||
expect(wrapper.get('[data-search-word-limit]').text()).toBe(
|
||||
'준비가 끝나기 전에는 ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.',
|
||||
)
|
||||
expect(wrapper.get('[data-search-word-limit]').text()).not.toContain('통째로')
|
||||
expect(wrapper.get('[data-search-word-limit]').text()).not.toContain('끝까지 다 써야')
|
||||
})
|
||||
|
||||
it('색인 상태를 못 받으면 안내 줄 · 한계 안내 없이 지금처럼 — 알리지 않는다', async () => {
|
||||
@@ -514,9 +514,7 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
|
||||
expect(listRequests()).toEqual([])
|
||||
const empty = wrapper.get('[data-search-too-many-words]')
|
||||
expect(empty.text()).toContain('찾을 말이 너무 많습니다')
|
||||
expect(empty.text()).toContain(
|
||||
'검색어와 조건에 넣은 낱말은 모두 합쳐 8개까지 찾을 수 있습니다. 검색 칸이나 [조건]에서 낱말을 줄여 주세요.',
|
||||
)
|
||||
expect(empty.text()).toContain(SEARCH_NOTICE_TEXT.tooManyWordsHint(8))
|
||||
// 다른 메일함에서 찾아도 같다 — [전체 메일함에서 찾기]를 권하지 않는다. 조건은 조건 줄에서 지울 수 있다
|
||||
expect(wrapper.find('.search-empty__all').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-search-condition-line-clear]').exists()).toBe(true)
|
||||
@@ -561,9 +559,9 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
|
||||
}
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
|
||||
expect(wrapper.text()).toContain('아직 맞는 메일을 찾지 못했습니다')
|
||||
expect(wrapper.text()).toContain(SEARCH_NOTICE_TEXT.partialTitle)
|
||||
expect(wrapper.text()).toContain('메일이 많아 한 번에 다 찾지 못했습니다. [더 찾기]를 누르면 이어서 찾습니다.')
|
||||
expect(wrapper.text()).not.toContain('에 맞는 메일이 없습니다')
|
||||
expect(wrapper.text()).not.toContain(MAIL_SEARCH_TEXT.emptyTitle('회의'))
|
||||
const sent = listRequests().length
|
||||
|
||||
exhausted = true
|
||||
@@ -572,7 +570,7 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
|
||||
|
||||
expect(listRequests().length).toBe(sent + 1)
|
||||
expect(wrapper.find('[data-search-more]').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
|
||||
expect(wrapper.text()).toContain(MAIL_SEARCH_TEXT.emptyTitle('회의'))
|
||||
})
|
||||
|
||||
it('결과에서 연 메일은 검색어와 글 조건 넷만 넘겨 강조를 받는다 — 제목 · 첨부 이름 · 글 본문의 맞은 말', async () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import { APP_PASSWORD_RESULT_TEXT } from '@/shared/constants/account-security'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { AppPassword, CreatedAppPassword } from '@/shared/types/account-security'
|
||||
import { formatBlockedDay, formatRelativeDayTime } from '@/shared/utils/format'
|
||||
@@ -252,7 +253,7 @@ describe('AccountAppPasswordsPage', () => {
|
||||
'uk0n',
|
||||
])
|
||||
expect(wrapper.get('[data-app-password-value]').text()).toBe(VALUE)
|
||||
expect(wrapper.get('.result__length').text()).toBe('40자입니다. 보기 좋게 4자씩 띄웠지만 띄어쓰기는 없습니다.')
|
||||
expect(wrapper.get('.result__length').text()).toBe(APP_PASSWORD_RESULT_TEXT.lengthNote('40'))
|
||||
expect(document.activeElement?.hasAttribute('data-app-password-copy')).toBe(true)
|
||||
expect(wrapper.get('.result__warning').text()).toContain('이 창을 닫으면 다시 볼 수 없습니다.')
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import { LOGIN_HISTORY_TEXT } from '@/shared/constants/account-security'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { AuthSession, LoginEvent } from '@/shared/types/account-security'
|
||||
import { formatRelativeDayTime } from '@/shared/utils/format'
|
||||
@@ -16,6 +17,8 @@ import AccountLoginHistoryPage from './AccountLoginHistoryPage.vue'
|
||||
// 설정 › 계정 › 로그인 기록 (12단계 — 디자인 원본 SettingsLoginHistory · 계약 5절 "로그인된 브라우저" · "로그인 기록")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
/** 로그인된 브라우저 아래 안내 — 가운데 [앱 비밀번호]가 링크다 */
|
||||
const SESSIONS_NOTE = `${LOGIN_HISTORY_TEXT.sessionsNoteBefore}${LOGIN_HISTORY_TEXT.sessionsNoteLink}${LOGIN_HISTORY_TEXT.sessionsNoteAfter}`
|
||||
/**
|
||||
* 2026년 그 시각을 이 PC 의 시간대(지역 시각)로 — 화면은 시각을 브라우저 시간대로 보인다.
|
||||
* "오늘 오전 9:05" 같은 글을 그대로 보려면 시각을 지역 시각으로 적어야 어느 시간대에서도 같다
|
||||
@@ -193,7 +196,7 @@ describe('AccountLoginHistoryPage', () => {
|
||||
])
|
||||
expect(wrapper.get('[data-sessions-end-others]').text()).toBe('다른 곳 모두 로그아웃')
|
||||
const note = wrapper.get('[data-sessions-note]')
|
||||
expect(note.text()).toBe('웹메일 로그인만 보입니다. 메일 프로그램의 연결을 끊으려면 앱 비밀번호를 지워 주세요.')
|
||||
expect(note.text()).toBe(SESSIONS_NOTE)
|
||||
expect(note.get('a').attributes('href')).toBe('/settings/account/app-passwords')
|
||||
})
|
||||
|
||||
@@ -253,9 +256,7 @@ describe('AccountLoginHistoryPage', () => {
|
||||
expect(dialog?.hasAttribute('open')).toBe(false)
|
||||
expect(sessionRows(wrapper).map((row) => row[0])).toEqual(['Chrome · Windows'])
|
||||
expect(wrapper.find('[data-sessions-end-others]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-sessions-note]').text()).toBe(
|
||||
'다른 곳에 로그인된 브라우저가 없습니다. 웹메일 로그인만 보입니다. 메일 프로그램의 연결을 끊으려면 앱 비밀번호를 지워 주세요.',
|
||||
)
|
||||
expect(wrapper.get('[data-sessions-note]').text()).toBe(`${LOGIN_HISTORY_TEXT.noOthers}${SESSIONS_NOTE}`)
|
||||
expect(document.activeElement?.id).toBe('login-sessions-title')
|
||||
})
|
||||
|
||||
@@ -398,7 +399,7 @@ describe('AccountLoginHistoryPage', () => {
|
||||
},
|
||||
})
|
||||
expect(wrapper.get('[data-events-more]').text()).toBe('더 보기')
|
||||
expect(wrapper.text()).toContain('최근 순서로 20개씩 보입니다. 90일이 지난 기록은 지워집니다.')
|
||||
expect(wrapper.text()).toContain(LOGIN_HISTORY_TEXT.moreHint)
|
||||
|
||||
await wrapper.get('[data-events-filter="failures"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { MAIL_CLIENTS_TEXT } from '@/shared/constants/mail-clients'
|
||||
import type { TwoFactorStatus } from '@/shared/types/account-security'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
@@ -91,9 +92,7 @@ describe('AccountMailClientsPage', () => {
|
||||
it('프로그램별 차례 — 새 Outlook 주의 줄 · 다섯 프로그램, 처음에는 모두 접힘', async () => {
|
||||
const wrapper = await mountPage()
|
||||
|
||||
expect(wrapper.get('[data-mail-clients-cloud-warning]').text()).toBe(
|
||||
'새 Outlook과 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다. 회사 정책에 따라 쓰지 않는 것이 좋습니다.',
|
||||
)
|
||||
expect(wrapper.get('[data-mail-clients-cloud-warning]').text()).toBe(MAIL_CLIENTS_TEXT.cloudWarning)
|
||||
const buttons = wrapper.findAll('[data-mail-client] h3 button')
|
||||
expect(buttons.map((button) => button.text())).toEqual([
|
||||
expect.stringContaining('아웃룩 (Windows)'),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { MAIL_BLOCK_TEXT } from '@/shared/constants/mail-block'
|
||||
import type { BlockedSender } from '@/shared/types/mail'
|
||||
import { formatBlockedDay } from '@/shared/utils/format'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
@@ -247,7 +248,7 @@ describe('MailBlocksPage', () => {
|
||||
})
|
||||
|
||||
describe('엔진에서 우리 규칙이 꺼져 있으면(isActive: false) 머리 아래 노란 줄 — 단추 없음', () => {
|
||||
const INACTIVE = '다른 곳에서 메일 설정이 바뀌어 지금은 차단이 되지 않습니다. 주소를 차단하거나 풀면 다시 켜집니다.'
|
||||
const INACTIVE = MAIL_BLOCK_TEXT.inactive
|
||||
|
||||
it('차단한 주소가 있을 때만 보인다(role=status) — 켜져 있거나 비었으면 없다', async () => {
|
||||
const off = await mountPage({ isActive: false })
|
||||
|
||||
@@ -6,6 +6,7 @@ import { RouterView } from 'vue-router'
|
||||
|
||||
import MessageReader from '@/components/mail/MessageReader.vue'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { MAIL_FILTER_APPLY_TEXT, MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
|
||||
import type {
|
||||
Mailbox,
|
||||
MailFilterRule,
|
||||
@@ -246,16 +247,16 @@ describe('MailFilterPage', () => {
|
||||
expect(row(wrapper, 'r5').get('[data-filter-down]').attributes('aria-disabled')).toBe('true')
|
||||
expect(wrapper.get('[data-filter-count]').text()).toBe('필터 5개를 만들었고, 50개까지 만들 수 있습니다. 줄을 누르면 고칠 수 있습니다.')
|
||||
expect(wrapper.get('[data-filter-notes]').findAll('p').map((note) => note.text())).toEqual([
|
||||
'·위에 있는 필터부터 차례로 메일을 정리합니다. 메일함으로 옮기는 필터에 맞은 메일은 그 아래 필터를 거치지 않습니다. 순서는 ▲ ▼ 로 바꿀 수 있습니다.',
|
||||
'·이미 받은 메일도 정리하려면 필터를 저장할 때 [받은편지함에 이미 있는 메일에도 적용]을 골라 주세요. 받은편지함에 있는 메일만 그 필터 하나로 정리합니다.',
|
||||
'·스팸으로 분류된 메일은 필터를 거치지 않고 그대로 스팸메일함으로 갑니다.',
|
||||
'·전달은 새로 오는 메일만 사내 주소로 합니다. 메일 한 통은 한 곳으로만 전달되고, 전달하는 필터가 여럿이면 위에 있는 필터가 먼저입니다. 전달해도 내 메일함에 그대로 남습니다.',
|
||||
'·필터가 메일을 옮겨도 부재중 자동 응답은 그대로 보냅니다.',
|
||||
`·${MAIL_FILTER_TEXT.noteOrderStrong}${MAIL_FILTER_TEXT.noteOrderAfter}`,
|
||||
`·${MAIL_FILTER_TEXT.noteApplyBefore}${MAIL_FILTER_TEXT.noteApplyStrong}${MAIL_FILTER_TEXT.noteApplyAfter}`,
|
||||
`·${MAIL_FILTER_TEXT.noteSpam}`,
|
||||
`·${MAIL_FILTER_TEXT.noteForward}`,
|
||||
`·${MAIL_FILTER_TEXT.noteVacation}`,
|
||||
])
|
||||
expect(wrapper.get('[data-filter-note-apply] strong').text()).toBe('[받은편지함에 이미 있는 메일에도 적용]')
|
||||
expect(wrapper.get('.settings-page__description').text()).toBe(
|
||||
'받은 메일을 조건에 따라 메일함으로 옮기거나, 읽음이나 중요로 표시하거나, 동료에게 전달합니다. 새로 오는 메일을 정리하고, 저장할 때 고르면 받은편지함에 이미 있는 메일도 함께 정리합니다.',
|
||||
)
|
||||
// 안내 상자의 [ ]는 고치기 창의 칸 이름 그대로다 — 칸 이름을 바꾸면 함께 바뀐다
|
||||
expect(wrapper.get('[data-filter-note-apply] strong').text()).toBe(`[${MAIL_FILTER_APPLY_TEXT.label}]`)
|
||||
expect(MAIL_FILTER_TEXT.noteApplyStrong).toBe(`[${MAIL_FILTER_APPLY_TEXT.label}]`)
|
||||
expect(wrapper.get('.settings-page__description').text()).toBe(MAIL_FILTER_TEXT.description)
|
||||
expect(wrapper.find('[data-filter-empty]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-filter-conflict]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-filter-inactive]').exists()).toBe(false)
|
||||
@@ -542,7 +543,7 @@ describe('MailFilterPage', () => {
|
||||
expect(wrapper.get('#mail-filter-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_012)
|
||||
const bar = wrapper.get('[data-filter-conflict]')
|
||||
expect(bar.attributes('role')).toBe('alert')
|
||||
expect(bar.text()).toContain('다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.')
|
||||
expect(bar.text()).toContain(ERROR_CODE_MESSAGES.MAIL_012)
|
||||
expect(toasts()).toEqual([])
|
||||
|
||||
filters = { rules: [RULES[2] as MailFilterRule], version: 'v9', isActive: true }
|
||||
@@ -721,7 +722,7 @@ describe('MailFilterPage', () => {
|
||||
const { wrapper } = await mountPage({ filters: { ...SET, isActive: false } })
|
||||
const bar = wrapper.get('[data-filter-inactive]')
|
||||
|
||||
expect(bar.text()).toContain('다른 곳에서 메일 설정이 바뀌어 지금은 필터와 부재중 자동 응답이 되지 않습니다.')
|
||||
expect(bar.text()).toContain(MAIL_FILTER_TEXT.inactive)
|
||||
await click(bar.get('button'))
|
||||
|
||||
expect(puts()).toEqual([{ rules: RULES, version: 'v1' }])
|
||||
@@ -814,7 +815,7 @@ describe('MailFilterPage', () => {
|
||||
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/filters')
|
||||
})
|
||||
|
||||
describe('받은편지함에 이미 있는 메일에도 적용 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates)', () => {
|
||||
describe('받은편지함에 이미 있는 메일도 정리하기 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates)', () => {
|
||||
const applyBox = (wrapper: VueWrapper) => wrapper.get('#mail-filter-apply')
|
||||
const applyInput = (wrapper: VueWrapper) => applyBox(wrapper).element as HTMLInputElement
|
||||
const applyHelp = (wrapper: VueWrapper) => wrapper.get('#mail-filter-apply-help').text()
|
||||
@@ -829,9 +830,9 @@ describe('MailFilterPage', () => {
|
||||
expect(applyInput(wrapper).checked).toBe(false)
|
||||
expect(applyBox(wrapper).attributes('disabled')).toBeUndefined()
|
||||
expect(applyBox(wrapper).attributes('aria-describedby')).toBe('mail-filter-apply-help')
|
||||
expect(wrapper.get('[data-filter-apply] label').text()).toBe('받은편지함에 이미 있는 메일에도 적용')
|
||||
expect(wrapper.get('[data-filter-apply] label').text()).toBe(MAIL_FILTER_APPLY_TEXT.label)
|
||||
expect(applyHelp(wrapper)).toBe(HELP)
|
||||
expect(wrapper.get('[data-filter-apply-spam]').text()).toBe('스팸으로 분류된 메일은 필터를 거치지 않습니다.')
|
||||
expect(wrapper.get('[data-filter-apply-spam]').text()).toBe(MAIL_FILTER_APPLY_TEXT.spam)
|
||||
// 6단계의 안내 띠는 없다
|
||||
expect(wrapper.text()).not.toContain('새로 오는 메일에만 적용됩니다')
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// - 읽기 화면 ⋯ 「이런 메일로 필터 만들기」는 ?from=<보낸 사람 주소> 로 온다 — 받은 뒤 새 필터 창을 그 주소로 채워 열고(50개면 열지 않는다)
|
||||
// 주소에서 지운다(replace — 새로 고침에 또 열리지 않게)
|
||||
// - 누른 단추가 사라져 초점을 잃으면(지운 행 · 사라진 줄 · 빈 상태의 [새 필터]) 가까운 행이나 머리의 [새 필터]로 옮긴다
|
||||
// - 창의 「받은편지함에 이미 있는 메일에도 적용」(7단계)을 고른 채 저장하면 저장한 뒤 받은편지함에 적용한다(useMailFilterActions → useMailFilterRun).
|
||||
// - 창의 「받은편지함에 이미 있는 메일도 정리하기」(7단계)를 고른 채 저장하면 저장한 뒤 받은편지함에 적용한다(useMailFilterActions → useMailFilterRun).
|
||||
// 이 탭에서 적용하는 중이면 창의 그 칸을 끈다(mail-filter-run.store 의 running — 화면을 떠나도 적용은 이어진다)
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { LARGE_FILE_SETTINGS_TEXT } from '@/shared/constants/large-file'
|
||||
import type { FileQuota, LargeFile, ShareLink } from '@/shared/types/large-file'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useLargeFilesStore } from '@/stores/large-files.store'
|
||||
@@ -198,9 +199,7 @@ describe('MailLargeFilesPage', () => {
|
||||
|
||||
await view.get('[data-unsent-delete]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(view.get('#large-delete-message').text()).toBe(
|
||||
'‘시공도면_v4.dwg’(620 MB) 파일을 지웁니다. 이 파일을 실은 임시 저장본을 열면 첨부 칸에 지운 파일로 보입니다. 지운 파일은 되돌릴 수 없습니다.',
|
||||
)
|
||||
expect(view.get('#large-delete-message').text()).toBe(LARGE_FILE_SETTINGS_TEXT.deleteMessage('시공도면_v4.dwg', '620 MB'))
|
||||
expect(document.activeElement?.hasAttribute('data-delete-cancel')).toBe(true)
|
||||
|
||||
await view.get('[data-delete-confirm]').trigger('click')
|
||||
@@ -283,7 +282,7 @@ describe('MailLargeFilesPage', () => {
|
||||
it('목록을 받지 못하면 사전 문구와 "다시 시도" — 알림은 띄우지 않는다', async () => {
|
||||
const { wrapper: view } = await mountPage({ failLinks: true })
|
||||
|
||||
expect(view.text()).toContain('메일 서비스에 연결할 수 없습니다.')
|
||||
expect(view.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
expect(useLargeFilesStore().status).toBe('error')
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { MAIL_NOTIFY_TEXT } from '@/shared/constants/mail-notify'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailNotifyStore } from '@/stores/mail-notify.store'
|
||||
import { useMailRealtimeStore } from '@/stores/mail-realtime.store'
|
||||
@@ -48,15 +49,15 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
|
||||
return wrapper.get('#notify-enabled').element as HTMLInputElement
|
||||
}
|
||||
|
||||
it('제목 · 설명 — 새 메일은 1분을 기다리지 않고 바로 목록에 나타난다', async () => {
|
||||
it('제목 · 설명 — 새 메일이 오면 바로 목록에서 볼 수 있다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
|
||||
expect(wrapper.get('h1').text()).toBe('알림')
|
||||
expect(wrapper.get('main').attributes('aria-labelledby')).toBe('notify-page-title')
|
||||
expect(wrapper.text()).toContain('새 메일은 1분을 기다리지 않고 바로 목록에 나타납니다.')
|
||||
expect(wrapper.text()).toContain(MAIL_NOTIFY_TEXT.description)
|
||||
})
|
||||
|
||||
it('묻기 전 — 칸은 비었고 "켜면 브라우저가 … [허용]을 눌러 주세요.", 보일 것 · 시험 알림은 흐리게', async () => {
|
||||
it('묻기 전 — 칸은 비었고 "켜면 브라우저가 … [허용]을 눌러 주세요.", 알림 내용 · 시험 알림은 흐리게', async () => {
|
||||
const wrapper = await mountPage()
|
||||
|
||||
expect(enableInput(wrapper).checked).toBe(false)
|
||||
@@ -132,7 +133,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
|
||||
expect(document.activeElement).toBe(enableInput(wrapper))
|
||||
})
|
||||
|
||||
it('시험 알림을 만들 수 없는 브라우저(휴대폰 Chrome 등)면 조용히 넘어가지 않고 "지원하지 않습니다"로 바꾼다 — 초점은 안내 글로', async () => {
|
||||
it('시험 알림을 만들 수 없는 브라우저(휴대폰 Chrome 등)면 조용히 넘어가지 않고 "쓸 수 없습니다"로 바꾼다 — 초점은 안내 글로', async () => {
|
||||
FakeNotification.reset('granted')
|
||||
const wrapper = await mountPage()
|
||||
await wrapper.get('#notify-enabled').setValue(true)
|
||||
@@ -143,7 +144,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
|
||||
await flushPromises()
|
||||
|
||||
const note = wrapper.get('#notify-enabled-note')
|
||||
expect(note.text()).toBe('이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다.')
|
||||
expect(note.text()).toBe(MAIL_NOTIFY_TEXT.noteUnsupported)
|
||||
expect(enableInput(wrapper).disabled).toBe(true)
|
||||
expect(wrapper.get('#notify-test').attributes('disabled')).toBeDefined()
|
||||
expect(document.activeElement).toBe(note.element)
|
||||
@@ -166,7 +167,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
|
||||
expect(enableInput(wrapper).checked).toBe(true)
|
||||
})
|
||||
|
||||
it('끄기 — 체크를 풀면 꺼지고 보일 것 · 시험 알림이 흐려진다 (허락은 그대로)', async () => {
|
||||
it('끄기 — 체크를 풀면 꺼지고 알림 내용 · 시험 알림이 흐려진다 (허락은 그대로)', async () => {
|
||||
FakeNotification.reset('granted')
|
||||
const wrapper = await mountPage()
|
||||
await wrapper.get('#notify-enabled').setValue(true)
|
||||
@@ -182,17 +183,15 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
|
||||
expect(wrapper.get('#notify-enabled-note').text()).toBe('이 브라우저에서 알림이 허용되어 있습니다.')
|
||||
})
|
||||
|
||||
it('지원하지 않는 브라우저 — 칸을 끄고 "지원하지 않습니다. 새 메일은 그래도 바로 목록에"', async () => {
|
||||
it('지원하지 않는 브라우저 — 칸을 끄고 "쓸 수 없습니다. 새 메일은 그래도 목록에서 바로"', async () => {
|
||||
vi.stubGlobal('Notification', undefined)
|
||||
const wrapper = await mountPage()
|
||||
|
||||
expect(enableInput(wrapper).disabled).toBe(true)
|
||||
expect(wrapper.get('#notify-enabled-note').text()).toBe(
|
||||
'이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다.',
|
||||
)
|
||||
expect(wrapper.get('#notify-enabled-note').text()).toBe(MAIL_NOTIFY_TEXT.noteUnsupported)
|
||||
})
|
||||
|
||||
it('메일 제목도 보이기 — 끄면 저장한다 ("끄면 보낸 사람 이름만 보입니다")', async () => {
|
||||
it('메일 제목도 보이기 — 끄면 저장한다 ("끄면 알림에 보낸 사람 이름만 나옵니다")', async () => {
|
||||
FakeNotification.reset('granted')
|
||||
const wrapper = await mountPage()
|
||||
await wrapper.get('#notify-enabled').setValue(true)
|
||||
@@ -200,7 +199,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
|
||||
|
||||
const subject = wrapper.get('#notify-show-subject')
|
||||
expect((subject.element as HTMLInputElement).checked).toBe(true)
|
||||
expect(wrapper.text()).toContain('끄면 보낸 사람 이름만 보입니다. 다른 사람과 화면을 함께 보는 자리라면 꺼 두는 것이 좋습니다.')
|
||||
expect(wrapper.text()).toContain(MAIL_NOTIFY_TEXT.showSubjectHint)
|
||||
await subject.setValue(false)
|
||||
|
||||
expect(useMailNotifyStore().settings.showSubject).toBe(false)
|
||||
@@ -213,7 +212,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
|
||||
const status = () => wrapper.get('#notify-realtime-status')
|
||||
|
||||
expect(wrapper.get('h2').text()).toBe('실시간 연결')
|
||||
expect(status().text()).toBe('연결되어 있어 새 메일이 오면 바로 목록에 나타납니다.')
|
||||
expect(status().text()).toBe(MAIL_NOTIFY_TEXT.realtimeLive)
|
||||
expect(status().find('.realtime-link__dot').classes()).toContain('is-live')
|
||||
|
||||
expect(wrapper.find('#notify-realtime-notice').exists()).toBe(false)
|
||||
@@ -240,7 +239,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
|
||||
expect(notes.findAll('p').map((note) => note.findAll('span').at(-1)?.text())).toEqual([
|
||||
'알림 설정은 이 브라우저에만 저장됩니다. 다른 PC나 브라우저에서는 그곳에서 따로 켜 주세요.',
|
||||
'웹메일 탭이 하나라도 열려 있어야 알림이 옵니다. 탭을 모두 닫으면 오지 않습니다.',
|
||||
'웹메일 화면을 보고 있을 때는 새 메일이 목록에 바로 보이므로 바탕화면 알림을 보내지 않습니다.',
|
||||
MAIL_NOTIFY_TEXT.noteVisible,
|
||||
])
|
||||
expect(notes.get('strong').text()).toBe('이 브라우저에만')
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 메일 › 알림 (디자인 원본 SettingsNotify · README "5단계 › 설정 › 메일 › 알림") — /settings/mail/notify
|
||||
// 위에서부터 바탕화면 알림 켜기 · 알림에 보일 것 · 시험 알림(NotifySettingsForm) — 가로줄 — 실시간 연결(RealtimeLinkStatus) — 안내 상자(바탕 #F3F8F8):
|
||||
// 위에서부터 바탕화면 알림 켜기 · 알림 내용 · 시험 알림(NotifySettingsForm) — 가로줄 — 실시간 연결(RealtimeLinkStatus) — 안내 상자(바탕 #F3F8F8):
|
||||
// 이 브라우저에만 적용 · 웹메일 탭이 하나라도 열려 있어야 · 웹메일 화면을 보고 있을 때는 보내지 않음.
|
||||
// 설정은 이 브라우저 · 사용자마다 저장한다(mail-notify.store). 들어올 때 · 탭으로 돌아올 때 브라우저의 허락을 다시 읽는다 — 사이트 설정에서 바꿨을 수 있다.
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { MAIL_SETTINGS_TEXT } from '@/shared/constants/mail-settings'
|
||||
import type { MailSettings } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
@@ -86,7 +87,7 @@ describe('MailSendReadPage', () => {
|
||||
'늘 보기',
|
||||
])
|
||||
expect(checkedImages(wrapper)).toEqual(['trusted'])
|
||||
expect(wrapper.get('[data-images-note]').text()).toContain('외부 이미지는 회사를 거쳐 불러오므로 보낸 사람은 이 PC의 주소를 알 수 없습니다.')
|
||||
expect(wrapper.get('[data-images-note]').text()).toContain(MAIL_SETTINGS_TEXT.imagesNote)
|
||||
expect(wrapper.get('[data-settings-status]').text()).toBe('')
|
||||
})
|
||||
|
||||
@@ -128,9 +129,7 @@ describe('MailSendReadPage', () => {
|
||||
['끔', 'true'],
|
||||
['켬', 'false'],
|
||||
])
|
||||
expect(wrapper.get('#send-read-group-hint').text()).toBe(
|
||||
'답장과 전달로 이어진 메일을 메일함 목록에서 한 줄로 묶어 보여 줍니다. 메일함 목록 위의 말풍선 모양 [대화로 묶기] 단추로도 켜고 끌 수 있습니다.',
|
||||
)
|
||||
expect(wrapper.get('#send-read-group-hint').text()).toBe(MAIL_SETTINGS_TEXT.groupHint)
|
||||
|
||||
await wrapper.get('[data-group-by-conversation="true"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { VACATION_TEXT } from '@/shared/constants/mail-vacation'
|
||||
import type { MailVacation, MailVacationState } from '@/shared/types/mail'
|
||||
import { formatDate } from '@/shared/utils/format'
|
||||
import { endOfDayIso, startOfDayIso } from '@/shared/utils/mail-vacation'
|
||||
@@ -111,9 +112,7 @@ describe('MailVacationPage', () => {
|
||||
expect((wrapper.get('#vacation-period-range').element as HTMLInputElement).checked).toBe(true)
|
||||
expect(value(wrapper, '#vacation-from')).toBe('2026-10-30')
|
||||
expect(value(wrapper, '#vacation-to')).toBe('2026-11-03')
|
||||
expect(wrapper.get('#vacation-period-hint').text()).toBe(
|
||||
'시작일 0시부터 끝나는 날 자정까지 답합니다. 시각은 이 브라우저의 시간대를 따릅니다.',
|
||||
)
|
||||
expect(wrapper.get('#vacation-period-hint').text()).toBe(VACATION_TEXT.rangeHint)
|
||||
expect(value(wrapper, '#vacation-subject')).toBe(SAVED.subject)
|
||||
const preview = wrapper.get('[data-vacation-preview]')
|
||||
expect(preview.text()).toContain('김아라 <ara.kim@arasystem.it.kr>')
|
||||
|
||||
@@ -112,7 +112,7 @@ export const TWO_FACTOR_ENABLE_TEXT = {
|
||||
/** 앱에 저장될 이름 — 서버의 otpauthUrl 과 같은 이름(계약 5절 "정한 것" 1) */
|
||||
savedAs: (email: string) => `앱에는 ‘ARA SYSTEM (${email})’로 저장됩니다.`,
|
||||
/** 켜지 않고 닫으면 그 키는 버린다 — 앱에 먼저 저장한 항목은 지워 달라고 안내한다 (README "켜기 창") */
|
||||
discardNote: '켜지 않고 닫으면 이 키는 더 쓸 수 없습니다. 앱에 먼저 저장했다면 그 항목을 지워 주세요.',
|
||||
discardNote: '켜지 않고 닫으면 이 키는 더 쓸 수 없습니다. 인증 앱에 먼저 추가했다면 앱에서 그 계정을 지워 주세요.',
|
||||
loading: 'QR 코드를 만드는 중…',
|
||||
codeLabel: '앱에 표시된 6자리 코드',
|
||||
passwordLabel: '지금 비밀번호',
|
||||
@@ -190,7 +190,7 @@ export const APP_PASSWORD_RESULT_TEXT = {
|
||||
message: (name: string) => `‘${name}’ 앱 비밀번호입니다. 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요.`,
|
||||
copy: '복사',
|
||||
copyLabel: '앱 비밀번호 복사',
|
||||
lengthNote: (length: string) => `${length}자입니다. 보기 좋게 4자씩 띄웠지만 띄어쓰기는 없습니다.`,
|
||||
lengthNote: (length: string) => `${length}자입니다. 보기 편하게 4자씩 띄어 놓았지만, 실제로는 띄어쓰기 없이 넣으면 됩니다.`,
|
||||
warningStrong: '이 창을 닫으면 다시 볼 수 없습니다.',
|
||||
warningAfter: ' 잊어버리면 지우고 새로 만들어 주세요.',
|
||||
copied: '앱 비밀번호를 복사했습니다.',
|
||||
@@ -235,7 +235,7 @@ export const LOGIN_HISTORY_TEXT = {
|
||||
unknownBrowser: '알 수 없는 브라우저',
|
||||
unknownValue: '알 수 없음',
|
||||
noOthers: '다른 곳에 로그인된 브라우저가 없습니다. ',
|
||||
sessionsNoteBefore: '웹메일 로그인만 보입니다. 메일 프로그램의 연결을 끊으려면 ',
|
||||
sessionsNoteBefore: '메일 프로그램은 이 목록에 없습니다. 연결을 끊으려면 ',
|
||||
sessionsNoteLink: '앱 비밀번호',
|
||||
sessionsNoteAfter: '를 지워 주세요.',
|
||||
sessionEnded: (device: string) => `‘${device}’에서 로그아웃했습니다.`,
|
||||
@@ -246,7 +246,7 @@ export const LOGIN_HISTORY_TEXT = {
|
||||
eventsHintAll: '최근 90일 · 웹메일과 메일 프로그램',
|
||||
eventsHintFailures: (count: string) => `최근 90일 · 실패 ${count}번`,
|
||||
eventsHintFailuresUnknown: '최근 90일 · 실패만',
|
||||
filterLabel: '보일 기록',
|
||||
filterLabel: '볼 기록',
|
||||
filterAll: '전체',
|
||||
filterFailures: '실패만',
|
||||
columnTime: '시각',
|
||||
@@ -263,7 +263,7 @@ export const LOGIN_HISTORY_TEXT = {
|
||||
failureWrongCode: '코드가 틀려 실패',
|
||||
failureAppPasswordRequired: '앱 비밀번호가 필요해 실패',
|
||||
more: '더 보기',
|
||||
moreHint: '최근 순서로 20개씩 보입니다. 90일이 지난 기록은 지워집니다.',
|
||||
moreHint: '최근 기록부터 20개씩 볼 수 있습니다. 90일이 지난 기록은 지워집니다.',
|
||||
emptyFailuresTitle: '최근 90일 동안 실패한 로그인이 없습니다',
|
||||
emptyFailuresHint: '[전체]를 누르면 성공한 로그인도 볼 수 있습니다.',
|
||||
emptyAllTitle: '최근 90일 동안 로그인 기록이 없습니다',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// 한도는 서버와 같은 값이다 — 서버가 최종 검증한다. 문구는 README "화면 문구 — 사용자에게 말하듯"(2026-11-05)을 따른다 — 보드의 옛 글과 다를 수 있다.
|
||||
|
||||
import type { ContactEmailLabel, ContactPhoneLabel, DirectoryKind } from '@/shared/types/contacts'
|
||||
import { directionParticle } from '@/shared/utils/korean'
|
||||
|
||||
/**
|
||||
* 주소록 라우트 이름 — 라우트 표(router/routes.ts) · 레일(app-modules.ts) · 화면 안의 링크 · 읽기 화면 ⋯ 「주소록에서 보기」가 함께 쓴다.
|
||||
@@ -126,7 +127,7 @@ export const CONTACTS_LIST_TEXT = {
|
||||
count: (count: string, shared: boolean) => `${count}${shared ? '개' : '명'}`,
|
||||
found: (count: string, shared: boolean) => `찾은 결과 ${count}${shared ? '개' : '명'}`,
|
||||
directoryNote: {
|
||||
person: '회사 메일을 쓰는 직원이라 이름과 메일 주소만 보입니다.',
|
||||
person: '회사 메일을 쓰는 직원입니다. 사내 목록에는 이름과 메일 주소만 있습니다.',
|
||||
shared: '여러 사람이 함께 받는 사내 주소입니다.',
|
||||
} satisfies Record<DirectoryKind, string>,
|
||||
addMembers: '구성원 넣기',
|
||||
@@ -141,7 +142,8 @@ export const CONTACTS_LIST_TEXT = {
|
||||
|
||||
/** 목록의 빈 상태 · 못 받음 (디자인 원본 Contacts · ContactsEdit "상태") */
|
||||
export const CONTACTS_EMPTY_TEXT = {
|
||||
searchTitle: (query: string, directory: boolean) => `‘${query}’에 맞는 ${directory ? '주소가' : '연락처가'} 없습니다`,
|
||||
searchTitle: (query: string, directory: boolean) =>
|
||||
`‘${query}’${directionParticle(query)} 찾은 ${directory ? '주소가' : '연락처가'} 없습니다`,
|
||||
searchHint: '이름, 주소, 회사, 전화번호의 일부나 이름의 초성으로 찾을 수 있습니다.',
|
||||
directorySearchHint: '이름이나 주소의 일부, 이름의 초성으로 찾을 수 있습니다.',
|
||||
firstTitle: '아직 연락처가 없습니다',
|
||||
@@ -164,7 +166,7 @@ export const CONTACT_DETAIL_TEXT = {
|
||||
more: '더 보기',
|
||||
menu: '연락처 메뉴',
|
||||
/** 상세 머리 줄(단추 묶음)의 이름 */
|
||||
actions: '연락처 동작',
|
||||
actions: '이 연락처로 할 일',
|
||||
addToMine: '내 주소록에 추가',
|
||||
noOrg: '소속 없음',
|
||||
noName: '(이름 없음)',
|
||||
@@ -183,9 +185,9 @@ export const CONTACT_DETAIL_TEXT = {
|
||||
/** 그룹 단추의 이름 — 누르면 그 그룹의 목록 */
|
||||
openGroup: (name: string) => `‘${name}’ 그룹 보기`,
|
||||
directoryHint:
|
||||
'사내 목록은 이름과 주소만 보이고 여기서 고칠 수 없습니다. 전화번호나 메모를 적어 두려면 내 주소록에 추가해 주세요.',
|
||||
noneTitle: '연락처를 골라 주세요',
|
||||
noneHint: '목록에서 고르면 주소와 전화번호가 여기에 보입니다.',
|
||||
'사내 목록에는 이름과 주소만 있고, 여기서는 고칠 수 없습니다. 전화번호나 메모를 적어 두려면 내 주소록에 추가해 주세요.',
|
||||
noneTitle: '고른 연락처가 없습니다',
|
||||
noneHint: '자세히 볼 연락처를 목록에서 골라 주세요.',
|
||||
leaveToList: '목록으로',
|
||||
loading: '연락처 불러오는 중',
|
||||
} as const
|
||||
@@ -274,7 +276,7 @@ export const CONTACT_MEMBERS_TEXT = {
|
||||
staff: '사내 직원',
|
||||
search: '구성원으로 넣을 연락처 찾기',
|
||||
searchPlaceholder: '이름, 주소, 초성으로 찾기',
|
||||
note: '이미 이 그룹에 있는 연락처는 보이지 않습니다. [사내 직원]에서 고르면 그 직원의 이름과 주소를 내 주소록에도 추가합니다.',
|
||||
note: '이 그룹에 이미 있는 사람은 목록에 없습니다. [사내 직원]에서 고른 사람은 이름과 주소가 내 주소록에도 함께 들어갑니다.',
|
||||
picked: (count: string) => `${count}명 고름`,
|
||||
pickMax: '한 번에 100명까지 넣을 수 있습니다.',
|
||||
add: '넣기',
|
||||
@@ -283,7 +285,7 @@ export const CONTACT_MEMBERS_TEXT = {
|
||||
loading: '불러오는 중',
|
||||
emptyMine: '넣을 수 있는 연락처가 없습니다',
|
||||
emptyStaff: '넣을 수 있는 직원이 없습니다',
|
||||
searchEmpty: (query: string) => `‘${query}’에 맞는 사람이 없습니다`,
|
||||
searchEmpty: (query: string) => `‘${query}’${directionParticle(query)} 찾은 사람이 없습니다`,
|
||||
} as const
|
||||
|
||||
/** 알림 (디자인 원본 ContactsEdit "알림" — 단추가 있으면 10초) */
|
||||
|
||||
@@ -176,7 +176,7 @@ export const MESSAGE_LARGE_FILE_TEXT = {
|
||||
downloads: (count: string) => `${count}회 받음`,
|
||||
notDownloaded: '아직 받지 않음',
|
||||
scanning: '보안 검사가 끝나면 받을 수 있습니다',
|
||||
infected: '위험한 내용이 있어 보안 검사에서 막았고, 받을 수 없습니다',
|
||||
infected: '보안 검사에서 위험한 내용이 나와 받을 수 없습니다',
|
||||
unscannable: '암호가 걸린 압축 파일처럼 검사하지 못한 파일',
|
||||
expired: (date: string) => `받을 수 있는 기간이 ${date}에 끝났습니다`,
|
||||
revoked: '보낸 사람이 링크를 끊어 받을 수 없습니다',
|
||||
@@ -225,7 +225,7 @@ export const LARGE_FILE_ALERT_TEXT = {
|
||||
export const LARGE_FILE_SETTINGS_TEXT = {
|
||||
title: '대용량 첨부',
|
||||
description: (days: string) =>
|
||||
`메일에 링크로 보낸 큰 파일입니다. 받는 사람은 보낸 날부터 ${days}일 동안 내려받고, 링크를 끊으면 바로 받을 수 없습니다.`,
|
||||
`메일에 링크로 보낸 큰 파일입니다. 받는 사람은 보낸 날부터 ${days}일 동안 내려받을 수 있고, 링크를 끊으면 그때부터 받을 수 없습니다.`,
|
||||
usageLabel: '보관 사용량',
|
||||
usageSplit: (sent: string, unsent: string) => `보낸 링크 ${sent}, 임시 저장했거나 올리다 멈춘 파일 ${unsent}입니다.`,
|
||||
usageNote: '기간이 지나거나, 링크를 끊거나, 보내지 않은 파일을 지우면 바로 줄어듭니다.',
|
||||
@@ -244,7 +244,7 @@ export const LARGE_FILE_SETTINGS_TEXT = {
|
||||
deleteLabel: (name: string) => `${name} 지우기`,
|
||||
deleteTitle: '보내지 않은 파일을 지울까요?',
|
||||
deleteMessage: (name: string, size: string) =>
|
||||
`‘${name}’(${size}) 파일을 지웁니다. 이 파일을 실은 임시 저장본을 열면 첨부 칸에 지운 파일로 보입니다. 지운 파일은 되돌릴 수 없습니다.`,
|
||||
`‘${name}’(${size}) 파일을 지웁니다. 이 파일을 넣어 둔 임시 저장본이 있으면 그 메일에서 파일을 빼고 다시 올려야 합니다. 지운 파일은 되돌릴 수 없습니다.`,
|
||||
deleteConfirm: '지우기',
|
||||
deleteCancel: '취소',
|
||||
deleted: (name: string) => `‘${name}’ 파일을 지웠습니다.`,
|
||||
@@ -272,7 +272,7 @@ export const LARGE_FILE_SETTINGS_TEXT = {
|
||||
notDownloaded: '아직 받지 않음',
|
||||
lastDownloaded: (when: string) => `마지막 ${when}`,
|
||||
more: '더 보기',
|
||||
moreHint: '최근에 보낸 순서로 20개씩 보입니다. 끝난 링크는 90일 뒤 목록에서 빠집니다.',
|
||||
moreHint: '최근에 보낸 것부터 20개씩 볼 수 있습니다. 끝난 링크는 90일 뒤 목록에서 빠집니다.',
|
||||
emptyTitle: '링크로 보낸 파일이 없습니다',
|
||||
/** max 는 늘 "20MB" 꼴(formatMegabytes — 받침이 없다)이라 조사를 '가'로 붙여 쓴다 */
|
||||
emptyHint: (max: string, days: string) =>
|
||||
@@ -326,7 +326,7 @@ export const SHARE_PAGE_TEXT = {
|
||||
revokedMessage: (sender: string) => `필요하면 보낸 사람(${sender})에게 문의해 주세요.`,
|
||||
revokedEnglish: 'The sender has stopped sharing these files.',
|
||||
notFoundTitle: '찾을 수 없는 링크입니다',
|
||||
notFoundMessage: '메일의 주소가 끝까지 복사되었는지 확인해 주세요.',
|
||||
notFoundMessage: '메일에 있던 주소를 끝까지 다 복사했는지 확인해 주세요.',
|
||||
notFoundEnglish: 'Link not found. Please check that the full address was copied.',
|
||||
tooManyTitle: '요청이 너무 많습니다',
|
||||
tooManyMessage: '잠시 뒤 다시 시도해 주세요.',
|
||||
|
||||
@@ -108,5 +108,5 @@ export const MAIL_BLOCK_TEXT = {
|
||||
* 머리 아래 노란 줄 — 차단한 주소가 있는데 엔진에서 우리 규칙이 꺼져 있을 때(isActive: false). 단추는 없다 — 차단하거나 풀면 다시 켜진다
|
||||
* (보드에 없다 — 7단계 필터 · 부재중 자동 응답의 노란 줄과 같은 결)
|
||||
*/
|
||||
inactive: '다른 곳에서 메일 설정이 바뀌어 지금은 차단이 되지 않습니다. 주소를 차단하거나 풀면 다시 켜집니다.',
|
||||
inactive: '다른 곳에서 메일 설정이 바뀌어 지금은 차단이 꺼져 있습니다. 주소를 차단하거나 풀면 차단이 다시 켜집니다.',
|
||||
} as const
|
||||
|
||||
@@ -35,10 +35,10 @@ export const MAIL_CLIENTS_TEXT = {
|
||||
'지금은 웹메일 비밀번호로도 연결되지만, 2단계 인증을 켜면 다시 연결할 때 막힙니다. 앱 비밀번호는 기기마다 따로 지울 수 있어 잃어버렸을 때도 안전합니다.',
|
||||
createAppPassword: '앱 비밀번호 만들기',
|
||||
stepsTitle: '프로그램별 연결 방법',
|
||||
stepsHint: '쓰는 프로그램을 누르면 연결 방법이 펼쳐집니다.',
|
||||
stepsHint: '쓰는 프로그램을 누르면 연결 방법을 볼 수 있습니다.',
|
||||
/** 권하지 않음으로 정했다(2026-10-31) — 회사가 허용하면 이 줄을 지운다 */
|
||||
cloudWarning:
|
||||
'새 Outlook과 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다. 회사 정책에 따라 쓰지 않는 것이 좋습니다.',
|
||||
'새 Outlook과 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다. 회사 정책에 따라 되도록 다른 메일 프로그램을 써 주세요.',
|
||||
listLabel: '메일 프로그램',
|
||||
notes: [
|
||||
'연결이 안 되면 사용자 이름에 메일 주소 전체를 넣었는지, 비밀번호 칸에 앱 비밀번호 40자를 띄어쓰기 없이 넣었는지 확인해 주세요.',
|
||||
@@ -114,7 +114,7 @@ export const MAIL_CLIENT_GUIDES: readonly MailClientGuide[] = [
|
||||
{
|
||||
id: 'android',
|
||||
name: '안드로이드',
|
||||
description: 'Gmail 앱 기준이며, 삼성 이메일 앱 같은 다른 메일 앱도 같은 값을 넣습니다',
|
||||
description: 'Gmail 앱으로 설명하며, 삼성 이메일 앱 같은 다른 메일 앱도 같은 값을 넣으면 됩니다',
|
||||
icon: 'smartphone',
|
||||
steps: [
|
||||
['Gmail 앱에서 프로필 사진 → [다른 계정 추가] → [기타]를 누릅니다.'],
|
||||
|
||||
@@ -84,7 +84,7 @@ export const MAIL_FILTER_SUMMARY_TEXT = {
|
||||
export const MAIL_FILTER_TEXT = {
|
||||
title: '필터',
|
||||
description:
|
||||
'받은 메일을 조건에 따라 메일함으로 옮기거나, 읽음이나 중요로 표시하거나, 동료에게 전달합니다. 새로 오는 메일을 정리하고, 저장할 때 고르면 받은편지함에 이미 있는 메일도 함께 정리합니다.',
|
||||
'새로 오는 메일을 조건에 따라 메일함으로 옮기거나, 읽음이나 중요로 표시하거나, 동료에게 전달합니다. 저장할 때 고르면 받은편지함에 이미 있는 메일도 함께 정리할 수 있습니다.',
|
||||
create: '새 필터',
|
||||
/**
|
||||
* 다 만들었을 때 [새 필터] 옆 · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」 아래 ·
|
||||
@@ -105,17 +105,17 @@ export const MAIL_FILTER_TEXT = {
|
||||
'보낸 사람, 받는 사람, 제목, 본문의 글로 메일을 골라 메일함으로 옮기거나, 읽음이나 중요로 표시하거나, 동료에게 전달합니다.',
|
||||
// 목록 위 노란 줄 — 다른 곳에서 바뀜(409 MAIL_012 — 글은 에러 코드 사전) · 동작하지 않음(isActive: false)
|
||||
reload: '다시 불러오기',
|
||||
inactive: '다른 곳에서 메일 설정이 바뀌어 지금은 필터와 부재중 자동 응답이 되지 않습니다.',
|
||||
inactive: '다른 곳에서 메일 설정이 바뀌어 지금은 필터와 부재중 자동 응답이 꺼져 있습니다.',
|
||||
reactivate: '다시 켜기',
|
||||
// 아래 안내 상자 — 첫 줄의 "위에서부터 차례로" · 둘째 줄의 칸 이름은 굵게 (7단계 — 디자인 원본 MailFilterApply 의 글)
|
||||
noteOrderStrong: '위에 있는 필터부터 차례로',
|
||||
noteOrderAfter: ' 메일을 정리합니다. 메일함으로 옮기는 필터에 맞은 메일은 그 아래 필터를 거치지 않습니다. 순서는 ▲ ▼ 로 바꿀 수 있습니다.',
|
||||
noteOrderAfter: ' 메일을 정리합니다. 메일함으로 옮기는 필터에 걸린 메일은 그 아래 필터를 거치지 않습니다. 순서는 ▲ ▼로 바꿀 수 있습니다.',
|
||||
noteApplyBefore: '이미 받은 메일도 정리하려면 필터를 저장할 때 ',
|
||||
noteApplyStrong: '[받은편지함에 이미 있는 메일에도 적용]',
|
||||
noteApplyAfter: '을 골라 주세요. 받은편지함에 있는 메일만 그 필터 하나로 정리합니다.',
|
||||
noteSpam: '스팸으로 분류된 메일은 필터를 거치지 않고 그대로 스팸메일함으로 갑니다.',
|
||||
noteApplyStrong: '[받은편지함에 이미 있는 메일도 정리하기]',
|
||||
noteApplyAfter: '를 골라 주세요. 받은편지함에 있는 메일만 그 필터 하나로 정리합니다.',
|
||||
noteSpam: '스팸 메일은 필터를 거치지 않고 바로 스팸메일함으로 갑니다.',
|
||||
noteForward:
|
||||
'전달은 새로 오는 메일만 사내 주소로 합니다. 메일 한 통은 한 곳으로만 전달되고, 전달하는 필터가 여럿이면 위에 있는 필터가 먼저입니다. 전달해도 내 메일함에 그대로 남습니다.',
|
||||
'전달은 새로 오는 메일만 사내 주소로 보냅니다. 메일 한 통은 한 곳으로만 전달할 수 있어서, 전달하는 필터가 여럿이면 가장 위에 있는 필터의 주소로 갑니다. 전달한 메일도 내 메일함에 그대로 남습니다.',
|
||||
noteVacation: '필터가 메일을 옮겨도 부재중 자동 응답은 그대로 보냅니다.',
|
||||
// 알림 — 켜기 · 끄기 · 순서 바꾸기는 목록이 바로 바뀌므로 알리지 않는다 (실패하면 되돌리고 오류 알림)
|
||||
saved: '필터를 저장했습니다.',
|
||||
@@ -167,12 +167,12 @@ export const MAIL_FILTER_DIALOG_TEXT = {
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 고치기 창의 「받은편지함에 이미 있는 메일에도 적용」 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates).
|
||||
* 고치기 창의 「받은편지함에 이미 있는 메일도 정리하기」 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates).
|
||||
* 6단계의 안내 띠("새로 오는 메일에만 적용됩니다 …") 자리에 둔다. 고를 수 없으면 설명이 그 까닭이다 —
|
||||
* 이 탭에서 다른 필터를 적용하는 중 → 옮길 메일함이 지워짐 → 받은편지함으로 옮기기만 → 전달만 (mail-filter-run.ts 의 filterApplyBlock 차례)
|
||||
*/
|
||||
export const MAIL_FILTER_APPLY_TEXT = {
|
||||
label: '받은편지함에 이미 있는 메일에도 적용',
|
||||
label: '받은편지함에 이미 있는 메일도 정리하기',
|
||||
help: '저장하면 받은편지함에서 조건에 맞는 메일을 찾아 바로 정리합니다.',
|
||||
/** 전달도 하는 필터 — 설명 뒤에 더한다 */
|
||||
helpForward: ' 전달은 새로 오는 메일에만 합니다.',
|
||||
@@ -183,7 +183,7 @@ export const MAIL_FILTER_APPLY_TEXT = {
|
||||
inboxOnly: '받은편지함으로 옮기기만 하는 필터는 이미 받은 메일에 할 일이 없습니다.',
|
||||
busy: '다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤에 고를 수 있습니다.',
|
||||
/** 상자 아래 한 줄 */
|
||||
spam: '스팸으로 분류된 메일은 필터를 거치지 않습니다.',
|
||||
spam: '스팸 메일은 필터를 거치지 않습니다.',
|
||||
} as const
|
||||
|
||||
/**
|
||||
@@ -196,7 +196,7 @@ export const MAIL_FILTER_RUN_TEXT = {
|
||||
progressCount: (scanned: string, total: string) => ` · ${scanned} / ${total}통 확인`,
|
||||
done: (quoted: string, matched: string) => `${quoted} 필터로 받은편지함의 메일 ${matched}통을 정리했습니다.`,
|
||||
/** 맞는 메일이 없었다 */
|
||||
none: (quoted: string) => `받은편지함에 ${quoted} 필터에 맞는 메일이 없습니다.`,
|
||||
none: (quoted: string) => `받은편지함에는 ${quoted} 필터에 걸리는 메일이 없습니다.`,
|
||||
/** 옮기는 필터의 끝 알림 단추 — 그 메일함으로 */
|
||||
viewMailbox: (mailbox: string) => `${mailbox} 보기`,
|
||||
/** 도중에 멈췄다 — [다시 시도]는 남은 메일부터 이어서 */
|
||||
|
||||
@@ -53,20 +53,20 @@ export const MAIL_NOTIFY_TEXT = {
|
||||
menu: '알림',
|
||||
title: '알림',
|
||||
description:
|
||||
'새 메일은 1분을 기다리지 않고 바로 목록에 나타납니다. 다른 창을 보는 동안에도 알고 싶으면 바탕화면 알림을 켜 주세요.',
|
||||
'새 메일이 오면 바로 목록에서 볼 수 있습니다. 다른 창을 보는 동안에도 알고 싶으면 바탕화면 알림을 켜 주세요.',
|
||||
enable: '새 메일이 오면 바탕화면 알림 보내기',
|
||||
noteAsk: '켜면 브라우저가 알림을 허락할지 한 번 묻습니다. [허용]을 눌러 주세요.',
|
||||
noteGranted: '이 브라우저에서 알림이 허용되어 있습니다.',
|
||||
noteBlocked:
|
||||
'브라우저가 이 사이트의 알림을 막고 있습니다. 주소창 왼쪽의 사이트 정보(자물쇠)를 눌러 [알림]을 [허용]으로 바꾼 뒤 이 화면을 새로 고쳐 주세요.',
|
||||
noteUnsupported: '이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다.',
|
||||
contentLegend: '알림에 보일 것',
|
||||
noteUnsupported: '이 브라우저에서는 바탕화면 알림을 쓸 수 없습니다. 새 메일은 그래도 목록에서 바로 볼 수 있습니다.',
|
||||
contentLegend: '알림 내용',
|
||||
showSubject: '메일 제목도 보이기',
|
||||
showSubjectHint: '끄면 보낸 사람 이름만 보입니다. 다른 사람과 화면을 함께 보는 자리라면 꺼 두는 것이 좋습니다.',
|
||||
showSubjectHint: '끄면 알림에 보낸 사람 이름만 나옵니다. 다른 사람과 화면을 함께 보는 자리라면 꺼 두시기를 권합니다.',
|
||||
test: '시험 알림 보내기',
|
||||
testHint: '바탕화면 오른쪽 아래에 알림이 뜨면 됩니다. 맥에서는 오른쪽 위에 뜹니다.',
|
||||
realtimeTitle: '실시간 연결',
|
||||
realtimeLive: '연결되어 있어 새 메일이 오면 바로 목록에 나타납니다.',
|
||||
realtimeLive: '연결되어 있어 새 메일이 오면 바로 알 수 있습니다.',
|
||||
realtimeLiveHint:
|
||||
'연결이 끊기면 1분마다 새 메일을 확인하면서 저절로 다시 연결합니다. 회사 방화벽이나 프록시가 오래 열린 연결을 끊으면 이렇게 될 수 있습니다.',
|
||||
realtimeDown: '연결할 수 없어 1분마다 확인합니다.',
|
||||
@@ -76,13 +76,13 @@ export const MAIL_NOTIFY_TEXT = {
|
||||
/** 처음 잇는 동안 (캔버스에는 없다 — 2026-10-28 구현하며 정함) */
|
||||
realtimeConnecting: '연결하는 중입니다…',
|
||||
/** Web Locks · BroadcastChannel 이 없는 브라우저 — 1분 확인만 한다 (캔버스에는 없다 — 2026-10-28 구현하며 정함) */
|
||||
realtimeUnsupportedHint: '이 브라우저는 실시간 연결을 지원하지 않습니다.',
|
||||
realtimeUnsupportedHint: '이 브라우저에서는 실시간 연결을 쓸 수 없습니다.',
|
||||
/** 아래 안내 상자 — 첫 줄의 "이 브라우저에만"은 굵게 */
|
||||
noteBrowserBefore: '알림 설정은 ',
|
||||
noteBrowserStrong: '이 브라우저에만',
|
||||
noteBrowserAfter: ' 저장됩니다. 다른 PC나 브라우저에서는 그곳에서 따로 켜 주세요.',
|
||||
noteOpenTab: '웹메일 탭이 하나라도 열려 있어야 알림이 옵니다. 탭을 모두 닫으면 오지 않습니다.',
|
||||
noteVisible: '웹메일 화면을 보고 있을 때는 새 메일이 목록에 바로 보이므로 바탕화면 알림을 보내지 않습니다.',
|
||||
noteVisible: '웹메일 화면을 보고 있을 때는 목록에서 새 메일을 바로 볼 수 있어 바탕화면 알림을 보내지 않습니다.',
|
||||
|
||||
// 시험 알림 — 지금 탭에서 바로 보낸다
|
||||
testTitle: 'ARA 웹메일',
|
||||
|
||||
@@ -86,7 +86,7 @@ export const SEARCH_CONDITIONS_TEXT = {
|
||||
since: '시작 날짜',
|
||||
until: '끝 날짜',
|
||||
attachmentOnly: '첨부 파일이 있는 메일만',
|
||||
hint: '검색 칸의 말과 이 조건에 모두 맞는 메일을 찾습니다. 받는 사람에는 참조로 받은 사람도 들어갑니다.',
|
||||
hint: '검색 칸에 쓴 말이 들어 있고 이 조건에도 맞는 메일을 찾습니다. 받는 사람으로 찾을 때는 참조로 받은 사람까지 찾습니다.',
|
||||
periodOrder: '끝 날짜를 시작 날짜와 같거나 그 뒤로 골라 주세요.',
|
||||
periodRange: '날짜는 1970년 1월 1일부터 9999년 12월 30일 사이에서 골라 주세요.',
|
||||
clear: '조건 지우기',
|
||||
@@ -121,17 +121,17 @@ export const SEARCH_NOTICE_TEXT = {
|
||||
* 낮 내내 같은 글이면 멈춘 줄 안다 (2026-11-06 실사용 시험)
|
||||
*/
|
||||
indexing:
|
||||
'지난 메일을 검색할 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말이 통째로 같은 메일만 찾습니다.',
|
||||
'지난 메일까지 낱말의 일부로도 찾을 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말을 끝까지 다 써야 찾을 수 있습니다.',
|
||||
/** 한 글자 낱말뿐이라 본문은 찾지 않았을 때 (서버와 같은 낱말 나누기 — search-match.ts) */
|
||||
singleChar:
|
||||
'한 글자로만 찾을 때는 본문은 빼고 제목, 보낸 사람과 받는 사람, 첨부 파일 이름에서 찾습니다. 본문까지 찾으려면 두 글자 이상으로 찾아 주세요.',
|
||||
/** 이어 찾을 것이 남았는데 아직 하나도 찾지 못했을 때 */
|
||||
partialTitle: '아직 맞는 메일을 찾지 못했습니다',
|
||||
partialTitle: '아직 찾은 메일이 없습니다',
|
||||
partialHint: '메일이 많아 한 번에 다 찾지 못했습니다. [더 찾기]를 누르면 이어서 찾습니다.',
|
||||
more: '더 찾기',
|
||||
moreBusy: '찾는 중…',
|
||||
/** 검색어와 조건을 합친 낱말이 한도를 넘는 주소로 들어왔을 때 — 찾지 않고 이 안내만 (조건이 있으면 조건 줄의 [조건 지우기]) */
|
||||
tooManyWordsTitle: '찾을 말이 너무 많습니다',
|
||||
tooManyWordsHint: (max: number) =>
|
||||
`검색어와 조건에 넣은 낱말은 모두 합쳐 ${max}개까지 찾을 수 있습니다. 검색 칸이나 [조건]에서 낱말을 줄여 주세요.`,
|
||||
`검색 칸과 [조건]에 넣은 낱말은 모두 합쳐 ${max}개까지입니다. 낱말을 몇 개 줄여 주세요.`,
|
||||
} as const
|
||||
|
||||
@@ -34,12 +34,12 @@ export const MAIL_SETTINGS_TEXT = {
|
||||
ask: { label: '늘 묻기', hint: '메일마다 [이미지 보기]를 눌러 봅니다' },
|
||||
trusted: {
|
||||
label: '주소록과 사내 직원의 메일은 늘 보기',
|
||||
hint: '보낸 사람이 내 주소록이나 사내 목록에 있으면 바로 보입니다',
|
||||
hint: '보낸 사람이 내 주소록이나 사내 목록에 있으면 묻지 않고 보여 드립니다',
|
||||
},
|
||||
always: { label: '늘 보기', hint: '스팸메일함의 메일은 이 설정과 상관없이 묻습니다' },
|
||||
} satisfies Record<ExternalImagesSetting, { label: string; hint: string }>,
|
||||
imagesNote:
|
||||
'외부 이미지는 회사를 거쳐 불러오므로 보낸 사람은 이 PC의 주소를 알 수 없습니다. 다만 이미지를 불러오면 보낸 사람은 메일을 열어 본 것을 알 수 있습니다.',
|
||||
'외부 이미지는 회사 쪽에서 대신 받아 오기 때문에 보낸 사람은 이 PC의 주소를 알 수 없습니다. 다만 이미지를 불러오면 보낸 사람이 내가 메일을 열어 본 것을 알 수 있습니다.',
|
||||
|
||||
/**
|
||||
* 대화로 묶기 (14단계 — 디자인 README "목록 — 대화로 묶어 보기 (ListThreads14)" 의 켬/끔 한 줄. 보드가 없어 글은 정했다).
|
||||
@@ -50,5 +50,5 @@ export const MAIL_SETTINGS_TEXT = {
|
||||
groupOff: '끔',
|
||||
groupOn: '켬',
|
||||
groupHint:
|
||||
'답장과 전달로 이어진 메일을 메일함 목록에서 한 줄로 묶어 보여 줍니다. 메일함 목록 위의 말풍선 모양 [대화로 묶기] 단추로도 켜고 끌 수 있습니다.',
|
||||
'켜면 답장과 전달로 이어진 메일을 메일함 목록에서 한 줄로 묶습니다. 메일함 목록 위의 말풍선 모양 [대화로 묶기] 단추로도 켜고 끌 수 있습니다.',
|
||||
} as const
|
||||
|
||||
@@ -56,11 +56,11 @@ export const SIGNATURE_LEAVE_DIALOG = {
|
||||
export const SIGNATURE_TEXT = {
|
||||
title: '서명',
|
||||
description:
|
||||
'보내는 메일 끝에 넣는 글입니다. 쓰기 화면을 열 때 본문에 넣어 두므로, 그 메일에서만 고치거나 지울 수도 있습니다.',
|
||||
'보내는 메일 끝에 넣는 글입니다. 메일을 쓰기 시작할 때 본문에 미리 넣어 두니, 그 메일에서만 고치거나 지울 수도 있습니다.',
|
||||
fieldLabel: '서명 내용',
|
||||
hint:
|
||||
'굵게, 기울임, 밑줄, 글자 색, 링크, 그림을 쓸 수 있습니다. 그림은 PNG, JPG, GIF 파일을 한 장 300KB 이하로 3장까지 넣을 수 있고 ' +
|
||||
'붙여 넣거나 끌어다 놓아도 됩니다. 그림은 메일에 함께 실어 보내므로 받는 쪽에서 외부 이미지처럼 막히지 않습니다.',
|
||||
'붙여 넣거나 끌어다 놓아도 됩니다. 그림은 메일에 함께 실어 보내서 받는 사람 쪽에서 외부 이미지처럼 막히지 않습니다.',
|
||||
usageLegend: '서명을 넣을 메일',
|
||||
forNew: '새 메일',
|
||||
forReply: '답장과 전달',
|
||||
|
||||
@@ -17,14 +17,14 @@ export const VACATION_NOTICE_REFRESH_MS = 60_000
|
||||
|
||||
export const VACATION_TEXT = {
|
||||
title: '부재중 자동 응답',
|
||||
description: '자리를 비운 동안 받은 메일에 정해 둔 글로 자동으로 답합니다. 켜 둔 동안에는 메일 화면 왼쪽의 메일함 목록 아래에 안내가 보입니다.',
|
||||
description: '자리를 비운 동안 받은 메일에 정해 둔 글로 자동으로 답합니다. 켜 둔 동안에는 메일 화면 왼쪽, 메일함 목록 아래에서 켜져 있는지 확인할 수 있습니다.',
|
||||
enable: '부재중 자동 응답 사용',
|
||||
periodLegend: '기간',
|
||||
untilOff: '끌 때까지 계속',
|
||||
range: '기간 정하기',
|
||||
from: '시작',
|
||||
to: '끝',
|
||||
rangeHint: '시작일 0시부터 끝나는 날 자정까지 답합니다. 시각은 이 브라우저의 시간대를 따릅니다.',
|
||||
rangeHint: '시작하는 날 0시부터 끝나는 날 밤 12시까지 답합니다. 시각은 이 PC의 시간으로 셉니다.',
|
||||
subject: '제목',
|
||||
body: '내용',
|
||||
bodyHint: '서식과 그림 없이 글자만 보내고, 서명은 붙지 않습니다.',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
import type { MailboxRole, RejectedRecipientReason } from '@/shared/types/mail'
|
||||
import { subjectParticle } from '@/shared/utils/korean'
|
||||
|
||||
/**
|
||||
* 메일함 정렬 순서 — 계약 2절 "메일함 정렬(확정 디자인 기준)".
|
||||
@@ -33,14 +34,15 @@ export const MAILBOX_ICONS: Readonly<Record<MailboxKind, IconName>> = {
|
||||
|
||||
/** 빈 메일함 안내 (디자인 원본 States · Main) */
|
||||
export const MAILBOX_EMPTY_HINTS: Readonly<Record<MailboxKind, string>> = {
|
||||
inbox: '새 메일이 오면 여기에 표시됩니다.',
|
||||
drafts: '임시 저장한 메일이 여기에 표시됩니다.',
|
||||
sent: '보낸 메일이 여기에 표시됩니다.',
|
||||
// 휴지통 · 스팸메일함은 메일 서버가 30일 뒤 지운다 — 메일이 없을 때는 목록 위 줄 대신 이 글에 붙인다 (13단계 — 디자인 README "휴지통 · 스팸메일함 — 30일 줄")
|
||||
junk: '스팸으로 분류된 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.',
|
||||
trash: '삭제한 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.',
|
||||
archive: '보관한 메일이 여기에 표시됩니다.',
|
||||
custom: '이 메일함으로 옮긴 메일이 여기에 표시됩니다.',
|
||||
inbox: '새 메일이 오면 여기로 들어옵니다.',
|
||||
drafts: '쓰다가 임시 저장한 메일은 여기에 남습니다.',
|
||||
sent: '보낸 메일은 여기에 남습니다.',
|
||||
// 휴지통 · 스팸메일함은 메일 서버가 30일 뒤 지운다 — 메일이 없을 때는 목록 위 줄 대신 이 글이 30일을 알린다 (13단계 — 디자인 README "휴지통 · 스팸메일함 — 30일 줄")
|
||||
junk: '스팸 메일은 여기에 30일 동안 남아 있다가 저절로 지워집니다.',
|
||||
trash: '지운 메일은 여기에 30일 동안 남아 있다가 저절로 지워집니다.',
|
||||
archive: '오래 두고 볼 메일을 여기로 옮겨 보관할 수 있습니다.',
|
||||
// 빈 메일함에서 보는 글이라 어디서 고르는지를 밝힌다 — [이동]은 고른 메일 도구 줄의 단추(MAIL_BULK_TEXT.move)
|
||||
custom: '다른 메일함에서 메일을 고른 뒤 [이동]을 누르면 이 메일함으로 옮길 수 있습니다.',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -291,7 +293,8 @@ export const MAIL_SEARCH_TEXT = {
|
||||
scopeLabel: '찾을 곳',
|
||||
scopeAll: '전체 메일함',
|
||||
scopeAllHint: '휴지통과 스팸메일함은 빼고 찾습니다',
|
||||
emptyTitle: (query: string) => (query ? `‘${query}’에 맞는 메일이 없습니다` : '조건에 맞는 메일이 없습니다'),
|
||||
emptyTitle: (query: string) =>
|
||||
query ? `‘${query}’${subjectParticle(query)} 들어간 메일이 없습니다` : '조건에 맞는 메일이 없습니다',
|
||||
/**
|
||||
* 결과 없음의 도움말 (14단계 디자인 원본 Search14) — 검색어와 조건으로 찾았으면 emptyHint, 조건만으로 찾았으면 emptyConditionsHint,
|
||||
* 검색어만으로 찾았으면(걸린 조건이 없다) emptyWordsHint — 없는 조건을 줄이라고 하지 않는다 (2026-11-06 실사용 시험)
|
||||
@@ -392,19 +395,28 @@ export const MAIL_IMAGES_SHOW = 'show'
|
||||
export const MAIL_IMAGE_PROXY_PATH = '/api/mail-images/'
|
||||
/** 읽기 화면의 노란 안내 줄 + [이미지 보기](36 — 밀도 예외 --size-notice-action) */
|
||||
export const MAIL_IMAGES_TEXT = {
|
||||
blocked: '외부 이미지를 차단했습니다. 개인정보 보호를 위해 보낸 사람 서버의 이미지는 불러오지 않습니다.',
|
||||
blocked: '외부 이미지는 막아 두었습니다. 이미지를 불러오면 보낸 사람이 내가 메일을 열어 본 것을 알 수 있습니다.',
|
||||
show: '이미지 보기',
|
||||
} as const
|
||||
|
||||
// ── 읽기 화면의 본문 안내 (디자인 README "읽기 화면의 본문 안내" · 계약 2절 "읽기 한도") ─────────
|
||||
/**
|
||||
* 서버가 본문을 다 주지 못했을 때 본문 위에 두는 안내 — "외부 이미지를 차단했습니다"와 같은 모양.
|
||||
* 서버가 본문을 다 주지 못했을 때 본문 위에 두는 안내의 앞 문장 — "외부 이미지는 막아 두었습니다"와 같은 모양.
|
||||
* - htmlOmitted · unavailable: 서식 본문을 정화 한도 때문에 비웠다(isHtmlBodyOmitted) — 글자 본문이 있을 때 · 없을 때
|
||||
* - truncated: 보여 주는 본문이 읽기 한도(2MB)에서 잘렸다(isBodyTruncated)
|
||||
* 읽기 화면은 뒤에 할 일(MAIL_BODY_NOTICE_ACTIONS)을 한 칸 띄워 잇고, 같은 대화 칸의 펼친 줄은 이 앞 문장만 보인다 (mail-body-notices.ts)
|
||||
*/
|
||||
export const MAIL_BODY_NOTICES = {
|
||||
htmlOmitted: '본문이 복잡해 서식 없이 글자만 표시합니다.',
|
||||
unavailable: '본문이 너무 크거나 복잡해 표시할 수 없습니다.',
|
||||
truncated: '본문이 길어 앞부분만 표시합니다.',
|
||||
htmlOmitted: '본문이 복잡해 서식 없이 글자만 보여 드립니다.',
|
||||
unavailable: '본문이 너무 크거나 복잡해 여기서는 열 수 없습니다.',
|
||||
truncated: '본문이 길어 앞부분만 보여 드립니다.',
|
||||
} as const
|
||||
export type MailBodyNoticeKind = keyof typeof MAIL_BODY_NOTICES
|
||||
/**
|
||||
* 본문 안내 뒤의 할 일 — 읽기 화면에서만. 읽기 화면 ⋯ 메뉴(MAIL_READER_MORE_TEXT)의 원문(.eml)에는 본문 전체가 있다.
|
||||
* 같은 대화 칸의 펼친 줄에는 붙이지 않는다 — 그 화면의 ⋯ 는 위에 연 메일의 것이라, 원문은 그 줄의 [이 메일만 열기]로 연 읽기 화면에서 받는다
|
||||
*/
|
||||
export const MAIL_BODY_NOTICE_ACTIONS = {
|
||||
unavailable: '[⋯] → [원문 내려받기 (.eml)]로 받아 볼 수 있습니다.',
|
||||
truncated: '전체는 [⋯] → [원문 내려받기 (.eml)]로 받아 볼 수 있습니다.',
|
||||
} as const satisfies Partial<Record<MailBodyNoticeKind, string>>
|
||||
|
||||
@@ -81,5 +81,5 @@ export const MAILBOX_OPERATION_TEXT = {
|
||||
deleteDoneMoved: (mailbox: string, count: string) =>
|
||||
`‘${mailbox}’ 메일함을 지웠습니다. 안의 메일 ${count}통은 휴지통에 있습니다.`,
|
||||
/** 도중에 실패 — 거기까지 처리한 수. 다시 하면 남은 것만 처리한다 */
|
||||
failed: (done: string, total: string) => `${total}통 가운데 ${done}통까지 처리하고 멈췄습니다. 다시 시도해 주세요.`,
|
||||
failed: (done: string, total: string) => `${total}통 가운데 ${done}통까지 마치고 멈췄습니다. 다시 시도해 주세요.`,
|
||||
} as const
|
||||
|
||||
@@ -43,7 +43,7 @@ export const LOGIN_PASSWORD_CHANGE_TEXT = {
|
||||
/** 로그인 — 2단계 인증 켜기(필수) (디자인 원본 LoginTwoFactorSetup). QR · 키 · [복사]의 글은 12단계 TWO_FACTOR_ENABLE_TEXT */
|
||||
export const LOGIN_TWO_FACTOR_SETUP_TEXT = {
|
||||
title: '2단계 인증을 켜 주세요',
|
||||
subtitle: '회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일로 이어집니다.',
|
||||
subtitle: '회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일을 쓸 수 있습니다.',
|
||||
scanTitle: '인증 앱으로 QR 코드를 찍어 주세요',
|
||||
scanHint:
|
||||
'인증 앱에서 [+] → [QR 코드 스캔]을 고릅니다. 앱이 없으면 Google Authenticator나 Microsoft Authenticator를 먼저 설치해 주세요.',
|
||||
@@ -67,7 +67,7 @@ export const LOGIN_TWO_FACTOR_SETUP_TEXT = {
|
||||
export const SUPPORT_CONTACT_TEXT = {
|
||||
/** 로그인 첫 화면 */
|
||||
passwordQuestion: '비밀번호를 잊으셨나요?',
|
||||
passwordFallback: '비밀번호를 잊으셨다면 시스템 관리자에게 문의해 주세요.',
|
||||
passwordFallback: '비밀번호를 잊으셨다면 관리자에게 문의해 주세요.',
|
||||
/** 로그인 코드 단계 · 2단계 인증 켜기 */
|
||||
authenticatorQuestion: '인증 앱을 쓸 수 없나요?',
|
||||
authenticatorFallback: '인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요.',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { MESSAGE_LARGE_FILE_TEXT } from '@/shared/constants/large-file'
|
||||
import type { MessageLargeFile } from '@/shared/types/large-file'
|
||||
import { isDownloadable, isEndedLinkStatus, messageLargeFileView, sharedFileStatusView } from './large-file-status'
|
||||
|
||||
@@ -41,7 +42,7 @@ describe('읽기 화면의 대용량 첨부 줄 (디자인 원본 MailReadLargeF
|
||||
|
||||
it('위험은 빨간 줄 · 끝난 링크와 받을 수 없는 파일은 흐리게 — 받기 단추 없음', () => {
|
||||
expect(messageLargeFileView({ ...FILE, status: 'infected' })).toEqual({
|
||||
meta: '위험한 내용이 있어 보안 검사에서 막았고, 받을 수 없습니다',
|
||||
meta: MESSAGE_LARGE_FILE_TEXT.infected,
|
||||
tone: 'danger',
|
||||
download: 'none',
|
||||
})
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
|
||||
import { mailBodyNotices } from './mail-body-notices'
|
||||
import { MAIL_BODY_NOTICE_ACTIONS, MAIL_BODY_NOTICES, MAIL_READER_MORE_TEXT } from '@/shared/constants/mail'
|
||||
import { mailBodyNotices, type MailBodyNoticeOptions } from './mail-body-notices'
|
||||
|
||||
const texts = (source: Parameters<typeof mailBodyNotices>[0]) => mailBodyNotices(source).map((item) => item.text)
|
||||
/** 읽기 화면 — 앞 문장 뒤에 할 일까지 */
|
||||
const READER: MailBodyNoticeOptions = { withAction: true }
|
||||
/** 같은 대화 칸의 펼친 줄 — 앞 문장만 */
|
||||
const CONVERSATION: MailBodyNoticeOptions = { withAction: false }
|
||||
|
||||
const texts = (source: Parameters<typeof mailBodyNotices>[0], options: MailBodyNoticeOptions = CONVERSATION) =>
|
||||
mailBodyNotices(source, options).map((item) => item.text)
|
||||
|
||||
describe('mailBodyNotices — 읽기 화면의 본문 안내 (계약 2절 "읽기 한도")', () => {
|
||||
it('서버가 다 준 본문이면 안내가 없다 — 표시 값이 없는(이전 서버) 메일도 같다', () => {
|
||||
expect(texts({ textBody: '본문', isHtmlBodyOmitted: false, isBodyTruncated: false })).toEqual([])
|
||||
expect(texts({ textBody: '본문' })).toEqual([])
|
||||
expect(texts({ textBody: '본문' }, READER)).toEqual([])
|
||||
})
|
||||
|
||||
it('서식 본문을 비웠는데 글자 본문이 있으면 "서식 없이 글자만", 없으면(공백뿐 포함) "표시할 수 없음"', () => {
|
||||
@@ -18,7 +25,7 @@ describe('mailBodyNotices — 읽기 화면의 본문 안내 (계약 2절 "읽
|
||||
})
|
||||
|
||||
it('보여 주는 본문이 잘렸으면 "앞부분만 표시"', () => {
|
||||
expect(mailBodyNotices({ textBody: null, isBodyTruncated: true })).toEqual([
|
||||
expect(mailBodyNotices({ textBody: null, isBodyTruncated: true }, CONVERSATION)).toEqual([
|
||||
{ kind: 'truncated', text: MAIL_BODY_NOTICES.truncated },
|
||||
])
|
||||
})
|
||||
@@ -32,4 +39,21 @@ describe('mailBodyNotices — 읽기 화면의 본문 안내 (계약 2절 "읽
|
||||
MAIL_BODY_NOTICES.unavailable,
|
||||
])
|
||||
})
|
||||
|
||||
it('읽기 화면은 앞 문장 뒤에 할 일(원문 내려받기)을 한 칸 띄워 잇는다 — 할 일이 없는 "서식 없이 글자만"은 앞 문장 그대로', () => {
|
||||
expect(texts({ textBody: null, isHtmlBodyOmitted: true }, READER)).toEqual([
|
||||
`${MAIL_BODY_NOTICES.unavailable} ${MAIL_BODY_NOTICE_ACTIONS.unavailable}`,
|
||||
])
|
||||
expect(texts({ textBody: '긴 글자 본문', isHtmlBodyOmitted: true, isBodyTruncated: true }, READER)).toEqual([
|
||||
MAIL_BODY_NOTICES.htmlOmitted,
|
||||
`${MAIL_BODY_NOTICES.truncated} ${MAIL_BODY_NOTICE_ACTIONS.truncated}`,
|
||||
])
|
||||
// 할 일은 읽기 화면 ⋯ 메뉴의 원문 내려받기 — 펼친 줄(앞 문장만)에는 없다
|
||||
expect(MAIL_BODY_NOTICE_ACTIONS.unavailable).toContain(MAIL_READER_MORE_TEXT.downloadSource)
|
||||
expect(MAIL_BODY_NOTICE_ACTIONS.truncated).toContain(MAIL_READER_MORE_TEXT.downloadSource)
|
||||
for (const text of texts({ textBody: null, isHtmlBodyOmitted: true, isBodyTruncated: true })) {
|
||||
expect(text).not.toContain(MAIL_READER_MORE_TEXT.downloadSource)
|
||||
}
|
||||
expect(texts({ textBody: null, isBodyTruncated: true })).toEqual([MAIL_BODY_NOTICES.truncated])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 읽기 화면의 본문 안내 — 서버가 본문을 다 주지 못했을 때 (계약 2절 MessageDetail · "읽기 한도") — 순수 함수만 둔다
|
||||
// 순서는 생략(서식 없이 · 표시할 수 없음) → 잘림 (디자인 README "읽기 화면의 본문 안내").
|
||||
|
||||
import { MAIL_BODY_NOTICES, type MailBodyNoticeKind } from '@/shared/constants/mail'
|
||||
import { MAIL_BODY_NOTICE_ACTIONS, MAIL_BODY_NOTICES, type MailBodyNoticeKind } from '@/shared/constants/mail'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
|
||||
export interface MailBodyNotice {
|
||||
@@ -9,22 +9,35 @@ export interface MailBodyNotice {
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface MailBodyNoticeOptions {
|
||||
/**
|
||||
* 앞 문장 뒤에 할 일(원문 내려받기 — MAIL_BODY_NOTICE_ACTIONS)까지 잇는다 — 읽기 화면만 true.
|
||||
* 같은 대화 칸의 펼친 줄은 false(앞 문장만): 그 화면의 ⋯ 는 위에 연 메일의 것이라,
|
||||
* 원문은 그 줄의 [이 메일만 열기]로 연 읽기 화면에서 받는다
|
||||
*/
|
||||
withAction: boolean
|
||||
}
|
||||
|
||||
type NoticeSource = Pick<MessageDetail, 'textBody' | 'isHtmlBodyOmitted' | 'isBodyTruncated'>
|
||||
|
||||
function notice(kind: MailBodyNoticeKind): MailBodyNotice {
|
||||
return { kind, text: MAIL_BODY_NOTICES[kind] }
|
||||
/** 안내마다의 할 일 — 할 일이 없는 안내(서식 없이 글자만)는 앞 문장 그대로다 */
|
||||
const ACTIONS: Readonly<Partial<Record<MailBodyNoticeKind, string>>> = MAIL_BODY_NOTICE_ACTIONS
|
||||
|
||||
function notice(kind: MailBodyNoticeKind, withAction: boolean): MailBodyNotice {
|
||||
const action = withAction ? ACTIONS[kind] : undefined
|
||||
return { kind, text: action ? `${MAIL_BODY_NOTICES[kind]} ${action}` : MAIL_BODY_NOTICES[kind] }
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 위에 둘 안내 — 없으면 빈 목록. 표시 값이 없는(이전 서버) 메일은 안내하지 않는다.
|
||||
* 글자 본문은 공백뿐이면 없는 것으로 본다 — 화면(MailBodyFrame)에 보이는 것이 없다.
|
||||
*/
|
||||
export function mailBodyNotices(message: NoticeSource): MailBodyNotice[] {
|
||||
export function mailBodyNotices(message: NoticeSource, options: MailBodyNoticeOptions): MailBodyNotice[] {
|
||||
const notices: MailBodyNotice[] = []
|
||||
const omitted = message.isHtmlBodyOmitted === true
|
||||
const hasText = (message.textBody ?? '').trim() !== ''
|
||||
if (omitted) notices.push(notice(hasText ? 'htmlOmitted' : 'unavailable'))
|
||||
if (omitted) notices.push(notice(hasText ? 'htmlOmitted' : 'unavailable', options.withAction))
|
||||
// 보여 줄 본문이 없으면("표시할 수 없음") 앞부분만 보인다는 안내는 맞지 않는다
|
||||
if (message.isBodyTruncated === true && !(omitted && !hasText)) notices.push(notice('truncated'))
|
||||
if (message.isBodyTruncated === true && !(omitted && !hasText)) notices.push(notice('truncated', options.withAction))
|
||||
return notices
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { MAIL_FILTER_RUN_TEXT } from '@/shared/constants/mail-filter'
|
||||
import type { Mailbox, MailFilterRule } from '@/shared/types/mail'
|
||||
import { newFilterDraft, type MailFilterDraft } from './mail-filter'
|
||||
import {
|
||||
@@ -55,7 +56,7 @@ describe('이미 받은 메일에 할 일 — 칸과 시작이 함께 쓰는 판
|
||||
})
|
||||
})
|
||||
|
||||
describe('「받은편지함에 이미 있는 메일에도 적용」을 고를 수 없는 까닭 (고치기 창의 칸 값)', () => {
|
||||
describe('「받은편지함에 이미 있는 메일도 정리하기」를 고를 수 없는 까닭 (고치기 창의 칸 값)', () => {
|
||||
it('이 탭에서 적용하는 중이 먼저 — 그다음 지워진 메일함 · 받은편지함으로만 · 전달만', () => {
|
||||
expect(filterApplyBlock(draft({ move: true }), MAILBOXES, true)).toBe('busy')
|
||||
expect(filterApplyBlock(draft({ move: true, moveTarget: 'gone', forward: true }), MAILBOXES, false)).toBe('missingMailbox')
|
||||
@@ -123,10 +124,10 @@ describe('알림 문구 (디자인 원본 MailFilterApplyStates)', () => {
|
||||
expect(copy.progress({ scanned: 0, total: 0 })).toBe('‘거래처 메일’ 필터로 받은편지함을 정리하는 중')
|
||||
})
|
||||
|
||||
it('끝 — N통에 적용, 0통이면 맞는 메일이 없다', () => {
|
||||
it('끝 — N통에 적용, 0통이면 걸리는 메일이 없다', () => {
|
||||
expect(copy.done(37)).toBe('‘거래처 메일’ 필터로 받은편지함의 메일 37통을 정리했습니다.')
|
||||
expect(copy.done(1234)).toBe('‘거래처 메일’ 필터로 받은편지함의 메일 1,234통을 정리했습니다.')
|
||||
expect(copy.done(0)).toBe('받은편지함에 ‘거래처 메일’ 필터에 맞는 메일이 없습니다.')
|
||||
expect(copy.done(0)).toBe(MAIL_FILTER_RUN_TEXT.none('‘거래처 메일’'))
|
||||
})
|
||||
|
||||
it('멈춤 — 전체 가운데 확인한 데까지 · 적용한 수', () => {
|
||||
|
||||
@@ -39,7 +39,7 @@ export function filterRunIdleReason(
|
||||
}
|
||||
|
||||
/**
|
||||
* 「받은편지함에 이미 있는 메일에도 적용」을 고를 수 없는 까닭 (디자인 원본 MailFilterApplyStates — 7단계 리뷰로 넓혔다)
|
||||
* 「받은편지함에 이미 있는 메일도 정리하기」를 고를 수 없는 까닭 (디자인 원본 MailFilterApplyStates — 7단계 리뷰로 넓혔다)
|
||||
* busy: 이 탭에서 다른 필터를 적용하는 중(가장 먼저) · missingMailbox · inboxOnly · forwardOnly: 전달만 하는 필터
|
||||
*/
|
||||
export type MailFilterApplyBlock = 'busy' | 'missingMailbox' | 'inboxOnly' | 'forwardOnly'
|
||||
@@ -99,7 +99,7 @@ export function filterRunTarget(
|
||||
export interface MailFilterRunCopy {
|
||||
/** 하는 중 — "‘…’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인" (전체를 모르면 수를 뺀다) */
|
||||
progress: (tally: Pick<MailFilterRunTally, 'scanned' | 'total'>) => string
|
||||
/** 끝남 — 할 일을 한 수. 0 이면 "받은편지함에 ‘…’ 필터에 맞는 메일이 없습니다." */
|
||||
/** 끝남 — 할 일을 한 수. 0 이면 "받은편지함에는 ‘…’ 필터에 걸리는 메일이 없습니다." */
|
||||
done: (matched: number) => string
|
||||
/** 도중에 멈춤 — 거기까지의 수 */
|
||||
stopped: (tally: MailFilterRunTally) => string
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { MAIL_NOTIFY_TEXT } from '@/shared/constants/mail-notify'
|
||||
import {
|
||||
mailEventsLockName,
|
||||
packRealtimeMessage,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
describe('realtimeLinkText (설정 › 알림의 실시간 연결 줄)', () => {
|
||||
it('연결됨은 초록 점과 "연결되어 있습니다", 끊김 · 지원 안 함은 "연결할 수 없어 1분마다 확인합니다."', () => {
|
||||
expect(realtimeLinkText('live')).toMatchObject({
|
||||
text: '연결되어 있어 새 메일이 오면 바로 목록에 나타납니다.',
|
||||
text: MAIL_NOTIFY_TEXT.realtimeLive,
|
||||
notice: null,
|
||||
live: true,
|
||||
})
|
||||
@@ -26,7 +27,7 @@ describe('realtimeLinkText (설정 › 알림의 실시간 연결 줄)', () => {
|
||||
expect(realtimeLinkText('unsupported')).toEqual({
|
||||
text: '연결할 수 없어 1분마다 확인합니다.',
|
||||
notice: null,
|
||||
hint: '이 브라우저는 실시간 연결을 지원하지 않습니다.',
|
||||
hint: MAIL_NOTIFY_TEXT.realtimeUnsupportedHint,
|
||||
live: false,
|
||||
})
|
||||
expect(realtimeLinkText('connecting')).toMatchObject({ text: '연결하는 중입니다…', notice: null, live: false })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { MAILBOX_OPERATION_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import { initialOperationTotal, mailboxOperationCopy } from './mailbox-operation'
|
||||
|
||||
// 오래 걸리는 메일함 처리의 알림 문구 (디자인 원본 MailActions4 "오래 걸리는 처리" · "알림 문구")
|
||||
@@ -10,7 +11,7 @@ describe('mailboxOperationCopy', () => {
|
||||
|
||||
expect(copy.progress(600, 1234)).toBe('받은편지함의 메일을 읽음으로 표시하는 중 · 600 / 1,234통')
|
||||
expect(copy.done(1234)).toBe('받은편지함의 메일 1,234통을 읽음으로 표시했습니다.')
|
||||
expect(copy.failed(600, 1234)).toBe('1,234통 가운데 600통까지 처리하고 멈췄습니다. 다시 시도해 주세요.')
|
||||
expect(copy.failed(600, 1234)).toBe(MAILBOX_OPERATION_TEXT.failed('600', '1,234'))
|
||||
// 다른 탭이 먼저 했다
|
||||
expect(copy.done(0)).toBe('받은편지함에 안 읽은 메일이 없습니다.')
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { MAILBOX_EMPTY_HINTS } from '@/shared/constants/mail'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import {
|
||||
emptyMailboxText,
|
||||
@@ -84,7 +85,7 @@ describe('emptyMailboxText', () => {
|
||||
it('메일함 이름과 받침에 맞는 조사로 안내한다', () => {
|
||||
expect(emptyMailboxText({ name: '임시보관함', role: 'drafts' })).toEqual({
|
||||
title: '임시보관함이 비어 있습니다',
|
||||
hint: '임시 저장한 메일이 여기에 표시됩니다.',
|
||||
hint: MAILBOX_EMPTY_HINTS.drafts,
|
||||
})
|
||||
expect(emptyMailboxText({ name: '프로젝트', role: null }).title).toBe('프로젝트가 비어 있습니다')
|
||||
})
|
||||
|
||||
@@ -228,8 +228,8 @@ describe('toast.store', () => {
|
||||
|
||||
const failed = toast.progress('비우는 중')
|
||||
store.dismiss(store.toasts[1]?.id ?? -1)
|
||||
failed.fail('21통 가운데 10통까지 처리하고 멈췄습니다. 다시 시도해 주세요.')
|
||||
expect(store.toasts.at(-1)).toMatchObject({ kind: 'error', message: '21통 가운데 10통까지 처리하고 멈췄습니다. 다시 시도해 주세요.' })
|
||||
failed.fail('21통 가운데 10통까지 마치고 멈췄습니다. 다시 시도해 주세요.')
|
||||
expect(store.toasts.at(-1)).toMatchObject({ kind: 'error', message: '21통 가운데 10통까지 마치고 멈췄습니다. 다시 시도해 주세요.' })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user