Compare commits
136 Commits
3e1410a522
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b53af525e | |||
| f8e6c6b0ee | |||
| e1c077dccd | |||
| 1e9ff0408c | |||
| b2f3b156b2 | |||
| 28ebe06767 | |||
| afbec5e3c3 | |||
| ee7a1fb471 | |||
| 1a8aa8e13c | |||
| e87bc244fe | |||
| f8369f5108 | |||
| 80d7b8d552 | |||
| 764436ebc9 | |||
| 970e9e14ad | |||
| f996075da7 | |||
| 5795f328a1 | |||
| 4f08b47fc3 | |||
| dab6dc3411 | |||
| caa591a16d | |||
| 971e08282f | |||
| f42805f96e | |||
| a0aba1d578 | |||
| ce7488076a | |||
| 2eaf8695b0 | |||
| f23b4963a9 | |||
| b50a81028f | |||
| 6972cb5a63 | |||
| bd5f1afd47 | |||
| bac3ce1ded | |||
| 3f9b0daf53 | |||
| cbe469552d | |||
| 659ea3b2fa | |||
| 7633bdc166 | |||
| 9d60ea8f12 | |||
| ee5e9d4adb | |||
| 36178572e1 | |||
| a2cda9b4ee | |||
| e8d3549f02 | |||
| 4838eba9af | |||
| 29ff09a8d6 | |||
| 8be7072614 | |||
| 59f38b3348 | |||
| 0fa8c95139 | |||
| 127551da12 | |||
| fa3b12b369 | |||
| db68ea1680 | |||
| e8dc6b5568 | |||
| d243600f41 | |||
| d7639225f7 | |||
| 1000bf06af | |||
| 821aec72b9 | |||
| 0dda0b6ccd | |||
| 09634757f7 | |||
| 823bdcafcf | |||
| ce5cefe64a | |||
| 56d2ad6a8a | |||
| 7e096d5e7b | |||
| c167875567 | |||
| 7e38eae2b7 | |||
| 7699cfc55f | |||
| 1636c35aef | |||
| 206364d2f1 | |||
| ca66c1d53e | |||
| 7855298eac | |||
| 24ab91b257 | |||
| 866abee860 | |||
| 9861ba90ae | |||
| 2755d118c2 | |||
| c9b58f4ad0 | |||
| 9aa21696d9 | |||
| 7d8f947156 | |||
| 457fd8127a | |||
| 4ffc01f484 | |||
| 80e1faec09 | |||
| cee5b661e7 | |||
| 54a285a3b6 | |||
| e4e1be49ca | |||
| f34980048b | |||
| 85670a1b9f | |||
| 5bfa45ed30 | |||
| c4c58d2e24 | |||
| 59b17051a4 | |||
| c4872c93a8 | |||
| b737720d29 | |||
| 75228cbb64 | |||
| 63b4792e58 | |||
| f07cb349e2 | |||
| ef1d718fb8 | |||
| a92063dd64 | |||
| 6aa3590c5a | |||
| 4916d28604 | |||
| dbe255cf1c | |||
| 579ee3e4e6 | |||
| 8254460751 | |||
| b8c1440e4d | |||
| a310c7a471 | |||
| 1ca09b7439 | |||
| 4ef5850613 | |||
| d706339649 | |||
| dc62d9883a | |||
| e4d7df2554 | |||
| 4e4c28bafe | |||
| 0eb3eaaa67 | |||
| 5a5959a26e | |||
| eeea19e20b | |||
| ca803272c4 | |||
| 8e651c786f | |||
| dd17fae5f4 | |||
| f533df0024 | |||
| 643bb8011b | |||
| 8add41d94f | |||
| cad1aba0ca | |||
| 27beaf28d4 | |||
| f55443b823 | |||
| 6ab8d331e3 | |||
| b1f85cee52 | |||
| 8cc9cbb383 | |||
| 43b01da1ff | |||
| 8133963f89 | |||
| a4a4aec314 | |||
| 9aa5f5d690 | |||
| 9f079fcead | |||
| d88854d11b | |||
| dba0741cca | |||
| 9807472474 | |||
| 2cde893d55 | |||
| c67c2ba7ed | |||
| 7276d878dd | |||
| 2e2c784f4c | |||
| f7f75b7fb1 | |||
| f45014f827 | |||
| cbc6245cc7 | |||
| bfba296b91 | |||
| 0238e0b22f | |||
| b4aa5c2ad0 | |||
| 9c61f888a5 |
@@ -1,8 +1,9 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
# main 은 Deploy 워크플로가 테스트+빌드를 수행하므로 CI 중복 실행 제외(배포 시간 단축).
|
||||
push:
|
||||
branches: [main, dev]
|
||||
branches: [dev]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
|
||||
@@ -99,3 +99,8 @@ app/src/main/assets/public
|
||||
app/src/main/assets/capacitor.config.json
|
||||
app/src/main/assets/capacitor.plugins.json
|
||||
app/src/main/res/xml/config.xml
|
||||
|
||||
# 릴리스 서명 (절대 커밋 금지)
|
||||
*.jks
|
||||
*.keystore
|
||||
key.properties
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
apply plugin: 'com.android.application'
|
||||
|
||||
// 릴리스 서명: android/key.properties 가 있으면 사용, 없으면 디버그 서명으로 폴백.
|
||||
def keystorePropsFile = rootProject.file("key.properties")
|
||||
def keystoreProps = new Properties()
|
||||
if (keystorePropsFile.exists()) {
|
||||
keystoreProps.load(new FileInputStream(keystorePropsFile))
|
||||
}
|
||||
def hasReleaseKeystore = keystorePropsFile.exists()
|
||||
|
||||
android {
|
||||
namespace = "kr.sblog.slimbudget"
|
||||
compileSdk = rootProject.ext.compileSdkVersion
|
||||
@@ -7,8 +15,8 @@ android {
|
||||
applicationId "kr.sblog.slimbudget"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
versionCode 3
|
||||
versionName "1.0.2"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
@@ -16,8 +24,20 @@ android {
|
||||
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
signingConfigs {
|
||||
if (hasReleaseKeystore) {
|
||||
release {
|
||||
storeFile rootProject.file(keystoreProps['storeFile'])
|
||||
storePassword keystoreProps['storePassword']
|
||||
keyAlias keystoreProps['keyAlias']
|
||||
keyPassword keystoreProps['keyPassword']
|
||||
}
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
// 키스토어가 있으면 릴리스 서명, 없으면 디버그 서명(로컬 테스트용)
|
||||
signingConfig hasReleaseKeystore ? signingConfigs.release : signingConfigs.debug
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
|
||||
@@ -11,7 +11,11 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-camera')
|
||||
implementation project(':capacitor-filesystem')
|
||||
implementation project(':capacitor-local-notifications')
|
||||
implementation project(':capacitor-preferences')
|
||||
implementation project(':capacitor-share')
|
||||
implementation project(':capawesome-capacitor-google-sign-in')
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:launchMode="singleTask"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
@@ -28,7 +29,10 @@
|
||||
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- 카드 결제 알림 자동인식: 알림 접근 리스너 서비스 -->
|
||||
<!-- 카드 결제 알림 자동인식(알림 접근 리스너) — 스토어 첫 출시에서는 비활성화.
|
||||
Google Play NotificationListenerService(알림 접근) 정책 심사 회피용으로 선언 제거.
|
||||
추후 정당화/선언 후 재활성화하려면 아래 service 블록 주석을 해제할 것. -->
|
||||
<!--
|
||||
<service
|
||||
android:name=".CardNotifListenerService"
|
||||
android:label="@string/app_name"
|
||||
@@ -38,6 +42,7 @@
|
||||
<action android:name="android.service.notification.NotificationListenerService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
-->
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
|
||||
@@ -50,15 +50,17 @@ public class CardNotifListenerService extends NotificationListenerService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 결제 알림 후보 판별: 금액(####원) + 결제/승인/출금 키워드.
|
||||
* '카드'는 광고에도 흔해 제외. 강한 승인신호(승인/출금/취소/환불) 없이 광고성 표현이 있으면 제외.
|
||||
* (1차 필터 — 최종 판별은 백엔드 파서)
|
||||
* 결제/이체 알림 후보 판별: 금액(####원) + 승인/결제/출금/입금/이체/취소 키워드.
|
||||
* '카드'는 광고에도 흔해 제외. 강한 신호 없이 광고성 표현이 있으면 제외.
|
||||
* (1차 필터 — 최종 판별/분류는 백엔드 파서)
|
||||
*/
|
||||
private boolean looksLikePayment(String s) {
|
||||
boolean money = s.matches("(?s).*\\d[\\d,]{2,}\\s*원.*");
|
||||
boolean kw = s.contains("승인") || s.contains("결제") || s.contains("출금");
|
||||
boolean kw = s.contains("승인") || s.contains("결제") || s.contains("출금")
|
||||
|| s.contains("입금") || s.contains("이체") || s.contains("송금");
|
||||
if (!money || !kw) return false;
|
||||
boolean strong = s.contains("승인") || s.contains("출금") || s.contains("취소") || s.contains("환불");
|
||||
boolean strong = s.contains("승인") || s.contains("출금") || s.contains("입금")
|
||||
|| s.contains("이체") || s.contains("송금") || s.contains("취소") || s.contains("환불");
|
||||
boolean adLike = s.matches("(?s).*(이벤트|광고|혜택|쿠폰|할인|적립|포인트|캐시백|마일리지|리워드|당첨|응모|추첨|프로모션|배너|무료|증정|모집).*");
|
||||
return strong || !adLike;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,11 @@ public class MainActivity extends BridgeActivity {
|
||||
final View content = findViewById(android.R.id.content);
|
||||
ViewCompat.setOnApplyWindowInsetsListener(content, (v, windowInsets) -> {
|
||||
Insets bars = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars());
|
||||
v.setPadding(bars.left, bars.top, bars.right, bars.bottom);
|
||||
Insets ime = windowInsets.getInsets(WindowInsetsCompat.Type.ime());
|
||||
// 키보드(IME)가 올라오면 그 높이만큼 하단 패딩 → WebView가 키보드 위로 줄어든다.
|
||||
// (입력 모달이 키보드에 가려지지 않고, 포커스된 입력칸이 보이도록 재배치됨)
|
||||
int bottom = Math.max(bars.bottom, ime.bottom);
|
||||
v.setPadding(bars.left, bars.top, bars.right, bottom);
|
||||
return WindowInsetsCompat.CONSUMED;
|
||||
});
|
||||
ViewCompat.requestApplyInsets(content);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<background>
|
||||
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||
</background>
|
||||
<foreground>
|
||||
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
@@ -1,5 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<background>
|
||||
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||
</background>
|
||||
<foreground>
|
||||
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 339 B After Width: | Height: | Size: 299 B |
|
Before Width: | Height: | Size: 809 B After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 720 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 158 B After Width: | Height: | Size: 160 B |
|
Before Width: | Height: | Size: 474 B After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 208 B After Width: | Height: | Size: 211 B |
|
Before Width: | Height: | Size: 575 B After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 403 B After Width: | Height: | Size: 425 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 778 B After Width: | Height: | Size: 834 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 36 KiB |
@@ -1,7 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Slim Budget</string>
|
||||
<string name="title_activity_main">Slim Budget</string>
|
||||
<string name="app_name">돈돼지가계부</string>
|
||||
<string name="title_activity_main">돈돼지가계부</string>
|
||||
<string name="package_name">kr.sblog.slimbudget</string>
|
||||
<string name="custom_url_scheme">kr.sblog.slimbudget</string>
|
||||
</resources>
|
||||
|
||||
@@ -8,5 +8,17 @@ project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/
|
||||
include ':capacitor-camera'
|
||||
project(':capacitor-camera').projectDir = new File('../node_modules/@capacitor/camera/android')
|
||||
|
||||
include ':capacitor-filesystem'
|
||||
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
|
||||
|
||||
include ':capacitor-local-notifications'
|
||||
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
|
||||
|
||||
include ':capacitor-preferences'
|
||||
project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android')
|
||||
|
||||
include ':capacitor-share'
|
||||
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
|
||||
|
||||
include ':capawesome-capacitor-google-sign-in'
|
||||
project(':capawesome-capacitor-google-sign-in').projectDir = new File('../node_modules/@capawesome/capacitor-google-sign-in/android')
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import sharp from 'sharp'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const files = ['icon-only', 'icon-foreground', 'icon-background']
|
||||
for (const f of files) {
|
||||
const svg = readFileSync(`assets/${f}.svg`)
|
||||
await sharp(svg, { density: 384 }).resize(1024, 1024).png().toFile(`assets/${f}.png`)
|
||||
console.log('wrote assets/' + f + '.png')
|
||||
}
|
||||
// 앱 아이콘은 이제 '돈돼지' 래스터 이미지(assets/piggy-fg.png) 기반입니다.
|
||||
// 재생성: piggy-fg.png → sharp 합성으로 icon-foreground/background/only.png 만든 뒤
|
||||
// `npx @capacitor/assets generate --android` 로 안드로이드 아이콘 생성.
|
||||
// (구 SVG 래스터화는 더 이상 사용하지 않음 — 실행해도 PNG 를 덮어쓰지 않도록 비활성화)
|
||||
console.log('skip: icons are raster (piggy). use piggy-fg.png + @capacitor/assets generate')
|
||||
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 419 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 542 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 319 KiB |
@@ -0,0 +1,132 @@
|
||||
# 돈돼지 가계부 — Play 스토어 첫 출시 진행 상황
|
||||
|
||||
> 최종 업데이트: 2026-06-28. 첫 정식 출시(무료 앱) 준비 로그 + 남은 로드맵.
|
||||
> 관련 문서: [play-store-listing.md](play-store-listing.md) · [production-access-draft.md](production-access-draft.md)
|
||||
|
||||
---
|
||||
|
||||
## ✅ 완료된 작업
|
||||
|
||||
### 출시 전략 결정
|
||||
- **무료 앱**으로 첫 출시 (수익화 기능은 순차 도입)
|
||||
- 첫 출시 기능 플래그 (`src/config/features.js`, 전부 `false`):
|
||||
- `BILLING_ENABLED=false` — 인앱결제 스텁이라 결제 없이 프리미엄 부여되는 악용 차단
|
||||
- `ADS_ENABLED=false` — 애드핏 미승인 + 데이터 안전 양식 단순화(광고 식별자 미수집)
|
||||
- `CARD_NOTIF_ENABLED=false` — NotificationListenerService 정책 심사 회피
|
||||
- 패키지명 `kr.sblog.slimbudget` **유지**(출시 후 영구 고정. 사용자 비노출이라 무방)
|
||||
|
||||
### 인프라 / 정책 페이지
|
||||
- **www.sblog.kr** 도메인 + 인증서 확장 (app.sblog.kr와 동일 dist 서빙)
|
||||
- 공개 URL: `/privacy`, `/terms`, `/account-deletion` (모두 200 확인)
|
||||
- 개인정보처리방침 광고 문구 → "현재 광고 없음 + 향후 도입 가능" 미래형으로 정합화
|
||||
|
||||
### 빌드 / 서명
|
||||
- **AAB**: `android/app/build/outputs/bundle/release/app-release.aab`
|
||||
- targetSdk 36, versionCode 1, versionName 1.0
|
||||
- 업로드 키(CN=Don Dwaeji) 서명 확인 (디버그 아님)
|
||||
- 결제·광고·카드알림 전부 OFF 클린 (NotificationListener 권한 없음 검증)
|
||||
- 키스토어: `android/upload-keystore.jks` + `android/key.properties` (gitignore, **이 PC에만 존재 — 분실 시 업데이트 불가, 반드시 백업**)
|
||||
|
||||
### 스토어 그래픽 (`brand/store/`)
|
||||
- `icon-512.png` (512×512 불투명)
|
||||
- `feature-1024x500.png` / `feature-1024x500-text.png` (피처그래픽, 택1)
|
||||
- 생성기 `brand/gen-store-assets.mjs` + 마스터 `brand/logo-1024.png`
|
||||
|
||||
### 스크린샷 (`brand/screenshots/`, 7컷, 실기기 1080×2340)
|
||||
- shot0 홈 대시보드(리드) / shot1 가계부 내역 / shot2 통계 / shot3 고정지출 / shot4 계좌 / shot5 게시판 / shot6 게시글 상세
|
||||
|
||||
### 등록 문서
|
||||
- `brand/play-store-listing.md` — 등록정보(제목·설명)·데이터안전 양식·콘텐츠등급 답안
|
||||
- `brand/production-access-draft.md` — 프로덕션 액세스 신청서 초안(4문항)
|
||||
|
||||
### 코드 수정 (배포 완료)
|
||||
- 인앱결제 진입점 비활성화 (UpgradeView "곧 출시 예정", AccountInfoView 정기결제 카드 숨김)
|
||||
- 광고 비활성화 (AdBanner + App.vue 게이팅, 하단 광고 여백 제거)
|
||||
- 둘러보기(데모) 모드 메뉴 접근 버그 수정 (AppHeader 햄버거 `demo.on` 노출)
|
||||
- 계정삭제 공개 페이지 추가
|
||||
|
||||
### 테스트 데이터 (캡처용, `sb_bt/scripts/`, gitignore 로컬 전용)
|
||||
- 테스트 계정 `kenneth73510@gmail.com` (DB로 PREMIUM 부여)
|
||||
- `seed-test-kenneth.sql`(계정+가계부), `seed-test-board.sql`(게시판 더미)
|
||||
- `cleanup-test-board.sql`(게시판 더미 삭제 — **캡처 후 정리 완료**)
|
||||
|
||||
---
|
||||
|
||||
## 🚧 현재 상태 (2026-06-30)
|
||||
- ✅ 개발자 계정 신원확인 완료 → 앱 생성 완료(`kr.sblog.slimbudget`)
|
||||
- ✅ 필수 설정(데이터안전·콘텐츠등급(전체이용가)·앱액세스(둘러보기)·광고없음·비거래자·방침URL) 완료
|
||||
- ✅ 스토어 등록정보·이미지(아이콘512·피처·스크린샷7) 완료
|
||||
- ✅ **비공개 테스트 트랙에 v2(AAB) 게시 → 검토 통과**
|
||||
- ✅ 구글 로그인 SHA-1 이슈 해결(Play 앱서명 SHA-1 등록) → 로그인 정상
|
||||
- ⏳ **테스터 12명 opt-in 대기**(업체 의뢰) → 14일 → 프로덕션 신청
|
||||
- 📌 조직 계정 전환은 보류(사업자등록 → DUNS 필요. 기프트콘 기능 때 함께)
|
||||
|
||||
---
|
||||
|
||||
## 📋 남은 로드맵 (프로덕션까지)
|
||||
|
||||
1. ⏳ **테스터 12명 opt-in** (업체 의뢰) — 14일 카운트 시작 조건
|
||||
2. ⏳ **14일 연속** 12명 유지 → **프로덕션 액세스 신청** (자료: production-access-draft.md)
|
||||
3. **v3 AAB 업로드** — 이번 세션 변경(외화·월별예산·전체화면모달 등) 반영 (14일 카운트 리셋 안 됨)
|
||||
4. 승인 → **프로덕션 출시**
|
||||
|
||||
### ⚠️ 폐쇄 테스트 의무 (개인 계정 + 2023-11-13 이후 개설)
|
||||
- **테스터 12명 이상 · 14일 연속** 폐쇄 테스트 후 프로덕션 신청 가능
|
||||
- 실기기·실계정 필수 (에뮬·봇·중복 불인정)
|
||||
- 14일 카운트 = "릴리스 승인 + 12명 opt-in" 둘 다 충족 시점부터
|
||||
- 테스터 확보: 지인 비공개 방침 → 품앗이 커뮤니티(무료·안전) 또는 평판 좋은 유료 서비스(실사용 보장형만)
|
||||
|
||||
### 익명 셋업 체크리스트
|
||||
- [ ] 전용 Gmail 생성 (개발자 연락처 + 품앗이용)
|
||||
- [ ] 공개 개발자 이름 → 브랜드명 (법적이름은 구글 내부 전용, 비공개)
|
||||
- [ ] 연락처 이메일(계정+앱) → 전용 메일
|
||||
- [ ] 거래자 상태 → **비거래자(Non-trader)** (주소 비공개)
|
||||
- [ ] 품앗이 노출은 전용 Gmail로만
|
||||
|
||||
---
|
||||
|
||||
## ✅ 적용 완료 (전부 웹/소스 배포됨)
|
||||
|
||||
**v2(versionCode 2, 1.0.1, 06-29)에 포함 — 비공개 테스트 게시·검토통과**
|
||||
- 신규 유저 빈 상태 CTA(홈 '시작하기'·내역·계좌) / 오프라인·네트워크 오류 배너
|
||||
- 앱 잠금(PIN, 옵트인) + 카메라/갤러리 복귀 오작동 수정
|
||||
- 전역 에러 폴백(흰 화면 방지) / 안드로이드 뒤로가기 통합(오버레이→뒤로→종료토스트)
|
||||
- 분류 순서변경 보호('순서변경' 토글)
|
||||
|
||||
**v2 이후 추가 — 웹 배포됨, v3 빌드 대기**
|
||||
- 앱 잠금 **1시간 유예**(잦은 PIN 입력 해소)
|
||||
- **외화 입/지출** — 통화+환율 자동조회(open.er-api.com 무료)→원화 환산, 원본 보존. `amount`는 환산 원화라 통계·예산 무변경
|
||||
- **월별 예산 + 전월/다음달 복사** — budget에 year/month 추가, 기존 예산은 현재 월로 1회 이관
|
||||
- 내역추가 **전체화면 모달**(바깥 닫힘 방지) / 분류 **아코디언**(대분류 삼각형) / 계좌 셀렉트 정리(종류별 안내·이름만)
|
||||
- 구글 로그인 **Play 서명 SHA-1 등록**(Google Cloud Android OAuth 클라 추가)
|
||||
|
||||
---
|
||||
|
||||
## 📌 남은 백로그 (출시 후 업데이트)
|
||||
1. **앱 업데이트 안내** — 새 버전 알림 + 구글플레이 바로가기
|
||||
2. **관리자 신고글 목록 페이지**
|
||||
3. **가계부 작성 포인트** — 건당 10P, 일 50P 제한
|
||||
4. **게시판 포인트 정책 개편** — 작성 50P(도배 제외)·키워드필터·추천+5/비추-1(일50)·남글추천+1(일10)/취소-1
|
||||
5. (선택) 첫 실행 온보딩 / Play 인앱 리뷰 / 생체 인증 / 외화 환율 자동 고도화
|
||||
6. **조직 계정 전환 + 기프트콘 교환** — 사업자등록→DUNS→앱이전, B2B 기프트콘 매입
|
||||
|
||||
### 🤖 AI 기능 (★ 정식 출시 전 적용 희망)
|
||||
- 전제: **LLM API 키**(Claude Haiku 4.5 권장 — 학습 미사용·톤 우수·저렴 / 또는 Gemini 무료쿼터) + **개인정보처리방침·데이터안전 갱신**(제3자 LLM 추가). 백엔드 프록시·키 서버보관·실패 시 폴백.
|
||||
- **AI 입력 보조** (편의성, 1순위)
|
||||
- ① **자연어 한 줄 입력** — "어제 스타벅스 5천원" → 폼 자동 채움(기존 parseText를 LLM 기반으로). 음성=기기STT+①
|
||||
- ② **영수증 사진 → AI 구조화** — 멀티모달 LLM이 총액·날짜·가맹점·분류 추천(기존 Vision OCR 보강/대체)
|
||||
- ③ **자동 분류 추천** — 가맹점/메모→분류 제안
|
||||
- 원칙: AI는 폼 prefill만, **사용자 확인 후 저장**
|
||||
- **AI 재무 코멘트** — 자산·수입·예상지출 기반 한마디("이러다 파산각" 등). 일1회 캐시·집계수치만 전송.
|
||||
|
||||
---
|
||||
|
||||
## 🔨 빌드 상태
|
||||
- **v2** (versionCode 2, 1.0.1): 비공개 테스트 게시·검토통과
|
||||
- **v3** (versionCode 3): 위 'v2 이후 추가' 반영 — 빌드 후 비공개 트랙 업로드 (테스터에게 최신본 제공, 14일 카운트 리셋 안 됨)
|
||||
|
||||
---
|
||||
|
||||
## 다음 세션 재개 지점
|
||||
**테스터 12명 opt-in 진행상황 확인**(업체 의뢰) → 12명 + 14일 연속 충족되면 **프로덕션 액세스 신청**(`production-access-draft.md`).
|
||||
v3 AAB를 비공개 트랙에 업로드해 테스터가 최신본을 받게.
|
||||
@@ -0,0 +1,93 @@
|
||||
// Play Console 스토어 그래픽 생성기.
|
||||
// 입력: brand/logo-1024.png (사용자가 저장한 1024x1024 돈돼지 마스터 아트)
|
||||
// 출력: brand/store/icon-512.png (스토어 고해상도 아이콘 512x512, 불투명)
|
||||
// brand/store/feature-1024x500.png (피처 그래픽 — 텍스트 없음, 항상 안정)
|
||||
// brand/store/feature-1024x500-text.png(피처 그래픽 — 한글 텍스트 포함, best-effort)
|
||||
//
|
||||
// 실행: node brand/gen-store-assets.mjs [입력경로]
|
||||
import sharp from 'sharp'
|
||||
import { mkdirSync, existsSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const SRC = resolve(process.argv[2] || join(__dirname, 'logo-1024.png'))
|
||||
const OUT = join(__dirname, 'store')
|
||||
|
||||
// 브랜드 색 (아이콘 배경의 크림/옐로 계열)
|
||||
const CREAM = '#FBF7E9'
|
||||
const YELLOW = '#F2C94C'
|
||||
const BROWN = '#B8860B'
|
||||
|
||||
if (!existsSync(SRC)) {
|
||||
console.error(`\n[!] 마스터 아트가 없습니다: ${SRC}`)
|
||||
console.error(` 1024x1024 돈돼지 PNG 를 위 경로에 저장한 뒤 다시 실행하세요.\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
mkdirSync(OUT, { recursive: true })
|
||||
|
||||
const log = (f) => console.log(' ✓', f)
|
||||
|
||||
// 1) 스토어 아이콘 512x512 — 불투명 크림 배경 위에 마스터를 합성(투명 PNG 대비)
|
||||
async function icon512() {
|
||||
const logo = await sharp(SRC).resize(512, 512, { fit: 'contain', background: CREAM }).png().toBuffer()
|
||||
const out = join(OUT, 'icon-512.png')
|
||||
await sharp({ create: { width: 512, height: 512, channels: 4, background: CREAM } })
|
||||
.composite([{ input: logo }])
|
||||
.flatten({ background: CREAM })
|
||||
.png()
|
||||
.toFile(out)
|
||||
log('store/icon-512.png (512x512, 불투명)')
|
||||
}
|
||||
|
||||
// 공통: 1024x500 배경 — 피그가 놓일 우측(크림)에서 좌측(옐로 살짝)으로.
|
||||
// 중심을 피그 쪽 크림으로 두어 마스터의 크림 배경 사각이 자연스럽게 녹도록 한다.
|
||||
function bgSvg(cx = '80%') {
|
||||
return Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="500">
|
||||
<defs>
|
||||
<radialGradient id="g" cx="${cx}" cy="48%" r="95%">
|
||||
<stop offset="0%" stop-color="${CREAM}"/>
|
||||
<stop offset="62%" stop-color="${CREAM}"/>
|
||||
<stop offset="100%" stop-color="${YELLOW}" stop-opacity="0.55"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="1024" height="500" fill="url(#g)"/>
|
||||
</svg>`)
|
||||
}
|
||||
|
||||
// 2) 피처 그래픽 (텍스트 없음) — 가운데 큼직하게, 박스 없이 녹아들게
|
||||
async function featurePlain() {
|
||||
const bg = await sharp(bgSvg('50%')).png().toBuffer()
|
||||
const pig = await sharp(SRC).resize(420, 420, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toBuffer()
|
||||
const out = join(OUT, 'feature-1024x500.png')
|
||||
await sharp(bg).composite([{ input: pig, top: 40, left: 302 }]).png().toFile(out)
|
||||
log('store/feature-1024x500.png (텍스트 없음)')
|
||||
}
|
||||
|
||||
// 3) 피처 그래픽 (한글 텍스트) — 좌측 텍스트 / 우측 피그, 겹침 없도록 간격 조정.
|
||||
async function featureWithText() {
|
||||
const bg = await sharp(bgSvg('82%')).png().toBuffer()
|
||||
const pig = await sharp(SRC).resize(380, 380, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toBuffer()
|
||||
const text = Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="500">
|
||||
<style>
|
||||
.t { font-family: 'Malgun Gothic','맑은 고딕',sans-serif; fill:${BROWN}; }
|
||||
.title { font-size: 80px; font-weight: 800; }
|
||||
.sub { font-size: 29px; font-weight: 500; fill:#8a6d10; }
|
||||
</style>
|
||||
<text x="64" y="232" class="t title">돈돼지 가계부</text>
|
||||
<text x="68" y="290" class="t sub">슬림하게 관리하는</text>
|
||||
<text x="68" y="332" class="t sub">가계부 · 자산 · 예산</text>
|
||||
</svg>`)
|
||||
const out = join(OUT, 'feature-1024x500-text.png')
|
||||
await sharp(bg)
|
||||
.composite([{ input: pig, top: 60, left: 630 }, { input: text }])
|
||||
.png()
|
||||
.toFile(out)
|
||||
log('store/feature-1024x500-text.png (한글 텍스트, 폰트 렌더 확인 필요)')
|
||||
}
|
||||
|
||||
console.log(`\n[스토어 에셋 생성] 입력: ${SRC}\n`)
|
||||
await icon512()
|
||||
await featurePlain()
|
||||
await featureWithText()
|
||||
console.log(`\n완료 → ${OUT}\n`)
|
||||
|
After Width: | Height: | Size: 908 KiB |
@@ -0,0 +1,127 @@
|
||||
# Play Console 등록 자료 — 돈돼지 가계부
|
||||
|
||||
> 첫 출시(무료 앱, 인앱결제·광고 모두 비활성화) 기준. Play Console 각 항목에 그대로 붙여넣기.
|
||||
> 광고(카카오 애드핏)를 껐으므로 광고 식별자 수집/제3자 공유가 없어 데이터 안전이 단순함.
|
||||
|
||||
---
|
||||
|
||||
## 1. 스토어 등록정보 (Main store listing)
|
||||
|
||||
### 앱 이름 (30자 이내)
|
||||
```
|
||||
돈돼지 가계부
|
||||
```
|
||||
|
||||
### 간단한 설명 / Short description (80자 이내)
|
||||
```
|
||||
가계부·자산·예산을 슬림하게. 고정지출 자동, 통계 차트, 영수증 인식까지.
|
||||
```
|
||||
|
||||
### 자세한 설명 / Full description (4000자 이내)
|
||||
```
|
||||
🐷 돈돼지 가계부 — 슬림하게 관리하는 가계부 · 자산 · 예산
|
||||
|
||||
복잡한 가계부는 그만! 수입·지출을 빠르게 기록하고, 자산과 예산을 한눈에 관리하세요.
|
||||
군더더기 없는 깔끔한 화면으로 매일 쓰기 부담 없는 가계부입니다.
|
||||
|
||||
▪ 간편한 가계부
|
||||
· 수입·지출·이체를 몇 번의 터치로 기록
|
||||
· 자주 쓰는 내역을 칩으로 등록해 1초 입력
|
||||
· 월별 수입/지출/잔액 요약과 일별 내역 정리
|
||||
|
||||
▪ 자산 · 부채 관리
|
||||
· 은행·현금·신용/체크카드·대출·투자 계좌를 한 곳에서
|
||||
· 총자산 · 총부채 · 순자산을 자동 집계
|
||||
|
||||
▪ 고정 지출 자동 반영 (프리미엄)
|
||||
· 월세·구독료·급여 등 반복 거래를 등록해두면 주기에 맞춰 자동 생성
|
||||
· 실제 결제만 '확인'으로 확정 — 빠뜨림 없는 가계부
|
||||
|
||||
▪ 통계와 예산 (프리미엄)
|
||||
· 분류별 지출, 수입 대비 지출, 지난달 대비, 월말 예상까지 차트로 분석
|
||||
· 카테고리별 예산을 정하고 예산 대비 지출을 한눈에
|
||||
|
||||
▪ 영수증 인식 (프리미엄)
|
||||
· 영수증을 촬영하면 금액·내역을 자동 인식해 입력 시간을 단축
|
||||
|
||||
▪ 커뮤니티
|
||||
· 짠테크·재테크 노하우를 나누는 게시판
|
||||
· 추천·댓글로 소통하고 좋은 글을 발견
|
||||
|
||||
▪ 그 외
|
||||
· 다크 모드 지원
|
||||
· 엑셀 백업/복구로 내 데이터를 안전하게 (프리미엄)
|
||||
· 로그인 없이 '둘러보기'로 먼저 체험
|
||||
|
||||
무료로 핵심 가계부 기능을 모두 사용할 수 있으며, 통계·예산·고정지출 자동화·영수증 인식·백업 등은 프리미엄에서 제공됩니다. (구독 기능은 순차적으로 제공될 예정입니다.)
|
||||
|
||||
오늘부터 돈돼지와 함께 슬림한 가계 관리를 시작해보세요!
|
||||
|
||||
문의: sbsb401824@gmail.com
|
||||
```
|
||||
|
||||
### 기타 등록정보 필드
|
||||
- **앱 카테고리**: 금융 (Finance)
|
||||
- **태그**: 가계부, 예산, 자산관리
|
||||
- **연락처 이메일**: sbsb401824@gmail.com
|
||||
- **개인정보처리방침 URL**: https://www.sblog.kr/privacy
|
||||
- **그래픽 에셋**:
|
||||
- 앱 아이콘: brand/store/icon-512.png
|
||||
- 그래픽 이미지(피처): brand/store/feature-1024x500-text.png (또는 -text 없는 버전)
|
||||
- 휴대전화 스크린샷: brand/screenshots/shot1~6 (6장)
|
||||
|
||||
---
|
||||
|
||||
## 2. 데이터 안전 (Data safety) 양식
|
||||
|
||||
> "앱 콘텐츠 → 데이터 보안" 에서 작성. 아래 기준으로 답하면 개인정보처리방침과 일치함.
|
||||
|
||||
### 개요 질문
|
||||
- 데이터를 수집하나요? → **예**
|
||||
- 데이터를 제3자와 공유하나요? → **아니요** (광고 끔. OCR은 처리 위탁이며 별도 공유 아님)
|
||||
- 전송 중 데이터가 암호화되나요? → **예** (HTTPS)
|
||||
- 사용자가 데이터 삭제를 요청할 수 있나요? → **예** (앱 내 '회원 탈퇴' + 이메일 요청, https://www.sblog.kr/account-deletion)
|
||||
|
||||
### 수집하는 데이터 유형 (각 항목: 수집됨 / 목적 / 공유 여부)
|
||||
|
||||
| 데이터 유형 | 수집 | 공유 | 목적 |
|
||||
|---|---|---|---|
|
||||
| **개인 정보 → 이름** | 예 | 아니요 | 앱 기능, 계정 관리 |
|
||||
| **개인 정보 → 이메일 주소** | 예 | 아니요 | 앱 기능, 계정 관리 |
|
||||
| **금융 정보 → 기타 금융 정보** (사용자가 입력한 수입·지출·자산 등 가계부 데이터) | 예 | 아니요 | 앱 기능 |
|
||||
| **사진 및 동영상 → 사진** (프로필·영수증·게시판 이미지) | 예 | 아니요 | 앱 기능 |
|
||||
| **앱 활동 → 사용자가 생성한 콘텐츠** (게시판 글·댓글) | 예 | 아니요 | 앱 기능 |
|
||||
| **앱 활동 → 앱 상호작용** | 예 | 아니요 | 앱 기능, 부정 이용 방지 |
|
||||
|
||||
각 항목 공통:
|
||||
- **수집이 필수인가요?** → "데이터 수집이 필수임" (서비스 제공에 필요)
|
||||
- **데이터가 암호화되나요(전송 중)?** → 예
|
||||
- **삭제 요청 가능?** → 예
|
||||
|
||||
> ✅ 광고를 껐으므로 **광고 식별자(기기 ID) 수집/공유가 없습니다** — 데이터 안전 양식이 단순해지고 심사 불일치 리스크가 사라졌습니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 콘텐츠 등급 설문 (IARC)
|
||||
|
||||
> "앱 콘텐츠 → 콘텐츠 등급" 설문. 핵심 답안:
|
||||
|
||||
- 앱 카테고리: **유틸리티/생산성/기타** (또는 금융)
|
||||
- 폭력/성적/혐오/약물/도박 콘텐츠: **모두 없음**
|
||||
- **사용자 간 상호작용/사용자 생성 콘텐츠(게시판)**: **있음** → "사용자가 콘텐츠를 만들고 공유할 수 있음" 으로 정직하게 표시
|
||||
- 신고 기능(5건 누적 블라인드)·관리자 차단 등 **중재 수단 보유** → 관련 질문에 "콘텐츠 신고/차단 기능 있음"으로 답변
|
||||
- 위치 공유 / 개인정보 공유 기능: **없음**
|
||||
- 디지털 구매(인앱결제): 첫 출시는 **비활성화** → "없음" (추후 활성화 시 등급 갱신)
|
||||
- **광고 포함 여부**: **아니요** (광고 껐음 — 추후 활성화 시 갱신)
|
||||
- 예상 등급: 대체로 **만 3세 이상(전체이용가)** 수준, UGC로 인해 일부 지역에서 Teen 가능 — 설문 결과 따름
|
||||
|
||||
---
|
||||
|
||||
## 4. 기타 출시 전 체크
|
||||
- **앱 액세스 권한**: 로그인 필요 → 심사용 테스트 계정 제공 (Play Console "앱 액세스 권한"에 kenneth73510 계정 또는 데모 안내). 데모(둘러보기)로도 핵심 화면 확인 가능함을 메모.
|
||||
- **타겟 출시 국가**: 대한민국 (필요 시 확대)
|
||||
- **데이터 삭제 URL**: https://www.sblog.kr/account-deletion (데이터 안전 양식 + 앱 콘텐츠)
|
||||
- **앱 카테고리/연락처/방침 URL** 위 1번 참고
|
||||
- 광고 포함 여부 질문: **아니요(광고 없음)** — 광고를 껐으므로 "광고 없음"으로 표시
|
||||
- ⚠️ **개인정보처리방침 정합성**: 현재 방침 4항이 "무료 회원에게 광고가 표시되며…"로 적혀 있어 광고 OFF 와 불일치. 심사자 교차확인 대비해 방침의 광고 문구를 "향후 광고가 도입될 수 있으며…"(미래형)로 완화 권장. (원하면 수정·배포 도와드림)
|
||||
```
|
||||
@@ -0,0 +1,57 @@
|
||||
# 프로덕션 액세스 신청서 초안 — 돈돼지 가계부
|
||||
|
||||
> 14일 폐쇄 테스트 완료 후 Play Console "프로덕션 액세스 신청" 폼에 입력.
|
||||
> ⚠️ [대괄호] 부분은 실제 테스트 후 본인 경험으로 교체하세요. 거짓·과장 금지 — 실제로 테스트한 내용을 적어야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## Q1. 테스터를 어떻게 모집했나요? (How did you recruit your testers?)
|
||||
|
||||
```
|
||||
실제 Android 기기와 Google 계정을 보유한 [12]명의 테스터를 모집해 비공개 테스트를 진행했습니다.
|
||||
테스터는 안드로이드 사용자 지인 및 개발자 커뮤니티의 베타 테스터 모임을 통해 모았으며,
|
||||
모두 실제 기기에 앱을 설치하고 14일간 사용했습니다.
|
||||
```
|
||||
> 유료/품앗이 서비스를 쓴 경우에도 "실제 기기·실제 계정 테스터"라는 사실 위주로 적되, 허위로 쓰지 마세요.
|
||||
|
||||
## Q2. 테스터와 어떻게 소통하고 피드백을 수집했나요?
|
||||
|
||||
```
|
||||
이메일과 메신저(오픈채팅)로 테스터에게 설치 안내와 점검 항목을 전달했습니다.
|
||||
점검 항목: 회원가입/구글 로그인, 가계부 수입·지출 기록, 계좌·자산 관리, 예산·통계 화면,
|
||||
고정지출 자동 반영, 커뮤니티 게시판(글·댓글·추천), 다크 모드.
|
||||
테스터가 사용 중 발견한 버그·불편 사항을 회신받아 정리했습니다.
|
||||
```
|
||||
|
||||
## Q3. 어떤 피드백을 받았고, 무엇을 개선했나요?
|
||||
|
||||
```
|
||||
주요 피드백과 개선:
|
||||
- 둘러보기(로그인 없이 체험) 모드에서 일부 화면의 메뉴 접근이 불편 → 메뉴 진입 동선 수정
|
||||
- 특정 기기에서 한글 입력 시 자동완성/맞춤법 검사로 입력이 끊김 → 입력 필드 설정 수정으로 해결
|
||||
- 무료/유료 기능 구분 및 화면 흐름이 헷갈린다는 의견 → 안내 문구·접근 동선 정리
|
||||
- [본인 테스트 중 실제로 발견한 버그/불편과 그에 대한 수정 내용을 추가 기재]
|
||||
크래시나 데이터 유실 등 치명적 문제는 발견되지 않았습니다.
|
||||
```
|
||||
|
||||
## Q4. 앱이 프로덕션에 준비되었다고 보는 이유는?
|
||||
|
||||
```
|
||||
돈돼지 가계부는 웹·Windows 데스크톱에서 이미 운영 중인 개인 가계부 서비스이며,
|
||||
이번 비공개 테스트로 Android 실기기에서 핵심 기능(가계부 기록, 계좌·자산, 예산·통계,
|
||||
고정지출 자동 반영, 커뮤니티, 구글 로그인)의 안정성을 14일간 검증했습니다.
|
||||
다양한 기기에서 크래시 없이 정상 동작하며, 테스트 중 제기된 개선 사항을 반영했습니다.
|
||||
개인정보처리방침·데이터 삭제 안내 등 정책 요건도 갖추어 정식 출시 준비가 되었다고 판단합니다.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## (선택) 영어 버전 — 폼이 영어로 표시될 경우
|
||||
|
||||
**Q1 Recruit:** I recruited [12] testers who own real Android devices with genuine Google accounts, through acquaintances and a developer beta-tester community. All installed the app and used it over the 14-day period.
|
||||
|
||||
**Q2 Feedback collection:** I communicated via email and messenger, sharing install instructions and a checklist (Google sign-in, expense/income tracking, accounts & assets, budget & statistics, recurring transactions, community board, dark mode), and collected bug reports and usability feedback.
|
||||
|
||||
**Q3 Findings & changes:** Key fixes included improving menu navigation in the guest "explore" mode, resolving a Korean-input (IME) issue on certain devices, and clarifying free/premium feature flow. [Add the real bugs you found.] No crashes or data-loss issues were found.
|
||||
|
||||
**Q4 Readiness:** Don Dwaeji budget app already runs in production on web and Windows desktop. The closed test validated the Android build's core features on real devices over 14 days with no crashes, and feedback was addressed. Privacy policy and data-deletion pages are in place, so the app is ready for production.
|
||||
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 279 KiB |
@@ -0,0 +1,9 @@
|
||||
; 기본 설치 경로 지정 (사용자 폴더 = 관리자 권한 불필요 → UAC 프롬프트 없음).
|
||||
; productName 이 한글이라 electron-builder 가 기본 경로를 package.json name(sb_pt)으로 폴백하는 문제도 함께 회피.
|
||||
; → %LOCALAPPDATA%\Programs\DonDwaeji 로 고정 (사용자는 설치 시 변경 가능).
|
||||
!macro preInit
|
||||
SetRegView 64
|
||||
WriteRegExpandStr HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation "$LOCALAPPDATA\Programs\DonDwaeji"
|
||||
SetRegView 32
|
||||
WriteRegExpandStr HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation "$LOCALAPPDATA\Programs\DonDwaeji"
|
||||
!macroend
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"appId": "kr.sblog.slimbudget",
|
||||
"appName": "Slim Budget",
|
||||
"appName": "돈돼지 가계부",
|
||||
"webDir": "dist",
|
||||
"server": {
|
||||
"cleartext": true
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# 멤버십(무료/유료) 메뉴·기능 구분 — 설계 (추후 구현)
|
||||
|
||||
> 상태: **설계만** (미구현). 결제·게이팅은 추후 작업.
|
||||
> 방향: **핵심 기록은 무료로 개방**(진입장벽 ↓), **과금 기능·자동화·고급 분석·무제한·백업은 유료**.
|
||||
> 계좌 개수 제한은 **두지 않음**(1~소수 제한은 핵심 기능을 막아 거부감 → 제외).
|
||||
|
||||
---
|
||||
|
||||
## 1. 무료 / 유료 구분
|
||||
|
||||
| 메뉴 / 기능 | 무료 | 유료(프리미엄) | 비고 |
|
||||
|---|:--:|:--:|---|
|
||||
| **가계부 내역**(수입·지출 기록) | ✅ | ✅ | 핵심 — 무료 개방 |
|
||||
| **계좌 관리**(은행·현금·카드·대출) | ✅ **무제한** | ✅ 무제한 | 개수 제한 없음 |
|
||||
| 자주 쓰는 내역(빠른 등록) | ✅ | ✅ | 편의, 무료 |
|
||||
| 월별 요약(수입/지출/잔액) | ✅ | ✅ | 기본 요약은 무료 |
|
||||
| 분류 관리 | ✅ 기본+소수 | ✅ 무제한·소분류(2단계) | 무료는 기본 분류 위주 |
|
||||
| 태그 관리 | — | ✅ | |
|
||||
| **통계**(순자산 추이·예상 지출·지난달 대비·분류 분석) | — | ✅ | 고급 분석은 유료 |
|
||||
| 예산 설정 | — | ✅ | |
|
||||
| 고정 지출(자동 생성·확인) | — | ✅ | 자동화 = 유료 |
|
||||
| **영수증 OCR** | — | ✅ | **Google Vision 과금** → 유료 필수 |
|
||||
| **카드 결제 알림 자동인식** | — | ✅ | 강력한 유료 훅(편의·자동화) |
|
||||
| 투자/자산(평가액) 관리 | — | ✅ | |
|
||||
| 데이터 백업/복구(엑셀·JSON) | — | ✅ | 추후 백업 기능과 연계 |
|
||||
| 게시판(커뮤니티/짠테크/재테크 팁) | ✅ | ✅ | 커뮤니티는 전체 개방 |
|
||||
| 다크모드·테마 | ✅ | ✅ | 기본 제공 |
|
||||
| 광고 | (도입 시) 표시 | 제거 | 광고 모델은 미정 |
|
||||
|
||||
## 2. 구분 기준
|
||||
- **무료** = 기본 기록 + (무제한)계좌 + 기본 분류 + 월 요약 → "충분히 써볼 만한" 수준
|
||||
- **유료** = ① 과금 기능(OCR) · ② 자동화(카드 알림 인식 · 고정지출) · ③ 고급 분석(통계 전체 · 예산) · ④ 무제한(태그/소분류) · ⑤ 백업/복구
|
||||
- 원칙: **무료를 일부러 불편하게 만들지 않고, 유료에 "확실히 편한 것"을 몰아준다**(전환율 우선).
|
||||
|
||||
## 3. 구현 시 필요한 것 (추후)
|
||||
- **데이터**: `member.plan`(`FREE`/`PREMIUM`) + `plan_expires_at`(만료일) 컬럼.
|
||||
- **게이팅(이중)**:
|
||||
- 프론트: 유료 메뉴에 **잠금 배지 + 업그레이드 유도**(데모의 잠금 화면 재사용 가능), 비유료는 진입 차단.
|
||||
- 백엔드: 해당 API에 **plan 권한 체크**(프론트 우회 방지). OCR·알림 인식·통계·예산·고정지출·투자·백업 엔드포인트.
|
||||
- **결제 연동**: 안드로이드 인앱결제(Google Play Billing) / 웹·PC는 PG. 정기결제 시 만료일 갱신.
|
||||
- **기존 데이터 보호**: 유료→무료 강등(만료) 시 데이터는 보존하되 **유료 메뉴만 잠금**(읽기 정도는 허용 검토).
|
||||
|
||||
## 4. 추후 결정 필요(미정)
|
||||
- 가격·주기(월/연), 무료체험 기간.
|
||||
- 광고 모델 도입 여부(무료=광고 / 유료=제거).
|
||||
- 무료 "기본 분류" 범위(개수·기본 템플릿만 허용 여부).
|
||||
- 강등 시 잠금 정책(완전 차단 vs 읽기 허용).
|
||||
|
||||
---
|
||||
|
||||
*참고: 데모(`/demo`)의 잠금 안내 화면을 유료 게이팅 UI 의 기반으로 재사용할 수 있음.*
|
||||
@@ -68,12 +68,155 @@
|
||||
## 13. 안드로이드
|
||||
- 에지투에지(`targetSdk 36`) 대응: **콘텐츠 루트에 시스템 바 인셋 패딩** 적용 → 헤더는 상태바 아래, 하단 내비는 소프트키 위.
|
||||
|
||||
## 14. PC 데스크톱 클라이언트 (Electron)
|
||||
- **Windows 설치형 `.exe`** — 번들 dist 대신 **라이브 사이트(app.sblog.kr) 직접 로드** → 프론트 변경이 **재설치 없이** 앱 재시작만으로 반영. 시작 시 `clearCache()`로 옛 HTML 캐시 방지.
|
||||
- 창 크기/위치/최대화 상태 **기억**(다음 실행 복원). 로그인 전 빈 사이드바 숨김(콘텐츠 중앙 정렬).
|
||||
- 웹 안내페이지에 **PC 버전 다운로드 링크**(`/download/SlimBudget-Setup.exe`).
|
||||
- ⚠️ 게시판 위지윅 에디터는 **Windows IME 첫 자음 중복**(tui.editor 업스트림 버그) — PC에선 감수(일반 입력칸은 정상).
|
||||
|
||||
## 15. 브랜드 앱 아이콘
|
||||
- 기본 Capacitor 아이콘 → **브랜드(초록 #00bc7e + 흰 막대 차트) 적응형 아이콘**(전 해상도, `@capacitor/assets`).
|
||||
|
||||
## 16. 홈 캘린더 · 계좌 · 카드
|
||||
- **홈 일별 캘린더**: 하단 인라인 패널 → **탭 시 팝업(모달)** 표시(시인성·단일 탭).
|
||||
- **계좌 이용내역 월별 표시**: 계좌 펼치면 `◀ YYYY.MM ▶` + 그 달 합계, 데이터 있는 달만 이동(긴 스크롤 해소).
|
||||
- **카드 알림 계좌 자동매칭 유연화**: 카드사 표기 차이 흡수(`KB/국민/국민카드/KB카드`, `농협/NH`, `비씨/BC`, `씨티/시티`) — 어떻게 등록해도 매칭.
|
||||
|
||||
## 17. 게시판 이미지 / 에디터
|
||||
- **인라인 이미지 = base64 → 서버 업로드(DB 저장)**: `POST /api/board/images`(로그인) → `GET /api/images/{id}`(공개). **절대 URL** 반환(앱은 출처가 localhost 라 상대경로 불가). 업로드 전 1280px·JPEG 압축.
|
||||
- **에디터 이미지 버튼**: Toast UI 기본 팝업 제거 → 클릭 시 **바로 파일선택→업로드**(작은 댓글창에서 팝업 OK 잘림 회피).
|
||||
- **모바일 에디터 팝업(표·링크 등)**: 화면 **중앙 모달**로(Toast UI 의 `margin-left:-150px` 로 좌측 잘리던 문제 해결).
|
||||
|
||||
---
|
||||
|
||||
# (이어진 작업 2) 알림 분류 · 투자 · 상환 (2026-06-12)
|
||||
|
||||
## 18. 투자 계좌 단순화 (종목 관리 제거)
|
||||
- 투자 계좌를 **퇴직연금식으로 통일**: 종목(매수·매도·현재가) 관리 UI 제거, **투자금(투입원금) + 현재 평가액**만 입력 → 손익·수익률 자동 계산. (위 2번의 시세 자동조회·보유 종목 표기는 본 변경으로 대체)
|
||||
- 계좌 펼침: **투자금 / 평가액 / 손익(%)** 요약 카드. 투자 탭 진입 시 자동 시세갱신 제거(불필요).
|
||||
|
||||
## 19. 알림 자동인식 — 입금·출금 분류
|
||||
- 카드: **승인=지출 / 취소·환불=수입**, 은행: **입금=수입 / 출금·이체=지출** 로 정리(은행 이체 알림이 지출로만 잡히던 문제 해결). 네이티브 1차 필터에 입금/이체/송금 키워드 추가.
|
||||
- 미확인(pending) 기본 계좌종류: 수입(입금)=은행, 지출(카드결제)=카드.
|
||||
|
||||
## 20. 광고 푸시·문자 오등록 차단 강화
|
||||
- 기존 광고 차단이 **죽은 코드**(강신호가 이미 보장된 지점이라 한 번도 실행 안 됨)였던 것 수정 → 승인/입금 신호가 있으면 광고어가 섞여도 등록되던 원인 제거.
|
||||
- 광고어 2단계 분리:
|
||||
- **무조건 차단**(이벤트·쿠폰·혜택·당첨·응모·프로모션·배너·세일·수신거부·바로가기 등): 거래신호가 있어도 제외.
|
||||
- **약한 광고어**(적립·포인트·할인·캐시백·마일리지 등): `결제`만 있고 명확한 거래신호(승인/입출금/취소)가 **없을 때만** 제외 → 실거래 영수증의 '포인트 적립'은 보존. `결제`는 광고 문구에도 흔해 **약신호로 강등**.
|
||||
|
||||
## 21. 자동인식 분류 학습
|
||||
- 알림 자동등록 시 **메모(가맹점) 키워드가 과거 확정 내역과 일치하면 그 분류를 자동 선반영**(가장 많이 쓴 분류, `스타벅스`↔`스타벅스 강남점` 포함관계 매칭, 같은 수입/지출만). 안전상 **'확인 필요' 상태는 유지**(무검토 오확정 방지) — 확정이 쌓일수록 정확도 상승.
|
||||
|
||||
## 22. 상환/납부 수정 가능
|
||||
- '상환/납부'(원금=이체 · 이자=지출 분리)를 **신규 등록뿐 아니라 수정 시에도** 선택 가능. 수정 시 상환 2건 생성 후 원본 1건 삭제(생성 성공 후 삭제 → 중간 실패 시 원본 보존). 잘못 넣은 단순 이체/지출을 **원금·이자 분리 상환으로 정정** 가능.
|
||||
|
||||
---
|
||||
|
||||
# (이어진 작업 3) 현금 계좌 · 통계 · 가계부 UX (2026-06-22)
|
||||
|
||||
## 23. 상환/납부 — 연회비(카드)
|
||||
- 카드 상환 시 **연회비**도 분리 입력(대상이 카드일 때만 노출). 원금=이체 · 이자=지출(분류 '이자') · **연회비=지출(분류 '연회비')**. 대출엔 연회비 입력 불가.
|
||||
|
||||
## 24. 현금 계좌
|
||||
- **계좌 관리에 '현금' 유형 추가**(이름·초기 잔액만). 순자산 계산에 **자산으로 합산**(BANK와 동일 취급).
|
||||
- **가계부 입력**: 현금 선택 시 등록된 현금 계좌를 고를 수 있고, 없으면 종전처럼 '미지정'으로 기록(하위호환).
|
||||
- **이체에도 현금 지원**: 출금/입금 종류에 현금 포함 → **ATM 출금(은행→현금)·현금 입금(현금→은행)** 등 현금 계좌 이체 가능. 중복되던 별도 '현금' 라디오 제거(일반 계좌 종류로 통합).
|
||||
|
||||
## 25. 고정 지출 — '확인 필요(pending)'로 생성
|
||||
- 자동 생성되는 고정지출 내역을 **'확인 필요'**로 만들어, 실제 결제·이체를 확인한 뒤 **'확인'으로 확정**(카드 알림과 동일 흐름). 메모 없으면 제목으로 채워 식별 쉽게. → "실제 처리 여부"를 사용자가 판단 가능.
|
||||
|
||||
## 26. 가계부 내역 → 고정지출 바로 등록
|
||||
- 내역 **수정 모달에 「🔁 이 내역을 고정지출로 등록」** 버튼. 현재 항목의 종류·금액·분류·메모·계좌로 **매월** 고정지출 생성(일자=내역 날짜, 시작=다음날로 이번 회차 중복 방지). 주기는 고정지출 화면에서 변경.
|
||||
|
||||
## 27. 통계 — 순자산 추이 주간화
|
||||
- 순자산 추이를 **최근 3개월·주간(월요일 기준 13주)**으로 변경. 백엔드 `weeklyWalletFlow` + `netWorthTrendWeekly` 추가, `/networth/trend?unit=WEEK&weeks=N`. x축 라벨은 격주 표기(겹침 방지).
|
||||
|
||||
## 28. 통계 — 이번달 예상 지출(월말 추정)
|
||||
- 대시보드 '수입 대비 지출'에 **예상 지출** 표시: `현재까지 지출 ÷ 경과일 × 그 달 총일수`(run-rate). 이번 달에만 표시, 예산 대비 %도 함께.
|
||||
|
||||
## 29. 통계 — 지난달 대비 이번달 지출
|
||||
- 같은 패널에 **「지난달 대비」** 행: `±차액 (±%)`(늘면 빨강·줄면 초록), 기준 '지난달 N원' 표기. 선택 월 기준으로 직전 달과 비교.
|
||||
|
||||
## 30. 가계부 — 날짜별 접기/펴기
|
||||
- 내역이 많을 때를 위해 **날짜 그룹 접기/펴기**. 기본은 **접힘 + 오늘만 펼침**(날짜 요약 = 날짜·건수·일 합계), 헤더 탭으로 개별 토글, 상단 **「모두 펴기/접기」** 토글. 월 이동 시 초기화, 내역 편집 시엔 펼침 상태 유지.
|
||||
|
||||
## 31. 앱(안드로이드) — 키보드가 입력폼 가리던 문제
|
||||
- 에지투에지 환경에서 **IME(키보드) 인셋을 콘텐츠 패딩에 반영** → 키보드가 뜨면 WebView가 키보드 위로 줄어들어 입력 모달이 가려지지 않음(`windowSoftInputMode=adjustResize` + 인셋 리스너). 모든 모달 입력 공통.
|
||||
|
||||
## 32. 계좌 관리 — 탭 한 줄
|
||||
- 계좌 종류 탭(은행/현금/카드/대출/투자)이 두 줄로 줄바꿈되던 것 → **한 줄(넘치면 가로 스크롤)**. 버튼 글자 줄바꿈 방지.
|
||||
|
||||
## 33. 분류 — 대분류/소분류(2단계)
|
||||
- **분류를 대분류 아래 소분류로 묶기**(2단계). `account_category.parent_id` 추가(멱등 마이그레이션). 기존 분류는 모두 대분류로 시작 → 분류 관리에서 소분류로 재배치.
|
||||
- **소분류 이름 기반 유지**(내역·예산·고정지출·통계 무변경) — 같은 소분류 이름 중복은 불가, 예산·통계는 **소분류 기준**.
|
||||
- 분류 관리: 행별 대분류 선택·소분류 들여쓰기, 드래그 정렬 유지. 자식 있는 대분류는 소분류화·삭제 차단.
|
||||
- 입력/예산/고정지출 드롭다운: **대분류 optgroup** 그룹핑(값은 소분류명).
|
||||
- 대시보드 통계: 파이는 소분류 기준 + 아래 **대분류 합계** 롤업 표시.
|
||||
|
||||
---
|
||||
|
||||
# (이어진 작업 4) 구글 로그인 · 다크모드 · UI 개편 (2026-06-28)
|
||||
|
||||
## 34. 구글 로그인/가입 (소셜 로그인)
|
||||
- **웹/PC/앱 3플랫폼 구글 로그인**: 웹·PC(Electron)는 **Google Identity Services(GIS)** ID 토큰, 앱(안드로이드)은 **네이티브 플러그인 `@capawesome/capacitor-google-sign-in`**(Credential Manager). 모두 ID 토큰을 백엔드 `POST /api/auth/google` 로 전달 → **tokeninfo 로 검증(aud=웹 클라이언트 ID)** 후 세션 발급.
|
||||
- **PC(Electron) 팝업 수정**: `accounts.google.com` 팝업을 외부 브라우저로 보내 결과가 안 돌아오던 문제 → **앱 내부 자식 창**으로 열어 opener 로 credential 전달.
|
||||
- **같은 이메일 자동 연결**: 구글 로그인 시 `member.google_id`(신규 컬럼)로 조회, 없으면 **검증된 동일 이메일의 기존 계정에 연결**(provider 유지 → 아이디 로그인/프로필 편집 보존), 그래도 없으면 신규 생성.
|
||||
- 설정 필요: Google Cloud **웹 + Android OAuth 클라이언트**(패키지 `kr.sblog.slimbudget` + 서명 SHA-1), 서버 `.env` `GOOGLE_CLIENT_ID`.
|
||||
|
||||
## 35. 구글 전용 로그인 (아이디 로그인/회원가입 숨김)
|
||||
- `src/config/features.js` `ID_LOGIN_ENABLED=false` 플래그로 **아이디/비번 폼·회원가입 UI 숨김**(로그인 모달·홈 랜딩). 백엔드 `/auth/login`·`/signup` 은 비상용으로 유지(복구 가능).
|
||||
|
||||
## 36. 브랜딩 — 돈돼지 가계부
|
||||
- 앱/파비콘/PC 아이콘을 **돈돼지(투명 배경)** 로 교체, 헤더 로고·런처 라벨 정리. 런처 라벨 `돈돼지가계부`(한 줄), 앱 표시명 `돈돼지 가계부`.
|
||||
|
||||
## 37. PC 설치파일 (Electron)
|
||||
- 산출물명 `SlimBudget-Setup.exe` → **`DonDwaeji-Setup.exe`**, 다운로드 URL `/download/DonDwaeji-Setup.exe`.
|
||||
- **사용자 폴더 설치**(`%LOCALAPPDATA%\Programs\DonDwaeji`, `build/installer.nsh`)로 변경 → **관리자 권한(UAC) 프롬프트 제거**. (게시자 미서명 경고/SmartScreen 은 인증서 없이는 잔존)
|
||||
|
||||
## 38. 게시판 공지 기능 (관리자)
|
||||
- `post.notice` 컬럼(멱등 ALTER), 목록 **정렬 `notice DESC, id DESC`(공지 최상단)**, 목록·상세 **공지 배지**.
|
||||
- 공지 설정은 **글 작성/수정 시 체크박스**로 일원화(커뮤니티+관리자). 상세의 공지 토글 버튼 제거, **목록 이동 아이콘** 추가.
|
||||
|
||||
## 39. 라이트/다크 테마
|
||||
- 설정 화면에 **시스템 / 라이트 / 다크** 선택. `base.css` 를 **`data-theme` 기반 수동 다크 + 시스템 폴백**으로 전환, `theme.js` 유틸 + ui 스토어 상태, `main.js` 시작 시 즉시 적용(깜빡임 방지).
|
||||
|
||||
## 40. 사이드바/헤더 개편 · 레이아웃 정리
|
||||
- 사이드바 상단에 **돼지 로고 + '돈돼지 가계부'**(메뉴 텍스트 대체), 사이드바 **메뉴 아이콘**(lucide) 추가. **헤더는 현재 화면 타이틀** 표시(돼지 로고 제거).
|
||||
- **PC 레이아웃**: 그리드 `'left top'` → 사이드바가 **좌측 상단(헤더 행)까지** 차지, 헤더는 우측 콘텐츠 영역만.
|
||||
- **화면 내 페이지 타이틀(`<h1>`) 제거 → 헤더로 일원화**, **화면 내 뒤로가기 버튼 제거**(하단 내비 뒤로가기만 유지).
|
||||
- **전 화면 본문 폭 통일**: `--page-max`(760px) + `.layout-body > *` 전역 규칙(뷰별 제각각 max-width 대체).
|
||||
|
||||
## 41. 화면별 액션 아이콘 위치 정리
|
||||
- 가계부/예산 **month-nav**: 좌 spacer + 우 액션으로 **월 변경은 가운데, 추가·검색/필터는 우측**. 고정지출: 안내문구 아래. 커뮤니티 글쓰기: 태그 필터와 같은 줄. 분류: 지출/수입 탭과 같은 줄.
|
||||
- **커뮤니티 검색 모달**: input `min-width:0` 으로 **검색 버튼이 모달 밖으로 밀리던 문제** 수정.
|
||||
|
||||
## 42. 자주 쓰는 내역 UX 정리
|
||||
- 등록 모달의 **'자주 쓰는 내역 불러오기' 버튼·피커 모달 제거**(번거로운 3단계 해소, 저장 기능은 유지). 가계부 quick-bar 에 **'⭐ 자주 쓰는 내역' 라벨** 추가.
|
||||
- 빠른등록(`useQuick`) **등록 성공과 목록 갱신 분리** — 갱신 오류가 '등록 실패'로 오인되던 케이스 방지.
|
||||
|
||||
## 43. 계정정보 단순화 · 구글 계정 이름 변경
|
||||
- 계정정보에서 **아이디·가입유형 행 제거**(이름/이메일/권한만). **소셜(구글) 계정도 비밀번호 재인증 없이 이름만 변경** 가능(ProfileEdit), 백엔드 `updateProfile` 그대로.
|
||||
|
||||
## 44. PC 화면 해상도 변경
|
||||
- Electron **preload(`window.desktop`) + IPC(`window:setSize`)** 추가, 설정 화면(PC)에 **창 크기 프리셋**(1024×720 ~ 1920×1080). 선택 크기는 Electron 창 상태 저장으로 다음 실행에도 유지.
|
||||
|
||||
## 45. 기타
|
||||
- 통계 '분류별 지출/수입'을 **대분류 기준 집계**(소분류 펼침 제거로 스크롤 단축).
|
||||
- 게시판 작성일: 1시간 이내 'N분 전' / 1일 이내 'N시간 전' / 그 이상 **`YYYY.MM.DD`**.
|
||||
- 웹 안내 페이지 한국어 줄바꿈 **절(어절) 단위**(`word-break: keep-all` + inline-block 묶음).
|
||||
|
||||
---
|
||||
|
||||
## 운영 반영 시 체크
|
||||
- 서버 `/opt/sb-backend/.env`
|
||||
- `ACCOUNT_CRYPTO_KEY` — 계좌번호 암호화를 켤 때만(미설정 시 평문, 깨지지 않음).
|
||||
- `app_setting` · `auth_session` 테이블 — 배포 시 `member.sql` 초기화로 자동 생성(가입 허용 플래그 / 세션 DB 백업).
|
||||
- `GOOGLE_CLIENT_ID` — 구글 로그인 웹 클라이언트 ID(미설정 시 구글 버튼 미노출). **앱 구글 로그인**은 Google Cloud 에 **Android OAuth 클라이언트**(패키지 `kr.sblog.slimbudget` + 서명 SHA-1) 등록 필요.
|
||||
- `member.google_id`(계정 연결) · `post.notice`(공지) 컬럼 — 기동 시 **멱등 ALTER** 자동 적용.
|
||||
- `app_setting` · `auth_session` · `board_image` 테이블 — 배포 시 `member.sql`/`board.sql` 초기화로 자동 생성(가입 허용 / 세션 DB 백업 / 게시글 이미지).
|
||||
- Redis `appendonly yes` — 세션 영속(이미 적용). 단, 세션 DB 이중 저장으로 Redis 유실에도 로그인 유지.
|
||||
- 서버 `.env`/환경변수 `PUBLIC_URL`(기본 `https://app.sblog.kr`) — 업로드 이미지 절대 URL 베이스(앱에서 이미지 로드에 필요).
|
||||
- **nginx**(`deploy/nginx-sb-front.conf` 참고): `location /` 에 `Cache-Control no-cache`(진입 HTML 최신화), `location /download/` → `/var/www/sb-downloads`(설치파일, 배포에 안 지워지는 영속 경로).
|
||||
- 앱 버전 표기는 `package.json` 의 `version`(현재 `1.0.0`)을 단일 출처로 사용.
|
||||
- **PC 설치파일**: `npm run electron:build` → `release-desktop/DonDwaeji-Setup.exe`(**사용자 폴더 설치=UAC 없음**, 미서명 → SmartScreen 경고 정상). 서버는 `/var/www/sb-downloads/DonDwaeji-Setup.exe` 로 업로드.
|
||||
- 배포는 `main` 푸시 → CI·Deploy 워크플로 모두 **테스트 통과해야 진행**(Gitea Actions).
|
||||
|
||||
@@ -1,12 +1,43 @@
|
||||
// Slim Budget 데스크톱(Electron) 메인 프로세스.
|
||||
// 돈돼지 가계부 데스크톱(Electron) 메인 프로세스.
|
||||
// 라이브 사이트(https://app.sblog.kr)를 직접 로드한다 → 프론트 변경이 자동 반영(재설치 불필요),
|
||||
// 같은 출처라 CORS 우회도 불필요. 배포된 웹 게이트가 Electron 을 감지해 안내페이지 대신 전체 앱을 띄운다.
|
||||
const { app, BrowserWindow, shell, session } = require('electron')
|
||||
//
|
||||
// 로딩 UX: 예전에는 시작 시 HTTP 캐시를 통째로 비워 매번 전체 자산을 재다운로드 → 흰 화면이 오래 노출됐다.
|
||||
// 이제 index.html 만 캐시버스트(?_=t)로 항상 최신을 받고, 해시된 정적 자산(JS/CSS)은 캐시를 유지해
|
||||
// 재시작이 빠르다. 로드되는 동안에는 스플래시(로딩) 창을 띄워 빈 화면을 가린다.
|
||||
// 또한 단일 인스턴스 잠금으로 중복 실행을 막아(두 번째 실행 시 사이트를 다시 로드하며 빈 화면이 보이던 문제),
|
||||
// 이미 실행 중이면 기존 창을 활성화한다.
|
||||
const { app, BrowserWindow, shell, ipcMain, Menu } = require('electron')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
// 기본 애플리케이션 메뉴(File/Edit/View/Window) 제거 — 메인 창뿐 아니라
|
||||
// 구글 로그인 팝업 등 자식 창에도 메뉴바가 안 붙도록 전역으로 비활성화.
|
||||
Menu.setApplicationMenu(null)
|
||||
|
||||
// 네이티브 대화상자(alert/confirm) 제목은 app.getName() 을 쓴다 → package.json name('sb_pt') 대신 표기.
|
||||
app.setName('돈돼지 가계부')
|
||||
|
||||
const APP_URL = 'https://app.sblog.kr'
|
||||
|
||||
// 현재 메인 창 참조(중복 실행 시 활성화 대상).
|
||||
let mainWindow = null
|
||||
|
||||
// 중복 실행 방지: 잠금을 못 얻으면 이미 실행 중 → 새 인스턴스를 즉시 종료한다.
|
||||
// (두 번째 실행에서 라이브 사이트를 다시 로드하느라 빈 화면이 보이던 현상 해결)
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit()
|
||||
} else {
|
||||
// 두 번째 실행 시도가 들어오면 기존 창을 복원/활성화.
|
||||
app.on('second-instance', () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
if (!mainWindow.isVisible()) mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 창 크기/위치를 userData 에 저장해 다음 실행 때 복원 (기본 해상도 고정 대신 사용자가 맞춘 크기 유지)
|
||||
function stateFile() {
|
||||
return path.join(app.getPath('userData'), 'window-state.json')
|
||||
@@ -28,9 +59,26 @@ function saveState(win) {
|
||||
}
|
||||
}
|
||||
|
||||
// 스플래시(로딩) 화면 — 라이브 사이트가 첫 렌더될 때까지 빈 흰 화면 대신 표시.
|
||||
function splashHtml() {
|
||||
const html = `<!doctype html><html><head><meta charset="utf-8"><title>돈돼지 가계부</title>
|
||||
<style>html,body{height:100%;margin:0;display:flex;align-items:center;justify-content:center;
|
||||
background:#1a1a1a;color:#eee;font-family:system-ui,'Segoe UI',sans-serif;-webkit-user-select:none;user-select:none}
|
||||
.box{text-align:center}
|
||||
.pig{font-size:46px;line-height:1}
|
||||
.name{margin-top:10px;font-size:17px;font-weight:700;letter-spacing:-0.01em}
|
||||
.spin{margin:18px auto 0;width:26px;height:26px;border:3px solid rgba(255,255,255,.2);
|
||||
border-top-color:#00bc7e;border-radius:50%;animation:r .8s linear infinite}
|
||||
@keyframes r{to{transform:rotate(360deg)}}
|
||||
.tip{margin-top:12px;font-size:12px;opacity:.5}</style></head>
|
||||
<body><div class="box"><div class="pig">🐷</div><div class="name">돈돼지 가계부</div>
|
||||
<div class="spin"></div><div class="tip">불러오는 중…</div></div></body></html>`
|
||||
return 'data:text/html;charset=utf-8,' + encodeURIComponent(html)
|
||||
}
|
||||
|
||||
// 오프라인/로드 실패 시 안내 화면 (다시 시도 버튼)
|
||||
function offlineHtml() {
|
||||
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Slim Budget</title>
|
||||
const html = `<!doctype html><html><head><meta charset="utf-8"><title>돈돼지 가계부</title>
|
||||
<style>html,body{height:100%;margin:0;display:flex;align-items:center;justify-content:center;
|
||||
background:#1a1a1a;color:#eee;font-family:system-ui,'Segoe UI',sans-serif}
|
||||
.box{text-align:center}h2{margin:0 0 8px}p{opacity:.7;margin:0}
|
||||
@@ -50,31 +98,70 @@ function createWindow() {
|
||||
y: s?.y,
|
||||
minWidth: 360,
|
||||
minHeight: 560,
|
||||
show: false, // 첫 렌더(ready-to-show) 전까지 숨김 → 빈 흰 화면 노출 방지(스플래시가 대신 보임)
|
||||
backgroundColor: '#1a1a1a',
|
||||
title: 'Slim Budget',
|
||||
title: '돈돼지 가계부',
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
// 한글 IME 첫 글자 중복 입력 방지 — Chromium 맞춤법 검사기가 조합을 방해하는 Electron 알려진 버그 회피
|
||||
spellcheck: false,
|
||||
},
|
||||
})
|
||||
if (s?.maximized) win.maximize()
|
||||
mainWindow = win
|
||||
|
||||
// 로딩 스플래시(프레임 없는 작은 창) — 메인이 준비되면 닫는다.
|
||||
const splash = new BrowserWindow({
|
||||
width: 300,
|
||||
height: 300,
|
||||
frame: false,
|
||||
resizable: false,
|
||||
movable: false,
|
||||
skipTaskbar: true,
|
||||
alwaysOnTop: true,
|
||||
backgroundColor: '#1a1a1a',
|
||||
title: '돈돼지 가계부',
|
||||
})
|
||||
splash.loadURL(splashHtml())
|
||||
splash.center()
|
||||
|
||||
// 메인 창을 한 번만 노출하고 스플래시를 정리(이벤트 중복/지연 대비).
|
||||
let revealed = false
|
||||
const reveal = () => {
|
||||
if (revealed) return
|
||||
revealed = true
|
||||
if (!win.isDestroyed()) win.show()
|
||||
if (!splash.isDestroyed()) splash.destroy()
|
||||
}
|
||||
win.once('ready-to-show', reveal)
|
||||
// 안전장치: 로드가 비정상적으로 지연돼도 12초 후엔 메인 창을 띄운다.
|
||||
setTimeout(reveal, 12000)
|
||||
|
||||
// 기본 메뉴바 제거(앱 자체 내비게이션 사용)
|
||||
win.removeMenu()
|
||||
win.loadURL(APP_URL)
|
||||
// index.html 만 캐시버스트(?_=t) → 항상 최신 프론트. 해시 자산은 캐시 유지 → 빠른 재시작.
|
||||
win.loadURL(APP_URL + '/?_=' + Date.now())
|
||||
|
||||
// 닫을 때 창 크기/위치 저장 → 다음 실행에 복원
|
||||
win.on('close', () => saveState(win))
|
||||
|
||||
// 메인 프레임 로드 실패(오프라인 등) 시 안내 화면. -3(aborted, 리다이렉트 등)은 무시.
|
||||
win.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||||
if (isMainFrame && errorCode !== -3) win.loadURL(offlineHtml())
|
||||
if (isMainFrame && errorCode !== -3) {
|
||||
win.loadURL(offlineHtml())
|
||||
reveal() // 오프라인 안내도 보이도록 메인 창 노출
|
||||
}
|
||||
})
|
||||
|
||||
// 외부(앱 외 도메인) 링크는 기본 브라우저로, 앱 내 링크는 창에서 처리
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
// 구글 로그인(GIS) 팝업은 앱 내부 자식 창으로 열어야 opener 로 로그인 결과(postMessage)가 돌아온다.
|
||||
// 외부 브라우저로 보내면 계정 선택 후 credential 이 앱으로 전달되지 않아 로그인이 끊긴다.
|
||||
if (/^https:\/\/accounts\.google\.com\//i.test(url)) {
|
||||
return { action: 'allow' }
|
||||
}
|
||||
if (/^https?:\/\//i.test(url) && !url.startsWith(APP_URL)) {
|
||||
shell.openExternal(url)
|
||||
return { action: 'deny' }
|
||||
@@ -83,14 +170,22 @@ function createWindow() {
|
||||
})
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// 시작 시 HTTP 캐시 비움 → 라이브 사이트의 최신 프론트를 항상 로드(옛 HTML 캐시로 인한 미반영 방지).
|
||||
// localStorage(로그인 세션)는 캐시가 아니라 영향 없음.
|
||||
try {
|
||||
await session.defaultSession.clearCache()
|
||||
} catch {
|
||||
/* 캐시 클리어 실패 무시 */
|
||||
}
|
||||
// 데스크톱 창 크기(해상도) 변경 — 렌더러(설정 화면)에서 호출
|
||||
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(() => {
|
||||
// 캐시는 비우지 않는다(빠른 재시작). 최신 프론트는 index.html 캐시버스트로 보장.
|
||||
createWindow()
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
|
||||
@@ -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'),
|
||||
})
|
||||
@@ -2,11 +2,10 @@
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg">
|
||||
<link rel="alternate icon" href="/favicon.ico">
|
||||
<link rel="apple-touch-icon" href="/logo.svg">
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>Slim Budget</title>
|
||||
<title>돈돼지 가계부</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
"@capacitor/camera": "^8.2.0",
|
||||
"@capacitor/cli": "^8.3.4",
|
||||
"@capacitor/core": "^8.3.4",
|
||||
"@capacitor/filesystem": "^8.1.2",
|
||||
"@capacitor/local-notifications": "^8.2.0",
|
||||
"@capacitor/preferences": "^8.0.1",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@capawesome/capacitor-google-sign-in": "^0.1.2",
|
||||
"@toast-ui/editor": "^3.2.2",
|
||||
"@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0",
|
||||
"axios": "^1.16.1",
|
||||
@@ -21,7 +25,8 @@
|
||||
"prismjs": "^1.30.0",
|
||||
"sortablejs": "^1.15.7",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^5.0.4"
|
||||
"vue-router": "^5.0.4",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/assets": "^3.0.5",
|
||||
@@ -1186,6 +1191,27 @@
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/filesystem": {
|
||||
"version": "8.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/filesystem/-/filesystem-8.1.2.tgz",
|
||||
"integrity": "sha512-doaaMfGoFR2hWU6aV6u83I+5ZsGyJVq+Gz4r9lMpJzUKMm1eMu0hLnFdV1aXZlU9FlK/RndFrVD8oRZfNOqWgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@capacitor/synapse": "^1.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/local-notifications": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-8.2.0.tgz",
|
||||
"integrity": "sha512-fvLY0w2w4MiX+DD4+Wv4DOwOLdzKZsMDwAcRv/Juudd+QbKbn69s6cM3xVqPwAiDqfnqsY4/S8xtQD6M73wY2A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/preferences": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/preferences/-/preferences-8.0.1.tgz",
|
||||
@@ -1195,6 +1221,40 @@
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/share": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/share/-/share-8.0.1.tgz",
|
||||
"integrity": "sha512-3cSBKBCJVon54rKDROP2rqGyeGks4pBh9TbaEk9S375Kbek/ZHe72N50zIa0Vn9Eac/SuhwgehO/mmA4CsUOiw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/synapse": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",
|
||||
"integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@capawesome/capacitor-google-sign-in": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@capawesome/capacitor-google-sign-in/-/capacitor-google-sign-in-0.1.2.tgz",
|
||||
"integrity": "sha512-41Z5LBVZJQ/20lEbzQJUtjfm7rCUhiq0EsfKmytTfKjqUjQB4zy0zlDMowe/z5CtetL8u7WGvERtyRYil2exww==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/capawesome-team/"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/capawesome"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
@@ -3995,6 +4055,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
@@ -5162,6 +5231,19 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
@@ -5358,6 +5440,15 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
@@ -5762,6 +5853,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/create-require": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
@@ -7459,6 +7562,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
@@ -12520,6 +12632,18 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ssri": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz",
|
||||
@@ -14155,6 +14279,24 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
@@ -14277,6 +14419,27 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-js": {
|
||||
"version": "1.6.11",
|
||||
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
},
|
||||
"build": {
|
||||
"appId": "kr.sblog.slimbudget",
|
||||
"productName": "Slim Budget",
|
||||
"productName": "돈돼지 가계부",
|
||||
"directories": {
|
||||
"output": "release-desktop"
|
||||
},
|
||||
@@ -30,13 +30,14 @@
|
||||
],
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
"icon": "assets/icon-only.png",
|
||||
"artifactName": "SlimBudget-Setup.${ext}"
|
||||
"icon": "build/icon.ico",
|
||||
"artifactName": "DonDwaeji-Setup.${ext}"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"include": "build/installer.nsh"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -45,7 +46,11 @@
|
||||
"@capacitor/camera": "^8.2.0",
|
||||
"@capacitor/cli": "^8.3.4",
|
||||
"@capacitor/core": "^8.3.4",
|
||||
"@capacitor/filesystem": "^8.1.2",
|
||||
"@capacitor/local-notifications": "^8.2.0",
|
||||
"@capacitor/preferences": "^8.0.1",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@capawesome/capacitor-google-sign-in": "^0.1.2",
|
||||
"@toast-ui/editor": "^3.2.2",
|
||||
"@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0",
|
||||
"axios": "^1.16.1",
|
||||
@@ -53,7 +58,8 @@
|
||||
"prismjs": "^1.30.0",
|
||||
"sortablejs": "^1.15.7",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^5.0.4"
|
||||
"vue-router": "^5.0.4",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/assets": "^3.0.5",
|
||||
|
||||
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -1,19 +1,40 @@
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, watch } from 'vue'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
import { computed, onErrorCaptured, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||
import AppSidebar from '@/components/layout/AppSidebar.vue'
|
||||
import AppBottomNav from '@/components/layout/AppBottomNav.vue'
|
||||
import AdBanner from '@/components/AdBanner.vue'
|
||||
import LoginModal from '@/components/LoginModal.vue'
|
||||
import SignupModal from '@/components/SignupModal.vue'
|
||||
import WebOnlyNotice from '@/components/WebOnlyNotice.vue'
|
||||
import AppDialog from '@/components/ui/AppDialog.vue'
|
||||
import AppLockScreen from '@/components/AppLockScreen.vue'
|
||||
import { appLock } from '@/composables/appLock'
|
||||
import { appError, reportFatal } from '@/composables/appError'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { reminders } from '@/native/reminders'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
import { demo, exitDemo } from '@/demo'
|
||||
import { ADS_ENABLED } from '@/config/features'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 로그인 또는 둘러보기(데모) 상태면 사이드바/전체 셸 표시
|
||||
const authed = computed(() => auth.isAuthenticated || demo.on)
|
||||
// 광고: 광고 기능이 켜져 있고 유료(PREMIUM) 회원이 아닐 때만 표시 (레이아웃 하단 여백도 함께 제어)
|
||||
const showAds = computed(() => ADS_ENABLED && !auth.isPremium)
|
||||
// 공개 법적 고지(개인정보처리방침·약관)는 웹 브라우저에서도 접근 허용 (앱 마켓 정책 URL)
|
||||
const isPublicLegal = computed(() => !!route.meta.public)
|
||||
|
||||
function leaveDemo() {
|
||||
exitDemo()
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
// 앱(Capacitor 네이티브) 또는 데스크톱(Electron) 클라이언트에서 전체 이용.
|
||||
// 웹(브라우저)은 안내 페이지만 노출. 개발 모드(npm run dev)는 예외로 전체 앱 표시.
|
||||
@@ -25,38 +46,178 @@ function onUnauthorized() {
|
||||
auth.clear()
|
||||
ui.openLogin()
|
||||
}
|
||||
// http.js 의 403 PREMIUM_REQUIRED 처리 → 업그레이드 안내 화면으로
|
||||
function onPremiumRequired() {
|
||||
if (route.name !== 'upgrade') router.push({ name: 'upgrade' })
|
||||
}
|
||||
|
||||
// ===== 네트워크 상태 배너 =====
|
||||
// 오프라인이거나(서버 연결 끊김) 서버 응답이 없을 때 상단에 안내 배너를 띄운다.
|
||||
const online = ref(typeof navigator !== 'undefined' ? navigator.onLine : true)
|
||||
const netTrouble = ref(false) // 온라인인데 서버 응답이 없는 경우(타임아웃/서버다운) 잠깐 표시
|
||||
let netTimer = null
|
||||
function onOnline() { online.value = true }
|
||||
function onOffline() { online.value = false }
|
||||
function onNetError() {
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) { online.value = false; return }
|
||||
netTrouble.value = true
|
||||
clearTimeout(netTimer)
|
||||
netTimer = setTimeout(() => { netTrouble.value = false }, 5000)
|
||||
}
|
||||
const showNetBanner = computed(() => !online.value || netTrouble.value)
|
||||
const netBannerText = computed(() =>
|
||||
!online.value
|
||||
? '📡 인터넷 연결이 끊겼어요. 연결 상태를 확인해 주세요.'
|
||||
: '⚠️ 서버에 연결할 수 없어요. 잠시 후 다시 시도해 주세요.',
|
||||
)
|
||||
function retryReload() {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
// ===== 앱 잠금(PIN) =====
|
||||
// 잠깐 다른 앱 다녀온 정도로는 잠그지 않고, 일정 시간 이상 백그라운드였을 때만 잠근다.
|
||||
// (완전 종료/재시작은 onMounted 에서 잠금) — 카메라/갤러리/공유 등 인앱 동작은 suppress 로 제외.
|
||||
const LOCK_GRACE_MS = 60 * 60 * 1000 // 1시간 미만 이탈은 잠그지 않음(잦은 PIN 입력 방지)
|
||||
let backgroundedAt = 0
|
||||
function onVisibility() {
|
||||
if (document.hidden) {
|
||||
backgroundedAt = Date.now()
|
||||
} else {
|
||||
const away = backgroundedAt ? Date.now() - backgroundedAt : 0
|
||||
backgroundedAt = 0
|
||||
if (away >= LOCK_GRACE_MS) appLock.lock() // lock() 은 suppress 창이면 건너뜀
|
||||
appLock.clearSuppress() // 복귀 — 일시중지 창 해제
|
||||
}
|
||||
}
|
||||
// 파일 선택(input[type=file]) 클릭 시 다음 백그라운드 전환을 잠금에서 제외
|
||||
function onFileInputClick(e) {
|
||||
const t = e.target
|
||||
if (t && t.tagName === 'INPUT' && t.type === 'file') appLock.suppressLock()
|
||||
}
|
||||
|
||||
// ===== 토스트 (뒤로가기 종료 안내 등) =====
|
||||
const toastMsg = ref('')
|
||||
let toastTimer = null
|
||||
function onToast(e) {
|
||||
toastMsg.value = e.detail || ''
|
||||
clearTimeout(toastTimer)
|
||||
toastTimer = setTimeout(() => { toastMsg.value = '' }, 1800)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!isApp) return // 웹은 안내만 — 앱 전용 초기화 생략
|
||||
window.addEventListener('auth:unauthorized', onUnauthorized)
|
||||
window.addEventListener('premium:required', onPremiumRequired)
|
||||
window.addEventListener('online', onOnline)
|
||||
window.addEventListener('offline', onOffline)
|
||||
window.addEventListener('net:error', onNetError)
|
||||
window.addEventListener('app:toast', onToast)
|
||||
// 앱 잠금: 저장된 세션으로 재진입하는 경우 시작 시 잠금(새 로그인 직후는 잠그지 않음)
|
||||
if (appLock.state.enabled && appLock.hasSession()) appLock.lock()
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
document.addEventListener('click', onFileInputClick, true)
|
||||
ui.loadSignupEnabled() // 회원가입 허용 여부 선로딩(랜딩/로그인 가입 버튼에 반영)
|
||||
// 로그인 상태면 전체 프로필(아바타·포인트) 최신화 — 재로그인 없이 헤더 아바타 반영
|
||||
if (auth.isAuthenticated) {
|
||||
auth.refreshProfile().then(() => {
|
||||
if (reminders.isNative()) {
|
||||
reminders.scheduleExpiryReminder({
|
||||
premium: auth.isPremium,
|
||||
autoRenew: !!auth.user?.planAutoRenew,
|
||||
expiresAt: auth.user?.planExpiresAt,
|
||||
})
|
||||
}
|
||||
})
|
||||
if (reminders.isNative()) reminders.scheduleEntryReminder(false) // 오늘 기록 여부는 홈/내역에서 보정
|
||||
}
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('auth:unauthorized', onUnauthorized)
|
||||
window.removeEventListener('premium:required', onPremiumRequired)
|
||||
window.removeEventListener('online', onOnline)
|
||||
window.removeEventListener('offline', onOffline)
|
||||
window.removeEventListener('net:error', onNetError)
|
||||
window.removeEventListener('app:toast', onToast)
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
document.removeEventListener('click', onFileInputClick, true)
|
||||
clearTimeout(netTimer)
|
||||
clearTimeout(toastTimer)
|
||||
})
|
||||
|
||||
// 자식 컴포넌트의 렌더/라이프사이클 오류를 잡아 흰 화면 대신 폴백 표시
|
||||
onErrorCaptured((err, _instance, info) => {
|
||||
reportFatal({ err, info })
|
||||
return false // 상위로 전파 중단
|
||||
})
|
||||
onUnmounted(() => window.removeEventListener('auth:unauthorized', onUnauthorized))
|
||||
|
||||
// 페이지 이동 시 모바일 사이드바 자동 닫힘
|
||||
watch(() => route.fullPath, () => ui.closeSidebar())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 웹(브라우저): 안내 페이지만 -->
|
||||
<WebOnlyNotice v-if="!isApp" />
|
||||
<!-- 치명적 오류 폴백 (흰 화면 방지) -->
|
||||
<div v-if="appError.hasError" class="fatal-screen">
|
||||
<div class="fatal-inner">
|
||||
<div class="fatal-emoji">😵</div>
|
||||
<h1 class="fatal-title">문제가 발생했어요</h1>
|
||||
<p class="fatal-sub">일시적인 오류일 수 있어요. 새로고침 후 다시 시도해 주세요.</p>
|
||||
<button type="button" class="fatal-btn" @click="retryReload">새로고침</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 웹(브라우저): 공개 법적 고지는 노출, 그 외엔 안내 페이지 -->
|
||||
<template v-if="!isApp">
|
||||
<RouterView v-if="isPublicLegal" />
|
||||
<WebOnlyNotice v-else />
|
||||
</template>
|
||||
|
||||
<!-- 앱(Capacitor): 전체 기능 -->
|
||||
<template v-else>
|
||||
<div class="layout" :class="{ 'sidebar-open': ui.sidebarOpen, 'no-sidebar': !auth.isAuthenticated }">
|
||||
<!-- 앱 잠금 화면 (로그인 상태 + 잠금 활성 시) -->
|
||||
<AppLockScreen v-if="appLock.state.locked && auth.isAuthenticated" />
|
||||
|
||||
<!-- 네트워크 오류/오프라인 안내 배너 -->
|
||||
<Transition name="netbar">
|
||||
<div v-if="showNetBanner" class="net-banner" role="alert">
|
||||
<span class="net-text">{{ netBannerText }}</span>
|
||||
<button type="button" class="net-retry" @click="retryReload">새로고침</button>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div class="layout" :class="{ 'sidebar-open': ui.sidebarOpen, 'no-sidebar': !authed, 'has-ad': showAds }">
|
||||
<AppHeader class="layout-top" />
|
||||
<!-- 로그인 전에는 메뉴가 없어 사이드바를 숨김(빈 칸 제거·중앙 정렬) -->
|
||||
<template v-if="auth.isAuthenticated">
|
||||
<!-- 로그인/둘러보기 전에는 메뉴가 없어 사이드바를 숨김(빈 칸 제거·중앙 정렬) -->
|
||||
<template v-if="authed">
|
||||
<AppSidebar class="layout-left" />
|
||||
<div class="sidebar-backdrop" @click="ui.closeSidebar()"></div>
|
||||
</template>
|
||||
<main class="layout-body">
|
||||
<div v-if="demo.on" class="demo-bar">
|
||||
<span>👀 <b>둘러보기 모드</b> · 샘플 데이터입니다.</span>
|
||||
<span class="demo-actions">
|
||||
<button type="button" class="db-login" @click="ui.openLogin('/account')">로그인</button>
|
||||
<button type="button" class="db-exit" @click="leaveDemo">나가기</button>
|
||||
</span>
|
||||
</div>
|
||||
<RouterView />
|
||||
</main>
|
||||
<!-- 하단 내비 위 광고 (유료 회원 제외). 앱은 320x50 모바일 배너(임시) -->
|
||||
<AdBanner v-if="showAds" class="app-ad" unit="DAN-qd8As3nqJjXTQKa6" :width="320" :height="50" />
|
||||
<AppBottomNav class="layout-bottom" />
|
||||
</div>
|
||||
|
||||
<LoginModal />
|
||||
<SignupModal />
|
||||
|
||||
<!-- 토스트 (뒤로가기 종료 안내 등) -->
|
||||
<Transition name="toast">
|
||||
<div v-if="toastMsg" class="app-toast" role="status">{{ toastMsg }}</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<!-- 공용 인앱 다이얼로그 (네이티브 alert/confirm/prompt 대체) -->
|
||||
<AppDialog />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -64,7 +225,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;
|
||||
@@ -80,6 +241,156 @@ watch(() => route.fullPath, () => ui.closeSidebar())
|
||||
grid-area: left;
|
||||
}
|
||||
|
||||
.demo-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.55rem 0.9rem;
|
||||
border: 1px solid hsla(160, 100%, 37%, 0.4);
|
||||
border-radius: 8px;
|
||||
background: hsla(160, 100%, 37%, 0.08);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
/* 네트워크 오류/오프라인 배너 — 화면 최상단 고정 */
|
||||
.net-banner {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: calc(env(safe-area-inset-top, 0) + 0.5rem) 0.9rem 0.5rem;
|
||||
background: #c0392b;
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.net-text {
|
||||
word-break: keep-all;
|
||||
}
|
||||
.net-retry {
|
||||
flex: none;
|
||||
padding: 0.25rem 0.7rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.net-retry:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.netbar-enter-active,
|
||||
.netbar-leave-active {
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
.netbar-enter-from,
|
||||
.netbar-leave-to {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 토스트 */
|
||||
.app-toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(72px + env(safe-area-inset-bottom, 0));
|
||||
transform: translateX(-50%);
|
||||
z-index: 2600;
|
||||
max-width: 80vw;
|
||||
padding: 0.6rem 1.1rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.82);
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
.toast-enter-from,
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(8px);
|
||||
}
|
||||
|
||||
/* 치명적 오류 폴백 화면 */
|
||||
.fatal-screen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 4000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
background: var(--color-background);
|
||||
}
|
||||
.fatal-inner {
|
||||
text-align: center;
|
||||
max-width: 360px;
|
||||
}
|
||||
.fatal-emoji {
|
||||
font-size: 2.8rem;
|
||||
}
|
||||
.fatal-title {
|
||||
margin-top: 0.6rem;
|
||||
font-size: 1.2rem;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
.fatal-sub {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.7;
|
||||
line-height: 1.6;
|
||||
word-break: keep-all;
|
||||
}
|
||||
.fatal-btn {
|
||||
margin-top: 1.4rem;
|
||||
padding: 0.7rem 1.8rem;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: hsla(160, 100%, 37%, 1);
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.fatal-btn:hover {
|
||||
background: hsla(160, 100%, 32%, 1);
|
||||
}
|
||||
.demo-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.demo-bar button {
|
||||
padding: 0.35rem 0.8rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.db-login {
|
||||
border: 1px solid hsla(160, 100%, 37%, 1);
|
||||
background: hsla(160, 100%, 37%, 1);
|
||||
color: #fff;
|
||||
}
|
||||
.db-exit {
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.layout-body {
|
||||
grid-area: body;
|
||||
padding: 1.5rem 2rem;
|
||||
@@ -92,6 +403,21 @@ watch(() => route.fullPath, () => ui.closeSidebar())
|
||||
grid-area: bottom;
|
||||
}
|
||||
|
||||
/* 하단 내비(56px) 바로 위 고정 광고 */
|
||||
.app-ad {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(56px + env(safe-area-inset-bottom, 0));
|
||||
z-index: 999;
|
||||
background: var(--color-background);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
/* 광고가 있을 땐 본문 하단 여백을 광고 높이(320x50 배너)만큼 더 확보 */
|
||||
.layout.has-ad .layout-body {
|
||||
padding-bottom: calc(56px + 62px + env(safe-area-inset-bottom, 0) + 1rem);
|
||||
}
|
||||
|
||||
/* 로그인 전: 사이드바 없이 단일 컬럼 — 콘텐츠가 창 중앙에 오도록 */
|
||||
.layout.no-sidebar {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import http from './http'
|
||||
import { demo, demoApi } from '@/demo'
|
||||
|
||||
// 백엔드 /api/account 엔드포인트와 매핑 (본인 데이터만)
|
||||
export const accountApi = {
|
||||
const realApi = {
|
||||
list({ year, month, type, category, walletId, keyword, tagId } = {}) {
|
||||
return http.get('/account/entries', { params: { year, month, type, category, walletId, keyword, tagId } })
|
||||
},
|
||||
@@ -14,8 +15,8 @@ export const accountApi = {
|
||||
categoryStats({ type, year, month } = {}) {
|
||||
return http.get('/account/category-stats', { params: { type, year, month } })
|
||||
},
|
||||
netWorthTrend({ months } = {}) {
|
||||
return http.get('/account/networth/trend', { params: { months } })
|
||||
netWorthTrend({ months, weeks, unit } = {}) {
|
||||
return http.get('/account/networth/trend', { params: { months, weeks, unit } })
|
||||
},
|
||||
create(payload) {
|
||||
return http.post('/account/entries', payload)
|
||||
@@ -26,6 +27,25 @@ export const accountApi = {
|
||||
remove(id) {
|
||||
return http.delete(`/account/entries/${id}`)
|
||||
},
|
||||
// 문자/푸시 텍스트 파싱(저장 안 함) → 추가 폼 자동채움
|
||||
parseText({ title, text } = {}) {
|
||||
return http.post('/account/entries/parse', { title, text })
|
||||
},
|
||||
// 환율 조회 — 외화 결제 입력 시 통화→원화 환율 자동 채움
|
||||
fxRate(from, to = 'KRW') {
|
||||
return http.get('/fx/rate', { params: { from, to } })
|
||||
},
|
||||
|
||||
// 자주 쓰는 내역(빠른 등록 템플릿)
|
||||
quickEntries() {
|
||||
return http.get('/account/quick-entries')
|
||||
},
|
||||
createQuickEntry(payload) {
|
||||
return http.post('/account/quick-entries', payload)
|
||||
},
|
||||
removeQuickEntry(id) {
|
||||
return http.delete(`/account/quick-entries/${id}`)
|
||||
},
|
||||
|
||||
// 계좌/카드 (사용자별)
|
||||
wallets() {
|
||||
@@ -56,8 +76,12 @@ export const accountApi = {
|
||||
},
|
||||
|
||||
// 예산 (사용자별)
|
||||
budgets() {
|
||||
return http.get('/account/budgets')
|
||||
budgets({ year, month } = {}) {
|
||||
return http.get('/account/budgets', { params: { year, month } })
|
||||
},
|
||||
// 다른 월 예산을 이 달로 복사(전월 복사 등)
|
||||
copyBudget({ fromYear, fromMonth, toYear, toMonth }) {
|
||||
return http.post('/account/budgets/copy', null, { params: { fromYear, fromMonth, toYear, toMonth } })
|
||||
},
|
||||
budgetStatus({ year, month }) {
|
||||
return http.get('/account/budgets/status', { params: { year, month } })
|
||||
@@ -71,8 +95,8 @@ export const accountApi = {
|
||||
setExpectedIncome({ year, month, expectedIncome }) {
|
||||
return http.put('/account/budgets/income', { expectedIncome }, { params: { year, month } })
|
||||
},
|
||||
createBudget(payload) {
|
||||
return http.post('/account/budgets', payload)
|
||||
createBudget(payload, { year, month } = {}) {
|
||||
return http.post('/account/budgets', payload, { params: { year, month } })
|
||||
},
|
||||
updateBudget(id, payload) {
|
||||
return http.put(`/account/budgets/${id}`, payload)
|
||||
@@ -173,6 +197,11 @@ export const accountApi = {
|
||||
return http.post(`/account/entries/${id}/confirm`, payload || {})
|
||||
},
|
||||
|
||||
// 데이터 복구(가져오기) — 전체 데이터 덮어쓰기
|
||||
restore(payload) {
|
||||
return http.post('/account/restore', payload)
|
||||
},
|
||||
|
||||
// 영수증 OCR (백엔드가 Google Vision 호출 → 전체 텍스트 반환)
|
||||
ocrReceipt(blob) {
|
||||
const fd = new FormData()
|
||||
@@ -183,3 +212,38 @@ export const accountApi = {
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ===== 둘러보기(데모) 모드 =====
|
||||
// demo.on 이면 읽기는 더미 데이터, 쓰기는 차단(안내 메시지)으로 응답한다.
|
||||
const DEMO_READS = {
|
||||
list: demoApi.list,
|
||||
summary: demoApi.summary,
|
||||
wallets: demoApi.wallets,
|
||||
netWorth: demoApi.netWorth,
|
||||
categories: demoApi.categories,
|
||||
recurrings: demoApi.recurrings,
|
||||
quickEntries: demoApi.quickEntries,
|
||||
pendingCount: demoApi.pendingCount,
|
||||
tags: demoApi.tags,
|
||||
walletEntries: demoApi.walletEntries,
|
||||
}
|
||||
const DEMO_WRITES = new Set([
|
||||
'create', 'update', 'remove', 'parseText', 'createQuickEntry', 'removeQuickEntry', 'repayment',
|
||||
'createRecurring', 'updateRecurring', 'removeRecurring', 'runRecurrings',
|
||||
'createWallet', 'updateWallet', 'removeWallet', 'reorderWallets',
|
||||
'createCategory', 'updateCategory', 'removeCategory', 'reorderCategories', 'importCategories',
|
||||
'createTag', 'updateTag', 'removeTag', 'reorderTags', 'confirmEntry', 'ocrReceipt', 'restore',
|
||||
'createBudget', 'updateBudget', 'removeBudget', 'setExpectedIncome',
|
||||
'createHolding', 'updateHolding', 'removeHolding', 'addTrade', 'updateTrade', 'removeTrade',
|
||||
'refreshPrices', 'refreshAllPrices',
|
||||
])
|
||||
|
||||
export const accountApi = new Proxy(realApi, {
|
||||
get(target, prop) {
|
||||
if (demo.on) {
|
||||
if (DEMO_READS[prop]) return DEMO_READS[prop]
|
||||
if (DEMO_WRITES.has(prop)) return () => demoApi.block()
|
||||
}
|
||||
return target[prop]
|
||||
},
|
||||
})
|
||||
|
||||
@@ -23,11 +23,22 @@ export const adminApi = {
|
||||
removeTag(id) {
|
||||
return http.delete(`/admin/tags/${id}`)
|
||||
},
|
||||
getBoardSetting() {
|
||||
return http.get('/admin/board-setting')
|
||||
|
||||
// 기본(디폴트) 분류 관리 (관리자) — 사용자가 '기본 분류 불러오기'로 가져감
|
||||
defaultCategories() {
|
||||
return http.get('/admin/default-categories')
|
||||
},
|
||||
setBoardSetting(tagCategoryId) {
|
||||
return http.put('/admin/board-setting', { tagCategoryId })
|
||||
createDefaultCategory(payload) {
|
||||
return http.post('/admin/default-categories', payload)
|
||||
},
|
||||
updateDefaultCategory(id, payload) {
|
||||
return http.put(`/admin/default-categories/${id}`, payload)
|
||||
},
|
||||
removeDefaultCategory(id) {
|
||||
return http.delete(`/admin/default-categories/${id}`)
|
||||
},
|
||||
reorderDefaultCategories(type, ids) {
|
||||
return http.put('/admin/default-categories/reorder', { type, ids })
|
||||
},
|
||||
|
||||
// 앱 설정 (관리자)
|
||||
@@ -45,6 +56,9 @@ export const adminApi = {
|
||||
updateMemberRole(id, role) {
|
||||
return http.put(`/admin/members/${id}/role`, { role })
|
||||
},
|
||||
updateMemberPlan(id, plan) {
|
||||
return http.put(`/admin/members/${id}/plan`, { plan })
|
||||
},
|
||||
updateMemberStatus(id, status) {
|
||||
return http.put(`/admin/members/${id}/status`, { status })
|
||||
},
|
||||
|
||||
@@ -11,6 +11,13 @@ export const authApi = {
|
||||
login(payload) {
|
||||
return http.post('/auth/login', payload)
|
||||
},
|
||||
// 구글 로그인/가입 — GIS에서 받은 ID 토큰 전달
|
||||
googleLogin(payload) {
|
||||
return http.post('/auth/google', payload)
|
||||
},
|
||||
googleClientId() {
|
||||
return http.get('/auth/google-client-id')
|
||||
},
|
||||
logout() {
|
||||
return http.post('/auth/logout')
|
||||
},
|
||||
@@ -28,4 +35,24 @@ export const authApi = {
|
||||
updateProfile(payload) {
|
||||
return http.put('/auth/profile', payload)
|
||||
},
|
||||
// 프로필 사진 변경/해제 — image 가 null/빈값이면 해제(구글 사진으로 폴백)
|
||||
updateProfileImage(image) {
|
||||
return http.put('/auth/profile-image', { image })
|
||||
},
|
||||
// 현재 사용자 활동 포인트 (최신값)
|
||||
points() {
|
||||
return http.get('/auth/points')
|
||||
},
|
||||
// 포인트 적립/차감 내역 (최신순)
|
||||
pointHistory() {
|
||||
return http.get('/auth/point-history')
|
||||
},
|
||||
// 현재 사용자 전체 프로필(아바타·포인트 포함) — 재로그인 없이 최신값 동기화
|
||||
profile() {
|
||||
return http.get('/auth/profile')
|
||||
},
|
||||
// 회원 탈퇴 — 계정·데이터 전체 삭제
|
||||
withdraw() {
|
||||
return http.delete('/auth/me')
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import http from './http'
|
||||
|
||||
// 백엔드 /api/billing 엔드포인트와 매핑
|
||||
export const billingApi = {
|
||||
// 구독 상품 목록
|
||||
products() {
|
||||
return http.get('/billing/products')
|
||||
},
|
||||
// Google Play 구매 검증 → 멤버십 부여
|
||||
verifyGoogle(payload) {
|
||||
return http.post('/billing/google/verify', payload)
|
||||
},
|
||||
// 현재 구독 현황 (플랜·만료일·다음 결제일·자동갱신)
|
||||
subscription() {
|
||||
return http.get('/billing/subscription')
|
||||
},
|
||||
// 구독 해지 (자동 갱신 중단)
|
||||
cancel() {
|
||||
return http.post('/billing/cancel')
|
||||
},
|
||||
// 구독 재개 (만료 전 자동 갱신 재개)
|
||||
resume() {
|
||||
return http.post('/billing/resume')
|
||||
},
|
||||
// 구매 복원 (기기 변경·재설치)
|
||||
restore() {
|
||||
return http.post('/billing/restore')
|
||||
},
|
||||
}
|
||||
@@ -17,6 +17,13 @@ export const boardApi = {
|
||||
get(id) {
|
||||
return http.get(`/board/posts/${id}`)
|
||||
},
|
||||
// 내 글/댓글 모아보기
|
||||
myPosts({ page = 1, size = 10 } = {}) {
|
||||
return http.get('/board/my/posts', { params: { page, size } })
|
||||
},
|
||||
myComments({ page = 1, size = 10 } = {}) {
|
||||
return http.get('/board/my/comments', { params: { page, size } })
|
||||
},
|
||||
create(payload) {
|
||||
return http.post('/board/posts', payload)
|
||||
},
|
||||
@@ -26,11 +33,20 @@ export const boardApi = {
|
||||
remove(id) {
|
||||
return http.delete(`/board/posts/${id}`)
|
||||
},
|
||||
tags() {
|
||||
return http.get('/board/tags')
|
||||
// 본문 인라인 이미지 업로드(서버 DB 저장) → { url } (예: /api/images/123)
|
||||
uploadImage(blob) {
|
||||
const fd = new FormData()
|
||||
fd.append('image', blob, 'image.jpg')
|
||||
return http.post('/board/images', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 30000,
|
||||
})
|
||||
},
|
||||
tagGroups() {
|
||||
return http.get('/board/tag-groups')
|
||||
tags(category) {
|
||||
return http.get('/board/tags', { params: { category: category || undefined } })
|
||||
},
|
||||
tagGroups(category) {
|
||||
return http.get('/board/tag-groups', { params: { category: category || undefined } })
|
||||
},
|
||||
addComment(id, content) {
|
||||
return http.post(`/board/posts/${id}/comments`, { content })
|
||||
@@ -38,10 +54,39 @@ export const boardApi = {
|
||||
removeComment(commentId) {
|
||||
return http.delete(`/board/comments/${commentId}`)
|
||||
},
|
||||
// 추천/비추천 (토글) — type: 'UP' | 'DOWN'
|
||||
votePost(id, type) {
|
||||
return http.post(`/board/posts/${id}/vote`, { type })
|
||||
},
|
||||
voteComment(commentId, type) {
|
||||
return http.post(`/board/comments/${commentId}/vote`, { type })
|
||||
},
|
||||
// 추천(👍)한 사람 목록
|
||||
recommenders(id) {
|
||||
return http.get(`/board/posts/${id}/recommenders`)
|
||||
},
|
||||
// 신고 (누적 5건 시 블라인드)
|
||||
reportPost(id) {
|
||||
return http.post(`/board/posts/${id}/report`)
|
||||
},
|
||||
reportComment(commentId) {
|
||||
return http.post(`/board/comments/${commentId}/report`)
|
||||
},
|
||||
// 댓글 블라인드 해제 (관리자)
|
||||
unblockComment(commentId) {
|
||||
return http.post(`/board/comments/${commentId}/unblock`)
|
||||
},
|
||||
block(id, reason) {
|
||||
return http.post(`/board/posts/${id}/block`, { reason })
|
||||
},
|
||||
unblock(id) {
|
||||
return http.post(`/board/posts/${id}/unblock`)
|
||||
},
|
||||
// 공지 설정/해제 (관리자) — 목록 최상단 고정
|
||||
setNotice(id) {
|
||||
return http.post(`/board/posts/${id}/notice`)
|
||||
},
|
||||
unsetNotice(id) {
|
||||
return http.post(`/board/posts/${id}/unnotice`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@ http.interceptors.response.use(
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('auth_user')
|
||||
window.dispatchEvent(new CustomEvent('auth:unauthorized'))
|
||||
} else if (status === 403 && error.response?.data?.code === 'PREMIUM_REQUIRED') {
|
||||
// 유료 전용 API 를 무료 회원이 호출(우회/직접 접근) — 업그레이드 안내
|
||||
window.dispatchEvent(new CustomEvent('premium:required'))
|
||||
} else if (!error.response && error.code !== 'ERR_CANCELED') {
|
||||
// 응답 자체가 없음 = 네트워크 오류(오프라인/타임아웃/서버 연결 불가). 취소 요청은 제외.
|
||||
window.dispatchEvent(new CustomEvent('net:error'))
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
|
||||
@@ -34,10 +34,26 @@
|
||||
--color-text: var(--vt-c-text-light-1);
|
||||
|
||||
--section-gap: 160px;
|
||||
/* 모든 화면 본문 공통 폭 (화면별 제각각이던 max-width 를 일관되게) */
|
||||
--page-max: 760px;
|
||||
}
|
||||
|
||||
/* 다크 변수 묶음 — 명시적 다크(data-theme=dark)와 시스템 다크에서 공통 사용 */
|
||||
:root[data-theme='dark'] {
|
||||
--color-background: var(--vt-c-black);
|
||||
--color-background-soft: var(--vt-c-black-soft);
|
||||
--color-background-mute: var(--vt-c-black-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-dark-2);
|
||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-dark-1);
|
||||
--color-text: var(--vt-c-text-dark-1);
|
||||
}
|
||||
|
||||
/* 시스템 설정 따름(테마 미지정 = 시스템). 명시적 라이트/다크가 있으면 그게 우선 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
:root:not([data-theme]) {
|
||||
--color-background: var(--vt-c-black);
|
||||
--color-background-soft: var(--vt-c-black-soft);
|
||||
--color-background-mute: var(--vt-c-black-mute);
|
||||
@@ -46,7 +62,7 @@
|
||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-dark-1);
|
||||
--color-text: var(--vt-c-text-dark-2);
|
||||
--color-text: var(--vt-c-text-dark-1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,32 @@
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* 모든 화면 본문 폭 통일 — 각 뷰가 제각각 두던 root max-width 를 한 곳에서 일관되게.
|
||||
(본문 직계 자식 = 각 화면 루트 컨테이너) scoped 스타일을 덮기 위해 !important 사용. */
|
||||
.layout-body > * {
|
||||
max-width: var(--page-max) !important;
|
||||
margin-left: auto !important;
|
||||
margin-right: auto !important;
|
||||
}
|
||||
|
||||
/* 폼 요소 폰트 상속(표준 리셋) — 네이티브 input/select/textarea 가 서로 폰트·높이 달라
|
||||
보이던 이질감 해소. (개별 화면 scoped 스타일이 더 구체적이면 그쪽이 우선) */
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
/* scoped 스타일이 없는 select 의 기본 모양을 input 과 맞춤 */
|
||||
select {
|
||||
box-sizing: border-box;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* 네이티브 앱(Capacitor) safe-area: 상단 상태바 / 하단 제스처바 영역 확보 */
|
||||
html.is-native .layout-top {
|
||||
/* 상태바 높이(safe-area)에 더해 여유 간격을 줘서 헤더가 상태바에 붙지 않게 */
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
// Capacitor 네이티브(안드로이드) 통합. 웹에서는 no-op.
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { App as CapApp } from '@capacitor/app'
|
||||
import { handleBack } from '@/composables/appBack'
|
||||
|
||||
export function setupCapacitor(router) {
|
||||
if (!Capacitor.isNativePlatform()) return
|
||||
|
||||
// 안드로이드 하드웨어 뒤로가기 → 라우터 뒤로, 더 못 가면 앱 종료
|
||||
CapApp.addListener('backButton', ({ canGoBack }) => {
|
||||
if (canGoBack && window.history.length > 1) {
|
||||
router.back()
|
||||
} else {
|
||||
CapApp.exitApp()
|
||||
}
|
||||
})
|
||||
// 안드로이드 하드웨어 뒤로가기 → 하단 내비 뒤로 버튼과 동일한 통합 처리
|
||||
CapApp.addListener('backButton', () => handleBack(router))
|
||||
|
||||
// 네이티브 표시용 클래스 (safe-area 등 스타일 분기)
|
||||
document.documentElement.classList.add('is-native')
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { ADS_ENABLED } from '@/config/features'
|
||||
|
||||
// 카카오 애드핏 배너. 광고 기능이 켜져 있고 유료(PREMIUM) 회원이 아닐 때만 표시한다.
|
||||
const props = defineProps({
|
||||
unit: { type: String, default: 'DAN-n0I5XRBOwAdSFbEX' },
|
||||
width: { type: Number, default: 728 },
|
||||
height: { type: Number, default: 90 },
|
||||
})
|
||||
|
||||
const auth = useAuthStore()
|
||||
const showAd = computed(() => ADS_ENABLED && !auth.isPremium)
|
||||
|
||||
const host = ref(null)
|
||||
const ADFIT_SRC = '//t1.kakaocdn.net/kas/static/ba.min.js'
|
||||
|
||||
onMounted(() => {
|
||||
if (!showAd.value || !host.value) return
|
||||
// SPA 에서 동적 삽입 시 애드핏이 인식하도록 ins 를 직접 만들고 로더를 다시 실행한다.
|
||||
const ins = document.createElement('ins')
|
||||
ins.className = 'kakao_ad_area'
|
||||
ins.style.display = 'none'
|
||||
ins.setAttribute('data-ad-unit', props.unit)
|
||||
ins.setAttribute('data-ad-width', String(props.width))
|
||||
ins.setAttribute('data-ad-height', String(props.height))
|
||||
host.value.appendChild(ins)
|
||||
|
||||
const s = document.createElement('script')
|
||||
s.type = 'text/javascript'
|
||||
s.src = ADFIT_SRC
|
||||
s.async = true
|
||||
host.value.appendChild(s)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="showAd" ref="host" class="ad-banner" :style="{ minHeight: height + 'px' }"></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ad-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
overflow: hidden; /* 좁은 화면에서 가로 넘침 방지 */
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { appLock, PIN_LENGTH } from '@/composables/appLock'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import PinPad from '@/components/PinPad.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const pin = ref('')
|
||||
const error = ref(false)
|
||||
|
||||
async function onComplete(p) {
|
||||
if (await appLock.verify(p)) {
|
||||
pin.value = ''
|
||||
error.value = false
|
||||
appLock.unlock()
|
||||
} else {
|
||||
error.value = true
|
||||
pin.value = ''
|
||||
}
|
||||
}
|
||||
function onInput(v) {
|
||||
pin.value = v
|
||||
error.value = false
|
||||
}
|
||||
async function forgot() {
|
||||
// PIN 분실 → 잠금 해제 + 로그아웃(재로그인 후 다시 설정). 갇힘 방지.
|
||||
appLock.disable()
|
||||
await auth.logout()
|
||||
appLock.unlock()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lock-screen">
|
||||
<div class="lock-inner">
|
||||
<div class="lock-logo">🐷</div>
|
||||
<h1 class="lock-title">돈돼지 가계부</h1>
|
||||
<p class="lock-sub" :class="{ err: error }">
|
||||
{{ error ? 'PIN이 일치하지 않아요' : 'PIN을 입력하세요' }}
|
||||
</p>
|
||||
<PinPad :value="pin" :length="PIN_LENGTH" class="lock-pad" @update:value="onInput" @complete="onComplete" />
|
||||
<button type="button" class="lock-forgot" @click="forgot">PIN을 잊으셨나요? 로그아웃</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lock-screen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 3000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: calc(env(safe-area-inset-top, 0) + 1rem) 1rem calc(env(safe-area-inset-bottom, 0) + 1rem);
|
||||
background: var(--color-background);
|
||||
}
|
||||
.lock-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.lock-logo {
|
||||
font-size: 2.6rem;
|
||||
}
|
||||
.lock-title {
|
||||
margin-top: 0.4rem;
|
||||
font-size: 1.25rem;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
.lock-sub {
|
||||
margin-top: 0.6rem;
|
||||
margin-bottom: 1.8rem;
|
||||
font-size: 0.92rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.lock-sub.err {
|
||||
color: #c0392b;
|
||||
opacity: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
.lock-forgot {
|
||||
margin-top: 2rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
opacity: 0.6;
|
||||
font-size: 0.85rem;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lock-forgot:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
@@ -6,10 +6,14 @@ import { setActivePinia, createPinia } from 'pinia'
|
||||
const { pushMock } = vi.hoisted(() => ({ pushMock: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: pushMock }) }))
|
||||
|
||||
// 아이디 로그인 플로우 검증용 — 운영에선 숨김(ID_LOGIN_ENABLED=false)이지만 로직은 유지되므로 켜서 테스트
|
||||
vi.mock('@/config/features', () => ({ ID_LOGIN_ENABLED: true }))
|
||||
|
||||
vi.mock('@/api/authApi', () => ({
|
||||
authApi: {
|
||||
login: vi.fn(),
|
||||
signupEnabled: vi.fn(() => Promise.resolve({ enabled: true })),
|
||||
googleClientId: vi.fn(() => Promise.resolve({ clientId: '' })),
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script setup>
|
||||
import { reactive, ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { reactive, ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
import { authApi } from '@/api/authApi'
|
||||
import { isNativeGoogle, nativeGoogleIdToken } from '@/native/googleAuth'
|
||||
import { ID_LOGIN_ENABLED } from '@/config/features'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
@@ -20,6 +23,103 @@ const form = reactive({ loginId: '', password: '', rememberMe: true })
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// ===== 구글 로그인 =====
|
||||
// 웹/Electron: GIS(웹) 렌더 버튼. 앱(안드로이드): 네이티브 Credential Manager 버튼.
|
||||
const isNative = isNativeGoogle()
|
||||
const GIS_SRC = 'https://accounts.google.com/gsi/client'
|
||||
const googleClientId = ref('') // 백엔드에서 받은 OAuth 웹 클라이언트 ID (없으면 버튼 숨김)
|
||||
const googleBtn = ref(null) // 구글 버튼이 그려질 컨테이너(웹 GIS)
|
||||
let gisScriptPromise = null // GIS 스크립트는 한 번만 로드
|
||||
|
||||
// ID 토큰으로 세션 발급 → 닫기/이동 (웹·네이티브 공통)
|
||||
async function finishGoogleLogin(idToken) {
|
||||
if (!idToken) return
|
||||
error.value = null
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.googleLogin(idToken, form.rememberMe)
|
||||
const redirect = ui.redirectPath
|
||||
ui.closeLogin()
|
||||
router.push(redirect || '/')
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '구글 로그인에 실패했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 앱(안드로이드) 네이티브 구글 로그인 버튼 클릭
|
||||
async function handleNativeGoogle() {
|
||||
if (loading.value || !googleClientId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const idToken = await nativeGoogleIdToken(googleClientId.value)
|
||||
loading.value = false
|
||||
await finishGoogleLogin(idToken)
|
||||
} catch (e) {
|
||||
loading.value = false
|
||||
// 사용자가 취소한 경우(메시지 없음)는 조용히 무시
|
||||
const msg = e?.message || ''
|
||||
if (msg && !/cancel/i.test(msg)) error.value = '구글 로그인에 실패했습니다.'
|
||||
}
|
||||
}
|
||||
|
||||
// GIS 스크립트 로드 (한 번만). 실패하면 reject.
|
||||
function loadGisScript() {
|
||||
if (window.google?.accounts?.id) return Promise.resolve()
|
||||
if (gisScriptPromise) return gisScriptPromise
|
||||
gisScriptPromise = new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script')
|
||||
s.src = GIS_SRC
|
||||
s.async = true
|
||||
s.defer = true
|
||||
s.onload = () => resolve()
|
||||
s.onerror = () => {
|
||||
gisScriptPromise = null
|
||||
reject(new Error('GIS load failed'))
|
||||
}
|
||||
document.head.appendChild(s)
|
||||
})
|
||||
return gisScriptPromise
|
||||
}
|
||||
|
||||
// 웹 GIS 자격증명(ID 토큰) 수신 → 공통 처리
|
||||
function onGoogleCredential(response) {
|
||||
finishGoogleLogin(response?.credential)
|
||||
}
|
||||
|
||||
// 모달 열릴 때 구글 버튼 준비. 클라이언트 ID 미설정/스크립트 실패 시 조용히 숨김.
|
||||
async function setupGoogle() {
|
||||
try {
|
||||
if (!googleClientId.value) {
|
||||
const { clientId } = await authApi.googleClientId()
|
||||
googleClientId.value = clientId || ''
|
||||
}
|
||||
if (!googleClientId.value) return // 서버에 미설정 → 구글 버튼 미노출
|
||||
if (isNative) return // 앱: 네이티브 버튼(템플릿)으로 처리 — GIS 스크립트 불필요
|
||||
await loadGisScript()
|
||||
await nextTick()
|
||||
if (!ui.loginOpen || !googleBtn.value) return
|
||||
window.google.accounts.id.initialize({
|
||||
client_id: googleClientId.value,
|
||||
callback: onGoogleCredential,
|
||||
})
|
||||
googleBtn.value.innerHTML = ''
|
||||
window.google.accounts.id.renderButton(googleBtn.value, {
|
||||
type: 'standard',
|
||||
theme: 'outline',
|
||||
size: 'large',
|
||||
shape: 'rectangular',
|
||||
text: 'continue_with',
|
||||
logo_alignment: 'center',
|
||||
width: 300,
|
||||
})
|
||||
} catch {
|
||||
// 구글 로그인 준비 실패 — 일반 로그인은 그대로 사용 가능하므로 버튼만 숨김
|
||||
googleClientId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 팝업이 열릴 때마다 입력값 초기화 (로그인 상태 유지는 기본 켜둠)
|
||||
watch(
|
||||
() => ui.loginOpen,
|
||||
@@ -29,6 +129,7 @@ watch(
|
||||
form.password = ''
|
||||
form.rememberMe = true
|
||||
error.value = null
|
||||
setupGoogle()
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -73,7 +174,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
<button class="close" type="button" aria-label="닫기" @click="ui.closeLogin()">×</button>
|
||||
<h2>로그인</h2>
|
||||
|
||||
<form class="form" @submit.prevent="handleLogin">
|
||||
<form v-if="ID_LOGIN_ENABLED" class="form" @submit.prevent="handleLogin">
|
||||
<input v-model="form.loginId" type="text" placeholder="아이디" autocomplete="username" :disabled="loading" />
|
||||
<input v-model="form.password" type="password" placeholder="비밀번호" autocomplete="current-password" :disabled="loading" />
|
||||
<label class="remember">
|
||||
@@ -85,11 +186,33 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
|
||||
<!-- 구글 로그인 — 서버에 클라이언트 ID 가 설정된 경우에만 노출 -->
|
||||
<div v-show="googleClientId" class="google-wrap">
|
||||
<div v-if="ID_LOGIN_ENABLED" class="divider"><span>또는</span></div>
|
||||
<!-- 앱(안드로이드): 네이티브 버튼 / 웹·PC: GIS 렌더 버튼 -->
|
||||
<button
|
||||
v-if="isNative"
|
||||
type="button"
|
||||
class="google-native"
|
||||
:disabled="loading"
|
||||
@click="handleNativeGoogle"
|
||||
>
|
||||
<svg class="g-logo" viewBox="0 0 18 18" width="18" height="18" aria-hidden="true">
|
||||
<path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.71-1.57 2.68-3.89 2.68-6.62z"/>
|
||||
<path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.81.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18z"/>
|
||||
<path fill="#FBBC05" d="M3.97 10.72a5.41 5.41 0 0 1 0-3.44V4.95H.96a9 9 0 0 0 0 8.1l3.01-2.33z"/>
|
||||
<path fill="#EA4335" d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.58A9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58z"/>
|
||||
</svg>
|
||||
Google 계정으로 계속하기
|
||||
</button>
|
||||
<div v-else ref="googleBtn" class="google-btn"></div>
|
||||
</div>
|
||||
|
||||
<button v-if="SOCIAL_LOGIN_ENABLED" type="button" class="naver" :disabled="loading" @click="handleNaverLogin">
|
||||
네이버로 로그인 (준비 중)
|
||||
</button>
|
||||
|
||||
<p class="links">
|
||||
<p v-if="ID_LOGIN_ENABLED" class="links">
|
||||
<template v-if="ui.signupEnabled">
|
||||
계정이 없으신가요?
|
||||
<a href="#" @click.prevent="goSignup">회원가입</a>
|
||||
@@ -185,6 +308,53 @@ h2 {
|
||||
border-color: #03c75a;
|
||||
color: #03c75a;
|
||||
}
|
||||
.google-wrap {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.google-btn {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.google-native {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
margin: 0 auto;
|
||||
padding: 0.6rem 1rem;
|
||||
border: 1px solid #747775;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #1f1f1f;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.google-native:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.google-native .g-logo {
|
||||
flex: none;
|
||||
}
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin: 0.75rem 0;
|
||||
color: var(--color-text);
|
||||
opacity: 0.55;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
.links {
|
||||
margin-top: 1.25rem;
|
||||
text-align: center;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup>
|
||||
// 재사용 PIN 키패드 — 점 표시 + 숫자 키패드. 길이 도달 시 complete 방출.
|
||||
const props = defineProps({
|
||||
value: { type: String, default: '' },
|
||||
length: { type: Number, default: 4 },
|
||||
})
|
||||
const emit = defineEmits(['update:value', 'complete'])
|
||||
|
||||
function press(d) {
|
||||
if (props.value.length >= props.length) return
|
||||
const next = props.value + d
|
||||
emit('update:value', next)
|
||||
if (next.length === props.length) emit('complete', next)
|
||||
}
|
||||
function del() {
|
||||
emit('update:value', props.value.slice(0, -1))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pinpad">
|
||||
<div class="dots">
|
||||
<span v-for="i in length" :key="i" class="dot" :class="{ on: value.length >= i }"></span>
|
||||
</div>
|
||||
<div class="keys">
|
||||
<button v-for="n in 9" :key="n" type="button" class="key" @click="press(String(n))">{{ n }}</button>
|
||||
<span class="key empty" aria-hidden="true"></span>
|
||||
<button type="button" class="key" @click="press('0')">0</button>
|
||||
<button type="button" class="key del" aria-label="지우기" @click="del">⌫</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pinpad {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.6rem;
|
||||
}
|
||||
.dots {
|
||||
display: flex;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
.dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid currentColor;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.dot.on {
|
||||
background: currentColor;
|
||||
opacity: 1;
|
||||
}
|
||||
.keys {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 64px);
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.key {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.key:active {
|
||||
background: var(--color-border);
|
||||
}
|
||||
.key.empty {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
.key.del {
|
||||
font-size: 1.2rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
// 작성자/추천자 아바타. 우선순위: 커스텀(profileImage) > 구글(googlePicture) > 이니셜.
|
||||
const props = defineProps({
|
||||
name: { type: String, default: '' },
|
||||
googlePicture: { type: String, default: '' },
|
||||
profileImage: { type: String, default: '' },
|
||||
size: { type: Number, default: 28 },
|
||||
})
|
||||
|
||||
const broken = ref(false)
|
||||
const src = computed(() => props.profileImage || props.googlePicture || '')
|
||||
const initial = computed(() => {
|
||||
const s = (props.name || '').trim()
|
||||
return s ? s[0].toUpperCase() : '?'
|
||||
})
|
||||
// src 가 바뀌면 깨짐 상태 리셋
|
||||
watch(src, () => { broken.value = false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="user-avatar" :style="{ width: size + 'px', height: size + 'px' }" :title="name">
|
||||
<img
|
||||
v-if="src && !broken"
|
||||
:src="src"
|
||||
:alt="name"
|
||||
referrerpolicy="no-referrer"
|
||||
@error="broken = true"
|
||||
/>
|
||||
<span v-else class="ua-initial" :style="{ fontSize: Math.round(size * 0.45) + 'px' }">{{ initial }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
vertical-align: middle;
|
||||
background: var(--color-background-mute);
|
||||
}
|
||||
.user-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.ua-initial {
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router'
|
||||
import AdBanner from '@/components/AdBanner.vue'
|
||||
// PC·모바일 웹에서 노출되는 안내 페이지. 실제 이용은 앱(모바일) 또는 PC 데스크톱 클라이언트에서.
|
||||
// PC 설치파일은 서버 /download/ 경로(영속 디렉터리)에서 서빙 — nginx 설정 참고.
|
||||
const PC_DOWNLOAD_URL = '/download/SlimBudget-Setup.exe'
|
||||
const PC_DOWNLOAD_URL = '/download/DonDwaeji-Setup.exe'
|
||||
// 모바일 앱(APK/스토어) 링크는 준비되면 연결
|
||||
const mobileReady = false
|
||||
</script>
|
||||
@@ -9,12 +11,18 @@ const mobileReady = false
|
||||
<template>
|
||||
<main class="web-only">
|
||||
<div class="card">
|
||||
<h1 class="brand">Slim Budget</h1>
|
||||
<h1 class="brand">돈돼지 가계부</h1>
|
||||
<p class="tagline">슬림하게 관리하는 가계부 · 자산 · 예산</p>
|
||||
|
||||
<div class="notice">
|
||||
<p class="lead">🖥️ PC는 <b>설치형 앱</b>으로, 📱 모바일은 <b>앱</b>으로 이용해요.</p>
|
||||
<p class="sub">웹 브라우저에서는 안내만 제공됩니다. 설치한 뒤 로그인해 주세요.</p>
|
||||
<p class="lead">
|
||||
<span class="ln">🖥️ PC는 <b>설치형 앱</b>으로,</span>
|
||||
<span class="ln">📱 모바일은 <b>앱</b>으로 이용해요.</span>
|
||||
</p>
|
||||
<p class="sub">
|
||||
<span class="ln">웹 브라우저에서는 안내만 제공됩니다.</span>
|
||||
<span class="ln">설치한 뒤 로그인해 주세요.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="dl-group">
|
||||
@@ -31,7 +39,18 @@ const mobileReady = false
|
||||
<li><span class="f-icon">🏦</span><b>자산·부채 관리</b><span>계좌·카드·대출·투자까지</span></li>
|
||||
<li><span class="f-icon">🎯</span><b>예산과 분석</b><span>예산 대비 지출·차트</span></li>
|
||||
</ul>
|
||||
|
||||
<nav class="legal-links">
|
||||
<RouterLink to="/privacy">개인정보처리방침</RouterLink>
|
||||
<span aria-hidden="true">·</span>
|
||||
<RouterLink to="/terms">이용약관</RouterLink>
|
||||
<span aria-hidden="true">·</span>
|
||||
<RouterLink to="/account-deletion">계정 삭제</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- 하단 광고 (유료 회원 제외) -->
|
||||
<AdBanner class="web-ad" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -39,13 +58,18 @@ const mobileReady = false
|
||||
.web-only {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.75rem;
|
||||
padding: 1.5rem;
|
||||
background:
|
||||
radial-gradient(120% 120% at 50% 0%, hsla(160, 100%, 37%, 0.12), transparent 60%),
|
||||
var(--color-background);
|
||||
}
|
||||
.web-ad {
|
||||
margin-top: auto; /* 페이지 하단으로 */
|
||||
}
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
@@ -54,6 +78,8 @@ const mobileReady = false
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 16px;
|
||||
background: var(--color-background-soft);
|
||||
/* 한국어는 단어(어절) 단위로 줄바꿈 — 'O요.' 가 혼자 다음 줄로 떨어지는 현상 방지 */
|
||||
word-break: keep-all;
|
||||
}
|
||||
.brand {
|
||||
font-size: 2rem;
|
||||
@@ -76,6 +102,11 @@ const mobileReady = false
|
||||
font-size: 0.86rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
/* 각 구절을 한 덩어리로 — 좁으면 절(span) 경계에서만 줄바꿈, 넓으면 한 줄 */
|
||||
.lead .ln,
|
||||
.sub .ln {
|
||||
display: inline-block;
|
||||
}
|
||||
.dl-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -136,4 +167,18 @@ const mobileReady = false
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.65;
|
||||
}
|
||||
.legal-links {
|
||||
margin-top: 1.4rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.legal-links a {
|
||||
color: var(--color-text);
|
||||
}
|
||||
.legal-links a:hover {
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,6 +5,8 @@ import '@toast-ui/editor/dist/toastui-editor.css'
|
||||
import codeSyntaxHighlight from '@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight-all'
|
||||
import 'prismjs/themes/prism-tomorrow.css'
|
||||
import '@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight.css'
|
||||
import { imageToBlob } from '@/utils/receiptOcr'
|
||||
import { boardApi } from '@/api/boardApi'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
@@ -14,27 +16,66 @@ const props = defineProps({
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
// PC(Electron)는 위지윅(contenteditable)에서 한글 IME 첫 자음이 중복되는 Chromium 버그가 있어
|
||||
// 마크다운(텍스트 기반) 모드로 입력받는다. 웹/모바일(APK)은 위지윅 유지.
|
||||
const isElectron = typeof navigator !== 'undefined' && /electron/i.test(navigator.userAgent)
|
||||
const editType = isElectron ? 'markdown' : props.initialEditType
|
||||
|
||||
const el = ref(null)
|
||||
const imgInput = ref(null)
|
||||
let editor = null
|
||||
// 에디터가 마지막으로 emit 한 값. 부모가 되돌려준 echo 를 구분해 입력 중 setMarkdown 으로 끊기지 않게.
|
||||
let lastEmitted = props.modelValue || ''
|
||||
|
||||
// 이미지: 압축 후 서버 업로드 → URL (base64 대신 본문엔 짧은 URL 만)
|
||||
async function uploadCompressed(fileOrBlob) {
|
||||
const blob = await imageToBlob(fileOrBlob, 1280) // 긴 변 1280px, JPEG 0.85
|
||||
const { url } = await boardApi.uploadImage(blob)
|
||||
return url
|
||||
}
|
||||
|
||||
// 커스텀 이미지 버튼 클릭 → 파일 선택 → 업로드 → 에디터에 삽입
|
||||
// (Toast UI 기본 이미지 팝업이 작은 댓글창에서 잘려 OK 를 못 누르는 문제 회피)
|
||||
async function onImagePick(e) {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!file || !editor) return
|
||||
try {
|
||||
const url = await uploadCompressed(file)
|
||||
const alt = file.name.replace(/\.[^.]+$/, '')
|
||||
editor.exec('addImage', { imageUrl: url, altText: alt })
|
||||
} catch {
|
||||
/* 업로드 실패 시 미삽입 */
|
||||
}
|
||||
}
|
||||
|
||||
function makeImageButton() {
|
||||
const btn = document.createElement('button')
|
||||
btn.type = 'button'
|
||||
btn.className = 'toastui-editor-toolbar-icons image'
|
||||
btn.style.margin = '0'
|
||||
btn.setAttribute('aria-label', '이미지')
|
||||
btn.addEventListener('click', (ev) => {
|
||||
ev.preventDefault()
|
||||
imgInput.value?.click()
|
||||
})
|
||||
return btn
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
editor = new Editor({
|
||||
el: el.value,
|
||||
height: props.height,
|
||||
initialEditType: editType,
|
||||
hideModeSwitch: true,
|
||||
initialEditType: props.initialEditType,
|
||||
hideModeSwitch: true, // 마크다운/위지윅 전환 탭 숨김 (WYSIWYG 전용)
|
||||
plugins: [codeSyntaxHighlight], // 코드 구문 강조
|
||||
initialValue: props.modelValue || '',
|
||||
placeholder: props.placeholder,
|
||||
usageStatistics: false,
|
||||
autofocus: false,
|
||||
// 기본 이미지 버튼(팝업)을 커스텀(바로 업로드)으로 교체
|
||||
toolbarItems: [
|
||||
['heading', 'bold', 'italic', 'strike'],
|
||||
['hr', 'quote'],
|
||||
['ul', 'ol', 'task', 'indent', 'outdent'],
|
||||
['table', { name: 'image', tooltip: '이미지', el: makeImageButton() }, 'link'],
|
||||
['code', 'codeblock'],
|
||||
],
|
||||
events: {
|
||||
change: () => {
|
||||
lastEmitted = editor.getMarkdown()
|
||||
@@ -64,5 +105,28 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="el"></div>
|
||||
<div>
|
||||
<div ref="el"></div>
|
||||
<input ref="imgInput" type="file" accept="image/*" hidden @change="onImagePick" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 전역(unscoped): Toast UI 팝업이 좁은 화면(댓글창 등)에서 좌측으로 넘쳐 잘리는 문제 →
|
||||
모바일에선 화면 중앙 모달로 띄워 항상 보이게 한다. -->
|
||||
<style>
|
||||
@media (max-width: 768px) {
|
||||
.toastui-editor-popup {
|
||||
position: fixed !important;
|
||||
left: 50% !important;
|
||||
top: 50% !important;
|
||||
right: auto !important;
|
||||
/* Toast UI 가 max-width:480px 에서 margin-left:-150px 를 줘서 좌측으로 밀려 잘림 → 취소 */
|
||||
margin: 0 !important;
|
||||
transform: translate(-50%, -50%) !important;
|
||||
max-width: 94vw !important;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
z-index: 1300;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { handleBack } from '@/composables/appBack'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -9,9 +10,8 @@ const isHome = computed(() => route.path === '/')
|
||||
const isSettings = computed(() => route.path.startsWith('/settings'))
|
||||
|
||||
function goBack() {
|
||||
// 히스토리가 있으면 뒤로, 없으면 홈으로 (앱 첫 화면에서 빠져나가지 않게)
|
||||
if (window.history.length > 1) router.back()
|
||||
else router.push('/')
|
||||
// 하드웨어 뒤로가기와 동일 동작(오버레이 닫기 → 화면 뒤로 → 최상위 종료 안내)
|
||||
handleBack(router)
|
||||
}
|
||||
function goForward() {
|
||||
router.forward()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<template>
|
||||
<footer class="app-footer">
|
||||
<span>© 2026 SlimBudget. All rights reserved.</span>
|
||||
<span>© 2026 돈돼지 가계부. All rights reserved.</span>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,12 +1,43 @@
|
||||
<script setup>
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink, useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
import { demo } from '@/demo'
|
||||
import { boardLabel } from '@/constants/boards'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// 라우트별 헤더 타이틀(현재 화면 이름)
|
||||
const TITLES = {
|
||||
home: '돈돼지 가계부',
|
||||
'demo-locked': '둘러보기',
|
||||
'account-entries': '가계부 내역',
|
||||
'account-stats': '통계',
|
||||
'account-recurrings': '고정 지출',
|
||||
'account-wallets': '계좌 관리',
|
||||
'account-categories': '분류 관리',
|
||||
'account-budget': '예산 설정',
|
||||
'account-tags': '태그 관리',
|
||||
'my-board': '내 글·댓글',
|
||||
users: '회원 관리',
|
||||
'admin-tags': '태그 관리(관리자)',
|
||||
'admin-default-categories': '기본 분류 설정',
|
||||
settings: '설정',
|
||||
'settings-account': '계정정보',
|
||||
'settings-account-edit': '가입정보 변경',
|
||||
}
|
||||
const pageTitle = computed(() => {
|
||||
const n = route.name
|
||||
if (n === 'board' || n === 'board-detail') return boardLabel(route.params.category)
|
||||
if (n === 'board-write' || n === 'board-edit') return route.params.id ? '글 수정' : '글쓰기'
|
||||
return TITLES[n] || '돈돼지 가계부'
|
||||
})
|
||||
|
||||
async function handleLogout() {
|
||||
await auth.logout()
|
||||
@@ -17,27 +48,25 @@ async function handleLogout() {
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<button v-if="auth.isAuthenticated" class="hamburger" type="button" aria-label="메뉴" @click="ui.toggleSidebar()">
|
||||
<button v-if="auth.isAuthenticated || demo.on" 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>
|
||||
</button>
|
||||
<RouterLink to="/" class="brand">
|
||||
<svg class="brand-mark" viewBox="0 0 32 32" aria-hidden="true">
|
||||
<rect width="32" height="32" rx="7" fill="#00bc7e" />
|
||||
<g fill="#ffffff">
|
||||
<rect x="7.5" y="17" width="3.4" height="8" rx="1.7" />
|
||||
<rect x="14.3" y="12.5" width="3.4" height="12.5" rx="1.7" />
|
||||
<rect x="21.1" y="8" width="3.4" height="17" rx="1.7" />
|
||||
</g>
|
||||
</svg>
|
||||
<span class="brand-text">SlimBudget</span>
|
||||
</RouterLink>
|
||||
<h1 class="page-title">{{ pageTitle }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<template v-if="auth.isAuthenticated">
|
||||
<span class="username">{{ auth.user?.name || auth.user?.loginId }}님</span>
|
||||
<RouterLink to="/settings/account" class="me" title="계정정보">
|
||||
<UserAvatar
|
||||
:name="auth.user?.name || auth.user?.loginId"
|
||||
:google-picture="auth.user?.googlePicture"
|
||||
:profile-image="auth.user?.profileImage"
|
||||
:size="26"
|
||||
/>
|
||||
<span class="username">{{ auth.user?.name || auth.user?.loginId }}님</span>
|
||||
</RouterLink>
|
||||
<IconBtn icon="logout" title="로그아웃" @click="handleLogout" />
|
||||
</template>
|
||||
<IconBtn v-else icon="login" title="로그인" variant="primary" @click="ui.openLogin()" />
|
||||
@@ -73,33 +102,38 @@ async function handleLogout() {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
.page-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
.brand:hover {
|
||||
background: transparent;
|
||||
}
|
||||
.brand-mark {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.brand-text {
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.me {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.me:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.username {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
/* 좁은 폰 화면: 이름 숨기고 아바타만 */
|
||||
.username {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hamburger {
|
||||
@@ -108,5 +142,9 @@ async function handleLogout() {
|
||||
.app-header {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
/* 좁은 화면(폰/앱): 브랜드명 텍스트 숨기고 로고만 — 두 줄 줄바꿈 방지 */
|
||||
.brand-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,29 +1,77 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
import { BOARDS } from '@/constants/boards'
|
||||
import { demo } from '@/demo'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
// 로그인 또는 둘러보기(데모)면 메뉴 노출. 데모에선 잠금 배지를 함께 표시.
|
||||
const showMenu = computed(() => auth.isAuthenticated || demo.on)
|
||||
// 유료 전용 메뉴 잠금 배지: 데모이거나, 로그인했지만 무료 회원일 때.
|
||||
const lockPremium = computed(() => demo.on || (auth.isAuthenticated && !auth.isPremium))
|
||||
|
||||
// 메뉴 아이콘 (lucide 스타일 인라인 SVG path — 하단 내비와 동일 톤). 값은 정적/신뢰 마크업.
|
||||
const icons = {
|
||||
entries: '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
|
||||
stats: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
|
||||
recurrings: '<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>',
|
||||
wallets: '<rect x="1" y="4" width="22" height="16" rx="2" ry="2"/><line x1="1" y1="10" x2="23" y2="10"/>',
|
||||
categories: '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
|
||||
budget: '<path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/>',
|
||||
tags: '<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>',
|
||||
board: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
||||
users: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
sliders: '<line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/>',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="app-sidebar">
|
||||
<div class="drawer-head">
|
||||
<span class="drawer-title">메뉴</span>
|
||||
<RouterLink to="/" class="brand" @click="ui.closeSidebar()">
|
||||
<img class="brand-mark" src="/logo-piggy.png" alt="" aria-hidden="true" />
|
||||
<span class="brand-text">돈돼지 가계부</span>
|
||||
</RouterLink>
|
||||
<button class="drawer-close" type="button" aria-label="닫기" @click="ui.closeSidebar()">×</button>
|
||||
</div>
|
||||
<nav class="menu">
|
||||
<!-- 가계부 영역 (홈은 하단 내비게이션으로 이동) -->
|
||||
<template v-if="auth.isAuthenticated">
|
||||
<RouterLink to="/account/entries" class="menu-item">가계부 내역</RouterLink>
|
||||
<RouterLink to="/account/stats" class="menu-item">통계</RouterLink>
|
||||
<RouterLink to="/account/recurrings" class="menu-item">고정 지출</RouterLink>
|
||||
<RouterLink to="/account/wallets" class="menu-item">계좌 관리</RouterLink>
|
||||
<RouterLink to="/account/categories" class="menu-item">분류 관리</RouterLink>
|
||||
<RouterLink to="/account/budget" class="menu-item">예산 설정</RouterLink>
|
||||
<RouterLink to="/account/tags" class="menu-item">태그 관리</RouterLink>
|
||||
<template v-if="showMenu">
|
||||
<RouterLink to="/account/entries" class="menu-item">
|
||||
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.entries" />
|
||||
<span>가계부 내역</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/account/stats" class="menu-item">
|
||||
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.stats" />
|
||||
<span>통계</span>
|
||||
<span v-if="lockPremium" class="lock-badge">🔒</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/account/recurrings" class="menu-item">
|
||||
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.recurrings" />
|
||||
<span>고정 지출</span>
|
||||
<span v-if="lockPremium" class="lock-badge">🔒</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/account/wallets" class="menu-item">
|
||||
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.wallets" />
|
||||
<span>계좌 관리</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/account/categories" class="menu-item">
|
||||
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.categories" />
|
||||
<span>분류 관리</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/account/budget" class="menu-item">
|
||||
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.budget" />
|
||||
<span>예산 설정</span>
|
||||
<span v-if="lockPremium" class="lock-badge">🔒</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/account/tags" class="menu-item">
|
||||
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.tags" />
|
||||
<span>태그 관리</span>
|
||||
<span v-if="lockPremium" class="lock-badge">🔒</span>
|
||||
</RouterLink>
|
||||
|
||||
<!-- 게시판 영역 -->
|
||||
<hr class="menu-divider" />
|
||||
@@ -32,14 +80,32 @@ const ui = useUiStore()
|
||||
:key="b.key"
|
||||
:to="`/board/${b.key}`"
|
||||
class="menu-item"
|
||||
>{{ b.label }}</RouterLink>
|
||||
>
|
||||
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.board" />
|
||||
<span>{{ b.label }}</span>
|
||||
<span v-if="demo.on" class="lock-badge">🔒</span>
|
||||
</RouterLink>
|
||||
<RouterLink v-if="auth.isAuthenticated" to="/my-board" class="menu-item">
|
||||
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.entries" />
|
||||
<span>내 글·댓글</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<!-- 관리자 영역 -->
|
||||
<template v-if="auth.user?.role === 'ADMIN'">
|
||||
<hr class="menu-divider" />
|
||||
<RouterLink to="/users" class="menu-item">회원 관리</RouterLink>
|
||||
<RouterLink to="/admin/tags" class="menu-item">태그 관리(관리자)</RouterLink>
|
||||
<RouterLink to="/users" class="menu-item">
|
||||
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.users" />
|
||||
<span>회원 관리</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/admin/tags" class="menu-item">
|
||||
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.tags" />
|
||||
<span>태그 관리(관리자)</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/admin/default-categories" class="menu-item">
|
||||
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.sliders" />
|
||||
<span>기본 분류 설정</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -49,34 +115,55 @@ const ui = useUiStore()
|
||||
.app-sidebar {
|
||||
background: var(--color-background-soft);
|
||||
border-right: 1px solid var(--color-border);
|
||||
padding: 1rem 0;
|
||||
padding: 0 0 1rem;
|
||||
}
|
||||
.drawer-head {
|
||||
display: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px; /* 헤더 높이와 맞춰 상단 정렬 (하단 보더 일치) */
|
||||
padding: 0 1.1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
.brand:hover {
|
||||
background: transparent;
|
||||
}
|
||||
.brand-mark {
|
||||
height: 26px;
|
||||
width: auto;
|
||||
max-width: 34px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.brand-text {
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.drawer-close {
|
||||
display: none; /* 데스크톱: 숨김 (모바일 드로어에서만 노출) */
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.app-sidebar {
|
||||
background: var(--color-background);
|
||||
min-height: 100%;
|
||||
}
|
||||
.drawer-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.drawer-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
.drawer-close {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
.menu {
|
||||
@@ -84,10 +171,27 @@ const ui = useUiStore()
|
||||
flex-direction: column;
|
||||
}
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0.7rem 1.5rem;
|
||||
color: var(--color-text);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.menu-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.lock-badge {
|
||||
margin-left: auto;
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.menu-item.router-link-exact-active .menu-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
.menu-divider {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup>
|
||||
import { nextTick, watch, ref } from 'vue'
|
||||
import { dialogState, dialogConfirm, dialogCancel } from '@/composables/dialog'
|
||||
|
||||
const inputEl = ref(null)
|
||||
// prompt 열릴 때 입력칸 포커스
|
||||
watch(
|
||||
() => dialogState.open,
|
||||
async (open) => {
|
||||
if (open && dialogState.type === 'prompt') {
|
||||
await nextTick()
|
||||
inputEl.value?.focus()
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="dlg-fade">
|
||||
<div v-if="dialogState.open" class="dlg-backdrop" @click.self="dialogCancel">
|
||||
<div class="dlg" role="dialog" aria-modal="true">
|
||||
<h2 class="dlg-title">{{ dialogState.title }}</h2>
|
||||
<p v-if="dialogState.message" class="dlg-msg">{{ dialogState.message }}</p>
|
||||
<input
|
||||
v-if="dialogState.type === 'prompt'"
|
||||
ref="inputEl"
|
||||
v-model="dialogState.inputValue"
|
||||
class="dlg-input"
|
||||
:placeholder="dialogState.placeholder"
|
||||
@keyup.enter="dialogConfirm"
|
||||
/>
|
||||
<div class="dlg-buttons">
|
||||
<button v-if="dialogState.type !== 'alert'" type="button" class="dlg-btn" @click="dialogCancel">
|
||||
{{ dialogState.cancelText }}
|
||||
</button>
|
||||
<button type="button" class="dlg-btn primary" :class="{ danger: dialogState.danger }" @click="dialogConfirm">
|
||||
{{ dialogState.okText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dlg-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 1rem;
|
||||
}
|
||||
.dlg {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
padding: 1.4rem 1.25rem calc(1.25rem + env(safe-area-inset-bottom));
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.dlg-title {
|
||||
font-size: 1.05rem;
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
.dlg-msg {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.dlg-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.5rem 0.7rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
}
|
||||
.dlg-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.dlg-btn {
|
||||
padding: 0.45rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dlg-btn.primary {
|
||||
background: hsla(160, 100%, 37%, 1);
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dlg-btn.primary.danger {
|
||||
background: #c0392b;
|
||||
border-color: #c0392b;
|
||||
}
|
||||
.dlg-fade-enter-active,
|
||||
.dlg-fade-leave-active {
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.dlg-fade-enter-from,
|
||||
.dlg-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -27,6 +27,7 @@ const ICONS = {
|
||||
refresh: ['M23 4v6h-6', 'M1 20v-6h6', 'M3.51 9a9 9 0 0 1 14.85-3.36L23 10', 'M1 14l4.64 4.36A9 9 0 0 0 20.49 15'],
|
||||
filter: ['M22 3H2l8 9.46V19l4 2v-8.54L22 3z'],
|
||||
menu: ['M3 12h18', 'M3 6h18', 'M3 18h18'],
|
||||
list: ['M8 6h13', 'M8 12h13', 'M8 18h13', 'M3 6h.01', 'M3 12h.01', 'M3 18h.01'],
|
||||
back: ['M19 12H5', 'M12 19l-7-7 7-7'],
|
||||
logout: ['M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4', 'M16 17l5-5-5-5', 'M21 12H9'],
|
||||
login: ['M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4', 'M10 17l5-5-5-5', 'M15 12H3'],
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { App as CapApp } from '@capacitor/app'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
import { appLock } from '@/composables/appLock'
|
||||
|
||||
// 통합 뒤로가기 처리 — 하드웨어 뒤로(capacitor.js)와 하단 내비 뒤로 버튼이 동일 동작.
|
||||
// 우선순위:
|
||||
// 1) 잠금 화면이면 우회 불가 → (네이티브) 종료
|
||||
// 2) 열린 전역 오버레이(회원가입/로그인/비번/사이드바) 닫기
|
||||
// 3) 화면 뒤로(히스토리)
|
||||
// 4) 최상위에서 '한 번 더 누르면 종료'(네이티브만, 실수 종료 방지)
|
||||
let exitArmed = false
|
||||
let exitTimer = null
|
||||
|
||||
export function handleBack(router) {
|
||||
const native = Capacitor.isNativePlatform()
|
||||
const ui = useUiStore()
|
||||
|
||||
if (appLock.state.locked) {
|
||||
if (native) CapApp.exitApp()
|
||||
return
|
||||
}
|
||||
|
||||
if (ui.signupOpen) { ui.closeSignup(); return }
|
||||
if (ui.loginOpen) { ui.closeLogin(); return }
|
||||
if (ui.passwordOpen) { ui.closePassword(); return }
|
||||
if (ui.sidebarOpen) { ui.closeSidebar(); return }
|
||||
|
||||
if (window.history.length > 1) { router.back(); return }
|
||||
|
||||
// 최상위(더 갈 곳 없음)
|
||||
if (native) {
|
||||
if (exitArmed) { CapApp.exitApp(); return }
|
||||
exitArmed = true
|
||||
window.dispatchEvent(new CustomEvent('app:toast', { detail: '한 번 더 누르면 종료됩니다' }))
|
||||
clearTimeout(exitTimer)
|
||||
exitTimer = setTimeout(() => { exitArmed = false }, 2000)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
// 전역 치명적 오류 상태. 컴포넌트 트리 밖에서 관리해, 렌더 오류로 화면이
|
||||
// 깨져도(흰 화면) App.vue 가 폴백 UI 를 대신 렌더할 수 있게 한다.
|
||||
export const appError = reactive({ hasError: false })
|
||||
|
||||
export function reportFatal(err) {
|
||||
// 콘솔에는 원본을 남기고, 사용자에겐 폴백 화면을 보여준다.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[fatal]', err)
|
||||
appError.hasError = true
|
||||
}
|
||||
|
||||
export function clearFatal() {
|
||||
appError.hasError = false
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
// 앱 잠금(PIN). 자체 구현 — 네이티브 플러그인 없이 동작.
|
||||
// PIN 은 평문 저장하지 않고 SHA-256 해시만 보관. 기본 비활성(옵트인).
|
||||
// 갇힘 방지: 잠금 화면에서 'PIN 분실 → 로그아웃' 으로 disable + 로그아웃 가능.
|
||||
const KEY_ENABLED = 'applock:enabled'
|
||||
const KEY_HASH = 'applock:hash'
|
||||
|
||||
export const PIN_LENGTH = 4
|
||||
|
||||
const state = reactive({
|
||||
enabled: localStorage.getItem(KEY_ENABLED) === '1',
|
||||
locked: false,
|
||||
})
|
||||
|
||||
// 카메라/갤러리/공유 등 앱 내 네이티브 동작을 호출하면 웹뷰가 잠깐 백그라운드로
|
||||
// 인식돼 잠금이 걸리는 오작동을 막기 위한 "잠금 일시중지" 창(timestamp).
|
||||
let suppressUntil = 0
|
||||
|
||||
async function hashPin(pin) {
|
||||
const data = new TextEncoder().encode('dondwaeji-applock:' + pin)
|
||||
const buf = await crypto.subtle.digest('SHA-256', data)
|
||||
return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
export const appLock = {
|
||||
state,
|
||||
/** PIN 설정 + 잠금 활성화 */
|
||||
async setPin(pin) {
|
||||
localStorage.setItem(KEY_HASH, await hashPin(pin))
|
||||
localStorage.setItem(KEY_ENABLED, '1')
|
||||
state.enabled = true
|
||||
},
|
||||
/** 입력 PIN 검증 */
|
||||
async verify(pin) {
|
||||
const stored = localStorage.getItem(KEY_HASH)
|
||||
return !!stored && (await hashPin(pin)) === stored
|
||||
},
|
||||
/** 잠금 해제(설정 끄기) */
|
||||
disable() {
|
||||
localStorage.removeItem(KEY_HASH)
|
||||
localStorage.removeItem(KEY_ENABLED)
|
||||
state.enabled = false
|
||||
state.locked = false
|
||||
},
|
||||
/** 잠금 걸기(활성화 + 일시중지 창 밖일 때만) */
|
||||
lock() {
|
||||
if (Date.now() < suppressUntil) return // 네이티브 동작 복귀 — 잠그지 않음
|
||||
if (state.enabled) state.locked = true
|
||||
},
|
||||
/** 다음 백그라운드 전환을 잠그지 않음(카메라/갤러리/공유 호출 직전에 사용) */
|
||||
suppressLock(ms = 60000) {
|
||||
suppressUntil = Date.now() + ms
|
||||
},
|
||||
/** 일시중지 창 해제(앱으로 복귀 시) */
|
||||
clearSuppress() {
|
||||
suppressUntil = 0
|
||||
},
|
||||
/** 잠금 풀기(이번 세션) */
|
||||
unlock() {
|
||||
state.locked = false
|
||||
},
|
||||
/** 저장된 세션이 있는지(앱 시작 시 잠금 여부 판단용) */
|
||||
hasSession() {
|
||||
return !!localStorage.getItem('token')
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
// 앱 공용 인앱 다이얼로그 상태(싱글턴). 네이티브 alert/confirm/prompt 대체.
|
||||
// PC(Electron)에서 제목이 'sb_pt'로 뜨던 문제 + 웹/APK 일관 UI 제공.
|
||||
const state = reactive({
|
||||
open: false,
|
||||
type: 'alert', // 'alert' | 'confirm' | 'prompt'
|
||||
title: '',
|
||||
message: '',
|
||||
okText: '확인',
|
||||
cancelText: '취소',
|
||||
danger: false,
|
||||
inputValue: '',
|
||||
placeholder: '',
|
||||
_resolve: null,
|
||||
})
|
||||
|
||||
function settle(result) {
|
||||
const r = state._resolve
|
||||
state._resolve = null
|
||||
state.open = false
|
||||
if (r) r(result)
|
||||
}
|
||||
|
||||
function alert(message, { title = '알림', okText = '확인' } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
Object.assign(state, { type: 'alert', title, message, okText, danger: false, _resolve: resolve, open: true })
|
||||
})
|
||||
}
|
||||
|
||||
function confirm(message, { title = '확인', okText = '확인', cancelText = '취소', danger = false } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
Object.assign(state, { type: 'confirm', title, message, okText, cancelText, danger, _resolve: resolve, open: true })
|
||||
})
|
||||
}
|
||||
|
||||
function prompt(message, { title = '입력', okText = '확인', cancelText = '취소', defaultValue = '', placeholder = '' } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
Object.assign(state, {
|
||||
type: 'prompt', title, message, okText, cancelText,
|
||||
inputValue: defaultValue, placeholder, danger: false, _resolve: resolve, open: true,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// AppDialog 컴포넌트에서 사용
|
||||
export const dialogState = state
|
||||
export function dialogConfirm() {
|
||||
settle(state.type === 'prompt' ? state.inputValue : true)
|
||||
}
|
||||
export function dialogCancel() {
|
||||
settle(state.type === 'prompt' ? null : false)
|
||||
}
|
||||
|
||||
export function useDialog() {
|
||||
return { alert, confirm, prompt }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// 로그인 방식 기능 플래그.
|
||||
// 구글 로그인만 운영하므로 아이디 로그인 폼 / 회원가입 UI 를 숨긴다.
|
||||
// 백엔드(/api/auth/login, /api/auth/signup)는 비상용으로 그대로 유지 —
|
||||
// 되돌리려면 이 값을 true 로 바꾸면 아이디 로그인/회원가입이 다시 노출된다.
|
||||
export const ID_LOGIN_ENABLED = false
|
||||
|
||||
// 카드 결제 알림 자동인식(NotificationListenerService) 기능.
|
||||
// 스토어 첫 출시에서는 Google Play 알림 접근 정책 심사 회피를 위해 비활성화.
|
||||
// (AndroidManifest 의 CardNotifListenerService 선언도 함께 주석 처리되어 있음)
|
||||
export const CARD_NOTIF_ENABLED = false
|
||||
|
||||
// 인앱결제(유료 구독) 기능.
|
||||
// 첫 출시는 무료 앱으로 등록 — 실제 Google Play Billing 연동(결제 플러그인 +
|
||||
// 서버 영수증 검증) 전까지는 구매 흐름이 테스트 스텁이라, 활성화하면 결제 없이
|
||||
// 프리미엄이 부여되는 문제가 있다. 그래서 구매 진입점을 모두 숨긴다.
|
||||
// 실연동 완료 후 true 로 바꾸면 업그레이드/정기결제 관리 화면이 다시 노출된다.
|
||||
export const BILLING_ENABLED = false
|
||||
|
||||
// 광고(카카오 애드핏) 노출 기능.
|
||||
// 첫 출시는 광고 미승인 상태 + 데이터 안전 양식 단순화를 위해 비활성화.
|
||||
// (광고 식별자 수집/제3자 공유가 사라져 심사가 단순해짐)
|
||||
// 애드핏 앱 광고단위 승인 후 true 로 바꾸면 무료 회원에게 광고가 다시 노출된다.
|
||||
export const ADS_ENABLED = false
|
||||
@@ -1,14 +1,17 @@
|
||||
// 회원가입 약관 본문. 모달에서 '전문 보기'로 노출.
|
||||
// 서비스명: Slim Budget (가계부·자산·예산 관리)
|
||||
// 이용약관 / 개인정보처리방침 본문. 가입 모달 '전문 보기' 및 /terms, /privacy 페이지에서 노출.
|
||||
// 서비스명: 돈돼지 가계부 (가계부·자산·예산 관리)
|
||||
|
||||
export const SERVICE_NAME = 'Slim Budget'
|
||||
export const SERVICE_NAME = '돈돼지 가계부'
|
||||
export const EFFECTIVE_DATE = '2026-06-28'
|
||||
// 개인정보 보호 문의 — 운영자 연락처(필요 시 변경)
|
||||
export const CONTACT_EMAIL = 'sbsb401824@gmail.com'
|
||||
|
||||
export const TERMS_OF_SERVICE = `제1조 (목적)
|
||||
이 약관은 ${SERVICE_NAME}(이하 "서비스")가 제공하는 가계부·자산·예산 관리 서비스의 이용과 관련하여 서비스와 회원 간의 권리, 의무 및 책임사항을 규정함을 목적으로 합니다.
|
||||
|
||||
제2조 (정의)
|
||||
1. "회원"이란 본 약관에 동의하고 서비스에 가입하여 이용하는 자를 말합니다.
|
||||
2. "계정"이란 회원 식별과 서비스 이용을 위해 회원이 등록한 아이디와 비밀번호를 말합니다.
|
||||
2. "계정"이란 회원 식별과 서비스 이용을 위해 회원이 등록(또는 소셜 로그인으로 연동)한 식별 정보를 말합니다.
|
||||
|
||||
제3조 (약관의 효력 및 변경)
|
||||
1. 본 약관은 회원이 가입 시 동의함으로써 효력이 발생합니다.
|
||||
@@ -26,33 +29,71 @@ export const TERMS_OF_SERVICE = `제1조 (목적)
|
||||
제6조 (게시물)
|
||||
회원이 게시판 등에 작성한 게시물의 권리와 책임은 작성자에게 있으며, 서비스 운영이나 타인의 권리를 침해하는 게시물은 사전 통지 없이 제한될 수 있습니다.
|
||||
|
||||
제7조 (계약 해지)
|
||||
회원은 언제든지 탈퇴를 요청할 수 있으며, 탈퇴 시 회원의 데이터는 관련 법령에서 정한 경우를 제외하고 지체 없이 삭제됩니다.
|
||||
제7조 (유료 서비스 및 결제)
|
||||
1. 서비스는 일부 기능을 유료(프리미엄) 구독으로 제공할 수 있습니다.
|
||||
2. 모바일 앱의 결제는 Google Play 등 앱 마켓의 인앱결제를 통해 이루어지며, 결제·환불·정기결제(자동 갱신)의 처리 및 정책은 해당 마켓의 규정을 따릅니다.
|
||||
3. 구독은 만료일까지 이용할 수 있으며, 해지하더라도 남은 기간 동안 이용이 유지된 후 무료로 전환됩니다.
|
||||
4. 청약철회 및 환불은 관련 법령과 앱 마켓 정책에 따릅니다.
|
||||
|
||||
제8조 (면책)
|
||||
서비스는 천재지변, 회원의 귀책사유, 제3자 서비스(시세 제공처 등)의 장애 등 합리적으로 통제할 수 없는 사유로 인한 손해에 대하여 책임을 지지 않습니다.
|
||||
제8조 (광고)
|
||||
서비스는 무료 회원에게 광고를 표시할 수 있으며, 유료(프리미엄) 회원에게는 광고를 표시하지 않습니다.
|
||||
|
||||
제9조 (계약 해지)
|
||||
회원은 언제든지 앱 내 '회원 탈퇴' 기능으로 탈퇴할 수 있으며, 탈퇴 시 회원의 데이터는 관련 법령에서 정한 경우를 제외하고 지체 없이 삭제됩니다.
|
||||
|
||||
제10조 (면책)
|
||||
서비스는 천재지변, 회원의 귀책사유, 제3자 서비스(시세 제공처, 앱 마켓, 광고 등)의 장애 등 합리적으로 통제할 수 없는 사유로 인한 손해에 대하여 책임을 지지 않습니다.
|
||||
|
||||
부칙
|
||||
본 약관은 가입 시점부터 적용됩니다.`
|
||||
본 약관은 ${EFFECTIVE_DATE}부터 적용됩니다.`
|
||||
|
||||
export const PRIVACY_POLICY = `${SERVICE_NAME}는 회원가입 및 서비스 제공을 위해 아래와 같이 개인정보를 수집·이용합니다.
|
||||
export const PRIVACY_POLICY = `${SERVICE_NAME}(이하 "서비스")는 이용자의 개인정보를 중요하게 생각하며, 「개인정보 보호법」 등 관련 법령을 준수합니다. 본 방침은 서비스가 어떤 정보를 어떻게 수집·이용·보관하는지 안내합니다.
|
||||
|
||||
1. 수집 항목
|
||||
- 필수: 아이디, 비밀번호(암호화 저장), 이름
|
||||
- 선택: 이메일
|
||||
- 서비스 이용 과정에서 회원이 직접 입력하는 가계부·자산·거래 내역 등
|
||||
1. 수집하는 개인정보 항목
|
||||
가) 회원가입·계정
|
||||
- 로컬 가입: 아이디, 비밀번호(단방향 암호화 저장), 이름, 이메일(선택)
|
||||
- 구글 로그인: 구글 계정 식별자, 이름, 이메일, 프로필 사진(구글 제공 범위)
|
||||
나) 서비스 이용 과정에서 회원이 직접 입력·등록하는 정보
|
||||
- 가계부·계좌·자산·예산·거래 내역 등 가계부 데이터
|
||||
- 게시판 글·댓글, 직접 업로드한 프로필 사진(선택)
|
||||
다) 자동으로 생성·수집되는 정보
|
||||
- 서비스 이용 기록, 기기·브라우저 정보(부정 이용 방지 및 향후 광고 도입 시 광고 제공 목적)
|
||||
- 인앱결제 시 구매 토큰 및 구독 상태(결제 검증·구독 관리 목적)
|
||||
|
||||
2. 수집·이용 목적
|
||||
- 회원 식별 및 로그인 등 계정 관리
|
||||
- 가계부·자산·예산 관리 기능 제공 및 회원 데이터 보관
|
||||
- 유료 구독 결제 검증 및 구독(자동 갱신·해지) 관리
|
||||
- (향후 도입 시) 무료 회원 대상 광고 제공
|
||||
- 서비스 운영, 문의 응대 및 부정 이용 방지
|
||||
|
||||
3. 보유 및 이용 기간
|
||||
- 회원 탈퇴 시까지 보유하며, 탈퇴 시 지체 없이 파기합니다.
|
||||
3. 처리위탁 및 제3자
|
||||
서비스는 기능 제공을 위해 아래 사업자의 서비스를 이용하며, 필요한 범위에서 정보가 처리될 수 있습니다.
|
||||
- Google: 구글 로그인(인증), 인앱결제(Google Play), 영수증 문자 인식(Google Cloud Vision OCR)
|
||||
- Kakao(카카오): 광고 노출(카카오 애드핏) — 향후 광고 도입 시, 무료 회원에 한함
|
||||
영수증 OCR은 회원이 촬영·선택한 영수증 이미지를 텍스트 인식 목적으로만 처리합니다. 서비스는 회원의 개인정보를 위 목적 외로 외부에 판매하지 않습니다.
|
||||
|
||||
4. 광고
|
||||
- 현재 서비스에는 광고가 표시되지 않습니다.
|
||||
- 향후 무료 회원을 대상으로 광고가 도입될 수 있으며, 이 경우 광고 식별·노출을 위해 기기 정보 등이 광고 사업자에 의해 처리될 수 있습니다. 유료(프리미엄) 회원에게는 광고가 표시되지 않습니다.
|
||||
|
||||
5. 결제 정보
|
||||
- 결제는 Google Play 등 앱 마켓의 인앱결제로 처리되며, 카드번호 등 결제수단 정보는 서비스가 저장하지 않습니다.
|
||||
- 서비스는 결제 검증·구독 관리를 위한 구매 토큰과 구독 상태(상품, 만료일 등)만 보관합니다.
|
||||
|
||||
6. 보유 및 이용 기간
|
||||
- 회원 탈퇴 시까지 보유하며, 탈퇴 시 회원의 데이터(가계부·게시물·포인트·결제기록 등)를 지체 없이 파기합니다.
|
||||
- 다만 관련 법령에 따라 보존이 필요한 경우 해당 기간 동안 보관합니다.
|
||||
|
||||
4. 동의 거부 권리 및 불이익
|
||||
- 회원은 개인정보 수집·이용 동의를 거부할 권리가 있습니다.
|
||||
- 다만 필수 항목에 동의하지 않을 경우 회원가입 및 서비스 이용이 제한됩니다.
|
||||
7. 이용자의 권리
|
||||
- 회원은 자신의 개인정보에 대해 열람·정정·삭제·처리정지를 요청할 수 있으며, 앱 내 '회원 탈퇴'로 계정과 데이터를 삭제할 수 있습니다.
|
||||
- 개인정보 수집·이용 동의를 거부할 권리가 있으나, 필수 항목 미동의 시 회원가입 및 서비스 이용이 제한됩니다.
|
||||
|
||||
비밀번호는 복호화가 불가능한 방식으로 암호화하여 저장되며, 운영자도 원문을 확인할 수 없습니다.`
|
||||
8. 안전성 확보 조치
|
||||
- 비밀번호는 복호화가 불가능한 방식으로 암호화하여 저장하며, 운영자도 원문을 확인할 수 없습니다.
|
||||
- 전송 구간 암호화(HTTPS) 등 합리적인 보호조치를 적용합니다.
|
||||
|
||||
9. 개인정보 보호 문의
|
||||
- 문의: ${CONTACT_EMAIL}
|
||||
|
||||
본 개인정보처리방침은 ${EFFECTIVE_DATE}부터 적용됩니다.`
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// 둘러보기(데모) 모드 — 실제 화면 컴포넌트를 그대로 쓰되 API 만 더미로 응답한다.
|
||||
// demo.on 이 true 면 accountApi 의 읽기는 아래 더미를, 쓰기는 block()(안내 메시지)으로 응답.
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export const demo = reactive({ on: false })
|
||||
|
||||
export function enterDemo() {
|
||||
demo.on = true
|
||||
}
|
||||
export function exitDemo() {
|
||||
demo.on = false
|
||||
}
|
||||
|
||||
// ===== 더미 데이터 (백엔드 응답 DTO 형태와 동일) =====
|
||||
const WALLETS = [
|
||||
{ id: 1, type: 'BANK', name: '신한 주거래', issuer: '신한', accountNumber: null, openingBalance: 0, balance: 2540000, manualValuation: false },
|
||||
{ id: 2, type: 'BANK', name: '주택청약', issuer: '국민', accountNumber: null, openingBalance: 0, balance: 5000000, manualValuation: false },
|
||||
{ id: 3, type: 'CASH', name: '현금', openingBalance: 0, balance: 120000, manualValuation: false },
|
||||
{ id: 4, type: 'CARD', name: '삼성카드', issuer: '삼성', cardType: 'CREDIT', openingBalance: 0, balance: -340000, manualValuation: false },
|
||||
{
|
||||
id: 5, type: 'INVEST', name: '연금저축', manualValuation: true,
|
||||
investedAmount: 7000000, currentValue: 8200000, balance: 8200000,
|
||||
valuationGain: 1200000, deposit: 0, stockValue: 8200000,
|
||||
},
|
||||
]
|
||||
|
||||
const CATEGORIES = [
|
||||
// 지출 대분류/소분류
|
||||
{ id: 10, type: 'EXPENSE', name: '식비', parentId: null },
|
||||
{ id: 11, type: 'EXPENSE', name: '점심', parentId: 10 },
|
||||
{ id: 12, type: 'EXPENSE', name: '저녁', parentId: 10 },
|
||||
{ id: 13, type: 'EXPENSE', name: '카페/간식', parentId: 10 },
|
||||
{ id: 20, type: 'EXPENSE', name: '교통', parentId: null },
|
||||
{ id: 21, type: 'EXPENSE', name: '대중교통', parentId: 20 },
|
||||
{ id: 22, type: 'EXPENSE', name: '택시', parentId: 20 },
|
||||
{ id: 30, type: 'EXPENSE', name: '주거/통신', parentId: null },
|
||||
{ id: 31, type: 'EXPENSE', name: '월세', parentId: 30 },
|
||||
{ id: 32, type: 'EXPENSE', name: '통신비', parentId: 30 },
|
||||
{ id: 40, type: 'EXPENSE', name: '문화/여가', parentId: null },
|
||||
{ id: 41, type: 'EXPENSE', name: 'OTT/구독', parentId: 40 },
|
||||
{ id: 50, type: 'EXPENSE', name: '쇼핑', parentId: null },
|
||||
// 수입
|
||||
{ id: 60, type: 'INCOME', name: '급여', parentId: null },
|
||||
{ id: 61, type: 'INCOME', name: '용돈', parentId: null },
|
||||
]
|
||||
|
||||
// 이번 달(현 시점) 기준 샘플 내역
|
||||
function ymd(day) {
|
||||
const now = new Date()
|
||||
const m = String(now.getMonth() + 1).padStart(2, '0')
|
||||
return `${now.getFullYear()}-${m}-${String(day).padStart(2, '0')}`
|
||||
}
|
||||
function E(id, day, type, category, amount, memo, walletId, walletName, opt = {}) {
|
||||
return {
|
||||
id, entryDate: ymd(day), type, category, amount, memo,
|
||||
walletId, walletName, toWalletId: null, toWalletName: null,
|
||||
installmentMonths: null, pending: !!opt.pending, tags: opt.tags || [],
|
||||
}
|
||||
}
|
||||
const ENTRIES = [
|
||||
E(101, 27, 'EXPENSE', '점심', 9000, '김밥천국', 4, '삼성카드'),
|
||||
E(102, 27, 'EXPENSE', '카페/간식', 5300, '스타벅스', 4, '삼성카드'),
|
||||
E(103, 27, 'EXPENSE', '대중교통', 2800, '지하철', 3, '현금'),
|
||||
E(104, 27, 'EXPENSE', '쇼핑', 25200, '다이소', 4, '삼성카드', { pending: true }),
|
||||
E(105, 25, 'INCOME', '급여', 4600000, '6월 급여', 1, '신한 주거래'),
|
||||
E(106, 25, 'EXPENSE', '통신비', 45300, '휴대폰 요금', 1, '신한 주거래'),
|
||||
E(107, 23, 'EXPENSE', 'OTT/구독', 17000, '넷플릭스', 4, '삼성카드', { tags: ['구독'] }),
|
||||
E(108, 23, 'EXPENSE', '저녁', 23000, '배달', 4, '삼성카드'),
|
||||
E(109, 20, 'EXPENSE', '쇼핑', 285000, '마트 장보기', 4, '삼성카드'),
|
||||
E(110, 18, 'EXPENSE', '저녁', 62000, '친구 외식', 4, '삼성카드', { tags: ['데이트'] }),
|
||||
E(111, 15, 'EXPENSE', '택시', 28000, '심야 택시', 4, '삼성카드'),
|
||||
E(112, 12, 'EXPENSE', '쇼핑', 320000, '의류 구매', 4, '삼성카드'),
|
||||
E(113, 8, 'EXPENSE', '대중교통', 55000, '교통카드 충전', 3, '현금'),
|
||||
E(114, 5, 'EXPENSE', '카페/간식', 18500, '베이커리', 4, '삼성카드'),
|
||||
E(115, 1, 'EXPENSE', '월세', 600000, '6월 월세', 1, '신한 주거래'),
|
||||
]
|
||||
|
||||
const RECURRINGS = [
|
||||
{ id: 201, title: '월세', type: 'EXPENSE', amount: 600000, category: '월세', memo: null, walletId: 1, walletName: '신한 주거래', toWalletId: null, toWalletName: null, frequency: 'MONTHLY', dayOfMonth: 1, dayOfWeek: null, monthOfYear: null, startDate: ymd(1), endDate: null, lastRunDate: null, nextDate: ymd(1), active: true },
|
||||
{ id: 202, title: '통신비', type: 'EXPENSE', amount: 45300, category: '통신비', memo: null, walletId: 1, walletName: '신한 주거래', toWalletId: null, toWalletName: null, frequency: 'MONTHLY', dayOfMonth: 15, dayOfWeek: null, monthOfYear: null, startDate: ymd(15), endDate: null, lastRunDate: null, nextDate: ymd(15), active: true },
|
||||
{ id: 203, title: '넷플릭스', type: 'EXPENSE', amount: 17000, category: 'OTT/구독', memo: null, walletId: 4, walletName: '삼성카드', toWalletId: null, toWalletName: null, frequency: 'MONTHLY', dayOfMonth: 23, dayOfWeek: null, monthOfYear: null, startDate: ymd(23), endDate: null, lastRunDate: null, nextDate: ymd(23), active: true },
|
||||
{ id: 204, title: '급여', type: 'INCOME', amount: 4600000, category: '급여', memo: null, walletId: 1, walletName: '신한 주거래', toWalletId: null, toWalletName: null, frequency: 'MONTHLY', dayOfMonth: 25, dayOfWeek: null, monthOfYear: null, startDate: ymd(25), endDate: null, lastRunDate: null, nextDate: ymd(25), active: true },
|
||||
]
|
||||
|
||||
const QUICK = [
|
||||
{ id: 301, label: '점심', type: 'EXPENSE', category: '점심', amount: 9000, memo: '점심', walletId: 4, walletName: '삼성카드' },
|
||||
{ id: 302, label: '커피', type: 'EXPENSE', category: '카페/간식', amount: 5300, memo: '커피', walletId: 4, walletName: '삼성카드' },
|
||||
{ id: 303, label: '대중교통', type: 'EXPENSE', category: '대중교통', amount: 2800, memo: '교통', walletId: 3, walletName: '현금' },
|
||||
]
|
||||
|
||||
const TAGS = [
|
||||
{ id: 401, name: '구독' },
|
||||
{ id: 402, name: '데이트' },
|
||||
{ id: 403, name: '경조사' },
|
||||
]
|
||||
|
||||
// 해당 연·월 내역만 (월 이동 반영)
|
||||
function inMonth(e, year, month) {
|
||||
if (!year || !month) return true
|
||||
return e.entryDate.startsWith(`${year}-${String(month).padStart(2, '0')}`)
|
||||
}
|
||||
// 검색·필터 적용
|
||||
function filterEntries(p = {}) {
|
||||
let rows = ENTRIES.filter((e) => inMonth(e, p.year, p.month))
|
||||
if (p.type) rows = rows.filter((e) => e.type === p.type)
|
||||
if (p.category) rows = rows.filter((e) => e.category === p.category)
|
||||
if (p.walletId) rows = rows.filter((e) => String(e.walletId) === String(p.walletId))
|
||||
if (p.keyword) {
|
||||
const k = String(p.keyword).trim()
|
||||
rows = rows.filter((e) => (e.memo || '').includes(k) || (e.category || '').includes(k))
|
||||
}
|
||||
if (p.tagId) {
|
||||
const t = TAGS.find((x) => String(x.id) === String(p.tagId))
|
||||
if (t) rows = rows.filter((e) => (e.tags || []).includes(t.name))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
function summaryOf(p = {}) {
|
||||
let income = 0, expense = 0
|
||||
for (const e of ENTRIES) {
|
||||
if (!inMonth(e, p.year, p.month)) continue
|
||||
if (e.type === 'INCOME') income += e.amount
|
||||
else if (e.type === 'EXPENSE') expense += e.amount
|
||||
}
|
||||
return { totalIncome: income, totalExpense: expense, balance: income - expense }
|
||||
}
|
||||
const NETWORTH = { totalAssets: 15860000, totalLiabilities: 340000, netWorth: 15520000 }
|
||||
|
||||
function ok(data) {
|
||||
// 깊은 복제로 화면에서 수정해도 원본 더미는 보존
|
||||
return Promise.resolve(JSON.parse(JSON.stringify(data)))
|
||||
}
|
||||
function block() {
|
||||
return Promise.reject({ response: { data: { message: '둘러보기 모드에서는 저장되지 않습니다. 로그인 후 이용해 주세요.' } } })
|
||||
}
|
||||
|
||||
// 읽기 응답 / 쓰기 차단
|
||||
export const demoApi = {
|
||||
list: (params) => ok(filterEntries(params)),
|
||||
summary: (params) => ok(summaryOf(params)),
|
||||
wallets: () => ok(WALLETS),
|
||||
netWorth: () => ok(NETWORTH),
|
||||
categories: () => ok(CATEGORIES),
|
||||
recurrings: () => ok(RECURRINGS),
|
||||
quickEntries: () => ok(QUICK),
|
||||
pendingCount: () => ok({ count: 1 }),
|
||||
tags: () => ok(TAGS),
|
||||
walletEntries: () => ok([]),
|
||||
block,
|
||||
}
|
||||