Compare commits
2 Commits
ea9fbc359b
...
7861eef05b
| Author | SHA1 | Date | |
|---|---|---|---|
| 7861eef05b | |||
| a261dd2ca2 |
@@ -1,44 +1,69 @@
|
|||||||
# sb_pt
|
# 돈돼지 가계부 — 프론트엔드 (sb_pt)
|
||||||
|
|
||||||
This template should help get you started developing with Vue 3 in Vite.
|
Vue 3 + Vite 기반의 가계부·자산·게시판 앱 프론트엔드. 웹 / 안드로이드(Capacitor) / Windows 데스크톱(Electron) 3개 플랫폼을 하나의 코드베이스로 빌드합니다.
|
||||||
|
|
||||||
## Recommended IDE Setup
|
- 서비스명 **돈돼지 가계부** (구 Slim Budget)
|
||||||
|
- 운영: [app.sblog.kr](https://app.sblog.kr) · 안내/정책 페이지 [www.sblog.kr](https://www.sblog.kr)
|
||||||
|
|
||||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
## 기술 스택
|
||||||
|
- **Vue 3** (`<script setup>` Composition API) + **Vite**
|
||||||
## Recommended Browser Setup
|
- **Pinia** (상태관리), **Vue Router** (라우팅 + 인증 가드)
|
||||||
|
- **Axios** (공통 인스턴스 + 토큰/401 인터셉터)
|
||||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
- **Toast UI Editor** + Prism.js (게시판 마크다운 작성/뷰어)
|
||||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
- **Capacitor** (안드로이드 하이브리드 앱), **Electron** (Windows 데스크톱)
|
||||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
- **Vitest** + `@vue/test-utils` (테스트)
|
||||||
- Firefox:
|
|
||||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
|
||||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
|
||||||
|
|
||||||
## Customize configuration
|
|
||||||
|
|
||||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
|
||||||
|
|
||||||
## Project Setup
|
|
||||||
|
|
||||||
|
## 빠른 시작
|
||||||
```sh
|
```sh
|
||||||
npm install
|
npm install
|
||||||
|
npm run dev # 개발 서버 (전체 앱 표시 — 웹 접근 제한 예외)
|
||||||
```
|
```
|
||||||
|
- Node: `^20.19.0 || >=22.12.0`
|
||||||
|
- API 베이스 URL: `VITE_API_BASE_URL` (기본 `/api`, Nginx 가 백엔드 8080 으로 프록시)
|
||||||
|
|
||||||
### Compile and Hot-Reload for Development
|
## 주요 스크립트
|
||||||
|
| 명령 | 설명 |
|
||||||
|
|---|---|
|
||||||
|
| `npm run dev` | 개발 서버 (HMR) |
|
||||||
|
| `npm run build` | `dist/` 정적 빌드 (웹 배포용) |
|
||||||
|
| `npm run build:app` | Capacitor 모드 빌드 (`--mode capacitor`, 앱 전용 env) |
|
||||||
|
| `npm run cap:sync` | 앱 빌드 + `cap sync` (안드로이드 동기화) |
|
||||||
|
| `npm run cap:android` | Android Studio 열기 |
|
||||||
|
| `npm run electron:dev` | Electron 데스크톱 실행 |
|
||||||
|
| `npm run electron:build` | Windows 설치파일(`DonDwaeji-Setup.exe`) 빌드 |
|
||||||
|
| `npm test` | Vitest 실행 |
|
||||||
|
| `npm run lint` | oxlint + eslint (`--fix`) |
|
||||||
|
|
||||||
```sh
|
## 플랫폼별 동작
|
||||||
npm run dev
|
- **웹(PC·모바일)**: 안내 페이지만 노출(앱 다운로드 유도). 실제 이용은 앱에서. (`npm run dev` 는 예외로 전체 앱 표시)
|
||||||
```
|
- **안드로이드**: Capacitor WebView 패키징. 빌드 전 `.env.capacitor` 의 백엔드 절대 URL 설정 필수 → [docs/ANDROID.md](docs/ANDROID.md)
|
||||||
|
- **Windows**: Electron 이 라이브 사이트(app.sblog.kr)를 직접 로드 → 프론트 변경이 재설치 없이 반영
|
||||||
|
|
||||||
### Compile and Minify for Production
|
## 인증
|
||||||
|
- 로그인 시 발급된 **Bearer 토큰**을 `localStorage` 에 저장, `http.js` 인터셉터가 `Authorization` 헤더에 주입
|
||||||
|
- **구글 로그인**(웹/PC=GIS, 앱=네이티브 플러그인) — 현재 소셜 로그인 전용(아이디/비번 폼은 `features.js` 플래그로 숨김)
|
||||||
|
- 401 응답 시 토큰 정리 후 로그인 모달, 라우트 `meta.requiresAuth` / `requiresAdmin` 가드
|
||||||
|
|
||||||
```sh
|
## 주요 기능
|
||||||
npm run build
|
- **가계부** — 수입·지출·이체 기록, 검색·필터, 날짜 그룹 접기/펴기, 외화 입력(환율 자동환산)
|
||||||
```
|
- **계좌·자산** — 은행·현금·카드·대출·투자 계좌, 순자산 자동 집계
|
||||||
|
- **고정 지출** — 반복 규칙 자동 생성 + '확인' 확정 흐름
|
||||||
|
- **통계** — 분류별 분석, 순자산 추이, 월말 예상 지출, 지난달 대비 (대시보드 차트)
|
||||||
|
- **예산** — 월별 카테고리 예산, 예산 대비 진행률
|
||||||
|
- **게시판** — 커뮤니티/짠테크/재테크 3개 게시판, 댓글·태그·이미지 업로드·공지
|
||||||
|
- **테마** — 시스템/라이트/다크 모드
|
||||||
|
|
||||||
### Lint with [ESLint](https://eslint.org/)
|
## 문서
|
||||||
|
| 문서 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| [docs/FRONTEND.md](docs/FRONTEND.md) | 아키텍처·디렉터리 구조·화면 명세 |
|
||||||
|
| [docs/release-2026-06-06.md](docs/release-2026-06-06.md) | 누적 변경 이력 (릴리스 노트) |
|
||||||
|
| [docs/ANDROID.md](docs/ANDROID.md) | 안드로이드(Capacitor) 빌드·배포 |
|
||||||
|
| [docs/plan-membership-tiers.md](docs/plan-membership-tiers.md) | 무료/유료 티어 설계 (미구현) |
|
||||||
|
| [deploy/README.md](deploy/README.md) | 프론트 배포(Nginx + Gitea Actions) |
|
||||||
|
| [brand/LAUNCH-PROGRESS.md](brand/LAUNCH-PROGRESS.md) | Play 스토어 출시 진행 상황·로드맵 |
|
||||||
|
|
||||||
```sh
|
## 배포
|
||||||
npm run lint
|
- `main` 푸시 → Gitea Actions 가 CI(빌드·테스트) + Deploy(Nginx 업로드·reload) 실행
|
||||||
```
|
- **테스트 게이트**: CI·배포 워크플로 모두 테스트 실패 시 배포 중단
|
||||||
|
- 상세: [deploy/README.md](deploy/README.md)
|
||||||
|
|||||||
+49
-31
@@ -1,16 +1,23 @@
|
|||||||
# Slim Budget — 프론트엔드 (sb_pt)
|
# 돈돼지 가계부 — 프론트엔드 (sb_pt)
|
||||||
|
|
||||||
Vue 3 + Vite 기반의 가계부·게시판 웹 애플리케이션 프론트엔드.
|
Vue 3 + Vite 기반의 가계부·자산·게시판 앱 프론트엔드. 하나의 코드베이스로 **웹 / 안드로이드(Capacitor) / Windows 데스크톱(Electron)** 3개 플랫폼을 빌드합니다.
|
||||||
|
|
||||||
## 기술 스택
|
## 기술 스택
|
||||||
- **Vue 3** (`<script setup>` Composition API) + **Vite**
|
- **Vue 3** (`<script setup>` Composition API) + **Vite**
|
||||||
- **Pinia** (상태관리), **Vue Router** (라우팅)
|
- **Pinia** (상태관리), **Vue Router** (라우팅)
|
||||||
- **Axios** (HTTP, 공통 인스턴스 + 토큰/401 인터셉터)
|
- **Axios** (HTTP, 공통 인스턴스 + 토큰/401 인터셉터)
|
||||||
- **Toast UI Editor** (`@toast-ui/editor`) + Prism.js (게시판 마크다운 작성/뷰어·코드 하이라이트)
|
- **Toast UI Editor** (`@toast-ui/editor`) + Prism.js (게시판 마크다운 작성/뷰어·코드 하이라이트)
|
||||||
|
- **Capacitor**(안드로이드 하이브리드 앱) · **Electron**(Windows 데스크톱) · **Vitest**(테스트)
|
||||||
|
|
||||||
## 브랜딩
|
## 브랜딩
|
||||||
- 서비스명 **Slim Budget** (헤더 로고/타이틀/푸터)
|
- 서비스명 **돈돼지 가계부** (구 Slim Budget)
|
||||||
- 로고: `public/logo.svg` (브랜드 그린 `#00bc7e` 배지 + 슬림 막대 차트), 파비콘으로도 사용
|
- 로고: 돈돼지(투명 배경) — 헤더/사이드바 로고·파비콘·앱 런처 아이콘. 브랜드 그린 `#00bc7e`
|
||||||
|
|
||||||
|
## 플랫폼별 동작
|
||||||
|
- **웹(PC·모바일)**: 안내 페이지([WebOnlyNotice.vue](../src/components/WebOnlyNotice.vue))만 노출(앱 다운로드 유도). 실제 이용은 앱에서. `npm run dev` 는 예외로 전체 앱 표시
|
||||||
|
- **안드로이드**: Capacitor WebView 패키징 → [ANDROID.md](ANDROID.md)
|
||||||
|
- **Windows**: Electron 이 라이브 사이트(app.sblog.kr)를 직접 로드 → 프론트 변경이 재설치 없이 반영
|
||||||
|
- 기능 플래그 [src/config/features.js](../src/config/features.js): `ID_LOGIN_ENABLED`(아이디 로그인)·`BILLING_ENABLED`(인앱결제)·`ADS_ENABLED`(광고)·`CARD_NOTIF_ENABLED`(카드알림) — 첫 출시 기준 전부 `false`
|
||||||
|
|
||||||
## 디렉터리 구조
|
## 디렉터리 구조
|
||||||
```
|
```
|
||||||
@@ -21,65 +28,76 @@ src/
|
|||||||
│ ├── boardApi.js # 게시판
|
│ ├── boardApi.js # 게시판
|
||||||
│ ├── adminApi.js # 관리자(태그 카테고리)
|
│ ├── adminApi.js # 관리자(태그 카테고리)
|
||||||
│ └── accountApi.js # 가계부 전체
|
│ └── accountApi.js # 가계부 전체
|
||||||
|
│ ├── billingApi.js # 인앱결제/구독
|
||||||
├── stores/ # Pinia (auth, ui, user)
|
├── stores/ # Pinia (auth, ui, user)
|
||||||
|
├── config/features.js # 기능 플래그(아이디로그인·결제·광고·카드알림)
|
||||||
├── router/index.js # 라우트 + 인증 가드
|
├── router/index.js # 라우트 + 인증 가드
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── layout/ # AppHeader / AppSidebar / AppFooter
|
│ ├── layout/ # AppHeader / AppSidebar / AppFooter / AppBottomNav
|
||||||
│ ├── ui/IconBtn.vue # 공통 SVG 아이콘 버튼
|
│ ├── ui/ # IconBtn / AppModal(전체화면 슬라이드업) / AppDialog / CategoryPicker
|
||||||
│ ├── editor/ # MarkdownEditor / MarkdownViewer
|
│ ├── editor/ # MarkdownEditor / MarkdownViewer
|
||||||
│ ├── LoginModal.vue / SignupModal.vue
|
│ ├── LoginModal.vue / SignupModal.vue / AppLockScreen.vue(PIN) / AdBanner.vue / WebOnlyNotice.vue
|
||||||
├── views/
|
├── views/
|
||||||
│ ├── HomeView.vue # 홈(대시보드 자리, 현재 빈 공간)
|
│ ├── HomeView.vue # 홈 대시보드(요약·일별 캘린더)
|
||||||
│ ├── UsersView.vue # 사용자 관리
|
|
||||||
│ ├── board/ # 게시판 (목록/상세/작성)
|
│ ├── board/ # 게시판 (목록/상세/작성)
|
||||||
│ ├── account/ # 가계부 (아래 참조)
|
│ ├── account/ # 가계부 (아래 참조)
|
||||||
│ └── admin/ # 태그 관리(관리자)
|
│ └── admin/ # 회원/태그 관리(관리자)
|
||||||
└── assets/main.css # 전역 스타일
|
└── assets/ # base.css(data-theme 다크모드) 등 전역 스타일
|
||||||
```
|
```
|
||||||
|
|
||||||
## 인증
|
## 인증
|
||||||
- 로그인 시 발급된 **Bearer 토큰**을 `localStorage`에 저장, `http.js` 요청 인터셉터가 `Authorization` 헤더에 주입
|
- 로그인 시 발급된 **Bearer 토큰**을 `localStorage`에 저장, `http.js` 요청 인터셉터가 `Authorization` 헤더에 주입
|
||||||
|
- **구글 로그인**(웹/PC=Google Identity Services, 앱=네이티브 플러그인) — 현재 소셜 로그인 전용(`ID_LOGIN_ENABLED=false` 로 아이디/비번 폼·회원가입 UI 숨김)
|
||||||
- 401 응답 시 토큰 정리 후 `auth:unauthorized` 이벤트 → 로그인 모달 표시
|
- 401 응답 시 토큰 정리 후 `auth:unauthorized` 이벤트 → 로그인 모달 표시
|
||||||
- 라우트 `meta.requiresAuth` / `requiresAdmin` 가드
|
- 라우트 `meta.requiresAuth` / `requiresAdmin` 가드
|
||||||
|
|
||||||
## 레이아웃 / 반응형
|
## 레이아웃 / 반응형 / 테마
|
||||||
- 그리드 레이아웃(헤더·사이드바·본문·푸터)
|
- 그리드 레이아웃 — 사이드바가 좌측 상단(헤더 행)까지, 헤더는 현재 화면 타이틀 표시. 본문 폭 `--page-max`(760px)로 통일
|
||||||
|
- **하단 고정 내비**([AppBottomNav.vue](../src/components/layout/AppBottomNav.vue)): 뒤로·앞으로·홈·새로고침·설정 (안드로이드 소프트키 위 안전영역)
|
||||||
- **모바일(≤768px)**: 사이드바가 **오프캔버스 드로어**(햄버거 토글 + 백드롭, 라우트 이동 시 자동 닫힘)
|
- **모바일(≤768px)**: 사이드바가 **오프캔버스 드로어**(햄버거 토글 + 백드롭, 라우트 이동 시 자동 닫힘)
|
||||||
- 모든 액션 버튼은 **아이콘화**(`IconBtn`: 등록/수정/삭제/검색/페이징 등), `title`로 접근성·툴팁 제공
|
- **입력 모달**: 전체화면 슬라이드업([AppModal.vue](../src/components/ui/AppModal.vue), 바깥 닫힘 방지). 셀렉트 대신 **뱃지/칩** 선택, 분류는 공통 [CategoryPicker.vue](../src/components/ui/CategoryPicker.vue)(대/소분류 아코디언)
|
||||||
- 한 줄에 담기 어려운 목록은 **2줄 표기**(게시판: 제목/메타, 가계부 내역: 분류·금액/날짜·메모·액션 등)
|
- **테마**: 시스템 / 라이트 / 다크 (설정 화면, `data-theme` 기반)
|
||||||
|
- 모든 액션 버튼은 **아이콘화**(`IconBtn`), `title`로 접근성·툴팁 제공
|
||||||
|
|
||||||
## 게시판 (`/board`)
|
## 게시판 (`/board`)
|
||||||
- 목록(페이징·태그 필터·검색[제목/본문/댓글]), 상세, 작성/수정(마크다운)
|
- **커뮤니티 / 짠테크 수다방 / 재테크 팁** 3개 게시판(카테고리)
|
||||||
- 댓글, 태그, 관리자 **열람 제한/해제**
|
- 목록(페이징·태그 필터·검색[제목/본문/댓글]·공지 최상단), 상세, 작성/수정(마크다운·이미지 업로드)
|
||||||
|
- 댓글, 추천(vote), 태그, **공지**(관리자), 관리자 **열람 제한/해제**, 신고
|
||||||
|
|
||||||
## 가계부 (`/account`)
|
## 가계부 (`/account`)
|
||||||
사용자별 데이터 격리(본인 데이터만). 주요 화면(사이드바 하위 메뉴):
|
사용자별 데이터 격리(본인 데이터만). 주요 화면(사이드바 하위 메뉴):
|
||||||
|
|
||||||
| 경로 | 화면 | 설명 |
|
| 경로 | 화면 | 설명 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `/account` | 대시보드 | 예산 대비 지출 도넛, **분류별 파이차트**, 기간별 예산대비지출 막대(툴팁), 수입대비지출, 월별 총액, **순자산 추이** 라인차트 |
|
| `/account` | 대시보드 | 예산 대비 지출 도넛, **분류별 파이차트**(대분류 집계), 수입대비지출(월말 예상·지난달 대비), 월별 총액, **순자산 추이**(주간) 라인차트, 일별 캘린더 |
|
||||||
| `/account/entries` | 가계부 내역 | 월별 내역 CRUD, **검색·필터**(키워드/구분/분류/계좌/태그), 분류 인라인 추가 |
|
| `/account/entries` | 가계부 내역 | 월별 내역 CRUD, **검색·필터**(키워드/구분/분류/계좌/태그), 날짜 그룹 접기/펴기, **외화 입력**(환율 자동환산), 내역→고정지출 바로 등록 |
|
||||||
| `/account/recurrings` | 정기 거래 | 일/주/월/년 반복 규칙, 진입 시 자동 반영(중복 없이) |
|
| `/account/recurrings` | 고정 지출 | 일/주/월/년 반복 규칙, 진입 시 자동 반영(중복 없이·'확인 필요'로 생성) |
|
||||||
| `/account/wallets` | 계좌 관리 | 은행/카드/대출/**투자** 계좌, 잔액·순자산, 상환/납부, **투자 포트폴리오** |
|
| `/account/wallets` | 계좌 관리 | 은행/**현금**/카드/대출/**투자** 계좌, 잔액·순자산, 상환/납부(원금·이자·연회비 분리), 월별 이용내역 |
|
||||||
| `/account/categories` | 분류 관리 | 수입/지출 분류 CRUD, 이름 변경 전파, 기존 내역에서 가져오기 |
|
| `/account/categories` | 분류 관리 | 수입/지출 분류 CRUD(**대/소분류 2단계**), 칩 그리드 관리, 순서변경, 기존 내역에서 가져오기 |
|
||||||
| `/account/budget` | 예산 설정 | 고정(일수 기준 자동 환산)/비고정, 예산 대비 진행률 |
|
| `/account/budget` | 예산 설정 | **월별 예산**(전월/다음달 복사), 고정(일수 기준 자동 환산)/비고정, 예산 대비 진행률 |
|
||||||
| `/account/tags` | 태그 관리 | 사용자별 가계부 태그 |
|
| `/account/tags` | 태그 관리 | 사용자별 가계부 태그 |
|
||||||
|
|
||||||
### 잔액·순자산 모델 (표시)
|
### 잔액·순자산 모델 (표시)
|
||||||
- 자산(+) / 부채(−) 부호 모델. 지갑 잔액 = 개시잔액 + 수입 − 지출 − 이체출금 + 이체입금
|
- 자산(+) / 부채(−) 부호 모델. 현금은 은행과 동일하게 자산 합산. 지갑 잔액 = 개시잔액 + 수입 − 지출 − 이체출금 + 이체입금
|
||||||
- 순자산 = 총자산 − 총부채 (투자계좌는 평가금액 반영)
|
- 순자산 = 총자산 − 총부채 (투자계좌는 평가금액 반영)
|
||||||
|
|
||||||
### 투자 포트폴리오 (계좌 관리 > 투자 탭)
|
### 투자 계좌 (계좌 관리 > 투자 탭)
|
||||||
- 투자계좌를 펼치면 [InvestPortfolio.vue](../src/views/account/InvestPortfolio.vue) 패널
|
- 투자계좌를 펼치면 [InvestPortfolio.vue](../src/views/account/InvestPortfolio.vue) 패널
|
||||||
- **종목별** 보유: 수량·평균매입단가·현재가(수동)·평가금액·**평가손익·수익률**·실현손익
|
- **단순형 UI**: **투자금(투입원금) + 현재 평가액**만 입력 → 손익·수익률 자동 계산(퇴직연금·연금 등 포함). 계좌 펼침 시 투자금/평가액/손익(%) 요약 카드
|
||||||
- **매수/매도** 건별 기록(이동평균 평단 자동 산출, 매도수량 초과 검증)
|
- 입금은 가계부 **이체**(은행→투자)로, 평가액은 순자산·추이에 반영
|
||||||
- 입금은 가계부 **이체**(은행→투자)로, 예수금·주식평가는 자동 계산되어 순자산·추이에 반영
|
- ℹ️ 백엔드는 종목 단위(매수·매도·시세 자동조회) API 를 유지하나 현재 UI 에는 노출하지 않음. 원화 기준
|
||||||
- 원화·정수 수량 기준 (해외주식 소수점/환율·자동 시세는 범위 외)
|
|
||||||
|
## 설정 화면
|
||||||
|
- 하단 내비 → 설정: 계정정보(이름·이메일·비밀번호 변경) · 테마(시스템/라이트/다크) · 앱 잠금(PIN, 1시간 유예) · 앱 버전 · App Data 삭제 · (PC)창 크기 프리셋
|
||||||
|
|
||||||
## 개발/빌드
|
## 개발/빌드
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run dev # 개발 서버
|
npm run dev # 개발 서버(전체 앱 표시)
|
||||||
npm run build # dist/ 정적 빌드
|
npm run build # dist/ 정적 빌드(웹 배포)
|
||||||
|
npm run cap:sync # 앱 빌드 + 안드로이드 동기화
|
||||||
|
npm run electron:build # Windows 설치파일
|
||||||
|
npm test # Vitest
|
||||||
```
|
```
|
||||||
- API 베이스 URL: `VITE_API_BASE_URL` (기본 `/api`)
|
- API 베이스 URL: `VITE_API_BASE_URL` (기본 `/api`)
|
||||||
|
- 상세: [../README.md](../README.md) · 앱 [ANDROID.md](ANDROID.md) · 배포 [../deploy/README.md](../deploy/README.md)
|
||||||
|
|||||||
@@ -76,6 +76,14 @@ export const boardApi = {
|
|||||||
unblockComment(commentId) {
|
unblockComment(commentId) {
|
||||||
return http.post(`/board/comments/${commentId}/unblock`)
|
return http.post(`/board/comments/${commentId}/unblock`)
|
||||||
},
|
},
|
||||||
|
// 신고 관리 (관리자) — 신고된 글/댓글 목록
|
||||||
|
reports() {
|
||||||
|
return http.get('/board/reports')
|
||||||
|
},
|
||||||
|
// 신고 무시 (관리자) — 블라인드 없이 신고 기록만 초기화
|
||||||
|
dismissReport(targetType, targetId) {
|
||||||
|
return http.post('/board/reports/dismiss', { targetType, targetId })
|
||||||
|
},
|
||||||
block(id, reason) {
|
block(id, reason) {
|
||||||
return http.post(`/board/posts/${id}/block`, { reason })
|
return http.post(`/board/posts/${id}/block`, { reason })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const TITLES = {
|
|||||||
users: '회원 관리',
|
users: '회원 관리',
|
||||||
'admin-tags': '태그 관리(관리자)',
|
'admin-tags': '태그 관리(관리자)',
|
||||||
'admin-default-categories': '기본 분류 설정',
|
'admin-default-categories': '기본 분류 설정',
|
||||||
|
'admin-reports': '신고 관리',
|
||||||
settings: '설정',
|
settings: '설정',
|
||||||
'settings-account': '계정정보',
|
'settings-account': '계정정보',
|
||||||
'settings-account-edit': '가입정보 변경',
|
'settings-account-edit': '가입정보 변경',
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const icons = {
|
|||||||
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"/>',
|
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"/>',
|
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"/>',
|
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"/>',
|
||||||
|
flag: '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/>',
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -106,6 +107,10 @@ const icons = {
|
|||||||
<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" />
|
<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>
|
<span>기본 분류 설정</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<RouterLink to="/admin/reports" 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.flag" />
|
||||||
|
<span>신고 관리</span>
|
||||||
|
</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -104,6 +104,12 @@ const router = createRouter({
|
|||||||
component: () => import('../views/admin/DefaultCategoryView.vue'),
|
component: () => import('../views/admin/DefaultCategoryView.vue'),
|
||||||
meta: { requiresAuth: true, requiresAdmin: true },
|
meta: { requiresAuth: true, requiresAdmin: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/reports',
|
||||||
|
name: 'admin-reports',
|
||||||
|
component: () => import('../views/admin/ReportAdminView.vue'),
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// 구 가계부 대시보드 → 내역으로 (대시보드 정보는 홈/통계로 이전)
|
// 구 가계부 대시보드 → 내역으로 (대시보드 정보는 홈/통계로 이전)
|
||||||
path: '/account',
|
path: '/account',
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { boardApi } from '@/api/boardApi'
|
||||||
|
import { useDialog } from '@/composables/dialog'
|
||||||
|
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
const items = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
const busyKey = ref(null) // 처리 중인 대상 키 (버튼 비활성화용)
|
||||||
|
|
||||||
|
const CATEGORY_LABEL = { community: '커뮤니티', saving: '짠테크 수다방', tips: '재테크 팁' }
|
||||||
|
|
||||||
|
const blockedCount = computed(() => items.value.filter((i) => i.blocked).length)
|
||||||
|
|
||||||
|
function keyOf(it) {
|
||||||
|
return `${it.targetType}:${it.targetId}`
|
||||||
|
}
|
||||||
|
function categoryLabel(c) {
|
||||||
|
return CATEGORY_LABEL[c] || c || '게시판'
|
||||||
|
}
|
||||||
|
function linkTo(it) {
|
||||||
|
return `/board/${it.category || 'community'}/${it.postId}`
|
||||||
|
}
|
||||||
|
function formatTime(s) {
|
||||||
|
if (!s) return ''
|
||||||
|
const d = new Date(s)
|
||||||
|
if (Number.isNaN(d.getTime())) return s
|
||||||
|
return d.toLocaleString('ko-KR', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
items.value = await boardApi.reports()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data?.message || '신고 목록을 불러오지 못했습니다.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(it, fn, successMsg) {
|
||||||
|
busyKey.value = keyOf(it)
|
||||||
|
try {
|
||||||
|
await fn()
|
||||||
|
await load()
|
||||||
|
if (successMsg) await dialog.alert(successMsg)
|
||||||
|
} catch (e) {
|
||||||
|
await dialog.alert(e.response?.data?.message || '처리에 실패했습니다.')
|
||||||
|
} finally {
|
||||||
|
busyKey.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 글 블라인드 (사유 입력)
|
||||||
|
async function blockPost(it) {
|
||||||
|
const reason = await dialog.prompt('블라인드 사유를 입력하세요. (열람 제한 안내에 표시)', {
|
||||||
|
title: '글 블라인드',
|
||||||
|
okText: '블라인드',
|
||||||
|
defaultValue: '신고 누적으로 블라인드 처리되었습니다.',
|
||||||
|
})
|
||||||
|
if (reason === null) return
|
||||||
|
await run(it, () => boardApi.block(it.postId, reason || null), '블라인드 처리했습니다.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 블라인드 해제 (글/댓글) — 신고 기록도 초기화됨
|
||||||
|
async function unblock(it) {
|
||||||
|
const ok = await dialog.confirm('블라인드를 해제하고 신고 기록을 초기화할까요?', {
|
||||||
|
title: '블라인드 해제',
|
||||||
|
okText: '해제',
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
const call = it.targetType === 'POST'
|
||||||
|
? () => boardApi.unblock(it.postId)
|
||||||
|
: () => boardApi.unblockComment(it.targetId)
|
||||||
|
await run(it, call, '블라인드를 해제했습니다.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 신고 무시 — 블라인드 없이 신고 기록만 삭제
|
||||||
|
async function dismiss(it) {
|
||||||
|
const ok = await dialog.confirm('이 신고를 무시하고 목록에서 제거할까요? (블라인드하지 않고 신고 기록만 삭제)', {
|
||||||
|
title: '신고 무시',
|
||||||
|
okText: '무시',
|
||||||
|
danger: true,
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
await run(it, () => boardApi.dismissReport(it.targetType, it.targetId))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="report-admin">
|
||||||
|
<div class="head">
|
||||||
|
<p class="desc">
|
||||||
|
신고 누적 <strong>5건</strong> 이상이면 자동 블라인드됩니다. 관리자는 여기서 신고 내용을 검토해
|
||||||
|
<b>블라인드</b> 하거나, 정상 글이면 <b>신고 무시</b>로 정리할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<button class="reload" type="button" :disabled="loading" @click="load">↻ 새로고침</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="msg error">{{ error }}</p>
|
||||||
|
<p v-if="loading" class="msg">불러오는 중…</p>
|
||||||
|
|
||||||
|
<p v-else-if="!items.length" class="msg empty">🎉 신고된 글·댓글이 없습니다.</p>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<p class="count">
|
||||||
|
총 <strong>{{ items.length }}</strong>건
|
||||||
|
<span v-if="blockedCount"> · 블라인드 {{ blockedCount }}건</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="list">
|
||||||
|
<li v-for="it in items" :key="keyOf(it)" class="card" :class="{ blocked: it.blocked }">
|
||||||
|
<div class="row1">
|
||||||
|
<span class="badge" :class="it.targetType === 'POST' ? 'post' : 'comment'">
|
||||||
|
{{ it.targetType === 'POST' ? '글' : '댓글' }}
|
||||||
|
</span>
|
||||||
|
<span class="cat">{{ categoryLabel(it.category) }}</span>
|
||||||
|
<span class="report-count">🚩 {{ it.reportCount }}</span>
|
||||||
|
<span v-if="it.blocked" class="badge blind">블라인드됨</span>
|
||||||
|
<span class="time">{{ formatTime(it.lastReportedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RouterLink :to="linkTo(it)" class="title-link">
|
||||||
|
<span v-if="it.targetType === 'COMMENT'" class="on-post">[{{ it.title }}] 의 댓글</span>
|
||||||
|
<span v-else class="ttl">{{ it.title }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
<p class="content">{{ it.content }}</p>
|
||||||
|
<p class="author">작성자: {{ it.authorName || '알 수 없음' }}</p>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<RouterLink :to="linkTo(it)" class="btn ghost">글 보기</RouterLink>
|
||||||
|
<button
|
||||||
|
v-if="it.targetType === 'POST' && !it.blocked"
|
||||||
|
class="btn danger"
|
||||||
|
type="button"
|
||||||
|
:disabled="busyKey === keyOf(it)"
|
||||||
|
@click="blockPost(it)"
|
||||||
|
>
|
||||||
|
블라인드
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="it.blocked"
|
||||||
|
class="btn"
|
||||||
|
type="button"
|
||||||
|
:disabled="busyKey === keyOf(it)"
|
||||||
|
@click="unblock(it)"
|
||||||
|
>
|
||||||
|
블라인드 해제
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="!it.blocked"
|
||||||
|
class="btn ghost"
|
||||||
|
type="button"
|
||||||
|
:disabled="busyKey === keyOf(it)"
|
||||||
|
@click="dismiss(it)"
|
||||||
|
>
|
||||||
|
신고 무시
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.report-admin {
|
||||||
|
padding: 0.5rem 0 2rem;
|
||||||
|
}
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.desc {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-soft, #666);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.reload {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-background-soft);
|
||||||
|
color: var(--color-text);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.msg {
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--color-text-soft, #666);
|
||||||
|
}
|
||||||
|
.msg.error {
|
||||||
|
color: #e5484d;
|
||||||
|
}
|
||||||
|
.msg.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
}
|
||||||
|
.count {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-soft, #666);
|
||||||
|
}
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
background: var(--color-background);
|
||||||
|
}
|
||||||
|
.card.blocked {
|
||||||
|
border-color: #e5484d55;
|
||||||
|
background: #e5484d0d;
|
||||||
|
}
|
||||||
|
.row1 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.12rem 0.45rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.badge.post {
|
||||||
|
background: #00bc7e22;
|
||||||
|
color: #00996a;
|
||||||
|
}
|
||||||
|
.badge.comment {
|
||||||
|
background: #3b82f622;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
.badge.blind {
|
||||||
|
background: #e5484d22;
|
||||||
|
color: #e5484d;
|
||||||
|
}
|
||||||
|
.cat {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-soft, #888);
|
||||||
|
}
|
||||||
|
.report-count {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #e5484d;
|
||||||
|
}
|
||||||
|
.time {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-soft, #999);
|
||||||
|
}
|
||||||
|
.title-link {
|
||||||
|
display: block;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.title-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.on-post {
|
||||||
|
color: var(--color-text-soft, #777);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--color-text-soft, #555);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.author {
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-soft, #999);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-background-soft);
|
||||||
|
color: var(--color-text);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.btn.ghost {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.btn.danger {
|
||||||
|
border-color: #e5484d;
|
||||||
|
color: #e5484d;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user