From 76cbdcf74c251cfa13b567833ea6cbe41d521a12 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 7 Nov 2026 14:09:51 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=EC=98=A4=EB=A5=98=20=EC=82=AC?= =?UTF-8?q?=EC=A0=84=20=EB=AC=B8=EA=B5=AC=2018=EA=B3=B3=EC=9D=84=20?= =?UTF-8?q?=EC=82=AC=EB=9E=8C=EC=9D=B4=20=EB=A7=90=ED=95=98=EB=93=AF=20?= =?UTF-8?q?=EB=8B=A4=EB=93=AC=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-11-07 사용자가 "자연스러운 문구가 아니라 시스템, 설계 문구 형식"이라고 짚은 글을 고친다(규칙 9 · 10 — 화면 설명 · 번역투 빼기). "일시적인 시스템 오류가 발생했습니다" · "요청하신 정보나 페이지를 찾을 수 없습니다" · "허용 범위를 초과했습니다" 같은 안내문 말투를 무엇이 안 됐고 무엇을 하면 되는지로 바꾼다. 적용하며 셋을 더 다듬었다: REQ_001 은 가장 자주 보는 로그인 · 코드 실패 한도와 진행 중인 일에 함께 맞게, MAIL_003 은 용량이 휴지통도 세므로 휴지통 비우기까지, FILE_001 은 전달하는 원본 첨부에도 맞게. 코드 · 상태 · 뜻은 그대로이고, 웹 · 앱 사전은 sync 로 다시 만들었다. Co-Authored-By: Claude Opus 5.5 --- backend/src/common/errors/error-codes.ts | 40 ++++++++++--------- .../mail-engine-events.parser.spec.ts | 2 +- flutter/lib/core/api/error_lexicon.g.dart | 36 ++++++++--------- flutter/test/error_lexicon_test.dart | 2 +- .../shared/constants/error-codes.generated.ts | 36 ++++++++--------- 5 files changed, 59 insertions(+), 57 deletions(-) diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index 814f837..d179cc1 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -20,30 +20,32 @@ export interface ErrorCodeDefinition { const COMMON_ERROR_CODES = { SYS_001: { status: HttpStatus.INTERNAL_SERVER_ERROR, - message: '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', + message: + '문제가 생겨 하던 일을 마치지 못했습니다. 잠시 뒤 다시 시도해 주세요.', }, REQ_001: { status: HttpStatus.TOO_MANY_REQUESTS, - message: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.', + message: + '너무 여러 번 시도했거나 같은 일이 이미 진행 중입니다. 잠시 뒤 다시 시도해 주세요.', }, AUTH_001: { status: HttpStatus.UNAUTHORIZED, - message: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.', + message: '로그인이 필요합니다. 로그인한 뒤 다시 시도해 주세요.', }, AUTH_002: { status: HttpStatus.UNAUTHORIZED, - message: '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.', + message: '안전을 위해 로그아웃했습니다. 다시 로그인해 주세요.', // 세션 만료는 클라이언트가 "인증된 세션이 있었는데 401 을 받은" 상황에서 생성한다. clientOnly: true, }, AUTH_003: { status: HttpStatus.FORBIDDEN, - message: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.', + message: '이 기능을 쓸 수 있는 권한이 없습니다.', }, // 로그인 자격 증명 불일치 — 계정 없음/비밀번호 틀림을 구분하지 않는다 (.claude/rules/security.md) AUTH_004: { status: HttpStatus.UNAUTHORIZED, - message: '이메일 또는 비밀번호가 올바르지 않습니다.', + message: '메일 주소나 비밀번호가 맞지 않습니다. 다시 확인해 주세요.', }, // 비밀번호 바꾸기(PUT /api/auth/password)의 지금 비밀번호가 틀림 — 세션은 그대로다. // 401 이 아니라 400 인 까닭: 웹 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다 (계약 1절) @@ -56,7 +58,7 @@ const COMMON_ERROR_CODES = { AUTH_006: { status: HttpStatus.BAD_REQUEST, message: - '코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 자동으로 맞춰지도록 설정되어 있는지 확인해 주세요.', + '코드가 맞지 않습니다. 휴대폰 설정에서 날짜와 시간을 자동으로 맞추게 해 두었는지 확인해 주세요.', }, // 2단계 인증을 켠 직원의 요청에 코드가 없음 — 다른 곳에서 켠 뒤의 화면. 화면은 코드 칸을 보인다 (계약 5절) AUTH_007: { @@ -88,11 +90,11 @@ const COMMON_ERROR_CODES = { }, RES_001: { status: HttpStatus.NOT_FOUND, - message: '요청하신 정보나 페이지를 찾을 수 없습니다.', + message: '찾을 수 없습니다. 이미 지워졌거나 옮겨졌을 수 있습니다.', }, BIZ_001: { status: HttpStatus.BAD_REQUEST, - message: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.', + message: '하려던 일을 마치지 못했습니다. 다시 시도해 주세요.', }, } as const satisfies Record; @@ -109,24 +111,24 @@ const COMMON_ERROR_CODES = { const DOMAIN_ERROR_CODES = { FILE_001: { status: HttpStatus.BAD_REQUEST, - message: '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.', + message: + '쓸 수 없는 형식의 파일입니다. 그 파일을 빼거나 다른 파일로 바꿔 주세요.', }, FILE_002: { status: HttpStatus.PAYLOAD_TOO_LARGE, - message: '파일 크기가 허용 범위를 초과했습니다.', + message: '파일이 너무 커서 올릴 수 없습니다.', }, // 대용량 첨부(11단계 — 계약 4절) — 올리기 · 파일의 지금 상태와 맞지 않는 요청: 끝낸 올리기에 조각 · 같은 조각을 동시에 · 받은 조각과 내용이 다른 조각 · // 빠진 조각이 있는데 끝내기 · 끝내기가 진행 중 · 살아 있는 링크에 실린 파일 지우기. 아무것도 바꾸지 않았다 — 클라이언트는 GET 으로 상태를 보고 잇는다 FILE_003: { status: HttpStatus.CONFLICT, message: - '파일 상태가 그사이 바뀌어 처리하지 못했습니다. 다시 시도해 주세요.', + '그사이 파일이 바뀌어 하던 일을 마치지 못했습니다. 다시 시도해 주세요.', }, // 조각의 Content-Digest(sha-256)가 받은 바이트와 다르다 — 그 조각을 받지 않았다 (같은 조각을 다시 보낸다) FILE_004: { status: HttpStatus.BAD_REQUEST, - message: - '파일을 올리는 중에 일부가 제대로 전달되지 않았습니다. 다시 시도해 주세요.', + message: '파일 일부가 제대로 올라가지 않았습니다. 다시 시도해 주세요.', }, // 직원 보관 한도(보관량 · 보내지 않은 파일 수)를 넘는다 — 만들지 않았다. 문구에 숫자를 넣지 않는다(한도가 env 로 바뀐다) FILE_005: { @@ -138,7 +140,7 @@ const DOMAIN_ERROR_CODES = { FILE_006: { status: HttpStatus.INSUFFICIENT_STORAGE, message: - '서버의 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.', + '회사 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.', }, // 더 내려받을 수 없는 파일 — 기간 지남 · 공유 멈춤 · 횟수 초과 · 위험 · 찾을 수 없음 (공개 받기 · 우리 웹메일 받기) FILE_007: { @@ -163,14 +165,14 @@ const DOMAIN_ERROR_CODES = { MAIL_003: { status: HttpStatus.BAD_REQUEST, message: - '메일함 용량이 부족합니다. 불필요한 메일을 정리한 뒤 다시 시도해 주세요.', + '메일함이 가득 찼습니다. 필요 없는 메일을 지우고 휴지통을 비운 뒤 다시 시도해 주세요.', }, // 발송 때 엔진에서 첨부(blob)를 찾지 못함 — 올려 두고 오래 지나 만료된 업로드·그사이 지워진 원본 메일의 첨부(전달)·잘못된 첨부 id. // 문구는 원인을 단정하지 않는다 — 올린 파일은 다시 올리고, 전달하는 원본 첨부는 빼야 한다 (계약 2절) MAIL_004: { status: HttpStatus.BAD_REQUEST, message: - '첨부 파일을 찾을 수 없습니다. 해당 첨부를 빼거나 다시 올린 뒤 보내 주세요.', + '첨부 파일을 찾을 수 없습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요.', }, // 같은 Idempotency-Key 로 이미 보낸 메일과 내용(받는 사람·제목·본문·첨부)이 다르다 — 보내지 않는다. // 첫 메일이 실제로 나갔을 수 있으므로 클라이언트는 보낸편지함을 확인하게 하고 새 키로 다시 보내게 한다 @@ -183,7 +185,7 @@ const DOMAIN_ERROR_CODES = { // 문구의 숫자는 attachment.policy.ts 의 MAX_ATTACHMENTS_PER_MESSAGE 와 같아야 한다 (attachment.policy.spec.ts 가 확인한다) MAIL_006: { status: HttpStatus.BAD_REQUEST, - message: '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.', + message: '첨부 파일은 메일 한 통에 20개까지 보낼 수 있습니다.', }, // 본문이 너무 길다 — 텍스트 100,000자·서식 본문 500,000자 초과, 서식 본문이 정화 전 비용 한도나 정화 뒤 2,000,000바이트를 넘음. // 메일은 나가지 않는다. 클라이언트는 보내기 전에 같은 한도로 미리 막고 본문 아래에 이 문구를 보인다 (계약 2절) @@ -248,7 +250,7 @@ const DOMAIN_ERROR_CODES = { MAIL_016: { status: HttpStatus.BAD_REQUEST, message: - '대용량 첨부 파일을 보낼 수 없습니다. 해당 파일을 빼거나 다시 올린 뒤 보내 주세요.', + '보낼 수 없는 대용량 첨부 파일이 있습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요.', }, // 주소록(8단계) — 그룹 만들기 · 이름 바꾸기 때 이 주소록에 같은 이름의 그룹이 있다(앞뒤 공백을 빼고 ASCII 대소문자를 무시하고 견준다). // 화면은 이름 칸 아래에 알림 없이 보인다 (계약 3절) diff --git a/backend/src/modules/account-security/mail-engine-events.parser.spec.ts b/backend/src/modules/account-security/mail-engine-events.parser.spec.ts index 793f912..9489d82 100644 --- a/backend/src/modules/account-security/mail-engine-events.parser.spec.ts +++ b/backend/src/modules/account-security/mail-engine-events.parser.spec.ts @@ -150,7 +150,7 @@ describe('엔진 웹훅 본문 파서 (createMailEngineEventsBodyParser)', () => success: false, error: { code: 'AUTH_001', - message: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.', + message: '로그인이 필요합니다. 로그인한 뒤 다시 시도해 주세요.', traceId: 'trace-e', }, }; diff --git a/flutter/lib/core/api/error_lexicon.g.dart b/flutter/lib/core/api/error_lexicon.g.dart index d1bf2e2..f378976 100644 --- a/flutter/lib/core/api/error_lexicon.g.dart +++ b/flutter/lib/core/api/error_lexicon.g.dart @@ -1,35 +1,35 @@ // backend/src/common/errors/error-codes.ts 에서 자동 생성된 파일입니다. 직접 수정하지 마세요. const Map kErrorCodeMessages = { - 'SYS_001': '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', - 'REQ_001': '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.', - 'AUTH_001': '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.', - 'AUTH_002': '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.', - 'AUTH_003': '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.', - 'AUTH_004': '이메일 또는 비밀번호가 올바르지 않습니다.', + 'SYS_001': '문제가 생겨 하던 일을 마치지 못했습니다. 잠시 뒤 다시 시도해 주세요.', + 'REQ_001': '너무 여러 번 시도했거나 같은 일이 이미 진행 중입니다. 잠시 뒤 다시 시도해 주세요.', + 'AUTH_001': '로그인이 필요합니다. 로그인한 뒤 다시 시도해 주세요.', + 'AUTH_002': '안전을 위해 로그아웃했습니다. 다시 로그인해 주세요.', + 'AUTH_003': '이 기능을 쓸 수 있는 권한이 없습니다.', + 'AUTH_004': '메일 주소나 비밀번호가 맞지 않습니다. 다시 확인해 주세요.', 'AUTH_005': '지금 비밀번호가 올바르지 않습니다.', - 'AUTH_006': '코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 자동으로 맞춰지도록 설정되어 있는지 확인해 주세요.', + 'AUTH_006': '코드가 맞지 않습니다. 휴대폰 설정에서 날짜와 시간을 자동으로 맞추게 해 두었는지 확인해 주세요.', 'AUTH_007': '인증 앱의 코드를 입력해 주세요.', 'AUTH_008': '2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요.', 'AUTH_009': '5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.', 'AUTH_010': '먼저 비밀번호를 바꾸거나 2단계 인증을 켜 주세요.', 'VAL_001': '입력하신 내용을 다시 확인해 주세요. 빠진 칸이 있거나 형식이나 길이가 맞지 않습니다.', - 'RES_001': '요청하신 정보나 페이지를 찾을 수 없습니다.', - 'BIZ_001': '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.', - 'FILE_001': '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.', - 'FILE_002': '파일 크기가 허용 범위를 초과했습니다.', - 'FILE_003': '파일 상태가 그사이 바뀌어 처리하지 못했습니다. 다시 시도해 주세요.', - 'FILE_004': '파일을 올리는 중에 일부가 제대로 전달되지 않았습니다. 다시 시도해 주세요.', + 'RES_001': '찾을 수 없습니다. 이미 지워졌거나 옮겨졌을 수 있습니다.', + 'BIZ_001': '하려던 일을 마치지 못했습니다. 다시 시도해 주세요.', + 'FILE_001': '쓸 수 없는 형식의 파일입니다. 그 파일을 빼거나 다른 파일로 바꿔 주세요.', + 'FILE_002': '파일이 너무 커서 올릴 수 없습니다.', + 'FILE_003': '그사이 파일이 바뀌어 하던 일을 마치지 못했습니다. 다시 시도해 주세요.', + 'FILE_004': '파일 일부가 제대로 올라가지 않았습니다. 다시 시도해 주세요.', 'FILE_005': '대용량 첨부 보관 한도를 넘어 더 올릴 수 없습니다. 보낸 파일의 링크를 끊거나 보내지 않은 파일을 지우면 공간이 생깁니다.', - 'FILE_006': '서버의 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.', + 'FILE_006': '회사 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.', 'FILE_007': '이 파일은 더 내려받을 수 없습니다. 필요하면 보낸 사람에게 문의해 주세요.', 'FILE_008': '보안 검사가 끝나지 않은 파일입니다. 잠시 뒤 다시 시도해 주세요.', 'MAIL_001': '메일 서비스에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.', 'MAIL_002': '받는 사람 주소를 다시 확인해 주세요.', - 'MAIL_003': '메일함 용량이 부족합니다. 불필요한 메일을 정리한 뒤 다시 시도해 주세요.', - 'MAIL_004': '첨부 파일을 찾을 수 없습니다. 해당 첨부를 빼거나 다시 올린 뒤 보내 주세요.', + 'MAIL_003': '메일함이 가득 찼습니다. 필요 없는 메일을 지우고 휴지통을 비운 뒤 다시 시도해 주세요.', + 'MAIL_004': '첨부 파일을 찾을 수 없습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요.', 'MAIL_005': '이미 보낸 메일과 내용이 달라 보내지 않았습니다. 보낸편지함을 확인한 뒤 다시 보내 주세요.', - 'MAIL_006': '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.', + 'MAIL_006': '첨부 파일은 메일 한 통에 20개까지 보낼 수 있습니다.', 'MAIL_007': '본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.', 'MAIL_008': '임시 저장한 메일이 다른 곳에서 바뀌었거나 지워졌습니다. 지금 내용을 새 임시 저장본으로 남겨 주세요.', 'MAIL_009': '같은 이름의 메일함이 이미 있습니다.', @@ -39,7 +39,7 @@ const Map kErrorCodeMessages = { 'MAIL_013': '필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나, 쓰지 않는 필터나 차단한 주소를 지워 주세요.', 'MAIL_014': '이미 보낸 메일이라 취소할 수 없습니다.', 'MAIL_015': '보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.', - 'MAIL_016': '대용량 첨부 파일을 보낼 수 없습니다. 해당 파일을 빼거나 다시 올린 뒤 보내 주세요.', + 'MAIL_016': '보낼 수 없는 대용량 첨부 파일이 있습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요.', 'CONTACT_001': '같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요.', 'CONTACT_002': '주소록 한도를 넘어 더 넣을 수 없습니다. 연락처 3,000개, 그룹 100개, 그룹 하나에 500명까지 넣을 수 있습니다.', }; diff --git a/flutter/test/error_lexicon_test.dart b/flutter/test/error_lexicon_test.dart index 1998e5f..d3c5c90 100644 --- a/flutter/test/error_lexicon_test.dart +++ b/flutter/test/error_lexicon_test.dart @@ -7,7 +7,7 @@ void main() { test('등록된 코드는 해당 한국어 메시지를 반환한다', () { expect( ErrorLexicon.getMessage('AUTH_001'), - '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.', + '로그인이 필요합니다. 로그인한 뒤 다시 시도해 주세요.', ); }); diff --git a/frontend/src/shared/constants/error-codes.generated.ts b/frontend/src/shared/constants/error-codes.generated.ts index 779e9d1..b539a34 100644 --- a/frontend/src/shared/constants/error-codes.generated.ts +++ b/frontend/src/shared/constants/error-codes.generated.ts @@ -1,35 +1,35 @@ // backend/src/common/errors/error-codes.ts 에서 자동 생성된 파일입니다. 직접 수정하지 마세요. export const ERROR_CODE_MESSAGES = { - SYS_001: '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', - REQ_001: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.', - AUTH_001: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.', - AUTH_002: '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.', - AUTH_003: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.', - AUTH_004: '이메일 또는 비밀번호가 올바르지 않습니다.', + SYS_001: '문제가 생겨 하던 일을 마치지 못했습니다. 잠시 뒤 다시 시도해 주세요.', + REQ_001: '너무 여러 번 시도했거나 같은 일이 이미 진행 중입니다. 잠시 뒤 다시 시도해 주세요.', + AUTH_001: '로그인이 필요합니다. 로그인한 뒤 다시 시도해 주세요.', + AUTH_002: '안전을 위해 로그아웃했습니다. 다시 로그인해 주세요.', + AUTH_003: '이 기능을 쓸 수 있는 권한이 없습니다.', + AUTH_004: '메일 주소나 비밀번호가 맞지 않습니다. 다시 확인해 주세요.', AUTH_005: '지금 비밀번호가 올바르지 않습니다.', - AUTH_006: '코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 자동으로 맞춰지도록 설정되어 있는지 확인해 주세요.', + AUTH_006: '코드가 맞지 않습니다. 휴대폰 설정에서 날짜와 시간을 자동으로 맞추게 해 두었는지 확인해 주세요.', AUTH_007: '인증 앱의 코드를 입력해 주세요.', AUTH_008: '2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요.', AUTH_009: '5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.', AUTH_010: '먼저 비밀번호를 바꾸거나 2단계 인증을 켜 주세요.', VAL_001: '입력하신 내용을 다시 확인해 주세요. 빠진 칸이 있거나 형식이나 길이가 맞지 않습니다.', - RES_001: '요청하신 정보나 페이지를 찾을 수 없습니다.', - BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.', - FILE_001: '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.', - FILE_002: '파일 크기가 허용 범위를 초과했습니다.', - FILE_003: '파일 상태가 그사이 바뀌어 처리하지 못했습니다. 다시 시도해 주세요.', - FILE_004: '파일을 올리는 중에 일부가 제대로 전달되지 않았습니다. 다시 시도해 주세요.', + RES_001: '찾을 수 없습니다. 이미 지워졌거나 옮겨졌을 수 있습니다.', + BIZ_001: '하려던 일을 마치지 못했습니다. 다시 시도해 주세요.', + FILE_001: '쓸 수 없는 형식의 파일입니다. 그 파일을 빼거나 다른 파일로 바꿔 주세요.', + FILE_002: '파일이 너무 커서 올릴 수 없습니다.', + FILE_003: '그사이 파일이 바뀌어 하던 일을 마치지 못했습니다. 다시 시도해 주세요.', + FILE_004: '파일 일부가 제대로 올라가지 않았습니다. 다시 시도해 주세요.', FILE_005: '대용량 첨부 보관 한도를 넘어 더 올릴 수 없습니다. 보낸 파일의 링크를 끊거나 보내지 않은 파일을 지우면 공간이 생깁니다.', - FILE_006: '서버의 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.', + FILE_006: '회사 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.', FILE_007: '이 파일은 더 내려받을 수 없습니다. 필요하면 보낸 사람에게 문의해 주세요.', FILE_008: '보안 검사가 끝나지 않은 파일입니다. 잠시 뒤 다시 시도해 주세요.', MAIL_001: '메일 서비스에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.', MAIL_002: '받는 사람 주소를 다시 확인해 주세요.', - MAIL_003: '메일함 용량이 부족합니다. 불필요한 메일을 정리한 뒤 다시 시도해 주세요.', - MAIL_004: '첨부 파일을 찾을 수 없습니다. 해당 첨부를 빼거나 다시 올린 뒤 보내 주세요.', + MAIL_003: '메일함이 가득 찼습니다. 필요 없는 메일을 지우고 휴지통을 비운 뒤 다시 시도해 주세요.', + MAIL_004: '첨부 파일을 찾을 수 없습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요.', MAIL_005: '이미 보낸 메일과 내용이 달라 보내지 않았습니다. 보낸편지함을 확인한 뒤 다시 보내 주세요.', - MAIL_006: '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.', + MAIL_006: '첨부 파일은 메일 한 통에 20개까지 보낼 수 있습니다.', MAIL_007: '본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.', MAIL_008: '임시 저장한 메일이 다른 곳에서 바뀌었거나 지워졌습니다. 지금 내용을 새 임시 저장본으로 남겨 주세요.', MAIL_009: '같은 이름의 메일함이 이미 있습니다.', @@ -39,7 +39,7 @@ export const ERROR_CODE_MESSAGES = { MAIL_013: '필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나, 쓰지 않는 필터나 차단한 주소를 지워 주세요.', MAIL_014: '이미 보낸 메일이라 취소할 수 없습니다.', MAIL_015: '보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.', - MAIL_016: '대용량 첨부 파일을 보낼 수 없습니다. 해당 파일을 빼거나 다시 올린 뒤 보내 주세요.', + MAIL_016: '보낼 수 없는 대용량 첨부 파일이 있습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요.', CONTACT_001: '같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요.', CONTACT_002: '주소록 한도를 넘어 더 넣을 수 없습니다. 연락처 3,000개, 그룹 100개, 그룹 하나에 500명까지 넣을 수 있습니다.', } as const -- 2.52.0 From c9e6858b2a67ee658463786dbd809f8ed3edaf23 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 7 Nov 2026 14:37:56 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(frontend):=20=EC=A1=B0=EC=82=AC=20?= =?UTF-8?q?=EB=8F=84=EC=9A=B0=EB=AF=B8=EA=B0=80=20=ED=98=B8=ED=99=98=20?= =?UTF-8?q?=EC=9E=90=EB=AA=A8=20=C2=B7=20=EC=88=AB=EC=9E=90=EC=9D=98=20?= =?UTF-8?q?=EB=B0=9B=EC=B9=A8=EB=8F=84=20=EA=B3=A0=EB=A5=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검색 결과 없음 · 주소록 찾기 글에 이/가 · 으로/로 조사를 붙이게 되면서, 초성 검색어(‘ㄱㅁ’)나 숫자로 끝나는 말이 "(으)로" · "이(가)" 꼴로 나왔다. 호환 자모 자음은 이름(기역 · 리을 …)대로 받침 있음(ㄹ 은 "로"), 모음은 받침 없음, 숫자는 읽는 소리(영 · 일 · 이 …)로 고른다. 라틴 글자 · 기호로 끝나면 지금처럼 괄호 꼴이다. Co-Authored-By: Claude Opus 5.5 --- frontend/src/shared/utils/korean.spec.ts | 84 ++++++++++++++++++++++- frontend/src/shared/utils/korean.ts | 85 ++++++++++++++++++------ 2 files changed, 146 insertions(+), 23 deletions(-) diff --git a/frontend/src/shared/utils/korean.spec.ts b/frontend/src/shared/utils/korean.spec.ts index 582da42..3c5d443 100644 --- a/frontend/src/shared/utils/korean.spec.ts +++ b/frontend/src/shared/utils/korean.spec.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest' import { directionParticle, objectParticle, subjectParticle } from './korean' +// 규칙은 korean.ts 머리말 — 짝: 앱 `flutter/lib/core/utils/korean_text.dart` (한쪽을 고치면 둘 다) + describe('subjectParticle (주격 조사 이/가)', () => { it.each([ ['받은편지함', '이'], @@ -27,9 +29,10 @@ describe('directionParticle (방향 조사 으로/로)', () => { // ㄹ 받침 → 로 ['서울', '로'], ['메일', '로'], - // 한글로 끝나지 않으면 발음을 알 수 없다 + // 숫자는 읽는 소리로 — 2026 은 "육" + ['2026', '으로'], + // 라틴 글자로 끝나면 소리를 알 수 없다 ['Work', '(으)로'], - ['2026', '(으)로'], ['', '(으)로'], ])('%s → %s', (word, particle) => { expect(directionParticle(word)).toBe(particle) @@ -49,3 +52,80 @@ describe('objectParticle (목적격 조사 을/를 — "휴지통을 비웠습 expect(objectParticle(word)).toBe(particle) }) }) + +describe('한글 호환 자모 (U+3131~U+3163 — 초성으로 찾은 말)', () => { + // 자음은 이름(기역 · 리을 · 미음 …)이 모두 받침으로 끝난다 — ㄹ(리을)만 ㄹ 받침이라 "로". 쌍자음 · 겹자음도 받침 있음(ㄹ 받침이 아니다) + it.each([ + ['ㄱ', '기역', '이', '을', '으로'], + ['ㄹ', '리을', '이', '을', '로'], + ['ㅁ', '미음', '이', '을', '으로'], + ['ㅎ', '히읗', '이', '을', '으로'], + ['ㄲ', '쌍기역', '이', '을', '으로'], + ['ㄳ', '기역시옷', '이', '을', '으로'], + ['ㄺ', '리을기역', '이', '을', '으로'], + ['ㅏ', '아', '가', '를', '로'], + ['ㅣ', '이', '가', '를', '로'], + ])('%s(%s) → %s · %s · %s', (jamo, reading, subject, object, direction) => { + expect([subjectParticle(jamo), objectParticle(jamo), directionParticle(jamo)]).toEqual([subject, object, direction]) + // 읽는 소리에 붙인 조사와 같다 + expect([subjectParticle(reading), objectParticle(reading), directionParticle(reading)]).toEqual([ + subject, + object, + direction, + ]) + }) + + it('초성 검색어는 마지막 자모로 고른다 — 범위 밖의 옛 자모는 소리를 알 수 없는 꼴', () => { + expect(directionParticle('ㄱㅁ')).toBe('으로') + expect(directionParticle('박ㅈ')).toBe('으로') + expect(subjectParticle('김ㅇㄹ')).toBe('이') + expect(directionParticle('김ㅇㄹ')).toBe('로') + // ㆍ(아래아 U+318D) — 호환 자모지만 U+3163 밖 + expect(subjectParticle('ㆍ')).toBe('이(가)') + }) +}) + +describe('숫자 0~9 — 읽는 소리(영 · 일 · 이 · 삼 · 사 · 오 · 육 · 칠 · 팔 · 구)의 받침으로', () => { + it.each([ + ['0', '영', '이', '을', '으로'], + ['1', '일', '이', '을', '로'], + ['2', '이', '가', '를', '로'], + ['3', '삼', '이', '을', '으로'], + ['4', '사', '가', '를', '로'], + ['5', '오', '가', '를', '로'], + ['6', '육', '이', '을', '으로'], + ['7', '칠', '이', '을', '로'], + ['8', '팔', '이', '을', '로'], + ['9', '구', '가', '를', '로'], + ])('%s(%s) → %s · %s · %s', (digit, reading, subject, object, direction) => { + expect([subjectParticle(digit), objectParticle(digit), directionParticle(digit)]).toEqual([subject, object, direction]) + // 읽는 소리에 붙인 조사와 같다 + expect([subjectParticle(reading), objectParticle(reading), directionParticle(reading)]).toEqual([ + subject, + object, + direction, + ]) + }) + + it('여러 자리 수 · 글자 뒤의 숫자도 마지막 숫자로 본다', () => { + expect(subjectParticle('2026')).toBe('이') + expect(subjectParticle('프로젝트2')).toBe('가') + expect(objectParticle('3분기 보고서 v10')).toBe('을') + expect(directionParticle('회의실 7')).toBe('로') + }) +}) + +describe('끝의 공백 · 닫는 따옴표 · 괄호 (지금 규칙 그대로)', () => { + it('앞뒤 공백은 지우고 본다 — 자모 · 숫자도 같다', () => { + expect(directionParticle('스팸메일함 ')).toBe('으로') + expect(directionParticle('ㄹ ')).toBe('로') + expect(subjectParticle(' 7 ')).toBe('이') + }) + + it('닫는 따옴표 · 괄호는 건너뛰지 않는다 — 기호로 끝나면 소리를 알 수 없는 꼴 (부르는 쪽이 따옴표 안의 말을 넘긴다)', () => { + expect(subjectParticle('보고서’')).toBe('이(가)') + expect(subjectParticle('"서버 이전"')).toBe('이(가)') + expect(objectParticle('휴지통)')).toBe('을(를)') + expect(directionParticle('회의(2)')).toBe('(으)로') + }) +}) diff --git a/frontend/src/shared/utils/korean.ts b/frontend/src/shared/utils/korean.ts index 9a821a3..a7cca88 100644 --- a/frontend/src/shared/utils/korean.ts +++ b/frontend/src/shared/utils/korean.ts @@ -1,4 +1,17 @@ // 한국어 문구 보조 — 순수 함수만 둔다 +// +// 조사는 앞말의 마지막 글자를 읽는 소리의 받침으로 고른다. 앞뒤 공백은 지우고 보며, 닫는 따옴표 · 괄호 같은 기호는 건너뛰지 않는다 +// (기호로 끝나면 소리를 알 수 없는 것으로 본다 — 부르는 쪽이 따옴표 안의 말을 넘긴다). +// - 한글 음절: 그 글자의 받침 +// - 한글 호환 자모(U+3131~U+3163 — 초성으로 찾을 때 흔하다): 자음 ㄱ~ㅎ은 이름(기역 · 니은 · 디귿 · 리을 · 미음 …)이 모두 +// 받침으로 끝나 받침 있음이고, ㄹ(리을)만 ㄹ 받침이다. 쌍자음 · 겹자음(ㄲ · ㄳ · ㄺ …)도 받침 있음(ㄹ 받침이 아니다). 모음 ㅏ~ㅣ는 받침 없음 +// - 숫자 0~9: 읽는 소리(영 · 일 · 이 · 삼 · 사 · 오 · 육 · 칠 · 팔 · 구)의 받침 — 받침 있음 0 · 1 · 3 · 6 · 7 · 8(그 가운데 ㄹ 받침 1 · 7 · 8), +// 받침 없음 2 · 4 · 5 · 9. 여러 자리 수도 마지막 숫자로 본다("2026" → 육) +// - 그 밖(라틴 글자 · 기호 · 빈 글자)은 소리를 알 수 없어 두 꼴을 함께 쓴다 — "이(가)" · "을(를)" · "(으)로" +// 짝: flutter `lib/core/utils/korean_text.dart` — 규칙을 바꾸면 둘 다 + +/** 마지막 소리의 받침 — 없음 · ㄹ 받침 · 그 밖의 받침 */ +type FinalSound = 'none' | 'rieul' | 'other' const HANGUL_SYLLABLE_START = 0xac00 const HANGUL_SYLLABLE_END = 0xd7a3 @@ -7,43 +20,73 @@ const JONGSEONG_COUNT = 28 /** 받침 번호 — 0 은 받침 없음, 8 은 ㄹ */ const NO_JONGSEONG = 0 const JONGSEONG_RIEUL = 8 +/** 한글 호환 자모 — 자음 ㄱ(U+3131)~ㅎ(U+314E), 그 가운데 ㄹ(U+3139), 모음 ㅏ(U+314F)~ㅣ(U+3163) */ +const JAMO_CONSONANT_START = 0x3131 +const JAMO_CONSONANT_END = 0x314e +const JAMO_RIEUL = 0x3139 +const JAMO_VOWEL_START = 0x314f +const JAMO_VOWEL_END = 0x3163 +/** 숫자 0~9 */ +const DIGIT_ZERO = 0x30 +const DIGIT_NINE = 0x39 +/** 숫자 0~9 를 읽는 소리의 받침 — 영 · 일 · 이 · 삼 · 사 · 오 · 육 · 칠 · 팔 · 구 */ +const DIGIT_FINAL_SOUNDS: readonly FinalSound[] = [ + 'other', // 0 영 + 'rieul', // 1 일 + 'none', // 2 이 + 'other', // 3 삼 + 'none', // 4 사 + 'none', // 5 오 + 'other', // 6 육 + 'rieul', // 7 칠 + 'rieul', // 8 팔 + 'none', // 9 구 +] -/** 마지막 글자의 받침 번호 — 한글 음절로 끝나지 않으면(영문 · 숫자 · 빈 글자) null */ -function lastJongseong(word: string): number | null { +/** 마지막 글자를 읽는 소리의 받침 — 소리를 알 수 없으면(라틴 글자 · 기호 · 빈 글자) null */ +function finalSound(word: string): FinalSound | null { const last = Array.from(word.trim()).pop() - if (!last) return null + const code = last?.codePointAt(0) + if (code === undefined) return null - const code = last.charCodeAt(0) - if (code < HANGUL_SYLLABLE_START || code > HANGUL_SYLLABLE_END) return null - return (code - HANGUL_SYLLABLE_START) % JONGSEONG_COUNT + if (code >= HANGUL_SYLLABLE_START && code <= HANGUL_SYLLABLE_END) { + const jongseong = (code - HANGUL_SYLLABLE_START) % JONGSEONG_COUNT + if (jongseong === NO_JONGSEONG) return 'none' + return jongseong === JONGSEONG_RIEUL ? 'rieul' : 'other' + } + if (code >= JAMO_CONSONANT_START && code <= JAMO_CONSONANT_END) return code === JAMO_RIEUL ? 'rieul' : 'other' + if (code >= JAMO_VOWEL_START && code <= JAMO_VOWEL_END) return 'none' + if (code >= DIGIT_ZERO && code <= DIGIT_NINE) return DIGIT_FINAL_SOUNDS[code - DIGIT_ZERO] ?? null + return null } /** - * 주격 조사 "이/가" — 마지막 글자에 받침이 있으면 "이", 없으면 "가". - * 한글로 끝나지 않는 이름(영문·숫자)은 발음을 알 수 없어 "이(가)" 를 쓴다. + * 주격 조사 "이/가" — 마지막 소리에 받침이 있으면 "이", 없으면 "가" (휴지통이 · 프로젝트가 · ㄱ이 · 2가). + * 소리를 알 수 없는 이름(라틴 글자 · 기호로 끝남)은 "이(가)" 를 쓴다. */ export function subjectParticle(word: string): '이' | '가' | '이(가)' { - const jongseong = lastJongseong(word) - if (jongseong === null) return '이(가)' - return jongseong === NO_JONGSEONG ? '가' : '이' + const sound = finalSound(word) + if (sound === null) return '이(가)' + return sound === 'none' ? '가' : '이' } /** - * 목적격 조사 "을/를" — 받침이 있으면 "을", 없으면 "를" (휴지통을 · 스팸메일함을 · 보관함을 · 메일 서버를). - * 한글로 끝나지 않는 이름(영문·숫자)은 발음을 알 수 없어 "을(를)" 을 쓴다. + * 목적격 조사 "을/를" — 받침이 있으면 "을", 없으면 "를" (휴지통을 · 스팸메일함을 · 보관함을 · 메일 서버를 · 3을 · ㅏ를). + * 소리를 알 수 없는 이름(라틴 글자 · 기호로 끝남)은 "을(를)" 을 쓴다. */ export function objectParticle(word: string): '을' | '를' | '을(를)' { - const jongseong = lastJongseong(word) - if (jongseong === null) return '을(를)' - return jongseong === NO_JONGSEONG ? '를' : '을' + const sound = finalSound(word) + if (sound === null) return '을(를)' + return sound === 'none' ? '를' : '을' } /** - * 방향 조사 "으로/로" — 받침이 있으면 "으로", 받침이 없거나 ㄹ 받침이면 "로" (보낸편지함으로 · 휴지통으로 · 보관함으로 · 서울로). - * 한글로 끝나지 않는 이름(영문·숫자)은 발음을 알 수 없어 "(으)로" 를 쓴다. + * 방향 조사 "으로/로" — 받침이 있으면 "으로", 받침이 없거나 ㄹ 받침이면 "로" + * (보낸편지함으로 · 휴지통으로 · 보관함으로 · 서울로 · ㄱ으로 · ㄹ로 · 6으로 · 7로). + * 소리를 알 수 없는 이름(라틴 글자 · 기호로 끝남)은 "(으)로" 를 쓴다. */ export function directionParticle(word: string): '으로' | '로' | '(으)로' { - const jongseong = lastJongseong(word) - if (jongseong === null) return '(으)로' - return jongseong === NO_JONGSEONG || jongseong === JONGSEONG_RIEUL ? '로' : '으로' + const sound = finalSound(word) + if (sound === null) return '(으)로' + return sound === 'other' ? '으로' : '로' } -- 2.52.0 From 1bd4b3d62614265bc2b0fe210283530521b197c7 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 7 Nov 2026 14:37:57 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix(frontend):=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=AC=B8=EA=B5=AC=2075=EA=B3=B3=EC=9D=84=20=EC=82=AC=EB=9E=8C?= =?UTF-8?q?=EC=9D=B4=20=EB=A7=90=ED=95=98=EB=93=AF=20=EB=8B=A4=EB=93=AC?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-11-07 사용자가 "자연스러운 문구가 아니라 시스템, 설계 문구 형식"이라고 짚은 글을 고친다(규칙 9 · 10). "여기에 표시됩니다 · 보입니다 · 나타납니다"처럼 화면을 주어로 한 설명과 "맞는 메일 · 분류된 · 지원하지 않습니다 · 처리" 같은 번역투 · 설계 말을, 내 메일이 어떻게 되는지와 무엇을 하면 되는지로 바꾼다. 정할 것 여섯은 추천안 (읽기 칸이 비었을 때 "고른 메일이 없습니다", 필터 칸 이름 [받은편지함에 이미 있는 메일도 정리하기], 본문 안내에 원문 내려받기까지, 로그인 소개 글)이다. 같은 대화 칸의 펼친 줄은 본문 안내의 앞 문장만 둔다(그 칸의 ⋯ 는 위에 연 메일의 것이다). 사전 글을 견주던 시험은 사전을 참조하게 바꿨다. 시험 2738 → 2762. Co-Authored-By: Claude Opus 5.5 --- .../src/components/feedback/StateMessage.vue | 2 +- .../mail/ConversationMessagePanel.vue | 3 +- .../src/components/mail/MailListFrame.spec.ts | 5 ++- .../src/components/mail/MailSearchBox.spec.ts | 5 +-- .../src/components/mail/MailSearchEmpty.vue | 2 +- .../src/components/mail/MailboxPanel.spec.ts | 3 +- .../mail/MessageConversation.spec.ts | 8 ++-- .../src/components/mail/MessageReader.spec.ts | 15 ++++--- .../mail/MessageReader.stage9.spec.ts | 3 +- .../src/components/mail/MessageReader.vue | 8 ++-- .../components/mail/MessageReaderNotices.vue | 3 +- .../components/mail/RecipientField.spec.ts | 2 +- .../src/components/mail/RecipientField.vue | 4 +- .../components/mail/editor/EditorLinkForm.vue | 2 +- .../settings/AppPasswordResultDialog.vue | 2 +- .../components/settings/LoginSessionList.vue | 2 +- .../settings/MailFilterApplyField.vue | 2 +- .../components/settings/MailFilterDialog.vue | 8 ++-- .../settings/NotifySettingsForm.vue | 4 +- .../settings/RealtimeLinkStatus.vue | 2 +- .../settings/VacationPeriodFields.vue | 2 +- frontend/src/composables/useApi.spec.ts | 6 ++- frontend/src/composables/useApi.ts | 2 +- .../composables/useLargeFileUploads.spec.ts | 2 +- .../src/composables/useMailFilterActions.ts | 4 +- .../src/composables/useMailFilterDialog.ts | 2 +- .../src/composables/useMailFilterRun.spec.ts | 3 +- frontend/src/composables/useMailFilterRun.ts | 4 +- .../src/pages/LoginPage.restricted.spec.ts | 4 +- frontend/src/pages/LoginPage.vue | 2 +- frontend/src/pages/NotFoundPage.vue | 2 +- .../src/pages/contacts/ContactsPage.spec.ts | 15 ++++--- frontend/src/pages/mail/ComposePage.vue | 2 +- .../src/pages/mail/MailboxPage.search.spec.ts | 22 +++++----- .../settings/AccountAppPasswordsPage.spec.ts | 3 +- .../settings/AccountLoginHistoryPage.spec.ts | 11 ++--- .../settings/AccountMailClientsPage.spec.ts | 5 +-- .../src/pages/settings/MailBlocksPage.spec.ts | 3 +- .../src/pages/settings/MailFilterPage.spec.ts | 29 +++++++------- .../src/pages/settings/MailFilterPage.vue | 2 +- .../pages/settings/MailLargeFilesPage.spec.ts | 7 ++-- .../src/pages/settings/MailNotifyPage.spec.ts | 27 ++++++------- .../src/pages/settings/MailNotifyPage.vue | 2 +- .../pages/settings/MailSendReadPage.spec.ts | 7 ++-- .../pages/settings/MailVacationPage.spec.ts | 5 +-- .../src/shared/constants/account-security.ts | 10 ++--- frontend/src/shared/constants/contacts.ts | 18 +++++---- frontend/src/shared/constants/large-file.ts | 10 ++--- frontend/src/shared/constants/mail-block.ts | 2 +- frontend/src/shared/constants/mail-clients.ts | 6 +-- frontend/src/shared/constants/mail-filter.ts | 22 +++++----- frontend/src/shared/constants/mail-notify.ts | 14 +++---- frontend/src/shared/constants/mail-search.ts | 8 ++-- .../src/shared/constants/mail-settings.ts | 6 +-- .../src/shared/constants/mail-signature.ts | 4 +- .../src/shared/constants/mail-vacation.ts | 4 +- frontend/src/shared/constants/mail.ts | 40 ++++++++++++------- .../src/shared/constants/mailbox-manage.ts | 2 +- .../shared/constants/session-restriction.ts | 4 +- .../shared/utils/large-file-status.spec.ts | 3 +- .../shared/utils/mail-body-notices.spec.ts | 32 +++++++++++++-- .../src/shared/utils/mail-body-notices.ts | 25 +++++++++--- .../src/shared/utils/mail-filter-run.spec.ts | 7 ++-- frontend/src/shared/utils/mail-filter-run.ts | 4 +- .../src/shared/utils/mail-realtime.spec.ts | 5 ++- .../shared/utils/mailbox-operation.spec.ts | 3 +- frontend/src/shared/utils/mailbox.spec.ts | 3 +- frontend/src/stores/toast.store.spec.ts | 4 +- 68 files changed, 283 insertions(+), 211 deletions(-) diff --git a/frontend/src/components/feedback/StateMessage.vue b/frontend/src/components/feedback/StateMessage.vue index a2da0de..5ab9f57 100644 --- a/frontend/src/components/feedback/StateMessage.vue +++ b/frontend/src/components/feedback/StateMessage.vue @@ -18,7 +18,7 @@ interface Props { tone?: 'neutral' | 'danger' /** 서버 추적 ID — 앞 8자리를 "문의 코드"로 보인다 */ traceId?: string | null - /** 영역을 세로로 가득 채워 가운데 둔다 (본문 영역의 "메일을 선택해 주세요") */ + /** 영역을 세로로 가득 채워 가운데 둔다 (본문 영역의 "고른 메일이 없습니다") */ fill?: boolean } diff --git a/frontend/src/components/mail/ConversationMessagePanel.vue b/frontend/src/components/mail/ConversationMessagePanel.vue index 3fce3e9..0823ed2 100644 --- a/frontend/src/components/mail/ConversationMessagePanel.vue +++ b/frontend/src/components/mail/ConversationMessagePanel.vue @@ -2,6 +2,7 @@ // "같은 대화" 칸에서 펼친 줄의 안 (14단계 — 디자인 원본 Thread14 "하나 펼침") // 받는 사람 · 제목 한 줄씩, 본문(MailBodyFrame — 검색 쿼리 없이 받은 본문이라 강조가 없다), [답장] · [이 메일만 열기](36 — 밀도 예외 --size-notice-action 재사용). // - 본문이 다 보이지 않으면(서버의 isHtmlBodyOmitted · isBodyTruncated) 읽기 화면과 같은 안내 줄(MAIL_BODY_NOTICES)을 본문 위에 — 읽기 화면도 같은 본문이다. +// 앞 문장만 보이고 원문 내려받기(할 일)는 붙이지 않는다 — 이 화면의 ⋯ 는 위에 연 메일의 것이라, 원문은 [이 메일만 열기]로 연 읽기 화면에서 받는다. // 외부 이미지 안내 · 첨부 목록은 두지 않는다 — [이 메일만 열기]로 연 읽기 화면에 있다. 첨부가 있으면 본문 아래 · [답장] 위에 // "첨부 파일 N개는 [이 메일만 열기]를 누르면 볼 수 있습니다." (수는 읽기 화면의 "첨부 파일 N개"와 같다 — 본문 그림 빼고 · 대용량 첨부 더해) // - 본문을 받는 동안은 뼈대(aria-busy), 못 받으면 사전 문구 + [다시 시도]. 그사이 지워진 메일(RES_001)이면 [다시 시도] · [답장] · [이 메일만 열기] 없이 — @@ -49,7 +50,7 @@ const ready = computed(() => (props.detail?.status === 'ready' ? props.detail.de const failure = computed(() => (props.detail?.status === 'error' ? props.detail.error : null)) /** 그사이 지워진 메일 — 다시 해도 같고 그 메일로 갈 곳도 없다 */ const missing = computed(() => failure.value?.code === 'RES_001') -const bodyNotices = computed(() => (ready.value ? mailBodyNotices(ready.value) : [])) +const bodyNotices = computed(() => (ready.value ? mailBodyNotices(ready.value, { withAction: false }) : [])) /** 읽기 화면에서 볼 수 있는 첨부 수 — 본문 그림(isInline)은 빼고 대용량 첨부는 더한다 (MessageReader 의 첨부 칸과 같은 수) */ const attachmentCount = computed(() => ready.value ? listedAttachments(ready.value.attachments).length + (ready.value.largeFiles?.length ?? 0) : 0, diff --git a/frontend/src/components/mail/MailListFrame.spec.ts b/frontend/src/components/mail/MailListFrame.spec.ts index 7f841e9..2f76a64 100644 --- a/frontend/src/components/mail/MailListFrame.spec.ts +++ b/frontend/src/components/mail/MailListFrame.spec.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mount } from '@vue/test-utils' import { createPinia, setActivePinia, type Pinia } from 'pinia' +import { MAILBOX_EMPTY_HINTS } from '@/shared/constants/mail' import type { Mailbox, MessageSummary } from '@/shared/types/mail' import { useAuthStore } from '@/stores/auth.store' import { useMailStore } from '@/stores/mail.store' @@ -239,7 +240,9 @@ describe('MailListFrame — 휴지통 · 스팸메일함의 30일 줄 (13단계) expect(wrapper.find('[data-retention-notice]').exists()).toBe(false) expect(wrapper.text()).toContain('휴지통이 비어 있습니다') - expect(wrapper.text()).toContain('삭제한 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.') + expect(wrapper.text()).toContain(MAILBOX_EMPTY_HINTS.trash) + // 줄 대신 이 글이 30일을 알린다 + expect(MAILBOX_EMPTY_HINTS.trash).toContain('30일') }) it('다른 메일함 · 검색 결과에는 없다', async () => { diff --git a/frontend/src/components/mail/MailSearchBox.spec.ts b/frontend/src/components/mail/MailSearchBox.spec.ts index b6791f8..851b59e 100644 --- a/frontend/src/components/mail/MailSearchBox.spec.ts +++ b/frontend/src/components/mail/MailSearchBox.spec.ts @@ -4,6 +4,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia' import { CLOCK } from '@/composables/useClock' import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated' +import { SEARCH_CONDITIONS_TEXT } from '@/shared/constants/mail-search' import type { Mailbox } from '@/shared/types/mail' import { useAuthStore } from '@/stores/auth.store' import { useMailStore } from '@/stores/mail.store' @@ -245,9 +246,7 @@ describe('MailSearchBox — [조건] · 「조건으로 찾기」 (14단계 디 '최근 1년', '날짜 고르기', ]) - expect(panel.text()).toContain( - '검색 칸의 말과 이 조건에 모두 맞는 메일을 찾습니다. 받는 사람에는 참조로 받은 사람도 들어갑니다.', - ) + expect(panel.text()).toContain(SEARCH_CONDITIONS_TEXT.hint) // 칸 안내 — 내용 칸은 칸 이름과 같은 말로 (2026-11-06 실사용 시험) expect(['from', 'to', 'subject', 'body'].map((name) => field(wrapper, name).attributes('placeholder'))).toEqual([ '이름이나 메일 주소', diff --git a/frontend/src/components/mail/MailSearchEmpty.vue b/frontend/src/components/mail/MailSearchEmpty.vue index e59248b..0620f3b 100644 --- a/frontend/src/components/mail/MailSearchEmpty.vue +++ b/frontend/src/components/mail/MailSearchEmpty.vue @@ -1,6 +1,6 @@