generated from ttipo/project-base
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:
@@ -85,6 +85,16 @@ describe('LoginPage', () => {
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
|
||||
})
|
||||
|
||||
it('휴대폰 받기 화면(QR 코드 · 앱의 [새 버전 받기])에서 왔으면 로그인한 뒤 /app 으로 돌아간다', async () => {
|
||||
installMockApi(() => ok(USER))
|
||||
const { wrapper, router } = await mountLogin('/login?redirect=/app')
|
||||
|
||||
await submit(wrapper, 'secret')
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('mobile-app')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/app')
|
||||
})
|
||||
|
||||
it('redirect 가 앱 밖 주소면 무시하고 메일로 간다', async () => {
|
||||
installMockApi(() => ok(USER))
|
||||
const { wrapper, router } = await mountLogin('/login?redirect=//evil.example')
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
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 { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { AppRelease } from '@/shared/types/app-release'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MobileAppPage from './MobileAppPage.vue'
|
||||
|
||||
// 휴대폰 받기 화면 /app (디자인 원본 SettingsMobileApp B · README "휴대폰 앱 받기 › 휴대폰 받기 화면 /app")
|
||||
// B1 받을 앱 있음 — 카드 · 설치 방법 · 아이폰 · 문의 줄 / B2 받을 앱 없음 — 안내와 문의 줄만 / 받지 못함 — 사전 문구와 [다시 시도]
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const CONTACT = 'IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it'
|
||||
/**
|
||||
* 올린 때는 지역 시각 11월 18일 10:23:45 로 만든다 — 화면은 브라우저 시간대의 날짜라, 어느 시간대에서 돌려도 "2026년 11월 18일"이다
|
||||
* (시간대도 입력이다 — testing 규칙. UTC 로 적은 고정값은 UTC−1:24 보다 서쪽에서 17일이 된다)
|
||||
*/
|
||||
const RELEASE: AppRelease = {
|
||||
version: '1.0.0',
|
||||
sizeBytes: 32505856,
|
||||
releasedAt: new Date(2026, 10, 18, 10, 23, 45).toISOString(),
|
||||
}
|
||||
const FILE_URL = '/api/app-releases/android/latest/file'
|
||||
|
||||
interface Replies {
|
||||
release?: () => MockResult
|
||||
contact?: () => MockResult
|
||||
me?: () => MockResult
|
||||
}
|
||||
|
||||
describe('MobileAppPage', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
let downloads: string[]
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
downloads = []
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
|
||||
downloads.push(this.getAttribute('href') ?? '')
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) {
|
||||
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountPage(replies: Replies = {}) {
|
||||
requests = installMockApi((request) => {
|
||||
switch (request.url) {
|
||||
case '/app-releases/android/latest':
|
||||
return (replies.release ?? (() => ok(RELEASE)))()
|
||||
case '/support-contact':
|
||||
return (replies.contact ?? (() => ok({ text: CONTACT })))()
|
||||
case '/auth/me':
|
||||
return (replies.me ?? (() => ok(USER)))()
|
||||
default:
|
||||
return fail(404, 'RES_001')
|
||||
}
|
||||
})
|
||||
const router = createTestRouter()
|
||||
await router.push('/app')
|
||||
await router.isReady()
|
||||
const wrapper = mount(MobileAppPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
it('앱 틀 없이 브랜드 띠(로고 · 제목 · 설명)와 내용 — 레일 · 상단 바가 없다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
|
||||
const band = wrapper.get('header')
|
||||
expect(band.get('img').attributes('alt')).toBe('ARA SYSTEM')
|
||||
expect(band.get('h1').text()).toBe('휴대폰 앱 받기')
|
||||
expect(band.text()).toContain('안드로이드 휴대폰에 설치하면 메일을 읽고 보내고, 새 메일 알림도 받을 수 있습니다.')
|
||||
expect(wrapper.find('main').exists()).toBe(true)
|
||||
expect(wrapper.find('nav').exists()).toBe(false)
|
||||
})
|
||||
|
||||
describe('B1 받을 앱 있음', () => {
|
||||
it('카드 — 이름 · "안드로이드 앱" · 버전 / 올린 날 / 크기 · [앱 내려받기](전체 폭, 크기까지 읽어 준다)', async () => {
|
||||
const wrapper = await mountPage()
|
||||
|
||||
const card = wrapper.get('[data-mobile-app-release]')
|
||||
expect(card.text()).toContain('ARA SYSTEM')
|
||||
expect(card.text()).toContain('안드로이드 앱')
|
||||
const facts = card.get('dl[data-mobile-app-facts]')
|
||||
expect(facts.findAll('dt').map((term) => term.text())).toEqual(['버전', '올린 날', '크기'])
|
||||
expect(facts.findAll('dd').map((value) => value.text())).toEqual(['1.0.0', '2026년 11월 18일', '31 MB'])
|
||||
const download = card.get('a[data-app-download]')
|
||||
expect(download.classes()).toContain('app-download--block')
|
||||
expect(download.text()).toBe('앱 내려받기')
|
||||
expect(download.attributes('aria-label')).toBe('앱 내려받기 (31 MB)')
|
||||
expect(download.attributes('href')).toBe(FILE_URL)
|
||||
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual([
|
||||
'GET /app-releases/android/latest',
|
||||
'GET /support-contact',
|
||||
])
|
||||
})
|
||||
|
||||
it('설치 방법 1~4 + 새 버전 한 줄 · 아이폰 안내 · 문의 줄(연락처가 있으면 "설치가 잘 되지 않나요?" 다음 줄에)', async () => {
|
||||
const wrapper = await mountPage()
|
||||
|
||||
expect(wrapper.get('#app-page-steps-title').text()).toBe('설치 방법')
|
||||
const steps = wrapper.findAll('[data-app-steps] li')
|
||||
expect(steps.map((step) => step.text().slice(1))).toEqual([
|
||||
'[앱 내려받기]를 눌러 파일을 받아 주세요. 브라우저가 한 번 더 물으면 그대로 받아 주세요.',
|
||||
'받은 파일을 열어 주세요. 처음에는 알 수 없는 앱의 설치를 허용할지 묻습니다. 허용한 뒤 설치를 이어 가 주세요.',
|
||||
'Play 프로텍트가 경고해도 이 화면에서 받은 ARA SYSTEM 앱이면 설치를 이어 가 주세요.',
|
||||
'설치한 앱을 열고 웹메일과 같은 메일 주소와 비밀번호로 로그인해 주세요.',
|
||||
])
|
||||
expect(wrapper.text()).toContain(
|
||||
'새 버전도 이 화면에서 받아 설치하면 됩니다. 쓰던 앱을 지우지 않고 설치하면 로그인과 설정이 그대로 남습니다.',
|
||||
)
|
||||
expect(wrapper.get('[data-app-iphone-note] a').attributes('href')).toBe('/settings/account/mail-clients')
|
||||
|
||||
const line = wrapper.get('[data-support-line]')
|
||||
expect(line.classes()).toContain('support-line--base')
|
||||
expect(line.text()).toContain('설치가 잘 되지 않나요?')
|
||||
expect(line.get('[data-support-contact]').text()).toBe(CONTACT)
|
||||
})
|
||||
|
||||
it('연락처가 없으면 문의 줄은 대신 쓰는 글 한 줄', async () => {
|
||||
const wrapper = await mountPage({ contact: () => ok({ text: ' ' }) })
|
||||
|
||||
expect(wrapper.get('[data-support-line]').text()).toBe('설치가 잘 되지 않으면 관리자에게 문의해 주세요.')
|
||||
})
|
||||
|
||||
it('[앱 내려받기] — 세션을 확인한 뒤 같은 주소로 받는다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
|
||||
await wrapper.get('a[data-app-download]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.map((request) => request.url)).toContain('/auth/me')
|
||||
expect(downloads).toEqual([FILE_URL])
|
||||
})
|
||||
})
|
||||
|
||||
describe('B2 받을 앱 없음 (404 RES_001)', () => {
|
||||
it('안내와 문의 줄만 — 카드 · 설치 방법 · 아이폰 안내는 없고 알리지 않는다', async () => {
|
||||
const wrapper = await mountPage({ release: () => fail(404, 'RES_001') })
|
||||
|
||||
const none = wrapper.get('[data-mobile-app-none]')
|
||||
expect(none.get('h2').text()).toBe('아직 받을 수 있는 앱이 없습니다')
|
||||
expect(none.text()).toContain('앱이 준비되면 이 화면에서 받을 수 있습니다.')
|
||||
expect(none.get('[data-support-line]').text()).toContain('언제 받을 수 있는지 궁금하신가요?')
|
||||
expect(none.get('[data-support-contact]').text()).toBe(CONTACT)
|
||||
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-app-steps]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-app-iphone-note]').exists()).toBe(false)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('연락처가 없으면 "언제 받을 수 있는지는 관리자에게 문의해 주세요."', async () => {
|
||||
const wrapper = await mountPage({ release: () => fail(404, 'RES_001'), contact: () => ok({ text: null }) })
|
||||
|
||||
expect(wrapper.get('[data-mobile-app-none] [data-support-line]').text()).toBe(
|
||||
'언제 받을 수 있는지는 관리자에게 문의해 주세요.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('받지 못하면 카드 자리에 사전 문구와 [다시 시도] — 알리지 않고, 누르면 다시 받는다', async () => {
|
||||
let reply: MockResult = 'network'
|
||||
const wrapper = await mountPage({ release: () => reply })
|
||||
|
||||
const alert = wrapper.get('main [role="alert"]')
|
||||
expect(alert.text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
|
||||
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-mobile-app-none]').exists()).toBe(false)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
reply = ok(RELEASE)
|
||||
await alert.get('button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-mobile-app-release]').text()).toContain('1.0.0')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,398 @@
|
||||
<script setup lang="ts">
|
||||
// 휴대폰 받기 화면 — /app (디자인 원본 SettingsMobileApp B · README "휴대폰 앱 받기 › 휴대폰 받기 화면 /app") · 탭 제목 "휴대폰 앱 받기 · ARA SYSTEM"
|
||||
// 설정 화면의 QR 코드와 앱의 [새 버전 받기]가 휴대폰 브라우저로 이 주소를 연다.
|
||||
// 독립 화면: 앱 레일 · 상단 바가 없다(인쇄 화면처럼 앱 틀 밖의 보호 화면). 로그인해야 열린다 — 로그인하지 않은 브라우저는 라우터 가드가
|
||||
// 지금의 로그인 화면으로 보냈다가(?redirect=/app) 로그인을 마치면 이 화면으로 돌아온다(새 비밀번호 · 2단계 켜기 단계가 걸린 직원도 마친 뒤 돌아온다).
|
||||
// - 브랜드 띠(#0B3640 — 로그인 브랜드 패널을 줄인 꼴: 로고 · 제목 · 설명) + 바탕 #F8F9FB 의 내용
|
||||
// - 받을 앱 있음(B1): 카드(앱 그림 52 · 이름 · "안드로이드 앱" · 버전 / 올린 날 / 크기 · [앱 내려받기] 전체 폭 48) → 설치 방법 1~4 + 새 버전 한 줄 →
|
||||
// 아이폰 안내 → 문의 줄("설치가 잘 되지 않나요?")
|
||||
// - 받을 앱 없음(B2 — 404 RES_001, 알림 없음): 안내와 문의 줄("언제 받을 수 있는지 …")만
|
||||
// - 받지 못함: 카드 자리에 사전 문구와 [다시 시도] (MailLoadError). 받는 동안은 자리만
|
||||
// - [앱 내려받기]는 첨부처럼 누르기 직전에 세션을 확인한다(MobileAppDownloadLink — confirmSession). 세션이 끝났으면 세션 만료 창
|
||||
// - 휴대폰 폭이 먼저다. 폭 480 보다 넓으면 띠는 화면 폭 전체로 두고 로고 · 제목 · 내용은 가운데 칸 480 에 맞춘다(B3)
|
||||
import { computed } from 'vue'
|
||||
|
||||
import logoFull from '@/assets/brand/logo-arasystem-white.png'
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import LoginSupportLine from '@/components/auth/LoginSupportLine.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import MobileAppDownloadLink from '@/components/mobile-app/MobileAppDownloadLink.vue'
|
||||
import MobileAppIcon from '@/components/mobile-app/MobileAppIcon.vue'
|
||||
import MobileAppIphoneNote from '@/components/mobile-app/MobileAppIphoneNote.vue'
|
||||
import MobileAppSteps from '@/components/mobile-app/MobileAppSteps.vue'
|
||||
import { APP_NAME } from '@/shared/constants/brand'
|
||||
import { MOBILE_APP_PAGE_TEXT, MOBILE_APP_TEXT } from '@/shared/constants/mobile-app'
|
||||
import { appReleaseFacts, appReleaseSize } from '@/shared/utils/app-release'
|
||||
import { useAppReleaseStore } from '@/stores/app-release.store'
|
||||
import { useSupportContactStore } from '@/stores/support-contact.store'
|
||||
|
||||
const appReleaseStore = useAppReleaseStore()
|
||||
const supportContactStore = useSupportContactStore()
|
||||
|
||||
/** 받을 앱 — 받은 판이 있을 때만 */
|
||||
const release = computed(() => (appReleaseStore.status === 'ready' ? appReleaseStore.release : null))
|
||||
const facts = computed(() => (release.value ? appReleaseFacts(release.value) : []))
|
||||
const size = computed(() => (release.value ? appReleaseSize(release.value) : ''))
|
||||
const loadError = computed(() => (appReleaseStore.status === 'error' ? appReleaseStore.error : null))
|
||||
|
||||
// 처음 그리기 전에 — 운영자가 새 판을 올렸을 수 있다. 문의 연락처는 받지 못하면 대신 쓰는 글 그대로(알리지 않는다)
|
||||
void appReleaseStore.load()
|
||||
void supportContactStore.ensureLoaded()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-page">
|
||||
<header class="app-page__band">
|
||||
<div class="app-page__band-inner">
|
||||
<!-- 흰 로고 — 어두운 브랜드 바탕(#0B3640) 위에만 둔다. 원본 960×459 -->
|
||||
<img
|
||||
:src="logoFull"
|
||||
:alt="APP_NAME"
|
||||
width="960"
|
||||
height="459"
|
||||
class="app-page__logo"
|
||||
>
|
||||
<div class="app-page__intro">
|
||||
<h1 class="app-page__title">
|
||||
{{ MOBILE_APP_PAGE_TEXT.title }}
|
||||
</h1>
|
||||
<p class="app-page__lead">
|
||||
{{ MOBILE_APP_PAGE_TEXT.lead }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="app-page__main">
|
||||
<div class="app-page__column">
|
||||
<!-- 받을 앱 있음 -->
|
||||
<template v-if="release">
|
||||
<div
|
||||
class="app-page__card"
|
||||
data-mobile-app-release
|
||||
>
|
||||
<div class="app-page__card-head">
|
||||
<MobileAppIcon large />
|
||||
<div class="app-page__card-name">
|
||||
<span class="app-page__name">{{ MOBILE_APP_TEXT.appName }}</span>
|
||||
<span class="app-page__platform">{{ MOBILE_APP_PAGE_TEXT.platform }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<dl
|
||||
class="app-page__facts"
|
||||
data-mobile-app-facts
|
||||
>
|
||||
<template
|
||||
v-for="fact in facts"
|
||||
:key="fact.label"
|
||||
>
|
||||
<dt class="app-page__fact-label">
|
||||
{{ fact.label }}
|
||||
</dt>
|
||||
<dd class="app-page__fact-value">
|
||||
{{ fact.value }}
|
||||
</dd>
|
||||
</template>
|
||||
</dl>
|
||||
<MobileAppDownloadLink
|
||||
:size="size"
|
||||
block
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section
|
||||
class="app-page__steps"
|
||||
aria-labelledby="app-page-steps-title"
|
||||
>
|
||||
<h2
|
||||
id="app-page-steps-title"
|
||||
class="app-page__section-title"
|
||||
>
|
||||
{{ MOBILE_APP_TEXT.stepsTitle }}
|
||||
</h2>
|
||||
<MobileAppSteps
|
||||
:steps="MOBILE_APP_PAGE_TEXT.steps"
|
||||
roomy
|
||||
/>
|
||||
<p class="app-page__update">
|
||||
{{ MOBILE_APP_PAGE_TEXT.updateText }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<MobileAppIphoneNote />
|
||||
|
||||
<div class="app-page__support">
|
||||
<LoginSupportLine
|
||||
:question="MOBILE_APP_PAGE_TEXT.supportQuestion"
|
||||
:fallback="MOBILE_APP_PAGE_TEXT.supportFallback"
|
||||
:contact="supportContactStore.text"
|
||||
size="base"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 받을 앱 없음 — 안내와 문의 줄만 -->
|
||||
<div
|
||||
v-else-if="appReleaseStore.status === 'none'"
|
||||
class="app-page__none"
|
||||
data-mobile-app-none
|
||||
>
|
||||
<AppIcon
|
||||
name="smartphone"
|
||||
:size="28"
|
||||
:stroke-width="1.5"
|
||||
class="app-page__none-icon"
|
||||
/>
|
||||
<h2 class="app-page__none-title">
|
||||
{{ MOBILE_APP_TEXT.noneTitle }}
|
||||
</h2>
|
||||
<p class="app-page__none-text">
|
||||
{{ MOBILE_APP_PAGE_TEXT.noneText }}
|
||||
</p>
|
||||
<div class="app-page__none-support">
|
||||
<LoginSupportLine
|
||||
:question="MOBILE_APP_TEXT.noneQuestion"
|
||||
:fallback="MOBILE_APP_TEXT.noneFallback"
|
||||
:contact="supportContactStore.text"
|
||||
size="base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 받지 못함 — 카드 자리에 사전 문구와 [다시 시도] -->
|
||||
<MailLoadError
|
||||
v-else-if="loadError"
|
||||
:error="loadError"
|
||||
leave-label=""
|
||||
@retry="appReleaseStore.load()"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="app-page__placeholder"
|
||||
aria-busy="true"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 휴대폰 폭(360)이 먼저다 — 넓은 화면(480 초과)은 아래 미디어 쿼리 */
|
||||
.app-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-surface-panel);
|
||||
color: var(--color-text);
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 브랜드 띠 — 로그인 브랜드 패널을 줄인 꼴 (로고 120 · 제목 20 · 설명) */
|
||||
.app-page__band {
|
||||
padding: var(--space-28) var(--space-20) var(--space-24);
|
||||
background: var(--color-brand-surface);
|
||||
}
|
||||
|
||||
.app-page__band-inner {
|
||||
max-width: 30rem;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-16);
|
||||
}
|
||||
|
||||
.app-page__logo {
|
||||
display: block;
|
||||
width: 7.5rem;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.app-page__intro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.app-page__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-heading);
|
||||
line-height: 1.35;
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--letter-spacing-heading);
|
||||
color: var(--color-on-brand);
|
||||
}
|
||||
|
||||
.app-page__lead {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-body);
|
||||
line-height: 1.6;
|
||||
color: var(--color-on-brand-muted);
|
||||
}
|
||||
|
||||
/* 내용 — 본문 14, 가운데 칸 480 */
|
||||
.app-page__main {
|
||||
flex: 1 1 auto;
|
||||
padding: var(--space-20) var(--space-16) var(--space-28);
|
||||
font-size: var(--font-size-body);
|
||||
}
|
||||
|
||||
.app-page__column {
|
||||
width: 100%;
|
||||
max-width: 30rem;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-22);
|
||||
}
|
||||
|
||||
/* 앱 카드 — 테두리 · 모서리 12 · 안쪽 16 */
|
||||
.app-page__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-14);
|
||||
padding: var(--space-16);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.app-page__card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-12);
|
||||
}
|
||||
|
||||
.app-page__card-name {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.app-page__name {
|
||||
font-size: var(--font-size-title-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
}
|
||||
|
||||
.app-page__platform {
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 버전 · 올린 날 · 크기 — 이름 칸 56 */
|
||||
.app-page__facts {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: var(--space-56) minmax(0, 1fr);
|
||||
row-gap: var(--space-2);
|
||||
font-size: var(--font-size-base);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.app-page__fact-label {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.app-page__fact-value {
|
||||
margin: 0;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.app-page__steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-10);
|
||||
}
|
||||
|
||||
.app-page__section-title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-title-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.app-page__update {
|
||||
margin: var(--space-2) 0 0;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 문의 줄 — 위에 가는 선 */
|
||||
.app-page__support {
|
||||
padding-top: var(--space-16);
|
||||
border-top: var(--border-width) solid var(--color-border);
|
||||
}
|
||||
|
||||
/* 받을 앱 없음 — 테두리 상자 안에 아이콘 28 · 제목 17 · 안내 · 가는 선 아래 문의 줄 */
|
||||
.app-page__none {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-10);
|
||||
padding: var(--space-20) var(--space-16);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.app-page__none-icon {
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.app-page__none-title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-title-md);
|
||||
line-height: var(--line-height-heading);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.app-page__none-text {
|
||||
margin: 0;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.app-page__none-support {
|
||||
align-self: stretch;
|
||||
margin-top: var(--space-4);
|
||||
padding-top: var(--space-12);
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.app-page__placeholder {
|
||||
min-height: 16rem;
|
||||
}
|
||||
|
||||
/* 넓은 화면(480 초과 — PC 브라우저) — 띠는 화면 폭 전체, 로고 · 제목 · 내용은 가운데 칸 480 (B3) */
|
||||
@media (min-width: 30.0625rem) {
|
||||
/* 띠 위 36 · 좌우 24 · 아래 32 */
|
||||
.app-page__band {
|
||||
padding: var(--space-36) var(--space-24) 2rem;
|
||||
}
|
||||
|
||||
.app-page__band-inner {
|
||||
gap: var(--space-18);
|
||||
}
|
||||
|
||||
.app-page__logo {
|
||||
width: 9.375rem;
|
||||
}
|
||||
|
||||
.app-page__title {
|
||||
font-size: var(--font-size-heading-lg);
|
||||
}
|
||||
|
||||
.app-page__main {
|
||||
padding: var(--space-28) var(--space-24) var(--space-40);
|
||||
}
|
||||
|
||||
.app-page__card {
|
||||
padding: var(--space-20);
|
||||
}
|
||||
|
||||
.app-page__none {
|
||||
padding: var(--space-24) var(--space-20);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,278 @@
|
||||
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 QrCodeImage from '@/components/QrCodeImage.vue'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { AppRelease } from '@/shared/types/app-release'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import AccountMobileAppPage from './AccountMobileAppPage.vue'
|
||||
|
||||
// 설정 › 계정 › 휴대폰 앱 (디자인 원본 SettingsMobileApp A · README "휴대폰 앱 받기 › 설정 › 계정 › 휴대폰 앱")
|
||||
// A1 받을 앱 있음 — 앱 칸 · QR · 설치 방법 · 새 버전 · 아이폰 / A2 받을 앱 없음 — 안내와 문의 줄만 / 받지 못함 — 사전 문구와 [다시 시도]
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const CONTACT = 'IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it'
|
||||
/**
|
||||
* 올린 때는 지역 시각 11월 18일 10:23:45 로 만든다 — 화면은 브라우저 시간대의 날짜라, 어느 시간대에서 돌려도 "2026년 11월 18일"이다
|
||||
* (시간대도 입력이다 — testing 규칙. UTC 로 적은 고정값은 UTC−1:24 보다 서쪽에서 17일이 된다)
|
||||
*/
|
||||
const RELEASE: AppRelease = {
|
||||
version: '1.0.0',
|
||||
sizeBytes: 32505856,
|
||||
releasedAt: new Date(2026, 10, 18, 10, 23, 45).toISOString(),
|
||||
}
|
||||
const FILE_URL = '/api/app-releases/android/latest/file'
|
||||
|
||||
interface Replies {
|
||||
release?: () => MockResult | Promise<MockResult>
|
||||
contact?: () => MockResult
|
||||
me?: () => MockResult | Promise<MockResult>
|
||||
refresh?: () => MockResult
|
||||
}
|
||||
|
||||
describe('AccountMobileAppPage', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
/** 실제 내려받기를 시작하는 임시 <a download> 의 클릭 — jsdom 은 이동하지 못하므로 가로채 기록만 한다 */
|
||||
let downloads: string[]
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
downloads = []
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
|
||||
downloads.push(this.getAttribute('href') ?? '')
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) {
|
||||
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountPage(replies: Replies = {}) {
|
||||
requests = installMockApi((request) => {
|
||||
switch (request.url) {
|
||||
case '/app-releases/android/latest':
|
||||
return (replies.release ?? (() => ok(RELEASE)))()
|
||||
case '/support-contact':
|
||||
return (replies.contact ?? (() => ok({ text: CONTACT })))()
|
||||
case '/auth/me':
|
||||
return (replies.me ?? (() => ok(USER)))()
|
||||
case '/auth/refresh':
|
||||
return (replies.refresh ?? (() => ok(USER)))()
|
||||
default:
|
||||
return fail(404, 'RES_001')
|
||||
}
|
||||
})
|
||||
const router = createTestRouter()
|
||||
await router.push('/settings/account/mobile-app')
|
||||
await router.isReady()
|
||||
const wrapper = mount(AccountMobileAppPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const requestLines = () => requests.map((request) => `${request.method} ${request.url}`)
|
||||
|
||||
describe('A1 받을 앱 있음', () => {
|
||||
it('머리 · 「안드로이드 앱」 칸 — 앱 이름과 "버전 · 올린 날 · 크기" 한 줄, [앱 내려받기]는 크기까지 읽어 준다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
|
||||
expect(wrapper.get('h1').text()).toBe('휴대폰 앱')
|
||||
expect(wrapper.text()).toContain(
|
||||
'안드로이드 휴대폰에 ARA SYSTEM 앱을 설치하면 메일을 읽고 보내고, 새 메일 알림도 받을 수 있습니다.',
|
||||
)
|
||||
expect(wrapper.get('#mobile-app-android-title').text()).toBe('안드로이드 앱')
|
||||
const box = wrapper.get('[data-mobile-app-release]')
|
||||
expect(box.text()).toContain('ARA SYSTEM')
|
||||
expect(box.get('[data-mobile-app-facts]').text()).toBe('버전 1.0.0 · 올린 날 2026년 11월 18일 · 크기 31 MB')
|
||||
const download = box.get('a[data-app-download]')
|
||||
expect(download.text()).toBe('앱 내려받기')
|
||||
expect(download.attributes('aria-label')).toBe('앱 내려받기 (31 MB)')
|
||||
expect(download.attributes('href')).toBe(FILE_URL)
|
||||
expect(download.attributes('download')).toBe('')
|
||||
expect(requestLines()).toEqual(['GET /app-releases/android/latest', 'GET /support-contact'])
|
||||
})
|
||||
|
||||
it('QR 은 지금 주소의 출처 + /app 만 담는다 — 휴대폰으로 받기 · 주소를 직접 넣는 길 · PC 에서 받으면 옮겨야 한다는 줄', async () => {
|
||||
const wrapper = await mountPage()
|
||||
|
||||
const qr = wrapper.getComponent(QrCodeImage)
|
||||
expect(qr.props('value')).toBe(`${window.location.origin}/app`)
|
||||
expect(wrapper.get('[data-qr-code]').attributes('aria-label')).toBe('휴대폰에서 앱을 받는 주소의 QR 코드')
|
||||
const phone = wrapper.get('[data-mobile-app-phone]')
|
||||
expect(phone.get('h3').text()).toBe('휴대폰으로 받기')
|
||||
expect(phone.text()).toContain(
|
||||
'휴대폰 카메라로 QR 코드를 찍어 주세요. 휴대폰 브라우저에서 웹메일에 로그인하면 바로 앱을 받을 수 있습니다.',
|
||||
)
|
||||
expect(phone.text()).toContain('QR 코드를 찍을 수 없으면 휴대폰 브라우저의 주소 칸에 아래 주소를 직접 넣어 주세요.')
|
||||
expect(phone.get('[data-mobile-app-address]').text()).toBe(`${window.location.host}/app`)
|
||||
expect(phone.text()).toContain('PC에서 받은 파일은 휴대폰으로 옮겨야 설치할 수 있습니다.')
|
||||
})
|
||||
|
||||
it('설치 방법 1~4(둘째 줄에 받는 파일 이름) · 새 버전이 나오면 · 아이폰 안내(메일 프로그램 연결 링크)', async () => {
|
||||
const wrapper = await mountPage()
|
||||
|
||||
expect(wrapper.get('#mobile-app-steps-title').text()).toBe('설치 방법')
|
||||
const steps = wrapper.findAll('[data-app-steps] li')
|
||||
expect(steps).toHaveLength(4)
|
||||
expect(steps.map((step) => step.find('[aria-hidden="true"]').text())).toEqual(['1', '2', '3', '4'])
|
||||
expect(steps[1]?.text()).toContain('받은 ‘ARA-SYSTEM-1.0.0.apk’ 파일을 열어 주세요.')
|
||||
expect(steps[3]?.text()).toContain('앱 비밀번호는 만들지 않으셔도 됩니다.')
|
||||
expect(wrapper.get('#mobile-app-update-title').text()).toBe('새 버전이 나오면')
|
||||
expect(wrapper.text()).toContain('꼭 필요한 업데이트가 있으면 앱이 먼저 알려 드립니다.')
|
||||
|
||||
const iphone = wrapper.get('[data-app-iphone-note]')
|
||||
expect(iphone.text()).toContain('아이폰용 앱은 아직 없습니다')
|
||||
expect(iphone.get('a').text()).toBe('메일 프로그램 연결')
|
||||
expect(iphone.get('a').attributes('href')).toBe('/settings/account/mail-clients')
|
||||
// 받을 앱이 있으면 문의 줄은 없다
|
||||
expect(wrapper.find('[data-support-line]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('받는 동안은 칸 자리만 둔다 (aria-busy) — QR · 설치 방법은 받은 뒤에', async () => {
|
||||
let release: (result: MockResult) => void = () => {}
|
||||
const gate = new Promise<MockResult>((settle) => {
|
||||
release = settle
|
||||
})
|
||||
const wrapper = await mountPage({ release: () => gate })
|
||||
|
||||
expect(wrapper.find('section [aria-busy="true"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-app-steps]').exists()).toBe(false)
|
||||
|
||||
release(ok(RELEASE))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('A2 받을 앱 없음 (404 RES_001)', () => {
|
||||
it('안내와 문의 줄만 — QR · 설치 방법 · 새 버전 · 아이폰 안내는 없고 알리지 않는다', async () => {
|
||||
const wrapper = await mountPage({ release: () => fail(404, 'RES_001') })
|
||||
|
||||
const none = wrapper.get('[data-mobile-app-none]')
|
||||
expect(none.text()).toContain('아직 받을 수 있는 앱이 없습니다')
|
||||
expect(none.text()).toContain('앱이 준비되면 이곳에서 받아 설치할 수 있습니다.')
|
||||
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-qr-code]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-app-steps]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-app-iphone-note]').exists()).toBe(false)
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('연락처가 있으면 물음 다음 줄에 연락처', async () => {
|
||||
const wrapper = await mountPage({ release: () => fail(404, 'RES_001') })
|
||||
|
||||
const line = wrapper.get('[data-mobile-app-none] [data-support-line]')
|
||||
expect(line.text()).toContain('언제 받을 수 있는지 궁금하신가요?')
|
||||
expect(line.get('[data-support-contact]').text()).toBe(CONTACT)
|
||||
expect(line.find('br').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('연락처를 정하지 않았거나 받지 못하면 대신 쓰는 글 한 줄', async () => {
|
||||
const unset = await mountPage({ release: () => fail(404, 'RES_001'), contact: () => ok({ text: null }) })
|
||||
expect(unset.get('[data-support-line]').text()).toBe('언제 받을 수 있는지는 관리자에게 문의해 주세요.')
|
||||
expect(unset.find('[data-support-contact]').exists()).toBe(false)
|
||||
unset.unmount()
|
||||
|
||||
setActivePinia((pinia = createPinia()))
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
const failed = await mountPage({ release: () => fail(404, 'RES_001'), contact: () => fail(503, 'SYS_001') })
|
||||
expect(failed.get('[data-support-line]').text()).toBe('언제 받을 수 있는지는 관리자에게 문의해 주세요.')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('받지 못함', () => {
|
||||
it('칸 자리에 사전 문구와 [다시 시도] — 알림은 띄우지 않고, 누르면 다시 받는다', async () => {
|
||||
let reply: MockResult = fail(503, 'SYS_001')
|
||||
const wrapper = await mountPage({ release: () => reply })
|
||||
|
||||
const alert = wrapper.get('section [role="alert"]')
|
||||
expect(alert.text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
|
||||
expect(alert.text()).toContain('문의 코드 3f1c0a2e')
|
||||
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-mobile-app-none]').exists()).toBe(false)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
reply = ok(RELEASE)
|
||||
await alert.get('button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(true)
|
||||
expect(requestLines().filter((line) => line.endsWith('/latest'))).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('[앱 내려받기] — 이동 전 세션 확인', () => {
|
||||
it('누르면 세션을 확인한 뒤 같은 주소로 받는다 — 원래 링크의 이동은 막고, 확인 중에 다시 눌러도 한 번만', async () => {
|
||||
let releaseMe: (result: MockResult) => void = () => {}
|
||||
const meGate = new Promise<MockResult>((settle) => {
|
||||
releaseMe = settle
|
||||
})
|
||||
const wrapper = await mountPage({ me: () => meGate })
|
||||
const link = wrapper.get<HTMLAnchorElement>('a[data-app-download]').element
|
||||
|
||||
const first = new MouseEvent('click', { bubbles: true, cancelable: true })
|
||||
link.dispatchEvent(first)
|
||||
link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
||||
await flushPromises()
|
||||
expect(first.defaultPrevented).toBe(true)
|
||||
expect(downloads).toEqual([])
|
||||
|
||||
releaseMe(ok(USER))
|
||||
await flushPromises()
|
||||
expect(requestLines().filter((line) => line.includes('/auth/'))).toEqual(['GET /auth/me'])
|
||||
expect(downloads).toEqual([FILE_URL])
|
||||
})
|
||||
|
||||
it('세션이 끝났으면 받지 않고 세션 만료 창으로 (갱신도 실패)', async () => {
|
||||
const wrapper = await mountPage({ me: () => fail(401, 'AUTH_001'), refresh: () => fail(401, 'AUTH_001') })
|
||||
|
||||
await wrapper.get('a[data-app-download]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requestLines().filter((line) => line.includes('/auth/'))).toEqual(['GET /auth/me', 'POST /auth/refresh'])
|
||||
expect(downloads).toEqual([])
|
||||
expect(useAuthStore().sessionExpired).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('세션이 바뀐 뒤 늦게 온 앱 정보는 쓰지 않는다', async () => {
|
||||
let release: (result: MockResult) => void = () => {}
|
||||
const gate = new Promise<MockResult>((settle) => {
|
||||
release = settle
|
||||
})
|
||||
const wrapper = await mountPage({ release: () => gate })
|
||||
|
||||
useAuthStore().clearAuth()
|
||||
release(ok(RELEASE))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-mobile-app-release]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,383 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 계정 › 휴대폰 앱 (디자인 원본 SettingsMobileApp A · README "휴대폰 앱 받기 › 설정 › 계정 › 휴대폰 앱") — /settings/account/mobile-app · 탭 제목 "휴대폰 앱"
|
||||
// 안드로이드 앱(APK)은 스토어에 올리지 않고 웹메일에 로그인한 직원만 받는다. PC 에서 보는 화면이다.
|
||||
// 머리 + 설명 → 「안드로이드 앱」 칸:
|
||||
// - 받을 앱 있음(A1): 앱 그림 · 이름 · "버전 · 올린 날 · 크기" · [앱 내려받기] / 아래 줄에 QR(지금 주소의 출처 + /app — 비밀 아님) · 「휴대폰으로 받기」 ·
|
||||
// 주소를 직접 넣는 길 · PC 에서 받으면 옮겨야 한다는 줄. 그 뒤 「설치 방법」 1~4 · 「새 버전이 나오면」 · 아이폰 안내(메일 프로그램 연결 링크)
|
||||
// - 받을 앱 없음(A2 — 404 RES_001, 알림 없음): 안내와 문의 줄만(문의 연락처가 있으면 물음 다음 줄에 연락처, 없거나 받지 못하면 대신 쓰는 글 한 줄)
|
||||
// - 받지 못함: 칸 자리에 사전 문구와 [다시 시도] (MailLoadError). 받는 동안은 자리만
|
||||
// - 들어올 때마다 새로 받는다(app-release.store — 운영자가 새 판을 올렸을 수 있다). 세션이 바뀐 뒤 늦게 온 응답은 쓰지 않는다
|
||||
// - [앱 내려받기]는 첨부처럼 이동 직전에 세션을 확인한다(MobileAppDownloadLink — confirmSession)
|
||||
// - 좁은 폭(휴대폰 — 480 이하): 설정 메뉴는 위로(지금 설정 셸), QR 줄은 빼고 [앱 내려받기]는 이름 아래 전체 폭
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import LoginSupportLine from '@/components/auth/LoginSupportLine.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import MobileAppDownloadLink from '@/components/mobile-app/MobileAppDownloadLink.vue'
|
||||
import MobileAppIcon from '@/components/mobile-app/MobileAppIcon.vue'
|
||||
import MobileAppIphoneNote from '@/components/mobile-app/MobileAppIphoneNote.vue'
|
||||
import MobileAppSteps from '@/components/mobile-app/MobileAppSteps.vue'
|
||||
import QrCodeImage from '@/components/QrCodeImage.vue'
|
||||
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
|
||||
import { MOBILE_APP_SETTINGS_TEXT, MOBILE_APP_TEXT } from '@/shared/constants/mobile-app'
|
||||
import {
|
||||
addressWithoutScheme,
|
||||
appReleaseFactsLine,
|
||||
appReleaseFileName,
|
||||
appReleaseSize,
|
||||
mobileAppPageUrl,
|
||||
} from '@/shared/utils/app-release'
|
||||
import { useAppReleaseStore } from '@/stores/app-release.store'
|
||||
import { useSupportContactStore } from '@/stores/support-contact.store'
|
||||
|
||||
const appReleaseStore = useAppReleaseStore()
|
||||
const supportContactStore = useSupportContactStore()
|
||||
|
||||
/** QR 이 담는 주소 — 지금 주소의 출처 + /app ("https://portal.arasystem.it.kr/app") */
|
||||
const pageUrl = mobileAppPageUrl(window.location.origin)
|
||||
/** 휴대폰 브라우저의 주소 칸에 넣을 글 ("portal.arasystem.it.kr/app") */
|
||||
const pageAddress = addressWithoutScheme(pageUrl)
|
||||
|
||||
/** 받을 앱 — 받은 판이 있을 때만 */
|
||||
const release = computed(() => (appReleaseStore.status === 'ready' ? appReleaseStore.release : null))
|
||||
const factsLine = computed(() => (release.value ? appReleaseFactsLine(release.value) : ''))
|
||||
const size = computed(() => (release.value ? appReleaseSize(release.value) : ''))
|
||||
const steps = computed(() => MOBILE_APP_SETTINGS_TEXT.steps(appReleaseFileName(release.value?.version ?? '')))
|
||||
const loadError = computed(() => (appReleaseStore.status === 'error' ? appReleaseStore.error : null))
|
||||
|
||||
// 처음 그리기 전에 — 운영자가 새 판을 올렸을 수 있다. 문의 연락처는 받지 못하면 대신 쓰는 글 그대로(알리지 않는다)
|
||||
void appReleaseStore.load()
|
||||
void supportContactStore.ensureLoaded()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsPageFrame
|
||||
title-id="mobile-app-page-title"
|
||||
:title="MOBILE_APP_SETTINGS_TEXT.title"
|
||||
:description="MOBILE_APP_SETTINGS_TEXT.description"
|
||||
>
|
||||
<section
|
||||
class="mobile-app__section"
|
||||
aria-labelledby="mobile-app-android-title"
|
||||
>
|
||||
<h2
|
||||
id="mobile-app-android-title"
|
||||
class="mobile-app__section-title"
|
||||
>
|
||||
{{ MOBILE_APP_SETTINGS_TEXT.androidTitle }}
|
||||
</h2>
|
||||
|
||||
<!-- 받을 앱 있음 — 앱 줄 + QR 줄 -->
|
||||
<div
|
||||
v-if="release"
|
||||
class="mobile-app__box"
|
||||
data-mobile-app-release
|
||||
>
|
||||
<div class="mobile-app__app">
|
||||
<MobileAppIcon />
|
||||
<div class="mobile-app__app-text">
|
||||
<span class="mobile-app__app-name">{{ MOBILE_APP_TEXT.appName }}</span>
|
||||
<p
|
||||
class="mobile-app__facts"
|
||||
data-mobile-app-facts
|
||||
>
|
||||
{{ factsLine }}
|
||||
</p>
|
||||
</div>
|
||||
<MobileAppDownloadLink
|
||||
class="mobile-app__download"
|
||||
:size="size"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mobile-app__phone"
|
||||
data-mobile-app-phone
|
||||
>
|
||||
<div class="mobile-app__qr-frame">
|
||||
<QrCodeImage
|
||||
:value="pageUrl"
|
||||
:label="MOBILE_APP_SETTINGS_TEXT.qrLabel"
|
||||
/>
|
||||
</div>
|
||||
<div class="mobile-app__phone-text">
|
||||
<h3 class="mobile-app__phone-title">
|
||||
{{ MOBILE_APP_SETTINGS_TEXT.phoneTitle }}
|
||||
</h3>
|
||||
<p class="mobile-app__copy">
|
||||
{{ MOBILE_APP_SETTINGS_TEXT.phoneScan }}
|
||||
</p>
|
||||
<div class="mobile-app__typing">
|
||||
<p class="mobile-app__copy">
|
||||
{{ MOBILE_APP_SETTINGS_TEXT.phoneTyping }}
|
||||
</p>
|
||||
<p
|
||||
class="mobile-app__address"
|
||||
data-mobile-app-address
|
||||
>
|
||||
{{ pageAddress }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="mobile-app__pc-note">
|
||||
{{ MOBILE_APP_SETTINGS_TEXT.pcNote }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 받을 앱 없음 — 안내와 문의 줄만 -->
|
||||
<div
|
||||
v-else-if="appReleaseStore.status === 'none'"
|
||||
class="mobile-app__none"
|
||||
data-mobile-app-none
|
||||
>
|
||||
<AppIcon
|
||||
name="smartphone"
|
||||
:size="24"
|
||||
:stroke-width="1.5"
|
||||
class="mobile-app__none-icon"
|
||||
/>
|
||||
<div class="mobile-app__none-text">
|
||||
<span class="mobile-app__none-title">{{ MOBILE_APP_TEXT.noneTitle }}</span>
|
||||
<span class="mobile-app__none-hint">{{ MOBILE_APP_SETTINGS_TEXT.noneText }}</span>
|
||||
</div>
|
||||
<div class="mobile-app__none-support">
|
||||
<LoginSupportLine
|
||||
:question="MOBILE_APP_TEXT.noneQuestion"
|
||||
:fallback="MOBILE_APP_TEXT.noneFallback"
|
||||
:contact="supportContactStore.text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 받지 못함 — 칸 자리에 사전 문구와 [다시 시도] -->
|
||||
<MailLoadError
|
||||
v-else-if="loadError"
|
||||
:error="loadError"
|
||||
leave-label=""
|
||||
@retry="appReleaseStore.load()"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="mobile-app__placeholder"
|
||||
aria-busy="true"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<template v-if="release">
|
||||
<section
|
||||
class="mobile-app__steps"
|
||||
aria-labelledby="mobile-app-steps-title"
|
||||
>
|
||||
<h2
|
||||
id="mobile-app-steps-title"
|
||||
class="mobile-app__section-title"
|
||||
>
|
||||
{{ MOBILE_APP_TEXT.stepsTitle }}
|
||||
</h2>
|
||||
<MobileAppSteps :steps="steps" />
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="mobile-app__update"
|
||||
aria-labelledby="mobile-app-update-title"
|
||||
>
|
||||
<h2
|
||||
id="mobile-app-update-title"
|
||||
class="mobile-app__section-title"
|
||||
>
|
||||
{{ MOBILE_APP_SETTINGS_TEXT.updateTitle }}
|
||||
</h2>
|
||||
<p
|
||||
v-for="text in MOBILE_APP_SETTINGS_TEXT.updateTexts"
|
||||
:key="text"
|
||||
class="mobile-app__copy"
|
||||
>
|
||||
{{ text }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<MobileAppIphoneNote />
|
||||
</template>
|
||||
</SettingsPageFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mobile-app__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.mobile-app__section-title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 앱 칸 — 테두리 · 모서리 10, 앱 줄과 QR 줄 사이에 가는 선 */
|
||||
.mobile-app__box {
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 앱 줄 — 그림 48 · 이름 · 정보 · [앱 내려받기](44). 좁으면 단추가 이름 아래 전체 폭으로 내려간다 */
|
||||
.mobile-app__app {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-14);
|
||||
padding: var(--space-16);
|
||||
}
|
||||
|
||||
.mobile-app__app-text {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
line-height: var(--line-height-heading);
|
||||
}
|
||||
|
||||
.mobile-app__app-name {
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mobile-app__facts {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.mobile-app__download {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* QR 줄 — QR 160(상자 안쪽 6 · 테두리) + 글 */
|
||||
.mobile-app__phone {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-20);
|
||||
padding: var(--space-16);
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.mobile-app__qr-frame {
|
||||
flex: 0 0 auto;
|
||||
padding: var(--space-6);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.mobile-app__phone-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.mobile-app__phone-title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-emphasis);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mobile-app__copy {
|
||||
margin: 0;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.mobile-app__typing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding-top: var(--space-10);
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.mobile-app__address {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mobile-app__pc-note {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 받을 앱 없음 — 테두리 상자 안에 아이콘 24 · 제목 · 안내 · 가는 선 아래 문의 줄 */
|
||||
.mobile-app__none {
|
||||
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);
|
||||
}
|
||||
|
||||
.mobile-app__none-icon {
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.mobile-app__none-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.mobile-app__none-title {
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mobile-app__none-hint {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.mobile-app__none-support {
|
||||
align-self: stretch;
|
||||
margin-top: var(--space-2);
|
||||
padding-top: var(--space-12);
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.mobile-app__placeholder {
|
||||
min-height: 16rem;
|
||||
}
|
||||
|
||||
.mobile-app__steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-10);
|
||||
}
|
||||
|
||||
.mobile-app__update {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
/* 좁은 폭(휴대폰) — QR 줄은 빼고(휴대폰에서는 [앱 내려받기]로 바로 받는다) 단추는 이름 아래 전체 폭 */
|
||||
@media (max-width: 30rem) {
|
||||
.mobile-app__phone {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-app__download {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -9,8 +9,9 @@ import { useAuthStore } from '@/stores/auth.store'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import SettingsLayout from './SettingsLayout.vue'
|
||||
|
||||
// 설정 메뉴 — 계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 로그인 기록 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림
|
||||
// (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계" · "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소" · "11단계 › 설정 › 메일 › 대용량 첨부" · "12단계 — 계정 보안")
|
||||
// 설정 메뉴 — 계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 휴대폰 앱 · 로그인 기록 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림
|
||||
// (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계" · "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소" · "11단계 › 설정 › 메일 › 대용량 첨부" · "12단계 — 계정 보안" ·
|
||||
// "13단계" · "휴대폰 앱 받기")
|
||||
|
||||
describe('SettingsLayout', () => {
|
||||
let pinia: Pinia
|
||||
@@ -35,7 +36,7 @@ describe('SettingsLayout', () => {
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
it('묶음 둘 — 계정(비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 로그인 기록) · 메일(서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림), 묶음 이름이 목록의 이름이다', async () => {
|
||||
it('묶음 둘 — 계정(비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 휴대폰 앱 · 로그인 기록) · 메일(서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림), 묶음 이름이 목록의 이름이다', async () => {
|
||||
const { wrapper } = await mountAt('/settings/account/password')
|
||||
const menu = wrapper.get('nav[aria-label="설정 메뉴"]')
|
||||
|
||||
@@ -48,6 +49,7 @@ describe('SettingsLayout', () => {
|
||||
['2단계 인증', '/settings/account/two-factor'],
|
||||
['앱 비밀번호', '/settings/account/app-passwords'],
|
||||
['메일 프로그램 연결', '/settings/account/mail-clients'],
|
||||
['휴대폰 앱', '/settings/account/mobile-app'],
|
||||
['로그인 기록', '/settings/account/login-history'],
|
||||
],
|
||||
[
|
||||
@@ -145,6 +147,24 @@ describe('SettingsLayout', () => {
|
||||
expect(clock.findAll('path').map((shape) => shape.attributes('d'))).toContain('M3 3v5h5')
|
||||
})
|
||||
|
||||
it('설정 › 계정 › 휴대폰 앱 — /settings/account/mobile-app, 로그인이 필요하고 탭 제목은 메뉴 이름 (앱의 라우트 표 그대로)', async () => {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes })
|
||||
|
||||
await router.push('/settings/account/mobile-app')
|
||||
expect(router.currentRoute.value.name).toBe('settings-account-mobile-app')
|
||||
expect(router.currentRoute.value.meta).toMatchObject({ title: '휴대폰 앱', requiresAuth: true })
|
||||
expect(router.resolve({ name: 'settings-account-mobile-app' }).fullPath).toBe('/settings/account/mobile-app')
|
||||
})
|
||||
|
||||
it('휴대폰 앱 메뉴는 휴대폰 아이콘(smartphone) · 지금 화면이면 현재로 표시한다', async () => {
|
||||
const { wrapper } = await mountAt('/settings/account/mobile-app')
|
||||
const current = wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')
|
||||
|
||||
expect(current.text()).toBe('휴대폰 앱')
|
||||
expect(current.find('rect').attributes()).toMatchObject({ x: '5', y: '2', width: '14', height: '20' })
|
||||
expect(current.find('line').attributes()).toMatchObject({ x1: '12', y1: '18', x2: '12.01', y2: '18' })
|
||||
})
|
||||
|
||||
it('대용량 첨부 메뉴는 링크 아이콘 · 지금 화면이면 현재로 표시한다', async () => {
|
||||
const { wrapper } = await mountAt('/settings/mail/large-files')
|
||||
const current = wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')
|
||||
|
||||
Reference in New Issue
Block a user