feat(frontend): 설정 › 휴대폰 앱과 휴대폰 받기 화면 /app 을 더한다

확정 보드 SettingsMobileApp 대로 PC 에서는 QR 로 휴대폰에 넘기고, 셸 밖 /app(로그인 필요)에서 휴대폰으로 받는다.
내려받기 전에 세션을 확인하고, 올린 판이 없으면(404 RES_001) 알림 없이 안내와 문의 줄을 보인다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-08 23:29:04 +09:00
parent f2ba81513e
commit 433ef12cc3
24 changed files with 2141 additions and 10 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
// QR 코드 그림 — 2단계 인증 켜기 창 (디자인 원본 SettingsTwoFactor: 흰 바탕 · 테두리 상자 안의 QR)
// QR 코드 그림 — 2단계 인증 켜기 창 (디자인 원본 SettingsTwoFactor: 흰 바탕 · 테두리 상자 안의 QR) · 설정 › 휴대폰 앱의 받기 주소(…/app — 비밀 아님)
// 칸은 shared/utils/qr-code.ts 가 고르고 여기서는 SVG <rect> 를 v-for 로 그린다 — v-html 을 쓰지 않는다.
// 바깥 여백(quiet zone)은 칸 2개 + 상자 안쪽 여백 — 읽는 앱이 그림의 끝을 알아보게 둔다.
// 이름(label)은 role="img" 의 aria-label — 그림의 내용(비밀이 든 주소)은 읽어 주지 않는다
@@ -8,7 +8,7 @@ import { computed } from 'vue'
import { qrModuleRuns } from '@/shared/utils/qr-code'
interface Props {
/** QR 에 담을 글 — otpauth 주소 */
/** QR 에 담을 글 — otpauth 주소 · 휴대폰 받기 화면 주소 */
value: string
/** 그림의 이름 (스크린리더) */
label: string
@@ -2,6 +2,7 @@
// 로그인 틀의 문의 줄 (13단계 — 디자인 README "13단계 › 문의 줄" · 계약 6절 "문의 연락처")
// 연락처가 있으면 물음(12.5px 회색) 다음 줄에 연락처(#344054 · 500), 없거나 받지 못했으면 지금 글 그대로(fallback).
// 연락처는 운영 도구로 넣는 설정값이다 — 글자로만 그린다(텍스트 보간 — 링크 · HTML 없음). 길어도 낱말 가운데서 끊지 않는다
// 휴대폰 앱 받기 화면(설정 › 휴대폰 앱 · /app)도 같은 꼴로 쓴다 — /app 은 본문이 14 라 글을 13 으로(size="base", 디자인 원본 SettingsMobileApp)
interface Props {
/** 물음 — "비밀번호를 잊으셨나요?" · "인증 앱을 쓸 수 없나요?" */
question: string
@@ -9,14 +10,18 @@ interface Props {
fallback: string
/** 연락처 한 줄 — 없으면 null */
contact: string | null
/** 글 크기 — label 12.5(기본) · base 13 */
size?: 'label' | 'base'
}
defineProps<Props>()
withDefaults(defineProps<Props>(), {
size: 'label',
})
</script>
<template>
<p
class="support-line"
:class="['support-line', { 'support-line--base': size === 'base' }]"
data-support-line
>
<template v-if="contact">
@@ -40,6 +45,10 @@ defineProps<Props>()
overflow-wrap: anywhere;
}
.support-line--base {
font-size: var(--font-size-base);
}
.support-line__contact {
color: var(--color-text-strong);
font-weight: 500;
@@ -0,0 +1,96 @@
<script setup lang="ts">
// [앱 내려받기] — 가장 늦게 올린 안드로이드 앱(APK)을 받는다 (디자인 원본 SettingsMobileApp)
// 첨부 받기처럼 <a href download> 이동이고, 누르기 직전에 세션을 확인한다(useAppReleases 의 downloadLatestAndroid — confirmSession).
// 세션이 끝났으면 세션 만료 창, 장애면 알림이 뜨고 이동하지 않는다. 확인하는 사이 다시 눌러도 한 번만 받는다.
// 가운데 버튼(새 탭)도 같은 흐름으로 받고, 오른쪽 버튼(메뉴 — 링크 주소 복사 등)은 브라우저에 맡긴다.
// 화면 낭독 이름은 크기까지 "앱 내려받기 (31 MB)" — 보이는 글로 시작한다.
// 설정의 앱 칸은 높이 44(글 13.5 · 아이콘 15), 휴대폰 받기 화면은 전체 폭 · 높이 48(block — 글 15 · 아이콘 18)
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { useAppReleases } from '@/composables/useAppReleases'
import { MOBILE_APP_TEXT } from '@/shared/constants/mobile-app'
import { appDownloadLabel } from '@/shared/utils/app-release'
interface Props {
/** 크기 글 ("31 MB") — 모르면 빈 글 */
size: string
/** 휴대폰 받기 화면 — 전체 폭 · 높이 48 */
block?: boolean
}
const props = withDefaults(defineProps<Props>(), {
block: false,
})
const { latestAndroidFileUrl, downloadLatestAndroid } = useAppReleases()
/** 가운데 버튼(새 탭으로 열기) — 열어도 결국 파일을 받으므로 같은 흐름으로 받는다 */
const MIDDLE_BUTTON = 1
const href = latestAndroidFileUrl()
const label = computed(() => appDownloadLabel(props.size))
/** 세션을 확인하는 중 — 기다리는 사이 다시 눌러도 한 번만 받는다 */
let pending = false
function onDownload(event: MouseEvent) {
if (event.type === 'auxclick' && event.button !== MIDDLE_BUTTON) return
event.preventDefault()
if (pending) return
pending = true
void downloadLatestAndroid().finally(() => {
pending = false
})
}
</script>
<template>
<a
:class="['app-download', { 'app-download--block': block }]"
:href="href"
:aria-label="label"
download
data-app-download
@click="onDownload"
@auxclick="onDownload"
>
<AppIcon
name="download"
:size="block ? 18 : 15"
:stroke-width="2"
/>
<span>{{ MOBILE_APP_TEXT.download }}</span>
</a>
</template>
<style scoped>
/* 채운 단추 — 청록 바탕 · 흰 글 (설정의 앱 칸: 높이 44 · 좌우 16 · 모서리 8) */
.app-download {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-6);
min-height: var(--size-touch);
padding: 0 var(--space-16);
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-size: var(--font-size-emphasis);
font-weight: 600;
text-decoration: none;
}
.app-download:hover {
background: var(--color-brand-strong);
color: var(--color-on-brand);
}
/* 휴대폰 받기 화면 — 전체 폭 · 높이 48 · 모서리 10 · 글 15 (44 보다 큰 보통 크기 — 밀도 예외가 아니다) */
.app-download--block {
display: flex;
gap: var(--space-8);
min-height: var(--space-48);
border-radius: var(--radius-lg);
font-size: var(--font-size-title-sm);
}
</style>
@@ -0,0 +1,40 @@
<script setup lang="ts">
// 앱 그림 — 남색 바탕에 흰 ARA 마크 (디자인 원본 SettingsMobileApp — 홈 화면 아이콘 apple-touch-icon.png 를 둥글게 깎은 것이라 새 그림이 들지 않는다)
// 설정의 앱 칸은 48(모서리 12), 휴대폰 받기 화면의 카드는 52(모서리 13). 옆에 앱 이름이 있어 그림은 읽어 주지 않는다(alt="")
import appIcon from '@/assets/brand/apple-touch-icon.png'
interface Props {
/** 휴대폰 받기 화면의 카드 — 52 */
large?: boolean
}
withDefaults(defineProps<Props>(), {
large: false,
})
</script>
<template>
<img
:src="appIcon"
alt=""
width="180"
height="180"
:class="['app-tile', { 'app-tile--large': large }]"
>
</template>
<style scoped>
/* 모서리는 한 변의 1/4 — 48 은 12, 52 는 13 (보드 값) */
.app-tile {
flex: 0 0 auto;
display: block;
width: 3rem;
height: 3rem;
border-radius: 25%;
}
.app-tile--large {
width: 3.25rem;
height: 3.25rem;
}
</style>
@@ -0,0 +1,62 @@
<script setup lang="ts">
// 아이폰 안내 상자 — 옅은 청록 바탕 · 안내 아이콘 · 제목(청록 굵게) + 글 (디자인 원본 SettingsMobileApp — 설정 화면 · 휴대폰 받기 화면이 같다)
// 「메일 프로그램 연결」은 설정 › 계정 › 메일 프로그램 연결로 가는 링크다(기본 메일 앱에 회사 메일을 잇는 차례가 있다). 글은 13 그대로
import AppIcon from '@/components/AppIcon.vue'
import LinkedText from '@/components/LinkedText.vue'
import { MOBILE_APP_TEXT } from '@/shared/constants/mobile-app'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
const mailClientsRoute = { name: SETTINGS_ROUTES.mailClients }
</script>
<template>
<div
class="iphone-note"
data-app-iphone-note
>
<AppIcon
name="info"
class="iphone-note__icon"
/>
<div class="iphone-note__body">
<span class="iphone-note__title">{{ MOBILE_APP_TEXT.iphoneTitle }}</span>
<LinkedText
:before="MOBILE_APP_TEXT.iphoneBefore"
:link="MOBILE_APP_TEXT.iphoneLink"
:after="MOBILE_APP_TEXT.iphoneAfter"
:to="mailClientsRoute"
/>
</div>
</div>
</template>
<style scoped>
.iphone-note {
display: flex;
align-items: flex-start;
gap: var(--space-10);
padding: var(--space-12) var(--space-14);
border-radius: var(--radius-md);
background: var(--color-brand-tint);
color: var(--color-text-strong);
font-size: var(--font-size-base);
}
.iphone-note__icon {
margin-top: var(--space-2);
color: var(--color-brand-strong);
}
.iphone-note__body {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.iphone-note__title {
font-weight: 600;
color: var(--color-brand-strong);
}
</style>
@@ -0,0 +1,65 @@
<script setup lang="ts">
// 설치 차례 — 번호 줄(청록 굵은 숫자 · 동그라미 없음 — 2단계 인증 「켜는 차례」와 같은 꼴, 디자인 원본 SettingsMobileApp)
// 숫자는 꾸밈이라 읽어 주지 않는다(aria-hidden) — 목록(ol)의 차례로 읽힌다.
// 설정 화면은 줄 사이 8 · 숫자 칸 뒤 6, 휴대폰 받기 화면(roomy)은 줄 사이 10 · 숫자 칸 뒤 4 · 줄 높이 1.55
interface Props {
steps: readonly string[]
/** 휴대폰 받기 화면 */
roomy?: boolean
}
withDefaults(defineProps<Props>(), {
roomy: false,
})
</script>
<template>
<ol
:class="['app-steps', { 'app-steps--roomy': roomy }]"
data-app-steps
>
<li
v-for="(step, index) in steps"
:key="index"
class="app-steps__item"
>
<span
class="app-steps__number"
aria-hidden="true"
>{{ index + 1 }}</span>
<span>{{ step }}</span>
</li>
</ol>
</template>
<style scoped>
.app-steps {
display: flex;
flex-direction: column;
gap: var(--space-8);
margin: 0;
padding: 0;
list-style: none;
}
.app-steps__item {
display: grid;
grid-template-columns: var(--space-20) minmax(0, 1fr);
gap: var(--space-6);
}
.app-steps__number {
font-weight: 700;
color: var(--color-brand-strong);
font-variant-numeric: tabular-nums;
}
.app-steps--roomy {
gap: var(--space-10);
line-height: 1.55;
}
.app-steps--roomy .app-steps__item {
gap: var(--space-4);
}
</style>