generated from ttipo/project-base
feat(frontend): 대용량 첨부 — 쓰기 · 읽기 · 설정 · 공개 내려받기 화면
- 쓰기: 20MB 넘는 파일 · 합계 25MB 를 넘기는 파일 · 메일에 못 담는 형식은 저절로 링크로(묻는 창 없음). 고르자마자 서버에 만들고 16MiB 조각(Content-Digest)을 파일 3개까지 함께, 끊기면 2 · 5 · 15 · 30초로 다시 · 1분 넘으면 멈춤 + [다시 시도], 새로고침 뒤 같은 파일을 다시 고르면 잇는다. 다 올릴 때까지 [보내기]는 aria-disabled + 까닭, 위험 판정은 빨간 줄 + 실시간 알림. 대용량 파일이 있으면 첫 원문 인용 앞을 data-ara-quote-start · bodyQuoteStart 로 알린다(서버가 그 자리에 덩이를 넣는다). - 읽기: 첨부 칸의 대용량 항목(서버의 largeFiles), 본문 덩이 접기, 보낸 사람의 받은 수 · 링크 끊기(끊은 날 — revokedAt). - 설정 › 메일 › 대용량 첨부: 보관 사용량 · 보내지 않은 파일 · 보낸 링크(끊기). - 공개 화면 /s/:token: 세션 복원 없이(meta.public) · credentials omit · no-referrer, 라우트는 대소문자를 가린다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -444,7 +444,9 @@ describe('ComposePage — 임시 저장 · 서명 (2단계)', () => {
|
||||
|
||||
expect(requests.some((request) => request.method === 'POST' && request.url === '/messages')).toBe(false)
|
||||
expect(wrapper.get('.compose__send').text()).toBe('보내기')
|
||||
expect(wrapper.get('.compose__send').attributes('disabled')).toBeDefined()
|
||||
// 꺼진 단추는 aria-disabled (11단계 — 초점은 받는다) · 까닭은 단추 옆 글
|
||||
expect(wrapper.get('.compose__send').attributes('aria-disabled')).toBe('true')
|
||||
expect(wrapper.get('#compose-send-why').text()).toBe('보낼 수 없는 파일이 있습니다 — 빨간 줄의 파일을 빼 주세요.')
|
||||
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.MAIL_004)
|
||||
expect(document.activeElement?.textContent?.trim()).toBe('파일 첨부')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
@@ -159,7 +159,7 @@ describe('ComposePage — 서식 편집기 청크', () => {
|
||||
)
|
||||
const alert = view.get('.compose__editor [role="alert"]')
|
||||
expect(alert.text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
|
||||
expect(view.get('.compose__send').attributes('disabled')).toBeDefined()
|
||||
expect(view.get('.compose__send').attributes('aria-disabled')).toBe('true')
|
||||
// 편집기에 넘기던 속성(초안 HTML 등)이 오류 화면에 붙지 않는다
|
||||
expect(alert.attributes('initial-html')).toBeUndefined()
|
||||
expect(loadErrors).toHaveLength(1)
|
||||
@@ -172,7 +172,7 @@ describe('ComposePage — 서식 편집기 청크', () => {
|
||||
|
||||
expect(loader.calls).toBe(2)
|
||||
expect(view.find('.compose__editor [role="alert"]').exists()).toBe(false)
|
||||
expect(view.get('.compose__send').attributes('disabled')).toBeUndefined()
|
||||
expect(view.get('.compose__send').attributes('aria-disabled')).toBeUndefined()
|
||||
// 누른 "다시 시도" 단추가 사라졌다 — 초점은 본문으로
|
||||
await vi.waitFor(() => {
|
||||
if (document.activeElement?.id !== 'compose-body') throw new Error('아직 초점이 없다')
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import { LARGE_FILE_TEXT, SEND_BLOCK_TEXT } from '@/shared/constants/large-file'
|
||||
import type { DraftLargeFile, FileQuota, LargeFile, MessageLargeFile } from '@/shared/types/large-file'
|
||||
import type { Draft, MessageDetail } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { createManualClock } from '@/test-utils/manual-clock'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockHandler,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { installProseMirrorDom } from '@/test-utils/prosemirror-dom'
|
||||
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
|
||||
import ComposePage from './ComposePage.vue'
|
||||
|
||||
// 쓰기 화면의 대용량 첨부 (11단계 — 디자인 원본 ComposeLargeFiles · 계약 4절 "발송 · 임시 저장과 함께" · "인용 속 링크의 상태")
|
||||
// 조각 올리기 자체는 useLargeFileUploads.spec.ts 가 본다 — 여기서는 화면이 잇는 것: 저장본의 파일 줄 · 보내기를 막는 까닭 ·
|
||||
// 보내기의 MAIL_016 뒤 다시 묻기 · 전달의 인용 속 링크 상태 · 「링크로 보내기」 · 인용 경계(덩이 자리)를 싣는 때와 싣지 않는 때
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
/** 지연 로딩한 편집기를 기다리는 시간 — ComposePage.spec.ts 와 같다 */
|
||||
const EDITOR_LOAD_TIMEOUT_MS = 15_000
|
||||
const MB = 1024 * 1024
|
||||
const GB = 1024 * MB
|
||||
const CHUNK = 16 * MB
|
||||
/** 한국 시간 2026-10-30 (금) 오후 2:15 */
|
||||
const NOW = Date.parse('2026-10-30T05:15:00Z')
|
||||
const SHARE = 'https://files.arasystem.it.kr/s/'
|
||||
const URL1 = `${SHARE}Qx7mK2vR9pL4wT8nB3cF6hJ1dS5gA0eY2uI7oP9kZ3x`
|
||||
const URL2 = `${SHARE}Ab3dE5fG7hJ9kL1mN3pQ5rS7tU9vW1xY3zA5bC7dE9f`
|
||||
|
||||
const QUOTA: FileQuota = {
|
||||
usedBytes: 2 * GB,
|
||||
limitBytes: 10 * GB,
|
||||
maxFileBytes: 2047 * MB,
|
||||
maxFilesPerMessage: 10,
|
||||
chunkSize: CHUNK,
|
||||
retentionDays: 30,
|
||||
}
|
||||
|
||||
const range = (count: number) => Array.from({ length: count }, (_, index) => index)
|
||||
|
||||
/** 다 올린 파일 — 1.2 GB, 깨끗함 */
|
||||
const READY: LargeFile = {
|
||||
id: 'lf-1',
|
||||
name: '설계도_v3.dwg',
|
||||
size: 1_288_490_189,
|
||||
status: 'ready',
|
||||
chunkSize: CHUNK,
|
||||
chunkCount: 77,
|
||||
receivedChunks: range(77),
|
||||
firstChunkSha256: 'sha-first',
|
||||
scanStatus: 'clean',
|
||||
createdAt: '2026-10-26T01:00:00Z',
|
||||
expiresAt: '2026-11-25T14:59:59.999Z',
|
||||
lastModified: 1_700_000_000_000,
|
||||
}
|
||||
|
||||
/** 올리다 멈춘 파일 — 612 MB 가운데 조각 10개 */
|
||||
const STOPPED: LargeFile = {
|
||||
...READY,
|
||||
id: 'lf-2',
|
||||
name: '현장영상_1029.mp4',
|
||||
size: 612 * MB,
|
||||
status: 'uploading',
|
||||
chunkCount: 39,
|
||||
receivedChunks: range(10),
|
||||
scanStatus: null,
|
||||
expiresAt: '2026-10-31T05:00:00Z',
|
||||
}
|
||||
|
||||
const DELETED: DraftLargeFile = { id: 'lf-3', status: 'missing', reason: 'deleted', name: '시공도면_v4.dwg', size: 620 * MB }
|
||||
|
||||
/**
|
||||
* 저장본 · 보내기 취소로 되돌린 초안의 답장 본문 — 서버 정화가 data-* 를 지워 인용 표시(data-mail-quote · data-quote-header)가 없다.
|
||||
* 남은 것은 쓰기 화면이 입힌 인라인 style 과 "원본 메일" 머리 문단 + 머리 표뿐이다 (리뷰 M1)
|
||||
*/
|
||||
const SANITIZED_REPLY_HTML =
|
||||
'<div style="font-family: \'Malgun Gothic\', sans-serif; font-size: 14px">' +
|
||||
'<p style="margin: 0">확인했습니다.</p>' +
|
||||
'<p style="margin: 0"><br></p>' +
|
||||
'<blockquote style="margin: 24px 0 0; padding: 2px 0 2px 14px; border-left: 2px solid #D0D5DD; color: #4A5363">' +
|
||||
'<p style="margin: 0; font-size: 12.5px"><strong>원본 메일</strong></p>' +
|
||||
'<table style="margin: 0 0 12px; border-collapse: collapse; font-size: 12.5px; line-height: 1.6"><tbody>' +
|
||||
'<tr><td style="padding: 0 12px 0 0; color: #667085">보낸 사람</td><td style="padding: 0">이도윤 <doyun.lee@arasystem.it.kr></td></tr>' +
|
||||
'</tbody></table>' +
|
||||
'<p style="margin: 0">김아라님, 안녕하세요.</p>' +
|
||||
'</blockquote></div>'
|
||||
|
||||
function draftWith(largeFiles: DraftLargeFile[]): Draft {
|
||||
return {
|
||||
id: 'd9',
|
||||
to: [{ name: '박준호', email: 'junho.park@arasystem.it.kr' }],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: '11월 현장 점검 자료',
|
||||
body: '자료 보냅니다.',
|
||||
bodyHtml: '<div><p>자료 보냅니다.</p></div>',
|
||||
attachments: [],
|
||||
inReplyTo: null,
|
||||
updatedAt: '2026-10-30T05:00:00Z',
|
||||
largeFiles,
|
||||
}
|
||||
}
|
||||
|
||||
function sharedFile(changes: Partial<MessageLargeFile>): MessageLargeFile {
|
||||
return {
|
||||
linkId: 'link1',
|
||||
fileId: 'f1',
|
||||
name: '설계도_v3.dwg',
|
||||
size: READY.size,
|
||||
status: 'available',
|
||||
expiresAt: '2026-11-29T14:59:59.999Z',
|
||||
revokedAt: null,
|
||||
url: URL1,
|
||||
downloads: null,
|
||||
...changes,
|
||||
}
|
||||
}
|
||||
|
||||
/** 원래 메일 — 서버가 첨부처럼 알아본 덩이(URL1, 파일 둘)와 인용 속 옛 링크(URL2) */
|
||||
const ORIGINAL: MessageDetail = {
|
||||
id: 'm1',
|
||||
threadId: 't1',
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.it.kr' },
|
||||
to: [{ name: '김아라', email: USER.email }],
|
||||
subject: '11월 현장 점검 자료',
|
||||
preview: '',
|
||||
receivedAt: '2026-10-30T01:42:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: true,
|
||||
size: 100,
|
||||
cc: [],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
sentAt: null,
|
||||
messageId: '<m1@arasystem.it.kr>',
|
||||
references: [],
|
||||
htmlBody: `<p>자료입니다.</p><div data-ara-large-files=""><a href="${URL1}">${URL1}</a></div><blockquote><a href="${URL2}">지난 자료</a></blockquote>`,
|
||||
textBody: '자료입니다.',
|
||||
hasBlockedImages: false,
|
||||
attachments: [{ id: 'att1', name: '현장사진.zip', type: 'application/zip', size: 3 * MB }],
|
||||
largeFiles: [sharedFile({}), sharedFile({ fileId: 'f2', name: '현장영상_1029.mp4', size: 612 * MB })],
|
||||
}
|
||||
|
||||
describe('ComposePage — 대용량 첨부', () => {
|
||||
let pinia: Pinia
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
beforeAll(installProseMirrorDom)
|
||||
// 편집기 모듈을 미리 불러 둔다 — 첫 변환 시간이 첫 테스트의 시간 한도에 잡히지 않게
|
||||
beforeAll(async () => {
|
||||
await import('@/components/mail/editor/MailEditor.vue')
|
||||
}, EDITOR_LOAD_TIMEOUT_MS)
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(NOW)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) {
|
||||
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
|
||||
}
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
vi.useRealTimers()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
/** 테스트마다 먼저 답하는 요청 — null 이면 server 가 답한다 */
|
||||
type ExtraHandler = (request: MockRequest) => MockResult | null | Promise<MockResult | null>
|
||||
|
||||
/** 화면이 늘 부르는 것 — 보관량 · 저장본 · 원본 메일. extra 가 먼저 답한다 */
|
||||
function server(options: { draft?: Draft; extra?: ExtraHandler } = {}): MockHandler {
|
||||
return async (request) => {
|
||||
const extra = await options.extra?.(request)
|
||||
if (extra) return extra
|
||||
const { method, url } = request
|
||||
if (method === 'GET' && url === '/file-quota') return ok(QUOTA)
|
||||
if (method === 'GET' && url === '/drafts/d9' && options.draft) return ok(options.draft)
|
||||
if (method === 'GET' && url === '/messages/m1') return ok(ORIGINAL)
|
||||
if (method === 'DELETE') return { status: 204 }
|
||||
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
}
|
||||
}
|
||||
|
||||
async function mountCompose(path: string, handler: MockHandler) {
|
||||
const requests = installMockApi(handler)
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(ComposePage, {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
plugins: [pinia, router],
|
||||
provide: { ...routeViewProvide(router), [CLOCK as symbol]: createManualClock(NOW) },
|
||||
},
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
if (!wrapper.find('.ProseMirror').exists()) throw new Error('편집기를 불러오는 중')
|
||||
},
|
||||
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
|
||||
)
|
||||
await flushPromises()
|
||||
return { wrapper, requests }
|
||||
}
|
||||
|
||||
const phases = (wrapper: VueWrapper) =>
|
||||
wrapper.findAll('[data-large-upload]').map((row) => row.attributes('data-phase'))
|
||||
const lineOf = (wrapper: VueWrapper, phase: string) =>
|
||||
wrapper.get(`[data-phase="${phase}"] .large-upload__line-text`).text()
|
||||
const calls = (requests: MockRequest[]) => requests.map((request) => `${request.method} ${request.url}`)
|
||||
|
||||
it('저장본의 대용량 파일 — 다 올린 것 · 올리다 멈춘 것 · 지운 것을 줄로, 빼야 할 것이 남은 동안 [보내기]는 까닭과 함께 막힌다', async () => {
|
||||
const { wrapper, requests } = await mountCompose(
|
||||
'/mail/compose?draft=d9',
|
||||
server({ draft: draftWith([READY, STOPPED, DELETED]) }),
|
||||
)
|
||||
|
||||
expect(phases(wrapper)).toEqual(['ready', 'waiting-file', 'missing'])
|
||||
expect(lineOf(wrapper, 'ready')).toBe('1.2 GB · 이 임시 저장본에 11월 25일까지 남습니다')
|
||||
expect(lineOf(wrapper, 'waiting-file')).toContain('올리다 멈춘 파일 — 같은 파일을 다시 고르면 이어서 올립니다')
|
||||
expect(wrapper.find('[data-phase="waiting-file"] [data-large-repick]').exists()).toBe(true)
|
||||
expect(lineOf(wrapper, 'missing')).toBe('설정 › 대용량 첨부에서 지운 파일입니다 — 빼고 다시 올려 주세요.')
|
||||
|
||||
const send = () => wrapper.get('.compose__send')
|
||||
expect(send().attributes('aria-disabled')).toBe('true')
|
||||
expect(wrapper.get('#compose-send-why').text()).toBe(SEND_BLOCK_TEXT.unsendable)
|
||||
|
||||
// 지운 파일을 빼면 — 서버에 지울 것이 없다. 남은 까닭은 다 올리지 못한 파일
|
||||
await wrapper.get('[data-phase="missing"] [data-large-remove]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('#compose-send-why').text()).toBe(SEND_BLOCK_TEXT.incomplete)
|
||||
expect(calls(requests)).not.toContain('DELETE /file-uploads/lf-3')
|
||||
|
||||
// 멈춘 파일을 빼면 서버의 올리기도 지운다 — 막을 까닭이 없어진다
|
||||
await wrapper.get('[data-phase="waiting-file"] [data-large-remove]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(calls(requests)).toContain('DELETE /file-uploads/lf-2')
|
||||
expect(phases(wrapper)).toEqual(['ready'])
|
||||
expect(wrapper.find('#compose-send-why').exists()).toBe(false)
|
||||
expect(send().attributes('aria-disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('보내기가 MAIL_016 이면 파일마다 다시 묻고, 찾을 수 없는 파일은 빨간 줄 · [보내기]를 막는다 (알림 없음)', async () => {
|
||||
const { wrapper, requests } = await mountCompose(
|
||||
'/mail/compose?draft=d9',
|
||||
server({
|
||||
draft: draftWith([READY]),
|
||||
extra: ({ method, url }) => {
|
||||
if (method === 'POST' && url === '/messages') return fail(400, 'MAIL_016')
|
||||
if (method === 'GET' && url === '/file-uploads/lf-1') return fail(404, 'RES_001')
|
||||
return null
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
const sent = requests.find((request) => request.method === 'POST' && request.url === '/messages')
|
||||
expect((sent?.data as Record<string, unknown> | undefined)?.largeFileIds).toEqual(['lf-1'])
|
||||
expect(calls(requests)).toContain('GET /file-uploads/lf-1')
|
||||
expect(phases(wrapper)).toEqual(['missing'])
|
||||
expect(lineOf(wrapper, 'missing')).toBe('서버에서 찾을 수 없는 파일입니다 — 빼고 다시 올려 주세요.')
|
||||
expect(wrapper.get('.compose__send').attributes('aria-disabled')).toBe('true')
|
||||
expect(wrapper.get('#compose-send-why').text()).toBe(SEND_BLOCK_TEXT.unsendable)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('전달 — 인용 속 원래 대용량 첨부 링크의 상태를 묻고 첨부 칸 아래 글자로만 알린다 · 원본 첨부에는 「링크로 보내기」가 없다', async () => {
|
||||
const { wrapper, requests } = await mountCompose(
|
||||
'/mail/compose?forward=m1',
|
||||
server({
|
||||
extra: ({ method, url }) =>
|
||||
method === 'POST' && url === '/share-link-lookups'
|
||||
? ok({
|
||||
links: [
|
||||
{ url: URL1, status: 'active', expiresAt: '2026-11-29T14:59:59.999Z' },
|
||||
{ url: URL2, status: 'revoked', expiresAt: '2026-10-29T14:59:59.999Z' },
|
||||
],
|
||||
})
|
||||
: null,
|
||||
}),
|
||||
)
|
||||
|
||||
const lookup = requests.find((request) => request.url === '/share-link-lookups')
|
||||
expect(lookup?.data).toEqual({ urls: [URL1, URL2] })
|
||||
expect(wrapper.findAll('[data-quoted-link]').map((notice) => notice.text())).toEqual([
|
||||
'원래 메일의 대용량 첨부 2개는 인용 속 링크로 함께 갑니다 — 11월 29일까지',
|
||||
'인용 속 대용량 첨부 링크는 공유가 멈춰 받을 수 없습니다.',
|
||||
])
|
||||
// 원래 메일의 대용량 첨부는 다시 싣지 않는다 — 링크 묶음이 없다
|
||||
expect(wrapper.find('[data-large-upload]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-send-as-link]').exists()).toBe(false)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('「링크로 보내기」 — 이 화면에서 고른 파일을 메일에서 빼고 링크로 올린다 · 올리는 동안 [보내기]는 까닭과 함께 막힌다', async () => {
|
||||
let releaseChunk: () => void = () => undefined
|
||||
const chunkGate = new Promise<void>((resolve) => {
|
||||
releaseChunk = resolve
|
||||
})
|
||||
const large = (status: 'uploading' | 'ready', received: number[]): LargeFile => ({
|
||||
...READY,
|
||||
id: 'lf-9',
|
||||
name: '회의록.pdf',
|
||||
size: 2 * MB,
|
||||
status,
|
||||
chunkCount: 1,
|
||||
receivedChunks: received,
|
||||
firstChunkSha256: null,
|
||||
scanStatus: status === 'ready' ? 'pending' : null,
|
||||
})
|
||||
const { wrapper, requests } = await mountCompose(
|
||||
'/mail/compose',
|
||||
server({
|
||||
extra: async ({ method, url }) => {
|
||||
if (method === 'POST' && url === '/mail-attachments') {
|
||||
return ok({ id: 'up1', name: '회의록.pdf', type: 'application/pdf', size: 2 * MB }, 201)
|
||||
}
|
||||
if (method === 'POST' && url === '/file-uploads') return ok(large('uploading', []), 201)
|
||||
if (method === 'PUT' && url === '/file-uploads/lf-9/chunks/0') {
|
||||
await chunkGate
|
||||
return ok(large('uploading', [0]))
|
||||
}
|
||||
if (method === 'POST' && url === '/file-uploads/lf-9/completion') return ok(large('ready', [0]))
|
||||
return null
|
||||
},
|
||||
}),
|
||||
)
|
||||
const picked = new File(['x'], '회의록.pdf', { lastModified: 0 })
|
||||
Object.defineProperty(picked, 'size', { value: 2 * MB })
|
||||
const picker = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(picker.element, 'files', { value: [picked], configurable: true })
|
||||
await picker.trigger('change')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('.upload-item')).toHaveLength(1)
|
||||
await wrapper.get('[data-send-as-link]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.upload-item').exists()).toBe(false)
|
||||
expect(phases(wrapper)).toEqual(['uploading'])
|
||||
expect(calls(requests)).toContain('POST /file-uploads')
|
||||
expect(wrapper.get('.compose__send').attributes('aria-disabled')).toBe('true')
|
||||
expect(wrapper.get('#compose-send-why').text()).toBe(SEND_BLOCK_TEXT.uploading('1'))
|
||||
|
||||
releaseChunk()
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
expect(phases(wrapper)).toEqual(['ready'])
|
||||
expect(lineOf(wrapper, 'ready')).toBe('2 MB · 보안 검사 중 — 끝나지 않아도 보낼 수 있습니다')
|
||||
expect(wrapper.find('#compose-send-why').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('표시 없는 저장본 HTML 로 연 답장도 원문 인용을 알아본다 — 대용량 파일을 실으면 경계 표시와 bodyQuoteStart 를 보낸다 (리뷰 M1)', async () => {
|
||||
const draft: Draft = { ...draftWith([READY]), inReplyTo: 'm1', subject: 'Re: 11월 현장 점검 자료', bodyHtml: SANITIZED_REPLY_HTML }
|
||||
const { wrapper, requests } = await mountCompose(
|
||||
'/mail/compose?draft=d9',
|
||||
server({
|
||||
draft,
|
||||
extra: ({ method, url }) => (method === 'POST' && url === '/messages' ? ok({ id: 'sent-1' }, 201) : null),
|
||||
}),
|
||||
)
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
const payload = requests.find((request) => request.method === 'POST' && request.url === '/messages')
|
||||
?.data as Record<string, unknown> | undefined
|
||||
expect(payload?.largeFileIds).toEqual(['lf-1'])
|
||||
const bodyHtml = String(payload?.bodyHtml)
|
||||
// 표시는 하나, 원문 인용 바로 앞 — 서버가 그 자리에 덩이를 넣는다
|
||||
expect(bodyHtml.split('data-ara-quote-start').length - 1).toBe(1)
|
||||
expect(bodyHtml).toContain('<div data-ara-quote-start=""></div><blockquote style="margin: 24px 0 0; ')
|
||||
// 텍스트의 자리는 인용 첫 줄의 처음 ("> 원본 메일")
|
||||
const body = String(payload?.body)
|
||||
const quoteStart = payload?.bodyQuoteStart
|
||||
expect(typeof quoteStart).toBe('number')
|
||||
expect(body.slice(Number(quoteStart)).startsWith('> 원본 메일')).toBe(true)
|
||||
expect(body.slice(0, Number(quoteStart))).toContain('확인했습니다.')
|
||||
})
|
||||
|
||||
it('대용량 파일이 없는 답장 — 보내기 · 임시 저장 본문에 경계 표시 · largeFileIds · bodyQuoteStart 가 없다', async () => {
|
||||
const { wrapper, requests } = await mountCompose(
|
||||
'/mail/compose?replyTo=m1',
|
||||
server({
|
||||
extra: ({ method, url }) => {
|
||||
if (method === 'POST' && url === '/share-link-lookups') return ok({ links: [] })
|
||||
if (method === 'POST' && url === '/drafts') return ok({ id: 'd1', attachments: [], updatedAt: '2026-10-30T05:16:00Z' }, 201)
|
||||
if (method === 'POST' && url === '/messages') return ok({ id: 'sent-1' }, 201)
|
||||
return null
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
await wrapper.get('#compose-save-draft').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
for (const url of ['/drafts', '/messages']) {
|
||||
const payload = requests.find((request) => request.method === 'POST' && request.url === url)?.data as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
expect(payload, url).toBeDefined()
|
||||
expect(payload, url).not.toHaveProperty('largeFileIds')
|
||||
expect(payload, url).not.toHaveProperty('bodyQuoteStart')
|
||||
const bodyHtml = String(payload?.bodyHtml)
|
||||
expect(bodyHtml, url).not.toContain('data-ara-quote-start')
|
||||
expect(bodyHtml, url).not.toContain('data-')
|
||||
// 원문 인용은 그대로 — 표시만 없다
|
||||
expect(bodyHtml, url).toContain('<blockquote style="margin: 24px 0 0; ')
|
||||
}
|
||||
})
|
||||
|
||||
it('「링크로 보내기」는 링크로 올리기 전 검사를 먼저 본다 — 보관 한도에 걸리면 메일에 담은 채 두고 까닭을 알린다 (리뷰 L3)', async () => {
|
||||
const nearlyFull: FileQuota = { ...QUOTA, usedBytes: QUOTA.limitBytes - MB }
|
||||
const { wrapper, requests } = await mountCompose(
|
||||
'/mail/compose',
|
||||
server({
|
||||
extra: ({ method, url }) => {
|
||||
if (method === 'GET' && url === '/file-quota') return ok(nearlyFull)
|
||||
if (method === 'POST' && url === '/mail-attachments') {
|
||||
return ok({ id: 'up1', name: '회의록.pdf', type: 'application/pdf', size: 2 * MB }, 201)
|
||||
}
|
||||
return null
|
||||
},
|
||||
}),
|
||||
)
|
||||
const picked = new File(['x'], '회의록.pdf', { lastModified: 0 })
|
||||
Object.defineProperty(picked, 'size', { value: 2 * MB })
|
||||
const picker = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(picker.element, 'files', { value: [picked], configurable: true })
|
||||
await picker.trigger('change')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-send-as-link]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('.upload-item')).toHaveLength(1)
|
||||
expect(wrapper.find('[data-large-upload]').exists()).toBe(false)
|
||||
expect(calls(requests)).not.toContain('POST /file-uploads')
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(toast?.message).toBe(LARGE_FILE_TEXT.overQuota('10GB'))
|
||||
expect(toast?.action).toMatchObject({ label: LARGE_FILE_TEXT.quotaLink, to: { name: 'settings-mail-large-files' } })
|
||||
})
|
||||
})
|
||||
@@ -469,13 +469,13 @@ describe('ComposePage', () => {
|
||||
expect(item.classes()).toContain('upload-item--failed')
|
||||
expect(item.get('.upload-item__error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_004)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
expect(wrapper.get('.compose__send').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('.compose__send').attributes('aria-disabled')).toBe('true')
|
||||
const removeButton = wrapper.get('button[aria-label="체크리스트.xlsx 첨부 취소"]')
|
||||
expect(document.activeElement).toBe(removeButton.element)
|
||||
|
||||
// 찾지 못한 첨부를 빼면 다시 보낼 수 있다
|
||||
await removeButton.trigger('click')
|
||||
expect(wrapper.get('.compose__send').attributes('disabled')).toBeUndefined()
|
||||
expect(wrapper.get('.compose__send').attributes('aria-disabled')).toBeUndefined()
|
||||
expect(requests.filter((request) => request.url === '/messages' && request.method === 'POST')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -538,7 +538,7 @@ describe('ComposePage', () => {
|
||||
const { wrapper } = await mountCompose('/mail/compose', replyApi)
|
||||
|
||||
expect(wrapper.get('.compose__attach-hint').text()).toBe(
|
||||
'파일당 최대 10MB · 합계 30MB · 최대 20개 · 문서, 이미지, 압축 파일',
|
||||
'파일당 최대 10MB · 합계 30MB · 최대 20개 · 더 큰 파일은 링크로 보냅니다 (파일당 2GB · 10개까지)',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -547,11 +547,11 @@ describe('ComposePage', () => {
|
||||
|
||||
expect(useMailStore().quota).toBeNull()
|
||||
expect(wrapper.get('.compose__attach-hint').text()).toBe(
|
||||
'파일당 최대 20MB · 합계 25MB · 최대 20개 · 문서, 이미지, 압축 파일',
|
||||
'파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 링크로 보냅니다 (파일당 2GB · 10개까지)',
|
||||
)
|
||||
})
|
||||
|
||||
it('첨부 합계는 서버 한도(attachmentTotalMaxBytes)로 붙일 때 막는다 — 전달 원본도 세고, 넘는 파일은 올리지 않고 빼고 보낸다', async () => {
|
||||
it('첨부 합계는 서버 한도(attachmentTotalMaxBytes)로 센다 — 전달 원본도 세고, 넘게 만드는 파일은 링크로 보낸다 (11단계)', async () => {
|
||||
const MB = 1024 * 1024
|
||||
useMailStore().quota = {
|
||||
usedBytes: 0,
|
||||
@@ -560,21 +560,39 @@ describe('ComposePage', () => {
|
||||
attachmentTotalMaxBytes: 15 * MB,
|
||||
}
|
||||
const PHOTOS = { id: 'att1', name: '현장사진.zip', type: 'application/zip', size: 12 * MB }
|
||||
/** 대용량 파일 — 한 조각(16MiB)이라 조각 하나 뒤 끝낸다 */
|
||||
const largeFile = (status: 'uploading' | 'ready', received: number[]) => ({
|
||||
id: 'lf1',
|
||||
name: '도면.pdf',
|
||||
size: 5 * MB,
|
||||
status,
|
||||
chunkSize: 16 * MB,
|
||||
chunkCount: 1,
|
||||
receivedChunks: received,
|
||||
firstChunkSha256: null,
|
||||
scanStatus: status === 'ready' ? 'unscannable' : null,
|
||||
createdAt: '2026-10-30T05:00:00Z',
|
||||
expiresAt: '2026-10-31T05:00:00Z',
|
||||
lastModified: 0,
|
||||
})
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose?forward=m1', ({ method, url }) => {
|
||||
if (method === 'GET' && url === '/messages/m1') return ok({ ...ORIGINAL, attachments: [PHOTOS] })
|
||||
if (method === 'POST' && url === '/mail-attachments') {
|
||||
return ok({ id: 'up1', name: '회의록.pdf', type: 'application/pdf', size: 2 * MB }, 201)
|
||||
}
|
||||
if (method === 'POST' && url === '/file-uploads') return ok(largeFile('uploading', []), 201)
|
||||
if (method === 'PUT' && url === '/file-uploads/lf1/chunks/0') return ok(largeFile('uploading', [0]))
|
||||
if (method === 'POST' && url === '/file-uploads/lf1/completion') return ok(largeFile('ready', [0]))
|
||||
if (method === 'POST' && url === '/messages') return ok({ id: 'sent-1' }, 201)
|
||||
return ok({})
|
||||
})
|
||||
const sized = (name: string, size: number) => {
|
||||
const chosen = new File(['x'], name)
|
||||
const chosen = new File(['x'], name, { lastModified: 0 })
|
||||
Object.defineProperty(chosen, 'size', { value: size })
|
||||
return chosen
|
||||
}
|
||||
|
||||
// 원본 12MB + 회의록 2MB = 14MB 는 통과, 도면 5MB 를 더하면 19MB 라 막힌다
|
||||
// 원본 12MB + 회의록 2MB = 14MB 는 메일에 담고, 도면 5MB 를 더하면 19MB 라 링크로 보낸다
|
||||
const picker = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(picker.element, 'files', {
|
||||
value: [sized('회의록.pdf', 2 * MB), sized('도면.pdf', 5 * MB)],
|
||||
@@ -582,14 +600,15 @@ describe('ComposePage', () => {
|
||||
})
|
||||
await picker.trigger('change')
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('.upload-item').map((item) => item.classes())).toEqual([
|
||||
expect.arrayContaining(['upload-item--done']),
|
||||
expect.arrayContaining(['upload-item--done']),
|
||||
expect.arrayContaining(['upload-item--failed']),
|
||||
])
|
||||
expect(wrapper.get('.upload-item--failed .upload-item__error').text()).toBe(
|
||||
ERROR_CODE_MESSAGES.FILE_002,
|
||||
expect(wrapper.get('[data-large-upload]').text()).toContain('도면.pdf')
|
||||
expect(wrapper.get('[data-large-switch]').text()).toBe(
|
||||
'‘도면.pdf’(5 MB)는 메일에 담으면 합계가 15MB 를 넘어 링크로 보냅니다.',
|
||||
)
|
||||
expect(requests.filter((request) => request.url === '/mail-attachments')).toHaveLength(1)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
@@ -605,6 +624,10 @@ describe('ComposePage', () => {
|
||||
{ id: 'att1', name: '현장사진.zip' },
|
||||
{ id: 'up1', name: '회의록.pdf' },
|
||||
])
|
||||
expect(payload?.largeFileIds).toEqual(['lf1'])
|
||||
// 덩이 자리 — 원문 인용 바로 앞의 표시와 텍스트의 인용 시작 자리
|
||||
expect(payload?.bodyHtml).toContain('<div data-ara-quote-start=""></div><blockquote')
|
||||
expect(typeof payload?.bodyQuoteStart).toBe('number')
|
||||
})
|
||||
|
||||
it('보내기 버튼은 submit 이고, 제목에서 Enter 를 눌러도 보내지 않는다', async () => {
|
||||
|
||||
@@ -40,6 +40,15 @@
|
||||
// [예약]은 보내기와 같은 흐름(임시 저장 기다리기 · 길이 검사 · 같은 발송 키)에 sendAt 을 더해 보낸다 → "10월 30일 (금) 오전 9:00 에 보냅니다." + [예약됨 보기].
|
||||
// 서버가 그 시각을 받지 않으면(VAL_001 — 그사이 5분 안이 됐거나 시계가 어긋남) 알림 대신 예약 창을 다시 열고 고르기 아래에 까닭
|
||||
// ("지금부터 5분 뒤 이후의 시각을 고르세요." · "7일 안의 시각만 고를 수 있습니다." — 이 화면의 검사로 고른다, schedule-send.ts 의 rejectedScheduleProblem)
|
||||
// 대용량 첨부 (11단계 — 계약 4절 "대용량 첨부" · 디자인 README "11단계 › 쓰기 화면 — 첨부 칸") — 잇는 일은 useComposeLargeFiles:
|
||||
// - 고른 파일이 20MB 를 넘거나, 메일에 담은 파일의 합계를 25MB 넘게 만들거나, 메일에 담지 못하는 형식(mp4 · dwg 등)이면 저절로 링크로 보낸다.
|
||||
// 이 화면에서 고른 일반 첨부는 「링크로 보내기」로 옮길 수 있다(링크로 올리기 전 검사를 통과할 때만).
|
||||
// - 링크 파일은 조각으로 올린다(useLargeFileUploads). 올리는 동안 · 다 올리지 못한 파일 · 빼야 하는 파일이 있으면 [보내기]를 aria-disabled 로 막고
|
||||
// 단추 옆에 까닭(sendBlock). 임시 저장은 그대로 된다 — 올리는 중 · 차례를 기다리는 파일도 저장본에 실린다(새로고침 뒤 같은 파일을 고르면 잇는다).
|
||||
// 지운 · 기간이 지난 파일이 남은 동안은 저장하지 않는다(서버가 MAIL_016). 보내기 · 저장이 MAIL_016 이면 파일마다 상태를 다시 묻는다.
|
||||
// - 보낼 때 largeFileIds 와 덩이 자리(서식 본문의 인용 경계 표시 · bodyQuoteStart)를 싣는다 — 대용량 파일이 없으면 표시를 지우고 보낸다(bodyFields).
|
||||
// 서버가 덩이를 넣은 뒤 다시 재므로 보내기 직전에 그만큼 텍스트 한도를 남긴다(sendReserve).
|
||||
// - 나가기 확인은 올리는 중이면 "올리는 중인 파일이 있습니다". 답장 · 전달의 인용 속 원래 링크는 상태만 물어 칸 아래 글자로 알린다(share-links.ts).
|
||||
// 고정 id — 화면 검증 · 테스트가 쓴다: 임시 저장 #compose-save-draft · 상태 #compose-draft-status(ComposeDraftStatus) ·
|
||||
// 휴지통 #compose-discard · 닫기 .compose__close
|
||||
import {
|
||||
@@ -56,7 +65,7 @@ import { useRoute, useRouter, type LocationQuery } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ComposeAlert from '@/components/mail/ComposeAlert.vue'
|
||||
import ComposeAttachmentList from '@/components/mail/ComposeAttachmentList.vue'
|
||||
import ComposeAttachmentGroups from '@/components/mail/ComposeAttachmentGroups.vue'
|
||||
import ComposeEditorLoadError from '@/components/mail/ComposeEditorLoadError.vue'
|
||||
import ComposeFooter from '@/components/mail/ComposeFooter.vue'
|
||||
import ComposeHeader from '@/components/mail/ComposeHeader.vue'
|
||||
@@ -73,6 +82,7 @@ import { getErrorMessage, notifyApiError, toApiError } from '@/composables/useAp
|
||||
import { useAttachmentUploads, type UploadItem } from '@/composables/useAttachmentUploads'
|
||||
import { useClock } from '@/composables/useClock'
|
||||
import { useComposeDraftLink } from '@/composables/useComposeDraftLink'
|
||||
import { useComposeLargeFiles } from '@/composables/useComposeLargeFiles'
|
||||
import { useDraftSaving, type DraftSaveRejected, type DraftSaveRequest } from '@/composables/useDraftSaving'
|
||||
import { useDraftTrash } from '@/composables/useDraftTrash'
|
||||
import { useLeaveConfirm } from '@/composables/useLeaveConfirm'
|
||||
@@ -81,12 +91,11 @@ import { RECIPIENT_SUGGESTIONS, useRecipientSuggestions } from '@/composables/us
|
||||
import { useScheduledMessageActions } from '@/composables/useScheduledMessageActions'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useWindowFileDrop } from '@/composables/useWindowFileDrop'
|
||||
import { COMPOSE_ATTACHMENT_ACCEPT } from '@/shared/constants/large-file'
|
||||
import {
|
||||
COMPOSE_LOADING_TITLE,
|
||||
COMPOSE_TITLES,
|
||||
DRAFT_LEAVE_SAVE_WAIT_MS,
|
||||
MAIL_ATTACHMENT_ACCEPT,
|
||||
MAIL_MAX_ATTACHMENTS,
|
||||
MAIL_MAX_RECIPIENTS,
|
||||
MAIL_PARTIAL_SEND_TEXT,
|
||||
MAIL_SENT_ACTION_LABEL,
|
||||
@@ -119,7 +128,6 @@ import {
|
||||
tokenFromAddress,
|
||||
type RecipientToken,
|
||||
} from '@/shared/utils/mail-address'
|
||||
import { attachmentHint } from '@/shared/utils/mail-attachment'
|
||||
import { buildForwardDraft, isForwardSubject } from '@/shared/utils/mail-forward'
|
||||
import { textToParagraphHtml } from '@/shared/utils/mail-quote'
|
||||
import { buildReplyDraft } from '@/shared/utils/mail-reply'
|
||||
@@ -171,22 +179,46 @@ const draftTrash = useDraftTrash()
|
||||
const scheduledActions = useScheduledMessageActions()
|
||||
const toast = useToast()
|
||||
const clock = useClock()
|
||||
const {
|
||||
items: uploadItems,
|
||||
isUploading,
|
||||
hasMissing,
|
||||
addFiles,
|
||||
addExisting,
|
||||
applySavedAttachments,
|
||||
markMissing,
|
||||
remove: removeUpload,
|
||||
reset: resetUploads,
|
||||
} = useAttachmentUploads({
|
||||
const normalUploads = useAttachmentUploads({
|
||||
// 한도는 서버 값(GET /mail-quota 의 파일당 attachmentMaxBytes · 합계 attachmentTotalMaxBytes)
|
||||
// — 못 받았으면 스토어가 대체값을 준다
|
||||
maxBytes: () => mailStore.attachmentMaxBytes,
|
||||
totalMaxBytes: () => mailStore.attachmentTotalMaxBytes,
|
||||
})
|
||||
const {
|
||||
items: uploadItems,
|
||||
isUploading,
|
||||
hasMissing,
|
||||
addExisting,
|
||||
applySavedAttachments,
|
||||
markMissing,
|
||||
remove: removeUpload,
|
||||
reset: resetUploads,
|
||||
} = normalUploads
|
||||
/** 대용량 첨부(링크 — 11단계) — 고른 파일 가르기 · 칸 아래 글 · 보내기를 막는 까닭 · 본문 필드 (useComposeLargeFiles) */
|
||||
const {
|
||||
uploads: largeUploads,
|
||||
quotedNotices,
|
||||
hint: attachmentHintText,
|
||||
normalTotalText,
|
||||
linkGroupHint,
|
||||
quotaWarning: largeQuotaWarning,
|
||||
switchNotice,
|
||||
sendBlock,
|
||||
bodyFields,
|
||||
sendReserve,
|
||||
attachFiles,
|
||||
isLinkable,
|
||||
sendAsLink,
|
||||
lookupQuotedLinks,
|
||||
reset: resetLargeFiles,
|
||||
} = useComposeLargeFiles({ clock, normal: normalUploads })
|
||||
const {
|
||||
items: largeItems,
|
||||
isUploading: largeUploading,
|
||||
hasMissing: hasMissingLarge,
|
||||
trackedIds: trackedLargeFileIds,
|
||||
} = largeUploads
|
||||
|
||||
const to = ref<RecipientToken[]>([])
|
||||
const cc = ref<RecipientToken[]>([])
|
||||
@@ -254,8 +286,11 @@ let formGeneration = 0
|
||||
* 보내기를 누르고 바로 떠나도 진행 중이던 저장을 기다려 새 draftId · 첨부 id 로 보낸다 (그사이 다른 메일로 바뀌었으면 보내지 않는다)
|
||||
*/
|
||||
let resetCount = 0
|
||||
/** 보내기 결과를 이 화면이 칸 안에서 직접 안내하는 코드 — 인터셉터 알림을 끈다 (양식이 사라졌으면 토스트로 대신 알린다) */
|
||||
const SEND_INLINE_CODES: readonly ErrorCode[] = ['MAIL_002', 'MAIL_010', 'MAIL_004', 'MAIL_007']
|
||||
/**
|
||||
* 보내기 결과를 이 화면이 칸 안에서 직접 안내하는 코드 — 인터셉터 알림을 끈다 (양식이 사라졌으면 토스트로 대신 알린다).
|
||||
* MAIL_016(대용량 파일을 쓸 수 없음 — 11단계)은 파일마다 상태를 다시 물어 빨간 줄로 보인다
|
||||
*/
|
||||
const SEND_INLINE_CODES: readonly ErrorCode[] = ['MAIL_002', 'MAIL_010', 'MAIL_004', 'MAIL_007', 'MAIL_016']
|
||||
/** 예약 발송에서 이 화면이 직접 안내하는 코드 — 위에 더해 VAL_001(서버가 그 시각을 받지 않음 — 예약 창을 다시 연다) */
|
||||
const SCHEDULE_INLINE_CODES: readonly ErrorCode[] = [...SEND_INLINE_CODES, 'VAL_001']
|
||||
/**
|
||||
@@ -273,7 +308,7 @@ const editorRef = ref<MailEditorHandle | null>(null)
|
||||
const subjectInputRef = ref<HTMLInputElement | null>(null)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const attachButtonRef = ref<HTMLButtonElement | null>(null)
|
||||
const attachmentListRef = ref<InstanceType<typeof ComposeAttachmentList> | null>(null)
|
||||
const attachmentListRef = ref<InstanceType<typeof ComposeAttachmentGroups> | null>(null)
|
||||
|
||||
function queryId(value: unknown): string | null {
|
||||
return typeof value === 'string' && value !== '' ? value : null
|
||||
@@ -304,9 +339,6 @@ const source = computed<ComposeSource>(() => sourceOf(route.query))
|
||||
const title = computed(() =>
|
||||
source.value === 'draft' && preparing.value ? COMPOSE_LOADING_TITLE : COMPOSE_TITLES[composeKind.value],
|
||||
)
|
||||
const attachmentHintText = computed(() =>
|
||||
attachmentHint(mailStore.attachmentMaxBytes, mailStore.attachmentTotalMaxBytes, MAIL_MAX_ATTACHMENTS),
|
||||
)
|
||||
/** 쓰기를 마치거나 닫으면 보던 화면으로 — 처음부터 쓰기로 들어왔으면 받은편지함 */
|
||||
const backTo = computed(() => mailStore.lastBrowseLocation ?? { name: 'mail' })
|
||||
const sender = computed(() => (authStore.user ? formatAddress(authStore.user) : ''))
|
||||
@@ -331,17 +363,12 @@ const recipientErrorMessage = computed(() =>
|
||||
const subjectErrorMessage = getErrorMessage('VAL_001')
|
||||
/** 본문이 너무 길면 서버는 MAIL_007 로 거절한다 — 보내기 전에 막을 때(내 글만으로 넘음)와 서버가 거절했을 때 모두 이 문구 */
|
||||
const bodyErrorMessage = getErrorMessage('MAIL_007')
|
||||
/** 서버가 찾지 못한 첨부(MAIL_004)가 남아 있으면 보내지 않는다 — 모르는 사이 첨부 없이 나가지 않게 */
|
||||
/** 보낼 준비 — 편집기를 불러왔고 채우는 · 보내는 · 버리는 중이 아니다 (첨부 때문에 막힌 것은 sendBlock — useComposeLargeFiles) */
|
||||
const canSend = computed(
|
||||
() =>
|
||||
editorReady.value &&
|
||||
!preparing.value &&
|
||||
!bodyPending.value &&
|
||||
!sending.value &&
|
||||
!discarding.value &&
|
||||
!isUploading.value &&
|
||||
!hasMissing.value,
|
||||
() => editorReady.value && !preparing.value && !bodyPending.value && !sending.value && !discarding.value,
|
||||
)
|
||||
/** 지금 보낼 수 있다 — 준비됐고 막는 까닭이 없다 */
|
||||
const sendable = computed(() => canSend.value && sendBlock.value === null)
|
||||
/**
|
||||
* 임시 저장할 수 있는 때 — 편집기가 준비됐고, 보내거나 버리는 중 · 보낸 뒤 · 버린 뒤가 아니고, 로그인 중.
|
||||
* 서버가 찾지 못한 첨부(MAIL_004)가 남아 있으면 저장하지 않는다 — 어느 첨부인지 몰라 모두 "찾지 못함"으로 바꿨으므로
|
||||
@@ -357,6 +384,7 @@ const canSaveDraft = computed(
|
||||
!discarded.value &&
|
||||
!sent.value &&
|
||||
!hasMissing.value &&
|
||||
!hasMissingLarge.value &&
|
||||
authStore.isLoggedIn,
|
||||
)
|
||||
|
||||
@@ -365,7 +393,7 @@ function savedAttachmentItems(): UploadItem[] {
|
||||
return uploadItems.value.filter((item) => item.status === 'done' && item.attachment !== null)
|
||||
}
|
||||
|
||||
/** 지금 양식(받는 사람 · 제목 · 저장할 첨부) — 저장한 양식(savedForm)과 견준다 */
|
||||
/** 지금 양식(받는 사람 · 제목 · 저장할 첨부 · 대용량 파일) — 저장한 양식(savedForm)과 견준다 */
|
||||
function snapshotForm(): ComposeFormSnapshot {
|
||||
return snapshotComposeForm({
|
||||
to: to.value,
|
||||
@@ -373,6 +401,7 @@ function snapshotForm(): ComposeFormSnapshot {
|
||||
bcc: bcc.value,
|
||||
subject: subject.value,
|
||||
attachments: savedAttachmentItems(),
|
||||
largeFiles: trackedLargeFileIds.value,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -392,7 +421,12 @@ const hasUnsavedChanges = computed(
|
||||
() =>
|
||||
!sent.value &&
|
||||
!discarded.value &&
|
||||
(hasDraftChanges.value || typingRecipient.value || isUploading.value || hasMissing.value),
|
||||
(hasDraftChanges.value ||
|
||||
typingRecipient.value ||
|
||||
isUploading.value ||
|
||||
hasMissing.value ||
|
||||
largeUploading.value ||
|
||||
hasMissingLarge.value),
|
||||
)
|
||||
|
||||
function addressesOf(tokens: readonly RecipientToken[]) {
|
||||
@@ -400,15 +434,15 @@ function addressesOf(tokens: readonly RecipientToken[]) {
|
||||
return tokens.filter((token) => token.valid).map((token) => token.address)
|
||||
}
|
||||
|
||||
/** 임시 저장 본문 — 발송 본문과 같은 필드 (받는 사람 0명도 된다 · 제목은 쓴 그대로) */
|
||||
/** 임시 저장 본문 — 발송 본문과 같은 필드 (받는 사람 0명도 된다 · 제목은 쓴 그대로 · 올리는 중인 대용량 파일도 싣는다) */
|
||||
function draftPayloadOf(content: MailEditorContent, attachments: readonly UploadItem[]): DraftPayload {
|
||||
return {
|
||||
to: addressesOf(to.value),
|
||||
cc: addressesOf(cc.value),
|
||||
bcc: addressesOf(bcc.value),
|
||||
subject: subject.value,
|
||||
body: content.text,
|
||||
...(content.html ? { bodyHtml: content.html } : {}),
|
||||
// 대용량 파일이 있으면 그 id 와 덩이 자리를 싣는다 — 없으면 인용 경계 표시를 지운다 (계약 4절)
|
||||
...bodyFields(content),
|
||||
attachments: attachments.flatMap((item) =>
|
||||
item.attachment ? [{ id: item.attachment.id, name: item.attachment.name }] : [],
|
||||
),
|
||||
@@ -464,6 +498,8 @@ const drafts = useDraftSaving({
|
||||
onFailed: (error, trigger) => {
|
||||
// 저장본에 실을 첨부를 서버가 찾지 못했다 — 보내기 때와 같이 표시한다 (빼거나 다시 올리게)
|
||||
if (error.code === 'MAIL_004') markMissing()
|
||||
// 실을 대용량 파일을 쓸 수 없다(지움 · 기간 지남) — 파일마다 다시 물어 빨간 줄로 (빼면 다시 저장한다)
|
||||
if (error.code === 'MAIL_016') void largeUploads.recheck()
|
||||
// 본문이 너무 길다 — 보내기와 같은 띠를 본문 아래에 (「임시 저장」을 눌렀으면 본문으로 초점도. 자동 저장은 초점을 뺏지 않는다)
|
||||
if (error.code === 'MAIL_007') {
|
||||
if (trigger === 'manual') showBodyTooLong()
|
||||
@@ -582,6 +618,8 @@ function resetForm(next: ComposeSource, sourceId: string | null) {
|
||||
rejectedByServer.value = null
|
||||
lengthError.value = null
|
||||
resetUploads()
|
||||
// 대용량 첨부 — 올리던 것을 멈춘다(서버의 올리기는 저장본이 이어 쓴다). 한도 · 사용량은 새로 받는다
|
||||
resetLargeFiles()
|
||||
// 양식을 새로 채우면 다른 메일이다 — 앞 양식에서 보내던 요청은 끝나도 이 양식을 건드리지 않는다 (formGeneration)
|
||||
formGeneration += 1
|
||||
resetCount += 1
|
||||
@@ -647,6 +685,8 @@ async function prepareFrom(messageId: string, sourceMode: Exclude<ComposeMode, '
|
||||
// 한도(텍스트 100,000 · 서식 500,000)를 넘는 원문은 편집기가 인용을 줄인다 (계약 2절 "답장·전달 초안의 길이")
|
||||
fitDraft.value = true
|
||||
renewEditor()
|
||||
// 인용 속 원래 대용량 첨부 링크 — 상태만 물어 칸 아래 글자로 (원래 메일이 알려 준 파일 이름을 쓴다)
|
||||
void lookupQuotedLinks(original.htmlBody, original.textBody, original.largeFiles ?? [])
|
||||
} catch {
|
||||
// 원문을 못 불러왔다 — 알림은 인터셉터가 띄웠다. 빈 양식으로 쓰게 둔다
|
||||
} finally {
|
||||
@@ -684,6 +724,8 @@ async function prepareFromDraft(draftId: string) {
|
||||
composeKind.value = draftKindOf(draft)
|
||||
const added = addExisting(draft.attachments)
|
||||
const filled = added.filter((item) => item.status === 'done').map((item) => item.key)
|
||||
// 저장본의 대용량 파일 — 다 올린 것 · 올리다 멈춘 것(같은 파일을 다시 골라야 이어진다) · 지운 · 기간이 지난 것
|
||||
largeUploads.addFromDraft(draft.largeFiles ?? [])
|
||||
// 불러온 그대로가 저장한 상태다 — 저장본 id · 저장 시각으로 시작한다 (다음 저장은 이 id 로 PUT).
|
||||
// 다른 메일 프로그램이 만든 저장본에 우리가 실을 수 없는 첨부(형식 · 개수 · 합계 사전 검사 실패 — 목록에 실패로 보인다)가 있으면
|
||||
// 원본을 지우지 않는다 — 첫 저장을 새 저장본으로 하고 원본은 그 첨부째 임시보관함에 남긴다 (보내기도 원본을 지우지 않는다)
|
||||
@@ -694,6 +736,8 @@ async function prepareFromDraft(draftId: string) {
|
||||
keepOriginal: added.some((item) => item.status === 'failed'),
|
||||
})
|
||||
renewEditor()
|
||||
// 답장 · 전달을 이어 쓰면 인용 속 원래 대용량 첨부 링크의 상태를 묻는다 (파일 이름은 모른다)
|
||||
if (composeKind.value !== 'new') void lookupQuotedLinks(draft.bodyHtml, draft.body, [])
|
||||
} catch {
|
||||
if (seq !== prepareSeq) return
|
||||
// 없는 저장본(그사이 보냈거나 지웠다) · 메일 서버 장애 — 알림은 인터셉터가 띄웠다. 임시보관함 목록으로 바꿔 끼운다
|
||||
@@ -730,7 +774,7 @@ function onEditorReady() {
|
||||
*/
|
||||
function attachFromBody(files: File[]) {
|
||||
if (files.length === 0) return
|
||||
const accepted = addFiles(files)
|
||||
const accepted = attachFiles(files)
|
||||
if (accepted.some(isImageFile)) toast.success(IMAGE_AS_ATTACHMENT_MESSAGE)
|
||||
}
|
||||
|
||||
@@ -809,6 +853,8 @@ function precheckSend(): boolean {
|
||||
return false
|
||||
}
|
||||
if (!canSend.value || !editorRef.value) return false
|
||||
// 첨부 때문에 막혔다 — 까닭은 [보내기] 옆 글이 알린다 (단추를 누르면 읽어 준다)
|
||||
if (sendBlock.value) return false
|
||||
|
||||
// 길이는 보낼 모양 그대로 잰다 (계약 2절 "발송 본문" — 넘기면 서버가 제목은 VAL_001, 본문은 MAIL_007 로 거절한다)
|
||||
if (subject.value.trim().length > MAIL_SUBJECT_MAX_LENGTH) {
|
||||
@@ -853,7 +899,8 @@ async function send(sendAt?: string) {
|
||||
const subjectText = subject.value.trim()
|
||||
// 서식 본문과 같은 내용의 텍스트를 함께 보낸다 — 본문이 비었으면 텍스트 메일 (계약 2절 "서식 본문").
|
||||
// 한도를 넘으면 편집기가 원문 인용을 더 줄인다. 내 글만으로 넘으면 null — 보내지 않고 본문 아래에 알린다
|
||||
const content = editorRef.value.getContentForSend()
|
||||
// 대용량 파일이 있으면 서버가 덩이를 넣은 뒤 다시 잰다 — 그만큼 텍스트 한도를 남긴다
|
||||
const content = editorRef.value.getContentForSend(sendReserve())
|
||||
if (content === null) {
|
||||
showBodyTooLong()
|
||||
return
|
||||
@@ -887,9 +934,9 @@ async function send(sendAt?: string) {
|
||||
if (generation === formGeneration) sending.value = false
|
||||
return
|
||||
}
|
||||
// 기다린 저장이 첨부를 찾지 못했거나(MAIL_004 — 첨부를 모두 "찾지 못함"으로 바꾼다) 그사이 새 첨부를 올리기 시작했다.
|
||||
// 지금 첨부로 보내면 모르는 사이 첨부 없이 나간다 — 보내지 않고 MAIL_004 와 같이 안내한다
|
||||
if (hasMissing.value || isUploading.value) {
|
||||
// 기다린 저장이 첨부를 찾지 못했거나(MAIL_004 — 첨부를 모두 "찾지 못함"으로 바꾼다 · 대용량 파일은 MAIL_016) 그사이 새 첨부를 올리기 시작했다.
|
||||
// 지금 첨부로 보내면 모르는 사이 첨부 없이 나간다 — 보내지 않고 MAIL_004 와 같이 안내한다 (대용량 파일은 [보내기] 옆 까닭 글)
|
||||
if (sendBlock.value !== null || isUploading.value) {
|
||||
if (generation !== formGeneration) {
|
||||
// 양식이 이미 없다 — 보내지 않았다는 것을 토스트로 알린다 (칸 안 안내를 볼 수 없다)
|
||||
if (hasMissing.value) notifyApiError({ code: 'MAIL_004', message: getErrorMessage('MAIL_004') })
|
||||
@@ -905,8 +952,8 @@ async function send(sendAt?: string) {
|
||||
const payload: SendMessagePayload = {
|
||||
...recipients,
|
||||
subject: subjectText,
|
||||
body: content.text,
|
||||
...(content.html ? { bodyHtml: content.html } : {}),
|
||||
// 대용량 파일이 있으면 그 id 와 덩이 자리를 싣는다 — 없으면 인용 경계 표시를 지운다 (계약 4절)
|
||||
...bodyFields(content),
|
||||
// 새로 올린 파일은 업로드 응답 id, 전달한 원본 첨부는 원본 id, 저장본의 첨부는 저장본 id 를 그대로 보낸다 (계약 2절)
|
||||
attachments: savedAttachmentItems().flatMap((item) =>
|
||||
item.attachment ? [{ id: item.attachment.id, name: item.attachment.name }] : [],
|
||||
@@ -972,6 +1019,11 @@ async function send(sendAt?: string) {
|
||||
} else if (code === 'VAL_001' && sendAt) {
|
||||
// 서버가 예약 시각을 받지 않았다(그사이 5분 안이 됨 · 시계가 어긋남) — 알림 대신 예약 창을 다시 열어 고르게 한다
|
||||
reopenSchedule(Date.parse(sendAt))
|
||||
} else if (code === 'MAIL_016') {
|
||||
// 대용량 파일을 쓸 수 없다(없음 · 지움 · 기간 지남 · 올리는 중 · 위험) — 파일마다 다시 물어 빨간 줄로 보인다.
|
||||
// 다시 물어도 막을 까닭이 보이지 않으면(그사이 고쳐졌다 등) 보내지 않았다는 것을 알린다
|
||||
await largeUploads.recheck()
|
||||
if (generation === formGeneration && sendBlock.value === null) notifyApiError(apiError)
|
||||
}
|
||||
// 그 밖의 실패는 인터셉터가 알렸다 — 쓴 내용은 그대로 둔다
|
||||
}
|
||||
@@ -1055,7 +1107,7 @@ function pickFiles() {
|
||||
|
||||
function onFilesChosen(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (input.files) addFiles(Array.from(input.files))
|
||||
if (input.files) attachFiles(Array.from(input.files))
|
||||
// 같은 파일을 다시 고를 수 있게 비운다 (실패한 파일 다시 올리기)
|
||||
input.value = ''
|
||||
}
|
||||
@@ -1113,9 +1165,14 @@ watch(allRecipients, () => {
|
||||
})
|
||||
|
||||
// 보낼 수 없게 되면(보내는 중 · 양식을 새로 채움 · 첨부를 올리기 시작함) 예약 창을 닫는다. 세션이 바뀌어도 닫는다 — 세션 만료 대화상자와 겹치지 않게
|
||||
watch([canSend, () => authStore.generation], ([can], [, previousSession]) => {
|
||||
watch([sendable, () => authStore.generation], ([can], [, previousSession]) => {
|
||||
if (!can || authStore.generation !== previousSession) closeSchedule()
|
||||
})
|
||||
|
||||
// 지운 · 기간이 지난 대용량 파일을 모두 뺐다 — 멈췄던 자동 저장을 다시 잡는다
|
||||
watch(hasMissingLarge, (missing) => {
|
||||
if (!missing) drafts.touch()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -1258,20 +1315,33 @@ watch([canSend, () => authStore.generation], ([can], [, previousSession]) => {
|
||||
class="visually-hidden"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
:accept="MAIL_ATTACHMENT_ACCEPT"
|
||||
:accept="COMPOSE_ATTACHMENT_ACCEPT"
|
||||
@change="onFilesChosen"
|
||||
>
|
||||
</div>
|
||||
<ComposeAttachmentList
|
||||
v-if="uploadItems.length > 0"
|
||||
<ComposeAttachmentGroups
|
||||
v-if="uploadItems.length > 0 || largeItems.length > 0 || quotedNotices.length > 0"
|
||||
ref="attachmentListRef"
|
||||
:items="uploadItems"
|
||||
:normal-items="uploadItems"
|
||||
:large-items="largeItems"
|
||||
:normal-total-text="normalTotalText"
|
||||
:link-hint="linkGroupHint"
|
||||
:quota-warning="largeQuotaWarning"
|
||||
:switch-notice="switchNotice"
|
||||
:quoted-notices="quotedNotices"
|
||||
:linkable="isLinkable"
|
||||
@remove="removeUpload"
|
||||
@send-as-link="sendAsLink"
|
||||
@large-pause="largeUploads.pause"
|
||||
@large-resume="largeUploads.resume"
|
||||
@large-remove="largeUploads.remove"
|
||||
@large-repick="(key: number, file: File) => void largeUploads.repick(key, file)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ComposeFooter
|
||||
:can-send="canSend"
|
||||
:blocked-reason="sendBlock?.text ?? null"
|
||||
:sending="sending"
|
||||
:can-save-draft="canSaveDraft"
|
||||
:draft-status="draftStatus"
|
||||
@@ -1293,6 +1363,7 @@ watch([canSend, () => authStore.generation], ([can], [, previousSession]) => {
|
||||
<!-- 저장하지 못한 채 떠나려 할 때 — 답할 때까지 이동이 기다린다 (useLeaveConfirm) -->
|
||||
<ComposeLeaveDialog
|
||||
:open="leaveConfirming"
|
||||
:uploading="largeUploading"
|
||||
@stay="stayOnCompose"
|
||||
@leave="leaveCompose"
|
||||
/>
|
||||
|
||||
@@ -53,6 +53,8 @@ useMailStatePolling({
|
||||
mailStore.syncCounts()
|
||||
void mailStore.syncVisibleList()
|
||||
void mailStore.loadScheduledCount()
|
||||
// 열린 메일의 대용량 첨부가 보안 검사 중이면 상태를 다시 받는다 (11단계 — 끝나면 받을 수 있다)
|
||||
void mailStore.refreshOpenedLargeFiles()
|
||||
},
|
||||
onHiddenChange: (inboxUnread) => mailStore.setInboxUnread(inboxUnread),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
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 { CLOCK } from '@/composables/useClock'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { FileQuota, LargeFile, ShareLink } from '@/shared/types/large-file'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useLargeFilesStore } from '@/stores/large-files.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { createManualClock } from '@/test-utils/manual-clock'
|
||||
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MailLargeFilesPage from './MailLargeFilesPage.vue'
|
||||
|
||||
// 설정 › 메일 › 대용량 첨부 (11단계 — 디자인 원본 SettingsLargeFiles · 계약 4절 "보낸 사람 관리")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const MB = 1024 * 1024
|
||||
const GB = 1024 * MB
|
||||
/** 한국 시간 2026-10-30 (금) 오후 2:15 */
|
||||
const NOW = Date.parse('2026-10-30T05:15:00Z')
|
||||
|
||||
const QUOTA: FileQuota = {
|
||||
usedBytes: 6.2 * GB,
|
||||
limitBytes: 10 * GB,
|
||||
maxFileBytes: 2047 * MB,
|
||||
maxFilesPerMessage: 10,
|
||||
chunkSize: 16 * MB,
|
||||
retentionDays: 30,
|
||||
}
|
||||
|
||||
function unsentFile(changes: Partial<LargeFile>): LargeFile {
|
||||
return {
|
||||
id: 'u1',
|
||||
name: '시공도면_v4.dwg',
|
||||
size: 620 * MB,
|
||||
status: 'ready',
|
||||
chunkSize: 16 * MB,
|
||||
chunkCount: 39,
|
||||
receivedChunks: [],
|
||||
firstChunkSha256: null,
|
||||
scanStatus: 'clean',
|
||||
createdAt: '2026-10-30T01:00:00Z',
|
||||
expiresAt: '2026-11-29T14:59:59.999Z',
|
||||
lastModified: null,
|
||||
...changes,
|
||||
}
|
||||
}
|
||||
|
||||
const LINK: ShareLink = {
|
||||
id: 'link1',
|
||||
sentEmailId: 'sent-m1',
|
||||
subject: '11월 현장 점검 자료 보내드립니다',
|
||||
sentAt: '2026-10-30T05:15:00Z',
|
||||
expiresAt: '2026-11-29T14:59:59.999Z',
|
||||
status: 'active',
|
||||
revokedAt: null,
|
||||
files: [
|
||||
{ id: 'f1', name: '설계도_v3.dwg', size: 1.2 * GB, status: 'available', downloadCount: 3, lastDownloadedAt: '2026-10-30T05:41:00Z' },
|
||||
{ id: 'f2', name: '현장영상_1029.mp4', size: 612 * MB, status: 'scanning', downloadCount: 0, lastDownloadedAt: null },
|
||||
],
|
||||
}
|
||||
|
||||
const page = <T>(items: T[], hasNext = false) => ({
|
||||
items,
|
||||
pageInfo: { hasNext, nextCursor: hasNext ? 'next' : null, total: items.length + (hasNext ? 20 : 0) },
|
||||
})
|
||||
|
||||
describe('MailLargeFilesPage', () => {
|
||||
let pinia: Pinia
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(NOW)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
vi.useRealTimers()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
interface Fixture {
|
||||
unsent?: LargeFile[]
|
||||
links?: ShareLink[]
|
||||
linksHasNext?: boolean
|
||||
quota?: FileQuota
|
||||
failLinks?: boolean
|
||||
/** 지우기가 409 FILE_003 — 그사이 그 파일을 실은 메일을 보냈다 (다시 받는 목록에서는 보낸 링크로 간다) */
|
||||
sentMeanwhile?: { unsent: LargeFile[]; links: ShareLink[] }
|
||||
}
|
||||
|
||||
async function mountPage(fixture: Fixture = {}) {
|
||||
let unsent = fixture.unsent ?? []
|
||||
let links = fixture.links ?? []
|
||||
let quota = fixture.quota ?? QUOTA
|
||||
const requests = installMockApi((request: MockRequest) => {
|
||||
const { method, url } = request
|
||||
if (method === 'GET' && url === '/file-uploads') return ok(page(unsent))
|
||||
if (method === 'GET' && url === '/share-links') {
|
||||
if (fixture.failLinks) return fail(503, 'MAIL_001')
|
||||
return ok(request.params.cursor ? page([{ ...LINK, id: 'link-old', subject: '지난 메일' }]) : page(links, fixture.linksHasNext))
|
||||
}
|
||||
if (method === 'GET' && url === '/file-quota') return ok(quota)
|
||||
if (method === 'GET' && url === '/mailboxes') {
|
||||
return ok(page([{ id: 'sent', name: '보낸편지함', role: 'sent', parentId: null, sortOrder: 0, totalCount: 1, unreadCount: 0 }]))
|
||||
}
|
||||
if (method === 'DELETE' && url.startsWith('/file-uploads/')) {
|
||||
if (fixture.sentMeanwhile) {
|
||||
unsent = fixture.sentMeanwhile.unsent
|
||||
links = fixture.sentMeanwhile.links
|
||||
return fail(409, 'FILE_003')
|
||||
}
|
||||
unsent = unsent.filter((file) => `/file-uploads/${file.id}` !== url)
|
||||
quota = { ...quota, usedBytes: quota.usedBytes - 620 * MB }
|
||||
return { status: 204 }
|
||||
}
|
||||
if (method === 'POST' && url === '/share-links/link1/revocation') {
|
||||
return ok({
|
||||
...LINK,
|
||||
status: 'revoked',
|
||||
revokedAt: '2026-10-30T05:20:00Z',
|
||||
files: LINK.files.map((file) => ({ ...file, status: 'revoked' })),
|
||||
})
|
||||
}
|
||||
return ok({})
|
||||
})
|
||||
const router = createTestRouter()
|
||||
await router.push('/settings/mail/large-files')
|
||||
await router.isReady()
|
||||
wrapper = mount(MailLargeFilesPage, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: createManualClock(NOW) } },
|
||||
})
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
return { wrapper, requests }
|
||||
}
|
||||
|
||||
it('사용량 — "6.2 / 10 GB" + 막대 + 보낸 링크 · 보내지 않은 파일로 나눈 양 (위험 판정 파일은 빼고 센다)', async () => {
|
||||
const { wrapper: view } = await mountPage({
|
||||
unsent: [
|
||||
unsentFile({ id: 'u1', size: 1.2 * GB, status: 'uploading', scanStatus: null }),
|
||||
unsentFile({ id: 'u2', size: 700 * MB, scanStatus: 'clean' }),
|
||||
unsentFile({ id: 'u3', size: 24 * MB, scanStatus: 'infected' }),
|
||||
],
|
||||
links: [LINK],
|
||||
})
|
||||
|
||||
expect(view.get('[data-large-usage]').text()).toBe('6.2 / 10 GB')
|
||||
expect(view.get('meter').attributes('aria-valuetext')).toBe('62%')
|
||||
expect(view.get('.large-usage__note').text()).toBe(
|
||||
'보낸 링크 4.3 GB · 보내지 않은 파일 1.9 GB(임시 저장 · 올리다 멈춤). 기간이 지나거나, 링크를 끊거나, 보내지 않은 파일을 지우면 바로 줄어듭니다.',
|
||||
)
|
||||
})
|
||||
|
||||
it('보내지 않은 파일 — 상태 · 크기(올린 만큼) · 남는 기한 / 보낸 링크 위에', async () => {
|
||||
const { wrapper: view } = await mountPage({
|
||||
unsent: [
|
||||
unsentFile({
|
||||
id: 'u1',
|
||||
name: '현장영상_1030.mp4',
|
||||
size: 1.2 * GB,
|
||||
status: 'uploading',
|
||||
scanStatus: null,
|
||||
chunkCount: 77,
|
||||
receivedChunks: Array.from({ length: 26 }, (_, index) => index),
|
||||
expiresAt: '2026-10-31T05:41:00Z',
|
||||
}),
|
||||
unsentFile({ id: 'u2', name: '발표자료_초안.pptx', size: 64 * MB, scanStatus: 'unscannable' }),
|
||||
],
|
||||
links: [LINK],
|
||||
})
|
||||
|
||||
expect(view.get('#large-unsent-title').text()).toBe('보내지 않은 파일 2개')
|
||||
const rows = view.findAll('[data-unsent]')
|
||||
expect(rows[0]?.get('.unsent__status').text()).toBe('올리다 멈춤')
|
||||
expect(rows[0]?.get('.unsent__size').text()).toContain('416 MB 올림')
|
||||
expect(rows[0]?.get('.unsent__until').text()).toContain('까지 남음')
|
||||
expect(rows[0]?.get('.unsent__until').text()).toContain('마지막으로 올린 뒤 24시간')
|
||||
expect(rows[1]?.get('.unsent__status').text()).toBe('다 올림 · 검사하지 못함')
|
||||
expect(rows[1]?.get('.unsent__until').text()).toBe('11월 29일까지 남음')
|
||||
// 공간을 바로 비울 수 있는 곳이라 보낸 링크보다 위
|
||||
const html = view.html()
|
||||
expect(html.indexOf('보내지 않은 파일 2개')).toBeLessThan(html.indexOf('보낸 링크 1개'))
|
||||
})
|
||||
|
||||
it('[지우기] — 확인 창(처음 초점 「취소」) 뒤 지우고 알린다 · 줄이 사라지고 사용량을 다시 받는다', async () => {
|
||||
const { wrapper: view, requests } = await mountPage({ unsent: [unsentFile({})], links: [LINK] })
|
||||
|
||||
await view.get('[data-unsent-delete]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(view.get('#large-delete-message').text()).toBe(
|
||||
'‘시공도면_v4.dwg’(620 MB) 파일을 지웁니다. 이 파일을 실은 임시 저장본이 있으면 열 때 첨부 칸에 ‘지운 파일’로 보입니다. 지운 파일은 되돌릴 수 없습니다.',
|
||||
)
|
||||
expect(document.activeElement?.hasAttribute('data-delete-cancel')).toBe(true)
|
||||
|
||||
await view.get('[data-delete-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.some((request) => request.method === 'DELETE' && request.url === '/file-uploads/u1')).toBe(true)
|
||||
expect(view.find('[data-unsent]').exists()).toBe(false)
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual(['‘시공도면_v4.dwg’ 파일을 지웠습니다.'])
|
||||
expect(view.get('[data-large-usage]').text()).toBe('5.6 / 10 GB')
|
||||
})
|
||||
|
||||
it('보낸 링크 — 메일마다 한 덩이: 제목은 보낸 메일로 · 보낸 날 · 끝나는 날 · 남은 날 · 파일마다 받은 수 · 상태', async () => {
|
||||
const { wrapper: view } = await mountPage({ links: [LINK] })
|
||||
|
||||
const block = view.get('[data-share-link="link1"]')
|
||||
const subject = block.get('a.link__subject')
|
||||
expect(subject.text()).toBe('11월 현장 점검 자료 보내드립니다')
|
||||
expect(subject.attributes('href')).toBe('/mail/sent/sent-m1')
|
||||
expect(block.get('.link__sent').text()).toBe('10월 30일 (금) 보냄 · 파일 2개 · 1.8 GB')
|
||||
expect(block.get('.link__until').text()).toContain('11월 29일까지')
|
||||
expect(block.get('.link__until').text()).toContain('30일 남음')
|
||||
const files = block.findAll('[data-shared-file]')
|
||||
expect(files[0]?.get('.link__file-downloads').text()).toContain('3회 받음')
|
||||
expect(files[0]?.get('.link__file-downloads').text()).toContain('마지막 오늘')
|
||||
expect(files[0]?.get('.link__file-status').text()).toBe('사용 중')
|
||||
expect(files[1]?.get('.link__file-downloads').text()).toBe('아직 받지 않음')
|
||||
expect(files[1]?.get('.link__file-status').text()).toBe('검사 중')
|
||||
// [링크 복사]는 두지 않는다 — 서버는 링크를 해시로만 안다
|
||||
expect(view.text()).not.toContain('링크 복사')
|
||||
})
|
||||
|
||||
it('[링크 끊기] — 메일 제목이 든 확인 창 뒤 끊고, 덩이는 "공유 멈춤 / 10월 30일에 끊음"(단추 없음) · 초점은 덩이 머리로', async () => {
|
||||
const { wrapper: view, requests } = await mountPage({ links: [LINK] })
|
||||
|
||||
await view.get('[data-link-revoke]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(view.get('#settings-large-revoke-message').text()).toContain(
|
||||
'‘11월 현장 점검 자료 보내드립니다’의 파일 2개를 받는 사람 모두 더는 내려받을 수 없습니다.',
|
||||
)
|
||||
await view.get('[data-revoke-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.some((request) => request.method === 'POST' && request.url === '/share-links/link1/revocation')).toBe(true)
|
||||
const block = view.get('[data-share-link="link1"]')
|
||||
expect(block.classes()).toContain('link--ended')
|
||||
expect(block.get('.link__until-main').text()).toBe('공유 멈춤')
|
||||
expect(block.get('.link__until-note').text()).toBe('10월 30일에 끊음')
|
||||
expect(block.find('[data-link-revoke]').exists()).toBe(false)
|
||||
expect(document.activeElement?.classList.contains('link__head')).toBe(true)
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([
|
||||
'링크를 끊었습니다. 받는 사람은 더 이상 내려받을 수 없습니다.',
|
||||
])
|
||||
})
|
||||
|
||||
it('[더 보기] — 20개씩 이어 받는다', async () => {
|
||||
const { wrapper: view } = await mountPage({ links: [LINK], linksHasNext: true })
|
||||
|
||||
expect(view.get('#large-links-title').text()).toBe('보낸 링크 21개')
|
||||
await view.get('[data-links-more]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(view.findAll('[data-share-link]').map((block) => block.attributes('data-share-link'))).toEqual([
|
||||
'link1',
|
||||
'link-old',
|
||||
])
|
||||
expect(view.find('[data-links-more]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('빈 상태 — 사용량은 그대로, "링크로 보낸 파일이 없습니다"', async () => {
|
||||
const { wrapper: view } = await mountPage({ quota: { ...QUOTA, usedBytes: 0 } })
|
||||
|
||||
expect(view.get('[data-large-usage]').text()).toBe('0 / 10 GB')
|
||||
expect(view.get('[data-large-empty]').text()).toContain('링크로 보낸 파일이 없습니다')
|
||||
expect(view.get('[data-large-empty]').text()).toContain('메일 쓰기에서 20MB 가 넘는 파일을 고르면 링크로 보냅니다.')
|
||||
expect(view.find('#large-unsent-title').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('목록을 받지 못하면 사전 문구와 "다시 시도" — 알림은 띄우지 않는다', async () => {
|
||||
const { wrapper: view } = await mountPage({ failLinks: true })
|
||||
|
||||
expect(view.text()).toContain('메일 서버와 연결할 수 없습니다.')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
expect(useLargeFilesStore().status).toBe('error')
|
||||
})
|
||||
|
||||
it('실시간 위험 판정은 받아 둔 목록에 바로 옮겨진다', async () => {
|
||||
const { wrapper: view } = await mountPage({ links: [LINK] })
|
||||
|
||||
useLargeFilesStore().noteInfected('f2', '현장영상_1029.mp4')
|
||||
await flushPromises()
|
||||
|
||||
const infected = view.get('[data-shared-file="f2"]')
|
||||
expect(infected.classes()).toContain('link__file--danger')
|
||||
expect(infected.get('.link__file-status').text()).toBe('위험내려받기를 막음')
|
||||
})
|
||||
|
||||
it('보낸 메일을 지웠으면(제목 null) 제목은 글자만 — 보낸편지함으로 가는 링크가 없다 (리뷰 L6)', async () => {
|
||||
const { wrapper: view } = await mountPage({ links: [{ ...LINK, subject: null }] })
|
||||
|
||||
const block = view.get('[data-share-link="link1"]')
|
||||
expect(block.find('a.link__subject').exists()).toBe(false)
|
||||
expect(block.get('.link__subject--plain').text()).toBe('(제목 없음)')
|
||||
})
|
||||
|
||||
it('지우기가 409 FILE_003 이면(그사이 그 파일을 실은 메일을 보냄) 목록을 다시 받고 확인 창을 닫는다 (리뷰 L9)', async () => {
|
||||
const file = unsentFile({})
|
||||
const { wrapper: view, requests } = await mountPage({
|
||||
unsent: [file],
|
||||
links: [],
|
||||
sentMeanwhile: { unsent: [], links: [LINK] },
|
||||
})
|
||||
const listCalls = () => requests.filter((request) => request.method === 'GET' && request.url === '/file-uploads').length
|
||||
|
||||
await view.get('[data-unsent-delete]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(listCalls()).toBe(1)
|
||||
await view.get('[data-delete-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
expect(listCalls()).toBe(2)
|
||||
expect(view.find('#large-delete-message').exists()).toBe(false)
|
||||
expect(view.find('[data-unsent]').exists()).toBe(false)
|
||||
expect(view.find('[data-share-link="link1"]').exists()).toBe(true)
|
||||
// 실패 알림은 인터셉터가 사전 문구로 (성공 알림은 없다)
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.FILE_003])
|
||||
})
|
||||
|
||||
it('끊은 링크 — 끊은 날은 한국 날짜로 "10월 22일에 끊음", 끊은 때가 없는 옛 응답은 "공유 멈춤"만', async () => {
|
||||
const ended = (id: string, revokedAt: string | null): ShareLink => ({
|
||||
...LINK,
|
||||
id,
|
||||
status: 'revoked',
|
||||
revokedAt,
|
||||
files: LINK.files.map((file) => ({ ...file, status: 'revoked' })),
|
||||
})
|
||||
const { wrapper: view } = await mountPage({
|
||||
// UTC 10월 21일 15:30 = 한국 10월 22일 오전 0:30
|
||||
links: [ended('revoked-kst', '2026-10-21T15:30:00Z'), ended('revoked-old', null)],
|
||||
})
|
||||
|
||||
const withDay = view.get('[data-share-link="revoked-kst"]')
|
||||
expect(withDay.get('.link__until-main').text()).toBe('공유 멈춤')
|
||||
expect(withDay.get('.link__until-note').text()).toBe('10월 22일에 끊음')
|
||||
const old = view.get('[data-share-link="revoked-old"]')
|
||||
expect(old.get('.link__until').text()).toBe('공유 멈춤')
|
||||
expect(old.find('.link__until-note').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 메일 › 대용량 첨부 (디자인 원본 SettingsLargeFiles · README "11단계 › 설정 › 메일 › 대용량 첨부" · 계약 4절 "보낸 사람 관리")
|
||||
// — /settings/mail/large-files · 탭 제목 "대용량 첨부"
|
||||
// 머리 "대용량 첨부" + 설명 → 사용량 상자("보관 사용량 6.2 / 10 GB" + 막대 + 보낸 링크 · 보내지 않은 파일로 나눈 양) →
|
||||
// 보내지 않은 파일(있을 때만 — 공간을 바로 비울 수 있는 곳이라 보낸 링크보다 위) → 보낸 링크(메일마다 한 덩이 · 20개씩 [더 보기]) 또는 빈 상태.
|
||||
// - 들어올 때마다 새로 받는다(다른 탭 · PC 에서 보냈거나 지웠을 수 있다 — large-files.store). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도"
|
||||
// - 메일 제목은 보낸편지함의 그 메일로 잇는다 — 메일함 목록을 받아 둔다(보낸편지함 id)
|
||||
// - 실시간 알림의 위험 판정은 받아 둔 목록에 바로 옮겨진다 (스토어의 noteInfected)
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import LargeFileLinkList from '@/components/settings/LargeFileLinkList.vue'
|
||||
import LargeFileUnsentList from '@/components/settings/LargeFileUnsentList.vue'
|
||||
import LargeFileUsage from '@/components/settings/LargeFileUsage.vue'
|
||||
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
|
||||
import { useClock } from '@/composables/useClock'
|
||||
import { LARGE_FILE_SETTINGS_TEXT } from '@/shared/constants/large-file'
|
||||
import { formatCount, formatMegabytes } from '@/shared/utils/format'
|
||||
import { useLargeFilesStore } from '@/stores/large-files.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
const largeFilesStore = useLargeFilesStore()
|
||||
const mailStore = useMailStore()
|
||||
const clock = useClock()
|
||||
|
||||
/** 화면을 연 때 — 남은 날 · "오늘 오후 2:41" 의 기준 */
|
||||
const now = clock.now()
|
||||
|
||||
const ready = computed(() => largeFilesStore.status === 'ready')
|
||||
const retentionDays = computed(() => largeFilesStore.limits.retentionDays)
|
||||
const description = computed(() => LARGE_FILE_SETTINGS_TEXT.description(formatCount(retentionDays.value)))
|
||||
const sentMailboxId = computed(() => mailStore.mailboxByRole('sent')?.id ?? null)
|
||||
const hasMoreLinks = computed(() => largeFilesStore.linksPageInfo?.hasNext === true)
|
||||
|
||||
/**
|
||||
* 사용량을 "보낸 링크 · 보내지 않은 파일"로 나눈다 — 보내지 않은 파일을 다 받았을 때만 (직원마다 50개라 보통 다 받는다).
|
||||
* 위험 판정 파일은 바이트를 곧 지워 사용량에서 빠진다
|
||||
*/
|
||||
const split = computed(() => {
|
||||
if (!largeFilesStore.unsentComplete) return null
|
||||
const unsentBytes = largeFilesStore.unsent.reduce(
|
||||
(sum, file) => (file.scanStatus === 'infected' ? sum : sum + file.size),
|
||||
0,
|
||||
)
|
||||
return { unsentBytes, sentBytes: Math.max(0, largeFilesStore.limits.usedBytes - unsentBytes) }
|
||||
})
|
||||
|
||||
const emptyHint = computed(() =>
|
||||
LARGE_FILE_SETTINGS_TEXT.emptyHint(formatMegabytes(mailStore.attachmentMaxBytes), formatCount(retentionDays.value)),
|
||||
)
|
||||
|
||||
// 처음 그리기 전에 — 지난번에 받아 둔 목록을 잠깐 그렸다 바꾸지 않게
|
||||
void largeFilesStore.loadSettings()
|
||||
void mailStore.ensureMailboxes()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsPageFrame
|
||||
title-id="large-files-page-title"
|
||||
:title="LARGE_FILE_SETTINGS_TEXT.title"
|
||||
:description="description"
|
||||
>
|
||||
<MailLoadError
|
||||
v-if="largeFilesStore.status === 'error' && largeFilesStore.error"
|
||||
:error="largeFilesStore.error"
|
||||
leave-label=""
|
||||
@retry="largeFilesStore.loadSettings()"
|
||||
/>
|
||||
|
||||
<template v-else-if="ready">
|
||||
<LargeFileUsage
|
||||
:used-bytes="largeFilesStore.limits.usedBytes"
|
||||
:limit-bytes="largeFilesStore.limits.limitBytes"
|
||||
:split="split"
|
||||
/>
|
||||
<LargeFileUnsentList
|
||||
v-if="largeFilesStore.unsent.length > 0"
|
||||
:files="largeFilesStore.unsent"
|
||||
:now="now"
|
||||
/>
|
||||
<LargeFileLinkList
|
||||
v-if="largeFilesStore.links.length > 0"
|
||||
:links="largeFilesStore.links"
|
||||
:total="largeFilesStore.linksTotal"
|
||||
:has-more="hasMoreLinks"
|
||||
:loading-more="largeFilesStore.loadingMore"
|
||||
:now="now"
|
||||
:sent-mailbox-id="sentMailboxId"
|
||||
@more="largeFilesStore.loadMoreLinks()"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="large-files-page__empty"
|
||||
data-large-empty
|
||||
>
|
||||
<AppIcon
|
||||
name="link"
|
||||
:size="22"
|
||||
class="large-files-page__empty-icon"
|
||||
/>
|
||||
<p class="large-files-page__empty-title">
|
||||
{{ LARGE_FILE_SETTINGS_TEXT.emptyTitle }}
|
||||
</p>
|
||||
<p class="large-files-page__empty-hint">
|
||||
{{ emptyHint }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="large-files-page__placeholder"
|
||||
aria-busy="true"
|
||||
/>
|
||||
</SettingsPageFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 빈 상태 — 가운데 정렬 상자 (디자인 원본 SettingsLargeFiles "빈 상태") */
|
||||
.large-files-page__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
padding: var(--space-28) var(--space-24);
|
||||
border: var(--border-width) solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-panel);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.large-files-page__empty-icon {
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.large-files-page__empty-title {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.large-files-page__empty-hint {
|
||||
max-width: 28rem;
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.large-files-page__placeholder {
|
||||
min-height: 16rem;
|
||||
}
|
||||
</style>
|
||||
@@ -9,8 +9,8 @@ import { useAuthStore } from '@/stores/auth.store'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import SettingsLayout from './SettingsLayout.vue'
|
||||
|
||||
// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 알림
|
||||
// (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계" · "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소")
|
||||
// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림
|
||||
// (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계" · "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소" · "11단계 › 설정 › 메일 › 대용량 첨부")
|
||||
|
||||
describe('SettingsLayout', () => {
|
||||
let pinia: Pinia
|
||||
@@ -35,7 +35,7 @@ describe('SettingsLayout', () => {
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
it('묶음 둘 — 계정(비밀번호) · 메일(서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 알림), 묶음 이름이 목록의 이름이다', async () => {
|
||||
it('묶음 둘 — 계정(비밀번호) · 메일(서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림), 묶음 이름이 목록의 이름이다', async () => {
|
||||
const { wrapper } = await mountAt('/settings/account/password')
|
||||
const menu = wrapper.get('nav[aria-label="설정 메뉴"]')
|
||||
|
||||
@@ -50,6 +50,7 @@ describe('SettingsLayout', () => {
|
||||
['필터', '/settings/mail/filters'],
|
||||
['보내기 · 읽기', '/settings/mail/send-read'],
|
||||
['차단한 주소', '/settings/mail/blocks'],
|
||||
['대용량 첨부', '/settings/mail/large-files'],
|
||||
['알림', '/settings/mail/notify'],
|
||||
],
|
||||
])
|
||||
@@ -97,6 +98,22 @@ describe('SettingsLayout', () => {
|
||||
expect(router.currentRoute.value.meta).toMatchObject({ title: '차단한 주소', requiresAuth: true })
|
||||
})
|
||||
|
||||
it('설정 › 메일 › 대용량 첨부 (11단계) — /settings/mail/large-files, 로그인이 필요하고 탭 제목은 메뉴 이름 (앱의 라우트 표 그대로)', async () => {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes })
|
||||
|
||||
await router.push('/settings/mail/large-files')
|
||||
expect(router.currentRoute.value.name).toBe('settings-mail-large-files')
|
||||
expect(router.currentRoute.value.meta).toMatchObject({ title: '대용량 첨부', requiresAuth: true })
|
||||
})
|
||||
|
||||
it('대용량 첨부 메뉴는 링크 아이콘 · 지금 화면이면 현재로 표시한다', async () => {
|
||||
const { wrapper } = await mountAt('/settings/mail/large-files')
|
||||
const current = wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')
|
||||
|
||||
expect(current.text()).toBe('대용량 첨부')
|
||||
expect(current.findAll('path')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('차단한 주소 메뉴는 원 + 빗금 아이콘 · 지금 화면이면 현재로 표시한다', async () => {
|
||||
const { wrapper } = await mountAt('/settings/mail/blocks')
|
||||
const current = wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import type { PublicLink, PublicLinkFile } from '@/shared/types/large-file'
|
||||
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
|
||||
import { flushPromises } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import SharePage from './SharePage.vue'
|
||||
|
||||
// 공개 내려받기 화면 /s/:token (11단계 — 디자인 원본 SharePage · 계약 4절 "공개 내려받기")
|
||||
// 로그인 없이 쿠키 없는 fetch 로 링크 정보를 받고, 상태마다 다른 화면을 그린다. 받기는 누르기 직전에 상태를 한 번 더 묻는다.
|
||||
|
||||
const TOKEN = 'Qx7mK2vR9pL4wT8nB3cF6hJ1dS5gA0eY2uI7oP9kZ3x'
|
||||
const GB = 1024 * 1024 * 1024
|
||||
const MB = 1024 * 1024
|
||||
/** 한국 시간 2026-10-30 (금) 오후 2:15 */
|
||||
const NOW = Date.parse('2026-10-30T05:15:00Z')
|
||||
|
||||
const DESIGN: PublicLinkFile = { id: 'f1', name: '설계도_v3.dwg', size: 1.2 * GB, status: 'available' }
|
||||
const VIDEO: PublicLinkFile = { id: 'f2', name: '현장영상_1029.mp4', size: 612 * MB, status: 'available' }
|
||||
|
||||
function publicLink(changes: Partial<PublicLink> = {}): PublicLink {
|
||||
return {
|
||||
status: 'active',
|
||||
sender: { name: '김아라', email: 'ara.kim@arasystem.it.kr' },
|
||||
sentAt: '2026-10-30T05:15:00Z',
|
||||
expiresAt: '2026-11-29T14:59:59.999Z',
|
||||
files: [DESIGN, VIDEO],
|
||||
reportAddress: 'security@arasystem.it.kr',
|
||||
...changes,
|
||||
}
|
||||
}
|
||||
|
||||
type Reply = { link: PublicLink } | { status: number } | 'network' | 'html'
|
||||
|
||||
interface FetchCall {
|
||||
url: string
|
||||
init: RequestInit
|
||||
}
|
||||
|
||||
describe('SharePage', () => {
|
||||
let wrapper: VueWrapper | null = null
|
||||
let clock: ManualClock
|
||||
let replies: Reply[]
|
||||
let calls: FetchCall[]
|
||||
let downloads: string[]
|
||||
|
||||
/** 링크 정보 요청에 차례로 답한다 — 남은 답이 없으면 마지막 답을 되풀이한다 */
|
||||
function fakeFetch(url: string, init: RequestInit): Promise<Response> {
|
||||
calls.push({ url, init })
|
||||
const reply = replies.length > 1 ? replies.shift() : replies[0]
|
||||
if (!reply || reply === 'network') return Promise.reject(new TypeError('Failed to fetch'))
|
||||
if (reply === 'html') {
|
||||
return Promise.resolve(new Response('<!doctype html><title>x</title>', { status: 200, headers: { 'Content-Type': 'text/html' } }))
|
||||
}
|
||||
if ('status' in reply) {
|
||||
return Promise.resolve(new Response(JSON.stringify({ success: false }), { status: reply.status }))
|
||||
}
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ success: true, data: reply.link }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
clock = createManualClock(NOW)
|
||||
replies = []
|
||||
calls = []
|
||||
downloads = []
|
||||
vi.stubGlobal('fetch', vi.fn(fakeFetch))
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
|
||||
downloads.push(this.getAttribute('href') ?? '')
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('meta[name="robots"], meta[name="referrer"]').forEach((meta) => meta.remove())
|
||||
})
|
||||
|
||||
async function mountAt(token: string, ...answers: Reply[]) {
|
||||
replies = answers
|
||||
const router = createTestRouter()
|
||||
await router.push(`/s/${token}`)
|
||||
await router.isReady()
|
||||
wrapper = mount(SharePage, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router], provide: { [CLOCK as symbol]: clock } },
|
||||
})
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
it('정상 — 보낸 사람 · 파일 수 · 합계 · 끝나는 날(한국 시간) · 파일마다 [내려받기] · 로그인 안내 · 영어 한 줄', async () => {
|
||||
const view = await mountAt(TOKEN, { link: publicLink() })
|
||||
|
||||
expect(view.get('h1').text()).toBe('김아라 님이 보낸 파일 2개 (1.8 GB)')
|
||||
// 이름과 주소 사이 띄어쓰기는 괄호 칸 안에 둔다 — 좁은 화면은 괄호와 함께 감추고 주소를 다음 줄로 (디자인 원본 SharePage "김아라 <ara.kim@…>")
|
||||
expect(view.get('.share__intro').text()).toContain('보낸 사람 김아라 <ara.kim@arasystem.it.kr> · ARA SYSTEM')
|
||||
expect(view.get('.share__intro').text()).toContain(
|
||||
'2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간) · 30일 남음',
|
||||
)
|
||||
expect(view.findAll('.share-file__meta').map((meta) => meta.text())).toEqual([
|
||||
'1.2 GB · 보안 검사 · 이상 없음',
|
||||
'612 MB · 보안 검사 · 이상 없음',
|
||||
])
|
||||
const download = view.get<HTMLAnchorElement>('[data-share-file="f1"] a.share-file__download')
|
||||
expect(download.attributes('href')).toBe(`/api/public-links/${TOKEN}/files/f1`)
|
||||
expect(download.attributes('aria-label')).toBe('설계도_v3.dwg 내려받기')
|
||||
expect(view.get('.share__safety').text()).toBe(
|
||||
'이 화면은 로그인이나 비밀번호를 묻지 않습니다. 묻는 화면이 나오면 닫고 보낸 사람에게 확인해 주세요.',
|
||||
)
|
||||
expect(view.get('.share__report').text()).toBe('받을 이유가 없는 파일이면 security@arasystem.it.kr 로 알려 주세요.')
|
||||
expect(view.get('.share__english').text()).toBe(
|
||||
'Files from 김아라 (ara.kim@arasystem.it.kr) — download by Nov 29, 2026 (KST). This page never asks for a login or password.',
|
||||
)
|
||||
// 메일 제목 · 본문 · 받는 사람 · 받은 수는 보이지 않는다
|
||||
expect(view.text()).not.toContain('회 받음')
|
||||
expect(document.title).toBe('김아라 님이 보낸 파일 — ARA SYSTEM')
|
||||
})
|
||||
|
||||
it('링크 정보는 쿠키 없이 · 캐시 없이 · 주소를 싣지 않고 묻는다 — 검색 엔진 · referrer 막기 머리도 둔다', async () => {
|
||||
await mountAt(TOKEN, { link: publicLink() })
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.url).toBe(`/api/public-links/${TOKEN}`)
|
||||
expect(calls[0]?.init).toMatchObject({ method: 'GET', credentials: 'omit', cache: 'no-store', referrerPolicy: 'no-referrer' })
|
||||
expect(document.head.querySelector('meta[name="robots"]')?.getAttribute('content')).toBe('noindex, nofollow')
|
||||
expect(document.head.querySelector('meta[name="referrer"]')?.getAttribute('content')).toBe('no-referrer')
|
||||
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
// 화면을 떠나면 머리에 둔 것을 거둔다
|
||||
expect(document.head.querySelector('meta[name="robots"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('[내려받기] — 누르기 직전에 상태를 한 번 더 묻고, 받을 수 있으면 그 파일 주소로 이동한다', async () => {
|
||||
const view = await mountAt(TOKEN, { link: publicLink() })
|
||||
|
||||
await view.get('[data-share-file="f2"] a.share-file__download').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(downloads).toEqual([`/api/public-links/${TOKEN}/files/f2`])
|
||||
})
|
||||
|
||||
it('[내려받기] — 그사이 공유가 멈췄으면 받지 않고 화면을 바꾸고 알린다', async () => {
|
||||
const view = await mountAt(TOKEN, { link: publicLink() }, { link: publicLink({ status: 'revoked', files: [] }) })
|
||||
|
||||
await view.get('[data-share-file="f1"] a.share-file__download').trigger('click')
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
expect(downloads).toEqual([])
|
||||
expect(view.get('[data-share-view="revoked"] h1').text()).toBe('보낸 사람이 공유를 멈췄습니다')
|
||||
expect(view.get('[role="status"]').text()).toBe('이 파일은 지금 내려받을 수 없습니다. 아래 안내를 확인해 주세요.')
|
||||
})
|
||||
|
||||
it('일부 검사 중 — 그 줄만 "보안 검사 중"이고 단추는 aria-disabled(누르면 까닭) · 15초 뒤 다시 묻는다', async () => {
|
||||
const scanning = publicLink({ files: [DESIGN, { ...VIDEO, status: 'scanning' }] })
|
||||
const view = await mountAt(TOKEN, { link: scanning }, { link: publicLink() })
|
||||
|
||||
expect(view.get('h1').text()).toBe('김아라 님이 보낸 파일 2개 (1.8 GB)')
|
||||
const blocked = view.get('[data-share-file="f2"] .share-file__download')
|
||||
expect(blocked.element.tagName).toBe('BUTTON')
|
||||
expect(blocked.attributes('aria-disabled')).toBe('true')
|
||||
await blocked.trigger('click')
|
||||
await flushPromises()
|
||||
expect(view.get('[role="status"]').text()).toBe('612 MB · 보안 검사 중')
|
||||
expect(downloads).toEqual([])
|
||||
|
||||
clock.advance(15_000)
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(view.get('[data-share-file="f2"] .share-file__download').element.tagName).toBe('A')
|
||||
// 다 끝났으면 더 묻지 않는다
|
||||
expect(clock.pendingCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('모두 검사 중 — 제목이 바뀌고 [다시 확인]', async () => {
|
||||
const view = await mountAt(TOKEN, { link: publicLink({ files: [{ ...DESIGN, status: 'scanning' }] }) }, { link: publicLink() })
|
||||
|
||||
expect(view.get('[data-share-view="scanning"] h1').text()).toBe('보안 검사 중입니다')
|
||||
expect(view.get('.share__english').text()).toBe('Security scan in progress — please check again in a few minutes.')
|
||||
|
||||
await view.get('[data-share-recheck]').trigger('click')
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
expect(view.get('[data-share-view="active"] h1').text()).toBe('김아라 님이 보낸 파일 2개 (1.8 GB)')
|
||||
})
|
||||
|
||||
it('위험 · 검사하지 못함 · 횟수 초과 — 줄마다 글, 위험과 횟수 초과는 단추가 없다', async () => {
|
||||
const view = await mountAt(TOKEN, {
|
||||
link: publicLink({
|
||||
files: [
|
||||
{ ...DESIGN, status: 'infected' },
|
||||
{ ...VIDEO, status: 'unscannable' },
|
||||
{ id: 'f3', name: '회의록.zip', size: 3 * MB, status: 'limit-reached' },
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
const infected = view.get('[data-share-file="f1"]')
|
||||
expect(infected.classes()).toContain('share-file--danger')
|
||||
expect(infected.get('.share-file__meta').text()).toBe('1.2 GB · 위험 — 막았습니다')
|
||||
expect(infected.find('.share-file__download').exists()).toBe(false)
|
||||
const unscannable = view.get('[data-share-file="f2"]')
|
||||
expect(unscannable.classes()).toContain('share-file--warning')
|
||||
expect(unscannable.find('a.share-file__download').exists()).toBe(true)
|
||||
const limit = view.get('[data-share-file="f3"]')
|
||||
expect(limit.get('.share-file__meta').text()).toBe(
|
||||
'내려받을 수 있는 횟수를 넘었습니다. 보낸 사람에게 다시 보내 달라고 요청해 주세요.',
|
||||
)
|
||||
expect(limit.find('.share-file__download').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('모두 위험 — "내려받을 수 없는 파일입니다"', async () => {
|
||||
const view = await mountAt(TOKEN, { link: publicLink({ files: [{ ...DESIGN, status: 'infected' }] }) })
|
||||
|
||||
expect(view.get('[data-share-view="infected"] h1').text()).toBe('내려받을 수 없는 파일입니다')
|
||||
expect(view.find('[data-share-recheck]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('만료 — 끝난 날 · 보낸 사람(이름, 주소)과 신고 주소 · 파일 이름은 보이지 않는다', async () => {
|
||||
const view = await mountAt(TOKEN, {
|
||||
link: publicLink({ status: 'expired', expiresAt: '2026-10-29T14:59:59.999Z', files: [] }),
|
||||
})
|
||||
|
||||
expect(view.get('[data-share-view="expired"] h1').text()).toBe('내려받을 수 있는 기간이 지났습니다')
|
||||
expect(view.get('[data-share-view="expired"] .share__line').text()).toBe(
|
||||
'2026년 10월 29일까지 받을 수 있었던 파일입니다. 파일은 지워져 다시 받을 수 없습니다. 필요하면 보낸 사람(김아라, ara.kim@arasystem.it.kr)에게 다시 보내 달라고 요청해 주세요.',
|
||||
)
|
||||
expect(view.get('.share__english').text()).toBe('This link expired on Oct 29, 2026 (KST).')
|
||||
expect(view.find('.share__report').exists()).toBe(true)
|
||||
expect(view.text()).not.toContain('설계도_v3.dwg')
|
||||
})
|
||||
|
||||
it('없는 링크(404) · 모양이 틀린 토큰 — 보낸 사람도 숨기고, 토큰이 틀리면 묻지도 않는다', async () => {
|
||||
const notFound = await mountAt(TOKEN, { status: 404 })
|
||||
expect(notFound.get('[data-share-view="not-found"] h1').text()).toBe('찾을 수 없는 링크입니다')
|
||||
expect(notFound.find('.share__report').exists()).toBe(false)
|
||||
expect(notFound.text()).not.toContain('김아라')
|
||||
notFound.unmount()
|
||||
wrapper = null
|
||||
|
||||
calls = []
|
||||
const malformed = await mountAt('too-short', { link: publicLink() })
|
||||
expect(malformed.get('[data-share-view="not-found"] h1').text()).toBe('찾을 수 없는 링크입니다')
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
|
||||
it('요청이 너무 많음(429) · 네트워크 실패 · JSON 이 아닌 응답 — [다시 시도]로 처음부터 다시 묻는다', async () => {
|
||||
const tooMany = await mountAt(TOKEN, { status: 429 }, 'network', 'html', { link: publicLink() })
|
||||
expect(tooMany.get('[data-share-view="too-many"] h1').text()).toBe('요청이 너무 많습니다')
|
||||
|
||||
await tooMany.get('[data-share-retry]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(tooMany.get('[data-share-view="error"] h1').text()).toBe('화면을 불러오지 못했습니다')
|
||||
|
||||
await tooMany.get('[data-share-retry]').trigger('click')
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
// 앞단이 HTML 을 돌려줘도 그리다 깨지지 않는다
|
||||
expect(tooMany.get('[data-share-view="error"] h1').text()).toBe('화면을 불러오지 못했습니다')
|
||||
|
||||
await tooMany.get('[data-share-retry]').trigger('click')
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
expect(tooMany.get('[data-share-view="active"] h1').text()).toBe('김아라 님이 보낸 파일 2개 (1.8 GB)')
|
||||
})
|
||||
|
||||
it('보낸 사람 이름이 없으면 주소로 — 제목 · 탭 제목', async () => {
|
||||
const view = await mountAt(TOKEN, {
|
||||
link: publicLink({ sender: { name: null, email: 'ara.kim@arasystem.it.kr' }, files: [DESIGN] }),
|
||||
})
|
||||
|
||||
expect(view.get('h1').text()).toBe('ara.kim@arasystem.it.kr 님이 보낸 파일 1개 (1.2 GB)')
|
||||
expect(view.find('.share__email').exists()).toBe(false)
|
||||
expect(document.title).toBe('ara.kim@arasystem.it.kr 님이 보낸 파일 — ARA SYSTEM')
|
||||
})
|
||||
|
||||
it('불러오는 중 — 자리 그림은 숨기고 "불러오는 중"을 화면 낭독기에 읽어 준다 (리뷰 L9)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(() => new Promise<Response>(() => undefined)))
|
||||
const router = createTestRouter()
|
||||
await router.push(`/s/${TOKEN}`)
|
||||
await router.isReady()
|
||||
wrapper = mount(SharePage, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router], provide: { [CLOCK as symbol]: clock } },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const loading = wrapper.get('[data-share-loading]')
|
||||
expect(loading.attributes('role')).toBe('status')
|
||||
expect(loading.text()).toBe('불러오는 중')
|
||||
expect(loading.findAll('[aria-hidden="true"]')).toHaveLength(3)
|
||||
expect(wrapper.get('main').attributes('aria-busy')).toBe('true')
|
||||
})
|
||||
|
||||
it('보낸 사람 이름이 빈 글이어도 주소로 — 이름 칸이 비지 않는다', async () => {
|
||||
const view = await mountAt(TOKEN, {
|
||||
link: publicLink({ sender: { name: '', email: 'ara.kim@arasystem.it.kr' }, files: [DESIGN] }),
|
||||
})
|
||||
|
||||
expect(view.get('.share__intro strong').text()).toBe('ara.kim@arasystem.it.kr')
|
||||
expect(view.find('.share__email').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,640 @@
|
||||
<script setup lang="ts">
|
||||
// 공개 내려받기 화면 — /s/:token (디자인 원본 SharePage · README "11단계 › 내려받기 화면" · 계약 4절 "공개 내려받기")
|
||||
// 메일 속 링크는 파일이 아니라 이 화면으로 온다. 로그인 없이 링크가 있는 누구나 연다 — 계정 비밀번호는 묻지 않는다.
|
||||
// 독립 화면: 앱 레일 · 상단 바 · 로그인 메뉴가 없다. 머리 바(#0B3640, 60 — 좁으면 52)에 흰 ARA SYSTEM 로고(88 — 좁으면 76) + "파일 내려받기",
|
||||
// 바탕 위 가운데 열 640. 탭 제목 "김아라 님이 보낸 파일 — ARA SYSTEM". 메일 제목 · 본문 · 받는 사람, 받은 수 · 남은 수는 보이지 않는다.
|
||||
// - 링크 정보(GET /api/public-links/{token})는 쿠키 없이 묻는다(usePublicShare — 세션 갱신 · 세션 만료 대화상자를 타지 않는다).
|
||||
// - 상태: 정상 · 검사 중(모두면 제목이 바뀌고 [다시 확인], 일부면 그 줄만) · 위험 · 검사하지 못함 · 횟수 초과 · 만료 · 회수(파일 이름도 숨김) ·
|
||||
// 없는 링크(보낸 사람도 숨김) · 요청이 너무 많음([다시 시도]) · 그 밖의 실패. 모든 상태에서 머리 · 영어 한 줄은 그대로, 신고 주소는 링크를 알 때.
|
||||
// - [내려받기]는 <a href> 이동(늘 저장 — 큰 파일을 fetch 로 받으면 메모리에 다 오른다) — 누르기 직전에 상태를 한 번 더 묻고,
|
||||
// 받을 수 없게 됐으면 화면을 바꾸고 알린다(이동 뒤 JSON 오류가 뜨지 않게). 끊기면 브라우저의 다시 시도로 이어 받는다.
|
||||
// - 검사 중인 파일이 있으면 잠시 뒤 다시 묻는다(15 · 30 · 60초 — 링크 정보는 IP 마다 1분 60회 안).
|
||||
// - 검색 엔진에 걸리지 않게(robots noindex) · 이 화면에서 나가는 요청에 주소가 실리지 않게(referrer no-referrer) 머리에 둔다 (앞단도 머리글로 막는다)
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import logoFull from '@/assets/brand/logo-arasystem-white.png'
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ShareFileRow from '@/components/share/ShareFileRow.vue'
|
||||
import { useClock, type ClockTimer } from '@/composables/useClock'
|
||||
import { usePublicShare, type PublicLinkResult } from '@/composables/usePublicShare'
|
||||
import { APP_NAME } from '@/shared/constants/brand'
|
||||
import { SHARE_PAGE_TEXT, SHARE_SCAN_POLL_DELAYS_MS, SHARE_SCAN_POLL_LIMIT } from '@/shared/constants/large-file'
|
||||
import type { PublicLink, PublicLinkFile } from '@/shared/types/large-file'
|
||||
import { formatCount, formatFileSize } from '@/shared/utils/format'
|
||||
import {
|
||||
formatKstEnglishDate,
|
||||
formatKstFullDate,
|
||||
formatKstLongDate,
|
||||
kstDaysLeft,
|
||||
} from '@/shared/utils/kst-date'
|
||||
import { isDownloadable } from '@/shared/utils/large-file-status'
|
||||
|
||||
type ShareState = { kind: 'loading' } | PublicLinkResult
|
||||
|
||||
const route = useRoute()
|
||||
const clock = useClock()
|
||||
const share = usePublicShare()
|
||||
|
||||
const state = shallowRef<ShareState>({ kind: 'loading' })
|
||||
/** 받기 전에 상태를 다시 묻는 중인 파일 */
|
||||
const busyFileId = ref<string | null>(null)
|
||||
/** 화면 낭독기에 읽어 줄 글 (role=status) */
|
||||
const announcement = ref<string>('')
|
||||
const host = typeof window === 'undefined' ? '' : window.location.host
|
||||
|
||||
let seq = 0
|
||||
let pollTimer: ClockTimer | null = null
|
||||
let polls = 0
|
||||
let unmounted = false
|
||||
const headTags: HTMLMetaElement[] = []
|
||||
|
||||
const token = computed(() => (typeof route.params.token === 'string' ? route.params.token : ''))
|
||||
const link = computed<PublicLink | null>(() => (state.value.kind === 'ok' ? state.value.link : null))
|
||||
const files = computed<PublicLinkFile[]>(() => (link.value?.status === 'active' ? link.value.files : []))
|
||||
const senderName = computed(() => link.value?.sender.name || link.value?.sender.email || '')
|
||||
const senderInline = computed(() =>
|
||||
link.value ? SHARE_PAGE_TEXT.senderInline(link.value.sender.name, link.value.sender.email) : '',
|
||||
)
|
||||
const totalSize = computed(() => formatFileSize(files.value.reduce((sum, file) => sum + file.size, 0)))
|
||||
const allScanning = computed(() => files.value.length > 0 && files.value.every((file) => file.status === 'scanning'))
|
||||
const allInfected = computed(() => files.value.length > 0 && files.value.every((file) => file.status === 'infected'))
|
||||
const anyDownloadable = computed(() => files.value.some((file) => isDownloadable(file.status)))
|
||||
|
||||
/** 지금 그릴 모양 — 링크 상태와 파일 상태로 고른다 */
|
||||
const view = computed(() => {
|
||||
const current = state.value
|
||||
if (current.kind !== 'ok') return current.kind
|
||||
if (current.link.status !== 'active') return current.link.status
|
||||
if (allScanning.value) return 'scanning'
|
||||
if (allInfected.value) return 'infected'
|
||||
return 'active'
|
||||
})
|
||||
|
||||
const untilText = computed(() => (link.value ? SHARE_PAGE_TEXT.until(formatKstFullDate(link.value.expiresAt)) : ''))
|
||||
const untilTail = computed(() => {
|
||||
if (!link.value) return ''
|
||||
const days = kstDaysLeft(link.value.expiresAt, clock.now()) ?? 0
|
||||
return days > 0 ? SHARE_PAGE_TEXT.untilTail(formatCount(days)) : SHARE_PAGE_TEXT.untilTailLastDay
|
||||
})
|
||||
|
||||
/** 영어 한 줄 — 상태마다 */
|
||||
const english = computed(() => {
|
||||
switch (view.value) {
|
||||
case 'active':
|
||||
return link.value
|
||||
? SHARE_PAGE_TEXT.englishActive(senderName.value, link.value.sender.email, formatKstEnglishDate(link.value.expiresAt))
|
||||
: ''
|
||||
case 'scanning':
|
||||
return SHARE_PAGE_TEXT.scanningEnglish
|
||||
case 'infected':
|
||||
return SHARE_PAGE_TEXT.infectedEnglish
|
||||
case 'expired':
|
||||
return link.value ? SHARE_PAGE_TEXT.expiredEnglish(formatKstEnglishDate(link.value.expiresAt)) : ''
|
||||
case 'revoked':
|
||||
return SHARE_PAGE_TEXT.revokedEnglish
|
||||
case 'not-found':
|
||||
return SHARE_PAGE_TEXT.notFoundEnglish
|
||||
case 'too-many':
|
||||
return SHARE_PAGE_TEXT.tooManyEnglish
|
||||
case 'error':
|
||||
return SHARE_PAGE_TEXT.errorEnglish
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
function clearPoll() {
|
||||
if (pollTimer !== null) clock.clearTimeout(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
|
||||
/** 검사 중인 파일이 있으면 잠시 뒤 다시 묻는다 — 간격을 늘리며, 정해진 횟수까지 */
|
||||
function schedulePoll() {
|
||||
clearPoll()
|
||||
if (unmounted || !files.value.some((file) => file.status === 'scanning') || polls >= SHARE_SCAN_POLL_LIMIT) return
|
||||
const delays = SHARE_SCAN_POLL_DELAYS_MS
|
||||
const delay = delays[Math.min(polls, delays.length - 1)] ?? 0
|
||||
pollTimer = clock.setTimeout(() => {
|
||||
pollTimer = null
|
||||
polls += 1
|
||||
void load(true)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/** 링크 정보를 묻는다 — quiet 면 지금 화면을 둔 채 바꾼다(다시 묻기 · 받기 직전). 늦게 온 응답은 버린다 */
|
||||
async function load(quiet = false): Promise<PublicLinkResult> {
|
||||
seq += 1
|
||||
const current = seq
|
||||
if (!quiet) state.value = { kind: 'loading' }
|
||||
const result = await share.getPublicLink(token.value)
|
||||
if (current === seq && !unmounted) {
|
||||
state.value = result
|
||||
schedulePoll()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** [다시 확인] · [다시 시도] — 처음부터 다시 묻는다 */
|
||||
function reload() {
|
||||
polls = 0
|
||||
void load()
|
||||
}
|
||||
|
||||
/** 같은 글이어도 다시 읽게 비웠다가 넣는다 */
|
||||
async function announce(text: string) {
|
||||
announcement.value = ''
|
||||
await nextTick()
|
||||
announcement.value = text
|
||||
}
|
||||
|
||||
/** [내려받기] — 누르기 직전에 상태를 한 번 더 묻고, 받을 수 있을 때만 받는다 */
|
||||
async function download(file: PublicLinkFile) {
|
||||
if (busyFileId.value) return
|
||||
busyFileId.value = file.id
|
||||
try {
|
||||
const result = await load(true)
|
||||
const current =
|
||||
result.kind === 'ok' && result.link.status === 'active'
|
||||
? result.link.files.find((candidate) => candidate.id === file.id)
|
||||
: undefined
|
||||
if (current && isDownloadable(current.status)) share.downloadPublicFile(token.value, file.id)
|
||||
else void announce(SHARE_PAGE_TEXT.unavailableNow)
|
||||
} finally {
|
||||
busyFileId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function addHeadMeta(name: string, content: string) {
|
||||
const meta = document.createElement('meta')
|
||||
meta.name = name
|
||||
meta.content = content
|
||||
document.head.append(meta)
|
||||
headTags.push(meta)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
addHeadMeta('robots', 'noindex, nofollow')
|
||||
addHeadMeta('referrer', 'no-referrer')
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true
|
||||
clearPoll()
|
||||
headTags.splice(0).forEach((meta) => meta.remove())
|
||||
})
|
||||
|
||||
// 탭 제목 — 보낸 사람을 알면 "김아라 님이 보낸 파일 — ARA SYSTEM"
|
||||
watch(
|
||||
senderName,
|
||||
(name) => {
|
||||
document.title = name ? SHARE_PAGE_TEXT.tabTitle(name, APP_NAME) : SHARE_PAGE_TEXT.tabTitleDefault(APP_NAME)
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
// 주소의 토큰이 바뀌면 다시 묻는다 (처음 열 때 포함)
|
||||
watch(
|
||||
token,
|
||||
() => {
|
||||
polls = 0
|
||||
void load()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="share">
|
||||
<header class="share__bar">
|
||||
<img
|
||||
:src="logoFull"
|
||||
:alt="APP_NAME"
|
||||
width="960"
|
||||
height="459"
|
||||
class="share__logo"
|
||||
>
|
||||
<span
|
||||
class="share__bar-divider"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="share__bar-title">{{ SHARE_PAGE_TEXT.barTitle }}</span>
|
||||
</header>
|
||||
|
||||
<main
|
||||
class="share__main"
|
||||
:aria-busy="view === 'loading' ? 'true' : undefined"
|
||||
>
|
||||
<div class="share__column">
|
||||
<!-- 불러오는 중 — 자리만 (화면 낭독기에는 "불러오는 중"을 읽어 준다) -->
|
||||
<div
|
||||
v-if="view === 'loading'"
|
||||
class="share__skeleton"
|
||||
role="status"
|
||||
data-share-loading
|
||||
>
|
||||
<span class="visually-hidden">{{ SHARE_PAGE_TEXT.loading }}</span>
|
||||
<span
|
||||
class="share__bar-skeleton share__bar-skeleton--title"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
class="share__bar-skeleton"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
class="share__bar-skeleton share__bar-skeleton--short"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 정상 -->
|
||||
<template v-else-if="view === 'active' && link">
|
||||
<div
|
||||
class="share__intro"
|
||||
data-share-view="active"
|
||||
>
|
||||
<h1 class="share__title">
|
||||
{{ SHARE_PAGE_TEXT.title(senderName, formatCount(files.length), totalSize) }}
|
||||
</h1>
|
||||
<p class="share__line">
|
||||
{{ SHARE_PAGE_TEXT.senderLabel }}
|
||||
<strong>{{ link.sender.name || link.sender.email }}</strong>
|
||||
<span
|
||||
v-if="link.sender.name"
|
||||
class="share__email"
|
||||
><span class="share__bracket"> <</span>{{ link.sender.email }}<span class="share__bracket">></span></span>
|
||||
<span class="share__org"> · {{ APP_NAME }}</span>
|
||||
</p>
|
||||
<p class="share__line">
|
||||
<strong>{{ untilText }}</strong>{{ untilTail }}
|
||||
</p>
|
||||
</div>
|
||||
<ul class="share__files">
|
||||
<ShareFileRow
|
||||
v-for="file in files"
|
||||
:key="file.id"
|
||||
:file="file"
|
||||
:href="share.publicFileUrl(token, file.id)"
|
||||
:busy="busyFileId === file.id"
|
||||
@download="download"
|
||||
@blocked="announce"
|
||||
/>
|
||||
</ul>
|
||||
<p
|
||||
v-if="anyDownloadable"
|
||||
class="share__hint"
|
||||
>
|
||||
{{ SHARE_PAGE_TEXT.resumeHint }}
|
||||
</p>
|
||||
<p class="share__safety">
|
||||
<AppIcon
|
||||
name="shield"
|
||||
:size="16"
|
||||
class="share__safety-icon"
|
||||
/>
|
||||
<span>{{ SHARE_PAGE_TEXT.noLogin }}</span>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- 모두 검사 중 · 모두 위험 — 제목이 바뀐다 -->
|
||||
<template v-else-if="(view === 'scanning' || view === 'infected') && link">
|
||||
<div
|
||||
class="share__intro"
|
||||
:data-share-view="view"
|
||||
>
|
||||
<h1 class="share__title">
|
||||
{{ view === 'scanning' ? SHARE_PAGE_TEXT.scanningTitle : SHARE_PAGE_TEXT.infectedTitle }}
|
||||
</h1>
|
||||
<p class="share__line">
|
||||
{{ view === 'scanning' ? SHARE_PAGE_TEXT.scanningMessage : SHARE_PAGE_TEXT.infectedMessage }}
|
||||
</p>
|
||||
</div>
|
||||
<ul class="share__files">
|
||||
<ShareFileRow
|
||||
v-for="file in files"
|
||||
:key="file.id"
|
||||
:file="file"
|
||||
:href="share.publicFileUrl(token, file.id)"
|
||||
:busy="busyFileId === file.id"
|
||||
@download="download"
|
||||
@blocked="announce"
|
||||
/>
|
||||
</ul>
|
||||
<div v-if="view === 'scanning'">
|
||||
<button
|
||||
type="button"
|
||||
class="share__action"
|
||||
data-share-recheck
|
||||
@click="reload"
|
||||
>
|
||||
{{ SHARE_PAGE_TEXT.recheck }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 끝난 링크 · 없는 링크 · 요청이 너무 많음 · 그 밖의 실패 -->
|
||||
<div
|
||||
v-else
|
||||
class="share__state"
|
||||
:data-share-view="view"
|
||||
>
|
||||
<AppIcon
|
||||
v-if="view === 'expired' || view === 'revoked' || view === 'not-found'"
|
||||
:name="view === 'expired' ? 'clock' : view === 'revoked' ? 'block' : 'link'"
|
||||
:size="24"
|
||||
class="share__state-icon"
|
||||
/>
|
||||
<h1 class="share__title">
|
||||
<template v-if="view === 'expired'">
|
||||
{{ SHARE_PAGE_TEXT.expiredTitle }}
|
||||
</template>
|
||||
<template v-else-if="view === 'revoked'">
|
||||
{{ SHARE_PAGE_TEXT.revokedTitle }}
|
||||
</template>
|
||||
<template v-else-if="view === 'not-found'">
|
||||
{{ SHARE_PAGE_TEXT.notFoundTitle }}
|
||||
</template>
|
||||
<template v-else-if="view === 'too-many'">
|
||||
{{ SHARE_PAGE_TEXT.tooManyTitle }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ SHARE_PAGE_TEXT.errorTitle }}
|
||||
</template>
|
||||
</h1>
|
||||
<p class="share__line">
|
||||
<template v-if="view === 'expired' && link">
|
||||
{{ SHARE_PAGE_TEXT.expiredMessage(formatKstLongDate(link.expiresAt), senderInline) }}
|
||||
</template>
|
||||
<template v-else-if="view === 'revoked'">
|
||||
{{ SHARE_PAGE_TEXT.revokedMessage(senderInline) }}
|
||||
</template>
|
||||
<template v-else-if="view === 'not-found'">
|
||||
{{ SHARE_PAGE_TEXT.notFoundMessage }}
|
||||
</template>
|
||||
<template v-else-if="view === 'too-many'">
|
||||
{{ SHARE_PAGE_TEXT.tooManyMessage }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ SHARE_PAGE_TEXT.errorMessage }}
|
||||
</template>
|
||||
</p>
|
||||
<div v-if="view === 'too-many' || view === 'error'">
|
||||
<button
|
||||
type="button"
|
||||
class="share__action"
|
||||
data-share-retry
|
||||
@click="reload"
|
||||
>
|
||||
{{ SHARE_PAGE_TEXT.retry }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="link && view !== 'loading'"
|
||||
class="share__report"
|
||||
>
|
||||
{{ SHARE_PAGE_TEXT.reportBefore }}<a :href="`mailto:${link.reportAddress}`">{{ link.reportAddress }}</a>{{ SHARE_PAGE_TEXT.reportAfter }}
|
||||
</p>
|
||||
<p
|
||||
v-if="english"
|
||||
class="share__english"
|
||||
lang="en"
|
||||
>
|
||||
{{ english }}
|
||||
</p>
|
||||
<p
|
||||
class="visually-hidden"
|
||||
role="status"
|
||||
>
|
||||
{{ announcement }}
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="share__footer">
|
||||
{{ APP_NAME }}<template v-if="host">
|
||||
· {{ host }}
|
||||
</template>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.share {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-surface-panel);
|
||||
color: var(--color-text);
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 머리 바 — 60, 흰 로고 88 + 가는 선 + "파일 내려받기" */
|
||||
.share__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-14);
|
||||
min-height: 3.75rem;
|
||||
padding: 0 var(--space-28);
|
||||
background: var(--color-brand-surface);
|
||||
}
|
||||
|
||||
.share__logo {
|
||||
display: block;
|
||||
width: 5.5rem;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.share__bar-divider {
|
||||
width: var(--border-width);
|
||||
height: var(--space-18);
|
||||
background: var(--color-on-brand-track);
|
||||
}
|
||||
|
||||
.share__bar-title {
|
||||
color: var(--color-on-brand-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.share__main {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--space-28) var(--space-24) var(--space-24);
|
||||
}
|
||||
|
||||
/* 가운데 열 640 */
|
||||
.share__column {
|
||||
width: 100%;
|
||||
max-width: 40rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-16);
|
||||
}
|
||||
|
||||
.share__intro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.share__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-heading);
|
||||
line-height: var(--line-height-heading);
|
||||
font-weight: 650;
|
||||
letter-spacing: var(--letter-spacing-tighter);
|
||||
}
|
||||
|
||||
.share__line {
|
||||
margin: 0;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.share__line strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.share__files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.share__hint {
|
||||
margin: calc(var(--space-6) * -1) 0 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 청록 상자 — 로그인이나 비밀번호를 묻지 않는다는 안내 */
|
||||
.share__safety {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-10);
|
||||
margin: 0;
|
||||
padding: var(--space-12) var(--space-14);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand-subtle);
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.share__safety-icon {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* 끝난 링크 · 없는 링크 등 — 아이콘 · 제목 · 글 */
|
||||
.share__state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-10);
|
||||
padding: var(--space-20);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.share__state-icon {
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
/* [다시 확인] · [다시 시도] — 흰 테두리 단추 44 */
|
||||
.share__action {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-16);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.share__action:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.share__report {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.share__english {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.share__footer {
|
||||
padding: var(--space-8) var(--space-24) var(--space-20);
|
||||
text-align: center;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.share__skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-12);
|
||||
}
|
||||
|
||||
.share__bar-skeleton {
|
||||
width: 70%;
|
||||
height: var(--space-12);
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--color-skeleton);
|
||||
}
|
||||
|
||||
.share__bar-skeleton--title {
|
||||
width: 55%;
|
||||
height: var(--space-20);
|
||||
}
|
||||
|
||||
.share__bar-skeleton--short {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
/* 좁은 폭(휴대폰 360) — 머리 52 · 로고 76, 보낸 사람 주소는 이름 아래 줄 */
|
||||
@media (max-width: 30rem) {
|
||||
.share__bar {
|
||||
gap: var(--space-10);
|
||||
min-height: 3.25rem;
|
||||
padding: 0 var(--space-16);
|
||||
}
|
||||
|
||||
.share__logo {
|
||||
width: 4.75rem;
|
||||
}
|
||||
|
||||
.share__bar-divider {
|
||||
height: var(--space-14);
|
||||
}
|
||||
|
||||
.share__main {
|
||||
padding: var(--space-20) var(--space-16) var(--space-18);
|
||||
}
|
||||
|
||||
.share__title {
|
||||
font-size: var(--font-size-title-lg);
|
||||
}
|
||||
|
||||
.share__email {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.share__bracket,
|
||||
.share__org {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user