generated from ttipo/project-base
feat(frontend): 쓰기 화면 나가기 확인·읽기 화면 본문 안내·내 메일 답장 받는 사람
- 쓰기 화면을 연 뒤 바꾼 것이 있으면 떠나기 전에 묻는다 — 앱 안 이동은 확인 대화상자, 탭 닫기·새로고침은 브라우저 확인창. 보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않고, 보내는 중에 떠나면 결과는 알림으로만 알린다(떠난 양식은 건드리지 않는다). 대화상자는 공용 ModalDialog 로 모았다 — 세션 만료 대화상자도 같은 것을 쓴다 - 본문 길이 초과는 MAIL_007 문구로 본문 아래에 알린다 (서버가 같은 코드를 돌려줘도 같다, 발송 키는 그대로) - 읽기 화면에 본문 안내(서식 없이 표시·표시할 수 없음·앞부분만 표시). 잘린 원문을 인용하면 생략 표시를 둔다. 인용할 원문은 읽기 화면과 같은 규칙으로 고른다 — 공백뿐인 HTML·그림만 남는 HTML 은 글자 본문으로 - 붙여 넣은 글·원문 인용에서 8px 미만 글자 크기와 알파 0.3 미만 글자 색을 버린다 (서버와 같은 규칙, 같은 표본 표 105행) - 내가 보낸 메일에 답장하면 받는 사람이 원래 받는 사람이다 (전에는 받는 사람 칸이 비었다) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,7 @@ describe('에러 코드 사전', () => {
|
||||
'MAIL_004', // 첨부 만료 — 쓰기 화면이 첨부를 "다시 올리기 필요"로 표시
|
||||
'MAIL_005', // 같은 키·다른 내용 — 쓰기 화면이 발송 키를 새로 만든다
|
||||
'MAIL_006', // 첨부 20개 초과 — 첨부 사전 검사가 사전 문구를 직접 꺼내 쓴다
|
||||
'MAIL_007', // 본문이 너무 김 — 쓰기 화면이 본문 아래 띠로 안내한다 (보내기 전 검사 · 서버 거절 모두)
|
||||
'FILE_001', // 첨부 형식 사전 검사
|
||||
'FILE_002', // 첨부 크기 사전 검사 · 413 역매핑
|
||||
]
|
||||
|
||||
@@ -115,6 +115,15 @@ const showErrorUI = (code: ErrorCode, message: string, traceId?: string) => {
|
||||
useToastStore().show({ kind: 'error', message, traceId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림을 끄고(silent) 보낸 요청의 실패를 나중에 알린다 — 그 실패를 칸 안에서 안내하려던 화면이 이미 사라졌을 때
|
||||
* (예: 보내는 중에 쓰기 화면을 떠났다). 다른 오류 알림과 같은 길(showErrorUI — 같은 코드 중복 억제 · 세션 만료는 대화상자)이다.
|
||||
*/
|
||||
export function notifyApiError(error: ApiError): void {
|
||||
if (error.canceled) return
|
||||
showErrorUI(error.code, error.message, error.traceId)
|
||||
}
|
||||
|
||||
// 중앙 Axios 인스턴스
|
||||
const api: AxiosInstance = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
|
||||
@@ -171,6 +171,18 @@ describe('useAttachmentUploads', () => {
|
||||
stop()
|
||||
})
|
||||
|
||||
it('올린 항목을 돌려준다 — 쓰기 화면이 "처음 채운 첨부"로 기억한다 (나가기 전 확인)', () => {
|
||||
installMockApi(() => ok({}))
|
||||
const { uploads, stop } = withUploads()
|
||||
uploads.addFiles([file('회의록.pdf')])
|
||||
|
||||
const added = uploads.addExisting([CHECKLIST, { ...CHECKLIST, id: 'att3', name: '견적.pdf' }])
|
||||
|
||||
expect(added.map((item) => item.name)).toEqual(['체크리스트.xlsx', '견적.pdf'])
|
||||
expect(uploads.items.value.slice(1).map((item) => item.key)).toEqual(added.map((item) => item.key))
|
||||
stop()
|
||||
})
|
||||
|
||||
it('허용 목록 밖의 원본 첨부는 실패(FILE_001)로 보이고 발송에서 빠진다', () => {
|
||||
installMockApi(() => ok({}))
|
||||
const { uploads, stop } = withUploads()
|
||||
|
||||
@@ -162,8 +162,9 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
|
||||
* 전달 — 원본 메일의 첨부를 목록에 미리 올려 둔다 (다시 올리지 않고 id 를 그대로 보낸다, 계약 2절 "전달").
|
||||
* 서버는 업로드와 같은 규칙으로 개수·확장자를, 발송 때 합계를 검사한다 — 걸리면 실패로 표시하고 발송에서 뺀다.
|
||||
* (파일당 한도는 업로드에만 있어 보지 않는다)
|
||||
* @returns 목록에 올린 항목 — 쓰기 화면이 "처음 채운 첨부"로 기억한다 (나가기 전 확인)
|
||||
*/
|
||||
function addExisting(attachments: readonly Attachment[]) {
|
||||
function addExisting(attachments: readonly Attachment[]): UploadItem[] {
|
||||
const running: AttachedSummary = { ...attached.value }
|
||||
const added = attachments.map((attachment): UploadItem => {
|
||||
const rejected = precheckAttachment(attachment, limitsFor('forward'), running)
|
||||
@@ -184,6 +185,7 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
|
||||
}
|
||||
})
|
||||
items.value = [...items.value, ...added]
|
||||
return added
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { onBeforeUnmount, readonly, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, type RouteLocationNormalized } from 'vue-router'
|
||||
|
||||
// 화면을 떠나기 전 확인 — 쓰던 내용을 잃는 이동을 묻는다 (디자인 README "나가기 전 확인")
|
||||
// - 앱 안 이동(링크 · 앱 레일 · 뒤로/앞으로 · 화면의 취소 · 닫기): 이동 가드가 답을 기다린다. 화면은 confirming 동안 대화상자를 띄운다.
|
||||
// 남기로 하면 이동을 취소한다 — 뒤로/앞으로였으면 라우터가 주소와 방문 기록을 되돌린다.
|
||||
// - 창 닫기 · 새로고침 · 다른 사이트로 가기: 지킬 것이 있는 동안만 beforeunload 를 걸어 브라우저 기본 확인창을 띄운다.
|
||||
// 화면 컴포넌트의 setup 에서 부른다 — 이동 가드(onBeforeRouteLeave · onBeforeRouteUpdate)는 RouterView 가 그린 화면에 걸린다.
|
||||
|
||||
export interface LeaveConfirmOptions {
|
||||
/**
|
||||
* 지금 앱 안에서 떠나면 잃는 것이 있다 — 이때만 묻는다. 반응형으로 읽는다.
|
||||
* 묻는 중에 false 가 되면(세션이 끝남 등) 대화상자를 닫고 그 이동은 취소한다 — 그 뒤의 이동은 다른 흐름(로그인 화면으로)이 한다.
|
||||
*/
|
||||
active: () => boolean
|
||||
/**
|
||||
* 창 닫기 · 새로고침 확인(beforeunload)을 걸 때 — 주지 않으면 active 와 같다. 반응형으로 읽는다.
|
||||
* 앱 안 이동은 묻지 않아도 창은 닫으면 안 되는 때가 있다 — 예: 보내는 중 (앱 안에서는 요청이 이어지고 결과를 토스트로 알리지만,
|
||||
* 창을 닫으면 요청이 끊길 수 있고 결과를 알릴 곳도 없다)
|
||||
*/
|
||||
unloadActive?: () => boolean
|
||||
/**
|
||||
* 같은 경로 안의 이동(쿼리만 바뀜)도 이 화면을 떠나는 것인가 — 예: 답장 쓰기 → 새 메일 쓰기는 양식을 새로 채운다.
|
||||
* 주지 않으면 같은 경로 안의 이동은 묻지 않는다.
|
||||
*/
|
||||
leavesOnUpdate?: (to: RouteLocationNormalized, from: RouteLocationNormalized) => boolean
|
||||
}
|
||||
|
||||
export function useLeaveConfirm(options: LeaveConfirmOptions) {
|
||||
/** 대화상자를 띄우고 답을 기다리는 중 */
|
||||
const confirming = ref<boolean>(false)
|
||||
/** 기다리는 답 — 뒤로 가기를 거듭 눌러 이동이 겹쳐도 대화상자는 하나, 답도 하나다 */
|
||||
let pending: Promise<boolean> | null = null
|
||||
let resolvePending: ((leave: boolean) => void) | null = null
|
||||
|
||||
function ask(): Promise<boolean> {
|
||||
if (!pending) {
|
||||
pending = new Promise<boolean>((resolve) => {
|
||||
resolvePending = resolve
|
||||
})
|
||||
confirming.value = true
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
/** 답한다 — true: 떠난다 · false: 남는다 (이동을 취소한다) */
|
||||
function answer(leave: boolean) {
|
||||
const resolve = resolvePending
|
||||
pending = null
|
||||
resolvePending = null
|
||||
confirming.value = false
|
||||
resolve?.(leave)
|
||||
}
|
||||
|
||||
function confirmLeave(): true | Promise<boolean> {
|
||||
return options.active() ? ask() : true
|
||||
}
|
||||
|
||||
onBeforeRouteLeave(() => confirmLeave())
|
||||
onBeforeRouteUpdate((to, from) => (options.leavesOnUpdate?.(to, from) ? confirmLeave() : true))
|
||||
|
||||
/** 창 닫기 · 새로고침 — 브라우저 기본 확인창 (문구는 브라우저가 정한다) */
|
||||
function onBeforeUnload(event: BeforeUnloadEvent) {
|
||||
event.preventDefault()
|
||||
// 옛 브라우저(Chrome · Edge 119 미만)는 returnValue 를 채워야 묻는다
|
||||
event.returnValue = true
|
||||
}
|
||||
|
||||
let listening = false
|
||||
function listenBeforeUnload(on: boolean) {
|
||||
if (on === listening) return
|
||||
listening = on
|
||||
if (on) window.addEventListener('beforeunload', onBeforeUnload)
|
||||
else window.removeEventListener('beforeunload', onBeforeUnload)
|
||||
}
|
||||
|
||||
// 지킬 것이 있는 동안만 건다 — 늘 걸어 두면 일부 브라우저가 페이지를 뒤로 가기 캐시(bfcache)에 두지 않는다 (MDN 권고)
|
||||
watch(options.unloadActive ?? options.active, listenBeforeUnload, { immediate: true })
|
||||
|
||||
watch(options.active, (active) => {
|
||||
if (!active && pending) answer(false)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
listenBeforeUnload(false)
|
||||
// 다른 이동이 이겨 화면이 사라진다 — 기다리던 이동은 취소로 끝낸다
|
||||
if (pending) answer(false)
|
||||
})
|
||||
|
||||
return {
|
||||
confirming: readonly(confirming),
|
||||
/** 계속 쓰기 — 이동을 취소한다 */
|
||||
stay: () => answer(false),
|
||||
/** 나가기 — 이동을 그대로 보낸다 */
|
||||
leave: () => answer(true),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user