Merge branch 'dev'
This commit is contained in:
+5
-7
@@ -1,11 +1,9 @@
|
|||||||
; 기본 설치 경로 지정.
|
; 기본 설치 경로 지정 (사용자 폴더 = 관리자 권한 불필요 → UAC 프롬프트 없음).
|
||||||
; productName 이 한글이라 electron-builder 가 기본 경로를 package.json name(sb_pt)으로 폴백하는 문제 회피.
|
; productName 이 한글이라 electron-builder 가 기본 경로를 package.json name(sb_pt)으로 폴백하는 문제도 함께 회피.
|
||||||
; → $PROGRAMFILES\DonDwaeji 로 고정 (사용자는 설치 시 변경 가능).
|
; → %LOCALAPPDATA%\Programs\DonDwaeji 로 고정 (사용자는 설치 시 변경 가능).
|
||||||
!macro preInit
|
!macro preInit
|
||||||
SetRegView 64
|
SetRegView 64
|
||||||
WriteRegExpandStr HKLM "${INSTALL_REGISTRY_KEY}" InstallLocation "$PROGRAMFILES64\DonDwaeji"
|
WriteRegExpandStr HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation "$LOCALAPPDATA\Programs\DonDwaeji"
|
||||||
WriteRegExpandStr HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation "$PROGRAMFILES64\DonDwaeji"
|
|
||||||
SetRegView 32
|
SetRegView 32
|
||||||
WriteRegExpandStr HKLM "${INSTALL_REGISTRY_KEY}" InstallLocation "$PROGRAMFILES\DonDwaeji"
|
WriteRegExpandStr HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation "$LOCALAPPDATA\Programs\DonDwaeji"
|
||||||
WriteRegExpandStr HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation "$PROGRAMFILES\DonDwaeji"
|
|
||||||
!macroend
|
!macroend
|
||||||
|
|||||||
@@ -53,4 +53,11 @@ export const boardApi = {
|
|||||||
unblock(id) {
|
unblock(id) {
|
||||||
return http.post(`/board/posts/${id}/unblock`)
|
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`)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-3
@@ -34,10 +34,12 @@
|
|||||||
--color-text: var(--vt-c-text-light-1);
|
--color-text: var(--vt-c-text-light-1);
|
||||||
|
|
||||||
--section-gap: 160px;
|
--section-gap: 160px;
|
||||||
|
/* 모든 화면 본문 공통 폭 (화면별 제각각이던 max-width 를 일관되게) */
|
||||||
|
--page-max: 760px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
/* 다크 변수 묶음 — 명시적 다크(data-theme=dark)와 시스템 다크에서 공통 사용 */
|
||||||
:root {
|
:root[data-theme='dark'] {
|
||||||
--color-background: var(--vt-c-black);
|
--color-background: var(--vt-c-black);
|
||||||
--color-background-soft: var(--vt-c-black-soft);
|
--color-background-soft: var(--vt-c-black-soft);
|
||||||
--color-background-mute: var(--vt-c-black-mute);
|
--color-background-mute: var(--vt-c-black-mute);
|
||||||
@@ -46,7 +48,21 @@
|
|||||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||||
|
|
||||||
--color-heading: var(--vt-c-text-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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 시스템 설정 따름(테마 미지정 = 시스템). 명시적 라이트/다크가 있으면 그게 우선 */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
: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);
|
||||||
|
|
||||||
|
--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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
font-weight: normal;
|
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 가 서로 폰트·높이 달라
|
/* 폼 요소 폰트 상속(표준 리셋) — 네이티브 input/select/textarea 가 서로 폰트·높이 달라
|
||||||
보이던 이질감 해소. (개별 화면 scoped 스타일이 더 구체적이면 그쪽이 우선) */
|
보이던 이질감 해소. (개별 화면 scoped 스타일이 더 구체적이면 그쪽이 우선) */
|
||||||
input,
|
input,
|
||||||
|
|||||||
@@ -13,8 +13,14 @@ const mobileReady = false
|
|||||||
<p class="tagline">슬림하게 관리하는 가계부 · 자산 · 예산</p>
|
<p class="tagline">슬림하게 관리하는 가계부 · 자산 · 예산</p>
|
||||||
|
|
||||||
<div class="notice">
|
<div class="notice">
|
||||||
<p class="lead">🖥️ PC는 <b>설치형 앱</b>으로, 📱 모바일은 <b>앱</b>으로 이용해요.</p>
|
<p class="lead">
|
||||||
<p class="sub">웹 브라우저에서는 안내만 제공됩니다. 설치한 뒤 로그인해 주세요.</p>
|
<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>
|
||||||
|
|
||||||
<div class="dl-group">
|
<div class="dl-group">
|
||||||
@@ -78,6 +84,11 @@ const mobileReady = false
|
|||||||
font-size: 0.86rem;
|
font-size: 0.86rem;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
/* 각 구절을 한 덩어리로 — 좁으면 절(span) 경계에서만 줄바꿈, 넓으면 한 줄 */
|
||||||
|
.lead .ln,
|
||||||
|
.sub .ln {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
.dl-group {
|
.dl-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,12 +1,39 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { RouterLink, useRouter } from 'vue-router'
|
import { computed } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useUiStore } from '@/stores/ui'
|
import { useUiStore } from '@/stores/ui'
|
||||||
|
import { boardLabel } from '@/constants/boards'
|
||||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const ui = useUiStore()
|
const ui = useUiStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
// 라우트별 헤더 타이틀(현재 화면 이름)
|
||||||
|
const TITLES = {
|
||||||
|
home: '돈돼지 가계부',
|
||||||
|
'account-entries': '가계부 내역',
|
||||||
|
'account-stats': '통계',
|
||||||
|
'account-recurrings': '고정 지출',
|
||||||
|
'account-wallets': '계좌 관리',
|
||||||
|
'account-categories': '분류 관리',
|
||||||
|
'account-budget': '예산 설정',
|
||||||
|
'account-tags': '태그 관리',
|
||||||
|
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() {
|
async function handleLogout() {
|
||||||
await auth.logout()
|
await auth.logout()
|
||||||
@@ -22,10 +49,7 @@ async function handleLogout() {
|
|||||||
<path d="M3 12h18" /><path d="M3 6h18" /><path d="M3 18h18" />
|
<path d="M3 12h18" /><path d="M3 6h18" /><path d="M3 18h18" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<RouterLink to="/" class="brand">
|
<h1 class="page-title">{{ pageTitle }}</h1>
|
||||||
<img class="brand-mark" src="/logo-piggy.png" alt="" aria-hidden="true" />
|
|
||||||
<span class="brand-text">돈돼지 가계부</span>
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
@@ -66,27 +90,14 @@ async function handleLogout() {
|
|||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
}
|
}
|
||||||
.brand {
|
.page-title {
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.45rem;
|
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-heading);
|
color: var(--color-heading);
|
||||||
}
|
|
||||||
.brand:hover {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
.brand-mark {
|
|
||||||
height: 28px;
|
|
||||||
width: auto;
|
|
||||||
max-width: 38px;
|
|
||||||
object-fit: contain;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.brand-text {
|
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.header-right {
|
.header-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ const icons = {
|
|||||||
<template>
|
<template>
|
||||||
<aside class="app-sidebar">
|
<aside class="app-sidebar">
|
||||||
<div class="drawer-head">
|
<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>
|
<button class="drawer-close" type="button" aria-label="닫기" @click="ui.closeSidebar()">×</button>
|
||||||
</div>
|
</div>
|
||||||
<nav class="menu">
|
<nav class="menu">
|
||||||
@@ -100,31 +103,51 @@ const icons = {
|
|||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
}
|
}
|
||||||
.drawer-head {
|
.drawer-head {
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.app-sidebar {
|
|
||||||
background: var(--color-background);
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
|
||||||
.drawer-head {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0.5rem 1rem 0.75rem;
|
padding: 0.5rem 1.1rem 0.75rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
border-bottom: 1px solid var(--color-border);
|
||||||
.drawer-title {
|
}
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-size: 1.15rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
color: var(--color-heading);
|
||||||
.drawer-close {
|
}
|
||||||
|
.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;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.app-sidebar {
|
||||||
|
background: var(--color-background);
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
.drawer-close {
|
||||||
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.menu {
|
.menu {
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import router from './router'
|
|||||||
import { setupCapacitor } from './capacitor'
|
import { setupCapacitor } from './capacitor'
|
||||||
import { useAuthStore } from './stores/auth'
|
import { useAuthStore } from './stores/auth'
|
||||||
import { useDialog } from './composables/dialog'
|
import { useDialog } from './composables/dialog'
|
||||||
|
import { initTheme } from './theme'
|
||||||
|
|
||||||
|
// 저장된 테마(라이트/다크/시스템) 즉시 적용 — 첫 렌더 깜빡임 방지
|
||||||
|
initTheme()
|
||||||
|
|
||||||
// 네이티브 alert 를 인앱 다이얼로그로 대체 (PC 'sb_pt' 제목 제거 + 웹/APK 일관).
|
// 네이티브 alert 를 인앱 다이얼로그로 대체 (PC 'sb_pt' 제목 제거 + 웹/APK 일관).
|
||||||
// confirm/prompt 는 동기 반환이라 오버라이드 불가 → 각 호출부에서 await dialog.confirm/prompt 사용.
|
// confirm/prompt 는 동기 반환이라 오버라이드 불가 → 각 호출부에서 await dialog.confirm/prompt 사용.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { authApi } from '@/api/authApi'
|
import { authApi } from '@/api/authApi'
|
||||||
|
import { getStoredTheme, setTheme as persistTheme } from '@/theme'
|
||||||
|
|
||||||
// 전역 UI 상태 (로그인/회원가입 레이어 팝업 등)
|
// 전역 UI 상태 (로그인/회원가입 레이어 팝업 등)
|
||||||
export const useUiStore = defineStore('ui', () => {
|
export const useUiStore = defineStore('ui', () => {
|
||||||
@@ -28,6 +29,13 @@ export const useUiStore = defineStore('ui', () => {
|
|||||||
sidebarOpen.value = false
|
sidebarOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 테마 (system | light | dark)
|
||||||
|
const theme = ref(getStoredTheme())
|
||||||
|
function setTheme(t) {
|
||||||
|
theme.value = t
|
||||||
|
persistTheme(t)
|
||||||
|
}
|
||||||
|
|
||||||
// 비밀번호 변경 모달
|
// 비밀번호 변경 모달
|
||||||
const passwordOpen = ref(false)
|
const passwordOpen = ref(false)
|
||||||
function openPassword() {
|
function openPassword() {
|
||||||
@@ -63,5 +71,6 @@ export const useUiStore = defineStore('ui', () => {
|
|||||||
signupEnabled, loadSignupEnabled,
|
signupEnabled, loadSignupEnabled,
|
||||||
sidebarOpen, toggleSidebar, closeSidebar,
|
sidebarOpen, toggleSidebar, closeSidebar,
|
||||||
passwordOpen, openPassword, closePassword,
|
passwordOpen, openPassword, closePassword,
|
||||||
|
theme, setTheme,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// 테마(라이트/다크/시스템) 적용 — <html data-theme> 속성으로 제어.
|
||||||
|
// light/dark : 명시 적용, system : 속성 제거 → OS prefers-color-scheme 따름
|
||||||
|
const KEY = 'theme'
|
||||||
|
|
||||||
|
export function getStoredTheme() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(KEY) || 'system'
|
||||||
|
} catch {
|
||||||
|
return 'system'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTheme(theme) {
|
||||||
|
const el = document.documentElement
|
||||||
|
if (theme === 'light' || theme === 'dark') {
|
||||||
|
el.setAttribute('data-theme', theme)
|
||||||
|
} else {
|
||||||
|
el.removeAttribute('data-theme') // system
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTheme(theme) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, theme)
|
||||||
|
} catch {
|
||||||
|
/* 저장 실패 무시 */
|
||||||
|
}
|
||||||
|
applyTheme(theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 앱 시작 시 저장값 즉시 적용 (마운트 전 호출 → 깜빡임 방지)
|
||||||
|
export function initTheme() {
|
||||||
|
applyTheme(getStoredTheme())
|
||||||
|
}
|
||||||
@@ -114,12 +114,32 @@ function hideTip() {
|
|||||||
const PIE_R = 46 // viewBox 120 기준, 선두께 20(hover 24) 포함해도 잘리지 않도록 (46+12=58 ≤ 60)
|
const PIE_R = 46 // viewBox 120 기준, 선두께 20(hover 24) 포함해도 잘리지 않도록 (46+12=58 ≤ 60)
|
||||||
const PIE_C = 2 * Math.PI * PIE_R
|
const PIE_C = 2 * Math.PI * PIE_R
|
||||||
const PIE_COLORS = ['#3b82a6', '#e67e22', '#2e7d32', '#9b59b6', '#c0392b', '#16a085', '#f39c12', '#8e44ad', '#27ae60', '#d35400', '#2980b9', '#7f8c8d']
|
const PIE_COLORS = ['#3b82a6', '#e67e22', '#2e7d32', '#9b59b6', '#c0392b', '#16a085', '#f39c12', '#8e44ad', '#27ae60', '#d35400', '#2980b9', '#7f8c8d']
|
||||||
const catTotal = computed(() => catData.value.reduce((s, d) => s + d.total, 0))
|
// 분류별 집계를 대분류 기준으로 묶는다 (소분류까지 펼치면 항목이 너무 많아 스크롤이 길어짐).
|
||||||
|
// 소분류는 부모(대분류)명으로 합산, 매핑이 없으면 자기 자신을 대분류로 본다.
|
||||||
|
const catByMajor = computed(() => {
|
||||||
|
const list = categoryList.value.filter((c) => c.type === catType.value)
|
||||||
|
const byId = {}
|
||||||
|
list.forEach((c) => (byId[c.id] = c))
|
||||||
|
const toMajor = (name) => {
|
||||||
|
const c = list.find((x) => x.name === name)
|
||||||
|
if (c && c.parentId != null && byId[c.parentId]) return byId[c.parentId].name
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
const map = {}
|
||||||
|
for (const d of catData.value) {
|
||||||
|
const major = toMajor(d.category)
|
||||||
|
map[major] = (map[major] || 0) + d.total
|
||||||
|
}
|
||||||
|
return Object.entries(map)
|
||||||
|
.map(([category, total]) => ({ category, total }))
|
||||||
|
.sort((a, b) => b.total - a.total)
|
||||||
|
})
|
||||||
|
const catTotal = computed(() => catByMajor.value.reduce((s, d) => s + d.total, 0))
|
||||||
const pieSegments = computed(() => {
|
const pieSegments = computed(() => {
|
||||||
const total = catTotal.value
|
const total = catTotal.value
|
||||||
if (total <= 0) return []
|
if (total <= 0) return []
|
||||||
let acc = 0
|
let acc = 0
|
||||||
return catData.value.map((d, i) => {
|
return catByMajor.value.map((d, i) => {
|
||||||
const len = (d.total / total) * PIE_C
|
const len = (d.total / total) * PIE_C
|
||||||
const seg = {
|
const seg = {
|
||||||
category: d.category,
|
category: d.category,
|
||||||
@@ -196,26 +216,6 @@ async function loadCategoryList() {
|
|||||||
categoryList.value = []
|
categoryList.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 소분류명 → 대분류명 매핑(현재 구분). 소분류가 아니면 자기 자신.
|
|
||||||
const majorRollup = computed(() => {
|
|
||||||
const list = categoryList.value.filter((c) => c.type === catType.value)
|
|
||||||
const byId = {}
|
|
||||||
list.forEach((c) => (byId[c.id] = c))
|
|
||||||
const toMajor = (name) => {
|
|
||||||
const c = list.find((x) => x.name === name)
|
|
||||||
if (c && c.parentId != null && byId[c.parentId]) return byId[c.parentId].name
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
const map = {}
|
|
||||||
for (const d of catData.value) {
|
|
||||||
const major = toMajor(d.category)
|
|
||||||
map[major] = (map[major] || 0) + d.total
|
|
||||||
}
|
|
||||||
const rows = Object.entries(map).map(([category, total]) => ({ category, total })).sort((a, b) => b.total - a.total)
|
|
||||||
// 실제로 묶인 게 있을 때만(소분류 존재) 노출
|
|
||||||
const hasSub = list.some((c) => c.parentId != null) && rows.length < catData.value.length
|
|
||||||
return hasSub ? rows : []
|
|
||||||
})
|
|
||||||
function setCatType(t) {
|
function setCatType(t) {
|
||||||
catType.value = t
|
catType.value = t
|
||||||
loadCat()
|
loadCat()
|
||||||
@@ -422,16 +422,7 @@ onMounted(async () => {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="majorRollup.length" class="major-rollup">
|
<p v-if="!pieSegments.length" class="empty">{{ catType === 'EXPENSE' ? '지출' : '수입' }} 내역이 없습니다.</p>
|
||||||
<div class="mr-head">대분류 합계</div>
|
|
||||||
<ul class="mr-list">
|
|
||||||
<li v-for="m in majorRollup" :key="m.category">
|
|
||||||
<span class="mr-cat">{{ m.category }}</span>
|
|
||||||
<span class="mr-amt">{{ won(m.total) }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<p v-else class="empty">{{ catType === 'EXPENSE' ? '지출' : '수입' }} 내역이 없습니다.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 기간별 예산 대비 지출 막대 차트 -->
|
<!-- 기간별 예산 대비 지출 막대 차트 -->
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ onBeforeUnmount(() => sortable?.destroy())
|
|||||||
<section class="tag-admin">
|
<section class="tag-admin">
|
||||||
<header class="head">
|
<header class="head">
|
||||||
<h1>가계부 태그 관리</h1>
|
<h1>가계부 태그 관리</h1>
|
||||||
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
|
|
||||||
</header>
|
</header>
|
||||||
<p class="hint">내가 등록한 태그는 나의 가계부 내역에서만 사용됩니다.</p>
|
<p class="hint">내가 등록한 태그는 나의 가계부 내역에서만 사용됩니다.</p>
|
||||||
|
|
||||||
|
|||||||
@@ -312,7 +312,6 @@ onBeforeUnmount(() => sortable?.destroy())
|
|||||||
<section class="wallet">
|
<section class="wallet">
|
||||||
<header class="head">
|
<header class="head">
|
||||||
<h1>계좌 관리</h1>
|
<h1>계좌 관리</h1>
|
||||||
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- 순자산 요약 -->
|
<!-- 순자산 요약 -->
|
||||||
|
|||||||
@@ -247,7 +247,6 @@ onMounted(() => {
|
|||||||
<header class="head">
|
<header class="head">
|
||||||
<h1>예산 설정</h1>
|
<h1>예산 설정</h1>
|
||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
|
|
||||||
<IconBtn icon="plus" title="예산 추가" variant="primary" @click="openCreate" />
|
<IconBtn icon="plus" title="예산 추가" variant="primary" @click="openCreate" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -188,7 +188,6 @@ onBeforeUnmount(destroySortables)
|
|||||||
<h1>분류 관리</h1>
|
<h1>분류 관리</h1>
|
||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
<button type="button" class="text-btn" @click="importDefaults">기본 분류 불러오기</button>
|
<button type="button" class="text-btn" @click="importDefaults">기본 분류 불러오기</button>
|
||||||
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<p class="hint">내역·예산에서 선택할 분류를 관리합니다. <b>대분류 아래 소분류</b>로 묶을 수 있고(2단계), 예산·통계는 소분류 기준입니다. 이름을 바꾸면 기존 내역·예산에도 반영됩니다.</p>
|
<p class="hint">내역·예산에서 선택할 분류를 관리합니다. <b>대분류 아래 소분류</b>로 묶을 수 있고(2단계), 예산·통계는 소분류 기준입니다. 이름을 바꾸면 기존 내역·예산에도 반영됩니다.</p>
|
||||||
|
|||||||
@@ -244,7 +244,6 @@ onMounted(load)
|
|||||||
<h1>고정 지출</h1>
|
<h1>고정 지출</h1>
|
||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
<IconBtn icon="refresh" title="지금 반영" @click="runNow" />
|
<IconBtn icon="refresh" title="지금 반영" @click="runNow" />
|
||||||
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
|
|
||||||
<IconBtn icon="plus" title="고정 지출 추가" variant="primary" @click="openCreate" />
|
<IconBtn icon="plus" title="고정 지출 추가" variant="primary" @click="openCreate" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -152,9 +152,6 @@ onBeforeUnmount(destroySortables)
|
|||||||
<section class="cat">
|
<section class="cat">
|
||||||
<header class="head">
|
<header class="head">
|
||||||
<h1>기본 분류 관리</h1>
|
<h1>기본 분류 관리</h1>
|
||||||
<div class="head-actions">
|
|
||||||
<IconBtn icon="back" title="뒤로" @click="router.back()" />
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
전체 사용자 공용 <b>기본 분류</b> 템플릿입니다. 사용자가 분류 관리에서 <b>‘기본 분류 불러오기’</b>로 자기 분류에 복사해 씁니다.
|
전체 사용자 공용 <b>기본 분류</b> 템플릿입니다. 사용자가 분류 관리에서 <b>‘기본 분류 불러오기’</b>로 자기 분류에 복사해 씁니다.
|
||||||
|
|||||||
@@ -80,6 +80,15 @@ async function unblockPost() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setNoticePost(on) {
|
||||||
|
try {
|
||||||
|
await (on ? boardApi.setNotice(postId) : boardApi.unsetNotice(postId))
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.response?.data?.message || '처리에 실패했습니다.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function addComment() {
|
async function addComment() {
|
||||||
const content = commentText.value.trim()
|
const content = commentText.value.trim()
|
||||||
if (!content) return
|
if (!content) return
|
||||||
@@ -115,7 +124,7 @@ onMounted(load)
|
|||||||
|
|
||||||
<article v-if="post">
|
<article v-if="post">
|
||||||
<header class="post-head">
|
<header class="post-head">
|
||||||
<h1>{{ post.title }}</h1>
|
<h1><span v-if="post.notice" class="notice-badge">공지</span>{{ post.title }}</h1>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<span>{{ post.authorName }}</span>
|
<span>{{ post.authorName }}</span>
|
||||||
<span>· {{ formatRelative(post.createdAt) }}</span>
|
<span>· {{ formatRelative(post.createdAt) }}</span>
|
||||||
@@ -142,8 +151,10 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<IconBtn icon="back" title="목록" @click="router.push(`/board/${category}`)" />
|
|
||||||
<div class="right-actions">
|
<div class="right-actions">
|
||||||
|
<!-- 관리자 공지 설정/해제 (커뮤니티) -->
|
||||||
|
<button v-if="isAdmin && category === 'community' && !post.notice" type="button" class="notice-btn" @click="setNoticePost(true)">공지 등록</button>
|
||||||
|
<button v-if="isAdmin && category === 'community' && post.notice" type="button" class="notice-btn" @click="setNoticePost(false)">공지 해제</button>
|
||||||
<!-- 관리자 제한/해제 -->
|
<!-- 관리자 제한/해제 -->
|
||||||
<button v-if="isAdmin && !post.blocked" type="button" class="warn" @click="blockPost">열람 제한</button>
|
<button v-if="isAdmin && !post.blocked" type="button" class="warn" @click="blockPost">열람 제한</button>
|
||||||
<button v-if="isAdmin && post.blocked" type="button" class="warn" @click="unblockPost">제한 해제</button>
|
<button v-if="isAdmin && post.blocked" type="button" class="warn" @click="unblockPost">제한 해제</button>
|
||||||
@@ -268,9 +279,23 @@ button.warn {
|
|||||||
border-color: #e67e22;
|
border-color: #e67e22;
|
||||||
color: #e67e22;
|
color: #e67e22;
|
||||||
}
|
}
|
||||||
|
button.notice-btn {
|
||||||
|
border-color: hsla(160, 100%, 37%, 1);
|
||||||
|
color: hsla(160, 100%, 37%, 1);
|
||||||
|
}
|
||||||
|
.notice-badge {
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: hsla(160, 100%, 37%, 1);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: flex-end;
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,8 +163,9 @@ onMounted(loadTags)
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="p in posts" :key="p.id" @click="router.push(`/board/${category}/${p.id}`)">
|
<tr v-for="p in posts" :key="p.id" :class="{ 'notice-row': p.notice }" @click="router.push(`/board/${category}/${p.id}`)">
|
||||||
<td>
|
<td>
|
||||||
|
<span v-if="p.notice" class="notice-badge">공지</span>
|
||||||
<template v-if="p.blocked && !isAdmin">
|
<template v-if="p.blocked && !isAdmin">
|
||||||
<span class="blocked-msg">🚫 관리자에 의해 제한된 게시글입니다.</span>
|
<span class="blocked-msg">🚫 관리자에 의해 제한된 게시글입니다.</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -318,6 +319,22 @@ button:disabled {
|
|||||||
color: #c0392b;
|
color: #c0392b;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
}
|
}
|
||||||
|
.notice-badge {
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
padding: 0.05rem 0.4rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: hsla(160, 100%, 37%, 1);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.notice-row {
|
||||||
|
background: hsla(160, 100%, 37%, 0.06);
|
||||||
|
}
|
||||||
|
.notice-row .title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
/* 하단 영역: 페이징(가운데) + 돋보기(오른쪽) 같은 행 */
|
/* 하단 영역: 페이징(가운데) + 돋보기(오른쪽) 같은 행 */
|
||||||
.list-footer {
|
.list-footer {
|
||||||
|
|||||||
@@ -2,19 +2,25 @@
|
|||||||
import { onMounted, ref, computed } from 'vue'
|
import { onMounted, ref, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { boardApi } from '@/api/boardApi'
|
import { boardApi } from '@/api/boardApi'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import MarkdownEditor from '@/components/editor/MarkdownEditor.vue'
|
import MarkdownEditor from '@/components/editor/MarkdownEditor.vue'
|
||||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||||
import { DEFAULT_BOARD } from '@/constants/boards'
|
import { DEFAULT_BOARD } from '@/constants/boards'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const category = route.params.category || DEFAULT_BOARD
|
const category = route.params.category || DEFAULT_BOARD
|
||||||
const editId = route.params.id || null
|
const editId = route.params.id || null
|
||||||
const isEdit = computed(() => !!editId)
|
const isEdit = computed(() => !!editId)
|
||||||
|
// 공지: 관리자가 커뮤니티 게시판에 새 글 작성할 때만 설정 가능
|
||||||
|
const isAdmin = computed(() => auth.user?.role === 'ADMIN')
|
||||||
|
const canNotice = computed(() => isAdmin.value && category === 'community' && !isEdit.value)
|
||||||
|
|
||||||
const title = ref('')
|
const title = ref('')
|
||||||
const content = ref('')
|
const content = ref('')
|
||||||
|
const notice = ref(false)
|
||||||
const tagGroups = ref([])
|
const tagGroups = ref([])
|
||||||
const selectedTagIds = ref([])
|
const selectedTagIds = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -65,6 +71,7 @@ async function submit() {
|
|||||||
title: title.value.trim(),
|
title: title.value.trim(),
|
||||||
content: content.value,
|
content: content.value,
|
||||||
category,
|
category,
|
||||||
|
notice: canNotice.value ? notice.value : false,
|
||||||
tagIds: selectedTagIds.value,
|
tagIds: selectedTagIds.value,
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -97,6 +104,12 @@ onMounted(async () => {
|
|||||||
<form class="write-form" @submit.prevent="submit">
|
<form class="write-form" @submit.prevent="submit">
|
||||||
<input v-model="title" type="text" placeholder="제목" maxlength="200" :disabled="loading" />
|
<input v-model="title" type="text" placeholder="제목" maxlength="200" :disabled="loading" />
|
||||||
|
|
||||||
|
<!-- 공지 등록 (관리자, 커뮤니티 새 글) -->
|
||||||
|
<label v-if="canNotice" class="notice-check">
|
||||||
|
<input v-model="notice" type="checkbox" :disabled="loading" />
|
||||||
|
📌 공지로 등록 (목록 최상단 고정)
|
||||||
|
</label>
|
||||||
|
|
||||||
<!-- 태그 선택 (DB 등록 태그를 뱃지로 토글) -->
|
<!-- 태그 선택 (DB 등록 태그를 뱃지로 토글) -->
|
||||||
<div class="tag-select">
|
<div class="tag-select">
|
||||||
<div v-if="!allTags.length" class="hint">
|
<div v-if="!allTags.length" class="hint">
|
||||||
@@ -178,6 +191,17 @@ h1 {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
}
|
}
|
||||||
|
.notice-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.notice-check input {
|
||||||
|
width: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
.buttons {
|
.buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|||||||
@@ -21,20 +21,10 @@ const roleLabel = computed(() => (u.value.role === 'ADMIN' ? '관리자' : '일
|
|||||||
function goEdit() {
|
function goEdit() {
|
||||||
router.push('/settings/account/edit')
|
router.push('/settings/account/edit')
|
||||||
}
|
}
|
||||||
function goBack() {
|
|
||||||
router.push('/settings')
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="account-info">
|
<div class="account-info">
|
||||||
<button type="button" class="back" @click="goBack">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M19 12H5" /><path d="M12 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
<span>설정</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<h1 class="page-title">계정정보</h1>
|
<h1 class="page-title">계정정보</h1>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
|
|||||||
@@ -3,8 +3,15 @@ import { ref } from 'vue'
|
|||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import { Preferences } from '@capacitor/preferences'
|
import { Preferences } from '@capacitor/preferences'
|
||||||
import { useDialog } from '@/composables/dialog'
|
import { useDialog } from '@/composables/dialog'
|
||||||
|
import { useUiStore } from '@/stores/ui'
|
||||||
|
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const ui = useUiStore()
|
||||||
|
const THEMES = [
|
||||||
|
{ value: 'system', label: '시스템' },
|
||||||
|
{ value: 'light', label: '라이트' },
|
||||||
|
{ value: 'dark', label: '다크' },
|
||||||
|
]
|
||||||
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '-'
|
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '-'
|
||||||
const clearing = ref(false)
|
const clearing = ref(false)
|
||||||
|
|
||||||
@@ -37,6 +44,25 @@ async function clearAppData() {
|
|||||||
<div class="settings">
|
<div class="settings">
|
||||||
<h1 class="page-title">설정</h1>
|
<h1 class="page-title">설정</h1>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="row">
|
||||||
|
<div class="row-main">
|
||||||
|
<span class="row-label">화면 테마</span>
|
||||||
|
<span class="row-sub">라이트 / 다크 모드 선택</span>
|
||||||
|
</div>
|
||||||
|
<div class="theme-seg">
|
||||||
|
<button
|
||||||
|
v-for="t in THEMES"
|
||||||
|
:key="t.value"
|
||||||
|
type="button"
|
||||||
|
class="seg-btn"
|
||||||
|
:class="{ active: ui.theme === t.value }"
|
||||||
|
@click="ui.setTheme(t.value)"
|
||||||
|
>{{ t.label }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<RouterLink to="/settings/account" class="row row-link">
|
<RouterLink to="/settings/account" class="row row-link">
|
||||||
<div class="row-main">
|
<div class="row-main">
|
||||||
@@ -132,6 +158,30 @@ async function clearAppData() {
|
|||||||
.danger {
|
.danger {
|
||||||
color: #c0392b;
|
color: #c0392b;
|
||||||
}
|
}
|
||||||
|
.theme-seg {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.seg-btn {
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
border: 0;
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.seg-btn:first-child {
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
.seg-btn.active {
|
||||||
|
background: hsla(160, 100%, 37%, 1);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
.chevron {
|
.chevron {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
|
|||||||
Reference in New Issue
Block a user