- 하단 내비게이션(뒤로/앞으로/홈/새로고침/설정), 사이드바 홈 제거 - 설정 화면: 계정정보·앱 버전(package.json)·App Data 삭제 - 가입정보 변경: 비밀번호 재인증 → 이름/이메일 수정 - 로그인 후 대시보드(/) 이동, 로그인 전 햄버거·네이버 로그인 버튼 숨김 - Vitest 도입(32): authApi/accountApi 와이어링, auth/ui 스토어, 로그인 플로우 - CI(.gitea): npm test 게이트 추가 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,10 @@ jobs:
|
||||
npx oxlint .
|
||||
npx eslint .
|
||||
|
||||
# 단위/컴포넌트 테스트 — 실패 시 빌드/배포 차단(게이트)
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export default defineConfig([
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
__APP_VERSION__: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Generated
+1367
-3
File diff suppressed because it is too large
Load Diff
+8
-2
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"name": "sb_pt",
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:app": "vite build --mode capacitor",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"cap:sync": "npm run build:app && cap sync",
|
||||
"cap:android": "cap open android",
|
||||
"preview": "vite preview",
|
||||
@@ -32,15 +34,19 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@pinia/testing": "^1.0.3",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-oxlint": "~1.60.0",
|
||||
"eslint-plugin-vue": "~10.8.0",
|
||||
"globals": "^17.5.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"oxlint": "~1.60.0",
|
||||
"vite": "^8.0.8",
|
||||
"vite-plugin-vue-devtools": "^8.1.1"
|
||||
"vite-plugin-vue-devtools": "^8.1.1",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ import { onMounted, onUnmounted, watch } from 'vue'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||
import AppSidebar from '@/components/layout/AppSidebar.vue'
|
||||
import AppFooter from '@/components/layout/AppFooter.vue'
|
||||
import AppBottomNav from '@/components/layout/AppBottomNav.vue'
|
||||
import LoginModal from '@/components/LoginModal.vue'
|
||||
import SignupModal from '@/components/SignupModal.vue'
|
||||
import ChangePasswordModal from '@/components/ChangePasswordModal.vue'
|
||||
@@ -49,7 +49,7 @@ watch(() => route.fullPath, () => ui.closeSidebar())
|
||||
<main class="layout-body">
|
||||
<RouterView />
|
||||
</main>
|
||||
<AppFooter class="layout-bottom" />
|
||||
<AppBottomNav class="layout-bottom" />
|
||||
</div>
|
||||
|
||||
<LoginModal />
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/api/http', () => ({
|
||||
default: {
|
||||
get: vi.fn(() => Promise.resolve({})),
|
||||
post: vi.fn(() => Promise.resolve({})),
|
||||
put: vi.fn(() => Promise.resolve({})),
|
||||
delete: vi.fn(() => Promise.resolve({})),
|
||||
},
|
||||
}))
|
||||
|
||||
import http from '@/api/http'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
|
||||
describe('accountApi 가계부 CRUD 와이어링', () => {
|
||||
it('list → GET /account/entries (필터 params)', () => {
|
||||
accountApi.list({ year: 2026, month: 6, type: 'EXPENSE' })
|
||||
expect(http.get).toHaveBeenCalledWith('/account/entries', {
|
||||
params: { year: 2026, month: 6, type: 'EXPENSE', category: undefined, walletId: undefined, keyword: undefined, tagId: undefined },
|
||||
})
|
||||
})
|
||||
|
||||
it('create → POST /account/entries', () => {
|
||||
const payload = { type: 'EXPENSE', amount: 1000, category: '식비' }
|
||||
accountApi.create(payload)
|
||||
expect(http.post).toHaveBeenCalledWith('/account/entries', payload)
|
||||
})
|
||||
|
||||
it('update → PUT /account/entries/:id', () => {
|
||||
accountApi.update(42, { amount: 2000 })
|
||||
expect(http.put).toHaveBeenCalledWith('/account/entries/42', { amount: 2000 })
|
||||
})
|
||||
|
||||
it('remove → DELETE /account/entries/:id', () => {
|
||||
accountApi.remove(42)
|
||||
expect(http.delete).toHaveBeenCalledWith('/account/entries/42')
|
||||
})
|
||||
|
||||
it('summary → GET /account/summary', () => {
|
||||
accountApi.summary({ year: 2026, month: 6 })
|
||||
expect(http.get).toHaveBeenCalledWith('/account/summary', { params: { year: 2026, month: 6 } })
|
||||
})
|
||||
|
||||
it('netWorth → GET /account/networth', () => {
|
||||
accountApi.netWorth()
|
||||
expect(http.get).toHaveBeenCalledWith('/account/networth')
|
||||
})
|
||||
|
||||
it('budgetStatus → GET /account/budgets/status', () => {
|
||||
accountApi.budgetStatus({ year: 2026, month: 6 })
|
||||
expect(http.get).toHaveBeenCalledWith('/account/budgets/status', { params: { year: 2026, month: 6 } })
|
||||
})
|
||||
|
||||
it('pendingCount → GET /account/entries/pending/count', () => {
|
||||
accountApi.pendingCount()
|
||||
expect(http.get).toHaveBeenCalledWith('/account/entries/pending/count')
|
||||
})
|
||||
|
||||
it('confirmEntry → POST /account/entries/:id/confirm (payload 기본값 {})', () => {
|
||||
accountApi.confirmEntry(7)
|
||||
expect(http.post).toHaveBeenCalledWith('/account/entries/7/confirm', {})
|
||||
})
|
||||
|
||||
it('createWallet → POST /account/wallets', () => {
|
||||
const payload = { name: '주거래', type: 'ASSET' }
|
||||
accountApi.createWallet(payload)
|
||||
expect(http.post).toHaveBeenCalledWith('/account/wallets', payload)
|
||||
})
|
||||
})
|
||||
@@ -20,4 +20,12 @@ export const authApi = {
|
||||
changePassword(payload) {
|
||||
return http.put('/auth/password', payload)
|
||||
},
|
||||
// 가입정보 변경 진입 전 비밀번호 재인증
|
||||
verifyPassword(password) {
|
||||
return http.post('/auth/verify-password', { password })
|
||||
},
|
||||
// 가입정보(이름/이메일) 변경
|
||||
updateProfile(payload) {
|
||||
return http.put('/auth/profile', payload)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
// http(axios 인스턴스)를 모킹 — 실제 네트워크 없이 호출 형태만 검증
|
||||
vi.mock('@/api/http', () => ({
|
||||
default: {
|
||||
get: vi.fn(() => Promise.resolve({})),
|
||||
post: vi.fn(() => Promise.resolve({})),
|
||||
put: vi.fn(() => Promise.resolve({})),
|
||||
delete: vi.fn(() => Promise.resolve({})),
|
||||
},
|
||||
}))
|
||||
|
||||
import http from '@/api/http'
|
||||
import { authApi } from '@/api/authApi'
|
||||
|
||||
describe('authApi 엔드포인트 와이어링', () => {
|
||||
it('signup → POST /auth/signup', () => {
|
||||
const payload = { loginId: 'u1', password: 'pw', name: '홍길동' }
|
||||
authApi.signup(payload)
|
||||
expect(http.post).toHaveBeenCalledWith('/auth/signup', payload)
|
||||
})
|
||||
|
||||
it('signupEnabled → GET /auth/signup-enabled', () => {
|
||||
authApi.signupEnabled()
|
||||
expect(http.get).toHaveBeenCalledWith('/auth/signup-enabled')
|
||||
})
|
||||
|
||||
it('login → POST /auth/login', () => {
|
||||
const payload = { loginId: 'u1', password: 'pw', rememberMe: true }
|
||||
authApi.login(payload)
|
||||
expect(http.post).toHaveBeenCalledWith('/auth/login', payload)
|
||||
})
|
||||
|
||||
it('logout → POST /auth/logout', () => {
|
||||
authApi.logout()
|
||||
expect(http.post).toHaveBeenCalledWith('/auth/logout')
|
||||
})
|
||||
|
||||
it('me → GET /auth/me', () => {
|
||||
authApi.me()
|
||||
expect(http.get).toHaveBeenCalledWith('/auth/me')
|
||||
})
|
||||
|
||||
it('changePassword → PUT /auth/password', () => {
|
||||
const payload = { currentPassword: 'a', newPassword: 'bbbbbbbb' }
|
||||
authApi.changePassword(payload)
|
||||
expect(http.put).toHaveBeenCalledWith('/auth/password', payload)
|
||||
})
|
||||
|
||||
it('verifyPassword → POST /auth/verify-password (password 래핑)', () => {
|
||||
authApi.verifyPassword('secret')
|
||||
expect(http.post).toHaveBeenCalledWith('/auth/verify-password', { password: 'secret' })
|
||||
})
|
||||
|
||||
it('updateProfile → PUT /auth/profile', () => {
|
||||
const payload = { name: '새이름', email: 'a@b.com' }
|
||||
authApi.updateProfile(payload)
|
||||
expect(http.put).toHaveBeenCalledWith('/auth/profile', payload)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
// useRouter().push 를 캡처
|
||||
const { pushMock } = vi.hoisted(() => ({ pushMock: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: pushMock }) }))
|
||||
|
||||
vi.mock('@/api/authApi', () => ({
|
||||
authApi: {
|
||||
login: vi.fn(),
|
||||
signupEnabled: vi.fn(() => Promise.resolve({ enabled: true })),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@capacitor/preferences', () => ({
|
||||
Preferences: {
|
||||
set: vi.fn(() => Promise.resolve()),
|
||||
get: vi.fn(() => Promise.resolve({ value: null })),
|
||||
remove: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
}))
|
||||
|
||||
import { authApi } from '@/api/authApi'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
import LoginModal from '@/components/LoginModal.vue'
|
||||
|
||||
function mountOpen() {
|
||||
const ui = useUiStore()
|
||||
return { ui, wrapper: mount(LoginModal, { global: { stubs: { teleport: true } } }) }
|
||||
}
|
||||
|
||||
async function submitLogin(wrapper) {
|
||||
await wrapper.find('input[autocomplete="username"]').setValue('u1')
|
||||
await wrapper.find('input[autocomplete="current-password"]').setValue('pw')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('LoginModal 로그인 플로우', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
authApi.login.mockResolvedValue({ token: 'tok', member: { id: 1, loginId: 'u1', name: 'N' } })
|
||||
})
|
||||
|
||||
it('redirect 없으면 로그인 후 대시보드(/)로 이동', async () => {
|
||||
const { ui, wrapper } = mountOpen()
|
||||
ui.openLogin('')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await submitLogin(wrapper)
|
||||
|
||||
expect(authApi.login).toHaveBeenCalledWith({ loginId: 'u1', password: 'pw', rememberMe: true })
|
||||
expect(pushMock).toHaveBeenCalledWith('/')
|
||||
expect(ui.loginOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('redirect 있으면 보존된 목적지로 이동', async () => {
|
||||
const { ui, wrapper } = mountOpen()
|
||||
ui.openLogin('/account/wallets')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await submitLogin(wrapper)
|
||||
|
||||
expect(pushMock).toHaveBeenCalledWith('/account/wallets')
|
||||
})
|
||||
|
||||
it('로그인 실패 시 에러 표시 + 이동 없음', async () => {
|
||||
authApi.login.mockRejectedValueOnce({ response: { data: { message: '아이디 또는 비밀번호가 올바르지 않습니다.' } } })
|
||||
const { ui, wrapper } = mountOpen()
|
||||
ui.openLogin('')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await submitLogin(wrapper)
|
||||
|
||||
expect(pushMock).not.toHaveBeenCalled()
|
||||
expect(ui.loginOpen).toBe(true)
|
||||
expect(wrapper.text()).toContain('아이디 또는 비밀번호가 올바르지 않습니다.')
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,9 @@ function goSignup() {
|
||||
ui.openSignup()
|
||||
}
|
||||
|
||||
// 소셜 로그인(네이버)은 준비 중 — 잠시 숨김. 구현 완료 시 true 로 전환.
|
||||
const SOCIAL_LOGIN_ENABLED = false
|
||||
|
||||
const form = reactive({ loginId: '', password: '', rememberMe: true })
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
@@ -41,7 +44,8 @@ async function handleLogin() {
|
||||
await auth.login(form.loginId, form.password, form.rememberMe)
|
||||
const redirect = ui.redirectPath
|
||||
ui.closeLogin()
|
||||
if (redirect) router.push(redirect)
|
||||
// 가드가 보존한 목적지가 있으면 그곳으로, 없으면 대시보드(홈)로 이동
|
||||
router.push(redirect || '/')
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '로그인에 실패했습니다.'
|
||||
} finally {
|
||||
@@ -81,7 +85,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
|
||||
<button type="button" class="naver" :disabled="loading" @click="handleNaverLogin">
|
||||
<button v-if="SOCIAL_LOGIN_ENABLED" type="button" class="naver" :disabled="loading" @click="handleNaverLogin">
|
||||
네이버로 로그인 (준비 중)
|
||||
</button>
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const isHome = computed(() => route.path === '/')
|
||||
const isSettings = computed(() => route.path.startsWith('/settings'))
|
||||
|
||||
function goBack() {
|
||||
// 히스토리가 있으면 뒤로, 없으면 홈으로 (앱 첫 화면에서 빠져나가지 않게)
|
||||
if (window.history.length > 1) router.back()
|
||||
else router.push('/')
|
||||
}
|
||||
function goForward() {
|
||||
router.forward()
|
||||
}
|
||||
function goHome() {
|
||||
if (!isHome.value) router.push('/')
|
||||
}
|
||||
function refresh() {
|
||||
// 현재 화면 데이터를 다시 불러옴 — 저장된 세션은 복원되므로 로그아웃되지 않음
|
||||
window.location.reload()
|
||||
}
|
||||
function goSettings() {
|
||||
if (!isSettings.value) router.push('/settings')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="bottom-nav" aria-label="앱 내비게이션">
|
||||
<button type="button" class="nav-btn" @click="goBack">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 12H5" /><path d="M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>뒤로</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="nav-btn" @click="goForward">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14" /><path d="M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span>앞으로</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="nav-btn" :class="{ active: isHome }" @click="goHome">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /><path d="M9 22V12h6v10" />
|
||||
</svg>
|
||||
<span>홈</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="nav-btn" @click="refresh">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M23 4v6h-6" /><path d="M1 20v-6h6" />
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10" /><path d="M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
||||
</svg>
|
||||
<span>새로고침</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="nav-btn" :class="{ active: isSettings }" @click="goSettings">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 21v-7" /><path d="M4 10V3" /><path d="M12 21v-9" /><path d="M12 8V3" />
|
||||
<path d="M20 21v-5" /><path d="M20 12V3" /><path d="M1 14h6" /><path d="M9 8h6" /><path d="M17 16h6" />
|
||||
</svg>
|
||||
<span>설정</span>
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bottom-nav {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: space-around;
|
||||
height: 56px;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
background: var(--color-background-soft);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.nav-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.15rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 0.68rem;
|
||||
opacity: 0.78;
|
||||
transition: opacity 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.nav-btn svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
.nav-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.nav-btn:active {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.nav-btn.active {
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -17,7 +17,7 @@ async function handleLogout() {
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<button class="hamburger" type="button" aria-label="메뉴" @click="ui.toggleSidebar()">
|
||||
<button v-if="auth.isAuthenticated" class="hamburger" type="button" aria-label="메뉴" @click="ui.toggleSidebar()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M3 12h18" /><path d="M3 6h18" /><path d="M3 18h18" />
|
||||
</svg>
|
||||
|
||||
@@ -15,11 +15,8 @@ const ui = useUiStore()
|
||||
<button class="drawer-close" type="button" aria-label="닫기" @click="ui.closeSidebar()">×</button>
|
||||
</div>
|
||||
<nav class="menu">
|
||||
<RouterLink to="/" class="menu-item">홈</RouterLink>
|
||||
|
||||
<!-- 가계부 영역 -->
|
||||
<!-- 가계부 영역 (홈은 하단 내비게이션으로 이동) -->
|
||||
<template v-if="auth.isAuthenticated">
|
||||
<hr class="menu-divider" />
|
||||
<RouterLink to="/account/entries" class="menu-item">가계부 내역</RouterLink>
|
||||
<RouterLink to="/account/stats" class="menu-item">통계</RouterLink>
|
||||
<RouterLink to="/account/recurrings" class="menu-item">고정 지출</RouterLink>
|
||||
|
||||
@@ -98,6 +98,25 @@ const router = createRouter({
|
||||
component: () => import('../views/account/RecurringView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
// 설정 (앱 하단 내비게이션 → 설정)
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
component: () => import('../views/settings/SettingsView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/settings/account',
|
||||
name: 'settings-account',
|
||||
component: () => import('../views/settings/AccountInfoView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/settings/account/edit',
|
||||
name: 'settings-account-edit',
|
||||
component: () => import('../views/settings/ProfileEditView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
+7
-1
@@ -84,13 +84,19 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
await clear()
|
||||
}
|
||||
|
||||
// 가입정보 변경 등으로 바뀐 사용자 필드를 로컬 세션에 병합 (헤더 이름 등 즉시 반영)
|
||||
async function applyUser(partial) {
|
||||
user.value = { ...(user.value || {}), ...partial }
|
||||
await persist()
|
||||
}
|
||||
|
||||
async function clear() {
|
||||
token.value = ''
|
||||
user.value = null
|
||||
await persist()
|
||||
}
|
||||
|
||||
return { token, user, ready, isAuthenticated, restore, login, signup, fetchMe, logout, clear }
|
||||
return { token, user, ready, isAuthenticated, restore, login, signup, fetchMe, applyUser, logout, clear }
|
||||
})
|
||||
|
||||
function safeParse(value) {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
vi.mock('@/api/authApi', () => ({
|
||||
authApi: {
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(() => Promise.resolve()),
|
||||
me: vi.fn(),
|
||||
signup: vi.fn(),
|
||||
signupEnabled: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// 네이티브 저장소 — 테스트에선 no-op (localStorage 미러만 검증)
|
||||
vi.mock('@capacitor/preferences', () => ({
|
||||
Preferences: {
|
||||
set: vi.fn(() => Promise.resolve()),
|
||||
get: vi.fn(() => Promise.resolve({ value: null })),
|
||||
remove: vi.fn(() => Promise.resolve()),
|
||||
clear: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
}))
|
||||
|
||||
import { authApi } from '@/api/authApi'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
describe('auth 스토어', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('login: 토큰/사용자 저장 + localStorage 미러 + isAuthenticated', async () => {
|
||||
authApi.login.mockResolvedValue({
|
||||
token: 'tok123',
|
||||
member: { id: 1, loginId: 'u1', name: '홍길동', email: 'a@b.com' },
|
||||
})
|
||||
const store = useAuthStore()
|
||||
await store.login('u1', 'pw', true)
|
||||
|
||||
expect(authApi.login).toHaveBeenCalledWith({ loginId: 'u1', password: 'pw', rememberMe: true })
|
||||
expect(store.token).toBe('tok123')
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
expect(store.user.email).toBe('a@b.com')
|
||||
expect(localStorage.getItem('token')).toBe('tok123')
|
||||
expect(JSON.parse(localStorage.getItem('auth_user')).name).toBe('홍길동')
|
||||
})
|
||||
|
||||
it('logout: 상태/스토리지 정리', async () => {
|
||||
authApi.login.mockResolvedValue({ token: 't', member: { id: 1, loginId: 'u1' } })
|
||||
const store = useAuthStore()
|
||||
await store.login('u1', 'pw', false)
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
|
||||
await store.logout()
|
||||
expect(authApi.logout).toHaveBeenCalled()
|
||||
expect(store.token).toBe('')
|
||||
expect(store.user).toBeNull()
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(localStorage.getItem('token')).toBeNull()
|
||||
expect(localStorage.getItem('auth_user')).toBeNull()
|
||||
})
|
||||
|
||||
it('logout: 서버 401 이어도 클라이언트는 정리', async () => {
|
||||
authApi.login.mockResolvedValue({ token: 't', member: { id: 1 } })
|
||||
authApi.logout.mockRejectedValueOnce(new Error('401'))
|
||||
const store = useAuthStore()
|
||||
await store.login('u1', 'pw', false)
|
||||
|
||||
await store.logout()
|
||||
expect(store.token).toBe('')
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
})
|
||||
|
||||
it('restore: localStorage 세션 복원 + ready', async () => {
|
||||
localStorage.setItem('token', 't2')
|
||||
localStorage.setItem('auth_user', JSON.stringify({ id: 2, name: '복원유저' }))
|
||||
const store = useAuthStore()
|
||||
await store.restore()
|
||||
|
||||
expect(store.token).toBe('t2')
|
||||
expect(store.user.name).toBe('복원유저')
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
expect(store.ready).toBe(true)
|
||||
})
|
||||
|
||||
it('applyUser: 변경 필드 병합 + 미러 갱신 (가입정보 변경 반영)', async () => {
|
||||
authApi.login.mockResolvedValue({
|
||||
token: 't',
|
||||
member: { id: 1, loginId: 'u1', name: '원래이름', email: 'old@b.com' },
|
||||
})
|
||||
const store = useAuthStore()
|
||||
await store.login('u1', 'pw', false)
|
||||
|
||||
await store.applyUser({ name: '새이름', email: 'new@b.com' })
|
||||
expect(store.user.name).toBe('새이름')
|
||||
expect(store.user.loginId).toBe('u1') // 기존 필드 보존
|
||||
expect(JSON.parse(localStorage.getItem('auth_user')).email).toBe('new@b.com')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
vi.mock('@/api/authApi', () => ({
|
||||
authApi: {
|
||||
signupEnabled: vi.fn(() => Promise.resolve({ enabled: true })),
|
||||
},
|
||||
}))
|
||||
|
||||
import { authApi } from '@/api/authApi'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
|
||||
describe('ui 스토어', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
|
||||
it('openLogin: 로그인 열고 회원가입 닫고 redirect 보존', () => {
|
||||
const ui = useUiStore()
|
||||
ui.signupOpen = true
|
||||
ui.openLogin('/account/entries')
|
||||
expect(ui.loginOpen).toBe(true)
|
||||
expect(ui.signupOpen).toBe(false)
|
||||
expect(ui.redirectPath).toBe('/account/entries')
|
||||
})
|
||||
|
||||
it('closeLogin: 닫고 redirect 초기화', () => {
|
||||
const ui = useUiStore()
|
||||
ui.openLogin('/x')
|
||||
ui.closeLogin()
|
||||
expect(ui.loginOpen).toBe(false)
|
||||
expect(ui.redirectPath).toBe('')
|
||||
})
|
||||
|
||||
it('openSignup: 회원가입 열고 로그인 닫고 허용여부 재조회', () => {
|
||||
const ui = useUiStore()
|
||||
ui.loginOpen = true
|
||||
ui.openSignup()
|
||||
expect(ui.signupOpen).toBe(true)
|
||||
expect(ui.loginOpen).toBe(false)
|
||||
expect(authApi.signupEnabled).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toggleSidebar / closeSidebar', () => {
|
||||
const ui = useUiStore()
|
||||
expect(ui.sidebarOpen).toBe(false)
|
||||
ui.toggleSidebar()
|
||||
expect(ui.sidebarOpen).toBe(true)
|
||||
ui.closeSidebar()
|
||||
expect(ui.sidebarOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('loadSignupEnabled: API 값 반영', async () => {
|
||||
authApi.signupEnabled.mockResolvedValueOnce({ enabled: false })
|
||||
const ui = useUiStore()
|
||||
await ui.loadSignupEnabled()
|
||||
expect(ui.signupEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('loadSignupEnabled: 조회 실패 시 기본 허용(true)', async () => {
|
||||
authApi.signupEnabled.mockRejectedValueOnce(new Error('net'))
|
||||
const ui = useUiStore()
|
||||
await ui.loadSignupEnabled()
|
||||
expect(ui.signupEnabled).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const u = computed(() => auth.user || {})
|
||||
const isLocal = computed(() => (u.value.provider || 'LOCAL') === 'LOCAL')
|
||||
|
||||
const providerLabel = computed(() => {
|
||||
switch (u.value.provider) {
|
||||
case 'NAVER': return '네이버'
|
||||
case 'LOCAL':
|
||||
default: return '일반(아이디/비밀번호)'
|
||||
}
|
||||
})
|
||||
const roleLabel = computed(() => (u.value.role === 'ADMIN' ? '관리자' : '일반회원'))
|
||||
|
||||
function goEdit() {
|
||||
router.push('/settings/account/edit')
|
||||
}
|
||||
function goBack() {
|
||||
router.push('/settings')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="account-info">
|
||||
<button type="button" class="back" @click="goBack">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 12H5" /><path d="M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>설정</span>
|
||||
</button>
|
||||
|
||||
<h1 class="page-title">계정정보</h1>
|
||||
|
||||
<section class="card">
|
||||
<div class="row">
|
||||
<span class="k">아이디</span>
|
||||
<span class="v">{{ u.loginId || '-' }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="k">이름</span>
|
||||
<span class="v">{{ u.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="k">이메일</span>
|
||||
<span class="v">{{ u.email || '-' }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="k">가입유형</span>
|
||||
<span class="v">{{ providerLabel }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="k">권한</span>
|
||||
<span class="v">{{ roleLabel }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button v-if="isLocal" type="button" class="primary-btn" @click="goEdit">정보변경</button>
|
||||
<p v-else class="hint">소셜 로그인 계정은 앱에서 가입정보를 변경할 수 없습니다.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.account-info {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.3rem 0.1rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
opacity: 0.75;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.back:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.back svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.card {
|
||||
background: var(--color-background-soft);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.95rem 1.1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.k {
|
||||
font-size: 0.88rem;
|
||||
opacity: 0.65;
|
||||
}
|
||||
.v {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
.primary-btn {
|
||||
width: 100%;
|
||||
margin-top: 1.25rem;
|
||||
padding: 0.8rem 1rem;
|
||||
border: 1px solid hsla(160, 100%, 37%, 1);
|
||||
border-radius: 8px;
|
||||
background: hsla(160, 100%, 37%, 1);
|
||||
color: #fff;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.primary-btn:hover {
|
||||
background: hsla(160, 100%, 32%, 1);
|
||||
}
|
||||
.hint {
|
||||
margin-top: 1.25rem;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.6;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,238 @@
|
||||
<script setup>
|
||||
import { reactive, ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { authApi } from '@/api/authApi'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const step = ref('verify') // 'verify' → 'edit'
|
||||
const password = ref('')
|
||||
const form = reactive({ name: '', email: '' })
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
// 소셜 계정은 비밀번호 인증 불가 → 계정정보로 돌려보냄
|
||||
if ((auth.user?.provider || 'LOCAL') !== 'LOCAL') {
|
||||
router.replace('/settings/account')
|
||||
}
|
||||
})
|
||||
|
||||
async function verify() {
|
||||
error.value = ''
|
||||
if (!password.value) {
|
||||
error.value = '비밀번호를 입력하세요.'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await authApi.verifyPassword(password.value)
|
||||
// 인증 통과 → 현재 값으로 폼 채우고 변경 화면 진입
|
||||
form.name = auth.user?.name || ''
|
||||
form.email = auth.user?.email || ''
|
||||
step.value = 'edit'
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '비밀번호 인증에 실패했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
const name = form.name.trim()
|
||||
if (!name) {
|
||||
error.value = '이름을 입력하세요.'
|
||||
return
|
||||
}
|
||||
const email = form.email.trim()
|
||||
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
error.value = '이메일 형식이 올바르지 않습니다.'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await authApi.updateProfile({ name, email: email || null })
|
||||
await auth.applyUser({ name: res.name, email: res.email })
|
||||
window.alert('가입정보가 변경되었습니다.')
|
||||
router.push('/settings/account')
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '가입정보 변경에 실패했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
router.push('/settings/account')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="profile-edit">
|
||||
<button type="button" class="back" @click="cancel">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 12H5" /><path d="M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>계정정보</span>
|
||||
</button>
|
||||
|
||||
<h1 class="page-title">가입정보 변경</h1>
|
||||
|
||||
<!-- 1단계: 비밀번호 재인증 -->
|
||||
<form v-if="step === 'verify'" class="card form" @submit.prevent="verify">
|
||||
<p class="guide">본인 확인을 위해 비밀번호를 입력하세요.</p>
|
||||
<label class="field">
|
||||
<span class="flabel">비밀번호</span>
|
||||
<input
|
||||
v-model="password" type="password" autocomplete="current-password"
|
||||
placeholder="현재 비밀번호" :disabled="loading"
|
||||
/>
|
||||
</label>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<button class="primary-btn" type="submit" :disabled="loading">
|
||||
{{ loading ? '확인 중…' : '확인' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- 2단계: 가입정보 변경 -->
|
||||
<form v-else class="card form" @submit.prevent="save">
|
||||
<label class="field">
|
||||
<span class="flabel">아이디</span>
|
||||
<input :value="auth.user?.loginId" type="text" disabled />
|
||||
<span class="fnote">아이디는 변경할 수 없습니다.</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="flabel">이름</span>
|
||||
<input v-model="form.name" type="text" maxlength="100" placeholder="이름" :disabled="loading" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="flabel">이메일</span>
|
||||
<input v-model="form.email" type="email" maxlength="255" placeholder="이메일 (선택)" :disabled="loading" />
|
||||
</label>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<div class="actions">
|
||||
<button class="ghost-btn" type="button" :disabled="loading" @click="cancel">취소</button>
|
||||
<button class="primary-btn" type="submit" :disabled="loading">
|
||||
{{ loading ? '저장 중…' : '저장' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-edit {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.3rem 0.1rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
opacity: 0.75;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.back:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.back svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.card {
|
||||
background: var(--color-background-soft);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem 1.1rem;
|
||||
}
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.guide {
|
||||
font-size: 0.88rem;
|
||||
opacity: 0.7;
|
||||
margin: 0;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.flabel {
|
||||
font-size: 0.82rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.field input {
|
||||
padding: 0.65rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.field input:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.fnote {
|
||||
font-size: 0.74rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.error {
|
||||
margin: 0;
|
||||
color: #c0392b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.actions .primary-btn {
|
||||
flex: 1;
|
||||
}
|
||||
.primary-btn {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid hsla(160, 100%, 37%, 1);
|
||||
border-radius: 8px;
|
||||
background: hsla(160, 100%, 37%, 1);
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.primary-btn:hover:not(:disabled) {
|
||||
background: hsla(160, 100%, 32%, 1);
|
||||
}
|
||||
.primary-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ghost-btn {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ghost-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,145 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
|
||||
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '-'
|
||||
const clearing = ref(false)
|
||||
|
||||
async function clearAppData() {
|
||||
if (clearing.value) return
|
||||
const ok = window.confirm(
|
||||
'앱에 저장된 모든 데이터(로그인 정보·캐시)를 삭제합니다.\n' +
|
||||
'삭제 후 로그아웃되며 첫 화면으로 이동합니다.\n계속하시겠습니까?',
|
||||
)
|
||||
if (!ok) return
|
||||
clearing.value = true
|
||||
try {
|
||||
try { await Preferences.clear() } catch { /* 네이티브 저장소 없음 무시 */ }
|
||||
try { localStorage.clear() } catch { /* 무시 */ }
|
||||
try { sessionStorage.clear() } catch { /* 무시 */ }
|
||||
try {
|
||||
if (window.caches?.keys) {
|
||||
const keys = await window.caches.keys()
|
||||
await Promise.all(keys.map((k) => window.caches.delete(k)))
|
||||
}
|
||||
} catch { /* 무시 */ }
|
||||
} finally {
|
||||
// 초기화 상태로 첫 화면 재시작
|
||||
window.location.replace('/')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings">
|
||||
<h1 class="page-title">설정</h1>
|
||||
|
||||
<section class="card">
|
||||
<RouterLink to="/settings/account" class="row row-link">
|
||||
<div class="row-main">
|
||||
<span class="row-label">계정정보</span>
|
||||
<span class="row-sub">아이디·이름·이메일 확인 및 변경</span>
|
||||
</div>
|
||||
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
|
||||
<div class="row">
|
||||
<div class="row-main">
|
||||
<span class="row-label">앱 버전 정보</span>
|
||||
</div>
|
||||
<span class="row-value">v{{ appVersion }}</span>
|
||||
</div>
|
||||
|
||||
<button type="button" class="row row-btn" :disabled="clearing" @click="clearAppData">
|
||||
<div class="row-main">
|
||||
<span class="row-label danger">App Data 삭제</span>
|
||||
<span class="row-sub">저장된 로그인 정보·캐시를 모두 비웁니다</span>
|
||||
</div>
|
||||
<span class="row-value danger">{{ clearing ? '삭제 중…' : '삭제' }}</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<p class="copyright">© 2026 SlimBudget. All rights reserved.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.card {
|
||||
background: var(--color-background-soft);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.1rem;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.row-link,
|
||||
.row-btn {
|
||||
cursor: pointer;
|
||||
}
|
||||
.row-link:hover,
|
||||
.row-btn:hover:not(:disabled) {
|
||||
background: var(--color-background-mute);
|
||||
}
|
||||
.row-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.row-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.row-label {
|
||||
font-size: 0.98rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.row-sub {
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.row-value {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.danger {
|
||||
color: #c0392b;
|
||||
}
|
||||
.chevron {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.copyright {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,18 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
// 앱 버전 정보(설정 > 앱 버전)에 노출 — package.json 을 단일 출처로 사용
|
||||
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8'))
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// 단위/컴포넌트 테스트 전용 설정. (앱 빌드 설정은 vite.config.js)
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
define: {
|
||||
// 일부 컴포넌트가 참조하는 빌드타임 상수
|
||||
__APP_VERSION__: JSON.stringify('test'),
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
include: ['src/**/*.{spec,test}.{js,mjs}'],
|
||||
clearMocks: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user