- backup.js importBackup: .xlsx 파싱 → 이름 기준 payload → POST /account/restore - SettingsView: '가져오기' 파일 선택 + 파괴적 작업 확인(이중) + 완료 후 새로고침 - accountApi.restore (데모에선 차단) - 창 크기: 가로/세로 각각 선택(세로 700~1200), 한 축 변경 시 나머지 유지 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -189,6 +189,11 @@ const realApi = {
|
|||||||
return http.post(`/account/entries/${id}/confirm`, payload || {})
|
return http.post(`/account/entries/${id}/confirm`, payload || {})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 데이터 복구(가져오기) — 전체 데이터 덮어쓰기
|
||||||
|
restore(payload) {
|
||||||
|
return http.post('/account/restore', payload)
|
||||||
|
},
|
||||||
|
|
||||||
// 영수증 OCR (백엔드가 Google Vision 호출 → 전체 텍스트 반환)
|
// 영수증 OCR (백엔드가 Google Vision 호출 → 전체 텍스트 반환)
|
||||||
ocrReceipt(blob) {
|
ocrReceipt(blob) {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
@@ -219,7 +224,7 @@ const DEMO_WRITES = new Set([
|
|||||||
'createRecurring', 'updateRecurring', 'removeRecurring', 'runRecurrings',
|
'createRecurring', 'updateRecurring', 'removeRecurring', 'runRecurrings',
|
||||||
'createWallet', 'updateWallet', 'removeWallet', 'reorderWallets',
|
'createWallet', 'updateWallet', 'removeWallet', 'reorderWallets',
|
||||||
'createCategory', 'updateCategory', 'removeCategory', 'reorderCategories', 'importCategories',
|
'createCategory', 'updateCategory', 'removeCategory', 'reorderCategories', 'importCategories',
|
||||||
'createTag', 'updateTag', 'removeTag', 'reorderTags', 'confirmEntry', 'ocrReceipt',
|
'createTag', 'updateTag', 'removeTag', 'reorderTags', 'confirmEntry', 'ocrReceipt', 'restore',
|
||||||
'createBudget', 'updateBudget', 'removeBudget', 'setExpectedIncome',
|
'createBudget', 'updateBudget', 'removeBudget', 'setExpectedIncome',
|
||||||
'createHolding', 'updateHolding', 'removeHolding', 'addTrade', 'updateTrade', 'removeTrade',
|
'createHolding', 'updateHolding', 'removeHolding', 'addTrade', 'updateTrade', 'removeTrade',
|
||||||
'refreshPrices', 'refreshAllPrices',
|
'refreshPrices', 'refreshAllPrices',
|
||||||
|
|||||||
@@ -82,3 +82,70 @@ export async function exportBackup() {
|
|||||||
|
|
||||||
await saveWorkbook(XLSX, wb, fileName())
|
await saveWorkbook(XLSX, wb, fileName())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 가져오기(복구) — 엑셀 파싱 → 백엔드 복구 =====
|
||||||
|
export async function importBackup(file) {
|
||||||
|
const XLSX = await import('xlsx')
|
||||||
|
const buf = await file.arrayBuffer()
|
||||||
|
const wb = XLSX.read(buf, { type: 'array' })
|
||||||
|
|
||||||
|
const rows = (name) => {
|
||||||
|
const ws = wb.Sheets[name]
|
||||||
|
return ws ? XLSX.utils.sheet_to_json(ws, { defval: null }) : []
|
||||||
|
}
|
||||||
|
const str = (v) => (v === null || v === undefined || v === '' ? null : String(v).trim())
|
||||||
|
const num = (v) => (v === null || v === undefined || v === '' ? null : Number(v))
|
||||||
|
const bool = (v) => v === true || v === 1 || ['true', 'TRUE', '1', 'Y', '예'].includes(String(v))
|
||||||
|
const date = (v) => {
|
||||||
|
if (v === null || v === undefined || v === '') return null
|
||||||
|
if (typeof v === 'number' && XLSX.SSF) {
|
||||||
|
const d = XLSX.SSF.parse_date_code(v)
|
||||||
|
if (d) return `${d.y}-${String(d.m).padStart(2, '0')}-${String(d.d).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
return String(v).trim().slice(0, 10) // 'YYYY-MM-DD'
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
wallets: rows('계좌')
|
||||||
|
.map((r) => ({
|
||||||
|
type: str(r['종류']), name: str(r['이름']), issuer: str(r['은행/카드사']),
|
||||||
|
accountNumber: str(r['계좌번호']), cardType: str(r['카드유형']),
|
||||||
|
openingBalance: num(r['개시잔액']), openingDate: date(r['개시일']), currentValue: num(r['평가액(수동)']),
|
||||||
|
}))
|
||||||
|
.filter((w) => w.name && w.type),
|
||||||
|
categories: rows('분류')
|
||||||
|
.map((r) => ({ type: str(r['구분']), name: str(r['이름']), parent: str(r['대분류']) }))
|
||||||
|
.filter((c) => c.name && c.type),
|
||||||
|
tags: rows('태그').map((r) => str(r['이름'])).filter(Boolean),
|
||||||
|
entries: rows('내역')
|
||||||
|
.map((r) => ({
|
||||||
|
entryDate: date(r['날짜']), type: str(r['구분']), category: str(r['분류']),
|
||||||
|
amount: num(r['금액']), memo: str(r['메모']), wallet: str(r['계좌']), toWallet: str(r['입금계좌']),
|
||||||
|
installmentMonths: num(r['할부개월']),
|
||||||
|
tags: str(r['태그']) ? String(r['태그']).split(',').map((s) => s.trim()).filter(Boolean) : [],
|
||||||
|
}))
|
||||||
|
.filter((e) => e.entryDate && e.type),
|
||||||
|
recurrings: rows('고정지출')
|
||||||
|
.map((r) => ({
|
||||||
|
title: str(r['제목']), type: str(r['구분']), amount: num(r['금액']), category: str(r['분류']), memo: str(r['메모']),
|
||||||
|
wallet: str(r['계좌']), toWallet: str(r['입금계좌']), frequency: str(r['주기']),
|
||||||
|
dayOfMonth: num(r['일']), dayOfWeek: num(r['요일']), monthOfYear: num(r['월']),
|
||||||
|
startDate: date(r['시작일']), endDate: date(r['종료일']), active: bool(r['활성']),
|
||||||
|
}))
|
||||||
|
.filter((r) => r.title && r.type),
|
||||||
|
budgets: rows('예산')
|
||||||
|
.map((r) => ({
|
||||||
|
category: str(r['분류']), fixed: bool(r['고정']), baseUnit: str(r['기준단위']), baseAmount: num(r['기준금액']),
|
||||||
|
daily: num(r['일']), weekly: num(r['주']), monthly: num(r['월']), yearly: num(r['년']),
|
||||||
|
}))
|
||||||
|
.filter((b) => b.category),
|
||||||
|
quickEntries: rows('자주쓰는내역')
|
||||||
|
.map((r) => ({
|
||||||
|
label: str(r['라벨']), type: str(r['구분']), category: str(r['분류']),
|
||||||
|
amount: num(r['금액']), memo: str(r['메모']), wallet: str(r['계좌']),
|
||||||
|
}))
|
||||||
|
.filter((q) => q.type),
|
||||||
|
}
|
||||||
|
|
||||||
|
await accountApi.restore(payload)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { RouterLink } from 'vue-router'
|
|||||||
import { Preferences } from '@capacitor/preferences'
|
import { Preferences } from '@capacitor/preferences'
|
||||||
import { useDialog } from '@/composables/dialog'
|
import { useDialog } from '@/composables/dialog'
|
||||||
import { useUiStore } from '@/stores/ui'
|
import { useUiStore } from '@/stores/ui'
|
||||||
import { exportBackup } from '@/utils/backup'
|
import { exportBackup, importBackup } from '@/utils/backup'
|
||||||
|
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const ui = useUiStore()
|
const ui = useUiStore()
|
||||||
@@ -21,8 +21,32 @@ async function doExport() {
|
|||||||
exporting.value = false
|
exporting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function importBackup() {
|
|
||||||
dialog.alert('가져오기(복구)는 준비 중입니다. 곧 제공됩니다.')
|
const importing = ref(false)
|
||||||
|
const fileInput = ref(null)
|
||||||
|
function pickImportFile() {
|
||||||
|
if (importing.value) return
|
||||||
|
fileInput.value?.click()
|
||||||
|
}
|
||||||
|
async function onImportFile(e) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
e.target.value = '' // 같은 파일 다시 선택 가능하게 초기화
|
||||||
|
if (!file) return
|
||||||
|
const ok = await dialog.confirm(
|
||||||
|
'가져오기를 하면 현재 가계부의 모든 데이터가 백업 파일 내용으로 교체됩니다.\n(투자 종목·매매내역은 백업에 없어 사라집니다)\n계속하시겠습니까?',
|
||||||
|
{ title: '데이터 가져오기', okText: '가져오기', danger: true },
|
||||||
|
)
|
||||||
|
if (!ok) return
|
||||||
|
importing.value = true
|
||||||
|
try {
|
||||||
|
await importBackup(file)
|
||||||
|
await dialog.alert('가져오기가 완료되었습니다. 화면을 새로고침합니다.')
|
||||||
|
window.location.reload()
|
||||||
|
} catch (err) {
|
||||||
|
window.alert(err?.response?.data?.message || err?.message || '가져오기에 실패했습니다.')
|
||||||
|
} finally {
|
||||||
|
importing.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const THEMES = [
|
const THEMES = [
|
||||||
{ value: 'system', label: '시스템' },
|
{ value: 'system', label: '시스템' },
|
||||||
@@ -32,19 +56,22 @@ const THEMES = [
|
|||||||
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '-'
|
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '-'
|
||||||
const clearing = ref(false)
|
const clearing = ref(false)
|
||||||
|
|
||||||
// PC(데스크톱) 전용: 창 해상도 변경 (Electron preload 의 window.desktop 존재 시에만)
|
// PC(데스크톱) 전용: 창 크기(가로·세로 각각) 변경 (Electron preload 의 window.desktop 존재 시에만)
|
||||||
const isDesktop = typeof window !== 'undefined' && !!window.desktop
|
const isDesktop = typeof window !== 'undefined' && !!window.desktop
|
||||||
const RESOLUTIONS = [
|
const WIDTHS = [900, 1024, 1280, 1366, 1600, 1920]
|
||||||
{ label: '1024 × 720', w: 1024, h: 720 },
|
const HEIGHTS = [700, 800, 900, 1000, 1080, 1200]
|
||||||
{ label: '1280 × 800', w: 1280, h: 800 },
|
// 한 축만 바꾸고 나머지 축은 현재 창 크기를 유지
|
||||||
{ label: '1366 × 768', w: 1366, h: 768 },
|
async function setDim(dim, value) {
|
||||||
{ label: '1600 × 900', w: 1600, h: 900 },
|
const v = Number(value)
|
||||||
{ label: '1920 × 1080', w: 1920, h: 1080 },
|
|
||||||
]
|
|
||||||
function onResolution(e) {
|
|
||||||
const v = e.target.value
|
|
||||||
if (!v) return
|
if (!v) return
|
||||||
const [w, h] = v.split('x').map(Number)
|
let cur = [1024, 768]
|
||||||
|
try {
|
||||||
|
cur = (await window.desktop?.getWindowSize?.()) || cur
|
||||||
|
} catch {
|
||||||
|
/* 무시 */
|
||||||
|
}
|
||||||
|
const w = dim === 'w' ? v : cur[0]
|
||||||
|
const h = dim === 'h' ? v : cur[1]
|
||||||
window.desktop?.setWindowSize(w, h)
|
window.desktop?.setWindowSize(w, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,17 +122,24 @@ async function clearAppData() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- PC(데스크톱) 전용: 창 해상도 -->
|
<!-- PC(데스크톱) 전용: 창 크기 (가로·세로 각각) -->
|
||||||
<section v-if="isDesktop" class="card">
|
<section v-if="isDesktop" class="card">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="row-main">
|
<div class="row-main">
|
||||||
<span class="row-label">화면 해상도</span>
|
<span class="row-label">창 크기</span>
|
||||||
<span class="row-sub">데스크톱 창 크기를 선택한 크기로 변경</span>
|
<span class="row-sub">가로·세로를 각각 선택</span>
|
||||||
</div>
|
</div>
|
||||||
<select class="res-select" @change="onResolution">
|
<div class="size-selects">
|
||||||
<option value="">선택</option>
|
<select class="res-select" title="가로(폭)" @change="setDim('w', $event.target.value)">
|
||||||
<option v-for="r in RESOLUTIONS" :key="r.label" :value="`${r.w}x${r.h}`">{{ r.label }}</option>
|
<option value="">가로</option>
|
||||||
|
<option v-for="w in WIDTHS" :key="w" :value="w">{{ w }}</option>
|
||||||
</select>
|
</select>
|
||||||
|
<span class="x">×</span>
|
||||||
|
<select class="res-select" title="세로(높이)" @change="setDim('h', $event.target.value)">
|
||||||
|
<option value="">세로</option>
|
||||||
|
<option v-for="h in HEIGHTS" :key="h" :value="h">{{ h }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -118,13 +152,14 @@ async function clearAppData() {
|
|||||||
</div>
|
</div>
|
||||||
<span class="row-value">{{ exporting ? '내보내는 중…' : '내보내기' }}</span>
|
<span class="row-value">{{ exporting ? '내보내는 중…' : '내보내기' }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="row row-btn" @click="importBackup">
|
<button type="button" class="row row-btn" :disabled="importing" @click="pickImportFile">
|
||||||
<div class="row-main">
|
<div class="row-main">
|
||||||
<span class="row-label">엑셀에서 가져오기</span>
|
<span class="row-label">엑셀에서 가져오기</span>
|
||||||
<span class="row-sub">백업 파일로 데이터 복구</span>
|
<span class="row-sub">백업 파일로 데이터 복구 (기존 데이터 덮어씀)</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="row-value">가져오기</span>
|
<span class="row-value">{{ importing ? '복구 중…' : '가져오기' }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<input ref="fileInput" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" class="hidden-file" @change="onImportFile" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@@ -229,9 +264,21 @@ async function clearAppData() {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.size-selects {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.size-selects .x {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
.res-select {
|
.res-select {
|
||||||
flex: none;
|
flex: none;
|
||||||
max-width: 150px;
|
max-width: 90px;
|
||||||
|
}
|
||||||
|
.hidden-file {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
.seg-btn {
|
.seg-btn {
|
||||||
padding: 0.4rem 0.7rem;
|
padding: 0.4rem 0.7rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user