Merge branch 'dev'
Deploy / deploy (push) Successful in 35s
CI / build (push) Failing after 12m53s

This commit is contained in:
ByungCheol
2026-06-28 00:02:12 +09:00
10 changed files with 145 additions and 101 deletions
+16 -1
View File
@@ -1,7 +1,7 @@
// Slim Budget 데스크톱(Electron) 메인 프로세스.
// 라이브 사이트(https://app.sblog.kr)를 직접 로드한다 → 프론트 변경이 자동 반영(재설치 불필요),
// 같은 출처라 CORS 우회도 불필요. 배포된 웹 게이트가 Electron 을 감지해 안내페이지 대신 전체 앱을 띄운다.
const { app, BrowserWindow, shell, session } = require('electron')
const { app, BrowserWindow, shell, session, ipcMain } = require('electron')
const fs = require('fs')
const path = require('path')
@@ -58,6 +58,7 @@ function createWindow() {
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
preload: path.join(__dirname, 'preload.cjs'),
// 한글 IME 첫 글자 중복 입력 방지 — Chromium 맞춤법 검사기가 조합을 방해하는 Electron 알려진 버그 회피
spellcheck: false,
},
@@ -91,6 +92,20 @@ function createWindow() {
})
}
// 데스크톱 창 크기(해상도) 변경 — 렌더러(설정 화면)에서 호출
ipcMain.handle('window:setSize', (e, w, h) => {
const win = BrowserWindow.fromWebContents(e.sender)
if (!win) return false
if (win.isMaximized()) win.unmaximize()
win.setSize(Math.max(360, Math.round(w)), Math.max(560, Math.round(h)))
win.center()
return true
})
ipcMain.handle('window:getSize', (e) => {
const win = BrowserWindow.fromWebContents(e.sender)
return win ? win.getSize() : [0, 0]
})
app.whenReady().then(async () => {
// 시작 시 HTTP 캐시 비움 → 라이브 사이트의 최신 프론트를 항상 로드(옛 HTML 캐시로 인한 미반영 방지).
// localStorage(로그인 세션)는 캐시가 아니라 영향 없음.
+8
View File
@@ -0,0 +1,8 @@
// 렌더러(웹)에서 안전하게 호출할 데스크톱 전용 API 노출 (contextIsolation 유지).
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('desktop', {
// 데스크톱 창 크기(해상도) 변경
setWindowSize: (w, h) => ipcRenderer.invoke('window:setSize', w, h),
getWindowSize: () => ipcRenderer.invoke('window:getSize'),
})
+1 -1
View File
@@ -68,7 +68,7 @@ watch(() => route.fullPath, () => ui.closeSidebar())
.layout {
display: grid;
grid-template-areas:
'top top'
'left top'
'left body'
'bottom bottom';
grid-template-columns: 220px 1fr;
+3 -2
View File
@@ -100,13 +100,14 @@ const icons = {
.app-sidebar {
background: var(--color-background-soft);
border-right: 1px solid var(--color-border);
padding: 1rem 0;
padding: 0 0 1rem;
}
.drawer-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1.1rem 0.75rem;
height: 56px; /* 헤더 높이와 맞춰 상단 정렬 (하단 보더 일치) */
padding: 0 1.1rem;
margin-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border);
}
+30 -51
View File
@@ -654,7 +654,6 @@ async function doRegisterRecurring() {
// ===== 자주 쓰는 내역(빠른 등록 템플릿) =====
const quickEntries = ref([])
const quickEditMode = ref(false)
const quickPickerOpen = ref(false) // 불러오기 모달 피커
async function loadQuick() {
try {
quickEntries.value = await accountApi.quickEntries()
@@ -668,6 +667,7 @@ function quickLabel(q) {
// 칩 탭 → 오늘 날짜로 즉시 등록
async function useQuick(q) {
if (quickEditMode.value) return
// 1) 등록 — 이 단계 실패만 '등록 실패'로 처리
try {
await accountApi.create({
entryDate: todayStr(),
@@ -680,11 +680,17 @@ async function useQuick(q) {
installmentMonths: null,
tagIds: [],
})
await load()
await loadPendingCount()
flash(`'${quickLabel(q)}' ${won(q.amount)} 등록했습니다.`)
} catch (e) {
flash(e.response?.data?.message || '빠른 등록에 실패했습니다.')
return
}
// 2) 등록 성공 안내 + 목록 갱신(갱신 실패는 등록 성공을 가리지 않게 분리)
flash(`'${quickLabel(q)}' ${won(q.amount)} 등록했습니다.`)
try {
await load()
await loadPendingCount()
} catch {
/* 목록 갱신 실패는 무시 — 다음 진입/새로고침 시 반영 */
}
}
async function removeQuick(q) {
@@ -695,23 +701,6 @@ async function removeQuick(q) {
flash(e.response?.data?.message || '삭제에 실패했습니다.')
}
}
// 신규 추가 시: 자주 쓰는 내역을 폼에 불러오기(채움, 저장은 사용자가)
function applyQuick(q) {
if (!q) return
form.type = q.type
form.category = q.category || ''
form.amount = q.amount
form.memo = q.memo || ''
if (q.walletId) {
form.walletId = q.walletId
form.walletKind = walletKindOf(q.walletId) || form.walletKind
}
syncCategoryMajor()
}
function pickQuick(q) {
applyQuick(q)
quickPickerOpen.value = false
}
// 현재 폼 값을 자주 쓰는 내역으로 저장
async function saveAsQuick() {
if (form.type !== 'INCOME' && form.type !== 'EXPENSE') return
@@ -830,6 +819,7 @@ onMounted(async () => {
<!-- 자주 쓰는 내역(빠른 등록) 오늘 날짜로 즉시 등록 -->
<div v-if="quickEntries.length" class="quick-bar">
<span class="quick-bar-label"> 자주 쓰는 내역</span>
<button
v-for="q in quickEntries" :key="q.id" type="button" class="quick-chip"
:title="quickEditMode ? '' : '오늘 날짜로 바로 등록'" @click="useQuick(q)"
@@ -848,15 +838,18 @@ onMounted(async () => {
</div>
<div class="month-nav">
<span class="mn-spacer"></span>
<IconBtn icon="chevronLeft" title="이전 달" size="sm" @click="prevMonth" />
<span class="period">{{ periodLabel }}</span>
<IconBtn icon="chevronRight" title="다음 달" size="sm" @click="nextMonth" />
<div class="mn-actions">
<IconBtn
icon="filter" title="검색·필터" class="filter-toggle"
:variant="hasFilter ? 'primary' : 'default'" @click="filterOpen = !filterOpen"
/>
<IconBtn icon="plus" title="내역 추가" variant="primary" @click="openCreate" />
</div>
</div>
<div v-if="filterOpen" class="filter-bar">
<input
@@ -986,13 +979,6 @@ onMounted(async () => {
<div v-if="ocrRunning" class="receipt-progress"><div class="bar"></div></div>
</div>
<!-- 신규 추가 : 자주 쓰는 내역 불러오기(모달 피커) -->
<button
v-if="!editId && quickEntries.length"
type="button" class="to-recurring to-quick"
:disabled="submitting" @click="quickPickerOpen = true"
> 자주 쓰는 내역 불러오기</button>
<label>거래일<input v-model="form.entryDate" type="date" :disabled="submitting" /></label>
<label>구분
<select v-model="form.type" :disabled="submitting" @change="onTypeChange">
@@ -1177,29 +1163,6 @@ onMounted(async () => {
</Teleport>
<!-- 자주 쓰는 내역 불러오기 (모달 피커) -->
<Teleport to="body">
<Transition name="fade">
<div v-if="quickPickerOpen" class="modal-backdrop quick-picker-backdrop" @click.self="quickPickerOpen = false">
<div class="modal quick-picker" role="dialog" aria-modal="true">
<button class="close" type="button" @click="quickPickerOpen = false">×</button>
<h2>자주 쓰는 내역 불러오기</h2>
<ul v-if="quickEntries.length" class="qp-list">
<li v-for="q in quickEntries" :key="q.id">
<button type="button" class="qp-item" @click="pickQuick(q)">
<span class="qp-label">{{ quickLabel(q) }}</span>
<span class="qp-meta">
<span v-if="q.category" class="qp-cat">{{ q.category }}</span>
<span class="qp-amt" :class="q.type === 'INCOME' ? 'income' : 'expense'">{{ q.type === 'INCOME' ? '+' : '-' }}{{ won(q.amount) }}</span>
</span>
</button>
</li>
</ul>
<p v-else class="msg">저장된 자주 쓰는 내역이 없습니다.</p>
</div>
</div>
</Transition>
</Teleport>
<!-- 문자/푸시 붙여넣기 (모달) -->
<Teleport to="body">
<Transition name="fade">
@@ -1265,6 +1228,16 @@ button.primary {
gap: 1rem;
margin-bottom: 1rem;
}
/* 좌측 spacer + 우측 액션을 같은 flex:1 로 둬서 가운데 네비를 정확히 중앙 유지 */
.mn-spacer {
flex: 1;
}
.mn-actions {
flex: 1;
display: flex;
justify-content: flex-end;
gap: 0.4rem;
}
.period {
font-size: 1.1rem;
font-weight: 600;
@@ -1839,6 +1812,12 @@ button.primary {
margin: 0.4rem 0 0.2rem;
align-items: center;
}
.quick-bar-label {
font-size: 0.82rem;
font-weight: 600;
opacity: 0.65;
margin-right: 0.2rem;
}
.quick-chip {
display: inline-flex;
align-items: center;
+12
View File
@@ -243,11 +243,14 @@ onMounted(() => {
<template>
<section class="budget">
<div class="month-nav">
<span class="mn-spacer"></span>
<IconBtn icon="chevronLeft" title="이전 달" size="sm" @click="prevMonth" />
<span class="period">{{ periodLabel }}</span>
<IconBtn icon="chevronRight" title="다음 달" size="sm" @click="nextMonth" />
<div class="mn-actions">
<IconBtn icon="plus" title="예산 추가" variant="primary" @click="openCreate" />
</div>
</div>
<!-- 예상 수입 vs 예산 -->
<div class="income-panel">
@@ -412,6 +415,15 @@ button.primary {
gap: 1rem;
margin-bottom: 1.25rem;
}
.mn-spacer {
flex: 1;
}
.mn-actions {
flex: 1;
display: flex;
justify-content: flex-end;
gap: 0.4rem;
}
.period {
font-size: 1.1rem;
font-weight: 600;
+5
View File
@@ -412,14 +412,19 @@ button:disabled {
gap: 0.5rem;
}
.search-type {
flex: none;
padding: 0.5rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
}
.search-form > .icon-btn {
flex: none;
}
.search-form input {
flex: 1;
min-width: 0; /* flex 자식이 내용 최소폭 아래로 줄어들 수 있게 — 검색 버튼이 모달 밖으로 밀리는 문제 방지 */
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 4px;
+1 -19
View File
@@ -7,15 +7,6 @@ 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() {
@@ -27,10 +18,6 @@ function goEdit() {
<div class="account-info">
<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>
@@ -39,18 +26,13 @@ function goEdit() {
<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>
<button type="button" class="primary-btn" @click="goEdit">이름 변경</button>
</div>
</template>
+19 -11
View File
@@ -1,5 +1,5 @@
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { computed, reactive, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { authApi } from '@/api/authApi'
@@ -7,17 +7,22 @@ import { authApi } from '@/api/authApi'
const auth = useAuthStore()
const router = useRouter()
const step = ref('verify') // 'verify' → 'edit'
// 소셜(구글 등) 계정은 비밀번호가 없어 재인증 없이 이름만 변경
const isSocial = computed(() => (auth.user?.provider || 'LOCAL') !== 'LOCAL')
const step = ref('verify') // 'verify' → 'edit' (소셜은 곧바로 edit)
const password = ref('') // verify 단계에서 입력한 현재 비밀번호 (비밀번호 변경 시 재사용)
const form = reactive({ name: '', email: '' })
const pw = reactive({ next: '', confirm: '' }) // 비밀번호 변경(선택)
const pw = reactive({ next: '', confirm: '' }) // 비밀번호 변경(선택, LOCAL 전용)
const loading = ref(false)
const error = ref('')
onMounted(() => {
// 소셜 계정 비밀번호 인증 불가 → 계정정보로 돌려보냄
if ((auth.user?.provider || 'LOCAL') !== 'LOCAL') {
router.replace('/settings/account')
// 소셜 계정: 비밀번호 인증 단계 건너뛰고 바로 이름 변경 화면으로
if (isSocial.value) {
form.name = auth.user?.name || ''
form.email = auth.user?.email || ''
step.value = 'edit'
}
})
@@ -48,13 +53,14 @@ async function save() {
error.value = '이름을 입력하세요.'
return
}
const email = form.email.trim()
// 소셜 계정은 이메일(구글 제공) 유지, LOCAL 만 폼에서 편집
const email = isSocial.value ? (auth.user?.email || null) : (form.email.trim() || null)
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
error.value = '이메일 형식이 올바르지 않습니다.'
return
}
// 비밀번호 변경(선택) — 새 비밀번호를 입력한 경우에만 검증/적용
const changePw = !!pw.next
// 비밀번호 변경(선택, LOCAL 전용) — 새 비밀번호를 입력한 경우에만 검증/적용
const changePw = !isSocial.value && !!pw.next
if (changePw) {
if (pw.next.length < 8 || pw.next.length > 64) {
error.value = '새 비밀번호는 8~64자여야 합니다.'
@@ -112,7 +118,7 @@ function cancel() {
<!-- 2단계: 가입정보 변경 -->
<form v-else class="card form" @submit.prevent="save">
<label class="field">
<label v-if="!isSocial" class="field">
<span class="flabel">아이디</span>
<input :value="auth.user?.loginId" type="text" disabled />
<span class="fnote">아이디는 변경할 없습니다.</span>
@@ -121,11 +127,12 @@ function cancel() {
<span class="flabel">이름</span>
<input v-model="form.name" type="text" maxlength="100" placeholder="이름" :disabled="loading" />
</label>
<label class="field">
<label v-if="!isSocial" class="field">
<span class="flabel">이메일</span>
<input v-model="form.email" type="email" maxlength="255" placeholder="이메일 (선택)" :disabled="loading" />
</label>
<template v-if="!isSocial">
<hr class="divider" />
<p class="section-label">비밀번호 변경 <span class="opt">(변경 시에만 입력)</span></p>
<label class="field">
@@ -136,6 +143,7 @@ function cancel() {
<span class="flabel"> 비밀번호 확인</span>
<input v-model="pw.confirm" type="password" autocomplete="new-password" maxlength="64" placeholder="새 비밀번호 확인" :disabled="loading" />
</label>
</template>
<p v-if="error" class="error">{{ error }}</p>
<div class="actions">
+34
View File
@@ -15,6 +15,22 @@ const THEMES = [
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '-'
const clearing = ref(false)
// PC(데스크톱) 전용: 창 해상도 변경 (Electron preload 의 window.desktop 존재 시에만)
const isDesktop = typeof window !== 'undefined' && !!window.desktop
const RESOLUTIONS = [
{ label: '1024 × 720', w: 1024, h: 720 },
{ label: '1280 × 800', w: 1280, h: 800 },
{ label: '1366 × 768', w: 1366, h: 768 },
{ label: '1600 × 900', w: 1600, h: 900 },
{ label: '1920 × 1080', w: 1920, h: 1080 },
]
function onResolution(e) {
const v = e.target.value
if (!v) return
const [w, h] = v.split('x').map(Number)
window.desktop?.setWindowSize(w, h)
}
async function clearAppData() {
if (clearing.value) return
const ok = await dialog.confirm(
@@ -62,6 +78,20 @@ async function clearAppData() {
</div>
</section>
<!-- PC(데스크톱) 전용: 해상도 -->
<section v-if="isDesktop" class="card">
<div class="row">
<div class="row-main">
<span class="row-label">화면 해상도</span>
<span class="row-sub">데스크톱 크기를 선택한 크기로 변경</span>
</div>
<select class="res-select" @change="onResolution">
<option value="">선택</option>
<option v-for="r in RESOLUTIONS" :key="r.label" :value="`${r.w}x${r.h}`">{{ r.label }}</option>
</select>
</div>
</section>
<section class="card">
<RouterLink to="/settings/account" class="row row-link">
<div class="row-main">
@@ -164,6 +194,10 @@ async function clearAppData() {
overflow: hidden;
flex-shrink: 0;
}
.res-select {
flex: none;
max-width: 150px;
}
.seg-btn {
padding: 0.4rem 0.7rem;
border: 0;