generated from ttipo/project-base
feat(frontend): 임시 저장·서명 설정·받는 사람 자동 완성 화면
- 쓰기 화면 자동 저장(3초 멈춤 · 30초 · 10초 간격 · 한 번에 하나)과 상태 글, 저장할 때마다 주소를 ?draft= 로 맞춰 새로고침해도 이어 쓴다. MAIL_008 · PUT 404 는 막지 않고 새 저장본으로 남긴다 - 찾지 못한 첨부가 남았거나 본문이 한도를 넘는 동안은 저장하지 않고, 실을 수 없는 첨부가 든 남의 저장본은 원본을 지우지 않는다 - 휴지통은 휴지통으로 옮기기(PATCH)와 되돌리기 — 이미 휴지통에 있는 메일을 DELETE 로 영구 삭제하지 않게 - 설정 › 메일 › 서명: 서식 · 그림(크기 줄) · 미리 보기 · 넣을 메일, 저장하지 않고 떠나면 묻는다 - 받는 사람 자동 완성: 사내 · 최근 묶음, 초성 검색(조합 중 글자 포함), 완전한 주소는 미리 고르지 않는다 - 본문에 그림으로 보인 첨부(isInline)는 읽기 화면의 첨부 목록에서 빼고 전달에는 붙인다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
// 단위 테스트 전용 — 손으로 돌리는 시계. advance(ms) 를 부를 때만 시간이 흐르고 그 사이에 닿는 타이머를 차례로 부른다.
|
||||
// 화면에는 CLOCK 으로 provide 한다: mount(Page, { global: { provide: { [CLOCK as symbol]: clock } } })
|
||||
// 앱 코드에서 import 하지 않는다 (번들에 들어가지 않는다).
|
||||
import type { Clock, ClockTimer } from '@/composables/useClock'
|
||||
|
||||
interface PendingTimer {
|
||||
id: symbol
|
||||
due: number
|
||||
/** 같은 시각이면 먼저 건 것부터 */
|
||||
order: number
|
||||
callback: () => void
|
||||
}
|
||||
|
||||
export interface ManualClock extends Clock {
|
||||
/** 시간을 ms 만큼 넘긴다 — 그 사이에 닿는 타이머(넘기는 동안 새로 건 것 포함)를 시각 순서대로 부른다 */
|
||||
advance: (ms: number) => void
|
||||
/** 아직 부르지 않은 타이머 수 */
|
||||
pendingCount: () => number
|
||||
}
|
||||
|
||||
export function createManualClock(start = 0): ManualClock {
|
||||
let current = start
|
||||
let nextOrder = 0
|
||||
const timers: PendingTimer[] = []
|
||||
|
||||
function nextDue(limit: number): PendingTimer | null {
|
||||
let found: PendingTimer | null = null
|
||||
for (const timer of timers) {
|
||||
if (timer.due > limit) continue
|
||||
if (!found || timer.due < found.due || (timer.due === found.due && timer.order < found.order)) found = timer
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
return {
|
||||
now: () => current,
|
||||
setTimeout: (callback, ms) => {
|
||||
const timer: PendingTimer = {
|
||||
id: Symbol('timer'),
|
||||
due: current + Math.max(0, ms),
|
||||
order: nextOrder,
|
||||
callback,
|
||||
}
|
||||
nextOrder += 1
|
||||
timers.push(timer)
|
||||
return timer.id
|
||||
},
|
||||
clearTimeout: (handle: ClockTimer) => {
|
||||
const index = timers.findIndex((timer) => timer.id === handle)
|
||||
if (index >= 0) timers.splice(index, 1)
|
||||
},
|
||||
advance: (ms) => {
|
||||
const target = current + ms
|
||||
for (let timer = nextDue(target); timer; timer = nextDue(target)) {
|
||||
timers.splice(timers.indexOf(timer), 1)
|
||||
current = Math.max(current, timer.due)
|
||||
timer.callback()
|
||||
}
|
||||
current = target
|
||||
},
|
||||
pendingCount: () => timers.length,
|
||||
}
|
||||
}
|
||||
@@ -122,3 +122,56 @@ export const STYLE_SAMPLES: readonly (readonly [property: string, value: string,
|
||||
['text-decoration', 'underline', false],
|
||||
]
|
||||
|
||||
|
||||
/** 표본 그림 id — 서버가 만드는 UUID 모양 */
|
||||
export const SAMPLE_IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'
|
||||
|
||||
/**
|
||||
* 그림(img) 속성 표본 — [속성, 값, 서버가 남기는가]. 계약 2절 "서식 본문"의 그림 예외 (서명 그림만).
|
||||
* src 가 남지 않으면 그림째 지운다. width · alt 가 남지 않으면 그 속성만 지운다.
|
||||
* ⚠️ 서버 정화(backend/src/modules/mail/html/signature-images.spec.ts · sanitize-outgoing-html.spec.ts)와 **같은 표**를 둔다.
|
||||
* 서버는 여기에 더해 id 가 보내는 사람의 그림인지 본다 (표의 id 는 보내는 사람의 그림이라고 본다).
|
||||
*/
|
||||
export const IMAGE_SAMPLES: readonly (readonly [attribute: 'src' | 'width' | 'alt', value: string, kept: boolean])[] = [
|
||||
// 주소 — 정확히 /api/mail-signature/images/{uuid} (앞뒤 공백은 떼고, 대소문자는 가리지 않는다)
|
||||
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}`, true],
|
||||
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID.toUpperCase()}`, true],
|
||||
['src', ` /api/mail-signature/images/${SAMPLE_IMAGE_ID} `, true],
|
||||
['src', '', false],
|
||||
['src', '/api/mail-signature/images/', false],
|
||||
['src', '/api/mail-signature/images/not-a-uuid', false],
|
||||
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}?x=1`, false],
|
||||
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}#a`, false],
|
||||
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}/../x`, false],
|
||||
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}/`, false],
|
||||
['src', `https://portal.example/api/mail-signature/images/${SAMPLE_IMAGE_ID}`, false],
|
||||
['src', `//portal.example/api/mail-signature/images/${SAMPLE_IMAGE_ID}`, false],
|
||||
['src', `api/mail-signature/images/${SAMPLE_IMAGE_ID}`, false],
|
||||
['src', `/API/mail-signature/images/${SAMPLE_IMAGE_ID}`, true],
|
||||
['src', `/api/files/${SAMPLE_IMAGE_ID}`, false],
|
||||
['src', `cid:${SAMPLE_IMAGE_ID}`, false],
|
||||
['src', 'data:image/png;base64,iVBORw0KGgo=', false],
|
||||
['src', 'https://evil.example/t.png', false],
|
||||
// 폭 — 1~600 정수 (앞뒤 공백은 뗀다)
|
||||
['width', '1', true],
|
||||
['width', '96', true],
|
||||
['width', '144', true],
|
||||
['width', '600', true],
|
||||
['width', ' 144 ', true],
|
||||
['width', '0', false],
|
||||
['width', '601', false],
|
||||
['width', '1000', false],
|
||||
['width', '096', false],
|
||||
['width', '-1', false],
|
||||
['width', '+5', false],
|
||||
['width', '14.5', false],
|
||||
['width', '144px', false],
|
||||
['width', '50%', false],
|
||||
['width', '1e2', false],
|
||||
['width', '', false],
|
||||
// 설명 — 200자 이하면 그대로 (빈 글자 포함)
|
||||
['alt', '', true],
|
||||
['alt', '회사 로고', true],
|
||||
['alt', 'ㄱ'.repeat(200), true],
|
||||
['alt', 'ㄱ'.repeat(201), false],
|
||||
]
|
||||
|
||||
@@ -28,6 +28,13 @@ export function createTestRouter(): Router {
|
||||
component: Empty,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{ path: '/settings', name: 'settings', redirect: { name: 'settings-mail-signature' } },
|
||||
{
|
||||
path: '/settings/mail/signature',
|
||||
name: 'settings-mail-signature',
|
||||
component: Empty,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user