feat: v3.1 대시보드 추가 (React + FastAPI)
Media Engine Control Panel — 6탭 웹 대시보드 [백엔드] FastAPI (dashboard/backend/) - server.py: 포트 8080, CORS, React SPA 서빙 - api_overview.py: KPI 카드 + 파이프라인 상태 + 활동 로그 - api_content.py: 칸반 보드 + 승인/거부 + 수동 트리거 - api_analytics.py: 방문자 추이 + 플랫폼/코너별 성과 - api_novels.py: 소설 목록/생성/에피소드 관리 - api_settings.py: engine.json CRUD - api_connections.py: AI 서비스 연결 관리 + 키 저장 - api_tools.py: 기능별 AI 도구 선택 - api_cost.py: 구독 현황 + API 사용량 추적 - api_logs.py: 시스템 로그 필터/검색 [프론트엔드] React + Vite + Tailwind + Recharts (dashboard/frontend/) - Overview: KPI 카드 + 파이프라인 + 코너별 바차트 + 활동 로그 - Content: 4열 칸반 보드 + 상세 모달 + 승인/거부 - Analytics: LineChart 방문자 추이 + 플랫폼별 성과 - Novel: 소설 목록 + 에피소드 테이블 + 새 소설 생성 폼 - Settings: 5개 서브탭 (AI연결/도구선택/배포채널/품질/비용관리) - Logs: 필터/검색 시스템 로그 뷰어 [디자인] CNN 다크+골드 테마 - 배경 #0a0a0d + 액센트 #c8a84e - 모바일 반응형 (Tailscale 외부 접속 대응) [실행] - dashboard/start.bat 더블클릭 → http://localhost:8080 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
122
dashboard/README.md
Normal file
122
dashboard/README.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# The 4th Path — Control Panel
|
||||
|
||||
미디어 엔진 컨트롤 패널 (React + FastAPI)
|
||||
|
||||
## 구조
|
||||
|
||||
```
|
||||
dashboard/
|
||||
├── backend/
|
||||
│ ├── server.py # FastAPI 메인
|
||||
│ ├── api_overview.py # 개요 탭 API
|
||||
│ ├── api_content.py # 콘텐츠 탭 API
|
||||
│ ├── api_analytics.py # 분석 탭 API
|
||||
│ ├── api_novels.py # 소설 탭 API
|
||||
│ ├── api_settings.py # 설정 API
|
||||
│ ├── api_connections.py# 연결 상태 API
|
||||
│ ├── api_tools.py # 도구 선택 API
|
||||
│ ├── api_cost.py # 비용 모니터 API
|
||||
│ └── api_logs.py # 로그 API
|
||||
└── frontend/
|
||||
├── src/
|
||||
│ ├── App.jsx
|
||||
│ ├── pages/
|
||||
│ │ ├── Overview.jsx
|
||||
│ │ ├── Content.jsx
|
||||
│ │ ├── Analytics.jsx
|
||||
│ │ ├── Novel.jsx
|
||||
│ │ ├── Settings.jsx
|
||||
│ │ ├── Logs.jsx
|
||||
│ │ └── settings/
|
||||
│ │ ├── Connections.jsx
|
||||
│ │ ├── ToolSelect.jsx
|
||||
│ │ ├── Distribution.jsx
|
||||
│ │ ├── Quality.jsx
|
||||
│ │ └── CostMonitor.jsx
|
||||
│ └── styles/
|
||||
│ └── theme.css
|
||||
├── package.json
|
||||
├── vite.config.js
|
||||
└── tailwind.config.js
|
||||
```
|
||||
|
||||
## 설치 및 실행
|
||||
|
||||
### 필수 요건
|
||||
|
||||
- Python 3.9+
|
||||
- Node.js 18+
|
||||
- npm 9+
|
||||
|
||||
### 백엔드 의존성 설치
|
||||
|
||||
```bash
|
||||
cd D:/workspace/blog-writer
|
||||
pip install fastapi uvicorn python-dotenv
|
||||
```
|
||||
|
||||
### 프론트엔드 의존성 설치
|
||||
|
||||
```bash
|
||||
cd D:/workspace/blog-writer/dashboard/frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
## 실행 방법
|
||||
|
||||
### Windows (더블클릭)
|
||||
|
||||
- **프로덕션**: `start.bat` 더블클릭
|
||||
- **개발 모드**: `start_dev.bat` 더블클릭
|
||||
|
||||
### Linux/Mac
|
||||
|
||||
```bash
|
||||
# 프로덕션 (프론트 빌드 후 백엔드만)
|
||||
bash dashboard/start.sh
|
||||
|
||||
# 개발 모드 (Vite 핫리로드 + 백엔드 reload)
|
||||
bash dashboard/start.sh dev
|
||||
```
|
||||
|
||||
### 수동 실행
|
||||
|
||||
```bash
|
||||
# 터미널 1 — 백엔드
|
||||
cd D:/workspace/blog-writer
|
||||
python -m uvicorn dashboard.backend.server:app --port 8080 --reload
|
||||
|
||||
# 터미널 2 — 프론트엔드 (개발)
|
||||
cd D:/workspace/blog-writer/dashboard/frontend
|
||||
npm run dev
|
||||
|
||||
# 또는 프론트 빌드 (프로덕션)
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 접속
|
||||
|
||||
| 모드 | URL |
|
||||
|------|-----|
|
||||
| 프로덕션 | http://localhost:8080 |
|
||||
| 개발(프론트) | http://localhost:5173 |
|
||||
| API 문서 | http://localhost:8080/docs |
|
||||
|
||||
## 탭 구성
|
||||
|
||||
| 탭 | 기능 |
|
||||
|----|------|
|
||||
| 개요 | KPI 카드 · 파이프라인 상태 · 코너별 비율 · 활동 로그 |
|
||||
| 콘텐츠 | 칸반 보드 · 승인/거부 |
|
||||
| 분석 | 방문자 추이 · 코너별 성과 · 인기글 |
|
||||
| 소설 | 연재 관리 · 에피소드 생성 |
|
||||
| 설정 | AI 연결 · 도구 선택 · 배포채널 · 품질 · 비용 |
|
||||
| 로그 | 시스템 로그 필터/검색 |
|
||||
|
||||
## Tailscale 외부 접속
|
||||
|
||||
```bash
|
||||
# 백엔드를 0.0.0.0으로 바인딩하면 Tailscale IP로 접속 가능
|
||||
python -m uvicorn dashboard.backend.server:app --host 0.0.0.0 --port 8080
|
||||
# 접속: http://<tailscale-ip>:8080
|
||||
```
|
||||
1
dashboard/__init__.py
Normal file
1
dashboard/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# dashboard package
|
||||
1
dashboard/backend/__init__.py
Normal file
1
dashboard/backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# dashboard/backend package
|
||||
131
dashboard/backend/api_analytics.py
Normal file
131
dashboard/backend/api_analytics.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
dashboard/backend/api_analytics.py
|
||||
Analytics 탭 API — 방문자 통계, KPI, 코너별 성과
|
||||
"""
|
||||
import json
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
DATA_DIR = BASE_DIR / "data"
|
||||
ANALYTICS_DIR = DATA_DIR / "analytics"
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _load_all_analytics() -> list:
|
||||
"""analytics/*.json 전체 로드"""
|
||||
records = []
|
||||
if not ANALYTICS_DIR.exists():
|
||||
return records
|
||||
|
||||
for f in sorted(ANALYTICS_DIR.glob("*.json")):
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
if isinstance(data, list):
|
||||
records.extend(data)
|
||||
elif isinstance(data, dict):
|
||||
records.append(data)
|
||||
except Exception:
|
||||
pass
|
||||
return records
|
||||
|
||||
|
||||
def _aggregate_kpi(records: list) -> dict:
|
||||
total_visitors = sum(r.get("visitors", 0) for r in records)
|
||||
total_pageviews = sum(r.get("pageviews", 0) for r in records)
|
||||
avg_duration = 0
|
||||
avg_ctr = 0.0
|
||||
|
||||
durations = [r.get("avg_duration_sec", 0) for r in records if r.get("avg_duration_sec")]
|
||||
if durations:
|
||||
avg_duration = int(sum(durations) / len(durations))
|
||||
|
||||
ctrs = [r.get("ctr", 0.0) for r in records if r.get("ctr")]
|
||||
if ctrs:
|
||||
avg_ctr = round(sum(ctrs) / len(ctrs), 2)
|
||||
|
||||
return {
|
||||
"visitors": total_visitors,
|
||||
"pageviews": total_pageviews,
|
||||
"avg_duration_sec": avg_duration,
|
||||
"ctr": avg_ctr,
|
||||
}
|
||||
|
||||
|
||||
def _aggregate_corners(records: list) -> list:
|
||||
corner_map: dict = {}
|
||||
for r in records:
|
||||
corner = r.get("corner", "기타")
|
||||
if corner not in corner_map:
|
||||
corner_map[corner] = {"visitors": 0, "pageviews": 0, "posts": 0}
|
||||
corner_map[corner]["visitors"] += r.get("visitors", 0)
|
||||
corner_map[corner]["pageviews"] += r.get("pageviews", 0)
|
||||
corner_map[corner]["posts"] += r.get("post_count", 1)
|
||||
|
||||
result = []
|
||||
for name, data in corner_map.items():
|
||||
result.append({"corner": name, **data})
|
||||
result.sort(key=lambda x: x["visitors"], reverse=True)
|
||||
return result
|
||||
|
||||
|
||||
def _top_posts(records: list, limit: int = 5) -> list:
|
||||
posts = []
|
||||
for r in records:
|
||||
if "title" in r and "visitors" in r:
|
||||
posts.append({
|
||||
"title": r["title"],
|
||||
"visitors": r["visitors"],
|
||||
"corner": r.get("corner", ""),
|
||||
"published_at": r.get("date", ""),
|
||||
})
|
||||
posts.sort(key=lambda x: x["visitors"], reverse=True)
|
||||
return posts[:limit]
|
||||
|
||||
|
||||
def _platform_performance(records: list) -> list:
|
||||
platform_map: dict = {}
|
||||
for r in records:
|
||||
platform = r.get("platform", "blogger")
|
||||
if platform not in platform_map:
|
||||
platform_map[platform] = {"visitors": 0, "posts": 0}
|
||||
platform_map[platform]["visitors"] += r.get("visitors", 0)
|
||||
platform_map[platform]["posts"] += 1
|
||||
|
||||
return [{"platform": k, **v} for k, v in platform_map.items()]
|
||||
|
||||
|
||||
@router.get("/analytics")
|
||||
async def get_analytics():
|
||||
records = _load_all_analytics()
|
||||
return {
|
||||
"kpi": _aggregate_kpi(records),
|
||||
"corners": _aggregate_corners(records),
|
||||
"top_posts": _top_posts(records),
|
||||
"platforms": _platform_performance(records),
|
||||
"total_records": len(records),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/analytics/chart")
|
||||
async def get_analytics_chart(days: int = Query(default=7, ge=1, le=365)):
|
||||
"""days일간 방문자 시계열 데이터"""
|
||||
records = _load_all_analytics()
|
||||
|
||||
today = date.today()
|
||||
date_range = [(today - timedelta(days=i)).isoformat() for i in range(days - 1, -1, -1)]
|
||||
|
||||
# 날짜별 집계
|
||||
daily: dict = {d: {"date": d, "visitors": 0, "pageviews": 0} for d in date_range}
|
||||
|
||||
for r in records:
|
||||
d = r.get("date", "")[:10]
|
||||
if d in daily:
|
||||
daily[d]["visitors"] += r.get("visitors", 0)
|
||||
daily[d]["pageviews"] += r.get("pageviews", 0)
|
||||
|
||||
return {"chart": list(daily.values()), "days": days}
|
||||
207
dashboard/backend/api_connections.py
Normal file
207
dashboard/backend/api_connections.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
dashboard/backend/api_connections.py
|
||||
Settings > Connections 탭 API — AI 서비스 연결 상태 확인/테스트
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
CONFIG_PATH = BASE_DIR / "config" / "engine.json"
|
||||
ENV_PATH = BASE_DIR / ".env"
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
AI_SERVICES = [
|
||||
{
|
||||
"id": "claude",
|
||||
"name": "Claude (Anthropic)",
|
||||
"env_key": "ANTHROPIC_API_KEY",
|
||||
"category": "writing",
|
||||
"description": "글쓰기 엔진 — claude-opus-4-5",
|
||||
},
|
||||
{
|
||||
"id": "gemini",
|
||||
"name": "Google Gemini",
|
||||
"env_key": "GEMINI_API_KEY",
|
||||
"category": "writing",
|
||||
"description": "글쓰기 엔진 — gemini-2.0-flash",
|
||||
},
|
||||
{
|
||||
"id": "openai",
|
||||
"name": "OpenAI (GPT + DALL-E + TTS)",
|
||||
"env_key": "OPENAI_API_KEY",
|
||||
"category": "multi",
|
||||
"description": "이미지(DALL-E 3) + TTS(tts-1-hd)",
|
||||
},
|
||||
{
|
||||
"id": "elevenlabs",
|
||||
"name": "ElevenLabs TTS",
|
||||
"env_key": "ELEVENLABS_API_KEY",
|
||||
"category": "tts",
|
||||
"description": "고품질 한국어 TTS",
|
||||
},
|
||||
{
|
||||
"id": "google_tts",
|
||||
"name": "Google Cloud TTS",
|
||||
"env_key": "GOOGLE_TTS_API_KEY",
|
||||
"category": "tts",
|
||||
"description": "Google Wavenet TTS",
|
||||
},
|
||||
{
|
||||
"id": "seedance",
|
||||
"name": "Seedance AI Video",
|
||||
"env_key": "SEEDANCE_API_KEY",
|
||||
"category": "video",
|
||||
"description": "AI 영상 생성 — Seedance 2.0",
|
||||
},
|
||||
{
|
||||
"id": "runway",
|
||||
"name": "Runway Gen-3",
|
||||
"env_key": "RUNWAY_API_KEY",
|
||||
"category": "video",
|
||||
"description": "AI 영상 생성 — Gen-3 Turbo",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class ApiKeyUpdate(BaseModel):
|
||||
api_key: str
|
||||
|
||||
|
||||
def _mask_key(key: str) -> str:
|
||||
if not key:
|
||||
return ""
|
||||
if len(key) <= 8:
|
||||
return "****"
|
||||
return key[:4] + "****" + key[-4:]
|
||||
|
||||
|
||||
def _get_connections():
|
||||
connections = []
|
||||
for svc in AI_SERVICES:
|
||||
key = os.getenv(svc["env_key"], "")
|
||||
connections.append({
|
||||
**svc,
|
||||
"connected": bool(key),
|
||||
"key_masked": _mask_key(key),
|
||||
})
|
||||
return connections
|
||||
|
||||
|
||||
@router.get("/connections")
|
||||
async def get_connections():
|
||||
return {"connections": _get_connections()}
|
||||
|
||||
|
||||
@router.post("/connections/{service_id}/test")
|
||||
async def test_connection(service_id: str):
|
||||
"""서비스 연결 테스트"""
|
||||
svc = next((s for s in AI_SERVICES if s["id"] == service_id), None)
|
||||
if not svc:
|
||||
raise HTTPException(status_code=404, detail="서비스를 찾을 수 없습니다.")
|
||||
|
||||
api_key = os.getenv(svc["env_key"], "")
|
||||
if not api_key:
|
||||
return {"success": False, "message": "API 키가 설정되지 않았습니다."}
|
||||
|
||||
# 간단한 연결 테스트
|
||||
try:
|
||||
if service_id == "claude":
|
||||
import anthropic
|
||||
client = anthropic.Anthropic(api_key=api_key)
|
||||
# 모델 목록으로 연결 테스트
|
||||
client.messages.create(
|
||||
model="claude-haiku-4-5",
|
||||
max_tokens=10,
|
||||
messages=[{"role": "user", "content": "ping"}],
|
||||
)
|
||||
return {"success": True, "message": "Claude 연결 성공"}
|
||||
|
||||
elif service_id == "openai":
|
||||
from openai import OpenAI
|
||||
client = OpenAI(api_key=api_key)
|
||||
client.models.list()
|
||||
return {"success": True, "message": "OpenAI 연결 성공"}
|
||||
|
||||
elif service_id == "gemini":
|
||||
import google.generativeai as genai
|
||||
genai.configure(api_key=api_key)
|
||||
model = genai.GenerativeModel("gemini-2.0-flash")
|
||||
model.generate_content("ping", generation_config={"max_output_tokens": 5})
|
||||
return {"success": True, "message": "Gemini 연결 성공"}
|
||||
|
||||
elif service_id in ("elevenlabs", "seedance", "runway", "google_tts"):
|
||||
import requests
|
||||
test_urls = {
|
||||
"elevenlabs": "https://api.elevenlabs.io/v1/models",
|
||||
"google_tts": f"https://texttospeech.googleapis.com/v1/voices?key={api_key}",
|
||||
"seedance": "https://api.seedance2.ai/v1/models",
|
||||
"runway": "https://api.runwayml.com/v1/organization",
|
||||
}
|
||||
headers_map = {
|
||||
"elevenlabs": {"xi-api-key": api_key},
|
||||
"runway": {"Authorization": f"Bearer {api_key}"},
|
||||
}
|
||||
url = test_urls.get(service_id, "")
|
||||
headers = headers_map.get(service_id, {})
|
||||
if url:
|
||||
resp = requests.get(url, headers=headers, timeout=10)
|
||||
if resp.status_code < 400:
|
||||
return {"success": True, "message": f"{svc['name']} 연결 성공"}
|
||||
else:
|
||||
return {"success": False, "message": f"HTTP {resp.status_code}"}
|
||||
|
||||
return {"success": True, "message": "키 존재 확인됨 (심층 테스트 미지원)"}
|
||||
|
||||
except ImportError as e:
|
||||
return {"success": False, "message": f"라이브러리 미설치: {e}"}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": str(e)[:200]}
|
||||
|
||||
|
||||
@router.put("/connections/{service_id}")
|
||||
async def update_api_key(service_id: str, req: ApiKeyUpdate):
|
||||
"""API 키를 .env 파일에 저장"""
|
||||
svc = next((s for s in AI_SERVICES if s["id"] == service_id), None)
|
||||
if not svc:
|
||||
raise HTTPException(status_code=404, detail="서비스를 찾을 수 없습니다.")
|
||||
|
||||
env_key = svc["env_key"]
|
||||
api_key = req.api_key.strip()
|
||||
|
||||
try:
|
||||
# .env 파일 읽기
|
||||
if ENV_PATH.exists():
|
||||
lines = ENV_PATH.read_text(encoding="utf-8").splitlines()
|
||||
else:
|
||||
lines = []
|
||||
|
||||
# 기존 키 교체 또는 추가
|
||||
updated = False
|
||||
new_lines = []
|
||||
for line in lines:
|
||||
if line.startswith(f"{env_key}=") or line.startswith(f"{env_key} ="):
|
||||
new_lines.append(f"{env_key}={api_key}")
|
||||
updated = True
|
||||
else:
|
||||
new_lines.append(line)
|
||||
|
||||
if not updated:
|
||||
new_lines.append(f"{env_key}={api_key}")
|
||||
|
||||
ENV_PATH.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
|
||||
|
||||
# 현재 프로세스 환경 변수도 업데이트
|
||||
os.environ[env_key] = api_key
|
||||
|
||||
return {"success": True, "message": f"{env_key} 키 저장 완료"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"키 저장 실패: {e}")
|
||||
173
dashboard/backend/api_content.py
Normal file
173
dashboard/backend/api_content.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
dashboard/backend/api_content.py
|
||||
Content 탭 API — 칸반 보드, 승인/거부, 수동 트리거
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
DATA_DIR = BASE_DIR / "data"
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class WriteRequest(BaseModel):
|
||||
topic: str = ""
|
||||
|
||||
|
||||
def _read_folder_cards(folder: Path, status: str) -> list:
|
||||
"""폴더에서 JSON 파일을 읽어 칸반 카드 목록 반환"""
|
||||
cards = []
|
||||
if not folder.exists():
|
||||
return cards
|
||||
|
||||
for f in sorted(folder.glob("*.json"), key=lambda x: x.stat().st_mtime, reverse=True):
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
cards.append({
|
||||
"id": f.stem,
|
||||
"file": str(f),
|
||||
"title": data.get("title", f.stem),
|
||||
"corner": data.get("corner", ""),
|
||||
"source": data.get("source", ""),
|
||||
"quality_score": data.get("quality_score", data.get("score", 0)),
|
||||
"created_at": data.get("created_at", data.get("collected_at", "")),
|
||||
"status": status,
|
||||
"summary": data.get("summary", data.get("body", "")[:200] if data.get("body") else ""),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return cards
|
||||
|
||||
|
||||
@router.get("/content")
|
||||
async def get_content():
|
||||
"""칸반 4열 데이터 반환"""
|
||||
queue = _read_folder_cards(DATA_DIR / "topics", "queue")
|
||||
queue += _read_folder_cards(DATA_DIR / "collected", "queue")
|
||||
|
||||
writing = _read_folder_cards(DATA_DIR / "drafts", "writing")
|
||||
|
||||
review = _read_folder_cards(DATA_DIR / "pending_review", "review")
|
||||
|
||||
published = _read_folder_cards(DATA_DIR / "published", "published")
|
||||
|
||||
return {
|
||||
"columns": {
|
||||
"queue": {"label": "글감큐", "cards": queue},
|
||||
"writing": {"label": "작성중", "cards": writing},
|
||||
"review": {"label": "검수대기", "cards": review},
|
||||
"published": {"label": "발행완료", "cards": published[:20]}, # 최근 20개만
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/content/{item_id}/approve")
|
||||
async def approve_content(item_id: str):
|
||||
"""검수 승인 — pending_review → published로 이동"""
|
||||
src = DATA_DIR / "pending_review" / f"{item_id}.json"
|
||||
if not src.exists():
|
||||
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다.")
|
||||
|
||||
try:
|
||||
data = json.loads(src.read_text(encoding="utf-8"))
|
||||
data["approved_at"] = datetime.now().isoformat()
|
||||
data["status"] = "approved"
|
||||
|
||||
dst = DATA_DIR / "published" / f"{item_id}.json"
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
dst.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
src.unlink(missing_ok=True)
|
||||
|
||||
return {"success": True, "message": f"{item_id} 승인 완료"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/content/{item_id}/reject")
|
||||
async def reject_content(item_id: str):
|
||||
"""검수 거부 — pending_review → discarded로 이동"""
|
||||
src = DATA_DIR / "pending_review" / f"{item_id}.json"
|
||||
if not src.exists():
|
||||
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다.")
|
||||
|
||||
try:
|
||||
data = json.loads(src.read_text(encoding="utf-8"))
|
||||
data["rejected_at"] = datetime.now().isoformat()
|
||||
data["status"] = "rejected"
|
||||
|
||||
dst = DATA_DIR / "discarded" / f"{item_id}.json"
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
dst.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
src.unlink(missing_ok=True)
|
||||
|
||||
return {"success": True, "message": f"{item_id} 거부 완료"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/manual-write")
|
||||
async def manual_write(req: WriteRequest):
|
||||
"""collector_bot + writer_bot 수동 트리거"""
|
||||
python = sys.executable
|
||||
bots_dir = BASE_DIR / "bots"
|
||||
|
||||
results = []
|
||||
|
||||
# collector_bot 실행
|
||||
collector = bots_dir / "collector_bot.py"
|
||||
if collector.exists():
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[python, str(collector)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
cwd=str(BASE_DIR),
|
||||
encoding="utf-8",
|
||||
)
|
||||
results.append({
|
||||
"step": "collector",
|
||||
"success": result.returncode == 0,
|
||||
"output": result.stdout[-500:] if result.stdout else "",
|
||||
"error": result.stderr[-300:] if result.stderr else "",
|
||||
})
|
||||
except subprocess.TimeoutExpired:
|
||||
results.append({"step": "collector", "success": False, "error": "타임아웃"})
|
||||
except Exception as e:
|
||||
results.append({"step": "collector", "success": False, "error": str(e)})
|
||||
else:
|
||||
results.append({"step": "collector", "success": False, "error": "파일 없음"})
|
||||
|
||||
# writer_bot 실행
|
||||
writer = bots_dir / "writer_bot.py"
|
||||
if writer.exists():
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[python, str(writer)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
cwd=str(BASE_DIR),
|
||||
encoding="utf-8",
|
||||
)
|
||||
results.append({
|
||||
"step": "writer",
|
||||
"success": result.returncode == 0,
|
||||
"output": result.stdout[-500:] if result.stdout else "",
|
||||
"error": result.stderr[-300:] if result.stderr else "",
|
||||
})
|
||||
except subprocess.TimeoutExpired:
|
||||
results.append({"step": "writer", "success": False, "error": "타임아웃"})
|
||||
except Exception as e:
|
||||
results.append({"step": "writer", "success": False, "error": str(e)})
|
||||
else:
|
||||
results.append({"step": "writer", "success": False, "error": "파일 없음"})
|
||||
|
||||
return {"results": results}
|
||||
133
dashboard/backend/api_cost.py
Normal file
133
dashboard/backend/api_cost.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
dashboard/backend/api_cost.py
|
||||
Settings > 비용관리 탭 API — 구독 정보, API 사용량
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
CONFIG_PATH = BASE_DIR / "config" / "engine.json"
|
||||
LOGS_DIR = BASE_DIR / "logs"
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
SUBSCRIPTION_PLANS = [
|
||||
{
|
||||
"id": "claude_pro",
|
||||
"name": "Claude Pro",
|
||||
"provider": "Anthropic",
|
||||
"monthly_cost_usd": 20.0,
|
||||
"env_key": "ANTHROPIC_API_KEY",
|
||||
"renewal_day": 1, # 매월 1일 갱신
|
||||
},
|
||||
{
|
||||
"id": "openai_plus",
|
||||
"name": "OpenAI API",
|
||||
"provider": "OpenAI",
|
||||
"monthly_cost_usd": 0.0, # 종량제
|
||||
"env_key": "OPENAI_API_KEY",
|
||||
"renewal_day": None,
|
||||
},
|
||||
{
|
||||
"id": "gemini_api",
|
||||
"name": "Google Gemini API",
|
||||
"provider": "Google",
|
||||
"monthly_cost_usd": 0.0, # 무료 티어 + 종량제
|
||||
"env_key": "GEMINI_API_KEY",
|
||||
"renewal_day": None,
|
||||
},
|
||||
{
|
||||
"id": "elevenlabs",
|
||||
"name": "ElevenLabs Starter",
|
||||
"provider": "ElevenLabs",
|
||||
"monthly_cost_usd": 5.0,
|
||||
"env_key": "ELEVENLABS_API_KEY",
|
||||
"renewal_day": 1,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _days_until_renewal(renewal_day):
|
||||
if renewal_day is None:
|
||||
return None
|
||||
today = date.today()
|
||||
next_renewal = date(today.year, today.month, renewal_day)
|
||||
if next_renewal <= today:
|
||||
# 다음 달
|
||||
if today.month == 12:
|
||||
next_renewal = date(today.year + 1, 1, renewal_day)
|
||||
else:
|
||||
next_renewal = date(today.year, today.month + 1, renewal_day)
|
||||
return (next_renewal - today).days
|
||||
|
||||
|
||||
def _parse_api_usage() -> list:
|
||||
"""logs/*.log에서 API 사용량 파싱"""
|
||||
usage_map: dict = {}
|
||||
patterns = {
|
||||
"claude": re.compile(r"claude.*?(\d+)\s*토큰|tokens[:\s]+(\d+)", re.IGNORECASE),
|
||||
"openai": re.compile(r"openai.*?(\d+)\s*토큰|gpt.*?tokens[:\s]+(\d+)", re.IGNORECASE),
|
||||
"gemini": re.compile(r"gemini.*?(\d+)\s*토큰", re.IGNORECASE),
|
||||
}
|
||||
|
||||
if not LOGS_DIR.exists():
|
||||
return []
|
||||
|
||||
for log_file in LOGS_DIR.glob("*.log"):
|
||||
try:
|
||||
content = log_file.read_text(encoding="utf-8", errors="ignore")
|
||||
for provider, pattern in patterns.items():
|
||||
matches = pattern.findall(content)
|
||||
tokens = sum(int(m[0] or m[1] or 0) for m in matches if any(m))
|
||||
if tokens:
|
||||
usage_map[provider] = usage_map.get(provider, 0) + tokens
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result = []
|
||||
for provider, tokens in usage_map.items():
|
||||
result.append({
|
||||
"provider": provider,
|
||||
"tokens": tokens,
|
||||
"estimated_cost_usd": round(tokens / 1_000_000 * 3.0, 4), # 근사치
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/cost/subscriptions")
|
||||
async def get_subscriptions():
|
||||
"""구독 정보 + 만료일 계산"""
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
subscriptions = []
|
||||
for plan in SUBSCRIPTION_PLANS:
|
||||
key_set = bool(os.getenv(plan["env_key"], ""))
|
||||
days_left = _days_until_renewal(plan.get("renewal_day"))
|
||||
subscriptions.append({
|
||||
"id": plan["id"],
|
||||
"name": plan["name"],
|
||||
"provider": plan["provider"],
|
||||
"monthly_cost_usd": plan["monthly_cost_usd"],
|
||||
"active": key_set,
|
||||
"renewal_day": plan.get("renewal_day"),
|
||||
"days_until_renewal": days_left,
|
||||
"alert": days_left is not None and days_left <= 5,
|
||||
})
|
||||
|
||||
total_monthly = sum(p["monthly_cost_usd"] for p in subscriptions if p["active"])
|
||||
return {
|
||||
"subscriptions": subscriptions,
|
||||
"total_monthly_usd": total_monthly,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/cost/usage")
|
||||
async def get_usage():
|
||||
"""logs에서 API 사용량 파싱"""
|
||||
return {"usage": _parse_api_usage()}
|
||||
109
dashboard/backend/api_logs.py
Normal file
109
dashboard/backend/api_logs.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
dashboard/backend/api_logs.py
|
||||
Logs 탭 API — 시스템 로그 파싱, 필터/검색
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
LOGS_DIR = BASE_DIR / "logs"
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
LOG_MODULES = {
|
||||
"": "전체",
|
||||
"scheduler": "스케줄러",
|
||||
"collector": "수집",
|
||||
"writer": "글쓰기",
|
||||
"converter": "변환",
|
||||
"publisher": "발행",
|
||||
"analytics": "분석",
|
||||
"novel": "소설",
|
||||
"engine_loader": "엔진",
|
||||
"error": "에러만",
|
||||
}
|
||||
|
||||
LOG_PATTERN = re.compile(
|
||||
r"(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})[,\.]?\d*\s+"
|
||||
r"\[?(\w+)\]?\s+(.*)"
|
||||
)
|
||||
|
||||
|
||||
def _parse_log_line(line: str, module: str) -> dict | None:
|
||||
m = LOG_PATTERN.match(line.strip())
|
||||
if not m:
|
||||
return None
|
||||
return {
|
||||
"time": m.group(1),
|
||||
"level": m.group(2).upper(),
|
||||
"module": module,
|
||||
"message": m.group(3)[:300],
|
||||
}
|
||||
|
||||
|
||||
def _read_logs(
|
||||
filter_module: str = "",
|
||||
search: str = "",
|
||||
limit: int = 200,
|
||||
) -> list:
|
||||
logs = []
|
||||
|
||||
if not LOGS_DIR.exists():
|
||||
return logs
|
||||
|
||||
# 로그 파일 목록 (최근 수정 순)
|
||||
log_files = sorted(LOGS_DIR.glob("*.log"), key=lambda f: f.stat().st_mtime, reverse=True)
|
||||
|
||||
error_only = filter_module == "error"
|
||||
|
||||
for log_file in log_files:
|
||||
module_name = log_file.stem # e.g. "scheduler", "collector"
|
||||
|
||||
# 모듈 필터
|
||||
if filter_module and not error_only and module_name != filter_module:
|
||||
continue
|
||||
|
||||
try:
|
||||
lines = log_file.read_text(encoding="utf-8", errors="ignore").splitlines()
|
||||
for line in reversed(lines):
|
||||
if not line.strip():
|
||||
continue
|
||||
entry = _parse_log_line(line, module_name)
|
||||
if entry is None:
|
||||
continue
|
||||
|
||||
# 에러만 필터
|
||||
if error_only and entry["level"] not in ("ERROR", "CRITICAL", "WARNING"):
|
||||
continue
|
||||
|
||||
# 검색 필터
|
||||
if search and search.lower() not in entry["message"].lower():
|
||||
continue
|
||||
|
||||
logs.append(entry)
|
||||
if len(logs) >= limit:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if len(logs) >= limit:
|
||||
break
|
||||
|
||||
return logs[:limit]
|
||||
|
||||
|
||||
@router.get("/logs")
|
||||
async def get_logs(
|
||||
filter: str = Query(default="", description="모듈 필터 (scheduler/collector/writer/converter/publisher/error)"),
|
||||
search: str = Query(default="", description="메시지 검색"),
|
||||
limit: int = Query(default=200, ge=1, le=1000),
|
||||
):
|
||||
logs = _read_logs(filter_module=filter, search=search, limit=limit)
|
||||
return {
|
||||
"logs": logs,
|
||||
"total": len(logs),
|
||||
"modules": LOG_MODULES,
|
||||
}
|
||||
170
dashboard/backend/api_novels.py
Normal file
170
dashboard/backend/api_novels.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
dashboard/backend/api_novels.py
|
||||
Novel 탭 API — 소설 목록, 새 소설 생성, 에피소드 생성
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
NOVELS_CONFIG_DIR = BASE_DIR / "config" / "novels"
|
||||
NOVELS_DATA_DIR = BASE_DIR / "data" / "novels"
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class NewNovelRequest(BaseModel):
|
||||
novel_id: str
|
||||
title: str
|
||||
title_ko: str
|
||||
genre: str
|
||||
setting: str
|
||||
characters: str
|
||||
base_story: str
|
||||
publish_schedule: str = "매주 월/목 09:00"
|
||||
episode_count_target: int = 50
|
||||
|
||||
|
||||
@router.get("/novels")
|
||||
async def get_novels():
|
||||
"""config/novels/*.json 읽어 반환"""
|
||||
NOVELS_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
novels = []
|
||||
|
||||
for path in sorted(NOVELS_CONFIG_DIR.glob("*.json")):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
# 에피소드 수 계산
|
||||
ep_dir = NOVELS_DATA_DIR / data.get("novel_id", path.stem) / "episodes"
|
||||
ep_files = list(ep_dir.glob("ep*.json")) if ep_dir.exists() else []
|
||||
ep_files = [
|
||||
f for f in ep_files
|
||||
if "_summary" not in f.name and "_blog" not in f.name
|
||||
]
|
||||
|
||||
data["episode_files"] = len(ep_files)
|
||||
|
||||
# 에피소드 목록 로드
|
||||
episodes = []
|
||||
for ef in sorted(ep_files, key=lambda x: x.name)[-10:]: # 최근 10개
|
||||
try:
|
||||
ep_data = json.loads(ef.read_text(encoding="utf-8"))
|
||||
episodes.append({
|
||||
"episode_num": ep_data.get("episode_num", 0),
|
||||
"title": ep_data.get("title", ""),
|
||||
"generated_at": ep_data.get("generated_at", "")[:10],
|
||||
"word_count": ep_data.get("word_count", 0),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
data["episodes"] = episodes
|
||||
|
||||
# 진행률
|
||||
target = data.get("episode_count_target", 0)
|
||||
current = data.get("current_episode", len(ep_files))
|
||||
data["progress"] = round(current / target * 100) if target else 0
|
||||
|
||||
novels.append(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"novels": novels}
|
||||
|
||||
|
||||
@router.post("/novels")
|
||||
async def create_novel(req: NewNovelRequest):
|
||||
"""새 소설 config 생성"""
|
||||
NOVELS_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
config_path = NOVELS_CONFIG_DIR / f"{req.novel_id}.json"
|
||||
if config_path.exists():
|
||||
raise HTTPException(status_code=409, detail="이미 존재하는 소설 ID입니다.")
|
||||
|
||||
novel_config = {
|
||||
"novel_id": req.novel_id,
|
||||
"title": req.title,
|
||||
"title_ko": req.title_ko,
|
||||
"genre": req.genre,
|
||||
"setting": req.setting,
|
||||
"characters": req.characters,
|
||||
"base_story": req.base_story,
|
||||
"publish_schedule": req.publish_schedule,
|
||||
"episode_count_target": req.episode_count_target,
|
||||
"current_episode": 0,
|
||||
"status": "active",
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"episode_log": [],
|
||||
}
|
||||
|
||||
config_path.write_text(
|
||||
json.dumps(novel_config, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
# 데이터 디렉터리 생성
|
||||
novel_data_dir = NOVELS_DATA_DIR / req.novel_id
|
||||
for sub in ["episodes", "shorts", "images"]:
|
||||
(novel_data_dir / sub).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return {"success": True, "novel_id": req.novel_id, "message": f"소설 '{req.title_ko}' 생성 완료"}
|
||||
|
||||
|
||||
@router.post("/novels/{novel_id}/generate")
|
||||
async def generate_episode(novel_id: str):
|
||||
"""다음 에피소드 생성 — NovelManager.run_episode_pipeline() 호출"""
|
||||
config_path = NOVELS_CONFIG_DIR / f"{novel_id}.json"
|
||||
if not config_path.exists():
|
||||
raise HTTPException(status_code=404, detail="소설을 찾을 수 없습니다.")
|
||||
|
||||
try:
|
||||
sys.path.insert(0, str(BASE_DIR / "bots"))
|
||||
sys.path.insert(0, str(BASE_DIR / "bots" / "novel"))
|
||||
from bots.novel.novel_manager import NovelManager
|
||||
manager = NovelManager()
|
||||
ok = manager.run_episode_pipeline(novel_id, telegram_notify=False)
|
||||
if ok:
|
||||
status = manager.get_novel_status(novel_id)
|
||||
return {
|
||||
"success": True,
|
||||
"episode_num": status.get("current_episode", 0),
|
||||
"message": f"에피소드 생성 완료",
|
||||
}
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="에피소드 생성 실패 — 로그 확인")
|
||||
except ImportError as e:
|
||||
raise HTTPException(status_code=500, detail=f"모듈 로드 실패: {e}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/novels/{novel_id}/episodes")
|
||||
async def get_episodes(novel_id: str):
|
||||
"""소설 에피소드 전체 목록"""
|
||||
ep_dir = NOVELS_DATA_DIR / novel_id / "episodes"
|
||||
if not ep_dir.exists():
|
||||
return {"episodes": []}
|
||||
|
||||
episodes = []
|
||||
for ef in sorted(ep_dir.glob("ep*.json"), key=lambda x: x.name):
|
||||
if "_summary" in ef.name or "_blog" in ef.name:
|
||||
continue
|
||||
try:
|
||||
ep_data = json.loads(ef.read_text(encoding="utf-8"))
|
||||
episodes.append({
|
||||
"episode_num": ep_data.get("episode_num", 0),
|
||||
"title": ep_data.get("title", ""),
|
||||
"generated_at": ep_data.get("generated_at", "")[:10],
|
||||
"word_count": ep_data.get("word_count", 0),
|
||||
"published": ep_data.get("published", False),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"episodes": episodes}
|
||||
218
dashboard/backend/api_overview.py
Normal file
218
dashboard/backend/api_overview.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""
|
||||
dashboard/backend/api_overview.py
|
||||
Overview 탭 API — KPI, 파이프라인 상태, 활동 로그
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, date
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
DATA_DIR = BASE_DIR / "data"
|
||||
LOGS_DIR = BASE_DIR / "logs"
|
||||
CONFIG_DIR = BASE_DIR / "config"
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
CORNER_LABELS = {
|
||||
"easy_world": "쉬운세상",
|
||||
"hidden_gem": "숨은보물",
|
||||
"vibe": "바이브",
|
||||
"fact_check": "팩트체크",
|
||||
"deep_dive": "딥다이브",
|
||||
"novel": "연재소설",
|
||||
}
|
||||
|
||||
|
||||
def _count_published_files() -> dict:
|
||||
"""published 폴더에서 오늘/이번주/총 발행 수 카운트"""
|
||||
published_dir = DATA_DIR / "published"
|
||||
if not published_dir.exists():
|
||||
return {"today": 0, "this_week": 0, "total": 0, "corners": {}}
|
||||
|
||||
today = date.today()
|
||||
week_start = today.toordinal() - today.weekday()
|
||||
|
||||
today_count = 0
|
||||
week_count = 0
|
||||
total_count = 0
|
||||
corner_counts: dict = {}
|
||||
|
||||
for f in published_dir.glob("*.json"):
|
||||
total_count += 1
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
published_at_str = data.get("published_at", "")
|
||||
corner = data.get("corner", "기타")
|
||||
|
||||
corner_counts[corner] = corner_counts.get(corner, 0) + 1
|
||||
|
||||
if published_at_str:
|
||||
try:
|
||||
pub_date = datetime.fromisoformat(
|
||||
published_at_str[:19]
|
||||
).date()
|
||||
if pub_date == today:
|
||||
today_count += 1
|
||||
if pub_date.toordinal() >= week_start:
|
||||
week_count += 1
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"today": today_count,
|
||||
"this_week": week_count,
|
||||
"total": total_count,
|
||||
"corners": corner_counts,
|
||||
}
|
||||
|
||||
|
||||
def _get_revenue() -> dict:
|
||||
"""analytics 폴더에서 수익 데이터 읽기"""
|
||||
analytics_dir = DATA_DIR / "analytics"
|
||||
if not analytics_dir.exists():
|
||||
return {"amount": 0.0, "currency": "USD", "status": "대기중"}
|
||||
|
||||
latest = None
|
||||
for f in sorted(analytics_dir.glob("*.json"), reverse=True):
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
if "revenue" in data:
|
||||
latest = data["revenue"]
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if latest is None:
|
||||
return {"amount": 0.0, "currency": "USD", "status": "대기중"}
|
||||
return latest
|
||||
|
||||
|
||||
def _parse_pipeline_status() -> List[dict]:
|
||||
"""scheduler.log에서 파이프라인 단계별 상태 파싱"""
|
||||
steps = [
|
||||
{"id": "collector", "name": "수집", "status": "waiting", "done_at": ""},
|
||||
{"id": "writer", "name": "글쓰기", "status": "waiting", "done_at": ""},
|
||||
{"id": "converter", "name": "변환", "status": "waiting", "done_at": ""},
|
||||
{"id": "publisher", "name": "발행", "status": "waiting", "done_at": ""},
|
||||
{"id": "uploader", "name": "유튜브 업로드", "status": "waiting", "done_at": ""},
|
||||
{"id": "analytics", "name": "분석", "status": "waiting", "done_at": ""},
|
||||
]
|
||||
|
||||
log_file = LOGS_DIR / "scheduler.log"
|
||||
if not log_file.exists():
|
||||
return steps
|
||||
|
||||
try:
|
||||
lines = log_file.read_text(encoding="utf-8", errors="ignore").splitlines()
|
||||
today_str = date.today().strftime("%Y-%m-%d")
|
||||
|
||||
for line in lines:
|
||||
if today_str not in line:
|
||||
continue
|
||||
low = line.lower()
|
||||
|
||||
for step in steps:
|
||||
sid = step["id"]
|
||||
if sid in low:
|
||||
# 타임스탬프 파싱
|
||||
m = re.match(r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})", line)
|
||||
ts = m.group(1)[11:16] if m else ""
|
||||
|
||||
if "완료" in line or "done" in low or "success" in low or "finish" in low:
|
||||
step["status"] = "done"
|
||||
step["done_at"] = ts
|
||||
elif "시작" in line or "start" in low or "running" in low:
|
||||
step["status"] = "running"
|
||||
step["done_at"] = ts
|
||||
elif "오류" in line or "error" in low or "fail" in low:
|
||||
step["status"] = "error"
|
||||
step["done_at"] = ts
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return steps
|
||||
|
||||
|
||||
def _get_activity_logs() -> List[dict]:
|
||||
"""logs/*.log에서 최근 20개 활동 로그 파싱"""
|
||||
logs = []
|
||||
log_files = sorted(LOGS_DIR.glob("*.log"), key=lambda f: f.stat().st_mtime, reverse=True)
|
||||
|
||||
for log_file in log_files[:5]: # 최근 5개 파일만
|
||||
try:
|
||||
lines = log_file.read_text(encoding="utf-8", errors="ignore").splitlines()
|
||||
for line in reversed(lines):
|
||||
if not line.strip():
|
||||
continue
|
||||
m = re.match(r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),?\d*\s+\[(\w+)\]\s+(.*)", line)
|
||||
if m:
|
||||
logs.append({
|
||||
"time": m.group(1)[11:16],
|
||||
"date": m.group(1)[:10],
|
||||
"level": m.group(2),
|
||||
"module": log_file.stem,
|
||||
"message": m.group(3)[:120],
|
||||
})
|
||||
if len(logs) >= 20:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if len(logs) >= 20:
|
||||
break
|
||||
|
||||
return logs[:20]
|
||||
|
||||
|
||||
def _get_corner_ratio(corner_counts: dict) -> List[dict]:
|
||||
"""코너별 발행 비율 계산"""
|
||||
total = sum(corner_counts.values()) or 1
|
||||
result = []
|
||||
for key, label in CORNER_LABELS.items():
|
||||
count = corner_counts.get(key, corner_counts.get(label, 0))
|
||||
result.append({
|
||||
"name": label,
|
||||
"count": count,
|
||||
"ratio": round(count / total * 100),
|
||||
})
|
||||
# 정의되지 않은 코너 추가
|
||||
known = set(CORNER_LABELS.keys()) | set(CORNER_LABELS.values())
|
||||
for k, v in corner_counts.items():
|
||||
if k not in known:
|
||||
result.append({
|
||||
"name": k,
|
||||
"count": v,
|
||||
"ratio": round(v / total * 100),
|
||||
})
|
||||
result.sort(key=lambda x: x["count"], reverse=True)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/overview")
|
||||
async def get_overview():
|
||||
counts = _count_published_files()
|
||||
revenue = _get_revenue()
|
||||
return {
|
||||
"kpi": {
|
||||
"today": counts["today"],
|
||||
"this_week": counts["this_week"],
|
||||
"total": counts["total"],
|
||||
"revenue": revenue,
|
||||
},
|
||||
"corner_ratio": _get_corner_ratio(counts["corners"]),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/pipeline")
|
||||
async def get_pipeline():
|
||||
return {"steps": _parse_pipeline_status()}
|
||||
|
||||
|
||||
@router.get("/activity")
|
||||
async def get_activity():
|
||||
return {"logs": _get_activity_logs()}
|
||||
43
dashboard/backend/api_settings.py
Normal file
43
dashboard/backend/api_settings.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
dashboard/backend/api_settings.py
|
||||
Settings 탭 API — engine.json 읽기/쓰기
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
CONFIG_PATH = BASE_DIR / "config" / "engine.json"
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
data: dict
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
async def get_settings():
|
||||
"""config/engine.json 반환"""
|
||||
if not CONFIG_PATH.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"설정 파일 읽기 실패: {e}")
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
async def update_settings(req: SettingsUpdate):
|
||||
"""config/engine.json 저장"""
|
||||
try:
|
||||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
CONFIG_PATH.write_text(
|
||||
json.dumps(req.data, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8"
|
||||
)
|
||||
return {"success": True, "message": "설정 저장 완료"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"설정 저장 실패: {e}")
|
||||
113
dashboard/backend/api_tools.py
Normal file
113
dashboard/backend/api_tools.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
dashboard/backend/api_tools.py
|
||||
Settings > 생성도구 선택 탭 API
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
CONFIG_PATH = BASE_DIR / "config" / "engine.json"
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
TOOL_CATEGORIES = {
|
||||
"writing": {
|
||||
"label": "글쓰기",
|
||||
"options": ["claude", "gemini", "openclaw"],
|
||||
"option_labels": {
|
||||
"claude": "Claude (Anthropic)",
|
||||
"gemini": "Google Gemini",
|
||||
"openclaw": "OpenClaw AI",
|
||||
},
|
||||
},
|
||||
"image_generation": {
|
||||
"label": "이미지 생성",
|
||||
"options": ["dalle", "external"],
|
||||
"option_labels": {
|
||||
"dalle": "DALL-E 3 (OpenAI)",
|
||||
"external": "수동 제공",
|
||||
},
|
||||
},
|
||||
"tts": {
|
||||
"label": "TTS (음성합성)",
|
||||
"options": ["google_cloud", "openai", "elevenlabs", "gtts"],
|
||||
"option_labels": {
|
||||
"google_cloud": "Google Cloud TTS",
|
||||
"openai": "OpenAI TTS (tts-1-hd)",
|
||||
"elevenlabs": "ElevenLabs",
|
||||
"gtts": "gTTS (무료)",
|
||||
},
|
||||
},
|
||||
"video_generation": {
|
||||
"label": "영상 생성",
|
||||
"options": ["ffmpeg_slides", "seedance", "runway", "sora", "veo"],
|
||||
"option_labels": {
|
||||
"ffmpeg_slides": "FFmpeg 슬라이드 (로컬)",
|
||||
"seedance": "Seedance 2.0",
|
||||
"runway": "Runway Gen-3",
|
||||
"sora": "OpenAI Sora",
|
||||
"veo": "Google Veo",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class ToolUpdate(BaseModel):
|
||||
tools: dict # {"writing": "claude", "tts": "gtts", ...}
|
||||
|
||||
|
||||
def _load_config() -> dict:
|
||||
if not CONFIG_PATH.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
@router.get("/tools")
|
||||
async def get_tools():
|
||||
"""현재 선택된 도구 + 선택 가능 목록 반환"""
|
||||
config = _load_config()
|
||||
result = {}
|
||||
|
||||
for category, meta in TOOL_CATEGORIES.items():
|
||||
current = config.get(category, {}).get("provider", meta["options"][0])
|
||||
result[category] = {
|
||||
"label": meta["label"],
|
||||
"current": current,
|
||||
"options": [
|
||||
{
|
||||
"value": opt,
|
||||
"label": meta["option_labels"].get(opt, opt),
|
||||
}
|
||||
for opt in meta["options"]
|
||||
],
|
||||
}
|
||||
|
||||
return {"tools": result}
|
||||
|
||||
|
||||
@router.put("/tools")
|
||||
async def update_tools(req: ToolUpdate):
|
||||
"""engine.json 도구 섹션 업데이트"""
|
||||
config = _load_config()
|
||||
|
||||
for category, provider in req.tools.items():
|
||||
if category in TOOL_CATEGORIES:
|
||||
if category not in config:
|
||||
config[category] = {}
|
||||
config[category]["provider"] = provider
|
||||
|
||||
try:
|
||||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
CONFIG_PATH.write_text(
|
||||
json.dumps(config, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8"
|
||||
)
|
||||
return {"success": True, "message": "도구 설정 저장 완료"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"저장 실패: {e}")
|
||||
78
dashboard/backend/server.py
Normal file
78
dashboard/backend/server.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
dashboard/backend/server.py
|
||||
미디어 엔진 컨트롤 패널 — FastAPI 메인 서버
|
||||
|
||||
실행: uvicorn dashboard.backend.server:app --port 8080
|
||||
또는: python -m uvicorn dashboard.backend.server:app --port 8080 --reload
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from dashboard.backend import (
|
||||
api_overview,
|
||||
api_content,
|
||||
api_analytics,
|
||||
api_novels,
|
||||
api_settings,
|
||||
api_connections,
|
||||
api_tools,
|
||||
api_cost,
|
||||
api_logs,
|
||||
)
|
||||
|
||||
app = FastAPI(title="The 4th Path — Control Panel", version="1.0.0")
|
||||
|
||||
# ── CORS ──────────────────────────────────────────────────────────────────────
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"http://localhost:5173",
|
||||
"http://localhost:8080",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://127.0.0.1:8080",
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ── API 라우터 등록 ────────────────────────────────────────────────────────────
|
||||
app.include_router(api_overview.router, prefix="/api")
|
||||
app.include_router(api_content.router, prefix="/api")
|
||||
app.include_router(api_analytics.router, prefix="/api")
|
||||
app.include_router(api_novels.router, prefix="/api")
|
||||
app.include_router(api_settings.router, prefix="/api")
|
||||
app.include_router(api_connections.router, prefix="/api")
|
||||
app.include_router(api_tools.router, prefix="/api")
|
||||
app.include_router(api_cost.router, prefix="/api")
|
||||
app.include_router(api_logs.router, prefix="/api")
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok", "service": "The 4th Path Control Panel"}
|
||||
|
||||
|
||||
# ── 정적 파일 서빙 (프론트엔드 빌드 결과) — API 라우터보다 나중에 등록 ──────────
|
||||
FRONTEND_DIST = Path(__file__).parent.parent / "frontend" / "dist"
|
||||
|
||||
if FRONTEND_DIST.exists():
|
||||
assets_dir = FRONTEND_DIST / "assets"
|
||||
if assets_dir.exists():
|
||||
app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets")
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
@app.get("/{full_path:path}", include_in_schema=False)
|
||||
async def serve_spa(full_path: str = ""):
|
||||
# API 경로는 위 라우터가 처리 — 여기는 SPA 라우팅용
|
||||
if full_path.startswith("api/"):
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": "Not Found"}, status_code=404)
|
||||
index = FRONTEND_DIST / "index.html"
|
||||
if index.exists():
|
||||
return FileResponse(str(index))
|
||||
return {"status": "frontend not built — run: npm run build"}
|
||||
15
dashboard/frontend/index.html
Normal file
15
dashboard/frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>The 4th Path · Control Panel</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
23
dashboard/frontend/package.json
Normal file
23
dashboard/frontend/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "blog-writer-dashboard",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^2.12.7",
|
||||
"lucide-react": "^0.400.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"vite": "^5.4.1",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.41"
|
||||
}
|
||||
}
|
||||
6
dashboard/frontend/postcss.config.js
Normal file
6
dashboard/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
71
dashboard/frontend/src/App.jsx
Normal file
71
dashboard/frontend/src/App.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useState } from 'react'
|
||||
import { LayoutDashboard, FileText, BarChart2, BookOpen, Settings, ScrollText } from 'lucide-react'
|
||||
import Overview from './pages/Overview.jsx'
|
||||
import Content from './pages/Content.jsx'
|
||||
import Analytics from './pages/Analytics.jsx'
|
||||
import Novel from './pages/Novel.jsx'
|
||||
import SettingsPage from './pages/Settings.jsx'
|
||||
import Logs from './pages/Logs.jsx'
|
||||
|
||||
const TABS = [
|
||||
{ id: 'overview', label: '개요', icon: LayoutDashboard, component: Overview },
|
||||
{ id: 'content', label: '콘텐츠', icon: FileText, component: Content },
|
||||
{ id: 'analytics', label: '분석', icon: BarChart2, component: Analytics },
|
||||
{ id: 'novel', label: '소설', icon: BookOpen, component: Novel },
|
||||
{ id: 'settings', label: '설정', icon: Settings, component: SettingsPage },
|
||||
{ id: 'logs', label: '로그', icon: ScrollText, component: Logs },
|
||||
]
|
||||
|
||||
export default function App() {
|
||||
const [activeTab, setActiveTab] = useState('overview')
|
||||
const [systemStatus, setSystemStatus] = useState('ok') // ok | warn | error
|
||||
|
||||
const ActiveComponent = TABS.find(t => t.id === activeTab)?.component || Overview
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-bg text-text overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<header className="flex items-center justify-between px-4 md:px-6 py-3 border-b border-border bg-card flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-accent font-bold text-base md:text-lg tracking-tight">
|
||||
The 4th Path
|
||||
</span>
|
||||
<span className="hidden md:inline text-subtext text-xs">· Control Panel</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${systemStatus === 'ok' ? 'bg-success' : systemStatus === 'warn' ? 'bg-warning' : 'bg-error'}`}></span>
|
||||
<span className="text-xs text-subtext hidden sm:inline">
|
||||
{systemStatus === 'ok' ? 'System OK' : systemStatus === 'warn' ? '경고' : '오류'}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 탭 네비게이션 */}
|
||||
<nav className="flex border-b border-border bg-card flex-shrink-0 overflow-x-auto">
|
||||
{TABS.map(tab => {
|
||||
const Icon = tab.icon
|
||||
const isActive = activeTab === tab.id
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-1.5 px-3 md:px-5 py-3 text-xs md:text-sm font-medium whitespace-nowrap transition-colors border-b-2 ${
|
||||
isActive
|
||||
? 'border-accent text-accent'
|
||||
: 'border-transparent text-subtext hover:text-text'
|
||||
}`}
|
||||
>
|
||||
<Icon size={15} />
|
||||
<span className="hidden sm:inline">{tab.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<ActiveComponent />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10
dashboard/frontend/src/main.jsx
Normal file
10
dashboard/frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './styles/theme.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
231
dashboard/frontend/src/pages/Analytics.jsx
Normal file
231
dashboard/frontend/src/pages/Analytics.jsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import {
|
||||
LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer, Cell,
|
||||
} from 'recharts'
|
||||
import { Loader2, RefreshCw, Users, Eye, Clock, MousePointerClick } from 'lucide-react'
|
||||
|
||||
const PERIOD_DAYS = { '이번주': 7, '이번달': 30, '전체': 365 }
|
||||
const PLATFORM_COLORS = { blogger: '#c8a84e', youtube: '#bf3a3a', instagram: '#4a5abf', x: '#888880' }
|
||||
|
||||
function KpiCard({ label, value, icon: Icon, color }) {
|
||||
return (
|
||||
<div className="card p-4 flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg bg-border ${color}`}>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-subtext">{label}</div>
|
||||
<div className="text-xl font-bold text-text">{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatSec(sec) {
|
||||
if (!sec) return '0분'
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = sec % 60
|
||||
return m > 0 ? `${m}분 ${s}초` : `${s}초`
|
||||
}
|
||||
|
||||
export default function Analytics() {
|
||||
const [period, setPeriod] = useState('이번주')
|
||||
const [analytics, setAnalytics] = useState(null)
|
||||
const [chart, setChart] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const days = PERIOD_DAYS[period]
|
||||
const [aRes, cRes] = await Promise.all([
|
||||
fetch('/api/analytics'),
|
||||
fetch(`/api/analytics/chart?days=${days}`),
|
||||
])
|
||||
const a = await aRes.json()
|
||||
const c = await cRes.json()
|
||||
setAnalytics(a)
|
||||
setChart(c.chart || [])
|
||||
} catch (e) {
|
||||
console.error('Analytics 로드 실패:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchData() }, [period])
|
||||
|
||||
if (loading && !analytics) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="animate-spin text-accent" size={32} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const kpi = analytics?.kpi || {}
|
||||
const corners = analytics?.corners || []
|
||||
const topPosts = analytics?.top_posts || []
|
||||
const platforms = analytics?.platforms || []
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-bold">성과 분석</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 기간 선택 */}
|
||||
<div className="flex rounded border border-border overflow-hidden">
|
||||
{Object.keys(PERIOD_DAYS).map(p => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPeriod(p)}
|
||||
className={`px-3 py-1.5 text-xs transition-colors ${
|
||||
period === p
|
||||
? 'bg-accent text-bg font-semibold'
|
||||
: 'text-subtext hover:text-text'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchData}
|
||||
className="text-xs text-subtext hover:text-accent"
|
||||
>
|
||||
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI 카드 4개 */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<KpiCard label="방문자" value={kpi.visitors?.toLocaleString() || '0'} icon={Users} color="text-accent" />
|
||||
<KpiCard label="페이지뷰" value={kpi.pageviews?.toLocaleString() || '0'} icon={Eye} color="text-info" />
|
||||
<KpiCard label="평균 체류시간" value={formatSec(kpi.avg_duration_sec)} icon={Clock} color="text-success" />
|
||||
<KpiCard label="CTR" value={`${kpi.ctr || 0}%`} icon={MousePointerClick} color="text-warning" />
|
||||
</div>
|
||||
|
||||
{/* 방문자 라인차트 */}
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-accent mb-4">방문자 추이 ({period})</h2>
|
||||
{chart.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-40 text-subtext text-sm">
|
||||
데이터 없음
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={chart} margin={{ left: 0, right: 8, top: 4, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#222228" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fill: '#888880', fontSize: 11 }}
|
||||
tickFormatter={d => d.slice(5)}
|
||||
/>
|
||||
<YAxis tick={{ fill: '#888880', fontSize: 11 }} width={40} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: '#111116', border: '1px solid #222228', borderRadius: 6 }}
|
||||
labelStyle={{ color: '#e0e0d8' }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="visitors"
|
||||
name="방문자"
|
||||
stroke="#c8a84e"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="pageviews"
|
||||
name="페이지뷰"
|
||||
stroke="#3a7d5c"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* 코너별 성과 테이블 */}
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-accent mb-3">코너별 성과</h2>
|
||||
{corners.length === 0 ? (
|
||||
<p className="text-subtext text-sm">데이터 없음</p>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-subtext border-b border-border">
|
||||
<th className="text-left py-2">코너</th>
|
||||
<th className="text-right py-2">방문자</th>
|
||||
<th className="text-right py-2">페이지뷰</th>
|
||||
<th className="text-right py-2">글 수</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{corners.map((c, idx) => (
|
||||
<tr key={idx} className="border-b border-border/50 hover:bg-card-hover">
|
||||
<td className="py-2">{c.corner}</td>
|
||||
<td className="py-2 text-right text-accent">{c.visitors.toLocaleString()}</td>
|
||||
<td className="py-2 text-right">{c.pageviews.toLocaleString()}</td>
|
||||
<td className="py-2 text-right text-subtext">{c.posts}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 인기글 TOP 5 */}
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-accent mb-3">인기글 TOP 5</h2>
|
||||
{topPosts.length === 0 ? (
|
||||
<p className="text-subtext text-sm">데이터 없음</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{topPosts.map((post, idx) => (
|
||||
<div key={idx} className="flex items-start gap-3 py-2 border-b border-border/50 last:border-0">
|
||||
<span className="text-accent font-bold text-sm w-5 flex-shrink-0">{idx + 1}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-text truncate">{post.title}</p>
|
||||
<div className="flex gap-3 mt-0.5">
|
||||
<span className="text-xs text-subtext">{post.corner}</span>
|
||||
<span className="text-xs text-accent">{post.visitors?.toLocaleString()} 방문</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 플랫폼별 성과 */}
|
||||
{platforms.length > 0 && (
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-accent mb-4">플랫폼별 성과</h2>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<BarChart data={platforms} margin={{ left: 0, right: 8, top: 4, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#222228" />
|
||||
<XAxis dataKey="platform" tick={{ fill: '#888880', fontSize: 12 }} />
|
||||
<YAxis tick={{ fill: '#888880', fontSize: 11 }} width={40} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: '#111116', border: '1px solid #222228', borderRadius: 6 }}
|
||||
labelStyle={{ color: '#e0e0d8' }}
|
||||
/>
|
||||
<Bar dataKey="visitors" name="방문자" radius={[4, 4, 0, 0]}>
|
||||
{platforms.map((p, idx) => (
|
||||
<Cell key={idx} fill={PLATFORM_COLORS[p.platform] || '#4a5abf'} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
271
dashboard/frontend/src/pages/Content.jsx
Normal file
271
dashboard/frontend/src/pages/Content.jsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Loader2, CheckCircle2, XCircle, RefreshCw, X, Star, ExternalLink } from 'lucide-react'
|
||||
|
||||
const STATUS_COLORS = {
|
||||
queue: 'bg-info/10 text-info border-info/30',
|
||||
writing: 'bg-warning/10 text-warning border-warning/30',
|
||||
review: 'bg-accent/10 text-accent border-accent/30',
|
||||
published: 'bg-success/10 text-success border-success/30',
|
||||
}
|
||||
|
||||
const STATUS_BADGE = {
|
||||
queue: 'badge-waiting',
|
||||
writing: 'badge-running',
|
||||
review: 'badge-running',
|
||||
published: 'badge-done',
|
||||
}
|
||||
|
||||
function QualityBar({ score }) {
|
||||
if (!score) return null
|
||||
const color = score >= 80 ? '#3a7d5c' : score >= 60 ? '#c8a84e' : '#bf3a3a'
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<div className="flex-1 h-1 bg-border rounded-full overflow-hidden">
|
||||
<div style={{ width: `${score}%`, background: color }} className="h-full rounded-full" />
|
||||
</div>
|
||||
<span className="text-xs font-mono" style={{ color }}>{score}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CardModal({ card, onClose, onApprove, onReject }) {
|
||||
if (!card) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70" onClick={onClose}>
|
||||
<div className="card w-full max-w-lg mx-4 p-5" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-text mb-1">{card.title}</h3>
|
||||
<div className="flex gap-2">
|
||||
{card.corner && (
|
||||
<span className="tag bg-accent/10 text-accent border border-accent/20">{card.corner}</span>
|
||||
)}
|
||||
<span className={`tag ${STATUS_BADGE[card.status]}`}>{card.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-subtext hover:text-text">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{card.quality_score > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center gap-1 text-xs text-subtext mb-1">
|
||||
<Star size={11} />
|
||||
품질 점수
|
||||
</div>
|
||||
<QualityBar score={card.quality_score} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{card.source && (
|
||||
<div className="mb-3">
|
||||
<p className="text-xs text-subtext mb-1">출처</p>
|
||||
<p className="text-xs text-info break-all">{card.source}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{card.summary && (
|
||||
<div className="mb-4">
|
||||
<p className="text-xs text-subtext mb-1">내용 요약</p>
|
||||
<p className="text-sm text-text leading-relaxed line-clamp-4">{card.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{card.created_at && (
|
||||
<p className="text-xs text-subtext mb-4">생성일: {card.created_at?.slice(0, 16)}</p>
|
||||
)}
|
||||
|
||||
{card.status === 'review' && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onApprove(card.id)}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 bg-success text-white text-sm font-medium rounded hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<CheckCircle2 size={14} />
|
||||
승인
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onReject(card.id)}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 bg-error text-white text-sm font-medium rounded hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<XCircle size={14} />
|
||||
거부
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KanbanCard({ card, onClick }) {
|
||||
return (
|
||||
<div
|
||||
className="card p-3 cursor-pointer hover:border-accent/50 transition-colors mb-2"
|
||||
onClick={() => onClick(card)}
|
||||
>
|
||||
<p className="text-sm font-medium text-text line-clamp-2 mb-1">{card.title}</p>
|
||||
{card.corner && (
|
||||
<span className="tag bg-accent/10 text-accent text-xs">{card.corner}</span>
|
||||
)}
|
||||
<QualityBar score={card.quality_score} />
|
||||
{card.status === 'review' && (
|
||||
<div className="flex gap-1 mt-2" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await fetch(`/api/content/${card.id}/approve`, { method: 'POST' })
|
||||
window.location.reload()
|
||||
}}
|
||||
className="flex-1 text-xs py-1 bg-success/20 text-success rounded hover:bg-success/30 transition-colors"
|
||||
>
|
||||
승인
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await fetch(`/api/content/${card.id}/reject`, { method: 'POST' })
|
||||
window.location.reload()
|
||||
}}
|
||||
className="flex-1 text-xs py-1 bg-error/20 text-error rounded hover:bg-error/30 transition-colors"
|
||||
>
|
||||
거부
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Content() {
|
||||
const [columns, setColumns] = useState({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedCard, setSelectedCard] = useState(null)
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
|
||||
const fetchContent = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/content')
|
||||
const data = await res.json()
|
||||
setColumns(data.columns || {})
|
||||
} catch (e) {
|
||||
console.error('Content 로드 실패:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchContent() }, [])
|
||||
|
||||
const handleApprove = async (id) => {
|
||||
setActionLoading(true)
|
||||
try {
|
||||
await fetch(`/api/content/${id}/approve`, { method: 'POST' })
|
||||
setSelectedCard(null)
|
||||
await fetchContent()
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReject = async (id) => {
|
||||
setActionLoading(true)
|
||||
try {
|
||||
await fetch(`/api/content/${id}/reject`, { method: 'POST' })
|
||||
setSelectedCard(null)
|
||||
await fetchContent()
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleManualWrite = async () => {
|
||||
if (!confirm('수집 + 글쓰기 봇을 수동으로 실행할까요? 수 분이 걸릴 수 있습니다.')) return
|
||||
try {
|
||||
const res = await fetch('/api/manual-write', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const data = await res.json()
|
||||
const msg = data.results?.map(r => `${r.step}: ${r.success ? '성공' : r.error || '실패'}`).join('\n')
|
||||
alert(msg || '실행 완료')
|
||||
await fetchContent()
|
||||
} catch (e) {
|
||||
alert('실행 실패: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="animate-spin text-accent" size={32} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const colOrder = ['queue', 'writing', 'review', 'published']
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-lg font-bold">콘텐츠 관리</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={fetchContent}
|
||||
className="flex items-center gap-1.5 text-xs text-subtext hover:text-accent border border-border px-3 py-1.5 rounded transition-colors"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
새로고침
|
||||
</button>
|
||||
<button
|
||||
onClick={handleManualWrite}
|
||||
className="flex items-center gap-1.5 text-xs bg-accent text-bg font-semibold px-3 py-1.5 rounded hover:opacity-80 transition-opacity"
|
||||
>
|
||||
수동 글쓰기 실행
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 칸반 보드 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{colOrder.map(colId => {
|
||||
const col = columns[colId]
|
||||
if (!col) return null
|
||||
const cards = col.cards || []
|
||||
return (
|
||||
<div key={colId} className="bg-card/50 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-accent">{col.label}</h3>
|
||||
<span className="text-xs bg-border text-subtext px-2 py-0.5 rounded-full">
|
||||
{cards.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="kanban-col overflow-y-auto max-h-[60vh]">
|
||||
{cards.length === 0 ? (
|
||||
<p className="text-xs text-subtext text-center py-8">비어있음</p>
|
||||
) : (
|
||||
cards.map(card => (
|
||||
<KanbanCard key={card.id} card={card} onClick={setSelectedCard} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 카드 상세 모달 */}
|
||||
{selectedCard && (
|
||||
<CardModal
|
||||
card={selectedCard}
|
||||
onClose={() => setSelectedCard(null)}
|
||||
onApprove={handleApprove}
|
||||
onReject={handleReject}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
202
dashboard/frontend/src/pages/Logs.jsx
Normal file
202
dashboard/frontend/src/pages/Logs.jsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Loader2, RefreshCw, Search, Filter } from 'lucide-react'
|
||||
|
||||
const LEVEL_STYLES = {
|
||||
ERROR: 'text-error',
|
||||
CRITICAL: 'text-error font-semibold',
|
||||
WARNING: 'text-warning',
|
||||
INFO: 'text-info',
|
||||
DEBUG: 'text-subtext',
|
||||
}
|
||||
|
||||
const FILTERS = [
|
||||
{ value: '', label: '전체' },
|
||||
{ value: 'scheduler', label: '스케줄러' },
|
||||
{ value: 'collector', label: '수집' },
|
||||
{ value: 'writer', label: '글쓰기' },
|
||||
{ value: 'converter', label: '변환' },
|
||||
{ value: 'publisher', label: '발행' },
|
||||
{ value: 'novel', label: '소설' },
|
||||
{ value: 'analytics', label: '분석' },
|
||||
{ value: 'error', label: '에러만' },
|
||||
]
|
||||
|
||||
export default function Logs() {
|
||||
const [logs, setLogs] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filterModule, setFilterModule] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [total, setTotal] = useState(0)
|
||||
const [autoRefresh, setAutoRefresh] = useState(false)
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (filterModule) params.set('filter', filterModule)
|
||||
if (search) params.set('search', search)
|
||||
params.set('limit', '200')
|
||||
|
||||
const res = await fetch(`/api/logs?${params}`)
|
||||
const data = await res.json()
|
||||
setLogs(data.logs || [])
|
||||
setTotal(data.total || 0)
|
||||
} catch (e) {
|
||||
console.error('Logs 로드 실패:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [filterModule, search])
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs()
|
||||
}, [fetchLogs])
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return
|
||||
const timer = setInterval(fetchLogs, 5000)
|
||||
return () => clearInterval(timer)
|
||||
}, [autoRefresh, fetchLogs])
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault()
|
||||
setSearch(searchInput)
|
||||
}
|
||||
|
||||
const levelCount = (level) => logs.filter(l => l.level === level).length
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-4">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-bold">시스템 로그</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1.5 text-xs text-subtext cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefresh}
|
||||
onChange={e => setAutoRefresh(e.target.checked)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
자동 새로고침 (5초)
|
||||
</label>
|
||||
<button
|
||||
onClick={fetchLogs}
|
||||
className="text-xs text-subtext hover:text-accent flex items-center gap-1 border border-border px-2 py-1 rounded"
|
||||
>
|
||||
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터 + 검색 */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{/* 모듈 필터 드롭다운 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={14} className="text-subtext flex-shrink-0" />
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{FILTERS.map(f => (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => setFilterModule(f.value)}
|
||||
className={`px-2.5 py-1 text-xs rounded-full transition-colors ${
|
||||
filterModule === f.value
|
||||
? 'bg-accent text-bg font-semibold'
|
||||
: 'border border-border text-subtext hover:text-text'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<form onSubmit={handleSearch} className="flex gap-2 ml-auto">
|
||||
<div className="relative">
|
||||
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-subtext" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
placeholder="메시지 검색..."
|
||||
className="bg-bg border border-border rounded pl-7 pr-3 py-1.5 text-xs text-text placeholder-subtext focus:outline-none focus:border-accent w-48"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="text-xs bg-accent text-bg px-3 py-1.5 rounded font-semibold hover:opacity-80">
|
||||
검색
|
||||
</button>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSearch(''); setSearchInput('') }}
|
||||
className="text-xs text-subtext hover:text-text"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* 통계 바 */}
|
||||
<div className="flex gap-4 text-xs">
|
||||
<span className="text-subtext">총 {total}건</span>
|
||||
{levelCount('ERROR') > 0 && <span className="text-error">오류 {levelCount('ERROR')}건</span>}
|
||||
{levelCount('WARNING') > 0 && <span className="text-warning">경고 {levelCount('WARNING')}건</span>}
|
||||
{levelCount('INFO') > 0 && <span className="text-info">정보 {levelCount('INFO')}건</span>}
|
||||
</div>
|
||||
|
||||
{/* 로그 리스트 */}
|
||||
<div className="card overflow-hidden">
|
||||
{loading && logs.length === 0 ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="animate-spin text-accent" size={24} />
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="text-center py-12 text-subtext text-sm">
|
||||
로그가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-full">
|
||||
{/* 테이블 헤더 */}
|
||||
<div className="flex gap-3 px-3 py-2 border-b border-border text-xs text-subtext bg-bg/50 sticky top-0">
|
||||
<span className="w-36 flex-shrink-0">시각</span>
|
||||
<span className="w-16 flex-shrink-0">레벨</span>
|
||||
<span className="w-24 flex-shrink-0">모듈</span>
|
||||
<span className="flex-1">메시지</span>
|
||||
</div>
|
||||
|
||||
{/* 로그 행 */}
|
||||
<div className="max-h-[calc(100vh-320px)] overflow-y-auto">
|
||||
{logs.map((log, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`flex gap-3 px-3 py-1.5 border-b border-border/30 hover:bg-card-hover text-xs font-mono ${
|
||||
idx % 2 === 0 ? '' : 'bg-black/10'
|
||||
}`}
|
||||
>
|
||||
<span className="w-36 flex-shrink-0 text-subtext whitespace-nowrap">
|
||||
{log.time}
|
||||
</span>
|
||||
<span className={`w-16 flex-shrink-0 ${LEVEL_STYLES[log.level] || 'text-subtext'}`}>
|
||||
{log.level}
|
||||
</span>
|
||||
<span className="w-24 flex-shrink-0 text-subtext truncate">
|
||||
[{log.module}]
|
||||
</span>
|
||||
<span className="flex-1 text-text break-all">
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
303
dashboard/frontend/src/pages/Novel.jsx
Normal file
303
dashboard/frontend/src/pages/Novel.jsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Loader2, RefreshCw, Plus, X, Play, BookOpen, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
|
||||
function ProgressBar({ value, max }) {
|
||||
const pct = max > 0 ? Math.min(100, Math.round(value / max * 100)) : 0
|
||||
const color = pct >= 80 ? '#3a7d5c' : pct >= 40 ? '#c8a84e' : '#4a5abf'
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs text-subtext mb-1">
|
||||
<span>{value} / {max} 화</span>
|
||||
<span style={{ color }}>{pct}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-border rounded-full overflow-hidden">
|
||||
<div style={{ width: `${pct}%`, background: color }} className="h-full rounded-full transition-all" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NewNovelModal({ onClose, onCreated }) {
|
||||
const [form, setForm] = useState({
|
||||
novel_id: '',
|
||||
title: '',
|
||||
title_ko: '',
|
||||
genre: '',
|
||||
setting: '',
|
||||
characters: '',
|
||||
base_story: '',
|
||||
publish_schedule: '매주 월/목 09:00',
|
||||
episode_count_target: 50,
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch('/api/novels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...form, episode_count_target: Number(form.episode_count_target) }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.detail || '생성 실패')
|
||||
}
|
||||
const data = await res.json()
|
||||
onCreated(data)
|
||||
onClose()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const field = (name, label, placeholder = '', type = 'text', rows = 0) => (
|
||||
<div>
|
||||
<label className="block text-xs text-subtext mb-1">{label}</label>
|
||||
{rows > 0 ? (
|
||||
<textarea
|
||||
rows={rows}
|
||||
value={form[name]}
|
||||
onChange={e => setForm(f => ({ ...f, [name]: e.target.value }))}
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-bg border border-border rounded px-3 py-2 text-sm text-text placeholder-subtext focus:outline-none focus:border-accent resize-none"
|
||||
required
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={type}
|
||||
value={form[name]}
|
||||
onChange={e => setForm(f => ({ ...f, [name]: e.target.value }))}
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-bg border border-border rounded px-3 py-2 text-sm text-text placeholder-subtext focus:outline-none focus:border-accent"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 overflow-y-auto py-6" onClick={onClose}>
|
||||
<div className="card w-full max-w-lg mx-4 p-5" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-accent flex items-center gap-2">
|
||||
<Plus size={16} />
|
||||
새 소설 만들기
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-subtext hover:text-text"><X size={18} /></button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{field('novel_id', '소설 ID (영문)', 'shadow-protocol')}
|
||||
{field('genre', '장르', '판타지 / SF / 로맨스')}
|
||||
</div>
|
||||
{field('title', '영문 제목', 'Shadow Protocol')}
|
||||
{field('title_ko', '한국어 제목', '그림자 프로토콜')}
|
||||
{field('setting', '세계관 설정', '2050년 서울, AI와 인간이 공존하는 사회...', 'text', 3)}
|
||||
{field('characters', '주요 등장인물', '주인공: 김하준(29세, AI 보안 전문가)...', 'text', 3)}
|
||||
{field('base_story', '기본 스토리', '주인공이 우연히 금지된 AI를 발견하면서...', 'text', 4)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{field('publish_schedule', '발행 일정', '매주 월/목 09:00')}
|
||||
{field('episode_count_target', '목표 에피소드', '50', 'number')}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-error text-xs">{error}</p>}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button type="button" onClick={onClose} className="flex-1 py-2 border border-border text-sm rounded hover:border-accent/50 transition-colors">
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 py-2 bg-accent text-bg text-sm font-semibold rounded hover:opacity-80 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Loader2 size={14} className="animate-spin inline" /> : '소설 생성'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NovelCard({ novel, onGenerate }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!confirm(`"${novel.title_ko}" 다음 에피소드를 생성할까요? 수 분이 걸릴 수 있습니다.`)) return
|
||||
setGenerating(true)
|
||||
try {
|
||||
const res = await fetch(`/api/novels/${novel.novel_id}/generate`, { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
alert(`에피소드 ${data.episode_num}화 생성 완료!`)
|
||||
onGenerate()
|
||||
} else {
|
||||
alert('생성 실패: ' + (data.detail || '알 수 없는 오류'))
|
||||
}
|
||||
} catch (e) {
|
||||
alert('생성 실패: ' + e.message)
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-text">{novel.title_ko}</h3>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<span className="tag bg-info/10 text-info">{novel.genre}</span>
|
||||
<span className={`tag ${novel.status === 'active' ? 'badge-done' : 'badge-waiting'}`}>
|
||||
{novel.status === 'active' ? '연재중' : '중단'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
className="flex items-center gap-1.5 text-xs bg-accent text-bg px-3 py-1.5 rounded font-semibold hover:opacity-80 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{generating ? <Loader2 size={12} className="animate-spin" /> : <Play size={12} />}
|
||||
다음 회 생성
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ProgressBar value={novel.current_episode || 0} max={novel.episode_count_target || 50} />
|
||||
|
||||
{novel.publish_schedule && (
|
||||
<p className="text-xs text-subtext mt-2">연재 일정: {novel.publish_schedule}</p>
|
||||
)}
|
||||
|
||||
{/* 에피소드 테이블 토글 */}
|
||||
{(novel.episodes?.length > 0) && (
|
||||
<div className="mt-3">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-1 text-xs text-subtext hover:text-accent transition-colors"
|
||||
>
|
||||
{expanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
에피소드 목록 ({novel.episodes.length}회)
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-2 overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-subtext border-b border-border">
|
||||
<th className="text-left py-1.5">화수</th>
|
||||
<th className="text-left py-1.5">제목</th>
|
||||
<th className="text-right py-1.5">생성일</th>
|
||||
<th className="text-right py-1.5">분량</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{novel.episodes.map((ep, idx) => (
|
||||
<tr key={idx} className="border-b border-border/50 hover:bg-card-hover">
|
||||
<td className="py-1.5 text-accent font-mono">{ep.episode_num}화</td>
|
||||
<td className="py-1.5 max-w-[200px] truncate">{ep.title}</td>
|
||||
<td className="py-1.5 text-right text-subtext">{ep.generated_at}</td>
|
||||
<td className="py-1.5 text-right text-subtext">{ep.word_count?.toLocaleString()}자</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Novel() {
|
||||
const [novels, setNovels] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
const fetchNovels = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/novels')
|
||||
const data = await res.json()
|
||||
setNovels(data.novels || [])
|
||||
} catch (e) {
|
||||
console.error('Novel 로드 실패:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchNovels() }, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="animate-spin text-accent" size={32} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-bold flex items-center gap-2">
|
||||
<BookOpen size={20} className="text-accent" />
|
||||
소설 연재 관리
|
||||
</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={fetchNovels}
|
||||
className="text-xs text-subtext hover:text-accent border border-border px-3 py-1.5 rounded flex items-center gap-1.5 transition-colors"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
새로고침
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="flex items-center gap-1.5 text-xs bg-accent text-bg font-semibold px-3 py-1.5 rounded hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Plus size={13} />
|
||||
새 소설 만들기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{novels.length === 0 ? (
|
||||
<div className="card p-8 text-center">
|
||||
<BookOpen size={40} className="text-subtext mx-auto mb-3" />
|
||||
<p className="text-subtext text-sm mb-3">등록된 소설이 없습니다.</p>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="inline-flex items-center gap-1.5 text-sm bg-accent text-bg font-semibold px-4 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Plus size={14} />
|
||||
첫 소설 만들기
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{novels.map(novel => (
|
||||
<NovelCard key={novel.novel_id} novel={novel} onGenerate={fetchNovels} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showModal && (
|
||||
<NewNovelModal
|
||||
onClose={() => setShowModal(false)}
|
||||
onCreated={() => fetchNovels()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
253
dashboard/frontend/src/pages/Overview.jsx
Normal file
253
dashboard/frontend/src/pages/Overview.jsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
|
||||
import { RefreshCw, CheckCircle2, Loader2, Clock, XCircle, AlertCircle, Zap, CalendarDays } from 'lucide-react'
|
||||
|
||||
const STEP_ICONS = {
|
||||
done: <CheckCircle2 size={16} className="text-success" />,
|
||||
running: <Loader2 size={16} className="text-info animate-spin" />,
|
||||
waiting: <Clock size={16} className="text-subtext" />,
|
||||
error: <XCircle size={16} className="text-error" />,
|
||||
}
|
||||
|
||||
const STEP_LABELS = {
|
||||
done: '완료',
|
||||
running: '실행중',
|
||||
waiting: '대기',
|
||||
error: '오류',
|
||||
}
|
||||
|
||||
const CORNER_COLORS = ['#c8a84e', '#3a7d5c', '#4a5abf', '#bf3a3a', '#7a5abf', '#5a7abf']
|
||||
|
||||
function KpiCard({ label, value, sub, color }) {
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="text-xs text-subtext mb-1">{label}</div>
|
||||
<div className={`text-2xl font-bold ${color || 'text-text'}`}>{value}</div>
|
||||
{sub && <div className="text-xs text-subtext mt-1">{sub}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PipelineStep({ name, status, done_at }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2 border-b border-border last:border-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{STEP_ICONS[status] || STEP_ICONS.waiting}
|
||||
<span className="text-sm">{name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`tag ${
|
||||
status === 'done' ? 'badge-done' :
|
||||
status === 'running' ? 'badge-running' :
|
||||
status === 'error' ? 'badge-error' :
|
||||
'badge-waiting'
|
||||
}`}>
|
||||
{STEP_LABELS[status] || '대기'}
|
||||
</span>
|
||||
{done_at && <span className="text-xs text-subtext font-mono">{done_at}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LOG_LEVEL_COLORS = {
|
||||
ERROR: 'text-error',
|
||||
WARNING: 'text-warning',
|
||||
INFO: 'text-info',
|
||||
DEBUG: 'text-subtext',
|
||||
}
|
||||
|
||||
export default function Overview() {
|
||||
const [kpi, setKpi] = useState(null)
|
||||
const [pipeline, setPipeline] = useState([])
|
||||
const [activity, setActivity] = useState([])
|
||||
const [corners, setCorners] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [lastUpdated, setLastUpdated] = useState('')
|
||||
|
||||
const fetchAll = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [ovRes, pipRes, actRes] = await Promise.all([
|
||||
fetch('/api/overview'),
|
||||
fetch('/api/pipeline'),
|
||||
fetch('/api/activity'),
|
||||
])
|
||||
const ov = await ovRes.json()
|
||||
const pip = await pipRes.json()
|
||||
const act = await actRes.json()
|
||||
|
||||
setKpi(ov.kpi)
|
||||
setCorners(ov.corner_ratio || [])
|
||||
setPipeline(pip.steps || [])
|
||||
setActivity(act.logs || [])
|
||||
setLastUpdated(new Date().toLocaleTimeString('ko-KR'))
|
||||
} catch (e) {
|
||||
console.error('Overview 로드 실패:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll()
|
||||
const timer = setInterval(fetchAll, 60000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
if (loading && !kpi) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="animate-spin text-accent" size={32} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const revenue = kpi?.revenue || {}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-bold text-text">개요 대시보드</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
{lastUpdated && (
|
||||
<span className="text-xs text-subtext">업데이트: {lastUpdated}</span>
|
||||
)}
|
||||
<button
|
||||
onClick={fetchAll}
|
||||
className="flex items-center gap-1.5 text-xs text-subtext hover:text-accent transition-colors"
|
||||
>
|
||||
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI 카드 4개 */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<KpiCard
|
||||
label="오늘 발행"
|
||||
value={kpi?.today ?? 0}
|
||||
sub="블로그+SNS"
|
||||
color={kpi?.today > 0 ? 'text-success' : 'text-subtext'}
|
||||
/>
|
||||
<KpiCard
|
||||
label="이번주 발행"
|
||||
value={kpi?.this_week ?? 0}
|
||||
sub="7일 기준"
|
||||
color="text-accent"
|
||||
/>
|
||||
<KpiCard
|
||||
label="총 글 수"
|
||||
value={kpi?.total ?? 0}
|
||||
sub={kpi?.today > 0 ? `+${kpi.today} 오늘` : '누적'}
|
||||
color="text-text"
|
||||
/>
|
||||
<KpiCard
|
||||
label="수익"
|
||||
value={revenue.amount != null ? `$${revenue.amount.toFixed(2)}` : '$0.00'}
|
||||
sub={revenue.status || '대기중'}
|
||||
color={revenue.amount > 0 ? 'text-success' : 'text-subtext'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* 파이프라인 상태 */}
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-accent mb-3">파이프라인 상태</h2>
|
||||
{pipeline.length === 0 ? (
|
||||
<p className="text-subtext text-sm">로그 데이터 없음</p>
|
||||
) : (
|
||||
pipeline.map(step => (
|
||||
<PipelineStep key={step.id} {...step} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 코너별 발행 비율 */}
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-accent mb-3">코너별 발행 비율</h2>
|
||||
{corners.length === 0 ? (
|
||||
<p className="text-subtext text-sm">발행 데이터 없음</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={corners} layout="vertical" margin={{ left: 8, right: 16, top: 4, bottom: 4 }}>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
tick={{ fill: '#888880', fontSize: 12 }}
|
||||
width={70}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ background: '#111116', border: '1px solid #222228', borderRadius: 6 }}
|
||||
labelStyle={{ color: '#e0e0d8' }}
|
||||
formatter={(v, n, p) => [`${p.payload.count}건 (${v}%)`, '비율']}
|
||||
/>
|
||||
<Bar dataKey="ratio" radius={[0, 4, 4, 0]}>
|
||||
{corners.map((_, idx) => (
|
||||
<Cell key={idx} fill={CORNER_COLORS[idx % CORNER_COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 빠른 액션 */}
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-accent mb-3">빠른 액션</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => window.location.href = '#content'}
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-accent text-bg text-xs font-semibold rounded hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<AlertCircle size={13} />
|
||||
승인 대기 확인
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const r = await fetch('/api/manual-write', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
|
||||
const d = await r.json()
|
||||
alert(JSON.stringify(d.results?.map(x => `${x.step}: ${x.success ? '성공' : x.error}`), null, 2))
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-2 border border-border text-xs rounded hover:border-accent hover:text-accent transition-colors"
|
||||
>
|
||||
<Zap size={13} />
|
||||
오늘 글감 수동 실행
|
||||
</button>
|
||||
<button
|
||||
onClick={fetchAll}
|
||||
className="flex items-center gap-1.5 px-3 py-2 border border-border text-xs rounded hover:border-accent hover:text-accent transition-colors"
|
||||
>
|
||||
<CalendarDays size={13} />
|
||||
데이터 새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 최근 활동 로그 */}
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-accent mb-3">최근 활동</h2>
|
||||
{activity.length === 0 ? (
|
||||
<p className="text-subtext text-sm">로그 없음</p>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{activity.map((log, idx) => (
|
||||
<div key={idx} className="flex gap-3 text-xs py-1 border-b border-border last:border-0">
|
||||
<span className="text-subtext font-mono whitespace-nowrap">{log.time}</span>
|
||||
<span className={`font-mono ${LOG_LEVEL_COLORS[log.level] || 'text-subtext'} w-12 flex-shrink-0`}>
|
||||
{log.level}
|
||||
</span>
|
||||
<span className="text-subtext w-20 flex-shrink-0">[{log.module}]</span>
|
||||
<span className="text-text truncate">{log.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
46
dashboard/frontend/src/pages/Settings.jsx
Normal file
46
dashboard/frontend/src/pages/Settings.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React, { useState } from 'react'
|
||||
import Connections from './settings/Connections.jsx'
|
||||
import ToolSelect from './settings/ToolSelect.jsx'
|
||||
import Distribution from './settings/Distribution.jsx'
|
||||
import Quality from './settings/Quality.jsx'
|
||||
import CostMonitor from './settings/CostMonitor.jsx'
|
||||
|
||||
const SUB_TABS = [
|
||||
{ id: 'connections', label: 'AI 연결', component: Connections },
|
||||
{ id: 'tools', label: '생성도구', component: ToolSelect },
|
||||
{ id: 'distribution', label: '배포채널', component: Distribution },
|
||||
{ id: 'quality', label: '품질·스케줄', component: Quality },
|
||||
{ id: 'cost', label: '비용관리', component: CostMonitor },
|
||||
]
|
||||
|
||||
export default function Settings() {
|
||||
const [activeSubTab, setActiveSubTab] = useState('connections')
|
||||
|
||||
const ActiveSub = SUB_TABS.find(t => t.id === activeSubTab)?.component || Connections
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6">
|
||||
<h1 className="text-lg font-bold mb-4">설정</h1>
|
||||
|
||||
{/* 서브탭 */}
|
||||
<div className="flex gap-1 mb-5 flex-wrap">
|
||||
{SUB_TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveSubTab(tab.id)}
|
||||
className={`px-4 py-2 text-xs rounded-lg font-medium transition-colors ${
|
||||
activeSubTab === tab.id
|
||||
? 'bg-accent text-bg'
|
||||
: 'bg-card border border-border text-subtext hover:text-text hover:border-accent/50'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 서브탭 내용 */}
|
||||
<ActiveSub />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
185
dashboard/frontend/src/pages/settings/Connections.jsx
Normal file
185
dashboard/frontend/src/pages/settings/Connections.jsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Loader2, CheckCircle2, Circle, RefreshCw, Key, Wifi } from 'lucide-react'
|
||||
|
||||
const CATEGORY_LABELS = {
|
||||
writing: '글쓰기',
|
||||
tts: 'TTS',
|
||||
image: '이미지',
|
||||
video: '영상',
|
||||
multi: '다목적',
|
||||
}
|
||||
|
||||
function ConnectionCard({ conn, onTest, onSaveKey }) {
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState(null)
|
||||
const [showKeyInput, setShowKeyInput] = useState(false)
|
||||
const [keyValue, setKeyValue] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const handleTest = async () => {
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
try {
|
||||
const res = await fetch(`/api/connections/${conn.id}/test`, { method: 'POST' })
|
||||
const data = await res.json()
|
||||
setTestResult(data)
|
||||
} catch (e) {
|
||||
setTestResult({ success: false, message: e.message })
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!keyValue.trim()) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch(`/api/connections/${conn.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ api_key: keyValue }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
setShowKeyInput(false)
|
||||
setKeyValue('')
|
||||
onSaveKey()
|
||||
}
|
||||
} catch (e) {
|
||||
alert('저장 실패: ' + e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{conn.connected ? (
|
||||
<CheckCircle2 size={16} className="text-success flex-shrink-0" />
|
||||
) : (
|
||||
<Circle size={16} className="text-subtext flex-shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-sm text-text">{conn.name}</p>
|
||||
<p className="text-xs text-subtext">{conn.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`tag ${conn.connected ? 'badge-done' : 'badge-waiting'}`}>
|
||||
{conn.connected ? '연결됨' : '미연결'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{conn.key_masked && (
|
||||
<p className="text-xs text-subtext font-mono mb-3">키: {conn.key_masked}</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={testing || !conn.connected}
|
||||
className="flex items-center gap-1 text-xs border border-border px-2 py-1 rounded hover:border-accent/50 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{testing ? <Loader2 size={11} className="animate-spin" /> : <Wifi size={11} />}
|
||||
연결 테스트
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowKeyInput(!showKeyInput)}
|
||||
className="flex items-center gap-1 text-xs border border-border px-2 py-1 rounded hover:border-accent/50 transition-colors"
|
||||
>
|
||||
<Key size={11} />
|
||||
{conn.connected ? 'API 키 변경' : 'API 키 등록'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className={`mt-2 text-xs px-2 py-1.5 rounded ${testResult.success ? 'bg-success/10 text-success' : 'bg-error/10 text-error'}`}>
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showKeyInput && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={keyValue}
|
||||
onChange={e => setKeyValue(e.target.value)}
|
||||
placeholder="API 키 입력..."
|
||||
className="flex-1 bg-bg border border-border rounded px-2 py-1.5 text-xs focus:outline-none focus:border-accent"
|
||||
onKeyDown={e => e.key === 'Enter' && handleSave()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="text-xs bg-accent text-bg px-3 py-1.5 rounded font-semibold hover:opacity-80 disabled:opacity-50 transition-opacity"
|
||||
>
|
||||
{saving ? <Loader2 size={11} className="animate-spin" /> : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Connections() {
|
||||
const [connections, setConnections] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchConnections = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/connections')
|
||||
const data = await res.json()
|
||||
setConnections(data.connections || [])
|
||||
} catch (e) {
|
||||
console.error('Connections 로드 실패:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchConnections() }, [])
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex justify-center py-8"><Loader2 className="animate-spin text-accent" size={24} /></div>
|
||||
}
|
||||
|
||||
// 카테고리별 그룹
|
||||
const grouped = {}
|
||||
connections.forEach(c => {
|
||||
const cat = c.category || 'other'
|
||||
if (!grouped[cat]) grouped[cat] = []
|
||||
grouped[cat].push(c)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-accent">AI 서비스 연결 상태</h3>
|
||||
<button onClick={fetchConnections} className="text-xs text-subtext hover:text-accent flex items-center gap-1">
|
||||
<RefreshCw size={12} />
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{Object.entries(grouped).map(([cat, conns]) => (
|
||||
<div key={cat}>
|
||||
<h4 className="text-xs text-subtext mb-2 uppercase tracking-wide">
|
||||
{CATEGORY_LABELS[cat] || cat}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{conns.map(conn => (
|
||||
<ConnectionCard
|
||||
key={conn.id}
|
||||
conn={conn}
|
||||
onTest={() => {}}
|
||||
onSaveKey={fetchConnections}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
133
dashboard/frontend/src/pages/settings/CostMonitor.jsx
Normal file
133
dashboard/frontend/src/pages/settings/CostMonitor.jsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Loader2, RefreshCw, AlertTriangle, DollarSign, Cpu } from 'lucide-react'
|
||||
|
||||
export default function CostMonitor() {
|
||||
const [subscriptions, setSubscriptions] = useState([])
|
||||
const [usage, setUsage] = useState([])
|
||||
const [totalMonthly, setTotalMonthly] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [sRes, uRes] = await Promise.all([
|
||||
fetch('/api/cost/subscriptions'),
|
||||
fetch('/api/cost/usage'),
|
||||
])
|
||||
const sData = await sRes.json()
|
||||
const uData = await uRes.json()
|
||||
setSubscriptions(sData.subscriptions || [])
|
||||
setTotalMonthly(sData.total_monthly_usd || 0)
|
||||
setUsage(uData.usage || [])
|
||||
} catch (e) {
|
||||
console.error('Cost 로드 실패:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchData() }, [])
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex justify-center py-8"><Loader2 className="animate-spin text-accent" size={24} /></div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-accent flex items-center gap-2">
|
||||
<DollarSign size={14} />
|
||||
비용 모니터링
|
||||
</h3>
|
||||
<button onClick={fetchData} className="text-xs text-subtext hover:text-accent flex items-center gap-1">
|
||||
<RefreshCw size={12} />
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 월간 비용 요약 */}
|
||||
<div className="card p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-subtext">예상 월간 고정 비용</p>
|
||||
<p className="text-2xl font-bold text-accent">${totalMonthly.toFixed(2)}</p>
|
||||
</div>
|
||||
<DollarSign size={32} className="text-accent/30" />
|
||||
</div>
|
||||
|
||||
{/* 구독 테이블 */}
|
||||
<div className="card p-4">
|
||||
<h4 className="text-sm font-medium text-text mb-3">구독 현황</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-subtext border-b border-border">
|
||||
<th className="text-left py-2">서비스</th>
|
||||
<th className="text-left py-2">제공사</th>
|
||||
<th className="text-center py-2">상태</th>
|
||||
<th className="text-right py-2">월 비용</th>
|
||||
<th className="text-right py-2">갱신 D-Day</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{subscriptions.map(sub => (
|
||||
<tr key={sub.id} className="border-b border-border/50 hover:bg-card-hover">
|
||||
<td className="py-2 font-medium">{sub.name}</td>
|
||||
<td className="py-2 text-subtext">{sub.provider}</td>
|
||||
<td className="py-2 text-center">
|
||||
<span className={`tag ${sub.active ? 'badge-done' : 'badge-waiting'}`}>
|
||||
{sub.active ? '활성' : '비활성'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
{sub.monthly_cost_usd > 0 ? `$${sub.monthly_cost_usd.toFixed(2)}` : '종량제'}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
{sub.days_until_renewal != null ? (
|
||||
<span className={sub.alert ? 'text-error font-semibold' : 'text-subtext'}>
|
||||
{sub.alert && <AlertTriangle size={10} className="inline mr-0.5" />}
|
||||
D-{sub.days_until_renewal}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-subtext">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API 사용량 */}
|
||||
<div className="card p-4">
|
||||
<h4 className="text-sm font-medium text-text mb-3 flex items-center gap-2">
|
||||
<Cpu size={13} />
|
||||
API 사용량 (로그 기반 추정)
|
||||
</h4>
|
||||
{usage.length === 0 ? (
|
||||
<p className="text-subtext text-sm">사용량 데이터 없음 (로그에서 파싱)</p>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-subtext border-b border-border">
|
||||
<th className="text-left py-2">제공사</th>
|
||||
<th className="text-right py-2">토큰 수</th>
|
||||
<th className="text-right py-2">예상 비용</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{usage.map((u, idx) => (
|
||||
<tr key={idx} className="border-b border-border/50">
|
||||
<td className="py-2 font-medium capitalize">{u.provider}</td>
|
||||
<td className="py-2 text-right font-mono">{u.tokens.toLocaleString()}</td>
|
||||
<td className="py-2 text-right text-accent">${u.estimated_cost_usd.toFixed(4)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<p className="text-xs text-subtext mt-2">* 사용량은 로그 파싱 기반 근사치입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
141
dashboard/frontend/src/pages/settings/Distribution.jsx
Normal file
141
dashboard/frontend/src/pages/settings/Distribution.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Loader2, Save, Globe } from 'lucide-react'
|
||||
|
||||
const PLATFORMS = [
|
||||
{ id: 'blogger', label: '블로거 (Blogger)', icon: '📝' },
|
||||
{ id: 'youtube', label: 'YouTube Shorts', icon: '▶️' },
|
||||
{ id: 'instagram', label: 'Instagram Reels', icon: '📸' },
|
||||
{ id: 'x', label: 'X (Twitter)', icon: '🐦' },
|
||||
{ id: 'tiktok', label: 'TikTok', icon: '🎵' },
|
||||
{ id: 'novel', label: '노벨피아', icon: '📖' },
|
||||
]
|
||||
|
||||
export default function Distribution() {
|
||||
const [settings, setSettings] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/settings')
|
||||
.then(r => r.json())
|
||||
.then(d => setSettings(d))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const togglePlatform = (id) => {
|
||||
setSettings(s => ({
|
||||
...s,
|
||||
publishing: {
|
||||
...s.publishing,
|
||||
[id]: {
|
||||
...(s.publishing?.[id] || {}),
|
||||
enabled: !(s.publishing?.[id]?.enabled ?? false),
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const updateSchedule = (key, value) => {
|
||||
setSettings(s => ({
|
||||
...s,
|
||||
schedule: { ...s.schedule, [key]: value },
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data: settings }),
|
||||
})
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
} catch (e) {
|
||||
alert('저장 실패: ' + e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex justify-center py-8"><Loader2 className="animate-spin text-accent" size={24} /></div>
|
||||
}
|
||||
|
||||
const publishing = settings?.publishing || {}
|
||||
const schedule = settings?.schedule || {}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h3 className="text-sm font-semibold text-accent flex items-center gap-2">
|
||||
<Globe size={14} />
|
||||
배포 채널 설정
|
||||
</h3>
|
||||
|
||||
{/* 플랫폼 ON/OFF */}
|
||||
<div className="card p-4">
|
||||
<h4 className="text-sm font-medium text-text mb-3">발행 채널</h4>
|
||||
<div className="space-y-3">
|
||||
{PLATFORMS.map(platform => {
|
||||
const enabled = publishing[platform.id]?.enabled ?? false
|
||||
return (
|
||||
<div key={platform.id} className="flex items-center justify-between py-2 border-b border-border last:border-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{platform.icon}</span>
|
||||
<span className="text-sm text-text">{platform.label}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => togglePlatform(platform.id)}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${
|
||||
enabled ? 'bg-success' : 'bg-border'
|
||||
}`}
|
||||
>
|
||||
<span className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
enabled ? 'translate-x-7' : 'translate-x-1'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 시차 배포 스케줄 */}
|
||||
<div className="card p-4">
|
||||
<h4 className="text-sm font-medium text-text mb-3">발행 시각 설정</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{[
|
||||
{ key: 'collector', label: '수집' },
|
||||
{ key: 'writer', label: '글쓰기' },
|
||||
{ key: 'converter', label: '변환' },
|
||||
{ key: 'publisher', label: '발행' },
|
||||
{ key: 'youtube_uploader', label: 'YouTube 업로드' },
|
||||
{ key: 'analytics', label: '분석' },
|
||||
].map(({ key, label }) => (
|
||||
<div key={key}>
|
||||
<label className="block text-xs text-subtext mb-1">{label}</label>
|
||||
<input
|
||||
type="time"
|
||||
value={schedule[key] || ''}
|
||||
onChange={e => updateSchedule(key, e.target.value)}
|
||||
className="w-full bg-bg border border-border rounded px-2 py-1.5 text-sm text-text focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 bg-accent text-bg text-sm font-semibold px-4 py-2 rounded hover:opacity-80 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
{saved ? '저장됨!' : '설정 저장'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
159
dashboard/frontend/src/pages/settings/Quality.jsx
Normal file
159
dashboard/frontend/src/pages/settings/Quality.jsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Loader2, Save, Shield } from 'lucide-react'
|
||||
|
||||
function Slider({ label, value, min, max, onChange, help }) {
|
||||
const pct = ((value - min) / (max - min)) * 100
|
||||
const color = value >= 80 ? '#3a7d5c' : value >= 60 ? '#c8a84e' : '#bf3a3a'
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-sm text-text">{label}</label>
|
||||
<span className="text-sm font-bold font-mono" style={{ color }}>{value}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={e => onChange(Number(e.target.value))}
|
||||
className="w-full accent-accent"
|
||||
style={{ accentColor: color }}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-subtext">
|
||||
<span>{min}</span>
|
||||
{help && <span>{help}</span>}
|
||||
<span>{max}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Quality() {
|
||||
const [settings, setSettings] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/settings')
|
||||
.then(r => r.json())
|
||||
.then(d => setSettings(d))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const updateQuality = (key, value) => {
|
||||
setSettings(s => ({
|
||||
...s,
|
||||
quality_gates: { ...s.quality_gates, [key]: value },
|
||||
}))
|
||||
}
|
||||
|
||||
const updateSchedule = (key, value) => {
|
||||
setSettings(s => ({
|
||||
...s,
|
||||
schedule: { ...s.schedule, [key]: value },
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data: settings }),
|
||||
})
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
} catch (e) {
|
||||
alert('저장 실패: ' + e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex justify-center py-8"><Loader2 className="animate-spin text-accent" size={24} /></div>
|
||||
}
|
||||
|
||||
const qg = settings?.quality_gates || {}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h3 className="text-sm font-semibold text-accent flex items-center gap-2">
|
||||
<Shield size={14} />
|
||||
품질 기준 설정
|
||||
</h3>
|
||||
|
||||
{/* 품질 점수 슬라이더 */}
|
||||
<div className="card p-4">
|
||||
<h4 className="text-sm font-medium text-text mb-4">품질 게이트 점수</h4>
|
||||
<Slider
|
||||
label="Gate 1 — 수집 최소 점수"
|
||||
value={qg.gate1_research_min_score ?? 60}
|
||||
min={0} max={100}
|
||||
help="리서치 품질"
|
||||
onChange={v => updateQuality('gate1_research_min_score', v)}
|
||||
/>
|
||||
<Slider
|
||||
label="Gate 2 — 글쓰기 최소 점수"
|
||||
value={qg.gate2_writing_min_score ?? 70}
|
||||
min={0} max={100}
|
||||
help="글 품질"
|
||||
onChange={v => updateQuality('gate2_writing_min_score', v)}
|
||||
/>
|
||||
<Slider
|
||||
label="Gate 3 — 자동 승인 점수"
|
||||
value={qg.gate3_auto_approve_score ?? 90}
|
||||
min={0} max={100}
|
||||
help="이 이상이면 자동 승인"
|
||||
onChange={v => updateQuality('gate3_auto_approve_score', v)}
|
||||
/>
|
||||
<Slider
|
||||
label="최소 핵심 포인트 수"
|
||||
value={qg.min_key_points ?? 2}
|
||||
min={1} max={10}
|
||||
onChange={v => updateQuality('min_key_points', v)}
|
||||
/>
|
||||
<Slider
|
||||
label="최소 단어 수"
|
||||
value={qg.min_word_count ?? 300}
|
||||
min={100} max={2000}
|
||||
onChange={v => updateQuality('min_word_count', v)}
|
||||
/>
|
||||
|
||||
{/* 크로스 리뷰 / 안전 검사 */}
|
||||
<div className="mt-4 space-y-3 border-t border-border pt-4">
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<span className="text-sm text-text">Gate 3 검수 필요</span>
|
||||
<div
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${qg.gate3_review_required ? 'bg-success' : 'bg-border'}`}
|
||||
onClick={() => updateQuality('gate3_review_required', !qg.gate3_review_required)}
|
||||
>
|
||||
<span className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${qg.gate3_review_required ? 'translate-x-7' : 'translate-x-1'}`} />
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<span className="text-sm text-text">안전 검사 (Safety Check)</span>
|
||||
<div
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${qg.safety_check ? 'bg-success' : 'bg-border'}`}
|
||||
onClick={() => updateQuality('safety_check', !qg.safety_check)}
|
||||
>
|
||||
<span className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${qg.safety_check ? 'translate-x-7' : 'translate-x-1'}`} />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 bg-accent text-bg text-sm font-semibold px-4 py-2 rounded hover:opacity-80 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
{saved ? '저장됨!' : '설정 저장'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
98
dashboard/frontend/src/pages/settings/ToolSelect.jsx
Normal file
98
dashboard/frontend/src/pages/settings/ToolSelect.jsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Loader2, Save } from 'lucide-react'
|
||||
|
||||
export default function ToolSelect() {
|
||||
const [tools, setTools] = useState({})
|
||||
const [selected, setSelected] = useState({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/tools')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
setTools(d.tools || {})
|
||||
const initial = {}
|
||||
Object.entries(d.tools || {}).forEach(([k, v]) => {
|
||||
initial[k] = v.current
|
||||
})
|
||||
setSelected(initial)
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await fetch('/api/tools', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tools: selected }),
|
||||
})
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
} catch (e) {
|
||||
alert('저장 실패: ' + e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex justify-center py-8"><Loader2 className="animate-spin text-accent" size={24} /></div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h3 className="text-sm font-semibold text-accent">생성 도구 선택</h3>
|
||||
|
||||
{Object.entries(tools).map(([category, data]) => (
|
||||
<div key={category} className="card p-4">
|
||||
<h4 className="text-sm font-medium text-text mb-3">{data.label}</h4>
|
||||
<div className="space-y-2">
|
||||
{data.options.map(opt => (
|
||||
<label
|
||||
key={opt.value}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-colors ${
|
||||
selected[category] === opt.value
|
||||
? 'bg-accent/10 border border-accent/30'
|
||||
: 'border border-transparent hover:bg-card-hover'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center flex-shrink-0 ${
|
||||
selected[category] === opt.value
|
||||
? 'border-accent'
|
||||
: 'border-subtext'
|
||||
}`}>
|
||||
{selected[category] === opt.value && (
|
||||
<div className="w-2 h-2 rounded-full bg-accent" />
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="radio"
|
||||
name={category}
|
||||
value={opt.value}
|
||||
checked={selected[category] === opt.value}
|
||||
onChange={() => setSelected(s => ({ ...s, [category]: opt.value }))}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="text-sm text-text">{opt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 bg-accent text-bg text-sm font-semibold px-4 py-2 rounded hover:opacity-80 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
{saved ? '저장됨!' : '설정 저장'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
90
dashboard/frontend/src/styles/theme.css
Normal file
90
dashboard/frontend/src/styles/theme.css
Normal file
@@ -0,0 +1,90 @@
|
||||
/* CNN 다크 + 골드 테마 */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--bg: #0a0a0d;
|
||||
--card: #111116;
|
||||
--border: #222228;
|
||||
--text: #e0e0d8;
|
||||
--subtext: #888880;
|
||||
--accent: #c8a84e;
|
||||
--success: #3a7d5c;
|
||||
--warning: #c8a84e;
|
||||
--error: #bf3a3a;
|
||||
--info: #4a5abf;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Noto Sans KR', 'Apple SD Gothic Neo', -apple-system, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* 스크롤바 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--subtext);
|
||||
}
|
||||
|
||||
/* 골드 액센트 버튼 */
|
||||
.btn-accent {
|
||||
background-color: var(--accent);
|
||||
color: #0a0a0d;
|
||||
font-weight: 600;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.btn-accent:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* 카드 기본 */
|
||||
.card {
|
||||
background-color: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* 태그 */
|
||||
.tag {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 상태 뱃지 */
|
||||
.badge-done { background: #1a3d2c; color: #4ade80; }
|
||||
.badge-running { background: #1a2a4d; color: #60a5fa; }
|
||||
.badge-waiting { background: #1a1a22; color: #888880; }
|
||||
.badge-error { background: #3d1a1a; color: #f87171; }
|
||||
|
||||
/* 칸반 드래그 영역 */
|
||||
.kanban-col {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
/* 로그 레벨 */
|
||||
.log-error { color: #f87171; }
|
||||
.log-warning { color: #fbbf24; }
|
||||
.log-info { color: #60a5fa; }
|
||||
.log-debug { color: #888880; }
|
||||
33
dashboard/frontend/tailwind.config.js
Normal file
33
dashboard/frontend/tailwind.config.js
Normal file
@@ -0,0 +1,33 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{js,ts,jsx,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
bg: '#0a0a0d',
|
||||
card: '#111116',
|
||||
border: '#222228',
|
||||
text: '#e0e0d8',
|
||||
subtext: '#888880',
|
||||
accent: '#c8a84e',
|
||||
'accent-dim': '#8a7236',
|
||||
success: '#3a7d5c',
|
||||
warning: '#c8a84e',
|
||||
error: '#bf3a3a',
|
||||
info: '#4a5abf',
|
||||
'card-hover': '#18181f',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Pretendard', 'Apple SD Gothic Neo', '-apple-system', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'Consolas', 'monospace'],
|
||||
},
|
||||
borderColor: {
|
||||
DEFAULT: '#222228',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
20
dashboard/frontend/vite.config.js
Normal file
20
dashboard/frontend/vite.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
})
|
||||
52
dashboard/start.bat
Normal file
52
dashboard/start.bat
Normal file
@@ -0,0 +1,52 @@
|
||||
@echo off
|
||||
chcp 65001 > nul
|
||||
title The 4th Path - Control Panel
|
||||
|
||||
echo ================================================
|
||||
echo The 4th Path · Control Panel
|
||||
echo ================================================
|
||||
|
||||
set SCRIPT_DIR=%~dp0
|
||||
set PROJECT_ROOT=%SCRIPT_DIR%..
|
||||
|
||||
:: Python 가상환경 활성화
|
||||
if exist "%PROJECT_ROOT%\venv\Scripts\activate.bat" (
|
||||
echo [*] 가상환경 활성화...
|
||||
call "%PROJECT_ROOT%\venv\Scripts\activate.bat"
|
||||
) else if exist "%PROJECT_ROOT%\.venv\Scripts\activate.bat" (
|
||||
call "%PROJECT_ROOT%\.venv\Scripts\activate.bat"
|
||||
)
|
||||
|
||||
:: 백엔드 의존성 확인
|
||||
echo [*] FastAPI 의존성 확인...
|
||||
pip install fastapi uvicorn python-dotenv --quiet 2>nul
|
||||
|
||||
:: 프론트엔드 의존성 설치
|
||||
if not exist "%SCRIPT_DIR%frontend\node_modules" (
|
||||
echo [*] npm 패키지 설치 중...
|
||||
cd /d "%SCRIPT_DIR%frontend"
|
||||
npm install
|
||||
)
|
||||
|
||||
:: 프론트엔드 빌드
|
||||
echo [*] 프론트엔드 빌드 중...
|
||||
cd /d "%SCRIPT_DIR%frontend"
|
||||
npm run build
|
||||
|
||||
if errorlevel 1 (
|
||||
echo [!] 프론트엔드 빌드 실패!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: 백엔드 서버 시작
|
||||
echo [*] 대시보드 서버 시작...
|
||||
echo.
|
||||
echo 접속 주소: http://localhost:8080
|
||||
echo 종료하려면 이 창을 닫으세요.
|
||||
echo.
|
||||
|
||||
cd /d "%PROJECT_ROOT%"
|
||||
python -m uvicorn dashboard.backend.server:app --host 0.0.0.0 --port 8080
|
||||
|
||||
pause
|
||||
90
dashboard/start.sh
Normal file
90
dashboard/start.sh
Normal file
@@ -0,0 +1,90 @@
|
||||
#!/bin/bash
|
||||
# The 4th Path — Control Panel 시작 스크립트
|
||||
# 백엔드(FastAPI) + 프론트엔드(Vite dev) 동시 실행
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
FRONTEND_DIR="$SCRIPT_DIR/frontend"
|
||||
BACKEND_DIR="$SCRIPT_DIR/backend"
|
||||
|
||||
echo "================================================"
|
||||
echo " The 4th Path · Control Panel"
|
||||
echo " 프로젝트 루트: $PROJECT_ROOT"
|
||||
echo "================================================"
|
||||
|
||||
# Python 가상환경 확인
|
||||
if [ -d "$PROJECT_ROOT/venv" ]; then
|
||||
echo "[*] 가상환경 활성화..."
|
||||
source "$PROJECT_ROOT/venv/bin/activate" 2>/dev/null || source "$PROJECT_ROOT/venv/Scripts/activate" 2>/dev/null
|
||||
elif [ -d "$PROJECT_ROOT/.venv" ]; then
|
||||
source "$PROJECT_ROOT/.venv/bin/activate" 2>/dev/null || source "$PROJECT_ROOT/.venv/Scripts/activate" 2>/dev/null
|
||||
fi
|
||||
|
||||
# 백엔드 의존성 확인
|
||||
echo "[*] 백엔드 의존성 확인..."
|
||||
cd "$PROJECT_ROOT"
|
||||
pip install fastapi uvicorn python-dotenv 2>/dev/null || true
|
||||
|
||||
# 프론트엔드 의존성 확인
|
||||
if [ ! -d "$FRONTEND_DIR/node_modules" ]; then
|
||||
echo "[*] 프론트엔드 의존성 설치 (npm install)..."
|
||||
cd "$FRONTEND_DIR"
|
||||
npm install
|
||||
fi
|
||||
|
||||
# 프론트 빌드 여부 확인
|
||||
if [ ! -d "$FRONTEND_DIR/dist" ]; then
|
||||
echo "[*] 프론트엔드 빌드..."
|
||||
cd "$FRONTEND_DIR"
|
||||
npm run build
|
||||
fi
|
||||
|
||||
# 함수: 백엔드 실행
|
||||
start_backend() {
|
||||
echo "[*] 백엔드 시작 (http://localhost:8080)..."
|
||||
cd "$PROJECT_ROOT"
|
||||
python -m uvicorn dashboard.backend.server:app --host 0.0.0.0 --port 8080 --reload &
|
||||
BACKEND_PID=$!
|
||||
echo " PID: $BACKEND_PID"
|
||||
}
|
||||
|
||||
# 함수: 프론트엔드 개발 서버 실행
|
||||
start_frontend_dev() {
|
||||
echo "[*] 프론트엔드 개발 서버 시작 (http://localhost:5173)..."
|
||||
cd "$FRONTEND_DIR"
|
||||
npm run dev &
|
||||
FRONTEND_PID=$!
|
||||
echo " PID: $FRONTEND_PID"
|
||||
}
|
||||
|
||||
# 실행 모드 선택
|
||||
MODE=${1:-"prod"}
|
||||
|
||||
if [ "$MODE" = "dev" ]; then
|
||||
start_backend
|
||||
start_frontend_dev
|
||||
echo ""
|
||||
echo "개발 모드 실행 중:"
|
||||
echo " 프론트엔드: http://localhost:5173"
|
||||
echo " 백엔드 API: http://localhost:8080"
|
||||
echo ""
|
||||
echo "종료: Ctrl+C"
|
||||
trap "kill $BACKEND_PID $FRONTEND_PID 2>/dev/null; exit" INT TERM
|
||||
wait
|
||||
else
|
||||
# 프로덕션 모드: 프론트 빌드 후 백엔드만 실행
|
||||
echo "[*] 프론트엔드 빌드..."
|
||||
cd "$FRONTEND_DIR"
|
||||
npm run build
|
||||
|
||||
start_backend
|
||||
echo ""
|
||||
echo "프로덕션 모드 실행 중:"
|
||||
echo " 대시보드: http://localhost:8080"
|
||||
echo ""
|
||||
echo "종료: Ctrl+C"
|
||||
trap "kill $BACKEND_PID 2>/dev/null; exit" INT TERM
|
||||
wait $BACKEND_PID
|
||||
fi
|
||||
40
dashboard/start_dev.bat
Normal file
40
dashboard/start_dev.bat
Normal file
@@ -0,0 +1,40 @@
|
||||
@echo off
|
||||
chcp 65001 > nul
|
||||
title The 4th Path - Control Panel (개발 모드)
|
||||
|
||||
echo ================================================
|
||||
echo The 4th Path · Control Panel (개발 모드)
|
||||
echo ================================================
|
||||
|
||||
set SCRIPT_DIR=%~dp0
|
||||
set PROJECT_ROOT=%SCRIPT_DIR%..
|
||||
|
||||
:: Python 가상환경 활성화
|
||||
if exist "%PROJECT_ROOT%\venv\Scripts\activate.bat" (
|
||||
call "%PROJECT_ROOT%\venv\Scripts\activate.bat"
|
||||
) else if exist "%PROJECT_ROOT%\.venv\Scripts\activate.bat" (
|
||||
call "%PROJECT_ROOT%\.venv\Scripts\activate.bat"
|
||||
)
|
||||
|
||||
:: 백엔드 의존성 확인
|
||||
pip install fastapi uvicorn python-dotenv --quiet 2>nul
|
||||
|
||||
:: 프론트엔드 의존성 설치
|
||||
if not exist "%SCRIPT_DIR%frontend\node_modules" (
|
||||
echo [*] npm 패키지 설치 중...
|
||||
cd /d "%SCRIPT_DIR%frontend"
|
||||
npm install
|
||||
)
|
||||
|
||||
echo [*] 백엔드 시작 중...
|
||||
start "FastAPI Backend" cmd /k "cd /d %PROJECT_ROOT% && python -m uvicorn dashboard.backend.server:app --host 0.0.0.0 --port 8080 --reload"
|
||||
|
||||
echo [*] 프론트엔드 개발 서버 시작 중...
|
||||
start "Vite Frontend" cmd /k "cd /d %SCRIPT_DIR%frontend && npm run dev"
|
||||
|
||||
echo.
|
||||
echo 백엔드: http://localhost:8080
|
||||
echo 프론트: http://localhost:5173 (개발 서버)
|
||||
echo.
|
||||
echo 두 창이 열렸습니다. 각 창을 닫으면 서버가 종료됩니다.
|
||||
pause
|
||||
Reference in New Issue
Block a user